DDD에서 JPA사용의 딜레마

Frog Lemon·2026년 1월 4일

프로젝트

목록 보기
2/2
post-thumbnail

오랜만에 쓰는 글이다. 우아한테크코스를 수료하고 계속 취업준비를 하며 최근 프론트엔드인 동네 친구와 함께 요리사들 위한 블로그형 커뮤니티인 '헬시플레이트'를 개발하고있다. 내가 이전부터 만들어보고 싶었던 프로젝트라 그런지 하루하루 재미있게 개발중에 있다. 프론트 개발자 1명, 백엔드 개발자 1명으로 이루어졌다보니 각자가 프로젝트 리더고 시니어의 책임감을 가지고 임하고 있다.

이번 프로젝트에서는 아키텍처를 직접 설계하며 다양한 고민들을 마주했는데 그 중하나가 DDD와 JPA에 대한 충돌점이다.

DDD를 왜 선택하셨나요?

헬시플레이트에는 인증, 유저, 식재료, 레시피등 다양한 논리적 도메인들이 존재한다. 또한 장기적인 프로젝트로서 추가적인 도메인이 늘어날 가능성이 높다라는 전제를 가지고 시작했다. 이러한 경우 일반적인 레이어드 아키텍처를 사용한다면 확장성과 유지보수성이 낮다고 판단했고 이를 잘 분리하고 관리를 하기위해 DDD의 아키텍처를 따라가기로했다.

단점

내가 느꼈던 DDD의 단점은 숙련도가 낮을 수록 개발속도가 느려지는것과 코드의 복잡도가 증가하는 것이다.

장점

물론 단점도 존재했지만 그보다 장점도 크게 느껴졌다.

우선, 코드를 작성할 때 한 번 더 고민하고 생각하며 나의 철학에 맞는 코드를 작성하고 있는지 한 번 더 확인할 수 있었다. "올바른 모듈에 위치하는지", "도메인 로직이 응용 계층에 섞여 있지는 않은지", "이 객체가 진짜 필요한 책임인지" 등을 끊임없이 질문하게 된다.

우아한테크코스를 수료한 지금, 요즘 들어 시간에 대한 조바심이 생기기 시작했고, 내가 1년동안 배운 우아한테크코스의 철학이 흔들릴 때도 생기는 것 같다. 빠르게 기능을 만들어내는 것도 중요하지만, 제대로 된 설계와 구조를 고민하는 시간이 결국 기술 부채를 줄이고 추후 기능 확장을 위한 리팩토링 영역을 줄이는 등 장기적으로는 더 빠른 개발을 가능하게 한다는 걸 깨달았다. DDD는 그런 고민을 강제하는 아키텍처였다.


DDD와 JPA의 충돌점

하지만 DDD를 실제로 적용하다 보니 JPA와의 충돌점들을 마주하게 되었다. 이론적으로 완벽해 보이는 두 기술이지만, 함께 사용하려니 여러 난관이 있었다.

1.Value Object의 불변성 vs JPA의 기본 생성자 요구사항

DDD의 Value Object는 불변 객체여야 한다. 하지만 JPA는 엔티티와 임베디드 객체에 기본 생성자를 요구한다.
Ingredient(식재료)는 Aggreate Root이고 IngredientName(식재료명)은 그안에 필드로 존재한다.

  @Embeddable
  @Getter
  @EqualsAndHashCode
  @NoArgsConstructor(access = AccessLevel.PROTECTED)  // JPA를 위한 타협
  public class IngredientName {

      @Column(name = "name", length = 100, nullable = false)
      private String value;

      private IngredientName(final String value) {
          this.value = value;
      }

      public static IngredientName of(final String value) {
          validateName(value);
          return new IngredientName(value);
      }

      private static void validateName(final String value) {
          if (value == null || value.isBlank()) {
              throw new IllegalArgumentException("식재료명은 공백일 수 없습니다.");
          }
          if (value.length() > 100) {
              throw new IllegalArgumentException("식재료명은 100자 이하여야 합니다.");
          }
      }
  }

