[개요]
해당 글은 예약 생성 및 수정 과정에서 발생한 동시성 문제를 다룹니다. 이를 해결하기 위해 애플리케이션, 데이터베이스, 인프라 각 수준에서 적용할 동시성 제어 기법을 검토하고 선택한 기술적 접근 방식에 대해 설명합니다.
[요약]
동시성 문제 해결을 위해 애플리케이션, 데이터베이스, 인프라 각 수준에서 동시성 제어 기법을 직접 경험하고 검토한 결과, Redis 분산락을 도입하여 동시성을 해결하는 전략을 선택하였습니다.
[문제 상황]
아래는 예약을 생성하는 로직입니다.

[문제 상황 시뮬레이션]
직접 작성한 테스트 코드로 동시에 여러 개의 요청이 들어오는 상황을 시뮬레이션한 결과, DeadLock이 발생했습니다.



[문제 상황 분석]

reserve메소드에는 @Transactional이 걸려있습니다. 이때 INSERT나 UPDATE 같이 쓰기 지연을 활용하는 경우에는 메소드의 순서대로 쿼리가 나가지 않습니다. 실제로 JPA의 쿼리를 확인해보면 INSERT → UPDATE 순으로 쿼리가 나가는 것을 확인할 수 있습니다.
그렇기에 INSERT 문이 먼저 나가면서 FK로 있는 AvailableDate에 S Lock이 걸립니다. S Lock은 다중 트랜잭션 간 동시 획득이 가능합니다. 그 뒤 두 트랜잭션에서 S Lock이 걸린 AvailableDate에 UPDATE 문이 나가면서 X Lock 획득을 위해 대기하는 과정에서 락 경합이 발생하여 DeadLock이 발생합니다.
DeadLock이 발생하면 데이터베이스에서는 하나의 트랜잭션을 롤백하여 DeadLock을 해소합니다. 이는 상당한 오버헤드로 전체 시스템의 성능을 저하시킬 수 있습니다.
[해결 방안]
[해결 방안 1 : 애플리케이션 수준에서의 동시성 제어]
[synchronized 사용]


synchronized 를 사용할 경우 동시성 문제를 해결할 수 있습니다. 이때 위처럼 클래스를 분리한 이유는 Transaction이 synchronized의 범위 안에서 시작하고 종료하도록 하기 위함입니다.
테스트 결과 DeadLock 발생 없이 동시성 제어가 잘 이뤄졌습니다.
[애플리케이션 수준에서의 동시성 제어 한계점]
synchronized에는 락 획득 대기 시 타임아웃 설정이나 공정성 정책을 지정할 수 없으며, 스레드가 락을 획득하지 못할 경우 무한정 대기하여 중단이나 타임아웃 제어가 어렵다는 단점이 있습니다.
또한, 애플리케이션 레벨에서의 락은 다중 서버 환경에서는 적용하기 어렵다는 한계로 인해 다른 방법을 고민했습니다.
[해결 방안 2 : 데이터베이스 수준에서의 동시성 제어]
[낙관적 락]
낙관적 락은 데이터베이스에 이미 영속화되어 버전 필드(@Version)가 존재하는 엔티티에서 동시성 충돌을 감지하는 데 사용됩니다. 따라서 새로운 엔티티 생성 시점에는 적용할 수 없기 때문에 여전히 DeadLock이 발생합니다.
[비관적 락]

비관적 락을 사용한 결과 DeadLock 발생 없이 동시성 제어가 잘 이뤄졌습니다.
다만, 비관적 락을 사용하게 되면 reserve 메소드가 진행될 동안 조건절에 추가한 Member, AvailableDate, Restaurant의 레코드에 락이 걸려 다른 트랜잭션에서 해당 레코드에 쓰기 작업을 할 수 없습니다.
그래서 가능한 다른 작업에 주는 영향을 최소화 할 수 있는 방법을 고민했습니다.
[Unique 제약 조건 추가 및 더티 체킹 제거]



한번 근본적으로 데드락이 발생한 원인을 생각해보니 결국 UPDATE 쿼리보다 INSERT 쿼리가 먼저 실행되면서 발생한 것이었습니다. 그리고 INSERT 쿼리가 먼저 실행된 이유는 JPA의 더티 체킹을 사용했기 때문이었습니다. 그렇기에 UPDATE 쿼리를 JPA의 더티 체킹을 사용하지 않고 직접 쿼리를 날리는 방식으로 수정했습니다.
또한 reservation 테이블에 Unique 제약 조건을 추가하여 예약이 중복 생성되는 상황을 데이터베이스 수준에서 제어했습니다.

