본문 바로가기
Dev

분산 시스템에서 동시성 제어와 멱등성을 어떻게 보장할까?

by Day-T 2026. 3. 27.

해외 송금 시스템의 핵심은 데이터 정합성이다. 고객의 통장에서 돈이 출금되고, 환전이 이루어지며, 해외 파트너망으로 전송되기까지의 모든 과정이 톱니바퀴처럼 한 치의 오차 없이 맞물려야 한다.

 

문제는 이 시스템이 국경을 넘어 여러 네트워크를 거친다는 점이다. 네트워크 단절이나 타임아웃이 빈번한 환경에서 중복 출금을 막고 장애를 방지하려면 프레임워크가 제공하는 편리한 추상화 기능에만 의존해서는 안 된다.

 

이번 글에서는 프레임워크 이면의 근본적인 동작 원리를 바탕으로 어떻게 동시성 제어와 멱등성을 설계해야 하는지 깊이 있게 파헤쳐 보자.


1. 동시성 제어

다수의 스레드가 공유 자원을 읽고, 수정하고, 다시 쓰는 과정에서 OS 커널의 선점형 스케줄러가 개입하면 어떻게 될까?

 

연산이 무작위로 교차하며 데이터의 정합성이 산산조각 나는 경쟁 상태(Race Condition)가 발생한다. 이를 통제하는 것이 동시성 제어의 핵심이다.

 

 

① 애플리케이션 레벨 제어: Synchronized와 Spring AOP의 충돌

 

Java의 synchronized 키워드를 사용하면 JVM은 OS 레벨의 Mutex(또는 Futex)를 호출해 스레드를 블로킹한다. 이 과정에서 발생하는 User-to-Kernel 모드 전환은 불필요한 CPU 사이클 낭비를 초래한다.

 

더 치명적인 문제는 Spring의 CGLIB 프록시 기반 @Transactional과 만났을 때 발생한다.

 

비즈니스 로직이 끝나는 순간 synchronized의 모니터 락은 즉시 해제되지만, 트랜잭션 프록시가 DB 커넥션에 물리적인 COMMIT을 수행하기까지는 필연적인 시간차가 존재한다. 이 찰나의 간극을 뚫고 타 스레드가 진입하면 아직 커밋되지 않은 과거 데이터를 읽어버리는 더티 리드가 발생한다.

 

그렇기에 해당 방법은 다중 서버 환경의 스케일 아웃을 논하기전에 단일 노드에서조차 원자성을 파괴하는 대표적인 안티 패턴이다.

 

 

② 비관적 락과 PostgreSQL MVCC

 

한 번 데이터가 꼬이면 롤백 비용이 기하급수적으로 커지는 금융 도메인에서는 RDBMS 엔진 레벨에서 배타적 락을 강제하는 것이 정석이다.

 

MySQL InnoDB가 메모리에 별도의 락 구조체를 두는 것과 달리, PostgreSQL은 실제 디스크나 버퍼 캐시에 적재된 데이터 페이지 내부의 튜플 헤더(xmax 트랜잭션 ID) 값을 직접 갱신하여 RowShareLock을 구현한다.

SELECT ... FOR UPDATE 쿼리가 인입되면 타 트랜잭션은 xmax에 기록된 트랜잭션이 끝날 때까지 Wait Queue에 강제로 블로킹된다.

 

이 방식은 메모리 락 테이블 초과로 인한 락 에스컬레이션 리스크는 없지만, 락 획득 자체가 물리적 디스크 Write(xmax 갱신)를 동반한다는 점을 반드시 명심해야 한다.

 

롱 트랜잭션은 곧 DB 커넥션 풀 고갈과 전체 처리량 감소로 직결되므로, 락을 쥐고 있는 트랜잭션의 바운더리는 최대한 작게 통제해야 한다.

 

 

③ 낙관적 락의 함정

낙관적 락은 물리적 DB 락을 배제한 Lock-free 방식이다.

 

CPU 레벨의 원자적 명령어인 CAS(Compare-And-Swap) 개념을 쿼리로 풀어내어 업데이트 시점에 버전 일치 여부만 판별(WHERE id = ? AND version = ?)한다.

 

블로킹이 없어 빠르다는 장점이 있지만, 송금 시스템처럼 동시성 경합이 빗발치는 도메인에 적용하는 순간 오히려 시스템을 마비시키는 원인이 된다. 버전 충돌로 튕겨 나간 수많은 트랜잭션들이 애플리케이션 스레드 풀을 점유한 채 지수 백오프를 먹이며 재시도를 폭격하기 때문에 결국 RDBMS의 CPU 스파이크를 유발하게 된다.

 

 

