React

FE와 TF App

Asset Type
File Type
When to use
Created by
Created time
2022/08/14 10:58
Last edited time
2022/08/22 23:21
Next.js 기반 프레임워크로 원칙을 다룬다.

Twelve-Factor app

한줄로 요약하면 독립적인 애플리케이션 운영을 위한 12가지 요소 다. 독립적인 은 사람, 시간, 환경 등 앱 운영시 영향을 받는 많은 것으로부터의 독립을 뜻한다.
12Factor는 BE, DevOps나 Cloud 영역에서 필수 원칙으로 알려져 있다. 충분히 검증되고 확실한 방법이다. 이 원칙대로 하면 자연스레 환경(IDC, OS 등)으로부터 독립된다. Cloud Native한 앱이 되니 쿠버나 AWS 어디든 올릴 수 있다. 또한 사람(개발자, 타팀 사람) 으로부터 독립되니 자연스레 개발과 운영이 분리되어 DevOps 도입이 수월해진다.
Spring Boot 방향성이 12Factor를 기반으로 발전해가니 자연스레 지켜지는 측면이 있다.
노드 서버로 서비스중이거나 Next.js의 API Routing, Middleware를 내보이고 있고 Node 서버에서 렌더링되는 컴포넌트를 출시했다. SSR 등 서버 영역을 도입하지 않은 팀은 몇 가지 원칙(6,7,8,9,12번)은 큰 의미가 없다. 나머지는 중요한 지침이 될 것이다.

1. 코드베이스

목표 : 버전 관리되는 하나의 코드베이스와 다양한 배포

사소해보이지만 가장 중요한 원칙이다. FT App의 기본이다. 이를 위반한 케이스 중 하나는 Monolithic이다.
일반적으로 서비스의 규모가 작을 땐 모놀리식으로 개발된다. UI 관련 코드도 같은 코드베이스에 있다. 서비스가 커져도 하나의 모놀리식을 유지하는 경우가 있다. 이 경우 하나의 코드베이스로 다양한 서비스를 배포하니 코드베이스 원칙 위반이다.
이런 경우 작업자들간의 의존도가 너무 높아 상시 배포가 불가능해진다. 많은 회사에서 MSA를 통해 코드베이스를 분리한다. 그 근거가 코드베이스 다.
하지만 코드베이스 원칙 하나만으로는 Repository 에 대한 명확한 기준을 잡기 어려운 경우가 있다. 요새 유행인 모노레포는? 위반인가?
위반이 아니다.
코드베이스레포 는 목적이 다르다. 코드베이스는 배포와 관련되어 있고, 레포는 업무 수행과 관련되어 있다.
레포를 어떻게 할지도 시작점에서 가장 중요한 결정이다. 콘웨이 법칙 이라는 명확한 이정표가 있다.
소프트웨어 구조는 해당 소프트웨어를 개발한 조직의 커뮤니케이션 구조를 닮게 된다.
업무 수행과 관련된 레포는 콘웨이 법칙으로 명확하게 제안할 수 있다. 하나의 레포는 단일 팀에서만 이용해야 한다. 만약 여러 팀이 하나의 레포를 이용하게 되면 필연적으로 공통 코드가 생기게 되고, 다른 팀과 의존성이 발생하게 된다.
모노레포는 각각의 패키지가 별도의 코드베이스가 되기 때문에 12Factor를 위반하지 않는다. 또한 해당 레포를 관리하는 팀은 단일 팀이기 때문에 콘웨이 법칙으로도 괜찮은 커뮤니케이션 구조를 갖는다.
코드베이스 에서 가장 중요한 것은 서비스 간의 의존성을 낮추고 독립된 커뮤니케이션 구조를 유지하는 것이다. 이를 만족하면 자연스레 독립된 배포 환경에 도달하게 된다. 이는 팀 작업 속도 향상을 가져오고 서비스 성장과 속도에도 영향을 끼치게 된다.

2. 종속성

목표 : 명시적으로 선언되고 분리된 종속성

