Skip to content

[재하] 1207(목) 개발기록

박재하 edited this page Dec 7, 2023 · 6 revisions

목표

  • 감정분석 AI API 연동
    • 사전 준비 (API 사용 신청 및 키 발급)
    • 구현
    • 테스트 (동작 화면)
  • e2e 테스트 코드 작성, 테스트
    • galaxy 모듈
    • star 모듈
    • board 모듈
    • admin 모듈
    • sentiment 모듈
    • 리팩토링, 코드 개선

감정분석 AI API 연동

사전 준비 (API 사용 신청 및 키 발급)

스크린샷 2023-12-07 오후 3 28 39

NCP 콘솔에서 AI/Naver API 사용 신청을 해두어야 사용 가능함. 도메인 등록 시 백엔드에서 접근해서 사용할 것이므로 localhost:3000을 등록해두자. 배포 시에도 동일하게 포트를 열기 때문에 잘 작동할 것으로 기대됨(안되면 알아보고 추가 등록 필요)

스크린샷 2023-12-07 오후 3 32 53

그러면 요기서 인증 정보(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에도 저장해둔다.

테스트 (동작 화면)

색상 변환 전 API 결과값만 반환

스크린샷 2023-12-07 오후 2 23 00 스크린샷 2023-12-07 오후 2 24 03

에러 처리하고 색상 변환하여 추천 색상값 반환

스크린샷 2023-12-07 오후 2 40 52

e2e 테스트 코드 작성, 테스트

galaxy 모듈

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 모듈

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 모듈

기존에 작성한 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을 켜고 커밋하면 해결되긴 하지만, 일단은 인터셉터를 고쳐서 사용하는 방향으로 페어분과 협의하고 문제해결을 보류시키는 것으로 결론내렸다.

sentiment 모듈

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 반영이 안된 채로 다음 로직이 실행되는 문제가 드러난 것)

테스트 (결과 화면)

galaxy

스크린샷 2023-12-07 오전 1 39 27

star

스크린샷 2023-12-07 오전 1 39 09

board (transaction interceptor on)

스크린샷 2023-12-07 오후 7 34 29

board (transaction interceptor off)

스크린샷 2023-12-07 오후 6 33 21

sentiment

스크린샷 2023-12-07 오후 4 42 36

transaction 제어 인터셉터 방식 -> 메소드 내부에서 수행하는 방식으로 변경

멘토님께서 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;
	}
}

테스트 (동작 화면)

스크린샷 2023-12-07 오후 11 58 48

이제 100% 통과한다.

학습 메모

  1. custome decorator to get cookie
  2. 감정 분석 API 공식 문서
  3. await beforeach async

소개

규칙

학습 기록

[공통] 개발 기록

[재하] 개발 기록

[준섭] 개발 기록

회의록

스크럼 기록

팀 회고

개인 회고

멘토링 일지

Clone this wiki locally