<?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>Thu, 09 Apr 2026 20:23:14 +0000</pubDate>
    <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" 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" 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-를-적용하는-인터셉터-구현" 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-사용하기" 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-데코레이터" 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" 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" 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="블루스카이의-모더레이션-시스템" 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="시간과-사람이-모자란다" 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="커스텀-피드" 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" 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="피드-쿼리" 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="라벨-지정" 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" 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가-뭐예요" 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-잡기" 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="인터셉터-구현하기" 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="인터셉터-적용하기" 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" 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" 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" 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>
    <item>
      <title>WSL 2에 ADB 디바이스 연결하기</title>
      <link>https://blog.heartade.dev/wsl-2e-adb-dibaiseu-yeongyeolhagi</link>
      <description>&lt;![CDATA[종종 뜬금없이 뭔가 문제를 해결했을 때 #뚝딱뚝딱 해시태그를 붙여서 블로그에 쓰려고 합니다. 오늘의 #뚝딱뚝딱 은 WSL 2에서 ADB로 안드로이드 디바이스를 연결해 디버깅하는 방법입니다.&#xA;&#xA;!--more--&#xA;&#xA;  출처: https://stackoverflow.com/questions/62145379/how-to-connect-android-studio-running-inside-wsl2-with-connected-devices-or-andr&#xA;&#xA;준비물&#xA;&#xA;호스트와 WSL2에 모두 ADB가 설치되어 있어야 합니다.&#xA;(당연히) 디바이스에서 USB 디버깅이 활성화되어 있어야 합니다.&#xA;&#xA;과정&#xA;&#xA;호스트 컴퓨터에 USB 디버깅으로 디바이스를 연결합니다.&#xA;호스트 컴퓨터에서 다음 명령어를 실행합니다. adb tcpip 5555&#xA;  꼭 5555번 포트일 필요는 없겠지만 일단 해당 포트를 연결해 봅시다.&#xA;디바이스에서 현재 IP를 찾아냅니다. 현재 제가 사용하는 기기(삼성 갤럭시 S23 Ultra, Android 13, One UI 5.1)에서는 다음과 같습니다.&#xA;One UI 5.1에서 기기 IP 찾기&#xA;&#xA;  와이파이 설정으로 진입합니다.&#xA;  현재 연결된 와이파이 네트워크의 설정으로 진입합니다.&#xA;  &#39;더보기&#39; 버튼을 누르면 맨 밑에 IP 정보가 있습니다.&#xA;WSL 게스트에서 adb connect [ip]:5555 를 실행합니다.&#xA;&#xA;여담&#xA;&#xA;그냥 무선 디버깅을 켜면 되는 것 아닐까요? 그러게요. WSL2에서 무선 디버깅을 시도해본 적은 없어서 잘 모르겠지만 다음에 시도할 일이 생기면 알려드릴게요.&#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> 해시태그를 붙여서 블로그에 쓰려고 합니다. 오늘의 <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> 은 WSL 2에서 ADB로 안드로이드 디바이스를 연결해 디버깅하는 방법입니다.</p>



<blockquote><p>출처: <a href="https://stackoverflow.com/questions/62145379/how-to-connect-android-studio-running-inside-wsl2-with-connected-devices-or-andr">https://stackoverflow.com/questions/62145379/how-to-connect-android-studio-running-inside-wsl2-with-connected-devices-or-andr</a></p></blockquote>

<h2 id="준비물">준비물</h2>
<ul><li>호스트와 WSL2에 모두 ADB가 설치되어 있어야 합니다.</li>
<li>(당연히) 디바이스에서 USB 디버깅이 활성화되어 있어야 합니다.</li></ul>

<h2 id="과정">과정</h2>
<ol><li>호스트 컴퓨터에 USB 디버깅으로 디바이스를 연결합니다.</li>
<li>호스트 컴퓨터에서 다음 명령어를 실행합니다. <code>adb tcpip 5555</code>
<ul><li>꼭 5555번 포트일 필요는 없겠지만 일단 해당 포트를 연결해 봅시다.</li></ul></li>

<li><p>디바이스에서 현재 IP를 찾아냅니다. 현재 제가 사용하는 기기(삼성 갤럭시 S23 Ultra, Android 13, One UI 5.1)에서는 다음과 같습니다.
<img src="https://onedrive.live.com/embed?resid=607804b5b88e5a9e%21315106&amp;authkey=%21AAr6Z45eLvrUYXs&amp;width=3657&amp;height=2375" alt="One UI 5.1에서 기기 IP 찾기"></p>
<ul><li>와이파이 설정으로 진입합니다.</li>
<li>현재 연결된 와이파이 네트워크의 설정으로 진입합니다.</li>
<li>&#39;더보기&#39; 버튼을 누르면 맨 밑에 IP 정보가 있습니다.</li></ul></li>

<li><p>WSL 게스트에서 <code>adb connect [ip]:5555</code> 를 실행합니다.</p></li></ol>

<h2 id="여담">여담</h2>

<p>그냥 무선 디버깅을 켜면 되는 것 아닐까요? 그러게요. WSL2에서 무선 디버깅을 시도해본 적은 없어서 잘 모르겠지만 다음에 시도할 일이 생기면 알려드릴게요.</p>



<hr>

<h3 id="dani-soohan-park-heartade" 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-adb-dibaiseu-yeongyeolhagi</guid>
      <pubDate>Fri, 01 Sep 2023 10:39:23 +0000</pubDate>
    </item>
    <item>
      <title>마음에 관한 짧은 글</title>
      <link>https://blog.heartade.dev/maeume-gwanhan-jjalbeun-geul</link>
      <description>&lt;![CDATA[표지 사진&#xA;&#xA;최근에 정신건강 관련 앱을 개발하는 스타트업을 경영하시는 분과 커피챗을 할 기회가 있었습니다. 사실 코로나 블루 시기에 주변에서 비극적인 일들을 많이 겪었기 때문에 조금 더 관심이 생겼던 것 같습니다.&#xA;&#xA;!--more--&#xA;&#xA;항상 기술이 사람의 마음을 보듬어줄 수 있다면 좋겠다고 생각해 왔지만, 한편으로는 사람의 마음이라는 것이 쉽게 치유될 수 있는 것이었다면 애초에 우리가 그토록 많은 상실을 겪지 않았을 것이라는 생각도 듭니다.&#xA;&#xA;개인적으로 생각하기에, 사람의 마음과 삶을 개선하려는 제품들이 가지는 가장 큰 한계는 그런 제품을 찾아 사용할 의지가 있을 만큼 건강한 사람들만 사용한다는 점입니다. 제품은 소비자에게 선택받을 뿐, 어떤 제품도 소비자를 스스로 찾아갈 수는 없기 때문입니다. 그렇기에 정작 우리가 가장 돕고 싶은 사람들, 이미 나아갈 희망을 잃어버린 사람들에게 우리는 도움의 손길을 내밀기조차 어렵습니다.&#xA;&#xA;커피챗에서도 그 이야기를 꺼내 보았지만, 아직 명확한 해결책이 있지는 않은 것 같았습니다. 다만 모두가 그 문제를 해결하기 위해 노력하고 있으니, 우선 도울 수 있는 사람들을 도우며 한 걸음씩 나아가다 보면 지금껏 닿지 못했던 사람들에게도 닿을 수 있는 날이 올지도 모르겠습니다.&#xA;&#xA;사실 제가 가장 힘든 시기를 버티게 해 준 것은 주변 사람들의 헌신이었습니다. 침대에 널브러져 있는 저를 흔들어 일으켜서 어떻게든 일상을 살아가게 만들어 준 사람들의 사랑과 헌신 덕분에 스스로를 챙길 만큼 회복될 때까지 버틸 수 있었습니다.&#xA;&#xA;제가 그런 헌신을 받을 수 있었던 것은 순전히 좋은 사람들 주변에 태어나 그들을 만날 수 있었던 운이 따라 주었기 때문이라고 생각합니다. 그만큼 운이 좋지 않았던 이들이 고독 속에 사라져 가는 모습을 우리는 너무 많이 보아 왔습니다.&#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/maeume-gwanhan-jjalbeun-geul.png" alt="표지 사진"></p>

<p>최근에 정신건강 관련 앱을 개발하는 스타트업을 경영하시는 분과 커피챗을 할 기회가 있었습니다. 사실 코로나 블루 시기에 주변에서 비극적인 일들을 많이 겪었기 때문에 조금 더 관심이 생겼던 것 같습니다.</p>



<p>항상 기술이 사람의 마음을 보듬어줄 수 있다면 좋겠다고 생각해 왔지만, 한편으로는 사람의 마음이라는 것이 쉽게 치유될 수 있는 것이었다면 애초에 우리가 그토록 많은 상실을 겪지 않았을 것이라는 생각도 듭니다.</p>

<p>개인적으로 생각하기에, 사람의 마음과 삶을 개선하려는 제품들이 가지는 가장 큰 한계는 그런 제품을 찾아 사용할 의지가 있을 만큼 건강한 사람들만 사용한다는 점입니다. 제품은 소비자에게 선택받을 뿐, 어떤 제품도 소비자를 스스로 찾아갈 수는 없기 때문입니다. 그렇기에 정작 우리가 가장 돕고 싶은 사람들, 이미 나아갈 희망을 잃어버린 사람들에게 우리는 도움의 손길을 내밀기조차 어렵습니다.</p>

<p>커피챗에서도 그 이야기를 꺼내 보았지만, 아직 명확한 해결책이 있지는 않은 것 같았습니다. 다만 모두가 그 문제를 해결하기 위해 노력하고 있으니, 우선 도울 수 있는 사람들을 도우며 한 걸음씩 나아가다 보면 지금껏 닿지 못했던 사람들에게도 닿을 수 있는 날이 올지도 모르겠습니다.</p>

<p>사실 제가 가장 힘든 시기를 버티게 해 준 것은 주변 사람들의 헌신이었습니다. 침대에 널브러져 있는 저를 흔들어 일으켜서 어떻게든 일상을 살아가게 만들어 준 사람들의 사랑과 헌신 덕분에 스스로를 챙길 만큼 회복될 때까지 버틸 수 있었습니다.</p>

<p>제가 그런 헌신을 받을 수 있었던 것은 순전히 좋은 사람들 주변에 태어나 그들을 만날 수 있었던 운이 따라 주었기 때문이라고 생각합니다. 그만큼 운이 좋지 않았던 이들이 고독 속에 사라져 가는 모습을 우리는 너무 많이 보아 왔습니다.</p>

<p>기술이 사람들의 헌신을 대신해줄 수 있을까요? 정확히 말하자면, 어떻게 해야 그런 헌신을 기술로 대신할 수 있을까요? 어려움을 겪는 사람에게 먼저 찾아가 도움의 손길을 내미는 기술은 어떻게 해야 만들 수 있을까요?</p>

<p>그런 기술을 만들 수 있는 사람이 되고 싶다는 소망이 있습니다.</p>



<hr>

<h3 id="dani-soohan-park-heartade" 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/maeume-gwanhan-jjalbeun-geul</guid>
      <pubDate>Thu, 31 Aug 2023 09:53:31 +0000</pubDate>
    </item>
  </channel>
</rss>