[자바 ORM 표준 JPA 프로그래밍] Chapter 05

YUSHIN KIM·2025년 10월 9일

Chapter 05. 연관 관계 매핑 기초

객체 간에는 참조(주소)를 통해 관계를 맺고 테이블 간에는 외래 키를 통해 관계를 맺는다. 객체 관계 매핑(ORM)에서는 객체의 참조와 테이블의 외래 키를 매핑함으로써 이러한 패러다임의 불일치 문제를 해결한다.

연관 관계 매핑과 관련된 핵심 키워드는 다음과 같다.

  • 방향(Direction): 단방향, 양방향이 있다. 예를 들어 회원과 팀 간의 관계에서 회원 \rightarrow 팀, 팀 \rightarrow 회원 참조가 둘 다 발생하는 것을 양방향 관계, 하나만 발생하는 것을 단방향 관계라고 한다. 방향은 객체 간의 관계에서만 존재하고 테이블 간의 관계는 항상 양방향이다.
  • 다중성(Multiplicity): 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M) 다중성이 있다. 예를 들어 회원과 팀 간의 관계에서 회원은 하나의 팀에만 속하고, 하나의 팀에는 여러 명의 회원을 보유할 수 있으므로 이 관계는 다대일 관계이다.
  • 연관 관계의 주인(owner): 객체를 양방향 연관 관계로 만들면 연관 관계의 주인을 정해야 한다.

5.1 단방향 연관 관계

회원과 팀 간의 관계를 통해 다대일 단방향 관계에 대해 이해해 보자.

  • 회원과 팀이 있다.
  • 회원은 하나의 팀에만 소속될 수 있다.
  • 회원과 팀은 다대일 관계이다.

객체 연관 관계

  • 회원 객체는 Member.team 필드(멤버 변수)로 팀 객체와 연관 관계를 맺는다.
  • 회원 객체와 팀 객체는 단방향 관계이다. 회원은 Member.team 필드를 통해 소속 팀을 알 수 있지만 반대로 팀은 소속 회원들을 알 수 없다. 즉, team \rightarrow member 방향으로 접근하는 필드는 없다.

테이블 연관 관계

  • 회원 테이블은 TEAM_ID 외래 키로 팀 테이블과 연관 관계를 맺는다.
  • 회원 테이블과 팀 테이블은 양방향 관계이다. 회원 테이블의 TEAM_ID 외래 키를 통해 회원과 팀을 조인할 수 있고, 반대로 팀과 회원도 조인할 수 있다. 즉, MEMBER 테이블의 TEAM_ID 외래 키와 TEAM 테이블의 TEAM_ID 컬럼으로 MEMBER JOIN TEAM도 가능하고 TEAM JOIN MEMBER도 가능하다.

객체 연관 관계와 테이블 연관 관계의 가장 큰 차이

참조를 통한 연관 관계는 언제나 단방향이다. 만약 객체 간 연관 관계를 양방향으로 만들고 싶으면 반대쪽 객체에도 필드를 추가해서 참조를 보관해야 한다. 이는 즉 단방향 연관 관계를 하나 더 만들어야 한다는 의미이다. 그러므로 양방향 연관 관계는 서로 다른 단방향 연관 관계 2개이다.

객체 연관 관계 vs 테이블 연관 관계 정리

  • 객체는 참조(주소)로 연관 관계를 맺는다.
  • 테이블은 외래 키로 연관 관계를 맺는다.
  • 참조를 사용하는 객체의 연관 관계는 단방향이다.
  • 외래 키를 사용하는 테이블의 연관 관계는 양방향이다.
  • 객체를 양방향으로 참조하려면 단방향 연관 관계를 2개 만들어야 한다.

5.1.1 순수한 객체 연관 관계

예제 5.1. 회원과 팀 클래스

public class Member {

    private String id;
    private String username;

    private Team team;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Team getTeam() {
        return team;
    }

    public void setTeam(Team team) {
        this.team = team;
    }
}

public class Team {

