5. 연관관계 매핑 - 단방향, 양방향 연관관계

2025. 6. 15. 16:55Spring/JPA

1. 연관관계 매핑 기초 개요
1) 연관관계 매핑의 목표
2) 연관관계가 필요한 이유
3) 예제 시나리오

2. 단방향/양방향 연관관계의 개념 
1) 단방향/양방향 연관관계 
2) 양방향 연관관계의 주인 

3. 단방향 연관관계 
1) 단방향 연관관계 적용 
2) 연관관계 저장, 조회, 수정 

4. 양방향 연관관계
1) 양방향 연관관계 적용 
2) 양방향 탐색 

5. 양방향 연관관계의 주인과 비주인의 차이: 읽기 전용 여부
1) 연관관계의 주인 → DB에 반영됨 (읽기 전용 X)
2) 비주인 → DB에 반영 안됨 (읽기 전용O)

 

6. 양방향 연관관계의 주인을 정하는 기준
1) DB에서 외래 키가 있는 테이블의 객체, 즉 Many(N) 쪽을 주인으로 정하자 
2) 반대로 One(1) 쪽을 주인으로 하면? 비효율적

7. 양방향 매핑에서 많이 하는 실수 
1) 연관관계의 주인에 값을 안 넣고, 비주인에만 값을 세팅한 경우 
2) 주인에는 값을 넣었지만, 비주인에는 값을 세팅하지 않은 경우 
3) 해결 방법: 양쪽 모두 값을 세팅하는 연관관계 편의 메서드 사용 권장
4) 무한 루프 주의

8. 굳이 양방향 연관관계를 쓰는 이유?
1) 양방향 연관관계가 필요한 이유 
2) 양방향 매핑은 꼭 필요한 경우에만 사용하자


1. 연관관계 매핑 기초 개요

1) 연관관계 매핑의 목표

  • '객체'와 '테이블'의 연관관계 차이 이해
  • '객체의 참조'와 '테이블의 외래 키' 매핑 방식 학습
  • 주요 용어
    • 방향(Direction): 단방향, 양방향
    • 다중성(Multiplicity): N:1, 1:N, 1:1, N:M
    • 연관관계의 주인(Owner): 양방향일 경우 외래 키 관리 주체 설정 필요

2) 연관관계가 필요한 이유

  • '객체'와 '테이블'의 차이
    • 객체: 참조를 사용해서 연관 객체를 찾음
    • 테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾음 
    • 두 패러다임 사이에는 큰 간격이 있다
  • 객체지향의 목표는 자율적인 객체들의 협력 구조
    이러한 객체들의 협력 관계를 표현하려면 연관관계 매핑이 필수
  • 연관관계가 없는 데이터 중심 모델링은 참조 대신 외래 키만 저장 (teamId)
    따라서 객체 간 탐색이나 협력 불가 → 비객체지향적

3) 예제 시나리오

  • 회원과 팀이 있다.
  • 회원은 하나의 팀에만 소속될 수 있고 팀은 여러 회원을 가질 수 있다.
  • 회원 입장에서 다대일 관계, 팀 입장에서 일대다 관계  

2. 단방향/양방향 연관관계의 개념 

1) 단방향/양방향 연관관계 

  • 단방향 연관관계란? 객체 A에서 객체 B를 참조하지만, B는 A를 모르는 구조
    • Member는 본인이 속한 Team을 참조하고 있지만 
    • Team은 본인이 어떤 Member들과 관계를 맺고 있는지 알지 못한다. 
  • 양방향 연관관계란? 객체 A에서 객체 B를 참조하고, B도 A를 참조하여, 서로를 알고 있는 구조이다. 
    • Member는 본인이 속한 Team을 참조하고 있고
    • Team은 List<Member>들을 참조하고 있다.  

