
엔티티들은 대부분 다른 엔티티와 연관 관계가 있다. 그렇기 때문에 객체와 테이블 연관 관계의 차이를 이해해야 하는데, 객체는 참조값을 통해 쭉쭉 찾아갈 수 있지만, 테이블에서는 외래키 값을 통해 찾아가야 한다. 여기서는 객체의 참조와 테이블의 외래키를 매핑하는 것이 목표다. 더 나아가 자율적인 객체들의 협력 공동체를 만들어야 한다는 객체 지향 설계의 목표를 달성해야 한다.
테이블 모델에서는 외래 키 하나만으로 양방향 탐색이 가능하다. 즉, 회원(Member) 테이블과 팀(Team) 테이블이 있을 때, 회원 테이블에 위치한 팀의 식별자(외래 키)를 사용하면 회원이 소속된 팀을 찾을 수 있고, 역으로 팀 식별자를 기준으로 조인하면 해당 팀에 소속된 모든 회원의 목록을 도출할 수 있다.

이 시나리오에서 객체를 정말 단순하게 테이블에 맞춰 모델링 한다고 하면 어떻게 될까? 팀과 멤버의 관계에서는 멤버를 INSERT할 때, TEAM_ID에 값을 넣으면서 여러 개의 멤버가 어떤 팀에 소속되었는지 알 수 있다.
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
// 회원은 팀 id를 가지고 있음
@Column(name = "TEAM_ID")
private Long teamId;
public Member() {};
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTeamId() {
return teamId;
}
public void setTeamId(Long teamId) {
this.teamId = teamId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
하지만, 테이블에 맞춰 외래키 값을 그대로 가지고 있다.

이렇게 객체를 테이블에 딱 맞춰서 모델링하게 되면 뭐가 문제인지 살펴보자. 일단 팀을 만들고 영속화 한 후, 회원을 저장하고, 만약 멤버1을 Team A에 소속 시키고 싶으면 TEAM_ID를 줘야 할 것이다. 근데 TEAM_ID는 어떻게 얻지? Team에서 em.persist(team)를 하게 되면 Team의 id 필드에 값이 들어가게 된다. 그래서 그냥 team.getId()로 꺼내면 된다. 그리고 em.persist(member)로 멤버를 저장해보자.
package hellojpa;
import jakarta.persistence.*;
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Team team = new Team();
team.setName("Team A");
em.persist(team); // team을 영속화
Member member = new Member();
member.setUsername("Member 1");
member.setTeamId(team.getId()); // TEAM_ID를 얻어서 멤버1에 설정
em.persist(member); // 그리고 멤버1 영속화
Member findMember = em.find(Member.class, member.getId());
Team findTeam = em.find(Team.class, team.getId());
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}
그러면 팀 쿼리가 나가고 멤버 쿼리가 나가는 것을 볼 수 있다.

멤버 입장에서는 FK 값에 1이 들어가야 한다. 덕분에 향후 필요하면 조인해서 연관된 멤버랑 팀을 뽑아낼 수 있다.

하지만 위의 과정은 외래키 식별자를 직접 다루고 있는 것이다. 그리고 em.find(Member.class, member.getId())처럼 조회할 때도 이슈가 있는데 내가 찾아온 멤버가 어느 팀 소속인지 알고 싶은데, 팀을 바로 가져오지 못 하니까 findTeamId라고 팀 아이디를 가져오기 위해 em.find()를 해서 팀을 찾은 다음, 또 팀을 꺼내야 한다. 이런 식으로 JPA에게 계속 물어봐야 하기 때문에 매우 객체 지향스럽지 못하다.
정리하자면, 객체를 테이블에 맞춰 데이터 중심으로 모델링 하게 되면, 협력 관계를 만들 수 없다는 것이다. 테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾고, 객체는 참조를 사용해서 연관된 객체를 찾는다. 패러다임이 완전히 다른 것이다.
객체 지향적인 아키텍처를 구성하기 위해서는 외래 키 값을 식별자 자체로 보유하는 대신, 해당 식별자가 가리키는 대상 엔티티의 참조를 객체 내부에 포함해야 한다.

