<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>뚝딱뚝딱 &amp;mdash; The Heartade Space</title>
    <link>https://blog.heartade.dev/tag:뚝딱뚝딱</link>
    <description>life as a continuous loop of building and breaking</description>
    <pubDate>Sun, 03 May 2026 13:28:42 +0000</pubDate>
    <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>블루스카이 라벨러 개발기</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>
  </channel>
</rss>