본문 바로가기
Backend/NestJS

[NestJS] 여러 DB 연결에서 Repository를 정확히 주입하는 방법

미지시료 2026. 10. 5.

하나의 NestJS 애플리케이션에서 여러 데이터베이스 연결을 사용할 때 Repository가 어떤 기준으로 구분되어 주입되는지 살펴보고, 같은 DI 토큰으로 테스트 대역까지 교체하는 과정을 정리했다.





시작하며

하나의 데이터베이스만 사용하는 동안에는 Repository 주입이 단순하다.

constructor(
  @InjectRepository(Account)
  private readonly accountRepository: Repository<Account>,
) {}

하지만 시스템이 여러 애플리케이션과 데이터 저장소로 나뉘면 한 서비스가 서로 다른 연결의 데이터를 함께 다뤄야 할 수 있다.

공통 데이터베이스
├── 사용자
├── 권한
└── 운영 대상

기능별 데이터베이스
├── 작업
├── 일정
└── 처리 결과

이때 생성자 매개변수의 TypeScript 타입만 보면 두 Repository는 모두 Repository<T>다. 어떤 데이터베이스 연결에서 Repository를 가져와야 하는지는 타입만으로 결정할 수 없다.

constructor(
  private readonly accountRepository: Repository<Account>,
  private readonly taskRepository: Repository<Task>,
) {}

NestJS와 TypeORM은 이 문제를 DI 토큰으로 해결한다. Entity와 데이터베이스 연결 이름을 조합해 서로 다른 Repository provider를 만들고, 서비스는 같은 토큰을 사용해 필요한 Repository를 주입받는다.

내가 이 구조를 제대로 확인하게 된 계기는 한 기능 Service에서 공통 데이터베이스와 별도 업무 데이터베이스를 함께 조회하면서였다. 데이터베이스 주소와 계정은 정상이고 각각의 연결도 생성됐지만, Repository를 추가한 뒤 애플리케이션이 시작되지 않았다. 처음에는 DB 접속 문제를 의심했지만 실제 원인은 forFeature()와 @InjectRepository()에 사용한 연결 이름이 맞지 않아 서로 다른 DI 토큰이 만들어진 것이었다.

이후에는 Repository 주입 문제를 DB 연결 문제로 한꺼번에 보지 않고 다음 세 단계로 나누어 확인했다.

단계 확인할 내용 실패했을 때 나타나는 범위
연결 생성 이름이 있는 DataSource가 생성되는가 DB 접속 또는 설정 오류
Repository 등록 현재 Module에 Entity와 연결 이름이 등록됐는가 Module 초기화 중 DI 오류
Repository 요청 주입부가 등록할 때와 같은 토큰을 요청하는가 특정 Service의 의존성 해석 오류

이 구분을 적용하니 Nest can't resolve dependencies 오류를 보고 무작정 환경 변수와 DB 권한부터 다시 확인하는 일을 줄일 수 있었다.





DI는 타입을 보고 객체를 만들어 주는 기능만은 아니다

NestJS의 DI Container는 provider를 토큰과 함께 보관한다.

DI token  ──→  provider instance

일반적인 서비스는 클래스 자체를 토큰으로 사용할 수 있다.

@Injectable()
export class AccessService {}

@Module({
  providers: [AccessService],
})
export class AccessModule {}

다른 서비스에서는 클래스 타입으로 주입받는다.

constructor(
  private readonly accessService: AccessService,
) {}

하지만 다음 대상은 클래스 타입만으로 구분하기 어렵다.

  • 같은 Entity를 사용하는 서로 다른 데이터베이스 연결
  • Cache나 설정처럼 인터페이스 타입으로 표현되는 객체
  • 전역 Guard와 Interceptor처럼 프레임워크가 정한 확장 지점
  • 테스트에서 실제 구현 대신 사용할 가짜 객체

이 경우에는 명시적인 토큰이 필요하다.

