기록하며 성장한다 - 개발, 회고

개발 회고록

[ 개발 회고록 ] Redisson Lock 을 활용한, 적립 포인트 → 사용 가능 포인트 전환 기능 구현.

전대홍 2024. 1. 3. 13:00

A : 포인트를 전환하는데, 왜 Lock 을 걸어야 하는거야 ???
B : 그건 동시성 이슈 때문에 그런거야 !

 

서론

현재 필자가 진행하고 있는 배달 토이프로젝트가 있다. ( 프로젝트 링크 << )

배달 서비스에는 정말 여러가지 기능들이 있고, 점주나 손님 혹은 라이더를 위한 여러가지 서비스나 혜택을 제공한다.

그 중에서 배달을 주문하는 손님의 혜택이라고 하면, 당연 할인이나 적립 혜택이 있을 수 있다.

필자는 이 부분을 설계하면서, 음식을 주문하면 고정적인 적립 포인트를 얻고,

그 적립포인트를 매번 조금씩 무분별하게 사용하면 안되니 5,000원 단위

"적립 포인트""사용가능 포인트" 로 전환하는 설계를 하였다.

오늘 개발 한 것은, 바로 그 포인트 전환 부분이다.

 


본론

1. 포인트 전환 로직과 문제점 ( 원인 )

근데 포인트 전환은 어려운 로직이 아닌데, 굳이 이렇게 글로 남길 필요가 있을까 ?

라고 생각한 분들이 계실 것이다. 필자도 처음에는 그렇게 생각했으니, 충분히 그렇게 생각 할 수 있다.

다음은 필자가 작성한 포인트 전환 로직의 일부이다.

Service 로직
실직적으로 포인트를 전환하는 로직

간단하게 설명하자면, 클라이언트로부터 전환하고자 하는 포인트를 입력 받는다.

이후 전환하려는 포인트가 보유 포인트보다 많은지를 확인하고, 

최소 5,000 원부터 전환 가능하며, 5,000원 단위로 전환이 가능하니 이 부분도 체크해두었다.

참고로 0 % 5000 도 0이고, -5000 % 5000 도 0으로 나오니 desiredChangePoints < 5_000 도 함께 걸어두는 것이 좋다.

 

그럼 이제 테스트를 진행해보자.

테스트는 코드는 이렇게 작성해주었다.

필자는 처음부터 문제가 무엇인지 알고 개발을 진행하였기 때문에, 실패를 가정하고 테스트 코드를 만들었음을 감안하고 코드를 읽어주길 바란다.

DB 에 실제로 적립 포인트가 5,000 포인트가 들어있고,

 5,000 포인트를 실제 사용가능한 포인트로 전환하는 작업을 테스트 하는 것이다.

특이한 점이 있다면 스레드 100개로 동시에 이 작업을 수행하는 것이다.

 

왜 그렇게 테스트를 할까?

이 부분은 실제 현업에서 있었던 사례이다.

어떤 유저가 포인트를 전환하는 과정에서 악의적으로 전환 버튼을 동시에 여러번 누를 수 있는 프로그램을 사용하였다.

그래서 전환 가능한 포인트보다 더 많은 포인트를 전환한 사례가 있었다고 한다.

<< 이런 사례가 생길 수 있는 이유 - 스레드의 동시성 문제 >>
- 스프링을 활용한 웹 애플리케이션의 동작 순서를 보면, 스프링 컨테이너에 들어가기 전에 웹 컨테이너를 방문한다.
- 우리가 해당 웹 애플리케이션에서 무언가를 동작하면,
- 거기서 톰캣에 할당된 "스레드 풀"로부터 스레드를 할당받는다.
- 근데 버튼을 동시에 여러번 누르면, 그 스레드 풀로부터 각각 스레드를 할당 받게되고,
- 그게 동시에 해당 레코드에 접근하게 되면서 이런 문제가 발생하게 된다.

 

그럼 실제로 여러 스레드가 들어가면 그런 일이 벌어질까??

과연 테스트 결과는 어떻게 나왔을까 ??

Lock 이 없을 경우의 테스트 결과

놀랍게도 2번 적용이 되었다.

Point 는 0이 되고, Available Point 는 5,000 이 되어야 정상인데

2번 적용이 되어, Point 가 -5,000 이 되었고, Available Point 가 10,000 이 되어버렸다.

이렇게 동시에 2개의 트랜잭션에서 하나의 레코드에 접근하는 경우, 같은 버전의 레코드를 갖게 되는데, 이 때 동시에 데이터를 변경한다면 이러한 문제가 발생할 수 있다.

물론 상황에 따라 마지막 결과만 정상적으로 반영되어도 괜찮은 비즈니스가 있겠지만, 포인트 전환의 경우 순차적으로 트랜잭션을 실행시켜 주어야지만, 결과에 영향이 없는 비즈니스이다.

 

