<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>lalunru.blog</title>
    <link>https://lalunru.tistory.com/</link>
    <description>머릿속에 흩어진 생각들을 글로 정리하며 성장합니다.</description>
    <language>ko</language>
    <pubDate>Sun, 28 Jun 2026 10:12:20 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>lalunru</managingEditor>
    <image>
      <title>lalunru.blog</title>
      <url>https://tistory1.daumcdn.net/tistory/8675121/attach/02da72c13b214277ba7f454461d855d1</url>
      <link>https://lalunru.tistory.com</link>
    </image>
    <item>
      <title>[MSW] REST 스냅샷과 WebSocket 실시간 데이터를 함께 쓸 때 생기는 경쟁 조건</title>
      <link>https://lalunru.tistory.com/entry/rest-ws-race-condition-useref</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocket으로 실시간 데이터를 받는 컴포넌트를 구현할 때, 흔히 마주치는 UX 문제가 있습니다. WS 연결이 완료되기까지 (STOMP 핸드셰이크, 구독, 첫 메시지 수신) 수 초가 걸리는 동안 사용자는 스켈레톤 화면만 보게 됩니다. 이를 개선하기 위해 &lt;b&gt;REST로 스냅샷을 먼저 받아 화면을 채우고, 이후 WS 실시간 데이터로 자연스럽게 전환하는 패턴&lt;/b&gt;을 도입했습니다. 그런데 이 방식을 단순하게 구현하면 REST와 WS 응답 사이에 경쟁 조건이 발생합니다. 이 글에서는 그 문제와 useRef를 활용한 해결 방법을 정리합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제 상황: REST와 WS가 동시에 달린다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호가창 컴포넌트는 아래 두 가지 데이터 소스를 동시에 운용합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;REST&lt;/b&gt;: 컴포넌트 마운트 시 /api/stocks/{stockCode}/orderbook으로 스냅샷 요청&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WS&lt;/b&gt;: STOMP over SockJS로 /topic/orderbook/{stockCode} 구독, 1초마다 실시간 데이터 수신&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 경우 REST가 먼저 응답하고, 이후 WS가 연결되어 실시간으로 교체되는 &lt;b&gt;시나리오 A&lt;/b&gt;로 흘러갑니다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;시나리오 A (정상)
t=0ms    REST 요청 + WS 연결 시작
t=200ms  REST 응답 &amp;rarr; 스냅샷 화면에 표시
t=800ms  WS 연결 완료 &amp;rarr; 실시간 데이터로 교체
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 네트워크 상황에 따라 WS가 먼저 도착하는 &lt;b&gt;시나리오 B&lt;/b&gt;입니다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;시나리오 B (역전)
t=0ms    REST 요청 + WS 연결 시작
t=500ms  WS 연결 완료 &amp;rarr; 실시간 데이터로 화면 표시
t=700ms  REST 응답 &amp;rarr; 더 오래된 스냅샷으로 덮어씌움 &amp;larr; 문제
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시나리오 B에서 REST 응답이 늦게 도착하면, 이미 최신 실시간 데이터가 표시된 화면을 과거 스냅샷이 덮어쓰게 됩니다. 사용자 입장에서는 화면이 순간적으로 과거 값으로 되돌아가는 것처럼 보입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 왜 useState로는 해결이 어려운가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 떠올릴 수 있는 방법은 useState로 WS 수신 여부를 추적하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const [wsReceived, setWsReceived] = useState(false);

// WS 수신 시
client.subscribe(topic, (message) =&amp;gt; {
  setWsReceived(true); // 상태 업데이트 &amp;rarr; 리렌더링 트리거
  setRaw(JSON.parse(message.body));
});