외부 시스템으로부터의 독립 을 강조한다. 종속성은 FE에서도 잘 실천중이다.
애플리케이션 실행에 관련된 의존성을 package.json 에 선언하면 의존성 관리툴(npm, yarn)을 통해 손쉽게 설치하고 실행할 수 있다.
12Ft에서는 더 넓은 범위의 종속성을 얘기한다.
TF App은 전체 시스템에 특정 패키지가 암묵적으로 존재하는 것에 절대 의존하지 않는다. …
FE에서는 대표적으로 Node.js 버전이 있다. 이를 package.json or .nvmrc 에 명시해 종속성을 관리해야 한다.
또한 package.json 에 정확한 버전(^, ~ 제거) 사용과 lock 파일을 통해 종속성을 고정해야 한다.
필수는 아니지만 OS, 실행 환경에 대한 암묵적인 종속성을 벗어나기 위해 도커 컨테이너화할 수 있다.

3. 설정

목표 : 환경(environment)에 저장된 설정

앱을 운영하려면 반드시 환경(개발, 스테이징, 프로덕션)이 분리되어 있어야 한다. 환경별로 달라질 수 있는 예시는 이렇다.
API 정보
CDN 정보
로깅 레벨..
설정 은 이러한 환경별 변수를 외부에서 주입함으로써 환경으로부터 독립 을 완성하는 원칙이다.
Next.js에서 공식 제공하는 Environment Variables를 통해 환경을 분리할 수 있는데, 이 방법은 엄밀히 보면 설정 을 위반하는 것이다.
예를 들면 Next.js는 API_HOST를 환경별로 분기할 수 있다.
1.
.env.local 파일 생성
2.
API_HOST 정보 기입
API_HOST=https://local.api.com/
JavaScript
복사
3.
클라 단에서 NEXT_PUBLIC_API_HOST 사용
useEffect(() => { fetch(`${NEXT_PUBLIC_API_HOST}/api/my_series`) }, []);
JavaScript
복사
4.
next build
위와 같이 개발하면 웹팩이 돌면서 모든 NEXT…를 치환해준다.
useEffect(() => { // NEXT_PUBLIC_API_HOST 가 치환되어 번들됨 fetch(`https://local.api.com/api/my_series`); }, []);
JavaScript
복사
뭐가 위반인가? 빌드 종속성 이 생긴 부분이다. 빌드 시 치환이므로 반드시 빌드가 필요해진다. 즉, 서버를 띄우는 런타임에서는 환경변수를 변경할 수 없다.
이 구조는 다양한 문제를 야기한다. 빌드 타이밍에 따라 결과가 달라질 수 있는 여지가 생긴다.
Next.js는 이런 상황을 우회하기 위해 설정을 런타임에 주입할 수 있는 runtimeConfig를 제공한다.
// next.config.js module.exports = { publicRuntimeConfig: { API_HOST: process.env.API_HOST, }, }
JavaScript
복사
이 방법을 쓰면 브라우저에서도 런타임에 주입된 API_HOST를 읽어올 수 있다.
import getConfig from 'next/config' const { publicRuntimeConfig : { API_HOST } } = getConfig();
JavaScript
복사
주의할 것은 Automatic Static Optimization이 적용된 페이지의 경우 getConfig()가 undefined가 된다. 특정 조건을 만족했을 경우 SSR 없이 Static HTML 파일을 그대로 서빙하기 때문이다.
Static HTML을 사용하지 않으면 적극 활용해라. 개인적으로 추천하는 방식은 Sidecar로 Nginx를 띄워 프록시하는 방식이다.
혹은 환경 변수 URL 사용 방식이 있다.
runtimeConfig를 그대로 쓰면 아쉬운 점이 있다.
비동기로 runtimeConfig 설정하는 것이 불가능하다.
다른 시스템에 환경 변수가 존재하면
KMS로부터 읽어오는 경우
next.config.js은 파일명이 고정이라 타스 사용 불가
이 때문에 custom nextConfig를 이용했다. 그리고 12Factor 구현체인 Dotenv 패키지를 이용했다.
const envName = process.env.ENV_NAME; // 개발, 스테이징, 프로덕션 const parsedEnv = Dotenv.config({ path: `/env/${envName}` }).parsed || {}; const serverRuntimeConfig = ....; const publicRuntimeConfig = ....; const nextConfigJsPath = await findUp('next.config.js'); const nextConfigJs = require(nextConfigJsPath)(); const nextApp = next({ dev: process.env.NODE_ENV !== 'production', conf: { ...nextConfigJs, serverRuntimeConfig, publicRuntimeConfig, }, });
JavaScript
복사
한 번의 빌드로 어디든 이식 가능한 앱이 되어 설정을 만족하는 앱이 되었다.
$ ENV_NAME=dev npm start # 개발 $ ENV_NAME=staging npm start # 스테이징 $ ENV_NAME=production npm start # 프로덕션 $ ENV_NAME=production LOG_LEVEL=DEBUG npm start
JavaScript
복사

4. 백엔드 서비스

목표 : 백엔드 서비스를 연결된 리소스로 취급

여기서 백엔드는 서드파티 서비스로 불 수 있다. FE 대표적인 서드파티 서비스는 에러 수집 플랫폼인 Sentry가 있다.
백엔드 서비스 원칙을 지키려면 각 환경별로 자유롭게 선택할 수 있어야 한다. 예를 들어 로컬 개발시에는 개발 서버 내 Sentry, 프로덕션 배포 시 클라우드용 Sentry를 이용할 수 있도록 해야 한다.

5. 빌드, 릴리즈, 실행

목표 : 철저하게 분리된 빌드와 실행 단계

개발과 운영의 독립(DevOps)를 실현하기 위한 것이다.
많은 FE 프로젝트는 이렇지 못했다.
요구하는 건 심플하다. Next.js는 다음 프로세스로 코드를 프로덕션에 반영한다.
1.
개발 중인 코드를 dev 에 푸시
2.
CI/CD 통해 개발 서버에 반영(npm i → next build → deploy)
3.
QA 시점이 되어 release 생성
4.
CI/CD 통해 QA 서버에 반영(npm i → next build → deploy)
5.
QA 요청 → QA 완료
6.
release → main 푸시
7.
CI/CD 통해 프로덕션 서버에 반영(npm i → next build → deploy)
12Ft에 의하면 잠재적 문제가 있다.
개발에 배포된 코드와 prod 코드가 같다는 보장이 없다.
npm i마다 반드시 동일한 의존성이 설치된다는 보장이 없다.
next build마다 반드시 동일한 결과물이 나온다는 보장이 없다.
각 단계(develop → QA → 프로덕션)를 반드시 개발자가 관여해야 한다(코드와 배포의 의존성)
사실상 버저닝이 불가능하다. 릴리즈가 분리되어 있지 않아서.
한번 배포되면 해당 결과물을 다른 환경에서 실행할 수 없다.
그럼 어떻게 분리할까?
일단 3. 설정 원칙을 만족해야 한다. 릴리즈된 결과물을 여러 환경에서 실행해볼 수 있어야 분리가 가능하다.

빌드

모든 종속성을 설치하고 코드를 빌드하며 관련된 애셋을 결합한다.
$ npm install && next build
JavaScript
복사

릴리즈

빌드 단계에서 만들어진 빌드 파일에 배포에 필요한 설정을 결합한다.
도커를 이용해 배포에 필요한 모든 설정(환경변수, pm2)을 결합했다.
COPY . /kakaopage ... CMD ["pm2", "start", "app.js"] # 환경변수는 도커 run 시 주입
JavaScript
복사
배포가 될 파일(Artifact)를 생성한다.
$ docker build . -t kakaopage:v1.2.3
JavaScript
복사

실행

$ docker run -d -p 3000:3000 -e ENV_NAME=production kakaopage:v1.2.3
JavaScript
복사
1.
개발팀에서 개발을 진행한다. 변경시마다 빌드를 통해 통합하고 검증한다.(CI)
2.
QA와 릴리즈할 버전을 논의 후 빌드된 코드를 릴리즈한다.
3.
QA에서는 해당 릴리즈를 QA, 스테이징 환경에 올려 테스트한다.
4.
릴리즈가 충분히 테스트되면 운영에서 적절한 시점에 배포 후 실행한다.
배포 속도가 빨라진다.
배포된 코드의 릴리즈 버전을 손쉽게 확인할 수 있게 된다.(QA 킬링포인트)
QA 프로세스가 간단하다.
Cloud Native한 앱이 되었다. 하나의 릴리즈를 물리 서버와 쿠버 모두 올려 테스트할 수 있다.
쿠버에 올릴 수 있게 되면서 모든 git 브랜치마다 미리보기 링크를 제공하는 프리뷰 서버를 띄울 수 있게 되었다.
다섯번째 원칙까지 적용하는 것이 가장 중요한 고비다.

6. 프로세스

목표 : 애플리케이션을 하나 혹은 여러 개의 stateless 프로세스로 실행

대부분 FE에서 잘 지켜진다.
발생하기 쉬운 건 Sticky 로드 밸런싱 방식이 있다.
로드밸런서는 두가지다. 한번 요청한 서버를 계속 유지해주는 스티키와 무작위로 분배하는 라운드로빈 방식이 있다. 이 중 스티키방식은 프로세스 를 위반한다. 스티키를 구현하기 위해 사용자 정보를 캐싱하고, 같은 유저의 이후 요청도 같은 프로세스에 전달될 것을 가정하게 되므로 위반이다.
즉, 빼라는 것이다.
제 PC는 아이콘이 안떠요
전 보이는데, 쿠키 삭제해봐, 브라우저 재설치해봐
서버에 문제가 있어 아이콘 업로드가 실패했는데 스티키라서 특정 PC에서만 문제가 발생한 케이스다.
로드밸런서는 스태틱 HTML 파일 배포나 직접 서버 운영 시 필연적으로 존재하므로 기본적으로 어떤 정책을 가졌는지 익혀야 한다.

7. 포트 바인딩

목표 : 포트 바인딩을 사용해서 서비스를 공개한다.

잘 지켜진다. 브라우저를 통해 포트 바인딩된 서비스에 접근하게 된다.

8. 동시성

프로세스 를 지키면 동시성 도 간단히 지킬 수 있다. 프로세스 는 state가 내부에 없는 거다. 따라서 stateless해서 수평적 확장(scale-out)할 수 있다. 특히 Node.js 환경에서 더욱 중요하다.
node.js는 싱글 스레드 기반이다. node 명령으로 서버 실행시 단 하나의 스레드만 돌아간다. CPU 중 하나의 코어만 쓴다. 이는 낭비다. 그래서 pm2를 쓴다.
// ecosystem.config.js module.exports = { apps: [ { // ... script: './.output/server/index.js', // next.js 번들링된 결과 instances: 'max', // 장비에서 허용하는 최대 코어 수만큼 프로세스 확장 exec_mode: 'cluster', // 모든 CPU를 사용하기 위해선 cluster 모드 사용 // ... }, ], };
JavaScript
복사
// package.json ... "start": "NODE_ENV=production pm2-runtime start ecosystem.config.js" ...
JavaScript
복사
... # 프로세스 매니저(pm2) 글로벌 설치 RUN npm install -g pm2 ... CMD ["npm", "start"]
JavaScript
복사
core가 8개인 장비에 코어만큼 프로세스를 띄운다. 꼭 필요하다.
Next.js 프로젝트는 HTML 생성 시 CPU 자원을 많이 소비하기 때문에 꼭 필요한 조치라고 볼 수 있습니다.

9. 폐기 가능

목표 : 빠른 시작과 그레이스풀 셧다운을 통한 안정성 극대화

앱 종료를 안전하게 하는 게 그레이스풀 셧다운이다. 대부분 서버가 잘 하고 있다. 하지만 상식이다.
$ kill 1234 # node.js 가 돌아가고있는 프로세스ID
JavaScript
복사
kill 명령을 날리면 SIGTERM(15) 이 전달되는 것으로 안전하게 종료해라는 시그널이다.
노드 서버는 이 명령을 받으면 더이상 추가 요청을 받지 않고 이벤트 루프가 비어질 때까지 대기한다.
모든 큐가 비워지면 프로세스를 종료한다.
AWS나 쿠버에서 컨테이너 종료는 먼저 전달하는 시그널이니 알아야 한다.
다음과 같이 종료한다면 당장 바꿔라.
kill -9 1234 # SIGKILL(9) 프로세스 즉시종료(NOT SAFE)
JavaScript
복사

10. 개발/프로덕션 환경 일치

목표 : 개발, 스테이징, 프로덕션 환경을 최대한 비슷하게 유지

https 적용 여부, 인증서 종류 -> Mixed Content, 인증서 신뢰도 등
CDN 정책 -> web 2.0 버전, 캐싱 등
백엔드(API) 서버 정책 -> 이중화, CORS, timeout 등
서드파티(Sentry, SDK)
데이터 -> 포맷, 글자 길이 제한 등
등등..

11. 로그

목표 : 로그를 이벤트 스트림으로 취급

FE 개발자는 클라에게 CS가 들어왔을 때 테스트 기기에서도 재현되길 기도하며 CS 내용대로 테스트를 진행한다. 안되면 로그에 의지해야 한다.
이슈를 얼마나 빨리 파악할 수 있는지가 로그 시스템에 달려 있다. 중요도가 낮게 보이는 경향이 있다.
서버든 클라든 열심히 로그를 남기는 게 기본이라 가정하면 손쉽게 로그를 남기고 수집함으로써 중앙화된 로그 분석 시스템을 통해 열람하는 것을 목표로 한다.
카카오페이지 웹팀에서는 아래 네 가지 로그를 수집해 이슈트래킹 시 참고하고 있습니다.
1.
미들웨어 엑세스 로그(Nginx)
2.
애플리케이션 에러 로그(Sentry)
3.
서버사이드 로그
4.
클라이언트사이드 로그

액세스 로그

사용자 브라우저 종류, IP 주소, 요청 소요 시간, HTTP 상태 코드 등을 볼 수 있다.
Next.js는 SSR을 쓰니 HTTP 상태 코드가 더 중요하다.
4XX ~ 5XX가 발생하면 사용자에게도 이 화면을 보여준다.
액세스 로그 중앙화가 잘되어 있으면 알람을 받을 수 있다. 사용자 환경을 유추할 브라우저 버전, IP 주소 등을 빠르게 확인할 수 있다.
AWS를 쓴다면 Cloud watch로 간단하게 할 수 있다.
별도 인프라여도 쉽다. ELK Stack(Elasticsearch + Logstash + Kibana)인데, FE개발자가 할일은 해당 스태 관리자에게 액세스 로그 수집을 부탁하거나 직접 filebeat를 설치해 logstash로 전달하는 작업을 하면 된다.
여기까진 쉽다.
나머지 logstash, elasticsearch, kibana는 사내 존재할 데이터 관련 팀에 부탁하면 된다.
꼭 수집해라.

애플리케이션 에러 로그(Sentry)

SSR/CSR 모두 센트리로 에러를 수집한다.

서버사이드 로그

Winston을 통해 남긴다.
500m 단위로 rotate하고 스토리지를 과하게 차지하지 않도록 파일 제거 옵션을 추가한다. 로깅 레벨(ERROR, WARN, INFO, DEBUG)를 환경변수로 제어하기 위해 silly로 놓고 내부 로직을 통해 제어한다.
const combinedCustomFormat = combine( customTimestamp(), customFormat, splat(), ); const transports = []; const DailyRotateFile = winstonDailyRotateFile; transports.push( new DailyRotateFile({ filename: 'logs/application-%DATE%.log', format: combinedCustomFormat, datePattern: 'YYYY-MM-DD', // 파일이 500m 이상이면 rotate 된다. (뒤에 .1, .2 식으로 넘버링된다) 물론 하루가 지날때도 rotate 된다. maxSize: '500m', // 로그 파일(gz 미포함)이 15개 넘으면 예전 파일을 삭제한다 maxFiles: 15, // gzip 파일이 삭제되지 않는 버그가 있어 압축하지 않는다. (버그 : https://github.com/winstonjs/winston-daily-rotate-file/issues/125) zippedArchive: false, }), ); return winstonCreateLogger({ // 모든 로그를 보여주되, 로그 레벨 컨트롤은 커스텀 환경변수를 이용한다 level: 'silly', format: combinedCustomFormat, transports, });
JavaScript
복사
노드 이벤트 루프 특성으로 인해 아쉬운 부분이 있다.
사용자에 의해 여러 함수가 실행되는 경우 해당 함수들 간의 컨텍스트를 표현할 방법이 없다는 것이다.
웹툰 최신화를 클릭해서 아래 함수가 호출된다 가정한다.
async function view(viewerId, userId){ logger.info("[VIEWER]-[OPEN] viewerId: %s, userId: %s", viewerId, userId) try { await checkUserCanRead(); await useTicket(); } catch (err) { logger.err("[VIEWER]-[ERROR] reason: %s", err.message, err); ... } }
JavaScript
복사
뷰어를 오픈하면 info 로그를, 에러 상황에서 err 에러를 남긴다.
2021-09-13 23:23:39.385+09:00 info : [VIEWER]-[OPEN] viewerId: 1234, userId: 555 2021-09-13 23:23:39.385+09:00 info : [VIEWER]-[OPEN] viewerId: 5678, userId: 342 2021-09-13 23:24:39.385+09:00 err : [VIEWER]-[ERROR] reason: 탈퇴한 유저입니다
Plain Text
복사
어떤 요청에서 이어진 에러인가? 그렇다고 모든 정보를 남길 건가?
로그를 남겼을 때 하나의 요청으로 간주할 정보가 필요했다. Thread Id 처럼.
한번의 요청에 호출되는 여러 함수를 위한 id가 필요하다.
2021-09-13 23:23:39.385+09:00 info --- [51234111]: [VIEWER]-[OPEN] viewerId: 1234, userId: 555 2021-09-13 23:23:39.385+09:00 info --- [41434234]: [VIEWER]-[OPEN] viewerId: 5678, userId: 342 2021-09-13 23:24:39.385+09:00 err --- [51234111]: [VIEWER]-[ERROR] reason: 탈퇴한 유저입니다
Plain Text
복사
하지만 노드는 싱글 스레드 구조라 기본 기능으로 불가능한 방식이다. 하지만 async_hooks 가 있다. 2년째 잘 쓰고 있다.
express-http-context를 쓰고 있다.
// express-http-context 미들웨어 등록 server.use(httpContext.middleware) ... server.get("/viewer", (req, res) => { // 최초 요청 시 httpContext 세팅 httpContext.set('contextId', uuidv4()) // 랜덤한 값을 생성해 contextId 등록 }) ... // 코드 전체에서 공용으로 사용하는 log 함수 function log(logLevel, logMessage){ // httpContext 로부터 contextId 를 가져와 로깅 정보에 추가 winstonLogger.log(logLevel, `[${httpContext.get('contextId')}] : ${logMessage}`) }
JavaScript
복사
이렇게 contextId를 로그에 남기면 로그 분석 시스템에서 이슈트래킹시 흐름을 추적할 수 있어 상당히 유용하다.

클라이언트사이드 로그

getInitialProps 를 사용하면 SSR/CSR 둘다 동작하므로 수집이 필요하다.
여기서는 SSR, CSR에 남겨지는 로그 사이에 어떻게 컨텍스트를 유지할 수 있는지를 얘기하려 한다.
cookie를 이용해 유지한다.
// _app.tsx function getInitialProps({ ctx: { res } }) { ... if(isServerSideRendering) { const contextId = httpContext.get('contextId'); res.cookie(COOKIE_CONTEXT_KEY, contextId, { .. httpOnly: true, expires: EXPIRES_DATE, .. }); } }
JavaScript
복사
쿠키에 존재하는 contextId도 남기면 전체 플로우를 추적할 수 있다.

12. Admin 프로세스

목표 : admin/maintenance 작업을 일회성 프로세스로 실행

배포 관련 스크립트를 동일 코드베이스에 넣기
여러 유틸리티성 스크립트를 같은 코드베이스에 넣기
동일한 종속성 분리 기술을 사용하기.