서비스와 레포지토리
공통점과 차이점
- 둘 다 클래스이다.
- 서비스는 비즈니스 로직을 넣는 곳이다.
- 레포지토리는 저장과 관련된 로직을 넣는 곳이다.
-
서비스는 데이터를 찾거나 저장하기 위해 하나 이상의 레포지토리를 사용한다.
- 레포지토리는 일반적으로 TypeORM entity, Mongoose schema 등으로 끝난다.
서비스와 레포지토리에서 사용하는 메서드는 동작이 비슷하다. 서비스에서 ‘데이터를 찾아라’ 라는 메서드를 호출할 것이고 레포지토리에서는 그 메서드를 실행할 것이기 때문. 그럼에도 서비스는 필요하다. 이유는 후술!
레포지토리 만들기
-
src 디렉토리 내에 messages.repository.ts와 messages.service.ts를 만든다.
- 루트 디렉토리에는 messages.json 파일을 만든다. 내용은 비워둔다. (안 만들면 찾을 파일이 없어서 오류발)
- messages.repository.ts 파일에 코드를 작성한다.
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
// messages.repository.ts
import { readFile, writeFile } from 'fs/promises';
export class MessagesRepository {
async findOne(id: string) { // id로 특정 메시지 찾는 메서드
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));
}
}
service 만들기
messages.service.ts 파일을 다음과 같이 작성하자.
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
import { MessagesRepository } from './messages.repository';
export class MessagesService {
messagesRepo: MessagesRepository;
constructor() {
// 원래 서비스가 종속성을 만드는 위치
// 즉 서비스는 repository가 없으면 제대로 작동할 수 없다.
// 실제 서비스에선 코드 아직 사용하지 말것.
// 현재는 아래대로 하자.
this.messagesRepo = new MessagesRepository();
}
findOne(id: string) {
return this.messagesRepo.findOne(id);
}
findAll() {
return this.messagesRepo.findAll();
}
create(content: string) {
return this.messagesRepo.create(content);
}
}
위 코드에서 보이듯 서비스에서 사용되는 메서드들이 레포지토리와 동일하다. 그럼에도 서비스를 만드는 이유는 후술한다.
컨트롤러 테스트하기
서비스의 인스턴스를 만들어 컨트롤러에 준다. 컨트롤러가 알맞게 처리하는지 테스트해야 한다.
우선 비어있는 messages.json파일에
{}
만 입력해서 빈 배열로 만들어준다.
이제 저번에 만들어둔 requests.http에서 실험해본다. 서버 켜있는지 꼭 확인할 것.
GET http://localhost:3000/messages
줄 위에 있는 Send Request 명령어를 클릭한다.
요청에 성공했다.
두번째 요청을 보내서 메시지를 등록하자.
이제 다시 첫번쨰 요청을 보내본다. 정상이라면 메시지를 보냈으니 등록되었을 것이고 그 후 전체를 조회했으니 방금 등록한 “yeeeees” 메시지가 떠야한다.
나는 위처럼 잘 떴다. 두번 보내서 두 개 등록됐다. 세번째 요청은 id로 메시지를 조회하는 요청이였으니 위에 입력된 id인 110을 입력하여 메시지가 조회되는지 테스트해보자. 아이디는 무작위값으로 해놨으니 사람마다 다를 수 있으니 본인 id를 입력하자.
성공!
에러 처리
존재하지 않는 ID 조회
현재 존재하지 않는 ID로 요청해도 status가 200으로 나온다. 이를 404로 바꿔보자.
컨트롤러 파일을 수정해본다.
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
37
38
39
40
41
42
43
44
// messages.comtroller.ts
import {
Controller,
Get,
Post,
Body,
Param,
NotFoundException, // 추가된 코드
} 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);
}
// 1) 메시지 받기를 완료하고 진행하므로 await 추가, 이에 맞게 async를 추가
// 2) 받은 후 메시지가 있는 지 검사하여 없으면 에러 출력
@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;
}
}
이 후 requests.http 파일에 가서 id로 메시지를 조회하는 코드의 id를 없는 id로 입력한다.
GET http://localhost:3000/messages/1101123
HTTP/1.1 404 Not Found X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 68 ETag: W/”44-9BSLGx1wv7YfkWeJjmFCNfz7LQ0” Date: Fri, 06 Oct 2023 11:33:39 GMT Connection: close { “message”: “message not found”, “error”: “Not Found”, “statusCode”: 404 }
라고 뜬다.
NotFoundExceptions 를 control + click하면 모든 exception을 볼 수 있다.(bad request, time out, unauthorized 등등)
Inversion of Control
서로 다른 클래스 간에는 종속성, 계층 구조가 있다.
현재 우리가 만들고 있는 서비스의 구조를 생각해보면, Repository가 없으면 Service는 올바르게 작동할 수 없다. 마찬가지로 컨트롤러도 서비스에 의존하며 없으면 제대로 작동하지 않는다.
그리고 컨트롤러와 서비스는 스스로 종속성을 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 컨트롤러 코드 중 일부
@Controller('messages')
export class MessagesController {
messagesService: MessagesService;
constructor() {
this.messagesService = new MessagesService(); // 종속성 만드는 부분
}
// 서비스 코드 중 일부
export class MessagesService {
messagesRepo: MessagesRepository;
constructor() {
this.messagesRepo = new MessagesRepository(); // 종속성 만드는 부분
둘 다 인스턴스를 생성할때 마다 자체 종속성을 생성한다.
Dependency injection의 존재 이유
역전 제어 원리(Inversion of Control Principle) : 클래스는 자신의 종속성 인스턴스를 만들어선 안된다.
나쁜 예 : 현재 우리 코드
1
2
3
4
5
6
7
export class MessagesService {
messagesRepo: MessagesRepository;
constructor() {
this.messagesRepo = new MessagesRepository();
}
}
차선 : 종속성을 생성하지 않고 인수로 종속성을 얻도록 설정(실제 프로젝트에선 typescript의 한계로 인해 이 형태를 사용)
1
2
3
4
5
6
7
export class MessagesService {
messagesRepo: MessagesRepository;
constructor(repo: MessagesRepository) { // 복사본을 전달하는 형태
this.messagesRepo = repo;
}
}
최선 : 서비스가 종속성을 받지만 구체적으로 복사본을 얻지 않고 인터페이스를 정의한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
interface Repository {
findOne(id: string);
findAll();
create(content: string);
}
export class MessagesService {
messagesRepo: Repository;
constructor(repo: Repository) {
this.messagesRepo = repo
}
}
세 번째 코드가 가장 좋은 이유
테스트와 실제에서 구분하여 사용할 수 있다.
테스트 환경 : 하드디스크에 직접 메시지를 저장하지 않고 FakeRepository
를 만들어서 사용. HDD 안쓰기 때문에 훨씬 빠르다.
실제 서비스 환경 : 실제 하드디스크에 저장하도록 하면 된다.
이 ‘구분’된 사용은 세번째 코드에서만 쓸 수 있음. 인터페이스를 정의했기 때문에 우리가 원하는대로 바꿀 수 있음. 나머지는 진짜 데이터를 전달해야하니 테스트때도 하드디스크를 사용하고 느릴 수밖에 없다.
Dependency Injection(DI)
의존성 주입 없이 제어 역전 한다면?
1
2
3
const repo = new MessagesRepository();
const service = new MessagesService(repo);
const controller = new MessagesController(service);
컨트롤러 하나 만들기 위해 코드를 3줄 작성하였음. 실제 서비스에서 repository, service가 많아지면 더욱 복잡해진다.
이를 해결하기 위해 종속성 주입을 사용한다.
DI Container(Injector) Flow
- 컨테이너에 모든 클래스들을 등록
- 컨테이너는 각 클래스들이 어떤 종속성을 가지는지 파악
- 우리는 컨테이너에게 필요한 클래스의 인스턴스를 생성할것을 요청
- 컨테이너는 필요한 모든 종속성을 만들고 인스턴스를 응답
- 컨테이너는 생성된 종속성 인스턴스들을 가지고 있다 필요할 때 재사용(추후에 관련된 이야기가 나온다.)
현재 우리가 만든 것의 프로젝의 흐름은
- 컨트롤러 내놔
- 컨트롤러 만들려면 서비스 필요해
- 서비스 내놔
- 서비스 만들려면 repo 필요해
- repo 내놔
- repo는 필요한게 없네? 만들어줄게. 자 여기 repo !
- 오 repo. 이제 서비스 만들어줄게. 자 여기 서비스 !
- 오 서비스. 이제 컨트롤러 만들어줄게 자 여기 컨트롤러!
- 만들어논 repo랑 서비스는 뒀다가 필요하면 또 써야겠다.
로 이해할 수 있다.
코드 리팩터링
1. 서비스와 컨트롤러 코드 수정
1
2
3
//messages.service.ts
export class MessagesService {
constructor(public messagesRepo: MessagesRepository) {} // 위의 import, 이후 메서드는 동일함
1
2
3
4
//messages.controller.ts
@Controller('messages')
export class MessagesController {
constructor(public messagesService: MessagesService) {} // 위의 import, 이후 메서드는 동일함
2. 서비스와 repository에 inject decorator 추가하여 DI 컨테이너에 등록하기
1
2
3
4
5
6
//messages.repository.ts
import { Injectable } from '@nestjs/common';
import { readFile, writeFile } from 'fs/promises';
@Injectable()
export class MessagesRepository {
1
2
3
4
5
6
//messages.service.ts
import { Injectable } from '@nestjs/common';
import { MessagesRepository } from './messages.repository';
@Injectable()
export class MessagesService {
컨트롤러는 할 필요 없다.
3. 두 클래스를 모듈 목록에 추가하기 : provider로 설정
provider는 다른 클래스들을 위해 사용될 수 있는 종속성들을 의미한다.
1
2
3
4
5
6
7
8
9
10
11
12
//messages.moddule.ts
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 {}
이제 리팩터링 전과 동일하게 작동하는지 requests.http에 들어가서 확인해보면 잘 작동된다!