<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>yoy0z-maps.log</title>
        <link>https://velog.io/</link>
        <description>Hello World!</description>
        <lastBuildDate>Wed, 25 Feb 2026 01:01:52 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>yoy0z-maps.log</title>
            <url>https://velog.velcdn.com/images/yoy0z-maps/profile/48c8c6db-3550-4a40-b0dd-299065f3f3fb/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. yoy0z-maps.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/yoy0z-maps" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Expo 앱 개발에서 성능 최적화를 위해서는 결국 Native를 할 줄 알아야 한다.]]></title>
            <link>https://velog.io/@yoy0z-maps/Expo-%EC%95%B1-%EA%B0%9C%EB%B0%9C%EC%97%90%EC%84%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%B4%EC%84%9C%EB%8A%94-%EA%B2%B0%EA%B5%AD-Native%EB%A5%BC-%ED%95%A0-%EC%A4%84-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%9C%EB%8B%A4</link>
            <guid>https://velog.io/@yoy0z-maps/Expo-%EC%95%B1-%EA%B0%9C%EB%B0%9C%EC%97%90%EC%84%9C-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%B4%EC%84%9C%EB%8A%94-%EA%B2%B0%EA%B5%AD-Native%EB%A5%BC-%ED%95%A0-%EC%A4%84-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%9C%EB%8B%A4</guid>
            <pubDate>Wed, 25 Feb 2026 01:01:52 GMT</pubDate>
            <description><![CDATA[<p>처음 메트로놈을 구현했을 때의 기획과 구현은 다음과 같았다. Expo의 기성 라이브러리인 Expo-Audio로 사운드를 재생하고, JavaScript의 <code>setInterval</code>로 박자를 맞추는 방식을 차용하였다. 느린 템포에서는 문제 없이 잘 작동되었지만, 테스트 과정에서 빠른 템포나 박자를 쪼갤 경우 메트로놈 화면에서 그려지는 박자와 오디오로 재생되는 박자가 서로 밀려서 틀어지는 경우가 생겼다. Native를 통해 구현하면 괜찮지 않을까라는 생각을 가지고 Swift를 건들여 보기로 했다. (그나마 swift라면 애플 디벨로퍼 아카데미에서 다뤄봤기 때문에, Java와 Kotlin은 할 줄 모른다...)</p>
<h1 id="기존-구현-방식-js-루프--expo-audio">기존 구현 방식: JS 루프 + expo-audio</h1>
<p>당시 코드는 남아있지는 않지만, 구조는 대략 아래와 비슷한 형식으로 문제는 분명했다.</p>
<ul>
<li><code>setInterval</code>자체가 JS 이벤트 루프에 묶여 있다. =&gt; JS 스레드가 렌더, 상태 업데이트, GC 등의 작업 처리 중에 있으면 interval 콜백이 제시간에 실행되지 못한다. (추가적으로 JS의 타이머는 실시간 보장이 없고, 최소 대기 시간이지 정확한 실행 시간이 아니다)</li>
<li>오디오 재생 호출도 브리지/비동기를 경유한다. =&gt; JS에서 맞추어서 play()를 호출해도 실제 네이티브 오디오 엔진이 재생하기까지 지연이 발생할 수 있다.</li>
<li>UI 상태 업데이트와 오디오 타이밍이 같은 루프에서 경쟁한다 =&gt; 같은 틱에서 애니메이션 계산, 오디오 트리거 등 같이 처리하면 무거운 작업이 타이밍 작업을 밀어낼 수 있다.<pre><code class="language-ts">let beat = 0;
let intervalId: ReturnType&lt;typeof setInterval&gt; | null = null;
</code></pre>
</li>
</ul>
<p>function startMetronome(bpm: number, beats: number) {
  const intervalMs = 60000 / bpm;</p>
<p>  intervalId = setInterval(async () =&gt; {
    const isAccent = beat === 0;</p>
<pre><code>if (isAccent) {
  await accentSound.playAsync();
} else {
  await clickSound.playAsync();
}

setCurrentBeat(beat);

beat = (beat + 1) % beats;</code></pre><p>  }, intervalMs);
}</p>
<pre><code>위와 같은 구조는 일반적인 앱의 동작의 우선 순위 처리 형태였고, 메트로놈은 매 틱의 정확함이 중요하기 때문에 다른 방법이 필요하다고 느꼈다.

# 현재 구현: 네이티브 모듈이 박자를 소유
현재 코드에서는 JS는 타이밍을 만들지 않고 네이티브 엔진을 제어만 한다. 네이티브가 박자를 만들고, JS는 이벤트를 구독만해서 UI만 반영한다. 즉 재생 시작 시점에 subdivision/tempo/beats를 네이티브로 전달하고, 이후 박자 이벤트를 받아 UI/햅틱만 처리한다.
```tsx
// hooks/useMetronome.ts
const play = useCallback(() =&gt; {
  setIsPlaying(true);
  setCurrentBeat(0);
  ExpoMetronome.setSubdivision(getSubdivisionValue(subdivision));
  ExpoMetronome.start(tempo, timeSignature.beats, soundEnabled, accentFirstBeat);
}, [tempo, timeSignature.beats, soundEnabled, accentFirstBeat, subdivision]);

useEffect(() =&gt; {
  const subscription = ExpoMetronome.onBeat((event) =&gt; {
    setCurrentBeat(event.beat);

    if (hapticEnabledRef.current) {
      Haptics.impactAsync(
        event.isAccent
          ? Haptics.ImpactFeedbackStyle.Heavy
          : Haptics.ImpactFeedbackStyle.Medium
      ).catch(() =&gt; {});
    }

    optionsRef.current?.onBeat?.(event.beat, event.isAccent);
  });

  return () =&gt; subscription.remove();
}, []);</code></pre><p>UI 자체는 에니메이션으로 인하여 바뻐도, tick 생성은 네이티브 스레드에서 수행을 하는 구조이다.</p>
<h2 id="ios-avaudioengine--dispatchsourcetimer">iOS: AVAudioEngine + DispatchSourceTimer</h2>
<p>iOS 쪽에서는 <code>DispatchSourceTimer</code>를 <code>userInteractive</code> QoS 큐에서 돌리고, <code>tick()</code>마다 오디오 버퍼를 재생한다.</p>
<pre><code class="language-swift">// ios/ExpoMetronomeModule.swift
let queue = DispatchQueue(label: &quot;metronome.timer&quot;, qos: .userInteractive)
timer = DispatchSource.makeTimerSource(queue: queue)

let subBeatInterval = getSubBeatInterval()
timer?.schedule(deadline: .now(), repeating: subBeatInterval)

timer?.setEventHandler { [weak self] in
  self?.tick()
}
timer?.resume()</code></pre>
<p>오디오도 매번 파일 I/O를 하지 않고, 클릭 버퍼를 생성해 재사용한다. 단순히 버퍼를 만드는 수준이 아니라, 엔진/노드 구조를 먼저 고정해두고 tick마다 버퍼만 예약 재생하는 방식으로 짰다.</p>
<ul>
<li><p><code>AVAudioEngine</code>는 초기화 시 한 번만 <code>start()</code></p>
</li>
<li><p><code>AVAudioPlayerNode</code>를 여러 개<code>(nodeCount = 8)</code> attach/connect 해두고 계속 재사용</p>
</li>
<li><p>매 tick마다 <code>scheduleBuffer(..., options: .interrupts)</code>로 클릭 버퍼를 즉시 예약 재생</p>
</li>
<li><p>타이밍은 <code>DispatchSourceTimer(qos: .userInteractive)</code>가 소유</p>
<pre><code class="language-swift">private func setupAudioEngine() {
audioEngine = AVAudioEngine()
guard let engine = audioEngine else { return }

let mainMixer = engine.mainMixerNode
let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)!

for _ in 0..&lt;nodeCount {
  let playerNode = AVAudioPlayerNode()
  engine.attach(playerNode)
  engine.connect(playerNode, to: mainMixer, format: format)
  playerNodes.append(playerNode)
}

clickBuffer = createClickBuffer(frequency: 3000, duration: 0.015, volume: 1.0, format: format)
accentBuffer = createClickBuffer(frequency: 1800, duration: 0.025, volume: 1.0, format: format)

try? engine.start()
for node in playerNodes { node.play() }
}
</code></pre>
</li>
</ul>
<p>private func playClick(isAccent: Bool) {
  guard let buffer = isAccent ? accentBuffer : clickBuffer else { return }
  let node = playerNodes[currentNodeIndex]
  currentNodeIndex = (currentNodeIndex + 1) % nodeCount
  node.scheduleBuffer(buffer, at: nil, options: .interrupts, completionHandler: nil)
}</p>
<pre><code>그리고 메인 비트에만 JS 이벤트를 올려 브리지 트래픽을 줄였다.
```swift
if isMainBeat {
  DispatchQueue.main.async { [weak self] in
    self?.sendEvent(&quot;onBeat&quot;, [
      &quot;beat&quot;: beat,
      &quot;isAccent&quot;: isAccent,
      &quot;tempo&quot;: currentTempo,
      &quot;subBeat&quot;: subBeat
    ])
  }
}</code></pre><h1 id="expo의-config-plugin-사용하기">Expo의 Config Plugin 사용하기</h1>
<p>expo를 사용할 때의 장점은 prebuild를 통해 생성되는 ios/, android/ 디렉토리에서 네이티브 코드를 직접 수정할 수 있다는 것이다. 그렇지만 직접 수정하는 방식은 의존성 추가로 인해 다시 prebuild 작업이 진행될 경우, 유저가 직접 작성한 코드는 없어지기 때문에 Expo에서는 Config Plugin을 통해 prebuild될 때 자동으로 작성했던 코드가 다시 작성될 수 있는 방식을 권장한다.</p>
<h2 id="네이티브-모듈-등록-expo-moduleconfigjson">네이티브 모듈 등록: expo-module.config.json</h2>
<p><code>expo-metronome(내 플러그인 이름)</code>는 별도 config plugin 없이, Expo Modules 메타데이터로 자동 링크된다.</p>
<pre><code class="language-json">{
  &quot;platforms&quot;: [&quot;ios&quot;, &quot;android&quot;],
  &quot;ios&quot;: { &quot;modules&quot;: [&quot;ExpoMetronomeModule&quot;] },
  &quot;android&quot;: { /* ... */ }
}</code></pre>
<p>루트 앱에서는 로컬 패키지로 연결:</p>
<pre><code class="language-json">&quot;expo-metronome&quot;: &quot;file:./expo-metronome&quot;</code></pre>
<p>JS 래퍼에서는 requireNativeModule(&#39;ExpoMetronome&#39;)로 바인딩:</p>
<pre><code class="language-ts">const ExpoMetronome = requireNativeModule(&#39;ExpoMetronome&#39;);</code></pre>
<blockquote>
<p><a href="https://docs.expo.dev/modules/overview/">https://docs.expo.dev/modules/overview/</a></p>
</blockquote>
<h2 id="권한-설정-localization">권한 설정 localization</h2>
<p>앱 개발을 할 때 추적, 마이크, 카메라 등 접근을 하기 위해서는 권한 설정이 필요하다. 앱을 최초 설치 후 실행을 하면, 해당 앱에 이 권한을 받는 것을 허용하겠다는 팝업이 뜨는데 이것이다. 이 팝업은 반드시 시스템 언어에 맞춰서 보여주어야한다. 그렇지 못할 경우 앱 심사는 무조건 반려된다(경험담).</p>
<p>루트 디렉토리에 plugin 폴더를 생성하고 <code>withInfoPlistLocalization</code>을 생성해 app.json에 등록한다</p>
<pre><code class="language-json">&quot;plugins&quot;: [
  &quot;./plugins/withInfoPlistLocalization&quot;,
  &quot;expo-router&quot;,
  ...
]</code></pre>
<p>앱에서 지원하는 언어별 권한 문구를 작성하고 아래와 같이 등록을 하면 된다. 즉 이 플러그인은 iOS prebuild 시점에 <code>Info.plist</code>의 기본 권한 문구 설정, InfoPlist.strings 파일 생성을 하고 Xcode프로젝트 리소스에 자동으로 추가를 해준다.</p>
<pre><code class="language-js">config = withInfoPlist(config, (config) =&gt; {
  config.modResults.NSMicrophoneUsageDescription = LOCALIZED_STRINGS.en.NSMicrophoneUsageDescription;
  config.modResults.NSUserTrackingUsageDescription = LOCALIZED_STRINGS.en.NSUserTrackingUsageDescription;
  return config;
});
...
project.addResourceFile(relativePath, { target: project.getFirstTarget().uuid }, ...)</code></pre>
<blockquote>
<p><a href="https://docs.expo.dev/config-plugins/plugins/">https://docs.expo.dev/config-plugins/plugins/</a></p>
</blockquote>
<h1 id="결론">결론</h1>
<p>expo-audio + JS loop는 빠르게 만들기 좋지만, 메트로놈처럼 타이밍 정확도가 핵심인 도메인에서는 한계가 분명했다. 네이티브가 박자를 소유하고 JS는 UI/상태만 담당하는 구조가 실제 사용성에서 더 좋은 결과를 만든다.</p>
<p>다만 iOS/Android 이중 관리가 필요하다(사실 Android쪽은 할 줄 몰라서 개발 안된 상태이다). 따라서 디버깅 포인트도 증가하고, Expo 관리 워크플로우도 마찬가지이지만 네이티브 빌드 지식도 요구된다. 아무튼 좋은 경험이었고, 향후 코틀린도 배워봐야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Expo 다국어 지원 앱에서 언어별 폰트 전략: Text 컴포넌트 래핑으로 해결하기]]></title>
            <link>https://velog.io/@yoy0z-maps/Expo-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%95%B1%EC%97%90%EC%84%9C-%EC%96%B8%EC%96%B4%EB%B3%84-%ED%8F%B0%ED%8A%B8-%EC%A0%84%EB%9E%B5-Text-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EB%9E%98%ED%95%91%EC%9C%BC%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yoy0z-maps/Expo-%EB%8B%A4%EA%B5%AD%EC%96%B4-%EC%95%B1%EC%97%90%EC%84%9C-%EC%96%B8%EC%96%B4%EB%B3%84-%ED%8F%B0%ED%8A%B8-%EC%A0%84%EB%9E%B5-Text-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EB%9E%98%ED%95%91%EC%9C%BC%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 13 Jan 2026 11:53:46 GMT</pubDate>
            <description><![CDATA[<h1 id="문제-다국어-지원하면-폰트가-문제가-된다">문제: 다국어 지원하면 폰트가 문제가 된다!</h1>
<p>대부분의 국내 출시 앱을 보면 기본적으로 영어와 한국어만 지원한다. 이 경우 한국에서 출시된 폰트 아무거나 사용해도 상관이 없지만, 그 외에 다른 나라 언어를 지원하고 해당 언어에 대한 폰트 설정이 필요해질 때는 달라진다.</p>
<p>우선 내가 개발하고 1차로 배포했던 앱의 다음 버전으로 일본어와 중국어를 지원하기로 결정했다. 기존에는 Pretendard를 사용하였는데 한국어와 영어만을 지원한다. 일본어의 경우 PretendardJP가 ttf로 존재하고, 중국어 폰트 또한 새로 설치해야한다.</p>
<p>폰트 설정하는 거야 앱 assets에 font 파일을 등록하고, <a href="https://docs.expo.dev/versions/latest/sdk/font/">expo-font</a>를 통해 사용할 폰트를 등록할 수 있지만 <strong>&quot;어떻게 하면 선택된 언어에 따라 렌더링 시 자동으로 fontFamily를 바꿔서 적용할 수 있을까?&quot;</strong> 고민을 했다. 나의 결론은 <strong>&quot;기존 Text 컴포넌트를 래핑해서 언어별 fontFamily를 자동으로 선택하게 만들자&quot;</strong>였다.</p>
<p>개발환경과 다국어 환경은 아래와 같다.</p>
<pre><code class="language-json">    &quot;@react-native-async-storage/async-storage&quot;: &quot;2.2.0&quot;,
    &quot;expo&quot;: &quot;~54.0.31&quot;,
    &quot;expo-localization&quot;: &quot;~17.0.8&quot;,
    &quot;react-i18next&quot;: &quot;^16.5.2&quot;,
    &quot;i18next&quot;: &quot;^25.7.4&quot;,</code></pre>
<h1 id="step1-커스텀-훅으로-현재-앱-언어-구독하기">Step1. 커스텀 훅으로 현재 앱 언어 구독하기</h1>
<p><code>useTranslation</code>은 기본적으로 앱에서 설정한 언어 상태를 구독하기 때문에, 사용자가 언어를 변경하면 변경을 감지하고 재렌더를 할 수 있다. 훅으로 분리한 이유는 다음에 사용할 커스텀 텍스트 컴포넌트 UI와 로직을 분리하고, 이 로직이 나중에 또 사용될 가능성을 염려해두고 커스텀 훅으로 분리하였다.</p>
<pre><code class="language-tsx">// hooks/useAppFonts.ts
import { useTranslation } from &quot;react-i18next&quot;;

export function useAppFonts() {
  const { i18n } = useTranslation();
  const isJa = i18n.language === &quot;ja&quot;;

  return {
    sans: isJa ? &quot;PretendardJP&quot; : &quot;Pretendard&quot;,
  };
}</code></pre>
<blockquote>
<p>예시로 일본어 적용 사례만 보여주었습니다. 다른 언어 추가도 동일합니다.</p>
</blockquote>
<h1 id="step2-커스텀-컴포넌트로-앱-언어에-따라-폰트-페밀리-변경하기">Step2. 커스텀 컴포넌트로 앱 언어에 따라 폰트 페밀리 변경하기</h1>
<p>함수 <code>pickFontFamily()</code>는 fontFamily(여기서는 Pretendard와 PretendardJP)와 fontWeight를 입력 받아 거기에 맞는 폰트(여기서는 fontWeight가 다른 Pretendard)로 반환해주는 함수이다. 살짝 복잡해 보일 수 있는데 variable를 사용하지 않고, static을 사용한 것은 <a href="https://docs.expo.dev/develop/user-interface/fonts/">Expo 문서의 Font</a>에서 권장하는 방식이다. </p>
<p>컴포넌트<code>AppText</code>자체는 기존 <code>Text</code> 컴포넌트에 fontFamily와 fontWeight(undefined =&gt; pickFontFamily()에서 반환하는 폰트 자체가 fontWeight가 적용된 상태임)를 override하는 역할을 한다.</p>
<pre><code class="language-tsx">type SupportedFonts = &quot;Pretendard&quot; | &quot;PretendardJP&quot;;

// components/AppText.tsx
import { useAppFonts } from &quot;@/hooks/useAppFonts&quot;;
import { StyleSheet, Text, TextProps } from &quot;react-native&quot;;

const FONT_MAPPING = {
  Pretendard: {
    200: &quot;PretendardThin&quot;,
    300: &quot;PretendardLight&quot;,
    400: &quot;PretendardRegular&quot;,
    500: &quot;PretendardMedium&quot;,
    600: &quot;PretendardSemiBold&quot;,
    700: &quot;PretendardBold&quot;,
  },
  PretendardJP: {
    // ... (일본어 폰트 매핑)
  },
};

function pickFontFamily(base: SupportedFonts, weight?: string | number) {
  let numericWeight = 400;

  // &quot;bold&quot;, &quot;normal&quot; 등 문자열 처리 대응
  if (weight === &quot;bold&quot;) numericWeight = 700;
  else if (weight === &quot;normal&quot;) numericWeight = 400;
  else if (typeof weight === &quot;string&quot;) numericWeight = parseInt(weight, 10);
  else if (typeof weight === &quot;number&quot;) numericWeight = weight;

  const fontSet = FONT_MAPPING[base];

  // 정확히 매칭되는 weight가 없으면 400(Regular) 반환
  return fontSet[numericWeight as keyof typeof fontSet] ?? fontSet[400];
}

export default function AppText({ style, ...props }: TextProps) {
  const font = useAppFonts();

  const flat = StyleSheet.flatten(style) || {};
  const resultFont = pickFontFamily(font.sans as &quot;PretendardJP&quot; | &quot;Pretendard&quot;, flat.fontWeight);

  return (
    &lt;Text
      {...props}
      style={[style, { fontFamily: resultFont, fontWeight: undefined }]}
    /&gt;
  );
}</code></pre>
<p>사용하는 법 또한 <code>Text</code> 컴포넌트와 동일하다. 색상 등 다양한 스타일을 똑같이 적용할 수 있다.</p>
<pre><code class="language-tsx">&lt;AppText style={styles.text}&gt;Hello&lt;/AppText&gt;


const styles = StyleSheet.create({
  text: {
    fontSize: 30,
    fontWeight: &quot;600&quot;,
    color: &quot;white&quot;
  },
});</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[React의 내장 Hooks를 통한 최적화]]></title>
            <link>https://velog.io/@yoy0z-maps/React%EC%9D%98-%EB%82%B4%EC%9E%A5-Hooks%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@yoy0z-maps/React%EC%9D%98-%EB%82%B4%EC%9E%A5-Hooks%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Mon, 05 Jan 2026 15:16:34 GMT</pubDate>
            <description><![CDATA[<p>React에는 다양한 Hook이 있다. <code>useState()</code>, <code>useRef()</code>, <code>useEffect()</code>와 같이 자주 사용되는 훅도 있지만, 자주 사용되지 않아서 잊혀져서 사용할 때마다 찾아보게 되는 훅들도 있다(특히 최적화와 관련있는 훅들). 그래서 이번 기회에 React의 훅에 대해서 확실하게 집고 넘어가보려고 한다.</p>
<h1 id="hook이란-hook의-등장-배경">Hook이란? Hook의 등장 배경</h1>
<p>React 16.8 버전이 된 후 함수형 컴포넌트가 생기고, 상태 관리와 생명주기 기능을 함수형 컴포넌트에서도 사용하기 위해 Hook이라는 개념이 생겼다. 이전에는 React에서는 상태, 생명주기를 사용하기 위해서는 클래스 컴포넌트를 사용해야했다. 코드가 길고 복잡하고, 악명 높은 <code>this</code>를 사용해야하고, 상태를 <code>state</code> 내부에서 선언해야하는 등 다양한 불편이 있었다.</p>
<pre><code class="language-jsx">class Counter extends React.Component {
  state = { count: 0 };

  componentDidMount() {
    console.log(&quot;mount&quot;);
  }

  componentDidUpdate() {
    console.log(&quot;update&quot;);
  }

  componentWillUnmount() {
    console.log(&quot;unmount&quot;);
  }

  render() {
    return &lt;button&gt;{this.state.count}&lt;/button&gt;;
  }
}</code></pre>
<p>이 외에도 클래스형 컴포넌트를 사용했을 때 구현이 어려운 <a href="https://medium.com/swlh/what-is-react-concurrent-mode-46989b5f15da">Concurrent React</a> 설계를 위해 함수형 컴포넌트를 등장시켰고, 클래스형 컴포넌트에서만 가능하던 작업을 함수형 컴포넌트에서도 가능케 하기 위해 <code>Hook</code>이 등장하였다.</p>
<pre><code class="language-tsx">function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() =&gt; {
    console.log(&quot;mount&quot;);
    return () =&gt; console.log(&quot;unmount&quot;);
  }, []);

  return &lt;button&gt;{count}&lt;/button&gt;;
}</code></pre>
<p>각 상태는 각자의 역할에 맞게 고유한 이름을 갖을 수 있게 되었고, 기존의 마운트, 업데이트, 언마운트 등 분리된 작업들이 <code>useEffect()</code>를 통해 하나의 Hook 안에서 처리가 가능하게 되었다. 또한 의존성 배열을 통해서 React가 자동으로 prev 비교를 해주기 때문에 렌더링 후처리 과정 또한 편리해졌다.</p>
<pre><code class="language-jsx">// 클래스 컴포넌트
componentDidUpdate(prevProps) {
  if (prevProps.userId !== this.props.userId) {
    fetchUser(this.props.userId);
  }
}

// 함수 컴포넌트
useEffect(() =&gt; {
  fetchUser(id);
}, [id]);</code></pre>
<h2 id="hook의-의존성-배열deps의-얕은-비교">Hook의 의존성 배열(deps)의 얕은 비교</h2>
<p><code>useEffect()</code>,<code>useCallback()</code>, <code>useMemo()</code> 같은 훅에는 의존성배열이 존재한다.이때 deps가 빈 배열이라면 컴포넌트가 처음 렌더링될 때만 실행된다. 그렇지만  deps에 변경이 있을 경우 재실행(연산)하게 되는데, 이때 React에서는 deps를 대상으로 얕은 비교를 실행한다.</p>
<blockquote>
<p>얕은 비교 (Shallow Compare): 객체나 배열 같은 복합 데이터의 값을 비교할 때 내부의 각 요소나 속성을 재귀적으로 비교하지 않고, 참조나 기본 타입 값만 비교한다.</p>
</blockquote>
<p>즉 다음과 같은 경우는 의존성 배열이 바뀐 것으로 간주하여, 매번 실행된다.</p>
<pre><code class="language-tsx">// value의 타입
// {
//   id: 0
//   name: &quot;John&quot;
// }

export default function Item(value) {
  useEffect(() =&gt; {
    console.log(value);
  }, [value])
}</code></pre>
<p>따라서 구조 분해 할당을 통해 필요한 원시 타입 값을 가져와서 사용해야 한다.</p>
<pre><code class="language-tsx">export default function Item(value) {
  const { id, name } = value;

  useEffect(() =&gt; {
    console.log(value);
  }, [id])
}</code></pre>
<h1 id="ref-hooks">Ref Hooks</h1>
<p>Ref를 사용하면 렌더링에 사용되지 않는 일부 정보들을 보유할 수 있다. State와 달리, Ref는 업데이트 되어도 다시 렌더링 되지 않는다. </p>
<h2 id="useref를-통해-ref-사용하기---dom-참조하기">useRef를 통해 Ref 사용하기 - DOM 참조하기</h2>
<p>아래 코드는 움직일 수 있는 모달을 구현한 코드이다. 해당 모달의 기능은 간단하다 주어진 영역 안에서 자유롭게 이동 가능하며, 모달 밖의 영역을 클릭했을 때 모달을 닫는 기능을 갖고 있다. React는 기본적으로 DOM을 직접적으로 제어할 수 없게 설계됐다. 그렇지만 모달을 클릭한 상태로 위치를 이동시키는 것은 DOM에 접근하는 것이다. 따라서 React에서는 DOM에 접근하기 위해 유일한 통로 <code>ref</code>가 존재한다.</p>
<p><code>modalRef</code>는 DOM이 생성되기 전 null이다. 그렇지만 DOM이 생기는 순간 ref 속성을 통해 해당 DOM을 modalRef.current에 연결해준다. 따라서 Modal의 이동이 가능해지는 것이다.</p>
<pre><code class="language-tsx">import { useEffect, useRef, useState } from &quot;react&quot;;

export default function DraggableModal({
  children,
  setOpenModal,
}: {
  children: React.ReactNode;
  setOpenModal: (open: boolean) =&gt; void;
}) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const dragStartPos = useRef({ x: 0, y: 0 });
  const modalRef = useRef&lt;HTMLDivElement&gt;(null);

  // Search Modal 드래그
  useEffect(() =&gt; {
    if (!isDragging) return;

    const handleMouseMove = (e: MouseEvent) =&gt; {
      const deltaX = e.clientX - dragStartPos.current.x;
      const deltaY = e.clientY - dragStartPos.current.y;
      setPosition((prev) =&gt; ({
        x: prev.x + deltaX,
        y: prev.y + deltaY,
      }));
      dragStartPos.current = { x: e.clientX, y: e.clientY };
    };

    const handleMouseUp = () =&gt; {
      setIsDragging(false);
    };

    window.addEventListener(&quot;mousemove&quot;, handleMouseMove);
    window.addEventListener(&quot;mouseup&quot;, handleMouseUp);

    return () =&gt; {
      window.removeEventListener(&quot;mousemove&quot;, handleMouseMove);
      window.removeEventListener(&quot;mouseup&quot;, handleMouseUp);
    };
  }, [isDragging]);

  const handleHeaderMouseDown = (e: React.MouseEvent) =&gt; {
    if (e.button !== 0) return;
    if (modalRef.current) {
      dragStartPos.current = {
        x: e.clientX,
        y: e.clientY,
      };
      setIsDragging(true);
    }
  };

  return (
    &lt;div
      className=&quot;fixed inset-0 flex items-center justify-center z-[9999]&quot;
      onClick={() =&gt; setOpenModal(false)}
    &gt;
      &lt;div
        ref={modalRef} // DOM 생성 시 modalRef.current에 연결
        className=&quot;flex flex-col w-[750px] h-[480px] shadow-[0_2px_20px_0_#badaff] rounded-2xl border-[1px] border-solid border-[rgba(var(--color-border-quaternary),0.08)] bg-[rgba(255,255,255,0.2)] backdrop-blur-[12px] overflow-hidden&quot;
        style={{
          transform: `translate(${position.x}px, ${position.y}px)`,
        }}
        onMouseDown={handleHeaderMouseDown}
        onClick={(e) =&gt; e.stopPropagation()}
      &gt;
        {children}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>위 코드를 보면 모달의 위치를 State로 관리한다. 별로 무거운 작업도 아니고, 작동도 잘 되지만 transform(모달 이동)이 발생할 때마다 State position이 변경되고, 동시에 이 모달은 재렌더링된다. (x0, y0)에서 (x1, y1)로 이동하는 모든 순간 렌더링이 되는 것은 실로 비효율적이다. 따라서 이 부분에 대한 최적화가 필요하다.</p>
<h3 id="react-rendering과-browser-rendering">React Rendering과 Browser Rendering</h3>
<p>위의 예시 코드를 <code>useRef()</code>를 통해 최적화하기 앞서 React Rendering과 Browser Rendering의 차이점을 정확하게 집고 넘어갈 필요가 있다.</p>
<ul>
<li>React Rendering = 컴포넌트 함수 실행 =&gt; JS 객체 (Virtual DOM) 생성 =&gt; Reconciliation (이전 Virtual DOM과 새 Virtual DOM 비교) =&gt; Commit (바뀐 부분만 실제 DOM API를 호출하여 반영)</li>
<li>Browser Rendering = DOM 생성 =&gt; CSSOM 생성 =&gt; Render Tree 생성 =&gt; Layout (Reflow) =&gt; Paint =&gt; Compositing (z-index, position 같은 속성 반영)<blockquote>
<p>아래 글들을 참고하였습니다. 더 자세한 정보는 아래 글에서 얻을 수 있습니다.
<a href="https://yozm.wishket.com/magazine/detail/2909/">브라우저가 화면을 그리는 방법, 렌더링 과정 이해하기</a>
<a href="elog.io/@minw0_o/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A0%8C%EB%8D%94%EB%A7%81-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81">리엑트 렌더링 !== 브라우저 렌더링</a></p>
</blockquote>
</li>
</ul>
<p>위 정보들을 통해 position이 변경되는 것은 Browser Rendering의 역할이라는 것을 알 수 있다. 즉 State를 사용하지 않아도 position 변경이 가능하다.</p>
<h2 id="useref를-통해-ref-사용하기---browser-rendering-트리거">useRef를 통해 Ref 사용하기 - Browser Rendering 트리거</h2>
<p>기존의 코드와 변경된 점은 position에 대하여 <code>useRef</code>를 사용한다는 것이다. 그래서 기존 코드에서는 위치가 변경되는 모든 과정 가운데 재렌더링이 발생했다면, 지금은 isDragging의 값이 변경되는 시점에만 재렌더링이 발생한다. 또한 기존에 사용하였던 modalRef를 사용하여 직접적으로 style의 transform 속성을 조작하였다.</p>
<pre><code class="language-tsx">import { useEffect, useRef, useState } from &quot;react&quot;;

export default function DraggableModal({
  children,
  setOpenModal,
}: {
  children: React.ReactNode;
  setOpenModal: (open: boolean) =&gt; void;
}) {
  const modalRef = useRef&lt;HTMLDivElement&gt;(null);
  const [isDragging, setIsDragging] = useState(false);

  const dragStartPos = useRef({ x: 0, y: 0 });
  const positionRef = useRef({ x: 0, y: 0 });

  const applyTransform = () =&gt; {
    const el = modalRef.current;
    if (!el) return;
    const { x, y } = positionRef.current;
    el.style.transform = `translate(${x}px, ${y}px)`;
  };

  // Search Modal 드래그
  useEffect(() =&gt; {
    if (!isDragging) return;

    const handleMouseMove = (e: MouseEvent) =&gt; {
      const deltaX = e.clientX - dragStartPos.current.x;
      const deltaY = e.clientY - dragStartPos.current.y;

      positionRef.current = {
        x: positionRef.current.x + deltaX,
        y: positionRef.current.y + deltaY,
      };

      dragStartPos.current = { x: e.clientX, y: e.clientY };

      applyTransform();
    };

    const handleMouseUp = () =&gt; {
      setIsDragging(false);
    };

    window.addEventListener(&quot;mousemove&quot;, handleMouseMove);
    window.addEventListener(&quot;mouseup&quot;, handleMouseUp);

    return () =&gt; {
      window.removeEventListener(&quot;mousemove&quot;, handleMouseMove);
      window.removeEventListener(&quot;mouseup&quot;, handleMouseUp);
    };
  }, [isDragging]);

  const handleHeaderMouseDown = (e: React.MouseEvent) =&gt; {
    if (e.button !== 0) return; // 좌클릭만 허용
    if (modalRef.current) {
      dragStartPos.current = {
        x: e.clientX,
        y: e.clientY,
      };
      setIsDragging(true);
    }
  };

  return (
    &lt;div
      className=&quot;fixed inset-0 flex items-center justify-center z-[9999]&quot;
      onClick={() =&gt; setOpenModal(false)}
    &gt;
      &lt;div
        ref={modalRef}
        className=&quot;flex flex-col w-[750px] h-[480px] shadow-[0_2px_20px_0_#badaff] rounded-2xl border-[1px] border-solid border-[rgba(var(--color-border-quaternary),0.08)] bg-[rgba(255,255,255,0.2)] backdrop-blur-[12px] overflow-hidden&quot;
        onMouseDown={handleHeaderMouseDown}
        onClick={(e) =&gt; e.stopPropagation()}
      &gt;
        {children}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h1 id="performance-hooks">Performance Hooks</h1>
<p>React에서는 성능을 재렌더링 성능을 최적화하기 위해 사용하는 훅들이 있다. 이러한 훅은 최적화 도구이지 기본 문법이 아니다. 또한 이러한 훅을 사용하는 것 자체만으로도 비용이 있기 때문에 무분별한 사용은 오히려 코드 가독성을 낮추고, 단순 연산에 비해 비용이 더 많이 든다.</p>
<h2 id="usememo---동일한-값의-재연산-방지하기">useMemo() - 동일한 값의 재연산 방지하기</h2>
<p><code>useMemo()</code>를 사용하면 비용이 많이 드는 계산 결과를 캐시할 수 있다. <code>useMemo()</code>는 파리미터로 콜백 함수와 의존성 배열을 받는다. 콜백 함수의 반환 값이 변수가 갖는 값이 되며, 의존성 배열이 변했을 때 기존 캐싱된 값을 무효하고 다시 계산을 한다.</p>
<pre><code class="language-tsx">const visibleTodos = useMemo(() =&gt; filterTodos(todos, tab), [todos, tab]);</code></pre>
<h2 id="usecallback---동일한-함수-생성-방지하기">useCallback() - 동일한 함수 생성 방지하기</h2>
<p><code>useCallback()</code>을 사용하면 함수 정의를 최적화된 컴포넌트에 전달하기 전에 캐시할 수 있다. 말이 추상적인데, 보다 직관적으로 설명하자면 함수는 Pure JS 문법이고, JS에서는 하나의 값이다. 따라서 재렌더링이 될 때마다 새로운 함수를 생성을 한다.</p>
<p>아래 코드를 예시로 들자면, Parent()의 props에 변경이 생겨 재렌더링 되었다. 이때 Parent() 내부에 
<code>CircleAvatar</code> 컴포넌트 역시 props에 변경이 생겨서 재렌더링되며, 선언된 함수 fetchData() 역시 새롭게 생성된다. 그렇지만 새로고침을 위한 <code>Child</code> 컴포넌트 또한 렌더링이 되는 것은 불필요한 상황이다. 즉 자식이 렌더를 스킵할 수 있는 구조이다. 그렇지만 React에서는 얕은 비교를 하기 때문에 <code>Child</code> 컴포넌트는 <code>fetchData</code>가 바뀐 것으로 간주하고 재렌더링을 한다.</p>
<pre><code class="language-tsx">function Parent(user) {
  const fetchData = async () =&gt; { ... };

  return (
    &lt;div&gt;
      &lt;CircleAvatar source={user.profileImg} /&gt;
      &lt;Child onRefresh={fetchData} /&gt;;
      {/* ... */}
    &lt;/div&gt;
  );
}</code></pre>
<p>이러한 상황에서 기존의 <code>fetchData</code>에 <code>useCallback</code>을 사용하여 부모인 <code>Parent</code>가 재렌더링 되어도 함수가 다시 생성되는 것을 방지하여, 자녀인 <code>Child</code>가 재렌더링 되는 것을 방지할 수 있다.</p>
<pre><code class="language-tsx">const fetchData = useCallback(async () =&gt; { ... }, [userId]);</code></pre>
<blockquote>
<p>Child가 React.memo가 아니거나, props로 내려가지 않는 함수라면 useCallback은 대부분 의미가 없다.</p>
</blockquote>
<h2 id="실제-사용-예시">실제 사용 예시</h2>
<p>SearchModal은 위에서 봤던 DraggableModal의 children으로 전달된다. DraggableModal은 마우스가 클릭되는 시점과 이동이 종료되는 시점에 재렌더링이 되는데, 이 두 시점에 자식 컴포넌트인 SearchModal 또한 재렌더링이 된다.</p>
<pre><code class="language-tsx">// SearchModal.tsx
export default function SearchModal({
  setOpenSearch,
}: {
  setOpenSearch: (open: boolean) =&gt; void;
}) {
  const [searchQuery, setSearchQuery] = useState(&quot;&quot;);
  const debouncedSearchQuery = useDebounce(searchQuery, 300);

  const { data: notes } = useQuery&lt;Note[]&gt;({
    queryKey: [&quot;notes&quot;, debouncedSearchQuery],
    queryFn: () =&gt; noteRepo.getNoteByQuery(debouncedSearchQuery),
    enabled: searchQuery.length &gt; 1,
  });

  // ...

  return (
    &lt;DraggableModal setOpenModal={setOpenSearch}&gt;
      {/* Header */}
      &lt;div className=&quot;flex items-center justify-between p-4 border-b-[1.5px] border-solid border-[rgba(var(--color-border-quaternary),0.08)] cursor-move flex-shrink-0&quot;&gt;
        ...
      &lt;/div&gt;
      {/* content */}
      &lt;section className=&quot;flex flex-col w-full flex-1 overflow-y-scroll custom-scrollbar px-4 py-3 gap-3&quot;&gt;
        &lt;SearchResult
          type=&quot;chat&quot;
          title=&quot;Chats&quot;
          data={chatThreads}
          searchQuery={searchQuery}
          setOpenSearch={setOpenSearch}
        /&gt;
        ...
      &lt;/section&gt;
      {/* footer */}
      &lt;div className=&quot;px-4 py-[10px] border-t-[1.5px] border-solid border-[rgba(var(--color-border-quaternary),0.08)] flex items-center justify-between flex-shrink-0&quot;&gt;
        ...
      &lt;/div&gt;
    &lt;/DraggableModal&gt;
  );
}</code></pre>
<p>따라서 자식 SearchResult 컴포넌트 또한 재렌더링이 되며 다음과 같은 페인 포인트가 존재한다.</p>
<ul>
<li><p>highlightText()가 아이템마다 매번 RegExp를 새로 생성한다 (map 안에서 계속)</p>
</li>
<li><p>렌더 부분이 길어서 가독성이 떨어진다</p>
<pre><code class="language-tsx">// SearchResult.tsx
export default function SearchResult({
type,
title,
data,
searchQuery,
setOpenSearch,
}: {
type: &quot;chat&quot; | &quot;note&quot;;
title: string;
data: SearchResultData;
searchQuery: string;
setOpenSearch: (open: boolean) =&gt; void;
}) {
const navigate = useNavigate();

const highlightText = (text: string, query: string) =&gt; {
  if (!query || query.length === 0) return text;

  const regex = new RegExp(
    `(${query.replace(/[.*+?^${}()|[\]\\]/g, &quot;\\$&amp;&quot;)})`,
    &quot;gi&quot;
  );
  const parts = text.split(regex);

  return parts.map((part, index) =&gt;
    regex.test(part) ? (
      &lt;span key={index} className=&quot;text-primary&quot;&gt;
        {part}
      &lt;/span&gt;
    ) : (
      part
    )
  );
};

return (
  &lt;div&gt;
    &lt;p className=&quot;font-noto-sans-kr font-medium text-[12px] text-text-secondary mb-2&quot;&gt;
      {title}
    &lt;/p&gt;
    {data &amp;&amp; data.length &gt; 0 ? (
      data.map((item) =&gt; (
        &lt;div
          onClick={() =&gt; {
            navigate(`/${type}/${item.id}`);
            setOpenSearch(false);
          }}
          key={item.id}
          className=&quot;w-full group cursor-pointer flex flex-col items-start gap-2.5 hover:bg-search-item-hover rounded-[10px] p-3&quot;
        &gt;
          &lt;p className=&quot;font-noto-sans-kr font-medium text-[14px]&quot;&gt;
            {highlightText(item.title, searchQuery)}
          &lt;/p&gt;
          &lt;p className=&quot;text-[12px] text-text-secondary line-clamp-1 group-hover:line-clamp-2&quot;&gt;
            {highlightText(
              type === &quot;chat&quot;
                ? (item as ChatThread).messages[0].content
                : (item as Note).content,
              searchQuery
            )}
          &lt;/p&gt;
        &lt;/div&gt;
      ))
    ) : (
      &lt;div className=&quot;w-full flex items-center justify-center py-1&quot;&gt;
        &lt;p className=&quot;text-[14px] font-medium&quot;&gt;{`No ${title} found`}&lt;/p&gt;
      &lt;/div&gt;
    )}
  &lt;/div&gt;
);
}
</code></pre>
</li>
</ul>
<pre><code>
위의 코드는 다음과 같이 리펙토링 작업이 가능하다
- 기존의 highlightText를 문자 그대로 바꿔주는 함수 escapeRegExp와 렌더를 담당하는 함수 renderHighlightParts로 분리하고, util함수로 분리해준다.
- 기존의 SearchResult.tsx의 map에서 렌더링되던 긴 렌더부분을 재사용 가능한 컴포넌트로 분리한다.
- highlight가 되야하는 부분은 searchQuery가 바뀌지 않는 한 불변해야 한다. 따라서 `useMemo`를 사용해서, 재연산을 방지한다.  (highlight 되야하는 부분이 재연산이 되면, map 부분도 재연산이 될 것이고 검색 결과가 많을 경우 이 비용이 비쌀 수가 있다)
- SearchResultItem은 자신이 클릭됐을 때 실행해야하는 함수를 파라미터로 받는다. 해당 함수는 부모인 SearchResult에서 선언됐다. Modal이 재렌더링될 때 SeacrhResult 또한 재렌더링 되고, SearchResult 내부에 선언된 함수는 새롭게 생긴다. 이때`useCallback`과 `React.memo()`를  사용하여서 data는 안 변하고, 모달 이동 과정 중에 발생하는 리렌더링에서 함수 참조 값의 불일치로 SearchResultItem이 렌더링 되는 것을 방지한다.
```tsx
// Util 함수로 분리
function escapeRegExp(s: string) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, &quot;\\$&amp;&quot;);
}

function renderHighlightParts(text: string, regex: RegExp | null) {
  if (!regex) return text;

  const parts = text.split(regex);
  return parts.map((part, idx) =&gt;
    idx % 2 === 1 ? (
      &lt;span key={idx} className=&quot;text-primary&quot;&gt;
        {part}
      &lt;/span&gt;
    ) : (
      part
    )
  );
}

// SearchResultItem으로 렌더 부분 재사용 컴포넌트로 분리
const SearchResultItem = React.memo(function SearchResultItem({
  id,
  title,
  preview,
  onClick,
  highlightRegex,
}: {
  id: string;
  title: string;
  preview: string;
  onClick: (id: string) =&gt; void;
  highlightRegex: RegExp | null;
}) {
  return (
    &lt;div
      onClick={() =&gt; onClick(id)}
      className=&quot;w-full group cursor-pointer flex flex-col items-start gap-2.5 hover:bg-search-item-hover rounded-[10px] p-3&quot;
    &gt;
      &lt;p className=&quot;font-noto-sans-kr font-medium text-[14px]&quot;&gt;
        {renderHighlightParts(title, highlightRegex)}
      &lt;/p&gt;
      &lt;p className=&quot;text-[12px] text-text-secondary line-clamp-1 group-hover:line-clamp-2&quot;&gt;
        {renderHighlightParts(preview, highlightRegex)}
      &lt;/p&gt;
    &lt;/div&gt;
  );
});

// SearchResult.tsx
export default function SearchResult({
  type,
  title,
  data,
  searchQuery,
  setOpenSearch,
}: {
  type: &quot;chat&quot; | &quot;note&quot;;
  title: string;
  data: SearchResultData;
  searchQuery: string;
  setOpenSearch: (open: boolean) =&gt; void;
}) {
  const navigate = useNavigate();

  const highlightRegex = useMemo(() =&gt; {
    const q = searchQuery.trim();
    if (!q) return null;
    return new RegExp(`(${escapeRegExp(q)})`, &quot;gi&quot;);
  }, [searchQuery]);

  const onClickItem = useCallback(
    (id: string) =&gt; {
      navigate(`/${type}/${id}`);
      setOpenSearch(false);
    },
    [navigate, setOpenSearch, type]
  );

  return (
    &lt;div&gt;
      &lt;p className=&quot;font-noto-sans-kr font-medium text-[12px] text-text-secondary mb-2&quot;&gt;
        {title}
      &lt;/p&gt;

      {data &amp;&amp; data.length &gt; 0 ? (
        data.map((item) =&gt; {
          const preview =
            type === &quot;chat&quot;
              ? (item as ChatThread).messages?.[0]?.content ?? &quot;&quot;
              : (item as Note).content ?? &quot;&quot;;

          return (
            &lt;SearchResultItem
              key={item.id}
              id={item.id}
              title={item.title}
              preview={preview}
              onClick={onClickItem}
              highlightRegex={highlightRegex}
            /&gt;
          );
        })
      ) : (
        &lt;div className=&quot;w-full flex items-center justify-center py-1&quot;&gt;
          &lt;p className=&quot;text-[14px] font-medium&quot;&gt;{`No ${title} found`}&lt;/p&gt;
        &lt;/div&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre><h1 id="마무리하며">마무리하며...</h1>
<p>React를 사용하여 개발한지 꽤 많은 시간이 흘렀지만 아직도 완벽하게 알고 있지 않다. React의 내장 훅들를 사용하여 커스텀훅을 만들어 UI와 로직의 분리로 코드 가독성을 올리고, Hook을 필요로 하는 로직을 재사용할 수 있는 등 다양한 시도를 할 수 있다. 다음번에는 커스텀 훅에 대하여 더 깊게 알아보고, 글로 남겨보는 시간을 가져봐야겠다.</p>
<blockquote>
<p>참고한 공식문서
<a href="https://ko.react.dev/reference/react/hooks">https://ko.react.dev/reference/react/hooks</a>
<a href="https://ko.react.dev/reference/react/memo#skipping-re-rendering-when-props-are-unchanged">https://ko.react.dev/reference/react/memo#skipping-re-rendering-when-props-are-unchanged</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[왜 백엔드 SDK를 만들었는가? (Express.js 기반 백엔드 API Client SDK 설계)]]></title>
            <link>https://velog.io/@yoy0z-maps/%EC%99%9C-%EB%B0%B1%EC%97%94%EB%93%9C-SDK%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8A%94%EA%B0%80-Express.js-%EA%B8%B0%EB%B0%98-%EB%B0%B1%EC%97%94%EB%93%9C-API-Client-SDK-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@yoy0z-maps/%EC%99%9C-%EB%B0%B1%EC%97%94%EB%93%9C-SDK%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8A%94%EA%B0%80-Express.js-%EA%B8%B0%EB%B0%98-%EB%B0%B1%EC%97%94%EB%93%9C-API-Client-SDK-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Mon, 22 Dec 2025 07:15:55 GMT</pubDate>
            <description><![CDATA[<p>이 글은 TACO에서 프론트엔드 개발자로 새 프로젝트를 진행하며, 팀 프로젝트에서 사용할 API Client SDK 구현과 배포 경험을 정리한 글이다. 정확한 포지션은 프론트엔드 개발자이지만 평소 기본적인 백엔드 지식과 DRF로 간단한 백엔드 토이프로젝트 경험도 있어서 백엔드 코드를 시간 날 때 어떻게 작성하나 구경하곤 한다. 아무튼 전에 써본 API Client SDK가 프론트 개발에 큰 편리함을 제공해줬어서, 이번 프로젝트에서 내가 의견을 주도적으로 의견을 내보았다.</p>
<h1 id="왜-백엔드-sdk를-만들었는가">왜 백엔드 SDK를 만들었는가?</h1>
<p>가장 큰 이유는 우리가 TypeScript를 채택했다는 것에서 시작되었다. TypeScript를 사용해 코드를 작성하는 만큼 <code>any</code> 같은 최상위 타입 사용을 하지 않고, 정확한 타이핑을 통해 타입 안정성을 가져가는 것을 목표로 했다. 그렇지만 지속되는 회의와 개발 프로세스를 거치며 특정 엔드포인드는 사라지기도, 다른 엔드포인트와 결합하기도, 새로 생기기도 했다. 이에 각 응답 DTO, 요청 파라미터도 바뀌었다. 프론트앤드 개발자로 인턴을 했을 때 팀에서 이런 SDK를 사용했을 때 이런 문제에 있어서 하나하나 문서를 찾아보지 않아도 쉽게 확인할 수 있다는 점이 너무 매력적이었다.
<img src="https://velog.velcdn.com/images/yoy0z-maps/post/2fa5e8c7-b875-4a16-886f-ff8b6240712b/image.gif" alt=""></p>
<p>그리고 전에 인턴을 했을 때 처음으로 백엔드 SDK를 접했었는데, 이때 SDK를 사용했을 때 프론트에서<code>fetch</code>나 <code>axios</code>  사용 없이 직접 API를 호출하는데 이게 정말 기존 방식보다 직관적이며, 코드 에디터 자체에서 응답 DTO나 요청 파라미터를 쉽게 확인할 수 있는 것도 너무 좋았다. (그래서 SDK를 구현하자고 적극적으로 내가 어필하였다) 물론 프론트에서도 유틸 함수로 만들어 사용하면 구현이 가능하지만, status code, error 처리 방식에 대해서 공통적으로 대응할 수 있는 것도 좋은 점이라고 생각을 한다.</p>
<pre><code class="language-ts">// fetch 방식
export const userApi = {
  async getUser(params: GetUserParams): Promise&lt;UserDTO&gt; {
    const res = await fetch(`/api/users/${params.userId}`);

    if (!res.ok) {
      const error = await res.json();
      throw new ApiError(res.status, error.code, error.message);
    }

    return res.json();
  },
};

// SDK 방식
const user = await api.user.getUser({ userId: &quot;...&quot; });</code></pre>
<h1 id="sdk란-무엇인가">SDK란 무엇인가?</h1>
<p>개발을 하면 SDK(Software Development Kit)라는 용어를 많이 접한다. 그냥 말 그대로 특정 서비스를 개발할 때 필요한 도구와 자료를 모아놓은 패키지이다. API, 문서, 샘플 코드 등 다양한 것들이 포함되어있다. 하나의 도구 상자라고 볼 수 있다. 사실 이 글에서 말하는 SDK는 ‘서비스 API를 타입 안전하게 호출하기 위한 클라이언트 라이브러리(API Client SDK)’에 가깝다.</p>
<p>SDK 자체가 이미 검증된 코드와 이에 맞는 가이드 문서를 포함하고 있어서 안정성과 개발 효율을 향상 시켜줄 수 있다. SDK는 사실 API부분을 넘어 더 다양한 툴을 제공하기 때문에 자세한 내용은 <a href="https://aws.amazon.com/ko/compare/the-difference-between-sdk-and-api/">AWS 문서</a>를 참고하면 좋을 것 같다.</p>
<h2 id="sdk-구조">SDK 구조</h2>
<pre><code class="language-tree">sdk/
├─ src/
│  ├─ endpoints/     =&gt; 실제 API 엔드포인트를 감싼 SDK 함수
│  │  ├─ health.ts
│  │  └─ me.ts
│  │
│  ├─ types/         =&gt; SDK에서 외부에 제공하는 API DTO 타입
│  │  └─ me.ts
│  │
│  ├─ client.ts      =&gt; SDK 진입점
│  ├─ config.ts      =&gt; SDK 전역에서 사용하는 설정 값 관리
│  ├─ http-builder.ts     =&gt; http요청을 통제하는 중앙 처리 (fetch 래핑, headers/credentials/query 관리, 응답 파싱)
│  └─ index.ts       =&gt; SDK에서 외부에 제공할 것을만 export
│
├─ package.json
├─ tsconfig.json
└─ README.md</code></pre>
<p>우선 RequestBuilder를 따로 분리한 이유는 HTTP 전송 로직 중복을 제거하기 위해서이다. 모든 API에서 공통적으로 필요한 것들을 반복해서 작성하는 것보다는 공통된 로직을 유틸리티 성격의 레이어로 한번 정의하여 SDK에서 API 호출 동작과 타입 시스템을 표준화하여 일관되게 유지할 수 있다.</p>
<p>또한 엔드포인트 코드의 단순화도 있는데 &quot;어떻게 요청을 보내는지&quot;가 아니라 &quot;어떤 API를 호출할 것인가&quot;에 포커스를 두었다. 따라서 http-builder.ts에서는 HTTP 로직의 중앙화를 책임지고 있다고 볼 수 있다.</p>
<pre><code class="language-ts">src/http-builder.ts
export type HttpResponse&lt;T&gt; =
  | { isSuccess: true; data: T; statusCode: number }
  | {
      isSuccess: false;
      error: { statusCode: number; message: string; body?: unknown };
    };

export class RequestBuilder {
  constructor(private opts: BuilderOptions) {}

  path(p: string): RequestBuilder {
    return new RequestBuilder({ ...this.opts, path: p });
  }

  async get&lt;T&gt;(): Promise&lt;HttpResponse&lt;T&gt;&gt; {
    return this.send&lt;T&gt;(&quot;GET&quot;);
  }

  private async send&lt;T&gt;(method: string): Promise&lt;HttpResponse&lt;T&gt;&gt; {
    const res = await fetch(/* ... */);

    if (!res.ok) {
      return {
        isSuccess: false,
        error: {
          statusCode: res.status,
          message: res.statusText,
        },
      };
    }

    return {
      isSuccess: true,
      statusCode: res.status,
      data: (await res.json()) as T,
    };
  }
}</code></pre>
<p>다음은 서버 상태를 반환하는 간단한 엔드포인트의 일부분이다.</p>
<pre><code class="language-ts">// src/endpoints/health.ts
import { RequestBuilder, type HttpResponse } from &#39;../http-builder.js&#39;;

export interface HealthResponse {
  ok: boolean;
}

export class HealthApi {
  constructor(private rb: RequestBuilder) {}

  // 여기 아래 있는 주석의 내용이 프론트 단에서 해당 API에 대해서 확인할 수 있는 내용
  /**
   * 서버의 헬스 상태를 확인합니다.
   * @returns 헬스 체크 결과
   * @example
   * const response = await client.health.get();
   * console.log(response.data);
   * // Output:
   * {
   *   ok: true
   * }
   */
  get(): Promise&lt;HttpResponse&lt;HealthResponse&gt;&gt; {
    return this.rb.path(&#39;/healthz&#39;).get&lt;HealthResponse&gt;();
  }
}</code></pre>
<p>마지막으로 index.ts 부분인데 SDK 내부에는 다양한 구현 세부사항이 존재하지만, 이 파일을 통해 SDK 사용자가 접근할 수 있는 기능과 타입만을 명시적으로 노출할 수 있다. (SDK를 사용하는 입장에서는 굳이 내부 파일 구조를 알 필요가 없다. 또한 이 파일에서 export되지 않은 요소는 SDK 내부 구현으로 사용자에게 직접적으로 제공되지 않아 책임 범위를 명확하게 할 수 있다)</p>
<pre><code class="language-ts">// Barrel exports: 공개 API만 노출
export { createGraphNodeClient, GraphNodeClient } from &#39;./client.js&#39;;

// Endpoints
export { HealthApi } from &#39;./endpoints/health.js&#39;;

// Types
export type { MeResponseDto, UserProfileDto } from &#39;./types/me.js&#39;;</code></pre>
<h2 id="sdk-배포하기">SDK 배포하기</h2>
<h3 id="1-sdk-초기화-및-의존성설치">1. SDK 초기화 및 의존성설치</h3>
<pre><code class="language-bash">cd sdk_dir
npm init -y  # package.json 생성</code></pre>
<h3 id="2-배포-파일-설정">2. 배포 파일 설정</h3>
<p>tsconfig.json에서 NodeNext 대신 ESNext+Bundler를 사용할 수 있는데 이 경우 별도의 tsup 의존성 설치가 필요하다.</p>
<pre><code class="language-json"># tsconfig.json
{
  &quot;compilerOptions&quot;: {
    &quot;target&quot;: &quot;ES2020&quot;,
    &quot;module&quot;: &quot;NodeNext&quot;, // ESNext
    &quot;moduleResolution&quot;: &quot;NodeNext&quot;, // Bundler
    &quot;declaration&quot;: true,
    &quot;declarationMap&quot;: true,
    &quot;emitDeclarationOnly&quot;: false,
    &quot;outDir&quot;: &quot;dist&quot;,
    &quot;strict&quot;: true,
    &quot;skipLibCheck&quot;: true
  },
  &quot;include&quot;: [&quot;src&quot;]
}

# package.json
{
  // ...
  &quot;type&quot;: &quot;module&quot;,
  &quot;main&quot;: &quot;./dist/index.js&quot;,
  &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: {
      &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/index.js&quot;,
      &quot;require&quot;: &quot;./dist/index.js&quot;
    }
  },
  &quot;files&quot;: [
    &quot;dist&quot;,
    &quot;README.md&quot;],
  &quot;sideEffects&quot;: false,
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;tsc -p tsconfig.json&quot;,
    &quot;prepublishOnly&quot;: &quot;npm run build&quot;
  },
  &quot;engines&quot;: {
    &quot;node&quot;: &quot;&gt;=18&quot;
  },
  &quot;license&quot;: &quot;MIT&quot;,
  &quot;publishConfig&quot;: {
    &quot;access&quot;: &quot;public&quot;
  },
  &quot;devDependencies&quot;: {
    &quot;@types/node&quot;: &quot;^24.10.0&quot;,
    &quot;typescript&quot;: &quot;^5.9.3&quot;
  }</code></pre>
<h4 id="nodenext와-esnext-그리고-nodenext와-bundler의-차이">NodeNext와 ESNext 그리고 NodeNext와 bundler의 차이</h4>
<p>Typescript를 사용하면서 프로젝트에서는 tsconfig.json파일을 필요로 한다. 이때 다양한 설정 옵션들이 있는데 여기서 다루는 옵션 2개를 간단하게 설명하면 아래와 같다.</p>
<ul>
<li>module: .ts파일이 .js로 변환될 때 어떤 모듈 문법으로 변환될 것인지 정하는 방식 (타입스크립트는 기본적으로 컴파일 단계에서 자바스크립트로 변환 후 실행된다)</li>
<li>moduleResolution: 타입스크립트가 모듈을 찾고, 해석하는 방식<blockquote>
<p><a href="https://velog.io/@jihyeong00/tsconfig-%EC%A0%95%EB%A6%AC">tsconfig.json</a>과 <a href="https://ironist-tapkim.tistory.com/25">Module Resolution</a>에 관한 다른 분이 쓴 글인데 좋은 것 같습니다.</p>
</blockquote>
</li>
</ul>
<p>module에는 여러가지 옵션이 있는데 여기서 다루는 옵션 2개를 간단하게 설명하면 아래와 같다.</p>
<ul>
<li>ESNext: TS가 ESM을 출력한다.</li>
<li>NodeNext: TS가 Node의 규칙을 반영해서 ESM/CJS를 다룬다
따라서 ESNext를 사용했을 때는 Node 런타임에서 모듈 로딩이 실패할 수 있는데(ESNext가 Node 런타임에서의 모듈 해석 규칙을 엄격히 강제하지 않음), 이것을 번들러(Vite, Webpack)에서 처리할 것을 전제로 하고 사용을 한다.<pre><code class="language-ts">export { HealthApi } from &#39;./endpoints/health.js&#39;;</code></pre>
즉 NodeNext를 사용하면 Node ESM 런타임 규칙에 맞게 모듈 import시 실제 파일 확장자를 명시해야한다. 번들러를 사용하는 경우, 이런 문제를 알아서 해결해주기 때문에 ESNext에서는 다음과 같이 사용할 수 있다.<pre><code class="language-ts">export { HealthApi } from &#39;./endpoints/health&#39;;</code></pre>
NodeNext환경에서 위와 같이 사용한다면 런타임에서 모듈 로딩이 실패한다.</li>
</ul>
<p>moduleResolution에도 여러가지 옵션이 있는데 여기서 다루는 옵션 2개를 간단하게 설명하면 아래와 같다.</p>
<ul>
<li>NodeNext: Node 런타임이 실제로 모듈을 찾는 방식을 사용</li>
<li>bundler: Vite나 Webpack 등 번들러에 맞춘 해석 방식
쉽게 말해 <code>import</code>를 어떻게 찾을 것인지 방식을 정하는 것이다.</li>
</ul>
<h3 id="3-배포하기">3. 배포하기</h3>
<p>npm에서는 dist를 포함하지만 git에서는 <code>dist</code>, <code>js</code>파일은 ignore해주면 된다.</p>
<pre><code class="language-bash">npm run build
npm login
npm publish</code></pre>
<h1 id="프론트에서-api-dto를-활용하는-법">프론트에서 API DTO를 활용하는 법</h1>
<p>NestJS나 Express.js처럼 백엔드도 TS를 사용한다면, API 요청/응답 DTO 타입을 SDK로 함께 배포하여 프론트에서도 그대로 사용할 수 있다. 이렇게 사용할 경우 프론트에서는 타입을 중복해서 정의할 필요 없으며, 프론트와 백엔드 간의 타입 불일치 가능성을 크게 줄일 수 있다.</p>
<p>그렇지만 프론트에서 화면 상태나 파생 데이터처럼 서버에는 존재하지 않는 데이터를 다루거나, UI 요구사항에 따라 DTO 타입을 그대로 사용하기 어려울 때가 있다. 이러한 경우 프론트 전용 도메인 타입을 별도로 정의하고, 백엔드에서 제공한 API DTO를 도메인 타입으로 변환해 사용하는 방식이 확장성과 유지보수 측면에서 더 적합하다.</p>
<pre><code class="language-ts">//  서버 Type
export interface MeResponseDto {
    user_id: string;
    profile: UserProfileDto;
    created_at: string;
}

//  src/domain/me.model.ts
export interface Me {
    id: string;
    profile: UserProfile; // UserProfile Mapping 생략...
    createdAt: Date;
    expandType?: boolean; // optional
}

//  src/mapper/me.mapper.ts
import { MeResponseDto} from &quot;@taco_tsinghua/graphnode-sdk/types&quot;;
import { Me } from &quot;@/src/domain/me.model.ts&quot;;

export function toModelMe(dto: MeResponseDTO): Me {
  return {
    id: dto.user_id,
    profile: dto.profile,
    createdAt: dto.created_at
      ? new Date(dto.created_at)
      : new Date(Date.now()),
    // expandType: false (optional)
  };
}</code></pre>
<h1 id="마무리">마무리</h1>
<p>전에 인턴을 했을 때는 백엔드를 NestJS를 사용해서 Nestia를 통해 SDK를 구현했었다. 따라서 Nestia를 통해 배포한 SDK쪽 코드를 봤을 때는 Nestia가 프레임워크 차원에서 자동화를 해준다. </p>
<p>Express.js는 개발자가 직접 설계를 해야하는 단점이 있지만, 대신 구현하는 것에 있어서 더 높은 자유도를 가질 수 있다. 예를 들자면 HttpError파일에서 throw로 예외 처리할 때가 있다. (http-builder부분도 마찬가지이다)</p>
<pre><code class="language-ts">// NestJS에서 Nestia를 사용한 SDK에서의 HttpError 관리 파일
export { HttpError } from &quot;@nestia/fetcher&quot;; // 이거 한 줄로 끝

// express.js에서는 직접 표준 Error 클래스를 정의함
export class HttpError extends Error {
  readonly status: number;
  readonly body: unknown;
  readonly headers: Headers;

  constructor(
    status: number,
    body: unknown,
    headers: Headers
  ) {
    super(`HTTP ${status}`);
    this.name = &quot;HttpError&quot;;
    this.status = status;
    this.body = body;
    this.headers = headers;
  }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[무엇이 좋은 자동 저장 방식인가? (프론트엔드에서 transaction outbox 패턴을 응용해서 로컬 DB와 서버 DB 데이터 동기화 구현하기)]]></title>
            <link>https://velog.io/@yoy0z-maps/%EB%AC%B4%EC%97%87%EC%9D%B4-%EC%A2%8B%EC%9D%80-%EC%9E%90%EB%8F%99-%EC%A0%80%EC%9E%A5-%EB%B0%A9%EC%8B%9D%EC%9D%B8%EA%B0%80-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-transaction-outbox-%ED%8C%A8%ED%84%B4%EC%9D%84-%EC%9D%91%EC%9A%A9%ED%95%B4%EC%84%9C-%EB%A1%9C%EC%BB%AC-DB%EC%99%80-%EC%84%9C%EB%B2%84-DB-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%8F%99%EA%B8%B0%ED%99%94-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yoy0z-maps/%EB%AC%B4%EC%97%87%EC%9D%B4-%EC%A2%8B%EC%9D%80-%EC%9E%90%EB%8F%99-%EC%A0%80%EC%9E%A5-%EB%B0%A9%EC%8B%9D%EC%9D%B8%EA%B0%80-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-transaction-outbox-%ED%8C%A8%ED%84%B4%EC%9D%84-%EC%9D%91%EC%9A%A9%ED%95%B4%EC%84%9C-%EB%A1%9C%EC%BB%AC-DB%EC%99%80-%EC%84%9C%EB%B2%84-DB-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%8F%99%EA%B8%B0%ED%99%94-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 16 Dec 2025 10:18:56 GMT</pubDate>
            <description><![CDATA[<p>이번에도 역시 학교 동아리에서 진행하는 프로젝트 개발을 하면서 고민하고 찾아낸 새로운 방법이다. 노션과 같이 앱 내부에 에디터가 존재하고 이 에디터에서 작성한 파일들은 로컬 DB와 서버 DB 두 곳에 모두 저장되어야한다. 로컬 DB를 두는 이유는 오프라인 상태에서 작업을 가능을 위해서, 서버 DB는 작업 변경 내용을 서로 다른 디바이스 간에서 동기화를 가능하게 하기 위해서이다. 그렇다면 &quot;언제 DB에 저장하는 작업을 실행할 것인가&quot;가 수동 저장 그리고 자동 저장 다양한 방면에서 더 좋은 사용자 경험을 위해 고민할 필요가 있었다.</p>
<h1 id="무엇이-좋은-저장-방식인가">무엇이 좋은 저장 방식인가</h1>
<p>가장 원초적인 방식은 수동 저장이다. 이것은 우리가 자주 사용하는 MS Office의 저장방식과 동일하다. 앱을 닫기 전에 변경 사항이 있으면 유저에게 저장을 할 것인지 물어보는 팝업과 버튼을 보여주거나, 아니면 유저가 Ctrl(cmd) + s와 같은 단축키를 통해 수동으로 저장하는 방식이 있다. 가장 직관적인 저장 방식이지만 유저가 직접 해야한다는 비용이 있다. 따라서 더 편리하고 저렴한 비용의 저장방식을 사용자들은 기대했다. 즉, 수동 저장 없이 노션과 같이 무언가를 적어두었다면 종이에 적어둔 것처럼 다시 앱을 열었을 때 내가 적어둔 내용을 그대로 볼 수 있어야했다.</p>
<blockquote>
<p>물론 수동 저장 방식은 사용자가 반드시 의식적으로 확인해야하는 경우 채택된다. (예를 들면 사용자가 비밀번호를 변경할 때 해당 비밀번호는 자동저장되지 않고, 유저가 확인 버튼을 눌러야 저장된다)</p>
</blockquote>
<h3 id="모든-입력마다-저장하면-어떨까">모든 입력마다 저장하면 어떨까?</h3>
<p>누구나 생각해볼 수 있는 방법이라고 생각한다. 사용자의 키 이벤트 하나 하나에 CRUD가 발생하고, 프론트엔드와 백엔드를 지속적으로 동기화할 수 있다. 어떻게 보면 데이터 손실을 가장 최소화할 수 있는 방식이다. 그렇지만 그러면 API를 호출이 엄청 많이 발생할 것이고, 앱도 느려지며, 서버 비용도 엄청 많이 나올 것이다.</p>
<p>그렇다면 대부분의 실무에서는 어떤 방식을 사용할까? Notion이나 Google Docs와 같이 문서편집 어플리케이션은 다양한 상황(예를 들자면 빠른 종료, 네트워크 이슈, 앱 크래시 등)을 어떻게 커버를 할까? 여러 글들을 찾아보면서 좋은 방법들을 찾을 수 있었다.</p>
<blockquote>
<p><a href="https://medium.com/%40brooklyndippo/to-save-or-to-autosave-autosaving-patterns-in-modern-web-applications-39c26061aa6b">To save or to autosave: Autosaving patterns in modern web applications</a>
<a href="https://ux.stackexchange.com/questions/143481/editors-autosave-ux">StackExchange: Editor&#39;s autosave UX</a>
<a href="https://www.dhiwise.com/post/implementing-auto-save-on-forms">Implementing Auto-Save Functionality on a Form - A Detailed Guide</a></p>
</blockquote>
<h3 id="옵션1-일정-시간-간격으로-자동-저장하기">옵션1) 일정 시간 간격으로 자동 저장하기</h3>
<p>이 방법은 특정한 작업 주기 상수 N(N &gt; 0)초을 정의하고, N의 시간이 흐를 때마다 저장을 하는 것이다. 이 방법의 장단점은 다음과 같다.</p>
<p>장점</p>
<ul>
<li>구현이 매우 간단하다</li>
<li>저장 타이밍이 예측 가능하다</li>
</ul>
<p>단점</p>
<ul>
<li>특정 경우 데이터가 손실될 수 있다(사용자가 붙여넣기 후 바로 앱을 종료했을 경우)</li>
<li>N초간 아무런 변화가 없어도 계속 저장 요청이 발생한다</li>
<li>신뢰성을 높이려면 N의 값이 작아야하는데, N이 작을 수록 비용이 많이 든다</li>
</ul>
<h3 id="옵션2-사용자의-입력을-트리거하여-입력-종료-후-저장">옵션2) 사용자의 입력을 트리거하여 입력 종료 후 저장</h3>
<p>이 방법은 상수 N(N &gt; 0)초을 정의하고, 특정 작업이 발생 후 N의 시간이 흐를 때마다 저장을 하는 것이다. 이러한 작업을 <a href="https://developer.mozilla.org/ko/docs/Glossary/Debounce">디바운싱</a>이라고 한다. 이 방법의 장단점은 다음과 같다.</p>
<p>장점</p>
<ul>
<li>실제로 의미 있는 시점에 저장이 가능하여 효율적이다</li>
<li>데이터 손실 가능성이 적다</li>
</ul>
<p>단점</p>
<ul>
<li>빠른 종료 상황에서는 여전히 데이터 손실의 리스크가 존재한다</li>
</ul>
<h3 id="옵션3-포커스-이동-시-저장">옵션3) 포커스 이동 시 저장</h3>
<p>현재 입력하는데 사용되는 필드의 포커스가 해제되거나 변경됐을 때 저장을 하는 것이다. 이 방법의 장단점은 다음과 같다.</p>
<p>장점</p>
<ul>
<li>서버 요청 방면에 있어서 가장 효율적인 방법이다.</li>
</ul>
<p>단점</p>
<ul>
<li>포커스가 유지된 상태에서 오류가 발생하면 수정된 데이터가 전부 손실될 수 있다.</li>
</ul>
<p>유저가 글을 빠르게 붙여넣고, 매우 빠르게 앱을 닫는 경우까지 고려를 하고 자동 저장을 설계하기에는 너무 복잡하였기 때문에 이 경우는 배제를 하고 옵션2와 옵션3을 합쳐서 다음과 같은 자동 저장 방식을 구현하기로 하였다.</p>
<ol>
<li><p>사용자의 입력 후 1.5초의 시간 동안 추가적인 입력이 없을 경우 자동 저장을 한다.</p>
</li>
<li><p>사용자가 입력하는 공간의 포커스가 해제되거나, 라우트 이동시 자동 저장을 한다.</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
 // 다른 코드 생략...
 // 1.5초의 시간 후 자동 저장
 saveTimeoutRef.current = setTimeout(async () =&gt; {
     try {
       let saved = false;

       // 아래 ID 조건을 사용하는 이유는 다이나믹 라우트를 사용하여 params가 null일 때는 새로운 파일을 생성하는 것으로, params.id가 있을 경우 해당 id에 맞는 파일을 수정하는 것으로 판단합니다.
       // ID가 있을 경우 업데이트 
       if (currentNoteId) {
         await noteRepo.updateNoteById(currentNoteId, markdown);
         queryClient.invalidateQueries({ queryKey: [&quot;notes&quot;] });
         saved = true;
       }
       // ID가 없을 경우 생성
       else {
         if (markdown.trim().length &gt; 0 &amp;&amp; !isInitializingRef.current) {
           const newNote = await noteRepo.create(markdown);
           setCurrentNoteId(newNote.id);
           lastEditedNoteIdRef.current = newNote.id;
           queryClient.invalidateQueries({ queryKey: [&quot;notes&quot;] });
           saved = true;
         }
       }

       // 저장 상태 UI 업데이트...
       if (saved) {
         setSaveStatus(&quot;saved&quot;);
         isDirtyRef.current = false;
         if (savedTimeoutRef.current) {
           clearTimeout(savedTimeoutRef.current);
         }
         savedTimeoutRef.current = setTimeout(() =&gt; {
           setSaveStatus(null);
         }, 1500);
       } else {
         setSaveStatus(null);
       }
     } catch (error) {
       console.error(&quot;Failed to save note:&quot;, error);
       setSaveStatus(null);
     }
   }, 2000);
});

useEffect(() =&gt; {
 const onVisibility = () =&gt; {
   if (document.visibilityState === &quot;hidden&quot;) {
     if (saveTimeoutRef.current) {
       clearTimeout(saveTimeoutRef.current);
       saveTimeoutRef.current = null;
     }
     void flushSave();
   }
 };

 document.addEventListener(&quot;visibilitychange&quot;, onVisibility);
 return () =&gt; document.removeEventListener(&quot;visibilitychange&quot;, onVisibility);
}, [currentNoteId]);</code></pre>
</li>
</ol>
<h1 id="문제점">문제점</h1>
<p>우리의 앱이 서버 DB만 사용하거나, 로컬 DB만을 사용하거나 한쪽의 DB만 사용한다면 괜찮지만 양쪽의 DB를 모두 사용하고 동기화를 목표로 하고 있기 때문에 위의 방법으로도 해결할 수 없는 문제점이있었다. </p>
<ol>
<li>사용자가 오프라인에서 작업 중이라면, 로컬 DB에 저장하는 동시에 서버 DB에 저장하여 동기화할 수 없다.</li>
<li>사용자가 온라인에서 작업 중이었지만 백엔드 DB에 저장하기 위해 API를 호출했을 때 갑작스러운 네트워크 에러가 발생한다면 데이터 불일치가 발생할 수 있다.</li>
</ol>
<h2 id="transaction-outbox-pattern">Transaction Outbox Pattern</h2>
<p>이 패턴은 분산 시스템에서 발생하는 이중 쓰기 작업 문제를 해결하기 위해 설계된 패턴이다. 이중 쓰기 작업은 애플리케이션이 서로 다른 두 시스템에 데이터를 쓸 때 발생한다.</p>
<blockquote>
<p>[참고] AWS: <a href="https://docs.aws.amazon.com/ko_kr/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html">트랜잭션 아웃박스 패턴</a></p>
</blockquote>
<p>구매 시스템을 예로 들자면, 주문 서비스는 새로운 주문을 DB에 저장하고, 동시에 메시지 브로커를 통해 주문 생성 이벤트를 발행한다. 만약 DB 저장은 성공했지만 이벤트 발행이 실패한다면, 주문 데이터는 존재하지만 결제·재고·알림 서비스는 이를 인지하지 못하는 상태가 된다. 이 경우 오류 로그조차 남지 않아 시스템은 조용히 불일치 상태에 빠지게 된다. 따라서 주문 서비스는 DB에 주문 저장과 주문 저장 이벤트 발행 이 두개의 작업이 같이 성공해야 의미가 있다. 이에 이를 하나의 논리적 작업으로 다루기 위해 트랜잭션 아웃박스 패턴이 사용된다.</p>
<p>따라서 주문 생성이라는 작업은 DB 저장과 이벤트 발행이 모두 성공해야 의미가 있으며, 이를 하나의 논리적 작업으로 다루기 위해 트랜잭션 아웃박스 패턴이 사용된다.</p>
<p>이 개념을 현재 개발 중인 애플리케이션에 적용하면, 에디터에서의 수정은 로컬 DB에 반영되는 동시에 백엔드 API를 통해 서버 DB에도 반영되어야 한다. 이는 전형적인 분산 시스템은 아니지만, 오프라인 환경과 네트워크 불확실성으로 인해 본질적으로 동일한 이중 쓰기 문제를 갖는다.</p>
<p>즉, 로컬 DB 수정과 서버 DB 반영이 모두 완료되어야 “저장이 완료되었다”고 말할 수 있으며, 이 중 하나라도 실패하면 시스템은 불완전한 상태가 된다. 이를 해결하기 위해 변경 내용을 즉시 서버로 보내지 않고, “변경됨”이라는 의도를 로컬 Outbox에 데이터로 저장해 두었다가, 네트워크가 복구되면 해당 변경을 재시도함으로써 최종적 일관성을 보장할 수 있다.</p>
<p>이러한 구조에서는 “변경됨”이라는 데이터가 존재하는 한 시스템은 아직 처리되지 않은 작업이 있음을 인지할 수 있으며, 별도의 분산 트랜잭션이나 강한 롤백 메커니즘 없이도 안정적인 동기화가 가능하다.
<img src="https://velog.velcdn.com/images/yoy0z-maps/post/d0929ea0-3c34-46df-aae7-ac91870fc364/image.png" alt=""></p>
<h3 id="transaction-outbox-pattern-구현하기1---indexed-db에-테이블-정의하기">transaction outbox pattern 구현하기(1) - Indexed DB에 테이블 정의하기</h3>
<p>우선 로컬 DB는 Indexed DB를 사용하였다. Electron이 React를 통해서 웹기반으로 돌아가는 Desktop App이지만 local storage를 사용하면 쿼리를 통한 검색이나 인덱스 조회가 불가능하거나, 메우 적은 용량 제한이 있고, 무엇보다 비동기가 아닌 동기식이라 실시간으로 수정이 일어나고, 해당 수정이 UI에 반영되야하는 에디터에서는 부적합하였다. 따라서 Indexed DB(dexie.js: Indexed DB의 Wrapper Library)를 사용하였다.</p>
<p>dexie.js를 통해 Indexed DB에 테이블을 추가할 때 아래와 같이 하면 된다.</p>
<pre><code class="language-ts">// src/types/Outbox.ts
export type OutboxOpType =
  | &quot;note.create&quot;
  | &quot;note.update&quot;
  | &quot;note.move&quot;
  | &quot;note.delete&quot;;

export type OutboxOp = {
  opId: string;
  entityId: string;
  type: OutboxOpType;

  payload: any;
  status: &quot;pending&quot; | &quot;processing&quot;;

  retryCount: number;
  nextRetryAt: number;

  createdAt: number;
  updatedAt: number;
  lastError?: string;
};

// src/db/app.db.ts
import Dexie, { Table } from &quot;dexie&quot;;

export class ChatDB extends Dexie {
  // Table&lt;T, K&gt; T: 테이블 타입, K: 기본키 타입 (T.id의 타입)
  // 다른 테이블들 생략...
  outbox!: Table&lt;OutboxOp, string&gt;;

  constructor() {
    // 기존 버전들 생략...
    this.version(3).stores({
      // 다른 테이블들 생략
      // 테이블이름: 키본키(opId), 인덱스(entityId, type...) =&gt; 인덱스틑 복합 인덱스 허용
      outbox: &quot;opId, entityId, type, status, createdAt, [status+nextRetryAt]&quot;,
    });
  }
}

export const db = new ChatDB();</code></pre>
<h3 id="transaction-outbox-pattern-구현하기-2---outbox-최적화하기">transaction outbox pattern 구현하기 (2) - outbox 최적화하기</h3>
<p>사실 이 구조는 transaction outbox pattern을 모방한 패턴이라고 봐야한다. 위에서 언급한 AWS 문서를 보면 <a href="https://docs.aws.amazon.com/ko_kr/prescriptive-guidance/latest/cloud-design-patterns/event-sourcing.html#">이벤트 소싱 패턴</a>을 구현해야한다고 한다. 이벤트 소싱의 핵심은 형삭 기억(레거시)이다. 즉 git처럼 과거의 모든 기록이 남아서 감사/추적/복구가 가능하다. 그렇지만 여기서 구현하는 Outbox는 단지 &quot;이 변경 사항을 아직 서버에 요청하지 못했다&quot;를 기억하는 작업 큐일 뿐이다. 따라서 이벤트 소싱과 다르게 과거 이벤트를 수정이나 덮어쓰는 것을 통해 요청을 최적화 할 수 있다.</p>
<p>예를 들면 파일 A를 오프라인에서 수정을 하였다. &quot;동해물과 백두산이&quot;를 입력하고 다음 가사가 생각나지 않아서 몇 초간 생각하는 시간이 흘렀다. 이때 이미 오프라인 상태라 API 호출이 안 되서 outbox에 하나의 큐가 쌓였다. 이때 가사가 생각이 나서 &quot;마르고 닳도록 하느님이 보우하사 우리 나라 만세&quot;를 입력하였다. 이러면 두개의 update outbox큐가 쌓이게 된다. 이것은 비효율적이기 때문에 update가 이렇게 누적될 경우 가장 최근것만 payload를 덮어써서 같은 파일을 수정했다면 한번의 호출로 끝내는 것이 효울적이다.</p>
<pre><code class="language-ts">// src/managers/outboxRepo.ts
import { db } from &quot;@/db/chat.db&quot;;
import uuid from &quot;@/utils/uuid&quot;;
import type { OutboxOp, OutboxOpType } from &quot;@/types/Outbox&quot;;
import type {
  NoteCreateDto,
  NoteUpdateDto,
} from &quot;@taco_tsinghua/graphnode-sdk&quot;;

/**
 * Coalesce 기준
 * (A) note.delete enqueue 시: 해당 noteId의 기존 pending op(create/update/move)를 전부 제거하고 delete만 남김
 * (B) note.create가 pending이면: 이후 update/move는 create payload에 흡수(merge)하고 새 op 만들지 않음
 * (C) note.update는 noteId당 1개만 유지: 이미 있으면 payload 덮어쓰기
 * (D) note.move도 NoteUpdateDto로 처리하며 noteId당 1개만 유지: 이미 있으면 payload 덮어쓰기
 */
export const outboxRepo = {
  async enqueueNoteCreate(noteId: string, payload: NoteCreateDto) {
    await enqueueWithCoalesce(&quot;note.create&quot;, noteId, payload);
  },

  async enqueueNoteUpdate(noteId: string, payload: NoteUpdateDto) {
    await enqueueWithCoalesce(&quot;note.update&quot;, noteId, payload);
  },

  async enqueueNoteMove(noteId: string, payload: NoteUpdateDto) {
    await enqueueWithCoalesce(&quot;note.move&quot;, noteId, payload);
  },

  async enqueueNoteDelete(noteId: string) {
    await enqueueWithCoalesce(&quot;note.delete&quot;, noteId, null);
  },
};

async function enqueueWithCoalesce(
  type: OutboxOpType,
  entityId: string,
  payload: any
) {
  const now = Date.now();

  if (!entityId) {
    throw new Error(&quot;entityId is required&quot;);
  }

  await db.transaction(&quot;rw&quot;, db.outbox, async () =&gt; {
    // (A) delete: 관련 op 정리 후 delete만 남김
    if (type === &quot;note.delete&quot;) {
      const related = await db.outbox
        .where(&quot;entityId&quot;)
        .equals(entityId)
        .toArray();
      const pendingOnly = related.filter((r) =&gt; r.status === &quot;pending&quot;);
      if (pendingOnly.length) {
        await db.outbox.bulkDelete(pendingOnly.map((r) =&gt; r.opId));
      }
      await db.outbox.put(
        makeOp(entityId, &quot;note.delete&quot;, { id: entityId }, now)
      );
      return;
    }

    // (B) create가 이미 pending이면: create payload에 update/move를 흡수
    const pendingCreate = await db.outbox
      .where({
        entityId,
        type: &quot;note.create&quot; as const,
        status: &quot;pending&quot; as const,
      })
      .first();

    if (pendingCreate) {
      const merged = mergeIntoCreatePayload(
        pendingCreate.payload as NoteCreateDto,
        type,
        payload
      );
      await db.outbox.update(pendingCreate.opId, {
        payload: merged,
        status: &quot;pending&quot;,
        nextRetryAt: now,
        updatedAt: now,
        lastError: undefined,
      });
      return;
    }

    // (C) update/move는 noteId당 1개로 coalesce
    if (type === &quot;note.update&quot; || type === &quot;note.move&quot;) {
      const existing = await db.outbox
        .where({ entityId, type: type as any, status: &quot;pending&quot; as const })
        .first();

      if (existing) {
        await db.outbox.update(existing.opId, {
          payload,
          status: &quot;pending&quot;,
          nextRetryAt: now,
          updatedAt: now,
          lastError: undefined,
        });
        return;
      }

      await db.outbox.put(makeOp(entityId, type, payload, now));
      return;
    }

    // (D) create가 없으면 create는 그대로 enqueue
    if (type === &quot;note.create&quot;) {
      await db.outbox.put(makeOp(entityId, &quot;note.create&quot;, payload, now));
      return;
    }

    // fallback
    await db.outbox.put(makeOp(entityId, type, payload, now));
  });
}

function makeOp(
  entityId: string,
  type: OutboxOpType,
  payload: any,
  now: number
): OutboxOp {
  return {
    opId: uuid(),
    entityId,
    type,
    payload,
    status: &quot;pending&quot;,
    retryCount: 0,
    nextRetryAt: now,
    createdAt: now,
    updatedAt: now,
  };
}

function mergeIntoCreatePayload(
  existing: NoteCreateDto,
  incomingType: OutboxOpType,
  incomingPayload: any
): NoteCreateDto {
  if (incomingType === &quot;note.update&quot; || incomingType === &quot;note.move&quot;) {
    const u = incomingPayload as NoteUpdateDto;
    return {
      id: existing.id,
      content: u.content ?? existing.content,
      title: u.title ?? existing.title,
      folderId: u.folderId ?? existing.folderId ?? null,
    };
  }
  return existing;
}
</code></pre>
<h3 id="transaction-outbox-pattern-구현하기-3---로컬-db-crud-작업과-트랜잭션으로-하나의-작업으로-묶기">transaction outbox pattern 구현하기 (3) - 로컬 DB CRUD 작업과 트랜잭션으로 하나의 작업으로 묶기</h3>
<p>여기서 중요한 것은 db.transaction으로 로컬 DB의 작업과 outbox 큐를 쌓는 작업을 하나의 작업으로 묶어야한다는 것이다. 즉 로컬 DB 작업이 실패했는데 서버 요청 작업 대기열 outbox 큐는 쌓이면 불일치하게 로컬과 서버의 정보가 불일치하게 된다.</p>
<pre><code class="language-ts">// src/managers/noteRepo.ts
import { db } from &quot;@/db/chat.db&quot;;
import { Note } from &quot;@/types/Note&quot;;
import extractTitleFromMarkdown from &quot;@/utils/extractTitleFromMarkdown&quot;;
import uuid from &quot;@/utils/uuid&quot;;
import { outboxRepo } from &quot;./outboxRepo&quot;;

export const noteRepo = {
  async create(content: string, folderId: string | null = null): Promise&lt;Note&gt; {
    const newNote: Note = {
      id: uuid(),
      title: extractTitleFromMarkdown(content),
      content,
      folderId,
      updatedAt: new Date(Date.now()),
      createdAt: new Date(Date.now()),
    };

    // transaction 안에서 실행되는 DB 작업은 전부 성공 또는 전부 실패 (rw = read write, 접근할 테이블 목록 전부 명시)
    await db.transaction(&quot;rw&quot;, db.notes, db.outbox, async () =&gt; {
      await db.notes.put(newNote);

      await outboxRepo.enqueueNoteCreate(newNote.id, {
        id: newNote.id,
        title: newNote.title,
        content: newNote.content,
        folderId: newNote.folderId,
      });
    });

    return newNote;
  },
</code></pre>
<h3 id="자동저장하기1---주기적으로-outbox-비우기">자동저장하기(1) - 주기적으로 outbox 비우기</h3>
<p>쌓여있는 outbox 큐를 소비를 해야하는 유틸 함수가 하나 필요하다.</p>
<ol>
<li>processing인데 60초 이상 처리 중인 outbox는 오류가 있음으로 보고 status를 pending으로 변경한다</li>
<li>outbox의 type에 맞는 API를 호출한다(백엔드는 SDK를 구현해서 사용했습니다).</li>
<li>API 호출에 성공한 outbox 큐는 제거를 하며, 실패한 큐는 상태를 변경하여 다시 대기열에 반영을 한다.<pre><code class="language-ts">// src/managers/syncWorker.ts
import { db } from &quot;@/db/chat.db&quot;;
import { api } from &quot;@/apiClient&quot;;
import type { OutboxOp } from &quot;@/types/Outbox&quot;;
</code></pre>
</li>
</ol>
<p>let running = false;</p>
<p>export async function syncOnce(limit = 20) {
  if (running) return;
  running = true;</p>
<p>  try {
    const now = Date.now();</p>
<pre><code>// 60초 이상 실패한 작업을 pending으로 변경해서 앱 크래시나 강제 종료로 인한 processing 상태 초기화
await db.outbox
  .where(&quot;status&quot;)
  .equals(&quot;processing&quot;)
  .and((op) =&gt; op.updatedAt &lt; now - 60_000)
  .modify({
    status: &quot;pending&quot;,
    nextRetryAt: now,
    updatedAt: now,
  });

// status가 pending이고 nextRetryAt이 현재 시간보다 작은 작업을 limit개만 가져옴
const ops = await db.outbox
  .where(&quot;[status+nextRetryAt]&quot;)
  .between([&quot;pending&quot;, 0], [&quot;pending&quot;, now])
  .limit(limit)
  .toArray();

for (const op of ops) {
  await processOp(op);
}</code></pre><p>  } finally {
    running = false;
  }
}</p>
<p>async function processOp(op: OutboxOp) {
  const now = Date.now();</p>
<p>  // 현재 작업 상황 업데이트 =&gt; 같은 탭에서 중복 실행 방지
  await db.outbox.update(op.opId, { status: &quot;processing&quot;, updatedAt: now });</p>
<p>  try {
    switch (op.type) {
      case &quot;note.create&quot;:
        await api.note.createNote(op.payload);
        break;</p>
<pre><code>  case &quot;note.update&quot;:
    await api.note.updateNote(op.entityId, op.payload);
    break;

  case &quot;note.move&quot;:
    await api.note.updateNote(op.entityId, op.payload);
    break;

  case &quot;note.delete&quot;:
    await api.note.deleteNote(op.entityId);
    break;
}

// 작업 성공 후 outbox에서 제거
await db.outbox.delete(op.opId);</code></pre><p>  } catch (e: any) {
    // 작업 실패 후 재시도 횟수 증가 및 지연 시간 계산 및 아웃박스 정보 업데이트
    const retryCount = (op.retryCount ?? 0) + 1;
    const delay = backoffMs(retryCount);</p>
<pre><code>await db.outbox.update(op.opId, {
  status: &quot;pending&quot;,
  retryCount,
  nextRetryAt: now + delay,
  updatedAt: now,
  lastError: String(e?.message ?? e),
});</code></pre><p>  }
}</p>
<p>function backoffMs(retryCount: number) {
  // 1s, 2s, 4s, 8s, 16s, 32s, max 60s (+jitter)
  const base = Math.min(60_000, 1000 * 2 ** Math.min(6, retryCount - 1));
  const jitter = Math.floor(Math.random() * 300);
  return base + jitter;
}</p>
<pre><code>

그 다음으로 온라인일 경우 5초마다 주기적으로 outbox 큐를 주기적으로 소비하는 함수를 추가하고, main.tsx에서 호출해주면 된다.
```ts
// src/managers/startSyncLoop.ts
import { syncOnce } from &quot;./syncWorker&quot;;

let started = false;
let timer: number | null = null;

export function startSyncLoop() {
  if (started) return;
  started = true;

  const handleOnline = () =&gt; syncOnce();
  window.addEventListener(&quot;online&quot;, handleOnline);

  timer = window.setInterval(() =&gt; {
    if (navigator.onLine) {
      syncOnce();
    }
  }, 5000);

  return () =&gt; {
    window.removeEventListener(&quot;online&quot;, handleOnline);
    if (timer !== null) {
      clearInterval(timer);
      timer = null;
    }
    started = false;
  };
}

// src/main.tsx
import React from &quot;react&quot;;
import ReactDOM from &quot;react-dom/client&quot;;
import App from &quot;./App&quot;;
import &quot;./index.css&quot;;
import { initI18n } from &quot;./i18n&quot;;
import { QueryClient, QueryClientProvider } from &quot;@tanstack/react-query&quot;;
import { startSyncLoop } from &quot;./managers/startSyncLoop&quot;;

startSyncLoop();

const queryClient = new QueryClient();

(async () =&gt; {
  await initI18n();

  ReactDOM.createRoot(document.getElementById(&quot;root&quot;)!).render(
    &lt;React.StrictMode&gt;
      &lt;QueryClientProvider client={queryClient}&gt;
        &lt;App /&gt;
      &lt;/QueryClientProvider&gt;
    &lt;/React.StrictMode&gt;
  );
})().catch((err) =&gt; {
  console.error(&quot;i18n init failed:&quot;, err);
});</code></pre><h1 id="마무리---오류를-발견하며">마무리 - 오류를 발견하며</h1>
<p>이 작업이 끝나고 회고하며 블로그를 쓰면서 치명적인 단점이 하나있다는 것을 발견했다. 바로 디바이스 A에서 outbox 큐가 전부 처리 안된 상태(특히 update부분)로 앱을 종료를 하고, 다른 디바이스 B에서 앱을 실행해 동일한 파일을 수정을 하고, 해당 outbox가 모두 소비된 상태에서 다시 디바이스 A에서 최신 내역을 받아올 때 기존 outbox가 B에서 수정한 최신 내역을 모두 덮어써버리는 치명적인 오류가 있다.</p>
<p>즉 Outbox 자체의 문제가 아니라 Conflict Resolution 전략이 필요하다. 충돌 자체는 막을 수 없다. 모든 이슈를 고려하면서 충돌을 막기에는 불가능하기 때문에 git처럼 충돌이 일어났을 경우 유저가 충돌을 직접 해결할 수 있는 방법을 제시해야한다. 아직은 구현하지 않았지만 시나리오는 다음과 같다.</p>
<p>우선 핵심 개념은 서버측 파일에 version(정수) 필드룰 두고, update가 성공할 때마다 서버에서 승격시킨다. 프론트에서는 서버에서 내려준 version을 로컬 DB에 저장해 해당 파일의 변경이 어떤 version을 기준으로 만들어졌는지를 나타내는 baseVersion으로 사용한다. </p>
<ol>
<li><p>A에서 오프라인에서 파일을 수정하였다. 수정하였을 때 해당 파일의 버전은 5였고, outbox 큐에 그대로 쌓여있다.</p>
</li>
<li><p>B에서도 동일한 version의 파일을 수정하였고, 이 outbox 큐는 소비가 되었고 서버에는 동기화된 노트의 version은 6 혹은 그 이상이다.</p>
</li>
<li><p>A 디바이스가 온라인 상태로 바뀌었고, 이때 다시 outbox 큐를 소비하기 위해 API를 호출했지만 A에서 작업한 파일의 baseVersion은 5를 기준으로 만들어졌고, 서버의 currentVersion은 7이기 때문에 다르다. 이 경우 백엔드에서는 요청에 reject(409)와 함께 다음과 같은 응답 결과를 준다.</p>
<pre><code class="language-json">{
&quot;error&quot;: &quot;CONFLICT&quot;,
&quot;currentVersion&quot;: 7,
&quot;serverNote&quot;: {
 &quot;id&quot;: &quot;...&quot;,
 &quot;version&quot;: 7,
 &quot;content&quot;: &quot;...&quot;
}
}</code></pre>
</li>
<li><p>프론트에서는 git과 같이 충돌을 해결을 할 수 있는 도구를 제공하고, 유저가 수동으로 충돌을 해결하였다면, 서버가 내려준 최신 버전을 기준으로 다시 update 요청을 보낸다. 이 요청이 성공하면 최종 version은 서버에서 승격된다.</p>
</li>
<li><p>서버에서 버전 승격까지 완료된다면 프론트에 승격된 버전과 opId를 응답으로 준다.</p>
</li>
<li><p>프론트에서 응답을 받고, 로컬 DB에 최신 버전 반영을 했으면 opId를 통해 해당 outbox op를 삭제하는 것을 같은 트랜잭션으로 묶어서 outbox 큐 소비를 완료한다.</p>
<p>즉 충돌 자체를 완전히 없애는 것보다는, 충돌을 명확히 감지하고 데이터 손실이 없는 해결 방법을 제공하는 것이 현실적이다.</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI Agent는 어떻게 작동할까? 그리고 어떻게 구현할 수 있을까?]]></title>
            <link>https://velog.io/@yoy0z-maps/AI-Agent%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9E%91%EB%8F%99%ED%95%A0%EA%B9%8C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B5%AC%ED%98%84%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@yoy0z-maps/AI-Agent%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9E%91%EB%8F%99%ED%95%A0%EA%B9%8C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B5%AC%ED%98%84%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Wed, 10 Dec 2025 06:06:34 GMT</pubDate>
            <description><![CDATA[<p>최근에 학교에 다니면서 인공지능과 컴퓨터 동아리에 풀스택 개발자로 합류하게 되었다. 데모데이가 2주 남은 시점 열린 회의에서 PM이 갑자기 우리 앱에 AI Agent 기능을 추가하자는 의견을 전달해주었다. (아무리 에자일리쉬하게 한다지만 이러한 메이저한 기능은 미리 기획하고 알려주면 좋을텐데 말이다) 아무튼 나 역시 개발할 때 Cursor를 사용하는 사람으로써 늘 이 부분에 대한 궁금증을 갖고 있었다.</p>
<h1 id="how-ai-ide-works">How AI IDE Works?</h1>
<p>Ai Agent를 어떻게 구현할지 전혀 갈피가 안 잡히는 상황에서 매우 좋은 <a href="https://blog.sshh.io/p/how-cursor-ai-ide-works">블로그 글(링크)</a>을 찾아서 볼 수 있었다. Cursor의 구현 과정부터 어떻게 하면 더 효과적으로 잘 사용할 수 있을지 적어둔 매우 좋은 글이었다. Cursor에 막 입문한 사람이 아니더라도 한번쯤은 읽어보면 좋은 글이다.</p>
<p>아무튼 간단하게 요약을 하자면 Cursor와 같은 AI IDE는 다음과 같은 과정을 복잡하게 래핑하여 구현되었다.</p>
<ol>
<li>Fork VSCode</li>
<li>Add a chat UI and pick a good LLM</li>
<li>Implement tools for the coding agent (read_file, write_file, run_command...)</li>
<li>Optimize the internal prompts</li>
</ol>
<p>그리고 가끔 방대한 코드 속에서 특정 기능을 구현했던 코드 파일을 찾아달라고 할 때 파일을 정확하게 찾아주는데, 이것은 역시 내 예상대로 모든 파일을 요청마다 LLM이 읽는 방식이 아니라, <strong>IDE가 전체 코드베이스를 벡터로 인덱싱</strong>하기 때문에 에이전트가 항상 정확한 파일을 찾을 수 있는 것이었다. 물론 이렇게 임베딩을 할 때 어느 정도 수준까지 쪼개서 하는지는 모르겠다(예를 들면 파일, 함수, 클래스... 등 다양한 단위).</p>
<h1 id="ai-agent의-uiux-뜯어보기">AI Agent의 UI/UX 뜯어보기</h1>
<p>개발자이지만 평소에 무엇이 좋은 UI/UX일까 고민을 많이 한고, 좋은 UI/UX 사례나 닐슨노만그룹의 아티클을 읽어보곤한다. 시중에 배포된 다양한 LLM을 이용한 채팅이나 에이전트 서비스들을 사용해보면 몇 가지 UI/UX 공통점이 존재한다. 나는 크게 2가지 공통점이 있다고 느꼈다. (부족한 학생이고 따로 UI/UX를 전공한 것은 아니라 전부 발견하지 못하거나 깊게 이해하지는 못할 수 있다.) 아래 두가지 예시를 준비했다.
<img src="https://velog.velcdn.com/images/yoy0z-maps/post/7d6ab617-bdeb-4efb-b093-56b28b42b817/image.gif" alt="">
<img src="https://velog.velcdn.com/images/yoy0z-maps/post/0b285fbb-6437-4c16-88a3-592cd6e1af63/image.gif" alt=""></p>
<h3 id="기존-소셜-미디어의-채팅과의-차이점1---새롭게-생성된-메시지의-위치">기존 소셜 미디어의 채팅과의 차이점(1) - 새롭게 생성된 메시지의 위치</h3>
<p>일반적인 소셜 미디어의 경우 한명 이상의 유저가 응답을 빠르게 주고 받는 경우가 많아서 새로운 채팅은 화면의 아래에 보이고, 기존 채팅은 그만큼 위로 밀리는 형식이다. 그렇지만 LLM과의 채팅에서는 주로 유저의 질문과 LLM의 답변이 한쌍의 흐름을 이루는 요청과 응답의 패턴이며, 일반적인 유저 사이의 채팅과 다르게 LLM의 답변까지 어느 정도의 시간이 소모된다. 질문과 답변 혹은 요청과 응답이라는 패턴을 이룬다는 점에서 유저가 보낸 텍스트는 스크린 최상단에 위치를 하며, 그 아래 LLM의 답변(응답)이 보여지는 형식이다.</p>
<p><img src="https://velog.velcdn.com/images/yoy0z-maps/post/7ca7a084-1c47-43c5-8d13-9f58dccf7ce4/image.gif" alt=""></p>
<h3 id="기존-소셜-미디어의-채팅과의-차이점2---데이터-형식과-프로세스-공유">기존 소셜 미디어의 채팅과의 차이점(2) - 데이터 형식과 프로세스 공유</h3>
<p>위의 Cursor에서 Agent가 사용자의 명령을 받고, 이해하며, 그 명령에 맞는 결과를 출력하기까지 일반적인 소셜 미디어의 채팅과 달리 응답 완료까지 적지 않은 소요시간이 걸린다. 그리고 사용자는 이 대기 시간에 지루함을 느낀다. 따라서 이러한 대기 시간을 달래기 위해 로딩스피너, 스켈레톤, 프로그레스바 등 다양한 UX 해결책이 제시된 가운데 유명한 LLM 서비스들은 다음과 같은 두가지 방식을 통해 유저의 지루함을 해소하였다. 현재 내가 보낸 응답이 처리되는 과정(상태)를 알려주는 것과 응답 결과를 한번에 보여주는 것이 아닌 스트리밍 데이터를 사용해서 데이터를 빠르게 실시간으로 보내주는 방법이다.</p>
<h1 id="ai-agent-실제로-구현하기">AI Agent 실제로 구현하기</h1>
<p>우리 서비스는 Desktop App이고 개발 환경에 대해 간단하게 설명하면 다음과 같다.</p>
<blockquote>
<p>LLM Provider: OpenAI, DeepSeek
FE: React 19, Electron 38, TypeScript
BE: Express.js 5.1, MongoDB 6.20</p>
</blockquote>
<h3 id="be-sse-구현-및-llm-모델-연결">BE: SSE 구현 및 LLM 모델 연결</h3>
<p>우선 SSE를 사용하기 위해 헬퍼 함수 하나를 만들었다. 백엔드쪽에서 SSE를 Agent만이 아니라 다른 곳에서 확장될 가능성을 고려하였다. 헤더부분의 설정은 클라이언트와 연결을 유지하고, event-stream 데이터를 보내기 위해 꼭 필요한 설정이다.</p>
<p>또한 Named Event설정을 통해 이벤트 종류별로 프론트에서 구분해서 콜백함수로 처리할 수 있다. 여기서 &#39;result&#39;와 같은 이벤트는 SSE에서 정해진 이벤트가 아니고, 백엔드와 프론트가 약속한 커스텀 이벤트 이름을 맞춰서 사용한다.</p>
<pre><code class="language-ts">  function setupSSE(res: express.Response) {
    res.setHeader(&#39;Content-Type&#39;, &#39;text/event-stream; charset=utf-8&#39;);
    res.setHeader(&#39;Cache-Control&#39;, &#39;no-cache, no-transform&#39;);
    res.setHeader(&#39;Connection&#39;, &#39;keep-alive&#39;);
    res.flushHeaders?.();

    const sendEvent = (event: string, data: unknown) =&gt; {
      res.write(`event: ${event}\n`);
      res.write(`data: ${JSON.stringify(data)}\n\n`);
    };

      // SSE - Named Event
      // sendEvent(&#39;result&#39;, {
      //   mode: &#39;summary&#39; as Mode, 이 부분으로 프론트에서 구현한 도구들과 연결
      //   answer: context,
      //   noteContent: null,
      // });


    return { sendEvent };
  }</code></pre>
<p>OpenAI SDK를 사용할 경우 OpenAI LLM 모델의 응답 결과 또한 Stream 형태로 받을 수 있다. <strong>for await...of</strong>에 관한 것은 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of">이 문서</a>를 확인하면 된다. SSE라는 것이 실시간으로 데이터를 보내주는 것이기 때문에 잠깐이라도 프론트에서 네트워크에 장애가 생긴다면 문제가 생길 수 있다. 따라서 최종 응답 결과를 보내주는 것을 통해 데이터의 정확성을 보장하고, 프론트에 응답이 완료 상태를 제공한다. 또한 프론트에서 LLM 응답을 통해 수행할 업무(파일을 수정할지, 단순 채팅인지)를 구분한다.</p>
<pre><code class="language-ts">        const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
          { role: &#39;system&#39;, content: systemPrompt },
          {
            role: &#39;user&#39;,
            content: hasContext ? `[Context]\n${context}\n\n[User]\n${trimmedUser}` : trimmedUser,
          },
        ];

        const stream = await openai.chat.completions.create({
          model: &#39;gpt-4.1-mini&#39;,
          stream: true,
          messages,
        });

        let fullAnswer = &#39;&#39;;

        // Stream에서 새로운 청크를 수신할 때마다 반복문 호출
        for await (const chunk of stream) {
          const delta = chunk.choices[0]?.delta?.content ?? &#39;&#39;;
          if (!delta) continue;
          fullAnswer += delta;
          sendEvent(&#39;chunk&#39;, { text: delta });
        }

        // 최종적으로 전체 응답 전달
        sendEvent(&#39;result&#39;, {
          mode: &#39;chat&#39; as Mode, // 프론트 도구들과 연결
          answer: fullAnswer,
          noteContent: null,
        });

        // Stream 종료
        return res.end();</code></pre>
<p>mode부분에 대해서는 고민이 많았다. LLM이 구현된 도구 중에 어떤 것을 사용할지 판단하는 것 또한 토큰이 소비되기 때문이다. 따라서 이 역시 기존 LLM 서비스에서 참고해서 가져왔다. 아래 사진과 같이 유저에게 가이드 텍스트 버튼을 던져준다.
<img src="https://velog.velcdn.com/images/yoy0z-maps/post/6306ce4a-b3d3-48af-8033-0be144a3e87b/image.png" alt=""></p>
<pre><code class="language-ts">type ModeHint = &#39;summary&#39; | &#39;note&#39; | &#39;auto&#39;;

    const { userMessage, contextText, modeHint } = req.body as {
      userMessage: string;
      contextText?: string;
      modeHint?: ModeHint;
    };</code></pre>
<p>해당 버튼을 사용할 경우 modeHind부분에 &#39;auto&#39;가 아닌 값을 부여하는 것을 통해서, 조건으로 이 경우에는 LLM이 굳이 어떤 도구를 사용할지 판단 과정을 생략하고 유저가 보낸 modeHint의 값을 그대로 최종 응답의 mode에 반영하는 식으로 구현하였다. 다만 텍스트 버튼을 클릭하지 않고, 직접 텍스트를 입력했을 때는 여전히 LLM이 어떤 도구를 사용할지 판단을 해야한다.</p>
<blockquote>
<p><a href="https://itsfuad.medium.com/understanding-server-sent-events-sse-with-node-js-3e881c533081">https://itsfuad.medium.com/understanding-server-sent-events-sse-with-node-js-3e881c533081</a> (Understanding Server-Sent Events (SSE) with Node.js 참고)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/yoy0z-maps/post/913e2ed5-27d4-438c-9e36-bcf5385172cb/image.gif" alt=""></p>
<h3 id="fe-stream-응답-받고-named-events-대응하기">FE: Stream 응답 받고, Named Events 대응하기</h3>
<p>아래는 백엔드 AI Agent를 담당하는 엔드포인트를 호출하는 프론트쪽 함수이다. buffer와 for문쪽을 보면 하드하게 포맷팅하여 문자열을 추출하는데, SSE 응답자체가 단일 텍스트 스트림이다. 따라서 버퍼에서 라인 단위 파싱을 통해 이벤트와 데이터 추출 과정이 필요하다. 더 자세한 것은 <a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format">SSE 공식문서</a>를 보면 알 수 있는데 아무튼 SSE 메시지는 표준 포맷이 고정되어있어서 가능하다.</p>
<pre><code class="language-ts">export async function agentChatStream({
  userMessage,
  contextText,
  callbacks,
}: AgentChatStreamParams): Promise&lt;void&gt; {
  const res = await fetch(`${API_BASE}/v1/agent/chat/stream`, {
    method: &quot;POST&quot;,
    headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
    body: JSON.stringify({
      userMessage,
      contextText: contextText ?? &quot;&quot;,
    }),
  });

  if (!res.ok) {
    // 에러처리(1)
  }

  const reader = res.body?.getReader();
  if (!reader) {
    // 에러처리(2)
  }

  const decoder = new TextDecoder();
  let buffer = &quot;&quot;;

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split(&quot;\n&quot;);
      buffer = lines.pop() || &quot;&quot;;

      let eventType: StreamEventType | null = null;
      let eventData: string | null = null;

      for (const line of lines) {
        if (line.startsWith(&quot;event: &quot;)) {
          eventType = line.slice(7).trim() as StreamEventType;
        } else if (line.startsWith(&quot;data: &quot;)) {
          eventData = line.slice(6).trim();
        } else if (line === &quot;&quot; &amp;&amp; eventType &amp;&amp; eventData) {
          try {
            const data = JSON.parse(eventData);

            switch (eventType) {
              case &quot;status&quot;:
                callbacks.onStatus?.(data as StreamStatusEvent);
                break;
              case &quot;chunk&quot;:
                callbacks.onChunk?.(data as StreamChunkEvent);
                break;
              case &quot;result&quot;: {
                const result = data as StreamResultEvent;
                callbacks.onResult?.(result);
                break;
              }
              case &quot;error&quot;:
                callbacks.onError?.(data as StreamErrorEvent);
                throw new Error((data as StreamErrorEvent).message);
            }
          } catch (e) {
            console.error(&quot;Failed to parse SSE event:&quot;, e, {
              eventType,
              eventData,
            });
          }

          eventType = null;
          eventData = null;
        }
      }
    }
  } finally {
    reader.releaseLock();
  }
}</code></pre>
<p>실제 해당 함수를 호출하는 곳에서는 아래와 같이 콜백 함수를 등록하여 사용할 수 있다.</p>
<pre><code class="language-ts">   await agentChatStream({
        userMessage: instruction, 
        contextText: combinedContent, 
        callbacks: {
          onStatus: (event) {},
          onResult: (event) {
            // if (event.mode === &quot;note&quot;)의 경우 LLM이 전달해 준 컨텍스트로 내부 DB에 작업
            const newNote = await noteRepo.create(cleanedContent);
            queryClient.invalidateQueries({ queryKey: [&quot;notes&quot;] });
          }
</code></pre>
<hr>
<h3 id="fe-ai-agent의-새로운-채팅을-화면-가장-위로-가져오는-ui-구현하기">FE: AI Agent의 새로운 채팅을 화면 가장 위로 가져오는 UI 구현하기</h3>
<p>사용자가 새 메시지를 전송하면 해당 메시지를 뷰포트 최상단 기준점으로 고정하고, 응답은 그 아래에 누적 렌더링한다. 이때 응답이 아직 짧아 화면 하단에 여백이 남는 경우, 남는 높이만큼 스페이서 영역을 하단에 삽입해 최신 턴이 항상 상단에 위치하도록 유지한다. 응답이 스트리밍되며 높이가 증가하면, 턴 블록의 높이 증가량만큼 스페이서 높이를 실시간으로 감소시켜 화면 하단 여백이 자연스럽게 줄어들도록 한다. 이를 통해 새 메시지가 항상 상단에서 시작되고, 응답 확장 시에도 시선 이동이 발생하지 않는 안정적인 읽기 흐름을 제공한다.</p>
<pre><code class="language-tsx">  // 레이아웃 측정/제어용 refs
  const scrollerRef = useRef&lt;HTMLDivElement | null&gt;(null);
  const turnRef = useRef&lt;HTMLDivElement | null&gt;(null);

  const [spacerH, setSpacerH] = useState(0);

  // 최신 유저 메시지를 상단에 보이게 스크롤바 위치 조절
  const scrollTurnToTop = () =&gt; {
    const scroller = scrollerRef.current;
    const turn = turnRef.current;
    if (!scroller || !turn) return;

    scroller.scrollTop = turn.offsetTop;
  };

  // 최신 유저 메시지가 채팅방 가장 상단에 보일 수 있게 하단 스페이서 높이 계산
  const recomputeSpacer = () =&gt; {
    const scroller = scrollerRef.current;
    const turn = turnRef.current;
    if (!scroller || !turn) return;

    const scrollerH = scroller.clientHeight;
    const turnH = turn.offsetHeight;

    const next = Math.max(0, scrollerH - turnH);
    setSpacerH(next);
  };

  // 새로운 메시지가 생기면 위 두 함수 호출
  useLayoutEffect(() =&gt; {
    recomputeSpacer();
    scrollTurnToTop();
  }, [currentUserText, currentAssistantText]);

  // 채팅 화면 사이즈를 유저가 조작했을 때 대응
  useEffect(() =&gt; {
    const onResize = () =&gt; {
      recomputeSpacer();
      scrollTurnToTop();
    };
    window.addEventListener(&quot;resize&quot;, onResize);
    return () =&gt; window.removeEventListener(&quot;resize&quot;, onResize);
  }, []);

  // // 스트리밍으로 오는 텍스트의 height를 계산해서 실시간으로 spacer 높이 조절
  useEffect(() =&gt; {
    const turn = turnRef.current;
    if (!turn) return;

    const ro = new ResizeObserver(() =&gt; {
      recomputeSpacer();
      scrollTurnToTop();
    });
    ro.observe(turn);

    return () =&gt; ro.disconnect();
  }, []);

  return (
    // ...
        // 채팅 부분 UI
        &lt;div ref={scrollerRef} className=&quot;flex-1 overflow-y-auto&quot;&gt;
          &lt;div className=&quot;min-h-full flex flex-col px-4 py-3&quot;&gt;
            &lt;div&gt;
              {history.map((m) =&gt; (
                &lt;MessageBubble key={m.id} m={m} /&gt;
              ))}
            &lt;/div&gt;
            &lt;div ref={turnRef} className=&quot;pt-2&quot;&gt;
              &lt;TurnBlock
                userText={currentUserText}
                assistantText={currentAssistantText}
              /&gt;
            &lt;/div&gt;
            &lt;div style={{ height: spacerH }} className=&quot;bg-transparent&quot; /&gt;
          &lt;/div&gt;
        &lt;/div&gt;</code></pre>
<p>여기서 중요한 점은 <code>useLayoutEffect</code>를 사용하여서 DOM이 업데이트된 후, 화면에 랜더링하기 전에 실행을 하는 것이다. <code>useEffect</code>와의 차이점을 간단하게 정리하자면 아래와 같다.</p>
<ul>
<li>useEffect: 비동기, 렌더링이 끝나고 콜백 함수를 실행</li>
<li>useLayoutEffect: 동기, 렌더링 전에 콜백 함수를 실행</li>
</ul>
<p>즉 <code>recomputeSpacer()</code>는 DOM을 측정하는 작업이고, 측정 결과로 <code>spacerH</code>를 설정하며 <code>scrollTop</code>을 변경한다. 따라서 랜더링 전에 측정과 조정 작업이 끝나야 사용자가 잘못된 레이아웃 보지 않거나, 화면이 깜빡이는 것을 방지할 수 있다.</p>
<h3 id="마무리">마무리</h3>
<p>아직은 MVP 완성 단계이기 때문에 기능적 퀄리티, 퀄리티도 완벽하진 못하다. 당장 Cursor의 경우만 보아도 유저의 질문에 대한 핵심 응답만 Sonnet, GPT, Claude 같은 대형 LLM에 맡기고, 나머지 직접 툴을 골라서 어디에 어떤 툴을 사용할지는 경량화된 Fast Apply LLM을 사용하는 방식을 통해 보다 더 효율적으로 하고 있다. 또한 파일 수정의 경우 Full Write 대신 diff 기반으로 처리하고 있는 등... 나 역시 이런 효율적인 시스템과 토큰 소비의 절감 등 다양한 방면에서 더 깊은 고민을 해봐야할 것 같다. 그래도 AI Agent들이 어떤식으로 내부에서 작동을 하는지 직접 구현하면 어느 정도 알아갈 수 있는 좋은 기회였다. </p>
]]></description>
        </item>
    </channel>
</rss>