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으로 옮길 때 가장 큰 변경점이다. 모든 @EntityRepositoryP1 또는 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*
  },
});

extendObject.create로 prototype을 확장한다. 그래서 this.find/this.save/this.manager 모두 동작한다. mixin 패턴의 간결한 구현이다.


요약

세 가지 패턴:

  • Repository.extend()권장, mixin, 타입 안전.
  • 클래스 상속 — NestJS DI 친화, 트랜잭션 시 manager.withRepository 필요.
  • @EntityRepositorydeprecated, v0.3에서 제거.

NestJS에서는 P2(클래스 상속) + Module Provider가 가장 흔하다. Repository 책임 = 쿼리만. 비즈니스 로직은 Service.

다음: 06 — ActiveRecord 모드BaseEntity를 상속하는 반대 진영. 매력과 한계, 그리고 언제 쓸 만한가.