sanga-log
← BACK
회고·2026. 09. 29·- views

늘 API를 받아 쓰기만 하던 프론트 개발자가 광고 구독 기능을 테이블 설계부터 직접 짜면서 겪은 일들을 정리했습니다. 첫 요청부터 404였습니다.

회고백엔드KotlinSpring테이블 설계결제SQS트러블슈팅

이번엔 응답을 만드는 쪽

저는 프론트엔드 개발자입니다. 지금까지 제 일은 잘 만들어진 API에 요청을 보내고, 내려온 응답을 화면에 그리는 것이었습니다. 그런데 이번엔 그 응답을 만드는 쪽이 저였습니다.

사장님용 솔루션에 광고 구독 상품(마커 광고, 리스팅 광고, 카카오 광고)을 새로 붙이게 됐는데, 화면만 만든다고 끝나는 일이 아니었습니다. 구독 API, 결제 결과를 받는 워커, 만료·갱신 배치, 운영 어드민까지 필요했습니다. 당시 회사의 프론트 개발자는 저까지 두 명이었습니다. 화면은 제가 맡았고, 어쩌다 보니 이번엔 서버 쪽까지 맡게 됐습니다. 두 달 반 동안 만진 레포만 7개입니다. Kotlin/Spring 5개, Django 어드민 1개, 그리고 프론트 1개.


API보다 테이블이 먼저였습니다

백엔드를 맡았다고 바로 API부터 짠 건 아니었습니다. 코드를 쓰기 전에 한 일은 이 순서였습니다.

  • 기획서에서 저장해야 할 데이터 뽑기
  • 비슷한 것끼리 묶어서 스프레드시트에 테이블로 그리기
  • 관계도(ERD) 그리기
  • 동료 백엔드 개발자에게 계속해서 리뷰받기
  • 테이블 만들기
  • 필요한 API 리스트 정리하기

코드는 그다음이었습니다.

기획서에서 시작했습니다

처음에 알려 주던 백엔드 동료가 일단 이 세 가지만 유념해서 짜 보라고 했습니다.

  • 테이블의 기본 단위는 개체입니다. 현실에서 독립적으로 구분되는 하나의 개념이요. (광고 설정, 쿠폰 설정처럼)
  • 각 행은 자기를 식별할 수 있는 고유한 ID를 가져야 합니다.
  • 테이블 사이의 관계를 생각합니다. 1:1인지, 1:N인지, N:M인지.

기획서를 보고 저장이 필요한 데이터부터 뽑았습니다. 그걸 그룹으로 묶어서 스프레드시트에 테이블로 그리고, 계속 고쳐 가면서 ERD를 만들었습니다.

테이블 설계는 생각보다 시간이 많이 걸렸습니다. 컬럼 하나, 관계 하나를 정할 때마다 생각할 게 정말 많았습니다.

데이터베이스 강의도 결제해서 새로 들었습니다. 다만 시간이 촉박해서 다 듣고 시작할 수는 없었고, 강의를 들으면서 바로바로 적용해 보았습니다. 모르는 게 너무 많아서 AI에게 계속해서 물어보면서 했는데, AI가 없었다면 도전해 보기 너무 어려웠을 거란 생각이 듭니다.

기존 테이블과 어떻게 이을까

새 테이블만 고민하면 되는 것도 아니었습니다. 기존 테이블과 어떻게 연결할지도 정해야 했는데, 쿠폰, 구독, 결제가 전부 다른 테이블에 따로 있어서 흐름을 파악하는 것만 해도 시간이 많이 걸렸습니다. 봐도 봐도 이해가 안 되는 것들도 있었습니다. 애초에 테이블을 연결한다는 게 무슨 말인지, 연결해서 어떤 식으로 쓰는 건지부터 처음엔 와닿지 않았습니다.

결제 쪽만 봐도 기존 흐름이 복잡했습니다. 매장, 주문, 결제가 각각 나뉘어 있었고, 결제 한 번에 결제 대상과 결제 로그, 주문이 따로따로 움직였습니다. 로그에 준비 상태가 먼저 찍히고, 주문이 완료되면 그제야 성공으로 바뀌는 식이었습니다. 결제가 비동기로 흐른다는 건 알고 있었지만, 어떤 방식으로 흘러가는지는 자세히 몰랐습니다. 이번에 플로우를 분석하면서 좀 더 자세히 알게 됐습니다. 거기에 처음 듣는 용어도 많아서 이해하기가 더 어려웠습니다.

