The Twelve-Factor app

younghyun·2023년 3월 7일
0

The Twelve-Factor app

공식 웹사이트에 잘 설명되어있지만 제가 이해한 대로 한 줄 요약하면 독립적인 애플리케이션 운영을 위한 12가지 요소.독립적인은 사람, 시간, 환경 등 애플리케이션 운영 시 영향을 받는 많은 것으로부터의 독립 뜻함.

12Factor는 BE(Back-end) 영역, 특히 DevOps나 Cloud 관련 영역에서 필수 원칙으로 알려져 있음. 십계명과 같은 위상이라 하여도 과언이 아님. 그 이유는 애플리케이션을 독립적으로 만들 때 충분히 검증됐고 확실한 방법이기 때문. 예를들면 12Factor 원칙대로 애플리케이션을 구성하면 자연스럽게 환경(IDC, OS, 등)으로부터 독립. Cloud Native 한 애플리케이션이 되니 쿠버네티스나 AWS 어디든 올릴 수 있게 됨. 또한 사람(개발자, 타팀 사람)으로부터 독립되니 자연스럽게 개발과 운영이 분리되어 DevOps 도입이 수월해짐. 그러니 BE 영역에선 일단 지키고 보는 필수 원칙이 됨. 사실 BE 영역의 프레임워크(대표적으로 Spring Boot) 방향성이 12Factor를 기반으로 발전해가니 자연스레 지켜지는 측면도 있음.

그럼 FE개발자도 알아야할까요? 저는 애플리케이션을 개발하고 운영한다면, FE개발자도 알면 좋은 부분이라 생각. 참고로 카카오엔터테인먼트에서 노드 서버(Next.js)로 서비스 중인 카카오페이지, 카카오웹툰은 FE개발자가 직접 운영하고 있음. 특히나 점차 FE개발의 스펙트럼이 서버 영역으로 넓혀지고 있는 지금이라면 더욱 필수라 생각. 최근 Next.js 에서 API Routing, Middleware를 내보이고 있고 React 에서도 Node 서버에서 렌더링되는 컴포넌트를 출시한 것을 보면서 더욱 확신이 듦. 물론 SSR 등의 서버영역을 도입하지 않은 개발팀에게는 몇 가지 원칙(6,7,8,9,12번)은 큰 의미가 없겠지만 나머지 원칙은 개발 및 운영에 중요한 지침이 될거라 생각.

이 글에서는 FE개발자인 우리가 어떻게 12Factor를 이해해야 하는지 알아보고 실제 서비스에서 어떻게 도입했는지 하나하나 얘기함.

코드베이스

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

사소해보이지만 가장 중요한 원칙. 12Factor 애플리케이션의 기본(베이스)이라 할 수 있는 부분. 하나의 코드베이스는 하나의 앱을 가지고 있어야 함. 이를 위반한 케이스 중 하나는 바로 그 유명한 모놀리식(Monolithic).

일반적으로 서비스의 규모가 작을 땐 한 팀에서 모놀리식으로 개발. UI 관련 코드도 같은 코드베이스에 존재. 그러다 서비스 규모가 커지면서 점차 사람이 늘어나고 팀도 하나 둘 늘어나게 됨. 회원팀, 상품팀, 주문팀, 그리고 FE팀 등등 팀이 늘어나는데 하나의 모놀리식을 유지하는 경우가 있음. 이 경우 하나의 코드베이스로 여러 다양한 서비스를 배포하니 코드베이스 원칙 위반. 제가 경험했던 프로젝트 중에 많게는 120명이 하나의 모놀리식으로 개발하는 경우도 있었음. 이 정도 수준이 되면 각 작업자들간의 의존도가 너무 높아 상시 배포가 불가능해짐. (매주 목요일 새벽에 대부분의 개발자가 출근해 정기배포를 진행해야했음). 많은 회사에서 MSA(Micro Service Architecture)를 통해 코드베이스를 분리하는데요, 그 근거가 되는 것이 바로 12Factor의 첫 번째 원칙인 코드베이스.

하지만 코드베이스 원칙 하나만으론 레포(Repository)에 대한 명확한 기준을 잡기 어려운 경우가 있음. 예를 들면 최근 각광받고 있는 모노레포는 어떨까요? 모노레포는 하나의 레포에서 여러개의 앱을 배포할 수 있는데요, 이 경우 코드베이스 위반일까요?

답부터 얘기하자면 위반이 아님.

