단건 결제를 정기 결제로 전환하면서 고민한 부분

1차 카테고리
Frontend
2차 카테고리
Payment
생성 일시
2025/01/17 08:46
최종 편집 일시
2025/06/02 02:06
발행여부
published
글 업데이트 로그
2025.05.22 - 글 수정 2025.05.02 - 글 수정 2025.04.30 - 글 수정, 가독성 개선
최근 단건 결제 방식을 구독 기반의 정기 결제로 전환하는 프로젝트를 진행했다. 실무에서 백엔드 작업은 처음이었고, 결제 도메인 역시 처음 접하는 상황이었다.
이에 기존 결제 코드와 포트원(PG)의 정기 결제 연동 흐름, 웹훅을 분석했고, 이 과정에서 서버 결제 로직을 구현과 코드를 개선하며, UX와 DX를 향상시키기 위한 고민을 기록하고자 한다.
서버는 Express(Node.js)를 사용했고, 정기 결제는 포트원(PG)의 비인증 결제 방식을 통해 연동했다. SDK 기반으로 빌링키 방식의 비인증 결제로 구현했다.
(공식 문서에는 명시되어 있지 않지만, 비인증 결제 방식에서 카카오페이와 KCP 간에 빌링키 발급 방식의 차이가 있다. 이 부분을 나중에서야 알게 되어 꽤 고생했었다. 꼼꼼하게 확인하자…)

1. 기존 결제 로직의 문제점

정기 결제로 전환하면서 서버의 기존 결제 로직에 다음과 같은 문제들이 있었다.
가독성
하나의 함수에 모든 로직이 순차적으로 나열되어 있어 흐름을 파악하기 어렵다. (결제 요청 → 응답 처리 → 상태 업데이트 → 후처리까지 모두 하나의 함수에 몰려있음)
예외 처리
결제 실패 시, 클라이언트가 정확한 실패 원인을 전달받지 못한다.
사용자에게는 “결제에 실패했습니다”라는 동일한 메시지만 보여진다.
로깅
예외 발생 시 단순히 console.error(e) 만 출력되기 때문에, 에러 발생 지점을 명확히 파악하기 어렵다.
.then() 지옥
비동기 로직이 .then() 체인 메소드로 순차적으로 사용되어 가독성이 떨어지고 코드 수정이 어렵다.
재현해보자면 대략 이런 느낌인데… 실제로 하나의 함수 안에 수백 줄의 코드가 들어 있었다.
try{ ... if(A라면){ A 작업.then() .then() .then() .then() ... }else if(B라면){ B 작업.then() .then() .then() ... }else if ... ... } catch(e){ console.error(e); }
JavaScript
복사

1-1. 사용자 결제 실패 원인 제공의 필요성

