장애 원인 진단 및 대응 과정

notion image
notion image
동일한 식별자에 대해 복수의 row가 존재하는 데이터 무결성 위반 문제가 발생했다.
우리 시스템은 사용자당 하나의 FCM 토큰만 존재한다는 전제 하에 User - FcmToken을 @OneToOne 관계로 설계했다. Hibernate는 식별자 기반 조회 시 단일 row를 전제로 동작하기 때문에, 조회 결과가 복수로 반환되자 이를 비정상 상태로 판단하고 예외를 발생시킨 것이다.
그 결과, 인증 객체 생성이 실패하면서 사용자가 로그인 자체가 불가능한 상태로 이어졌다.
 

중복 row의 원인 — 유니크 제약의 부재와 팬텀 리드

중복된 row가 들어갔던 원인은 트랜잭션 격리 수준과 관련이 있었다.
InnoDB의 기본 격리 수준인 Repeatable Read에서는, 각 트랜잭션이 자신의 스냅샷(MVCC)을 기준으로 데이터를 조회한다.
시점
서버 A (Transaction A)
서버 B (Transaction B)
비고 (DB 상태 및 격리 수준)
t1
findByUser(597) 실행
ㅤ
[스냅샷 A 생성] 데이터 없음
t2
ㅤ
findByUser(597) 실행
⚠️ [스냅샷 B 생성] 데이터 없음
t3
INSERT (user_id=597) 실행
ㅤ
T1의 변경사항은 아직 Undo 로그에만 존재
t4
ㅤ
INSERT (user_id=597)실행
⚠️ [MVCC 격리] T1이 커밋 전이므로 B의 스냅샷에서는 여전히 데이터가 없는 것으로 판단
t5
COMMIT 완료
ㅤ
DB에 실제 반영
t6
ㅤ
COMMIT 완료
⚠️ 유니크 제약 없음 → 그대로 저장

실제 로그 기반 확인

실제 서버 로그를 대조한 결과, 위 시나리오와 동일한 흐름이 확인되었다.
  • 두 요청의 조회 시점이 수 ms 이내로 겹침, 두 요청 모두 existingToken=null로 판단
  • 이후 각각 INSERT 및 COMMIT 수행
 
이때, DB는 잘못된 동작을 한 것이 아니라, 격리 수준이 보장하는 일관된 스냅샷을 그대로 제공한 것이고 결과적으로 유니크 제약이 없었기에 중복 row가 삽입 되었다.
문제는 크게 두 가지였다.
  1. 유니크 제약 없이 비관적 락에 의존하였다. (JPA의 @OneToOne은 오로지 이미 중복된 데이터를 '읽을 때 에러를 내는' 역할)
  1. 애플리케이션 로직도 원자성을 보장하지 못하였다. (조회→삽입)
 
notion image
notion image
실제로 관련해서 에러 문의가 들어왔고, 사용자는 로그인에 실패하게 되었다.
 

장애 원인 검증 — 비관적 락이 중복 요청을 막지 못했던 이유

결론부터 말하면, 비관적 락 역시 낙관적 락과 마찬가지로 이미 존재하는 레코드의 대해서만 동작하며, row가 부재한 상황에서 발생하는 동시 INSERT 경쟁은 제어하지 못한다.
당시에는 비관적 락을 통해 동시성 제어가 가능할 것으로 판단했으나, 실제 테스트 결과 기대한 수준으로 동작하지 않는다는 점을 확인할 수 있었다.
 

테스트 검증 과정에서 발견한 문제점

Case 1) 기존 토큰이 존재할 때 — 동시 update 요청

동일 유저의 토큰을 여러 스레드가 동시에 갱신하는 상황에서, 데이터 정합성이 유지되는지 검증하였다.
notion image
결과는 DB에는 항상 단일 row만 유지되었으며, 최종 토큰 값은 가장 마지막 요청의 값으로 정상적으로 반영됨을 확인하였다.

⚠️❗ Case 2) 기존 토큰이 없는 상태에서 여러 insert 동시 요청

이번에는 row가 존재하지 않는 상황에서, 여러 요청이 동시에 들어올 때 중복 없이 직렬화되는지를 검증하였다.
기대한 결과는 1개의 요청만 INSERT 성공 후, 이후 요청은 row 존재를 확인한 뒤 UPDATE로 전환되는 것이다.
notion image
그러나 동일 user_id에 대해 다수의 row가 생성되었고, 데이터 정합성이 깨졌다.
 

비관적 락이 동작하지 않은 이유 — row 부재 상황에서의 InnoDB 판단

이 문제는 단순히 “락이 걸리지 않았다”가 아닌, InnoDB가 해당 쿼리에 대해 어떤 락 전략을 선택했는가의 문제로 생각했다.

