REDIS 데이터 모델들

소개

요즘에는 REDIS를 사용하지 않는 곳을 찾아보기가 힘들 겁니다. 세션 데이터, 토큰, 유저 요청, 처리 결과, 메시지 박스 등 다양한 목적으로 사용을 합니다. 이번 포스트에서는 REDIS를 이용한 데이터 모델링에 대한 내용을 다루고 있습니다. 가능하면 개발 현장에서 있음직한 요구들을 중심으로 내용들을 채워볼 생각입니다.

이 문서는 REDIS 설치와 기본적인 사용법등에 대해서는 다루지 않습니다.

메시지 박스의 구현

지금 메신저 서비스를 개발하고 있습니다. 메시지를 보냈는데, 수신 대상이 연결하지 않은 상태일 수도 있는데, 이 경우를 대비하기 위해서 메시지 박스를 만들려고 합니다. 나중에 유저가연결하면 메시지 박스에서 메시지를 읽어 갈 겁니다.

먼저 메시지 박스정책을 설계하기로 했습니다.

  1. 모든 메시지는 중요하다. 즉 메시지를 잃어버리면 안됩니다. REDIS는 RDBMS처럼 지속(Persistent)가능한 데이터를 저장하기 위한 목적의 데이터베이스가 아닙니다. 일정부분 그런 기능을 제공하고 있긴 하지만 RDBMS에 비할 바가 아닙니다. 그러므로 메시지의 원본은 MySQL이나 MongoDB 등에 저장을 해두어야 합니다.
  2. 메시지 박스의 크기는 무한하지 않다. 원본이 있으니 메시지 박스의 크기가 무한할 필요는 없습니다. 가장 최근에 받은 N 개의 메시지만 저장하고 있으면 됩니다. N 개를 초과한 메시지들은 MySQL등에서 읽어오면 됩니다.
  3. 읽은 후에는 메시지 박스에서 삭제 한다.

기능상으로는 "최근 받은 N 개의 메시지를 저장한다"가 핵심이 되겠습니다. 이런 데이터 모델을 Capped List 라고 하는데, REDIS의 LPUSH와 LTRIM을 이용해서 만들기로 했습니다. 데이터 모델은 아래와 같이 묘사 할 수 있습니다.

REDIS 명령으로 테스트를 진행했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> LPUSH mylist 1 2 3 4 5 6 7 8 9
(integer) 9
127.0.0.1:6379> LRANGE mylist 0 -1
1) "9"
2) "8"
3) "7"
4) "6"
5) "5"
6) "4"
7) "3"
8) "2"
9) "1"
127.0.0.1:6379> LTRIM mylist 0 2
OK
127.0.0.1:6379> LRANGE mylist 0 -1
1) "9"
2) "8"
3) "7"

mylist에 1부터 9까지 9개의 integer 데이터를 입력한 후 LTRIM mylist 0 2 명령을 수행했습니다. Left 0 ~ 2 까지의 값을 제외하고 모두 삭제하라는 의미이고 결과적으로 가장 마지막에 입력된 9, 8, 7이 남았습니다.

페이지 혹은 아이템별 방문 카운트

아이템별 방문 카운트 정보는 관리자에게 웹 서비스 최적화를 위한 정보를 제공합니다. 서비스 성격에 따라서 ElasticSearch와 같은 검색엔진 기반의 분석 소프트웨어를 사용하기도 합니다. 하지만 단지 하나의 소프트웨어만을 사용하는 경우는 많지 않습니다. 서비스 환경에 따라서 요구사항이 크게 달라지기 때문입니다. 마법의 은탄환은 없습니다. REDIS는 아이템별 통계 정보를 위한 간단하지만 (서비스 종류에 따라서는) 매우 효율적인 수단을 제공합니다.

REDIS의 INCR을 이용해서 간단하게 key를 카운트 할 수 있습니다.

1
2
> INCR item:item-id
(integer) 1