// REST 응답 시
getOrderbook(stockCode).then((snapshot) =&amp;gt; {
  if (wsReceived) return; // wsReceived를 참조
  setRaw(snapshot);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에는 두 가지 문제가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째, stale closure 문제입니다.&lt;/b&gt; getOrderbook().then() 콜백은 호출 시점의 wsReceived 값을 클로저로 캡처합니다. REST 요청을 보낸 시점에는 wsReceived가 false였기 때문에, WS가 먼저 도착해서 setWsReceived(true)를 호출해도 이미 캡처된 클로저 안의 wsReceived는 여전히 false입니다. 결국 조건문을 통과하여 스냅샷이 덮어쓰게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;REST 요청 시점:  wsReceived = false (클로저에 캡처)
WS 수신:        setWsReceived(true) &amp;rarr; 리렌더링
REST 응답 도착: 클로저 안의 wsReceived는 여전히 false &amp;rarr; 덮어씌움 발생
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째, 불필요한 리렌더링입니다.&lt;/b&gt; wsReceived는 화면에 표시할 값이 아니라 내부 제어 플래그입니다. 이 값이 바뀔 때마다 리렌더링이 발생하면 컴포넌트 전체가 다시 그려지는 비용이 생깁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 해결: useRef로 비동기 클로저 간 상태 공유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useRef는 렌더링을 트리거하지 않으면서, 컴포넌트 생애주기 동안 동일한 객체 참조를 유지합니다. 비동기 콜백들이 같은 ref.current를 바라보기 때문에 stale closure 문제가 발생하지 않습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const wsReceivedRef = useRef(false);

// REST 선호출
useEffect(() =&amp;gt; {
  wsReceivedRef.current = false; // stockCode 변경 시 초기화

  getOrderbook(stockCode).then((snapshot) =&amp;gt; {
    if (wsReceivedRef.current) return; // WS가 이미 받았으면 무시
    if (snapshot) {
      setRaw(snapshot);
      setStatus('default');
    }
    // snapshot이 null이면 skeleton 유지 (장 시작 전, 미수집 상태)
  });
}, [stockCode]);

// WS 수신 시 플래그 설정
client.subscribe(topic, (message) =&amp;gt; {
  wsReceivedRef.current = true; // 이후 REST 응답은 항상 무시
  setRaw(JSON.parse(message.body));
  setStatus('default');
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;wsReceivedRef.current는 어느 콜백에서 읽든 항상 현재 값을 반환합니다. WS 수신 후 true로 바뀌고 나면, 나중에 도착한 REST 응답의 콜백에서 참조해도 true가 읽힙니다. stale closure가 발생하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useState와 useRef의 차이를 정리하면 다음과 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useState useRef&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;리렌더링 트리거&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비동기 콜백에서의 최신값 참조&lt;/td&gt;
&lt;td&gt;X (stale closure)&lt;/td&gt;
&lt;td&gt;O (항상 현재값)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;화면에 표시할 값&lt;/td&gt;
&lt;td&gt;적합&lt;/td&gt;
&lt;td&gt;부적합&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;내부 제어 플래그&lt;/td&gt;
&lt;td&gt;부적합&lt;/td&gt;
&lt;td&gt;&lt;b&gt;적합&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 우선순위 전략: &quot;마지막이 이긴다&quot;가 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴의 핵심은 &lt;b&gt;단방향 우선순위&lt;/b&gt;입니다. &quot;가장 나중에 도착한 데이터가 이긴다&quot;는 단순한 규칙이 아니라, &lt;b&gt;WS가 한 번이라도 수신되면 REST는 항상 진다&lt;/b&gt;는 명확한 우선순위를 가집니다. 이렇게 설계한 이유는 두 데이터 소스의 성격이 다르기 때문입니다. REST 스냅샷은 요청 시점의 정적인 값이고, WS 데이터는 실시간으로 갱신되는 값입니다. 어느 시점에 도착하든, 실시간 데이터가 스냅샷보다 항상 우선합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 엣지 케이스 처리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;snapshot이 null인 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST 응답에서 data: null이 내려오는 경우가 있습니다. 장 시작 전이거나 아직 데이터가 수집되지 않은 상태입니다. 이 경우 스켈레톤을 유지하고 에러 상태로 전환하지 않습니다. null은 서버 오류가 아니라 정상 응답이기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;getOrderbook(stockCode).then((snapshot) =&amp;gt; {
  if (wsReceivedRef.current) return;
  if (snapshot) {           // null이면 이 블록을 건너뜀
    setRaw(snapshot);
    setStatus('default');
  }
  // snapshot이 null &amp;rarr; status는 'skeleton' 유지
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;WS 연결이 끊기는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WS가 끊기더라도 이미 받아둔 데이터(REST 스냅샷 또는 마지막 WS 데이터)를 유지합니다. 스켈레톤으로 되돌리지 않습니다. 사용자 입장에서 잠깐의 연결 끊김으로 화면이 비는 것보다, 약간 오래된 데이터가 표시되는 편이 낫습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;STOMP 오류 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;STOMP 연결 오류 시 상태 전환은 현재 상태에 따라 분기합니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;client.onStompError = () =&amp;gt; {
  // 이미 데이터가 있는 상태면 유지, 스켈레톤 상태일 때만 에러로 전환
  setStatus((prev) =&amp;gt; (prev === 'skeleton' ? 'error' : prev));
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 이미 화면에 있는데 WS 오류가 발생했다고 해서 에러 UI로 전환하면 사용자 경험이 오히려 나빠집니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST와 WS를 함께 운용할 때 발생하는 경쟁 조건은 &quot;타이밍 문제&quot;처럼 보이지만, 실제로는 &lt;b&gt;두 비동기 소스 간의 우선순위를 명확하게 정의하지 않아서&lt;/b&gt; 생기는 설계 문제입니다. useRef가 이 상황에서 적합한 이유는 단순히 리렌더링을 막아서가 아닙니다. 비동기 콜백이 클로저로 값을 캡처하는 특성상, 항상 최신값을 참조할 수 있는 뮤터블 컨테이너가 필요하고, useRef가 정확히 그 역할을 합니다. 화면에 표시할 필요 없는 내부 제어 플래그라면 useState보다 useRef가 더 적합한 도구입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;React 공식 문서 &amp;mdash; useRef&lt;/b&gt;&lt;br /&gt;&lt;a href=&quot;https://react.dev/reference/react/useRef&quot;&gt;https://react.dev/reference/react/useRef&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;React 공식 문서 &amp;mdash; Synchronizing with Effects&lt;/b&gt;&lt;br /&gt;&lt;a href=&quot;https://react.dev/learn/synchronizing-with-effects&quot;&gt;https://react.dev/learn/synchronizing-with-effects&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@stomp/stompjs 공식 문서&lt;/b&gt;&lt;br /&gt;&lt;a href=&quot;https://stomp-js.github.io/guide/stompjs/using-stompjs-v5.html&quot;&gt;https://stomp-js.github.io/guide/stompjs/using-stompjs-v5.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Dev Notes/Frontend</category>
      <category>react</category>
      <category>STOMP</category>
      <category>useRef</category>
      <category>websocket</category>
      <category>비동기</category>
      <category>트러블슈팅</category>
      <category>프론트엔드</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/11</guid>
      <comments>https://lalunru.tistory.com/entry/rest-ws-race-condition-useref#entry11comment</comments>
      <pubDate>Wed, 10 Jun 2026 22:42:05 +0900</pubDate>
    </item>
    <item>
      <title>[MSW] MSW v2가 WebSocket을 막을 때 &amp;mdash; SockJS 폴백으로 우회한 방법</title>
      <link>https://lalunru.tistory.com/entry/msw-websocket-sockjs-bypass</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 개발 환경에서 MSW(Mock Service Worker)는 API 목 처리에 있어 거의 표준에 가까운 도구가 되었습니다. REST 요청을 인터셉트하고 가짜 응답을 내려주는 역할을 깔끔하게 수행해 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 MSW v2부터 WebSocket 인터셉트 기능이 추가되면서 예상치 못한 문제와 마주쳤습니다. 실제 백엔드 WebSocket 서버에 연결해야 하는 상황인데, MSW가 포트와 URL을 가리지 않고 브라우저의 모든 WebSocket 연결을 가로채 버린 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 문제를 해결하기 위해 시도했던 방법들과 최종적으로 SockJS의 특성을 이용해 우회한 과정을 공유합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제 상황: MSW v2의 WebSocket 전면 인터셉트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트는 호가창(주문서) 데이터를 STOMP over WebSocket 방식으로 백엔드에서 실시간으로 수신하는 구조였습니다. 백엔드 서버는 포트 &lt;code&gt;9090&lt;/code&gt;에서 &lt;code&gt;/topic/orderbook/{stockCode}&lt;/code&gt; 토픽으로 1초마다 데이터를 전송합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &lt;b&gt;MSW v2가 &lt;code&gt;window.WebSocket&lt;/code&gt;을 전역으로 패치한다&lt;/b&gt;는 점이었습니다. MSW가 초기화되는 순간, 브라우저에서 발생하는 모든 WebSocket 연결 시도(포트 번호나 URL에 무관하게)를 MSW가 먼저 가로채게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 프론트엔드 개발 서버(포트 &lt;code&gt;5173&lt;/code&gt;)에서 백엔드(포트 &lt;code&gt;9090&lt;/code&gt;)로 WebSocket 연결을 시도해도, MSW 입장에서는 &lt;code&gt;localhost&lt;/code&gt; 어딘가로 향하는 WS 연결이기 때문에 그냥 가로채 버립니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;[브라우저] WS 연결 시도 &amp;rarr; localhost:9090/ws
               &amp;darr;
[MSW] &quot;내가 처리한다&quot; (인터셉트)
               &amp;darr;
[결과] 실제 백엔드와 연결 불가&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 시도한 방법들과 실패 원인&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 1. Vite 프록시로 우회&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 떠올린 방법은 Vite의 프록시 설정이었습니다. &lt;code&gt;/ws-orderbook&lt;/code&gt; 경로를 &lt;code&gt;9090&lt;/code&gt;으로 프록시하고, WS 연결 URL을 &lt;code&gt;localhost:5173/ws-orderbook&lt;/code&gt;으로 바꾸면 같은 포트 내에서 연결되니 MSW를 속일 수 있을 거라 생각했습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// vite.config.ts
server: {
  proxy: {
    '/ws-orderbook': {
      target: 'http://localhost:9090',
      ws: true,
    },
  },
},&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;: 실패. MSW는 &lt;code&gt;localhost:5173&lt;/code&gt; 자체를 통째로 인터셉트하기 때문에, 프록시 경로를 바꿔도 MSW가 먼저 연결을 가로챕니다. 프록시는 서버 레벨의 작업이고, MSW 패치는 브라우저 레벨의 작업이라 순서상 MSW가 항상 먼저입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 2. MSW &lt;code&gt;server.connect()&lt;/code&gt; passthrough&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MSW에는 특정 연결을 실제 서버로 통과시키는 &lt;code&gt;passthrough&lt;/code&gt; API가 있습니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// MSW WebSocket passthrough 시도
ws.link('ws://localhost:9090/*').on('connection', ({ server }) =&amp;gt; {
  server.connect();
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;: 실패. 브라우저 환경에서 MSW의 Service Worker가 실제 외부 WS 연결을 중계하는 방식이 제대로 동작하지 않았습니다. 연결 자체가 성립되지 않거나 데이터 수신이 불안정했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 3. NativeWebSocket 캡처&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MSW가 &lt;code&gt;window.WebSocket&lt;/code&gt;을 패치하기 전에 원본 WebSocket 생성자를 미리 캡처해두는 방법을 시도했습니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// MSW 임포트보다 먼저 실행되길 기대
const NativeWebSocket = window.WebSocket;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;: 실패. Vite의 모듈 번들링 특성상 임포트 순서를 코드 작성 순서로 완전히 보장할 수 없었고, 실제로 MSW 패치가 항상 먼저 적용되었습니다. ES 모듈의 정적 분석과 호이스팅 동작 때문에 이 접근법 자체가 구조적으로 불안정합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도 4. MSW 전체 비활성화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 편의를 위해 &lt;code&gt;.env.local&lt;/code&gt;에 환경변수를 추가해 MSW를 아예 끄는 방법을 시도했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;: 반쪽짜리 해결. 호가창은 동작했지만, 종목 랭킹, 실시간 시세 등 다른 MSW 핸들러에 의존하는 기능이 전부 함께 죽었습니다. 하나를 살리려고 전체를 희생하는 방식이라 근본적인 해결책이 아니었습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 핵심 발견: 백엔드는 SockJS를 사용하고 있었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네 가지 시도가 모두 막히고 나서, 방향을 바꿔 백엔드 코드를 직접 살펴보았습니다. &lt;code&gt;WebSocketConfig.java&lt;/code&gt;를 확인하니 백엔드가 순수 WebSocket이 아닌 &lt;b&gt;SockJS&lt;/b&gt;를 사용하고 있다는 것을 발견했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// WebSocketConfig.java (백엔드)
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint(&quot;/ws&quot;)
            .setAllowedOriginPatterns(&quot;*&quot;)
            .withSockJS(); // SockJS 사용 중
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 SockJS의 동작 방식이 문제 해결의 실마리가 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SockJS는 WebSocket을 직접 사용하지 않을 때, HTTP 롱폴링(Long-Polling)으로 폴백합니다.&lt;/b&gt; 연결 전 &lt;code&gt;/info&lt;/code&gt; 엔드포인트에 HTTP 요청을 보내 서버의 WebSocket 지원 여부를 확인하고, 그 결과에 따라 전송 방식을 결정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, SockJS를 클라이언트로 사용하면 &lt;code&gt;window.WebSocket&lt;/code&gt;을 직접 사용하지 않고 HTTP 요청 기반으로 통신할 수 있습니다. &lt;b&gt;MSW의 WS 인터셉트는 &lt;code&gt;window.WebSocket&lt;/code&gt;을 패치하는 것이기 때문에, HTTP 기반으로 동작하는 SockJS 연결은 MSW의 영향권 밖에 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 최종 해결: SockJS + STOMP 직접 연결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;sockjs-client&lt;/code&gt;를 설치하고, &lt;code&gt;@stomp/stompjs&lt;/code&gt;의 &lt;code&gt;webSocketFactory&lt;/code&gt;에 SockJS를 주입하는 방식으로 교체했습니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;npm install sockjs-client @stomp/stompjs
npm install --save-dev @types/sockjs-client&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// use-orderbook.ts (변경 후)
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

const client = new Client({
  // window.WebSocket 대신 SockJS를 팩토리로 주입
  webSocketFactory: () =&amp;gt; new SockJS('http://localhost:9090/ws'),
  onConnect: () =&amp;gt; {
    client.subscribe(`/topic/orderbook/${stockCode}`, (message) =&amp;gt; {
      const data: OrderbookDto = JSON.parse(message.body);
      setViewModel(data);
    });
  },
});

client.activate();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 사용하던 &lt;code&gt;useWebSocket&lt;/code&gt; 훅이나 내장 &lt;code&gt;WebSocket&lt;/code&gt;을 거치지 않고, SockJS 팩토리를 직접 주입함으로써 MSW의 인터셉트를 완전히 우회했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;502&quot; data-origin-height=&quot;837&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mOeXZ/dJMcadaTNl7/TciklJkST1kRhPfPaON8w0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mOeXZ/dJMcadaTNl7/TciklJkST1kRhPfPaON8w0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mOeXZ/dJMcadaTNl7/TciklJkST1kRhPfPaON8w0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmOeXZ%2FdJMcadaTNl7%2FTciklJkST1kRhPfPaON8w0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;502&quot; height=&quot;837&quot; data-origin-width=&quot;502&quot; data-origin-height=&quot;837&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 추가 문제: SockJS의 &lt;code&gt;/info&lt;/code&gt; 요청도 MSW가 가로챈다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결은 성공했지만, 콘솔에서 새로운 오류가 발생했습니다. SockJS는 연결 전 서버 상태를 확인하기 위해 &lt;code&gt;http://localhost:9090/ws/info&lt;/code&gt; 로 HTTP GET 요청을 먼저 보내는데, &lt;b&gt;MSW가 이 HTTP 요청까지 인터셉트해서 핸들러가 없다는 오류를 뱉고 있었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 간단했습니다. MSW 핸들러 파일에 &lt;code&gt;localhost:9090&lt;/code&gt;으로 향하는 모든 요청을 통과시키는 passthrough 핸들러를 추가했습니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// handlers/stock.ts (MSW 핸들러 파일)
import { http, passthrough } from 'msw';

export const stockHandlers = [
  // 기존 핸들러들...

  // localhost:9090으로 향하는 모든 요청은 실제 서버로 통과
  http.get('http://localhost:9090/*', () =&amp;gt; passthrough()),
  http.post('http://localhost:9090/*', () =&amp;gt; passthrough()),
];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 MSW가 살아있으면서도, &lt;code&gt;9090&lt;/code&gt; 포트로 향하는 요청은 MSW를 거치지 않고 실제 백엔드로 전달됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 마무리 및 회고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 정리하면 다음과 같습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;시도&lt;/th&gt;
&lt;th&gt;실패 원인&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vite 프록시 우회&lt;/td&gt;
&lt;td&gt;MSW는 브라우저 레벨에서 패치 &amp;rarr; 프록시 경로 변경으로는 우회 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MSW passthrough&lt;/td&gt;
&lt;td&gt;브라우저 환경에서 WS 중계가 불안정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NativeWebSocket 캡처&lt;/td&gt;
&lt;td&gt;Vite 모듈 시스템의 임포트 순서 보장 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MSW 전체 비활성화&lt;/td&gt;
&lt;td&gt;다른 MSW 의존 기능 전체 손상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;SockJS 주입 (최종 해결)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;HTTP 폴링 기반 &amp;rarr; MSW WS 인터셉트 우회&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-path-to-node=&quot;13,1&quot; data-ke-size=&quot;size16&quot;&gt;이번 트러블슈팅의 핵심은 결국 &lt;b data-index-in-node=&quot;17&quot; data-path-to-node=&quot;13,1&quot;&gt;라이브러리의 내부 동작 원리&lt;/b&gt;를 파악하는 것이었습니다. MSW가 window.WebSocket을 패치한다는 점과 SockJS가 상황에 따라 HTTP 폴링으로 폴백할 수 있다는 점, 이 두 가지 파편화된 지식을 연결하지 못했다면 네 번의 실패 이후에도 계속 같은 레이어에서 맴돌았을 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;13,2&quot; data-ke-size=&quot;size16&quot;&gt;도구를 사용할 때 &quot;어떻게 쓰는가&quot;만큼 &quot;어떻게 동작하는가&quot;를 아는 것이 막힌 상황을 돌파하는 데 훨씬 직접적인 무기가 된다는 것을 다시 한번 실감했습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;MSW 공식 문서 &amp;mdash; Intercepting WebSockets&lt;/b&gt;&lt;br /&gt;&lt;a href=&quot;https://mswjs.io/docs/api/ws&quot;&gt;https://mswjs.io/docs/api/ws&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SockJS 공식 GitHub &amp;mdash; How it works&lt;/b&gt;&lt;br /&gt;&lt;a href=&quot;https://github.com/sockjs/sockjs-client#readme&quot;&gt;https://github.com/sockjs/sockjs-client#readme&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@stomp/stompjs 공식 문서 &amp;mdash; Using with SockJS&lt;/b&gt;&lt;br /&gt;&lt;a href=&quot;https://stomp-js.github.io/stomp-websocket/codo/extra/docs-src/sockjs.md.html&quot;&gt;https://stomp-js.github.io/stomp-websocket/codo/extra/docs-src/sockjs.md.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Dev Notes/Frontend</category>
      <category>MSW</category>
      <category>sockjs</category>
      <category>STOMP</category>
      <category>ViTE</category>
      <category>websocket</category>
      <category>트러블슈팅</category>
      <category>프론트엔드</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/10</guid>
      <comments>https://lalunru.tistory.com/entry/msw-websocket-sockjs-bypass#entry10comment</comments>
      <pubDate>Fri, 15 May 2026 16:59:52 +0900</pubDate>
    </item>
    <item>
      <title>[Shadertoy] Shadertoy 입문 &amp;mdash; 브라우저에서 시작하는 실시간 셰이더 개발</title>
      <link>https://lalunru.tistory.com/entry/shadertoy-glsl-intro</link>
      <description>&lt;h2&gt;1. 들어가며&lt;/h2&gt;
&lt;p&gt;셰이더를 처음 공부할 때 가장 큰 장벽은 환경 세팅입니다. OpenGL 컨텍스트를 만들고, 컴파일 파이프라인을 구성하고, 렌더링 루프를 짜야 비로소 &amp;quot;Hello, Triangle&amp;quot;을 볼 수 있습니다.&lt;strong&gt;Shadertoy&lt;/strong&gt;는 그 과정을 브라우저 하나로 압축합니다. 계정을 만들고 접속하면, 코드를 작성하는 즉시 결과가 화면에 렌더링됩니다. 별도의 IDE도, 그래픽스 드라이버 설정도 필요 없습니다. 이 글에서는 Shadertoy의 기본 구조와 핵심 유니폼 변수를 정리하고, 실제로 이 플랫폼을 활용해 제가 구현한 NPR 셰이더들이 어떤 원리로 동작하는지 간략하게 소개합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. Shadertoy가 뭔가요?&lt;/h2&gt;
&lt;p&gt;Shadertoy는 Inigo Quilez(iq)와 Pol Jeremias가 만든 &lt;strong&gt;웹 기반 GLSL 셰이더 샌드박스&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/miG5C/dJMcaicfrtK/0G9yImNp32RSmokUtWpFF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/miG5C/dJMcaicfrtK/0G9yImNp32RSmokUtWpFF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/miG5C/dJMcaicfrtK/0G9yImNp32RSmokUtWpFF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmiG5C%2FdJMcaicfrtK%2F0G9yImNp32RSmokUtWpFF1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;브라우저에서 GLSL ES(WebGL 기반)로 Fragment Shader를 작성하면 실시간으로 렌더링되는 특징이 있습니다. 전 세계 개발자들이 올린 셰이더를 포크하거나 코드를 직접 확인할 수 있어 학습 자료로도 방대하며, 저장 및 공유가 URL 하나로 끝나 용이합니다. 처음 접속하면 우측 상단의 &lt;strong&gt;New&lt;/strong&gt; 버튼을 클릭해 새 셰이더를 만들 수 있습니다. 로그인 없이도 작성과 실행은 가능하지만, 저장과 공유를 위해서는 계정이 필요합니다. 편집창 하단의 &lt;strong&gt;▶ (컴파일 버튼)&lt;/strong&gt; 또는 &lt;code&gt;Alt+Enter&lt;/code&gt;를 누르면 코드가 즉시 반영됩니다. 단, Shadertoy에서 작성하는 코드는 &lt;strong&gt;Fragment Shader만&lt;/strong&gt;입니다. Vertex Shader는 플랫폼이 내부적으로 처리하며, 사용자는 각 픽셀의 색상을 결정하는 로직만 작성합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. 기본 구조 이해하기&lt;/h2&gt;
&lt;p&gt;Shadertoy를 처음 열면 아래와 같은 화면이 나타납니다. 왼쪽이 렌더링 결과, 오른쪽이 코드 편집창입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dEDy0A/dJMb99TR5lr/l3Vk5CD8jIIwDuvBE13GkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dEDy0A/dJMb99TR5lr/l3Vk5CD8jIIwDuvBE13GkK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dEDy0A/dJMb99TR5lr/l3Vk5CD8jIIwDuvBE13GkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdEDy0A%2FdJMb99TR5lr%2Fl3Vk5CD8jIIwDuvBE13GkK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;기본으로 작성되어 있는 코드는 다음과 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-glsl&quot;&gt;void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord / iResolution.xy;
    vec3 col = vec3(uv, 0.0);
    fragColor = vec4(col, 1.0);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;일반적인 GLSL의 &lt;code&gt;main()&lt;/code&gt; 대신 &lt;code&gt;mainImage()&lt;/code&gt;를 진입점으로 사용합니다. 파라미터는 다음과 같습니다.&lt;/p&gt;
&lt;table style=&quot;width:100%; border-collapse:collapse; color:#ffffff;&quot;&gt;
  &lt;thead&gt;
    &lt;tr style=&quot;border-bottom:1px solid #ffffff;&quot;&gt;
      &lt;th style=&quot;padding:8px 12px; text-align:left;&quot;&gt;파라미터&lt;/th&gt;
      &lt;th style=&quot;padding:8px 12px; text-align:left;&quot;&gt;설명&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr style=&quot;border-bottom:1px solid #555;&quot;&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;out vec4 fragColor&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;최종 출력 색상 (RGBA)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;in vec2 fragCoord&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;현재 픽셀의 화면 좌표 (픽셀 단위)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;위 코드를 실행하면 좌하단이 검정, 우상단이 노란색인 그라디언트가 렌더링됩니다. &lt;code&gt;uv.x&lt;/code&gt;가 R 채널, &lt;code&gt;uv.y&lt;/code&gt;가 G 채널에 각각 매핑되기 때문입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. 자주 쓰는 유니폼 변수 (Built-in Uniforms)&lt;/h2&gt;
&lt;p&gt;Shadertoy는 셰이더에 자동으로 전달되는 내장 유니폼 변수들을 제공합니다. 이 변수들을 이해하는 것이 Shadertoy 활용의 핵심입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-glsl&quot;&gt;uniform vec3  iResolution;   // 뷰포트 해상도 (픽셀 단위, z는 픽셀 종횡비)
uniform float iTime;         // 셰이더 실행 시간 (초)
uniform float iTimeDelta;    // 이전 프레임과의 시간 차이 (초)
uniform int   iFrame;        // 현재 프레임 번호
uniform vec4  iMouse;        // 마우스 상태 (xy: 현재 위치, zw: 클릭 위치)
uniform vec4  iDate;         // 현재 날짜 (년, 월, 일, 시각(초))
uniform sampler2D iChannel0; // 입력 채널 0 (텍스처, 사운드 등)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;가장 자주 쓰는 세 가지를 정리하면 다음과 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;iResolution — UV 정규화&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-glsl&quot;&gt;// 픽셀 좌표를 0.0 ~ 1.0 범위로 정규화
vec2 uv = fragCoord / iResolution.xy;

// 화면 중앙을 원점으로, 종횡비를 보정한 좌표계
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 번째 방식이 더 자주 쓰입니다. 화면 비율에 상관없이 원형이 원형으로 렌더링되기 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;iTime — 애니메이션&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-glsl&quot;&gt;// 시간에 따라 색상이 변하는 예시
float r = 0.5 + 0.5 * sin(iTime);
float g = 0.5 + 0.5 * sin(iTime + 2.094); // 120도 위상 차이
float b = 0.5 + 0.5 * sin(iTime + 4.188); // 240도 위상 차이
fragColor = vec4(r, g, b, 1.0);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;iMouse — 인터랙션&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-glsl&quot;&gt;// 마우스 위치를 UV 좌표로 변환
vec2 mouse = iMouse.xy / iResolution.xy;

// 마우스 클릭 여부 확인 (iMouse.z &amp;gt; 0 이면 클릭 중)
if (iMouse.z &amp;gt; 0.0) {
    // 클릭 중일 때의 로직
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;5. 자주 쓰는 GLSL 함수&lt;/h2&gt;
&lt;p&gt;Shadertoy에서 사실상 표준처럼 쓰이는 함수들입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-glsl&quot;&gt;// 값을 특정 범위로 부드럽게 전환 (셰이더의 핵심 함수)
float smoothstep(float edge0, float edge1, float t);

// 두 값 사이를 선형 보간
float mix(float a, float b, float t);  // t=0이면 a, t=1이면 b

// 길이 1로 정규화
vec3 normalize(vec3 v);

// 내적 (두 벡터의 방향 유사도)
float dot(vec3 a, vec3 b);

// 픽셀 단위의 미분값 (안티앨리어싱에 활용)
float fwidth(float p);  // abs(dFdx(p)) + abs(dFdy(p))

// 소수점 부분만 추출 (타일링 패턴에 유용)
float fract(float x);

// 절대값, 최소/최대값
float abs(float x);
float min(float a, float b);
float max(float a, float b);
float clamp(float x, float minVal, float maxVal);&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;6. 실전 예시: 원 그리기&lt;/h2&gt;
&lt;p&gt;이론을 이해했으면 가장 기본적인 형태를 직접 그려보는 것이 빠릅니다. 아래 코드를 그대로 붙여넣으면 흰 원이 렌더링됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-glsl&quot;&gt;void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    // 화면 중앙 원점, 종횡비 보정 UV
    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

    // 원점으로부터의 거리
    float dist = length(uv);

    // 반지름 0.3의 원 (smoothstep으로 경계를 부드럽게)
    float circle = smoothstep(0.31, 0.29, dist);

    fragColor = vec4(vec3(circle), 1.0);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEkXm8/dJMb997pH4Y/0zRQVTX1t2kicDZ32FiJXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEkXm8/dJMb997pH4Y/0zRQVTX1t2kicDZ32FiJXk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEkXm8/dJMb997pH4Y/0zRQVTX1t2kicDZ32FiJXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEkXm8%2FdJMb997pH4Y%2F0zRQVTX1t2kicDZ32FiJXk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;code&gt;smoothstep(0.31, 0.29, dist)&lt;/code&gt;는 dist가 0.31보다 크면 0, 0.29보다 작으면 1을 반환합니다. 이 두 값 사이에서 부드럽게 전환되어 경계에 안티앨리어싱이 적용됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. Shadertoy에서 Unity로 이식하기&lt;/h2&gt;
&lt;p&gt;Shadertoy는 프로토타이핑에 최적화된 환경이지만, 실제 게임이나 앱에 적용하려면 Unity(HLSL)로 이식해야 합니다. 주요 차이점은 다음과 같습니다.&lt;/p&gt;
&lt;table style=&quot;width:100%; border-collapse:collapse; color:#ffffff;&quot;&gt;
  &lt;thead&gt;
    &lt;tr style=&quot;border-bottom:1px solid #ffffff;&quot;&gt;
      &lt;th style=&quot;padding:8px 12px; text-align:left;&quot;&gt;항목&lt;/th&gt;
      &lt;th style=&quot;padding:8px 12px; text-align:left;&quot;&gt;Shadertoy (GLSL)&lt;/th&gt;
      &lt;th style=&quot;padding:8px 12px; text-align:left;&quot;&gt;Unity URP (HLSL)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr style=&quot;border-bottom:1px solid #555;&quot;&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;진입점&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;mainImage()&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;frag()&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr style=&quot;border-bottom:1px solid #555;&quot;&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;UV 좌표&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;fragCoord / iResolution.xy&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;i.uv&lt;/code&gt; (버텍스에서 전달)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr style=&quot;border-bottom:1px solid #555;&quot;&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;시간&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;iTime&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;_Time.y&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr style=&quot;border-bottom:1px solid #555;&quot;&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;텍스처 샘플링&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;texture(iChannel0, uv)&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv)&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;벡터 타입&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;vec2 / vec3 / vec4&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;padding:8px 12px;&quot;&gt;&lt;code&gt;float2 / float3 / float4&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;함수명과 타입 표기만 변환하면 대부분의 로직은 그대로 이식됩니다. &lt;code&gt;vec3&lt;/code&gt; → &lt;code&gt;float3&lt;/code&gt;, &lt;code&gt;mix&lt;/code&gt; → &lt;code&gt;lerp&lt;/code&gt; 정도가 가장 자주 만나는 차이입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. 실제로 만든 것들&lt;/h2&gt;
&lt;p&gt;이 파이프라인을 활용해 직접 구현한 NPR 셰이더들입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LrpEQ/dJMcaa6lfS9/M9vKf0oMImfT6MG5ClkGd1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LrpEQ/dJMcaa6lfS9/M9vKf0oMImfT6MG5ClkGd1/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LrpEQ/dJMcaa6lfS9/M9vKf0oMImfT6MG5ClkGd1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/LrpEQ/dJMcaa6lfS9/M9vKf0oMImfT6MG5ClkGd1/img.gif&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cross-Hatching&lt;/strong&gt;: Value Noise로 유기적인 펜화 질감을 구현한 크로스해칭 셰이더&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b8iLvc/dJMcahK9PaS/PjmauMXV9LkoISoLQ00Iu0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b8iLvc/dJMcahK9PaS/PjmauMXV9LkoISoLQ00Iu0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b8iLvc/dJMcahK9PaS/PjmauMXV9LkoISoLQ00Iu0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/b8iLvc/dJMcahK9PaS/PjmauMXV9LkoISoLQ00Iu0/img.gif&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Toon Shading + Rim Light&lt;/strong&gt;: Hue-Shift와 스크린 스페이스 노멀 불연속성 감지로 구현한 셀 애니메이션 스타일&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/di2vsL/dJMb99TR5yz/0DYFfJssvRJQ9qcHEqKUlk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/di2vsL/dJMb99TR5yz/0DYFfJssvRJQ9qcHEqKUlk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/di2vsL/dJMb99TR5yz/0DYFfJssvRJQ9qcHEqKUlk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/di2vsL/dJMb99TR5yz/0DYFfJssvRJQ9qcHEqKUlk/img.gif&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Null Cathedral&lt;/strong&gt;: IFS 기반 프랙탈 레이마칭 + ACES 톤매핑, 크로마틱 어버레이션 포스트 프로세싱&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;  &lt;a href=&quot;https://lalunru.tistory.com/entry/npr-shaders&quot;&gt;프로젝트 상세 기록 보기&lt;/a&gt;&lt;br&gt;  &lt;a href=&quot;https://www.shadertoy.com/user/aa40272446&quot;&gt;Shadertoy 프로필&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;9. 마무리&lt;/h2&gt;
&lt;p&gt;Shadertoy는 셰이더를 처음 접하는 사람에게는 진입 장벽을 낮춰주고, 이미 알고 있는 사람에게는 아이디어를 빠르게 검증하는 도구입니다. &amp;quot;브라우저를 열고 코드를 작성하면 결과가 나온다&amp;quot;는 단순한 피드백 루프가 셰이더 학습의 속도를 크게 높여줍니다. 수식과 결과가 즉시 연결되는 경험을 반복하다 보면, 렌더링 파이프라인에 대한 직관이 자연스럽게 생깁니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;10. 참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Shadertoy 공식 사이트&lt;/strong&gt;: 브라우저에서 바로 실습 가능한 GLSL 셰이더 샌드박스.&lt;br&gt;&lt;a href=&quot;https://www.shadertoy.com&quot;&gt;https://www.shadertoy.com&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Book of Shaders&lt;/strong&gt;: 셰이더의 기초 개념부터 고급 기법까지 시각적으로 설명하는 온라인 교재.&lt;br&gt;&lt;a href=&quot;https://thebookofshaders.com&quot;&gt;https://thebookofshaders.com&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Inigo Quilez 블로그&lt;/strong&gt;: Shadertoy 창시자의 SDF, 레이마칭, 절차적 텍스처 관련 기술 글 모음.&lt;br&gt;&lt;a href=&quot;https://iquilezles.org/articles/&quot;&gt;https://iquilezles.org/articles/&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Dev Notes/Unity</category>
      <category>glsl</category>
      <category>NPR</category>
      <category>Shadertoy</category>
      <category>Unity</category>
      <category>webgl</category>
      <category>그래픽스</category>
      <category>셰이더</category>
      <category>셰이더토이</category>
      <category>유니티</category>
      <category>프래그먼트셰이더</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/9</guid>
      <comments>https://lalunru.tistory.com/entry/shadertoy-glsl-intro#entry9comment</comments>
      <pubDate>Mon, 11 May 2026 17:14:25 +0900</pubDate>
    </item>
    <item>
      <title>[Project] NPR Shaders &amp;mdash; GLSL로 구현한 비사실적 렌더링 셰이더 컬렉션</title>
      <link>https://lalunru.tistory.com/entry/npr-shaders</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&amp;family=Inter:wght@300;400;500;600;700;800&amp;display=swap');

  :root {
    --bg:          #0d1117;
    --card:        rgba(255,255,255,0.04);
    --card-border: rgba(255,255,255,0.08);
    --green:       #2ecc71;
    --text:        #ffffff;
    --text-muted:  rgba(255,255,255,0.45);
    --text-dim:    rgba(255,255,255,0.25);
    --tag-border:  rgba(255,255,255,0.2);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Inter', 'Noto Sans KR', sans-serif;
    background: var(--bg);
    color: var(--text);
    max-width: 800px;
    margin: 0 auto;
    padding: 64px 24px 100px;
    line-height: 1.7;
    position: relative;
  }

  body::before {
    content: '';
    position: fixed;
    inset: 0;
    background-image:
      radial-gradient(1px 1px at 10% 15%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 25% 40%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 40% 8%,  rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 55% 55%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 70% 25%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 80% 70%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 90% 45%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 15% 75%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 60% 88%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 35% 62%, rgba(255,255,255,0.35) 0%, transparent 100%),
      radial-gradient(1.5px 1.5px at 48% 32%, rgba(255,255,255,0.6) 0%, transparent 100%),
      radial-gradient(1px 1px at 73% 80%, rgba(255,255,255,0.25) 0%, transparent 100%),
      radial-gradient(1px 1px at 5%  50%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 88% 12%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 22% 92%, rgba(255,255,255,0.2) 0%, transparent 100%);
    pointer-events: none;
    z-index: 0;
  }

  .breadcrumb { font-size: 12px; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 48px; position: relative; z-index: 1; }
  .breadcrumb span { margin: 0 6px; color: var(--text-dim); }

  .header { position: relative; z-index: 1; margin-bottom: 40px; }
  .category-pill { display: inline-block; background: var(--green); color: #000; font-size: 11px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; margin-bottom: 20px; }
  .status-badge { display: inline-block; background: rgba(46,204,113,0.15); border: 1px solid rgba(46,204,113,0.35); color: var(--green); font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px; margin-left: 10px; vertical-align: middle; }
  .proj-title { font-size: 52px; font-weight: 800; letter-spacing: -2px; line-height: 1.05; margin-bottom: 14px; }
  .proj-sub { font-size: 15px; font-weight: 300; color: var(--text-muted); margin-bottom: 24px; }
  .tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
  .tag { font-size: 12px; color: var(--text-muted); border: 1px solid var(--tag-border); padding: 3px 10px; border-radius: 20px; }

  hr { border: none; border-top: 1px solid rgba(255,255,255,0.07); margin: 48px 0; position: relative; z-index: 1; }

  .section-title { font-size: 36px; font-weight: 800; letter-spacing: -1px; margin-bottom: 28px; position: relative; z-index: 1; display: flex; align-items: center; gap: 6px; }
  .section-title .dot { width: 10px; height: 10px; background: var(--green); border-radius: 50%; flex-shrink: 0; }

  .meta-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 24px 28px; position: relative; z-index: 1; }
  .meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 32px; }
  .meta-key { font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--text-dim); margin-bottom: 4px; }
  .meta-val { font-size: 14px; font-weight: 500; }

  .bg-text { font-size: 15px; font-weight: 300; color: var(--text-muted); line-height: 1.9; position: relative; z-index: 1; }
  .bg-text strong { color: var(--text); font-weight: 600; }

  /* 셰이더 카드 */
  .shader-list { display: flex; flex-direction: column; gap: 16px; position: relative; z-index: 1; }
  .shader-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; overflow: hidden; }
  .shader-header { padding: 18px 24px 16px; border-bottom: 1px solid rgba(255,255,255,0.06); display: flex; align-items: center; gap: 12px; }
  .shader-num { font-size: 11px; font-weight: 700; color: var(--green); letter-spacing: 1px; font-family: 'Inter', monospace; }
  .shader-title { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; }
  .shader-body { padding: 0 24px; }
  .shader-row { display: grid; grid-template-columns: 72px 1fr; gap: 16px; padding: 16px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 14px; line-height: 1.8; }
  .shader-row:last-child { border-bottom: none; }
  .row-label { font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding-top: 3px; font-family: 'Inter', monospace; }
  .label-concept { color: #c084fc; }
  .label-impl    { color: var(--green); }
  .label-result  { color: #74c0fc; }
  .row-content { color: rgba(255,255,255,0.65); font-weight: 300; }
  .row-content strong { color: var(--text); font-weight: 600; }

  code { font-family: 'Inter', monospace; font-size: 12px; color: rgba(255,255,255,0.5); background: rgba(255,255,255,0.07); padding: 1px 6px; border-radius: 4px; }

  /* 트러블슈팅 */
  .trouble-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; overflow: hidden; position: relative; z-index: 1; }
  .trouble-header { padding: 16px 24px; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 14px; font-weight: 600; color: var(--green); }
  .trouble-body { padding: 0 24px; }
  .trouble-row { display: grid; grid-template-columns: 72px 1fr; gap: 16px; padding: 16px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 14px; line-height: 1.8; }
  .trouble-row:last-child { border-bottom: none; }
  .label-problem { color: #ff6b6b; }
  .label-solve   { color: #fcc419; font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding-top: 3px; font-family: 'Inter', monospace; }

  /* 링크 */
  .link-row { display: flex; gap: 10px; flex-wrap: wrap; position: relative; z-index: 1; }
  .link-btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; text-decoration: none; transition: background 0.2s, color 0.2s, border-color 0.2s; }
  .link-btn-primary { background: var(--green); color: #000; }
  .link-btn-primary:hover { background: #27ae60; }
  .link-btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--tag-border); }
  .link-btn-secondary:hover { border-color: var(--green); color: var(--green); }
  .link-btn svg { width: 15px; height: 15px; flex-shrink: 0; }

  /* 회고 */
  .retro-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; position: relative; z-index: 1; }
  .retro-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 20px 22px; }
  .retro-key { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--green); margin-bottom: 10px; }
  .retro-val { font-size: 13.5px; font-weight: 300; color: var(--text-muted); line-height: 1.85; }

  @media (max-width: 560px) {
    .proj-title { font-size: 34px; }
    .meta-grid, .retro-grid { grid-template-columns: 1fr; }
    .shader-row, .trouble-row { grid-template-columns: 60px 1fr; gap: 10px; }
    body { padding: 40px 16px 72px; }
    .section-title { font-size: 28px; }
  }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div class=&quot;breadcrumb&quot;&gt;Projects &lt;span&gt;·&lt;/span&gt; 셰이더 개발&lt;/div&gt;

&lt;div class=&quot;header&quot;&gt;
  &lt;div class=&quot;category-pill&quot;&gt;셰이더 개발&lt;/div&gt;
  &lt;h1 class=&quot;proj-title&quot;&gt;NPR Shaders&lt;span class=&quot;status-badge&quot;&gt;진행 중&lt;/span&gt;&lt;/h1&gt;
  &lt;p class=&quot;proj-sub&quot;&gt;실시간 GLSL을 활용하여 구현한&lt;br&gt;비사실적 렌더링(NPR) 기법 연구 및 셰이더 컬렉션&lt;/p&gt;
  &lt;div class=&quot;tag-row&quot;&gt;
    &lt;span class=&quot;tag&quot;&gt;GLSL&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;HLSL&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Unity&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;URP&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Shadertoy&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;C#&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Overview &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;meta-card&quot;&gt;
  &lt;div class=&quot;meta-grid&quot;&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Period&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;2026.03 – 진행 중&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Type&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;개인 프로젝트&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Role&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;Technical Artist / 셰이더 개발&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Platform&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;Shadertoy (GLSL) · Unity URP / Built-in&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Background &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;bg-text&quot;&gt;
  현대 그래픽스 엔진에서 실사 렌더링은 이미 보편화되었으나, 개발자가 빛의 해석 방식을 직접 제어하여 고유한 예술적 스타일을 구축하는 &lt;strong&gt;NPR(Non-Photorealistic Rendering)&lt;/strong&gt; 영역은 여전히 높은 기술적 이해를 요구합니다.&lt;br&gt;&lt;br&gt;
  스타일라이즈드 게임 및 애니메이션 렌더링에 필수적인 &lt;strong&gt;톤·아웃라인·색상 체계의 직접적인 제어권&lt;/strong&gt;을 확보하기 위해 본 프로젝트를 시작했습니다.
&lt;/p&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Shaders &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;shader-list&quot;&gt;

  &lt;div class=&quot;shader-card&quot;&gt;
    &lt;div class=&quot;shader-header&quot;&gt;
      &lt;span class=&quot;shader-num&quot;&gt;01&lt;/span&gt;
      &lt;span class=&quot;shader-title&quot;&gt;실시간 크로스해칭(Cross-Hatching) 시스템&lt;/span&gt;
      &lt;a href=&quot;https://www.shadertoy.com/view/7f2GWR&quot; target=&quot;_blank&quot; style=&quot;margin-left:auto;font-size:11px;color:var(--green);text-decoration:none;opacity:0.8;white-space:nowrap;&quot;&gt;Shadertoy →&lt;/a&gt;
    &lt;/div&gt;
    &lt;div class=&quot;shader-body&quot;&gt;
      &lt;div class=&quot;shader-row&quot;&gt;
        &lt;div class=&quot;row-label label-concept&quot;&gt;Concept&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;표면 휘도(Luminance)에 따라 펜 드로잉 레이어를 가변적으로 누적하여 수작업 펜화 느낌을 실시간으로 재현하는 셰이더입니다. Praun et al.의 논문을 분석하고 텍스처 없이 순수 GLSL 코드로 재구성했습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;shader-row&quot;&gt;
        &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;0도~135도까지 &lt;strong&gt;45도 간격의 4개 해칭 레이어&lt;/strong&gt;를 블렌딩하는 알고리즘을 설계했습니다. 단순 수식으로 계산된 직선은 3D 모델 위에서 지나치게 정교하게 배치되어 이질감이 생기는 문제가 있었습니다. 이를 해결하기 위해 해칭 선이 진행되는 방향을 따라 &lt;strong&gt;Value Noise를 중첩시켜 선의 굵기와 굴곡에 무작위성&lt;/strong&gt;을 부여했으며, &lt;code&gt;smoothstep&lt;/code&gt;과 &lt;code&gt;fwidth&lt;/code&gt; 함수를 조합하여 해상도 변화에도 선이 깨지지 않는 안티앨리어싱 처리를 수행했습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;shader-row&quot;&gt;
        &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;수치적으로 계산된 패턴이 아닌, 아티스트가 실제 펜으로 덧칠한 듯한 자연스러운 농도 표현과 유기적인 질감을 확보했습니다.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class=&quot;shader-card&quot;&gt;
    &lt;div class=&quot;shader-header&quot;&gt;
      &lt;span class=&quot;shader-num&quot;&gt;02&lt;/span&gt;
      &lt;span class=&quot;shader-title&quot;&gt;고급 툰 셰이딩(Toon Shading) + 림 라이트&lt;/span&gt;
      &lt;a href=&quot;https://www.shadertoy.com/view/sfj3WR&quot; target=&quot;_blank&quot; style=&quot;margin-left:auto;font-size:11px;color:var(--green);text-decoration:none;opacity:0.8;white-space:nowrap;&quot;&gt;Shadertoy →&lt;/a&gt;
    &lt;/div&gt;
    &lt;div class=&quot;shader-body&quot;&gt;
      &lt;div class=&quot;shader-row&quot;&gt;
        &lt;div class=&quot;row-label label-concept&quot;&gt;Concept&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;단순 명암 단계화를 넘어 실제 셀 애니메이션의 채색 기법을 셰이더로 구현했습니다. 빛의 방향과 표면 법선 벡터를 기반으로 색조를 물리적으로 제어합니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;shader-row&quot;&gt;
        &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;&lt;strong&gt;Hue-Shift 기법&lt;/strong&gt;을 적용하여 그림자 영역은 차갑게(Cool), 하이라이트 영역은 따뜻하게(Warm) 색조를 이동시켜 셀 애니메이션의 채색 방식을 재현했습니다. &lt;strong&gt;Fresnel 기반의 림 라이트&lt;/strong&gt;를 구현하고 빛의 방향에 따른 가시성 제어 로직을 최적화했습니다. 아웃라인은 &lt;strong&gt;스크린 스페이스 노멀 불연속성 감지&lt;/strong&gt; 방식으로 처리하여 지오메트리를 복제하지 않고도 외곽선을 렌더링했습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;shader-row&quot;&gt;
        &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;추가 지오메트리 없이 셀 애니메이션 특유의 아웃라인과 색조 분리가 실시간으로 구현되었습니다.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class=&quot;shader-card&quot;&gt;
    &lt;div class=&quot;shader-header&quot;&gt;
      &lt;span class=&quot;shader-num&quot;&gt;03&lt;/span&gt;
      &lt;span class=&quot;shader-title&quot;&gt;Null Cathedral — 프랙탈 레이마칭&lt;/span&gt;
      &lt;a href=&quot;https://www.shadertoy.com/view/73lGzS&quot; target=&quot;_blank&quot; style=&quot;margin-left:auto;font-size:11px;color:var(--green);text-decoration:none;opacity:0.8;white-space:nowrap;&quot;&gt;Shadertoy →&lt;/a&gt;
    &lt;/div&gt;
    &lt;div class=&quot;shader-body&quot;&gt;
      &lt;div class=&quot;shader-row&quot;&gt;
        &lt;div class=&quot;row-label label-concept&quot;&gt;Concept&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;반복적으로 붕괴하는 재귀적 메가구조물을 심우주 중계기 시점으로 포착한 실시간 레이마칭 셰이더입니다. 관측 가능한 공간 너머를 유영하는 구조물의 허구적 기록물 — 수학적 프랙탈 구조를 서사적 미장센으로 치환하는 실험입니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;shader-row&quot;&gt;
        &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;&lt;strong&gt;IFS(Iterated Function System)&lt;/strong&gt; 기반 프랙탈 변환(&lt;code&gt;formula&lt;/code&gt;)을 4회 반복하여 Distance Estimator를 구성하고, 160-step 레이마칭으로 렌더링했습니다. Hue-Shift와 무지갯빛 쉰(&lt;strong&gt;iridescence&lt;/strong&gt;) 레이어를 조합한 색채 시스템, Fresnel 기반 글로우, 5-step 소프트 섀도우, 스크린 스페이스 엣지 검출 아웃라인을 구현했습니다. 포스트 프로세싱으로 &lt;strong&gt;ACES 톤매핑, 크로마틱 어버레이션, 글리치 라인 노이즈, 필름 그레인, 비네팅&lt;/strong&gt;을 적용하여 손상된 전송 신호 느낌을 연출했습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;shader-row&quot;&gt;
        &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;기술적 정밀도와 예술적 연출이 공존하는 실시간 렌더링을 구현했습니다. 순수 수학 함수만으로 건축적 구조물의 질감과 서사적 분위기를 연출했습니다.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Troubleshooting &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;trouble-card&quot;&gt;
  &lt;div class=&quot;trouble-header&quot;&gt;해칭 패턴의 기계적 정교함으로 인한 이질감 제거&lt;/div&gt;
  &lt;div class=&quot;trouble-body&quot;&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;수식으로 계산된 직선 패턴을 적용했을 때, 3D 모델 위에서 선들이 너무 정교하게 배치되어 Hand-drawn feel이 결여되는 문제가 발생했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;label-solve&quot;&gt;Solve&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;해칭 선이 진행되는 방향을 따라 미세한 Value Noise를 중첩하여 선의 굵기와 굴곡에 무작위성을 부여했습니다. &lt;code&gt;smoothstep&lt;/code&gt;과 &lt;code&gt;fwidth&lt;/code&gt; 함수를 조합해 해상도 변화에도 선이 깨지지 않는 안티앨리어싱 처리를 추가했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot; style=&quot;color:#74c0fc;font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding-top:3px;font-family:'Inter',monospace;&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;아티스트가 실제 펜으로 덧칠한 듯한 자연스러운 농도 표현과 유기적인 질감을 확보했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Demo &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div style=&quot;position:relative;z-index:1;display:grid;grid-template-columns:1fr 1fr;gap:12px;&quot;&gt;
  &lt;div style=&quot;background:var(--card);border:1px solid var(--card-border);border-radius:12px;overflow:hidden;&quot;&gt;
    &lt;div style=&quot;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:var(--text-dim);padding:14px 18px 10px;font-weight:600;&quot;&gt;01 · Cross-Hatching Shader&lt;/div&gt;
    &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdwr4U/dJMcahdh1as/fDgWuEO9yz34C1OkhVaQhk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdwr4U/dJMcahdh1as/fDgWuEO9yz34C1OkhVaQhk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdwr4U/dJMcahdh1as/fDgWuEO9yz34C1OkhVaQhk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bdwr4U/dJMcahdh1as/fDgWuEO9yz34C1OkhVaQhk/img.gif&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

  &lt;/div&gt;
  &lt;div style=&quot;background:var(--card);border:1px solid var(--card-border);border-radius:12px;overflow:hidden;&quot;&gt;
    &lt;div style=&quot;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:var(--text-dim);padding:14px 18px 10px;font-weight:600;&quot;&gt;02 · Toon Shading + Rim Light&lt;/div&gt;
    &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baZui5/dJMcajhPOO6/oB3gKizf4waOOi8Ka3JxP0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baZui5/dJMcajhPOO6/oB3gKizf4waOOi8Ka3JxP0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baZui5/dJMcajhPOO6/oB3gKizf4waOOi8Ka3JxP0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/baZui5/dJMcajhPOO6/oB3gKizf4waOOi8Ka3JxP0/img.gif&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

  &lt;/div&gt;
  &lt;div style=&quot;background:var(--card);border:1px solid var(--card-border);border-radius:12px;overflow:hidden;grid-column:1/-1;&quot;&gt;
    &lt;div style=&quot;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:var(--text-dim);padding:14px 18px 10px;font-weight:600;&quot;&gt;03 · Null Cathedral — Fractal Raymarching&lt;/div&gt;
    &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0dLn3/dJMcacwiUXq/4TUdRlCX4bwNEv10PMwbZk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0dLn3/dJMcacwiUXq/4TUdRlCX4bwNEv10PMwbZk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0dLn3/dJMcacwiUXq/4TUdRlCX4bwNEv10PMwbZk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/0dLn3/dJMcacwiUXq/4TUdRlCX4bwNEv10PMwbZk/img.gif&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Links &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;link-row&quot;&gt;
  &lt;a class=&quot;link-btn link-btn-primary&quot; href=&quot;https://github.com/lalunru/npr-shaders&quot; target=&quot;_blank&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.604-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836a9.59 9.59 0 012.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.744 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z&quot;/&gt;&lt;/svg&gt;
    GitHub
  &lt;/a&gt;
  &lt;a class=&quot;link-btn link-btn-secondary&quot; href=&quot;https://www.shadertoy.com/user/aa40272446&quot; target=&quot;_blank&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; style=&quot;opacity:0.7&quot;&gt;&lt;polygon points=&quot;5 3 19 12 5 21 5 3&quot;/&gt;&lt;/svg&gt;
    Shadertoy
  &lt;/a&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Retrospective &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;retro-grid&quot;&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;수학 → 시각&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;수학적 모델을 시각적인 결과물로 치환하는 과정을 통해 Technical Artist로서 필요한 구현 역량을 쌓았습니다. Praun et al. 논문을 분석하고 텍스처 없이 순수 GLSL로 재구성하면서, 렌더링 파이프라인에서 중요한 것은 물리적 시뮬레이션보다 '빛을 어떻게 예술적으로 왜곡할 것인가'에 대한 통제력임을 깨달았습니다.&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;다음 목표&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;Unity URP의 렌더 피처를 활용하여 더욱 복잡한 포스트 프로세싱 효과로 확장해볼 계획입니다. Shadertoy(GLSL)에서 Unity(HLSL)로 이식하는 파이프라인을 완성하고 아티스트가 파라미터를 실시간으로 조정할 수 있는 구조로 발전시키고자 합니다.&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>Projects/Unity</category>
      <category>glsl</category>
      <category>HLSL</category>
      <category>NPR</category>
      <category>Shadertoy</category>
      <category>ToonShading</category>
      <category>Unity</category>
      <category>URP</category>
      <category>셰이더</category>
      <category>포트폴리오</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/8</guid>
      <comments>https://lalunru.tistory.com/entry/npr-shaders#entry8comment</comments>
      <pubDate>Fri, 8 May 2026 19:58:20 +0900</pubDate>
    </item>
    <item>
      <title>[Project] AssetMind &amp;mdash; 금융자산관리 웹 서비스 UI/UX 개발 및 실시간 데이터 처리</title>
      <link>https://lalunru.tistory.com/entry/asset-mind</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&amp;family=Inter:wght@300;400;500;600;700;800&amp;display=swap');

  :root {
    --bg:          #0d1117;
    --card:        rgba(255,255,255,0.04);
    --card-border: rgba(255,255,255,0.08);
    --green:       #2ecc71;
    --text:        #ffffff;
    --text-muted:  rgba(255,255,255,0.45);
    --text-dim:    rgba(255,255,255,0.25);
    --tag-border:  rgba(255,255,255,0.2);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Inter', 'Noto Sans KR', sans-serif;
    background: var(--bg);
    color: var(--text);
    max-width: 800px;
    margin: 0 auto;
    padding: 64px 24px 100px;
    line-height: 1.7;
    position: relative;
  }

  body::before {
    content: '';
    position: fixed;
    inset: 0;
    background-image:
      radial-gradient(1px 1px at 10% 15%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 25% 40%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 40% 8%,  rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 55% 55%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 70% 25%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 80% 70%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 90% 45%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 15% 75%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 60% 88%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 35% 62%, rgba(255,255,255,0.35) 0%, transparent 100%),
      radial-gradient(1.5px 1.5px at 48% 32%, rgba(255,255,255,0.6) 0%, transparent 100%),
      radial-gradient(1px 1px at 73% 80%, rgba(255,255,255,0.25) 0%, transparent 100%),
      radial-gradient(1px 1px at 5%  50%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 88% 12%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 22% 92%, rgba(255,255,255,0.2) 0%, transparent 100%);
    pointer-events: none;
    z-index: 0;
  }

  .breadcrumb { font-size: 12px; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 48px; position: relative; z-index: 1; }
  .breadcrumb span { margin: 0 6px; color: var(--text-dim); }

  .header { position: relative; z-index: 1; margin-bottom: 40px; }
  .category-pill { display: inline-block; background: var(--green); color: #000; font-size: 11px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; margin-bottom: 20px; }
  .status-badge { display: inline-block; background: rgba(46,204,113,0.15); border: 1px solid rgba(46,204,113,0.35); color: var(--green); font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px; margin-left: 10px; vertical-align: middle; }
  .proj-title { font-size: 52px; font-weight: 800; letter-spacing: -2px; line-height: 1.05; margin-bottom: 14px; }
  .proj-sub { font-size: 15px; font-weight: 300; color: var(--text-muted); margin-bottom: 24px; }
  .tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
  .tag { font-size: 12px; color: var(--text-muted); border: 1px solid var(--tag-border); padding: 3px 10px; border-radius: 20px; }

  hr { border: none; border-top: 1px solid rgba(255,255,255,0.07); margin: 48px 0; position: relative; z-index: 1; }

  .section-title { font-size: 36px; font-weight: 800; letter-spacing: -1px; margin-bottom: 28px; position: relative; z-index: 1; display: flex; align-items: center; gap: 6px; }
  .section-title .dot { width: 10px; height: 10px; background: var(--green); border-radius: 50%; flex-shrink: 0; }

  .meta-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 24px 28px; position: relative; z-index: 1; }
  .meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 32px; }
  .meta-key { font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--text-dim); margin-bottom: 4px; }
  .meta-val { font-size: 14px; font-weight: 500; }

  /* 역할 변화 타임라인 */
  .role-timeline { position: relative; z-index: 1; }
  .role-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 20px 24px; display: flex; gap: 20px; align-items: flex-start; margin-bottom: 10px; }
  .role-phase { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--text-dim); min-width: 72px; padding-top: 3px; font-family: 'Inter', monospace; }
  .role-phase.current { color: var(--green); }
  .role-content-wrap { flex: 1; }
  .role-title { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
  .role-desc { font-size: 13px; font-weight: 300; color: var(--text-muted); line-height: 1.75; }
  .role-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
  .role-tag { font-size: 11px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 4px; color: var(--text-dim); }

  .bg-text { font-size: 15px; font-weight: 300; color: var(--text-muted); line-height: 1.9; position: relative; z-index: 1; }
  .bg-text strong { color: var(--text); font-weight: 600; }

  /* 기여 카드 */
  .contrib-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; overflow: hidden; margin-bottom: 16px; position: relative; z-index: 1; }
  .contrib-header { padding: 18px 24px 16px; border-bottom: 1px solid rgba(255,255,255,0.06); display: flex; align-items: center; gap: 12px; }
  .contrib-num { font-size: 11px; font-weight: 700; color: var(--green); letter-spacing: 1px; font-family: 'Inter', monospace; }
  .contrib-title { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; }
  .contrib-body { padding: 0 24px; }
  .contrib-row { display: grid; grid-template-columns: 72px 1fr; gap: 16px; padding: 16px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 14px; line-height: 1.8; }
  .contrib-row:last-child { border-bottom: none; }
  .row-label { font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding-top: 3px; font-family: 'Inter', monospace; }
  .label-problem { color: #ff6b6b; }
  .label-impl    { color: var(--green); }
  .label-result  { color: #74c0fc; }
  .row-content { color: rgba(255,255,255,0.65); font-weight: 300; }
  .row-content strong { color: var(--text); font-weight: 600; }

  code { font-family: 'Inter', monospace; font-size: 12px; color: rgba(255,255,255,0.5); background: rgba(255,255,255,0.07); padding: 1px 6px; border-radius: 4px; }

  /* 트러블슈팅 */
  .trouble-list { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 12px; }
  .trouble-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; overflow: hidden; }
  .trouble-header { padding: 16px 24px; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 14px; font-weight: 600; color: var(--green); letter-spacing: 0.3px; display: flex; align-items: center; gap: 10px; }
  .trouble-num { font-size: 10px; font-family: 'Inter', monospace; color: var(--text-dim); font-weight: 700; letter-spacing: 1px; }
  .trouble-body { padding: 0 24px; }
  .trouble-row { display: grid; grid-template-columns: 72px 1fr; gap: 16px; padding: 14px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 13.5px; line-height: 1.8; }
  .trouble-row:last-child { border-bottom: none; }
  .label-solve { color: #fcc419; font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding-top: 3px; font-family: 'Inter', monospace; }

  /* 링크 */
  .link-row { display: flex; gap: 10px; flex-wrap: wrap; position: relative; z-index: 1; }
  .link-btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; text-decoration: none; transition: background 0.2s, color 0.2s, border-color 0.2s; }
  .link-btn-primary { background: var(--green); color: #000; }
  .link-btn-primary:hover { background: #27ae60; }
  .link-btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--tag-border); }
  .link-btn-secondary:hover { border-color: var(--green); color: var(--green); }
  .link-btn svg { width: 15px; height: 15px; flex-shrink: 0; }

  /* 회고 */
  .retro-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; position: relative; z-index: 1; }
  .retro-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 20px 22px; }
  .retro-key { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--green); margin-bottom: 10px; }
  .retro-val { font-size: 13.5px; font-weight: 300; color: var(--text-muted); line-height: 1.85; }

  @media (max-width: 560px) {
    .proj-title { font-size: 36px; }
    .meta-grid, .retro-grid { grid-template-columns: 1fr; }
    .contrib-row, .trouble-row { grid-template-columns: 60px 1fr; gap: 10px; }
    body { padding: 40px 16px 72px; }
    .section-title { font-size: 28px; }
    .role-card { flex-direction: column; gap: 8px; }
  }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div class=&quot;breadcrumb&quot;&gt;Projects &lt;span&gt;·&lt;/span&gt; 웹 프론트엔드&lt;/div&gt;

