home

동시 요청에서 정확한 순차번호 생성하기

애플리케이션을 개발하다 보면 다양한 요구사항을 마주하게 됩니다. 특히, 금융 도메인에서는 ‘정확한 값’, 혹은 ‘유일한 값’을 다뤄야 하는 상황이 자주 발생합니다.

최근에는 은행에 송금 요청을 보낼 때 ‘하루 단위로 유일하고 순차적인 전문번호를 생성해야 한다’ 라는 요구사항에 직면하였습니다.

단순히 생각하면 ‘DB에서 최근 번호를 조회한 후 1을 더해서 사용하면 되지 않을까?’라고 할 수 있지만 동시성 문제를 한 번이라도 겪어봤다면 다른 방법을 고안한게 됩니다. 위와 같은 요구사항에서 마주칠 수 있는 동시성 문제는 밑과 같습니다.

  1. 요청A: 번호 조회 → 10
  2. 요청B: 번호 조회 → 10 (아직 요청A가 완료되지 않음)
  3. 요청A: 11번으로 송금 요청 → 성공
  4. 요청A: DB 업데이트 → 마지막 번호 11로 변경
  5. 요청B: 11번으로 송금 요청 → 중복 번호로 오류 발생!

이런 상황을 막기 위해 많은 해결법이 존재합니다. 그중에서도

  1. 여러 요청이 동시에 와도 중복 없이 1씩 증가한 값을 얻어야 함
  2. 번호는 하루 단위로 초기화되어야 함.

이 두 가지의 요구사항에 초점을 맞춰 해결 방안들을 살펴보겠습니다.

옵션1: 증분 쿼리

첫 번째 방법은 데이터베이스의 원자적 연산 기능을 활용하는 것입니다.

증분 쿼리는 데이터베이스나 데이터 처리 워크플로우에서 이전 실행에서 이미 처리된 데이터는 제외하고, 최신에 추가된 데이터만을 선택하거나 처리하기 위한 쿼리 방식입니다.

데이터베이스의 원자적 연산 기능을 조합하면 잠금 없이 원자적으로 좋아요 수와 같은 필드를 안전하게 처리할 수 있습니다.

UPDATE transfer_sequence 
SET seq_number = seq_number + 1 
WHERE date_key = '20250812';

SELECT seq_number 
FROM transfer_sequence 
WHERE date_key = '20250812';

이렇게 하면 DB는 date_key20250812인 seq_number의 값을 1을 올리는 원자적 연산으로 처리를 합니다.

즉, 동일 데이터에 대한 원자적 연산이 동시에 실행될 경우 이를 순차적으로 실행된다는 거죠!

정리하자면 다음과 같습니다.

UPDATE … SET count = count + 1이나 INCR 명령과 같은 증분 연산은, 데이터베이스 내부에서 읽기, 연산, 쓰기를 하나의 원자 단위로 묶어서 수행한다. 따라서 락을 걸지 않아도 값이 유실되지 않는다.

트레이드 오프는 높은 동시성에서 락 대기 발생 가능성이 존재하고, 2번 요구사항을 위해 초기화 로직을 별도로 구현해야 합니다.

옵션2: DynamoDB Atomic Increment

다음은 DynamoDB입니다. DynamoDB에서도 숫자 속성의 값을 원자적으로 증가 혹은 감소시키는 “Atomic Counter” 기능을 제공합니다.

왜 “Atomic Counter”이라는 이름이냐 하면, 여러 클라이언트가 동시에 같은 속성에 증분 연산을 요청해도 DynamoDB는 각 요청을 직렬로 처리해서 경쟁 상태 없이 안전하게 값을 변경하기 때문입니다. 즉, 여러 요청이 동시에 발생해도 중복되거나 빠지는 숫자 없이 일관된 결과를 얻을 수 있습니다.

boto3를 사용하면 대충 이렇게 코드를 짤 수 있습니다.

import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('sequence_table')

response = table.update_item(
    Key={'date_key': '20250812'},
    UpdateExpression="ADD seq_number :incr",
    ExpressionAttributeValues={':incr': 1},
    ReturnValues="UPDATED_NEW"
)

current_sequence = response['Attributes']['seq_number']

ADD 연산은 새 값을 이전에 존재하던 값을 더하고, 각 update_item 연산이 원자적으로 실행되어, 여러 클라이언트가 동시에 값을 변경하더라도 값은 하나씩 증가하게 됩니다.

그런데 쓰기는 이렇게 원자적으로 보장을하는데, 읽기에서 지연시간 등으로 인해서 예전의 값을 보게되면 의미가 없게 되어버립니다. 그래서 DynamoDB에서는 강력한 읽기 일관성이라는 기능도 제공합니다.

강력한 읽기 일관성

DynamoDB의 기본적인 읽기 일관성은 Eventually Consistent Read 입니다. DynamoDB는 기본적으로 여러 읽기 복사본이 존재하기 때문에 최신 값을 복사하는 데 레이턴시가 존재합니다. 따라서 Eventually Consistent Read에서 읽는 값은 최신 값이 아닐 수도 있습니다. 하지만 읽는 속도가 더 빠르고 비용이 저렴하다는 장점이 있죠.

