TypeORM을 사용하면서 다음과 같은 문제를 겪으신 적 있으신가요?
단순 조회 시의 문제: 단순히 find 메서드를 사용해 데이터를 조회할 때도, 엔터티에 정의된 모든 조인 속성이 자동으로 타입 추론에 포함되므로 실수로 조인된 속성에 접근하여 예기치 않은 오류가 발생하는 경우
조인 조회 시의 문제: 조인을 포함한 find 메서드를 사용해 데이터를 조회할 때도, 엔터티에 정의된 모든 조인 속성이 자동으로 타입 추론에 포함되므로 실제로 조인을 하지 않은 속성에 실수로 접근하게 되어 오류가 발생하는 경우
저는 TypeORM을 사용하면서 위 두 가지 문제를 모두 경험했고, 런타임 전에 타입스크립트를 통해 이러한 타입 에러를 잡고 싶었습니다. 이를 위해 어떻게 타입 추론을 더 정확하게 할 수 있을 지 고민해보았습니다.
문제 해결 방법을 공유하기에 앞서, 현재 팀의 백엔드 구조를 알아야하는데요.
2가지 특징을 기억해주세요
모든 repository가 하나의 슈퍼 클래스를 상속받는 구조로 설계되어 있습니다.
슈퍼 클래스는 각 repository에서 공통적으로 사용하는 메서드들을 포함하고 있어, 코드의 재사용성과 일관성을 높여주기 위한 목적입니다.
아래 예시 코드를 통해 이를 살펴보겠습니다.
이해를 돕기 위해 코드 내용을 간략하게 작성했습니다.
export class SuperClass {
async findByIdOrThrow(id: T['id']): Promise<OmitUppercaseProps<T>> {
const findOption: FindOneOptions = { where: { id } };
const res = await this.findOne(findOption);
if (!res) {
throw new NotFoundException(`don't exist ${id}`);
}
return res;
}
async findOneWithOmitNotJoinedPropsOrThrow<R extends FindOptionsRelations<T>>(
filters: FindOptionsWhere<Mutable<T>>,
findOptionsRelations: R,
withDeleted?: boolean,
): Promise<OmitNotJoinedProps<T, R>> {
const findOption: FindOneOptions = {
where: filters,
relations: findOptionsRelations,
withDeleted,
};
const res = await this.findOne(findOption);
if (!res) {
const msgList: string[] = [];
for (const [key, value] of Object.entries(filters)) {
msgList.push(`${key}: ${value}`);
}
throw new NotFoundException(`don't exist ${msgList.join(', ')}`);
}
return res as OmitNotJoinedProps<T, R>;
}
}
위 코드에서 슈퍼 클래스는 findByIdOrThrow, findOneWithOmitNotJoinedPropsOrThrow와 같은 공통 메서드를 정의하고 있습니다.
export class SampleRepository extends SuperClass {}
각 레파지토리는 이처럼 슈퍼 클래스를 상속받아, 슈퍼 클래스에서 정의된 메서드를 사용할 수 있습니다.
이를 통해 코드의 중복을 줄이고, 각 레파지토리에서 일관된 메서드들을 사용할 수 있게 됩니다.
저희 팀은 엔터티에서 일반 컬럼과 조인 속성을 구분하기 위해, 조인 속성의 이름을 대문자로 시작하는 컨벤션을 따르고 있습니다.
아래 예시 코드를 통해 이를 살펴보겠습니다.
이해를 돕기 위해 코드 내용을 간략하게 작성했습니다.
@Entity('Post')
export class Post {
@Column({ type: 'uuid', primary: true })
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
title: string;
@Column({ type: 'text', nullable: true })
content: string | null;
@ManyToOne(() => User, (user) => user.Posts)
@JoinColumn({ name: 'user_id', referencedColumnName: 'id' })
User: User;
@OneToMany(() => Comment, (comment) => comment.Post)
Comments: Comment[];
}
앞서 설명한 두 가지 특징을 기억해주세요!
이제부터 TypeORM에서 발생하는 타입 추론 문제를 어떻게 해결했는지 설명드리겠습니다.
예를 들어, Post 엔터티를 사용한다고 가정합시다.
TypeORM에서 제공하는 방식대로 repository를 만들면, 조회 시 반환 타입은 항상 Post 엔터티 클래스의 타입이 됩니다. 이로 인해 엔터티에 정의된 모든 조인 속성이 자동으로 타입 추론에 포함됩니다.
따라서 실수로 조인된 속성에 접근할 경우, 예기치 않은 오류가 발생합니다. (물론 런타임 때 오류가 발생할 것입니다.)
const post = await this.postRepository.findById(postId)
const user = post.User // 타입 추론에 포함되므로 의도치 않게 조인된 속성에 접근, 런타임 시 에러 발생
저희 팀은 조인 속성을 대문자로 시작하는 컨벤션을 따르고 있다는 점에 착안하여
"대문자로 시작하는 속성을 제거하는 유틸리티 타입을 만들면, 해결할 수 있지 않을까?"라는 생각이 들었습니다.
export type OmitUppercaseProps<T> = {
[K in keyof T as K extends string
? K extends `${Uppercase<string>}${string}`
? never
: K
: K]: T[K];
};
OmitUppercaseProps 유틸리티 타입은 입력 타입 T의 모든 속성 중 대문자로 시작하는 속성을 제외한 새로운 타입을 생성합니다.
export class SuperClass {
async findByIdOrThrow(id: T['id']): Promise<OmitUppercaseProps<T>> {
const findOption: FindOneOptions = { where: { id } };
const res = await this.findOne(findOption);
if (!res) {
throw new NotFoundException(`don't exist ${id}`);
}
return res;
}
}
그리고 OmitUppercaseProps 유틸리티 타입을 슈퍼클래스의 단순 find 조회 메서드에 적용했습니다.
이제 슈퍼클래스의 findByIdOrThrow 메서드를 사용할 경우, 조인 속성을 제외한 엔터티 타입이 반환됩니다. 이를 통해 실수로 조인 속성에 접근하여 발생하던 에러를 방지할 수 있게 되었습니다.
예를 들어, Post 엔터티에서 사용자와 댓글 정보를 조인하여 가져와야 하는 상황을 생각해보겠습니다.
이때 코드는 다음과 같이 작성될 수 있습니다:
const post = await this.postRepository.findOne({
where: { id: postId },
relations: { User: true },
});
const user = post.User
const comments = Post.Comments // 실수로 relations 속성에 Comments를 포함하지 않아 런타임 시 에러 발생
이 코드에서 post객체는 User와의 조인을 통해 사용자 정보를 포함하고 있습니다. 그러나 실수로 relations 속성에 Comments를 포함하지 않았기 때문에, post.Comments에 접근하려고 하면 런타임에서 undefined이 반환되어 오류가 발생합니다.
타입 추론이 완벽하지 못하다 보니, 실수로 인한 누락을 런타임 전에 타입스크립트를 통해 잡아내지 못하기 때문에 디버깅이 어려워질 수 있습니다.
이 문제를 해결하기 위해, 조인 메서드 사용 시 조인 대상이 되는 조인 속성들을 제외하고 조인 대상에 속하지 않는 조인 속성을 제거하는 OmitNotJoinedProps라는 유틸리티 타입을 구현하였습니다.
해당 유틸리티 타입을 사용하면, 실제로 조인된 속성만 타입 추론에 포함되므로 실수를 방지할 수 있습니다.
export type OmitNotJoinedProps<T, R extends FindOptionsRelations<T>> = {
[K in keyof T as K extends string
? K extends `${Uppercase<string>}${string}`
? K extends keyof R
? K
: never
: K
: K]: K extends keyof R
? R[K] extends true
? T[K] extends (infer U)[]
? OmitUppercaseProps<U>[]
: OmitUppercaseProps<T[K]>
: R[K] extends object
? T[K] extends (infer U)[]
? OmitNotJoinedProps<U, R[K]>[]
: OmitNotJoinedProps<T[K], R[K]>
: T[K]
: T[K];
};
OmitNotJoinedProps 유틸리티 타입을 슈퍼 클래스의 조인 메서드의 적용해보았습니다. findOneWithOmitNotJoinedPropsOrThrow 메서드를 통해, 조인되지 않은 속성에 대한 접근을 방지할 수 있습니다.
export class SuperClass {
async findOneWithOmitNotJoinedPropsOrThrow<R extends FindOptionsRelations<T>>(
filters: FindOptionsWhere<Mutable<T>>,
findOptionsRelations: R,
withDeleted?: boolean,
): Promise<OmitNotJoinedProps<T, R>> {
const findOption: FindOneOptions = {
where: filters,
relations: findOptionsRelations,
withDeleted,
};
const res = await this.findOne(findOption);
if (!res) {
const msgList: string[] = [];
for (const [key, value] of Object.entries(filters)) {
msgList.push(`${key}: ${value}`);
}
throw new NotFoundException(`don't exist ${msgList.join(', ')}`);
}
return res as OmitNotJoinedProps<T, R>;
}
}
위에서 설명한 유틸리티 타입을 슈퍼 클래스의 메서드에 적용함으로써, 보다 정확한 타입 추론을 가능하게 하고, 런타임 이전에 타입 에러를 잡아낼 수 있었습니다.
TypeORM을 사용하는 개발자분들께도 이러한 방식으로 타입 추론을 강화하여 더욱 안전하고 신뢰할 수 있는 코드를 작성해보시기를 추천드립니다.