Skip to main content

· 15 min read

얼마전 토스과제를 진행하며 알게된 점들은 매끄러운 사용자경험 및 개발 생산성을 위해
frontend 로직뿐 아니라 보이지 않는 환경설정에도 엄청난 노력을 하고있음을 알게되었다.
이전에 개발을 하며 알고는 있지만 자세하게 몰랐던 부분과 그들의 디테일에
놀라워하며 면접준비를 했는데 이 글에서는 이 과정에서 알게된 사실들을 정리한다.

참고

Youtube 및 toss tech blog를 참고하여 작성한 글 입니다.

dependencies in Package.json

우리가 개발을하며 패키지매니저를 통해 라이브러리를 설치할 때는 package.json 내의 dependencies 필드를 통해 의존성을 규정하는것으로 설치한다 이때 해당 패키지의 이름과 버전범위를 지정한 객체를 통해 설치하는데 버전의 범위는 하나 혹은 여러개의 공백으로 분리된 문자열이다.
버전범위를 지정하는 자세한 방법은 semver를 참고

만약 개발중에만 필요한 라이브러리일 경우 devDependencies필드를 활용하며

npm install some-package --save-dev

위와 같은 명령어로 설치 가능하고 패키지를 설치 후 devDependencies에 추가한다.
여기서 만약 내가 clone된 repository에서 npm install했을 경우 npm은 이를 프로젝트를
개발중이라고 인식하고 devDependencies에 추가하니 유의할 것.

마지막으로 본인만의 plugin을 만들 때 사용하는 peerDependencies인데
정의에 따르면 상속되는 의존성으로 패키지를 사용하는 곳에서 제공해야하는 의존성이다.
만약 프로젝트 내에서 패키지를 peerDependencies로 명시할 경우 패키지를 제공하는 책임을
가장 상위프로젝트로 바꾸기 때문에 번들링 결과에서 중복을 막을 수 있다.

하지만 이러한 장점에도 치명적인 단점은 의존성 전파문제이다.

package A:
peerDependencies:
"package P"
package B:
dependencies:
"package A"
peerDependencies:
"package P"
package C
dependencies:
"package B"
peerDependencies:
"package P"

위와 같이 peerDependency를 사용하는 package A에서 A를 가지고 있는 모든 패키지들은 A를 사용하지 않음에도 peerDependencies에 추가해주어야 하며 이는 의존성의 관리복잡도를 증가시켜
에러의 확률을 높인다 npm과 같은 패키지매니저들에서 이러한 오류들을 엄밀히 검사하지 않기 때문에 유의해야 하며 따라서 peerDependencies를 사용하는 경우
단 하나의 패키지만 존재해야 하는경우(싱글톤) 일때 사용할 수 있다.

dependencies의 문제점

위와 같은 문제말고도 npm/node_modules의 문제는 유령의존성(Phantom Dependency) 현상이다
node_modules를 사용하는 yarn v1 및 npm에서는 중복해서 설치하는 모듈을 피하기 위해
호이스팅 기법을 사용한다

Hoisting Tree

왼쪽과 같은 의존성트리일 경우 디스크공간의 절약을 위해 중복되는 트리를 지우는 과정에서
직접 의존하고 있지 않았던 B(1.0) 패키지를 불러와 사용할 수 있다.
package.json 에 명시하지 않은 라이브러리를 사용할 수 있는 현상을 Phantom Dependency라고 하며
이러한 특성은 시스템을 혼란스럽게 하고 최악의 경우 Runtime Error의 가능성을 높힌다.

토스팀에서는 이러한 문제를 해결하기 위해 Yarn Berry + PnP를 도입하며 해결했다.
Yarn Berry는 Plug'n'Play 전략을 이용하여 기존의 Package.json을 기반으로 의존성 트리를
만들어 node_modules 구조를 생성하던 방식에서 의존성 설치시 node_modules를 생성하지 않고
.yarn/cache폴더에 의존성 정보를 저장한다 또한 .pnp.cjs 파일에 의존성을 찾을 수 있는
정보를 기록하여 효율적인 패키지 관리를 할 수 있다.

yarn은 기존 node.js의 require함수를 덮어씌워 동작하며 효율적인 패키지관리를
할 수 있고 이때문에 특정 패키지와 의존성에 대한 정보가 필요할 때 바로 알 수 있다
또한 Yarn PnP 시스템에서 각 의존성은 Zip 아카이브로 관리하게 되어 다음과 같은 장점이 생긴다

  1. node_modules가 없음 -> 빠른 설치 가능
  2. 패키지들은 하나의 Zip아카이브로 저장하여 중복방지
  3. 파일의 수가 적어지며 변경사항의 감지가 쉬워지고 삭제가 빠르다.

Zero Install

