객체는 member.getTeam() 처럼 참조(Reference)로 관계를 탐색하는 반면 테이블은 TEAM_ID 외래키(Foreign Key)를 이용해서 관계를 찾는다.
이 간극을 메워주는 것이 JPA의 연관관계 매핑이다.
"객체 지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다."
| 구분 | Member | Team |
|---|---|---|
| 테이블 모델 | member_id (PK), team_id (FK), name | team_id (PK), name |
| 객체 모델 (잘못된 설계) | id, teamId, name | id, name |
| 객체 모델 (정상적인 설계) | id, name, Team team | id, name, List<Member> members |
테이블은 FK로 연결되지만 객체는 참조 필드로 연결 되어야 객체지향적인 설계가 된다.
DB는 외래키 기반 관계, 객체는 참조 기반 관계, 이 둘을 매핑 시켜야 ORM이 완성된다.
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
@ManyToOne: 다대일 관계@JoinColumn: 외래키 컬럼 지정(TEAM_ID)Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeam(team); // 단방향 연관관계 설정
em.persist(member);
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);
member.setTeam(teamB); // 새로운 팀으로 변경
테이블은 FK 하나로 양방향 조회가 가능하지만 객체는 단방향 두개로 표현해야 한다.
| 객체 | 방향 | 설명 |
|---|---|---|
Member.team | 회원 → 팀 | 다대일 단방향 |
Team.members | 팀 → 회원 | 일대다 단방향 |
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
mappedBy = "team": 나는 주인이 아니고, Member 엔티티의 team 필드에 의해 매핑되었다 라는 뜻Member findMember = em.find(Member.class, member.getId());
List<Member> members = findMember.getTeam().getMembers();
for (Member m : members) {
System.out.println("m = " + m.getUsername());
}
-> findMember -> getTeam() -> getMembers() 순서로 객체 그래프를 따라간다.
객체는 가급적 단방향이 좋다. 단방향으로 충분히 설계가 가능하고 양방향은 조회(탐색) 편의 기능일 뿐이다.
이 부분이 좀 헷갈리지만 원리를 이해하면 단순하다.
| 구분 | 관계 개수 | 예시 |
|---|---|---|
| 객체 | 단방향 2개 | Member → Team, Team → Member |
| 테이블 | 외래키 1개 | MEMBER.TEAM_ID 하나로 양방향 가능 |
객체의 양방향 관계는 단방향 2개를 합친것이기 때문에 두 객체 중 하나만 외래키를 관리해야 한다.
| 규칙 | 설명 |
|---|---|
| 외래키가 있는 쪽이 주인 | (즉, Member.team이 주인) |
| 주인만 외래키 등록/수정 가능 | 주인이 아닌 쪽은 읽기 전용 |
주인이 아닌 쪽은 mappedBy 속성으로 주인 지정 | |
| 비즈니스 로직 기준 ❌ → 외래키 기준으로 판단해야 함 |
정리하면 외래키를 들고있는 쪽(Member)이 연관관계의 주인이다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
// ❌ 역방향(주인 아닌 쪽)만 설정
team.getMembers().add(member);
em.persist(member);
-> Member 테이블의 TEAM_ID가 null로 들어간다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
team.getMembers().add(member);
// 주인(Member.team)에 값 설정
member.setTeam(team);
em.persist(member);
JPA는 영속성 컨텍스트(1차 캐시)를 사용하기 때문에 flush/clear 전에 조회하면 DB가 아닌 메모리의 상태를 반환한다. 그래서 한쪽만 세팅하면 캐시 내 객체 상태가 불일치 할 수 있다.
순수 객체 상태를 유지하기 위해 항상 양쪽 다 설정하는 습관을 들여야 한다. 하지만 매번 두줄의 코드를 쓰면 실수 할 수 있으므로 편의 메서드를 만들어 두면 좋다
// 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);
}
단 두 방식 중 하나만 사용해야 한다. 둘 다 호출하면 중복추가나 무한루프 위험이 있다.
이름은 단순한 setter 보다는 changeTeam(), addMember()처럼 의미 있는 메서드명이 좋다.
양방향 매핑에서 toString(), lombok, JSON 변환 시, 서로 참조를 타고 돌다가 StackOverflowError가 발생할 수 있다.
Member.toString( → team.toString() → 다시 member.toString() …toString() 직접 생성 (Lombok 자동생성 지향)| 항목 | 요약 |
|---|---|
| 단방향 매핑만으로도 기능 완성 | 양방향은 탐색 편의 기능일 뿐 |
| 연관관계 주인은 외래키가 있는 쪽 | Member.team |
| 주인만 DB 외래키 등록/수정 가능 | mappedBy 사용 X |
| 주인이 아닌 쪽은 mappedBy로 주인 지정 | 읽기 전용 |
| 항상 양쪽 값 세팅 | 순수 객체 상태 유지 |
| 편의 메서드 작성 추천 | 실수 방지 |
| 양방향 무한루프 주의 | DTO 변환 필수 |
JPA의 연관관계 매핑은 객체의 참조와 테이블의 외래키 사이의 간극을 메워주는 기술이다.
단방향 매핑으로 관계를 명확히 표현하고, 필요할 때만 양방향을 추가하여 외래키 기준으로 주인을 명확히 설정해야 한다.
그리고 항상 객체 그래프 일관성을 유지하는 습관이 중요하다.