파일 변수 Deep-Dive
서론 제가 속해있는 스토리FE개발팀에서는 카카오페이지, 카카오웹툰, 타파스 등의 다양한 웹 서비스를 개발하고 운영하고 있습니다. 웹 서비스의 규모가 커지고 제공하는 기능들이 많아지다 보니 다양한 유형의 상태(state)를 효율적으로 관리하는 방법에 대해 고민해야 했는데요. 이 글에서는 저희 팀에서 특정 유형의 상태를 관리하는 방법으로 사용하고 있는 파일 변수에 대해 소개하고 파일 변수에 대한 운영 노하우를 공유하고자 합니다. Redux 처음에 프로젝트를 개발할 때는 상태에 대해서 깊이 생각할 필요가 없었습니다. 모든 상태를 한 곳에서 통으로 관리해도 충분하다고 생각했고 실제로 프로젝트의 규모가 작을 때에는 전혀 문제가 없었습니다. 따라서 대표적인 상태 관리 라이브러리 중 하나인 Redux를 사용해 하나의 전역 스토어(store)에서 프로젝트의 모든 상태를 관리했습니다. 많은 상태 관리 라이브러리 중에서 Redux를 사용하기로 결정했던 이유를 몇 가지 뽑아보자면 다음과 같습니다. Redux 미들웨어 Redux는 액션이 트리거되어 그로 인한 상태의 변경 사항이 스토어에 반영되기까지의 과정을 제어하고 확장할 수 있는 미들웨어 기능을 제공합니다. redux-thunk나 redux-saga와 같은 라이브러리들이 미들웨어에 속합니다. Redux Toolkit 하나의 Redux 플로우를 정의하기 위해서는 상당히 많은 코드가 필요합니다. 액션을 정의하고 리듀서를 구현해야 하며 스토어에 상태를 추가해야 하고, 비동기 작업이 필요하다면 thunk 함수나 saga를 정의해야 합니다. Redux Toolkit을 사용하면 이와 같은 일련의 플로우를 매우 간단하게 구현할 수 있는 API들을 제공합니다. Redux Devtools Redux의 가장 큰 장점 중 하나는 상태의 불변성을 유지함으로써 상태 변경의 히스토리를 쉽게 추적할 수 있다는 것입니다. 그리고 redux devtools 도구를 통해 이러한 상태 변경 히스토리를 쉽게 확인할 수 있습니다. 이처럼 Redux가 제공하는 여러 이점을 활용해 상태를 잘 관리하고 있었는데, 프로젝트의 규모가 커질수록 한계가 찾아왔습니다. 서버로부터 받아오는 상태를 관리하기 위해 추가된 saga 코드가 많아지다 보니 가독성이 떨어져서 하나의 Redux 플로우를 따라가기가 어려워졌습니다. 특정 페이지에서 한 번에 너무 많은 액션을 트리거 하다보니 Redux Devtools가 부하를 버티지 못하고 다운되어버리는 상황도 종종 발생했습니다. 어떤 값이 주기적으로 변경되는 값이고 어떤 값이 한 번 초기화되고 변경되지 않는 값인지를 한 눈에 파악하기 힘들어졌습니다. Redux 자체에 문제가 있는 것은 아닙니다. Redux는 모든 상태를 하나의 저장소 안에서 관리한다는 기본 원칙을 충실히 지키고 있습니다. 문제에 대한 책임은 모든 상태를 Redux에 저장해서 관리하고자 했던 저희 팀에게 있었습니다. Redux의 메인테이너인 Mark Erikson은 자신의 블로그에 다음과 같은 말을 남긴 적이 있습니다. “Redux is Overused” Redux가 불필요한 영역까지 너무 남용되고 있다는 뜻입니다. Mark Erikson이 지적한 것처럼 저희 팀은 Redux를 모든 곳에서 남용해서 사용하고 있었고, 이로 인해 한계를 맞이했습니다. 상태에는 유형이 있습니다. 팀에서 사용하는 상태 관리 방식의 한계를 극복하기 위해서는 Redux를 통한 상태 관리의 의존성을 최대한 줄여야 했습니다. Redux로 관리할 필요가 없는 상태 값에는 무엇이 있는지 확인하던 도중 상태를 유형별로 구분할 수 있다는 사실을 발견했고, 이를 통해 저희 팀에서 Redux로 관리하고 있던 상태들을 아래와 같은 유형별로 분류할 수 있었습니다. 서버 상태: API 요청 및 응답에 대한 상태. 추적이 필요한 상태: 값의 변경으로 인해 UI가 리렌더링 되어야 하거나 특정 로직을 트리거 시켜야 하는 등 값의 변경을 계속해서 추적해야 하는 상태. 추적이 불필요한 상태: 값이 변경되어도 별도의 로직이 수행될 필요가 없어서 값의 변경을 추적할 필요가 없는 상태. 서버 상태의 경우 기존에 redux-saga 미들웨어를 통해 비동기 API 요청 관련 상태를 관리하던 부분이었습니다. 서버 상태는 굳이 Redux의 전역 스토어로 관리할 필요 없이 react-query의 데이터 캐싱 기능을 활용하면 상태 관리를 간단하게 구현할 수 있다는 사실을 발견했습니다. 따라서 redux-saga 미들웨어를 제거하고 react-query 라이브러리를 통해 서버 상태를 관리하도록 일괄 변경했습니다. react-query를 활용한 서버 상태 관리는 esme의 블로그 글에서 더 자세한 내용을 확인하실 수 있습니다. 추적이 필요한 상태들은 여전히 상태 관리 라이브러리를 통해 관리해 줘야 했습니다. 다만 Redux 미들웨어를 더 이상 활용하지 않기도 하고, 클라이언트 사이드에서 변경되는 UI를 관리하는 정도의 단순한 상태들을 Redux로 관리하기에는 라이브러리가 너무 무겁다고 판단했습니다. 따라서, Redux보다 훨씬 가벼운 Zustand라는 상태 관리 라이브러리를 통해 추적이 필요한 최소한의 상태를 관리하도록 결정했습니다. 추적이 불필요한 상태들은 가능하면 별도의 상태 관리 라이브러리 없이 파일 변수로 관리하기로 결정했습니다. 여기에서 말하는 추적이 불필요한 상태란 값이 변경되더라도 별도의 로직을 수행할 필요가 없으나 단순히 개발 편의를 위해 전역 store에 넣어서 관리하는 상태이거나, window.navigator.userAgent를 통해 알 수 있는 유저의 환경 정보와 같이 사용자 요청에 대해 결정되며 값이 도중에 변경될 수 없는 상태를 의미합니다. 지금부터 저희 팀에서 추적이 불필요한 상태를 관리하기 위해 사용하고 있는 파일 변수가 무엇인지부터 살펴보도록 하겠습니다. 파일 변수 이 글에서 말하는 파일 변수란 무엇인가요? 파일 변수를 활용하는 방법들에 대해 설명하기 전에 우선 이 글에서 말하는 파일 변수가 정확히 무엇인지부터 짚고 넘어갈 필요가 있습니다. 왜냐하면 자바스크립트 생태계에서 파일 변수라는 용어는 엄밀히 말해 존재하지 않기 때문입니다. 자바스크립트에서 변수를 하나 선언하면 해당 변수가 어느 위치에 선언되었는가에 따라 스코프가 할당됩니다. 일반적으로 잘 알려진 스코프는 다음과 같습니다. Global Scope (전역 범위): 코드의 모든 영역에서 선언된 변수에 접근할 수 있습니다. 전역 범위에 선언된 변수를 보통 전역 변수라고 부릅니다. Function Scope (함수 범위): 함수 내부에 선언된 변수의 경우 함수 외부에서 접근이 불가능하며 오직 함수 내부에서만 접근이 가능합니다. Block Scope (블록 범위): ES6 문법에서 새로 도입된 let 과 const 선언문으로 인해 추가된 스코프로, 중괄호 내부에 let 또는 const로 선언된 변수는 중괄호 외부에서 접근이 불가능하며 오직 중괄호 내부에서만 접근이 가능합니다. ES6에서는 여기에 더해 모듈이라는 개념이 새롭게 도입됨에 따라 하나의 스코프가 추가되었는데, 바로 Module Scope(모듈 범위) 입니다. Javascript ES6 문법을 사용할 경우 특정 파일 내에서 export 문을 사용한다면 해당 파일 자체가 하나의 모듈이 됩니다. 그리고 해당 파일 내에서 변수를 선언하면 해당 변수는 선언된 파일 내에서만 접근이 가능하며 외부에서 접근이 불가능합니다. 예를 들어, 아래와 같이 example.js 라는 파일 내에서 전역으로 var1이라는 변수를 선언한다면 var1은 example.js 파일 내에서 정의된 함수 등에서만 접근이 가능하며 외부 파일에서는 접근이 불가능합니다. // example.js
let var1
export function func1() ( ... ) 이 글에서 말하는 파일 변수란 이처럼 특정 파일(모듈) 내에 전역으로 정의되어 스코프가 파일 내부로 한정된 변수라고 정의하겠습니다. 다시 말해 파일 변수는 모듈 범위 전역 변수 (Module Scope Global Variable) 라고 할 수 있겠습니다만, 저희 팀에서는 이렇게 긴 이름 대신 파일에 종속된 변수다라는 의미를 직관적으로 알 수 있는 파일 변수 라는 용어를 사용하고 있습니다. 클라이언트 사이드에서 파일 변수는 어떻게 동작하며 어떻게 사용하나요? 앞서 Redux를 통해 값의 변경을 추적할 필요가 없는 값들을 파일 변수로 관리하겠다고 언급했습니다. 이 글에서는 클라이언트 사이드에서 파일 변수를 활용하는 방법과 서버 사이드에서 활용하는 방법을 나눠서 설명해 드릴 텐데, 우선 클라이언트 사이드부터 소개하겠습니다. 여러분이 온라인 쇼핑몰 웹사이트를 운영하는데 유저의 환경이 모바일 환경인지 PC 환경인지에 따라 웹 사이트의 UI와 UX를 다르게 제공하고 싶다고 가정하겠습니다. 단순하게는 환경에 따라 별도의 아이콘을 보여준다거나, 더 나아가서 PC 환경에서는 모달을 보여주고 모바일 환경에서는 토스트 메시지를 보여주도록 동작을 완전히 분기할 수도 있습니다. 이를 구현하기 위해 유저가 페이지에 최초 진입했을 때 유저의 userAgent 정보를 가져온 후, 이를 바탕으로 유저가 모바일 환경인지 아닌지를 isMobile 이라는 불리언 변수에 저장해 관리하고자 합니다. 이 책의 모든 예시는 이 isMobile이라는 값을 어떻게 관리할지에 초점을 맞추겠습니다. 예시를 최대한 단순화하기 위해서 Boolean 타입 변수인 isMobile을 활용했으나 실 서비스에서는 isMobile 보다 훨씬 복잡하고 더 의미있는 값들도 파일 변수로 관리할 수 있습니다. 저희 팀에서는 유저의 액세스 토큰 정보나 디바이스 ID와 같은 유저 관련 정보들도 파일 변수로 관리하고 있습니다. 이 isMobile 변수를 Redux 스토어에서 상태 값으로 관리하도록 구현할 수도 있습니다. 특정 도메인에 종속된 정보가 아니고 여러 컴포넌트에서 활용될 수 있는 정보이니 전역 스토어에 저장해놓고 필요할 때마다 꺼내 쓰면 편하겠죠. 유저의 환경은 유저가 웹 사이트에 남아있는 동안 바뀌지 않는 정보이므로 isMobile의 값을 페이지 최초 진입 시 한 번만 초기화해 준다고 가정했을 때, 아래와 같이 코드를 작성할 수 있을 것입니다. // client/state.js
// isMobile이라는 상태를 redux store에 정의합니다. const initialState = ( isMobile: undefined, ) // client/_app.js
const isMobile = useSelector(store => store.isMobile) const dispatch = useDispatch()
// userAgent 정보를 분석해 모바일 환경인지 여부를 리턴하는 함수 function parseUserAgent(userAgent) ( ... )
// 페이지 최초 진입 시 isMobile 정보를 한 번 갱신해줍니다. useEffect(() => ( dispatch(actions.setIsMobile(parseUserAgent(window.navigator.userAgent))) ), []) _app에서 유저의 userAgent 정보를 바탕으로 Redux 스토어의 isMobile 상태 값을 갱신해 주고 있습니다. 우리의 목표는 최대한 Redux로 관리되는 값들을 파일 변수로 관리하도록 변경하는 것이므로, 이 값을 Redux를 통해 관리할 필요가 있는지 고민해 봅시다. isMobile이라는 값은 한 번 할당되면 더 이상 변경되지 않는 값이기 때문에, 해당 값이 변경됐는지 실시간으로 추적할 필요도 없습니다. 그리고 변경되지 않는 값이므로 UI 리렌더링을 고려할 필요가 없는 것은 물론이고요. 이 변수 하나를 Redux에서 관리하기 위해 최소 state, action, reducer에 대한 코드를 프로젝트에 추가해야 하는데, 이렇게 큰 비용을 들여서 관리할 필요가 있는지 의심스럽습니다. 이 isMobile이라는 변수를 파일 변수로 관리하도록 변경해 보면 어떨까요? media.js 라는 파일을 생성한 후, 아래와 같이 isMobile 이라는 변수를 파일 내에 전역으로 선언합니다. // client/media.js
let isMobile = undefined 파일 변수는 선언된 파일 내로 스코프가 한정되기 때문에 외부에서 접근이 불가합니다. 외부에서 isMobile 값을 참조할 수 있도록 getter 함수를 정의하고 export를 통해 내보냅시다. // client/media.js
let isMobile = undefined
export function getIsMobile() ( if(isMobile === undefined) ( isMobile = parseUserAgent(window.navigator.userAgent) ) return isMobile )
// userAgent 정보를 분석해 모바일 환경인지 여부를 리턴하는 함수 function parseUserAgent(userAgent) ( ... ) 위 getter 함수에서 주목해야 할 점은 초기화 시점에 값의 할당까지 담당하고 있다는 점입니다. getter 함수에서는 단순히 값을 반환하고 setter 함수를 추가로 정의해 이전 코드에서처럼 _app에서 setter 함수를 호출하는 방식으로 구현할 수도 있습니다. 하지만 isMobile과 같이 반드시 앱 시작 시점에 초기화 될 필요가 없고, 한 번 초기화되면 추후 변경되지 않는 값임이 확실한 경우에는 해당 값을 최초로 참조할 때 초기화해 줘도 무방합니다. 이를 싱글턴 패턴이라고 합니다. 위와 같이 isMobile이라는 값을 파일 변수로 정의해 사용한다면 Redux로 관리할 때 필요한 보일러 플레이트 전혀 없이 필요한 모든 곳에서 단순히 getIsMobile 함수를 호출해 값을 가져올 수 있습니다. 클라이언트 사이드에서 파일 변수 사용 시 유의할 점은 무엇인가요? 아래 두 상황에서는 파일 변수를 사용할 때 유의해야 합니다. 1. 특정 페이지에 대해 SSG를 사용하는 경우 빌드 타임에 HTML을 생성해 주는 Static Site Generation (SSG)의 경우, 파일 변수를 임포트해서 렌더링에 사용하는 코드를 작성하더라도 클라이언트 사이드에서 관리되는 파일 변수의 값을 활용할 수 없다는 점을 유의해야 합니다. SSG로 제공되는 페이지는 온전히 서버에서 빌드 타임에 HTML이 생성되기 때문에 클라이언트의 현재 상태를 참조할 수 있는 방법이 없기 때문입니다. 위 예시에서 정의한 getIsMobile 함수의 경우 서버 환경에는 window 객체가 존재하지 않기 때문에, 해당 함수를 SSG 페이지에서 호출하는 코드를 작성했다면 애플리케이션 빌드 시점에 window is not defined라는 에러를 보게 될 것입니다. 2. Hard Navigation으로 페이지를 이동하는 경우 Hard Navigation이란 window.location.href 를 통해 특정 URL로 이동하거나 a 태그를 통해 페이지를 이동하는 등 Web API를 활용한 페이지 이동 방식을 뜻합니다. 이 경우 페이지를 새로고침 하거나 브라우저 주소창에 직접 URL을 쳐서 페이지에 진입하는 것과 동일하게 페이지 자체가 처음부터 다시 그려지는 것이기 때문에, 상태 값과 같이 컨텍스트에 유지되던 데이터와 함께 파일 변수 또한 사라지게 됩니다. 이와 반대되는 개념인 Soft Navigation은 Client-Side Navigation이라고도 불리며, 브라우저의 페이지 이동 방식을 사용하는 대신 자바스크립트를 사용해 페이지 이동을 구현함으로써 더 빠른 페이지 이동을 가능케 하고 기존 데이터를 그대로 유지할 수 있게 해줍니다. 우리가 React로 프로젝트를 구현할 때 라우팅을 위해 react-router와 같은 라이브러리를 사용하거나, Next.js에서 useRouter와 같은 라우팅 관련 기능들을 사용해야 하는 이유입니다. 따라서, 어떤 이유에서건 Hard Navigation을 프로젝트에서 사용해야 한다면 해당 시점에 클라이언트의 파일 변수 값이 유지되지 않는다는 점을 유의해야 합니다. Deep-Dive: 페이지 이동 시 파일 변수의 값이 초기화되지는 않나요? 여기서 이런 의문이 들 수 있습니다. 파일 변수를 사용하는 페이지가 아니라 사용하지 않는 페이지로 이동한다면 파일 변수의 값이 사라지는 거 아닌가? 이동한 페이지에서는 더 이상 파일 변수가 정의된 파일을 임포트하지 않는데! 타당한 의문입니다. 자바스크립트 환경에서는 가비지 컬렉터가 주기적으로 동작하며, 현재 메모리에 할당되어 있는 특정 데이터가 더 이상 참조되지 않는다면 - 혹은 해당 데이터에 더 이상 도달할 수 없다면 - 메모리에서 할당 해제하기 때문입니다. 만약 내가 A라는 페이지에서 isMobile이라는 파일 변수를 임포트해서 사용하다가 파일 변수를 사용하지 않는 B라는 페이지로 이동했을 때, 이동한 페이지에서는 더 이상 isMobile을 참조하는 곳이 없으므로 가비지 컬렉터가 파일 변수를 지워버리지 않을까요? 결론부터 말씀드리자면 한 번 임포트 된 ES6 모듈에 대해서는 가비지 컬렉터에 의해 파일 변수의 값이 날아가는 걱정을 하지 않아도 됩니다. 어떻게 값이 유지되는지를 알기 위해서는 HTML 스펙을 조금 깊이 살펴볼 필요가 있습니다. 여러분이 웹 사이트에 접속하면 여러모로 친숙하실 document 라는 객체가 생성됩니다. 그리고 이 객체는 Soft Navigation을 통해 URL이 변경되는 등의 동작으로 내부 값이 변경될 수는 있어도, 한 번 생성되면 유저가 웹 사이트를 이탈하거나 Hard Navigation을 통해 기존 DOM 정보를 모두 상실하지 않는 한 계속 유지됩니다. HTML5에 대한 표준 스펙이 명시되어 있는 HTML Standard 에는 Document에 대한 항목이 존재하는데, 이 항목에는 아래와 같은 내용이 존재합니다. 각 Document 객체는 초기에 값이 비어있는 모듈 맵 (Module Map)을 가지고 있다. 모듈 맵이 정확히 무엇이고 어떤 역할을 하는지는 Mozilla 블로그 포스팅 에 아주 상세하게 설명되어 있습니다. 다만 해당 내용이 매우 길기 때문에, 이 글에서는 모듈 맵이 다음과 같은 특성을 가진다는 것만 짚고 넘어가도 충분합니다. 모듈 스크립트를 임포트하면 이 정보가 모듈 맵에 저장됩니다. 모듈 맵은 모듈 스크립트에 대한 캐시 저장소 역할을 제공합니다. Module Map 그리고 마지막으로 HTML5 표준 스펙에서는 모듈 맵에 대해 다음과 같이 설명하고 있습니다. 모듈 맵은 임포트 된 모듈 스크립트들이 Document나 워커 당 한 번만 패치 / 파싱 / 평가되도록 보장하기 위해 사용됩니다. 이 내용들을 조합해 보면 다음과 같은 결론을 내릴 수 있습니다. 웹 사이트 진입 시 한 번 생성되고 해제되지 않는 Document 객체는 모듈 맵을 가지고 있으며, 이 모듈 맵은 특정 모듈을 단 한 번만 가져오도록 보장해 줍니다. 이전 페이지에서 임포트 된 모듈이 현재 페이지에서 사용되지 않는다고 가비지 컬렉션을 한다면, 다시 모듈을 사용하는 페이지로 이동했을 때 모듈을 리패치해야 하는데 이는 모듈 맵이 특정 모듈을 단 한 번만 패치하도록 보장해야 한다는 원칙을 깨뜨리는 일이 되겠죠. 따라서, 한 번 임포트 된 모듈은 가비지 컬렉션 되지 않으며 해당 모듈이 전역 변수로써 참조하고 있는 파일 변수 또한 가비지 컬렉션 되지 않는다는 결론을 내릴 수 있습니다. 서버 사이드 (Node.js 환경)에서 파일 변수는 어떻게 동작하며 어떻게 사용해야 하나요? 지금까지는 클라이언트 환경에서 파일 변수를 활용하는 방법에 대해 알아봤습니다. 그런데, 만약 서버 환경에서 파일 변수를 사용하고 싶다면 어떻게 해야 할까요? 유저가 PC웹 환경이 아니라 모바일 웹 환경에서 /pc-only 라는 URL로 접근을 시도했을 때, 해당 요청을 /mobile-only 라는 URL로 리다이렉트 시키기 위해 Node.js 서버 환경에서 다음과 같은 Express 미들웨어를 작성했다고 가정해 봅시다. export async function pcOnlyMiddleware(req, res, next) ( const isMobile = parseUserAgent(req.headers['user-agent'])
if (req.path === '/pc-only' && isMobile) ( res.redirect('/mobile-only') return )
n
