백엔드
결제 웹훅 수신 API 구현
Java 21과 Spring Boot 3으로 결제 웹훅을 원본 본문 기준 HMAC-SHA256으로 검증하고, 멱등하게 저장하는 최소 API를 구현한다. 요청·서명·중복·오류 응답 계약과 MockMvc 테스트를 모두 만족해야 한다.
Task
성공 조건
- Controller, 서명 검증 Service, JPA Repository, 이벤트 엔티티를 분리하고 구현과 테스트를 합쳐 4~8개 파일 안에 둔다.
- 수신한 원본 UTF-8 바이트와 payment.webhook-secret으로 계산한 소문자 16진수 HMAC-SHA256을 저장 전에 상수 시간 비교로 검증한다.
- 유효한 신규 요청은 200 received=true·duplicate=false로 한 번 저장하고, 같은 eventId는 200 duplicate=true로 추가 저장하지 않는다.
- eventId 존재 확인과 데이터베이스 유일성 제약을 함께 사용해 중복 저장을 막는다.
- 서명 오류는 INVALID_SIGNATURE·401, 헤더·JSON·필수 필드·amount 오류는 INVALID_WEBHOOK_REQUEST·400으로 응답하고 저장하지 않는다.
- MockMvc로 유효 서명 저장, 잘못된 서명 미저장, 중복 eventId 추가 미저장을 검증한다.
Common conditions
핵심 조건
- Spring Boot 3 웹훅 수신 API에서 역직렬화 전 원본 바이트로 HMAC-SHA256을 검증한다.
- eventId의 사전 존재 확인과 DB 유일성 제약으로 멱등성을 보장한다.
- 명세의 200·400·401 JSON 계약과 세 가지 MockMvc 테스트를 최소 파일 수로 구현한다.
Model results
모델별 결과
원본 바이트 HMAC 검증과 DB 유일성 제약을 7개 파일에 담고, 서비스 트랜잭션 경계를 분리해 동시 중복 충돌도 중복 응답으로 해석한 구현.
결제 웹훅 수신 API 구현
서명 검증이 이 과업의 핵심이라, 컨트롤러가 본문을 byte[]로 받아 역직렬화 이전의 원본 바이트로 HMAC-SHA256을 먼저 검증하고, 통과한 뒤에만 JSON 파싱과 필드 검증을 수행합니다. 비교는 MessageDigest.isEqual로 상수 시간에 가깝게 처리했습니다. 멱등성은 existsByEventId 선확인과 event_id 유일성 제약의 이중 방어로 보장하고, 동시 요청이 제약에 걸리면 DataIntegrityViolationException을 중복으로 해석합니다. 총 7개 파일(구현 6 + 테스트 1)입니다.
src/main/java/com/samebrief/payment/webhook/PaymentWebhookEvent.java
package com.samebrief.payment.webhook;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(name = "payment_webhook_events",
uniqueConstraints = @UniqueConstraint(name = "uk_payment_webhook_events_event_id", columnNames = "event_id"))
public class PaymentWebhookEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_id", nullable = false)
private String eventId;
@Column(nullable = false)
private String type;
@Column(name = "order_id", nullable = false)
private String orderId;
@Column(nullable = false)
private Long amount;
@Column(nullable = false)
private String currency;
protected PaymentWebhookEvent() {
}
public PaymentWebhookEvent(String eventId, String type, String orderId, Long amount, String currency) {
this.eventId = eventId;
this.type = type;
this.orderId = orderId;
this.amount = amount;
this.currency = currency;
}
public Long getId() {
return id;
}
public String getEventId() {
return eventId;
}
}
event_id에 유일성 제약을 걸어 애플리케이션 레벨 확인이 경합으로 뚫려도 데이터베이스가 중복 저장을 막습니다.
src/main/java/com/samebrief/payment/webhook/PaymentWebhookEventRepository.java
package com.samebrief.payment.webhook;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PaymentWebhookEventRepository extends JpaRepository<PaymentWebhookEvent, Long> {
boolean existsByEventId(String eventId);
}
src/main/java/com/samebrief/payment/webhook/WebhookSignatureVerifier.java
package com.samebrief.payment.webhook;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class WebhookSignatureVerifier {
private static final String ALGORITHM = "HmacSHA256";
private final byte[] secret;
public WebhookSignatureVerifier(@Value("${payment.webhook-secret}") String secret) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}
public boolean verify(byte[] rawBody, String signature) {
byte[] provided;
try {
provided = HexFormat.of().parseHex(signature.toLowerCase());
} catch (IllegalArgumentException e) {
return false;
}
return MessageDigest.isEqual(hmac(rawBody), provided);
}
private byte[] hmac(byte[] rawBody) {
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(secret, ALGORITHM));
return mac.doFinal(rawBody);
} catch (Exception e) {
throw new IllegalStateException("HMAC-SHA256 계산에 실패했습니다.", e);
}
}
}
수신한 원본 바이트 그대로 HMAC을 계산하고, 비교는 MessageDigest.isEqual을 사용해 타이밍 차이를 줄입니다. 시크릿과 본문은 어디에도 로그로 남기지 않습니다.
src/main/java/com/samebrief/payment/webhook/WebhookPayload.java
package com.samebrief.payment.webhook;
public record WebhookPayload(String type, String orderId, Long amount, String currency) {
boolean isValid() {
return hasText(type)
&& hasText(orderId)
&& amount != null && amount >= 1
&& hasText(currency);
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
src/main/java/com/samebrief/payment/webhook/PaymentWebhookService.java
package com.samebrief.payment.webhook;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
@Service
public class PaymentWebhookService {
private final PaymentWebhookEventRepository repository;
public PaymentWebhookService(PaymentWebhookEventRepository repository) {
this.repository = repository;
}
/**
* 이벤트를 저장하고, 이미 같은 eventId가 존재하면 저장 없이 중복으로 응답한다.
*
* @return 중복 여부
*/
public boolean record(String eventId, WebhookPayload payload) {
if (repository.existsByEventId(eventId)) {
return true;
}
try {
repository.save(new PaymentWebhookEvent(
eventId, payload.type(), payload.orderId(), payload.amount(), payload.currency()));
return false;
} catch (DataIntegrityViolationException e) {
// 존재 확인과 저장 사이에 다른 요청이 먼저 저장한 경우: 유일성 제약이 막아 주므로 중복으로 처리
return true;
}
}
}
메서드에 @Transactional을 걸지 않았습니다. 하나의 트랜잭션으로 묶으면 제약 위반 시 트랜잭션이 rollback-only가 되어 예외를 잡아도 커밋 단계에서 다시 실패합니다. 각 리포지토리 호출이 자체 트랜잭션으로 실행되게 두어 제약 위반을 중복 응답으로 안전하게 변환합니다.
src/main/java/com/samebrief/payment/webhook/PaymentWebhookController.java
package com.samebrief.payment.webhook;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/payment-webhooks")
public class PaymentWebhookController {
record WebhookResponse(boolean received, boolean duplicate) {
}
record ErrorResponse(String code, String message) {
}
private static class WebhookException extends RuntimeException {
private final HttpStatus status;
private final String code;
private WebhookException(HttpStatus status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
static WebhookException invalidSignature() {
return new WebhookException(HttpStatus.UNAUTHORIZED,
"INVALID_SIGNATURE", "웹훅 서명이 올바르지 않습니다.");
}
static WebhookException invalidRequest() {
return new WebhookException(HttpStatus.BAD_REQUEST,
"INVALID_WEBHOOK_REQUEST", "웹훅 요청이 올바르지 않습니다.");
}
}
private final WebhookSignatureVerifier signatureVerifier;
private final PaymentWebhookService webhookService;
private final ObjectMapper objectMapper;
public PaymentWebhookController(WebhookSignatureVerifier signatureVerifier,
PaymentWebhookService webhookService,
ObjectMapper objectMapper) {
this.signatureVerifier = signatureVerifier;
this.webhookService = webhookService;
this.objectMapper = objectMapper;
}
@PostMapping
public WebhookResponse receive(
@RequestHeader(value = "X-Payment-Event-Id", required = false) String eventId,
@RequestHeader(value = "X-Payment-Signature", required = false) String signature,
@RequestBody byte[] rawBody) {
if (isBlank(eventId) || isBlank(signature)) {
throw WebhookException.invalidRequest();
}
if (!signatureVerifier.verify(rawBody, signature)) {
throw WebhookException.invalidSignature();
}
WebhookPayload payload = parse(rawBody);
if (!payload.isValid()) {
throw WebhookException.invalidRequest();
}
boolean duplicate = webhookService.record(eventId, payload);
return new WebhookResponse(true, duplicate);
}
private WebhookPayload parse(byte[] rawBody) {
try {
return objectMapper.readValue(rawBody, WebhookPayload.class);
} catch (IOException e) {
throw WebhookException.invalidRequest();
}
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
@ExceptionHandler(WebhookException.class)
ResponseEntity<ErrorResponse> handleWebhookException(WebhookException e) {
return ResponseEntity.status(e.status)
.body(new ErrorResponse(e.code, e.getMessage()));
}
}
@RequestBody WebhookPayload로 받으면 스프링이 역직렬화한 뒤라 원본 바이트를 잃습니다. byte[]로 받아 서명 검증 → JSON 파싱 → 필드 검증 → 저장 순서를 강제했습니다. 예외와 핸들러는 이 엔드포인트에서만 쓰이므로 컨트롤러 내부에 한정해 두어 다른 API에 영향을 주지 않습니다.
src/test/java/com/samebrief/payment/webhook/PaymentWebhookControllerTest.java
package com.samebrief.payment.webhook;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(PaymentWebhookController.class)
@Import({WebhookSignatureVerifier.class, PaymentWebhookService.class})
@TestPropertySource(properties = "payment.webhook-secret=test-secret")
class PaymentWebhookControllerTest {
private static final String BODY = """
{"type":"payment.succeeded","orderId":"order_123","amount":19900,"currency":"KRW"}""";
@Autowired
private MockMvc mockMvc;
@MockitoBean
private PaymentWebhookEventRepository repository;
@Test
@DisplayName("유효한 서명의 신규 요청은 200과 duplicate=false를 반환하고 저장한다")
void receiveNewEvent() throws Exception {
given(repository.existsByEventId("evt_20260723_001")).willReturn(false);
mockMvc.perform(post("/api/v1/payment-webhooks")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Payment-Event-Id", "evt_20260723_001")
.header("X-Payment-Signature", sign(BODY))
.content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.received").value(true))
.andExpect(jsonPath("$.duplicate").value(false));
then(repository).should().save(any(PaymentWebhookEvent.class));
}
@Test
@DisplayName("잘못된 서명 요청은 401과 INVALID_SIGNATURE를 반환하고 저장하지 않는다")
void receiveInvalidSignature() throws Exception {
mockMvc.perform(post("/api/v1/payment-webhooks")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Payment-Event-Id", "evt_20260723_001")
.header("X-Payment-Signature", sign(BODY + " "))
.content(BODY))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_SIGNATURE"))
.andExpect(jsonPath("$.message").value("웹훅 서명이 올바르지 않습니다."));
then(repository).shouldHaveNoInteractions();
}
@Test
@DisplayName("같은 eventId의 중복 요청은 200과 duplicate=true를 반환하고 추가 저장하지 않는다")
void receiveDuplicateEvent() throws Exception {
given(repository.existsByEventId("evt_20260723_001")).willReturn(true);
mockMvc.perform(post("/api/v1/payment-webhooks")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Payment-Event-Id", "evt_20260723_001")
.header("X-Payment-Signature", sign(BODY))
.content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.received").value(true))
.andExpect(jsonPath("$.duplicate").value(true));
then(repository).should(never()).save(any(PaymentWebhookEvent.class));
}
private String sign(String body) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec("test-secret".getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
}
}
Repository만 목으로 대체하고 WebhookSignatureVerifier와 PaymentWebhookService는 실제 빈을 사용합니다. 테스트가 요청 본문으로 직접 HMAC을 계산해 서명하므로 "원본 바이트 기준 검증"이 실제로 동작하는지까지 확인되고, 저장 호출 여부는 리포지토리 목으로 검증합니다. Spring Boot 3.4 미만이라면 @MockitoBean 대신 org.springframework.boot.test.mock.mockito.MockBean을 사용합니다.
실행
./gradlew test --tests PaymentWebhookControllerTest
예상 결과: 테스트 3건 모두 통과(200 신규 저장, 401 잘못된 서명·저장 없음, 200 중복·추가 저장 없음).
Recommendation
이 과업의 추천 모델: Claude
세 결과 중 Claude가 원문 바이트 검증, 오류 계약, 테스트, 파일 수 제한을 함께 충족하면서도 유일성 충돌을 호출자 트랜잭션 밖에서 처리해 실제 동시 요청의 멱등성까지 가장 안전하게 다뤘다.
잘 맞는 경우
- 외부 결제사 웹훅처럼 서명 검증과 중복 방지가 모두 중요한 작은 Spring Boot 엔드포인트
- 원문 요청 보존, DB 유일성 제약, MockMvc 계약 테스트를 짧은 구현 안에 함께 담아야 할 때
주의할 점
- 실제 적용 전 프로젝트의 Spring Boot 버전에 맞춰 MockitoBean을 MockBean으로 바꿀지 확인해야 한다.
- 제공자 계약이 소문자 16진수만 허용한다면 Claude 구현의 서명 형식 검사를 엄격하게 보완해야 한다.
Prompt and environment
공통 프롬프트와 실행 환경
프롬프트 전문 보기
Java 21과 Spring Boot 3으로 결제 웹훅을 수신·검증·저장하는 최소 API를 구현해 주세요. 필요한 파일만 추가·수정하고 구현과 테스트를 합쳐 4~8개 파일 안에 머무르세요.
POST /api/v1/payment-webhooks는 Content-Type: application/json, X-Payment-Event-Id, X-Payment-Signature 헤더를 받습니다. 서명은 수신한 원본 요청 본문 UTF-8 바이트와 payment.webhook-secret으로 계산한 HMAC-SHA256의 소문자 16진수 문자열입니다. JSON 역직렬화 후 재직렬화한 값으로 검증하면 안 됩니다.
본문은 type, orderId, amount, currency를 가지며 type·orderId·currency는 비어 있지 않은 문자열, amount는 1 이상 정수여야 합니다. eventId도 비어 있지 않아야 합니다. payment_webhook_events에 id, 유일한 eventId, type, orderId, amount, currency를 저장하고 엔티티를 HTTP 응답으로 직접 노출하지 마세요.
유효한 신규 요청은 {"received":true,"duplicate":false}, 이미 존재하는 eventId는 {"received":true,"duplicate":true}와 함께 200을 반환합니다. 잘못된 서명은 INVALID_SIGNATURE와 401, 헤더 누락·JSON 형식 오류·필수 필드 오류·0 이하 amount는 INVALID_WEBHOOK_REQUEST와 400을 반환합니다. 서명 검증은 저장보다 먼저 수행하고 가능한 한 상수 시간 비교를 사용하세요. 존재 여부 확인과 DB 유일성 제약을 모두 사용하고, 시크릿과 전체 요청 본문은 로그에 남기지 마세요.
Controller, 서명 검증 Service, JPA Repository, 이벤트 엔티티를 역할에 맞게 분리하세요. MockMvc로 유효 서명 저장, 잘못된 서명 미저장, 같은 eventId 중복 시 추가 미저장을 검증하세요. 재시도 큐, 비동기 처리, 운영 관측성, 명세 밖 결제 API는 추가하지 마세요.
- Java 21
- Spring Boot 3.x
- Spring Data JPA 및 PostgreSQL 호환 JPA 설정
- JUnit 5, MockMvc
- 테스트 설정: payment.webhook-secret=test-secret