본문 바로가기
개발 일지 · 팁

JPA 연관관계를 안 쓰면 조회는 어떻게 하나 — FK 참조, QueryDSL on 조인, 애플리케이션 조립

코딩패트릭 2026. 8. 2.

지난 글(JPA 연관관계, 쓸까 말까 — @ManyToMany도 써도 될까?)에서 우리 팀이 객체 매핑을 거의 쓰지 않는 쪽으로 흘러왔다는 이야기를 했다. 그 글을 쓰고 나서 스스로 찜찜했던 부분이 하나 있었다. "안 쓴다"까지는 말했는데, "그럼 조회는 어떻게 하느냐"에 대한 답을 안 했다는 것이다.

연관관계를 걷어내면 문제가 하나 생긴다. order.getCustomer().getName() 한 줄이면 끝나던 게 안 된다. 주문에서 고객 이름을 가져오려면 고객을 따로 조회해서 붙여야 한다. 이게 매번 불편하다면 그 컨벤션은 오래 못 간다. 실제로는 안 불편했으니 몇 년째 유지되고 있는 건데, 그 "안 불편한 방법"이 뭔지를 정리해 두려고 한다.

이번에는 감이 아니라 코드를 직접 세어 봤다. 우리 팀이 운영하는 서비스 두 개를 열어서 연관관계 어노테이션이 몇 개 있는지, 어디에 있는지, 조회 코드는 어떤 모양인지를 전부 훑었다. 결과가 생각보다 일관돼서, 그 규칙을 세 가지로 정리했다. 사내 이름이 드러나지 않도록 엔티티·컬럼·패키지 이름은 전부 일반적인 이름으로 바꿔서 옮긴다. 구조와 어노테이션은 실제 코드 그대로다.

두 코드베이스를 세어 보니 — 연관관계는 어디에 살아남아 있었나

먼저 대상부터. 성격이 꽤 다른 서비스 두 개를 골랐다.

하나는 오래된 업무 시스템이다. 수년째 굴러가는 모놀리식 API 서버고, 엔티티 클래스가 500개가 넘는다. 사람이 여럿 거쳐 갔고, 초기 코드와 최근 코드의 스타일 차이가 눈에 보인다. 다른 하나는 비교적 최근에 만든 인증·계정 서비스다. 멀티 모듈 구조에 엔티티는 29개, 대부분 최근 1~2년 안에 쓰인 코드다.

연관관계 어노테이션을 세어 보면 이렇게 나온다.

어노테이션 레거시 업무 시스템 신규 인증 서비스
@Entity 500여 개 29개
@ManyToOne 약 120곳 0
@OneToMany 약 66곳 0
@OneToOne 15곳 0
@ManyToMany 2곳 0
@JoinColumn 150여 곳 0

신규 서비스는 아예 0이다. 29개 엔티티 전부가 다른 엔티티를 객체로 물고 있지 않고, Long 타입 FK 필드만 들고 있다. 지난 글에서 말한 "연관관계 최소화"가 가장 순수한 형태로 적용된 쪽이다.

재미있는 건 레거시 쪽이다. 500개 중 120곳에 @ManyToOne이 있으니 결코 적지 않다. "우리 팀은 연관관계를 안 쓴다"는 말이 반쯤은 사실이 아닌 셈이다. 그래서 이 120곳이 어디에 몰려 있는지를 패키지별로 다시 세어 봤는데, 여기서 규칙이 드러났다.

@ManyToOne이 붙은 파일들은 대부분 초창기에 만들어진 평평한 model 패키지에 하나씩 흩어져 있었다. 도메인 구분 없이 엔티티 클래스를 한 폴더에 다 넣던 시절의 코드다. 반면 나중에 도메인별로 쪼개서 만든 패키지들에서는 연관관계가 확 줄고, 남아 있는 것들도 성격이 뚜렷하게 달랐다. 그리고 그 글에서 "웬만하면 쓰지 말자"고 했던 @ManyToMany는 딱 2곳 남아 있었는데, 둘 다 가장 오래된 패키지의 같은 엔티티 하나에 붙어 있었다.