아이템이 없을 경우 새로 만들어서 카운트를 합니다. 이런 류의 데이터는 시계열(time series)형태로 저장을 해야 쓸만해 집니다. 시계열 데이터를 저장 할 때는 시간 해상도(몇 분 주기로 저장할지)를 결정해야 합니다. 하루 단위로 데이터를 저장한다면, 주, 월, 분기, 년 단위의 정보를 뽑을 수 있겠지만, 출,퇴근,업무시간, 식사시간등 시간단위 정보를 얻을 수는 없을 겁니다. 그래서 시간 단위로 해상도를 높이기로 했습니다.

1
2
3
> INCR item.item-0:2014122511
> INCR item.item-1:2014122511
> INCR item.item-2:2014122512

아이템별 카운트를 좀 쓸만하게 응용해 보겠습니다. 저희 회사는 음악서비스를 운영하고 있습니다. 그래서 유저에게 음악을 추천하는 서비스를 만들기로 했습니다. 우리는 유저는 관심있는 음악 페이지를 방문한다는 가정을 세웠습니다. 그래서 음악 장르에 태깅한 다음, 유저가 방문 할 때마다 카운트를 하기로 했습니다. 어느 정도 데이터가 모이면 이 데이터를 근거로 유저가 좋아할 만한 음악을 추천할 수 있을 겁니다.

1
2
3
> INCR user.category:50.0
> INCR user.category:50.1
> INCR user.category:50.1

50은 유저 아이디, 0과 1은 카테고리 번호 입니다. 유저 수 * 카테고리 개수 만큼의 key가 만들어지니 꽤 많은 메모리를 소비할 수 있을 것이다.

API 호출 제한

OpenAPI를 서비스를 하려면, 일정 시간동안 호출 가능한 최대 API 개수에 제한을 걸어야 할 때가 있다.

유저 ID와 시간을 조합한 key를 만들고, 이 key에 대해서 INCR을 호출하는 것으로 쉽게 구현 할 수 있다. INCR 연산 후 값이 최대 크기를 초과하는지를 비교하면 된다.

1
2
> INCR apicall.map.user01:20141212
"1812"

user01 유저가 map api를 호출한 예다. 현재 1812 번의 호출이 있었다. 간단하지만 주기적으로 0으로 재 설정해야 하는 문제가 있다. 이 문제는 EXPIRE와의 조합으로 해결 할 수 있다. EXPIRE 설정한 key에 set, getset을 명령을 실행하면 EXPIRE 값이 재설정된다. 하지만 INCR 연산에 대해서는 재 설정되지 않으므로 주기적으로 삭제할 수 있다. 아래는 루비코드다. 루비를 사용해본 적이 없더라도 이해하는데 문제 없을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
require 'redis'
class OpenAPIManager
    @redis = nil
    def initialize
        @redis = Redis.new(:host=>"192.168.57.2")
    end
    # OpenAPI 호출 횟수를 초과했는지 검사한다.
    def call? user_id
        key = "apicall.#{user_id}:counter"
        # 키가 없다면 생성하고 
        # expire 시간을 설정한다. 
        if !@redis.exists key
            @redis.incr key
            @redis.expire key, 3600 * 24
        end
        count = @redis.incr key
        # 호출 가능 횟수를 초과하면, false를 반환한다. 
        # 이제 이 유저는 API를 호출할 수 없다. 
        # 남은 TTL 시간이 지나면 Key가 삭제되고, 유저는 
        # 다시 api를 호출할 수 있게 된다.
        if count.to_i > 10000
            return false
        end
        ttl = @redis.ttl(key)
        puts "key TTL : #{ttl}"
        return true
    end
    def call name
        puts "API CALL : #{name}"
    end
end
apimgr = OpenAPIManager.new
if apimgr.call? 2
    apimgr.call "/test"
else
    puts "OpenAPI Call ERROR"
end

잘 작동하지만 API 호출 카운트 정보를 기록으로 남길 수 없기 때문에, 아이템 별 카운트와 함께 사용 해야 한다.

TAG 분류

책 판매 사이트에 Tag 기반 분류 기능을 추가하기로 했다. SADD를 이용 Tag를 만들었다.

