IT일상

우아한테크코스 팀 프로젝트 3차 스프린트 회고 본문

우아한테크코스

우아한테크코스 팀 프로젝트 3차 스프린트 회고

solo5star 2023. 8. 31. 21:56
리뉴얼 된 블로그로 보기: https://solo5star.dev/posts/54/

 

우아한테크코스의 팀 프로젝트는 총 5개의 스프린트로 이루어져 있습니다.
각 차수마다 2주간의 스프린트 기간을 가지고, 구현한 내용을 데모데이 때 발표합니다.

 

 

GitHub 이슈 라벨링 개선

GitHub 이슈를 분류하여 보기 위해 라벨을 만들고 라벨링을 한다. 크게 프론트, 백엔드 두 개로 분류하고 필터를 걸어 분야 별 이슈를 작업한다. 원래는 위와 같이 라벨을 만들고 관리하였으나 다음과 같은 문제가 있었다.

 

1. 특정 라벨은 색깔이 눈에 띄지 않는다. 채도가 낮아 흐릿한 것이 원인이라 생각했다.

2. 색깔만으로 어떤 라벨인지 구분이 쉽지 않다. 위의 문제와 비슷한 문제이다.

3. 양 옆에 넣은 이모지때문에 라벨이 길어져 칸반에서 보기가 쉽지 않다.

 

따라서 이렇게 개선하였다. 원래 없던 우선순위 라벨도 만들었다. HSL Color 모델을 사용하여 전반적으로 채도를 높이고 밝게 만들었다. 역시 색깔을 적절히 잘 설정해놓으니 어떤 라벨인지 금방 구분이 된다. 팀원들도 만족해주었다!

 

칸반에서도 깔끔하게 보인다. 라벨 디자인을 개선함으로서 칸반을 더욱 자주 보고 사용하게 되었다.

 

 

초기 데이터 수집

요즘카페의 화면 대부분은 컨텐츠가 차지한다

2차 데모데이를 마치고 회고하였을 때 초기 데이터 수집이 최우선적으로 필요하다는 걸 느꼈다. 실사용자가 사용하기 위해선 초기 데이터가 필수적으로 있어야 하기 때문이다. MVP를 만들기 위해서 데이터 수집은 빠질 수가 없었다.

 

뿐만 아니라 양질의 데이터가 필요하다. 서비스 특성 상 서비스의 품질을 결정하는 것이 메인 화면에 표시되는 카페 사진이기 떄문이다.

 

데이터 수집은 크롤링 없이 사람의 손으로 진행하였다. 데이터가 서비스의 품질을 결정짓기 때문에 직접 높은 퀄리티의 데이터를 선정하기 위함이다. 데이터 사용 허락은 카페 사장님들에게 DM을 보내어 직접 허락을 받았다. 이번 스프린트를 진행하며 가장 정성을 들인 부분이었다.

 

 

HTTPS

HTTPS 설정 과정에 직접 참여한 것은 아니지만 추후 certbot을 docker-ize하면서 알게된 내용이라 정리해보았다.

 

HTTPS란, HTTP 프로토콜에 보안 레이어인 SSL/TLS가 추가된 프로토콜이다. 보안 레이어 위에서 통신하는 특성 상, 종단 간 암호화가 보장된다. 따라서 사이트를 운영할 때 필수적으로 설정해줘야 하는 것 중 하나이기도 하다.

 

HTTPS가 적용되지 않은 사이트는 브라우저에서 경고를 표시한다

HTTPS를 적용하기 위해선 TLS 인증서 발급이 필요하다. 인증서 발급도 그냥 하면 되는 것이 아니고 브라우저(Chrome, Firefox 등)에서 신뢰할 수 있는 인증서를 발급받아야 한다. 이러한 조건을 만족하는 기관 중 무료이며 쉽게 발급받을 수 있는 곳이 바로 Let's Encrypt이다.

 

