
글 너무 오랜만에 써서 어떻게 시작해야 될지 모르겠네요... 제목 이렇게 짓는 거 맞나
Q. 감자에게 동시성 문제란?
A. 에...가위바위보 같은 존재죠. 저 가위만 내서 맨날 지니까 누가 가위바위보 하자고 하면 냅다 도망부터 갑니다. 피할 수 없다면 더 열심히 피해라? 뭐 그런 말 있잖아요
📌 도망은 재능의 영역입니다
TMI - 감자는 달리기가 매우매우 느리다.
아무튼 도망 실패로 얼결에 동시성 문제를 해결하게 되었다. 에혀
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberTicketService {
private final MemberRepository memberRepository;
private final TicketRepository ticketRepository;
private final MemberTicketRepository memberTicketRepository;
@Transactional
public void issue(Long memberId, Long ticketId) {
Member member = getMember(memberId);
Ticket ticket = getTicket(ticketId);
validateIssuable(member, ticket);
memberTicketRepository.save(new MemberTicket(member, ticket));
ticket.decrementQuantity();
}
// ...
}
멤버 티켓 발행 기능의 상세 요구 사항으로는 다음과 같다:
- 각 Member는 최대 두 장의 티켓을 발행할 수 있다.
- Ticket 재고가 남아 있을 때만 멤버 티켓 발행이 가능하다.
테스트에서는 ExecutorService를 활용해 다음과 같이 여러 스레드를 동시에 실행시킨다.
for (Member member : members) {
for (int i = 0; i < memberRequestCount; i++) {
executorService.submit(
() -> memberTicketService.issue(member.getId(), getRandomTicket(tickets).getId()));
}
}
그래서 당연히 여러 스레드로 돌린 테스트는 와장창 실패한다.

📌 데드락이 무럭무럭

데드락으로 인해 티켓의 재고 수정에 실패했다는 예외 메시지가 발생했다. 아... 나 이런 거 해결할 줄 모르는데
어떤 상황에서 데드락이 발생하고 있는 건지 확인하기 위해 MySQL에 접속해서 상태를 확인해 주었다.
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-11-18 14:09:24 140736780715776
*** (1) TRANSACTION:
TRANSACTION 2148, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 54, OS thread handle 140736262551296, query id 1881 192.168.65.1 root updating
update ticket set name='???',quantity=29 where id=16
첫 번째 트랜잭션은 id가 2148이며, 현재 인덱스 읽기가 시작된 상태로 id가 16인 ticket에 대해 UPDATE 쿼리를 실행하려고 하고 있다.
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table ticket.ticket trx id 2148 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
해당 트랜잭션은 ticket 테이블의 id가 16인 레코드에 대한 읽기 잠금(Shared Lock)을 보유하고 있다. 읽기 잠금은 다른 트랜잭션이 해당 레코드를 읽을 수 있지만 수정은 하지 못하게 막는다.
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table ticket.ticket trx id 2148 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
이 부분은 트랜잭션이 대기 중인 락에 대한 정보를 나타내고 있다. 트랜잭션이 ticket 테이블의 id가 16인 레코드에 대해 쓰기 잠금(Exclusive Lock)을 얻기 위해, 해당 레코드를 읽기 잠금으로 잡고 있는 다른 트랜잭션이 잠금을 해제할 때까지 대기하고 있는 상태로 볼 수 있다.
*** (2) TRANSACTION:
TRANSACTION 2145, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 46, OS thread handle 140736490080000, query id 1884 192.168.65.1 root updating
update ticket set name='???',quantity=29 where id=16
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table ticket.ticket trx id 2145 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table ticket.ticket trx id 2145 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
그리고 두 번째 트랜잭션도 마찬가지로 id가 16인 ticket 테이블의 레코드에 대한 읽기 잠금을 획득한 상태에서, 해당 레코드에 대한 쓰기 잠금을 얻기 위해 대기하고 있다.