1
2
3
4
5
6
7
8
9
10
> SET book:1 "{'title' : 'Diving into Python', 'author': 'Mark Pilgrim'}"
> SET book:2 "{'title' : 'Programing Erlang', 'author': 'Joe Armstrong'}"
> SET book:3 "{'title' : 'Programing in Haskell', 'author': 'Graham Hutton'}"
> SADD tag:python 1
> SADD tag:erlang 2
> SADD tag:haskell 3
> SADD tag:programming 1 2 3
> SADD tag:computing 1 2 3
> SADD tag:distributedcomputing 2
> SADD tag:FP 2 3

python태그를 가지는 컨텐츠는 book:1, programming 태그를 가지는 컨텐츠는 book:1, book:2, boo:3 이런 식으로 태깅했다.

이제 REDIS의 SINTER(교집합), SUNION(합집합), SDIFF(차집합)등의 집합연산을 이용해서 Tag를 분류하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
require 'redis'
redis = Redis.new(:host=>'192.168.56.5')
# erlang와 haskell을 모두 태깅한 문서 == 0
redis.sinter('tag:erlang', 'tag:haskell') do | book |
end
# computing와 programming를 모두 태깅한 문서 == 3
redis.sinter('tag:programming', 'tag:computing').each do | book |
    puts redis.get("book:#{book}")
end
# erlang 혹은 haskell을 태깅한 문서 == 2
redis.sunion('tag:erlang', 'tag:haskell').each do | book |
    puts redis.get("book:#{book}")
end
# programming와 haskell을 태깅하지 않은 문서 == 2
redis.sdiff('tag:programming', 'tag:haskell').each do | book |
    puts redis.get("book:#{book}")
end

Log aggregation

시스템에서 발생하는 로그들을 중앙에 저장하려고 한다. REDIS를 버퍼용도로 사용했다. 애플리케이션들은 REDIS에 수집된 로그들을 읽어서 파일로 저장하거나 처리 한다.

각 노드는 LPUSH로 REDIS에 밀어 넣고, 처리하는 쪽에서는 BRPOP으로 꺼낸다.

1
2
3
4
5
6
require 'redis'
redis = Redis.new(:host=>'192.168.56.5', :port=>6379)
loop do
    item = redis.brpop('logging', 0)
    puts item[1]
end

Pub/Sub 커뮤니케이션

Pub/Sub 커뮤니케이션 용도로 사용 할 수 있다. 유저간 채널 메시지 교환등이 대표적인 곳이다. 메시지 큐를 따로 제공하지 않기 때문에 "지나간 메시지를 읽는 등"의 기능을 구현하려면 별도의 장치를 만들어야 한다. 토픽(topic)구독을 위한 채널을 만들 때, 계속 떠 있으면서 Pub 되는 메시지를 큐에 저장해서 서비스하는 봇을 만드는 식으로 해결 할 수 있을 것이다.

Shopping Cart 관리

쇼핑 카트를 만들려고 합니다. 쇼핑 카트는 보통 임시로 유지하기 때문에 REDIS로 모델링하기 좋은 기능입니다. 소핑 카트의 기능은 대략 아래와 같을 겁니다.

  1. 유저는 쇼핑카트를 만들 수 있다.
  2. 쇼핑카트에는 하나 이상의 상품을 담을 수 있다.
  3. 상품을 뺄 수도 있다.
  4. 로그아웃 하거나 일정 시간이 지나면 삭제한다.

카트를 식별할 ID를 만들어야 하는데, 유저 ID를 기반으로 만들기로 했습니다. 유저가 굳이 두 개 이상의 카트를 가질 필요는 없을 겁니다. RDBMS를 이용한 다면 대략 아래와 같은 카트 관리 테이블이 만들어 질겁니다.

UserID ProductID Qty
1 28 1
1 372 2
2 15 1
2 160 5
2 201 3

UserID를 키로 하고 ProductID가 필드가 되고 Qty를 값으로 할 수 있을 겁니다. REDIS의 HSET이 이 요구사항을 정확히 만족합니다.

위의 테이블을 REDIS에 밀어 넣어보았습니다.

