Featured image of post 인터페이스 의존성 주입

인터페이스 의존성 주입

최근 Nest.js를 이용해서 다중 사용자가 동시에 참여할 수 있는 웹 기반 퀴즈 게임을 만드는 프로젝트의 백엔드를 구현하고 있습니다.(과거에 유행했었던 큐플레이와 비슷한 프로젝트라고 보시면 됩니다.)

큐플레이

팀 구성원들과 애자일한 개발을 경험하기 위해 필수적인 요소들을 배포하고 점진적으로 확장하기로 결정했기 때문에 진행 초반인 지금은 1인을 플레이를 기준으로 기능들을 만들어가고 있는데요

그러한 이유로 현재 구현중인 퀴즈 플레이 정보(이하 퀴즈존)는 사용자가 적다는 것을 가정하여 서버의 메모리에서 관리하도록 구현하고 있었습니다.

그런데 구현하는 과정에서 걱정 거리가 생겼어요

  • 최종 기획은 다중 사용자로 확정
  • 이후 채팅 기능을 지원할 수 있음

프로젝트가 발전할수록 서버의 메모리 공간이 아쉬워 질 수 있고, 서버 한대로 부족할 수 있기때문에 Redis 같은 것들을 이용해서 확장해야하지 않을까? 라는 걱정이었어요

물론 익스트림 프로그래밍(XP)의 원칙 중 YAGNI(You aren’t gonna need it)이나 KISS(Keep it simple, stupid)처럼 당장 필요하지 않은 것들은 구현하지 않을 것을 강조하긴합니다만…

아키텍처 관점에서 확장성변경에 대한 안전장치인터페이스는 당장 필요한 것이 아닌가?! 라는 판단합리화을 하게 되었습니다.

그래서 퀴즈존이 저장될 스토리지 인터페이스를 만들고, 메모리를 활용하는 스토리지를 인터페이스를 이용해 구현하기로 결정했어요

인터페이스를 통해 구현된 스토리지를 인터페이스에 의존하도록 만들기 위해서 인터페이스 의존성 주입이 꼭 필요했습니다.

이 과정에서 제가 학습하고 적용한 과정을 적어보려고합니다.

Nest.js의 철학

먼저 Nest의 철학을 살펴보면 앞으로 설명할 내용들이 조금 더 잘 이해될겁니다.

공식 문서의 나와있는 Nest의 철학을 살펴볼까요?

/* 생략… */

However, while plenty of superb libraries, helpers, and tools exist for Node (and server-side JavaScript), none of them effectively solve the main problem of - Architecture.

Nest provides an out-of-the-box application architecture which allows developers and teams to create highly testable, scalable, loosely coupled, and easily maintainable applications.

위에서 언급한 내용처럼 Nest는 아키텍처 문제를 효과적으로 해결하기 위해 만들어진 백엔드 프레임워크입니다.

이 철학을 바탕으로, 기능들을 모듈화하고 역할별로 분리하여 확장성과 테스트 가능성을 확보하는 것이 Nest의 큰 장점 중 하나이고 이를 구조적으로 강제하고있어요

DDD와 Nest Module

Nest는 도메인 주도 설계(DDD)를 쉽게 적용할 수 있도록 설계되었습니다.

Nest CLI의 nest g resource 명령어를 사용해 특정 도메인에 대한 모듈, 서비스, 컨트롤러 등을 생성하면, 관련 기능들이 한 모듈에 모여있는 DDD 형식으로 구조가 자동으로 만들어지게 되어요

반대로 이렇게 만들어진 모듈들을 다른 모듈에서 활용하려면 추가적인 설정을 필요로 하기 때문에 확실하게 도메인으로 분리되어야 쉽고 편하게 개발할 수 있는 환경을 강제하고 있습니다.

의존성 주입

Nest에서는 의존성 주입을 통해 객체 간의 의존성을 해결합니다.

DI는 @Injectable 데코레이터를 사용해 프로바이더를 정의하고, 이를 생성자에서 주입받아 사용할 수 있게 합니다.

1
2
@Injectable()
export class QuizService {}

위와 같이 구현된 퀴즈 서비스는 아래와 같이 같은 디렉터리에 만들어진 quiz.module.ts에 아래와 같이 활용됩니다.

1
2
3
4
5
6
7
8
@Module({
  controllers: [QuizController],
  providers: [
    QuizService,
  ],
  exports: [QuizService],
})
export class QuizModule {}

여기서는 리포지토리 인터페이스를 통해 퀴즈 정보를 관리하는 클래스를 만들고, 이 인터페이스를 기반으로 한 메모리 기반 처리와, 확장이 필요한 경우 레디스와 같은 외부 스토리지를 활용하도록 설계할 수 있습니다.

인터페이스 의존성 주입

기본적인 활용법은 확인했고, 기존에 제가 해결하려고 했던 인터페이스를 활용하면서 의존성을 주입하려면 어떻게 해야할까요?