1. 실행 계획(EXPLAIN) 검토

id
select_type
table
type
key
rows
Extra
1
SIMPLE
fcm_token
ALL
NULL
602
Using where
실행 계획 결과, 해당 쿼리는 인덱스를 사용하지 못하고 Full Table Scan으로 수행되었다.
  • user_id 기준으로 특정 레코드를 직접 지칭할 수 있는 인덱스가 없음
  • InnoDB 입장에서 어느 범위를 잠가야 할지 명확하게 정의할 수 없는 상태
즉, 조회 조건에 인덱스가 없으므로, 데이터가 존재하지 않는 시점에는 락을 걸 인덱스 페이지 자체가 존재하지 않아 락 획득 과정이 생략되었다.
 

2. 실제 락 정보 (performance_schema) 검토

ENGINE
OBJECT_NAME
INDEX_NAME
LOCK_TYPE
LOCK_MODE
LOCK_STATUS
INNODB
fcm_token
GEN_CLUST_INDEX
RECORD
X
ㅤ
  • 클러스터 인덱스(Primary Key)에 대한 Record Lock만 존재
  • Gap Lock / Next-Key Lock은 존재하지 않음
즉, 조회 조건(user_id=597)을 보호하는 락은 전혀 생성되지 않았고, 해당 값에 대한 INSERT를 차단할 수 없는 상태임을 확인할 수 있었다.
 

갭 락(Gap Lock)을 유도한다면?

앞서 조회 조건에 잠글 row가 없어 비관적 락이 무력화됨을 확인하였다. 그렇다면, 범위 조건을 변경하여 InnoDB가 갭 락을 생성하도록 유도하는 방법을 검토하였다.
갭 락은 일반적으로 범위 조건을 기반으로 인덱스 구간을 보호할 때 생성되므로, 쿼리를 범위 검색 형태로 변경하거나 인덱스를 재설계하면 동시 INSERT를 차단할 수 있을 것으로 기대할 수 있다.
그러나 이 접근에는 다음과 같은 한계가 존재한다.
  • 갭 락은 실행 계획과 옵티마이저 판단에 의존적이다. (동일한 쿼리라도 항상 동일한 락이 보장되지 않음)
  • 인덱스를 타지 못하는 경우, 범위 락이 과도하게 확장될 수 있다. 최악의 경우 넥스트 키 락이 테이블 전체 범위로 확장될 가능성이 존재한다.
  • 락 범위가 넓어질수록 트랜잭션 대기 및 성능 저하로 이어질 수 있다.
 

[1차 방안] 명시적 예외 처리 및 재시도 전략

이 구조의 핵심은 동시성 충돌을 예외가 아닌, 정상 흐름의 일부로 간주하는 것이다.
  • 동시 INSERT 충돌을 유니크 예외로 감지하고, 상위에서 처리하여 에러 전파를 차단
  • 재시도를 통해 선행 트랜잭션이 커밋된 이후에는 UPDATE로 전환
이 접근은 별도의 락 없이도, 멀티 서버 환경에서도 일관된 동작을 보장한다.
 
그러나 이 방식은 충돌을 해결하는 것이 아니라, 발생 이후 복구하는 구조라는 한계를 가진다.
  • 예외 발생 시 트랜잭션이 rollback-only 상태가 되므로, 재시도를 위해 REQUIRES_NEW로 별도 트랜잭션을 강제해야 한다. 트랜잭션 경계를 추가로 관리해야 하는 부담으로 이어진다.
  • 예외 생성 → 롤백 → 신규 트랜잭션 생성 → 재실행 과정이 반복되며, 경합이 잦을수록 불필요한 오버헤드가 누적된다.
 

[최종 방안] NativeQuery 기반 원자성 보장

최종적으로는 예외 기반 제어 대신, DB가 제공하는 Upsert 구문을 활용했다.
  • 조회 → 조건 분기 → 저장 과정을 제거하고, 단일 쿼리로 처리함으로써 RTT 1회로 감소
  • INSERT와 UPDATE를 하나의 원자적 연산으로 통합
  • 예외가 발생하지 않으므로 rollback-only 상태를 고려할 필요 없음
[주의점]
@Modyfing은 1차 캐시를 거치지 않고 직접 DB에 반영되므로, 대량의 데이터 처리나 단순 상태 변경 시 메모리 효율이 좋다.
그러나, JPA를 거치지 않고 DB에 접근하는 만큼, 같은 트랜잭션 내에서 해당 엔티티를 다시 조회할 경우 1차 캐시에 남아있는 과거 데이터를 보게 될 수 있다.
따라서, @Modifying(clearAutomatically = true) 설정으로 쿼리 실행 후 1차 캐시가 비워지도록 해야 한다.