2) 양방향 연관관계의 주인 

  • 이때 양방향 연관관계에서는 "연관관계의 주인"이라는 개념이 필요하다. 왜일까?
  • 테이블과 객체라는 패러다임 사이에 양방향 관계에 관한 차이 존재 
    1. 테이블 양방향 관계: 단방향 관계 1개 (외래키 1개)  
      • 그냥 MEMBER 테이블의 TEAM_ID 외래키 하나로 "멤버가 팀에 속해 있다"를 표현하면 됨
      • 그 외래키를 거꾸로 조인하면 → "팀이 가진 멤버들"도 알 수 있다
    2. 객체 양방향 관계: 서로 다른 단방향 관계 2개 
      • 객체는 테이블처럼 조인을 사용하지 않기 때문에 
      • member.getTeam(), team.getMembers() 이렇게 각자 참조 필드를 갖고 있어야 양쪽 방향을 오갈 수 있음
  • 이때 객체에서 참조가 2개면, DB 외래키는 어떤 객체를 따라야 할지 혼란스러워진다.  
    • JPA 입장에선 양쪽에 객체 참조가 다 있는데, 어떤 객체를 보고 외래키 값을 insert/update할까?
    • 예를 들어 멤버가 팀을 바꾸는 경우
      member.setTeam(teamB)은 했지만 teamA.getMembers().remove(member)는 안 해줬다면?
      → 둘 사이에 불일치가 생기고
      어떤 객체의 데이터를 기준으로 DB 외래키에 반영해야 하는지 JPA는 혼란
  • 따라서 "이 연관관계의 주인은 얘야!" 라고 선언하여 JPA는 주인 필드만 가지고 외래키를 제어하게끔 강제한다. 

3. 단방향 연관관계 

1) 단방향 연관관계 적용 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
    
    //외래키 사용
    //@Column(name = "TEAM_ID")
    //private Long teamId;

    //객체 사용 → 단방향 연관관계 설정
    @ManyToOne 
    @JoinColumn(name = "TEAM_ID") //외래 키 컬럼 지정
    private Team team;
    ...
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
    ...
}
  • @ManyToOne
    • 나는 Member 객체(Many)고, Team이라는 객체(One)와 N:1 연관관계를 맺고 있다는 의미
  • @JoinColumn(name = "TEAM_ID")
    • @JoinColumn은 “이 필드로 외래키 컬럼을 관리할 것이며, 즉 이 필드가 연관관계의 주인이다" 라는 의미 
    • 또한 DB 외래키는 "TEAM_ID"라는 컬럼명으로 존재한다는 것을 알려준다. 
  • 그렇다면 JPA는 외래키가 두 테이블 중 어디에 있어야 하는지 어떻게 알까? 
    • DB에서 외래키는 항상 Many(다) 쪽에 생긴다. 
    • 따라서 JPA는 @ManyToOne을 보고
      "이 객체가 Many 쪽이니까 외래키는 이 객체(Member)의 테이블에 있어야 한다."고 인식한다.
    • 또한 @JoinColumn(name = "TEAM_ID")를 보고
      "Member 테이블의 TEAM_ID 컬럼이 외래키다." 라고 명확히 알게 되는것. 
  • 스키마 자동 생성 모드에 따른 실제 작동 방식  
    • JPA가 DDL을 직접 생성하는 경우 (create, create-drop, update 모드)
      "TEAM_ID"라는 외래 키 컬럼을 Many에 속하는 MEMBER 테이블에 생성함
    • 이미 DB에 테이블이 존재하는 경우 (none, validate 모드)
      MEMBER 테이블에 "TEAM_ID"라는 컬럼이 있을거라 가정하고 이 컬럼이 외래 키 역할이라고 인식 

2) 연관관계 저장, 조회, 수정 

//1. 연관관계 설정 
//팀 저장
Team team = new Team();
team.setName("TeamA"); 
em.persist(team);

//회원 저장
Member member = new Member(); 
member.setName("member1");

//단방향 연관관계 설정, 참조 저장
member.setTeam(team);  
em.persist(member);

//2. 연관관계 조회
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam(); //참조를 사용하여 객체 그래프 탐색

//외래키 사용 -> 비객체지향적
//Long teamId = member.getTeamId();
//Team findTeam = em.find(Team.class, teamId); 

//3. 연관관계 수정
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);