    private String id;
    private String name;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

예제 5.2. 동작 코드

@Test
void naiveMemberTeam() {

    // 생성자(id, name)
    Member member1 = new Member("member1", "회원1");
    Member member2 = new Member("member2", "회원2");
    Team team1 = new Team("team1", "팀1");

    member1.setTeam(team1);
    member2.setTeam(team1);

    Team foundTeam = member1.getTeam();
    Assertions.assertEquals(team1, foundTeam);
}

위의 그림은 클래스 간 관계, 아래의 그림은 인스턴스 간 관계를 나타낸다. 위 코드와 같이 객체는 참조를 사용해 연관 관계를 탐색할 수 있는데 이것을 객체 그래프 탐색이라고 한다.

5.1.2 테이블 연관 관계

예제 5.3. 테이블 DDL

CREATE TABLE MEMBER (
    MEMBER_ID VARCHAR(255) NOT NULL,
    TEAM_ID VARCHAR(255),
    USERNAME VARCHAR(255),
    PRIMARY KEY (MEMBER_ID)
);

CREATE TABLE TEAM (
    TEAM_ID VARCHAR(255) NOT NULL,
    NAME VARCHAR(255),
    PRIMARY KEY (TEAM_ID)
);

ALTER TABLE MEMBER ADD CONSTRAINT FK_MEMBER_TEAM
    FOREIGN KEY (TEAM_ID)
    REFERENCES TEAM;

테이블 DML

INSERT INTO TEAM(TEAM_ID, NAME) VALUES ('team1', '팀1');
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES ('member1', 'team1', '회원1');
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES ('member2', 'team1', '회원2');

-- 회원 1이 소속된 팀 조회
SELECT T.*
FROM MEMBER M
    JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
WHERE M.MEMBER_ID = 'member1';

위 코드와 같이 데이터베이스 테이블은 외래 키를 사용해서 연관 관계를 탐색할 수 있는데 이것을 조인이라고 한다.

5.1.3 객체 관계 매핑

지금까지 살펴본 객체 간의 연관 관계, 테이블 간의 연관 관계를 JPA를 통해 매핑해 보자.

예제 5.4. 매핑한 회원 엔터티

package jpabook.domain;

import jakarta.persistence.*;

@Entity
public class Member {

    @Id
    @Column(name = "MEMBER_ID")
    private String id;

    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Team getTeam() {
        return team;
    }

    public void setTeam(Team team) {
        this.team = team;
    }
}

예제 5.5. 매핑한 팀 엔터티

package jpabook.domain;

import jakarta.persistence.*;

@Entity
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;

    private String name;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
  • 객체 연관 관계: 회원 객체의 Member.team 필드 사용
  • 테이블 연관 관계: 회원 테이블의 MEMBER.TEAM_ID 외래 키 컬럼 사용

ORM의 연관 관계 매핑이란 Member.teamMEMBER.TEAM_ID를 매핑하는 것이다.

  • @ManyToOne: 다애일 관계 매핑 정보를 나타낸다. 연관 관계 매핑 시 다중성을 나타내는 애너테이션을 필수로 사용해야 한다.
  • @JoinColumn(name = "TEAM_ID"): 조인 컬럼 애너테이션은 외래 키를 매핑할 때 사용한다. name 속성에는 매핑할 외래 키 이름을 지정한다. 이 애너테이션은 생략 가능하다.

5.1.4 @JoinColumn

@JoinColumn은 외래 키를 매핑한다.

속성기능기본 값
name매핑할 외래 키 이름필드명 + _ + 참조하는 테이블의 기본 키 컬럼명
referencedColumnName외래 키가 참조하는 대상 테이블의 컬럼명참조하는 테이블의 기본 키 컬럼명
foreignKey(DDL)외래 키 제약 조건 직접 지정
테이블 생성 시에만 사용
unique
nullable
insertable
updatable
columnDefinition
table
@Column의 속성과 동일한 의미

현재 예제에서 @JoinColumn 생략 시 JPA는 team_TEAM_ID를 외래 키 이름으로 판단할 것이다.

5.1.5 @ManyToOne

@ManyToOne 애너테이션은 다대일 관계를 매핑한다.

속성기능기본 값
optionalfalse로 설정 시 연관된 엔터티가 항상 있어야 함true
fetch글로벌 페치 전략 설정- @ManyToOne=FetchType.EAGER
- @OneToMany=FetchType.LAZY
cascade영속성 전이 기능 사용
targetEntity연관된 엔터티의 타입 정보 설정
속성 대신 컬렉션 제너릭으로 타입 정보 추론 가능

targetEntity 속성은 다음과 같이 사용한다.

@OneToMany
private List<Member> members;	// 제네릭으로 타입 정보 추론 가능

@OneToMany(targetEntity = Member.class)
private List members;			// 제네릭이 없을 시 타입 정보 추론 불가

5.2 연관 관계 사용

5.2.1 저장

예제 5.6. 회원과 팀을 저장하는 코드

@BeforeEach
@Transactional
void setUp() {
    // 팀1 저장
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    // 회원1 저장
    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1); // 연관 관계 설정 member1 -> team1
    em.persist(member1);