&lt;div class=&quot;header&quot;&gt;
  &lt;div class=&quot;category-pill&quot;&gt;웹 프론트엔드&lt;/div&gt;
  &lt;h1 class=&quot;proj-title&quot;&gt;AssetMind&lt;span class=&quot;status-badge&quot;&gt;진행 중&lt;/span&gt;&lt;/h1&gt;
  &lt;p class=&quot;proj-sub&quot;&gt;Figma 디자인부터 디자인 시스템 구축, 실서비스 API 연동까지&lt;br&gt;UI 개발을 전담한 금융자산관리 웹 서비스&lt;/p&gt;
  &lt;div class=&quot;tag-row&quot;&gt;
    &lt;span class=&quot;tag&quot;&gt;React&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;TypeScript&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Tailwind CSS&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Storybook&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Zustand&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;React Query&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;WebSocket (STOMP)&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;MSW&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Vite&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Figma&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Vercel&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Overview &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;meta-card&quot;&gt;
  &lt;div class=&quot;meta-grid&quot;&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Period&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;2026.01 – 2026.06 (진행 중)&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Team&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;4인 팀 프로젝트 (UI · UX · 백엔드 · 데이터)&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Platform&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;Web — PC / Tablet / Mobile&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Infra&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;서버 2대 운영 (동적 서버 + 정적 서버)&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Role &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;role-timeline&quot;&gt;
  &lt;div class=&quot;role-card&quot;&gt;
    &lt;div class=&quot;role-phase&quot;&gt;초기&lt;/div&gt;
    &lt;div class=&quot;role-content-wrap&quot;&gt;
      &lt;div class=&quot;role-title&quot;&gt;UI 개발 전담&lt;/div&gt;
      &lt;div class=&quot;role-desc&quot;&gt;FFigma에서 직접 컬러 팔레트, 타이포그래피, 컴포넌트 시안을 구성하고 이를 토대로 Tailwind CSS 컴포넌트를 구현하였습니다. 또한, Storybook으로 독립적인 문서화 환경을 구축했습니다.&lt;/div&gt;
      &lt;div class=&quot;role-tags&quot;&gt;
        &lt;span class=&quot;role-tag&quot;&gt;컴포넌트 설계&lt;/span&gt;
        &lt;span class=&quot;role-tag&quot;&gt;디자인 시스템&lt;/span&gt;
        &lt;span class=&quot;role-tag&quot;&gt;Storybook&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;role-card&quot; style=&quot;border-color: rgba(46,204,113,0.25);&quot;&gt;
    &lt;div class=&quot;role-phase current&quot;&gt;현재&lt;/div&gt;
    &lt;div class=&quot;role-content-wrap&quot;&gt;
      &lt;div class=&quot;role-title&quot;&gt;UI/UX 전담 — 디자인부터 API 연동까지&lt;/div&gt;
      &lt;div class=&quot;role-desc&quot;&gt;UX팀원 이탈 후 역할을 인수하여, Figma 시안 구현·컬러 팔레트 구성 등 디자인 시스템 전체를 담당하는 동시에, UI 모듈을 조립해 실제 페이지를 구성하고 API 연동까지 직접 처리하고 있습니다. 현재는 성능·안정성 측면의 UX 품질 개선 작업도 함께 진행 중입니다.&lt;/div&gt;
      &lt;div class=&quot;role-tags&quot;&gt;
        &lt;span class=&quot;role-tag&quot;&gt;Figma 시안 구현&lt;/span&gt;
        &lt;span class=&quot;role-tag&quot;&gt;컬러 팔레트 구성&lt;/span&gt;
        &lt;span class=&quot;role-tag&quot;&gt;페이지 조립&lt;/span&gt;
        &lt;span class=&quot;role-tag&quot;&gt;API 연동&lt;/span&gt;
        &lt;span class=&quot;role-tag&quot;&gt;UX 안정성·속도 개선&lt;/span&gt;
        &lt;span class=&quot;role-tag&quot;&gt;리팩터링&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Background &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;bg-text&quot;&gt;
  토스증권 등 기존 금융 서비스를 레퍼런스로 삼아, 복잡한 주식 및 자산 데이터를 &lt;strong&gt;직관적으로 시각화&lt;/strong&gt;하는 서비스를 기획했습니다.&lt;br&gt;&lt;br&gt;
  PC, 태블릿, 모바일 등 다양한 해상도에서 일관된 사용자 경험을 제공하는 &lt;strong&gt;반응형 크로스 플랫폼&lt;/strong&gt;을 구축하는 것을 목표로 개발을 진행했습니다.
