Skip to content

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

박재하 edited this page Nov 16, 2023 · 3 revisions

목표

  • 리뷰 피드백 반영한 것 충돌 없이 PR merge
  • board, auth CRUD 완성
  • docker로 FE, BE 모두 배포해보기, nginx 추가
    • nginx 역할 : FE 빌드해서 적정 파일 서빙, BE 리버스 프록시로 /api 경로에 매핑
    • 가능하다면 docker-compose와 github actions로 자동 배포도 시도하기

체크리스트

  • 갈라진 branch, 충돌 해결해서 rebase하기
  • GET /board (#84 [06-13])
  • GET /board/by-author (#65 [09-03])
  • PATCH /board/:id/like (#45 [06-08])
  • PATCH /board/:id/unlike (#45 [06-08])
  • PATCH /board/:id (#88 [06-14])
  • DELETE /board/:id (#89 [06-15])
  • auth 모듈과 관련 e2e 테스트 구상
  • POST /auth/signup
  • POST /auth/signin
  • GET /auth/signout
  • GET /auth/is-available-username, GET /auth/is-available-nickname

갈라진 branch, 충돌 해결해서 rebase하기

스크린샷 2023-11-16 오후 12 26 19

어제 PR을 두 개 날렸는데, 첫 PR의 리뷰 피드백에서 수정소요가 발생해서 브랜치가 갈라졌다.

그런데 두 번째 PR이 같은 파일에서 수정이 일어나 자동 rebase가 안돼서 충돌을 해결해서 보내야 하는 상황!

스크린샷 2023-11-16 오후 12 34 01

그렇다고 위 그림과 같이 merge를 해서 보내면 커밋 로그들이 안남고 master에 merge 커밋만 남는다. 이러면 안되니 rebase를 하긴 해야함

스크린샷 2023-11-16 오후 12 35 05 스크린샷 2023-11-16 오후 12 36 15

요런식으로!

스크린샷 2023-11-16 오후 12 37 44

어떻게 하냐면 위 그림도 일단 git rebase를 걸어놓고 충돌 해결 후 git add, 그리고 그 후에 git rebase --continue를 진행하라고 한다.

첫 PR에서 수정한 코드들은 두번째 PR 뒤에도 반영되어야 하므로 이전에 했던 --skip 방식을 쓰면 안된다.

rebase --skip을 통한 충돌 해결 사례는 이전에 작성한 다음 포스팅 참고.

git switch [B]
git rebase [A]
# IDE에서 직접 충돌 해결 후
git add .
git rebase --continue

아무튼 대략적으로 A에 추가된 커밋를 B에 rebase시키는 흐름은 위와 같다. 해보자.

스크린샷 2023-11-16 오후 12 48 57

??

스크린샷 2023-11-16 오후 12 49 16

?????

스크린샷 2023-11-16 오후 12 48 46 스크린샷 2023-11-16 오후 12 49 35

??????? 뭐야 충돌 없이 이전 커밋들도 반영 잘 되네? 뭐임 ㅋ

설레발치고 온갖소리 다했지만 git이 결국 파일이 아닌 line-by-line으로 충돌을 검사해서 rebase 명령만으로 알아서 처리가 되나보다 ㅋ.. 쉽게 문제 해결.

그래도 앞으로 충돌이 나면 그때 위에서 조사한 방식으로 해결하면 될듯. 끝!

스크린샷 2023-11-16 오후 12 54 13

참고로 push는 force로 해주셔야 함

git push -f origin feat/get-board-by-id
# 이후 PR에 commit들 바뀌어있나 확인

GET /board

RED

// board.e2e-spec.ts
// (추가 필요) 서버는 사용자의 글 목록을 전송한다.
it('GET /board', async () => {
	const response = await request(app.getHttpServer()).get('/board').expect(200);

	expect(response).toHaveProperty('body');
	expect(response.body).toBeInstanceOf(Array);

	const boards = response.body as Board[];
	if (boards.length > 0) {
		expect(boards[0]).toHaveProperty('id');
		expect(boards[0]).toHaveProperty('title');
	}
});

위와 같이 테스트 코드를 작성해줬다!

스크린샷 2023-11-16 오후 1 33 06

실패하는 테스트 코드 완성

GREEN

repository에서 가져와서 board 리스트를 가져와서 넘겨주는 service코드를 작성한다.

// board.service.ts
async findAll() {
  const boards = await this.boardRepository.find();
  return boards;
}
스크린샷 2023-11-16 오후 1 37 26

통과!

REFACTOR

// board.controller.ts
@Get()
findAllBoards(): Promise<Board[]> {
  return this.boardService.findAllBoards();
}
// board.service.ts
async findAllBoards(): Promise<Board[]> {
  const boards = await this.boardRepository.find();
  return boards;
}

타입 명시 및 메소드명 변경

스크린샷 2023-11-16 오후 1 41 32

마찬가지로 잘 통과되고

스크린샷 2023-11-16 오후 1 41 57

postman에서 리스트도 한 번 확인해봄

GET /board/by-author

RED

author를 하드코딩 할 게 아니라 직접 넣어줘야 하는게 아닐까 하는 논의가 나와서, 만들어둔 POST /board 메소드를 이용해서 앞단에 직접 특정 author의 게시물을 넣어줬다.

근데 그러면 이게 찾아진 게 해당 author인지 확인하는 것도 넣어야 하는 게 아닌가?

그런식으로 또 GET /board/:id 메소드를 이용해서 author 확인도 해주고 그래보았다.

// #65 [09-03] 서버는 검색된 사용자의 글 데이터를 전송한다.
it('GET /board/by-author', async () => {
	const author = 'testuser';
	const board = {
		title: 'test',
		content: 'test',
		author,
	};
	await request(app.getHttpServer()).post('/board').send(board).expect(201);

	const response = await request(app.getHttpServer())
		.get(`/board/by-author?author=${author}`)
		.expect(200);

	expect(response).toHaveProperty('body');
	expect(response.body).toBeInstanceOf(Array);

	const boards = response.body as Board[];
	if (boards.length > 0) {
		expect(boards[0]).toHaveProperty('id');
		expect(boards[0]).toHaveProperty('title');
	}

	const id = boards[0].id;

	const response2 = await request(app.getHttpServer())
		.get(`/board/${id}`)
		.expect(200);

	expect(response2.body.author).toBe(author);
});

근데 이런 식이면 TDD라기 보단 그냥 한도끝도 없을 것 같아서, 구현을 위한 테스트라는 본질이 흐려지지 않게 적절히 상한을 둘 필요가 있다고 판단됨.

결국 다음으로 합의

// board.e2e-spec.ts
// #65 [09-03] 서버는 검색된 사용자의 글 데이터를 전송한다.
it('GET /board/by-author', async () => {
	const author = 'testuser';
	const board = {
		title: 'test',
		content: 'test',
		author,
	};
	await request(app.getHttpServer()).post('/board').send(board);

	const response = await request(app.getHttpServer())
		.get(`/board/by-author?author=${author}`)
		.expect(200);

	expect(response).toHaveProperty('body');
	expect(response.body).toBeInstanceOf(Array);

	const boards = response.body as Board[];
	expect(boards.length).toBeGreaterThan(0);
	expect(boards[0]).toHaveProperty('id');
	expect(boards[0]).toHaveProperty('title');
});
스크린샷 2023-11-16 오후 2 08 21

GREEN

// board.controller.ts
@Get('by-author')
findAllBoardsByAuthor(@Query('author') author: string): Promise<Board[]> {
  return this.boardService.findAllBoardsByAuthor(author);
}

쿼리 파라미터는 위와 같이 가져올 수 있다. 학습메모 5 참고~

// board.service.ts
async findAllBoardsByAuthor(author: string): Promise<Board[]> {
  const boards = await this.boardRepository.findBy({ author });
  return boards;
}
스크린샷 2023-11-16 오후 2 17 45

REFACTOR

구현 과정에서 타입을 명시해서 리팩터 과정은 딱히 필요없는걸로!

PATCH /board/:id/like

RED

// board.e2e-spec.ts
it('PATCH /board/:id/like', async () => {
	const board = {
		title: 'test',
		content: 'test',
		author: 'test',
	};
	const createdBoard = (
		await request(app.getHttpServer()).post('/board').send(board)
	).body;
	expect(createdBoard).toHaveProperty('like_cnt');
	const cntBeforeLike = createdBoard.like_cnt;

	const resLike = await request(app.getHttpServer())
		.patch(`/board/${createdBoard.id}/like`)
		.expect(200);

	expect(resLike).toHaveProperty('body');
	expect(resLike.body).toHaveProperty('like_cnt');
	const cntAfterLike = resLike.body.like_cnt;

	expect(cntAfterLike).toBe(cntBeforeLike + 1);
});

새로운 보드 생성 후, like_cnt가 반환되는지 확인하고, PATCH /board/:id/like 요청 후 like_cnt가 1 증가하는지를 본다.

스크린샷 2023-11-16 오후 2 47 17

GREEN & REFACTOR

// board.entity.ts
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Board extends BaseEntity {
	@PrimaryGeneratedColumn()
	id: number;

	@Column({ type: 'varchar', length: 255, nullable: false })
	title: string;

	@Column({ type: 'text', nullable: true })
	content: string;

	@Column({ type: 'varchar', length: 50, nullable: false })
	author: string;

	@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
	created_at: Date;

	@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
	updated_at: Date;

	@Column({ type: 'int', default: 0 })
	like_cnt: number;
}

entity에 int, default 0인 like_cnt 컬럼을 추가해주고

// board.controller.ts
@Patch(':id/like')
patchLike(@Param('id') id: string): Promise<Partial<Board>> {
  return this.boardService.patchLike(+id);
}
// board.service.ts
async patchLike(id: number): Promise<Partial<Board>> {
  const board = await this.findBoardById(id);
  board.like_cnt += 1;
  await this.boardRepository.save(board);
  return { like_cnt: board.like_cnt };
}

컨트롤러와 서비스 작성. 모든 데이터가 아닌 Patch된 like_cnt만 넘겨주는 것으로 하자.

스크린샷 2023-11-16 오후 3 00 15

잘 통과됨

스크린샷 2023-11-16 오후 3 05 28

postman으로도 해봄

PATCH /board/:id/unlike

RED

// #45 [06-08] 서버는 좋아요 / 좋아요 취소 요청을 받아 데이터베이스의 데이터를 수정한다.
it('PATCH /board/:id/unlike', async () => {
	const board = {
		title: 'test',
		content: 'test',
		author: 'test',
	};
	const createdBoard = (
		await request(app.getHttpServer()).post('/board').send(board)
	).body;
	expect(createdBoard).toHaveProperty('like_cnt');
	const cntBeforeUnlike = createdBoard.like_cnt;

	const resUnlike = await request(app.getHttpServer())
		.patch(`/board/${createdBoard.id}/unlike`)
		.expect(200);

	expect(resUnlike).toHaveProperty('body');
	expect(resUnlike.body).toHaveProperty('like_cnt');
	const cntAfterUnlike = resUnlike.body.like_cnt;

	expect(cntAfterUnlike).toBe(cntBeforeUnlike - 1);
});

like와 유사하게 하나 줄어드는지만 봄

스크린샷 2023-11-16 오후 3 10 59

GREEN & REFACTOR

// board.controller.ts
@Patch(':id/unlike')
patchUnlike(@Param('id') id: string): Promise<Partial<Board>> {
  return this.boardService.patchUnlike(+id);
}
// board.service.ts
async patchUnlike(id: number): Promise<Partial<Board>> {
  const board = await this.findBoardById(id);
  board.like_cnt -= 1;
  await this.boardRepository.save(board);
  return { like_cnt: board.like_cnt };
}
스크린샷 2023-11-16 오후 3 13 27 스크린샷 2023-11-16 오후 3 16 09

PATCH /board/:id

RED

// (추가 필요) 서버는 사용자의 요청에 따라 글을 수정한다.
it('PATCH /board/:id', async () => {
	const board = {
		title: 'test',
		content: 'test',
		author: 'test',
	};
	const createdBoard = (
		await request(app.getHttpServer()).post('/board').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(`/board/${id}`)
		.send({ title: 'updated', content: 'updated' })
		.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(toUpdate.content);
});

게시글의 title, content 수정 후, 수정한 것이 반영되었는지를 검사한다.

스크린샷 2023-11-16 오후 3 35 07

GREEN & REFACTOR

// board.controller.ts
@Patch(':id')
updateBoard(@Param('id') id: string, @Body() updateBoardDto: UpdateBoardDto) {
  return this.boardService.updateBoard(+id, updateBoardDto);
}
// board.service.ts
async updateBoard(id: number, updateBoardDto: UpdateBoardDto) {
  const board: Board = await this.findBoardById(id);

  const updatedBoard: Board = await this.boardRepository.save({
    ...board,
    ...updateBoardDto,
  });
  return updatedBoard;
}

추가로 Update 될 때 자동으로 시간이 갱신되도록 entity에서 create_at, update_at을 각각 CreateDateColumn(), UpdateDateColumn()으로 어노테이션을 수정해줬다.

// board.entity.ts
import {
	BaseEntity,
	Column,
	CreateDateColumn,
	Entity,
	PrimaryGeneratedColumn,
	UpdateDateColumn,
} from 'typeorm';

@Entity()
export class Board extends BaseEntity {
	@PrimaryGeneratedColumn()
	id: number;

	@Column({ type: 'varchar', length: 255, nullable: false })
	title: string;

	@Column({ type: 'text', nullable: true })
	content: string;

	@Column({ type: 'varchar', length: 50, nullable: false })
	author: string;

	@CreateDateColumn()
	created_at: Date;

	@UpdateDateColumn()
	updated_at: Date;

	@Column({ type: 'int', default: 0 })
	like_cnt: number;
}
스크린샷 2023-11-16 오후 3 47 13

테스트 잘 통과됨

스크린샷 2023-11-16 오후 3 51 20 스크린샷 2023-11-16 오후 3 51 45

DELETE /board/:id

RED

// board.e2e-spec.ts
// (추가 필요) 서버는 사용자의 요청에 따라 글을 삭제한다.
it('DELETE /board/:id', async () => {
	const board: CreateBoardDto = {
		title: 'test',
		content: 'test',
		author: 'test',
	};
	const newBoard = (
		await request(app.getHttpServer()).post('/board').send(board)
	).body;

	await request(app.getHttpServer())
		.delete(`/board/${newBoard.id}`)
		.expect(200);

	await request(app.getHttpServer()).get(`/board/${newBoard.id}`).expect(404);
});
스크린샷 2023-11-16 오후 3 57 38

GREEN & REFACTOR

// board.controller.ts
@Delete(':id')
deleteBoard(@Param('id') id: string): Promise<void> {
  return this.boardService.deleteBoard(+id);
}
// board.service.ts
async deleteBoard(id: number): Promise<void> {
  const result = await this.boardRepository.delete({ id });
}

없으면 404뜨는 remove 대신 delete 메소드를 활용했다.

스크린샷 2023-11-16 오후 4 05 56

auth 모듈과 관련 e2e 테스트 구상

// #12 [02-05] 서버는 아이디 중복을 검사하고 결과를 클라이언트에 전송한다.
// #16 [02-09] 서버는 회원가입 데이터를 받아 형식 검사와 아이디 중복검사를 진행한다.
// #17 [02-10] 검사에 통과하면 회원 정보를 데이터베이스에 저장한다.
// #20 [03-02] 사용자가 정보제공을 허용하여 콜백 API 요청을 받으면, 백엔드 서버는 요청에 포함된 코드를 통해 해당 서비스의 인가 서버에 액세스 토큰을 요청한다.
// #21 [03-03] 액세스 토큰을 전달받으면, 백엔드 서버는 액세스 토큰을 통해 해당 서비스의 리소스 서버에 사용자 정보를 요청한다.
// #22 [03-04] 사용자 정보를 전달받으면, 필요한 속성만 추출하여 회원 정보를 데이터베이스에 저장한다.
// #27 [04-04] 데이터베이스에서 로그인 데이터로 조회를 하여 비교한다.
// #28 [04-05] 없는 회원 정보라면 NotFoundError를 응답한다.
// #29 [04-06] 있는 회원 정보라면 JWT(Access Token 및 Refresh Token)를 발급하고 쿠키에 저장한다.
// #30 [04-07] JWT에 대한 Refresh Token을 Redis에 저장한다.
// #33 [05-02] 로그아웃 요청을 받으면 토큰을 읽어 해당 회원의 로그인 여부를 확인한다.
// #34 [05-03] 로그인을 하지 않은 사용자의 요청이라면 BadRequest 에러를 반환한다.
// #35 [05-04] 로그인을 한 사용자라면 Redis의 Refresh Token 정보를 삭제한다.
// #36 [05-05] 브라우저 쿠키의 JWT를 없애는 요청을 보낸다.

이슈부터 긁어왔다. 각 이슈를 분석해 만들어야 할 API를 명세함

// auth.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';

describe('AuthController (/auth, e2e)', () => {
	let app: INestApplication;

	beforeEach(async () => {
		const moduleFixture: TestingModule = await Test.createTestingModule({
			imports: [AppModule],
		}).compile();

		app = moduleFixture.createNestApplication();
		await app.init();
	});

	// #12 [02-05] 서버는 아이디 중복을 검사하고 결과를 클라이언트에 전송한다.
	it.todo('GET /auth/check-duplicate-username');

	// #91 [02-12] 서버는 닉네임 중복을 검사하고 결과를 클라이언트에 전송한다.
	it.todo('GET /auth/check-duplicate-nickname');

	// #16 [02-09] 서버는 회원가입 데이터를 받아 형식 검사와 아이디 중복검사를 진행한다.
	// #17 [02-10] 검사에 통과하면 회원 정보를 데이터베이스에 저장한다.
	it.todo('POST /auth/signup');

	// #20 [03-02] 사용자가 정보제공을 허용하여 콜백 API 요청을 받으면, 백엔드 서버는 요청에 포함된 코드를 통해 해당 서비스의 인가 서버에 액세스 토큰을 요청한다.
	// #21 [03-03] 액세스 토큰을 전달받으면, 백엔드 서버는 액세스 토큰을 통해 해당 서비스의 리소스 서버에 사용자 정보를 요청한다.
	// #22 [03-04] 사용자 정보를 전달받으면, 필요한 속성만 추출하여 회원 정보를 데이터베이스에 저장한다.
	it.todo('GET /auth/oauth/:service'); // OAuth2.0 서비스 로그인 페이지로 리다이렉트, 쿼리 스트링으로 client_id, scope 담아서 보냄
	it.todo('GET /auth/oauth/:service/callback');
	// OAuth2.0 서비스 로그인 후 리다이렉트, 쿼리 스트링으로 code 담아서 보냄.
	// 백엔드 서버는 해당 서비스의 인가 서버에 액세스 토큰을 요청한다.
	// 액세스 토큰을 전달받으면, 백엔드 서버는 액세스 토큰을 통해 해당 서비스의 리소스 서버에 사용자 정보를 요청한다.
	// 사용자 정보를 받으면, 유저 테이블을 조회하여 이미 가입한 회원인지 확인한다.
	// 이미 가입한 회원이라면, JWT를 발급하고 쿠키에 저장한다. -> JWT 리턴하는지 확인
	// 새로운 회원이라면, 클라이언트에 닉네임 정보를 받아오도록 요청함. 받아오면 회원 정보를 데이터베이스에 저장하고 JWT를 발급하고 쿠키에 저장한다. -> redirect 확인
	// 위 로직은 e2e 테스트가 힘들기 때문에 구현을 먼저 하는 것으로 결정.

	// #27 [04-04] 데이터베이스에서 로그인 데이터로 조회를 하여 비교한다.
	// #28 [04-05] 없는 회원 정보라면 NotFoundError를 응답한다.
	// #29 [04-06] 있는 회원 정보라면 JWT(Access Token 및 Refresh Token)를 발급하고 쿠키에 저장한다.
	// #30 [04-07] JWT에 대한 Refresh Token을 Redis에 저장한다.
	it.todo('POST /auth/signin');

	// #33 [05-02] 로그아웃 요청을 받으면 토큰을 읽어 해당 회원의 로그인 여부를 확인한다.
	// #34 [05-03] 로그인을 하지 않은 사용자의 요청이라면 BadRequest 에러를 반환한다.
	// #35 [05-04] 로그인을 한 사용자라면 Redis의 Refresh Token 정보를 삭제한다.
	// #36 [05-05] 브라우저 쿠키의 JWT를 없애는 요청을 보낸다.
	it.todo('POST /auth/signout');
});

코드에 나와있는 대로 OAuth2.0 로그인 및 회원가입에 대해서는 e2e 테스트가 힘들기 때문에, TDD에서 제외하기로 결정함.

POST /auth/signup

RED

// #16 [02-09] 서버는 회원가입 데이터를 받아 형식 검사와 아이디 중복검사를 진행한다.
// #17 [02-10] 검사에 통과하면 회원 정보를 데이터베이스에 저장한다.
// #16 [02-09] 서버는 회원가입 데이터를 받아 형식 검사와 아이디 중복검사를 진행한다.
// #17 [02-10] 검사에 통과하면 회원 정보를 데이터베이스에 저장한다.
it('POST /auth/signup', async () => {
	const randomeBytes = Math.random().toString(36).slice(2, 10);

	const newUser = {
		username: randomeBytes,
		nickname: randomeBytes,
		password: randomeBytes,
	};

	const response = await request(app.getHttpServer())
		.post('/auth/signup')
		.send(newUser)
		.expect(201);

	expect(response).toHaveProperty('body');
	const createdUser = response.body;
	expect(createdUser).toHaveProperty('id');
	expect(typeof createdUser.id).toBe('number');

	expect(createdUser).toMatchObject({
		username: newUser.username,
		nickname: newUser.nickname,
	});
});
스크린샷 2023-11-16 오후 5 39 51

GREEN & REFACTOR

// user.entity.ts
import {
	Column,
	CreateDateColumn,
	Entity,
	PrimaryGeneratedColumn,
} from 'typeorm';

@Entity()
export class User {
	@PrimaryGeneratedColumn()
	id: number;

	@Column({ type: 'varchar', length: 50, nullable: false, unique: true })
	username: string;

	@Column({ type: 'varchar', length: 100, nullable: false })
	password: string;

	@Column({ type: 'varchar', length: 50, nullable: false, unique: true })
	nickname: string;

	@CreateDateColumn()
	created_at: Date;
}
// create-user.dto.ts
export class CreateUserDto {
	username: string;
	password: string;
	nickname: string;
}

엔티티와 createDto부터 만들어주고

// auth.controller.ts
@Post('signup')
signUp(@Body() createUserDto: CreateUserDto): Promise<Partial<User>> {
  return this.authService.signUp(createUserDto);
}
yarn workspace server add bcrypt

bcrypt 모듈 설치 후

// auth.service.ts
import * as bcrypt from 'bcrypt';
...

async signUp(createUserDto: CreateUserDto): Promise<Partial<User>> {
	const salt = await bcrypt.genSalt();
	const hashedPassword = await bcrypt.hash(createUserDto.password, salt);

	const newUser = this.authRepository.create({
		...createUserDto,
		password: hashedPassword,
	});

	const createdUser: User = await this.authRepository.save(newUser);
	createdUser.password = undefined;

	return createdUser;
}

controller와 service를 만들어줬다.

생성 시엔 password hash시켜주고! 생성 후 반환할 때는 password는 빼주고!

스크린샷 2023-11-16 오후 6 06 13

통과!

스크린샷 2023-11-16 오후 6 07 20 스크린샷 2023-11-16 오후 6 17 34

hash도 잘 만들어진다!

POST /auth/signin

RED

// auth.e2e-spec.ts
it('POST /auth/signin', 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);

	newUser.nickname = undefined;
	const response = await request(app.getHttpServer())
		.post('/auth/signin')
		.send(newUser)
		.expect(200);

	expect(response).toHaveProperty('headers');
	expect(response.headers).toHaveProperty('set-cookie');
	const cookies = response.headers['set-cookie'];
	expect(cookies).toBeInstanceOf(Array);
	expect(cookies.length).toBeGreaterThan(0);
	expect(cookies[0]).toContain('accessToken');

	newUser.password = 'wrong password';
	await request(app.getHttpServer())
		.post('/auth/signin')
		.send(newUser)
		.expect(401);
});

실패하는 테스트 코드 작성. cookie를 어떻게 확인하는지를 잘 몰라 좀 헤맸다.

스크린샷 2023-11-16 오후 8 51 36

GREEN & REFACTOR

yarn workspace server add @nestjs/jwt @nestjs/passport passport passport-jwt

먼저 로그인, JWT 관련 모듈을 설치한다.

// jwt.config.ts
import { JwtModuleOptions } from '@nestjs/jwt';
import { configDotenv } from 'dotenv';
configDotenv();

const jwtConfig: JwtModuleOptions = {
	secret: process.env.JWT_SECRET,
	signOptions: {
		expiresIn: 3600,
	},
};

export { jwtConfig };
// auth.module.ts
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';

@Module({
	imports: [
		PassportModule.register({ defaultStrategy: 'jwt' }),
		JwtModule.register(jwtConfig),
		TypeOrmModule.forFeature([User]),
	],
	controllers: [AuthController],
	providers: [AuthService],
})
export class AuthModule {}

config 파일을 만들고 auth.module.ts에 JWT 모듈을 등록해준다.

// board.controller.ts
import {
	Controller,
	Get,
	Post,
	Body,
	Patch,
	Param,
	Delete,
	HttpCode,
	Res,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignUpUserDto } from './dto/signup-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
import { SignInUserDto } from './dto/signin-user.dto';
import { Response } from 'express';

@Controller('auth')
export class AuthController {
	constructor(private readonly authService: AuthService) {}
	...
	@Post('signin')
	@HttpCode(200)
	async signIn(
		@Body() signInUserDto: SignInUserDto,
		@Res({ passthrough: true }) res: Response,
	) {
		const result = await this.authService.signIn(signInUserDto);
		res.cookie('accessToken', result.accessToken, { httpOnly: true });

		return result;
	}
	...
}

이게 좀 어려운데, 일단 NestJS에서 기본적으로 Post 데코레이터를 등록하면 성공 시 201을 리턴해준다. 그래서 @HttpCode(200)를 붙여줘야 함.

그리고 보통 jwt 토큰을 관리하는 데 두 가지 방식이 있다.

  1. 그냥 리턴해줘서 client에서 이걸 로컬 스토리지 같은 데에 저장해뒀다가, 매번 Authorization 헤더Bearer ${accessToken} 요렇게 직접 쏴주게 하는 방법이 있고
  2. 서버 단에서 set-cookie로 쿠키에 저장해 뒀다가, Cookie 헤더로 매번 자동으로 실려오는 쿠키에서 accessToken을 확인하는 방법이 있다.

우리는 FE 담당 분들께 따로 Task를 주지 않아도 되고, 보안적으로도 더 안전하다고 알려져있는 cookie 방식을 사용하기로 결정. 그래서 res.cookie() 메소드를 이용해 httpOnly로 쿠키를 등록해줬다.

async signIn(signInUserDto: SignInUserDto): Promise<{ accessToken: string }> {
	const { username, password } = signInUserDto;

	const user = await this.authRepository.findOneBy({ username });

	if (user && (await bcrypt.compare(password, user.password))) {
		const payload = { username };
		const accessToken = await this.jwtService.sign(payload);

		return { accessToken };
	} else {
		throw new UnauthorizedException('login failed');
	}
}

마지막으로 서비스 단에서는 bcrypt 모듈을 활용해 검증하고, jwt 토큰을 활용해 토큰을 발급해준다.

스크린샷 2023-11-16 오후 9 27 42

드디어 완성!

GET /auth/signout

RED

// #33 [05-02] 로그아웃 요청을 받으면 토큰을 읽어 해당 회원의 로그인 여부를 확인한다.
// #34 [05-03] 로그인을 하지 않은 사용자의 요청이라면 BadRequest 에러를 반환한다.
// #35 [05-04] 로그인을 한 사용자라면 Redis의 Refresh Token 정보를 삭제한다.
// #36 [05-05] 브라우저 쿠키의 JWT를 없애는 요청을 보낸다.
it('GET /auth/signout', 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);

	newUser.nickname = undefined;
	await request(app.getHttpServer()).post('/auth/signin').send(newUser);

	const response = await request(app.getHttpServer())
		.get('/auth/signout')
		.expect(200);

	expect(response).toHaveProperty('headers');
	expect(response.headers).toHaveProperty('set-cookie');
	const cookies = response.headers['set-cookie'];

	expect(cookies.length).toBeGreaterThan(0);
	expect(cookies[0]).toBe(
		'accessToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly',
	);
});
스크린샷 2023-11-16 오후 9 45 01

GREEN & REFACTOR

// auth.controller.ts
@Get('signout')
async signOut(@Res({ passthrough: true }) res: Response) {
	res.clearCookie('accessToken', { path: '/', httpOnly: true });
	return { message: 'success' };
}

이건 controller만 넣어주면 된다. 간단!

스크린샷 2023-11-16 오후 9 58 48

GET /auth/is-available-username, GET /auth/is-available-nickname

RED

// auth.e2e-spec.ts
// #12 [02-05] 서버는 아이디 중복을 검사하고 결과를 클라이언트에 전송한다.
it('GET /auth/is-available-username', 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);

	await request(app.getHttpServer())
		.get(`/auth/is-available-username?username=${randomeBytes}`)
		.expect(409);

	await request(app.getHttpServer())
		.get(`/auth/is-available-username?username=${randomeBytes + '1'}`)
		.expect(200);

	await request(app.getHttpServer())
		.get(`/auth/is-available-username`)
		.expect(400);
});

// #91 [02-12] 서버는 닉네임 중복을 검사하고 결과를 클라이언트에 전송한다.
it('GET /auth/is-available-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);

	await request(app.getHttpServer())
		.get(`/auth/is-available-nickname?nickname=${randomeBytes}`)
		.expect(409);

	await request(app.getHttpServer())
		.get(`/auth/is-available-nickname?nickname=${randomeBytes + '1'}`)
		.expect(200);

	await request(app.getHttpServer())
		.get(`/auth/is-available-nickname`)
		.expect(400);
});

