From e9bff78cbfe0d7b5c91e7113da9c560040d230ee Mon Sep 17 00:00:00 2001 From: eastargh Date: Thu, 30 Apr 2026 17:44:52 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=B9=ED=9B=85=20=EB=B0=9C=EC=86=A1/?= =?UTF-8?q?=EC=88=98=EC=8B=A0=20sample?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/ext/djb/webhook/WebhookSendLog.java | 74 +++++++++ .../controller/WebhookSendController.java | 131 ++++++++++++++++ .../ext/djb/webhook/dto/WebhookPayload.java | 31 ++++ .../djb/webhook/dto/WebhookSendRequest.java | 13 ++ .../repository/WebhookSendLogRepository.java | 31 ++++ .../service/WebhookReceiveService.java | 144 ++++++++++++++++++ .../djb/webhook/service/WebhookService.java | 144 ++++++++++++++++++ 7 files changed, 568 insertions(+) create mode 100644 src/main/java/com/eactive/eai/rms/data/ext/djb/webhook/WebhookSendLog.java create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/webhook/controller/WebhookSendController.java create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/webhook/dto/WebhookPayload.java create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/webhook/dto/WebhookSendRequest.java create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/webhook/repository/WebhookSendLogRepository.java create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookReceiveService.java create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookService.java diff --git a/src/main/java/com/eactive/eai/rms/data/ext/djb/webhook/WebhookSendLog.java b/src/main/java/com/eactive/eai/rms/data/ext/djb/webhook/WebhookSendLog.java new file mode 100644 index 0000000..f2bcdf2 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/ext/djb/webhook/WebhookSendLog.java @@ -0,0 +1,74 @@ +package com.eactive.eai.rms.data.ext.djb.webhook; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "PTL_WEBHOOK_SEND_LOG") +@Getter +@Setter +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class WebhookSendLog { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "webhook_send_log_seq") + @SequenceGenerator( + name = "webhook_send_log_seq", + sequenceName = "SEQ_PTL_WEBHOOK_SEND_LOG", + allocationSize = 1 + ) + private Long id; + + @Column(name = "TARGET_URL", nullable = false, length = 500) + private String targetUrl; + + @Column(name = "EVENT_TYPE", length = 100) + private String eventType; + + @Lob + @Column(name = "PAYLOAD") + private String payload; + + @Column(name = "SIGNATURE", length = 500) + private String signature; + + @Column(name = "STATUS_CODE") + private Integer statusCode; + + @Lob + @Column(name = "RESPONSE_BODY") + private String responseBody; + + @Column(name = "SUCCESS", length = 1) + private String success; // 오라클 CHAR(1) : 'Y' / 'N' + + @Lob + @Column(name = "ERROR_MESSAGE") + private String errorMessage; + + @Column(name = "RETRY_COUNT") + private Integer retryCount = 0; + + @CreatedDate + @Column(name = "CREATED_AT", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "SENT_AT") + private LocalDateTime sentAt; + + /* Boolean 편의 메서드 */ + public void setSuccess(Boolean success) { + this.success = (success != null && success) ? "Y" : "N"; + } + + public Boolean getSuccess() { + return "Y".equalsIgnoreCase(this.success); + } +} \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/webhook/controller/WebhookSendController.java b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/controller/WebhookSendController.java new file mode 100644 index 0000000..3f2a1a0 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/controller/WebhookSendController.java @@ -0,0 +1,131 @@ +package com.eactive.eai.rms.ext.djb.webhook.controller; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +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; + +import com.eactive.eai.rms.data.ext.djb.webhook.WebhookSendLog; +import com.eactive.eai.rms.ext.djb.webhook.dto.WebhookSendRequest; +import com.eactive.eai.rms.ext.djb.webhook.service.WebhookReceiveService; +import com.eactive.eai.rms.ext.djb.webhook.service.WebhookService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api/webhook") +@RequiredArgsConstructor +public class WebhookSendController { + + private final WebhookService webhookService; + private final WebhookReceiveService webhookReceiveService; + + private static final int KEY_BYTE_LENGTH = 32; // 256bit + + /* ------------------------------------------------------------------ */ + /* 발송 */ + /* ------------------------------------------------------------------ */ + + @PostMapping("/send.json") + public ResponseEntity> sendWebhook( + @RequestBody WebhookSendRequest request) { + + WebhookSendLog result = webhookService.send( + request.getTargetUrl(), + request.getEventType(), + request.getData() + ); + + Map response = new HashMap<>(); + response.put("logId", result.getId()); + response.put("success", result.getSuccess()); + response.put("statusCode", result.getStatusCode()); + + return ResponseEntity.ok(response); + } + + + /* ------------------------------------------------------------------ */ + /* 수신 */ + /* ------------------------------------------------------------------ */ + + /** + * 웹훅 수신 엔드포인트 + * + * 헤더 예시: + * X-Webhook-Signature : sha256={hmac값} + * X-Webhook-Event : ORDER_CREATED + * X-Webhook-Timestamp : 1712345678901 + */ + @PostMapping("/receive") + public ResponseEntity> receiveWebhook( + @RequestHeader(value = "X-Webhook-Signature", required = false) String signature, + @RequestHeader(value = "X-Webhook-Event", required = false) String eventType, + @RequestHeader(value = "X-Webhook-Timestamp", required = false) String timestamp, + @RequestBody String rawPayload) { + + log.info("[Webhook] 수신 - eventType: {}, timestamp: {}", eventType, timestamp); + + // 1. 필수 헤더 누락 체크 + if (signature == null || eventType == null || timestamp == null) { + log.warn("[Webhook] 수신 거부 - 필수 헤더 누락"); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(errorResponse("필수 헤더가 누락되었습니다.")); + } + + // 2. 서명 검증 + boolean isValid = webhookReceiveService.verifySignature(rawPayload, signature); + if (!isValid) { + log.warn("[Webhook] 수신 거부 - 서명 불일치 / eventType: {}", eventType); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(errorResponse("서명 검증에 실패하였습니다.")); + } + + // 3. 타임스탬프 유효성 검증 (5분 이내 요청만 허용) + boolean isTimestampValid = webhookReceiveService.verifyTimestamp(timestamp); + if (!isTimestampValid) { + log.warn("[Webhook] 수신 거부 - 타임스탬프 만료 / eventType: {}", eventType); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(errorResponse("요청이 만료되었습니다.")); + } + + // 4. 이벤트 처리 + try { + webhookReceiveService.process(eventType, rawPayload); + } catch (Exception e) { + log.error("[Webhook] 이벤트 처리 실패 - eventType: {}, error: {}", eventType, e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(errorResponse("이벤트 처리 중 오류가 발생하였습니다.")); + } + + // 5. 정상 응답 + Map response = new HashMap<>(); + response.put("result", "OK"); + response.put("eventType", eventType); + + return ResponseEntity.ok(response); + } + + /* ------------------------------------------------------------------ */ + /* 공통 */ + /* ------------------------------------------------------------------ */ + + private Map errorResponse(String message) { + Map error = new HashMap<>(); + error.put("result", "ERROR"); + error.put("message", message); + return error; + } +} \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/webhook/dto/WebhookPayload.java b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/dto/WebhookPayload.java new file mode 100644 index 0000000..c3e040d --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/dto/WebhookPayload.java @@ -0,0 +1,31 @@ +package com.eactive.eai.rms.ext.djb.webhook.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WebhookPayload { + + private String eventType; + private String eventId; + private long timestamp; + private Object data; + + public static WebhookPayload of(String eventType, Object data) { + return WebhookPayload.builder() + .eventType(eventType) + .eventId(UUID.randomUUID().toString()) + .timestamp(System.currentTimeMillis()) + .data(data) + .build(); + } +} diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/webhook/dto/WebhookSendRequest.java b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/dto/WebhookSendRequest.java new file mode 100644 index 0000000..6250bc0 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/dto/WebhookSendRequest.java @@ -0,0 +1,13 @@ +package com.eactive.eai.rms.ext.djb.webhook.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class WebhookSendRequest { + + private String targetUrl; + private String eventType; + private Object data; +} \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/webhook/repository/WebhookSendLogRepository.java b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/repository/WebhookSendLogRepository.java new file mode 100644 index 0000000..08141d6 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/repository/WebhookSendLogRepository.java @@ -0,0 +1,31 @@ +package com.eactive.eai.rms.ext.djb.webhook.repository; + + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.eactive.eai.rms.data.EMSDataSource; +import com.eactive.eai.rms.data.ext.djb.webhook.WebhookSendLog; + +@Repository +@EMSDataSource +public interface WebhookSendLogRepository extends JpaRepository { + + // 오라클 CHAR(1) Y/N 기준 조회 + @Query("SELECT l FROM WebhookSendLog l " + + "WHERE l.success = 'N' AND l.retryCount < :maxRetry") + List findFailedLogs(@Param("maxRetry") int maxRetry); + + @Query("SELECT l FROM WebhookSendLog l " + + "WHERE l.eventType = :eventType " + + "AND l.createdAt BETWEEN :from AND :to") + List findByEventTypeAndPeriod( + @Param("eventType") String eventType, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); +} \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookReceiveService.java b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookReceiveService.java new file mode 100644 index 0000000..fca1ad0 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookReceiveService.java @@ -0,0 +1,144 @@ +package com.eactive.eai.rms.ext.djb.webhook.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebhookReceiveService { + + private final ObjectMapper objectMapper; + + + private String secretKey = "HW8JtFpkPmQqsVmr0Rb81P4qDypaNIVbGf3ZNMMPfyerhOHshoKABLaMBo1HA4BldTxhw1NjYmVnoPu9IIV7cyRXjecL3b2UctyR7DnVaJausltZLLr8Qm4Bzs4wRmgf"; + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final long TIMESTAMP_LIMIT = 5 * 60 * 1000L; // 5분 (ms) + + /* ------------------------------------------------------------------ */ + /* 서명 검증 */ + /* ------------------------------------------------------------------ */ + + /** + * 수신된 서명과 payload 로 재계산한 서명을 비교 + * 헤더값 형식 : "sha256={hex값}" + */ + public boolean verifySignature(String rawPayload, String receivedSignature) { + try { + // "sha256=" 접두어 제거 + String receivedHex = receivedSignature.startsWith("sha256=") + ? receivedSignature.substring(7) + : receivedSignature; + + String expectedHex = generateSignature(rawPayload); + + // 타이밍 공격 방지 : MessageDigest.isEqual 사용 + return MessageDigest.isEqual( + expectedHex.getBytes(StandardCharsets.UTF_8), + receivedHex.getBytes(StandardCharsets.UTF_8) + ); + } catch (Exception e) { + log.error("[Webhook] 서명 검증 중 오류 발생", e); + return false; + } + } + + /* ------------------------------------------------------------------ */ + /* 타임스탬프 검증 */ + /* ------------------------------------------------------------------ */ + + /** + * 요청 타임스탬프가 현재 시각 기준 5분 이내인지 확인 (Replay Attack 방지) + */ + public boolean verifyTimestamp(String timestampStr) { + try { + long requestTime = Long.parseLong(timestampStr); + long now = System.currentTimeMillis(); + return Math.abs(now - requestTime) <= TIMESTAMP_LIMIT; + } catch (NumberFormatException e) { + log.warn("[Webhook] 타임스탬프 파싱 실패: {}", timestampStr); + return false; + } + } + + /* ------------------------------------------------------------------ */ + /* 이벤트 처리 */ + /* ------------------------------------------------------------------ */ + + /** + * eventType 에 따라 분기 처리 + */ + public void process(String eventType, String rawPayload) throws Exception { + JsonNode root = objectMapper.readTree(rawPayload); + JsonNode data = root.path("data"); + + log.info("[Webhook] 이벤트 처리 시작 - eventType: {}", eventType); + + switch (eventType) { + case "ORDER_CREATED": + handleOrderCreated(data); + break; + case "ORDER_CANCELLED": + handleOrderCancelled(data); + break; + case "PAYMENT_COMPLETED": + handlePaymentCompleted(data); + break; + default: + log.warn("[Webhook] 처리되지 않은 이벤트 - eventType: {}", eventType); + break; + } + + log.info("[Webhook] 이벤트 처리 완료 - eventType: {}", eventType); + } + + /* ------------------------------------------------------------------ */ + /* 이벤트별 핸들러 (비즈니스 로직 구현) */ + /* ------------------------------------------------------------------ */ + + private void handleOrderCreated(JsonNode data) { + log.info("[Webhook] ORDER_CREATED 처리 - data: {}", data); + // TODO: 주문 생성 처리 로직 + } + + private void handleOrderCancelled(JsonNode data) { + log.info("[Webhook] ORDER_CANCELLED 처리 - data: {}", data); + // TODO: 주문 취소 처리 로직 + } + + private void handlePaymentCompleted(JsonNode data) { + log.info("[Webhook] PAYMENT_COMPLETED 처리 - data: {}", data); + // TODO: 결제 완료 처리 로직 + } + + /* ------------------------------------------------------------------ */ + /* 공통 유틸 */ + /* ------------------------------------------------------------------ */ + + private String generateSignature(String payload) throws Exception { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + SecretKeySpec keySpec = new SecretKeySpec( + secretKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM); + mac.init(keySpec); + byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hash); + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookService.java b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookService.java new file mode 100644 index 0000000..a7f1f5d --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookService.java @@ -0,0 +1,144 @@ +package com.eactive.eai.rms.ext.djb.webhook.service; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestTemplate; + +import com.eactive.eai.rms.data.ext.djb.webhook.WebhookSendLog; +import com.eactive.eai.rms.ext.djb.webhook.dto.WebhookPayload; +import com.eactive.eai.rms.ext.djb.webhook.repository.WebhookSendLogRepository; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebhookService { + + private final WebhookSendLogRepository sendLogRepository; + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate; + + + private String secretKey = "HW8JtFpkPmQqsVmr0Rb81P4qDypaNIVbGf3ZNMMPfyerhOHshoKABLaMBo1HA4BldTxhw1NjYmVnoPu9IIV7cyRXjecL3b2UctyR7DnVaJausltZLLr8Qm4Bzs4wRmgf"; + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final int MAX_RETRY = 3; + + /** + * 웹훅 발송 메인 메서드 + */ + @Transactional + public WebhookSendLog send(String targetUrl, String eventType, Object data) { + WebhookSendLog sendLog = new WebhookSendLog(); + sendLog.setTargetUrl(targetUrl); + sendLog.setEventType(eventType); + + try { + // 1. Payload 생성 + WebhookPayload webhookPayload = WebhookPayload.of(eventType, data); + String payloadJson = objectMapper.writeValueAsString(webhookPayload); + sendLog.setPayload(payloadJson); + + // 2. Secret Key로 HMAC-SHA256 서명 생성 + String signature = generateSignature(payloadJson); + sendLog.setSignature(signature); + + // 3. HTTP 요청 헤더 구성 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Webhook-Signature", "sha256=" + signature); + headers.set("X-Webhook-Event", eventType); + headers.set("X-Webhook-Timestamp", String.valueOf(webhookPayload.getTimestamp())); + + // 4. 발송 + sendLog.setSentAt(LocalDateTime.now()); + HttpEntity request = new HttpEntity<>(payloadJson, headers); + ResponseEntity response = restTemplate.postForEntity(targetUrl, request, String.class); + + // 5. 성공 로그 + sendLog.setStatusCode(response.getStatusCode().value()); + sendLog.setResponseBody(response.getBody()); + sendLog.setSuccess(response.getStatusCode().is2xxSuccessful()); + + log.info("[Webhook] 발송 성공 - eventType: {}, url: {}, status: {}", + eventType, targetUrl, response.getStatusCode()); + + } catch (HttpClientErrorException | HttpServerErrorException e) { + sendLog.setStatusCode(e.getStatusCode().value()); + sendLog.setResponseBody(e.getResponseBodyAsString()); + sendLog.setSuccess(false); + sendLog.setErrorMessage(e.getMessage()); + log.error("[Webhook] HTTP 오류 - eventType: {}, url: {}, status: {}", + eventType, targetUrl, e.getStatusCode()); + + } catch (Exception e) { + sendLog.setSuccess(false); + sendLog.setErrorMessage(e.getMessage()); + log.error("[Webhook] 발송 실패 - eventType: {}, url: {}, error: {}", + eventType, targetUrl, e.getMessage()); + } + + return sendLogRepository.save(sendLog); + } + + /** + * 실패 건 재시도 + */ + @Transactional + public void retryFailedWebhooks() { + List failedLogs = + sendLogRepository.findFailedLogs(MAX_RETRY); + + for (WebhookSendLog failedLog : failedLogs) { + try { + log.info("[Webhook] 재시도 - id: {}, retry: {}", failedLog.getId(), failedLog.getRetryCount()); + failedLog.setRetryCount(failedLog.getRetryCount() + 1); + sendLogRepository.save(failedLog); + + send(failedLog.getTargetUrl(), failedLog.getEventType(), + objectMapper.readValue(failedLog.getPayload(), Object.class)); + + } catch (Exception e) { + log.error("[Webhook] 재시도 실패 - id: {}", failedLog.getId(), e); + } + } + } + + /** + * HMAC-SHA256 서명 생성 + * Java 17 미만은 HexFormat 미지원이므로 직접 변환 + */ + private String generateSignature(String payload) throws Exception { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + SecretKeySpec keySpec = new SecretKeySpec( + secretKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM); + mac.init(keySpec); + byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hash); + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} \ No newline at end of file