회원 멤버와 프로필 사진처럼 1:1로 매핑되는 관계입니다.
핵심
외래 키(FK)를 어디에 둘지 선택해야 합니다.
Member가 Profile를 사용하는 관계라면, Member 테이블에 profile_id를 둡니다. Member가 주인이 되어 조회 시 더 직관적입니다.Profile 테이블에 profile_id를 둡니다. 이 경우 Profile가 주인이 됩니다.헷갈린다면,
“누가 먼저냐”를 따져보는 것도 좋습니다.
이 세상에 사람이 먼저일지, 사람의 사진이 먼저일지 생각해본다면사람이 우선입니다.
사람이 존재해야 사진도 존재하니까요!
따라서 위 경우에 Member 테이블이 주인(주도 테이블)이 되는 것이 자연스럽습니다.
코드 예시
@Getter
@Entity
@Table(name = "members")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private Profile profile;
// ...
}
@Getter
@Entity
@Table(name = "profiles")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Profile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String imageUrl;
// ...
}
Member → Team처럼, '다수' 쪽에서 '단일' 쪽을 참조하는 관계입니다. 사실상 거의 모든 관계의 핵심입니다!
핵심
연관관계의 주인이 되는 쪽입니다. (@JoinColumn으로 FK를 관리)
🚨 중요! ⭐⭐⭐⭐⭐
모든 연관관계를 @ManyToOne 하나만으로 정의 가능합니다.
코드 예시
@Getter
@Entity
@Table(name = "members")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
// ...
}
@Getter
@Entity
@Table(name = "teams")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// ...
}
Team → List<Member>처럼, '단일' 쪽에서 '다수' 쪽을 참조하는 관계입니다.
핵심
@OneToMany는 @ManyToOne이 반드시 매핑 관계에 있는 엔티티에 정의되어 있어야 사용할 수 있습니다.
따라서 @OneToMany를 사용하기 전에 @ManyToOne을 먼저 정의해야합니다.
코드 예시
@Getter
@Entity
@Table(name = "members")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
// ...
}
@Getter
@Entity
@Table(name = "teams")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
// ...
}
한 권의 책은 여러 명의 저자를 가질 수 있고, 한 명의 저자는 여러 권의 책을 집필할 수 있습니다.
이처럼, 다대다 관계를 표현합니다.
핵심
⚠️ 실무에서도, 연습에서도
@ManyToMany를 절대 사용하지 않습니다.
코드 예시
@Getter
@Entity
@Table(name = "books")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
// Book이 관계의 주인 (조인 테이블 생성)
@ManyToMany
@JoinTable(
name = "book_author", // 자동으로 생성되는 조인 테이블
joinColumns = @JoinColumn(name = "book_id"), // 현재 엔티티의 FK
inverseJoinColumns = @JoinColumn(name = "author_id") // 반대쪽 엔티티의 FK
)
private List<Author> authors = new ArrayList<>();
}
@Getter
@Entity
@Table(name = "authors")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(mappedBy = "authors")
private List<Book> books = new ArrayList<>();
}
사용하지 않아야하는 이유
코드를 보면 Book과 Author 엔티티 클래스가 정의되어있습니다.
하지만 실제로 테이블은 Book과 Author만 생성되지 않습니다.
(BOOK_AUTHOR라는 중간 테이블이 자동으로 만들어짐.)
이런 식으로 코드로 작성하지 않은 숨겨진 SQL이 실행되기 때문에 추후에 수정 사항이 있어도 유지보수가 어렵습니다.
💡 예상되는 문제점들
💡 비유를 통해 알아보기
FetchType.EAGER(즉시 로딩): 햄버거와 감자튀김을 반드시 세트로만 시킬 수 있는 패스트푸드점입니다. 원하든 원치 않든 무조건 햄버거와 감자튀김이 세트로만 나옵니다.FetchType.LAZY(지연 로딩): 햄버거만 먼저 받고, 나중에 감자튀김이 먹고 싶어지면 따로 주문할 수 있는 패스트푸드점입니다. 감자튀김은 먹어도 되고, 먹지 않아도 됩니다.
EAGER는 이름 그대로 '즉시' 로딩하는 전략입니다. 부모 엔티티를 조회하면, 연관된 자식 엔티티까지 한 번에 모두 조회합니다.
@Getter
@Entity
@Table(name = "members")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
@JoinColumn(name = "team_id")
private Team team;
// ...
}
(코드에서 @ManyToOne(fetch = FetchType.EAGER)를 확인!)
@Getter
@Entity
@Table(name = "teams")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// ...
}
Team과 Member가 이미 존재하는 상황에서 Member를 조회하면,(SELECT 쿼리가 딱 1번 실행!)Team과 Member를 join문을 사용하여 동시에 가져오는 것을 로그로 확인해볼 수 있습니다.
LAZY는 '지연'해서 로딩하는 전략입니다. 부모 엔티티를 조회할 때는 연관된 자식 엔티티를 가져오지 않고, 실제로 그 데이터가 필요한 시점에 비로소 DB에서 조회합니다.
@Getter
@Entity
@Table(name = "members")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
@JoinColumn(name = "team_id")
private Team team;
// ...
}
(코드에서 @ManyToOne(fetch = FetchType.LAZY)를 확인!)
@Getter
@Entity
@Table(name = "teams")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// ...
}
Team과 Member가 이미 존재하는 상황에서 Member를 조회하면,(SELECT 쿼리가 2번 실행!)
Member 데이터 따로, Team 테이블을 따로 쿼리해서 가져오는 것을 확인할 수 있습니다.
FetchType.LAZY를 사용했을 때, 실제로 데이터베이스에서 연관 데이터를 가져오지 않는다고 했습니다.
Member 엔티티 객체의 team 필드는 그럼 어떻게 되는 걸까요?
@Transactional(readOnly = true)
public GetMemberResponse findOne(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(
() -> new IllegalStateException("없는 멤버입니다.")
);
System.out.println("팀 확인: " + member.getTeam().getClass().getName());
return new GetMemberResponse(
member.getId(),
member.getUsername(),
member.getTeam().getId(),
member.getTeam().getName()
);
}
(위와 같은 코드를 실행하여 Member를 조회하는 상황)
null이거나 실제 객체라고 생각할 수 있겠지만, 실제로는 프록시 객체가 매핑되어있습니다..
($HibernateProxy라고 적혀있는 것을 확인 가능하다.)
그리고 실제 사용할 때, 실제 객체의 데이터를 내부적으로 로드하여 마치 실제 객체처럼 행동하게 됩니다. (이를 Proxy Initialization이라고 합니다)
JPA의 프록시 객체는 마치 책의 목차와 같습니다. 우리가 특정 정보를 찾을 때 책 전체를 다 읽지 않고 목차부터 보듯이, JPA도 Member를 조회할 때 Team 정보는 목차(Proxy)만 살짝 가져옵니다. 그리고 우리가 실제로 그 Team 정보를 펼쳐볼 때 해당 페이지(데이터)를 읽어오는 방식으로 최적화를 한 것입니다.