쿠폰 발급 기능 개발기

DooDleDog·2026년 7월 28일
post-thumbnail

오프라인 행사나 제휴처에 나눠줄 쿠폰이 필요했습니다. 코드 하나를 앱에 입력하면 정해진 일수만큼 프리미엄이 열리는, 결제를 거치지 않는 경로입니다.

기능 자체는 단순해 보였습니다. 코드 테이블 하나 만들고, 입력받은 코드에 사용 표시를 남기고, 프리미엄을 켜주면 끝일 것 같았습니다.

막힌 건 전부 "그 프리미엄을 어디에 어떻게 켜느냐"였습니다. 프리미엄이라는 상태는 이미 인앱 결제 쪽이 관리하고 있었고, 거기에 손을 대는 순간 결제 웹훅과 싸우게 됩니다.

결제 프리미엄 컬럼을 덮어쓰려다 멈췄다

유저 테이블에는 이미 프리미엄 여부와 만료 시각 컬럼이 있었습니다. 쿠폰을 등록할 때 이 두 컬럼을 갱신하면 세 줄로 끝나는 일이었습니다.

접은 이유는 그 컬럼의 소유자가 따로 있다는 점이었습니다. 인앱 결제 플랫폼의 웹훅과, 만료된 프리미엄을 정리하는 크론이 그 컬럼을 계속 쓰고 있었습니다.

구독 취소 웹훅이 한 번 들어오면 쿠폰으로 준 프리미엄까지 같이 꺼집니다. 만료 정리 크론은 이게 쿠폰인지 결제인지 모른 채 만료일이 지난 유저를 일괄로 내립니다. 반대 방향도 문제였습니다. 쿠폰 7일이 결제 유저의 남은 만료일을 덮어쓰면 돈 낸 사람이 손해를 봅니다.

그래서 저장과 판정을 분리했습니다. 쿠폰은 자기 스키마의 자기 테이블에만 기록하고, 결제 컬럼은 건드리지 않습니다. 프리미엄인지 아닌지는 구독 등급을 계산하는 시점에 두 출처를 합산해서 답합니다.

덕분에 게이팅 코드는 한 줄도 고치지 않았습니다. 채팅 사용량 제한 같은 기능은 모두 등급 계산 함수를 거쳐 가기 때문에, 그 함수 하나에 쿠폰 판정을 끼워 넣자 나머지가 자동으로 따라왔습니다.

"프리미엄인데 어디서 온 프리미엄인가"

처음엔 활성 쿠폰이 있으면 등급을 premium으로 반환했습니다. 동작은 했습니다. 사용량 제한도 정상적으로 풀렸습니다.

문제는 앱 쪽에서 이 둘을 구분할 수 없다는 것이었습니다. 결제한 적 없는 쿠폰 사용자에게 구독 관리 화면이 열리고, 문의가 들어와도 이 사람이 결제자인지 이벤트 참여자인지 서버 응답만 보고는 알 수 없습니다.

그래서 등급에 coupon을 따로 만들었습니다. 우선순위는 기관 연결이 가장 높고, 그다음 결제 프리미엄, 그다음 쿠폰, 마지막이 무료입니다. 사용량 한도는 쿠폰도 결제와 동일하게 매핑했습니다. 혜택은 같게 주면서 출처만 드러내는 쪽입니다.

이 구분은 등록 단계에서도 쓰입니다. 이미 기관 소속이거나 결제 구독 중인 유저가 쿠폰을 넣으면, 쿠폰만 한 장 소진되고 얻는 건 없습니다. 그래서 등급을 먼저 조회해 그 두 경우는 409로 막고, 쿠폰 등급인 유저는 등록을 허용했습니다.

서로를 필요로 하는 두 모듈

등급 계산은 유저 도메인에 있고, 활성 쿠폰 판정은 쿠폰 도메인에 있습니다. 그런데 쿠폰을 등록할 때 위처럼 현재 등급을 알아야 하니, 쿠폰 도메인도 유저 도메인을 필요로 합니다. 서로를 import하면 모듈 순환입니다.

forwardRef로 묶는 대신 프로젝트에 이미 있던 방식을 따랐습니다. 필요한 메서드만 선언한 인터페이스와 심볼 토큰을 공용 계약 파일에 두고, 각 모듈이 자기 서비스를 그 토큰으로 등록합니다. 상대편은 초기화 시점에 ModuleRef로 토큰만 꺼내 씁니다.

두 서비스 사이에 컴파일 타임 의존이 남지 않아 순환이 사라지고, 단위 테스트에서는 토큰에 목 객체만 꽂으면 됩니다. 대신 배선을 빼먹으면 런타임에 조용히 undefined가 되므로, 그 부분은 등급 계산 스펙으로 덮었습니다.

조건부 UPDATE로 막히지 않던 동시성

쿠폰은 1회용이니 같은 코드가 두 번 먹히면 안 됩니다. 처음 구현은 조회해서 사용 여부를 확인하고, 통과하면 사용 표시를 저장하는 순서였습니다. 확인과 기록 사이가 벌어져 있어 두 요청이 같은 코드를 함께 통과할 수 있습니다.

그래서 기록을 조건부 UPDATE 한 방으로 바꿨습니다.

UPDATE coupons
   SET redeemed_at = now(), redeemed_by_user_id = $1, premium_expires_at = $2
 WHERE code = $3 AND redeemed_at IS NULL

