Skip to main content

Questions

한 세션이 진행 중일 때(pomo or break 관계없이), 다른 페이지들을 자유롭게 방문할 수 있도록 하는 것

Global state 대신에 Indexed DB를 사용한 이유

이 애플리케이션은 로그인하지 않은 익명 사용자도 뽀모도로 타이머, 타임라인 등의 기능을 사용할 수 있도록 설계되었습니다.

이 요구사항을 만족시키기 위해서는, 사용자가 브라우저 탭을 닫거나 컴퓨터를 재부팅한 후에도 데이터가 유지되는 **영속성(Persistence)**이 반드시 필요했습니다.

ZustandRedux 같은 Global State(전역 상태) 관리 도구와 IndexedDB는 이 '영속성'의 범위생명 주기에 있어 근본적인 차이가 있습니다.


1. Global State의 한계: 인메모리(In-Memory)와 휘발성

ZustandRedux 같은 전역 상태 관리 라이브러리는 인메모리(In-Memory) 저장소입니다.

  • 생명 주기와 렉시컬 환경 (LE):

    • 전역 상태 스토어(Store)는 애플리케이션이 로드될 때 생성되는 자바스크립트 객체입니다.
    • 이 객체(State)는 특정 React 컴포넌트에 종속되지는 않지만, 애플리케이션의 최상위 렉시컬 환경(Lexical Environment) 또는 클로저(Closure) 내에 존재합니다.
    • 렉시컬 환경의 생명 주기는 브라우저 탭(Renderer Process)의 생명 주기와 같습니다.
  • 데이터 소실:

    • 사용자가 브라우저 탭을 닫거나 새로고침하면, 해당 탭의 Renderer Process가 종료됩니다.
    • 이때 해당 프로세스에 할당된 모든 메모리(Heap)와 자바스크립트 실행 컨텍스트(Execution Contexts), 그리고 그에 묶인 모든 렉시컬 환경이 소멸됩니다.
    • 결과적으로, 메모리상에 존재했던 모든 Global State는 영구적으로 소실됩니다.
  • 결론: Global State는 "앱이 활성화된 동안"의 상태 관리에 유용하지만, 세션(Session)을 초월하는 영속성을 제공하지 못하므로 익명 사용자의 데이터를 보존할 수 없습니다.


2. IndexedDB의 이점: 디스크 기반(Disk-Based)과 영속성

IndexedDB는 자바스크립트 객체가 아닌, 디스크(Disk)에 파일로 저장되는 브라우저 내장 데이터베이스입니다.

  • 생명 주기와 아키텍처:

    • IndexedDB 데이터는 Renderer Process(탭)의 메모리가 아닌, 사용자의 하드 드라이브에 저장됩니다.
    • 이 데이터는 특정 탭의 생명 주기에 묶여있지 않으며, 모든 프로세스(탭, 서비스 워커)를 관리하는 브라우저 프로세스(Browser Process) 에 의해 중앙 관리됩니다.
    • Renderer Process(메인 스레드)는 오직 비동기 IPC(프로세스 간 통신) 를 통해서만 브라우저 프로세스에게 데이터 저장을 요청할 뿐입니다.
  • 데이터 보존:

    • 탭이 닫혀서 Renderer Process가 종료되더라도, 디스크에 저장된 IndexedDB 파일은 전혀 영향을 받지 않습니다.
    • 사용자가 다음 날 브라우저를 새로 켜고 앱에 재방문하면, 애플리케이션은 다시 IndexedDB에 접근하여 어제의 데이터를 그대로 불러올 수 있습니다.
  • 결론: IndexedDB세션을 초월하는 영속성을 제공하는 유일한 솔루션입니다.

    • 이를 통해 회원가입하지 않은 사용자도 Timeline을 포함한 주요 기능의 데이터를 유지하며 사용할 수 있습니다.

