Skip to content

1212(화) 회의록

박재하 edited this page Dec 12, 2023 · 4 revisions

발표 ppt에 넣을 내용

💡 **그룹프로젝트 6주간 쌓아온 기술적 경험의 기승전결을 발표합니다.** 프로젝트 과정에서 있었던 팀과 개인의 핵심 경험을 중심으로 공유 내용을 정리해보세요. 청중들이 우리 팀의 발표를 더 쉽게 이해하고, 더 좋은 질문과 피드백을 제공할 수 있도록 사전에 프로젝트를 살펴볼수 있는 자료를 구비해야 합니다.

✔️ 발표 구성

  • 주요 도전거리와 결과를 중심으로 내용을 구성합니다.
  • 팀의 **기술적 경험**에 집중하여 발표합니다.
    • 기술 선택 이유, 과정에서 발생한 주요 이슈, 해결 과정, 새롭게 배운 +점 등 기승전결을 담아보세요.
    • 짧은 시간에 모든 내용을 발표하기는 어려울 수 있기 때문에, 선택과 집중을 고려해도 좋습니다
  • 서비스 기획 & 기능 소개, 동작 데모 등은 발표의 맥락이 되는 내용을 중심으로 최소화하여 공유합니다.

발표

  • 프로젝트 소개
    • 프로젝트 주제 소개
    • 이 주제를 선택한 이유
  • 서비스 데모
    • 보여주면서 기능 설명하기
  • 기술스택 소개
  • 프론트
    • Three.js + R3F
      • 정보가 너무 없다
      • 수학 어렵다
      • 최적화🙃
    • zustand
    • 라우터??
      • 페이지는 사실상 2개이고 기능들을 모달로 처리함
      • 이라는 하나의 컴포넌트에 /home, /guest, /search 세 개의 라우터가 붙어서
    • 에러처리?
  • 백엔드
    • 기술적 도전
      • 둘 다 제대로된 웹 프로젝트가 처음이라 NestJS, Nginx, docker등 모든 것이 기술적 도전이었음
      • TDD + NestJS Lifecycle
      • 인프라 및 배포

팀 및 팀원 소개

안녕하세요 저희는 프론트 김동민, 김가은, 이백범, 백엔드 송준섭, 박재하로 이루어진 Web16 전 다 좋아요 팀입니다.

프로젝트 소개

웹 3D 기반 일기

저희 팀은 우리의 다양한 기억들을 시각화하여 보고 싶어 웹 3D 기반 일기 서비스인 “별 하나에 글 하나”를 기획하고 구현하였습니다.

서비스를 구현할 때 사용한 기술스택들은 다음과 같습니다. 각 기술들을 선정할 때는 장단점들을 열심히 분석하여 결정했고, 그 과정은 팀원들의 블로그에 잘 정리해뒀습니다. 자세한 정보가 궁금하시면 레포지토리 위키를 참조해주세요.

지금부터 데모 발표를 시작하겠습니다.

저희 팀은 다양한 기억들을 시각화하여 볼 수 있는 서비스를 만들기 위해 웹 3D 기반 일기 서비스를 개발하였습니다. 자신만의 우주에

시각화한 일기로 자신만의 은하를 꾸며, 다른사람들에게 공유할수 있는 서비스 “별 하나에 글 하나”를 기획하고 구현하였습니다. 지금부터 데모 발표를 시작하겠습니다.

데모

랜딩 페이지 - 뒤 은하 + 배경음악(재하님이 만드심!!!!)

로그인 - Oauth도 가능함!

워프 스크린

메인페이지

코치마크

leva

글 작성 - 마크다운

별 커스텀 - 감정 분석 및 여러가지 옵션 선택가능

마우스 컨트롤, 포스트 이동 보여주기

호버 라벨 보여주기

작성한 글 보여주기 - 글 수정, 삭제

은하 커스텀

공유하기 - 링크 복사해서 줌 채팅에 공유, 시크릿창으로

검색하기 - 좋아요~

서비스의 랜딩페이지에서는 저희가 직접만든 은하를 마우스 움직임을 통해 조작해볼수 있습니다. 또한 백엔드 팀원이신 재하님이 만드신 배경음악을 버튼을 통해 재생하고 뮤트할 수 있습니다.

