추석 이벤트 무료 이용 기간 지급 기능 개발 회고

Series: 회고

회고contains 2

들어가며

추석이 얼마 남지 않았다. 회사에서 추석 이벤트로 무료 이용 기간을 지급해달라는 요청을 받았다. 대상은 1년 이내 결제 이력이 있는 고객이었다. 이벤트 내용 자체는 단순했다. 최근 1년 안에 결제한 고객을 조회하고, 해당 고객들에게 정해진 무료 이용 기간을 지급하면 되는 작업이었다. 요청받은 내용을 정리하면 다음과 같았다.

대상: 1년 이내 결제 이력이 있는 고객
시행 일시: 2025.10.06(월) 추석 당일 02시
처리 내용:  대상 고객에게 추석 이벤트 무료 이용 기간(3개월) 지급

이번 글에서는 추석 이벤트 무료 이용 기간 지급 기능을 개발하면서 어떤 문제를 해결하려고 했는지, 왜 직접 DB 수정 방식 대신 기능으로 만들었는지, 그리고 중복 지급과 실패 재처리를 막기 위해 어떤 구조로 구현했는지 정리해보려 한다.

1. 배경

처음에는 크게 복잡한 작업이라고 생각하지 않았다. 누군가가 매년 해왔던 일이었고, 대상 조건과 지급해야 하는 값도 정해져 있었기 때문이다. 사용자 목록을 조회하고, 해당 사용자의 이용 기간을 늘린 후 이력을 남기는 작업으로 파악이 되었기 때문이다. 기존의 구조 파악을 마친 후 기존에는 이런 이벤트성 지급 작업을 어떤 방식으로 처리했는지 질문하였고, 그 답변을 전달받고 나서 생각이 달라졌다.

잠깐, 추석 당일 02시에 데이터베이스에 접속해서 직접 쿼리를 날려야 한다고?

더군다나 왜 새벽 2시로 정한 것인지도 궁금했는데 그 이유는 새벽 2시면 대부분의 고객이 이용하지 않는 시간일 것이라는 추측이었기 때문이었다. 조금 충격이었다. 운영 중인 서비스에서 사용자의 이용 기간은 결제와 직접 연결된 민감한 데이터이다. 그런데 이 값을 이벤트 당일 정해진 시간에 사람이 직접 데이터베이스에 접속해서 수정해야 한다는 것은 꽤 위험한 방식이라고 느껴졌다. 물론 직접 쿼리를 실행하는 방식이 가장 빠를 수는 있다. 별도의 기능을 만들 필요도 없고 대상자 조회 쿼리와 업데이트 쿼리만 준비하면 당장 이벤트를 처리할 수 있다. 하지만 빠른 방식과 안전한 방식은 엄연히 다르다고 생각한다.

조건절 하나가 잘못되면 대상자가 달라질 수 있다. 이미 지급된 사용자에게 다시 지급될 수도 있고, 반대로 지급되어야 할 사용자가 누락될 수도 있다. 중간에 쿼리가 실패하면 어디까지 반영되었는지 다시 확인해야 한다. 무엇보다 추석 당일 02시에 사람이 직접 처리해야 한다는 점 자체가 운영 리스크라는 생각이 들었다. 그래서 기획팀, 대표님과 협의를 진행하면서 데이터베이스의 수치를 직접 수정하는 방식이 가진 위험성을 공유했다. 그리고 이 작업이 단순한 일회성 작업이 아니라 앞으로도 반복될 수 있는 운영 업무라는 것을 듣고 그들을 설득하기 시작했다.

자동화와 안정성 모두 확보하는 방향으로 아예 기능을 개발하면 어떨까요?
추석 1주일 전까지 모든 기능 개발과 테스트를 마친 후 운영 배포 완료하겠습니다

결국 이번 작업은 단순히 개발자가 직접 무료 이용 기간을 지급하는 작업에서 끝내지 않고, 관리자에서 이벤트를 등록하면 시스템이 예약된 시점에 대상자를 추출하고 무료 이용 기간을 지급하는 기능으로 개발하기로 했다.

1. 직접 DB를 수정하는 방식의 문제

직접 쿼리로 처리하는 방식이 항상 잘못된 것은 아니다. 긴급 대응이나 일회성 보정 작업에서는 필요한 경우도 있다. 하지만 반복되는 운영 업무를 계속 쿼리로 처리하는 것은 위험하다고 생각한다. 가장 큰 문제는 실수 가능성이다. 조건절 하나가 잘못되면 대상자가 달라진다.

UPDATE users
SET expired_at = expired_at + INTERVAL '7 days'
WHERE paid_at >= NOW() - INTERVAL '1 year';

위 쿼리는 예시일 뿐이지만, 실제 운영 데이터에서는 조건이 훨씬 복잡할 수 있다. 결제 취소 여부, 환불 여부, 이미 지급된 이벤트인지 여부, 회원 상태 등을 함께 고려해야 할 수 있다. 이때 조건 하나가 빠지면 원하지 않는 사용자에게 지급될 수 있다. 두 번째 문제는 재실행이다. 쿼리를 실행하다가 중간에 실패했을 때 어디까지 반영되었는지 확인해야 한다. 일부 사용자에게만 지급된 상태라면, 다시 실행했을 때 이미 지급된 사용자에게 중복 지급되지 않도록 멱등성을 보장해야한다. 세 번째 문제는 추적이다. 실제로 고객 CS로 이용 기간에 대한 문의가 들어오는 상황이므로, 운영팀 입장에서는 어떤 이벤트로 누구에게 언제 지급되었는지 확인할 수 있어야 한다. 하지만 단순 UPDATE 쿼리만 실행하면 지급 이력이 명확하게 남지 않을 수 있다. 나중에 고객 문의가 들어왔을 때 '이 사용자가 왜 이용 기간이 늘어났는지'를 확인하기 어려워진다. 이번 기능을 개발할때 가장 중요하게 생각한 부분은 이 세 가지였다.

