OPENMIND 프로젝트

ClydeHan·2024년 3월 27일
2

프로젝트 소개

질문과 답변을 통해 마음을 열고 대화 나누는 소통 플랫폼인 '오픈마인드'
자유롭게 질문을 생성하고 답변 할 수 있습니다.
또한 링크를 통해서 질문과 답변을 공유할 수 있습니다.

오픈마인드 바로가기
깃허브 바로가기


프로젝트 준비

기술스택


주요기능

피드 생성
메인페이지에서 이름을 입력하고 '질문 받기' 버튼을 클릭하면 피드를 생성

질문 리스트
정렬 순서를 최신순, 이름순으로 설정

질문 하기
'질문 작성하기' 버튼을 클릭하여 질문 등록
질문에 '좋아요' '싫어요' 기능

답변 하기
질문에 대한 답변 기능
답변 거절 기능
답변 수정 기능
질문 삭제 및 모두 삭제 기능
질문에 '좋아요' '싫어요' 기능

링크 공유
'링크 아이콘'을 클릭하면 URL을 클립보드에 복사
'카카오 아이콘'을 클릭하면 카카오톡으로 공유 가능
'페이스북 아이콘'을 클릭하면 페이스북으로 공유 가능


Git Branch 전략

  • Github Flow

R&R 분배 및 일정 수립

  • R&R 분배FLOW CHART를 통한 공정 흐름 이해

FLOW CHART



데이터 흐름



R&R 분배

기능 별 난이도 1, 2, 3 으로 치환, 10점씩 역할 분배, 역량 수준 고려하여 예상 일정 수립, 리팩토링, 버그픽스, 추가기능 구현 일정 추가 계획.


내 역할

  • 팀장
  • 보일러 플레이트(boiler plate) 세팅
  • 깃허브 세팅
  • 답변하기 페이지 퍼블리싱
  • 답변 거절 기능
  • 답변 개별 삭제, 전체 삭제
  • 답변 수정
  • 데이터 호출 시 스켈레톤 추가
  • 발표 자료 대본 초안 작성 (팀원 별 기능 구현 이슈 취합)

개발 기간

2024.01.17(수) ~ 2024.02.01(목)


이슈 및 문제 해결

작업 초기 세팅(eslint, prettier, husky) 이슈

  • 👍🏻 잘한점, 유지하고 싶은점
    당장 내일 사용해야 하는 팀 프로젝트 세팅을 밤을 새서 완료했다. eslint, prettier, husky를 설치했고, github 프로젝트 세팅도 완료했다. 처음 접한 사람의 입장에서 프로젝트 세팅은 쉬운일이 아니었다. 정해놓은 목표를 달성 할 때 까지 포기하지 않고 끊임없이 노력한 점을 유지하고 싶다.

  • 🤔 문제점, 문제점을 해결하기 위한 노력
    eslint, husky를 설치하는 것은 생각보다 많은 어려움이 없었지만 이 자체를 이해하는 것에서 어려움을 느꼈다. eslint가 무슨 기능을 하는지, 그리고 어떻게 동작하는지를 공부하는 것. 그리고 그 전에 근본적인 것을 모르고 있어서 곤란했던 상황도 있었다. npm이 뭔지, package.json이 왜 있는지, 이런 기본적인 의문조차 가지지 않고 그냥 해왔는데 그게 화근이 되었다. npm install이 작동하는 원리를 알게되니, 팀 프로젝트 세팅에 대해서 조금 명확하게 이해할 수 있었다.
    husky가 동작을 안 하는 것이 문제였고, 이 부분 때문에 몇 시간을 허비했다. eslint가 실행되고 코드에 문제가 있어도 commit이 잘 되는 문제였다. husky가 어떻게 설치가 되고 그 기능들을 어떻게 원하는대로 설정하고 동작하게 하는지에 대한 이해가 없으니 어떻게 해결해야 할지도 모르고 계속 헤맨 것이다. 하지만 package.json에 대해 이해를 하니, husky가 동작하지 않는 이유에 대한 가설을 세울 수 있게 되었다. 문제는 husky 세팅 폴더 안의 husky 실행 조건이 push 할 때만 lint를 실행하는 것이었다. 그래서 commit을 할 때에도 lint를 실행하게끔 코드를 추가했고, 그 후로 정상적으로 작동을 하게 되었다.

