소소하게 진행 중인 프로젝트에서 예상과 다르게 동작하는 부분이 있었는데요, 원인을 파악하고 문제를 해결해 가는 과정에서 새롭게 알게 된 내용들이 있어서 또 짧은 글 남기게 되었습니다.
# 00. 배경
단일 프로세스 멀티 스레드 환경에서 동시에 요청을 보내 레이스 컨디션을 유발시키고 자바 수준에서 처리할 수 있는 여러 가지 동기화 방법들(Synchronized, ReentrantLock, StampedLock 등)을 적용해 보는 시간을 가지고 있었습니다.
사실 이렇게 자바 수준에서 적용하는 Lock들은 결국 단일 프로세스 내에서의 동기화만 보장하기 때문에 애플리케이션이 수평 확장된 분산 환경에서는 동작이 제한적이라는 것을 이미 알고 있는 상태로 시작했습니다.
때문에 저는 JUnit 테스트를 통해 어떠한 락도 적용되지 않은 로직에서 레이스 컨디션이 발생하는 것을 확인하고, 자바 동기화를 적용해서 극복하고, 각 전략별로 지표를 분석해서 어떤 상황에서 어떤 락을 적용하는 것이 좋은지 고민해 보고, 분산 환경 테스트를 통해 한계를 눈으로 확인하고 이후 자연스럽게 DB 락으로 실험을 넘어가는 흐름으로 설계를 했습니다.
# 01. 실험 과정 및 문제 직면
https://github.com/LeeEuyJoon/concurrency-srategies-demo

JUnit 테스트는 위와 같이 두 개의 상황을 가정해서 실험을 진행했습니다.
글에서는 첫 번째 상황을 기준으로 얘기를 이어가겠습니다.
어떠한 Lock도 적용하지 않은 로직에서는 의도대로 두 스레드가 동시에 DB에서 잔고 10만원을 읽고 각각 잔액을 5만 원으로 업데이트하여 최종적으로 잔액이 0원이 아닌 5만 원이 남게 되어 문제가 발생했습니다.

이제 인출하는 로직을 synchronized 키워드를 이용하여 임계 영역 내에 두어 단 하나의 스레드만이 접근할수 있도록 동기화를 시도했습니다.
기존과 같은 상황에 대해 JUnit 테스트를 진행했고, 당연히 테스트를 통과할 것을 예상했지만 ....

여전히 레이스 컨디션이 발생하고 있었습니다...
# 02. @Transactional은 프록시 기반 AOP
원인은 서비스 코드에서 withdraw(...) 함수에 붙은 @Transactional 애너테이션에 있었습니다.
@Transactional은 해당 애너테이션이 붙은 메서드에 대해 원자성을 보장하기 위해 DB 작업을 할 때 트랜잭션 시작, 커밋/롤백을 스프링이 대신 수행해주도록 합니다.
이때 트랜잭션 시작, 커밋/롤백과 같은 경계를 자동으로 삽입하기 위해 @Transactional 애너테이션이 붙은 클래스에 대해 프록시를 씌워 호출을 프록시가 가로채도록 합니다.
테스트 코드에 getClass() 메서드를 통해 실제 런타임 때 사용되는 클래스를 확인해 보면 다음과 같습니다.

제가 synchronized 키워드를 이용해 동기화를 적용한 클래스의 이름은 SynchronizedStrategy이지만 실제 반환하고 있는 클래스를 보면 뒤에 &&SpringCGLIB&&0이 붙어있는 것을 확인할 수 있습니다.
CGLIB는 바이트 기반 프록시 라이브러리인데, 이는 스프링에서 @Transactional이 붙으면 CGLIB 라이브러리를 사용하여 프록시 객체를 생성하여 요청을 처리하고 있음을 의미합니다.
@Transactional로 인해 프록시 객체가 사용되는 것은 알겠는데... 그래서 이게 레이스 컨디션이 발생하는 것이랑 무슨 상관이 있는 걸까요?
핵심은 synchronized의 임계 영역이 트랜잭션 범위 내에 있다는 것입니다.
요청의 흐름을 정리해 보면서 문제를 이해할 수 있었습니다.

1. 프록시가 Thread A의 withdraw() 진입 시점에 트랜잭션 시작
2. 거의 동시 시점인 Thread B의 withdraw() 진입 시점에 트랜잭션 시작
3. Thread A가 모니터 락 획득
4. Thread A가 DB 조회, balance=10만 확인
5. Thread A가 메모리 객체에서 balance=5만으로 업데이트 (아직 commit은 안 됨)
6. Thread A의 모니터 락 반납
7. 락 대기 중이던 Thread B가 모니터 락 획득
8. Thread B가 DB 조회, 여전히 balance=10만 확인
9. Thread A의 commit 및 트랜잭션 종료 -> db에서 balance=5만으로 업데이트
10. Thread B가 메모리 객체에서 balance=5만으로 업데이트 (조회 시점에 잔고가 10만 원이었으니까)
11. Thread B가 모니터 락 반납
12. Thread B가 commit 및 트랜잭션 종료 -> db에서 balance=5만으로 업데이트
결국 synchronized는 임계 영역만큼만 동기화를 보장해 주고, 임계 구역을 빠져나온 다음 트랜잭션 커밋이 일어나기 때문에 그 사이에 다른 스레드에서 커밋되기 전의 값을 읽어버려서 문제가 발생하는 것이었습니다.
@Transactional이 문제를 발생시키고 있다는 것을 더 확실하게 하기 위해 @Transactional을 적용하지 않은 구현체 코드를 작성하여 동일한 테스트를 진행했습니다.