1. 잘못된 대상에게 지급되지 않아야 한다.
2. 같은 사용자에게 중복 지급되지 않아야 한다.
3. 누가, 어떤 이벤트로, 언제 지급되었는지 추적할 수 있어야 한다.

단순히 수치를 바꾸는 기능이 아니라 이벤트 작업 자체를 안전하게 관리할 수 있는 구조가 필요했다.

2. 구조 설계

전체 구조는 다음과 같이 설계했다.

flowchart TB
    A[관리자] --> B[이벤트 등록]
    B --> C[(event 테이블)]
    B --> D[(event_free_period_config 테이블)]

    E[AWS EventBridge Scheduler] -->|1시간마다 실행| F[NestJS ECS Task]

    F --> C
    F --> D
    F --> G[(event_free_period_grant 테이블)]
    F --> H[Queue Produce]

    H --> I[ECS Service<br/>Queue Processor]

    I --> J[사용자 이용 기간 조회]
    I --> K[사용자 이용 기간 증가]
    I --> L[지급 결과 저장]
    I --> G

관리자는 관리자 페이지에서 이벤트를 등록할 수 있다. 이벤트의 공통 정보는 event 테이블에 저장하고, 무료 이용 기간 지급에 필요한 세부 설정은 event_free_period_config 테이블에 저장했다. 이렇게 테이블을 분리한 이유는 event 테이블을 이벤트의 공통 모델로 두고 싶었기 때문이다. 이벤트 이름, 설명, 상태, 예약 시각 같은 값은 이벤트 종류와 관계없이 공통으로 사용할 수 있다. 반면 지급할 무료 이용 기간, 기준 결제 시작일, 기준 결제 종료일은 무료 이용 기간 지급 이벤트에만 필요한 설정이다. 그래서 이 값들은 event_free_period_config 테이블로 분리했다.

이후 AWS EventBridge Scheduler가 1시간마다 NestJS 기반 ECS Task를 실행한다. 이 ECS Task는 예약된 이벤트를 조회하고, 처리 가능한 이벤트를 찾아 대상자를 추출한 뒤 event_free_period_grant 테이블에 저장한다. 그리고 각 대상자에 대해 Queue job을 발행한다. AWS EventBridge Scheduler로 ECS Task를 실행하도록 설계한 이유는 단순하다. 스케줄링을 위해 별도의 서버를 상시 운영하는 것은 리소스 측면에서 비효율적이라고 판단했기 때문이다.

실제 무료 이용 기간 지급은 별도의 ECS Service에서 처리한다. Queue Processor는 job을 하나씩 소비하면서 사용자의 현재 이용 기간을 조회하고, 기간을 증가시키고, 지급 결과를 event_free_period_grant 테이블에 기록한다. 생산자와 처리자를 분리한 이유는 이벤트 대상자가 많아질 수 있기 때문이다. 이벤트 하나에 수천 명 또는 수만 명의 대상자가 있을 수 있는데, 이 모든 사용자의 이용 기간을 하나의 Task에서 한 번에 처리하면 작업 시간이 길어지고 실패했을 때 복구도 어려워진다. 그래서 이벤트 대상자를 추출하는 작업과 실제 지급 작업을 분리했다. 여기까지 읽어보면 한 가지 의문이 들 수도 있다.

스케줄링을 위해 별도의 서버를 상시 운영하는 것이 비효율적이라고 판단해 ECS Task를 채택했는데, Queue Processor를 ECS Service로 상시 운영한다면 결국 같은 상황 아닌가?

이 구조에서 핵심은 Queue Processor라고 볼 수 있다. 즉, 작업을 실제로 처리하는 Processor는 항상 실행 상태를 유지해야 한다. 반면, Queue를 생성(Produce)하는 역할은 반드시 상시 실행될 필요가 없다. 따라서 Queue를 어떻게 생성할 것인가를 고민하는 과정에서, 별도의 서버를 상시 운영하기보다는 필요한 시점에만 실행되는 스케줄링 방식(ECS Task)이 더 적합하다고 판단했다.

3. 테이블 설계

이번 기능에서는 크게 세 개의 테이블을 사용했다.

event: 이벤트의 공통 정보를 관리하는 테이블
event_free_period_config: 무료 이용 기간 지급 이벤트의 설정을 관리하는 테이블
event_free_period_grant: 이벤트별 사용자 지급 대상과 지급 결과를 관리하는 테이블

테이블을 나눌 때 가장 중요하게 본 것은 책임의 분리였다. event는 이벤트 자체의 상태를 관리하고, event_free_period_config는 무료 이용 기간 이벤트의 조건과 지급일 수를 관리한다. 그리고 event_free_period_grant는 실제로 어떤 사용자에게 지급이 시도되었고, 성공했는지 또는 실패했는지를 관리한다. 이 글에서 표의 UNIQUE 값은 제약 그룹을 의미한다. 예를 들어 같은 숫자가 여러 컬럼에 적혀 있다면, 해당 컬럼들이 하나의 unique 제약을 구성한다는 의미이다.

