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
◦
◦
쿼리스트링인 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를 적용해도 무관하다.
▪
◦
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
Related Posts
Search