01 — OneToOne (일대일 관계)
질문:
User와Profile이 1:1로 연결될 때, 어느 쪽 테이블에 FK를 두고 어떤 데코레이터를 써야 하는가? 한 줄 답:@OneToOne+@JoinColumn을 한 쪽 엔티티에만 붙인다 —@JoinColumn이 붙은 쪽 테이블이 외래키의 주인이다. 반대편의@OneToOne은 탐색 편의용일 뿐 DB 스키마에 아무 영향이 없다.
한 장 그림
Why — 왜 한 쪽만 @JoinColumn인가
RDB의 1:1 관계는 물리적으로는 비대칭이다 — 둘 중 한 테이블에 FK 컬럼을 둬야 한다. 양쪽에 두면 순환 참조 + 중복 데이터가 된다.
| RDB 사실 | TypeORM의 표현 |
|---|---|
| FK는 한 테이블에만 있다 | @JoinColumn은 한 쪽 엔티티에만 붙는다 |
| FK가 있는 테이블이 child | @JoinColumn 붙은 쪽이 소유 쪽 (owning side) |
| 양쪽에서 join 가능 | TypeORM은 양쪽에 데코레이터를 두면 양방향 탐색을 자동화 |
핵심 invariant:
@JoinColumn의 위치 = FK 컬럼이 사는 테이블. 이게 1:1, 1:N, N:M 모두를 관통하는 규칙이다.
How — 코드로 보는 네 가지 형태
1) 단방향 — 가장 간단
User에서만 profile을 본다. Profile에서 user를 보지 않는다.
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@Column() email: string;
@OneToOne(() => Profile)
@JoinColumn()
profile: Profile;
}
// profile.entity.ts
@Entity()
export class Profile {
@PrimaryGeneratedColumn() id: number;
@Column() bio: string;
}생성되는 스키마:
CREATE TABLE users (
id INT PRIMARY KEY,
email VARCHAR,
profileId INT, -- ★ FK가 users에 산다
FOREIGN KEY (profileId) REFERENCES profiles(id)
);
@JoinColumn을User쪽에 붙였기에 *users.profileId*가 생긴다.
2) 양방향 — 양쪽에서 탐색
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@OneToOne(() => Profile, (profile) => profile.user)
@JoinColumn() // ★ 한쪽만
profile: Profile;
}
// profile.entity.ts
@Entity()
export class Profile {
@PrimaryGeneratedColumn() id: number;
@OneToOne(() => User, (user) => user.profile) // ★ JoinColumn 없음
user: User;
}스키마는 위와 동일. 차이는 코드에서 profile.user를 불러올 수 있다는 점뿐.
3) 어느 쪽이 소유 쪽이 되어야 하는가
| 기준 | 결정 |
|---|---|
| 필수 인 쪽 vs 선택 인 쪽 | 선택적인 쪽에 FK (nullable FK) |
| 자주 join 해 가져오는 쪽 | 가져오는 쪽에 FK가 있어야 join 한 번에 끝남 |
| 큰 테이블 vs 작은 테이블 | 작은 테이블에 FK (분리·캐싱 용이) |
예: User(필수) ↔ Profile(선택 — 가입 후 추가). FK는 Profile 쪽에 두는 게 자연스럽다 — user가 없는 profile은 무의미하지만, profile이 없는 user는 흔하다.
@Entity()
export class Profile {
@PrimaryGeneratedColumn() id: number;
@OneToOne(() => User)
@JoinColumn() // ★ FK가 profiles 쪽
user: User;
}4) @JoinColumn 옵션 — 컬럼 이름 커스터마이즈
@OneToOne(() => User)
@JoinColumn({ name: 'owner_id', referencedColumnName: 'id' })
owner: User;기본은 <property>Id (camelCase) → ownerId 였을 컬럼이 owner_id로 바뀐다. 레거시 DB와 붙일 때 유용.
What — 양방향 vs 단방향 비교 테이블
| 항목 | 단방향 | 양방향 |
|---|---|---|
| 스키마 (DDL) | 동일 | 동일 |
userRepo.findOne({ relations: { profile: true } }) | OK | OK |
profileRepo.findOne({ relations: { user: true } }) | 불가 | OK |
| 데코레이터 정의 위치 | 한 쪽 | 양 쪽 (inverseSide 콜백으로 연결) |
| save 시 cascade 동작 | 소유 쪽에서만 | 양쪽 어디서든 (cascade 옵션 따라) |
| 권장 시점 | 한 방향만 쓸 게 명백할 때 | 양쪽에서 자주 탐색할 때 |
결정 규칙: 반대편에서 탐색이 필요 없으면 단방향. 양방향은 추가 비용 0이지만, 무의미한 양방향은 코드 가독성을 떨어뜨린다.
What-if — 자주 만나는 함정
1) 양쪽에 @JoinColumn 붙임
// User
@OneToOne(() => Profile)
@JoinColumn() // ★
profile: Profile;
// Profile
@OneToOne(() => User)
@JoinColumn() // ★ ← 두 번째!
user: User;결과: TypeORM이 users.profileId와 profiles.userId 두 FK를 만든다. 데이터 정합성이 코드 양쪽에서 갈리고, 어느 쪽이 진실인지 알 수 없게 된다.
2) @JoinColumn을 빼먹음
@OneToOne(() => Profile) // ★ JoinColumn 없음
profile: Profile;결과: TypeORM이 FK 컬럼을 만들지 않는다. userRepo.save({ profile }) 해도 어디에도 저장 안 됨. 단순히 *반대편 정의 (inverse side)*로만 해석된다.
규칙: 단방향이든 양방향이든, 적어도 한 쪽에는
@JoinColumn이 있어야 한다.
3) inverseSide 콜백을 빼고 양방향 시도
// User
@OneToOne(() => Profile) // ★ inverse 콜백 없음
@JoinColumn()
profile: Profile;
// Profile
@OneToOne(() => User) // ★ inverse 콜백 없음
user: User;결과: TypeORM이 두 관계를 서로 다른 두 개로 인식한다 — User → Profile과 Profile → User가 별개 관계가 되어, FK도 두 개. 양방향은 반드시 inverseSide 콜백으로 연결해야 한다.
// 올바른 양방향
@OneToOne(() => Profile, (p) => p.user) // ★ inverseSide
@JoinColumn()
profile: Profile;4) 1:1인데 유니크 제약이 없는 줄 모름
TypeORM의 @JoinColumn은 자동으로 UNIQUE 제약을 건다. 직접 만든 FK에는 이걸 빼먹기 쉽다 — 여러 user가 같은 profile을 가리키는 1:N으로 변질된다.
-- TypeORM이 @JoinColumn으로 생성
ALTER TABLE users ADD CONSTRAINT UQ_profile UNIQUE (profileId);수동 마이그레이션 작성 시 이 제약을 빼먹으면 1:1이 깨진다.
Insight — 비대칭성은 DB가 강제하는 것
”왜 ORM이 비대칭을 못 숨기는가”
JPA(Java)도, Doctrine(PHP)도, ActiveRecord(Ruby)도 모두 같은 비대칭을 강요한다 — JPA는 @JoinColumn, Doctrine은 inversedBy, ActiveRecord는 belongs_to. 이름은 다르지만 FK는 한 테이블에만 있어야 한다는 RDB 사실이 변하지 않는다.
ORM은 RDB의 추상화이지 우회가 아니다 — FK의 비대칭은 모든 ORM의 영원한 출발점이다.
”Prisma는 어떻게 다르게 표현하는가”
model User {
id Int @id
profile Profile?
}
model Profile {
id Int @id
userId Int @unique // ← 여기에 FK 명시
user User @relation(fields: [userId], references: [id])
}Prisma는 FK 컬럼을 schema에 직접 적게 한다 — userId @unique 한 줄로 1:1 + FK 위치 + 유니크가 모두 한 곳에 모인다. TypeORM의 @JoinColumn이 어디에 붙었는지를 코드 양쪽을 봐야 알 수 있는 것과 대조된다.
”1:1이 정말 1:1이라면, 한 테이블로 합치는 게 낫다”
1:1 관계가 항상 같이 존재하고 항상 같이 조회된다면, 분리할 이유가 없다 — 한 테이블에 다 박으면 join 0회. 분리하는 유일한 정당화는:
- 선택적 관계 —
Profile이 있을 수도, 없을 수도 - 접근 패턴 분리 —
Profile만 따로 자주 조회/캐싱 - 권한 분리 — 다른 서비스가
Profile만 읽음 - 큰 컬럼 —
Profile.bio(TEXT) 같은 게User조회를 무겁게 만들 때
1:1을 만들기 전 왜 분리하는가를 먼저 답해야 한다.
요약
@JoinColumn은 한 쪽에만: 그 쪽 테이블이 FK의 주인이다. 양방향은inverseSide콜백으로: 콜백 없으면 별개 관계 두 개로 해석된다. 단방향이 디폴트: 양방향은 반대편 탐색이 필요할 때만. 1:1을 만들기 전에 왜 분리하는가를 답하라: 합쳐서 끝날 수도 있다.
다음: 02 — OneToMany & ManyToOne — 가장 흔한 관계. FK는 항상 Many 쪽이라는 규칙의 본격적인 적용.