코드베이스와 레포는 목적이 다름. 12Factor에서 얘기하는 코드베이스 는 배포 와 관련되어있고, 레포 는 업무 수행과 관련되어있다고 볼 수 있음.

레포 를 어떻게 할 것인지도 코드베이스 와 마찬가지로 애플리케이션을 구성하는 시작점에서 가장 중요한 결정 중 하나로 볼 수 있음. 여기에 대해선 콘웨이 법칙(Conway’s law)이라는 명확한 이정표가 있음. 일반적으로 12Factor의 첫번째 원칙을 얘기할 때 콘웨이 법칙도 같이 거론됨. 콘웨이 법칙은 다음과 같음.

소프트웨어 구조는 해당 소프트웨어를 개발한 조직의 커뮤니케이션 구조를 닮게 된다. - 콘웨이

다양한 곳에 적용할 수 있는 법칙. 조직의 커뮤니케이션 구조는 정말로 코드나 서비스 아키텍처에 그대로 녹아져있음. 업무 수행과 관련된 레포 는 콘웨이 법칙으로 명확하게 제안할 수 있음. 하나의 레포는 단일 팀에서만 이용해야 함. 만약 여러 팀에서 하나의 레포를 이용하게 되면 필연적으로 공통 코드가 생기게되고, 개발과 운영 시 다른 팀 작업분과 의존성이 발생하게 됨. 결국 여러 팀이 하나의 레포를 사용하듯이, 하나의 커뮤니케이션 구조로 들어오게 됨. 반대도 콘웨이 법칙이 적용. 하나의 팀에서 여러 레포를 사용하게 되면 각 레포의 담당자 간 사일로(Silo)가 발생.

모노레포는 각각의 패키지가 별도의 코드베이스가 되기 때문에 12Factor를 위반하지 않음. 또한 해당 레포 를 관리하는 팀은 단일 팀이기 때문에 콘웨이 법칙으로 볼 때도 괜찮은 커뮤니케이션 구조를 가짐.

첫 번째 원칙인 코드베이스에서 가장 중요한 것은 서비스 간 의존성을 낮추고 독립된 커뮤니케이션 구조를 유지하는 것. 이를 만족하면 자연스럽게 독립된 배포 환경에 도달하게 됨. 이는 곧 팀의 작업 속도 향상을 가져오고 서비스의 성장과 속도에도 영향을 끼치게 됨.

종속성

목표 : 명시적으로 선언되고 분리된 종속성
두 번째 원칙인 종속성에서 강조하는 것은 외부 시스템으로부터의 독립. 종속성은 FE에서 어느 정도 잘 실천하고 있는 원칙이라 생각함. 애플리케이션 실행에 관련된 의존성을 package.json 에 선언하면 의존성 관리툴(npm, yarn)을 통해 손쉽게 설치하고 실행할 수 있음.

하지만 12Factor에선 조금 더 넓은 범위의 종속성 을 얘기하고 있음.

Twelve-Factor App은 전체 시스템에 특정 패키지가 암묵적으로 존재하는 것에 절대 의존하지 않음. 종속선 선언 mainifest를 이용하여 모든 종속성을 완전하고 엄격하게 선언. 더 나아가, 종속성 분리 툴을 사용하여 실행되는 동안 둘러싼 시스템으로 암묵적인 종속성 “유출”이 발생하지 않는 것을 보장. 이런 완전하고 명시적인 종속성의 명시는 개발과 서비스 모두에게 동일하게 적용됨.

암묵적인 종속성은 여러 가지가 있지만, FE개발에선 대표적으로 Node.js 버전이 있음. 코드에서 사용 중인 Node.js 버전을 package.json(volta), 혹은 .nvmrc(nvm) 에 명시해 종속성을 관리해야 함.

또한 package.json 에 정확한 버전(^, ~ 제거) 사용과 lock 파일을 통해 종속성을 고정(Dependency Pinning) 해야 함.

마지막으로 필수는 아니지만 강하게 제안하는 것은 OS, 실행환경에 대한 암묵적인 종속성을 벗어나기 위해 도커 컨테이너화 하는 것.

설정

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

애플리케이션을 운영하려면 반드시 환경(개발, 스테이징, 프로덕션 등)이 분리되어있어야 함. 환경별로 달라질 수 있는 예시는 다음과 같음.

  • API 정보
  • CDN 정보
  • 로깅 레벨
  • etc….
    12Factor의 세 번째 원칙인 설정 은 이러한 환경별 변수를 코드 내부에 두지 않고 외부로부터 주입받음으로써 환경으로부터 독립을 완성하는 원칙.