3.1. event

event 테이블은 이벤트의 공통 정보를 저장하는 테이블이다.

컬럼명타입NULLABLEUNIQUE설명
iduuid(PK)NOT NULL1이벤트 ID
created_byuuid(FK)NULLABLE관리자 ID
typevarcharNOT NULL이벤트 타입(FREE_PERIOD)
namevarcharNOT NULL이벤트 이름
descriptionvarcharNULLABLE이벤트 설명
statusvarcharNOT NULLRESERVED, PRODUCING, PRODUCED, COMPLETED
scheduled_attimestamptzNOT NULL이벤트 실행 시각
produced_attimestamptzNULLABLE대상자 생성 완료 시각
created_attimestamptzNOT NULL생성 시각
updated_attimestamptzNOT NULL수정 시각

event 테이블에서 가장 중요한 컬럼은 type, status, scheduled_at이다. type은 이벤트의 종류를 나타낸다. 이번 기능에서는 무료 이용 기간 지급 이벤트만 다루기 때문에 FREE_PERIOD를 사용했다. 이벤트 공통 테이블을 따로 둔 이유는 나중에 다른 종류의 이벤트가 추가될 가능성을 고려했기 때문이다. 예를 들어 쿠폰 지급, 포인트 지급 같은 이벤트가 생기더라도 이벤트의 공통 정보는 event 테이블에서 관리할 수 있다. scheduled_at은 이벤트가 실행되어야 하는 시각이다. 이번 추석 이벤트의 경우 KST 기준으로 2025.10.06(월) 02시, UTC 기준으로 2025.10.05(일) 17시가 된다. EventBridge Scheduler는 1시간마다 ECS Task를 실행하고, Task는 scheduled_at을 기준으로 실행 대상 이벤트를 조회한다. status는 이벤트의 처리 상태를 나타낸다.

RESERVED: 이벤트가 등록되었고, 아직 대상자 생성이 시작되지 않은 상태
PRODUCING: 이벤트 대상자를 추출하고 Queue job을 생성하는 중인 상태
PRODUCED: 이벤트 대상자 생성과 Queue job 발행이 완료된 상태
COMPLETED: 이벤트에 속한 지급 건이 모두 완료된 상태

여기서 PRODUCEDCOMPLETED를 분리한 것이 중요하다. PRODUCED는 대상자 생성과 Queue 발행이 끝났다는 의미이지, 모든 사용자에게 무료 이용 기간이 지급되었다는 의미는 아니다. 실제 사용자별 지급 성공 여부는 event_free_period_grant 테이블에서 관리한다. produced_at은 대상자 생성이 완료된 시각이다. 이벤트가 PRODUCED 상태로 변경될 때 함께 기록할 수 있다. 모닌터링 시 이벤트가 언제 대상자 생성을 완료했는지 확인할 수 있어야 하므로 updated_at과 별도로 produced_at을 두었다.

3.2. event_free_period_config

event_free_period_config 테이블은 무료 이용 기간 지급 이벤트의 세부 설정을 저장하는 테이블이다.

컬럼명타입NULLABLEUNIQUE설명
iduuid(PK)NOT NULL1ID
event_iduuid(FK)NOT NULL2이벤트 ID
daysintNOT NULL지급할 이용 기간
paid_start_attimestamptzNULLABLE기준 결제 일자(시작)
paid_end_attimestamptzNULLABLE기준 결제 일자(종료)
created_attimestamptzNOT NULL생성 시각
updated_attimestamptzNOT NULL수정 시각

event_free_period_configevent의 관계는 1:1이다. 즉, 하나의 무료 이용 기간 이벤트는 하나의 무료 이용 기간 설정을 가진다. 이 테이블에서 가장 중요한 값은 days, paid_start_at, paid_end_at이다. days는 사용자에게 지급할 무료 이용 기간이다. 예를 들어 7일을 지급한다면 days7이 된다. paid_start_atpaid_end_at은 대상자를 추출할 때 사용하는 결제 이력 기준이다. 이번 이벤트는 1년 이내 결제 이력이 있는 고객이 대상이므로 해당 조건을 이 두 컬럼으로 표현할 수 있다.

paid_start_at: 기준 결제 일자 시작
paid_end_at: 기준 결제 일자 종료

대상자를 추출할 때는 대략 다음과 같은 조건을 사용할 수 있다.

SELECT
  DISTINCT user_id
FROM payments
WHERE
  paid_at >= :paid_start_at
  AND paid_at < :paid_end_at
  AND status = 'PAID';

실제 서비스에서는 결제 취소, 환불, 이용권 상태 등 더 많은 조건이 필요할 수 있다. 중요한 점은 대상자 추출 조건이 코드에 하드코딩되지 않고, 이벤트 설정 테이블에 저장된 값을 기준으로 동작한다는 것이다. 이렇게 설계하면 다음에 비슷한 이벤트를 진행할 때도 새 코드를 배포하지 않고 관리자에서 이벤트 설정만 등록해 처리할 수 있다.

3.3. event_free_period_grant

event_free_period_grant 테이블은 이벤트별 사용자 지급 대상과 지급 결과를 관리하는 테이블이다.