constructor(
  @Inject(CACHE_MANAGER)
  private readonly cacheManager: Cache,
) {}

Repository 주입 역시 같은 원리다. @InjectRepository()는 단순히 편의를 위한 표시가 아니라, TypeORM이 정한 Repository 토큰을 NestJS에 전달하는 역할을 한다.





데이터베이스 연결부터 이름으로 구분한다

먼저 애플리케이션의 상위 Module에서 각각의 연결을 등록한다.

@Module({
  imports: [
    TypeOrmModule.forRoot({
      name: 'primary',
      type: 'mysql',
      host: process.env.PRIMARY_DB_HOST,
      database: process.env.PRIMARY_DB_NAME,
      autoLoadEntities: true,
      synchronize: false,
    }),

    TypeOrmModule.forRoot({
      name: 'feature',
      type: 'mysql',
      host: process.env.FEATURE_DB_HOST,
      database: process.env.FEATURE_DB_NAME,
      autoLoadEntities: true,
      synchronize: false,
    }),
  ],
})
export class ApplicationModule {}

primary와 feature는 단순한 설명용 문자열이 아니다. 이후 DataSource와 Repository를 찾는 토큰에 계속 사용되는 식별자다.

DataSource(primary)
DataSource(feature)

연결 이름을 생략하거나 서로 다르게 작성하면 다른 provider를 찾게 된다. 환경 변수의 DB 주소가 올바르더라도 DI 단계에서 Repository를 해석하지 못할 수 있다.





forFeature가 현재 Module에 Repository provider를 등록한다

연결을 만들었다고 모든 Module에서 모든 Repository를 바로 사용할 수 있는 것은 아니다. 실제로 Repository가 필요한 기능 Module에서 Entity와 연결 이름을 등록해야 한다.

@Module({
  imports: [
    TypeOrmModule.forFeature(
      [Account, AccessRule],
      'primary',
    ),
    TypeOrmModule.forFeature(
      [Task, TaskResult],
      'feature',
    ),
  ],
  providers: [TaskService],
})
export class TaskModule {}

이 코드는 Entity별 Repository 구현 파일을 생성하지 않는다. 애플리케이션이 실행될 때 TypeORM의 Entity Metadata와 DataSource를 이용할 수 있도록 Repository provider를 NestJS Module에 등록한다.

개념적으로는 다음과 같은 토큰이 만들어진다고 볼 수 있다.

RepositoryToken(Account, primary)
RepositoryToken(AccessRule, primary)
RepositoryToken(Task, feature)
RepositoryToken(TaskResult, feature)

따라서 같은 Entity라도 연결 이름이 다르면 별도의 Repository로 구분할 수 있다.

RepositoryToken(Account, primary)
≠
RepositoryToken(Account, feature)

Entity와 연결 이름으로 Repository 주입 토큰을 구분하는 흐름





InjectRepository는 등록할 때와 같은 토큰을 요청한다

서비스에서는 forFeature()에 사용한 Entity와 연결 이름을 동일하게 지정한다.

@Injectable()
export class TaskService {
  constructor(
    @InjectRepository(Account, 'primary')
    private readonly accountRepository: Repository<Account>,

    @InjectRepository(AccessRule, 'primary')
    private readonly accessRuleRepository: Repository<AccessRule>,

    @InjectRepository(Task, 'feature')
    private readonly taskRepository: Repository<Task>,

    @InjectDataSource('feature')
    private readonly featureDataSource: DataSource,
  ) {}
}

이 흐름을 등록과 조회로 나누면 이해하기 쉽다.

등록
TypeOrmModule.forFeature([Task], 'feature')
  → RepositoryToken(Task, feature)에 provider 등록

조회
@InjectRepository(Task, 'feature')
  → RepositoryToken(Task, feature) 요청

결과
두 토큰이 같으므로 feature 연결의 Task Repository 주입

