Search

4. Nest 아키텍처: 서비스와 리포지토리

1. 서비스 vs 리포지토리

1.1. 서비스와 리포지토리를 나누는 이유

서비스와 리포지토리는 역할이 다르지만 CRUD 작업을 하다 보면 자연스럽게 유사한 메서드가 많아진다.
이는 아주 흔한 일이기 때문에 괜찮다.
서비스와 리포지토리를 그럼에도 불구하고 분리하는 이유는 책임을 명확히 분리하고, 유지보수성과 확장성을 높이기 위함이다.

1. 책임 분리 (Separation of Concerns)

리포지토리데이터를 어떻게 저장하고 가져올지를 담당한다.
서비스비즈니스 로직을 적용하고, 여러 리포지토리를 조합하여 데이터를 가공하는 역할을 한다.
만약 하나의 클래스가 둘 다 담당한다면, 데이터베이스 관련 코드와 비즈니스 로직이 뒤섞여 유지보수하기 어려워진다.

2. 유지보수성 항상 (Maintainability)

데이터베이스 구조가 변경되더라도 리포지토리만 수정하면 되므로 서비스 코드에는 영향을 주지 않는다.
반대로, 비즈니스 로직이 변경되더라도 리포지토리는 그대로 두고 서비스만 수정할 수 있다.

3. 재사용성 증가 (Reusability)

여러 서비스에서 같은 데이터 조작 로직을 사용할 수 있도록 리포지토리를 공유할 수 있다.
예를 들어, UserRepository를 UserServiceAuthService에서 각각 사용할 수 있다.

4. 테스트 용이성 (Testability)

서비스 레이어를 분리하면 리포지토리를 모킹(mocking)하여 테스트하기 쉬워진다.
데이터베이스 없이도 서비스 로직을 단위 테스트(Unit Test)할 수 있다.
구분
서비스 (Service)
리포지토리 (Repository)
역할
비즈니스 로직 처리
데이터베이스 조작 (CRUD)
책임
여러 리포지토리를 조합하여 데이터를 가공
데이터 저장, 조회, 수정, 삭제
종속성
여러 리포지토리를 주입받아 사용
ORM (TypeORM, Prisma 등)을 통해 DB와 직접 연결
재사용성
특정 도메인에 맞춰 작성
여러 서비스에서 재사용 가능
테스트 용이성
비즈니스 로직을 독립적으로 테스트 가능
데이터베이스 없이 Mocking 가능

2. 리포지토리 구현하기

src 폴더에 messages.repository.ts 파일을 생성한 뒤 다음과 같이 코드를 작성한다.
// src/messages.repository.ts import { readFile, writeFile } from 'fs/promises'; export class MessagesRepository { async findOne(id: string) { } async findAll() { } async create(message: string) { } }
TypeScript
복사
그리고 모든 메시지를 저장할 작은 스토리지 역할을 하는 messages.jsons을 생성한다.
이 파일에 저장되는 다양한 메시지들을 어떻게 형식화할지 간단한 예를 작성해보면 다음과 같다.
{ "12": { "content": "hi there!", "id": 12 }, "13": { "content": "hi there!", "id": 13 }, "124": { "content": "hi there!", "id": 14 } }
JSON
복사
전체적으로 거대한 객체가 하나 있고, 그 안에는 ID를 나타내는 다양한 키가 존재하는 구조이다.
그리고 각각의 키 값은 메시지 객체가 될 것이다.
다시 리포지토리 파일로 돌아와서 다음과 findOne 함수를 작성한다.
import { readFile, writeFile } from 'fs/promises'; export class MessagesRepository { async findOne(id: string) { const contents = await readFile('messages.json', 'utf8'); const messages = JSON.parse(contents); return messages[id]; } }
TypeScript
복사
readFile('messages.json', 'utf8')
fs/promises 모듈을 사용하여 messages.json 파일을 비동기적으로 읽는다.
utf8 인코딩을 지정하여 텍스트 형식으로 데이터를 가져온다.
JSON.parse(contents)
파일에서 읽어온 JSON 문자열을 JavaScript 객체로 변환한다.
변환된 데이터는 { "id1": { id: "id1", content: "message1" }, ... } 같은 형태의 객체다.
return messages[id]
id를 키로 하여 해당 메시지를 찾고 반환한다.
존재하지 않는 id를 조회하면 undefined가 반환된다.
그런 다음 나머지 findAll() 함수와 create() 함수를 작성한다.
import { readFile, writeFile } from 'fs/promises'; export class MessagesRepository { async findOne(id: string) { const contents = await readFile('messages.json', 'utf8'); const messages = JSON.parse(contents); return messages[id]; } async findAll() { const contents = await readFile('messages.json', 'utf8'); const messages = JSON.parse(contents); return messages; } async create(content: string) { const contents = await readFile('messages.json', 'utf8'); const messages = JSON.parse(contents); const id = Math.floor(Math.random() * 999); messages[id] = { id, content }; await writeFile('messages.json', JSON.stringify(messages)); } }
TypeScript
복사
const id = Math.floor(Math.random() * 999);
0부터 998 사이의 랜덤한 숫자를 생성하여 메시지의 고유 ID로 사용한다.
Math.random()은 0 이상 1미만의 실수를 반환하므로, Math.floor()를 사용해 정수로 변환한다.
하지만 중복된 ID가 발생할 가능성이 있으므로, 실제 애플리케이션에서는 UUID 같은 고유한 식별자를 사용하는 것이 좋다.
messages[id] = { id, content };
id를 키로 사용하여 새로운 메시지 객체를 저장한다.
await writeFile('messages.json', JSON.stringify(messages));
변경된 메시지 객체를 파일에 다시 저장한다.
JSON.stringify(messages)를 사용하여 JavaScript 객체 → JSON 문자열로 변환한다.