④ 분산 락과 Redisson

 

원천 DB의 커넥션 고갈을 막기 위해, 임계 구역 제어 역할을 인메모리 락 매니저인 Redis로 격리시키는 전략이다.

 

이때 Spring의 기본 클라이언트인 Lettuce를 사용해 SETNX 명령을 무한 루프로 쏘아대는 스핀 락을 구현하면 싱글 스레드 기반인 Redis의 이벤트 루프에 심각한 CPU 오버헤드가 발생한다.

 

정답은 Redisson입니다.

 

원자적 처리를 보장하는 Lua Script와 Redis의 Pub/Sub 메커니즘을 결합한 Redisson은 락 획득 실패 시 스레드를 OS 레벨에서 대기 상태로 전환한다. 이후 락 해제 이벤트가 발행(Publish)되면 이를 구독(Subscribe)하여 깨어나므로 네트워크 I/O 병목과 Redis CPU 부하를 획기적으로 최적화할 수 있다.

 

 

💡 비관적 락 한계 극복

 

① 교착 상태(Deadlock)의 논리적 파훼

OS 데드락 발생 4대 조건 중 순환 대기를 애플리케이션 로직으로 끊어낸다.

 

A→B, B→A 동시 이체 시 발생하는 교착 상태는 계좌 PK의 대소 관계를 판별하여 반드시 식별자가 작은 순서대로 락을 획득하도록 정렬을 강제함으로써 사이클을 방지할 수 있다.

 

 

② JPA 1차 캐시로 인한 갱신 유실(Lost Update)

JPA는 1차 캐시를 통해 애플리케이션 레벨에서 Repeatable Read 격리 수준을 에뮬레이션한다.

 

만약 비관적 락 획득 전 일반 findById로 엔티티를 캐시에 적재해 버리면, 이후 락을 동반한 조회를 하더라도 JPA는 영속성 컨텍스트에 쥐고 있던 '과거 스냅샷'을 반환해 버린다.

 

이는 타 트랜잭션의 커밋을 덮어씌우는 갱신 유실로 직결되므로 데이터 로드 페이즈의 첫 단추부터 무조건 락 획득 쿼리를 강제하여 1차 캐시 오염을 원천 차단해야 한다.


2. 멱등성

멱등성이란 f(f(x)) = f(x)라는 수학적 정의처럼 연산을 여러 번 중복해서 수행해도 시스템의 최종 상태가 1회 수행한 것과 동일하게 유지되는 성질을 말한다.

 

글로벌 뱅킹망을 통신하는 송금 API는 네트워크 파티션으로 인한 클라이언트 측의 타임아웃 재시도 부하를 항시 고려해야 한다. 무거운 RDBMS 트랜잭션 롤백 없이 데이터의 일관성을 유지하려면 아키텍처 레벨의 다중 방어막이 필수적이다.

 

 

 2-Tier 이중 방어막 아키텍처 (Redis + PostgreSQL)

 

RDBMS의 UNIQUE 제약 조건이라는 단일 방어망에만 기대면 어떻게 될까?

 

사용자의 무차별적인 동시 재시도 트래픽(일명 '따닥')이 디스크 기반의 B-Tree 인덱스를 미친 듯이 탐색하게 만들고 순식간에 DB 커넥션 풀과 CPU를 고갈시킨다. 따라서 메모리 I/O와 디스크 I/O를 물리적으로 분리하는 2-Tier 방어 아키텍처가 필요하다.

 

Tier 1 (Redis): O(1)의 메모리 접근 속도와 SETNX 명령어는 원자성을 보장한다. 짧은 TTL을 세팅하여 네트워크 지연으로 발생한 중복 요청을 메모리 I/O 단계에서 빠르게 쳐낸다(Fast-fail). 무의미한 DB 트랜잭션 자체를 아예 열지 않는 것이 핵심이다.

 

Tier 2 (PostgreSQL): transaction_request_id 기반의 Unique Index를 구성한다. 혹여 Redis에 일시적인 장애가 발생하거나 TTL 만료 후 유입되는 늦은 트래픽이 있더라도 PostgreSQL 엔진이 인덱스 페이지 삽입 단계에서 물리적인 키 충돌을 감지하고 안전하게 튕겨낸다.

 

 

 

 

 비동기 EDA 환경의 순서 역전 방어