[추가 문제]

Unique 제약 조건과 더티 체킹을 제거하여 예약을 생성하는 로직에서 동시성을 제어하는 것은 성공했습니다. 하지만 예약을 수정할 때는 이미 생성된 예약이므로 Unique 제약 조건을 사용할 수 없어 한 사람이 동시에 여러 번 같은 요청을 보낼 경우(중복 API 호출) 동시성 제어가 이뤄지지 않아 예약 가능 인원 수가 중복 감소되거나 중복 증가되는 문제가 발생했습니다.

결국 Lock을 통해 중복 API 호출을 막아야겠다고 판단하였고 MySQL의 Named Lock과 Redis 분산락 중에서 선택하기로 했습니다.
[MySQL 분산락 - Named Lock]

Named Lock을 활용하여 동시성 제어를 할 수 있었습니다. 이때 synchronzed와 같이 클래스를 분리한 이유는 Named Lock 또한 결국 트랜잭션 내에서 실행되기 때문에 비즈니스 로직과 하나의 트랜잭션에서 실행할 경우 동시성 제어가 이뤄지지 않기 때문입니다.
해결 방법으로는 Named Lock 전용 별도의 커넥션 풀을 사용하거나 클래스를 분리하는 방법이 있었는데 이 당시 Named Lock과 Redis 분산락 중 고민하고 있던 상황이었기에 빠른 비교를 위해 리소스가 덜 드는 클래스 분리를 선택했습니다.
추가로 Lock을 획득한 뒤 로직이 끝난 뒤 해제하지 않은 이유는 Lock을 활용한 이유가 한명의 유저가 중복 호출하는 경우를 막기 위한 용도이고 Lock을 획득할 때 Lock의 Name에 memberId를 포함하기 때문에 다른 유저의 로직에는 영향이 없기 때문에 별도로 해제하는 로직을 넣진 않았습니다.
[해결 방안 3 : 인프라 수준에서의 동시성 제어]
[Redis 분산락]

Redis 분산락을 활용하여 동시성을 제어한 코드입니다. 이때 Named Lock과 달리 하나의 트랜잭션으로 묶은 이유는 Redis는 MySQL과 별개의 DB이므로 트랜잭션에 영향을 받지 않아 하나의 트랜잭션으로 묶었습니다.
[Named Lock vs Redis 분산락]
Named Lock의 장단점은 아래와 같습니다.
장점
- 별도의 인프라 구축이 필요없다.
- 추가적인 인프라 유지보수의 비용이 없다.
단점
- 락을 획득하고 해제하는 과정에서 쿼리가 매번 발생하여 불필요한 DB 부하가 초래될 수 있다.
- DB 벤더마다 구현 방식이 다르다.
반대로 Redis 분산락의 장단점은 아래와 같습니다.
장점
- 별도의 인프라를 사용하여 DB의 부담을 줄일 수 있다.
- 인메모리 DB이므로 Named Lock에 비해 성능이 좋다.
단점
- 별도의 인프라 구축이 필요하다.
- 추가적인 인프라 유지보수의 비용이 든다.
위 장단점을 고려해봤을 때 현재 서비스에서 이미 캐시의 용도로 Redis가 구축된 상태였기에 분산락과는 별개로 Redis의 관리가 필요한 상태였고 프로젝트 초기여서 DB 벤더가 달라질 가능성도 존재했기에 Redis 분산락을 사용하기로 결정했습니다.
[결론]
본 프로젝트에서는 예약 생성 및 수정 과정에서 동시성 문제를 해결하기 위해 애플리케이션, 데이터베이스, 인프라 각 수준에서의 동시성 제어 기법을 검토하였습니다. 애플리케이션 수준에서의 동시성 제어는 다중 서버 환경에서의 한계가 존재했기에 데이터베이스 수준과 인프라 수준에서의 동시성 제어 기법을 고민했습니다.
최종적으로 Redis가 이미 구축된 상황이었기에 기존에 구축된 인프라를 활용하고 DB의 부하를 줄이기 위해 Redis 분산락을 선택하였습니다.
'SW마에스트로' 카테고리의 다른 글
| SW마에스트로 합격 후기 - 심층면접 (0) | 2025.04.10 |
|---|---|
| SW마에스트로 합격 후기 - 2차 코딩테스트 (0) | 2025.04.09 |
| SW마에스트로 합격 후기 - 1차 코딩테스트 (0) | 2025.04.09 |
| SW 마에스트로 합격 후기 - 자기소개서 (0) | 2025.04.07 |