Next.js 에서 공식적으로 제공하는 Environment Variables 를 통해 환경을 분리할 수 있음. 이 방법은 엄밀히 보면 설정 원칙을 위반한 방법.

예를들면, Next.js 프로젝트에선 아래 절차대로 API_HOST 를 환경별로 분기할 수 있음.

  1. .env.local 파일 생성
  2. API_HOST 정보 기입
API_HOST=https://local.api.com/
  1. 클라이언트단 코드에서 NEXT_PUBLIC_API_HOST 사용
useEffect(() => {
  fetch(`${NEXT_PUBLIC_API_HOST}/api/my_series`)
}, []);
  1. next build
    위와 같이 개발할 경우 웹팩이 돌면서 모든 NEXT_PUBLIC_API_HOST 를 https://local.api.com/ 로 치환(replacement)하게 됨.
useEffect(() => {
  // NEXT_PUBLIC_API_HOST 가 치환되어 번들됨
  fetch(`https://local.api.com/api/my_series`);
}, []);

어떤 부분이 12Factor의 설정 원칙을 위반했을까요? 바로 빌드 종속성 이 생긴 부분. Next.js 에서 기본으로 제공하는 환경변수 방식은 빌드시 문자열을 치환해버리기 때문에 반드시 빌드를 진행해야만 함. 이 말은 즉, 서버를 띄우는 런타임에 환경변수를 변경할 수 없게 됨. 만약 동일한 코드를 다른 환경(개발 -> 스테이징)에 배포한다면 반드시 다시 빌드해야 함.

이러한 구조는 다양한 문제를 야기할 수 있음. 일반적으로 프로덕션에 배포되기까지 여러 환경을 거치게 되는데요, 각 환경별로 빌드하게 된다면 빌드 타이밍에 따라 결과가 달라질 수 있는 여지가 생김. 이는 결국 빌드 결과물에 대한 신뢰도에 영향을 끼치고 잠재적인 문제들에 노출될 수 있음.

Next.js 에선 이런 상황을 우회하기 위해 설정을 런타임에 주입할 수 있는 runtimeConfig 를 제공함.

// next.config.js
module.exports = {
  publicRuntimeConfig: {
    API_HOST: process.env.API_HOST,
  },
}

이 방법을 사용하면 브라우저에서도 런타임에 주입된 API_HOST 를 읽어올 수 있음.

import getConfig from 'next/config'

const { publicRuntimeConfig : { API_HOST } } = getConfig();

주의할 점 하나는 Next.js 의 Automatic Static Optimization 이 적용된 페이지의 경우 getConfig() 가 undefined 가 됨. 이유는 특정 조건을 만족했을 경우 SSR(Server Side Rendering) 없이 Static HTML 파일을 그대로 서빙하기 때문, 카카오페이지 웹은 Static HTML 을 사용하지 않기 때문에 runtimeConfig 를 적극 활용. 반드시 Static HTML 을 이용해야한다면 다른 방식을 추천. 개인적으로 가장 추천하는 방식은 Sidecar로 Nginx 를 띄워 프록시 하는 방식. 혹은 환경변수 URL을 이용하는 방식도 괜찮아보임. 각자 상황에 맞는 적절한 방식으로 구현하는 것을 추천함. 저희 서비스 상황에는 환경변수 주입을 구현할 때 runtimeConfig 가 가장 적절하다 판단됨.

하지만 runtimeConfig 를 그대로 사용하려면 아쉬운 점이 꽤 있음.

  • 비동기로 runtimeConfig 설정하는 것이 불가능함.
    • 다른 시스템에 환경 변수가 존재하는 경우(Centralized Configuration)
    • KMS(Key Management System) 로부터 읽어오는 경우
  • Next.js 설정파일(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,
  },
});

이제 런타임에 환경변수를 변경하는 것이 가능해짐. 한 번의 빌드로 각 환경에서뿐 아니라 어디든 이식 가능한 애플리케이션이 됨. 세 번째 원칙인 설정을 만족하는 앱이 됨.

$ ENV_NAME=dev npm start                         # 개발
$ ENV_NAME=staging npm start                     # 스테이징
$ ENV_NAME=production npm start                  # 프로덕션
$ ENV_NAME=production LOG_LEVEL=DEBUG npm start  # 프로덕션 환경에 로그 레벨만 변경

