TypeORM Lazy Relations란?
TypeORM에서 엔티티 관계를 로딩하는 방식은 크게 Eager, 명시적 로딩(relations 옵션), 그리고 Lazy 세 가지다. Lazy Relations는 관계 프로퍼티에 접근하는 시점에 비로소 DB 쿼리를 실행하는 패턴으로, Promise 타입으로 선언하여 구현한다.
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
@Entity()
export class Order {
@PrimaryGeneratedColumn()
id: number;
@Column()
totalAmount: number;
// Lazy Relation: Promise 타입으로 선언
@ManyToOne(() => User, user => user.orders)
user: Promise<User>;
}
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany(() => Order, order => order.user)
orders: Promise<Order[]>;
}
이제 order.user에 접근하면 자동으로 SELECT 쿼리가 실행된다. 명시적으로 relations: ['user']를 지정하지 않아도 된다.
Lazy Relations 동작 원리
TypeORM은 엔티티 인스턴스 생성 시 Object.defineProperty로 getter를 주입한다. 이 getter가 프로퍼티 접근을 가로채서 DB 쿼리를 실행하고 Promise를 반환한다.
// 내부적으로 이런 식으로 동작
Object.defineProperty(orderInstance, 'user', {
get: () => {
// 첫 접근 시 SELECT * FROM user WHERE id = ? 실행
// 이후 접근은 캐시된 결과 반환
return dataSource.getRepository(User).findOneBy({ id: userId });
}
});
중요한 점은 한 번 로딩된 결과는 캐시된다는 것이다. 같은 인스턴스에서 order.user를 여러 번 호출해도 쿼리는 한 번만 실행된다.
N+1 문제: Lazy의 가장 큰 함정
Lazy Relations의 최대 위험은 N+1 쿼리 문제다. 리스트 조회에서 각 항목의 관계에 접근하면 항목 수만큼 추가 쿼리가 발생한다.
// ❌ N+1 문제 발생
const orders = await orderRepository.find(); // 1번 쿼리: SELECT * FROM order
for (const order of orders) {
const user = await order.user; // N번 쿼리: SELECT * FROM user WHERE id = ?
console.log(`${user.name}: ${order.totalAmount}`);
}
// 주문 100개 → 총 101번 쿼리 실행!
// ✅ 해결: 명시적 relations 로딩
const orders = await orderRepository.find({
relations: { user: true }, // JOIN으로 1번에 로딩
});
// 또는 QueryBuilder 사용
const orders = await orderRepository
.createQueryBuilder('order')
.leftJoinAndSelect('order.user', 'user')
.getMany();
Lazy Relations는 단건 조회에서만 안전하다. 리스트 조회에서는 반드시 relations 옵션이나 QueryBuilder JOIN을 사용해야 한다.
Lazy vs Eager vs 명시적 로딩 비교
| 방식 | 선언 | 로딩 시점 | N+1 위험 | 사용 적합성 |
|---|---|---|---|---|
| Eager | eager: true |
항상 JOIN | 없음 | 항상 필요한 관계 |
| 명시적 | relations: {} |
호출 시 JOIN | 없음 | 대부분의 경우 (권장) |
| Lazy | Promise<T> |
접근 시 개별 쿼리 | 높음 | 단건 + 조건부 접근 |
Lazy Relations 저장(Save) 시 주의점
Lazy Relation을 설정할 때도 Promise.resolve()로 감싸야 한다. 일반 값을 직접 할당하면 타입 에러가 발생한다.
// ✅ 올바른 Lazy Relation 저장
const user = await userRepository.findOneBy({ id: 1 });
const order = new Order();
order.totalAmount = 50000;
order.user = Promise.resolve(user); // Promise로 감싸기
await orderRepository.save(order);
// ❌ 잘못된 방식 — 타입 에러
order.user = user; // Type 'User' is not assignable to type 'Promise<User>'
이 패턴은 코드 가독성을 떨어뜨린다. 매번 Promise.resolve()를 호출해야 하고, 실수하기 쉽다.
직렬화(Serialization) 문제
Lazy Relation은 Promise 타입이므로 JSON.stringify()나 class-transformer에서 자동 직렬화되지 않는다.
// ❌ user가 빈 객체로 직렬화됨
const order = await orderRepository.findOneBy({ id: 1 });
console.log(JSON.stringify(order));
// {"id":1,"totalAmount":50000,"user":{}} ← Promise 객체!
// ✅ 해결: await로 먼저 resolve
const order = await orderRepository.findOneBy({ id: 1 });
const resolved = {
...order,
user: await order.user,
};
console.log(JSON.stringify(resolved));
// {"id":1,"totalAmount":50000,"user":{"id":1,"name":"Kim"}}
NestJS에서 Interceptor를 활용한 응답 변환 시에도 Lazy Relation의 Promise가 자동 resolve되지 않으므로, DTO 변환 단계에서 명시적으로 await해야 한다.
테스트 환경에서의 Lazy Relations
Lazy Relations는 TypeORM의 내부 프록시 메커니즘에 의존하므로, 순수 객체를 생성하면 동작하지 않는다.
// ❌ 테스트에서 new로 생성하면 Lazy 동작 안 함
const order = new Order();
order.user = Promise.resolve(mockUser);
const user = await order.user; // 동작은 하지만 DB 프록시 아님
// ✅ 테스트에서는 Repository mock으로 처리
const mockOrderRepo = {
findOne: jest.fn().mockResolvedValue({
id: 1,
totalAmount: 50000,
user: Promise.resolve({ id: 1, name: 'Kim' }),
}),
};
// 또는 테스트 DB에서 실제 저장 후 조회
const saved = await orderRepository.save(order);
const loaded = await orderRepository.findOneBy({ id: saved.id });
const user = await loaded.user; // 실제 Lazy 프록시 동작
Lazy Relations를 써야 할 때
대부분의 경우 명시적 로딩이 더 낫다. Lazy Relations가 유리한 경우는 제한적이다.
| 시나리오 | 권장 방식 | 이유 |
|---|---|---|
| API 응답에 관계 포함 | 명시적 relations | 직렬화 문제 없음 |
| 리스트 조회 + 관계 | QueryBuilder JOIN | N+1 방지 |
| 단건 + 조건부 관계 접근 | Lazy | 불필요한 JOIN 방지 |
| 도메인 로직에서 간헐적 참조 | Lazy | 코드 간결성 |
| 깊은 중첩 관계 | Lazy | 필요한 깊이만 로딩 |
정리
TypeORM Lazy Relations는 Promise 타입으로 선언하여 접근 시점에 로딩하는 패턴이다. N+1 문제, 직렬화 불가, Promise.resolve() 저장, 테스트 프록시 의존성 등 실무에서 마주치는 함정이 많다. 단건 조회의 조건부 관계 접근에만 제한적으로 사용하고, 리스트 조회에서는 반드시 명시적 로딩이나 QueryBuilder를 사용하는 것이 프로덕션 안전 전략이다.