
그냥 유행에 탑승하고 싶어서..

신발을 사고싶었다. 9시 반에 발매가 된다고 해서 시간을 맞춰 들어갔다. 하지만 역시나!!
서버가 터졌다. 주문과 결제까지 완료가 된 상태에서 완료를 누르니 다시 주문 페이지로 돌아가졌고 혹시나 하는 마음에 결제를 한번 더 진행했다.
돈은 2번 다 나갔는데 주문이 되지 않았다... 주문 확인 내역에 아무것도 없었다. 이런 경우 나중에 순서대로 처리가 되고 취소가 된 결제는 환불이 들어오기 때문에 크게 걱정은 하지 않았지만.. 혼나야겠지?
너네 동시성 제어좀 하자!!!
내가 겪은 상황이 동시성 제어 때문은 아니겠지만 동시성 제어에 문제가 발생하면 비슷한 현상이 나타난다.
한정판 발매가 이뤄질 때 그 순간 수많은 요청이 동시에 서버로 들어간다. 이 때 재고가 1개 남았는데 주문이 3개가 들어왔다면?
3명의 주문이 동시에 성공하고 재고는 -2개가 되는 상황이 발생할 수 있다. (나처럼 주문과 결제는 됐지만 사실 물건은 없고 돈만 나가는거야)
어떻게 이런 불미스러운 일이 가능한걸까?
경쟁 상태 (Race Condition)
경쟁 상태가 발생하면 방금과 같은 일이 발생할 수 있다.
경쟁 상태란 여러 스레드나 프로세스가 하나의 자원(공유 자원)에 동시에 접근하여 읽거나 쓰려고 하는 상황이다. 동신에 접근을 시도할 때 접근의 타이밍이나 순서 등이 결과에 영향을 줄 수 있는 상태인 것이다.
일상 생활에서도 겪을 수 있는 문제이고 개발을 하다보면 동시성 제어는 자주 언급되는 문제이다. 이를 해결하고자 하는 상황도 많고 중요한 내용이니 이번에 알고 넘어가자!
우선 문제 상황을 재현해보자. 동시성 제어가 없는 상황에서 어떤 일이 발생할까?
한정판 신발 100개 있고 단순하게 주문이 들어오면 재고가 줄어드는 상황이다.


재고를 100개로 두고 100개의 주문 요청을 동시에 보내는 테스트 코드를 돌려보자.

정상적인 상황이라면 재고는 0개가 되어야 한다.
하지만 결과!!

테스트는 실패했고 아직 91개의 신발이 남아있다. (재고 값은 달라져도 항상 실패한다.)
9개의 주문만 정상처리 되었고 91개의 주문은 요청은 들어갔지만 재고에서 계산되지 않았다.
여러 스레드가 동시에 decrease 메서드에 접근하고 재고 조회와 감소를 하려하는데 그 시간 차이 때문에 이런 현상이 발생한다.
첫 번째 스레드가 재고 여부를 확인하는 읽기 단계 이후 재고를 줄이려는 수정 단계를 거치기 직전 두 번째 스레드가 읽기를 수행하면 아직 재고가 줄어들지 않아 아직 재고가 100개라고 인식한다.
즉, 두 스레드가 모든 과정을 거쳐도 재고는 1개만 줄어들게 된다.
해결하기 1 : synchronized
가장 간단한 해결 방법이 synchronized이다. 자바에서 지원하는 방식이며 synchronized 가 된 부분에는 하나의 스레드만 접근이 가능하다. 이후 요청들은 대기 상태가 된다.
아까 테스트 했던 코드의 decrease 부분에 synchronized를 추가해보자.

그리고 같은 테스트를 재실행 해보면...
성공을 예상했으나

실패했다. 재고가 3개 남은 것으로 보아 전보다는 동시성 관리가 이루어진 것 같지만 뭐가 문제일까..
원인은 트랜젝션이었다.
지금 코드를 동작시키면 이렇게 작동한다.
- 트랜잭션 시작: decrease를 호출하기 전 트랜잭션을 시작
- 메서드 호출: decrease를 호출
- 트랜잭션 커밋: decrease 메서드 실행이 완전히 끝난 후에 트랜잭션을 데이터베이스에 최종 커밋
이 때!! 메서드 실행이 완전히 끝난 후에
부분이 포인트였다.
먼저 첫 번째 스레드가 프록시를 통해 decrease 메서드를 호출하고 synchronized 덕분에 하나의 요청만 수행된다.
그렇게 편한 상태로 메서드 내부 로직(재고 조회 -> 수량 감소)을 실행한다.
그리고 decrease 메서드 실행을 마치게 되면 바로 이 순간 synchronized 가 해제된다!
하지만 첫 번째 스레드의 트랜잭션은 아직 데이터베이스에 커밋되지 않았다. (프록시가 이제 막 커밋을 시도하려는데...)
두 번째 스레드가 synchronized 해제를 확인하고 바로 decrease 메서드에 진입한다. 그렇게 되면 스레드 2가 데이터베이스에서 재고를 조회했을 때 스레드 1의 변경사항은 아직 커밋 전이고 스레드 2는 변경 전의 재고(100개)를 읽게 된다..
결과적으로, 여러 스레드가 변경 전의 재고를 기준으로 계속 값을 덮어쓰게 되니 업데이트 유실(Lost Update)이 발생한 것이다.
믿었던 친구에게 배신당했다. 그럼 이제 어떻게 해결할까?
일반적으로 'DB Lock', 'Redis 분산 락' 두 가지 방법을 사용한다.
두 방법에 대해 알아보고 적절하게 선택해보자.
해결하기 2 : DB에 Lock 걸기
트랜잭션 범위 안에서 데이터를 일관성있게 관리하려면 데이터베이스가 제공하는 락 기능을 사용할 수 있다.
대표적인 방법인 비관적 락을 사용해서 문제를 해결해보자.
비관적 락은 어차피 문제가 발생할 것이라 가정하고 데이터 조회부터 아예 다른 트랜잭션의 접근을 차단하는 것이다.
Repository에 Lock을 추가하고 / synchronized 는 삭제하고 새로 만든 findByIdWithPessimisticLock 을 사용해보자.


