Blog

[NestJS] 17. 게시글 및 회원 관리 기능 보완(CRUD) 리팩토링

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

1. MVP 기능 구현 추가 계획

📌
현재 페이지 구성은 일부만을 HttpClient API 요청 확인을 위해서 임시 페이지처럼 구성 했었다.
•
따라서 대부분의 테스트는 POSTMAN으로 API 확인이 대부분이었다.
•
이제 실제 프론트엔드만으로도 사용 할 수 있도록 게시글 작성, 조회, 검색, 회원 정보 수정 등 프론트엔드 구성 위주로 진행해보고자 한다.
•
일부 미구현된 백엔드 API도 추가적으로 작업할 예정이다.
•
이후 추가적으로 권한에 따른 메뉴 노출 변경 등 프론트엔드 Guard를 사용해보고자 한다.
📌
백엔드 API MVP구성 현재 상태 리뷰
•
백엔드 API는 게시글, 로그인및회원가입, 회원으로 구성되고 있다.
◦
로그인 및 회원가입은 프론트+백엔드 기본은 완료, 카카오 로그인 추가 프론트엔드 구성 필요
◦
게시글은 API 구현은 완료, 프론트엔드는 기본 조회 기능만 구현됨, 프론트엔드 추가 구성 필요
◦
회원은 회원정보수정, 탈퇴 등 API 추가 구성 필요, 프론트엔드도 MyPage 위주로 추가 구현 필요
•
인증 관련 Controller인 auth.controller.ts
◦
카카오 로그인 관련 프론트엔드 추가작업 필요
import { Body, Controller, Get, Logger, Post, Query, Req, Res, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; import { Response } from 'express'; import { AuthService } from './auth.service'; import { SignUpRequestDto } from './dto/sign-up-request.dto'; import { User } from "src/user/user.entity"; import { SignInRequestDto } from './dto/sign-in-request.dto'; import { AuthGuard } from '@nestjs/passport'; import { GetUser } from './get-user.decorator'; import { UserResponseDto } from '../user/dto/user-response.dto'; import { ApiResponse } from 'src/common/api-response.dto'; import { ProfileService } from 'src/file/profile-file.service'; import { FileInterceptor } from '@nestjs/platform-express'; @Controller('api/auth') export class AuthController { private readonly logger = new Logger(AuthController.name); // Logger 인스턴스 constructor(private authService: AuthService, private profileService: ProfileService){} // 회원 가입 기능 @Post('/signup') @UseInterceptors(FileInterceptor('profilePicture')) async signUp(@Body() signUpRequestDto: SignUpRequestDto, @UploadedFile() file: Express.Multer.File): Promise<ApiResponse<UserResponseDto>> { this.logger.verbose(`Attempting to sign up user with email: ${signUpRequestDto.email}`); const user = await this.authService.signUp(signUpRequestDto); if (file) { await this.profileService.uploadProfilePicture(file, user.id); } const userResponseDto = new UserResponseDto(user); this.logger.verbose(`User signed up successfully: ${JSON.stringify(userResponseDto)}`); return new ApiResponse(true, 201, 'User signed up successfully', userResponseDto); } // 로그인 기능 @Post('/signin') async signIn(@Body() signInRequestDto: SignInRequestDto, @Res() res: Response): Promise<void> { this.logger.verbose(`Attempting to sign in user with email: ${signInRequestDto.email}`); const { jwtToken, user } = await this.authService.signIn(signInRequestDto); const userResponseDto = new UserResponseDto(user); this.logger.verbose(`User signed in successfully: ${JSON.stringify(userResponseDto)}`); // [3] 쿠키 설정 res.cookie('Authorization', jwtToken, { httpOnly: false, // 클라이언트 측 스크립트에서 쿠키 접근 금지 secure: false, // HTTPS에서만 쿠키 전송, 임시 비활성화 maxAge: 3600000, // 1시간 sameSite: 'lax', // CSRF 공격 방어 및 크로스 사이트 요청에서 쿠키 포함 }); res.status(200).json(new ApiResponse(true, 200, 'Sign in successful', { jwtToken, user: userResponseDto })); } // 인증된 회원이 들어갈 수 있는 테스트 URL 경로 @Post('/test') @UseGuards(AuthGuard()) // @UseGuards : 핸들러는 지정한 인증 가드가 적용됨 -> AuthGuard()의 'jwt'는 기본값으로 생략가능 async testForAuth(@GetUser() user: User): Promise<ApiResponse<UserResponseDto>> { this.logger.verbose(`Authenticated user accessing test route: ${user.email}`); const userResponseDto = new UserResponseDto(user); return new ApiResponse(true, 200, 'You are authenticated', userResponseDto); } // 카카오 로그인 페이지 요청 @Get('/kakao') @UseGuards(AuthGuard('kakao')) async kakaoLogin(@Req() req: Request) { // 이 부분은 Passport의 AuthGuard에 의해 카카오 로그인 페이지로 리다이렉트 } // 카카오 로그인 콜백 엔드포인트 @Get('kakao/callback') async kakaoCallback(@Query('code') kakaoAuthResCode: string, @Res() res: Response) { // Authorization Code 받기 const { jwtToken, user } = await this.authService.signInWithKakao(kakaoAuthResCode); // 쿠키에 JWT 설정 res.cookie('Authorization', jwtToken, { httpOnly: true, // 클라이언트 측 스크립트에서 쿠키 접근 금지 secure: false, // HTTPS에서만 쿠키 전송, 임시 비활성화 maxAge: 3600000, // 1시간 sameSite: 'lax', // CSRF 공격 방어 }); const userResponseDto = new UserResponseDto(user); this.logger.verbose(`User signed in successfully: ${JSON.stringify(userResponseDto)}`); res.status(200).json(new ApiResponse(true, 200, 'Sign in successful', { jwtToken, user: userResponseDto })); } }
TypeScript
복사
•
회원 정보 관련된 user.controller.ts
◦
mypage를 접근하기 위한 특정 회원 정보 조회 정도 기능만 구현되어있다.
◦
회원 정보 수정, 삭제 등 추가 API 구성이 필요하다.
import { Controller, Get, Logger, Param, UseGuards } from '@nestjs/common'; import { UserService } from './user.service'; import { AuthGuard } from '@nestjs/passport'; import { RolesGuard } from 'src/auth/custom-role.guard'; import { UserResponseDto } from 'src/user/dto/user-response.dto'; import { ApiResponse } from 'src/common/api-response.dto'; import { UserWithFilesResponseDto } from './dto/user-with-files-response.dto'; @Controller('api/users') @UseGuards(AuthGuard('jwt'), RolesGuard) export class UserController { private readonly logger = new Logger(UserController.name); constructor(private userService: UserService){} // 특정 번호의 회원 정보 조회 @Get(':id') async getUserById(@Param('id') id: number): Promise<ApiResponse<UserWithFilesResponseDto>> { this.logger.verbose(`Retrieving User with ID ${id}`); const user = await this.userService.findOneByIdWithFiles(id); const userDto = new UserWithFilesResponseDto(user); this.logger.verbose(`User retrieved successfully: ${JSON.stringify(userDto)}`); return new ApiResponse(true, 200, 'User retrieved successfully', userDto); } }
TypeScript
복사

2. 게시글 CRUD 구성

2.1 현재 상태 점검

📌
현재 API 구성 상태 점검
•
게시글 관련 Controller인 article.controller.ts
import { Body, Controller, Delete, Get, Logger, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common'; import { ArticleService } from './article.service'; import { Article } from './article.entity'; import { CreateArticleRequestDto } from './dto/create-article-request.dto'; import { ArticleStatus } from './article-status.enum'; import { UpdateArticleRequestDto } from './dto/update-article-request.dto'; import { ArticleStatusValidationPipe } from './pipes/article-status-validation.pipe'; import { AuthGuard } from '@nestjs/passport'; import { RolesGuard } from 'src/auth/custom-role.guard'; import { Roles } from 'src/auth/roles.decorator'; import { UserRole } from 'src/user/user-role.enum'; import { GetUser } from 'src/auth/get-user.decorator'; import { User } from "src/user/user.entity"; import { ApiResponse } from 'src/common/api-response.dto'; import { ArticleResponseDto } from './dto/article-response.dto'; @Controller('api/articles') @UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증과 role 커스텀 가드를 적용 export class ArticleController { private readonly logger = new Logger(ArticleController.name); // Logger 인스턴스 생성 // 생성자 주입(DI) constructor(private articleService: ArticleService){} // 게시글 작성 기능 @Post('/') @Roles(UserRole.USER) async createArticle(@Body() createArticleRequestDto: CreateArticleRequestDto, @GetUser() user: User): Promise<ApiResponse<ArticleResponseDto>> { this.logger.verbose(`User ${user.username} creating a new Article. Data: ${JSON.stringify(createArticleRequestDto)}`); const article = await this.articleService.createArticle(createArticleRequestDto, user); const articleDto = new ArticleResponseDto(article); this.logger.verbose(`Article created successfully: ${JSON.stringify(articleDto)}`); return new ApiResponse(true, 201, 'Article created successfully', articleDto); } // 전체 게시글 조회 기능 @Get('/') @Roles(UserRole.USER) async getAllArticles(): Promise<ApiResponse<ArticleResponseDto[]>> { this.logger.verbose('Retrieving all Articles'); const articles = await this.articleService.getAllArticles(); const articleDtos = articles.map(article => new ArticleResponseDto(article)); this.logger.verbose(`All articles retrieved successfully: ${JSON.stringify(articleDtos)}`); return new ApiResponse(true, 200, 'All articles retrieved successfully', articleDtos); } // 페이징 처리된 게시글 조회 @Get('/paginated') async getPaginatedArticles( @Query('page') page: number = 1, @Query('limit') limit: number = 10 ): Promise<ApiResponse<ArticleResponseDto[]>> { this.logger.verbose(`Retrieving paginated articles: page ${page}, limit ${limit}`); const articles = await this.articleService.getPaginatedArticles(page, limit); const articleDtos = articles.map(article => new ArticleResponseDto(article)); this.logger.verbose(`Paginated articles retrieved successfully`); return new ApiResponse(true, 200, 'Paginated articles retrieved successfully', articleDtos); } // 나의 게시글 조회 기능 @Get('/myarticles') async getMyAllArticles(@GetUser() user: User): Promise<ApiResponse<ArticleResponseDto[]>> { this.logger.verbose(`User ${user.username} retrieving their Articles`); const articles = await this.articleService.getMyAllArticles(user); const articleDtos = articles.map(article => new ArticleResponseDto(article)); this.logger.verbose(`User articles retrieved successfully: ${JSON.stringify(articleDtos)}`); return new ApiResponse(true, 200, 'User articles retrieved successfully', articleDtos); } // 특정 번호의 게시글 조회 @Get('/:id') async getArticleById(@Param('id') id: number): Promise<ApiResponse<ArticleResponseDto>> { this.logger.verbose(`Retrieving Article with ID ${id}`); const article = await this.articleService.getArticleById(id); const articleDto = new ArticleResponseDto(article); this.logger.verbose(`Article retrieved successfully: ${JSON.stringify(articleDto)}`); return new ApiResponse(true, 200, 'Article retrieved successfully', articleDto); } // 특정 작성자의 게시글 조회 @Get('/search') async getArticlesByAuthor(@Query('author') author: string): Promise<ApiResponse<ArticleResponseDto[]>> { this.logger.verbose(`Searching Articles by author ${author}`); const articles = await this.articleService.getArticlesByAuthor(author); const articleDtos = articles.map(article => new ArticleResponseDto(article)); this.logger.verbose(`Articles retrieved by author successfully: ${JSON.stringify(articleDtos)}`); return new ApiResponse(true, 200, 'Articles retrieved by author successfully', articleDtos); } // 특정 번호의 게시글 삭제 @Delete('/:id') @Roles(UserRole.USER) async deleteArticleById(@Param('id') id: number, @GetUser() user: User): Promise<ApiResponse<void>> { this.logger.verbose(`User ${user.username} deleting Article with ID ${id}`); await this.articleService.deleteArticleById(id, user); this.logger.verbose(`Article deleted successfully with ID ${id}`); return new ApiResponse(true, 200, 'Article deleted successfully'); } // 특정 번호의 게시글의 일부 수정 (관리자가 부적절한 글을 비공개로 설정) @Patch('/:id/status') @Roles(UserRole.ADMIN) async updateArticleStatusById(@Param('id') id: number, @Body('status', ArticleStatusValidationPipe) status: ArticleStatus, @GetUser() user: User): Promise<ApiResponse<void>> { this.logger.verbose(`Admin ${user.username} updating status of Article ID ${id} to ${status}`); await this.articleService.updateArticleStatusById(id, status, user); this.logger.verbose(`Article status updated successfully for ID ${id} to ${status}`); return new ApiResponse(true, 200, 'Article status updated successfully'); } // 특정 번호의 게시글의 전체 수정 @Put('/:id') async updateArticleById(@Param('id') id: number, @Body() updateArticleRequestDto: UpdateArticleRequestDto): Promise<ApiResponse<void>> { this.logger.verbose(`Updating Article with ID ${id}`); await this.articleService.updateArticleById(id, updateArticleRequestDto); this.logger.verbose(`Article updated successfully with ID ${id}`); return new ApiResponse(true, 200, 'Article updated successfully'); } }
TypeScript
복사

2.2 추가 작업

2.2.1 게시글 작성 부분

📌
엔터티 설계 변경
•
기존 File 엔터티의 문제점
◦
File 테이블을 통해 Type으로 사용자 프로필 사진과 게시글의 첨부 파일을 구분하고자 했으나 이는 복잡한 연관관계를 가질 뿐만 아니라 두개는 별개의 기능임에도 불구하고 불필요한 의존관계가 형성됨
◦
따라서 실제 기능별 테이블로 구분하기로 함
▪
기존 File → ProfilePicture 테이블
▪
게시글의 첨부파일은 Attachment 테이블
◦
이에 따라 관련 코드 리팩토링
◦
profile-picture.entity.ts
import { BaseEntity } from "src/common/base.entity"; import { User } from "src/user/user.entity"; import { Column, Entity, ManyToOne } from "typeorm"; import { ProfilePictureType } from "./profile-picture-type.enum"; @Entity() export class ProfilePicture extends BaseEntity { @Column() filename: string; @Column() mimetype: string; @Column() path: string; @Column() size: number; @Column() profilePictureType: ProfilePictureType; @Column() url: string; @ManyToOne(() => User, user => user.profilePictures, { eager: false }) user: User; }
TypeScript
복사
◦
profile-picture-type.enum.ts
export enum ProfilePictureType { IMAGE = 'IMAGE', DEFAULT = 'DEFAULT' }
TypeScript
복사
◦
위 엔터티명, 파일명 규칙에 따른 모든 변경사항 적용
📌
attachment 엔터티 생성
•
위 리팩토링이 완료된 ProfilePicture 의 구조와 거의 동일한 attachment 구조 및 파일 생성
•
attachment.entitiy.ts
◦
다른 점은 profile-picture의 경우 user와 연관관계를 가지지만, attachment는 article과 연관관계를 가지고 있음
import { BaseEntity } from "src/common/base.entity"; import { Column, Entity, ManyToOne } from "typeorm"; import { Article } from "src/article/article.entity"; import { AttachmentType } from "./attachment-type.enum"; @Entity() export class Attachment extends BaseEntity { @Column() filename: string; @Column() mimetype: string; @Column() path: string; @Column() size: number; @Column() attachmentType: AttachmentType; @Column() url: string; @ManyToOne(() => Article, article => article.attachments, { eager: false }) article: Article; }
TypeScript
복사
•
아래 코드들은 코드의 구조와 메서드 명칭만 ProfilePicture→Attachment 로 변경된 수준으로 거의 동일한 코드를 가짐
•
이를 통해 중복 코드가 발생하는 것 처럼 느껴지지만
•
모듈화를 위해 File이라는 공용 의존관계를 끊어내며 복잡성을 감소시키는 균형을 선택함
◦
attachment.service.ts
import { Injectable } from '@nestjs/common'; import { Attachment } from './entities/attachment.entity'; import { AttachmentType } from './entities/attachment-type.enum'; import { AttachmentUploadService } from './attachment-upload.service'; import { Article } from 'src/article/article.entity'; @Injectable() export class AttachmentService { constructor(private readonly attachmentUploadService: AttachmentUploadService) {} // 게시글 파일 업로드 async uploadArticleFiles(file: Express.Multer.File, article: Article) { // 파일 업로드 const result = await this.attachmentUploadService.uploadFile(file); // 파일 메타데이터 저장 const newFile = await this.createFileMetadata(result, article); // 파일 엔터티를 데이터베이스에 저장 await this.attachmentUploadService.save(newFile); return result; } // 파일 메타데이터 생성 메서드 private async createFileMetadata(result: any, article: Article): Promise<Attachment> { const newFile = new Attachment(); newFile.path = result.filePath; newFile.filename = result.filename; newFile.mimetype = result.mimetype; newFile.size = result.size; newFile.attachmentType = AttachmentType.IMAGE; newFile.url = result.url; newFile.article = article; return newFile; } }
TypeScript
복사
◦
attachment-upload.service.ts
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { promises as fs } from 'fs'; import * as path from 'path'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; import { Attachment } from './entities/attachment.entity'; @Injectable() export class AttachmentUploadService { private uploadPath = path.join(__dirname, '../..', 'public', 'uploads', 'attachment'); constructor( @InjectRepository(Attachment) private readonly attachmentRepository: Repository<Attachment> ) { this.ensureUploadPathExists(); } async ensureUploadPathExists() { try { await fs.mkdir(this.uploadPath, { recursive: true }); } catch (err) { throw new HttpException('Failed to create upload directory', HttpStatus.INTERNAL_SERVER_ERROR); } } // 파일 업로드 async uploadFile(file: Express.Multer.File) { const uniqueFilename = `${uuidv4()}-${file.originalname}`; const filePath = path.join(this.uploadPath, uniqueFilename); const fileUrl = `http://localhost:${process.env.SERVER_PORT}/uploads/attachment/${uniqueFilename}`; try { await fs.writeFile(filePath, file.buffer); // 파일 저장 return { message: 'File uploaded successfully', filePath: filePath, filename: uniqueFilename, mimetype: file.mimetype, size: file.size, url: fileUrl, }; } catch (err) { throw new HttpException('Failed to upload file', HttpStatus.INTERNAL_SERVER_ERROR); } } // 파일 엔터티 데이터베이스에 저장 async save(file: Attachment) { try { return await this.attachmentRepository.save(file); } catch (err) { throw new HttpException('Failed to save file', HttpStatus.INTERNAL_SERVER_ERROR); } } }
TypeScript
복사
◦
attachemnt.module.ts
import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Attachment } from './entities/attachment.entity'; import { AttachmentUploadService } from './attachment-upload.service'; import { AttachmentService } from './attachment.service'; @Module({ imports: [ TypeOrmModule.forFeature([Attachment]), ], providers: [AttachmentUploadService, AttachmentService], exports: [AttachmentUploadService, AttachmentService] }) export class AttachmentModule {}
TypeScript
복사
◦
attachment-response.dto.ts
import { AttachmentType } from "../entities/attachment-type.enum"; import { Attachment } from "../entities/attachment.entity"; export class AttachmentResponseDto { id: number; filename: string; mimetype: string; path: string; size: number; attachmentType: AttachmentType; url: string; createdAt: Date; updatedAt: Date; constructor(file: Attachment){ this.id = file.id; this.filename = file.filename; this.mimetype = file.mimetype; this.path = file.path; this.size = file.size; this.attachmentType = file.attachmentType; this.url = file.url; this.createdAt = file.createdAt; this.updatedAt = file.updatedAt; } }
TypeScript
복사
📌
게시글 작성 Controller에 파일 첨부 기능 추가
•
article.controller.ts
◦
프로필 사진 업로드 기능 구현과 동일하게 @UseInterceptors(FileInterceptor('articleFile'))인터셉터를 통해 multer 라이브러리 기능을 사용 할 수 있도록 설정
◦
파일 첨부 기능이 구현된 attachmentService의 uploadArticleFiles(file, article) 메서드를 호출하여 게시글에 파일을 첨부
import { Body, Controller, Delete, Get, Logger, Param, Patch, Post, Put, Query, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; import { ArticleService } from './article.service'; ... import { AttachmentService } from 'src/file/attachment/attachment.service'; import { ArticleWithAttachmentAndUserResponseDto } from './dto/article-with-attachment-user-response.dto'; @Controller('api/articles') @UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증과 role 커스텀 가드를 적용 export class ArticleController { private readonly logger = new Logger(ArticleController.name); // Logger 인스턴스 생성 // 생성자 주입(DI) constructor(private articleService: ArticleService, private attachmentService: AttachmentService){} // 게시글 작성 기능 @Post('/') @UseInterceptors(FileInterceptor('articleFile')) @Roles(UserRole.USER) async createArticle( @Body() createArticleRequestDto: CreateArticleRequestDto, @GetUser() user: User, @UploadedFile() file: Express.Multer.File ): Promise<ApiResponse<ArticleWithAttachmentAndUserResponseDto>> { this.logger.verbose(`User ${user.username} creating a new Article. Data: ${JSON.stringify(createArticleRequestDto)}`); const article = await this.articleService.createArticle(createArticleRequestDto, user); if (file) { await this.attachmentService.uploadArticleFiles(file, article); } const articleDto = new ArticleWithAttachmentAndUserResponseDto(article); this.logger.verbose(`Article created successfully: ${JSON.stringify(articleDto)}`); return new ApiResponse(true, 201, 'Article created successfully', articleDto); } // 전체 게시글 조회 기능 ... }
TypeScript
복사
•
브라우저를 통한 테스트
•
백엔드 서버의 로그를 통한 게시글 생성 및 파일 업로드 쿼리 모습

2.2.2 게시글 조회

2.2.2.1 게시글 페이징 목록

📌
게시글 페이징 처리가 추가된 API 상태
•
•
이전 백엔드 서버의 경우 페이징 관련 Controller와 Service 계층은 구성되어 있다.
•
article.controller.ts
import { Body, Controller, Delete, Get, Logger, Param, Patch, Post, Put, Query, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; import { ArticleService } from './article.service'; import { CreateArticleRequestDto } from './dto/create-article-request.dto'; ... import { FileInterceptor } from '@nestjs/platform-express'; import { AttachmentService } from 'src/file/attachment/attachment.service'; import { ArticleWithAttachmentAndUserResponseDto } from './dto/article-with-attachment-user-response.dto'; import { ArticlePaginatedResponseDto } from './dto/article-paginated-response.dto'; @Controller('api/articles') @UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증과 role 커스텀 가드를 적용 export class ArticleController { private readonly logger = new Logger(ArticleController.name); // Logger 인스턴스 생성 // 생성자 주입(DI) constructor(private articleService: ArticleService, private attachmentService: AttachmentService){} // 게시글 작성 기능 ... // 전체 게시글 조회 기능 ... // 페이징 처리된 게시글 조회 @Get('/paginated') async getPaginatedArticles( @Query('page') page: number = 1, @Query('limit') limit: number = 10 ): Promise<ApiResponse<ArticlePaginatedResponseDto>> { this.logger.verbose(`Retrieving paginated articles: page ${page}, limit ${limit}`); const paginatedResponse = await this.articleService.getPaginatedArticles(page, limit); this.logger.verbose(`Paginated articles retrieved successfully`); return new ApiResponse(true, 200, 'Paginated articles retrieved successfully', paginatedResponse); } // 나의 게시글 조회 기능 ... }
TypeScript
복사
•
article.service.ts
◦
쿼리빌더를 통해
import { Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { Article } from './entities/article.entity'; ... import { ArticlePaginatedResponseDto } from './dto/article-paginated-response.dto'; import { ArticleWithAttachmentAndUserResponseDto } from './dto/article-with-attachment-user-response.dto'; @Injectable() export class ArticleService { private readonly logger = new Logger(ArticleService.name); // Logger 인스턴스 생성 constructor( @InjectRepository(Article) private articleRepository: Repository<Article> ){} // 게시글 작성 ... // 전체 게시글 조회 ... // 페이징 추가 게시글 조회 기능 async getPaginatedArticles(page: number, limit: number): Promise<ArticlePaginatedResponseDto> { this.logger.verbose(`Retrieving paginated articles: page ${page}, limit ${limit}`); const skip: number = (page - 1) * limit; const [articles, totalCount] = await this.articleRepository.createQueryBuilder("article") .leftJoinAndSelect("article.attachments", "attachment") .leftJoinAndSelect("article.user", "user") .skip(skip) .take(limit) .orderBy("article.createdAt", "DESC") // 내림차순 .getManyAndCount(); const articleDtos = articles.map(article => new ArticleWithAttachmentAndUserResponseDto(article)); this.logger.verbose(`Paginated articles retrieved successfully`); return new ArticlePaginatedResponseDto(articleDtos, totalCount); } // 나의 게시글 조회 ... }
TypeScript
복사
📌
DTO 수정
•
기존 게시글 반환 DTO는 게시글 정보만을 반환하고 있기 때문에 페이지와 관련된 필드가 필요하다.
•
기존 DTO는 필요에 따라 사용되는 부분이 있기 때문에, 페이지 숫자를 포함한 DTO는 별도로 생성하여 사용하고자 한다.
•
article-with-attachment-user-response.dto.ts
◦
게시글과 연관된 첨부자료, 회원 정보까지 최대한 모든 정보를 제공하는 DTO를 구성
import { AttachmentResponseDto } from "src/file/attachment/dto/attachment-response.dto"; import { ArticleStatus } from "../entities/article-status.enum"; import { Article } from "../entities/article.entity"; import { UserResponseDto } from "src/user/dto/user-response.dto"; export class ArticleWithAttachmentAndUserResponseDto { id: number; author: string; title: string; contents: string; status: ArticleStatus; createdAt: Date; updatedAt: Date; user: UserResponseDto; attachments: AttachmentResponseDto[]; 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; this.attachments = article.attachments?article.attachments.map(attachment => new AttachmentResponseDto(attachment)) : []; } }
TypeScript
복사
•
위 게시글, 회원, 첨부자료 정보들을 포함하면서 페이지 카운트를 제공하는 DTO를 구성
import { ArticleWithAttachmentAndUserResponseDto } from "./article-with-attachment-user-response.dto"; export class ArticlePaginatedResponseDto { articles: ArticleWithAttachmentAndUserResponseDto[]; totalCount: number; constructor(articles: ArticleWithAttachmentAndUserResponseDto[], totalCount: number) { this.articles = articles; this.totalCount = totalCount; } }
TypeScript
복사
📌
프론트엔드 구성
•
article-paginated-list 리소스 폴더 구성
•
article-paginated-list.component.html
<ion-header> <ion-toolbar> <ion-title>Articles</ion-title> </ion-toolbar> </ion-header> <ion-content> <ion-list> <ion-item *ngFor="let article of articles" (click)="viewArticle(article.id)"> <ion-label> <h2>{{ article.title }}</h2> <p>{{ article.contents }}</p> </ion-label> </ion-item> </ion-list> <div class="pagination"> <ion-button [disabled]="currentPage === 1" (click)="changePage(currentPage - 1)"> Previous </ion-button> <span>Page {{ currentPage }} of {{ totalPages }}</span> <ion-button [disabled]="currentPage === totalPages" (click)="changePage(currentPage + 1)"> Next </ion-button> </div> </ion-content>
TypeScript
복사
•
article-paginated-list.component.scss
.pagination { display: flex; justify-content: center; align-items: center; margin: 16px 0; ion-button { margin: 0 8px; } span { margin: 0 16px; } }
TypeScript
복사
•
article-paginated-list.component.ts
◦
현재 페이지, 최대 페이지, 표시할 게시글 갯수 등의 변수를 활용한 간단한 연산으로 구성되어 있다.
▪
currentPage : 최초 현재 페이지는 1로 초기화, 추후 동적으로 컨트롤러의 쿼리스트링 page를 변경시킴
▪
limit : 페이지당 보여줄 게시글 갯수
▪
totalCount : 게시글의 총 개수
▪
totalPages : 페이지는 총 게시글 수를 보여줄 갯수로 나누는 연산
•
예로 총 55개면 10개씩 나타나도록 구성하면 5.5로 총 6페이지가 필요하다.
import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { ArticleService } from '../../../services/article/article.service'; import { ArticleResponseData } from 'src/app/models/article/article-response-data.interface'; @Component({ selector: 'app-article-paginated-list', templateUrl: './article-paginated-list.component.html', styleUrls: ['./article-paginated-list.component.scss'], }) export class ArticlePaginatedListComponent implements OnInit { articles: ArticleResponseData[] = []; currentPage: number = 1; limit: number = 10; totalPages: number = 0; totalCount: number = 0; constructor(private articleService: ArticleService, private router: Router) {} ngOnInit() { this.loadPaginatedArticles(); } loadPaginatedArticles() { this.articleService.getPaginatedArticles(this.currentPage, this.limit).subscribe({ next: response => { if (response.success) { this.articles = response.data.articles; this.totalCount = response.data.totalCount; this.totalPages = Math.ceil(this.totalCount / this.limit); } else { console.error(response.message); } }, error: err => { console.error('Error fetching paginated articles:', err); }, complete: () => { console.log('Fetching paginated articles request completed.'); } }); } viewArticle(id: number) { this.router.navigate(['/articles/detail', id]); } changePage(page: number) { this.currentPage = page; this.loadPaginatedArticles(); } }
TypeScript
복사
•
article.service.ts
◦
HttpClient를 통해 localhost:3000/api/articles/paginated?page=1&limit=10 과 같은 형태의 요청을 보내게 된다.
◦
쿼리스트링인 page는 동적으로 변하면서 게시글 목록의 페이지를 이동하게 된다.
◦
limit 은 한 페이지에서 보여지는 글의 갯수이며 10으로 고정되어 있다.
import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ApiResponse } from '../../models/common/api-response.interface'; import { ArticleResponseData } from '../../models/article/article-response-data.interface'; import { ArticlePaginatedResponse } from 'src/app/models/article/article-paginated-response-data.interface'; @Injectable({ providedIn: 'root' }) export class ArticleService { private apiUrl = 'http://localhost:3000/api/articles'; constructor(private http: HttpClient) { } getAllArticles(): Observable<ApiResponse<ArticleResponseData[]>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.get<ApiResponse<ArticleResponseData[]>>(`${this.apiUrl}`, { headers, withCredentials: true }); } getPaginatedArticles(page: number, limit: number): Observable<ApiResponse<ArticlePaginatedResponse>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.get<ApiResponse<ArticlePaginatedResponse>>(`${this.apiUrl}/paginated?page=${page}&limit=${limit}`, { headers, withCredentials: true }); } getArticleById(id: number): Observable<ApiResponse<ArticleResponseData>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.get<ApiResponse<ArticleResponseData>>(`${this.apiUrl}/${id}`, { headers, withCredentials: true }); } writeArticle(formData: FormData): Observable<ApiResponse<ArticleResponseData>> { const headers = new HttpHeaders({ 'enctype': 'multipart/form-data' }); return this.http.post<ApiResponse<ArticleResponseData>>(`${this.apiUrl}`, formData, { headers, withCredentials: true }); } }
TypeScript
복사
•
위 수정사항에 맞추어 라우팅 모듈 및 의존성 주입 설정을 추가해준다.
◦
article-routing.module.ts
import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { ArticleListComponent } from './article-list/article-list.component'; import { ArticleDetailComponent } from './article-detail/article-detail.component'; import { ArticleWriteComponent } from './article-write/article-write.component'; import { ArticlePaginatedListComponent } from './article-pagenated-list/article-paginated-list.component'; const routes: Routes = [ { path: '', component: ArticleListComponent }, { path: 'list', component: ArticleListComponent }, { path: 'paginated-list', component: ArticlePaginatedListComponent }, { path: 'detail/:id', component: ArticleDetailComponent }, { path: 'write', component: ArticleWriteComponent }, ]; @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] }) export class ArticleRoutingModule {}
TypeScript
복사
◦
menu.component.html
<ion-menu side="end" contentId="main-content"> <ion-header> <ion-toolbar> <ion-title>Menu</ion-title> </ion-toolbar> </ion-header> <ion-content> <ion-list> <ion-item routerLink="/auth">SignIn & SignUp</ion-item> <ion-item routerLink="/articles/write">Write an Article</ion-item> <ion-item routerLink="/articles/list">Article List</ion-item> <ion-item routerLink="/articles/paginated-list">Article Paginated List</ion-item> </ion-list> </ion-content> </ion-menu>
TypeScript
복사
◦
article.module.ts
import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { IonicModule } from '@ionic/angular'; import { ArticleListComponent } from './article-list/article-list.component'; import { ArticleRoutingModule } from './article-routing.module'; import { ArticleDetailComponent } from './article-detail/article-detail.component'; import { ArticleWriteComponent } from './article-write/article-write.component'; import { ArticlePaginatedListComponent } from './article-pagenated-list/article-paginated-list.component'; @NgModule({ imports: [ CommonModule, FormsModule, IonicModule, ArticleRoutingModule ], declarations: [ArticleListComponent, ArticlePaginatedListComponent, ArticleDetailComponent, ArticleWriteComponent] }) export class ArticleModule {}
TypeScript
복사
•
브라우저를 통한 테스트

2.2.2.2 게시글 상세정보 파일 첨부

📌
게시글 상세 보기의 첨부 파일 표현
•
article.service.ts
•
QueryBuilder를 활용한 쿼리 튜닝
◦
attachment, user 정보를 포함한 article 데이터 조회
import { Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common'; ... import { ArticlePaginatedResponseDto } from './dto/article-paginated-response.dto'; import { ArticleWithAttachmentAndUserResponseDto } from './dto/article-with-attachment-user-response.dto'; @Injectable() export class ArticleService { private readonly logger = new Logger(ArticleService.name); // Logger 인스턴스 생성 constructor( @InjectRepository(Article) private articleRepository: Repository<Article> ){} // 게시글 작성 ... // 전체 게시글 조회 ... // 페이징 추가 게시글 조회 기능 async getPaginatedArticles(page: number, limit: number): Promise<ArticlePaginatedResponseDto> { this.logger.verbose(`Retrieving paginated articles: page ${page}, limit ${limit}`); const skip: number = (page - 1) * limit; const [foundArticles, totalCount] = await this.articleRepository.createQueryBuilder("article") .leftJoinAndSelect("article.attachments", "attachment") .leftJoinAndSelect("article.user", "user") .skip(skip) .take(limit) .orderBy("article.createdAt", "DESC") // 내림차순 .getManyAndCount(); const articleDtos = foundArticles.map(foundArticle => new ArticleWithAttachmentAndUserResponseDto(foundArticle)); this.logger.verbose(`Paginated articles retrieved successfully`); return new ArticlePaginatedResponseDto(articleDtos, totalCount); } // 나의 게시글 조회 ... }
TypeScript
복사
•
article-detail.component.html
<ion-header> <ion-toolbar> <ion-title>Article Detail</ion-title> </ion-toolbar> </ion-header> <ion-content> <ion-card *ngIf="article"> <ion-card-header> <ion-card-title>{{ article.title }}</ion-card-title> <ion-card-subtitle> <p>작성자: {{ article.user.username }}</p> <p>작성 시간: {{ article.createdAt | date: 'short' }}</p> <!-- 날짜 포맷 --> </ion-card-subtitle> </ion-card-header> <ion-card-content> <p>{{ article.contents }}</p> <!-- 첨부파일이 있는 경우 이미지 태그로 표시 --> <div *ngIf="article.attachments && article.attachments.length > 0"> <h3>첨부파일:</h3> <div *ngFor="let attachment of article.attachments"> <img [src]="attachment.url" alt="{{ attachment.filename }}" style="max-width: 100%; height: auto;"/> <p>{{ attachment.filename }}</p> </div> </div> </ion-card-content> </ion-card> </ion-content>
TypeScript
복사
•
스타일 리팩토링
◦
article-detail.component.html
<ion-header> <ion-title>Article Detail</ion-title> </ion-header> <ion-content> <ion-card *ngIf="article" class="card"> <!-- 게시글 제목 헤더 --> <ion-card-header> <ion-card-title class="card-title">{{ article.title }}</ion-card-title> </ion-card-header> <!-- 게시글 정보 서브타이틀 --> <ion-card-subtitle class="card-header"> <ion-item> <ion-label>작성자: {{ article.user.username }}</ion-label> </ion-item> <ion-item> <ion-label>작성일: {{ article.createdAt | date: 'short' }}</ion-label> </ion-item> </ion-card-subtitle> <!-- 게시글 본문 --> <ion-card-content class="card-content"> <!-- 본문 내용 --> <p>{{ article.contents }}</p> <!-- 첨부파일 이미지 본문 추가 --> <ion-list *ngIf="article.attachments && article.attachments.length > 0"> <ion-item *ngFor="let attachment of article.attachments"> <img *ngIf="isImage(attachment.url)" [src]="attachment.url" alt="{{ attachment.filename }}" class="thumbnail-img"/> </ion-item> </ion-list> </ion-card-content> <ion-card-content class="attachment-card"> <!-- 첨부파일이 다운로드 부분 --> <ion-list *ngIf="article.attachments && article.attachments.length > 0"> <h3>첨부파일:</h3> <ion-item *ngFor="let attachment of article.attachments"> <ion-card class="attachment-card"> <ion-card-header> <ion-card-subtitle>{{ attachment.filename }}</ion-card-subtitle> </ion-card-header> <ion-card-content> <ion-thumbnail slot="start"> <img [src]="attachment.url" alt="{{ attachment.filename }}" class="thumbnail-img"/> </ion-thumbnail> <ion-label> <p>파일 링크가 필요하시면 클릭하세요.</p> </ion-label> <ion-button slot="end" fill="outline" (click)="downloadFile(attachment.url)"> <ion-icon slot="start" name="download"></ion-icon> 첨부파일 받기 </ion-button> </ion-card-content> </ion-card> </ion-item> </ion-list> </ion-card-content> </ion-card> </ion-content>
TypeScript
복사
◦
article-detail.component.scss
:host { display: block; margin-top: 80px; } .card { border: 1px solid #ddd; border-radius: 8px; background-color: #fff; // 카드 배경색 } .card-title { font-size: 1.5em; font-weight: bold; } .card-content { font-size: 1.1em; line-height: 1.5; margin: 10px; // 위아래 여백 추가 padding: 10px; // 내용에 패딩 추가 height: 250px; } .thumbnail-img { max-width: 100%; height: auto; } .attachment-card { margin-top: 10px; // 첨부파일 카드 간격 border: 1px solid #ccc; // 카드 테두리 border-radius: 8px; background-color: #f9f9f9; // 첨부파일 카드 배경색 padding: 10px; // 카드 내부 패딩 } .card-header { padding: 0; // 카드 헤더 패딩 제거 } .card-header, ion-item { --ion-item-background: transparent; // 배경색 투명 --ion-item-border-color: transparent; // 경계선 제거 }
Scss
복사
◦
article-detail.component.ts
import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { ArticleService } from '../../../services/article/article.service'; import { ArticleWithAttachmentAndUserResponseData } from 'src/app/models/article/article-with-attachment-user-response-data.interface'; @Component({ selector: 'app-article-detail', templateUrl: './article-detail.component.html', styleUrls: ['./article-detail.component.scss'], }) export class ArticleDetailComponent implements OnInit { article: ArticleWithAttachmentAndUserResponseData | undefined; constructor( private route: ActivatedRoute, private articleService: ArticleService ) {} ngOnInit() { this.loadArticle(); } loadArticle() { const id = this.route.snapshot.paramMap.get('id'); if (id) { this.articleService.getArticleById(+id).subscribe({ next: response => { if (response.success) { this.article = response.data; } else { console.error(response.message); } }, error: err => { console.error('Error fetching article:', err); }, complete: () => { console.log('Fetching an article request completed.'); } }); } else { console.error('Article ID is null'); } } isImage(url: string): boolean { return url.match(/\.(jpeg|jpg|gif|png|bmp|webp)$/i) !== null; } downloadFile(url: string) { fetch(url) .then(response => { if (!response.ok) { throw new Error('Network response was not ok'); } return response.blob(); // Blob 형태로 변환 }) .then(blob => { const link = document.createElement('a'); const url = window.URL.createObjectURL(blob); link.href = url; link.setAttribute('download', ''); document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); }) .catch(error => { console.error('Download failed:', error); }); } }
TypeScript
복사
◦
파일 다운로드 추가(fetch) 및 게시글 레이아웃 수정
◦
게시글 목록(페이징) 부분 스타일 통일

2.2.3 게시글 수정 부분

📌
백엔드 API 서버
•
article.service.ts
•
Service 계층에서 게시글 객체를 반환 할 수 있도록 수정
import { Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common'; ... import { ArticlePaginatedResponseDto } from './dto/article-paginated-response.dto'; import { ArticleWithAttachmentAndUserResponseDto } from './dto/article-with-attachment-user-response.dto'; @Injectable() export class ArticleService { private readonly logger = new Logger(ArticleService.name); // Logger 인스턴스 생성 constructor( @InjectRepository(Article) private articleRepository: Repository<Article> ){} // 게시글 작성 ... // 특정 번호의 게시글의 전체 수정 async updateArticleById(id: number, updateArticleRequestDto: UpdateArticleRequestDto): Promise<Article> { this.logger.verbose(`Attempting to update Article with ID ${id}`); const foundArticle = await this.getArticleById(id); const { author, title, contents, status } = updateArticleRequestDto; foundArticle.author = author; foundArticle.title = title; foundArticle.contents = contents; foundArticle.status = status; await this.articleRepository.save(foundArticle); this.logger.verbose(`Article with ID ${id} updated successfully: ${JSON.stringify(foundArticle)}`); return foundArticle } }
TypeScript
복사
•
article.controller.ts
•
import { Body, Controller, Delete, Get, Logger, Param, Patch, Post, Put, Query, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; import { ArticleService } from './article.service'; ... import { AttachmentService } from 'src/file/attachment/attachment.service'; import { ArticleWithAttachmentAndUserResponseDto } from './dto/article-with-attachment-user-response.dto'; import { ArticlePaginatedResponseDto } from './dto/article-paginated-response.dto'; @Controller('api/articles') @UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증과 role 커스텀 가드를 적용 export class ArticleController { private readonly logger = new Logger(ArticleController.name); // Logger 인스턴스 생성 // 생성자 주입(DI) constructor(private articleService: ArticleService, private attachmentService: AttachmentService){} // 게시글 작성 기능 ... // 특정 번호의 게시글의 전체 수정 @Put('/:id') @UseInterceptors(FileInterceptor('articleFile')) async updateArticleById( @Param('id') id: number, @Body() updateArticleRequestDto: UpdateArticleRequestDto, @UploadedFile() file: Express.Multer.File ): Promise<ApiResponse<void>> { this.logger.verbose(`Updating Article with ID ${id}`); const updatedArticle = await this.articleService.updateArticleById(id, updateArticleRequestDto); if (file) { await this.attachmentService.uploadArticleFiles(file, updatedArticle); } this.logger.verbose(`Article updated successfully with ID ${id}`); return new ApiResponse(true, 200, 'Article updated successfully'); } }
TypeScript
복사
•
테스트

2.2.4 게시글 삭제 부분

📌
백엔드 API 서버
•
attachment.entity.ts
◦
cascade 옵션을 통한 부모 데이터 삭제 시 함께 삭제
import { BaseEntity } from "src/common/base.entity"; import { Column, Entity, ManyToOne } from "typeorm"; import { Article } from "src/article/entities/article.entity"; import { AttachmentType } from "./attachment-type.enum"; @Entity() export class Attachment extends BaseEntity { @Column() filename: string; @Column() mimetype: string; @Column() path: string; @Column() size: number; @Column() attachmentType: AttachmentType; @Column() url: string; @ManyToOne(() => Article, article => article.attachments, { eager: false, onDelete: 'CASCADE' }) article: Article; }
TypeScript
복사
📌
프론트엔드 클라이언트 서버
•
article-detail.component.html
◦
onClick 이벤트를 통해 confirmDelte() 함수 호출
... <ion-list> <ion-item> <ion-button color="tertiary" slot="start" (click)="updateArticle()">수정하기</ion-button> <ion-button color="primary" slot="end" (click)="goBack()">돌아가기</ion-button> <ion-button color="danger" (click)="confirmDelete()"> <ion-icon slot="icon-only" name="trash"></ion-icon> </ion-button> </ion-item> </ion-list> ...
TypeScript
복사
•
article-detail.component.ts
◦
alertController 를 통한 삭제 여부 확인창 팝업
◦
deleteArticle() 메서드를 통한 특정 id 게시글 객체 삭제 호출
import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { ArticleService } from '../../../services/article/article.service'; import { ArticleWithAttachmentAndUserResponseData } from 'src/app/models/article/article-with-attachment-user-response-data.interface'; import { Location } from '@angular/common'; import { AlertController } from '@ionic/angular'; @Component({ selector: 'app-article-detail', templateUrl: './article-detail.component.html', styleUrls: ['./article-detail.component.scss'], }) export class ArticleDetailComponent implements OnInit { article: ArticleWithAttachmentAndUserResponseData | undefined; showDeleteAlert: boolean = false; constructor( private route: ActivatedRoute, private articleService: ArticleService, private location: Location, private router: Router, private alertController: AlertController ) {} ... async confirmDelete() { const alert = await this.alertController.create({ header: '게시글 삭제', message: '정말로 이 게시글을 삭제하시겠습니까?', buttons: [ { text: '취소', role: 'cancel', handler: () => { console.log('삭제가 취소되었습니다.'); } }, { text: '삭제', handler: () => { this.deleteArticle(); } } ] }); await alert.present(); // 알림 표시 } deleteArticle() { const articleId = this.route.snapshot.paramMap.get('id'); if (articleId) { this.articleService.deleteArticle(+articleId).subscribe({ next: response => { if (response.success) { console.log('Delete an article successful:', response.data); this.router.navigate(['articles']); } else { console.error('Delete an article failed:', response.message); } }, error: err => { console.error('Delete an article error:', err); }, complete: () => { console.log('Delete an article request completed.'); } }); } else { console.error('Article ID is null, cannot delete the article.'); } } }
TypeScript
복사
•
article.service.ts
import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ApiResponse } from '../../models/common/api-response.interface'; import { ArticleWithAttachmentAndUserResponseData } from '../../models/article/article-with-attachment-user-response-data.interface'; import { ArticlePaginatedResponse } from 'src/app/models/article/article-paginated-response-data.interface'; import { ArticleWithUserResponseData } from 'src/app/models/article/article-with-user-response-data.interface'; @Injectable({ providedIn: 'root' }) export class ArticleService { private apiUrl = 'http://localhost:3000/api/articles'; constructor(private http: HttpClient) { } getAllArticles(): Observable<ApiResponse<ArticleWithUserResponseData[]>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.get<ApiResponse<ArticleWithUserResponseData[]>>(`${this.apiUrl}`, { headers, withCredentials: true }); } getPaginatedArticles(page: number, limit: number): Observable<ApiResponse<ArticlePaginatedResponse>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.get<ApiResponse<ArticlePaginatedResponse>>(`${this.apiUrl}/paginated?page=${page}&limit=${limit}`, { headers, withCredentials: true }); } getArticleById(id: number): Observable<ApiResponse<ArticleWithAttachmentAndUserResponseData>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.get<ApiResponse<ArticleWithAttachmentAndUserResponseData>>(`${this.apiUrl}/${id}`, { headers, withCredentials: true }); } writeArticle(formData: FormData): Observable<ApiResponse<ArticleWithAttachmentAndUserResponseData>> { const headers = new HttpHeaders({ 'enctype': 'multipart/form-data' }); return this.http.post<ApiResponse<ArticleWithAttachmentAndUserResponseData>>(`${this.apiUrl}`, formData, { headers, withCredentials: true }); } updateArticle(id: number, formData: FormData): Observable<ApiResponse<ArticleWithAttachmentAndUserResponseData>> { const headers = new HttpHeaders({ 'enctype': 'multipart/form-data' }); return this.http.put<ApiResponse<ArticleWithAttachmentAndUserResponseData>>(`${this.apiUrl}/${id}`, formData, { headers, withCredentials: true }); } deleteArticle(id: number): Observable<ApiResponse<void>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.delete<ApiResponse<void>>(`${this.apiUrl}/${id}`, { headers, withCredentials: true }); } }
TypeScript
복사

3. 로그인 및 회원 CRUD 구성

3.1 추가 작업

3.1.1 회원 가입 부분

📌
코드 리팩토링
•
auth.controller.ts
◦
계층의 역할과 기능에 맞도록 기존 Controller 계층의 file 처리 호출을 Service 계층으로 이동
◦
컨벤션 통일
@Controller('api/auth') export class AuthController { private readonly logger = new Logger(AuthController.name); constructor(private authService: AuthService){} // 회원 가입 기능 @Post('/signup') @UseInterceptors(FileInterceptor('profilePicture')) async signUp( @Body() signUpRequestDto: SignUpRequestDto, @UploadedFile() file: Express.Multer.File ): Promise<ApiResponse<UserResponseDto>> { this.logger.verbose(`Attempting to sign up user with email: ${signUpRequestDto.email}`); const newUser = await this.authService.signUp(signUpRequestDto, file); const userResponseDto = new UserResponseDto(newUser); this.logger.verbose(`User signed up successfully: ${JSON.stringify(userResponseDto)}`); return new ApiResponse(true, 201, 'User signed up successfully', userResponseDto); } // 로그인 기능 ... }
TypeScript
복사
•
auth.service.ts
◦
프로필 사진(파일) 처리 추가
◦
컨벤션 통일
@Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( @InjectRepository(User) private usersRepository: Repository<User>, private jwtService: JwtService, private httpService: HttpService, private userService: UserService, private profilePictureService: ProfilePictureService ) {} // 회원 가입 async signUp(signUpRequestDto: SignUpRequestDto, file?: Express.Multer.File): Promise<User> { this.logger.verbose(`Attempting to sign up user with email: ${signUpRequestDto.email}`); // 이메일 중복 확인 await this.checkEmailExists(signUpRequestDto.email); // 비밀번호 해싱 const hashedPassword = await this.hashPassword(signUpRequestDto.password); const newUser = this.usersRepository.create( Object.assign({}, signUpRequestDto, { password: hashedPassword }) ); if (file) { await this.profilePictureService.uploadProfilePicture(file, newUser); } const savedUser = await this.usersRepository.save(newUser); this.logger.verbose(`User signed up successfully with email: ${savedUser.email}`); this.logger.debug(`User details: ${JSON.stringify(savedUser)}`); return savedUser; } ... }
TypeScript
복사

3.1.2 회원 조회 부분

📌
백엔드 API 서버
•
user.service.ts
◦
회원 정보 + 프로필 사진 조회 비지니스 로직
◦
프로필 사진은 QueryBuilder를 통해서 추가 쿼리를 발생 시켜야 함(Lazy Loading 상태)
@Injectable() export class UserService { private readonly logger = new Logger(UserService.name); constructor( @InjectRepository(User) private readonly userRepository: Repository<User>, private profilePictureService: ProfilePictureService ) {} async findOneById(id: number): Promise<User> { return this.userRepository.findOneBy({id}); } // 회원정보+파일 정보 조회 async getUserByIdWithProfile(id: number): Promise<User> { this.logger.verbose(`Retrieving User with ID ${id}`); const foundUser = await this.userRepository.createQueryBuilder('user') .leftJoinAndSelect('user.profilePictures', 'ProfilePicture') .where('user.id = :id', { id }) .getOne(); if (!foundUser) { this.logger.warn(`User with ID ${id} not found`); throw new NotFoundException(`User with ID ${id} not found`); } this.logger.verbose(`User retrieved successfully with ID ${id}: ${JSON.stringify(foundUser)}`); return foundUser; } // 회원정보 수정 ... // 회원 탈퇴 ... }
TypeScript
복사
•
user.controller.ts
◦
회원 테이블의 기본키PK 인 id를 통해서 해당 회원의 정보 조회 + 프로필 사진 조회
@Controller('api/users') @UseGuards(AuthGuard('jwt'), RolesGuard) export class UserController { private readonly logger = new Logger(UserController.name); constructor(private userService: UserService){} // 특정 번호의 회원 정보 조회 @Get(':id') async getUserById( @Param('id') id: number, ): Promise<ApiResponse<UserWithProfilePictureResponseDto>> { this.logger.verbose(`Retrieving User with ID ${id}`); const foundUser = await this.userService.getUserByIdWithProfile(id); const userDto = new UserWithProfilePictureResponseDto(foundUser); this.logger.verbose(`User retrieved successfully: ${JSON.stringify(userDto)}`); return new ApiResponse(true, 200, 'User retrieved successfully', userDto); } // 회원 정보 수정 ... // 특정 번호의 회원 탈퇴 ... }
TypeScript
복사
📌
프론트 클라이언트 서버
•
mypage.component.html
◦
로그인한 회원이 자신의 정보를 볼 수 있는 양식 Body 추가
<ion-content [fullscreen]="true"> <ion-list *ngIf="user"> <ion-item *ngIf="profilePicture"> <ion-label> <h2>Profile Picture</h2> <ion-img [src]="profilePicture" alt="Profile Picture"></ion-img> <!-- 프로필 이미지를 표시 --> </ion-label> </ion-item> <ion-item> <ion-label> <h2>Username</h2> <p>{{ user.username }}</p> </ion-label> </ion-item> <ion-item> <ion-label> <h2>Email</h2> <p>{{ user.email }}</p> </ion-label> </ion-item> <ion-item> <ion-label> <h2>Role</h2> <p>{{ user.role }}</p> </ion-label> </ion-item> <ion-item> <ion-label> <h2>Address</h2> <p>{{ user.address }}, {{ user.detailAddress }}</p> </ion-label> </ion-item> <ion-item> <ion-label> <h2>Postal Code</h2> <p>{{ user.postalCode }}</p> </ion-label> </ion-item> </ion-list> <ion-list> <ion-item> <ion-button color="tertiary" slot="start" (click)="updateUser()">수정하기</ion-button> <ion-button color="primary" slot="end" (click)="goBack()">돌아가기</ion-button> <ion-button color="danger" (click)="confirmDelete()"> <ion-icon slot="icon-only" name="trash"></ion-icon> </ion-button> </ion-item> </ion-list> </ion-content>
TypeScript
복사
•
mypage.component.ts
◦
나의 회원 정보 페이지 body에 담겨질 데이터를 템플릿에 처리 하기 위한 컴포넌트 정의 부분
◦
getUserProfileById() 에 회원을 구분 할 수 있는 고유번호인 id를 전달하며 Service 호출
import { Component, OnInit } from '@angular/core'; import { UserService } from 'src/app/services/user/user.service'; import { AuthService } from 'src/app/services/auth/auth.service'; import { Router } from '@angular/router'; import { AlertController } from '@ionic/angular'; import { Location } from '@angular/common'; import { UserWithProfilePictureResponseData } from 'src/app/models/user/user-with-profile-picture-response-data.interface'; @Component({ selector: 'app-mypage', templateUrl: './mypage.component.html', styleUrls: ['./mypage.component.scss'], }) export class MypageComponent implements OnInit { user: UserWithProfilePictureResponseData | undefined; profilePicture: string | undefined; showDeleteAlert: boolean = false; constructor( private userService: UserService, private authService: AuthService, private location: Location, private router: Router, private alertController: AlertController ) {} ngOnInit() { this.loadUser(); } loadUser() { const userId = this.authService.getUserIdFromToken(); if (userId !== null) { this.userService.getUserProfileById(userId).subscribe({ next: response => { if (response.success) { this.user = response.data; this.setProfilePicture(); } else { console.error(response.message); } }, error: err => { console.error('Error fetching user:', err); }, complete: () => { console.log('Fetching user request completed.'); } }); } else { console.error('User ID is null. User is not logged in.'); } } setProfilePicture() { if (this.user && this.user.profilePictures && this.user.profilePictures.length > 0) { const profilePicture = this.user.profilePictures[this.user.profilePictures.length - 1]; // 마지막 업로드된 사진 this.profilePicture = profilePicture.url; console.log(profilePicture) } else { this.profilePicture = undefined; } } goBack() { this.location.back(); } ... }
TypeScript
복사
•
user.service.ts
◦
HttpClient를 통한 백엔드 API 호출 부분
◦
나의 회원 정보를 단순 조회하는 것이기 때문에 로그인된 회원의 id를 엔드포인트에 PathVariable로 전달하며 Get Method 요청
@Injectable({ providedIn: 'root' }) export class UserService { private apiUrl = 'http://localhost:3000/api/users'; constructor(private http: HttpClient) { } getUserProfileById(id: number): Observable<ApiResponse<UserWithProfilePictureResponseData>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.get<ApiResponse<UserWithProfilePictureResponseData>>(`${this.apiUrl}/${id}`, { headers, withCredentials: true }); } ... }
TypeScript
복사
•
테스트
◦
프로필 사진이 없는 유저
◦
프로필 사진을 업로드한 유저

3.1.3 회원 정보 수정 부분

📌
백엔드 API 서버
•
user.controller.ts
◦
회원 정보 수정 핸들러 추가
◦
Multer 모듈을 통한 파일 업로드 기능(프로필 사진 재업로드)
◦
기존 Controller 계층에서 파일 처리를 호출하던 코드 부분을 Service 계층으로 이동 시킴(유사한 코드들 모두 리팩토링 ex: 게시글 작성, 수정, 회원 가입 등)
▪
역할과 기능에 맞도록 컨트롤러는 라우팅과 서비스 계층을 호출하는 용도로만 하기 위함
@Controller('api/users') @UseGuards(AuthGuard('jwt'), RolesGuard) export class UserController { private readonly logger = new Logger(UserController.name); constructor(private userService: UserService, private profilePictureService: ProfilePictureService ){} // 특정 번호의 회원 정보 조회 @Get(':id') async getUserById( @Param('id') id: number ): Promise<ApiResponse<UserWithProfilePictureResponseDto>> { this.logger.verbose(`Retrieving User with ID ${id}`); const user = await this.userService.getUserByIdWithProfile(id); const userDto = new UserWithProfilePictureResponseDto(user); this.logger.verbose(`User retrieved successfully: ${JSON.stringify(userDto)}`); return new ApiResponse(true, 200, 'User retrieved successfully', userDto); } // 회원 정보 수정 @Put(':id') @UseInterceptors(FileInterceptor('profilePicture')) async updateUser( @Param('id') id: number, @Body() updateUserRequestDto: UpdateUserRequestDto, @GetUser() logginedUser: User, @UploadedFile() file?: Express.Multer.File, ): Promise<ApiResponse<UserWithProfilePictureResponseDto>> { this.logger.verbose(`User ${logginedUser.username} updating User details with ID ${id}`); const updatedUser = await this.userService.updateUser(id, updateUserRequestDto, logginedUser, file); const userWithProfilePictureResponseDto = new UserWithProfilePictureResponseDto(updatedUser); this.logger.verbose(`User updated successfully: ${JSON.stringify(userWithProfilePictureResponseDto)}`); return new ApiResponse(true, 200, 'User updated successfully', userWithProfilePictureResponseDto); } }
TypeScript
복사
•
user.sevice.ts
◦
회원 정보 수정 비지니스 로직 작성
▪
파라미터로 파일을 받아오는 것이 추가되어 파일 처리 서비스를 여기서 호출 하고 있음
@Injectable() export class UserService { private readonly logger = new Logger(UserService.name); constructor( @InjectRepository(User) private readonly userRepository: Repository<User>, private profilePictureService: ProfilePictureService ) {} async findOneById(id: number): Promise<User> { return this.userRepository.findOneBy({id}); } // 회원정보+파일 정보 조회 ... // 회원정보 수정 async updateUser(id: number, updateUserRequestDto: UpdateUserRequestDto, logginedUser: User, file?: Express.Multer.File): Promise<User> { this.logger.verbose(`Attempting to update User with ID ${id}`); const foundUser = await this.getUserByIdWithProfile(id); if (foundUser.id !== logginedUser.id) { this.logger.warn(`User ${logginedUser.username} attempted to update User details ${id} without permission`); throw new UnauthorizedException(`You do not have permission to update this User`); } const updatedUser = await this.userRepository.save( Object.assign(foundUser, updateUserRequestDto) ); if (file) { await this.profilePictureService.uploadProfilePicture(file, updatedUser); } this.logger.verbose(`User with ID ${id} updated successfully: ${JSON.stringify(updatedUser)}`); return updatedUser; } // 회원 탈퇴 ... }
TypeScript
복사
📌
프론트엔드 클라이언트 서버
•
user-update.component.html
◦
회원정보 수정 양식의 모습
<ion-content> <ion-grid> <ion-row> <ion-col size="12"> <ion-card> <ion-card-header> <ion-card-title>Update User Details</ion-card-title> </ion-card-header> <ion-card-content> <form (ngSubmit)="onUpdateUser()"> <!-- PostalCode Input --> <ion-item> <ion-label position="floating">Postal Code</ion-label> <ion-input type="text" [(ngModel)]="postalCode" name="postalCode"></ion-input> </ion-item> <!-- Address Input --> <ion-item> <ion-label position="floating">Address</ion-label> <ion-input type="text" [(ngModel)]="address" name="address"></ion-input> </ion-item> <!-- Detail Address Input --> <ion-item> <ion-label position="floating">Detail Address</ion-label> <ion-input type="text" [(ngModel)]="detailAddress" name="detailAddress"></ion-input> </ion-item> <!-- Attachment Input --> <ion-item> <ion-label>Profile Picture</ion-label> <input type="file" (change)="onFileChange($event)"> </ion-item> <!-- Update Article Button --> <ion-button expand="full" type="submit">Update</ion-button> </form> </ion-card-content> </ion-card> </ion-col> </ion-row> </ion-grid> </ion-content>
TypeScript
복사
•
update-user.component.ts
◦
onClick 이벤트로 작동하는 회원 정보 수정 메서드
◦
추출한 회원 id, FormData에 담은 수정 값들을 Service의 updateUser() 메서드에 전달하며 호출
import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { UserService } from 'src/app/services/user/user.service'; @Component({ selector: 'app-user-update', templateUrl: './user-update.component.html', styleUrls: ['./user-update.component.scss'], }) export class UserUpdateComponent implements OnInit { postalCode: string = ''; address: string = ''; detailAddress: string = ''; profilePicture: File | null = null; userId!: number; constructor( private route: ActivatedRoute, private userService: UserService, private router: Router ) {} ... onUpdateUser() { const formData = new FormData(); formData.append('postalCode', this.postalCode); formData.append('address', this.address); formData.append('detailAddress', this.detailAddress); if (this.profilePicture) { formData.append('profilePicture', this.profilePicture); } // 수정 API 호출 this.userService.updateUser(this.userId, formData).subscribe({ next: response => { if (response.success) { console.log(response) console.log('User updated successfully:', response.data); this.router.navigate(['my-page']); } else { console.error('Update user failed:', response.message); } }, error: err => { console.error('Update user error:', err); }, complete: () => { console.log('Update user request completed.'); } }); } onFileChange(event: any) { const file = event.target.files[0]; if (file) { this.profilePicture = file; console.log(file.name) } } goBack() { this.router.navigate(['/my-page']); } }
TypeScript
복사
•
user.service.ts
◦
HttpClient를 통한 백엔드 API 서버로 id, formData, 인증 정보를 함께 전달하며 PUT Method 요청
◦
파일이 첨부될 수 있는 요청 데이터 형태기 때문에 인코딩 타입을 multipart/form-data 를 명시적으로 작성해준다.
@Injectable({ providedIn: 'root' }) export class UserService { private apiUrl = 'http://localhost:3000/api/users'; constructor(private http: HttpClient) { } getUserProfileById(id: number): Observable<ApiResponse<UserWithProfilePictureResponseData>> { ... } updateUser(id: number, formData: FormData): Observable<ApiResponse<UserWithProfilePictureResponseData>> { const headers = new HttpHeaders({ 'enctype': 'multipart/form-data' }); return this.http.put<ApiResponse<UserWithProfilePictureResponseData>>(`${this.apiUrl}/${id}`, formData, { headers, withCredentials: true }); } deleteUser(id: number): Observable<ApiResponse<void>> { ... } }
TypeScript
복사
•
테스트
•
수정된 정보 반환
•
수정된 정보의 마이 페이지
•
테스트 유저 회원 정보 수정 후 백엔드 서버 쿼리 실행 로그

3.1.4 회원 탈퇴 부분

📌
백엔드 API 서버
•
user.controller.ts
@Controller('api/users') @UseGuards(AuthGuard('jwt'), RolesGuard) export class UserController { private readonly logger = new Logger(UserController.name); constructor(private userService: UserService){} ... // 특정 번호의 회원 탈퇴 @Delete(':id') @Roles(UserRole.USER) async deleteUserById( @Param('id') id: number, @GetUser() logginedUser: User ): Promise<ApiResponse<void>> { this.logger.verbose(`User ${logginedUser.username} deleting User with ID ${id}`); await this.userService.deleteUserById(id, logginedUser); this.logger.verbose(`User deleted successfully with ID ${id}`); return new ApiResponse(true, 200, 'User deleted successfully'); } }
TypeScript
복사
•
user.service.ts
◦
로그인 된 유저 본인의 아이디인 경우 회원 탈퇴 가능
@Injectable() export class UserService { private readonly logger = new Logger(UserService.name); constructor( @InjectRepository(User) private readonly userRepository: Repository<User>, private profilePictureService: ProfilePictureService ) {} async findOneById(id: number): Promise<User> { return this.userRepository.findOneBy({id}); } ... // 회원 탈퇴 async deleteUserById(id: number, logginedUser: User): Promise<void> { this.logger.verbose(`User ${logginedUser.username} is attempting to delete User with ID ${id}`); const foundUser = await this.findOneById(id); if (foundUser.id !== logginedUser.id) { this.logger.warn(`User ${logginedUser.username} attempted to delete User ID ${id} without permission`); throw new UnauthorizedException(`You do not have permission to delete this User`); } await this.userRepository.remove(foundUser); this.logger.verbose(`User with ID ${id} deleted by User ${logginedUser.username}`); } }
TypeScript
복사
•
article.entity.ts
◦
onDelete: 'CASCADE' 속성으로 회원 탈퇴 시 자식 관계인 게시글 자동 삭제
import { Column, Entity, ManyToOne, OneToMany } from "typeorm"; import { ArticleStatus } from "./article-status.enum"; import { User } from "src/user/entities/user.entity"; import { BaseEntity } from "src/common/base.entity"; import { Attachment } from "src/file/attachment/entities/attachment.entity"; @Entity() export class Article extends BaseEntity { @Column() author: string; @Column() title: string; @Column() contents: string; @Column() status: ArticleStatus; @ManyToOne(() => User, user => user.articles, { eager: true, onDelete: 'CASCADE' }) user: User; @OneToMany(() => Attachment, attachment => attachment.article, { eager: true }) attachments: Attachment[]; }
TypeScript
복사
📌
프론트엔드 클라이언트 서버
•
mypage.component.html
◦
onClick 이벤트로 confirmDelete() 메서드 실행
... <ion-list> <ion-item> <ion-button color="tertiary" slot="start" (click)="updateUser()">수정하기</ion-button> <ion-button color="primary" slot="end" (click)="goBack()">돌아가기</ion-button> <ion-button color="danger" (click)="confirmDelete()"> <ion-icon slot="icon-only" name="trash"></ion-icon> </ion-button> </ion-item> </ion-list> </ion-content>
TypeScript
복사
•
mypage.component.ts
◦
삭제 확인 경고 이후 확인 선택 시 deleteUser() 메서드로 회원 탈퇴
◦
getUserIdFromToken() 으로 회원 id 획득
@Component({ selector: 'app-mypage', templateUrl: './mypage.component.html', styleUrls: ['./mypage.component.scss'], }) export class MypageComponent implements OnInit { user: UserWithProfilePictureResponseData | undefined; profileImage: string | undefined; showDeleteAlert: boolean = false; constructor( private userService: UserService, private authService: AuthService, private location: Location, private router: Router, private alertController: AlertController ) {} ngOnInit() { this.loadUser(); } loadUser() { ... async confirmDelete() { const alert = await this.alertController.create({ header: '회원 탈퇴', message: '정말로 회원을 탈퇴 하시겠습니까?', buttons: [ { text: '취소', role: 'cancel', handler: () => { console.log('탈퇴가 취소되었습니다.'); } }, { text: '삭제', handler: () => { this.deleteUser(); } } ] }); await alert.present(); // 알림 표시 } deleteUser() { const userId = this.authService.getUserIdFromToken(); if (userId) { this.userService.deleteUser(+userId).subscribe({ next: response => { if (response.success) { console.log('Delete an user successful:', response.data); this.router.navigate(['/']); } else { console.error('Delete an user failed:', response.message); } }, error: err => { console.error('Delete an user error:', err); }, complete: () => { console.log('Delete an user request completed.'); } }); } else { console.error('User ID is null, cannot delete the user.'); } } }
TypeScript
복사
•
user.service.ts
◦
HttpClient를 통한 회원 탈퇴 API 요청
@Injectable({ providedIn: 'root' }) export class UserService { private apiUrl = 'http://localhost:3000/api/users'; constructor(private http: HttpClient) { } getUserProfileById(id: number): Observable<ApiResponse<UserWithProfilePictureResponseData>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.get<ApiResponse<UserWithProfilePictureResponseData>>(`${this.apiUrl}/${id}`, { headers, withCredentials: true }); } updateUser(id: number, formData: FormData): Observable<ApiResponse<UserWithProfilePictureResponseData>> { const headers = new HttpHeaders({ 'enctype': 'multipart/form-data' }); return this.http.put<ApiResponse<UserWithProfilePictureResponseData>>(`${this.apiUrl}/${id}`, formData, { headers, withCredentials: true }); } deleteUser(id: number): Observable<ApiResponse<void>> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.delete<ApiResponse<void>>(`${this.apiUrl}/${id}`, { headers, withCredentials: true }); } }
TypeScript
복사
•
테스트
◦
테스트 유저 회원 탈퇴 화면
◦
테스트 유저 회원 탈퇴 후 클라이언트 로그
◦
테스트 유저 회원 탈퇴 후 백엔드 서버 쿼리 실행 로그

4. 프론트엔드 Angular에서의 Guard 적용

4.1 인가의 이해

📌
Guard란?
•
Guard는 주로 인가(Authorization)와 인증(Authentication)을 처리하는 데 사용되는 모듈
•
백엔드에서 NestJS의 Guard가 특정 요청에 대한 접근을 제어하는 데 사용되듯이, 프론트엔드에서도 Angular(Ionic 포함)의 Guard를 통해 라우팅에 대한 접근 권한을 관리 함
📌
프론트 엔드에서의 인가 처리의 이유
•
라우팅 보호 측면
◦
접근 제어 : 특정 경로에 접근할 수 있는 사용자를 제한
▪
예시)
•
로그인하지 않은 사용자가 보호된 경로에 접근하려고 할 때, 로그인 페이지로 리다이렉트 시키려고 할 때
•
사용자의 역할이나 상태에 따라 특정 경로에 대한 접근을 허용하거나 거부 할 때
•
사용자 경험 향상 측면
◦
데이터 로딩 : 특정 경로에 접근하기 전에 데이터를 미리 로드하여 필요한 정보를 준비
▪
예시)
•
사용자가 특정 페이지로 이동하기 전에 필요한 데이터를 미리 가져와서 페이지가 로드될 때 데이터를 즉시 사용할 수 있도록 준비
•
Guard에서 비동기 요청을 처리하여, 데이터가 로드되기 전까지 페이지 전환을 차단하여 데이터가 준비된 상태에서 페이지를 볼 수 있도록 함

4.2 Guard 사용해보기

4.2.1 접근 제어 기획

📌
필요한 접근 제어를 생각해 보면 다음과 같다.
•
로그인 상태 자체에 대한 접근 제어
•
로그인 된 상태에서 해당 유저의 역할(UserRole)에 따른 접근 제어
그 외 상황에 따라 더 많은 제어가 필요 할 수도 있지만 예제에서는 이 정도 접근 제어를 다뤄보도록 한다. 기획에 따라 아래 구현을 응용하여 추가적인 가드를 생성해나가는 방식이다.

4.2.2 Guard 모듈 생성

📌
Guards 폴더에 모아두기
•
프로젝트는 전반적으로 역할과 기능에 대한 폴더로 구조화를 신경쓰고 있는 상태이다. 따라서,
◦
root 폴더에 guards라는 리소스 폴더를 생성해준다.
◦
해당 폴더 내에서 접근 제어와 관련된 가드들이 모이게 될 것이다.
•
해당 폴더 위치에서 터미널을 이용해서 아래 CLI 명령어로 가드를 생성 한다. 물론 수동으로 파일을 생성해도 큰 차이는 없다.
ng generate guard auth
Shell
복사
•
auth.guard.ts
◦
해당 파일이 생성되면 아래와 같이 클래스를 정의한다.
◦
AuthService를 통해 로그인 상태를 확인하고 이에 따라 true/false를 반환하는 단순한 로직이다.
import { Injectable } from '@angular/core'; import { CanActivate, Router } from '@angular/router'; import { AuthService } from '../services/auth/auth.service'; @Injectable({ providedIn: 'root', }) export class AuthGuard implements CanActivate { constructor(private authService: AuthService, private router: Router) {} canActivate(): boolean { if (this.authService.isLoggedIn()) { return true; } else { this.router.navigate(['/login']); return false; } } }
TypeScript
복사
•
auth.service.ts
◦
위 가드 클래스에서 호출하고 있는 isLoggedIn() 메서드가 구현되어 있어야 한다.
◦
현재는 JWT-쿠키 방식으로 로그인 상태를 확인 하는 가장 단순한 형태를 선택했지만 추후 보다 정교한 검증 단계가 필요할 수도 있다.
@Injectable({ providedIn: 'root' }) export class AuthService { private apiUrl = 'http://localhost:3000/api/auth'; constructor(private http: HttpClient) { } ... private getCookie(name: string): string | null { const value = `; ${document.cookie}`; console.log("document.cookie:"+ value) const parts = value.split(`; ${name}=`); if (parts.length === 2) { const cookieValue = parts.pop()?.split(';').shift(); return cookieValue ? cookieValue : null; } return null; } isLoggedIn(): boolean { const token = this.getCookie('Authorization'); return !!token; } ... }
TypeScript
복사

4.2.3 라우팅 모듈에 AuthGuard 적용

📌
정의된 가드 적용 위치 고려
•
기획에서 생각한 서비스의 움직임은 “핵심 서비스 경로는 로그인된 상태여야 한다” 라는 점을 세부적으로 풀어보면
◦
게시글 CUD 관련 서비스는 모두 로그인 상태여야 한다.
▪
R 조회는 로그인 상태가 아니어도 된다는 것을 기억
◦
회원 RUD 관련 서비스는 모두 로그인 상태여야 한다.
▪
C 회원 가입은 로그인 상태가 아니어도 된다는 것을 기억
📌
app-routing.module.ts
•
위 요구사항에 따라서 모든 경로를 관리하고 있는 에서 적용시킨다면 아래와 같다.
◦
아래처럼 라우팅 모듈에서 canActivate 속성을 추가하고 가드 클래스명을 작성하면 해당 URL에(아래 같은 경우엔 그 자식 경로까지 모두)적용하고자 하는 접근 제어가 적용된다.
◦
localhost:4200/my-page 자체에 접근하면 회원 RUD(조회, 수정, 삭제)를 할 수 있다. 따라서 전역으로 AuthGuard를 적용해도 무관하다.
▪
C 회원 가입은 이미 라우팅이 분리되어 localhost:4200/auth 의 자식 경로에서 이루어지기 때문에 제어할 필요가 없다.
◦
하지만 localhost:4200/articles 경로에 조회 부분은 제외되어야 하기 때문에 세부적으로 자식 라우팅 모듈에서 정의해야 할 필요가 있다.
const routes: Routes = [ { path: '', component: HomeComponent, }, { path: 'articles', loadChildren: () => import('./pages/article/article.module').then(m => m.ArticleModule) }, { path: 'auth', loadChildren: () => import('./pages/auth/auth.module').then(m => m.AuthModule) }, { path: 'my-page', loadChildren: () => import('./pages/user/user.module').then(m => m.UserModule), canActivate: [AuthGuard] } ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule {}
TypeScript
복사
•
클라이언트 테스트
◦
로그인되지 않은 상태에서는 마이페이지에 접근 할 수 없다.
◦
로그인 페이지로 리다이렉트 된다.
◦
로그인된 상태에서는 마이 페이지에 접근, 수정 및 탈퇴 기능을 모두 이용 할 수 있다.
📌
article-routing.module.ts
•
app-routing.module.ts에서 article로 정의된 경로의 자식 경로들은 article 리소스 폴더로 모듈화된 article-routing.module.ts가 세부 경로를 관리하는 계층적 구조를 가지고 있다.
•
게시글의 조회는 로그인을 필요로 하지 않고
•
게시글 작성, 수정, 삭제는 기본적인 로그인 상태를 확인하는 가드가 적용되어야 한다.
•
동일하게 AuthGuard를 라우터에 적용 시킨다.
const routes: Routes = [ { path: '', component: ArticlePaginatedListComponent }, { path: 'list', component: ArticleListComponent }, { path: 'paginated-list', component: ArticlePaginatedListComponent }, { path: 'detail/:id', component: ArticleDetailComponent }, { path: 'write', component: ArticleWriteComponent, canActivate: [AuthGuard] }, { path: 'update/:id', component: ArticleUpdateComponent, canActivate: [AuthGuard] }, ]; @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] }) export class ArticleRoutingModule {}
TypeScript
복사
•
클라이언트 테스트
◦
로그인되지 않은 상태에서는 게시글 작성 양식 페이지로 접근 할 수 없다.
◦
로그인 페이지로 리다이렉트
◦
로그인된 상태에서는 게시글 작성, 수정, 삭제 기능에 접근 할 수 있다.

4.3 역할 권한 제어 추가

📌
가드는 여러개를 추가로 구현 할 수 있다.
•
로그인 가드는 기본 사항이며 역할별로 권한을 제어하는 것을 추가하고자 한다.
•
예시를 위해 게시글 목록 조회 기능중에 페이징 처리되지 않은 모든 목록을 불러오는 기능이 하나 있다. 해당 페이지 접근은 관리자(ADMIN)만 접근 할 수 있도록 샘플을 구현해보고자 한다.
📌
role.guard.ts
•
로그인 가드와 유사하지만 next, state라는 파라미터가 추가된다.
◦
특정 경로에 대한 추가적인 정보(예: 필요한 역할)나 상태(예: 현재 URL)를 확인해야 할 경우, next와 state를 매개변수로 받아 사용
◦
로그인 가드에서는 사용자가 로그인했는지를 확인하기만 하므로, 추가적인 경로 정보나 상태 정보를 필요로 하지 않으므로 생략된 것
•
아래 두개의 값을 비교하여 true/false를 반환한다.
◦
next.data['role'] 부분에서 다음 코드에서 라우터에서 제어 할 역할을 작성한 데이터가 들어온다.
◦
userRole 변수에는 실제 로그인한 유저의 역할값을 받아오게 된다.
◦
역할 말고도 상태 등으로 응용 구현 할 수 있다.
•
추가로 역할에 대한 잘못된 접근이기 떄문에 리다이렉트 경로를 오류페이지로 설정했다.
◦
this.router.navigate(['common/forbidden']);
◦
해당 페이지는 간단하게 추가 구성해야 한다.
import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; import { AuthService } from '../services/auth/auth.service'; @Injectable({ providedIn: 'root', }) export class RoleGuard implements CanActivate { constructor(private authService: AuthService, private router: Router) {} canActivate( next: ActivatedRouteSnapshot, state: RouterStateSnapshot ): boolean { const requiredRole = next.data['role']; const userRole = this.authService.getUserRoleFromToken(); if (userRole === requiredRole) { return true; } else { this.router.navigate(['error/forbidden']); return false; } } }
TypeScript
복사
📌
auth.service.ts
•
토큰으로부터 UserRole을 가져 올 수 있도록 getUserRoleFromToken() 메서드를 추가 구성한다.
@Injectable({ providedIn: 'root' }) export class AuthService { private apiUrl = 'http://localhost:3000/api/auth'; constructor(private http: HttpClient) { } signUp(formData: FormData): Observable<AuthResponse> { return this.http.post<AuthResponse>(`${this.apiUrl}/signup`, formData, { withCredentials: true }); } signIn(signInRequestData: SignInRequestData): Observable<AuthResponse> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); return this.http.post<AuthResponse>(`${this.apiUrl}/signin`, signInRequestData, { headers, withCredentials: true }); } getUserIdFromToken(): number | null { const token = this.getCookie('Authorization'); console.log("token:"+ token) if (token) { const decodedToken: any = jwtDecode(token); return decodedToken.userId; } return null; } getUserRoleFromToken(): string | null { const token = this.getCookie('Authorization'); if (token) { const decodedToken: any = jwtDecode(token); return decodedToken.role || null; } return null; } private getCookie(name: string): string | null { const value = `; ${document.cookie}`; console.log("document.cookie:"+ value) const parts = value.split(`; ${name}=`); if (parts.length === 2) { const cookieValue = parts.pop()?.split(';').shift(); return cookieValue ? cookieValue : null; } return null; } isLoggedIn(): boolean { const token = this.getCookie('Authorization'); return !!token; } getUserProfilePictureFromToken(): string | null { const token = this.getCookie('Authorization'); if (token) { const decodedToken: any = jwtDecode(token); return decodedToken.profilePictureUrl || null; // 프로필 사진 URL 반환 } return null; } }
TypeScript
복사
📌
article-routing.module.ts
•
가드 작동여부를 체크하기 위한 샘플로 전체 게시글 목록 조회를 접근하는 라우터에 로그인 가드와 동일하게 작성한 RoleGuard를 적용한다.
•
여기서 추가적으로 data에 role: ADMIN 값을 넣게 되면서 위 requiredRole 변수에서 해당 라우터에서 필요로하는 권한을 설정하게 된다.
const routes: Routes = [ { path: '', component: ArticlePaginatedListComponent }, { path: 'list', component: ArticleListComponent, canActivate: [RoleGuard], data: { role: 'ADMIN' } }, { path: 'paginated-list', component: ArticlePaginatedListComponent }, { path: 'detail/:id', component: ArticleDetailComponent }, { path: 'write', component: ArticleWriteComponent, canActivate: [AuthGuard] }, { path: 'update/:id', component: ArticleUpdateComponent, canActivate: [AuthGuard] }, ]; @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] }) export class ArticleRoutingModule {}
TypeScript
복사
•
클라이언트 테스트
◦
로그인을 했지만 UserRole이 ADMIN이 아닌 경우 해당 페이지로 접근 할 수 없다.
◦
이번엔 로그인한 유저가 ADMIN인 경우
◦
ADMIN은 해당 URL에 접근 할 수 있다.

PS. Github

•
리팩토링 완료 된 결과 코드 묶음은 Github를 참고
•
Backend
◦
•
Frontend
◦
Search
⚙
 | Main Page | Category |  Tags | About Me | Contact | Portfolio