404-Factory 애플리케이션에서 공통으로 사용하는 Java 라이브러리입니다.
이 저장소는 표준 API 응답 형식, 예외 추상화, Spring MVC 전역 응답/예외 처리 기능뿐만 아니라, 이벤트 주도 아키텍처(Event-Driven Architecture)를 위한 공통 이벤트 봉투 스키마, Kafka 연동 지원, 그리고 Transactional Outbox 및 Inbox 패턴을 구현한 자바 모듈들을 제공합니다.
Gradle 멀티 모듈 프로젝트로 구성되어 있습니다.
| 모듈 | 설명 |
|---|---|
core |
ApiResponse, ErrorResponse, ValidationException, ErrorCode, BaseException 등 공통 DTO와 예외 계약을 제공합니다. |
contract |
Spring MVC 환경에서 응답 래핑과 전역 예외 처리를 담당하는 GlobalControllerAdvice를 자동 설정으로 제공합니다. core 모듈에 의존합니다. |
event |
도메인 이벤트 및 Kafka 이벤트 메시지 발행을 위한 표준 스키마(Event, EventEnvelope, EventPayload, EventType 등)와 EventEnvelopeFactory, IdempotencyKeyGenerator 등의 인프라 인터페이스를 제공합니다. |
kafka |
Kafka 이벤트 메시지 발행 및 소비를 위한 EventPublisher, CommonKafkaConsumer, EventDispatcher, EventHandler를 제공합니다. event 모듈에 의존합니다. |
outbox-core |
Transactional Outbox 패턴을 구현하기 위한 도메인 모델(OutboxEvent), 저장소 인터페이스(OutboxRepository) 및 공통 서비스(OutboxService)를 제공합니다. event 모듈에 의존합니다. |
outbox-jpa |
JPA 기반의 Outbox 영속성 레이어(OutboxEntity, JpaOutboxRepository)와 로컬 Spring 이벤트를 가로채 Outbox 테이블에 자동으로 저장하는 OutboxEventListener를 제공합니다. outbox-core 및 kafka 모듈에 의존합니다. |
inbox-core |
Idempotent Consumer (Inbox 패턴)를 위한 도메인 모델(ProcessedEvent), 이력 저장소 인터페이스(ProcessedEventRepository) 및 서비스(InboxService)를 제공합니다. event 모듈에 의존합니다. |
inbox-jpa |
JPA 기반의 Inbox 이력 관리 및 @InboxProcessed 어노테이션 기반 AOP(InboxProcessedAspect)를 제공하여 손쉽게 중복 이벤트 처리를 방지합니다. inbox-core 모듈에 의존합니다. |
- Java 17
- Gradle 8.14 (Kotlin DSL)
- Spring Boot 3.5.14
core 모듈은 여러 서비스에서 공통으로 사용할 API 응답 DTO와 예외 관련 인터페이스/클래스를 제공합니다.
성공 응답과 실패 응답을 동일한 형식으로 표현하기 위한 표준 API 응답 래퍼입니다.
성공 응답 예시:
{
"success": true,
"status": 200,
"message": "success",
"data": {
"id": 1,
"name": "example"
},
"timestamp": "2026-05-21T10:15:30"
}실패 응답 예시:
{
"success": false,
"status": 400,
"message": "Invalid input value.",
"error": {
"code": "CA002",
"details": [{
"target": "name",
"message": "must not be blank"
}]
},
"timestamp": "2026-05-21T10:15:30"
}ApiResponse의 error 필드에 들어가는 에러 상세 정보입니다.
포함하는 값은 다음과 같습니다.
code: 애플리케이션에서 정의한 에러 코드details: 유효성 검증 실패 상세 목록
Jakarta Bean Validation 과정에서 발생한 단일 검증 실패 정보를 표현합니다.
포함하는 값은 다음과 같습니다.
target: 검증에 실패한 필드, 파라미터 또는 객체 이름message: 검증 실패 메시지
서비스별 에러 코드를 표준화하기 위한 인터페이스입니다.
구현체는 다음 값을 제공합니다.
- HTTP 상태 코드
- 에러 코드
- 사용자 또는 클라이언트에게 전달할 메시지
예시:
public enum UserErrorCode implements ErrorCode {
USER_NOT_FOUND(404, "USER001", "User not found.");
private final int status;
private final String code;
private final String message;
UserErrorCode(int status, String code, String message) {
this.status = status;
this.code = code;
this.message = message;
}
@Override
public int getStatus() {
return status;
}
@Override
public String getCode() {
return code;
}
@Override
public String getMessage() {
return message;
}
}도메인별 커스텀 예외의 기반이 되는 추상 런타임 예외 클래스입니다.
BaseException을 상속하면 GlobalControllerAdvice에서 일관된 형식의 에러 응답으로 처리할 수 있습니다.
예시:
public class UserException extends BaseException {
public UserException(ErrorCode errorCode) {
super(errorCode);
}
public UserException(ErrorCode errorCode, Throwable cause) {
super(errorCode, cause);
}
}contract 모듈은 Spring MVC 기반 애플리케이션에서 공통 응답 형식과 전역 예외 처리를 제공합니다.
주요 기능은 다음과 같습니다.
- 성공 응답 자동 래핑
- 비즈니스 예외 처리
- 유효성 검증 예외 처리
- Spring MVC 예외 처리
contract 모듈은 Spring Boot의 Auto Configuration을 지원합니다.
ContractAutoConfiguration은 다음 조건을 만족할 때 GlobalControllerAdvice Bean을 등록합니다.
- Servlet 기반 Web Application인 경우
ObjectMapperBean이 존재하는 경우- 사용자가 직접 등록한
GlobalControllerAdviceBean이 없는 경우
컨트롤러가 반환하는 성공 응답은 자동으로 ApiResponse 형식으로 감싸집니다.
컨트롤러 예시:
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
return userService.getUser(id);
}응답 예시:
{
"success": true,
"status": 200,
"message": "success",
"data": {
"id": 1,
"name": "example"
},
"timestamp": "2026-05-21T10:15:30"
}이미 ApiResponse 타입으로 반환한 응답은 다시 감싸지 않습니다.
다음 경우에는 응답을 ApiResponse로 감싸지 않습니다.
- 응답 본문이 이미
ApiResponse인 경우 - HTTP 메서드가
HEAD,OPTIONS,TRACE인 경우 - Servlet 기반 응답이 아닌 경우
- 응답 본문을 가지지 않는 HTTP 상태 코드인 경우
1xx3xx204 No Content
org.springdoc패키지에 속한 클래스의 응답인 경우
org.springdoc 패키지는 OpenAPI / Swagger 문서 생성에 영향을 주지 않기 위해 제외됩니다.
컨트롤러가 String을 반환하는 경우에도 JSON 형식의 ApiResponse로 응답하기 위해 별도 직렬화 처리를 수행합니다.
컨트롤러 예시:
@GetMapping("/hello") public String hello() {
return "hello";
}응답 예시:
{
"success": true,
"status": 200,
"message": "success",
"data": "hello",
"timestamp": "2026-05-21T10:15:30"
}BaseException을 상속한 예외는 GlobalControllerAdvice에서 처리됩니다.
예시:
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
if (id == null) {
throw new UserException(UserErrorCode.USER_NOT_FOUND);
}
return userService.getUser(id);
}응답 예시:
{
"success": false,
"status": 404,
"message": "User not found.",
"error": {
"code": "USER001"
},
"timestamp": "2026-05-21T10:15:30" }@Valid 또는 Jakarta Validation을 통해 요청 값 검증에 실패하면 표준 에러 응답으로 변환됩니다.
요청 DTO 예시:
public class CreateUserRequest {
@NotBlank
private String name;
@Email
private String email;
// getter/setter
}컨트롤러 예시:
@PostMapping("/users")
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.createUser(request);
}응답 예시:
{
"success": false,
"status": 400,
"message": "Invalid input value.",
"error": {
"code": "CA002",
"details": "must not be blank"
},
"timestamp": "2026-05-21T10:15:30"
}컨트롤러 메서드의 @RequestParam, @PathVariable, @RequestHeader, @RequestBody 등에서 발생하는 검증 실패도 처리합니다.
입력값 검증 실패는 400 Bad Request로 처리되며, 반환값 검증 실패는 500 Internal Server Error로 처리됩니다.
Spring MVC 내부에서 발생한 예외는 ApiResponse 에러 형식으로 변환됩니다.
별도로 처리되지 않은 예외는 내부 서버 오류로 변환됩니다.
응답 예시:
{
"success": false,
"status": 500,
"message": "An unexpected internal server error occurred.",
"error": {
"code": "CA001"
},
"timestamp": "2026-05-21T10:15:30" }| 코드 | HTTP 상태 | 메시지 |
|---|---|---|
CA001 |
500 |
An unexpected internal server error occurred. |
CA002 |
400 |
Invalid input value. |
CA003 |
500 |
Invalid output value. |
CA004 |
500 |
Failed to write JSON response. |
CA005 |
XXX |
MVC-related error occurred. |
CA005는 Spring MVC 예외 처리 과정에서 실제 응답 상태 코드를 사용합니다.
event 모듈은 분산 이벤트 기반 아키텍처 환경에서 메시지의 정합성과 규격을 보장하기 위한 핵심 도메인 모델과 추상화 레이어를 제공합니다.
Event<T extends EventPayload>- 모든 도메인 이벤트가 지녀야 할 기본적인 메타데이터 구조를 강제하는 인터페이스입니다.
idempotencyKey(멱등 키),eventType(이벤트 논리명),payload(실제 바디),traceId(분산 추적 식별자),timestamp(발생 시각),aggregateType(애그리거트 타입),aggregateId(애그리거트 식별자) 정보에 접근할 수 있는 메서드를 제공합니다.
EventEnvelope<T extends EventPayload>Event인터페이스의 표준 구현체입니다. Jackson 직렬화/역직렬화 시 상속 구조를 안정적으로 유지하기 위해@JsonTypeInfo설정을 탑재하고 있습니다.
EventPayload- 이벤트 바디로 사용될 비즈니스 DTO 클래스를 마킹하기 위한 인터페이스입니다.
EventType- 이벤트의 성격과 물리적 구분을 위한 이름을 제공하는 인터페이스입니다. 주로 Enum 클래스로 구현하여 사용합니다.
EventEnvelopeFactoryIdempotencyKeyGenerator및TraceIdProvider,Clock을 주입받아, 중복되지 않는 고유 멱등성 키와 분산 추적용 traceId가 포함된 완성형EventEnvelope인스턴스를 일관되게 생성할 수 있도록 돕는 유틸리티 클래스입니다.
kafka 모듈은 Kafka 브로커로 이벤트를 전송하고, 수신된 이벤트를 적절한 서비스(핸들러)로 라우팅하는 기본 메시징 솔루션을 자동 설정과 함께 제공합니다.
EventPublisher- 도메인 이벤트를 외부 메시징 브로커 또는 로컬 버스로 발행하는 기본 인터페이스입니다.
CommonKafkaConsumer- 스프링
@KafkaListener어노테이션을 탑재하고 브로커로부터 레코드를 주기적으로 수집하여EventDispatcher로 이관해 주는 기본 컨슈머 클래스입니다. sigma.event.consumer.topics및groupId설정을 활성화하여 작동합니다.
- 스프링
EventDispatcher- 들어온 JSON 메시지에서
eventType필드를 식별한 뒤, 해당 이벤트를 구독하고 있는EventHandler<?>구현체를 스캔 및 매핑하여, payload 타입에 맞춰 역직렬화된Event<T>객체를 던져 주는 라우터 역할을 합니다.
- 들어온 JSON 메시지에서
EventHandler<T extends EventPayload>- microservice 영역에서 특정
eventType의 이벤트를 수신하여 처리하는 비즈니스 리스너 규격입니다.
- microservice 영역에서 특정
sigma:
event:
consumer:
topics: equipment-events, recipe-events
groupId: anomaly-detection-service데이터베이스 상태 변경(Transaction)과 Kafka 이벤트 메시지 발행 간의 원자성(Atomicity)을 보장하기 위해 Transactional Outbox 패턴을 쉽게 활용할 수 있도록 지원합니다.
- 비즈니스 로직을 수행하는 서비스에서
EventPublisher.publish(event)를 호출합니다. outbox-jpa모듈은 이 이벤트를 Spring의 로컬ApplicationEventPublisher로 포워딩합니다.@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)로 등록된OutboxEventListener가 트랜잭션이 데이터베이스에 커밋되기 직전에 이를 가로채,outbox테이블에 엔티티(OutboxEntity) 형태로 저장합니다.- 이로써 비즈니스 데이터의 변경과 이벤트 저장이 하나의 로컬 데이터베이스 트랜잭션으로 묶여 원자성을 보장받게 됩니다.
- 저장된 Outbox 데이터는 이후 별도의 CDC 툴(Debezium 등)이나 Outbox Poller 스케줄러를 통해 실제 Kafka 브로커로 발행됩니다.
OutboxEvent<T extends EventPayload>:EventEnvelope을 확장하며, 파티셔닝 키로 사용하기 위해aggregateType과aggregateId를 필수로 보관합니다.OutboxService: 전해받은 이벤트를 직렬화하여 아웃박스 영속성 레이어에 적재하는 핵심 제어 인터페이스입니다.OutboxEntity: 데이터베이스outbox테이블과 1:1 매핑되는 JPA 엔티티입니다.OutboxEventListener:BEFORE_COMMIT시점에 로컬 이벤트를 가로채 DB 아웃박스 레코드로 기록합니다.
@Service
@RequiredArgsConstructor
@Transactional
public class AnomalyDetectionServiceImpl implements AnomalyDetectionService {
private final EventPublisher eventPublisher;
private final EventEnvelopeFactory eventEnvelopeFactory;
public void detectAnomaly(...) {
// ... 비즈니스 로직 및 엔티티 수정
AnomalyCreatedPayload payload = AnomalyCreatedPayload.builder()
.equipmentId(equipment.getId())
.severity("CRITICAL")
.occurredTime(Instant.now())
.build();
// 1. 이벤트 봉투 생성
EventEnvelope<AnomalyCreatedPayload> eventEnvelope = eventEnvelopeFactory.create(
AnomalyEventType.ANOMALY_CREATED,
payload
);
// 2. 이벤트 발행 (로컬 트랜잭션과 묶여 outbox 테이블에 저장됨)
eventPublisher.publish(eventEnvelope);
}
}메시지 브로커(Kafka)로부터 유입되는 중복 메시지나 재처리(Retry) 요청으로 인해 발생하는 비즈니스 오작동을 차단하기 위해 **Idempotent Consumer (Inbox 패턴)**를 제공합니다.
- 카프카 토픽으로부터 이벤트를 수신하여 처리하는 핸들러의
process메서드에@InboxProcessed어노테이션을 부착합니다. - AOP 가로채기(
InboxProcessedAspect)를 통해, 메서드가 실행되기 전 수신한Event객체의idempotencyKey와eventType을 기반으로InboxService.isAlreadyProcessed(idempotencyKey)를 조회합니다. - 만약 이미 동일한 키로 처리 완료된 이력이 DB 테이블(
processed_events)에 기록되어 있다면, 메서드 실행을 즉시 스킵하고null을 반환합니다. - 처음 유입된 이벤트인 경우, 메서드가 정상적으로 완료되면 커밋 시점에 성공 이력(
ProcessedEventEntity)을 함께 데이터베이스에 영속화하여 중복 방지를 확정 짓습니다.
ProcessedEvent: 처리 완료된 메시지 식별 모델입니다.InboxService: 중복 유입 여부 판별 및 완료 이력 작성을 대행하는 코어 인터페이스입니다.@InboxProcessed: 멱등 처리를 보장하고자 하는 타겟 메서드에 선언적으로 부여하는 어노테이션입니다.ProcessedEventEntity: 데이터베이스processed_events테이블과 연동되는 JPA 엔티티입니다.
@Component
@RequiredArgsConstructor
public class EquipmentRecipeUpdatedHandler implements EventHandler<EquipmentRecipePayload> {
private final EquipmentRecipeRepository equipmentRecipeRepository;
@Override
public String getEventType() {
return "EquipmentRecipeUpdated";
}
@Override
@Transactional
@InboxProcessed // 멱등 처리 AOP 활성화
public void process(Event<EquipmentRecipePayload> event) {
// 이미 처리된 event.getIdempotencyKey()가 존재하는 경우 이 아래 코드는 실행되지 않고 자동 스킵됩니다.
EquipmentRecipePayload payload = event.getPayload();
EquipmentRecipe recipe = EquipmentRecipe.builder()
.id(payload.getEquipmentRecipeId())
.version(payload.getVersion())
.build();
equipmentRecipeRepository.save(recipe);
}
}