초안에서 저는 쿠폰도 결제도 전부 새 테이블로 그렸습니다. 리뷰에서 돌아온 답은, 쿠폰은 이미 구축돼 있으니 우리는 각 회차의 쿠폰을 어떻게 세팅했고 얼마나 썼는지 묶어 주기만 하면 된다는 거였습니다. 결제도 마찬가지였습니다.

처음 그린 초안리뷰 뒤
광고 상품상품 테이블테이블 없이 타입 값으로 관리
쿠폰광고 쿠폰, 쿠폰 발급 테이블기존 쿠폰 테이블을 쓰고, 회차와 묶는 테이블만 추가
결제결제, 결제 수단 테이블기존 주문, 카드 테이블 참조
결제일다음 결제일 하나원래 결제 예정일과 실제 다음 시도일 두 개

새 테이블을 그리는 일인 줄 알았는데, 이미 있는 테이블을 찾아서 잇는 일이기도 했습니다.

기존 테이블을 쓰기로 했다고 끝난 것도 아니었습니다. 쿠폰을 쓸 수 있는 요일은 기존 쿠폰에 있는 설정을 쓰기로 했는데, 쿠폰 테이블을 찾아보니 요일 필드가 없었습니다. "없는 게 맞을까요?" 하고 물어봤더니 답은 "하나 더 타고 들어가야 해요"였습니다. 쿠폰 하나도 기본 타입, 상세 설정, 실제로 발급된 쿠폰으로 테이블이 나뉘어 있었던 거죠.

결제일은 리뷰에서 이런 질문을 받았습니다. "결제가 실패하면 일자가 밀릴 텐데, 어떻게 원복해 줄 건가요?" 날짜 컬럼 하나로는 답할 수가 없었습니다. 그래서 실패해도 안 바뀌는 결제 예정일과, 실패하면 하루씩 밀리는 다음 시도일로 나눴습니다. 그러면 재시도 중이라는 상태를 따로 두지 않아도, 두 날짜가 다른지만 보면 알 수 있습니다.

enum도 DB에 타입으로 박아 두면 값을 추가하거나 뺄 때마다 DB를 고쳐야 했습니다. 그래서 컬럼은 문자열로 두고, 값은 코드에서 enum으로 관리하기로 했습니다.

그렇게 정리한 관계도를 단순화하면 이렇습니다. 실제 테이블 이름 대신 역할로 적었습니다.

광고 구독 테이블 관계도

테이블을 만들고, API 리스트를 정리했습니다

리뷰를 받으며 고친 표를 CREATE TABLE로 옮겼습니다. 구독 테이블을 단순화하면 이런 모양입니다.

-- 이름과 컬럼은 단순화한 예시입니다
CREATE TABLE subscribe (
  id                     BIGINT      NOT NULL AUTO_INCREMENT,
  shop_id                INT         NOT NULL,  -- 기존 매장 참조
  product_type           VARCHAR(16) NOT NULL,  -- 값은 코드의 enum으로 관리
  status                 VARCHAR(16) NOT NULL,
  scheduled_payment_date DATE        NOT NULL,  -- 원래 결제 예정일. 실패해도 안 바뀜
  next_payment_date      DATE        NOT NULL,  -- 실제 다음 시도일. 실패하면 밀림
  PRIMARY KEY (id)
);

새로 만든 테이블은 모두 여섯 개입니다. 구독과 쿠폰에서 다섯 개, 카카오 광고 발송에서 한 개가 나왔습니다.

테이블담는 것
구독매장의 광고 구독. 상품 종류, 상태, 결제일
회차구독 1건의 회차별 시작일, 종료일, 금액
이력결제 성공·실패 같은 이벤트. 주문, 결제 수단과 연결
회차별 쿠폰 설정그 회차에 어떤 쿠폰을 세팅했는지. 한 회차에 여러 개 가능
회차별 발급 쿠폰그 설정으로 실제 발급된 쿠폰
카카오 광고 발송건별 발송 신청. 문구, 이미지, 발송 대상

