[REDIS] REDIS Transaction과 WATCH로 동시성 문제 해결하기
포스트
취소

[REDIS] REDIS Transaction과 WATCH로 동시성 문제 해결하기

개요

OIDC 프로토콜을 도입할 때, Authorization Code를 동시에 조회해서 여러 사람이 가져가는 일을 방지하고 싶었습니다. 그러다 찾은게 WATCH였는데, WATCH 명령어가 뭐고 왜 사용하는지 정리해 보겠습니다.

Transaction

WATCH를 이해하기 위해서는 우선 트랜잭션에 대해 이해해야 합니다.

트랜잭션은 데이터베이스에서 상태를 변화시킬때 더이상 나눠지지 않는 작은 단위로 쪼개 모든 연산이 모두 성공하거나 모두 실패하는 작업 단위입니다. 그래서 트랜잭션으로 묶인 작업들은 작업 수행 과정에서 하나의 오류라도 발생하면 모두 취소되게 됩니다.

예시로 설명하자면 은행 계좌 출금 후 입금이 있습니다. A계좌에서 B계좌로 1000원을 입금할때, A 계좌에서는 1000원이 빠지고 B 계좌에서는 1000원이 추가되어야 합니다. 이 과정에서 A계좌에서 1000원이 빠졌는데 B계좌에 1000원이 더해지는 과정에서 오류가 발생하면 A계좌는 돈이 없어졌지만 B계좌에는 돈이 들어오지 않는 문제가 발생합니다.
이럴때 사용하는게 트랜잭션으로, 출금과 입금 작업을 트랜잭션으로 묶어 입급-출금이 모두 성공해야만 반영되게 할 수 있습니다.

ACID

트랜잭션은 Atomicity, Consistency, Isolation, Durability 4가지 원칙을 가집니다. 각 특성의 앞글자를 따서 ACID라고 표현합니다.

  • 원자성(Atomicity): 트랜잭션은 더이상 분해가 불가능한 최소 단위이므로 전부 성공하거나 전부 실패해야 한다는 원칙입니다.
  • 일관성(Consistency): 트랜잭션이 성공하고 난 후에도 데이터베이스는 일관적이어야 한다는 원칙입니다. 단어가 직관적이지 않아서 예시로 들어 설명하자면, 계좌는 0원 미만이 될 수 없다는 규칙이 있으면 트랜잭션 후에도 이 규칙이 지켜저야 한다는 뜻 입니다.
  • 격리성(Isolation): 실행중인 트랜잭션의 다른 트랜잭션의 영향을 받지 않아야 한다는 원칙입니다. 이를 위해서 트랜잭션 격리 수준이라는 개념이 도입되었는데, 이 부분은 다른 글에서 자세히 알아보겠습니다.
  • 영속성(Durability): 트랜잭션이 성공하고 나면, 그 결과는 데이터베이스에 영구적으로 보존되어야 한다는 원칙입니다.

Redis에서의 Transaction

Redis는 데이터를 메모리에만 저장하는 In-Memory DB이긴 하지만, Redis도 트랜젝션을 지원합니다. 다만 일반적인 DB에서 제공하는 트랜잭션과 약간의 차이가 있는데, Redis에서는 트랜잭션 도중 오류가 발생해도 Roolback을 하지 않고 정상적인 커맨드는 모두 실행시킵니다. 그래서 무결성이 중요한 데이터는 Redis에 저장하면 안됩니다.

Redis는 왜 Rollback을 지원하지 않는지 궁금해져서 찾아보니 빠른 성능을 위해서 의도적으로 Rollback을 배제했다고 합니다.

Redis Transaction 명령어

Redis에서는 트랜잭션을 위해 MULTI, EXEC, DISCARD 명령어를 지원합니다.

  • MULTI: 트랜잭션을 시작하기 위한 명령어 입니다. 이 명령어 이후 입력되는 명령어는 바로 실행되지 않고 큐에 추가되어 대기합니다.
  • EXEC: 큐에 있는 명령어를 일괄적으로 실행하는 명령어 입니다. EXEC로 명령어 모음이 순차적으로 실행될때는 다른 요청이 중간에 섞이지 않습니다.
  • DISCARD: 큐에 있는 명령어를 일괄적으로 삭제하는 명령어입니다.
1
2
3
4
5
6
7
8
9
>> MULTI
"OK"
>> SET TICKET_A 1
"QUEUED"
>> SET TICKET_B 2
"QUEUED"
>> EXEC
1) "OK"
2) "OK"

실제로 실행해보면 MULTI 이후에 실행되는 명령어는 모두 큐에 담겼다는 QUEUED가 표시되는걸 볼 수 있습니다. 이후 EXEC를 실행하면 큐에 담긴 명령어가 순차적으로 잘 처리되었는지 출력됩니다.