    // 회원2 저장
    Member member2 = new Member("member2", "회원2");
    member2.setTeam(team1); // 연관 관계 설정 member2 -> team1
    em.persist(member2);

    em.flush();
}

@Test
@Transactional
void testSave() {

	Team team1 = em.find(Team.class, "team1");
    Member foundMember = em.find(Member.class, "member1");
    Assertions.assertEquals(team1, member1.getTeam());
}

테스트 실행 시 JPA는 다음과 같은 삽입 SQL 쿼리를 데이터베이스에 전송한다.

Hibernate: 
    insert 
    into
        team
        (name, team_id) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        member
        (team_id, username, member_id) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        member
        (team_id, username, member_id) 
    values
        (?, ?, ?)

5.2.2 조회

연관 관계가 있는 엔터티를 조회하는 방법은 크게 2가지가 있다.

  • 객체 그래프 탐색(객체 연관 관계를 사용한 조회)
  • 객체지향 쿼리 사용(JPQL)

객체 그래프 탐색

member.getTeam()과 같이 객체를 통해 연관된 엔터티를 조회하는 것을 객체 그래프 탐색이라 한다.

객체지향 쿼리 사용

예제 5.7. JPQL 조인 검색

@Test
@Transactional
void queryLogicJoin() {

    String jpql = "SELECT m FROM Member m JOIN m.team t WHERE t.name=:teamName";

    List<Member> resultList = em.createQuery(jpql, Member.class)
            .setParameter("teamName", "팀1")
            .getResultList();

    for (Member member : resultList) {
        System.out.println("[query] member.username=" + member.getUsername());
    }
    
    Assertions.assertEquals(2, resultList.size());
}

JPQL에서는 FROM member m JOIN m.team t와 같이 회원 엔터티의 팀과의 관계를 나타내는 필드(m.team)를 통해 두 테이블을 조인했다. 이처럼 JPQL은 객체(엔터티)를 대상으로 한 쿼리 언어이며 SQL보다 간결하다.

테스트 실행 시 JPA는 다음과 같은 조회 SQL 쿼리를 데이터베이스에 전송한다.

Hibernate: 
    select
        m1_0.member_id,
        m1_0.team_id,
        m1_0.username 
    from
        member m1_0 
    join
        team t1_0 
            on t1_0.team_id=m1_0.team_id 
    where
        t1_0.name=?

5.2.3 수정

예제 5.8. 연관 관계를 수정하는 코드

@Test
@Transactional
void updateRelation() {

	// 새로운 팀2
    Team team2 = new Team("team2", "팀2");
    em.persist(team2);

    // 회원1에 새로운 팀2 설정
    Member member = em.find(Member.class, "member1");
    member.setTeam(team2);

    Member foundMember = em.find(Member.class, "member1");
    Assertions.assertEquals(team2, foundMember.getTeam());
}

테스트 실행 시 JPA는 다음과 같은 수정 SQL 쿼리를 데이터베이스에 전송한다.

Hibernate: 
    update
        member 
    set
        team_id=?,
        username=? 
    where
        member_id=?

연관 관계 수정 시에도 마찬가지로 JPA 영속성 컨텍스트의 변경 감지 기능이 작동하여 setTeam() 메서드로 연관된 객체가 저장된 필드를 수정하면 변경 사항이 커밋 시 자동으로 반영된다.

5.2.4 제거

예제 5.9. 연관 관계를 삭제하는 코드

@Test
@Transactional
void deleteRelation() {

    Member member1 = em.find(Member.class, "member1");
    member1.setTeam(null);

    em.flush();

    Member foundMember = em.find(Member.class, "member1");
    Assertions.assertEquals(null, foundMember.getTeam());
}

테스트 실행 시 JPA는 다음과 같은 수정 SQL 쿼리를 데이터베이스에 전송한다.