여기서 한발 더 나아가 의존성을 Git으로 관리한다면 어떨까?
일반적인 node_modules의 크기는 1.2GB/13만5천개의 파일을 가지고 있지만 yarn을 사용한다면
139MB/2천개의 압축파일로 줄어든다 이렇게 줄어든 파일크기는 Git으로 관리하여 버전관리에
포함시키고 설치할게 아예없는 환경을 구성한다.(Zero-Install)

토스팀에서 Zero-Install을 사용하며 얻은 장점 두 가지

  1. clone 혹은 branch변경시 yarn install 실행x

    • 만약 다른 의존성을 사용하는 브랜치로 이동한다면 재설치할 필요가 없다.
  2. CI동작시 의존성 설치시간 절약

    • cache miss 일경우 60 ~ 90초 가량 소요되던 시간을 의존성 복제만으로
      바로 사용이 가능하여 시간절약

Bundler

javascript에 모듈이라는 개념이 없던시절 규모가 큰 프로그램을 개발하기 위해 파일을 쪼개서 작업했지만
스크립트 태그를 통한 전역변수 참조를 했기때문에 함수나 변수의 이름 충돌 문제를 일으키고
규모가 커질수록 스크립트 로드시간이 증가하여 사용자 경험에 안좋은 영향을 끼쳤다.

이러한 문제를 해결하기 위해 파일을 하나로 합치는 번들링(Bundling) 이라는 개념이 탄생하고
CommonJS의 require함수가 등장하며 이 시점부터 파일단위의 개발이 가능해지고 수천개의
JS파일로 분리하며 라이브러리의 쉬운 재사용성을 통해 더 나은 개발경험을 제공했다.

모듈의 탄생과 함께 생겨난 번들러는 CommonJS이후에도 생겨난 모듈에 의해 설계의 영향을 미쳤고
지금 이시점에도 굉장히 다양한 번들러들이 있지만 번들러의 동작은 크게 세 가지로 구분한다.

  1. Resolution
  2. Load
  3. Optimization

Resolution

resolution단계에서는 import/require되는 파일의 위치를 정확하게 찾는역할

import { App } from './App';

App을 import 하는경우 ./App의 정확한 경로를 탐색한다 (App.js, App.ts, App.tsx)
번들러에서는 이러한 설정을 기본제공하고 필요에따라 커스텀가능.
이렇게 정확한경로를 탐색하여 어떤파일들을 합쳐야 최종결과물이 되는지 알 수 있다.

Load

Load단계에서는 표준 Javascript로 변환하는 역할을 하는데 현대의 웹 개발은
HTML,CSS,Javascript로만 개발하기엔 어려움이 있고 이를 보완하고자 슈퍼셋 언어들이 등장하였다.
이에 따라 대표적으로 Typescript와 같은언어를 사용하기위해 트랜스파일러가 등장하며
번들링과정에서 Babel/SWC같은 트랜스파일러를 수용하여 표준Javascript 이외에도 사용가능한
형태로 발전해 트랜스파일링 과정을 거치는것이 Load단계에 해당함.

여기서 트랜스파일러는 한 언어를 추상화단계가 비슷한 언어로 변환해주는 역할을하지만
언어 전체적으로 트랜스파일하지는 않는다. 문법의 문제가 아닌 언어의 표준이 변경되거나
새로추가되는 함수의 경우 폴리필(polyfill)과정을 거쳐야한다 구현이 누락된
새로운 기능을 메꿔주는(fill in) 역할을 하며 기능이나 사용자의 브라우저에 따라
다양하게 설정할 수 있다.

Optimization

Resolution/Load 단계를 거쳐 완전한 JS파일 하나를 생성했다면 다양한 의존성을 사용하는
JS파일의 크기는 너무 크기 때문에 성능저하를 유발할 수 있다 따라서 파일 크기를 줄이기위해
두 가지 방법을 사용함

  1. Minification (Compression + Mangling)
  2. Tree Shaking

1-1. Minification - Compression 코드의 text를 최대한 압축

  • undefined -> void 0
  • 2 + 3 -> 5
  • !a && !b -> !(a || b)
  • Infinity -> 1/0

1-2. Minification - Mangling 변수,Class,파일이름 최적화

// function add(num1, num2) { return num1 + num2 }
function add(l, r) {
return l + r;
}

2. Tree Shaking 사용하지 않는 코드 제거

Tree Shaking 단계에서는 사용하지 않는 코드를 분석하고 제거하는 역할을 하는데
사용하지 않는 코드가 무엇인지 판별하는 일은 까다로운 편에 속한다(정적분석의 어려움)
따라서 번들러별로 알고리즘 및 접근방식이 천차만별이라 큰 차이가 있는 사항이기도 하다.