작업 브랜치 휴먼 에러 이슈

  • 문제 : 처음 작업 때 branchmain으로 만들고 커밋을 했다. 그리고 그 후에 feature branch를 생성해서 쭉 진행했다. (처음 하나의 commitmain이고 나머지는 전부 feature인 상태)

    main branch로 제일 첫번째 commit이 하나 생성 되어있는것을 인지하지 못하고 feature branch를 새로 생성해서 쭉 commit하며 작업해서 작업들이 많이 쌓여있었다. feature branch만 팀 레포지토리 develop 브랜치에 pr 요청 올리고 merge를 하는거라서, main 브랜치는 팀 레파지토리에 올라가지 않는다. (해당 내용을 깨닫기 위해 길고 긴 시행착오가 있었다.)

  • 작업한 featurepr 올리고 머지하면 팀 레파지토리의 develop 브랜치는 제일 최신 상태를 유지한다. 그리고 그것을 내 리모트 레파지토리에 싱크 포크하면 내 리모트 레파지토리는 최신 상태를 유지한다. 그리고 내 리모트 레파지토리의 main 은 초기 상태 그대로이다. 왜냐? mainmerge하지 않았으니까. (어차피 mainclone해오지도 않을거다. 지금 내가 필요한건 작업중인 develop이니까 마지막까지 main은 초기 상태를 유지하는 게 맞는 것이다.)내 로컬 레파지토리를 다 지운다. 그리고 최신 상태인 내 리모트 레파지토리의 develop 브랜치만을 clone 해오면 잘못 만든 main 브랜치가 삭제되고 어떤 오류도 발생하지 않는다.

css 부모, 자식 상속 이슈


텍스트 입력창, 버튼 부분이 꽉 채워지지 않던 문제
입력창과 버튼을 감싸는 divflex-grow: 1; 을 주었음에도 늘어나지 않았다. 안의 요소들과 감싸는 div태그들에 width: 100%; 을 주지 않아서 생긴 문제였다.


컴포넌트화를 한 이후에 사진처럼 쪼그라드는 문제
question-card의 부모 요소들에 flex를 넣고, width를 100%를 넣어봐도 계속 바뀌지 않았다.
컴포넌트로 생성된 이상한 이름 클래스네임을 가진 div가 문제인 것 같아서 해당 태그의 부모요소 div를 만들어서 flex를 넣어봤지만 변하는 게 없었다.
여기서 실수 2개. 해당 부모 요소에 width: 100%를 주지 않은 것. 그리고 스타일드 컴포넌트 바깥에 부모 div를 만들어놓고 styled component 안에서 해당 div에 스타일을 지정해준 것.


이렇게 스타일드 컴포넌트 제일 첫줄에 width를 주니 굳이 div 부모요소를 만들어줄 필요도 없고 바로 부모요소로 적용이 됐다.

eslint 삼항연산자 중복 선언 금지 규칙, if else 선언문 규칙 이슈

  • 문제 및 해결 :
    삼항연산자를 두 번 썼을 때 eslint 오류가 발생했다. 가독성 문제 때문에 따로 if else를 사용하라는 규칙이다. 그래서 삼항연사자를 지우고 따로 함수를 만들어서 if else를 사용하기로 결정했고, else if 를 사용하니 이번에는 Unnecessary 'else' after 'return’ 라는 eslint 오류가 발생했다. 이는 if 문 후에 불필요한 else를 붙이는 것도 가독성에 문제가 있다는 규칙이다. else대신 return만 사용해도 else역할을 하니, else를 사용하지 말라는 규칙이다.

옵셔널 체이닝 이슈


  • 해당 코드에서 2번 째 줄에 ?. 를 붙이지 않으니 오류가 발생했다. ?.optional chaining이라는 java script 문법이다. 객체가 Null, undefined인 경우에 error를 발생시키지 않고 대신 Undefined를 반환하는 문법이다. 위에 상황을 예로들어서 옵셔널 체이닝을 사용하면 question 이나 question.answer이 없는 경우에도 코드가 안전하게 실행될 수 있다. question 이나 question.answer이 항상 존재한다고 보장할 수 없는 상황에서 유용하다. 그 밑에 옵셔널 체이닝을 사용하지 않은 이유는, question.answer이 존재하지 않을 경우의 return값을 미답변으로 지정해줬기 때문이다.