구독 상태는 이때까지만 해도 구독 중, 해지 예정, 종료 세 개였습니다. 왜 나중에 늘어났는지는 아래에서 나옵니다 😅

마지막으로 필요한 API 리스트를 정리했습니다. 경로만 적은 게 아니라 요청과 응답 JSON, 검증 규칙까지 적었습니다.

GET     구독 + 쿠폰 현황 조회
POST    구독 + 쿠폰 생성
POST    다음 회차 쿠폰 생성
PATCH   다음 회차 쿠폰 수정
DELETE  다음 회차 쿠폰 삭제
POST    구독 취소

이번 작업에서 꼭 해 보고 싶은 게 하나 있었습니다. 프론트를 하면서는 응답을 가공하거나, 어떤 필드가 필요한지 하나하나 확인해 보고 쓰는 경우가 많았습니다. 그럴 때면 종종 필요한 정보만 바로 쓸 수 있게 내려오면 좋겠다고 생각했습니다. 그래서 이번엔 시작할 때부터 마음먹었습니다. 계산과 가공은 백엔드에서 최대한 끝내고, 프론트에서는 응답을 그대로 쓰자!

조회 API 하나에 구독 상태, 현재 회차, 쿠폰 현황, 취소할 때의 환불 미리보기까지 담았습니다. 환불 금액은 서버에서 계산해서 내려 주고, 쿠폰을 고칠 수 있는지도 editable로 내려 줬습니다. 이번 회차 쿠폰은 수정할 수 없고 다음 회차 쿠폰만 수정할 수 있어서, 응답도 둘로 나눴습니다.

// 단순화한 응답 예시
{
  "subscribe": { "status": "ACTIVE", "nextPaymentDate": "2026-04-04" },
  "registeredCoupons": [{ "discountAmt": 1000, "editable": false }],  // 이번 회차
  "scheduledCoupons":  [{ "discountAmt": 1000, "editable": true }],   // 다음 회차
  "cancelInfo": { "refundPreview": { "refundAmt": 30000 } }
}

마커와 리스팅은 같은 API를 쓰게 하고, 카카오 광고는 건별로 보내는 구조라 회차에 묶지 않고 따로 뺐습니다. 프론트에서 늘 받아 보던 API 명세서를 이번엔 제가 쓰고 있었습니다.

이제야 코드를 쓸 차례였습니다.

컨트롤러에 엔드포인트를 파고 응답 코드를 직접 정하는데 기분이 묘했습니다. 늘 남이 주던 걸, 제가 저한테 주고 있었으니까요.

코드를 쓰면서는 정책도 새롭게 만들어 갔습니다

테이블과 API를 정했다고 끝이 아니었습니다. 정책도 새롭게 만들어 갔고, 정한 건 노션에 계속 쌓아 뒀습니다. 이런 것들입니다.

  • 갱신 결제는 3번까지만 재시도하고, 그래도 실패하면 구독을 해지합니다.
  • 결제와 동시에 광고가 노출되기 때문에, 당일에 환불해도 하루치는 차감합니다.
  • 기간 안에 못 쓴 쿠폰은 다음 달로 넘어갑니다. 이때 수량에는 넣고 예산에는 넣지 않습니다. 이전 회차 예산으로 이미 채운 쿠폰이니까요.
  • 쿠폰을 기간 안에 다 못 쓰는 매장이 계속 있어서, 영업 담당자가 어드민에서 구독 기간을 늘릴 수 있게 했습니다. 구독 기간마다 한 번, 최대 한 달까지입니다.
  • 화면의 "발급"은 사장님이 고른 수량이 아니라 사용자가 앱에서 실제로 받아 간 수입니다.

바뀐 것도 있습니다. 카카오 광고는 API까지 따로 설계했는데, 초기에 제대로 검토되지 않은 채 진행되다 보니 새로 알아보고 준비해야 할 게 많았습니다. 기간 안에 못 할 것 같아서, 신청은 자동으로 받고 최종 발송은 기존 방식대로 영업 담당자가 하는 걸로 바뀌었습니다.


그동안의 저: "이거 500 나는데 확인 부탁드려요"