Redis Transaction Lock

Redis는 WATCH 명령어를 이용한 낙관적 락(Optimistic Lock)을 지원합니다. WATCH 명령어로 특정 키를 지정하면, Redis는 해당 키가 EXEC 처리시 변경되었는지 검증하여 변경되었다면 트랜잭션을 실패 처리할 수 있습니다.

트랜잭션 자체가 다른 작업과 격리된 환경을 제공하는데 왜 Lock이 필요한지 의문을 가질 수 있습니다. 그 이유는 트랜잭션이 Write 명령에만 적용되기 때문입니다. 기차 좌석 예약 프로세스를 예시로 설명하겠습니다.

보통 기차 좌석 예약은 좌석이 있는지 확인 - 좌석 정보 업데이트 - 유저 예약 정보 저장 순서로 이루어집니다. 그런데 여기에 같은 좌석을 동시에 예매하려고 시도하면 문제가 발생합니다. A유저가 좌석 예약 시도 후 B 유저가 0.001초 차이로 특정 좌석을 예매하려고 시도한다면 두 유저는 좌석 확인 단계에서 모두 좌석이 비었다는 결과를 받게됩니다. 그래서 문제 없이 두 유저 모두 좌석 정보 업데이트 - 유저 예약 정보 저장 트랜잭션이 수행되고, 두 유저는 같은 좌석은 예매했다고 처리가 됩니다. 그러나 실제로 DB에는 더 늦게 저장한 B 유저의 정보로 덮여쓰였을 것이므로 A 유저는 분면 예약에 성공했지만 예약 정보는 없는 상황이 됩니다.

이런 상황을 방지하기 위해 사용하는게 바로 WATCH 입니다.

WATCH

WATCH 명령어는 앞서 간단히 설명했듯이 특정 키가 다른 사용자에 의해 수정되었는지 검증하는 기능을 제공합니다. WATCH는 특정 키에 낙관적 락을 걸어 트랜잭션 수행시 키의 값이 바뀌었는지 검증 후, 바뀌었다면 WatchError을 발생시키고 트랜잭션을 취소하는 기능을 가집니다.

낙관적 락(Optimistic Lock)

아까부터 계속 낙관적 락이라는 용어가 나와서 정리하고 가겠습니다.

낙관적 락은 특정 데이터에 락을 걸때 충돌이 일어나지 않알것이라는 낙관적인 가정을 하고 락을 거는 방식입니다. 낙관적 락은 락을 걸어도 데이터의 수정을 막지 않습니다. 대신 수정 직전 변경된 적이 있는지 검증 후, 처음과 다르게 변경되었다면 적절한 절차를 수행합니다.
낙관적 락은 요청 자체를 막지 않기 때문에 성능적으로 이득이 있지만, 데이터 충돌시 처리 과정을 따로 작성해야한다는 불편함이 있습니다.

낙관적 락과 반대로 비관적 락(Pessimistic Lock)도 있는데, 이건 Lock을 하면 생각하는 다른 접근을 모두 차단하는 Lock 입니다. 그래서 비관적 락을 사용하면 충돌은 가능성은 원천 차단되지만, 작업이 모두 수행될떄까지 다른 작업이 대기를 해야한다는 문제가 있습니다.

Redis는 싱글 스레드로 동작하기때문에 비관적 락을 도입하면 다른 모든 요청이 처리되지 못한다는 문제가 있습니다. 그래서 Redis는 Lock을 도입할때 낙관적 락을 사용하게 만들었습니다.

WATCH 사용 예시

WATCH를 실제로는 어떤식으로 활용하는지 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
>> WAHTCH TICKET_A
"OK"
>> MULTI
"OK"
>> SET TICKET_A 1
"QUEUED"
>> SET TICKET_B 2
"QUEUED"
>> EXEC
1) "OK"
2) "OK"

WATCH를 트랜잭션 시작 전 낙관적 락을 걸 키를 저장해주면 끝입니다. 위 예시에서는 TICKET_A에 낙관적 락을 걸고, EXEC 시점에 데이터가 변경되었는지 검증 후 트랜잭션을 실행합니다. 만약 TICKET_A의 데이터가 다른 트랜잭션에 의해 수정되었다면 WatchError을 내며 트랜잭션을 실패처리 할것입니다.


Redis에서 트랜잭션을 사용하는 방법과 Lock을 활용하는 방법을 알아봤습니다. 이 외에도 원자적 처리를 위한 LUA Script도 지원하는데, 이부분은 다른 글에서 상세히 알아보겠습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

[OIDC] OIDC(OpenID Connect)와 Oauth2.0

-