Quantcast
Channel: Charsyam's Blog
Viewing all articles
Browse latest Browse all 122

[입 개발] 분산 락에 대해서…

$
0
0

서비스를 개발하다 보면, 결국 Lock 이 필요한 시점이 있습니다. 하나의 Process 레벨에서 사용하 수 있는 Lock 부터, 프로레스 들끼리의 경합을 보장하기 위한 Lock 등 사용할 수 있는 것들이 많습니다.

그런데 서비스 레벨에서 고민하게 되면, 서버가 한 대가 아니라 두 대일 때 부터, 단순한 Lock을 사용할 수 가 없습니다. 다르게 말하면, 여러 대의 서버에서 공유하는 자원에 대해서 Lock 을 보장하기 위해서는 기존 방식은 사용할 수 가 없다라는 것입니다.

결국 Lock 도 여러 대의 서버들이나 분산 자원에서 획득할 수 있어야 하는 거죠. 그런데 이런 경우는 서비스에서 흔히 발생할 수 있습니다. 특정 리소스를 한 번에 한 명만 처리되어야 하는 경우인데, 예를 들면, A라는 고객의 결제가 여러건이 발생할 때, 한 번에 하나씩만 결제 처리를 진행해야 하는 경우입니다.

예를 들어 결제 처리가 동시에 일어나게 된다면 다음과 같은 경우가 발생할 수 있습니다. 예를 들어 초기에 1000원이 있는 계좌에 각각 500원 결제와 800원 결제가 발생한다고 가정하겠습니다.

금액이 1000원 뿐이므로 두 개중 하나는 실패해야 하지만, 순서에 따라서 500원 결제가 실패하든, 800원 결제가 실패하든 정상입니다. 다음 두 케이스가 그렇습니다.

위의 두 케이스는 500원이 남거나 200원이 남는 정상 케이스인데, 문제는 다음과 같은 경우입니다. 결국 1300원이 지불되었지만, 계좌에는 500원이 남아있습니다. 즉 1300원을 쓰고 도리어 200원이 이득이 되는 경우가 되면… 서비스는 문제가 발생합니다.

그래서 이런 문제를 해결하기 위해서 Lock 을 사용해서 각각의 트랜잭션이 각각 처리될 수 있도록 하게 됩니다. (이를 위해서 Lock을 쓰거나, Serialization 을 통해 한번에 하나만 동작하는 것을 보장하거나 할 수 있습니다.)

일단 위의 예를 보면, 그냥 DB Lock 쓰면 안되나요? 라고 물어볼 수 있습니다. 넵 가능합니다. DB Lock 도 가장 쉽게 사용할 수 있는 분산 Lock이라고 볼 수 있습니다. 서비스에서 서버가 여러 대라도 DB는 공유자원으로 대부분 같이 사용하니 실제로 필요한 Row 에 Lock을 건다거나 하는 식으로 DB를 분산 Lock으로 사용이 가능합니다.

그런데 DB는 가장 중요한 공유 자원이기 때문에, 가능하면, 부하를 덜 주는 것이 좋다라고 생각합니다. 이 때 사용하는 것이 Redis 와 같이 좀 더 사용하기 쉽고, 비용 효율적인 공유자원을 분산 Lock으로 사용하는 것입니다. 그래서 여기서는 Redis를 분산 Lock으로 사용하는 사례에 대해서 간단하게 설명해 보려고 합니다.

Redis 를 분산락으로 사용하는 가장 쉬운 방법은 Redis 가 싱글 스레드라는 특성을 이용해서 Lock을 사용하는 방법입니다. 일반적으로 많이 사용하는 방법은 setnx 라는 명령을 사용해서 성공한 경우에 Lock을 획득한다고 가정하는 것입니다.

setnx key:user_1 value

setnx 명령을 이용하면 딱 하나만 해당 Key를 생성할 수 있고, 이미 생성되어 있을 때는 해당 명령이 실패하게 됩니다. 이 특성을 이용해서 분산 Lock으로 이용할 수 있습니다. 그런데 이걸로 Lock으로 쉽게 사용이 가능할까요?

여기서 고민해야 하는 것은 setnx 를 시도했다가 실패하면 어떻게 해야할까요? 비지니스 로직에서 생각한다면, 일반적으로는 Lock 획득을 시도하면, 보통 Lock을 획득해야 다음으로 진행하게 되는데, setnx 는 Key 생성이 실패하면 바로 실패하게 되어버립니다. 그래서 보통 일반적인 형태로 구현하기 위해서는 계속 반복적으로 Lock을 얻기 위한 시도를 해야 합니다.

while:
     ok = try setnx("key:user_1") 
     if ok:
          할일()
           del key
           break

위의 코드를 보면 setnx 에 실패하면 계속 lock을 획득할 때 까지 진행하게 됩니다. 이런 형태를 SpinLock 이라고 부르고 이런형태로 많이 사용하게 됩니다.

