엔티티끼리 관계를 맺을 때 붙이는 어노테이션을 코드와 함께 정리했습니다. 지난 글에서 개념으로만 짚었던 연관관계의 주인이 코드에서 어떻게 드러나는지에 초점을 맞췄어요.
예제는 Spring Boot 3 (jakarta.persistence)와 Lombok 기준입니다.
| 어노테이션 | 관계 | 예시 | FK 위치 (주인) |
|---|---|---|---|
@ManyToOne | 다대일 | 회원 N : 팀 1 | N쪽 (회원) |
@OneToMany | 일대다 | 팀 1 : 회원 N | 보통 mappedBy로 읽기 전용 |
@OneToOne | 일대일 | 회원 1 : 사물함 1 | 주 테이블 쪽 (선택) |
@ManyToMany | 다대다 | 회원 N : 상품 N | 연결 테이블 (실무에서는 지양) |

회원(N)이 팀(1)에 소속되는 관계입니다. DB에서 FK는 항상 N쪽에 생기기 때문에, @ManyToOne이 붙은 쪽이 자연스럽게 연관관계의 주인이 됩니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id") // FK 컬럼명
private Team team;
public Member(String name, Team team) {
this.name = name;
this.team = team;
}
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
public Team(String name) {
this.name = name;
}
}
Hibernate가 만들어주는 DDL입니다.
create table member (
id bigint generated by default as identity,
name varchar(255),
team_id bigint,
primary key (id)
);
alter table member
add constraint fk_member_team
foreign key (team_id) references team (id);
@JoinColumn(name = "team_id"): FK 컬럼을 지정합니다. 생략하면 필드명_PK컬럼명(team_id)으로 자동 생성됩니다.fetch = LAZY: @ManyToOne의 기본값은 EAGER이므로, 명시적으로 LAZY를 지정하는 습관을 들이는 게 좋습니다.Team team = new Team("백엔드");
em.persist(team);
Member member = new Member("hyun", team);
em.persist(member); // INSERT INTO member (name, team_id) VALUES ('hyun', 1)
팀에서 소속 회원을 꺼내고 싶다면 Team 쪽에도 컬렉션을 추가합니다. 이건 DB가 아니라 객체 탐색을 위한 편의 기능이고, 테이블 구조는 그대로입니다.
@Entity
public class Team {
// ...
@OneToMany(mappedBy = "team") // Member.team 필드가 주인
private List<Member> members = new ArrayList<>();
}
mappedBy = "team"은 "연관관계의 주인은 Member의 team 필드이고, 나는 읽기만 한다" 는 선언입니다.
Team team = new Team("백엔드");
em.persist(team);
Member member = new Member("hyun", null);
team.getMembers().add(member); // ❌ 주인이 아닌 쪽에만 세팅
em.persist(member);
// INSERT INTO member (name, team_id) VALUES ('hyun', null) ← FK가 null!
FK는 주인(Member.team) 의 값으로만 저장됩니다. 그렇다고 주인에만 세팅하면, 같은 트랜잭션 안에서 team.getMembers()가 비어 있는 상태가 됩니다. 그래서 양쪽 모두 세팅하는 연관관계 편의 메서드를 만들어 둡니다.
public class Member {
// ...
public void changeTeam(Team team) {
if (this.team != null) {
this.team.getMembers().remove(this); // 기존 팀에서 제거
}
this.team = team;
team.getMembers().add(this); // 새 팀에 추가
}
}

toString(), Lombok @ToString: Member와 Team이 서로를 출력하며 무한 루프가 생깁니다. 연관관계 필드는 toString에서 제외합니다.회원 1명이 사물함 1개를 가지는 관계입니다. FK는 둘 중 어느 쪽 테이블에 둬도 됩니다.
@Entity
public class Member {
// ...
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "locker_id") // member 테이블에 FK
private Locker locker;
}
@Entity
public class Locker {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String number;
@OneToOne(mappedBy = "locker") // 양방향이 필요할 때만
private Member member;
}
create table member (
id bigint generated by default as identity,
name varchar(255),
locker_id bigint unique, -- 1:1 이라서 unique
primary key (id)
);
mappedBy(주인이 아닌) 쪽의 @OneToOne은 LAZY를 지정해도 즉시 로딩처럼 동작하는 경우가 많습니다. FK가 없는 쪽은 연관 객체가 null인지 확인하려면 어차피 조회를 해야 해서, 프록시를 만들 수 없기 때문입니다.회원은 여러 상품을 주문하고, 상품도 여러 회원에게 주문됩니다. 코드로는 @ManyToMany 한 줄이면 됩니다.
@ManyToMany
@JoinTable(
name = "member_product",
joinColumns = @JoinColumn(name = "member_id"),
inverseJoinColumns = @JoinColumn(name = "product_id")
)
private List<Product> products = new ArrayList<>();
JPA가 member_product 연결 테이블을 알아서 만들어주지만, 실무에서는 사용하지 않습니다.
@ManyToMany를 @OneToMany + @ManyToOne 두 개로 쪼갭니다.
@Entity
public class MemberProduct {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
private int count; // 연결 테이블에 추가 컬럼 가능
private LocalDateTime orderedAt;
}
// Member, Product 쪽
@OneToMany(mappedBy = "member")
private List<MemberProduct> memberProducts = new ArrayList<>();

부모 엔티티의 상태 변화를 자식에게 함께 전파합니다.
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Member> members = new ArrayList<>();
Team team = new Team("백엔드");
Member hyun = new Member("hyun", team);
Member kim = new Member("kim", team);
team.getMembers().add(hyun); // cascade는 부모의 컬렉션을 따라 전파되므로
team.getMembers().add(kim); // 컬렉션에 담아야 함
em.persist(team); // Team만 persist 했는데 Member 2명도 함께 INSERT
| 옵션 | 동작 |
|---|---|
PERSIST | 부모를 저장할 때 자식도 저장 |
REMOVE | 부모를 삭제할 때 자식도 삭제 |
MERGE | 병합 시 자식도 병합 |
ALL | 위 전부 |
부모와의 연관관계가 끊어진 자식을 자동으로 DELETE 합니다.
team.getMembers().remove(0); // 컬렉션에서만 제거했는데
// 커밋 시 DELETE FROM member WHERE id = ? 실행
⚠️
cascade와orphanRemoval은 자식의 생명주기를 부모가 완전히 책임지는 경우(예: 주문과 주문상품)에만 사용합니다. 다른 엔티티도 참조하는 대상(예: 회원이 여러 곳에서 참조됨)에 걸면 의도치 않게 데이터가 삭제될 수 있습니다.

| 상황 | 선택 |
|---|---|
| N:1 | @ManyToOne(fetch = LAZY) + @JoinColumn, N쪽이 주인 |
| 반대 방향 조회가 필요 | @OneToMany(mappedBy = ...) 추가 + 연관관계 편의 메서드 |
| 1:1 | 주 테이블(자주 조회하는 쪽)에 FK, @OneToOne(fetch = LAZY) |
| N:N | @ManyToMany 대신 연결 엔티티 |
| 자식 생명주기를 부모가 관리 | cascade = ALL + orphanRemoval = true |
실무 체크리스트
LAZY로 지정한다.toString과 JSON 응답(DTO 변환)에서 순환을 끊는다.@ManyToMany는 쓰지 않는다.다음 글에서는 이렇게 맺은 연관관계를 조회할 때 생기는 N+1 문제와 fetch join을 코드로 정리해보겠습니다.