헥사고날 아키텍처와 모놀리식 모듈 구조 실험 #
들어가며 – 설계를 밀어붙였던 3개월차 프로젝트 #
원티드 포텐업 세 번째 프로젝트도 LXP도메인이었다.
이번 프로젝트에서도 기능 구현보다 설계 선택이 실제 개발 경험에 어떤 영향을 주는지를 직접 확인해보는 데 초점을 두었다.
헥사고날 아키텍처, 도메인 모델과 영속 모델의 분리,그리고 향후 MSA 전환을 염두에 둔 모놀리식 모듈 구조까지
이론으로는 익숙했던 개념들을 현실적인 제약 속에서 끝까지 밀어붙여보는 경험이었다.
그 과정에서 구조는 점점 단단해졌지만,
동시에 복잡성에 대한 부담과 개발 속도에 대한 고민도 함께 따라왔다.
설계를 끝까지 가져가려는 선택은,
자연스럽게 팀의 속도와 방향에 대한 고민으로 이어졌다.
기술적인 판단이 곧 팀에 영향을 미친다는 점에서, 이번 프로젝트는 “개발자”뿐 아니라 “리더”로서의 역할에 대해서도 돌아보게 만든 시간이었다.
이와 관련한 생각은 아래 글에 따로 정리해 두었다.
https://minyong.dev/docs/porten_up/reading_review/
이번 프로젝트의 목표는 명확했다
- 헥사고날 아키텍처를 통해 외부 의존성과의 결합도를 낮출 것
- Application Layer에서는
@Transactional,@Retryable,@EventListener정도만 허용하고, 나머지 기술 의존성은 최대한 Infra로 밀어낼 것 - 도메인 모델과 JPA 엔티티를 완전히 분리해볼 것
- 향후 MSA 전환을 고려한 모놀리식 모듈 구조를 먼저 경험해볼 것
하지만 솔직히, 이번 프로젝트는 많이 힘들었다.
헥사고날 아키텍처를 도입하면서 의존성 방향을 계속 신경 써야 했고, 클래스 수는 빠르게 늘어났다. 단순 CRUD에도 만들어야 할 클래스가 너무 많아졌고, 모놀리식 모듈 간의 의존성 문제도 해결해야 했다. 구조적인 깔끔함에 집착하다 보니 변경되는 부분이 너무 컸고, 시간적 여유도 없었다.
무엇보다 팀원 간 소통이 원활하지 않았다. 설계에 대한 컨텍스트 공유가 부족했고, 결과적으로 프론트엔드에게 일정 내에 API를 제공하지 못했다. MVP 단계에서 “이건 하지 말자"라고 내가 먼저 결론 내려버린 순간들도 있었다.
기술적으로 실험하고 싶은 것은 많았는데, 팀 전체의 속도와 맞추지 못한 부분이 가장 큰 아쉬움으로 남는다.
그래도 이 과정에서 배운 것들이 있다. 이 회고는 그 기록이다.
전체 아키텍처 개요 #
이번 프로젝트에서 시도한 구조를 다이어그램으로 정리하면 다음과 같다.
핵심은 Domain Layer가 어떤 외부 기술에도 의존하지 않는 것이었다.
또한 application또한 외부와의 기술에도 의존을 최소한 하기 위해 @Transactional, @Retryable, @EventListener 만 허용 만 하고
infra와 presentaion와의 결합도를 최소화 하기 위해 모든 통로를
interface(usecase, port) 로 정의하였다
이 회고에서 다루는 내용 #
잘했던 점
- 도메인 모델과 JPA 엔티티의 완전한 분리
- ReadModel을 통한 쓰기/읽기 책임 분리
- Domain Event와 Integration Event의 명확한 분리
- 보일러플레이트 제거를 위한 공통 라이브러리 배포
아쉬웠던 점
- Aggregate 비대함을 완전히 해결하지 못함
- Outbox 패턴 미완성
- CommandBus/QueryBus 도입의 애매함
- 모듈 간 테스트 구조의 어려움
1. 도메인 모델과 JPA 엔티티 분리 – 가장 의도적으로 가져간 선택 #
왜 분리하려고 했는가? #
이전 프로젝트에서 JPA 엔티티가 곧 도메인 모델이었을 때 몇 가지 불편함이 있었다.
-
첫 번째는 더티 체킹으로 인한 의도치 않은 상태 변경이다. 트랜잭션 안에서 엔티티를 조회한 뒤 getter로 꺼낸 컬렉션을 수정하면, 명시적으로 save()를 호출하지 않아도 DB에 반영된다. 의도한 변경인지 실수인지 코드만 봐서는 구분하기 어렵다.
-
두 번째는 지연 로딩 예외다. 트랜잭션 밖에서 연관 엔티티에 접근하면
LazyInitializationException이 터진다. 이를 피하려고 OSIV를 켜거나, 모든 곳에서 fetch join을 신경 써야 했다. -
세 번째는 테스트 환경의 무거움이다. 단순한 비즈니스 로직을 테스트하고 싶을 뿐인데, JPA 설정과 DB 연결이 필요했다.
@DataJpaTest나@SpringBootTest없이는 도메인 로직 검증조차 어려웠다. -
네 번째는 도메인 모델의 오염이다.
@Entity,@OneToMany,@JoinColumn같은 JPA 어노테이션이 도메인 클래스에 덕지덕지 붙으면서, 비즈니스 로직과 영속성 설정이 뒤섞였다. 조회 성능을 위해@EntityGraph를 추가하다 보면 도메인 설계가 JPA 최적화에 끌려다니는 느낌이 들었다.
실제 코드 구조
// 도메인 모델 - 순수 Java, JPA 의존성 없음
public class Course {
private final CourseUUID uuid;
private final CourseTitle title;
private final CourseSections sections;
private final CourseTags tags;
public void addSection(SectionUUID uuid, String title) {
this.sections = sections.addSection(uuid, title);
}
}
<br>
// JPA 엔티티 - 영속성만 담당
@Entity
@Table(name = "courses")
public class CourseJpaEntity {
@Id @GeneratedValue
private Long id;
@Column(name = "uuid")
private String uuid;
private String title;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<SectionJpaEntity> sections = new ArrayList<>();
}
어떤 효과가 있었는가? #
1. 도메인 테스트가 쉬워졌다
// JPA 설정 없이 순수 단위 테스트 가능
@Test
@DisplayName("exception when adding duplicate Section UUID")
void addDuplicateSection() {
// [수정] setUp에 이미 있는 section-123을 중복 추가 시도
SectionUUID duplicateUUID = new SectionUUID("section-123");
assertThatThrownBy(() -> course.addSection(duplicateUUID,"중복 섹션"))
.isInstanceOf(CourseException.class);
}
2. 의도치 않은 상태 변경을 차단했다
더티 체킹에 의존하지 않기 때문에 명시적으로 save()를 호출해야만 저장된다.
단점: 매핑 코드 폭발 #
Domain ↔ JpaEntity ↔ ReadModel ↔ ViewDTO ↔ Response
계층이 늘어나면서 매핑 코드가 많아졌다. 초기 피로도가 상당했지만, 구조적 안정성을 얻기 위한 비용이라고 판단했다.
2. ReadModel 분리 – 이전부터 분리하고 싶었던 의도적 선택2 #
왜 분리했는가? #
쓰기 모델에는 instructorId만 존재하는데, 조회 화면에서는 instructorName이 필요하다.
효과
// Before: N+1 지옥
public List<CourseListResponse> getCourseList() {
List<Course> courses = courseRepository.findAll();
return courses.stream()
.map(course -> {
User instructor = userService.findById(course.getInstructorId()); // N번!
List<Tag> tags = tagService.findByIds(course.getTagIds()); // N번!
return new CourseListResponse(course, instructor, tags);
})
.collect(toList());
}
// After: ReadModel만 조회
public List<CourseListResponse> getCourseList() {
return readModelRepository.findAll().stream()
.map(CourseListResponse::from)
.collect(toList());
}
아쉬운 점: Tag 검색 설계 미흡 #
ReadModel에 tagsJson으로 저장했는데, Tag name으로 Course를 검색해야 할 때 문제가 됐다.
// JSON 검색은 성능 이슈
@Query("SELECT c FROM CourseReadJpaEntity c WHERE c.tagsJson LIKE %:tagName%")
List<CourseReadJpaEntity> findByTagName(String tagName);
다음에는 tagNames를 별도 컬럼으로 빼서 검색에 최적화된 구조로 설계해야겠다.
@Entity
public class CourseReadJpaEntity {
// ...
@ElementCollection
@CollectionTable(name = "course_read_tag_names")
private List<String> tagNames; // 검색용 별도 컬럼
@Column(columnDefinition = "JSON")
private String tagsDetailJson; // 상세 정보용 JSON
}
3. Domain Event와 Integration Event 분리 #
왜 분리했는가? #
많은 프로젝트에서 Application Layer에서 바로 Integration Event를 발행한다. 하지만 이렇게 하면 도메인 로직과 외부 시스템 연동이 뒤섞인다.
현재는 RabbitMQ나 Kafka 같은 메시지 큐를 사용하지 않는다. 모든 이벤트가 Spring의 ApplicationEventPublisher를 통해 동기적으로 처리된다.
그럼에도 Domain Event와 Integration Event를 분리한 이유는 나중에 MSA로 전환할 때 변경 범위를 최소화하기 위해서다.
이렇게 분리한 이유 #
| 구분 | Domain Event | Integration Event |
|---|---|---|
| 관심사 | 도메인 내부 | 외부 시스템 연동 |
| 발행 위치 | Domain/Application | Infrastructure |
| 포맷 | 도메인 객체 그대로 | 외부 스키마에 맞춤 |
| 실패 처리 | 트랜잭션 롤백 | 재시도/Outbox |
나중에 메시지 큐를 도입하더라도 Infrastructure Layer의 발행 부분만 교체하면 된다.
Application Layer는 여전히 ApplicationEventPublisher.publish(domainEvent)만 호출하면 되고, 그게 로컬로 처리되든 Kafka로 나가든 알 필요가 없다
4. Aggregate 비대함을 줄이기 위한 Wrapper 전략 – 중간 단계의 타협 #
문제 상황 #
Course가 모든 메서드를 직접 들고 있으면 거대한 클래스가 된다.
public class Course {
// Section 관련 메서드들
public void addSection(...) { }
public void removeSection(...) { }
public void updateSectionTitle(...) { }
// Lecture 관련 메서드들 (Section 내부)
public void addLecture(...) { }
public void removeLecture(...) { }
// Tag 관련 메서드들
public void addTag(...) { }
public void removeTag(...) { }
// ... 메서드가 계속 늘어남
}
시도한 해결책: Wrapper 클래스 #
public class Course {
private final CourseSections sections; // Wrapper
private final CourseTags tags; // Wrapper
public Course addSection(Section section) {
return new Course(uuid, title, sections.add(section), tags);
}
}
public class CourseSections {
private final List<Section> sections;
public CourseSections add(Section section) {
validateMaxCount();
// ...
}
private void validateMaxCount() {
if (sections.size() >= 20) {
throw new DomainException(CourseErrorCode.SECTION_MAX_EXCEEDED);
}
}
}
한계 인식 #
현재 Wrapper 클래스들은 “Aggregate 분리가 필요한데 아직 안 한 상태"의 중간 단계다.
나중에 Section을 별도 Aggregate로 분리하면 이 Wrapper들이 오히려 발목을 잡을 수 있다. 이 부분은 확장 시점에 다시 재검토가 필요하다.
왜 Wrapper가 나중에 발목을 잡을 수 있는가? #
현재 구조는 이렇다
Course (Aggregate Root)
└── CourseSections (Wrapper)
└── List<Section>
└── SectionLectures (Wrapper)
└── List<Lecture>
만약 Section을 별도 Aggregate로 분리하면 이렇게 바뀌어야 한다
Course (Aggregate Root)
└── List<SectionId> // ID만 참조
Section (별도 Aggregate Root)
└── List<Lecture>
문제는 CourseSections Wrapper가 Section 객체를 직접 들고 있다는 점이다.
// 현재: Wrapper가 Section 전체를 관리
public class CourseSections {
private final List<Section> sections; // 객체 직접 참조
public CourseSections add(Section section) { ... }
public int calculateTotalDuration() {
return sections.stream()
.mapToInt(Section::getTotalDuration)
.sum();
}
}
Section이 별도 Aggregate가 되면
List<Section>→List<SectionId>로 바꿔야 함calculateTotalDuration()같은 메서드는 더 이상 Wrapper에서 계산 불가 (Section을 직접 조회해야 함)- Wrapper의 존재 의미 자체가 사라짐
결국 Wrapper를 걷어내고 구조를 다시 짜야 한다. Aggregate 분리 시점에 Wrapper가 중간 레이어로 껴있으면 변경 범위가 오히려 커진다.
5. 이벤트 처리 – Spring Modulith의 이벤트 발행 테이블로 Outbox 흉내내기 #
Spring Modulith의 event_publication 테이블 #
모놀리식 모듈 구조에서 이벤트 히스토리를 관리하기 위해 Spring Modulith의 이벤트 발행 테이블을 활용했다.
-- Spring Modulith가 자동 생성하는 테이블
CREATE TABLE event_publication (
id UUID PRIMARY KEY,
listener_id VARCHAR(255), -- 어떤 리스너가 처리해야 하는지
event_type VARCHAR(255), -- 이벤트 클래스명
serialized_event TEXT, -- 직렬화된 이벤트
publication_date TIMESTAMP, -- 발행 시점
completion_date TIMESTAMP -- 처리 완료 시점 (NULL이면 미완료)
);
동작 원리 #
트랜잭션 시작
│
├── Course 저장
├── applicationEventPublisher.publishEvent(CourseCreatedEvent)
│ │
│ └── Spring Modulith가 event_publication에 자동 저장 (BEFORE_COMMIT)
│
트랜잭션 커밋 (Course + event_publication 원자적 저장)
│
└── @TransactionalEventListener(AFTER_COMMIT) 실행
│
├── 성공 → completion_date 채워짐
└── 실패 → completion_date NULL 유지 (재시도 대상)
핵심은 이벤트 저장이 도메인 저장과 같은 트랜잭션에서 일어난다는 점이다.
Course가 저장되면 이벤트도 반드시 저장된다.
왜 Outbox 흉내인가? #
Outbox 패턴의 핵심 아이디어를 Spring Modulith가 이미 구현해두었다
| Outbox 패턴 | Spring Modulith |
|---|---|
| Outbox 테이블에 저장 | event_publication 테이블에 저장 |
| 같은 트랜잭션에서 저장 | BEFORE_COMMIT에서 저장 |
| 스케줄러로 재시도 | IncompleteEventPublications로 재시도 |
6. CommandBus / QueryBus – Routing 역할로 한정 #
도입 의도 #
UseCase 의존성 정리가 목적이었다. 미들웨어나 MSA 전환 대비 같은 거창한 이유는 아니었다.
// UseCase가 늘어나도 의존성은 2개로 고정
@RestController
class CourseController {
private final CommandBus commandBus;
private final QueryBus queryBus;
}
현재 구조: Facade + Bus 조합 #
현재는 Facade와 Bus를 함께 사용하고 있다.
Controller → Facade → CommandBus → UseCase(Handler)
@Component
public class CourseFacade {
private final CommandBus commandBus;
private final QueryBus queryBus;
public CourseDetailView createCourse(CourseCreateRequest request) {
return commandBus.dispatch(new CourseCreateCommand(...));
}
public CourseDetailView getCourse(String courseId) {
return queryBus.dispatch(new CourseGetQuery(courseId));
}
}
Facade는 요청을 조합하고, Bus는 적절한 Handler로 라우팅한다. 현재는 Bus가 사실상 단순 라우팅 역할만 하고 있다.
나중에 확장할 수 있는 방향 #
1. 공통 로깅/메트릭 수집
public class LoggingCommandBus implements CommandBus {
private final CommandBus delegate;
@Override
public <R> R dispatch(Command<R> command) {
log.info("Command 시작: {}", command.getClass().getSimpleName());
long start = System.currentTimeMillis();
R result = delegate.dispatch(command);
log.info("Command 완료: {}ms", System.currentTimeMillis() - start);
return result;
}
}
2. Saga 패턴이 필요할 때
예를 들어 “강의 결제” 시나리오를 생각해보면
1. 결제 승인 (Payment)
2. 수강 등록 (Enrollment)
3. 알림 발송 (Notification)
중간에 실패하면 보상 트랜잭션이 필요하다
public class EnrollmentSaga {
public void execute(EnrollmentCommand command) {
try {
// Step 1: 결제
PaymentResult payment = commandBus.dispatch(new PaymentCommand(...));
// Step 2: 수강 등록
EnrollmentResult enrollment = commandBus.dispatch(new CreateEnrollmentCommand(...));
// Step 3: 알림
commandBus.dispatch(new SendNotificationCommand(...));
} catch (EnrollmentFailedException e) {
// 보상: 결제 취소
commandBus.dispatch(new CancelPaymentCommand(...));
throw e;
}
}
}
Bus가 있으면 각 단계를 독립적인 Command로 분리하고, Saga Orchestrator에서 Bus를 통해 순차적으로 실행할 수 있다. 각 Command는 자기 책임만 지고, 전체 흐름 제어는 Saga가 담당한다.
현재는 이런 복잡한 시나리오가 없어서 Bus가 단순 라우팅만 하고 있지만, 구조는 이미 갖춰놨으니 필요할 때 확장하면 된다.
7. 공통 라이브러리 배포 – 편리함과 자유도의 충돌 #
왜 만들었는가? #
여러 모듈에서 반복되는 코드를 줄이고 싶었다.
common-lib/
├── common-domain/ # 순수 자바
│ ├── event/ # DomainEvent, IntegrationEvent
│ ├── model/ # BaseEntity, ValueObject, Page
│ └── exception/ # ErrorCode, DomainException
├── common-application/ # UseCase, Command, Query 인터페이스
└── common-infrastructure/ # Spring 의존 구현체
겪었던 문제: 빈 충돌 #
초기에 @Component가 붙은 클래스를 라이브러리에 포함시켰다가 빈 충돌이 발생했다.
해결: 라이브러리에서 @Component 제거, 사용하는 쪽에서 빈 등록 #
// 라이브러리: @Component 없음
public class SpringDomainEventPublisher implements DomainEventPublisher {
// ...
}
// 사용하는 프로젝트에서 직접 빈 등록
@Configuration
public class EventConfig {
@Bean
public DomainEventPublisher domainEventPublisher(ApplicationEventPublisher publisher) {
return new SpringDomainEventPublisher(publisher);
}
}
라이브러리는 클래스만 제공하고, 빈 등록 책임은 사용하는 프로젝트에 맡기는 방식으로 해결했다.
또 하나의 아쉬움: 문서화 부족 #
공통 라이브러리의 “계약"이 명확하지 않았다. 다음에는 README와 Javadoc을 더 신경 써야겠다.
8. MSA를 준비하기 위한 모놀리식 모듈 구조 #
시도한 구조 #
project/
├── application/ # Boot 진입점
├── api/ # Controller들이 여기서 다른 모듈 조립
├── course/ # Course Bounded Context
│ ├── domain/
│ ├── application/
│ └── infrastructure/
├── enrollment/ # Enrollment Bounded Context
├── user/ # User Bounded Context
└── common/ # 공통 모듈
장점 #
- 모듈 간 의존성이 명확해졌다
- “이 모듈은 왜 존재하는가?“를 계속 질문하게 되었다
문제: 테스트 진입점 #
Spring Boot 4에서는 멀티 모듈 구조에서 테스트 동작 방식이 더 엄격해졌다. @DataJpaTest 같은 슬라이스 테스트가 이전처럼 느슨하게 루트를 추론하지 않는다.
우리 프로젝트는 application 모듈만 Boot 애플리케이션 루트로 두고, course, user 같은 서브 모듈은 순수 라이브러리 모듈로 설계했다. 문제는 서브 모듈에서 JPA 테스트를 실행하면 Boot 설정을 찾지 못한다는 점이었다.
결국 서브 모듈에서 JPA Repository를 테스트하려면 테스트용 JPA 설정을 직접 구성해야 했다.
이 경험을 통해 Boot 애플리케이션은 하나만 유지하고, 서브 모듈은 Boot에 의존하지 않는 순수 모듈로 설계하는 것이 멀티 모듈 구조에서 안정적이라는 걸 배웠다. 자세한 내용은 관련 이슈 문서에 정리해두었다
관련 이슈 문서화
https://github.com/orgs/Poten-Up-3rd-Project/discussions/37
왜 헥사고날 아키텍처를 선택했는가 – 솔직히 실험이었다 #
선택의 이유 #
사실 명확한 비즈니스 요구사항이 있어서 헥사고날을 선택한 건 아니었다. 공부했던 걸 실제로 적용해보고 싶었다. 책과 강의에서 봤던 “외부 의존성으로부터 도메인을 보호한다”, “포트와 어댑터로 결합도를 낮춘다"는 개념이 매력적으로 느껴졌고, 이번 프로젝트에서 직접 경험해보고 싶었다.
또 하나는, 이 프로젝트를 다음 프로젝트의 기반으로 가져갈 생각이 있었다. 유지보수하기 쉽고, 확장 가능한 구조를 미리 만들어두면 나중에 편하지 않을까 하는 기대가 있었다.
레이어드였으면 단순 CRUD에 Controller → Service → Repository 3개면 끝났을 것을, 헥사고날에서는 Controller → Facade → UseCase → DomainService → Port → Adapter → Mapper까지, 하나의 기능에 6~7개 클래스가 필요했다.
써보니까 힘들었던 점 #
1. 의존성 방향을 계속 신경 써야 했다
“이 클래스는 어느 레이어에 있어야 하지?”, “이 import가 의존성 방향을 위반하는 건 아닌가?” 매번 고민해야 했다. 레이어드에서는 자연스럽게 흐르던 코드가, 헥사고날에서는 구조를 먼저 생각해야 했다.
2. 단순한 변경도 여러 파일을 건드려야 했다
필드 하나 추가하는데 Domain → JpaEntity → Mapper → DTO까지 4개 파일을 수정해야 하는 경우가 많았다. 구조적 안정성을 얻는 대신 민첩성을 잃었다.
3. 팀원과 컨텍스트 공유가 어려웠다
헥사고날 아키텍처의 개념 자체를 공유하는 게 오버헤드였다.
예를 들어, 모놀리식에서는 다른 모듈의 서비스를 직접 주입받아도 동작하는 데 문제가 없다. 그런데 나는 “외부 모듈이라도 내 도메인 관점에서 Port를 정의하고, Adapter에서 변환해야 한다"는 원칙을 적용하고 있었다.
// 팀원입장
@Service
public class CourseService {
private final UserService userService; // 외부 모듈 직접 주입
}
// 내 원칙: 내 도메인에 맞는 Port를 정의해야 한다
public interface UserQueryPort {
InstructorInfo getInstructor(Long userId);
}
@Component
public class UserAdapter implements UserQueryPort {
private final UserService userService;
@Override
public InstructorInfo getInstructor(Long userId) {
UserDto user = userService.getUser(userId);
return new InstructorInfo(user.getId(), user.getName()); // 내 도메인에 맞게 변환
}
}
“왜 굳이 이렇게 해야 하는지"를 설명하는 것 자체가 시간이었다.
게다가 이번 프로젝트 팀원들은 이전 프로젝트에서 함께한 사람들이 아니었다. 내 설계 스타일을 모르는 상태에서 DDD, 헥사고날 개념까지 전달하려니 벅찼다. 코드 리뷰에서 세세하게 피드백을 주고 싶었지만, 나도 구조 잡느라 시간이 부족했다. 결국 충분한 컨텍스트 공유 없이 각자 작업하는 시간이 많아졌고, 이게 후반부에 통합할 때 문제가 됐다.
솔직한 고백 #
돌아보면, 왜 이렇게까지 “깔끔한 구조"에 집착했는지 나도 잘 모르겠다.
처음에는 “유지보수를 위해서”, “확장성을 위해서"라고 생각했는데, 정작 MVP도 못 맞추면서 구조만 다듬고 있었다. “좋은 구조"가 목적이 되어버린 순간이 있었던 것 같다.
다음에는 “이 구조가 지금 이 프로젝트에 정말 필요한가?“를 먼저 물어봐야겠다. 헥사고날이 나쁜 게 아니라, 상황에 맞지 않는 선택이 문제였다.
마치며 – 구조에 치여서 속도를 잃었던 3개월 #
솔직히 이번 프로젝트는 기술적 실험에 너무 매몰되었던 것 같다.
헥사고날, 도메인/JPA 분리, CQRS, 이벤트 분리, 모놀리식 모듈… 모든 걸 한꺼번에 적용하려다 보니 정작 팀과 함께 달려야 할 속도를 맞추지 못했다.
프론트엔드에게 일정 내에 API를 제공하지 못한 것, 팀원들과 설계 컨텍스트를 충분히 공유하지 못한 것, 혼자 고민하다가 결론을 먼저 내려버린 것. 이런 부분들이 기술적 성취보다 더 큰 아쉬움으로 남는다.
그래도 배운 것들이 있다:
- 도메인/JPA 분리는 초기 비용이 크지만, 테스트와 유지보수에서 확실한 이점이 있다
- Domain Event / Integration Event 분리는 Application Layer를 깔끔하게 유지하는 데 도움이 된다
- ReadModel 설계 시 검색 요구사항까지 고려해야 한다
- Bus 패턴은 Routing 이상의 역할이 필요할 때 확장하면 된다
- Outbox 패턴은 재시도 로직까지 있어야 완성이다
- 공통 라이브러리는 인터페이스만 제공하고, 빈 등록은 사용처에서 해야 한다
다음 프로젝트에서는 기술적 실험의 범위를 좁히고, 팀과 함께 달릴 수 있는 속도를 먼저 맞춰야겠다. 구조는 천천히, 점진적으로 개선해도 늦지 않다.