서론
최근 신입 개발자분이 입사하셨다. TypeORM을 사용해서 특정 기능을 구현하던 도중, 계속해서 하위 테이블에서 상위 테이블의 FK가 NULL로 들어가는 문제가 있었다. 구현하신 로직을 따라가면서 문제점을 발견할 수 있었는데, 기존에 하위 모델에서 가지고 있는 상위 모델 객체의 정보를 저장 직전에 ORM의 create 인터페이스로 새로 생성하여 저장했기 때문이다.
현재 내가 개발중인 도메인의 테이블들은 대부분 비정규화가 심한 테이블들이여서 ORM에서 관계를 매핑해주지 않고 ORM의 인터페이스 혹은 raw query로 JOIN을 수행하고 있다. 이렇다보니 한 번에 무엇이 문제인지 찾을 수 없었다. 사용하고 있는 특정 기술들 중 핵심적인 ORM이기 때문에, 이번 일을 계기로 하나하나 직접 사용하며 정리해보면서 나에게, 또 누군가에게 레퍼런스가 되었으면 한다.
(@DeleteDateColumn사용을 위한 deleted_at도 추가했습니다.)
국룰과도 같은 user, post로 Join의 속성들을 하나하나 알아보자.
테이블 생성
@Entity('post')
export class Post {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id!: number;
@Column({ type: 'varchar' })
title!: string;
@Column({ type: 'text' })
content!: string;
//soft delete를 위한 컬럼이 아닌, update cascade를 위한 필드
@Column({ type: 'boolean', default: false })
deleted!: boolean | null;
@DeleteDateColumn({ type: 'datetime', precision: 0, nullable: true, default: null })
deletedAt!: Date | null;
@ManyToOne(() => User, (user) => user.posts)
user!: User;
}
@Entity('user')
export class User {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id!: number;
@Column({ type: 'varchar' })
name!: string;
@Column({ type: 'varchar' })
email!: string;
//soft delete를 위한 컬럼이 아닌, update cascade를 위한 필드
@Column({ type: 'boolean', default: false })
deleted!: boolean | null;
@DeleteDateColumn({ type: 'datetime', precision: 0, nullable: true, default: null })
deletedAt!: Date | null;
@OneToMany(() => Post, (post) => post.user)
posts?: Post[];
}
보통 위와 같이, 관계를 설정해주면 런타임 시에 ORM에서 데코레이터를 기반으로 각 엔터티들을 읽고 데이터베이스에 직접 테이블을 생성/수정하게 된다. Post에 굳이 userId 컬럼을 직접 명시해주지 않아도 user의 PK값이 자동으로 FK로 설정되며, 디폴트로 모델명_PK명이 생성된다.
user에서, user아님으로 필드명을 변경해주자 아래처럼 FK 변경을 위해 작업을 추가로 수행한다.
관계를 정의했으니, 이제 어떤 관계인지를 실제로 설정해보자.
RelationOptions
TypeORM에서는, 엔터티 간의 관계에서 다음과 같은 설정들을 제공한다.
export interface RelationOptions {
cascade?: boolean | ("insert" | "update" | "remove" | "soft-remove" | "recover")[];
nullable?: boolean;
onDelete?: OnDeleteType;
onUpdate?: OnUpdateType;
deferrable?: DeferrableType;
createForeignKeyConstraints?: boolean;
lazy?: boolean;
eager?: boolean;
persistence?: boolean;
orphanedRowAction?: "nullify" | "delete" | "soft-delete" | "disable";
}
cascade
cascade는 연관된 엔터티가 삽입, 수정, 삭제될 때 자동으로 전파되도록 설정하는 옵션이다. TypeORM에서 엔터티를 처리하는 방식을 제어하기 위해 사용된다. 쉽게 말해 코드레벨에서의 자식 엔터티로의 추가 작업을 전파하는 방법이며, 실제 데이터베이스의 제약 조건에는 영향을 끼치지 않는다. 또한 반드시 명시적으로 상태를 정의하거나, 변경해주어야 동작한다.
양방향에 cascade 제약 조건을 사용할 경우 순환 에러가 발생한다.
재귀적으로 상위가 하위에 전파된 동작을 하위에서 다시 상위로 전파하기 때문이다.
insert
@Entity('user')
export class User {
@OneToMany(() => Post, (post) => post.user, { cascade: 'insert' })
posts?: Post[];
}
it('CASCADE INSERT:: 유저 엔터티 내부에서 포스트 엔터티를 같이 생성할 수 있다.', async () => {
// given
const post = postRepo.create({
title: 'test',
content: 'test',
});
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
posts: [post],
});
// when
await userRepo.save(user);
const userResult = await userRepo.find({ relations: ['posts'] });
expect(userResult).toHaveLength(1);
expect(userResult[0].posts).toHaveLength(1);
});
user 객체에 정의된 posts를 정의하고 user를 save할 경우, post도 같이 저장된다.
update
@Entity('user')
export class User {
@OneToMany(() => Post, (post) => post.user, { cascade: 'update' })
posts?: Post[];
}
@Entity('user')
export class User {
@OneToMany(() => Post, (post) => post.user, { cascade: 'update' })
posts?: Post[];
}
it('CASCADE UPDATE:: 유저의 isActive 상태 변경이 포스트에 전파된다.', async () => {
// given
const post = postRepo.create({
title: 'test',
content: 'test',
});
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
posts: [post],
});
// when
await userRepo.save(user);
// when
const userResult = await userRepo.findOneOrFail({ where: { id: user.id }, relations: ['posts'] });
userResult.deleted = true; // 유저 상태 변경
if (userResult.posts) {
userResult.posts[0].deleted = true; // 포스트 상태 변경
}
await userRepo.save(userResult);
// then
const postResult = await postRepo.findOneOrFail({ where: { id: post.id } });
expect(postResult).toBeDefined();
expect(postResult?.deleted).toBe(true);
});
강제로 업데이트 상황을 만들어보기위해 어거지로 deleted를 업데이트 시켜주었다.
user가 update될 때, posts를 감지하여, 업데이트 된 변경사항이 있으면 자동으로 반영해준다.
remove
다들 짐작하겠지만 데이터베이스 레벨에서의 제약 조건을 설정하지 않았기 때문에, TypeORM이 정의하는 기본 옵션으로 정의된다. 하위 테이블에서 FK를 참조할 때, ON DELETE, ON UPDATE가 NO ACTION으로 정의된다.
위에서도 얘기했듯이, cascade는 단순 ORM에서 코드 동작을 정의하는 것이다. 그렇기 때문에 DB에서 DELETE에 대해 부모 레코드를 삭제할 수 없다는 에러를 반환하게 된다.
@Entity('user')
export class User {
@OneToMany(() => Post, (post) => post.user, { cascade: 'delete' })
posts?: Post[];
}
it('CASCADE REMOVE:: 유저 엔터티를 삭제하면 포스트 엔터티도 같이 삭제된다.', async () => {
// given
const post = postRepo.create({
title: 'test',
content: 'test',
});
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
posts: [post],
});
await userRepo.save(user);
// when
await userRepo.remove(user);
const userResult = await userRepo.find();
const postResult = await postRepo.find();
expect(userResult).toHaveLength(0);
expect(postResult).toHaveLength(0);
});
그렇기 때문에, 반드시 하위 테이블에 onDelete를 설정하여 DB에 제약조건을 걸어주도록 하자.
@Entity('post')
export class Post {
@ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
user!: User;
}
soft-remove
/**
* Records the delete date of a given entity.
*/
softRemove<T extends DeepPartial<Entity>>(entity: T, options?: SaveOptions): Promise<T & Entity>;
TypeORM에는 soft delete를 위해 softRemove 인터페이스를 제공한다.
이 softRemove는 @DeleteDateColumn 데코레이터가 달린 날짜 형태의 필드를 제어하여 soft delete를 구현하도록 되어있다. 조회 관련 인터페이스에서는, 이를 기본적으로 조회하지 않도록 되어있으며, soft delete된 레코드 까지 조회하기 위해서는 withDeleted를 true로 지정해서 조회해야한다.
/**
* Indicates if soft-deleted rows should be included in entity result.
*/
withDeleted?: boolean;
이제 cascade를 사용해서, soft-remove를 하위 테이블까지 전파해보려고 한다.
@Entity('user')
export class User {
@DeleteDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
deletedAt!: Date;
@OneToMany(() => Post, (post) => post.user, { cascade: ['soft-remove'] })
posts?: Post[];
}
it('CASCADE SOFT REMOVE:: 유저를 소프트 삭제하면 포스트도 소프트 삭제된다.', async () => {
// given
const post = postRepo.create({
title: 'test',
content: 'test',
});
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
});
post.user = user;
await userRepo.save(user);
await postRepo.save(post);
// when
user.posts = [post];
await userRepo.softRemove(user); // 소프트 삭제
// then
const allUsers = await userRepo.find();
const allPosts = await postRepo.find();
const userResult = await userRepo.find({ withDeleted: true });
const postResult = await postRepo.find({ withDeleted: true });
expect(allUsers).toHaveLength(0);
expect(allPosts).toHaveLength(0);
expect(userResult).toHaveLength(1);
expect(postResult).toHaveLength(1);
});
단순 컬럼값을 제어하는 것이지 실제 삭제하는 것이 아니기 때문에, 하위 테이블에 ON DELETE 제약 조건을 걸 필요는 없다. 하지만 반드시 cascade를 soft-remove만 사용해야 하는 상황이라면 반드시 soft-remove 전에 엔터티 객체에 관계 명시를 해준 후에 삭제를 해야한다.
다소 불편한 것 같아서, 이슈를 찾아봤는데 이미 4~5년 전에 발행된 이슈가 있었다. 아직 미해결인 것 같지만 해결책이 없는 것 같아 각자의 방법대로 사용중인 것 같았다. 보통은 cascade를 true로 사용하면서, soft-remove를 사용하며, 실제 TypeORM의 soft-remove 테스트 코드도 그렇게 적용되어 있다. 나도 이슈에 코멘트를 달아놓고, 실제로 삭제할법한 두 가지의 상황을 가정하여 위 테스트 코드의 when 절을 변경해보았다. 두 가지 모두 삭제하기 위해 PK를 받아와서 삭제하는 상황이다.
// when
// 가능한 방법, 실제로 관계 명시를 통해 엔터티 객체를 그대로 삭제하여 하위 엔터티까지 영향
const fetchedUser = await userRepo.findOneOrFail({ where: { id: user.id }, relations: ['posts'] });
await userRepo.softRemove(fetchedUser); // 소프트 삭제
// userId에 해당하는 테이블만 삭제하기 때문에, 하위 테이블에 영향 X
await userRepo.softDelete(user.id);
두 번째 방법이 동작하지 않는 이유는, 위에서 언급했듯 명시적으로 관계를 정의하지 않았기 때문인 것 같다.
recover
recover은 soft-remove로 인해 삭제되었던 레코드를 삭제되지 않은 상태로 복구하는 기능이다.
TypeORM의 recover 인터페이스를 사용하면되고, 거의 모든 상황에서 soft-remove와 같이 사용한다.
nullable
/**
* Indicates if relation column value can be nullable or not.
*/
nullable?: boolean;
부모 자식 관계에서 자식 엔터티가 부모 엔터티가 nullable할 수 있기 때문에 이를 설정해주는 값이다.
@Entity('post')
export class Post {
@ManyToOne(() => User, (user) => user.posts, { nullable: true })
user?: User;
}
이를 통해 Post 엔터티에서 user_id 필드가 NULLABLE할 수 있다.
onDelete, onUpdate
cascade는 TypeORM이 코드 레벨에서 제약조건에 따른 이후 행동들을 정의한 것이라면, 이 두 옵션은 실제 데이터베이스에서 부모 엔터티의 행동에 따라 자식 엔터티의 외래 키를 어떻게 처리할지 결정한다. 실제 데이터베이스의 외래 키 제약 조건을 지정하는 옵션으로 앞, 뒤의 설정들은 모두 코드 레벨에서 동작을 정의하지만 유일하게 데이터베이스에 적용되는 옵션이다.
onDelete
- CASCADE: 부모 엔터티 삭제 시 자식 엔터티도 삭제
- SET NULL: 부모 엔터티 삭제 시 자식 엔터티의 참조 키를 NULL로 설정
- RESTRICT: 부모 엔터티가 자식 엔터티와의 관계를 유지하고 있을 경우 삭제 불가능
onUpdate
- CASCADE: 부모 엔터티의 키가 변경될 때 자식 엔터티의 참조 키도 같이 변경됨
- RESTRICT: 부모 엔터티의 키가 변경되면 에러 발생
deferrable
/**
* Indicate if foreign key constraints can be deferred.
* IMMEDIATE: 변경 사항 발생 시 즉시 확인한다.
* DEFFRRED: 트랜잭션 커밋 시점에 확인한다.
*/
export type DeferrableType = "INITIALLY IMMEDIATE" | "INITIALLY DEFERRED";
외래키 제약 조건의 지연 여부를 설정한다.
it('INITIALLY DEFERRED:: 외래 키 제약 조건이 트랜잭션 커밋 시점에서 확인된다.', async () => {
// given
const post = postRepo.create({
title: 'test',
content: 'test',
});
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
});
post.user = user;
await userRepo.save(user);
await postRepo.save(post);
// when
const queryRunner = userRepo.manager.connection.createQueryRunner();
await queryRunner.startTransaction();
// 부모 엔터티 삭제
const fetchedUser = await userRepo.findOneOrFail({ where: { id: user.id }, relations: ['posts'] });
await queryRunner.manager.remove(fetchedUser);
// 트랜잭션 중간 상태에서 외래 키 무결성 위반 발생 여부 확인
const remainingPosts = await queryRunner.manager.find(Post);
expect(remainingPosts).toHaveLength(1); // 삭제되지 않은 상태 확인
try {
// 커밋 시도
await queryRunner.commitTransaction(); // 여기서 외래 키 제약 조건 위반 발생
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
expect(error.message).toContain('foreign key constraint');
} else {
throw error;
}
} finally {
await queryRunner.rollbackTransaction();
await queryRunner.release();
}
});
위와 같은 테스트를 작성하여, 예상 동작을 기대했지만 실패했다. InnoDB에서는 모든 외래 키 제약 조건을 항상 IMMEDIATE로 설정하기 때문에 코드 레벨에서 동작하는 deffered 옵션은 동작하지 않았다. 공식 문서를 살펴보니 MySQL의 스토리지 엔진 중 NDB만 deferrable을 지원하며 NO ACTION레벨에서만 지원한다고 한다.
psql의 경우 아래처럼 DEFFERABLE을 설정하여 사용이 가능하다.
CREATE TABLE post (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
user_id INT,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES user(id)
ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED
);
createForeignKeyConstraints
/**
* Indicates whether foreign key constraints will be created for join columns.
* Can be used only for many-to-one and owner one-to-one relations.
* Defaults to true.
*/
createForeignKeyConstraints?: boolean;
데이터베이스에서 FK는 데이터 일관성을 유지한다는 장점을 가지고 있다. 하지만 각종 제약 조건으로 인해, 데이터 자체를 수동으로 변경해야하거나 서비스 규모가 큰 경우의 샤딩, 파티셔닝 등을 할 때 제약 조건에 따른 제약들이 발생한다. 우리 회사의 데이터베이스도 이러한 제약 조건에서 자유롭기 위해 FK를 제거하고, 논리적인 관계만 유지한 채로 사용하고 있다.
이러한 기능을 지원해주는 속성이 createForeignKeyConstraints이다. 이 속성을 활성화하면 FK 제약 조건을 걸지 않는다. 즉 데이터베이스에서 FK 상태가 아닌 것을 의미한다. 외래 키가 적용되지 않았더라도, 논리적인 관계는 유지할 수 있기 때문에 정상적으로 조회는 가능하며, 1:N 관계에서 하위 관계(N)에 적용하거나, 1:1관계에서만 적용이 가능하다.
@Entity('post')
export class Post {
@ManyToOne(() => User, (user) => user.posts, {
nullable: false,
createForeignKeyConstraints: false,
lazy: true,
})
user!: User;
}
기존의 Post 엔터티에서, createForeignKeyContstraints를 false로 설정하면, 아래처럼 Post 테이블에, User의 제약조건이 걸리지 않는다.
실제 FK가 아니더라도 TypeORM이 연관된 데이터를 올바르게 로드할 수 있는지 확인하기 위해 위해 lazy loding(지연 로딩)을 적용해주었다. 지연 로딩을 통해 SQL JOIN 없이도 내부적으로 관계를 매핑하고 데이터를 로드할 수 있음을 검증해보자.
it('createForeignKeyConstraints:: 외래 키 제약 조건을 생성하지 않는다.
논리적 관계는 유지되어 조회가 가능하다.', async () => {
// given
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
});
await userRepo.save(user);
const post = postRepo.create({
title: 'test',
content: 'test',
user,
});
await postRepo.save(post);
// when
const fetchedPost = await postRepo.findOneOrFail({ where: { title: post.title } });
const fetchedUser = await fetchedPost.user;
// then
expect(fetchedPost.title).toBe(post.title);
expect(fetchedUser.name).toBe(user.name);
});
FK를 걸지 않는다는 의미는 곧 INDEX 생성도 하지 않는다는 의미이다.
연관 관계가 있는 조회에서 성능 이슈가 발생하지 않으려면, 반드시 createForeignKeyConstraints를 false로 설정해 둔 관계 엔터티에 인덱스를 설정해주어야한다. 엔터티 정의 시 아래의 a, b 위치 중에 작성해주면 된다. 필자는 복합 인덱스는 클래스 레벨에, 단일 인덱스는 필드 레벨에 작성하는 편이다.
@Entity('post')
@Index('idx_user_id', ['user']) // ⓐ
export class Post {
@Index('idx_user_id') // ⓑ
@ManyToOne(() => User, (user) => user.posts, {
nullable: false,
createForeignKeyConstraints: false,
lazy: true,
})
user!: User;
}
lazy / eager (관계 테이블 로딩)
/**
* Set this relation to be lazy. Note: lazy relations are promises. When you call them they return promise
* which resolve relation result then. If your property's type is Promise then this relation is set to lazy automatically.
*/
lazy?: boolean;
/**
* Set this relation to be eager.
* Eager relations are always loaded automatically when relation's owner entity is loaded using find* methods.
* Only using QueryBuilder prevents loading eager relations.
* Eager flag cannot be set from both sides of relation - you can eager load only one side of the relationship.
*/
eager?: boolean;
lazy와 eager은 엔터티 간 관계를 로드하는 방식과 관련된 속성이다. 엔터티 간의 관계를 사용할 때 연관된 데이터를 언제, 어떻게 로드할지 제어한다. 편리하다고 생각되지만 개인적으로 잘 사용하지 않는 속성이다. 관련해서 포스팅도 작성했지만, 실제 Join을 명시하는 것을 좋아하는 내 습관 때문이다. 포스팅에서도 언급됐듯 TypeORM의 기본 로딩은 Lazy도 Eager도 아니다.
lazy
지연 로딩으로도 불리는 lazy loding은 실제로 접근하려고 할 때 추가적인 조회를 통해 로드된다. 관계가 정의된 필드를 호출할 때 추가적인 쿼리 실행으로 데이터가 생성된다.
it('Lazy Loding', async () => {
// given
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
});
await userRepo.save(user);
const post = postRepo.create({
title: 'test',
content: 'test',
user,
});
await postRepo.save(post);
// when
const fetchedPost = await postRepo.findOneOrFail({ where: { title: post.title } });
console.log('POST', fetchedPost); // 쿼리 구분을 위한 콘솔
const fetchedUser = await fetchedPost.user;
// then
expect(fetchedPost.title).toBe(post.title);
expect(fetchedUser.name).toBe(user.name);
});
위 콘솔을 보면 User객체는 없는데 어떻게 테스트가 통과했는지 의문이 들 수 있다.
lazy loding을 사용한 조회 시 지정한 엔터티는 Promise shell 상태로 조회된다. 이 Promise 내부에는 DB 접근 트리거 역할을 하는 LazyLoding Handler을 포함하고 있어 후에 await를 사용하여 해당 객체에 접근할 때 추가적인 DB I/O 를 통해 데이터를 가져올 수 있게 된다.
이러한 Lazy Loding의 특성은 초기 데이터 로딩 시 불필요한 데이터를 가져오지 않고, 필요할 때만 데이터를 가져오기 때문에 메모리 절약이 가능하다. 하지만 Promise 객체를 생성하는 과정에서 일반 조회 방식 대비 메모리 사용량은 많다. 특히 위 테스트 상황처럼 N + 1 문제가 발생하게 된다. N + 1 문제를 방지하기 위해, 적절하게 eager혹은 default를 사용하고, default 사용 시 쿼리 빌더 등으로 join을 명시해서 사용해주자.
eager
eager loding은 연관된 데이터를 엔터티를 로드하는 시점에 즉시 가져온다. find 인터페이스로 엔터티를 조회할 때 자동으로 연관된 데이터도 가져오게 된다.
it('Eager Loding', async () => {
// given
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
});
await userRepo.save(user);
const post = postRepo.create({
title: 'test',
content: 'test',
user,
});
await postRepo.save(post);
// when
const fetchedPost = await postRepo.findOneOrFail({ where: { title: post.title } });
// then
expect(fetchedPost.title).toBe(post.title);
expect(fetchedPost.user.name).toBe(user.name);
});
항상 연관된 데이터를 포함해 로드하기 때문에 쿼리가 한 번만 실행되며, 자동으로 조인하여 로드되기 때문에 추가적인 쿼리 호출이 필요없다. 하지만 항상 데이터를 로드하기 때문에 성능 최적화를 위해서 반드시 관계 데이터가 항상 필요할 경우에만 사용하도록 하는것이 좋다.
persistence
/**
* Indicates if persistence is enabled for the relation.
* By default its enabled, but if you want to avoid any changes in the relation to be reflected in the database you can disable it.
* If its disabled you can only change a relation from inverse side of a relation or using relation query builder functionality.
* This is useful for performance optimization since its disabling avoid multiple extra queries during entity save.
*/
persistence?: boolean;
https://github.com/typeorm/typeorm/issues/2859
주석을 해석해보고, 위 이슈에서의 열띤 토론을 이해해보려고 했는데 테스트 작성에 실패했다. 도저히 어떤 테스트를 짜야할 지 모르겠다. 속성을 false로 지정하면 역방향에서만 관계를 변경할 수 있다고 하고, 혹은 쿼리 빌더를 이용해 관계 변경을 수행할 수 있다고 한다. 일단 차근차근 풀어보자.
우리의 User와 Post관계에서 관계의 소유자는 Post이다. Post에서 FK를 가지고 있기 때문이다.
반대로 역방향(Inverse Side)은 User쪽에서 Posts의 상태를 변경하는 상황이다.
// when: 역방향에서 관계 변경 시도
user.posts = [];
await userRepo.save(user);
// then: 역방향에서 관계 변경
const savedUser = await userRepo.findOneOrFail({ where: { id: user.id }, relations: ['posts'] });
expect(savedUser.posts).toHaveLength(0);
// when: 정방향에서 관계 변경 시도
const newUser = userRepo.create({
name: 'new user',
email: 'new@new.com',
});
await userRepo.save(newUser);
post.user = newUser;
expect(post.user).toBe(newUser);
await postRepo.save(post);
// then: 정방향에서 관계 변경
const savedPost = await postRepo.findOneOrFail({ where: { id: post.id }, relations: ['user'] });
expect(savedPost.user).toBe(user);
하지만, 지금 정방향에서도 역방향에서도 관계 변경 시 반영이 잘 되는 모습이라 제대로 구현이 되지 않았다. 만약 구현이 되더라도 어떠한 상황에서 적절히 사용해야 하는건지 잘 모르겠다. 이건 찾는 대로 해당 포스팅에 수정해두도록 하겠다.
orphanedRowAction
/**
* When a parent is saved (with cascading but) without a child row
* that still exists in database, this will control what shall happen to them.
* delete will remove these rows from database.
* nullify will remove the relation key.
* disable will keep the relation intact. Removal of related item is only possible through its own repo.
*/
orphanedRowAction?: "nullify" | "delete" | "soft-delete" | "disable";
부모-자식 관계에서 부모 엔터티 저장 시, 기존에 자식 엔터티가 데이터베이스에 존재하지만 부모와의 관계가 끊어진 경우 해당 자식 엔터티의 처리를 정의하는 옵션이다. 코드 레벨에서 동작하는 옵션이기 때문에 상위 레벨인 FK 제약 조건이 걸려있다면 같은 설정이 아니라면 올바르게 동작하지 않는다. 같은 설정을 두더라도, 이는 DB 레벨에서 제약조건에 따른 결과이지, 코드 레벨에서 무언가 수행하기 위해 추가로 입력하는 것이 아니다.
예를 들어, ON DELETE CASCADE를 사용하고, orphanedRowAction을 nullify로 사용한다면, 제약 조건에 따라 삭제될 것이다. 따라서 기본적으로 createForeignKeyConstraints설정을 false로 두고 필요 시 코드레벨에서 정의해서 사용하는 설정이라고 생각된다.
- nullify: 자식 엔터티의 외래 키 값을 NULL로 설정한다.
- delete: 자식 엔터티도 같이 삭제한다.
- soft-delete: 자식 엔터티를 소프트 딜리트한다. (@DeleteDateColumn)
- disable: 아무 것도 하지 않는다.
it('orphanedRowAction: nullify', async () => {
// given
const post = postRepo.create({
title: 'test',
content: 'test',
});
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
});
user.posts = await postRepo.find();
await userRepo.save(user);
post.user = user;
await postRepo.save(post);
const fetchedPost = await postRepo.findOneOrFail({ where: { title: post.title }, relations: ['user'] });
// when: 부모-자식 관계 끊기
user.posts = [];
await userRepo.save(user);
// then
expect(fetchedPost.user).not.toBeNull();
expect(fetchedPost.user?.name).toBe(user.name);
const removedPost = await postRepo.findOneOrFail({ where: { title: post.title }, relations: ['user'] });
expect(removedPost.user).toBeNull();
});
it('orphanedRowAction: delete', async () => {
// given
const post = postRepo.create({
title: 'test',
content: 'test',
});
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
});
user.posts = await postRepo.find();
await userRepo.save(user);
post.user = user;
await postRepo.save(post);
const fetchedPost = await postRepo.find();
// when: 부모-자식 관계 끊기
user.posts = [];
await userRepo.save(user);
// then: 자식 엔터티가 삭제되었는지 확인
const removedPost = await postRepo.find();
expect(fetchedPost.length).toBe(1);
expect(removedPost.length).toBe(0);
});
it('orphanedRowAction: soft-delete', async () => {
// given
const post = postRepo.create({
title: 'test',
content: 'test',
});
const user = userRepo.create({
name: 'test',
email: 'test@test.com',
});
user.posts = await postRepo.find();
await userRepo.save(user);
post.user = user;
await postRepo.save(post);
const fetchedPost = await postRepo.find();
// when: 부모-자식 관계 끊기
user.posts = [];
await userRepo.save(user);
// then: 자식 엔터티가 조회되지 않는지 확인.
const removedPost = await postRepo.find();
const whithDeletedPost = await postRepo.find({ withDeleted: true });
expect(fetchedPost.length).toBe(1);
expect(removedPost.length).toBe(0);
expect(whithDeletedPost.length).toBe(1);
});
각각의 테스트에서 createForeignKeyConstraints를 false로 두고, orphanedRowAction을 제어하면서 테스트를 진행했다. 이 설정은 FK를 실제 DB에 사용하지 않으면서 무언가 코드 레벨에서의 제약 컨벤션(?)을 걸어 사용할 때 유용할 것 같다.
정리
이렇게 정리해놓고 보니, 현재 사용중인 엔터티들도 단순 leftJoinAndMap 으로 조인해서 사용할 것이 아니라, FK 제약조건을 없애고, 논리적으로만 매핑해서 사용하는 방법이 훨씬 나을 것 같다는 생각이 들었다. 생각보다 현재 로직들에서도 활용하면 좋을 것들이 눈에 보여 잘 정리했다는 생각이 든다.
테스트 코드는 깃허브에서 확인하실 수 있습니다.
references.
https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html
https://dev.mysql.com/doc/refman/8.4/en/ansi-diff-foreign-keys.html
https://stackoverflow.com/questions/55098023/typeorm-cascade-option-cascade-ondelete-onupdate
https://github.com/typeorm/typeorm/issues/2859
https://github.com/typeorm/typeorm/issues/5838
https://github.com/typeorm/typeorm/issues/5877
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!