member.setTeam(teamB); //연관관계 새로운 팀으로 재설정

 

4. 양방향 연관관계 

1) 양방향 연관관계 적용 

@Entity
public class Member {
    @ManyToOne 
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    ...
}

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    ...
}
      • 기존 단방향 관계 유지 + Team 객체에 반대 방향 매핑 추가
      • @OneToMany
        • 나는 Team 객체(One)고, Member라는 객체(Many)와 1:N 연관관계를 맺고 있다는 의미 
      • mappedBy = "team"
        • 이 연관관계에서 주인 클래스는 반대 객체 Member이다. 
        • 그리고 그 Member 객체에서 나 Team 객체는 "team" 이라는 이름의 필드로 존재한다. 
        • 즉 mappedBy를 통해 나는 주인 클래스가 아니라는 것을 밝히고 
          주인 클래스 내에서
          자기 자신을 가리키는 필드 이름을 정확히 지정한다.

2) 양방향 탐색

 public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();
        try{
            //1. 팀, 멤버 세팅 
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            Member member = new Member();
            member.setName("Member1");
            member.setTeam(team); //연결
            em.persist(member);

            //2. DB에 보내고 영속성 컨텍스트 초기화
            em.flush();
            em.clear();

            //3. 조회된 멤버가 속한 팀의 멤버 찾기 (양방향 조회)  
            Member foundMember = em.find(Member.class, member.getId());
            List<Member> members = foundMember.getTeam().getMembers();

            for (Member m : members) {
                System.out.println("m = " + m.getName());
            }

            tx.commit();
        } catch (Exception e){
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
        }
        emf.close();
    }

 

5. 양방향 연관관계의 주인과 비주인의 차이: 읽기 전용 여부

1) 연관관계의 주인 → DB에 반영됨 (읽기 전용 X)

@ManyToOne 
@JoinColumn(name = "TEAM_ID") 
private Team team;
  • @JoinColumn은 이 필드가 외래키를 관리하는 연관관계의 주체(주인)임을 선언한다. 
  • 여기서 해당 컬럼이 외래키를 관리한다는 것은
    객체 필드 값이 바뀌면 → JPA가 이를 감지해서 → DB의 외래키 컬럼 값을 자동으로 insert/update 반영해준다는 것
  • 즉, Member 객체에서 setTeam(team)으로 값 설정하는 경우 
    → JPA는 member.team_id 값을 DB에 업데이트함
  • 그 결과, 나중에 team을 DB에서 새로 조회한 후 team.getMembers()를 호출하면 
    → JPA는 아래와 같은 쿼리를 실행하고 해당 팀에 속한 멤버들을 조회할 수 있게 됨
team.getMembers() 
-> SELECT * FROM member WHERE team_id = ?

2) 비주인 → DB에 반영 안됨 (읽기 전용O)

class Team {
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    ..
}
  • 비주인 객체는 mappedBy를 통해 나는 주인 클래스가 아니라는 것을 밝힌다.
    또한 Member 객체에서 나 Team 객체는 "team" 이라는 이름의 필드로 존재한다는 것을 명시한다. 
  • 따라서 JPA는 Team.members 컬렉션은 그냥 일반 자바 객체 필드로 취급함
    → em.persist(team) 시 Team 객체는 영속성 컨텍스트(1차 캐시)에 저장되지만
    → 내부의 members 필드는 단지 ArrayList 객체 그대로 저장될 뿐이다. 
    → 즉, JPA는 members 리스트 안에 무엇이 들어 있든 관심 없음
    이 필드를 추적하지도, 신경쓰지도 않고 당연히 DB에 INSERT/UPDATE 안함

 

6. 양방향 연관관계의 주인을 정하는 기준 

1) DB에서 외래 키가 있는 테이블의 객체, 즉 Many(N) 쪽을 주인으로 정하자 

  • DB 원칙: 관계형 데이터베이스에서는 항상 외래키는 N(Many) 쪽에 위치함
  • JPA 원칙: 외래키를 제어할 수 있는 쪽이 주인 
  • 이때, 외래키가 실제로 있는 객체를 주인으로 정해야 JPA가 insert할 때 외래키 값을 넣으면서 한 방에 처리할 수 있다. 