@InjectDataSource('feature')도 같은 연결 이름을 사용하지만, 주입 대상은 Repository가 아니라 DataSource다. 여러 Repository를 하나의 트랜잭션으로 묶거나 QueryRunner가 필요한 경우에 직접 사용한다.

연결 이름을 빠뜨렸을 때 발생하는 문제

처음 Repository를 추가했을 때 가장 쉽게 놓친 부분은 연결 이름이었다. 다중 연결을 사용하는데 다음처럼 연결 이름을 생략하면 문제가 발생한다.

@InjectRepository(Task)
private readonly taskRepository: Repository<Task>

이 코드는 feature 연결의 Repository를 의미하지 않는다. 기본 연결에 해당하는 토큰을 요청한다. 기본 연결에 해당 Repository가 등록되지 않았다면 애플리케이션 시작 과정에서 의존성을 해결하지 못한다.

Nest can't resolve dependencies of the TaskService ...

오류 메시지는 Service의 생성자 문제처럼 보이지만 원인은 여러 위치에 있을 수 있다.

증상 자주 발생한 원인 먼저 확인할 위치
애플리케이션 시작 시 특정 Repository를 해석하지 못함 @InjectRepository()에서 연결 이름 누락 Service 생성자
Entity를 등록했는데도 주입 실패 forFeature()와 주입부의 연결 이름 불일치 기능 Module과 Service
다른 Module에서는 되는데 현재 Module에서만 실패 Repository provider가 현재 Module 범위에 없음 Module의 imports
테스트에서만 의존성 해석 실패 테스트 대역에 기본 연결 토큰 사용 getRepositoryToken() 호출부
트랜잭션에서 다른 DB의 Repository가 필요함 잘못된 DataSource를 주입함 @InjectDataSource() 연결 이름

나는 이 표의 순서대로 연결 이름, Module 범위, 테스트 토큰을 확인했다. DB 연결 자체가 생성됐다는 사실만으로 Repository 주입도 올바르다고 판단하면 안 된다.





공통 기능은 Repository가 아니라 Service 경계로 공개한다

여러 기능에서 같은 사용자 접근 규칙이 필요하다고 해서 각 Module이 관련 Repository를 모두 주입받으면 조회 조건과 예외 처리도 함께 중복된다.

공통 Module에서 Repository를 사용하고, 다른 Module에는 Service만 공개할 수 있다.

@Module({
  imports: [
    TypeOrmModule.forFeature(
      [Account, AccessRule],
      'primary',
    ),
  ],
  providers: [AccessService],
  exports: [AccessService],
})
export class AccessModule {}

기능 Service는 데이터 구조보다 업무 기능에 가까운 의존성을 받는다.

constructor(
  private readonly accessService: AccessService,
  @InjectRepository(Task, 'feature')
  private readonly taskRepository: Repository<Task>,
) {}

여기서 exports하는 것은 AccessService다. 내부 Repository까지 외부에 공개할 필요는 없다. Module이 Repository 구성과 조회 규칙을 감추고, 외부에는 접근 가능한 대상 조회와 같은 기능만 제공한다.

다만 Service를 providers에 등록하는 것만으로 다른 Module에서 사용할 수 있는 것은 아니다. 제공하는 Module의 exports와 사용하는 Module의 imports가 함께 맞아야 한다.





테스트도 운영 코드와 같은 토큰을 사용한다

DI 토큰을 이해하면 실제 DB 없이 Service를 단위 테스트하는 방법도 자연스럽게 이어진다.

운영 코드가 요청하는 토큰이 다음과 같다면,

@InjectRepository(Account, 'primary')
private readonly accountRepository: Repository<Account>

테스트 Module에는 같은 토큰으로 가짜 객체를 등록한다.

const findOne = jest.fn();

const moduleRef = await Test.createTestingModule({
  providers: [
    AccessService,
    {
      provide: getRepositoryToken(Account, 'primary'),
      useValue: { findOne },
    },
    {
      provide: getRepositoryToken(AccessRule, 'primary'),
      useValue: { find: jest.fn() },
    },
  ],
}).compile();

