02 — OneToMany & ManyToOne (1:N · N:1)
질문:
User한 명이Post여러 개를 가질 때, 어느 쪽 테이블에 FK가 있고 어느 데코레이터를 어느 쪽에 두는가? 한 줄 답:@OneToMany와@ManyToOne은 항상 쌍으로 정의되고, FK는 항상 Many 쪽 테이블에 산다 — RDB의 1:N을 표현하는 단 하나의 물리적 방식이고, TypeORM은 이걸 강제한다.
한 장 그림
Why — 왜 Many 쪽에 FK가 있어야 하는가
RDB에서 1:N을 표현하는 물리적 방식은 하나뿐이다.
| 시도 | 결과 |
|---|---|
One 쪽에 FK 배열 (postIds INT[]) | 정규화 깨짐 — PostgreSQL이 array를 지원해도 FK 무결성 보장 안 됨 |
| One 쪽에 FK 한 개 | 1:N이 아니라 1:1이 됨 |
| Many 쪽에 FK ★ | 각 row가 자기 부모를 정확히 한 명 가리킴 — 정규화 충족 |
결정적 사실: Many 쪽에 FK를 두면 모든 row가 단 하나의 부모를 가리키고,
WHERE userId = ?한 줄로 그 부모의 모든 자식을 가져올 수 있다. 이게 정규형의 본질.
이 사실 위에서 TypeORM의 데코레이터 쌍이 결정된다:
@ManyToOne이 FK를 가진 쪽 (소유 쪽, owning side)@OneToMany는 반대 방향 탐색용 (inverse side)
@OneToMany만 단독으로 불가능하다 — FK가 어디 있는지 알 수가 없기 때문.
How — 코드의 표준 형태
1) 양방향 (가장 흔함)
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@Column() name: string;
@OneToMany(() => Post, (post) => post.user) // ★ inverse side
posts: Post[];
}
// post.entity.ts
@Entity()
export class Post {
@PrimaryGeneratedColumn() id: number;
@Column() title: string;
@ManyToOne(() => User, (user) => user.posts) // ★ owning side
user: User;
}생성되는 스키마:
CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR);
CREATE TABLE posts (
id INT PRIMARY KEY,
title VARCHAR,
userId INT, -- ★ FK가 posts에
FOREIGN KEY (userId) REFERENCES users(id)
);
@JoinColumn이 없어도 동작한다 —@ManyToOne은 자동으로 FK 컬럼을 만든다.@JoinColumn은 컬럼명 커스터마이즈 용도일 뿐.
2) @ManyToOne만 — 단방향
@Entity()
export class Post {
@PrimaryGeneratedColumn() id: number;
@Column() title: string;
@ManyToOne(() => User) // ★ inverse 콜백 없음
user: User;
}
// User에는 posts 필드를 두지 않음스키마 동일. user.posts로는 못 가지만 post.user는 가능. Post에서 User만 보는 경우가 흔하므로, 단방향 @ManyToOne은 완전히 정상적인 형태다.
3) @OneToMany만 — 작동하지만 권장하지 않음
@Entity()
export class User {
@OneToMany(() => Post, (post) => post.user)
@JoinColumn() // ★ 강제로 사용
posts: Post[];
}TypeORM이 Post에 userId FK를 만들긴 한다. 하지만 Post 엔티티에 user 필드가 없으면 save 시점에 어색한 동작이 발생한다 — 권장은 반드시 @ManyToOne을 같이 정의.
4) @JoinColumn 옵션으로 컬럼명 변경
@ManyToOne(() => User)
@JoinColumn({ name: 'author_id' }) // ★ posts.author_id
author: User;What — 1:1 vs 1:N의 비교
| 항목 | OneToOne | OneToMany / ManyToOne |
|---|---|---|
| FK 위치 | @JoinColumn 붙은 쪽 (자유) | 항상 Many 쪽 (강제) |
| FK 컬럼의 UNIQUE | 자동으로 있음 | 없음 (그래서 N개 가능) |
| 단방향 표현 | @JoinColumn 한 쪽만 | @ManyToOne 한 쪽만 (Many → One 방향) |
| 양방향에 필요한 콜백 | inverseSide 필수 | inverseSide 필수 |
| 흔한 안티패턴 | 양쪽에 @JoinColumn | @OneToMany만 정의 (@ManyToOne 빠뜨림) |
핵심: 1:1은 FK 위치를 고를 수 있고, 1:N은 고를 여지가 없다. RDB가 그렇게 정해 놓았다.
What-if — 자주 만나는 함정
1) @OneToMany만 정의하고 @ManyToOne을 빠뜨림
// User
@OneToMany(() => Post, (post) => post.user) // ★ post.user를 가리킴
posts: Post[];
// Post — user 필드 자체가 없음 ★결과: TypeORM 컴파일 단계는 통과하지만 런타임에 관계가 안 풀린다. userRepo.findOne({ relations: { posts: true } }) 호출 시 posts를 못 찾는다 — 콜백이 가리키는 post.user가 존재하지 않으니까.
규칙:
@OneToMany는 절대 단독으로 쓰지 않는다 — 반드시 반대편@ManyToOne이 짝.
2) 양쪽 콜백 방향이 어긋남
// User
@OneToMany(() => Post, (post) => post.writer) // ★ writer
posts: Post[];
// Post
@ManyToOne(() => User, (user) => user.posts)
user: User; // ★ 필드명은 user결과: TypeORM이 서로 다른 두 관계로 인식 → FK가 예상치 못한 곳에 생기거나 NULL이 가득. 콜백이 가리키는 필드명이 실제 필드명과 정확히 일치해야 한다.
3) cascade: true를 양쪽에 줌
@OneToMany(() => Post, (p) => p.user, { cascade: true })
posts: Post[];
@ManyToOne(() => User, (u) => u.posts, { cascade: true })
user: User;결과: userRepo.save(user) → posts도 save, postRepo.save(post) → user도 save (= user의 다른 필드들이 덮어쓰기됨). 양방향 cascade는 예측 불가능한 update를 만든다 — cascade는 한 방향만.
4) posts: Post[]를 초기화하지 않고 save
const user = new User();
user.name = 'A';
// user.posts = []; ← 빠뜨림
await userRepo.save(user);@OneToMany는 Promise를 반환하지 않는 한 그냥 배열 필드다 — undefined라도 save는 통과. 하지만 그 뒤 user.posts.push(post) 시 런타임 에러. 항상 user.posts = [] 초기화 권장.
5) WHERE userId IS NULL을 허용하는지 모름
기본값은 nullable FK. @ManyToOne 옵션으로 명시:
@ManyToOne(() => User, { nullable: false }) // ★ NOT NULL FK
user: User;레거시 데이터에 고아 row가 있으면 마이그레이션이 실패한다 — 정리 후 nullable: false로.
Insight — Many 쪽이 진실의 주인
”코드의 직관과 DB의 진실이 어긋날 때”
코드를 읽는 사람은 user.posts가 주된 관계처럼 느낀다 — “사용자가 글을 가진다”. 하지만 DB의 진실은 정반대다: Post가 자기 부모를 가리킨다. user.posts는 역방향 쿼리(SELECT * FROM posts WHERE userId = ?)의 결과일 뿐.
멘탈 모델: 부모는 자식의 존재를 모른다. 자식이 자기 부모를 안다. 데코레이터 쌍은 이 사실을 양방향으로 탐색 가능하게 포장한 것.
”왜 JPA의 @OneToMany는 기본이 lazy인가”
JPA는 컬렉션 관계(@OneToMany, @ManyToMany)를 기본 lazy로, 단일 관계(@ManyToOne, @OneToOne)를 기본 eager로 정했다. 직관: 작은 건 따라가도 괜찮고, 큰 건 따로 가져오라.
TypeORM은 모든 관계가 기본 lazy다 (find에서 relations 명시 안 하면 안 불러옴). 이게 N+1을 피하는 안전한 디폴트 — 다음 챕터 04 — Eager vs Lazy에서 자세히.
”1:N과 N:1은 같은 관계의 두 시점”
User --1:N--> Post (User의 시점)
User <--N:1-- Post (Post의 시점)물리적으로는 posts.userId 한 컬럼이지만, 코드에서는 어디서 출발하느냐에 따라 두 방향이 모두 의미가 있다. TypeORM이 양방향 데코레이터를 요구하는 이유 — 두 시점 모두 코드에서 표현하려는 것.
”왜 @JoinColumn이 @ManyToOne에는 옵션인가”
@OneToOne은 FK 위치가 자유라서 @JoinColumn이 필수다. 하지만 @ManyToOne은 FK가 자기 테이블에 있어야 하는 게 자명하므로 @JoinColumn 없이도 결정된다 — 컬럼명을 바꾸고 싶을 때만 옵션으로 쓴다. 물리 제약이 데코레이터의 필요성을 결정한다.
요약
데코레이터는 항상 쌍:
@OneToMany↔@ManyToOne, 반대 콜백으로 연결. FK는 항상 Many 쪽: 선택의 여지 없음 — RDB가 강제. 단방향이면@ManyToOne만:@OneToMany단독은 피한다. cascade는 한 방향만: 양쪽 cascade는 update 무한 루프의 길.
다음: 03 — ManyToMany — 양쪽 다 Many면 제3의 조인 테이블이 필요하다.