Contents

패스트파이브 기업과제 회고록

FastFive 프리온보딩 기업과제

과제 진행간 개요

기간

프로젝트 수행 기간 : 2022.12.12. Mon ~ 2022. 12.22. Thu (11일간)
최초 정식 코드 배포일 : 2022.12.22. Thu
프로젝트 보수 기간 : 2022.12.23. Fri ~ 2022.12.27. Tue (5일간)

인원

프론트엔드 3명, 백엔드 2명

백엔드 기술스택

  • JavaScript, TypeScript, TYPEORM, mySQL, Node, Express

개발환경

  • OS : macOS
  • 개발 tool : WebStrom, DBeaver, Postman
  • 형상관리 : GitHub
  • 문서관리 및 티켓관리 : Notion
  • 협업 툴 : Slack

백엔드 기능분담

  • ERD 분석 및 작성 : 송인찬, 김**님
  • DBmate 세팅 : 송인찬
  • User API : 송인찬
  • category API : 송인찬
  • feedList API : 송인찬
  • reply API : 송인찬
  • posting API : 김**님
    • 임시저장 posting: 김**님
    • 임시저장 수정 posting: 김**님
    • 정식저장 posting: 김**님
    • delete posting API : 송인찬

결과


최초 문서 분석

이번 프로젝트는 typescript라는 새로 배운 언어를 도입하여 진행하기로 하면서,
처음엔 아니었으나 프로젝트 종료시점에서 어쩌다 보니 내가 맡은 파트가 꽤… 많아졌다.
 

패스트파이브에서 준 개발 의뢰문서를 처음 봤을 때, 상세등록과 덧글 부분이 가장 난이도가 높아보였다.
난이도가 높다고 느껴진 부분은 다음과 같다.

  • 상세등록
    • 임시저장 : 1분마다 자동저장
    • 이미지 업로드 (작성시점에 이미지 등록시 미리보기가 가능해야 함)
    • 파일 업로드 (파일명과 파일 링크를 반환해야 함)
  • 덧글
    • 대댓글 구조
    • 비공개 댓글의 경우 로그인 사용자별 내용 공개처리

 


User API 작업을 시작하다

사실 개발문서에는 직접적인 user API 관련 언급은 없었다.
하지만 로그인한 사용자별 등급이 존재했었고, 이에 따른 커뮤니티 이용별 권한이 따로이 나눠져 있었다.
또한 최종적으로 website 링크를 배포 했을때 이러한 권한을 보여주려면 user API가 무조건 있어야 한다고 판단했다.
따라서 회원가입과 백엔드측에서 서버 세팅시 필요하게끔 약식으로 작성을 하였고,
로그인은 프론트엔드가 API 통신 테스트를 하는데 크게 문제되지 않도록 실제와는 달리 의도적으로 에러핸들링을 최소한으로 하였다.
 

회원권한조회 API

user API 중 유일하게 가장 유용하면서도 의미있으며 필수적인 API로써,
로그인한 회원의 정보를 실시간으로 클라이언트(프론트엔드)에서 확인할 수 있도록 설계를 했다.
 

"user_id": 9,
"write_permission": true,
"is_admin": true,
"member_type": "입주예정자",
"company_name": "세탁 상태",
"group_id": 8,
"nickname": "reservation1",
"email": "[email protected]",
"position_name": "대표",
"start_date": "2023년 03월 01일",
"end_date": "2023년 12월 31일",
"period": "총 306일 계약",
"group_feed_exist": true,
"feed_id": 8

 
해당 API는 위와 같은 응답 json을 반환한다.
로그인시, 위 정보를 한번에 줄수도 있겠지만 다음과 같은 이유로 따로 API를 분리시켰다.

localStorage에는 최소한의 정보만이 암호화 된채 담겨야 한다. 회원의 권한과 관련된 정보는 localStorage에 저장되지 않는 것이 보안상 유리하다. API를 분리하여 사용함으로써 실시간으로 변경되는 권한 내역까지 잡아낼 수 있다.  

실제 mySQL의 테이블에는 group의 start_dateend_date만이 담기지만,
이를 바탕으로 model단에서 member_type(멤버 등급)과, write_permission(글쓰기 가능 조건, is_admin 권한이 있더라도 퇴주기업은 글쓰기 불가하기 때문), 그리고 period(해당 업체의 계약기간)를 담아 보낼 수 있도록 하였다.  

