이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 1:1, 1:N, N:M 관계는 테이블에서 어떻게 표현하는가?
  • 관계 설계가 API 성능과 JPA 연관관계에 어떤 영향을 주는가?
  • 카디널리티를 잘못 보면 어떤 문제가 생기는가?

개요

엔티티 관계는 도메인 객체 사이의 연결을 DB 테이블에 표현하는 방식이다. 관계를 잘못 잡으면 중복, 고아 데이터, 잘못된 cascade, 느린 조인, 복잡한 migration으로 이어진다.

백엔드 개발자는 ERD를 그림으로만 보지 말고 “이 관계가 얼마나 자주 조회되고 변경되는가”까지 봐야 한다.

원리

관계와 카디널리티의 원리는 “어떤 테이블이 누구를 참조하는가”를 넘어 관계의 최대 크기, 생명주기, 조회 방향, 삭제 정책을 DB와 코드가 함께 이해하게 만드는 것이다. 1:N 관계라도 N이 최대 5인지 500만인지에 따라 인덱스, pagination, cascade, batch 전략이 달라진다.

백엔드 개발자는 ERD의 선 하나를 API 비용으로 번역해야 한다. JPA 연관관계는 객체 탐색을 편하게 만들지만, DB에서는 FK 검사, join, lazy loading, fetch join, N+1, cascade delete 비용으로 나타난다.

1:N 관계

회원과 주문은 보통 1:N이다.

CREATE TABLE orders (
    id bigint PRIMARY KEY,
    member_id bigint NOT NULL REFERENCES members(id),
    created_at timestamp NOT NULL
);

N 쪽인 orders가 FK를 가진다. 회원별 주문 목록을 자주 조회하면 orders(member_id, created_at) 인덱스가 필요하다.

CREATE INDEX idx_orders_member_created
ON orders (member_id, created_at DESC);

N:M 관계

게시글과 태그는 N:M일 수 있다. 중간 테이블로 표현한다.

CREATE TABLE post_tags (
    post_id bigint NOT NULL REFERENCES posts(id),
    tag_id bigint NOT NULL REFERENCES tags(id),
    PRIMARY KEY (post_id, tag_id)
);

태그별 게시글 검색이 필요하면 반대 방향 인덱스도 검토한다.

CREATE INDEX idx_post_tags_tag_post
ON post_tags (tag_id, post_id);

관계 테이블은 단순 연결뿐 아니라 생성자, 생성 시각, 상태 같은 속성을 가질 수 있다. 그 순간 독립 엔티티로 승격할 수 있다.

중간 테이블은 양방향 조회 요구를 분리해서 본다. 게시글 상세에서 태그를 보여주는 조회와 태그 검색에서 게시글을 찾는 조회는 서로 다른 인덱스를 요구할 수 있다. 한쪽만 보고 PK (post_id, tag_id)만 두면 태그별 검색이 느려질 수 있다.

1:1 관계

회원과 프로필 상세처럼 1:1로 분리할 수 있다.

CREATE TABLE member_profiles (
    member_id bigint PRIMARY KEY REFERENCES members(id),
    bio text,
    avatar_url varchar(500)
);

1:1 분리는 다음 이유가 있을 수 있다.

  • optional 데이터가 크다.
  • 보안/권한 경계가 다르다.
  • 변경 빈도가 다르다.
  • 테이블 폭을 줄이고 싶다.

불필요한 1:1 분리는 조인만 늘릴 수 있으므로 이유가 필요하다.

반대로 1:1 분리가 운영상 유용한 경우도 있다. 민감정보를 별도 테이블로 분리하면 접근 권한과 감사 로그를 다르게 적용하기 쉽고, 큰 text/json 부가정보를 분리하면 핵심 목록 조회의 row 폭을 줄일 수 있다.

JPA 연관관계와 DB 관계

JPA 연관관계는 객체 탐색을 편하게 하지만 DB 조인 비용을 없애지 않는다.

@ManyToOne(fetch = FetchType.LAZY)
private Member member;

order.getMember() 접근은 lazy loading SQL을 만들 수 있다. 관계를 맺는 순간 API에서 어떻게 조회할지, N+1이 생길지 함께 봐야 한다.

@OneToMany(mappedBy = "member")
private List<Order> orders;

이 컬렉션은 코드에서는 member.getOrders() 한 줄이지만, 운영에서는 “회원 한 명이 주문을 최대 몇 건 가질 수 있는가”라는 질문을 만든다. 최대 크기가 큰 컬렉션은 무심코 전체 로딩하지 말고 별도 목록 API와 cursor/limit을 둔다.

실전 팁

  • FK 방향은 조회와 생명주기를 함께 보고 정한다.
  • N:M은 중간 테이블을 기본으로 사용한다.
  • 관계 테이블에 속성이 생기면 독립 엔티티로 본다.
  • JPA cascade는 생명주기가 정말 같은 경우에만 둔다.
  • 관계별 자주 쓰는 조회 방향에 인덱스를 둔다.
  • 관계의 최대 크기를 문서화한다. 무제한으로 커질 수 있는 1:N은 컬렉션 로딩보다 별도 query/pagination으로 다룬다.
  • cascade remove는 자식 수와 삭제 로그, lock, FK 영향을 계산한 뒤 쓴다.
  • FK를 두지 않는 선택을 했다면 orphan 탐지 쿼리와 운영 정리 절차를 둔다.

위험 신호!

  • N:M을 문자열 배열이나 콤마 문자열로 저장한다.
  • FK 없이 애플리케이션 코드만으로 관계를 관리한다.
  • 모든 연관관계에 cascade remove를 건다.
  • 1:N 컬렉션을 항상 즉시 로딩한다.
  • 관계의 최대 크기를 생각하지 않는다.
  • 대형 1:N 관계에 cascade delete를 걸어 한 요청이 수십만 row 삭제를 유발한다.
  • FK가 없는데 orphan row를 찾는 쿼리나 운영 검증이 없다.

확인 질문

  • N:M 관계를 중간 테이블로 표현하는 이유는 무엇인가?
    • 양쪽 엔티티가 여러 개씩 연결될 수 있으므로 연결 자체를 행으로 관리해야 하기 때문이다.
  • 관계 조회 방향에 인덱스가 필요한 이유는 무엇인가?
    • FK가 있다고 해서 모든 조회 방향이 빠른 것은 아니며, 실제 WHERE/JOIN 조건에 맞는 인덱스가 필요하기 때문이다.
  • JPA 연관관계가 DB 비용을 없애지 못하는 이유는 무엇인가?
    • 객체 탐색은 결국 SQL 조회나 조인으로 변환되어 DB에서 실행되기 때문이다.
  • 1:N 관계의 N 최대 크기를 설계 때 확인해야 하는 이유는 무엇인가?
    • N의 크기에 따라 컬렉션 로딩, pagination, 인덱스, cascade delete, batch 처리와 장애 영향이 완전히 달라지기 때문이다.

참고 문서