조만간 OLO 마일리지라는 것이 생길 예정입니다

해당 마일리지(포인트) 처리 개발 과정에서 발생 되었던 서버의 동시성 이슈들과 해결 방법 그리고 그 후의 상에 대해 정리해보고자 합니다

첫 번째 - 포인트 잔액 갱신

포인트 처리 비즈니스에는 적립 / 적립 취소 / 사용 / 사용 취소 / 삭제(탈퇴 처리) 와 같은 경우가 있습니다

여기서 적립의 경우는 SQS 를 이용한 하이브리드한 동기(SQS 전송 후 비즈니스 처리를 적립기에 위임하지만 응답에 unique key 를 담아 보내는 구조) 처리를 하고 있습니다

우아한 형제들의 포인트 처리를 벤치마킹 했어서 비교하자면 적립 취소 까지는 SQS 를 사용하고 있었고 현재 저희 비즈니스는 적립 큐만 사용하고 있어 큐 분기는 없어서 다른 비즈니스는 모두 디비를 통해 처리하고 있습니다 (이 부분은 적립 취소 큐를 따로 쓰거나 적립 큐에서 이벤트를 키로 분기 해도 될 것 같습니다)

어쨌든 포인트 코어 | 포인트 적립기 모두 잔액 갱신이 가능한 분산 환경 상황이었고 동시에 같은 요청이 오게 되면 포인트 잔액이 틀어지는 상황이 생겨났습니다

분산 서버 환경 예시

 

코드로 보자면

val requestPoint = (...)
 
val balance = getRedisBalance(key)
val updateBalance = balance + requestPoint
 
setRedisBalance(key, updateBalance)

적립 Worker 도 N 개 이고 포인트 Service Worker 도 N 개 입니다 이때 동시 요청들이 get 하고 set 할 타이밍에 get 에서 같은 값이 읽히게 되면 처리하지 못하는 적립 요청이 생기게 됩니다

get ~ (사칙연산) ~ set 할 타이밍에 찰나의 유실이 발생됩니다

해결방법

개발자 방에도 문의해봤었고 점심식사 하기 전 엘레베이터 앞에서 충섭 팀장님께 Redis 동시성 처리를 문의 했었는데 그 때 잠깐 Redis 의 Incr 이라는 Command 에 대해 듣게 되었습니다

퍼플아이오 개발자 대화방
출처 :  https://www.slideshare.net/RedisLabs/atomicity-in-redis-thomas-hunter

해당 연산을 Lettuce 라는 Redis Client 에서 지원을 해주어서 증감/감소 연산을 할 때 Atomic 한 동시성 처리가 매우 쉽게 가능해졌습니다

Spring Code 단, Ktor 에서는 약간 다르지만 원리는 같습니다

두 번째 - 포인트 동시 변경 처리

포인트가 변경되는 비즈니스로 아래와 같은 부분이 있습니다

  • 만료 포인트 즉시 만료시키기
  • 가용 포인트 확인해서 사용 시키기
  • 사용 포인트 취소시 취소한 히스토리(이력) 추가하기

이 경우는 첫 번째 경우와 다르게 동시에 처리 하려는 Event(주문, 영수증발급, 포인트 차감/충전) 등 이런 상황이 동시에 처리하려고 하는 경우는 1건에 대해서만 유효한 것으로 인지하고 나머지 경우는 중복되게 처리하지 않기로 했습니다 (비즈니스 적으로 유효한지 안 한지 판단이 필요한 부분)

Mauve 의 포인트 설계는 자유 분방한 Client Event 이기 때문에

[
  { group = 1, eventId = A, member = tom }, { group = 2, eventId = B, member = tom }
]

이런 형태로 되어 있었습니다 만약 아래와 같은 구조였다면 event 의 유일성이 더 보장되었을 것 같은데요