백엔드 서비스

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

네 번째 원칙인 백엔드 서비스는 말 그대로 백엔드 서비스로부터 독립을 요구하는 원칙.

여기서 백엔드란 서드파티 서비스로 볼 수 있음. FE 의 대표적인 서드파티는 에러 수집 플랫폼인 Sentry가 있음. 백엔드 서비스 원칙을 지키기 위해선 각 환경별로 자유롭게 선택할 수 있어야함. 예를들면 로컬 개발시엔 개발 서버에 설치된 Sentry, 프로덕션 배포시엔 클라우드용 Sentry 를 이용할 수 있도록 선택할 수 있어야 함. 일반적인 FE 환경이라면 대부분 잘 지키고 있는 원칙이라 생각되어 바로 다음 원칙으로 넘어감.

빌드, 릴리즈, 실행

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

다섯 번째 원칙인 빌드, 릴리즈, 실행은 개인적으로 아주 중요하게 생각하는 원칙. 이 원칙을 만족해야 개발과 운영의 독립(DevOps) 를 실현할 수 있음. 지금까지 본 많은 FE 프로젝트는 대부분 이 원칙과 동떨어져 운영되고 있었음. 필자가 지금 팀에서 가장 시급하다 생각해 처음으로 적용한 것도 이 다섯번째 원칙이었음.

빌드, 릴리즈, 실행 원칙이 요구하는 것은 아주 심플. 말 그대로 빌드, 릴리즈, 실행 단계를 철저하게 분리하는 것. 보편적인 Next.js 프로젝트는 다음 프로세스를 통해 코드가 프로덕션에 반영됨. git 전략은 git flow 를 쓴다고 가정.

  1. 개발 중인 코드를 develop 브랜치에 푸시
  2. CI/CD 통해 개발 서버에 반영(npm install -> next build -> deploy)
  3. QA 시점이 되어 release 브랜치 생성
  4. CI/CD 통해 QA 서버에 반영(npm install -> next build -> deploy)
  5. QA 요청 -> QA 완료
  6. release -> main 푸시
  7. CI/CD 통해 프로덕션 서버에 반영(npm install -> next build -> deploy)
    사실 크게 나쁘지는 않은 방식. 대부분 큰 문제 없이 운영됨. 하지만 12Factor에 의거하면 몇 가지 잠재적인 문제가 있음.
  • 개발에 배포된 코드와 리얼에 반영된 코드가 같다는 보장이 없음.
  • npm install 마다 반드시 동일한 의존성이 설치된다는 보장이 없음.
  • next build 마다 반드시 동일한 결과물이 나온다는 보장이 없음.
  • 각 단계(develop -> QA -> 프로덕션)를 반드시 개발자가 관여해야 함. (코드와 배포 간 의존성으로 인해)
  • 사실상 버저닝이 불가능 함. 릴리즈가 분리되어있지 않으니 v1.2.3(SemVer) 같은 버전이 불가능 함. 버전이 git commit 에 의존함. 어찌 분리해도 한 버전을 여러 환경에서 실행해볼 수 없음.
  • 한번 배포되면 해당 결과물을 다른 환경에서 실행해볼 수 없음. (리얼에 배포된 파일을 로컬에서 실행해볼 수 없습니다)
    그러면 빌드, 릴리즈, 실행 을 어떻게 분리할 수 있을까요?

일단 III. 설정 원칙을 만족해야함. 릴리즈 된 결과물을 여러 환경에서 실행해볼 수 있어야 분리가 가능해짐. 설정을 분리했다면 아래와 같이 분리할 수 있음.

빌드

모든 종속성을 설치하고 코드를 빌드하며 관련된 에셋을 결합.

$ npm install && next build

릴리즈

빌드 단계에서 만들어진 빌드파일에 배포에 필요한 설정을 결합. 저희는 도커를 이용해 배포에 필요한 모든 설정(환경변수, pm2 등)을 결합함.

COPY . /kakaopage
..
CMD ["pm2", "start", "app.js"]  # 환경변수는 도커 run 시 주입

배포가 될 파일(Artifact) 를 생성함.

$ docker build . -t kakaopage:v1.2.3

실행

실행 단계에서는 실행환경에 맞는 환경변수를 주어 실행

