Blog

[NestJS] 10. 프로젝트 문제 인식과 컨벤션 리팩토링, 트러블 슈팅

Category
Author
citeFred
citeFred
PinOnMain
1 more property
NestJS, TypeORM 이해하기
Table of Content

1. 현재까지 진행된 상태에서 짚어 볼 수 있는 문제점

1.1 컨벤션(Convention) 이란?

명명규칙을 기획 초기에 수립하는 것의 중요성을 확인하고자 한다.
컨벤션(Convention)은 코드 작성, 명명 규칙, 폴더 구조, 파일명 등 프로젝트 내에서 개발자들이 공통적으로 따르는 규칙이나 가이드라인을 의미
이런 규칙을 수립하는 것을 컨벤션을 수립하다 라는 표현을 쓰기도 한다.
컨벤션은 문서화하여 프로젝트의 모든 개발자가 참고할 수 있도록 하는 것이 좋음, 보통은 프로젝트의 README.md 파일이나 프로젝트 SA페이지 또는 팀 블로그 등 페이지로 정리
실전 문서화 예시
컨벤션의 목적
일관성 유지: 코드베이스 전체에서 일관된 스타일과 구조를 유지
가독성 향상: 명확한 규칙에 따라 작성된 코드는 이해하기 쉽고, 다른 개발자들이 코드를 확인하는 유지보수성 향상
협업 효율성 증가: 팀원 간의 코드 스타일이 통일되면 협업 시 불필요한 충돌이 줄어들고, 코드 리뷰의 편의성 향상
버그 예방: 일관된 규칙을 따르면 실수나 버그를 쉽게 찾아내고 빠른 픽스가 가능
컨벤션의 종류
코딩 스타일 컨벤션:
들여쓰기, 공백, 줄바꿈, 중괄호 배치 등 코드 스타일에 대한 규칙
예: 2-space 들여쓰기 vs 4-space 들여쓰기, 공백 한 줄 삽입 등
명명 규칙 컨벤션:
변수, 함수, 클래스, 파일명 등을 어떻게 명명할지에 대한 규칙
예: camelCase vs snake_case, PascalCase, Kebab-Case 사용 등
폴더 구조 컨벤션:
프로젝트의 폴더 구조와 파일 배치에 대한 규칙
예: 기능별로 폴더를 나누기, 특정 폴더에 특정 타입의 파일 배치 등
프레임워크 컨벤션:
특정 프레임워크에서 권장하는 규칙
예: NestJS에서 컨트롤러 파일은 .controller.ts로, 서비스 파일은 .service.ts로 명명하기 등
커밋 컨벤션 및 PR 컨벤션
일관된 방식으로 커밋 메시지, PR을 작성하고 관리하기 위한 규칙
예: 커밋 컨벤션 (git config 를 통한 commit_messege_template.md 설정)
# <타입> : <제목> 형식으로 작성하세요 ################ # 타입 설정하기 ## feature : 새로운 기능 추가 ## fix : 버그 수정 ## docs : 문서 수정 ## test : 테스트 코드 추가 ## refactor : 코드 리팩토링 ## style : 코드 의미에 영향을 주지 않는 변경사항 ## chore : 빌드 부분 혹은 패키지 매니저 수정사항 # 제목 설정하기 ## 제목 작성 후 공백 한줄을 포함해야, 제목과 본문이 구별됨 ## 제목 첫 글자는 대문자로 작성, 마침표를 사용하지 않음 ## 제목은 명령문으로 사용, 과거형을 사용하지 않음 ## 제목은 50글자로 제한 ################ ##### 제목을 아랫줄에 작성하세요 ##### ################ # 본문 설정 ## 본문 작성 후 공백 한줄을 포함해야, 본문과 Resolves가 구별됨 ## 본문의 각 행은 72글자로 제한 ## 본문은 "왜"와 "무엇을"위주로 작성 ## 본문은 행으로 구분되어야 함 ## 본문의 내용은 *으로 시작함 ################ ##### 본문(추가 설명)을 아랫줄에 작성하세요 ##### ################ # Resolves 설정 ## Resolves 작성 후 공백 한줄을 포함해야, Resolves와 See also가 구별됨 ## Resolves의 내용도 *으로 시작함 ## 해결한 이슈는 닫을 수 있도록 함. # 이슈 종료 방법 ## '키워드 #이슈번호' # issue 종료 키워드 (github) ## * close - 일반 개발 이슈 ## * closes ## * closed ## * fix - 버그 fix 이슈 ## * fixed ## * resolve - 문의 요청사항 이슈 ## * resolves ## * resolved ################ ##### Resolves를 작성하세요 (생략 가능) ##### ################ # See also 설정 ## 연관된 이슈의 경우 이슈 번호와 연관 이슈 내용을 입력 ## See also의 내용도 *으로 시작함 ################ ##### See also를 작성하세요 (생략 가능) ##### ################ # Remember me ~ Commit Message 규칙 ## 1. 제목과 본문을 빈 행으로 구분한다. ## 2. 제목을 50글자 내로 제한 ## 3. 제목 첫 글자는 대문자로 작성 ## 4. 제목 끝에 마침표 넣지 않기 ## 5. 제목은 명령문으로 사용하되, 과거형을 사용하지 않는다. ## 6. 본문의 각 행은 72글자 내로 제한 ## 7. 어떻게 보다는 무엇과 왜를 설명 ################
Markup
복사
예: PR 컨벤션(.github PULL_REQUEST_TEMPLATE.md) 템플릿 예시
### PR 타입(하나 이상의 PR 타입을 선택해주세요) - [X] 기능 추가 - [ ] 기능 삭제 - [ ] 버그 수정 - [ ] 의존성, 환경 변수, 빌드 관련 코드 업데이트 ### 반영 브랜치 ### 변경 사항 ### 테스트 결과
Markup
복사