3. 서비스 구현하기

우리의 목표는 리포지토리 앞단에 서비스를 위치시키는 것이다.
먼저 messages.service.ts 파일에 MessagesService라는 클래스를 만들고 export 한다.
export class MessagesService { }
TypeScript
복사
이 클래스에는 리포지토리의 사본이 필요하다. 따라서, 실제로 데이터를 저장하고 검색할 리포지토리를 사용하기 위해 import 한다.
import { MessagesRepository } from './messages.repository'; export class MessagesService { }
TypeScript
복사
그리고 클래스 생성자 안에는 우리의 MessagesRepository의 새로운 인스턴스를 생성하고 그걸 이 클래스의 속성에 할당할 것이다.
import { MessagesRepository } from './messages.repository'; export class MessagesService { messagesRepo: MessagesRepository; constructor() { // Service is creating its own dependencies this.messagesRepo = new MessagesRepository(); } }
TypeScript
복사
메시지 리포지토리는 서비스의 의존성이다. 다른 말로 하자면, 리포지토리가 없으면 서비스는 제대로 작동할 수 없다.
즉 우리는 이 두 클래스들 간에 의존성을 설정했다. 그리고 서비스는 자체적인 의존성을 생성하고 있다.
단, 위 코드는 추후에 리팩토링이 필요하다. 실제 앱에서는 이렇게 사용하지 않는다.
Nest에서는 의존성 주입이라고 하는 아주 특수한 시스템을 사용하여 클래스들 간의 의존성을 설정한다.
따라서, 위 코드는 아주 임시적인 코드고 다음 몇 장에 걸쳐 이 코드를 삭제할 것이다.
이제 서비스 안에 리포지토리에 접근하는 함수들을 각각 생성한다.
import { MessagesRepository } from './messages.repository'; export class MessagesService { messagesRepo: MessagesRepository; constructor() { // Service is creating its own dependencies this.messagesRepo = new MessagesRepository(); } findOne(id: string) { this.messagesRepo.findOne(id); } findAll() { return this.messagesRepo.findAll(); } create(content: string) { return this.messagesRepo.create(content); } }
TypeScript
복사

4. 컨트롤러 수동 테스트

컨트롤러도 마찬가지로 서비스의 앞단에 있기 때문에 의존성 주입을 위해 다음과 같이 코드를 작성해준다.
import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { CreateMessageDto } from './dtos/create-message.dto'; import { MessagesService } from './messages.service'; @Controller('messages') export class MessagesController { messagesService: MessagesService; constructor() { this.messagesService = new MessagesService(); } @Get() listMessages() { return this.messagesService.findAll(); } @Post() createMessage(@Body() body: CreateMessageDto) { return this.messagesService.create(body.content); } @Get('/:id') getMessage(@Param('id') id: string) { return this.messagesService.findOne(id); } }
TypeScript
복사
이제 세 라우트 핸들러가 데이터를 잘 응답해주는지 테스트를 해보자.
메시지 생성 테스트
POST http://localhost:3000/messages content-type: application/json { "content": "asdf" }
Plain Text
복사
HTTP/1.1 201 Created
JSON
복사
전체 메시지 조회 테스트
GET http://localhost:3000/messages
Plain Text
복사
HTTP/1.1 200 OK { "669": { "id": 669, "content": "asdf" } }
JSON
복사
상세 메시지 조회 테스트
GET http://localhost:3000/messages/669
Shell
복사
HTTP/1.1 200 { "id": 669, "content": "asdf" }
JSON
복사

5. Nest에 내장된 예외 라이브러리로 오류 보고하기

만약 상세 메시지 조회 테스트에서 messages.json에 없는 key 값을 이용해서 데이터를 조회 한다면 어떻게 될까?
GET http://localhost:3000/messages/123123
Plain Text
복사
HTTP/1.1 200 OK
JSON
복사
우리는 데이터가 조회되지 않을 때 별다른 예외처리를 안해주었기 때문에 200 OK 메세지가 응답된다.
하지만 우리가 원하는 응답은 데이터가 없을 때는 데이터가 없다는 것을 알릴 수 있는 적절한 응답이 필요하다.
이를 위해 컨트롤러 파일의 getMessage 함수를 다음과 같이 수정한다.
import { NotFoundException } from '@nestjs/common'; @Get('/:id') async getMessage(@Param('id') id: string) { const message = await this.messagesService.findOne(id); if (!message) { throw new NotFoundException('message not found'); } return message; }
TypeScript
복사
메시지가 없을 경우 message 변수에 undefined가 저장되기 때문에 조건문에 의해 NotFoundException 예외 처리를 던지게 된다.
GET http://localhost:3000/messages/123123
Plain Text
복사
HTTP/1.1 404 Not Found { "statusCode": 404, "message": "message not found", "error": "Not Found" }
JSON
복사
자주 사용하는 NestJS Built-in HTTP 예외
상태 코드
예외명 (@nestjs/common)
설명
400
BadRequestException
잘못된 요청 (유효성 검사 실패, 잘못된 데이터 형식 등)
401
UnauthorizedException
인증되지 않은 사용자 (로그인 필요)
403
ForbiddenException
권한 없음 (접근 제한)
404
NotFoundException
리소스를 찾을 수 없음
500
InternalServerErrorException
서버 내부 오류
502
BadGatewayException
잘못된 게이트웨이 응답
504
GatewayTimeoutException
게이트웨이 타임아웃

6. 의존성 주입

지금 우리 애플리케이션 안에는 아주 명확한 의존성 혹은 계층구조가 있다.
서비스는 올바르게 작동하기 위해 리포지토리에 의존하고, 컨트롤러는 서비스에 의존한다.
기존에 작성한 우리의 코드를 살펴보면 컨트롤러와 서비스는 아뢔와 같이 자체적으로 의존성을 생성하고 있다.
@Controller('messages') export class MessagesController { messagesService: MessagesService; constructor() { this.messagesService = new MessagesService(); } }
TypeScript
복사
그래서 MessageController의 인스턴스를 생성할 때마다 그 클래스는 자동으로 자체적인 의존성을 생성하고 있다.

6.1. 제어 역전

제어 역전 원칙은 이 원칙을 따르면 약간 더 쉽게 재사용 가능한 코드를 빌드하거나 작성할 수 있다고 하는 하나의 소프트웨어 엔지니어링 개념이다.
이 원칙에 따르면, 클래스 자체가 자신의 의존성의 인스턴스를 생성하지 않도록 클래스를 작성해야한다.
우리 코드의 몇 가지 다른 버전을 살펴보고, 제어 역전 원칙에 더 잘 부합되게 코드를 작성할 수 있는 몇 가지 방법을 살펴보자.

1) 나쁜 케이스

MessagesService가 인스턴스를 생성할 때마다 MessagesRepository의 인스턴스도 생성하고 있다.
export class MessagesService { messagesRepo: MessagesRepository; constructor() { this.messagesRepo = new MessagesRepository(); } }
TypeScript
복사

2) 보통 케이스

MessagesService가 자체적인 의존성을 생성하도록 하는게 아니라, 생성자에 대한 인수로서 의존성을 받도록 설정한다.
export class MessagesService { messagesRepo: MessagesRepository; constructor(repo: MessagesRepository) { this.messagesRepo = repo; } }
TypeScript
복사

3) 좋은 케이스

2번 케이스의 단점은 여전히 MessagesRepository에 의존하고 있다는 점이다.
MessagesServiceMessagesRepository에 직접 의존하지 않고, 대신 Repository 인터페이스에 의존하도록 변경한다.
interface Repository { findOne(id: string); findAll(); create(content: string); } export class MessagesService { messagesRepo: Repository; constructor(repo: Repository) { this.messagesRepo = repo; } }
TypeScript
복사
왜 3번 케이스가 좋은걸까?
기존 방식에서는 MessagesService가 MessagesRepository를 직접 생성했기 때문에, 테스트 시 실제 데이터베이스가 필요했을 수도 있다.
3번 케이스처럼 코드를 작성하면 실제로 하드 드라이브에 기록하고 하드 드라이브에서 파일을 읽는 대신에 FakeRepository를 만들어서 테스트가 가능하다.
그래서 우리는 클래스가 자체적인 의존성을 생성하도록 허용하지 말아야 한다.
하지만 이런 제어 역전이라는 개념 자체가 종종 독보다 실이 큰 경우가 있는 것으로 알려져있다.
제어 역전에 관련된 약간의 이슈를 살펴본 다음 의존성 주입 시스템으로 그걸 어떻게 해결하는지 알아보자.

6.2. 의존성 주입

제어 역전(Inversion of Control, IoC)을 사용하면 코드의 유연성과 재사용성이 향상되지만, 한 가지 큰 단점이 있다.
바로 의존성 관리의 어려움이다.

의존성 관리의 문제

앞서 제어 역전(Inversion of Control, IoC)의 개념을 적용하여 MessagesService가 MessagesRepository의 인스턴스를 직접 생성하지 않고, 외부에서 주입받도록 개선했다. 이제 다음과 같은 코드가 되었다.
const repo = new MessagesRepository(); const service = new MessagesService(repo); const controller = new MessagesController(service);
TypeScript
복사
우리가 일단 제어 역전을 사용하기 시작하면 컨트롤러를 만들기 위해 약 세 배 많은 코드를 작성해야한다.
그런데 만일 더 복잡한 애플리케이션을 만든다면 하나의 컨트롤러를 만들기 위해 이런 양의 코드를 작성해야할 수도 있다.
const repo = new MessagesRepository(); const service = new MessagesService(repo); const controller = new MessagesController(service); const repo = new MessagesRepository(); const service = new MessagesService(repo); const controller = new MessagesController(service); const repo = new MessagesRepository(); const service = new MessagesService(repo); const controller = new MessagesController(service);
TypeScript
복사

DI 컨테이너

위 문제를 해결하면서도 제어 역전을 이용할 수 있도록 하기 위해 우리는 의존성 주입이라는 기법을 도입할 것이다.
의존성 주입이 작동하는 데 있어 가장 핵심이 되는 것은 DI 컨테이너이다.
DI 컨테이너는 크게 두 가지 주요 역할을 수행한다.
1.
클래스 및 의존성 리스트 저장
2.
객체 인스턴스 관리
새로운 Nest 애플리케이션을 만들면 자동으로 DI 컨테이너가 생성된다.
애플리케이션을 시작할 때 애플리케이션 안에서 컨트롤러를 제외하고 우리가 만든 모든 클래스를 살펴보며 DI 컨테이너에 등록한다.

클래스 및 의존성 리스트 저장

애플리케이션에서 사용되는 모든 클래스 및 그것들의 의존성 목록을 유지한다.
어떤 클래스가 어떤 의존성을 필요로 하는지 미리 등록해둔다.

객체 인스턴스 관리

등록된 클래스를 기반으로 필요한 인스턴스를 생성하고 저장한다.
messagesRepomessagesServicemessagesController
인스턴스를 여러 번 생성하지 않고 재사용할 수 있도록 관리한다.
정리하자면 DI 컨테이너의 흐름은 다음과 같다.
1.
애플리케이션 시작 시, 모든 클래스를 컨테이너에 등록한다.
2.
컨테이너가 각 클래스가 필요로 하는 의존성을 분석한다.
3.
필요한 클래스의 인스턴스를 생성하도록 컨테이너에 요청한다.
여기서의 클래스는 거의 대부분 컨트롤러
4.
컨테이너가 모든 필요한 의존성을 생성한 후, 최종 인스턴스르 반환한다.
5.
컨테이너는 생성된 의존성 인스턴스를 유지하며, 필요할 때 재사용한다.

6.3. 의존성 주입을 이용하기 위한 리팩토링

그럼 이제 애플리케이션 안에서 DI 컨테이너를 사용하기 위해 리팩토링을 시작해보자.

MessagesService 리팩토링

export class MessagesService { constructor(messagesRepo: MessagesRepository) { this.messagesRepo = messagesRepo; } }
TypeScript
복사
생성자를 정의하여 의존성을 인수로 받도록 변경한다.
messagesRepo를 생성자 인수로 받아 초기화한다.
그리고 그 타입은 MessagesRepository가 될 것이다.
꿀팁: 생성자 안에서 따로 속성을 정의할 필요 없이, TypeScript의 public 키워드를 사용해 속성 정의를 간소화할 수 있다.
export class MessagesService { constructor(public messagesRepo: MessagesRepository) {} }
TypeScript
복사

MessagesController 리팩토링

컨트롤러도 마찬가지로 리팩토링해준다.
export class MessagesController { constructor(public messagesService: MessagesService) {} }
TypeScript
복사

DI 컨테이너에 서비스 및 리포지토리 등록

그 다음, 서비스리포지토리를 DI 컨테이너에 추가해야 한다.
NestJS에서 제공하는 @Injectable() 데코레이터를 추가하여 클래스가 DI 컨테이너에 등록되도록 한다.
import { Injectable } from '@nestjs/common'; @Injectable() export class MessagesService { constructor(public messagesRepo: MessagesRepository) {} } @Injectable() export class MessagesRepository { // 데이터 액세스 로직 }
TypeScript
복사
참고로, 컨트롤러는 @Injectable()을 추가하지 않아도 NestJS가 자동으로 인스턴스를 생성하고 필요한 의존성을 주입한다.

모듈에서 providers 리스트에 추가

모든 서비스와 리포지토리를 모듈의 providers 배열에 추가해야 한다.
import { Module } from '@nestjs/common'; import { MessagesController } from './messages.controller'; import { MessagesService } from './messages.service'; import { MessagesRepository } from './messages.repository'; @Module({ controllers: [MessagesController], providers: [MessagesService, MessagesRepository], }) export class MessagesModule {}
TypeScript
복사
providers: 다른 클래스에서 의존성으로 사용할 수 있는 클래스 목록
controllers: Nest가 자동으로 생성할 컨트롤러 목록

6.4. 의존성 주입에 관한 추가 참고사항

왜 DI의 좋은 케이스를 사용하지 않았는가?

6.1절에서 우리가 DI를 사용할 때, 나쁜 → 보통 → 좋은 케이스가 있다고 언급했었다.
좋은 케이스: 모든 의존성을 인터페이스로 정의하여 구현체 교체를 쉽게하는 방식
하지만 TypeScript의 제한으로 인해 좋은 케이스를 도입하는 것이 어렵고, 대신 보통 케이스를 많이 사용한다.
export class MessagesController { constructor(public messagesService: MessagesService) {} }
TypeScript
복사
NestJS도 프로젝트 내 모든 서비스, 리포지토리, 컨트롤러가 직접 클래스를 참조하는 방식을 채택했다.

싱글톤 패턴

Nest는 컨트롤러를 생성할 때 DI 컨테이너에 인스턴스 생성을 요청한다.
DI 컨테이너는 필요한 의존성을 확인하고 자동으로 인스턴스를 생성하며, 생성된 인스턴스를 내부 리스트에 저장한다.
동일한 의존성을 여러 클래스에서 요구하더라도, 컨테이너는 하나의 인스턴스만 생성하여 공유한다.
이를 증명하기 위한 방법은 다음과 같다.
export class MessagesController { constructor( public messagesService: MessagesService, public messagesService2: MessagesService, public messagesService3: MessagesService, ) { console.log(messagesService === messagesService2); console.log(messagesService2 === messagesService3); }
TypeScript
복사
위 코드를 실행시켜보면 콘솔에 true가 출력되는 것을 확인할 수 있다.
이렇게 생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴한다.
우리는 이와 같은 소프트웨어 디자인 패턴을 싱글턴 패턴 이라고 한다.

DI의 실질적인 이점

NestJS의 DI 시스템이 때로는 불필요한 복잡성을 유발하는 것처럼 보일 수도 있다.
하지만 테스트를 작성할 떄 엄청난 이점이 있다.
만약 테스트를 하지 않는다면, DI의 이점이 줄어들고 NestJS가 최적의 선택이 아닐 수도 있다.