eslint 버튼 태그 외 클릭 이벤트 선언 이슈

  • 버튼 태그가 아닌 태그에 OnClick 속성을 주면 에러가 발생한다. 그럴 때 role=”presentation” 을 주면 해결된다. 아주 간단히 요약하면, 버튼 태그의 기능을 그대로 사용할 수 있지만 버튼태그로써의 접근성을 지워주는 역할을 한다. div태그처럼 아무 의미없는 태그가 되는 것이지만 버튼 태그의 기능은 사용할 수 있는 것이다.

공식문서 : https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/presentation_role

엔터 제출 이슈, textarea 줄바꿈 이슈


  • textareaonKeyDown으로 선언한 함수를 넣어줘서 엔터키로 데이터 정상 전송이 된다. 하지만 서버에 등록돼서 받아오는 데이터는 줄바꿈이 적용되지 않았다. 해당 문제는 데이터를 출력해주는 클래스에 white-space:pre-line을 선언해주니 해결되었다.

kebab dropdown 이슈

  • 케밥 팝업 바깥 클릭 시 닫기 이슈
    setClickStatus를 상위 컴포넌트에서 Props로 받아왔다.
  • 클릭중인 상태인지 확인하는 상태관리이다. 클릭중일 때는 케밥 드롭다운이 활성화된다.

  • dropdownRef로 케밥 최상위 요소에 ref를 심어서 참조를 하고,

  • useEffect 훅을 사용하여 드롭다운 메뉴 외부를 클릭 했을 때 드롭다운 메뉴를 닫는 기능을 구현한다.
    e.target은 내가 클릭한 요소를 확인하는 요소이니까 dropdownRef.current.contains(e.target) 이 코드는 내가 지금 클릭한 곳이 드롭다운 안쪽인지 확인을 하는 것이다. (ref가 드롭다운 부모 요소에 붙어있으니까)
    그 밑 addEventListener는 마우스 클릭 할 때 마다 handleClickOutside 함수를 호출한다. 그리고 리턴문은 클린업 함수이다. 클린업 함수는 컴포넌트가 언마운트 되기 전에 실행되는 것인데, 이벤트 리스너를 제거한다. 이렇게 되면 컴포넌트가 언마운트 된 후에도 이벤트 리스너가 메모리에 남아있지 않게 된다.
    (여기서 unmount란 컴포넌트 생명주기의 마지막 단계이다. 컴포넌트가 DOM에서 제거되는 단계이다. 조건부 렌더링에 의해서 컴포넌트가 더이상 표시되지 않거나, 사용자가 페이지를 이동해서 다른 컴포넌트가 렌더링 되는 경우에 언마운트가 발생할 수 있다.)

API 호출 함수 관련 이슈

  • 문제:
    처음 내용을 입력하고 버튼을 누르면 새로고침이 되고 입력이 안 된다.
    그리고 다시 내용을 입력하고 버튼을 누르면 정상적으로 등록이 된다.
    이 문제를 팀원이 button 태그를 submit말고 button으로 바꾸고 post메서드 이행 후에 새로고침하기위해 submitAnswer함수 최하단에 window.location.reload(true);을 넣으라고 말씀해주셔서 그대로 했더니 계속 오류가 발생한다.

  • 해결:
    buttonsubmit타입을 줬는데, submit의 기본 동작에 새로고침이 들어가있다. 그래서 내용이 입력되기 전에 기본적으로 새로고침이 들어가는 것이다. 그래서 두번째 다시 동작 했을 때, 기본 동작에 의해 새로고침이 되면서 전에 썼던 내용이 들어가는 것이다. 이를 해결하기 위하여 버튼 타입을 button으로 바꾸고 post 메서드 이행 후에 새로고침 명령어를 의도적으로 넣어서 내용을 입력하고 해당 내용이 서버에 post되면 새로고침이 되는 것이다.

useParams 구조분해 이슈

  • 문제 및 해결 :
    우연으로 끼워맞춰서 동작을 하게 된 부분 하나가 더 있다. const { id } = question; 이 부분인데, id가 들어가 있기 때문에 useParams를 줘야한다고 생각해서 useParams를 줬는데, 오류가 떴다. 왜 오류가 떴나면, 파람스를 사용하면 지금 보이는 페이지에 해당하는 Id를 받아오기 때문에 post 이행문에서 요구하는 id는 지금 보이는 페이지의 Id가 아니라 질문에 대한 id이기 때문에 오류가 발생한 것이었다. 그래서 const {id}=question을 사용하게 되면 question 안에 id를 받아온다.
