본문으로 건너뛰기
홈
기술
기술 전체
프로그래밍68
컴퓨터 과학63
AI48
웹 개발36
인프라33
데이터31
소프트웨어 공학18
소개
← 목록으로소프트웨어 공학 › 설계 › 객체지향

객체지향 연관 관계

목차

객체지향 연관 관계

주문에서 고객 이름을 표시하려면 주문은 어떤 고객이 주문했는지 알아야 한다. 가장 직접적인 방법은 Order가 Customer 참조를 갖는 것이다. 그러나 고객이 주문 목록까지 가져야 하는지, 주문을 삭제할 때 고객도 삭제해야 하는지, 주문 항목이 비어 있어도 되는지는 별개의 질문이다. 탐색 방향, 다중성, 생명주기를 나누어 생각하면 연관 관계를 단순히 필드 하나 추가하는 문제로 다루지 않을 수 있다.

다이어그램의 화살표와 숫자 읽기

classDiagram
  Customer "1" <-- "0..*" Order : 주문자
  Order "1" --> "1..*" OrderLine : 주문 항목

이 모델에서 한 주문은 한 고객을 참조하고, 한 고객에는 0개 이상의 주문이 연결될 수 있다. 주문은 하나 이상의 주문 항목을 가진다. 화살표는 객체 모델에서 탐색할 방향을 표현한다. Order가 고객을 알 수 있다는 사실만으로 Customer에도 List<Order>가 필요해지는 것은 아니다. Mermaid 클래스 다이어그램의 관계와 다중성

다중성은 구현에 옮길 업무 규칙이다. Customer 쪽의 1을 필수라는 뜻으로 정했다면 생성할 때 null을 거부할 수 있다. OrderLine의 1..*를 만족하려면 주문 확정 시 적어도 하나의 항목이 있어야 한다. 임시 장바구니는 비어 있어도 되지만 확정 주문은 비어 있을 수 없다면 두 상태를 같은 클래스에 담을지부터 결정해야 한다.

참조와 컬렉션을 안전하게 보관하기

아래는 Java 17 이상에서 실행할 수 있는 작은 모델이다. 주문 항목은 값으로 다루고 주문 생성 시 목록을 복사한다.

import java.util.List;
import java.util.Objects;

record Customer(long id, String name) {
    Customer {
        if (id <= 0) throw new IllegalArgumentException("고객 ID가 필요합니다.");
        if (name == null || name.isBlank()) throw new IllegalArgumentException("고객 이름이 필요합니다.");
    }
}

record OrderLine(String productId, int quantity) {
    OrderLine {
        if (productId == null || productId.isBlank()) throw new IllegalArgumentException("상품 ID가 필요합니다.");
        if (quantity <= 0) throw new IllegalArgumentException("수량은 양수여야 합니다.");
    }
}

final class Order {
    private final Customer customer;
    private final List<OrderLine> lines;

    Order(Customer customer, List<OrderLine> lines) {
        this.customer = Objects.requireNonNull(customer);
        this.lines = List.copyOf(lines);
        if (this.lines.isEmpty()) throw new IllegalArgumentException("주문 항목이 필요합니다.");
    }

    Customer customer() { return customer; }
    List<OrderLine> lines() { return lines; }
}

public class AssociationExample {
    public static void main(String[] args) {
        var customer = new Customer(7, "철수");
        var order = new Order(customer, List.of(new OrderLine("keyboard", 2)));
        System.out.println(order.customer().name()); // 철수
        System.out.println(order.lines().get(0).quantity()); // 2
    }
}

List.copyOf는 호출자가 전달한 목록을 나중에 수정해 주문 상태를 바꾸는 통로를 차단한다. 반환 목록도 수정할 수 없다. 다만 목록 복사는 원소까지 모두 깊게 복제하는 작업은 아니다. 여기서는 원소가 불변인 record이므로 원소의 상태를 외부에서 바꾸는 문제도 피한다. 가변 엔티티를 목록에 넣는다면 컬렉션 보호와 엔티티 변경 권한을 따로 다뤄야 한다.

이 예제의 Customer는 설명을 위한 불변 값이다. 실제 서비스에서 이름이 바뀔 수 있는 고객 엔티티를 직접 참조할지, 고객 ID를 저장하고 조회할지, 주문 당시 이름을 스냅샷으로 남길지는 화면과 업무 규칙에 따라 정한다. 현재 고객 정보와 주문 당시 계약 정보가 같아야 하는 것은 아니다.

양방향 관계의 일관성 비용

학생과 수업을 양방향으로 연결한다고 생각해 보자. 학생의 수업 목록에는 새 수업을 넣었지만 수업의 학생 목록에는 학생을 넣지 않았다면 어느 방향에서 읽는지에 따라 결과가 달라진다.

student.courses = [courseA]
courseA.students = []

학생 기준: 수강 중
수업 기준: 등록된 학생 없음

문제를 해결하려면 관계 변경을 한 진입점으로 모으거나 한쪽을 기준 상태로 삼아 다른 방향은 조회 결과로 제공해야 한다. 양쪽에 public setter를 열어 두면 호출자가 항상 두 군데를 함께 맞춰야 한다. 관계를 양방향으로 만든 뒤 유지 비용을 추가하는 것보다 실제 탐색 요구가 있는지 먼저 확인하는 편이 낫다.

고객 화면의 주문 목록도 고객 객체가 모든 주문을 메모리에 보관해야만 구현할 수 있는 것은 아니다. findOrdersByCustomerId(customerId, page)와 같은 조회가 더 적합할 수 있다. 고객 한 명의 주문이 수만 건이면 전체 컬렉션을 객체 그래프에 넣는 것 자체가 부담이다.

소유 관계와 삭제 전파를 구분하기

주문 항목은 특정 주문 안에서만 의미를 갖도록 모델링할 수 있다. UML의 합성(composition) 표기는 이런 강한 전체·부분 관계를 표현할 때 쓰인다. 다음 그림의 채워진 마름모는 주문 쪽에 붙는다.

classDiagram
  Order "1" *-- "1..*" OrderLine : 소유
  Order --> Customer : 주문자 참조

그림을 그렸다고 데이터베이스의 cascade 삭제나 메모리 관리가 자동으로 설정되는 것은 아니다. 주문 삭제 시 항목 삭제가 필요한지, 법적·업무적 보관 때문에 삭제 대신 상태 변경을 해야 하는지, 공유 객체를 잘못 지우지 않는지 저장소에서 별도로 구현한다. 고객은 여러 주문이 참조하므로 주문 하나를 지웠다고 함께 지워서는 안 된다.

객체 연관과 데이터베이스 관계의 경계

데이터베이스 외래 키는 행 사이의 참조 무결성을 표현한다. 객체의 필드는 코드가 어느 방향으로 값을 탐색하는지 표현한다. 테이블에 customer_id가 있다고 해서 고객 엔티티에도 모든 주문 필드가 필요하지는 않다. ORM의 연관 관계 주인이라는 개념도 업무상 객체 소유권과 동일한 말이 아니다.

직접 객체 참조를 늘리면 사용은 편해질 수 있지만 지연 로딩, 예상하지 못한 추가 조회, 순환 JSON 직렬화 같은 비용이 생긴다. 응답에 필요한 값만 DTO로 조립하거나 다른 애그리거트는 ID로 참조하는 선택을 비교한다. 연관 관계 검토에서는 “연결할 수 있는가”에 더해 “어떤 동작이 이 연결을 실제로 필요로 하는가”를 코드의 사용 지점에서 확인한다.

같은 카테고리의 글