Stay Hungry Stay Foolish

TIL

[TIL] 2026년 01월 07일

dev스카이 2026. 1. 7. 17:02

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.teamteamId 같은 숫자가 아니라, 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단계입니다.

  1. new Team(), new Member()
    • 그냥 메모리의 자바 객체입니다. 아직 DB랑 무관합니다.
  2. em.persist(...)
    • 객체를 영속성 컨텍스트(= EntityManager가 관리하는 영역)에 넣어서 "관리 대상(Managed)"으로 만듭니다. 그리고 "나중에 DB에 넣을 대상"으로 예약됩니다.
  3. 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.membersmappedBy반대편(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 변환 시 서로를 반복적으로 호출하며 무한히 순환할 수 있습니다. 이는 애플리케이션 오류로 이어집니다.