// 코드베이스에 남아 있는 @ManyToMany 두 곳 — 둘 다 같은 엔티티, 가장 오래된 패키지
@ManyToMany(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
@JoinTable(name = "material_ingredient",
    joinColumns = @JoinColumn(name = "materialId"),
    inverseJoinColumns = @JoinColumn(name = "ingredientId"))
private List<Ingredient> ingredients = new ArrayList<>();

즉 이 팀의 컨벤션은 "누가 정해서 위에서 내려온 규칙"이 아니라, 시간이 지나면서 코드가 스스로 수렴한 결과에 가깝다. 오래된 코드일수록 매핑이 많고, 새로 쓴 코드일수록 없다. 그리고 새 코드에 남아 있는 소수의 연관관계에는 공통점이 있다.

그 공통점을 한 줄로 말하면 이렇다. 부모가 사라지면 같이 사라져야 하는 자식만 객체로 물고 있고, 나머지는 전부 ID다.

이게 무슨 뜻인지 보려면 실제 엔티티를 하나 봐야 한다. 아래는 신규 코드 쪽에서 가장 흔한 형태다. 주문(Order)이 주문 유형(OrderType)을 참조하지만, 객체가 아니라 ID로 들고 있다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = {"orderId"}, callSuper = false)
@Where(clause = "status != 'DELETE'")
public class Order extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long orderId;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private OrderCategory category;

    @Column
    private String code;

    @Column(nullable = false)
    private Long orderTypeId;   // OrderType 엔티티를 참조하지만 매핑하지 않는다

    @Column(nullable = false)
    private Integer itemCount;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private NormalDeleteStatus status;

    public Order(OrderCategory category, String code, OrderTypeDto orderType, Integer itemCount) {
        this.category = category;
        this.code = code;
        this.orderTypeId = orderType.getOrderTypeId();
        this.itemCount = itemCount;
        this.status = NormalDeleteStatus.NORMAL;
    }

    public Integer incrementAndGet() {
        this.itemCount++;
        return this.itemCount;
    }

    public void delete() {
        this.status = NormalDeleteStatus.DELETE;
    }
}

여기서 눈여겨볼 게 생성자다. 필드는 Long orderTypeId인데, 생성자가 받는 건 Long이 아니라 OrderTypeDto다. 받아서 그 안의 ID만 꺼내 저장한다. 아무 Long이나 넘길 수 있으면 주문 유형 ID 자리에 사용자 ID를 넣어도 컴파일이 통과하는데, 이렇게 하면 그 실수를 컴파일 시점에 막을 수 있다. 매핑을 걷어내면서 잃는 타입 안정성을 생성자에서 일부 되찾는 방식이다.

더 재미있는 건, 두 코드베이스가 서로 다른 시기에 다른 사람 손으로 쓰였는데도 같은 관용구가 나온다는 점이다. 신규 인증 서비스의 사용자 엔티티도 똑같이 생겼다.

public User(Company company, String loginId, String name, String email) {
    this.companyId = company.getCompanyId();   // Company를 받아서 ID만 저장한다
    this.loginId = loginId.replaceAll("-", "");
    this.name = CryptoUtil.encrypt(name);
    this.email = CryptoUtil.encrypt(email);
    this.status = UserStatus.RDY;
}

UserCompany 객체를 필드로 갖고 있지 않다. 그런데 생성자는 Company를 받는다. 받아서 ID만 꺼내고 버린다. 두 팀원이 약속한 적 없이 같은 모양을 만들었다는 건, 이게 이 컨벤션 아래에서 자연스럽게 도달하게 되는 형태라는 뜻이다.

규칙 ① 애그리거트 안에서는 매핑을 쓴다 — cascade와 orphanRemoval이 기준

그럼 새 코드에 남아 있는 연관관계는 뭘까. 도메인별로 정리된 패키지에서 @OneToMany가 쓰인 곳을 전부 뽑아 봤더니, 예외 없이 같은 옵션 조합이었다.

@OneToMany(
    mappedBy = "invoice",
    fetch = FetchType.LAZY,
    orphanRemoval = true,
    cascade = CascadeType.ALL
)
private final List<InvoiceGroup> groups = new ArrayList<>();

cascade = ALL, orphanRemoval = true, fetch = LAZY. 네 군데 도메인에서 각각 독립적으로 쓰였는데 옵션이 전부 같다. 이 조합이 의미하는 건 명확하다. 부모를 저장하면 자식도 저장되고, 부모를 지우면 자식도 지워지고, 컬렉션에서 빼면 DB에서도 사라진다. 다시 말해 자식은 부모 없이 혼자 존재할 이유가 없는 데이터다.