1
2
3
4
5
> HSET cart.user:1  28 1
> HSET cart.user:1  372 2
> HSET cart.user:2  15 1
> HSET cart.user:2  160 5
> HSET cart.user:2  201 3

아래는 루비코드입니다. 역시 이해하는데 전혀 어려움 없을 겁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
require 'redis'
class ShoppingChart
    @redis = nil
    def initialize
        @redis = Redis.new(:host=>'192.168.56.5', :port=>6379)
    end
    def allItem userid
        puts "HGET ALL ITEM ===="
        puts "%10s : %s" % ["Product","Qty"]
        @redis.hgetall('cart.user:1').each  do | field, value |
            puts "%10s : %s" % [field, value]
        end
    end
    def removeItem userid, productId
        @redis.hdel "cart.user:#{userid}", productId
    end
    def addItem userid, productId, qty
        @redis.hset "cart.user:#{userid}", productId, qty
    end
end
cart = ShoppingChart.new
cart.allItem 1
cart.removeItem 1, 28
cart.allItem 1
cart.addItem 1, 1280, 5
cart.addItem 1, 1312, 2
cart.allItem 1

카트의 만료시간은 EXPIRE로 설정하면 되겠습니다.

Atomic GET and Delete

GET과 DELETE를 원자적으로(atomically) 수행하고 싶다면 MULTI-EXEC 명령을 이용하면 됩니다. GET & DELETE 뿐 아니라 트랜잭션 처리가 필요한 여러 영역에 응용 할 수 있습니다.

1
2
3
4
5
6
7
8
9
> SET toto 1
> MULTI
> GET toto
QUEUED
> DEL toto
QUEUED
> EXEC
1) "1"
2) (integer) 1

MULTI를 설정한 후, 명령을 수행하면 QUEUED 상태가 됩니다. 예를 들어 DEL toto를 하면, toto 키가 삭제되지 않고 QUEUED로 상태만 변합니다. 이렇게 명령들을 QUEUED 상태에 둔 다음, EXEC 명령을 실행하는 시점에서 동시에 수행을 합니다.

Simple Social Graph

친구의 친구의 친구의 관계들을 그래프로 표현하면 간단한 소셜 그래프가 될 겁니다. 이 관계는 follows와 followers의 리스트로 정의 할 수 있습니다. 아래와 같은 소셜 그래프를 REDIS로 표현을 하려고 합니다.

유저 "1"을 중심으로 하는 소셜 그래프입니다. 화살표는 팔로잉의 방향입니다. 예컨데 1과 2의 관계에서 2는 1의 팔로워이고, 2는 1을 팔로잉 하고 있습니다. 1과 3은 서로 팔로잉한(맞팔) "친구" 관계입니다. 4와 5는 "알 수도 있는 사람" 정도가 될겁니다. 예제의 그래프를 REDIS로 만들었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 12의 관계
> SADD user.follower:1 2
> SADD user.following:2 1
# 13의 관계
> SADD user.friend:1 3 
> SADD user.friend:3 1 
# 34의 관계
> SADD user.following:3 4
> SADD user.follower:4 3
# 24의 관계
> SADD user.following:2 4
> SADD user.follower:4 2
# 35의 관계
> SADD user.following:3 5
> SADD user.follower:5 3

테스트를 해 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 유저 4의 팔로워 23이 나와야 합니다.
> SMEMBERS user.follower:4
1) "2"
2) "3"
# 유저 1의 친구
> SMEMBERS user.friend:1
1) "3"
# 3을 방문했을 때, 3과 관련된 유저를 추천합니다.
# 4, 5, 1을 추천합니다.
> MULTI
> SMEMBERS user.follower:3
QUEUED
> SMEMBERS user.following:3
QUEUED
> SMEMBERS user.friend:3
> EXEC
1) (empty list or set)
2) 1) "4"
   2) "5"
3) 1) "1"

이 데이터 모델은 그럭저럭 작동하지만 유저간의 친밀도(Weight)를 알 수는 없습니다. 소셜 네트워크 기반의 서비스를 하려면 유저간의 친밀도를 계산 할 수 있어야 합니다. 가중치를 적용한 그래프는 아래와 같이 표현될 겁니다.