컬럼명타입NULLABLEUNIQUE설명
iduuid(PK)NOT NULL1ID
event_iduuid(FK)NOT NULL2이벤트 ID
user_iduuid(FK)NOT NULL2사용자 ID
statusvarcharNOT NULLPENDING, IN_PROGRESS, COMPLETED, FAILED
retry_countintNOT NULL재시도 횟수
last_failed_messagetextNULLABLE마지막 실패 메시지
daysintNOT NULL지급된 이용 기간
before_expired_attimestamptzNULLABLE지급 전 이용 기간 만료 시각
after_expired_attimestamptzNULLABLE지급 후 이용 기간 만료 시각
created_attimestamptzNOT NULL생성 시각
updated_attimestamptzNOT NULL수정 시각

이 테이블은 이번 기능에서 가장 중요한 테이블이다. 단순히 지급 대상자를 저장하는 테이블이 아니라, 사용자별 지급 상태와 지급 결과를 함께 관리한다. event_id, user_id 조합에는 unique 제약을 걸었다.

UNIQUE (event_id, user_id)

즉, 이 테이블은 지급 대상 테이블이면서 동시에 지급 결과 이력 테이블 역할을 한다. 그래서 별도의 지급 이력 테이블을 추가하지 않더라도, 이벤트 단위와 사용자 단위의 지급 결과를 추적할 수 있도록 하였다.

누가 지급 대상인지 기록
현재 지급 상태를 기록
지급 성공/실패 결과를 기록
지급 전후 만료 시각을 기록

이 제약은 중복 지급을 막기 위한 핵심 장치이다. 같은 이벤트에서 같은 사용자가 여러 번 대상자로 추출되더라도 event_free_period_grant에는 한 번만 저장되어야 한다. 애플리케이션 코드에서 중복을 막는 것도 중요하지만, 이벤트 지급처럼 중복 실행이 치명적인 작업은 데이터베이스 제약으로 한 번 더 막아주는 편이 좋다고 판단했다. status는 사용자별 지급 상태를 나타낸다.

PENDING: 지급 대상자로 생성되었지만 아직 지급 처리가 시작되지 않은 상태
IN_PROGRESS: Queue Processor가 해당 사용자 지급을 처리 중인 상태
COMPLETED: 무료 이용 기간 지급이 완료된 상태
FAILED: 지급 처리 중 오류가 발생한 상태

retry_count는 지급 실패 후 재시도된 횟수를 기록한다. last_failed_message에는 마지막 실패 원인을 저장한다. 이 값이 있어야 운영 중 특정 사용자에게 지급이 실패했을 때 원인을 추적할 수 있다. days는 실제 지급된 무료 이용 기간이다. 이 값은 event_free_period_config.days에서 가져와 저장한다. 굳이 설정 테이블에 이미 있는 값을 지급 테이블에도 저장하는 이유는 지급 당시의 값을 남기기 위해서이다. 나중에 이벤트 설정이 변경되더라도, 특정 사용자에게 실제로 며칠이 지급되었는지는 event_free_period_grant.days를 보면 알 수 있다. before_expired_atafter_expired_at은 지급 전후의 이용 기간 만료 시각이다. 이 두 컬럼이 있어야 고객 문의가 들어왔을 때 지급 전에는 언제까지 이용 가능했고, 지급 후에는 언제까지 연장되었는지를 확인할 수 있다.

4. 이벤트 처리 흐름

이벤트는 관리자에서 등록된 뒤 예약 상태로 대기한다. 이후 EventBridge Scheduler에 의해 ECS Task가 실행되면, 해당 시점에 처리해야 하는 이벤트 목록을 조회한다. 이를 도식화하면 다음과 같다.

sequenceDiagram
    participant Scheduler as EventBridge Scheduler
    participant Task as ECS Task
    participant Event as event 테이블
    participant Config as event_free_period_config 테이블
    participant Grant as event_free_period_grant 테이블
    participant Queue as Queue

    Scheduler->>Task: 1시간마다 Task 실행
    Task->>Event: 예약된 FREE_PERIOD 이벤트 목록 조회
    Task->>Config: 이벤트 설정 조회

    loop 이벤트 순회
        Task->>Event: RESERVED → PRODUCING 상태 변경 시도
        Event-->>Task: 변경 성공 시 event id 반환

        Task->>Task: paid_start_at, paid_end_at 기준 대상자 추출
        Task->>Grant: 대상자 저장
        Task->>Queue: 사용자별 지급 job 발행
        Task->>Event: PRODUCING → PRODUCED 상태 변경
    end

여기서 핵심은 이벤트를 바로 처리하지 않고 상태를 먼저 변경한다는 점이다. 이벤트 처리 Task가 실행되면 해당 이벤트를 PRODUCING 상태로 변경하려고 시도한다.

UPDATE event
SET
  status = 'PRODUCING',
  updated_at = NOW()
WHERE
  id = :event_id
  AND (
    status = 'RESERVED'
    OR (
      status = 'PRODUCING'
      AND updated_at < NOW() - INTERVAL '1 hour'
    )
  )
RETURNING id;

이 쿼리는 상태를 바꾸는 동시에 잠금 역할을 한다. 조건을 보면 이벤트 상태가 RESERVED인 경우에만 PRODUCING으로 변경하도록 했다. 즉, 아직 처리되지 않은 이벤트만 처리 대상으로 잡는다. 그리고 이미 PRODUCING 상태인 이벤트라도 updated_at이 1시간 이상 지난 경우에는 다시 처리할 수 있도록 했다. 이 조건을 넣은 이유는 Task가 중간에 실패할 수 있기 때문이다. 예를 들어 이벤트를 PRODUCING 상태로 바꾼 뒤 대상자 추출 중 예기치 못한 상황으로 인해 Task가 죽으면 이벤트는 계속 PRODUCING 상태로 남을 수 있다. 이 상태를 영원히 방치하면 이벤트가 다시 처리되지 않는다. 그래서 일정 시간이 지난 PRODUCING 이벤트는 재처리할 수 있도록 조건을 열어두었다. 만약 UPDATE 결과가 없다면 해당 이벤트는 처리하지 않고 넘어간다.