DDD 용어로는 애그리거트다. 거창하게 말하지 않아도, 판단 기준은 질문 하나로 줄어든다. 부모가 삭제될 때 이 자식도 같이 삭제되는 게 맞나? 맞으면 매핑하고, 아니면 ID만 든다.

청구서 도메인이 이 규칙을 3단계로 보여 준다. 청구서(Invoice) 하나가 여러 개의 청구 묶음(InvoiceGroup)을 갖고, 묶음 하나가 여러 개의 항목(InvoiceLine)을 갖는 구조다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = {"invoiceId"})
public class Invoice {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long invoiceId;

    @Column(nullable = false)
    private Long orderId;              // 애그리거트 밖 — ID만 든다

    @Column(nullable = false)
    private BigDecimal netAmount;

    @Column(nullable = false)
    private BigDecimal vatAmount;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private TaxType taxType;

    @OneToMany(
        mappedBy = "invoice",
        fetch = FetchType.LAZY,
        orphanRemoval = true,
        cascade = CascadeType.ALL
    )
    private final List<InvoiceGroup> groups = new ArrayList<>();   // 애그리거트 안 — 객체로 든다

    public static Invoice create(Long orderId, InvoiceCommand command) {
        requireNonNull(command);

        Invoice invoice = new Invoice();
        invoice.orderId = requireNonNull(orderId);
        invoice.taxType = requireNonNull(command.getTaxType());
        invoice.status = command.getStatus();

        command.getGroups()
            .forEach(groupCommand -> InvoiceGroup.create(invoice, groupCommand));

        invoice.calculate();
        return invoice;
    }

    private void calculate() {
        if (CollectionUtils.isEmpty(this.groups)) {
            this.netAmount = BigDecimal.ZERO;
            this.vatAmount = BigDecimal.ZERO;
        } else {
            this.netAmount = this.calculateTotalNetAmount();
            this.vatAmount = this.taxType.calculateVat(this.netAmount);
        }
    }