연동하다 에러가 나면 저는 늘 스크린샷을 찍어서 백엔드 개발자분께 보냈습니다. "이거 500 나는데 확인 부탁드려요." 그러면 얼마 뒤 "고쳤어요" 하고 답이 왔습니다.

이 습관이 얼마나 몸에 배어 있었는지는 이번 작업을 하면서 알았습니다. 테스트하다가 400, 500 에러가 뜨자, 저도 모르게 백엔드 개발자분을 태그하고 "이런 에러가 발생했는데 확인 부탁드립니다"를 쓰고 있었습니다. 잘 적었나~ 하고 다시 읽어 보는데... 그 API, 제 거였습니다. 제가 만든 거요. 😅

조용히 글을 지우고 제가 직접 원인을 찾기 시작했는데, 그게 그렇게 낯설 수가 없었습니다. 늘 에러를 넘기던 쪽이었는데, 이번엔 넘길 데가 없었으니까요.


첫 요청부터 404가 났습니다

dev 서버에 배포하고 프론트에서 첫 요청을 보냈는데 결과는 404. 컨트롤러에 분명히 매핑했는데? 로컬에선 잘 되던 게 dev에서만 404였습니다.

알고 보니 원인은 제 코드가 아니라 길목에 있었습니다. dev 게이트웨이는 경로에 v2가 붙은 요청만 새 서버로 보내고, 나머지는 기존 레거시 서버로 보내고 있었습니다. v2를 안 붙인 제 요청은 레거시 서버로 흘러가서, 거기서 "그런 주소 없는데?" 하고 404를 받은 거였죠.

// 경로는 예시입니다
before: /api/service/subscribe
after : /api/service/v2/subscribe

고친 건 경로에 v2 두 글자였습니다.

그 뒤로도 에러는 꾸준했습니다. 일주일쯤 뒤엔 제 PR을 develop에 머지했더니 dev 서버가 아예 뜨지 않았습니다. 카카오 광고용 지역 목록 API를 새로 만들면서, 먼저 만들어 둔 옛 버전을 지우지 않았던 거였습니다. 같은 주소에 API가 두 개 있으니 Spring이 어느 쪽으로 보낼지 정하지 못하고 Ambiguous mapping을 던지며 부팅 단계에서 멈췄습니다. 요청이 실패하는 게 아니라 서버가 아예 안 뜨는 에러도 있다는 걸 이때 처음 알았습니다.


결제에는 "아직 모른다"가 있었습니다

"응답 하나 만든다"는 게 이렇게 정할 게 많은 일인 줄 몰랐습니다. 그중 제일 낯설었던 건 결제였습니다.

프론트에서 결제는 늘 "됐다 / 안 됐다" 둘 중 하나였습니다. 그런데 서버에서 보니 그 사이에 "아직 모른다"는 시간이 있었습니다.

처음엔 저도 프론트 감각대로 짰습니다. 구독을 신청하고 나서 바로 ACTIVE로 열었더니, 결제가 실패해도 화면에선 구독 중인 것처럼 보였습니다. 결제는 PG사에 요청을 보내고, 결과는 큐(SQS)를 타고 워커로 나중에 돌아옵니다. 신청하는 순간에는 결제가 될지 안 될지 서버도 모릅니다. 그래서 흐름을 이렇게 바꿨습니다.

비동기 결제 확정 흐름

쿠폰도 결제가 성공한 뒤에 만들어야 했습니다. 그래서 신청할 때 고른 쿠폰 구성을 구독에 JSON으로 저장해 두고, 결제가 성공하면 그걸로 쿠폰을 만들게 했습니다.

이 "아직 모른다"를 화면에서 받는 것도 제 일이었습니다.

  • 1초 간격으로 최대 10번 폴링
  • 10초 안에 확정이 안 되면 "결제 확인 중" 안내로 전환 (스피너에 갇히지 않게)
  • 창을 닫았다 다시 열어도 PENDING이면 폴링 재개
  • 신청 응답(PENDING)은 성공으로 치지 않고, 확정은 폴링 결과로만 판단
  • 같은 순간의 두 번째 클릭은 state로 못 막아서 ref로 한 번 더 차단

코드로 보면 이렇습니다.