2) 반대로 One(1) 쪽을 주인으로 하면? 비효율적

  • 외래키는 여전히 Many쪽의 테이블에 있는데… One쪽 객체가 외래키 값을 컨트롤하게 됨 
    즉, 외래키가 없는 객체가 외래키 값을 컨트롤하게 됨 
  • 이에 따른 JPA의 외래키 설정 과정
    member insert (외래키 null) -> 그다음 team insert -> Team.members를 보고 member 테이블을 update해서 외래키 설정
    • 처음에 JPA는 Member 객체의 insert 쿼리는 외래키 null인 상태로 비워둔 채 날리고 (주인이 아니니까 외래키 조작 불가)
    • 나중에 Team 객체가 insert
    • 그리고 Team 객체에서 주인으로 설정한 members 필드의 외래키 설정을 반영하기 위해 
      MEMBER 테이블의 기존 데이터에 update쿼리를 날려서 외래키 TEAM_ID를 채운다.
  • 따라서 insert → update 쿼리가 2번 발생하므로 비효율적이라 권장되지 않음

 

7. 양방향 매핑에서 많이 하는 실수 

1) 연관관계의 주인에 값을 안 넣고, 비주인에만 값을 세팅한 경우 

team.getMembers().add(member);
//member.setTeam(team) //안함
  • 이 코드는 단순히 자바 컬렉션(List<Member>)에 객체를 추가한 것일 뿐, JPA는 이 동작을 전혀 인식하지 않는다.
  • JPA에서 연관관계를 DB에 반영하는 기준은 연관관계의 주인(여기서는 Member.team)이기 때문이다.
  • 따라서 member.setTeam(team) 없이 team.getMembers().add(member)만 했을 경우 
    • flush()나 commit()이 되어도 DB에는 아무 변화가 없음
    • Member 테이블의 team_id는 여전히 null
    • DB 상에서 연관관계가 성립되지 않은 상태
Team findTeam = em.find(Team.class, teamId);
List<Member> members = findTeam.getMembers(); // ← 이 시점에 쿼리는 나감
  • 이때 SELECT * FROM member WHERE team_id = ? 쿼리가 실행되지만
    DB에 team_id가 설정된 member가 없으므로 결과는 빈 리스트

2) 주인에는 값을 넣었지만, 비주인에는 값을 세팅하지 않은 경우 

team.getMembers().add(member);
//member.setTeam(team) //안함
  • 이 경우 DB에는 정상적으로 연관관계가 반영되지만,
    자바 객체 기준의 team.getMembers()는 여전히 빈 리스트로 남아 있음.
  • 즉, JPA는 연관관계를 주인 기준으로만 DB에 반영하지만, 자바 메모리 상 객체 그래프는 동기화되지 않음.
  • 이로 인해 화면 출력이나 테스트 코드에서 의도치 않은 결과가 나올 수 있다.

3) 해결 방법: 양쪽 모두 값을 세팅하는 연관관계 편의 메서드 사용 권장

public void addMember(Member member) {
    members.add(member);
    member.setTeam(this);
}
  • 이렇게 양방향 관계를 동시에 설정하는 메서드를 통해
    DB와 자바 객체 상태가 항상 일관되도록 유지하는 것이 가장 안전하다.
  • 단, 이 편의 메서드는 양쪽 모두에 만들기보다는 한 쪽에만 두는 것이 좋다.
    양쪽 모두에서 서로를 설정하면, 순환 호출이나 객체 상태 중복 설정 등 문제가 발생할 수 있기 때문이다.