json
"count": 12,
    "next": null,
    "previous": "https://openmind-api.vercel.app/3-5/subjects/2805/questions/?limit=100",
    "results": [
        {
            "id": 3698,
            "subjectId": 2805,
            "content": "뭐 드시냐고 세번째 물어봅니다",
            "like": 0,
            "dislike": 0,
            "createdAt": "2024-01-23T15:37:22.022619Z",
            "answer": {
                "id": 1949,
                "questionId": 3698,
                "content": "배 먹어 배!",
                "isRejected": true,
                "createdAt": "2024-01-23T15:51:21.322068Z"
            }

상위 컴포넌트에서 받아온 questionresults 배열을 매핑해온 것이다.

여기서 Id는 해당 질문에 대한 질문 Id이고, subjectIduseParams를 사용하면 받아오게 되는 질문을 받는 대상에 대한 id이다. 그래서 const { id } = question; 를 사용하게 되면 구조분해로 인하여 id3698을 사용하게 되는 것이고, 이는 post에서 요구하는 양식의 Id와 같다.

jsx
const submitAnswer = async () => {
    const response = await fetch(
      `https://openmind-api.vercel.app/3-5/questions/${id}/answers/`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          content,
          isRejected: false,
        }),
      },
    );

여기서 또 하나의 우연이 발생했는데,

