JPA - 연관관계 매핑

KoK·2025년 6월 22일

JPA

목록 보기
4/8
post-thumbnail

전통적인 DB 설계 방식에서는 테이블간 관계를 외래키(FK) 로 연결한다.
예를 들어 Order테이블에 member_id컬럼이 존재한다면, 이 컬럼을 통해 Member테이블과 Order테이블 간의 연관관계가 있다는걸 알 수 있다.
하지만 JPA에서는 이렇게 외래 키만을 필드로 두는 것보다 실제 Java 객체 간의 참조를 기반으로 연관관계를 매핑하는 것이 핵심이다.

즉, order.memberId처럼 단순히 외래 키 값을 보관하는 것이 아니라, order.getMember()처럼 객체 자체를 직접 참조하는 방식이 권장된다. 이것이 바로 객체지향적인 설계와 데이터베이스 설계의 차이이며, JPA가 추구하는 방향이다.

1. 테이블에 객체를 맞춰 설계한다면?

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private String id;

    @Column(name = "USERNAME")
    private String username;

    @Column(name = "TEAM_ID")
    private Long teamId;
}
@Entity
@Getter
@Setter
public class Team {

    @Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

}
Member member = new Member();
member.setUsername("member1");
member.setTeamId(team.getId());
em.persist(member);
  • 위 코드는 그냥 보면 문제가 없는 코드이지만, Team을 참조하는 필드가 아닌 Team의 외래키인 Long teamId를 직접 필드로 두고 있다.
    이건 마치 DB테이블의 외래키컬럼에 값을 직접 넣는 것과 유사하다.
    하지만 이 방식은 객체지향보다는 DB에 의존적인 절차지향적인 코드라고 볼 수 있다.

1-1. 테이블중심 설계의 문제점

연관된 객체를 직접 다룰 수 없다.

Team team = findMember.getTeam(); 
// Member가 Team을 참조하는 필드를 가지고있는게 아니기 때문에 불가능

매번 아래와 같은 방식으로 접근해야 한다.

Long teamId = findMember.getTeamId();
Team team = em.find(Team.class, teamId);

1-2. 양방향 연관관계 설정 불가능

예를 들어, Team 입장에서 그 팀에 소속된 멤버들을 조회하고 싶다면
List< Member > 같은 필드는 사용할 수 없으며, 직접 쿼리를 날려야 한다.

1-3. JPA의 연관관계 관리 기능을 활용할 수 없다.

Cascade, orphanRemoval, FetchType.LAZY 같은 강력한 기능들은
객체 간 관계를 통해서만 동작한다. FK만 있으면 아무 의미 없다.

2. 객체 간 연관관계 매핑 시 이점

2-1. 객체 그대로 연결할 수 있다.

member.setTeam(team);
  • 이렇게 해주면 Member객체는 Team객체를 참조할 수 있고, Team객체도 List< Member > 로 구성된 멤버목록을 가질 수 있다.

2-2. 코드가 객체지향적이고 간결해진다.

System.out.println(member.getTeam().getName());
  • 객체끼리 연관관계 매핑을 통해 서로 참조하게 되면 위 코드들처럼 복잡하게 여러번 타고 들어가는게 아닌, 간결하고 객체지향적으로 데이터에 접근이 가능하다.

2-3. JPA가 연관된 객체를 자동으로 관리한다

  • 연관된 엔티티를 함께 저장 (Cascade)
  • 연관된 엔티티 삭제 (orphanRemoval)
  • 지연 로딩 (LAZY)으로 성능 최적화
  • 더 직관적인 코드 작성 가능

3. 테이블 중심 설계의 문제점 요약

  • 직접 FK 설정
    -> 객체를 직접 연결하지 않으므로 객체 간 탐색이 불가능
  • 데이터 중심 사고
    -> 설계가 SQL 중심이 되며, 객체지향 언어의 이점을 활용하지 못함
  • 코드 중복
    -> 반복적으로 em.find() 등을 사용해야 함
  • 기능 제한
    -> Cascade, FetchType, orphanRemoval 등이 적용되지 않음
  • 유지보수 어려움
    -> 관계 파악이 어려워지고, 실수도 잦아짐

4. 객체는 객체답게, 관계는 연관관계로

JPA는 단순히 SQL 대신 자동으로 쿼리를 만들어주는 도구가 아니다.
객체지향적인 코드로 설계한 엔티티들을 DB와 매핑해주는 기술이다.
그래서 우리는 "테이블 설계를 그대로 따라" JPA를 사용하면 안 된다.
객체는 객체답게 설계하고, 관계는
@ManyToOne, @OneToMany로 명확히 매핑해줘야
JPA의 모든 기능과 성능을 제대로 활용할 수 있다.