// 단순화한 코드
const POLL_INTERVAL_MS = 1000;
const POLL_MAX_ATTEMPTS = 10;

const pollPaymentResult = async () => {
  if (pollingRef.current) return; // 이미 돌고 있으면 또 시작하지 않는다
  pollingRef.current = true;
  try {
    for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
      await sleep(POLL_INTERVAL_MS);
      const status = await fetchSubscribeStatus();
      if (status === "ACTIVE" || status === "FAILED") return; // 확정되면 끝
    }
    setPaymentTimedOut(true); // 10초가 지나도 PENDING이면 "결제 확인 중" 안내
  } finally {
    pollingRef.current = false;
  }
};

솔직히 프론트만 할 때는 폴링이 늘 불만이었습니다. 결과를 바로 상태값으로 내려주면 되지, 왜 프론트가 몇 번씩 다시 물어봐야 하지? 그런데 직접 짜 보니 바로 줄 수가 없었습니다. 신청 API가 응답하는 순간에는 결제 결과가 아직 PG사에서 큐를 타고 오는 중이라, 서버도 결과를 모릅니다. 결과가 나중에 오는 구조라서, 누군가는 다시 물어봐야 했던 겁니다.

그래서 지금은 고민의 방향이 바뀌었습니다. "왜 폴링을 해야 하지?"가 아니라 "결과가 오기까지 보통 얼마나 걸리지? 그동안 화면에 뭘 보여 주지?"를 먼저 생각합니다. 몇 초까지는 로딩으로 버티고, 그보다 늦어지면 어떤 안내로 바꿀지.

나중에 실제 데이터로 재 보니, 신청부터 결제 결과가 나오기까지 중앙값은 2.8초였고 30건 중 29건이 10초 안에 끝났습니다.

물론 한 번에 되진 않았습니다. 며칠 뒤엔 조회 API가 PENDING/FAILED인데도 회차·쿠폰 정보를 채워서 내려주는 바람에, 프론트가 그걸 보고 "구독 중" 화면을 그려 버렸습니다. 서버도 저, 화면도 저. 누굴 탓할 수도 없었습니다.

리뷰를 받고 고친 것도 있습니다. 결제 요청 메시지는 DB 저장이 커밋된 뒤에 보내야 하는데, 저는 그걸 TransactionSynchronizationManager에 직접 등록해서 처리하고 있었습니다. 리뷰를 반영해 예약 쪽 코드와 같은 구조(이벤트를 발행하고 @TransactionalEventListener(AFTER_COMMIT) 리스너가 보내는 구조)로 바꿨습니다. 동작은 같아도 팀의 다른 코드와 같은 모양이어야 다음 사람이 읽기 쉽습니다. 프론트에서 컨벤션 맞추는 거랑 똑같은 얘기였습니다.


에러가 왜 자주 나는지, 이제 압니다

1. 흔적도 없이 실패한 400 — 범인은 저였습니다

특정 매장에서만 광고 구독 신청이 계속 실패했습니다. 이상한 건 주문에도 결제에도 아무 기록이 없었다는 점입니다. 데이터가 아예 안 생겼다는 건 저장하기 전에 막혔다는 뜻이라, 여기서 범위가 확 줄었습니다.

원인은 쿠폰 사용 시간이었습니다. 그 매장은 쿠폰을 자정까지 쓸 수 있게 설정했는데, 화면이 그걸 24:00:00으로 보내고 있었습니다. 그런데 서버의 Java LocalTime은 00:00:00부터 23:59:59까지만 받습니다. 24:00:00은 시간이 아니라서 요청 본문을 읽는 단계에서 바로 400(HttpMessageNotReadableException)이 났고, 서비스 로직은 한 줄도 돌지 않았습니다. 시간 제한을 안 건 매장은 멀쩡하니까 특정 사장님만 실패하는 것처럼 보였던 거죠.

그리고 이 24:00을 보낸 화면은... 제가 만든 거였습니다. 🙃

// 화면엔 24:00으로 보여주고, 서버엔 23:59:59로 보낸다
const formTimeToServerTime = (time: string): string =>
  time === "24:00" ? "23:59:59" : `${time}:00`;

2. 결제는 됐는데 아무 일도 안 일어났습니다

