🗓️ 2025. 04. 12
⏱️ 9

Nest.js에서 Spring처럼 Interface 주입하기

제목은 어그로고, 사실 추상 클래스 씁니다.

개요

Nest.js에서는 Spring과 달리 Interface를 주입할 수 없습니다.
Spring 경험자 입장에서는 가장 당황스러운 차이점 중 하나가 아닐까 싶은데요, 이는 TypeScript의 Interface가 런타임에는 존재하지 않기 때문입니다. 하지만 추상 클래스를 이용한다면 마치 Spring처럼 Interface를 주입하는 듯한 DX를 만들어낼 수 있습니다.

개인적으로 제가 선호하는 방식이자, 일종의 아이디어를 제안하는 글이므로 '이렇게 쓰는 사람도 있구나' 하고 가볍게 읽어주시면 감사하겠습니다.

보편적인 DI

기본적으로 Nest.js는 Interface 자체를 주입할 순 없습니다.
타입인 Interface는 빌드 이후 사라져버리니, 모듈을 초기화하는 과정에서 참조할 수 있는 값이랄 게 없기 때문입니다.
그래서 Interface에 의존하려면 문자열이나 Symbol을 선언해 토큰으로 주입해야 하죠.

가장 익숙할 Repository 패턴을 예로 들어보겠습니다.
(ORM이 난무하는 생태계이기도 하니까요...)

보통은 아래처럼 의존할 Interface와 토큰으로 사용할 값을 함께 선언합니다.

export const POST_REPOSITORY = Symbol('PostRepository'); // Symbol 혹은 문자열
export interface IPostRepository {
  findById(id: number): Promise<Post>;
	// ...
}

그리곤 Interface를 구현하는 구현체를 만들고,

export class PostRepositoryImpl implements IPostRepository {
  async findById(id: number): Promise<Post> {
    // ...  
  }
}

토큰과 @Inject 데코레이터를 이용해 의존성을 주입한 뒤 Interface로 타입을 명시해줍니다.

@Injectable()
export class PostService {
  constructor(@Inject(POST_REPOSITORY) private readonly postRepository: IPostRepository) {}
  // ...
}

당연히 모듈쪽에선 다음과 같이 provide에 토큰을, useClass에 구현체를 지정해줘야 합니다.

@Module({
  providers: [
    PostService,
    {
      provide: POST_REPOSITORY,
      useClass: PostRepositoryImpl
    }
  ]
})
export class PostModule {}

저는 Nest.js를 이용하던 초기부터 이 방식이 마음에 들지 않았습니다.
Interface에 의존하게끔 만드는 데 토큰이라는 관리 요소가 추가되는 데다, 의존성이 있는 클래스에서도 @Inject 데코레이터 사용으로 인해 생성자가 지저분해지는 게 몹시 보기 싫었거든요.

그래서 Spring처럼 토큰 없이 Interface를 주입할 순 없을까 고민해봤습니다.

Interface 주입의 대안, 추상 클래스

@Inject를 이용한 토큰 주입 방식은 TypeScript의 Interface가 런타임에 실체가 없는 탓에 사용한 방식이었습니다.
그런데... 우린 이미 구현은 없지만 타입은 있고, 런타임에 값으로 실재하는 개념을 알고 있습니다. 바로 추상 클래스입니다.

아까 예시로 들었던 Repository 패턴을 추상 클래스를 이용해 리팩토링해보겠습니다.

// 추상 클래스는 런타임에 존재하는 값이므로
// Symbol 토큰은 불필요해집니다.
export abstract class IPostRepository {
  abstract findById(id: number): Promise<Post>;
}
// 구현체는 그대로입니다.
export class PostRepositoryImpl implements IPostRepository {
  async findById(id: number): Promise<Post> {
    // ...  
  }
}
@Injectable()
export class PostService {
  // 마찬가지로 @Inject와 Symbol 토큰은 불필요해집니다.
  constructor(private readonly postRepository: IPostRepository) {}
  // ...
}
@Module({
  providers: [
    PostService,
    {
	  // Symbol 토큰 대신 추상 클래스를 provide에 사용합니다.
      provide: IPostRepository,
      useClass: PostRepositoryImpl
    }
  ]
})
export class PostModule {}

어떠신가요? 모듈에서도 이 Interface의 구현체로 이 클래스를 사용하겠다는 의도가 더 명확히 느껴집니다.
물론 Interface로 사용됨에도 실체는 추상 클래스이기 때문에, 모든 메서드에 abstract 키워드를 붙여줘야하는 단점이 생기지만 그 단점을 제외하면 이 편이 훨씬 더 직관적이고 이후 사용에 있어 편리합니다.

사실 토큰 자체를 이용하지 않는 것은 아닙니다.
아시다시피 클래스를 Provider로 사용할 경우, 별도의 provide 지정이 없으면 Nest.js는 자동으로 provide 속성을 클래스로 지정합니다. 추상 클래스로 Interface를 주입하는 건 이같은 기본 동작 방식을 활용한 것이죠.

@Module({
  providers: [
    PostService,
  ]
})
export class ModuleA {}

// 위, 아래 모듈은 똑같이 동작

@Module({
  providers: [
    {
      provide: PostService,
      useClass: PostService
    }
  ]
})
export class ModuleB {}

참고로, 이렇게 추상 클래스를 Interface로 사용하는 방식은 C++의 순수 가상 클래스와 유사합니다.
C++는 TypeScript나 Java/Kotlin과 달리 interface 키워드가 없기 때문에 순수 가상 함수 가지는 클래스를 Interface 개념으로 사용합니다.

class Shape {
public:
  virtual ~Shape() = default;

  virtual void draw() = 0; 
  virtual double area() = 0;
};
class Circle : public Shape {
private:
  double radius;

public:
  Circle(double r) : radius(r) {}

  void draw() override {
    // ... 
  }

  double area() override {
    return 3.14 * radius * radius;
  }
};

런타임에 존재해야 하는 Interface를 언어 레벨에서 지원하지 않을 때, 대안으로 클래스를 사용할 수 있는 점이 서로 닮아있습니다. 물론 정확히는 C++는 nominal typing이고, TypeScript는 structural typing이기 때문에 다른 점이 있지만요.

extends vs implements

추상 클래스로 Interface를 주입할 때 한 가지 선택해야 할 것이 있습니다.
바로 '추상 클래스를 어떻게 취급할 것인가?' 입니다.

우리가 Interface처럼 사용한다지만 실제론 추상 클래스이기 때문에 구현체를 만들 때 extends를 써야 할 것만 같은 생각이 들지만 개인적으로 저는 implements를 사용하는 게 더 옳다고 봅니다.

어디까지나 Interface를 주입하는 경험을 위해 추상 클래스를 빌려썼을 뿐이지, 추상 클래스의 기능이 목적인 건 아니니까요. 이런 이유로 구현체를 구현할 때에도 implements로 타입만을 가져오고, prefix로 I까지 붙여 Interface 목적임을 더 분명히 해야 한다고 생각합니다. TypeScript는 언어 특성상 값 공간(value space)과 타입 공간(type space)이 구분되므로 값으로서 존재할 수 있는 추상 클래스의 기능을 최대한 지워내 개발자의 실수를 줄이는 거죠.

저는 약 5년 정도 이같은 방식으로 사용해왔는데 그간 딱히 불편하거나 문제됐던 적은 없었습니다.
여러분은 어떻게 생각하시나요?

돌아가기
© 2025 VERYCOSY.