Full output
Antigravity 원문 결과물
결제 웹훅 수신 API 구현 / Google / Gemini 3.6 Flash / 실행일 2026-07-23
05. 결제 웹훅 수신 API 구현 (Antigravity)
추가된 파일 목록
src/main/java/com/samebrief/payment/webhook/domain/PaymentWebhookEvent.javasrc/main/java/com/samebrief/payment/webhook/repository/PaymentWebhookEventRepository.javasrc/main/java/com/samebrief/payment/webhook/dto/PaymentWebhookRequest.javasrc/main/java/com/samebrief/payment/webhook/dto/PaymentWebhookResponse.javasrc/main/java/com/samebrief/payment/webhook/dto/ErrorResponse.javasrc/main/java/com/samebrief/payment/webhook/service/PaymentWebhookService.javasrc/main/java/com/samebrief/payment/webhook/controller/PaymentWebhookController.javasrc/test/java/com/samebrief/payment/webhook/controller/PaymentWebhookControllerTest.java
src/main/java/com/samebrief/payment/webhook/domain/PaymentWebhookEvent.java
package com.samebrief.payment.webhook.domain;
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(name = "event_id", nullable = false, unique = true)
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;
}
public String getType() {
return type;
}
public String getOrderId() {
return orderId;
}
public Long getAmount() {
return amount;
}
public String getCurrency() {
return currency;
}
}
src/main/java/com/samebrief/payment/webhook/repository/PaymentWebhookEventRepository.java
package com.samebrief.payment.webhook.repository;
import com.samebrief.payment.webhook.domain.PaymentWebhookEvent;
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/dto/PaymentWebhookRequest.java
package com.samebrief.payment.webhook.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record PaymentWebhookRequest(
@NotBlank(message = "type은 필수입니다.")
String type,
@NotBlank(message = "orderId는 필수입니다.")
String orderId,
@NotNull(message = "amount는 필수입니다.")
@Min(value = 1, message = "amount는 1 이상이어야 합니다.")
Long amount,
@NotBlank(message = "currency는 필수입니다.")
String currency
) {
}
src/main/java/com/samebrief/payment/webhook/dto/PaymentWebhookResponse.java
package com.samebrief.payment.webhook.dto;
public record PaymentWebhookResponse(
boolean received,
boolean duplicate
) {
public static PaymentWebhookResponse success() {
return new PaymentWebhookResponse(true, false);
}
public static PaymentWebhookResponse duplicate() {
return new PaymentWebhookResponse(true, true);
}
}
src/main/java/com/samebrief/payment/webhook/dto/ErrorResponse.java
package com.samebrief.payment.webhook.dto;
public record ErrorResponse(
String code,
String message
) {
}
src/main/java/com/samebrief/payment/webhook/service/PaymentWebhookService.java
package com.samebrief.payment.webhook.service;
import com.samebrief.payment.webhook.domain.PaymentWebhookEvent;
import com.samebrief.payment.webhook.dto.PaymentWebhookRequest;
import com.samebrief.payment.webhook.dto.PaymentWebhookResponse;
import com.samebrief.payment.webhook.repository.PaymentWebhookEventRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@Service
public class PaymentWebhookService {
private static final String HMAC_SHA256 = "HmacSHA256";
private final PaymentWebhookEventRepository repository;
private final String webhookSecret;
public PaymentWebhookService(
PaymentWebhookEventRepository repository,
@Value("${payment.webhook-secret}") String webhookSecret) {
this.repository = repository;
this.webhookSecret = webhookSecret;
}
@Transactional
public PaymentWebhookResponse processWebhook(
String eventId,
String signature,
byte[] rawPayload,
PaymentWebhookRequest request) {
if (!verifySignature(rawPayload, signature)) {
throw new InvalidSignatureException("웹훅 서명이 올바르지 않습니다.");
}
if (repository.existsByEventId(eventId)) {
return PaymentWebhookResponse.duplicate();
}
try {
PaymentWebhookEvent event = new PaymentWebhookEvent(
eventId,
request.type(),
request.orderId(),
request.amount(),
request.currency()
);
repository.save(event);
return PaymentWebhookResponse.success();
} catch (DataIntegrityViolationException e) {
return PaymentWebhookResponse.duplicate();
}
}
public boolean verifySignature(byte[] rawPayload, String signatureHeader) {
if (signatureHeader == null || signatureHeader.isBlank() || rawPayload == null) {
return false;
}
try {
Mac mac = Mac.getInstance(HMAC_SHA256);
SecretKeySpec secretKey = new SecretKeySpec(
webhookSecret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256
);
mac.init(secretKey);
byte[] hmacBytes = mac.doFinal(rawPayload);
StringBuilder hexBuilder = new StringBuilder();
for (byte b : hmacBytes) {
hexBuilder.append(String.format("%02x", b));
}
String expectedSignature = hexBuilder.toString();
return MessageDigest.isEqual(
expectedSignature.getBytes(StandardCharsets.UTF_8),
signatureHeader.trim().toLowerCase().getBytes(StandardCharsets.UTF_8)
);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
return false;
}
}
public static class InvalidSignatureException extends RuntimeException {
public InvalidSignatureException(String message) {
super(message);
}
}
}
src/main/java/com/samebrief/payment/webhook/controller/PaymentWebhookController.java
package com.samebrief.payment.webhook.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.samebrief.payment.webhook.dto.ErrorResponse;
import com.samebrief.payment.webhook.dto.PaymentWebhookRequest;
import com.samebrief.payment.webhook.dto.PaymentWebhookResponse;
import com.samebrief.payment.webhook.service.PaymentWebhookService;
import com.samebrief.payment.webhook.service.PaymentWebhookService.InvalidSignatureException;
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 {
private final PaymentWebhookService paymentWebhookService;
private final ObjectMapper objectMapper;
public PaymentWebhookController(PaymentWebhookService paymentWebhookService, ObjectMapper objectMapper) {
this.paymentWebhookService = paymentWebhookService;
this.objectMapper = objectMapper;
}
@PostMapping
public ResponseEntity<?> receiveWebhook(
@RequestHeader(value = "X-Payment-Event-Id", required = false) String eventId,
@RequestHeader(value = "X-Payment-Signature", required = false) String signature,
@RequestBody(required = false) byte[] rawPayload) {
if (eventId == null || eventId.isBlank() || signature == null || signature.isBlank() || rawPayload == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("INVALID_WEBHOOK_REQUEST", "웹훅 요청이 올바르지 않습니다."));
}
PaymentWebhookRequest request;
try {
request = objectMapper.readValue(rawPayload, PaymentWebhookRequest.class);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("INVALID_WEBHOOK_REQUEST", "웹훅 요청이 올바르지 않습니다."));
}
if (request.type() == null || request.type().isBlank()
|| request.orderId() == null || request.orderId().isBlank()
|| request.currency() == null || request.currency().isBlank()
|| request.amount() == null || request.amount() < 1) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("INVALID_WEBHOOK_REQUEST", "웹훅 요청이 올바르지 않습니다."));
}
PaymentWebhookResponse response = paymentWebhookService.processWebhook(eventId, signature, rawPayload, request);
return ResponseEntity.ok(response);
}
@ExceptionHandler(InvalidSignatureException.class)
public ResponseEntity<ErrorResponse> handleInvalidSignature(InvalidSignatureException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("INVALID_SIGNATURE", "웹훅 서명이 올바르지 않습니다."));
}
}
src/test/java/com/samebrief/payment/webhook/controller/PaymentWebhookControllerTest.java
package com.samebrief.payment.webhook.controller;
import com.samebrief.payment.webhook.repository.PaymentWebhookEventRepository;
import com.samebrief.payment.webhook.service.PaymentWebhookService;
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.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
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;
@WebMvcTest(PaymentWebhookController.class)
@Import(PaymentWebhookService.class)
@TestPropertySource(properties = "payment.webhook-secret=test-secret")
class PaymentWebhookControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private PaymentWebhookEventRepository repository;
private String calculateSignature(String secret, String payload) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(keySpec);
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
hexString.append(String.format("%02x", b));
}
return hexString.toString();
}
@Test
@DisplayName("1. 유효한 서명 요청 시 200 OK 응답 및 이벤트 저장 호출")
void validSignatureRequest() throws Exception {
String eventId = "evt_20260723_001";
String payload = "{\"type\":\"payment.succeeded\",\"orderId\":\"order_123\",\"amount\":19900,\"currency\":\"KRW\"}";
String validSignature = calculateSignature("test-secret", payload);
given(repository.existsByEventId(eventId)).willReturn(false);
mockMvc.perform(post("/api/v1/payment-webhooks")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Payment-Event-Id", eventId)
.header("X-Payment-Signature", validSignature)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.received").value(true))
.andExpect(jsonPath("$.duplicate").value(false));
verify(repository).save(any());
}
@Test
@DisplayName("2. 잘못된 서명 요청 시 401 Unauthorized 응답 및 저장 호출 없음")
void invalidSignatureRequest() throws Exception {
String eventId = "evt_20260723_001";
String payload = "{\"type\":\"payment.succeeded\",\"orderId\":\"order_123\",\"amount\":19900,\"currency\":\"KRW\"}";
String invalidSignature = "invalid_signature_hex_code";
mockMvc.perform(post("/api/v1/payment-webhooks")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Payment-Event-Id", eventId)
.header("X-Payment-Signature", invalidSignature)
.content(payload))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_SIGNATURE"))
.andExpect(jsonPath("$.message").value("웹훅 서명이 올바르지 않습니다."));
verify(repository, never()).save(any());
}
@Test
@DisplayName("3. 중복된 eventId 요청 시 200 OK 응답, duplicate: true 및 추가 저장 없음")
void duplicateEventIdRequest() throws Exception {
String eventId = "evt_20260723_001";
String payload = "{\"type\":\"payment.succeeded\",\"orderId\":\"order_123\",\"amount\":19900,\"currency\":\"KRW\"}";
String validSignature = calculateSignature("test-secret", payload);
given(repository.existsByEventId(eventId)).willReturn(true);
mockMvc.perform(post("/api/v1/payment-webhooks")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Payment-Event-Id", eventId)
.header("X-Payment-Signature", validSignature)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.received").value(true))
.andExpect(jsonPath("$.duplicate").value(true));
verify(repository, never()).save(any());
}
}
실행 및 테스트 방법
테스트 실행 명령
./gradlew test --tests com.samebrief.payment.webhook.controller.PaymentWebhookControllerTest
예상 테스트 결과
PaymentWebhookControllerTest > validSignatureRequest() PASSED
PaymentWebhookControllerTest > invalidSignatureRequest() PASSED
PaymentWebhookControllerTest > duplicateEventIdRequest() PASSED
BUILD SUCCESSFUL