<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>The Heartade Space</title>
    <link>https://blog.heartade.dev/</link>
    <description>life as a continuous loop of building and breaking</description>
    <pubDate>Wed, 14 Oct 2026 23:18:11 +0000</pubDate>
    <item>
      <title>빈자의 에이전틱 코딩, 토큰 모아 태산</title>
      <link>https://blog.heartade.dev/binjayi-eijeontig-koding-tokeun-moa-taesan</link>
      <description>&lt;![CDATA[카카오의 은혜를 입어 쓰던 ChatGPT Pro 플랜이 끝난 뒤로 저는 Astra를 잠깐만 써도 1주일치 사용량이 날아가는 삶을 살고 있습니다. 토큰은 비싸고 사람은 저렴한 것이 오늘의 우리네 현실인 것입니다. 그러니 토큰 값을 아끼려면 사람이 머리를 더 써야 합니다.&#xA;&#xA;그래서 오늘의 주제는, 토큰을 아주 조금 아끼는 방법 한 가지입니다.&#xA;&#xA;!--more--&#xA;&#xA;이런 코드가 있다고 해 봅시다.&#xA;&#xA;import { Input } from &#39;@/components/ui/input&#39;&#xA;import { createFileRoute } from &#39;@tanstack/react-router&#39;&#xA;import { useState } from &#39;react&#39;&#xA;&#xA;export const Route = createFileRoute(&#39;/demo&#39;)({&#xA;  component: RouteComponent,&#xA;})&#xA;&#xA;type BookData = {&#xA;  title: string&#xA;  authorname?: string[]&#xA;}&#xA;&#xA;async function fetchData(title: string): Promise&lt;{&#xA;  docs: BookData[]&#xA;}  {&#xA;  const url = https://openlibrary.org/search.json?q=title:${title}&#xA;  const response = await fetch(url)&#xA;  const data = await response.json()&#xA;  return data&#xA;}&#xA;&#xA;function RouteComponent() {&#xA;  const data, setData] = useStateBookData[)&#xA;  &#xA;  return div&#xA;    div className=&#34;p-8 flex flex-col gap-4 items-center&#34;&#xA;      h1 className=&#34;text-2xl font-bold&#34;Book Search/h1&#xA;      Input placeholder=&#34;Enter book title&#34; onChange={async (e) = {&#xA;        const title = e.target.value&#xA;        const result = await fetchData(title)&#xA;        if (!title) {&#xA;          setData([])&#xA;          return&#xA;        }&#xA;        setData(result.docs)&#xA;      }} /  div className=&#34;p-8 flex flex-col gap-4 items-center&#34;&#xA;        {data.length ? data.map((book, index) =  (&#xA;          div key={index} className=&#34;border p-4 w-full max-w-md&#34;&#xA;            h2 className=&#34;text-lg font-bold&#34;{book.title}/h2&#xA;            p className=&#34;text-sm&#34;Author(s): {book.authorname?.join(&#39;, &#39;) ?? &#39;Unknown Author&#39;}/p&#xA;          /div&#xA;        )) : pNo books found/p}&#xA;      /div&#xA;    /div&#xA;  /div&#xA;}&#xA;&#xA;책 제목을 입력하면 실시간으로 Open Library 검색 결과를 보여주는 예제인데, 대충 이런 모양이 되죠.&#xA;&#xA;스크린샷&#xA;&#xA;샬롯의 거미줄이 제일 위에 뜨는 걸 보니 역시 거미는 웹 개발자인 모양이네요.&#xA;&#xA;그런데 이대로라면 사용자가 input 필드에 있는 값을 바꿀 때마다 API에 요청을 보내게 되고, 요청이 너무 많을 뿐 아니라 아래쪽의 목록이 휙휙 바뀌는 게 보기 좋지 않습니다. 이럴 때는 보통 debouncing이라는 기법을 적용하게 됩니다. 이걸 에이전트에게 고쳐 달라고 할 때, 대부분은 이런 식으로 요청할 거예요.&#xA;&#xA;&#34;/src/routes/demo.tsx에 debounce를 구현해 줘.&#34;&#xA;&#xA;//...&#xA;async function fetchData(title: string, signal: AbortSignal): Promise&lt;{&#xA;  docs: BookData[]&#xA;}  {&#xA;  const url = https://openlibrary.org/search.json?q=${encodeURIComponent(title:${title})}&#xA;  const response = await fetch(url, { signal })&#xA;  const data = await response.json()&#xA;  return data&#xA;}&#xA;&#xA;function RouteComponent() {&#xA;  const data, setData] = useStateBookData[)&#xA;  const [title, setTitle] = useState(&#39;&#39;)&#xA;&#xA;  useEffect(() =  {&#xA;    if (!title.trim()) {&#xA;      setData([])&#xA;      return&#xA;    }&#xA;&#xA;    const controller = new AbortController()&#xA;    const timeout = window.setTimeout(async () =  {&#xA;      try {&#xA;        const result = await fetchData(title.trim(), controller.signal)&#xA;        setData(result.docs)&#xA;      } catch (error) {&#xA;        if (!controller.signal.aborted) {&#xA;          console.error(&#39;Book search failed&#39;, error)&#xA;          setData([])&#xA;        }&#xA;      }&#xA;    }, 300)&#xA;&#xA;    return () =  {&#xA;      window.clearTimeout(timeout)&#xA;      controller.abort()&#xA;    }&#xA;  }, [title])&#xA;//...&#xA;&#xA;스크린샷&#xA;&#xA;Route component 안에 debounce를 구현해 주네요. 입력 토큰 137K, 출력 토큰 1.3K, 캐싱된 토큰을 제외한 전체 사용량은 42.5K입니다. 재사용 불가능한 코드인 게 마음에 들지 않지만, 어쨌든 잘 작동합니다.&#xA;&#xA;하지만 인간지능을 조금만 더 써서 에이전트가 쓸 토큰을 줄여 봅시다. 물론 debounce를 직접 작성해도 되지만 남의 코드를 가져오면 훨씬 빠르죠. @uidotdev/usehooks에 useDebounce가 포함되어 있으니 해당 패키지를 설치한 다음 이렇게 요청해 봅시다.&#xA;&#xA;&#34;/src/routes/demo.tsx의 input에 @uidotdev/usehooks의 useDebounce를 300ms delay로 적용해 줘.&#34;&#xA;&#xA;//...&#xA;function RouteComponent() {&#xA;  const data, setData] = useStateBookData[)&#xA;  const [title, setTitle] = useState(&#39;&#39;)&#xA;  const debouncedTitle = useDebounce(title, 300)&#xA;&#xA;  useEffect(() =  {&#xA;    if (!debouncedTitle) {&#xA;      setData([])&#xA;      return&#xA;    }&#xA;&#xA;    let cancelled = false&#xA;    fetchData(debouncedTitle).then((result) =  {&#xA;      if (!cancelled) setData(result.docs)&#xA;    })&#xA;&#xA;    return () =  {&#xA;      cancelled = true&#xA;    }&#xA;  }, [debouncedTitle])&#xA;&#xA;  return div&#xA;    div className=&#34;p-8 flex flex-col gap-4 items-center&#34;&#xA;      h1 className=&#34;text-2xl font-bold&#34;Book Search/h1&#xA;      Input placeholder=&#34;Enter book title&#34; value={title} onChange={(e) = setTitle(e.target.value)} /  //...&#xA;&#xA;스크린샷&#xA;&#xA;입력 토큰 112K, 출력 토큰 1.01K, 총 사용량 34.7K로 줄어들었습니다. 대충 20% 정도 더 저렴해졌다고 보면 되겠네요. 이 사용량에는 package.json을 열어서 useHooks가 설치되어 있는지 확인하는 부분도 들어 있기 때문에, 같은 컨텍스트 내에서 동일한 구현을 여러 번 반복한다면 useDebounce를 재사용하는 비용은 더욱 줄어들 거예요.&#xA;&#xA;여기에서는 간단한 패턴을 예시로 들었지만, 작성해야 하는 코드가 복잡해질수록 외부 라이브러리를 사용하는 것이 훨씬 저렴해집니다. 에이전트가 CSS를 한땀한땀 깎는 것보다 @shadcn/ui를 사용하면 훨씬 저렴해지는 식이죠.&#xA;&#xA;left-pad 사건을 기억하시나요? 그 때는 왜 내로라하는 빅테크 회사들이 다들 겨우 문자열 앞에 들여쓰기를 하는 11줄짜리 코드를 외부 디펜던시로 가져다 쓰다가 그 사단이 났는지 의문이었는데, 이제는 이해할 수 있게 되었습니다. 하루에 코드를 열 줄이라도 덜 쓰면 토큰 모아 태산을 만들 수 있을지도 몰라요.&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p>카카오의 은혜를 입어 쓰던 ChatGPT Pro 플랜이 끝난 뒤로 저는 Astra를 잠깐만 써도 1주일치 사용량이 날아가는 삶을 살고 있습니다. 토큰은 비싸고 사람은 저렴한 것이 오늘의 우리네 현실인 것입니다. 그러니 토큰 값을 아끼려면 사람이 머리를 더 써야 합니다.</p>

<p>그래서 오늘의 주제는, 토큰을 아주 조금 아끼는 방법 한 가지입니다.</p>



<p>이런 코드가 있다고 해 봅시다.</p>

<pre><code class="language-typescript">import { Input } from &#39;@/components/ui/input&#39;
import { createFileRoute } from &#39;@tanstack/react-router&#39;
import { useState } from &#39;react&#39;

export const Route = createFileRoute(&#39;/demo&#39;)({
  component: RouteComponent,
})

type BookData = {
  title: string
  author_name?: string[]
}

async function fetchData(title: string): Promise&lt;{
  docs: BookData[]
}&gt; {
  const url = `https://openlibrary.org/search.json?q=title:${title}`
  const response = await fetch(url)
  const data = await response.json()
  return data
}

function RouteComponent() {
  const [data, setData] = useState&lt;BookData[]&gt;([])
  
  return &lt;div&gt;
    &lt;div className=&#34;p-8 flex flex-col gap-4 items-center&#34;&gt;
      &lt;h1 className=&#34;text-2xl font-bold&#34;&gt;Book Search&lt;/h1&gt;
      &lt;Input placeholder=&#34;Enter book title&#34; onChange={async (e) =&gt; {
        const title = e.target.value
        const result = await fetchData(title)
        if (!title) {
          setData([])
          return
        }
        setData(result.docs)
      }} /&gt;
      &lt;div className=&#34;p-8 flex flex-col gap-4 items-center&#34;&gt;
        {data.length ? data.map((book, index) =&gt; (
          &lt;div key={index} className=&#34;border p-4 w-full max-w-md&#34;&gt;
            &lt;h2 className=&#34;text-lg font-bold&#34;&gt;{book.title}&lt;/h2&gt;
            &lt;p className=&#34;text-sm&#34;&gt;Author(s): {book.author_name?.join(&#39;, &#39;) ?? &#39;Unknown Author&#39;}&lt;/p&gt;
          &lt;/div&gt;
        )) : &lt;p&gt;No books found&lt;/p&gt;}
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
}

</code></pre>

<p>책 제목을 입력하면 실시간으로 Open Library 검색 결과를 보여주는 예제인데, 대충 이런 모양이 되죠.</p>

<p><img src="https://i.imgur.com/mecm6XK.png" alt="스크린샷"></p>

<p>샬롯의 거미줄이 제일 위에 뜨는 걸 보니 역시 거미는 웹 개발자인 모양이네요.</p>

<p>그런데 이대로라면 사용자가 input 필드에 있는 값을 바꿀 때마다 API에 요청을 보내게 되고, 요청이 너무 많을 뿐 아니라 아래쪽의 목록이 휙휙 바뀌는 게 보기 좋지 않습니다. 이럴 때는 보통 debouncing이라는 기법을 적용하게 됩니다. 이걸 에이전트에게 고쳐 달라고 할 때, 대부분은 이런 식으로 요청할 거예요.</p>
<ul><li><strong>“<code>/src/routes/demo.tsx</code>에 debounce를 구현해 줘.”</strong></li></ul>

<pre><code class="language-typescript">//...
async function fetchData(title: string, signal: AbortSignal): Promise&lt;{
  docs: BookData[]
}&gt; {
  const url = `https://openlibrary.org/search.json?q=${encodeURIComponent(`title:${title}`)}`
  const response = await fetch(url, { signal })
  const data = await response.json()
  return data
}