Session을 종료하는 함수들을 왜 index.ts파일 대신 service worker에 작성했는지?

  • sw.js가 결국에는 setInterval을 돌리는데 적합하지 않다는 것을 알고 난 후, index.tsx파일에서 countdown함수를 정의해서 사용했습니다.
  • Countdown이 종료되어 세션이 끝나면 필요한 데이터들을 service worker의 postMessage API를 이용해 service worker로 보낸 후, 그곳에서(sw.js) fetch API를 이용해서 HTTP request를 보냈습니다.
  • Main thread의 부담을 줄인다는 막연한 생각과 sw.js를 사용한 김에 계속 사용할 수 있으면 해보자는 생각에 버리지 못한 것이 계속 사용하게된 이유입니다.
  • 어떠한 특별한 기술적 이유 및 그것을 뒷받침한 근거는 매우 부족합니다. 그냥 전제를 대충 하고 그 전제가 옳지 않을까? 하는 막연한 믿음이 있었고, 기능상에 딱히 문제가 없었기 때문에 그냥 그대로 두었습니다.
  • 그런데 기능을 계속 추가하다보니 sw.js와 index.tsx파일 사이에서 주고받아야할 데이터들이 많아지고 주고 받는 과정의 복잡함도 그 정도나 너무 심해졌습니다. 이게 정말 필요한것인가 라는 의문도 들었고 막연하게 main thread의 부담을 줄인다는 전제가 과연 옳은것인지 잘 몰랐는데, event loop을 제대로 공부하면서, 다음과 같은 문제를 인식하게 되었습니다.

1. 잘못된 가정: "모든 작업은 메인 스레드를 막는다"

저의 막연한 전제는 "fetch(네트워크 I/O)나 IndexedDB(디스크 I/O) 작업은 '무거운 일'이므로, 메인 스레드에서 실행하면 UI 렌더링(Painting)을 막을(blocking) 것이다" 라는 추측이었습니다.

그래서 이 "무거운 일"을 서비스 워커라는 별도의 스레드로 분리하면 성능에 이득이 있을 것이라 가정했습니다.

2. 실제 원리: 비동기 Web API와 이벤트 루프

하지만 이벤트 루프와 브라우저 아키텍처를 학습하면서, 이 가정이 근본적으로 틀렸음을 깨달았습니다.

핵심은 fetchIndexedDB가 '비동기(Asynchronous) Web API' 라는 점입니다.

  1. 메인 스레드는 '요청'만 합니다: 메인 스레드(이벤트 루프)는 이 작업들을 '실행'하는 것이 아니라 '요청(Request)' 만 합니다.

  2. 실제 작업은 다른 프로세스가 처리합니다:

    • fetch API 호출 시, Renderer Process는 IPC를 통해 Browser Process에 네트워크 요청을 전달하며, 이는 다시 전용 Network Process로 위임되어 실제 네트워크 트래픽을 처리합니다.
    • IndexedDB API 호출 시, 보안 및 권한 검증을 위해 Browser Process를 거쳐 Storage Process로 요청이 전달되며, 이곳에서 실제 디스크 I/O 작업이 독립적으로 수행됩니다.
  3. 메인 스레드는 막히지 않습니다 (Non-Blocking): 메인 스레드는 이 요청을 보낸 직후, 작업이 완료되기를 기다리지 않고 즉시 다음 일(UI 렌더링, 사용자 입력 처리) 을 수행합니다.

  4. 결과는 큐로 돌아옵니다: 나중에 다른 프로세스(네트워크, 브라우저)가 작업을 완료하면, 그 결과(콜백)가 태스크 큐(Task Queue) 를 통해 메인 스레드의 이벤트 루프로 돌아와 처리됩니다.

3. 결론: 불필요한 복잡성 (성급한 최적화)

결론적으로, 제가 "무겁다"고 생각했던 작업들은 애초에 메인 스레드를 막지 않는(non-blocking) 비동기 작업 이었습니다.

sw.js로 작업을 분리한 것은 성능상 이득이 전혀 없는 '성급한 최적화(Premature Optimization)' 였습니다. 오히려 로직이 index.tsxsw.js 두 파일로 분산되고, 불필요한 스레드 간 통신(postMessage, BroadcastChannel) 오버헤드만 발생시켜 코드의 복잡성과 유지보수성만 악화시킨 잘못된 설계였습니다.

