서비스와 레포지토리

공통점과 차이점

  • 둘 다 클래스이다.
  • 서비스는 비즈니스 로직을 넣는 곳이다.
  • 레포지토리는 저장과 관련된 로직을 넣는 곳이다.
  • 서비스는 데이터를 찾거나 저장하기 위해 하나 이상의 레포지토리를 사용한다.

  • 레포지토리는 일반적으로 TypeORM entity, Mongoose schema 등으로 끝난다.

서비스와 레포지토리에서 사용하는 메서드는 동작이 비슷하다. 서비스에서 ‘데이터를 찾아라’ 라는 메서드를 호출할 것이고 레포지토리에서는 그 메서드를 실행할 것이기 때문. 그럼에도 서비스는 필요하다. 이유는 후술!

레포지토리 만들기

  1. src 디렉토리 내에 messages.repository.ts와 messages.service.ts를 만든다.

  2. 루트 디렉토리에는 messages.json 파일을 만든다. 내용은 비워둔다. (안 만들면 찾을 파일이 없어서 오류발)
  3. 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 명령어를 클릭한다.

img1

요청에 성공했다.

두번째 요청을 보내서 메시지를 등록하자.

img2

이제 다시 첫번쨰 요청을 보내본다. 정상이라면 메시지를 보냈으니 등록되었을 것이고 그 후 전체를 조회했으니 방금 등록한 “yeeeees” 메시지가 떠야한다.

img3

나는 위처럼 잘 떴다. 두번 보내서 두 개 등록됐다. 세번째 요청은 id로 메시지를 조회하는 요청이였으니 위에 입력된 id인 110을 입력하여 메시지가 조회되는지 테스트해보자. 아이디는 무작위값으로 해놨으니 사람마다 다를 수 있으니 본인 id를 입력하자.

img4

성공!

에러 처리

존재하지 않는 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

  1. 컨테이너에 모든 클래스들을 등록
  2. 컨테이너는 각 클래스들이 어떤 종속성을 가지는지 파악
  3. 우리는 컨테이너에게 필요한 클래스의 인스턴스를 생성할것을 요청
  4. 컨테이너는 필요한 모든 종속성을 만들고 인스턴스를 응답
  5. 컨테이너는 생성된 종속성 인스턴스들을 가지고 있다 필요할 때 재사용(추후에 관련된 이야기가 나온다.)

현재 우리가 만든 것의 프로젝의 흐름은

  1. 컨트롤러 내놔
  2. 컨트롤러 만들려면 서비스 필요해
  3. 서비스 내놔
  4. 서비스 만들려면 repo 필요해
  5. repo 내놔
  6. repo는 필요한게 없네? 만들어줄게. 자 여기 repo !
  7. 오 repo. 이제 서비스 만들어줄게. 자 여기 서비스 !
  8. 오 서비스. 이제 컨트롤러 만들어줄게 자 여기 컨트롤러!
  9. 만들어논 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에 들어가서 확인해보면 잘 작동된다!