로그인 페이지에서는 회원가입을 통해 아이디를 생성할 수 있고, 소셜아이디를 통한 Oauth 로그인도 진행할 수 있습니다.

로그인을 진행하면 메인페이지로 이동하는 사이에 멋진 로딩 애니메이션을 확인할 수 있습니다.

메인페이지에 도착하면 사용자는 코치마크를 통해 서비스의 소개와 대략적인 사용방법을 익힐 수 있습니다.

다음으로 위쪽에 존재하는 컨트롤 박스를 통해 은하의 밝기, 흐림 효과, 마우스 휠 속도를 조정할 수 있습니다.

아래의 언더바에 존재하는 글 작성하기 버튼을 통해 새로운 글을 작성하고 별을 생성할 수 있습니다. 마크다운 문법을 통해 글을 작성할 수 있고, Preview 뷰를 통해 작성한 마크다운 문법을 확인할 수 있습니다. 또한 사진 버튼을 클릭하는 것으로 원하는 사진도 업로드 할 수 있습니다.

다음 버튼을 클릭하면 생성될 별을 사용자의 기호대로 커스텀할 수 있습니다. 별의 색깔, 크기, 모양 등을 선택할 수 있고, 글의 내용을 토대로 AI 감정분석을 통해 색깔을 추천받을 수도 있습니다. 별의 형태를 결정하고 저장 버튼을 클릭하면 작성한 글 내용과 별 형태에 따라 사용자의 은하에 새로운 별이 생성됩니다.

은하에서 사용자는 마우스 클릭, 드래그, 휠을 통해 은하 공간을 탐험할 수 있습니다. 별에 마우스를 올리면 해당 별글의 제목을 확인할 수 있고, 한 번 클릭 시 카메라가 해당 별로 접근하며, 한번 더 클릭하면 글의 내용을 확인할 수 있습니다. 여러장의 이미지를 슬라이드를 통해 확인할 수 있으며, 글 수정, 삭제도 가능합니다. 수정 시엔 글 작성과 마찬가지로 마크다운 문법을 통해 작성할 수 있고, 삭제 버튼을 클릭하면 글이 삭제되며 은하에서도 별이 사라집니다.

언더바의 은하 커스텀 버튼을 클릭하면 사용자의 은하를 변경할 수 있습니다. 은하의 여러가지 부분들을 조정하고 저장하기 버튼을 통해 메인페이지의 은하에 해당 내용을 반영시킬 수 있습니다.

또한 언더바의 공유하기 버튼을 통해 사용자의 은하를 다른 사람들과 공유할 수 있는 링크를 확인 및 복사할 수 있으며, 자신의 은하를 공개하고 싶지 않은 사용자는 검색 허용을 체크 해제하는 것으로 검색되지 않게 만들 수 있습니다.

마지막으로 검색 바를 통해 다른 사용자들의 은하를 확인할 수 있으며, 해당 은하로 이동해 은하의 모습, 별글들을 구경하고 좋아요 표시를 남길 수 있습니다.

이상으로 저희 별하나에 글 하나 서비스 주요기능 소개를 마치겠습니다. 감사합니다.

프론트

기술스택

저희팀 프론트엔드는 3D 화면을 구현하기 위해 Three.js와 React-Three-Fiber를 사용하게 되었는데요. 저희 모두 이 기술을 사용하는 것이 처음이었기 때문에 많은 시행착오를 겪었습니다. 그래서 오늘은 Three.js와 React-Three-FIber에 관련한 저희의 경험을 위주로 이야기해보도록 하겠습니다.

먼저 가장 처음 겪은 문제는 카메라였습니다. 카메라는 3D 화면 상에서 사용자의 시점과 같다고 생각하면 되는데요. 카메라의 자연스러운 움직임이 사용자 경험에 직결되기 때문에 다양한 시도를 해봤습니다.

(냅다 아이패드 고민의 흔적 보여주기)