토스에서는 위와 같은 번들러의 특성을 활용해 React Native의 Metro 에서
ESbuild로 옮기며 빌드속도를 최적화 했던 사례를 소개했다.

Git 분석 및 활용

VCS중 하나인 Git은 델타기반 버전관리 시스템으로 파일의 변화를 시간순으로 관리해
프로젝트의 스냅샷을 저장하고 파일간 변경사항을 차이(diff)/델타(delta) 형태로
저장하고 파일을 세 가지 상태인 Committed, Modified, Staged 관리하며
이 세 가지의 상태는 프로젝트의 세 가지 상태와 연관되어 있고 다음과 같다.

  1. Working Directory - 실제 작업공간
  2. Staging Area - 변경된 파일들의 대기공간
  3. Repository(.git directory) - 최종적으로 저장된 파일들의 공간

Git은 안전하고 일관된 파일관리를 위해 sha-1 해시알고리즘으로 40자길이의 16진수 문자열을
만들고 모든것을 해시로 식별하여 데이터의 무결성을 확인한다.

Git basic

git으로 버전관리를 시작하기 위해 본인의 작업폴더에서 git init 명령어를 실행하면
.git폴더가 생성되고 이곳에 파일들의 정보가 저장된다.
우리가 실제로 작업하는공간(Working Directory)의 파일은 Tracked와 Untracked로 나누며
Tracked파일의 경우 스냅샷에 포함돼 있던 파일로 Unmodified, Modified, Staged 상태중
하나이다.

git-status

git-add

참고

· 5 min read

Recent Posts

메인페이지에 recentpost 기능을 추가하려고 미루다가 드디어 시작하며
docusaurus에서 직접 파일시스템에 접근하려고 몇번을 삽질하다가
plugin을 만들어 사용해야한다는 결론을 얻었다.
이미 만들어진 blog 관련 plugin이 있긴했지만 plugin-content-blog
config 필드에서 option을통해 수정하는 방식은 원하던 모양이 아니라 패스했음.

Architecture

Archive탭에 있는 글 중 최신순으로 5개만 메인페이지에 보여주고 싶었는데
메인페이지에서 비동기로 파일시스템을 호출해 읽은다음 slug를 리턴하는 식으로
구상했지만 docusaurus의 제작의도랑은 전혀 다르기때문에 에러가 생길 수 밖에없다.
친절하게도 아키텍처 소개글을 보며 감을 잡을수 있었음

docusaurus의 설계 혹은 멘탈모델이 코드를 직접 import해서 쓰거나 하지않고
json으로 임시파일을 만들어 데이터를 주고받거나 사용자가 plugin에 접근한다면
오로지 config.js를 통해 상호작용 할 수 있도록 설계되어 있기때문에
여기서 제공하는 Lifecycle API를 이용해 build시 생성된 json 파일로
내 블로그 데이터에 접근해야 한다. 따라서 대부분의 커스텀은 config에서 가능함

Architecture

그래서 문서를 찾아보던중 globalData를 사용할 수 있는 useGlobalData API가
있긴했으나 docs의 정보만 담아줄 뿐 blog의 데이터는 담기지 않길래 Lifecycle API를 이용해
build시 json에 블로그 정보들을 담아 사용하는 쪽으로 변경했다.
globalData의 정보들은 npm start.docusaurus/globalData.json에서 확인가능

Using Plugins

plugin은 명령어를 통해 설치할 수도 있지만 로컬파일을 불러와 사용가능함

docusaurus.config.js
export default {
// ...
plugins: ['./src/plugins/docusaurus-local-plugin'],
};

로컬파일을 불러와 사용하는 경우 절대경로로 명시한다.

docusaurus.config.js
export default {
// ...
plugins: [
async function myPlugin(context, options) {
// ...
return {
name: 'my-plugin',
async loadContent() {
// ...
},
async contentLoaded({ content, actions }) {
// ...
},
/* other lifecycle API */
};
},
],
};

직접 함수를 작성하는 방법도 가능

위와같이 경로를 지정했다면 Docusaurus측에서 제공하는 Lifecycle APIs를
이용해 코드를 작성하면 된다.

API들중 가장 중요하다고 볼 수 있는 async loadContent()
async contentLoaded({content, actions}) 두 가지가 있다 loadContent에서 파일시스템에
접근하거나 다양한 동작들이 가능하고 loadContent에서 내가 필요한 값을 return한다면
그 값은contentLoaded함수에서 content 파라미터로 받는다. actions의 경우
기본으로 제공하는 3가지 함수들이 있고 경로를 설정하는 경우 appRoute
json파일을 만든다면 createData 나의 경우 기존 globalData.json에 데이터를 추가하려고 했기에 마지막인 setGlobalData를 사용했다.

  • loadContent - 다양한 동작을 정의하는 함수 (파일시스템, API호출...)
  • contentLoaded - content,actions를 파라미터로 받는 함수
    • content: loadCotent의 리턴값
    • actions: appRoute, createData, setGlobalData로 구성