Service 코드는 변경되지 않는다. NestJS Container가 동일한 토큰에 연결된 provider를 실제 Repository 대신 테스트 객체로 넘긴다.

같은 Repository 토큰으로 운영 구현과 테스트 대역을 교체하는 구조

이 방식의 핵심은 TypeScript의 Repository<Account> 타입을 흉내 내는 것이 아니라 운영 코드가 요청하는 런타임 토큰을 동일하게 맞추는 것이다.

연결 이름을 빼고 다음처럼 작성하면 운영 코드와 다른 토큰이 된다.

// 운영 코드는 Account + primary 토큰을 요청한다.
{ provide: getRepositoryToken(Account), useValue: fakeRepository }

따라서 다중 DB 환경의 Repository 테스트에서는 Entity뿐 아니라 연결 이름까지 운영 코드와 동일한지 확인해야 한다.

테스트를 작성할 때도 한 번 같은 실수를 했다. Repository 타입에 맞는 가짜 객체를 만들었기 때문에 주입될 것이라고 생각했지만, NestJS는 TypeScript 타입이 아니라 런타임 토큰으로 provider를 찾는다. 테스트 Module에 getRepositoryToken(Account)를 등록하고 운영 Service가 getRepositoryToken(Account, 'primary')를 요청하면 두 토큰은 일치하지 않는다.

운영 코드와 테스트 코드를 다음처럼 한 줄씩 대응시켜 확인하는 편이 가장 빨랐다.

운영 코드의 주입 대상 테스트 Module의 provider 토큰
@InjectRepository(Account, 'primary') getRepositoryToken(Account, 'primary')
@InjectRepository(Task, 'feature') getRepositoryToken(Task, 'feature')
@InjectDataSource('feature') getDataSourceToken('feature')

가짜 Repository에는 테스트 대상 메서드가 실제로 호출하는 기능만 넣었다. 예를 들어 조회 로직을 검증한다면 findOne을 제공하고, 호출 인자와 반환값을 확인했다. TypeORM Repository 전체를 흉내 내기보다 Service가 사용하는 계약만 작게 구성하는 편이 테스트 의도를 읽기 쉬웠다.





트랜잭션에서는 같은 연결의 manager를 사용한다

연결 이름을 정확히 주입했다고 해서 여러 데이터베이스의 작업이 하나의 트랜잭션으로 묶이는 것은 아니다. feature DataSource에서 시작한 트랜잭션은 해당 연결이 관리하는 작업만 제어한다.

await this.featureDataSource.transaction(async (manager) => {
  const taskRepository = manager.getRepository(Task);
  const resultRepository = manager.getRepository(TaskResult);

  await taskRepository.save(task);
  await resultRepository.save(result);
});

처음에는 생성자에서 주입받은 Repository를 트랜잭션 콜백 안에서도 그대로 사용하기 쉬웠다. 하지만 해당 작업을 같은 트랜잭션에 포함하려면 콜백으로 전달된 manager에서 Repository를 가져와야 한다. 반대로 primary와 feature처럼 서로 다른 연결의 변경을 한 번에 원자적으로 처리할 수 있다고 가정해서도 안 된다.

작업 범위 처리 기준
같은 DataSource 안의 여러 Repository 하나의 transaction()과 callback manager 사용
서로 다른 DataSource의 변경 각 연결의 트랜잭션과 실패 보상 방식을 별도로 설계
단순 조회 후 한 연결만 변경 변경이 발생하는 연결을 기준으로 트랜잭션 설정





DI가 의존성의 종류까지 줄여 주지는 않는다

DI를 사용한다고 Service의 결합도가 자동으로 낮아지는 것은 아니다.

constructor(
  @InjectRepository(EntityA, 'primary') private readonly a: Repository<EntityA>,
  @InjectRepository(EntityB, 'primary') private readonly b: Repository<EntityB>,
  @InjectRepository(EntityC, 'feature') private readonly c: Repository<EntityC>,
  @InjectRepository(EntityD, 'feature') private readonly d: Repository<EntityD>,
  // ...
) {}

