비교 요약으로 돌아가기

Full output

Antigravity 원문 결과물

결제 웹훅 수신 API 구현 / Google / Gemini 3.6 Flash / 실행일 2026-07-23

05. 결제 웹훅 수신 API 구현 (Antigravity)

추가된 파일 목록

  1. src/main/java/com/samebrief/payment/webhook/domain/PaymentWebhookEvent.java
  2. src/main/java/com/samebrief/payment/webhook/repository/PaymentWebhookEventRepository.java
  3. src/main/java/com/samebrief/payment/webhook/dto/PaymentWebhookRequest.java
  4. src/main/java/com/samebrief/payment/webhook/dto/PaymentWebhookResponse.java
  5. src/main/java/com/samebrief/payment/webhook/dto/ErrorResponse.java
  6. src/main/java/com/samebrief/payment/webhook/service/PaymentWebhookService.java
  7. src/main/java/com/samebrief/payment/webhook/controller/PaymentWebhookController.java
  8. src/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