$ docker run -d -p 3000:3000 -e ENV_NAME=production kakaopage:v1.2.3

이렇게 각 단계가 분리되었을 때 어떤 일이 일어날까요?

  1. 개발팀에서는 개발을 진행함. 코드 변경시마다 빌드를 통해 통합하고 검증(Continuous Integration)
  2. QA와 릴리즈할 버전을 논의 후 빌드된 코드를 v1.2.3으로 릴리즈 함.
  3. QA에서는 해당 릴리즈를 QA 환경, 스테이징 환경에 올려 테스트해봄.
  4. 릴리즈가 충분히 테스트되면 운영에서 적절한 시점에 배포 후 실행 함.
    차이점이 느껴지나요? 개발 프로세스 자체는 크게 변하지 않았음. 가장 큰 차이는 개발과 인프라 간, 그리고 개발과 운영간 의존성이 낮아져 느슨한 결합도를 유지할 수 있다는 점.

또한 팀에서는 12Factor의 다섯번째 원칙까지 도달했을 때 운영중인 Next.js 프로젝트의 독립성이 놀랍도록 높아진 것을 느낄 수 있었음.

  • 배포 속도가 빨라짐. 기존엔 배포마다 install, build 를 하다보니 15분 내외로 걸렸는데 지금은 1분 내외로 배포가 진행됨.
  • 배포된 코드의 릴리즈 버전을 손쉽게 확인할 수 있게 됨. (QA 킬링 포인트)
  • QA 프로세스가 간단해짐. 기존엔 배포 타이밍도 조율하고 QA팀과의 커뮤니케이션 미스도 잦았는데 지금은 명확한 버전만 전달하면 되니 간단해짐.
  • 자연스럽게 Cloud Native 한 앱이 되었음. 하나의 릴리즈를 물리 서버와 쿠버네티스 모두에 올려서 테스트할 수 있게 되었음.
  • 쿠버네티스에 올릴 수 있게 되면서 모든 git 브랜치마다 미리보기 링크를 제공하는 프리뷰 서버를 띄울 수 있게 되었음.
    많은 사람이 행복해졌음.

개인적으로 이 다섯번째 원칙까지 적용하는 것이 12Factor를 적용할 때 가장 중요한 고비라 생각. 지금까지 관찰한 여러 프로젝트 중 다섯가지 원칙을 모두 지킨 프로젝트가 손에 꼽음. 그만큼 실천하기 어려운 부분이 많음. 왜냐하면 배포 프로세스를 변경해야 하기 때문. 협업자들 간에 협의와 변경 절차가 필요하기 때문에 실천하기 어려울 수 있음. 필자가 경험한 바로는 충분히 합리적인 원칙이므로 시간이 걸릴지언정 결국 적용할 수 있었음. 이 원칙을 모두 지켰을 때 누릴 수 있는 자유로움과 역동성이 있기 때문에 자신 있게 추천함.

프로세스

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

대부분의 FE 환경에서는 잘 지키고 있는 원칙. 기본적으로 HTTP 요청은 Stateless 하고, 사용자의 브라우저 환경은 매우 독립적.

이 원칙에 대해선 발생하기 쉬운 케이스인 스티키(Sticky) 로드밸런싱 방식에 대해 한번 언급하고 넘어갈까 함.

로드밸런서는 크게 두 가지 방식으로 나눌 수 있는데요, 한번 요청한 서버를 계속 유지해주는 스티키 방식과 무작위로 분배하는 라운드로빈 방식이 대표적. 이 중 스티키 방식은 12Factor의 여섯번째 원칙인 프로세스 원칙을 위반. 스티키를 구현하기 위해선 사용자 정보를 캐싱하고, 같은 유저의 이후 요청도 같은 프로세스에 전달될 것을 가정하게 되는데 이는 명백한 프로세스 원칙 위반. 로드밸런싱 방식을 선택한다면 반드시 스티키 방식을 빼고 선택해야 함. 안그러면 다음과 같은 상황을 맞닥뜨릴 수 있음. (눈물겨운 경험담.)

“개발자님! 제 PC 에선 계속 아이콘이 안나와요!”

“어라? 제 PC 에선 나오는데.. 껐다 켜보시겠어요? 아니면 쿠키도 삭제해주세요. 안되면 음.. 브라우저도 다시 설치해보시고 음..”