    private BigDecimal calculateTotalNetAmount() {
        return this.groups.stream()
            .flatMap(group -> group.getLines().stream())
            .map(InvoiceLine::getNetAmount)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

같은 클래스 안에서 두 스타일이 공존한다. orderIdLong이고 groups는 객체다. 이게 우연이 아니라 규칙이라는 걸 이 한 클래스가 보여 준다.

여기서 매핑을 쓰는 이유도 코드에 그대로 드러난다. calculate() 때문이다. 청구서의 공급가액과 부가세는 하위 항목들의 합으로 정해지는 값이다. 항목이 하나 추가되면 청구서 총액이 반드시 같이 바뀌어야 한다. 이 규칙이 깨진 상태로 저장되면 안 된다. 이런 걸 불변식이라고 부르는데, 불변식을 지키려면 계산 주체가 자식들을 전부 손에 쥐고 있어야 한다. ID만 들고 있으면 총액을 계산할 때마다 리포지토리를 불러야 하고, 그러면 계산 책임이 엔티티 밖으로 새어 나간다.

몇 가지 세부 컨벤션도 같이 굳어져 있다.

  • 컬렉션 필드는 final이다. 컬렉션 자체를 통째로 갈아 끼우는 걸 원천 차단한다. 추가·삭제는 반드시 메서드를 통해야 한다.
  • 기본 생성자는 protected다. JPA가 리플렉션으로 쓰기 위해 필요할 뿐, 애플리케이션 코드에서 빈 객체를 만드는 건 막는다.
  • 생성은 new가 아니라 정적 팩토리 메서드다. 생성 시점에 자식들까지 만들고 총액까지 계산한 뒤 완성된 상태로 반환한다.
  • 양방향 연결은 자식 쪽 팩토리가 책임진다. InvoiceGroup.create(invoice, command) 안에서 group.setInvoice(invoice)invoice.getGroups().add(group)를 둘 다 한다. 지난 글에서 말한 연관관계 편의 메서드의 변형이다.
  • @EqualsAndHashCode(of = {"invoiceId"})로 식별자만 비교한다. 컬렉션 필드가 equals에 끼어들면 지연 로딩이 터지거나 무한 재귀가 생기기 때문이다.

정리하면 이 팀에서 @OneToMany는 "연관관계를 맺는 도구"가 아니라 "애그리거트 경계를 선언하는 도구"로 쓰인다. 어떤 엔티티에 cascade = ALL, orphanRemoval = true가 붙어 있으면 그건 곧 "이 둘은 한 덩어리다"라는 선언이다. 코드를 읽는 사람 입장에서는 이게 꽤 편하다. 매핑이 있으면 한 덩어리, 없으면 남남. 판단할 게 없다.

반대로 말하면, 이 규칙을 지키지 못하는 연관관계는 전부 제거 대상이다. 오래된 패키지에 남아 있는 @ManyToOne 120곳 중 상당수가 그런 것들이다. 단순히 "조회할 때 편하니까" 붙어 있는 참조들인데, 이런 건 다음 규칙으로 대체된다.

규칙 ② 경계를 넘으면 QueryDSL on 조인으로 붙인다

애그리거트 밖은 ID만 든다고 했다. 그럼 주문과 담당자와 부서를 한 번에 조회해야 할 때는 어떻게 하나. 매핑이 없으니 join(order.assignee) 같은 걸 쓸 수 없다.

답은 간단하다. QueryDSL의 on 절에 조인 조건을 직접 쓰면 된다. 연관관계 매핑은 조인의 필요조건이 아니다. 매핑이 있으면 조인 조건을 하이버네이트가 대신 써 주는 것뿐이고, 없으면 내가 쓰면 된다.

@RequiredArgsConstructor
public class AssignmentRepositoryCustomImpl implements AssignmentRepositoryCustom {

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<OrderItemAssigneeDto> findAssigneeIdsByProductIds(Collection<Long> productIds,
                                                                  Set<DepartmentCode> departmentCodes) {
        QOrderItem orderItem = QOrderItem.orderItem;
        QOrderSheet orderSheet = QOrderSheet.orderSheet;
        QAssignment assignment = QAssignment.assignment;
        QFlow flow = QFlow.flow;
        QDepartment department = QDepartment.department;

        return this.jpaQueryFactory
            .select(
                Projections.constructor(
                    OrderItemAssigneeDto.class,
                    orderItem.productId,
                    assignment.userId
                )
            )
            .from(orderItem)
            .innerJoin(orderItem.orderSheet, orderSheet)                          // 애그리거트 안 — 매핑을 그대로 쓴다
            .innerJoin(assignment).on(assignment.orderId.eq(orderSheet.orderId))  // 경계 밖 — on 절로 직접 붙인다
            .innerJoin(flow).on(flow.flowId.eq(assignment.flowId))
            .innerJoin(department).on(department.departmentId.eq(flow.departmentId))
            .where(
                orderItem.productId.in(productIds),
                department.departmentCode.in(departmentCodes),
                assignment.userId.isNotNull()
            )
            .fetch();
    }
}

한 쿼리 안에 두 스타일이 섞여 있는 게 이 컨벤션의 실제 모습이다. orderItem.orderSheet는 애그리거트 내부라 매핑이 있으니 그대로 쓰고, 나머지 세 개는 on으로 붙인다. 조인 조건을 직접 쓰는 게 번거로울 것 같지만, 실제로는 쿼리를 읽을 때 "무엇과 무엇이 어떤 키로 붙는지"가 눈에 보여서 오히려 낫다는 쪽에 가깝다. 매핑 조인은 조건이 엔티티 정의에 숨어 있어서, 쿼리만 봐서는 어떤 컬럼으로 붙는지 알 수 없다.

신규 인증 서비스도 완전히 같은 모양이다. 여긴 매핑이 아예 0개라 전부 on 조인이다.

@Override
public UserRegistrationDto findRegistrationInfo(Long userId) {
    QUser user = QUser.user;
    QCompany company = QCompany.company;
    QCompanyName companyName = QCompanyName.companyName;

    return jpaQueryFactory
        .select(Projections.constructor(
            UserRegistrationDto.class,
            user.userId,
            company.companyId,
            user.name,
            user.email,
            user.loginId,
            companyName.name
        ))
        .from(user)
        .join(company).on(company.companyId.eq(user.companyId))
        .join(companyName).on(companyName.companyId.eq(company.companyId),
                              companyName.languageCode.eq(LanguageCode.KO))
        .where(user.userId.eq(userId))
        .fetchOne();
}

세 테이블을 조인하는데 엔티티에는 연관관계가 하나도 없다. 그리고 on 절에 조인 조건과 필터 조건을 함께 넣을 수 있어서, 다국어 이름 테이블에서 한국어 행만 붙이는 것도 자연스럽게 처리된다. 매핑으로 했다면 @Where를 걸거나 조인 후 필터링을 해야 했을 부분이다.

그리고 두 코드베이스에서 공통으로 관찰되는 더 중요한 규칙이 있다. 조회 쿼리는 엔티티를 반환하지 않는다.

레거시 코드베이스에서 Projections를 세어 보면 170곳이다. Projections.constructor가 117곳, Projections.fields가 53곳. 커스텀 리포지토리 구현체 대부분이 엔티티가 아니라 DTO를 반환한다.

DTO 프로젝션을 기본으로 두면 따라오는 이점이 여러 개다.

  • 필요한 컬럼만 SELECT한다. 엔티티를 조회하면 쓰지도 않을 20개 컬럼이 전부 딸려 온다.
  • 영속성 컨텍스트에 안 올라간다. 조회 전용 데이터가 1차 캐시를 채우지 않고, 트랜잭션이 끝날 때 더티 체킹 대상도 되지 않는다. 조회만 했는데 UPDATE가 나가는 사고가 구조적으로 불가능해진다.
  • 지연 로딩이 없다. DTO는 그냥 값 객체라, 컨트롤러에서 쓰든 다른 스레드로 넘기든 LazyInitializationException이 날 여지가 없다.
  • 반환 타입이 곧 명세가 된다. 이 쿼리가 무엇을 주는지 DTO 클래스만 보면 안다.

Projections.constructorfields보다 두 배 이상 많은 것도 의미가 있다. 생성자 방식은 파라미터 순서와 타입이 컴파일 시점에 검증되고, DTO를 불변으로 만들 수 있다. fields 방식은 세터나 필드 주입이라 순서가 틀려도 컴파일이 통과한다. 굳이 고르라면 생성자 쪽이라는 판단이 코드에 반영돼 있다.

여기서 자연스럽게 따라오는 게 계층 구조다. 신규 서비스는 리포지토리를 감싸는 얇은 영속성 서비스 계층을 두고, 도메인 서비스가 그것들을 조합한다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncryptor passwordEncryptor;
    private final UserRoleService userRoleService;

    public User saveBy(UserRegisterRequest request, Company company) {
        this.validateExistUser(request.loginId(), ErrorCode.USER_ALREADY_EXISTS);
        User user = request.from(company);
        user.setPassword(passwordEncryptor.encrypt(request.password()));
        this.userRepository.save(user);
        this.userRoleService.saveBy(user, request.roleIds());   // 다른 엔티티는 자기 서비스가 책임진다
        return user;
    }

    public User findElseThrowBy(Long userId) {
        User user = this.userRepository.findByUserId(userId);
        if (user == null) {
            throw new ErrorCodeException(ErrorCode.USER_NOT_FOUND);
        }
        return user;
    }
}

User를 저장한 다음 권한 연결을 UserRoleService에 위임한다. 매핑으로 cascade를 걸었다면 userRepository.save(user) 한 줄로 끝났을 일이다. 대신 지금은 "무엇이 언제 저장되는지"가 코드에 다 적혀 있다. 줄 수는 늘었지만 감춰진 게 없다. 이 트레이드오프를 어떻게 볼지는 마지막 절에서 다시 이야기한다.

규칙 ③ N+1은 fetch join이 아니라 batch_fetch_size와 애플리케이션 조립으로 막는다

연관관계 이야기를 하면 반드시 N+1이 따라 나온다. 그런데 코드를 훑다가 가장 의외였던 게 이 부분이었다.

레거시 코드베이스 전체에서 fetchJoin() 호출이 0건이다. QueryDSL을 그렇게 많이 쓰면서 fetch join을 한 번도 안 쓴다. N+1의 교과서적 해법이 fetch join인데, 실무 코드에서는 아예 안 쓰이고 있었다.

대신 세 가지가 그 자리를 채우고 있다.

첫째는 설정 한 줄이다. 운영 프로파일에 이게 들어 있다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

지연 로딩된 프록시를 하나씩 초기화하는 대신, 최대 100개씩 모아서 WHERE id IN (?, ?, ..., ?) 한 방으로 가져오게 하는 설정이다. 100건짜리 목록에서 각각의 연관 엔티티를 건드리면 원래 101번 나갈 쿼리가 2번으로 줄어든다. 애그리거트 내부 컬렉션에는 fetch = LAZY가 걸려 있으니, 이 설정 하나가 남아 있는 모든 매핑의 N+1을 한 번에 덮는다.

fetch join 대신 이걸 택한 이유는 명확하다. fetch join은 페이징과 같이 못 쓴다. 컬렉션을 fetch join하면 조인 결과로 행이 뻥튀기되기 때문에 하이버네이트가 DB 페이징을 포기하고 전부 읽어서 메모리에서 자른다. 데이터가 늘어나면 그대로 사고다. 컬렉션을 두 개 이상 fetch join하면 아예 예외가 난다. batch fetch는 이 두 문제가 없다. 부모 쿼리는 페이징 그대로 나가고, 자식은 별도 쿼리로 모아서 가져온다.

무엇보다 fetch join은 쿼리마다 챙겨야 하는 반면, batch fetch는 한 번 설정하면 전역으로 적용된다. 팀에서 컨벤션을 유지하려면 "매번 기억해야 하는 규칙"보다 "잊어도 동작하는 기본값"이 훨씬 강하다.

둘째는 @EntityGraph다. 전체 17곳에서 쓰인다. 쓰이는 자리를 보면 성격이 일정하다.

public interface CountryRepository extends JpaRepository<Country, Long> {

