JDBC에서 JPA로- 잘된 것과 아쉬운 것들 #
원티드 포텐업 첫 번째 프로젝트에서는 LXP를 주제로, 순수 자바와 JDBC만으로 CLI 프로그램을 만들었습니다. 그때는 “JDBC 환경에서 DDD를 어떻게 적용할 수 있을까?”라는 고민이 중심이었고, Aggregate, Repository, 레이어 분리 같은 개념을 프레임워크 없이 직접 구현해 보면서 설계의 중요성과 기술의 한계를 꽤 뼈저리게 느꼈습니다.
이번 두 번째 프로젝트 역시 주제는 넓은 의미에서 LXP이지만, 방향이 조금 달랐습니다. roadmap.sh를 클론한, 사용자 학습 여정에 더 초점을 맞춘 서비스를 만들고자 했습니다. 사용자는 하나의 로드맵을 선택하고, 그 안의 토픽/서브토픽을 따라가며 진도를 관리하고, 자신의 학습 진행 상황을 한눈에 확인할 수 있는 구조를 목표로 했습니다.
기술 스택도 한 단계 올라갔습니다. 이번에는 Spring Boot와 JPA를 기반으로, JPA의 연관관계, 영속성 컨텍스트, 도메인 이벤트를 제대로 활용해 보면서 “JDBC에서 머릿속으로만 그렸던 DDD 설계를, 실제 웹 애플리케이션에서 얼마나 현실적으로 구현할 수 있을까?”를 실험해 보고 싶었습니다.
이 회고에서 다루는 내용 #
이번 회고는 크게 두 가지 축으로 구성되어 있습니다.
기술적 시도와 개선 과제 (JPA + DDD 관점) #
- Aggregate Root 설계: 루트를 통해서만 접근한다는 원칙을 어디까지 적용해야 할까?
- JPA 조회 전략: 엔티티 그래프와 BatchSize만으로 충분했을까? Read Model 분리는?
- Command/Factory/DomainService 책임 분리: 어디서 무엇을?
- 낙관적 락 전략: 충돌 감지 이후 재시도 로직은 어디에?
- 도메인 이벤트: 결합도는 낮췄지만, 동기 처리로 인한 응답 지연은?
- 다른 도메인 DTO 참조: 직접 import하니 테스트와 경계가 모호해진 경험
개인적인 커뮤니케이션에 대한 반성 #
- 말투와 태도: 효율성을 추구하다 날카로워진 건 아니었을까?
- 설계 수준: 팀의 러닝 커브를 충분히 고려했는가?
- 소통의 양: 혼자 고민하기보다 함께 풀어갔어야 하지 않았을까?
첫 번째 JDBC 기반 프로젝트 회고가 기술 스택 제약 속에서 DDD를 어떻게 흉내 내보았는가에 가까웠다면, 이번 회고는 JPA로 DDD를 진지하게 시도해 보니 어떤 부분이 잘 됐고, 어떤 부분이 아쉬웠으며, 다음에는 어떻게 개선할 수 있을까에 초점을 두고 있습니다.
완벽한 성공 사례라기보다는, “실제로 해보니 이런 지점에서 생각보다 많이 고민했다"는 솔직한 기록에 가깝습니다.
이 글이, 비슷한 고민을 하고 계신 분들께 “아, 나만 이런 고민하는 게 아니구나"라는 작은 위로와, “다음에는 이렇게 해볼 수 있겠구나"라는 힌트를 함께 줄 수 있으면 좋겠습니다.
들어가며 – 내가 처음 그리고 싶었던 아키텍처 #
앞서 말했듯, JDBC 기반 프로젝트에서는 조금 과하게(?) DDD를 흉내 냈습니다.
- Row와 Domain Entity를 분리하고
- Aggregate 별로 Repository를 따로 두고
- 간단한 ApplicationContext를 직접 만들어 의존성을 주입하고
- 다른 Aggregate 참조는 반드시 Service 레이어에서만 하도록 강제하고
덕분에 레이어 분리와 의존성 방향은 나름 깔끔하게 가져갈 수 있었습니다. 하지만 JDBC 특성상
- 연관관계는 전부 수동으로 관리해야 했고
- 영속성 컨텍스트가 없으니 변경 감지도 직접 구현해야 했고
- 트랜잭션 경계도 매번 직접 신경 쓰면서 코드를 짜야 했습니다.
그래서 두 번째 프로젝트를 시작할 때 이런 기대를 했습니다.
JPA를 쓰면 이런 저수준 고민은 많이 줄어들 거고, 이제는 도메인 설계와 아키텍처에 더 집중할 수 있겠지?
그리고 처음에 머릿속에 그렸던 역할 분리는 대략 이랬습니다.
계층별로 그리고 있었던 역할 #
Application Layer #
- 유스케이스 조립
- 트랜잭션 경계 설정
- 외부 I/O 및 응답 DTO 매핑
Domain Service #
- 여러 Aggregate에 걸친 정책/불변식
- Command DTO 생성·검증
Entity / Aggregate Root #
- 불변식 유지
- 상태 전이
- 하위 엔티티로의 위임
- 도메인 이벤트 등록
Event Listener #
- 도메인 이벤트 구독
- 다른 Bounded Context / 도메인으로 로직 위임
- 필요 시 별도 트랜잭션(
REQUIRES_NEW)
Repository + Adapter #
- Aggregate 단위 영속화 계약 (Repository 인터페이스)
- JPA/JDBC 등 기술 세부 구현은 Adapter에 캡슐화
CQRS에 대한 욕심 #
또 하나 욕심냈던 건 CQRS였습니다. Read/Write를 분리하고, Read Model은 조회 요구사항에 맞게 따로 설계하고, 이벤트로 동기화하는 구조를 해보고 싶었습니다.
하지만 현실적으로
- Read 테이블/인덱스를 따로 설계하고
- 동기화 전략(이벤트, 배치, Outbox 등)을 다 챙기기에는 이번 프로젝트 스코프와 일정이 빠듯했습니다.
그래서 이번에는
- 서비스만 Command / Query로 분리한 Lite CQRS 정도까지만 적용했고
- 모델/테이블은 여전히 Write/Read가 함께 쓰는 구조였습니다.
1. Aggregate Root가 점점 뚱뚱해지는 경험 #
루트를 통해서만 변경한다"는 말의 함정 #
모든것은 root를 통해야 된다며?
그럼 상태 변경 메서드는 전부 root를 통해 제어 해야 하는 거 아닌가?
그래서 초기 Travel은 이런 모습이었습니다 #
public void addSubTopics(Long topicId, List<ProgressSubTopicCommand> commands) {
updateValid();
getTopicOrThrow(topicId).addSubTopics(commands);
}
public void markTopic(Long topicId, ProgressStatus status) {
updateValid();
getTopicOrThrow(topicId).changeStatus(status);
}
public void markSubTopic(Long topicId, Long subTopicId, ProgressStatus status) {
updateValid();
getTopicOrThrow(topicId).markSubTopic(subTopicId, status);
}
public void activateTopic(Long topicId, ActiveStatus status) {
updateValid();
getTopicOrThrow(topicId).activate(status);
}
public void activateSubTopic(Long topicId, Long subTopicId, ActiveStatus status) {
updateValid();
getTopicOrThrow(topicId).getSubTopicOrThrow(subTopicId).activate(status);
}
대부분의 메서드가:
updateValid()로 “Travel이 아카이브 상태가 아닌지” 체크하고- 실제 로직은 다시 Topic/SubTopic에 위임하는 구조였습니다.
덕분에 Root는 점점 이런 래핑 메서드들로 붐비기 시작했습니다.
불변식은 지키는데, Root가 애매하게 비대해진 느낌
예를 들어 전체 진행 상태를 계산하는 로직은 처음엔 이렇게 Root 안에 있었습니다.
public TravelProgressStatus getStatus() {
return calculateStatus();
}
private TravelProgressStatus calculateStatus() {
Set<ProgressStatus> allStatuses =
topics.stream()
.filter(it -> !it.isArchived())
.flatMap(
topic ->
Stream.concat(
Stream.of(topic.getStatus()),
topic.getSubTopics().stream()
.filter(it -> !it.isArchived())
.map(ProgressSubTopic::getStatus)))
.collect(Collectors.toSet());
if (allStatuses.contains(ProgressStatus.TODO)
|| allStatuses.contains(ProgressStatus.IN_PROGRESS)) {
return TravelProgressStatus.IN_PROGRESS;
}
return allStatuses.contains(ProgressStatus.DONE)
? TravelProgressStatus.DONE
: TravelProgressStatus.IN_PROGRESS;
}
이 로직은 사실 topics 컬렉션 내부 상태만 보고 결론을 내립니다.
Travel의 isArchived 여부와는 상관이 없습니다.
그런데도 “Aggregate Root니까"라는 이유로 이런 메서드들이 Root로 계속 빨려 들어오다 보니,
- Root는 비대해지고
- “정말 Root가 책임져야 하는 것"과 “그냥 컬렉션이 담당해도 될 것"이 뒤섞이는 느낌이 들기 시작했습니다.
이번 프로젝트에서의 한계 #
이번 프로젝트에서는 프로젝트 후반부쯤에야
“이런 건 ProgressTopics 같은 컬렉션 객체로 빼는 게 더 자연스럽지 않았을까?” 정도만 떠올렸고,
실제로 전면 도입까지 하지는 못했습니다.
다음에 시도해보고 싶은 것 #
다음에는 아예 처음부터
- Root는 전역적인 불변식(
Travel이 아카이브 상태면 어떤 변경도 불가)만 책임지고 - Topic/SubTopic 관련 계산/조합 로직은 컬렉션 객체(
ProgressTopics)로 빼는 방향을 기본값으로 가져가 보고 싶습니다.
깨달은 것 #
이 경험을 통해서, Aggregate Root를 통해서만 변경한다는 말을 좀 더 현실적으로 이해하게 됐습니다.
외부에서 child를 ID로 직접 로딩해서 마음대로 바꾸지 말라는 것이지,
모든 메서드 구현이 Root 안에 있어야 한다는 뜻은 아니다.
2. JPA 그래프 로딩과 BatchSize – 편한 만큼 조심해야 했다 #
JPA를 쓰면서 가장 먼저 시도한 것 중 하나는 엔티티 그래프 로딩입니다.
@NamedEntityGraph+fetch = LAZY@BatchSize로 N+1 줄이기- Travel–Topic–SubTopic을 효율적으로 불러오기
처음에는 엔티티 그래프 + BatchSize면 N+1도 해결되고, 성능도 괜찮겠지?라고 생각했습니다.
실제 구현 방식 #
그래서 실제 구현은
Travel → Topic까지는 엔티티 그래프/페치 조인으로 한 번에 가져오고Topic → SubTopic은 LAZY +@BatchSize로 묶어서 가져오는 방식
으로 설계했습니다.
이렇게 하면 Travel–Topic–SubTopic을 한 번에 3중 조인하지는 않기 때문에,
1 × 10 × 10 = 100 row 같은 극단적인 조인 결과값을 해결하긴 했습니다만.
하지만 여전히 문제는 있었다 #
Travel–Topic만 보더라도
- Travel 50개
- 각 Travel마다 Topic 10개
만 되어도, Travel–Topic 조인 결과는 이미 50 × 10 = 500 row가 됩니다.
여기에 Topic–SubTopic도 BatchSize로 한 번에 가져오다 보면,
쿼리 수(N+1)는 줄었지만 결국 전체적으로 읽어오는 row 수와 네트워크 I/O는 꽤 커진다는 점을 체감하게 되었습니다.
JPA가 중복을 제거해준다는 건 어디까지나 JVM 안의 이야기이고,
네트워크/DB I/O 비용은 그대로 다 지불하고 있었습니다.
여기서 다시 보이는 경계: 쓰기 모델 ≠ 읽기 모델 #
이 지점에서
“쓰기 모델을 위한 엔티티 구조를 그대로 조회에 재사용하는것”
의 한계를 분명히 느꼈습니다.
- 쓰기 모델은 **도메인 규칙과 변경을 표현하는데 최적화 되어있고,
- 읽기 모델은 데이터를 보기 좋게, 빠르게 보여주는것에 최적화 되어야 한다
하지만 이번 프로젝트에서는 두 가지를 한 덩어리로 묶어 놓은 상태였고, 그 결과 JPA 페치전략과 엔티티 그래프 튜닝으로 한계를 메꾸는 구조가 되었습니다.
이 경험 이후로,
“쓰기 모델과 읽기 모델의 경계를. 왜 이코드가 바뀌는(변경이유) 를 기준으로 다시 그려봐야겠다”
는 생각이 들었고, 그게 곧 CQRS를 진지하게 고민하는 계기 되었습니다.
이번 프로젝트의 한계 #
이번 프로젝트에서는
- 완전한 CQRS(Read Model 테이블 분리, 이벤트 동기화)는 가지 못하고
- 서비스만 Command / Query로 분리한 Lite CQRS 수준에 머물렀습니다
그래도 이 경험 덕분에 확실해진 건 하나였습니다.
쓰기 모델과 읽기 모델을 정말 분리해보고 싶다.
다음에 시도해보고 싶은 것 #
다음에는:
- Write Model은 지금처럼 JPA + 도메인 엔티티로 가져가되
- Read Model은 별도 테이블/Redis 등으로 설계하고
- 데이터 정합성이 중요한 동기화전략에서는도메인 이벤트 + Outbox 패턴으로 비동기로 처리해 보려 합니다
- Read 쪽은 JPA 엔티티 대신 가벼운 POJO + JDBC/QueryDSL 조합도 진지하게 고려 중입니다
이건 단순히 “CQRS"를 한번써보고싶다” 는 호기심이 아니라
- Aggregate 설계의 피로
- 쓰기/읽기 요구를 한 모델로 끌어 안았을때의 구조적 한계
를 겪어 본 뒤, 그에 대한 다음 설계 실험으로서 CQRS를 도입해 보고 싶은 마음에 가깝습니다.
3. Command/Factory/DomainService 책임 분리: 어디서 무엇을? #
내가 실제로 가지고 있던 도메인 서비스 코드 #
먼저 실제 코드부터 보겠습니다.
@DomainService
public class TravelCreationService {
public Travel create(UserView user, RoadMapView roadMap) {
if (!user.status().equals(AccountStatus.ACTIVE)) {
throw new DomainException(TravelErrorCode.TRAVEL_USER_NOT_ACTIVE);
}
if (!roadMap.isDraft() || roadMap.isDeleted()) {
throw new DomainException(TravelErrorCode.TRAVEL_ROADMAP_INVALID);
}
List<ProgressTopicCommand> topicCommands =
roadMap.topics().stream()
.map(
t ->
new ProgressTopicCommand(
t.id(),
t.subTopics().stream()
.map(
st ->
new ProgressSubTopicCommand(
st.id()))
.toList()))
.toList();
TravelCommand travelCommand = new TravelCommand(user.id(), roadMap.id(), topicCommands);
return Travel.create(travelCommand);
}
}
다시 보니, 사실은 세 가지 책임을 하고 있었다 #
1. 교차 Aggregate 정책 검증 #
if (!user.status().equals(AccountStatus.ACTIVE)) {
throw new DomainException(TravelErrorCode.TRAVEL_USER_NOT_ACTIVE);
}
if (!roadMap.isDraft() || roadMap.isDeleted()) {
throw new DomainException(TravelErrorCode.TRAVEL_ROADMAP_INVALID);
}
user.status == ACTIVE인지roadMap이 draft 상태이고, 삭제되지 않았는지
이건 User + RoadMap + Travel 사이에 걸쳐 있는 정책이라
어느 한 Aggregate Root 내부에 넣기 애매합니다.
이런 상황을 위해 Domain Service가 존재한다라는 의도로 분리했습니다.
2. RoadMap → Travel 구조로 재구성 #
List<ProgressTopicCommand> topicCommands =
roadMap.topics().stream()
.map(t -> new ProgressTopicCommand(
t.id(),
t.subTopics().stream()
.map(st -> new ProgressSubTopicCommand(st.id()))
.toList()))
.toList();
이 부분은 단순 매핑 같지만, 사실은 도메인 정책이 들어가 있습니다.
“새로운 Travel은 RoadMap의 Topic/SubTopic 구조를 그대로 가져와서 시작한다”
즉, RoadMap의 현재 상태를 Travel의 초기 상태 스냅샷으로 쓰겠다는 결정입니다.
이 지식은 Travel 혼자보다는 RoadMap과 Travel을 모두 아는 도메인 서비스에 두는 게 더 자연스럽습니다.
3. Aggregate Root에게 최종 조립 위임 #
TravelCommand travelCommand = new TravelCommand(user.id(), roadMap.id(), topicCommands);
return Travel.create(travelCommand);
도메인 서비스는 Travel이 이해할 수 있는 형식까지 맞춰 주고,
실제 내부 조립, 불변식, 연관관계 설정은 Root에 위임합니다.
그렇다면 이 정도 역할은 괜찮은 것 아닌가? #
이 정도 역할이라면,
여러 도메인의 상태를 보고 Travel 생성 가능 여부를 판단하고,
생성이 가능하다면 Travel 쪽에서 이해할 수 있는 형태로 데이터를 재구성해준다
라고 보는 게 맞다고 느꼈습니다.
그럼 “도메인 서비스 비만"은 아닌가? #
이렇게 되면 한 파일 안에
- 교차 Aggregate 정책/불변식 로직과
- 단순한 매핑/조립 로직이 뒤섞이면서,
“정책을 읽고 싶어서 도메인 서비스를 열었는데,
80%가 변환/조립 코드인 상태"가 됩니다.
제가 돌아보면서 아쉬웠던 지점은 바로 여기입니다.
다음에는 이렇게 가져가 보고 싶다 #
이번 프로젝트에서는 TravelCreationService 정도에서 멈췄고,
이 이상 거대해지기 전에 프로젝트가 끝났습니다.
그래서 다음에는, 코드가 커지기 시작하면 아예 역할을 더 명확하게 나누어 볼 생각입니다.
역할 분리 방향 #
DomainService
- 여러 Aggregate에 걸친 정책/불변식에 집중
- “이 케이스에서 Travel을 만들어도 되는가?“에 대한 판단 위주
Factory / Creator / Mapper
- Command → 엔티티 조립
- 도메인 내부 구조를 알고 있는 쪽
예시 코드 #
@DomainService
public class TravelCreationService {
public void validate(UserView user, RoadMapView roadMap) {
if (!user.status().equals(AccountStatus.ACTIVE)) {
throw new DomainException(TravelErrorCode.TRAVEL_USER_NOT_ACTIVE);
}
if (!roadMap.isDraft() || roadMap.isDeleted()) {
throw new DomainException(TravelErrorCode.TRAVEL_ROADMAP_INVALID);
}
}
public Travel create(UserView user, RoadMapView roadMap) {
validate(user, roadMap);
TravelCommand command = TravelCommandFactory.from(user, roadMap); // 이런 느낌
return Travel.create(command);
}
}
이번 회고에서 얻은 감각 #
이번엔 시간과 여건상 여기까지는 못 갔지만,
- 도메인 서비스는 “정책을 읽기 쉽게 만드는 쪽"으로
- 조립/매핑은 별도(Factory/Mapper)로 빼는 쪽이 더 좋겠다
는 감각을 얻었습니다.
4. 낙관적 락 전략: 충돌 감지 이후 재시도 로직은 어디에? #
@Version을 달았는게 충돌 감지 이후 재시도와 에러 처리는 어디에?
#
이번 프로젝트에서 @Version을 AggregateRoot에 붙였습니다.
@MappedSuperclass
public abstract class AggregateRoot extends IdAuditEntity {
@Getter
@Version
protected Long version;
@Transient
protected final List<DomainEvent> domainEvents = new ArrayList<>();
// ...
}
동시성 제어는 이것만으로도 기술적으로는 작동합니다.
실제로 동시 수정이 발생하면 OptimisticLockException이 발생하고, 한쪽은 실패합니다.
하지만 프로젝트가 끝나고 보니 동시성 충돌을 감지만 할 뿐 처리는 없었습니다.
이 부분은 다음에 꼭 정리하고 싶은 숙제로 남았습니다.
사실 지금 돌아보면, 이 낙관적 락 처리 로직은
- 각 Application Service마다 개별적으로
try-catch를 두기보다는 @Retryable이나 직접 만든@OptimisticRetry어노테이션 + AOP로 낙관적 락 재시도를 공통 관심사로 분리하는 편이 더 깔끔했을 것 같습니다.
Spring의 @Retryable 활용하여 관심사 분리
#
@Service
public class TravelApplicationService {
@Retryable(
value = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50)
)
@Transactional
public void markSubTopic(Long travelId, Long topicId, Long subTopicId, ProgressStatus status) {
Travel travel = travelRepository.getById(travelId);
travel.markSubTopic(topicId, subTopicId, status);
}
}
AOP 직접 정의해보기 #
각 도메인 별로 정책을 다르게 가져 갈 수 도 있다고 생각합니다.
커스텀한 @OptimisticRetry는 낙관적 락 충돌에만 특화 정책/로깅/모니터링을 내 도메인에 맞게 커스터마이징하기 쉬움 필요하면 도메인별로 다른 구현도 만들 수 있음 (ex. 코어 도메인은 5회, 비핵심은 2회 등)
@Slf4j
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // @Transactional 보다 바깥에서 감싸게 (상황에 따라 조정)
public class OptimisticRetryAspect {
@Around("@annotation(retry)")
public Object retryOnOptimisticLock(ProceedingJoinPoint pjp, OptimisticRetry retry) throws Throwable {
int attempts = 0;
int maxAttempts = retry.maxAttempts();
long delay = retry.delay();
또 하나의 고민: Aggregate 경계와 충돌 확률 #
- Root(
Travel)에만@Version이 달려 있기 때문에 - 하위 엔티티(Topic/SubTopic)만 변경해도
Travel의 version이 올라갑니다 - Aggregate가 비대해질수록, 그리고 그 Aggregate를 동시에 수정할 사용자 수가 많을수록
충돌 확률이 기하급수적으로 늘어날 수 있습니다
이건 결국 Aggregate 경계를 어떻게 가져갈 것인가와 다시 연결되는 문제입니다.
다음에 시도해보고 싶은 것 #
이번엔 “충돌이 실제로 심각하게 많이 발생하는 수준까지 가지는 않았다"에서 멈췄고, 다음에는
- 실제 Optimistic Lock 충돌 로그를 모니터링해서
- 어느 도메인/어떤 유스케이스에서 충돌이 많은지 파악하고
- Aggregate 경계와 재시도 정책을 조정해 나가는 쪽으로 한 단계 더 나아가 보고 싶습니다
5. 도메인 이벤트 – 결합도는 낮췄지만, 응답이 길어졌던 구조 #
도메인 이벤트를 도입한 이유 #
Roadmap과 Travel 사이의 결합도를 줄이기 위해 도메인 이벤트를 도입했습니다.
- Roadmap에서 Topic/SubTopic이 변경되면
TopicEventOccurred,SubTopicEventOccurred같은 이벤트를 발행하고- Travel 측에서 이를 받아 Travel 상태를 동기화하는 구조입니다
결합도는 낮아졌다 #
덕분에
- Roadmap 도메인은 Travel의 존재를 몰라도 되고
- Travel 도메인은 Roadmap의 내부 구현을 몰라도 됩니다
결합도 측면에서는 분명히 개선이었습니다.
하지만 여전히 동기 처리였다 #
문제는
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
이 조합은 여전히 동기입니다.
- 원래 트랜잭션이 커밋된 이후에 실행되지만
- 같은 쓰레드, 같은 HTTP 요청 흐름 안에서 도는 구조는 그대로입니다
이번 프로젝트에서의 한계 #
이번 프로젝트에서는
- 운영 환경/장애 대응/모니터링까지 생각했을 때
- 당장 비동기 메시징 + Outbox로 옮기기에는 부담스럽다고 판단했고
- 결국 끝까지 동기 도메인 이벤트 구조로 프로젝트를 마무리했습니다
다음에 시도해보고 싶은 것 #
이 부분도 “완전히 해결한” 것이 아니라,
다음에는 Outbox + 비동기 핸들러로 가져가 보고 싶은 지점으로 남아 있습니다.
비동기 전환 시 고려할 방향
- Outbox 패턴으로 이벤트를 DB에 먼저 기록 (같은 트랜잭션)
- 별도 Worker/Consumer가 Outbox를 읽어서 처리
- Travel 동기화는 eventual consistency 허용
- 사용자 응답은 빠르게 반환하고, 실제 반영은 조금 뒤에
6. 다른 도메인의 View DTO를 직접 import 했을 때의 답답함 #
처음에는 그냥 가져다 썼다 #
Travel 쪽에서 Roadmap 정보를 참조할 때,
처음에는 Roadmap의 View DTO를 그대로 가져다 썼습니다.
public class TravelService {
private final RoadmapQueryService roadmapQueryService; // interface
public void createTravel(Long userId, Long roadmapId) {
RoadmapViewResponse roadmap = roadmapQueryService.getRoadmapView(roadmapId);
if (!roadmap.active()) {
throw new IllegalStateException("비활성화된 로드맵");
}
// ...
}
}
동작은 하는데, 점점 이런 생각이 들었습니다.
점점 답답해지는 구조 #
- Travel 테스트를 짤 때도 Roadmap의 DTO 구조를 알아야 하고
- 사실 Travel에 필요한 정보는
roadmapId,active여부,topicIds정도뿐인데
굳이 전체 View DTO를 알아야 할까? - Roadmap 쪽 API가 바뀌면 Travel 테스트가 같이 깨지는 구조가 맞는 걸까?
Adapter 패턴을 도입했으면 깔끔했을 것 #
사실 이 부분은, 지금이라도 Adapter 패턴을 도입했으면 깔끔했을 지점이었습니다.
이상적인 구조 #
Travel 모듈 입장에서 port는 정의했지만
// Travel이 정의한 Port
public interface RoadmapPort {
RoadmapInfo getRoadmapInfo(Long roadmapId);
}
// Travel이 필요한 최소한의 정보만
public record RoadmapInfo(
Long id,
boolean active,
List topicIds
) {}
실제 구현체(Adapter)가
@Component
public class RoadmapLocalAdapter implements RoadmapPort {
private final RoadmapQueryService roadmapQueryService;
@Override
public RoadmapInfo getRoadmapInfo(Long roadmapId) {
RoadmapViewResponse view = roadmapQueryService.getRoadmapView(roadmapId);
return new RoadmapInfo(view.id(), view.isActive(), view.topicIds());
}
}
해당 변환로직을 작성했더라면..
현재는 adapter가 service쪽에 정의된 interface 규격에 철저히 맞춰서(의존해서) 구현했기때문에 즉 adapter 변환 과정을 거치지 않기 때문에 의존이 되었습니다.
나중에 MSA로 전환할 때도 쉬워진다 #
나중에 진짜로 MSA로 찢을 때도
- 포트 인터페이스는 그대로 두고
- Adapter만 HTTP/gRPC 호출로 교체하면 되니
- 이사 비용이 훨씬 줄어들었을 것입니다
@Component
public class RoadmapHttpAdapter implements RoadmapPort {
private final WebClient webClient;
@Override
public RoadmapInfo getRoadmapInfo(Long roadmapId) {
// HTTP 호출로 변경
return webClient.get()
.uri("/api/roadmaps/{id}", roadmapId)
.retrieve()
.bodyToMono(RoadmapApiResponse.class)
.map(this::toRoadmapInfo)
.block();
}
}
이번 프로젝트에서의 한계 #
이번에는 중간에 이 생각을 뒤늦게 했고,
전체 구조를 바꾸기에는 이미 늦은 타이밍이어서 일부만 적용하고 말았습니다.
그래도 얻은 것 #
이번 경험 덕분에
“MSA라서 Port/Adapter를 쓰는 게 아니라,
지금 모놀리식이어도 외부 도메인은 Port 관점으로 보자”
라는 태도를 다음 프로젝트의 기본값으로 가져가게 되었습니다.
기술적 회고 마무리 – 형식으로서의 DDD가 아니라, 의도로서의 DDD #
이번 프로젝트는
JPA + DDD 스타일로 Aggregate를 설계해보고
도메인 이벤트로 결합도를 낮춰보고
Lite CQRS로 Command/Query를 나눠보는 실험이었습니다.
돌아보면, 완벽하게 구현한 것보다
“해보니 이렇게는 안 되겠구나”를 많이 배운 프로젝트에 가깝습니다.
특히 이런 점들을 몸으로 느꼈습니다.
Aggregate Root는 “경계의 지킴이”이지, 모든 행위 메서드를 다 끌어안는 거대한 객체가 아니다.
쓰기 모델과 읽기 모델은 책임이 다르다. CQRS는 패턴 이름보다 책임을 분리하겠다는 태도가 더 중요하다.
도메인 이벤트는 결합도를 낮추지만, 동기/비동기, 일관성 수준, 이벤트 모델링을 잘못 잡으면 운영 복잡도를 쉽게 키워버릴 수 있다.
언젠가 MSA로 가야지를 생각한다면, 지금 모놀리식이라도 Port/Adapter로 경계를 느슨하게 만들어 두는 것이 나중에 훨씬 큰 도움이 된다.
무엇보다도 느낀 건,
실제로 “도메인의 의도를 잘 드러내는 코드” 사이에는 간극이 크다는 점입니다.
이번 프로젝트는 그 간극을 처음으로 본격적으로 체감해 본 경험이었습니다.
다음 프로젝트에서는
Aggregate 크기를 더 과감하게 쪼개 보기도 하고 진짜 CQRS(Read Model 분리 + 이벤트 동기화)를 한번 적용해 보기도 하고 도메인 서비스/팩토리/컬렉션 객체를 초반부터 의도적으로 분리해 보면서
이론으로만 알던 DDD를 점점 제 코드 스타일에 맞게 현실적으로 녹여보고 싶습니다.
팀과의 커뮤니케이션에 대한 개인적인 반성 #
이번 프로젝트를 하면서 기술적인 부분 외에 제 자신을 많이 돌아보게 된 지점이 있습니다.
바로 커뮤니케이션 방식에 대한 부분입니다.
돌이켜보면, 제가 사용하는 말투가 듣는 이에 따라 다르게 받아들여졌을 수 있겠다는 생각이 들었습니다.
저는 “효율적으로 이야기해야지”라는 생각으로 단호하게 말한 것이었는데, 어떤 분께는 그게 날카롭게 느껴졌을 수도 있었겠다는 후회가 남습니다.
특히 MVP 단계에서 기능을 추가할지 말지를 논의할 때,
제가 “그건 하지 말자”라고 비교적 단정적으로 잘라버렸던 순간들이 있었습니다.
지금 생각해 보면, 그것이 팀원들과 충분히 이야기하지 않고 제가 먼저 결론을 내려 버린 독단적인 결정은 아니었을까 스스로를 돌아보게 됩니다.
또 한 가지는 설계 수준과 방향에 대한 부분입니다.
저는 이번 프로젝트에서 DDD, 레이어링, 도메인 이벤트 등 비교적 난이도 있는 설계를 도입하려고 했습니다.
물론 팀원들에게 강제로 강요하지는 않았지만,
“팀의 현재 수준과 러닝 커브를 충분히 고려했는가?”라는 질문에는 자신 있게 답하기 어렵습니다.
어쩌면 제가 조금 앞서 나가고 있었던 건 아닐까,
차라리 더 단순한 구조로 맞추고 함께 이해해 나가는 쪽이 팀 전체에 더 좋지 않았을까 하는 아쉬움도 남습니다.
커뮤니케이션의 양도 아쉽습니다.
지금 돌아보면, 각자가 맡은 이슈를 각자 해결하려고만 했던 순간이 많았습니다.
트러블슈팅이나 설계 고민을 더 자주 테이블 위에 올리고,
“이건 나 혼자 고민하는 게 아니라 우리 팀의 문제다”라는 관점으로 같이 풀어봤다면
분명히 더 좋은 경험이 되었을 것 같다는 생각이 듭니다.
앞으로 남은 프로젝트에서는 말투, 속도, 상대의 상황에 더 귀 기울이면서,
서로 충분히 소통하고 함께 결정해 나가는 팀원이 되고 싶습니다.
다행히도, 각자의 역할을 끝까지 책임감 있게 수행해 주신 덕분에
프로젝트를 무사히 마무리할 수 있었다고 생각합니다.
처음에는 이 프로젝트로 내가 무엇을 기술적으로 가져갈 수 있을까?에만 초점을 맞췄다면,
이제는 한 걸음 더 나아가 이 프로젝트를 통해 어떤 개발자로 성장할 것인가?라는 질문도 함께 가져가고 싶습니다.
앞으로는 기술적인 성취뿐만 아니라,
커뮤니케이션, 공감, 협업 같은 소프트 스킬도 의식적으로 훈련하면서
팀과 함께 성장하는 개발자가 되기 위해 더 노력하겠습니다.