알고보니 특정 서버에 문제가 있어 아이콘 파일 업로드가 실패했는데, 로드밸런서 설정이 스티키로 되어있어 특정 PC(IP) 에서만 문제가 발생한 케이스. 로드밸런서는 스태틱 HTML 파일 배포나 직접 서버를 운영할 때 필연적으로 존재하는 부분이기 때문에 기본적으로 어떤 정책을 갖고 있는지 익혀두는 것이 좋음.

포트 바인딩

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

FE개발 환경에서는 필연적으로 지켜지는 원칙. 보편적인 FE 애플리케이션은 브라우저를 통해 포트 바인딩 된 서비스에 접근함.

동시성(Concurrency)

여섯 번째 원칙인 프로세스 원칙을 지켰다면 동시성 원칙도 간단하게 지킬 수 있음. 프로세스 원칙은 프로세스 내부에 상태(state)가 존재하지 않는 것. 반드시 공유해야 할 상태가 없으니(stateless) 수평으로 확장(scale-out) 할 수 있음. 이 특징은 특히 node.js 환경에서 더욱 중요함.

node.js 는 싱글 스레드 기반으로 돌아감. node 명령으로 서버를 실행했을 때 단 하나의 스레드만 돌아감. 즉, CPU 중 단 하나의 core만 사용. 보통 작은 시스템도 2개 이상의 코어를 갖고 있는데 기본 node 실행으로 운영하는 건 심각한 리소스 낭비. 프로세스를 수평으로 확장하는 가장 손쉬운 방법은 대표적인 프로세스 매니저인 pm2를 이용하는 것.

카카오페이지 웹은 아래와 같은 설정으로 프로세스 확장을 하고 있음.

// ecosystem.config.js
module.exports = {
  apps: [
    {
      // ...
      script: './.output/server/index.js',    // next.js 번들링된 결과
      instances: 'max',                       // 장비에서 허용하는 최대 코어 수만큼 프로세스 확장
      exec_mode: 'cluster',                   // 모든 CPU를 사용하기 위해선 cluster 모드 사용
      // ...
    },
  ],
};
// package.json
...
    "start": "NODE_ENV=production pm2-runtime start ecosystem.config.js"
...
  ...
  # 프로세스 매니저(pm2) 글로벌 설치
  RUN npm install -g pm2
  ...
  CMD ["npm", "start"]

위와 같이 설정하면 아래처럼 core가 8개인 장비에 코어만큼 프로세스가 떠 있는 것을 볼 수 있음. Next.js 프로젝트는 HTML 생성 시 CPU 자원을 많이 소비하기 때문에 꼭 필요한 조치라고 볼 수 있음.

폐기 가능(Disposability)

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

애플리케이션 종료를 안전하게 할 수 있는 것을 Graceful Shutdown 이라 함. 대부분의 서버는 이 부분을 잘 수행해주기 때문에 크게 고려하지 않아도 됨. 그래도 상식선에서 꼭 알고 있는것이 좋음.

서비스가 PID 1234 로 돌아가고 있다고 가정.

$ kill 1234   # node.js 가 돌아가고있는 프로세스ID

어떤 일이 일어날까요? 기본 kill 명령을 날렸을 때 SIGTERM(15) 시그널이 전달되는걸 알고 있나요? 이 시그널은 “안전하게 종료해” 라는 시그널. 우리의 node.js 서버는 이 명령을 받으면 더 이상의 추가요청 받는 것을 중단하고 이벤트 루프가 비어질 때 까지 대기함. 그리고 모든 큐가 비워지면 프로세스를 종료함. SIGTERM 명령은 범용적으로 쓰이는 명령어고 AWS 나 쿠버네티스 등에서 컨테이너를 종료할 때 먼저 전달하는 시그널이니 상식선에서 꼭 알고 있어야함. 프로세스 종료 관련해서는 이 글에 잘 정리되어있음.

혹시 이 글을 읽는 독자 중 다음처럼 프로세스를 종료하고 있다면 당장 바꿔야 함.

kill -9 1234     # SIGKILL(9) 프로세스 즉시종료(NOT SAFE)

개발/프로덕션 환경 일치

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

다른 원칙보다 강제성이 크지 않음. 하지만 개발자를 행복하게 만드는 정책 으로 볼 수 있음.

골자는 “개발, 스테이징, 프로덕션 각 환경 사이에 차이가 없도록 하자” 임. 이 차이는 대표적으로 시간, 담당자, 툴 세 가지가 있음.

