이전에 @ManyToOne으로 모든 연관관계를 풀어낼 수 있다고 했습니다.
이를 잘 이해하려면 데이터베이스의 테이블의 구조에 대해 한 번 생각해보면 됩니다.
다시 Team과 Member의 양방향 매핑 코드를 보겠습니다.
@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<>();
// ...
}
이 두 엔티티 클래스 코드에서 Member 엔티티에는 Team team이 존재하고,
Team 엔티티에는 List<Member> members가 존재한다.
❌ 데이터베이스의 데이터(한 칸)에 객체나 리스트의 데이터가 들어갈 수 있을까?
→ 불가능하다!
그런데 이 말에 따르면, List<Member> members 라는 건 코드 상으로는 가능하지만, 데이터베이스의 입장에서는 불가능한 일입니다.
실제 데이터베이스에서는 양방향이라는 건 존재하지 않습니다. 오직 단방향만 존재합니다.
따라서,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
@ManyToOne을 사용하는 이 단방향만 있으면 모든 연관관계 매핑을 개발할 수 있는 것입니다.
[ 이와 관련한 @OneToMany 이야기 ]
1. @ManyToOne이 존재해야만 @OneToMany가 가능(단방향이 있어야만 양방향이 존재)
2. 양방향은 실제로는 존재하지 않지만, JPA가 양방향처럼 동작하게 만듬
3. 실무에서는 대부분 양방향 매핑을 쓰지 않는 방향으로 개발을 진행
@ManyToMany를 사용하면 가운데 테이블을 암묵적으로 만들어내는 것을 배웠습니다.
이를 @ManyToOne으로 풀어내는 건 간단합니다. 중간 테이블을 직접 생성해주면 됩니다!.
오히려 @ManyToOne으로 풀어내는 것이 더 명시적이고 쉬울 수 있습니다.
@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<>();
}
@Getter
@Entity
@Table(name = "authors")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
@Getter
@Entity
@Table(name = "books")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
}
@Getter
@Entity
@Table(name = "book_author")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BookAuthor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "book_id")
private Book book;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
}
이렇게 직접 ‘BookAuthor’ 테이블을 만들어서 명시적으로 중간 테이블을 관리할 수 있습니다.
(아래의 예시를 기반으로 진행)
@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;
// ...
}
fetch = FetchType.LAZY: 연관 엔티티(Team)를 프록시로 지연 로딩(쿼리 최적화/순환 로딩 방지)
optional: 이 연관관계가 필수인지(=null 허용 X) 여부를 정의
optional = false → 필수 관계. JPA에서 null을 허용하지 않음을 의미.optional = true(default 값) → 선택 관계. null 허용.name: FK 컬럼명 (ex. @JoinColumn(name = "team_id") )
unique: 유니크 제약 부여 여부(기본: false).
true로 설정하면 여러 Member가 같은 Team을 가리킬 수 없게 되어 사실상 1:1 제약이 됩니다.DB 입장에서 @OneToOne이라는 건, 본질적으로는 존재하지 않습니다.
실제로는 이 또한 다대일 관계입니다. 다만 하나 있느냐, 여러개 있느냐의 차이입니다.
하나 있는 경우 편의성을 위해 JPA에서 @OneToOne이라는 도구를 제공해주고 있습니다.
즉, @JoinColumn(unique=true)를 사용하면 @OneToOne과 동일한 기능을 하게 됩니다.
nullable: FK 컬럼의 NULL 허용(기본: true).
@ManyToOne(optional = false)와 항상 짝을 맞추세요.foreignKey: FK 제약 메타데이터.
@ForeignKey(name = "fk_member_team", value = ConstraintMode.XXX) 형식.name: DB에 생성될 FK 제약 이름value: FK 생성 모드.ConstraintMode.CONSTRAINT(default 값)→ 실제 FK 제약 생성ConstraintMode.NO_CONSTRAINT → 실제 FK 제약 미생성(논리적 연관만; 다만 무결성 보장은 애플리케이션/배치로 책임져야 함)@JoinColumn 모든 옵션 사용 예시
@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, optional = false)
@JoinColumn(
name = "team_id", // FK 컬럼명
unique = true, // 유니크 제약 부여 (1:1 관계와 동일한 효과)
nullable = false, // FK 컬럼의 NULL 불허
foreignKey = @ForeignKey(
name = "fk_member_team", // DB에 생성될 FK 제약 이름
value = ConstraintMode.CONSTRAINT // 실제 FK 제약 생성
)
)
private Team team;
// 생성자, 기타 메서드 등...
}
@ManyToOne는 항상 LAZY (선택권)optional=false와 nullable=false를 사용하고 아니라면 설정하지 않는 것을 권장✅ FK가 null이면 안되는 경우
@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, optional = false)
@JoinColumn(name = "team_id", nullable = false)
private Team team;
// ...
}
✅ FK가 null이어도 되는 경우
@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;
// ...
}