jsx

        body: JSON.stringify({
          content,
          isRejected: false,
  

이 부분은 post 할 때 맞춰야 하는 양식인데, 원래는

jsx

        body: JSON.stringify({
					questionId : id
          content,
          isRejected: false,
  

이렇게 들어가야 한다, 하지만 fetch로 주소값에서 ${id}를 보내고 있기 때문에 해당 부분은 자연스럽게 양식을 맞춰서 보내지 않아도 idconst { id } = question;id로 전달이 된 것이다. 즉, 질문 id가 자동으로 설정되어서 보내진 것이다.

API DELETE 요청 이슈

  • 문제 및 해결 :
    api 딜리트 요청은 따로 리스폰스를 받아오는 게 아니기 때문에 변수를 선언해서 담을 필요가 없다. 즉 기존 api 호출 함수처럼 변수에 담고 그 변수에 json() 을 달지 않아도 된다. (이거 때문에 오류 떴었다.)

질문 리스트 렌더링 이슈


갈색 영역이 하얀 영역을 감싸고 있고, 하얀 영역안에 질문 컨텐츠가 렌더링 되는 구조.

일단 질문의 수만큼 하얀 영역이 생성되어야 하지만 그 부분을 해결하지 못해서 팀원의 코드를 참고했다. 반복되는 부분을 컴포넌트화 시켰고, 딱 반복되는 그 컴포넌트 부분만 배열의 map을 사용해서 반복 시키니 질문의 개수가 새로 생길 때 마다 해당 컴포넌트가 새로 생성이 되었다. (딱 반복되어야 하는 부분을 잘 파악하고 매핑을 하는 것이 중요하다.)

그리고 팀원의 코드를 흡수하며 알아낸 사실 하나. 서버에서 api를 호출하여 배열을 받아오면 해당 배열을 매핑(map)해서 사용하기 전 까지는 그냥 prop으로 전달해줘도 된다. 즉, 사용하기 전 까지 key값을 전달하고 매핑을 할 필요가 없다는 뜻이다. 반복되어 생성되어야 하는 부분을 잘 파악하고 해당 컴포넌트에 매핑을 해줘야한다.

답변 등록 재렌더링 로직 이슈


setContent가 아니라 setQuestions를 사용한 이유는 setContent에서 사용하는 데이터가 question.answer인데 이 데이터는 상위 컴포넌트의 useStatesetQuestions에서 받아와야지 사용 할 수 있기 때문이다. 그래서 상위컴포넌트에서 세터함수를 props로 받아와서 사용했다.

setQuestions(prevQuewstions ⇒ { 이 부분에 대한 설명은 아래와 같다.
임의로 지정한 인수 prevQuestionssetQuestions의 기존 값이 들어간다.

(세터 함수에는 원래 setQuestion(() ⇒ 값) 이 디폴트인데 ‘() ⇒ ‘ 이 부분이 생략된 것이다. 그래서 세터함수에 값을 넣으면 해당 값으로 세터함수를 받는 get이 변경될 수 있는 것이다.)

그리고 그 값에서 findIndex를 사용하여 question.id와 일치하는 값을 찾아서 index에 저장한다.

const temp = prevQuestions.filter(prevQuestion => prevQuestion); 이 부분은 인덱스로 바로 접근이 안 돼서 딥복사를 하는 과정이다. (새로운 배열을 리턴하는 기능이 필요하기 때문에 filter를 사용했다. 하지만 map을 사용해도 기능을 한다. 이 부분에 대한 추가 공부를 따로 하고 아래에 기입할 예정이다.)


해당 부분은 filter보다는 map 또는 스프레드 연산자를 사용하는 것이 더 명확하고 적합하기 때문에 map으로 수정했다.

temp[index].answer = result;
위에서 선언해준 index는 내가 현재 필요한 배열의 index가 저장되어 있다. 딥복사를 한 temp 배열의 [index]에 접근해서 해당 배열의 answer에 접근을하고 그 값에 서버에서 fetch해온 result를 넣어준다.

return temp;
그리고 temp를 리턴해준다.

스켈레톤 구현 이슈

cssskeleton ui를 구현할 수 있다. useState로 상태관리를 하며 서버에서 데이터를 받아올 때 스켈레톤 ui를 띄우고 다 받아오면 서버 데이터를 띄운다는 조건문으로 구현할 수 있다.

하지만 편하게 스켈레톤 ui 라이브러리를 설치하면 반짝이는 ui가 자동으로 생성되고, 간단하게 크기 등의 스타일만 넣어주면 된다.

스켈레톤을 구현하며 막혔던 부분은 처음 설치 과정, 그리고 설계되어 있는 코드에 따라서 렌더링이 다르게 나올 수도 있다는 것. 부모 태그와 자식 태그가 컴포넌트로 나뉘어 복잡하게 설계되어 있는 상태에서 자식 태그에 스켈레톤을 적용하면 스타일이 제대로 적용되지 않고 부모태그에 간섭되는 경우가 있었다. 이런 경우에는 마진으로 급하게 마무리 했다. 설치 부분은 import 문제였는데, Skeleton import문은 항상 상단에 있어야한다. 리액트 임포트문 바로 밑에.


그리고 스켈레톤 css 임포트문은 아무데나 배치해도 상관없다.

스켈레톤 컴포넌트 사용 예시

스켈레톤 무한스크롤 충돌 이슈

다음 날 발표인데 Netflify 배포 환경에서 특정 기능이 작동을 안 했다. QuestionPageAPI 호출이 안 됐고, 이상하게 AnswerPageAPI 호출은 정상적으로 됐다. 둘은 똑같은 호출 함수를 공유한다. (무한스크롤 함수도 똑같이 공유한다.)

본론으로 들어가기 전, 해당 내용은 무한스크롤 로직(IntersectionObserver API)에 대한 기본적인 이해가 되어있는 상태에서 읽는 것을 권장한다.

❗️ 문제 상황

DOM 요소와 ref 관련 문제
페이지에 DOM 요소가 렌더링되어야 ref를 통해 참조된 요소(elementRef.current)가 실제로 존재하게 된다. 이 요소가 존재해야 Intersection Observer가 해당 요소의 화면 내 위치를 추적할 수 있다.

로딩 스피너와 스켈레톤 UI의 동시 실행
isLoading 상태가 활성화되는 순간 스켈레톤 UI를 1초 동안 표시하는 로직과 loading Spinner(observer)를 0.3초 동안 표시하는 로직이 겹쳐서, 스켈레톤 UI와 로딩 스피너(옵저버)가 동시에 실행된다. 하지만, 이벤트 루프(event loop)에서 처리하는 과정에서 문제가 발생한다.

무한 스크롤과 로딩 스피너 관찰 문제
무한 스크롤 기능의 서버 데이터 로딩 조건은 뷰포트에 로딩 스피너가 보여야 한다는 것이다. 그러나, 스켈레톤 UI가 로딩 스피너보다 길게(1초) 표시되어 로딩 스피너 DOM이 실제로 존재하지 않는 상황이 발생한다. 이로 인해 elementRef.currentnull이 되어, Intersection Observer가 관찰할 DOM 요소가 없게 된다.

❗️ 해결 과정

원인 파악
스켈레톤을 구현하지 않았을 때로 커밋을 돌렸을 때 무한 스크롤이 정상적으로 동작하는 것을 떠올리며 콘솔 로그를 차근차근 출력해가며 근본적이고 작은 부분부터 문제점을 찾아갔다. 스켈레톤 UI의 지연 시간(1000밀리초)이 로딩 스피너의 표시 시간보다 길어서, 스켈레톤 UI가 활성화되어 있을 때 elementRef.current가 로딩 스피너를 참조하지 못하는 문제를 발견했다.

해결 방법
useEffect의 의존성 배열에 elementRef.current를 추가함으로써, elementRef.current 값에 변화가 있을 때마다 효과를 재실행하여 Intersection Observer가 새로운 DOM 요소를 관찰할 수 있도록 했다. 이는 elementRef.current 값이 null에서 실제 DOM 요소로 변화할 때, Observer가 이를 감지하고 적절히 반응할 수 있게 만들어, 무한 스크롤(infinite scroll) 기능이 정상적으로 작동하게 한다.

결론
로딩 스피너와 스켈레톤 UI가 겹치면서 발생한 이벤트 루프와 관련된 문제는 elementRef.current의 변화를 감지하여 Intersection Observer가 적절히 반응하도록 함으로써 해결되었다. 이 과정에서 useEffect의 의존성 배열에 elementRef.current를 추가하는 것이 핵심적인 해결책이었다. 또한, 스켈레톤 UI의 지연 시간을 로딩 스피너(옵저버)를 0.3초보다 낮게 표시하는 로직으로 변경을 해도 정상적으로 작동하는 것을 확인했다.

첫 협업 팀 프로젝트 후기

🥹 아쉬운 점
한 페이지에 여러 사람이 붙어 기능 개발을 하면서 생긴 문제가 꽤 컸다. 팀원들 각자의 코드를 작성하는 방식이 달라서 코드를 이해하는데 오래 걸렸고, 추가 기능을 구현 할 때와 버그 픽스를 할 때 등 유지 보수에 매우 큰 어려움을 겪었다. 이런 방식의 설계는 리팩토링은 필수로 진행을 해주거나, 리팩토링이 필요없을 정도의 컨벤션을 확실하게 설계하고 갔어야 했다고 생각한다. 또한 한 페이지를 한 사람이 맡는 방식의 프로젝트 진행 방식으로 진행해봐도 좋을 것 같다는 생각을 했다. 상대적으로 쉬운 난이도의 페이지가 있다면 해당 팀원이 빨리 끝내고 다른 팀원들을 도와주었다면 더 순조롭게 진행이 가능했을 것 같다.
의견을 자유롭게 제시하고, 그에 반 하는 의견이 있다면 그에 대한 합리적인 이유와 대처방안 등을 부담없이 제시하는 등의 건강한 토론 분위기가 형성되지 못했던 것 같다.

😻 좋았던 점
모든 팀원이 밤을 새서라도 마감 기한을 지켜주는 등의 열정적인 모습을 보여줘서 팀장으로서의 책임감이 더욱 커져서 열심히 하게 되는 원동력이 되었다. 또한 팀원들의 기본 능력이 뛰어나서, 구현에 문제가 생겼을 때 도움을 많이 받았고 덕분에 대부분의 공정이 순조롭게 흘러갔다고 생각한다.

😉 소감
협업 과정에서 발생한 문제들을 명확하게 해결하지는 못했지만, 다음 프로젝트 때 부터는 어떤 식으로 작업을 시작하고 규칙을 짜야할지, 그리고 문제가 생겼을 때 어떻게 해결해 나갈지에 대한 값진 인사이트를 얻었다. 이렇게 얻은 인사이트들과 그동안 작성한 회고를 돌아보며 다음 프로젝트 때는 똑같은 문제와 실수를 반복하지 않도록 확실하게 규칙과 환경을 조성할 수 있게 된 것 같다. 많은 것을 얻어가는 좋은 프로젝트 경험이었다.

0개의 댓글