해결 방법으로는

  • @NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용해서 JPA의 요구사항을 만족하면서도 외부에서의 무분별한 생성은 막는다.
  • 정적 팩토리 메서드를 통해서만 객체를 생성하도록 강제하여 유효성 검증을 보장한다.

2. Aggregate Root와 연관관계 관리

DDD에서는 Aggregate 간의 참조는 ID로 해야한다고 권장하지만, JPA의 연관관계 매핑은 객체 참조를 사용한다.
이 부분이 가장 애매하다고 느껴졌다. DDD관점에서 다른 Aggregate와의 관계를 간접참조로 구성한다면 JPA의 객체참조로부터 오는 dirty checking, fetch=Lazy, 엔티티 그래프 장점들을 포기해야하는것이 아닌가?

@Entity
@Table(name = "ingredient")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Ingredient extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ingredient_id")
    private Long id;

    //등록한 회원
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "registered_by")
    private User registeredBy;

위의 코드는 식재료를 등록한 사용자를 판별하기위해 User 객체를 @ManyToOne으로 연관관계를 맺었었다. 하지만 DDD관점에서 아래와 같은 문제점들이 있다.

  • Ingredient Aggregate가 User Aggregate를 객체로 소유
  • updateIngredient() 안에서 User 접근이 기술적으로 가능
  • Lazy라도 탐색 자체가 허용됨

DDD에서는 애그리거트간 독립성과 트랜잭션 일관성이 매우 중요하다.Ingredient가 저장될때 User또한 같이 변경된다면 사이드 이펙트가 발생할 것이다.


고민과 해결책 모색

Aggregate Root간 연관관계 문제를 해결하기 위해 두 가지 방향을 고민했다.

방향 1. 순수 DDD 방식 - ID 참조만 사용

@Embeddable
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RegisterId {

    @Column(name = "registeredId")
    private Long value;

    private RegisterId(final Long value) {
        this.value = value;
    }

    public static RegisterId of(final Long value) {
        return new RegisterId(value);
    }
}

public class Ingredient{
	
    @Embedded
	private RegisterId registeredBy;  // Long userId를 감싼 Value Object

}

전형적인 DDD의 ID 간접 참조 방식이다. 사용시 따라오는 장점과 고민되는점은 아래와 같았다.

장점

  • Aggregate 간 의존성 제거
  • 도메인 모델의 순수성 유지
  • 의도하지 않은 User 변경 원천 차단

고민되는 점

  • User 정보가 필요한 조회 시 JOIN 쿼리를 직접 작성해야 함
  • "내가 등록한 식재료 목록 + 등록자 이름" 같은 조회는 도메인 모델만으로는 표현하기 어려움
  • 조회 책임이 개발자에게 넘어오기 때문에 비효율적인 조회 로직을 만들 가능성이 있다.

방향 2. 실용적 JPA 방식 - 객체 참조 사용

@ManyToOne(fetch = FetchType.LAZY)
private User registeredBy;

장점

  • 객체 그래프 탐색 가능
  • 간단한 조회 코드 작성 가능
  • JPA의 편의성 극대화

문제점

  • Aggregate 간 직접 참조로 인해 독립성 훼손
  • 도메인 로직 내에서 User 엔티티 접근 가능
  • 의도하지 않은 상태 변경 가능성
  • DDD의 Aggregate 경계 원칙과 충돌

어느 한쪽을 선택하면 다른 한쪽의 장점을 포기해야하는 딜레마에 빠지게 된다.


CQRS로 해결하기

CQRS란?

CQRS(Command Query Responsibility Segregation)는 쓰기(Command)와 읽기(Query)의 책임을 분리하는 아키텍처 패턴이다.

  • Command (쓰기)
    • 데이터를 변경
    • 비즈니스 규칙과 도메인 무결성에 집중
  • Query (읽기)
    • 데이터를 조회
    • 성능과 조회 편의성에 집중