또한, 회사의 멤버가 “우리회사 소개하기"라는 버튼을 누를 때,
클라이언트가 회사의 글이 있으면 해당 게시글로 이동하고, 없다면 글쓰기 API로 이동시킬 수 있도록
group_feed_exist, feed_id를 찾아 반환할 수 있도록 하였다.  

sql where 절의 변수에서는 중간 탈취를 막기 위해 query injection처리를 하였다.  

mySQL query 펼치기
SELECT
u.id AS user_id,
(CASE
WHEN date (ug.end_date) >= date (now())
AND u.is_admin = TRUE THEN TRUE
ELSE FALSE
END
) AS write_permission,
u.is_admin,
(CASE
WHEN date (ug.start_date) <= date (now())
AND date (ug.end_date) >= date (now()) THEN '입주자'
WHEN date (ug.end_date) < date (now()) THEN '퇴주자'
WHEN date (ug.start_date) > date (now()) THEN '입주예정자'
ELSE '일반가입자'
END
) AS member_type,
ug.company_name,
u.group_id,
u.nickname,
u.email,
u.position_name,
DATE_FORMAT(ug.start_date, '%Y년 %m월 %d일') AS start_date,
DATE_FORMAT(ug.end_date, '%Y년 %m월 %d일') AS end_date,
concat("총 ", TIMESTAMPDIFF(DAY, ug.start_date, ug.end_date) + 1, "일 계약") AS period
FROM users u
INNER JOIN user_group ug ON
ug.id = u.group_id
WHERE
u.id = ?
`,
[userId]

 
 

가장 많이 고민한 reply API

댓글 테이블은 다음과 같은 구조를 가졌다.
 
https://i.imgur.com/OSqXWoE.png  

대댓글은 자기참조 구조 활용

여기서 난 대댓글의 구조에서 parent_reply_id를 같은 테이블의 id값으로 잡는,
즉 자기참조 구조를 사용하였다.
최초에는 parent_reply_id에다 같은 테이블의 id값을 foreign key로 잡았었는데, 이 fk를 해제한 이유는 아이러니하게도 검색효율성 때문이었다. 1

  • isNull 함수 검색보다 const 상수검색으로 검색효율성 증대
  • 이에 null일때, 0으로 입력되도록 default 값을 세팅
  • 대댓글 그룹핑시 불필요한 함수 사용 제거 가능

 

https://i.imgur.com/yhIm3MP.png  

read replies by feed API - mySQL query 펼치기
SELECT
	t2.id AS reply_id,
	t2.feed_id,
	u2.id AS feed_user_id,
	t2.is_private,
	t2.is_deleted,
	t2.comment,
	t2.parent_reply_id,
	u3.id AS parent_user_id,
	t2.reply_group,
	t2.rnk,
	u.id AS reply_user_id,
	ug.company_name,
	u.nickname,
	u.email,
	u.position_name,
	u.is_admin,
CASE
WHEN instr(DATE_FORMAT(t2.created_at,
	'%Y년 %m월 %d일 %p %h:%i'),
	'PM') > 0
THEN
REPLACE(DATE_FORMAT(t2.created_at,
	'%Y년 %m월 %d일 %p %h:%i'),
	'PM',
	'오후')
ELSE
REPLACE(DATE_FORMAT(t2.created_at,
	'%Y년 %m월 %d일 %p %h:%i'),
	'AM',
	'오전')
END AS created_at
FROM
	(
		SELECT
			*
		FROM
			(
				SELECT
				r.id,
				r.is_private,
				r.is_deleted,
				r.user_id,
				r.feed_id,
				r.comment,
				r.parent_reply_id,
				r.created_at,
				RANK() OVER (PARTITION BY parent_reply_id
				ORDER BY
				id ASC
			) AS rnk,
			r.parent_reply_id AS reply_group
		FROM
			replies r
		WHERE
			feed_id = 1
			AND parent_reply_id) AS t1
		UNION ALL
		(
			SELECT
			r2.id,
			r2.is_private,
			r2.is_deleted,
			r2.user_id,
			r2.feed_id,
			r2.comment,
			r2.parent_reply_id,
			r2.created_at,
			r2.parent_reply_id AS rnk,
			r2.id AS reply_group
			FROM
			replies r2
			WHERE
			r2.feed_id = 1
			AND r2.parent_reply_id = 0
			ORDER BY
			r2.id ASC
		)
	) AS t2
INNER JOIN users u ON
	t2.user_id = u.id
LEFT JOIN replies r3 ON
	r3.id = t2.parent_reply_id
LEFT JOIN users u3 ON
	u3.id = r3.user_id
LEFT JOIN user_group ug ON
	ug.id = u.group_id
LEFT JOIN feeds f ON
	f.id = t2.feed_id
LEFT JOIN users u2 ON
	f.user_id = u2.id
ORDER BY
	reply_group,
	rnk

 
 

sql의 결과를 보기좋게 재가공!!

read replies by feed API - typescript code 펼치기
.then(value => {
      value.map((e: any) => {
        if (value[0].parent_reply_id !== 0) {
          let temporary = {
            reply_id: e.parent_reply_id,
            parent_reply_id: 0,
            is_fake: true,
          };
          value.unshift(temporary);
        }
        return value;
      });

      value = [...value].map(item => {
        return {
          ...item,
          is_private: item.is_private === 1,
          is_deleted: item.is_deleted === 1,
          comment: item.comment === '0' ? false : item.comment,
          reply: [],
        };
      });

      value
        .filter(
          (e: any) =>
            e.is_private === true &&
            (e.reply_user_id || e.parent_user_id || e.feed_user_id) !== userId
        )
        .map((e: any) => {
          e.comment = false;
          return e;
        });

      value
        .filter((e: any) => e.parent_reply_id !== 0)
        .forEach((e: any) =>
          value
            .find((re: any) => re.reply_id === e.parent_reply_id)
            .reply.push(e)
        );

      return value.filter((e: any) => e.parent_reply_id === 0);
    });

 
 

이리하여 나온 멋진 res.json은 아래 이미지와 같다.  

https://i.imgur.com/AVaDb3K.png  
 

풀이
  • 댓글과 대댓글의 depth를 나눈 테이블구조가 아닌 자기참조 구조는 무한대댓글 구조를 구성할 수 있는 장점이 있다.
    1. 페이지네이션에서 댓글과 대댓글을 포함한 개수를 실행할 수 있도록 db에서는 대댓글을 분리하지 않은채 limit에 따라 댓글을 가져온다
    2. 비공개 댓글이나 삭제된 댓글은 로그인 유저의 정보에 따라 여기서 바로 내용을 false로 전환시킨다. (애시당초 클라이언트 쪽으로 데이터가 나가지 않도록 원천 차단)
    3. 페이지네이션으로 인한 n페이지의 최초 댓글이 대댓글일 경우, 원댓글 속에 들어가는 json 구조를 유지하기 위해 가상의 원댓글을 만들어주었다.
    4. 끝으로 대댓글은 댓글 속 reply라는 배열 안으로 모두 집어넣고, 최초의 json depth 단계에서는 원댓글들만 남게 함으로써, 클라이언트에서 구분하기 쉽게 response를 재가공하였다.
    5. 뿐만 아니라 이러한 구조에서 대댓글이 소속되는 원댓글에 대한 정보를 클라이언트에서 보다쉽게 구분할 수 있도록 reply_group이라는 컬럼을 추가생성되게 함으로써 원댓글 속으로 다시 한번 묶어주는 조건값을 생성해주고, 이 안에서 대댓글의 순서 정렬을 위해 rnk라는 컬럼을 추가로 만들었다.
  • 프론트엔드에서는 왠만한 상황에서도 컴포넌트를 유지하는데 최대한 불편함 없이, 조건의 오류 없이 json데이터를 가져올 수 있도록 고민하고 설계하였다.
  • 이 로직을 고민하는데 완전히 구현하는데 대략 이틀이나 걸렸다.  

그외 기타 고민의 의사결정 과정들

  1. 하위 카테고리 설정

    • 처음 카테고리를 설정할땐 직무 위주로 찾고 있었는데 문득, 패스트파이브는 B2B 업종이라는 걸 간과했다는 사실을 발견했다.
    • 즉, 카테고리는 직무의 나열이 아니, 업종의 나열이 되어야 한다고 판단했다.
    • 즉각 한국표준산업분류를 찾아봤고, 이에 따라 직무가 아닌 회사의 업종으로 나눌 수 있게끔 다시 세팅을 하였다.
  2. 퇴주자 글

    1. 단순 삭제시 - 데이터 유실의 문제점
    2. 삭제는 아니고 가림 => 2번으로 선택한 이유 기록! 왜?!!
      • 퇴주기업의 게시글이라는 멘트로 공개 - 입주자 우선의 고객경험상 게시글 리스트가 오염될 수 있고,
      • 데이터를 보호함으로써 패스트파이브에서는 추후
        • 전체 데이터 통계
        • 유의미한 멤버혜택 정보를 되려 게시글 작성자에게 추천할 수도 있다고 생각했다.  

개선과정

  • 반복코드들의 middleware 처리
    • controller단에서 무한히 반복되는 try-catch문을 따로 뽑아내어 미들웨어 처리함으로써 코드를 더 간결히 하였다.
    • 대부분의 API에서 권한 검증을 함에 따라, 유저 권한 API를 jsonwebtoken 다음 단계로 하여 역시 미들웨어 처리하였다.
       
router.post(
'',  
catchMiddleware(authMiddleware),  
catchMiddleware(checkPermission), 
catchMiddleware(replyController.createReply)
);

 

  • 배포 사이트에서 보는 이가 실제로 로그인해서 살펴볼 수 있도록 로그인 페이지와 해당 페이지에 시연용 ID와 PW를 남겨놓자고 건의하였고, 프론트엔드분들의 수고로 다음과 같이 남겨졌다.

 
https://i.imgur.com/s6vTvwU.png  


회고

저스트코드의 과정중, 내게 이번 프로젝트는 따로 진행한 미니프로젝트를 포함한다면 4번째 프로젝트였다.
이전의 세 프로젝트와 다른 점은 이전까지의 클론프로젝트가 아닌,
즉, 모체가 없는 단순 문서만으로 진행해야하는 부분이었다.
때문에 ERD에서 고민이 조금 더 들어갔고,
이전의 프로젝트와는 달리, 프로젝트 진행 중간에 ERD 수정이 빈번히 이루어졌다.
 

프로젝트 단위로 코딩을 했을때 가장 좋은 점은 전체 사이클을 오롯이 한바퀴 돌려볼 수 있다는 점이었다.
때문에 기본적인 기능구현만을 위한 작업은 생각보다 금방 끝났고, 미들웨어 처리라던가, service단에서의 활용성 등 이전 프로젝트에 비해 코드를 더욱 조직화, 세분화함으로써 보다 복잡한 코드들을 나름 정리할 수 있었다.
 

어쩌다 보니 백엔드에서 많은 파트를 담당하게 되었는데 이는 내게 더 많은 도움과 공부가 되었다.
이제 어느정도 클론사이트의 경우 혼자서도 2주정도면 할수 있을것 같은 근거없는(?) 자신감도 생겼다.
새로운 길을 걷는 자에게 이런 자신감이 없다면 시작부터 흔들리거나 불안할 수 있다는 점에서 이는 큰 장점이라 생각한다.
 

이번 프로젝트에서 내가 목표로하였던 백엔드 지향점은 다음과 같다.
 

기획의도를 파악하여 타 부서와의 불필요한 조율 및 미팅을 최소화하고,
클라이언트 즉, 프론트엔드와의 비가시적인 동기화를 최대한 고려할 수 있는 조화로운 백엔드 엔지니어
 

이러한 점에서 이번 프로젝트에서는 전보다 더 많은 고민과 발전을 이루었음에,
아직 채워지지 않은 나의 포트폴리오 시작점에서 또하나의 유의미한 “한 자리"를 기록하게 되었다.
 

아쉬웠던 점은, 클론사이트가 아니다보니 처음부터 프론트엔드와의 합을 맞추기까지 꽤나 오랜 시간이 걸렸고, 이로인해 API 통신 테스트까지의 일정이 다소 지연되었었다. 이번 경험은 실무에서 많은 도움이 될 것이다.
 

끝으로,
저스트코드의 과정이 끝남으로써 정기적으로 주어지는 프로젝트가 없다는 부분에서 오는 무료함과 상실감을 느끼게 되었다.
목표가 없다면 그것은 죽은 영혼이다!라는 글을 본적이 있다. 프로젝트를 진행할때의 그 긴장감과 일정을 맞추기 위한 적절한 압박감이 존재함을 느끼게 하였고,
코딩을 하는 내내 나 스스로가 몹시 즐거워한다는 점을 느꼈다.
 

이제 또 다른 목표를 잡으러 가봐야겠다.  


  1. 참고 [블라인드 따라하기] 8. 대댓글 구현하기 - 디비 성능 개선기 : 네이버 블로그](https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=1ilsang&logNo=221569040532↩︎