-
Notifications
You must be signed in to change notification settings - Fork 1
[재하] 1207(목) 개발기록
- 감정분석 AI API 연동
- 사전 준비 (API 사용 신청 및 키 발급)
- 구현
- 테스트 (동작 화면)
- e2e 테스트 코드 작성, 테스트
- galaxy 모듈
- star 모듈
- board 모듈
- admin 모듈
- sentiment 모듈
- 리팩토링, 코드 개선
NCP 콘솔에서 AI/Naver API 사용 신청을 해두어야 사용 가능함. 도메인 등록 시 백엔드에서 접근해서 사용할 것이므로 localhost:3000
을 등록해두자.
배포 시에도 동일하게 포트를 열기 때문에 잘 작동할 것으로 기대됨(안되면 알아보고 추가 등록 필요)
그러면 요기서 인증 정보(API KEY ID와 SECRET)를 확인할 수 있다.
nest g resource sentiment
별도의 모듈로 구성하기 위해 setiment라는 이름의 리소스를 생성
import { Body, Controller, Post } from '@nestjs/common';
import { SentimentService } from './sentiment.service';
import { GetSentimentDto } from './dto/get-sentiment.dto';
@Controller('sentiment')
export class SentimentController {
constructor(private readonly sentimentService: SentimentService) {}
@Post()
getSentiment(@Body() body: GetSentimentDto) {
return this.sentimentService.getSentiment(body);
}
}
컨트롤러는 본문 내용을 사용자나 통신 단에서 탈취할 가능성이 있는 공격자로부터 숨겨야하기 때문에 POST 방식으로 json으로 전달.
POST /sentiment
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { GetSentimentDto } from './dto/get-sentiment.dto';
import { clovaConfig } from 'src/config/clova.config';
@Injectable()
export class SentimentService {
async getSentiment(body: GetSentimentDto) {
const { content } = body;
const response = await fetch(clovaConfig.url, {
method: 'POST',
headers: {
'X-NCP-APIGW-API-KEY-ID': clovaConfig.api_key_id,
'X-NCP-APIGW-API-KEY': clovaConfig.api_key_secret,
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content,
}),
});
const result = await response.json();
// 에러 발생한 경우 internal server error
if (result.error || !result.document) {
throw new InternalServerErrorException('네이버 감성분석 API 오류');
}
// 감성분석 결과(result.document)가 있는 경우
const { positive, negative, neutral } = result.document.confidence;
// 값이 없거나 숫자로 변환이 안될 경우 에러 리턴
if (
!positive ||
!negative ||
!neutral ||
isNaN(positive) ||
isNaN(negative) ||
isNaN(neutral)
) {
throw new InternalServerErrorException('네이버 감성분석 API 오류');
}
// 0~100 사이의 positive, negative, neutral을 각각 0x00~0xFF 사이의 R, G, B 값으로 변환
const positiveColor = Math.round((Number(positive) / 100) * 0xff);
const negativeColor = Math.round((Number(negative) / 100) * 0xff);
const neutralColor = Math.round((Number(neutral) / 100) * 0xff);
// 무조건 두자리 숫자의 string으로 변환
const toHex = (color: number) => {
const hex = color.toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
// #RRGGBB 형식으로 변환
const colorRecommendation =
`#` + toHex(positiveColor) + toHex(neutralColor) + toHex(negativeColor);
return { color: colorRecommendation };
}
}
학습 메모 2의 감정분석 API 공식문서를 보고 위와 같이 데이터를 받아오도록 구성했다.
전달 받은 데이터 중 document의 confidence 속성 내에 있는 통합 1의 긍정(positive), 부정(negative), 중립(neutral) 백분위를 확인하여 이 값을 적절한 색상으로 변환해야 한다.
색상 변환은 0100 사이의 긍정, 부정, 중립 값을 각각 0255(0xFF) 사이의 2자리수 16진수로 치환하고, 이를 RGB 컬러 코드인 #RRGGBB
형태의 string으로 만든 후 반환하는 형태로 이루어진다.
import { configDotenv } from 'dotenv';
configDotenv();
export const clovaConfig = {
url: process.env.CLOVA_URL,
api_key_id: process.env.CLOVA_API_KEY_ID,
api_key_secret: process.env.CLOVA_API_KEY_SECRET,
};
마지막으로 API KEY, URL, SECRET 등은 process.env로 보안처리하여 github secret에도 저장해둔다.
galaxy는 유저별로 할당되기 때문에, beforeEach에서 유저를 하나 생성해서 로그인까지 해두어야
정상적인 API 호출이 가능하다. /auth/signup
및 /auth/signin
API을 활용하여 accessToken을 발급받은 후,
galaxy 모듈 API 요청 시 필요하면 이 accessToken
을 쿠키에 넣어 보내도록 했다.
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import * as cookieParser from 'cookie-parser';
import { AppModule } from '../../src/app.module';
describe('GalaxyController', () => {
let app: INestApplication;
let accessToken: string;
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
await app.init();
// 유저 만들고 로그인 후 accessToken 받아오기
const randomeBytes = Math.random().toString(36).slice(2, 10);
const newUser = {
username: randomeBytes,
nickname: randomeBytes,
password: randomeBytes,
};
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
newUser.nickname = undefined;
const signInResponse = await request(app.getHttpServer())
.post('/auth/signin')
.send(newUser);
signInResponse.headers['set-cookie'].forEach((cookie: string) => {
if (cookie.includes('accessToken')) {
accessToken = cookie.split(';')[0].split('=')[1];
}
});
});
it('should be defined', () => {
expect(app).toBeDefined();
});
it('GET /galaxy', async () => {
const response = await request(app.getHttpServer())
.get('/galaxy')
.set('Cookie', [`accessToken=${accessToken}`])
.expect(200);
expect(response).toHaveProperty('body');
const { body } = response;
expect(body).toHaveProperty('_id');
});
it('GET /galaxy/by-nickname', async () => {
const randomeBytes = Math.random().toString(36).slice(2, 10);
const newUser = {
username: randomeBytes,
nickname: randomeBytes,
password: randomeBytes,
};
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
const response = await request(app.getHttpServer())
.get('/galaxy/by-nickname')
.query({ nickname: newUser.nickname })
.expect(200);
expect(response).toHaveProperty('body');
const { body } = response;
expect(body).toHaveProperty('_id');
});
it('PATCH /galaxy', async () => {
const testCase = { test: 'test', test_nested: { test: 'test' } };
await request(app.getHttpServer())
.patch('/galaxy')
.set('Cookie', [`accessToken=${accessToken}`])
.send(testCase)
.expect(200);
const response = await request(app.getHttpServer())
.get('/galaxy')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(response).toHaveProperty('body');
const { body } = response;
expect(body).toHaveProperty('_id');
expect(body).toMatchObject(testCase);
});
afterAll(async () => {
await app.close();
});
});
star는 계정 인증과 더불어 게시글이 있어야 하기 때문에, POST /post
API까지 사용하여
beforeEach에서 별글을 생성한 후에, 이어지는 테스트로 넘어가도록 했다.
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import * as cookieParser from 'cookie-parser';
import { AppModule } from '../../src/app.module';
describe('StarController', () => {
let app: INestApplication;
let accessToken: string;
let initialNickname: string;
let initialPost;
let initialStar;
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
await app.init();
// 유저 만들고 로그인 후 accessToken 받아오기
const randomeBytes = Math.random().toString(36).slice(2, 10);
const newUser = {
username: randomeBytes,
nickname: randomeBytes,
password: randomeBytes,
};
initialNickname = randomeBytes;
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
newUser.nickname = undefined;
const signInResponse = await request(app.getHttpServer())
.post('/auth/signin')
.send(newUser);
signInResponse.headers['set-cookie'].forEach((cookie: string) => {
if (cookie.includes('accessToken')) {
accessToken = cookie.split(';')[0].split('=')[1];
}
});
// POST /post로 게시글 하나 생성하기
initialStar = { test: 'test' };
const post = {
title: 'test',
content: 'test',
star: JSON.stringify(initialStar),
};
const postResponse = await request(app.getHttpServer())
.post('/post')
.set('Cookie', [`accessToken=${accessToken}`])
.send(post);
initialPost = postResponse.body;
});
it('should be defined', () => {
expect(app).toBeDefined();
});
it('GET /star', async () => {
const response = await request(app.getHttpServer())
.get('/star')
.set('Cookie', [`accessToken=${accessToken}`])
.expect(200);
expect(response).toHaveProperty('body');
const { body } = response;
expect(typeof body).toBe('object');
expect(body.length).toBe(1);
const post = body[0];
expect(post).toHaveProperty('id');
expect(post.id).toBe(initialPost.id);
expect(post).toHaveProperty('title');
expect(post.title).toBe(initialPost.title);
expect(post).toHaveProperty('star');
expect(post.star).toMatchObject(initialStar);
});
it('GET /star/by-author', async () => {
const response = await request(app.getHttpServer())
.get('/star/by-author')
.query({ author: initialNickname })
.expect(200);
expect(response).toHaveProperty('body');
const { body } = response;
expect(typeof body).toBe('object');
expect(body.length).toBe(1);
const post = body[0];
expect(post).toHaveProperty('id');
expect(post.id).toBe(initialPost.id);
expect(post).toHaveProperty('title');
expect(post.title).toBe(initialPost.title);
expect(post).toHaveProperty('star');
expect(post.star).toMatchObject(initialStar);
});
it('PATCH /star/:id', async () => {
const testCase = { test: 'test', test_nested: { test: 'test' } };
await request(app.getHttpServer())
.patch(`/star/${initialPost.id}`)
.set('Cookie', [`accessToken=${accessToken}`])
.send(testCase)
.send(testCase)
.expect(200);
const response = await request(app.getHttpServer())
.get('/star')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(response).toHaveProperty('body');
const { body } = response;
expect(typeof body).toBe('object');
expect(body.length).toBe(1);
const post = body[0];
expect(post).toHaveProperty('id');
expect(post.id).toBe(initialPost.id);
expect(post).toHaveProperty('title');
expect(post.title).toBe(initialPost.title);
expect(post).toHaveProperty('star');
expect(post.star).toMatchObject(initialStar);
});
afterAll(async () => {
await app.close();
});
});
기존에 작성한 board(post)모듈을 개선했다. 안 쓰는 endpoint들은 없애고 남아 있는 것들은 인터페이스 변경사항에 맞게 수정.
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';
import { Board } from '../../src/board/entities/board.entity';
import { UpdateBoardDto } from '../../src/board/dto/update-board.dto';
import { CreateBoardDto } from '../../src/board/dto/create-board.dto';
import * as cookieParser from 'cookie-parser';
import { encryptAes } from '../../src/util/aes.util';
describe('BoardController (/board, e2e)', () => {
let app: INestApplication;
let accessToken: string;
let post_id: number;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
await app.init();
// 유저 만들고 로그인 후 accessToken 받아오기
const randomeBytes = Math.random().toString(36).slice(2, 10);
const newUser = {
username: randomeBytes,
nickname: randomeBytes,
password: randomeBytes,
};
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
newUser.nickname = undefined;
const signInResponse = await request(app.getHttpServer())
.post('/auth/signin')
.send(newUser);
signInResponse.headers['set-cookie'].forEach((cookie: string) => {
if (cookie.includes('accessToken')) {
accessToken = cookie.split(';')[0].split('=')[1];
}
});
// 별글도 하나 생성 후 수행
const board = {
title: 'test',
content: 'test',
star: '{}',
};
const postedBoard = await request(app.getHttpServer())
.post('/post')
.set('Cookie', [`accessToken=${accessToken}`])
.send(board);
post_id = postedBoard.body.id;
});
// #60 [08-06] 서버는 전송 받은 데이터를 데이터베이스에 저장한다.
it('POST /post', async () => {
const board = {
title: 'test',
content: 'test',
star: '{}',
};
const response = await request(app.getHttpServer())
.post('/post')
.set('Cookie', [`accessToken=${accessToken}`])
.send(board)
.expect(201);
expect(response).toHaveProperty('body');
const { body } = response;
expect(body).toHaveProperty('id');
expect(typeof body.id).toBe('number');
expect(body).toHaveProperty('title');
expect(body.title).toBe(board.title);
expect(body).toHaveProperty('content');
expect(body.content).toBe(encryptAes(board.content)); // 암호화되었는지 확인
expect(body).toHaveProperty('star');
expect(typeof body.star).toBe('string');
});
// #39 [06-02] 서버는 사용자의 글 데이터를 전송한다.
it('GET /post/:id', async () => {
const board: CreateBoardDto = {
title: 'test',
content: 'test',
star: '{}',
};
const newBoard = (
await request(app.getHttpServer())
.post('/post')
.set('Cookie', [`accessToken=${accessToken}`])
.send(board)
).body;
const response = await request(app.getHttpServer())
.get(`/post/${newBoard.id}`)
.expect(200);
expect(response).toHaveProperty('body');
const { body } = response;
expect(body).toHaveProperty('id');
expect(body.id).toBe(newBoard.id);
expect(body).toHaveProperty('title');
expect(body).toHaveProperty('content');
expect(body).toHaveProperty('like_cnt');
expect(body).toHaveProperty('images');
});
it('GET /post/:id/is-liked', async () => {
const response = await request(app.getHttpServer())
.get(`/post/${post_id}/is-liked`)
.set('Cookie', [`accessToken=${accessToken}`])
.expect(200);
expect(response).toHaveProperty('body');
const { text } = response;
expect(text === 'true' || text === 'false').toBe(true);
});
// (추가 필요) 서버는 사용자의 요청에 따라 글을 수정한다.
it('PATCH /post/:id', async () => {
const board = {
title: 'test',
content: 'test',
star: '{}',
};
const createdBoard = (
await request(app.getHttpServer())
.post('/post')
.set('Cookie', [`accessToken=${accessToken}`])
.send(board)
).body;
expect(createdBoard).toHaveProperty('id');
const id = createdBoard.id;
const toUpdate: UpdateBoardDto = {
title: 'updated',
content: 'updated',
};
const updated = await request(app.getHttpServer())
.patch(`/post/${id}`)
.set('Cookie', [`accessToken=${accessToken}`])
.send(toUpdate)
.expect(200);
expect(updated).toHaveProperty('body');
const updatedBoard = updated.body;
expect(updatedBoard).toHaveProperty('id');
expect(updatedBoard.id).toBe(id);
expect(updatedBoard).toHaveProperty('title');
expect(updatedBoard.title).toBe(toUpdate.title);
expect(updatedBoard).toHaveProperty('content');
expect(updatedBoard.content).toBe(encryptAes(toUpdate.content));
});
// #45 [06-08] 서버는 좋아요 / 좋아요 취소 요청을 받아 데이터베이스의 데이터를 수정한다.
it('PATCH /post/:id/like', async () => {
const board = {
title: 'test',
content: 'test',
star: '{}',
};
const resCreate = await request(app.getHttpServer())
.post('/post')
.set('Cookie', [`accessToken=${accessToken}`])
.send(board);
const createdBoard = resCreate.body;
expect(createdBoard).toHaveProperty('like_cnt');
const cntBeforeLike = createdBoard.like_cnt;
const resLike = await request(app.getHttpServer())
.patch(`/post/${createdBoard.id}/like`)
.set('Cookie', [`accessToken=${accessToken}`])
.expect(200);
expect(resLike).toHaveProperty('body');
expect(resLike.body).toHaveProperty('like_cnt');
const cntAfterLike = resLike.body.like_cnt;
expect(cntAfterLike).toBe(cntBeforeLike + 1);
});
it('PATCH /post/:id/unlike', async () => {
const board = {
title: 'test',
content: 'test',
star: '{}',
};
const createdBoard = (
await request(app.getHttpServer())
.post('/post')
.set('Cookie', [`accessToken=${accessToken}`])
.send(board)
).body;
const likedBoard = (
await request(app.getHttpServer())
.patch(`/post/${createdBoard.id}/like`)
.set('Cookie', [`accessToken=${accessToken}`])
).body;
const cntBeforeUnlike = likedBoard.like_cnt;
const resUnlike = await request(app.getHttpServer())
.patch(`/post/${createdBoard.id}/unlike`)
.set('Cookie', [`accessToken=${accessToken}`])
.expect(200);
expect(resUnlike).toHaveProperty('body');
expect(resUnlike.body).toHaveProperty('like_cnt');
const cntAfterUnlike = resUnlike.body.like_cnt;
expect(cntAfterUnlike).toBe(cntBeforeUnlike - 1);
});
// (추가 필요) 서버는 사용자의 요청에 따라 글을 삭제한다.
it('DELETE /post/:id', async () => {
const board: CreateBoardDto = {
title: 'test',
content: 'test',
star: '{}',
};
const newBoard = (
await request(app.getHttpServer())
.post('/post')
.set('Cookie', [`accessToken=${accessToken}`])
.send(board)
).body;
await request(app.getHttpServer())
.delete(`/post/${newBoard.id}`)
.set('Cookie', [`accessToken=${accessToken}`])
.expect(200);
await request(app.getHttpServer()).get(`/post/${newBoard.id}`).expect(404);
});
afterEach(async () => {
// 로그아웃
await request(app.getHttpServer())
.post('/auth/signout')
.set('Cookie', [`accessToken=${accessToken}`]);
await app.close();
});
});
여기서 테스트를 시도할때마다 결과가 달라지는 문제가 발생했는데, 대체로 async/await 설정을 제대로 해주지 않아 생긴 문제였다.
- async/await가 제대로 적용되지 않은 비동기 로직들로 인해 테스트 시 어떨 때는 통과되고, 어떨 때는 통과가 되지 않는 문제들이 발생함
- 컨트롤러 단의 필요한 메소드를 async로, service를 await해줌으로써 response 후 결과가 반영되는 문제 해결
- transaction interceptor의 commitTransaction()이 response 후에 적용되는 문제(POST /post 등)는 아직 해결 못함
Transaction Interceptor가 Response후에 트랜잭션을 커밋하여 생기는 문제는 아래와 다시 메소드 내부에서 transaction을 켜고 커밋하면 해결되긴 하지만, 일단은 인터셉터를 고쳐서 사용하는 방향으로 페어분과 협의하고 문제해결을 보류시키는 것으로 결론내렸다.
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';
describe('SentimentController', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('should be defined', () => {
expect(app).toBeDefined();
});
it('POST /sentiment', async () => {
const response = await request(app.getHttpServer())
.post('/sentiment')
.send({ content: 'test' })
.expect(200);
expect(response).toHaveProperty('body');
const { body } = response;
expect(body).toHaveProperty('color');
const { color } = body;
expect(typeof color).toBe('string');
expect(color.startsWith('#')).toBe(true);
});
});
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseInterceptors,
UsePipes,
ValidationPipe,
ParseIntPipe,
UseGuards,
UploadedFiles,
UseFilters,
} from '@nestjs/common';
import { BoardService } from './board.service';
import { CreateBoardDto } from './dto/create-board.dto';
import { UpdateBoardDto } from './dto/update-board.dto';
import { Board } from './entities/board.entity';
import { ApiTags } from '@nestjs/swagger';
import { FilesInterceptor } from '@nestjs/platform-express';
import { CookieAuthGuard } from '../auth/cookie-auth.guard';
import { GetUser } from '../auth/decorators/get-user.decorator';
import { UserDataDto } from '../auth/dto/user-data.dto';
import { decryptAes } from '../util/aes.util';
import { GetBoardByIdResDto } from './dto/get-board-by-id-res.dto';
import { awsConfig, bucketName } from '../config/aws.config';
import { CreateBoardSwaggerDecorator } from './decorators/swagger/create-board-swagger.decorator';
import { FindBoardByIdSwaggerDecorator } from './decorators/swagger/find-board-by-id-swagger.decorator';
import { UpdateBoardSwaggerDecorator } from './decorators/swagger/update-board-swagger.decorator';
import { PatchLikeSwaggerDecorator } from './decorators/swagger/patch-like-swagger.decorator';
import { PatchUnlikeSwaggerDecorator } from './decorators/swagger/patch-unlike-swagger.decorator';
import { DeleteBoardSwaggerDecorator } from './decorators/swagger/delete-board-by-id-swagger.decorator';
import { LogInterceptor } from '../interceptor/log.interceptor';
import { TransactionInterceptor } from '../interceptor/transaction.interceptor';
import { GetQueryRunner } from '../interceptor/decorators/get-querry-runner.decorator';
import { DeleteResult, QueryRunner } from 'typeorm';
import { GetIsLikedSwaggerDecorator } from './decorators/swagger/get-is-liked-swagged.decorator';
import { HttpExceptionFilter } from '../exception-filter/http.exception-filter';
@Controller('post')
@UseInterceptors(LogInterceptor)
@UseFilters(HttpExceptionFilter)
@ApiTags('게시글 API')
export class BoardController {
constructor(private readonly boardService: BoardService) {}
@Post()
@UseGuards(CookieAuthGuard)
@UseInterceptors(FilesInterceptor('file', 3))
@UseInterceptors(TransactionInterceptor)
@UsePipes(ValidationPipe)
@CreateBoardSwaggerDecorator()
async createBoard(
@Body() createBoardDto: CreateBoardDto,
@GetUser() userData: UserDataDto,
@UploadedFiles() files: Express.Multer.File[],
@GetQueryRunner() queryRunner: QueryRunner,
): Promise<Board> {
return await this.boardService.createBoard(
createBoardDto,
userData,
files,
queryRunner,
);
}
// 게시글에 대한 User정보 얻기
@Get(':id')
@FindBoardByIdSwaggerDecorator()
async findBoardById(
@Param('id', ParseIntPipe) id: number,
): Promise<GetBoardByIdResDto> {
const found = await this.boardService.findBoardById(id);
// AES 복호화
if (found.content) {
found.content = decryptAes(found.content); // AES 복호화하여 반환
}
const boardData: GetBoardByIdResDto = {
id: found.id,
title: found.title,
content: found.content,
like_cnt: found.like_cnt,
images: found.images.map(
(image) => `${awsConfig.endpoint.href}${bucketName}/${image.filename}`,
),
};
return boardData;
}
@Get(':id/is-liked')
@UseGuards(CookieAuthGuard)
@GetIsLikedSwaggerDecorator()
async getIsLiked(
@Param('id', ParseIntPipe) id: number,
@GetUser() userData: UserDataDto,
): Promise<boolean> {
return await this.boardService.getIsLiked(id, userData);
}
// 사진도 수정할 수 있도록 폼데이터 형태로 받기
@Patch(':id')
@UseGuards(CookieAuthGuard)
@UseInterceptors(FilesInterceptor('file', 3))
@UseInterceptors(TransactionInterceptor)
@UsePipes(ValidationPipe)
@UpdateBoardSwaggerDecorator()
async updateBoard(
@Param('id', ParseIntPipe) id: number,
@Body() updateBoardDto: UpdateBoardDto,
@GetUser() userData: UserDataDto,
@UploadedFiles() files: Express.Multer.File[],
@GetQueryRunner() queryRunner: QueryRunner,
) {
return await this.boardService.updateBoard(
id,
updateBoardDto,
userData,
files,
queryRunner,
);
}
@Patch(':id/like')
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
@PatchLikeSwaggerDecorator()
async patchLike(
@Param('id', ParseIntPipe) id: number,
@GetUser() userData: UserDataDto,
): Promise<Partial<Board>> {
return await this.boardService.patchLike(id, userData);
}
@Patch(':id/unlike')
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
@PatchUnlikeSwaggerDecorator()
async patchUnlike(
@Param('id', ParseIntPipe) id: number,
@GetUser() userData: UserDataDto,
): Promise<Partial<Board>> {
return await this.boardService.patchUnlike(id, userData);
}
// 연관된 Image 및 Star, Like도 함께 삭제
@Delete(':id')
@UseGuards(CookieAuthGuard)
@UseInterceptors(TransactionInterceptor)
@UsePipes(ValidationPipe)
@DeleteBoardSwaggerDecorator()
async deleteBoard(
@Param('id', ParseIntPipe) id: number,
@GetUser() userData: UserDataDto,
@GetQueryRunner() queryRunner: QueryRunner,
): Promise<DeleteResult> {
return await this.boardService.deleteBoard(id, userData, queryRunner);
}
}
필요한 컨트롤러 메소드를 async로 두고, 서비스 메소드를 await하면, 테스트가 실행될 때마다 됐다 안됐다 하는 문제를 해결할 수 있다. (jest 테스트가 DB생성 후 바로 생성된 레코드를 확인하고 이러니까 DB 반영이 안된 채로 다음 로직이 실행되는 문제가 드러난 것)
멘토님께서 transaction은 인터셉터 방식으로 하는 것은 여러 문제가 있다는 피드백을 주셨고, 과감히 인터셉터를 버리는 것으로 결정.
추천해주신 dataSource.transaction(async (manager) => {});
방식으로 모두 변경해줬다.
...
@Injectable()
export class BoardService {
constructor(
private readonly fileService: FileService,
private readonly dataSource: DataSource,
@InjectRepository(Board)
private readonly boardRepository: Repository<Board>,
@InjectRepository(Image)
private readonly imageRepository: Repository<Image>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectModel(Star.name)
private readonly starModel: Model<Star>,
) {}
async createBoard(
createBoardDto: CreateBoardDto,
userData: UserDataDto,
files: Express.Multer.File[],
): Promise<Board> {
let createdBoard: Board;
await this.dataSource.transaction(async (manager: EntityManager) => {
const { title, content, star } = createBoardDto;
const user: User = await manager.findOneBy(User, {
id: userData.userId,
});
const images: Image[] = [];
if (files && files.length > 0) {
for (const file of files) {
// Object Storage에 업로드
const imageInfo = await this.fileService.uploadFile(file);
// 이미지 리포지토리에 저장
const image = manager.create(Image, {
...imageInfo,
});
const createdImage = await manager.save(image);
images.push(createdImage);
}
}
// 별 스타일이 존재하면 MongoDB에 저장
let star_id: string;
if (star) {
const starDoc = new this.starModel({
...JSON.parse(star),
});
await starDoc.save();
star_id = starDoc._id.toString();
}
const board = manager.create(Board, {
title,
content: encryptAes(content), // AES 암호화하여 저장
user,
images,
star: star_id,
});
createdBoard = await manager.save(board);
createdBoard.user.password = undefined; // password 제거하여 반환
});
return createdBoard;
}
async updateBoard(
id: number,
updateBoardDto: UpdateBoardDto,
userData: UserDataDto,
files: Express.Multer.File[],
): Promise<Board> {
let updatedBoard: Board;
await this.dataSource.transaction(async (manager: EntityManager) => {
const board: Board = await manager.findOneBy(Board, { id });
if (!board) {
throw new NotFoundException('board not found');
}
// 게시글 작성자와 수정 요청자가 다른 경우
if (board.user.id !== userData.userId) {
throw new BadRequestException('not your post');
}
// star에 대한 수정은 별도 API(PATCH /star/:id)로 처리하므로 400 에러 리턴
if (updateBoardDto.star) {
throw new BadRequestException('cannot update star');
}
if (files && files.length > 0) {
const images: Image[] = [];
for (const file of files) {
const imageInfo = await this.fileService.uploadFile(file);
const image = manager.create(Image, {
...imageInfo,
});
const updatedImage = await manager.save(image);
images.push(updatedImage);
}
// 기존 이미지 삭제
for (const image of board.images) {
// 이미지 리포지토리에서 삭제
await manager.delete(Image, { id: image.id });
// NCP Object Storage에서 삭제
await this.fileService.deleteFile(image.filename);
}
// 새로운 이미지로 교체
board.images = images;
}
// updateBoardDto.content가 존재하면 AES 암호화하여 저장
if (updateBoardDto.content) {
updateBoardDto.content = encryptAes(updateBoardDto.content);
}
updatedBoard = await manager.save(Board, {
...board,
...updateBoardDto,
});
updatedBoard.user.password = undefined; // password 제거하여 반환
});
return updatedBoard;
}
async deleteBoard(id: number, userData: UserDataDto): Promise<DeleteResult> {
let result: DeleteResult;
await this.dataSource.transaction(async (manager: EntityManager) => {
const board: Board = await manager.findOneBy(Board, { id });
if (!board) {
throw new NotFoundException('board not found');
}
// 게시글 작성자와 삭제 요청자가 다른 경우
if (board.user.id !== userData.userId) {
throw new BadRequestException('not your post');
}
// 연관된 이미지 삭제
for (const image of board.images) {
// 이미지 리포지토리에서 삭제
await manager.delete(Image, { id: image.id });
// NCP Object Storage에서 삭제
await this.fileService.deleteFile(image.filename);
}
// 연관된 별 스타일 삭제
if (board.star) {
await this.starModel.deleteOne({ _id: board.star });
}
// like 조인테이블 레코드들은 자동으로 삭제됨 (외래키 제약조건 ON DELETE CASCADE)
// 게시글 삭제
result = await manager.delete(Board, { id });
});
return result;
}
}
이제 100% 통과한다.
© 2023 debussysanjang
- 🐙 [가은] Three.js와의 설레는 첫만남
- 🐙 [가은] JS로 자전과 공전을 구현할 수 있다고?
- ⚽️ [준섭] NestJS 강의 정리본
- 🐧 [동민] R3F Material 간단 정리
- 👾 [재하] 만들면서 배우는 NestJS 기초
- 👾 [재하] GitHub Actions을 이용한 자동 배포
- ⚽️ [준섭] 테스트 코드 작성 이유
- ⚽️ [준섭] TypeScript의 type? interface?
- 🐙 [가은] 우리 팀이 Zustand를 쓰는 이유
- 👾 [재하] NestJS, TDD로 개발하기
- 👾 [재하] AWS와 NCP의 주요 서비스
- 🐰 [백범] Emotion 선택시 고려사항
- 🐧 [동민] Yarn berry로 모노레포 구성하기
- 🐧 [동민] Vite, 왜 쓰는거지?
- ⚽️ [준섭] 동시성 제어
- 👾 [재하] NestJS에 Swagger 적용하기
- 🐙 [가은] 너와의 추억을 우주의 별로 띄울게
- 🐧 [동민] React로 멋진 3D 은하 만들기(feat. R3F)
- ⚽️ [준섭] NGINX 설정
- 👾 [재하] Transaction (트랜잭션)
- 👾 [재하] SSH 보안: Key Forwarding, Tunneling, 포트 변경
- ⚽️ [준섭] MySQL의 검색 - LIKE, FULLTEXT SEARCH(전문검색)
- 👾 [재하] Kubernetes 기초(minikube), docker image 최적화(멀티스테이징)
- 👾 [재하] NestJS, 유닛 테스트 각종 mocking, e2e 테스트 폼데이터 및 파일첨부
- 2주차(화) - git, monorepo, yarn berry, TDD
- 2주차(수) - TDD, e2e 테스트
- 2주차(목) - git merge, TDD
- 2주차(일) - NCP 배포환경 구성, MySQL, nginx, docker, docker-compose
- 3주차(화) - Redis, Multer 파일 업로드, Validation
- 3주차(수) - AES 암복호화, TypeORM Entity Relation
- 3주차(목) - NCP Object Storage, HTTPS, GitHub Actions
- 3주차(토) - Sharp(이미지 최적화)
- 3주차(일) - MongoDB
- 4주차(화) - 플랫폼 종속성 문제 해결(Sharp), 쿼리 최적화
- 4주차(수) - 코드 개선, 트랜잭션 제어
- 4주차(목) - 트랜잭션 제어
- 4주차(일) - docker 이미지 최적화
- 5주차(화) - 어드민 페이지(전체 글, 시스템 정보)
- 5주차(목) - 감정분석 API, e2e 테스트
- 5주차(토) - 유닛 테스트(+ mocking), e2e 테스트(+ 파일 첨부)
- 6주차(화) - ERD
- 2주차(화) - auth, board 모듈 생성 및 테스트 코드 환경 설정
- 2주차(목) - Board, Auth 테스트 코드 작성 및 API 완성
- 3주차(월) - Redis 연결 후 RedisRepository 작성
- 3주차(화) - SignUpUserDto에 ClassValidator 적용
- 3주차(화) - SignIn시 RefreshToken 발급 및 Redis에 저장
- 3주차(화) - 커스텀 AuthGuard 작성
- 3주차(수) - SignOut시 토큰 제거
- 3주차(수) - 깃헙 로그인 구현
- 3주차(토) - OAuth 코드 통합 및 재사용
- 4주차(수) - NestJS + TypeORM으로 MySQL 전문검색 구현
- 4주차(목) - NestJS Interceptor와 로거
- [전체] 10/12(목)
- [전체] 10/15(일)
- [전체] 10/30(월)
- [FE] 11/01(수)~11/03(금)
- [전체] 11/06(월)
- [전체] 11/07(화)
- [전체] 11/09(목)
- [전체] 11/11(토)
- [전체] 11/13(월)
- [BE] 11/14(화)
- [BE] 11/15(수)
- [FE] 11/16(목)
- [FE] 11/19(일)
- [BE] 11/19(일)
- [FE] 11/20(월)
- [BE] 11/20(월)
- [BE] 11/27(월)
- [FE] 12/04(월)
- [BE] 12/04(월)
- [FE] 12/09(금)
- [전체] 12/10(일)
- [FE] 12/11(월)
- [전체] 12/11(월)
- [전체] 12/12(화)