쓰기 모델은 DDD 원칙을 엄격히 준수하고,
읽기 모델은 JOIN, DTO 프로젝션 등 조회 최적화를 적극 활용한다.

즉,

  • Command 모델에서는 Aggregate 독립성과 도메인 규칙을 지키는 것이 가장 중요하고
  • Query 모델에서는 객체 그래프보다는 SQL 관점에서 “어떻게 빠르게 조회할 것인가”에 집중한다.

조회가 필요한 경우: 식재료 정보 + 등록한 사용자 정보

예를 들어
“내가 등록한 식재료 목록과 함께 등록한 사용자 정보(닉네임 등)를 조회”해야 한다면
Command 모델의 Ingredient 엔티티를 그대로 사용하는 것은 적합하지 않다.

이 경우 Query 전용 DTO와 JOIN 쿼리를 활용한다.


Query 모델 – 읽기 전용 DTO

package com.healthy_plate.ingredient.application.dto;

/**
 * 읽기 전용 DTO - 식재료 정보 + 등록자 정보
 * CQRS Query 모델
 */
public record IngredientWithUserDto(
    Long ingredientId,
    String ingredientName,
    String ingredientNameEn,
    Integer calorie,
    Double servingSize,
    String unit,
    String registrationType,
    Boolean isVerified,
    Long registeredUserId,
    String registeredUserNickname
) {

    public static IngredientWithUserDto of(
        final Long ingredientId,
        final String ingredientName,
        final String ingredientNameEn,
        final Integer calorie,
        final Double servingSize,
        final String unit,
        final String registrationType,
        final Boolean isVerified,
        final Long registeredUserId,
        final String registeredUserNickname
    ) {
        return new IngredientWithUserDto(
            ingredientId,
            ingredientName,
            ingredientNameEn,
            calorie,
            servingSize,
            unit,
            registrationType,
            isVerified,
            registeredUserId,
            registeredUserNickname
        );
    }
}

이 DTO는 조회 전용(Query Model) 이며,

  • Entity가 아니다
  • 변경 로직이 없다
  • 화면/API에 필요한 데이터만 포함한다

Query Repository – JOIN 기반 조회

@Override
@Query("""
    SELECT new com.healthy_plate.ingredient.application.dto.IngredientWithUserDto(
        i.id,
        i.name.value,
        i.nameEn,
        i.calorie.value,
        i.servingSize.value,
        CAST(i.unit AS string),
        CAST(i.registrationType AS string),
        i.isVerified,
        u.id,
        u.profile.nickname
    )
    FROM Ingredient i
    JOIN User u ON i.registerId.value = u.id
    WHERE u.id = :userId
""")
List<IngredientWithUserDto> findByUserIdWithUserInfo(
    @Param("userId") Long userId
);

특징

  • User 엔티티를 Command 모델에서 직접 참조하지 않음
  • 조회 시에만 명시적으로 JOIN 수행
  • DTO 프로젝션으로 필요한 데이터만 조회
  • N+1 문제 없이 쿼리 1번으로 조회 완료

정리

  • 쓰기(Command)

    • Ingredient는 User를 객체로 참조하지 않고 ID(Value Object)만 보관
    • Aggregate 독립성 및 도메인 무결성 유지
  • 읽기(Query)

    • JOIN + DTO를 활용해 조회 성능 최적화
    • 화면/API 요구사항에 맞춘 유연한 조회 가능

CQRS를 통해
DDD의 원칙과 JPA의 실용성 사이의 충돌을 자연스럽게 해소할 수 있었다.

profile
도전하며 굴러가는 돌멩이, 인생 마라톤 중 😎

3개의 댓글

comment-user-thumbnail
2026년 1월 30일

안녕하세요 레몬~ 잘 지내시나요? 아마입니다. 저도 요즘 DDD에 대한 고민을 하고 있는데, 마침 좋은 글을 읽었네요. 혹시 userId를 VO로 감싼 이유가 어떻게 되나요? 저는 그냥 Long을 사용했어서요.

1개의 답글