Full output
Codex 원문 결과물
결제 웹훅 수신 API 구현 / OpenAI / GPT-5.6 Terra / 실행일 2026-07-23
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;
@Entity
@Table(name = "payment_webhook_events")
public class PaymentWebhookEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String eventId;
@Column(nullable = false)
private String type;
@Column(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;
}
}
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.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
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 final byte[] secret;
public WebhookSignatureVerifier(@Value("${payment.webhook-secret}") String secret) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}
public boolean isValid(byte[] rawBody, String signature) {
if (signature == null || !signature.matches("[0-9a-f]{64}")) {
return false;
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
byte[] expected = mac.doFinal(rawBody);
byte[] supplied = HexFormat.of().parseHex(signature);
return MessageDigest.isEqual(expected, supplied);
} catch (NoSuchAlgorithmException | InvalidKeyException exception) {
throw new IllegalStateException("HMAC-SHA256을 초기화할 수 없습니다.", exception);
}
}
}
src/main/java/com/samebrief/payment/webhook/PaymentWebhookService.java
package com.samebrief.payment.webhook;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PaymentWebhookService {
private final PaymentWebhookEventRepository eventRepository;
public PaymentWebhookService(PaymentWebhookEventRepository eventRepository) {
this.eventRepository = eventRepository;
}
/** @return true when the event was already stored. */
@Transactional
public boolean receive(String eventId, WebhookPayload payload) {
if (eventRepository.existsByEventId(eventId)) {
return true;
}
try {
eventRepository.saveAndFlush(new PaymentWebhookEvent(
eventId, payload.type(), payload.orderId(), payload.amount(), payload.currency()));
return false;
} catch (DataIntegrityViolationException exception) {
// A concurrent request inserted the same eventId after the existence check.
return true;
}
}
}
record WebhookPayload(String type, String orderId, Long amount, String currency) {
boolean isValid() {
return hasText(type) && hasText(orderId) && hasText(currency)
&& amount != null && amount >= 1;
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
src/main/java/com/samebrief/payment/webhook/PaymentWebhookController.java
package com.samebrief.payment.webhook;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.ResponseEntity;
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 {
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 ResponseEntity<WebhookReceipt> receive(
@RequestHeader(value = "X-Payment-Event-Id", required = false) String eventId,
@RequestHeader(value = "X-Payment-Signature", required = false) String signature,
@RequestBody(required = false) byte[] rawBody) {
if (eventId == null || eventId.isBlank() || signature == null || signature.isBlank()
|| rawBody == null) {
throw new InvalidWebhookRequestException();
}
if (!signatureVerifier.isValid(rawBody, signature)) {
throw new InvalidSignatureException();
}
WebhookPayload payload = parseAndValidate(rawBody);
boolean duplicate = webhookService.receive(eventId, payload);
return ResponseEntity.ok(new WebhookReceipt(true, duplicate));
}
private WebhookPayload parseAndValidate(byte[] rawBody) {
try {
WebhookPayload payload = objectMapper.readValue(rawBody, WebhookPayload.class);
if (!payload.isValid()) {
throw new InvalidWebhookRequestException();
}
return payload;
} catch (JsonProcessingException exception) {
throw new InvalidWebhookRequestException();
}
}
private record WebhookReceipt(boolean received, boolean duplicate) {
}
}
src/main/java/com/samebrief/payment/webhook/WebhookExceptionHandler.java
package com.samebrief.payment.webhook;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class WebhookExceptionHandler {
@ExceptionHandler(InvalidSignatureException.class)
ResponseEntity<ErrorResponse> invalidSignature() {
return error(HttpStatus.UNAUTHORIZED, "INVALID_SIGNATURE", "웹훅 서명이 올바르지 않습니다.");
}
@ExceptionHandler(InvalidWebhookRequestException.class)
ResponseEntity<ErrorResponse> invalidRequest() {
return error(HttpStatus.BAD_REQUEST, "INVALID_WEBHOOK_REQUEST", "웹훅 요청이 올바르지 않습니다.");
}
private ResponseEntity<ErrorResponse> error(HttpStatus status, String code, String message) {
return ResponseEntity.status(status).body(new ErrorResponse(code, message));
}
private record ErrorResponse(String code, String message) {
}
}
class InvalidSignatureException extends RuntimeException {
}
class InvalidWebhookRequestException extends RuntimeException {
}
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.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.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(PaymentWebhookController.class)
@Import({WebhookSignatureVerifier.class, PaymentWebhookService.class, WebhookExceptionHandler.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;
@MockBean
private PaymentWebhookEventRepository eventRepository;
@Test
void storesEventWhenSignatureIsValid() throws Exception {
given(eventRepository.existsByEventId("evt_001")).willReturn(false);
mockMvc.perform(request("evt_001", sign(BODY)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.received").value(true))
.andExpect(jsonPath("$.duplicate").value(false));
then(eventRepository).should().saveAndFlush(any(PaymentWebhookEvent.class));
}
@Test
void doesNotStoreEventWhenSignatureIsInvalid() throws Exception {
mockMvc.perform(request("evt_001", "0".repeat(64)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_SIGNATURE"));
then(eventRepository).shouldHaveNoInteractions();
}
@Test
void doesNotStoreSameEventIdTwice() throws Exception {
given(eventRepository.existsByEventId("evt_001")).willReturn(false, true);
mockMvc.perform(request("evt_001", sign(BODY)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.duplicate").value(false));
mockMvc.perform(request("evt_001", sign(BODY)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.received").value(true))
.andExpect(jsonPath("$.duplicate").value(true));
then(eventRepository).should().saveAndFlush(any(PaymentWebhookEvent.class));
}
private static org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder request(
String eventId, String signature) {
return post("/api/v1/payment-webhooks")
.contentType("application/json")
.header("X-Payment-Event-Id", eventId)
.header("X-Payment-Signature", signature)
.content(BODY);
}
private static 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)));
}
}
실행 명령: ./gradlew test
예상 결과: PaymentWebhookControllerTest의 유효 서명 저장, 잘못된 서명 미저장, 중복 이벤트 추가 미저장 테스트가 모두 통과합니다.