목차
- 초기 셋팅
- 문제 상황
- 문제 해결법
- synchronized
- Pessimistic Lock
- Optimistic Lock
- Named Lock
- Redis
1. 초기 셋팅



2. 문제 상황
단일 스레드가 실행할 때는 정상적으로 테스트가 통과되는 것을 볼 수 있다.

하지만 만약에 동시에 100개의 스레드가 재고 수량 감소를 요청하면 어떨까?

1️⃣ 동시에 100개의 요청에 대한 테스트 코드를 보면 ExcutorService를 이용해 100개의 요청을 동시에 실행
2️⃣ CountDownLatch로 모든 요청이 끝날 때 까지 대기
3️⃣ stockService.decrease(1L, 1L);를 100번 실행하여 재고를 100개 감소
4️⃣ assertEquals를 이용해 재고가 0인지 검증
내 예상 : 예상 재고 수량 = 0개
실제 : 88개

왜 이런 문제가 발생했을까?
🚨레이스 컨디션이 발생🚨
레이스 컨디션이란 여러 개의 스레드가 동일한 자원을 동시에 변경하려고 할 때 발생하는 문제이다.
멀티스레드 환경(즉 실제 어플리케이션 환경)에서는 코드가 동시에 실행되므로 한 스레드가 변경하는 데이터를 다른 스레드가 동시에 읽거나 변경할 수 있다.
➡️ 위 테스트 코드로 흐름(여러 스레드가 동시에 실행된다는 가정)
- 스레드 A가 stock.getQuantity()를 읽음 -> 100
- 스레드 B가 stock.getQuantity()를 읽음 -> 100
- 스레드 A가 100 - 1 = 99 계산 후 저장
- 스레드 B도 100 - 1 = 99 계산 후 저장
- 즉 원래는 100 -> 98이 되어야 하지만 두 스레드가 100에서 각각 감소 연산을 하고 동시에 저장하여 99가 돼버리는 문제가 발생함.
같은 데이터를 여러 스레드가 동시에 읽고 동시에 수정하여 잘못된 값이 저장되는 문제이다.

그럼 어떻게 이걸 해결해야 할까
➡️ 나의 생각 : A의 스레드가 작업을 할 때는 B 스레드는 대기하고 A의 스레드의 작업이 완료되면 대기하고 있던 B 스레드가 작업을 시작하면 되지 않을까?
3. 문제 해결법
1. Synchronized
자바에서 제공하는 Synchronized를 이용하여 해당 메서드 선언부에 적어주면 그 메서드는 한 개의 스레드만 접근 가능하게 해줄 수 있다.

synchronized를 붙여줬으니 1개의 스레드만 접근할 수 있으므로 테스트 코드는 성공할 것이다. 한번 테스트 코드를 실행해보자!

재고 감소 메서드가 synchronized를 쓰기 전보다는 어느정도 재고가 줄었지만 여전히 테스트 코드에 실패한다!
왜 그럴까?
🚨 @Transactional과 synchronized를 사용해도 동시성 문제가 해결되지 않는 이유🚨
먼저 이런 질문이나 생각이 있을 수도 있다. (필자도 이런 생각을 했었음)
❓어차피 @Transactional과 synchronized가 똑같은 메서드 단위에 적용되는거라 synchronized는 메서드가 끝날 때 해제되고 @Transactional도 메서드가 끝나면 트랜잭션이 커밋되는거 아닌가? 라고 생각될 것이다.
물론 겉으로 보면은 맞는 말이다! 하지만 스프링에서 @Transactional 동작 방식은 AOP 기반 트랜잭션 관리 책임이 메서드 호출 전후를 감싸는 프록시 객체에 있다. ✅ 즉 개발자가 작성한 메서드 자체가 트랜잭션을 관리하는 것이 아니다!

1️⃣ AOP Proxy가 먼저 트랜잭션을 시작한다.
2️⃣ synchronized가 락을 걸어서 한 번에 하나의 스레드만 decrease() 실행이 가능하다.
3️⃣ 메서드가 끝나면서 synchronized 락 해제
4️⃣ 하지만 트랜잭션이 커밋되기 전이라 아직 변경 사항이 DB에 반영되지 않았다.
5️⃣ 그래서 다음 스레드가 실행될 때 DB에는 변경 사항이 변경되지 않았으므로 여전히 이전 데이터를 읽을 가능성이 크다!
✅ 결과적으로 정리하면
➡️ synchronized가 해제되는 순간 다음 스레드가 실행됨 -> 하지만 트랜잭션 커밋이 아직 끝나지 않았기 때문에 다른 스레드는 여전히 이전 값을 읽을 수 있다. 따라서 동시성 문제가 발생할 수 있음.
그럼 데이터의 변경 사항을 즉시 반영하기 위해서는 synchronized만 단독으로 사용해야 되는데 이러면 단점이 있다..!
바로 Transactional의 커밋 또는 롤백 기능을 사용하지 못한다. 그래서 예외 발생 시 데이터가 롤백되지 않고 그대로 저장된다는 치명적인 단점이 있다! 또 synchronized만 단독으로 사용할 시에는 하나의 스레드만 실행되도록 강제하기 때문에 성능 저하 이슈가 발생할 수 있다.
그럼 이 방법 말고 또 무슨 방법이 있을까?!
2. Pessimistic Lock (비관적 락)
MySql에서 제공하는 Pessimistic Lock을 이용하여 실제 데이터에 Lock을 걸어서 정형성을 맞춘다.

