비교 요약으로 돌아가기

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의 유효 서명 저장, 잘못된 서명 미저장, 중복 이벤트 추가 미저장 테스트가 모두 통과합니다.