위의 그림처럼 팀의 ID가 아니라 팀의 참조값을 그대로 가져오는 것이다.
package hellojpa;
import jakarta.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
// @Column(name = "TEAM_ID")
// private Long teamId;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team; // Team의 참조값을 그대로 가져온다.
public Member() {};
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Team getTeam() {
return team;
}
public void setTeam(Team team) {
this.team = team;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
그리고 위의 코드를 보면, JPA 프레임워크에는 이 참조가 데이터베이스 상의 어떤 외래 키 컬럼과 연동되는지를 명시해야 한다. 이때 사용되는 어노테이션이 @ManyToOne과 @JoinColumn이다.
@ManyToOne은 해당 엔티티가 다(N) 측이고 참조되는 엔티티가 일(1) 측임을 선언하며, @JoinColumn은 실제 데이터베이스 테이블에서 관계를 맺기 위해 사용되는 외래 키 컬럼의 물리적 이름을 매핑한다. Hibernate는 기본적으로 엔티티의 이름과 참조 속성의 이름을 조합하여 외래 키 컬럼을 추론하지만, 명시적인 @JoinColumn 설정을 통해 데이터베이스 명명 규칙과 일치시키는 것이 실무적 관례이다.
package hellojpa;
import jakarta.persistence.*;
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Team team = new Team();
team.setName("Team A");
em.persist(team);
Member member = new Member();
member.setUsername("Member 1");
member.setTeam(team);
em.persist(member);
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam(); // 팀을 바로 끄집어 낼 수 있음
System.out.println("findTeam: " + findTeam.getName());
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}

Hibernate는 이러한 단방향 매핑 정보를 바탕으로, 영속성 컨텍스트 내에서 객체의 상태 변화를 추적한다. 객체가 엔티티 매니저를 통해 영속화 될 때, Hibernate는 참조된 객체의 식별자를 추출하여 자동으로 외래 키 컬럼에 삽입한다.
데이터를 조회할 때에도 지연 로딩(Lazy Loading) 또는 즉시 로딩(Eager Loading) 전략에 따라 참조된 대상을 식별자를 통해 자동 조회하여 메모리 상에 객체 그래프를 복원해 낸다.
결과적으로 단방향 매핑만으로도 테이블의 외래 키를 관리하고 조인 쿼리를 수행하는 본연의 매핑 목적은 완벽히 달성되는 것이다. 구조적 단순함과 명확성 덕분에 도메인 모델 내에서 불필요한 참조 종속성을 줄일 수 있으며, 실무적으로 모든 연관 관계의 설계는 단방향에서 시작하는 것이 강력히 권장된다.
위에서 작성한 코드에서는 멤버에서 팀으로 갈 수는 있다. 그럼 팀에서 멤버로는 갈 수 있을까? 현재로서는 방법이 없다. 반대로 참조해서 찾아갈 수 있게 하려면 어떻게 해야 할까?