Service worker process가 종료된다고 해서, 어떻게 위임된 작업인 setInterval까지 멈추게 되는지? (원래 sw를 도입한 이유가 깨진 지점)

  • Web API(타이머)는 다른 프로세스에서 작업되고 있기 때문에 멈추지 않습니다.
  • 다만, Task Queue(작업 큐)는 Service Worker 프로세스에 묶여있기(Process-Specific) 때문에,
  • Web API가 콜백(callback)을 전달할 대상(Task Queue)이 사라지는 것입니다.
  • 부가 설명 링크 -> Service Worker의 수명 주기와 Web API (setInterval)

인터넷 연결이 끊길 때에도 핵심 기능 사용할 수 있게 하기

왜 꼭 Indexed DB를 이용해야 했는지? Cache는 왜 사용하지 않은 것인지?

데이터 모양에 더 적합한 유연한 구조이다 Cache보다

실패한 HTTP request를 저장하는 Indexed DB의 모양이 아래처럼 정의되어 있는데, 한 사용자가 같은 컴퓨터의 같은 브라우저를 사용할 수도 있다는 가정하에, 사용자별로 실패한 request를 저장하는 구조입니다. 그리고 value에 해당하는 객체는 HTTP method별로 실패한 request를 모아두는 구조입니다. 이러한 객체구조를 만들고 상황에 따라 그중 일부를 update하는 로직이 필요한 상황에서는 JS Object를 value로 저장할 수 있는 Indexed DB가 단지 key value pair로 Request와 Response Object를 강제하는 cache보다 적합합니다.

Code
import { IDBPDatabase, DBSchema, openDB } from "idb";

// ...

interface TimerRelatedDB extends DBSchema {
// ...

failedReqInfo: {
value: {
userEmail: string;
value: ERR_CONTROLLER["failedReqInfo"]; //https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html
};
key: string;
};

// ...
}
export interface ERR_CONTROLLER {
owner: string;
failedReqInfo: {
GET: UrlAndData[];
POST: UrlAndData[];
PATCH: Map<string, UrlAndData>; // TODO: string은 뭐야?
DELETE: UrlAndData[];
};

registerFailedReqInfo: (reqConfig: AxiosRequestConfig) => void;

mergeData: (existingData: any, newReqConfig: AxiosRequestConfig) => any;

resendFailedReqs: () => Promise<{
postResults: PromiseSettledResult<AxiosResponse<any, any>>[];
patchResults: PromiseSettledResult<AxiosResponse<any, any>>[];
}>;

getAndResendFailedReqsFromIDB: () => Promise<boolean>;

storeFailedReqsToIDB: () => Promise<void>;

emptyFailedReqInfo: () => void;

handlePatchRequests: (url: string, data: any) => void;

updatePatchEntry: (url: string, newData: any) => void;
}

Type definitions

type UrlAndData = {
url: string;
data?: any;
};

export interface UpdateCategoryDTO {
name: string;
data: CategoryDTO;
}
export interface UpdateCategoryDTOWithUUID extends UpdateCategoryDTO {
_uuid: string;
}
export interface CategoryDTO {
name?: string;
color?: string;
isCurrent?: boolean;
isOnStat?: boolean;
}