시간은 배포되는 코드에 대한 기억력과 관련 있으니 간격이 작을수록 좋고, 배포 시 어떤 이슈가 생길지 모르니 코드 작성자도 같이 모니터링하는 것이 좋음. 그럼 개발 환경과 프로덕션 환경이 최대한 유사해야할 이유는 무엇일까요? 이유는 잠재적인 위험 요소를 빠르게 파악하기 위해서. 개발 환경과 프로덕션이 다를 경우, 프로덕션에 배포하기 전에는 이슈를 파악하지 못하는 경우가 왕왕 발생함.

예를들면 아래와 같이 개발과 프로덕션간 적용 여부나 정책이 달라서 코드가 프로덕션에 배포된 후에 뒤늦게 문제를 발견하게 될 수도 있음.

  • https 적용 여부, 인증서 종류 -> Mixed Content, 인증서 신뢰도 등
  • CDN 정책 -> web 2.0 버전, 캐싱 등
  • 백엔드(API) 서버 정책 -> 이중화, CORS, timeout 등
  • 서드파티(Sentry, SDK)
  • 데이터 -> 포맷, 글자 길이 제한 등
  • 등등..
    이 원칙은 아무래도 정책 관련된 내용이니 단일 팀에서만 진행하기는 곤란한 면이 있음. 개발/프로덕션환경 일치 원칙은 여러 사람, 팀, 서비스에 얽혀있는 부분이다보니 시간을 갖고 천천히 적용해가야 할 원칙. 어렵겠지만 하나씩 맞춰가다 보면 어느 순간 개발환경이 확연히 좋아지는걸 느낄 수 있는 원칙이기도 함. 개발 환경이 프로덕션 환경과 유사하게 될수록 장애 발생율이 감소하고 개발자도 더욱 자신감 있게 개발할 수 있음.

로그

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

FE개발자들은 클라이언트로부터 CS가 들어왔을 때 부디 테스트 기기에서도 재현되길 기도하며 CS 내용대로 테스트를 진행함. 재현되면 다행이지만 사용자 환경이 워낙 다양해 재현되지 않는 경우도 많음. 이때 사실상 의지할 곳은 로그밖에 없음. 이슈를 얼마나 빠르게 파악할 수 있는지는 결국 로그 시스템이 얼마나 갖춰있고, 이슈 트래킹이 얼마나 간편한지에 달려있음. 서버개발 시 로그의 중요성은 이루 말할 수 없을 정도로 높은 반면 FE 영역에서는 상대적으로 중요도를 낮게 보는 경향이 있는 것 같음.

열한 번째 원칙인 로그 는 일단 그 중요성을 기저에 깔고 감. 로그의 중요성은 아무리 강조해도 지나치지 않음. 서버든 클라이언트든 열심히 로그를 남기는 게 기본이라 가정했을 때, 손쉽게 로그를 남기고 수집함으로써 종내엔 중앙화된 로그 분석 시스템을 통해 열람하는 것을 목표로 함.

카카오페이지 웹팀에서는 아래 네 가지 로그를 수집해 이슈트래킹 시 참고하고 있음.

  1. 미들웨어 엑세스 로그(Nginx)
  2. 애플리케이션 에러 로그(Sentry)
  3. 서버사이드 로그
  4. 클라이언트사이드 로그

모두 중요한 로그라 하나하나 짚어봄.

액세스 로그

엑세스 로그를 통해 사용자 브라우저 종류, IP 주소, 요청 소요 시간, HTTP 상태 코드 등을 볼 수 있습니다. Next.js 를 사용한다면 대부분 서버사이드렌더링을 이용하기 때문에 HTTP 상태 코드가 더욱 중요함. 서버사이드렌더링 시 4XX~5XX 에러가 발생하면 사용자에게도 같은 에러 코드와 함께 에러 화면을 보여주게 됨. 엑세스 로그 중앙화가 잘 되어있다면 해당 에러 발생 시 알람을 받을 수 있고, 사용자 환경을 유추할 수 있는 브라우저 버전, IP 주소 등을 빠르게 확인할 수 있음.

엑세스 로그는 AWS 를 사용한다면 CloudWatch 같은 모니터링 시스템을 통해 수집과 시각화를 간단하게 할 수 있음.