Hibernate: 
    update
        member 
    set
        team_id=?,
        username=? 
    where
        member_id=?

5.2.5 연관된 엔터티 삭제

연관된 엔터티를 삭제하려면 기존에 있던 연관 관계를 먼저 제거하여 외래 키 제약 조건에 위배되지 않게 해야 한다.

member1.setTeam(null);	// 회원1 연관 관계 제거
member2.setTeam(null);	// 회원2 연관 관계 제거
em.remove(team);		// 팀 삭제

5.3 양방향 연관 관계

팀에서 회원으로 객체 그래프 탐색을 할 수 있도록 단방향 연관 관계를 추가해 양방향 연관 관계를 성립시킬 것이다.

회원과 팀 엔터티 간의 관계는 팀 입장에서는 일대다 관계이다. 팀 엔터티는 여러 개의 회원 엔터티와 연관 관계를 맺을 수 있으므로 컬렉션을 사용해야 한다. 그러므로 Team.members 필드는 List 컬렉션 타입으로 정의한다. JPA는 List 외에도 Collection, Set, Map 같은 다양한 컬렉션을 지원한다.

그러므로 객체 연관 관계를 나타내는 필드는 다음과 같이 정리된다.

  • 회원 \rightarrow 팀: Member.team
  • \rightarrow 회원: Team.members

데이터베이스 테이블은 외래 키 하나로 양방향으로 조회할 수 있다. 그러므로 데이터베이스에는 추가 사항이 없다.

5.3.1 양방향 연관 관계 매핑

예제 5.10. 매핑한 회원 엔터티

@Entity
public class Member {

    @Id
    @Column(name = "MEMBER_ID")
    private String id;

    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
...

회원 엔터티는 변경 사항이 없다.

예제 5.11. 매핑한 팀 엔터티

@Entity
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;

    private String name;
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<Member>();
       
    public void addMember(Member member) {
        members.add(member);
        member.setTeam(this);
    }
...

팀 엔터티에는 컬렉션인 List<Member> members 필드를 추가하고 일대다 관계 매핑을 위해 @OneToMany 매핑 애너테이션을 사용했다. mappedBy 속성은 양방향 매핑일 때 사용하며, 반대쪽 엔터티가 매핑하고 있는 필드의 이름을 값으로 전달하면 된다. 이 경우에는 반대쪽 엔터티(Member)의 매핑 필드가 team이다.

이렇게 양방향 매핑을 마치면 팀에서 회원 엔터티로 객체 그래프 탐색이 가능해진다.

5.3.2 일대다 컬렉션 조회

예제 5.12. 일대다 방향으로 객체 그래프 탐색

@Test
@Transactional
void bidirectionalObjectGraphSearch() {

    Team team = em.find(Team.class, "team1");
    List<Member> members = team.getMembers();   // (팀 -> 회원) 객체 그래프 탐색

    for (Member member : members) {
        System.out.println("member.username = " + member.getUsername());
    }

    Assertions.assertEquals(2, members.size());
}

5.4 연관 관계의 주인

엄밀히 말해서 객체 간 양방향 연관 관계라는 것은 없고 서로 다른 단방향 연관 관계 2개를 애플리케이션 로직으로 잘 묶어서 양방향인 것처럼 보이게 할 뿐이다.

테이블은 외래 키 하나로 두 테이블의 연관 관계를 관리할 수 있다. 엔터티는 단방향으로 매핑하면 참조를 하나만 사용하고 이 참조로 외래 키를 관리한다. 그러므로 엔터티를 양방향으로 매핑하면 객체의 연관 관계를 관리하는 포인트는 두 곳이 된다.

엔터티를 양방향 연관 관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나인 패러다임의 불일치가 발생한다. 이로 인해 JPA에서는 두 객체 연관 관계 중 하나를 정해서 테이블의 외래 키를 관리하도록 해야 하는데 이것을 연관 관계의 주인(Owner)이라 한다.

5.4.1 양방향 매핑의 규칙: 연관 관계의 주인

연관 관계의 주인만이 데이터베이스 연관 관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있다. 반면 주인이 아닌 쪽은 읽기만 할 수 있다. 어떤 연관 관계를 주인으로 정할지는 mappedBy 속성으로 명시한다.