테스트를 통과하는 것을 확인했고요, @Transactional 애너테이션을 withdraw() 메서드에서 제거함으로써 트랜잭션 범위가 withdraw() 메서드 단위가 아닌 syncrhonized 블럭 내에 있는 jpa 메서드에 의해 select, update 단위로 적용되어 레이스 컨디션이 발생하지 않습니다.
# 03. 동기화를 하면서 원자성도 보장하기
@Transactional이 위와 같은 문제를 일으키고 있다고 해서 @Transactional 애너테이션을 무턱대고 제거하기에는 분명 찜찜한 부분이 있습니다.
지금에야 출금 프로세스가 단순하지만, 실제 운영되고 있는 예외 케이스가 다양한 복잡한 비즈니스 로직에서 @Transactional을 통해 메서드의 원자성을 보호하지 않는다면, 데이터 정합성이 깨질 수 있습니다.
그래서 저는 동기화를 보장하는 syncrhonized 블록 내에서 트랜잭션이 커밋될 수 있도록 코드 리팩토링을 시도했습니다.

우선, 트랜잭션 단위로 db 작업을 하는 메서드를 정의하고 @Transactional 애너테이션을 붙입니다.
이때, @Transactional은 동일 클래스 내의 메서드 호출 시 적용이 안되기 때문에 별도의 TxWorker 클래스를 정의했습니다.

이후 기존 구현체 코드에서 락을 실행하는 메서드가 매개변수로 Supplier 함수형 인터페이스를 받고, withdraw(...) 메서드에서 TxWorker의 인출 메서드를 콜백으로 넘겨줍니다.
결과적으로 아래와 같은 흐름으로 두 스레드가 동작할 것을 기대했습니다.

이렇게 코드를 리팩토링함으로써 executeWithLock(...) 메서드 synchronized 임계 영역 내에서 각 스레드 트랜잭션이 시작하고 커밋되기 때문에 레이스컨디션이 발생하지 않을 것이라 예상하고 테스트 코드를 작성했고요.

통과했습니다.
참고로 syncrhonized에 대해서만 해당되는 내용이 아니고요, 자바의 동기화 방식들 ReentrantLock, ReentrantReadWriteLock, StampedLock에 대해서도 모두 테스트를 시도했는데, 마찬가지로 메서드 단위에 @Transactional을 이용할 시 레이스 컨디션 문제를 극복하지 못하고 임계 영역 내에 트랜잭션을 포함시켜야 테스트를 통과하는 것을 확인했습니다.






# 04. 지표 비교
이번엔 수정한 트랜잭션 구조가 지표에 어떤 영향을 주는지 확인해 보기 위해 JMeter를 이용하여 50개의 스레드에서 10번씩 인출 요청을 시도해 봤습니다.


첫 번째 이미지가 문제가 발생하는 기존 코드에 대한 처리량 관련 지표이고,
두 번째 이미지가 임계영역 내에 트랜잭션을 포함하도록 리팩토링한 로직에 대한 처리량 관련 지표입니다.

해당 이미지는 각 방식에 대한 트랜잭션 점유시간을 계측한 결과입니다.
응답 처리 속도나 초당 처리량과 같은 지표는 문제를 일으키던 기존 방식이 더 빠르다고 볼 수 있었고, 트랜잭션 점유 시간은 새로 작성한 방식이 더 짧음을 확인할 수 있습니다.
같은 조건으로 요청을 반복했을 때 어느 정도의 편차는 있지만, 두 처리량이나 트랜잭션 점유 시간에 대한 순위가 달라지지는 않았습니다.
수정된 방식이 기존 방식에 비해 상대적으로 처리랑 관련 지표가 좋지 못한 이유는 아무래도 락을 먼저 잡고, 그 안에서 트랜잭션을 시작하기에, 기존 요청이 보내지고 응답이 돌아오는 시간 전부 락 대기 시간에 반영되기 때문이라고 추측을 해볼 수 있습니다.
반면, 트랜잭션 점유 시간은 확실히 짧아져 안전성은 더 높다고 할 수 있습니다.
추가적으로 @Transactional을 사용하지 않는 요청에 대해서도 실험을 진행해 보았는데, 결과는 아래와 같습니다.

응답 속도와 단위 처리량이 압도적으로 좋아지는 모습을 볼 수 있습니다.
락 범위를 늘리고 트랜잭션까지 감싸면 확실히 예측 가능하고 여러 예외 케이스에 대해 안전한 구조를 가질 수 있지만, 성능은 확실히 희생된다는 점을 알게 되었고요, 실제 시스템에서 어떤 전략을 선택할지는 "정합성이 우선인가, 성능 우선인가"라는 비즈니스 요구사항에 달려있을 수 있겠다가도... 사실 당연히 정합성이 우선이 아닐까 싶습니다.
물론 이 시간 이후로 db와 분산락을 이용한 다른 전략들도 실험할 예정에 있는데 db나 분산락을 이용하는 방식이 안전성이나 성능에 있어서 더 효율적이지 않을까 예상이 되긴 합니다.
분명 미니 프로젝트 느낌으로 시작해서 금방 끝내고 새로운거 시작하고 반복하면서 평소에 관심이 있었던 내용들 빠르게 축적해가고자 했는데 ...
생각보다 시간을 많이 쓰고 있습니다.
벌려놓은 것도 많고, 게으르기도 하고 ...
'Spring' 카테고리의 다른 글
| 쿼리 지옥 JPA의 N+1 문제를 둘러싼 오해 (0) | 2025.08.26 |
|---|---|
| 컨트롤러 요청/응답 가로채기, 스프링 인터셉터(Interceptor) (0) | 2025.05.13 |