웹훅 발송/수신 sample

This commit is contained in:
eastargh
2026-04-30 17:44:52 +09:00
parent 6ccd669ec6
commit e9bff78cbf
7 changed files with 568 additions and 0 deletions
@@ -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);
}
}
@@ -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<Map<String, Object>> sendWebhook(
@RequestBody WebhookSendRequest request) {
WebhookSendLog result = webhookService.send(
request.getTargetUrl(),
request.getEventType(),
request.getData()
);
Map<String, Object> 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<Map<String, Object>> 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<String, Object> response = new HashMap<>();
response.put("result", "OK");
response.put("eventType", eventType);
return ResponseEntity.ok(response);
}
/* ------------------------------------------------------------------ */
/* 공통 */
/* ------------------------------------------------------------------ */
private Map<String, Object> errorResponse(String message) {
Map<String, Object> error = new HashMap<>();
error.put("result", "ERROR");
error.put("message", message);
return error;
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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<WebhookSendLog, Long> {
// 오라클 CHAR(1) Y/N 기준 조회
@Query("SELECT l FROM WebhookSendLog l " +
"WHERE l.success = 'N' AND l.retryCount < :maxRetry")
List<WebhookSendLog> findFailedLogs(@Param("maxRetry") int maxRetry);
@Query("SELECT l FROM WebhookSendLog l " +
"WHERE l.eventType = :eventType " +
"AND l.createdAt BETWEEN :from AND :to")
List<WebhookSendLog> findByEventTypeAndPeriod(
@Param("eventType") String eventType,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
}
@@ -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();
}
}
@@ -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<String> request = new HttpEntity<>(payloadJson, headers);
ResponseEntity<String> 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<WebhookSendLog> 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();
}
}