1.2 명명 규칙 오류 발견하기

기존 DTO 명명 규칙 살펴보기
현재 존재하는 DTO 클래스들은 모두 Request관련 Dto들이다.
위 파일들의 명명 규칙은 내가 선호하는 방식이아닌 이유는 다음과 같다.
DTO의 이름으로 기능을 표현하고 있지만 Response 관련 DTO 클래스들이 추가되면 혼동이 생길 수 있다.
Request, Response 등 역할에 대해 직관적이지 않다.
새로운 명명 규칙 정책 적용
새롭게 명명 규칙을 정리했으며 다음과 같다.
Request관련 클래스명은 ~RequestDto , 파일 명은 ~-request.dto.ts
Response관련 클래스명은 ~ResponseDto , 파일명은 ~-response.dto.ts
파일명은 NestJS에서 일반적인 KeBab-case 사용( - 하이픈 구분)
기존 DTO들부터 명칭을 변경해준다.
파일명
create-user.dto.tssign-up-request.dto.ts
login-user.dto.tssign-in-request.dto.ts
create-article.dto.tscreate-article-request.dto.ts
update-article.dto.tsupdate-article-request.dto.ts
클래스명
CreateUserDtoSignUpRequestDto
LogInUserDtoSignInRequestDto
CreateArticleDtoCreateArticleRequestDto
UpdateArticleDtoUpdateArticleRequestDto
기존 클래스를 사용하던 Controller, Service 계층에서도 위 명칭으로 수정해준다.
수정을 하면서 얼마나 많은 부분들이 수정되어야 하는지 볼 수 있다.
이만큼 처음의 명명규칙과 관련된 컨벤션(Convention) 정책을 수립하는 것의 중요성을 알 수 있다.
Board라는 것이 게시글인가? 에 대한 문제점
현재 DTO의 컨벤션을 맞추면서 기획 단계에서 애매한 것들, 수립하지 못한것들이 나타나고 있다.
근본적으로 Board를 게시글 엔터티, 리소스 명칭으로 사용하는 것 자체의 문제점이 있다.
게시글은 Post 또는 Article이 적당하다.
Board는 게시판 그 자체이다.
실제로는 자유게시판, 공지게시판, 직원게시판 등 여러개가 생성 될 가능성이 있다
이 경우 위 처럼 게시판을 구분하는 기능 확장 가능성이 높은 케이스이기 때문에 초기에 수정해야 이후 문제가 발생하지 않을 가능성이 높다.
Board로 관련되어있는 모든 키워드를 Article로 변경한다.
엔터티 명칭까지 BoardArticle로 변경 할 것이기 때문에 DB의 테이블 자체가 article로 변경 될 것이다.
Post 를 사용하지 않는 이유는 POST Http method와 겹치기 때문에 충돌이 날 가능성이 있다.
현재 boards.controller.ts, board.entity와 같이 단수복수가 혼재되어 있는 상태이다.
기본적으로 단수로 표현하는 것이 가장 편리하고 가독성이 좋다.
여러 게시판을 다루는 경우에 Boards 처럼 복수를 사용하기는 하지만,
일부의 불규칙보다는 전체 통일성을 지키는 것이 가장 중요하다. BoardController 처럼 단수 또항 그 의미가 모호하지 않다.
BoardArticle로 바꾸는 과정에서 기존 Boards인 복수 부분들을 Articles로 통일
하지만 메서드 명칭에서getAllArticles() 처럼 해당 기능이 여러 게시글을 가져오는 것이 명확한 것만 Articles처럼 복수형을 사용
수만은 파일과 소스코드 내 명칭이 변경되었다. 가장 큰 변화는 테이블 자체가 변경되어 기존 데이터들은 소실된다는 점.
이제 기존 board 테이블이 아닌 article을 사용하게 되었다.
엔드포인트는 복수가 표준이다.
RESTful API에서는 컬렉션을 다룰 때 복수형을 사용하는 것이 표준
따라서 ‘api/articles’ 프리픽스는 맞는 표현이다.
@Controller('api/articles') ...
TypeScript
복사
POSTMAN을 통한 리팩토링 테스트
이만큼 초기 기획에서 명명규칙을 올바르게 결정해야 추후 서비스 운영단계에서 데이터베이스가 바뀌는 등 최악의 상황을 막을 수 있다.
개발자는 이런 오류, 확장 가능성을 고려한 설계변경을 개발단계에서 미리 발견하고 피보팅해야 한다.
단수와 복수의 문제는 현재까지도 개발자들 사이에서 명확하게 정해진 방법론이 없다.
하지만 다양한 의견과 REST api의 첫 논문에서도 정확하게 규정하고 있지않기 때문에 관례적인 명명규칙이 정착 되고있다.
다양한 사례에서 이런 공통점을 볼 수 있다.
Entity는 단수형(테이블명은 단수)을 사용하는 의견
URI(엔드포인트 리소스명)은 “localhost:3000/api/users” 처럼 복수형을 사용하는 의견
src 소스폴더내의 각 리소스(컬렉션)은 복수를 사용하는 의견 src/users/…
이런 다수의 의견들이 개발자들의 관례를 만들어나가고 공통적으로 사용되게 된다.
결론적으로 이와 같이 규칙을 정하도록 했다.
src/users/… : 리소스를 나타내는 컬렉션 폴더와 하위 소스코드는 복수를 선택
그 중 엔터티와 DTO는 단수를 선택
/api/users : URI는 복수를 선택
auth 모듈은 인증 관련 모든 기능을 관리하는 단일 모듈로 간주. 따라서, 모듈 이름은 단수형인 것이 적합
이와 같이 기능 단위의 모듈(ex: file, payment, config 등)은 단수형을 사용하도록 구성