별을 클릭하면 현재 위치에서 해당 별을 바라보도록 해야하는데요. 처음에는 카메라의 위치는 그대로 둔 채 시야만 회전하도록 했습니다. (단점 이야기) 그 이후 별에 가까이 가기 위해 직선으로 움직이는 방법을 택했습니다. (단점 이야기) 하지만 조금 더 자연스러운 움직임을 구현하고 싶어 회전 방식의 장점과 직선 방식의 장점을 가져와 포물선 움직임을 만들어냈습니다.

완벽한 구현을 했다고 생각했지만 아직도 문제점들이 있었습니다. 별과의 거리가 너무 멀어지면 별이 너무 작게 보였고, 거리가 먼 별에 가까워지기까지 너무 많은 시간이 걸렸습니다. 어찌보면 당연한 이야기일 수 있지만, 서비스를 이용하는 사용자 입장에서는 불편할 수 있는 요소였습니다. 그래서 거리가 먼 별에 갈 때는 속도를 빠르게 했고, 별이 거리에 비해서 커보이게 처리했습니다. 네 ^^ 배고프네요 / 연어 사케동 ^^ 머리아파

백범님 안녕하세요 🐧

안녕하세요

개발시 어려웠던 점

저희 프론트는 저희에게 생소한 기술인 threejs를 사용하기로 했는데요 저희 프론트 팀 전부가 threejs가 처음이었기 때문에 관련해서 많은 뭘 겪었지 시행착오?를 겪었습니다. 어려움을 겪었다. 아무튼 겪음

힘들었다

먼저 그것에 대해 이야기해보도록하겠습니다. 가장ㅁ먼저 겪었던 어려움은 카메라 입니다. 사용자의 시점

카메라가 중요한 이유 → 그래서 잘만들어야함 → 이를 고민함 (자료) → 결과

→ 추가적인 문제가 발생함 → 해결!

  • Three js
    • 카메라
      • 3D 공간에서 사용자가 움직여여 하다 보니 자연스러운 카메라 움직임이 매우 중요했다.
      • 회전→직선→회전+직선=포물선
      • 물체가 멀어지면 너무 작아진다
      • 이동하는 거리에 따라 속도 조절이 필요했다
      • 배운 점 : 사용자 경험을 향상시키기는것이 고민할 내용이 많고, 쉽지않다는것을 경험함. 3D여서 좀 더 거시기함
      • 수학적 지식을 학습 및 활용할 수 있는 좋은 기회였음 꽤나 재밌을지도?
    • 정보의 부족
      • OpenGL > WebGL > three.js > R3F
      • R3F는 공식문서도 그닥 친절하지 않고, 사용하는 사람도 많지 않아 정보가 부족했다. 그나마 three.js의 공식문서가 잘 작성되어 있어 많이 참고함, 팀원들끼리 열심히 고민하였음
      • 최적화
        • Instancing 기법 →
          • 은하의 별 하나하나가 각각의 인스턴스로 존재했는데, 은하를 하나의 인스턴스로 합쳐버림 ? 일까
          • draw call 최소화
          • CPU가 한 번에 많은 데이터를 처리하도록 → CPU 병목현상을 해결했다
        • 금요일 현황공유시간 때 최적화에 대한 지적을 많이 받았음
        • PerformanceMonitor → 프레임 드랍 발생시 해상도를 낮추는 방식으로 사용자의 흐름에 방해되지 않도록 개선
          • CPU가 여유가 생겼더니 GPU가 힘들어하는 경우가 생김 → 그래서 힘들면 대충 일하도록 해줌
    • 학습 과정에서 블로그 작성, 최대한 공식문서를 활용하려고 노력함!
  • 폴더구조
    • FSD 방식 적용
    • 프로젝트를 6개의 레이어로 나누고, 레이어들을 다시 슬라이스와 세그먼트들로 분할하여 관리하는 방식
    • 이를 통해 파일들을 더 확실히 분리하고 한눈에 들어오는 아키텍쳐를 설계할 수 있었음
    • 짱인듯?


  • 카메라
    • 위 두가지를 구현하는데 수학적 지식이 사용됨
  • 최적화
    • 3D 관련 정보가 적음
  • 전역 관리
    • 처음에는 zustand가 사용이 쉽고 간편하여 남용했는데, 그러다보니 문제가 많이 발생함 → 최대한 줄여보는 방식으로 해결
  • 라우팅
    • 초기에 라우팅을 확실하게 정의하지 않고 개발을 진행했는데, 그러다보니 갈아엎은 부분이 많았다. 좋은 경험이었음
    • 2개의 페이지만 갖고 있고, 나머지 기능들은 모두 모달을 띄워 진행했음 → 처리가 어려웠음
    • PrivateRoute, PublicRoute

  • 폴더구조
    • FSD 방식 적용
    • 프로젝트를 6개의 레이어로 나누고, 레이어들을 다시 슬라이스와 세그먼트들로 분할하여 관리하는 방식
    • 이를 통해 파일들을 더 확실히 분리하고 한눈에 들어오는 아키텍쳐를 설계할 수 있었음
    • 짱인듯?

