[TIL] 2026년 01월 07일
ORM 매핑이 필요한 이유
객체와 관계형 데이터베이스가 관계를 표현하는 방식의 근본적인 차이가 있습니다. 객체는 서로 다른 객체를 '참조'하며 관계를 맺습니다. 반면에 테이블은 '외래 키' 값을 통해 다른 테이블의 데이터를 연결하죠. 이 차이 때문에 ORM 매핑이 필요합니다.
🚀 메모리(객체) ↔ 저장소(테이블) 관계에서 JPA가 중간(영속성 컨텍스트)에서 무엇을 하는지
핵심: 코드에서는 객체 참조로 관계를 만들고, JPA가 그걸 DB의 FK로 바꿔서 저장한다.
1) 엔티티(객체) 쪽 코드: "객체 참조"로 관계 표현
@Entity
@Table(name = "TEAM")
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
@Entity
@Table(name = "MEMBER")
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID") // MEMBER 테이블에 TEAM_ID(FK) 컬럼이 생김
private Team team;
public void changeTeam(Team team) {
this.team = team;
}
}
- 여기서 Member.team은 teamId 같은 숫자가 아니라, Team "객체 자체"를 참조합니다.
- 그런데 @JoinColumn(name="TEAM_ID") 때문에, DB에는 MEMBER 테이블에 TEAM_ID(FK) 컬럼으로 저장됩니다. (ManyToOne이면 FK는 “소스 엔티티(여기서는 MEMBER) 테이블”에 놓입니다.)
2) "메모리에서 만들고 → 저장 대상으로 등록하고 → 커밋 때 DB에 반영" 흐름
// 보통 스프링이면 @Transactional 안에서 실행된다고 보면 됩니다.
Team team = new Team();
team.setName("TeamA"); //지금은 그냥 자바 객체(메모리)
em.persist(team); //영속성 컨텍스트에 등록(관리 시작)
Member member = new Member();
member.setUsername("member1");
member.changeTeam(team); //객체 참조로 관계 연결
em.persist(member); // member도 영속성 컨텍스트에 등록
// 트랜잭션 commit 시점에 flush가 일어나며 DB에 반영됨
tx.commit();
여기서 포인트는 3단계입니다.
- new Team(), new Member()
- 그냥 메모리의 자바 객체입니다. 아직 DB랑 무관합니다.
- em.persist(...)
- 객체를 영속성 컨텍스트(= EntityManager가 관리하는 영역)에 넣어서 "관리 대상(Managed)"으로 만듭니다. 그리고 "나중에 DB에 넣을 대상"으로 예약됩니다.
- commit(또는 flush)
- 변경 사항이 즉시 DB로 나가는 게 아니라, 보통 커밋 직전에 flush(동기화) 되면서 INSERT/UPDATE 같은 SQL이 DB에 전달됩니다.
3) find()가 "엔티티를 찾는다"는 게 코드에서 어떤 의미인지
Long teamId = 100L;
Team t1 = em.find(Team.class, teamId);
Team t2 = em.find(Team.class, teamId);
System.out.println(t1 == t2) // 같은 트랜잭션/같은 영속성 컨텍스트면 보통 true
em.find(Team.class, 100L)은 PK가 100인 Team 엔티티(객체)를 가져온다는 뜻이고, JPA는 "한 영속성 컨텍스트 안에서 같은 PK는 객체 1개만 존재"하도록 관리합니다.
그래서 같은 트랜잭션 범위에서 같은 PK를 두 번 찾으면 DB를 또 찍기보다 같은 객체를 재사용하는 형태가 됩니다.(1차 캐시/동일성 보장)
1) Team 엔티티 코드 풀이
@Entity
@Table(name = "TEAM")
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
- @Entity
- 이 클래스가 JPA가 관리하는 영속 객체(엔티티)라는 뜻입니다. 즉, DB에 저장/조회 할 대상으로 인식됩니다. 엔티티의 상태는 필드(또는 프로퍼티)로 표현되고, 엔티티는 최소 1개 이상의 @Id(PK)를 가져야 한다.
- @Table(name = "TEAM")
- 이 엔티티가 매핑될 테이블 이름을 TEAM으로 지정한 겁니다. @Table은 "이 엔티티의 기본 테이블이 무엇인지"를 지정합니다. (참고로 @Table을 안 쓰면 기본 규칙으로 테이블명이 결정됩니다.)
- @Id
- id 필드가 기본 키(PK)라는 뜻입니다. 엔티티를 구분하는 식별자 역할을 합니다.
- @GeneratedValue
- id 값은 애플리케이션이 직접 넣기보다, JPA/DB가 자동 생성하도록 맡기겠다는 의미입니다. @Id와 같이 쓰는 PK 생성 전략 지정입니다.
- private String name;
- 별도 표시가 없으면 기본적으로 DB 컬럼으로 매핑되는 영속 필드로 취급됩니다.(영속 제외는 @Transient 등으로 따로 표시)
2) Member 엔티티 코드 풀이("관계" 부분이 핵심)
@Entity
@Table(name = "MEMBER")
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID") // MEMBER 테이블에 TEAM_ID(FK) 컬럼이 생김
private Team team;
public void changeTeam(Team team) {
this.team = team;
}
}
- private Team team;
- 여기서 제일 중요한 포인트는 teamId(Long)가 아니라 Team “객체 참조”를 들고 있다는 것입니다.
- 즉, 자바 코드에서는 “멤버가 어느 팀인지”를 숫자 FK로 들고 있는 게 아니라 Team 객체를 가리키는 참조로 표현합니다.
- @ManyToOne
- “멤버 여러 명이 하나의 팀에 속할 수 있다”는 카디널리티(다:1) 를 선언한 겁니다. ORM이 이 관계를 엔티티 연관관계로 인식하게 됩니다.
- @JoinColumn(name = "TEAM_ID")
- 이 연관관계를 DB에서 표현할 때, MEMBER 테이블에 TEAM_ID라는 조인 컬럼(=외래키 컬럼)을 쓰겠다는 뜻입니다. @JoinColumn 자체가 “조인을 위해 사용할 컬럼”을 지정하는 애노테이션입니다.
- 결과적으로 DB에서는 대략 이런 모양이 됩니다.
- TEAM(id PK, name, …)
- MEMBER(id PK, username, TEAM_ID FK → TEAM.id)
- “그럼 member.team = team 하면 DB의 TEAM_ID는 누가 채워요?”
- Hibernate 같은 JPA 구현체는 @ManyToOne 연관관계가 세팅되면, 그에 맞는 FK 컬럼 값을 설정(동기화)하는 방식으로 동작합니다.
- 즉, 코드에서는 member.team을 세팅했는데, 저장될 때는 DB의 MEMBER.TEAM_ID가 채워지게 됩니다.
- changeTeam(Team team) 메서드
- 이건 JPA 필수 문법이라기보다는, 팀 변경이라는 행위를 메서드로 묶어서 캡슐화한 겁니다. 나중에 양방향 관계로 확장되면(Team이 members 컬렉션을 가질 때) 이 메서드 안에서 양쪽을 함께 맞추는 식으로 “관계 일관성”을 유지하기도 좋아집니다.
💡 여기서 말하는 teamId는 무엇일까?
teamId는 지금 엔티티 코드에 "필드로 존재하는 값"이 아니라, DB 관점에서 말하는 외래키(FK) 값을 쉽게 부른 표현입니다.
1) 코드에서 “teamId 필드”는 없음
- 필드 이름: team(타입은 Team)
- 어노테이션: @JoinColumn(name = "TEAM_ID")
여기서 @JoinColumn(name="TEAM_ID")는 “이 연관관계를 DB에 저장할 때, MEMBER 테이블의 TEAM_ID 컬럼을 조인 컬럼(외래키 컬럼)으로 쓰겠다”는 뜻이지, 자바 필드 이름을 teamId로 만들겠다는 뜻이 아닙니다.
2) 그럼 TEAM_ID(=teamId)는 어디에 있냐면 “DB 테이블 컬럼”으로 있다
자바 코드에서는 member.team이라는 객체 참조를 세팅하지만, 저장될 때는 DB에 MEMBER.TEAM_ID = TEAM.ID 형태로 들어갑니다. 이때 그 TEAM_ID 컬럼 값(=FK)을 흔히 말로 “teamId”라고 부르는 겁니다.
3) teamId 값을 코드에서 보고 싶으면?
엔티티에 teamId 필드가 없으니 보통은 이렇게 봅니다.
- member.getTeam().getId() → “팀 객체의 id”, 즉 DB의 TEAM_ID에 들어가는 값과 같은 의미
정리하면, teamId는 ‘자바 필드’가 아니라 ‘DB의 외래키 컬럼 값’을 의미하는 표현이고, JPA에서는 그 FK를 직접 들고 있기보다 Team team 참조로 관계를 모델링하는 게 기본입니다.
3) 양방향 매핑시 연관관계의 주인에 값을 입력
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
team.getMembers().add(member);
//연관관계의 주인에 값 설정
member.setTeam(team); //**
em.persist(member);
한 줄 씩 해석해보자
Team team = new Team();
team.setName("TeamA");
em.persist(team);
- Team 객체를 만들고 이름을 넣습니다.
- em.persist(team)로 Team을 영속성 컨텍스트에 등록해서 "저장 대상"으로 만듭니다. (보통 커밋/플러시 시점에 INSERT가 나갑니다.)
Member member = new Member();
member.setName("member1");
- Member 객체를 만들고 이름을 넣습니다. 아직 팀과의 관계는 안 잡힌 상태입니다.
team.getMembers().add(member);
- Team 쪽 컬렉션에 member를 추가합니다.
- 다만 Team.members가 mappedBy인 반대편(inverse side)이라면, 이 줄은 객체 그래프(메모리)에서만 연결을 만든 것에 가깝고, DB의 FK(TEAM_ID)를 “결정”하는 주체는 아닙니다.
member.setTeam(team); //**
- 이 줄이 핵심입니다. Member.team은 보통 @JoinColumn(TEAM_ID)가 붙은 연관관계의 주인(owning side) 이고, JPA는 이 쪽 값을 기준으로 MEMBER 테이블의 TEAM_ID(FK)를 채웁니다.
- 그래서 화면에 "양방향 매핑 시 연관관계의 주인에 값을 입력해야 한다"라고 적혀 있는 것입니다.
em.persist(member);
- Member도 영속 상태로 만들고, 커밋/플러시 때 저장되도록 합니다.
- 결과적으로 DB에는 MEMBER.TEAM_ID = TEAM.ID 형태로 FK가 들어가게 됩니다(주인 쪽을 세팅했기 때문에).
JPA에서 양방향 연관관계의 '연관관계 주인'은 무엇을 기준으로 결정하는 것이 가장 중요할까요?
외래 키가 데이터베이스 테이블 어디에 위치하는지가 연관관계 주인을 결정하는 핵심 기준입니다. 보통 N:1 관계에서 N쪽에 외래 키가 있으므로 N쪽이 주인이 됩니다.
JPA 양방향 연관관계에서 '연관관계 주인'의 역할은 무엇일까요?
연관관계 주인만이 데이터베이스에 있는 외래 키의 값을 변경하거나 등록할 수 있습니다. 주인 아닌 쪽(mappedBy 설정된 곳)은 외래 키 값을 읽기만 가능합니다.
JPA 연관관계를 설계할 때 권장되는 초기 접근 방식은 무엇일까요?
처음부터 양방향으로 설계하면 불필요하게 복잡해질 수 있습니다. 단방향으로 충분히 설계한 후, 애플리케이션 개발 중 역방향 조회가 정말 필요할 때 양방향을 추가하는 것이 좋습니다.
JPA 양방향 연관관계를 사용할 때, Lombok의 toString()이나 JSON 직렬화 라이브러리 사용 시 주의해야 할 가장 흔한 문제는 무엇일까요?
양쪽 엔티티가 서로를 참조하는 구조 때문에 toString이나 JSON 변환 시 서로를 반복적으로 호출하며 무한히 순환할 수 있습니다. 이는 애플리케이션 오류로 이어집니다.