본문 바로가기

개발자 강화/프론트엔드

[토스증권 테크톡톡] 26/02/24 - 인사이트 정리 (애니메이션 최적화, DS커스터마이징 등)

일시: 2월 24일 오후 7시~9시
장소: 토스증권 유튜브 채널 스트리밍


[Session #1] 토스증권 프론트엔드 인프라 - (장재영님, Client Platform Team Leader)

세션을 들으며 노트했던 내용을 바탕으로 인사이트 정리

본 세션 내용과 다른 부분이 있을 수 있음

카나리 배포

점진적 배포를 위한 FE 인프라 세팅에 대한 내용과 함께 연관 영상을 소개해주셨다

 

Native ESM에 올라탄 마이크로 프론트엔드 (토스 메이커스 컨퍼런스 25)

https://youtu.be/6g49EaSKZHg?si=92fvoZ2bDzCRXwg4


토스증권 내부 LLM 활용 사례

AI 기반 QA 자동 생성 

- QA 체크리스트 생성 서버가 code diff, PR description, 작업 요구사항, github codebase를 수집해서 내부 LLM에 넘김

- AI가 '이 변경사항에서 테스트 해야 할 항목'을 자동으로 만들어줌

 

-> PR을 올리면 QA 체크리스트가 자동으로 생성됨


증프챗봇 - 코드/문서 검색 어시스턴트

- 임직원이 자연으로 질문하면 내부 LLM이 토스증권 코드베이스 맥락에 맞는 답변을 줌

- 비개발 직군이 잘 활용한다고 함

 

기술 스택

- Mastra AI Framework: Tool 관리, Agent 위임, 워크플로우

- Qdrant Vector Store: 문서 탐색

- Explore Code Agent: 코드 탐색

- Git Repo Tools

- Langfuse: Agent의 동작 모니터링


AI 기반 CS 대응 효율화

1. 내부 LLM이 CS 내용 분류 (키워드, 카테고리, 지면)

2. RAG로 유사 CS 검색 -> 해결 방법 제안

3. 해결방안 Vector DB에 적재

4. 담당 팀 자동 호출 / 채널 포워딩

 


[Session #2] 우당탕탕 디자인 시스템 - (박건영님, Frontend UX Engineer)

세션을 들으며 노트했던 내용을 바탕으로 인사이트 정리

본 세션 내용과 다른 부분이 있을 수 있음


DS(디자인 시스템) 버저닝 관리

- 날짜 기반 버저닝을 사용 (메이저 + 배포된 날짜 + 순번 조합)


DS 컴포넌트 커스터마이징 지원

DS의 딜레마...

- 조금만 바꾸고 싶은데 DS팀에 요청하면 시간 걸리고...

- 내가 마개조하면 버전업했을 때 깨질 수 있고...

(최근 DS 구축을 시작해서 남일 같지 않다)


extends HTMLAttributes의 문제

- TypeScripts에서 인터페이스를 extends하면, 부모에 이미 있는 속성과 호환되지 않는 타입으로 재선언할 수 없음.

// React 내부 정의: color는 string
interface HTMLAttributes {
  color?: string;
  // ...수백 개의 속성
}

interface ButtonProps extends HTMLAttributes<HTMLButtonElement> {
  color: "red" | "blue";
  // "red" | "blue"는 string의 부분집합이라 에러가 안 날 수도 있음
}

 

- "red" | "blue"는 string의 부분집합이라 에러가 발생하지 않을 수 있음. 하지만, 사용하는 쪽에서는 문제가 됨.

// 타입이 합쳐지면서 string 전체가 허용되어 버림
<Button color="아무문자열이나됨" />  // ← 에러가 안 남!

 

 

- TypeScript가 부모의 string과 자식의 "red" | "blue"를 합치면서, 결과적으로 string이 이김

- 자동 완성에서도 red, blue에 대한 추천이 안나옴. 타입 안정성이 무너짐.


아이디어: MUI(Material UI)의 slotProps를 주목하자

 

컴포넌트 고유의 props와 네이티브 HTML 속성을 같은 평면에 합치지 않는다.

 

slots - 컴포넌트 내부의 서부 요소를 무엇으로 렌더링할지 결정한다.

사용자가 내부 요소를 통째로 교체할 수 있다.

 

slotProps - 각 서브 요소에 어떤 네이티브 props를 전달할지 결정한다.

styles, 이벤트 핸들러 등 HTML 속성은 여기로 격리된다

 

// 기존: 모든 props가 한 평면에 뒤섞임
<Button color="red" onClick={handle} style={{...}} />

// MUI 방식: 컴포넌트 props와 네이티브 props가 분리됨
<Button
  theme="red"                              // 컴포넌트 고유 props
  slotProps={{
    root: { style: {...} },                // root 요소에 전달
    button: { onPointerDownCapture: handle } // button 요소에 전달
  }}
/>

 

ButtonProps 인터페이스가 HTMLAttributes를 extends 할 필요가 없어진다.

color 같은 이름 충돌이 구조적으로 불가능해지고, 컴포넌트 API 표면이 깔끔하게 유지된다.

 

*MUI 관련 Docs: https://mui.com/material-ui/customization/overriding-component-structure/

 

토스증권 DS에서는 이 패턴을 참고해 createCustomSlots / customProps라는 자체 API를 구현했다고 한다.


아이콘 표준화

DS에서 아이콘을 다루다 보면, 이런 문제를 맞닥뜨릴 수 있다.

같은 24px 박스에 렌더해도, 아이콘마다 내부 여백이 달라서 실제로 눈에 보이는 크기감이 달라지는 문제다.

 

하지만, 아이콘의 여백은 바꿀 수 없다.

그래도, 이 아이콘들이 비슷한 크기로 눈에 보였으면 좋겠다.

 

각 아이콘마다 '이상적인 크기'에 도달하기 위한 배율을 미리 정의해둔다.

그리고, Assets 컴포넌트가 이를 참조해서 자동으로 스케일링을 처리한다.

 

사용하는 쪽에서는 small / medium / large 같은 표준화된 사이즈만 지정하면 된다.


 

[Session #3] 토스증권 AI 개발로 배운 실전 인터렉션 팁 - (이승현님, Frontend Developer) / 20분

세션을 들으며 노트했던 내용을 바탕으로 인사이트 정리

본 세션 내용과 다른 부분이 있을 수 있음


애니메이션 랜더링 최적화

브라우저 랜더링 파이프라인

브라우저가 화면을 그리는 과정은 네 단계로 이뤄진다

Style -> Layout -> Paint -> Composite

 

속성을 변경하면 어느 단계부터 다시 실행되는지 달라진다.

 

Layout부터 재실행 (가장 비쌈)

- width, height, padding, margin, font-size 등.

- 주변 요소 배치까지 전부 다시 계산 필요

 

Paint부터 재실행 (비쌈)

- color, background-color, background-image (gradient 포함), box-shadow, border-color 등.

- 픽셀을 다시 찍어야 함.

 

Composite만 실행 (빠름)

- transform, opacity.

- GPU가 기존 비트맵을 이동/회전/투명도 조절만 하면 된다.


비트맵이란

Paint 단계에서 브라우저는 css 선언들을 해석해서,

최종적으로 '이 픽셀이 무슨 색'이라는 정보가 담긴 완성된 그림(비트맵)을 만든다.

이 비트맵은 GPU 메모리에 텍스처로 올라간다.

 

transform이 빠른 이유는, 이 완성된 그림을 다시 그리지 않고 통째로 이동/확대/회전만 하기 때문이다.

 

사진 앱에서 핀치 줌을 하면 원본 사진을 다시 찍는 게 아니라, 기존 사진을 확대해서 보여주는 것과 같은 원리이다.

그래서 scale을 많이 키우면 흐려진다. 원본 비트맵의 해상도는 그대로이기 때문이다.

 

반면, color: red를 color: blue로 바꾸면, 비트맵 안의 픽셀을 전부 다시 찍어야 한다.

기존 비트맵을 재활용할 방법이 없어서 Paint가 재실행된다.


Repaint가 없는 속성 / 있는 속성

- Repaint가 없음 (Composite만)

   transform (translateX, translateY, scale, rotate), opacity

 

- Repaint 발생

   color, background, background-image, box-shadow, border-color

   단색이든, gradient든 repaint 필요

 

-> transform과 opacity를 제외한 거의 모든 시각적 속성이 repaint 대상


토스증권의 문제 상황

- 종목 리스트 페이지가 있다.

- 종목 옆에 화살표가 있고, 왼쪽에서 오른쪽으로 빛이 스르르 지나가는 shimmer 효과가 적용되어 있다.

 

기존 상황

framer-motion은 JavaScript 메인 스레드에서 매 프레임마다 값을 계산하고 스타일에 반영한다

 

animate={{x, y, scale, rotate, opacity }}만 쓰면 성능 문제가 거의 없다.

하지만, background, color, width 같은 속성을 매 프레임 변경할 땐 이슈가 발생한다.

 

그라데이션 애니메이션을 framer-motion으로 구현하면,

그라디언트 값 자체를 매 프레임 변경한다.

 

background 속성이 매 프레임마다 발생하니, repaint도 같이 발생한다.

 

한 화살표에 최소 50회 repaint, 한 페이지에 최소 7개 화살표 존재.

결국 최소 350회 repaint가 발생하고, 해당 페이지만 들어가면 기기가 뜨거워지는 현상이 발생했다.

 

이럴 땐, 반복적이고 단순한 애니메이션은 framer-motion 대신 순수 css나 WAAPI로 분리하는 방법이 있다.

복잡한 인터렉션/제스처만 framer-motion에 맡기는 것이다.


해결: radial-gradient + translateX

 

화살표 모양의 틀(clip-path)을 만들어두고,

그 아래에서 radial-gradient가 이미 그려진 레이어를 translateX로 슬라이딩 시킨다

 

화살표 모양 마스크 (clip-path):
┌─────────────────────────▶

그 안에서 그라디언트 레이어가 translateX로 이동:

프레임 1: [■■■░░░░■■■■■■■■■■■■■▶
프레임 2: [■■■■■■■░░░░■■■■■■■■■▶
프레임 3: [■■■■■■■■■■■░░░░■■■■■▶

 

그라디언트 자체는 처음 한 번만 paint하고, 이후에는 translateX만 변경한다

transform은 compositor가 처리하므로 repaint가 발생하지 않는다.

 

결과: Paint count 1회

// ai로 생성해본 예시 (실제와 다를 수 있음)
.arrow-track {
  overflow: hidden;
  clip-path: polygon(...);  /* 화살표 모양 마스크 */
}

.arrow-gradient {
  background: radial-gradient(...);  /* 한 번만 paint */
  animation: slide 2s infinite;
}

@keyframes slide {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}

+ 참고: WAAPI (Web Animation API)

브라우저에 내장된 네이티브 애니메이션 API이다.

별도 라이브러리 없이 사용 가능하다.

const animation = element.animate(
  [
    { transform: 'translateX(-100%)' },
    { transform: 'translateX(100%)' }
  ],
  { duration: 2000, iterations: Infinity, easing: 'ease-in-out' }
);

 

css @keyframes처럼 브라우저 엔진에 애니메이션을 통째로 위임하면서도,

JS로 동적 제어 (일시정지, 배속, 특정 지점 이동, 역방향 재생)가 가능하다

animation.pause();
animation.playbackRate = 2;
animation.currentTime = 500;
animation.reverse();
animation.finished.then(() => console.log('done'));

결론

시각적으로 동일한 효과를 '비트맵으로 새로 그리는 방식'이 아닌, '이미 그린 비트맵을 이동시키는 방식'을 사용한다.


스와이프 제스처

가로 스크롤을 시작하면 가로 방향으로만, 세로 스크롤을 시작하면 세로 방향으로만 스크롤 되게 해야 한다.

즉, 한 방향으로만 스크롤이 잠기는 동작이 필요하다


오잉? 그거 overflow:auto로 되는거 아녀?

아니다... (ㅠㅠ)

overlfow: auto를 쓰면 대각선 방향으로 스크롤이 가능해져버린다.

overflow-x: hidden - 가로 스크롤 자체가 아예 막혀버린다


브라우저 기본 스크롤을 버린 이유

추가 요구사항이 있었다

- 추후 핀치줌 제스처가 추가될 가능성이 있다

- 가로 방향에는 스냅 기능이 필요하다

- 브라우저 기본 스크롤은 세밀한 제어가 어렵다

 

*핀치 줌: 터치스크린에 두 손가락을 대고 벌리거나(확대) 오므리는(축소) 동작으로 화면의 이미지를 자유롭게 조절


아이디어: 컴퓨터 그래픽스의 카메라 개념

 

3D 그래픽스에서 카메라의 개념:

세상은 고정되어 있고, 카메라를 움직여서 보이는 영역을 바꾼다

 

스크롤에 적용해보자:

콘텐츠는 그대로 있고, 뷰포트(카메라)가 이동한다.

 

three.js OrbitControl: https://github.com/mrdoob/three.js/blob/dev/examples/jsm/controls/OrbitControls.js

 

처음엔 three.js를 스크롤 때문에 도입했다는 뜻인가? 라고 생각했는데,

OrbitControl의 터치 처리 알고리즘(방향 잠금, 관성, 감속, 스냅)을 참고해서 2D DOM용으로 재구현한 것으로 보였다.

(three.js를 이것 땜에 도입하기엔 라이브러리가 너무 크기 때문에, 토스가 그럴리가 없다는 생각을 했었다)

 

OrbitControl 아이디어를 차용했다는 부분까지만 메모했다.

이 아래 구조, 구현 내용은 따로 찾아본 내용이라 실제와 다를 수 있다.


구조

뷰포트는 항상 제자리에 고정.

터치하면 콘텐츠 컨테이너 자체가 반대 방향으로 이동한다

// ai로 그려본 상상 구조. 아마도 이런 모양일 듯이라는 추측

┌──────────────────────────────────────┐
│          콘텐츠 컨테이너                 │
│   (transform: translate3d로 이동)      │
│                                      │
│  ┌─────┬─────┬─────┐                 │
│  │종목A │종목B │종목C │                 │
│  └─────┴─────┴─────┘                 │
│  ┌─────┬─────┬─────┐                 │
│  │종목D │종목E │종목F │                 │
│  └─────┴─────┴─────┘                 │
│              ⋮                       │
└──────────────────────────────────────┘

         ↑ 이 전체가 움직임

   ┌ ─ ─ ─ ─ ─ ─ ─ ┐
   │  뷰포트 (카메라)  │ ← 사용자가 보는 화면. 고정.
   │ overflow:hidden│
   └ ─ ─ ─ ─ ─ ─ ─ ┘

카메라 로직이 하는 일

1. 터치 시작 -> 시작점 기록
2. 터치 이동 -> 이동량(delta) 계산
3. 방향 판별 -> 수평/수직 중 더 큰 쪽 선택
4. 한 축으로 잠금 -> 결정된 방향으로만 이동 허용
5. 터치 끝 -> 속도 기반 관성(inertia) 계산
6. 감속 -> 마찰 계수 적용해서 서서히 멈춤

 

contentElement.style.transform =
  `translate3d(${-camera.position.x}px, ${-camera.position.y}px, 0)`;

translate3d를 쓰면 브라우저가 해당 요소를 별도 GPU 레이어로 올린다.

이 레이어의 위치만 GPU가 이동시키면 되므로, 나머지 UI는 repaint 없이 유지된다


기본 스크롤 vs 커스텀 스크롤: 가상화 관점

그렇다면 이렇게 구현한 커스텀 스크롤과 기본 스크롤의 차이점은 뭘까?

 

화면에 종목이 1000개 있을 때, 1000개를 전부 DOM에 올리면 노드 수가 수만 개가 된다.

가상화는 화면에 보이는 20개 정도만 DOM에 만들고, 나머지는 JS 데이터(배열)로만 들고 있는 기법이다.

JS 데이터 1,000개: ~수백 KB, CPU 비용 거의 없음
DOM 데이터 노드 15,000개: ~수십 MB, 스타일/레이아웃 계산 비용 큼

 

가상화 적용 후:

JS 데이터 1,000개: ~수백 KB (그대로 유지)
DOM 노드 300개 (종목 20개 분량): ~수 MB (화면에 보이는 것만)

 

데이터가 메모리에 있다고 자동으로 DOM이 되는 것이 아니다.

React가 명시적으로 렌더해야 DOM 노드가 생긴다.

가상화는 이 변환 시점을 '화면에 보일 때'까지 미루는 것이다.

 

기본 스크롤에서도 가상화는 가능하지만, 타이밍 차이가 있다

 

기본 스크롤 + 가상화:

브라우저가 스크롤 -> 화면 이동 -> scroll 이벤트 (비동기) -> DOM 교체 -> 사이에 빈 영역이 잠깐 보일 수 있음

 

커스텀 스크롤 + 가상화:

터치 입력 -> position 계산 (동기) -> DOM 교체 -> transform 적용 -> 같은 프레임에서 처리, 빈 영역 없음


확장성

같은 카메라 모델에서 다양한 제스처를 통합 관리할 수 있다.

 

핀치줌: scale 파라미터 추가
스냅: position 보정 로직 추가
관성 조절: 마찰 계수 변경

트레이드 오프

커스텀 스크롤은 JS로 requestAnimationFrame을 돌리는 것.

메인 스레드가 블로킹 되면 스크롤이 버벅일 수 있다.

기본 스크롤은 브라우저가 compositor 스레드에서 처리하므로 이런 문제가 없다.

 

단순한 세로 스크롤이면 기본 스크롤이 낫고,

방향 잠금 + 스냅 + 핀치줌 확장까지 필요한 상황에서만 커스텀 스크롤이 합리적인 선택이다.


인트로 영상

animated webp나 png(gif)가 아니라 영상을 쓴 점이 흥미로웠다.


Cache API로 영상을 기기에 저장

한 번 받은 영상을 기기에 명시적으로 저장해두면, 이후에는 네트워크 요청 없이 바로 재생할 수 있다


Media DevTools로 디버깅

Chrome DevTools의 Media 패널로 영상의 로딩 상태, 버퍼링, 코덱 정보, 에러 등을 확인할 수 있다

Media 패널이 뭔지 궁금해서 직접 열어봄

내가 요즘 자주 듣는 델타룬 브금 영상으로 열어봄 (너무 잼민이 같은 영상인가...)

 

좌측 Players 목록 - 지금 유튜브에서 활성화된 미디어 플레이어

no_input.mp3 / open.mp3 / success.mp3 / failure.mp3 - 유튜브 기본 효과음

FrameTitle 태그 - 실제 영상 플레이어

 

우측 탭

Properties - 기본 정보

코덱이 기기에서 지원되는가, 해상도가 맞는가, 영상 길이가 예상과 일치하는가

 

Events - 미디어 상태 전환을 시간순으로 기록

정상 흐름: kLoad → kStarting → kPlaying → kSuspended

비정상: kLoad → kStarting → kBufferingState: BUFFERING_HAVE_NOTHING → (멈춤)

영상 url은 로드했으나, 데이터가 들어오지 않은 경우

 

Messages - 브라우저 미디어 엔진의 내부 로그

브라우저가 미디어 파일을 받은 후 내부적으로 어떻게 처리했는지 보여줌

에러가 발생했을 때, 미디어 파이프라인 내부의 구체적인 실패 지점을 알 수 있다

 

Timeline - 버퍼링 구간을 시각적으로 볼 수 있음. (Buffering Status)


세션에서 인상깊었던 점

1. 최근 초기 구축된 DS를 사용하며 피드백을 자주 하고 있다.

- 수정을 지원하지 않는 props를 건드려야할 때마다 수정 요청을 하는 게 곤란했는데, customSlots 아이디어를 얻게 되어 좋았다.

 

2. framer-motion을 써서 애니메이션을 자주 만들고 있는데, repaint와 발열의 연관성에 대해 고민해보게 됐다.

- 적재 적소에 따라 순수 css 또는 framer-motion을 쓰는 유연성이 필요하구나 싶었다.

 

3. 스크롤 기능을 지원하려고 컴퓨터 그래픽스 카메라 개념까지 도입한 것에 감탄했다.

- 진짜 '엔지니어'란 저렇게 벽을 깨는 사고를 해야 하는구나, 그런 생각을 했다.

 

4. 내부 챗봇을 잘 구축하고, 활용하는 것으로 보인다.

- 최근 난 ai rule을 구축해서 코드 베이스 탐색을 용이하게 만들었다.

- 거기서 한 발 더 나아가 코드베이스 챗봇을 만들수도 있겠구나 하는 영감을 얻었다.

 

작성: 2026.02.28. 15:12

728x90