UPDATE 결과 있음: 현재 Task가 이벤트 처리 권한을 획득한 것
UPDATE 결과 없음: 이미 다른 Task가 처리 중이거나 처리할 수 없는 상태이면 continue

이 방식은 일종의 상태 기반 락처럼 동시에 여러 Task가 실행되더라도 같은 이벤트를 중복으로 생산하지 않도록 막아준다.

5. 대상자 추출과 지급 대상 저장

이벤트 처리 권한을 얻으면 다음 단계는 대상자 추출이다. 이번 추석 이벤트의 대상은 1년 이내 결제한 고객이었다. 이 조건은 event_free_period_config 테이블의 paid_start_at, paid_end_at으로 표현했다. 예를 들어 이벤트 시행일이 2025.10.06 02:00이고 1년 이내 결제 고객이 대상이라면, 결제 기준 시작일과 종료일을 설정으로 저장할 수 있다.

paid_start_at: 2024.10.06 02:00
paid_end_at: 2025.10.06 02:00

대상자 추출은 대략 다음과 같은 흐름으로 진행된다.

SELECT
  DISTINCT user_id
FROM payments
WHERE
  paid_at >= :paid_start_at
  AND paid_at < :paid_end_at
  AND status = 'PAID';

서비스의 결제 정책 중에서 환불, 테스트 결제 등이 포함되어 있으므로 실결제만 포함될 수 있도록 하였다. 중요한 점은 이 대상자 추출 결과를 바로 사용자 테이블에 반영하지 않고, 먼저 event_free_period_grant 테이블에 저장한다는 것이다.

INSERT INTO event_free_period_grant
  (event_id, user_id, status, retry_count, days, created_at, updated_at)
VALUES
  (:event_id, :user_id, 'PENDING', 0, :days, NOW(), NOW())
ON CONFLICT (event_id, user_id) DO NOTHING;

여기서 daysevent_free_period_config.days 값을 복사해서 저장한다. 이벤트 설정에 이미 지급 일수가 있는데도 지급 테이블에 다시 저장하는 이유는 지급 당시의 값을 보존하기 위해서이다. 예를 들어 이벤트 설정이 나중에 수정되더라도 이미 생성된 지급 대상에게 실제로 며칠을 지급하려고 했는지는 event_free_period_grant.days에 남아 있어야 한다. 지급 결과를 추적할 때도 이 값이 필요하다. ON CONFLICT (event_id, user_id) DO NOTHING은 같은 이벤트에서 같은 사용자가 중복 저장되는 것을 막기 위한 처리이다.

flowchart LR
    A[event_id] --> C[UNIQUE event_id + user_id]
    B[user_id] --> C
    C --> D[동일 이벤트 내 중복 대상자 저장 방지]

대상자를 저장한 뒤에는 사용자별로 Queue job을 발행한다.

jobId: event_id:user_id

jobId에도 event_iduser_id를 포함했다. 같은 이벤트에서 같은 사용자에 대한 지급 job을 식별하기 위해서이다. 대상자 저장과 job 발행이 끝나면 이벤트 상태를 PRODUCED로 변경한다. 이때 대상자 생성 완료 시각도 함께 기록한다.

UPDATE event
SET
  status = 'PRODUCED',
  produced_at = NOW(),
  updated_at = NOW()
WHERE
  id = :event_id
  AND status = 'PRODUCING';

PRODUCED는 실제 사용자에게 무료 이용 기간이 모두 지급되었다는 의미는 아니라 이벤트 대상자 추출과 job 발행이 완료되었다는 의미이다. 실제 지급 여부는 event_free_period_grant.status로 관리한다. 이렇게 나누어야 이벤트 단위의 처리 상태와 사용자 단위의 지급 상태를 각각 추적할 수 있다.

  • event.status = PRODUCED: 이벤트 대상자 추출과 Queue 발행 완료
  • event_free_period_grant.status = COMPLETED: 특정 사용자에게 무료 이용 기간 지급 완료

6. Queue Processor에서 실제 지급 처리하기

실제 무료 이용 기간 지급은 Queue Processor에서 처리한다. Queue Processor는 ECS Service로 실행되며, Queue에 쌓인 job을 가져와 사용자별 지급 작업을 수행한다. 처리 흐름은 다음과 같다.

sequenceDiagram
    participant Queue as Queue
    participant Processor as Queue Processor
    participant Grant as event_free_period_grant 테이블
    participant User as 사용자 이용 기간
    participant Event as event 테이블

    Queue->>Processor: event_id, user_id job 전달

    Processor->>Grant: PENDING/FAILED → IN_PROGRESS 변경 시도
    Grant-->>Processor: 변경 성공 시 id 반환

    Processor->>User: 현재 이용 기간 만료 시각 조회
    Processor->>User: 이용 기간 증가
    Processor->>Grant: before_expired_at, after_expired_at, COMPLETED 저장
    Processor->>Event: 모든 지급 완료 시 COMPLETED 변경

Queue Processor에서도 바로 지급하지 않고 먼저 event_free_period_grant 상태를 IN_PROGRESS로 변경한다.

UPDATE event_free_period_grant
SET
  status = 'IN_PROGRESS',
  updated_at = NOW()