@Lock 속성은 PESSIMISTIC_WRITE를 적어줬다. 그 이유는 다른 트랜잭션은 읽기도 못하고 쓰기도 못하는 못하는 속성이기 때문에 반드시 순차적으로 업데이트 시키기 위해 사용했다.

그리고 서비스 계층에서 synchronized 키워드를 제거하고 Repository에서 작성해준 쿼리 메서드를 사용해줬다.

이번에는 테스트에 성공했다!
왜 Pessimistic Lock으로는 테스트에 성공한 것일까?
일단 Pessimistic Lock 흐름을 그림으로 살펴보자!



이런 식으로 T1이 작업할 때는 어느 누구도 접근을 하지 못하게 만들어서 작업 완료 (즉 재고 감소)까지 마치고 접근 가능하게 만드니 동시성문제를 해결할 수 있었다!
❓하다보니 궁금증이 생겼다. “synchronized는 @Transactional과 함께 사용할 때 트랜잭션 커밋 전까지 DB에 반영되지 않아서 동시성 이슈가 있었는데, Pessimistic Lock은 @Transactional과 같이 써도 문제가 없을까?”
➡️ Lock은 synchronized와 다르게 DB 레벨에서 직접 락을 걸어 동시성 문제를 방지하기 때문에 다른 트랜잭션이 이 데이터를 읽거나 수정하려고 하면 트랜잭션이 끝날 때까지 대기해야해서 동시성 문제가 생기지 않는다.
그럼 동시성을 해결하려면 이 비관적 락만 사용하면 될 것 같지만 막상 또 아니다!
이유는 내가 생각하기에 2가지가 있다.
1️⃣ 락이 걸린 상태에서 트랜잭션이 길어지면 다른 트랜잭션이 대기하는 시간이 길어져 성능이 떨어질 수 있다. 즉 하나의 트랜잭션이 오래 유지되면 Deadlock이 발생할 가능성이 있기 때문이다.
2️⃣ Pessimistic_Write는 단순히 데이터를 읽는 Select 쿼리도 락을 걸어버리기 때문에 즉 읽기 요청도 쓰기 요청 처럼 동작하기에 성능이 저하될 수 있다.
3. Optimistic Lock (비관적 락)
optimistic Lock은 실제로 Lock을 이용하지 않고 엔티티에 Version을 추가함으로써 데이터의 정합성을 맞추는 방법이다.

이런 식으로 엔티티에 version 컬럼을 추가하고 필드 위에 @Version을 붙여주면 된다!

그리고 OptimisticLock을 사용하기 위해 Lock 속성을 OPTIMISTIC으로 바꿔주었다.

Pessimistic Lock과 똑같이 서비스 계층에 OptimisticLock 전용 Repository 쿼리 메서드만 바꿔주었다.
테스트 코드는 동일하니 바로 테스트를 진행해보겠다!

오잉? 실패해버렸다. 그래서 문제가 무엇인지 검색해 보았는데 낙관적 락은 Version이 충돌하면 예외를 던진다. 그래서 예외가 터진 것이었다..🥲
❓그럼 어떤 식으로 해결할까?

✅ 바로 @Retryable 어노테이션을 사용하는 것이다.
❓ 그래서 @Retryable이 뭘까?
➡️ @Retryable은 특정 예외가 발생했을 때 자동으로 재시도하도록 도와주는 스프링 프레임워크의 기능이다.
내가 작성한 @Retryable 애노테이션 안에 속성들은 retryFor, maxAttempts, backoff가 있다.
1️⃣ retryFor 속성 : 내가 정의한 예외가 발생하면 재시도 한다는 뜻이다.
ObjectOptimisticLockingFailureException는 Spring Data JPA에서 낙관적 락 버전 충돌 발생시 나타나는 예외이다.
2️⃣ maxAttempts 속성: 최대 시도 횟수이다. 나는 15를 적었으니 최대 15번 시도한다는 뜻이다.
3️⃣ backoff 속성 : 재시도 간격이다. 숫자의 단위는 ms 이므로 나는 0.5초마다 재시도 한다는 뜻이다.
🤔 근데 @Retryble을 사용해도 재고 감소는 주는데 테스트는 실패하는 경우가 생겼다.
✅ 이 경우 나는 maxAttemps와 backoff 속성을 한 5번 정도 바꿔준 것 같다. 최대 시도 횟수와 재시도 간격을 너무 짧게 하면 버전 충돌이 일어날 것이고 너무 길게한다면 성능저하가 일어날텐데 이때는 차라리 비관 락을 쓰는게 맞는것 같다. 이것도 속성 값 전략을 잘 설계해야할 것 같다!
그럼 간단하게 그림으로 흐름을 살펴보자!
'Back-End > DataBase' 카테고리의 다른 글
| 데이터베이스 연결(Driver) (0) | 2025.01.15 |
|---|---|
| SQL로 복잡한 쿼리 실습하기 (0) | 2025.01.15 |
| SQL로 요리하듯 데이터 다루기 (0) | 2025.01.15 |
| Spring JdbcTemplate의 RowMapper() (1) | 2024.12.09 |
| 커넥션 풀과 데이터소스 (0) | 2024.12.06 |