    @EntityGraph(attributePaths = {"documents", "certificationInfo"}, type = EntityGraphType.LOAD)
    List<Country> findWithDocumentsBy();
}
public interface ProductSampleRepository extends JpaRepository<ProductSample, Long> {

    @EntityGraph(attributePaths = {"brandCompany"}, type = EntityGraphType.LOAD)
    Optional<ProductSample> findByCompanyIdAndProductSampleIdAndStatusNot(Long companyId,
                                                                          Long productSampleId,
                                                                          ProductSampleStatus status);
}

공통점은 페이징이 없는 조회라는 것이다. 단건 조회이거나, 국가 목록처럼 개수가 고정된 코드성 데이터 전체 조회다. 페이징이 없으면 fetch join의 위험이 사라지니 그때는 쓴다. 그리고 QueryDSL의 fetchJoin() 대신 @EntityGraph를 쓰는 이유는 선언 위치 때문이다. 리포지토리 메서드 시그니처 위에 붙어 있으면 "이 메서드는 이것까지 같이 가져온다"가 호출하는 쪽에서 바로 보인다.

셋째가 가장 많이 쓰이는 방법인데, 애플리케이션에서 조립하는 것이다. 레거시 코드베이스에서 Collectors.toMap이 391곳, Collectors.groupingBy가 228곳 쓰인다. 이 숫자의 상당수가 이 패턴이다.

@Service
@RequiredArgsConstructor
public class AssignmentDependencyService {