function RouteComponent() {
  const [data, setData] = useState&lt;BookData[]&gt;([])
  const [title, setTitle] = useState(&#39;&#39;)

  useEffect(() =&gt; {
    if (!title.trim()) {
      setData([])
      return
    }

    const controller = new AbortController()
    const timeout = window.setTimeout(async () =&gt; {
      try {
        const result = await fetchData(title.trim(), controller.signal)
        setData(result.docs)
      } catch (error) {
        if (!controller.signal.aborted) {
          console.error(&#39;Book search failed&#39;, error)
          setData([])
        }
      }
    }, 300)

    return () =&gt; {
      window.clearTimeout(timeout)
      controller.abort()
    }
  }, [title])
//...
</code></pre>

<p><img src="https://i.imgur.com/USrXxL5.png" alt="스크린샷"></p>

<p>Route component 안에 debounce를 구현해 주네요. 입력 토큰 137K, 출력 토큰 1.3K, 캐싱된 토큰을 제외한 전체 사용량은 42.5K입니다. 재사용 불가능한 코드인 게 마음에 들지 않지만, 어쨌든 잘 작동합니다.</p>

<p>하지만 인간지능을 조금만 더 써서 에이전트가 쓸 토큰을 줄여 봅시다. 물론 debounce를 직접 작성해도 되지만 남의 코드를 가져오면 훨씬 빠르죠. <code>@uidotdev/usehooks</code>에 <code>useDebounce</code>가 포함되어 있으니 해당 패키지를 설치한 다음 이렇게 요청해 봅시다.</p>
<ul><li><strong>“<code>/src/routes/demo.tsx</code>의 input에 <code>@uidotdev/usehooks</code>의 <code>useDebounce</code>를 300ms delay로 적용해 줘.”</strong></li></ul>

<pre><code class="language-typescript">//...
function RouteComponent() {
  const [data, setData] = useState&lt;BookData[]&gt;([])
  const [title, setTitle] = useState(&#39;&#39;)
  const debouncedTitle = useDebounce(title, 300)

  useEffect(() =&gt; {
    if (!debouncedTitle) {
      setData([])
      return
    }

    let cancelled = false
    fetchData(debouncedTitle).then((result) =&gt; {
      if (!cancelled) setData(result.docs)
    })

    return () =&gt; {
      cancelled = true
    }
  }, [debouncedTitle])

  return &lt;div&gt;
    &lt;div className=&#34;p-8 flex flex-col gap-4 items-center&#34;&gt;
      &lt;h1 className=&#34;text-2xl font-bold&#34;&gt;Book Search&lt;/h1&gt;
      &lt;Input placeholder=&#34;Enter book title&#34; value={title} onChange={(e) =&gt; setTitle(e.target.value)} /&gt;
//...
</code></pre>

<p><img src="https://i.imgur.com/6CXlm6j.png" alt="스크린샷"></p>

<p>입력 토큰 112K, 출력 토큰 1.01K, 총 사용량 34.7K로 줄어들었습니다. 대충 20% 정도 더 저렴해졌다고 보면 되겠네요. 이 사용량에는 <code>package.json</code>을 열어서 <code>useHooks</code>가 설치되어 있는지 확인하는 부분도 들어 있기 때문에, 같은 컨텍스트 내에서 동일한 구현을 여러 번 반복한다면 <code>useDebounce</code>를 재사용하는 비용은 더욱 줄어들 거예요.</p>

<p>여기에서는 간단한 패턴을 예시로 들었지만, 작성해야 하는 코드가 복잡해질수록 외부 라이브러리를 사용하는 것이 훨씬 저렴해집니다. 에이전트가 CSS를 한땀한땀 깎는 것보다 <code>@shadcn/ui</code>를 사용하면 훨씬 저렴해지는 식이죠.</p>

<p><a href="https://en.wikipedia.org/wiki/Npm_left-pad_incident"><code>left-pad</code></a> 사건을 기억하시나요? 그 때는 왜 내로라하는 빅테크 회사들이 다들 겨우 문자열 앞에 들여쓰기를 하는 11줄짜리 코드를 외부 디펜던시로 가져다 쓰다가 그 사단이 났는지 의문이었는데, 이제는 이해할 수 있게 되었습니다. 하루에 코드를 열 줄이라도 덜 쓰면 토큰 모아 태산을 만들 수 있을지도 몰라요.</p>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/binjayi-eijeontig-koding-tokeun-moa-taesan</guid>
      <pubDate>Wed, 07 Oct 2026 09:15:16 +0000</pubDate>
    </item>
    <item>
      <title>달팽이 때문에 만든 웹캠 타임랩스 앱, Memilcam</title>
      <link>https://blog.heartade.dev/dalpaengi-ddaemune-mandeun-webkaem-taimraebseu-aeb-memilcam</link>
      <description>&lt;![CDATA[이런저런 기능들&#xA;&#xA;최근에 달팽이 사육장에 웹캠을 달아서 펫캠으로 쓰는 방법에 대한 글을 올리면서 타임랩스 프로그램이 아쉽다는 언급을 했었죠. 그래서 직접 만들었습니다. 세계 최초 (아마도) 달팽이 타임랩스 프로그램, 메밀캠(Memilcam)을 소개합니다. 달팽이가 아니더라도 뭐든지 찍을 수 있죠.&#xA;&#xA;!--more--&#xA;&#xA;어디에 쓸 수 있나요?&#xA;반려동물 관찰용. 집에 없는 동안 반려동물이 뭐를 하고 지내는지 궁금하지 않으신가요?&#xA;식물 성장 기록. Boxlapse 영상처럼 새싹이 자라나는 모습을 찍을 수도 있습니다.&#xA;3D 프린터, 그림/공예/조립 등 작업 과정 기록.&#xA;CCTV 대용.&#xA;&#xA;핵심 기능&#xA;&#xA;메밀캠은 기존의 데스크탑용 타임랩스 프로그램에서 찾아보기 힘들었던 새로운 기능들을 제공합니다. (아무래도 대부분 사람들은 데스크탑으로 타임랩스를 찍으려고 하지 않으니까요)&#xA;&#xA;일정 시간 간격으로 자동으로 영상을 렌더링할 수 있습니다.&#xA;하드웨어와 드라이버가 지원하는 경우 수동 초점, 노출, 화이트밸런스 등을 지원합니다.&#xA;영상을 렌더링한 뒤에 촬영된 사진을 자동으로 삭제할 수 있습니다.&#xA;날짜, 시간 등을 화면에 워터마크로 기록할 수 있습니다.&#xA;&#xA;이 모든 기능을 00년대 똑딱이 카메라에서 영감을 얻은 못생긴 UI와 함께 무료로 즐겨보세요!&#xA;&#xA;Memilcam 아이콘&#xA;&#xA;바보같이 생긴 우리 집 달팽이를 모델로 만든 아이콘도 있습니다. 애매한 광원 효과에서 00년대의 향취가 물씬 느껴지죠.&#xA;&#xA;메밀캠은 아래의 링크에서 다운로드할 수 있습니다.&#xA;&#xA;GitHub&#xA;&#xA;사용 중인 컴퓨터의 종류에 따라 다음 파일을 다운로드하면 됩니다. 잘 모르겠다면 그냥 .exe로 끝나는 파일을 아무 거나 다운로드하시면 됩니다. small.exe가 아닌 파일이 필요한 사람은 보통 본인이 뭐를 다운해야 하는지도 알고 있거든요./small&#xA;&#xA; Windows (x86-64 및 ARM64 동시 지원)&#xA;   설치 프로그램: MemilCam-Setup-[버전].exe&#xA;   포터블: MemilCam-[버전].exe&#xA; macOS&#xA;   MemilCam-[버전]-universal.dmg&#xA;     smallmacOS 버전은 테스트가 진행되지 않은 상태입니다. macOS에서 버그가 발생한다면 문제 해결을 위해 저에게 맥북을 한 대 사 주시면 됩니다./small&#xA; Linux&#xA;   Flatpak:&#xA;     x86-64: MemilCam-[버전]-x86_64.flatpak&#xA;     ARM64: MemilCam-[버전]-aarch64.flatpak&#xA;   AppImage:&#xA;     x86-64: MemilCam-[버전].AppImage&#xA;     ARM64: MemilCam-[버전]-arm64.AppImage&#xA;   Snap: 추후 지원 예정&#xA;   포터블:&#xA;     x86-64: memilcam-[버전].tar.gz&#xA;     ARM64: memilcam-[버전]-arm64.tar.gz&#xA;&#xA;아래의 링크에서 사용 설명서를 읽어볼 수 있습니다.&#xA;https://github.com/Heartade/memilcam/blob/main/docs/INDEX-ko.md&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p><img src="https://imgur.com/PaGxN8X.png" alt="이런저런 기능들"></p>

<p>최근에 <a href="https://blog.naver.com/heartade/224342864182">달팽이 사육장에 웹캠을 달아서 펫캠으로 쓰는 방법에 대한 글</a>을 올리면서 타임랩스 프로그램이 아쉽다는 언급을 했었죠. 그래서 직접 만들었습니다. 세계 최초 (아마도) 달팽이 타임랩스 프로그램, 메밀캠(Memilcam)을 소개합니다. 달팽이가 아니더라도 뭐든지 찍을 수 있죠.</p>



<h3 id="어디에-쓸-수-있나요">어디에 쓸 수 있나요?</h3>
<ul><li>반려동물 관찰용. 집에 없는 동안 반려동물이 뭐를 하고 지내는지 궁금하지 않으신가요?</li>
<li>식물 성장 기록. Boxlapse 영상처럼 새싹이 자라나는 모습을 찍을 수도 있습니다.</li>
<li>3D 프린터, 그림/공예/조립 등 작업 과정 기록.</li>
<li>CCTV 대용.</li></ul>

<h3 id="핵심-기능">핵심 기능</h3>

<p>메밀캠은 기존의 데스크탑용 타임랩스 프로그램에서 찾아보기 힘들었던 새로운 기능들을 제공합니다. (아무래도 대부분 사람들은 데스크탑으로 타임랩스를 찍으려고 하지 않으니까요)</p>
<ul><li>일정 시간 간격으로 자동으로 영상을 렌더링할 수 있습니다.</li>
<li>하드웨어와 드라이버가 지원하는 경우 수동 초점, 노출, 화이트밸런스 등을 지원합니다.</li>
<li>영상을 렌더링한 뒤에 촬영된 사진을 자동으로 삭제할 수 있습니다.</li>
<li>날짜, 시간 등을 화면에 워터마크로 기록할 수 있습니다.</li></ul>

<p>이 모든 기능을 00년대 똑딱이 카메라에서 영감을 얻은 못생긴 UI와 함께 무료로 즐겨보세요!</p>

<p><img src="https://github.com/Heartade/memilcam/blob/main/assets/icon128.png?raw=true" alt="Memilcam 아이콘"></p>

<p>바보같이 생긴 우리 집 달팽이를 모델로 만든 아이콘도 있습니다. 애매한 광원 효과에서 00년대의 향취가 물씬 느껴지죠.</p>

<p>메밀캠은 아래의 링크에서 다운로드할 수 있습니다.</p>

<p><a href="https://github.com/Heartade/memilcam/releases/">GitHub</a></p>

<p>사용 중인 컴퓨터의 종류에 따라 다음 파일을 다운로드하면 됩니다. 잘 모르겠다면 그냥 <code>.exe</code>로 끝나는 파일을 아무 거나 다운로드하시면 됩니다. <small><code>.exe</code>가 아닌 파일이 필요한 사람은 보통 본인이 뭐를 다운해야 하는지도 알고 있거든요.</small></p>
<ul><li>Windows (x86-64 및 ARM64 동시 지원)
<ul><li>설치 프로그램: <code>MemilCam-Setup-[버전].exe</code></li>
<li>포터블: <code>MemilCam-[버전].exe</code></li></ul></li>
<li>macOS
<ul><li><code>MemilCam-[버전]-universal.dmg</code>
<ul><li><small>macOS 버전은 테스트가 진행되지 않은 상태입니다. macOS에서 버그가 발생한다면 문제 해결을 위해 저에게 맥북을 한 대 사 주시면 됩니다.</small></li></ul></li></ul></li>
<li>Linux
<ul><li>Flatpak:
<ul><li>x86-64: <code>MemilCam-[버전]-x86_64.flatpak</code></li>
<li>ARM64: <code>MemilCam-[버전]-aarch64.flatpak</code></li></ul></li>
<li>AppImage:
<ul><li>x86-64: <code>MemilCam-[버전].AppImage</code></li>
<li>ARM64: <code>MemilCam-[버전]-arm64.AppImage</code></li></ul></li>
<li>Snap: 추후 지원 예정</li>
<li>포터블:
<ul><li>x86-64: <code>memilcam-[버전].tar.gz</code></li>
<li>ARM64: <code>memilcam-[버전]-arm64.tar.gz</code></li></ul></li></ul></li></ul>

<p>아래의 링크에서 사용 설명서를 읽어볼 수 있습니다.
<a href="https://github.com/Heartade/memilcam/blob/main/docs/INDEX-ko.md">https://github.com/Heartade/memilcam/blob/main/docs/INDEX-ko.md</a></p>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/dalpaengi-ddaemune-mandeun-webkaem-taimraebseu-aeb-memilcam</guid>
      <pubDate>Tue, 08 Sep 2026 10:40:05 +0000</pubDate>
    </item>
    <item>
      <title>인디언 이삿짐</title>
      <link>https://blog.heartade.dev/indieon-isasjim</link>
      <description>&lt;![CDATA[이삿짐을 싸고 있다. 이사를 갈 것이기 때문이다. 이사할 집은 정해지지 않았다. 하지만 일단 이삿짐을 싸면 세계가 나의 의지에 감응하여 이사를 성사시켜 줄 것이다.&#xA;&#xA;!--more--&#xA;&#xA;이사가 될 때까지 이삿짐을 싸는 건 인디언 기우제랑 비슷한 느낌인데, 사실 생각해 보면 비가 올 때까지 비가 오기를 비는 것은 모든 기우제가 매한가지일텐데도 굳이 아메리카 원주민의 이름을 붙여 부르는 것은 스스로가 이성적인 문명인이라고 자부하는 구대륙 인간들의 오만일 것이다.&#xA;&#xA;하지만 나는 당당하게 문명의 이성을 배격하고 일단 이사가 될 때까지 이삿짐을 쌀 것이다. 근대는 실패했다.&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p>이삿짐을 싸고 있다. 이사를 갈 것이기 때문이다. 이사할 집은 정해지지 않았다. 하지만 일단 이삿짐을 싸면 세계가 나의 의지에 감응하여 이사를 성사시켜 줄 것이다.</p>



<p>이사가 될 때까지 이삿짐을 싸는 건 인디언 기우제랑 비슷한 느낌인데, 사실 생각해 보면 비가 올 때까지 비가 오기를 비는 것은 모든 기우제가 매한가지일텐데도 굳이 아메리카 원주민의 이름을 붙여 부르는 것은 스스로가 이성적인 문명인이라고 자부하는 구대륙 인간들의 오만일 것이다.</p>

<p>하지만 나는 당당하게 문명의 이성을 배격하고 일단 이사가 될 때까지 이삿짐을 쌀 것이다. 근대는 실패했다.</p>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/indieon-isasjim</guid>
      <pubDate>Mon, 23 Feb 2026 18:32:46 +0000</pubDate>
    </item>
    <item>
      <title>커피 이야기</title>
      <link>https://blog.heartade.dev/keopi-iyagi</link>
      <description>&lt;![CDATA[친구들이 블로그를 쓰길래 조금 의욕이 샘솟아서 저도 아무 말이나 써 보기로 했어요. 다시 꾸준히 쓸 거창한 결심 같은 건 없습니다. 그냥 지금 쓰고 싶은 김에 쓰고 깔끔하게 잊어버릴 거예요. 작심삼일이 되지 않는 가장 쉬운 방법은 작심을 하지 않는 거죠.&#xA;&#xA;!--more--&#xA;&#xA;작년 10월에 호치민 국제공항에서 환승을 하다가 핀(phin)을 하나 업어왔습니다. 대충 베트남식 커피 필터입니다. 베트남에서는 맨날 아침에 핀으로 커피 우리면서 담배 한 까치 태우고 출근하는 문화가 있다는데 왠지 멋있어 보여서 저도 해 보기로 했습니다. 오천 원도 안 했던 것 같은데, 이걸 써먹어 보려다가 그라인더까지 들이게 되었습니다. 비싼 전동 그라인더를 나눔해 주신 S님께 감사의 말씀을 전합니다. S님의 강아지에게도 감사의 말씀을 전합니다. 그라인더에 흰털을 좀 묻힌 것 외에 딱히 보탬이 된 것 같지는 않지만 귀여우니까요.&#xA;&#xA;아무튼 이게 뭐냐면 대충 커피가루를 넣고 물을 부으면 커피가 나오는 물건인데 (아무래도 그렇겠죠) 지금까지는 분쇄원두를 사서 썼지만 저는 커피를 기껏해야 1주일에 한두 번 우려먹기 때문에 원두 봉투가 비어갈 때쯤엔 커피가 아니라 숯차가 되어 있었습니다. (숯차를 드셔보셨나요? 이름 그대로 식용 숯을 물에 타먹는 건데 모든 새로운 경험이 삶을 풍족하게 하는 것은 아님을 가르쳐주는 맛입니다.) 그리하여 그라인더를 구한다 하였더니 오랜 트친이신 S님께서 황공하게도 뉴비를 구휼하고자 그라인더를 하사하심에 일이 여기에 이르게 되었습니다.&#xA;&#xA;문제는 이 물건을 쓰려면 커피를 얼마나 곱게 갈아서 물을 얼마나 넣어야 하는지 다들 말이 다르다는 거였습니다. 베트남 바리스타를 직접 인터뷰한 영상을 봤더니 정석은 없고 베트남에서는 사람마다 자기만의 방법이 있다고 하더라고요. 맞는 말이라서 화가 났습니다. 세상 만사에 정답은 없는 법이지만 분명한 오답은 있고 지금 무지몽매한 한국인이 풀악셀을 밟고 오답으로 직진하려고 하는데 도움이 하나도 안 되잖아요! 베트남 사람한테 김치 만드는 방법을 가르쳐 주면서 그거 정석은 없고 대충 사람마다 나름의 방법으로 배추에 소금이랑 양념 치면 된다고 하면 멀쩡한 김치가 나오겠어요? 근데 생각해 보니 저희 외할머니도 저한테 김치는 대충 배추에 소금이랑 양념 치면 된다고 가르쳐 주시긴 하셨어요. 이래서 전문가들이란.&#xA;&#xA;그래도 최소한 모두가 공통적으로 하는 말이 있었는데, 커피를 곱게 갈아야 한다더라고요. 그래서 기세등등하게 제일 곱게 갈아 봤습니다. 분쇄원두는 이런 느낌이 아니었던 것 같다는 생각이 잠깐 들었지만 그대로 무시하고, 핀에 커피를 채우고 물을 넣고 들뜬 마음으로 씻고 왔더니 커피 가루가 그대로 반죽이 되어서 한 방울도 우러나지 않은 채로 식어가고 있더라고요. 핀을 뒤집어도 떨어지지 않아서 긁어내야 했는데 아주 쫀득했습니다. 두바이쫀득쿠키에 이은 왕십리쫀득커피의 탄생의 순간을 여러분께 공유할 수 있어 영광입니다.&#xA;&#xA;조금 덜 곱게 갈아낸 두 번째 커피는 조금 더 거친 반죽이 되었고, 세 번째가 되어서야 드디어 30분에 걸쳐 아주 천천히 사람의 인내심을 시험하는 속도로 우러난 커피를 맛볼 수 있었습니다. 아주 쓰고 미적지근하지만 의외로 맛있었습니다. 좀만 더 멀쩡히 우리는 데 성공하면 아주 맛있는 커피를 먹을 수 있을 것 같다는 오기에 사로잡힐 뻔했지만 이만큼의 카페인을 한 잔 더 마시면 오늘 저녁을 집이 아니라 영안실에서 맞이할 것 같아서 진정하기로 했습니다.&#xA;&#xA;언젠가 핀으로 제대로 된 커피를 만들 수 있게 되면 레시피를 공유해 볼게요. 그 내용은 다음과 같을 것입니다. &#34;진정한 커피를 만드는 데에는 정답이 없다. 당신의 마음이 가는 대로 만들어낸 한 잔의 커피만이 당신에게 완벽할 수 있음을 알기에 이 글에서 자세한 방법을 공유하는 건 의미가 없을 것이다...&#34;&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p>친구들이 블로그를 쓰길래 조금 의욕이 샘솟아서 저도 아무 말이나 써 보기로 했어요. 다시 꾸준히 쓸 거창한 결심 같은 건 없습니다. 그냥 지금 쓰고 싶은 김에 쓰고 깔끔하게 잊어버릴 거예요. 작심삼일이 되지 않는 가장 쉬운 방법은 작심을 하지 않는 거죠.</p>



<p>작년 10월에 호치민 국제공항에서 환승을 하다가 핀(phin)을 하나 업어왔습니다. 대충 베트남식 커피 필터입니다. 베트남에서는 맨날 아침에 핀으로 커피 우리면서 담배 한 까치 태우고 출근하는 문화가 있다는데 왠지 멋있어 보여서 저도 해 보기로 했습니다. 오천 원도 안 했던 것 같은데, 이걸 써먹어 보려다가 그라인더까지 들이게 되었습니다. 비싼 전동 그라인더를 나눔해 주신 S님께 감사의 말씀을 전합니다. S님의 강아지에게도 감사의 말씀을 전합니다. 그라인더에 흰털을 좀 묻힌 것 외에 딱히 보탬이 된 것 같지는 않지만 귀여우니까요.</p>

<p>아무튼 이게 뭐냐면 대충 커피가루를 넣고 물을 부으면 커피가 나오는 물건인데 (아무래도 그렇겠죠) 지금까지는 분쇄원두를 사서 썼지만 저는 커피를 기껏해야 1주일에 한두 번 우려먹기 때문에 원두 봉투가 비어갈 때쯤엔 커피가 아니라 숯차가 되어 있었습니다. (숯차를 드셔보셨나요? 이름 그대로 식용 숯을 물에 타먹는 건데 모든 새로운 경험이 삶을 풍족하게 하는 것은 아님을 가르쳐주는 맛입니다.) 그리하여 그라인더를 구한다 하였더니 오랜 트친이신 S님께서 황공하게도 뉴비를 구휼하고자 그라인더를 하사하심에 일이 여기에 이르게 되었습니다.</p>

<p>문제는 이 물건을 쓰려면 커피를 얼마나 곱게 갈아서 물을 얼마나 넣어야 하는지 다들 말이 다르다는 거였습니다. 베트남 바리스타를 직접 인터뷰한 영상을 봤더니 정석은 없고 베트남에서는 사람마다 자기만의 방법이 있다고 하더라고요. 맞는 말이라서 화가 났습니다. 세상 만사에 정답은 없는 법이지만 분명한 오답은 있고 지금 무지몽매한 한국인이 풀악셀을 밟고 오답으로 직진하려고 하는데 도움이 하나도 안 되잖아요! 베트남 사람한테 김치 만드는 방법을 가르쳐 주면서 그거 정석은 없고 대충 사람마다 나름의 방법으로 배추에 소금이랑 양념 치면 된다고 하면 멀쩡한 김치가 나오겠어요? 근데 생각해 보니 저희 외할머니도 저한테 김치는 대충 배추에 소금이랑 양념 치면 된다고 가르쳐 주시긴 하셨어요. 이래서 전문가들이란.</p>

<p>그래도 최소한 모두가 공통적으로 하는 말이 있었는데, 커피를 곱게 갈아야 한다더라고요. 그래서 기세등등하게 제일 곱게 갈아 봤습니다. 분쇄원두는 이런 느낌이 아니었던 것 같다는 생각이 잠깐 들었지만 그대로 무시하고, 핀에 커피를 채우고 물을 넣고 들뜬 마음으로 씻고 왔더니 커피 가루가 그대로 반죽이 되어서 한 방울도 우러나지 않은 채로 식어가고 있더라고요. 핀을 뒤집어도 떨어지지 않아서 긁어내야 했는데 아주 쫀득했습니다. 두바이쫀득쿠키에 이은 왕십리쫀득커피의 탄생의 순간을 여러분께 공유할 수 있어 영광입니다.</p>

<p>조금 덜 곱게 갈아낸 두 번째 커피는 조금 더 거친 반죽이 되었고, 세 번째가 되어서야 드디어 30분에 걸쳐 아주 천천히 사람의 인내심을 시험하는 속도로 우러난 커피를 맛볼 수 있었습니다. 아주 쓰고 미적지근하지만 의외로 맛있었습니다. 좀만 더 멀쩡히 우리는 데 성공하면 아주 맛있는 커피를 먹을 수 있을 것 같다는 오기에 사로잡힐 뻔했지만 이만큼의 카페인을 한 잔 더 마시면 오늘 저녁을 집이 아니라 영안실에서 맞이할 것 같아서 진정하기로 했습니다.</p>

<p>언젠가 핀으로 제대로 된 커피를 만들 수 있게 되면 레시피를 공유해 볼게요. 그 내용은 다음과 같을 것입니다. “진정한 커피를 만드는 데에는 정답이 없다. 당신의 마음이 가는 대로 만들어낸 한 잔의 커피만이 당신에게 완벽할 수 있음을 알기에 이 글에서 자세한 방법을 공유하는 건 의미가 없을 것이다...”</p>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/keopi-iyagi</guid>
      <pubDate>Mon, 26 Jan 2026 03:32:38 +0000</pubDate>
    </item>
    <item>
      <title>NestJS에서 응답 객체에 Class Transform 적용하기</title>
      <link>https://blog.heartade.dev/nestjseseo-eungdab-gaegcee-class-transform-jeogyonghagi</link>
      <description>&lt;![CDATA[표지 사진&#xA;&#xA;오늘의 #뚝딱뚝딱 도 #NestJS 이야기입니다.&#xA;&#xA;DB에서 사용자 정보를 가져와서 전송하는 API가 필요하다고 생각해 보세요. return await this.userRepository.findOne({where: {id: userId}}); 정도를 작성하고 커밋을 누르기만 하면 된다면 얼마나 좋을까요?&#xA;&#xA;!--more--&#xA;&#xA;하지만 그렇게 했다가는 응답 객체에 들어가면 안 되는 내용까지 들어가게 되겠죠.&#xA;&#xA;{&#xA;  &#34;id&#34;: &#34;heartade&#34;,&#xA;  &#34;name&#34;: &#34;Dani&#34;,&#xA;  &#34;email&#34;: &#34;soohan@heartade.dev&#34;,&#xA;  &#34;secret&#34;: &#34;진짜진짜비밀&#34;&#xA;}&#xA;&#xA;물론 return await this.userRepository.findOne({where: {id: userId}}) as PickUser, &#34;id&#34;|&#34;name&#34;|&#34;email&#34;; 도 소용이 없습니다. 타입스크립트의 타입 체크는 컴파일 타임에만 동작하니까요.&#xA;&#xA;이런 상황을 예방하는 가장 직관적인 방법은 아래와 같은 모양일 거예요.&#xA;&#xA;// UserResponseDto extends PickType(User, [&#34;id&#34;, &#34;name&#34;, &#34;email&#34;])&#xA;async getUser(userId: string): PromiseUserResponseDto {&#xA;  const user = await this.userRepository.findOne({where: {id: userId}});&#xA;  // 클라이언트에게 보여줘도 되는 정보만 한땀한땀 골라서 전달&#xA;  // 코드에 다른 필드를 넣으면 &#34;Object literal may only specify known properties&#34; 오류 발생&#xA;  return {id: user.id, name: user.name, email: user.email};&#xA;}&#xA;&#xA;하지만 응답에 들어가는 필드가 많아질수록 이런 방식은 귀찮아집니다. 심지어 Object literal을 반환하는 게 아니라면 추가로 들어간 필드가 있어도 타입 체크에 걸리지도 않죠. 이왕 UserResponseDto를 정의한 김에 그냥 알아서 반환값을 끼워맞춰 주는, 좀 더 예쁘고 깔끔한 방법은 없는 걸까요?&#xA;&#xA;그래서 class-transformer를 사용해 보기로 했습니다.&#xA;&#xA;class-transformer를 적용하는 인터셉터 구현&#xA;&#xA;NestInterceptor에서 class-transformer를 사용하면 컨트롤러가 응답을 반환하기 전에 응답 객체를 특정한 타입으로 변환하도록 할 수 있습니다. 그리고 우리가 클라이언트에게 보낼 필드만 담긴 타입을 정의해서 인터셉터 생성자의 인수로 주면 되는 거죠.&#xA;&#xA;// response.interceptor.ts&#xA;import {&#xA;  CallHandler,&#xA;  ExecutionContext,&#xA;  Injectable,&#xA;  InternalServerErrorException,&#xA;  NestInterceptor,&#xA;} from &#39;@nestjs/common&#39;&#xA;import { instanceToPlain, plainToInstance } from &#39;class-transformer&#39;&#xA;import { validate } from &#39;class-validator&#39;&#xA;import { switchMap } from &#39;rxjs&#39;&#xA;&#xA;@Injectable()&#xA;export class ResponseInterceptorT extends object&#xA;  implements NestInterceptorany, T&#xA;{&#xA;  constructor(private readonly dto: new () =  T) {}&#xA;&#xA;  intercept(context: ExecutionContext, next: CallHandler) {&#xA;    // 컨트롤러가 handle된 뒤에 pipe를 실행합니다.&#xA;    return next.handle().pipe(&#xA;      switchMap(async (data) =  {&#xA;        // instanceToPlain(data)를 실행해서 plain object로 변환한 다음,&#xA;        // plainToInstance(this.dto, ...)를 실행해서 plain object로 변환된 data를 dto 타입으로 변환합니다.&#xA;        const transformedData = plainToInstance(this.dto, instanceToPlain(data))&#xA;        // class-validator로 이상한 값이 없는지 점검도 한 번 해 줍니다.&#xA;        const errors = await validate(transformedData)&#xA;        if (errors.length   0) {&#xA;          throw new InternalServerErrorException(&#xA;            &#34;RESPONSE VALIDATION FAILED&#34;,&#xA;          )&#xA;        }&#xA;        return transformedData&#xA;      }),&#xA;    )&#xA;  }&#xA;}&#xA;&#xA;이제 클라이언트에게 보여줄 사용자 정보가 담긴 UserResponseDto를 정의하고...&#xA;&#xA;// user-response.dto.ts&#xA;import { PickType } from &#39;@nestjs/swagger&#39;&#xA;import { User } from &#39;src/entities/user.entity&#39;&#xA;&#xA;export class UserResponseDto extends PickType(User, [&#xA;  &#39;id&#39;,&#xA;  &#39;name&#39;,&#xA;  &#39;email&#39;,&#xA;] as const) {}&#xA;&#xA;컨트롤러에 인터셉터를 적용하고...&#xA;&#xA;// user.controller.ts&#xA;// ...&#xA;export class UserController {&#xA;  // ...&#xA;  @Get(&#39;:id&#39;)&#xA;  @UseInterceptors(new ResponseInterceptor(UserResponseDto))&#xA;  async getUser(@Param(&#39;id&#39;) userId: string) {&#xA;    await this.userService.getUser(userId);&#xA;  }&#xA;}&#xA;&#xA;실행해 보면... 아까랑 같은 결과가 나옵니다. 왠지 여전히 secret 필드를 넣어서 보내 주고 있는 거죠.&#xA;&#xA;@Expose() 사용하기&#xA;&#xA;이런 문제가 생기는 원인은 plainToInstance가 변환 대상이 되는 클래스에 없는 필드가 plain object에 있다고 해서 삭제하지는 않기 때문입니다.&#xA;&#xA;특정 필드를 변환 과정에서 삭제하려면 그 필드를 exclude해야 합니다. class-transformer는 기본적으로 클래스나 필드에 달 수 있는 @Exclude()와 @Expose() 데코레이터를 제공하고, plainToInstance의 설정 기본값은 @Exclude()가 붙어 있지 않은 모든 필드를 노출하는 거죠.&#xA;&#xA;이 기본 설정을 그대로 둔 상태로 User 클래스의 secret에 @Exclude()를 적용하는 방법도 있겠지만, secret을 반환해야 하는 API를 따로 만들 일이 있을지도 모르니 그리 마음에 들지 않습니다. 그러니 plainToInstance의 strategy를 기본값인 exposeAll 대신 excludeAll로 바꿔 주고, 각 DTO 클래스에서 어떤 필드를 클라이언트에게 노출할지 @Expose()로 알려 주는 게 좋겠어요.&#xA;&#xA;// response.interceptor.ts&#xA;// ...&#xA;@Injectable()&#xA;export class ResponseInterceptorT extends object&#xA;  implements NestInterceptorany, T&#xA;{&#xA;  constructor(private readonly dto: new () =  T) {}&#xA;&#xA;  intercept(context: ExecutionContext, next: CallHandler) {&#xA;    // ...&#xA;        const transformedData = plainToInstance(&#xA;          this.dto,&#xA;          instanceToPlain(data),&#xA;          {&#xA;            // 변환 시 @Expose()가 붙어 있지 않은 모든 필드를 삭제합니다.&#xA;            strategy: &#39;excludeAll&#39;,&#xA;            // nested 객체도 변환을 적용합니다.&#xA;            enableImplicitConversion: true,&#xA;          },&#xA;        )&#xA;// ...&#xA;&#xA;// user-response.dto.ts&#xA;import { PickType } from &#39;@nestjs/swagger&#39;&#xA;import { Expose } from &#39;class-transformer&#39;&#xA;import { User } from &#39;src/entities/user.entity&#39;&#xA;&#xA;export class UserResponseDto extends PickType(User, [&#xA;  &#39;id&#39;,&#xA;  &#39;name&#39;,&#xA;  &#39;email&#39;,&#xA;] as const) {&#xA;  @Expose()&#xA;  id: string;&#xA;  @Expose()&#xA;  name: string;&#xA;  @Expose()&#xA;  email: string;&#xA;}&#xA;&#xA;여기까지 하고 나니 계획대로 userRepository에서 반환된 User 객체를 컨트롤러에서 그대로 반환해도 클라이언트에는 id, name, email만 전송되게 되었습니다. 하지만 이번엔 UserResponseDto가 너무 길어져서 처음이 더 나을 지경이네요.&#xA;&#xA;그래서 정해진 필드 몇 개만 모아서 @Expose()할 수 있는 데코레이터를 하나 더 만들었습니다.&#xA;&#xA;@ExposeFields() 데코레이터&#xA;&#xA;간단하게 필드 이름의 목록을 인수로 넣으면 그 필드에 @Expose()를 붙여 주는 데코레이터입니다.&#xA;&#xA;// expose-fields.decorator.ts&#xA;import { Expose } from &#39;class-transformer&#39;&#xA;&#xA;export default function ExposeFields&lt;&#xA;  T extends object,&#xA;  // 필드 이름이 아닌 값을 넣지 못하도록 keyof T를 extend해 줍니다.&#xA;  // 단순히 keyof T를 사용하면 숫자 인덱스 접근자도 포함하는데,&#xA;  // PropertyDecorator는 (target: Object, propertyKey: string | symbol) =  void로 정의되기 때문에&#xA;  // 필드에 Expose()를 적용하는 과정에서 타입이 충돌합니다.&#xA;  K extends keyof T &amp; (string | symbol),&#xA;  (fields: readonly K[]) {&#xA;  return function (constructor: new (...args: any[]) =  T) {&#xA;    fields.forEach((field) =  {&#xA;      Expose()(constructor.prototype, field)&#xA;    })&#xA;  }&#xA;}&#xA;&#xA;그러면 이제 UserResponseDto를 훨씬 간결하게 만들 수 있죠.&#xA;&#xA;// user-response.dto.ts&#xA;import { PickType } from &#39;@nestjs/swagger&#39;&#xA;import { ExposeFields } from &#39;src/decorators/expose-fields.decorator&#39;&#xA;import { User } from &#39;src/entities/user.entity&#39;&#xA;&#xA;@ExposeFields([&#39;id&#39;, &#39;name&#39;, &#39;email&#39;] as const)&#xA;export class UserResponseDto extends PickType(User, [&#xA;  &#39;id&#39;,&#xA;  &#39;name&#39;,&#xA;  &#39;email&#39;,&#xA;] as const) {}&#xA;&#xA;사실 이제는 PickType을 쓰지 않고 바로 User를 extend해도 알아서 id, name, field만 걸러져서 응답 데이터로 전송됩니다! 이젠 DTO에 @ExposeFields를 달고 요청 핸들러에 @UseInterceptors를 달기만 하면 서비스 구현체에서 User를 바로 반환하더라도 문제없어요. 다만 그렇게 하면 Swagger 문서에 나오는 필드와 실제 반환되는 필드가 서로 달라질테니 저는 PickType을 그대로 두기로 했습니다.&#xA;&#xA;// user-response.dto.ts&#xA;import { PickType } from &#39;@nestjs/swagger&#39;&#xA;import { ExposeFields } from &#39;src/decorators/expose-fields.decorator&#39;&#xA;import { User } from &#39;src/entities/user.entity&#39;&#xA;&#xA;@ExposeFields([&#39;id&#39;, &#39;name&#39;, &#39;email&#39;] as const)&#xA;export class UserResponseDto extends User {}&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p><img src="https://blog.heartade.dev/img/posts/nestjseseo-eungdab-gaegcee-class-transform-jeogyonghagi.png" alt="표지 사진"></p>

<p>오늘의 <a href="https://blog.heartade.dev/tag:%EB%9A%9D%EB%94%B1%EB%9A%9D%EB%94%B1" class="hashtag"><span>#</span><span class="p-category">뚝딱뚝딱</span></a> 도 <a href="https://blog.heartade.dev/tag:NestJS" class="hashtag"><span>#</span><span class="p-category">NestJS</span></a> 이야기입니다.</p>

<p>DB에서 사용자 정보를 가져와서 전송하는 API가 필요하다고 생각해 보세요. <code>return await this.userRepository.findOne({where: {id: userId}});</code> 정도를 작성하고 커밋을 누르기만 하면 된다면 얼마나 좋을까요?</p>



<p>하지만 그렇게 했다가는 응답 객체에 들어가면 안 되는 내용까지 들어가게 되겠죠.</p>

<pre><code class="language-json">{
  &#34;id&#34;: &#34;heartade&#34;,
  &#34;name&#34;: &#34;Dani&#34;,
  &#34;email&#34;: &#34;soohan@heartade.dev&#34;,
  &#34;secret&#34;: &#34;진짜진짜비밀&#34;
}
</code></pre>

<p>물론 <code>return await this.userRepository.findOne({where: {id: userId}}) as Pick&lt;User, &#34;id&#34;|&#34;name&#34;|&#34;email&#34;&gt;;</code> 도 소용이 없습니다. 타입스크립트의 타입 체크는 컴파일 타임에만 동작하니까요.</p>

<p>이런 상황을 예방하는 가장 직관적인 방법은 아래와 같은 모양일 거예요.</p>

<pre><code class="language-typescript">// UserResponseDto extends PickType(User, [&#34;id&#34;, &#34;name&#34;, &#34;email&#34;])
async getUser(userId: string): Promise&lt;UserResponseDto&gt; {
  const user = await this.userRepository.findOne({where: {id: userId}});
  // 클라이언트에게 보여줘도 되는 정보만 한땀한땀 골라서 전달
  // 코드에 다른 필드를 넣으면 &#34;Object literal may only specify known properties&#34; 오류 발생
  return {id: user.id, name: user.name, email: user.email};
}
</code></pre>

<p>하지만 응답에 들어가는 필드가 많아질수록 이런 방식은 귀찮아집니다. 심지어 Object literal을 반환하는 게 아니라면 추가로 들어간 필드가 있어도 타입 체크에 걸리지도 않죠. 이왕 <code>UserResponseDto</code>를 정의한 김에 그냥 알아서 반환값을 끼워맞춰 주는, 좀 더 예쁘고 깔끔한 방법은 없는 걸까요?</p>

<p>그래서 <code>class-transformer</code>를 사용해 보기로 했습니다.</p>

<h2 id="class-transformer-를-적용하는-인터셉터-구현"><code>class-transformer</code>를 적용하는 인터셉터 구현</h2>

<p><code>NestInterceptor</code>에서 <code>class-transformer</code>를 사용하면 컨트롤러가 응답을 반환하기 전에 응답 객체를 특정한 타입으로 변환하도록 할 수 있습니다. 그리고 우리가 클라이언트에게 보낼 필드만 담긴 타입을 정의해서 인터셉터 생성자의 인수로 주면 되는 거죠.</p>

<pre><code class="language-typescript">// response.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  InternalServerErrorException,
  NestInterceptor,
} from &#39;@nestjs/common&#39;
import { instanceToPlain, plainToInstance } from &#39;class-transformer&#39;
import { validate } from &#39;class-validator&#39;
import { switchMap } from &#39;rxjs&#39;

@Injectable()
export class ResponseInterceptor&lt;T extends object&gt;
  implements NestInterceptor&lt;any, T&gt;
{
  constructor(private readonly dto: new () =&gt; T) {}

  intercept(context: ExecutionContext, next: CallHandler) {
    // 컨트롤러가 handle된 뒤에 pipe를 실행합니다.
    return next.handle().pipe(
      switchMap(async (data) =&gt; {
        // instanceToPlain(data)를 실행해서 plain object로 변환한 다음,
        // plainToInstance(this.dto, ...)를 실행해서 plain object로 변환된 data를 dto 타입으로 변환합니다.
        const transformedData = plainToInstance(this.dto, instanceToPlain(data))
        // class-validator로 이상한 값이 없는지 점검도 한 번 해 줍니다.
        const errors = await validate(transformedData)
        if (errors.length &gt; 0) {
          throw new InternalServerErrorException(
            &#34;RESPONSE VALIDATION FAILED&#34;,
          )
        }
        return transformedData
      }),
    )
  }
}
</code></pre>

<p>이제 클라이언트에게 보여줄 사용자 정보가 담긴 <code>UserResponseDto</code>를 정의하고...</p>

<pre><code class="language-typescript">// user-response.dto.ts
import { PickType } from &#39;@nestjs/swagger&#39;
import { User } from &#39;src/entities/user.entity&#39;

export class UserResponseDto extends PickType(User, [
  &#39;id&#39;,
  &#39;name&#39;,
  &#39;email&#39;,
] as const) {}
</code></pre>

<p>컨트롤러에 인터셉터를 적용하고...</p>

<pre><code class="language-typescript">// user.controller.ts
// ...
export class UserController {
  // ...
  @Get(&#39;:id&#39;)
  @UseInterceptors(new ResponseInterceptor(UserResponseDto))
  async getUser(@Param(&#39;id&#39;) userId: string) {
    await this.userService.getUser(userId);
  }
}
</code></pre>

<p>실행해 보면... 아까랑 같은 결과가 나옵니다. 왠지 여전히 <code>secret</code> 필드를 넣어서 보내 주고 있는 거죠.</p>

<h2 id="expose-사용하기"><code>@Expose()</code> 사용하기</h2>

<p>이런 문제가 생기는 원인은 <code>plainToInstance</code>가 변환 대상이 되는 클래스에 없는 필드가 plain object에 있다고 해서 삭제하지는 않기 때문입니다.</p>

<p>특정 필드를 변환 과정에서 삭제하려면 그 필드를 <code>exclude</code>해야 합니다. <code>class-transformer</code>는 기본적으로 클래스나 필드에 달 수 있는 <code>@Exclude()</code>와 <code>@Expose()</code> 데코레이터를 제공하고, <code>plainToInstance</code>의 설정 기본값은 <code>@Exclude()</code>가 붙어 있지 않은 모든 필드를 노출하는 거죠.</p>

<p>이 기본 설정을 그대로 둔 상태로 <code>User</code> 클래스의 <code>secret</code>에 <code>@Exclude()</code>를 적용하는 방법도 있겠지만, <code>secret</code>을 반환해야 하는 API를 따로 만들 일이 있을지도 모르니 그리 마음에 들지 않습니다. 그러니 <code>plainToInstance</code>의 <code>strategy</code>를 기본값인 <code>exposeAll</code> 대신 <code>excludeAll</code>로 바꿔 주고, 각 DTO 클래스에서 어떤 필드를 클라이언트에게 노출할지 <code>@Expose()</code>로 알려 주는 게 좋겠어요.</p>

<pre><code class="language-typescript">// response.interceptor.ts
// ...
@Injectable()
export class ResponseInterceptor&lt;T extends object&gt;
  implements NestInterceptor&lt;any, T&gt;
{
  constructor(private readonly dto: new () =&gt; T) {}

  intercept(context: ExecutionContext, next: CallHandler) {
    // ...
        const transformedData = plainToInstance(
          this.dto,
          instanceToPlain(data),
          {
            // 변환 시 @Expose()가 붙어 있지 않은 모든 필드를 삭제합니다.
            strategy: &#39;excludeAll&#39;,
            // nested 객체도 변환을 적용합니다.
            enableImplicitConversion: true,
          },
        )
// ...
</code></pre>

<pre><code class="language-typescript">// user-response.dto.ts
import { PickType } from &#39;@nestjs/swagger&#39;
import { Expose } from &#39;class-transformer&#39;
import { User } from &#39;src/entities/user.entity&#39;

export class UserResponseDto extends PickType(User, [
  &#39;id&#39;,
  &#39;name&#39;,
  &#39;email&#39;,
] as const) {
  @Expose()
  id: string;
  @Expose()
  name: string;
  @Expose()
  email: string;
}
</code></pre>

<p>여기까지 하고 나니 계획대로 <code>userRepository</code>에서 반환된 <code>User</code> 객체를 컨트롤러에서 그대로 반환해도 클라이언트에는 <code>id</code>, <code>name</code>, <code>email</code>만 전송되게 되었습니다. 하지만 이번엔 <code>UserResponseDto</code>가 너무 길어져서 처음이 더 나을 지경이네요.</p>

<p>그래서 정해진 필드 몇 개만 모아서 <code>@Expose()</code>할 수 있는 데코레이터를 하나 더 만들었습니다.</p>

<h2 id="exposefields-데코레이터"><code>@ExposeFields()</code> 데코레이터</h2>

<p>간단하게 필드 이름의 목록을 인수로 넣으면 그 필드에 <code>@Expose()</code>를 붙여 주는 데코레이터입니다.</p>

<pre><code>// expose-fields.decorator.ts
import { Expose } from &#39;class-transformer&#39;

export default function ExposeFields&lt;
  T extends object,
  // 필드 이름이 아닌 값을 넣지 못하도록 keyof T를 extend해 줍니다.
  // 단순히 keyof T를 사용하면 숫자 인덱스 접근자도 포함하는데,
  // PropertyDecorator는 (target: Object, propertyKey: string | symbol) =&gt; void로 정의되기 때문에
  // 필드에 Expose()를 적용하는 과정에서 타입이 충돌합니다.
  K extends keyof T &amp; (string | symbol),
&gt;(fields: readonly K[]) {
  return function (constructor: new (...args: any[]) =&gt; T) {
    fields.forEach((field) =&gt; {
      Expose()(constructor.prototype, field)
    })
  }
}
</code></pre>

<p>그러면 이제 <code>UserResponseDto</code>를 훨씬 간결하게 만들 수 있죠.</p>

<pre><code class="language-typescript">// user-response.dto.ts
import { PickType } from &#39;@nestjs/swagger&#39;
import { ExposeFields } from &#39;src/decorators/expose-fields.decorator&#39;
import { User } from &#39;src/entities/user.entity&#39;

@ExposeFields([&#39;id&#39;, &#39;name&#39;, &#39;email&#39;] as const)
export class UserResponseDto extends PickType(User, [
  &#39;id&#39;,
  &#39;name&#39;,
  &#39;email&#39;,
] as const) {}
</code></pre>

<p>사실 이제는 <code>PickType</code>을 쓰지 않고 바로 <code>User</code>를 extend해도 알아서 <code>id, name, field</code>만 걸러져서 응답 데이터로 전송됩니다! 이젠 DTO에 <code>@ExposeFields</code>를 달고 요청 핸들러에 <code>@UseInterceptors</code>를 달기만 하면 서비스 구현체에서 <code>User</code>를 바로 반환하더라도 문제없어요. 다만 그렇게 하면 Swagger 문서에 나오는 필드와 실제 반환되는 필드가 서로 달라질테니 저는 <code>PickType</code>을 그대로 두기로 했습니다.</p>

<pre><code class="language-typescript">// user-response.dto.ts
import { PickType } from &#39;@nestjs/swagger&#39;
import { ExposeFields } from &#39;src/decorators/expose-fields.decorator&#39;
import { User } from &#39;src/entities/user.entity&#39;

@ExposeFields([&#39;id&#39;, &#39;name&#39;, &#39;email&#39;] as const)
export class UserResponseDto extends User {}
</code></pre>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/nestjseseo-eungdab-gaegcee-class-transform-jeogyonghagi</guid>
      <pubDate>Fri, 21 Feb 2025 21:35:57 +0000</pubDate>
    </item>
    <item>
      <title>블루스카이 DID 확인하기</title>
      <link>https://blog.heartade.dev/beulruseukai-did-hwaginhagi</link>
      <description>&lt;![CDATA[표지 사진&#xA;&#xA;AT 프로토콜 SDK를 사용할 때는 흔히 계정의 ID로 사용하는 핸들(Handle) 대신 DID(Decentralized Identifier)를 사용해야 할 때가 있습니다. 핸들을 변경하거나 다른 서비스 제공자로 이동하더라도 동일한 사용자를 참조할 방법이 필요하기 때문입니다. 파이썬 AT 프로토콜 SDK를 사용할 때 계정의 DID를 알아내는 스크립트를 간단히 메모해 둡니다. (제가 까먹을 예정이라 참고용으로 쓰는 거예요!)&#xA;&#xA;!--more--&#xA;&#xA;from atproto import Client, models&#xA;&#xA;client = Client()&#xA;https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle&#xA;res = client.com.atproto.identity.resolve_handle(&#xA;    models.ComAtprotoIdentityResolveHandle.Params(&#xA;        handle=&#34;heartade.dev&#34;&#xA;    )&#xA;)&#xA;print(res.did) # did:plc:e4a32z23pazq5dxnucj6wpee&#xA;&#xA;https://bsky.app/profile/heartade.dev와 https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee가 같은 프로필로 연결되는 것을 볼 수 있습니다.&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p><img src="https://blog.heartade.dev/img/posts/check-bluesky-did.png" alt="표지 사진"></p>

<p>AT 프로토콜 SDK를 사용할 때는 흔히 계정의 ID로 사용하는 핸들(Handle) 대신 DID(Decentralized Identifier)를 사용해야 할 때가 있습니다. 핸들을 변경하거나 다른 서비스 제공자로 이동하더라도 동일한 사용자를 참조할 방법이 필요하기 때문입니다. 파이썬 AT 프로토콜 SDK를 사용할 때 계정의 DID를 알아내는 스크립트를 간단히 메모해 둡니다. (제가 까먹을 예정이라 참고용으로 쓰는 거예요!)</p>



<pre><code class="language-python">from atproto import Client, models

client = Client()
# https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle
res = client.com.atproto.identity.resolve_handle(
    models.ComAtprotoIdentityResolveHandle.Params(
        handle=&#34;heartade.dev&#34;
    )
)
print(res.did) # did:plc:e4a32z23pazq5dxnucj6wpee
</code></pre>

<p><a href="https://bsky.app/profile/heartade.dev">https://bsky.app/profile/heartade.dev</a>와 <a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee</a>가 같은 프로필로 연결되는 것을 볼 수 있습니다.</p>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/beulruseukai-did-hwaginhagi</guid>
      <pubDate>Mon, 21 Oct 2024 05:11:01 +0000</pubDate>
    </item>
    <item>
      <title>블루스카이 라벨러 개발기</title>
      <link>https://blog.heartade.dev/beulruseukai-rabelreo-gaebalgi</link>
      <description>&lt;![CDATA[표지 사진&#xA;&#xA;블루스카이 유저가 급증하면서 성인 포스트 위주의 계정들이 우후죽순 등장하기 시작했습니다. 블루스카이의 기본 모더레이션 시스템도 제법 잘 작동하고 있지만, 미디어만 가려 주기 때문에 성인 포스트의 글은 피드에 그대로 노출되는 문제가 발생하기 시작했습니다.&#xA;&#xA;SNS 사용자가 성인 콘텐츠를 작성해서는 안 된다고 생각하지는 않지만, 예를 들어 공공장소에서 피드를 볼 때는 내용을 가려 놓을 수 있는 쪽이 서로에게 편리하지 않을까요? 그래서 대충 직접 해결해 보기로 했습니다.&#xA;&#xA;!--more--&#xA;---&#xA;&#xA;블루스카이의 모더레이션 시스템&#xA;&#xA;블루스카이는 기본 모더레이션 시스템 외에 다양한 모더레이션 서비스를 만들거나 구독할 수 있는 구조로 되어 있습니다. (자세한 내용은 공식 블로그를 참조하세요.) 블루스카이에서 제공하는 오픈소스 모더레이션 도구인 Ozone을 서버에 설치하는 것만으로 자체 모더레이션 서비스를 만들 수 있으며, Ozone은 모더레이션 라벨을 정의하고, 신고를 접수하고 처리하는 데에 필요한 모든 도구를 제공합니다.&#xA;&#xA;블루스카이의 커스텀 라벨은 다음과 같은 형식으로 지정됩니다.&#xA;&#xA;{&#xA;  &#34;labelValues&#34;: [&#xA;    &#34;gnl-adult&#34;&#xA;  ],&#xA;  &#34;labelValueDefinitions&#34;: [&#xA;    {&#xA;      &#34;blurs&#34;: &#34;content&#34;,&#xA;      &#34;locales&#34;: [&#xA;        {&#xA;          &#34;lang&#34;: &#34;ko&#34;,&#xA;          &#34;name&#34;: &#34;성인물 관련&#34;,&#xA;          &#34;description&#34;: &#34;한국어로 된 성인물 혹은 해당 콘텐츠와 주로 상호작용하는 계정에 라벨을 지정합니다.&#34;&#xA;        },&#xA;        {&#xA;          &#34;lang&#34;: &#34;en&#34;,&#xA;          &#34;name&#34;: &#34;Adult contents (KR)&#34;,&#xA;          &#34;description&#34;: &#34;Adult contents in Korean language and accounts frequently interacting with or posting them.&#34;&#xA;        }&#xA;      ],&#xA;      &#34;severity&#34;: &#34;inform&#34;,&#xA;      &#34;adultOnly&#34;: true,&#xA;      &#34;identifier&#34;: &#34;gnl-adult&#34;,&#xA;      &#34;defaultSetting&#34;: &#34;warn&#34;&#xA;    }&#xA;  ]&#xA;}&#xA;&#xA;blurs는 라벨이 적용되는 포스트에 &#34;warn&#34; 설정을 적용했을 때에 포스트가 어떻게 표시되는지 결정합니다.&#xA;  &#34;none&#34;: 가리지 않음&#xA;  &#34;media&#34;: 미디어만 가림&#xA;  &#34;content&#34;: 글과 미디어를 가림&#xA;severity는 라벨이 적용되는 포스트나 계정에 적용되는 안내 배지의 종류를 결정합니다.&#xA;  &#34;none&#34;: 안내하지 않음&#xA;  &#34;inform&#34;: 중립적 안내&#xA;  &#34;alert&#34;: 경고&#xA;defaultSetting은 라벨이 적용되는 포스트나 계정을 기본적으로 어떻게 처리할지를 결정합니다. 라벨을 구독하는 사용자들은 각자 필요에 따라 설정을 변경할 수 있습니다.&#xA;  &#34;none&#34;: 별도의 처리 없음&#xA;  &#34;warn&#34;: 경고를 표시하되 경고를 누르면 내용을 볼 수 있음&#xA;  &#34;hide&#34;: 피드에 아예 표시하지 않음&#xA;&#xA;시간과 사람이 모자란다&#xA;&#xA;Ozone이 있다면 사용자들이 라벨러에 신고를 하고, 관리자와 모더레이터들이 열심히 신고를 처리하는 방식으로 라벨러를 충분히 운용할 수 있습니다. 하지만 블루스카이가 예상보다 훨씬 커져 버렸다는 게 문제였습니다. 그래서 자동 라벨링 스크립트를 구축하기로 마음먹었죠.&#xA;&#xA;기본적으로 제가 떠올린 성인 계정을 구분하는 방법은 다음과 같았습니다.&#xA;&#xA;성인 계정을 3개 이상 팔로우하는 계정은 성인 계정일 것이다.&#xA;성인 계정이 주로 사용하는 태그를 포스트하는 계정은 성인 계정일 것이다.&#xA;&#xA;이 중 첫 번째 방법이 그리 좋지 않다는 것을 깨닫기까지는 그리 오랜 시간이 걸리지 않았습니다. 케빈 베이컨의 6단계 법칙에 따라 팔로잉을 따라가다 보면 순식간에 블루스카이의 모든 사람이 성인 계정이 된다는 결론에 이르렀죠. 그러니 2번 방법을 사용할 수밖에 없었습니다. 그렇다면 한국어 포스트를 실시간으로 보면서 특정 키워드나 해시태그를 사용하는 포스트가 올라올 때마다 라벨을 지정할 방법이 필요합니다.&#xA;&#xA;커스텀 피드&#xA;&#xA;처음에는 Firehose를 사용하여 블루스카이의 이벤트 스트림에 실시간으로 구독하는 방법을 고민했지만, 제 서버로 네트워크 전체에서 발생하는 초당 수백 개의 이벤트를 처리할 수 있을 것 같지 않아서 블루스카이의 커스텀 피드 기능을 사용하기로 했습니다.&#xA;&#xA;피드 개발도 나름대로 시간과 노동력이 드는 일이지만, 다행히 정규표현식 등을 사용한 커스텀 피드 제작을 지원하는 서드파티 서비스인 SkyFeed를 사용하여 특정 키워드가 포함된 게시물을 모아 보여주는 피드를 빠르게 만들 수 있었습니다.&#xA;&#xA;이제 이 피드를 일정 시간 간격(현재 제 라벨러는 3분 간격)으로 폴링하여 라벨링을 수행하는 크론잡 스크립트만 만들면 완성입니다.&#xA;&#xA;블루스카이 SDK&#xA;&#xA;간단하게 파이썬의 atproto 라이브러리를 사용하여 로그인, 피드 쿼리, 라벨을 처리하는 방법을 알아봅시다.&#xA;&#xA;로그인&#xA;&#xA;from atprotoclient import Client, models&#xA;import os&#xA;from dotenv import loaddotenv&#xA;&#xA;loaddotenv()&#xA;&#xA;labelerdid = os.getenv(&#34;LABELERDID&#34;) # 라벨러 계정의 DID&#xA;handle = os.getenv(&#34;ACCTHANDLE&#34;) # 로그인할 계정의 DID 또는 핸들&#xA;pw = os.getenv(&#34;ACCTPW&#34;) # 로그인할 계정의 비밀번호&#xA;&#xA;client = Client()&#xA;client.login(handle, pw)&#xA;모더레이션 API를 사용하고자 하는 경우 라벨러 계정의 DID를 &#39;atprotolabeler&#39; 프록시 헤더로 설정해야 합니다.&#xA;client.configureproxyheader(&#39;atprotolabeler&#39;, labelerdid)&#xA;반대로 피드를 받아올 때에 라벨을 적용하고 싶은 경우 다음과 같이 설정합니다.&#xA;client.configurelabelersheader([labelerdid])&#xA;&#xA;피드 쿼리&#xA;&#xA;이전 코드에 이어서...&#xA;&#xA;feeduri = &#34;at://[피드를 소유한 계정의 DID]/app.bsky.feed.generator/[피드 ID]&#34;&#xA;feedresult = client.app.bsky.feed.getfeed(&#xA;    models.AppBskyFeedGetFeed.Params(&#xA;        feed=feeduri,&#xA;        limit=100,&#xA;        cursor=None&#xA;    )&#xA;)&#xA;cursor = feedresult.cursor # 다음 페이지를 쿼리하고 싶다면 이 커서를 사용합니다.&#xA;posts = [item.post for item in feedresult.feed] # 포스트 목록을 가져옵니다.&#xA;&#xA;라벨 지정&#xA;&#xA;이전 코드에 이어서...&#xA;&#xA;for post in posts:&#xA;    client.tools.ozone.moderation.emitevent(&#xA;        models.ToolsOzoneModerationEmitEvent.Data(&#xA;            createdby=client.me.did # 내 계정의 DID&#xA;            event=models.ToolsOzoneModerationDefs.ModEventLabel(&#xA;                createlabelvals=[&#34;추가할 라벨 identifier&#34;],&#xA;                negatelabel_vals=[&#34;제거할 라벨 identifier&#34;]&#xA;            ),&#xA;            subject=models.ComAtprotoRepoStrongRef.Main(cid=post.cid, uri=post.uri),&#xA;        )&#xA;    )&#xA;&#xA;각 API 엔드포인트에 대한 내용은 블루스카이 공식 문서와 AT 프로토콜 파이썬 SDK 문서에서 확인할 수 있습니다. AT 프로토콜의 모든 API는 기본적으로 Lexicon으로 정의되기 때문에, 해당 문서도 읽어 보면 좋습니다.&#xA;&#xA;소감&#xA;&#xA;모더레이션이라는 작업에 대한 소감을 말할 수 있게 되기에는 아직 라벨러를 오랜 시간 구동해 보지 않았지만, 라벨이 잘못 지정되는 경우와 사용자들이 라벨을 부정적인 경고의 의미로 받아들이는 경우가 있어 고민이 되기도 합니다. (기본적으로 블루스카이의 라벨은 &#39;분류 도구&#39;에 해당하고, 라벨이 적용된다고 해서 블루스카이 이용에 문제가 생기지는 않습니다. 사용자들이 프로필에서 자신의 시간대를 알려줄 수 있는 시간대 라벨도 있죠.)&#xA;&#xA;한편 라벨러를 개발하는 것이 생각보다 훨씬 쉬웠다는 점이 인상깊었습니다. Lexicon 개념을 간단히 이해하고 나면 공식 문서만 보고도 블루스카이의 모든 API를 무리 없이 사용할 수 있었고, 오랜만에 가벼운 사이드 프로젝트 느낌으로 즐겁게 개발할 수 있었습니다. 여러분도 블루스카이의 오픈 소스 생태계에 원하는 기능이 있다면, 직접 만들어 보시는 건 어떨까요?&#xA;&#xA;---&#xA;&#xA;태그: #뚝딱뚝딱 #블루스카이&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p><img src="https://blog.heartade.dev/img/posts/developing-bluesky-labeler.png" alt="표지 사진"></p>

<p>블루스카이 유저가 급증하면서 성인 포스트 위주의 계정들이 우후죽순 등장하기 시작했습니다. 블루스카이의 기본 모더레이션 시스템도 제법 잘 작동하고 있지만, 미디어만 가려 주기 때문에 성인 포스트의 글은 피드에 그대로 노출되는 문제가 발생하기 시작했습니다.</p>

<p>SNS 사용자가 성인 콘텐츠를 작성해서는 안 된다고 생각하지는 않지만, 예를 들어 공공장소에서 피드를 볼 때는 내용을 가려 놓을 수 있는 쪽이 서로에게 편리하지 않을까요? 그래서 대충 직접 해결해 보기로 했습니다.</p>



<hr>

<h2 id="블루스카이의-모더레이션-시스템">블루스카이의 모더레이션 시스템</h2>

<p>블루스카이는 기본 모더레이션 시스템 외에 다양한 모더레이션 서비스를 만들거나 구독할 수 있는 구조로 되어 있습니다. (자세한 내용은 <a href="https://bsky.social/about/blog/03-12-2024-stackable-moderation">공식 블로그</a>를 참조하세요.) 블루스카이에서 제공하는 오픈소스 모더레이션 도구인 <a href="https://github.com/bluesky-social/ozone">Ozone</a>을 서버에 설치하는 것만으로 자체 모더레이션 서비스를 만들 수 있으며, Ozone은 모더레이션 라벨을 정의하고, 신고를 접수하고 처리하는 데에 필요한 모든 도구를 제공합니다.</p>

<p>블루스카이의 커스텀 라벨은 다음과 같은 형식으로 지정됩니다.</p>

<pre><code class="language-json">{
  &#34;labelValues&#34;: [
    &#34;gnl-adult&#34;
  ],
  &#34;labelValueDefinitions&#34;: [
    {
      &#34;blurs&#34;: &#34;content&#34;,
      &#34;locales&#34;: [
        {
          &#34;lang&#34;: &#34;ko&#34;,
          &#34;name&#34;: &#34;성인물 관련&#34;,
          &#34;description&#34;: &#34;한국어로 된 성인물 혹은 해당 콘텐츠와 주로 상호작용하는 계정에 라벨을 지정합니다.&#34;
        },
        {
          &#34;lang&#34;: &#34;en&#34;,
          &#34;name&#34;: &#34;Adult contents (KR)&#34;,
          &#34;description&#34;: &#34;Adult contents in Korean language and accounts frequently interacting with or posting them.&#34;
        }
      ],
      &#34;severity&#34;: &#34;inform&#34;,
      &#34;adultOnly&#34;: true,
      &#34;identifier&#34;: &#34;gnl-adult&#34;,
      &#34;defaultSetting&#34;: &#34;warn&#34;
    }
  ]
}
</code></pre>
<ul><li><code>blurs</code>는 라벨이 적용되는 포스트에 <code>&#34;warn&#34;</code> 설정을 적용했을 때에 포스트가 어떻게 표시되는지 결정합니다.
<ul><li><code>&#34;none&#34;</code>: 가리지 않음</li>
<li><code>&#34;media&#34;</code>: 미디어만 가림</li>
<li><code>&#34;content&#34;</code>: 글과 미디어를 가림</li></ul></li>
<li><code>severity</code>는 라벨이 적용되는 포스트나 계정에 적용되는 안내 배지의 종류를 결정합니다.
<ul><li><code>&#34;none&#34;</code>: 안내하지 않음</li>
<li><code>&#34;inform&#34;</code>: 중립적 안내</li>
<li><code>&#34;alert&#34;</code>: 경고</li></ul></li>
<li><code>defaultSetting</code>은 라벨이 적용되는 포스트나 계정을 기본적으로 어떻게 처리할지를 결정합니다. 라벨을 구독하는 사용자들은 각자 필요에 따라 설정을 변경할 수 있습니다.
<ul><li><code>&#34;none&#34;</code>: 별도의 처리 없음</li>
<li><code>&#34;warn&#34;</code>: 경고를 표시하되 경고를 누르면 내용을 볼 수 있음</li>
<li><code>&#34;hide&#34;</code>: 피드에 아예 표시하지 않음</li></ul></li></ul>

<h2 id="시간과-사람이-모자란다">시간과 사람이 모자란다</h2>

<p>Ozone이 있다면 사용자들이 라벨러에 신고를 하고, 관리자와 모더레이터들이 열심히 신고를 처리하는 방식으로 라벨러를 충분히 운용할 수 있습니다. 하지만 블루스카이가 예상보다 훨씬 커져 버렸다는 게 문제였습니다. 그래서 자동 라벨링 스크립트를 구축하기로 마음먹었죠.</p>

<p>기본적으로 제가 떠올린 성인 계정을 구분하는 방법은 다음과 같았습니다.</p>
<ol><li>성인 계정을 3개 이상 팔로우하는 계정은 성인 계정일 것이다.</li>
<li>성인 계정이 주로 사용하는 태그를 포스트하는 계정은 성인 계정일 것이다.</li></ol>

<p>이 중 첫 번째 방법이 그리 좋지 않다는 것을 깨닫기까지는 그리 오랜 시간이 걸리지 않았습니다. <a href="https://en.wikipedia.org/wiki/Six_Degrees_of_Kevin_Bacon">케빈 베이컨의 6단계 법칙</a>에 따라 팔로잉을 따라가다 보면 순식간에 블루스카이의 모든 사람이 성인 계정이 된다는 결론에 이르렀죠. 그러니 2번 방법을 사용할 수밖에 없었습니다. 그렇다면 한국어 포스트를 실시간으로 보면서 특정 키워드나 해시태그를 사용하는 포스트가 올라올 때마다 라벨을 지정할 방법이 필요합니다.</p>

<h2 id="커스텀-피드">커스텀 피드</h2>

<p>처음에는 <a href="https://docs.bsky.app/docs/advanced-guides/firehose">Firehose</a>를 사용하여 블루스카이의 이벤트 스트림에 실시간으로 구독하는 방법을 고민했지만, 제 서버로 네트워크 전체에서 발생하는 초당 수백 개의 이벤트를 처리할 수 있을 것 같지 않아서 블루스카이의 커스텀 피드 기능을 사용하기로 했습니다.</p>

<p>피드 개발도 나름대로 시간과 노동력이 드는 일이지만, 다행히 정규표현식 등을 사용한 커스텀 피드 제작을 지원하는 서드파티 서비스인 <a href="https://skyfeed.app">SkyFeed</a>를 사용하여 특정 키워드가 포함된 게시물을 모아 보여주는 피드를 빠르게 만들 수 있었습니다.</p>

<p>이제 이 피드를 일정 시간 간격(현재 제 라벨러는 3분 간격)으로 폴링하여 라벨링을 수행하는 크론잡 스크립트만 만들면 완성입니다.</p>

<h2 id="블루스카이-sdk">블루스카이 SDK</h2>

<p>간단하게 <a href="https://atproto.blue/en/latest/">파이썬의 <code>atproto</code> 라이브러리</a>를 사용하여 로그인, 피드 쿼리, 라벨을 처리하는 방법을 알아봅시다.</p>

<h3 id="로그인">로그인</h3>

<pre><code class="language-python">from atproto_client import Client, models
import os
from dotenv import load_dotenv

load_dotenv()

labeler_did = os.getenv(&#34;LABELER_DID&#34;) # 라벨러 계정의 DID
handle = os.getenv(&#34;ACCT_HANDLE&#34;) # 로그인할 계정의 DID 또는 핸들
pw = os.getenv(&#34;ACCT_PW&#34;) # 로그인할 계정의 비밀번호

client = Client()
client.login(handle, pw)
# 모더레이션 API를 사용하고자 하는 경우 라벨러 계정의 DID를 &#39;atproto_labeler&#39; 프록시 헤더로 설정해야 합니다.
client.configure_proxy_header(&#39;atproto_labeler&#39;, labeler_did)
# 반대로 피드를 받아올 때에 라벨을 적용하고 싶은 경우 다음과 같이 설정합니다.
client.configure_labelers_header([labeler_did])
</code></pre>

<h3 id="피드-쿼리">피드 쿼리</h3>

<pre><code class="language-python"># 이전 코드에 이어서...

feed_uri = &#34;at://[피드를 소유한 계정의 DID]/app.bsky.feed.generator/[피드 ID]&#34;
feed_result = client.app.bsky.feed.get_feed(
    models.AppBskyFeedGetFeed.Params(
        feed=feed_uri,
        limit=100,
        cursor=None
    )
)
cursor = feed_result.cursor # 다음 페이지를 쿼리하고 싶다면 이 커서를 사용합니다.
posts = [item.post for item in feed_result.feed] # 포스트 목록을 가져옵니다.
</code></pre>

<h3 id="라벨-지정">라벨 지정</h3>

<pre><code class="language-python"># 이전 코드에 이어서...

for post in posts:
    client.tools.ozone.moderation.emit_event(
        models.ToolsOzoneModerationEmitEvent.Data(
            created_by=client.me.did # 내 계정의 DID
            event=models.ToolsOzoneModerationDefs.ModEventLabel(
                create_label_vals=[&#34;추가할 라벨 identifier&#34;],
                negate_label_vals=[&#34;제거할 라벨 identifier&#34;]
            ),
            subject=models.ComAtprotoRepoStrongRef.Main(cid=post.cid, uri=post.uri),
        )
    )
</code></pre>

<p>각 API 엔드포인트에 대한 내용은 <a href="https://docs.bsky.app/docs/category/http-reference">블루스카이 공식 문서</a>와 <a href="https://atproto.blue/en/latest/">AT 프로토콜 파이썬 SDK 문서</a>에서 확인할 수 있습니다. AT 프로토콜의 모든 API는 기본적으로 <a href="https://atproto.com/guides/lexicon">Lexicon</a>으로 정의되기 때문에, 해당 문서도 읽어 보면 좋습니다.</p>

<h2 id="소감">소감</h2>

<p>모더레이션이라는 작업에 대한 소감을 말할 수 있게 되기에는 아직 라벨러를 오랜 시간 구동해 보지 않았지만, 라벨이 잘못 지정되는 경우와 사용자들이 라벨을 부정적인 경고의 의미로 받아들이는 경우가 있어 고민이 되기도 합니다. (기본적으로 블루스카이의 라벨은 &#39;분류 도구&#39;에 해당하고, 라벨이 적용된다고 해서 블루스카이 이용에 문제가 생기지는 않습니다. 사용자들이 프로필에서 자신의 시간대를 알려줄 수 있는 <a href="https://bsky.app/profile/khronos.world">시간대 라벨</a>도 있죠.)</p>

<p>한편 라벨러를 개발하는 것이 생각보다 훨씬 쉬웠다는 점이 인상깊었습니다. Lexicon 개념을 간단히 이해하고 나면 공식 문서만 보고도 블루스카이의 모든 API를 무리 없이 사용할 수 있었고, 오랜만에 가벼운 사이드 프로젝트 느낌으로 즐겁게 개발할 수 있었습니다. 여러분도 블루스카이의 오픈 소스 생태계에 원하는 기능이 있다면, 직접 만들어 보시는 건 어떨까요?</p>

<hr>

<p>태그: <a href="https://blog.heartade.dev/tag:%EB%9A%9D%EB%94%B1%EB%9A%9D%EB%94%B1" class="hashtag"><span>#</span><span class="p-category">뚝딱뚝딱</span></a> <a href="https://blog.heartade.dev/tag:%EB%B8%94%EB%A3%A8%EC%8A%A4%EC%B9%B4%EC%9D%B4" class="hashtag"><span>#</span><span class="p-category">블루스카이</span></a></p>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/beulruseukai-rabelreo-gaebalgi</guid>
      <pubDate>Mon, 21 Oct 2024 04:58:11 +0000</pubDate>
    </item>
    <item>
      <title>NestJS: typeerror: do not know how to serialize a bigint</title>
      <link>https://blog.heartade.dev/nestjs-typeerror-do-not-know-how-to-serialize-a-bigint</link>
      <description>&lt;![CDATA[표지 사진&#xA;&#xA;Postgres의 BIGSERIAL 타입을 Prisma로 가져오면 BigInt 타입이 됩니다. 이걸 #NestJS 컨트롤러에서 그대로 반환하면 이런 오류를 보게 되죠.&#xA;&#xA;typeerror: do not know how to serialize a bigint&#xA;&#xA;JSON.stringify() 함수에서 왠지 bigint 타입을 지원하지 않기 때문에 발생하는 문제라고 하는데, bigint를 반환하기 전에 string 같은 걸로 바꿔 주면 간단히 해결되겠지만 그렇다고 해서 bigint가 들어가는 모든 모델에 대해 타입 변환 함수 같은 걸 작성해 주기는 귀찮죠.&#xA;&#xA;그런고로 오늘의 #뚝딱뚝딱 은 응답 DTO의 모든 bigint를 알아서 string으로 바꿔 주는 방법입니다. 더 나은 해결책이 있을 것 같기는 한데, 저는 이렇게 해결했어요.&#xA;&#xA;!--more--&#xA;&#xA;Interceptor가 뭐예요?&#xA;&#xA;인터셉터는 NestJS 애플리케이션에서 요청과 응답을 가로채거나 변형할 수 있도록 하는 클래스로, @Injectable() 데코레이터를 사용해야 하며 NestInterceptor 인터페이스를 구현해야 합니다.&#xA;&#xA;NestInterceptor 인터페이스를 구현하려면 intercept(context: ExecutionContext, next: CallHandler) 함수를 구현해야 하는데, 우리는 CallHandler만 쓸 거예요. CallHandler.handle()을 써서 루트 핸들러를 호출할 수 있고, 그 결과를 pipe()로 조작할 수 있습니다. 그러니까 우리는 루트 핸들러가 실행되어 나온 결과에 bigint가 있으면 모조리 string으로 바꿔 주는 인터셉터를 구현하면 되는 거죠.&#xA;&#xA;ExecutionContext에 대해서는 문서를 참조합시다.&#xA;&#xA;bigint 잡기&#xA;&#xA;그냥 객체의 모든 키를 재귀적으로 순회하면서 typeof data[key] === &#39;bigint&#39;면 data[key] = data[key].toString()을 실행해 줍시다.&#xA;&#xA;convertBigIntToString(data: any): any {&#xA;// 배열 타입이라면...&#xA;  if (Array.isArray(data)) {&#xA;// 배열 안의 모든 요소에 대해 실행&#xA;    return data.map((item) =  this.convertBigIntToString(item));&#xA;  } else if (data !== null &amp;&amp; typeof data === &#39;object&#39;) {&#xA;// 오브젝트 안의 모든 필드에 대해...&#xA;    Object.keys(data).forEach((key) =  {&#xA;// 필드의 타입이 bigint라면...&#xA;      if (typeof data[key] === &#39;bigint&#39;) {&#xA;// string으로 바꿔 주기&#xA;        data[key] = data[key].toString();&#xA;// 필드의 타입이 object라면...&#xA;      } else if (typeof data[key] === &#39;object&#39;) {&#xA;// nested object에 대해 같은 작업 반복&#xA;        convertBigIntToString(data[key]);&#xA;      }&#xA;    });&#xA;  }&#xA;  return data;&#xA;}&#xA;&#xA;인터셉터 구현하기&#xA;&#xA;next.handle().pipe()에서 pipe()에 들어가는 인자는 rxjs.OperatorFunction인데, rxjs의 Observable을 인자로 받는 함수입니다. 여기서 Observable로는 출력 스트림이 들어갑니다. 자세한 설명은 접어두고, 우리는 핸들러의 결과에 convertBigIntToString()을 실행하면 되니까 rxjs/operators에서 map 함수를 가져옵시다.&#xA;&#xA;import {&#xA;  CallHandler,&#xA;  ExecutionContext,&#xA;  Injectable,&#xA;  NestInterceptor,&#xA;} from &#39;@nestjs/common&#39;;&#xA;import { Observable } from &#39;rxjs&#39;;&#xA;import { map } from &#39;rxjs/operators&#39;;&#xA;&#xA;@Injectable()&#xA;export class BigIntInterceptor implements NestInterceptor {&#xA;  intercept(context: ExecutionContext, next: CallHandler): Observableany {&#xA;    return next.handle().pipe(map((data) =  this.convertBigIntToString(data)));&#xA;  }&#xA;&#xA;  private convertBigIntToString(data: any): any {&#xA;    if (Array.isArray(data)) {&#xA;      return data.map((item) =  this.convertBigIntToString(item));&#xA;    } else if (data !== null &amp;&amp; typeof data === &#39;object&#39;) {&#xA;      Object.keys(data).forEach((key) =  {&#xA;        if (typeof data[key] === &#39;bigint&#39;) {&#xA;          data[key] = data[key].toString();&#xA;        } else if (typeof data[key] === &#39;object&#39;) {&#xA;          this.convertBigIntToString(data[key]);&#xA;        }&#xA;      });&#xA;    }&#xA;    return data;&#xA;  }&#xA;}&#xA;&#xA;인터셉터 적용하기&#xA;&#xA;@UseInterceptors() 데코레이터를 사용해서 컨트롤러나 메소드에 Interceptor를 적용할 수 있습니다.&#xA;&#xA;// hello.dto.ts&#xA;// bigint가 request에 있는 경우 ValidationPipe({transform: true})를 사용하기만 해도 됩니다.&#xA;export class HelloRequestDto {&#xA;  @ApiProperty()&#xA;  hello: bigint;&#xA;}&#xA;&#xA;// bigint가 response에 있는 경우 JSON.serialize()에서 bigint를 처리하지 못하므로 인터셉터가 필요합니다.&#xA;export class HelloResponseDto {&#xA;  @ApiProperty()&#xA;  goodbye: bigint;&#xA;}&#xA;&#xA;// hello.controller.ts&#xA;@Get(&#39;hello&#39;)&#xA;@UseInterceptors(BigIntInterceptor)&#xA;helloController(&#xA;  @Query() body: HelloRequestDto&#xA;): HelloResponseDto {&#xA;  return ({ goodbye: body.hello })&#xA;}&#xA;&#xA;app.useGlobalInterceptors()를 쓰면 전역 범위에 적용할 수도 있습니다.&#xA;&#xA;// main.ts&#xA;async function bootstrap() {&#xA;  / ... /&#xA;  app.useGlobalInterceptors(&#xA;    new BigIntInterceptor(),&#xA;  );&#xA;  / ... /&#xA;}&#xA;&#xA;이제 bigint가 포함된 요청을 전송하면 결과가 string으로 변환되어 날아오는 것을 확인할 수 있습니다.&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p><img src="https://blog.heartade.dev/img/posts/nestjs-serialize-bigint.png" alt="표지 사진"></p>

<p>Postgres의 <code>BIGSERIAL</code> 타입을 Prisma로 가져오면 <code>BigInt</code> 타입이 됩니다. 이걸 <a href="https://blog.heartade.dev/tag:NestJS" class="hashtag"><span>#</span><span class="p-category">NestJS</span></a> 컨트롤러에서 그대로 반환하면 이런 오류를 보게 되죠.</p>

<pre><code>typeerror: do not know how to serialize a bigint
</code></pre>

<p><code>JSON.stringify()</code> 함수에서 왠지 <code>bigint</code> 타입을 지원하지 않기 때문에 발생하는 문제라고 하는데, <code>bigint</code>를 반환하기 전에 <code>string</code> 같은 걸로 바꿔 주면 간단히 해결되겠지만 그렇다고 해서 <code>bigint</code>가 들어가는 모든 모델에 대해 타입 변환 함수 같은 걸 작성해 주기는 귀찮죠.</p>

<p>그런고로 오늘의 <a href="https://blog.heartade.dev/tag:%EB%9A%9D%EB%94%B1%EB%9A%9D%EB%94%B1" class="hashtag"><span>#</span><span class="p-category">뚝딱뚝딱</span></a> 은 응답 DTO의 모든 <code>bigint</code>를 알아서 <code>string</code>으로 바꿔 주는 방법입니다. 더 나은 해결책이 있을 것 같기는 한데, 저는 이렇게 해결했어요.</p>



<h2 id="interceptor가-뭐예요">Interceptor가 뭐예요?</h2>

<p>인터셉터는 NestJS 애플리케이션에서 요청과 응답을 가로채거나 변형할 수 있도록 하는 클래스로, <code>@Injectable()</code> 데코레이터를 사용해야 하며 <code>NestInterceptor</code> 인터페이스를 구현해야 합니다.</p>

<p><code>NestInterceptor</code> 인터페이스를 구현하려면 <code>intercept(context: ExecutionContext, next: CallHandler)</code> 함수를 구현해야 하는데, 우리는 <code>CallHandler</code>만 쓸 거예요. <code>CallHandler.handle()</code>을 써서 루트 핸들러를 호출할 수 있고, 그 결과를 <code>pipe()</code>로 조작할 수 있습니다. 그러니까 우리는 루트 핸들러가 실행되어 나온 결과에 <code>bigint</code>가 있으면 모조리 <code>string</code>으로 바꿔 주는 인터셉터를 구현하면 되는 거죠.</p>

<p><code>ExecutionContext</code>에 대해서는 <a href="https://docs.nestjs.com/fundamentals/execution-context">문서를 참조합시다</a>.</p>

<h2 id="bigint-잡기"><code>bigint</code> 잡기</h2>

<p>그냥 객체의 모든 키를 재귀적으로 순회하면서 <code>typeof data[key] === &#39;bigint&#39;</code>면 <code>data[key] = data[key].toString()</code>을 실행해 줍시다.</p>

<pre><code class="language-typescript">convertBigIntToString(data: any): any {
// 배열 타입이라면...
  if (Array.isArray(data)) {
// 배열 안의 모든 요소에 대해 실행
    return data.map((item) =&gt; this.convertBigIntToString(item));
  } else if (data !== null &amp;&amp; typeof data === &#39;object&#39;) {
// 오브젝트 안의 모든 필드에 대해...
    Object.keys(data).forEach((key) =&gt; {
// 필드의 타입이 bigint라면...
      if (typeof data[key] === &#39;bigint&#39;) {
// string으로 바꿔 주기
        data[key] = data[key].toString();
// 필드의 타입이 object라면...
      } else if (typeof data[key] === &#39;object&#39;) {
// nested object에 대해 같은 작업 반복
        convertBigIntToString(data[key]);
      }
    });
  }
  return data;
}
</code></pre>

<h2 id="인터셉터-구현하기">인터셉터 구현하기</h2>

<p><code>next.handle().pipe()</code>에서 <code>pipe()</code>에 들어가는 인자는 <code>rxjs.OperatorFunction</code>인데, <code>rxjs</code>의 <code>Observable</code>을 인자로 받는 함수입니다. 여기서 <code>Observable</code>로는 출력 스트림이 들어갑니다. 자세한 설명은 접어두고, 우리는 핸들러의 결과에 <code>convertBigIntToString()</code>을 실행하면 되니까 <code>rxjs/operators</code>에서 <code>map</code> 함수를 가져옵시다.</p>

<pre><code class="language-typescript">import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from &#39;@nestjs/common&#39;;
import { Observable } from &#39;rxjs&#39;;
import { map } from &#39;rxjs/operators&#39;;

@Injectable()
export class BigIntInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable&lt;any&gt; {
    return next.handle().pipe(map((data) =&gt; this.convertBigIntToString(data)));
  }

  private convertBigIntToString(data: any): any {
    if (Array.isArray(data)) {
      return data.map((item) =&gt; this.convertBigIntToString(item));
    } else if (data !== null &amp;&amp; typeof data === &#39;object&#39;) {
      Object.keys(data).forEach((key) =&gt; {
        if (typeof data[key] === &#39;bigint&#39;) {
          data[key] = data[key].toString();
        } else if (typeof data[key] === &#39;object&#39;) {
          this.convertBigIntToString(data[key]);
        }
      });
    }
    return data;
  }
}
</code></pre>

<h2 id="인터셉터-적용하기">인터셉터 적용하기</h2>

<p><code>@UseInterceptors()</code> 데코레이터를 사용해서 컨트롤러나 메소드에 Interceptor를 적용할 수 있습니다.</p>

<pre><code class="language-typescript">// hello.dto.ts
// bigint가 request에 있는 경우 ValidationPipe({transform: true})를 사용하기만 해도 됩니다.
export class HelloRequestDto {
  @ApiProperty()
  hello: bigint;
}

// bigint가 response에 있는 경우 JSON.serialize()에서 bigint를 처리하지 못하므로 인터셉터가 필요합니다.
export class HelloResponseDto {
  @ApiProperty()
  goodbye: bigint;
}

// hello.controller.ts
@Get(&#39;hello&#39;)
@UseInterceptors(BigIntInterceptor)
helloController(
  @Query() body: HelloRequestDto
): HelloResponseDto {
  return ({ goodbye: body.hello })
}
</code></pre>

<p><code>app.useGlobalInterceptors()</code>를 쓰면 전역 범위에 적용할 수도 있습니다.</p>

<pre><code class="language-typescript">// main.ts
async function bootstrap() {
  /* ... */
  app.useGlobalInterceptors(
    new BigIntInterceptor(),
  );
  /* ... */
}
</code></pre>

<p>이제 <code>bigint</code>가 포함된 요청을 전송하면 결과가 <code>string</code>으로 변환되어 날아오는 것을 확인할 수 있습니다.</p>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/nestjs-typeerror-do-not-know-how-to-serialize-a-bigint</guid>
      <pubDate>Fri, 15 Dec 2023 17:00:17 +0000</pubDate>
    </item>
    <item>
      <title>WSL 2에 pyenv를 설치할 때 PATH가 충돌하는 문제</title>
      <link>https://blog.heartade.dev/wsl-2e-pyenvreul-seolcihal-ddae-pathga-cungdolhaneun-munje</link>
      <description>&lt;![CDATA[오늘의 #뚝딱뚝딱 은 아주 짧은 이야기입니다. curl https://pyenv.run | bash 로 pyenv를 설치했는데 오류가 출력되더라고요.&#xA;&#xA;!--more--&#xA;&#xA;/mnt/c/Users/sooba/.pyenv/pyenv-win/bin/pyenv: 3: cygpath: not found&#xA;/mnt/c/Users/sooba/.pyenv/pyenv-win/bin/pyenv: 3: exec: cmd: not found&#xA;저번 포스트에서 일어났던 문제가 또 일어난 것입니다. pyenv 설치 스크립트가 보기에 $PATH에 pyenv가 이미 설치되어 있으니 .bashrc 수정을 스킵해 버린 것 같습니다.&#xA;&#xA;.bashrc에 다음과 같은 내용을 추가하면 간단히 해결됩니다.&#xA;&#xA;export PATH=&#34;$HOME/.pyenv/bin:$PATH&#34;&#xA;eval &#34;$(pyenv init --path)&#34;&#xA;eval &#34;$(pyenv virtualenv-init -)&#34;&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p>오늘의 <a href="https://blog.heartade.dev/tag:%EB%9A%9D%EB%94%B1%EB%9A%9D%EB%94%B1" class="hashtag"><span>#</span><span class="p-category">뚝딱뚝딱</span></a> 은 아주 짧은 이야기입니다. <code>curl https://pyenv.run | bash</code> 로 <code>pyenv</code>를 설치했는데 오류가 출력되더라고요.</p>



<pre><code>/mnt/c/Users/sooba/.pyenv/pyenv-win/bin/pyenv: 3: cygpath: not found
/mnt/c/Users/sooba/.pyenv/pyenv-win/bin/pyenv: 3: exec: cmd: not found
</code></pre>

<p><a href="https://blog.heartade.dev/wsl-2eseo-pathga-hoseuteu-meosingwa-cungdolhaneun-munje">저번 포스트</a>에서 일어났던 문제가 또 일어난 것입니다. <code>pyenv</code> 설치 스크립트가 보기에 <code>$PATH</code>에 <code>pyenv</code>가 이미 설치되어 있으니 <code>.bashrc</code> 수정을 스킵해 버린 것 같습니다.</p>

<p><code>.bashrc</code>에 다음과 같은 내용을 추가하면 간단히 해결됩니다.</p>

<pre><code class="language-sh">export PATH=&#34;$HOME/.pyenv/bin:$PATH&#34;
eval &#34;$(pyenv init --path)&#34;
eval &#34;$(pyenv virtualenv-init -)&#34;
</code></pre>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/wsl-2e-pyenvreul-seolcihal-ddae-pathga-cungdolhaneun-munje</guid>
      <pubDate>Thu, 12 Oct 2023 06:34:39 +0000</pubDate>
    </item>
    <item>
      <title>WSL 2에서 PATH가 호스트 머신과 충돌하는 문제</title>
      <link>https://blog.heartade.dev/wsl-2eseo-pathga-hoseuteu-meosingwa-cungdolhaneun-munje</link>
      <description>&lt;![CDATA[WSL에 깔린 우분투에서 npx react-native run-android를 실행하다가 이런 오류를 마주쳤습니다.&#xA;-bash: /mnt/c/Program Files/nodejs/npm: /bin/sh^M: bad interpreter: No such file or directory&#xA;우분투에 bash가 없다니 무슨 상황일까요? 그리고 대체 어쩌다가 npm이 /mnt/c/Program Files/에서 실행되는 걸까요? 이 문제를 해결해 보려고 한참 동안 노력한 끝에(여기서 노력이란 StackOverflow를 뒤지는 것을 말합니다) 문제를 얼렁뚱땅 해결한 과정을 여기에 씁니다.&#xA;&#xA;!--more--&#xA;&#xA;발단&#xA;&#xA;때는 2017년, 마이크로소프트는 WSL과 호스트 머신 사이에 환경 변수를 공유하는 기능을 추가했습니다. API 토큰 같은 환경 변수들을 WSL에서도 그대로 사용할 수 있다는 것은 분명한 장점이었습니다.&#xA;&#xA;전개&#xA;&#xA;이 과정의 결과로 PATH 환경 변수도 윈도우와 WSL 머신 사이에 공유되기 시작했다는 것이 문제의 근원이었습니다. 제 WSL 머신에서 echo $PATH를 실행하면,&#xA;/home/heartade/.nvm/versions/node/v14.21.3/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:[...]:/mnt/c/Program Files/nodejs:/mnt/c/Program Files/JetBrains/IntelliJ IDEA Community Edition 2023.1/bin:[...]:/home/heartade/android/tools/bin:/home/heartade/android/platform-tools&#xA;위와 같이 윈도우의 PATH 환경 변수에 들어 있는 경로들이 우분투의 PATH에 붙어 있는 것을 확인할 수 있습니다.&#xA;&#xA;위기&#xA;&#xA;하지만 일반적으로는 PATH 환경 변수의 맨 앞에서부터 실행 파일을 찾게 되니 문제가 없지 않을까요? nvm은 node 실행 파일의 경로를 PATH의 맨 앞에 붙이는걸요.&#xA;&#xA;하지만 어쨌든 문제는 발생하고 있었습니다. 빌드 과정 중의 어딘가에서 프로세스가 윈도우 호스트로 탈출한 다음 sh를 찾아 헤매고 있었던 것입니다. 윈도우에서 node를 지워 버리는 방법도 있겠지만, 대부분 작업은 윈도우 호스트에서 하기 때문에 그럴 수 없었습니다.&#xA;&#xA;절정&#xA;&#xA;저는 결국 윈도우 PATH를 WSL 머신에서 삭제하는 방법을 찾아야 했습니다. 이는WSL에서 더 이상 code . 명령어를 쓸 수 없다는 슬픔을 감내해야 한다는 의미기도 했습니다.&#xA;&#xA;WSL 머신에서 /etc/wsl.conf 파일을 열거나 만들고 다음과 같은 설정을 추가하면 윈도우 PATH 연동을 중단할 수 있습니다.&#xA;&#xA;[interop]&#xA;appendWindowsPath=false&#xA;&#xA;설정 변경 이후에는 호스트 머신에서 한 번 wsl --shutdown 명령으로 WSL 머신을 재시작해 줘야 합니다.&#xA;&#xA;결말&#xA;&#xA;이 글을 쓰면서 문제를 재현해 보기 위해 appendWindowsPath=false를 지워 봤는데, 정작 이번에는 문제가 재현되지 않았습니다. code . 명령어도 정상적으로 작동했습니다. 그간의 고생을 생각하면 허망할 따름이었습니다.&#xA;&#xA;앞의 문제가 발생한 정확한 이유는 알 수 없게 되었지만, 개인적으로는 nvm이 실행되는 위치가 .bashrc이기 때문에 별도로 sh 프로세스가 실행된 경우 node나 npm이 윈도우 경로에서 실행되는 문제가 발생하는 것 아닌지 의심하고 있습니다.&#xA;&#xA;이번엔 얼렁뚱땅 해결되었지만, 다음에 같은 문제가 발생하면 원인을 정확히 파악할 때까지 파고들어 보고 명확한 결론을 말씀드리도록 하겠습니다.&#xA;&#xA;태그: #뚝딱뚝딱&#xA;&#xA;!--more--&#xD;&#xA;---&#xD;&#xA;Dani Soohan Park (@heartade)&#xD;&#xA;&#xD;&#xA;Follow this blog at Fediverse:&#xD;&#xA;@heartade@blog.heartade.dev&#xD;&#xA;&#xD;&#xA;Follow my shorter shoutouts at Fediverse:&#xD;&#xA;@heartade@social.silicon.moe&#xD;&#xA;&#xD;&#xA;Follow me at Bluesky:&#xD;&#xA;@heartade.dev]]&gt;</description>
      <content:encoded><![CDATA[<p>WSL에 깔린 우분투에서 <code>npx react-native run-android</code>를 실행하다가 이런 오류를 마주쳤습니다.</p>

<pre><code>-bash: /mnt/c/Program Files/nodejs/npm: /bin/sh^M: bad interpreter: No such file or directory
</code></pre>

<p>우분투에 <code>bash</code>가 없다니 무슨 상황일까요? 그리고 대체 어쩌다가 <code>npm</code>이 <code>/mnt/c/Program Files/</code>에서 실행되는 걸까요? 이 문제를 해결해 보려고 한참 동안 노력한 끝에(여기서 노력이란 StackOverflow를 뒤지는 것을 말합니다) 문제를 얼렁뚱땅 해결한 과정을 여기에 씁니다.</p>



<h2 id="발단">발단</h2>

<p>때는 2017년, <a href="https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows/">마이크로소프트는 WSL과 호스트 머신 사이에 환경 변수를 공유하는 기능을 추가했습니다.</a> API 토큰 같은 환경 변수들을 WSL에서도 그대로 사용할 수 있다는 것은 분명한 장점이었습니다.</p>

<h2 id="전개">전개</h2>

<p>이 과정의 결과로 <code>PATH</code> 환경 변수도 윈도우와 WSL 머신 사이에 공유되기 시작했다는 것이 문제의 근원이었습니다. 제 WSL 머신에서 <code>echo $PATH</code>를 실행하면,</p>

<pre><code>/home/heartade/.nvm/versions/node/v14.21.3/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:[...]:/mnt/c/Program Files/nodejs:/mnt/c/Program Files/JetBrains/IntelliJ IDEA Community Edition 2023.1/bin:[...]:/home/heartade/android/tools/bin:/home/heartade/android/platform-tools
</code></pre>

<p>위와 같이 윈도우의 <code>PATH</code> 환경 변수에 들어 있는 경로들이 우분투의 <code>PATH</code>에 붙어 있는 것을 확인할 수 있습니다.</p>

<h2 id="위기">위기</h2>

<p>하지만 일반적으로는 <code>PATH</code> 환경 변수의 맨 앞에서부터 실행 파일을 찾게 되니 문제가 없지 않을까요? <code>nvm</code>은 <code>node</code> 실행 파일의 경로를 <code>PATH</code>의 맨 앞에 붙이는걸요.</p>

<p>하지만 어쨌든 문제는 발생하고 있었습니다. 빌드 과정 중의 어딘가에서 프로세스가 윈도우 호스트로 탈출한 다음 <code>sh</code>를 찾아 헤매고 있었던 것입니다. 윈도우에서 <code>node</code>를 지워 버리는 방법도 있겠지만, 대부분 작업은 윈도우 호스트에서 하기 때문에 그럴 수 없었습니다.</p>

<h2 id="절정">절정</h2>

<p>저는 결국 윈도우 <code>PATH</code>를 WSL 머신에서 삭제하는 방법을 찾아야 했습니다. 이는WSL에서 더 이상 <code>code .</code> 명령어를 쓸 수 없다는 슬픔을 감내해야 한다는 의미기도 했습니다.</p>

<p>WSL 머신에서 <code>/etc/wsl.conf</code> 파일을 열거나 만들고 다음과 같은 설정을 추가하면 윈도우 <code>PATH</code> 연동을 중단할 수 있습니다.</p>

<pre><code>[interop]
appendWindowsPath=false
</code></pre>

<p>설정 변경 이후에는 호스트 머신에서 한 번 <code>wsl --shutdown</code> 명령으로 WSL 머신을 재시작해 줘야 합니다.</p>

<h2 id="결말">결말</h2>

<p>이 글을 쓰면서 문제를 재현해 보기 위해 <code>appendWindowsPath=false</code>를 지워 봤는데, 정작 이번에는 문제가 재현되지 않았습니다. <code>code .</code> 명령어도 정상적으로 작동했습니다. 그간의 고생을 생각하면 허망할 따름이었습니다.</p>

<p>앞의 문제가 발생한 정확한 이유는 알 수 없게 되었지만, 개인적으로는 <code>nvm</code>이 실행되는 위치가 <code>.bashrc</code>이기 때문에 별도로 <code>sh</code> 프로세스가 실행된 경우 <code>node</code>나 <code>npm</code>이 윈도우 경로에서 실행되는 문제가 발생하는 것 아닌지 의심하고 있습니다.</p>

<p>이번엔 얼렁뚱땅 해결되었지만, 다음에 같은 문제가 발생하면 원인을 정확히 파악할 때까지 파고들어 보고 명확한 결론을 말씀드리도록 하겠습니다.</p>

<p>태그: <a href="https://blog.heartade.dev/tag:%EB%9A%9D%EB%94%B1%EB%9A%9D%EB%94%B1" class="hashtag"><span>#</span><span class="p-category">뚝딱뚝딱</span></a></p>



<hr>

<h3 id="dani-soohan-park-heartade">Dani Soohan Park (@heartade)</h3>

<p>Follow this blog at Fediverse:
<a href="https://blog.heartade.dev/heartade"><a href="https://blog.heartade.dev/@/heartade@blog.heartade.dev" class="u-url mention">@<span>heartade@blog.heartade.dev</span></a></a></p>

<p>Follow my shorter shoutouts at Fediverse:
<a href="https://social.silicon.moe/@heartade"><a href="https://blog.heartade.dev/@/heartade@social.silicon.moe" class="u-url mention">@<span>heartade@social.silicon.moe</span></a></a></p>

<p>Follow me at Bluesky:
<a href="https://bsky.app/profile/did:plc:e4a32z23pazq5dxnucj6wpee">@heartade.dev</a></p>
]]></content:encoded>
      <guid>https://blog.heartade.dev/wsl-2eseo-pathga-hoseuteu-meosingwa-cungdolhaneun-munje</guid>
      <pubDate>Fri, 01 Sep 2023 12:01:04 +0000</pubDate>
    </item>
  </channel>
</rss>