외부 파트너망의 콜백을 Kafka 같은 이벤트 큐로 비동기 처리하는 환경에서는 파티션 분산 및 리밸런싱 과정에서 필연적으로 이벤트의 순서가 꼬이는 현상이 발생한다. 송금 취소 이벤트가 송금 승인 이벤트보다 컨슈머에 먼저 도달해버리는 상황이 대표적이다.

 

이를 제어하기 위해 이벤트 Payload에 파트너망 발송 시점의 논리적 시계 역할을 하는 타임스탬프를 삽입한다.

 

컨슈머는 PostgreSQL에 기록된 도메인 상태 레코드의 타임스탬프와 큐에서 꺼낸 이벤트의 타임스탬프를 대조한다. 인입된 이벤트가 DB에 기록된 시간보다 '과거의 이벤트'로 판명될 경우 도메인 로직 실행을 우회(Bypass)하고 컨슈머 오프셋만 전진시켜 메시지를 안전하게 폐기한다.


Self Dive

Q1. synchronized와 @Transactional을 함께 사용할 때 발생하는 동시성 누수(Race Condition)에 대해 OS 스레드와 트랜잭션 생명주기 관점에서 설명해 주세요.

Spring @Transactional은 CGLIB 프록시 기반으로 동작합니다.

비즈니스 메서드가 종료되는 순간 JVM의 synchronized 모니터 락은 즉시 해제되지만, 프록시가 DB 커넥션을 통해 물리적인 COMMIT 명령을 내리기까지는 필연적인 시간차가 존재합니다.

이 락 해제와 커밋 사이의 간극에 다른 OS 스레드가 락을 획득하고 진입하면, 아직 커밋되지 않은 과거 데이터를 읽게 되어 갱신 유실(Lost Update)이 발생합니다.

 

 

Q2. 금융 도메인에서 비관적 락을 표준으로 삼은 이유는 무엇이며, PostgreSQL에서는 이 락을 어떻게 물리적으로 구현하고 있나요?

송금 코어는 롤백 비용이 극도로 높으므로, 충돌을 사전에 차단하는 비관적 락이 적합합니다.

PostgreSQL은 SELECT ... FOR UPDATE
실행 시 메모리에 락 테이블을 유지하는 대신,


실제 디스크/버퍼의 데이터 페이지 내 튜플 헤더의 xmax(트랜잭션 ID) 필드를 직접 갱신하여 배타적 락(RowShareLock)을 구현합니다. 타 트랜잭션은 이 xmax를 확인하고 해당 트랜잭션이 끝날 때까지 Wait Queue에서 대기합니다.

 

 

Q3. 낙관적 락을 송금 코어 로직에 적용했을 때 발생할 수 있는 부작용은 무엇인가요? 데이터베이스 리소스 관점에서 설명해 주세요.

송금 도메인처럼 동시성 경합이 빈번할 때 낙관적 락을 쓰면 OptimisticLockException이 다수 발생합니다.

이를 처리하기 위해 애플리케이션 스레드가 지수 백오프 등으로 재시도를 반복하게 되며, 이는 수많은 무의미한 Read/Update 쿼리 폭격을 발생시켜 RDBMS의 커넥션 풀을 고갈시키고 CPU 스파이크를 유발합니다.

 

 

Q4. 분산 락 구현 시 Lettuce 대신 Redisson을 선택하셨습니다. 스핀 락과 Pub/Sub 방식의 차이점을 Redis 이벤트 루프와 OS 스레드 관점에서 설명해 주세요.

Lettuce는 SETNX를 루프를 돌며 계속 호출하는 스핀 락 방식이라, 싱글 스레드로 동작하는 Redis의 이벤트 루프를 블로킹하고 엄청난 네트워크 I/O 부하를 줍니다.

반면 Redisson은 Pub/Sub 방식을 사용하여, 락 획득 실패 시 OS 스레드를 대기(Sleep/Blocking) 상태로 전환합니다. 락이 해제될 때 Redis가 이벤트를 발행(Publish)하면 그때 스레드를 깨워 재시도하므로 CPU와 네트워크 리소스를 극적으로 최적화합니다.

 

 

Q5. 교착 상태를 애플리케이션 로직으로 파훼하기 위해 '락 정렬'을 강제했다고 하셨습니다. 이것이 OS 데드락 4대 조건 중 어떤 것을 깨뜨리는 것이며, 구체적으로 코드를 어떻게 작성해야 하나요?