5. 1:1, 1:N, N:1, N:M

JPA에서는 객체 간의 관계를 DB 테이블의 외래키를 이용해 매핑한다.
하지만 객체 지향적으로 설계하기 위해선 단순히 외래키만 사용할 게 아니라, 정확한 방향성과 연관관계 매핑 어노테이션을 잘 이해하고 써야 한다.

5-1. 1:1 (OneToOne)

- 한 엔티티가 다른 엔티티와 1:1로만 연결될 때

예: User ↔ UserProfile

@Entity
public class User {
	@Id @GeneratedValue
    private Long id;
    
    @OneToOne
    @JoinColumn(name = "profile_id) // FK
    private UserProfile profile;
}
@Entity
public class UserProfile {
	@Id @GeneratedValue
    private Long id;
    
    @OneToOne(mappedBy = "profile")
    private User user;
}

5-2. 1:N (OneToMany)

  • 한 엔티티가 여러 개의 엔티티를 가질 때
    예: Team → 여러 Member
@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    @OneToMany
    @JoinColumn(name = "team_id") // FK는 member 테이블에 생김
    private List<Member> members = new ArrayList<>();
}
  • 하지만 단방향 1:N은 실제로 잘 사용하지 않음.
    → 이유: JPA가 중간 테이블을 안 쓰고 외래키를 업데이트해야 하기 때문.
    해결책: N:1 쪽에서 매핑하고, 양방향으로 쓰자
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}
@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "team") // 읽기 전용
    private List<Member> members = new ArrayList<>();
}

5-3. N:1 (ManyToOne)

  • 여러 개의 엔티티가 하나의 엔티티와 연결될 때
    예: 여러 Member → 하나의 Team (가장 자주 사용됨)
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "team_id") // FK
    private Team team;
}

5-4. N:M (ManyToMany)

  • 서로 다대다 관계일 때
    → 예: Student ↔ Course
@Entity
public class Student {
    @Id @GeneratedValue
    private Long id;

    @ManyToMany
    @JoinTable(name = "student_course",
               joinColumns = @JoinColumn(name = "student_id"),
               inverseJoinColumns = @JoinColumn(name = "course_id"))
    private List<Course> courses = new ArrayList<>();
}
@Entity
public class Course {
    @Id @GeneratedValue
    private Long id;

    @ManyToMany(mappedBy = "courses")
    private List<Student> students = new ArrayList<>();
}
  • @ManyToMany는 중간 테이블에 추가 컬럼(등록일 등)을 못 넣기 때문에, 실무에선 중간 엔티티(예: Enrollment)를 만들어 1:N + N:1로 풀어냄

JPA에서는 항상 연관관계의 방향과 주인을 정확히 설계하고,
필요시 mappedBy, JoinColumn, JoinTable을 올바르게 지정해줘야 한다.

6. 연관관계의 주인과 mappedBy

6-1. 연관관계의 주인이란?

연관관계의 주인(Owner)은 DB의 외래키(FK)를 관리하는 엔티티이다.
즉, 어떤 쪽이 외래키 값을 insert/update할 책임이 있는지를 나타낸다.

JPA는 '주인'만 외래키를 관리할 수 있다.

6-2. mappedBy란?

mappedBy는 반대편에서 연관관계의 주인을 지정하는 속성이다.
mappedBy = "team" → 이 말은 "이 연관관계의 관리는 Member.team이 함"이라는 의미이다.

즉, mappedBy가 붙은 쪽은 읽기 전용, insert/update에 관여하지 않는다.

Member N:1 Team (ManyToOne — 외래키 주인)

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "team_id") // FK 관리 → 이게 '연관관계 주인'
    private Team team;
}

Team 1:N Member (OneToMany — 주인이 아님)

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "team") // 'team'은 Member 엔티티 안에 있는 필드명
    private List<Member> members = new ArrayList<>();
}

6-3. 왜 이렇게 나눠야할까?

  • 양방향 관계는 실제로는 단방향 두 개가 아닌, 하나의 외래키만 존재
  • DB에서는 외래키가 member.team_id 하나인데, 양쪽에서 insert, update를 하면 충돌 위험
  • 그래서 한쪽만 외래키를 관리해야 하며, 그게 바로 연관관계의 주인
profile
개발 이것저것

0개의 댓글