  • 주인은 mappedBy 속성을 사용하지 않는다.
  • 주인이 아니면 mappedBy 속성을 사용해 속성의 값으로 연관 관계의 주인을 지정해야 한다.

연관 관계의 주인을 정한다는 것은 외래 키 관리자를 선택하는 것이다. 이 예제에서는 회원 테이블에 있는 TEAM_ID 외래 키를 관리할 관리자를 선택해야 한다. 만약 회원 엔터티에 있는 Member.team 필드를 주인으로 선택하면 엔터티 자신의 테이블에 있는 외래 키를 관리하면 된다. 하지만 팀 엔터티에 있는 Team.members 필드를 주인으로 선택하면 물리적으로 독립된 테이블의 외래 키를 관리해야 한다.

5.4.2 연관 관계의 주인은 외래 키가 있는 곳

즉, 연관 관계의 주인은 테이블에 외래 키가 있는 엔터티의 필드로 정해야 한다. 이 예제에서는 Member.team이 연관 관계의 주인이 되고, 주인이 아닌 Team.members에는 @OneToMany 매핑 애너테이션의 mappedBy 속성 값에 연관 관계의 주인 이름을 전달해야 한다.

연관 관계의 주인만 데이터베이스 연관 관계와 매핑되고 외래 키를 관리할 수 있다. 주인이 아닌 반대편(inverse, non-owning side)은 읽기만 가능하고 외래 키를 변경할 수는 없다. 생각해 보면 외래 키 자체가 referencing table에 있는 것인데 referenced table과 대응되는 엔터티가 외래 키의 관리자가 되는 것은 말이 되지 않는다.

데이터베이스 테이블의 일대다, 다대일 관계에서는 항상 Many-side가 외래 키를 가진다. 즉, @ManyToOne 애너테이션으로 지정된 필드가 항상 연관 관계의 주인이 되므로 mappedBy 속성 설정이 불가능하다. 그래서 @ManyToOne 애너테이션에는 mappedBy 속성 자체가 정의되어 있지가 않다.

5.5 양방향 연관 관계 저장

예제 5.13. 양방향 연관 관계 저장

@BeforeEach
@Transactional
void setUp() {
    // 팀1 저장
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    // 회원1 저장
    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1); // 연관 관계 설정 member1 -> team1
    em.persist(member1);

    // 회원2 저장
    Member member2 = new Member("member2", "회원2");
    member2.setTeam(team1); // 연관 관계 설정 member2 -> team1
    em.persist(member2);

    em.flush();
}

앞서 살펴본 예제 5.6과 같이 엔터티 저장 시 연관 관계의 주인 필드에는 반드시 값을 설정해 주어야 한다. 그래야 엔터티 매니저가 해당 필드를 통해 외래 키를 관리하고 데이터베이스 저장 시 외래 키도 저장될 수 있다.

5.6 양방향 연관 관계의 주의점

예제 5.14. 양방향 연관 관계 주의점

@BeforeEach
@Transactional
void setUp() {
    // 회원1 저장
    Member member1 = new Member("member1", "회원1");
    em.persist(member1);

    // 회원2 저장
    Member member2 = new Member("member2", "회원2");
    em.persist(member2);

    // 팀1 저장
    Team team1 = new Team("team1", "팀1");
    team1.getMembers().add(member1);
    team1.getMembers().add(member2);
    em.persist(team1);

    em.flush();
}

@Test
@Transactional
void testSaveNonOwner() {

    Member member1 = em.find(Member.class, "member1");
    Member member2 = em.find(Member.class, "member2");

    Assertions.assertEquals(null, member1.getTeam());
    Assertions.assertEquals(null, member2.getTeam());
}

양방향 연관 관계 설정 시 연관 관계의 주인에는 값을 입력하지 않고 주인이 아닌 필드에만 값을 입력하면 데이터베이스에는 외래 키 값이 null로 저장된다.

5.6.1 순수한 객체까지 고려한 양방향 연관 관계

하지만 객체 관점에서는 양쪽 방향에 모두 값을 입력해 주는 것이 가장 안전하다.

예제 5.15. 순수한 객체 연관 관계

@Test
@Transactional
void testNaiveBidirectional() {

    Team team1 = new Team("team1", "팀1");
    Member member1 = new Member("member1", "회원1");
    Member member2 = new Member("member2", "회원2");

    member1.setTeam(team1);
    member2.setTeam(team1);

    List<Member> members = team1.getMembers();
    Assertions.assertEquals(0, members.size());
}

