현재 진행 중인 프로젝트는 시간 대여 서비스로, 누군가가 주최한 모임의 정해진 시간에 참여를 신청하고 예약/결제하는 그런 로직을 가지고 있다.
근데 처음 어플리케이션을 설계하는 단계에서 이 예약 신청 단계에서 생길 수 있는 동시성 문제를 어떻게 할 것인지에 대해 똑바로 생각해놓지 않았고.. 그 결과는 ^^~.. 후.. 정말 부끄러운 일이다
멀티 쓰레드로 동작하는 스프링 웹 어플리케이션에서 동시성 문제는 아주아주 중요한 기본 중의 기본인데, 이런 기본을 똑바로 생각하지 않고 프로젝트를 시작한 나를 반성하며, 지난 며칠간 다시 처음으로 돌아가 동시성 문제를 해결하는 여러 방안들을 복습하는 시간을 가졌다.
기본적으로 동시성 문제의 해결은 어플리케이션에서 해결하기(주로 단일 서버), DB에서 해결하기(분산환경), 혹은 이 외부 시스템을 활용하기(Redis, Kafaka)(대규모 분산환경)까지 여러가지 방법이 있다.
오늘은 그 중 DB락(Optimistic Lock, Pesimistic Lock, User level lock)으로 해결하는 방법을 다뤄보려고 한다.
알고리즘은 언제 풀지..?
이런 말도 안되게 단촐한 엔티티가 존재한다고 가정해보자.
그냥 reservationId로 reservation 엔티티 불러온 뒤, 인원만 추가해주면 끝이다.
이제 즐거운 테스트 코드를 작성할 시간이다.
테.스.트.조.아
Reservation 엔티티의 경우 매번 일일히 저장하기 귀찮기 때문에 BeforEach로 저장해줄 것이며, 모임의 인원 제한은 50명으로 고정이다.
TrasnactionTemplate은 추후 동시성 테스트에 필요해서 미리 만들어놓은 것이니 신경 쓰지 말자.
우선 로직이 정상적으로 동작하는지 먼저 확인해보았다.
누가 봐도 성공할 것이 확실한 테스트이기 때문에 굳이 결과를 올리진 않겠다.
아무튼 이렇게 순차적으로 요청한다면 결과에는 아무런 문제가 없다.
하지만 병렬 요청에는 어떨까?
50명의 유저가 동시에 요청했다고 가정해보자.
50명이 요청했는데 reservation에 기록된 참여 유저수는 15명이다.
로직은 아주 많이 다르지만 내 프로젝트에서는 테스트 결과 정원이 1명인 예약을 10명이 동시에 신청하면 10명이 전부 신청에 성공하고 있었다 ^^..
이유가 뭘까요?
우리가 원하는 그림은 바로 아래와 같다.
모든 쓰레드가 서로 알아서 일을 처리하는 것이다.
하지만 현실은 밑의 그림이다.
쓰레드는 스택 영역을 제외한 모든 메모리 영역을 공유하기 때문에 이런 동시성 문제가 발생한다.
N번 쓰레드가 현재 유저수를 연산하고 있는 동안 임계구역에 같이 들어간 쓰레드들은 그 전의 정보를 바탕으로 연산하고 결과를 커밋한다.
결국 이게 반복되면서 실제로 참여한 유저수와 현재 유저수에 말도 안되는 격차가 생기는 것이다.
이런 상태를 경쟁조건(Race Condition)이라고 하는데, 아마 이쯤 읽으면서 이런 생각을 할 수도 있을 것이다.
"저거 synchronized 붙이면 되는 거 아님? 모하러 DB락을 걸고 있음? ㅋㅋ 바보"
synchronized는 분산 환경에서는 의미가 없기도 하고, 성능 문제 때문에라도 잘 사용하지는 않지만 그래도 synchronized 를 한 번 붙여보자.
띠용.. 분명히 Synchronized는 상호 배제의 역할을 수행하기 때문에 동시성 문제를 해결할 수 있을 거라고 생각했는데, 조금 완화됐을 뿐 여전히 결과가 다르게 나온다.
이유가 뭘까?
그림이 조금 이상한데, 트랜잭션이 저렇게 하나씩 번갈아가면서 시작되는 거 아니고 메서드랑 같이 시작되어야 한다.
대충 그렸으니까 양해 바랍니다.
아무튼 Transaction은 메서드가 종료될 때 끝나면서 commit을 날리는데, 이 때는 메서드가 이미 종료된 상태이므로 Synchronized가 적용되지 않는다.
그러면 이 때 임계구역에 입장한 쓰레드들은 여전히 수정되기 이전의 데이터를 보고 있다.
못 믿겠다면 Transaction을 떼고 확인해보자.
트랜잭션이 없다면 synchronized만으로 단일 프로세스에서는 동시성 문제가 해결된다.
근데 트랜잭션이 없으면 안되겠죠.
방법이 없지는 않은데, 어차피 synchronized로 동시성 문제 해결하는 걸 알아보기 위함이 아니였으니까 본론으로 넘어가자.
JPA에서 제공하는 DB락은 낙관적 락(Optimistic Lock)과 비관적 락(Pesimistic Lock)이 있다.
짧게 설명하면 낙관적 락은 충돌이 일어날 일이 많지 않을 것이라고 가정하고 일단 모두 디비에 접근하도록 허용한 뒤, 이후에 이를 처리하는 것이며 비관적 락은 충돌이 일어날 일이 잦을 것이라고 가정하고 이미 쓰기(혹은 읽기까지)가 진행 중이라면 아예 접근 자체를 차단하는 방식이다.
이는 DB의 Isolation Level(read uncommitted, read committed, repeatable read, serializable)하고는 전혀 별개다.
DB 격리 단계는 읽기의 정합성을 지키기 위함이고, 락은 쓰기 단계에서의 정합성을 지키는 것이 주 목적이다.
또한, 락의 경우 무조건 비관적 락이라고 성능이 나쁘고 낙관적 락이라고 성능이 좋지도 않다.
트랜잭션간의 충돌이 비일비재하게 일어날 경우 애초에 접근 자체를 차단하는 것이 커넥션 자원을 아끼는 방법이 될 수 있다.
말이 길었다.
일단 비관적 락부터 테스트해보자.
사용법은 baeldung에 아주 친절하게 잘 설명되어 있다.
https://www.baeldung.com/jpa-pessimistic-locking
위에 @Lock 어노테이션으로 LockModeType만 붙여주면 된다.
우리는 쓰기 정합성만 맞춰주면 되는 상태이니 PESSIMISTIC_WRITE를 사용할 것이다.
옵션이 다양하고, 소스코드 주석에도 설명이 자세하게 써있으니 읽어보면 좋다.
바로 성공.
배타적 락은 이런 원리로 동작한다.
위 그림은 분산 환경을 예시로 들고 있는데, 싱글 프로세스일 때도 다를 건 없다.
다만 어플리케이션단에서 해결할 수 있는 더 좋은 방법들이 있을 뿐.
이 다음은 Optimistic Lock인데, 이 친구는 준비물이 하나 필요하다.
바로 버전이다.
낙관적 락은 위의 그림에 나온 순서대로 작동한다.
"우리가 남이가~"하면서 너나할 것 없이 다 같이 일단 데이터 들고 가서 작성한 다음에, 업데이트할 때 버전 정보가 달라졌다면 롤백하거나 다시 시도하는 방식으로 동시성 문제를 해결한다.
그 말은 즉, 위의 코드만 가지고 시도하면 테스트에 실패한다.
왜? 버전이 안 맞으면 롤백할 뿐 재시도하는 건 구현하는 쪽이 몫이다.
물론 테스트에 실패했다고 해도 처음 동시성 문제가 있을 때의 실패와는 결이 다르다, 이쪽에서는 실제로 25명의 유저만 예약에 성공한 것이니 말이다.
이번에는 기존 ReservationService 내부에서 처리할 수 없고 외부에서 처리해줘야 한다.
이유는 Synchronized 때와 같다. Transaction 때문이다.
이미 예전 버전을 가지고 있는 Transaction을 아무리 catch해서 재운 다음에 재실행시켜봤자 계속해서 예전 버전을 보고 있으니 실패만 무한하게 반복할 뿐이다.
이제 다시 원하는 결과가 나온 것을 확인할 수 있다.
힘들어서 그만 쓰고 싶지만 아직 하나 남았다.
마지막으로 우아한 형제들 기술 블로그에서도 볼 수 있는 (https://techblog.woowahan.com/2631/) User level lock이다.
이는 특정 문자열로 잠금을 하는 방법으로 사용된다.
위 블로그에서도 언급하고 있지만, 이 방법의 경우 이런 식으로 구현하면 Lock을 가져오는 쓰레드와 트랜잭션을 유지하는 쓰레드까지 총 두 개를 사용하게 되기 때문에 이런식으로 구현하면 좋지 않다.
코드는 기존과 별다른 차이가 없다.
어찌 보면 Pessimistic Lock과 별 차이가 없다고 느낄 수도 있지만, 특정 문자열로 잠금을 할 수 있다는 것은 보다 유연성을 가져온다.
솔직히 말해서 나는 아직까지 파티셔닝 같은 것들을 해본 경험이 없기 때문에 User Level Lock이 실제로 얼마나 더 유연하게 사용할 수 있는지는 모른다.
근데 일단 쓸 줄 알면 나중에 필요할 때 생각나겠지 ㅎㅎ;
후.. 머리로만 알고 있는 것과 한 번 정리하는 건 다른 것 같다.
생각보다 너무 시간이 오래 걸렸지만 그래도 정리한 보람이 있다.
Redis나 Application에서 해결하는 방법은 언제 또 올릴지 모르겠지만.. 아무튼 오늘은 여기서 끝이다.
'Language & Framework > Spring' 카테고리의 다른 글
Spring 동시성 문제 때려잡기 (3) Redis lock (Lettuce, Redisson) (0) | 2023.02.11 |
---|---|
Spring 동시성 문제 때려잡기 (2) Application Lock (synchronized, semaphore, ReentrantLock) (0) | 2023.01.30 |
스프링 시큐리티의 DelegatingFilterProxy, FilterChainProxy, SecurityFilterChain에 대해 알아보자. (0) | 2022.09.20 |
꾸준히 듣게되는 Servlet과 Dispatcher Servlet에 대해 알아보자 (0) | 2022.08.10 |
MVC패턴이란? Spring MVC의 기본 흐름을 알아보자. (0) | 2022.07.29 |