이를 해결하기 위해서는 어떻게 해야할까 ???

 

 

2. Lock 과 기술 선택

Lock 을 활용하면, 트랜잭션이 순차적으로 접근하게 할 수 있다.

그리고 Lock 에는 굉장히 많은 종류가 있다.

Syncronized 를 활용한 방법이 있을 수도 있고,

DB 에서 제공해주는 Lock 을 활용하는 비관적 락, 애플리케이션단에서 JPA 의 @Version 을 활용할 수 있는 낙관적 락, 필자는 MySQL 을 사용하고 있으니 User Lock ( Named Lock ) 을 활용할 수도 있다.

그리고 Redis 를 활용한 Lettuce 스핀 락이나, Redisson 도 있다.

 

필자는 서버가 여러대일 경우를 가정하고 토이 프로젝트를 진행하기 때문에, 서버가 하나일 때만 Lock 이 가능한 Syncronized 는 선택하지 않았다.

또한, 비관적 Lock 은 충돌이 빈번하게 일어나는 로직을 상대로 거는 Lock 이다. 이렇게 어쩌다 한 번씩 악의적으로 일어날 수 있는 로직을 방어하는데에는 적합하지 않아서 선택하지 않았다.

낙관적 Lock 은 충돌을 감지하여 하나의 작업만 성공시키게 할 수 있으므로 이러한 경우에 사용하기 좋다고 느꼈다. 실제로 라이더가 주문을 잡는경우를 가정하면 낙관적 Lock 이 좋다고도 생각을 하고 있었기 때문에, 비슷한 포인트 전환이라면 낙관적 Lock 을 써도 될 거 같다고 판단하였다. 그러나 다른 Lock 들이 많기에 잠시 보류하였다.

User Lock 은 위의 모든 상황에 대처할 수 있는 좋은 Lock 방식이라는 생각이 들었다. 그러나 DB Connection Pool 을 분리해야하며, 어떤 작업이 오래걸릴 경우 그만큼 잠금을 소유하게 되는데, 그로 인해 다른 작업들이 타임아웃으로 실패할 수 있으며, 실질적인 저장소인 DB에 부담을 줄 수 있는 문제가 있을 수 있으므로 보류하였다.

마지막으로 Redisson 과 Lettuce를 알아보았다. 둘 다 Reids 를 활용하는 잠금 방식이다. Lettuce는 스핀락 방식이라 캐시로도 활용하고있는 레디스에 부하를 줄 있다고 판단하였다. 그리고 데드락 상태가 생길 수도 있으니 이를 해결할 수 있는 로직도 따로 만들어주어야 하기 때문에 Lettuce 는 제외하였다.

Redisson 은 Reids 와 라이브러리를 사용해야 한다는 단점이 있지만, 필자가 보류하고 고민하였던 User Lock 의 단점을 충분히 커버할 수 있는 좋은 Lock 방식이라고 판단하였다. 무엇보다 Redisson 은 잠금을 획득했는데 애플리케이션 서버가 죽는다거나 하는 Lock 이 해제되지 않는 상태여도 만료 시간이 지나면 Lock 을 해제하기 때문에, 그런 잠금 상태 관리에 대한 추가 로직을 직접 작성할 필요가 없다는 장점이 컸다. 즉, Retry 같은 별도 구현이 불필요하다는 것이다.

그래서 결론적으로는 Redisson Lock 을 선택하게 되었다.

 

해당 내용에 대해서는 필자가 TIL 블로그에 정리한 글이 있으니 참고하면 좋다. ( TIL 블로그 포스팅 링크 <<< )

 

 

3. 해결

먼저 Redisson 에 대한 implementaion 을 추가해주어야 한다.

gradle 에 아래와 같은 implementation 을 추가해주었다.

implementation 'org.redisson:redisson-spring-boot-starter:3.23.2' // Redisson

 

그 다음은 사용할 서비스 로직을 제어할 Facade 를 만들었다.

코드는 아래와 같이 구현하였다.

 

마지막으로 서비스에 직접적으로 연결하던 Controller 를,

Facade 를 경유할 수 있도록 로직을 수정해주었다.

 

준비는 끝났으니,

이제 테스트만 남았다.

테스트 코드는 일전에 시도하였던 테스트 코드에서, 다이렉트로 Service 로 연결하였던 걸 Facade 를 거치게끔만 수정하였다.

 

 

결과는 ??

 

매우 성공적이었다 !

 

Lock 의 필요성과 함께, 여러가지 중요한 사실들을 직접 경험하며 알 수 있었다.

단순한 공부 뿐 아니라, 이렇게 직접 구현하며 경험할 수 있다는게 너무 좋았고,

머리에도 더 많이 남게 되는 거 같다 ^^