WHERE
  event_id = :event_id
  AND user_id = :user_id
  AND (
    status = 'PENDING'
    OR status = 'FAILED'
    OR (
      status = 'IN_PROGRESS'
      AND updated_at < NOW() - INTERVAL '1 hour'
    )
  )
RETURNING id, days;

이 쿼리도 이벤트 처리 쿼리와 비슷한 역할을 한다. 지급 가능한 상태의 row만 IN_PROGRESS로 변경한다. 처음 지급되는 대상자는 PENDING 상태이다. 이전에 실패한 대상자는 FAILED 상태일 수 있다. 그리고 처리 중이던 job이 장애로 인해 멈췄다면 IN_PROGRESS 상태로 남아 있을 수 있다. 이 경우에도 updated_at이 1시간 이상 지난 row는 다시 처리할 수 있도록 했다. UPDATE 결과가 없으면 해당 job은 처리하지 않는다.

  • UPDATE 결과 있음: 현재 Processor가 이 사용자 지급 작업을 처리할 수 있음
  • UPDATE 결과 없음: 이미 처리 중이거나 완료된 작업 또는 1시간 이내에 처리되지 않고 종료된 작업

이 구조 덕분에 같은 job이 중복으로 들어오더라도 이미 완료된 지급 작업은 다시 실행되지 않는다.

7. 지급 작업은 트랜잭션으로 묶기

사용자의 무료 이용 기간을 지급하는 과정은 하나의 작업처럼 보여야 한다. 사용자의 이용 기간을 증가시켰는데 지급 결과 저장에 실패하거나, 지급 결과는 저장했는데 실제 이용 기간 증가가 실패하면 데이터가 어긋날 수 있다. 그래서 Queue Processor에서는 실제 지급 로직을 트랜잭션으로 처리했다. 흐름을 코드 형태로 표현하면 다음과 같다.

async function process(job: Job<{ eventId: string; userId: string }>) {
  const { eventId, userId } = job.data;

  const inProgressResult = await dataSource
    .createQueryBuilder()
    .update(EventFreePeriodGrant)
    .set({
      status: 'IN_PROGRESS',
      updatedAt: () => 'NOW()',
    })
    .where('event_id = :eventId', { eventId })
    .andWhere('user_id = :userId', { userId })
    .andWhere(
      `
        (
          status IN (:...statuses)
          OR (
            status = :status
            AND updated_at < NOW() - INTERVAL '1 hour'
          )
        )
      `,
      {
        statuses: ['PENDING', 'FAILED'],
        status: 'IN_PROGRESS',
      },
    )
    .returning(['id', 'days'])
    .execute();

  const grant = inProgressResult.raw[0] as
    | {
        id: string;
        days: number;
      }
    | undefined;

  if (!grant) {
    await markAsCompletedIfAllGrantsCompleted(eventId);

    return;
  }

  try {
    await dataSource.transaction(async (manager) => {
      const userSubscription = await manager.findOne(UserSubscription, {
        where: { userId },
        lock: { mode: 'pessimistic_write' },
      });

      if (!userSubscription) {
        throw new Error('사용자 이용권 정보를 찾을 수 없습니다.');
      }

      const now = new Date();
      const beforeExpiredAt = userSubscription.expiredAt;
      const baseExpiredAt = beforeExpiredAt && beforeExpiredAt > now ? beforeExpiredAt : now;
      const afterExpiredAt = dayjs(baseExpiredAt).add(grant.days, 'day').toDate();

      userSubscription.expiredAt = afterExpiredAt;

      await manager.save(userSubscription);
      await manager
        .createQueryBuilder()
        .update(EventFreePeriodGrant)
        .set({
          status: 'COMPLETED',
          beforeExpiredAt,
          afterExpiredAt,
          updatedAt: () => 'NOW()',
        })
        .where('event_id = :eventId', { eventId })
        .andWhere('user_id = :userId', { userId })
        .andWhere('status = :status', { status: 'IN_PROGRESS' })
        .execute();
    });
  } catch (error) {
    await dataSource
      .createQueryBuilder()
      .update(EventFreePeriodGrant)
      .set({
        status: 'FAILED',
        lastFailedMessage: error instanceof Error ? error.message : String(error),
        retryCount: () => 'retry_count + 1',
        updatedAt: () => 'NOW()',
      })
      .where('event_id = :eventId', { eventId })
      .andWhere('user_id = :userId', { userId })
      .andWhere('status = :status', { status: 'IN_PROGRESS' })
      .execute();

    throw error;
  }

  await markAsCompletedIfAllGrantsCompleted(eventId);
}

async function markAsCompletedIfAllGrantsCompleted(eventId: string) {
  await dataSource
    .createQueryBuilder()
    .update(Event)
    .set({
      status: 'COMPLETED',
      updatedAt: () => 'NOW()',
    })
    .where('id = :eventId', { eventId })
    .andWhere('status = :eventStatus', { eventStatus: 'PRODUCED' })
    .andWhere(
      `
        NOT EXISTS (
          SELECT
            1
          FROM event_free_period_grant g
          WHERE
            g.event_id = :eventId
            AND g.status <> :grantStatus
        )
      `,
      { grantStatus: 'COMPLETED' },
    )
    .execute();
}

실제 코드와 완전히 같지는 않지만, 핵심 흐름은 위와 같다. 트랜잭션 안에서 처리한 작업은 다음과 같다.

