10분짜리 팀 발표를 앞두고 자료를 준비하면서, 결국 프로젝트 전체 구조를 다시 훑게 됐다. 백엔드 도메인 하나하나, 프론트 라우트 하나하나 들여다보면서 "우리가 만든 게 정확히 뭔지"를 발표 대본에 옮기는 작업이었는데, 이 과정 자체가 꽤 괜찮은 코드 리뷰였다.
발표자료를 정리하면서 자연스럽게 다음 질문으로 이어졌다.
"그래서 이거, 실제 운영되는 서비스랑 비교하면 뭐가 부족해?"
답을 찾으려고 코드를 더 깊이 파다 보니 흥미로운 것들이 나왔다.
StubPointService가 deduct() 호출 시 항상 true를 반환하고, 잔액 조회는 항상 더미값 9999를 내려주고 있었다. 공고 등록도, 연락처 열람도 "포인트를 쓴다"는 흐름만 있고 실제로 차감되는 게 없었다.이걸 정리해서 팀 발표 자료에 "향후 계획" 섹션으로 넣었다. 그리고 발표가 끝나자마자, 이 리스트가 그날 하루의 실제 작업 목록이 됐다.
가장 먼저 손댄 건 "비밀번호 찾기"였다. 없어서는 안 되는데 없던 기능이다.
설계 방향을 정할 때 고민했던 지점은 하나였다: 이미 만들어져 있는 인증코드 인프라를 재사용할 것인가, 새로 만들 것인가. 재사용 쪽으로 정했다. 회원가입 때 쓰던 POST /api/verifications/send, /verify 를 그대로 쓰고, 그 위에 POST /api/auth/password-reset 하나만 새로 얹었다.
여기서 실수하기 쉬운 지점이 하나 있었다. "이메일을 인증했다"는 사실만으로 비밀번호를 바꿔주면, 그 이메일이 진짜 그 계정 소유자의 이메일인지 확인하는 절차가 빠지게 된다. 회원가입 로직에 이미 "인증한 대상이 실제 가입 정보와 같은지" 검증하는 코드가 있길래, 그 패턴을 그대로 가져와 재설정 로직에도 넣었다. 그리고 비밀번호를 바꾸면 기존에 로그인해둔 다른 기기의 세션(Refresh Token)도 전부 무효화하도록 했다 — 계정 탈취 시나리오를 생각하면 당연히 있어야 하는 처리다.
로컬에서 정상 케이스 하나, 실패 케이스 다섯 개(남의 인증 재사용 시도, 약한 비밀번호, 존재하지 않는 계정, 소셜 전용 계정, 세션 무효화 확인)를 curl로 직접 돌려보고 나서야 PR을 올렸다.
혼자 비밀번호 찾기를 만드는 사이, 팀원들도 각자 자리에서 움직이고 있었다. 몇 시간 사이에 머지된 PR 목록을 보면:
Member.point가 진짜 잔액 컬럼이 됐고, 결제 검증은 클라이언트가 보낸 값을 믿지 않고 서버가 포트원 API로 재조회하는 방식으로 설계돼 있었다.나는 여기에 알림 기능을 얹었다. 지원 결과, 시설 승인/반려, 문의 답변 — 세 지점에서 서버가 알림 한 줄을 쌓고, 프론트는 30초 주기로 폴링해서 헤더 종 아이콘 배지를 갱신하는 방식이다. 실시간 푸시는 아니지만, 하루 만에 "동작하는 알림"을 만드는 데는 이 정도가 현실적인 선이라고 판단했다.
한 가지 재밌었던 순간: PR을 올리기 직전에 main을 다시 pull 받았더니, 이미 신영씨가 헤더 컴포넌트에 user.unreadNotifications 라는 필드를 // TODO: 알림 API 연동 시 교체 라는 주석과 함께 미리 만들어 두고 있었다. 같은 기능을 서로 다른 방향에서 준비하고 있었던 셈인데, 딱 맞아떨어져서 그 TODO 자리를 그대로 채우기만 하면 됐다.
팀원 한 명이 지나가듯 던진 말 한마디로 오후의 방향이 바뀌었다. 관리자 대시보드 전체를 curl로 하나씩 두드려보기 시작했다.
회원관리, 시설 승인/반려, 공지 CRUD, 포인트 충전 내역 — 다 정상이었다. 그런데 문의 답변 등록 흐름에서 이상한 걸 발견했다.
POST /api/admin/support/inquiries/1/replies
→ 응답: { "replies": [{ "id": null, "content": "답변드립니다", ... }] }
답변을 달았는데 그 답변의 id가 null로 돌아온다. 원인을 찾아보니 InquiryReply는 IDENTITY 채번 전략을 쓰는데, 서비스 코드가 inquiry.addReply(...) 로 컬렉션에 추가만 하고 flush 없이 바로 응답 DTO를 만들고 있었다. Hibernate 입장에서는 아직 INSERT를 실행하지 않았으니 id를 모르는 게 당연했다.
이게 왜 문제냐면, 프론트가 이 id를 React 리스트의 key로 쓰고 있었다. 관리자가 한 문의에 답변을 연달아 두 번 등록하면(새로고침 없이), 두 번째 답변도 id: null이 되면서 React가 리스트 항목을 헷갈려 할 수 있는 상황이었다. flush() 한 줄로 해결했다.
문의 답변 버그를 고치고 나서, 팀원이 전체 코드 스캔을 요청했다. TODO 주석, 남은 console.log, 시크릿 커밋 여부 같은 걸 훑고 나서, 결제/포인트 로직을 조금 더 깊게 들여다봤다. 그날 새로 실연동된 포트원 결제 코드였기 때문에 가장 리스크가 높은 부분이라고 판단했다.
그리고 진짜 문제를 찾았다.
// PointChargeService.complete()
PointCharge charge = pointChargeRepository.findByPaymentId(paymentId)...
if (!charge.isPending()) {
return alreadyProcessedResponse; // 중복 방지용 체크
}
// ... 포트원 서버에 검증 요청 ...
charge.markPaid();
pointService.credit(memberId, charge.getAmount(), ...);
isPending() 체크가 있으니 중복 호출을 막는 것처럼 보이지만, 이 체크 자체에는 잠금이 없었다. 같은 결제 건에 대해 complete가 정확히 동시에 두 번 들어오면(네트워크 재시도, 혹은 의도적으로 요청을 두 번 보내는 것도 가능하다), 두 요청 모두 아직 커밋되지 않은 PENDING 상태를 보고 통과한 뒤, 둘 다 포인트를 적립해버릴 수 있다. 결제는 한 번 했는데 포인트는 두 배로 받는, 명백한 결제 취약점이었다.
Member(잔액)에는 이미 비관적 락(SELECT ... FOR UPDATE)이 걸려 있었는데, 정작 "이 결제를 이미 처리했는가"를 판단하는 PointCharge 쪽에는 잠금이 없었던 게 원인이었다. 같은 방식으로 PointCharge에도 잠금을 걸어서 해결했다.
연락처 열람(ContactUnlockService.unlock())에도 비슷한 구조의 문제가 있었다. "이력 확인 → 포인트 차감 → 이력 저장" 순서였는데, 두 요청이 동시에 들어오면 이력 저장 단계에서 두 번째 요청이 유니크 제약 위반으로 실패하는 구조였다. 코드는 이 예외를 catch해서 조용히 넘어가도록 되어 있었다 — 언뜻 보면 안전해 보이는 처리다.
여기서 재밌는 삽질을 했다. 이 catch-and-continue 방식으로 고친 다음, "진짜 동시에 두 번 호출해도 안전한가"를 확인하려고 통합 테스트를 만들었다. 8개 스레드로 같은 인재를 동시에 열람 요청하게 만든 테스트였는데, 결과는 예상과 달랐다.
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
Hibernate는 flush가 한 번이라도 실패하면, 애플리케이션 코드가 그 예외를 catch해서 무시하더라도 해당 트랜잭션 전체를 "rollback-only"로 표시해버린다. 그래서 메서드는 정상적으로 return 됐는데, 트랜잭션을 커밋하려는 순간 스프링이 "이 트랜잭션은 롤백하기로 되어 있었다"며 조용히 롤백해버리고 UnexpectedRollbackException을 던진 것이다. 결과적으로 이중 차감은 막았지만, 두 요청 중 하나는 처리되지 않은 예외로 끝나는 상태였다 — catch 블록이 있어도 아무 소용이 없었던 셈이다.
이 사실을 알고 나니 접근을 완전히 바꿔야 했다. 예외를 잡아서 우회하는 대신,애초에 경쟁이 생기지 않도록 시설 회원 row에 비관적 락을 걸어서 "확인 → 차감 → 저장" 구간 전체를 하나의 락 구간으로 묶었다. 같은 시설이 같은 인재를 동시에 두 번 열람 요청해도, 두 번째 요청은 첫 번째가 끝날 때까지 기다렸다가 "이미 처리됐다"는 걸 보고 무료로 처리된다. 예외에 기대지 않는 방식이라 이번엔 진짜 안전했다.
그리고 정확히 같은 catch-and-continue 패턴이 코드베이스 안에 하나 더 있었다 — 찜하기(스크랩) 기능이었다. 돈이 오가는 곳은 아니라 심각도는 낮지만, 버튼을 빠르게 두 번 누르면 같은 방식으로 500 에러가 날 수 있는 구조였다. 같은 방식으로 고쳤다.
세 개의 버그가 전부 같은 근본 원인에서 나왔다는 게 인상 깊었다: "확인하고 → 뭔가 하고 → 저장한다"는 패턴은, 그 사이에 다른 요청이 끼어들 수 있다는 걸 항상 의심해야 한다. 그리고 "예외를 catch해서 우회하면 안전하겠지"라는 직관이 항상 맞는 건 아니라는 것도 배웠다. JPA/Hibernate를 쓴다면, 트랜잭션 안에서 발생한 예외를 catch하고 계속 진행하는 코드를 볼 때마다 "이 트랜잭션, 정말 커밋까지 무사히 될까?"를 한 번 더 의심해봐야 한다.
증명 방법도 하나 배웠다. 동시성 버그는 말로 설명하기보다 실제로 여러 스레드를 동시에 띄워서 재현하는 테스트를 짜는 게 제일 확실하다. 수정 전 코드로 테스트를 돌려서 실패하는 걸 직접 본 다음, 수정하고 나서 통과하는 걸 확인하는 것 — 이 과정이 없었다면 "락을 걸었으니 됐다"는 확신에 그쳤을 텐데, 실제로는 첫 번째 수정 시도(catch 방식)도 겉보기엔 그럴듯했지만 틀렸다는 걸 테스트가 아니었으면 몰랐을 것이다.
| PR | 내용 |
|---|---|
| #115, #117 | 비밀번호 찾기(백엔드/프론트) |
| #116 | 회원 유형 정리 + 포인트 충전(포트원) 연동 |
| #118, #120 | 알림 도메인(백엔드/프론트) |
| #121 | 관리자 대시보드 |
| #122 | 관리자 계정 인재 연락처 마스킹 해제 버그 수정 |
| #125 | 포인트 컬럼/테이블 마이그레이션 사후 기록 |
| #126 | 문의 답변 id null 버그 수정 |
| #127 | 결제/포인트 동시요청 이중 처리 버그 수정 |
발표 하나 준비하려다가 하루 종일 코드를 고친 날이었다.