안녕하세요, 개발자 SEOBI 입니다. 전 회사에서 프로젝트 전반을 책임졌던 서버 리딩 경험은 제 커리어의 가장 큰 설렘이었습니다. 사용자와 거래 데이터를 지켜내는 그 긴장감과 성취감을 다시 느끼고 싶어, 공부한 것들을 블로그에 아낌없이 공유하려 합니다.
https://gift123.tistory.com/89
서버 개발 (1) MySQL - 트랜잭션 잠금 관리
안녕하세요, 개발자 SEOBI 입니다. 전 회사에서 프로젝트 전반을 책임졌던 서버 리딩 경험은 제 커리어의 가장 큰 설렘이었습니다.사용자와 거래 데이터를 지켜내는 그 긴장감과 성취감을 다시
gift123.tistory.com
사실 위 공부를 했었던 이유는 과거 운영환경에서 일어난 Pessimistic Lock 장애가 있었기 때문 입니다. 당시 재현이 힘들었던 그 Lock 을 어떻게 해결해갔는지 제가 겪은 현업에서의 실제 사례를 꺼내와 보도록 하겠습니다.
어느날 앱에서 사용하는 로그인 api에 대한 급격한 에러로그와 함께 많은 고객 문의와 구글 스토어의 부정적인 리뷰가 쏟아지기 시작했습니다. 운영 환경에서 사용자의 로그인이 간헐적으로 안된다는 긴급장애가 발견 되었습니다. 상황을 재현하기 위해 개발계 환경에서 테스트를 통해 증상을 재현하기 매우 힘들었습니다. 확인 해보니, 구체적으로, 여러개의 쓰레드가 같은 사용자 ID로 동시에 로그인 시도를 하면 Lock wait timeout exceeded; try restarting transaction 에러와 함께 Hibernate의 PessimisticLockException이 발생했습니다. 아래는 해당 오류 로그의 일부입니다:
....
org.springframework.dao.PessimisticLockingFailureException: could not execute statement
Caused by: org.hibernate.PessimisticLockException: could not execute statement
Caused by: cohttp://m.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
Lock wait timeout exceeded; try restarting transaction
..
이 오류는 데이터베이스에서 트랜잭션 간 락 충돌이 일어날 때 나타납니다. 즉, 하나의 트랜잭션이 특정 Row에 대한 Exclusive Lock(배타적 잠금)을 오래 쥐고 놓지 않으면, 동일한 데이터를 업데이트하려는 다른 트랜잭션은 지정된 시간 내에 락을 얻지 못하고 시간 초과됩니다. Hibernate에서는 이러한 DB 락 타임아웃 상황을 JPA 는 “요청한 락을 획득하지 못했을 때 발생하는 예외”로 PessimisticLockException을 발생 시킵니다.
/**
* (문제 코드) 하나의 트랜잭션에 모든 일을 몰아넣은 상태
*/
@Transactional
fun signIn(...) {
//..생략
//dirty 마킹//
member.detail.lastLoginAt = now
member.detail...
member.detail...
member.detail...
...
// 1. save() 함수로 인해 flush() -> update member.. X락 획득!
loginLogRepository.save(LoginLog(member, ip))
//2. Redis I/O 50 ms, 외부 HTTP 호출 300 ms
redisTokenSvc.saveToken(...)
amplitudeClient.sendEvent(...)
/*
* 4. member 가 PESSIMISTIC_WRITE로 잠금
* member행을 기준으로 거래목록 로드 한뒤 관련 엔티티 필드 수정
*/
val txList:List<Tx> = txRepository.findBy..(member.id)
txList.forEach {
...
}
}
위가 실제 문제가 됬던 코드의 견본을 가져왔습니다. 위 코드에서 로그인 요청을 처리하면서 하나의 트랜잭션 안에 많은 작업을 수행합니다. 이로 인해 동시 트랜잭션간에 락충돌이 일어났습니다. 우선 동시 트랜 잭션간에 락 충돌 = "락 대상이 겹쳤다. + 락을 오래 쥐었다." 을 기억하고 위 트랜잭션 내부 코드를 하나씩 살펴보겠습니다.
첫번째 작업은 loginLogRepository.save(..) 을 사용합니다. spring 의 repository.save() 는 flush() 후 insert or update 작업을 수행합니다. save() 를 호출하기 전 회원 정보를 갱신하는 작업을 했습니다. hibernate 는 setXXX() 로 바로 commit 하지 않고, 캐시처리 하고 save() 지점에 기존 캐시 작업들을 flush() 합니다. 쓰기 작업을 하기 때문에 위 트랜잭션은 이 때 X 락을 획득하게 됩니다.
-> 회원 상태 갱신 + X락 획득
두번째 작업은 redis 연동이나 외부 서비스 호출과 같이 DB락과 관계 없는 작업도 같은 트랜잭션 안에서 처리 되고 있었습니다. 트랜잭션이 해당 회원 행의 Lock 시간이 조금더 길어집니다.
-> 트랜잭션 작업양 소폭 증가
세번째 작업은 해당 회원 행이 이미 잠금이 걸린 상태에서 txRepository.findBy(member.id)로 여러 엔티티를 로드한 뒤, 루프를 돌며 다른 여러 엔티티 정보를 수정합니다. 즉, 연쇄 쿼리가 트랜잭션 안에서 쏟아지기 때문에 작업량이 상당히 많아집니다.
-> 트랜잭션 작업양 대폭 증가
위 3가지 작업을 통해 락을 오래 쥐었다. 라는 조건이 완성됩니다. 그리고 첫번째 작업으로 인해 같은 사용자가 login 을 또 시도하게 되면 작업 초반부에 이전 트랜잭션과 락 대상이 겹쳤다. 조건이 생깁니다. 그렇게 락 충돌 = 락 대상이 겹쳤다. + 락을 오래 쥐었다. 로 인해서 두번째 트랜잭션은 lock wait 상태에 빠지게 되고 특정 시간이 지나면 hibernate 의 PessimisticLockException이 발생합니다.
sequenceDiagram
participant T1 as 로그인 요청 #1
participant T2 as 로그인 요청 #2
T1->>DB: UPDATE member ... (X-Lock 확보)
Note over T1,DB: 1,2,3 단계까지 트랜잭션 길게 수행
T2->>DB: UPDATE member ... (락 대기)
Note over T2,DB: 50초 내 락 못얻으면 TimeOut
T1-->>DB: COMMIT (락 해제)
T2-->>DB: 락 획득 OR TimeOut
위 문제점은 정리 하자면 다음과 같습니다.
이러한 구조에서는 가능한 짧게 유지해야 할 트랜잭션을 불필요하게 오래 유지하고 있었고, 그 결과 동시 트랜잭션 간에 락 충돌과 타임아웃이 발생했던 것입니다.
문제의 핵심이 트랜잭션 경합과 과도한 락 보유 시간이었으므로, 해결 전략은 자연스럽게 트랜잭션을 더 세분화하여 핵심 로직과 부가 로직의 수행 시점을 분리하는 것이었습니다. Spring의 트랜잭션 전파 속성 중 REQUIRES_NEW를 이용하면 이러한 분리가 가능합니다 REQUIRES_NEW 는 현재 실행 중인 트랜잭션이 있다면 그 트랜잭션을 잠시 보류(suspend)시키고 별도의 새로운 트랜잭션을 시작합니다. 이를 통해 하나의 요청 내에서 둘 이상의 독립적인 트랜잭션을 사용할 수 있게 됩니다.
@Transactional
fun signIn(...) {
//..생략
//dirty 마킹//
member.detail.lastLoginAt = now
member.detail...
member.detail...
member.detail...
...
logService.handle(member.id, req.ip)
}
data class SignInEvent(
val memberId: Long, // memberId만 전달
val ip: String
)
@Component
class LogSerivice
private val memberRepository: MemberRepository,
private val loginLogRepository: LoginLogRepository,
private val txRepository: TxRepository,
private val amplitudeClient: AmplitudeClient,
private val redisTokenSvc: RedisTokenService
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun handle(memberId:Long, ip:String){
val member = memberRepository.findById(event.memberId).orElseThrow { MemberNotFoundException() }
loginLogRepository.save(LoginLog(member,ip))
redisTokenSvc.saveToken(...)
amplitudeClient.sendEvent(...)
val txList:List<Tx> = txRepository.findBy..(member.id)
txList.forEach {
...
}
}
}
하지만 위 코드로 락 충돌을 해결 한 것은 아닙니다. 락 충돌을 해결 하기 위한 다음 작업을 진행하기 전에 별도의 트랜잭션을 둠으로써, 부분 실패를 하더라도 주 트랜잭션(signIn) 과 격리했기 때문에 새 트랜잭션에서 예외가 발생해도 주 트랜잭션이 롤백되지 않습니다. 즉 서로 결과가 독립적이며 락‧데드락 전파를 줄입니다.
둘을 함께 붙여야 "락 해제 최소화 + DB 무결성 + 부분 실패 방지" 라는 세마리 토끼를 한꺼번에 잡을 수 있습니다.
@Transactional
fun signIn(...) {
//..생략
//dirty 마킹//
member.detail.lastLoginAt = now
member.detail...
member.detail...
member.detail...
...
// ★ 커밋 후 처리할 이벤트 준비
eventPublisher.publishEvent(SignInEvent(member.id, req.ip))
//Commit X락 해제
}
data class SignInEvent(
val memberId: Long,
val ip: String
)
@Component
class SignInEventHandler(
private val memberRepository: MemberRepository,
private val loginLogRepository: LoginLogRepository,
private val txRepository: TxRepository,
private val amplitudeClient: AmplitudeClient,
private val redisTokenSvc: RedisTokenService
) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun handle(event: SignInEvent) {
val member = memberRepository.findById(event.memberId).orElseThrow { MemberNotFoundException() }
loginLogRepository.save(LoginLog(member,ip))
redisTokenSvc.saveToken(...)
amplitudeClient.sendEvent(...)
val txList:List<Tx> = txRepository.findBy..(member.id)
txList.forEach {
...
}
}
}
위 코드를 보면 member.set.. 으로 인한 DB Update 작업이 flush 되어 한번에 처리 되면서 X락이 해제가 되면서 주 트랜잭션의 작업이 먼저 Commit 됩니다. 주 트랜잭션의 작업이 끝나서야 AFTER_COMMIT 리스너 실행이 됩니다. 리스너에서 회원 정보를 재조회 합니다. 리스너 메서드는 별도의 트랜잭션 컨텍스트에서 동작하며 member 를 select로 조회(S락)합니다. 그다음 LoginLog의 save() 작업이 진행합니다.
LoginLog 삽입과 FK 검증: 새로운 트랜잭션에서 LoginLog 엔티티를 INSERT할 때, 외래키 제약조건(FK)으로 인해 회원(parent) 테이블에 대한 참조 무결성 검증이 이루어집니다. 이 과정에서 InnoDB는 해당 Member 레코드에 공유 락(S-락)을 획득하여 부모 레코드의 존재를 확인하고 유지합니다. 외래키 체크 시 참조되는 부모 또는 자식 레코드에 대해 row-level S-락을 겁니다.
요약하면 주 트랜잭션에서 회원 정보 수정 작업을 이미 끝냈고, 두번째 트랜잭션에서 회원을 수정하지 않기 때문에 X락은 걸리지 않았기 때문에 LoginLog 삽입 시에도 회원에 걸리는 것은 일시적인 S락일 뿐입니다.
signIn() ← @Transactional(REQUIRED, 기존 TX #A)
├─ member..
├─ eventPublisher.publishEvent()
└─ 커밋 ───────────╮
│ (X-락 해제)
AFTER_COMMIT 리스너 │
└─ handle() ← @Transactional(REQUIRES_NEW) ⇒ 새 TX #B
├─ SELECT member
├─ INSERT login_log
├─ Redis / 외부 HTTP
└─ 커밋 (TX #B 종료)
① | signIn() 메서드 진입 | 스프링 AOP 프록시가 트랜잭션 A 시작 |
② | member… 수정 · publishEvent() 호출 | 아직 트랜잭션 A 안 |
③ | signIn() 리턴( } ) | 프록시가 commit() 호출 → DB 커밋 수행 (member update 작업 모두 종료) |
④ | 커밋이 끝난 직후 | afterCommit() 콜백 실행 → @TransactionalEventListener(AFTER_COMMIT) 리스너 호출 |
⑤ | 리스너 안에서 @Transactional(REQUIRES_NEW) | 새 트랜잭션 B 시작 → 후처리 쿼리 실행 |
⑥ | 리스너 끝 | 트랜잭션 B 커밋 → 리스너 종료 |
⑦ | 컨트롤러·서비스 호출자에게 값 반환 | 전체 호출 스택 복귀 |
1) 코드만 교체 | DB 스키마 수정이 없어서 → 최악의 경우 즉시 롤백 가능 |
2) 소규모 카나리 (≈ 5 %) | API 게이트웨이에서 /v1/.../signin 일부 호출을 /v2/.../signin으로 우회 |
3) 2시간 모니터링 후 전면 전환 | 오류율·응답시간이 정상임을 확인하고 100 % 전환 |
24 시간 동안 같은 계정 동시 로그인 시도에서 락 관련 오류가 단 한 건도 재현되지 않음을 확인하고, 배포를 마무리했습니다.
서버개발 (1) MySQL - 트랜잭션 잠금 관리 (1) | 2025.04.22 |
---|