&lt;/p&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Contributions &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;01&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;디자인 시스템 구축 — Figma → Tailwind → Storybook&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;Figma에서 직접 &lt;strong&gt;컬러 팔레트, 타이포그래피, 컴포넌트 시안&lt;/strong&gt;을 구성하고 이를 Tailwind CSS로 구현했습니다. Tailwind Config와 Storybook 간 &lt;strong&gt;자동 매핑 시스템&lt;/strong&gt;을 개발하여 색상·타이포그래피 토큰을 시각적으로 동기화하고, Storybook으로 컴포넌트를 독립 문서화하여 팀 내 재사용성과 커뮤니케이션 효율을 높였습니다. Vercel을 활용한 Storybook 자동 배포 파이프라인을 구축하여 PR마다 프리뷰 배포 환경을 제공했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;02&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;반응형 데이터 시각화 및 차트 구현&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;Desktop / Tablet / Mobile 3개 뷰포트별 레이아웃을 구현하고, TradingView 외부 차트 위젯을 React 환경에 맞게 캡슐화했습니다. &lt;code&gt;lightweight-charts&lt;/code&gt; 기반 캔들스틱 차트를 구현하여 1분/5분/일/주/월 기간 탭 전환 시 API params를 분기 처리했습니다. 실시간(Realtime), 시장 휴장(Market-closed), 로딩(Skeleton) 등 &lt;code&gt;pageState&lt;/code&gt;를 정의하여 서비스 상태별 렌더링 케이스를 체계화했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;03&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;실서비스 API 연동 및 실시간 데이터 처리&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;UX 인수 후 Storybook 디자인 시스템 기반 UI 모듈을 조립하여 실제 페이지를 구성하고, 백엔드 API 연동까지 직접 처리했습니다. SockJS를 제거하고 순수 WebSocket + STOMP(&lt;code&gt;@stomp/stompjs&lt;/code&gt;)로 전환하여 MSW mock 환경과의 호환성 문제를 해결했습니다. Zustand store에 &lt;code&gt;mapVersion&lt;/code&gt; 카운터를 도입하여 Map 객체의 참조 비교 한계를 우회, 실시간 랭킹 갱신 시 리렌더를 안정적으로 트리거하는 구조를 설계했습니다. React Query + WebSocket 이중 구독 구조로 호가창 초기 데이터(REST)와 실시간 업데이트(WebSocket)를 분리 처리하는 &lt;code&gt;useOrderbook&lt;/code&gt; 훅을 설계했습니다. 현재는 렌더링 최적화와 안정성 측면의 UX 개선 작업을 진행 중입니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;04&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;MSW 기반 Mock 환경 구축&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;MSW(Mock Service Worker)로 REST API 및 WebSocket 전 구간을 mock하여 백엔드 없이 독립적인 프론트엔드 개발 환경을 구축했습니다. 실제 백엔드 API 스펙(&lt;code&gt;timestamp&lt;/code&gt;, string 타입 필드 등)을 분석하여 mock 응답 구조를 실서버와 동일하게 맞추고, 급등락 알림·랭킹 실시간 업데이트·개별 종목 체결 데이터 등 다양한 WebSocket 토픽을 mock하여 실제와 유사한 시나리오를 시뮬레이션했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;05&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;예외 상태 처리 고도화 및 극단값 레이아웃 보호&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;&lt;code&gt;ErrorBoundary&lt;/code&gt; 컴포넌트를 구현하여 페이지·섹션 단위로 에러를 격리하고 &quot;다시 시도&quot; Fallback UI를 제공했습니다. 로딩(Skeleton), 에러(Error), 빈 데이터(Empty) 세 가지 예외 상태를 각 컴포넌트에 명시적으로 정의했습니다. 99:1과 같은 극단적인 매수/매도 비율 데이터 유입 시에도 게이지 막대가 최소 너비(4px)를 유지하도록 방어 로직을 적용하고, 모바일 뷰에서 호가창을 10호가→5호가 요약형으로 축소하여 가독성을 확보했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Troubleshooting &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;trouble-list&quot;&gt;

  &lt;div class=&quot;trouble-card&quot;&gt;
    &lt;div class=&quot;trouble-header&quot;&gt;
      &lt;span class=&quot;trouble-num&quot;&gt;01&lt;/span&gt;
      SockJS → 순수 WebSocket 전환으로 MSW 호환성 해결
    &lt;/div&gt;
    &lt;div class=&quot;trouble-body&quot;&gt;
      &lt;div class=&quot;trouble-row&quot;&gt;
        &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;SockJS를 사용한 STOMP 연결에서 MSW가 XHR polling 폴백을 처리하지 못해 &lt;code&gt;STOMP Connected&lt;/code&gt; 이벤트가 발생하지 않았습니다. Vite 프록시가 MSW보다 먼저 &lt;code&gt;/ws-stock&lt;/code&gt; 요청을 가로채는 것도 원인 중 하나였습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;trouble-row&quot;&gt;
        &lt;div class=&quot;label-solve&quot;&gt;Solve&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;SockJS를 완전히 제거하고 &lt;code&gt;brokerURL&lt;/code&gt;로 순수 WebSocket을 직접 연결하는 방식으로 전환했습니다. Vite 프록시에서 &lt;code&gt;/ws-stock&lt;/code&gt; 항목을 제거하여 MSW가 WebSocket 요청을 정상 처리하도록 했습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;trouble-row&quot;&gt;
        &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;&lt;code&gt;STOMP Connected&lt;/code&gt; → SUBSCRIBE → 실시간 MESSAGE 수신까지 전 구간이 정상 동작하게 되었습니다.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class=&quot;trouble-card&quot;&gt;
    &lt;div class=&quot;trouble-header&quot;&gt;
      &lt;span class=&quot;trouble-num&quot;&gt;02&lt;/span&gt;
      Map 참조 비교 한계 우회 — &lt;code&gt;mapVersion&lt;/code&gt; 카운터 설계
    &lt;/div&gt;
    &lt;div class=&quot;trouble-body&quot;&gt;
      &lt;div class=&quot;trouble-row&quot;&gt;
        &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;Zustand store에서 &lt;code&gt;stockMap(Map 객체)&lt;/code&gt;을 React 상태로 구독할 때, Worker에서 새 Map 인스턴스로 교체해도 React의 &lt;code&gt;Object.is&lt;/code&gt; 비교가 내용 변경을 감지하지 못해 실시간 갱신 시 UI가 업데이트되지 않았습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;trouble-row&quot;&gt;
        &lt;div class=&quot;label-solve&quot;&gt;Solve&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;store에 &lt;code&gt;mapVersion: number&lt;/code&gt; 카운터를 추가하여 매 업데이트마다 1씩 증가시키고, 컴포넌트에서 &lt;code&gt;mapVersion&lt;/code&gt;을 구독해 &lt;code&gt;useMemo&lt;/code&gt; 재실행을 트리거하는 구조를 설계했습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;trouble-row&quot;&gt;
        &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;실시간 주가 데이터가 초당 1회 갱신되며 현재가·등락률·순위 변동이 UI에 즉시 반영됩니다.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class=&quot;trouble-card&quot;&gt;
    &lt;div class=&quot;trouble-header&quot;&gt;
      &lt;span class=&quot;trouble-num&quot;&gt;03&lt;/span&gt;
      Web Worker &lt;code&gt;window is not defined&lt;/code&gt; — Vite 빌드 이슈 분석
    &lt;/div&gt;
    &lt;div class=&quot;trouble-body&quot;&gt;
      &lt;div class=&quot;trouble-row&quot;&gt;
        &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;Web Worker에서 주가 데이터 정렬을 처리하던 중 &lt;code&gt;window is not defined&lt;/code&gt; 에러가 발생하여 Worker가 응답을 보내지 못했습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;trouble-row&quot;&gt;
        &lt;div class=&quot;label-solve&quot;&gt;Solve&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;Vite가 Worker 번들링 시 &lt;code&gt;env.mjs&lt;/code&gt;를 자동 주입하는데, Worker 환경에는 &lt;code&gt;window&lt;/code&gt; 객체가 없어 크래시가 발생함을 확인했습니다. 80종 데이터에는 성능 차이가 미미함을 PM과 협의 후, Web Worker를 제거하고 메인 스레드에서 직접 정렬을 처리하는 방식으로 전환했습니다.&lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;trouble-row&quot;&gt;
        &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
        &lt;div class=&quot;row-content&quot;&gt;Worker 의존성 제거 후 실시간 갱신이 정상 동작하게 되었습니다.&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Links &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;link-row&quot;&gt;
  &lt;a class=&quot;link-btn link-btn-primary&quot; href=&quot;https://github.com/ASSETMIND/AssetMind&quot; target=&quot;_blank&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.604-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836a9.59 9.59 0 012.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.744 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z&quot;/&gt;&lt;/svg&gt;
    GitHub
  &lt;/a&gt;
  &lt;a class=&quot;link-btn link-btn-secondary&quot; href=&quot;https://assetmind-storybook-qn21uhw9k-lalunrus-projects.vercel.app/?path=/docs/getting-started-introduction--docs&quot; target=&quot;_blank&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot; style=&quot;opacity:0.7&quot;&gt;&lt;path d=&quot;M9.4 2.1L8.3 3.7H2v18.6h20V3.7h-6.3l-1.1-1.6H9.4zM12 6.3c3.1 0 5.6 2.5 5.6 5.6S15.1 17.5 12 17.5 6.4 15 6.4 11.9 8.9 6.3 12 6.3zm0 1.9c-2.1 0-3.7 1.7-3.7 3.7S9.9 15.6 12 15.6s3.7-1.7 3.7-3.7S14.1 8.2 12 8.2zm0 1.5c1.2 0 2.2 1 2.2 2.2S13.2 14.1 12 14.1s-2.2-1-2.2-2.2 1-2.2 2.2-2.2z&quot;/&gt;&lt;/svg&gt;
    Storybook
  &lt;/a&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Retrospective &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;retro-grid&quot;&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;구조적 설계의 중요성&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;컴포넌트 단위로 UI를 설계하고 Storybook과 연동하는 과정에서, 단순한 화면 구현을 넘어 상태와 뷰포트 변화에 유연하게 대응하는 구조적 설계의 중요성을 배웠습니다.&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;브라우저·번들러 깊이&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;SockJS 호환성 문제, Vite 빌드 환경의 Worker 이슈, Map 참조 비교 한계 등 예상치 못한 문제들을 디버깅하며 브라우저와 번들러의 동작 방식에 대한 깊은 이해를 쌓을 수 있었습니다.&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;retro-card&quot; style=&quot;grid-column: 1 / -1;&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;유연한 협업과 오너십&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;UX팀원 이탈이라는 예상치 못한 상황에서 역할을 인수하여, Figma 시안 구현과 컬러 팔레트 구성 등 디자인 시스템 전체를 담당하면서 동시에 UI 모듈로 페이지를 구성하고 API 연동까지 처리하는 작업을 주도했습니다. 현재는 안정성·속도 측면의 UX 개선 작업까지 함께 진행하며, 폭넓은 프론트엔드 역할을 경험하고 있습니다.&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>Projects/Frontend</category>
      <category>MSW</category>
      <category>react</category>
      <category>storybook</category>
      <category>tailwindcss</category>
      <category>TypeScript</category>
      <category>websocket</category>
      <category>zustand</category>
      <category>포트폴리오</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/7</guid>
      <comments>https://lalunru.tistory.com/entry/asset-mind#entry7comment</comments>
      <pubDate>Fri, 8 May 2026 19:45:30 +0900</pubDate>
    </item>
    <item>
      <title>[Unity VR] 성능 한계를 공포 연출로 커버한 라이트맵 최적화 경험</title>
      <link>https://lalunru.tistory.com/entry/unity-vr-lightmap-optimization</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VR 개발에서 프레임레이트는 단순한 성능 지표가 아닙니다. Meta Quest 2의 권장 프레임레이트는 72fps인데, 이를 밑돌면 화면 끊김이 신체적인 불쾌감, 즉 멀미로 직결됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 나폴리탄 괴담 장르를 기반으로 제작한 Meta Quest 2용 1인칭 VR 공포 어드벤처 게임 &lt;b&gt;Line 0&lt;/b&gt;를 개발하던 중 마주친 프레임 저하 문제를 해결한 과정을 공유합니다. 단순히 성능을 끌어올리는 것에서 그치지 않고, 최적화 과정에서 생긴 한계를 공포 게임의 연출로 커버한 경험이기도 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제 상황: 30fps, 멀미가 나는 게임&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 빌드를 플레이테스트했을 때 프레임레이트가 30fps 언저리에 머물렀습니다. 일반 게임이라면 &quot;조금 버벅이는 수준&quot;으로 넘길 수 있지만, VR에서는 달랐습니다. 헤드셋을 쓴 플레이테스터들이 실제로 멀미를 호소했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity Profiler로 병목을 분석한 결과, 원인은 명확했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;씬 전체에 배치된 실시간 조명의 연산 비용이 매 프레임마다 발생하고 있었습니다.&lt;/li&gt;
