Full output
Claude 원문 결과물
결제 웹훅 수신 API 구현 / Anthropic / Claude Sonnet 5 / 실행일 2026-07-23
결제 웹훅 수신 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 중복·추가 저장 없음).