콘텐츠로 이동

ID 참조 vs 객체 참조

정의

객체가 연관 객체를 메모리 주소(객체 참조) 로 직접 쥘 것인가, 식별자 필드(예: Long shopId) 로만 연결할 것인가의 선택입니다. 핵심 규칙은 한 줄입니다.

애그리거트 내부는 객체 참조로 빠르게 탐색하되, 애그리거트 경계를 가로지르는 협력은 오직 식별자(ID) 참조로만 연결합니다.

경계를 어디에 긋는지는 concept-aggregate-boundary, 끊어진 협력을 잇는 방법은 concept-domain-event-eventual-consistency에서 다룹니다.

객체 참조가 경계를 넘으면 무너지는 것

증상 메커니즘 결과
트랜잭션 경계가 번짐 주문 완료 처리 중 무심코 가게 객체까지 같은 트랜잭션에서 수정 가게 행 잠금 장기화 → 같은 가게 주문 사용자 대기
조회가 무거워짐 연관이 얽힌 상태의 즉시 로딩 거대 조인 쿼리
조회가 무거워짐 (2) 지연 로딩으로 회피 시 N+1 쿼리

객체 참조는 초기 설계에서 직관적이고 빠르지만, 규모가 커지면 쓰기 단위가 도메인 경계를 넘어 번지는 것 자체가 동시성 병목의 원인이 됩니다.

Before / After

객체 참조 (경계를 넘는 직접 연결):

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private Shop shop;                 // 다른 애그리거트를 주소로 직접 참조

    public void place() {
        if (!shop.isOpen()) {          // 편리하지만 —
            throw new IllegalStateException("영업 중이 아닙니다");
        }
        // 주문 트랜잭션 안에서 shop 변경까지 가능해져 버림
    }
}

ID 참조 (경계는 식별자로만):

@Entity
public class Order {
    private Long shopId;               // 식별자만 보관 — 주소에 닿을 수 없음

    public void place() {
        // shop 객체가 없으므로 가게 검증은 여기서 불가능 → 아래 도메인 서비스로
        this.status = OrderStatus.PLACED;
    }
}

이렇게 하면 주문의 쓰기 단위가 자기 테이블로 압축되어, 다른 도메인 테이블의 쓰기 잠금을 건드리는 일이 설계 수준에서 사라집니다.

트레이드오프 — 빈약한 도메인 모델 함정

식별자로 바꾸는 순간, 객체 그래프를 타고 돌던 검증 로직이 컴파일 에러를 냅니다. 이 공백을 서비스 레이어에 리포지토리를 여러 개 주입해 절차지향으로 메우면 도메인이 빈껍데기(Anemic Domain Model)가 됩니다. 정석은 별도 도메인 서비스로 중재하는 것입니다.

@Component
public class OrderValidator {          // 도메인 서비스 — 여러 애그리거트에 걸친 검증만 담당
    private final ShopRepository shopRepository;

    public void validate(Order order) {
        Shop shop = shopRepository.findById(order.getShopId())
                .orElseThrow(() -> new NoSuchElementException("가게 없음"));
        if (!shop.isOpen()) {
            throw new IllegalStateException("영업 중이 아닙니다");
        }
    }
}

검증 규칙 자체는 도메인 층에 남고, 애플리케이션 서비스는 orderValidator.validate(order) 한 줄만 호출합니다.

판단 기준 요약

관계 연결 방식 이유
애그리거트 내부 (Order ↔ OrderLineItem) 객체 참조 같은 라이프사이클·같은 트랜잭션 — 강결합이 정당
애그리거트 경계 밖 (Order → Shop, Menu) ID 참조 트랜잭션·잠금·조회를 도메인 단위로 격리

같은 인사이트 패턴 — "편한 기본값은 규모에서 함정이 된다"

페이지 편한 기본값 규모에서의 함정 실무 권장
이 페이지 JPA @ManyToOne 객체 참조 트랜잭션 번짐·N+1·잠금 대기 경계 밖은 ID 참조
concept-transactional-rollback-policy @Transactional 기본 롤백 정책 체크 예외가 commit됨 rollbackFor = Exception.class
concept-db-connection-pool 무한 수명 커넥션 DB wait_timeout과 충돌 maxLifetime < wait_timeout
concept-varchar-length-prefix 관습적 VARCHAR(255) utf8mb4에서 인덱스 한계 초과 VARCHAR(63) 또는 도메인 길이
concept-cronjob-concurrency-trap concurrencyPolicy 기본 Allow 중복 실행 Forbid + activeDeadlineSeconds

→ 공통 원리: 직관적이라서 선택되는 기본 연결·기본 설정은 소규모에서만 무해합니다. 규모의 어느 지점에서 무너지는지 알고, 무너지기 전에 경계를 다시 긋습니다.

빠른 진단

  • "주문 저장했는데 가게 테이블 잠금이 잡혀 있다" → 트랜잭션이 경계를 넘고 있습니다.
  • "목록 조회 한 번에 쿼리가 수십 개" → 경계 넘는 연관의 지연 로딩 (N+1).
  • "연관 엔티티 하나 바꿨더니 컴파일 에러가 도메인 전체로 번진다" → 객체 참조 결합도가 경계를 넘은 상태.
  • "서비스 레이어에 리포지토리가 5개씩 주입된다" → 빈약한 도메인 — 도메인 서비스 도입 시점.

원본 출처

  • raw: raw/object-dependency/id-reference-vs-object-reference.md (블로그 글 유래 영속 개념 노트)
  • 원 영상: 조영호, "우아한객체지향: 의존성을 이용해 설계 진화시키기" (우아한테크세미나, 2019)

관련 페이지