대략 이런 상황으로 데드락이 발생하는 것이다.
📌 데드락 해결 짱 쉽잖아
플래그 대사 그만 좀 말해요
synchronized 키워드
Java에서 synchronized 키워드는 동기화를 구현하기 위해 사용된다. 여러 스레드가 동시에 실행될 때, 공유 자원에 접근하는 것을 제어하여 상호 배제를 보장한다. synchronized는 한 번에 하나의 스레드만 특정 코드 블록을 실행하도록 보장하여, 여러 스레드가 동시에 같은 자원에 접근할 때 생길 수 있는 데이터의 불일치나 오류를 방지한다.
@Transactional
public synchronized void issue(Long memberId, Long ticketId) {
Member member = getMember(memberId);
Ticket ticket = getTicket(ticketId);
validateIssuable(member, ticket);
memberTicketRepository.save(new MemberTicket(member, ticket));
ticket.decrementQuantity();
}
TicketService의 코드를 위와 같이 수정하면, 멤버 티켓을 발행하는 로직은 한 번에 하나의 스레드에서만 실행될 수 있다. 즉, 이 경우 상호 배제가 보장되어 데드락은 발생할 수 없다.
이렇게 쉬운 방법으로 동시성 문제를 해결할 수 있었다니 🙃 설레는 마음으로 테스트를 실행하자

아주 멋지게 실패했습니다 ? 🥵

불행 중 다행으로 데드락은 해결됐는데, 티켓 재고와 발행된 멤버 티켓 간 정합성이 맞지 않는 문제가 발생했다.
📌 synchronized도 두들겨 보고 건너자
synchronized를 사용했음에도 코드가 의도대로 동작하지 않는 이유는 Spring AOP 때문이다.
내가 예상한 issue 메서드 호출 시의 실행 흐름은 다음과 같다:
- 동기화된 코드 블록에 진입
- 트랜잭션 시작
- 멤버 티켓 발행 로직 수행
- 트랜잭션 커밋
- 동기화된 코드 블록을 벗어남
그러나 @Transactional은 프록시 객체 생성을 통해 트랜잭션을 관리하기 때문에 실제로는 다음과 같은 흐름으로 코드가 실행된다:
- 트랜잭션 시작
- 동기화된 코드 블록에 진입
- 멤버 티켓 발행 로직 수행
- 동기화된 코드 블록을 벗어남
- 트랜잭션 커밋
따라서 특정 스레드의 커밋이 종료되지 않은 상태에서 다른 스레드에서 코드 블록에 접근이 가능해져 완전한 상호 배제가 보장되지 않는 것이다.
해결 방안 1. RestTemplate 적용
제일 간단한 해결 방법으로 @Transactional 어노테이션을 사용하지 않고, 직접 issue 메서드 내에 트랜잭션을 구현할 수 있다.
public class MemberTicketService {
private final TransactionTemplate transactionTemplate;
public synchronized void issue(Long memberId, Long ticketId) {
transactionTemplate.execute(status -> {
Member member = getMember(memberId);
Ticket ticket = getTicket(ticketId);
validateIssuable(member, ticket);
memberTicketRepository.save(new MemberTicket(member, ticket));
ticket.decrementQuantity();
return null;
});
}
}
이렇게 코드를 작성하면 issue 메서드를 호출했을 때, 첫 번째 실행 흐름대로 진행되어서 테스트가 잘 통과하는 것을 볼 수 있다.

해결 방안 2. MemberTicketIssueService 추가
실행 흐름을 의도한 대로 바꾼다는 점에서 RestTemplate을 적용하는 것과 본질은 같은데, 별도 서비스를 분리하는 방법이다.
@Service
@RequiredArgsConstructor
public class MemberTicketService {
private final MemberTicketIssueService memberTicketIssueService;
public synchronized void issue(Long memberId, Long ticketId) {
memberTicketIssueService.issue(memberId, ticketId);
}
}
위와 같이 MemberTicketService에서 MemberTicketIssueService의 issue 메서드를 호출하는 방식으로, MemberTicketIssueSerivce의 issue 메서드에는 @Transactional 어노테이션이 적용되어 있다.
@Service
@RequiredArgsConstructor
public class MemberTicketIssueService {
private final MemberRepository memberRepository;
private final TicketRepository ticketRepository;
private final MemberTicketRepository memberTicketRepository;
@Transactional
public void issue(Long memberId, Long ticketId) {
Member member = getMember(memberId);
Ticket ticket = getTicket(ticketId);
validateIssuable(member, ticket);
memberTicketRepository.save(new MemberTicket(member, ticket));
ticket.decrementQuantity();
}
}
이렇게 작성하는 경우에는 Controller에서 실수로 MemberTicketIssueService의 메서드를 호출하지 않도록 주의해야 한다.
트랜잭션 전파 레벨
MemberTicketService에도 @Transactional 어노테이션을 붙여도 되지 않나? 싶어서 MemberTicketService의 issue 메서드에도 추가해 봤다.
그리고 무참히 무너진 프로덕션 코드 🥸

