최근 결제 방식을 정기 구독으로 전환하는 작업을 했었다.
결제 모듈은 처음 다뤄보는 작업이라, API 문서와 웹훅에 대해 많은 공부가 필요했다.
특히, 포트원 API 문서만 몇번을 읽었는지… 읽어도 읽어도 이해가 가지 않는 부분이 많았음.
그렇게 머리를 싸매며 SDK 기반으로 빌링키를 이용한 비인증 결제 방식으로 진행하였다.
음… UI를 직접 만들 수 있는 상황도 아니고 주문고유번호를 활용한 빌링키 방식이 제일 적합해 보였다.
•
빌링키: 구독형 정기결제, 종량제 과금결제 등 구현 시 원하는 시점에 재 결제를 진행할 수 있는 결제용 암호화 키. 고객사가 고객의 카드 정보를 소유할 수 없기 때문에 카드사로부터 해당 카드에 대응하는 빌링키를 발급받아 저장하고, 이후 원하는 시점에 해당 빌링키로 결제를 청구하기 위해 사용
여튼, 현재 상황에서 정기 결제로의 전환은 필수적이었다.
하지만 레거시 코드에서 개선할 부분들이 너무 많았다는거..
또한, 나중에 PG사별로 정기 결제 처리 방식이 다르다는 것을 알았을 때는 상당히 오열했다…
(백엔드 개발자들 짱..
)
1. 문제점
크게 다음과 같은 문제가 있었다.
•
기존의 결제 코드에서는 하나의 함수에서 모듈화 없이 너무 많은 기능을 순차적으로 처리하고 있었다.
안그래도 가독성이 안 좋은데, 분기처리가 너무 많았다.
•
그 예외 처리에 대한 에러 로그는 console.error(e) 하나가 다였다.
•
비동기 처리들을 .then() 메소드 체이닝으로 처리해 가독성이 심각하게 더 떨어졌다.
•
Express.js로 구성되어있는데, 파일 구조가 너무 어려워서 파일 찾는 것조차 어려웠다…
프론트엔드나 백엔드나 구조의 복잡도를 해결한 아키텍쳐를 제공하는 Next.js, Nest.js가 있다.
Next.js에서는 라우팅의 복잡도를 디렉토리 기반으로 파일 시스템 라우팅으로 해결해줬다.
Nest.js에서는 모듈 기반의 아키텍쳐로(Post, User 등등)으로 API를 찾기가 매우 간편했다.
큰 작업이 되겠지만, 적극 마이그레이션을 해야되지 않나 생각했다.
기존 코드가 이런 느낌인데, 이게 한 백줄은 기본으로 넘어갔던 기억이…
try {
if(A라면){
A 작업.then()
.then()
.then()
}else if(B라면){
B 작업.then()
.then()
.then()
}else if ...
...
} catch(e){
console.error(e);
}
JavaScript
복사
이게 뭐가 문제야?
1.
어느 지점에서 문제가 발생했는지 파악이 어렵다.
어디에서 오류가 발생한건지 명령형의 코드를 일일히 다시 읽어야 했고,
분기처리 때문에 흐름 파악이 어려웠다.
리팩토링하는데 시간은 걸리고, 기능 구현은 빠르게 해야되고 상당히 어려웠다..
2.
예외 처리에 대해서 클라이언트에게 정보를 주지 못하고 있었다.
어떻게 보면 가장 크리티컬한 문제가 아닌가…?
기존에는 결제 처리 성공에 대해서만 사용자에게 알리고, 실패의 경우에는 일괄적인 오류 처리를 했다.
따라서, 클라이언트가 예외 처리 정보를 정확히 알도록 해서 사용자 경험을 개선하고자 했다.
기존의 코드에서는 하나의 큰 try 안에 DB 조회, 외부 API 호출, 로직 처리 등등
여러 코드들이 순차적 처리되는 스파게티 코드(모놀리식 함수)였다.
이는 예외 처리가 발생했을 때, 어느 지점인지 파악하기 어렵게 만들었다.
2. 문제 개선 - 모듈화
위와 같은 문제점들이 있었고, 이걸 어떻게 개선하려 했는지 보여주고자 한다.
•
하나의 catch 블록에서 처리하면 모든 에러에 대해 똑같은 메시지 또는 처리 방식을 쓰게 된다.
•
즉, 어떤 예외 처리에 대해서도 status를 400을 보내주게 된다.
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.log(e);
return res.status(400).send(e);
}
};
JavaScript
복사
그래서 가장 먼저 기능별로 모듈화를 우선적으로 했다.
이렇게 하면 각각의 모듈의 영역에서, 예외 처리가 발생할 경우 해당 영역의 상세 에러를 로깅할 수 있다.
// 카카오페이 결제
async function kakaoBilling(...) {
...
try {
// 결제 성공시 DB 업데이트
await updateSuccessfulPayments(...);
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
복사
음… 하지만 try-catch가 중첩되면서 err 컨트롤을 어떻게 해줘야 되는지 고민했는데,
중첩된 try-catch로 에러 처리를 누가 해야되는지에 대해 구조가 복잡해지는 문제가 있었다.
나는 최상위 try-catch에서만 예외 처리에 대한 컨트롤을 하고, 하위에서는 throw만 해주는 원칙을 세웠다.
내가 새로 구성한 단계별 try-catch 구조를 간단하게 그려보면 아래와 같다.
기능 구현을 하면서 시행착오 후 탄생한 구조다.
정기 결제 요청 로직의 주요 처리는 아래과 같다.
1.
데이터베이스 상태 업데이트
2.
PG사 결제 예약 스케쥴링
아래 그림에서 findSubscribe 함수는 여러 결제 처리 로직에서 공통적으로 호출된다.
기존 코드에서의 로깅으로는 어느 결제 처리 로직에서 에러가 발생했는지 파악하기 어렵다.
이를 정확하게 추적하기 위해서는 중첩된 try-catch 구조가 필요했고, 각각의 상세 로깅이 필요했다.
정말 신기하게도 내가 한 고민들이 관심사 분리라는 것을 나중에 깨달았다.
사실 이런 아키텍쳐의 용어들이 익숙하지는 않은데, 자연스럽게 고민하고 적용하고 있다는 것이 신기했다.
하지만 ‘나는 모듈화한 것뿐인데 이게 관심사 분리야?’란 생각이 들었다.
최상단은 예외 처리를하고, 하위에서는 도메인 처리 맡도록 역할을 분리한 것이 관심사 분리에 해당된다고 한다.
즉, 아키텍쳐 측면에서는 → 관심사 분리
코드 레벨에서는 SOLID 원칙 중 → SRP 적용
여기서 갑자기 디자인 패턴과 SOLID가 좀 헷갈렸는데, 아래 예시가 아주 적절한거 같다.
SOLID 원칙 | 건축의 설계 철학 (예: “지진에 강한 구조로 지어야 한다”) |
디자인 패턴 | 그걸 반영한 구체적인 건축 방식 (예: 내진 설계 기법, 철골 구조 등) |
디자인 패턴도 패턴은 보면 알겠는데, 용어를 잘 기억을 못하겠다…
이 부분도 조만간 정리를 해보려 한다.
3. 문제 개선 - 에러 패턴 생성
정리하자면, 1.문제점에서 모듈화를 통해 기능을 분리했고,
오류 파악 지점을 명확하게 알 수 있도록 상세 로깅을 했으며,
클라이언트에게 오류에 대한 상세 메시지를 보내 프론트단에서 예외 처리가 가능하도록 만들었다.
근데 각각의 결제 관련 모듈마다 예외 처리 메세지를 직접 입력하는게 버겁다.
공통화 할 수 있는 방법이 필요했다.
따라서 아래와 같이 errorCode만 입력하면 에러 메세지, 에러 코드, code의 정보를 담는 객체를 제공하는 유틸함수를 만들었고, 예외 처리에서는 해당 에러 코드를 반환하게 해줬다.
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
복사
4. 문제 개선 - 가독성
모듈화를 하면서 같이 진행한 부분이 가독성 개선이다.
리팩토링까지 혼자 진행하려다보니 버겁긴 했다.
기능 구현과 리팩토링은 철저히 구분이 되어야 된다는데,
시간이 없다보니 같이 진행하게 되었다.
여튼 기존코드에서는 분기 처리에 사용된 식별자들이 의미가 불명확했다.
따라서 분기 처리 기준을 파악하기 어려워 로직의 흐름을 이해하기 어려웠다.
if (res === 1) { // 어떤 값이 담겨있는지 파악하기가 어려웠움.
...
Model.findOne({
where: {MERCHANT_UID},
}).then(res => {
Model2.update({
...
}).then(res2 => {
})
...
} else (x === 2)...
JavaScript
복사
비동기 처리를 .then 후속 처리 메소드로만 구현해 부분 수정이 필요할 때도,
전체 코드를 다시 해석하거나 전체를 수정해야 되는 상황이 발생했다.
이러한 문제들을 해결하기 위해 기능별 로직 분리가 필요했고, async-await을 사용해 동기 처리를 해줬다.
5. 정리
이 프로젝트를 진행하면서 처음 백엔드 로직을 다루다보니 아쉬운 부분이 많다.
여러 공통화를 진행해 재사용성을 높일 수 있는 부분도 있었고,
에러 패턴도 더 효율적으로 작성하지 못한 문제도 있었고,
또한, 로그, 트랜잭션 레벨을 고려하지 못한게 좀 아쉽다.
여러 아쉬운 점이 존재했지만, 에러 및 예외 처리의 신뢰도가 가장 중요하게 생각하고 개발했다.
결제가 가장 예민한 부분이고 절대 결제 무결성이 깨지면 안되기 때문이다.
시간적인 문제로 기능 구현에 집중했었지만, 실 결제만 300번이 넘는 QA를 직접 진행하는 상황이 있었다.
따라서, 앞으로 이런 외부 API에 의존하는 코드를 자동 테스트할 방법에 대해서 공부해보려 한다…
Reference
부족한 글 읽어주셔서 감사합니다~
피드백 환영합니다~