테이블 연관 관계를 보면 단방향에서와 바뀐게 없다. 멤버에서 내가 소속된 팀을 알고 싶으면 MEMEBER의 TEAM_ID랑 TEAM의 TEAM_ID를 조인하면 알 수 있고, 반대로 팀의 입장에서 우리 팀에 어떤 멤버들이 소속되어 있는지를 알고 싶으면 TEAM의 PK랑 MEMBER의 FK랑 조인하면 된다.
여기서 중요한 점은 테이블의 연관 관계는 외래키 하나로 양쪽으로 건너갈 수 있다는 것이다. 사실상 테이블의 연관 관계에서는 방향이라는 개념 자체가 없다.
근데 문제는 객체다. 이전 코드에서는 Member 클래스에서 team의 참조값을 가지고 있었다. 근데 Team 클래스를 보면 알겠지만, 아무것도 없다.
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
...
@Entity
public class Team {
// 그냥 뭐가 없음...
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
...
그렇기 때문에 List members라는 필드를 넣어 줘야 양쪽으로 갈 수 있다. 팀에서 멤버로는 일대다 이므로 @OneToMany 애노테이션을 적어주면 된다. 그리고 mappedBy를 하나 적어야 하는데, 이건 일대다 매핑에서 "난 주인의 어떤 컬럼이랑 연결되어 있지?" 를 해결해주는 것이다. mappedBy = "team"이라고 적어주자.
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team") // 난 team이랑 연결되어 있음
private List<Member> members = new ArrayList<>();
...
근데 mappedBy의 정체가 뭔지 좀 의문이다. mappedBy를 이해하기 위해서는 객체와 테이블 간에 연관 관계를 맺는 차이를 이해해야 한다.
객체의 연관 관계에는 키 포인트가 2가지가 있다. 회원에서 팀으로 가는 연관 관계 1개(단방향), 팀에서 회원으로 가는 연관 관계 1개(단방향), 그림을 다시 보면 알겠지만, Member에서는 team이라는 참조값으로 Team으로 갈 수 있고, 반대로 Team에서는 members라는 참조값으로 Member로 갈 수 있다. 단방향 연관 관계 2개가 있는 것이다. 근데 그냥 억지로 양방향 연관 관계라고 우긴 것이다.
문제는 관계를 수정할 수 있는 진입점이 두 곳으로 늘어났다는 것이다. 회원의 소속 팀을 변경할 때 회원의 팀 참조를 수정해야 하는지, 아니면 팀의 멤버 컬렉션 내부 요소에서 해당 회원을 제거하고 새로운 팀의 컬렉션에 추가해야 하는지 데이터베이스 매핑 계층에서는 판별할 수 없다.

그럼 둘 중에 뭘로 매핑 해야 하는거지? Member의 team 값을 바꿨을 때 MEMBER 테이블의 외래키 값이 업데이트가 돼야 하는건가, 아니면 Team 객체의 members를 업데이트 해야 하는건가? 예를 들어, 내가 멤버를 바꾸고 싶거나 어느 새로운 팀에 들어가고 싶다면 Member 객체의 team 참조를 바꿔야 하는건지, Team 객체의 members 참조를 바꿔야 할지 객체 입장에서는 어떻게 해야 할지 모른다.
이러한 문제를 해결하기 위해 JPA는 두 객체의 연관 관계 중 하나를 연관 관계의 주인(Owner of the Relationship)으로 명시하도록 강제한다. Member 객체에 있는 team 참조로 외래키를 관리할지, Team 객체에 있는 members 참조로 외래키를 관리할지 둘 중 하나로 연관 관계의 주인(Owner)을 정해야 한다.
연관 관계의 주인이란 실제 데이터베이스 테이블의 외래 키 값을 등록, 수정, 삭제할 수 있는 절대적 권한을 지닌 참조 필드를 의미한다. 주인이 아닌 반대편 필드는 외래 키의 변경에 어떠한 영향도 미칠 수 없는 단순한 읽기 전용으로 전락하게 된다.
양방향 연관 관계를 설정할 때, 주인이 아닌 측의 @OneToMany 매핑 애노테이션에는 반드시 mappedBy 속성을 부여해야 한다. mappedBy의 값으로는 연관 관계의 주인 역할을 하는 대상 엔티티 내의 필드 이름을 지정한다. 이 속성이 선언되는 순간 Hibernate의 영속성 컨텍스트는 해당 필드에 대한 쓰기 연산을 추적 스냅샷에서 제외하며, 트랜잭션 종료에 따른 데이터베이스 플러시 시점에도 해당 변경 사항을 철저히 무시한다.
즉, 연관 관계의 주인이 아닌 컬렉션에 새로운 엔티티를 추가하거나 제거하더라도 데이터베이스 상의 외래 키 컬럼은 절대 변경되지 않는다.
데이터베이스 트랜잭션과 정규화의 관점에서 볼 때 외래 키가 물리적으로 위치한 테이블과 1:1로 매핑된 엔티티, 다시 말해 다(N) 측의 엔티티가 항상 연관 관계의 주인으로 설정되어야 한다.
이래 놓고 나중에 members에 값 집어 넣고, DB에 값이 추가 안 된다고 JPA가 버그 걸린거 아니냐라는 소리를 한다면 이 개념을 전혀 이해하지 못한 것이다.
"외래키가 있는 곳을 주인으로 정하면 된다!"

양방향 매핑 시 가장 많이 발생하는 실수에 대해 알아보자.
바로 연관 관계의 주인에 값을 입력하지 않는 것이다. 멤버와 팀을 각각 하나씩 만들고, 팀에 멤버를 추가하고 실행해보자.
package hellojpa;
import jakarta.persistence.*;
import java.util.List;
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
// 멤버 하나 만들고,
Member member = new Member();
member.setUsername("Member 1");
em.persist(member);
// 팀 하나 만들고,
Team team = new Team();
team.setName("Team A");
team.getMembers().add(member); // 멤버를 추가(여기가 문제!)
em.persist(team);
em.flush();
em.clear();
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}