결제 성공 뒤에는 해야 할 일들이 있습니다. 결제 로그 남기기, 슬랙 알림, 구독 활성화. 실패하면 재시도하고, 3번 실패하면 해지합니다. 그런데 이게 전부 안 돌았습니다.

워커의 결제 결과 리스너에는 처리할 주문 타입 목록이 있고, 목록에 없는 타입이면 메시지를 확인(ack)만 하고 조용히 넘깁니다. 문제는 그 목록에 제가 새로 만든 광고 구독 타입을 안 넣었다는 거였습니다.

// 단순화한 코드 (타입 이름은 바꿈)
if (orderTxTypeCd !in listOf(
        "AD_LISTING_LEVEL_3",
        "AD_SEARCH_LEVEL_1",
        // "AD_SUBSCRIBE_MARKER",  ← 이게 없었다
    )
) {
    ack.acknowledge()  // 내 결제 결과도 여기서 조용히 사라짐
    return
}

에러가 나면 차라리 낫습니다. 로그라도 남으니까요. 아무 일도 안 일어나는 게 제일 무섭습니다.

3. 순서 하나 때문에 "환불할 결제 내역을 찾을 수 없습니다"

구독 취소는 두 가지를 합니다. 레거시 쪽 구독을 만료시키고, 결제를 환불합니다. 처음엔 만료 → 환불 순서로 짰는데, 결과는 "환불할 결제 내역을 찾을 수 없습니다"였습니다.

환불 대상을 찾을 때 레거시 데이터를 기준으로 찾는 경로가 있는데, 바로 앞 단계에서 그 데이터의 종료일을 어제로 바꿔 버렸으니 못 찾은 거였습니다. 제가 방금 만료시킨 걸 제가 찾고 있었던 거죠.

순서를 바꿔서 환불(외부 호출)을 먼저 하게 했습니다. 그러면 덤으로, 환불이 실패했을 때 만료나 노출 변경이 아예 실행되지 않습니다. 외부 호출은 DB처럼 롤백이 안 되니까요. 실패할 수 있는 걸 앞에 두면 "반쯤 취소된" 상태가 생기지 않습니다. 반대편에 서 보지 않았으면 몰랐을 것 같습니다.


프론트로 돌아와서

프론트만 할 때는 에러를 보면 늘 "백엔드에서 왜 이렇게 했을까?"부터 생각했습니다. 응답이 이상하면 서버 쪽 사정이겠거니 하고, 스크린샷을 넘기는 데서 제 일이 끝났습니다.

지금은 제가 먼저 원인을 찾아봅니다. 요청 payload는 뭐였는지, 데이터가 저장됐는지 안 됐는지, 어느 단계에서 막혔는지. 그리고 "고쳐주세요" 대신 "이런 값이 들어가면 이 단계에서 에러가 나는 것 같아요"처럼, 어떤 상황 때문에 에러가 나는지를 같이 알려 드립니다.

화면을 짤 때도 그렇습니다. 서버가 "아직 모른다"를 줄 수 있다는 걸 알고 나니, 스피너 하나를 그릴 때도 타임아웃은? 새로고침하면? 두 번 누르면? 을 먼저 생각하게 됐습니다.


마무리

처음 404를 보고 웃었던 순간이 이번 작업에서 제일 기억에 남습니다. 백엔드 개발자에게 API를 받아서 바로 테스트해 보면 에러부터 뜨는 경우가 종종 있어서, "나는 안 그래야지!!" 하고 개발했는데... 첫 API부터 너무 당연하게 에러가 떠서 많은 생각이 들면서 헛웃음이 났습니다 ㅋㅋㅋㅋㅋ 제가 안 해 본 건 함부로 판단하면 안 된다는 걸 다시 또 뼈저리게 느꼈던 것 같습니다.

받기만 하던 사람에서, 만들어도 본 사람으로. 딱 한 뼘이지만 넘어왔습니다. 다음에 백엔드 개발자분께 에러를 보낼 땐, 스크린샷 말고 payload부터 보내려고 합니다. 🙂

다음 편에서는 출시하고 나서 겪은, 코드로는 못 고치는 문제들을 이어서 써 보겠습니다.

👉 2편: 출시하고 나서, 코드로는 못 고치는 것들