유저 1에게 친구를 추천한다고 가정해보겠습니다. 아마 6, 4, 5 중에 한명을 추천해야 할 겁니다. ZADD를 이용해서 구현하기로 했습니다. ZADD는 "정렬을 취한 추가적인 값"을 설정  할 수 있는데, 이 값을 가중치로 이용하면 간단하게 구현 할 수 있습니다. ZADD를 이용 위의 그래프를 다시 만들어 봤습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 12의 관계
> ZADD user.follower:1 2 2
> ZADD user.following:2 2 1
# 13의 관계
> ZADD user.friend:1 6 3 
> ZADD user.friend:3 6 1 
# 34의 관계
> ZADD user.following:3 2 4
> ZADD user.follower:4 2 3
# 24의 관계
> ZADD user.following:2 1 4
> ZADD user.follower:4 1 2
# 35의 관계
> ZADD user.following:3 4 5
> ZADD user.follower:5 4 3

아래는 테스트에 사용한 루비 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
require 'redis'
class Friend
    @id = nil
    @redis = nil
    def initialize id
        @redis = Redis.new(:host=>"192.168.56.5")
        @id = id
    end
    def follower
        @redis.zrange "user.follower:#{@id}", 0, -1, :with_scores => true
    end
    def following
        @redis.zrange "user.following:#{@id}", 0, -1, :with_scores => true
    end
    def friend
        @redis.zrange "user.friend:#{@id}", 0, -1, :with_scores => true
    end
end
id = ARGV[0]
my = Friend.new id
my.follower.each do | v |
    puts "#{v[0]} : #{v[1]}"
end
my.following.each do | v |
    puts "#{v[0]} : #{v[1]}"
end
my.friend.each do | v |
    puts "#{v[0]} : #{v[1]}"
end

이 방식은 그래프가 복잡 할 수록, 즉 연결된 노드가 많을 수록 계산해야 하는 양이 크게 늘어날 수 있는 문제가 있습니다. 이해하기 쉽게 "촌"으로 생각해 보겠습니다. 1촌은 바로 옆에 있는 아이템, 2촌은 한 단계 건너 있는 아이템입니다. 만약 1촌이 100명이고, 이 100명이 각각 100명 정도의 아이템과 연결돼 있다면 100 * 100의 아이템을 검사를 해야 할 겁니다. 작은 양이 아니죠.

이런 경우 1촌관계로 연결된 아이템 중, 가중치가 가장 높은 아이템에 대해서만 연산하는 식으로 범위를 한 정 할 수 있을 겁니다.

 Session Storage

많은 웹 애플리케이션 서버들이 session 을 이용해서 상태를 관리합니다. 이들 세션 정보는 데이터베이스를 이용해서 서버들이 서로 공유 할 수 있어야 합니다. REDIS를 이용해서 session 저장소를 만들어 봅시다.

세션 저장소는 아래의 기능을 가지고 있어야 할 겁니다.

  1. Create : 세션의 생성
  2. Destroy : 세션의 삭제
  3. Read : 세션의 검색
  4. Expire : 일정 시간 사용하지 않은 세션의 삭제

세션이름을 Key로 하고 세션 데이터는 string 형태로 저장한다. EXPIRE로 유효시간을 설정하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
require 'redis'
class Session
    @r = nil
    @ttl = nil
    def initialize ttl
        @r = Redis.new(:host=>"192.168.56.5")
        @ttl = ttl
    end
    def create
        r = Random.new
        id = r.rand(0..10000)
        session = "session:#{id}"
        @r.expire session, @ttl
        @r.set session, "{'id':'#{id}'}"
        return session
    end
    def read session_id
        if @r.exists session_id
            @r.expire session_id, @ttl
            return @r.get(session_id)
        end
    end
    def write session_id, data
        if @r.exists session_id
            @r.expire session_id, @ttl
            return @r.set(session_id, data)
        end
    end
end
session = Session.new(3600)
session_id = session.create
session_data = session.read session_id
puts session_data
session.write(session_id, "hello world")
session_data = session.read session_id
puts session_data

