아웃박스 패턴 프로젝트 (Day 2): 주문 생성과 아웃박스 기록, 한 번에!
안녕하세요! 지난번에는 아웃박스 패턴을 활용하여 데이터 유실 없는 이벤트 기반 아키텍처를 구축하는 프로젝트의 전체적인 그림을 그려봤었죠. 오늘은 그 대장정의 첫걸음, Day 1: 기본 프로젝트 구조 설정 및 도메인 모델링을 함께 진행해 보겠습니다.
단단한 기반이 있어야 튼튼한 집을 지을 수 있듯이, 프로젝트의 초기 세팅과 핵심 도메인 모델링은 매우 중요합니다. 너무 복잡하게 생각하지 말고, 필요한 것들부터 하나씩 차근차근 시작해 봅시다
프로젝트 초기 설정 : Spring Boot & Gradle
spring boot를 먼저 생성해볼게요. 저는 인텔리제이의 Spring Initializer을 활용해서 아주 쉽게 의존성들을 추가하여 프로젝트를 만들어 보겠습니다.
Spring Initializr 설정:
Project: Gradle Project
Language: Java
Spring Boot: 3.5.4
Group:
com.example.outboxArtifact:
order-servicePackaging: Jar
Java: 17
필수 의존성 (Dependencies):
Spring Web: 웹 애플리케이션 개발을 위한 기본
Spring Data JPA: JPA와 데이터베이스 연동
PostgreSQL Driver: PostgreSQL 데이터베이스 연결 드라이버
Lombok: 반복적인 코드(Getter, Setter 등)를 줄여주는 유용한 라이브러리
Spring for Apache Kafka: Kafka 연동 (나중에 이벤트 발행 및 수신에 사용)
2. application.properties설정
PostgreSQL 데이터베이스와 JPA 연동을 위한 기본 설정을 추가해야 합니다. src/main/resources/application.properties 파일을 열고 다음 내용을 추가해 주세요.
application.properties
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# DataSource Settings (PostgreSQL)
spring.datasource.url=jdbc:postgresql://localhost:5432/outbox_db
spring.datasource.username=postgres
spring.datasource.password=your_password # 실제 비밀번호로 변경하세요!
spring.datasource.driver-class-name=org.postgresql.Driver
# JPA Settings
spring.jpa.hibernate.ddl-auto=update # 개발 단계에서는 'update'로 테이블 자동 생성/수정
spring.jpa.show-sql=true # 실행되는 SQL 쿼리 로그 출력
spring.jpa.properties.hibernate.format_sql=true # SQL 쿼리 포맷팅
# Kafka Settings (추후 사용)
spring.kafka.bootstrap-servers=localhost:9092 # 로컬 Kafka 브로커 주소
spring.kafka.consumer.group-id=order_service_group
spring.kafka.producer.retries=3 # 메시지 전송 실패 시 재시도 횟수
중요: spring.datasource.password는 여러분의 PostgreSQL 비밀번호로 반드시 변경해야 합니다. outbox_db 데이터베이스가 아직 없다면, PostgreSQL 클라이언트(psql 등)에서 미리 생성해두는 것이 좋습니다.
SQL
1
2
-- PostgreSQL에서 데이터베이스 생성 (CLI)
CREATE DATABASE outbox_db;
3. 핵심 도메인 모델링: Order 엔티티
이제 주문 서비스를 위한 핵심 엔티티인 Order를 정의해 보겠습니다. 단순화를 위해 필수적인 필드만 포함하고, JPA 매핑을 적용합니다.
src/main/java/com/example/outbox/order/domain/Order.java 파일을 생성하고 다음 코드를 작성합니다.
Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example.outbox.order.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.Builder;
import java.time.LocalDateTime;
import java.util.UUID; // 주문 ID를 UUID로 사용
@Entity
@Table(name = "orders") // 'order'는 SQL 예약어일 수 있으므로 'orders'로 변경
@Getter
@Setter // 엔티티에 @Setter는 지양하지만, 예시의 편의를 위해 포함. 실제 프로덕션에서는 Builder나 메서드로 제어 권장
@NoArgsConstructor // JPA를 위한 기본 생성자
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID) // UUID 생성 전략 사용 (MySQL 8.0 이상, PostgreSQL 13.0 이상)
private UUID id;
@Column(nullable = false)
private Long userId; // 주문한 사용자 ID
@Column(nullable = false)
private String productName; // 상품 이름
@Column(nullable = false)
private int quantity; // 수량
@Column(nullable = false)
private double totalPrice; // 총 가격
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status; // 주문 상태 (예: PENDING, PAID, SHIPPED, CANCELLED)
@Column(nullable = false)
private LocalDateTime orderDate; // 주문 일시
// Lombok의 @Builder 어노테이션 활용
@Builder
public Order(Long userId, String productName, int quantity, double totalPrice, OrderStatus status, LocalDateTime orderDate) {
this.userId = userId;
this.productName = productName;
this.quantity = quantity;
this.totalPrice = totalPrice;
this.status = status;
this.orderDate = orderDate;
}
// 비즈니스 로직에 따른 상태 변경 메서드 (setter 대신)
public void markAsPaid() {
if (this.status == OrderStatus.PENDING) {
this.status = OrderStatus.PAID;
} else {
throw new IllegalStateException("주문 상태가 PENDING이 아니어서 결제 완료 처리할 수 없습니다.");
}
}
}
OrderStatus Enum도 정의해 줍시다. src/main/java/com/example/outbox/order/domain/OrderStatus.java 파일을 생성합니다.
Java
1
2
3
4
5
6
7
8
9
package com.example.outbox.order.domain;
public enum OrderStatus {
PENDING, // 주문 대기 (결제 전)
PAID, // 결제 완료
SHIPPED, // 배송 중
DELIVERED,// 배송 완료
CANCELLED // 주문 취소
}
📊 4. 아웃박스 테이블(OutboxMessage) 모델링
이제 아웃박스 패턴의 핵심인 OutboxMessage 엔티티를 정의할 차례입니다. 이 테이블은 실제 메시지 큐로 전송될 이벤트 메시지들을 임시로 저장하는 역할을 합니다.
src/main/java/com/example/outbox/outbox/domain/OutboxMessage.java 파일을 생성하고 다음 코드를 작성합니다.
Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.example.outbox.outbox.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Builder;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "outbox_messages")
@Getter
@NoArgsConstructor // JPA를 위한 기본 생성자
public class OutboxMessage {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, updatable = false)
private String aggregateType; // 이벤트가 발생한 도메인 타입 (예: "Order")
@Column(nullable = false, updatable = false)
private UUID aggregateId; // 이벤트가 발생한 도메인 엔티티의 ID (예: 주문 ID)
@Column(nullable = false, updatable = false)
private String eventType; // 이벤트 타입 (예: "OrderPaidEvent")
@Column(nullable = false, columnDefinition = "jsonb") // JSONB 타입으로 저장 (PostgreSQL 특화)
private String payload; // 이벤트 데이터 (JSON 문자열 형태로 저장)
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OutboxStatus status; // 메시지 처리 상태 (PENDING, SENT)
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt; // 메시지 생성 시간
@Column
private LocalDateTime processedAt; // 메시지 처리 시간
@Builder
public OutboxMessage(String aggregateType, UUID aggregateId, String eventType, String payload, OutboxStatus status, LocalDateTime createdAt) {
this.aggregateType = aggregateType;
this.aggregateId = aggregateId;
this.eventType = eventType;
this.payload = payload;
this.status = status;
this.createdAt = createdAt;
}
// 메시지 상태 변경 메서드
public void markAsSent() {
this.status = OutboxStatus.SENT;
this.processedAt = LocalDateTime.now();
}
}
OutboxStatus Enum도 정의해 줍니다. src/main/java/com/example/outbox/outbox/domain/OutboxStatus.java 파일을 생성합니다.
Java
1
2
3
4
5
6
package com.example.outbox.outbox.domain;
public enum OutboxStatus {
PENDING, // 아직 발행되지 않은 상태
SENT // 발행 완료된 상태
}