Fullstack · 프리랜서 · 2026.05 – 09
위브닝Wevening
성향 테스트로 낯선 4~6명을 매칭해 저녁 다이닝 모임을 주선하고, 모임 후에는 인앱결제 재화(Wev)로 1:1 채팅을 여는 소셜 다이닝 서비스입니다. 서버(NestJS)와 운영 어드민 웹(React)을 모두 맡아 스토어 출시까지 책임졌습니다.
- 역할
- 풀스택: 서버 주 개발, 운영 어드민 웹 단독 개발
- 규모
- API 118개 · 테스트 2,215건 · 운영 화면 10개
- 01인앱결제 이중 지급 차단과 환불 추적
- 02보상 지급 직렬화
- 03성향 기반 그룹 매칭 (서버 + 어드민)
- 04스스로 복구하는 크론
- 05동시 401 토큰 갱신 단일화 (어드민)
- 06되돌릴 수 없는 운영 작업의 안전장치 (어드민)
- 07브랜치 기반 환경 분리 배포와 PWA (어드민)
Case 01
인앱결제 이중 지급 차단과 환불 추적
중복 제출 판정을 애플리케이션에서 DB unique 제약으로 옮겨, 동시 요청에서도 재화가 한 번만 지급되게 했습니다.
문제
- 안드로이드에서 결제 직후 같은 영수증이 거의 동시에 두 번 제출됐습니다. 한쪽 요청은 서버 오류로 실패하고, 그 실패 처리가 이미 검증된 영수증을 FAILED로 덮어썼습니다.
- 스토어에서 환불이 일어나도 서버는 알 수 없어, 환불된 결제가 서비스 안에서는 유효하게 남았습니다.
판단 근거
- '먼저 조회한 뒤 저장'하는 방식은 두 요청이 동시에 조회를 통과할 수 있어 경쟁 조건을 막지 못합니다. 최종 판정은 DB unique 제약이 맡아야 합니다.
- unique 위반은 오류가 아니라 '다른 요청이 이미 지급을 마쳤다'는 뜻입니다. 실패가 아닌 멱등한 응답으로 다루고, 영수증 상태는 건드리지 않아야 합니다.
해결
- 결제, 영수증, 잔액, 거래 원장을 하나의 트랜잭션으로 묶고 잔액은 원자적으로 올렸습니다. 외부 스토어 검증은 트랜잭션 밖에서 해 잠금 시간을 줄였습니다.
- unique 위반이 나면 '이미 지급됨(409)'으로 응답하고 영수증 상태는 그대로 둡니다. 다른 계정에 등록된 영수증의 재사용도 막았습니다.
- Apple 거래는 운영 환경을 먼저 조회하고 없으면 sandbox로 다시 조회해, 심사·TestFlight 결제도 검증되게 했습니다.
- Apple·Google 환불 알림 웹훅을 연동하고, 관리자 확인은 조건부 갱신으로 한 번만 기록합니다.
결과
- 중복 제출에서도 이중 지급이 없고, 결제 직후 오류가 깜빡이던 문제가 사라짐
- 결제부터 스토어 환불까지 서버에서 추적 가능
Case 02
보상 지급 직렬화
PostgreSQL 외래 키 잠금과의 충돌을 분석해 FOR UPDATE 대신 FOR NO KEY UPDATE를 골랐고, 상황에 맞는 도구로 다른 경쟁 조건도 없앴습니다.
문제
- 매칭이 실패하면 현금 결제자에게 보상 참가권을 주는데, 1인 보유 상한이 있습니다.
- 같은 사용자의 실패 처리가 크론과 관리자 수동 처리에서 동시에 돌면, 둘 다 '상한 미만'으로 판단해 상한을 넘겨 지급할 수 있었습니다.
판단 근거
- 사용자 행을 잠가 직렬화하는 것이 가장 단순합니다.
- 하지만 FOR UPDATE로 잠그면, 같은 트랜잭션에서 참가권을 추가할 때 외래 키 검사가 요구하는 KEY SHARE 잠금과 충돌해 교착 상태가 생길 수 있습니다.
- FOR NO KEY UPDATE는 KEY SHARE와 충돌하지 않으면서, 같은 행을 잠그려는 다른 트랜잭션은 막아 줍니다.
해결
- 사용자 행을 FOR NO KEY UPDATE로 잠근 뒤 보유 수를 세고, 상한 미만일 때만 지급합니다.
- 관리자 Wev 지급에도 같은 잠금과 멱등 키를 적용해 사용자 정지·탈퇴와도 직렬화되게 했습니다.
- 다른 경쟁 조건에는 상황에 맞는 도구를 골랐습니다. 신고는 Serializable 트랜잭션과 제한된 재시도, 상태 전이는 조건부 갱신, 중복 생성은 unique 제약으로 막았습니다.
결과
- 상한 초과 지급과 교착 상태 없이 지급이 직렬화됨
- 프로모션 코드 1인 한도, 동시 신고 중복 등 다른 경쟁 조건도 잠금·조건부 UPDATE·unique 제약으로 제거
Case 03
성향 기반 그룹 매칭 (서버 + 어드민)
운영자가 수작업하던 4~6인 편성을 '알고리즘 제안 → 운영자 조정 → 확정' 흐름으로 바꿨습니다.
문제
- 회차마다 운영자가 그룹을 직접 짜고 있어 시간이 많이 들고 결과가 일관되지 않았습니다.
- 성향 검사 미응답자, 성비, 과거에 같이 앉았던 사람과의 재회 같은 조건도 함께 따져야 했습니다.
판단 근거
- 완전 자동화는 현장 사정을 아는 운영자의 판단을 배제하고, 완전 수동은 시간이 너무 많이 듭니다.
- 최적의 조합을 찾는 문제는 경우의 수가 폭발적으로 늘어납니다. 운영자가 검토하는 흐름에는 빠르고 결과가 항상 같은 그리디가 더 맞습니다.
- 과거 동반 이력을 강제 조건으로 두면 인원이 적을 때 그룹을 아예 못 만들 수 있어, 우선순위와 경고로 다뤘습니다.
해결
- Big Five 점수를 정규화해 가중 궁합 거리로 계산하고, 후보를 재회 회피 → 성향 응답자 → 성비 순으로 좁힌 뒤 그룹과 가장 가까운 사람을 고르는 그리디 알고리즘을 만들었습니다.
- 동점이면 ID 순으로 정해 같은 입력이면 항상 같은 결과가 나오고, 성비 불균형 같은 위험은 운영자 검토용 경고로 내보냅니다.
- 어드민에서는 제안 결과를 저장하지 않는 미리보기로 보여주고, 인원을 다른 그룹이나 미배정으로 옮기는 편집 화면과 확정·취소 흐름을 만들었습니다.
결과
- 그룹 구성이 수작업에서 '자동 제안 + 운영자 조정'으로 바뀌어 매주 운영의 표준 절차로 자리 잡음
- 결과가 결정적이라 단위 테스트로 알고리즘의 동작을 고정
Case 04
스스로 복구하는 크론
모든 크론을 멱등하게 설계해, 실행을 놓쳐도 최대 5분 안에 다음 실행이 따라잡도록 했습니다.
문제
- 식당 정보 공개 크론이 하루 한 번만 돌았는데, 공개 시각도 정확히 그 시점이었습니다. 배포나 DB 순단으로 한 번만 놓쳐도 참가자는 모임이 시작된 뒤에야 장소를 알게 됐습니다.
- 관리자가 한 명을 수동으로 실패 처리하면 인원이 줄어, 나머지 신청까지 연쇄로 실패할 위험이 있었습니다.
판단 근거
- 조회 조건이 이미 '공개 시각이 지났고 아직 공개 전인 그룹'이라 멱등합니다. 실행 주기만 줄이면 놓친 처리를 다음 실행이 따라잡습니다.
- 연쇄 실패는 같은 회차를 반복 평가해서 생기므로 회차별 '이미 평가함' 표시가 필요합니다. 다만 일부만 성공했는데 표시를 남기면 남은 환불이 영원히 처리되지 않습니다.
해결
- 공개 크론을 5분마다 돌게 바꿨습니다.
- 회차별 평가 완료 표시는 남은 실패 대상이 0건일 때만 남기고, 일부 실패 시 다음 실행에서 다시 시도합니다.
- 회차 단위로 예외를 격리하고, 알림은 환불 트랜잭션이 커밋된 뒤에 보내 알림 실패가 환불을 다시 일으키지 않게 했습니다.
결과
- 실행을 놓쳐도 최대 5분 안에 자동 복구
- 수동 처리와 자동 처리가 충돌하지 않음
Case 05
동시 401 토큰 갱신 단일화 (어드민)
진행 중인 갱신 Promise를 공유해, 동시에 몇 개의 요청이 401을 받든 refresh 호출은 1회만 나가게 했습니다.
문제
- 운영 화면은 API 여러 개를 동시에 호출합니다. 토큰이 만료되면 요청마다 refresh를 따로 시도해, 새로 발급된 refresh 토큰이 서로를 무효화하고 로그인이 풀릴 수 있었습니다.
판단 근거
- refresh 토큰을 교체하는 구조에서는 갱신 요청이 반드시 한 번만 나가야 합니다.
- 실패한 요청을 다시 보낼 때는 횟수 상한이 있어야 무한 루프가 생기지 않습니다.
해결
- 갱신 중이면 같은 Promise를 기다리게 하고, 401을 받은 요청은 갱신 후 1회만 다시 시도합니다.
- 갱신에 실패하면 토큰을 지우고 로그인 화면으로 보냅니다.
- 에러를 ApiError(상태, 코드, 메시지)로 통일해 화면은 인증을 신경 쓰지 않고 API만 호출합니다.
결과
- 동시 요청에서도 refresh 호출은 1회로 보장
Case 06
되돌릴 수 없는 운영 작업의 안전장치 (어드민)
환불·보상·푸시가 한 번에 일어나는 작업에서 화면이 서버 상태와 어긋나지 않게 하고, '세션이 앱에 안 보이는 이유'를 운영자가 직접 확인하게 했습니다.
문제
- 매칭 실패 처리는 결제 환불, 보상 참가권 지급, 앱 푸시 발송이 한 번에 일어나고 되돌릴 수 없습니다.
- 요청 도중 모달을 닫거나 버튼을 연달아 누르면 화면 목록이 실제 상태와 달라질 수 있었습니다.
- 운영자는 '이 세션이 왜 앱에 안 보이냐'를 반복해서 개발자에게 물었습니다.
판단 근거
- 상태 전이 규칙은 서버가 소유하므로, 어드민의 책임을 '요청이 진행되는 동안 화면을 서버와 일관되게 유지하는 것'으로 정했습니다.
- 노출 여부는 상태, 신청 마감, 지역 활성 여부, 노출 기간이 겹쳐서 정해져, 판정 순서를 잘못 잡으면 잘못 표시됩니다.
해결
- 요청이 진행 중일 때는 모달을 닫지 못하게 하고, 버튼을 연달아 눌러도 한 번만 처리됩니다.
- 에러 코드별로 후속 동작을 나눠, 처리할 수 없는 상태면 재시도를 막고 대상이 없으면 목록을 새로고침합니다.
- 서버의 노출 규칙을 판정 함수로 옮겨, 배지(노출중/노출 예정/미노출/숨김)와 사유 문장을 함께 보여줍니다.
결과
- 환불 처리에서 중복 실행과 화면 불일치가 생기지 않음
Case 07
브랜치 기반 환경 분리 배포와 PWA (어드민)
빌드 브랜치로 운영·개발 API를 자동으로 고르게 해 개발 빌드가 운영 API를 바라보는 실수를 구조적으로 막고, PWA는 앱 셸만 캐시했습니다.
문제
- 운영 빌드와 미리보기 빌드가 같은 빌드 명령과 환경 변수를 써서, 개발 브랜치 빌드가 운영 API를 바라볼 위험이 있었습니다.
- 운영자가 현장에서 모바일로 어드민에 빠르게 들어갈 수단이 필요했습니다.
판단 근거
- 배포 플랫폼이 넣어 주는 빌드 브랜치 값을 기준으로 삼으면 사람이 설정을 실수할 여지가 없습니다.
- 운영 데이터가 낡아 보이는 것은 앱을 열 수 없는 것보다 더 위험합니다. 그래서 PWA는 화면 껍데기(앱 셸)만 캐시하기로 했습니다.
해결
- 빌드할 때 브랜치에 따라 API 주소가 자동으로 정해집니다. main은 운영 API, 나머지는 개발 API를 봅니다.
- 어떤 경로로 들어와도 앱이 열리도록 SPA 폴백을 설정했습니다.
- PWA는 앱 셸만 미리 캐시하고 API 응답은 캐시하지 않습니다. 설치 안내도 넣었습니다.
결과
- 환경을 잘못 지정하는 사고가 생기지 않는 배포 구조
- 운영자는 모바일 홈 화면에서 바로 어드민을 실행
Retrospective
회고
어드민의 가장 큰 한계는 자동화 테스트가 없다는 점이고, 다음 개선은 도메인 로직부터 테스트를 붙이는 것입니다.
- 테스트 부재: 타입체크·린트·빌드를 훅으로 강제하고 수동 검증과 AI 리뷰로 보완했습니다. 노출 판정과 날짜 그룹핑 같은 순수 함수부터 단위 테스트를 붙이겠습니다.
- 서버 규칙 복제: 노출 기간 같은 값을 어드민에 상수로 복제했습니다. 서버가 판정 결과를 내려주는 방식이 더 안전합니다.
- 서버 상태 관리: 화면마다 로딩·에러 처리를 반복합니다. 화면이 더 늘면 TanStack Query 도입을 검토하겠습니다.