🗄️ TypeORM2. Relations (1:1 · 1:N · N:M)01 — OneToOne (일대일 관계)

01 — OneToOne (일대일 관계)

질문: UserProfile이 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)
);

@JoinColumnUser 쪽에 붙였기에 *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 } })OKOK
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.profileIdprofiles.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 → ProfileProfile → 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회. 분리하는 유일한 정당화는:

  1. 선택적 관계 — Profile이 있을 수도, 없을 수도
  2. 접근 패턴 분리Profile만 따로 자주 조회/캐싱
  3. 권한 분리 — 다른 서비스가 Profile만 읽음
  4. 큰 컬럼Profile.bio (TEXT) 같은 게 User 조회를 무겁게 만들 때

1:1을 만들기 전 왜 분리하는가를 먼저 답해야 한다.


요약

@JoinColumn은 한 쪽에만: 그 쪽 테이블이 FK의 주인이다. 양방향은 inverseSide 콜백으로: 콜백 없으면 별개 관계 두 개로 해석된다. 단방향이 디폴트: 양방향은 반대편 탐색이 필요할 때만. 1:1을 만들기 전에 왜 분리하는가를 답하라: 합쳐서 끝날 수도 있다.

다음: 02 — OneToMany & ManyToOne — 가장 흔한 관계. FK는 항상 Many 쪽이라는 규칙의 본격적인 적용.