&lt;li&gt;VR은 좌우 눈 각각에 대해 렌더링을 수행하기 때문에, 일반 3D 게임 대비 GPU 부하가 약 2배에 달합니다.&lt;/li&gt;
&lt;li&gt;실시간 조명과 VR의 이중 렌더링이 맞물리면서 프레임이 목표치의 절반도 채 되지 않았습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해결 방안 모색&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 든 생각은 &quot;조명의 수를 줄이거나 품질을 낮추자&quot;였습니다. 하지만 공포 게임 특성상 조명은 분위기를 결정하는 핵심 요소였고, 단순히 줄이는 것은 게임의 완성도를 떨어뜨리는 선택이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 방향을 완전히 바꿨습니다. 실시간 연산 자체를 없애고, 조명 결과를 텍스처에 미리 구워버리는 베이크 방식으로 전환하기로 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 아이디어는 이것이었습니다. &quot;조명을 실시간으로 계산하는 대신, 미리 계산해서 텍스처에 저장해두자.&quot;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 라이트맵 베이크 전환 적용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unity의 Lighting 설정에서 씬의 모든 조명을 100% 베이크 방식으로 전환하고, 라이트맵을 생성했습니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Mixed Lighting &amp;rarr; Baked Indirect 에서
Baked GI &amp;rarr; 100% Baked 로 전환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 텍스처 해상도도 낮춰 GPU 메모리 부담을 줄였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 기대 이상이었습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;전환 전&lt;/th&gt;
&lt;th&gt;전환 후&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;프레임레이트&lt;/td&gt;
&lt;td&gt;~30fps&lt;/td&gt;
&lt;td&gt;~72fps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실시간 조명 연산&lt;/td&gt;
&lt;td&gt;매 프레임 발생&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;멀미 호소&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 새로운 문제: 실시간 그림자가 사라졌다!&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능 문제는 해결됐지만 새로운 문제가 생겼습니다. 베이크된 라이트맵은 조명 결과를 텍스처에 고정시키기 때문에, 오브젝트의 움직임에 반응하는 실시간 그림자 연산이 불가능해집니다. 플레이어나 NPC가 움직여도 그림자가 따라오지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 게임이라면 치명적인 단점이었겠지만, 저는 여기서 개발 중인 게임의 장르에 주목했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이건 공포 게임이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공포 게임은 원래 플레이어의 시야를 의도적으로 방해합니다. 어두운 환경, 짙은 안개, 제한된 시야각은 공포 연출의 기본 문법입니다. 그림자가 어색해도, 어두우면 보이지 않습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 한계를 연출로 커버하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간 그림자 부재를 숨기기 위해 다음과 같은 연출을 적용했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;베이크된 라이트맵의 전체 명도를 낮춰 씬을 전반적으로 어둡게 처리했습니다.&lt;/li&gt;
&lt;li&gt;지하철 역사 특유의 형광등 깜빡임 효과를 추가해 조명이 불안정한 환경임을 자연스럽게 연출했습니다.&lt;/li&gt;
&lt;li&gt;안개를 적극적으로 활용해 먼 거리의 그림자 부재가 눈에 띄지 않도록 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 플레이테스터들은 그림자 부재를 인식하지 못했고, 오히려 어두운 환경이 공포감을 높이는 데 기여했다는 피드백을 받았습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 마무리 및 회고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경험에서 얻은 가장 큰 교훈은 &lt;b&gt;&quot;기술적 한계가 때로는 연출의 조건이 될 수 있다&quot;&lt;/b&gt; 는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간 그림자를 포기한 것은 처음엔 명백한 결함처럼 느껴졌습니다. 하지만 장르의 특성을 이해하고 있었기에, 그 한계를 오히려 공포 연출의 도구로 전환할 수 있었습니다. 단순히 문제를 해결하는 것을 넘어, 제약 조건 안에서 최선의 결과를 만들어내는 것이 개발의 본질이라는 것을 다시 한번 느꼈습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 전체 코드는 아래 GitHub 레포지토리에서 확인하실 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/lalunru/unity-vr-game-project/&quot;&gt;GitHub - unity-vr-game-project&lt;/a&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Unity 공식 문서 - Lightmapping&lt;/b&gt;: 라이트맵 베이크의 기본 개념과 Unity에서의 설정 방법.&lt;br /&gt;&lt;a href=&quot;https://docs.unity3d.com/Manual/Lightmappers.html&quot;&gt;https://docs.unity3d.com/Manual/Lightmappers.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Unity 공식 문서 - VR Performance Optimization&lt;/b&gt;: Meta Quest 환경에서의 VR 성능 최적화 가이드.&lt;br /&gt;&lt;a href=&quot;https://docs.unity3d.com/Manual/xr-performance-optimization.html&quot;&gt;https://docs.unity3d.com/Manual/xr-performance-optimization.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Meta Quest Developer Docs - Performance Guidelines&lt;/b&gt;: Quest 2의 권장 프레임레이트 및 GPU 최적화 기준.&lt;br /&gt;&lt;a href=&quot;https://developer.oculus.com/documentation/unity/unity-perf/&quot;&gt;https://developer.oculus.com/documentation/unity/unity-perf/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Dev Notes/Unity</category>
      <category>Meta Quest</category>
      <category>Unity</category>
      <category>VR</category>
      <category>게임개발</category>
      <category>공포게임</category>
      <category>라이트맵</category>
      <category>성능최적화</category>
      <category>프레임레이트</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/2</guid>
      <comments>https://lalunru.tistory.com/entry/unity-vr-lightmap-optimization#entry2comment</comments>
      <pubDate>Fri, 8 May 2026 18:38:21 +0900</pubDate>
    </item>
    <item>
      <title>[React] 제네릭을 활용한 재사용 가능한 데이터 카드 컴포넌트 설계</title>
      <link>https://lalunru.tistory.com/entry/react-generic-data-card</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 개발을 진행하다 보면 디자인 형태는 완전히 동일하지만, 주입되는 데이터의 구조가 달라 매번 새로운 컴포넌트를 생성해야 하는 상황을 마주하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 금융 도메인이나 대시보드 형태의 서비스를 개발할 때 이러한 문제가 자주 발생합니다. 주식, 현금, 부동산 등 각 자산군마다 백엔드에서 내려주는 API 응답 형태가 다르기 때문입니다. 이 글에서는 이러한 상황에서 TypeScript의 제네릭을 활용하여 컴포넌트를 유연하게 추상화하고 중복 코드를 제거한 과정을 공유합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제 상황: 늘어나는 보일러플레이트와 중복 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 직관적인 구현을 위해 데이터 타입별로 컴포넌트를 분리했습니다. 백엔드에서 전달받는 주식과 현금 데이터의 타입은 다음과 같이 서로 다른 필드명을 가지고 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;interface StockInfo {
  stockName: string;
  currentPrice: number;
}