./src/plugins/my-plugin.js
export default {
plugins: [
async function myPlugin(context, options) {
return {
name: 'my-plugin',
async loadContent() {
return 1;
},

// loadContent의 return값 content에 전달
async contentLoaded({ content, actions }) {
const { setGlobalData } = actions;
setGlobalData({ myBlogData: content });
},
};
},
],
};

위처럼 코드를 작성하면 npm start시 globalData에 데이터가 저장되고
내가 원하는 부분에서 사용하려면 useGlobalDatausePluginData를 사용해
데이터를 가져와 사용하면 끝

./src/pages/index.tsx
import { usePluginData } from '@docusaurus/useGlobalData';

export default function Home() {
const { myBlogData } = usePluginData('my-plugin');

return (
<ul className="post-list-container">
{myBlogData?.map((post) => (
<li key={post.slug}>
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
{post.date}
</li>
))}
</ul>
);
}

· 6 min read

SSR(Server-Side Rendering) 도입의 변화

여러기업들에서 사용자에게 더 나은 사용성을 제공하기 위해 노력하고 있다.
Frontend 측면에서 어떤 노력들을 하고있을까 찾아보았는데
첫번째는 Node.js 의 발전으로 server와 client가 같은 언어를 사용하게 되면서
렌더링의 책임을 Server로 이전하려는 움직임이 나타났고
클라이언트 측에서 렌더링을 담당하던 React와 같은 환경에서
사용자들이 페이지에 처음으로 진입하는 시간(LCP/FCP)을 줄여 더 나은 사용성을 제공한 것이다.

이런 대표적인 프레임워크로 Next.js가 있고 단점은
Server측에서 렌더링 한다는 것은 결국 관리해야하는 서버가 있다는 것이고 곧
모니터링 하는데 비용이 들고 트래픽이 몰릴 경우를 대비해야한다.
또한 Build 및 배포시간이 오래걸린다는 단점도 존재.

Observability?

컨퍼런스나 자료들을 찾다보면 Observability라는 단어를 심심치 않게 볼 수 있다.
도대체 무슨 뜻일까 보다보니 나에게는 이상적으로 다가왔는데
이 단어를 만든 사람 Rudolf E Kalman은 "시스템의 출력으로부터 시스템의 상태를 이해할 수 있는 능력" 이라고 한다 결국 이 말이 로깅이나 모니터링 지표를 통해 우리의 App의 상태를 측정하고
발 빠른 대응이 가능한 능력이라고 생각한다.

특히나 규모가 큰 기업들은 MSA같은 아키텍처를 사용하고 요청이 분산처리되어
제대로 로그를 확인하거나 모니터링이 힘들 수 있다.
이상적으로는 에러가 아예없는 환경을 꿈꾸긴 하겠지만 에러는 있을 수 밖에 없고
그렇기에 발빠른 대응을 위해 최대한 Observability를 향상시키기 위해 힘쓰는것 같다.

Web Vitals

대표적으로 프론트엔드의 성능을 측정할 때 신경 쓰는 4가지는

  • TTFB: 브라우저가 서버로부터 첫번째 바이트를 수신하는 속도(>500ms)
  • LCP: 가장 큰 콘텐츠가 브라우저에 그려지기까지의 속도(>2.5s)
  • FCP: 사용자에게 볼 수 있는 콘텐츠가 최초로 그려지기까지의 속도(>1.8s)
  • TTI: 페이지 로딩이 완료되고 사용자 입력에 응답하기까지의 속도(>100ms)

이 외에도 레이아웃 시프트(CLS)나 TTI와 유사한 FID등이 있다.

이런 지표들의 자세한 설명이아닌 이유에 대해 생각해보려 하는데
위와같은 지표들을 통해 성능을 측정/최적화 하는 것은 사용자 경험 개선으로 이어지고
꾸준한 모니터링을 통해 성능의 저하를 막는다고 생각한다 혹은 테스트코드 작성을 통해 막거나?

정리

누군가 나에게 안전한 프론트엔드 서비스란 뭐에요? 라고 묻는다면
Frontend의 Observability를 가능한 최대로 끌어올리는 것과 Metric같은 보조지표를 활용하여
App의 성능을 측정하고 최적화,정확성을 테스트하여 웹사이트의 다양한 측면들을 원활하게
만들어 사용자 경험을 개선하고 에러를 감지하여 신속하게 처리할 수 있는 모니터링 환경을 구성하는 것 이상적으로는 에러가 아예없는 환경이 안전한 프론트엔드 서비스에 근접하지 않을까 라고 대답할것같다.

참고