생성자 의존성이 계속 늘어난다면 그 Service가 너무 많은 책임을 맡고 있지 않은지 점검해야 한다. 관련 조회를 별도 Service로 묶거나 업무 경계에 따라 Service를 나누는 편이 나을 수 있다.

현재 방식은 TypeORM의 Repository<T>에 직접 의존한다. 테스트 대역을 주입하기는 쉽지만 ORM을 완전히 교체할 수 있는 Repository Port를 둔 구조는 아니다.

// 현재 구조와는 다른 수준의 추상화
interface AccountRepositoryPort {
  findActiveById(id: number): Promise<Account | null>;
}

이런 인터페이스가 반드시 필요한 것은 아니다. ORM 교체 가능성, 도메인 로직의 복잡도, 테스트 범위를 고려하지 않고 모든 Repository를 한 번 더 감싸면 파일과 매핑 코드만 늘어날 수 있다.

중요한 것은 현재 구조가 제공하는 범위를 정확히 이해하는 것이다.

  • NestJS DI를 통해 Repository provider를 교체할 수 있다.
  • Entity와 연결 이름으로 여러 Repository를 구분할 수 있다.
  • TypeORM과의 컴파일 및 런타임 의존성까지 제거한 구조는 아니다.
  • DI는 트랜잭션 경계나 업무 책임을 대신 설계하지 않는다.





다중 DB Repository 주입 확인 순서

다중 연결에서 의존성 오류가 발생하면 다음 순서로 확인한다.

  1. forRoot() 또는 forRootAsync()에 연결 이름을 지정한다.
  2. forFeature()에 Entity와 같은 연결 이름을 전달한다.
  3. @InjectRepository()에도 같은 연결 이름을 전달한다.
  4. Repository를 사용하는 Module 범위에 해당 provider를 등록한다.
  5. 공통 Service를 제공하는 Module에서 Service를 exports한다.
  6. 공통 Service를 사용하는 Module에서 제공 Module을 imports한다.
  7. 테스트의 getRepositoryToken()에도 같은 연결 이름을 전달한다.
  8. 트랜잭션용 DataSource도 올바른 연결 이름으로 주입한다.

문자열로 된 연결 이름이 여러 파일에 반복되면 상수로 관리하는 방법도 고려할 수 있다.

export const DB_CONNECTION = {
  PRIMARY: 'primary',
  FEATURE: 'feature',
} as const;

다만 상수를 사용하더라도 Module 등록과 주입의 연결 관계를 대신 확인해 주지는 않는다. 연결별 Module을 작게 유지하고, 한 Service가 불필요하게 여러 데이터베이스를 넘나들지 않게 하는 것이 더 중요하다.





마치며

여러 데이터베이스를 사용하는 NestJS 애플리케이션에서 Repository는 TypeScript 타입만으로 구분되지 않는다. Entity와 연결 이름으로 만들어진 DI 토큰이 어떤 DataSource의 Repository를 주입할지 결정한다.

forRoot()는 이름 있는 데이터베이스 연결을 만들고, forFeature()는 현재 Module에서 사용할 Repository provider를 등록한다. @InjectRepository()는 같은 토큰을 요청하며, 테스트에서는 getRepositoryToken()으로 동일한 자리에 가짜 구현을 넣는다.

이 구조를 이해하고 나면 Nest can't resolve dependencies 오류를 단순한 Module 설정 문제로만 보지 않게 된다. 등록한 토큰과 요청한 토큰이 같은지, 그리고 그 provider가 현재 Module의 범위 안에 있는지를 순서대로 확인할 수 있다.

DI의 장점은 객체 생성을 숨기는 데만 있지 않다. 운영과 테스트에서 같은 의존성의 자리를 유지하면서, 실행 환경에 맞는 구현을 연결할 수 있다는 데 있다.