하지만 원자성이 더 중요한 데이터의 경우 Strongly Consistent Read 라는 것을 사용하면 됩니다. 항상 직전의 쓰기까지 반영된 최신 데이터를 반환합니다. 마치 master-slave로 이루어진 RDB에서 write instance를 통해서 읽기를 하는 것과 비슷합니다. 단점으로는 지연 시간이 늘어나고, 처치량도 낮을 뿐더러 비용도 비쌉니다.

특이사항으로는 GSI에는 적용이 안되고 LSI와 테이블에 가능합니다. 강력한 일관성 읽기를 사용하려면 다음과 같은 파라미터를 함께 요청하면 됩니다.

response = table.get_item(
    Key={'date_key': '20250812'},
    ConsistentRead=True  # 강일관성 읽기
)

옵션3: Redis의 INCR 명령어

마지막으로 Redis의 INCR 명령어는 Redis 서버 차원에서 원자성이 보장됩니다. 즉, 여러 클라이언트가 동시에 많은 키에 대해서 INCR를 수행해도 각 연산은 서버 내부적으로 직렬화되고, 경쟁상태 없이 정확하게 값이 1씩 증가합니다. 심지어 읽기 마저도 원자성을 보장해줍니다.

‘어떻게 원자성을 달성할 수 있느냐?’ 하면 두 가지 정도 꼽을 수 있습니다.

  1. Redis의 명령어 처리는 단일 스레드를 사용하기 때문에, 개별 명령어는 모두 하나의 단위 작업으로 서버 큐에서 직렬로 처리 되기 때문
  2. 값 조회 → 증가 → 저장 → 결과 반환 과정을 하나의 명령으로 처리하기 때문에, 결과적으로 각각의 INCR 명령이 끝난 뒤에만 값이 갱신되기 때문

즉, RDB처럼 별도의 SELECT와 UPDATE를 조합할 필요가 없어 더 안전하게 원자성이 보장됩니다.

사용법 또한 아주 간단합니다. 밑과 같이 한 줄로 처리됩니다.

import redis

client = redis.Redis(host='localhost', port=6379, db=0)

# 날짜를 키로 사용하여 일일 카운터 구현
date_key = '20250812'
sequence_number = client.incr(date_key)

print(f"오늘의 순차번호: {sequence_number}")

incr 명령을 쓰는 순간, 현재date_key를 조회하고 값을 업데이트 -> 이후에 값을 저장 한 뒤 증가된 값을 count로 돌려줍니다.

이렇게 쉬운 로직으로 빠르고 간단하게 요구사항을 해결할 수 있고, TTL을 사용해서 손쉽게 키를 자동으로 만료시킬 수도 있습니다.

하지만 단점 또한 당연히 존재하는데, redis에 대한 의존성이 생기므로 redis를 잘 관리하지 않는다면 단일 실패 지점이 생길 수도 있다는 위험이 있습니다.

필자의 선택: Redis

저는 해결 방법으로 Redis를 선택했습니다. 이유는 밑과 같습니다.

  1. 원자적 증분과 일일 초기화(1번, 2번 요구사항) 모두 간단하게 구현 가능이 가능하다.
  2. 간단함에 비해 빠른 응답 시간을 보장한다.

만약 Redis를 사용할 수 없는 환경이었다면 다음과 같이 구현할 계획이었습니다

  1. 전문번호 테이블 생성
  2. 송금 요청 시 날짜별 레코드 UPDATE
  3. UPDATE 결과를 송금 요청에 포함

위와 같은 방법으로 1번 요구사항은 정말 간단하게 해결이 가능했지만, 2번 요구사항은 문제점이 존재합니다.

00시 00분에 정확하게 날짜에 맞는 전문번호 레코드를 미리 생성해두지 않는 이상, 자정에 동시 송금 요청이 들어올 때 심각한 경합 상황이 발생합니다.

자정(00:00:00) 상황:
요청 A: UPDATE transfer_sequence SET seq_number = seq_number + 1 WHERE date_key = '20250813'
요청 B: UPDATE transfer_sequence SET seq_number = seq_number + 1 WHERE date_key = '20250813'

문제 1: 레코드가 없어서 둘 다 0개 행 업데이트됨
해결 시도: INSERT 문으로 레코드 생성

문제 2: 동시에 INSERT 시도 → 중복 키 오류로 송금 실패!
문제 3: 제약조건으로 막으면 → 한 쪽 송금이 실패

이 문제를 해결하려면 결국 DB 레벨 락이나 Redis 분산 락을 사용하여 별도의 로직을 생성하거나, 미리 레코드를 생성하는 배치작업을 추가해야합니다.

결국 핵심적인 비즈니스 로직인 송금이 내부적인 기술 문제로 실패하는 것이, 외부 의존성을 추가하는 것보다 훨씬 더 큰 리스크라고 판단하여서 Redis를 선택하였습니다.

다른 환경과 요구사항에서는 다른 방법이 더 적절할 수 있고, 이 선택이 최고의 선택은 아닙니다. 늘 이런 선택에 있어서 가장 중요한 것은 적절한 균형점과 타협 이라는 것을 이번 기회에 한번 더 느끼게 됩니다.