4) 무한 루프 주의

  • 양방향 연관관계에서 toString(), 롬복 @ToString(lombok), 또는 JSON 직렬화(Jackson 등)를 할 경우
    Team → Member → Team → Member ...처럼 객체가 서로를 반복 호출하면서 StackOverflowError가 발생할 수 있다.
  • 예: System.out.println(team);
    1. team.toString() → 내부에 members 출력
    2. members 안의 member.toString() → 내부에 team 출력
    3. 다시 team.toString() → members 출력...
    4. 무한 반복 → StackOverflowError 발생
  • 이를 방지하려면
    • 롬복에서 ToString 사용 X
    • 컨트롤러(API)에서 엔티티 반환 X
    • 사용할 경우 @ToString(exclude = ...), @JsonIgnore, @JsonBackReference 등을 적절히 설정하여
    • 양방향 연관 필드는 절대로 자동 순환 출력되지 않도록 조심해야 한다.

 

8. 굳이 양방향 연관관계를 쓰는 이유?

1) 양방향 연관관계가 필요한 이유 

  • DB 입장에서 보면 Team.members처럼 외래 키를 직접 갖지 않는 필드는
    실제 INSERT/UPDATE 쿼리에 반영되지 않기 때문에 굳이 만들지 않아도 될 것 같음.
  • 또한 단방향 연관관계만으로도 Member 테이블에서 팀 아이디로 조건 거는 방식으로
    해당 팀에 속한 Member 객체 모두 조회할 수 있는데 왜 굳이 양방향 연관관계를 써야할까?
  • DB 입장에서 조회 자체는 단방향으로 충분하다.
    하지만 JPA는 단순히 DB 저장용이 아니라, 자바 객체 간 관계(= 객체 그래프)를 모델링하는 도구이기 때문에
    “객체 입장에서의 탐색 편의성과 일관성”이 중요하다. 
  • 객체 그래프 탐색, 코드 구조, 영속성 컨텍스트 성능 최적화 측면을 고려할 때 양방향 연관관계를 사용해야 한다.
//양방향 연관관계가 있는 경우
for (Team team : teams) {
    System.out.println(team.getName());
    for (Member m : team.getMembers()) { // 객체 그래프를 탐색하는 객체지향적인 코드
        // JPA가 이 시점에 지연 로딩을 통해 
        // SELECT * FROM member WHERE team_id = ? 쿼리를 자동으로 실행함
        System.out.println(" - " + m.getName());
    }
}

//양방향 연관관계가 없는 경우 (단방향만 있는 경우)
for (Team team : teams) {
    System.out.println(team.getName());
    
    // 각 팀에 속한 멤버를 직접 쿼리로 조회해야 함
    // → JPA의 연관관계 매핑을 활용하지 못하므로 코드 분리가 발생하고, 객체지향성이 떨어짐
    List<Member> members = memberRepository.findByTeamId(team.getId());
    for (Member m : members) {
        System.out.println(" - " + m.getName());
    }
}

2) 양방향 매핑은 꼭 필요한 경우에만 사용하자

  • 사실 단방향 매핑만으로도 연관관계는 충분히 표현 가능하다.
    • JPA는 객체 간 참조만 명확히 되어 있으면, 외래 키 관리와 INSERT/SELECT 등 DB 작업을 잘 처리할 수 있다.
    • 따라서 기본적으로는 단방향 매핑만으로 설계를 마무리하는 것이 가장 이상적이다.
  • 양방향 매핑은 객체 지향적인 탐색 즉, 자바 객체의 탐색 편의성을 위해 추가적으로 사용하는 기능이다
    • 예: team.getMembers()처럼 역방향으로 객체 그래프를 탐색해야 하는 경우
    • 특히 JPQL을 사용할 때
      select m from Member m where m.team.name = :name 이런 쿼리도 필요하지만
      select t from Team t join t.members m where ... 처럼 역방향 탐색 수요도 많다
  • 단방향 매핑만 잘 설계해두면, 나중에 필요한 시점에 양방향을 붙여도 DB 테이블 구조는 전혀 변경되지 않는다
    • 즉, 양방향은 JPA 내부 객체 모델의 문제일 뿐, DB의 외래 키나 제약 조건에는 아무 영향도 없다
    • 따라서 초기에 무리해서 양방향을 남발하기보다는
      단방향으로 설계를 깔끔히 한 뒤, 정말 필요한 경우에만 양방향을 추가하는 것이 좋다