과정을 하나씩 거치며 확인해보도록 하겠습니다.

인터페이스 정의

먼저 QuizRepository라는 인터페이스를 정의해볼게요

1
2
3
4
export interface QuizRepository {
  saveQuiz(quiz: Quiz): Promise<void>;
  getQuiz(id: string): Promise<Quiz>;
}

일단 간단하게 퀴즈를 저장하는 saveQuiz, 저장된 퀴즈를 가져오는 getQuiz만 구현하도록 했습니다.

메모리 기반 구현

이제 메모리 기반 리포지토리 클래스를 QuizRepository 인터페이스를 구현하여 작성합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Injectable()
export class QuizMemoryRepository implements QuizRepository {
  private readonly quizzes: Map<string, QuizData> = new Map();

  saveQuiz(quiz: Quiz): void {
    this.quizzes.set(quiz.id, quiz);
  }

  getQuiz(id: string): QuizData | undefined {
    return this.quizzes.get(id);
  }
}

간단하죠?

모듈에 프로바이더 설정

Nest의 @ModuleQuizMemoryRepositoryQuizRepository 타입으로 등록해 줍니다.

1
2
3
4
5
6
7
8
@Module({
  providers: [
    QuizService,
    { provide: 'QuizRepository', useClass: QuizMemoryRepository },
  ],
  exports: ['QuizStorage'],
})
export class QuizModule {}

리포지토리 사용

다른 클래스에서는 인터페이스 타입으로 스토리지를 주입받아 사용할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Injectable()
export class QuizService {
  constructor(
    @Inject('QuizRepository') 
    private readonly repository: QuizRepository
  ) {}

  createQuiz(data: QuizData) {
    this.storage.saveQuiz(data);
  }
}

이렇게 인터페이스를 사용하여 의존성을 주입하면, 추후 스토리지가 변경되더라도 코드 수정 없이 새로운 스토리지를 적용할 수 있게 되어 유연성과 확장성이 높아집니다.

리포지토리 개선

그렇다면 메모리 리포지토리의 의존성인 Map@Inject로 주입할 수 있겠죠?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Injectable()
export class QuizMemoryRepository implements QuizRepository {
  constructor(
    @Inject('QuizStorage') storage: Map<string, Quiz>
  ) {}

  saveQuiz(quiz: Quiz): void {
    this.quizzes.set(quiz.id, quiz);
  }

  getQuiz(id: string): QuizData | undefined {
    return this.quizzes.get(id);
  }
}

@Module에 등록해줍니다.

1
2
3
4
5
6
7
8
9
@Module({
  providers: [
    QuizService,
    { provide: 'QuizRepository', useClass: QuizMemoryRepository },
    { provide: 'QuizStorage', useValue: new Map() },
  ],
  exports: ['QuizStorage'],
})
export class QuizModule {}

큰 의미는 없어 보이지만 테스트코드를 작성해보면 약간의 이점을 얻을 수 있어요

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
describe('QuizZoneService', () => {
  let service: QuizZoneService;
  let storage: Map<string, QuizZone>;

  beforeEach(async () => {
    storage = new Map();

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        QuizZoneService,
        {
          provide: 'QuizRepository',
          useClass: QuizMemoryRepository,
        },
        {
          provide: 'QuizStorage',
          useValue: storage,
        },
      ],
    }).compile();

    service = module.get<QuizZoneService>(QuizZoneService);
  });

  describe('사용자가 퀴즈존 생성 요청을 보내면 퀴즈존을 생성한다.', () => {
    it('사용자의 세션 ID를 이용하여 퀴즈존을 생성한다.', async () => {
      const sid = '1234';

      await service.create(sid);

      const { player } = storage.get(sid);

      expect(player.id).toEqual(sid);
    });
  });
});

일반적으로 주입받은 의존성은 private으로 선언되기 때문에 접근할 수 없지만, 외부에서 주입해주기 때문에 실제 storage에 값이 적절히 들어갔는지 확실하게 판단할 수 있게되었습니다.

마무리

인터페이스를 사용한 의존성 주입은 확장성에 굉장히 큰 도움을 주게됩니다.

저의 경우에는 Repository를 인터페이스로 만들었기 때문에 추후 레디스와 같은 스토리지로 변경 필요할 때 Repository를 구현하기만 하면 손쉽게 교체가 가능합니다.

그리고 MemoryRepositoryMap을 외부에서 주입받도록 했기 때문에 비즈니스 로직테스트 할 때도 실제 처리가 잘 이루어지는지 확인하기 용이했었죠! 물론 모킹을 하고 의도대로 동작되는지만 확인해도 괜찮을 것 같긴해요

작고 보잘것 없는 예시이지만, 끝까지 봐주셔서 감사합니다.😊

다음번에는 Nest에서 의존성 주입하는 과정을 깊게 파볼게요😎