그런데 위의 구조로 개발을 한다면 만약에 Lock을 건 프로세서가 장애등으로 해당 Lock을 풀지못하면 어떻게 될까요? 계속 해당 Lock을 획득할 수가 없을 것입니다. 여기서 다시 설정하는 것이 redis의 expire 를 이용해서 key를 특정 시간이 지나면 자동으로 삭제되도록 하는 것입니다.

def lock(key):
    ok = setnx(key)
    expire(key, 5)
     return ok

while:
    ok = lock(key)
     if ok:
         할일()
          del key
          break

Spinlock 형태는 Lock을 획득할 때 까지 계속 시도를 하기 때문에, 자원을 더 많이 사용하게 됩니다. 그래서 보통 짧은 시간안에 Lock을 획득할 수 있는 케이스에 사용하게 됩니다. 즉, 하나의 작업이 길게 하는 곳에서 이렇게 Lock 을 획득하기 어려운 케이스라면 낭비가 있을 수 있습니다.

그런데 위에서 setnx 로 설명을 했지만, 현재는 Redis에 set 명령에 nx, ex 등의 명령을 추가로 줄 수 있어서, 라이브러리만 지원하면, setnx와 expire 를 동시에 처리할 수 있습니다.

setnx로 설명을 했지만, Redis 에서는 실제로 명령을 한번에 실행되게 해주는 방법들이 있습니다. Lua Script 를 이용하거나, multi/exec 명령을 이용해서 위의 Lock을 설정하는 형태로 할 수 있습니다.

예를 들어 많이 사용하는 Redis Library 중에 Redisson 을 보면 일부 기능은 Lua Script 로 구현하고 있습니다.

    @Override
    <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
                            "local mode = redis.call('hget', KEYS[1], 'mode'); " +
                            "if (mode == false) then " +
                                  "redis.call('hset', KEYS[1], 'mode', 'write'); " +
                                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                                  "return nil; " +
                              "end; " +
                              "if (mode == 'write') then " +
                                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                                      "local currentExpire = redis.call('pttl', KEYS[1]); " +
                                      "redis.call('pexpire', KEYS[1], currentExpire + ARGV[1]); " +
                                      "return nil; " +
                                  "end; " +
                                "end;" +
                                "return redis.call('pttl', KEYS[1]);",
                        Arrays.<Object>asList(getRawName()),
                        unit.toMillis(leaseTime), getLockName(threadId));
    }

Redisson 의 경우 Spinlock 형태도 제공하지만, 아래와 같은 Pub/Sub을 이용해서 notify 형태로도 Lock 구현을 제공하고 있습니다.

    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                "local mode = redis.call('hget', KEYS[1], 'mode'); " +
                "if (mode == false) then " +
                    "redis.call('publish', KEYS[2], ARGV[1]); " +
                    "return 1; " +
                "end;" +
                "if (mode == 'write') then " +
                    "local lockExists = redis.call('hexists', KEYS[1], ARGV[3]); " +
                    "if (lockExists == 0) then " +
                        "return nil;" +
                    "else " +
                        "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                        "if (counter > 0) then " +
                            "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                            "return 0; " +
                        "else " +
                            "redis.call('hdel', KEYS[1], ARGV[3]); " +
                            "if (redis.call('hlen', KEYS[1]) == 1) then " +
                                "redis.call('del', KEYS[1]); " +
                                "redis.call('publish', KEYS[2], ARGV[1]); " +
                            "else " +
                                // has unlocked read-locks
                                "redis.call('hset', KEYS[1], 'mode', 'read'); " +
                            "end; " +
                            "return 1; "+
                        "end; " +
                    "end; " +
                "end; "
                + "return nil;",
        Arrays.<Object>asList(getRawName(), getChannelName()),
        LockPubSub.READ_UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
    }

Redis를 이용해서 분산 Lock을 쉽게 구현할 수 있지만, 사실 여기서 더 고민할 부분은 Redis 가 장애가 나면 어떻게 될 것인가에 대한 대비를 해둬야 한다는 것입니다. 특히 분산 Lock의 경우, Lock에 문제가 생기면 전체 시스템이 문제가 발생할 수 있기 때문입니다.

직접 구현하셔야 하는 분들은 이런 차이를 이해하시고 개발하시면 될듯 합니다. 여기서 고민을 해보면, 해당 부분이 꼭 실행되어야 하는게 아니라면, 저기서 한번 시도하고, 바로 다른 일로 넘어갈 수도 있습니다. (예를 들어, 해당 결제 이벤트가 큐 기반이라면, 해당 유저가 처리중이면 다시 그냥 Queue에 넣어버리고 다른 유저를 처리할 수도 있습니다.)


Viewing all articles
Browse latest Browse all 122

Trending Articles