WORK EXPERIENCE

전자 결재 시스템

승인권자가 모바일 앱에서 승인하거나 반려하는 전자 결재 시스템

Java, Spring, MySQL

아키텍처

Before - Lost Update 상황Approver AMySQLApprover BSELECT 상태 조회status: 대기SELECT 상태 조회status: 대기UPDATE 반려UPDATE 승인Lost Update마지막 요청이 덮어씀After - 조건부 UPDATE 적용Approver AMySQLApprover BUPDATE WHERE 상태=대기record lockaffected rows: 1UPDATE WHERE 상태=대기affected rows: 0충돌 예외 처리중복 처리 차단Lost Update 방지

개요

  • 기안자가 결재를 상신하고 승인권자가 모바일에서 승인하거나 반려하는 전자 결재 시스템

문제

  • 여러 승인권자가 동일 결재를 동시에 처리하면서 Lost Update 발생
  • SELECT로 상태를 조회한 뒤 UPDATE하는 사이 다른 요청이 상태를 변경할 수 있는 구조
  • 두 요청이 모두 '승인 대기'를 읽으면 다른 쪽 승인 요청이 반려 요청을 덮어써 최종 상태가 뒤집힘

해결 전략

  • WHERE status = '승인 대기' 조건을 포함한 단일 UPDATE 사용
  • MySQL UPDATE가 조건에 맞는 인덱스 레코드에 record lock을 걸어 동시 요청을 직렬화
  • affected rows가 0이면 이미 처리된 결재로 판단

기술 선택 이유

  • Redis 분산 락 - 제외

    • 락 획득과 해제, TTL 관리가 추가되고 일관성 제어 지점이 DB와 Redis로 분산됨
  • 비관적 락 (SELECT ... FOR UPDATE) - 제외

    • 행을 미리 선점해 확실하지만, 경합 시 대기 시간이 길어지고 커넥션 점유가 늘어남
  • 낙관적 락 - 제외

    • version 컬럼 추가와 충돌 시 재시도와 예외 변환 비용이 필요
  • 조건부 단일 UPDATE - 채택

    • 별도 락 저장소나 컬럼 추가 없이 동시 요청 제어

검증

  • 동일 결재 건에 승인과 반려를 다중 스레드에서 별도 트랜잭션으로 같은 시점에 요청해 재현
  • 두 요청 중 먼저 상태를 변경한 1건만 성공, 후속 요청은 affected rows 0으로 예외 처리
  • 최종 상태가 승인 또는 반려 중 하나로 남아 후속 요청의 덮어쓰기 차단 확인

회고

  • affected rows 0을 이미 처리된 결재로 판단. 존재하지 않는 결재도 같은 결과가 나온다는 점은 고려하지 못했음
  • 비관적 락 제외 근거로 든 대기 시간과 커넥션 점유는 수치 비교 없이 판단한 값임