Let's Encrypt에서 발급을 쉽게 받을 수 있게 해주는 프로그램으로 certbot이 있다. 80번 포트를 개방해두고 certbot 프로그램을 실행하면 인증서가 발급된다. 80번 포트 개방이 필요한 이유는, 도메인의 소유를 증명해야 하기 때문이다. 80번을 개방하는 방법은 HTTP-01 challenge라고 하는데 포트를 개방하지 않고 DNS TXT 레코드를 설정하여 소유를 증명하는 방법인 DNS-01 challenge도 있다. 개인적으로 HTTP-01 challenge가 더 편리하다고 생각하여 가능하다면 HTTP-01 challenge를 권하고 싶다.

 

HTTP-01 challenge로 도메인 소유를 증명하려면 certbot을 standalone 혹은 webroot 방식으로 실행하면 된다.

* standalone 방식은 certbot이 자체적으로 80번 포트에 HTTP 서버를 띄워 도메인 소유를 증명한다.

* webroot 방식은 기존에 실행중인 Nginx의 정적 디렉토리를 이용하여 도메인 소유를 증명한다.

 

HTTPS 인증서 발급이 완료되면 /etc/letsencrypt/live/{DOMAIN} 경로에 인증서 파일이 생성된다.

 

server {
  listen 443 ssl;
  
  ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
  
  location / {
    root /usr/share/nginx/html;
    include /etc/nginx/mime.types;
    try_files $uri $uri/ /index.html;
  }

  location /api {
    proxy_pass http://172.17.0.1:8080;
  }
}

이를 Nginx에서 사용하도록 설정해주면 HTTPS 설정이 완료된다.

 

 

프론트엔드 개발 환경 구성 및 플로우

프론트엔드는 백엔드 개발 진행 상태에 영향을 많이 받는다.

1. 백엔드의 기능이 아직 완성되지 않았다면 MSW를 사용하여 API 모킹 후 기능을 개발해야 한다.

2. 백엔드의 기능이 완성되면 MSW를 비활성화하고 프론트엔드 측에서 잘 연동되는지 테스트가 필요하다.

 

백엔드의 기능이 완성되었을 때, 프론트엔드에서 연동이 잘 되는지 테스트를 해봐야 한다. 이전에는 개발서버에 백엔드를 배포하고 프론트에서 테스트를 했다. 문제는 API 연동이 잘 안되었을 때 백엔드를 수정하고 개발서버에 다시 배포해봐야 API 연동이 잘 되는지 테스트할 수 있다.

 

개발서버에 매번 배포하는 것은 병목이 너무 심하기 때문에, 이를 생략할 수 있도록 로컬에서 백엔드를 띄워 빠르게 테스트할 수 있는 방법에 대해 고민해보았다.

 

로컬에서 백엔드를 띄우려면 환경이 갖춰져 있어야 한다. 환경이라 함은, 백엔드를 띄우기 위해 필요한 요소들이다. Java Runtime 17, Gradle, MySQL 8 이 Spring 백엔드를 띄우기 위해 필요하다. (Gradle은 wrapper를 사용하면 없어도 되긴 하다)

 

FROM gradle:8.1.1-jdk as build

WORKDIR /work

# 빌드 스테이지에 소스 코드 파일들 추가
COPY src ./src
COPY build.gradle .
COPY settings.gradle .