    private final AssignmentDependencyRepository repository;

    public Map<Long, List<Long>> groupPrecedingIdsByFollowingId(Long orderId) {
        List<AssignmentDependency> dependencies = this.repository.findAllByOrderId(orderId);

        return dependencies.stream()
            .collect(Collectors.groupingBy(
                AssignmentDependency::getFollowingAssignmentId,
                Collectors.mapping(
                    AssignmentDependency::getPrecedingAssignmentId,
                    Collectors.toList()
                )
            ));
    }
}

패턴은 항상 세 단계다. 먼저 주 목록을 조회한다. 거기서 필요한 ID들을 뽑아 findAllByXxxIdIn(ids)으로 연관 데이터를 한 번에 가져온다. 마지막에 toMap이나 groupingBy로 인덱싱해서 메모리에서 붙인다.

쿼리 수는 항상 2번이다. 목록이 10건이든 1000건이든 2번이다. 그리고 이 방식에는 매핑으로는 못 하는 게 두 가지 있다.

하나는 애그리거트를 넘나드는 조립이다. 주문 목록에 담당자 이름과 부서명을 붙여야 하는데 이 셋이 서로 다른 애그리거트라면, 매핑으로 묶는 순간 경계가 무너진다. 조회 편의 때문에 도메인 구조를 훼손하는 셈이다. 조립 방식은 경계를 그대로 두고 화면에 필요한 모양만 만든다.

다른 하나는 DB가 다른 경우다. 신규 인증 서비스는 데이터소스를 두 개 쓴다. 계정 정보가 한쪽에, 예전부터 쓰던 사업자 정보가 다른 쪽에 있다. 물리적으로 다른 DB라 애초에 조인이 불가능하고, 연관관계 매핑은 시도조차 할 수 없다. 각각 조회해서 ID로 맞추는 것 외에 방법이 없다. 처음부터 조립하는 습관이 들어 있으면 이런 상황이 특별한 케이스가 아니라 평소와 같은 코드가 된다.

애초에 ID로만 참조하면 그 ID가 같은 DB에 있든 다른 DB에 있든, 심지어 다른 서비스의 API 뒤에 있든 호출부 코드가 거의 안 바뀐다. 매핑으로 묶어 두면 그 순간 두 테이블은 영원히 같은 DB에 있어야 한다.

여기에 한 가지 덧붙이면, ID 생성 전략도 이 컨벤션과 맞물려 있다. 신규 서비스는 PK를 DB 자동 증가에 맡기지 않고 애플리케이션에서 만들어 넣는다.

@PrePersist
public void prePersist(Object entity) {
    // @GenerateId가 붙은 Long 필드가 비어 있으면 애플리케이션에서 ID를 채워 넣는다
    for (Field field : entity.getClass().getDeclaredFields()) {
        if (field.isAnnotationPresent(GenerateId.class) && field.getType().equals(Long.class)) {
            field.setAccessible(true);
            if (field.get(entity) == null) {
                field.set(entity, IdGenerator.next());
            }
        }
    }
}

자동 증가 PK를 쓰면 부모를 INSERT해서 flush하기 전까지 ID를 모른다. 그래서 자식에 FK를 세팅하려면 저장 순서를 신경 써야 하고, 이 불편함이 "그냥 cascade로 묶자"는 유혹의 큰 부분을 차지한다. ID를 애플리케이션이 미리 만들면 그 제약이 사라진다. 부모 객체를 만든 시점에 이미 ID가 있으니, 자식 FK를 바로 채우고 둘을 따로 저장해도 된다. FK 참조 컨벤션이 불편하지 않은 데는 이런 뒷받침이 있다.

이 컨벤션으로 무엇을 잃고 무엇을 얻었나

여기까지가 규칙이고, 이제 대가를 이야기해야 공정하다. 지난 글에서도 썼지만 나는 이 컨벤션이 정답이라고 생각하지 않는다.

먼저 잃는 것.

코드가 길어진다. 이건 반박할 수 없다. invoice.getOrder().getCustomer().getName() 한 줄이면 될 걸 조회 두 번에 Map 조립까지 해야 한다. 서비스 클래스 하나가 리포지토리 대여섯 개를 주입받는 모습도 흔하다. 위에서 본 UserService가 리포지토리 하나에 다른 서비스 둘을 물고 있는 것처럼, 저장 한 번에 여러 컴포넌트가 관여한다.

타입 안정성이 약해진다. Long orderTypeId에 사용자 ID를 넣어도 컴파일러는 모른다. 생성자에서 DTO를 받는 관용구로 일부 막지만 완벽하지 않다. 리포지토리 메서드에 ID를 잘못 넘기는 실수는 여전히 런타임에 발견된다.

DB의 참조 무결성과 코드가 분리된다. 매핑이 없으니 하이버네이트가 FK 제약을 만들어 주지 않는다. 제약은 별도 마이그레이션 스크립트로 관리해야 하고, 그마저 안 걸어 두면 부모가 지워졌는데 자식이 남는 상황이 생길 수 있다. 실제로 오래된 테이블에서 이런 고아 데이터가 발견되는 일이 있다.

객체지향적이지 않다는 비판도 유효하다. 도메인 객체가 서로를 모르고 ID만 들고 있으면, 도메인 로직이 엔티티가 아니라 서비스로 올라가기 쉽다. 청구서처럼 애그리거트를 제대로 잡은 곳은 로직이 엔티티 안에 있지만, ID 참조만 있는 엔티티들은 사실상 데이터 홀더에 가깝다. 실제로 우리 코드에서 도메인 로직이 살아 있는 엔티티는 애그리거트 루트로 잡힌 몇 개뿐이고, 나머지는 게터와 몇 개의 상태 변경 메서드만 있는 클래스다.

조회 코드가 화면 단위로 늘어나는 문제도 있다. DTO 프로젝션을 기본으로 두면 화면 하나마다 전용 DTO와 전용 쿼리 메서드가 생긴다. 응답 필드가 하나 추가될 때마다 DTO, 프로젝션, 쿼리 세 군데를 같이 고쳐야 한다. 엔티티를 통째로 반환하고 필요한 걸 꺼내 쓰는 방식이었다면 안 생겼을 작업이다. 커스텀 리포지토리 구현체가 계속 두꺼워지는 것도 이 때문이다.

그리고 얻는 것.

쿼리가 예측 가능하다. 이게 가장 크다. 코드를 읽으면 몇 번의 쿼리가 나갈지 셀 수 있다. 지연 로딩이 언제 터질지, 어떤 프록시가 초기화될지 추적할 필요가 없다. 성능 문제가 생겼을 때 원인을 찾는 시간이 짧다.

모듈과 DB를 나눌 여지가 남는다. 앞에서 말한 대로 두 개의 DB를 쓰는 서비스가 실제로 있고, 그게 특별한 예외 처리 없이 굴러간다. 나중에 도메인을 떼어낼 때도 ID 참조는 그대로 API 호출로 바꾸면 되지만, 매핑은 전부 걷어내야 한다.

경계가 코드에 드러난다. 매핑이 있으면 한 덩어리, 없으면 남남이라는 규칙이 서면 도메인 구조를 문서 없이 코드에서 읽을 수 있다. 새로 합류한 사람에게 "이건 왜 매핑이 없어요?"가 아니라 "매핑이 있는 것만 한 덩어리예요"로 설명이 끝난다.

마지막으로 남은 부채도 솔직히 적어 둔다. 오래된 패키지의 @ManyToOne 몇 곳은 fetch 옵션이 없어서 기본값인 EAGER로 동작한다. 목록 조회 한 번에 조인이 딸려 붙는 자리들이고, 정리 대상이다. @ManyToMany 2곳도 마찬가지다. 컨벤션이 있다고 코드베이스 전체가 그 컨벤션을 따르고 있는 건 아니다. 새로 쓰는 코드에만 적용되고, 오래된 코드는 손댈 이유가 생길 때 같이 정리된다.

그래서 다른 팀에 이걸 그대로 권하겠느냐 하면, 아니다. 판단 기준을 굳이 세 줄로 줄이면 이렇게 말하겠다.