백엔드

  • 본문
    • 테스트 (환경설정, TDD, e2e, 유닛, mocking)

      처음에는 아예 TDD를 목표로 하여 페어 프로그래밍으로 테스트 코드 작성을 시작 함

      각자 학습 후 함께 환경설정

      NestJS + Jest는 유닛 테스트를 작성할 때 각 계층(Controller, Service, ,,)을 Mocking하여 테스트하는 구조

      즉 Controller 계층을 유닛 테스트한다고 한다면, Controller 로직에 필요한 Service 계층 등은 Mocking하여 잘 실행된다고 가정하고 Controller 로직 그 자체에 대해서만 테스트코드를 작성

      그렇게 Controller 테스트 코드 작성을 하는 도중 조금 어색함을 느낌 (이렇게 하는 게 맞나?, 이게 무슨 의미가 있지?)

      사실 서비스의 핵심 로직은 Service 계층에 있음

      그 계층이 잘 실행됐다고 가정하고 테스트 코드를 짜려니 별로 짤 내용도 없고, 그저 Mocking한 리턴 값을 그대로 다시 그게 맞는 지 확인하는 느낌이었음

      Service 계층의 테스트 코드를 작성을 하려고 해도 정작 테스트할 필요가 있는 것은 데이터베이스와 통신하고 원하는 값이 나오는지 확인하는 부분 아닌가? 그 부분을 Mocking하면 의미가 있나? 라는 생각이 들었음

      그래서 멘토님께 질문을 함

      멘토님은 아직 서비스 로직이 복잡해지지 않아서 어색함을 느낄 수 있다, 로직이 좀 더 복잡해지고 서비스 규모가 커질수록 Mocking을 하더라도 그 과정을 테스트 하는 것이 중요할 수 있다라고 말씀을 해주심

      그리고 너무 간단한 테스트 코드만 작성되는 것 같으면 먼저 E2E 테스트를 작성하는 것 어떻겠냐고 추천을 해주심

      그 후 방향성이 잡혀 “실패하는 E2E 테스트 코드를 작성 → 기능 구현 → 테스트 코드 통과 확인” 과정으로 TDD를 진행함

      에러가 나오는 상황과, 잘 실행되는 상황을 작성을 하고 기능을 구현한 후 확인을 하니 내가 작성한 코드에 대한 믿음도 생기고, 다음에 코드를 수정을 하였을 때 테스트 코드를 통과하는 지만 확인을 하면 내가 생각한 대로 잘 작성이 되었는지 알 수 있어서 TDD의 장점이 이런 것이구나 라는 생각이 들었음

      아쉽게도 프로젝트가 진행이 되면서 나도 모르게 예전 습관대로 먼저 코드를 작성하고 테스트 코드를 나중에 작성하여 확인하는 식으로 되어 100퍼센트 TDD를 하진 못했지만, TDD를 하는 이유와 장점, 방법 등은 조금은 배울 수 있었음

      • 쿼리 로그, 트랜잭션 제어, 쿼리 최적화 (queryRunner, 인터셉터, transaction callback / query plan, queryBuilder)
    • 인증(cookie-session, JWT, OAuth2.0)

      세션 방식 vs JWT

      → 서버 부담을 조금 줄여줄 수 있는 JWT 선택

      → JWT를 만드는데 사용하는 비밀 키는 서버에서 환경변수로 저장

      Authorization 헤더에 Bearer 토큰 vs 쿠키에 저장

      → Authorization은 클라이언트에서 JWT를 저장해두었다가, 매 요청마다 수동으로 서버로 보내줘야함

      → 쿠키에 저장하지 않을 이유가 없다고 판단하여 쿠키에 저장

      RefreshToken + Redis 사용

      → AccessToken을 서버에서 확인할 때, 서버에서 별도로 저장한 데이터를 확인하지 않고 그 토큰이 유효한지만 확인함

      → 탈취를 당했을 때 이 사용자가 악성 사용자인지 구분할 방법이 없음

      → 그래서 AccessToken의 유효 기간을 짧게 하고, 그 AccessToken을 다시 발급하도록 도와주는 RefreshToken을 생성

      → 그리고 RefreshToken에 대한 정보는 Redis에 저장하여 비교 과정을 거침

      → 로그인한 정보는 영속적으로 저장할 필요는 없으므로 성능이 좋은 인메모리 데이터베이스인 Redis 활용

      만약 RefreshToken이 탈취당했다면?

      → RefreshToken을 1회성으로 해서 AccessToken을 새로 발급해줄 때 RefreshToken을 새로 갱신

      → 만약 RefreshToken을 탈취당했다면 다음 요청에서 토큰 정보가 다르면 그 유저 권한을 잠시 블락하고 다른 인증 절차(휴대폰 인증 등)

      근데 생각해보니 세션 방식이랑 별로 차이가 없어보임 → 보안과 성능 사이의 트레이드 오프

      RefreshToken 갱신은 하지 않기로 함

      그래서 구현한 로직은 다음과 같음

      1. 사용자가 로그인
      2. AccessToken, RefreshToken 생성 및 발급 (유저 정보, 유효 기간 정보 포함)
      3. Redis에 유저에 대한 RefreshToken값 저장
      4. 이후 요청이 들어오면 AccessToken 확인
      5. 유효하면 로그인 유저라고 판단
      6. 유효하지 않다면 RefreshToken 확인
      7. 유효하지 않다면 로그인하지 않은 유저라고 판단
      8. 유효하다면 Redis에 저장된 RefreshToken값과 비교
      9. 유효하다면 로그인 유저라고 판단, AccessToken 재발급
    • NestJS Request LifeCycle, Enhancers (로깅, 인증, 트랜잭션, 스웨거, …)

      1

      NestJS는 위와 같은 LifeCycle을 가짐

      Middleware

      Guard

      • Custom AuthGuard - CookieAuthGuard

        • 쿠키에서 AccessToken, RefreshToken 정보를 가져와서 인증/인가를 진행
        @Injectable()
        export class CookieAuthGuard extends AuthGuard('jwt') {
        	constructor(
        		private readonly jwtService: JwtService,
        		private readonly redisRepository: RedisRepository,
        	) {
        		super();
        	}
        
        	async canActivate(context: ExecutionContext): Promise<boolean> {
        		const request = context.switchToHttp().getRequest();
        		const response = context.switchToHttp().getResponse();
        
        		if (!request.cookies) {  // request에 쿠키가 없다면 로그인 하지 않았다고 판단
        			throw new UnauthorizedException('login is required');
        		}
        
        		const accessToken = request.cookies['accessToken'];
        		try {  // AccessToken이 유효하다면 
        			const { userId, username, nickname, status } =
        				this.jwtService.verify(accessToken);
        
        			request.user = { userId, username, nickname, status };
        			return true;
        		} catch (error) {}
        
        		const refreshToken = request.cookies['refreshToken'];
        		try {
        			const { userId, username, nickname, status } =
        				this.jwtService.verify(refreshToken);
        			request.user = { userId, username, nickname, status };
        		} catch (error) {
        			response.clearCookie(
        				JwtEnum.ACCESS_TOKEN_COOKIE_NAME,
        				cookieOptionsConfig,
        			);
        			response.clearCookie(
        				JwtEnum.REFRESH_TOKEN_COOKIE_NAME,
        				cookieOptionsConfig,
        			);
        			throw new UnauthorizedException('login is required');
        		}
        
        		if (
        			!(await this.redisRepository.checkRefreshToken(
        				request.user.username,
        				refreshToken,
        			))
        		) {
        			response.clearCookie(
        				JwtEnum.ACCESS_TOKEN_COOKIE_NAME,
        				cookieOptionsConfig,
        			);
        			response.clearCookie(
        				JwtEnum.REFRESH_TOKEN_COOKIE_NAME,
        				cookieOptionsConfig,
        			);
        			throw new UnauthorizedException('login is required');
        		}
        
        		const newAccessToken = await createJwt(
        			{
        				id: request.user.userId,
        				username: request.user.username,
        				nickname: request.user.nickname,
        				status: request.user.status,
        			},
        			JwtEnum.ACCESS_TOKEN_TYPE,
        			this.jwtService,
        		);
        		response.cookie(
        			JwtEnum.ACCESS_TOKEN_COOKIE_NAME,
        			newAccessToken,
        			cookieOptionsConfig,
        		);
        		return true;
        	}
        }
      • nestjs-rate-limit

        • ip당 요청 수 제한
        • 과도한 요청이 발생하면 많은 비용이 발생될 우려가 있는 스토리지 서버에 파일 업로드를 하는 글 작성이나 AI Sentiment를 활용하는 색상 추천 경우에 제한을 걸어둠

      Interceptor

      • 로깅
        1. Logger 썼고
        2. Custom Interceptor로 Req, Res
        3. Custom Interceptor로 Transaction 리팩토링 → 테스트코드 작성 중 문제점을 발견하여 철회

      Pipe

      • Validation Pipe
        • 클라잉언트에서 회원 가입, 로그인 등 요청 데이터를 dto로 가져올 때 형식 검사를 함

      Controller, Service, Repository

      • 클라이언트에 필요한 백엔드 로직 담당

      Exception Filter

      • 에러 로그 MongoDB에 저장
        • admin 페이지에서 MongoDB의 에러 로그 데이터를 불러와 시각화 하는 작업
        • 언제 어떤 에러가 발생했는지 확인 가능
    • 배포 (VPC, 방화벽, SSH, NAT Gateway, NGINX, Docker, Docker Compose, GitHub Actions, HTTPS, CORS, 플랫폼 종속성, 이미지 최적화)

      • VPC, public/private subnet 구성하고 비밀번호 관리, 포트 관리
        • SSH key forwarding, tunneling
        • MongoDB 설치때매 NAT Gateway까지..
      • docker, docker-compose로 도커네트워크 구성
      • NGINX 설정 관련 트러블 슈팅 admin
        • 최초엔 web/was 구분 (docker-compose로 리버스 프록시 구성)
        • 그 뒤엔 HTTPS 적용, 트러블 슈팅
        • 그 뒤엔 admin 페이지 적용, 트러블 슈팅
      • CORS 트러블 슈팅
        • 프론트 개발서버(localhost환경)에서 API 사용하기 위해 CORS 허용 설정 트러블 슈팅
          • 처음엔 origin 문제. enableCors()에 origin: true로 해결
          • 그 뒤에 쿠키 문제. httpOnly, secure 적용하고 sameSite:none, withCredentials:true, origin 명시 등등해서 해결
      • 배포 자동화
        • github actions, 플랫폼 종속성 문제 트러블 슈팅, 이미지 최적화
  • 사진자료
    • 테스트 코드

      처음에 포부는 100% TDD

      Mocking 세팅

      describe('AuthController', () => {
      	let controller: AuthController;
      	let service: AuthService;
      
      	beforeEach(async () => {
      		// 테스트용 모듈 생성
      		const module: TestingModule = await Test.createTestingModule({
      			controllers: [AuthController],
      			providers: [
      				AuthService,
      			],
      		}).compile();
      
      		// 모듈에서 controller, service 불러옴
      		controller = module.get<AuthController>(AuthController);
      		service = module.get<AuthService>(AuthService);
      	});
      
      	it('should be defined', () => {
      		expect(controller).toBeDefined();
      	});
      
      	it('signUp', async () => {
      		expect(controller.signUp).toBeDefined();
      	
      		// service의 signUp 로직을 Mocking (잘 실행 돼서 서비스의 signUp 결과가 다음과 같이 나왔다고 가정)
      		jest.spyOn(service, 'signUp').mockImplementation(async () => {
      			return {
      				id: 1,
      				username: 'test',
      				nickname: 'test',
      				password: 'test'
      			};
      		});
      		
      		// controller.signUp의 결과를 result 변수에 저장
      		const result = await controller.signUp({
      			username: 'test',
      			nickname: 'test',
      			password: 'test',
      		});
      	
      		// 결과에 대한 테스트 코드 작성
      		expect(result).toBeDefined();
      		expect(result).toHaveProperty('id');
      		expect(result).toHaveProperty('username');
      		expect(result.username).toBe('test');
      		expect(result).toHaveProperty('nickname');
      		expect(result.nickname).toBe('test');
      	});
      }

      이 때의 Controller의 signUp 로직은 아래와 같음

      @Post('signup')
      signUp(@Body() signUpUserDto: SignUpUserDto) {
      	return this.authService.signUp(signUpUserDto);
      }

      너무 간단하다보니 이렇게 Service 로직을 Mocking을 하고 그것을 그대로 바로 테스트하는 테스트 코드를 작성하는 것에 회의감이 들었음

      멘토님에게 상담 → 아직 서비스 로직이 너무 간단해서 그럴 수 있다, 대신 E2E 테스트를 작성하는 방법으로 하는 것이 어떤가 라는 말씀을 해주심

      E2E 테스트 코드 작성

      방향성이 잡혀 E2E 테스트를 먼저 작성하는 방식으로 바꿈

      “todo 작성 → 실패하는 테스트 코드 추가 → Api 구현 → 테스트 코드 동작 확인”의 순서로 TDD를 하고자 함

      1. todo 작성

      미리 생각해놓은 이슈에 대하여 작성해야할 테스트코드 todo를 미리 작성

      describe('BoardController (e2e)', () => {
      	let app: INestApplication;
      
      	beforeEach(async () => {
      		const moduleFixture: TestingModule = await Test.createTestingModule({
      			imports: [AppModule],
      		}).compile();
      
      		app = moduleFixture.createNestApplication();
      		await app.init();
      	});
      
      	describe('/board', () => {
      		// #39 [06-02] 서버는 사용자의 글 데이터를 전송한다.
      		it.todo('GET /board/:id');
      
      		// (추가 필요) 서버는 사용자의 글 목록을 전송한다.
      		it.todo('GET /board');
      
      		...
      	});
      });
      1. 실패하는 테스트 코드 작성 (Red)
      describe('/board', () => {
      	...
      
      	// #60 [08-06] 서버는 전송 받은 데이터를 데이터베이스에 저장한다.
      	it('POST /board', async () => {
      		const newBoard = {
      			title: 'test',
      			content: 'test',
      		};
      
      		const response = await request(app.getHttpServer()))
      			.post('/board')
      			.send(board)
      			.expect(201)  // 응답 상태 코드가 201인지 확인
      		
      		expect(response).toHaveProperty('body'); // 응답에 body가 있는지 확인
      		const body = response.body;
      		
      		expect(body).toHaveProperty('id');  // 결과가 id 속성을 가지고 있는지
      		expect(body).toHaveProperty('title');  // 결과가 title 속성을 가지고 있는지
      		expect(body.title).toBe('test');  // title 속성값이 'test'인지
      		expect(body).toHaveProperty('content');  // 결과가 content 속성을 가지고 있는지
      		expect(body.content).toBe('test');  // content 속성값이 'test'인지
      	});
      
      	...
      });

      1 67ad175143f2/67d57824-e486-42f3-b464-667751a636bb/Untitled.png)

      1. 기능 구현
      // board.controller.ts
      @Post()
      create(@Body() createBoardDto: CreateBoardDto): Promise<Board> {
      	return this.boardService.create(createBoardDto);
      }
      // board.service.ts
      async create(createBoardDto: CreateBoardDto): Promise<Board> {
      	const { title, content } = createBoardDto;
      
      	const board = this.boardRepository.create({
      		title,
      		content,
      	});
      	const created: Board = await this.boardRepository.save(board);
      
      	return created;
      }
      1. 테스트코드 확인 (Green)

      1

      이렇게 API 구현 전에 테스트코드를 먼저 작성하고 구현 후 동작을 확인하니 코드에 대한 검증도 쉬웠고, 추후에 수정도 쉬웠음

      사실 E2E 테스트만 작성하고 유닛 테스트같은 경우는 마지막에 따로 작성하여 100% TDD를 했다고는 못하겠지만, TDD를 하는 이유와 장점, 방법 등을 조금은 배울 수 있었음

    • 트랜잭션

      1차: transaction 제어 없이 구현

      async updateBoard(
      	id: number,
      	updateBoardDto: UpdateBoardDto,
      	userData: UserDataDto,
      	files: Express.Multer.File[],
      ) {
      	const board: Board = await this.findBoardById(id);
      
      	...
      
      	const updatedBoard: Board = await this.boardRepository.save({
      		...board,
      		...updateBoardDto,
      	});
      	return updatedBoard;
      }

      2차: createQueryRunner와 startTransaction, try-catch문을 활용한 transaction 제어

      async updateBoard(
      		id: number,
      		updateBoardDto: UpdateBoardDto,
      		userData: UserDataDto,
      		files: Express.Multer.File[],
      	) {
      		// transaction 생성하여 board, image, star, like 테이블 동시에 수정
      		const queryRunner = this.dataSource.createQueryRunner();
      		await queryRunner.connect();
      
      		// const board: Board = await this.boardRepository.findOneBy({ id });
      		const board: Board = await queryRunner.manager.findOneBy(Board, { id });
      		...
      
      		// transaction 시작
      		await queryRunner.startTransaction();
      		try {
      			...
      			const updatedBoard: Board = await queryRunner.manager.save(Board, {
      				...board,
      				...updateBoardDto,
      			});
      
      			// commit Transaction
      			await queryRunner.commitTransaction();
      			...
      			return updatedBoard;
      		} catch (err) {
      			Logger.error(err);
      			await queryRunner.rollbackTransaction();
      		} finally {
      			await queryRunner.release();
      		}
      	}
      • before: board테이블과 연관된 image테이블 수정 시 하나의 transaction에 들어가지 않아 에러 시 롤백이 정상적으로 수행되지 않음
      bef
      • after: queryRunner의 transaction 기능을 활용하여 하나의 transaction에 모든 수정 로직이 들어가도록 변경
      aft

      3차: custom interceptor를 활용한 transaction 제어

      @Injectable()
      export class TransactionInterceptor implements NestInterceptor {
      	constructor(private readonly dataSource: DataSource) {}
      
      	async intercept(
      		context: ExecutionContext,
      		next: CallHandler<any>,
      	): Promise<Observable<any>> {
      		const req = context.switchToHttp().getRequest();
      		const path = req.originalUrl;
      		const method = req.method;
      
      		const queryRunner: QueryRunner = this.dataSource.createQueryRunner();
      		await queryRunner.connect();
      		await queryRunner.startTransaction();
      
      		...
      		req.queryRunner = queryRunner;
      
      		return next.handle().pipe(
      			catchError(async (error) => {
      				await queryRunner.rollbackTransaction();
      				await queryRunner.release();
      
      				...
      			}),
      			tap(async () => {
      				await queryRunner.commitTransaction();
      				await queryRunner.release();
      
      				...
      			}),
      		);
      	}
      }
      async updateBoard(
      		id: number,
      		updateBoardDto: UpdateBoardDto,
      		userData: UserDataDto,
      		files: Express.Multer.File[],
      		queryRunner: QueryRunner,
      	) {
      		const board: Board = await queryRunner.manager.findOneBy(Board, { id });
      		...
      		const updatedBoard: Board = await queryRunner.manager.save(Board, {
      			...board,
      			...updateBoardDto,
      		});
      
      		...
      		return updatedBoard;
      	}

      4차: DataSource.transaction() 방식

      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,
      			});
      
      			...
      		});
      
      		return createdBoard;
      	}

소개

규칙

학습 기록

[공통] 개발 기록

[재하] 개발 기록

[준섭] 개발 기록

회의록

스크럼 기록

팀 회고

개인 회고

멘토링 일지

Clone this wiki locally