이건 트랜잭션 전파 레벨과 관련이 있는데, 기본값은 Propagation.REQUIRED다.

만약 현재 이미 시작된 트랜잭션이 존재하는 경우에는 해당 트랜잭션에 포함되고, 트랜잭션이 없는 경우에는 새로운 트랜잭션을 시작하는 방식으로 동작한다. 즉, MemberTicketService의 issue 메서드에 @Transactional을 적용하면, 두 번째 실행 흐름이 돼서 MemberTicketIssueService를 분리한 의미가 없어지는 것이다.
배운 걸 한 번 적용해 보기 위해 MemberTicketIssueService의 트랜잭션 전파 레벨을 REQUIRES_NEW로 설정했다. 참고로 이 옵션은, 현재 시작된 트랜잭션이 존재하는 경우에도 새로운 트랜잭션을 생성한다.

그러니까 프로덕션 코드는 다음과 같은 상태
@Service
@RequiredArgsConstructor
public class MemberTicketIssueService {
private final MemberRepository memberRepository;
private final TicketRepository ticketRepository;
private final MemberTicketRepository memberTicketRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void issue(Long memberId, Long ticketId) {
Member member = getMember(memberId);
Ticket ticket = getTicket(ticketId);
validateIssuable(member, ticket);
memberTicketRepository.save(new MemberTicket(member, ticket));
ticket.decrementQuantity();
}
}
@Service
@RequiredArgsConstructor
public class MemberTicketService {
private final MemberTicketIssueService memberTicketIssueService;
@Transactional
public synchronized void issue(Long memberId, Long ticketId) {
memberTicketIssueService.issue(memberId, ticketId);
}
}
테스트를 실행하자 심상치 않은 움직임을 발견했다.

테스트가 꽤 오랫동안 멈춰 있다가, 갑자기 Sql Error와 함께 실패했다.

이 오류는 HikariCP 연결 풀에서 발생하는 오류로, 데이터베이스 연결을 요청했지만 사용할 수 있는 커넥션이 없어서 타임아웃이 발생했음을 의미한다.
테스트 실행 전까지는 synchronized가 걸려 있는 MemberTicketService의 메서드에 의한 트랜잭션 하나, 그리고 내부에서 호출된 MemberTicketIssueService의 메서드에서 새로 생성된 트랜잭션 하나로 총 두 개의 커넥션이 생성될 것이라고 예상했다. 커넥션 풀의 기본값은 10으로 2보다 훨씬 작은 값이다. 그러나 테스트 결과를 통해 활성 커넥션을 초과하는 요청이 발생하는 가능성을 열어두고, 원인을 찾아 봤다.
우선 애플리케이션의 datasource 설정에서 hikari.maxPoolSize를 20으로 늘리고, 테스트에 다음과 같이 활성 커넥션 개수를 주기적으로 출력하는 스케줄러를 추가했다.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
int activeConnections = dataSource.getHikariPoolMXBean().getActiveConnections();
System.out.println("Current Active Connections: " + activeConnections);
}, 0, 300, TimeUnit.MICROSECONDS);
그리고 모든 테스트에서 동일하게 최대 (활성화된 스레드 개수 + 1) 개의 커넥션이 활성화되었다.

스프링이 생성한 프록시에서 synchronized 범위에 진입하기 전, 커넥션을 요청하기 때문에 위와 같은 결과가 나타난 것으로 판단된다. 커넥션 자원이 계속 대기하면서 자원이 많이 낭비되기 때문에, 이 방법은 실제로 서비스하는 애플리케이션에서 사용하긴 어려울 것 같다 🥲
📌 이렇게 열심히 해결했지만
synchronized는 스케일 아웃 한 번이면 물거품이 된다 🫠 그래서 서버 다중화가 이루어져도 동시 요청을 해결하는 방법에 대해 더 학습해야... 👀
'감자-갱생-프로젝트' 카테고리의 다른 글
| Spring Security는 사드세요... 제발 - Architecture 편 (1) | 2025.04.14 |
|---|---|
| 내 이름은 지속적 통합, 실패했죠 (3) | 2025.04.10 |
| 동시성의 이름으로 날 용서하지 않겠다 - Distributed Lock 편 (2) | 2024.12.03 |
| 동시성의 이름으로 날 용서하지 않겠다 - Optimistic Lock 편 (1) | 2024.12.02 |
| 동시성의 이름으로 날 용서하지 않겠다 - Pessimistic Lock 편 (2) | 2024.11.19 |