service worker에서 fetch API를 보내는 경우에도 사용 가능한 저장소 (localStroage나 sessionStorage와 다르게)

  • localStorage는 문자열만 저장 가능하기 때문에, JSON.stringify()JSON.parse()를 이용하면 위의 JS 객체들을 저장 및 꺼내와서 update를 할 수 있습니다.

    • Patch의 Map은 Object.fromEntries(myMap)를 사용해서 일반 객체로 변환하고,
    • new Map(Object.entries(myObj)를 사용해서 다시 Map으로 만드는 과정을 반복해야합니다.
  • 그러나 service worker에서 http request를 보내는 경우(/timer이외의 페이지에서 세션이 종료되는 경우)에는 localStorage에 직접 접근이 불가능하므로 사용할 수 없습니다.

    • 물론 Renderer process에서 message event를 이용해서 localStorage에서 값들을 가져온 후,
    • message event를 이용해서 service worker에 전달해 줄 수도 있겠지만, 비교적 복잡해 보입니다.
  • 그리고 조금이라도 복잡한 category patch payload update같은 작업을 하려면 synchronous API인 localStorage 를 사용하는 것보다 asynchronous API인 indexed db 를 사용하는 것이 혹시 모를 UI의 버벅거리는 현상(main thread 블로킹)을 막을 수 있습니다.

Service worker에서 localStorage에 접근이 불가능한 이유

Service worker는 tab process, 즉 Renderer process와는 별개의 process에서 작동합니다. 그래서 Renderer process의 global execution context의 thisBinding인 window객체에 접근할 수 없고, 그러므로 window객체를 통해 노출되는 localStorage에 접근할 수 없습니다.

Indexed DB는 어떻게 Service worker와 Main thread에서 모두 접근 가능한지

인터넷 다운 감지 로직은 정말 '인터넷 다운' 상태만을 정확히 감지하는가?

Axios interceptor 코드

Code
axiosInstance.interceptors.response.use(
function (response) {
return response;
},
function (error: AxiosError) {
console.log("error message from the interceptor", error);
if (error.code === "ERR_NETWORK" && !navigator.onLine) {
// console.log("axios req config is here");
// console.log(error.config);
// console.log("navigator.online", navigator.onLine);

if (
error.config.method?.toUpperCase() === "PATCH" &&
error.config.url === RESOURCE.CATEGORIES
) {
return Promise.reject(error.config);
}

if (
error.config.method?.toUpperCase() !== "GET" &&
error.config.method?.toUpperCase() !== "DELETE"
)
// PATCH requests to any URL other than "/categories", All POST requests.
errController.registerFailedReqInfo(error.config);
}
return Promise.reject(error);
}
);

오프라인 요청 처리의 기술적 한계

이 애플리케이션의 핵심 기능 중 하나는 인터넷 연결이 불안정한 환경(예: 도서관)에서도 사용자의 작업(예: 뽀모도로 세션 완료)이 유실되지 않도록 보장하는 것입니다.

이를 위해서는 두 가지 문제를 해결해야 합니다.

  1. **"연결 끊김"**을 정확히 감지하여 요청을 IndexedDB에 저장하는 것.
  2. **"재연결"**을 정확히 감지하여 IndexedDB의 요청을 서버로 재전송하는 것.

하지만 이 과정에서 브라우저가 제공하는 API의 명확한 기술적 한계가 존재했습니다.


1. "연결 끊김" 감지의 한계

"인터넷이 안 되는" 상황을 100% 정확하게 감지하는 API는 존재하지 않습니다.

  • 동작 방식: 이 API는 "내 기기 ↔ 공유기(LAN)" 간의 연결 상태만 확인합니다. (예: Wi-Fi 연결이 끊어짐)
  • 치명적 문제: 가장 흔한 문제인, "공유기는 연결됐지만 공유기의 인터넷(WWW)이 먹통인" 상태를 감지하지 못합니다.
  • 이 시나리오에서 navigator.onLine은 여전히 true를 반환하므로, "오프라인" 상태를 놓치게 됩니다.
error.code === 'ERR_NETWORK' (Axios)의 한계
  • 동작 방식: 실제 서버와 통신을 시도하다가 실패할 때 발생합니다.
  • 장점: "공유기 인터넷 먹통" 상황(DNS 조회 실패 등)을 정확히 잡아냅니다.
  • 한계점: 너무 포괄적입니다. 실제 오프라인뿐만 아니라, CORS Preflight 실패나 일시적인 DNS 오류 등 다른 모든 네트워크 레벨의 오류를 포함합니다.

2. "재연결" 감지의 한계

"인터넷이 100% 다시 연결되는" 순간을 알려주는 이벤트는 존재하지 않습니다.

  • online 이벤트의 한계: offline 이벤트와 마찬가지로, "내 기기 ↔ 공유기(LAN)" 연결이 복구될 때만 발생합니다.
  • 치명적 문제: 만약 Wi-Fi 연결은 한 번도 끊기지 않았고(즉, offline 이벤트가 발생 안 함) **"공유기의 인터넷(WWW)"**만 먹통이었다가 복구된 경우, online 이벤트는 절대 발생하지 않습니다.
  • 즉, online 이벤트는 재전송을 시도할 "신호탄"은 될 수 있지만, "인터넷이 된다"는 보장으로 사용할 수 없습니다.

3. 최종 아키텍처 전략 (실제로 수정 및 구현해야할 부분들)

이러한 API의 한계로 인해, "완벽한 감지"를 포기하는 대신, **"방어적인 설계"**를 시도하는 것이 최종 전략입니다.

1. 공격적인 저장 (Aggressive Caching)
  • navigator.onLine을 신뢰하지 않습니다.
    • 현재 코드는 navigator.onLine을 신뢰하여, local machine과 LAN과의 연결 끊김만 잡아내고 있음.
  • axios 인터셉터에서 error.code === 'ERR_NETWORK'가 발생한 모든 (POST, PATCH) 요청은, 그 원인이 무엇이든(CORS든, 진짜 오프라인이든) 일단 전부 IndexedDB에 저장합니다.
2. 낙관적인 재시도 (Optimistic Retries)
  • "인터넷이 될 것"이라고 가정할 수 있는 모든 시점에 재전송을 시도합니다.
  • 트리거 1: window.addEventListener('online') (불확실하지만 가장 빠른 신호)
  • 트리거 2: 앱 로드 시(DOMContentLoaded) 또는 로그인 시 (가장 확실한 신호)
3. 방어적인 재전송 (Defensive Resending - The Safety Net)
  • resendFailedReqs 함수는 **"재시도가 또 실패할 경우"**를 대비하는 안전망 역할을 합니다.
  • Promise.allSettled로 모든 요청을 재시도한 뒤, 큐를 무조건 비우는 것이 아닙니다.
  • Promise.allSettled의 결과 배열을 확인하여, 오직 '성공'(fulfilled)한 요청만 선별적으로 IndexedDB에서 삭제합니다.
  • '실패'(rejected)한 요청은 그대로 IndexedDB에 남겨두어, 다음 재시도 트리거(예: 다음 앱 로드)를 기다립니다.

이 설계를 통해 online 이벤트가 잘못된 신호(False Positive)였더라도, 사용자의 데이터는 유실되지 않고 다음 기회에 다시 전송될 수 있습니다.


4. resendFailedReqs 로직의 버그 및 개선점 (Safety Net)

현재 resendFailedReqs (재전송) 함수에는, 불확실한 신호(예: 인터넷 없는 Wi-Fi의 online 이벤트)로 인해 재시도가 또다시 실패했을 때, 데이터가 영구적으로 유실되는 치명적인 버그가 존재합니다.

💣 치명적인 버그 (데이터 유실)

현재 로직은 Promise.allSettled로 재시도를 await한 후, 그 결과(patchResults)의 status가 'fulfilled'인지 'rejected'인지 확인하지 않습니다.

  • 버그 발생: 재시도한 요청이 모두 다시 ERR_NETWORK로 실패(rejected)하더라도, await 다음 줄의 this.failedReqInfo.PATCH.clear();emptyFailedReqInfo(userEmail);무조건 실행됩니다.
  • 결과: Axios 인터셉터가 이 실패를 감지하고 데이터를 메모리에 '재등록'하려고 시도하지만, 그 직후에 이 삭제 로직이 실행되면서 메모리와 IndexedDB의 모든 데이터가 영구적으로 삭제됩니다.
✅ 개선 방안 (Safety Net 구축)

이 데이터 유실 버그를 해결하고 "방어적인 재전송(Safety Net)"을 완성하기 위해서는, **"전체 삭제"**가 아닌 **"선별적 삭제"**로 로직을 변경해야 합니다.

  1. resendFailedReqs 함수에서 PATCH.clear(), POST = [], emptyFailedReqInfo() 코드를 모두 제거해야 합니다.
  2. await Promise.allSettled(...)가 끝난 후, 반환된 patchResultspostResults 배열을 **루프(loop)**로 돌아야 합니다.
  3. 루프 내에서 result.status === 'fulfilled'인, 즉 '성공'한 요청만 골라내야 합니다.
  4. 오직 '성공'한 요청들만 failedReqInfo 객체(메모리)와 IndexedDB에서 개별적으로 삭제하는 로직을 추가해야 합니다.
  5. result.status === 'rejected'인, 즉 '실패'한 요청은 큐와 DB에 그대로 남겨두어 다음 재시도 트리거(예: 다음 앱 로드)를 기다리게 해야 합니다.

근거 자료 (MDN)

Horizontal Timeline 만들기

CSS 성능 관점에서 개선할 점들은?

left vs. transform: 최적화 원리 및 성능 측정

CSS Structure

main-comp-layout timeline-structure

Timeline component (Original `left` logic)
// Timeline.tsx

export default function Timeline({ arrOfSessions }: TimelineProps) {
const divRef = useRef<HTMLDivElement>(null);
// ... (기타 ref 및 변수) ...

function moveTimelineByDragging(clientX: number) {
// ... (델타값 및 newLeftVal 계산 로직) ...
let newLeftVal = leftWhenMouseDown.current! + deltaX;

// ... (복잡한 경계 처리 로직 생략) ...
// if (isTimelineBeingDraggedBeyondLeftEdge()) { ... }
// else if (...) { ... }

// 💥 [문제 지점 #1: Layout(Reflow) 즉각 촉발]
// 계산된 left 값을 DOM style에 직접 할당합니다.
// 이 코드는 onMouseMove가 호출될 때마다 실행되며,
// 브라우저의 Layout(Reflow)을 '강제로', '즉시' 촉발시킵니다.
divRef.current!.style.left = newLeftVal + "px";
}

function handleMouseMove(ev: React.MouseEvent<HTMLDivElement>) {
if (isButtonPressed.current) {
// 💥 [문제 지점 #2: 잦은 호출]
// 1초에 수백 번 발생할 수 있는 onMouseMove 이벤트가
// Layout을 촉발하는 함수(moveTimelineByDragging)를 직접 호출합니다.
moveTimelineByDragging(ev.clientX);
}
}

return (
<div
ref={divRef}
style={{
position: "absolute",
// 💥 [문제 지점 #3: CSS 속성]
// 컴포넌트의 위치 기준이 Layout을 유발하는 'left'로 설정되어 있습니다.
left: initialLeftAndRight.left,
// ...
}}
// 💥 [문제 지점 #4: 이벤트 연결]
// 이벤트 핸들러가 DOM 조작 함수에 직접 연결되어 있습니다.
onMouseMove={handleMouseMove}
onWheel={handleWheel} // onWheel도 동일한 문제를 가집니다.
// ...
>
<Scale />
<Session ... />
<DetailArea ... />
</div>
);
}
Session component (`left` logic)
// Session.styled.tsx

// 💥 [문제 지점 #5: 연쇄 Reflow]
// 'Session' 컴포넌트의 위치('left')는
// 부모인 'Timeline'의 위치를 기준으로 계산됩니다.
// 따라서 Timeline이 Reflow되면, 수많은 Session 자식 요소들도
// '연쇄적으로' Reflow 대상이 됩니다.
export const SessionStyled = styled.div<SessionStyledProps>`
display: inline-block;
position: absolute;
// ...

@media (width <= ${BREAK_POINTS.MOBILE}) {
left: ${({ seconds }) => seconds * PIXEL.PER_SEC.IN_MOBILE + "px"};
}

@media (${BREAK_POINTS.MOBILE} < width <= ${BREAK_POINTS.FHD}) {
left: ${({ seconds }) => seconds * PIXEL.PER_SEC.IN_FHD + "px"};
}

// ... (기타 미디어 쿼리) ...
`;
1. left 사용 시 (현재 방식)
  • 동작 원리: left는 **Layout(Reflow)**을 유발하는 속성입니다.
  • 문제점:
    1. Timelineleft 값이 1px이라도 변경되면, Timeline 자신의 레이아웃 계산이 다시 발생합니다.
    2. TimelineSession, OneHour, Scale 등 여러 자손 요소들의 Containing Block 역할을 합니다.
    3. 따라서 Timeline의 위치가 다시 계산되면, Timeline에 의존하는 모든 자손 요소들(Session ➡️ Duration, OneHour ➡️ TenMinutes 등)의 위치까지 연쇄적으로 Reflow가 발생합니다.
    4. 이 모든 계산은 CPU가 처리하며, 드래그/휠 이벤트가 발생할 때마다 매번 실행되어 부담을 줍니다.
2. transform 사용 시 (개선 방식)
  • 동작 원리: transformComposite 단계를 유발하는 속성입니다.
  • 최적화 수준:
    1. Timelinetransform 속성을 적용하면, 브라우저는 Timeline독립적인 그래픽 레이어(Compositor Layer), 즉 '투명 필름'으로 분리합니다.
    2. 이때 가장 중요한 점은, Timeline의 모든 자손 요소들(Session, Duration 등)은 바로 그 '필름' 위에 함께 그려집니다.
    3. transform: translateX() 값이 변경되면, 브라우저는 비싼 Layout(Reflow)과 Paint 단계를 완전히 건너뜁니다.
    4. 대신 GPUComposite 단계에서 그 '필름' 전체를 통째로 옆으로 옮기기만 합니다.
  • 결과: left를 썼을 때 발생했던 '연쇄 Reflow'가 완전히 사라집니다. Timeline 자신과 그 수많은 자손 요소들 모두 Reflow 과정에서 벗어나, 매우 가벼운 GPU 작업만으로 이동하게 됩니다.

렌더링 타이밍(Rendering Timing) 최적화하기

requestAnimationFrame (rAF)의 부재
1. 문제점: 이벤트 핸들러에서의 동기적(Synchronous) DOM 조작

현재 Timeline.tsxmoveTimelineByDragging 함수는 onMouseMove 이벤트 핸들러가 호출될 때마다 즉시, 그리고 동기적으로 divRef.current.style.left 값을 변경시킵니다.

// Timeline.tsx
function moveTimelineByDragging(clientX: number) {
// ... (중략) ...
let newLeftVal = leftWhenMouseDown.current! + deltaX;

// ... (경계 처리) ...

// [문제 지점]
// 이 코드는 onMouseMove가 호출될 때마다 즉시 실행되어,
// 브라우저의 Layout(Reflow)을 '강제로', '즉시' 촉발시킵니다.
divRef.current!.style.left = newLeftVal + "px";
}

function handleMouseMove(ev: React.MouseEvent<HTMLDivElement>) {
if (isButtonPressed.current) {
// 1초에 수백 번 호출될 수 있음
moveTimelineByDragging(ev.clientX);
}
}
2. 왜 이것이 치명적인가? (Jank와 프레임)

이 코드는 **'버벅임(Jank)'**을 유발하는 전형적인 원인입니다.

  • 이벤트 vs. 프레임: 사용자의 입력 장치(마우스, 트랙패드)는 1초에 100번, 200번, 혹은 그 이상 mousemove 이벤트를 발생시킬 수 있습니다. 하지만 대부분의 모니터는 1초에 60번(60Hz) 화면을 새로고침합니다. (즉, 1 프레임당 16.7ms의 시간이 주어집니다.)

  • '즉각 촉발'의 함정: style.left = ... 같은 코드는 rAF 큐를 통하지 않고 **"지금 당장 Layout(Reflow)을 실행하라!"**는 강제 명령입니다. 만약 16.7ms라는 1 프레임 시간 안에 mousemove가 5번 발생하면, 브라우저는 이 "강제 명령"을 5번 받아 Layout을 5번 강제로 실행하게 됩니다.

  • 낭비되는 연산: 브라우저가 16.7ms 안에 이 5번의 강제 Layout을 처리하다가 마감 시간을 넘기면, 해당 프레임을 건너뛰게 됩니다 (Frame Drop). 이것이 사용자가 느끼는 '버벅임'의 정체입니다. 어차피 사용자의 눈은 16.7ms 동안 발생한 5번의 변경 중 가장 마지막 1개의 결과만 보게 됩니다. 즉, 앞의 4번의 Layout 계산은 완전히 낭비된 연산입니다.

3. 개선 방식: requestAnimationFrame (rAF)

rAF는 이 "즉각 촉발"을 막고, **"다음 프레임을 그리기 직전에만 실행해 줘"**라고 브라우저에 '예약'하는 장치입니다. rAF는 렌더링에 최적화된 특별한 비동기(Async) 작업입니다.

  • '게이트키퍼(Gatekeeper)' 구현: 16.7ms 안에 mousemove가 5번 발생해도, rAF를 5번 예약할 필요는 없습니다. 1프레임당 1번만 예약하면 됩니다. 이를 위해 isTicking 같은 boolean 플래그(게이트키퍼)를 사용합니다.

  • 작동 원리:

    1. handleMouseMove에서는 이제 style을 직접 변경하지 않습니다. 오직 '마지막 마우스 위치'만 ref에 저장합니다.
    2. isTicking(게이트키퍼)이 false(문이 열림)일 때만 rAF를 예약하고, isTicking = true로 문을 잠급니다.
    3. 16.7ms 안에 추가로 mousemove가 발생해도, isTickingtrue이므로 rAF를 중복 예약하지 않습니다.
    4. (16.7ms 뒤) 다음 프레임을 그리기 직전, 브라우저가 예약된 rAF 콜백(예: updateStyle)을 단 한 번 실행합니다.
    5. updateStyleref에 저장된 '최종' 마우스 위치를 읽어 style단 한 번 변경합니다.
    6. isTicking = false로 문을 다시 열어 다음 프레임을 준비합니다.
4. 적용 코드 예시 (in Timeline.tsx)
export default function Timeline({ arrOfSessions }: TimelineProps) {
const divRef = useRef<HTMLDivElement>(null);

// ... 기존 ref들 ...
const leftWhenMouseDown = useRef<number | null>(null);

// [rAF 개선] 추가 ref
const latestMouseX = useRef<number>(0);
const isTicking = useRef<boolean>(false);

// [rAF 개선] DOM을 실제 변경하는 함수
const updateStyle = () => {
// 1. ref에 저장된 '최종' 위치로 moveTimelineByDragging을 *단 한번* 호출
moveTimelineByDragging(latestMouseX.current);

// 2. 게이트키퍼(잠금) 해제
// 다음 프레임에서 rAF를 예약할 수 있도록 문을 다시 엽니다.
isTicking.current = false;
};

function moveTimelineByDragging(clientX: number) {
// 이 함수는 이제 '계산'만 담당하고,
// DOM 변경을 직접 수행합니다. (updateStyle에 의해 1프레임 1번만 호출됨)
let deltaX =
clientX -
(isTouching.current ? touchStartX.current : clientXByMouseDown.current);

// ... (중략: 기존 경계 처리 로직) ...
let newLeftVal = leftWhenMouseDown.current! + deltaX;

if (isTimelineBeingDraggedBeyondLeftEdge()) {
divRef.current!.style.left = "0px";
} else if (/* ... */) {
divRef.current!.style.right = "0px";
divRef.current!.style.left = "";
} else {
// 💥핵심💥: 이 DOM 변경 코드가
// onMouseMove에서 1초에 100번 호출되는 대신,
// rAF 콜백에서 1초에 60번(즉, 1프레임 1번)만 호출됩니다.
divRef.current!.style.left = newLeftVal + "px";
}

// ... (헬퍼 함수들) ...
}

// [rAF 개선] 변경된 마우스 이동 핸들러
function handleMouseMove(ev: React.MouseEvent<HTMLDivElement>) {
// 1. 실제 DOM 변경 대신, '최종' 위치만 ref에 저장합니다.
latestMouseX.current = ev.clientX;

// 2. 게이트키퍼(isTicking)가 열려있는지(false) 확인합니다.
if (isButtonPressed.current && !isTicking.current) {
// 3. 문을 잠그고, 다음 프레임에 updateStyle을 실행하도록 '예약'합니다.
isTicking.current = true;
requestAnimationFrame(updateStyle);
}
// (만약 isTicking이 true라면?
// 아무것도 안 합니다. 이미 rAF가 예약되어 있으므로,
// 최신 X좌표만 덮어쓰고 중복 예약을 방지합니다.)
}

// ... (중략: onWheel, onTouchMove 등도 동일하게 rAF로 개선할 수 있음) ...

return (
<div
ref={divRef}
style={{
// ...
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} // 이 핸들러는 이제 매우 가벼워졌습니다.
// ...
>
{/* ... */}
</div>
);
}
결과 요약:
  • Before (rAF 없음): mousemove 5번 → style.left 5번 변경 → Layout 5번 강제 실행 (낭비 + 버벅임)
  • After (rAF 사용): mousemove 5번 → ref 값 5번 덮어쓰기 (매우 쌈) → rAF 1번 예약 → Layout 1번 실행 (최적화 + 부드러움)