{
  evnetId = A,
  points = [
    { group = 1, member = tom }, { group = 2, member =tom
  ]
}

이런 구조가 아니기 때문에 Array 처리에 대한 모든 형태의 보장이 되야 했고 해당 포인트 들 모두 잘 처리해야 합니다

동시에 여러 포인트 처리가 들어오면 모두 다 처리가 되기 때문에 사용자는 딱 한번만 처리 되길 원하는 상황에 동시에 여러번 들어오면 트랜잭션이 끝나기 전에 기존에 데이터를 읽어서 (Select ~ 이후 Insert ) 주문의 unique 가 보장되지 않고 여러번 처리됩니다

이런 상황에서는 데이터베이스에서 처리와 어플리케이션에서 처리하는 것 에 대해 고려를 해봐야 하는데요

Isolation Level과 Lock 이라는 것이 있어 우리가 데이터를 잘못 읽을 상황을 잡아줍니다

트랜잭션의 격리 수준(isolation)이란?

  • 동시에 여러 트랜잭션이 처리될 때특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정하는 것.

트랜잭션 격리 수준은 어떤게 있을까?

  • READ UNCOMMITTED (가장 낮음)
  • READ COMMITTED (현재 어플리케이션에서 사용하는 상태)
  • REPEATABLE READ
  • SERIALIZABLE (가장 높음)

위 내용이 현재 중요한 부분은 아니기 때문에 간략히 넘어갑니다

가장 높은 경우로 하면 성능 측면에서는 동시 처리성능이 가장 낮기 때문에 성능이 필요한 경우에는 쓰지 않는 것으로 알고 있습니다

Lock이란?

데이터의 일관성을 보장하기 위한 방법입니다

Lock의 종류는?

Lock은 상황에 따라서 크게 두가지로 나누어 집니다

  1. Shared Lock(공유 Lock 또는 Read Lock)

보통 데이터를 읽을 때 사용합니다 원하는 데이터에 lock을 걸었지만 다른 세션에서 읽을 수 있습니다 공유Lock을 설정한 경우 추가로 공유Lock을 설정할 수 있지만, 배타적 Lock은 설정할 수 없습니다.

즉, 내가 보고 있는 데이터는 다른 사용자가 볼 수 있지만, 변경할 수는 없습니다.

2. Exclusive Lock(배타적 Lock 또는 Write lock)

보통 데이터를 변경할 때 사용합니다 이름에서 느껴지는 것 처럼 해당 Lock이 해제되기 전까지는 다른 공유Lock, 배타적Lock을 설정할 수 없습니다.

즉, 읽기 기와 쓰기가 불가능하다는 의미입니다.

Lock 을 사용해서 동시에 동일한 요청이 오는 경우 둘 중에 하나만 읽어서 처리하고 두 번째 요청에 대해서 동일한 현상이 발생되지 않게 처리할 수 있습니다

실제로 만료 포인트가 있는지 DB 에서 Select 를 해서 만료할 수치를 계산해서 Insert 를 이후에 하는데 Select 할 때 Lock 을 걸면 중복되어 소멸될 여지가 없기 때문에 문제가 해결됩니다 (JPA 기준 해당 Query 에 Lock 을 사용)

당장 해결 하는 방식은 간단했으나 창수 팀장님께서 DB에 Lock 을 걸어 버리면 특히나 포인트의 모든 히스토리가 하나의 테이블로 관리되는 상황에서 의도치않은 Row Lock, Table Lock 이 생길 수 있고 성능이 저하 될 수 있어서 Redis 를 통한 분산 트랜잭션 처리에 대한 아이디어를 주셨습니다

마침 첫 번째 해결 방안에 대해 고민했을때 Redis 를 통한 분산락을 잡아 처리하는 방식에 대한 학습이 되어 있기 때문에 주저없이 Redisson 을 통한 분산락을 적용했습니다

관련 영상은 인프런에도 있어서 아마도 비슷할 것 같습니다 (그 만큼 대중적인 해결방법인듯)

해결방법

바로 코드로

Redisson 을 통한 분산락 적용

 

원리는 간단합니다

라이브러리에서 Lock 객체를 주기 때문에 해당 락을 잡고 락 유지 시간동안 executingFunction 에 비즈니스 처리를 넣습니다

해당 작업이 수행되기 전에 동시 요청 되는 LockName Event 에 대해 막습니다

executingFunction commit 이 되고 나면 Lock 을 해소해서 그 순간의 동시 요청을 막습니다

라이브러리가 다 해줘서 제가 한게 뭐지? 싶습니다

아무튼 이걸로 해결이 다 될까요?

 

세 번째 - 클라이언트의 잘못된 동시 반복 요청 자체를 차단

두 번째 과정으로 동시에 처리라는 것은 막을 수 있습니다 (즉 00:00 초 사이 순간적인 동시 처리가 막히는 거죠)

하지만 문제는 처리가 끝나고 UnLock() 이 되는 상황에 발생됩니다

순간 동시 처리는 막히지만 UnLock() 이후에 클라이언트가 잘못된 요청을 다시 주면 동시 처리를 막지 못한 것처럼 처리되어 버린다는 괴상한 논리구조가 생기게 됩니다

처음의 동시에 처리는 막혔지만 두 번, 세 번의 동일 요청이 막히지 못하게 됩니다

사실상 클라이언트가 잘못된 요청을 동시에 많이주면 이 때는 처음 동시만 막고 그 이후는 처리가 되는 상황이됩니다

이 상황에서 창수팀장님이 incr 을 통해 키를 잡고 만료시간을 지정해서 키가 만료되는 동안 해당 요청을 막는 방법을 얘기하셨습니다

헤결방법

바로 코드로

incr 을 이용한 만료시간 적용

마침 incr 은 학습이 된 상태라 Redisson 라이브러리는 처음 쓰는 것이지만 그래도 금새 적용해서 만료시간 이전에 동시 요청이 되는 경우도 막아 버렸습니다

하지만 5초간 동일 LockName 으로 오는 경우까지 다 막혀버립니다

즉 이미 동시처리 문제가 해소된 이후에도 방패를 바로 거두지 못하는 경우가 생기게 되는데 이때 executingFunction 이 처리되고 자동 만료가 된다면 또 잘못된 동시 요청을 허용하게 된다는 것이됩니다

즉 안전성을 위해서는 5초간은 잘못된 요청이 와도 막겠다가 됩니다

이 부분을 좀 더 나이스하게 처리하려면 LockName 이 겹치지 않으면 의도하지 않은 Blocking 도 발생하지 않을 수 있습니다

클라이언트 입장에서는 자체 에러 상황에 에러 복구를 위해 ReTry 시도가 생길 수도 있다는 것이지요

어쨌든 INCR 과 DB+REDIS 간 분산락을 잡음으로 DB 트랜잭션의 유일성이 보장되었습니다

 

마지막으로

글 초반에 Client Event Id의 모호함으로 맘에 들지않는 JSON 구조에 대해 잠깐 얘기도 했었습니다

그 밖에도 코어 서비스의 SaaS 비즈니스들의 확장 가능성에 대한 모호함으로 인해 중요한 값을 필수화 하려고 무리해서 Path Variable 로 올리면서 REST API URI 가 매우 맘에 들지 않는 부분도 캐치가 되었는데요

또 거기에 더해서 초기 설계상 포인트의 키 시스템을 잘 도입했으면 하는 아쉬움도 더해지고 있습니다

전에 빌링 파트는 아니지만 빌링 서비스 처리를 옆에서 보면서 BillKey 에 대해서 알고는 있었어서 포인트와 연결되는 BillKey(PointKey) 가 있는 것에 대해 고민들 해봐야 할것 같습니다 (아래는 단순한 하나의 예시)

개인 사이드 프로젝트서 BillKey 사용

+ Recent posts