NaverPay 동시성 파싱 버그 수정
NaverPay 서비스에서 잘못된 파서 사용으로 발생한 동시성 버그와 그 안정화 과정을 기록한다. 이 문제는 우리가 멀티스레드 환경을 충분히 고려하지 못한 채 코드를 작성하면서 생긴 것이었다. 평소에는 잘 드러나지 않았지만, 비동기 워커가 여러 스레드에서 같은 코드를 동시에 호출하는 상황과 트래픽이 몰리는 순간이 겹치면 원하지 않는 결과가 섞여 결제 오류로 이어질 수 있었다. 이상한 에러 로그가 반복해서 보이기 전까지는 사용자가 다시 결제를 시도하며 넘어가는 경우도 많아 발견이 늦어졌다.
이 글에서 말하는 NaverPay 서비스는 NaverPay 자체가 아니라, Nike Payment 시스템 안에서 NaverPay를 연동하던 서비스다.
왜 문서화와 버그 수정을 함께 봐야 했나
특히 이런 동시성 파싱 버그는 단일 스레드 환경에서는 잘 드러나지 않고, 멀티스레드 환경에서만 간헐적으로 재현되는 유형이어서 기록할 가치가 있다.
멀티스레드에서만 드러난 문제는 무엇이었나
NaverPay는 KakaoPay와 함께 Nike의 주요 간편결제 수단이다. 결제 연동 서비스는 NaverPay의 Stored Payment을 관리한다 — 결제수단 등록(register), 해지(unregister), 인증(authorize). 이 세 API가 NaverPay 벤더와의 핵심 통신 경로다.
2024년 2월에 이 서비스에 대한 집중 작업이 시작되었다. Stored NaverPay API를 체계적으로 문서화하는 과정에서 코드를 면밀히 리뷰하면서 동시성 이슈를 발견했다. 도메인 변경 대응은 성격이 달라서 별도 글에서 다루고, 이 글에서는 파싱 버그와 안정화 과정에만 집중한다.
핵심 체크포인트
- 2024년 2월 중순: Stored NaverPay API를 문서화하며 요청/응답과 상태 전이를 정리했다.
- 2024년 3월: 빌드 인증 체계 변경에 맞춰 관련 설정을 함께 정리했다.
- 2024년 4월: 동시성 파싱 버그를 수정했다.
안정화 작업을 어떤 순서로 진행했나
2024년 2월 중순, NaverPay의 register, unregister, authorize API를 체계적으로 문서화했다. 작업을 여러 단위로 나눠 진행한 것은 각 API의 요청/응답 스펙, 에러 코드, 상태 전이를 개별적으로 정리하기 위해서였다.
이 문서화 작업은 단순히 기존 코드를 설명하는 것이 아니었다. NaverPay API의 실제 동작을 코드와 대조하면서, 코드가 API 스펙을 정확히 반영하는지 검증하는 과정이었다. 이 과정에서 스레드 안전성 문제가 드러난 것이다.
2024년 4월에 수정한 이 버그의 핵심은 NaverPay API 응답을 파싱하는 코드에서 스레드 안전하지 않은 객체를 공유하고 있었다는 것이다. 구체적으로, 날짜/시간 파싱에 사용하는 SimpleDateFormat 인스턴스가 클래스 필드로 선언되어 여러 스레드가 동시에 접근할 수 있는 상태였다. 평소에는 로드밸런싱과 트래픽 분산 덕분에 크게 드러나지 않을 수 있었지만, 요청이 몰리면 같은 인스턴스에서 호출이 겹치며 문제가 터질 수 있는 구조였다.
Java의 SimpleDateFormat은 스레드 안전하지 않다. 단일 스레드에서는 문제없이 동작하지만, 여러 스레드가 동시에 같은 SimpleDateFormat 인스턴스의 parse() 또는 format()을 호출하면 내부 Calendar 객체가 오염되어 잘못된 결과를 반환하거나 NumberFormatException이 발생한다. 결제 서비스에서는 이런 잘못된 파싱 결과가 그대로 승인 시간, 상태값 해석, 후속 처리 오류로 이어질 수 있다.
API 동작을 먼저 문서화했다
요청/응답과 상태 전이를 정리해 무엇이 정상 동작인지 기준을 만들었다.
공유 파서를 안전한 타입으로 교체했다
공유 mutable formatter를 제거하고 멀티스레드에서도 안전한 방식으로 바꿨다.
파서 교체 예시 코드
멀티스레드 파싱 이슈는 공유 mutable formatter를 제거하고, immutable formatter로 교체하는 방식이 안전하다.
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneId.of("Asia/Seoul"));
public Instant parseApprovedAt(String raw) {
LocalDateTime dt = LocalDateTime.parse(raw, FORMATTER);
return dt.atZone(ZoneId.of("Asia/Seoul")).toInstant();
}안정화 작업이 한 번에 끝나지 않는 이유
이 글이 남긴 기준은 단순히 SimpleDateFormat을 바꿨다는 데 있지 않다. 운영 중인 결제 서비스에서는 사용자가 재시도하며 지나가 버리는 간헐 오류도 결국 로그에 흔적을 남기고, 그 흔적을 따라가다 보면 기본적인 파서 선택 하나가 실제 결제 안정성을 흔들 수 있다는 사실을 확인하게 된다. 문서화와 코드 리뷰는 이런 문제를 발견하는 출발점이 될 수 있고, 멀티스레드 환경을 전제로 한 안전한 기본 타입 선택은 생각보다 훨씬 중요하다. 도메인 변경처럼 외부 벤더가 주도한 변화는 뒤 글에서 별도로 다룬다.
다음 글에서는 이런 안정화 경험을 바탕으로 운영 중 디버깅 속도를 높이기 위해 logging flag를 어떻게 전파했는지 이어서 본다.