  • 서비스 하나에 DB 하나이고 도메인 경계가 안정적이라면, 연관관계를 적극적으로 쓰는 쪽이 코드가 짧고 명확하다.
  • 도메인이 커서 나중에 쪼갤 가능성이 있거나 이미 DB가 여러 개라면, 애그리거트 밖은 ID로 두는 편이 나중에 덜 아프다.
  • 어느 쪽을 고르든 팀 안에서 기준이 하나여야 한다. 같은 코드베이스에 두 스타일이 섞여 있으면서 기준이 없는 게 가장 나쁘다. 우리 코드의 오래된 절반이 정확히 그 상태였다.

정리

연관관계를 줄인 코드베이스에서 조회를 처리하는 방법을 세 가지로 정리하면 이렇다.

상황 방법
부모와 자식이 생명주기를 공유할 때 @OneToMany + cascade = ALL + orphanRemoval + LAZY
애그리거트 경계를 넘어 여러 테이블을 한 번에 조회할 때 QueryDSL on 조인 + Projections.constructor DTO
목록에 연관 데이터를 붙일 때 findAllByXxxIdIn 한 번 더 조회 후 toMap·groupingBy로 조립
N+1 방어 default_batch_fetch_size 전역 설정, 페이징 없는 조회에 한해 @EntityGraph

핵심은 매핑을 쓰느냐 마느냐가 아니라, 매핑에 의미를 부여했다는 점이라고 생각한다. 이 팀에서 @OneToMany는 조회 편의를 위한 도구가 아니라 애그리거트 경계 선언이다. 그 의미가 팀 안에서 공유되니까, 매핑이 있는 코드와 없는 코드가 한 클래스에 섞여 있어도 헷갈리지 않는다.

지난 글에서 "@ManyToMany는 쓰지 말자"까지만 이야기하고 끝냈는데, 사실 더 중요한 건 그 다음 질문이었다. 안 쓰면 뭘로 대체하나. 이 글이 그 답이 됐으면 한다.

참고