1. event_free_period_grant row를 IN_PROGRESS 상태로 변경
2. 사용자의 현재 이용 기간 만료 시각 조회
3. 지급할 days만큼 이용 기간 증가
4. event_free_period_grant에 지급 전후 만료 시각 저장
5. event_free_period_grant 상태를 COMPLETED로 변경

이 작업들은 함께 성공하거나 함께 실패해야 한다. 특히 이용 기간 증가와 지급 결과 저장은 분리되면 안 된다. 사용자의 기간은 늘어났는데 event_free_period_grant에 결과가 없다면 나중에 원인을 추적하기 어렵고, 반대로 COMPLETED로 기록되었는데 실제 기간이 늘어나지 않았다면 고객에게 잘못된 정보를 제공하게 된다. 여기서 before_expired_atafter_expired_at은 운영 관점에서 중요한 값이며 이 값이 있어야 나중에 이벤트로 정확히 얼마나 연장되었는지를 확인할 수 있다. 실패한 경우에는 event_free_period_grant 상태를 FAILED로 변경하고 retry_count를 증가시킨다. 또한 마지막 실패 메시지를 last_failed_message에 저장한다.

UPDATE event_free_period_grant
SET
  status = 'FAILED',
  retry_count = retry_count + 1,
  last_failed_message = :message,
  updated_at = NOW()
WHERE
  event_id = :event_id
  AND user_id = :user_id
  AND status = 'IN_PROGRESS';

이렇게 해두면 실패한 지급 건을 나중에 다시 처리할 수 있다. 또한 어떤 사용자의 지급이 실패했는지도 운영 관점에서 확인할 수 있다.

8. 이벤트 완료 처리

event 테이블에는 COMPLETED 상태가 있다. 이 상태는 이벤트에 속한 사용자별 지급 작업이 모두 완료되었을 때 사용할 수 있다. 이벤트가 PRODUCED 상태가 되었다고 해서 전체 이벤트가 끝난 것은 아니다. PRODUCED는 대상자를 만들고 Queue에 job을 발행했다는 의미이다. 실제 무료 이용 기간 지급은 Queue Processor에서 사용자별로 처리된다. 그래서 이벤트의 최종 완료 여부는 event_free_period_grant 테이블을 기준으로 판단하도록 했다.

UPDATE event e
SET
  status = 'COMPLETED',
  updated_at = NOW()
WHERE e.id = :event_id
  AND e.status = 'PRODUCED'
  AND NOT EXISTS (
    SELECT
      1
    FROM event_free_period_grant g
    WHERE g.event_id = e.id
      AND g.status <> 'COMPLETED'
  );

위 쿼리는 해당 이벤트의 모든 지급 건이 COMPLETED일 때만 이벤트 상태를 COMPLETED로 변경한다. 실패 건이 남아 있다면 이벤트는 아직 완전히 끝난 상태가 아니다. 이 경우 운영자는 FAILED 상태의 지급 건을 확인하고, 원인을 해결한 뒤 재처리할 수 있어야 한다. 이렇게 이벤트 상태와 사용자별 지급 상태를 분리하면 전체 이벤트 진행 상황을 더 명확하게 볼 수 있다.

  • event.status: 이벤트 전체 흐름
  • event_free_period_grant.status: 사용자별 지급 흐름

예를 들어 이벤트 상태가 PRODUCED인데 일부 event_free_period_grantFAILED라면, 대상자 생성은 끝났지만 일부 사용자 지급이 실패한 상태라고 해석할 수 있다. 반대로 이벤트 상태가 COMPLETED라면, 해당 이벤트에 속한 모든 사용자 지급이 완료되었다고 볼 수 있다.

9. 상태를 기준으로 중복 실행을 막기

이번 기능에서 가장 신경 쓴 부분은 중복 실행이었다. 이벤트 지급 기능은 구조상 같은 작업이 여러 번 실행될 가능성을 고려해야 한다. EventBridge Scheduler는 1시간마다 Task를 실행한다. Task가 중간에 실패할 수도 있고, Queue job이 재시도될 수도 있다. 네트워크 문제나 일시적인 DB 오류로 인해 같은 job이 다시 들어올 수도 있다. 그래서 한 번만 실행될 것이라는 가정은 하지 않았다. 대신 여러 번 실행되어도 결과가 한 번만 반영되도록 만들었다. 이런 구조를 보통 멱등성이라고 부른다. 같은 요청을 여러 번 수행해도 결과가 달라지지 않도록 만드는 것이다. 이번 기능에서는 다음 장치들을 사용했다.

event 상태 전이: 같은 이벤트를 여러 Task가 동시에 생산하지 못하게 함
event_free_period_grant UNIQUE 제약: 같은 이벤트에서 같은 사용자가 중복 대상자로 저장되지 않게 함
event_free_period_grant 상태 전이: 같은 사용자 지급 job이 중복 처리되지 않게 함
before_expired_at, after_expired_at 저장: 지급 전후 이용 기간을 추적할 수 있게 함
last_failed_message`, `retry_count 저장: 실패 원인과 재시도 횟수를 추적할 수 있게 함

이벤트 상태 흐름을 정리하면 다음과 같다.

stateDiagram-v2
    [*] --> RESERVED

    RESERVED --> PRODUCING: 처리 시작
    PRODUCING --> PRODUCED: 대상자 추출 및 job 발행 완료
    PRODUCED --> COMPLETED: 모든 지급 건 완료

    PRODUCING --> PRODUCING: 1시간 이상 지연 시 재처리 가능

사용자별 지급 상태는 다음과 같다.