분명 INSERT 쿼리는 2방이 나갔는데, DB를 확인해보면, MEMBER 테이블에서 MEMBER_ID와 USERNAME은 있는데, TEAM_ID는 null이다? 연관 관계의 주인이 아닌 Team의 members에만 값을 저장했기 때문이다. 위의 코드에서 연관 관계의 주인인 Member의 team에 아무 것도 입력하지 않았기 때문에 TEAM_ID 외래키의 값도 null이 저장되는 것이다.
이렇게 역방향(주인이 아닌 방향)만 연관 관계를 설정해서 외래키 값이 null이 되는 것을 조심해야 한다. 객체 지향 관점에서 본다면, 양방향 매핑 시 가급적이면 양쪽에 모두 값을 넣어주는 것이 맞다.
결국 양쪽 다 신경써야 한다는 말이다. Member에서 member.setTeam(team)를 호출한다든지, Team에서 team.getMembers().add(member)를 각각 호출하다 보면 실수로 둘 중 하나만 호출해서 양방향이 깨질 우려가 있다. 따라서 양방향 관계에서 두 코드는 하나인 것처럼 사용하는 것이 안전하다. Member 클래스의 setTeam() 메서드를 아래와 같이 리팩토링 해보자.
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
// 사용 예시
member1.changeTeam(team1);
member2.changeTeam(team1);
문맥상 메서드명도 changeTeam()으로 변경하고, 양방향 관계를 모두 설정하도록 변경했다.
<최종 코드>
public void JpaMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
// 팀 하나 만들고,
Team team = new Team();
team.setName("Team A");
em.persist(team);
// 멤버 하나 만들고,
Member member = new Member();
member.setUsername("Member 1");
member.changeTeam(team); // 연관 관계의 주인에 값을 추가
em.persist(member);
em.flush();
em.clear();
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}

이제 정리하도록 하자. 단방향 매핑과 비교해서 양방향 매핑은 훨씬 복잡하다. 연관 관계의 주인도 정해야 하고, 2개의 단방향 연관 관계를 양방향으로 만들기 위해 로직도 잘 관리해야 한다. 여기서 중요한 사실은 연관 관계가 하나인 단방향 매핑은 언제나 연관 관계의 주인이라는 점이다. 양방향은 여기에 주인이 아닌 연관 관계를 하나 더 추가한 것 뿐이다. 주인이 아닌 쪽에 mappedBy로 주인을 지정해야 한다.
그리고 양방향은 비즈니스 로직의 필요에 따라 달라지겠지만 우선 단방향 매핑을 사용하고 반대 방향으로 객체 그래프 탐색 기능이 필요할 때 양방향을 사용하도록 코드를 추가해도 무방하다.
<참고 자료>
Map Associations with JPA and Hibernate – The Ultimate Guide
Understanding JPA/Hibernate Associations