signup 후 요청하면 중복될테니 409(Conflict) 에러 리턴, 중복 없으면 200(OK) 리턴. 값 안넣으면 400(Bad Request) 에러 리턴.

스크린샷 2023-11-16 오후 10 16 06

자동 생성된 GET /auth/:id에 잡혀서 200이 리턴되어버림. 지워주고 다시 돌렸다.

스크린샷 2023-11-16 오후 10 19 03

GREEN & REFACTOR

// auth.controller.ts
@Get('is-available-username')
isAvailableUsername(@Query('username') username: string) {
	return this.authService.isAvailableUsername(username);
}

@Get('is-available-nickname')
isAvailableNickname(@Query('nickname') nickname: string) {
	return this.authService.isAvailableNickname(nickname);
}
// auth.service.ts
async isAvailableUsername(username: string): Promise<boolean> {
	if (!username) {
		throw new BadRequestException('username is required');
	}

	const user = await this.authRepository.findOneBy({ username });

	if (user) {
		throw new ConflictException('username already exists');
	} else {
		return true;
	}
}

async isAvailableNickname(nickname: string): Promise<boolean> {
	if (!nickname) {
		throw new BadRequestException('nickname is required');
	}

	const user = await this.authRepository.findOneBy({ nickname });

	if (user) {
		throw new ConflictException('nickname already exists');
	} else {
		return true;
	}
}
스크린샷 2023-11-16 오후 10 30 25

학습메모

  1. git merge 기초
  2. git rebase 기초
  3. git rebase와 conflict 해결
  4. git rebase --skip을 통한 충돌 해결 사례
  5. nestjs query parameter 받기
  6. set header authorization 방법

소개

규칙

학습 기록

[공통] 개발 기록

[재하] 개발 기록

[준섭] 개발 기록

회의록

스크럼 기록

팀 회고

개인 회고

멘토링 일지

Clone this wiki locally