OS 데드락 4대 조건 중 '순환 대기' 조건을 파괴한 것입니다.
항상 자원(계좌 PK)의 대소 관계를 판별하여 일관된 순서로 락을 획득하게 강제하면 사이클이 발생하지 않습니다.

Long firstId = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);

accountRepository.findByIdWithPessimisticLock(firstId); accountRepository.findByIdWithPessimisticLock(secondId);

 

 

Q6. JPA를 사용할 때 비관적 락을 걸기 전 일반 findById로 엔티티를 먼저 조회하면 어떤 문제가 발생하나요? 영속성 컨텍스트(1차 캐시)의 동작 원리와 연관 지어 설명해 주세요.

JPA는 영속성 컨텍스트(1차 캐시)를 통해 애플리케이션 레벨의 Repeatable Read를 보장합니다.

일반 findById로 엔티티를 조회하면 1차 캐시에 저장되며, 동일 트랜잭션 내에서 findByIdWithPessimisticLock을 호출하여 DB에서 최신 데이터를 가져오더라도, JPA는 결과셋을 무시하고 1차 캐시에 있던 과거 스냅샷을 반환합니다.

결국 과거 데이터를 기반으로 덮어쓰기가 발생하므로 데이터 최초 로드 시점부터 비관적 락 쿼리를 사용해야 합니다.

 

 

Q7. 글로벌 뱅킹망과 같이 타임아웃이 잦은 환경에서 멱등성 보장이 왜 필수적인가요? 트랜잭션 롤백만으로는 해결할 수 없는 이유를 클라이언트 입장에서 설명해 주세요.

네트워크 타임아웃 발생 시, 서버 내부적으로 트랜잭션이 성공했는지 실패(롤백)했는지 클라이언트는 알 수 없습니다.

성공한 응답이 네트워크 유실로 클라이언트에게 도달하지 못했을 때, 클라이언트는 성공 시까지 맹목적인 재시도를 수행합니다. 멱등성이 보장되어야만 서버가 이 중복 트랜잭션을 다시 실행하지 않고 이전에 캐싱해 둔 성공 응답만 안전하게 반환할 수 있습니다.

 

 

Q8. 멱등성을 보장하기 위해 Redis와 PostgreSQL을 결합한 2-Tier 아키텍처를 설계하셨습니다. 단일 RDBMS의 UNIQUE 제약조건만 사용하지 않고 굳이 Redis를 1-Tier로 둔 이유는 무엇인가요?

단일 RDBMS의 UNIQUE 제약조건에만 의존하면, '따닥' 같은 초단기 폭주 트래픽이 디스크 기반의 B-Tree 인덱스를 탐색하기 위해 DB 커넥션을 맺고 CPU 연산을 수행하게 됩니다.

Redis를 앞단에 두면 O(1) 시간 복잡도를 가지는 메모리 I/O(SETNX)를 통해 DB 커넥션을 맺기 전에 요청을 즉각 튕겨내는 역할을 수행할 수 있습니다.

 

 

Q9. Redis에 장애가 발생하여 1-Tier 방어막이 뚫리거나, 3초 TTL이 만료된 후 5초 뒤에 중복 요청이 들어온다면 시스템은 이를 어떻게 방어하나요?

Redis는 성능 최적화를 위한 수단일 뿐, 최종적인 데이터 무결성은 RDBMS가 보장합니다.

핵심 거래 원장 테이블의 transaction_request_id 컬럼에 설정된 UNIQUE 인덱스에 의해, PostgreSQL 엔진이 데이터 페이지 삽입 단계에서 물리적인 키 충돌을 검출하고 DataIntegrityViolationException을 발생시켜 완벽하게 물리적 중복을 차단합니다.

 

 

Q10. EDA 환경에서 '송금 승인'보다 '송금 취소' 이벤트가 Consumer에 먼저 도달하는 순서 역전 현상을 어떻게 방어하나요?

이벤트 발행 시 생성한 논리적 시계 또는 타임스탬프를 페이로드에 포함시킵니다.

Consumer는 트랜잭션 수행 전 DB에 기록된 도메인 엔티티의 최신 이벤트 시간과 인입된 이벤트의 시간을 대조합니다. 인입된 이벤트가 더 과거의 낡은 이벤트(Stale Event)라면 비즈니스 로직 처리를 건너뛰고(Bypass) Kafka Offset만 커밋한 뒤 메시지를 폐기(Discard)하여 결과적 일관성을 유지합니다.