# 테스트를 제외하고 빌드 진행
RUN gradle build -x test
RUN mv /work/build/libs/*[!-plain].jar ./app.jar


FROM eclipse-temurin:17-jre

COPY --from=build /work/app.jar .

ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh ./wait-for-it.sh
RUN chmod +x ./wait-for-it.sh

CMD ./wait-for-it.sh -t 0 mysql:3306 -- java -jar ./app.jar

환경을 구축하는 것은 번거롭기 때문에... Docker를 도입하였다. Docker를 사용하면 환경을 구축할 필요가 없다. Spring 백엔드를 로컬에 띄우기 위해 Dockerfile을 직접 작성하였다.

 

version: "3.8"
services:
  server:
    build:
      context: ../server
      dockerfile: ../server/Dockerfile
    environment:
      # ...

  mysql:
    image: library/mysql:8.0.33
    environment:
      # ...
    volumes:
      - db-data:/var/lib/mysql

volumes:
  db-data:

백엔드를 구동하기 위해선 MySQL도 띄워져 있어야 하기 때문에 docker-compose.yml에 MySQL 컨테이너도 하나 띄울 수 있도록 작성해두었다.

 

docker compose up

이제 docker compose up 명령만 입력하면 백엔드와 MySQL이 켜지게 되고 백엔드와 API 연동 테스트를 진행할 수 있는 환경이 갖추어 지게 된다.

 

docker compose build

만약 최신 백엔드 소스코드로 다시 빌드해야 한다면 docker compose build 명령으로 백엔드를 다시 빌드할 수 있다. Java에 대해서 잘 모르더라도 docker 명령만 알면 되기 때문에, 참 편리하다!

 

남은 한 가지 문제는, 로컬에서 DB를 띄우면 데이터가 없고 비어있기 때문에 백엔드 API 연동을 제대로 테스트하기 어려울 수 있다. 따라서 개발 서버에 있는 DB의 데이터를 로컬 DB로 클론할 수 있는 방법이 필요하다고 생각했고, 이를 다음 스프린트에서 진행하려고 한다. ("예정"이라고 되어있다!)

 

 

개발 서버 추가

기존에는 main 브랜치와 feature 브랜치 밖에 없었다. 배포하기도 전의 단계라 stable한 브랜치가 필요없었지만, 이제 배포를 고려해야 하기 때문에 stable한 브랜치를 분리할 필요가 있었다. 이제 그것이 main 브랜치이고, rapidly하게 개발하는게 dev 브랜치이다.

 

dev 브랜치를 만들게 되면서 main, dev 각각 최신 버전으로 배포될 서버가 필요했다. main 브랜치의 배포 서버는 운영 서버이고 이미 셋업이 되어있어 개발 서버의 추가 구축이 필요했다. 이는 백엔드 팀원이 진행해주었으며 과정 자체는 운영 서버를 셋업하는 거랑 크게 다를게 없었다. Nginx, HTTPS 설정, CI/CD 파이프라인 설정 등 거의 다 동일했고 달랐던 것은 도메인 정도였다.

 

근데... 셋업 과정이 거의 다 동일했음에도 불구하고 애를 먹었다. 이전에 운영 서버를 구축하면서 1) 겪었던 트러블을 따로 정리해두지 않았던 점, 2) 운영 서버 구축에 필요한 코드가 정리되어 있지 않은 점 때문이었다.

 

똑같은 작업을 하는데 고생도 똑같이 하는건 잘못되었다고 생각한다. 트러블을 따로 정리해두는건 그렇다 쳐도, 운영 서버 구축에 필요한 코드의 정리는 꼭 필요하다고 생각한다. 지금은 인프라 구축에 필요한 코드를 git에 올리진 않는다. 그렇다 보니 코드가 유실되고 같은 고생을 반복하게 된다. 따라서 ... 향후 인프라 구축에 필요한 코드를 git에 올리는 작업을 할 예정이다.

 

 

webpack production 모드 설정

배포를 고려하게 되면서 main 브랜치와 dev 브랜치를 나누게 되었다. main 브랜치는 실제 운영을 위한 용도로 사용하기 때문에, 각종 최적화가 고려된 production 설정으로 배포되어야 한다.

 

export default {
  mode: 'production',
  // ...
};

최적화를 위해 minimize, tree shaking 등 고려할 것이 많다. 하지만 빠르게 설정해야 하는 상황에서 이들을 하나하나 공부하고 설정하는 것은 다소 부담이 되었다.

 

webpack에서는 production을 위한 best practice 설정을 제공해주는 모드가 있는데, 그것이 바로 mode: 'production' 이다. webpack config에서 이렇게만 수정해주면 된다.

 

production mode에서는 사용하지 않는 주석 제거, 변수명 압축, Tree Shaking을 기본적으로 수행하여 번들된 js 파일의 용량을 최소화할 수 있다. development와 production을 비교하면 14.2MB 에서 660KB로 줄어든 것을 볼 수 있다. development에서 용량이 굉장히 큰 것은, react-icons 때문이다. production에서 안 쓰는 icon이 Tree Shaking되면서 용량이 많이 줄어든 것으로 보인다.

 

660KB도 용량이 큰 편에 속한다. webpack의 권장에 따르면 244KB 이하로 줄이는 것이 좋아보인다. React.lazy 등 Lazy Loading을 사용하여 번들 js를 분할할 필요가 있어 보인다. (Code Splitting)

 

 

프론트엔드 기능 구현

디자인을 개선하였다. 기존의 화면보다 더 컨텐츠에 집중할 수 있도록 디자인을 수정하였다.

그리고 사소하게 개선된 부분도 많이 있다. 하단의 네비게이션 바를 없애고 상단 바를 추가하였고 모달에 더 많은 정보가 표시되게끔 하였다.

 

기존에는 로그인 페이지가 따로 있었는데 모달로 변경하였다. 컨텐츠 페이지에서 로그인 페이지로 이동하는 것은 컨텐츠를 이용하는 흐름을 확 깨버린다는 생각에 이렇게 바꾸었다. 또한 로그인에 들어가는 버튼들이 많지 않기 때문에 페이지씩이나 될 필요도 없었던 것 같다.

좋아요 한 카페의 목록은 인스타그램을 참고하여 디자인을 바꾸었다. 기존에는 각 아이템에 라운딩 처리를 했지만 휴대폰의 갤러리나 인스타그램처럼 정사각형으로 꽉 채워서 보는 것이 더 이쁜 것 같아 이렇게 바꾸었다.

 

기존에는 상, 하 로만 스크롤이 가능했는데 한 카페에 대해 여러 장의 이미지를 볼 수 있도록 좌, 우 스와이프도 넣었다.

 

그런데... PC에서 좌, 우로 스와이프할 땐 이미지가 빠르게 로딩이 되었는데 모바일(특히 지하철 Wi-Fi나 데이터)에서는 로딩이 느리다. 따라서 좌, 우에 가상의 <img> 태그를 넣어 양 옆의 이미지가 미리 로딩될 수 있도록 처리하였다.

 

서비스 특성 상 공유에도 중점을 두고 있어 링크가 공유되었을 때 미리보기가 표시될 수 있도록 Open Graph도 추가하였다. 조금 아쉬운 건 특정 카페를 공유할 때 그 카페에 대한 정보가 표시되지 않고 위와 같이 항상 고정된 미리보기가 표시된다.

 

이는 Open Graph 메타 태그를 동적으로 생성하여 개선할 수 있는데, Scraper Bot 특성 상 Client-Side에서 렌더링하는 것보다 Server-Side에서 렌더링하는 것이 효과적일 것이라 생각하여 백엔드 단에서 구현할 것 같다.

 

 

로그인

지난 스프린트에서 로그인을 구현하긴 했지만 이번에는 refresh token 구현과 JWT를 파싱하는 로직이 추가되었다.

 

refresh token은 HttpOnly 쿠키에 저장되기 때문에 JavaScript에서 제어가 불가능하다. 따라서 프론트엔드 측에선 refresh를 무작정 시도해볼 수 밖에 없다. access token이 만료되면(401 Unauthorized) refresh를 한 번 시도해보고, refresh 실패 시 로그아웃 처리 및 error를 throw한다.

 

refresh token을 지우는 것 또한 JavaScript에서 제어할 수 없어 백엔드에 요청을 날려 지워야 한다. 백엔드로 로그아웃 요청을 하면 Set-Cookie 헤더를 통해 refresh token을 지울 수 있다.

 

access token에서 사용자의 ID를 얻어야 하기 때문에 access token, 즉 JWT를 디코딩했다. JWT는 header, payload, signature로 구성되어 있으며 dot(.)으로 구분되어 있다. 디코딩 자체는 base64로 하면 되기 때문에 간단하다. payload의 sub(subject)가 사용자의 ID인데, 이 값으로 GET /members/{ID} 에 요청하면 본인의 정보를 얻을 수 있다.

 

 

트러블 슈팅: 다른 탭에 갔다가 오니 카페 목록이 셔플된다

로그인 된 사용자인 경우 카페 목록을 셔플하여 응답해준다. (기획의 의도 자체는 추천 목록이지만, MVP 단계에서는 우선 셔플로 하기로 하였다) 문제는 요청할 때 마다 셔플된 목록을 반환하는 점이었다. 즉 멱등하지 않은 API라 할 수 있다.

 

react-query에서는 useQuery로 가져온 데이터가 기본적으로 stale하다고 여긴다. 즉 신선하지 않은 상태라는 것이다.

Server State 특성 상 상태의 제어권은 Server측에 있고 Client는 Server의 상태를 가져와 표시하게 된다. 가져온 직후 Server의 상태가 바뀔 수도 있기 때문에, Server로 부터 상태를 가져온 그 시점에 즉시 stale하다고 할 수 있다.

 

따라서 react-query에서는 가능한 한 fresh한 상태로 유지하기 위해 적극적으로 refetch를 한다. 다른 탭에 갔다 왔을 때도 refetch를 하는 것도 react-query에서 의도한 동작이다.

 

하지만 호출하려는 API는 호출할 때 마다 항상 셔플하여 주기 때문에 Server측에서 상태를 제어하고 있다고 보기 어렵다. 셔플된 목록을 Client측에서 유지하고 있어야 한다. 따라서 상태의 주도권은 Client에 있다고 볼 수 있다.

 

즉 Server로 부터 받은 데이터가 영원히 fresh하다고 볼 수 있다. 따라서 영원히 stale되지 않게 staleTime: Infinity, cacheTime: Infinity를 적용하였다. 결과, 다른 탭에 갔다 왔을 때 원치 않는 셔플 문제를 해결할 수 있었다.

 

 

API 잠수함 패치

팀에서는 API 명세를 노션으로 관리하고 있다. 새로운 기능이 필요하면 팀에서 회의를 거쳐 API 명세를 정하고 노션에 기록한다. 그런데 ... API 명세와 다르게 백엔드가 구현된 이슈가 있었다.

 

JSON으로 이미지를 받을 때 images.urls로 받기로 정했지만 실제로는 images로 구현이 되었다. 백엔드 구현을 수정하는 것이 맞겠지만 단순 이미지 URL 주소의 배열인 것을 생각하면 images인 것이 맞아서 ... 프론트엔드 측에서 수정하기로 했었다.

 

또 다른 잠수함 패치로는, liked-cafes로 받기로 하였는데 likedCafes로 구현이 되었었다. 이건 백엔드에 수정을 요구하였다.

 

사람은 완벽하지 않기 때문에 반드시 실수를 한다. 그렇지만 자꾸 이러한 일이 발생하면 팀원과의 신뢰가 떨어지고 답답할 것 같다. 이러한 문제를 시스템에서부터 방지할 수 있으면 좋지 않을까? 싶어 다음 스프린트 때 Open API Specification를 도입하여 API 문서와 코드의 싱크를 맞출 수 있게끔 해볼 계획이다.

 

 

배포

고된 구현 끝에 빠르게 배포해볼 수 있었다. 빠르게 배포를 하려는 이유는, 최소한의 가치를 전달하는 MVP를 만들어 고객에게 피드백을 받기 위함이다.

 

테오의 스프린트 방, 개발바닥 오픈채팅방에 올렸는데 좋은 반응들을 많이 해주셔서 정말 좋았다! (감사합니다)

 

 

배포의 저주

배포를 한 직후 치명적인 문제를 발견하였다. 이것이 바로 배포의 저주인가? Access Token이 만료되었을 때 Refresh Token을 통해 Access Token을 재발급하는데, 재발급을 했을 때 흰 화면이 뜨면서 앱이 박살나는 현상을 발견하였다.

백엔드 측에서 Access Token을 Refresh를 할 때 컨트롤러가 호출되기 전에 Filter라는 인증 레이어(횡단 관심이기 때문에 아마 모든 요청에 대해 처리하는 것 으로 보인다)에서 Access Token을 검사하는데, 이 Filter는 Authorization 헤더가 있다면 동작한다. Refresh 요청할 때 만료된 Access Token을 Authorization 헤더에 넣어서 요청하여 Filter단에서 400계열의 응답이 왔었고 이것이 앱이 터지는 것으로 이어졌다.

 

프론트엔드 측에서 Refresh를 할 때 Authorization 헤더를 제외하고 요청을 하도록 하여 버그를 수정할 수 있었다. (다행히도...)

 

 

가입자 수 모니터링

운영을 한다면 가장 하고 싶은 것 1순위! 바로 서비스의 성장을 기록하는 것이다. 그래서 grafana + prometheus를 도입하였다. 자세한 내용은 https://solo5star.tistory.com/50 여기서 확인할 수 있다.

 

사용자 수 모니터링 빠르게 시작하기 (Grafana + Prometheus)

고객으로부터 빠르게 피드백을 받기 위해 MVP(Minimal Viable Product) 상태로 배포하였습니다. 오픈채팅방 몇 군데에 소개 글과 링크를 뿌렸고 다행히도 많은 사람들이 관심을 가지고 사용해주셨습니

solo5star.tistory.com

 

사실 서비스에선 가입자 수보다 활성 사용자 수가 더 중요하긴 하지만... 팀원의 동기 부여를 위한 것이라 생각하면 나쁘지 않다!

 

 

피드백과 사용성 개선

배포와 동시에 설문조사도 진행하였다. 피드백을 정성껏 해준 사용자가 정말 많았다. (감사합니다) 피드백을 보고 개선해야 할 점이나 추가해야 할 기능들을 정하였다.

 

고객의 설문에 기반하여 기능을 추가하였을 때 중구난방의 앱이 될 수도 있지 않냐는 의견을 받기도 했는데, 정말 적은 고객이라도 만족시킬 수 있는 앱부터 만드는 것이 우선이라고 생각한다. 아무리 범용적이고 좋은 기능을 만들어도 사용하는 고객이 없다면 아무런 소용이 없다. 프로젝트의 핵심적인 가치를 위배하지 않는 한, 고객의 피드백을 우선적으로 반영하기로 했다.

 

설문 조사를 종합한 결과 두드러지는 의견을 모아볼 수 있었다. 특히 이미지 로딩이 느리다는 것이 가장 시급히 해결해야 할 문제라고 생각했고, 이를 다음 스프린트의 최우선 목표로 잡았다.

 

다음으로 메뉴를 볼 수 있으면 좋겠다는 의견이 많았다. 처음 앱을 기획할 땐 기획의 컨셉에 맞지 않다 생각하여 제외한 기능이었지만, 고객의 니즈에 따라 이 기능 또한 다음 스프린트에서 구현하기로 하였다.

 

 

3차 스프린트를 마치며...

정말 정신이 없었던 스프린트였다. 아침부터 저녁 늦게까지 쉬지 않고 달려도 끝이 보이지 않았는데, 막상 3차 스프린트가 끝나고 되돌아 보니 정말 많은 걸 했던 것 같다.

 

최대한 일찍 MVP를 만들고 배포를 했던 것이 정말 좋았던 것 같다. 팀에서 기획했던 대로 기능을 만들 때는 사용자가 정말로 기능을 좋아해줄까에 대한 확신이 없었다. 그렇지만 사용자로 부터 피드백을 받고 반영하여 기능을 만들어 나간다면 사용자가 좋아하는 앱이 될 수 있다는 확신을 가질 수 있을 것 같다.

Comments