stateDiagram-v2
    [*] --> PENDING

    PENDING --> IN_PROGRESS: 지급 처리 시작
    FAILED --> IN_PROGRESS: 재시도
    IN_PROGRESS --> IN_PROGRESS: 1시간 이상 지연 시 재처리 가능

    IN_PROGRESS --> COMPLETED: 지급 성공
    IN_PROGRESS --> FAILED: 지급 실패

이 구조에서 중요한 점은 COMPLETED 상태의 지급 건은 다시 처리되지 않는다는 것이다. 이미 지급이 완료된 사용자라면 같은 job이 다시 들어와도 IN_PROGRESS로 상태 변경이 되지 않고, 따라서 실제 이용 기간 증가 로직까지 도달하지 않는다.

10. 왜 이벤트 생산과 지급 처리를 분리했는가

처음에는 ECS Task 하나에서 이벤트 조회, 대상자 추출, 이용 기간 지급까지 모두 처리할 수도 있었다. 구현만 보면 그 방식이 더 단순하다.

이벤트 조회
→ 대상자 조회
→ 사용자별 이용 기간 증가
→ 종료

하지만 이 방식은 대상자가 많아질수록 부담이 커진다. 하나의 작업 안에서 모든 지급을 처리하면 중간에 실패했을 때 복구가 어렵다. 예를 들어 10,000명 중 6,000명까지 지급한 뒤 실패했다고 해보자. 이때 6,001번째부터 다시 처리해야 하는지, 전체를 다시 처리해도 되는지 판단해야 한다. 중복 지급 방어가 없다면 재실행 자체가 위험해진다. 그래서 이벤트 생산과 지급 처리를 분리했다.

ECS Task
→ 이벤트 단위 작업
→ event 조회
→ event_free_period_config 조회
→ 대상자 추출
→ event_free_period_grant 저장
→ Queue job 발행

Queue Processor
→ 사용자 단위 작업
→ event_free_period_grant 상태 변경
→ 무료 이용 기간 지급
→ 지급 성공/실패 결과 저장

이렇게 분리하면 이벤트 단위로는 대상자와 job을 만드는 데 집중할 수 있고, 지급 처리는 사용자 단위로 독립적으로 처리할 수 있다. 특정 사용자의 지급이 실패해도 전체 이벤트가 실패하는 것이 아니라 해당 사용자 지급 건만 FAILED 상태로 남는다. 이후 실패 건만 다시 처리할 수 있다. 또한 Queue를 사용하면 처리량을 조절하기도 쉽다. 한 번에 너무 많은 사용자의 이용 기간을 업데이트하지 않도록 Processor 개수나 동시 처리 수를 조절할 수 있다.

11. 개발하면서 느낀 점

이번 작업은 기능 자체만 보면 복잡한 기능은 아니었다. 이벤트를 등록하고, 대상자를 추출하고, 무료 이용 기간을 지급하는 흐름이다. 하지만 실제로 구현하면서 중요했던 부분은 어떻게 안전하게 처리할 것인가였다. 운영 환경에서 직접 DB를 수정하는 방식은 빠르지만, 반복되기 시작하면 기능으로 만들어야 할 신호라고 생각한다. 특히 결제, 이용권, 포인트, 쿠폰처럼 사용자의 권한이나 금전적 가치와 연결된 데이터라면 더 그렇다.

이번에도 처음 요청만 보면 쿼리로 처리할 수 있는 작업이었다. 하지만 기획팀, 대표님과 협의하면서 이 작업이 앞으로도 반복될 수 있고, 직접 DB를 수정하는 방식이 위험하다는 점을 공유했다. 그리고 기능 개발 방향으로 설득했다. 개인적으로는 개발자로서 단순히 요청받은 작업을 처리하는 것도 중요하지만, 그 작업이 운영에서 어떤 방식으로 반복되고 있는지 보는 것도 중요하다고 생각한다. 반복되는 수동 작업은 언젠가 실수로 이어질 가능성이 높다. 그리고 그 실수를 줄이는 것이 기능 개발의 목적이 될 수 있다.

이번 테이블 설계에서도 이 부분을 많이 고려했다. 이벤트 자체는 event에서 관리하고, 무료 이용 기간 이벤트의 조건은 event_free_period_config로 분리하고, 사용자별 지급 상태와 결과는 event_free_period_grant에서 관리하도록 했다. 덕분에 이벤트 전체 흐름과 사용자별 지급 흐름을 나누어 볼 수 있었고, 실패한 지급 건도 추적할 수 있게 되었다.

마치며

이번 작업을 통해 다시 느낀 점은 운영 편의를 위한 기능일수록 데이터 정합성을 더 신경 써야 한다는 것이다. 관리자 기능이나 이벤트 기능은 사용자에게 직접 보이는 화면이 아닐 수 있지만, 실제 서비스 데이터에는 큰 영향을 준다. 특히 이용 기간처럼 결제와 연결된 데이터는 더 조심해야 한다. 이번 기능은 추석 이벤트를 위한 개발이었지만, 단순히 하나의 이벤트를 처리하기 위한 기능은 아니었다. 앞으로 비슷한 이벤트가 반복되더라도 직접 DB를 수정하지 않고, 시스템 안에서 안전하게 지급할 수 있는 흐름을 만든 작업이었다. 무엇보다 그동안의 공부해왔던 내용들(이를테면, 멱등성)을 실무에서 자연스럽게 녹여낸 것이 개인적으로는 큰 의미가 있다는 생각이 든다.