4. 유저 서비스의 정의와 의존성 주입
요약
이전 글에서는
- 모듈, 컨트롤러, 프로바이더의 개념과 그것들의 관계에 대해 설명하였습니다.
- 모듈(Module)은 컨트롤러와 프로바이더를 포함하는, 하나의 기능 단위(응집체)입니다.
- 컨트롤러(Controller)는 접근하는 사용자로부터의 요청을 수립하고 응답을 반환합니다.
- 서비스(Service)는 요청 내용으로부터 서버가 실현하고 싶은 핵심 기능과 로직을 수행합니다.
본 글에서는
- 유저 데이터를 관리하는 서비스를 예시 구현하기 위한 몇가지 요소들에 대해서 설명합니다.
- 유저 서비스의 기능 및 모델(데이터)을 정의합니다.
- 유저 모듈-컨트롤러-프로바이더 파일을 생성 및 구성합니다.
- 또한, 저번 글에 이은 의존성 주입(DI) 기술에 대한 설명과 예시를 설명합니다.
4-1. 유저 서비스
유저 서비스는 유저 데이터를 관리하기 위해 구현된 서비스를 의미합니다.
서버가 유저 서비스를 제공함으로써, 외부 사용자는 유저를 관리할 수 있게 됩니다.
본 글에서는 아래와 같은 대표적인 유저 서비스 기능으로 아래와 같은 기능을 구현하고자 합니다.
실제로 운영되는 유저 서비스는 훨씬 더 복잡하게 구현될 것입니다. 특히, 유저의 인증 서비스를 유저 서비스와 분리하여 인증 서비스에 집중된 로직을 별도로 구현하게 됩니다. 다만, 본 문서에서는 내용의 이해와 흐름을 위해 단편적인 유저 서비스를 구현합니다.
기능명 | 메소드명 | 예시 설명 |
유저의 새로운 생성 | `createUser` | 사용자로부터 새로운 유저의 정보를 입력받아 유저를 생성합니다. 생성하는 유저는 다른 유저와 구분될 수 있어야 하기 때문에, 특정 고유 정보가 존재해야 합니다. |
생성된 모든 유저를 조회 | `findAllUsers` | 현재 존재하는 모든 유저들의 정보를 반환합니다. 단, 요청 시 반환할 수 있는 유저 정보를 구분할 필요가 있습니다. |
조건에 따른 하나의 유저를 조회 | `findUserBy` | 조건에 따라 유저 한 명의 정보를 반환합니다. 유저 한 명을 검색하기 위해서는 그 한 명을 특정지을 수 있어야 하기 때문에, 조건에는 특정 고유 정보가 포함되어야 합니다. |
유저의 정보 변경 | `updateUser` | 유저 한 명의 정보를 변경합니다. 유저의 정보를 변경하기 위해서는 앞선 유저 조회와 같이 하나의 유저를 특정지을 수 있어야 합니다. 따라서 특정 고유 정보와 변경할 새로운 정보가 같이 전달되어야 합니다. |
유저의 삭제 | `deleteUser` | 유저의 정보를 변경하는 기능과 같이, 특정 고유 정보를 통해 하나의 유저를 특정지으면, 해당 유저를 삭제합니다. |
위 테이블의 예시는 CRUD(Create/Read/Update/Delete) 라고 불리우는 데이터의 생성/조회/변경/삭제의 기능을 나타내고 있습니다. 저 서비스는 유저의 관리를 도모하기 때문에, 유저의 CRUD 기능을 기본적으로 구현하게 됩니다.
구현할 유저 서비스의 기본 다섯 가지 기능을 정의하였다면, 이제 그 기능에서 실제로 다루게 되는 "데이터"가 무엇인지 정의해야 합니다. 즉, 유저 서비스에서 생성/조회/변경/삭제를 수행하며 관리하는 "유저"란 무엇인가에 대하여 정의합니다.
4-2. 유저(User)의 정의
본 글에서는 "유저" 데이터를 아래와 같이 정의하고자 합니다.
항목 | 영명 |
유저의 이름 | Nickname |
유저의 이메일 | |
유저의 비밀번호 | Password |
유저의 생년월일 | Birth |
설계와 정의에 따라 유저는 다르게 결정될 수 있으며 가지는 데이터 또한 다르게 정의될 수 있습니다. "서버에 접근하는 모든 주체"가 될 수도 있고, "회원가입을 완료한 주체"가 될 수도 있습니다. 본 글에서는 서버에 데이터가 저장되는, 즉, 회원가입을 완료(= 유저 정보를 저장)한 주체를 유저라고 정의합니다.
이는 데이터 도메인을 담은 객체(Object)가 되거나, 데이터베이스와 연계하여 개체(Entity)로 구현/표현합니다.
아직 데이터베이스에 대한 연결을 수행하지 않았기 때문에, 객체로 취급하여 구현하도록 하겠습니다.
유저 데이터는 항목 자체만으로도 비즈니스 로직을 포함할 수도 있으며, 관련된 기능들의 구현에서 비즈니스 로직을 포함할 수 있습니다.
구현 예시에서, 사용자는 유저 서비스를 통해 자신의 정보(유저 데이터)를 저장, 조회, 변경, 삭제할 수 있으며, 해당 기능들의 내부에서 데이터와 연계된 비즈니스 로직이 정의됩니다.
src 경로에서 user 디렉토리를 새로 생성한 뒤, user.ts 파일을 작성합니다.
위의 예시와 같이 행동하는 객체(Object)를 만들기 위해 Class 형태로 구현하여 만드는 것은 객체 지향형 프로그래밍 방식입니다. 실제로는 객체의 행동(Action)을 기능(Method)으로 정의하여 직접 구현하는 것이 포함되어야 하나, 생략하도록 하겠습니다.
4-3. 유저 모듈, 컨트롤러, 프로바이더의 생성
유저와 관련된 기능을 구현하기 위하여, 유저 모듈, 컨트롤러 그리고 프로바이더를 생성하겠습니다.
Nest의 생성 Convention에 따라서 다음과 같이 명명하여 파일을 생성합니다.
- 유저 모듈 : user.module.ts
- 유저 컨트롤러 : user.contorller.ts
- 유저 프로바이터 : user.service.ts
새로운 파일들을 생성하였으면, 이제 각각의 파일들이 모듈-컨트롤러-프로바이더의 역할을 수행하도록 코드를 작성합니다.
저는 Import 하는 순서에 따라 Service-Controller-Module 순서대로 작성하는 편입니다.
user.service.ts 에서는 `@Injectable` 데코레이터를 이용하여 주입(Injection)될 수 있음을 표현합니다. 이는 user.module.ts 에 대한 의존성 주입을 수행하기 위해 필요합니다.
user.controller.ts 에서는 `@Controller` 데코레이터를 이용하여 컨트롤러임을 명시하며, 접근할 Route 이름을 지정합니다. 또한, 의존성 주입을 통해 `UserService` 를 이용할 것임을 작성해 줍니다.
user.module.ts 에서는 앞서 생성된 `UserService` 와 `UserController` 를 하나의 응집체로 묶어주면서,
의존성 주입을 통해 `UserModule` 하위의 소스코드들이 `UserService` 를 이용할 수 있음을 명시합니다.
위와 같이 각 모듈-컨트롤러-프로바이더의 파일들을 생성해 준다면, 유저 모듈에 대한 준비가 완료됩니다.
마지막으로 프로젝트에서 새롭게 생겨난 유저 모듈을 연결하기 위해 `AppModule` 에 `UserModule` 을 Imports 합니다.
그러면, 기존 실행은 아래와 같은 로그가 나오는 반면
다음과 같이 `UserModule` 종속성이 인식되면서 `UserModule` 에 정의한 `UserController` 의 내역이 함께 출력됩니다.
4-4. 의존성 주입 (Dependency Injection)
앞선 모듈-컨트롤러-프로바이더 소스코드를 직접 생성하면서 Module 내부로 Controller와 Provider를 주입하였습니다. 그리고 `AppModule` 에 `UserModule` 을 주입함으로써, `UserModule` 에 속한 기능들을 가져오고 있다는 것을 알 수 있습니다.
이와 같이 Module에서의 선언은, 해당 Module의 하위 종속성을 런타임 단위에서 동적으로 결정할 수 있도록 해줍니다.
4-4-1. IoC (Inversion of Control - 제어 역전/반전)
먼저, 의존성 주입을 설명하기 위해 같이 동반되는 용어인 IoC를 설명합니다.
IoC는 객체 또는 메소드의 호출(연동)을 개발자가 제어하는 것이 아니라 프로그램이 제어하는 것을 의미합니다. 구체적으로, 구현체 간의 의존 관계를 결정하거나 객체의 생명 주기를 다루는 것을 개발자가 미리 확정지어 두는 것이 아니라, 사용자의 요청에 따라 혹은 외부 환경에 따라 프로그램(혹은 프레임워크)이 직접 제어하는 것을 의미합니다.
또한, IoC는 그냥 컨테이너 혹은 IoC 컨테이너라고 불리우는, 코드의 처리 과정을 수행하는 별도의 기능에 의하여 관리됩니다. 이 컨테이너의 구현 또는 설정에 따라(일반적으로는 프레임워크의 기본 구현/설정에 따라), 컨테이너는 개발자의 코드를 참조하고 객체의 생명 주기를 관리해 줍니다.
4-4-2. DI (Dependency Injection - 의존성 주입)
DI는 IoC 프로그래밍 모델을 구현하는 방식 중 하나로써, 각 클래스의 의존 관계를 외부로부터 결정하는 기술입니다.
Nest에서는 IoC 컨테이너가 직접 객체 생명 주기를 관리하면서, 개발자가 구현한 Module의 의도에 맞도록 자동으로 의존성 주입을 수행해 줍니다. 이를 통해 개발자는 객체 생명 주기를 신경쓰지 않고 개발할 수 있으며, 환경설정에 따라 런타임 단위에서 주입되는 의존 관계를 동적으로 만들 수 있습니다.
4-4-3. Nest에서의 DI
객체 지향형 프로그래밍에 따라, 객체는 생성되고 나면 삭제할 때까지 그 상태를 추적해야 하기 때문에 관리를 요구하게 됩니다. 그렇기 때문에 의존성 주입이 없는 세계에서는, 어떤 객체에서 다른 객체의 기능을 이용하기 위해 그 객체를 생성한 뒤 이용해야 할 것입니다.
예를 들어, `UserController` 에서 `UserService` 를 직접 생성 및 할당해서 사용한다고 가정하겠습니다.
생성자(Constructor)를 통해 `UserService` 를 생성 및 할당하였습니다.
이 경우 생성된 `this.userService` 서비스 객체는 관리대상이 되었기 때문에, `UserController` 는 각 메소드에서 해당 서비스 객체를 이용할 때마다, 그 객체의 변경점에 대하여 고려해야 합니다. 만약 어떤 메소드에 의해 `this.userService` 에 있는 기능 또는 상태가 변경되었다면, 예상치 못한 코드 결과물을 내뱉을 수 있게 됩니다.
즉, `UserController` 가 `UserService` 를 직접 참조하고 있기 때문에 `UserService` 가 변경될 때마다 `UserController` 가 변경되어야 하는 의존성을 야기시킵니다. 이는 구현부일 수도 있고, 실제 실행중인 환경에서도 그럴 수 있습니다.
Nest는 의존성 주입을 통해 `UserController` 에서 사용할 `UserService` 를 `UserModule` 에서 결정하기 때문에, 객체의 생명 주기를 신경쓰지 않아도 됩니다. Nest에 구현된 IoC 컨테이너가 `UserController` 에 주입될 `UserService` 를 생성하고 관리합니다.
위 코드를 보면, 별도로 `UserService` 에 대한 객체 생성 없이 `UserService` 를 이용한 구현이 가능함을 볼 수 있습니다.
그러나 위 코드만을 봐서는 의존성 주입이 정확히 어떤 행동을 하는지 잘 보이지 않습니다. 실제로 그냥 외부에서 객체를 생성해서 대입해줄뿐, Module 단위에서 `UserService` 를 넣어준다는 것이 어떤 의미인지 이해되지 않습니다.
이는, 클래스 간 의존성을 분리시켜주는 "인터페이스" 코드와 Module 단위에서의 의존성 주입 예시로 확인할 수 있습니다.
물론 인터페이스를 이용하지 않고, `UserModule` 에서 `UserService` 를 주입하여 `UserController` 의 생성자에서 직접 `UserService` 를 사용하는 위의 예시 또한, 의존성 주입의 예시입니다. 다만, 이는 객체 생성 주기만을 관리하는 방식의 의존성 주입이고, 아래에서는 좀더 동적인 의존성 주입을 수행하는 예시를 설명합니다.
4-4-4. 인터페이스를 활용한 의존성 주입(DI)
우리는 클래스 간 의존성(종속성)을 피하기 위해 인터페이스를 이용합니다. 추상화된 인터페이스를 선언하고, 별도로 구현체를 구현하여 해당 구현체를 특정 레벨에서 설정하여 이용할 수 있습니다.
위의 예시를 기반으로, `UserController` 가 이용하고자 하는 대표적인 기능들을 인터페이스로 선언한 뒤, `UserService` 가 이 인터페이스를 구현하는 구현체가 되어, 앞선 객체로 인한 의존성을 회피할 수 있습니다.
예시로 `UserController` 가 `createUser` 메소드를 이용한다고 가정합니다. 인터페이스 `IUserService` 를 선언합니다.
`IUserService` 를 구현하는 객체는 해당 `createUser` 메소드를 구현해야만 합니다.
인터페이스를 선언하였기 때문에, `UserController` 에서는 `IUserService` 를 구현하는 구현체와 상관없이 이를 이용할 수 있습니다.
`UserController` 의 코드를 위와 같이 작성하면, 다음과 같은 의문이 들게 됩니다.
`IUserService` 는 인터페이스인데, `UserController` 에 대한 인터페이스의 실제 구현부가 언제 지정되는거지?
Nest에서는 의존성 주입을 통해 실제 구현부의 주입을 수행할 수 있기 때문에, Module 단위에서 Provider를 지정하여 인터페이스에 주입될 실제 구현부를 지정할 수 있습니다. 구체적으로, `UserController` 에서 이용하는 `IUserService` 에 대한 구현부(예시로는 `UserService`)가 `UserModule` 을 통해 주입될 수 있습니다.
의존성을 주입하기 이전에 Nest를 한번 실행해 보시기 바랍니다.
ERROR [ExceptionHandler] Nest can't resolve dependencies of the UserController (?). Please make sure that the argument Object at index [0] is available in the UserModule context.
Potential solutions:
- Is UserModule a valid NestJS module?
- If Object is a provider, is it part of the current UserModule?
- If Object is exported from a separate @Module, is that module imported within UserModule?
@Module({
imports: [ /* the Module containing Object */ ]
})
위와 같은 보기만 해도 읽기 싫은 에러가 발생할 것입니다. 찬찬히 읽어보면 다음과 같은 문구를 볼 수 있습니다.
- Nest can't resolve dependencies of the UserController (?).
- Nest에서 `UserController` 에 주입되는 인자를 이해하지 못하고 있습니다.
- 당연합니다. 인터페이스는 개발자가 인지하기 위한 언어이지, 실제로 코드가 실행되면 연계될/할당될 대상이 필요합니다. 추상화된 코드만이 연동되어 있기 때문에, 실제 실행에서 이용할 클래스 대상을 찾지 못하여 에러가 발생합니다.
- Please make sure that the argument Object at index [0] is available in the UserModule context.
- `UserController` 에서 이용되는 argument Object at index [0] (첫번째 인자 = `IUserService` 에 대한 오브젝트)를 이용할 수 있도록, `UserModule` 하위에서 인식할 수 있는지 확인해야 합니다.
- 이를 통해, `UserModule` 내부에서 현재 `IUserService` 의 실제 구현에 대한 클래스를 찾지 못했기 때문에, `UserController` 에 주입할 수 없어 발생하는 에러임을 알 수 있습니다.
정리하면, 의존성 주입이 올바르게 이루어지지 않는다면 Nest에서는 위와 같은 의존성 주입 에러를 발생시킵니다.
이는 결국 Module 단위의 구현을 수행하는 Nest 프레임워크에서는, 의존성 주입이 올바르게 이루어지기 위해, Module 내부에서의 의존성 체크(= Controller/Provider 간 또는 Provider/Provider 간의 주입)가 필수라는 것을 알 수 있습니다.
이제 `UserService` 를 `IUserService` 에 대한 구현체로 만들면서, 의존성 주입을 수행해 보겠습니다.
`UserService` 는 `IUserService` 를 구현하는 구현체로써, 위와 같이 변경됩니다.
`createUser` 메소드에 대한 상세가 해당 필수 구현 메소드에서 구현됩니다.
이제 `UserModule` 에서 의존성 주입을 통해, `UserController` 가 이용할 인터페이스 `IUserService` 에 대한 클래스로 `UserService` 를 지정할 수 있습니다. Nest에서는 Module에서 `provide` 구문을 이용하여 동적인 의존성 주입을 수행할 수 있습니다.
`UserModule` 에서, `provide` 와 `useClass` 구문을 이용하여 의존성 주입을 수행합니다. 이는 제공하는 토큰의 이름을 지정(provide)하고 어떤 클래스를 주입할지(useClass) 결정하는 구문입니다. 해석하면, "UserService" 라고 하는 토큰 이름에, `UserService` 클래스를 주입하도록 하겠다고 설정하는 것입니다.
Nest는 의존성 주입을 수행하는 방법으로 `useClass` 이외에도 `useValue` , `useFactory` , `useExisting` 등 다양한 방법으로 객체를 주입할 수 있도록 지원합니다. 객체를 불러오는 방식에 따라서, Providers에 대한 의존성 주입을 수행할 수 있습니다.
중요한 점은, 이 구문에 특정 클래스를 사용하기 위해서는 해당 클래스에게 `@Injectable` (주입할 수 있는) 데코레이터가 붙어 있어야 합니다. 이 때문에, 처음 구현된 `UserService` 는 해당 데코레이터와 함께 작성되었습니다.
이제 `@Inject` 데코레이터를 이용하여 `UserModule` 하위에 있는 모든 곳에서 해당 "UserService" 토큰을 통해 의존성 주입 받을 수 있게 됩니다. 이 토큰을 이용하면, 해당 구현부는 `UserService` 클래스로 대입됩니다.
`UserController` 의 코드를 위와 같이 변경하면, 이제 Nest의 실행이 올바르게 진행됩니다. 의존성 주입이 완료된 것입니다.
하지만 여기까지 진행했어도, 앞선 `UserService` 를 직접 의존성 주입하는 것과 인터페이스를 활용한 의존성 주입의 차이가 무엇인지 잘 느껴지지 않습니다. 그 차이는 Module 단위 또는 의존성 주입을 받는 위치에서 이용하는 토큰 이름을 결정하면서 발생합니다.
즉, 위 방식을 기반으로 하여 주어지는 토큰의 이름을 조절할 수 있다면, 이는 `UserController` 에서 이용하는 `IUserService` 에 대한 구현체를 조절하여 주입할 수 있다는 것을 의미합니다.
4-4-5. 의존성 주입(DI)의 활용 예시
이제와서 보이는 의존성 주입의 대표적인 예시는 런타임 단위에서 환경 분리에 따른 클래스의 동적 할당입니다.
예를 들어, 동일한 `UserController` 내에서, 개발 환경에서는 `UserDevService` 클래스를 이용하고 테스트 환경에서는 `UserTestService` 클래스를 해당 `IUserService` 인터페이스의 구현체로 이용하는 것입니다. 의존성 주입을 활용하면, 외부 환경설정에 따라 토큰을 달리 결정하여 같은 `UserController` 임에도 서로 다른 클래스를 주입하여 서로 다른 구현체를 이용할 수 있게 됩니다.
같은 내용의 서비스 두 개가 있습니다. (실제로 구현하게 되면 구현하는 메소드의 내용이 다를 것입니다.)
`UserDevService` 는 개발 환경에서, `UserTestService` 는 테스트 환경에서 이용하고자 합니다. 두 서비스는 모두 `IUserService` 인터페이스를 구현하고 있기 때문에, 인터페이스를 이용하는 `UserController` 에 주입될 수 있는 상태입니다.
`UserModule` 에서는 두 개의 서비스를 각각의 토큰에 대입될 수 있도록 지정해 줍니다. 토큰의 이름을 구분하기 위해 별도의 직관적인 값으로 지정해 주었습니다.
위 코드에서는, 의존성 주입 `@Inject` 내부에 대입되는 조건문에 따라 토큰이 결정됩니다. 토큰이 결정됨에 따라 주입되는 구현체가 달라지기 때문에, `UserController` 가 이용하는 `IUserService` 인터페이스의 실제 동작 내용이 달라지게 됩니다.
위에서는 예시를 위해 단순 `Boolean` 을 기입하였지만, 실제로는 런타임 환경을 확인하는 등 특정 조건에 따라 토큰이 결정되어 `UserController` 의 코드 수정 없이도 양쪽 서비스들을 이용할 수 있게 됩니다.
`UserDevService` 와 `UserTestService` 의 메소드 구현을 다르게 한 뒤, 실제로 조건문을 변경하여 실행시켜 보시기 바랍니다. 조건에 따라 주입되는 서비스가 다르기 때문에, 실행된 결과물이 해당 서비스의 메소드로부터 도출됩니다.
추가적으로, 토큰의 결정은 Service 내부 `@Inject` 데코레이터뿐만 아니라, Module의 `provide` 구문에서 결정할 수도 있으며, 별도의 함수로 구현하여 해당 함수 내부에 구현된 로직에 따라 결정되도록 할 수 있습니다. 이렇게 생성하는 객체를 특정 로직에 따라 할당/이용될 수 있도록 조절하는 것을 팩토리(Factory) 방식이라고 합니다. 앞서 소개한 `useClass`, `useFactory`, `useValue` 등의 구문과 연계하여 다양한 방식으로 의존성 주입을 수행할 수 있습니다.
결론
Nest에서는 Module 단위에서 의존성 주입을 통해 모듈 내부에서 주입될 클래스를 결정할 수 있습니다.
- Module의 `providers` 에서 `provide` 구문을 활용합니다.
해당 모듈에 속한 클래스들은 마치 전역으로 선언된 객체를 사용하는 것처럼 주입된 클래스들을 이용할 수 있으며, 이 클래스들은 IoC 컨테이너에 의해 알아서 생성 주기가 관리되기 때문에, 객체의 생성/삭제에 대하여 신경쓰지 않아도 됩니다.
- 클래스를 그대로 주입하는 경우 직접 이용 : `UserService` 가 직접 주입되었다면 그대로 이용
- 클래스에 대한 토큰으로 주입하는 경우 : `provide` 구문과 `Inject` 구문을 기반으로 토큰을 이용
또한, 런타임 단위에서 주어지는 조건들이나 환경에 따라 모듈 내로 주입되는 클래스를 결정할 수 있기 때문에, 해당 클래스들을 이용하는 클래스에서는 그 구현 내용에 대한 의존성이 줄어들게 됩니다.
여기까지 유저 서비스의 구현을 위한 정의들과 Nest에서의 의존성 주입에 대하여 설명하였습니다. 다음 글에서는 유저 서비스의 구현에 대한 구체적인 예시를 설명하도록 하겠습니다.
읽어주셔서 감사합니다.
'개발자 💻 > NestJS' 카테고리의 다른 글
class-validator 의 @IsBoolean 데코레이터와 enableImplicitConversion 설정된 Transform 의 문제 (0) | 2024.02.29 |
---|---|
[초보자의 눈으로 보는 NestJS] 5. 유저 서비스의 구현 (0) | 2024.02.24 |
[초보자의 눈으로 보는 NestJS] 3+. API 테스트를 위한 Postman (0) | 2023.07.19 |
[초보자의 눈으로 보는 NestJS] 3. 모듈, 컨트롤러, 그리고 프로바이더 (0) | 2023.07.18 |
[초보자의 눈으로 보는 NestJS] 2. NestJS 보일러플레이트 설치 (0) | 2023.03.13 |