목록 보기
프론트엔드와 THE TWELVE-FACTOR APP
기타

프론트엔드와 THE TWELVE-FACTOR APP

카카오엔터테인먼트FE
카카오엔터테인먼트FE
2021년 11월 25일

안녕하세요 카카오엔터테인먼트에서 FE개발하고 있는 스티브입니다 저는 12Factor 를 저희 서비스의 FE 영역에서 어떻게 이해하고 적용했는지 써볼까 합니다. 저희가 사용하고 있는 프레임워크인 Next.js 위주로 작성되었지만 원칙에 대한 내용이기 때문에 다른 기술을 사용 중이어도 도움이 될 거로 생각합니다. The Twelve-Factor app 혹시 12Factor를 들어보셨나요? (이따금 FE개발자들에게 물어보지만 한 명도 몰랐던) 12Factor(The Twelve Factors. 이하 12Factor)는 직역하면 12가지 요소를 뜻하지요. 그럼 어떤 것을 위한 12가지 요소일까요? 공식 웹사이트에 잘 설명되어있지만 제가 이해한 대로 한 줄 요약하면 독립적인 애플리케이션 운영을 위한 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를 이해해야 하는지 알아보고 실제 서비스에서 어떻게 도입했는지 하나하나 얘기해보겠습니다. I. 코드베이스 목표 : 버전 관리되는 하나의 코드베이스와 다양한 배포 사소해보이지만 가장 중요한 원칙입니다. 12Factor 애플리케이션의 기본(베이스)이라 할 수 있는 부분입니다. 하나의 코드베이스는 하나의 앱을 가지고 있어야합니다. 이를 위반한 케이스 중 하나는 바로 그 유명한 모놀리식(Monolithic) 입니다. 전통적인 모놀리식 구조 - 출처 : 크리스 리차드슨 마이크로서비스 일반적으로 서비스의 규모가 작을 땐 한 팀에서 모놀리식으로 개발됩니다. UI 관련 코드도 같은 코드베이스에 존재하죠. 그러다 서비스 규모가 커지면서 점차 사람이 늘어나고 팀도 하나 둘 늘어나게 됩니다. 회원팀, 상품팀, 주문팀, 그리고 FE팀 등등 팀이 늘어나는데 하나의 모놀리식을 유지하는 경우가 있습니다. 이 경우 하나의 코드베이스로 여러 다양한 서비스를 배포하니 코드베이스 원칙 위반입니다. 제가 경험했던 프로젝트 중에 많게는 120명이 하나의 모놀리식으로 개발하는 경우도 있었습니다. 이 정도 수준이 되면 각 작업자들간의 의존도가 너무 높아 상시 배포가 불가능해집니다. (매주 목요일 새벽에 대부분의 개발자가 출근해 정기배포를 진행해야했죠). 많은 회사에서 MSA(Micro Service Architecture)를 통해 코드베이스를 분리하는데요, 그 근거가 되는 것이 바로 12Factor의 첫 번째 원칙인 코드베이스 입니다. 하지만 코드베이스 원칙 하나만으론 레포(Repository)에 대한 명확한 기준을 잡기 어려운 경우가 있습니다. 예를 들면 최근 각광받고 있는 모노레포는 어떨까요? 모노레포는 하나의 레포에서 여러개의 앱을 배포할 수 있는데요, 이 경우 코드베이스 위반일까요? 답부터 얘기하자면 위반이 아닙니다. 코드베이스와 레포는 목적이 다릅니다. 12Factor에서 얘기하는 코드베이스 는 배포 와 관련되어있고, 레포 는 업무 수행과 관련되어있다고 볼 수 있습니다. 레포 를 어떻게 할 것인지도 코드베이스 와 마찬가지로 애플리케이션을 구성하는 시작점에서 가장 중요한 결정 중 하나로 볼 수 있습니다. 여기에 대해선 콘웨이 법칙(Conways law)이라는 명확한 이정표가 있습니다. 일반적으로 12Factor의 첫번째 원칙을 얘기할 때 콘웨이 법칙도 같이 거론됩니다. 콘웨이 법칙은 다음과 같습니다. 소프트웨어 구조는 해당 소프트웨어를 개발한 조직의 커뮤니케이션 구조를 닮게 된다. - 콘웨이 다양한 곳에 적용할 수 있는 법칙입니다. 지금까지 많은 개발팀을 거쳐왔는데 이 법칙을 벗어난 적이 없습니다. 조직의 커뮤니케이션 구조는 정말로 코드나 서비스 아키텍처에 그대로 녹아져있습니다. 업무 수행과 관련된 레포 는 콘웨이 법칙으로 명확하게 제안할 수 있습니다. 하나의 레포는 단일 팀에서만 이용해야 합니다. 만약 여러 팀에서 하나의 레포를 이용하게 되면 필연적으로 공통 코드가 생기게되고, 개발과 운영 시 다른 팀 작업분과 의존성이 발생하게 됩니다. 결국 여러 팀이 하나의 레포를 사용하듯이, 하나의 커뮤니케이션 구조로 들어오게 됩니다. 반대도 콘웨이 법칙이 적용됩니다. 하나의 팀에서 여러 레포를 사용하게 되면 각 레포의 담당자 간 사일로(Silo)가 발생합니다. 모노레포는 각각의 패키지가 별도의 코드베이스가 되기 때문에 12Factor를 위반하지 않습니다. 또한 해당 레포 를 관리하는 팀은 단일 팀이기 때문에 콘웨이 법칙으로 볼 때도 괜찮은 커뮤니케이션 구조를 갖습니다. 첫 번째 원칙인 코드베이스에서 가장 중요한 것은 서비스 간 의존성을 낮추고 독립된 커뮤니케이션 구조를 유지하는 것입니다. 이를 만족하면 자연스럽게 독립된 배포 환경에 도달하게 됩니다. 이는 곧 팀의 작업 속도 향상을 가져오고 서비스의 성장과 속도에도 영향을 끼치게 됩니다. II. 종속성 목표 : 명시적으로 선언되고 분리된 종속성 두 번째 원칙인 종속성에서 강조하는 것은 외부 시스템으로부터의 독립입니다. 종속성은 FE에서 어느 정도 잘 실천하고 있는 원칙이라 생각합니다. 애플리케이션 실행에 관련된 의존성을 package.json 에 선언하면 의존성 관리툴(npm, yarn)을 통해 손쉽게 설치하고 실행할 수 있습니다. 하지만 12Factor에선 조금 더 넓은 범위의 종속성 을 얘기하고 있습니다. Twelve-Factor App은 전체 시스템에 특정 패키지가 암묵적으로 존재하는 것에 절대 의존하지 않습니다. 종속선 선언 mainifest를 이용하여 모든 종속성을 완전하고 엄격하게 선언합니다. 더 나아가, 종속성 분리 툴을 사용하여 실행되는 동안 둘러싼 시스템으로 암묵적인 종속성 “유출”이 발생하지 않는 것을 보장합니다. 이런 완전하고 명시적인 종속성의 명시는 개발과 서비스 모두에게 동일하게 적용됩니다. The Twelve-Factor Dependencies 부분 암묵적인 종속성은 여러 가지가 있지만, FE개발에선 대표적으로 Node.js 버전이 있습니다. 코드에서 사용 중인 Node.js 버전을 package.json(volta), 혹은 .nvmrc(nvm) 에 명시해 종속성을 관리해야 합니다. 또한 package.json 에 정확한 버전(^, ~ 제거) 사용과 lock 파일을 통해 종속성을 고정(Dependency Pinning) 해야 합니다. 마지막으로 필수는 아니지만 강하게 제안하는 것은 OS, 실행환경에 대한 암묵적인 종속성을 벗어나기 위해 도커 컨테이너화 하는 것입니다. III. 설정 목표 : 환경(environment)에 저장된 설정 애플리케이션을 운영하려면 반드시 환경(개발, 스테이징, 프로덕션 등)이 분리되어있어야 합니다. 환경별로 달라질 수 있는 예시는 다음과 같습니다. API 정보 CDN 정보 로깅 레벨 etc. 12Factor의 세 번째 원칙인 설정 은 이러한 환경별 변수를 코드 내부에 두지 않고 외부로부터 주입받음으로써 환경으로부터 독립을 완성하는 원칙입니다. Next.js 에서 공식적으로 제공하는 Environment Variables 를 통해 환경을 분리할 수 있는데요, 이 방법은 엄밀히 보면 설정 원칙을 위반한 방법입니다. 예를들면, Next.js 프로젝트에선 아래 절차대로 API_HOST 를 환경별로 분기할 수 있습니다. .env.local 파일 생성 API_HOST 정보 기입 API_HOST=https://local.api.com/ 클라이언트단 코드에서 NEXT_PUBLIC_API_HOST 사용 useEffect(() => ( fetch($(NEXT_PUBLIC_API_HOST)/api/my_series) ), []) 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 # 프로덕션 환경에 로그 레벨만 변경 IV. 백엔드 서비스 목표 : 백엔드 서비스를 연결된 리소스로 취급 네 번째 원칙인 백엔드 서비스는 말 그대로 백엔드 서비스로부터 독립을 요구하는 원칙입니다. 여기서 백엔드란 서드파티 서비스로 볼 수 있습니다. FE 의 대표적인 서드파티는 에러 수집 플랫폼인 Sentry가 있습니다. 백엔드 서비스 원칙을 지키기 위해선 각 환경별로 자유롭게 선택할 수 있어야합니다. 예를들면 로컬 개발시엔 개발 서버에 설치된 Sentry, 프로덕션 배포시엔 클라우드용 Sentry 를 이용할 수 있도록 선택할 수 있어야합니다. 일반적인 FE 환경이라면 대부분 잘 지키고 있는 원칙이라 생각되어 바로 다음 원칙으로 넘어가겠습니다. V. 빌드, 릴리즈, 실행 목표: 철저하게 분리된 빌드와 실행 단계 다섯 번째 원칙인 빌드, 릴리즈, 실행은 개인적으로 아주 중요하게 생각하는 원칙입니다. 이 원칙을 만족해야 개발과 운영의 독립(DevOps) 를 실현할 수 있습니다. 지금까지 본 많은 FE 프로젝트는 대부분 이 원칙과 동떨어져 운영되고 있었습니다. 필자가 지금 팀에서 가장 시급하다 생각해 처음으로 적용한 것도 이 다섯번째 원칙이었습니다. 빌드, 릴리즈, 실행 원칙이 요구하는 것은 아주 심플합니다. 말 그대로 빌드, 릴리즈, 실행 단계를 철저하게 분리하는 것입니다. 보편적인 Next.js 프로젝트는 다음 프로세스를 통해 코드가 프로덕션에 반영됩니다. git 전략은 git flow 를 쓴다고 가정하겠습니다. 개발 중인 코드를 develop 브랜치에 푸시 CI/CD 통해 개발 서버에 반영(npm install -> next build -> deploy) QA 시점이 되어 release 브랜치 생성 CI/CD 통해 QA 서버에 반영(npm install -> next build -> deploy) QA 요청 -> QA 완료 release -> main 푸시 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 이렇게 각 단계가 분리되었을 때 어떤 일이 일어날까요? 개발팀에서는 개발을 진행합니다. 코드 변경시마다 빌드를 통해 통합하고 검증합니다(Continuous Integration) QA와 릴리즈할 버전을 논의 후 빌드된 코드를 v1.2.3으로 릴리즈 합니다. QA에서는 해당 릴리즈를 QA 환경, 스테이징 환경에 올려 테스트해봅니다. 릴리즈가 충분히 테스트되면 운영에서 적절한 시점에 배포 후 실행 합니다. 차이점이 느껴지나요? 개발 프로세스 자체는 크게 변하지 않았습니다. 가장 큰 차이는 개발과 인프라 간, 그리고 개발과 운영간 의존성이 낮아져 느슨한 결합도를 유지할 수 있다는 점입니다. 또한 팀에서는 12Factor의 다섯번째 원칙까지 도달했을 때 운영중인 Next.js 프로젝트의 독립성이 놀랍도록 높아진 것을 느낄 수 있었습니다. 배포 속도가 빨라졌습니다. 기존엔 배포마다 install, build 를 하다보니 15분 내외로 걸렸는데 지금은 1분 내외로 배포가 진행됩니다. 배포된 코드의 릴리즈 버전을 손쉽게 확인할 수 있게 되었습니다. (QA 킬링 포인트) QA 프로세스가 간단해졌습니다. 기존엔 배포 타이밍도 조율하고 QA팀과의 커뮤니케이션 미스도 잦았는데 지금은 명확한 버전만 전달하면 되니 간단해졌습니다. 자연스럽게 Cloud Native 한 앱이 되었습니다. 하나의 릴리즈를 물리 서버와 쿠버네티스 모두에 올려서 테스트할 수 있게 되었습니다. 쿠버네티스에 올릴 수 있게 되면서 모든 git 브랜치마다 미리보기 링크를 제공하는 프리뷰 서버를 띄울 수 있게 되었습니다. 많은 사람이 행복해졌습니다! 개인적으로 이 다섯번째 원칙까지 적용하는 것이 12Factor를 적용할 때 가장 중요한 고비라 생각합니다. 지금까지 관찰한 여러 프로젝트 중 다섯가지 원칙을 모두 지킨 프로젝트가 손에 꼽습니다. 그만큼 실천하기 어려운 부분이 많습니다. 왜냐하면 배포 프로세스를 변경해야 하기 때문입니다. 협업

댓글 0

댓글을 작성하려면 로그인이 필요합니다.

댓글을 불러오는 중...