그렇다면~~

성공!!
'낙관적 락' 이라는 방법도 있다. 비관적 락은 문제가 발생할 것이라 가정한다. 하지만 만약 동시에 수정하는 일이 거의 없어 충돌이 잘 예상되지 않는 상황이라면 낙관적 락도 고려해보자.
낙관적 락은 데이터를 읽을 때 락을 걸지 않고 데이터를 업데이트하는 시점에 내가 읽었던 데이터가 그 사이에 다른 사람에 의해 변경되지는 않았는지를 버전을 확인한다. 보통 데이터 테이블에 version 컬럼을 추가하고 같이 읽어가면서 확인한다. 즉, 수정하는 그 시점에만 확인하는 방법이다.
해결하기 3 : Redis
이번엔 redis를 사용하여 동시성 문제를 해결해보자.
Redis 를 사용하는 이유는 크게 두가지이다.
1. 모든 데이터를 메모리에서 처리하는 In-Memory 기반이기 때문에 속도가 빠르다. 락을 획득하고 해제하는 과정이 빠르게 일어나기 때문에 이후 테스트 실행 결과에서 시간을 보면 확실히 빠른 것을 확인할 수 있다.
2. 그리고 원자적이라는 특징을 가지고 있기 때문이다. Redis는 싱글 스레드 기반으로 명령어 하나가 실행될 때 다른 명령어는 실행될 수 없다. 때문에 한번에 한 개의 요청만 처리되어 동시성이 제어가된다.
그럼 원자적이라는 특징으로 동시성을 제어하는 원리에 대해 알아보자.
분산락을 이용하는 방법이다.
분산 락의 개념은 간단하다!!
분산락은 하나의 자원에 대해 한 번에 하나의 서버(지금은 서버거 1개지만 결국 모든 요청을 의미한다.)만 접근할 수 있게 사용 권한을 부여하고 관리하는 것이다.
우리는 재고(자원)에 대한 접근 권한은 락(Lock)이라고 부른다. 만약 서버가 여러개인 상황에서도 사용 권한을 관리하기 위해 모든 서버는 Redis를 바라보고 있으므로 분산 환경에서도 동시성 제어가 가능하다. 우리의 쇼핑몰은 서버가 늘어날 가능성도 있기 때문에 Redis를 사용해서 동시성을 제어해보자.
스레드가 메서드를 호출하면 서버는 레디스에 키를 생성한다.
키 생성에 성공하면 락을 획득하는 것이다. 즉 자원에 접근할 수 있는 권한이 주어진다.
원자적 실행이라는 특성 덕분에 키는 단 하나만 생성된다. 그렇게 하나의 스레드가 락을 선점하게 되고 그 상태에서 다른 요청들이 온다면 나머지 요청은 대기하게 된다.
그렇게 락을 가진 상태에서 작업의 진행이 모두 끝나면 락을 해체하고(키를 삭제하고) 다음 스레드가 다시 락을 획득하여 순차적으로 진행된다.
이 때 락을 획득한 요청이 어떠한 오류로 멈춰서 키를 삭제하지 못하는 상황을 막기 위해 유효시간을 설정해 주는 것도 잊지 말자!
개인적으로 제일 편하다고 생각한다. 코드는 다시 처음 문제가 있던 상태로 돌려놓고 실습을 진행한다.
레디스를 설정하고

이 전처럼 100개의 재고와 100개의 요청을 보내면

성공!
책에선 짧게 지나간 내용이지만 알아두면 좋을 것 같은 내용이라 주제로 잡게 되었다. 경쟁 상태는 눈에는 보이지 않지만 안정성과 밀접한 관련이 있는 부분이므로 이번 기회에 잘 알아두자!
특히 프로젝트를 진행중이라면 DB 락을 사용할지 Redis 분산 락을 사용할지 합당하게 선택하는 것도 중요할 것 같다. 의논해봅시다
신발은 약 1시간 뒤 하나의 결제는 승인되었고 나머지는 자동 취소가 진행됐다. (환불은 2주가 걸렸다.)

'CS 먹고 레벨업~' 카테고리의 다른 글
| JOIN이 느린건 내 골반이 멈추지 않는 탓일까? ㅜ.ㅜ (2) | 2025.10.28 |
|---|---|
| 이건 트랜잭션 두번째 레슨 좋은 건 너만 알기 (0) | 2025.10.21 |
| 내 기술은 모두 한 단계 진화한다. (2) | 2025.10.01 |
| 서버 하나 추가해봐 (0) | 2025.09.24 |
| 혼자야? (1) | 2025.09.17 |