interface CashInfo {
  accountName: string;
  balance: number;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 타입 차이 때문에 각 데이터에 맞는 컴포넌트를 일일이 만들어야 했습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ❌ Bad: 데이터 타입마다 컴포넌트를 따로 생성하는 방식
function StockCard({ stockData }: { stockData: StockInfo }) {
  return (
    &amp;lt;div className=&quot;card&quot;&amp;gt;
      &amp;lt;h3&amp;gt;{stockData.stockName}&amp;lt;/h3&amp;gt;
      &amp;lt;p&amp;gt;{stockData.currentPrice}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

function CashCard({ cashData }: { cashData: CashInfo }) {
  return (
    &amp;lt;div className=&quot;card&quot;&amp;gt;
      &amp;lt;h3&amp;gt;{cashData.accountName}&amp;lt;/h3&amp;gt;
      &amp;lt;p&amp;gt;{cashData.balance}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 치명적인 단점이 존재했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UI 디자인(예: &lt;code&gt;.card&lt;/code&gt;의 여백이나 색상)이 변경될 경우 모든 Card 컴포넌트를 찾아가 수정해야 합니다.&lt;/li&gt;
&lt;li&gt;새로운 자산 타입이 추가될 때마다 거의 동일한 형태의 컴포넌트를 새로 작성해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해결 방안 모색: 제어 역전(IoC)과 뷰(View) 계층의 분리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 원인은 UI를 렌더링하는 로직과 데이터의 구조에 의존하는 로직이 강하게 결합되어 있다는 점이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 컴포넌트는 '어떤 형태의 데이터'가 들어오는지 몰라도 되도록, 데이터 추출 로직을 외부로 위임하는 방식을 채택했습니다. 즉, 컴포넌트가 직접 데이터를 해석하는 대신 외부에서 로직을 주입받는 방식인 &lt;b&gt;제어 역전&lt;/b&gt; 개념을 도입했습니다. 이를 통해 컴포넌트는 오직 화면을 그리는 역할에만 집중하도록 설계했습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 제네릭을 활용한 컴포넌트 추상화 적용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TypeScript의 제네릭을 사용하여, 앞서 언급한 제어 역전 패턴을 적용했습니다. 어떤 데이터 타입(&lt;code&gt;T&lt;/code&gt;)이 들어오든 대응할 수 있는 &lt;code&gt;DataCard&lt;/code&gt; 컴포넌트를 구현하고, 객체에서 필요한 값을 뽑아내는 함수를 Props로 주입받도록 설계했습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import React from 'react';

// 공통으로 사용될 Card 컴포넌트의 Props 정의
interface DataCardProps&amp;lt;T&amp;gt; {
  data: T;
  extractTitle: (item: T) =&amp;gt; string;
  extractAmount: (item: T) =&amp;gt; number;
}

export function DataCard&amp;lt;T&amp;gt;({ data, extractTitle, extractAmount }: DataCardProps&amp;lt;T&amp;gt;) {
  return (
    &amp;lt;div className=&quot;rounded-xl border p-6 shadow-sm&quot;&amp;gt;
      &amp;lt;h3 className=&quot;text-lg font-semibold text-gray-800&quot;&amp;gt;
        {extractTitle(data)}
      &amp;lt;/h3&amp;gt;
      &amp;lt;p className=&quot;mt-4 text-2xl font-bold text-gray-900&quot;&amp;gt;
        {extractAmount(data).toLocaleString()}원
      &amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 이 컴포넌트를 사용할 때는 아래와 같이 사용할 데이터와 그 데이터에 맞는 추출 로직만 전달하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;// ✅ Good: 하나의 컴포넌트로 다양한 데이터 타입 대응
function Dashboard() {
  const stock: StockInfo = { stockName: '애플', currentPrice: 150000 };
  const cash: CashInfo = { accountName: '월급통장', balance: 3000000 };

  return (
    &amp;lt;&amp;gt;
      &amp;lt;DataCard
        data={stock}
        extractTitle={(s) =&amp;gt; s.stockName}
        extractAmount={(s) =&amp;gt; s.currentPrice}
      /&amp;gt;
      &amp;lt;DataCard
        data={cash}
        extractTitle={(c) =&amp;gt; c.accountName}
        extractAmount={(c) =&amp;gt; c.balance}
      /&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 마무리 및 회고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제네릭과 제어 역전을 활용하여 컴포넌트를 설계한 결과, UI 코드의 중복을 획기적으로 줄일 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디자인 시스템이 변경되더라도 &lt;code&gt;DataCard&lt;/code&gt; 컴포넌트 하나만 수정하면 전체 서비스에 반영되므로 유지보수성이 크게 향상되었습니다. 단순히 동작하는 코드를 넘어, 변경에 유연하게 대처할 수 있는 아키텍처를 고민하는 것의 중요성을 깨달을 수 있었습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;TypeScript Handbook - Generics&lt;/b&gt;: 다양한 타입에 재사용 가능한 컴포넌트를 생성하는 제네릭의 기본 개념과 활용법.&lt;br /&gt;&lt;a href=&quot;https://www.typescriptlang.org/docs/handbook/2/generics.html&quot;&gt;https://www.typescriptlang.org/docs/handbook/2/generics.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;React 공식 문서 - Passing Props to a Component&lt;/b&gt;: 리액트 컴포넌트 간 데이터를 안전하고 효율적으로 전달하는 방법.&lt;br /&gt;&lt;a href=&quot;https://react.dev/learn/passing-props-to-a-component&quot;&gt;https://react.dev/learn/passing-props-to-a-component&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Kent C. Dodds - Inversion of Control&lt;/b&gt;: 프론트엔드 컴포넌트 설계 시 제어권을 외부로 위임하여 재사용성을 극대화하는 IoC 패턴에 대한 심도 있는 고찰.&lt;br /&gt;&lt;a href=&quot;https://kentcdodds.com/blog/inversion-of-control&quot;&gt;https://kentcdodds.com/blog/inversion-of-control&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Dev Notes/Frontend</category>
      <category>Generic</category>
      <category>IOC</category>
      <category>react</category>
      <category>TypeScript</category>
      <category>리팩토링</category>
      <category>재사용성</category>
      <category>컴포넌트설계</category>
      <category>프론트엔드</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/1</guid>
      <comments>https://lalunru.tistory.com/entry/react-generic-data-card#entry1comment</comments>
      <pubDate>Fri, 8 May 2026 18:37:15 +0900</pubDate>
    </item>
    <item>
      <title>[Project] LSTM 기반 악성 URL 탐지 모델 &amp;mdash; LSTM과 피처 엔지니어링의 결합</title>
      <link>https://lalunru.tistory.com/entry/lstm-malicious-url</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&amp;family=Inter:wght@300;400;500;600;700;800&amp;display=swap');

  :root {
    --bg:          #0d1117;
    --card:        rgba(255,255,255,0.04);
    --card-border: rgba(255,255,255,0.08);
    --green:       #2ecc71;
    --text:        #ffffff;
    --text-muted:  rgba(255,255,255,0.45);
    --text-dim:    rgba(255,255,255,0.25);
    --tag-border:  rgba(255,255,255,0.2);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Inter', 'Noto Sans KR', sans-serif;
    background: var(--bg);
    color: var(--text);
    max-width: 800px;
    margin: 0 auto;
    padding: 64px 24px 100px;
    line-height: 1.7;
    position: relative;
  }

  body::before {
    content: '';
    position: fixed;
    inset: 0;
    background-image:
      radial-gradient(1px 1px at 10% 15%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 25% 40%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 40% 8%,  rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 55% 55%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 70% 25%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 80% 70%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 90% 45%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 15% 75%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 60% 88%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 35% 62%, rgba(255,255,255,0.35) 0%, transparent 100%),
      radial-gradient(1.5px 1.5px at 48% 32%, rgba(255,255,255,0.6) 0%, transparent 100%),
      radial-gradient(1px 1px at 73% 80%, rgba(255,255,255,0.25) 0%, transparent 100%),
      radial-gradient(1px 1px at 5%  50%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 88% 12%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 22% 92%, rgba(255,255,255,0.2) 0%, transparent 100%);
    pointer-events: none;
    z-index: 0;
  }

  .breadcrumb { font-size: 12px; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 48px; position: relative; z-index: 1; }
  .breadcrumb span { margin: 0 6px; color: var(--text-dim); }

  .header { position: relative; z-index: 1; margin-bottom: 40px; }
  .category-pill { display: inline-block; background: var(--green); color: #000; font-size: 11px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; margin-bottom: 20px; }
  .proj-title { font-size: 48px; font-weight: 800; letter-spacing: -2px; line-height: 1.05; margin-bottom: 14px; }
  .proj-sub { font-size: 15px; font-weight: 300; color: var(--text-muted); margin-bottom: 24px; }
  .tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
  .tag { font-size: 12px; color: var(--text-muted); border: 1px solid var(--tag-border); padding: 3px 10px; border-radius: 20px; }

  hr { border: none; border-top: 1px solid rgba(255,255,255,0.07); margin: 48px 0; position: relative; z-index: 1; }

  .section-title { font-size: 36px; font-weight: 800; letter-spacing: -1px; margin-bottom: 28px; position: relative; z-index: 1; display: flex; align-items: center; gap: 6px; }
  .section-title .dot { width: 10px; height: 10px; background: var(--green); border-radius: 50%; flex-shrink: 0; }

  .meta-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 24px 28px; position: relative; z-index: 1; }
  .meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 32px; }
  .meta-key { font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--text-dim); margin-bottom: 4px; }
  .meta-val { font-size: 14px; font-weight: 500; }

  .bg-text { font-size: 15px; font-weight: 300; color: var(--text-muted); line-height: 1.9; position: relative; z-index: 1; }
  .bg-text strong { color: var(--text); font-weight: 600; }

  /* 성능 테이블 */
  .perf-wrap { position: relative; z-index: 1; }
  .perf-table { width: 100%; border-collapse: collapse; font-size: 13px; }
  .perf-table th { font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--text-dim); font-weight: 600; padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,0.08); text-align: left; }
  .perf-table td { padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.05); color: rgba(255,255,255,0.7); }
  .perf-table tr:last-child td { border-bottom: none; }
  .perf-table tr.highlight td { color: var(--text); font-weight: 600; }
  .perf-table tr.highlight td:first-child { color: var(--green); }

  /* 기여 카드 */
  .contrib-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; overflow: hidden; margin-bottom: 16px; position: relative; z-index: 1; }
  .contrib-header { padding: 18px 24px 16px; border-bottom: 1px solid rgba(255,255,255,0.06); display: flex; align-items: center; gap: 12px; }
  .contrib-num { font-size: 11px; font-weight: 700; color: var(--green); letter-spacing: 1px; font-family: 'Inter', monospace; }
  .contrib-title { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; }
  .contrib-body { padding: 0 24px; }
  .contrib-row { display: grid; grid-template-columns: 72px 1fr; gap: 16px; padding: 16px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 14px; line-height: 1.8; }
  .contrib-row:last-child { border-bottom: none; }
  .row-label { font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding-top: 3px; font-family: 'Inter', monospace; }
  .label-problem { color: #ff6b6b; }
  .label-impl    { color: var(--green); }
  .label-result  { color: #74c0fc; }
  .row-content { color: rgba(255,255,255,0.65); font-weight: 300; }
  .row-content strong { color: var(--text); font-weight: 600; }

  code { font-family: 'Inter', monospace; font-size: 12px; color: rgba(255,255,255,0.5); background: rgba(255,255,255,0.07); padding: 1px 6px; border-radius: 4px; }

  /* 트러블슈팅 */
  .trouble-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; overflow: hidden; position: relative; z-index: 1; }
  .trouble-header { padding: 16px 24px; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 14px; font-weight: 600; color: var(--green); letter-spacing: 0.3px; }
  .trouble-body { padding: 0 24px; }
  .trouble-row { display: grid; grid-template-columns: 72px 1fr; gap: 16px; padding: 16px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 14px; line-height: 1.8; }
  .trouble-row:last-child { border-bottom: none; }

  /* 모델 아키텍처 다이어그램 */
  .arch-wrap { position: relative; z-index: 1; }
  .arch-diagram {
    background: var(--card);
    border: 1px solid var(--card-border);
    border-radius: 12px;
    padding: 32px 24px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0;
  }
  .arch-branches {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 32px;
    width: 100%;
    max-width: 560px;
    margin-bottom: 0;
  }
  .arch-branch {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0;
  }
  .arch-branch-label {
    font-size: 10px;
    letter-spacing: 1.5px;
    text-transform: uppercase;
    color: var(--text-dim);
    margin-bottom: 12px;
    font-weight: 600;
  }
  .arch-box {
    width: 100%;
    padding: 10px 14px;
    border-radius: 8px;
    text-align: center;
    font-size: 13px;
    font-weight: 500;
    position: relative;
  }
  .arch-box.input-seq  { background: rgba(99,102,241,0.2);  border: 1px solid rgba(99,102,241,0.4);  color: #a5b4fc; }
  .arch-box.input-feat { background: rgba(46,204,113,0.15); border: 1px solid rgba(46,204,113,0.35); color: #6ee7b7; }
  .arch-box.embed      { background: rgba(99,102,241,0.15); border: 1px solid rgba(99,102,241,0.3);  color: #a5b4fc; }
  .arch-box.dense-feat { background: rgba(46,204,113,0.1);  border: 1px solid rgba(46,204,113,0.25); color: #6ee7b7; }
  .arch-box.lstm       { background: rgba(99,102,241,0.2);  border: 1px solid rgba(99,102,241,0.4);  color: #a5b4fc; }
  .arch-box.concat     { background: rgba(239,68,68,0.15);  border: 1px solid rgba(239,68,68,0.35);  color: #fca5a5; }
  .arch-box.dense-h    { background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.35); color: #fcd34d; }
  .arch-box.dropout    { background: rgba(156,163,175,0.1); border: 1px solid rgba(156,163,175,0.25); color: #9ca3af; }
  .arch-box.output     { background: rgba(46,204,113,0.2);  border: 1px solid rgba(46,204,113,0.4);  color: #6ee7b7; }
  .arch-box .sub { font-size: 11px; opacity: 0.7; margin-top: 2px; }
  .arch-arrow {
    width: 2px;
    height: 20px;
    background: rgba(255,255,255,0.15);
    margin: 0 auto;
    position: relative;
  }
  .arch-arrow::after {
    content: '';
    position: absolute;
    bottom: -4px;
    left: 50%;
    transform: translateX(-50%);
    width: 0;
    height: 0;
    border-left: 4px solid transparent;
    border-right: 4px solid transparent;
    border-top: 5px solid rgba(255,255,255,0.2);
  }
  .arch-merge-section {
    width: 100%;
    max-width: 560px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0;
    margin-top: 0;
  }
  .arch-merge-arrow {
    display: flex;
    width: 100%;
    justify-content: center;
    gap: 32px;
    margin-bottom: 0;
  }
  .arch-merge-line {
    width: 2px;
    height: 20px;
    background: rgba(255,255,255,0.15);
  }

  /* 링크 */
  .link-row { display: flex; gap: 10px; flex-wrap: wrap; position: relative; z-index: 1; }
  .link-btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; text-decoration: none; transition: background 0.2s, color 0.2s, border-color 0.2s; }
  .link-btn-primary { background: var(--green); color: #000; }
  .link-btn-primary:hover { background: #27ae60; }
  .link-btn svg { width: 15px; height: 15px; flex-shrink: 0; }

  /* 회고 */
  .retro-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; position: relative; z-index: 1; }
  .retro-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 20px 22px; }
  .retro-key { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--green); margin-bottom: 10px; }
  .retro-val { font-size: 13.5px; font-weight: 300; color: var(--text-muted); line-height: 1.85; }

  @media (max-width: 560px) {
    .proj-title { font-size: 32px; }
    .meta-grid, .retro-grid { grid-template-columns: 1fr; }
    .contrib-row, .trouble-row { grid-template-columns: 60px 1fr; gap: 10px; }
    body { padding: 40px 16px 72px; }
    .section-title { font-size: 28px; }
    .arch-branches { grid-template-columns: 1fr; gap: 0; }
  }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div class=&quot;breadcrumb&quot;&gt;Projects &lt;span&gt;·&lt;/span&gt; 딥러닝&lt;/div&gt;

&lt;div class=&quot;header&quot;&gt;
  &lt;div class=&quot;category-pill&quot;&gt;딥러닝&lt;/div&gt;
  &lt;h1 class=&quot;proj-title&quot;&gt;악성 URL 분류&lt;br&gt;딥러닝 모델&lt;/h1&gt;
  &lt;p class=&quot;proj-sub&quot;&gt;URL 문자 시퀀스와 보안 도메인 지식 기반 수작업 특성을 결합한&lt;br&gt;하이브리드 멀티 인풋 딥러닝 탐지 모델&lt;/p&gt;
  &lt;div class=&quot;tag-row&quot;&gt;
    &lt;span class=&quot;tag&quot;&gt;Python&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;TensorFlow / Keras&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;LSTM&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Pandas&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;scikit-learn&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Google Colab&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Overview &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;meta-card&quot;&gt;
  &lt;div class=&quot;meta-grid&quot;&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Period&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;2025.05 – 2025.06&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Type&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;개인 프로젝트&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Role&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;AI 모델 설계 · 데이터 전처리 및 분석&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Dataset&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;Kaggle Malicious URLs Dataset (651,195개)&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Background &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;bg-text&quot;&gt;
  새로운 유형의 악성 URL이 급증하면서 기존의 규칙 기반 탐지 방식의 한계가 명확해졌습니다. 공격 패턴의 변화를 유연하게 식별하기 위해서는 학습 기반 모델의 도입이 필수적이었습니다.&lt;br&gt;&lt;br&gt;
  단순한 텍스트 구조만으로는 정상 사이트를 교묘하게 모방한 URL을 분류하기 어렵기 때문에, URL의 문맥을 파악하는 &lt;strong&gt;LSTM 신경망&lt;/strong&gt;과 보안 도메인 지식이 반영된 &lt;strong&gt;수작업 특성(Handcrafted Features)&lt;/strong&gt;을 결합한 멀티 인풋 신경망 기반 탐지 모델을 설계했습니다.
&lt;/p&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Model Architecture &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;arch-wrap&quot;&gt;
  &lt;div class=&quot;arch-diagram&quot;&gt;
    &lt;div class=&quot;arch-branches&quot;&gt;
      &lt;!-- Branch 1: URL Sequence --&gt;
      &lt;div class=&quot;arch-branch&quot;&gt;
        &lt;div class=&quot;arch-branch-label&quot;&gt;Branch 1 — URL Sequence&lt;/div&gt;
        &lt;div class=&quot;arch-box input-seq&quot;&gt;
          URL Text Sequence
          &lt;div class=&quot;sub&quot;&gt;문자 단위 토큰화 · pad_sequences(100)&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;arch-arrow&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;arch-box embed&quot;&gt;
          Embedding Layer
          &lt;div class=&quot;sub&quot;&gt;vocab=10,000 · dim=64&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;arch-arrow&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;arch-box lstm&quot;&gt;
          LSTM (64 units)
          &lt;div class=&quot;sub&quot;&gt;문자 순서 · 패턴 · 맥락 학습&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;arch-arrow&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;arch-box&quot; style=&quot;background:rgba(99,102,241,0.12);border:1px solid rgba(99,102,241,0.25);color:#a5b4fc;&quot;&gt;
          → 64-dim output
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;!-- Branch 2: Handcrafted Features --&gt;
      &lt;div class=&quot;arch-branch&quot;&gt;
        &lt;div class=&quot;arch-branch-label&quot;&gt;Branch 2 — Handcrafted Features&lt;/div&gt;
        &lt;div class=&quot;arch-box input-feat&quot;&gt;
          9가지 보안 특성
          &lt;div class=&quot;sub&quot;&gt;엔트로피 · URL 길이 · IP 여부 등&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;arch-arrow&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;arch-box dense-feat&quot;&gt;
          Dense (32, ReLU)
        &lt;/div&gt;
        &lt;div class=&quot;arch-arrow&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;arch-box&quot; style=&quot;background:rgba(46,204,113,0.08);border:1px solid rgba(46,204,113,0.2);color:#6ee7b7;&quot;&gt;
          → 32-dim output
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;!-- Merge --&gt;
    &lt;div class=&quot;arch-merge-section&quot;&gt;
      &lt;div class=&quot;arch-merge-arrow&quot;&gt;
        &lt;div class=&quot;arch-merge-line&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;arch-merge-line&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;
      &lt;div style=&quot;width:100%;max-width:560px;&quot;&gt;
        &lt;div class=&quot;arch-box concat&quot; style=&quot;margin:0 auto;max-width:280px;&quot;&gt;
          Concatenate (96-dim)
          &lt;div class=&quot;sub&quot;&gt;64 + 32 = 96&lt;/div&gt;
        &lt;/div&gt;
        &lt;div class=&quot;arch-arrow&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;arch-box dense-h&quot; style=&quot;margin:0 auto;max-width:280px;&quot;&gt;
          Dense (64, ReLU)
        &lt;/div&gt;
        &lt;div class=&quot;arch-arrow&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;arch-box dropout&quot; style=&quot;margin:0 auto;max-width:280px;&quot;&gt;
          Dropout (0.5)
        &lt;/div&gt;
        &lt;div class=&quot;arch-arrow&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;arch-box output&quot; style=&quot;margin:0 auto;max-width:280px;&quot;&gt;
          Output · Dense (4, Softmax)
          &lt;div class=&quot;sub&quot;&gt;Benign / Phishing / Malware / Defacement&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Performance &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;perf-wrap&quot;&gt;
  &lt;div class=&quot;meta-card&quot; style=&quot;padding:0;&quot;&gt;
    &lt;table class=&quot;perf-table&quot;&gt;
      &lt;thead&gt;
        &lt;tr&gt;
          &lt;th&gt;클래스&lt;/th&gt;
          &lt;th&gt;Precision&lt;/th&gt;
          &lt;th&gt;Recall&lt;/th&gt;
          &lt;th&gt;F1-Score&lt;/th&gt;
          &lt;th&gt;Support&lt;/th&gt;
        &lt;/tr&gt;
      &lt;/thead&gt;
      &lt;tbody&gt;
        &lt;tr&gt;
          &lt;td&gt;Benign&lt;/td&gt;
          &lt;td&gt;0.87&lt;/td&gt;&lt;td&gt;0.94&lt;/td&gt;&lt;td&gt;0.90&lt;/td&gt;&lt;td&gt;6,018&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr class=&quot;highlight&quot;&gt;
          &lt;td&gt;Defacement ★&lt;/td&gt;
          &lt;td&gt;1.00&lt;/td&gt;&lt;td&gt;0.99&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.99&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;6,055&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
          &lt;td&gt;Malware&lt;/td&gt;
          &lt;td&gt;0.97&lt;/td&gt;&lt;td&gt;0.87&lt;/td&gt;&lt;td&gt;0.92&lt;/td&gt;&lt;td&gt;4,733&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
          &lt;td&gt;Phishing&lt;/td&gt;
          &lt;td&gt;0.86&lt;/td&gt;&lt;td&gt;0.87&lt;/td&gt;&lt;td&gt;0.86&lt;/td&gt;&lt;td&gt;5,923&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr style=&quot;border-top: 1px solid rgba(255,255,255,0.12);&quot;&gt;
          &lt;td style=&quot;color:var(--green);font-weight:600;&quot;&gt;전체 정확도&lt;/td&gt;
          &lt;td colspan=&quot;3&quot; style=&quot;color:var(--text);font-weight:600;&quot;&gt;92% (Accuracy 0.92 · F1-macro 0.92)&lt;/td&gt;
          &lt;td&gt;22,729&lt;/td&gt;
        &lt;/tr&gt;
      &lt;/tbody&gt;
    &lt;/table&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Contributions &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;01&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;멀티 인풋 딥러닝 아키텍처 설계&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;URL 문자열 시퀀스만으로 학습시켰을 때, 정상 사이트를 교묘하게 모방한 피싱 URL의 분류 성능에 한계가 있었습니다. LSTM 단독 구조로는 탐지하기 어려운 이상 징후가 존재했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;
        &lt;strong&gt;Branch 1 (LSTM)&lt;/strong&gt;: URL 문자를 정수로 변환 후 &lt;code&gt;pad_sequences(maxlen=100)&lt;/code&gt;로 고정 길이 패딩. &lt;code&gt;Embedding(vocab=10000, dim=64)&lt;/code&gt; → &lt;code&gt;LSTM(64)&lt;/code&gt;로 문자 순서·패턴·맥락을 학습.&lt;br&gt;&lt;br&gt;
        &lt;strong&gt;Branch 2 (Handcrafted)&lt;/strong&gt;: &lt;code&gt;URLFeatureAnalyzer&lt;/code&gt; 클래스를 구현해 엔트로피, URL 길이, 숫자 수, 파라미터 수, IP 주소 여부, &lt;code&gt;@&lt;/code&gt;·&lt;code&gt;%20&lt;/code&gt; 포함 여부 등 9가지 보안 도메인 특성을 추출. &lt;code&gt;Dense(32, ReLU)&lt;/code&gt;로 처리.&lt;br&gt;&lt;br&gt;
        두 분기의 출력을 &lt;code&gt;Concatenate&lt;/code&gt;로 병합한 뒤 &lt;code&gt;Dense(64) → Dropout(0.5) → Dense(4, Softmax)&lt;/code&gt; 구조로 4클래스 분류.
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;LSTM 단독 구조 대비 피싱 클래스 탐지 성능이 향상. 전체 정확도 &lt;strong&gt;92%&lt;/strong&gt; 달성. 특히 URL 패턴이 뚜렷한 Defacement 클래스에서 &lt;strong&gt;F1-Score 0.99&lt;/strong&gt;를 기록.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;02&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;대규모 데이터셋 전처리 및 클래스 균형 조정&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;총 651,195개 샘플 중 Malware 클래스(32,520개)가 Benign(428,103개)에 비해 현저히 적어 클래스 불균형 문제가 발생했습니다. 불균형 상태로 학습 시 다수 클래스에 편향된 모델이 생성될 위험이 있었습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;결측치·중복 제거 후 라벨별로 데이터를 분리하고, Malware를 제외한 나머지 3개 클래스(Benign, Defacement, Phishing)에서 각 &lt;code&gt;30,000&lt;/code&gt;개를 &lt;code&gt;sample(random_state=101)&lt;/code&gt;로 샘플링했습니다. Malware는 전체(23,645개)를 사용해 최종 113,645개의 균형 잡힌 데이터셋을 구성했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;클래스 편향 없이 균형 잡힌 학습 데이터를 확보하여, 소수 클래스인 Malware도 F1-Score 0.92로 안정적인 탐지 성능을 달성했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Troubleshooting &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;trouble-card&quot;&gt;
  &lt;div class=&quot;trouble-header&quot;&gt;피싱 URL 분류 성능 개선 — 시퀀스 모델의 한계를 피처 엔지니어링으로 보완&lt;/div&gt;
  &lt;div class=&quot;trouble-body&quot;&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;LSTM만으로 학습했을 때, 피싱 사이트가 정상 URL과 외형적으로 유사하게 설계되기 때문에 문자 시퀀스 패턴만으로는 충분한 판별 근거가 부족했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label&quot; style=&quot;color:#fcc419;font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding-top:3px;font-family:'Inter',monospace;&quot;&gt;Solve&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;피처 엔지니어링을 도입해 보안 전문가의 시각으로 이상 징후를 수치화했습니다. &lt;strong&gt;URL 엔트로피&lt;/strong&gt;(난독화된 문자열 탐지), &lt;strong&gt;IP 직접 입력 여부&lt;/strong&gt;(DNS 필터링 우회 탐지), &lt;code&gt;@&lt;/code&gt; 기호 수, &lt;code&gt;%20&lt;/code&gt; 인코딩 수 등 9가지 지표를 &lt;code&gt;URLFeatureAnalyzer&lt;/code&gt; 클래스로 추출하여 LSTM 출력과 병렬로 병합했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;두 입력 방식이 상호 보완적으로 작용하여 피싱 클래스의 탐지 성능이 향상되었으며, 전체 정확도 및 F1-Score 모두 &lt;strong&gt;92%&lt;/strong&gt;를 달성했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Links &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;link-row&quot;&gt;
  &lt;a class=&quot;link-btn link-btn-primary&quot; href=&quot;https://github.com/lalunru/Malicious-URL-Classification&quot; target=&quot;_blank&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.604-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836a9.59 9.59 0 012.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.744 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z&quot;/&gt;&lt;/svg&gt;
    GitHub
  &lt;/a&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Retrospective &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;retro-grid&quot;&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;핵심 경험&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;딥러닝 모델 설계 시 데이터의 성격에 따라 단일 모델보다 멀티 인풋 구조가 더 견고하고 높은 성능을 낼 수 있음을 배웠습니다. LSTM을 보안 영역에 적용하며 모델의 추론 능력에만 의존하지 않고 도메인 지식을 피처로 개입시켰을 때 생기는 시너지를 경험했습니다.&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;공격자 시각의 중요성&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;오답률이 높았던 피싱 클래스를 분석하며, 방어 모델을 개선하기 위해서는 공격자의 우회 의도를 논리적으로 파악하는 관점이 필수적임을 깨달았습니다. 이는 단순 성능 지표 개선을 넘어 도메인 이해의 중요성을 일깨워 준 경험이었습니다.&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>Projects/Machine learning</category>
      <category>Keras</category>
      <category>LSTM</category>
      <category>Python</category>
      <category>tensorflow</category>
      <category>딥러닝</category>
      <category>악성URL</category>
      <category>포트폴리오</category>
      <category>피처엔지니어링</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/6</guid>
      <comments>https://lalunru.tistory.com/entry/lstm-malicious-url#entry6comment</comments>
      <pubDate>Fri, 8 May 2026 18:33:57 +0900</pubDate>
    </item>
    <item>
      <title>[Project] 설명 가능한 인공지능(XAI)을 활용한 네트워크 침입 탐지 시스템의 개발 &amp;mdash; 블랙박스 모델의 판단 근거를 설명하다</title>
      <link>https://lalunru.tistory.com/entry/xai-ids</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&amp;family=Inter:wght@300;400;500;600;700;800&amp;display=swap');

  :root {
    --bg:          #0d1117;
    --card:        rgba(255,255,255,0.04);
    --card-border: rgba(255,255,255,0.08);
    --green:       #2ecc71;
    --text:        #ffffff;
    --text-muted:  rgba(255,255,255,0.45);
    --text-dim:    rgba(255,255,255,0.25);
    --tag-border:  rgba(255,255,255,0.2);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Inter', 'Noto Sans KR', sans-serif;
    background: var(--bg);
    color: var(--text);
    max-width: 800px;
    margin: 0 auto;
    padding: 64px 24px 100px;
    line-height: 1.7;
    position: relative;
  }

  body::before {
    content: '';
    position: fixed;
    inset: 0;
    background-image:
      radial-gradient(1px 1px at 10% 15%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 25% 40%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 40% 8%,  rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 55% 55%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 70% 25%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 80% 70%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 90% 45%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 15% 75%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 60% 88%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 35% 62%, rgba(255,255,255,0.35) 0%, transparent 100%),
      radial-gradient(1.5px 1.5px at 48% 32%, rgba(255,255,255,0.6) 0%, transparent 100%),
      radial-gradient(1px 1px at 73% 80%, rgba(255,255,255,0.25) 0%, transparent 100%),
      radial-gradient(1px 1px at 5%  50%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 88% 12%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 22% 92%, rgba(255,255,255,0.2) 0%, transparent 100%);
    pointer-events: none;
    z-index: 0;
  }

  .breadcrumb {
    font-size: 12px; color: var(--text-muted); letter-spacing: 0.5px;
    margin-bottom: 48px; position: relative; z-index: 1;
  }
  .breadcrumb span { margin: 0 6px; color: var(--text-dim); }

  .header { position: relative; z-index: 1; margin-bottom: 40px; }
  .category-pill {
    display: inline-block; background: var(--green); color: #000;
    font-size: 11px; font-weight: 700; letter-spacing: 1.5px;
    text-transform: uppercase; padding: 4px 12px; border-radius: 20px; margin-bottom: 20px;
  }
  .proj-title {
    font-size: 48px; font-weight: 800; letter-spacing: -2px;
    line-height: 1.05; margin-bottom: 14px;
  }
  .proj-sub { font-size: 15px; font-weight: 300; color: var(--text-muted); margin-bottom: 24px; }
  .tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
  .tag {
    font-size: 12px; color: var(--text-muted);
    border: 1px solid var(--tag-border); padding: 3px 10px; border-radius: 20px;
  }

  hr { border: none; border-top: 1px solid rgba(255,255,255,0.07); margin: 48px 0; position: relative; z-index: 1; }

  .section-title {
    font-size: 36px; font-weight: 800; letter-spacing: -1px;
    margin-bottom: 28px; position: relative; z-index: 1;
    display: flex; align-items: center; gap: 6px;
  }
  .section-title .dot { width: 10px; height: 10px; background: var(--green); border-radius: 50%; flex-shrink: 0; }

  .meta-card {
    background: var(--card); border: 1px solid var(--card-border);
    border-radius: 12px; padding: 24px 28px; position: relative; z-index: 1;
  }
  .meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 32px; }
  .meta-key { font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--text-dim); margin-bottom: 4px; }
  .meta-val { font-size: 14px; font-weight: 500; }

  .bg-text { font-size: 15px; font-weight: 300; color: var(--text-muted); line-height: 1.9; position: relative; z-index: 1; }
  .bg-text strong { color: var(--text); font-weight: 600; }

  /* 성능 테이블 */
  .perf-table {
    width: 100%; border-collapse: collapse;
    position: relative; z-index: 1;
    font-size: 13px;
  }
  .perf-table th {
    font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase;
    color: var(--text-dim); font-weight: 600; padding: 10px 16px;
    border-bottom: 1px solid rgba(255,255,255,0.08); text-align: left;
  }
  .perf-table td { padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.05); color: rgba(255,255,255,0.7); }
  .perf-table tr:last-child td { border-bottom: none; }
  .perf-table tr.highlight td { color: var(--text); font-weight: 600; }
  .perf-table tr.highlight td:first-child { color: var(--green); }
  .bar-cell { display: flex; align-items: center; gap: 8px; }
  .bar { height: 4px; border-radius: 99px; background: var(--green); opacity: 0.7; }

  /* 기여 카드 */
  .contrib-card {
    background: var(--card); border: 1px solid var(--card-border);
    border-radius: 12px; overflow: hidden; margin-bottom: 16px; position: relative; z-index: 1;
  }
  .contrib-header {
    padding: 18px 24px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
    display: flex; align-items: center; gap: 12px;
  }
  .contrib-num { font-size: 11px; font-weight: 700; color: var(--green); letter-spacing: 1px; font-family: 'Inter', monospace; }
  .contrib-title { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; }
  .contrib-body { padding: 0 24px; }
  .contrib-row {
    display: grid; grid-template-columns: 72px 1fr; gap: 16px;
    padding: 16px 0; border-bottom: 1px solid rgba(255,255,255,0.05);
    font-size: 14px; line-height: 1.8;
  }
  .contrib-row:last-child { border-bottom: none; }
  .row-label { font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding-top: 3px; font-family: 'Inter', monospace; }
  .label-problem { color: #ff6b6b; }
  .label-impl    { color: var(--green); }
  .label-result  { color: #74c0fc; }
  .row-content { color: rgba(255,255,255,0.65); font-weight: 300; }
  .row-content strong { color: var(--text); font-weight: 600; }

  code {
    font-family: 'Inter', monospace; font-size: 12px;
    color: rgba(255,255,255,0.5); background: rgba(255,255,255,0.07);
    padding: 1px 6px; border-radius: 4px;
  }

  /* 트러블슈팅 */
  .trouble-card {
    background: var(--card); border: 1px solid var(--card-border);
    border-radius: 12px; overflow: hidden; position: relative; z-index: 1;
  }
  .trouble-header {
    padding: 16px 24px; border-bottom: 1px solid rgba(255,255,255,0.06);
    font-size: 14px; font-weight: 600; color: var(--green); letter-spacing: 0.3px;
  }
  .trouble-body { padding: 0 24px; }
  .trouble-row {
    display: grid; grid-template-columns: 72px 1fr; gap: 16px;
    padding: 16px 0; border-bottom: 1px solid rgba(255,255,255,0.05);
    font-size: 14px; line-height: 1.8;
  }
  .trouble-row:last-child { border-bottom: none; }

  /* XAI 결과 그리드 */
  .xai-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; position: relative; z-index: 1; }
  .xai-card {
    background: var(--card); border: 1px solid var(--card-border);
    border-radius: 12px; padding: 20px 22px;
  }
  .xai-key { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--green); margin-bottom: 10px; }
  .xai-val { font-size: 13.5px; font-weight: 300; color: var(--text-muted); line-height: 1.85; }

  /* 링크 */
  .link-row { display: flex; gap: 10px; flex-wrap: wrap; position: relative; z-index: 1; }
  .link-btn {
    display: inline-flex; align-items: center; gap: 8px;
    padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 500;
    text-decoration: none; transition: background 0.2s, color 0.2s, border-color 0.2s;
  }
  .link-btn-primary { background: var(--green); color: #000; }
  .link-btn-primary:hover { background: #27ae60; }
  .link-btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--tag-border); }
  .link-btn-secondary:hover { border-color: var(--green); color: var(--green); }
  .link-btn svg { width: 15px; height: 15px; flex-shrink: 0; }

  /* 회고 */
  .retro-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; position: relative; z-index: 1; }
  .retro-card { background: var(--card); border: 1px solid var(--card-border); border-radius: 12px; padding: 20px 22px; }
  .retro-key { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--green); margin-bottom: 10px; }
  .retro-val { font-size: 13.5px; font-weight: 300; color: var(--text-muted); line-height: 1.85; }

  @media (max-width: 560px) {
    .proj-title { font-size: 32px; }
    .meta-grid, .retro-grid, .xai-grid { grid-template-columns: 1fr; }
    .contrib-row, .trouble-row { grid-template-columns: 60px 1fr; gap: 10px; }
    body { padding: 40px 16px 72px; }
    .section-title { font-size: 28px; }
  }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div class=&quot;breadcrumb&quot;&gt;Projects &lt;span&gt;·&lt;/span&gt; AI / XAI&lt;/div&gt;

&lt;div class=&quot;header&quot;&gt;
  &lt;div class=&quot;category-pill&quot;&gt;AI / XAI&lt;/div&gt;
  &lt;h1 class=&quot;proj-title&quot;&gt;XAI 기반&lt;br&gt;네트워크 침입 탐지 시스템&lt;/h1&gt;
  &lt;p class=&quot;proj-sub&quot;&gt;설명 가능한 인공지능(XAI)을 활용한 네트워크 침입 탐지 시스템 개발 — 졸업논문&lt;/p&gt;
  &lt;div class=&quot;tag-row&quot;&gt;
    &lt;span class=&quot;tag&quot;&gt;Python&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;XGBoost&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;SHAP&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;LIME&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;scikit-learn&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Pandas&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Google Colab&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Overview &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;meta-card&quot;&gt;
  &lt;div class=&quot;meta-grid&quot;&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Period&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;2025.03 – 2025.11&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Type&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;개인 프로젝트 (졸업논문)&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Role&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;AI 모델 설계 · XAI 파이프라인 구축 전담&lt;/div&gt;
    &lt;/div&gt;
    &lt;div&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Dataset&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;UNSW-NB15 (82,332 / 175,341 샘플)&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Background &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;bg-text&quot;&gt;
  기존 머신러닝 기반 침입 탐지 시스템(IDS)은 높은 성능에도 불구하고 판단 근거를 알 수 없는 &lt;strong&gt;블랙박스(Black-box)&lt;/strong&gt; 구조로 인해 보안 현장에서의 신뢰성이 낮다는 한계가 있었습니다.&lt;br&gt;&lt;br&gt;
  단순 탐지를 넘어 &lt;strong&gt;'왜 해당 트래픽이 공격으로 판단되었는가'&lt;/strong&gt;에 대한 객관적 근거를 제시할 수 있는 XAI(Explainable AI) 기술을 통합하여, 보안 전문가의 대응 정책 수립을 지원하는 차세대 IDS 프로토타입 개발을 목표로 연구를 진행했습니다.
&lt;/p&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Model Performance &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;meta-card&quot; style=&quot;padding: 0;&quot;&gt;
  &lt;table class=&quot;perf-table&quot;&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;모델&lt;/th&gt;
        &lt;th&gt;정확도&lt;/th&gt;
        &lt;th&gt;정밀도&lt;/th&gt;
        &lt;th&gt;재현율&lt;/th&gt;
        &lt;th&gt;F1-Score&lt;/th&gt;
        &lt;th&gt;ROC-AUC&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;의사결정트리&lt;/td&gt;
        &lt;td&gt;0.86&lt;/td&gt;&lt;td&gt;0.85&lt;/td&gt;&lt;td&gt;0.84&lt;/td&gt;&lt;td&gt;0.84&lt;/td&gt;&lt;td&gt;0.87&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;MLP&lt;/td&gt;
        &lt;td&gt;0.90&lt;/td&gt;&lt;td&gt;0.91&lt;/td&gt;&lt;td&gt;0.89&lt;/td&gt;&lt;td&gt;0.90&lt;/td&gt;&lt;td&gt;0.92&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr class=&quot;highlight&quot;&gt;
        &lt;td&gt;XGBoost ★&lt;/td&gt;
        &lt;td&gt;0.97&lt;/td&gt;&lt;td&gt;0.96&lt;/td&gt;&lt;td&gt;0.95&lt;/td&gt;&lt;td&gt;0.95&lt;/td&gt;&lt;td&gt;0.97&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Contributions &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;01&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;하이브리드 탐지 모델 설계 및 성능 최적화&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;단일 모델로는 탐지 성능과 해석 가능성을 동시에 확보하기 어려웠습니다. 화이트박스 모델은 해석은 쉽지만 성능이 낮고, 블랙박스 모델은 성능은 높지만 판단 근거를 설명할 수 없는 상충 관계가 존재했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;의사결정트리(화이트박스), MLP, XGBoost(블랙박스) 세 모델을 동일한 파이프라인에서 비교 실험했습니다. &lt;code&gt;StratifiedKFold&lt;/code&gt; 5겹 교차 검증으로 클래스 불균형 문제를 완화하고, XGBoost는 &lt;code&gt;n_estimators=300&lt;/code&gt;, &lt;code&gt;early_stopping_rounds=20&lt;/code&gt;으로 과적합을 방지했습니다. 의사결정트리를 해석의 기준선(baseline)으로 설정하여 블랙박스 모델의 XAI 결과를 검증하는 3계층 비교 구조를 설계했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;XGBoost 모델이 &lt;strong&gt;F1-Score 0.95, ROC-AUC 0.97&lt;/strong&gt;로 가장 우수한 탐지 성능을 달성했으며, 의사결정트리 대비 성능 격차를 정량적으로 검증했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;02&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;블랙박스 해소를 위한 XAI 파이프라인 통합&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;XGBoost와 MLP는 높은 탐지 성능을 보이지만 내부 연산 구조가 복잡해 판단 근거를 사람이 이해할 수 없었습니다. 보안 전문가가 탐지 결과를 신뢰하고 대응 정책을 수립하기 위해서는 '왜 공격으로 판단했는지'에 대한 설명이 필요했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;MLP 모델에 &lt;strong&gt;LIME&lt;/strong&gt;(&lt;code&gt;LimeTabularExplainer&lt;/code&gt;)을 적용해 개별 샘플 단위의 지역적 예측 근거를 시각화했습니다. XGBoost 모델에는 &lt;strong&gt;SHAP&lt;/strong&gt;(&lt;code&gt;TreeExplainer&lt;/code&gt;)을 적용해 전역적 특성 중요도(Summary Plot), 특성 간 상호작용(Dependence Plot), 단일 예측 기여도(Force Plot), 누적 의사결정 경로(Decision Plot) 등 4가지 시각화를 구현했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;LIME 분석에서 MLP가 &lt;code&gt;sttl&lt;/code&gt;, &lt;code&gt;ct_dst_sport_ltm&lt;/code&gt;, &lt;code&gt;sbytes&lt;/code&gt;를 주요 판단 기준으로 사용함을 확인했습니다. SHAP 분석에서는 XGBoost가 &lt;code&gt;ct_dst_sport_ltm&lt;/code&gt;과 &lt;code&gt;dwin&lt;/code&gt;의 비선형 상호작용을 학습하고 있음을 발견했습니다. 두 기법의 핵심 특성이 의사결정트리의 상위 노드와 &lt;strong&gt;부분적으로 일치&lt;/strong&gt;하여 블랙박스 모델의 예측이 임의 잡음이 아닌 일관된 특성에 기반함을 검증했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;03&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;데이터 전처리 파이프라인 설계&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;UNSW-NB15 대규모 데이터셋 정제 과정에서 범주형 특성 인코딩 불일치와 결측치 처리 순서 오류로 인한 데이터 유실, 그리고 학습 데이터 정보가 테스트 데이터로 유입되는 &lt;strong&gt;데이터 누수(Data Leakage)&lt;/strong&gt; 문제가 발생했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;&lt;code&gt;StandardScaler&lt;/code&gt;와 &lt;code&gt;OneHotEncoder&lt;/code&gt;의 &lt;code&gt;fit()&lt;/code&gt;을 학습 데이터에만 적용하고, 테스트 데이터에는 &lt;code&gt;transform()&lt;/code&gt;만 적용하도록 전처리 구조를 재설계했습니다. &lt;code&gt;ColumnTransformer&lt;/code&gt;로 수치형 표준화와 범주형 인코딩을 단일 파이프라인으로 통합하여 일관된 변환 기준을 확립했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;데이터 무결성을 보장하는 전처리 파이프라인을 완성하여 모델 평가의 신뢰도를 확보했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Troubleshooting &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;trouble-card&quot;&gt;
  &lt;div class=&quot;trouble-header&quot;&gt;데이터 누수(Data Leakage) 방지를 위한 전처리 파이프라인 재설계&lt;/div&gt;
  &lt;div class=&quot;trouble-body&quot;&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;초기 구현에서 전체 데이터셋에 &lt;code&gt;StandardScaler&lt;/code&gt;를 먼저 &lt;code&gt;fit&lt;/code&gt;한 뒤 분할하는 방식을 사용했습니다. 이 경우 테스트 데이터의 통계 정보(평균, 표준편차)가 스케일러 학습에 반영되어 모델 평가가 과도하게 낙관적으로 측정되는 데이터 누수 문제가 발생했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-solve&quot; style=&quot;color:#fcc419; font-size:10px; font-weight:700; letter-spacing:1px; text-transform:uppercase; padding-top:3px; font-family:'Inter',monospace;&quot;&gt;Solve&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;학습/테스트 데이터 분리를 전처리보다 먼저 수행하도록 순서를 재정립했습니다. &lt;code&gt;col_trans.fit(X_train)&lt;/code&gt;으로 학습 데이터에서만 변환 기준을 학습하고, &lt;code&gt;col_trans.transform(X_test)&lt;/code&gt;으로 동일 기준을 테스트 데이터에만 적용하도록 구조를 체계화했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;데이터 무결성이 보장된 전처리 파이프라인을 완성하여 실무 수준의 신뢰도 있는 모델 평가 결과를 도출했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;XAI Analysis &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;xai-grid&quot;&gt;
  &lt;div class=&quot;xai-card&quot;&gt;
    &lt;div class=&quot;xai-key&quot;&gt;LIME — MLP 지역적 해석&lt;/div&gt;
    &lt;div class=&quot;xai-val&quot;&gt;테스트 샘플 단위 예측 근거 분석. &lt;code&gt;sttl &amp;lt;= -1.17&lt;/code&gt; 조건이 정상 판정의 핵심 근거로 작용했으며, &lt;code&gt;ct_dst_sport_ltm&lt;/code&gt;, &lt;code&gt;sbytes&lt;/code&gt;가 보조 근거로 기여함을 시각화했습니다. 의사결정트리 상위 노드와 일관성 확인.&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;xai-card&quot;&gt;
    &lt;div class=&quot;xai-key&quot;&gt;SHAP — XGBoost 전역적 해석&lt;/div&gt;
    &lt;div class=&quot;xai-val&quot;&gt;전역 특성 중요도 1위는 &lt;code&gt;ct_dst_sport_ltm&lt;/code&gt;. 의사결정트리(1위: &lt;code&gt;sttl&lt;/code&gt;)와 차이 발생 — 앙상블 모델이 단일 트리가 포착 못하는 비선형 상호작용을 반영하기 때문으로 분석.&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;xai-card&quot;&gt;
    &lt;div class=&quot;xai-key&quot;&gt;SHAP Dependence Plot&lt;/div&gt;
    &lt;div class=&quot;xai-val&quot;&gt;&lt;code&gt;sttl&lt;/code&gt;과 &lt;code&gt;dwin&lt;/code&gt; 간 상호작용 분석. sttl 값이 동일해도 dwin 값에 따라 공격 판단 강도가 달라지는 비선형 패턴을 발견. 단일 특성이 아닌 특성 조합 기반의 복합 탐지 전략을 확인.&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;xai-card&quot;&gt;
    &lt;div class=&quot;xai-key&quot;&gt;SHAP Decision Plot&lt;/div&gt;
    &lt;div class=&quot;xai-val&quot;&gt;다수 샘플의 누적 의사결정 경로 시각화. &lt;code&gt;ct_dst_sport_ltm&lt;/code&gt; 부근에서 공격/정상 샘플이 뚜렷하게 분리됨. 완만하게 누적되는 혼동 구간(오탐/미탐 가능성)도 식별.&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Links &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;link-row&quot;&gt;
  &lt;a class=&quot;link-btn link-btn-primary&quot; href=&quot;https://github.com/lalunru/XAI-Network-Intrusion-Detection-System&quot; target=&quot;_blank&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.604-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836a9.59 9.59 0 012.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.744 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z&quot;/&gt;&lt;/svg&gt;
    GitHub
  &lt;/a&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Retrospective &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;retro-grid&quot;&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;핵심 경험&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;AI 모델의 성능 못지않게 '해석 가능성'이 실무에서 얼마나 중요한지 깨달았습니다. 특히 보안 도메인 지식(패킷 지속 시간, TTL, 세션 특성 등)을 모델의 특성으로 녹여내는 과정이 분석의 품질을 결정한다는 점을 배웠습니다.&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;데이터 파이프라인&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;데이터 누수라는 구조적 문제를 직접 경험하고 해결하며, 전처리 순서 하나가 모델 신뢰도 전체에 영향을 미친다는 것을 체감했습니다. 체계적인 전처리 파이프라인 설계 역량을 키웠습니다.&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>Projects/Machine learning</category>
      <category>AI</category>
      <category>lime</category>
      <category>MLP</category>
      <category>shap</category>
      <category>XAI</category>
      <category>XGBoost</category>
      <category>데이터 누수</category>
      <category>블랙박스</category>
      <category>졸업논문</category>
      <category>화이트박스</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/5</guid>
      <comments>https://lalunru.tistory.com/entry/xai-ids#entry5comment</comments>
      <pubDate>Fri, 8 May 2026 18:22:52 +0900</pubDate>
    </item>
    <item>
      <title>[Project] Beyond the Door of Memory &amp;mdash; Unity 2D 감정 서사 어드벤처 게임</title>
      <link>https://lalunru.tistory.com/entry/Beyond-the-Door-of-Memory</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&amp;family=Inter:wght@300;400;500;600;700;800&amp;display=swap');

  :root {
    --bg:          #0d1117;
    --card:        rgba(255,255,255,0.04);
    --card-border: rgba(255,255,255,0.08);
    --green:       #2ecc71;
    --text:        #ffffff;
    --text-muted:  rgba(255,255,255,0.45);
    --text-dim:    rgba(255,255,255,0.25);
    --tag-border:  rgba(255,255,255,0.2);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Inter', 'Noto Sans KR', sans-serif;
    background: var(--bg);
    color: var(--text);
    max-width: 800px;
    margin: 0 auto;
    padding: 64px 24px 100px;
    line-height: 1.7;
    position: relative;
  }

  body::before {
    content: '';
    position: fixed;
    inset: 0;
    background-image:
      radial-gradient(1px 1px at 10% 15%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 25% 40%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 40% 8%,  rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 55% 55%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 70% 25%, rgba(255,255,255,0.5) 0%, transparent 100%),
      radial-gradient(1px 1px at 80% 70%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 90% 45%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 15% 75%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 60% 88%, rgba(255,255,255,0.2) 0%, transparent 100%),
      radial-gradient(1px 1px at 35% 62%, rgba(255,255,255,0.35) 0%, transparent 100%),
      radial-gradient(1.5px 1.5px at 48% 32%, rgba(255,255,255,0.6) 0%, transparent 100%),
      radial-gradient(1px 1px at 73% 80%, rgba(255,255,255,0.25) 0%, transparent 100%),
      radial-gradient(1px 1px at 5%  50%, rgba(255,255,255,0.3) 0%, transparent 100%),
      radial-gradient(1px 1px at 88% 12%, rgba(255,255,255,0.4) 0%, transparent 100%),
      radial-gradient(1px 1px at 22% 92%, rgba(255,255,255,0.2) 0%, transparent 100%);
    pointer-events: none;
    z-index: 0;
  }

  .breadcrumb {
    font-size: 12px;
    color: var(--text-muted);
    letter-spacing: 0.5px;
    margin-bottom: 48px;
    position: relative; z-index: 1;
  }
  .breadcrumb span { margin: 0 6px; color: var(--text-dim); }

  .header { position: relative; z-index: 1; margin-bottom: 40px; }

  .category-pill {
    display: inline-block;
    background: var(--green);
    color: #000;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 1.5px;
    text-transform: uppercase;
    padding: 4px 12px;
    border-radius: 20px;
    margin-bottom: 20px;
  }

  .proj-title {
    font-size: 52px;
    font-weight: 800;
    letter-spacing: -2px;
    line-height: 1.0;
    margin-bottom: 14px;
  }
  .proj-sub {
    font-size: 15px;
    font-weight: 300;
    color: var(--text-muted);
    margin-bottom: 24px;
  }
  .tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
  .tag {
    font-size: 12px;
    color: var(--text-muted);
    border: 1px solid var(--tag-border);
    padding: 3px 10px;
    border-radius: 20px;
  }

  hr {
    border: none;
    border-top: 1px solid rgba(255,255,255,0.07);
    margin: 48px 0;
    position: relative; z-index: 1;
  }

  .section-title {
    font-size: 36px;
    font-weight: 800;
    letter-spacing: -1px;
    margin-bottom: 28px;
    position: relative; z-index: 1;
    display: flex;
    align-items: center;
    gap: 6px;
  }
  .section-title .dot {
    width: 10px; height: 10px;
    background: var(--green);
    border-radius: 50%;
    flex-shrink: 0;
  }

  .meta-card {
    background: var(--card);
    border: 1px solid var(--card-border);
    border-radius: 12px;
    padding: 24px 28px;
    position: relative; z-index: 1;
  }
  .meta-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 20px 32px;
  }
  .meta-key {
    font-size: 10px;
    letter-spacing: 1.5px;
    text-transform: uppercase;
    color: var(--text-dim);
    margin-bottom: 4px;
  }
  .meta-val { font-size: 14px; font-weight: 500; }

  .bg-text {
    font-size: 15px;
    font-weight: 300;
    color: var(--text-muted);
    line-height: 1.9;
    position: relative; z-index: 1;
  }
  .bg-text strong { color: var(--text); font-weight: 600; }

  /* 기여 카드 */
  .contrib-card {
    background: var(--card);
    border: 1px solid var(--card-border);
    border-radius: 12px;
    overflow: hidden;
    margin-bottom: 16px;
    position: relative; z-index: 1;
  }
  .contrib-header {
    padding: 18px 24px 16px;
    border-bottom: 1px solid rgba(255,255,255,0.06);
    display: flex;
    align-items: center;
    gap: 12px;
  }
  .contrib-num {
    font-size: 11px;
    font-weight: 700;
    color: var(--green);
    letter-spacing: 1px;
    font-family: 'Inter', monospace;
  }
  .contrib-title { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; }
  .contrib-body { padding: 0 24px; }
  .contrib-row {
    display: grid;
    grid-template-columns: 72px 1fr;
    gap: 16px;
    padding: 16px 0;
    border-bottom: 1px solid rgba(255,255,255,0.05);
    font-size: 14px;
    line-height: 1.8;
  }
  .contrib-row:last-child { border-bottom: none; }
  .row-label {
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 1px;
    text-transform: uppercase;
    padding-top: 3px;
    font-family: 'Inter', monospace;
  }
  .label-problem { color: #ff6b6b; }
  .label-impl    { color: var(--green); }
  .label-result  { color: #74c0fc; }
  .label-solve   { color: #fcc419; }

  .row-content { color: rgba(255,255,255,0.65); font-weight: 300; }
  .row-content strong { color: var(--text); font-weight: 600; }

  code {
    font-family: 'Inter', monospace;
    font-size: 12px;
    color: rgba(255,255,255,0.5);
    background: rgba(255,255,255,0.07);
    padding: 1px 6px;
    border-radius: 4px;
  }

  /* 트러블슈팅 카드 */
  .trouble-card {
    background: var(--card);
    border: 1px solid var(--card-border);
    border-radius: 12px;
    overflow: hidden;
    position: relative; z-index: 1;
  }
  .trouble-header {
    padding: 16px 24px;
    border-bottom: 1px solid rgba(255,255,255,0.06);
    font-size: 14px;
    font-weight: 600;
    color: var(--green);
    letter-spacing: 0.3px;
  }
  .trouble-body { padding: 0 24px; }
  .trouble-row {
    display: grid;
    grid-template-columns: 72px 1fr;
    gap: 16px;
    padding: 16px 0;
    border-bottom: 1px solid rgba(255,255,255,0.05);
    font-size: 14px;
    line-height: 1.8;
  }
  .trouble-row:last-child { border-bottom: none; }

  /* 데모 */
  .demo-wrap { position: relative; z-index: 1; }
  .demo-label {
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 1.5px;
    text-transform: uppercase;
    color: var(--text-dim);
    margin-bottom: 12px;
  }
  .demo-block { margin-bottom: 28px; }
  .demo-note {
    font-size: 13px;
    color: var(--text-muted);
    font-weight: 300;
    margin-top: 8px;
  }
  .video-wrap {
    position: relative;
    width: 100%;
    padding-top: 56.25%;
    border-radius: 10px;
    overflow: hidden;
    background: rgba(255,255,255,0.03);
    border: 1px solid var(--card-border);
  }
  .video-wrap iframe {
    position: absolute;
    top: 0; left: 0;
    width: 100%; height: 100%;
    border: none;
  }

  /* 링크 */
  .link-row { display: flex; gap: 10px; flex-wrap: wrap; position: relative; z-index: 1; }
  .link-btn {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 10px 20px;
    border-radius: 8px;
    font-size: 13px;
    font-weight: 500;
    text-decoration: none;
    transition: background 0.2s, color 0.2s, border-color 0.2s;
  }
  .link-btn-primary { background: var(--green); color: #000; }
  .link-btn-primary:hover { background: #27ae60; }
  .link-btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--tag-border); }
  .link-btn-secondary:hover { border-color: var(--green); color: var(--green); }
  .link-btn svg { width: 15px; height: 15px; flex-shrink: 0; }

  /* 회고 */
  .retro-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
    position: relative; z-index: 1;
  }
  .retro-card {
    background: var(--card);
    border: 1px solid var(--card-border);
    border-radius: 12px;
    padding: 20px 22px;
  }
  .retro-key {
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 1.5px;
    text-transform: uppercase;
    color: var(--green);
    margin-bottom: 10px;
  }
  .retro-val {
    font-size: 13.5px;
    font-weight: 300;
    color: var(--text-muted);
    line-height: 1.85;
  }

  @media (max-width: 560px) {
    .proj-title { font-size: 36px; }
    .meta-grid, .retro-grid { grid-template-columns: 1fr; }
    .contrib-row, .trouble-row { grid-template-columns: 60px 1fr; gap: 10px; }
    body { padding: 40px 16px 72px; }
    .section-title { font-size: 28px; }
  }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div class=&quot;breadcrumb&quot;&gt;Projects &lt;span&gt;·&lt;/span&gt; 2D 게임 개발&lt;/div&gt;

&lt;div class=&quot;header&quot;&gt;
  &lt;div class=&quot;category-pill&quot;&gt;2D 게임 개발&lt;/div&gt;
  &lt;h1 class=&quot;proj-title&quot;&gt;Beyond the&lt;br&gt;Door of Memory&lt;/h1&gt;
  &lt;p class=&quot;proj-sub&quot;&gt;무의식 속 꿈의 공간을 배경으로 기억 조각을 수집하며 진실을 찾는&lt;br&gt;2D 감정 서사형 어드벤처 게임&lt;/p&gt;
  &lt;div class=&quot;tag-row&quot;&gt;
    &lt;span class=&quot;tag&quot;&gt;Unity 2D&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;C#&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;Coroutine&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;State Machine&lt;/span&gt;
    &lt;span class=&quot;tag&quot;&gt;PC (Windows)&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Overview &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;meta-card&quot;&gt;
  &lt;div class=&quot;meta-grid&quot;&gt;
    &lt;div class=&quot;meta-item&quot;&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Period&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;2025.03 – 2025.06&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;meta-item&quot;&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Team&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;2인 팀 프로젝트&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;meta-item&quot;&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Role&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;클라이언트 개발 (조작 시스템 및 기능 구현)&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;meta-item&quot;&gt;
      &lt;div class=&quot;meta-key&quot;&gt;Platform&lt;/div&gt;
      &lt;div class=&quot;meta-val&quot;&gt;PC (Windows)&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Background &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;p class=&quot;bg-text&quot;&gt;
  플레이어의 선택에 따라 결말이 달라지는 &lt;strong&gt;몰입형 감정 서사&lt;/strong&gt;를 전달하기 위해 기획되었습니다.&lt;br&gt;&lt;br&gt;
  무의식과 트라우마라는 심리적 요소를 시각적으로 표현하고, 이를 &lt;strong&gt;잠입 요소 및 동적 환경 변화&lt;/strong&gt;와 같은 게임 메커니즘과 결합하여 플레이어에게 심리적 압박감을 제공하는 것을 목표로 개발했습니다.
&lt;/p&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Contributions &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;01&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;다중 엔딩 및 씬 제어 시스템 구축&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;플레이어의 누적 선택이 결말에 반영되어야 했지만, 씬 간 상태 공유 수단이 없어 선택 결과를 엔딩까지 유지할 방법이 필요했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;&lt;code&gt;GameCounter.cs&lt;/code&gt;에서 &lt;code&gt;static int value&lt;/code&gt;로 씬 이동 이후에도 유지되는 전역 상태 카운터를 구현하고, &lt;code&gt;OnCountFinished_SwitchScene.cs&lt;/code&gt;에서 &lt;code&gt;FixedUpdate()&lt;/code&gt;마다 해당 값을 감지해 조건 충족 시 지정된 씬으로 자동 전환하는 분기 로직을 작성했습니다. &lt;code&gt;Interactable.cs&lt;/code&gt;와 &lt;code&gt;TalkManager.cs&lt;/code&gt;를 연동해 오브젝트 상호작용 시 대화 UI가 열리고, 닫힐 때 &lt;code&gt;OnDialogClosed&lt;/code&gt; 이벤트로 카운터가 갱신되어 스토리 분기가 진행되도록 설계했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;씬 전체를 복제하지 않고 단일 카운터 값만으로 2개의 엔딩 분기를 제어하는 경량 상태 머신을 구현했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;02&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;동적 환경 알고리즘 및 시각적 피드백 구현&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;씬을 리로드하지 않고도 게임 중 실시간으로 긴장감을 끌어올릴 수 있는 레벨 디자인 알고리즘이 필요했습니다. 씬 리로드 시 플레이 흐름이 단절되고 로딩 오버헤드가 발생하는 문제가 있었습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;&lt;code&gt;Sometime_RandomCreatePrefab.cs&lt;/code&gt;에서 &lt;code&gt;InvokeRepeating()&lt;/code&gt;으로 일정 간격마다 &lt;code&gt;SpriteRenderer.bounds&lt;/code&gt; 범위 내 랜덤 좌표에 프리팹을 생성하는 동적 배치 로직을 구현했습니다. 또한 &lt;code&gt;PlayerController.cs&lt;/code&gt;에서 &quot;Glass&quot; 태그 오브젝트와의 충돌 시 &lt;code&gt;StartCoroutine(FlashPlayer())&lt;/code&gt;를 호출해 &lt;code&gt;SpriteRenderer&lt;/code&gt;를 0.1초 간격으로 깜빡이는 시각적 피드백을 구현하고, 3회 피격 시 게임오버 씬으로 전환하는 체력 시스템을 작성했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;씬 리로드 없이 실시간으로 오브젝트가 배치되는 동적 맵 환경을 구성하고, 피격 시 시각적 타격감을 코드로 표현하여 플레이 몰입감을 높였습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;contrib-card&quot;&gt;
  &lt;div class=&quot;contrib-header&quot;&gt;
    &lt;span class=&quot;contrib-num&quot;&gt;03&lt;/span&gt;
    &lt;span class=&quot;contrib-title&quot;&gt;AI 기반 추적 시스템 및 잠입 메커니즘 설계&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;contrib-body&quot;&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;단순히 쫓아오기만 하는 적은 긴장감이 낮고, 지형을 활용한 회피 전략이 성립하지 않아 게임성이 부족했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-impl&quot;&gt;Impl&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;&lt;code&gt;Forever_Chase.cs&lt;/code&gt;에서 &lt;code&gt;FixedUpdate()&lt;/code&gt;마다 적과 플레이어 간의 방향 벡터를 정규화하고 &lt;code&gt;Rigidbody2D.velocity&lt;/code&gt;에 직접 주입하는 방식으로 물리 기반 추적 로직을 구현했습니다. &lt;code&gt;TypingGlitchWithSpawn.cs&lt;/code&gt;에서는 텍스트가 일정 횟수 반복 타이핑되면 가속도가 붙으면서 글리치 효과가 증가하고, 완료 시 적 오브젝트를 &lt;code&gt;Instantiate&lt;/code&gt;로 스폰하는 연출 시퀀스를 작성했습니다. 시야 차단 판정은 Collider2D 레이어로 처리해 지형 뒤에 숨으면 추적이 끊기도록 설계했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;contrib-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;플레이어가 지형지물을 적극적으로 활용해 시야를 차단하며 회피하는 잠입 액션 메커니즘을 구현했습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Troubleshooting &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;trouble-card&quot;&gt;
  &lt;div class=&quot;trouble-header&quot;&gt;씬 리로드 없이 실시간 동적 오브젝트 배치 구현하기&lt;/div&gt;
  &lt;div class=&quot;trouble-body&quot;&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-problem&quot;&gt;Problem&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;스테이지에서 수집 아이템의 위치를 매번 다르게 배치하고 싶었으나, 씬을 리로드하면 플레이 진행 상태가 초기화되고 로딩 오버헤드가 발생하는 문제가 있었습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-solve&quot;&gt;Solve&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;&lt;code&gt;Sometime_RandomCreatePrefab.cs&lt;/code&gt;에서 &lt;code&gt;InvokeRepeating()&lt;/code&gt;으로 일정 간격마다 &lt;code&gt;CreatePrefab()&lt;/code&gt;을 호출하고, &lt;code&gt;SpriteRenderer.bounds.size&lt;/code&gt;로 오브젝트의 범위를 구한 뒤 &lt;code&gt;Random.Range()&lt;/code&gt;로 그 안의 랜덤 좌표에 프리팹을 생성하는 방식으로 구현했습니다.&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;trouble-row&quot;&gt;
      &lt;div class=&quot;row-label label-result&quot;&gt;Result&lt;/div&gt;
      &lt;div class=&quot;row-content&quot;&gt;씬 리로드 없이 게임 진행 중 실시간으로 오브젝트가 새로운 위치에 생성되어, 플레이 흐름의 단절 없이 매 플레이마다 다른 배치 환경을 제공할 수 있었습니다.&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Demo &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;demo-wrap&quot;&gt;
  &lt;div class=&quot;demo-block&quot;&gt;
    &lt;div class=&quot;demo-label&quot;&gt;Ending 1 · True Ending&lt;/div&gt;
    &lt;div class=&quot;video-wrap&quot;&gt;
      &lt;iframe src=&quot;https://www.youtube.com/embed/bCFzRnGHndE&quot; width=&quot;300&quot; height=&quot;225&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;demo-block&quot;&gt;
    &lt;div class=&quot;demo-label&quot;&gt;Ending 2 · Bad Ending&lt;/div&gt;
    &lt;div class=&quot;video-wrap&quot;&gt;
      &lt;iframe src=&quot;https://www.youtube.com/embed/BnaHkbnLC_Y&quot; width=&quot;300&quot; height=&quot;225&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Links &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;link-row&quot;&gt;
  &lt;a class=&quot;link-btn link-btn-primary&quot; href=&quot;https://github.com/lalunru/unity-2d-adventure-memory&quot; target=&quot;_blank&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.604-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836a9.59 9.59 0 012.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.744 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z&quot;/&gt;&lt;/svg&gt;
    GitHub
  &lt;/a&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;div class=&quot;section-title&quot;&gt;Retrospective &lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;retro-grid&quot;&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;핵심 경험&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;Unity 코루틴과 상태 관리 로직을 다루며 게임 엔진의 생명주기를 이해하고 코드를 최적화하는 방법을 체득했습니다. static 카운터 기반의 경량 상태 머신으로 씬 간 데이터 유지 문제를 해결한 경험이 특히 인상적이었습니다.&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;retro-card&quot;&gt;
    &lt;div class=&quot;retro-key&quot;&gt;기획 → 코드 치환&lt;/div&gt;
    &lt;div class=&quot;retro-val&quot;&gt;추상적인 기획 의도(트라우마, 기억 상실)를 카메라 쉐이크, 좌표 랜덤 재배치, 글리치 텍스트 같은 구체적인 코드 로직으로 치환하며 서사와 기술적 메커니즘을 엮어 사용자 경험을 설계하는 역량을 키웠습니다.&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>Projects/Unity</category>
      <category>2D게임</category>
      <category>c#</category>
      <category>Unity</category>
      <category>Unity2D</category>
      <category>다중엔딩</category>
      <category>인디게임</category>
      <category>코루틴</category>
      <category>포트폴리오</category>
      <author>lalunru</author>
      <guid isPermaLink="true">https://lalunru.tistory.com/4</guid>
      <comments>https://lalunru.tistory.com/entry/Beyond-the-Door-of-Memory#entry4comment</comments>
      <pubDate>Fri, 8 May 2026 17:17:38 +0900</pubDate>
    </item>
  </channel>
</rss>