1.3 Auth 리소스에 User가 혼재된 문제

Auth 폴더 안에 회원가입과 로그인을 위한 user.entity.tsUser관련 클래스들이 섞여있다.
사실 인증과 인가 / 회원 기능은 구분해야 한다.
그 중 회원가입은 종종 auth 기능에 포함되는 관례가 있다.
Auth는 로그인 회원가입 비밀번호 변경 등이 포함되는 것이 일반적
User는 사용자 프로필 관리, 역할 변경 등이 일반적
기능과 역할의 구분을 위하여 폴더를 별도로 구분하고, 주입을 통해서 사용하도록 하는 것이 올바르다고 판단되었다.
user 폴더는 사용자와 관련된 모든 데이터, 필요한 파일들을 포함
user.entity.ts
user.module.ts
user-role.enum.ts
auth 폴더는 인증과 관련된 모든 파일들을 포함
auth/dto/
sign-up-request.dto.ts
sign-in-request.dto.ts
auth.controller.ts
auth.module.ts
auth.service.ts
custom-role.guard.ts
get-user.decorator.ts
jwt.strategy.ts
roles.decorator.ts

2. 엔터티 공통 부분 모듈화

현재 2개의 엔터티 말고도 앞으로 계속해서 기능이 추가될 때 마다 엔터티가 추가될 가능성이 있다.
모든 엔터티에서 공통적으로 다루어야 할 것들이 있다.
id : 해당 엔터티의 기본키(PK) 역할이며 고유한 객체임을 구분 할 수 있는 필드
createAt : 해당 데이터가 생성된 시간을 기록하기 위해 사용하는 필드
updateAt : 해당 데이터가 수정된 시간을 기록하기 위해 사용하는 필드(또는 modifiedAt)
추상 클래스(Abstract Class)로 공통 부분을 모아두고 각 엔터티에서 상속 받기
id, createAt, updateAtCommonEntity라는 추상 클래스로 생성한다.
해당 파일은 src/common 이라는 폴더에 위치시켜 공용으로 사용되는 파일임을 인지
common.entity.ts
import { PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; export abstract class CommonEntity { @PrimaryGeneratedColumn() id: number; @CreateDateColumn({ type: 'timestamp' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamp' }) updatedAt: Date; }
TypeScript
복사
articleuser 엔터티에서 중복되는 id 컬럼 부분을 제거해도 된다.
엔터티 클래스는 위 공통부분인 CommonEntity를 상속 받아야 한다. extends CommonEntity
article.entity.ts
import { Column, Entity, ManyToOne } from "typeorm"; import { ArticleStatus } from "./article-status.enum"; import { User } from "src/user/entities/user.entity"; import { CommonEntity } from "src/common/entities/common.entity"; @Entity() export class Article extends CommonEntity { @Column() author: string; @Column() title: string; @Column() contents: string; @Column() status: ArticleStatus @ManyToOne(Type => User, user => user.articles, { eager: false }) user: User; }
TypeScript
복사
user.entity.ts
import { Column, Entity, OneToMany } from "typeorm"; import { UserRole } from "./user-role.enum"; import { Article } from "src/article/entities/article.entity"; import { CommonEntity } from "src/common/entities/common.entity"; @Entity() export class User extends CommonEntity { @Column() username: string; @Column() password: string; @Column({ unique: true }) email: string; @Column() role: UserRole; @OneToMany(Type => Article, article => article.author, { eager: false }) articles: Article[]; }
TypeScript
복사
이후 테이블에 id, createAt, updateAt 컬럼이 정상적으로 생성되었는지 확인한다.

3. Entity가 반환되는 문제점

Request는 DTO로 변환되서 처리되지만 Response는 Entity가 반환되고 있음
Request와 Response 는 DTO를 사용하는 것을 권장 하고 있다.
1.
보안 문제
Entity를 반환하기보다는, 필요한 정보만 담긴 DTO를 반환하도록 코드를 수정하는 것이 좋다.
이렇게 하면 불필요한 정보가 클라이언트로 노출되는 것을 방지 할 수 있음
실제로 현재 게시글과 유저의 연관관계가 있으며, 즉시 로딩 (Eager)를 사용하고 있어서 불필요한 회원의 모든 정보(암호화된 Password 포함)가 함께 반환되고 있다.
2.
데이터 과다 전송 문제
내부 비지니스 로직에서만 사용되는 관계 데이터가 포함되어 있다.
클라이언트는 사실 불필요한 내용들로 송수신 자원의 낭비와 성능 저하까지 초래 할 수 있다.
3.
데이터 일관성 및 무결성 홰손과 유지보수 문제
Entity는 기본적으로 비지니스 로직, 데이터베이스 엑세스와 관련된 중요한 클래스이다.
클라이언트의 요구사항 변화에 따라 불필요한 엔티티 구조(= DB구조)가 변경 될 가능성이 있다.
응답에는 ResponseDto도 가 필요한 것도 당연하지만 StatusCode , message 등의 추가 정보 응답이 필요하다.
API로 요청이 처리된 결과, 메시지 등을 포함하고자 한다.
common 리소스 폴더에 api-response.dto.ts를 생성
export class ApiResponseDto<T> { success: boolean; statusCode?: number; message?: string; data?: T; error?: string; constructor(success: boolean, statusCode?: number, message?: string, data?: T, error?: string) { this.success = success; this.statusCode = statusCode; this.message = message; this.data = data; this.error = error; } }
TypeScript
복사
모든 API의 반환은 위 클래스의 인스턴스를 이용하도록 한다.
변수명 마다 ?는 객체의 속성이 선택적, 생략가능을 의미
속성 값이 없으면 제외 됨
data 필드의 타입에 있는 T는 제네릭스(Generic)를 사용하여 타입을 동적으로 유연하게 설정
string이 들어오면 string, responseDtoresponseDto로 타입을 맞춰준다 이해

3.1 회원 기능 ResponseDto 등 수정사항

user-response.dto.ts
password를 제외한 정보를 반환할 수 있도록 구성
반환할 회원의 정보는 추가 또는 삭제 해도 된다.
import { UserRole } from "src/user/user-role.enum"; import { User } from "src/user/user.entity"; export class UserResponseDto { id: number; username: string; email: string; role: UserRole; createdAt: Date; updatedAt: Date; constructor(user: User){ this.id = user.id; this.username = user.username; this.email = user.email; this.role = user.role; this.createdAt = user.createdAt; this.updatedAt = user.updatedAt; } }
TypeScript
복사
auth.controller.ts
DTO를 활용할 수 있도록 기존 코드를 수정 해준다.
DTO 변환은 컨트롤러가 전담하도록 한다.
응답 전 로깅을 추가하기 위해서 서비스 계층을 호출하여 바로 반환하던 부분을 변수에 할당하는 것으로 풀어서 정리했다.
import { Body, Controller, HttpStatus, Logger, Post, Res } from '@nestjs/common'; import { AuthService } from './auth.service'; import { SignInRequestDto } from './dto/sign-in-request.dto'; import { Response } from 'express'; import { ApiResponseDto } from 'src/common/api-response-dto/api-response.dto'; @Controller('api/auth') export class AuthController { private readonly logger = new Logger(AuthController.name); constructor(private authService: AuthService){} // Sign-In @Post('/signin') async signIn(@Body() signInRequestDto: SignInRequestDto, @Res() res:Response): Promise<void> { this.logger.verbose(`User with email: ${signInRequestDto.email} is try to signing in`); const accessToken = await this.authService.signIn(signInRequestDto); this.logger.verbose(`User with email: ${signInRequestDto.email} issued JWT ${accessToken}`); // [2] JWT를 헤더에 저장 후 ApiResponse를 바디에 담아서 전송 res.setHeader('Authorization', accessToken); const response = new ApiResponseDto(true, HttpStatus.OK, 'User logged in successfully', { accessToken }); res.send(response); } }
TypeScript
복사
auth.service.ts
인증/인가 Service 로직은 그대로 Entity를 반환한다.
DTO 변환은 Controller 계층이 담당해야 하므로 Service 계층에서의 코드 변경은 없다.
쿠키 생성을 Service에서 Controller 계층으로 이동시켜 해당 부분의 코드가 제거되었다.
추가 변경점은 반환 시 로깅을 추가하였다.
import { Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { User } from '../user/entities/user.entity'; import * as bcrypt from 'bcryptjs' import { SignInRequestDto } from './dto/sign-in-request.dto'; import { JwtService } from '@nestjs/jwt'; import { UserService } from 'src/user/user.service'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( private jwtService: JwtService, private userService: UserService, ){} // Sign-In async signIn(signInRequestDto : SignInRequestDto): Promise<string> { this.logger.verbose(`User with email: ${signInRequestDto.email} is signing in`); const { email, password } = signInRequestDto; try{ const existingUser = await this.userService.findUserByEmail(email); if(!existingUser || !(await bcrypt.compare(password, existingUser.password))) { throw new UnauthorizedException('Invalid credentials'); } // [1] JWT 토큰 생성 const payload = { id: existingUser.id, email: existingUser.email, username: existingUser.username, role: existingUser.role }; const accessToken = await this.jwtService.sign(payload); this.logger.verbose(`User with email: ${signInRequestDto.email} issued JWT ${accessToken}`); return accessToken; } catch (error) { this.logger.error(`Invalid credentials or Internal Server error`); throw error; } } }
TypeScript
복사

3.2 게시글 기능 ResponseDto 등 수정사항

article-response.dto.ts
게시글도 마찬가지로 필요한 정보만 내보낼 수 있도록 구성한다.
연관관계를 가진 user의 정보가 필요하다면 마찬가지로 user-response.dto.ts 로 부터 가져 올 수 있다.
컨트롤러에서 쉽게 Entity DTO 변환 할 수 있도록 생성자를 구성해준다.
import { User } from "src/user/user.entity"; import { ArticleStatus } from "../article-status.enum"; import { Article } from "../article.entity"; import { UserResponseDto } from "src/auth/dto/user-response.dto"; export class ArticleResponseDto { id: number; author: string; title: string; contents: string; status: ArticleStatus; createdAt: Date; updatedAt: Date; user: UserResponseDto; constructor(article: Article) { this.id = article.id; this.author = article.author; this.title = article.title; this.contents = article.contents; this.status = article.status; this.createdAt = article.createdAt; this.updatedAt = article.updatedAt; this.user = article.user ? new UserResponseDto(article.user) : null; } }
TypeScript
복사
article.controller.ts
DTO를 활용할 수 있도록 기존 코드를 수정 해준다. 전반적인 코드가 수정되었다.
ArticleResponseDtoApiResponseDto로 랩핑하여 반환하기 때문에 반환 타입의 변경이 있다.
DTO 변환은 컨트롤러가 전담하도록 한다.
응답 전 로깅을 추가하기 위해서 서비스 계층을 호출하여 바로 반환하던 부분을 변수에 할당하는 것으로 풀어서 정리했다.
import { Body, Controller, Delete, Get, HttpStatus, Logger, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common'; import { ArticleService } from './article.service'; import { Article } from './entities/article.entity'; import { CreateArticleRequestDto } from './dto/create-article-request.dto'; import { ArticleResponseDto } from './dto/article-response.dto'; import { SearchArticleResponseDto } from './dto/search-article-response.dto'; import { UpdateArticleRequestDto } from './dto/update-article-request.dto'; import { ArticleStatusValidationPipe } from '../common/pipes/article-status-validation.pipe'; import { ArticleStatus } from './entities/article-status.enum'; import { AuthGuard } from '@nestjs/passport'; import { RolesGuard } from 'src/auth/custom-guards-decorators/custom-role.guard'; import { UserRole } from 'src/user/entities/user-role.enum'; import { User } from 'src/user/entities/user.entity'; import { ApiResponseDto } from 'src/common/api-response-dto/api-response.dto'; import { GetUser } from 'src/auth/custom-guards-decorators/get-user.decorator'; import { Roles } from 'src/auth/custom-guards-decorators/roles.decorator'; @Controller('api/articles') @UseGuards(AuthGuard(), RolesGuard) export class ArticleController { private readonly logger = new Logger(ArticleController.name); constructor(private articleService: ArticleService){} // CREATE @Post('/') async createArticle( @Body() createArticleRequestDto: CreateArticleRequestDto, @GetUser() logginedUser: User): Promise<ApiResponseDto<void>> { this.logger.verbose(`User: ${logginedUser.username} is try to creating a new article with title: ${createArticleRequestDto.title}`); await this.articleService.createArticle(createArticleRequestDto, logginedUser) this.logger.verbose(`Article created Successfully`); return new ApiResponseDto(true, HttpStatus.CREATED, 'Article created Successfully'); } // READ - all @Get('/') @Roles(UserRole.USER) async getAllArticles(): Promise<ApiResponseDto<ArticleResponseDto[]>> { this.logger.verbose(`Try to Retrieving all Articles`); const articles: Article[] = await this.articleService.getAllArticles(); const articlesResponseDto = articles.map(article => new ArticleResponseDto(article)); this.logger.verbose(`Retrieved all articles list Successfully`); return new ApiResponseDto(true, HttpStatus.OK, 'Article list retrive Successfully', articlesResponseDto); } // READ - by Loggined User @Get('/myarticles') async getMyAllArticles(@GetUser() logginedUser: User): Promise<ApiResponseDto<ArticleResponseDto[]>> { this.logger.verbose(`Try to Retrieving ${logginedUser.username}'s all Articles`); const articles: Article[] = await this.articleService.getMyAllArticles(logginedUser); const articlesResponseDto = articles.map(article => new ArticleResponseDto(article)); this.logger.verbose(`Retrieved ${logginedUser.username}'s all Articles list Successfully`); return new ApiResponseDto(true, HttpStatus.OK, 'Article list retrive Successfully', articlesResponseDto); } // READ - by id @Get('/:id') async getArticleDetailById(@Param('id') id: number): Promise<ApiResponseDto<ArticleResponseDto>> { this.logger.verbose(`Try to Retrieving a article by id: ${id}`); const articleResponseDto = new ArticleResponseDto(await this.articleService.getArticleDetailById(id)); this.logger.verbose(`Retrieved a article by ${id} details Successfully`); return new ApiResponseDto(true, HttpStatus.OK, 'Article retrive Successfully', articleResponseDto); } // READ - by keyword @Get('/search/:keyword') async getArticlesByKeyword(@Query('author') author: string): Promise<ApiResponseDto<SearchArticleResponseDto[]>> { this.logger.verbose(`Try to Retrieving a article by author: ${author}`); const articles: Article[] = await this.articleService.getArticlesByKeyword(author); const articlesResponseDto = articles.map(article => new SearchArticleResponseDto(article)); this.logger.verbose(`Retrieved articles list by ${author} Successfully`); return new ApiResponseDto(true, HttpStatus.OK, 'Article list retrive Successfully', articlesResponseDto); } // UPDATE - by id @Put('/:id') async updateArticleById( @Param('id') id: number, @Body() updateArticleRequestDto: UpdateArticleRequestDto): Promise<ApiResponseDto<void>> { this.logger.verbose(`Try to Updating a article by id: ${id} with updateArticleRequestDto`); await this.articleService.updateArticleById(id, updateArticleRequestDto) this.logger.verbose(`Updated a article by ${id} Successfully`); return new ApiResponseDto(true, HttpStatus.NO_CONTENT, 'Article update Successfully'); } // UPDATE - status <ADMIN> @Patch('/:id') @Roles(UserRole.ADMIN) async updateArticleStatusById( @Param('id') id: number, @Body('status', ArticleStatusValidationPipe) status: ArticleStatus): Promise<ApiResponseDto<void>> { this.logger.verbose(`ADMIN is trying to Updating a article by id: ${id} with status: ${status}`); await this.articleService.updateArticleStatusById(id, status); this.logger.verbose(`ADMIN Updated a article's by ${id} status to ${status} Successfully`); return new ApiResponseDto(true, HttpStatus.NO_CONTENT, 'Article status changed Successfully'); } // DELETE - by id @Delete('/:id') @Roles(UserRole.USER, UserRole.ADMIN) async deleteArticleById(@Param('id') id: number, @GetUser() logginedUser: User): Promise<ApiResponseDto<void>> { this.logger.verbose(`User: ${logginedUser.username} is trying to Deleting a article by id: ${id}`); await this.articleService.deleteArticleById(id, logginedUser); this.logger.verbose(`Deleted a article by id: ${id} Successfully`); return new ApiResponseDto(true, HttpStatus.NO_CONTENT, 'Article delete Successfully'); } }
TypeScript
복사
article.service.ts
게시글 Service 로직은 그대로 Entity를 반환한다.
DTO 변환은 Controller 계층이 담당해야 하므로 Service 계층에서의 코드 변경은 없다.
변경점은 반환 시 로깅을 추가하였다.
import { BadRequestException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { Article } from './entities/article.entity'; import { ArticleStatus } from './entities/article-status.enum'; import { CreateArticleRequestDto } from './dto/create-article-request.dto'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { UpdateArticleRequestDto } from './dto/update-article-request.dto'; import { User } from 'src/user/entities/user.entity'; @Injectable() export class ArticleService { private readonly logger = new Logger(ArticleService.name); constructor( @InjectRepository(Article) private articleRepository : Repository<Article> ){} // CREATE async createArticle(createArticleRequestDto: CreateArticleRequestDto, logginedUser: User): Promise<void> { this.logger.verbose(`User: ${logginedUser.username} is creating a new article with title: ${createArticleRequestDto.title}`); const { title, contents } = createArticleRequestDto; if (!title || !contents) { throw new BadRequestException('Title, and contents must be provided'); } const newArticle = this.articleRepository.create({ author: logginedUser.username, title, contents, status: ArticleStatus.PUBLIC, user: logginedUser }); await this.articleRepository.save(newArticle); this.logger.verbose(`Article title with ${newArticle.title} created Successfully`); } // READ - all async getAllArticles(): Promise<Article[]> { this.logger.verbose(`Retrieving all Articles`); const foundArticles = await this.articleRepository.find(); this.logger.verbose(`Retrieved all articles list Successfully`); return foundArticles; } // READ - by Loggined User async getMyAllArticles(logginedUser: User): Promise<Article[]> { this.logger.verbose(`Retrieving ${logginedUser.username}'s all Articles`); const foundArticles = await this.articleRepository.createQueryBuilder('article') .leftJoinAndSelect('article.user', 'user') .where('article.userId = :userId', { userId : logginedUser.id }) .getMany(); this.logger.verbose(`Retrieved ${logginedUser.username}'s all Articles list Successfully`); return foundArticles; } // READ - by id async getArticleDetailById(id: number): Promise<Article> { this.logger.verbose(`Retrieving a article by id: ${id}`); const foundArticle = await this.articleRepository.createQueryBuilder('article') .leftJoinAndSelect('article.user', 'user') .where('article.id = :id', { id }) .getOne(); if (!foundArticle) { throw new NotFoundException(`Article with ID ${id} not found`); } this.logger.verbose(`Retrieved a article by ${id} details Successfully`); return foundArticle; } // READ - by keyword async getArticlesByKeyword(author: string): Promise<Article[]> { this.logger.verbose(`Retrieving a article by author: ${author}`); if (!author) { throw new BadRequestException('Author keyword must be provided'); } const foundArticles = await this.articleRepository.findBy({ author: author }) if (foundArticles.length === 0) { throw new NotFoundException(`No articles found for author: ${author}`); } this.logger.verbose(`Retrieved articles list by ${author} Successfully`); return foundArticles; } // UPDATE - by id async updateArticleById(id: number, updateArticleRequestDto: UpdateArticleRequestDto): Promise<void> { this.logger.verbose(`Updating a article by id: ${id} with updateArticleRequestDto`); const foundArticle = await this.getArticleDetailById(id); const { title, contents } = updateArticleRequestDto; if (!title || !contents) { throw new BadRequestException('Title and contents must be provided'); } foundArticle.title = title; foundArticle.contents = contents; await this.articleRepository.save(foundArticle) this.logger.verbose(`Updated a article by ${id} Successfully`); } // UPDATE - status <ADMIN> async updateArticleStatusById(id: number, status: ArticleStatus): Promise<void> { this.logger.verbose(`ADMIN is Updating a article by id: ${id} with status: ${status}`); const result = await this.articleRepository.update(id, { status }); if (result.affected === 0) { throw new NotFoundException(`Article with ID ${id} not found`); } this.logger.verbose(`ADMIN Updated a article's by ${id} status to ${status} Successfully`); } // DELETE - by id async deleteArticleById(id: number, logginedUser: User): Promise<void> { this.logger.verbose(`User: ${logginedUser.username} is Deleting a article by id: ${id}`); const foundArticle = await this.getArticleDetailById(id); if (foundArticle.user.id !== logginedUser.id) { throw new UnauthorizedException('Do not have permission to delete this article') } await this.articleRepository.delete(foundArticle); this.logger.verbose(`Deleted a article by id: ${id} Successfully`); } }
TypeScript
복사

4. JWT 로그인 만료시간 버그픽스

POSTMAN에서 계속 로그인 이후 10초 이내로 로그인 상태가 해제되는 문제가 있었다.
로그인 후 쿠키(JWT)가 있는 상태인데 5초정도 이후 로그인 상태가 해지되었다.
이는 만료 시간과 관련있을 것이라 생각해서 문제점을 살펴보았다.
.env 파일에 분명히 36000으로 1시간으로 설정 해둔 부분이 적용이 되지 않는 문제로 보인다.
결과, dotenv를 통해 만료 시간을 설정하던 모듈 부분에서의 오류인 것으로 확인되었다.
dotenv.env 파일의 환경변수 값을 불러오면 값은 문자열 타입이다.
따라서 강제 형변환을 통해 parseInt()로 랩핑하여 숫자 타입으로 변경했다.
import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from "src/user/user.entity"; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import * as dotenv from 'dotenv'; import { JwtStrategy } from './jwt.strategy'; dotenv.config(); @Module({ imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_SECRET, signOptions:{ expiresIn: parseInt(process.env.JWT_EXPIRATION, 10) } }), TypeOrmModule.forFeature([User]) ], controllers: [AuthController], providers: [AuthService, JwtStrategy], exports: [JwtModule, PassportModule], }) export class AuthModule {}
TypeScript
복사
POSTMAN을 통한 테스트
이제 오랜 시간동안 로그인 상태를 유지하는 것을 확인했다.
Search
 | Main Page | Category |  Tags | About Me | Contact | Portfolio