1. 서비스 vs 리포지토리
1.1. 서비스와 리포지토리를 나누는 이유
•
서비스와 리포지토리는 역할이 다르지만 CRUD 작업을 하다 보면 자연스럽게 유사한 메서드가 많아진다.
◦
이는 아주 흔한 일이기 때문에 괜찮다.
•
서비스와 리포지토리를 그럼에도 불구하고 분리하는 이유는 책임을 명확히 분리하고, 유지보수성과 확장성을 높이기 위함이다.
1. 책임 분리 (Separation of Concerns)
•
리포지토리는 데이터를 어떻게 저장하고 가져올지를 담당한다.
•
서비스는 비즈니스 로직을 적용하고, 여러 리포지토리를 조합하여 데이터를 가공하는 역할을 한다.
•
만약 하나의 클래스가 둘 다 담당한다면, 데이터베이스 관련 코드와 비즈니스 로직이 뒤섞여 유지보수하기 어려워진다.
2. 유지보수성 항상 (Maintainability)
•
데이터베이스 구조가 변경되더라도 리포지토리만 수정하면 되므로 서비스 코드에는 영향을 주지 않는다.
•
반대로, 비즈니스 로직이 변경되더라도 리포지토리는 그대로 두고 서비스만 수정할 수 있다.
3. 재사용성 증가 (Reusability)
•
여러 서비스에서 같은 데이터 조작 로직을 사용할 수 있도록 리포지토리를 공유할 수 있다.
•
예를 들어, UserRepository를 UserService, AuthService에서 각각 사용할 수 있다.
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
복사
•
이렇게 Nest에 내장된 예외 라이브러리를 사용하면 상황에 맞는 응답 코드와 적절한 메시지를 쉽게 반환할 수 있다.
•
자주 사용하는 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에 의존하고 있다는 점이다.
•
MessagesService가 MessagesRepository에 직접 의존하지 않고, 대신 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 컨테이너에 등록한다.
클래스 및 의존성 리스트 저장
•
애플리케이션에서 사용되는 모든 클래스 및 그것들의 의존성 목록을 유지한다.
•
어떤 클래스가 어떤 의존성을 필요로 하는지 미리 등록해둔다.
객체 인스턴스 관리
•
등록된 클래스를 기반으로 필요한 인스턴스를 생성하고 저장한다.
◦
messagesRepo → messagesService → messagesController
•
인스턴스를 여러 번 생성하지 않고 재사용할 수 있도록 관리한다.
•
정리하자면 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가 최적의 선택이 아닐 수도 있다.