<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>오늘의 WOD</title>
        <link>https://velog.io/</link>
        <description>현재 블로그 : https://jasonsong97.tistory.com/</description>
        <lastBuildDate>Fri, 23 Feb 2024 13:32:14 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>오늘의 WOD</title>
            <url>https://velog.velcdn.com/images/jaegeunsong_1997/profile/f82de33c-b787-43f6-8c2e-85cb2cbac4d0/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 오늘의 WOD. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jaegeunsong_1997" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[NestJS-Follow count]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Follow-count</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Follow-count</guid>
            <pubDate>Fri, 23 Feb 2024 13:32:14 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️follow-count-increment--decrement">🖊️Follow Count increment &amp; decrement</h3>
<p>팔로우를 누른 경우, 팔로우 요청 또는 팔로우 삭제 경우에만 count를 조절하도록 하겠습니다.</p>
<p>트랜젝션을 사용해서 구현을 해보겠습니다. 먼저 count를 담당할 컬럼을 만들겠습니다.</p>
<ul>
<li>users.entity.ts</li>
</ul>
<pre><code class="language-typescript">.
.
@Column({
      default: 0
})
followerCount: number;

@Column({
      default: 0
})
followeeCount: number;</code></pre>
<ul>
<li>users.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Patch(&#39;follow/:id/confirm&#39;) // 나를 팔로우 하려는 상대 id
@UseInterceptors(TransactionInterceptor)
async patchFollowConfirm(
    @User() user: UsersModel,
    @Param(&#39;id&#39;, ParseIntPipe) followerId: number,
    @QueryRunner() qr: QR,
) {
    await this.usersService.confirmFollow(followerId, user.id, qr);
    await this.usersService.incrementFollowerCount(user.id, qr);
    return true;
}

@Delete(&#39;follow/:id&#39;)
@UseInterceptors(TransactionInterceptor)
async deleteFollow(
    @User() user: UsersModel,
    @Param(&#39;id&#39;, ParseIntPipe) followeeId: number, // 내가 팔로우하는 상대
    @QueryRunner() qr: QR,
) {
    await this.usersService.deleteFollow(user.id, followeeId, qr);
    await this.usersService.decrementFollowerCount(user.id, qr);
    return true;
}</code></pre>
<ul>
<li>users.service.ts</li>
</ul>
<pre><code class="language-typescript">getUsersRepository(qr?: QueryRunner) {
  // qr 있는 경우
  return qr ? qr.manager.getRepository&lt;UsersModel&gt;(UsersModel) : this.usersRepository;
}

getUsersFollowRepository(qr?: QueryRunner) {
  // qr 있는 경우
  return qr ? qr.manager.getRepository&lt;UserFollowersModel&gt;(UserFollowersModel) : this.userFollowersRepository;
}
.
.
async followUser(followerId: number, followeeId: number, qr?: QueryRunner) {
    const userFollowersRepository = this.getUsersFollowRepository(qr);
    await userFollowersRepository.save({
        follower: {
              id: followerId
        },
        followee: {
              id: followeeId
        }
    });

    return true;
}
.
.
async confirmFollow(followerId: number, followeeId: number, qr?: QueryRunner) {
    const userFollowersRepository = this.getUsersFollowRepository(qr);

    // 중간테이블에 데이터가 존재하는지 확인
    const existing = await userFollowersRepository.findOne({
        where: {
            follower: {
                  id: followerId
            },
            followee: {
                  id: followeeId
            }
        },
        relations: {
            follower: true,
            followee: true
        },
    });
    if (!existing) throw new BadRequestException(`존재하지 않는 팔로우 요청입니다. `);

    // save값을 넣으면, 변경된 부분만 update한다.
    await userFollowersRepository.save({
        ...existing,
        isConfirmed: true,
    });
    return true;
}
.
.
async deleteFollow(followerId: number, followeeId: number, qr?: QueryRunner) {
    const userFollowersRepository = this.getUsersFollowRepository(qr);
    await userFollowersRepository.delete({
        follower: {
              id: followerId,
        },
        followee: {
              id: followeeId,
        },
    });
    return true;
}
.
.
async incrementFollowerCount(userId: number, qr?: QueryRunner) {
    const userRepository = this.getUsersRepository(qr);
    await userRepository.increment({
          id: userId
    }, &#39;followerCount&#39;, 1);
}

async decrementFollowerCount(userId: number, qr?: QueryRunner) {
    const userRepository = this.getUsersRepository(qr);
    await userRepository.decrement({
          id: userId
    }, &#39;followerCount&#39;, 1);
}</code></pre>
<p>테스트를 진행해보겠습니다.</p>
<p>현재 1번 사용자로 2번사용자를 팔로우하고 1번사용자가 요청을 수락하면 2번 사용자의 follwer는 1증가합니다. 하지만 1번 사용자의 follwee는 0 그대로 존재합니다.</p>
<p>이 문제를 해결해보겠습니다.</p>
<p>{{추가 필요!!!!!!!!!!!!!!}}</p>
<hr>
<h3 id="🖊️comment-count">🖊️Comment Count</h3>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async incrementCommentCount(postId: number, qr?: QueryRunner) {
    const repository = this.getRepository(qr);
    await repository.increment({
          id: postId,
    }, &#39;commentCount&#39;, 1);
}

async decrementCommentCount(postId: number, qr?: QueryRunner) {
    const repository = this.getRepository(qr);
    await repository.decrement({
          id: postId,
    }, &#39;commentCount&#39;, 1);
}</code></pre>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">.
.
getRepository(qr?: QueryRunner) {
    return qr ? qr.manager.getRepository&lt;CommentsModel&gt;(CommentsModel) : this.commentsRepository;
}
.
.
async createComment(
    dto: CreateCommentsDto,
    postId: number,
    author: UsersModel,
    qr?: QueryRunner,
) {
    const repository = this.getRepository(qr);
    return repository.save({
        ...dto,
        post: {
              id: postId
        },
        author,
    });
}
.
.
async deleteComment(
    id: number,
    qr?: QueryRunner
) {
    const repository = this.getRepository(qr);
    const comment = await repository.findOne({
        where: {
              id,
        }
    });
    if (!comment) throw new BadRequestException(`존재하지 않는 댓글입니다. `);
    await repository.delete(id);
    return id;
}</code></pre>
<ul>
<li>comments.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseInterceptors(TransactionInterceptor)
async postComment(
    @Param(&#39;postId&#39;, ParseIntPipe) postId: number,
    @Body() body: CreateCommentsDto,
    @User() user: UsersModel,
    @QueryRunner() qr: QR,
) {
    const resp = await this.commentsService.createComment(
        body, 
        postId, 
        user,
        qr
    );
    await this.postsService.incrementCommentCount(
        postId,
        qr
    );

    return resp;
}
.
.
@Delete(&#39;:commentId&#39;)
@UseInterceptors(TransactionInterceptor)
@UseGuards(IsCommentMineOrAdminGuard)
async deleteComment(
    @Param(&#39;commentId&#39;, ParseIntPipe) commentId: number,
    @Param(&#39;postId&#39;, ParseIntPipe) postId: number, // endpoint에서 받아옴
    @QueryRunner() qr: QR,
) {
    const resp = await this.commentsService.deleteComment(
      commentId,
      qr
    );
    await this.postsService.decrementCommentCount(postId, qr);
    return resp;
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Follow]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Follow</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Follow</guid>
            <pubDate>Fri, 23 Feb 2024 08:52:20 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️이론">🖊️이론</h3>
<p>인스타그램 기반의 팔로우 시스템을 적용하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9525d8cf-ee87-4743-accb-23abe6be09ef/image.png" alt=""></p>
<ul>
<li>Following Many to Many Relation</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/66486f1e-570f-40df-8dac-cf2c2a6805d4/image.png" alt=""></p>
<p>Mnay To Many 관계에서는 중간 테이블을 만들어야 합니다. 하지만 Follow의 경우, User User 형태가 됩니다. 따라서 다음과 같이 만들어야 합니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/e2767a7e-d044-4b73-b6eb-e1fe3c4969c4/image.png" alt=""></p>
<p>User 테이블이 Follwer와 Follwee 역할을 담당하게 됩니다.</p>
<hr>
<h3 id="🖊️followers--followees-프로퍼티-생성">🖊️Followers &amp; Followees 프로퍼티 생성</h3>
<ul>
<li>users.entity.ts</li>
</ul>
<pre><code class="language-typescript">.
.
// 내가 팔로우 하고 있는 사람들
@ManyToMany(() =&gt; UsersModel, (user) =&gt; user.followees)
@JoinTable()
followers: UsersModel[];

// 나를 팔로우 하고 있는 사람들
@ManyToMany(() =&gt; UsersModel, (user) =&gt; user.followers)
followees: UsersModel[];</code></pre>
<p>pgadmin으로 확인을 하면 다음과 같이 생성이 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/43c03de3-cafc-436b-816e-95bfb9a6fee3/image.png" alt=""></p>
<hr>
<h3 id="🖊️follow-시스템-로직-작성-및-테스트">🖊️Follow 시스템 로직 작성 및 테스트</h3>
<p>팔로우를 하는 로직을 작성하겠습니다.</p>
<ul>
<li>users.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post(&#39;follow/:id&#39;)
async postFollow(
    @User() user: UsersModel, // follower
    @Param(&#39;id&#39;, ParseIntPipe) followeeId: number,
) {
    await this.usersService.followUser(
        user.id,
        followeeId
    );
    return true;
}</code></pre>
<ul>
<li>users.service.ts</li>
</ul>
<pre><code class="language-typescript">async followUser(followerId: number, followeeId: number) {
    const user = await this.usersRepository.findOne({
        where: {
              id: followerId
        },
        relations: {
              followees: true
        }
    });
    if (!user) throw new BadRequestException(`존재하지 않는 팔로워 입니다. `);

    await this.usersRepository.save({
        ...user,
        followees: [
            ...user.followees,
            {
                  id: followeeId,
            }
        ]
    })
}</code></pre>
<p>이번에는 팔로워들을 가져오는 API를 만들겠습니다.</p>
<ul>
<li>users.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Get(&#39;follow/me&#39;)
async getFollow(
      @User() user: UsersModel
) {
      return this.usersService.getFollowers(user.id);
}</code></pre>
<ul>
<li>users.service.ts</li>
</ul>
<pre><code class="language-typescript">async getFollowers(userId: number): Promise&lt;UsersModel[]&gt; {
    const user = await this.usersRepository.findOne({
        where: {
            id: userId,
        },
        relations: {
              followers: true
        }
    });
    return user.followers;
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p>1번 <code>codefactory</code>로 2번 <code>codefactory1</code>를 팔로우 하겠습니다. 먼저 1번 사용자로 로그인을 하겠습니다. 후에 2번을 팔로우 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/b5660f37-c1d2-4148-820d-0c7bf3158390/image.png" alt=""></p>
<p><code>2번 사용자</code>로 로그인을 다시 하고, 나의 팔로워 가져오는 API를 호출합니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/fa64fcca-9d68-41bc-b757-4bf98ce50cee/image.png" alt=""></p>
<pre><code>[
    {
        &quot;id&quot;: 1,
        &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;nickname&quot;: &quot;codefactory&quot;,
        &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    }
]</code></pre><p>만약 <code>3번 사용자</code>로 1번을 팔로우하고 <code>1번 사용자</code>로 로그인 후 나의 팔로워 가져오는 API를 호출하면 다음과 같이 나오게 됩니다.</p>
<pre><code>[
    {
        &quot;id&quot;: 2,
        &quot;updatedAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
        &quot;nickname&quot;: &quot;codefactory1&quot;,
        &quot;email&quot;: &quot;codefactory1@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    },
    {
        &quot;id&quot;: 3,
        &quot;updatedAt&quot;: &quot;2024-01-27T18:34:48.009Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-27T18:34:48.009Z&quot;,
        &quot;nickname&quot;: &quot;codefactory19&quot;,
        &quot;email&quot;: &quot;codefactory19@codefactory.ai&quot;,
        &quot;role&quot;: &quot;ADMIN&quot;
    }
]</code></pre><p>매우 정석적인 방법입니다. 이런 방법으로 하면 중간 테이블에 데이터를 추가할 수 없고 오로지 follower와 followee만 컬럼으로 지정할 수 있습니다. 예를 들면 내가 팔로우를 했을 때, 상대방이 confirm을 했는지 이런 데이터를 넣을 수가 없습니다.</p>
<hr>
<h3 id="🖊️follow-테이블-직접-생성">🖊️Follow 테이블 직접 생성</h3>
<p>따라서 직접 중간 테이블을 만들겠습니다. 직접 테이블을 구현해서 ManyToMany 형식으로 만들겠습니다.</p>
<ul>
<li>users/entity/user-followers.entity.ts</li>
</ul>
<pre><code class="language-typescript">import { BaseModel } from &quot;src/common/entity/base.entity&quot;;
import { UsersModel } from &quot;./users.entity&quot;;
import { Column, Entity, ManyToOne } from &quot;typeorm&quot;;

@Entity()
export class UserFollowersModel extends BaseModel {

    @ManyToOne(() =&gt; UsersModel, (user) =&gt; user.followers)
    follower: UsersModel;

    @ManyToOne(() =&gt; UsersModel, (user) =&gt; user.followees)
    followee: UsersModel;

    @Column({
          default: false
    })
    isConfirmed: boolean;
}</code></pre>
<ul>
<li>users.entity.ts</li>
</ul>
<pre><code class="language-typescript">.
.
@OneToMany(() =&gt; UserFollowersModel, (ufm) =&gt; ufm.follower) // 변경
followers: UserFollowersModel[];

@OneToMany(() =&gt; UserFollowersModel, (ufm) =&gt; ufm.followee) // 변경
followees: UserFollowersModel[];</code></pre>
<p>그리고 app.module.ts에 <code>UserFollowersModel</code>를 등록합니다.</p>
<ul>
<li>app.module.ts</li>
</ul>
<pre><code class="language-typescript">entities: [
    PostsModel,
    UsersModel,
    ImageModel,
    ChatsModel,
    MessagesModel,
    CommentsModel,
    UserFollowersModel // 추가
],</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/52eae7fe-0541-4925-8e1f-0cf72c7580e8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/175714cf-5c1f-42af-8dd2-4f39b230d513/image.png" alt=""></p>
<p>정석적인 방법으로 하면 컬럼은 2개로 고정이 되지만, 직접 테이블을 생성하면 여러 컬럼을 넣을 수 있습니다.</p>
<hr>
<h3 id="🖊️custom-table에-맞춰서-로직-변경">🖊️Custom Table에 맞춰서 로직 변경</h3>
<p>이전 팔로우를 하는 기능에서는 relation이 ManyToMany라는 가정하에 직접 followee를 넣었습니다.</p>
<ul>
<li>users.service.ts</li>
</ul>
<pre><code class="language-typescript">async followUser(followerId: number, followeeId: number) {
    const user = await this.usersRepository.findOne({
        where: {
              id: followerId
        },
        relations: {
              followees: true
        }
    });
    if (!user) throw new BadRequestException(`존재하지 않는 팔로워 입니다. `);

    await this.usersRepository.save({
        ...user,
        followees: [
            ...user.followees,
            {
                  id: followeeId,
            }
      ]
    })
}</code></pre>
<p>따라서 기존 코드를 삭제하고 새로운 코드를 작성하겠습니다.</p>
<pre><code class="language-typescript">constructor(
    @InjectRepository(UsersModel)
    private readonly usersRepository: Repository&lt;UsersModel&gt;,
    @InjectRepository(UserFollowersModel)
    private readonly userFollowersRepository: Repository&lt;UserFollowersModel&gt;,
) {}
.
.
async followUser(followerId: number, followeeId: number) {
    const result = await this.userFollowersRepository.save({
        follower: {
              id: followerId
        },
        followee: {
              id: followeeId
        }
    });

    return true;
}

async getFollowers(userId: number): Promise&lt;UsersModel[]&gt; {
    const result = await this.userFollowersRepository.find({
        where: {
            // 팔로우하는 대상
            followee: {
                id: userId
            }
        },
        relations: {
            follower: true,
            followee: true
        }
    });
    return result.map((x) =&gt; x.follower); // follower만 뽑아서 리스트만들기
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/c8184f81-4690-4ca5-b883-ee2629e8df58/image.png" alt=""></p>
<p><code>2번, 3번 사용자</code>로 1번 사용자를 팔로우 하겠습니다.</p>
<pre><code>[
    {
        &quot;id&quot;: 3,
        &quot;updatedAt&quot;: &quot;2024-01-27T18:34:48.009Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-27T18:34:48.009Z&quot;,
        &quot;nickname&quot;: &quot;codefactory19&quot;,
        &quot;email&quot;: &quot;codefactory19@codefactory.ai&quot;,
        &quot;role&quot;: &quot;ADMIN&quot;
    },
    {
        &quot;id&quot;: 2,
        &quot;updatedAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
        &quot;nickname&quot;: &quot;codefactory1&quot;,
        &quot;email&quot;: &quot;codefactory1@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    }
]</code></pre><hr>
<h3 id="🖊️confirm-follow-로직-추가">🖊️Confirm Follow 로직 추가</h3>
<ul>
<li>users.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Patch(&#39;follow/:id/confirm&#39;) // 나를 팔로우 하려는 상대 id
async patchFollowConfirm(
    @User() user: UsersModel,
    @Param(&#39;id&#39;, ParseIntPipe) followerId: number,
) {
    await this.usersService.confirmFollow(followerId, user.id);
    return true;
}</code></pre>
<ul>
<li>users.service.ts</li>
</ul>
<pre><code class="language-typescript">async confirmFollow(followerId: number, followeeId: number) {
    // 중간테이블에 데이터가 존재하는지 확인
    const existing = await this.userFollowersRepository.findOne({
        where: {
            follower: {
                  id: followerId
            },
            followee: {
                  id: followeeId
            }
        },
        relations: {
            follower: true,
            followee: true
        },
    });
    if (!existing) throw new BadRequestException(`존재하지 않는 팔로우 요청입니다. `);

    // save값을 넣으면, 변경된 부분만 update한다.
    await this.userFollowersRepository.save({
        ...existing,
        isConfirmed: true,
    });
    return true;
}</code></pre>
<p>그리고 팔로우를 가져오는 API에서는 isConfirmed가 true인것만 가져와야합니다.</p>
<pre><code class="language-typescript">async getFollowers(userId: number): Promise&lt;UsersModel[]&gt; {
    const result = await this.userFollowersRepository.find({
        where: {
            followee: {
                id: userId,
            },
            isConfirmed: true, // 추가
        },
        relations: {
            follower: true,
            followee: true,
        },
    });
  return result.map((x) =&gt; x.follower);
}</code></pre>
<p>포스트맨으로 확인을 하겠습니다. 1번 사용자로 로그인을 하고 팔로워를 가져오는 기능을 호출하면 아무것도 나오지 않습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/4fa3e9c2-fc05-4d5d-a2da-83f410bffda9/image.png" alt=""></p>
<p>왜냐하면 우리가 허가한 팔로우 요청이 없기 때문입니다. 그러면 2번과 3번이 팔로우 요청을 한 것을 confirm을 하도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/cc4efb9f-71cd-4e54-9c06-b0a61274a25c/image.png" alt=""></p>
<p><code>2번 사용자가 팔로우 요청한 것을 허가</code>를 하면 확인이 되는 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/20e3631f-bfa7-4ef3-870c-9df47844f911/image.png" alt=""></p>
<pre><code>[
    {
        &quot;id&quot;: 2,
        &quot;updatedAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
        &quot;nickname&quot;: &quot;codefactory1&quot;,
        &quot;email&quot;: &quot;codefactory1@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    }
]</code></pre><p>이번에는 <code>getFollow()</code>를 팔로우 요청이 온 것들을 포함해서 보여주는 것과 포함하지 않고 보여주는 코드로 수정하겠습니다.</p>
<ul>
<li>users.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Get(&#39;follow/me&#39;)
async getFollow(
    @User() user: UsersModel,
    @Query(&#39;includeNotConfirmed&#39;, new DefaultValuePipe(false), ParseBoolPipe) includeNotConfirmed: boolean
) {
      return this.usersService.getFollowers(user.id, includeNotConfirmed);
}</code></pre>
<ul>
<li>users.service.ts</li>
</ul>
<pre><code class="language-typescript">async getFollowers(userId: number, includeNotConfirmed: boolean): Promise&lt;UsersModel[]&gt; {
    const where = {
        // 팔로우하는 대상
        followee: {
            id: userId
        }
      };

    // 허가 한것들만 추가를 해라!
    if (!includeNotConfirmed) {
          where[&#39;isConfirmed&#39;] = true;
    }

    const result = await this.userFollowersRepository.find({
        where,
        relations: {
            follower: true,
            followee: true
        }
    });
    return result.map((x) =&gt; x.follower);
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/f7541265-ef9d-44b7-bfc6-02f08ebdf975/image.png" alt=""></p>
<pre><code>[
    {
        &quot;id&quot;: 3,
        &quot;updatedAt&quot;: &quot;2024-01-27T18:34:48.009Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-27T18:34:48.009Z&quot;,
        &quot;nickname&quot;: &quot;codefactory19&quot;,
        &quot;email&quot;: &quot;codefactory19@codefactory.ai&quot;,
        &quot;role&quot;: &quot;ADMIN&quot;
    },
    {
        &quot;id&quot;: 2,
        &quot;updatedAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
        &quot;nickname&quot;: &quot;codefactory1&quot;,
        &quot;email&quot;: &quot;codefactory1@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    }
]</code></pre><p>응답 데이터 형태를 바꾸겠습니다.</p>
<pre><code class="language-typescript">async getFollowers(userId: number, includeNotConfirmed: boolean): Promise&lt;UsersModel[]&gt; {
    const where = {
        followee: {
            id: userId
        }
      };

    if (!includeNotConfirmed) {
          where[&#39;isConfirmed&#39;] = true;
    }

    const result = await this.userFollowersRepository.find({
        where,
        relations: {
            follower: true,
            followee: true
        }
    });
    return result.map((x) =&gt; ({
        id: x.follower.id,
        nickname: x.follower.nickname,
        email: x.follower.email,
        isConfirmed: x.isConfirmed,
    })); // follower만 뽑아서 리스트만들기
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/5346e94c-1049-4475-aa7d-de29ef6d5ec5/image.png" alt=""></p>
<pre><code>[
    {
        &quot;id&quot;: 3,
        &quot;nickname&quot;: &quot;codefactory19&quot;,
        &quot;email&quot;: &quot;codefactory19@codefactory.ai&quot;,
        &quot;isConfirmed&quot;: false
    },
    {
        &quot;id&quot;: 2,
        &quot;nickname&quot;: &quot;codefactory1&quot;,
        &quot;email&quot;: &quot;codefactory1@codefactory.ai&quot;,
        &quot;isConfirmed&quot;: true
    }
]</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/0c72e12f-6929-4072-8361-42903b10cea4/image.png" alt=""></p>
<pre><code>[
    {
        &quot;id&quot;: 2,
        &quot;nickname&quot;: &quot;codefactory1&quot;,
        &quot;email&quot;: &quot;codefactory1@codefactory.ai&quot;,
        &quot;isConfirmed&quot;: true
    }
]</code></pre><hr>
<h3 id="🖊️follow-취소-요청-작업">🖊️Follow 취소 요청 작업</h3>
<ul>
<li>users.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Delete(&#39;follow/:id&#39;)
async deleteFollow(
    @User() user: UsersModel,
    @Param(&#39;id&#39;, ParseIntPipe) followeeId: number, // 내가 팔로우하는 상대
) {
    await this.usersService.deleteFollow(user.id, followeeId);
    return true;
}</code></pre>
<ul>
<li>users.service.ts</li>
</ul>
<pre><code class="language-typescript">async deleteFollow(followerId: number, followeeId: number) {
    await this.userFollowersRepository.delete({
        follower: {
              id: followerId,
        },
        followee: {
              id: followeeId,
        },
    });
    return true;
}</code></pre>
<p>포스트맨으로 테스트하겠습니다. 2번 사용자는 1번 사용자를 팔로우 하고 있습니다.</p>
<p>2번 사용자로 로그인을 해서 1번 사용자를 언팔로우 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/baa5a6d0-7a11-4916-ba32-c33a2cd8657d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9795597d-ae66-4e0c-ad0c-953426cb4f5d/image.png" alt=""></p>
<pre><code>[
    {
        &quot;id&quot;: 3,
        &quot;nickname&quot;: &quot;codefactory19&quot;,
        &quot;email&quot;: &quot;codefactory19@codefactory.ai&quot;,
        &quot;isConfirmed&quot;: false
    }
]</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Authorization]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Authorization</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Authorization</guid>
            <pubDate>Thu, 22 Feb 2024 07:48:26 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️ispostmineoradmin-guard">🖊️IsPostMineOrAdmin Guard</h3>
<p><code>patchPost</code>의 경우 ADMIN 뿐만 아니라 본인인 경우에도 허용이 되도록 만들어야 합니다. 이것을 guard를 사용해서 만들어보겠습니다.</p>
<ul>
<li>posts/guard/is-post-mine-or-admin.guard.ts</li>
</ul>
<pre><code class="language-typescript">import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from &quot;@nestjs/common&quot;;
import { RolesEnum } from &quot;src/users/const/roles.const&quot;;
import { PostsService } from &quot;../posts.service&quot;;

@Injectable()
export class IsPostMineOrAdminGuard implements CanActivate {

    constructor(
        private readonly postService: PostsService,
    ) {}

    async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
        const req = context.switchToHttp().getRequest() as Request &amp; {user: UsersModel}; // 인터섹션(user는 존재한다.)
        const {user} = req;
        if (!user) throw new UnauthorizedException(`사용자 정보를 가져올 수 없습니다. `);

        /**
         * Admin일 경우 그냥 패스
         */
        if (user.role === RolesEnum.ADMIN) return true;

        const postId = req.params.postId;
        if (!postId) throw new BadRequestException(`Post ID가 파라미터로 제공 돼야합니다. `)

        return this.postService.isPostMine(
            user.id,
            parseInt(postId)
        );
     }
}</code></pre>
<p>post 서비스에서 해당 post가 나의 것인지 확인하는 코드를 작성하겠습니다.</p>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async isPostMine(userId: number, postId: number) {
    return this.postsRepository.exists({
        where: {
            id: postId,
            author: {
                  id: userId,
            }
        },
          relations: {
            author: true,
        }
    })
}</code></pre>
<p>이제 적용을 하겠습니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Patch(&#39;:id&#39;)
@UseGuards(IsPostMineOrAdmin)
patchPost(
    @Param(&#39;id&#39;, ParseIntPipe) id: number, 
    @Body() body: UpdatePostDto,
) {
    return this.postsService.updatePost(id, body);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/0cda3e9d-1a37-4759-aefa-bda4b73b92cd/image.png" alt=""></p>
<pre><code>{
    &quot;message&quot;: &quot;Post ID가 파라미터로 제공 돼야합니다. &quot;,
    &quot;error&quot;: &quot;Bad Request&quot;,
    &quot;statusCode&quot;: 400
}</code></pre><p>이유는 컨트롤러에서 값을 받을 때 <code>id</code>로 받기 때문입니다. 따라서 바꿔줍니다.</p>
<pre><code class="language-typescript">@Patch(&#39;:postId&#39;)
@UseGuards(IsPostMineOrAdmin)
patchPost(
    @Param(&#39;postId&#39;, ParseIntPipe) id: number, 
    @Body() body: UpdatePostDto,
) {
    return this.postsService.updatePost(id, body);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a8b05bd1-e574-4775-a32f-75ac7490eda2/image.png" alt=""></p>
<pre><code>{
    &quot;id&quot;: 91,
    &quot;updatedAt&quot;: &quot;2024-02-21T22:14:39.664Z&quot;,
    &quot;createdAt&quot;: &quot;2024-01-28T02:12:54.444Z&quot;,
    &quot;title&quot;: &quot;Authorization Check&quot;,
    &quot;content&quot;: &quot;임의로 생성된 포수트 내용 82&quot;,
    &quot;likeCount&quot;: 0,
    &quot;commentCount&quot;: 0
}</code></pre><p>만약 다른 사용자로 PATCH 요청을 보내면 forbidden 에러가 발생합니다. 이 에러 메세지를 작성하겠습니다.</p>
<ul>
<li>posts/guard/is-post-mine-or-admin.guard.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class IsPostMineOrAdminGuard implements CanActivate {

    constructor(
        private readonly postService: PostsService,
    ) {}

    async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
        const req = context.switchToHttp().getRequest() as Request &amp; {user: UsersModel};
        const {user} = req;
        if (!user) throw new UnauthorizedException(`사용자 정보를 가져올 수 없습니다. `);

        if (user.role === RolesEnum.ADMIN) return true;

        const postId = req.params.postId;
        if (!postId) throw new BadRequestException(`Post ID가 파라미터로 제공 돼야합니다. `)

          // 추가
        const isOk = await this.postService.isPostMine(
            user.id,
            parseInt(postId)
        );
        if (!isOk) throw new ForbiddenException(`권한이 없습니다. `);
        return true;
    }
}</code></pre>
<p>다른 사용자로 로그인 후에 다시 요청해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/26299179-b3aa-4b31-96ec-201b3c56ad00/image.png" alt=""></p>
<pre><code>{
    &quot;message&quot;: &quot;권한이 없습니다. &quot;,
    &quot;error&quot;: &quot;Forbidden&quot;,
    &quot;statusCode&quot;: 403
}</code></pre><p>관리자 계정으로 로그인 후, PATCH요청을 해도 잘 실행되는 것을 알 수 있습니다.</p>
<hr>
<h3 id="🖊️iscommentmineoradmin-guard-생성-및-적용">🖊️IsCommentMineOrAdmin Guard 생성 및 적용</h3>
<p>이번에는 comment 또한 ADMIN과 실제 사용자만 가능하도록 만들겠습니다.</p>
<ul>
<li>comments/guard/is-comment-mine-or-admin.guard.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class IsCommentMineOrAdminGuard implements CanActivate {

    constructor(
        private readonly commentService: CommentsService,
    ) {}

    async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
        const req = context.switchToHttp().getRequest() as Request &amp; {user: UsersModel};
        const {user} = req;
        if (!user) throw new UnauthorizedException(`사용자 정보를 가져올 수 없습니다. `);

        if (user.role === RolesEnum.ADMIN) return true;

        const commentId = req.params.commentId;
        if (!commentId) throw new BadRequestException(`Comment ID가 파라미터로 제공 돼야합니다. `)

        const isOk = await this.commentService.isCommentMine(
            user.id,
            parseInt(commentId)
        );
        if (!isOk) throw new ForbiddenException(`권한이 없습니다. `);
        return true;
    }
}</code></pre>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">async isCommentMine(userId: number, commentId: number) {
    return this.commentsRepository.exists({
        where: {
            id: commentId,
            author: {
                  id: userId,
            }
        },
        relations: {
              author: true,
        }
    })
}</code></pre>
<ul>
<li>comment.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Patch(&#39;:commentId&#39;)
@UseGuards(IsCommentMineOrAdminGuard)
async patchComment(
    @Param(&#39;commentId&#39;, ParseIntPipe) commentId: number,
    @Body() body: UpdateCommentsDto,
) {
    return this.commentsService.updateComment(
        body,
        commentId
    )
}

@Delete(&#39;:commentId&#39;)
@UseGuards(IsCommentMineOrAdminGuard)
async deleteComment(
      @Param(&#39;commentId&#39;, ParseIntPipe) commentId: number,
) {
      return this.commentsService.deleteComment(commentId);
}</code></pre>
<p>포스트맨으로 테스트 진행하기</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-RBAC(Role Based Access Control)]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-RBACRole-Based-Access-Control</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-RBACRole-Based-Access-Control</guid>
            <pubDate>Thu, 22 Feb 2024 06:41:05 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️roles-decorator-작업">🖊️Roles Decorator 작업</h3>
<p>관리자만 Post를 삭제할 수 있도록 RBAC을 사용하겠습니다. 먼저 관리자인지 아닌지를 구분하는 데코레이터를 만들겠습니다.</p>
<ul>
<li>users/decorator/roles.decorator.ts</li>
</ul>
<pre><code class="language-typescript">import { SetMetadata } from &quot;@nestjs/common&quot;;
import { RolesEnum } from &quot;../const/roles.const&quot;;

export const ROLES_KEY = &#39;user_roles&#39;;

// @Roles(RolesEnum.ADMIN) -&gt; admin 사용자만 사용 가능
export const Roles = (role: RolesEnum) =&gt; SetMetadata(ROLES_KEY, role) // 키값과 키값에 해당하는 데이터 넣기</code></pre>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Delete(&#39;:id&#39;)
@UseGuards(AccessTokenGuard)
@Roles(RolesEnum.ADMIN) // admin만 접근 가능
deletePost(@Param(&#39;id&#39;, ParseIntPipe) id: number) {
      return this.postsService.deletePost(id);
}</code></pre>
<p>메타데이터를 적용했으니까, 관리자가 아니면 아래 deletePost 기능을 못하게 만들겠습니다. 즉, 특정 코드를 막는 기능이기 때문에 Guard를 사용하겠습니다. user에 관한 guard이기 때문에 user에서 생성하겠습니다.</p>
<ul>
<li>users/guard/roles.guard.ts</li>
</ul>
<pre><code class="language-typescript">import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from &quot;@nestjs/common&quot;;
import { Reflector } from &quot;@nestjs/core&quot;;
import { ROLES_KEY } from &quot;../decorator/roles.decorator&quot;;

@Injectable()
export class RolesGuard implements CanActivate {

    constructor(
        private readonly reflector: Reflector,
    ) {}

    async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
        /**
         * Roles annotation에 대한 metadata를 가져와야한다.
         * 
         * Reflector: IoC에서 자동으로 주입을 받을 수 있다.
         * getAllAndOverride(): 
         *    ROLES_KEY에 해당되는 annotation에 대한 정보를 전부 가져옵니다.
         *    그중에서 가장 가까운 값을 가져와서 override(덮어씌운다)한다.
         *    EX) 컨트롤러에 붙여도, 메소드에 적용된 어노테이션을 가져온다.
         */ 
        const requireRole = this.reflector.getAllAndOverride(
            ROLES_KEY,
            [
                // 어떤 context에서 가져올거야?
                context.getHandler(),
                context.getClass()
            ]
        );

        // Roles Annotation 등록 X
        if (!requireRole) return true;

        // RolesGuard를 실행하기전에 AccessToken이 통과되기 때문에
        const {user} = context.switchToHttp().getRequest();
        if (!user) throw new UnauthorizedException(`토큰을 제공해주세요! `);
        if (user.role !== requireRole) throw new ForbiddenException(`이 작업을 수행할 권한이 없습니다. ${requireRole} 권한이 필요합니다.`);
        return true;
    }
}</code></pre>
<p>이 Guard는 전역적으로 적용할 것입니다. 왜냐하면 아래의 아래의 코드 때문입니다. 해당 코드는 권한을 필요로 하는 모든 경우 전부 적용시키도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9ecdb0e7-db7c-459f-816d-28e51f82f04e/image.png" alt=""></p>
<ul>
<li>app.module.ts</li>
</ul>
<pre><code class="language-typescript">providers: [AppService, 
    {
        provide: APP_INTERCEPTOR,
        useClass: ClassSerializerInterceptor, // 다른 모듈에서도 ClassSerializerInterceptor를 적용받는다.
    },
    {
        provide: APP_GUARD,
        useClass: RolesGuard
    }
],</code></pre>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/c3875f1f-1318-4c04-b675-0dccd02a63da/image.png" alt=""></p>
<pre><code>{
    &quot;message&quot;: &quot;토큰을 제공해주세요! &quot;,
    &quot;error&quot;: &quot;Unauthorized&quot;,
    &quot;statusCode&quot;: 401
}</code></pre><p>다음과 같이 에러가 나는 이유는 <code>우리가 적용한 Guard가 전역적으로 먼저 발동하기 때문에 AccessToken 보다 먼저 실행되어서 user가 존재하지 않기</code> 때문입니다.</p>
<hr>
<h3 id="🖊️모든-route-private로-ispublic-annotation-생성">🖊️모든 Route Private로, IsPublic Annotation 생성</h3>
<p>이번에는 <code>AccessToken</code>이 <code>@Roles</code>보다 먼저 실행되도록 글로벌하게 만들겠습니다. 보안을 위해서는 전역적으로 AccessTokenGuard가 작용하게 만들고, 필요하지 않는 것만 <code>@IsPublic</code> 을 만들어서 적용하겠습니다.</p>
<ul>
<li>app.module.ts</li>
</ul>
<pre><code class="language-typescript">providers: [AppService, 
    {
        provide: APP_INTERCEPTOR,
        useClass: ClassSerializerInterceptor, // 다른 모듈에서도 ClassSerializerInterceptor를 적용받는다.
    },
    {
        // 보안을 위해서는 기본값을 이렇게 만든다
        provide: APP_GUARD,
        useClass: AccessTokenGuard,
    },
    {
        provide: APP_GUARD,
        useClass: RolesGuard
    }
],</code></pre>
<p>common쪽에 <code>@IsPublic</code>을 만들어 주겠습니다.</p>
<ul>
<li>common/decorator/is-public.decorator.ts</li>
</ul>
<pre><code class="language-typescript">import { SetMetadata } from &quot;@nestjs/common&quot;;

export const IS_PUBLIC_KEY = &#39;is_public&#39;;

export const IsPublic = () =&gt; SetMetadata(IS_PUBLIC_KEY, true);</code></pre>
<p>이제 <code>IsPublic</code>을 감지해야할 기능이 필요합니다. 따라서 감지하는 기능은 <code>token쪽</code>에 작성을 하겠습니다.</p>
<ul>
<li>auth/guard/bearer-token.guard.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class BearerTokenGuard implements CanActivate {

    constructor(
        private readonly authService: AuthService,
         private readonly userService: UsersService,
         private readonly reflector: Reflector,
    ) {}

    async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
        // 토큰을 검증하기 전에 reflect metadata 기능을 사용해서 
        // public route를 달았는지 안 달았는지를 확인하는 검증을 하고
        // 달려있으면 바로 true를 반환하는 기능
        const isPublic = this.reflector.getAllAndOverride(
          IS_PUBLIC_KEY,
          [
            context.getHandler(),
            context.getClass(),
          ]
        );
        const req = context.switchToHttp().getRequest();
        if (isPublic) {
              req.isRoutePublic = true; // 표시
              return true;
        }

        const rawToken = req.headers[&#39;authorization&#39;];
        if (!rawToken) throw new UnauthorizedException(&#39;토큰이 없습니다.&#39;);
        const token = this.authService.extractTokenFromHeader(rawToken, true);
        const result = await this.authService.verifyToken(token);
          .
        .
}

@Injectable()
export class AccessTokenGuard extends BearerTokenGuard {
     async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
          await super.canActivate(context);
          const req = context.switchToHttp().getRequest();
          if (req.isRoutePublic) return true; // 추가
          if (req.tokenType !== &#39;access&#39;) throw new UnauthorizedException(&#39;Access Token이 아닙니다.&#39;);
          return true;
     }
}

@Injectable()
export class RefreshTokenGuard extends BearerTokenGuard {
     async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
          await super.canActivate(context);
          const req = context.switchToHttp().getRequest();
          if (req.isRoutePublic) return true; // 추가
          if (req.tokenType !== &#39;refresh&#39;) throw new UnauthorizedException(&#39;Refresh Token이 아닙니다.&#39;);
          return true;
     }
}</code></pre>
<p><code>@IsPublic</code>을 사용하는 곳에서는 return true가 되기 때문에 토큰이 필요없게 됩니다.</p>
<p>post 컨트롤러 1곳에만 적용을 해보겠습니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Get()
@IsPublic()
getPosts(
      @Query() query: PaginatePostDto,
) {
      return this.postsService.paginatePosts(query);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/fdaed5e6-ea9b-4a70-aa38-21e01471dd39/image.png" alt=""></p>
<p>포스트맨으로 요청하면 응답이 에러없이 잘 나오는 것을 알 수 있습니다. 또한 삭제도 잘 되는 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/b870c088-b856-4959-924a-60f71f3a9712/image.png" alt=""></p>
<hr>
<h3 id="🖊️public-route-정리">🖊️Public Route 정리</h3>
<ul>
<li>auth</li>
</ul>
<pre><code class="language-typescript">@Post(&#39;token/access&#39;)
@IsPublic()
@UseGuards(RefreshTokenGuard)
postTokenAccess

@Post(&#39;token/refresh&#39;)
@IsPublic()
@UseGuards(RefreshTokenGuard)
postTokenRefresh(

@Post(&#39;login/email&#39;)
@IsPublic()
@UseGuards(BasicTokenGuard)
postLoginEmail(

@Post(&#39;register/email&#39;)
@IsPublic()
postRegisterEmail(</code></pre>
<ul>
<li>common</li>
</ul>
<pre><code class="language-typescript">@Post(&#39;image&#39;)
@UseInterceptors(FileInterceptor(&#39;image&#39;))
postImage</code></pre>
<ul>
<li>posts</li>
</ul>
<pre><code class="language-typescript">@Get()
@IsPublic()
getPosts

@Post(&#39;random&#39;)
async postPostsRandom

@Get(&#39;:id&#39;)
@IsPublic()
getPost

@Post()
@UseInterceptors(TransactionInterceptor)
async postPosts

@Patch(&#39;:id&#39;)
patchPost

@Delete(&#39;:id&#39;)
@Roles(RolesEnum.ADMIN) 
deletePost</code></pre>
<ul>
<li>users</li>
</ul>
<pre><code class="language-typescript">@Get()
@Roles(RolesEnum.ADMIN)
getUsers</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Module Nesting]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Module-Nesting</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Module-Nesting</guid>
            <pubDate>Thu, 22 Feb 2024 01:42:09 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️module-nesting">🖊️Module Nesting</h3>
<p>댓글 기능을 만들도록 하겠습니다. 댓글의 경우 Post 내부에 존재하기 때문에 posts 내부에 생성을 하도록 하겠습니다.</p>
<pre><code>nest g resource -&gt; comments -&gt; REST API -&gt; n</code></pre><ul>
<li>posts/comments/comments.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Controller(&#39;posts/:postId/comments&#39;)
export class CommentsController {
  constructor(private readonly commentsService: CommentsService) {}

  /**
   * 1) Entity 생성
   * author -&gt; 작성자
   * comment -&gt; 실제 댓글 내용
   * likeCount -&gt; 좋아요 갯수
   * 
   * id -&gt; PrimaryGeneratedColumn
   * createAt -&gt; 생성일자
   * updatedAt -&gt; 업데이트일자
   * 
   * 2) GET() pagination
   * 3) GET(&#39;:commentId&#39;) 특정 comment만 하나 가져오는 기능
   * 4) POST() 코멘트 생성하는 기능
   * 5) PATCH(&#39;:commentId&#39;) 특정 comment 업데이트 하는 기능
   * 6) DELETE(&#39;:commentId) 특정 comment 삭제하는 기능
   */
}</code></pre>
<hr>
<h3 id="🖊️comments-entiy">🖊️Comments Entiy</h3>
<ul>
<li>posts/comments/entity/comments.entity.ts</li>
</ul>
<pre><code class="language-typescript">import { IsNumber, IsString } from &quot;class-validator&quot;;
import { BaseModel } from &quot;src/common/entity/base.entity&quot;;
import { PostsModel } from &quot;src/posts/entity/posts.entity&quot;;
import { UsersModel } from &quot;src/users/entity/users.entity&quot;;
import { Column, Entity, ManyToOne } from &quot;typeorm&quot;;

@Entity()
export class CommentsModel extends BaseModel {

    @ManyToOne(() =&gt; UsersModel, (user) =&gt; user.postComments)
    author: UsersModel;

    @ManyToOne(() =&gt; PostsModel, (post) =&gt; post.comments)
    post: PostsModel;

    @Column()
    @IsString()
    comment: string;

    @Column({
          default: 0
    })
    @IsNumber()
    likeCount: number;
}</code></pre>
<ul>
<li>posts.entity.ts</li>
</ul>
<pre><code class="language-typescript">.
.
@OneToMany(() =&gt; CommentsModel, (comment) =&gt; comment.post)
comments: CommentsModel[];</code></pre>
<ul>
<li>users.entity.ts</li>
</ul>
<pre><code class="language-typescript">.
.
@OneToMany(() =&gt; CommentsModel, (comment) =&gt; comment.author)
postComments: CommentsModel[];</code></pre>
<p>app.module.ts에 등록을 하고 typeORM 사용을 위해 typeORM 등록을 하겠습니다.</p>
<ul>
<li>app.module.ts</li>
</ul>
<pre><code class="language-typescript">entities: [
    PostsModel,
    UsersModel,
    ImageModel,
    ChatsModel,
    MessagesModel,
    CommentsModel, // 등록
],</code></pre>
<ul>
<li>comments.module.ts</li>
</ul>
<pre><code class="language-typescript">import { Module } from &#39;@nestjs/common&#39;;
import { CommentsService } from &#39;./comments.service&#39;;
import { CommentsController } from &#39;./comments.controller&#39;;
import { TypeOrmModule } from &#39;@nestjs/typeorm&#39;;
import { CommentsModel } from &#39;./entity/comments.entity&#39;;

@Module({
    imports: [
        TypeOrmModule.forFeature([
              CommentsModel,
        ])
    ],
    controllers: [CommentsController],
    providers: [CommentsService],
})
export class CommentsModule {}
</code></pre>
<hr>
<h3 id="🖊️paginate-comments-api">🖊️Paginate Comments API</h3>
<ul>
<li>comments/dto/paginate-comments.dto.ts</li>
</ul>
<pre><code class="language-typescript">import { BasePaginationDto } from &quot;src/common/dto/base-pagination.dto&quot;;

export class PaginateCommentsDto extends BasePaginationDto {}</code></pre>
<ul>
<li>comments.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Get()
getComments(
    @Param(&#39;postId&#39;, ParseIntPipe) postId: number,
    @Query() query: PaginateCommentsDto
) {
    return this.commentsService.paginateComments(
        query,
        postId,
    );
}</code></pre>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class CommentsService {

    constructor(
        @InjectRepository(CommentsModel)
           private readonly commentsRepository: Repository&lt;CommentsModel&gt;,
           private readonly commonService: CommonService,
    ) {}

    paginateComments(
        dto: PaginateCommentsDto,
         postId: number,
    ) {
        return this.commonService.paginate(
            dto,
            this.commentsRepository,
            {
                where: {
                    post: {
                          id: postId,
                    }
                }
            },
            `posts/${postId}/comments`,
        );
    }
}</code></pre>
<ul>
<li>comments.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
              CommentsModel,
        ]),
        CommonModule, // 등록
    ],
    controllers: [CommentsController],
    providers: [CommentsService],
})
export class CommentsModule {}</code></pre>
<hr>
<h3 id="🖊️id-기반으로-하나의-comment-가져오는-api">🖊️ID 기반으로 하나의 Comment 가져오는 API</h3>
<ul>
<li>comments.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Get(&#39;:commentId&#39;)
getComment(
      @Param(&#39;commentId&#39;, ParseIntPipe) commentId: number,
) {
      return this.commentsService.getCommentById(commentId);
}</code></pre>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">async getCommentById(id: number) {
    const comment = await this.commentsRepository.findOne({
        where: {
              id,
        }
    });
    if (!comment) throw new BadRequestException(`id: ${id} Comment는 존재하지 않습니다. `);
      return comment;
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/681a5a5e-5a8a-4305-85d6-9c501d43f67c/image.png" alt=""></p>
<pre><code>{
    &quot;message&quot;: &quot;id: 1 Comment는 존재하지 않습니다. &quot;,
    &quot;error&quot;: &quot;Bad Request&quot;,
    &quot;statusCode&quot;: 400
}</code></pre><hr>
<h3 id="🖊️comment-생성-api">🖊️Comment 생성 API</h3>
<ul>
<li>dto/create-comments.dto.ts</li>
</ul>
<pre><code class="language-typescript">import { PickType } from &quot;@nestjs/mapped-types&quot;;
import { CommentsModel } from &quot;../entity/comments.entity&quot;;

export class CreateCommentsDto extends PickType(CommentsModel, [
     &#39;comment&#39; // comment 프로퍼티만 상속받기
]) {}</code></pre>
<ul>
<li>comments.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
postComment(
    @Param(&#39;postId&#39;, ParseIntPipe) postId: number,
    @Body() body: CreateCommentsDto,
    @User() user: UsersModel
) {
    return this.commentsService.createComment(
        body, 
        postId, 
        user
    )
} </code></pre>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">async createComment(
    dto: CreateCommentsDto,
    postId: number,
    author: UsersModel // AccessToken에서 넘겨주면 UsersModel이 들어있음
) {
    return this.commentsRepository.save({
        ...dto,
        post: {
              id: postId
        },
        author,
    })
}</code></pre>
<ul>
<li>comments.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
              CommentsModel,
        ]),
        CommonModule,
        AuthModule,
        UsersModule,
    ],
    controllers: [CommentsController],
    providers: [CommentsService],
})
export class CommentsModule {}</code></pre>
<p>포스트맨으로 테스트를 해보겠습니다. 로그인 후 토큰 값을 넣은 뒤에 요청합니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/987c46b3-20f3-4ce8-99f5-fb0ae909da32/image.png" alt=""></p>
<pre><code>{
    &quot;comment&quot;: &quot;강의 너무 좋아요!!!&quot;,
    &quot;post&quot;: {
        &quot;id&quot;: 101
    },
    &quot;author&quot;: {
        &quot;id&quot;: 4,
        &quot;updatedAt&quot;: &quot;2024-02-18T02:33:34.030Z&quot;,
        &quot;createdAt&quot;: &quot;2024-02-18T02:33:34.030Z&quot;,
        &quot;nickname&quot;: &quot;codefactory123&quot;,
        &quot;email&quot;: &quot;codefactory123@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    },
    &quot;id&quot;: 1,
    &quot;updatedAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
    &quot;likeCount&quot;: 0
}</code></pre><p>101에 대한 댓글 페이지네이션을 요청하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/dccb810a-5b33-4762-9324-177d46b13f50/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 1,
            &quot;updatedAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
            &quot;comment&quot;: &quot;강의 너무 좋아요!!!&quot;,
            &quot;likeCount&quot;: 0
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: null
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: null
}</code></pre><p>누가 작성했는지까지 포함하겠습니다.</p>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">paginateComments(
    dto: PaginateCommentsDto,
    postId: number,
) {
    return this.commonService.paginate(
      dto,
      this.commentsRepository,
      {
          where: {
              post: {
                    id: postId,
              }
          },
          relations: { // 추가
                author: true
          }
      },
      `posts/${postId}/comments`,
    );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/3edb7918-7685-4748-a132-70c849334252/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 1,
            &quot;updatedAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
            &quot;comment&quot;: &quot;강의 너무 좋아요!!!&quot;,
            &quot;likeCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 4,
                &quot;updatedAt&quot;: &quot;2024-02-18T02:33:34.030Z&quot;,
                &quot;createdAt&quot;: &quot;2024-02-18T02:33:34.030Z&quot;,
                &quot;nickname&quot;: &quot;codefactory123&quot;,
                &quot;email&quot;: &quot;codefactory123@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: null
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: null
}</code></pre><p>이번에는 특정 Post의 comment정보를 가져오는 요청을 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/ec648956-6336-4676-b3c0-76649a5f8a4c/image.png" alt=""></p>
<pre><code>{
    &quot;id&quot;: 1,
    &quot;updatedAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
    &quot;comment&quot;: &quot;강의 너무 좋아요!!!&quot;,
    &quot;likeCount&quot;: 0
}</code></pre><p>해당 요청에도 override 기능을 추가해보겠습니다.</p>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">async getCommentById(id: number) {
    const comment = await this.commentsRepository.findOne({
        where: {
              id,
        },
        relations: { // 추가
              author: true
        }
    });
    if (!comment) throw new BadRequestException(`id: ${id} Comment는 존재하지 않습니다. `);
    return comment;
}</code></pre>
<p>그리고 override 부분에서 계속해서 <code>author: true</code>가 반복됩니다. 따라서 묶어주도록 하겠습니다. <code>FindManyOptions</code> 기능을 이용하겠습니다.</p>
<ul>
<li>comments/const/default-comments-find-options.const.ts</li>
</ul>
<pre><code class="language-typescript">import { FindManyOptions } from &quot;typeorm&quot;;
import { CommentsModel } from &quot;../entity/comments.entity&quot;;

export const DEFAULT_COMMENT_FIND_OPTIONS: FindManyOptions&lt;CommentsModel&gt; = {
    relations: {
          author: true
    },
}</code></pre>
<p>서비스 코드를 바꿔줍니다.</p>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">paginateComments(
    dto: PaginateCommentsDto,
    postId: number,
) {
    return this.commonService.paginate(
        dto,
        this.commentsRepository,
        {
            ...DEFAULT_COMMENT_FIND_OPTIONS,
            where: {
                post: {
                      id: postId,
                }
            },
        },
        `posts/${postId}/comments`,
    );
}

async getCommentById(id: number) {
    const comment = await this.commentsRepository.findOne({
        ...DEFAULT_COMMENT_FIND_OPTIONS,
        where: {
              id,
        }
    });
    if (!comment) throw new BadRequestException(`id: ${id} Comment는 존재하지 않습니다. `);
    return comment;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/67981c27-6ecb-4140-b26e-338208c1d365/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 1,
            &quot;updatedAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
            &quot;comment&quot;: &quot;강의 너무 좋아요!!!&quot;,
            &quot;likeCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 4,
                &quot;updatedAt&quot;: &quot;2024-02-18T02:33:34.030Z&quot;,
                &quot;createdAt&quot;: &quot;2024-02-18T02:33:34.030Z&quot;,
                &quot;nickname&quot;: &quot;codefactory123&quot;,
                &quot;email&quot;: &quot;codefactory123@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: null
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: null
}</code></pre><p>응답 데이터에서 보면 author정보는 사실상 id와 nickname만 필요합니다. 따라서 default option을 바꿔보도록 하겠습니다.</p>
<ul>
<li>default-comments-find-options.const.ts</li>
</ul>
<pre><code class="language-typescript">import { FindManyOptions } from &quot;typeorm&quot;;
import { CommentsModel } from &quot;../entity/comments.entity&quot;;

export const DEFAULT_COMMENT_FIND_OPTIONS: FindManyOptions&lt;CommentsModel&gt; = {
    relations: {
          author: true
    },
    select: {
        author: {
            id: true,
            nickname: true,
        }
    }
}</code></pre>
<p>같은 요청을 보내보겠습니다.</p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 1,
            &quot;updatedAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
            &quot;comment&quot;: &quot;강의 너무 좋아요!!!&quot;,
            &quot;likeCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 4,
                &quot;nickname&quot;: &quot;codefactory123&quot;
            }
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: null
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: null
}</code></pre><hr>
<h3 id="🖊️patch-comment-api">🖊️PATCH: Comment API</h3>
<ul>
<li>dto/update-comments-dto.ts</li>
</ul>
<pre><code class="language-typescript">import { PartialType } from &quot;@nestjs/mapped-types&quot;;
import { CreateCommentsDto } from &quot;./create-comments.dto&quot;;

// CreateCommentsDto의 부분 상속
export class UpdateCommentsDto extends PartialType(CreateCommentsDto) {}</code></pre>
<ul>
<li>comments.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Patch(&#39;:commentId&#39;)
@UseGuards(AccessTokenGuard)
async patchComment(
    @Param(&#39;commentId&#39;, ParseIntPipe) commentId: number,
    @Body() body: UpdateCommentsDto,
) {
    return this.commentsService.updateComment(
        body,
        commentId
    )
}</code></pre>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">async updateComment(
    dto: UpdateCommentsDto,
    commentId: number,
) {
      const comment = await this.commentsRepository.findOne({
        where: {
              id,
        }
    });
    if (!comment) throw new BadRequestException(`존재하지 않는 댓글입니다. `);

    // preload 기능
    const prevComment = await this.commentsRepository.preload({
        id: commentId, // id 기반의 commentId 들어오게 됨
        ...dto, // 나머지는 dto내용으로 변경
    });

    const newComment = await this.commentsRepository.save(
          prevComment,
    );

    return newComment;
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/1c1c6af1-d566-4747-a37d-013b879e1148/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/677a30c4-bb39-4fd6-82e5-84fbd9aa3b1b/image.png" alt=""></p>
<pre><code>{
    &quot;id&quot;: 1,
    &quot;updatedAt&quot;: &quot;2024-02-21T18:17:05.264Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
    &quot;comment&quot;: &quot;NestJS 너무&quot;,
    &quot;likeCount&quot;: 0
}</code></pre><p>GET 요청으로 바꿔졌는지 확인을 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a4282c66-6fd9-45d2-8bf9-8bc04d46cf24/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 1,
            &quot;updatedAt&quot;: &quot;2024-02-21T18:17:05.264Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-21T17:43:27.716Z&quot;,
            &quot;comment&quot;: &quot;NestJS 너무&quot;,
            &quot;likeCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 4,
                &quot;nickname&quot;: &quot;codefactory123&quot;
            }
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: null
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: null
}</code></pre><hr>
<h3 id="🖊️delete-comment-api">🖊️DELETE: Comment API</h3>
<ul>
<li>comments.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Delete(&#39;:commentId&#39;)
@UseGuards(AccessTokenGuard)
async deleteComment(
      @Param(&#39;commentId&#39;, ParseIntPipe) commentId: number,
) {
      return this.commentsService.deleteComment(commentId);
}</code></pre>
<ul>
<li>comments.service.ts</li>
</ul>
<pre><code class="language-typescript">async deleteComment(
      id: number
) {
    const comment = await this.commentsRepository.findOne({
        where: {
              id,
        }
    });
    if (!comment) throw new BadRequestException(`존재하지 않는 댓글입니다. `);

    await this.commentsRepository.delete(id);
    return id;
}</code></pre>
<p>포스트맨으로 테스트를 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/4d8e8613-f7c4-4e7f-a6a7-0b050bd1a94f/image.png" alt=""></p>
<pre><code>{
    &quot;raw&quot;: [],
    &quot;affected&quot;: 1
}</code></pre><p>GET 요청으로 확인하면 4번이 사라진 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/0571a45d-b752-463a-95fe-2f9de51f8488/image.png" alt=""></p>
<hr>
<h3 id="🖊️path-parameter-검증-middleware">🖊️Path Parameter 검증 Middleware</h3>
<p>현재 endpoint 경로는 post가 존재하면 에러를 던지는 코드는 존재하지 않습니다. 이 부분은 Middleware로 적용을 해보겠습니다. Middleware는 가장 앞단에서 먼저 필터링을 시작합니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d30c554f-4d7c-4c74-9828-abd0a74a4a9b/image.png" alt=""></p>
<p>컨트롤러에서 전반적으로 post가 존재하지 않으면 전부 BadRequestException을 던지도록 하겠습니다. 먼저 middleware 코드를 작성하겠습니다.</p>
<ul>
<li>comments/middleware/post-exists.middleware.ts</li>
</ul>
<pre><code class="language-typescript">import { BadRequestException, Injectable, NestMiddleware } from &quot;@nestjs/common&quot;;
import { NextFunction, Request, Response } from &quot;express&quot;;
import { PostsService } from &quot;src/posts/posts.service&quot;;

@Injectable()
export class PostExistsMiddleware implements NestMiddleware {

    constructor(
        private readonly postService: PostsService, 
    ) {}

    async use(req: Request, res: Response, next: NextFunction) {
        const postId = req.params.postId; // path parameter 안의 postId를 가져올 수 있습니다.
        if (!postId) throw new BadRequestException(`Post ID 파라미터는 필수입니다. `);
        const exists = await this.postService.checkPostExistsById(
              parseInt(postId),
        );
        if (!exists) throw new BadRequestException(`Post가 존재하지 않습니다. `);
        next(); // next를 해줘야 다음단계로 이동
    }
}</code></pre>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async checkPostExistsById(id: number) {
    return this.postsRepository.exists({
        where: {
              id,
        },
    })
}</code></pre>
<p>이제 <code>PostExists Middleware</code>를 등록하겠습니다. Middleware를 등록하려면 등록할 module로 가서 implements를 합니다.</p>
<ul>
<li>comments.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
              CommentsModel,
        ]),
        CommonModule,
        AuthModule,
        UsersModule,
        PostsModule, // 등록
    ],
    controllers: [CommentsController],
    providers: [CommentsService],
})
export class CommentsModule implements NestModule {

    configure(consumer: MiddlewareConsumer) {
        consumer
            .apply(PostExistsMiddleware) // 적용할 Middleware
            .forRoutes(CommentsController); // 적용할 Controller 전체
    }
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다. 존재하지 않는 Post를 조회하겠습니다. 만약 comment와 관련된 endpoint로 199번을 조회하면 전부 동일한 에러 메세지를 보내줄 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/328faf15-be32-47aa-85ca-5263b5cf5a55/image.png" alt=""></p>
<pre><code>{
    &quot;message&quot;: &quot;Post가 존재하지 않습니다. &quot;,
    &quot;error&quot;: &quot;Bad Request&quot;,
    &quot;statusCode&quot;: 400
}</code></pre><p>이런식으로 Middleware를 적용하는 것이 좋은 사례인 것을 알 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Web Socket(deep)]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Web-Socketdeep</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Web-Socketdeep</guid>
            <pubDate>Sun, 18 Feb 2024 09:00:57 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️validation-pipe">🖊️Validation pipe</h3>
<p>이번에는 Pipe를 사용해보도록 하겠습니다.</p>
<p>REST API에서의 pipe와 Gateway에서의 API는 서로 다르지 않습니다. 공식문에서도 별로 다르지 않다고 합니다.</p>
<p>포스트맨으로 1번 사용자를 연결 후 JSON으로 아무런 값을 주지않고 <code>create_chat</code>을 해보도록 하겠습니다.</p>
<p>먼저 리스닝으로 <code>exception</code>과  <code>receive_message</code>를 활성화 해줍니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d7b07e78-cff8-4dac-b826-f9007f69cc6a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/3af1d386-3dcb-42ff-ba3c-9f03b839cdac/image.png" alt=""></p>
<p>이렇게 나오면 안됩니다. 왜냐하면 어떠한 값이 뭐가 잘못 되었다고 나와야합니다. 그런데 InternalServerError가 나오게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/8af283b1-3a33-48ba-b218-b2687e2bb2cb/image.png" alt=""></p>
<p>우리는 이미 <code>main.ts</code>에서  Validation pipe를 전역적으로 작용하도록 만들었습니다. 하지만 작동을 하지 않습니다. </p>
<ul>
<li>main.ts</li>
</ul>
<pre><code class="language-typescript">app.useGlobalPipes(new ValidationPipe({
    transform: true, // 변화는 해도 된다.
    transformOptions: {
          enableImplicitConversion: true // 임의로 변환하는 것을 허가한다.
    },
    whitelist: true,
    forbidNonWhitelisted: true,
}));</code></pre>
<p>아쉽게도 글로벌 파이프를 전역적으로 작용하는 것은 오직 <code>REST API 컨트롤러</code>에만 적용이 됩니다. 그래서 우리가 Gateway를 사용할 때는 따로 Validation을 <code>gateway</code>에 추가해줘야 합니다.</p>
<p>따라서 <code>@SubscribeMessage</code>별로 <code>@UsePipes</code>를 적용해야합니다.</p>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@UsePipes(new ValidationPipe({
    transform: true, // 변화는 해도 된다.
    transformOptions: {
          enableImplicitConversion: true // 임의로 변환하는 것을 허가한다.
    },
    whitelist: true,
    forbidNonWhitelisted: true,
}))
@SubscribeMessage(&#39;create_chat&#39;)
async createChat(
    @MessageBody() data: CreateChatDto,
    @ConnectedSocket() socket: Socket,
) {
    const chat = await this.chatsService.createChat(
          data, 
    );
}</code></pre>
<p>포스트맨으로 다시 보내도 에러가 발생합니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d92cc866-aa58-4930-9088-400a6666da02/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/ac616f6b-5639-4af3-8507-383a0563bc06/image.png" alt=""></p>
<p>현재 에러의 위치를 보면 validation.pipe로 나옵니다. 즉, dto에서는 통과가 되었다는 것입니다. 하지만 Bad Request Exception이 메세지로 전달이 되지않고 터져버렸습니다.</p>
<p>왜냐하면 우리는 에러를 던질 때 WsException으로 던져야합니다. 하지만 class-validator는 기본적으로 REST API를 위해서 설계가 된것이기 때문에 모든 Expection들이 HTTP Excetpion을 extends하고 있습니다.</p>
<p>하지만 WsException은 HTTP Exception을 extends하고 있지 않습니다. 그래서 다음과 같은 에러가 발생한 것입니다.</p>
<p>그러면 우리는 HTTP Exception들이 발생했을 때, WsException으로 변환해주는 코드를 작성하면 되는 것입니다.</p>
<hr>
<h3 id="🖊️exception-filter-적용">🖊️Exception Filter 적용</h3>
<p>이어서 HTTP Exception들이 발생했을 때, WsException으로 변환해주는 코드를 작성하겠습니다.</p>
<p>Exception Filter에서 HTTP Exception을 모두 잡아서 WsException으로 바꿔주면 에러들을 전부 잡을 수 있습니다.</p>
<p>common 폴더에서 작업을 진행하겠습니다.</p>
<ul>
<li>common/exception-filter/socket-catch-http.exception-filter.ts</li>
</ul>
<pre><code class="language-typescript">@Catch(HttpException)
export class SocketCatchHttpExceptionFilter extends BaseWsExceptionFilter&lt;HttpException&gt; {
    // BaseWsExceptionFilter를 상속하면 Ws관련 Exception을 만들 수 있다.

    catch(exception: HttpException, host: ArgumentsHost): void {
        super.catch(exception, host);

        const socket = host.switchToWs().getClient(); // 소켓 가져오기
        socket.emit( // 현재 소켓에다가만 emit하기
            &#39;exception&#39;, // 이벤트 이름
            {    
                  // 실제 응답에서 받는 {메세지 형태들}
                  data: exception.getResponse(),
            }
        )
    }
}</code></pre>
<p><code>Exception Filter</code>를 적용하겠습니다.</p>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@UsePipes(new ValidationPipe({
    transform: true,
    transformOptions: {
          enableImplicitConversion: true
    },
    whitelist: true,
    forbidNonWhitelisted: true,
}))
@UseFilters(SocketCatchHttpExceptionFilter) // 적용
@SubscribeMessage(&#39;create_chat&#39;)
async createChat(
    @MessageBody() data: CreateChatDto,
    @ConnectedSocket() socket: Socket,
) {
    const chat = await this.chatsService.createChat(
          data, 
    );
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p>User 1과 User 2모두 리스너를 <code>exception</code>, <code>receive_message</code>를 열겠습니다. 그리고 <code>create_chat</code>을 실행하면 에러가 2개가 나옵니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/7d3a998d-1471-4490-b924-2b35c272ff08/image.png" alt=""></p>
<p>이유는 <code>super.catch()</code> 때문입니다. 따라서 해당 코드를 삭제합니다. 이후에 다시 똑같은 방법으로 테스트를 하게되면</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/86f3755e-c67c-4a0f-9c11-368d193fea69/image.png" alt=""></p>
<p>다음 에러 메세지는 User 1번에게만 가게 됩니다. User 2번은 어떠한 에러 메세지를 받지 못합니다. 왜냐하면 연결된 사용자한테만 가는 <code>emit</code> 때문입니다.</p>
<pre><code>즉, Gateway에서는 Pipe를 적용하기 위해서는 각각의 메소드 위에다가 적용을 해줘야합니다.</code></pre><hr>
<h3 id="🖊️guard-적용">🖊️Guard 적용</h3>
<p>이번에는 Guard를 적용해보겠습니다. REST API에서 적용한 방법과 동일합니다.</p>
<ul>
<li>auth/guard/socket/socket-bearer-token.guard.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class SocketBearerTokenGuard implements CanActivate {

    constructor(
        private readonly authService: AuthService,
         private readonly userService: UsersService,
    ) {}

    async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
        // 지금 연결해서 사용하고 있는 소켓
        const socket = context.switchToWs().getClient();

        // 헤더 가져오기
        const headers = socket.handshake.headers;

        // Bearer xxx
        const rawToken = headers[&#39;authorization&#39;];
        if (!rawToken) throw new WsException(&#39;토큰이 없습니다. &#39;);

        const token = this.authService.extractTokenFromHeader(
            rawToken,
            true
        );

        const payload = this.authService.verifyToken(token);
        const user = await this.userService.getUserByEmail(payload.email);

        socket.user = user;
        socket.token = token;
        socket.tokeType = payload.tokenType;
    }
}</code></pre>
<p>이제 try catch로 묶겠습니다. 왜냐하면 기본적으로 HTTP Exception을 주기 때문에 WsException으로 변경을 해줘야 합니다.</p>
<pre><code class="language-typescript">@Injectable()
export class SocketBearerTokenGuard implements CanActivate {

    constructor(
        private readonly authService: AuthService,
         private readonly userService: UsersService,
    ) {}

    async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
        const socket = context.switchToWs().getClient();
        const headers = socket.handshake.headers;
        const rawToken = headers[&#39;authorization&#39;];
        if (!rawToken) throw new WsException(&#39;토큰이 없습니다. &#39;);

        try {
            const token = this.authService.extractTokenFromHeader(
                rawToken,
                true
            );

            const payload = this.authService.verifyToken(token);
            const user = await this.userService.getUserByEmail(payload.email);

            socket.user = user;
            socket.token = token;
            socket.tokeType = payload.tokenType;
            return true;
        } catch (error) {
              throw new WsException(&#39;토큰이 유효하지 않습니다.&#39;)
        }
    }
}</code></pre>
<p>chats.module.ts에서 provider로 등록을 하겠습니다.</p>
<ul>
<li>chats.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
              ChatsModel,
              MessagesModel,
        ]),
        CommonModule,
        AuthModule,
          UsersModule,
    ],
    controllers: [
        ChatsController,
        MessagesController,
    ],
    providers: [
        ChatsGateway,
        ChatsService,
        ChatsMessagesService,
    ],
})
export class ChatsModule {}</code></pre>
<p>이제 chats.gateway.ts에 적용을 하겠습니다.</p>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@UsePipes(new ValidationPipe({
    transform: true,
    transformOptions: {
          enableImplicitConversion: true
    },
    whitelist: true,
    forbidNonWhitelisted: true,
}))
@UseFilters(SocketCatchHttpExceptionFilter)
@UseGuards(SocketBearerTokenGuard) // 추가
@SubscribeMessage(&#39;create_chat&#39;)
async createChat(
    @MessageBody() data: CreateChatDto,
      // 인터섹션: user가 UsersModel이라고 존재한다.
      // 토큰이 통과되면 user가 있다는 것을 알 수 있다.
    @ConnectedSocket() socket: Socket &amp; {user: UsersModel}, 
) {
    const chat = await this.chatsService.createChat(
          data, 
    );
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다. 로그인을 하지 않고 create_chat을 누르면 예상한대로 나옵니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/e6c02837-46ca-408d-bacb-22c773400d00/image.png" alt=""></p>
<p>로그인을 하고 응답받은 accessToken을 Headers에 넣어주겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/2c45fe8f-970f-48f9-8a85-e5d7d3bd7aab/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/8c662063-810d-4a83-a2d6-d2c34e045b29/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/6611e070-87d5-4088-bc53-559bd479b284/image.png" alt=""></p>
<p>따라서 REST API에서 적용한 것처럼 똑같이 Gateway에서도 Guard를 적용할 수 있는 것을 알 수 있습니다.</p>
<hr>
<h3 id="🖊️decorator-기반-로직-변경">🖊️decorator 기반 로직 변경</h3>
<p>나머지 기능을 완성하겠습니다. <code>enter_chat</code>에도 Guard, Filter, Pipe를 적용하겠습니다. 로그인을 해야만 접근이 가능하도록 만들겠습니다.</p>
<p>또한 <code>send_message</code>에도 동일하게 적용을 하겠습니다.</p>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@UsePipes(new ValidationPipe({
    transform: true,
    transformOptions: {
          enableImplicitConversion: true
    },
    whitelist: true,
    forbidNonWhitelisted: true,
}))
@UseFilters(SocketCatchHttpExceptionFilter)
@UseGuards(SocketBearerTokenGuard)
@SubscribeMessage(&#39;send_message&#39;)
async sendMessage(
.
.
@UsePipes(new ValidationPipe({
    transform: true,
    transformOptions: {
    enableImplicitConversion: true
    },
    whitelist: true,
    forbidNonWhitelisted: true,
}))
@UseFilters(SocketCatchHttpExceptionFilter)
@UseGuards(SocketBearerTokenGuard)
@SubscribeMessage(&#39;enter_chat&#39;)
async enterChat(</code></pre>
<p>추가적으로 CreateMessageDto에서 authorId를 number로 받고 있었습니다. 이 또한 제거를 하고 직접 받도록 하겠습니다.</p>
<ul>
<li>chats/messages/dto/create-message.dto.ts</li>
</ul>
<pre><code class="language-typescript">import { PickType } from &quot;@nestjs/mapped-types&quot;;
import { MessagesModel } from &quot;../entities/messages.entity&quot;;
import { IsNumber } from &quot;class-validator&quot;;

export class CreateMessagesDto extends PickType(MessagesModel, [
      &#39;message&#39;,
]) {
    @IsNumber()
    chatId: number;

      // 제거
}</code></pre>
<ul>
<li>messages.service.ts</li>
</ul>
<pre><code class="language-typescript">async createMessage(
    dto: CreateMessagesDto,
    authorId: number
) {
    const message = await this.messagesRepository.save({
        chat: {
              id: dto.chatId,
        },
        author: {
              id: authorId, // 변경
        },
        message: dto.message,
    });
    return this.messagesRepository.findOne({
        where: {
              id: message.id,
        },
        relations: {
              chat: true,
        }
    });
}</code></pre>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@UsePipes(new ValidationPipe({
    transform: true, // 변화는 해도 된다.
    transformOptions: {
          enableImplicitConversion: true // 임의로 변환하는 것을 허가한다.
    },
    whitelist: true,
    forbidNonWhitelisted: true,
}))
@UseFilters(SocketCatchHttpExceptionFilter)
@UseGuards(SocketBearerTokenGuard)
@SubscribeMessage(&#39;send_message&#39;)
async sendMessage(
    @MessageBody() dto: CreateMessagesDto,
    @ConnectedSocket() socket: Socket &amp; {user: UsersModel}, // 변경
) {
    const chatExists = await this.chatsService.checkIfChatExists(
          dto.chatId,
    );

    if (!chatExists) {
        throw new WsException(
              `존재하지 않는 채팅방입니다. Chat ID : ${dto.chatId}`,
        );
    }

    const message = await this.messagesService.createMessage(
        dto,
        socket.user.id
    );
    socket.to(message.chat.id.toString()).emit(&#39;receive_message&#39;, message.message);
}</code></pre>
<p>다음과 같이 바꾸는 이유는 엑세스 토큰으로 부터 사용자 정보를 받아오기 위해서 입니다.</p>
<hr>
<h3 id="🖊️accesstoken을-매번-검증할때의-문제">🖊️AccessToken을 매번 검증할때의 문제</h3>
<p>지금부터는 accessToken을 적용하는 문제에 대해서 알아보겠습니다.</p>
<p>로그인을 하게 되면 accessToken과 refreshToken을 제공합니다. accessToken의 경우 5분입니다. 그 이후에는 만료가 되어서 다시 refreshToken으로 재발급을 받아야합니다.</p>
<p>포스트맨의 경우 connection을 하게되면 바꿀 수 없게 되어버립니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/bbeda8be-ff91-49b8-b413-fcbf5d34e336/image.png" alt=""></p>
<p>그럼 5분 안으로는 소통이 가능하지만, 그 이후 만료가 되면 <code>토큰이 유효하지 않습니다.</code> 에러를 던지게 됩니다. 그래서 매번 무엇인가를 검증을 한다면 <code>Guard</code>를 사용하는 것이 맞습니다. </p>
<p>하지만 <code>Bearer Token</code> 검증을 통해 사용자와의 socket과 연결에는 매번 <code>Guard</code>를 이용한 검증을 할 필요가 없습니다.</p>
<p>이제부터 사용자를 어떻게 각각의 소켓과 연결하는지를 알아보겠습니다.</p>
<hr>
<h3 id="🖊️socket에서-사용자-정보-저장">🖊️Socket에서 사용자 정보 저장</h3>
<p>먼저 <code>chats.gateway.ts</code>에 있는 <code>Guard</code>를 모두 지워줍니다.</p>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@WebSocketGateway({
    // ws:localhost:3000/chats
    namespace: &#39;/chats&#39;,
})
export class ChatsGateway implements OnGatewayConnection{

    constructor(
        private readonly chatsService: ChatsService,
         private readonly messagesService: ChatsMessagesService
    ){}

    @WebSocketServer()
    server: Server;

    handleConnection(socket: Socket) {
          console.log(`on connect called : ${socket.id}`);
    }

    @UsePipes(new ValidationPipe({
        transform: true,
        transformOptions: {
              enableImplicitConversion: true
        },
        whitelist: true,
        forbidNonWhitelisted: true,
    }))
    @UseFilters(SocketCatchHttpExceptionFilter)
    @SubscribeMessage(&#39;enter_chat&#39;)
    async enterChat(
        @MessageBody() data: EnterChatDto,
         @ConnectedSocket() socket: Socket &amp; {user: UsersModel},
    ) {  
        for (const chatId of data.chatIds) {
            const exists = await this.chatsService.checkIfChatExists(
                  chatId,
            );

            if (!exists) {
                throw new WsException({
                    code: 100,
                    message: `존재하지 않는 chat 입니다. chatId: ${chatId}`,
                });
            }
        }
        socket.join(data.chatIds.map((x) =&gt; x.toString()));
    }

    @UsePipes(new ValidationPipe({
        transform: true,
        transformOptions: {
              enableImplicitConversion: true
        },
        whitelist: true,
        forbidNonWhitelisted: true,
    }))
    @UseFilters(SocketCatchHttpExceptionFilter)
    @SubscribeMessage(&#39;create_chat&#39;)
    async createChat(
        @MessageBody() data: CreateChatDto,
         @ConnectedSocket() socket: Socket &amp; {user: UsersModel},
    ) {
        const chat = await this.chatsService.createChat(
              data, 
        );
    }

    @UsePipes(new ValidationPipe({
        transform: true,
        transformOptions: {
              enableImplicitConversion: true
        },
        whitelist: true,
        forbidNonWhitelisted: true,
    }))
    @UseFilters(SocketCatchHttpExceptionFilter)
    @SubscribeMessage(&#39;send_message&#39;)
    async sendMessage(
          @MessageBody() dto: CreateMessagesDto,
           @ConnectedSocket() socket: Socket &amp; {user: UsersModel},
    ) {
        const chatExists = await this.chatsService.checkIfChatExists(
              dto.chatId,
        );

        if (!chatExists) {
            throw new WsException(
                  `존재하지 않는 채팅방입니다. Chat ID : ${dto.chatId}`,
            );
        }
        const message = await this.messagesService.createMessage(
              dto,
              socket.user.id
        );
        socket.to(message.chat.id.toString()).emit(&#39;receive_message&#39;, message.message);
    }
}</code></pre>
<pre><code>Guard를 지우고 어떻게 사용자 정보를 가지고 올 수 있을까? </code></pre><p>Socket이라는 것은 연결이 되면 서로 Pipe같은 것이 생깁니다. 그래서 한번 연결되어 있으면 그 연결은 지속이 됩니다.</p>
<pre><code class="language-typescript">async handleConnection(socket: Socket &amp; {user: UsersModel}) {
    console.log(`on connect called : ${socket.id}`); // 어떤 id 소켓이 연결됨?

      // 임시 테스트
    const user = await this.usersService.getUserByEmail(&#39;codefactory@codefactory.ai&#39;);
    socket.user = user; // 소켓에 사용자 정보 넣기
}</code></pre>
<p>따라서 1번 연결되고 나면 socket에는 메세지를 보낼 때 계속해서 유지가 됩니다. 이것은 디버깅을 통해서 socket에 들어있는 값을 확인해보면 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d82b5fa3-3a9c-45ac-bd2b-5bc19ac07b34/image.png" alt=""></p>
<p>따라서 REST API처럼 모든 곳에 Guard를 붙일 필요 없이, <code>handleConnection</code>에서 핸들링만 해주고 <code>socket.user = user;</code>로 넣어주면, 나머지 메세지를 보낼 때 소켓에 들어있는 값을 신뢰할 수 있게 됩니다.</p>
<p>또한 여기서는 중간에 에러가 발생하면 에러를 throw 하는 것보다는 연결을 끊어주겠습니다.</p>
<pre><code class="language-typescript">async handleConnection(socket: Socket &amp; {user: UsersModel}) {
    console.log(`on connect called : ${socket.id}`); // 어떤 id 소켓이 연결됨?
    const headers = socket.handshake.headers; // 헤더 가져오기
    const rawToken = headers[&#39;authorization&#39;]; // Bearer xxx
    if (!rawToken) socket.disconnect(); // 토큰이 없으면 연결 끊기

    try {
        const token = this.authService.extractTokenFromHeader(
            rawToken,
            true
        );

        const payload = this.authService.verifyToken(token);
        const user = await this.usersService.getUserByEmail(payload.email);
        socket.user = user;
        return true;
    } catch (error) {
          socket.disconnect(); // 에러가 나면 연결 끊기
    }
}</code></pre>
<p>포스트맨으로 로그인을 하고 토큰없이 connect를 하면 바로 연결이 끊기는 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/e4b697bc-c59f-49aa-8a6a-8c5ddb83ef00/image.png" alt=""></p>
<hr>
<h3 id="🖊️gateway-lifecycle-hooks">🖊️Gateway Lifecycle Hooks</h3>
<p>연결되었을 때 handleconnection을 통해서 특정 기능이 발생하도록 만들었습니다. 이것을 <code>Lifecycle Hooks</code>이라고 합니다. <code>Lifecycle Hooks</code>은 2개가 더 있습니다.</p>
<p><code>OnGatewayInit</code> 입니다. <code>OnGatewayInit</code>는 <code>afterInit</code> 함수를 사용할 수 있게 만들어 줍니다. 이 함수는 실제 서버를 inject 받을 때 사용되는 함수이기도 하고, 그리고 Gateway가 초기화되었을 때 실행할 수 있는 함수 입니다.</p>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">export class ChatsGateway implements OnGatewayConnection, OnGatewayInit{ // 추가

    constructor(
        private readonly chatsService: ChatsService,
         private readonly messagesService: ChatsMessagesService,
         private readonly usersService: UsersService,
         private readonly authService: AuthService,
    ){}

    @WebSocketServer()
    server: Server;

    afterInit(server: any) { // server: Server; 이것과 동일한 값을 받는다.
          console.log(`after gateway init`); // 로그 확인
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9212aec0-2ede-4f86-8a8b-f193bb446e11/image.png" alt=""></p>
<p><code>after gateway init</code> 이후로 소켓 메세지들이 구독된것을 알 수 있습니다. 그래서 Gateway가 시작했을 때 특정 함수 또는 로직을 실행하고 싶으면 <code>afterInit</code>이라는 <code>Lifecycle Hooks</code>을 사용하면 됩니다.</p>
<p>다음은 연결이 끊긴 이후를 관리하는 <code>OnGatewayDisconnect</code>입니다.</p>
<pre><code class="language-typescript">export class ChatsGateway implements OnGatewayConnection, OnGatewayInit, OnGatewayDisconnect{
.
.
handleDisconnect(socket: Socket) {
      console.log(`on disconnect called : ${socket.id}`);
}</code></pre>
<p>포스트맨으로 연결 후 disconnect를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/daedcaaf-e759-4ca8-a863-3e0fdb58bac8/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Web Socket(basic)]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Web-Socket</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Web-Socket</guid>
            <pubDate>Sat, 17 Feb 2024 10:08:00 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️web-socket-이론">🖊️Web Socket 이론</h3>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a413fceb-fc80-43e4-ae74-e835010903b9/image.png" alt=""></p>
<p>이 HTTP통신의 <code>단점</code>은 <code>단방향</code>이라는 것입니다. <code>client쪽에서 반드시 요청</code>을 보내야 서버에서 응답을 보내주게 됩니다.</p>
<pre><code>카카오톡의 경우 누군가가 메세지롤 보면 나는 카카오톡에게 요청을 보내지 않았지만 응답이 자동으로 오게됩니다.</code></pre><p>따라서 웹소켓을 사용하게 되면 <code>요청와 응답의 경계</code>가 사라집니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a6ac82aa-66bc-4421-b5ef-b1f6cb8a9d09/image.png" alt=""></p>
<p>웹소켓을 사용하면 <code>파이프</code>가 생기게 됩니다. 이를 통해서 서버에서 어떤일이 발생하면 사용자에게 알려 줄 수 있게 됩니다.</p>
<hr>
<h3 id="🖊️socket-io-이론">🖊️Socket IO 이론</h3>
<p><code>Socket IO</code>는 Websocket 프로토콜을 사용해서 만든 <code>low-latency</code>(낮은 지연 시간), <code>bidrectional</code>(양방향 소통), <code>event based</code>(이벤트 기반)으로 클라이언트와 서버가 통신 할 수 있게 해주는 기능입니다.</p>
<pre><code>즉, web socket위에 소켓 IO를 올린 것 입니다. 예를 들면 nest.js가 express.js를 사용하는 것과 비슷합니다.</code></pre><p>먼저 기본적인 통신 방법에 대해서 알아보겠습니다.</p>
<h4 id="기본적인-통신">기본적인 통신</h4>
<ul>
<li>서버 코드</li>
</ul>
<pre><code class="language-typescript">import { Server } from &quot;socket.io&quot;;

const io = new Server(3000); // Socket.io 서버 생성

// 클라이언트가 서버에 연결되면 실행되는 함수 정의
// on 함수를 실행하면 특정 이벤트 (첫 번째 파라미터)가 
// 있을 때 콜백 함수를 실행 할 수 있으며
// 해당 콜백 함수는 메세지를 첫번째 파라미터로 받는다.
// connection 이벤트는 미리 정의된 이벤트로 &quot;연결 됐을때&quot; 실행된다.
io.on(&quot;connection&quot;, (socket) =&gt; {
      // 메세지 보내기
      // 첫번째 파라미터는 이벤트 이름
      // 두번째~이후 무한 파라미터 메시지
    socket.emit(&quot;hello_from_server&quot;, &quot;this is message for server&quot;);

      // hello_from_client 이벤트 메세지 받기(on 리스닝)
      socket.on(&quot;hello_from_client&quot;, (message) =&gt; {
        console.log(message); // 클라이언트로 부터 온 메세지
    });
});</code></pre>
<ul>
<li>클라이언트 코드</li>
</ul>
<p><code>ws</code> : websockt 약자</p>
<pre><code class="language-typescript">import { io } from &quot;socket.io-client&quot;;

const socket = io(&quot;ws://localhost:3000&quot;); // Socket.io 서버에 연결

// &quot;hello_from_client&quot; 이벤트를 듣고 있는 소켓에 메세지 보내기
socket.emit(&quot;hello_from_client&quot;, &quot;this is message for client&quot;);

// &quot;hello_from_server&quot; 이벤트로 메세지가 오면 함수 실행(on 리스닝)
socket.on(&quot;hello_from_server&quot;, (message) =&gt; {
    console.log(message); // 서버로 부터 온 메세지
});</code></pre>
<p>웹소켓은 기본적으로 <code>양방향 통신</code>입니다. 하지만 연결을 할 때 만큼은 <code>클라이언트에서 서버에게 연결</code>을 해야합니다. 그 이후에 누가 먼저랄거 없이 메세지를 보내면 됩니다.</p>
<p>위의 코드에서 <code>on</code>은 특정 이벤트를 <code>리스닝(받는)하는</code> 함수입니다. 반대로 <code>emit</code>은 <code>보내는</code> 함수입니다.</p>
<p>따라서 양방향에서 서로 <code>on</code>으로 보내고 <code>emit</code>을 통해서 받으면서 <code>양방향 통신</code>을 하게 됩니다.</p>
<h4 id="acknowledgment">Acknowledgment</h4>
<p><code>메세지를 잘 받았다는 OK신호를 보내는 것</code>입니다. 보냈을 경우 상대방이 잘 받았는지 확인을 위해 존재합니다.</p>
<ul>
<li>서버 코드</li>
</ul>
<pre><code class="language-typescript">// &quot;hello&quot; 룸에 &quot;world&quot;라는 메세지를 보낸다.
// 세번째 파라미터는 콜백 함수로 acknowledgment가 오면 실행
socket.emit(&quot;hello&quot;, &quot;world&quot;, (response) =&gt; {
    console.log(response); // 수신양호
});</code></pre>
<ul>
<li>클라이언트 코드</li>
</ul>
<pre><code class="language-typescript">// 첫번쨰 파라미터에 이벤트 이름을 입력하고
// 두번째 파라미터에 메세지가 왔을때 실행할 함수를 입력한다.
// 함수는 첫번쨰 파라미터로 메세지, 두번째 파라미터로
// 수신 응답을 할 수 있는 콜백 함수가 주어진다.
socket.on(&quot;hello&quot;, (message, callback) =&gt; {
    console.log(message); // &quot;world&quot;
      callback(&quot;수신 양호&quot;);
});</code></pre>
<h4 id="namespace와-room">Namespace와 Room</h4>
<p>이 부분은 소켓을 정리하는 부분입니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a507bc60-954b-4871-9dcc-3b9a251bfd7a/image.png" alt=""></p>
<p>우리가 처음 시작하면 기본으로 설정하지 않아도 <code>emit</code>을 사용하면 <code>/</code>로 이동을 하게 됩니다.</p>
<pre><code>그리고 각각의 namespace 내부에는 room으로 나눠지게 됩니다. 예를 들면 카카오의 각각의 톡방같은 개념이 Namespace입니다.</code></pre><p>namespace가 <code>/</code>인 <code>room1</code>과 namespace가 <code>/chat</code>인 <code>room1</code>은 완전히 서로 다른 것입니다. 왜냐하면 namespace가 다르기 때문입니다. 따라서 완전히 다른 <code>room</code>입니다.</p>
<ul>
<li>서버 코드</li>
</ul>
<pre><code class="language-typescript">// of를 이용하면 namespace를 정할 수 있다.
// namespace는 일반적으로 라우트 형태를 따라 지정한다.
const chatNamespace = io.of(&quot;/chat&quot;);

// chatNamespace에 연결된 소켓만 아래 코드가 실행된다.
chatNamespace.on(&quot;connection&quot;, (socket) =&gt; {
    // 현재 연결된 socket을 room1에 연결한다.
      // 이 room1은 /chat namespace에만 존재하는 room1이다.
      socket.join(&quot;room1&quot;);
      chatNamespace.to(&quot;room1&quot;).emit(&quot;hello&quot;, &quot;world&quot;);
});

// /noti namespace를 생성
const notiNamespace = io.of(&quot;/noti&quot;);

// /noti namespace에 연결된 소켓만 실행된다.
notiNamesapce.on(&quot;connection&quot;, (socket) =&gt; {
    // 이 room1은 /chat namespace의 room1과 전혀 관련이 없다.
      // 다른 namespace의 room1에는 들어갈 수 없다.
      socket.join(&quot;room1&quot;);

      // 역시나 /noti namespace의 room1에만 메세지를 보낸다.
      notiNamespace.to(&quot;room1&quot;).emit(&quot;hello&quot;, &quot;codefactory&quot;);
});</code></pre>
<ul>
<li>클라이언트 코드</li>
</ul>
<pre><code class="language-typescript">// 기본 namespace로 연결한다 -&gt; /
const socket = io(&quot;https://localhost:3000&quot;);

// chat namespace로 연결한다. -&gt; /chat
const chatSocket = io(&quot;https://localhost:3000/chat&quot;);

// noti namespace로 연결한다. -&gt; /noti
const chatSocket = io(&quot;https://localhost:3000/noti&quot;);

// client에서는 room을 정할 수 있는 기능이 없다.
// room은 서버에서만 socket.join()을 실행해서 특정 룸에
// 들어가도록 할 수 있다.</code></pre>
<h4 id="emit--broadcast">emit &amp; broadcast</h4>
<p><code>broadcast</code>는 <code>나를 제외한 모두에게 메세지를 보내는 것</code>입니다.</p>
<pre><code class="language-typescript">// 연결된 모든 socket들에 메세지를 보낸다.
socket.emit(&quot;hello&quot;, &quot;world&quot;);
socket.to(&quot;room1&quot;).emit(&quot;hello&quot;, &quot;world&quot;); // 예시

// 나 뺴고 모두에게 메세지를 보낸다.
socket.broadcast.emit(&quot;hello&quot;, &quot;world&quot;);</code></pre>
<p>지금까지는 SocketIO를 생으로 작업하는 코드입니다. 다음부터는 nest.js에서 지원하는 소켓코드를 학습하겠습니다.</p>
<hr>
<h3 id="🖊️gateway-생성하고-메세지-리스닝">🖊️Gateway 생성하고 메세지 리스닝</h3>
<p>코드로 websocket을 다뤄보도록 하겠습니다. 아래 3가지(<code>@nestjs/websockets</code> <code>@nestjs/platform-socket.io</code> <code>socket.io</code>)를 설치합니다.</p>
<pre><code>yarn add @nestjs/websockets @nestjs/platform-socket.io socket.io</code></pre><p>이후에 <code>package.json</code>으로 이동해서 버전을 맞춰주도록 하겠습니다. </p>
<p>이렇게 해주는 이유는 REST API의 경우에는 상관이 없지만 <code>양방향 통신을 할 경우에는 버전의 호환성 문제</code>가 생기기 때문입니다. <code>싱크를 맞춰주는 것</code>으로 이해하면 됩니다.</p>
<pre><code>yarn add @nestjs/common @nestjs/core @nestjs/jwt @nestjs/platform-express @nestjs/platform-socket.io @nestjs/typeorm @nestjs/websockets</code></pre><p>리소스를 생성하겠습니다. WebSockets으로 하면 훨씬 간단하지만 저희는 직접 REST API로 구현을 해보면서 깊게 이해해보겠습니다.</p>
<pre><code>nest g resource -&gt; chats -&gt; REST API -&gt; no</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/4b405713-ed72-4e23-95fd-3dcfd93e124e/image.png" alt=""></p>
<p>chats에 Socket IO를 연결하는 gateway파일을 생성하겠습니다.</p>
<ul>
<li>chats/chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">import { WebSocketGateway } from &quot;@nestjs/websockets&quot;;

@WebSocketGateway({
    // ws:localhost:3000/chats
    namespace: &#39;/chats&#39;,
})
export class ChatGateway {

}</code></pre>
<p>이제 <code>on connect</code>설정을 하겠습니다. </p>
<pre><code class="language-typescript">import { OnGatewayConnection, WebSocketGateway } from &quot;@nestjs/websockets&quot;;
import { Socket } from &quot;socket.io&quot;;

@WebSocketGateway({
    namespace: &#39;/chats&#39;,
})
export class ChatGateway {

    handleConnection(socket: Socket) {
          console.log(`on connect called : ${socket.id}`); // 어떤 id 소켓이 연결됨?
    }
}</code></pre>
<p><code>이벤트 리스너 코드</code>를 작성하겠습니다. 기본 클래식 코드는 <code>socket.on()</code>으로 시작합니다. 하지만 nest.js에서는 어노테이션을 사용해서 간결하게 만들어줍니다.</p>
<p>아래 추가된 코드는 기본 코드와 동일한 의미를 가지는 코드입니다.</p>
<pre><code class="language-typescript">@WebSocketGateway({
    namespace: &#39;/chats&#39;,
})
export class ChatGateway {

    handleConnection(socket: Socket) {
          console.log(`on connect called : ${socket.id}`);
    }

      // 기본 코드와 동일 -&gt; socket.on(&#39;send_message&#39;, (message) =&gt; {console.log(message)});
    @SubscribeMessage(&#39;send_message&#39;)
    sendMessage(
        @MessageBody() message: string,
    ) {
          console.log(message);
    }
}</code></pre>
<p>gateway를 만들었으니 <code>chats.module.ts</code>에 등록을 해줘야 합니다.</p>
<ul>
<li>chats.module.ts</li>
</ul>
<pre><code class="language-typescript">import { Module } from &#39;@nestjs/common&#39;;
import { ChatsService } from &#39;./chats.service&#39;;
import { ChatsController } from &#39;./chats.controller&#39;;
import { ChatsGateway } from &#39;./chats.gateway&#39;;

@Module({
    controllers: [ChatsController],
    providers: [
        ChatsGateway, // 등록
        ChatsService
    ],
})
export class ChatsModule {}</code></pre>
<p>그러면 로그에서 재미있는 것을 발견할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/83b5f53c-9360-4cd7-9748-c1a4af5127a0/image.png" alt=""></p>
<p><code>ChatsGateway</code>가 <code>send_message</code>를 구독했다는 것을 알 수 있습니다. 이제 포스트맨으로 구독을 테스트 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9487d94f-d8b9-49af-b766-93301e86a841/image.png" alt=""></p>
<p>new를 누릅니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/33478eab-4b44-4af8-b795-56523975a660/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a8a3b46a-aa1c-426d-a38a-9dc12f1eaa70/image.png" alt=""></p>
<pre><code>주소: ws://{{host}}/chats</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/6c23d8c1-ae65-442a-aeaa-a15f83467585/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/610359cb-fe9f-4ce4-a0ef-53ce0df34936/image.png" alt=""></p>
<p>현재 우리는 <code>send_message</code>라는 이벤트에 우리는 <code>subscribe</code>를 했습니다. 여기서 메세지를 보낼려면 client에서는 <code>emit</code>을 해야합니다.</p>
<p>포스트맨으로 client에서 Server에게 <code>emit</code>을 하는 방법은 간단합니다. 아래와 같이 진행하면 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/2055abb7-94d8-4a65-87a9-dba0b1b5ae57/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/bc53b895-5795-47bc-b1e7-3c8bd54c8d86/image.png" alt=""></p>
<p>그러면 Client 쪽에서 <code>emit</code>을 통해서 보낸 메세지가 Server에서는 <code>on</code>을 통해서 받아지는 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/19ca6b9a-c328-43d6-b3c4-0e87473f9556/image.png" alt=""></p>
<hr>
<h3 id="🖊️서버에서-메시지-보내기">🖊️서버에서 메시지 보내기</h3>
<p>Client에서 <code>emit</code>을 통해서 받은 메세지를 Client 전체에다가 메시지를 보낼려면 <code>chats.gateway.ts</code>에서 Server를 inject 해야합니다. </p>
<p><code>Inject를 하게 되면 nest.js가 알아서 server에 Server 객체</code>를 넣어줍니다. 여기서 server는 우리가 이전에 기본 형태로 만들었던 <code>const io = new Server(3000);</code> 과 동일합니다.</p>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">import { MessageBody, OnGatewayConnection, SubscribeMessage, WebSocketGateway, WebSocketServer } from &quot;@nestjs/websockets&quot;;
import { Socket, Server } from &quot;socket.io&quot;;

@WebSocketGateway({
    namespace: &#39;/chats&#39;,
})
export class ChatsGateway implements OnGatewayConnection{

      @WebSocketServer()
    server: Server; // Nest.js가 Server 객체를 주입해준다.

    handleConnection(socket: Socket) {
          console.log(`on connect called : ${socket.id}`);
    }

    @SubscribeMessage(&#39;send_message&#39;)
    sendMessage(
        @MessageBody() message: string,
    ) {
          this.server.emit(&#39;receive_message&#39;, &#39;hello_from_server&#39;); // 변경
    }
}</code></pre>
<p>코드로 알 수 있지만, server에서 <code>receive_message</code> 이벤트를 <code>emit</code>해서 <code>hello_from_server</code>를 메세지로 보내주고 있습니다.</p>
<p>포스트맨으로 테스트를 하겠습니다. 포스트맨으로 서버에서 보내는 값을 리스닝을 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/eba10945-9776-42f8-a6c8-798cd513c83a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/044f12c2-9b99-407b-bb53-ab723b486c44/image.png" alt=""></p>
<p>연결을 해줍니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/42b2f5bb-50c6-4100-930f-a8d2ceea1b4a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/4fba5d51-eff5-424f-8952-f682d7085a12/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/8510a3f4-781f-4e28-bc9d-e8dc5b00a652/image.png" alt=""></p>
<p>send 메시지를 하자마자 receive 메시지가 온 것을 알 수 있습니다.</p>
<p>이번에는 여러개의 <code>Socket IO</code>를 만들어 보겠습니다. 왜냐하면 서버에서 메시지를 보내면 연결되어 있는 모든 client에게 메시지가 가기 때문입니다.</p>
<p>User 2를 만들고 리스너를 활성화 시킵니다</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/e70ddae9-ae0d-4f78-b0bf-9e0e18b76cab/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/83c7306d-5d89-4340-8d2b-3f983618ef55/image.png" alt=""></p>
<p>그리고 <code>User 1</code>은 <code>disconnect</code>를 하고 다시 연결하겠습니다. 여기서 알 수 있는 것은 2개의 소켓은 완전히 다른 것임을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d6fb2b17-0ca1-4f3e-bdbc-768103b338b1/image.png" alt=""></p>
<p>소켓을 1개더 추가해서 총 3개를 만들겠습니다. 현재 완전히 다른 ID로 소켓이 연결되었습니다. 즉 <code>/chats</code>으로 연결되어 있는 소켓이 3개라는 의미입니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/f4ee6bf6-14f1-4f41-950e-8267c9aa9046/image.png" alt=""></p>
<pre><code>만약 User 1으로 이동해서 send_message를 보내게 되면?
-&gt; User 1, User 2, User 3 모두 receive_message를 받게된다!</code></pre><hr>
<h3 id="🖊️room-활용">🖊️Room 활용</h3>
<p>이번에는 User가 총 3명이 있는데, 특정 <code>Room</code>에 있는 사용자만 메세지를 받을 수 있도록 만들어 보겠습니다.</p>
<ul>
<li>chats/chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@WebSocketGateway({
    namespace: &#39;/chats&#39;,
})
export class ChatsGateway implements OnGatewayConnection{

    @WebSocketServer()
    server: Server;

    handleConnection(socket: Socket) {
          console.log(`on connect called : ${socket.id}`);
    }

    @SubscribeMessage(&#39;enter_chat&#39;)
    enterChat(
        // Room의 Id를 리스트로 받는다.
        @MessageBody() data: number[],
        // 지금 현재 연결된 소켓
        @ConnectedSocket() socket: Socket,
    ) {
        for (const chatId of data) {
            // socket.join()
            // join은 string만 받는다.
            socket.join(chatId.toString());
        }
    }

    @SubscribeMessage(&#39;send_message&#39;)
    sendMessage(
            @MessageBody() message: string,
    ) {
          this.server.emit(&#39;receive_message&#39;, &#39;hello_from_server&#39;);
    }
}</code></pre>
<p>이제 보내고 싶은 Room에다가 보내는 코드를 추가하겠습니다. 지금 현재의 단점은 <code>sendMessage()</code>를 보면 모든 연결된 소켓에게 전부 메시지를 보내고 있습니다. 따라서 보내고 싶은 곳만 골라서 보내겠습니다.</p>
<pre><code class="language-typescript">@SubscribeMessage(&#39;send_message&#39;)
sendMessage(
      @MessageBody() message: string,
) {
      this.server.in(
          // message.chatId에 해당하는 Room
          message.chatId.toString()
    ).emit(&#39;receive_message&#39;, &#39;hello_from_server&#39;);
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다. 서버를 재실행 할때마다 당연히 client 연결도 끊어지기 때문에 다시 연결해 줍니다.</p>
<p>User 1을 1번방에, User 2는 2번방에, User 3은 1번방에 넣겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/6ccc285c-6462-45e2-b745-3653f416de57/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/3283115e-976d-4ecc-8389-6c0ede084c13/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/bd0b7ea7-90f3-472a-b97d-7ba98c02a971/image.png" alt=""></p>
<p>이제 User 1에서 메세지를 보내겠습니다. 메세지를 보내는 형태는 <code>@MessageBody() message: {message: string, chatId: number},</code> 형태로 바뀌어져서 <code>JSON 형태</code>로 작성을 해줍니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/ffe32551-73e6-4fa1-a2aa-fbc9b259d5fc/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/ccb19658-dc50-4560-af70-8223bd54c364/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/6ca0e95b-182f-4684-b27f-7f357e2fdcd3/image.png" alt=""></p>
<p>1, 3번 User는 메시지를 받고 2번 사용자는 메시지를 안받는 결과가 나왔습니다.</p>
<p>이번에는 client에서 받아온 메세지를 그대로 받아오도록 만들겠습니다.</p>
<pre><code class="language-typescript">@SubscribeMessage(&#39;send_message&#39;)
sendMessage(
      @MessageBody() message: string,
) {
      this.server.in(
          message.chatId.toString()
    ).emit(&#39;receive_message&#39;, message.message); // 변경
}</code></pre>
<p>이번에는 disconnect 후 다시 연결해서 User 1(1, 2) / User 2(2) / User 3(1)방에 넣도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/cfa90424-49d4-4cb6-806f-a1f22376b16e/image.png" alt=""></p>
<p>User 1이 다음과 같이 <code>send_message</code>를 하게되면, User 1번과 User 3번이 메세지를 받습니다.</p>
<p>이번에는 User 1번이 2번 방에 보내보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/6cda3767-5fbe-4086-bcf6-4b38d84a1fe8/image.png" alt=""></p>
<p>User 1과 User 2가 메세지를 받습니다.</p>
<p>이로써 특정 Room에 들어있는 User에게만 고르는 것을 알아보았습니다.</p>
<hr>
<h3 id="🖊️broadcasting">🖊️BroadCasting</h3>
<p>BroadCasting을 적용해서 User 1이 1번방 또는 2번방으로 보내면, 나를 제외하고 메세지를 받도록 만들겠습니다.</p>
<pre><code class="language-typescript">@SubscribeMessage(&#39;send_message&#39;)
sendMessage(
      @MessageBody() message: string,
) {
      socket.to(message.chatId.toString()).emit(&#39;receive_message&#39;, message.message);
}</code></pre>
<p>이전에 구현한 <code>this.server</code>는 연결된 서버 전체를 의미하기 때문에 따로 사용자를 구분하지 않고 모두에게 메시지 전달이 됩니다. 하지만 <code>socket.to</code>의 경우 나를 제외한 사람들에게 메세지가 전달됩니다.</p>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p>User 1(1, 2) / User 2(2) / User 3(1) 후, 다음과 같이 전송을 하게 되면,</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/0403d3c6-d74f-4d63-9888-542636f0cbc6/image.png" alt=""></p>
<p>1번 User에게는 receive_message는 오지 않고 3번 User에게 전달됩니다.</p>
<p>즉 여기서 알 수 있는 것은, this.server를 사용하면, in으로 필터링한 모든 소켓들에게 전부 다 보냅니다.</p>
<p>반면 socket을 이용해서 보내면 해당 소켓에 나를 제외한 사람들에게 보내게됩니다.</p>
<hr>
<h3 id="🖊️chart-entity">🖊️Chart Entity</h3>
<p>지금부터 chat을 생성할 때는 복잡해집니다. 왜냐하면 해당 Room에 사용자가 존재하는지 이런 것들을 여러가지로 체크해야하기 때문입니다.</p>
<p>그리고 dto를 통해서 생성을 하도록 하겠습니다.</p>
<ul>
<li>chats/dto/create-chat.dto.ts</li>
</ul>
<pre><code class="language-typescript">import { IsNumber } from &quot;class-validator&quot;;

export class CreateChatDto {

    // 첫 번째 파라미터는 어떤 숫자들인지
    // 두 번쨰 파라미터는 각각 검증할 거야?
    @IsNumber({}, {each: true})
    userIds: number[];
}</code></pre>
<ul>
<li>chats/chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@SubscribeMessage(&#39;create_chat&#39;)
createChat(
    @MessageBody() data: CreateChatDto,
    @ConnectedSocket() socket: Socket,
) {

}</code></pre>
<p>그리고 Chats Entity를 생성하겠습니다. 그리고 반대쪽인 UsersModel에도 추가를 하겠습니다.</p>
<ul>
<li>chats/entities/chats.entity.ts</li>
</ul>
<pre><code class="language-typescript">@Entity()
export class ChatsModel extends BaseModel {

    // 1명의 사용자는 N개의 채팅방에 들어간다.
    // 1개의 채팅방은 여러 사용자가 있다.
    @ManyToMany(() =&gt; UsersModel, (user) =&gt; user.chats)
      @JoinTable()
    users: UsersModel[];
}</code></pre>
<ul>
<li>users/entities/users.entity.ts</li>
</ul>
<pre><code class="language-typescript">@Entity()
export class UsersModel extends BaseModel {

      .
    .
    @ManyToMany(() =&gt; ChatsModel, (chat) =&gt; chat.users)
    chats: ChatsModel[];
}</code></pre>
<p>2개중 1곳에 <code>@JoinTable()</code>을 만들어 주겠습니다. 왜냐하면 ManyToMany은 테이블을 3개로 관리합니다. 중간테이블이 추가되는 것입니다.</p>
<p>그리고 까먹지 말고 <code>app.module.ts</code>에 <code>ChatsModel</code>을 등록합니다. 그리고 Chat 서비스코드에 TypeORM을 사용할 것이기 때문에 chats.module.ts에 가서 TypeORM을 등록합니다.</p>
<ul>
<li>chats/chats.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
              ChatsModel,
        ])
    ],
    controllers: [ChatsController],
    providers: [
        ChatsGateway,
        ChatsService
    ],
})
export class ChatsModule {}</code></pre>
<p>이제 서비스 코드에서 기능을 구현하겠습니다.</p>
<ul>
<li>chats.service.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class ChatsService {

    constructor(
        @InjectRepository(ChatsModel)
         private readonly chatRepository: Repository&lt;ChatsModel&gt;,
    ) {}


    async createChat(dto: CreateChatDto) {
        // await가 붙어있으면 async 필요
        const chat = await this.chatRepository.save({
            // 1, 2, 3
            // [{id:1}, {id:2}, {id:3}]
            users: dto.userIds.map((id) =&gt; ({id})),
        });

        return this.chatRepository.findOne({
            where: {
                  id: chat.id,
            },
        });
    }
}</code></pre>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">export class ChatsGateway implements OnGatewayConnection{

    constructor(
        private readonly chatsService: ChatsService,
    ){}
.
.
@SubscribeMessage(&#39;create_chat&#39;)
async createChat(
    @MessageBody() data: CreateChatDto,
    @ConnectedSocket() socket: Socket,
) {
    const chat = await this.chatsService.createChat(
          data, 
    );
}</code></pre>
<p>포스트맨으로 1번 사용자와 2번 사용자가 같이있는 chat 방을 만들어 보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/4a8bcb7c-b665-43c9-9114-cd7164841f0b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/94a2f430-5c00-4237-8ac3-b563daa960af/image.png" alt=""></p>
<pre><code>select * from chats_model_users_users_model;</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/3d66a22f-27e9-4860-8fad-f7b12c7b4a83/image.png" alt=""></p>
<hr>
<h3 id="🖊️pagination-chat-api-생성">🖊️Pagination Chat API 생성</h3>
<p>채팅방을 페이지네이션하는 기능을 만들겠습니다. dto를 생성하고 서비스 코드에 적용을 하겠습니다.</p>
<ul>
<li>chats/dto/paginate-chat.dto.ts</li>
</ul>
<pre><code class="language-typescript">import { BasePaginationDto } from &quot;src/common/dto/base-pagination.dto&quot;;

export class PaginateChatDto extends BasePaginationDto {}</code></pre>
<ul>
<li>chats.service.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class ChatsService {

    constructor(
       @InjectRepository(ChatsModel)
       private readonly chatRepository: Repository&lt;ChatsModel&gt;,
       private readonly commonService: CommonService,
    ) {}

      // 페이지네이션 적용
    paginateChats(dto: PaginateChatDto) {
        return this.commonService.paginate(
            dto,
            this.chatRepository,
            {}, // overrideOption
            &#39;chats&#39;
        );
    }

      .
    .</code></pre>
<ul>
<li>chats.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
              ChatsModel,
        ]),
        CommonModule, // 추가 -&gt; CommonService 사용하기 때문에
    ],
    controllers: [ChatsController],
    providers: [
        ChatsGateway,
        ChatsService
    ],
})
export class ChatsModule {}</code></pre>
<p>컨트롤러로 이동해서 페이지네이션 API를 작성하겠습니다.</p>
<ul>
<li>chats.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Get()
paginateChat(
      @Query() dto: PaginateChatDto
) { 
      return this.chatsService.paginateChats(dto);
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다. </p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/dfd20ef9-003b-4f29-b0a5-7197ca2b5091/image.png" alt=""></p>
<p>잘 생성이 되는 것을 알 수 있습니다. </p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 1,
            &quot;updatedAt&quot;: &quot;2024-02-17T15:43:39.735Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-17T15:43:39.735Z&quot;
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: null
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: null
}</code></pre><p>하지만 어떤 사용자들이 존재하는지 또한 보고 싶습니다.</p>
<ul>
<li>chats.service.ts</li>
</ul>
<pre><code class="language-typescript">paginateChats(dto: PaginateChatDto) {
    return this.commonService.paginate(
      dto,
      this.chatRepository,
      {
          relations: { // 추가
                users: true,
          }
      },
      &#39;chats&#39;
    );
}</code></pre>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 1,
            &quot;updatedAt&quot;: &quot;2024-02-17T15:43:39.735Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-17T15:43:39.735Z&quot;,
            &quot;users&quot;: [
                {
                    &quot;id&quot;: 1,
                    &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                    &quot;nickname&quot;: &quot;codefactory&quot;,
                    &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                    &quot;role&quot;: &quot;USER&quot;
                },
                {
                    &quot;id&quot;: 2,
                    &quot;updatedAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-01-26T06:48:51.110Z&quot;,
                    &quot;nickname&quot;: &quot;codefactory1&quot;,
                    &quot;email&quot;: &quot;codefactory1@codefactory.ai&quot;,
                    &quot;role&quot;: &quot;USER&quot;
                }
            ]
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: null
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: null
}</code></pre><hr>
<h3 id="🖊️enter-chat-이벤트-업데이트--wsexception-던지기">🖊️Enter Chat 이벤트 업데이트 &amp; WSException 던지기</h3>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@SubscribeMessage(&#39;enter_chat&#39;)
enterChat(
    @MessageBody() data: number[],
    @ConnectedSocket() socket: Socket,
) {  
    socket.join(data.map((x) =&gt; x.toString())); // 한번에 join
}</code></pre>
<p>다음과 같이 바꿔주도록 하겠습니다. 어차피 이전에 for 루핑을 통한 방법과 동일한 코드입니다. 한번에 모든 방들이 join이 되는 것 입니다.</p>
<p>이제는 enterChat하는 코드를 수정해보겠습니다. 먼저 enterChat에도 dto를 생성하겠습니다.</p>
<ul>
<li>chats/dto/enter-chat.dto.ts</li>
</ul>
<pre><code class="language-typescript">import { IsNumber } from &quot;class-validator&quot;;

export class EnterChatDto {

    @IsNumber({}, {each: true})
    chatIds: number[];
}</code></pre>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@SubscribeMessage(&#39;enter_chat&#39;)
enterChat(
    @MessageBody() data: EnterChatDto, // 변경
    @ConnectedSocket() socket: Socket,
) {  
    socket.join(data.chatIds.map((x) =&gt; x.toString())); // 변경
}</code></pre>
<p>그리고 chat을 join하려고 할때, 굳이 존재하지 않는 chat은 할 필요가 없습니다. 따라서 chat이 존재하는지 존재하지 않는지 확인하는 절차를 추가하겠습니다.</p>
<ul>
<li>chats.service.ts</li>
</ul>
<pre><code class="language-typescript">.
.
async checkIfChatExists(chatId: number) {
    const exists = await this.chatRepository.exists({
        where: {
              id: chatId,
        },
    });
    return exists;
}</code></pre>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@SubscribeMessage(&#39;enter_chat&#39;)
async enterChat(
    @MessageBody() data: EnterChatDto,
    @ConnectedSocket() socket: Socket,
) {  
    for (const chatId of data.chatIds) {
        const exists = await this.chatsService.checkIfChatExists(
              chatId,
        );

        if (!exists) throw new WsException({
            code: 100,
            message: `존재하지 않는 chat 입니다. chatId: ${chatId}`,
        });
    }

    socket.join(data.chatIds.map((x) =&gt; x.toString()));
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다. 먼저 User 1 ~ 3번까지 연결을 합니다. 이후에는 존재하지 않는 chatId를 <code>enter_chat</code> 해보겠습니다. 현재 2번 chat은 존재하지 않습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/24e9958f-2ca5-49d2-8694-43d8247939d8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a3179fa0-8007-4323-91a6-f50ba66d3b8d/image.png" alt=""></p>
<p>WsException은 exception이라는 이벤트로 받을 수 있습니다. exception으로 리스닝을 해줍니다. </p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/1cfa1443-0f9f-4b9e-8166-fe5a544d68e3/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/b6fedc8e-4b0c-4589-a0d6-508919c5b8d5/image.png" alt=""></p>
<p>이번에는 올바르게 넣어보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/7d0b0e15-7b41-4c19-b4f1-c66d85bf7ddf/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/e10a0a7e-92da-47a9-96b8-de3c243c4422/image.png" alt=""></p>
<hr>
<h3 id="🖊️메세지-보내기-끝">🖊️메세지 보내기 끝</h3>
<p>1개의 Room에는 여러개의 message가 있을 수 있습니다. 따라서 MessagesModel을 생성하겠습니다.</p>
<p>또한 1명의 사용자만이 N개의 message를 작성할 수 있습니다.</p>
<ul>
<li>chats/messages/entities/messages.entity.ts</li>
</ul>
<pre><code class="language-typescript">import { ChatsModel } from &quot;src/chats/entities/chats.entity&quot;;
import { BaseModel } from &quot;src/common/entity/base.entity&quot;;
import { ManyToOne } from &quot;typeorm&quot;;

@Entity()
export class MessagesModel extends BaseModel {

    // N개의 메시지가 1개의 Chat방에 연결된다.
    @ManyToOne(() =&gt; ChatsModel, (chat) =&gt; chat.messages)
    chat: ChatsModel;

      // 1명의 사용자가 N개의 message를 작성한다.
      @ManyToOne(() =&gt; UsersModel, (user) =&gt; user.messages)
    author: UsersModel;

      @Column()
    @IsString()
    message: string;
}</code></pre>
<p>이제 ChatsModel로도 이동을 해서 <code>@OneToMany</code>를 작성하겠습니다.</p>
<ul>
<li>chats/entities/chats.entity.ts</li>
</ul>
<pre><code class="language-typescript">import { BaseModel } from &quot;src/common/entity/base.entity&quot;;
import { UsersModel } from &quot;src/users/entities/users.entity&quot;;
import { Entity, JoinTable, ManyToMany, OneToMany } from &quot;typeorm&quot;;
import { MessagesModel } from &quot;../messages/entities/messages.entity&quot;;

@Entity()
export class ChatsModel extends BaseModel {

    @ManyToMany(() =&gt; UsersModel, (user) =&gt; user.chats)
    @JoinTable()
    users: UsersModel[];

      // 추가
    @OneToMany(() =&gt; MessagesModel, (message) =&gt; message.chat)
    messages: MessagesModel;
}</code></pre>
<ul>
<li>users/entites/users.entity.ts</li>
</ul>
<pre><code class="language-typescript">@Entity()
export class UsersModel extends BaseModel {

      // 추가
    @OneToMany(() =&gt; MessagesModel, (message) =&gt; message.author)
    messages: MessagesModel;
}</code></pre>
<p>마지막으로 잊지말고 <code>app.module.ts</code>에 <code>MessagesModel</code>을 등록합니다.</p>
<p>message 관련 서비스 코드를 작성하겠습니다.</p>
<ul>
<li>chats/messages/messages.service.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class ChatsMessagesService {

    constructor(
        @InjectRepository(MessagesModel)
         private readonly messagesRepository: Repository&lt;MessagesModel&gt;,
         private readonly commonService: CommonService,
    ) {}
}</code></pre>
<p>메세지를 페이지네이션하는 코드를 만들겠습니다.</p>
<pre><code class="language-typescript">@Injectable()
export class ChatsMessagesService {

    constructor(
        @InjectRepository(MessagesModel)
         private readonly messagesRepository: Repository&lt;MessagesModel&gt;,
         private readonly commonService: CommonService,
    ) {}

      paginateMessages(
        dto: BasePaginationDto,
         overrideFindOptions: FindManyOptions&lt;MessagesModel&gt;,
    ) {
        return this.commonService.paginate(
            dto,
            this.messagesRepository,
            overrideFindOptions, // 외부에서도 받도록 만들기
            &#39;message&#39;
        )
    }
}</code></pre>
<p>다음에는 createMessage를 만들겠습니다. 우선은 관련된 dto가 필요합니다.</p>
<ul>
<li>chats/messages/dto/create-messages.dto.ts</li>
</ul>
<pre><code class="language-typescript">import { PickType } from &quot;@nestjs/mapped-types&quot;;
import { MessagesModel } from &quot;../entities/messages.entity&quot;;
import { IsNumber } from &quot;class-validator&quot;;

export class CreateMessagesDto extends PickType(MessagesModel, [
    &#39;message&#39;, // MessagesModel에서 message를 제외하고는 전부 객체이기 때문에 message만 골라서 상속을 받는다.
]) {
    @IsNumber()
    chatId: number;

    @IsNumber()
    authorId: number; // 원래는 작성자의 이름을 이렇게 주면 안된다. CC) accessToken -&gt; 임시로
}</code></pre>
<p>서비스코드로 이동해서 마저 작성을 하겠습니다.</p>
<ul>
<li>chats.service.ts</li>
</ul>
<pre><code class="language-typescript">async createMessage(
      dto: CreateMessagesDto,
) {
    const message = await this.messagesRepository.save({
        chat: {
              id: dto.chatId,
        },
        author: {
              id: dto.authorId,
        },
        message: dto.message,
    });
    return this.messagesRepository.findOne({
        where: {
              id: message.id,
        }
    });
}</code></pre>
<p><code>chats.module.ts</code>로 이동 후, <code>ChatsMessagesService</code>와 <code>MessagesModel</code>을 등록합니다. </p>
<p><code>chats.gateway.ts</code>로 이동을 해서 코드를 수정하겠습니다.</p>
<ul>
<li>chats.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
            ChatsModel,
            MessagesModel,
        ]),
        CommonModule,
    ],
    controllers: [ChatsController],
    providers: [
        ChatsGateway,
        ChatsService,
        ChatsMessagesService,
    ],
})
export class ChatsModule {}</code></pre>
<ul>
<li>chats.gateway.ts</li>
</ul>
<pre><code class="language-typescript">@SubscribeMessage(&#39;send_message&#39;)
async sendMessage(
    @MessageBody() dto: CreateMessagesDto,
    @ConnectedSocket() socket: Socket,
) {
    const chatExists = await this.chatsService.checkIfChatExists(
          dto.chatId,
    );

    if (!chatExists) {
        throw new WsException(
              `존재하지 않는 채팅방입니다. Chat ID : ${dto.authorId}`,
        );
    }

      // 메세지 생성
    const message = await this.messagesService.createMessage(
          dto,
    );

      // 소켓 속에다가 생성된 메시지를 기반으로, 해당되는 방(message.chat.id)에다가 메시지를 보낸다.
    socket.to(message.chat.id.toString()).emit(&#39;receive_message&#39;, message.message);
}</code></pre>
<p>메세지를 페이지네이션하는 컨트롤러 코드를 작성하겠습니다.</p>
<ul>
<li>messages.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Controller(&#39;chats/:cid/messages&#39;)
export class MessagesController {

    constructor(
        private readonly messagesService: ChatsMessagesService,
    ) {}

    @Get()
    paginateMessage(
        @Param(&#39;cid&#39;, ParseIntPipe) id: number,
         dto: BasePaginationDto,
    ) {
        return this.messagesService.paginateMessages(
            dto,
            { // overrideOption
                where: {
                    // 특정 chat에 관련된 id만 필터링됨
                    chat: {
                          id,
                    }
                }
            }
        );
    }
}</code></pre>
<ul>
<li>chats.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
            ChatsModel,
            MessagesModel,
        ]),
        CommonModule,
    ],
    controllers: [
        ChatsController,
        MessagesController,
    ],
    providers: [
        ChatsGateway,
        ChatsService,
        ChatsMessagesService,
    ],
})
export class ChatsModule {}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다. 현재는 chat 방이 1, 2 총 2개로 구성되어 있습니다. 사용자 1, 2, 3 을 연결해줍니다.</p>
<p>2번 방에 사용자 1, 2, 3을 전부 넣어줍니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d661343b-cf72-4c96-9115-43f62308ac66/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/99d0a3dc-8c68-4cae-9a04-b4045c902eb4/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/60671619-d5b7-4043-87dd-59b94159895f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/0db7d15f-e144-432b-aeda-d5748d3475ff/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 2,
            &quot;updatedAt&quot;: &quot;2024-02-17T20:27:53.986Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-17T20:27:53.986Z&quot;,
            &quot;message&quot;: &quot;Hellow Nes Jeans&quot;
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: null
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: null
}</code></pre><p><code>2번 chat</code>에 해당하는 아이디가 2인 메세지가 1개있는 것을 알 수 있습니다. 만약에 우리가 메세지에 해당되는 <code>chat room</code>까지 보고 싶으면 다음과 같이 코드를 바꾸면 됩니다.</p>
<ul>
<li>chats/messages/messages.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Get()
paginateMessage(
    @Param(&#39;cid&#39;, ParseIntPipe) id: number,
    @Query() dto: BasePaginationDto,
) {
    return this.messagesService.paginateMessages(
        dto,
        {
            where: {
                chat: {
                      id,
                }
            },
              // 추가
            relations: {
                author: true,
                chat: true,
            }
        }
    );
}</code></pre>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 2,
            &quot;updatedAt&quot;: &quot;2024-02-17T20:27:53.986Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-17T20:27:53.986Z&quot;,
            &quot;message&quot;: &quot;Hellow Nes Jeans&quot;,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            },
            &quot;chat&quot;: {
                &quot;id&quot;: 2,
                &quot;updatedAt&quot;: &quot;2024-02-17T20:01:57.991Z&quot;,
                &quot;createdAt&quot;: &quot;2024-02-17T20:01:57.991Z&quot;
            }
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: null
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: null
}</code></pre><p>메세지의 정보와 누가 어떤 방에서 보냈는지를 알 수 있습니다.</p>
<p>포스트맨에서 다시 한번 테스트를 해보겠습니다.</p>
<p>사용자 1, 2, 3을 전부 2번방에 넣습니다. 그리고 1번 사용자가 메세지를 보냅니다. 결과는 1번 사용자가 보낸 메세지가 2, 3번 사용자에게 도달하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/ceeec93f-0148-4069-b2ad-4028e4b09cb9/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Middleware]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Middleware</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Middleware</guid>
            <pubDate>Thu, 15 Feb 2024 23:01:08 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️이론">🖊️이론</h3>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/059f1157-b991-482c-bf90-2009fe590193/image.png" alt=""></p>
<p><a href="https://docs.nestjs.com/middleware">공식문서</a></p>
<p>미들웨어는 가장 앞에서 먼저 요청을 받습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/fdde5f39-6217-42dd-a83e-7f36e50a8b72/image.png" alt=""></p>
<p>Middleware functions can perform the following tasks:</p>
<ul>
<li>execute any code.(어떤 코드는 실행가능)</li>
<li>make changes to the request and the response objects.(무엇인가 추가하거나 삭제하는 가능)</li>
<li>end the request-response cycle.(가드같은 역할도 가능)</li>
<li>call the next middleware function in the stack.(여러개의 미들웨어 적용 가능, 적용된 순서로 진행)</li>
<li><code>if the current middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging.</code> (미들웨어를 실행하고 내부에서 다음으로 넘어가려면 반드시 next()를 사용, 그렇지 않으면 그 위치에서 정지)</li>
</ul>
<hr>
<h3 id="🖊️middleware-생성-및-사용">🖊️Middleware 생성 및 사용</h3>
<p>Middleware를 구현하겠습니다. <code>NestMiddleware</code>를 implements를 하면 <code>use</code>라는 함수를 오버라이드 해야합니다.</p>
<ul>
<li>common/middleware/log.middleware.ts</li>
</ul>
<pre><code class="language-typescript">import { Injectable, NestMiddleware } from &quot;@nestjs/common&quot;;
import { NextFunction } from &quot;express&quot;;

@Injectable()
export class LogMiddleware implements NestMiddleware {

    use(req: Request, res: Response, next: NextFunction) {

    }
}</code></pre>
<p>NextFunction은 express에서 가져온 기능인것을 import를 통해서 알 수 있습니다.</p>
<p>NextFunction을 사용하지 않으면 요청이 멈춘다고 했습니다. 따라서 next()를 적용합니다.</p>
<pre><code class="language-typescript">import { Injectable, NestMiddleware } from &quot;@nestjs/common&quot;;
import { NextFunction } from &quot;express&quot;;

@Injectable()
export class LogMiddleware implements NestMiddleware {

    use(req: Request, res: Response, next: NextFunction) {
        console.log(`[REQ] ${req.method} ${req.url} ${new Date().toLocaleString(&#39;kr&#39;)}`);
        next(); // 적용
    }
}</code></pre>
<p>이제 미들웨어를 적용하겠습니다. 다른 것들은 함수에 어노테이션을 적용을 합니다. 하지만 미들웨어는 모듈에다가 적용을 합니다.</p>
<ul>
<li>posts.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
            PostsModel,
            ImageModel,
        ]),
        AuthModule,
        UsersModule,
        CommonModule,
    ],
    controllers: [PostsController],
    providers: [PostsService, PostsImagesService,],
    exports: [PostsService]
})
export class PostsModule implements NestModule{

}</code></pre>
<p>NestModule를 implements하면 <code>configure</code> 메소드로 미들웨어를 <code>consumer</code> 즉 <code>소비</code>해야합니다.</p>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
            PostsModel,
            ImageModel,
        ]),
        AuthModule,
        UsersModule,
        CommonModule,
    ],
    controllers: [PostsController],
    providers: [PostsService, PostsImagesService,],
    exports: [PostsService]
})
export class PostsModule implements NestModule{
  configure(consumer: MiddlewareConsumer) { // 미들웨어를 소비(consumer)한다.
    consumer.apply(
      // 적용하고 싶은 미들웨어 넣기
      LogMiddleware,
    )
  }
}</code></pre>
<p>포스트맨으로 GET 요청을 해보도록 하겠습니다. 현재는 <code>@UseInterceptor</code>가 없는 상태입니다. 만약에 <code>@UseInterceptor</code>를 추가한 상태로 요청을 보내면 미들웨어에서 로그를 만들지 않고 <code>@UseInterceptor</code> 내부에서 로그를 만들어 냅니다.</p>
<pre><code class="language-typescript">@Get()
getPosts(
      @Query() query: PaginatePostDto,
) {
    //throw new BadRequestException(&#39;에러 테스트&#39;); // HttpExceptionFilter 테스트
    return this.postsService.paginatePosts(query);
}</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/542493d4-5033-4085-9727-307dc37803b6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/e4498ea1-c48a-43c0-8df8-5883fe720f4f/image.png" alt=""></p>
<p>아무것도 나오지 않은 것을 알 수 있습니다. 왜냐하면 저희가 미들웨어를 적용하고 싶은 라우트를 지정하지 않았기 때문입니다. 라우트를 지정해주도록 하겠습니다.</p>
<pre><code class="language-typescript">export class PostsModule implements NestModule{

    configure(consumer: MiddlewareConsumer) {
        consumer.apply(
            LogMiddleware,
        ).forRoutes({
            path: &#39;posts&#39;, // posts에 해당하는 path에 적용할 것이다.
            method: RequestMethod.ALL, // 적용을 하고 싶은 메소드에 적용
        })
    }
}</code></pre>
<pre><code>path: &#39;posts&#39;
path: &#39;posts*&#39; // 모든 경로
.
.
method: RequestMethod.ALL
method: RequestMethod.GET
method: RequestMethod.POST
.
.</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a39afa1a-8fa8-43a7-b1bf-8bf53ed3af24/image.png" alt=""></p>
<p>이번에는 path: &#39;paths&#39;상태에서 특정 id에 해당하는 요청을 보내보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a1f47fb3-349f-4791-94ed-65abe3076e0e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/8d080423-c45a-4d8b-8670-00c322c14305/image.png" alt=""></p>
<p>아무것도 찍히지 않는 것을 알 수 있습니다. 즉, 여기서 알 수 있는 점은 path경로가 정확히 <code>posts</code>인 경우에만 해당이 되는 것입니다. </p>
<p>만약 posts가 있는 모든 경로에 적용을 하고 싶은 경우 <code>path*</code>로 작성해야 합니다.</p>
<pre><code class="language-typescript">export class PostsModule implements NestModule{

    configure(consumer: MiddlewareConsumer) {
        consumer.apply(
            LogMiddleware,
        ).forRoutes({
            path: &#39;posts*&#39;, 
            method: RequestMethod.GET,
        })
    }
}</code></pre>
<p>동일하게 Get 요청을 보내보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/28784456-9ed8-4888-8cdc-58421a7cf7ab/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/30b2b025-644e-4b61-93b4-08324f8a6615/image.png" alt=""></p>
<p>이 의미는 PostsModule에 작성한 Middleware코드를 AppModule로 옮기고 path를 <code>*</code>로 하게되면 모든 곳에 적용이 가능하다는 것을 알 수 있습니다.</p>
<ul>
<li>posts/posts.module.ts</li>
</ul>
<pre><code class="language-typescript">export class PostsModule implements NestModule{
    // implements NestModule
      // 전부 app.module.ts로 옮기기!
}</code></pre>
<ul>
<li>app.module.ts</li>
</ul>
<pre><code class="language-typescript">export class AppModule implements NestModule{

    configure(consumer: MiddlewareConsumer) {
        consumer.apply(
            LogMiddleware,
        ).forRoutes({
            path: &#39;*&#39;, // 전체 적용
            method: RequestMethod.ALL,
        })
    }
}</code></pre>
<p>따라서 로그인을 하게되어도 로그가 남는 것을 알 수 있습니다. 어떠한 요청에도 전부 로그가 남게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9808eb1c-6c49-4432-9b07-591df4954dd4/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/ce34d126-15d1-4f09-95a3-1ec8faac304f/image.png" alt=""></p>
<p>즉, Middleware의 특징은 가장 먼저 적용이 된다는 점입니다. 그리고 우리가 적용을 할 때, 패턴(path, methed 적용)을 가지고 적용할 수 있다는 큰 장점이 있습니다.</p>
<p>따라서 지금 처럼 로그를 콘솔에 단순하게 찍는 것이 아니라, 로그를 모니터링 할 수 있는 것과 연동을 시켜서 Middleware와 연결하면 유용합니다.</p>
<p>추가로 CORS 같은 보안적인 것도 Middleware로 구현이 되어있습니다. 따라섯 순서대로 보안을 적용하고 그 다음에 통과가 되면 Pipe로 이동을 하는 것입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Exception filter]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Exception-filter</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Exception-filter</guid>
            <pubDate>Thu, 15 Feb 2024 22:38:36 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️이론">🖊️이론</h3>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/059f1157-b991-482c-bf90-2009fe590193/image.png" alt=""></p>
<p>Exception Filter는 로직처리가 되고 진행이 됩니다. 그 후에는 interceptor로 이동하게 됩니다. 즉 interceptor에서도 Exception Filter를 핸들링 할 수 있다는 의미입니다.</p>
<p>Exception Filter는 예외를 필터링 하는 것입니다. 보통 Exception Filter가 유용한 경우는 에러가 생겼을 때 로그를 남기거나 모니터링에 이러한 문제점이 발생한다 라고 알려주기 위해서 사용될 때 의미가 좋습니다.</p>
<p><a href="https://docs.nestjs.com/exception-filters">공식문서</a></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/98e01607-5a3d-45ec-80de-ab4339ca5bac/image.png" alt=""></p>
<p>사진으로 알 수 있듯, Filter는 Client Side에 가깝고, Server 쪽이랑은 먼 것을 알 수 있습니다.</p>
<p>Built-in HTTP exceptions#
Nest provides a set of standard exceptions that inherit from the base <code>HttpException</code>. These are exposed from the @nestjs/common package, and represent many of the most common HTTP exceptions:</p>
<pre><code>BadRequestException
UnauthorizedException
NotFoundException
ForbiddenException
NotAcceptableException
RequestTimeoutException
ConflictException
GoneException
HttpVersionNotSupportedException
PayloadTooLargeException
UnsupportedMediaTypeException
UnprocessableEntityException
InternalServerErrorException
NotImplementedException
ImATeapotException
MethodNotAllowedException
BadGatewayException
ServiceUnavailableException
GatewayTimeoutException
PreconditionFailedException</code></pre><p>여기서 주목해야하는 것은 <code>HttpException</code> 입니다. Exception 목록들은 전부 <code>HttpException</code>를 상속받은 것입니다. 추후에 Exception Filter 클래스를 만들 때, <code>HttpException</code>에 해당하는 Exception만 잡는 코드를 작성할 것입니다.</p>
<hr>
<h3 id="🖊️httpexceptionfilter">🖊️HttpExceptionFilter</h3>
<p>본격적으로 코드를 작성하고 적용을 해보겠습니다.</p>
<ul>
<li>common/exception-filter/http-exception-filter.ts</li>
</ul>
<pre><code class="language-typescript">import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from &quot;@nestjs/common&quot;;

@Catch(HttpException) // HttpException에 해당하는 모든 예외를 자는다.
export class HttpExceptionFilter implements ExceptionFilter {

    catch(exception: HttpException, host: ArgumentsHost) {
        const context = host.switchToHttp();
        const response = context.getResponse();
        const request = context.getRequest();
        const status = exception.getStatus();

        response
            .status(status)
            .json({
            statusCode: status,
            message: exception.message,
            timestamp: new Date().toLocaleString(&#39;kr&#39;),
            path: request.url, // 어떤 url에서 에러 발생 했는지 알 수 있음
        });
    }
}</code></pre>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Get()
@UseInterceptors(LogInterceptor)
@UseFilters(HttpExceptionFilter)
getPosts(
      @Query() query: PaginatePostDto,
) {
    throw new BadRequestException(&#39;에러 테스트&#39;);
    return this.postsService.paginatePosts(query);
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/86139fb7-d952-4879-8821-1f53befa66cc/image.png" alt=""></p>
<pre><code>{
    &quot;message&quot;: &quot;에러 테스트&quot;,
    &quot;error&quot;: &quot;Bad Request&quot;,
    &quot;statusCode&quot;: 400
}
.
.
차이비교
.
.
{
    &quot;statusCode&quot;: 400,
    &quot;message&quot;: &quot;에러 테스트&quot;,
    &quot;timestamp&quot;: &quot;2024. 2. 16. 오전 7:31:22&quot;,
    &quot;path&quot;: &quot;/posts?order__createdAt=DESC&amp;take=2&quot;
}</code></pre><p>Exception Filter 코드의 경우 보통 중간 부분에 로그를 저장하거나 외부 모니터링 시스템이 API를 콜 하는 코드를 작성합니다. 그리고 이 Exception Filter를 전역적으로 적용하기 위해서 main.ts에 적용을 합니다.</p>
<ul>
<li>common/exception-filter/http-exception-filter.ts</li>
</ul>
<pre><code class="language-typescript">import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from &quot;@nestjs/common&quot;;

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {

    catch(exception: HttpException, host: ArgumentsHost) {
        const context = host.switchToHttp();
        const response = context.getResponse();
        const request = context.getRequest();
        const status = exception.getStatus();

        // 로그 파일을 생성하거나
        // 에러 모니터링 시스템에 API 콜 하기

        response
            .status(status)
            .json({
            statusCode: status,
            message: exception.message,
            timestamp: new Date().toLocaleString(&#39;kr&#39;),
            path: request.url,
        });
    }
}</code></pre>
<ul>
<li>main.ts</li>
</ul>
<pre><code class="language-typescript">import { NestFactory } from &#39;@nestjs/core&#39;;
import { AppModule } from &#39;./app.module&#39;;
import { ValidationPipe } from &#39;@nestjs/common&#39;;
import { HttpExceptionFilter } from &#39;./common/exception-filter/http-exception-filter&#39;;

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.useGlobalPipes(new ValidationPipe({
        transform: true,
        transformOptions: {
          enableImplicitConversion: true
        },
        whitelist: true,
        forbidNonWhitelisted: true,
    }));

    app.useGlobalFilters(new HttpExceptionFilter()); // Exception Filter 전역 적용
    await app.listen(3000);
}
bootstrap();</code></pre>
<p>하지만 지금은 사용하지 않으니 main.ts와 컨트롤러에서 강제로 에러를 던진 코드와 @UseFilter를 전부 지우겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Interceptor]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Interceptor</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Interceptor</guid>
            <pubDate>Thu, 15 Feb 2024 15:34:12 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️이론">🖊️이론</h3>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/059f1157-b991-482c-bf90-2009fe590193/image.png" alt=""></p>
<p>인터셉터의 경우 요청, 응답일 때 발생합니다. 즉 요청과 응답을 <code>핸들링</code> 할 수 있다는 것입니다.</p>
<p><a href="https://docs.nestjs.com/interceptors">공식문서</a></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/6c790220-cb22-47b5-8e16-6e37ce9f7300/image.png" alt=""></p>
<p>여기서 주목할 점은 각각의 interceptor를 따로 핸들링하는 것이 아니라, <code>한 곳에서 2개 모두 핸들링이 가능</code>합니다.</p>
<pre><code>bind extra logic before / after method execution
-&gt; 메소드를 실행하기 전과 후 추가 로직을 작성할 수 있다.
transform the result returned from a function
-&gt; 함수에서 받은 값을 변형할 수 있다.
transform the exception thrown from a function
-&gt; 함수에서 던진 에러를 변형할 수 있다.
extend the basic function behavior
-&gt; 기본으로 작성함 함수에 추가로 작성을 할 수 있다.
completely override a function depending on specific conditions (e.g., for caching purposes)
-&gt; 어떤한 함수의 기능을 완전히 오버라이드 할 수 있다.</code></pre><hr>
<h3 id="🖊️interceptor를-이용해서-logger-구현">🖊️Interceptor를 이용해서 logger 구현</h3>
<p>전반적으로 사용할 수 있는 interceptor를 만들겠습니다. interceptor를 구현하겠습니다.</p>
<ul>
<li>common/interceptor/log.interceptor.ts</li>
</ul>
<pre><code class="language-typescript">import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from &quot;@nestjs/common&quot;;
import { Observable } from &quot;rxjs&quot;;

@Injectable()
export class LogInterceptor implements NestInterceptor {

    intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Observable&lt;any&gt; {
        /**
         * Request 요청이 들어온 timestamp를 찍는다.
         * [REQ] {요청 path} {요청 시간}
         * 
         * 요청이 끝날때 (응답이 나갈때) 다시 timestamp를 찍는다.
         * [RES] {요청 path} {응답 시간} {얼마나 걸렸는지 ms}
         */
        const request = context.switchToHttp().getRequest();
        const path = request.originalUrl; // /posts, /common/image
        const now = new Date();
        console.log(`[REQ] ${path} ${now.toLocaleString(&#39;kr&#39;)}`); // [REQ] {요청 path} {요청 시간}
    }
}</code></pre>
<p>interceptor 함수 반환 타입의 경우 <code>Observable&lt;any&gt;</code>입니다. <code>Observable</code>은 rxjs에서 제공해주는 stream같은 기능이기 때문에 마음대로 변형이 가능합니다. <code>return next</code>를 추가합니다.</p>
<p>여기서 알 수 있는 것은 <code>return next.handle()</code>전에는 endpoint가 실행되기 전에 미리 실행이 됩니다. </p>
<p>하지만 <code>return next.handle()</code> 이후에는 함수의 로직이 실행되고 응답이 반환됩니다. 따라서 이 부분에서 응답을 가로채서 변형하거나 모니터링을 할 수 있는 것입니다.</p>
<pre><code class="language-typescript">import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from &quot;@nestjs/common&quot;;
import { Observable } from &quot;rxjs&quot;;

@Injectable()
export class LogInterceptor implements NestInterceptor {

    // Observable: Rxjs에서 제공해주는 stream 같은 기능
    intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Observable&lt;any&gt; {
        const request = context.switchToHttp().getRequest();
        const path = request.originalUrl;
        const now = new Date();
        console.log(`[REQ] ${path} ${now.toLocaleString(&#39;kr&#39;)}`);
        // 여기까지는 함수에 interceptor 적용시, 함수 적용에 전에 작동함 -&gt; endpoint 실행되기 전에 미리 실행되는 것

        // return next.handle()을 실행하는 순간 -&gt; 라우트의 로직이 전부 실행되고 응답이 반환된다.
        // observable(응답을 받아서 자유롭게 변형이 가능한 것)로
        return next
              .handle() // 응답값 받을 수 있음
    }
}
.
.
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from &quot;@nestjs/common&quot;;
import { Observable } from &quot;rxjs&quot;; // rxjs는 nest.js 설치할 때부터 설치되어 있다.

@Injectable()
export class LogInterceptor implements NestInterceptor {

    intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Observable&lt;any&gt; {
        const request = context.switchToHttp().getRequest();
        const path = request.originalUrl;
        const now = new Date();
        console.log(`[REQ] ${path} ${now.toLocaleString(&#39;kr&#39;)}`);

        return next
            .handle()
            .pipe( // 원하는 rxjs의 함수들을 무한히 넣을 수 있다. 그리고 이 함수들은 응답에 대해서 순서대로 실행이 된다.
                tap( // 모니터링 기능
                  // observable은 handle에서 받은 응답값이 들어감, 여기서 response를 볼 수 있음
                  (observable) =&gt; console.log(observable),
                ),
              );
    }
}</code></pre>
<p>테스트를 위해서 posts 컨트롤러에 어노테이션을 붙입시다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Get()
@UseInterceptors(LogInterceptor) // 추가
getPosts(
      @Query() query: PaginatePostDto,
) {
      return this.postsService.paginatePosts(query);
}</code></pre>
<p>포스트맨으로 해당하는 API에 Get 요청을 보냅니다. </p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/8e2a7f2c-9efb-4e78-b9f5-985e3be0e7e4/image.png" alt=""></p>
<p>예상대로 <code>[REQ]</code>가 나오는 것을 알 수 있습니다. 이번에는 응답 형태를 바꿔보겠습니다. <code>map()</code>은 응답을 변형하는 기능입니다.</p>
<pre><code class="language-typescript">return next
    .handle()
    .pipe(
        tap(
              (observable) =&gt; console.log(observable),
        ),
        map( // 응답 변형하는 기능
              (observable) =&gt; {
                return {
                    message: &#39;응답이 변경 됐습니다. &#39;,
                    response: observable,
                }
              }
        ),
);</code></pre>
<p>응답이 변경된 것을 알 수 있습니다.</p>
<pre><code>{
    &quot;message&quot;: &quot;응답이 변경 됐습니다. &quot;, // 변경
    &quot;response&quot;: { // 변경
        &quot;data&quot;: [
            {
                &quot;id&quot;: 115,
                &quot;updatedAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
                &quot;createdAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
                &quot;title&quot;: &quot;제목&quot;,
                &quot;content&quot;: &quot;내용&quot;,
                &quot;likeCount&quot;: 0,
                &quot;commentCount&quot;: 0,
                &quot;author&quot;: {
                    &quot;id&quot;: 1,
                    &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                    &quot;nickname&quot;: &quot;codefactory&quot;,
                    &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                    &quot;role&quot;: &quot;USER&quot;
                },
                &quot;images&quot;: [
                    {
                        &quot;id&quot;: 4,
                        &quot;updatedAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
                        &quot;createdAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
                        &quot;order&quot;: 0,
                        &quot;type&quot;: 0,
                        &quot;path&quot;: &quot;/public\\posts\\e3989050-da50-49e3-9dc6-e99afe9612cc.png&quot;
                    }
                ]
            }
        ],
        &quot;cursor&quot;: {
            &quot;after&quot;: 115
        },
        &quot;count&quot;: 1,
        &quot;next&quot;: &quot;http://localhost:3000/posts?order__createdAt=DESC&amp;take=1&amp;where__id__less_than=115&quot;
    }
}</code></pre><p>만약에 tap()을 1번더 추가를 하게된다면 마지막 부분이 달라지는 것을 알 수 있습니다.</p>
<pre><code class="language-typescript">return next
    .handle()
    .pipe(
        tap(
              (observable) =&gt; console.log(observable),
        ),
        map(
            (observable) =&gt; {
                return {
                    message: &#39;응답이 변경 됐습니다. &#39;,
                    response: observable,
                }
            }
        ),
        tap(
              (observable) =&gt; console.log(observable),
        ),
);</code></pre>
<pre><code>[Nest] 3828  - 2024. 02. 16. 오전 12:19:58     LOG [NestApplication] Nest application successfully started +5ms
[REQ] /posts?order__createdAt=DESC&amp;take=1 2024. 2. 16. 오전 12:19:59
{
  data: [
    PostsModel {
      id: 115,
      updatedAt: 2024-02-13T14:04:28.383Z,
      createdAt: 2024-02-13T14:04:28.383Z,
      title: &#39;제목&#39;,
      content: &#39;내용&#39;,
      likeCount: 0,
      commentCount: 0,
      author: [UsersModel],
      images: [Array]
    }
  ],
  cursor: { after: 115 },
  count: 1,
  next: &#39;http://localhost:3000/posts?order__createdAt=DESC&amp;take=1&amp;where__id__less_than=115&#39;
}

// 이부분!!!!!!!!!!!!!!!
{
  message: &#39;응답이 변경 됐습니다. &#39;,
  response: {
    data: [ [PostsModel] ],
    cursor: { after: 115 },
    count: 1,
    next: &#39;http://localhost:3000/posts?order__createdAt=DESC&amp;take=1&amp;where__id__less_than=115&#39;
  }
}</code></pre><p>따라서 rxjs에서 제공해주는 함수들을 사용하면 순서대로 되는 것을 알 수 있고 <code>tap</code>의 경우 모니터링을, <code>map</code>의 경우 변형을 할 수 있게됩니다. 지금까지는 rxjs의 기본기 였습니다.</p>
<p>tap과 map을 삭제하고 원래 작성하려던 코드를 작성하겠습니다.</p>
<pre><code class="language-typescript">import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from &quot;@nestjs/common&quot;;
import { Observable, map, tap } from &quot;rxjs&quot;;

@Injectable()
export class LogInterceptor implements NestInterceptor {

    intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Observable&lt;any&gt; {

        const now = new Date();
        const request = context.switchToHttp().getRequest();
        const path = request.originalUrl;

        console.log(`[REQ] ${path} ${now.toLocaleString(&#39;kr&#39;)}`);
        return next
            .handle()
            .pipe( // 변경
                tap(
                  (observable) =&gt; console.log(`[REQ] ${path} ${new Date().toLocaleString(&#39;kr&#39;)} ${new Date().getMilliseconds() - now.getMilliseconds()}ms`),
            ),

        );
    }
}</code></pre>
<p>포트스맨으로 GET요청을 보내면 몇 초가 걸렸는지 나오게 됩니다.</p>
<pre><code>[REQ] /posts?order__createdAt=DESC&amp;take=1 2024. 2. 16. 오전 12:26:17
[REQ] /posts?order__createdAt=DESC&amp;take=1 2024. 2. 16. 오전 12:26:17 54ms</code></pre><p>이런 기능을 할 수 있는 것은 요청과 응답 모두 가로채기가 가능한 interceptor만 존재합니다. 만약 rxjs에 관심이 있으면 <a href="https://rxjs.dev/guide/operators">공식문서</a>를 참고하면 됩니다.</p>
<hr>
<h3 id="🖊️transaction-interceptor-생성">🖊️Transaction interceptor 생성</h3>
<p>posts 컨트롤러에서 트랜젝션 생성부분은 반드시 진행되어야 하는 부분입니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {    
      // interceptor request 적용
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    // 여기까지

    try {
          // postsPost함수만 적용
        const post = await this.postsService.createPost( 
              userId, body, queryRunner,
        );

        for (let i = 0; i &lt; body.images.length; i++) {
            await this.postsImagesService.createPostImage({
                post,
                order: i,
                path: body.images[i],
                type: ImageModelType.POST_IMAGE,
            }, queryRunner);
        }
          // 여기까지

          // interceptor response 적용
        await queryRunner.commitTransaction();
        await queryRunner.release();

        return this.postsService.getPostById(post.id);
    } catch (error) {
        await queryRunner.rollbackTransaction();
        await queryRunner.release();
        throw new InternalServerErrorException(&#39;에러 발생&#39;);
    }
      // 여기까지
}</code></pre>
<p>트랜젝션 인터셉터를 만들겠습니다.</p>
<ul>
<li>common/interceptor/transaction.interceptor.ts</li>
</ul>
<pre><code class="language-typescript">import { DataSource } from &#39;typeorm&#39;;
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from &quot;@nestjs/common&quot;;
import { Observable, tap } from &quot;rxjs&quot;;

@Injectable()
export class TransactionInterceptor implements NestInterceptor {

    constructor(
        private readonly dataSource: DataSource,
    ) {}

    async intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Observable&lt;any&gt; {
          const request = context.switchToHttp().getRequest();
          // post 컨트롤러 앞부분 내용 복사해서 가져오기
        const queryRunner = this.dataSource.createQueryRunner();
        await queryRunner.connect();
        await queryRunner.startTransaction();
    }
}
.
.
@Injectable()
export class TransactionInterceptor implements NestInterceptor {

    constructor(
        private readonly dataSource: DataSource,
    ) {}

    async intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Observable&lt;any&gt; {
          const request = context.switchToHttp().getRequest();
        const queryRunner = this.dataSource.createQueryRunner();
        await queryRunner.connect();
        await queryRunner.startTransaction();
          return next
               .handle()
               .pipe(
                    tap(async () =&gt; { // 여기!!!!
                         await queryRunner.commitTransaction();
                         await queryRunner.release();
                    })
               );
    }
}</code></pre>
<p>아래에 추가된 코드는 postsPost() 컨트롤러에서 정상적으로 모든것이 실행되는 것을 의미하는 코드입니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/2a4dfb80-d0b9-400f-acc4-f3ff5901b6e2/image.png" alt=""></p>
<p>에러가 발생할 수 있으니 에러가 발생할 경우 롤백을 하는 코드를 추가하겠습니다. 그리고 intercept 함수가 async로 구성되어있기 때문에 Promise로 감싸도록 하겠습니다.</p>
<pre><code class="language-typescript">@Injectable()
export class TransactionInterceptor implements NestInterceptor {

    constructor(
        private readonly dataSource: DataSource,
    ) {}

    async intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Promise&lt;Observable&lt;any&gt;&gt; {
          const request = context.switchToHttp().getRequest();
        const queryRunner = this.dataSource.createQueryRunner();
        await queryRunner.connect();
        await queryRunner.startTransaction();
          return next
               .handle()
               .pipe(
                      // 추가
                      catchError(
                         async (error) =&gt; {
                              await queryRunner.rollbackTransaction();
                              await queryRunner.release();
                              throw new InternalServerErrorException(error.message);
                         }
                    ),
                    tap(async () =&gt; {
                         await queryRunner.commitTransaction();
                         await queryRunner.release();
                    })
               );
    }
}</code></pre>
<p>현재 작업된 코드를 분석하면 postPost() 컨트롤러의 상단 3개는 </p>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {    
      // interceptor request 적용
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();</code></pre>
<p>트랜젝션 인터셉터 위부분 3줄과 동일한 과정을 진행합니다.</p>
<p>그리고 <code>return next.handle()</code>을 진입하면서 <code>postPost()</code> 메소드 내부 로직이 실행됩니다.</p>
<pre><code class="language-typescript">const post = await this.postsService.createPost( 
      userId, body, queryRunner,
);

for (let i = 0; i &lt; body.images.length; i++) {
    await this.postsImagesService.createPostImage({
        post,
        order: i,
        path: body.images[i],
        type: ImageModelType.POST_IMAGE,
    }, queryRunner);
}</code></pre>
<p>그리고 마지막 <code>postPost()</code> 부분은 <code>return next.handle()</code> 이후의 코드들에 해당됩니다.</p>
<pre><code class="language-typescript">    await queryRunner.commitTransaction();
    await queryRunner.release();

    return this.postsService.getPostById(post.id);
} catch (error) {
    await queryRunner.rollbackTransaction();
    await queryRunner.release();
    throw new InternalServerErrorException(&#39;에러 발생&#39;);
}</code></pre>
<p>그리고 <code>postPost()</code>내부 로직을 진행하려면 <code>queryRunner</code>를 넣어줘야 합니다. </p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/928516b9-cccd-40c8-8609-41a98c272e20/image.png" alt=""></p>
<p><code>request객체</code>는 <code>interceptor에서 계속 전달</code>됩니다. 따라서 <code>queryRunner를 생성하는 순간</code> 함수를 진행하는 순간 <code>request객체에 queryRunner를 넣어주는 것</code>입니다.</p>
<ul>
<li>common/interceptor/transaction.interceptor.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class TransactionInterceptor implements NestInterceptor {

    constructor(
        private readonly dataSource: DataSource,
    ) {}

    async intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Promise&lt;Observable&lt;any&gt;&gt; {
          const request = context.switchToHttp().getRequest();
        const queryRunner = this.dataSource.createQueryRunner();
        await queryRunner.connect();
        await queryRunner.startTransaction();
          request.queryRunner = queryRunner; // 내부 로직 실행시키기 위해서 queryRunner 넣어주기
          return next
               .handle()
               .pipe(
                      catchError(
                         async (error) =&gt; {
                              await queryRunner.rollbackTransaction();
                              await queryRunner.release();
                              throw new InternalServerErrorException(error.message);
                         }
                    ),
                    tap(async () =&gt; {
                         await queryRunner.commitTransaction();
                         await queryRunner.release();
                    })
               );
    }
}</code></pre>
<p>postPost()로 이동을 해서 트랜젝션 관련 코드는 전부 지우겠습니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(TransactionInterceptor) // 추가
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
) {
    const post = await this.postsService.createPost( 
          userId, body, queryRunner,
    );

    for (let i = 0; i &lt; body.images.length; i++) {
        await this.postsImagesService.createPostImage({
            post,
            order: i,
            path: body.images[i],
            type: ImageModelType.POST_IMAGE,
        }, queryRunner);
    }

    return this.postsService.getPostById(post.id);
}</code></pre>
<p>이제 queryRunner를 가져오는 코드를 작성하겠습니다. 물론 request객체를 받아서 넣어줄 수 있습니다. 이전에 <code>User decorator</code>를 만들었던 방법으로 <code>parameter decorator</code>를 만들겠습니다.</p>
<ul>
<li>common/decorator/query-runner.decorator.ts</li>
</ul>
<pre><code class="language-typescript">import { ExecutionContext, InternalServerErrorException } from &#39;@nestjs/common&#39;;
import { createParamDecorator } from &#39;@nestjs/common&#39;;

export const QueryRunner = createParamDecorator ((data, context: ExecutionContext) =&gt; {
    const req = context.switchToHttp().getRequest();
    if (!req.queryRunner) throw new InternalServerErrorException(`QueryRunner Decorator를 사용하려면 TransactionInterceptor를 적용해야 합니다.`,)
    return req.QueryRunner;
})</code></pre>
<p>컨트롤러에 코드를 QueryRunner를 불러오는 코드를 추가하겠습니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">import { DataSource, QueryRunner as QR } from &#39;typeorm&#39;;
import { QueryRunner } from &#39;src/common/decorator/query-runner.decorator&#39;;
.
.
@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(TransactionInterceptor)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
      @QueryRunner() queryRunner: QR,
) {
    const post = await this.postsService.createPost( 
          userId, body, queryRunner,
    );

    for (let i = 0; i &lt; body.images.length; i++) {
        await this.postsImagesService.createPostImage({
            post,
            order: i,
            path: body.images[i],
            type: ImageModelType.POST_IMAGE,
        }, queryRunner);
    }

    return this.postsService.getPostById(post.id);
}</code></pre>
<p>그런데 트랜잭션 타입에 따라서 커밋이 되기전에 <code>return this.postsService.getPostById(post.id);</code> 부분에서 최신값을 가져오지 못할 수 도 있습니다. 따라서 queryRunner를 넘기도록 하겠습니다.</p>
<p>그리고 컨트롤러 코드에 테스트를 위해서 에러를 터트리겠습니다.</p>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async getPostById(id: number, queryRunner?: QueryRunner) {
    const repository = this.getRepository(queryRunner);
    const post = await repository.findOne({
        ...DEFAULT_POST_AND_OPTIONS,
        where: {
              id,
        }
    });
    if (!post) throw new NotFoundException();
    return post;
}</code></pre>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">import { DataSource, QueryRunner as QR } from &#39;typeorm&#39;;
import { QueryRunner } from &#39;src/common/decorator/query-runner.decorator&#39;;
.
.
@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(TransactionInterceptor)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
      @QueryRunner() queryRunner: QR,
) {
    const post = await this.postsService.createPost( 
          userId, body, queryRunner,
    );

      throw new InternalServerErrorException(&#39;test&#39;) // 에러 발생

    for (let i = 0; i &lt; body.images.length; i++) {
        await this.postsImagesService.createPostImage({
            post,
            order: i,
            path: body.images[i],
            type: ImageModelType.POST_IMAGE,
        }, queryRunner);
    }

    return this.postsService.getPostById(post.id, queryRunner);
}</code></pre>
<p>포스트맨으로 로그인과 이미지까지 임시폴더에 저장을 합니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d770e209-5e3b-4855-bc11-6c1e304ed63f/image.png" alt=""></p>
<pre><code>{
    &quot;message&quot;: &quot;test&quot;,
    &quot;error&quot;: &quot;Internal Server Error&quot;,
    &quot;statusCode&quot;: 500
}</code></pre><p>Get 요청으로 확인을 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/e9bddeb8-437e-4144-a36f-c9c37147aef1/image.png" alt=""></p>
<p>마지막 데이터가 원래 149였는데 에러가 발생해서 데이터가 추가되지 않았습니다. 따라서 트랜젝션이 잘 실행되는 것을 알 수 있습니다.</p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 149,
            &quot;updatedAt&quot;: &quot;2024-02-15T13:07:54.548Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-15T13:07:54.548Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            },
            &quot;images&quot;: []
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: 149
    },
    &quot;count&quot;: 1,
    &quot;next&quot;: &quot;http://localhost:3000/posts?order__createdAt=DESC&amp;take=1&amp;where__id__less_than=149&quot;
}</code></pre><p>에러 코드를 삭제하고 다시 실행 후, 동일하게 포스트맨을 진행해봅시다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d4f17dfe-aa36-4645-a609-3baf2cc78825/image.png" alt=""></p>
<pre><code>{
    &quot;id&quot;: 150,
    &quot;updatedAt&quot;: &quot;2024-02-15T13:10:15.793Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-15T13:10:15.793Z&quot;,
    &quot;title&quot;: &quot;제목&quot;,
    &quot;content&quot;: &quot;내용&quot;,
    &quot;likeCount&quot;: 0,
    &quot;commentCount&quot;: 0,
    &quot;author&quot;: {
        &quot;id&quot;: 1,
        &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;nickname&quot;: &quot;codefactory&quot;,
        &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    },
    &quot;images&quot;: [
        {
            &quot;id&quot;: 5,
            &quot;updatedAt&quot;: &quot;2024-02-15T13:10:15.872Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-15T13:10:15.872Z&quot;,
            &quot;order&quot;: 0,
            &quot;type&quot;: 0,
            &quot;path&quot;: &quot;/public\\posts\\1e8aec81-f514-4f8a-b0d1-da643cc79122.png&quot;
        }
    ]
}</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Transaction]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Transaction</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Transaction</guid>
            <pubDate>Mon, 12 Feb 2024 12:19:59 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️transaction-이론">🖊️Transaction 이론</h3>
<p><code>Transaction</code></p>
<p>A model, B model
Post API -&gt; A 모델을 저장하고, B 모델을 저장한다.</p>
<pre><code class="language-typescript">await repository.save(a);
await repository.save(b); // 이경우는 순서대로 진행이 된다.</code></pre>
<p>만약에 a를 저장하다가 실패하면 b를 저장하면 안될경우 -&gt; <code>all or nothing</code></p>
<pre><code>transaction
start -&gt; 시작 (실행하는 모든 기능은 transaction에 묶인다)
commit -&gt; 저장 (한번에 저장이 된다.)
rollback -&gt; 원상복구 (start를 하고 commit 전까지 문제가 발생하면 원상복구를 한다.)</code></pre><hr>
<h3 id="🖊️imagemodel-만들기">🖊️ImageModel 만들기</h3>
<p>image는 1개보다는 여러개를 올릴 수 있도록 바꿔보겠습니다. 여러개를 올리므로써 transaction관련 코드를 작성할 수 있습니다. transaction으로 post를 생성하는 것과 이미지를 생성하고 옮기는 코드까지 전부 묶어서 1개라도 에러가 발생하면 rollback하도록 하겠습니다.</p>
<p><code>posts.entity.ts</code>에서 <code>image</code>필드를 전부 제거합니다. 데이터베이스 컬럼에도 사라졌습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/6a340289-330f-483a-8c54-35142e10857a/image.png" alt=""></p>
<p>1개의 Post에는 여러개의 Image가 붙을 수 있습니다. 따라서 ImagesModel을 만들고 PostsModel에서 <code>@OneToMany</code>로 묶어줍니다.</p>
<p>여기서는 image가 공유된다는 가정하에 common 폴더에서 작업을 하겠습니다.</p>
<p>여기서 <code>@Transform</code>에서 <code>value</code>는 path에 입력된 값 자체를 의미하고, <code>obj</code>는 ImageModel이 instance화가 되었을 때를 의미합니다.</p>
<ul>
<li>image.entity.ts</li>
</ul>
<pre><code class="language-typescript">export enum ImageModelType {
     POST_IMAGE,
}

@Entity()
export class ImageModel extends BaseModel {

    @Column({
          default: 0, // order가 FE로부터오면 그대로 반영, FE로부터 아무런 값이 오지않으면 0하고 생성된 순서대로 만들 것
    })
    @IsInt()
    @IsOptional()
    order: number;

    // UsersModel -&gt; 사용자 프로필 이미지
    // PostsModel -&gt; 포스트 이미지
    @Column({
          enum: ImageModelType
    })
    @IsString()
    @IsEnum(ImageModelType)
    type: ImageModelType;

    @Column()
    @IsString()
    @Transform(({value, obj}) =&gt; { // obj: 현재 객체, ImageModel이 instance로 되었을 때
        if (obj.type === ImageModelType.POST_IMAGE) {
            return join(
                POST_IMAGE_PATH, // POST_IMAGE_PATH 경로에 path를 추가
                value,
            )
        } else {
              return value;
        }
    })
    path: string;
}</code></pre>
<p>이제 연관관계를 추가하겠습니다. post는 null이 가능하도록 해야합니다. 왜냐하면 post랑만 연동되는 것이 아닌 user랑만 연동이 될 수 있기 때문입니다.</p>
<ul>
<li>image.entity.ts</li>
</ul>
<pre><code class="language-typescript">export enum ImageModelType {
     POST_IMAGE,
}

@Entity()
export class ImageModel extends BaseModel {

    @Column({
          default: 0,
    })
    @IsInt()
    @IsOptional()
    order: number;

    @Column({
          enum: ImageModelType
    })
    @IsString()
    @IsEnum(ImageModelType)
    type: ImageModelType;

    @Column()
    @IsString()
    @Transform(({value, obj}) =&gt; {
        if (obj.type === ImageModelType.POST_IMAGE) {
            return join(
                POST_IMAGE_PATH,
                value,
            )
        } else {
              return value;
        }
    })
    path: string;

    @ManyToOne((type) =&gt; PostsModel, (post) =&gt; post.images)
    post?: PostsModel; // 추가
}</code></pre>
<ul>
<li>posts.entity.ts</li>
</ul>
<pre><code class="language-typescript">@Entity()
export class PostsModel extends BaseModel {

    @ManyToOne(() =&gt; UsersModel, (user) =&gt; user.posts, {
          nullable: false,
    })
    author: UsersModel;

    @Column()
    @IsString({
          message: stringValidationMessage
    }) 
    title: string;

    @Column()
    @IsString({
          message: stringValidationMessage
    }) 
    content: string;

    @Column()
    likeCount: number;

    @Column()
    commentCount: number;

    @OneToMany((type) =&gt; ImageModel, (image) =&gt; image.post)
    images: ImageModel[]; // 추가
}</code></pre>
<p>마지막으로 <code>app.module.ts</code>에 <code>ImageModel</code>을 추가합니다.</p>
<ul>
<li>app.module.ts</li>
</ul>
<pre><code class="language-typescript">TypeOrmModule.forRoot({
    type: &#39;postgres&#39;,
    host: process.env[ENV_DB_HOST_KEY],
    port: parseInt(process.env[ENV_DB_PORT_KEY]),
    username: process.env[ENV_DB_USERNAME_KEY],
    password: process.env[ENV_DB_PASSWORD_KEY],
    database: process.env[ENV_DB_DATABASE_KEY],
    entities: [
        PostsModel,
        UsersModel,
        ImageModel, // 추가
    ],
    synchronize: true,
}),</code></pre>
<p>만약 여기까지 진행을 했는데도 다음과 같은 에러가 나온 경우 <code>dist</code>폴더를 삭제하고 재실행하면 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a77fcf9b-b2dc-45df-b567-df1e237ba34f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/2b34a206-18a7-4963-b440-5b518b57be65/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/ce254a35-0f15-41c9-9fa7-f09bb0f371e6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/bcda9adf-1fa7-4769-bec6-2034982f0ecc/image.png" alt=""></p>
<hr>
<h3 id="🖊️imagemodel-생성하는-로직-작성">🖊️ImageModel 생성하는 로직 작성</h3>
<ul>
<li>posts/dto/create-post.dto.ts</li>
</ul>
<pre><code class="language-typescript">export class CreatePostDto extends PickType(PostsModel, [&#39;title&#39;, &#39;content&#39;]) {

    @IsString({
          each: true, // 리스트 안에있는 것들을 검증 해야한다.
    })
    @IsOptional()
    images: string[] = []; // 아무것도 없는 경우 empty array
}</code></pre>
<p>여러개의 이미지들을 받을 수 있도록 바꿔줍니다. 또한 리스트에 들어있는 각각의 이미지를 검증해야하기 때문에 <code>each: true</code>를 붙여줍니다.</p>
<p>컨트롤러로 이동 후 몇가지를 변경하겠습니다. 트랜잭션을 진행할 때, post 관련 작업을 먼저 하고, 그 다음에 post가 잘 생성이 되면 image작업을 해야됩니다. 왜냐하면 이미지 작업을 할 때, 이미지를 옮기는 것이 있기 때문입니다. 즉, 이미지를 옮기는 과정을 가장 마지막에 할 것입니다.</p>
<p>왜냐하면 트랜젝션에는 롤백 기능이 있기 때문입니다. 따라서 데이터베이스와 관련이 있는 작업을 먼저 진행하고, 나중에 관련이 없는 파일을 옮기는 것과 같은 것을 마지막에 작업하도록 하겠습니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const post = await this.postsService.createPost( // 위치 변경
          userId, body,
    );

    await this.postsService.createPostImage(body); // temp -&gt; posts
}
.
.
변경
.
.
@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const post = await this.postsService.createPost(
          userId, body,
    );

      // 추가
    for (let i = 0; i &lt; body.images.length; i++) {
          await this.postsService.createPostImage(body);
    }
}</code></pre>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async generatePosts(userId: number) {
    for(let i = 0; i &lt; 100; i++) {
        await this.createPost(userId, {
            title: `임의로 생성된 ${i}`,
            content: `임의로 생성된 포수트 내용 ${i}`,
            images: [], // 추가
        });
    }
}</code></pre>
<p>그리고 image생성과 관련해서 dto를 추가로 만들도록 하겠습니다. 왜냐하면 지금 <code>createPostImage()</code>는 image가 1개만 되기 때문입니다.</p>
<ul>
<li>posts/image/dto/create-image.dto.ts</li>
</ul>
<pre><code class="language-typescript">export class CreatePostImageDto extends PickType(ImageModel, [
     &#39;path&#39;,
     &#39;post&#39;,
     &#39;order&#39;,
     &#39;type&#39;,
]) {}</code></pre>
<p>post의 경우는 컨트롤러에서 직접 주입 받도록 하겠습니다.</p>
<pre><code class="language-typescript">@Entity()
export class ImageModel extends BaseModel {

     @Column({
          default: 0,
     })
     @IsInt()
     @IsOptional()
     order: number;

     @Column({
          enum: ImageModelType
     })
     @IsString()
     @IsEnum(ImageModelType)
     type: ImageModelType;

     @Column()
     @IsString()
     @Transform(({value, obj}) =&gt; {
          if (obj.type === ImageModelType.POST_IMAGE) {
               return join(
                    POST_IMAGE_PATH,
                    value,
               )
          } else {
               return value;
          }
     })
     path: string;

     @ManyToOne((type) =&gt; PostsModel, (post) =&gt; post.images)
     post?: PostsModel; // 이거는 직접 주입받자!!
}</code></pre>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const post = await this.postsService.createPost(
          userId, body,
    ); // 여기서 post를 직접 주입받는다!

    for (let i = 0; i &lt; body.images.length; i++) {
          await this.postsService.createPostImage(body);
    }
}</code></pre>
<p>다시 서비스로 이동해서 코드를 작성하겠습니다. 또한 이미지 모델을 생성해야하기 때문에 주입을 받도록 하겠습니다. 그리고 posts.module.ts에 imports를 하겠습니다.</p>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">constructor(
    @InjectRepository(PostsModel)
    private readonly postsRepository: Repository&lt;PostsModel&gt;,
    @InjectRepository(ImageModel)
      private readonly imageRepository: Repository&lt;ImageModel&gt;,
    private readonly commonService: CommonService,
    private readonly configService: ConfigService,
) {}
.
.
async createPostImage(dto: CreatePostImageDto) { // 변경
    const tempFilePath = join(
        TEMP_FOLDER_PATH,
        dto.path, // 변경 : 왜냐하면 CreatePostImageDto는 각각의 하나가 image 생성
    );

    try {
          await promises.access(tempFilePath);
    } catch (error) {
          throw new BadRequestException(&#39;존재하지 않는 파일입니다. &#39;);
    }

    const fileName = basename(tempFilePath);
    const newPath = join(
        POST_IMAGE_PATH,
        fileName,
    );

    await promises.rename(tempFilePath, newPath);
    return true;
}</code></pre>
<ul>
<li>posts.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
            PostsModel,
            ImageModel, // 추가
        ]),
        AuthModule,
        UsersModule,
        CommonModule,
    ],
    controllers: [PostsController],
    providers: [PostsService],
    exports: [PostsService]
})
export class PostsModule {}</code></pre>
<p>그리고 createPostImage()안에서 image 모델은 언제 저장하는 것이 가장 좋은지를 생각해야합니다. 무엇인가를 만들때 파일을 temp에서 이동 시키기전에 저장하는 것이 좋다고 생각합니다.</p>
<p>왜냐하면 만약 파일을 이동시키고 에러가 발생하면 다시 원상복구를 해야해서 번거롭기 때문입니다.</p>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async createPostImage(dto: CreatePostImageDto) {
    const tempFilePath = join(
        TEMP_FOLDER_PATH,
        dto.path,
    );

    try {
          await promises.access(tempFilePath);
    } catch (error) {
          throw new BadRequestException(&#39;존재하지 않는 파일입니다. &#39;);
    }

    const fileName = basename(tempFilePath);
    const newPath = join(
        POST_IMAGE_PATH,
        fileName,
    );

    // save
    const result = await this.imageRepository.save({
          ...dto, // create하고 save도 가능하고 바로 넣어도 상관없음
    });

    await promises.rename(tempFilePath, newPath);
    return result; // 변경
}</code></pre>
<p>그리고 createPost() 또한 바꿔주도록 하겠습니다. 왜냐하면 image가 CreatePostImage와 ImageModel이 호환 되지않기 때문에 에러가 발생합니다. posts의 엔티티로 가게 되면 image는 리스트 타입으로 되어있습니다. 따라서 <code>images: []</code> 이렇게 만들어 줍니다.</p>
<p>그리고 createPost를 할 때는 Post 자체만 생성을 하고, 이후에 createPostImage()를 하면서 이미지를 생성합니다. 이때 <code>save</code> 부분에서 image를 저장하는 동시에 자동으로 post와 연결이 됩니다. 왜냐하면 PickType으로 상속을 받기 때문입니다.</p>
<pre><code class="language-typescript">async createPost(authorId: number, postDto: CreatePostDto ) {
    const post = this.postsRepository.create({
        author: {
              id: authorId,
        },
        ...postDto,
        images: [], // createPostImage()를 실행하면 post를 생성하고 image가 생성되면서 자동으로 연결이 된다.
        likeCount: 0,
        commentCount: 0,
    });

    const newPost = await this.postsRepository.save(post);
    return newPost;
}

async createPostImage(dto: CreatePostImageDto) {
    const tempFilePath = join(
        TEMP_FOLDER_PATH,
        dto.path,
    );

    try {
        await promises.access(tempFilePath);
    } catch (error) {
          throw new BadRequestException(&#39;존재하지 않는 파일입니다. &#39;);
    }

    const fileName = basename(tempFilePath);
    const newPath = join(
        POST_IMAGE_PATH,
        fileName,
    );

    // save
    const result = await this.imageRepository.save({
          ...dto,
    });

    await promises.rename(tempFilePath, newPath);
    return result;
}</code></pre>
<p>컨트롤러를 변경해줍니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
      // 현재 image없이 post만 생성된 상태
    const post = await this.postsService.createPost(
          userId, body,
    );

      // 루핑하면서 image 생성 -&gt; 동시에 post와 연동된다.
    for (let i = 0; i &lt; body.images.length; i++) {
        await this.postsService.createPostImage({
            post,
            order: i,
            path: body.images[i],
            type: ImageModelType.POST_IMAGE,
        });
    }

      // 생성된 post id 반환
      return this.postsService.getPostById(post.id);
}</code></pre>
<p>포스트맨으로 테스트를 해보겠습니다. 먼저 로그인을 하고 1개의 image만 해보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a638bd6b-ad57-4708-bcbe-f0b5a2710d46/image.png" alt=""></p>
<pre><code>{
    &quot;fileName&quot;: &quot;6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png&quot;
}</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/538c9f04-9a85-4c7e-b2d2-03ac5f31b43c/image.png" alt=""></p>
<pre><code>{
    &quot;id&quot;: 112,
    &quot;updatedAt&quot;: &quot;2024-02-13T05:52:20.390Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-13T05:52:20.390Z&quot;,
    &quot;title&quot;: &quot;제목&quot;,
    &quot;content&quot;: &quot;내용&quot;,
    &quot;likeCount&quot;: 0,
    &quot;commentCount&quot;: 0,
    &quot;author&quot;: {
        &quot;id&quot;: 1,
        &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;nickname&quot;: &quot;codefactory&quot;,
        &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    }
}</code></pre><p>이번에는 1개의 post에 여러개의 이미지를 생성해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9a2a27ce-9853-44de-aa5e-3d471ac5fc60/image.png" alt=""></p>
<pre><code>{
    &quot;id&quot;: 113,
    &quot;updatedAt&quot;: &quot;2024-02-13T05:54:05.084Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-13T05:54:05.084Z&quot;,
    &quot;title&quot;: &quot;제목&quot;,
    &quot;content&quot;: &quot;내용&quot;,
    &quot;likeCount&quot;: 0,
    &quot;commentCount&quot;: 0,
    &quot;author&quot;: {
        &quot;id&quot;: 1,
        &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;nickname&quot;: &quot;codefactory&quot;,
        &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    }
}</code></pre><p>get 요청으로 확인해보도록 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9a5fef09-8312-467b-aee5-b34477dfe277/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 113,
            &quot;updatedAt&quot;: &quot;2024-02-13T05:54:05.084Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T05:54:05.084Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },
        {
            &quot;id&quot;: 112,
            &quot;updatedAt&quot;: &quot;2024-02-13T05:52:20.390Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T05:52:20.390Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },</code></pre><p>image가 보이지 않습니다. 이 문제는 이전에도 여러번 겪은 문제입니다. 왜냐하면 이제는 post 엔티티는 author과 images 모두 relation이기 때문입니다. 이후에 다시 포스트맨으로 요청을 해보겠습니다.</p>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async getPostById(id: number) {
    const post = await this.postsRepository.findOne({
        where: {
              id,
        },
        relations: [
            &#39;author&#39;,
            &#39;images&#39; // 추가
        ],
    });
    if (!post) throw new NotFoundException();
    return post;
}
.
.
async paginatePosts(dto: PaginatePostDto) {
    return this.commonService.paginate(
        dto,
        this.postsRepository,
        {
              relations: [&#39;author&#39;, &#39;images&#39;] // 추가
        },
        &#39;posts&#39;
    );
}</code></pre>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 113,
            &quot;updatedAt&quot;: &quot;2024-02-13T05:54:05.084Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T05:54:05.084Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            },
            &quot;images&quot;: [
                {
                    &quot;id&quot;: 2,
                    &quot;updatedAt&quot;: &quot;2024-02-13T05:54:05.155Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-02-13T05:54:05.155Z&quot;,
                    &quot;order&quot;: 0,
                    &quot;type&quot;: 0,
                    &quot;path&quot;: &quot;C:\\workspace\\nestjsworkspace\\df_sns_2\\public\\posts\\0a314f62-3bfe-4e2b-8e49-925fbceacf70.png&quot;
                },
                {
                    &quot;id&quot;: 3,
                    &quot;updatedAt&quot;: &quot;2024-02-13T05:54:05.226Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-02-13T05:54:05.226Z&quot;,
                    &quot;order&quot;: 1,
                    &quot;type&quot;: 0,
                    &quot;path&quot;: &quot;C:\\workspace\\nestjsworkspace\\df_sns_2\\public\\posts\\3d651d58-2939-409f-afc0-6a109816f906.png&quot;
                }
            ]
        },
        {
            &quot;id&quot;: 112,
            &quot;updatedAt&quot;: &quot;2024-02-13T05:52:20.390Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T05:52:20.390Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            },
            &quot;images&quot;: [
                {
                    &quot;id&quot;: 1,
                    &quot;updatedAt&quot;: &quot;2024-02-13T05:52:20.487Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-02-13T05:52:20.487Z&quot;,
                    &quot;order&quot;: 0,
                    &quot;type&quot;: 0,
                    &quot;path&quot;: &quot;C:\\workspace\\nestjsworkspace\\df_sns_2\\public\\posts\\6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png&quot;
                }
            ]
        },</code></pre><p>public부터 경로가 오도록 만들어 보겠습니다.</p>
<ul>
<li>image.entity.ts</li>
</ul>
<pre><code class="language-typescript">@Entity()
export class ImageModel extends BaseModel {

    @Column({
          default: 0,
    })
    @IsInt()
    @IsOptional()
    order: number;

    @Column({
          enum: ImageModelType
    })
    @IsString()
    @IsEnum(ImageModelType)
    type: ImageModelType;

    @Column()
    @IsString()
    @Transform(({value, obj}) =&gt; {
        if (obj.type === ImageModelType.POST_IMAGE) {
            return `/${join(
                POST_PUBLIC_IMAGE_PATH, // POST_IMAGE_PATH 경로에 path를 추가
                value,
            )}`
        } else {
              return value;
        }
    })
    path: string;

    @ManyToOne((type) =&gt; PostsModel, (post) =&gt; post.images)
    post?: PostsModel;
}</code></pre>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 113,
            &quot;updatedAt&quot;: &quot;2024-02-13T05:54:05.084Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T05:54:05.084Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            },
            &quot;images&quot;: [
                {
                    &quot;id&quot;: 2,
                    &quot;updatedAt&quot;: &quot;2024-02-13T05:54:05.155Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-02-13T05:54:05.155Z&quot;,
                    &quot;order&quot;: 0,
                    &quot;type&quot;: 0,
                    &quot;path&quot;: &quot;/public\\posts\\0a314f62-3bfe-4e2b-8e49-925fbceacf70.png&quot; // 변경
                },
                {
                    &quot;id&quot;: 3,
                    &quot;updatedAt&quot;: &quot;2024-02-13T05:54:05.226Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-02-13T05:54:05.226Z&quot;,
                    &quot;order&quot;: 1,
                    &quot;type&quot;: 0,
                    &quot;path&quot;: &quot;/public\\posts\\3d651d58-2939-409f-afc0-6a109816f906.png&quot; // 변경
                }
            ]
        },
        {
            &quot;id&quot;: 112,
            &quot;updatedAt&quot;: &quot;2024-02-13T05:52:20.390Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T05:52:20.390Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            },
            &quot;images&quot;: [
                {
                    &quot;id&quot;: 1,
                    &quot;updatedAt&quot;: &quot;2024-02-13T05:52:20.487Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-02-13T05:52:20.487Z&quot;,
                    &quot;order&quot;: 0,
                    &quot;type&quot;: 0,
                    &quot;path&quot;: &quot;/public\\posts\\6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png&quot; // 변경
                }
            ]
        },</code></pre><pre><code>/public\\posts\\6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png
http://localhost:3000/public//posts//6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png</code></pre><p>잘 나오는 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/5c4f40a3-f6a1-4c19-8407-b509f60a5325/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a05b3342-4333-4bd9-bc9a-4ccffb205688/image.png" alt=""></p>
<p>지금부터는 서비스 코드에 고정으로 들어가게 되는 값들이 있습니다. 이것을 정리하도록 하겠습니다.</p>
<ul>
<li>posts/const/default-post-find-options.const.ts</li>
</ul>
<pre><code class="language-typescript">import { FindManyOptions } from &quot;typeorm&quot;;
import { PostsModel } from &quot;../entities/posts.entity&quot;;

export const DEFAULT_POST_AND_OPTIONS: FindManyOptions&lt;PostsModel&gt; = {
    // relations: [
    //      &#39;author&#39;, 
    //      &#39;images&#39;
    // ] 이것도 가능하다
    relations: {
        author: true,
        images: true,
    }
}</code></pre>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async getAllPosts() {
    return await this.postsRepository.find({
          ...DEFAULT_POST_AND_OPTIONS,
    });
}

async paginatePosts(dto: PaginatePostDto) {
    return this.commonService.paginate(
        dto,
        this.postsRepository,
        {
              ...DEFAULT_POST_AND_OPTIONS
        },
        &#39;posts&#39;
    );
}

async getPostById(id: number) {
    const post = await this.postsRepository.findOne({
          ...DEFAULT_POST_AND_OPTIONS,
        where: {
              id,
        },
    });
    if (!post) throw new NotFoundException();
    return post;
}</code></pre>
<p>이렇게 함으로써 매번 변경할 필요 없이 <code>DEFAULT_POST_AND_OPTIONS</code>에서 변경을 하면 됩니다.</p>
<hr>
<h3 id="🖊️transaction-시작">🖊️Transaction 시작</h3>
<p>이제부터는 트랜젝션을 사용하지 않으면 발생하는 전형적인 문제에 대해서 알아보겠습니다. post를 생성하는 코드와 이미지를 만드는 코드를 보면 각각 전부 따로 놀고 있습니다. 강제로 에러를 줘보도록 하겠습니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const post = await this.postsService.createPost(
          userId, body,
    );

      throw new InternalServerErrorException(&#39;@@@@@@@@&#39;) // 강제 에러 발생

    for (let i = 0; i &lt; body.images.length; i++) {
        await this.postsService.createPostImage({
            post,
            order: i,
            path: body.images[i],
            type: ImageModelType.POST_IMAGE,
        });
    }

    return this.postsService.getPostById(post.id);
}</code></pre>
<p>그리고 포스트맨으로 보내보도록 하겠습니다. 로그인을 진행하고 /common/image로 temp폴더에 임시 저장까지 한 뒤, /post를 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/3c2260c1-e3a3-4098-8af6-bb416a805292/image.png" alt=""></p>
<pre><code>{
    &quot;message&quot;: &quot;@@@@@@@@&quot;,
    &quot;error&quot;: &quot;Internal Server Error&quot;,
    &quot;statusCode&quot;: 500
}</code></pre><p>하지만 post 전체 조호를 클릭하면 다음과 같이 나옵니다. 이 데이터는 존재하면 안되는 데이터 입니다. post까지는 생성을 했는데 이미지를 생성하지 못한것 즉, 제대로 작업되지 않은 것입니다. 따라서 이전 상태로 롤백이 되어야합니다. 버그입니다.</p>
<p>이제 이 기능들을 1개로 묶어서 트랜젝션으로 관리하도록 하겠습니다. 강제로 발생시킨 에러를 다시 지워줍니다.</p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 114,
            &quot;updatedAt&quot;: &quot;2024-02-13T13:21:40.868Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T13:21:40.868Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            },
            &quot;images&quot;: []
        },</code></pre><p>컨트롤러에서 DataSource를 가져옵니다. DataSource는 nest.js 패키지안에 들어있습니다. DataSource를 만들고 release까지 진행이 되어야합니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">constructor(
    private readonly postsService: PostsService,
    private readonly dataSource: DataSource, // 추가
) {}

@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    // 트랜젝션과 관련된 모든 쿼리를 담당할 쿼리 러너를 생성한다.
    const queryRunner = this.dataSource.createQueryRunner();

    // 쿼리 러너에 연결한다.
    await queryRunner.connect();

    // 쿼리 러너에서 트랜젝션을 시작한다.
    // 이 시점부터 같은 쿼리 러너를 사용하면
    // 트랜젝션 안에서 데이터베이스 액션을 실행 할 수 있다.
    await queryRunner.startTransaction();

    // 로직 실행 -&gt; tryCatch 내부에 사용하기
    try {
      const post = await this.postsService.createPost( 
        userId, body,
      );

      for (let i = 0; i &lt; body.images.length; i++) {
        await this.postsService.createPostImage({
          post,
          order: i,
          path: body.images[i],
          type: ImageModelType.POST_IMAGE,
        });
      }

      // 정상적으로 진행된 경우, commit -&gt; release
      await queryRunner.commitTransaction();
      await queryRunner.release();

      return this.postsService.getPostById(post.id);
    } catch (error) {
      // 어떤 에러든 에러가 던져지면 트랜젝션을 종료하고 워래 상태로 되돌린다.
      await queryRunner.rollbackTransaction();
      await queryRunner.release();
    }
}</code></pre>
<p>하지만 아직 쿼리 러너의 모든 기능이 트랜젝션이 묶이지 않습니다. <code>같은 쿼리러너</code>를 사용해야만 트랜젝션에 묶이는 것입니다.</p>
<p>따라서 같은 쿼리러너를 사용한 경우에는 같은 저장소에서 가져오도록 만들고, 아닌 경우는 주입을 받았던 레포지토리를 사용하도록 만들겠습니다.</p>
<ul>
<li>psots.service.ts</li>
</ul>
<pre><code class="language-typescript">getRepository(queryRunner?: QueryRunner) {
    // queryRunner가 있는 경우에는 queryRunner 저장소만 사용
    // 아니면 주입받은 저장소 사용
    return queryRunner ? queryRunner.manager.getRepository&lt;PostsModel&gt;(PostsModel) : this.postsRepository;
}</code></pre>
<pre><code class="language-typescript">getRepository(queryRunner?: QueryRunner) {
    // queryRunner가 있는 경우에는 queryRunner 저장소만 사용
    // 아니면 주입받은 저장소 사용
    return queryRunner ? queryRunner.manager.getRepository&lt;PostsModel&gt;(PostsModel) : this.postsRepository;
}

async createPost(authorId: number, postDto: CreatePostDto, queryRunner?: QueryRunner) {
    const repository = this.getRepository(queryRunner); // 추가

    const post = repository.create({ // 변경
        author: {
          id: authorId,
        },
        ...postDto,
        images: [],
        likeCount: 0,
        commentCount: 0,
    });

    const newPost = await repository.save(post); // 변경
    return newPost;
}</code></pre>
<hr>
<h3 id="🖊️transaction-적용-및-테스트">🖊️Transaction 적용 및 테스트</h3>
<p>이제 <code>createPost</code>에서는 queryRunner를 파라미터로 값을 받습니다. 따라서 컨트롤러에 queryRunner를 추가하도록 하겠습니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const post = await this.postsService.createPost( 
        userId, body, queryRunner, // 추가
      );

      for (let i = 0; i &lt; body.images.length; i++) {
        await this.postsService.createPostImage({
          post,
          order: i,
          path: body.images[i],
          type: ImageModelType.POST_IMAGE,
        });
      }

      await queryRunner.commitTransaction();
      await queryRunner.release();

      return this.postsService.getPostById(post.id);
    } catch (error) {
      await queryRunner.rollbackTransaction();
      await queryRunner.release();
    }
}</code></pre>
<p>그리고 queryRunner를 필요로 하는 createPostImage 서비스 메소드도 바꾸도록 하겠습니다. createPostImage서비스 코드를 보면 imageRepository를 사용하는 공간이 있습니다. 근데 여기서 우리가 queryRunner를 만들고 하면 복잡해집니다. post관련된 기능과 postImage관련된 기능이 섞이기 때문에 image 폴더에 post의 image기능을 담당하는 코드를 작성하겠습니다.</p>
<p>먼저 posts.service.ts에 있는 <code>createPostImage()</code> 내부의 로직을 그대로 옮겨 붙이고 posts.service.ts에 있는 <code>createPostImage()</code>는 제거하겠습니다.</p>
<p>그리고 추가로 queryRunner를 파라미터로 받겠습니다.</p>
<ul>
<li>image/images.service.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class PostsImagesService{

    constructor(
        @InjectRepository(ImageModel)
        private readonly imageRepository: Repository&lt;ImageModel&gt;,
    ){}

      getRepository(queryRunner?: QueryRunner) {
          return queryRunner ? queryRunner.manager.getRepository&lt;ImageModel&gt;(ImageModel) : this.imageRepository;
     }

    async createPostImage(dto: CreatePostImageDto, queryRunner?: QueryRunner) {
          const repository = this.getRepository(queryRunner); // 추가
        const tempFilePath = join(
            TEMP_FOLDER_PATH,
            dto.path,
        );

        try {
              await promises.access(tempFilePath);
        } catch (error) {
              throw new BadRequestException(&#39;존재하지 않는 파일입니다. &#39;);
        }

        const fileName = basename(tempFilePath);
        const newPath = join(
            POST_IMAGE_PATH,
            fileName,
        );

        const result = await repository.save({ // 변경
              ...dto,
        });

        await promises.rename(tempFilePath, newPath);
        return result;
    }
}</code></pre>
<p>그리고 posts의 컨트롤러로 이동을 해서 postsImagesService를 주입하고 module에서도 provider로 제공을 하도록 하겠습니다.</p>
<ul>
<li>posts.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        TypeOrmModule.forFeature([
            PostsModel,
            ImageModel,
        ]),
        AuthModule,
        UsersModule,
        CommonModule,
    ],
    controllers: [PostsController],
    providers: [PostsService, PostsImagesService,],
    exports: [PostsService]
})
export class PostsModule {}</code></pre>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Controller(&#39;posts&#39;)
export class PostsController {
    constructor(
        private readonly postsService: PostsService,
        private readonly postsImagesService: PostsImagesService, // 추가
        private readonly dataSource: DataSource,
    ) {}
.
.
@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
      const queryRunner = this.dataSource.createQueryRunner();
      await queryRunner.connect();
      await queryRunner.startTransaction();

      try {
          const post = await this.postsService.createPost( 
                userId, body, queryRunner,
          );

          for (let i = 0; i &lt; body.images.length; i++) {
              await this.postsImagesService.createPostImage({ // 변경
                  post,
                  order: i,
                  path: body.images[i],
                  type: ImageModelType.POST_IMAGE,
              }, queryRunner); // 추가
          }

          await queryRunner.commitTransaction();
          await queryRunner.release();

          return this.postsService.getPostById(post.id);
      } catch (error) {
          await queryRunner.rollbackTransaction();
          await queryRunner.release();
      }
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다. 로그인을 하고 임시 폴더에 이미지를 저장까지 해놓고 진행하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/5a45ba3a-7b2c-4c36-acf8-d442ed7476f8/image.png" alt=""></p>
<pre><code>{
    &quot;id&quot;: 115,
    &quot;updatedAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
    &quot;title&quot;: &quot;제목&quot;,
    &quot;content&quot;: &quot;내용&quot;,
    &quot;likeCount&quot;: 0,
    &quot;commentCount&quot;: 0,
    &quot;author&quot;: {
        &quot;id&quot;: 1,
        &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
        &quot;nickname&quot;: &quot;codefactory&quot;,
        &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
        &quot;role&quot;: &quot;USER&quot;
    },
    &quot;images&quot;: [
        {
            &quot;id&quot;: 4,
            &quot;updatedAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
            &quot;order&quot;: 0,
            &quot;type&quot;: 0,
            &quot;path&quot;: &quot;/public\\posts\\e3989050-da50-49e3-9dc6-e99afe9612cc.png&quot;
        }
    ]
}</code></pre><p>115번입니다. get 요청을 해도 115번으로 나오고 있습니다.</p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 115,
            &quot;updatedAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            },
            &quot;images&quot;: [
                {
                    &quot;id&quot;: 4,
                    &quot;updatedAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
                    &quot;order&quot;: 0,
                    &quot;type&quot;: 0,
                    &quot;path&quot;: &quot;/public\\posts\\e3989050-da50-49e3-9dc6-e99afe9612cc.png&quot;
                }
            ]
        },</code></pre><p>이번에는 중간에 에러를 만들겠습니다.</p>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
        const post = await this.postsService.createPost( 
              userId, body, queryRunner,
        );

        throw new InternalServerErrorException(&#39;!!!!!!!!!!!!!!!!!!!!!!!&#39;);

        for (let i = 0; i &lt; body.images.length; i++) {
            await this.postsImagesService.createPostImage({
                post,
                order: i,
                path: body.images[i],
                type: ImageModelType.POST_IMAGE,
            }, queryRunner);
        }

        await queryRunner.commitTransaction();
        await queryRunner.release();

        return this.postsService.getPostById(post.id);
    } catch (error) {
        await queryRunner.rollbackTransaction();
        await queryRunner.release();
          throw new InternalServerErrorException(&#39;에러 발생&#39;);
    }
}</code></pre>
<p>똑같이 로그인을 하고 이미지를 임시 폴더에 저장하고 진행하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a83d5e7c-dd5e-441d-b0a6-63d7392b3dc9/image.png" alt=""></p>
<pre><code>{
    &quot;message&quot;: &quot;에러 발생&quot;,
    &quot;error&quot;: &quot;Internal Server Error&quot;,
    &quot;statusCode&quot;: 500
}</code></pre><p>전체 조회를 해도 그대로 115번이 존재하는걸 알 수 있습니다. 즉, 안에서 에러가 발생하면서 트랜젝션이 롤백이 된 것입니다.</p>
<p>이제 강제로 만든 에러는 제거합니다.</p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 115,
            &quot;updatedAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
            &quot;title&quot;: &quot;제목&quot;,
            &quot;content&quot;: &quot;내용&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            },
            &quot;images&quot;: [
                {
                    &quot;id&quot;: 4,
                    &quot;updatedAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
                    &quot;createdAt&quot;: &quot;2024-02-13T14:04:28.383Z&quot;,
                    &quot;order&quot;: 0,
                    &quot;type&quot;: 0,
                    &quot;path&quot;: &quot;/public\\posts\\e3989050-da50-49e3-9dc6-e99afe9612cc.png&quot;
                }
            ]
        },</code></pre><p>따라서 서버에서 a, b, c 에러가 날 수 있는 상황이 있으면 무조건 트랜젝션으로 묶어줘야 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-선 업로드]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-%EC%84%A0-%EC%97%85%EB%A1%9C%EB%93%9C</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-%EC%84%A0-%EC%97%85%EB%A1%9C%EB%93%9C</guid>
            <pubDate>Wed, 07 Feb 2024 01:50:51 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️이론">🖊️이론</h3>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/0416c071-938e-4103-a540-8d2f06301447/image.png" alt=""></p>
<p>고전적인 방법으로, 제목 내용 이미지를 작성하고 <code>업로드 버튼</code>을 누르면 서버로 전송이 됩니다. </p>
<p><code>현재 구현돼있는 이미지 업로드 방식</code></p>
<ul>
<li>제목, 내용, 이미지를 모두 선택한 다음 모든 정보를 한번에 서버로 업로드</li>
<li>텍스트는 빠르지만 파일은 오래걸릴 수 있음</li>
<li>업로드 버튼을 누를 때, 시간이 오래걸리면 사용자는 불편을 느낌</li>
<li>파일 1개가 아닌 여러개면 더욱 오랜시간이 걸림</li>
</ul>
<p><code>앞으로 바꿀 방식</code></p>
<ul>
<li>이미지를 선택할 때마다 업로드 시키기</li>
<li>업로드 된 이미지들은 <code>임시</code> 폴더에 잠시 저장 <code>(/public/temp)</code></li>
<li>이미지 업로드를 한 후 응답받은 이미지의 경로만 프론트에서 저장 해둔 후, 포스트를 업로드 할 때 이미지의 경로만 추가</li>
<li>POST /posts 엔드포인트에 이미지 경로를 함께 보낼경우 해당 이미지를 임시 폴더<code>(/public/temp)</code>에서부터 포스트 폴더<code>(/public/posts)</code>로 이동시키기</li>
<li>PostEntity의 image 필드에 경로를 추가한다.</li>
<li><code>S3 presigned url을 사용하면 많이 사용되는 방식</code>이다.</li>
</ul>
<p><code>장단점</code></p>
<table>
<thead>
<tr>
<th align="left"></th>
<th>기존 방식</th>
<th align="center">신규 방식</th>
</tr>
</thead>
<tbody><tr>
<td align="left">체감 속도</td>
<td>이미지 업로드되는 시간 기다리기 때문에 길다.</td>
<td align="center">이미지를 선택하자 마자 먼저 업로드를 진행하기 때문에 속도감이 좋다.</td>
</tr>
<tr>
<td align="left">서버 과부하</td>
<td>업로드 버튼을 눌렀을때만 요청이 보내지기 때문에 포스트 하나당 한번의 요청만 보낸다.</td>
<td align="center">이미지를 선택할때마다 업로드를 진행하기 때문에 많은 요청이 보내진다. 특히 이미지 선택하고 포스트를 하지 않으면 리소스만 낭비한다.</td>
</tr>
<tr>
<td align="left">엔드포인트 관리</td>
<td>파일을 업로드 해야하는 엔드포인트가 생길때마다 파일 업로드 관련 multer세팅을 계속해줘야 한다.</td>
<td align="center">공통된 이미지 업로드 엔드포인드를 하나 만들어서 모든 이미지 업로드를 한번에 관리 할 수 있다.</td>
</tr>
<tr>
<td align="left">파일 관리</td>
<td>포스팅 버튼을 눌렀을 때만 파일이 업로드 되기 때문에 잉여 파일이 생길 가능성이 적다.</td>
<td align="center">이미지를 선택하면 바로 업로드를 진행하기 때문에 선택한 이미지를 삭제하거나 변경하면 잉여 파일이 생긴다. 잉여 파일들은 주기적으로 삭제 해줘야한다.</td>
</tr>
</tbody></table>
<hr>
<h3 id="🖊️이미지-업로드-엔드포인트-생성">🖊️이미지 업로드 엔드포인트 생성</h3>
<p><code>posts 모듈</code>에 있던 <code>MulterModule</code>를 <code>common 모듈</code>로 이동시킵니다. 모든 파일 업로드는 <code>common 모듈</code>에서 진행을 하도록 하겠습니다.</p>
<ul>
<li>common.module.ts</li>
</ul>
<pre><code class="language-typescript">import { BadRequestException, Module } from &#39;@nestjs/common&#39;;
import { CommonService } from &#39;./common.service&#39;;
import { CommonController } from &#39;./common.controller&#39;;
import { MulterModule } from &#39;@nestjs/platform-express&#39;;
import { extname } from &#39;path&#39;;
import * as multer from &#39;multer&#39;;
import {v4 as uuid} from &#39;uuid&#39;; // 일반적으로 많이 사용하는 버전 v4

@Module({
    imports: [
          AuthModule,
        UsersModule,
        MulterModule.register({
            // 파일을 다룰 떄 여러가지 옵션 기능
            limits: {
                // 바이트 단위로 입력
                fileSize: 10000000,
            },
            fileFilter: (req, file, cb) =&gt; { // req: 요청 | file: req에 들어있는 파일
                /**
                 * cb(에러, boolean)
                 * 
                 * 첫번째 파라미터에는 에러가 있을경우 에러정보를 넣어준다.
                 * 두번쨰 파라미터는 파일을 받을지 말지 boolean을 넣어준다.
                 */

                // asdasd.jpg -&gt; .jpg
                const extension = extname(file.originalname);

                if (extension !== &#39;.jpg&#39; &amp;&amp; extension !== &#39;.jpeg&#39; &amp;&amp; extension !== &#39;.png&#39;) {
                  return cb(new BadRequestException(&#39;jpg/jpeg/png 파일만 없로드 가능합니다. &#39;), false); // cb(에러, boolean)
                }
                return cb(null, true); // 파일을 받는다.
            },
            storage: multer.diskStorage({
                // 파일 저장시 어디로 이동 시킬까?
                destination: function(req, res, cb) { 
                      cb(null, ); // 파일 업로드 위치
                },
                // 파일 이름 뭐로 지을래?
                filename: function(req, file, cb) {
                    // uuid + file의 확장자
                    cb(null, `${uuid()}${extname(file.originalname)}`); // POST_IMAGE_PATH로 이동
                }
            }),
        }),
    ],
    controllers: [CommonController],
    providers: [CommonService],
    exports: [CommonService]
})
export class CommonModule {}</code></pre>
<p>파일 업로드의 위치만 변경하면 됩니다. path.const.ts로 이동해서 임시폴더의 경로를 만들어 주고, public에 temp 폴더를 생성하겠습니다.</p>
<ul>
<li>common/const/path.const.ts</li>
</ul>
<pre><code class="language-typescript">import { TEMP_FOLDER_NAME } from &#39;./path.const&#39;;
import { join } from &quot;path&quot;;

export const PROJECT_ROOT_PATH = process.cwd();
export const PUBLIC_FOLDER_NAME = &#39;public&#39;;
export const POSTS_FOLDER_NAME = &#39;posts&#39;;
// 임시 폴더 이름
export const TEMP_FOLDER_NAME = &#39;temp&#39;;

export const PUBLIC_FOLDER_PATH = join(
     PROJECT_ROOT_PATH,
     PUBLIC_FOLDER_NAME
)

export const POST_IMAGE_PATH = join(
     PUBLIC_FOLDER_PATH,
     POSTS_FOLDER_NAME,
)

export const POST_PUBLIC_IMAGE_PATH = join(
     PUBLIC_FOLDER_NAME,
     POSTS_FOLDER_NAME,
)

// 추가
export const TEMP_FOLDER_PATH = join(
     PUBLIC_FOLDER_PATH,
     TEMP_FOLDER_NAME,
)</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/8457d409-4b6b-49f5-bf3f-317ddec01d3d/image.png" alt=""></p>
<p>선 업로드 방식은 먼저 temp 폴더에 전부 저장을 하겠습니다. common.module.ts로 가서 임시 폴더의 경로를 입력하겠습니다. 이제 파일을 업로드하면 전부 temp폴더에 이동을 할 것입니다.</p>
<ul>
<li>common.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
          AuthModule,
        UsersModule,
        MulterModule.register({
            limits: {
                fileSize: 10000000,
            },
            fileFilter: (req, file, cb) =&gt; {
                const extension = extname(file.originalname);

                if (extension !== &#39;.jpg&#39; &amp;&amp; extension !== &#39;.jpeg&#39; &amp;&amp; extension !== &#39;.png&#39;) {
                  return cb(new BadRequestException(&#39;jpg/jpeg/png 파일만 없로드 가능합니다. &#39;), false);
                }
                return cb(null, true);
            },
            storage: multer.diskStorage({
                destination: function(req, res, cb) { 
                      cb(null, TEMP_FOLDER_PATH); // 파일 업로드 위치
                },
                filename: function(req, file, cb) {
                    cb(null, `${uuid()}${extname(file.originalname)}`);
                }
            }),
        }),
    ],
    controllers: [CommonController],
    providers: [CommonService],
    exports: [CommonService]
})
export class CommonModule {}</code></pre>
<p>common.module.ts에 작성을 했으니까, controller로 이동해서 작업을 하겠습니다.</p>
<ul>
<li>common.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Controller(&#39;common&#39;)
export class CommonController {

    constructor(private readonly commonService: CommonService) {}

    @Post(&#39;image&#39;)
      @UseGuards(AccessTokenGuard)
    @UseInterceptors(FileInterceptor(&#39;image&#39;))
    postImage(
          @UploadedFile() file: Express.Multer.File,
    ) {
        // 여기까지 왔다면?
    }
}</code></pre>
<p>만약 주석이 달려있는 곳까지 통과를 했다면 common.module.ts의 코드를 전부 통과하고, temp 폴더에 이미지가 존재하는 상태입니다. 마지막은 filename만 반환하면 됩니다.</p>
<pre><code class="language-typescript">@Controller(&#39;common&#39;)
export class CommonController {

    constructor(private readonly commonService: CommonService) {}

    @Post(&#39;image&#39;)
      @UseGuards(AccessTokenGuard)
    @UseInterceptors(FileInterceptor(&#39;image&#39;))
    postImage(
          @UploadedFile() file: Express.Multer.File,
    ) {
        return {
              fileName: file.filename,
        }
    }
}</code></pre>
<p>포스트맨으로 테스트를 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/996bf910-f6d2-4888-a688-8635630ad530/image.png" alt=""></p>
<pre><code>{
    &quot;fileName&quot;: &quot;e9cdf67e-015f-449d-b0fa-1f654a35b082.png&quot;
}</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/965dbed7-47be-4619-b940-442a73580db3/image.png" alt=""></p>
<hr>
<h3 id="🖊️post-posts엔드포인트-변경">🖊️POST posts엔드포인트 변경</h3>
<p>현재 이미지 name을 받아오는 API를 만들었습니다. 이제 post를 생성할 때, 반환받을 이미지의 이름을 <code>postPosts()</code>에 보내주게 되면 해당 path에 mapping을 하고 temp폴더에 있는 이미지를 post 폴더로 이동시키도록 만들겠습니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(FileInterceptor(&#39;image&#39;)) // 더 이상 필요 없다. -&gt; 이미지 받는 것은 common에서 처리하기 때문에!
postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    return this.postsService.createPost(
          userId, body, file?.filename,
    );
}
.
.
변경
.
.
@Post()
@UseGuards(AccessTokenGuard)
postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    return this.postsService.createPost(
          userId, body,
    );
}</code></pre>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async createPost(authorId: number, postDto: CreatePostDto) { // 변경
    const post = this.postsRepository.create({
        author: {
              id: authorId,
        },
        ...postDto,
        // 변경
        likeCount: 0,
        commentCount: 0,
    });

    const newPost = await this.postsRepository.save(post);
    return newPost;
}</code></pre>
<ul>
<li>posts/dto/create-post.dto.ts</li>
</ul>
<pre><code class="language-typescript">export class CreatePostDto extends PickType(PostsModel, [&#39;title&#39;, &#39;content&#39;]) {

     @IsString()
     @IsOptional()
     image?: string;
}</code></pre>
<p>image에 temp 폴더의 이미지 이름을 넣어주게 되면, <code>postPostw()</code> 내부의 <code>createPost()</code>에 <code>CreatePostDto</code>를 통해서 들어가고 생성됩니다.</p>
<p>포스트맨으로 테스트를 해보겠습니다. 로그인을 먼저 하고 common/image를 보내서 temp폴더에 이미지를 이동시킵니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/532415a0-86e1-4498-b607-83f63a339f57/image.png" alt=""></p>
<pre><code>{
    &quot;fileName&quot;: &quot;36b07d14-7378-4370-a605-58fad992b224.png&quot;
}</code></pre><p>그 후, 해당 <code>이미지 이름</code>을 복사해서 /posts에 추가를 합니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9db67d19-b705-4214-ad42-24b48421c50e/image.png" alt=""></p>
<pre><code>{
    &quot;title&quot;: &quot;Flutter lecture&quot;,
    &quot;content&quot;: &quot;POST REQUEST&quot;,
    &quot;image&quot;: &quot;/public\\posts\\36b07d14-7378-4370-a605-58fad992b224.png&quot;, // 생성
    &quot;likeCount&quot;: 0,
    &quot;commentCount&quot;: 0,
    &quot;author&quot;: {
        &quot;id&quot;: 1
    },
    &quot;id&quot;: 110,
    &quot;updatedAt&quot;: &quot;2024-02-12T01:48:35.564Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-12T01:48:35.564Z&quot;
}</code></pre><p>등록이 잘 되었는지 확인을 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/50847bab-8b27-457d-a10a-c48041c0ef1c/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 110,
            &quot;updatedAt&quot;: &quot;2024-02-12T01:48:35.564Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-12T01:48:35.564Z&quot;,
            &quot;title&quot;: &quot;Flutter lecture&quot;,
            &quot;content&quot;: &quot;POST REQUEST&quot;,
            &quot;image&quot;: &quot;/public\\posts\\36b07d14-7378-4370-a605-58fad992b224.png&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },</code></pre><p>사진이 들어가 있는지 확인을 해보겠습니다.</p>
<pre><code>http://localhost:3000/public//posts//36b07d14-7378-4370-a605-58fad992b224.png</code></pre><pre><code>// 20240212195015
// http://localhost:3000/public//posts//36b07d14-7378-4370-a605-58fad992b224.png

{
  &quot;statusCode&quot;: 404,
  &quot;message&quot;: &quot;ENOENT: no such file or directory, stat &#39;C:\\workspace\\nestjsworkspace\\df_sns_2\\public\\index.html&#39;&quot;
}</code></pre><p>이렇게 에러가 발생한 이유는 temp폴더에 있는 사진을 posts폴더로 아직 옮기지 않았기 때문입니다. </p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/7de6880f-b9aa-4fed-bde5-96ba38993236/image.png" alt=""></p>
<hr>
<h3 id="🖊️엔티티가-생성될-때-임시-폴더로부터-이미지-파일-이동시키기">🖊️엔티티가 생성될 때 임시 폴더로부터 이미지 파일 이동시키기</h3>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async createPostImage(dto: CreatePostDto) {
    // dto의 이름을 기반으로 파일의 경로를 생성한다.
    const tempFilePath = join( // 이미지의 절대경로를 tempFilePath로
        TEMP_FOLDER_PATH,
        dto.image,
    );
}
.
.
추가
.
.
import { promises } from &#39;fs&#39;; // fs: file system

async createPostImage(dto: CreatePostDto) {

    const tempFilePath = join(
        TEMP_FOLDER_PATH,
        dto.image,
    );

    try { // import { promises } from &#39;fs&#39;
        // 파일이 존재하는지 확인
        // 만약에 파일이 존재하지 않으면 에러를 던짐
        await promises.access(tempFilePath);
    } catch (error) {
          throw new BadRequestException(&#39;존재하지 않는 파일입니다. &#39;);
    }
}
.
.
완성
.
.
import { basename, join } from &#39;path&#39;;

async createPostImage(dto: CreatePostDto) {

    const tempFilePath = join(
        TEMP_FOLDER_PATH,
        dto.image,
    );

    try {
        await promises.access(tempFilePath);
    } catch (error) {
          throw new BadRequestException(&#39;존재하지 않는 파일입니다. &#39;);
    }

    // 파일의 이름만 가져오기
    // /Users/aaa/bbb/ccc/asdad.jpg -&gt; asdad.jpg
    const fileName = basename(tempFilePath);

    // 새로 이동할 포스트 폴더의 경로 + 이미지 이름
    // {프로젝트 경로}/public/posts/asdad.jpg
    const newPath = join(
        POST_IMAGE_PATH,
        fileName,
    );

    // 파일 옮기기
    await promises.rename(tempFilePath, newPath);
    return true;
}</code></pre>
<p>post를 생성하기 전에 만드는 방법으로 컨트롤러 코드를 작성하겠습니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    await this.postsService.createPostImage(body); // temp -&gt; posts
    return this.postsService.createPost(
          userId, body,
    );
}</code></pre>
<p><code>temp폴더</code>에 존재하는 이미지를 <code>posts폴더</code>로 이동시키고 <code>post를 생성</code>합니다. 포스트맨으로 테스트를 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d90a75b3-6c0c-44a9-b2b2-45467461421b/image.png" alt=""></p>
<pre><code>{
    &quot;fileName&quot;: &quot;ba9df4fd-f970-4709-91b5-8a36d5251794.png&quot;
}</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/bbfcaf99-d31a-4b1e-9aa4-077f88c2d73c/image.png" alt=""></p>
<pre><code>{
    &quot;title&quot;: &quot;Flutter lecture&quot;,
    &quot;content&quot;: &quot;POST REQUEST&quot;,
    &quot;image&quot;: &quot;/public\\posts\\ba9df4fd-f970-4709-91b5-8a36d5251794.png&quot;,
    &quot;likeCount&quot;: 0,
    &quot;commentCount&quot;: 0,
    &quot;author&quot;: {
        &quot;id&quot;: 1
    },
    &quot;id&quot;: 111,
    &quot;updatedAt&quot;: &quot;2024-02-12T02:18:52.698Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-12T02:18:52.698Z&quot;
}</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/819df707-fa55-419e-9698-a2bfdee950b3/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 111,
            &quot;updatedAt&quot;: &quot;2024-02-12T02:18:52.698Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-12T02:18:52.698Z&quot;,
            &quot;title&quot;: &quot;Flutter lecture&quot;,
            &quot;content&quot;: &quot;POST REQUEST&quot;,
            &quot;image&quot;: &quot;/public\\posts\\ba9df4fd-f970-4709-91b5-8a36d5251794.png&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },</code></pre><pre><code>http://localhost:3000/public//posts//ba9df4fd-f970-4709-91b5-8a36d5251794.png</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a96b2da2-ad5a-4353-8d69-cec96247960a/image.png" alt=""></p>
<p><code>ba9df4fd-f970-4709-91b5-8a36d5251794.png</code> 이미지가 이동된 것을 알 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/a436cc4e-9efe-40cd-bbb1-5a08ff0412a8/image.png" alt=""></p>
<p>지금 과정은 temp폴더에 있는 사진을 옮기면 끝이 나기 때문에 사용자 입장에서는 매우 빠르다고 느끼게 됩니다.</p>
<p>물론 단점으로는 temp폴더에 적재된 이미지들을 저장소를 위해 관리해줘야하는 불편함이 존재합니다. 따라서 기존방법과 새로운 방법을 같이 사용하는 것이 좋습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-Static File Serving]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-Static-File-Serving</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-Static-File-Serving</guid>
            <pubDate>Tue, 06 Feb 2024 13:38:20 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️static-file-serving-옵션-추가">🖊️Static File Serving 옵션 추가</h3>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/e5839801-4c18-4bc4-95b3-910a5dba6c22/image.png" alt=""></p>
<p>파일이 잘 올라갔지만, 이 파일을 볼 수 있어야합니다. 따라서 <code>Static File</code>을 이용해서 볼 수 있도록 만들겠습니다. <code>Static File</code>은 이미지를 의미합니다. 이후에 경로를 외부에서도 볼 수 있도록 바꾸는 것입니다. nest.js에서는 이렇게 이미지와 같은 <code>Static File</code>들을 외부에서도 볼 수 있도록 기능을 만들어 놓았습니다. 먼저 패키지를 설치합니다.</p>
<pre><code>yarn add @nestjs/serve-static</code></pre><p>app.module.ts에 ServeStaticModule를 추가합니다. <code>rootPath</code>는 파일들을 serving할 최상단의 폴더를 의미합니다. 즉 <code>public</code>을 포함하는 것입니다.</p>
<p>따라서 <code>PUBLIC_FOLDER_PATH</code>를 하게되면 public은 건너뛰고 <code>/posts/4022.jpg</code>이렇게 주소가 만들어집니다. 하지만 이미 우리는 posts 컨트롤러에 해당 URL이 존재하기 때문에 약간의 변경을 주겠습니다.</p>
<p><code>serveRoot</code>에 <code>/public</code>을 주므로써 <code>/public/posts/4022.jpg</code>만들어지게 됩니다.</p>
<ul>
<li>app.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        ConfigModule.forRoot({
            envFilePath: &#39;.env&#39;,
            isGlobal: true,
        }),
        TypeOrmModule.forRoot({
            type: &#39;postgres&#39;,
            host: process.env[ENV_DB_HOST_KEY],
            port: parseInt(process.env[ENV_DB_PORT_KEY]),
            username: process.env[ENV_DB_USERNAME_KEY],
            password: process.env[ENV_DB_PASSWORD_KEY],
            database: process.env[ENV_DB_DATABASE_KEY],
            entities: [
                PostsModel,
                UsersModel,
            ],
            synchronize: true,
        }),
        PostsModule,
        UsersModule,
        AuthModule,
        CommonModule,
          // 추가
        ServeStaticModule.forRoot({
            // 4022.jpg      
            rootPath: PUBLIC_FOLDER_PATH, // http://localhost:3000/posts/4022.jpg
            serveRoot: &#39;/public&#39; // http://localhost:3000/public/posts/4022.jpg
        }),
          // 여기까지
    ],
    controllers: [AppController],
    providers: [AppService, {
        provide: APP_INTERCEPTOR,
        useClass: ClassSerializerInterceptor,
    }],
})
export class AppModule {}</code></pre>
<p>포스트맨으로 잘 나오는지 테스트를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/8e9838d6-cba0-4bd2-a22e-21572b8f9a93/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 109,
            &quot;updatedAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;,
            &quot;title&quot;: &quot;파일 업로드&quot;,
            &quot;content&quot;: &quot;파일 업로드 테스트&quot;,
            &quot;image&quot;: &quot;06961c7a-b923-4d24-a13b-d4cc2fa5ee63.png&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },
        .
        .
        {
            &quot;id&quot;: 90,
            &quot;updatedAt&quot;: &quot;2024-01-28T02:12:54.437Z&quot;,
            &quot;createdAt&quot;: &quot;2024-01-28T02:12:54.437Z&quot;,
            &quot;title&quot;: &quot;임의로 생성된 81&quot;,
            &quot;content&quot;: &quot;임의로 생성된 포수트 내용 81&quot;,
            &quot;image&quot;: null,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: 90
    },
    &quot;count&quot;: 20,
    &quot;next&quot;: &quot;http://localhost:3000/posts?order__createdAt=DESC&amp;take=20&amp;where__id__less_than=90&quot;
}</code></pre><p>image를 복사해서 URL에 넣어봅니다.</p>
<pre><code>http://localhost:3000/public/posts/06961c7a-b923-4d24-a13b-d4cc2fa5ee63.png</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/db1e8360-7e29-44a6-b666-2fd8eb2a2732/image.png" alt=""></p>
<p>StaticFile Serving을 해놓을 파일을 등록하면, 그 폴더안에 넣은 모든 파일들은 외부에서 볼 수 있습니다.</p>
<hr>
<h3 id="🖊️class-transformer-이용해서-url에-prefix-추가">🖊️Class Transformer 이용해서 URL에 prefix 추가</h3>
<pre><code>{
            &quot;id&quot;: 109,
            &quot;updatedAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;,
            &quot;title&quot;: &quot;파일 업로드&quot;,
            &quot;content&quot;: &quot;파일 업로드 테스트&quot;,
            &quot;image&quot;: &quot;06961c7a-b923-4d24-a13b-d4cc2fa5ee63.png&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },</code></pre><p>image에서 prefix까지 전부 받으면 좋을 것 같습니다. <code>/public/posts</code>까지 붙여서 말이죠.</p>
<p>엔티티에서 프로퍼티를 변경할 수 있는 기능은 <code>class-transformer</code>를 사용하는 것입니다. 이번에는 <code>@Transform()</code>을 배워보도록 하겠습니다.</p>
<p><code>@Transform</code> 내부에서 <code>value</code>를 받습니다. 이 값은 <code>image 키값</code>에 매칭이되는 <code>value</code>를 의미합니다. 예를 들면 <code>4022.jpg</code>를 의미합니다.</p>
<p>따라서 이 값이 존재하는 경우에만 <code>POST_PUBLIC_IMAGE_PATH</code>와 <code>value</code>를 <code>join</code>한다는 것입니다.</p>
<ul>
<li>posts.entity.ts</li>
</ul>
<pre><code class="language-typescript">@Entity()
export class PostsModel extends BaseModel {

    @ManyToOne(() =&gt; UsersModel, (user) =&gt; user.posts, {
          nullable: false,
    })
    author: UsersModel;

    @Column()
    @IsString({
          message: stringValidationMessage
    }) 
    title: string;

    @Column()
    @IsString({
          message: stringValidationMessage
    }) 
    content: string;

    @Column({
          nullable: true,
    })
    // value가 존재할 경우에만 value 앞에 /public/posts를 붙임, value가 존재하지 않으면 null or undefined
    @Transform(({value}) =&gt; value &amp;&amp; `/${join(POST_PUBLIC_IMAGE_PATH, value)}`) // value: image에 입력된 값 의미
    image?: string;

    @Column()
    likeCount: number;

    @Column()
    commentCount: number;
}</code></pre>
<p>포스트맨으로 테스트를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/763b4bac-1f21-432e-a68a-cf35ce4923e7/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 109,
            &quot;updatedAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;,
            &quot;title&quot;: &quot;파일 업로드&quot;,
            &quot;content&quot;: &quot;파일 업로드 테스트&quot;,
            &quot;image&quot;: &quot;/public\\posts\\06961c7a-b923-4d24-a13b-d4cc2fa5ee63.png&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },
        .
        .
        {
            &quot;id&quot;: 90,
            &quot;updatedAt&quot;: &quot;2024-01-28T02:12:54.437Z&quot;,
            &quot;createdAt&quot;: &quot;2024-01-28T02:12:54.437Z&quot;,
            &quot;title&quot;: &quot;임의로 생성된 81&quot;,
            &quot;content&quot;: &quot;임의로 생성된 포수트 내용 81&quot;,
            &quot;image&quot;: null,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: 90
    },
    &quot;count&quot;: 20,
    &quot;next&quot;: &quot;http://localhost:3000/posts?order__createdAt=DESC&amp;take=20&amp;where__id__less_than=90&quot;
}</code></pre><pre><code>http://localhost:3000/public//posts//06961c7a-b923-4d24-a13b-d4cc2fa5ee63.png</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/b7daf15c-0a09-410b-ae3f-957b9f0b14c7/image.png" alt=""></p>
<p>지금까지 파일 업로드의 매우 클래식한 방법을 알아보았습니다. 다음 챕터에서는 현대에서 많이 사용되는 파일 업로드를 사용해 보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@PreUpdate, @PrePersist]]></title>
            <link>https://velog.io/@jaegeunsong_1997/PreUpdate-PrePersist</link>
            <guid>https://velog.io/@jaegeunsong_1997/PreUpdate-PrePersist</guid>
            <pubDate>Tue, 06 Feb 2024 01:23:44 GMT</pubDate>
            <description><![CDATA[<h1 id="정의">정의</h1>
<p><code>@PreUpdate</code>와 <code>@PrePersist</code>는 JPA에서 사용되는 라이프사이클 콜백 어노테이션입니다. 엔티티의 상태가 DB에 <code>저장되기 전에</code> 동작이 수행되도록 지정하는 데 사용됩니다.</p>
<h2 id="preupdate">@PreUpdate</h2>
<p>엔티티의 상태가 업데이트 되기전에 실행되는 메소드입니다.</p>
<pre><code class="language-java">@Entity
public class YourEntity {

    // fields, getters, setters, etc.

    @PreUpdate
    public void beforeUpdate() {
        // Your logic before the entity is updated
    }
}
</code></pre>
<h2 id="prepersist">@PrePersist</h2>
<p>엔티티가 처음으로 저장되기 직전에 호출됩니다. 엔티티가 저장되기 전에 필요한 초기화 작업을 수행하거나 생성일을 설정하는 작업에 사용됩니다.</p>
<pre><code class="language-java">@Entity
public class YourEntity {

    // fields, getters, setters, etc.

    @PrePersist
    public void beforePersist() {
        // Your logic before the entity is persisted (stored)
    }
}
</code></pre>
<hr>
<h1 id="결론">결론</h1>
<pre><code class="language-java">import java.time.LocalDateTime;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;

@Entity
public class Book {

    @Id
    private Long id;

    private String title;

    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;

    // Constructors, getters, setters, etc.

    @PrePersist
    public void beforePersist() {
        // 엔터티가 저장되기 전에 호출되는 메서드
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    @PreUpdate
    public void beforeUpdate() {
        // 엔터티가 업데이트되기 전에 호출되는 메서드
        this.updatedAt = LocalDateTime.now();
    }
}
</code></pre>
<p><code>@PrePersist</code>는 엔티티가 저장되기 전에 호출되는 메소드이고 <code>@PreUpdate</code>는 엔티티가 업데이트되기 전에 호출되는 메소드입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-파일 업로드]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C</guid>
            <pubDate>Sun, 04 Feb 2024 13:32:49 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️multer-세팅">🖊️Multer 세팅</h3>
<p>자바스크립트 진영에서는 이미지를 Multer를 이용해서 쉽게 풀어나갑니다. 따라서 Nest.js에서도 Multer 라이브러리를 사용해서 파일 업로드를 구현하겠습니다. 먼재 4개의 페키지<code>multer @types/multer uuid @types/uuid</code> 를 설치하겠습니다.</p>
<pre><code>// multer + multer의 타입 definition을 제공해주는 타입스크립트 파일 추가
yarn add multer @types/multer uuid @types/uuid</code></pre><ul>
<li>posts/entities/posts.entity.ts</li>
</ul>
<pre><code class="language-typescript">@Entity()
export class PostsModel extends BaseModel { 

    @ManyToOne(() =&gt; UsersModel, (user) =&gt; user.posts, {
          nullable: false,
    })
    author: UsersModel;

    @Column()
    @IsString({
          message: stringValidationMessage
    }) 
    title: string;

    @Column()
    @IsString({
          message: stringValidationMessage
    }) 
    content: string;

    @Column({
          nullable: true,
    })
    image?: string; // 추가(null 일수도 있고 아닐수도 있다!)

    @Column()
    likeCount: number;

    @Column()
    commentCount: number;
}</code></pre>
<p>posts.module.ts에 MulterModule을 등록하겠습니다.</p>
<ul>
<li>posts.module.ts</li>
</ul>
<pre><code class="language-typescript">import { MulterModule } from &#39;@nestjs/platform-express&#39;;

@Module({
    imports: [
        TypeOrmModule.forFeature([
            PostsModel,
        ]),
        AuthModule,
        UsersModule,
        CommonModule,
        MulterModule.register({ // 파일을 다룰 떄 여러가지 옵션 기능
            limits: {
                fileSize: 10000000, // 바이트 단위로 입력
            },
            fileFilter: (req, file, cb) =&gt; { // req: 요청 | file: req에 들어있는 파일
                /**
                 * cb(error, boolean)
                 * 
                 * 첫번째 파라미터에는 에러가 있을경우 에러정보를 넣어준다.
                 * 두번쨰 파라미터는 파일을 받을지 말지 boolean을 넣어준다.
                 */
            }
        }),
    ],
    controllers: [PostsController],
    providers: [PostsService],
    exports: [PostsService]
})
export class PostsModule {}</code></pre>
<p><code>jpg, jpeg, png</code>만 받을 수 있도록 <code>fileFilter</code>를 추가하겠습니다.</p>
<pre><code class="language-typescript">MulterModule.register({
    limits: {
          fileSize: 10000000,
    },
      // 추가
    fileFilter: (req, file, cb) =&gt; {
        const extension = extname(file.originalname); // asdasd.jpg -&gt; .jpg
        if (extension !== &#39;.jpg&#39; &amp;&amp; extension !== &#39;.jpeg&#39; &amp;&amp; extension !== &#39;.png&#39;) {
              return cb(new BadRequestException(&#39;jpg/jpeg/png 파일만 없로드 가능합니다. &#39;), false); // cb(에러, boolean)
        }
        return cb(null, true); // 파일을 받는다.
    },</code></pre>
<p><code>저장소(폴더) storage</code> 경로를 추가하겠습니다. <code>storage</code>에서의 <code>cb</code>는 2가지 인수로 받는데 <code>error와 definition</code>을 받습니다. <code>fileFilter</code>는 <code>error과 acceptFile</code>을 받습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/dab79fe0-55a5-4e32-8e41-2ab6a4f8cd41/image.png" alt=""></p>
<pre><code class="language-typescript">import * as multer from &#39;multer&#39;;
.
.
MulterModule.register({
    limits: {
        fileSize: 10000000,
    },
    fileFilter: (req, file, cb) =&gt; {
        const extension = extname(file.originalname);
        if (extension !== &#39;.jpg&#39; &amp;&amp; extension !== &#39;.jpeg&#39; &amp;&amp; extension !== &#39;.png&#39;) {
              return cb(new BadRequestException(&#39;jpg/jpeg/png 파일만 없로드 가능합니다. &#39;), false);
        }
        return cb(null, true);
    },
    storage: multer.diskStorage({
        // 파일 저장시 어디로 이동 시킬까?
        destination: function(req, res, cb) { 
              cb(null, 경로)
        },
    }),
}),</code></pre>
<p>경로를 작성하기에 앞서, 저희는 앞으로 여러 파일과 이미지를 많이 사용할 것이기 때문에, common.const에다가 각각의 path를 정리하겠습니다.</p>
<ul>
<li>common/const/path.const.ts</li>
</ul>
<pre><code class="language-typescript">// 서버 프로젝트의 루트 폴더
export const PROJECT_ROOT_PATH = process.cwd(); // current working directory 약자
// 외부에서 접근 가능한 파일들을 모아둔 폴더 이름
export const PUBLIC_FOLDER_NAME = &#39;public&#39;;</code></pre>
<p>그리고 public 폴더를 생성해주고, 계속해서 추가를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/0132ff4a-fd04-4772-9ddc-4acaf2cbd469/image.png" alt=""></p>
<pre><code class="language-typescript">import { join } from &quot;path&quot;;

// /Users/asdqewdsadad/asdasd/bfgb/DF_SNS_2
export const PROJECT_ROOT_PATH = process.cwd(); // 서버 프로젝트의 루트 폴더
export const PUBLIC_FOLDER_NAME = &#39;public&#39;;
export const POSTS_FOLDER_NAME = &#39;posts&#39;; // 포스트 이미지들을 저장할 폴더 이름

// 실제 공개폴더의 절대경로
// /{프로젝트의 위치}/public
export const PUBLIC_FOLDER_PATH = join( // 경로를 만들어 주는 함수
     // string을 무한히 넣을 수 있다.
     PROJECT_ROOT_PATH,
     PUBLIC_FOLDER_NAME
)

// 포스트 이미지를 저장할 폴더
// /{프로젝트의 위치}/public/posts
export const POST_IMAGE_PATH = join(
     PUBLIC_FOLDER_PATH,
     POSTS_FOLDER_NAME,
)</code></pre>
<p>지금까지 작성한 것은 프로젝트의 위치에서부터 경로를 가져오고 있습니다. 그 의미는 <code>PUBLIC_FOLDER_PATH</code>과 <code>POST_IMAGE_PATH</code>는 완전히 절대경로인 것입니다. 하지만 우리가 요청을 받을 때는 <code>절대경로</code>를 사용하지 않고 <code>/public/posts/xxx.png</code> 이렇게 받을 것입니다. 뒤에 이미지는 다를 수 있으니까 <code>/public/posts</code>까지 작성해 보겠습니다.</p>
<pre><code class="language-typescript">import { join } from &quot;path&quot;;

export const PROJECT_ROOT_PATH = process.cwd();
export const PUBLIC_FOLDER_NAME = &#39;public&#39;;
export const POSTS_FOLDER_NAME = &#39;posts&#39;;
export const PUBLIC_FOLDER_PATH = join( 
     PROJECT_ROOT_PATH,
     PUBLIC_FOLDER_NAME
)
export const POST_IMAGE_PATH = join(
     PUBLIC_FOLDER_PATH,
     POSTS_FOLDER_NAME,
)

// 절대경로 x
// http://localhost:3000 + /public/posts/xxx.png
export const POST_PUBLIC_IMAGE_PATH = join(
     PUBLIC_FOLDER_NAME,
     POSTS_FOLDER_NAME,
)</code></pre>
<p><code>/public/posts</code> 뒤에 나오는 <code>xxx.png</code>는 나중에 처리하겠습니다. 왜냐하면 그때마다 확장자가 달라지기 때문입니다. </p>
<p>다시 posts.module.ts로 가서 마저 storage의 cb함수를 작성하겠습니다.</p>
<pre><code class="language-typescript">MulterModule.register({
    limits: {
        fileSize: 10000000,
    },
    fileFilter: (req, file, cb) =&gt; {
        const extension = extname(file.originalname);
        if (extension !== &#39;.jpg&#39; &amp;&amp; extension !== &#39;.jpeg&#39; &amp;&amp; extension !== &#39;.png&#39;) {
              return cb(new BadRequestException(&#39;jpg/jpeg/png 파일만 없로드 가능합니다. &#39;), false);
        }
        return cb(null, true);
    },
    storage: multer.diskStorage({
        destination: function(req, res, cb) { 
              cb(null, POST_IMAGE_PATH); // 여기로 파일을 업로드 한다
        },
    }),
}),</code></pre>
<p>definition에 파일을 업로드시 필요한 filename을 추가하겠습니다.</p>
<pre><code class="language-typescript">import {v4 as uuid} from &#39;uuid&#39;; // uuid 사용시, v4가 일반적
.
.
MulterModule.register({
    limits: {
        fileSize: 10000000,
    },
    fileFilter: (req, file, cb) =&gt; {
        const extension = extname(file.originalname);
        if (extension !== &#39;.jpg&#39; &amp;&amp; extension !== &#39;.jpeg&#39; &amp;&amp; extension !== &#39;.png&#39;) {
              return cb(new BadRequestException(&#39;jpg/jpeg/png 파일만 없로드 가능합니다. &#39;), false);
        }
        return cb(null, true);
    },
    storage: multer.diskStorage({
        // 파일 저장시 어디로 이동 시킬까?
        destination: function(req, res, cb) { 
              cb(null, POST_IMAGE_PATH); // 파일 업로드 위치
        },
        // 파일 이름 뭐로 지을래?
        filename: function(req, res, cb) {
            // uuid + file의 확장자
            cb(null, `${uuid()}${extname(file.originalname)}`) // POST_IMAGE_PATH로 이동
        }
    }),
}),</code></pre>
<hr>
<h3 id="🖊️fileinterceptor-적용">🖊️FileInterceptor 적용</h3>
<p>컨트롤러에서 파일업로드를 어떻게 받는지 확인해보겠습니다. postsPost는 데이터를 새롭게 추가하는 메소드이기 때문에 <code>@UseInterceptor</code>를 추가합니다. 그리고 내부에는 FileInterceptor를 넣고 파라미터로 파일을 업로드할 필드의 이름을 넣습니다.</p>
<p>만약 <code>image</code>가 되면 image라는 키값에 <code>uuid.png</code>가 value로 매칭이 되고 컨트롤러 로직이 실행됩니다.</p>
<p>즉, 컨트롤러 로직은 <code>@UseInterceptor</code>를 통해서 posts.module.ts에 작성한 로직이 전부 통과되어야 발동하는 것입니다.</p>
<ul>
<li>posts.controller.ts</li>
</ul>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(FileInterceptor(&#39;image&#39;)) // 파일을 업로드할 필드의 이름 -&gt; image라는 키값에 넣어서 보냄
postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
) {
    return this.postsService.createPost(
          userId, body
    );
}</code></pre>
<p>그러면 만들어진 파일을 받고 사용하는 코드를 작성하겠습니다.</p>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(FileInterceptor(&#39;image&#39;))
postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
      @UploadedFile() file?: Express.Multer.File, // 추가
) {
    return this.postsService.createPost(
          userId, body
    );
}</code></pre>
<p>객체를 생성할 때 이미지를 넣어야하니까 controller와 posts.service.ts 모두 추가를 하겠습니다.</p>
<pre><code class="language-typescript">@Post()
@UseGuards(AccessTokenGuard)
@UseInterceptors(FileInterceptor(&#39;image&#39;))
postPosts(
    @User(&#39;id&#39;) userId: number,
    @Body() body: CreatePostDto,
      @UploadedFile() file?: Express.Multer.File,
) {
    return this.postsService.createPost(
          userId, body, file?.filename, // filename은 file이 null이 아닌 경우에만 들어올 수 있으니까
    );
}</code></pre>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">async createPost(authorId: number, postDto: CreatePostDto, image?: string) { // 추가
    const post = this.postsRepository.create({
        author: {
              id: authorId,
        },
        ...postDto,
        image, // 추가
        likeCount: 0,
        commentCount: 0,
    });
    const newPost = await this.postsRepository.save(post);
    return newPost;
}</code></pre>
<p>포스트맨으로 테스트를 해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/871a523a-aa7f-4e81-8058-dd8b0d699823/image.png" alt=""></p>
<pre><code>{
    &quot;title&quot;: &quot;파일 업로드&quot;,
    &quot;content&quot;: &quot;파일 업로드 테스트&quot;,
    &quot;image&quot;: &quot;06961c7a-b923-4d24-a13b-d4cc2fa5ee63.png&quot;, // uuid
    &quot;likeCount&quot;: 0,
    &quot;commentCount&quot;: 0,
    &quot;author&quot;: {
        &quot;id&quot;: 1
    },
    &quot;id&quot;: 109,
    &quot;updatedAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;,
    &quot;createdAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;
}</code></pre><p>실제로 저장이 되었는지 확인해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d186e882-3c7d-43e9-abf5-69d74975fe87/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 109,
            &quot;updatedAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;,
            &quot;createdAt&quot;: &quot;2024-02-04T04:30:17.814Z&quot;,
            &quot;title&quot;: &quot;파일 업로드&quot;,
            &quot;content&quot;: &quot;파일 업로드 테스트&quot;,
            &quot;image&quot;: &quot;06961c7a-b923-4d24-a13b-d4cc2fa5ee63.png&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },
        .
        .
        {
            &quot;id&quot;: 90,
            &quot;updatedAt&quot;: &quot;2024-01-28T02:12:54.437Z&quot;,
            &quot;createdAt&quot;: &quot;2024-01-28T02:12:54.437Z&quot;,
            &quot;title&quot;: &quot;임의로 생성된 81&quot;,
            &quot;content&quot;: &quot;임의로 생성된 포수트 내용 81&quot;,
            &quot;image&quot;: null,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: 90
    },
    &quot;count&quot;: 20,
    &quot;next&quot;: &quot;http://localhost:3000/posts?order__createdAt=DESC&amp;take=20&amp;where__id__less_than=90&quot;
}</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/89f70649-f35c-478d-96c9-85a8a62d692b/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS-config module]]></title>
            <link>https://velog.io/@jaegeunsong_1997/NestJS-config-module-l5oppmhx</link>
            <guid>https://velog.io/@jaegeunsong_1997/NestJS-config-module-l5oppmhx</guid>
            <pubDate>Sun, 04 Feb 2024 08:11:59 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️env-파일-작성">🖊️ENV 파일 작성</h3>
<ul>
<li>auth/const/auth.const.ts</li>
</ul>
<pre><code class="language-typescript">export const PROTOCOL = &#39;http&#39;;
export const HOST = &#39;localhost:3000&#39;;</code></pre>
<p>지금 우리는 환경변수를 다음과 같이 작성을 했습니다. 매우 중요한 정보이기 때문에 노출이 안되어야 합니다. 따라서 깃허브와 같은 곳에서는 깃허브에 올릴 때, <code>gitignore</code>를 해줘야 합니다.</p>
<p>그리고 <code>nest.js</code>에서는 이런 환경 변수를 잘 다룰 수 있도록 <code>config 모듈</code>을 제공해주고 있습니다. 이 모듈은 기본으로 설치되어 있는 모듈이 아니기 때문에 설치를 해줘야합니다.</p>
<pre><code>yarn add @nestjs/config</code></pre><p>일단 환경변수를 어떤 식으로 선언하는지 보도록 하겠습니다. 최상단에 <code>.env</code>파일을 생성합니다.</p>
<p>그리고 <code>.env</code>파일을 생성하면 가장 먼저 해야할 것이 <code>gitignore</code>에 등록을 해줘야 합니다. 깃에서 관리를 하지 못하게 만드는 것입니다. <code>auth/common/const/env.const.ts</code>에 있는 내용을 <code>.env</code>파일에 작성을 해줍니다.</p>
<p>또한 JWT관련 내용도 추가하겠습니다.</p>
<ul>
<li>.env</li>
</ul>
<pre><code>JWT_SECRET=codefactory
HASH_ROUNDS=10
PROTOCOL=http
HOST=localhost:3000</code></pre><p>그리고 app.module.ts를 보면 데이터베이스 내용들도 전부 하드코딩으로 박혀있습니다.</p>
<pre><code class="language-typescript">TypeOrmModule.forRoot({
    type: &#39;postgres&#39;,
    host: &#39;127.0.0.1&#39;,
    port: 5433,
    username: &#39;postgresql&#39;,
    password: &#39;postgresql&#39;,
    database: &#39;postgresql&#39;,
    entities: [
        PostsModel,
        UsersModel,
    ],
    synchronize: true,
}),</code></pre>
<p>이런 정보들도 PROD가 실행되면 보안을 위해서 숨겨야합니다. 이 정보 또한 env 파일로 옮기겠습니다.</p>
<ul>
<li>.env</li>
</ul>
<pre><code>JWT_SECRET=codefactory
HASH_ROUNDS=10

PROTOCOL=http
HOST=localhost:3000

DB_HOST=localhost
DB_PORT=5433
DB_USERNAME=postgresql
DB_PASSWORD=postgresql
DB_DATABASE=postgresql</code></pre><hr>
<h3 id="🖊️환경변수-적용">🖊️환경변수 적용</h3>
<p>이제부터는 env파일에 적힌 환경변수를 사용해보겠습니다. 일단 app.module.ts에 Config 모듈을 등록해야합니다.</p>
<ul>
<li>app.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
          ConfigModule.forRoot({
            envFilePath: &#39;.env&#39;, // nest.js에서 사용될 env 닉네임
            isGlobal: true, // AppModule에 설정해주면 어디서든 사용 가능
        }),
        TypeOrmModule.forRoot({
            type: &#39;postgres&#39;,
            host: &#39;127.0.0.1&#39;,
            port: 5433,
            username: &#39;postgresql&#39;,
            password: &#39;postgresql&#39;,
            database: &#39;postgresql&#39;,
            entities: [
                PostsModel,
                UsersModel,
            ],
            synchronize: true,
        }),
        PostsModule,
        UsersModule,
        AuthModule,
        CommonModule,
    ],
    controllers: [AppController],
    providers: [AppService, {
        provide: APP_INTERCEPTOR,
        useClass: ClassSerializerInterceptor,
    }],
})
export class AppModule {}</code></pre>
<p>그리고 환경변수들이 사용되는 부분을 전부 바꿔줍니다. 일단 <code>auth.const.ts</code> 파일을 삭제합니다. 그리고 에러가 나오는 부분을 전부 바꾸겠습니다.</p>
<ul>
<li>auth.service.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class AuthService {

     constructor(
          private readonly jwtService: JwtService,
          private readonly usersService: UsersService,
          private readonly configService: ConfigService, // 추가
     ) {}
.
.
verifyToken(token: string) {
    try {
        return this.jwtService.verify(token, { 
              secret: this.configService.get&lt;string&gt;(&#39;JWT_SECRET&#39;), // 키값을 넣기 + 어떤 type을 가져올지 명시
        }); 
    } catch (error) {
          throw new UnauthorizedException(&#39;토큰이 만료됐거나 잘못된 토큰입니다. &#39;);
    }
}</code></pre>
<p>근데 지금처럼 JWT_SECRET하는 것이 기분이 나쁩니다. 왜냐하면 이전에 작성한 것과 사실상 별 다른게 없기 때문입니다. 오타의 가능성도 있고 키값의 이름이 변경될 수 있기 때문입니다.</p>
<p>따라서 key값들을 정리하도록 하겠습니다. common/const에 새로운 파일을 생성하겠습니다.</p>
<ul>
<li>common/const/env-keys.const.ts</li>
</ul>
<pre><code class="language-typescript">// 서버 프로토콜 -&gt; http / https
export const ENV_PROTOCOL_KEY = &#39;PROTOCOL&#39;;
// 서버 호스트 -&gt; localhost:3000
export const ENV_HOST_KEY = &#39;HOST&#39;;
// JWT 토큰 시크릿 -&gt; codefactory
export const ENV_JWT_SECRET_KEY = &#39;JWT_SECRET&#39;;
// JWT 토큰 해시 라운드 수 -&gt; 10
export const ENV_HASH_ROUNDS_KEY = &#39;HASH_ROUNDS&#39;;
// 데이터베이스 호스트 -&gt; localhost
export const ENV_DB_HOST_KEY = &#39;DB_HOST&#39;;
// 데이터베이스 포트 -&gt; 5433
export const ENV_DB_PORT_KEY = &#39;DB_PORT&#39;;
// 데이터베이스 사용자 이름 -&gt; postgresql
export const ENV_DB_USERNAME_KEY = &#39;DB_USERNAME&#39;;
// 데이터베이스 사용자 비밀번호 -&gt; postgresql
export const ENV_DB_PASSWORD_KEY = &#39;DB_PASSWORD&#39;;
// 데이터베이스 이름
export const ENV_DB_DATABASE_KEY = &#39;DB_DATABASE&#39;;</code></pre>
<p>이제 auth.service.ts를 바꿔줍니다.</p>
<ul>
<li>auth.service.ts</li>
</ul>
<pre><code class="language-typescript">verifyToken(token: string) {
    try {
        return this.jwtService.verify(token, { 
              secret: this.configService.get&lt;string&gt;(ENV_JWT_SECRET_KEY), // 적용
        }); 
    } catch (error) {
          throw new UnauthorizedException(&#39;토큰이 만료됐거나 잘못된 토큰입니다. &#39;);
    }
}
.
.
rotateToken(token: string, isRefreshToken: boolean) {
    const decoded = this.jwtService.verify(token, {
          secret: this.configService.get&lt;string&gt;(ENV_JWT_SECRET_KEY), // 적용
    });

    if (decoded.type !== &#39;refresh&#39;) throw new UnauthorizedException(&#39;토큰 재발급은 refresh 토큰으로만 가능합니다.&#39;);
    return this.signToken({
      ...decoded,
    }, isRefreshToken);
}
.
.
signToken(user: Pick&lt;UsersModel, &#39;email&#39; | &#39;id&#39;&gt;, isRefreshToken: boolean) {
    const payload = {
        email: user.email,
        sub: user.id,
        type: isRefreshToken ? &#39;refresh&#39; : &#39;access&#39;,
    }
    return this.jwtService.sign(payload, {
        secret: this.configService.get&lt;string&gt;(ENV_JWT_SECRET_KEY), // 변경
        expiresIn: isRefreshToken ? 3600 : 300,
    });
}
.
.
async registerWithEmail(user: RegisterUserDto) {
    const hash = await bcrypt.hash(
        user.password,
        parseInt(this.configService.get&lt;string&gt;(ENV_HASH_ROUNDS_KEY)), // 변경
    );
    const newUser = await this.usersService.createUser({
        ...user,
        password: hash,
    });
    return this.loginUser(newUser);
}</code></pre>
<p>이번에는 env.const.ts를 삭제하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/405a8a2a-d0b4-4b18-ac25-5dc219080f91/image.png" alt=""></p>
<ul>
<li>common.service.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class CommonService {

      // 추가
    constructor (
        private readonly configService: ConfigService, 
    ) {}
.
.
private async cursorPaginate&lt;T extends BaseModel&gt;(
    dto: BasePaginationDto,
    repository: Repository&lt;T&gt;,
    overrideFindOptions: FindManyOptions&lt;T&gt; = {},
    path: string,
) {

    const findOptions = this.composeFindOptions&lt;T&gt;(dto);
    const results = await repository.find({
        ...findOptions,
        ...overrideFindOptions,
    });

    const lastItem = results.length &gt; 0 &amp;&amp; results.length === dto.take ? results[results.length - 1] : null;

      // 변경
    const protocol = this.configService.get&lt;string&gt;(ENV_PROTOCOL_KEY);
    const host = this.configService.get&lt;string&gt;(ENV_HOST_KEY);
    const nextUrl = lastItem &amp;&amp; new URL(`${protocol}://${host}/${path}`);
      // 여기까지</code></pre>
<ul>
<li>posts.service.ts</li>
</ul>
<pre><code class="language-typescript">@Injectable()
export class PostsService {

    constructor(
    @InjectRepository(PostsModel)
       private readonly postsRepository: Repository&lt;PostsModel&gt;,
       private readonly commonService: CommonService,
       private readonly configService: ConfigService, // 추가
    ) {}
.
.
async cursorPaginatePosts(dto: PaginatePostDto) {
    const where: FindOptionsWhere&lt;PostsModel&gt; = {};
    if (dto.where__id__less_than) {
          where.id = LessThan(dto.where__id__less_than);
    } else if(dto.where__id__more_than) {
          where.id = MoreThan(dto.where__id__more_than);
    }

    const posts = await this.postsRepository.find({
        where,
        order: {
              createdAt: dto.order__createdAt,
        },
        take: dto.take,
    });

    const lastItem = posts.length &gt; 0 &amp;&amp; posts.length === dto.take ? posts[posts.length - 1] : null;

      // 변경
    const protocol = this.configService.get&lt;string&gt;(ENV_PROTOCOL_KEY);
    const host = this.configService.get&lt;string&gt;(ENV_HOST_KEY);
    const nextUrl = lastItem &amp;&amp; new URL(`${protocol}://${host}/posts`);
      // 여기까지</code></pre>
<p>잘 나오는지 포스트맨으로 테스트를 하겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/ba68aea6-1fe7-4568-9a43-d76ae38fdd9e/image.png" alt=""></p>
<pre><code>{
    &quot;accessToken&quot;: &quot;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGNvZGVmYWN0b3J5LmFpIiwic3ViIjoxLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzA3MDMzNTgyLCJleHAiOjE3MDcwMzM4ODJ9.28YvVZL4M2jfzIWnFyBvCOLMtvSCujZmRqsIac74YjM&quot;,
    &quot;refreshToken&quot;: &quot;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGNvZGVmYWN0b3J5LmFpIiwic3ViIjoxLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTcwNzAzMzU4MiwiZXhwIjoxNzA3MDM3MTgyfQ.uwOTeY3b70GQwSzPOBaEbH0Ch8Nxpn-u0gdWoHOjrN4&quot;
}</code></pre><p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/9283c0c2-4291-4e0c-9ee2-501deb31b849/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 101,
            &quot;updatedAt&quot;: &quot;2024-01-28T02:12:54.520Z&quot;,
            &quot;createdAt&quot;: &quot;2024-01-28T02:12:54.520Z&quot;,
            &quot;title&quot;: &quot;임의로 생성된 92&quot;,
            &quot;content&quot;: &quot;임의로 생성된 포수트 내용 92&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },
        .
        .
        {
            &quot;id&quot;: 29,
            &quot;updatedAt&quot;: &quot;2024-01-28T02:12:53.979Z&quot;,
            &quot;createdAt&quot;: &quot;2024-01-28T02:12:53.979Z&quot;,
            &quot;title&quot;: &quot;임의로 생성된 20&quot;,
            &quot;content&quot;: &quot;임의로 생성된 포수트 내용 20&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        }
    ],
    &quot;total&quot;: 17
}</code></pre><p>env 파일을 사용하고도 요청이 잘 오는 것을 확인할 수 있습니다.</p>
<hr>
<h3 id="🖊️process-객체를-이용한-환경변수-불러오기">🖊️process 객체를 이용한 환경변수 불러오기</h3>
<p>이번에는 DB관련 환경변수를 사용하도록 하겠습니다. app.module.ts에서는 configService로 주입을 받아 가져올 수 가 없습니다. 따라서 <code>process</code>라는 글로벌 변수를 사용해서 <code>process.env</code>를 하면 환경변수를 가져올 수 있습니다.</p>
<ul>
<li>app.module.ts</li>
</ul>
<pre><code class="language-typescript">@Module({
    imports: [
        ConfigModule.forRoot({
            envFilePath: &#39;.env&#39;,
            isGlobal: true,
        }),
        TypeOrmModule.forRoot({
            type: &#39;postgres&#39;,
              // 변경
            host: process.env[ENV_DB_HOST_KEY],
            port: parseInt(process.env[ENV_DB_PORT_KEY]),
            username: process.env[ENV_DB_USERNAME_KEY],
            password: process.env[ENV_DB_PASSWORD_KEY],
            database: process.env[ENV_DB_DATABASE_KEY],
              // 여기까지
            entities: [
                PostsModel,
                UsersModel,
            ],
            synchronize: true,
        }),
        PostsModule,
        UsersModule,
        AuthModule,
        CommonModule,
        ConfigModule,
    ],
    controllers: [AppController],
    providers: [AppService, {
        provide: APP_INTERCEPTOR,
        useClass: ClassSerializerInterceptor,
    }],
})
export class AppModule {}</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/8f69a70d-dd2b-4e42-bfcb-563d09d6def1/image.png" alt=""></p>
<p>저장하고 실행하면 터미널이 잘 실행되는 것을 알 수 있습니다. 하지만 잘 못 입력이 된 경우, 에러를 확인하기 위해 password를 빈값으로 해보겠습니다.</p>
<pre><code class="language-typescript">@Module({
    imports: [
        ConfigModule.forRoot({
            envFilePath: &#39;.env&#39;,
            isGlobal: true,
        }),
        TypeOrmModule.forRoot({
            type: &#39;postgres&#39;,
              // 변경
            host: process.env[ENV_DB_HOST_KEY],
            port: parseInt(process.env[ENV_DB_PORT_KEY]),
            username: process.env[ENV_DB_USERNAME_KEY],
            password: &#39;&#39;, // 에러~~~~~~~~~~~~~~~~~~~~
            database: process.env[ENV_DB_DATABASE_KEY],
              // 여기까지
            entities: [
                PostsModel,
                UsersModel,
            ],
            synchronize: true,
        }),
        PostsModule,
        UsersModule,
        AuthModule,
        CommonModule,
        ConfigModule,
    ],
    controllers: [AppController],
    providers: [AppService, {
        provide: APP_INTERCEPTOR,
        useClass: ClassSerializerInterceptor,
    }],
})
export class AppModule {}</code></pre>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/5f2fbfcd-724c-4780-9efa-bc0a113c73ed/image.png" alt=""></p>
<p>페이지네이션 요청도 확인해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/d06137c7-4d87-498e-8aea-2e70f24b86c8/image.png" alt=""></p>
<pre><code>{
    &quot;data&quot;: [
        {
            &quot;id&quot;: 108,
            &quot;updatedAt&quot;: &quot;2024-01-28T02:12:54.578Z&quot;,
            &quot;createdAt&quot;: &quot;2024-01-28T02:12:54.578Z&quot;,
            &quot;title&quot;: &quot;임의로 생성된 99&quot;,
            &quot;content&quot;: &quot;임의로 생성된 포수트 내용 99&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        },
        .
        .
        {
            &quot;id&quot;: 89,
            &quot;updatedAt&quot;: &quot;2024-01-28T02:12:54.431Z&quot;,
            &quot;createdAt&quot;: &quot;2024-01-28T02:12:54.431Z&quot;,
            &quot;title&quot;: &quot;임의로 생성된 80&quot;,
            &quot;content&quot;: &quot;임의로 생성된 포수트 내용 80&quot;,
            &quot;likeCount&quot;: 0,
            &quot;commentCount&quot;: 0,
            &quot;author&quot;: {
                &quot;id&quot;: 1,
                &quot;updatedAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;createdAt&quot;: &quot;2024-01-26T05:58:10.800Z&quot;,
                &quot;nickname&quot;: &quot;codefactory&quot;,
                &quot;email&quot;: &quot;codefactory@codefactory.ai&quot;,
                &quot;role&quot;: &quot;USER&quot;
            }
        }
    ],
    &quot;cursor&quot;: {
        &quot;after&quot;: 89
    },
    &quot;count&quot;: 20,
    &quot;next&quot;: &quot;http://localhost:3000/posts?order__createdAt=DESC&amp;take=20&amp;where__id__less_than=89&quot;
}</code></pre><p>환경변수를 불러오는 방법은 2가지가 있습니다. process를 사용하는 방법이 있고, 이 방법보다 좋은 방법인 configService를 주입받아 get함수를 실행해서 가져오는 방법입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SEQUENCE와 IDENTITY]]></title>
            <link>https://velog.io/@jaegeunsong_1997/SEQUENCE%EC%99%80-IDENTITY</link>
            <guid>https://velog.io/@jaegeunsong_1997/SEQUENCE%EC%99%80-IDENTITY</guid>
            <pubDate>Wed, 31 Jan 2024 06:46:31 GMT</pubDate>
            <description><![CDATA[<h3 id="🖊️identity">🖊️identity</h3>
<p>identity전략은 DB에 값을 저장하고 나서야 기본 키 값을 구할 수 있습니다.</p>
<pre><code class="language-java">// IDENTITY 매핑 코드
@Entity
public class Member{

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
     private Long id;
     ...
   }
.
.
.
private static void logic(EntityManager em) {
   Member member = new Member();
   em.persist(member);
   System.out.println(&quot;member.id = &quot; + member.getId());
}
// 출력: member.id = 1</code></pre>
<p>이유는 엔티티가 영속 상태가 되기 위해서는 식별자가 필요합니다. IDENTITY 전략의 경우, 식별자 생성을 DB에 저장해야 얻을 수 있으므로 <code>em.persist()</code>를 호출하여 <code>객체를 영속성 상태로 만드는 순간 INSERT SQL이 호출</code>됩니다. 따라서 <code>LAZY LOADING</code> 못합니다.</p>
<h3 id="🖊️sequence">🖊️sequence</h3>
<p>DB 시퀀스는 유일한 값을 순서대로 생성하는 특별한 DB 오브젝트입니다. SEQUENCE 전력은 이 <code>SEQUENCE를 사용해서</code> 기본 키를 생성합니다.</p>
<pre><code class="language-java">// 시퀀스 DDL
CREATE TABLE MEMBER (
    ID BIGINT NOT NULL PRIMARY KEY,
   DATA VARCHAR(255)
)

// 시퀀스 생성
CREATE SEQUENCE MEMBER_SEQ START WITH 1 INCREMENT BY 1;
.
.
.
// 시퀀스 매핑 코드
@Entity
@SequenceGenerator(
   name = &quot;MEMBER_SEQ_GENERATOR&quot;,
   sequenceName = &quot;MEMBER_SEQ&quot;, //매핑할 데이터베이스 시퀀스 이름
   initialValue = 1, allocationSize = 1)
public class Member{

   @Id
   @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = &quot;MEMBER_SEQ_GENERATOR&quot;)
   private Long id;
}</code></pre>
<p><code>em.persist()</code>를 호출 시, <code>DB 시퀀스를 사용해서 식별자를 조회</code>합니다. <code>식별자를 엔티티에 할당한 후에 엔티티를 영속성 컨텍스트에 저장</code>합니다. 이 후 트랜잭션을 커밋해서 플러시가 일어났을 경우, 엔티티를 DB에 저장합니다.</p>
<p>IDENTITY과의 차이점은 IDENTITY의 경우 영속성 컨텍스트에 저장하기 위해 INSERT를 바로 날린다는 점입니다.</p>
<ul>
<li>name: 식별자 생성기 이름</li>
<li>sequenceName: DB에 등록된 시퀀스 이름</li>
<li>initialValueDDL: 생성시에만 사용됨</li>
<li>allocationSize: 50(시퀀스 한 번 호출에 증가하는 수)</li>
<li>catalog, schema: DB catalog, schema 이름</li>
</ul>
<p><code>allocationSize</code></p>
<p>결론은 데이터베이스에 여러번 접근하는 것을 방지해서 성능적으로 장점이 있기 때문입니다.</p>
<p>SEQUENCE 전략은 데이터베이스 시퀀스를 통해 식별자를 조회하는 추가 작업이 필요합니다. 따라서 다음과 같이 데이터베이스와 2번의 통신을 합니다. </p>
<ol>
<li>식별자를 구하려고 데이터베이스 시퀀스를 조회한다. ex) SELECT MEMBER_SEQ.NEXTVAL … </li>
<li>조회한 시퀀스를 기본 값으로 사용해 데이터베이스에 저장한다. ex) INSERT INTO MEMBER …</li>
</ol>
<p>위와 같이 시퀀스에 접근하는 것을 줄이기 위해 JPA는 @SequenceGenerator.allocationSize를 사용합니다. 간단히 설명하면, 여기에 설정한 값만큼 한 번에 시퀀스 값을 증가시키고 나서 그만큼 메모리에 시퀀스 값을 할당합니다.</p>
<p>예를들어, allocation 값이 50이면 시퀀스를 한 번에 50을 증가시킨 다음에 1 ~ 50까지는 메모리에 시퀀스 값을 할당합니다. 그리고 51이 되면 시퀀스 값을 100으로 증가시킨 다음 51 ~ 100까지 메모리에서 식별자를 할당합니다.</p>
<p>이 최적화 방법은 시퀀스 값을 선점하므로 여러 JVM이 동시에 동작해도 기본 키 값이 충돌하지 않는 장점이 있습니다. 반면에 데이터베이스에 직접 접근해서 데이터를 등록할 때 시퀀스 값이 한 번에 많이 증가한다는 점을 염두해두어야 합니다. 이런 상황이 부담스럽고 INSERT 성능이 중요하지 않으면 allocationSize의 값을 1로 설정하면 됩니다.</p>
<hr>
<p><code>GenerationType.IDENTITY</code>는 DB의 자동 증기 기능을 사용하여 주 키를 생성하는 방식이고, DB에 의존적입니다.</p>
<p><code>GenerationType.SEQUENCE</code>는 DB의 시퀀스를 사용하여 주 키를 생성하는 방식이고, DB에 의존적이지만 시퀀스의 사용을 명시적으로 지정할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@JsonInclude]]></title>
            <link>https://velog.io/@jaegeunsong_1997/JsonInclude</link>
            <guid>https://velog.io/@jaegeunsong_1997/JsonInclude</guid>
            <pubDate>Wed, 31 Jan 2024 05:19:30 GMT</pubDate>
            <description><![CDATA[<p><code>@JsonInclude</code>는 Java 객체를 JSON으로 변환할 때 포함할 필드를 지정합니다. 객체의 일부 필드를 무시하고 JSON으로 변환할 떄 해달 필드를 제외할 수 있습니다.</p>
<ol>
<li><code>@JsonInclude(JsonInclude.Include.NON_NULL)</code></li>
</ol>
<p>Null이 아닌 필드만 JSON으로 포함됩니다. Null인 경우 JSON에서 생략됩니다.</p>
<pre><code class="language-java">@JsonInclude(JsonInclude.Include.NON_NULL)
public class MyObject {
    private String name;
    private Integer age;
    // getters and setters...
}</code></pre>
<ol start="2">
<li><code>@JsonInclude(JsonInclude.Include.NON_EMPTY)</code></li>
</ol>
<p>Null이 아니고, 빈 문자열(&quot;&quot;)이 아닌 필드만 포함됩니다. 즉, 문자열 필드가 비어있는 경우에도 JSON에서 생략됩니다.</p>
<pre><code class="language-java">@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class MyObject {
    private String name;
    private String address;
    // getters and setters...
}</code></pre>
<ol start="3">
<li><code>@JsonInclude(JsonInclude.Include.NON_DEFAULT)</code></li>
</ol>
<p>기본값으로 초기화된 필드만 포함됩니다. 숫자형은 0, boolean은 false가 됩니다.</p>
<pre><code class="language-java">@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public class MyObject {
    private int id; // 기본값 0
    private boolean active; // 기본값 false
    // getters and setters...
}</code></pre>
<ol start="4">
<li><code>@JsonInclude(JsonInclude.Include.ALWAYS)</code></li>
</ol>
<p>항상 모든 필드를 포함합니다. Null이든 아니든 상관없이 모든 필드를 JSON에 포함합니다.</p>
<pre><code class="language-java">@JsonInclude(JsonInclude.Include.ALWAYS)
public class MyObject {
    private String name;
    private Integer age;
    // getters and setters...
}</code></pre>
<ol start="5">
<li><code>사용자 정의 로직</code></li>
</ol>
<p>사용자 정의로직을 제공해서 어떤 필드를 포함시킬지 결정할 수도 있습니다.</p>
<pre><code class="language-java">@JsonInclude(
    value = JsonInclude.Include.CUSTOM,
    valueFilter = MyCustomFilter.class
)
public class MyObject {
    private String sensitiveData;
    // getters and setters...
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[SecurityContextHolder]]></title>
            <link>https://velog.io/@jaegeunsong_1997/SecurityContextHolder</link>
            <guid>https://velog.io/@jaegeunsong_1997/SecurityContextHolder</guid>
            <pubDate>Wed, 31 Jan 2024 03:19:02 GMT</pubDate>
            <description><![CDATA[<pre><code class="language-java">SecurityContextHolder
.getContext()
.getAuthentication()
.getPrincipal()</code></pre>
<p>Spring Security 컨텍스트에서 사용자의 정보를 가져오는 코드입니다.</p>
<p>Spring Security는 인증된 사용자의 정보를 <code>Authenticatoin</code> 객체에 저장합니다.</p>
<p>따라서 <code>SecurityContextHolder.getContext().getAuthentication()</code>는 Authentication을 반환하게 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/jaegeunsong_1997/post/7a07d089-c618-43b7-a00b-060d36f6c393/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[BaseEntity 설계]]></title>
            <link>https://velog.io/@jaegeunsong_1997/BaseEntity-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@jaegeunsong_1997/BaseEntity-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Wed, 31 Jan 2024 03:05:38 GMT</pubDate>
            <description><![CDATA[<h1 id="정의">정의</h1>
<p>실무에서 BaseEntity 추상클래스가 반드시 가져야하는 필드들입니다.</p>
<p><code>serialVersionUID</code>: 데이터의 무결성을 지키기 위해서 <code>고유 식별자</code>로 반드시 존재해야합니다. 직렬화와 역직렬화시 매핑을 통해 호환성을 체크합니다.</p>
<p><code>updateCount</code>: DB row, 즉 객체가 몇 번 바뀌게 되었는지 카운팅을 해주며 <code>버전 정보</code>를 의미합니다. <code>Optimistic Lock</code>을 통해 <code>데이터의 일관성</code>을 체크합니다. 처음과 마지막을 조회하면서 값이 다르면 중간에 다른 스레드가 접근하고 값을 바꾼것으로 인지해 에러를 보냅니다.</p>
<p><code>createDate</code>: 생성날짜</p>
<p><code>updateDate</code>: 수정날짜</p>
<p><code>deleteFlag</code>: 사용유무</p>
<p><code>createUserId</code>: 누가 생성을 했는지</p>
<p><code>updateUserId</code>: 누가 업데이트를 했는지</p>
<hr>
<h1 id="결론">결론</h1>
<p>전반적으로 보면 DB에서 <code>데이터를 추적</code>하거나 <code>데이터의 일관성</code>을 지키는 쪽과 관련된 것을 알 수 있습니다.</p>
]]></description>
        </item>
    </channel>
</rss>