JWT를 기반의 인증/권한 시스템에도 응용 할 수 있습니다. 다만 JWT의 경우 한 유저가 하나 이상의 토큰을 가질 수 있기 때문에 모델이 조금 더 복잡해 질 수 있습니다.

  1. 토큰을 관리 하기 위해서 전체 key 중에서 유저의 토큰을 가져와야 한다. 패턴( 예. yundream:token1, yundream:token2....)으로 가져와야 하는데, REDIS는 key를 btree로 관리하는 대신 여러 개의 버킷에 key를 관리하기 때문에 패턴으로 꺼내는게 매우 비효율적입니다.
  2. 1 문제를 해결 하기 위해서 리스트 타입의 key를 써야 하는데, EXPIRE가 Key 단위로만 설정 할 수 있기 때문에 토큰 단위로는 EXPIRE 설정을 할 수 없다는 문제가 있습니다.

2번 방식으로 관리를 해야 할 겁니다. Key 단위로의 EXPIRE 설정은 서비스 레벨에서는 큰 문제는 아니기 때문입니다.

자동완성

영어사전 서비스에 자동완성 기능을 제공하려 합니다. Solr이나 ElasticSearch 등으로도 서비스 할 수 있기는 합니다만 검색엔진에 요청을 보내는 건 효율적이지 않습니다. 그래서 REDIS로 만들기로 했습니다. REDIS는 일종의 캐시로 작동할 겁니다. 자동완성은 트리구조가 될 겁니다. 대략 아래와 같겠죠.

계층적으로 구성된 단어 목록 중 적당한 단어를 우선 추천하기 위해서는 경로에 가중치를 줘야 합니다. 위 그림은 가중치가 설정된 모습을 묘사하고 있습니다. a->ap->app->apple 순으로 추천이 될 겁니다. 가중치는 유저의 질의어를 분석해서 만들면 됩니다. 유저가 "apple"을 검색어로 입력을 했다면 a->ap->app->apple 각각에 +1 씩 가중치를 주는 식입니다. 위 그래프를 REDIS로 표현했습니다. ZADD로 score를 줄 수 있는데, score가 낮을 값이 우선 검색됩니다. 10을 최소 0을 최대 가중치로 가정해서 데이터를 입력했습니다.

1
2
3
4
5
6
7
8
127.0.0.1:6379> ZADD term.next:a 10 ac
127.0.0.1:6379> ZADD term.next:a 5 ap
127.0.0.1:6379> ZADD term.next:a 10 agree
127.0.0.1:6379> ZADD term.next:ap 6 app
127.0.0.1:6379> ZADD term.next:ap 10 apoint
127.0.0.1:6379> ZADD term.next:app 10 apply
127.0.0.1:6379> ZADD term.next:app 7 apple
127.0.0.1:6379> ZADD term.next:app 8 application

유저가 단어창에 "a"를 입력 하면 아래와 같은 결과가 출력됩니다.

1
2
3
4
127.0.0.1:6379> ZRANGEBYSCORE term.next:a -inf +inf
1) "ap"
2) "ac"
3) "agree"

예상대로 ap가 가장 먼저 검색 됐습니다. 유저가 app까지 입력할 경우입니다.

1
2
3
4
127.0.0.1:6379> ZRANGEBYSCORE term.next:app -inf +inf
1) "apple"
2) "application"
3) "apply"

원하는 결과가 나왔습니다. 잘 작동하는 군요. 이제 단어사전과 유저질의어를 분석해서 score만 잘 관리해 주면, 유저 질의어 기반의 자동완성 기능을 구현할 수 있을 겁니다. 한 걸음 더나아가 개인화된 자동완성 기능도 만들 수 있을 겁니다. 데이터양이 많아 질 수 있으니 좀 고민을 해야 겠죠.

자동완성은 결국 "유사도가 높은 아이템의 추천" 입니다. 잘 응용하면 유저 질의러를 기반으로 하는 (거의)실시간 상품추천 시스템의 구성도 가능 할 겁니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.