JPA를 사용하지 않는 순수한 객체에서 한 방향만 연관 관계를 설정하면 반대 방향 연관 관계에는 어떤 것도 저장되지 않는다. 기대하는 대로 동작하게 하려면 양쪽 모두 관계를 설정해야 한다.

예제 5.16. 양방향 모두 관계를 설정

@Test
@Transactional
void testNaiveBidirectional() {

    Team team1 = new Team("team1", "팀1");
    Member member1 = new Member("member1", "회원1");
    Member member2 = new Member("member2", "회원2");

    member1.setTeam(team1);
    team1.getMembers().add(member1);

    member2.setTeam(team1);
    team1.getMembers().add(member2);

    List<Member> members = team1.getMembers();
    Assertions.assertEquals(2, members.size());
}

그러므로 JPA를 활용할 때는 다음과 같이 별도의 메서드를 통해 양방향 연관 관계의 구성을 보장하는 것도 좋은 방법이 될 것이다.

예제 5.17. JPA로 코드 완성

// Member.java
public void setTeam(Team team) {
    this.team = team;
    team.getMembers().add(this);
}

// Team.java
public void addMember(Member member) {
    members.add(member);
    member.setTeam(this);
}

5.6.2 연관 관계 편의 메서드

앞서 리팩토링한 Member.setTeam(), Team.addMember() 메서드와 같이 편의 메서드를 정의하면 실수를 줄일 수 있다.

예제 5.18. 양방향 리팩토링 전체 코드

@BeforeEach
@Transactional
void setUp() {
    // 팀1 저장
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    // 회원1 저장
    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1);
    em.persist(member1);

    // 회원2 저장
    Member member2 = new Member("member2", "회원2");
    member2.setTeam(team1);
    em.persist(member2);

    em.flush();
}

@Test
@Transactional
void testORMBidirectional() {

    Team foundTeam = em.find(Team.class, "team1");
    Assertions.assertEquals(2, foundTeam.getMembers().size());
}

5.6.3 연관 관계 편의 메서드 작성 시 주의사항

member1.setTeam(teamA);
member1.setTeam(teamB);
boolean member1Found = teamA.getMembers().contains(member1);

위 코드 호출 시 member1이 속한 팀을 teamB로 변경했지만 teamAmembers 연관 관계 필드에 member1이 남아 있는 버그가 발생한다. 이러한 데이터 불일치 버그를 방지하기 위해 기존의 연관 관계를 삭제하는 코드를 편의 메서드에 추가해야 한다.

예제 5.19. 기존 관계 제거

// Member.java
public void setTeam(Team team) {
    if (this.team != null) {
        this.team.getMembers().remove(this);
    }
    this.team = team;
    team.getMembers().add(this);
}

// Team.java
public void addMember(Member member) {
    members.add(member);
    if (member.getTeam() != null) {
        member.getTeam().getMembers().remove(member);
    }
    member.setTeam(this);
}

객체에서 서로 다른 단방향 연관 관계 2개를 양방향인 것처럼 보이게 하기 위해선 이처럼 많은 고민이 필요하지만, 관계형 데이터베이스는 외래 키 하나로 양방향 연관 관계에 대한 문제를 간단히 해결한다. 이는 패러다임의 불일치를 해결하기 위한 까다로움을 잘 보여주는 예시이다.

5.7 정리

단방향 매핑과 비교해서 양방향 매핑은 연관 관계의 주인도 정해야 하고 두 개의 단방향 연관 관계를 양방향처럼 보이게 하기 위해 치밀한 로직을 설계해야 하기에 복잡하다. 중요한 사실은 연관 관계가 하나인 단방향 매핑은 언제나 연관 관계의 주인이라는 점이다. 양방향의 장점은 반대 방향으로 객체 그래프 탐색이 가능하다는 것뿐이다.

내용을 정리하면 다음과 같다.

  • 단방향 매핑만으로도 테이블과 객체의 연관 관계 매핑은 완료된다.
  • 단방향을 양방향으로 만들면 반대 방향으로 객체 그래프 탐색이 가능해진다.
  • 양방향 연관 관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야 한다.
  • 연관 관계의 주인은 비즈니스 중요도가 아닌 외래 키의 위치를 기준으로 정해야 한다.
profile
안녕하세요

0개의 댓글