Blog

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

Category
Author
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.ts → sign-up-request.dto.ts
•
login-user.dto.ts → sign-in-request.dto.ts
•
create-article.dto.ts → create-article-request.dto.ts
•
update-article.dto.ts → update-article-request.dto.ts
▪
클래스명
•
CreateUserDto → SignUpRequestDto
•
LogInUserDto → SignInRequestDto
•
CreateArticleDto → CreateArticleRequestDto
•
UpdateArticleDto → UpdateArticleRequestDto
◦
기존 클래스를 사용하던 Controller, Service 계층에서도 위 명칭으로 수정해준다.
•
수정을 하면서 얼마나 많은 부분들이 수정되어야 하는지 볼 수 있다.
•
이만큼 처음의 명명규칙과 관련된 컨벤션(Convention) 정책을 수립하는 것의 중요성을 알 수 있다.
📌
Board라는 것이 게시글인가? 에 대한 문제점
•
현재 DTO의 컨벤션을 맞추면서 기획 단계에서 애매한 것들, 수립하지 못한것들이 나타나고 있다.
💡
근본적으로 Board를 게시글 엔터티, 리소스 명칭으로 사용하는 것 자체의 문제점이 있다.
•
게시글은 Post 또는 Article이 적당하다.
•
Board는 게시판 그 자체이다.
◦
실제로는 자유게시판, 공지게시판, 직원게시판 등 여러개가 생성 될 가능성이 있다
◦
이 경우 위 처럼 게시판을 구분하는 기능 확장 가능성이 높은 케이스이기 때문에 초기에 수정해야 이후 문제가 발생하지 않을 가능성이 높다.
•
Board로 관련되어있는 모든 키워드를 Article로 변경한다.
◦
엔터티 명칭까지 Board → Article로 변경 할 것이기 때문에 DB의 테이블 자체가 article로 변경 될 것이다.
◦
Post 를 사용하지 않는 이유는 POST Http method와 겹치기 때문에 충돌이 날 가능성이 있다.
💡
현재 boards.controller.ts, board.entity와 같이 단수와 복수가 혼재되어 있는 상태이다.
•
기본적으로 단수로 표현하는 것이 가장 편리하고 가독성이 좋다.
•
여러 게시판을 다루는 경우에 Boards 처럼 복수를 사용하기는 하지만,
•
일부의 불규칙보다는 전체 통일성을 지키는 것이 가장 중요하다. BoardController 처럼 단수 또항 그 의미가 모호하지 않다.
◦
Board → Article로 바꾸는 과정에서 기존 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.ts 등 User관련 클래스들이 섞여있다.
•
사실 인증과 인가 / 회원 기능은 구분해야 한다.
•
그 중 회원가입은 종종 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, updateAt을 CommonEntity라는 추상 클래스로 생성한다.
•
해당 파일은 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
복사
•
article과 user 엔터티에서 중복되는 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, responseDto면 responseDto로 타입을 맞춰준다 이해

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를 활용할 수 있도록 기존 코드를 수정 해준다. 전반적인 코드가 수정되었다.
•
ArticleResponseDto를 ApiResponseDto로 랩핑하여 반환하기 때문에 반환 타입의 변경이 있다.
•
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