갱신된 행이 0이면 그 사이 누가 먼저 썼다는 뜻이니 409로 응답합니다. 앞의 조회는 "없는 코드", "비활성", "기간 지남"을 구분해 알려주는 용도로만 남겼습니다.

여기서 한 번 더 막혔습니다. 같은 유저가 서로 다른 코드 두 개를 동시에 넣으면 각각 다른 행이라 조건부 UPDATE가 둘 다 성공합니다. 쿠폰은 두 장 소진되는데, 활성 프리미엄은 만료일 중 가장 늦은 하나만 취하므로 한 장은 그냥 사라집니다.

이건 행 단위 조건으로는 잡을 수 없어서 유저 단위 Redis 락으로 등록을 직렬화했습니다. TTL 10초를 걸고 끝나면 해제하며, 락을 못 잡으면 "처리 중"으로 응답합니다. 유저 경합은 락이, 코드 경합은 조건부 UPDATE가 맡는 두 층 구조가 됐습니다.

트랜잭션 안에서는 재시도가 안 된다

운영에서는 쿠폰을 한 번에 여러 장 만듭니다. 코드는 이벤트 접두사에 난수 여섯 자를 붙인 형태라 드물게 겹칠 수 있고, 겹치면 유니크 제약 위반(23505)을 잡아 코드를 다시 뽑습니다. 최대 여덟 번까지 시도합니다.

처음엔 반복문에서 한 건씩 저장했습니다. 100장을 요청했다가 60번째에서 실패하면 앞의 59장은 이미 커밋되어 있는데 응답으로 돌려줄 코드 목록은 사라집니다. DB에만 남고 아무도 모르는 쿠폰이 생기는 셈입니다.

그래서 배치 전체를 트랜잭션으로 감쌌더니, 이번엔 재시도가 깨졌습니다. PostgreSQL은 트랜잭션 도중 에러가 나면 트랜잭션 전체를 중단 상태로 만듭니다. 23505를 잡아 다시 저장을 시도해도 이미 중단된 트랜잭션이라 이후 쿼리가 전부 거부됩니다.

해결은 시도 하나하나를 세이브포인트로 감싸는 것이었습니다. TypeORM에서는 트랜잭션 안에서 트랜잭션을 한 번 더 열면 세이브포인트가 됩니다.

for (let attempt = 0; attempt < MAX_RETRY; attempt += 1) {
  try {
    return await em.transaction(async (savepointEm) => {
      const repo = savepointEm.getRepository(Coupon);
      return (await repo.save(repo.create(build()))).code;
    });
  } catch (error) {
    if (this.isUniqueViolation(error)) continue;
    throw error;
  }
}

충돌한 시도만 세이브포인트까지 되돌아가고 바깥 배치 트랜잭션은 살아 있습니다. 전부 성공하면 한 번에 커밋되고, 여덟 번을 넘기면 배치 전체가 롤백됩니다.

잘못 뿌린 쿠폰 되돌리기와 남은 한계

쿠폰을 잘못 배포하는 일은 생깁니다. 회수는 두 갈래로 나눴습니다. 아직 안 쓴 쿠폰은 비활성으로 내려 등록을 막고, 이미 쓴 쿠폰은 만료 시각만 비웁니다.

이때 사용 표시는 그대로 남겨둡니다. 프리미엄만 회수되고 그 코드는 다시 쓸 수 없습니다. 등급은 저장값이 아니라 계산값이라 다음 조회에서 알아서 내려갑니다. 회수 시각과 사유도 함께 남겨 나중에 왜 회수했는지 추적할 수 있게 했습니다.

코드를 계속 찍어보는 시도도 막아야 했습니다. 대문자와 숫자 36자에서 여섯 자를 뽑으니 조합은 21억 7천만 가지쯤 되지만, 발급량이 늘면 유효 코드에 걸릴 확률도 함께 올라갑니다. 등록 API에 분당 5회 제한을 걸었습니다. 코드를 한 번 입력하는 정상 사용자는 걸리지 않습니다.

남긴 것도 있습니다. 회수는 여전히 조회 후 수정 방식이라 원자적이지 않습니다. 운영자 한 명이 누르는 단일 조작이라 감수했습니다. 등급을 계산할 때마다 쿠폰 조회가 한 번 더 나가는데 캐시는 넣지 않았습니다. 인덱스를 타는 단건 조회라 두고 봤고, 실제 부하는 측정하지 못했습니다. 기간 누적도 없습니다. 쿠폰을 두 장 써도 가장 늦은 만료일 하나만 유효합니다.

이번 작업에서 가장 크게 남은 건 "상태를 어디에 저장할까"보다 "이 컬럼의 주인이 누구인가"를 먼저 물어야 한다는 점이었습니다. 결제 컬럼을 세 줄로 덮어썼다면 기능은 그날 끝났겠지만, 웹훅과 크론과 쿠폰이 같은 값을 두고 싸우는 버그를 나중에 훨씬 비싸게 찾았을 겁니다.

동시성도 한 가지 장치로 끝나지 않았습니다. 조건부 UPDATE는 같은 행 경합에만 답을 주고, 서로 다른 행을 건드리는 같은 유저의 경합은 다른 층에서 막아야 했습니다. 어떤 자원이 경합하는지를 먼저 정하고 그 단위에 맞는 방어를 고르는 순서가 맞았습니다.

profile
개발자

0개의 댓글