가장 크리티컬한 문제는, 클라이언트가 결제 실패 시 그 원인을 제공받지 못한다는 점이라 생각했다. 기존 로직에서는 결제 실패 시, 항상 동일한 오류 메세지만 전달하고 있었다. 즉, 서버에서는 단순히 console.error(e) 를 출력하고, 해당 에러를 그대로 클라이언트에 전달한다. 결과적으로 프론트단에서는 "결제에 실패했습니다"와 같은 일관된 메시지만 사용자에게 보여줄 수 밖에 없었다.
아래는 예외가 일관적으로 처리되는 서버 측 코드의 예시다.
try { ... if (status === 'paid') { ... // 결제 처리를 성공 => 데이터베이스에 결제 상태 업데이트 등 return res.status(200).send({ status: 'success', message: '결제 성공', }); } else if (status === 'cancelled') { ... // 결제 취소 처리 완료 => 데이터베이스에 결제 취소 상태 업데이트 완료 return res.status(200).send({ status: 'success', message: '결제 취소 성공', }); } ... } catch (e) { console.error(e); return res.status(400).send(e); } };
JavaScript
복사

1-2. 예외 발생 지점 파악의 어려움

위 코드에서 보이듯, 수많은 로직이 있음에도 로깅은 console.error(e) 하나뿐이었다. 예외가 발생한 지점을 찾기 위해 매번 에러 로그를 확인하고, 코드 흐름을 다시 추적해야 했다. 이런 과정을 반복하다 보니 점점 비효율적이라는 생각이 들었고, 예외 발생 지점을 한눈에 파악할 수 있는 구조로 바꾸고 싶었다.
그래서 정기 결제로 전환하는 과정에서, 단순히 결제 기능만 구현하는 데 그치지 않고, 아래처럼 UX와 DX까지 함께 개선하는 것을 목표로 삼았다.
사용자에게 정확한 결제 실패 메세지를 전달 (UX)
개발자의 예외 파악 시간 단축 (DX)

2. 결제 처리 로직 쪼개기

가장 먼저 한 작업은 결제 처리 로직을 기능별로 분리하는 것이었다. ‘기능별로 함수를 나누고, 각 함수에서 try-catch로 감싸면, 그 기능 내에서 예외 처리를 책임질 수 있지 않을까?’라는 생각이 들었기 때문이다.
그래서 기존의 .then() 메소드로 줄줄이 이어져 있던 로직들을 함수 단위로 분리하고, 각 함수에서 try-catch로 감싸 예외를 로깅하도록 구조를 바꿨다. 아래 예시 코드와 같다.
// 카카오페이 결제 async function kakaoBilling(...) { ... try { // 결제 성공시 DB 업데이트 await setPaymentSuccess(...); transaction.commit(); } catch (err) { transaction.rollback(); console.error('❌ ~ kakaoBilling ~ err:', err); throw err; } } // 다음 결제 예약 async function nextPaymentScheduling(...) { try { ... // PG사로 예약 요청 await postSchedule(...); } catch (err) { console.error('❌ ~ nextPaymentScheduling ~ err:', err); throw err; } }
JavaScript
복사

2-1. try-catch 예외 처리 구조화

기능별로 모듈화를 진행할수록 ‘구조가 깔끔해진다’고 느꼈다. 하지만 예외 처리가 2단계 이상으로 중첩되는 구조가 생기기 시작했다.
다음과 같은 구조를 보자.
- 최상위 try-catch └ 데이터베이스 상태 업데이트 try-catch └ PG사 결제 예약 스케쥴링 try-catch
Plain Text
복사
각 단계마다 try-catch가 존재하다 보니, 어디에서 예외를 처리해줘야할지 판단해야 했다. 그래서 2가지의 경우를 먼저 생각해봤다.
각각의 catch 블록에서 예외 처리
최상위 try-catch에서만 예외 처리, 하위 try-catch는 throw만
처음에는 첫번째처럼 ‘그냥 각 함수 catch 블록에서 처리하면 되지 않을까?’ 라고 생각했지만, 클라이언트 응답까지 각 함수 catch 블록에 넣어줘야되서 직관적으로 배제해야겠다고 판단했다.
그래서 2번을 선택하게 되었고, 예외 처리를 위해 다음과 같은 원칙을 정했다.
하위 함수에서는 로깅과 throw만
→ “여기서 문제가 발생했다”는 정보만 던지고, 예외 처리 책임은 지지 않는다.
console.error(e) 상세 로깅을 해준다.
최상위 catch에서 모든 예외 처리 담당
→ 사용자에게 보여줄 메시지, 서버 응답 형식, 로깅 등은 여기서 통일 처리
정리하자면
1.
데이터베이스 상태 업데이트 또는 PG사 결제 예약 스케쥴링중 하나라도 실패하면, 그 에러는 최상위 try-catch로 전달된다.
2.
최상위에서 예외 처리를 해주고 응답 객체를 클라이언트에 반환한다.
try { // 결제 성공 시, await setPaymentSuccess(...); // DB 업데이트 await postSchedule(...); // 정기 결제 예약 res.status(200).json(...); } catch (e) { console.error('[결제 처리 실패]', e); res.status(500).json({ ... }); }
JavaScript
복사
이제 2가지가 가능해졌다.
최상위 try-catch에서만 예외 처리를 담당
→ 클라이언트에게 정확한 결제 실패 메세지를 전달할 수 있게 됐다.
하위 함수에서는 로깅과 throw만
→ 단계별로 로깅이 가능해져 개발자의 예외 파악 시간 단축
마지막으로 트랜잭션 처리도 함께 묶었다. 만약, 데이터베이스 상태 업데이트 또는 PG사 결제 예약 스케쥴링중 하나라도 실패하면 최상위의 catch 블록에서 PG사에 결제 취소 요청을 하도록 했다.
관심사 분리
‘누가 예외를 처리할 것인가?’에 대한 고민이 결국 소프트웨어 설계 원칙에서 나오는 관심사 분리(Separation of Concerns)라는걸 알게되었다. 처음엔 그냥 ‘기능별로 나눈건데 왜 관심사 분리지?’ 생각했지만, 에러 처리와 비즈니스 로직은 각각의 책임 안에서라는 분리한 구조는 ‘아키텍처 관심사 분리’에 해당된다고 한다.
사실 관심사 분리, SOLID 같은 용어들은 익숙하게 들어왔지만, 이러한 부분을 의식하면서 코드를 작성해본 기억은 없었다. (반성...) 하지만 더 효율적인 코드를 작성하려고 고민할수록 자연스럽게 설계 원칙과 디자인 패턴에 가까워진다는 것을 경험했고, 이 경험이 꽤 인상 깊었다.
이번 경험을 통해 설계 원칙과 패턴에 대한 이해가 있으면 더 빠르고 안정적인 설계가 가능하다는 것을 체감했고, 앞으로 관심을 가지고 학습하고자 한다.

2-3. 에러 로깅

이전에는 각 모듈화된 하위 함수의 catch 블록에서 로깅을 해주고 있었다. 그런데 문득, ‘최상위에서 에러를 받아서 한 번만 로깅하면 되는 거 아닌가?’ 라는 생각이 들었다.
실제로 나는 예외를 모두 throw로 최상위로 던져줬고, 최상위 catch에서 한 번만 받아 클라이언트 응답 + 에러 로깅을 처리하고 있었다.
왜 나는 각 모듈마다 catch 블록에서 로깅을 또 하고 있는지 헷갈렸다. 고민하다가 개발자가 한눈에 중첩된 예외 구조를 파악하기 위해서라는 것을 깨달았다.
예를 들어, 아래 그림의 findSubscribe 함수는 클라이언트의 결제 요청이든, 웹훅을 통한 자동 결제든 여러 흐름에서 공통으로 호출된다.
물론 최상위 try-catch에서 클라이언트 요청인지 웹훅 요청인지 구분으로도 어느 정도 처리는 가능했다. 하지만, 어디서 예외가 발생했는지 아는 것만으로는 흐름을 파악하기 어려웠다.
따라서 어떤 흐름을 거쳐 그 지점에 도달했는지를 알기 위해서 각 단계마다 로깅이 필요했다. 이를 통해 전체 결제 흐름을 한 눈에 추적할 수 있기 때문이다.
즉, 아래와 같은 흐름을 한눈에 파악하기 위해서 각 catch 블록에 로깅을 해줬다. 그러면 예외가 발생했을 때, 어떤 단계별로 예외 처리가 진행되었는지 한눈에 파악하기 쉽다.

2-4. 클라이언트 요청, 웹훅 이벤트의 중복 결제 처리

상세 로깅을 확인하고 나서 파악이 된 문제점이 있었다. 바로 클라이언트 요청과 웹훅 요청이 중복 처리되고 있다는 것이었다.
사용자가 결제를 완료하면 프론트엔드에서 서버로 결제 처리 요청을 보내고, 동시에 포트원(PG)에서도 웹훅을 통해 결제 완료 처리에 대한 정보를 서버로 전달한다. 이 두 요청은 서로 별개의 흐름으로 작동하면서, 결국 같은 결제 건을 두 번 처리하게 되는 문제가 발생했다.
처음엔 ‘DB에 status 필드를 체크해서 이미 처리된 건이면 건너뛰면 되겠지’ 하고 단순하게 생각했다. 하지만 예상과는 다른 결과가 나왔다.
웹훅과 클라이언트 요청이 거의 동시에 들어오는 경우, 아직 DB에 반영되기 전이기 때문에, 둘 다 “처리되지 않은 상태”로 인식하고 동시에 로직을 실행해버리는 상황이 발생했다.
이는 트랜잭션 격리 레벨에 대한 이해가 필요했다.
이 중복 처리 문제를 해결할 수 있는 방법은 크게 두 가지가 있었다.
1.
Sequelize 트랜잭션으로 결제 처리 중 잠그기
가장 먼저 떠올린 방법은, 같은 결제 건(merchant_uid)에 대해 동시에 요청이 들어와도 오직 하나의 요청만 결제 로직을 실행하도록 제어하는 것이다.
const result = await sequelize.transaction(async (t) => { const payment = await Payment.findOne({ where: { merchant_uid }, lock: t.LOCK.UPDATE, // 행 잠금 (FOR UPDATE) transaction: t, }); if (payment.is_paid) { // 이미 처리된 결제 return; } // 결제 상태 업데이트 await payment.update({ is_paid: true }, { transaction: t }); // 후속 처리... });
JavaScript
복사
이 코드는 결제 row 자체를 잠그는 row-level lock 방식이다. 즉, 두 개의 요청이 동시에 들어오더라도 첫 번째 요청이 해당 row를 잠근 상태면, 두 번째 요청은 대기하게 된다.
그럼 다른 사용자들의 결제는 괜찮을까?
이 방식은 해당 결제 건의 row만 잠그기 때문에, merchant_uid가 다른 다른 사용자의 요청에는 전혀 영향을 주지 않는다. 즉, 동시성 문제를 해결하면서도 병렬성은 유지되는 구조다.
2.
트랜잭션 격리 수준 조정
Sequelize의 기본 트랜잭션 격리 수준은 READ COMMITTED다. 이는 아직 커밋되지 않은 데이터는 다른 트랜잭션에서 읽을 수 없도록 막는 수준이다.
하지만 나와 같은 문제가 발생한다.
클라이언트 요청은 트랜잭션 A,
웹훅 요청은 트랜잭션 B라고 하자.
이때, 트랜잭션 A가 먼저 SELECT를 실행해서 is_paid = false를 확인했고, 거의 동시에 트랜잭션 B도 SELECT를 실행해 동일한 is_paid = false를 보게 된다. 이렇게 되면 각각의 트랜잭션이 독립적으로 결제 처리를 시도하게 되고, 결국 동시성 제어가 실패한다.
그러면 READ UNCOMMITED격리 수준이면 해결될까? READ UNCOMMITED면 다른 트랜잭션에서 수정된 값을 조회할 수 있다.
안된다!
예를 들어 트랜잭션 A가 is_paid = true로 값을 수정하고 아직 커밋하지 않은 상태에서, 트랜잭션 B가 READ UNCOMMITTED으로 해당 값을 읽는다면 커밋되지 않은 중간 상태의 값을 먼저 읽어버리는 상황이 발생할 수 있다.
만약 트랜잭션 A가 이후에 롤백된다면, 트랜잭션 B는 존재하지 않는 데이터를 기준으로 처리하게 되어 데이터 일관성이 깨질 수 있다.
따라서 row-level lock을 활용해 결제 로직을 감싸는 방식이 가장 안전하고 효과적인 해결책으로 보인다.
[Client Request] --> [try lock row] --> [결제 처리] --> [commit] [Webhook Request] --> [row locked → 대기] --> [is_paid 확인 후 skip]
Plain Text
복사
await sequelize.transaction(async (t) => { const payment = await Payment.findOne({ where: { merchant_uid }, transaction: t, lock: t.LOCK.UPDATE, }); if (payment.is_paid) { return; // 이미 처리됨 } // 결제 처리 로직 await payment.update({ is_paid: true }, { transaction: t }); });
JavaScript
복사

3. 에러 코드 설계

지금까지 기능별로 모듈화하고 예외 처리 구조화와 상세 로깅을 위한 작업을 했다. 그런데 모듈화된 함수마다 예외 처리 메세지를 직접 작성하는 일이 반복되었다.
그래서 errorCode만 넘기면, 에러 메시지, HTTP 상태 코드, 내부 코드 등을 포함한 에러 응답 객체를 반환하는 유틸 함수를 만들었다.
이제는 예외 상황에서 단순히 에러 코드를 넘기기만 하면 되기 때문에, 처리도 훨씬 간편해졌고, 전체 코드 구조도 훨씬 깔끔해졌다.
const getErrorObj = (errorCode) => { switch (errorCode) { ... case '2000': return { message: '[PG Error]: 결제 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', error_code: 2000, code: 'fail', }; case '2001': return { message: '[PG Error]: 결제 정보 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', error_code: 2001, code: 'fail', }; ... } }
JavaScript
복사
에러 코드
지금 생각해보니 숫자로 표현한게 보기가 어렵다.. 그냥 PG_PAYMENT_ERR , PG_FETCH_FAILED 로 코드를 만들었으면 더 편했을거 같다.
const getErrorObj = (errorCode) => { switch (errorCode) { case 'PG_PAYMENT_ERR': return { message: '결제 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', error_code: 'PG_PAYMENT_ERR', code: 'fail', } ... } }
JavaScript
복사
처음엔 Microsoft와 같은 대기업에서 숫자 기반으로 에러 코드를 사용하는 것을 보고 따라서 설계했다. 숫자 기반 코드가 문자열보다 데이터 크기도 작고, 파싱 속도도 빠르다 보니 한국어, 영어, 일본어 등 다국어 지원이 필요한 글로벌 서비스라면 이 방식이 훨씬 효율적이라 한다.
error_code
lang
message
1001
ko
결제 실패: 카드 승인 거절
1001
en
Payment failed: Card declined
1002
ko
세션이 만료되었습니다.
1002
en
Session expired. Please log in.
하지만 내가 진행한 프로젝트처럼 한정된 사용자 환경에서 빠르게 문제를 파악해야 하는 경우라면, PG_PAYMENT_ERR 같은 식별 가능한 문자열 기반 코드가 오히려 디버깅과 로그 분석에는 더 직관적일 수도 있을거 같다.

4. 가독성 높이기

모듈화를 진행하면서 함께 신경 쓴 부분이 바로 가독성 개선이다. 사실 기능 구현과 리팩토링은 분리해서 진행하는 것이 이상적이라고들 하지만… 현실에서는 늘 시간과의 싸움이다 보니, 이번에는 어쩔 수 없이 병행하게 됐다.
기존 코드에서는 분기 처리에 사용된 식별자들의 의미를 한눈에 파악하기 어려웠고, 덕분에 로직이 어디서 어떻게 갈리는지 파악하는 데 시간이 꽤 걸렸다. 게다가 콜백 안에 또 콜백이 중첩되어 있어서, 코드를 따라가다 보면 어느 순간 흐름을 놓치기 십상이었다.
if (res === 1) { // 어떤 값이 담겨있는지 파악하기가 어려웠움. ... Model.findOne({ where: {MERCHANT_UID}, }).then(res => { Model2.update({ ... }).then(res2 => { }) ... } else (x === 2)...
JavaScript
복사
그래서 이런 비동기 처리 흐름은 전부 async-await 방식으로 바꿔줬고, 변수명이나 함수명도 최대한 의미가 드러나게 작성해서, 코드만 봐도 무슨 역할을 하는지 바로 파악할 수 있도록 정리했다.
try { const user = await findUser(...); const nextSchedule = genNextSchedule(paymentDataFromPG); // 결제 정보 업데이트 await updateDB(...); transaction.commit(); } catch (err) { console.error('❌ ~ kakaoBilling ~ err:', err); transaction.rollback(); throw err; }
JavaScript
복사

5. 정리

이번 프로젝트에서는 처음으로 백엔드와 결제 도메인까지 직접 다뤄보는 경험을 했다. 돌이켜보면 아쉬운 부분이 많다.
예를 들어, CI/CD를 도입해 개발 생산성을 높이거나, 자동 테스트를 통해 결제 로직을 안정적으로 검증하는 시도도 해보고 싶었지만, 기능 구현, 코드 개선, QA까지 모두 챙기기엔 시간이 부족했다.
무엇보다도 가장 중요한 목표는 안정적인 결제 처리였다. 그래서 데이터가 제대로 입력되는지, 예외 처리가 정확히 작동하는지에 집중해 개발을 진행했다.
실제로 실 결제 QA만 300건 이상을 수행했고, 테스트 결제 모듈에서는 누적 결제 금액이 1억 원을 넘는 일도 있었다. 이 과정에서 DB에 데이터가 정확히 반영되는지 하나하나 꼼꼼히 확인했고, 그만큼 테스트의 중요성을 절실하게 느낄 수 있었다.
특히, 이런 외부 API 의존 로직도 테스트 자동화가 가능했다면 개발 생산성이 두 배는 더 높아졌을 것이라는 생각이 들었다. 그래서 앞으로는 프론트엔드에서도 테스트는 꼭 제대로 공부해봐야겠다는 다짐을 하게 됐다.
물론 프론트엔드 개발도 병행하긴 했지만, 고난이도의 작업을 경험하지는 못해서 아쉬움이 남는다. 그럼에도 불구하고, 결제 도메인 개발 자체가 정말 재미있었고, 이 경험을 통해 한층 더 성장할 수 있었다고 생각한다.

 Reference