05 — Custom Repository
질문:
findActiveUsers(),getUsersByDepartment(id)같은 도메인 메서드는 어디에 두는가? 서비스에? Repository에? 그리고 어떻게 Repository에 메서드를 붙이는가? 한 줄 답: 기본 Repository에 도메인 메서드를 더하려면Repository.extend()(v0.3+) 또는 클래스 상속 패턴을 쓴다. v0.2의@EntityRepository데코레이터는 deprecated됐다.
Why — 왜 Custom Repository가 필요한가
서비스 계층이 find 옵션을 직접 들고 다니면 점점 비대해진다.
// service에 매번 같은 옵션을 반복
class UserService {
async getActiveAdmins() {
return this.userRepo.find({
where: { active: true, role: 'admin', deletedAt: IsNull() },
relations: { profile: true },
order: { createdAt: 'DESC' },
});
}
async getActiveByCountry(country: string) {
return this.userRepo.find({
where: { active: true, profile: { country }, deletedAt: IsNull() },
relations: { profile: true },
order: { createdAt: 'DESC' },
});
}
// ...같은 패턴이 10개째
}→ find 옵션이 도메인 지식을 들고 있다. 이걸 Repository 안으로 이동시키면:
class UserService {
async getActiveAdmins() {
return this.userRepo.findActiveAdmins(); // ← 짧다
}
async getActiveByCountry(country: string) {
return this.userRepo.findActiveByCountry(country); // ← 짧다
}
}이게 Custom Repository의 동기다 — 도메인 쿼리의 이름을 한곳에 모은다.
How — 세 가지 패턴
P1 — Repository.extend() (v0.3 권장)
import { DataSource, Repository } from 'typeorm';
import { User } from './user.entity';
export interface UserRepository extends Repository<User> {
findActiveAdmins(): Promise<User[]>;
findActiveByCountry(country: string): Promise<User[]>;
}
export const UserRepository = (dataSource: DataSource): UserRepository =>
dataSource.getRepository(User).extend({
findActiveAdmins() {
return this.find({
where: { active: true, role: 'admin' },
relations: { profile: true },
});
},
findActiveByCountry(country: string) {
return this.find({
where: { active: true, profile: { country } },
relations: { profile: true },
});
},
});→ Repository에 메서드를 mixin. this가 원본 Repository를 가리킨다.
P2 — 클래스 상속 (NestJS 친화)
import { Repository } from 'typeorm';
export class UserRepository extends Repository<User> {
findActiveAdmins() {
return this.find({
where: { active: true, role: 'admin' },
relations: { profile: true },
});
}
findActiveByCountry(country: string) {
return this.find({
where: { active: true, profile: { country } },
relations: { profile: true },
});
}
}
// 인스턴스화 — DataSource로부터 base repo를 받아 wrap
const baseRepo = dataSource.getRepository(User);
const userRepo = new UserRepository(
baseRepo.target,
baseRepo.manager,
baseRepo.queryRunner,
);NestJS에서는 Provider로 위 인스턴스화를 한 곳에 두고 주입한다 (아래 NestJS 절).
P3 — @EntityRepository (deprecated, v0.3에서 사라짐)
// ✗ v0.3에서 작동 안 함
import { EntityRepository, Repository } from 'typeorm';
@EntityRepository(User)
export class UserRepository extends Repository<User> {
findActiveAdmins() { /* ... */ }
}
// 사용 — 이것도 v0.3에서 deprecated
const userRepo = connection.getCustomRepository(UserRepository);마이그레이션: v0.2 코드를 v0.3으로 옮길 때 가장 큰 변경점이다. 모든
@EntityRepository를 P1 또는 P2로 바꿔야 한다.
What — NestJS 환경에서의 주입
NestJS는 @nestjs/typeorm 패키지가 Repository 주입을 처리한다.
기본 — 그냥 @InjectRepository
// user.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>, // ← 기본 Repository
) {}
}→ 도메인 메서드는 Service에 들어간다. 가장 단순.
Custom Repository를 주입하려면
방법 A — 클래스 상속 + Provider
// user.repository.ts
@Injectable()
export class UserRepository extends Repository<User> {
constructor(private dataSource: DataSource) {
super(User, dataSource.createEntityManager());
}
findActiveAdmins() {
return this.find({ where: { active: true, role: 'admin' } });
}
}
// user.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserRepository, UserService],
exports: [UserRepository],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(private userRepo: UserRepository) {} // ← 직접 주입
}→ 가장 NestJS스러운 패턴. 의존성 그래프에 깔끔하게 들어간다.
방법 B — Custom Provider + extend()
// user.repository.ts
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
export const userRepositoryProvider = {
provide: USER_REPOSITORY,
inject: [DataSource],
useFactory: (dataSource: DataSource) =>
dataSource.getRepository(User).extend({
findActiveAdmins() {
return this.find({ where: { active: true, role: 'admin' } });
},
}),
};
// user.module.ts
@Module({
providers: [userRepositoryProvider, UserService],
exports: [USER_REPOSITORY],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(
@Inject(USER_REPOSITORY)
private userRepo: ReturnType<typeof userRepositoryProvider['useFactory']>,
) {}
}→ 더 자유롭지만 타입 추론이 까다롭다.
What-if — 함정 7가지
1) @EntityRepository를 그대로 v0.3에 들고 갔다
// v0.2 코드 — v0.3에서 *컴파일은 되지만 런타임 에러*
@EntityRepository(User)
export class UserRepository extends Repository<User> {}
// → connection.getCustomRepository는 undefined를 반환마이그레이션 가이드의 최우선 작업이다. P1 또는 P2로 변환.
2) 클래스 상속에서 super 호출을 잘못
@Injectable()
export class UserRepository extends Repository<User> {
constructor(private dataSource: DataSource) {
super(User, dataSource.createEntityManager());
// ↑ 두 번째 인자가 *EntityManager*여야 함
}
}super(target, manager, queryRunner?) 시그니처를 정확히 맞춰야 한다. 잘못 넣으면 런타임에 this.manager.connection이 undefined.
3) 트랜잭션 안에서 기본 Repository를 부른다
async transferMoney(fromId: number, toId: number) {
await this.dataSource.transaction(async (manager) => {
// ✗ — 이건 *트랜잭션 밖*의 Repository
await this.userRepo.update(fromId, { balance: ... });
// ✓ — 트랜잭션 안의 Repository
await manager.getRepository(User).update(fromId, { balance: ... });
});
}Custom Repository를 클래스 상속으로 만들었다면, 트랜잭션 안에서는 manager.withRepository(UserRepository) (v0.3+)를 써야 한다.
await this.dataSource.transaction(async (manager) => {
const userRepo = manager.withRepository(this.userRepo);
await userRepo.findActiveAdmins();
});4) Custom Repository에 너무 많은 책임
class UserRepository extends Repository<User> {
// ✗ 도메인 메서드 한 줄이 50줄 비즈니스 로직
async signUp(input: SignUpDto) {
if (!input.email.includes('@')) throw ...;
await sendVerificationEmail(...);
const user = await this.save({ ... });
await chargeFreeCredits(user.id);
return user;
}
}Custom Repository는 쿼리의 집이다 — 비즈니스 로직의 집이 아니다. 검증·외부 API 호출·이벤트는 Service에. Repository 안에는 DB 접근만.
5) DTO와 엔티티를 섞는다
class UserRepository extends Repository<User> {
// ✗ — 반환 타입을 DTO로
async findActiveAdmins(): Promise<UserListItemDto[]> {
const users = await this.find({ ... });
return users.map(u => ({ id: u.id, name: u.name })); // ← 변환까지
}
}Repository는 엔티티를 반환하는 게 경계의 정의다. DTO 변환은 Service/Controller 책임. 섞으면 재사용성이 깨진다.
6) 제네릭 Repository를 만들고 싶다는 유혹
// ✗ 일반적으로 안티패턴
class BaseRepository<T> extends Repository<T> {
findActive() {
return this.find({ where: { active: true } as any });
}
}엔티티마다 컬럼이 다르다 — 공통 베이스 Repository는 as any가 난무하게 된다. 각 엔티티 Repository를 따로 만드는 게 정도.
7) Repository에 static 메서드
// ✗ — ActiveRecord 흉내
class UserRepository extends Repository<User> {
static async findById(id: number) { ... }
}static은 DataSource를 모르는 메서드가 된다. 인스턴스 메서드로만 두라.
Insight — Custom Repository는 경계의 어휘집이다
서비스가 부르는 도메인 단어(findActiveAdmins, findByDepartment)와 Repository가 부르는 SQL 도구(find/save) 사이의 번역 계층이 Custom Repository다.
→ 이게 잘 되면:
- 같은 쿼리가 한 이름으로 통일된다.
- find 옵션 객체가 Service에 누설되지 않는다.
- 테스트 시 Custom Repository만 mock하면 도메인 동작을 검증할 수 있다.
v0.3의 조용한 큰 변화
v0.2 → v0.3 마이그레이션에서 가장 덜 광고된 큰 변경이 Custom Repository다.
- v0.2:
@EntityRepository데코레이터 +getCustomRepository로 프레임워크 친화. - v0.3:
extend()또는 순수 상속으로 DI 컨테이너 친화.
→ TypeORM 메인테이너는 이를 NestJS의 영향이라고 회고한다. NestJS의 DI 컨테이너 위에 데코레이터 한 겹을 더 얹는 게 어색했고, 클래스 상속이 더 자연스러웠다.
재미있는 사실 — Repository.extend()의 this는 원본
const userRepo = dataSource.getRepository(User).extend({
findActiveAdmins() {
return this.find({ where: { active: true, role: 'admin' } });
// ↑ this는 *원본 Repository*
},
});extend는 Object.create로 prototype을 확장한다. 그래서 this.find/this.save/this.manager 모두 동작한다. mixin 패턴의 간결한 구현이다.
요약
세 가지 패턴:
Repository.extend()— 권장, mixin, 타입 안전.- 클래스 상속 — NestJS DI 친화, 트랜잭션 시
manager.withRepository필요.@EntityRepository— deprecated, v0.3에서 제거.NestJS에서는 P2(클래스 상속) + Module Provider가 가장 흔하다. Repository 책임 = 쿼리만. 비즈니스 로직은 Service.
다음: 06 — ActiveRecord 모드 —
BaseEntity를 상속하는 반대 진영. 매력과 한계, 그리고 언제 쓸 만한가.