-
Notifications
You must be signed in to change notification settings - Fork 2
[재하] 1122(수) 개발기록
- 게시글 본문 암복호화
- Entity 간 관계 적용 (OneToMany, ManyToOne, OneToOne)
- AuthGuard 적용
- POST /board에 author <- nickname 직접 삽입
- user 외래키 활용하도록 메소드 개선
- 좋아요 중복 비허용 (조인테이블 생성)
- S3에 사진 데이터 저장하기
스프린트때 직접 개발해두었던 암복호화 모듈을 활용해 utils에 넣었다. (학습메모 1)
// aes.util.ts
import crypto from 'crypto';
import { aesConfig } from '../config/aes.config';
const encryptAes = (plainText) => {
const algorithm = 'aes-256-cbc'; // 암호 알고리즘
const key = crypto.scryptSync(aesConfig.password, aesConfig.salt, 32); // 암호화 키
const iv = aesConfig.iv; // 초기화 벡터
const cipher = crypto.createCipheriv(algorithm, key, iv);
let cipherText = cipher.update(plainText, 'utf8', 'base64');
cipherText += cipher.final('base64');
return cipherText;
};
const decryptAes = (cipherText) => {
const algorithm = 'aes-256-cbc'; // 암호 알고리즘
const key = crypto.scryptSync(aesConfig.password, aesConfig.salt, 32); // 암호화 키
const iv = aesConfig.iv; // 초기화 벡터
const decipher = crypto.createDecipheriv(algorithm, key, iv);
let plainText = decipher.update(cipherText, 'base64', 'utf8');
plainText += decipher.final('utf8');
return plainText;
};
export { encryptAes, decryptAes };
import { configDotenv } from 'dotenv';
configDotenv();
export const aesConfig = {
password: process.env.AES_PASSWORD,
salt: process.env.AES_SALT,
iv: Buffer.alloc(16, 0),
};
config 파일로 .env에 있는 AES_PASSWORD, AES_SALT를 활용하도록 보안처리
이제 POST /board
, PATCH /board/:id
및 GET /board
에 암복호화 처리를 넣어준다.
// board.service.ts
...
import { encryptAes, decryptAes } from 'src/utils/aes.util';
@Injectable()
export class BoardService {
...
async createBoard(createBoardDto: CreateBoardDto): Promise<Board> {
const { title, content, author } = createBoardDto;
const board = this.boardRepository.create({
title,
content: encryptAes(content), // AES 암호화하여 저장
author,
});
const created: Board = await this.boardRepository.save(board);
return created;
}
async findBoardById(id: number): Promise<Board> {
const found: Board = await this.boardRepository.findOneBy({ id });
if (!found) {
throw new NotFoundException(`Not found board with id: ${id}`);
}
if (found.content) {
found.content = decryptAes(found.content); // AES 복호화하여 반환
}
return found;
}
async updateBoard(id: number, updateBoardDto: UpdateBoardDto) {
const board: Board = await this.findBoardById(id);
// updateBoardDto.content가 존재하면 AES 암호화하여 저장
if (updateBoardDto.content) {
updateBoardDto.content = encryptAes(updateBoardDto.content);
}
const updatedBoard: Board = await this.boardRepository.save({
...board,
...updateBoardDto,
});
return updatedBoard;
}
}
암호화돼서 잘 저장됨
GET으로 요청할 땐 복호화되어 반환됨
DB에 저장될때는 암호문으로! IV나 salt를 컬럼에 있는 값을 조합해서 쓰면 같은 input이라도 다른 암호문이 나오게 할 수 있다. 필요하면 개선하자
게시글 하나당 사진 최대 하나이므로 OneToOne. 없을 수도 있으므로 nullable을 넣었다. 학습메모 2 참고. Image Entity에는 별도로 설정이 필요 없다.
// board.entity.ts
@Entity()
export class Board extends BaseEntity {
...
@OneToOne(() => Image, { nullable: true })
@JoinColumn()
image_id: number;
}
image_id가 FK로 Image 엔티티의 PK인 id를 참조하게 되는 거임.
-- Active: 1694011841232@@192.168.64.2@3306@b1g1
CREATE TABLE `board` (
...
UNIQUE KEY `REL_1e34245d55ca9414293c6e4276` (`imageIdId`),
...
CONSTRAINT `FK_1e34245d55ca9414293c6e4276c` FOREIGN KEY (`imageIdId`) REFERENCES `image` (`id`),
...
) ENGINE=InnoDB AUTO_INCREMENT=138 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
board 테이블의 DDL을 확인해보면 image(id)
에 대한 외래키 설정이 잘 되어 있는 것을 확인할 수 있다.
하나의 작성자가 여러 글을 쓸 수 있으므로 이러한 관계가 정립된다.
@Entity()
export class Board extends BaseEntity {
...
@ManyToOne(() => User, (user) => user.boards, { onDelete: 'CASCADE' })
user: User;
}
@Entity()
export class User {
...
@OneToMany(() => Board, (board) => board.user)
boards: Board[];
}
author를 외래키로 설정하고 user(nickname)을 참조하고 싶은 상태인데, 그런 설정은 찾아봐도 안나옴. 추후 가능하면 넣자. user.nickname으로 지금도 접근할 수 있다.
ON DELETE CASCADE
문도 삽입
-- Active: 1694011841232@@192.168.64.2@3306@b1g1
CREATE TABLE `board` (
`id` int NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`content` text,
`author` varchar(50) NOT NULL,
`like_cnt` int NOT NULL DEFAULT '0',
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
`imageId` int DEFAULT NULL,
`userId` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `REL_6c2a2c2b30fbb895ef2dc41294` (`imageId`),
KEY `FK_c9951f13af7909d37c0e2aec484` (`userId`),
CONSTRAINT `FK_6c2a2c2b30fbb895ef2dc412947` FOREIGN KEY (`imageId`) REFERENCES `image` (`id`),
CONSTRAINT `FK_c9951f13af7909d37c0e2aec484` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=138 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
대망의 AuthGuard 적용. 잘 동작할까..?
안됨. AuthModule을 board에서 import해도 안되는데, 학습메모 4 참고하면 AuthModule에서 guard 사용에 필요한 PassportModule과 JwtStrategy를 export해주지 않았기 때문인듯.
import { AuthModule } from 'src/auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([Board, Image]), AuthModule],
controllers: [BoardController],
providers: [BoardService],
})
export class BoardModule {}
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register(jwtConfig),
TypeOrmModule.forFeature([User]),
],
controllers: [AuthController],
providers: [AuthService, CookieAuthGuard, RedisRepository],
exports: [CookieAuthGuard, PassportModule],
})
export class AuthModule {}
근데 JwtStrategy가 없다. 거기에 해당하는 게(validate 로직이 들어가는 게) CookieAuthGuard라는 페어분이 만드신 custom guard인데, 이걸 export해줘야 한다. (그래서 strategy 만들었다 지움)
그래도 안됨. 동작은 하는데 로그인을 해서 쿠키가 있는데도 통과가 안됨 코드도 보고 console.log()로 어디서 Unauthorized가 나오나 봤는데
// cookie auth guard
...
@Injectable()
export class CookieAuthGuard extends AuthGuard('jwt') {
constructor(
private readonly jwtService: JwtService,
private readonly redisRepository: RedisRepository,
) {
super();
}
...
async canActivate(context: ExecutionContext): Promise<boolean> {
...
const refreshToken = request.cookies['refreshToken'];
try {
const { userId, username, nickname } =
this.jwtService.verify(refreshToken);
request.user = { userId, username, nickname };
} catch (error) {
response.clearCookie(JwtEnum.ACCESS_TOKEN_COOKIE_NAME);
response.clearCookie(JwtEnum.REFRESH_TOKEN_COOKIE_NAME);
console.log(2);
throw new UnauthorizedException('로그인이 필요합니다.');
}
...
}
}
이 jwtService 파트에서 try catch문으로 에러가 나는거였다. verify가 안된 게 아니라 그냥 jwtService가 정의가 안돼서 그런거임 의존성 주입이 제대로 안돼서!
따라서 저 CookieAuthGuard에서 의존하는 JwtService
와 RedisRepository
를 함께 export 및 import를 시켜줘야 한다 이말씀.
추가로 이 상황에선 PassportModule모듈은 필요없다.
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../config/jwt.config';
import { RedisRepository } from './redis.repository';
import { CookieAuthGuard } from './cookie-auth.guard';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register(jwtConfig),
TypeOrmModule.forFeature([User]),
],
controllers: [AuthController],
providers: [AuthService, CookieAuthGuard, RedisRepository],
exports: [JwtModule, CookieAuthGuard, RedisRepository],
})
export class AuthModule {}
최종적으로 AuthModule에선 [JwtModule, CookieAuthGuard, RedisRepository]
export.
로그인 하면
이제 Guard로 막혔던 게 잘 접근이 된다.
로그아웃하면 다시 안돼야하는데 된다. HTTP 로그를 보니 accessToken만 지워가지고 소스코드를 봤다.
@Get('signout')
async signOut(@Res({ passthrough: true }) res: Response) {
res.clearCookie('accessToken', { path: '/', httpOnly: true });
return { message: 'success' };
}
accessToken만 지우고 refreshToken이 살아있으니 현재 인증관리 로직 상 guard를 통과할 때 요청 없이 자동으로 refresh되어서 다시 accessToken을 발급받아 인증이 되어버리는 것.
// auth.controller.ts
@Get('signout')
@UseGuards(CookieAuthGuard)
async signOut(@Req() req, @Res({ passthrough: true }) res: Response) {
res.clearCookie('accessToken', { path: '/', httpOnly: true });
res.clearCookie('refreshToken', { path: '/', httpOnly: true });
await this.authService.signOut(req.user.username);
return { message: 'success' };
}
// auth.service.ts
async signOut(username: string) {
// redis에 저장된 refreshToken 삭제
await this.redisRepository.del(username);
}
// redis.repository.ts
@Injectable()
export class RedisRepository {
...
async del(key: string) {
return this.redisClient.del(key);
}
}
기능은 다 만들어뒀는데 이러면 로그인 안한 상태에서 로그아웃을 하면 Unauthorized가 뜨긴함. 음..!
아무튼 이제 잘 제거되어 다시 UnauthorizedException을 받을 수 있게 된다.
이제 검증이 완료됐으니 게시글 CRUD의 모든 기능에 가드를 넣어준다.
...
@Controller('board')
@ApiTags('게시글 API')
export class BoardController {
constructor(private readonly boardService: BoardService) {}
@Post()
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
createBoard(@Body() createBoardDto: CreateBoardDto): Promise<Board> {
return this.boardService.createBoard(createBoardDto);
}
@Get()
@UseGuards(CookieAuthGuard)
findAllBoards(): Promise<Board[]> {
return this.boardService.findAllBoards();
}
@Get('by-author')
@UseGuards(CookieAuthGuard)
findAllBoardsByAuthor(@Query('author') author: string): Promise<Board[]> {
return this.boardService.findAllBoardsByAuthor(author);
}
@Get(':id')
@UseGuards(CookieAuthGuard)
findBoardById(@Param('id', ParseIntPipe) id: number): Promise<Board> {
return this.boardService.findBoardById(id);
}
@Patch(':id')
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
updateBoard(
@Param('id', ParseIntPipe) id: number,
@Body() updateBoardDto: UpdateBoardDto,
) {
return this.boardService.updateBoard(id, updateBoardDto);
}
@Patch(':id/like')
@UseGuards(CookieAuthGuard)
patchLike(@Param('id', ParseIntPipe) id: number): Promise<Partial<Board>> {
return this.boardService.patchLike(id);
}
@Patch(':id/unlike')
@UseGuards(CookieAuthGuard)
patchUnlike(@Param('id', ParseIntPipe) id: number): Promise<Partial<Board>> {
return this.boardService.patchUnlike(id);
}
@Delete(':id')
@UseGuards(CookieAuthGuard)
deleteBoard(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.boardService.deleteBoard(id);
}
@Post(':id/image')
@UseGuards(CookieAuthGuard)
@UseInterceptors(FileInterceptor('file', { dest: './uploads' }))
@UsePipes(ValidationPipe)
uploadFile(
@Param('id', ParseIntPipe) board_id: number,
@UploadedFile() file: CreateImageDto,
): Promise<Board> {
return this.boardService.uploadFile(board_id, file);
}
}
로그인 했을 때
로그아웃 했을 때
// cookie-auth.guard.ts
request.user = { userId, username, nickname };
이제 Guard를 통해 req.user에 정보를 넣어주므로
@Get()
@UseGuards(CookieAuthGuard)
findAllBoards(@Req() req): Promise<Board[]> {
console.log(req.user);
return this.boardService.findAllBoards();
}
이제 가드만 넣으면 유저 정보를 언제든 얻을 수 있다.
요렇게! nickname까지!
이제 create dto로 author를 입력받는 게 아닌 서버에서 자동 삽입해주도록 바꿔보자.
@Post()
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
@ApiOperation({ summary: '게시글 작성', description: '게시글을 작성합니다.' })
@ApiCreatedResponse({ status: 201, description: '게시글 작성 성공' })
@ApiBadRequestResponse({
status: 400,
description: '잘못된 요청으로 게시글 작성 실패',
})
createBoard(
@Req() req,
@Body() createBoardDto: CreateBoardDto,
): Promise<Board> {
if (req.user && req.user.nickname)
createBoardDto.author = req.user.nickname;
return this.boardService.createBoard(createBoardDto);
}
// 서버에서 직접 삽입해주도록 변경 (validation 제거)
// @IsNotEmpty({ message: '게시글 작성자는 필수 입력입니다.' })
// @IsString({ message: '게시글 작성자는 문자열로 입력해야 합니다.' })
// @MaxLength(50, { message: '게시글 작성자는 50자 이내로 입력해야 합니다.' })
author: string;
validation 데코레이터는 모두 주석처리.
이제 author는 삽입되어도 무시하고 원래 닉네임으로 잘 들어간다.
{ eager: true }
구문을 사용하지 않아 바로 반영이 안되어 수많은 삽질을 반복했다..
@Entity()
export class User {
@OneToMany(() => Board, (board) => board.user, { eager: false })
boards: Board[];
}
@Entity()
export class Board extends BaseEntity {
@ManyToOne(() => User, (user) => user.boards, {
eager: true,
onDelete: 'CASCADE',
})
user: User;
}
결과적으로 보드쪽에서 {eager: true}
, 유저쪽에서 {eager: false}
, 해주면 DB에 잘 반영되더라
어째선진 모르겠지만 TypeORM에서 관계성이 있는 값을 등록할 때, id가 아닌 객체 자체를 삽입해줘야 한다.
따라서 User Repository를 Board에서도 불러와야 한다.
@Module({
imports: [TypeOrmModule.forFeature([Board, Image, User]), AuthModule],
controllers: [BoardController],
providers: [BoardService],
})
export class BoardModule {}
board 모듈에서 TypeOrmModule에 User를 같이 불러와주고
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
export const GetUser = createParamDecorator((_, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.user;
});
@Req
로 request 전체를 불러오지 않고 cookie의 jwt 토큰에서 추출한 User에 대한 데이터(id, username, nickname)만 추출하도록
@GetUser()
데코레이터를 새로 만들었다.
또한 이렇게 가져온 값의 유효성을 입증하기 위해 user-data.dto.ts를 board모듈에 추가해 필요한 곳에 타입을 지정하도록 했다.
import { IsInt, IsNotEmpty, IsString } from 'class-validator';
export class UserDataDto {
@IsNotEmpty()
@IsInt()
userId: number;
@IsNotEmpty()
@IsString()
username: string;
@IsNotEmpty()
@IsString()
nickname: string;
}
Validation Pipe도 사용할 수 있게 각종 데코레이터를 추가했다.
board의 생성 시엔 author를 집어넣는 대신, 참조된 user 객체의 nickname 항목을 가져다 쓰면 된다.
그렇기 때문에 해당 board entity에서 author를 아예 제거하고, user repository에서 직접 user 객체를 찾아 저장 시 삽입하도록 변경했다.
async createBoard(
createBoardDto: CreateBoardDto,
userData: UserDataDto,
): Promise<Board> {
const { title, content } = createBoardDto;
const user = await this.userRepository.findOneBy({ id: userData.userId });
const board = this.boardRepository.create({
title,
content: encryptAes(content), // AES 암호화하여 저장
user,
});
const createdBoard: Board = await this.boardRepository.save(board);
createdBoard.user.password = undefined; // password 제거하여 반환
return createdBoard;
}
수정, 삭제 시에는 단순 Guard 인증 뿐만이 아니라, 내가 작성한 게시글인 경우에만 처리가 가능해야 한다. 따라서 user data를 활용해 이를 검사한다.
async updateBoard(
id: number,
updateBoardDto: UpdateBoardDto,
userData: UserDataDto,
) {
const board: Board = await this.findBoardById(id);
// 게시글 작성자와 수정 요청자가 다른 경우
if (board.user.id !== userData.userId) {
throw new BadRequestException('You are not the author of this post');
}
// updateBoardDto.content가 존재하면 AES 암호화하여 저장
if (updateBoardDto.content) {
updateBoardDto.content = encryptAes(updateBoardDto.content);
}
const updatedBoard: Board = await this.boardRepository.save({
...board,
...updateBoardDto,
});
return updatedBoard;
}
async deleteBoard(id: number, userData: UserDataDto): Promise<void> {
const board: Board = await this.findBoardById(id);
// 게시글 작성자와 삭제 요청자가 다른 경우
if (board.user.id !== userData.userId) {
throw new BadRequestException('You are not the author of this post');
}
const result = await this.boardRepository.delete({ id });
}
본인인 경우
다른 사용자인 경우 잘 차단되는 것 확인
update, delete 간 findBoardById()의 호출로 인해 수정이 발생하지 않았을 때 평문으로 복호화된 레코드가 저장되어버리는 문제 발생
async findBoardById(@Param('id', ParseIntPipe) id: number): Promise<Board> {
const found = await this.boardService.findBoardById(id);
// AES 복호화
if (found.content) {
found.content = decryptAes(found.content); // AES 복호화하여 반환
}
return found;
}
decrypt 로직을 controller단으로 땡겨서 해당 문제를 해결했다.
서비스 내에서는 암호화된 채로만 사용한다.
마지막으로 /board/by-author는 닉네임으로 조회를 하되 author 컬럼이 아닌 user.nickname을 조회하도록 변경했고,
아무런 파라미터가 없으면 본인 닉네임을 삽입하도록 했다.
findAllBoardsByAuthor(
@Query('author') author: string,
@GetUser() userData: UserDataDto,
): Promise<Board[]> {
// 파라미터 없는 경우 로그인한 사용자의 게시글 조회
author = author ? author : userData.nickname;
return this.boardService.findAllBoardsByAuthor(author);
}
async findAllBoardsByAuthor(author: string): Promise<Board[]> {
const boards = await this.boardRepository.findBy({
user: { nickname: author },
});
return boards;
}
닉네임 입력한 경우
닉네임 없는 경우
- POST /board 개선
- PATCH /board/:id
- DELETE /board/:id
- GET /board/by-author
// board.entity.ts
@ManyToMany(() => User, { eager: true })
@JoinTable()
likes: User[];
@Column({ type: 'int', default: 0 })
like_cnt: number;
Many To Many 및 @JoinTable()
데코레이터로 like에 대한 조인 테이블을 생성한다.
잘 생성됨
@Patch(':id/like')
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
patchLike(
@Param('id', ParseIntPipe) id: number,
@GetUser() userData: UserDataDto,
): Promise<Partial<Board>> {
return this.boardService.patchLike(id, userData);
}
@Patch(':id/unlike')
@UseGuards(CookieAuthGuard)
@UsePipes(ValidationPipe)
patchUnlike(
@Param('id', ParseIntPipe) id: number,
@GetUser() userData: UserDataDto,
): Promise<Partial<Board>> {
return this.boardService.patchUnlike(id, userData);
}
컨트롤러 단에서 위에서 작업했던 것과 같은 방식으로 user data를 받아 서비스로 넘긴다.
like_cnt를 단순히 더하고 빼는 것이 아닌, jointable에 삽입하고 빼는 과정으로 처리하고, 그 배열의 길이를 like_cnt에 넣는 형식으로 해서 예외 상황을 없앤다.
async patchLike(id: number, userData: UserDataDto): Promise<Partial<Board>> {
const board = await this.findBoardById(id);
console.log(board);
if (board.likes.find((user) => user.id === userData.userId)) {
throw new BadRequestException('You already liked this post');
}
const user = await this.userRepository.findOneBy({ id: userData.userId });
if (!user) {
throw new NotFoundException(`Not found user with id: ${userData.userId}`);
}
board.likes.push(user);
board.like_cnt = board.likes.length;
const updatedBoard = await this.boardRepository.save(board);
return { like_cnt: updatedBoard.like_cnt };
}
async patchUnlike(
id: number,
userData: UserDataDto,
): Promise<Partial<Board>> {
const board = await this.findBoardById(id);
if (!board.likes.find((user) => user.id === userData.userId)) {
throw new BadRequestException('You have not liked this post');
}
const user = await this.userRepository.findOneBy({ id: userData.userId });
if (!user) {
throw new NotFoundException(`Not found user with id: ${userData.userId}`);
}
board.likes = board.likes.filter((user) => user.id !== userData.userId);
board.like_cnt = board.likes.length;
const updatedBoard = await this.boardRepository.save(board);
return { like_cnt: updatedBoard.like_cnt };
}
잘못된 요청이 있으면 400 및 404 에러 처리를 해준다.
- PATCH /board/:id/like
- PATCH /board/:id/unlike
© 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(화)