5. 유저 서비스의 구현
요약
이전 글에서는
- 유저의 모듈/컨트롤러/프로바이더 파일을 생성하고, 각 코드 간 연관성에 대하여 설명하였습니다.
- 의존성 주입 (DI : Dependency Injection) 기술에 대한 자세한 설명과 그것이 사용될 수 있는 예시를 설명하였습니다.
본 글에서는
- 유저 배열을 기반으로 유저 데이터를 관리하는 CRUD 기능을 구현합니다.
- 구현에서는 실제 데이터베이스를 연동하지 않습니다.
- NestJS 공식 문서에서 나오는 Providers 예시와 같이, 배열을 기반으로 유저 데이터가 관리됩니다.
5-1. 유저 서비스의 기능 정의와 모델
이전 글에서 유저 서비스가 가져야 할 기능을 다음과 같이 정의하였습니다.
기능명 | 메소드명 | 예시 설명 |
유저의 새로운 생성 | `createUser` | 사용자로부터 새로운 유저의 정보를 입력받아 유저를 생성합니다. 생성하는 유저는 다른 유저와 구분될 수 있어야 하기 때문에, 특정 고유 정보가 존재해야 합니다. |
생성된 모든 유저를 조회 | `findAllUsers` | 현재 존재하는 모든 유저들의 정보를 반환합니다. 단, 요청 시 반환할 수 있는 유저 정보를 구분할 필요가 있습니다. |
조건에 따른 하나의 유저를 조회 | `findUserBy` | 조건에 따라 유저 한 명의 정보를 반환합니다. 유저 한 명을 검색하기 위해서는 그 한 명을 특정지을 수 있어야 하기 때문에, 조건에는 특정 고유 정보가 포함되어야 합니다. |
유저의 정보 변경 | `updateUser` | 유저 한 명의 정보를 변경합니다. 유저의 정보를 변경하기 위해서는 앞선 유저 조회와 같이 하나의 유저를 특정지을 수 있어야 합니다. 따라서 특정 고유 정보와 변경할 새로운 정보가 같이 전달되어야 합니다. |
유저의 삭제 | `deleteUser` | 유저의 정보를 변경하는 기능과 같이, 특정 고유 정보를 통해 하나의 유저를 특정지으면, 해당 유저를 삭제합니다. |
그리고 유저의 모델(Model) 데이터를 다음과 같이 정의하였습니다.
항목 | 영명 |
유저의 이름 | Nickname |
유저의 이메일 | |
유저의 비밀번호 | Password |
유저의 생년월일 | Birth |
유저의 모델은 user.ts 파일에 클래스 형태로 구현하여, 하나의 유저를 하나의 객체로 인식하게 합니다.
이 글에서는 해당 유저 모델의 고유 인자(Primary Key)를 `email` 값으로 가정하여 설명합니다. 하나의 유저는 고유한 이메일을 가져야 하며, 같은 이메일을 가진 유저는 존재할 수 없습니다.
일반적으로 유저 서비스는 유저를 관리하는 서비스이기 때문에 지속성(Persistence)이 필요하므로, 데이터베이스 기반의 서비스여야 합니다. 다만, 이 글에서는 유저를 저장하는 배열을 데이터베이스 대신 이용합니다.
위의 기능 정의와 모델은 매우 기본적인 버전이라고 볼 수 있습니다. 당연히 구현의 요구사항에 따라 더욱 많은 기능과 데이터가 포함될 수 있습니다. 구현하고자 하는 유저의 모델에 대해서 잘 생각하고, 그 모델에 대해 제공할 수 있는 기능이 무엇인지 생각하면 좋습니다.
5-2. 유저 모듈의 파일 구성
유저 데이터를 다루는 모듈(Module)을 구현하기 위한 디렉토리 및 파일들을 생성합니다.
여기서 말하는 유저 모듈은 '유저 기능/서비스 영역' 전체를 의미합니다. 유저 모듈은 `UserModule` 코드 자체를 의미하기도 하지만, 유저 서비스와 컨트롤러 및 기능들을 모두 포함한 유저 응집체를 가리킬 수 있습니다. 이렇게 구현된 유저 모듈을 통해 다른 모듈이나 서비스에서 유저 모듈이 제공하는 다양한 기능들을 이용할 수 있게 됩니다.
생성된 코드들은 다음과 같은 역할을 하게 됩니다.
- user.controller.ts
: 유저 서비스를 이용하고자 하는 클라이언트(외부)에게 제공되는 기능들을 요청/응답할 인터페이스(소통) 구현 - user.module.ts
: 유저 서비스에서 구현된 코드들을 모두 응집하여, 기능을 연결하거나 다른 모듈에서 이용하기 위한 구현 - user.service.ts
: 유저 서비스에서 제공하고자 하는 실제 기능, 비즈니스 로직을 구현 - user.ts
: 유저 서비스에서 다룰 유저 데이터의 모델을 구현 (상기 구현)
5-3. 유저 서비스 (Provider)
유저 데이터를 저장하고 관리할 배열을 정의합니다.
UserService 클래스 내에서 멤버 변수로 존재하는 이 유저 데이터 배열 `users` 는, UserService 객체의 생존 주기에 의존하기 때문에 코드의 재실행에 따라 빈 배열로 초기화됩니다. 즉, 이 배열은 단일 실행 내에서만 유지되는 데이터임을 기억하셔야 합니다.
(주의) UserService에 필요한 기능들을 모두 이어서 구현하므로, 아래에서 보일 이미지들이 길어집니다!
5-3-1. 유저의 새로운 생성 (createUser)
기능 정의 :
사용자로부터 새로운 유저의 정보를 입력받아 유저를 생성합니다.
생성하는 유저는 다른 유저와 구분될 수 있어야 하기 때문에, 특정 고유 정보가 존재해야 합니다.
사용자(클라이언트)는 새로운 유저를 생성하기 위하여 이 기능을 실행합니다. 이 기능은 해당 사용자의 유저 정보가 이미 생성되어 있는지 확인하기 위해 이메일을 통하여 중복 검사를 수행합니다. 만약 해당 유저가 가입되어 있지 않다면, 그 정보를 유저 배열에 저장하여 생성을 완료합니다.
5-3-2. 생성된 모든 유저를 조회 (findAllUsers)
기능 정의 :
현재 존재하는 모든 유저들의 정보를 반환합니다.
단, 요청 시 반환할 수 있는 유저 정보를 구분할 필요가 있습니다.
사용자는 저장된 유저들의 정보를 확인하기 위해, 조회를 요청할 수 있습니다. 단, 유저의 개인정보에 해당하는 비밀번호는 아무렇게나 노출되어서는 안 되므로, 생략 되어야 한다고 가정하겠습니다. (생략을 위해 빈 문자열로 대치합니다.)
유저들의 정보를 반환할 때에는 어떤 정보를 반환하고 싶은지 결정하고, 반환하고 싶은 정보만을 반환하도록 합니다.
실제로 서비스를 구현할 때, "조회"를 수행하는 기능은 해당 조회하고자 하는 항목이 어떤 것인지를 명세화(결정)해야 합니다. 이는 서비스의 요구사항과 구현하는 기능/요소에 따라 달라지기 때문에, 기능별로 정의될 필요가 있습니다.
특히, 이러한 명세화는 개발 소스코드의 영역(레이어)별로 요구되기도 합니다. 특정 레이어에서 다른 레이어를 호출할 때, 전달할 데이터만을 명세해서 전달하는 것이 그 예시입니다. 이러한 방식을 명확히 구분 짓거나, 편리하게 재사용하기 위하여 인터페이스 또는 객체로 정의할 수 있는데, 이러한 표현 방식을 DTO(Data Transfer Object), 즉, 데이터 전송 객체라고 합니다.
예를 들어, createUser 기능에서 User의 새로운 정보를 받아들이기 위해 User 모델을 그대로 이용할 수도 있지만, 일부 데이터만을 이용해 유저를 생성하려고 하는 경우, 유저 정보의 부분집합에 해당하는 부분만을 DTO 방식으로 전달 받을 수 있습니다.
DTO에 관련된 설명은 다음 글에서 이어질 예정입니다.
5-3-3. 조건에 따른 하나의 유저를 조회 (findUserBy)
기능 정의 :
조건에 따라 유저 한 명의 정보를 반환합니다.
유저 한 명을 검색하기 위해서는 그 한 명을 특정지을 수 있어야 하기 때문에, 조건에는 특정 고유 정보가 포함되어야 합니다.
이 기능은 주어진 조건에 따라 (이메일 정보에 따라) 특정화된 유저의 데이터를 반환합니다. 만약 존재하지 않는 경우 반환하지 않습니다. 현재 유저 모델에 존재하는 고유 정보가 이메일 밖에 없으므로, 특정화는 이메일을 통해서만 가능합니다.
앞선 예시에서는 개인정보를 제외하고 반환하였지만, 이 기능에서는 그대로 반환하고 있습니다.
5-3-4. 유저의 정보 변경 (updateUser)
기능 정의 :
유저 한 명의 정보를 변경합니다.
유저의 정보를 변경하기 위해서는 앞선 유저 조회와 같이 하나의 유저를 특정지을 수 있어야 합니다.
따라서 특정 고유 정보와 변경할 새로운 정보가 같이 전달되어야 합니다.
입력된 이메일 정보를 통해 특정화된 유저의 데이터를 수정합니다. 만약 존재하지 않으면 (대상이 없으므로) 수정이 일어나지 않습니다.
유저를 수정하기 위해 사용할 수 있는 로직은 여러 가지가 있습니다. 이 기능에서는 배열에 저장된 유저의 위치(인덱스)를 기반으로 요청된 데이터를 그대로 덮어 씌우는 방식(Overwrite)으로 구현하였으나, 실제로는 데이터베이스와 함께 변경된 정보(속성)만을 수정하는 것이 더 좋은 방법입니다.
5-3-5. 유저의 삭제 (deleteUser)
기능 정의 :
유저의 정보를 변경하는 기능과 같이, 특정 고유 정보를 통해 하나의 유저를 특정지으면, 해당 유저를 삭제합니다.
이 기능에서는 이메일 정보를 통해 특정화된 유저의 데이터를 삭제합니다. 만약 존재하지 않으면 삭제가 일어나지 않습니다. 이메일을 기반으로 유저를 특정화하여, 유저가 존재하는 경우 해당 유저를 배열에서 제거하고 있습니다.
여기까지 유저의 데이터를 배열로 관리하는 유저 서비스(프로바이더)를 구현하였습니다.
프로바이더에서는 이렇게 데이터를 관리/가공하는 직접적인 비즈니스 로직을 구현하는 역할을 맡고 있습니다.
5-4. 유저 컨트롤러 (Controller)
유저 서비스가 비즈니스 로직을 구현했다면, 유저 컨트롤러는 실제 사용자(클라이언트)가 이용하기 위해 소통하는 인터페이스를 구현합니다. 유저 컨트롤러를 통해 어떻게 이 기능을 부르면 될지, 어떤 데이터가 반환될지가 명세됩니다.
유저 서비스를 호출하기 위해 생성자에 주입해 줍니다.
UserService는 클래스 객체인데 왜 생성/할당이 없죠?
의존성 주입(DI) 기술에 의해 유저 모듈에서 주입될 대상 및 생성주기가 결정되어 자동 주입될 예정이기 때문에, 유저 컨트롤러에서 별도로 할당해줄 필요가 없습니다.
5-3-1. 컨트롤러의 구현과 명시
유저를 새로 생성하는 서비스 기능을 호출하기 위해, 컨트롤러 내에 아래와 같이 함수를 정의할 수 있습니다.
만약 다른 소스코드에서 `UserController` 객체를 통해 호출한다면 해당 코드는 동작할 것입니다. 그러나 실행한 서버의 외부(클라이언트)에서 어떤 방법과 경로를 통해 해당 기능을 호출할 수 있는지는 정의되지 않았습니다. 이를 해결하기 위해 기능을 호출할 방식(Method)과 경로(Route)를 설정해야 합니다. 이는 NestJS 데코레이터를 통해 정의합니다.
`@Post` 데코레이터는 해당 함수를 호출하기 위해 POST 메소드를 사용해야 하며, `create` 경로를 통해 접근해야 함을 명시합니다.
`@Controller` 데코레이터를 통해 이 유저 컨트롤러 전체에 대한 경로 접두어(Prefix)가 `user` 임을 알 수 있으므로, 이 기능을 호출하기 위해서 결론적으로 user/create 경로로 접근하면 된다는 사실을 알 수 있습니다.
유저 서비스와 컨트롤러를 쉽게 연결하는 방식은 위에 작성된 코드처럼 유저 서비스에서 정의된 데이터를 그대로 컨트롤러가 받고 넘겨주는 것입니다. 이 때, 유저 컨트롤러가 받아야 하는 데이터가 사용자로부터 입력된 데이터임을 명시하는 데코레이터 또한 작성해야 합니다.
`@Body` 데코레이터는 해당 함수의 입력 데이터가 사용자로부터 입력된 Body 데이터임을 명시합니다. 이를 통해, 입력된 데이터가 유저 모델의 형태를 가진 경우, 올바른 컨트롤러 및 서비스 함수를 실행시킬 수 있게 됩니다.
그런데 입력하는 Body 데이터가 유저 모델이 아니라 아무렇게나 입력되면 어떻게 하죠?
이를 위해 처리하는 것을 데이터 검증(Data Validation)이라고 합니다. 이는 DTO와 연관지어 설명할 예정입니다.
이외에도 `@Param` , `@Query` 과 같이 GET 메소드를 위한 경로 쿼리/파라미터를 설정할 수도 있습니다.
다음은 쿼리/파라미터를 이용하여 경로를 생성하는 예시입니다.
`@Param` 파라미터는 경로의 파라미터가 되어, 요청 경로 자체에 기입되는 값을 기반으로 요청 값을 확인합니다. 이 때, 경로에 해당 대치될 문구를 `:` 을 붙여 표현합니다. 즉, `:email` 값은 해당 위치에 작성될 문자열로 대치됩니다.
예를 들어, 위의 예시에서는 `email` 값으로 `abcd` 를 넣기 위해, user/email/abcd 경로로 요청을 수행하면 됩니다.
반면, `@Query` 파라미터는 경로의 `?` 값 뒤에 붙는 쿼리 명시를 통해 기입되는 값을 기반으로 요청 값을 확인합니다.
예를 들어, 위의 예시에서는 `email` 값으로 `abcd` 를 넣기 위해, user?email=abcd 경로로 요청을 수행하면 됩니다.
이제 위의 규칙들을 기반으로 컨트롤러 함수들을 모두 구현할 수 있게 됩니다.
- `createUser`
: 유저를 생성하기 위해 user/create 경로로 접근하여 `@Body` 에 명시된 `User` 데이터를 입력해야 합니다. - `findUserByEmail`
: 유저를 조회하기 위해 user/email/(검색하고 싶은 이메일) 경로로 접근하여 `@Param` 에 명시된 이메일을 기반으로 조회합니다. - `findUserByEmailQuery`
: 유저를 조회하기 위해 user?email=(검색하고 싶은 이메일) 경로로 접근하여 `@Query` 에 명시된 이메일을 기반으로 조회합니다. - `findAllUsers`
: 유저를 모두 조회하기 위해 user/all 경로로 접근합니다. - `updateUser`
: 유저를 수정하기 위해 user/update 경로로 접근하여 `@Body` 에 명시된 `User` 데이터를 입력해야 합니다. - `deleteUser`
: 유저를 삭제하기 위해 user/email/(삭제할 이메일) 경로로 접근하여 `@Param` 에 명시된 이메일을 기반으로 삭제합니다.
여기서 `findUserByEmail` 기능과 `deleteUser` 기능이 같은 경로를 가졌지만, 입력되는 HTTP 메소드(Method)가 다르기 때문에 요청 시의 메소드에 따라 요청을 구분할 수 있게 됩니다.
여기까지 사용자가 유저 서비스에서 구현된 함수를 호출할 수 있도록 명시하는 유저 컨트롤러를 구현하였습니다. 유저 컨트롤러를 구현함으로써, 이제 외부에서의 실질적인 코드 호출 테스트가 가능하게 됩니다.
컨트롤러에서는 이렇게 구현된 서비스(비즈니스 로직)을 호출할 수 있도록 그 경로와 입력될 데이터(물론, 출력될 데이터 또한)를 명시 구현하는 역할을 맡고 있습니다.
5-5. 유저 모듈 (Module)
이제 유저 서비스와 유저 컨트롤러들을 모두 묶어, 서버 애플리케이션에 포함되도록 유저 모듈을 구현합니다.
`UserModule` 에서는 컨트롤러로 `UserController` 를, 프로바이더로 `UserService` 가 이용됨을 작성합니다.
만약 이 `UserModule` 을 가져가는(Import) 모듈에서 해당 하위의 프로바이더를 이용하고 싶다면 (예를 들어, 인증 서비스에서 유저 서비스에 대한 로직이 필요할 때), 내보내기(Export)를 명시하여 작성해줄 수 있습니다.
이제 구현된 `UserModule` 을 `AppModule` 에 추가하여 서버 애플리케이션 실행에 동봉될 수 있도록 합니다.
참고로 이 코드들은 모두 NestJS 보일러플레이트 코드에서 추가한 내용이므로, `AppController` 및 `AppService` 는 지우거나 무시하셔도 좋습니다.
여기까지 모두 구현하였다면 유저 모듈/컨트롤러/서비스의 구현이 완료되었습니다.
실행과 함께 우리가 의도한 비즈니스 로직을, 올바른 방식으로 호출할 수 있는지 확인합니다.
5-6. 유저 서비스의 실행 및 확인
유저 서비스를 포함한 서버 애플리케이션을 실행시켜 유저 서비스의 구현을 확인합니다.
위와 같이 `UserModule` 과 `UserController` 가 주입되면서, 관련된 호출 메소드와 경로들이 연결(Mapping)된 것을 볼 수 있습니다.
순서대로 기능들을 호출해 보면서 구현된 결과를 테스트합니다.
Postman 앱을 이용하여 유저를 생성하는 기능을 테스트해볼 수 있습니다. Postman 앱은 HTTP API를 테스트해볼 수 있는 도구입니다.
5-6-1. 유저 생성 테스트
유저의 생성을 담당하는 `createUser` 함수에서 별도의 반환 값이나 출력을 주고 있지 않기 때문에 올바르게 실행되었는지 확인이 어렵습니다. 일반적으로는 반환 값이나 출력을 통해 실행의 과정/결과를 확인하는 것이 좋습니다.
우리는 조회를 통해 저장된 데이터를 불러올 수 있기 때문에, 이를 통해 유저가 생성되었는지 확인해 봅니다.
모든 유저들을 조회할 수 있는 `findAllUsers` 함수를 통해, 배열에 생성/저장된 모든 유저들을 조회합니다. 위의 실행을 통해 앞서 생성한 유저가 올바르게 생성 및 저장되었음을 알 수 있었습니다. 단, 본 기능에서는 개인정보인 비밀번호를 빈 값으로 대치하여 반환하고 있습니다.
5-6-2. 유저 조회 테스트
생성된 유저들 중 특정 유저를 조회하고 싶다면, `findUserByEmail` 함수를 이용할 수 있습니다. 유저의 고유 정보인 이메일을 통해 해당 유저를 조회합니다.
조회 경로에 쿼리(Query)를 이용하여 이메일을 지정합니다. 해당 이메일을 가진 유저의 정보가 반환됩니다. 본 기능에서는 앞선 모든 유저 조회 기능과 달리, 비밀번호가 함께 반환됩니다.
조회 경로에 파라미터(Param)를 이용하여 이메일을 지정합니다. 실제 경로의 중간에 해당 문자열이 대치되어, 그 이메일을 가진 유저의 정보가 반환됩니다.
이와 같이 조회를 수행하기 위해 쿼리 또는 파라미터를 이용할 수 있습니다.
보편적으로, 쿼리(Query) 기반의 조회는 해당 리소스에 대한 다양한 조건들을 능동적으로 '함께' 설정하기 위해 이용됩니다. 예를 들어, 유저를 검색할 때 이메일을 기반으로도 검색할 수도, 닉네임을 기반으로도 검색할 수 있고, 그 둘을 다 활용하여 검색할 수도 있습니다. 백엔드에서 반환하는 여러 개의 데이터를 다루기 위해 이용되는 페이징(Pagination) 기술에서도 쿼리 기반 조회를 주로 사용합니다.
반면, 파라미터(Param) 기반의 조회는 해당 리소스에 대한 조건을 명시하고 내포됨을 인지하기 위해 이용됩니다. 예를 들어, 유저를 검색할 때 '이메일'만을 이용하여 검색하거나, '닉네임'만을 이용하여 검색할 수 있으며, 이 때, 해당 이메일과 닉네임은 유저에 속한 정보로써 해당 유저를 지칭하는 인자가 됨을 알게 합니다. 백엔드에서 반환하는 단일 데이터나 속성에 해당하는 데이터만을 조회하기 위해, 리소스의 단일 조회를 수행하는 경우 파라미터 기반 조회를 주로 사용합니다.
이렇듯 보편적인 방식이 있지만, 개인/조직에서 지향하는 방식이나 컨벤션에 따라, 자신이 구현하고자 방향과 목적성에 맞게 경로를 설정하면 됩니다.
5-6-3. 유저 수정 테스트
유저를 수정하기 위해 새로운 데이터를 덮어 씌울 수 있습니다. 이 때, 기존에 저장된 유저가 없다면 무시되고, 있다면 해당 데이터의 이메일을 기반으로 데이터를 매치하여 덮어 씌웁니다.
수정 요청에 따라 해당 이메일과 일치하는 데이터가 수정된 것을 확인할 수 있습니다.
5-6-4. 유저 삭제 테스트
입력된 이메일을 기반으로, 그 이메일을 가진 유저가 있는 경우 그 유저를 삭제합니다. 이제 다시 조회를 하려고 해도 해당 이메일을 가진 유저가 없기 때문에 조회되지 않습니다.
결론
본 글에서는 유저 배열을 기반으로 유저 데이터를 관리하는 CRUD 기능을 구현하였으며, 이에 대한 코드 구현 및 연결이 올바르게 이루어졌는지 확인하기 위해 Postman을 활용하여 CRUD 테스트를 진행하였습니다.
- 유저 모델은 유저가 어떤 데이터를 가지는지 정의합니다.
- 유저 서비스는 유저 데이터를 다루는 비즈니스 로직을 구현합니다.
- 유저 컨트롤러는 유저 서비스를 호출하기 위한 방식을 명시합니다.
- 유저 모듈은 유저 컨트롤러 및 서비스를 서버 애플리케이션에 등록하기 위해 모아줍니다.
다음 글에서는 컨트롤러/서비스의 개선점으로, 입/출력되는 데이터를 DTO로 정의하고 그 과정에서 동반되는 데이터 검증(Data Validation)에 대해서 설명합니다.
읽어주셔서 감사합니다.
'개발자 💻 > NestJS' 카테고리의 다른 글
[초보자의 눈으로 보는 NestJS] 6. DTO와 Validation (0) | 2024.05.31 |
---|---|
class-validator 의 @IsBoolean 데코레이터와 enableImplicitConversion 설정된 Transform 의 문제 (0) | 2024.02.29 |
[초보자의 눈으로 보는 NestJS] 4. 유저 서비스의 정의와 의존성 주입 (1) | 2023.11.13 |
[초보자의 눈으로 보는 NestJS] 3+. API 테스트를 위한 Postman (0) | 2023.07.19 |
[초보자의 눈으로 보는 NestJS] 3. 모듈, 컨트롤러, 그리고 프로바이더 (0) | 2023.07.18 |