원문: https://medium.com/airbnb-engineering/how-airbnb-smoothly-upgrades-react-b1d772a565fd
프런트엔드 인프라를 점진적으로 최신화하여 다운그레이드 없이 최신 리액트로 배포하기
에어비앤비의 프런트엔드는 최근 중요한 목표를 달성했습니다. 모든 웹이 리액트 16에서 최신 메이저 버전1인 리액트 18로 업그레이드되었습니다. 숙박 게스트와 호스트 페이지를 포함한 많은 인터페이스와 내부에 여러 도구를 갖고 있는 프로덕트에서 이러한 업그레이드를 진행하는 것은 아주 큰 프로젝트였습니다. 이를 안전하게 수행하기 위해 우리는 리액트 업그레이드 시스템을 만들었습니다. 이 시스템은 새로운 리액트 버전을 모노레포 전반에 점진적으로 배포하고, 업그레이드 결과를 측정할 수 있는 재사용 가능한 인프라입니다. 이 글에서는 우리의 업그레이드 철학, 우리가 만든 시스템, 그리고 이 업그레이드를 수행하면서 배운 점에 관해 이야기하려고 합니다.
1: 리액트 17은 추가적인 기능 없이 최소한의 중단 변경 사항을 가진 "디딤돌" 릴리스로 2020년에 출시되었습니다. 이 업그레이드 작업을 진행할 때는 이미 리액트 18이 출시된 상태였기 때문에 18로 직접 업그레이드하기로 했습니다. 현재 작성 시점에서 리액트 19는 베타 버전으로, 우리는 리액트 19에 대해 리액트 업그레이드 시스템을 재사용하고 있습니다.
이 글은 주로 리액트에 초점을 맞추고 있지만, 이 시스템과 우리가 얻은 교훈은 정기적인 업그레이드가 필요한 많은 웹 프레임워크와 라이브러리에 적용될 수 있습니다.
의존성 업그레이드는 오래 지속되는 프로젝트에서 흔히 발생하는 작업입니다. 업그레이드는 버그를 수정하고, 성능을 향상시키며, 새로운 API를 사용할 수 있게 합니다. 일부 업그레이드는 간단하지만, 많은 양의 제품 코드가 변경된 API나 미묘한 동작 가정에 의존할 경우 업그레이드는 더 어렵습니다. 에어비앤비의 웹 모노레포에서는 (드문 예외를 제외하고) 최상위 의존성의 한 버전만을 허용하며, 레포 루트에 하나의 package.json 파일이 있습니다. 이를 통해 모노레포 내부의 코드가 내부적으로 호환되고 일관성이 유지되며, 사용자에게 중복된 패키지가 배포되는 것을 방지할 수 있습니다. 업그레이드 시스템이 도입되기 전에는 각 의존성의 단일 버전을 유지해야 했기 때문에, 원자적(atomic) 업데이트를 수행해야 했습니다. 이는 사전에 많은 마이그레이션 작업을 필요하고, 오랜 기간 동안 업그레이드 브랜치를 유지해야 하며, 최종적으로 사용자에게 배포될 때 하나의 큰 마일스톤이 필요했습니다. 이러한 접근 방식은 오류가 발생하기 쉽고 위험하므로, 순조롭게 업그레이드를 배포하기 위해서는 "희생정신의" 엔지니어링 노력이 수반되어야만 했습니다.
이상적으로는 문제가 없는 소규모의 업그레이드를 점진적으로 배포하는 것이 좋습니다. 하지만 대규모 모노레포에 이 시스템을 테스트하고 점진적으로 배포할 방법이 없었으므로, 여러 번 업그레이드를 시도하고 문제가 발견될 때마다 다운그레이드하는 경우가 잦았습니다. 특히 이러한 전략에서는 성능 저하 문제를 포착하기가 어려웠습니다. 배포 전에 성능 데이터를 수집할 방법이 없었기 때문에 배포 시 0%에서 100%로 한 번에 배포할 수밖에 없었습니다.
시간 경과에 따른 리액트의 주요 버전과 마이너 버전에 대한 이상과 현실을 보여주는 그래프
리액트 업그레이드 시스템의 목표는 희생이 아닌 일상적인 작업으로 업그레이드를 더 원활하게 만드는 것이었습니다. 구체적인 목표는 다음과 같았습니다.
위와 같은 목표를 먼저 설정한 뒤, 이상적인 아키텍처가 어떤 모습이어야 하는지에 대한 아이디어를 수집하기 시작했습니다. 장기적인 업그레이드 브랜치를 피하여 점진적으로 업그레이드할 수 있기를 원했고, A/B 테스트를 통해 실제 운영 환경에서 피드백을 받아 배포를 결정할 수 있기를 바랐습니다.
이상적인 업그레이드 시스템을 단순화한 다이어그램
시스템을 가장 단순하게 구현할 때 해결해야 할 몇 가지 문제가 있었습니다. 하나의 리액트 버전을 선택해 렌더링해야 했고, 런타임에서 두 버전 사이를 동적으로 전환하기가 어려웠습니다. 다음은 이 단순한 접근 방식으로 기본 애플리케이션을 렌더링 하는 코드의 예시입니다.
import React18 from "react";
import React16 from "react"; // 중복된 import?
if (shouldEnableReact18()) {
const root = React18.createRoot(container);
root.render(<App />);
} else {
React16.render(<App />, container);
}
여기서 두 가지 문제가 있습니다.
<App />
이 한 버전에서는 호환되지 않을 수 있습니다.이 문제를 해결하기 위해 우리는 모듈 별칭(module aliasing)을 사용해 버전을 분리하고, 환경 타겟팅(environment targeting)을 통해 분리된 두 리액트 버전을 빌드하고 실행했습니다.
모듈 별칭을 사용하여 어디에서 가져오는지에 대한 문제를 해결했습니다. 예를 들면 yarn을 사용할 때 다음과 같이 package.json에 또 다른 리액트 의존성을 추가했습니다.
"react-18": "npm:react@18"
이를 통해 우리는 'react-18' 패키지에서 리액트를 가져올 수 있었습니다. 이로써 일부 문제를 해결할 수 있었지만, (커스텀 리졸버와 빌드 시스템과 같은) 많은 도구는 어떤 버전을 사용할지 알아야 한다는 문제가 남았습니다. 로직을 중앙화하기 위해, 우리는 모든 커스텀 도구를 중앙의 "전역 별칭(global alias)" 설정과 연결했습니다. 전역 별칭 설정 덕분에 한 곳에서 모든 도구에 대해 별칭을 정의할 수 있었습니다. Babel, Jest™, Webpack™ 및 기타 커스텀 리졸루션 로직 모두에서 'react'를 'react-18'로 리다이렉트 하는 조건을 인식하도록 만들었습니다. 모듈을 "전역 별칭"으로 처리하면서 사용자 코드를 변경하지 않고도 리다이렉트를 백그라운드에서 처리할 수 있었습니다.
리액트 16 또는 18 모두에서 실행될 수 있는 컴포넌트의 경우, 업그레이드 기간 동안 두 버전 모두에서 동작하는 타입을 사용하고자 했습니다. 다행히 리액트 팀은 주요 버전 간에도 하위 호환성을 유지하고 있었습니다.
우리는 리액트 18의 타입을 설치하고, 리액트 18에 새롭게 추가된 API에 16과 18 모두에서 동작하는 심 레이어(shim layer)를 생성했습니다. 예를 들어 useTransition은 리액트 16에서 아무 작업도 하지 않는 함수로 작동했습니다. 심을 적용할 수 없는 API(예: useId)의 경우, 타입 보강(type augmentation)을 통해 해당 훅이 런타임에 정의되지 않을 수 있음을 표시했습니다.
타입스크립트에만 발생한 리액트 18의 중단 변경 사항은 업그레이드가 완료될 때까지 기다렸다가 점진적으로 수정했습니다. 우리는 타입 증강을 활용하여 차이를 패치하고, 이를 통해 모노레포에서 새로운 타입스크립트 오류를 점진적으로 수정할 수 있도록 했습니다.
중복된 import 문제를 해결하기 위해, 두 개의 서로 다른 빌드 아티팩트를 생성해야 했습니다. 하나는 리액트 16을 포함하고, 다른 하나는 리액트 18을 포함했습니다. 이를 각각 "제어(control)" 아티팩트와 "처리(treatment)" 아티팩트라고 부르겠습니다. 에어비앤비는 서버 사이드 렌더링을 사용하기 때문에, 서버에서 두 개의 아티팩트를 각각 다른 노드 프로세스에서 실행해야 했습니다. 따라서 두 개의 서로 다른 쿠버네티스 환경을 설정했습니다. 이를 환경 타겟팅이라고 부르겠습니다.
프로덕션 환경에서 모듈 별칭과 환경 타겟팅을 사용하여 서로 다른 버전의 프레임워크 같이 배포하기
또한 빌드할 때 애셋에 환경 변수(REACT_UPGRADE)를 작성하고, 이 변수를 노드 SSR 서비스의 런타임에서 설정했습니다. 이를 통해 업그레이드 시스템의 한쪽에서만 필요한 조건부 로직을 수행할 수 있었습니다.
이 설정은 로컬 개발 환경에서도 잘 작동했습니다. "로컬" 개발 환경도 배포되었기 때문에, 이 설정을 사용하여 프로덕션과 동일한 방식으로 로컬 개발을 위한 리액트 버전을 구성할 수 있었습니다. 각 SSR 서비스가 리액트 18로 업그레이드됨에 따라, 해당 서비스의 개발 환경도 리액트 18로 전환하여 프로덕션과 로컬 개발 버전을 동기화했습니다.
에어비앤비는 포괄적인 테스트 스위트를 가지고 있어, 사용자에게 노출하기 전에 업그레이드의 안전성에 대한 신뢰를 쌓는 데 도움이 되었습니다. 테스트 스위트에는 시각 회귀 테스트, 통합 테스트 및 단위 테스트가 포함되어 있습니다. 사용자에게 배포하기 전에, 각 테스트 스위트에서 발생한 모든 새로운 실패 케이스를 수정했습니다.
단위 테스트는 프레임워크 내부에서 추상화하기 가장 어려웠습니다. Enzyme과 리액트 테스트 라이브러리를 조합하여 함께 사용하기 때문에, 단위 테스트, 심, 어댑터에서 API와 프레임워크 내부에 대한 가정을 수정해야 했습니다. 이를 달성하기 위해, 리액트 16과 18에서 모든 단위 테스트를 실행하여 리액트 18 테스트 스위트에서 발생하는 기존 실패를 점진적으로 수정하면서 허용했습니다. 우리는 이 "허용된 실패(permitted failures)" 목록을 사용해 시간이 지남에 따라 테스트 실패 횟수를 줄여나갔고, 새로운 실패가 목록에 추가되는 것을 막아 백슬라이딩을 방지했습니다. 이 접근 방식으로 컴포넌트와 테스트 환경의 문제를 점진적으로 수정할 수 있게 해 주었습니다.
수백 건의 테스트 실패를 해결하는 작업을 대시보드로 추적하고, 업그레이드 시스템을 통해 수정 사항을 점진적으로 병합했으며, 소수의 개발자에게 작업을 분담했습니다. 이를 통해 마이그레이션 작업을 광범위한 프론트엔드 팀에게 대부분 투명하게 공유하였고, 배포 전에 업그레이드에 대한 신뢰를 얻을 수 있었습니다.
모듈 별칭과 환경 타겟팅이 마련된 후, 우리는 동일한 코드베이스에서 다른 버전의 리액트를 작성하고 배포할 수 있게 됐습니다. 안전성과 테스트 가능성을 보장하기 위해선 새로운 환경을 점진적으로 배포할 방법도 필요했습니다. 한 번에 발생하는 변경 사항의 양을 줄이기 위해, 우리는 트래픽과 제품 전반에 걸쳐 출시를 제어하고자 했습니다. 실험 인프라는 트래픽을 각기 다른 두 프로덕션 환경(제어와 처리)으로 자유롭게 전환할 수 있게 해 주었습니다. 이 설정 덕분에 우리는 업그레이드를 먼저 내부에서 테스트하고, 문제가 발견될 경우 업그레이드를 완전히 중단할 수 있었습니다.
여러 페이지의 출시를 제어하는 건 더 어려운 일입니다. SPA 내에서 여러 리액트 버전을 관리하는 것은 리액트 루트를 언마운트하고 마운트해야 함을 의미합니다. 이는 성능 저하를 초래하고 사용자 경험을 저하할 수 있습니다.
이러한 이유로 앱 수준에서 페이지 출시 업그레이드를 관리했습니다. 에어비앤비의 모노레포에는 많은 SPA가 포함되어 있기 때문에, 각 애플리케이션에 대해 업그레이드를 켜고 끌 수 있는 리액트 업그레이드 시스템이 유용했습니다. 리액트 업그레이드 시스템을 사용하여, 먼저 내부에서 업그레이드를 단일 애플리케이션에 배포할 수 있었고, 개발자들이 개발 및 스테이징 사이트에서 테스트를 위해 업그레이드에 옵트인(opt-in) 및 옵트아웃(opt-out)할 수 있는 방법을 제공했습니다. 이러한 접근 방식은 기능 브랜치가 장기간 남아있지 않도록 했고, 점진적 업그레이드라는 목표를 달성하는 데 기여했습니다.
이 시스템으로 에어비앤비의 모든 웹에 리액트를 18을 완전히 배포했으며, 롤백이 필요하지 않았습니다. 업그레이드 후에는 새로운 루트 API와 동시 렌더링 기능과 같은 새로운 API 테스트를 시작할 수 있었습니다. 우리는 업그레이드가 안정화될 때까지 몇 주 동안 이러한 기능을 채택하는 것을 의도적으로 미뤘습니다. 이를 통해 우리는 다운그레이드가 필요하거나 코드 변경 사항을 되돌릴 일이 없다는 확신을 가질 수 있었습니다.
새로운 기능을 채택함으로써 성능 개선을 경험하게 되어 흥미롭고, 우리는 이러한 기능을 확장하여 혜택을 받을 수 있는 주요 UI에 계속해서 실험하고 있습니다.
업그레이드 목표를 자주 달성하기 위해, 리액트 업그레이드 시스템을 사용하여 리액트의 카나리 채널을 테스트할 것입니다. 리액트 18 대신 카나리 태그를 가리키면, 리액트 19를 마이그레이션 하려면 어떤 작업이 필요한지 미리 볼 수 있습니다. 업그레이드할 때 "희생적인" 노력을 들게 하지 않으려면, 대규모의 일회성 변경 대신 지속적으로 최신 상태를 유지하는 노력이 필요합니다.
리액트 업그레이드 시스템의 목표는 점진적이고 빈번한 업그레이드와 테스트를 하는 것이었습니다. 환경 타겟팅과 모듈 별칭 시스템을 결합하여 점진적으로 업그레이드하고 테스트할 수 있었습니다. 리액트 19 베타 버전을 사용하기 시작했고, 리액트 19에 대한 사전 준비를 진행하고 있습니다.
리액트 버전 간, 심지어 주요 버전 간 하위 호환성을 위해 노력해 준 리액트 팀에게 감사의 말씀을 전하고 싶습니다. 이러한 노력이 없었다면 이번 업그레이드 접근 방식은 불가능했을 것입니다.
리액트 업그레이드 시스템을 사용하여 리액트 18 출시에 대한 자신감을 얻었으며 향후 업그레이드에도 이 접근 방식을 사용할 것입니다. 앞으로도 계속 업그레이드가 필요하기 때문에 업그레이드 시스템에 투자하는 것은 가치 있는 일이라고 믿습니다. 리액트 업그레이드 시스템을 통해 점진적으로 업그레이드를 테스트하고 출시하여 사용자에게 최상의 사용자 경험과 성능을 제공할 수 있었습니다.
이러한 업무에 관심이 있으시다면 지금 열려 있는 채용 공고를 확인해 보세요!
업그레이드 시스템을 구축하는 데 앞장서 주시고 이 게시물을 작성해 주신 Joshua Nelson에게 깊은 감사의 말씀을 전합니다.
또한, 이 시스템과 그 구성 요소들에 대한 도움을 주신 Kim Nguyen, Callie Riggins Zetino, James Robinson, Dan Beam, Kaeson Ho, Rae Liu, Michael James, Noah Sugarman, Laurie Jin, Brie Bunge, Matt Mulder, Victor Lin에게도 감사드립니다.
모든 제품 이름, 로고 및 브랜드는 해당 소유자의 자산입니다. 이 웹사이트에 사용된 모든 회사, 제품 및 서비스 이름은 식별 목적으로만 사용됩니다. 이러한 이름, 로고 및 브랜드의 사용이 보증을 의미하지는 않습니다.
No other fast-food restaurant in the United States has as many independently owned and run franchisees as Sonic Drive-In. Oklahoma City is home to the company's main office. https://talktosonic.boats/