별도 인프라를 운영중이어도 로그 수집 자체는 어렵지 않음. 엑세스 로그를 시각화할 때 가장 많이 사용하는 방식이 ELK Stack(Elasticsearch + Logstash + Kibana) 인데요, 아마 소속된 조직에서 크거나 작게 사용 중인 스택일 것으로 생각. FE개발자가 할 일은 해당 스택 관리자에게 엑세스 로그 수집을 부탁하거나, 직접 filebeat 를 설치해 logstash 로 전달하는 작업 을 하면 됨. 여기까지는 FE개발자도 손쉽게 할 수 있음. 나머지 logstash, elasticsearch, kibana는 사내에 (아마도) 존재할 데이터 관련 팀에 부탁하면 됨. 관련 팀이 없다면 조금(?) 발품팔면 직접 구축할 수도 있음. 정말 중요한 로그니 만약 수집중이 아니라면 꼭 수집하는 것을 추천함.

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

SSR/CSR 모두 Sentry 로 에러를 수집하고 있음. 크게 특별한 점은 없음.

서버사이드 로그

서버사이드렌더링 시 발생하는 로그는 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,
});

여기까지 설정했을 때 Node.js 의 이벤트루프 특성으로 인한 아쉬운 부분이 있었음. 바로 사용자의 요청에 의해 여러 함수가 실행되었을 때, 해당 함수들 간의 컨텍스트를 표현할 방법이 없다는 것이었음.

예를들면 사용자가 나 혼자만 레벨업 웹툰 최신화를 클릭했을 때 아래 함수가 호출된다고 가정.

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

위의 view 함수에서는 두 번의 로그를 남기고 있음. 뷰어를 오픈했을 때 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: 탈퇴한 유저입니다

어찌보면 너무 당연한 사실에 많이 당황. 위 로그처럼 에러가 발생했을 때 어떤 요청으로부터 이어진 것인지 파악할 수 없다는 점이 충격으로 다가옴. 그렇다고 로그를 남길 때마다 모든 필요한 정보(여기선 viewerId, userId)를 넘기는 건 너무 아름답지 않게 보였음.

로그를 남겼을 때 하나의 요청으로 간주할 수 있는 정보가 필요. 자바 진영의 Thread Id 처럼요. 예를들면 아래처럼 한번의 요청(request) 에 호출되는 여러 함수를 관통할 수 있는 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: 탈퇴한 유저입니다

하지만 사실상 Node.js 는 싱글 스레드 구조라 기본 기능으로는 불가능한 방식이었습니다. 모두 동일한 스레드에서 돌아가기 때문에 다른 언어의 ThreadLocal 개념이 없었음. 그렇게 불가능할 것으로 생각하고 포기할까 하는 순간 Node.js 의 실험적인 기능인 async_hooks 가 검색엔진에 걸렸음. 내부에서 검토해본 결과 사용해도 괜찮다는 확신이 들어 2년 전 실서비스에 적용했고, 지금까지 문제 없이 완벽하게 동작하고 있음. async_hooks 에 대한 개념은 NodeJS에서 async_hooks을 이용해 요청의 고유한 context 사용하기 글에 아주 잘 정리되어있음.

저희는 async_hooks 를 손쉽게 사용하도록 래핑한 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}`)    
}

이렇게 contextId 를 로그에 남겨주면 로그 분석 시스템에서 이슈 트래킹 시 요청의 흐름을 추적(tracing) 할 수 있기 때문에 상당히 유용함.

클라이언트 사이드 로그

만약 Next.js 의 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,
      ..
    });
  }
}

브라우저 로그를 수집하는 서버에서 쿠키에 존재하는 contextId 도 남겨주면 전체 플로우를 추적(tracing) 할 수 있음.

Admin 프로세스

목표: admin/maintenance 작업을 일회성 프로세스로 실행
마지막 원칙인 Admin 프로세스 는 FE 영역에서는 크게 신경쓰지 않아도 되는 원칙이라 생각. 굳이 적용해보자면 다음 항목들에 적용해볼 수 있음.

  • 배포 관련 스크립트를 동일 코드베이스에 놓기
  • 여러 유틸리티성 스크립트(codegen, git, check eslint 등)를 같은 코드베이스에 놓기
  • 동일한 종속성 분리 기술(npm, yarn)을 사용하기. 예를들면 별도 스크립트 실행이 아닌 npm run codegen 로 실행하기

참고
https://fe-developers.kakaoent.com/2021/211125-create-12factor-app-with-nextjs/

profile
선명한 기억보다 흐릿한 메모

0개의 댓글