<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Dat.queue</title>
        <link>https://velog.io/</link>
        <description>You better cool it off before you burn it out /</description>
        <lastBuildDate>Thu, 29 Feb 2024 08:12:07 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Dat.queue</title>
            <url>https://velog.velcdn.com/images/from_numpy/profile/d80ec615-46e2-411b-926e-9b49bd3cb842/image.jfif</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Dat.queue. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/from_numpy" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[주문 결제 #5] 재고 차감 시나리오를 통해 알아보는 Lock]]></title>
            <link>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-5-%EC%A7%9A%EA%B3%A0-%EB%84%98%EC%96%B4%EA%B0%80%EB%8A%94-DB-Lock-Transaction</link>
            <guid>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-5-%EC%A7%9A%EA%B3%A0-%EB%84%98%EC%96%B4%EA%B0%80%EB%8A%94-DB-Lock-Transaction</guid>
            <pubDate>Thu, 29 Feb 2024 08:12:07 GMT</pubDate>
            <description><![CDATA[<p>추후 소개 할 &quot;<u>메뉴 재고 차감</u>&quot; 로직에 앞서 데이터베이스 관점에서 트랜잭션과 그에 따른 락 처리등에 대해 알아보고자 한다. </p>
<p>메뉴 재고를 차감하는 다양한 시나리오가 있을 것이다. 각각의 서비스마다, 혹은 구현해야 할 기능마다 적합한 재고 차감 시나리오가 존재할 것이며 어울리는 최적의 트랜잭션과 락을 구축하는 것이 중요하다. </p>
</br>

<h2 id="🧃-lock-wait-timeout-exceeded--deadlock">🧃 <code>Lock wait timeout exceeded + DeadLock</code></h2>
<p>주문 트랜잭션 내부 <u>메뉴 재고 차감</u> 로직에서 유의해야 할 부분은 무엇일까?</p>
<p>이전 포스팅에서도 언급하였다시피 <span style="color:red"><strong>&quot;Lock Timeout&quot;</strong></span>의 문제가 있을 것이다. 만약 주문 과정에 있어 동일한 메뉴에 여러 명의 유저가 접근하게 될 경우 대기시간이 발생할 것이다. 이때 발생할 수 있는 timeout exceeded(타임아웃 초과)를 해결하기 위해 결제 모듈을 트랜잭션으로부터 분리하는 등의 과정을 거쳤지만 이는 100%의 해결법이 될 수 없고 추가로 발생할 수 있는 <span style="color:red"><strong>&quot;Dead Lock(교착 상태)&quot;</strong></span>까지 고려해야 한다.</p>
<p>추가적으로 주문 트랜잭션 수행 중 <strong>&quot;재고 수량&quot;</strong>에 대해 <u>어느 정도</u>의 <strong>&quot;격리 혹은 유연함&quot;</strong>을 부여할 것인가에 관해 고민해 볼 필요가 있다. 이 부분에서 가장 중요한 것은 본인이 진행하고 있는 프로젝트의 (그 중에서도 해당 재고 차감 기능) 특징에 대해 정확히 이해하는 것이지 않을까 싶다. </p>
<p>같은 재고 차감이라 할 지라도 프로세스의 특징에 따라 저 마다의 유연함과 격리(엄격함)성이 요구될 것이다.</p>
</br>

<h3 id="test-scenario">&gt; <code>Test Scenario</code></h3>
<p>테스트를 진행하기에 앞서 도커를 통해 mysql을 구동하여 준다.</p>
<pre><code>docker run --name example_db -e MYSQL_ROOT_PASSWORD=root -p 3308:3306 -d mysql</code></pre><p>(port: 3308, container_name: example_db)</p>
<p>그 후 아래와 같이 mysql로 접속할 수 있다. </p>
<pre><code>docker exec -it example_db mysql -u root -p</code></pre></br>


<ol>
<li><p>테이블 생성 및 데이터 삽입</p>
<pre><code>mysql&gt; USE example_db;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql&gt; CREATE TABLE menus (menuId INT PRIMARY KEY, stock INT);
Query OK, 0 rows affected (0.11 sec)

mysql&gt; INSERT INTO menus (menuId, stock) VALUES (1, 10), (2, 10);
Query OK, 2 rows affected (0.02 sec)
Records: 2  Duplicates: 0  Warnings: 0</code></pre><p><code>menus</code> 테이블을 생성한 후 간단한 테스트를 위해 2개의 레코드를 등록한다.</p>
<p>```
mysql&gt; SELECT * FROM menus;</p>
</li>
</ol>
<p>+--------+-------+
| menuId | stock |
+--------+-------+
|      1 |    10 |
|      2 |    10 |
+--------+-------+
2 rows in set (0.00 sec)
    ```</p>
<ol start="2">
<li><p>두 개의 세션에 각 각 트랜잭션 시작</p>
<p> 락을 통한 교착 상태를 테스트 하기 위해 2개의 세션 준비 (터미널로 진행)</p>
<p>각 각의 세션에 트랜잭션 시작</p>
<pre><code>mysql&gt; START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)</code></pre><ol start="3">
<li>첫 번째 세션에서 <code>menuId=1</code>인 메뉴의 재고 차감</li>
</ol>
<pre><code>mysql&gt; UPDATE menus SET stock = stock - 1 WHERE menuId = 1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql&gt; SELECT * FROM menus;
+--------+-------+
| menuId | stock |
+--------+-------+
|      1 |     9 |
|      2 |    10 |
+--------+-------+
2 rows in set (0.00 sec)</code></pre><p><span style="color:red">commit (x)</span></p>
</br>

<ol start="4">
<li>두 번째 세션에서 <code>menuId=2</code>인 메뉴의 재고 차감</li>
</ol>
<pre><code>mysql&gt; UPDATE menus SET stock = stock - 1 WHERE menuId = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0</code></pre><p><span style="color:red">commit (x)</span></p>
<p>  자, 이렇게 함으로써 현재 첫 번째 세션은 <code>menuId=1</code>의 메뉴에 대한 락을 취득하였고 두 번째 세션은 <code>menuId=2</code>에 대한 락을 취득하게 되었다.</p>
<p> 이제부터가 문제를 맞이할 수 있는 상황이다.</p>
<p>  현재 임의로 두 세션의 UPDATE 쿼리 수행 후 <code>commit</code>을 하지 않았다. 이는 <strong><code>Lock Timeout Exceeded</code></strong>를 야기할 것을 의미한다.</p>
</br>

</li>
</ol>
<ol start="5">
<li><p>두 번째 세션에서 <code>menuId=1</code>인 메뉴의 재고 차감</p>
<pre><code> mysql&gt; UPDATE menus SET stock = stock - 1 WHERE menuId = 1;</code></pre><p> 두 번째 세션에서 위와 같이 <code>menuId=1</code>인 레코드에 대해 접근할 경우 기존 <u>첫 번째 세션에서 획득한 락으로 인해</u> 대기 상태에 머무는 것을 확인할 수 있을 것이다.</p>
<p> 이것이 바로 <strong>&quot;동시성(Concurrency) 문제&quot;</strong>이다. </p>
<p> 현재 첫 번째 세션에서 <code>menuId=1</code>에 대한 재고 수량 차감(업데이트) 후 <code>commit</code>을 해주지 않았기 때문에 데이터베이스 수준에서 제시된 락 타임아웃 시간이 지나면 아래와 같은 에러를 마주하게 된다.</p>
<pre><code> Lock wait timeout exceeded; try restarting transaction</code></pre> </br>

<ol start="6">
<li><p>첫 번째 세션에서 <code>menuId=2</code>인 메뉴의 재고 차감 ⚠</p>
<pre><code>mysql&gt; UPDATE menus SET stock = stock - 1 WHERE menuId = 2;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction</code></pre><p>바로 이 상황에서 <strong>&quot;Dead Lock(교착 상태)&quot;</strong>이 발생하게 된다.</p>
<p>현재, 락이 풀리지 않은 <code>menuId=1</code>의 레코드에 대해 두 세션의 동시적 수정 접근이 일어났고 그에 따라 두 번째 선점한 세션에 대한 대기가 발생하였다.</p>
<p>이 상태에서 <code>menuId=2</code>에 마저 두 세션의 동시적 수정 접근이 일어난 것이다. 즉, 서로가 서로에 대한 락을 획득하려 대기하는 상황에 빠지게 되었고, 우린 이를 _<strong>&quot;교착 상태&quot;</strong>_라 부른다.</p>
</li>
</ol>
</li>
</ol>
</br>

<ol start="7">
<li><p>첫 번째 세션 자동 Rollback / 두 번째 세션 Rollback or Commit</p>
<p> 데드락 발생으로 인해 첫 번째 세션은 롤백 처리되고, 두 번째 세션의 경우는 커밋을 할 것인지 롤백을 할 것인지 결정해 줄 수 있다.</p>
</li>
</ol>
</br>

</br>

<h2 id="🧃-shared-lock--exclusive-lock-pessimistic-lock">🧃 <code>Shared Lock &amp;&amp; Exclusive Lock</code> (<code>Pessimistic Lock</code>)</h2>
<p>데이터베이스 Lock(락)엔 <strong>&quot;Shared Lock(공유 락 _read lock)&quot;</strong>과 <strong>&quot;Exclusive Lock(쓰기 락 _write lock)&quot;</strong>이 존재한다. </p>
<p>각각의 특징에 대해 알아보고 어떤 방법을 사용할지에 대해 고민해보자.</p>
</br>

<h3 id="shared-lock공유-락">&gt; <code>Shared Lock</code>(공유 락)</h3>
<p>동일하게 2개의 세션을 열고 각각 트랜잭션을 시작한다. <em>(임의로 첫 번째 세션을: A, 두 번째를 B라 하겠습니다)</em></p>
<ol>
<li><p>A세션의 조회 쿼리에 Shared Lock 획득(<code>for share</code>)</p>
<pre><code> mysql&gt; select * from menus where menuId=1 for share;
 +--------+-------+
 | menuId | stock |
 +--------+-------+
 |      1 |    10 |
 +--------+-------+</code></pre><p> <span style="color:red">commit (x)</span></p>
 </br>
</li>
<li><p>B세션에서 <code>menuId=1</code>의 레코드에 업데이트 쿼리 수행</p>
<pre><code> mysql&gt; UPDATE menus SET stock = stock - 1 WHERE menuId = 1;</code></pre><p> 결과: A세션에서 &quot;<u>Shared Lock</u>&quot;을 획득한 상태에서 <code>commit</code>을 수행하지 않았으므로 대기에 빠진다.</p>
<p> A세션에서 <code>commit</code>을 수행한다면 B세션의 업데이트 쿼리는 대기가 풀리며 처리가 된다.</p>
 </br>


</li>
</ol>
<ol start="3">
<li><p>B세션에서 <code>menuId=1</code>의 레코드에 조회 쿼리 수행</p>
<pre><code>mysql&gt; select * from menus where menuId=1 for share;
+--------+-------+
| menuId | stock |
+--------+-------+
|      1 |    10 |
+--------+-------+
1 row in set (0.01 sec)</code></pre><p>다른 세션(트랜잭션)의 Shared Lock은 허용한다.</p>
<p>즉, <strong>&quot;Shared Lock(공유 락)&quot;</strong>의 경우는 <u>읽기는 허용하며 쓰기는 허용하지 않는다</u>. </p>
</br>

</li>
</ol>
<h3 id="exclusive-lock배타-락">&gt; <code>Exclusive Lock</code>(배타 락)</h3>
<p>Exclusive Lock의 경우 흔히 Write Lock(쓰기 락)이라 불리며 이는 Shared Lock과는 다르게 이름에서 예측할 수 있듯 쓰기는 물론 읽기까지 허용하지 않는다. (쓰기, 읽기 모두에 잠금을 건다)</p>
<ol>
<li><p>A세션의 조회 쿼리의 Exclusive Lock 획득 (<code>for update</code>)</p>
<pre><code> mysql&gt; select * from menus where menuId=1 for update;
 +--------+-------+
 | menuId | stock |
 +--------+-------+
 |      1 |    10 |
 +--------+-------+
 1 row in set (0.00 sec)</code></pre><p> <span style="color:red">commit (x)</span></p>
 </br>
</li>
<li><p>B세션에서 동일하게 Exclusive Lock을 통한 조회 </p>
<pre><code> mysql&gt; select * from menus where menuId=1 for update;</code></pre><p> 결과: <code>commit</code> 되지 않은, 배타 락을 먼저 획득한 A 세션으로 인해 <u>무기한 대기</u>에 빠진다. 만약 A세션의 커밋 혹은 롤백시 B세션의 대기는 풀리며 반영된다.</p>
</li>
</ol>
</br>

</br>


<h2 id="🧃-적절한-시나리오는-무엇일까">🧃 적절한 시나리오는 무엇일까</h2>
<p>일반 모놀리식 환경(단일 DB 가정)에서 <u>적절한 재고 차감 시나리오</u>는 무엇일까? </p>
<p>정답은 없다. </p>
<p>물론, 분산 환경이 아닌 단일 DB 환경이므로 해결책에 대한 선택지는 줄어들 수 있겠지만 <em>&quot;어떤 재고 차감 서비스인가?&quot;</em> 에 따라 상이 할 것이다. </p>
<p>최대한 serializable하게 잡아 확실한 동시성 제어를 보장할 수도 있지만 이는 동시에 <u>성능에 부하를 주는 결론으로 이어진다</u>. </p>
<p>공연 티켓팅, 극장 예매, 그리고 현재 나의 프로젝트에서 서술하고 있는 메뉴 재고 차감 등 여러 서비스에서 어떤 포인트에 중점을 둘 것인가를 결정하는 것이 가장 선수되어야 할 핵심이지 않을까 싶다.</p>
</br>

<h3 id="use-exclusive-lock--prevent-circular-wait">&gt; <code>Use Exclusive Lock</code> + <code>Prevent Circular Wait</code></h3>
<p>프로젝트의 초기 단계엔 많은 유저가 모이지 않을 것이며 설령 많은 유저가 생긴다 하더라도 티켓팅과 같은 동시 다발적인 접근 요청이 이루어지는 것이 아닌, 각 매장의 메뉴에 대한 동시적 접근 정도가 이루어진다. </p>
<p>더불어 재고의 잔여 수량에 대해 정확하게 체킹하는 것을 중점으로 둔다. 서로 다른 트랜잭션에서 수량에 대한 동시 접근 시 다른 조회 값을 불러오는 경우가 발생하면 안된다.</p>
<p>이러한 가정과 함께 <strong>Exclusive Lock</strong>을 사용하여 동시성을 제어해 보고자 한다.</p>
</br>

<p><strong>✔ Scenario</strong></p>
<p>동시성을 제어할 수 있으며 &quot;데드락(교착 상태)&quot;을 <u>피하는 방법</u> 역시 고려해 보아야 한다. 물론 이는 최대한 트랜잭션을 작게 잡는 것을 전제로 한다.</p>
<p>재고를 차감하기 전, 메뉴의 재고 수량이 존재하는가(0보다 큰가)에 대한 체킹을 먼저 수행 해준다. 이때 조회문에서 &quot;SELECT ... FOR UPDATE&quot;를 통한 <strong>Exclusive Lock</strong>을 취해준다면 <u>애초에 조회부터 못하게 되니</u> 원치 않은 잔여수량이 계산되는 등의 동시성 문제를 어느정도 해결할 수 있다. (물론 성능감소는 따라올 것이다)</p>
<p>하지만 그렇다고 해서 &quot;Dead Lock&quot;을 피할 수 있는 것은 아니다. 만약 여전히 맞물린 메뉴에 대해 각자의 락 선점이 이루어졌을 경우 교착은 일어나게 된다. </p>
<p>이 문제는 <em>&quot;<strong>Circular Wait</strong>(순환 대기, 환형 대기)&quot;</em> 라 부르며, 아래의 방법을 해결책으로 제시할 수 있다.</p>
</br>

<blockquote>
<p>락 <em>&quot;순서화&quot;</em> 를 통해 모든 트랜잭션에서 동일한 순서로 락을 획득하게 하여 사이클을 형성하는 대기 상태를 방지한다.</p>
</blockquote>
<p>즉, 자원 A, B, C가 존재한다면 <u>락의 순서 또한</u> 항상 A, B, C가 되게끔 하는 것이다. </p>
<p>만약, 메뉴 1, 2에 대한 재고를 업데이트 할 경우 각 트랜잭션 마다 전부 <u>메뉴 1에 대한 락 획득 -&gt; 메뉴 2에 대한 락 획득</u> 으로 진행한다.</p>
<pre><code class="language-sql"># Transaction 1

START TRANSACTION;

-- 메뉴 1의 재고를 업데이트하기 위해 해당 레코드를 선택하고 FOR UPDATE를 수행합니다.
SELECT * FROM menus WHERE menuId = 1 FOR UPDATE;
UPDATE menus SET stock = stock - 1 WHERE menuId = 1;

-- 메뉴 2의 재고를 업데이트하기 위해 해당 레코드를 선택하고 FOR UPDATE를 수행합니다.
SELECT * FROM menus WHERE menuId = 2 FOR UPDATE;
UPDATE menus SET stock = stock - 1 WHERE menuId = 2;

COMMIT;

# Transaction 2

START TRANSACTION;

SELECT * FROM menus WHERE menuId = 1 FOR UPDATE;
UPDATE menus SET stock = stock - 1 WHERE menuId = 1;

SELECT * FROM menus WHERE menuId = 2 FOR UPDATE;
UPDATE menus SET stock = stock - 1 WHERE menuId = 2;

COMMIT;</code></pre>
</br>

</br>

<h2 id="생각정리-및-다음-포스팅-예고">생각정리 및 다음 포스팅 예고</h2>
<p>메뉴 재고 차감 시나리오를 간단한 쿼리문을 통해 전개해 나가는 시간을 가져보았다.</p>
<p>가장 먼저 Lock Timeout과 Dead-Lock의 개념과 발생 이유 및 상황에 대해 인지할 필요가 있었으며, 동시성(Concurrency)문제와 데드락 문제를 해결할 수 있는 방법을 고민하였다.</p>
<p>이번 포스팅에서 설명한 비관적 락(Pessimistic Lock)외에 실제 DB 수준의 락을 사용하는 것이 아닌 <u>버전 정보를 이용</u>하여 동시성을 제어하는 <strong>&quot;낙관적 락(Optimistic Lock)&quot;</strong> 또한 존재한다.</p>
<p>비관적 락, 낙관적 락 모두 각자의 장단점이 존재하며 구현하고자 하는 프로세스및 추가 환경을 고려하여 선택하는 것이 중요하다.</p>
<p>다음 포스팅에선 위의 종합된 내용들을 토대로, NestJS와 Typeorm을 사용하여 재고 차감 로직을 작성하는 시간을 가져보고자 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[주문 결제 #4] 주문 API의 설계 과정을 소개드립니다.]]></title>
            <link>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-4-%EC%A3%BC%EB%AC%B8%EC%9D%80-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EC%A7%84%ED%96%89%EB%90%A9%EB%8B%88%EB%8B%A4NestJS</link>
            <guid>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-4-%EC%A3%BC%EB%AC%B8%EC%9D%80-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EC%A7%84%ED%96%89%EB%90%A9%EB%8B%88%EB%8B%A4NestJS</guid>
            <pubDate>Sun, 25 Feb 2024 04:24:02 GMT</pubDate>
            <description><![CDATA[<h2 id="🧃-최선의-프로세스를-찾아서">🧃 최선의 프로세스를 찾아서</h2>
</br>

<p>앞선 포스팅들을 통해 pg사(toss payments)를 통한 <u>결제 플로우를 구축</u> 해 보았으며 그에 대한 예외 처리까지 알아보았다. </p>
<p>순수 결제 플로우에 해당하는 &quot;요청 - 인증 - 승인&quot; 과정에 문제가 발생하였다면 (외부 사정으로 인한 이슈만 없다면) 자연스래 결제가 취소 처리 될 것이며, 만약 승인 과정이 성공적으로 끝나게 된다면 본격적인 &quot;주문&quot; 로직이 실행 되게 끔 로직을 구상하였다.</p>
<p>조금 더 직관적으로 말하자면, <strong>&quot;결제 승인 API&quot;</strong>가 끝난 후 <strong>&quot;전체 주문 트랜잭션 API&quot;</strong>의 실행을 이루는 것이다.</p>
</br>

<h3 id="왜-결제-승인-api와-주문-api를-분리하게-되었는가-lock-timeout">&gt; &quot;왜&quot; 결제 승인 API와 주문 API를 분리하게 되었는가 (<code>Lock Timeout</code>)</h3>
<p>결제및 주문 플로우를 구상하기 시작한 당시의 머릿속엔 <strong>&quot;결제와 주문은 하나다&quot;</strong> 라는 문장이 고정으로 박혀 있었다. </p>
<p><span style="color:green"><em>&quot;만약 주문이 취소된다면 결제도 취소 되어야하니까... 무조건 결제와 주문은 한 트랜잭션 안에서 진행되어야 할 거야...! 결제는 매우 중요하니까!&quot;</em></span></p>
</br>

<p>한 번 위의 생각에 따라 결제/주문 프로세스를 그려보자.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2a63ced1-dace-4e30-af50-67cfb30a823f/image.png" alt=""></p>
<p>언뜻 보기엔 깔끔해보인다. </p>
<p>하지만 위의 프로세스는 <u>정말 큰 문제</u>를 야기할 수 있다. 바로 <span style="color:red"><strong>&quot;Timeout Exceeded&quot;</strong></span>를 터뜨릴 수 있다는 것이다.</p>
<p>결제 모듈은 &quot;외부 모듈&quot;이다. 별도의 서드파티(third-party)와 api 소통을 하게 되는 과정은(특히 결제 연동의 경우) 정말 다양한 이유로 지연을 발생시킬 수 있다. </p>
<p>일반적으로 Lock timeout이 발생하는 이유는 <u>단일 트랜잭션의 수행 시간 증가</u>에 있다. </p>
<p>현재 프로젝트에서 사용중인 RDBMS인 <strong>MySQL</strong> 기준으로 생각해보자. default isolation-level은 &quot;<u>REPEATABLE READ</u>&quot;이다. </p>
<p>REPEATABLE READ는 트랜잭션의 첫 조회시 해당 데이터에 Shared Lock을 걸고 데이터의 <strong>snapshot</strong>을 생성한다. 이후 동일 트랜잭션 내의 조회(select)는 snapshot에서 읽게 되는 구조이다.</p>
<p>SELECT를 할 때 snapshot을 생성한다는 것은 READ COMMITTED 역시 동일하지만 REPEATABLE READ와는 조금 방식이 다르다. 아무튼 중요한 것은 해당 내용이 아니라 <em><strong>&quot;트랜잭션의 SELECT 시점에서 snapshot이 구축된다&quot;</strong></em> 는 것이다.</p>
<p>이 snapshot 생성 시간 동안 lock이 걸리게 되고, 만약 트랜잭션 내부의 결제 모듈로 인해 특정 조회에 따른 <u>snapshot 생성 시간이 길어지게 되면</u> timeout exceeded를 초래하게 된다.</p>
</br>

<p>자, 그럼 트랜잭션의 lock timeout 초과를 막을 수 있는 방법은 무엇일까?</p>
<blockquote>
<ol>
<li>db level에서 timeout 시간 조정</li>
<li>db level에서의 isolation level 수정</li>
<li>code level에서의 수정 (적절한 트랜잭션 구축)</li>
</ol>
</blockquote>
<p>대표적으로 위의 방법을 수행 할 수 있게 된다.</p>
<p>1번 방법의 경우 간단한 쿼리문을 통해 현재 rdbms에 부여된 timeout 시간을 확인한 뒤 조정하면 된다. </p>
<p>2번 방법은 단순하게 결정할 문제는 아니었다. 물론 애플리케이션 서버 개발자인 본인의 입장에서 일 수도 있다. isolation level을 수정하는 방법보다 주어진 rdbms의 default isolation level(REPEATABLE READ)을 그대로 유지하되, 최대한 코드 레벨에서의 수정을 통해 이를 해결함이 적절하지 않을까 판단하였다.</p>
<p>그리고 이 방법으로 타이틀 제목에서 나왔듯이 &quot;<u>결제모듈 연동과 주문 트랜잭션의 분리</u>&quot;를 택하게 되었다.</p>
<p>결제 연동을 주문 트랜잭션과 분리시켜 트랜잭션을 최대한 짧게 가져가고 트랜잭션 내부 오류로 인한 롤백 시, 결제 취소를 찌르는 구조이다. </p>
<p>아래에서 조금 더 자세히 알아보자.</p>
</br>

<h3 id="구체적-프로세스-진행-과정">&gt; 구체적 프로세스 진행 과정</h3>
<p>앞선 내용을 토대로 수정된 프로세스를 그려보자.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/55478076-7b49-41d0-8eb0-5d4e3f2049f6/image.png" alt=""></p>
<p>이전 포스팅에서 결제 api만 따로 생성한 이유도 이에 있었다. 결제 모듈 연동을 통해 결제 승인을 성공하게 된다면(<code>status=DONE</code>) 그에 따라 주문 트랜잭션을 시작하게 되는 것이다.</p>
<hr>
<p><a href="https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-2-toss-payments-%EA%B2%B0%EC%A0%9C-%ED%94%8C%EB%A1%9C%EC%9A%B0%EB%A5%BC-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%95%84%EC%9A%94-NestJS">이전 포스팅 _toss payments 결제 연동 ✔</a></p>
<hr>
<p>즉, 유저는 결제 완료 후 자연스래 주문 진행까지 수행할 수 있지만 실은 클라이언트에서 2개의 api 요청을 날리게끔 하는 것이다. </p>
<p>온전히 주문 트랜잭션만을 수행하는 api가 존재함으로써 최대한의 트랜잭션 lock timeout 문제를 줄일 수 있게 된다.</p>
<p>주문 트랜잭션 내부 수행 로직은 크게 아래와 같다.</p>
<hr>
<ol>
<li><p>트랜잭션 시작</p>
</li>
<li><p>주문서 생성 및 주문 메뉴 생성</p>
</li>
<li><p>결제 시 사용한 쿠폰 삭제 및 포인트 차감 </p>
</li>
<li><p>쿠폰 및 포인트 내역(로그) 생성</p>
</li>
<li><p>재고 차감 ⚠</p>
</li>
<li><p>장바구니 비우기</p>
</li>
<li><p>알림 토큰(디바이스 토큰) 테이블 조회</p>
</li>
<li><p>트랜잭션 커밋</p>
</li>
<li><p>트랜잭션 실패 시 롤백</p>
</li>
</ol>
<hr>
<p>그럼 위의 로직을 담은(호출하게 되는) <u>서비스 클래스</u>를 정의해보자. </p>
<p><span style="color:red">(각각의 세부 수행 로직은 추후 이어지는 포스팅에서 따로 다룰 예정입니다)</span></p>
</br>

<p><strong>✔ OrderService</strong></p>
<pre><code class="language-ts">// order.service.ts

export class OrderService implements OrderUseCase {

  constructor(
    private readonly orderRepository: OrderDrivenPort,
    private readonly menuStockHandleRepository: MenuStockHandlerDrivenPort,
    private readonly orderCouponsHandleRepository: OrderCouponsHandlerDrivenPort,
    private readonly orderPointsHandleRepository: OrderPointsHandleDrivenPort,
    private readonly emptyCartMenusRepository: EmptyCartMenusDrivenPort,  
    private readonly tempOrdersRepository: PaymentDrivenPort,
    private readonly userPointsRepository: UserPointDrivenPort,
    private readonly storeRepository: StoreDrivenPort,
    private readonly notificationRepository: NotificationDrivenPort,
  ) {}

  public async processOrderTransaction(userId: number, orderReqCommand: OrderReqCommand): Promise&lt;NotificationPayloadModel&gt; {

    // get temp-orders data for validation check!
    const tempOrdersData = await this.tempOrdersRepository.getTempOrdersData(orderReqCommand.orderNumber);

    const { couponAmount, pointAmount } = tempOrdersData;

    // coupon amount valid check!
    if (couponAmount !== orderReqCommand.useCouponAmount) {
      throw new CouponAmountMissMatchException();
    }

    // point amount valid check!
    if (pointAmount !== orderReqCommand.usePointAmount) {
      throw new PointAmountMissMatchException();
    }

    const queryRunner = dataSource.createQueryRunner();

    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 주문 생성 (주문, 주문상세, 주문메뉴, 주문메뉴옵션 엔티티 생성)
      await this.orderRepository.createOrders(userId, orderReqCommand, queryRunner);

      // 메뉴 수량 차감
      await this.menuStockHandleRepository.decreaseMenuStock(orderReqCommand, queryRunner);

      // 회원 쿠폰 삭제
      await this.orderCouponsHandleRepository.deleteUserCoupons(orderReqCommand, queryRunner);

      // 쿠폰 사용 내역 생성 (사용한 쿠폰이 하나라도 존재할 경우)
      if (orderReqCommand.userMereCouponId || orderReqCommand.userStoreCouponId) {
        await this.orderCouponsHandleRepository.insertUseCouponsLog(
          orderReqCommand.userMereCouponId,
          orderReqCommand.userStoreCouponId,
          orderReqCommand.orderNumber,
          queryRunner
        );
      }

      // 회원 포인트 차감
      if (orderReqCommand.usePointAmount &gt; 0) {
        await this.orderPointsHandleRepository.decreaseUserPoints(userId, orderReqCommand, queryRunner);
      }

      // 회원 포인트 내역 생성 (포인트 금액이 0이상일 경우)
      if (orderReqCommand.usePointAmount &gt; 0) {
        const { remainPoint} = await this.userPointsRepository.getUserRemainPoint(userId);
        await this.orderPointsHandleRepository.insertUserPointsLog(userId, orderReqCommand.storeId, orderReqCommand.usePointAmount, 1, remainPoint, queryRunner);
      }

      // 장바구니 비우기
      await this.emptyCartMenusRepository.emptyCartMenus(orderReqCommand, queryRunner);

      // notification
      const storeName = await this.storeRepository.findStoreName(orderReqCommand.storeId);
      const notificationTitle = &quot;주문&quot;;
      const notificationBody = `[${storeName}] 결제가 완료되었어요. 사장님이 주문을 곧 접수할 예정이에요.`;

      const notificationTokenEntity = await this.notificationRepository.getExistsDeviceToken(userId);

      await queryRunner.commitTransaction();

      return new NotificationPayloadModel(notificationTitle, notificationBody, notificationTokenEntity);

    } catch (err) {
      await queryRunner.rollbackTransaction();      
      throw err;
    } finally {
      await queryRunner.release();
    }
  }
}</code></pre>
</br>

<p>실제로 트랜잭션 시 롤백과 더하여 <strong>&quot;결제 취소&quot;</strong>를 시켜주는 행위, 주문 성공 혹은 실패 시 유저에게 <strong>&quot;푸시 알림&quot;</strong>을 던지는 행위는 별도의 도메인이다. </p>
<p>즉, 엄밀히 말하면 &quot;공통 도메인 유스케이스&quot;라 할 수 있고 현재 프로젝트의 아키텍처 특성 상 주문 도메인의 컨트롤러 내부에 유스케이스로써 불러오는 구조로 수행된다.</p>
</br>

<p><strong>✔ OrderProcessController</strong></p>
<pre><code class="language-ts">// order-process.controller.ts

@ApiTags(&#39;order&#39;)
@ApiBearerAuth(&#39;access-token&#39;)
@ErrorResponse(HttpStatus.UNAUTHORIZED, [
  AuthErrorsDefine[&#39;0007&#39;]
])
@UseGuards(JwtAccessAuthGuard)
@Controller(&#39;order&#39;)
export class OrderProcessController {
  private readonly cancelReason_case1 = UnCatchedMsg.UNCATCHED_MSG;
  private readonly cancelReason_case2 = UnCatchedMsg.CUSTOMER_CHANGE_OF_MIND;

  constructor(
    @Inject(OrderPaymentUseCaseSymbol)
    private readonly orderPaymentUseCase: OrderPaymentUseCase,
    @Inject(OrderUseCaseSymbol)
    private readonly orderUseCase: OrderUseCase,
    @Inject(FCMCommonInPortSymbol)
    private readonly fcmCommonUseCase: FCMCommonInPort,
  ) {}

  @ApiOperation({
    summary: &#39;주문 트랜잭션 api&#39;,
    description: &#39;pg사 결제 승인 완료에 따른 자체 서비스 주문 트랜잭션 시작&#39;
  })
  @ApiBody({
    type: OrderReqDto,
  })
  @ApiCreatedResponse({
    status: 201,
    description: &#39;주문 생성&#39;
  })
  @ErrorResponse(HttpStatus.BAD_REQUEST, [
    PaymentErrorsDefine[&#39;1130&#39;],
    OrderErrorsDefine[&#39;3101&#39;],
    OrderErrorsDefine[&#39;3102&#39;],
    OrderErrorsDefine[&#39;4500&#39;],
    OrderErrorsDefine[&#39;4501&#39;],
    OrderErrorsDefine[&#39;4502&#39;],
    OrderErrorsDefine[&#39;4503&#39;],
  ])
  @Post(&#39;/start-transaction&#39;)
  @UseFilters(TossPaymentsCancelFilter)
  @UsePipes(new ValidateOrderIdPipe())
  async startOrderTransaction(
    @Req() request: ExtendedRequest,
    @Body() orderReqDto: OrderReqDto,
  ) {
    const userId = request.userId;
    const orderReqCommand = OrdersMapper.mapToCommand(orderReqDto);

    try {
      const { notificationTitle, notificationBody, notificationTokenEntity } = await this.orderUseCase.processOrderTransaction(userId, orderReqCommand);
      // send-notification
      if (notificationTokenEntity &amp;&amp; notificationTokenEntity.notificationToken) {

        await this.fcmCommonUseCase.sendTokenToFirebase(
          notificationTokenEntity.notificationTokenId,
          notificationTokenEntity.notificationToken,
          notificationTitle,
          notificationBody,
          &#39;ic_order&#39;,
          10,
        );
      }  
    } catch (err) {
      // 중요!! 결제 취소 api(application server to pg server) 호출
      await this.orderPaymentUseCase.cancelPayments(orderReqCommand.paymentKey, this.cancelReason_case1);
      throw err;
    }
  }</code></pre>
</br>

<p>위와 같은 식이다. 서비스 레이어끼리의 의존성 주입을 지양하는 아키텍처를 추구하였고, 이에 따라 최상단의 컨트롤러 레이어에서 주문 시 부가적으로 수행되는 결제(Payment) 및 알림(Notification using FCM) 유스케이스(추상회된 인터페이스)를 불러오게 되었다.</p>
<p>즉, 추후 보여지겠지만 <strong>OrderProcessService</strong>에서 모든 주문 트랜잭션을 수행한 뒤 리턴값으로 void가 아닌 위의 코드에서 보는 것과 같이 푸시 알림 발송에 필요한 정보들(<code>TokenEntity, Title, Body</code>)을 넘겨준다.</p>
<pre><code class="language-ts">export class NotificationPayloadModel {
  notificationTitle: string;
  notificationBody: string;
  notificationTokenEntity: NotificationTokenResModel;

  constructor(notificationTitle: string, notificationBody: string, notificationTokenEntity: NotificationTokenResModel) {
    this.notificationTitle = notificationTitle;
    this.notificationBody = notificationBody;
    this.notificationTokenEntity = notificationTokenEntity;
  }
}</code></pre>
</br>

<h3 id="결제-취소-구현">&gt; 결제 취소 구현</h3>
<p>마지막으로 확인하게 될 부분이 바로 라우트 핸들러 함수의 <span style="color:red"><strong>catch</strong></span> 문 내부에서 수행되는 <span style="color:red"><strong>&quot;결제 취소&quot;</strong></span>이다.</p>
<pre><code class="language-ts">// order-payment.service.ts

export class OrderPaymentService implements OrderPaymentUseCase {
  private readonly tossUrl = &#39;https://api.tosspayments.com/v1/payments/&#39;;
  private readonly secretKey = process.env.TOSS_TEST_SECRET_KEY;

  constructor(
    private readonly paymentRepository: PaymentDrivenPort,
  ) {}

  // ... 생략

  public async cancelPayments(paymentKey: string, cancelReason: string): Promise&lt;void&gt; {
    const idempotency = uuid_v4();

    try {
      await axios.post(
        `${this.tossUrl}/${paymentKey}/cancel`,
        {
          cancelReason,
        },
        {
          headers: {
            Authorization: `Basic ${Buffer.from(`${this.secretKey}:`).toString(&#39;base64&#39;)}`,
            &#39;Content-Type&#39;: &#39;application/json&#39;,
            &#39;Idempotency-Key&#39;: `${idempotency}`,
          },
          data: {
            cancelReason: cancelReason,
          },
        });
    } catch (err) {
      throw err;
    }
  }
}</code></pre>
</br>

<p>서비스 레이어의 <code>try...catch</code> 내부에서 <code>throw err</code>를 통해 상위 레이어로 에러를 던지게 되고, 최종 주문 프로세스의 catch 문 내부에서 수행된 결제 취소 api(애플리케이션 서버 -&gt; pg 서버)에서 발생하게 된 에러는 커스텀 익셉션 필터가 맡게 된다.</p>
<pre><code class="language-ts">@UseFilters(TossPaymentsCancelFilter)</code></pre>
</br>

<p>커스텀 익셉션 필터의 코드는 아래와 같다. 바로 이전의 포스팅에서 다루었던 <strong>&quot;결제 승인에 따른 에러(<code>TossPaymentsConfirmFilter</code>)&quot;</strong> 와 동일한 형식이다.</p>
<p>결제 승인시 에러가 발생할 수 있는 것 처럼 결제 취소시에도 거의 대부분 비슷한 이유로 에러가 발생할 수 있으며 서버 측에선 이에 대한 예외 처리를 해 줄 필요가 있다.</p>
<p>(구현에 대한 자세한 내용은 이전 포스팅을 참조 바랍니다 ⬇⬇)</p>
<p><a href="https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-3-pg%EC%82%AC%EC%9D%98-%EC%97%90%EB%9F%AC%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B8%EB%93%A4%EB%A7%81%ED%95%A0%EA%B9%8C-NestJS">이전 포스팅 _pg사의 에러는 어떻게 핸들링할까? ✔</a></p>
</br>

<p><strong>✔ TossPaymentsCancelFilter</strong></p>
<pre><code class="language-ts">@Catch(AxiosError)
export class TossPaymentsCancelFilter implements ExceptionFilter {

  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse&lt;Response&gt;();
    const request = ctx.getRequest&lt;Request&gt;();
    const curr_timestamp: string = new Date().toLocaleString(&quot;ko-KR&quot;, { timeZone: &quot;Asia/Seoul&quot;});

    const ex = handlingException(exception);

    let responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;

    switch (exception.response.status) {
      case 400:
        responseStatus = HttpStatus.BAD_REQUEST;
        break;
      case 401:
        responseStatus = HttpStatus.UNAUTHORIZED;
        break;
      case 403:
        responseStatus = HttpStatus.FORBIDDEN;
        break;
      case 404:
        responseStatus = HttpStatus.NOT_FOUND;
        break;
      default:
        responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;
    }

    response.status(responseStatus).json({
      errorCode: ex.code,
      status: responseStatus,
      msg: ex.message,
      timestamp: curr_timestamp, 
      path: request.url,
    });
  }
}

interface ExceptionStatus {
  code: string;
  message: string;
}

const handlingException = (err: AxiosError): ExceptionStatus =&gt; {
  const code = err.response.data[&#39;code&#39;];

  if (!!code) {
    return Object.keys(cancelErrCodeMessageObject).includes(code) 
      ? cancelErrCodeMessageObject[code] 
      : { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: &quot;알 수 없는 오류로 인한 결제 취소 실패 :) 고객센터 문의 바람&quot; }
  }

  return { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: &quot;알 수 없는 오류로 인한 결제 취소 실패 :) 고객센터 문의 바람&quot; };
}</code></pre>
</br>

<p><strong>✔ cancelErrCodeMessageObject</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/db987747-e648-4efe-984a-261662194afe/image.png" alt=""></p>
</br>

<p><strong>✔ 예외 시 응답</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/8a1cc87d-c776-4781-bb32-578716c773d0/image.png" alt=""></p>
<h2 id="다음-포스팅-예고">다음 포스팅 예고</h2>
<p>이어질 다음 포스팅들에선 위의 주문 트랜잭션 내부 로직 구현중 일부 중요하다고 판단하는 내용에 대해 추가적인 지식 공유를 해보고자 한다.</p>
<p>꼭 단순 코드 레벨의 내용이 아니더라도 주문 관련 도메인을 관리하게 되면서 부가적으로 처리해줘야 할 여러 세부요소(로깅, 테이블 설계 등)등에 대해서도 얘기해보면 좋을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[주문 결제 #3] pg사의 에러는 어떻게 핸들링할까? (NestJS)]]></title>
            <link>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-3-pg%EC%82%AC%EC%9D%98-%EC%97%90%EB%9F%AC%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B8%EB%93%A4%EB%A7%81%ED%95%A0%EA%B9%8C-NestJS</link>
            <guid>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-3-pg%EC%82%AC%EC%9D%98-%EC%97%90%EB%9F%AC%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B8%EB%93%A4%EB%A7%81%ED%95%A0%EA%B9%8C-NestJS</guid>
            <pubDate>Sun, 11 Feb 2024 08:06:45 GMT</pubDate>
            <description><![CDATA[<h2 id="🧃-결제-승인시-발생하는-에러">🧃 결제 승인시 발생하는 에러</h2>
<p>이전 포스팅의 마지막에서 언급하였다시피 <strong>&quot;토스 페이먼츠&quot;</strong> pg사 측에서 던져준 에러를 어떻게 핸들링하는 것이 좋을지 고민해 볼 필요가 있다.</p>
<p><strong>&quot;결제 승인&quot;</strong>이란 작업시에 발생할 수 있는 에러엔 어떤 것들이 있을까?</p>
<p><a href="https://docs.tosspayments.com/reference/error-codes#%EA%B2%B0%EC%A0%9C-%EC%8A%B9%EC%9D%B8">결제 승인 에러 코드 - 개발자 센터</a></p>
<p>위의 링크를 통해 결제 승인 시 발생할 수 있는 모든 에러를 확인할 수 있다. 아래와 같은 에러 코드들이  발생할 수 있는 에러로써 제시된다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/732e81b2-e80c-482d-aa49-0b14d4ca128c/image.png" alt=""></p>
<p>충분히 자주 발생할 수 있는 에러도 있고, 동시에 거의 발생할 일이 없을거 같은 에러 또한 보인다. </p>
</br>

<h3 id="모든-에러를-다-처리해-주어야-할까">&gt;  모든 에러를 다 처리해 주어야 할까?</h3>
<p>위 질문의 대답으로 나는 &quot;그렇다&quot;를 택하였다. 결제와 같이 금전 거래가 발생하는 것은 굉장히 예민한 부분이라 생각한다. 서드파티와 같은 외부 라이브러리를 사용하면서 발생하는 에러는 왠만하면 클라이언트측에 공개하지 않는 것이 1차적 접근이지만 결제의 경우는 엄연히 별개이다.</p>
<p>아래의 에러들을 잠깐 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/e90e1dfb-ee88-4891-b5da-349853c7be1a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/26ff6a70-f1e6-4e51-828a-4908212da8a3/image.png" alt=""></p>
<p>결제 승인 과정에서 충분히 발생할 수 있는 에러이다. 이와 같은 에러가 발생하였을 경우 서버는 클라이언트에게 정확한 에러 상황 메시지를 전달하여 유저가 불편함을 겪지 않게끔 해줄 필요가 있다. </p>
<p>반면 아래와 같은 에러는 어떠할까?</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/dbd0aa63-2350-4736-8976-778cdcbdbeb7/image.png" alt=""></p>
<p>&quot;잘못된 시크릿 키&quot; 연동. 이러한 에러는 애플리케이션 서버 측의 엄연한 실수이다. 해당 에러뿐 아니라 몇 가지 더 존재하게 되는데, 이는 굳이 유저에게 알려줄 필요도 없고 <u>보안상의 이슈가 될 수 있는 에러</u>이다.</p>
<p>하지만, &quot;개발 단계&quot;에서는 충분히 핸들링 해줄 가치가 있고 물론 운영단계에서도 어떤 일이 일어날지 모르니 pg사로부터 받아올 필요가 있다. </p>
<p>이렇게 다양한 케이스의 에러가 존재하고 본인은 어떻게 이를 처리해 주었는가에 대해 경험을 공유해보고자 한다.</p>
</br>

</br>

<h2 id="🧃-custom-exception-filter-설계">🧃 <code>Custom Exception Filter</code> 설계</h2>
</br>

<p>일전에 프로젝트 빌딩 단계를 다루는 글 중 <strong>&quot;에러를 어떻게 소통하는 가&quot;</strong>에 대해 다뤄본적이 있다. </p>
<p>해당 글 내부에서 pg사와 같은 서드파티 에러를 Nestjs에서 어떤식으로 핸들링할지 소개했었고 그 방법으로 <strong>&quot;에러 필터&quot;</strong>를 적용하였다.</p>
<blockquote>
<p><a href="https://velog.io/@from_numpy/Project-2-%EC%97%90%EB%9F%AC%EB%A5%BC-%EC%86%8C%ED%86%B5%ED%95%B4%EB%B3%B4%EC%9E%90">에러를 소통해보자 - velog 포스팅</a></p>
</blockquote>
<h3 id="tosspaymentsconfirmfilter">&gt; <code>TossPaymentsConfirmFilter</code></h3>
<p>결제 승인 시 발생 할 수 있는 외부 에러를 핸들링 해주는 커스텀 필터</p>
<pre><code class="language-ts">// payments-confirm.filter.ts

import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from &quot;@nestjs/common&quot;;
import { AxiosError } from &quot;axios&quot;;
import { Request, Response } from &quot;express&quot;;
import { UnCatchedExceptionErrCodeEnum } from &quot;../../enums/uncatched-exception.enum&quot;;
import { confirmErrorCodeMessageObject } from &quot;./messages/tossPayments-errcode-message.object&quot;;

@Catch(AxiosError)
export class TossPaymentsConfirmFilter implements ExceptionFilter {

  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse&lt;Response&gt;();
    const request = ctx.getRequest&lt;Request&gt;();
    const curr_timestamp: string = new Date().toLocaleString(&quot;ko-KR&quot;, { timeZone: &quot;Asia/Seoul&quot;});

    const ex = handlingException(exception);

    let responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;

    switch (exception.response.status) {
      case 400:
        responseStatus = HttpStatus.BAD_REQUEST;
        break;
      case 401:
        responseStatus = HttpStatus.UNAUTHORIZED;
        break;
      case 403:
        responseStatus = HttpStatus.FORBIDDEN;
        break;
      case 404:
        responseStatus = HttpStatus.NOT_FOUND;
        break;
      default:
        responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;
    }

    response.status(responseStatus).json({
      errorCode: ex.code,
      status: responseStatus,
      msg: ex.message,
      timestamp: curr_timestamp,
      path: request.url,
    });
  }
}

interface ExceptionStatus {
  code: string;
  message: string;
}

const handlingException = (err: AxiosError): ExceptionStatus =&gt; {
  const code = err.response.data[&#39;code&#39;];

  if (!!code) {
    return Object.keys(confirmErrorCodeMessageObject).includes(code) 
      ? confirmErrorCodeMessageObject[code] 
      : { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: &quot;알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람&quot; }
  }

  return { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: &quot;알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람&quot; };
}</code></pre>
</br>

<p>발생할 수 있는 에러 상태는 <code>400</code>, <code>401</code>, <code>403</code>, <code>404</code>, <code>500</code> 으로 다양하다. </p>
<p>각 상태에 대한 모든 에러를 받아주도록 하며, 에러에 대한 정의는 별도의 객체로 나타내 필터 클래스를 깔끔히 해준다.</p>
<p>조금 헷갈릴 수 있지만, <code>handlingException()</code> 내부에서 선언한 아래의 코드와</p>
<pre><code class="language-ts">const code = err.response.data[&#39;code&#39;];</code></pre>
<p>공통 응답 객체를 이루는데 사용될 <strong><code>code</code></strong>는 <span style="color:red"><strong>다르다</strong></span>. </p>
<pre><code class="language-ts">{ code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: &quot;알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람&quot; }</code></pre>
</br>

<p>첫 번째, <code>err.response.data</code>를 통해 접근한 <code>code</code>는 토스 페이먼츠의 에러 응답 코드에 접근한 경우이고 두 번째 <code>code</code>는 애플리케이션 서버에서 지정한 <code>errorCode</code>이다. 이는 별도의 enum을 통해 관리되고 아래에서 소개하도록 하겠다.</p>
</br>

<h3 id="confirmerrorcodemessageobject--------------paymentconfirmexceptionerrcodeenum">&gt; <code>confirmErrorCodeMessageObject</code> &amp;&amp;             <code>PaymentConfirmExceptionErrCodeEnum</code></h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/7e26f4d3-8254-4480-bde1-19b00558127e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/77dcc946-d504-4919-8003-0e5c7d91916e/image.png" alt=""></p>
<p>더 좋은 방법이 있는지는 모르겠지만 토스 페이먼츠 문서에서 제시하는 결제 승인 관련 <strong>&quot;모든&quot;</strong> 에러를 핸들링 하기 위해 위와 같은 수작업을 해줄 수 밖에 없었다. </p>
<p>항상 깨끗하고 깔끔한 코드만 존재하는 것이 아니라 위와 같은 작업또한 서비스를 구성하고 운영하는데 있어 그만큼 중요한 코드라 생각이 들었으므로 한땀 한땀 작성해주었다. </p>
<p>클라이언트 측 또한 발생할 수 있는 에러를 개발자 문서를 통해 확인할 수 있지만 이렇게 자체 서버의 <strong>&quot;공통 응답 에러&quot;</strong>와 연결짓기 위해선 서버 측에서 관리하는 과정이 필요했던 것이다.</p>
<p>여기서 주의해야 할 점은 아무리 토스 페이먼츠에서 400번대로 넘겨져온 에러라 해도 보안상 클라이언트에게 넘겨줄 필요가 없는 에러의 경우는 <strong>&quot;알 수 없는 에러 _UnCatched Error&quot;</strong>로 교체해주도록 하는 것이다.</p>
</br>

<h3 id="라우트-핸들러에-적용">&gt; 라우트 핸들러에 적용</h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/a4804d00-adea-4082-9905-159080cfb884/image.png" alt=""></p>
<p>앞서 생성한 커스텀 예외 필터를 라우트 핸들러 함수(API) 레벨에 주입해 줌으로써 pg사로 부터 넘어온 에러를 비즈니스 로직까지 끌고 오지 않고, 유연하게 처리해 줄 수 있게 되었다. </p>
<p>추후, &quot;결제 취소&quot;와 관련된 에러도 동일하게 처리할 수 있다.</p>
</br>

<p>결제는 민감하고 어떤 일이 발생할지 예측할 수 없다. 에러가 &quot;완전히&quot; 일어나지 않게 모든 상황을 컨트롤 하는 것은 사실상 불가능에 가깝고 에러가 발생하더라도 예외 처리를 통해 문제 상황을 인지하고 적절한 제스처를 취해주는 것이 중요하다.  </p>
<p>클라이언트 측은 애플리케이션과 공유하는 자체 에러 코드를 통해 &quot;유저에게 보여줄 에러&quot;, &quot;그렇지 않을 에러&quot;를 판별해(물론 이는 단순 클라이언트 개발자만이 다룰 문제는 아닐 것이다) 결제 승인시 발생하는 에러에 대해 유연한 처리를 수행할 수 있다.</p>
</br>

<h2 id="다음-포스팅-예고">다음 포스팅 예고</h2>
<p>다음 포스팅부턴 본격적으로 <strong>&quot;주문 프로세스&quot;</strong>에 들어갈 것이다. 주문 플로우는 어떻게 진행되고 부가적으로 처리해주어야 할 작업엔 어떤 것들이 있고 등의 경험을 함께 공유해보고자 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[주문 결제 #2] toss-payments 결제 플로우를 적용해보아요 (NestJS)]]></title>
            <link>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-2-toss-payments-%EA%B2%B0%EC%A0%9C-%ED%94%8C%EB%A1%9C%EC%9A%B0%EB%A5%BC-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%95%84%EC%9A%94-NestJS</link>
            <guid>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-2-toss-payments-%EA%B2%B0%EC%A0%9C-%ED%94%8C%EB%A1%9C%EC%9A%B0%EB%A5%BC-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%95%84%EC%9A%94-NestJS</guid>
            <pubDate>Sun, 11 Feb 2024 06:16:35 GMT</pubDate>
            <description><![CDATA[<h2 id="🧃-토스-페이먼츠-문서는-친절하다">🧃 토스 페이먼츠 문서는 친절하다.</h2>
</br>

<h3 id="요청---인증----승인-flow">&gt; 요청 - 인증  - 승인 flow</h3>
<p>기획의 요구에 따라 사용할 pg사로 <strong>&quot;토스 페이먼츠&quot;</strong>를 채택하게 되었고, 결제 플로우에 대해 아무것도 아는 것이 없는 나는 개발자 문서를 보기 시작하였다.</p>
<p> 결제 연동, 에러 코드, 리다이렉트 처리 등등 필수적으로 봐야할 다양한 문서들이 상세하고 친절하게 존재하였지만 그 중 가장 오랫동안 들여다본 문서는 <strong>&quot;결제 흐름&quot;</strong> 파트였다. 해당 문서의 내용이 사실 상 결제 로직을 설계하는데 있어 가장 핵심이였고, 이를 이해하지 못한다면 디테일한 요소들은 아무런 의미가 없었다.</p>
<p><a href="https://docs.tosspayments.com/guides/learn/payment-flow">tosspayments 개발자 문서 - 결제 흐름 이해하기 ✔</a></p>
<p>물론, 토스 페이먼츠를 사용함으로써 해당 문서만 보는 것보다 포트 원, kg 이니시스 등 여러 pg사 문서를 함께 보는 것이 도움이 되었다. pg사 마다 조금씩의 플로우 차이는 존재하지만, 결제 api를 연동하고 클라이언트/서버에선 각각 어떤식의 처리와 소통을 해야하는지 대부분 비슷한 흐름속에서 설명한다.</p>
</br>

<p>다시 돌아와 위의 링크를 눌러보면 가장 먼저 마주하게 될 구문과 그림이 있다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/bbcfa63f-6d2b-4cfa-967e-6a7869623c57/image.png" alt=""></p>
<p><strong>요청 - 인증 - 승인</strong> 으로 이어지는 위 플로우에 대해 간단하게 정리하자면 아래와 같다. 이미 개발자 문서에 자세히 나와있으므로 여기 작성하는 것은 생략하도록 하겠다.</p>
<ul>
<li><p>요청 : 구매자가 상품 또는 서비스를 구매하기 위해 주문서에 결제 요청 정보(상품 정보, 결제 금액 등)를 입력하고 결제 요청을 하는 단계</p>
</li>
<li><p>인증 : 카드사가 요청받은 결제 정보, 즉 고객의 신용카드 정보와 결제 금액을 확인해서 이 거래가 유효하며 결제를 허용해도 되는지 확인하는 과정</p>
</li>
<li><p>승인 : 바로 인증된 결제를 카드사에 승인해달라고 요청하는 과정</p>
</li>
</ul>
</br>

<p>위의 세 단계를 보고 가장 궁금했던 점은 <strong>&quot;인증과 승인의 분리&quot;</strong>였고 이는 역시 문서에 잘 설명이 되어있었다. </p>
<p>토스페이먼츠 역시 다른 pg사들과 마찬가지로  <strong>&quot;웹훅(Webhook)&quot;</strong>을 제공한다. 어떻게 보면 결제 연동에 있어 가장 중요한 기능이기도 한 &quot;웹훅&quot;을 메인 플로우로 두지 않고 인증과 승인을 분리한 해당 플로우는 생각보다 매력적이었다. (모든 이유는 개발자 문서에 잘 나와있습니다)</p>
<p>실제로 여러 서비스 애플리케이션을 보면 인증요청과 결제(승인)요청을 분리한 케이스를 볼 수 있다. 유저에겐 이를 노출하지 않고 한 번에 클라이언트 서버와 리소스 서버만의 통신으로 진행되기도 하고 유저에게 이를 직접 (버튼 클릭 등의 이벤트) 맡기기도 한다.</p>
<p>이번 프로젝트에선 서비스의 특징및 유저 경험 등을 고려해 전자의 방법을 택하였다. </p>
<p>또한 <strong>&quot;웹훅&quot;</strong>이 결제 진행의 메인 플로우에 사용되지 않을 뿐, 웹훅 역시 추후 <strong>&quot;주문 누락 배치 프로세스&quot;</strong>를 구축할 때 사용하게 끔 하였다. 금전이 흘러가는 기능 구현인 만큼 두 방법 모두를 사용할 필요가 있었다.</p>
</br>

<h3 id="클라이언트--서버-역할에-따른-전체-흐름">&gt; 클라이언트 / 서버 역할에 따른 전체 흐름</h3>
<p>요청 - 인증 - 승인 플로우에 맞게 전체 로직을 구상한다고 하면, 클라이언트와 애플리케이션 서버가 각 부분에서 어떠한 역할을 하는지를 정리하는 과정이 필요하다.</p>
<p>서버 개발자, 클라이언트 개발자 상관없이 각 파트의 역할과 책임 그리고 api 소통을 정리함으로써 더 생산적인 로직 설계에 들어갈 수 있다.</p>
<p>일단 <strong>&quot;실 주문&quot;</strong>은 미루고, 결제 과정에 대해서만 생각하자. 그 이유는 아마 다음 포스팅 정도에서 중요하게 언급되지 않을까 싶다.</p>
</br>

<hr>
<ol>
<li><p>클라이언트는 사용자가 입력한 정보를 가지고 <code>결제하기</code> 버튼을 누른 뒤 서버에 &quot;가주문 테이블 생성&quot; API를 호출</p>
</li>
<li><p>서버는 입력받은 데이터를 토대로 가주문 테이블을 생성 후 클라이언트가 필요로 하는 일련의 데이터 응답</p>
</li>
<li><p>클라이언트는 반환받은 값을 가지고 이어서 <code>requestPayment({ 결제 정보 })</code> 함수를 통해 토스페이먼츠에 <u>결제창 호출</u>을 수행</p>
</li>
<li><p>결제창에서 구매자는 일련의 절차 후 최종적으로 결제 완료 수행. 이 후, 토스페이먼츠는 결제의 성공 여부 및 관련 파라미터를 <strong>callback url</strong>로 리다이렉트 ( + 가주문 데이터와의 정합성 체크)</p>
</li>
<li><p>서버(애플리케이션 서버)는 성공/실패에 따라 적절한 controller로 응답을 받음
 성공적으로 결제가 이루어졌다면 애플리케이션 서버에서 토스페이먼츠 서버에게 <strong>&quot;최종 결제 승인 요청&quot;</strong>을 보냄. (이 때, 실패하게 되면 클라이언트에게 실패 사유의 에러를 리턴하고 종료)</p>
</li>
<li><p>토스페이먼츠는 해당 요청을 검증하고 정상적으로 애플리케이션 서버에 반환. 서버도 해당 데이터 확인 후 잘 래핑해 클라이언트에 반환.</p>
</li>
<li><p>클라이언트는 애플리케이션 서버로 부터 결제 승인이 완료되었다는 메시지 (<strong><code>{ status: &quot;DONE&quot; }</code></strong>)를 받게 되면 그때부터 실 주문 API 시작.</p>
</li>
</ol>
<hr>
<p>마지막 7번단계를 제외한 1 ~ 6 단계를 이번 포스팅에서 다뤄보고자 한다. 그럼 이제 본격적 코드 레벨로 들어가보자.</p>
</br>

<h2 id="🧃-로직-설계-with-nestjs-react">🧃 로직 설계 with Nestjs (+React)</h2>
</br>

<h3 id="1-가주문-테이블-생성">&gt; 1) 가주문 테이블 생성</h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/fcb6e29b-1cfd-42cd-9b69-f9c5bc3bf024/image.png" alt=""></p>
<p>앱의 경우는 다를 수 있겠지만 어떠한 이유에서라도 <strong>&quot;가주문 테이블&quot;</strong>을 미리 생성하는 작업은 필요하다. 결제 전 미리 서버로 보낸 결제 관련 데이터는 정합성 체킹및 로깅을 위한 선수 작업이라 할 수 있다.</p>
<p>또한, 결제에 필요한 <code>orderId(토스 페이먼츠에서 결제에 사용하게 될 주문 ID)</code>의 생성을 클라이언트가 아닌 서버에서 진행해, 가주문 생성 시 응답으로 넘겨주도록 한다.</p>
</br>

<p><strong>temp_orders</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/12a76569-b48f-4100-82fa-60c0d27feee6/image.png" alt=""></p>
<p>가주문 테이블인 만큼 간단한 정보만 기입해준다.</p>
<pre><code class="language-ts">// tempOrders.entity.ts
@Entity()
export class TempOrders {
  @PrimaryColumn({ type: &#39;varchar&#39;, length: 25, comment: &#39;== orderNumber&#39; })
  tempOrderId: string;

  @Column({ type: &#39;varchar&#39;, length: 50 })
  orderName: string;

  @Column({ type: &#39;int&#39; })
  pointAmount: number;

  @Column({ type: &#39;mediumint&#39; })
  couponAmount: number;

  @Column({ type: &#39;int&#39; })
  totalAmount: number;

  @BeforeInsert()
  generateTempOrderId() {
    // 무작위 문자열을 생성하는 함수
    function generateRandomOrderId(length: number): string {
      const charset = &#39;abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=&#39;;
      let randomString = &#39;&#39;;
      for (let i = 0; i &lt; length; i++) {
        const randomIndex = Math.floor(Math.random() * charset.length);
        randomString += charset.charAt(randomIndex);
      }
      return randomString;
    }

    // 생성된 무작위 문자열을 tempOrderId에 할당
    this.tempOrderId = generateRandomOrderId(25);
  }
}</code></pre>
<p><code>nanoid</code>와 같은 라이브러리를 사용하여도 상관이 없을 듯하다. 어떤 방법이 되었든 토스페이먼츠에서 제시하는 <strong><code>orderId</code></strong> 형식 제한에 맞추어 temp_orders 테이블 pk 값을 생성해준다. </p>
<p>typeorm에서 제공하는 <strong><code>@BeforeInsert()</code></strong>를 통해 sql의 trigger와 같은 기능을 구현해 낼 수 있다. </p>
</br>

<p>클라이언트에게 가주문 테이블 생성 시 pk 값을 제외한 테이블을 이루는 4가지 데이터를 요청 필드로 받고, 응답 시 trigger를 통해 생성 된 랜덤한 <code>orderId</code>값을 리턴한다.</p>
<p>그럼, 아래와 같이 가주문 테이블을 생성 해 보자.</p>
<p>5,000원 주문 금액에 2,000원 포인트를 사용하여 <strong>총 결제 금액 3,000원</strong>을 가주문 테이블에 저장한다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2fadb247-9dcf-4c86-bf8d-8fdc7f191357/image.png" alt=""></p>
</br>

<h3 id="-frontend-test">&gt; (+) FrontEnd Test</h3>
<p>당시 앱 클라이언트보다 서버 구현의 진행도가 빨랐고, 주문 결제의 플로우를 스스로 설계해야하는 상황이었다. 이로 인해 프론트단 구현이 없는 서버만의 API 설계가 쉽지 않았고, 이에 따라 그나마 할 수 있는 스택인 <strong>리액트</strong>를 이용해 웹 단으로 <strong>결제창 페이지를 띄워보기로 하였다</strong>. (당시는 플러터 또한 웹뷰를 사용하고 있었다)</p>
<p>리액트로 결제창을 연동하는 과정은 공식문서 혹은 벨로그 토스페이먼츠 문서에서 쉽게 찾아볼 수 있다.</p>
<blockquote>
<p><a href="https://docs.tosspayments.com/guides/payment-widget/integration?frontend=react">결제창 연동하기 - 개발자센터</a></p>
</blockquote>
<pre><code class="language-ts">// Checkout.tsx

  const handlePayment = async () =&gt; {
    const paymentWidget = paymentWidgetRef.current;

    try {
      // ------ &#39;결제하기&#39; 버튼 누르면 결제창 띄우기 ------
      // https://docs.tosspayments.com/reference/widget-sdk#requestpayment결제-정보
      await paymentWidget?.requestPayment({
        orderId: &quot;w-SivxVx_1CBquDv9H6YPAWGe&quot;,
        orderName: &quot;떡볶이&quot;,
        customerName: &quot;남토스&quot;,
        customerEmail: &quot;customer123@gmail.com&quot;,
        successUrl: `${window.location.origin}/success`,
        failUrl: `${window.location.origin}/fail`,
      });
    } catch (err) {
      console.error(err);
    }
  };</code></pre>
<p>프론트 단에서 핵심은 위의 <code>handlePayment()</code> 함수 내부에 구현된 <strong><code>requestPayemnt()</code></strong>이다.</p>
<p>앞서 가주문 테이블 생성 시 랜덤으로 만든 문자열 <code>orderId</code>를 서버로부터 받은 후, 위와 같이 해당 필드에 넣어준다. 추가적으로 알아볼 프로퍼티론 결제 인증 성공 여부에 따른 <strong><code>successUrl</code></strong>, <strong><code>failUrl</code></strong>이 될 수 있겠다.</p>
</br>

<p><strong>결제창 페이지</strong> 
(실제 연동 시엔 카드 결제, 간편 결제 만을 다룹니다)</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/5d962aa7-d7da-4171-97e4-1c4aab06827b/image.png" alt=""></p>
</br>

<h3 id="2-결제-요청---인증">&gt; 2) 결제 요청 -&gt; 인증</h3>
<p>간단히 <strong>toss pay</strong>를 사용해 결제를 진행해보자. 가주문 테이블 생성 과 일치하게 끔, 총 결제 금액(3,000)과 할인 포인트(2,000)원을 동일하게 적용한다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/c2b88b77-0398-4304-893d-3bd70b3fac8d/image.png" alt=""></p>
<p>위와 같이 각 사용 카드사 혹은 간편 결제사의 UI에 따라 결제 요청을 수행할 수 있게 된다. 그 후, 결제 요청이 <strong>&quot;성공&quot;</strong> 하였을 시 우리가 앞서 프론트 단에서 설정한 callback url에 따라 <strong>&quot;success url&quot;</strong>로 이동하게 된다.</p>
<pre><code class="language-tsx">// Success.tsx
export function SuccessPage() {
  const [searchParams] = useSearchParams();
  console.log(searchParams, &quot;success&quot;);
  // 서버로 승인 요청

  return (
    &lt;&gt;
      &lt;h1&gt;결제 성공&lt;/h1&gt;
      &lt;div&gt;{`주문 아이디: ${searchParams.get(&quot;orderId&quot;)}`}&lt;/div&gt;
      &lt;div&gt;{`결제 금액: ${Number(
        searchParams.get(&quot;amount&quot;)
      ).toLocaleString()}원`}&lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/7c76e538-29c3-4099-9cca-b3fba2da7b0f/image.png" alt=""></p>
<p>위와 같은 success page가 렌더링되며 동시에 callback url로 아래와 같은 정보를 받게 된다.</p>
<pre><code>http://localhost:5173/success?paymentType=NORMAL
    &amp;orderId=w-SivxVx_1CBquDv9H6YPAWGe
    &amp;paymentKey=WjDM1PvGzZ0RnYX2w532*****************KBaOo47
    &amp;amount=3000</code></pre><p><code>success url</code>로 넘어오는 쿼리 파라미터는 총 4가지로, 아래와 같다.</p>
<ul>
<li><p><strong>paymentKey</strong>: 결제를 식별하는 키 값이다. 해당 값은 토스 페이먼츠에서 발급하며 결제 승인, 조회, 취소등의 전반적 운영에 필요한 필수 값이다.</p>
</li>
<li><p><strong>orderId</strong>: 주문 ID, 결제 요청에 보내는 값이다.</p>
</li>
<li><p><strong>amount</strong>: 결제 금액이다. 결제 요청에 보낸 결제 금액과 같은지 반드시 확인해볼 필요가 있다. 클라이언트에서 결제 금액을 조작해 승인하는 행위를 방지할 수 있으며, 만약 데이터 불일치시 클라이언트에서 이에 대한 조치를 취해줄 필요가 있다.</p>
</li>
<li><p><strong>paymentType</strong>: 결제 유형이다. <code>NORMAL</code>, <code>BRANDPAY</code>, <code>KEYIN</code> 중 하나이며, 우리의 경우 <strong>&quot;일반 결제&quot;</strong>에 해당하는 NORMAL 타입만을 다루게 된다.</p>
</li>
</ul>
</br>

<p>여기서 <code>paymentType</code>을 제외한 나머지 3가지 필드를 필수적으로 서버에 넘겨주어야 한다. &lt;요청 - 인증&gt; 까진 서버의 개입이 없었다면 이제부턴 애플리케이션 <strong>서버의 역할이 중요시 수행</strong>된다. </p>
<p>클라이언트로부터 받은 위의 3가지 필드를 pg사(토스 페이먼츠)로 보내주어 <strong>&quot;결제 승인&quot;</strong>을 처리할 수 있다.</p>
</br>

<h3 id="3-결제-승인-application-server-to-pg-server">&gt; 3) 결제 승인 (application server to pg server)</h3>
<p>결제 승인 과정 또한 개발자 문서에 상세히 나와 있다.</p>
<p><a href="https://docs.tosspayments.com/reference#%EA%B2%B0%EC%A0%9C-%EC%8A%B9%EC%9D%B8">API &amp; SDK 결제 승인</a></p>
<p>어려운 것은 없다, <strong>express.js</strong>로 만들어진 좋은 예시 코드가 나와있으니 충분히 Nestjs에 반영할 수 있다.</p>
</br>

<pre><code>curl --request POST \
  --url https://api.tosspayments.com/v1/payments/confirm \
  --header &#39;Authorization: Basic dGVzdF9za196WExrS0V5cE5BcldtbzUwblgzbG1lYXhZRzVSOg==&#39; \
  --header &#39;Content-Type: application/json&#39; \
  --header &#39;Idempotency-Key: SAAABPQbcqjEXiDL&#39; \ (멱등키 추가)
  --data &#39;{&quot;paymentKey&quot;:&quot;5zJ4xY7m0kODnyRpQWGrN2xqGlNvLrKwv1M9ENjbeoPaZdL6&quot;,&quot;orderId&quot;:&quot;a4CWyWY5m89PNh7xJwhk1&quot;,&quot;amount&quot;:15000}&#39;</code></pre><p>위를 베이스로 하여 header에 <strong>&quot;멱등키(Idempotency-Key)&quot;</strong>를 추가하도록 해준다. </p>
<p><span style="color:gray">(멱등키 생성 및 생성 이유에 대한 자세한 내용은 개발자 문서를 참고하기 바랍니다)</span></p>
<p><a href="https://docs.tosspayments.com/reference/using-api/idempotency-key">멱등키 - 개발자 문서</a></p>
</br>

<p>결제 승인 로직은 비즈니스 로직인 만큼 <strong>서비스 레이어</strong>에서 수행하도록 하며 전체 코드는 아래와 같다.</p>
<pre><code class="language-ts">/* order_payment.service.ts */

export class OrderPaymentService implements OrderPaymentUseCase {
  private readonly tossUrl = &#39;https://api.tosspayments.com/v1/payments/&#39;;
  private readonly secretKey = process.env.TOSS_TEST_SECRET_KEY; // 시크릿 키

  constructor(
    private readonly paymentRepository: PaymentDrivenPort,
  ) {}

  public async requestTossPayment(tossPaymentReqCommand: TossPaymentReqCommand): Promise&lt;TossPaymentsConfirmResModel&gt; {
    const idempotency = uuid_v4();  // 멱등키 생성

    const { paymentKey, orderId, amount } = tossPaymentReqCommand;

    try {
      const response = await axios.post(
        `${this.tossUrl}/${paymentKey}`,
        {
          orderId,
          amount,
        },
        {
          headers: {
            Authorization: `Basic ${Buffer.from(`${this.secretKey}:`).toString(&#39;base64&#39;)}`,
            &#39;Content-Type&#39;: &#39;application/json&#39;,
            &#39;Idempotency-Key&#39;: `${idempotency}`,  // 멱등키 추가
          },
          // 필수 승인 요청 데이터
          data: {
            paymentKey: paymentKey,
            amount: amount,
            orderId: orderId,
          }
        },
      );

      // 가주문 테이블에 저장된 데이터 (orderId, totalAmount)와 승인시 내려온 데이터의 정합성 체크
      const tempOrdersData = await this.paymentRepository.getTempOrdersData(orderId);

      const { payment_orderId, totalAmount } = tempOrdersData;

      if (response.data.orderId !== payment_orderId) {
        throw new OrderIdMissMatchException();
      }

      if (response.data.totalAmount !== totalAmount) {
        throw new PaymentTotalAmountMissMatchException();
      }

      const method = response.data.method;

      let paymentMethod: string = &quot;알 수 없음&quot;;

      if (method === &quot;카드&quot;) {
        // 발급사 코드에 따른 발급사명 리턴 수행작업
        const issuerCode: CardIssuerCode = response.data.card.issuerCode;
        paymentMethod = CardIssuerName[issuerCode];
      }

      if (method === &quot;간편결제&quot;) {
        paymentMethod = response.data.easyPay.provider;
      }

      if (method === &quot;휴대폰&quot;) {
        paymentMethod = response.data.method;
      }

      return new TossPaymentsConfirmResModel(
        response.data.orderId,
        response.data.paymentKey,
        response.data.orderName,
        response.data.status,
        paymentMethod,
        response.data.approvedAt,
        response.data.totalAmount
      );

    } catch (err) {
      throw err;
    }
  }
}</code></pre>
</br>

<p><strong>Check Point 1</strong></p>
<p>가주문 테이블과의 데이터 정합성 체킹 과정을 수행한다. 물론 결제 인증 후, 클라이언트 단에서도 1차 체킹 작업을 수행하지만 서버에서도 승인 후 성공 응답 데이터를 통해 더블 체킹을 수행해준다. 이 때, <code>orderId</code>, <code>totalAmount</code> 등에 miss-match가 일어날 경우 애플리케이션 서버에서 설정한 별도의 에러를 띄운다.</p>
<pre><code class="language-ts">export class OrderIdMissMatchException extends HttpBaseException {
  constructor() {
    super(PaymentConfirmExceptionErrCodeEnum.ORDER_ID_MISS_MATCH, HttpStatus.BAD_REQUEST, PaymentConfirmExceptionMsgEnum.ORDER_ID_MISS_MATCH);
  }
}

export class PaymentTotalAmountMissMatchException extends HttpBaseException {
  constructor() {
    super(PaymentConfirmExceptionErrCodeEnum.PAYMENT_TOTAL_AMOUNT_MISS_MATCH, HttpStatus.BAD_GATEWAY, PaymentConfirmExceptionMsgEnum.PAYMENT_TOTAL_AMOUNT_MISS_MATCH);
  }
}</code></pre>
</br>

<p><strong>Check Point 2</strong></p>
<p>클라이언트에게 결제시 사용한 카드 발급사 혹은 간편결제 수단을 알려줄 필요가 있었다. (해당 데이터를 유저에게 보여주기 위함)</p>
<p>결제 승인 성공 시, 응답 데이터로 위의 필드를 제공받을 수 있으며 이를 클라이언트에게 전달해주면 될 것이다. </p>
<p>참고로 결제 승인 성공 시 받을 수 있는 Payment 객체는 아래의 문서에서 확인할 수 있다.</p>
<p><a href="https://docs.tosspayments.com/reference#payment-%EA%B0%9D%EC%B2%B4">코어 API _Payment 객체 - 개발자 센터</a></p>
</br>

<p>여기서 별도의 처리를 해줘야 할 포인트가 존재한다.</p>
<p>카드사 관련 요청 시엔 토스페이먼츠에서 제공하는 &quot;원하는&quot; 형식을 사용할 수 있지만, <strong>응답시엔</strong> 항상 <strong>두 자리 &quot;숫자&quot;</strong> 코드로 돌아온다.</p>
<p><a href="https://docs.tosspayments.com/reference/codes#%EA%B5%AD%EB%82%B4">기관 코드 - 개발자 센터</a></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2e93c408-28c4-4211-b0fb-fef45246cb5b/image.png" alt=""></p>
<p>물론, 이와 같은 숫자 코드를 그대로 클라이언트에게 보내 줄 수 있지만, 결국 클라이언트는 별도의 객체를 생성해 발급사명(혹은 간편 결제사명)과 매핑해주는 과정을 수행해주어야 한다. </p>
<p>이 과정은 서버에서 처리해주도록 하였으며 <u>아래와 같은 객체를 생성</u>해 줄 수 있었다. 참고로, 우린 <strong>&quot;발급사 코드(issuerCode)&quot;</strong>를 사용하는 것이며 이는 &quot;매입사 코드(acquirerCode)&quot;와 다르니 처음이라면 꼭 확인해보면 좋을 것이다.</p>
<pre><code class="language-ts">export const CardIssuerName = {
  &quot;3K&quot;: &quot;기업BC&quot;,
  &quot;46&quot;: &quot;광주은행&quot;,
  &quot;71&quot;: &quot;롯데카드&quot;,
  &quot;30&quot;: &quot;KDB산업은행&quot;,
  &quot;31&quot;: &quot;BC카드&quot;,
  &quot;51&quot;: &quot;삼성카드&quot;,
  &quot;38&quot;: &quot;새마을금고&quot;,
  &quot;41&quot;: &quot;신한카드&quot;,
  &quot;62&quot;: &quot;신협&quot;,
  &quot;36&quot;: &quot;씨티카드&quot;,
  &quot;33&quot;: &quot;우리BC카드&quot;,
  &quot;W1&quot;: &quot;우리카드&quot;,
  &quot;37&quot;: &quot;우체국예금보험&quot;,
  &quot;39&quot;: &quot;저축은행중앙회&quot;,
  &quot;35&quot;: &quot;전북은행&quot;,
  &quot;42&quot;: &quot;제주은행&quot;,
  &quot;15&quot;: &quot;카카오뱅크&quot;,
  &quot;3A&quot;: &quot;케이뱅크&quot;,
  &quot;24&quot;: &quot;토스뱅크&quot;,
  &quot;21&quot;: &quot;하나카드&quot;,
  &quot;61&quot;: &quot;현대카드&quot;,
  &quot;11&quot;: &quot;국민카드&quot;,
  &quot;91&quot;: &quot;NH농협카드&quot;,
  &quot;34&quot;: &quot;Sh수협은행&quot;,
} as const;

export type CardIssuerCode = keyof typeof CardIssuerName;</code></pre>
<p><code>enum</code> 객체를 사용하려했지만 숫자로 된 문자열을 key로 두는 것에 제한이 있었고, 이에 따라 일반 객체와 <code>as const</code>로써 정의하였다. </p>
<p>간편결제 같은 경우는 <code>data.easyPay.provider</code> 값으로 한글 결제사명이 넘어오니 상관없다. </p>
</br>

<p>이렇게 결제 승인이 잘 수행된다면 클라이언트에게 최종적으로 아래와 같은 리스폰스를 전달해준다. 모든 Payment 객체를 전달해줄 필요는 없다.</p>
<pre><code class="language-ts">export class TossPaymentsConfirmResModel {

  @ApiProperty({
    example: &#39;2wUPgtYG**********dfRybHsxo&#39;,
    description: &#39;결제 승인이 완료된 후 다시 리턴하는 orderId값으로, 실주문 api시 다시 요청객체로 받습니다.&#39;,
    required: true,
  })
  orderId: string;

  @ApiProperty({
    example: &#39;d9ojO5qEvKm**********RybneK***********Gg7pD&#39;,
    description: &#39;결제 승인이 완료된 후 다시 리턴하는 paymentKey값으로, 실주문 api시 다시 요청객체로 받습니다.&#39;,
    required: true,
  })
  paymentKey: string;

  @ApiProperty({
    example: &#39;대표 메뉴 1&#39;,
    description: &#39;결제 시 대표메뉴로써 작성한 주문 명&#39;,
    required: true,
  })
  orderName: string;

  @ApiProperty({
    example: &#39;DONE&#39;,
    description: &#39;결제 승인에 따른 결제 상태(DONE, CANCELED ...)&#39;,
    required: true,
  })
  status: string;

  @ApiProperty({
    example: &#39;토스페이&#39;,
    description: &#39;결제 승인 후 pg사로부터 받게 된 결제 수단(카드 매입사 혹은 발급사명)&#39;,
    required: true,
  })
  paymentMethod: string;

  @ApiProperty({
    example: &#39;2023-11-30T18:34:51+09:00&#39;,
    description: &#39;결제가 승인 된 시간&#39;,
    required: true,
  })
  approvedAt: string;

  @ApiProperty({
    example: 5000,
    description: &#39;총 결제된 금액&#39;,
    required: true,
  })
  amount: number;

  constructor(orderId: string, paymentKey: string, orderName: string, status: string, paymentMethod: string, approvedAt: string, amount: number) {
    this.orderId = orderId;
    this.paymentKey = paymentKey;
    this.orderName = orderName;
    this.status = status;
    this.paymentMethod = paymentMethod;
    this.approvedAt = approvedAt;
    this.amount = amount;
  }
}</code></pre>
</br>

<p><strong>결제 승인 성공</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/4b9ef0e5-c91b-42dd-810d-bd6433818e0c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2891dd1a-fd2f-4943-9481-9fe851c2d67c/image.png" alt=""></p>
</br>

<h2 id="다음-포스팅-예고">다음 포스팅 예고</h2>
<p>본격적으로 <u>주문에 들어가기 전</u> <strong>&quot;서버&quot;</strong>에서 처리해야 할 부분이 아직 남았다.</p>
<p>앞서 결제 로직에서 데이터 정합성 체킹을 위해 전체 금액과 주문 ID를 비교하는 과정을 가졌다. 이렇게 <strong>&quot;애플리케이션 서버&quot;</strong>에서 발생한 에러는 비즈니스 로직에서 에러를 핸들링하기 편하지만 서드파티(pg사)측에서 던지게 될 에러는 어떻게 처리해 주어야 할지 고민이 되기 마련이다. </p>
<p>다음 포스팅에선 간단히 서드 파티 에러를 유연하게 처리하는 방법과 그에 대한 생각을 조금 공유해보고자 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[주문 결제 #1] 장바구니로부터의 시작 (NestJS) ]]></title>
            <link>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-1-%EC%9E%A5%EB%B0%94%EA%B5%AC%EB%8B%88%EB%A1%9C%EB%B6%80%ED%84%B0%EC%9D%98-%EC%8B%9C%EC%9E%91</link>
            <guid>https://velog.io/@from_numpy/%EC%A3%BC%EB%AC%B8-%EA%B2%B0%EC%A0%9C-1-%EC%9E%A5%EB%B0%94%EA%B5%AC%EB%8B%88%EB%A1%9C%EB%B6%80%ED%84%B0%EC%9D%98-%EC%8B%9C%EC%9E%91</guid>
            <pubDate>Fri, 09 Feb 2024 06:52:58 GMT</pubDate>
            <description><![CDATA[<h2 id="🥤-결제-주문-프로세스-고민의-시간">🥤 결제-주문 프로세스, 고민의 시간</h2>
<p>프로젝트를 진행하면서 구축하였던 <strong>&quot;주문-결제 프로세스&quot;</strong>를 기록으로 남겨보고자 한다. </p>
<p>사용자가 제품을 결제하고 주문하는 과정은 어떤 과정속에서 진행될까? 물론 도메인마다 상이할 수도 있겠지만 이번에 참여한 o2o 서비스의 경우, 흔히 생각하는 배달앱과 비슷한 프로세스로 진행이 된다.</p>
<hr>
<p>장바구니 -&gt; 결제(주문)서 -&gt; pg사 연동을 통한 결제 진행 -&gt; 결제 성공 시 주문 프로세스 진행 -&gt; 주문 성공 혹은 취소 -&gt; 주문 성공 시 매장 서버에 주문 접수 알림</p>
<p><span style="color:gray">( 물론 위의 과정 사이 사이에 디테일한 여러 포인트가 있을 것이다... )</span></p>
<hr>
<p>아직 진행중인 &quot;메시징 큐 (Rabbit MQ)&quot;를 통한 Server to Server  통신을 제외한 유저의 주문 결제 프로세스 과정을 담아보려 한다.</p>
<p>주문 결제 프로세스를 진행하는 과정은 정말 많이 고민하고 시간을 많이 투자하였던 것 같다. 일전에 Stripe를 통해 간단한 주문 결제 과정을 체험해본 적은 있지만 그것은 사실 아무것도 아니였다. 이번엔 실제 유저의 사용을 고려해야했고, 주문 혹은 주문 취소시 발생할 수 있는 모든 부가적 요소 (재고처리, 푸시알림, 취소시 롤백 등) 에 대한 고민 역시 요구되었다.</p>
<p>실제 금전을 통한 결제가 이뤄지는 만큼 100%는 당연히 힘들겠지만 최대한 문제가 없는 상황을 만들려 하였고, 설령 금전과 관련된 문제 (결제는 되었지만 주문이 되지 않았다, 주문은 되었지만 결제가 되지 않았다)가 일어나더라도 이를 빠르게 트래킹 하기위해 일련의 장치를 두는 것을 고려할 필요도 있었다.</p>
</br>

<p>API 설계 역시 마찬가지다. <span style="color:green">(이미지 출처: 미리)</span></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/1a75b607-700c-4991-b2e3-6da3c06e6164/image.png" alt=""></p>
<p>더 나은 유저 경험을 위해 어떻게 화면 설계를 보안하고, 각 화면 혹은 버튼마다 어떤 기능을 제공할 것인가에 대한 명세를 고민하였다. 단순 코드 한 줄 짜는것보다 클라이언트와 서버간, 화면 명세에 따른 API 플로우를 정의하고 고민을 나누는 것이 더 중요한 시간이었다.</p>
<p>당시 서버의 진행 상황이 조금 더 빨랐기 때문에 나 역시 간단히 React를 사용해 toss payments 결제 창을 띄워 전체 <strong>결제 -&gt; 주문 플로우</strong>을 미리 설계해 보는 시간을 가져 보았고, 추후 실제 앱(Flutter) 클라이언트와 연동하는데 있어 빠르게 API를 적용해 볼 수 있게 되었다.</p>
</br>

<p>결제 주문을 진행하면서 고민하였던 모든 생각을 다 녹여 낼 순 없겠지만 앞으로 진행될 각 파트에서 조금 더 기능적으로 구체화 된 고민을 공유해보고자 한다. </p>
<p>그럼, 이번 포스팅에선 결제 주문 서 페이지로 넘어가기 전, <strong>첫 시작</strong>을 알리는 <strong>&quot;장바구니(카트)&quot;</strong> 페이지를 잠깐 들여다보며 이번 시리즈를 예고해 보도록 하겠다. </p>
</br>

</br>

<h2 id="🧃-장바구니에서-체크해야-할-것">🧃 장바구니에서 체크해야 할 것</h2>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/a2ec9b16-8be1-46b1-9b5c-c79e6dfeeef8/image.png" alt=""></p>
<p>옵션에 대한 수량은 존재하지 않지만 테이블에서 보다 시피 각 메뉴에 대해 주문할 <strong>&quot;수량&quot;</strong>은 분명히 체킹해 줄 필요가 있었다. </p>
<p>유저는 단일 메뉴가 아닌 여러 메뉴를 주문할 수 있고, 각 메뉴당 (재고 선 안에서)원하는 수량을 장바구니에서 설정해 줄 수 있다.</p>
<p>물론, 메뉴 상세 페이지 등에서 메뉴의 잔여 재고 수량을 보여주도록 하고 있지만 유저가 장바구니로 넘어온 시점에서 재고 수량이 달라질 수 있다.</p>
<p>A,B 메뉴를 장바구니에 담은 유저가 있다고 가정하자. 만약 A 메뉴의 잔여 수량이 5개이지만 유저가 7개를 주문 수량으로 넣었을 경우는 충분히 일어날 수 있는 상황이다. 하지만 이 상황은 엄연히 &quot;품절&quot;이 일어난 것이 <strong>아닌</strong> 잔여 수량의 불충분으로 인한 미스이다. 즉, 해당 체킹을 굳이 결제-주문 과정까지 넘겨주지 않고 해당 화면 내에서 검증해주는 것이 바람직하다 판단하였다. <span style="color:green">(이미지 출처: 미리)</span></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/1ba87e86-0e8c-4651-a286-f9167ec01d43/image.png" alt=""></p>
<p>현재 위의 화면에서 &quot;OOOO원 주문하기&quot;를 클릭하게 되면 일반적으로 주문서 페이지로 이동하게 된다. 이에 따라 주문서 페이지로 이동하기 전, 수량 체킹을 수행하는 API를 두어 선처리를 해주게끔 한다.</p>
</br>

<h3 id="수량-체킹-로직-설계">&gt; 수량 체킹 로직 설계</h3>
<p>여러 메뉴를 주문하고, 동시에 각 메뉴마다 여러 수량을 주문하였을 경우 우리가 앞서 가정한대로 특정 메뉴의 주문수량이 잔여수량이 넘기게 된다면 &quot;서버&quot;는 &quot;클라이언트&quot;에게 어떤 값을 넘겨주어야 할까?</p>
<p>이것에 초점을 맞추어 해당 API 로직을 설계한다.</p>
</br>

<p><strong>Request DTO</strong> </p>
<p>장바구니 화면에서 클라이언트는 각 메뉴의 ID(식별자)값과 주문 수량을 알고 있는 상태이다. 두 값을 객체형태로 배열에 담아 서버 요청에 사용한다.</p>
<pre><code class="language-ts">export class CartMenuReqDto {

  @ApiProperty({
    example: 1,
    description: &#39;주문할 메뉴 ID&#39;,
    required: true,
  })
  @IsInt()
  menuId: number;

  @ApiProperty({
    example: 10,
    description: &#39;주문할 메뉴 수량&#39;,
    required: true
  })
  @IsInt()
  @Min(1)
  cartMenuQuantity: number;
}

export class MenuStockCheckReqDto {

  @ApiProperty({
    type: [CartMenuReqDto],
  })
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() =&gt; CartMenuReqDto)
  menus: CartMenuReqDto[];
}</code></pre>
</br>

<p><strong>Command</strong></p>
<p>클라이언트로부터 전달받은 위의 DTO 객체는 프리젠테이션 내부 매핑 과정을 거쳐 아래와 같은 command 객체로써 domain layer에게 전달된다.</p>
<pre><code class="language-ts">export class CartMenuReqCommand {
  constructor(
    public menuId: number,
    public cartMenuQuantity: number
  ) {}
}

export class MenuStockCheckReqCommand {
  constructor(
    public menus: CartMenuReqCommand[]
  ) {}
}
</code></pre>
</br>

<p><strong>Response DTO</strong></p>
<p>클라이언트에게 응답할 필드를 정의한 객체이다. 재고 수량 조건에 맞지 않는 주문 메뉴가 존재하는가에 대한 불리언 정보와, 수량 조건에 맞지 않는 메뉴에 대한 정보를 객체로 묶은 배열로써 전달해준다.</p>
<pre><code class="language-ts">export class InefficientMenu {

  @ApiProperty({
    example: 1,
    description: &#39;재고 수량이 불충분한 메뉴의 메뉴ID&#39;,
    required: true,
  })
  menuId: number;

  @ApiProperty({
    example: &#39;메뉴명 1&#39;,
    description: &#39;재고 수량이 불충분한 메뉴의 메뉴명&#39;,
    required: true,
  })
  menuName: string;

  @ApiProperty({
    example: 2,
    description: &#39;해당 메뉴의 남은 재고 수량&#39;,
    required: true,
  })
  remainQuantity: number;

  constructor(menuId: number, menuName: string, remainQuantity: number) {
    this.menuId = menuId;
    this.menuName = menuName;
    this.remainQuantity = remainQuantity;
  }
}

export class MenuStockCheckResponse {

  @ApiProperty({
    example: 1,
    description: &#39;재고 수량이 불충분한 메뉴가 존재하는지 여부&#39;,
    required: true,
  })
  isInSufficientMenuExisting: boolean;

  @ApiProperty({
    type: [InefficientMenu]
  })
  @Type(() =&gt; InefficientMenu)
  @ValidateNested({ each: true })
  inEfficientMenus: InefficientMenu[];

  constructor(isInSufficientMenuExisting: boolean, inEfficientMenus: InefficientMenu[]) {
    this.isInSufficientMenuExisting = isInSufficientMenuExisting;
    this.inEfficientMenus = inEfficientMenus;
  }
}</code></pre>
</br>

<p><strong>Adaptor</strong> (Service, Controller는 생략)</p>
<pre><code class="language-ts">  public async checkMenuStocksIsSufficient(menuStockCheckReqCommand: MenuStockCheckReqCommand): Promise&lt;MenuStockCheckResponse&gt; {
    const inEfficientMenus: InefficientMenu[] = [];

    for (const menuData of menuStockCheckReqCommand.menus) {
      const { menuId, cartMenuQuantity } = menuData;

      const menu = await this.menuRepository.findByCondition({
        select: {
          menuId: true,
          name: true,
          quantity: true,
        },
        where: {
          menuId: menuId,
        },
      });

      if (menu.quantity &lt; cartMenuQuantity) {
        // Menu doesn&#39;t have enough stock
        inEfficientMenus.push({ 
          menuId: menu.menuId,
          menuName: menu.name,
          remainQuantity: menu.quantity,
        });
      }
    }

    const isInSufficientMenuExisting = inEfficientMenus.length !== 0;

    return new MenuStockCheckResponse(isInSufficientMenuExisting, inEfficientMenus);
  }</code></pre>
</br>

<h3 id="test">&gt; Test</h3>
<p><strong>정상적 주문 수행 (재고 미스 X)</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/a950c30c-0067-4165-a66d-ffb96d5486e6/image.png" alt=""></p>
<p><strong>재고 수량 미스</strong> </p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/e09d6e75-1323-40a0-b7d8-c01378b0d872/image.png" alt=""></p>
</br>

<p>자, 이렇게 재고 수량에 대한 미스가 발생하게 되면 위의 응답 정보를 통해 클라이언트는 주문서로 넘어갈지 특정 dialog 창을 띄워 유저에게 상황을 인지 시킬지를 판별할 수 있게 된다. </p>
</br>

<p>(결제-주문 포스팅은 계속 이어집니다 👍👍👍)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project] #3 <커서 기반 페이지네이션>과 함께하는 무한스크롤(feat. NestJS)]]></title>
            <link>https://velog.io/@from_numpy/Project-3-%EC%BB%A4%EC%84%9C-%EA%B8%B0%EB%B0%98%EC%9D%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EC%84%A4%EA%B3%84-feat.-NestJS</link>
            <guid>https://velog.io/@from_numpy/Project-3-%EC%BB%A4%EC%84%9C-%EA%B8%B0%EB%B0%98%EC%9D%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EC%84%A4%EA%B3%84-feat.-NestJS</guid>
            <pubDate>Sat, 27 Jan 2024 16:28:09 GMT</pubDate>
            <description><![CDATA[<h2 id="🧃-무한스크롤을-구현해야-한다">🧃 무한스크롤을 구현해야 한다.</h2>
<p>어떤 정보에 대한 목록을 유저에게 보여주는데 있어서 도메인의 특성상 특정 페이지로 이동이 가능한 페이징 형태보단 무한스크롤이 대부분 요구되었다.</p>
<p>무한스크롤을 구현하려할 때 <strong>&quot;서버&quot;</strong>에서 고려해야할 사항은 무엇일까? 물론, 클라이언트와 어떤 형식의 요청 쿼리로 소통할 것인가도 포함이다.</p>
<p>물론 개발자마다 중요하게 생각하는 부분이 다를 수 있겠지만, 내가 여태 페이지네이션들을 구현해보며 중점을 두었던 부분은 <strong>첫째</strong>는 무조건 <strong>&quot;성능(조회 속도)&quot;</strong> 이고, <strong>둘째</strong>는 구현하고자 하는 페이지네이션에 <strong>&quot;정말 필요한 기법&quot;</strong>을 사용하는 것이라 본다. </p>
<p>무한 스크롤은 정말 빠르게 변동할 수 있는 데이터 목록인 만큼 UX에 민감해야하고 결국 이는 1차적으로 조회 API의 성능이 우선시 된다. </p>
<p>이 부분에 대해 이번 글에서 깊게 설명하고 싶지만, 사실 해당 개념적 내용은 일전에 따로 다룬 적이 있었다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/9081a961-319a-4a85-a516-28be30e14816/image.png" alt=""></p>
<p>해당 기능을 처음 구현해 볼 당시 관련 글이 많이 없었고, 그래서 그런지 꽤 많으신 분들이 페이지네이션 관련 글을 봐주신 것 같다. </p>
</br>

<hr>
<p>🥊🥊🥊🥊🥊</p>
<p>위의 이미지와 같은 검색 키워드로 일전에 제가 작성한 글을 찾으실 수 있습니다.</p>
<p>왜 &quot;무한스크롤&quot;을 구현하는데 <strong>&quot;Cursor-Based-Pagination(커서 기반 페이지네이션)&quot;</strong> 기법을 사용했는가에 대해 설명합니다.</p>
<p>더불어 아래에 첨부되는 링크 중 2번째에선 만약 커서 값이 &quot;Unique&quot;하지 않을 경우 발생할 수 있는 문제를 다루고, 이를 통해 어떠한 커서 값을 생성하는 지를 제시합니다. </p>
<p>이번 포스팅에선 해당 내용의 자세한 부분에 대해 생략하고 코드적 접근에 포인트를 두고자 하므로 사전에 <span style="color:red"><strong>꼭 아래 두 링크를 보시고 오시면 감사하겠습니다.</strong></span></p>
<p><a href="https://velog.io/@from_numpy/NestJS-cursor-based-pagination-nest%EC%97%90%EC%84%9C-%EC%BB%A4%EC%84%9C%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98%EC%9D%84-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EC%9E%90">#1 Cursor-Based Pagination _Query 성능 관점 접근, NestJS 구현 접근</a></p>
<p><a href="https://velog.io/@from_numpy/Cursor-Based-Pagination%EC%97%90-%EB%8B%A4%EA%B0%80%EA%B0%80%EA%B8%B0-2-feat.-%EC%9C%A0%EB%8B%88%ED%81%AC%ED%82%A4%EA%B0%80-%EC%95%84%EB%8B%8C-%EC%BB%AC%EB%9F%BC%EC%9D%84-%EC%BB%A4%EC%84%9C%EB%A1%9C-%EB%91%94%EB%8B%A4%EB%A9%B4-NestJS">#2 Cursor-Based Pagination _Custom한 커서값 생성 배경과 구현</a></p>
<p>🥊🥊🥊🥊🥊</p>
<hr>
</br>

<p>사실, 지금에서야 말하지만 위 링크에서 NestJS를 사용한 구현 코드 부분은 내가 작성하였지만 굉장히 좋지 않은 코드이다. </p>
<p>그 당시 코드 레벨에서의 수준이 너무 좋지 않았고, 성능에 이슈를 불러일으킬 만한 (전체 조회 등등...) 불필요한 연산들을 많이 수행하였다. 페이지네이션 조회 시간이 굉장히 오래 걸리는 경우가 발생하였고 하지만 기능 구현에 기뻐 그 이외의 것들을 생각해보지 못하였다. </p>
<p>이미 작성된 포스팅에 대한 수정작업은 아마 이 글이 끝나고 진행될 것 같고, 이번 글에선 저 당시 보다 훨씬 더 고도의 커서 기반 페이지네이션을 작업을 서술해보고자 한다.</p>
</br>

<p>들어가기에 앞서 잠시 무한스크롤을 구현하는데 있어 <strong>&quot;왜&quot;</strong> ** &quot;Offset-Based(오프셋)&quot;** 방식을 쓰지 않았는가에 대해 말하고 싶다. </p>
<p>오프셋 기법을 사용하였을 때 발생할 수 있는 기능적 문제(중복 아이템 호출), 데이터베이스 조회 성능과 관련된 문제는 (위 링크 확인) 예전 포스팅에서 전부 다루어서 따로는 언급안하겠지만 그냥 단순히 생각해서 <strong>&quot;무한스크롤&quot;</strong>에 <strong>&quot;오프셋&quot;</strong>이란 개념이 맞을까? 란 생각을 한 번 해볼 수 있을거 같다.</p>
<p>spring 환경의 orm이든 Node orm이든 페이지네이션을 할 수 있는 orm 수준의 기능을 지원해주고, 이는 대부분 데이터베이스의 <strong>&quot;limit-offset&quot;</strong>, orm 수준의 <strong>&quot;take-skip&quot;</strong>으로 활용할 수 있는 <strong>&quot;오프셋&quot;</strong> 기반의 페이지네이션 api 함수이다. 아주 간편하게 페이징 처리를 할 수 있는 api 함수를 제공해주기에 종종 무한스크롤을 구현한 블로그들에서도 이렇게 오프셋을 활용한 코드들을 많이 볼 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/a94311f4-52ed-45b6-816e-e41d3f51b4ea/image.png" alt=""></p>
<p>하지만 그림만 보아도 &quot;오프셋&quot;이란 개념이 무한스크롤과는 맞지 않다는 생각이 들지 않을 수가 없었다. </p>
<p><span style="color:green"><em>&quot;쉽게 사용할 수 있다&quot;</em></span> 는 사실 &quot;서버 측&quot;에 해당하는 말이다. </p>
<p>물론 오프셋으로 구현을 하더라도 클라이언트에게 &quot;take와 skip&quot; 그대로가 아닌(그대로인 일부 사례들도 보았다....)  skip을 통해 서버측에서 로직으로 구현한 &quot;page(페이지 수)&quot;를 날려줄 것을 요구하겠지만 애초에 &quot;무한 스크롤&quot;에 &quot;페이지&quot;를 요구한다... 이것 또한 모순이 있다고 본다. </p>
<p>오프셋 기법은 말 그대로 특정 페이지로써 바로 이동이 가능하게끔 <code>offset(skip)</code>, <code>limit(take)</code> 를 이용해 페이지 호출을 한다는 것인데 이는 무한스크롤의 의미와는 너무 다르다 생각이 든다. 우리가 무한 스크롤링을 하면서 &quot;페이지&quot;란 개념을 떠올리진 않기 때문이다. </p>
<p>결국 서버 측에서 무한스크롤 구현에 오프셋 기반의 페이징 처리를 할 경우, 이는 클라이언트와의 협업에 있어 의심할 여지가 있는 API 소통을 하고 있다 생각이 들 수 밖에 없었다. </p>
<p>물론, 여러 관점들이 존재하겠지만 성능적 측면은 물론이거니와 앞으로 설명할 <strong>&quot;Custom Cursor-Based Pagination&quot;</strong>을 도입하게 된 충분한 이유가 되었다.</p>
</br>

<h3 id="요구되는-기능">&gt; 요구되는 기능</h3>
<p>프로젝트에서 무한 스크롤을 요구하는 화면 정의는 여러 군데 존재하였지만 그 중 가장 구현이 복잡하였던 <strong>&quot;가게 리스트 조회&quot;</strong>를 바탕으로 이번 글을 설명하고자 한다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/72c26fcd-fcbe-4713-9ac1-5ff626fe3389/image.png" alt=""></p>
<p><span style="color:green">(출처: 미리, -- 가게 명 노출 x )</span></p>
<p>요구되는 기능은 첫 째로 키워드에 따른 정렬이다. 이미지에 (흐릿하게) 보이듯이 메인이 되는 &quot;<u>유저 등록 위치에 따른 매장과의 거리 순</u>&quot;, &quot;<u>매장 찜 등록에 따른 찜 많은 순</u>&quot;, &quot;<u>등록된 리뷰가 많은 순</u>&quot;, &quot;<u>별점 순</u>&quot;에 따른 정렬이 구현되어야 한다. </p>
<p>두 번째는 모든 키워드에 따른 정렬은 기본적으로 <u>유저가 설정한 위치에 따른 서비스에서 정한 거리 범위 내에 있는</u> 가게여야 한다. 유저와 멀리 떨어져있는 가게를 굳이 보여줄 필요는 없도록 기획이 되었기 때문에 (직접 픽업이 필요한 o2o 서비스이다) 설정한 거리 범위(<strong><code>limitDistance</code></strong>) 내부의 가게들만 리스트에 포함시킨다.</p>
<p>마지막으로는 불러올 정보들이다. 매장 썸네일 사진, 매장 태그, 별점, 찜 수, 예상 수령 시간 등등 뷰에서 처리하기 위한 다양한 정보들을 데이터베이스 내부에서 불러와야한다. 어떤 정렬 기준이던 불러올 정보의 데이터 셋은 전부 동일하다는 기획이다.  </p>
</br>

</br>

<h2 id="🧃-요청응답-모델dto-설계">🧃 요청/응답 모델(<code>dto</code>) 설계</h2>
<p>이번 챕터 역시 자세한 설명은 생략하는것이 좋을 것 같다. 위에 링크로 걸어둔 포스팅에서 해당 내용을 다루었고, 이는 커서 기반에만 국한되지 않고 오프셋 기반 등 여러 방식의 페이지네이션 API를 설계하는데 있어 전반적으로 사용한 요청/응답 모델 들이다. </p>
<hr>
<p>한 번더 링크를 남겨두도록 하겠습니다. 아래 포스팅의 내용을 통해 커서 기반의 페이지네이션을 비롯해 여러 다른 방식의 페이지네이션 API에 사용될 공통적인 객체 모델을 확인할 수 있습니다.</p>
<p><a href="https://velog.io/@from_numpy/NestJS-Pagination-with-TypeORM-feat-Refactoring#%EA%B4%80%EC%8B%AC-%EB%B6%84%EB%A6%AC%EB%A5%BC-%ED%86%B5%ED%95%9C-%EB%A6%AC%ED%8E%99%ED%86%A0%EB%A7%81-%EA%B5%AC%ED%98%84">NestJS Pagination with Typeorm (Offset-Based) __ 관심사 분리 파트 확인 바람</a></p>
<p><a href="https://velog.io/@from_numpy/NestJS-cursor-based-pagination-nest%EC%97%90%EC%84%9C-%EC%BB%A4%EC%84%9C%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98%EC%9D%84-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EC%9E%90#-%EA%B5%AC%EC%83%81-%EB%B0%8F-%EB%AA%A8%EB%8D%B8-%EC%83%9D%EC%84%B1">NestJS Pagination with Typeorm (Cursor-Based) __ 구상 및 모델 생성 파트 확인 바람</a></p>
<hr>
<p>해당 객체를 정의한 파일들은 모든 도메인에 공용적으로 쓰이므로 <code>common</code> 디렉토리 내부에 관리토록 하였고, 폴더 구조는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2e94cc71-3877-4a61-a464-e5585104ecbc/image.png" alt=""></p>
<h3 id="요청부request">&gt; 요청부(<code>request</code>)</h3>
<pre><code class="language-ts">// cursor=page-option.dto.ts
import { ApiProperty } from &quot;@nestjs/swagger&quot;;
import { Type } from &quot;class-transformer&quot;;
import { IsOptional, ValidationArguments, ValidationOptions, registerDecorator } from &quot;class-validator&quot;;

export class CursorPageOptionsDto {

  @ApiProperty({
    example: 5,
    description: &#39;하나의 페이지당 가져올 데이터의 개수 (필수 x)&#39;,
    required: false,
  })
  @Type(() =&gt; Number)
  @IsOptional()
  // custom decorator
  @IsPositiveNumber()
  take?: number = 5;

  @ApiProperty({
    example: &#39;0000000000000000&#39;,
    description: &#39;16자리의 스트링으로된 커스텀 커서 값 (첫 페이징 시작시엔 서버에서 지정한 디폴트 값 발동)&#39;,
    required: false,
  })
  @Type(() =&gt; String)
  @IsOptional()
  // custom decorator
  @IsSixteenDigitString()
  customCursor?: string;
}

function IsSixteenDigitString(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: &#39;isFourteenDigitString&#39;,
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          if (typeof value !== &#39;string&#39;) {
            return false;
          }
          return /^[0-9]{16}$/.test(value);
        },
        defaultMessage(validationArguments?: ValidationArguments) {
          return `${validationArguments.property}는 16자리 숫자의 스트링값 형태이어야 합니다.`;
        },
      },
    });
  };
}

function IsPositiveNumber(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: &#39;isPositiveNumber&#39;,
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          return typeof value === &#39;number&#39; &amp;&amp; value &gt; 0;
        },
        defaultMessage(validationArguments?: ValidationArguments) {
          return `${validationArguments.property}는 숫자타입임과 동시에 양수여야 합니다.`;
        },
      },
    });
  };
}</code></pre>
<p>물론 클라이언트측에서 올바른 형식의 쿼리 필드값을 넘겨주겠지만, 유효성 체킹을 하는 건 항상 동반이 되어야한다 생각한다. 그러므로 우린 직접 커스텀 데코레이터를 생성해 가져올 갯수(<code>take</code>)와 커서 값(<code>customCursor</code>)에 대한 유효성 검증을 진행한다.</p>
<ul>
<li><strong>take</strong>: 양수일 것</li>
<li><strong>customCursor</strong>: 16자리 숫자의 스트링값 형태여야 할 것</li>
</ul>
<p>만약 형식에 어긋날 경우 아래와 같은 에러를 응답 받을 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/7d15f5d3-983e-4311-a154-90e705eabe7e/image.png" alt=""></p>
</br>

<h3 id="응답부-request">&gt; 응답부 (<code>request</code>)</h3>
</br>

<p><strong>CursorPageResModel</strong> (최종 응답)</p>
<pre><code class="language-ts">// cursor-page-res.dto.ts
import { IsArray } from &quot;class-validator&quot;;
import { CursorPageMetaRes } from &quot;./cursor-page-meta.dto&quot;;
import { ApiProperty } from &quot;@nestjs/swagger&quot;;

export class CursorPageResModel&lt;T&gt; {
  @ApiProperty({
    type: &#39;array&#39;,
    items: {
      type: &#39;item&#39;
    }
  })
  @IsArray()
  readonly data: T[];

  @ApiProperty({
    type: CursorPageMetaRes
  })
  readonly meta: CursorPageMetaRes;

  constructor(data: T[], meta: CursorPageMetaRes) {
    this.data = data;
    this.meta = meta;
  }
}</code></pre>
</br>

<p><strong>CursorPageMetaRes</strong> (메타 데이터)</p>
<pre><code class="language-ts">// custom-cursor-page.meta.dto.ts
import { ApiProperty } from &quot;@nestjs/swagger&quot;;
import { CursorPageMetaDtoParameters } from &quot;./cursor-page-meta-param.interface&quot;;

export class CursorPageMetaRes {

  @ApiProperty({
    example: &#39;10&#39;,
    description: &#39;take&#39;,
    required: true,
  })
  readonly take: number;

  @ApiProperty({
    example: &#39;true&#39;,
    description: &#39;hasNextData&#39;,
    required: true,
  })
  readonly hasNextData: boolean;

  @ApiProperty({
    example: &#39;0000000000000000&#39;,
    description: &#39;customCursor&#39;,
    required: true,
  })
  readonly customCursor: string;

  constructor({cursorPageOptionsCommand, hasNextData, customCursor}: CursorPageMetaDtoParameters) {
    this.take = cursorPageOptionsCommand.take;
    this.hasNextData = hasNextData;
    this.customCursor = customCursor;
  }
}</code></pre>
<p>기획에 전체 아이템 갯수를 표시해줄 필요는 없었기 때문에, 클라이언트에게 제공해주는 메타 데이터는 <strong><code>take</code></strong>, <strong><code>hasNextData(다음 데이터가 존재하는가)</code></strong>, <strong><code>customCursor</code></strong>를 포함하도록 하였다.</p>
<p>데이터를 응답하는데 있어 객체(엔티티)의 ID 식별자, 그리고 정렬 속성값을 제공하기 때문에 커스텀 커서(<strong><code>customCursor</code></strong>)값은 클라이언트 측에서 만들어도 무방하다. 하지만 커서 값을 정의하는 책임 및 로직을 클라이언트로 가져갈 필요는 없다고 판단하였고, 스크롤 구현에만 집중케끔 하기로 하였다.</p>
</br>

<p><strong>CursorPageMetaDtoParameters</strong></p>
<pre><code class="language-ts">// cursor-page-meta-param.interface.ts
export interface CursorPageMetaDtoParameters {
  cursorPageOptionsCommand: CursorPageOptionsCommand;
  hasNextData: boolean;
  customCursor: string;
}</code></pre>
</br>

</br>

<h2 id="🧃-로직-설계">🧃 로직 설계</h2>
<p>이전의 경험을 토대로 커서 페이지네이션 로직을 설계하면서 1차적으로 중요하게 생각하였던 것은 조회 성능이었다. 부드럽게 리스트가 조회되어야 할 무한 스크롤 특성 상, API를 빈번하게 호출하는데 있어 최대한 속도를 줄여나가야 했다.</p>
<p>객체지향적 측면에서의 &quot;클린한 코드&quot;, &quot;좋은 코드&quot;를 만드는 것 또한 물론 중요하지만 사실 상 orm을 통한 <strong>&quot;쿼리문&quot;</strong> 작성이 핵심이었고 <u>성능에 지장을 줄 수 있는 쿼리 함수및 쿼리 식을</u> 최대한 <u><strong>지양</strong></u>하는 것을 목표로 하였다.</p>
</br>

<p>모든 정렬에 대한 로직을 설명하기 전, 먼저 <strong>&quot;거리 순&quot;</strong>에 따른 페이지네이션 처리 로직을 알아보자.</p>
<p><span style="color:gray">(다시 한 번 언급하지만 커서값을 이루는 요소 및 생성 이유에 대한 설명은 생략하니 꼭 먼저 포스팅 상단의 링크를 보고 오시기 바랍니다)</span></p>
<h3 id="단계-1-base">&gt; 단계 1 (<code>base</code>)</h3>
<p>먼저 로직을 바로 확인해보자. </p>
<p>거의 대부분의 로직은 <strong>&quot;Adaptor(어댑터)&quot;</strong> 영역에서 작성된다. 페이징 필터링을 하는데 있어 실질적으로 Raw Query와 직/간접적으로 연결된 로직이 많으므로, 굳이 서비스 레이어까지 들고가는 것은 불필요하지 않을까 생각했다.</p>
<p>페이지네이션에 필요한 option dto는 그대로 불러오는 것이 아닌, 서비스 영역부턴 command 객체로써 받아온다.</p>
<pre><code class="language-ts">// cursor-page-option.command.ts
export class CursorPageOptionsCommand {
  constructor(
    public take?: number,
    public customCursor?: string,
  ) {}
}</code></pre>
<pre><code class="language-ts">// store_driven.adpator.ts
export class StoreMySqlAdaptor implements StoreDrivenPort {
  constructor(
    @Inject(TypeOrmStoreRepositorySymbol)
    private readonly storeRepository: TypeOrmStoreRepository,
  ) {}

  async paginateStoresByDistance(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    const limitDistance = +process.env.AROUND_STORE_LIMIT_DISTANCE; // 거리 제한(미터)
    const takePerPage = cursorPageOptionsCommand.take;
    // queryBuilder 식을 통한 customCursor 정의
    // MySql의 공간함수를 사용하여 커스텀 커서 조건 식 생성 (아래에서 추가 설명)
    const queryByCustomCursor = 
      `(CONCAT(LPAD(FLOOR(ST_DISTANCE_SPHERE(store.latlng, ul.latlng)), 8, &#39;0&#39;), LPAD(store.storeId, 8, &#39;0&#39;)) &gt; :customCursor)`

    const storeQBuilder = this.storeRepository
      .createQueryBuilder(&#39;store&#39;)
      .select(
        &#39;ST_DISTANCE_SPHERE(ul.latlng, store.latlng)&#39;, &#39;dist&#39;
      )
      .addSelect([
        &#39;store.storeId AS storeId&#39;,
        &#39;store.storeName AS storeName&#39;,
        &#39;store.rating AS rating&#39;,
        &#39;store.createdAt AS createdAt&#39;,
        &#39;store.storeThumbImgUrl AS storeThumbImgUrl&#39;,
        &#39;store.curPickupTime AS curPickupTime&#39;,
        &#39;store.likeCount AS likeCount&#39;,
        &#39;store.reviewCount AS reviewCount&#39;,
        // leftJoin을 통해 불러온 store_tags 테이블의 tagName을 GROUP_CONCAT으로 불러온다.
        &#39;GROUP_CONCAT(storeTags.tagName) AS tags&#39;
      ])
      .innerJoin(&#39;user_locations&#39;, &#39;ul&#39;)
      .leftJoin(&#39;store_tags&#39;, &#39;storeTags&#39;, &#39;storeTags.storeId = store.storeId&#39;)

      // 클라이언트에서 요청을 보낸 take(불러올 갯수)값보다 하나를 더 불러오도록 한다. (아래에서 추가 설명)
      .limit(takePerPage + 1)
      .where(&#39;ul.userId = :userId&#39;, {userId})
      .andWhere(`ul.isActivated = 1`)

      // 앞서 생성한 커서값을 통한 비교식을 조건으로 달아준다.
      .andWhere(queryByCustomCursor, {
        customCursor: cursorPageOptionsCommand.customCursor,
      })

      // 집계함수를 사용하였기 때문에(거리 계산) 이를 select 하기 위해 groupBy에 등록해준다.
      .groupBy(`dist, store.storeId`)
      // 모든 정렬은 항상 오름차순 혹은 내림차순이다. &quot;거리순&quot;이므로 작은 거리 순부터 정렬될 것이다.
      .orderBy({
        &quot;dist&quot;: &quot;ASC&quot;,
        &quot;store.storeId&quot;: &quot;ASC&quot;,
      })
      // 모든 불러올 가게 데이터 리스트는 지정해 준(ex _ 4,000m) 유저와 매장 사이의 거리 이내의 값들만 유효하게끔 한다.
      .having(`dist &lt; ${limitDistance}`);

    const stores: StoreResponseModel[]; = await storeQBuilder.getRawMany();

    // 실제로 클라이언트에게 응답할 데이터는 `take+1`이 아니라 `take` 이어야 한다. 
    // 즉, 아래와 같은 작업으로 마지막 응답 데이터를 뺀 나머지 리스트를 얻는다.
    const responseStores = stores.slice(0, takePerPage);

    // 다음 데이터의 존재 유무
    let hasNextData: boolean = true;

    // 응답 데이터들 중 마지막 데이터 불러옴.
    const lastDataPerPage = responseStores[responseStores.length - 1];

    // 앞서 구한 `lastDataPerPage`를 사용하여 다음 커서 조회를 위한 customCursor 생성
    let customCursor = this.createCustomCursor(parseInt(lastDataPerPage[&#39;dist&#39;]), lastDataPerPage[&#39;storeId&#39;]);

    /* 주의 */
    // 만약 커서값에 따라 요청한 페이지 다음 페이지에 어떠한 데이터도 존재하지 않을 경우 
    // 아래와 같이 hasNextData=false, customCursor=null을 가지게끔 한다. (아래에서 추가 설명)
    if (stores.length &lt;= takePerPage) {
      customCursor = null;
      hasNextData = false;
    } 

    const cursorPageMeta = new CursorPageMetaRes({ cursorPageOptionsCommand, hasNextData, customCursor });

    return new CursorPageResModel&lt;StoreResponseModel&gt;(responseStores, cursorPageMeta);
  }

  // custom-cursor 값 생성 (ex. &quot;0000355500000010&quot; -&gt; storeId=10이며 거리(dist)=3555m)
  private createCustomCursor(property: number, id: number): string {
    return String(property).padStart(8, &#39;0&#39;) + String(id).padStart(8, &#39;0&#39;);
  }</code></pre>
</br>

<p>다음 단계로 넘어가기 전 몇 가지 중요한 사항을 체크해보자.</p>
</br>

<h3 id="중요한-포인트-체크-커서-페이지네이션을-이루는-핵심">&gt; 중요한 포인트 체크 (커서 페이지네이션을 이루는 핵심)</h3>
<ul>
<li><p><strong>queryByCustomCursor</strong></p>
<pre><code class="language-ts">const queryByCustomCursor = 
      `(CONCAT(LPAD(FLOOR(ST_DISTANCE_SPHERE(store.latlng, ul.latlng)), 8, &#39;0&#39;), LPAD(store.storeId, 8, &#39;0&#39;)) &gt; :customCursor)`

// ... 

 .andWhere(queryByCustomCursor, {
   customCursor: cursorPageOptionsCommand.customCursor,
 })</code></pre>
<p>물론 다른 조건식들도 중요하지만 <strong>&quot;Cursor-Pagitnation&quot;</strong>을 구현하는데 있어 가장 <strong>&quot;핵심&quot;</strong>이 되는 조건절이지 않을까 싶다.</p>
<p>만약 클라이언트가 서버로 부터 아래의 <code>customCursor</code>값을 받았다고 해보자.</p>
<pre><code class="language-ts">&quot;meta&quot;: {
  &quot;take&quot;: 5,
  &quot;hasNextData&quot;: true,
  &quot;customCursor&quot;: &quot;0000897000000189&quot;
}</code></pre>
<p>해당 커서 값이 의미하는 바는 응답 데이터 리스트 들 중 마지막 아이템의 <code>storeId</code>와 <code>dist</code>값을 조합해서 만든 결과이다. 참고로 <strong><code>storeId</code></strong>는 모두 <strong>고유</strong>하므로 두 번째 8자리의 숫자에 배치해두면 <strong><code>dist</code></strong>의 소수점을 버리더라도 <u>항상 고유한 커서 값을 유지</u>할 수 있게 된다. (아래 이미지 참조)</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/1ddaaa9a-ffcc-4d04-aa0c-4bf906179a40/image.png" alt=""></p>
</li>
</ul>
<p> 그리고 해당 커서 값을 통해서 다음 불러올 데이터들을 서버로 요청할 수 있게 된다. 그 데이터 리스트는 당연히 <strong>&quot;거리 순&quot;</strong> 정렬이므로 8970m보다 큰 거리 간격의 가게 데이터가 요청 될 것이다. </p>
<p> 이를 가능케 하는 작업이 바로 위의 조건절 식이라 할 수 있다. 클라이언트로부터 요청 받은 커스텀 커서값을 통해 항상 정렬 기준에 부합하는 데이터를 불러올 수 있다.</p>
<p> <img src="https://velog.velcdn.com/images/from_numpy/post/41838d95-3088-40a9-921b-1e91307c5b1c/image.png" alt=""></p>
 </br>

<ul>
<li><p><strong>takePerPage</strong></p>
<p>페이지 당 불러올 데이터 갯수에 해당하는 <strong><code>takePerPage</code></strong> 역시 상당히 중요하다.</p>
<p>예전 포스팅 당시엔 이 부분을 실수하였다. 지금에서야 바로 잡고 수정해보도록 한다.</p>
<pre><code class="language-ts">// 클라이언트에서 요청을 보낸 take(불러올 갯수)값보다 하나를 더 불러오도록 한다. (아래에서 추가 설명)
.limit(takePerPage + 1)

// ...

const stores: StoreResponseModel[]; = await storeQBuilder.getRawMany();

if (stores.length &lt;= takePerPage) {
   customCursor = null;
   hasNextData = false;
 } </code></pre>
</li>
</ul>
<p>   _&quot;왜 클라이언트로부터 요청받은 갯수(<code>takePerPage</code>)를 그대로 불러오는게 아니라, &quot;하나&quot;를 추가로 불러오는 것일까?&quot; _</p>
<p>  그 이유는 바로 응답으로 보여질 <strong>&quot;메타데이터&quot;</strong>의 정확성 때문이다.</p>
<p>  메타데이터로써 클라이언트에게 커스텀 커서 값, 그리고 다음 페이지 데이터의 존재 유무를 응답해준다. 오프셋 기반의 페이지네이션과 달리 무한 스크롤에서 클라이언트에게 전달될 정보는 그리 많지도 않고 그리 많이 필요하지도 않다. <span style="color:gray">(물론 요구사항에 따라 달라진다. 현재는 한 방향으로만 스크롤되는 무한스크롤의 예시이다)</span></p>
<p>  즉, 제공해주는 <strong><code>hasNextData</code></strong>이 모든 케이스에서 정확하다면 클라이언트는 별도의 작업없이 마지막 스크롤에대한 처리를 쉽게 할 수 있을 것이다.</p>
<p>  보통의 케이스에서 다음 페이지의 데이터 존재 유무를 알아내야 한다면 어떤식으로 진행될까?</p>
<p>  곰곰히 생각해보면 여러 글들에서, 그리고 이전의 나조차도 아래와 같은 방식을 사용하였다.</p>
<hr>
<p>  <span style="color:green"><strong>&quot;takePerPage(페이지 당 불러올 갯수) =&gt; total(전체 데이터 카운트)&quot;</strong> </span></p>
<hr>
<p>  위의 방식이 틀린 것은 절대 아니지만 나의 경우 &quot;전체 데이터 카운팅&quot;이 필요하지 않았으므로 굳이 사용해 성능에 지장을 주고 싶지 않았다.</p>
<p>  미미할 수도 있겠지만 전체 데이터를 카운트하는 작업은 어찌됐건 불필요한 비용이기 때문이다.</p>
<p>  *<em>즉, 다른 방법을 생각해야 했다. *</em></p>
<pre><code>그 방법은 **&quot;페이지당 불러올 갯수 + 1&quot;**을 통해 다음 페이지의 데이터중 첫 번째 데이터까지 미리 구하는 것이다. 그리고 해당 데이터 리스트의 길이와 페이지당 불러올 갯수 값을 비교한다. 

**`store.length`**는 **`limit(takePerPage+1)`** 이므로 마지막 커서를 제외한 모든 상황에선 무조건 **`store.length &gt; takePerPage`**가 성립된다. 즉, 두 값이 같게 되는 순간부터, 부등호가 역전이 되는 순간 부터는 **&quot;다음 데이터가 존재하지 않음&quot;**을 설명할 수 있게 된다.</code></pre><p>  이에 따라 아래와 같은 비교 식을 정의할 수 있다.</p>
<pre><code class="language-ts">        if (stores.length &lt;= takePerPage) {
          customCursor = null;
          hasNextData = false;
        } 
</code></pre>
</br>


<h3 id="단계-2-성능-개선과-부하-테스트-having절-은-옳은-선택일까">&gt; 단계 2) 성능 개선과 부하 테스트 (<code>Having절</code> 은 옳은 선택일까)</h3>
</br>

<p>앞선 쿼리 식을 다시 살펴보자.</p>
<pre><code class="language-ts">const storeQBuilder = this.storeRepository
  .createQueryBuilder(&#39;store&#39;)
  .select(
    &#39;ST_DISTANCE_SPHERE(ul.latlng, store.latlng)&#39;, &#39;dist&#39;
  )
  .addSelect([
    &#39;store.storeId AS storeId&#39;,
    &#39;store.storeName AS storeName&#39;,
    &#39;store.rating AS rating&#39;,
    &#39;store.createdAt AS createdAt&#39;,
    &#39;store.storeThumbImgUrl AS storeThumbImgUrl&#39;,
    &#39;store.curPickupTime AS curPickupTime&#39;,
    &#39;store.likeCount AS likeCount&#39;,
    &#39;store.reviewCount AS reviewCount&#39;,
    // leftJoin을 통해 불러온 store_tags 테이블의 tagName을 GROUP_CONCAT으로 불러온다.
    &#39;GROUP_CONCAT(storeTags.tagName) AS tags&#39;
  ])
  .innerJoin(&#39;user_locations&#39;, &#39;ul&#39;)
  .leftJoin(&#39;store_tags&#39;, &#39;storeTags&#39;, &#39;storeTags.storeId = store.storeId&#39;)
  // 클라이언트에서 요청을 보낸 take(불러올 갯수)값보다 하나를 더 불러오도록 한다. (아래에서 추가 설명)
  .limit(takePerPage + 1)
  .where(&#39;ul.userId = :userId&#39;, {userId})
  .andWhere(`ul.isActivated = 1`)
  // 앞서 생성한 커서값을 통한 비교식을 조건으로 달아준다.
  .andWhere(queryByCustomCursor, {
    customCursor: cursorPageOptionsCommand.customCursor,
  })
  // 집계함수를 사용하였기 때문에(거리 계산) 이를 select 하기 위해 groupBy에 등록해준다.
  .groupBy(`dist, store.storeId`)
  // 모든 정렬은 항상 오름차순 혹은 내림차순이다. &quot;거리순&quot;이므로 작은 거리 순부터 정렬될 것이다.
  .orderBy({
    &quot;dist&quot;: &quot;ASC&quot;,
    &quot;store.storeId&quot;: &quot;ASC&quot;,
  })
  // 모든 불러올 가게 데이터 리스트는 지정해 준(ex _ 4,000m) 유저와 매장 사이의 거리 이내의 값들만 유효하게끔 한다.
  .having(`dist &lt; ${limitDistance}`);</code></pre>
</br>

<p>마지막에 사용한 <strong><code>having()</code></strong> 즉, <strong>HAVING</strong> 절에 주목해보자. </p>
<p>처음 코드를 작성할 땐 아무생각이 HAVING 절 내부에서 거리 계산을 해주었지만, 이는 곧 테스트 시 생각보다..? 느린 성능으로 다가왔다.</p>
<p>HAVING 절은 알다시피 위의 코드와 같이, <strong>GROUPBY 이후</strong>에 실행이 된다. 즉, 모든 결과 행을 전체 정렬한 뒤 HAVING 절이 실행되기 때문에 이는 굉장히 비효율적이라 할 수 있다.</p>
<p>만약 하나의 API 호출당 내뱉어야 하는 쿼리 결과의 데이터 셋이 더 <strong>&quot;클&quot;</strong> 경우 이는 더 눈에띄는 성능 저하로 다가왔을 것이다.</p>
</br>

<p><strong>어떻게 수정해 줄 수 있을까?</strong> 아주 간단하다.</p>
<p><span style="color:red"><strong>HAVING절을 제거하고</strong></span>, 유저의 위치 테이블과 조인하는 부분에서 바로 조건을 걸어주면 된다.</p>
<pre><code class="language-ts">.innerJoin(&#39;user_locations&#39;, &#39;ul&#39;, `ST_DISTANCE_SPHERE(ul.latlng, store.latlng) &lt;= ${limitDistance}`)</code></pre>
</br>

<p> inner join 내부에서 이를 적용하면 필터링이 조인 조건에 포함되어 <u>쿼리 실행 계획을 더 효율적으로</u> 만들어 줄 수 있다. 필요한 데이터만 걸러서 GROUPBY로 이동하기 때문에 성능에 더 유리하게 된 것이다.</p>
<p> 실제로 두 케이스를 비교해보았을 때 HAVING절을 사용하지 않은 경우가 단순 한 번의 API 요청 테스트에서도  <strong>3배</strong> 정도의 <strong>성능 개선</strong>을 보였다.</p>
</br>

<p>간단히 <strong>apache benchmark</strong> 부하 테스트를 통해 동시 요청 건 수를 늘려보자.</p>
<p>사실 더 유의미한 테스트는 불러올 데이터 셋의 크기를 키우는 것이다. 하지만 불러올 데이터 셋의 크기를 키우는 것은 몇 가지 제약사항이 있기 때문에 (이는 당연히 성능 차이가 확실히 생길 것임에 의심할 여지가 없다) 동시 요청 건 수를 늘려 테스트를 해보았다.</p>
</br>

<p>아래는 <strong>1,000건</strong>의 동시 요청에 따른 비교이다. </p>
<pre><code>ab -n 1000 -c 1000 &quot;http://localhost:3030/stores/paginateStoreByDistance?customCursor=0000626700000200&quot;</code></pre></br>

<p><strong>✔ without having</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/bb98bbea-db70-40e4-8f89-b1052193223d/image.png" alt=""></p>
<p><strong>✔ with having</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/b43623dc-3f90-4cfd-a9fb-6bd829d31b3f/image.png" alt=""></p>
<p>Tps(Time per request) 뿐만 아니라 median(중앙값), max(최댓값) 등에서도 <strong>HAVING</strong>을 사용할 경우가 눈에 띄게 <strong>비효율적인 성능</strong>을 낸 다는 것을 확인할 수 있다. </p>
</br>  

<p>쿼리 성능이 우선적으로 생각되야 하므로 API 호출 시간이 예상외로 늦을 시 이러한 점을 항상 의심해 볼 필요가 있다.</p>
</br>

</br>

<h2 id="🧃-refactoring-최종-코드-입니다">🧃 <code>Refactoring</code> (최종 코드 입니다)</h2>
<p>앞서 우린 <strong>&quot;거리 순&quot;</strong>에 따른 커서 페이지네이션 정렬에 대해 알아보았다. 하지만 기획에 따라 &quot;찜 순&quot;, &quot;리뷰 많은 순&quot;, &quot;별점 순&quot;에 따른 정렬또한 진행해야 한다.</p>
<p>물론, 거리 순에서 수행하였던 것과 동일하게 해주면 된다. 어짜피 리턴하는 응답 값은 동일한 모델 객체를 바라보므로 <strong>정렬</strong>과 <strong>커서값 생성</strong>(커서 값을 생성하는 데 있어 각 정렬 기준 속성 값이 포함되어야 한다)만 달리 해주면 된다.</p>
<p>하지만 그렇다고 일일히 <strong><code>paginateStoresByDistance()</code></strong>를 생성한 것 처럼 동일한 크기의 로직으로 <strong><code>paginateStoresByLikeCount()</code></strong>, <strong><code>paginateStoresRating()</code></strong>, <strong><code>paginateStoresReviewCount()</code></strong>를 만들 것인가?</p>
<p>보기에 좋지 않을 뿐더러, 추후 정렬 기준의 변동 및 추가가 일어날 경우 또 하나의 뚱뚱한 함수를 생성해야 하는 일이 생긴다.</p>
<p>즉, 이런 점을 고려해 <u>db에 접근해 쿼리 데이터를 호출하는 함수</u>, <u>각 속성(정렬 기준)으로 전달 될 메인로직 및 공통 객체 응답 부를 담은 함수</u>, <u>각 정렬마다의 커스텀 커서와 정렬 차순을 정의한 함수</u>로 나누어 진행하기로 했다. </p>
<p>전체 어댑터 부의 로직은 아래와 같다. 거의 모든 로직이 정의된다 보면 된다.</p>
<pre><code class="language-ts">// store_driven.adpator.ts

export class StoreMySqlAdaptor implements StoreDrivenPort {
  constructor(
    @Inject(TypeOrmStoreRepositorySymbol)
    private readonly storeRepository: TypeOrmStoreRepository,
  ) {}

  private async getStores(
    userId: number,
    cursorPageOptionsCommand: CursorPageOptionsCommand,
    queryByCustomCursor: string,
    orderBy: {},
  ): Promise&lt;{ stores: any[] }&gt; {
    const limitDistance = +process.env.AROUND_STORE_LIMIT_DISTANCE; // 거리 제한(미터)

    const storeQBuilder = this.storeRepository
      .createQueryBuilder(&#39;store&#39;)
      .select(
        &#39;ST_DISTANCE_SPHERE(ul.latlng, store.latlng)&#39;, &#39;dist&#39;
      )
      .addSelect([
        &#39;store.storeId AS storeId&#39;,
        &#39;store.storeName AS storeName&#39;,
        &#39;store.rating AS rating&#39;,
        &#39;store.createdAt AS createdAt&#39;,
        &#39;store.storeThumbImgUrl AS storeThumbImgUrl&#39;,
        &#39;store.curPickupTime AS curPickupTime&#39;,
        &#39;store.status AS status&#39;,
        &#39;store.likeCount AS likeCount&#39;,
        &#39;store.reviewCount AS reviewCount&#39;,
        &#39;IFNULL(GROUP_CONCAT(storeTags.tagName), &quot;&quot;) AS tags&#39;
      ])
      .innerJoin(&#39;user_locations&#39;, &#39;ul&#39;, `ST_DISTANCE_SPHERE(ul.latlng, store.latlng) &lt;= 10000`)
      .leftJoin(&#39;store_tags&#39;, &#39;storeTags&#39;, &#39;storeTags.storeId = store.storeId&#39;)
      .limit(cursorPageOptionsCommand.take + 1)
      .where(&#39;ul.userId = :userId&#39;, { userId })
      .andWhere(`ul.isActivated = 1`)
      .andWhere(queryByCustomCursor, {
        customCursor: cursorPageOptionsCommand.customCursor,
      })
      .groupBy(`dist, store.storeId`)
      .orderBy(orderBy);

    const stores: StoreResponseModel[] = await storeQBuilder.getRawMany();

    return {
      stores,
    };
  } 

  private createCustomCursor(property: number, id: number): string {
    return String(property).padStart(8, &#39;0&#39;) + String(id).padStart(8, &#39;0&#39;);
  }

  private async paginateStoresByAttribute(
    userId: number,
    cursorPageOptionsCommand: CursorPageOptionsCommand,
    queryByCustomCursor: string,
    orderBy: {},
    customCursorAccessor: (store: any) =&gt; number,
  ): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {

    const takePerPage = cursorPageOptionsCommand.take;
    // cursorPageMeta를 미리 초기화합니다.
    let cursorPageMeta = new CursorPageMetaRes({ cursorPageOptionsCommand, hasNextData: false, customCursor: null });

    const { stores } = await this.getStores(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy
    );

    const responseStores = stores.slice(0, takePerPage);

    if (stores.length === 0) {
      return new CursorPageResModel&lt;StoreResponseModel&gt;([], cursorPageMeta);
    }

    const transformedStoresData: StoreResponseModel[] = responseStores.map(store =&gt; ({
      ...store,
      rating: parseFloat(store.rating),
    }));

    let hasNextData: boolean = true;

    const lastDataPerPage = transformedStoresData[transformedStoresData.length - 1];
    let customCursor = this.createCustomCursor(customCursorAccessor(lastDataPerPage), lastDataPerPage[&#39;storeId&#39;]);

    if (stores.length &lt;= takePerPage) {
      customCursor = null;
      hasNextData = false;
    } 

    cursorPageMeta = new CursorPageMetaRes({ cursorPageOptionsCommand, hasNextData, customCursor });
    return new CursorPageResModel&lt;StoreResponseModel&gt;(transformedStoresData, cursorPageMeta);
  }

  /* 거리 순 */
  public async paginateStoresByDistance(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    const queryByCustomCursor = 
      `(CONCAT(LPAD(FLOOR(ST_DISTANCE_SPHERE(store.latlng, ul.latlng)), 8, &#39;0&#39;), LPAD(store.storeId, 8, &#39;0&#39;)) &gt; :customCursor)`;

    const orderBy = {
      &quot;dist&quot;: &quot;ASC&quot;,
      &quot;store.storeId&quot;: &quot;ASC&quot;,
    };

    return this.paginateStoresByAttribute(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy,
      store =&gt; parseInt(store[&#39;dist&#39;]),
    );
  }

  /* 좋아요(찜) 순 */
  public async paginateStoresByLikeCount(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    const queryByCustomCursor = 
      `(CONCAT(LPAD(store.likeCount, 8, &#39;0&#39;), LPAD(store.storeId, 8, &#39;0&#39;)) &lt; :customCursor)`;

    const orderBy = {
      &quot;store.likeCount&quot;: &quot;DESC&quot;,
      &quot;store.storeId&quot;: &quot;DESC&quot;,
    };

    return this.paginateStoresByAttribute(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy,
      store =&gt; store[&#39;likeCount&#39;],
    );
  }

  /* 별점 순 */
  public async paginateStoresRating(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    const queryByCustomCursor = 
      `(CONCAT(LPAD(CONVERT(rating, CHAR) * 10, 8, &#39;0&#39;), LPAD(store.storeId, 8, &#39;0&#39;)) &lt; :customCursor)`;

    const orderBy = {
      &quot;store.rating&quot;: &quot;DESC&quot;,
      &quot;store.storeId&quot;: &quot;DESC&quot;,
    };

    return this.paginateStoresByAttribute(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy,
      store =&gt; parseFloat(store[&#39;rating&#39;]) * 10,
    );
  }

  /* 리뷰 카운트 순 */
  public async paginateStoresReviewCount(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    const queryByCustomCursor = 
      `(CONCAT(LPAD(store.reviewCount, 8, &#39;0&#39;), LPAD(store.storeId, 8, &#39;0&#39;)) &lt; :customCursor)`;

    const orderBy = {
      &quot;store.reviewCount&quot;: &quot;DESC&quot;,
      &quot;store.storeId&quot;: &quot;DESC&quot;,
    };

    return this.paginateStoresByAttribute(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy,
      store =&gt; store[&#39;reviewCount&#39;],
    );
  }
}</code></pre>
</br>

</br>

<h2 id="🧃-service--controller">🧃 <code>Service</code> &amp; <code>Controller</code></h2>
<h3 id="service--usecase">&gt; <code>Service</code> &amp; <code>UseCase</code></h3>
<pre><code class="language-ts">// store.usecase.ts
export const StoreUseCaseSymbol = Symbol(&#39;StoreUseCase_Token&#39;);

export interface StoreUseCase {
  paginateStoresByAttribute(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand, attributeName: string): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt;;
}


// store.service.ts
export class StoreService implements StoreUseCase {
  constructor(
    private readonly storeRepository: StoreDrivenPort,
  ) {}

  private async findStoresByDistance(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    return await this.storeRepository.paginateStoresByDistance(userId, cursorPageOptionsCommand);
  }

  private async findStoresByLikeCount(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    return await this.storeRepository.paginateStoresByLikeCount(userId, cursorPageOptionsCommand);
  }

  private async findStoresByRating(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    return await this.storeRepository.paginateStoresRating(userId, cursorPageOptionsCommand);
  }

  private async findStoresByReviewCount(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    return await this.storeRepository.paginateStoresReviewCount(userId, cursorPageOptionsCommand);
  }

  // 해당 함수만이 컨트롤러에서 호출되는 유스케이스 메서드의 구현체이다.
  public async paginateStoresByAttribute(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand, attributeName: string): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    switch (attributeName) {
      case &#39;distance&#39;:
        return this.findStoresByDistance(userId, cursorPageOptionsCommand);
      case &#39;likeCount&#39;:
        return this.findStoresByLikeCount(userId, cursorPageOptionsCommand);
      case &#39;rating&#39;:
        return this.findStoresByRating(userId, cursorPageOptionsCommand);
      case &#39;reviewCount&#39;:
        return this.findStoresByReviewCount(userId, cursorPageOptionsCommand);
      default:
        return null;
    }
  }
}</code></pre>
</br>

<p>정렬 기준에 따른 각 함수를 유스 케이스로 둘 수도 있지만 정렬 속성에 따른 <strong>&quot;동일한&quot;</strong> 페이지네이션을 수행한다고 판단하였고 이에 따라 프리젠테이션으로 전달해 줄 유스케이스는 <strong>&quot;하나&quot;</strong>의 <strong><code>paginateStoresByAttribute()</code></strong>로 두었다.</p>
</br>

<h3 id="querykey-validation">&gt; <code>QueryKey Validation</code></h3>
<p>일전 응답 객체에서 각 쿼리 키의 벨류에 대한 검증을 직접 데코레이터를 생성해 취해준 것을 알 것이다. (customCursor의 value는 16자리의 숫자로 된 스트링 값인지, take의 value는 number인지)</p>
<p>이번에는 커스텀 파이프를 생성해 쿼리 키 <strong>자체</strong>의 유효성 검증을 취해준다. 만약 허용된 키 이름 이외의 값이 요청으로 들어오면 정해준 에러를 내뱉게끔 한다.</p>
<pre><code class="language-ts">// custom-cursor-queryKey.pipe.ts
import { ArgumentMetadata, Injectable, PipeTransform } from &quot;@nestjs/common&quot;;
import { PaginationQueryKeyInvalidException } from &quot;../exception-handle/exception-classes/bad_request_exception/custom_cursor.exception&quot;;

@Injectable()
export class CursorPagingReqQueryKeyPipe implements PipeTransform {
  private readonly allowedKeys = [&#39;customCursor&#39;, &#39;take&#39;]; // 허용된 키 이름 배열

  transform(value: any, metadata: ArgumentMetadata) {
    if (metadata.type === &#39;query&#39;) {
      const actualKeys = Object.keys(value);

      // 요청의 키 이름이 허용된 키 이름 배열에 포함되는지 검사
      const invalidKeys = actualKeys.filter(key =&gt; !this.allowedKeys.includes(key));
      if (invalidKeys.length &gt; 0) {
        throw new PaginationQueryKeyInvalidException();
      }

      return value;
    }

    return value;
  }
}</code></pre>
</br>

<h3 id="controller">&gt; <code>Controller</code></h3>
<pre><code class="language-ts">// store.controller.ts

@SkipThrottle()
@ApiTags(&#39;Stores&#39;)
@ApiBearerAuth(&#39;access-token&#39;)
@UseGuards(JwtAccessAuthGuard)
@UsePipes(new CursorPagingReqQueryKeyPipe())
@Controller(&#39;stores/paginate&#39;)
export class StoreController {
  constructor(
    @Inject(StoreUseCaseSymbol)
    private readonly storeUseCase: StoreUseCase,
  ) {}

  private async getStoresByAttribute(
    attributeName: string,
    cursorPageOptionsDto: CursorPageOptionsDto,
    request: ExtendedRequest,
  ): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    const userId: number = request.userId;
    const cursorPageOptionsCommand: CursorPageOptionsCommand = PaginateOptionsDtoToCommandMapper.mapToCommand(cursorPageOptionsDto);

    if (!cursorPageOptionsCommand.customCursor) {
      if (request.path === &#39;/stores/paginateStoreByDistance&#39;) {
        cursorPageOptionsCommand.customCursor = DefaultCustomCursorValueBuilder.toDefaultValue(8, 8, &quot;0&quot;);
      } else {
        cursorPageOptionsCommand.customCursor = DefaultCustomCursorValueBuilder.toDefaultValue(8, 8, &quot;9&quot;);
      }
    }

    return this.storeUseCase.paginateStoresByAttribute(userId, cursorPageOptionsCommand, attributeName);
  }

  @Get(&#39;storeByDistance&#39;)
  @ApiOperation({
    summary: &#39;거리 순 가게 페이지네이션&#39;,
    description: &#39;유저 위치 4000m 내외 가게 중 거리 순에 따른 가게 목록 조회&#39;
  })
  @ApiCursorPaginatedResponse(StoreResponseModel)
  async paginateStoreByDistance(
    @Query() cursorPageOptionsDto: CursorPageOptionsDto,
    @Req() request: ExtendedRequest
  ): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    return this.getStoresByAttribute(&#39;distance&#39;, cursorPageOptionsDto, request);
  }

  @Get(&#39;storeByLikeCount&#39;)
  @ApiOperation({
    summary: &#39;찜 순 가게 페이지네이션&#39;,
    description: &#39;유저 위치 4000m 내외 가게 중 찜 순에 따른 가게 목록 조회&#39;
  })
  @ApiCursorPaginatedResponse(StoreResponseModel)
  async paginateStoreByLikeCount(
    @Query() cursorPageOptionsDto: CursorPageOptionsDto,
    @Req() request: ExtendedRequest
  ): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    return this.getStoresByAttribute(&#39;likeCount&#39;, cursorPageOptionsDto, request);
  }

  @Get(&#39;storeByRating&#39;)
  @ApiOperation({
    summary: &#39;별점 순 가게 페이지네이션&#39;,
    description: &#39;유저 위치 4000m 내외 가게 중 별점 순에 따른 가게 목록 조회&#39;
  })
  @ApiCursorPaginatedResponse(StoreResponseModel)
  async paginateStoreByRating(
    @Query() cursorPageOptionsDto: CursorPageOptionsDto,
    @Req() request: ExtendedRequest
  ): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    return this.getStoresByAttribute(&#39;rating&#39;, cursorPageOptionsDto, request);
  }

  @Get(&#39;storeByReviewCount&#39;)
  @ApiOperation({
    summary: &#39;리뷰 순 가게 페이지네이션&#39;,
    description: &#39;유저 위치 4000m 내외 가게 중 리뷰 순에 따른 가게 목록 조회&#39;
  })
  @ApiCursorPaginatedResponse(StoreResponseModel)
  async paginateStoreByReviewCount(
    @Query() cursorPageOptionsDto: CursorPageOptionsDto,
    @Req() request: ExtendedRequest
  ): Promise&lt;CursorPageResModel&lt;StoreResponseModel&gt;&gt; {
    return this.getStoresByAttribute(&#39;reviewCount&#39;, cursorPageOptionsDto, request);
  }
}</code></pre>
</br>

<p>아래 부분을 주목해 보자.</p>
<pre><code class="language-ts">    if (!cursorPageOptionsCommand.customCursor) {
      if (request.path === &#39;/stores/paginate/storeByDistance&#39;) {
        cursorPageOptionsCommand.customCursor = DefaultCustomCursorValueBuilder.toDefaultValue(8, 8, &quot;0&quot;);
      } else {
        cursorPageOptionsCommand.customCursor = DefaultCustomCursorValueBuilder.toDefaultValue(8, 8, &quot;9&quot;);
      }
    }</code></pre>
<p><strong><code>request.path</code></strong>를 통해 접근한 요청 api가 만약 <strong>&quot;거리 순&quot;</strong> api일 경우 / 혹은 그 <strong>나머지</strong>(별점 순, 좋아요 순, 리뷰 카운트 순) api일 경우, 고정 디폴트로 가지게 되는 커스텀 커서 값이 다르게 생성되도록 설정하였다.</p>
<p>참고로 고정(default) 커스텀 커서 값의 생성은 아래의 빌더 클래스에서 생성 된다.</p>
<pre><code class="language-ts">export class DefaultCustomCursorValueBuilder {
  static toDefaultValue(digitByTargetColumn: number, digitById: number, initialValue: string) {
    const defaultCustomCursor: string =  String().padStart(digitByTargetColumn, `${initialValue}`) + String().padStart(digitById, `${initialValue}`);
    return defaultCustomCursor;
  }
}</code></pre>
</br>

<p><strong>&quot;거리 순&quot;</strong>의 경우는 <strong>&quot;가까운 순서대로&quot;</strong>이다. 반대로 <strong>&quot;나머지&quot;</strong>는 <strong>&quot;많은 혹은 큰 순서대로&quot;</strong>이다.</p>
<p>클라이언트 입장에선 가장 처음의 요청 상황에 <span style="color:gray">(물론 협의를 통해 클라이언트도 지정해줘도 무방하다)</span> 커스텀 커서 값을 알 수 없기 때문에 서버에선 고정 디폴트 커서 값을 자동 적용케끔 하였다.</p>
<p>이에 따라 &quot;거리 순&quot;의 경우 16자리의 숫자 값으로 된 스트링 중 <strong>가장 작은 값</strong>인 <strong>&quot;0000000000000000&quot;</strong>을, &quot;나머지&quot;의 경우 <strong>가장 큰 값</strong>인 <strong>&quot;9999999999999999&quot;</strong>를 적용해주도록 하였다.</p>
</br>

<h2 id="🥤-동작-확인-with-postman">🥤 동작 확인 (<code>with Postman</code>)</h2>
<blockquote>
<p>거리 순 (dist)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/56709334-4dca-4cc3-9278-77c0a01a294c/image.gif" alt=""></p>
</br>

<blockquote>
<p>좋아요 순 (likecount)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/d5384b4a-c2ba-42b4-9648-868816d637b3/image.gif" alt=""></p>
</br>

<blockquote>
<p>별점 순 (rating)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/b862b5ba-ff36-45e0-8496-a84a51e2dee8/image.gif" alt=""></p>
</br>

<blockquote>
<p>리뷰 카운트 순 (reviewCount)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/4b072a84-50a9-4178-b170-d4a95c856d3b/image.gif" alt=""></p>
</br>

<h2 id="🧃-생각정리">🧃 생각정리</h2>
<p>어쩌다 보니 긴 글이 된 점에 읽으실 분들께 심심치않은 사과를 먼저 드려본다. </p>
<p>(두 포스팅으로 나눌 수도 있었지만 글의 통일성이 깨질거 같아 하나로 통합하였습니다. 긴 글 읽어주심에 감사드립니다)</p>
<p>이번 글은 NestJS를 사용했지만 NestJS가 중심이 아닌 오로지 <strong>&quot;무한 스크롤을 구현하기 위해 서버에선 어떤 제스처를 취해야 하는가, 어떤 설계가 필요한가&quot;</strong>에 초점을 맞추었던 것 같다. </p>
<p>아쉬운 점도 참 많았다. 클라이언트 측에서 테스트 해 본 결과 성능 측면에선 만족을 하였지만, 갠 적으로 코드레벨에서의 아쉬움이 없지 않아 있다. 대부분의 로직이 어댑터 클래스에서 수행이 되었고 일부 데이터 액세스와 비교적 거리가 있는 로직을 서비스 레이어로 옮기는 것이 생각보다 쉽지 않았다. 오히려 레이어간 책임을 명확하게 하려고 시도 하다 보니 불필요한 과정이 수반되게 되었고 더욱 가독성을 저해할 뿐이었다.</p>
<p>이로 인해 서비스 레이어에선 싱크홀 안티 패턴이 발생하였지만, 사실 상 쿼리 로직이 전부인 해당 기능에선 꼭 이상하지만은 않을지도 모른다는 생각이 든다.</p>
<p>로 쿼리 레벨을 사용하는 만큼, 또한 커서값을 형성하는데 있어 모두 제 각기의 쿼리식을 수행하는 만큼 정말로 &quot;공통 페이지네이션 모듈&quot;을 생성하는 건 아직 나에겐 큰 어려움이었다. 어쩌면 굳이 공통 로직 클래스를 생성하는 행위가 오버헤드일지도 모르겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project] #2 에러를 소통해보자]]></title>
            <link>https://velog.io/@from_numpy/Project-2-%EC%97%90%EB%9F%AC%EB%A5%BC-%EC%86%8C%ED%86%B5%ED%95%B4%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@from_numpy/Project-2-%EC%97%90%EB%9F%AC%EB%A5%BC-%EC%86%8C%ED%86%B5%ED%95%B4%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Sat, 20 Jan 2024 07:01:17 GMT</pubDate>
            <description><![CDATA[<h2 id="🧃-규격화된-에러-설계는-왜-필요한가">🧃 규격화된 에러 설계는 왜 필요한가</h2>
<p>프로젝트 개발을 진행하면서 가장 먼저 구축에 들어갔던 작업이 <strong>&quot;에러 공통 응답 객체&quot;</strong> 설계였다. 여태껏 클라이언트와의 소통 없이 오로지 백엔드의 입장에서만 코드를 작성해왔던 나에게 에러를 던진다는 것은 그다지 큰 의미가 없었다. </p>
<p>그냥 누구나 생각할 수 있는 특정 에러 상황을 가정하고 (예를 들어 데이터 불일치와 같은...) 아래와 같이 코드 한 줄을 적으면 끝이였기 때문이었다.</p>
<pre><code class="language-ts">throw new BadRequestException(&quot;금액 데이터 불일치입니다&quot;);</code></pre>
<p>그리고 아래와 같은 에러 응답을 확인할 수 있었다.</p>
<pre><code class="language-ts">{
    &quot;statusCode&quot;: 400,
    &quot;message&quot;: &quot;금액 데이터 불일치입니다&quot;,
    &quot;error&quot;: &quot;BadRequest&quot;
}</code></pre>
<p>이것이 잘 못 되었단 뜻일까? </p>
<p>그렇지 않다. 위와 같이 NestJS에서 제공하는 에러객체를 코드레벨에서 바로 사용해 일련해 메시지만 작성해주고 NestJS에서 제공하는 응답을 바로 던질 수 있다는 것은 매우 빠른 개발 시간을 보장해준다 생각할 수도 있다. 동시에 커스텀한 무언가를 구축하지 않았으므로 코어적 코드 레벨과 충돌하는 경우도 적을 것이다.</p>
<p>하지만 왜 개발단계에서의 규격화된 에러 설계가 필요했고, 더 좋은 공통 에러 응답을 위해 시간을 소요하였을까? 이런 궁금증으로 글을 시작해보고자 한다.</p>
</br>

<h3 id="에러는-던지면-끝이-아니다-클라이언트와의-소통이다">&gt; 에러는 던지면 끝이 아니다, 클라이언트와의 소통이다.</h3>
<p>서버에서 정한 에러는 단순 응답을 넘어서 클라이언트가 해당 응답을 받고 핸들링 과정을 통해 일련의 처리를 수행하게 된다. 발생하게 되는 모든 에러가 유저에게 에러 메시지와 같은 일련의 UI를 제공하는 것은 아니지만 어찌됐건 클라이언트는 서버로부터 넘어온 에러에 대해 분기처리를 하고 이를 필요한 모델로 넘겨주게 된다.</p>
<p>개발 초기 당시, 클라이언트측과 와이어프레임에 따라 발생할 수 있는 각 도메인 별 다양한 예외 처리 상황에 대해 논의를 해보았고 자체 서버에서 발생할 수 있는 에러 뿐만 아니라 사용하는 <strong>서드파티에 따른 에러</strong> 역시 고려를 하지 않을 수 없었다. </p>
<p>많은 에러가 발생할 것으로 예상함과 동시에 <u>각 API에서 중복된 에러 또한 존재할 것임</u>을 판단하였고, 클라이언트 측에선 이를 <u>전역 핸들러</u>로써 관리하기로 하였다.</p>
<p>이에 따라, 서버측에서도 클라이언트의 간편한 분기 처리를 위해 각 에러마다의 차별화된 &quot;무언가&quot;를 전달해 줄 필요가 있다 판단하였고 이를 단순 에러 메시지가 아닌 사내의 <strong>규격화된 에러 코드</strong>로써 명시하기로 하였다. (에러 코드 응답은 아래의 설명에서 계속됩니다.)</p>
<p>이렇게 클라이언트와의 소통을 고려한 조금은 더 구체화 된 공통 에러 응답 객체를 만들어 낼 필요성을 느꼈다.</p>
</br>

<h3 id="에러-하나하나를-클래스화-할-필요가-있다">&gt; 에러 하나하나를 클래스화 할 필요가 있다.</h3>
<p>가장 처음에 보았던 에러 처리를 다시 살펴보자.</p>
<pre><code class="language-ts">throw new BadRequestException(&quot;금액 데이터 불일치입니다&quot;);</code></pre>
<p>클라이언트와의 소통을 떠나서, 위와 같은 에러처리의 문제점은 무엇일까? </p>
<p>첫 번째는 각 에러 마다의 <strong>&quot;중요도&quot;</strong>와 <strong>&quot;고유성&quot;</strong>의 감쇠라고 생각한다. 물론 개발자 입장에서 위의 코드를 보면 메시지의 내용에 따라 누가봐도 &quot;금액 데이터 불일치&quot;에 대한 에러임을 예상할 수 있을 것이다. 하지만 이는 결국 NestJS의 <strong><code>Built-IN HttpException</code></strong> 클래스에 메시지만을 작성해준 격이므로, 앞으로 더 늘어나게 될 각각의 에러에 대한 명세를 하긴 아쉽다 생각하였다.</p>
<p>두 번째는 <strong>&quot;활용도&quot;</strong>의 문제이다. 아래에서 코드를 통해 예시를 보이겠지만, 추후 <strong>스웨거</strong>등의 문서화를 구현할 때 만약 각 각의 에러가 별도의 객체화가 되어있지 않다면 활용하는데 제약을 받게 되고 <u>구체화 된 문서 작성에 어려움을 겪을 것</u>이다.</p>
</br>

<h2 id="🧃-베이스-설계-과정-nestjs">🧃 베이스 설계 과정 (NestJS)</h2>
<p>자, 위에서 얘기를 나눠보았던 관점/생각 및 내용들을 토대로 본격적인 예외처리 과정을 진행해보자. 예외처리를 하는데 있어 플로우를 설계하는 것이 매우 중요하지만 사용하게 될 프레임워크(NestJS)에 대한 규약과 라이프사이클을 이해하는 것 또한 중요하다. </p>
<p><a href="https://www.borntodev.com/2023/09/29/nest-js-request-lifecycle-%E0%B8%97%E0%B8%B3%E0%B8%84%E0%B8%A7%E0%B8%B2%E0%B8%A1%E0%B9%80%E0%B8%82%E0%B9%89%E0%B8%B2%E0%B9%83%E0%B8%88-request-lifecycle/">이미지 출처 (NestJS Request Lifecycle)</a></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/8d323ecd-125d-4ce6-bba2-fc2586ad925b/image.png" alt=""></p>
<p><strong>&quot;커스텀(Custom)&quot;</strong>한 무언가를 만들어 낸다는 것은 결국 사용하고 있는 라이브러리 혹은 프레임워크를 이용함과 동시에 <span style="color:red"><strong>&quot;충돌&quot;</strong></span> 또한 불러일으킬 수 있다는 것을 의미한다 본다. 이런 점들을 염두해 두고 설계할 필요가 있었다.</p>
</br>

<h3 id="http-exception-베이스-구축">&gt; <code>Http Exception</code> 베이스 구축</h3>
<p>먼저, <strong>공통 응답 에러 객체</strong> 생성을 위한 베이스를 구축할 필요가 있다. 지난 포스팅이었던 아키텍처 편에서도 잠깐 소개가 되었지만 공통적으로 사용할 클래스를 정의하는데 있어서 항상 &quot;추상 클래스(인터페이스)&quot;를 선수적으로 생성하기로 한다.</p>
<p><strong>BaseException Interface</strong></p>
<pre><code class="language-ts">// base-exception.interface.ts

export interface IBaseException {
  errorCode: string;
  timestamp: string;
  statusCode: number;
  msg: string;
  path: string;
}</code></pre>
<p>커스텀하게 만들게 된 자체적 <strong>&quot;에러코드(errorCode)&quot;</strong>, Http 상태코드(statusCode), 그리고 에러 메시지 및 추가적 정보(timestamp, path)를 공통 에러 응답 객체 필드로 정의하였다.</p>
</br>

<p>다음은 해당 인터페이스의 구현체이며, 에러 응답을 직접적으로 정의하는 곳이다. 해당 클래스는 앞서 정의한 인터페이스를 구현함과 동시에 nest에서 제공하는 <code>HttpException</code> 클래스를 확장받는다.</p>
<pre><code class="language-ts">// http-base.exception.ts

import { HttpException } from &quot;@nestjs/common&quot;;
import { IBaseException } from &quot;./interfaces/base-exception.interface&quot;;
import { ApiProperty } from &#39;@nestjs/swagger&#39;;
import { Expose } from &quot;class-transformer&quot;;

export class HttpBaseException extends HttpException implements IBaseException {
  constructor(
    errorCode: string, 
    statusCode: number,
    msg: string,
  ) {
    super(errorCode, statusCode);
    this.errorCode = errorCode;
    this.statusCode = statusCode;
    this.msg = msg;
  }

  @ApiProperty({
    example: &quot;1000&quot;,
    description: &quot;자체 애플리케이션 에러코드&quot;
  })
  @Expose()
  errorCode: string;

  @ApiProperty({
    example: &quot;20YY. MM. DD. 오후 HH:MM:SS&quot;,
    description: &quot;API 응답 시간&quot;
  })
  @Expose()
  timestamp: string;

  @ApiProperty({
    example: 400,
    description: &quot;Http Base 상태 코드 400~500대 응답&quot;
  })
  @Expose()
  statusCode: number;

  @ApiProperty({
    example: &quot;example msg&quot;,
    description: &quot;에러 메시지&quot;
  })
  @Expose()
  msg: string;

  @ApiProperty({
    example: &quot;/sth/sth ...&quot;,
    description: &quot;api 경로&quot;
  })
  @Expose()
  path: string;
}
</code></pre>
</br>

<h3 id="validation-에러-충돌과-해결">&gt; <code>Validation</code> 에러 충돌과 해결</h3>
<p>위와 같이 <strong><code>HttpBaseException</code></strong>을 구현하였을 때 고려하지 못한 케이스가 존재하였다. </p>
<p>바로 <strong>&quot;유효성 검증&quot;</strong>에 대한 예외처리이다.</p>
<p>서버에 요청으로 넘어오는 DTO 객체의 각 필드에 대한 유효성 검증은 *<em><code>class-validator</code> *</em>라이브러리를 사용하도록 하였다.</p>
<p>간단한 예시를 들어보자. 클라이언트로부터 상품 생성시 필요한 DTO 객체를 받는다고 하자. 아래와 같이  <strong><code>class-validator</code></strong>를 통한 검증 데코레이터를 사용하게 된다.</p>
<pre><code class="language-ts">import { IsNotEmpty, IsNumber, IsString } from &quot;class-validator&quot;;

export class ProductCreateDto {

  @IsNotEmpty()
  @IsString()
  title?: string;

  @IsNotEmpty()
  @IsString()
  description?: string;

  @IsNotEmpty()
  @IsString()
  image?: string;

  @IsNotEmpty()
  @IsNumber()
  price?: number;
}</code></pre>
<p>그런데 만약 클라이언트에서 description에 string이 아닌 number값을, price에 number가 아닌 string값을 보내주었다면 어떤 형태의 에러가 응답될까? </p>
<pre><code class="language-ts">{
    &quot;statusCode&quot;: 400,
    &quot;message&quot;: [
        &quot;description must be a string&quot;,
        &quot;price must be a number conforming to the specified constraints&quot;
    ],
    &quot;error&quot;: &quot;Bad Request&quot;
}</code></pre>
<p>위와 같이, message 필드에 문자열 값이 담긴 <strong><code>Array</code></strong> 타입의 오류 메시지가 출력된다. 이는 <code>class-validator</code>의 <strong><code>ValidationError</code></strong> 구현 코드에 따른 것으로 <u>이미 정해져 있는 규약</u>이다.</p>
<p><a href="https://github.com/nestjs/nest/blob/master/packages/common/interfaces/external/validation-error.interface.ts#L9">nestjs/class-validator source code</a></p>
<pre><code class="language-ts">// nest/packages/common/interfaces/external/validation-error.interface.ts

export interface ValidationError {

  target?: Record&lt;string, any&gt;;

  // DTO에 정의된 프로퍼티
  property: string;

  value?: any;
  /**
   * Constraints that failed validation with error messages.
   */
  constraints?: {
    [type: string]: string;  // 해당 부분에 따른 message 필드 규약
  };

  children?: ValidationError[];

  contexts?: {
    [type: string]: any;
  };
}</code></pre>
<p>하지만, 앞서 공통 에러 응답을 위해 정의한 <strong><code>HttpBaseException</code></strong> 클래스는 <strong><code>HttpException</code></strong>을 상속받으며, 이는 또한 <strong><code>Error</code></strong>란 인터페이스를 상속받게 된다.</p>
<pre><code class="language-ts">interface Error {
    name: string;
    message: string;
    stack?: string;
}</code></pre>
<p>그리고 보다시피 <code>message</code> 필드는 <strong><code>string</code></strong> 타입을 받을 것을 default로 두게 된다.</p>
<p>여기서 <span style="color:red"><strong>&quot;충돌&quot;</strong></span>이 일어난 것이다. 기본 Base HttpException의 message 필드는 string을 바라보는 반면, 유효성 검증을 위해 사용하게 될 class-validator의 ValidationError에서 제시하는 message 필드는 Array를 바라보고 있다.</p>
<p>만약 어떠한 조취를 취해주지 않을 경우 여러 DTO 필드에서 <u>복수적으로 유효성 검증 에러가 났을 시</u>, 그에 대한 처리를 해줄 수 없게 된다.</p>
<p>이러한 이유로 <code>HttpBaseException</code>에는 아래와 같이 해당 케이스에 대한 추가적 처리를 해주도록 하였다. </p>
<p>최종 응답 객체의 <strong><code>msg(메시지)</code></strong>는 무조건 string 타입이 와야하고, 이에 따라 Array로 넘어온 validation 에러 메시지를 string으로 <strong>포맷팅</strong>하는 작업을 취해준다. 이때, 앞서 살펴본 <strong><code>ValidationError</code></strong> 클래스의 <strong><code>property</code></strong>와 <strong><code>constraints</code></strong>를 활용함으로써 복수 필드에 대한 에러를 응답할 수 있도록 한다.</p>
</br>

<p><strong>수정된 HttpBaseException</strong></p>
<pre><code class="language-ts">export class HttpBaseException extends HttpException implements IBaseException {
  constructor(
    errorCode: string, 
    statusCode: number,
    // msg 타입 구체화 
    msg: string | {property: string; constraints: { [key: string]: string }}[],
  ) {
    super(errorCode, statusCode);
    this.errorCode = errorCode;
    this.statusCode = statusCode;

    // 받게 될 message 타입에 대한 조건 처리
    if (typeof msg === &quot;string&quot;) {
      this.msg = msg; // If msg is a string, assign it directly
    } else if (Array.isArray(msg)) {
      this.msg = this.formatErrorMessage(msg); // Format the array into a string
    } else {
      this.msg = &quot;Invalid message format&quot;; // Handle other types of msg if needed
    }
  }

  // formats custom msg Array to string using &quot;property&quot; and &quot;constraints&quot; which contains in `ValidationError`.
  private formatErrorMessage(errors: { property: string; constraints: { [key: string]: string } }[]): string {
    return errors
      .map(error =&gt; `validation-error) ${error.property} =&gt; ${Object.values(error.constraints).join(&quot;, &quot;)}`)
      .join(&quot;; &quot;);
  }

  // swagger property 생략

  errorCode: string;

  timestamp: string;

  statusCode: number;

  msg: string;

  path: string;
}
</code></pre>
<p>현재 단계로 아직 에러 응답이 완성 된 것은 아니지만, 아래와 같은 형태를 보일 것이다.</p>
<pre><code class="language-ts">{
    &quot;errorCode&quot;: &quot;1111&quot;,
    &quot;statusCode&quot;: 400,
    &quot;msg&quot;: &quot;validation-error) pointAmount =&gt; pointAmount must be a number conforming to the specified constraints; validation-error) couponAmount =&gt; couponAmount must be a number conforming to the specified constraints&quot;,
    &quot;timestamp&quot;: &quot;2024. 1. 18. 오후 10:08:14&quot;,
    &quot;path&quot;: &quot;/order/payment/create/temporary-orders&quot;
}</code></pre>
</br>

<h3 id="filter">&gt; <code>Filter</code></h3>
<p>베이스 객체가 위와 같이 생성되었다면 마지막으로 취해주어야 할 부분은 <strong>&quot;Exception Filter&quot;</strong>이다. 앞서 그림(NestJS Request LifeCycle 참조)에서도 볼 수 있듯이 생명 주기 전반에 걸쳐 에러를 핸들링하게 되는 필터를 생성할 필요가 있다. 그리고 지금 생성할 필터는 Http Exception에 대한 애플리케이션 <strong>전역</strong> 필터가 될 것이다.  </p>
<pre><code class="language-ts">// http-exception.filter.ts

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest&lt;Request&gt;();
    const response = ctx.getResponse&lt;Response&gt;();
    const curr_timestamp: string = new Date().toLocaleString(&quot;ko-KR&quot;, { timeZone: &quot;Asia/Seoul&quot;});

    logger.debug(exception);

    const res = exception instanceof HttpBaseException 
      ? exception        
      : new UnCatchedException(); 

    res.timestamp = curr_timestamp;
    res.path = request.url;

    return response.status(res.statusCode).json({
      errorCode: res.errorCode,
      statusCode: res.statusCode,
      msg: res.msg,
      timestamp: res.timestamp,
      path: res.path,
    })
  }
}</code></pre>
<p>해당 에러 필터는 전역적으로 사용하기 위해 부트스트래핑 단계의 <code>main.ts</code>에 설정하게끔 한다.</p>
<pre><code class="language-ts">app.useGlobalFilters(new GlobalExceptionFilter());</code></pre>
</br>

<h3 id="커스텀-validationpipe-설계">&gt; 커스텀 <code>ValidationPipe</code> 설계</h3>
<p>앞선 과정까지만 진행되면 참 좋았겠지만 아직 또 함정이 남았다. 바로 이전에 마주한 <code>class-validator</code> 이슈이다. </p>
<p><code>HttpBaseException</code>에서 진행하였던 msg 포맷팅 과정은 <em>&quot;이러한 배열 형식의 Validation msg가 들어왔을 경우 이를 Array to string 하여라&quot;</em> 이다. <code>HttpBaseException</code> 클래스가 리턴하는 <code>msg</code>는 string이지만 생성자 매개변수에는 앞서 정의한 규약의 Array를 허용한다는 것이다. 즉, ValidationError는 이러한 과정을 거쳐서 최종 응답이 된다.</p>
<p>결국 우리는 <code>@nestjs/common</code>에서 제공하는 <strong><code>ValidationPipe</code></strong> 그대로를 사용할 수 <strong>없다</strong>는 것이다. 배열로 메시지를 <code>HttpBaseException</code>에 전달하는 건 둘째치고, 애초에 해당 파이프가 리턴하는 값 자체가 <code>HttpBaseException</code>이 아니기 때문에 유효성 검증에 대한 에러 응답 시 우리가 정한 에러를 뱉을 수 <strong>없게</strong> 된다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2433df4c-72a2-4cb9-8209-9d2425c3a468/image.png" alt=""></p>
<p><a href="https://github.com/nestjs/nest/blob/master/packages/common/pipes/validation.pipe.ts">⬆⬆⬆ nestjs/validation.pipe.ts source code</a></p>
<p>이 사실을 마주하고 굉장히 머리가 아팠지만 직접 커스텀한 ValidationPipe를 만들기로 하였다. </p>
<p>원하든, 원치 않았던 소스 코드를 까보는 상황을 마주하였고 간단한 차원에서 이를 아래와 같이 구현해 낼 수 있었다.</p>
<pre><code class="language-ts">// custom-validation.pipe.ts

import {
  ArgumentMetadata,
  HttpStatus,
  Injectable,
  PipeTransform,
} from &#39;@nestjs/common&#39;;
import { plainToClass } from &#39;class-transformer&#39;;
import { validate, ValidationError } from &#39;class-validator&#39;;
import { HttpBaseException } from &#39;../exception-handle/base/http-base.exception&#39;;
import { ValidateExceptionErrCodeEnum } from &#39;../exception-handle/enums/validate-exception.enum&#39;;

@Injectable()
export class CustomValidationPipe implements PipeTransform&lt;any&gt; {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);

    /* 중요 포인트 !!! */
    if (errors &amp;&amp; errors.length &gt; 0) {
      const translatedError = await this.transformError(errors);

      // ValidationPipe의 예외 처리로써 우리가 규약한 HttpBaseException을 던지게끔 한다. 
      // 내부인자는 (에러코드, http 상태코드, Array 형태의 에러 메시지)
      throw new HttpBaseException(ValidateExceptionErrCodeEnum.VALIDATION_ERROR, HttpStatus.BAD_REQUEST, translatedError);
    }
    return value;
  }

  async transformError(errors: ValidationError[]) {
    const data = [];
    for (const error of errors) {
      // define property which wants to assign in response 
      data.push({
        property: error.property,
        constraints: error.constraints
      });
    }
    return data;
  }

  private toValidate(metatype: unknown): boolean {
    // 아래 타입의 에러에 대한 검증만을 진행한다.
    const types: unknown[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}</code></pre>
</br>

<p>마찬가지로 해당 커스텀 파이프 또한 <code>main.ts</code>에 전역으로 설정해준다.</p>
<pre><code class="language-ts">app.useGlobalPipes(new CustomValidationPipe());</code></pre>
<p>이러한 장치를 둠으로써 우린 최종적으로 규격화된 에러 응답에 대한 베이스 구축을 함과 동시에 외부 라이브러리(<code>class-validator</code>)에 대한 충돌을 해결할 수 있었다. </p>
<p>(유효성 검증과같이 전역적으로 작용하는 예외에 대해선 이를 무시할 순 없기에 위와 같은 별도의 수고가 동반됨이 불필요하지 않다고 생각한다)</p>
</br>

<h3 id="🥤-추가class-transform-적용-수정">🥤 (+추가)<code>class-transform</code> 적용 수정</h3>
<p>글을 업로드 한 시점에서 찾게 된 수정사항이 생겨 추가적으로 내용을 덧붙인다. </p>
<p>위와 같이 Custom Validation Pipe를 생성할 경우 <strong><code>transform()</code></strong> 함수 내부에서 오로지 <strong><code>value</code></strong> 값만 리턴하기 때문에 만약 <code>class-transformer</code>를 통한 클래스화 변형을 특정 멤버변수에 주입하였더 하더라도 적용받지 못하게 된다. </p>
<p>일반적으로 우리가 전역 Validation Pipe에서 <code>class-transformer</code>를 사용하기 위해 <code>main.ts</code>에서 아래와 같이 설정하는 것 처럼</p>
<pre><code class="language-ts">app.useGlobalPipes(new ValdiationPipe({ transform: true });</code></pre>
<p>우리가 커스텀하게 생성한 파이프에도 동일한 처리를 해주어야 한다.</p>
<p>즉, <code>CustomValidationPipe</code>의 생성자 매개변수로 해당 option 설정을 받고, <code>transform()</code> 메서드 내부의 <code>plainToClass()</code> 과정을 거친 object를 리턴해줄 필요가 있다.</p>
</br>

<p><strong>수정</strong></p>
<pre><code class="language-ts">@Injectable()
export class CustomValidationPipe implements PipeTransform&lt;any&gt; {

  // 추가
  constructor(private readonly options?: { transform: boolean }) {}

  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);

    if (errors &amp;&amp; errors.length &gt; 0) {
      const translatedError = await this.transformError(errors);
      throw new HttpBaseException(ValidateExceptionErrCodeEnum.VALIDATION_ERROR, HttpStatus.BAD_REQUEST, translatedError);
    }

    // 수정 
    return this.options?.transform ? object : value;
  }

 // 나머지 동일 ... </code></pre>
</br>

<p><strong>적용</strong></p>
<pre><code class="language-ts">// main.ts
app.useGlobalPipes(new CustomValidationPipe({ transform: true }));</code></pre>
</br>

<h2 id="🧃-예외-클래스의-생성-및-적용">🧃 예외 클래스의 생성 및 적용</h2>
<p>앞선 베이스 구축에 따른 서버내의 개별적 클래스를 생성해보고 이를 로직내에 적용해보자.</p>
<p>간단하게 <strong>&quot;유저 인증(Auth) 권한&quot;</strong>에 대한 예외 클래스는 어떻게 구현되며 적용되는지를 알아보자.</p>
<h3 id="exceptionclass-생성-feat-에러코드">&gt; <code>ExceptionClass</code> 생성 (feat. <code>에러코드</code>)</h3>
<p>개별 예외 클래스는 <code>HttpBaseException</code>을 상속받게끔 한다. 이는 곧 <code>HttpBaseException</code>의 내부 생성자인 &quot;<u>errorcode, http statuscode, msg</u>&quot;를 품게 됨을 의미한다.</p>
<p>이를 인지하며, 아래와 같은 유저 권한 에러에 대한 (유효하지 않는 엑세스 토큰 체킹) 예외 클래스를 생성할 수 있다.</p>
<pre><code class="language-ts">// user_unauthorized.exception.ts

import { HttpStatus } from &quot;@nestjs/common&quot;;
import { HttpBaseException } from &quot;../../base/http-base.exception&quot;;
import { AuthExceptionErrCodeEnum, AuthExceptionMsgEnum } from &quot;../../enums/auth-exception.enum&quot;;

// 유저 권한 에러 (유효하지 않은 액세스 토큰 or 액세스 토큰 만료)
export class UserUnAuthorizedException extends HttpBaseException {
  constructor() {
    super(AuthExceptionErrCodeEnum.USER_UNAUTHORIZED, HttpStatus.UNAUTHORIZED, AuthExceptionMsgEnum.USER_UNAUTHORIZED); 
  }
}</code></pre>
<p><code>super()</code>의 첫 번째 인자, 세 번째 인자는 자체 에러코드와 메시지 값이 될 것이고 이는 별개의 <strong><code>enum</code></strong> 객체에서 정의하도록 한다. </p>
</br>

<p><strong>AuthExceptionErrCodeEnum &amp;&amp; AuthExceptionMsgEnum</strong></p>
<p>이왕 보는 김에 Auth에 대한 에러 코드및 메시지를 담고 있는 Enum 객체 전체를 확인해보자.</p>
<p>(에러코드 및 메시지 Enum 객체는 도메인에 따른 파일 분리를 진행하였습니다)</p>
<pre><code class="language-ts">// auth_exception.enum.ts 

export enum AuthExceptionErrCodeEnum {
  EMAIL_NOTFOUND = &quot;0001&quot;,
  JWT_INVALID_TOKEN = &quot;0003&quot;,
  JWT_MALFORMED = &quot;0004&quot;,
  JWT_INVALID_SIGNATURE = &quot;0005&quot;,
  JWT_EXPIRED = &quot;0006&quot;,
  USER_UNAUTHORIZED = &quot;0007&quot;,   // 해당 부분 (인증 권한 에러)

  // ....

  REFRESH_TOKEN_MISS_MATCHED = &quot;0013&quot;,
  REFRESH_TOKEN_UNAUTHORIZED = &quot;0014&quot;,
}

export enum AuthExceptionMsgEnum {
  EMAIL_NOTFOUND = &quot;이메일을 찾을 수 없습니다.&quot;,
  JWT_INVALID_TOKEN = &quot;잘못된 jwt 토큰 값 입니다.&quot;,
  JWT_MALFORMED = &quot;토큰의 구성요소가 잘못되었습니다.&quot;,
  JWT_INVALID_SIGNATURE = &quot;시크릿키 값 오류입니다.&quot;,
  JWT_EXPIRED = &quot;리프레시 토큰이 만료되었습니다.&quot;,
  USER_UNAUTHORIZED = &quot;유효하지 않은 유저입니다 (인증 권한 에러)&quot;,  // 해당 부분 (인증 권한 에러)

  // ....
  REFRESH_TOKEN_MISS_MATCHED = &quot;요청 된 리프레시 토큰과 불일치 합니다.&quot;,
  REFRESH_TOKEN_UNAUTHORIZED = &quot;권한이 없는 토큰입니다 (삭제된 리프레시 토큰).&quot;,
}</code></pre>
<p>해당 에러코드및 메시지는 추후 재사용을 위해 Enum 객체화를 진행하였고, 별도의 파일에 분리하였다.</p>
<p>코드에서 확인할 수 있듯이 Enum의 member에 대해 정의한 해당 숫자 조합의 문자열이 바로 <strong>&quot;에러코드(errorcode)&quot;</strong>이다.</p>
<p>추후 블로그에서도 볼 수 있겠지만 인증, 유저, 주문 등의 큼지막한 도메인 별로 &quot;0<del>0999&quot;, &quot;1000</del>1999&quot;, &quot;2000~2999&quot; ... 의 에러코드를 나눌 수 있었고 해당 에러코드에 대한 범위 및 규약은 클라이언트와 서버간의 협의로 규정을 두었고 추가되는 에러및 에러코드는 문서(현재는 swagger)로써 공유하도록 하였다. </p>
<p>(클라이언트는 위의 에러 코드를 통해 에러 핸들링을 진행합니다)</p>
</br>

<h3 id="exceptionclass-사용">&gt; <code>ExceptionClass</code> 사용</h3>
<p>NestJS를 해보신 분들이라면 잘 아시겠지만 엑세스 토큰 검증을 통한 인증 권한 예외 처리는 <strong>&quot;가드(Guard)&quot;</strong>에서 진행할 수 있다. </p>
<p>이에 따라 만들어준 가드의 catch 문 내부에 해당 에러 클래스를 던지면 될 것이다. (인증가드 구현에 대한 설명은 생략하겠습니다)</p>
<pre><code class="language-ts">// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from &quot;@nestjs/common&quot;;
import { JwtService } from &quot;@nestjs/jwt&quot;;
import logger from &quot;../../logger/log.util&quot;;
import { ExtendedRequest } from &quot;../../../apis/auth/domain/auth/utils/jwt-request.interface&quot;;
import { UserUnAuthorizedException } from &quot;../../exception-handle/exception-classes/unauthorized_exception/user_unauthorized.exception&quot;;

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise&lt;any&gt; {

    const request = context.switchToHttp().getRequest&lt;ExtendedRequest&gt;();
    const method = request.method;
    const url = request.url;
    const now = Date.now();
    const token = this.extractTokenFromHeader(request);

    try {
      const decodedToken = this.jwtService.verify(token, {
        secret: process.env.JWT_ACCESS_SECRET
      });
      request.userId = decodedToken.userId;
      return decodedToken;
    } catch(err) {
      logger.error(
        `${method} ${url} ${Date.now() - now}ms`,
        context.getClass().name,
      );

      // ----------------------------  //
      /* 앞서 생성한 예외 클래스를 던진다. */
      // ----------------------------  //
      throw new UserUnAuthorizedException();
    }
  }

  private extractTokenFromHeader(request: ExtendedRequest): string | undefined {
    const [type, token] = request.headers.authorization?.split(&#39; &#39;) ?? [];
    return type === &#39;Bearer&#39; ? token : undefined;
  }
}</code></pre>
</br>

<p><strong>에러 발생 후 응답 확인</strong></p>
<pre><code class="language-ts">{
    &quot;errorCode&quot;: &quot;0007&quot;,
    &quot;statusCode&quot;: 401,
    &quot;msg&quot;: &quot;유효하지 않은 유저입니다 (인증 권한 에러)&quot;,
    &quot;timestamp&quot;: &quot;2024. 1. 18. 오후 11:55:29&quot;,
    &quot;path&quot;: &quot;/order/payment/create/temporary-orders&quot;
}</code></pre>
<p>다른 예외 클래스들도 마찬가지로 필요한 각 로직및 각 계층의 예외처리를 요하는 곳에 위치시키면 된다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/226e1e4e-a007-465b-8e27-ec13c57c0ff2/image.png" alt=""></p>
</br>

</br>

<h2 id="🧃-외부-의존적-에러는-어떻게-처리할까">🧃 외부 의존적 에러는 어떻게 처리할까</h2>
</br>

<p>일반적으로 클라이언트와 에러를 소통하는데 있어 굳이 클라이언트가 알 필요 없는 에러, 혹은 보안상 민감한 에러의 경우 <strong><code>UnCatchedExcxeptionClass</code></strong>를 응답케 하였다<spn style="color:red">(즉, 아무 처리도 취해주지 않은 에러 _ errorcode:&quot;9999&quot;, msg: &quot;알 수 없는 에러입니다&quot;)</span>. </p>
<p>이는 외부 라이브러리에 대한 특정 에러 혹은 데이터베이스(orm 수준의)차원에서 발생한 에러 등이 될 수 있을 것이다.</p>
<p>하지만 이런 외부 의존적 에러들 중에도 <u>단순 기록을 넘어 클라이언트에게 응답을 취해주어야 하는</u> 에러들이 존재하였다. 물론, 이는 어디까지나 프로덕션이 아닌 개발단계에서의 제스쳐일수도 있고, 혹은 팀이 정한 룰에 따라 그렇지 아니할 수도 있다. </p>
<p>프로젝트 진행 중 처리하도록 한 <u>외부 의존적 에러</u>는 대표적으로 아래와 같았다.</p>
<ul>
<li><p><strong>jwt 라이브러리 에러</strong> ---------- (선택적)</p>
</li>
<li><p><strong>pg사(toss-payments)응답 에러</strong> ------------ (필수적)</p>
</li>
</ul>
</br>

<p>jwt 라이브러리 관련 에러는 사실 개발 단계에서 원만한 소통을 위해 처리해주어야 하는 선택적 사항이었다면 결제 시(+결제 취소 시) pg사에서 넘어온 에러는 굉장히 민감하게 필수적으로 처리해주어야 했다. </p>
<p>하지만 해당 외부 에러들을 처리하는데는 몇 가지 짚고 넘어가야 할 점이 존재하였다.</p>
<ul>
<li><p>생성한 <code>HttpBaseException</code> 규격이 적용되지 않은 상태이다.</p>
</li>
<li><p>비즈니스 로직 수준에서 에러를 정의할 수 없다. (+ 다뤄야 할 에러가 너무 많다)</p>
</li>
<li><p>사용될 api(라우트 핸들러 함수)는 제한적이다. </p>
</li>
</ul>
</br>

<p>위 세 가지 사항을 고려하였을 때 해당 에러는 별도의 <span style="color:green"><strong>&quot;커스텀 예외 필터&quot;</strong></span>로 생성한 뒤, 필요한 컨트롤러 레벨 혹은 <u>라우터 레벨</u>에 주입해주도록 하였다.</p>
</br>

<h3 id="jwtexceptionfilter">&gt; <code>JwtExceptionFilter</code></h3>
<p>jwt 예외필터는 외부 에러에 대한 처리를 해주기 위함이다. 더 좋은 방법이 있을 수도 있겠지만, 해당 라이브러리 에러가 가지고 있는 개별적 에러에 대한 분기처리 작업이 필요하였다. </p>
<pre><code class="language-ts">// jwt-exception.filter.ts

@Catch(Error)
export class JwtExceptionFilter implements ExceptionFilter {

  catch(exception: any , host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse&lt;Response&gt;();
    const request = ctx.getRequest&lt;Request&gt;();
    const curr_timestamp: string = new Date().toLocaleString(&quot;ko-KR&quot;, { timeZone: &quot;Asia/Seoul&quot;});

    let errorCode: string;
    let statusCode: number;
    let msg: string;

    if (exception instanceof HttpBaseException) {
      statusCode = exception.getStatus();

      switch (statusCode) {
        case HttpStatus.TOO_MANY_REQUESTS:
          errorCode = TooManyRequestsErrCodeEnum.API_TOO_MANY_REQUESTS;
          break;

        case HttpStatus.BAD_REQUEST:
          errorCode = ValidateExceptionErrCodeEnum.VALIDATION_ERROR;
          break;

        default:
          errorCode = UnCatchedExceptionErrCodeEnum.UNCATCHED;
      }

      msg = exception.msg;
    } else {
      const ex = handlingException(exception);
      errorCode = ex.code;
      msg = ex.message;
      statusCode = HttpStatus.UNAUTHORIZED;
    }

    response.status(statusCode).json({
      errorCode,
      statusCode,
      msg,
      timestamp: curr_timestamp,
      path: request.url,
    });
  }
}

interface ExceptionStatus {
  code: string;
  message: string;
}

const handlingException = (err: Error): ExceptionStatus =&gt; {
  if (err.name === &quot;JsonWebTokenError&quot; &amp;&amp; err.message === &#39;invalid token&#39;) {
    return { code: AuthExceptionErrCodeEnum.JWT_INVALID_TOKEN, message: AuthExceptionMsgEnum.JWT_INVALID_TOKEN }
  }

  if (err.name === &quot;JsonWebTokenError&quot; &amp;&amp; err.message === &#39;jwt malformed&#39;) {
    return { code: AuthExceptionErrCodeEnum.JWT_MALFORMED, message: AuthExceptionMsgEnum.JWT_MALFORMED}
  }

  if (err.name === &quot;JsonWebTokenError&quot; &amp;&amp; err.message === &#39;invalid signature&#39;) {
    return { code: AuthExceptionErrCodeEnum.JWT_INVALID_SIGNATURE, message: AuthExceptionMsgEnum.JWT_INVALID_SIGNATURE};
  }

  if (err.name === &quot;TokenExpiredError&quot;) {
    return { code: AuthExceptionErrCodeEnum.JWT_EXPIRED, message: AuthExceptionMsgEnum.JWT_EXPIRED}
  }

  return { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: UnCatchedMsg.UNCATCHED_MSG }
}</code></pre>
<p>해당 라이브러리를 통해 넘어온 값 중 분기처리를 가능케 하는 필드로 <strong><code>name</code></strong>과 <strong><code>message</code></strong>를 선택하였고 이를 통해 처리하고자 하는 예외에 대해 핸들링 할 수 있었다.</p>
<p>사실 여기서 핵심은 <strong><code>jwt</code></strong> <u>외부 라이브러리에 대한 예외 처리</u>인데 <span style="color:red"><strong>&quot;왜&quot;</strong></span> <strong><code>HttpException</code></strong>에 대한 별도의 처리가 또 <strong>추가</strong>되었느냐 이다.</p>
<p>우리는 앞서 전역 http 필터인 <strong><code>GlobalExceptionFilter</code></strong>를 생성해 주었기 때문에 <strong><code>JwtExceptionFilter</code></strong>에서 http 예외처리를 한 점에 의문이 들 것이다.</p>
</br>

<p>하지만 이는 NestJS ExceptionFilter의 <strong>&quot;적용 순서&quot;</strong>로써 설명이 된다. </p>
<p>다른 Enhancer들과 달리, <strong>유일하게</strong> ExceptionFilter는 <span style="color:red">전역필터가 먼저 적용되지 않는다</span>.</p>
<blockquote>
<p><strong>라우터 레벨 -&gt; 컨트롤러 레벨 -&gt; 전역 순서</strong>로 적용되므로, 결국 최하단의 라우터 레벨에서 결국 전역 필터를 처리받지 못하게 된다.</p>
</blockquote>
<p>이에 따라 해당 라우터에서 발생하게 되는 http에러(<code>Too Many Requests</code>, <code>Validation</code>, ...)를 별도 처리 할 필요가 있다.</p>
<p>만약, 해당 처리를 해주지 않는다면 만약 Validation(<code>class-validator</code>) 에러가 발생하였을 시 체킹을 하지 못하고 <strong><code>Uncatched Error</code></strong>를 받게 될 것이다. </p>
</br>

<h3 id="tosspaymentsexceptionfilter">&gt; <code>TossPaymentsExceptionFilter</code></h3>
<p>토스 페이먼츠 (pg사) 연동 시 발생할 수 있는 에러이다. </p>
<p><u>결제 승인 시</u>, <u>결제 취소 승인 시</u>. 이렇게 두 가지 케이스에 예외가 발생할 수 있고, 이에 따라 각 경우에 대한 커스텀 필터를 정의할 필요가 있었다.</p>
<p><span style="color:gray">(에러 내용에 대한 자세한 설명은 추후 <u>결제-주문 포스팅</u>에서 다루도록 하겠습니다)</span></p>
</br>

<blockquote>
<p><strong>TossPaymentsConfirmFilter</strong> (결제 승인시 라우터 수준의 에러 필터)</p>
</blockquote>
<pre><code class="language-ts">// payments-confirm.filter.ts

@Catch(AxiosError)
export class TossPaymentsConfirmFilter implements ExceptionFilter {

  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse&lt;Response&gt;();
    const request = ctx.getRequest&lt;Request&gt;();
    const curr_timestamp: string = new Date().toLocaleString(&quot;ko-KR&quot;, { timeZone: &quot;Asia/Seoul&quot;});

    const ex = handlingException(exception);

    let responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;

    switch (exception.response.status) {
      case 400:
        responseStatus = HttpStatus.BAD_REQUEST;
        break;
      case 401:
        responseStatus = HttpStatus.UNAUTHORIZED;
        break;
      case 403:
        responseStatus = HttpStatus.FORBIDDEN;
        break;
      case 404:
        responseStatus = HttpStatus.NOT_FOUND;
        break;
      default:
        responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;
    }

    response.status(responseStatus).json({
      errorCode: ex.code,
      status: responseStatus,
      msg: ex.message,
      timestamp: curr_timestamp,
      path: request.url,
    });
  }
}

interface ExceptionStatus {
  code: string;
  message: string;
}

// 핸들링 할 익셉션의 모든 케이스는 필터에서 직접 정의하는 것이 아닌 별도의 객체로 위임해준다.
const handlingException = (err: AxiosError): ExceptionStatus =&gt; {
  const code = err.response.data[&#39;code&#39;];

  if (!!code) {
    return Object.keys(confirmErrorCodeMessageObject).includes(code) 
      ? confirmErrorCodeMessageObject[code] 
      : { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: &quot;알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람&quot; }
  }

  return { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: &quot;알 수 없는 오류로 인한 결제 승인 거부 :) 고객센터 문의 바람&quot; };
}</code></pre>
</br>

<blockquote>
<p><strong>TossPaymentsCancelFilter</strong> (결제 취소시 라우터 수준의 에러 필터)</p>
</blockquote>
<p>위의 <strong><code>TossPaymentsConfirmFilter</code></strong>와 형식은 동일하다. 단지 에러 핸들링 시 받게 될 메시지 객체에 차이가 있다. </p>
</br>

<blockquote>
<p><strong>confirmErrorCodeMessageObject</strong> &amp;&amp; <strong>cancelErrorCodeMessageObject</strong> (이미지 코드 참조)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/a268259f-fe7a-47c5-b127-ca2c67724712/image.png" alt=""></p>
</br>

<h2 id="🧃-swagger에-에러를-조금-더-예쁘게-담아보자">🧃 <code>Swagger</code>에 에러를 조금 더 &quot;예쁘게&quot; 담아보자.</h2>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/fa408054-06bd-4486-9987-9bd0377c37a2/image.png" alt=""></p>
<p>스웨거에는 위와 같이 에러를 응답할 수 있게 끔 하는 데코레이터가 존재한다.</p>
<p>400, 401, 403 ... 등의 각 상태코드에 대해 분리 할 수 있고, 이를 스웨거 상에 명세할 수 있지만 <strong>&quot;각 상태코드&quot;</strong>에 대한 <strong>&quot;여러&quot;</strong> 에러 응답을 나타내는데 있어서 불편함이 있었다.</p>
<p>그에 따라 특정 API 마다 발생할 수 있는 각 상태코드 별 에러를 커스텀하게 표현하기로 하였다.</p>
<h3 id="커스텀-데코레이터-생성">&gt; 커스텀 데코레이터 생성</h3>
<p>커스텀 데코레이터를 생성하는데 있어서 기존 우리가 정의해두었던 에러 형식인 <code>HttpBaseException</code>의 필드값을 그대로 불러온다. 그 후, 응답 객체만 동일 시하게 설정하면 끝이다.</p>
<pre><code class="language-ts">export interface ErrorResponseOption {
  /**
   * 예시의 제목을 기술
   */
  exampleTitle: string;
  /**
   * service및 implements 레이어에서 적었던 오류 메시지를 기술.
   */
  message: string | {property: string; constraints: { [key: string]: string }}[];
  /**
   * 오류가 나는 상황을 기술.
   */
  exampleDescription: string;
  /**
   * 에러 코드에 대해 기술(자체 애플리케이션 내의 에러코드).
   */
  errorcode?: string;
}

// ---------------------------------------------------------

export const ErrorResponse = (
  statusCode: HttpStatus,
  errorResponseOptions: ErrorResponseOption[]
) =&gt; {
  const examples = errorResponseOptions
    .map((err: ErrorResponseOption) =&gt; {
      const errorResDto = new HttpBaseException(
        err.errorcode,
        statusCode,
        err.message,
      );
      return {
        [err.exampleTitle]: {
          value: {
            errorCode: errorResDto.errorCode,
            statusCode: errorResDto.statusCode,
            msg: errorResDto.msg,
            timestamp: new Date().toLocaleString(&quot;ko-KR&quot;, { timeZone: &quot;Asia/Seoul&quot;}),
            path: &#39;http://localhost:3030/sth/sth/...&#39;,
          },
          description: err.exampleDescription
        }
      };
    })
    .reduce(function (result, item) {
      Object.assign(result, item);
      return result;
    }, {}) // null값 있을 경우 필터링

  return applyDecorators(
    ApiExtraModels(
      HttpBaseException,
    ),
    ApiResponse({
      status: statusCode,
      content: {
        &#39;application/json&#39;: {
          schema: {
            oneOf: [
              {
                $ref: getSchemaPath(HttpBaseException)
              }
            ]
          },
          examples: examples
        }
      }
    })
  )
}</code></pre>
</br>

<p>그 후 아래와 같이 직접 특정 에러코드에 대한 정의를 명시해준다. 이는 굉장한 수작업을 동반하지만... 좋은 스웨거 에러 응답을 위해 어쩔 수 없는 과정이었다.</p>
<p><strong>ErrorsDefine</strong></p>
<p>아래는 주문 시에 발생하게 되는 에러 응답에 대한 정의 예시이다.</p>
<pre><code class="language-ts">// order_erros_define.ts

export const OrderErrorsDefine = {
  &#39;3102&#39;: {
    model: UserStoreCouponNotFoundException,
    exampleDescription: &#39;주문 진행 중 유저의 가게 쿠폰을 찾을 수 없을 때&#39;,
    exampleTitle: &#39;존재하지 않는 유저의 가게 쿠폰 감지&#39;,
    message: UserExceptionMsgEnum.USER_STORE_COUPON_NOT_FOUND,
    errorcode: UserErrCodeEnum.USER_STORE_COUPON_NOT_FOUND
  },
  &#39;4500&#39;: {
    model: MenuOutOfStockException,
    exampleDescription: &#39;주문 진행 중 메뉴 재고가 소진되었을 때&#39;,
    exampleTitle: &#39;메뉴 재고 소진&#39;,
    message: OrderExceptionMsgEnum.MENU_OUT_OF_STOCK,
    errorcode: OrderExceptionErrCodeEnum.OUT_OF_STOCK_ERROR
  },

  // ... ...

  &#39;4520&#39;: {
    model: OrderNotFoundException,
    exampleDescription: &#39;request param에 해당하는 orderId값에 대한 주문 데이터를 찾을 수 없을 때&#39;,
    exampleTitle: &#39;orderId에 대해 존재하지 않는 주문 데이터&#39;,
    message: OrderExceptionMsgEnum.ORDER_NOT_FOUND,
    errorcode: OrderExceptionErrCodeEnum.ORDER_NOT_FOUND,
  }
};</code></pre>
</br>

<h3 id="적용및-스웨거-확인">&gt; 적용및 스웨거 확인</h3>
<p>이렇게 커스텀 데코레이터와 내부 매개변수로 쓰이게 될 <code>ErrorResponseOption[]</code>을 정의하였다면 아래와 같이 컨트롤러 레벨 또는 라우터 레벨에 주입해주면 된다.</p>
<pre><code class="language-ts">// order-process.controller.ts

@ApiTags(&#39;order&#39;)
@ApiBearerAuth(&#39;access-token&#39;)

// 유저 인증 권한에 대한 에러 응답이다. 해당 명세는 거의 대부분의 컨트롤러에 주입될 것이다.
@ErrorResponse(HttpStatus.UNAUTHORIZED, [
  AuthErrorsDefine[&#39;0007&#39;]
])
@UseGuards(JwtAccessAuthGuard)
@Controller(&#39;order&#39;)
export class OrderProcessController {

  // 생략 ...

  @ApiOperation({
    summary: &#39;주문 트랜잭션 api&#39;,
    description: &#39;pg사 결제 승인 완료에 따른 자체 서비스 주문 트랜잭션 시작&#39;
  })
  @ApiBody({
    type: OrderReqDto,
  })
  @ApiCreatedResponse({
    status: 201,
    description: &#39;주문 생성&#39;
  })

  // 주문 과정 중 발생 할 수 있는 400 상태 코드 에러들에 관한 명세이다.
  @ErrorResponse(HttpStatus.BAD_REQUEST, [
    PaymentErrorsDefine[&#39;1130&#39;],
    OrderErrorsDefine[&#39;3101&#39;],
    OrderErrorsDefine[&#39;3102&#39;],
    OrderErrorsDefine[&#39;4500&#39;],
    OrderErrorsDefine[&#39;4501&#39;],
    OrderErrorsDefine[&#39;4502&#39;],
    OrderErrorsDefine[&#39;4503&#39;],
  ])
  @Post(&#39;/start-transaction&#39;)
  @UseFilters(TossPaymentsCancelFilter)
  @UsePipes(new ValidateOrderIdPipe())
  async startOrderTransaction(
    @Req() request: ExtendedRequest,
    @Body() orderReqDto: OrderReqDto,
  ) { 
    //  생략 ...   
  }</code></pre>
</br>

<p><strong>Swagger Test</strong></p>
<p>아래와 같이 커스텀 데코레이터에서 설정한 폼에 따른 동적인 에러 응답이 보여지게 된다. </p>
<p>🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍</p>
<hr>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/0d4d27c1-07ce-45f1-9bf0-12bd07657898/image.gif" alt=""></p>
<hr>
<p>🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍🌍</p>
</br>

</br>

<h2 id="생각정리">생각정리</h2>
<p>프로젝트를 진행하며 에러처리를 하는 과정은 참 어쩌면 막노동? 이었단 생각이 들기도 한다. </p>
<p>에러 코드및 메시지, 그리고 스웨거에 명세하기 위한 에러 description 정의와 같은 작업은 일일이 수작업으로 작성해 주어야 했으며 이는 굉장히 고된 작업이었다. </p>
<p>하지만 이러한 과정을 수행한 뒤, 에러를 처리하는데 있어 유연한 사고를 기를 수 있었고 추가적으로 프레임워크(NestJS)의 특성에 대해 조금 더 깊게 생각해보는 계기가 되었다.</p>
<p>에러를 잘 핸들링 하는 것도 중요하지만 동시에 에러를 잘 &quot;소통&quot;하는 것 역시 중요하다 생각한다. 설령 그것이 개발단계 일지라도 클라이언트와 발생하게 될 에러에 대해 규약하고, 명세하는 과정은 굉장히 의미있는 시간이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project] #1 아키텍처 설계는 흥미로운 경험이었다.]]></title>
            <link>https://velog.io/@from_numpy/Project-1-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84%EB%8A%94-%ED%9D%A5%EB%AF%B8%EB%A1%9C%EC%9A%B4-%EA%B2%BD%ED%97%98%EC%9D%B4%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@from_numpy/Project-1-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84%EB%8A%94-%ED%9D%A5%EB%AF%B8%EB%A1%9C%EC%9A%B4-%EA%B2%BD%ED%97%98%EC%9D%B4%EC%97%88%EB%8B%A4</guid>
            <pubDate>Mon, 15 Jan 2024 13:43:06 GMT</pubDate>
            <description><![CDATA[<h2 id="아키텍처-설계-배경-nestjs">아키텍처 설계 배경 (<code>NestJS</code>)</h2>
<p>아래에서 보여지겠지만, 현재 프로젝트의 베이스가 되는 아키텍처및 디자인 패턴 설계는 처음부터 마음먹고 진행된 것이 전혀 아니었고 일련의 배경이 존재하였다. 그 얘기를 먼저 진행해보고자 한다.</p>
<p>어떤 패턴을 사용하든, 어떤 패턴을 구축하든 일단 가장 먼저 이해하고 인지해야할 것은 바로 언어와 프레임워크라 생각한다. (그 중에서도 프레임워크가 될 수 있지 않을까 싶다.) </p>
<h3 id="여태-nestjs를-어떻게-사용해-왔는가">여태 <code>NestJS</code>를 어떻게 사용해 왔는가</h3>
<p>NestJS를 통해 서버 개발에 입문하는 사람(취준생, 초입자 등등...)이 일반적으로 마주하게 되는 구조(Structure)는 아마 아래와 같지 않을까 싶다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2fa89fc6-b9e5-4cc6-aea9-e060a9cb3697/image.png" alt=""></p>
<p>프로젝트를 진행하기 전, 주로 공식문서 베이스와 유데미에서 찾게 된 온라인 강의(<u>2020</u>년 기준의 강의였다...)하나를 통해 NestJS를 공부해왔던 나로서는 위의 아키텍처와 패턴이 항상 당연하게 여겨졌다.</p>
<p>HTTP request를 다루는 <strong>Controller</strong>, 그리고 Controller에서 담기엔 복잡한 태스크를 <strong><code>@Injectable()</code></strong>을 통한 위임으로 구현한 <strong>Service layer</strong>, 그리고 이를 이들의 구조를 정의하는 <strong>Module</strong>. 더하여, Service 클래스와 같이 <strong><code>@Injectable()</code></strong>을 통해 프로바이더로써 동작하게 되는 Guard, Pipe, Filter와 같은 <strong>Middleware</strong> 까지.</p>
<p>미들웨어 계층의 Enhancer들을 제외하면 각 도메인 별, 실 비즈니스 로직을 담고 있는 클래스는 결국 <strong>Controller와</strong> <strong>Service</strong>가 될 것이다.</p>
<p>나 역시 프로젝트를 위와 같이 NestJS를 마주하면 바로 접해볼 수 있는 위와같은 패턴의 아키텍처로 시작하였다.</p>
</br>

<h3 id="기존-아키텍처의-문제점-_-1-nestjs-관점">기존 아키텍처의 문제점 _ #1 NestJS 관점</h3>
<p>혹시나 논란을 불러일으키지 않을까 해서 잠시 언급하자면, 소제목에서 언급한 <strong>&quot;문제점(Problem)&quot;</strong>은 개발을 마주하는 &quot;나&quot;, 그리고 &quot;팀&quot;에 있어서의 문제이지 범용적인, 혹은 절대적인 <strong>&quot;단점&quot;</strong>을 말하는 것이 아님을 말한다.</p>
</br>

<p><strong>하나,</strong></p>
<p><span style="color:green"><strong>&quot;Circular Dependency(순환 종속성)&quot;</strong></span>를 피할 수 없다.</p>
<p>위의 그림에서 볼 수 있듯이 기존 아키텍처에선 root AppModule을 여러 도메인의 모듈들이 바라보고 있고, 각 모듈에서 선언된 provider(Service class혹은 미들웨어)를 서로 다른 모듈에 종속된 provider 내부에서 생성자로 사용하고자 할 시, 일반적으로 모듈 자체를 다른 모듈에 *<em><code>imports</code> *</em>해주게끔 하였다.</p>
<p><code>@Injectable()</code> 데코레이터를 통한 Singleton dependency로 정의된 Service 클래스는 또 다른 Singleton dependency한 Service 클래스를 생성자로써 불러왔다. </p>
<p>이는 피할 수 없는 상황이였을 것이다. </p>
<p>모든 로직을 전부 Service 클래스에 위임해 진행하는 위의 경우, 한 도메인 클래스 내부에 무조건 다른 도메인의 로직을 불러올 상황이 일어나는 것은 당연했기 때문이다.</p>
<p>아래의 간단한 예시를 보자. </p>
<pre><code class="language-typescript">@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
  ) {}
 }


@Injectable()
export class UserService {
  constructor(
    // ...
  ) {}
 }
</code></pre>
<p><code>@Injectable()</code> 프로바이더로 정의된 AuthService에서 UserService 클래스를 생성자로써 주입받아 사용하려 한다.</p>
<p>이를 가능케 하기 위해 <code>UserModule</code> 측에선 <code>UserService</code>를 provider로 등록함과 동시에 exports 해주어야 했고, <code>AuthModule</code>에서 <code>UserModule</code> 자체를 imports 해주었다. 위의 그림과 같이 말이다.</p>
<pre><code class="language-ts">@Module({
  // ...
  providers: [UserService],
  exports: [UserService]
})
export class UserModule {}


@Module({
  imports: [
    UserModule
  ],
  providers: [AuthService],
  // ...
})
export class AuthModule {}</code></pre>
<p>하지만 이대로 실행하면 어떤 결과를 불러올까? </p>
<p>다들 아시다시피 <span style="color:red"><strong>&quot;Circular Dependency&quot;</strong></span>가 발생하게 된다. 클래스 A에서 클래스 B가 필요하고, 클래스 B에서 클래스 A가 필요한 그 상황으로 인해 해당 에러가 발생한 것이다.</p>
<p>NestJS에서는 보통 아래의 3가지 상황에서 순환 종속성 에러를 마주하게 된다.</p>
<hr>
<ul>
<li>circular file imports - where <code>a.ts</code> import <code>b.ts</code> which imports <code>a.ts</code></li>
<li>circular module imports - where <code>AuthModule</code> imports <code>UserModule</code> import <code>AuthModule</code>, like the above error</li>
<li>circular constructors - where <code>AuthService</code> injects <code>UserService</code> which injects <code>AuthService</code></li>
</ul>
<hr>
<p>그리고 NestJS에선 이러한 순환 종속성을 해결(? <span style="color:red">사실 해결이라 할 순 없다...</span>)하기 위해 <strong>Forward Reference</strong>(전달 참조)를 제시한다.</p>
<p><strong><code>forwardRef()</code></strong>를 사용한 해당 방법은 모듈이나 서비스 등을 참조하는 순간까지 해당 참조를 늦추는(<u>lazy evaluation</u>) 방식으로 순환 종속성 문제를 해결할 수 있다.</p>
<pre><code class="language-ts">@Module({
  imports: [
    forwardRef(() =&gt; UserModule)  // forward reference
  ],
  providers: [AuthService]
})
export class AuthModule {}</code></pre>
<p><span style="color:red"><strong>그러나,</strong></span> Nest는 위의 방법을 제시하기 전 하나의 코멘트를 붙인다.</p>
<p><em>&quot;While circular dependencies should be avoided where possible, you can&#39;t always do so.&quot;</em></p>
<p><strong>가능</strong>하면 되도록 순환 종속성을 <strong>&quot;피해(avoid)&quot;</strong>란 것이다.</p>
<p>하지만 기존의 아키텍처와 같이, <strong>Service</strong> 클래스에 <strong>집중적으로</strong> 많은 태스크가 다뤄지는 상황에서 도메인이 점점 늘어날수록 자연스래 외부 모듈의 종속된 서비스 클래스를 불러와야하는 상황이 발생할 수 밖에 없다.</p>
</br>

<p><strong>둘,</strong></p>
<p>개발 단계중, 복잡한 <strong>의존성 에러</strong>를 빈번히 마주할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/583a94ab-8a94-4dbb-bc47-de3f15d699c3/image.png" alt=""></p>
<p>흔히, 볼 수 있는 에러이다. 의존성 주입과 관련되어 일련의 클래스가 주입되지 못하였거나 혹은 잘못되었을 경우 Nest에서 이를 해결하지 못해 발생하게 되는 경우다. </p>
<p>아주 간단한 수준의, 혹은 토이 프로젝트 정도의 규모에선 (다루게 되는 모듈이 많지 않을 경우) 크게 문제가 되지 않을 것이다. 설령, 개발 단계에서 위의 에러가 발생하였다 하더라도 디버깅 하는데는 문제가 없을 것이다.</p>
<p>하지만, 다루게 되는 기능에 따른 도메인이 점점 늘어날수록, 여러 갯수의 모듈들이 서로가 서로를 물게 되고, <strong><code>@Injectable()</code></strong>로 구현된 싱글톤 클래스 역시 서로가 서로에게 주입되며 개발단계에서 상당한 에러를 불러일으킬 것이다. 더불어, (나와같은 초보자 입장에선) 해당 종속성을 트랙킹하는 것에 있어서도 난항을 겪게 될 것이다.</p>
</br>

<h3 id="기존-아키텍처의-문제점-_-2-도메인-설계-관점">기존 아키텍처의 문제점 _ #2 도메인 설계 관점</h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2df1ceaa-7f68-4a9c-910c-9235eae95f78/image.png" alt=""></p>
<p>위는 데이터베이스 설계도 중 일부이다. 당연히 빙산의 일각에 불구하고, 초기 와이어프레임 당시에도 각 도메인별 테이블 수는 예상과 달리 매우 많았고, 진행을 이어가며 추가될 테이블 혹은 컬럼 또한 상당히 늘어났다.</p>
<p>이는 결국, 각 도메인별 책임져야 할 태스크가 (로직이) 늘어난다는 것을 의미하고 데이터 관리를 위한 CRUD가 추가로 요해지게 된다.</p>
<p>NestJS에서 일반적으로 제시하는 <strong>레포지터리 패턴</strong>(Repository Pattern)은 어떠한가?</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/860a1889-1676-432c-a979-692827daaec3/image.png" alt=""></p>
<p>위와 같이 (Typeorm 기준) Service 클래스에서 Typeorm의 Repository 클래스를 불러와 *<em><code>@Inject()</code> *</em>데코레이터를 이용해 주입시킨다. 물론, 모듈단에서든 혹은 별개의 provider 파일에서 사용자 지정 종속성 주입 작업이 필요할 것이다.</p>
<p>Typeorm이라면 사실 위의 경우보다 <strong><code>@InjectRepository()</code></strong>를 Service 클래스 생성자 내부에서 불러오는 것을 더 많이 보게 된다.</p>
</br>

<p>그렇다면 이 모든 CRUD 작업을 Service 클래스에 정의하는 것이 꽤 괜찮다 말할 수 있을까?</p>
<p><strong>-Service-</strong>는 흔히 말하는 <strong>&quot;Buisness Layer&quot;</strong>다. </p>
<p>비즈니스 계층은 프로그램의 <strong>&quot;핵심&quot;</strong>을 담당하는 계층이다. 물론 단순한 CRUD 작업 자체가 비즈니스 로직이 될 수 있다. 충분히 그렇지만, 우리가 비즈니스 로직을 설계하고 아키텍처를 구상하는데 있어서 만큼은 지향점을 <span style="color:green">&quot;CRUD를 넘어선, CRUD의 복합체로 이루어진 고수준의 UseCase에 맞추어야&quot;</span>하지 않을까 생각한다. </p>
<p>오로지 유저의 행위 자체에 초점을 맞춘, API를 정의한 라우트 핸들러 함수가 직접적으로 품고 있는 메서드(함수)만을 Service 클래스에 담는 것이다.</p>
<p>물론, <strong><code>@InjectRepository(Entity)</code></strong>를 Service 생성자 내부에서 사용하는 방법 또한 엄연히 DB Context를 분리시킨 Repository Pattern이라고 할 수는 있지만 로직의 분리를 위해선 <u>별도의 계층 하나가 추가 될 필요</u>가 있는 것이다. </p>
<p>자세한 건 아래에서 얘기를 나눠보도록 하겠다.</p>
</br>

</br>

<h2 id="아키텍처-설계-과정-그리고-도입">아키텍처 설계 과정, 그리고 도입</h2>
<p>꼭 배경을 언급하고 싶어 서론이 길어졌고, 이제는 본격적인 설계과정 도입을 소개해보고자 한다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/f3c1ab8d-6ec0-4235-8824-81bfa815e0a0/image.png" alt=""></p>
<p>전체 구조는 위와 같다. 보통의 케이스에 크게 벗어나진 않을 것이다.</p>
<ul>
<li><p><strong>apis</strong> - 도메인 모듈및 모든 내부 로직을 정의</p>
</li>
<li><p><strong>common</strong> - 환경 설정 및(config 파일) 인프라 베이스, 공유 모듈등을 정의</p>
</li>
<li><p><strong>middlewares</strong> - Enhancer(Guard, Pipe, Filter ...)들을 정의</p>
</li>
<li><p><strong>models</strong> - orm수준의 entity 클래스를 정의 (별도로 분리하였다)</p>
</li>
</ul>
<p>(<code>commands</code>, <code>standalone.module.ts</code>는 무시하셔도 좋습니다)</p>
</br>

<p><span style="color:gray">(아키텍처 소개인만큼 세세한 설정 파일 등을 까보진 않겠습니다, 도메인 설계 관점을 포인트로 진행하도록 하겠습니다.) </span></p>
</br>

<h3 id="port포트---adaptor어댑터-패턴">&gt; <code>port(포트)</code> - <code>Adaptor(어댑터)</code> 패턴</h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/02d9c2f2-2c36-416b-a735-ebb4d8791df8/image.png" alt=""></p>
<p>와이어 프레임(디자인 및 데이터베이스 설계)을 토대로 위와 같이 도메인을 디렉토리 별로 분리하게 되었다. 각각의 영역에서 처리해야 할 로직들이 많을 거라 예상되었고, 나에게 있어 이는 별도의 도메인으로 나눌 이유가 되었다.</p>
<p>자, 그럼 하나의 <strong>도메인을 예시</strong>를 통해 분석해 보자.</p>
<p><strong>orders</strong> 가 좋을 것 같다. <strong>orders</strong> 도메인을 열어보자.</p>
<p>참고로, payment(결제)와 orders(주문)을 별개의 모듈로 생성할 만큼 분리할 이유는 없었기에 하나의 모듈로 정의하였다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/f918bf90-cd2b-486d-a209-0eb9c98444c3/image.png" alt=""></p>
<p>아마, 위의 구조를 보자마자 어떠한 아키텍처가 떠오르는 분이 계실 것이다.</p>
<p>그렇다, 바로 <span style="color:green"><strong>&quot;Hexagonal Architecture(헥사고날 아키텍처)&quot;</strong></span>이다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/20810d8f-0d21-4d8c-bd15-3ebb04ded0a5/image.png" alt=""></p>
<p>그리고 이는 아래와 같은 포트-어댑터 패턴을 적극적으로 사용해 설계되었다 볼 수 있을 것이다. (이미지 참조 __ <a href="https://engineering.linecorp.com/ko/blog/port-and-adapter-architecture">Line engineering 지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기</a>)</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/401b2beb-beae-4950-a037-a891e3f5d302/image.png" alt=""></p>
<p><em>&quot;포트와 어댑터를 통해 소프트웨어 환경에 쉽게 연결할 수 있는 느슨하게 결합된 응용 프로그램 구성 요소를 만드는 것을 목표로 합니다.&quot;</em></p>
<p>포트 어댑터 아키텍처는 위와 같이 언급한다.</p>
<p>조금 더 자세히 말하면, <u>인터페이스</u>나 <u>Infrastrucutre</u>의 변경에 영향을 받지 않는 순수한 도메인을 구축하고 관리하는 것이라 할 수 있다.</p>
<p>도메인 클래스(Service Class)는 결국 어떠한 구현체(Adaptor)를 직접적으로 받는 것이 아닌, <span style="color:green"><strong>추상적인 인터페이스를 바라보게 되는 것</strong></span>이다.</p>
<p>감이 오지 않을 수 있으니 직접 코드를 통해 확인해보자.</p>
</br>

<h3 id="presentation">&gt; <code>presentation</code></h3>
<p>흔히 우리가 <strong><code>Controller</code></strong>라 부르는 녀석이 정의될 곳이다. </p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2f60a0d7-0d7d-4d58-bbda-d7dd2a494321/image.png" alt=""></p>
<p>클라이언트 입장에서 보면 API의 구현을 담은 <strong><code>Web Rest Controller</code></strong>도 하나의 어댑터<span style="color:gray">(http rest가 아닌 socket에 대한 구현체가 그려질 수도 있음)</span>이다. </p>
<p>컨트롤러는 클라이언트와의 소통을 통한 API 정의, 나아가 화면 정의들을 판단하여 유저 관점에서 분리하도록 진행하였다. </p>
<p>그리고 클라이언트란 계층과 데이터 교환을 위해 사용되는 <strong>DTO</strong><span style="color:gray">(Data Transfer Object)</span>역시 presentation 레이어에 위치시킨다.</p>
<p>잠시 있다가 컨트롤러 코드에서 확인할 수 있겠지만, dto 객체를 그대로 도메인 영역으로 보내주지 않는다. 
클라이언트로부터 넘어오는 dto 객체의 명세는 <u>언제나 바뀔 가능성이 있다</u>. 또한, 거의 일어나지 않을수도 있겠지만 클라이언트로부터 넘어오는 값과 영속성 계층(orm to db)에 저장해야할 값의 <u>타입이 달라야 할 수도</u> 있다. </p>
<p>마지막으로 필요한 부분은 <strong>매퍼</strong>(Mapper)이다. </p>
<p>domain 파트에서 보겠지만 dto를 그대로 도메인 영역에 전달하는 것이 아닌 <strong>command</strong>란 객체로 변환해 전달하는 만큼, 이를 매핑해주는 클래스가 필요하다. 이는 정적인(<strong><code>static</code></strong>)클래스로 충분히 구현가능하며, 컨트롤러의 각 라우트 핸들러 함수 내부에서 호출된다.  </p>
<pre><code class="language-ts">// orders.mapper.ts

export class OrdersMapper {
  static mapToCommand(orderReqDto: OrderReqDto): OrderReqCommand {
    const cartMenus = orderReqDto.cartMenus.map((cartMenus) =&gt; CartMenusForOrdersMapper.mapToCommand(cartMenus));

    return new OrderReqCommand(
      orderReqDto.storeId,
      orderReqDto.userNick,

      // ... (생략)

      orderReqDto.userStoreCouponId,
      orderReqDto.orderRequest
    );
  }
}

export class OrdersCancelMapper {
  static mapToCommand(orderCancelReqDto: OrderCancelReqDto): OrderCancelReqCommand {
    return new OrderCancelReqCommand(
      orderCancelReqDto.orderId,
      orderCancelReqDto.storeId,
    );
  }
}</code></pre>
</br>

<p>이러한 구성요소들을 바탕으로 주문을 진행하는 <strong><code>OrderProcessController</code></strong>를 정의할 수 있다.</p>
<pre><code class="language-ts">// order-process.controller.ts
// swagger 명세 생략 

@UseGuards(JwtAccessAuthGuard)
@Controller(&#39;order&#39;)
export class OrderProcessController {
  private readonly cancelReason_case1 = UnCatchedMsg.UNCATCHED_MSG;
  private readonly cancelReason_case2 = UnCatchedMsg.CUSTOMER_CHANGE_OF_MIND;

  // 생성자 내부에선 UseCase 인터페이스를 받아오게 된다. ~Symbol 토큰을 provider로써 주입받고,
  // 추후 이는 애플리케이션&gt;모듈 단에서 정의된다.
  constructor(
    @Inject(OrderPaymentUseCaseSymbol)
    private readonly orderPaymentUseCase: OrderPaymentUseCase,
    @Inject(OrderUseCaseSymbol)
    private readonly orderUseCase: OrderUseCase,
    @Inject(OrderCancelUseCaseSymbol)
    private readonly orderCancelUseCase: OrderCancelUseCase,
  ) {}

  // 주문 생성
  @Post(&#39;/create&#39;)
  @UseFilters(TossPaymentsCancelFilter)
  @UsePipes(new ValidateOrderIdPipe())
  async startOrderTransaction(
    @Req() request: ExtendedRequest,
    @Body() orderReqDto: OrderReqDto,
  ) {
    const userId = request.userId;

    // dto to command mapping (to transfer -&gt; domain layer)
    const orderReqCommand = OrdersMapper.mapToCommand(orderReqDto); 

    try {
      await this.orderUseCase.processOrderTransaction(userId, orderReqCommand);
    } catch (err) {
      await this.orderPaymentUseCase.cancelPayments(orderReqCommand.paymentKey, this.cancelReason_case1);
      throw err;
    }
  }

  // 주문 취소
  @Post(&#39;/cancel&#39;)
  @UseFilters(TossPaymentsCancelFilter)
  async cancelOrders(
    @Req() request: ExtendedRequest,
    @Body() orderCancelReqDto: OrderCancelReqDto,
  ) {
    const userId = request.userId;
    const orderCancelReqCommand = OrdersCancelMapper.mapToCommand(orderCancelReqDto);
    const paymentKey = await this.orderCancelUseCase.getPaymentKey(orderCancelReqCommand.orderId);

    try {
      await this.orderCancelUseCase.cancelOrders(userId, orderCancelReqCommand);
      await this.orderPaymentUseCase.cancelPayments(paymentKey, this.cancelReason_case2);
    } catch (err) {
      throw err;
    }
  }
}</code></pre>
<p>💢💢💢</p>
<p>핵심은 Controller는 직접적으로 <strong>&quot;Service&quot;</strong> 클래스를 불러오는 것이 아닌 <strong>&quot;UseCase&quot;</strong>란 인터페이스에 접근한다는 것이다. 그리고 해당 UseCase에서 선언된 메서드 내부 파라미터엔 dto 객체가 아닌 command 객체를 받게끔 한다.</p>
</br>

<h3 id="domain">&gt; domain</h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/3327dc8c-7872-4279-b6bd-df93d7e1f2c1/image.png" alt=""></p>
<p>핵심은 <strong>port</strong>와 <strong>service</strong> 클래스이다. 위의 아키텍처에서 이 2가지에 대해 이해하고 자연스럽게 받아들이는 과정은 생각보다 쉽지 않았고 그만큼 중요하였다.</p>
</br>

<p><strong>listeners</strong></p>
<p>이벤트 핸들러를 정의하는 구간이다. 비즈니스 로직이므로 서비스 클래스에 정의할 수 도 있겠지만, 서비스 클래스엔 유저의 행위에 의한 큼지막한 유즈케이스에 초점을 두었다. </p>
<p>유저가 주문을 완료하면 레디스의 sorted-set을 활용하여 주문 수 카운트를 +1 시켜준다고 하자. 이는 주문 수 랭킹 기능을 만들기 위해서이다. 하지만 명확히 따지자면 주문을 생성하는 부분과는 다른 명세의 기능이다. 주문 시 일어날 수 있는 주문서 생성, 포인트 적립, 쿠폰 사용, 메뉴 차감 등과는 다르게 RDBMS에 접근하지 않는 별개의 행위이므로 &quot;이벤트&quot;라 간주하고 진행하였다. </p>
<p>위의 판단하에, 비즈니스 로직으로부터 이벤트 기능을** &quot;Decoupling&quot;**시킬 필요가 있었다.</p>
<p>NestJS의 <strong><code>EventEmitter</code></strong>를 활용하였으며, 자세한 건 추후 소개하도록 하겠다.</p>
</br>

<p><strong>models</strong></p>
<p>도메인 계층에서 사용될 객체 모델이며, 이 역시 도메인의 순수성을 지키기 위한 장치이다.</p>
<pre><code class="language-ts">// 가주문 테이블(temp_orders)을 조회했을 때 반환되는 값.
export class TempOrderChecksResModel {
  payment_orderId: string;
  totalAmount: number;
  pointAmount: number;
  couponAmount: number;
}</code></pre>
<p>여태 나의 경우엔 <strong><code>find</code></strong>를 통한 <strong>조회 로직</strong>을 수행하는데 있어<span style="color:gray">(일반적 NestJS layered 아키텍처의 경우라봐도 무방하다)</span> 서비스 클래스 내부 메서드의 리턴 값으로 직접적 엔티티 객체를 불러왔다. </p>
<pre><code class="language-ts">@Injectable()
export class OrderService {
  // Orders 엔티티를 리턴 타입으로 직접 받아옴.
  async findOrderDataBySth(sth: ~~dto): Promise&lt;Orders&gt; {} 
}</code></pre>
<p>이것이 문제가 된 다는 것은 <span style="color:red"><strong>&quot;절대&quot;</strong></span> 아니다. </p>
<p>하지만, 포트-어댑터 패턴을 채택하기로 한 시점, 도메인 영역은 <strong>infrastructure</strong> 영역을 모르게 할 필요가 있었다. 이에 대해 TypeORM으로 구현된 엔티티 객체를 그대로 불러오지 않고, 별도의 Response 객체로 변환하는 작업을 수행하였다.</p>
<p>이는 <span style="color:green">[infrastructure layer --&gt; domain layer]</span>로 응답 객체를 전달하는데 핵심이 되는 과정이지 절대로 <u>presentation 단에 응답하기 위해 생성한 Response Dto가 아니다...!</u> </p>
<p>즉, Response Dto로 그대로 쓰일 수도 있지만 핵심은 Domain이다.</p>
</br>

<p>🤞 <strong>port</strong> ✌</p>
<p>포트-어댑터(or hexagonal)패턴은 외부 계층에서 도메인 및 유즈케이스로의 통신은 오로지 <strong>&quot;port&quot;</strong>를 통해서 이루어지도록 한다. </p>
<p>즉, 해당 포트(Interface)를 통해 내부 비즈니스 영역을 외부로 노출시키는 것이다.</p>
<p>포트는 <strong>Inbound port</strong>와 <strong>Outbound port</strong>로 나뉜다.</p>
<ul>
<li><p><strong>Inbound port</strong>: 도메인 영역 사용을 위해 노출된 인터페이스</p>
</li>
<li><p><strong>Outbound port</strong>: 도메인에서 인프라 영역을 사용하기 위해(인프라를 호출시키기 위해) 노출된 인터페이스</p>
</li>
</ul>
<p>In port와 Out port는 아키텍처 측면에서 <span style="color:green">내부 영역(domain)</span>, <span style="color:green">외부 영역(infrastructure)</span>을 기준으로 제시된 개념이다. 도메인으로부터 &quot;들어오고(In)&quot;, &quot;나가고(out)&quot;로 <u>오해하지 말도록</u> 하자.</p>
</br>

<p>먼저 <strong>Inbound port</strong>는 아래와 같이 구성하였다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/943b5275-fb23-4f66-bf13-3be390242905/image.png" alt=""></p>
<p>도메인 영역을 정의하는 만큼, 앞서 보았던 프리젠테이션 영역의 컨트롤러 내부에 불러왔던 <strong><code>command</code></strong>객체와 <strong><code>usecase</code></strong>를 볼 수 있다.</p>
<p>Dto와 Command 객체는 Static 클래스의 매퍼를 통해 매핑해준다는 것을 확인하였고, 이 둘은 같을 수도 있고 달라질 수도 있다.</p>
<p>아래는 예시로, 토스 페이먼츠 결제 진행 시 도메인에서 불러올 command 객체이다.</p>
<pre><code class="language-ts">// tossPayments_request.command.ts

export class TossPaymentReqCommand {
  constructor(
    public paymentKey: string,
    public orderId: string,
    public amount: number,
  ) {}
}</code></pre>
<p>다음은 컨트롤러에서 불러오고, Service Class를 <strong>구현체</strong>(Implements)로 가지는 <strong>UseCase</strong>이다.</p>
<p>Typescript의 Interface를 통해 나타낼 수 있다.</p>
<p>컴파일 타임에서 추상 클래스의 역할만 온전히 해주면 되므로 런타임에 터지는 것은 문제가 되지 않는다. <span style="color:gray">(어짜피 모듈단에서 종속성 주입만 잘해주면 된다)</span></p>
<p>아래는 결제 시 진행하게 되는 유저의 행위를 추상화한 UseCase이다.</p>
<pre><code class="language-ts">export const OrderPaymentUseCaseSymbol = Symbol(&#39;Order_PaymentUseCase_Token&#39;);

export interface OrderPaymentUseCase {
  getUserInfoForPayment(userId: number): Promise&lt;RequiredUserInfoResModel&gt;;
  createTemporaryOrderTable(tempOrderReqCommand: TempOrderReqCommand): Promise&lt;RegisteredOrderIdResModel&gt;;
  requestTossPayment(tossPaymentReqCommand: TossPaymentReqCommand): Promise&lt;TossPaymentsConfirmResModel&gt;;
  cancelPayments(paymentKey: string, cancelReason: string): Promise&lt;void&gt;;
}</code></pre>
<p>컨트롤러에 생성자 주입을 가능케 하기 위한 Custom Token을 정의한뒤, interface를 통한 유즈케이스를 선언한다.</p>
<p>결제 화면에 필요한 유저 정보를 불러오기 위한 행위, 가주문을 생성하는 행위, 토스페이먼츠에 결제를 요청하는 행위, 결제를 취소하는 행위. </p>
<p>이렇게 행위에 집중한 고수준의 메서드를 생성한다.</p>
<p>또한, 앞서 알아본 것과 같이 철저히 외부 종속성을 없애기 위해 반환 타입으로 별도의 생성 모델을 가지고 매개변수로는 command를 불러온다.</p>
</br>

<p><strong>Outbound port</strong>는 인프라 영역(Mysql, Redis, Cloud, MQ, ...)을 도메인에서 사용하게 하기 위한 부분이라 하였다.</p>
<p>조금 더 직관적인 db 접근 함수가 제시될 것이다. 물론, 쿼리 문 자체가 비즈니스 로직이 되는 경우도 있다. 이러한 간단한 케이스에선 레이어의 분리는 일어나지만 같은 메서드 구현체를 가져갈 수도 있다.</p>
<p>아래는 결제에 사용되는 out port 이다.</p>
<pre><code class="language-ts">export interface PaymentDrivenPort {
  insertToTemporaryOrders(tempOrderReqCommand: TempOrderReqCommand): Promise&lt;RegisteredOrderIdResModel&gt;;
  getTempOrdersData(orderId: string): Promise&lt;TempOrderChecksResModel&gt;;
}</code></pre>
<p><code>insertToTemporaryOrders()</code> 메서드는 사실 상 유즈케이스의 <code>createTemporaryOrderTable()</code>와 일맥상통하기 때문에 와닿지 않겠지만, <code>getTempOrdersData()</code>의 경우는 다르다. </p>
<p>이는 가주문 테이블에 저장된 데이터와 실 결제 시 pg사로 부터 리턴된 데이터의 정합성을 체킹하기 위해 비즈니스 로직 내부에서 호출된다.</p>
<p>생성된 가주문 데이터를 불러오는 메서드는 오로지 주문 아이디(<code>orderId</code>)를 통해 가주문 테이블을 뒤지는 행위에 불과하고, 이는 곧 비즈니스 로직이 아닌 레포지터리 단, 즉 outbound로 빠지게 되는 것이다.</p>
</br>

<p>✌ <strong>Service</strong> 🤞</p>
<p>도메인 계층의 마지막으로 살펴 볼 영역은 <strong>&quot;Service&quot;</strong> 클래스이다. </p>
<p>위에선 Payment 로직을 예시로 들었지만, 이 아키텍처의 <strong>&quot;Service&quot;</strong>를 설명하기엔 <strong>OrderService</strong>가 매우 적절하지 않나 싶다.</p>
<pre><code class="language-ts">
// order.usecase.ts
export const OrderUseCaseSymbol = Symbol(&#39;Order_UseCase_Token&#39;);

export interface OrderUseCase {
  processOrderTransaction(userId: number, orderReqCommand: OrderReqCommand): Promise&lt;void&gt;;
}

// order.usecase.ts
export class OrderService implements OrderUseCase {

  constructor(
    private readonly orderRepository: OrderDrivenPort,
    private readonly menuStockHandleRepository: MenuStockHandlerDrivenPort,
    private readonly orderCouponsHandleRepository: OrderCouponsHandlerDrivenPort,
    private readonly orderPointsHandleRepository: OrderPointsHandleDrivenPort,
    private readonly emptyCartMenusRepository: EmptyCartMenusDrivenPort,  
    private readonly tempOrdersRepository: PaymentDrivenPort,
    private readonly userPointsRepository: UserPointDrivenPort,
    private readonly storeRepository: StoreDrivenPort,
    private readonly notificationRepository: NotificationDrivenPort,
    private readonly fcmSendRepository: FCMSendDrivenPort,
  ) {}

  public async processOrderTransaction(userId: number, orderReqCommand: OrderReqCommand): Promise&lt;void&gt; {

    // get temp-orders data for validation check!
    const tempOrdersData = await this.tempOrdersRepository.getTempOrdersData(orderReqCommand.orderNumber);

    const { couponAmount, pointAmount } = tempOrdersData;

    // coupon amount valid check!
    if (couponAmount !== orderReqCommand.useCouponAmount) {
      throw new CouponAmountMissMatchException();
    }

    // point amount valid check!
    if (pointAmount !== orderReqCommand.usePointAmount) {
      throw new PointAmountMissMatchException();
    }

    const queryRunner = dataSource.createQueryRunner();

    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 주문 생성 (주문, 주문상세, 주문메뉴, 주문메뉴옵션 엔티티 생성)
      await this.orderRepository.createOrders(userId, orderReqCommand, queryRunner);

      // 메뉴 수량 차감
      await this.menuStockHandleRepository.decreaseMenuStock(orderReqCommand, queryRunner);

      // 회원 쿠폰 삭제
      await this.orderCouponsHandleRepository.deleteUserCoupons(orderReqCommand, queryRunner);

      // 쿠폰 사용 내역 생성 (사용한 쿠폰이 하나라도 존재할 경우)
      if (orderReqCommand.userMereCouponId || orderReqCommand.userStoreCouponId) {
        await this.orderCouponsHandleRepository.insertUseCouponsLog(
          orderReqCommand.userMereCouponId,
          orderReqCommand.userStoreCouponId,
          orderReqCommand.orderNumber,
          queryRunner
        );
      }

      // 회원 포인트 차감
      if (orderReqCommand.usePointAmount &gt; 0) {
        await this.orderPointsHandleRepository.decreaseUserPoints(userId, orderReqCommand, queryRunner);
      }

      // 회원 포인트 내역 생성 (포인트 금액이 0이상일 경우)
      if (orderReqCommand.usePointAmount &gt; 0) {
        const { remainPoint} = await this.userPointsRepository.getUserRemainPoint(userId);
        await this.orderPointsHandleRepository.insertUserPointsLog(userId, orderReqCommand.storeId, orderReqCommand.usePointAmount, 1, remainPoint, queryRunner);
      }

      // 장바구니 비우기
      await this.emptyCartMenusRepository.emptyCartMenus(orderReqCommand, queryRunner);

      // notification
      const storeName = await this.storeRepository.findStoreName(orderReqCommand.storeId);
      const notificationTitle = &quot;주문&quot;;
      const notificationBody = `[${storeName}] 결제가 완료되었어요. 사장님이 주문을 곧 접수할 예정이에요.`;

      const notificationTokenEntity = await this.notificationRepository.getExistsDeviceToken(userId);

      // send-notification
      if (notificationTokenEntity &amp;&amp; notificationTokenEntity.notificationToken) {
        // save-notification
        await this.notificationRepository.saveNotification(notificationTokenEntity.notificationTokenId, notificationTitle, notificationBody, queryRunner);

        await this.fcmSendRepository.sendToFirebase(
          notificationTokenEntity.notificationToken,
          notificationTitle,
          notificationBody,
          &#39;ic_order&#39;,
        );
      }

      await queryRunner.commitTransaction();
    } catch (err) {
      await queryRunner.rollbackTransaction();      
      throw err;
    } finally {
      await queryRunner.release();
    }
  }
}</code></pre>
</br>

<p>정말 재밌는 경우이다. </p>
<p>&quot;주문 생성 api&quot;에서 <u>유저의 행위</u>는 무엇일까? 그렇다, 그냥 주문 전체를 진행하는 것이다. 하지만, 이러한 <strong><em>&quot;주문 전체&quot;</em></strong> 는 굉장히 많은 부가적 처리를 동반한다.</p>
<p>추후, 주문 프로세스를 다루는 포스팅에서 더 자세히 얘기하겠지만 단순 주문서를 생성하는 것을 넘어 &quot;메뉴 차감&quot;, &quot;쿠폰 사용 처리&quot;, &quot;포인트 차감&quot;, &quot;장바구니 비우기&quot;, &quot;푸시 알림 처리&quot; 등등... 많은 추가적 로직을 요구하게 된다.</p>
<p>그리고 데이터 일치성및 정합성을 위해 이는 무조건 <strong>&quot;하나의&quot;</strong> 논리적인 단위(Transaction)에서 일어나야 하며, 서비스 클래스에서 트랜잭션을 구축하게 된다.</p>
<p>서비스와 서비스 끼리 통신하는 경우는 없어야 하며 <strong><code>OrderService</code></strong> 내부 생성자에서 볼 수 있는 것과 같이, 하나의 서비스는 여러개의 포트를 불러올 수 있고, 이는 Order 도메인 뿐만 아니라 외부의 도메인에서 정의한 포트 또한 상관없이 불러올 수 있게 하였다.</p>
<p>💢<span style="color:green">도메인 서비스 클래스에서의 트랜잭션 사용 및 외부 도메인 모듈의 함수 호출에 대해선 의문이 생길 수도 있을 것이다. 이는 조금 있다가 다시 얘기해보고자 한다.</span>💢</p>
</br>

<p>마지막으로 눈여겨 볼 부분은 <strong>&quot;<code>@Injectable()</code>&quot;</strong>의 <span style="color:red">부재</span>이다. </p>
<p>사실 부재?는 아니고 내가 제거한 것이다. 물론, 이는 굉장히 <strong>민감한 주제</strong>일 수 있다고 본다. 지금부터 전달할 내용은 절대 옳고 그름을 말하는 것이 아닌, 이러한 설계를 하게 된 그 당시와 지금의 &quot;나의 생각&quot;임을 미리 말씀드린다.</p>
<p><span style="color:green"><strong>&quot;도메인에서 NestJS 자체의 종속을 없애자는 제스쳐가 아니다&quot;</strong></span></p>
<p>DDD(Domain Driven Design)혹은 헥사고날 관점에서 항상 제시되는 내용중 하나로 <em>&quot;서비스 클래스는 어떠한 외부 모듈 혹은 프레임워크를 알아서 안된다&quot;</em> 란 내용을 본 적이 있을 것이다.</p>
<p>이는 서버에 어떤 외부 종속성이 개입되더라도 순수한 비즈니스 도메인 계층을 오로지 도메인 관점에서 지키자는 것으로 해석할 수 있다. </p>
<p>하지만 사실 상<span style="color:gray"> (사실 상이라기 보다 나의 개발 능력의 현재 한계라 봐도 무방할 것이다...)</span> 외부 종속성을 완벽히 걷어내는 것은 불가능 하였다.  </p>
<p>이런 상황에서 내가 굳이 NestJS에서 제공하는 클래스의 DI 상태를 가능케 하는<strong><code>@Injectable()</code></strong>을 걷어낸 이유는 무엇이였을까?</p>
<p>솔직히 말해서 일부는 아키텍처의 모습을 갖추기 위한 제스처임을 부정할 수 없을 거 같다. 하지만, 이렇게 서비스 클래스를 <strong>&quot;Plain of Typescript Class&quot;</strong>로 만듦으로써 클래스의 복잡한 의존성을 온전히 <strong>Module</strong>단에 맡길 수 있었다. </p>
<p>물론, 이렇게 될 시 모듈단에서 추가적인 클래스의 의존성 주입 처리를 해줘야하기에 부담이 될 수 있다. </p>
<p>그러나 이는 돌이켜보면 &quot;막노동(좋은 말로 수작업)?&quot;이 요구되는 것 이외엔 모두 <strong>&quot;이점&quot;</strong>으로 다가왔다. 어짜피, 시스템 아키텍처의 흐름 상 서비스 끼리 서로가 서로를 불러오는 상황은 없으므로 순환 의존성 에러는 발생하지 않고, 직접 커스텀 프로바이더를 생성하는 수작업이 오히려 애플리케이션의 정확성을 증가시키는 행위가 되었다.</p>
<p>글이 길어진다. 조금 빨리 진행해보자.</p>
</br>

<h3 id="infrastructure">&gt; <code>infrastructure</code></h3>
<p>포트-어댑터 중 <span style="color:red"><strong>&quot;어댑터&quot;</strong></span>가 선언 될 영역이다. 즉, port의 구현체(implements)라고 할 수 있다.</p>
<p>흔히 우리가 레포지터리 계층으로 보는 부분이 정의된다 생각하면 편하다.</p>
<p>하지만, 나의 경우는 이를 좀 더 <strong>구체화</strong> 시켰다. </p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/d562fff4-1d0c-49d4-9ad1-d4d45c694c43/image.png" alt=""></p>
<p>인프라스트럭쳐 계층에선 redis, mysql(typeorm...), aws (s3...), firebase, MQ(Rabbitmq...) 등의 인프라 영역을 다룬다. 흔히, 해당 영역에서 제공하는 api 함수를 적극 사용해 C, R, U, D의 작업을 수행하게 된다.</p>
<p>현재 진행중인 서버 to 서버간의 통신을 위한 Messsaging Queue를 제외한 위의 나머지 4가지를 인프라 영역으로 사용하였다. 그 중에서도 사실 상 가장 많이 사용된 부분은 RDBMS (Mysql)과의 데이터 소통을 위한 orm(typeorm)기반의 구현체 작성이었고, 아주 간단히 알아보도록 하자.</p>
<p>먼저 구현체를 작성하기 전, <strong>interface</strong>와 <strong>repositories</strong>를 잠깐 살펴보자.</p>
<p><strong><code>src &gt; common &gt; infrastructure</code></strong>에서 정의한 베이스 인터페이스, 베이스 클래스를 impl 및 extends 받은 인터페이스와 레포지터리 클래스라 보면 된다.</p>
<pre><code class="language-ts">
// typeorm.repository.interface.ts
import { DeepPartial, DeleteResult, FindManyOptions, FindOneOptions, FindOptionsWhere, InsertResult, ObjectId, QueryRunner, SelectQueryBuilder, UpdateResult } from &quot;typeorm&quot;;
import { QueryDeepPartialEntity } from &#39;typeorm/query-builder/QueryPartialEntity&#39;;

export interface BaseInterfaceRepository&lt;T&gt; {
  create(data: DeepPartial&lt;T&gt;): T;
  createMany(data: DeepPartial&lt;T&gt;[]): T[];
  save(data: DeepPartial&lt;T&gt;): Promise&lt;T&gt;;

  // 생략 ...

  createQueryBuilder(alias: string, queryRunner?: QueryRunner): SelectQueryBuilder&lt;T&gt;;
}

// typeorm.abstract.repository.ts
export abstract class BaseAbstractRepository&lt;T&gt; implements BaseInterfaceRepository&lt;T&gt; {
  private entity: Repository&lt;T&gt;;

  protected constructor(
    entity: Repository&lt;T&gt;
  ) {
    this.entity = entity;
  }

  readonly manager: EntityManager;

  public async save(data: DeepPartial&lt;T&gt;, options?: SaveOptions): Promise&lt;T&gt; {
    return await this.entity.save(data, options);
  }

  // 생략 ...

  public createQueryBuilder(alias?: string, queryRunner?: QueryRunner): SelectQueryBuilder&lt;T&gt; {
    return this.entity.createQueryBuilder(alias, queryRunner);
  }

}</code></pre>
</br>

<p><strong>interfaces</strong></p>
<pre><code class="language-ts">export interface OrdersRepositoryInterface extends BaseInterfaceRepository&lt;Orders&gt; {}</code></pre>
</br>

<p><strong>repositories</strong> (Repository interface를 구현체로 가진다)</p>
<pre><code class="language-ts">export const TypeOrmOrdersRepositorySymbol = Symbol(&#39;Typeorm_Orders_Token&#39;);

export class TypeOrmOrdersRepository extends BaseAbstractRepository&lt;Orders&gt; implements OrdersRepositoryInterface {
  constructor(
    @InjectRepository(Orders)
    private readonly ordersRepository: Repository&lt;Orders&gt;
  ) {
    super(ordersRepository);
  }
}</code></pre>
<p>레포지터리 클래스에서는 별도의 메서드를 생성하지 않도록 한다. 그리고 UseCase와 같이 추후 커스텀한 클래스 의존성을 가능케 하기 위해 토큰을 함께 생성한다.</p>
</br>

<p>🤞 <strong>adaptor</strong> ✌</p>
<p>이제 어댑터이다. Outbound port의 어댑터라 할 수 있으며, 특정 db에 데이터를 저장 및 관리하는 직접 적 쿼리 함수가 정의된다.</p>
<p>(로직의 내용은 지금 중요하지 않으니 구조 위주로만 봐주셔도 됩니다, 대부분의 과정을 생략하였습니다)</p>
<pre><code class="language-ts">// orders_driven.adaptor.ts

export class OrderMySqlAdaptor implements OrderDrivenPort {
  constructor(
    @Inject(TypeOrmOrdersRepositorySymbol)
    private readonly ordersRepository: TypeOrmOrdersRepository,

    // 생략 ... 

    @Inject(TypeOrmCartMenuOptionsRepositorySymbol)
    private readonly cartMenuOptionsRepository: TypeOrmCartMenuOptionsRepository,
    @Inject(TypeOrmPaymentLogsRepositorySymbol)
    private readonly paymentLogsRepository: TypeOrmPaymentLogsRepository,
    private eventEmitter: EventEmitter2,
  ) {}

  public async getOrderById(orderId: number): Promise&lt;OrderDataByChecking&gt; {
    const order = await this.ordersRepository
      .createQueryBuilder(&#39;order&#39;)
      .select([
        &#39;order.orderId&#39;
      ])
      .where(&#39;order.orderId = :orderId&#39;, { orderId })
      .getOne();

    return order;
  }

  public async createOrders(userId: number, orderReqCommand: OrderReqCommand, queryRunner: QueryRunner): Promise&lt;void&gt; {
    try {
      const newOrder = await this.createOrder(userId, orderReqCommand, queryRunner);
      const { orderId } = newOrder;

      await this.createOrderDetails(orderId, orderReqCommand, queryRunner);

      // 생략 ...

      const store = await this.getStoreData(orderReqCommand.storeId);

      this.eventEmitter.emit(&#39;order.completed&#39;, store);

      await this.changePaymentStatusToCompleted(orderReqCommand.orderNumber);
    } catch (err) {
      throw err;
    }
  } 

  // 생략 ...

  private async createOrderMenuOption(orderMenuId: number, cartMenuOptionEntity: any, queryRunner: QueryRunner): Promise&lt;void&gt; {
    const { detailOptionId, ...cartMenuOptionData } = cartMenuOptionEntity;

    await this.orderMenuOptionsRepository
      .createQueryBuilder(&#39;orderMenuOption&#39;, queryRunner)
      .insert()
      .values({
        ...cartMenuOptionData,
        orderMenu: { orderMenuId },
        detailOption: { detailOptionId },
      })
      .execute();
  }
}</code></pre>
<p>주문 생성을 위해 데이터베이스에게 일련의 명령을 전달하는 구현체(어댑터)이다.</p>
<p>주문을 생성하는 과정에선 주문 테이블, 주문 상세 테이블, 주문 메뉴 테이블, 주문 메뉴 옵션 테이블, 장바구니 테이블 등 여러 테이블로의 쿼리 함수가 실행될 필요가 있었고 이러한 작업을 위의 <strong><code>OrderMySqlAdaptor</code></strong>에서 정의하게 되었다.</p>
<p>이 역시 Service 클래스와 마찬가지로, <strong><code>@Injectable()</code></strong> 데코레이터를 주입하지 않으며 모듈단에 해당 의존성 관련 책임을 전가 시키게끔 하였다.</p>
</br>

<h3 id="application-module">&gt; <code>application</code> (<code>Module</code>)</h3>
<p>모듈단은 생각보다 중요하고, 동시에 지옥이 펼쳐질 수도 있다. </p>
<p>하지만 이는 내가 지향하고 설계한 아키텍처를 위해 감당해야할 trade-off 이며 지금에서는 크게 어려움이 없지 않나 싶다.</p>
<p>아래는 <strong><code>OrderModule</code></strong> 클래스이다. 길어보여도 거의 대부분이 생략된 코드이다. 어쩔 수 없다. 각 도메인마다 모듈은 하나로 두게 되고, 결제 및 주문 생성, 취소 등 이 모든 작업에 대한 <strong>provider 주입</strong>을 이 내부에서 취해주어햐 하기 때문이다.</p>
<pre><code class="language-ts">// order.module.ts

----------------------------------------------------
export const tempOrdersToSqlAdaptorProviders = [
  {
    provide: TypeOrmTempOrdersRepositorySymbol,
    useClass: TypeOrmTempOrdersRepository,
  }
];

export const ordersToSqlAdaptorProviders = [
  {
    provide: TypeOrmOrdersRepositorySymbol,
    useClass: TypeOrmOrdersRepository,
  }
];

// 생략 ...
----------------------------------------------------

export const orderProcessorToDomainProviders = [
  OrderMySqlAdaptor,
  MenuStockHandlerMySqlAdaptor,
  OrderCouponsHandlerMysqlAdaptor,
  OrderPointsHandlerMysqlAdaptor,
  EmptyCartMenusMySqlAdaptor,
  UserPointsMySqlAdapter,
  {
    provide: OrderUseCaseSymbol,
    useFactory: (
      orderRepository: OrderDrivenPort,
      menuStockHandleRepository: MenuStockHandlerDrivenPort,
      orderCouponAndPointHandleRepository: OrderCouponsHandlerDrivenPort,
      orderPointsHandleRepository: OrderPointsHandleDrivenPort,
      emptyCartMenusRepository: EmptyCartMenusDrivenPort, 
      tempOrdersRepository: PaymentDrivenPort,
      userPointsRepository: UserPointDrivenPort,
      storeRepository: StoreDrivenPort,
      notificationRepository: NotificationDrivenPort,
      fcmSendRepository: FCMSendDrivenPort,
    ) =&gt; {
      return new OrderService(
        orderRepository,
        menuStockHandleRepository,
        orderCouponAndPointHandleRepository,
        orderPointsHandleRepository,
        emptyCartMenusRepository,
        tempOrdersRepository,
        userPointsRepository,
        storeRepository,
        notificationRepository,
        fcmSendRepository,
      );
    },
    inject: [
      OrderMySqlAdaptor, 
      MenuStockHandlerMySqlAdaptor, 
      OrderCouponsHandlerMysqlAdaptor, 
      OrderPointsHandlerMysqlAdaptor,
      EmptyCartMenusMySqlAdaptor,
      PaymentMySqlAdaptor,
      UserPointsMySqlAdapter,
      StoreMySqlAdaptor,
      NotificationMySqlAdaptor,
      FCMSendAdaptor,
    ]
  }
];

// 생략 ...
----------------------------------------------------

@Module({
  imports: [
    TypeOrmModule.forFeature([
      Users,
      TempOrders,
      Orders,

      // 생략...

      UserNotificationTokens,
      UserNotifications,
    ]),
    SharedModule,
    EventEmitterModule.forRoot(),
  ],
  controllers: [
    PaymentProcessorController,
    OrderProcessController,

    // 생략 ...
  ],
  providers: [
    ...userToSqlAdaptorProviders,
    ...storeToSqlAdaptorProviders,
    ...tempOrdersToSqlAdaptorProviders,
    ...ordersToSqlAdaptorProviders,

    // 생략 ...

    ...orderProcessorToDomainProviders,
  ],
})
export class OrderModule {}</code></pre>
<p>클래스 단위의 <strong><code>@Injectable()</code></strong>을 거둬낸 만큼 standard한 방식이든, custom한 방식이든 직접 모듈내에서 sql 수준의 어댑터에 대한 프로바이더와 도메인 클래스에 대한 프로바이더를 정의해야 했다. </p>
<p>특히 서비스에 대한 프로바이더를 정의할 땐, <strong><code>useFactory</code></strong>를 통한 동적 프로바이더 생성이 필요하였고, 이는 상당히 귀찮은 작업이었지만 개발단계에서 해당 클래스가 사용될 경우에만 동적으로 주입해줌에 따라 오히려 쉽게 Service를 적용시킬 수 있었다.</p>
<p>앞서 outbound port를 소개할 때, 외부 모듈의 out port를 서비스 클래스에서 받아옴에 따라 외부 모듈에서 정의한 infra 구현체의 함수를 사용할 수 있다고 하였다. </p>
<p>위의 동작을 가능케 하기 위해, 결국 사용할 모듈에서도 외부 모듈에서 정의한 <code>sqlAdaptor</code>에 대한 프로바이더를 생성해줄 필요가 있는데, 이는 외부 모듈에서 선언한 것을 그대로 불러옴을 허용함에 따라 코드의 반복을 막도록 하였다.</p>
<p>즉, <strong><code>OrderModule</code></strong>에서 아래의 adaptor를 사용한다고 하면</p>
<pre><code class="language-ts">export const userToSqlAdaptorProviders = [
  {
    provide: TypeOrmUserRepositorySymbol,
    useClass: TypeOrmUserRepository,
  }
];</code></pre>
<p>이것을 한번 더 만들어주는 것이 아닌 <strong><code>UserModule</code></strong>에 이미 선언되어 있으므로 <u>그대로 불러오면 된다</u>.</p>
</br>

<h2 id="좋은-설계를-한-것이-맞을까">좋은 설계를 한 것이 맞을까?</h2>
<p>글의 진행 상 아키텍처를 구상하면서 겪은 많은 과정들을 생략하게 되어 아쉽다. 글을 마무리 하기 전에 몇 가지 키워드를 통해 생각을 말해보고자 한다.</p>
</br>

<h3 id="폴더와-파일이-굉장히-많아졌다">&gt; 폴더와 파일이 굉장히 많아졌다.</h3>
<p>부정할 수 없는 사실이다. 도메인을 하나 생성할때마다, 포트를 하나 뜷을때마다, 부가적인 파일들이 항상 따라다니며 추가되었고 이는 단순 시간 측면에서의 비용을 늘게 했다해도 과언이 아니다. </p>
<p>하지만 대부분의 로직을 마무리 한 이 시점에서의 생각은 만족스러운 trade-off 였다고 본다. 도메인이 늘어나고, 다루는 로직이 점점 늘어날 수록 부담스러울 정도로 분리된 이 구조가 로직을 tracking 하는데 굉장히 이점을 주었다. </p>
<p>작성한지 오래된 로직을 찾아야 하거나 혹은 수정해야 할 경우, 특정 메서드의 자취를 잘 짜여놓은 아키텍처의 흐름에 맞게 찾아갈 수 있게 되어 매우 좋았다. </p>
<p>프리젠테이션부터 도메인을 거쳐 인프라스트럭처 계층까지. 또는 그 반대로 인프라스트럭처부터 도메인을 거쳐 프리젠테이션 계층까지. </p>
<p>모든 케이스에서 데이터 흐름을 전달하는 메서드의 흐름은 딱 저 두 가지였고, 중구남방 여러 도메인의 서비스 계층을 왔다갔다하는 이전의 아키텍처에 비해 훨씬 좋은 추적을 가능케 하였다.</p>
</br>

<h3 id="모놀리식-구조이다-msa의-플로우에-신경쓸-필요는-없다">&gt; 모놀리식 구조이다. <code>MSA</code>의 플로우에 신경쓸 필요는 없다.</h3>
<p>진행중인 서비스가 웹 서버와 앱 서버로 분리가 되어있고, 이 두 서버가 통신을 한다는 측면에서 MSA의 흐름을 고려해야하나?라 생각할 뻔하였지만 특정 기능을 제외한 (주문 통신) 나머지에 있어선 온전히 모놀리식 구조였고 도메인 간의 일체 이동을 금할 필요는 없었다. </p>
<p>도메인 간의 이동이라 해서 서비스 계층간의 이동이란 뜻은 절대 아니고, 앞서 outbound port 영역에서 살펴본 것과 같이 인프라 영역은 철저히 여러 모듈의 도메인에서 공통적으로 사용할 수 있게 끔 허용한다는 것이다.</p>
<p>즉, <strong><code>UserMySqlAdaptor</code></strong>에서 정의한 <code>findByUserId()</code>란 메서드를 <strong><code>UserDrivenPort</code></strong>를 통해 <strong><code>OrderService</code></strong>에서 사용가능하게끔 한다는 것이다.</p>
<p>과유불급이라 하였다.</p>
<p>모놀리식 구조로써 가져갈 수 있는 아키텍처에 최선을 다해도 충분하다 본다. </p>
</br>

<h3 id="sinkhole-anti-pattern싱크홀-안티패턴">&gt; <code>Sinkhole Anti Pattern(싱크홀 안티패턴)</code></h3>
<p>싱크홀 안티패턴이란 말을 들어보았는가? </p>
<p>요청이 한 계층에서 다른 계층으로 이동할 때 특정 계층이 아무런 비즈니스 로직도 처리하지 않은 채 그냥 다음으로 전달을 하는 경우를 말한다.</p>
<p>내 코드에서도 간헐적으로, 아니 그 이상으로 일어나는 상황이다.</p>
<pre><code class="language-ts">// likeStore.service.ts

export class LikeStoreService implements LikeStoreUseCase {
  constructor(
    private readonly likeStoreRepository: LikeStoreDrivenPort,
  ) {}

  public async getLikeStoresByPaginate(cursorPageOptionsCommand: CursorPageOptionsCommand, userId: number):  Promise&lt;CursorPageResModel&lt;LikeStoreResponseModel&gt;&gt; {
    return await this.likeStoreRepository.getLikeStoresByPaginate(cursorPageOptionsCommand, userId);
  }

  public async insertToLikeStores(storeId: number, userId: number): Promise&lt;void&gt; {
    await this.likeStoreRepository.addStoreLikeCount(storeId);
    await this.likeStoreRepository.insertStoresToUser(storeId, userId);
  }

  public async deleteFromLikeStores(storeId: number, userId: number): Promise&lt;void&gt; {
    await this.likeStoreRepository.subStoreLikeCount(storeId);
    await this.likeStoreRepository.deleteStoreFromUser(storeId, userId);
  }

  public async deleteStoreList(deleteLikeStoresReqCommand: DeleteLikeStoresReqCommand, userId: number): Promise&lt;void&gt; {
    return await this.likeStoreRepository.deleteStoresList(deleteLikeStoresReqCommand, userId);
  }
}</code></pre>
<p>&quot;가게 좋아요(찜)&quot; 기능에 대한 비즈니스 로직을 구성하는 서비스 클래스이다.</p>
<p>하지만 가만히 보면 별다른 로직을 수행하지 않은 채 그냥 out port에서 정의한 메서드를 그대로 호출하고 있다. </p>
<p>이러한 케이스를 <strong>&quot;지양&quot;</strong>하라고 하지만 동시에 완전히 피하는 것은 불가능하다 말하기도 한다.</p>
<p>실제로 비즈니스 로직이지만 단순한 sql 쿼리문 정도가 끝인 경우가 있고, 필요에 따라 infra adaptor에서 단순 crud를 넘어 일련의 기능을 처리해야할 경우도 존재한다. 굳이 서비스에서 복잡하게 불러와 처리하기 힘든 상황을 어댑터에 맡기는 것이 깔끔할 수 있기 때문이다.</p>
<p>뭐, 지금에서야 말하지만 <strong>&quot;아키텍처 숙련도&quot;</strong> 또한 이러한 싱크홀 안티패턴의 퍼센티지를 좌우한다고 본다. </p>
<p>나의 경우도, 진행이 될수록, 아키텍쳐가 온전히 나에게 들어올 수록 어댑터와 서비스 계층에서 나눠야 할 책임을 유연하게 판단할 수 있었고 꽤 괜찮은 형태를 만들어 낼 수 있었다. </p>
<p>너무 민감해하며, 아키텍처에 잡아먹히면 절대 안되지만 해당 안티패턴을 지양하기 위해 항상 고려는 해야한다 생각한다.</p>
</br>

<h3 id="예외적-상황에-너무-머리를-싸매지-말자">&gt; 예외적 상황에 너무 머리를 싸매지 말자.</h3>
<p>나는 어떤 아키텍처를 구축한 것일까?</p>
<p>누군가 <span style="color:red"><em>&quot;너는 잘못된 헥사고날 아키텍쳐를 구축했어!&quot;</em></span> 라고 하면 그건 당당히 <strong>&quot;아니다&quot;</strong>라고 말할 수 있을거 같다.</p>
<p>왜냐면 난 애초에 헥사고날 아키텍처를 구축한게 <strong>&quot;아니기&quot;</strong> 때문이다.</p>
<p>어떠한 아키텍처를 구축했어! 라고 말하는 것은 굉장히 민감한 것 같다. 그 아키텍처의 모든 규약에 대해 지켜야 할 필요가 생기고 이것이 오히려 개발 생산성의 저하로 불러올 수 있기 때문이다. </p>
<p>나는 포트 어댑터 패턴을 토대로 한 확장및 분리된 레이어드 아키텍처를 설계했다고 말할 수 있을거 같다. 그 과정에 있어 헥사고날, 그리고 DDD(Domain Driven Design)아키텍처를 참고한 것은 사실이다.</p>
<p>이번 글에선 전체 흐름을 위주로 말하였지만 추후 진행되는 기능 구현및 로직 설계의 과정에서 보면</p>
<p><em>&quot;어, 이거 그 아키텍처 위배한 거 아니야..?&quot;</em></p>
<p>라고 생각할 만한 부분들이 나타날 것이다. 하지만, 명확한 규제의 아키텍처를 따라가기엔 나조차도 너무 부족하고 현실적 차원에서 좋은 코드를 설계하는것 그 이상으로 중요한 것들이 존재하기 때문이다.</p>
<p>그 적절한 줄다리기를 하는 것은 생각보다 어렵고, <strong>&quot;서비스&quot;</strong>를 개발하는 <strong>&quot;엔지니어&quot;</strong>로써 단순 코드 한 줄, 클래스 하나를 바라보는게 전부는 아니란 생각이다.</p>
</br>

<p>사실 이 글을 누가 읽어주실지는 모르겠다. </p>
<p>매우 길게 작성했지만, 동시에 적고 싶은 내용의 40%는 적지 못한 글인 것 같기도 하다. </p>
<p>가독성을 위해 두 파트로 나눌까 생각하였지만 글의 일관성을 위해 하나로 이어쓴 점에 대해선 읽으실 분들께 심심한 사죄를 드린다...</p>
<p>마지막으로, 프로젝트 전반에 걸친 해당 아키텍처를 설계할 수 있게 끔 도와주시고 이 글을 작성할 수 있는 바탕이 되어주신 백엔드 개발자 &quot;최재형&quot;님께 감사의 말씀을 드리고 싶다.</p>
<p><a href="https://velog.io/@choicore/posts">&quot;최재형&quot;님 개인 블로그</a></p>
<p>그럼...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[내 작은 서랍 속의 커머스]]></title>
            <link>https://velog.io/@from_numpy/%EB%82%B4-%EC%9E%91%EC%9D%80-%EC%84%9C%EB%9E%8D-%EC%86%8D%EC%9D%98-%EC%BB%A4%EB%A8%B8%EC%8A%A4</link>
            <guid>https://velog.io/@from_numpy/%EB%82%B4-%EC%9E%91%EC%9D%80-%EC%84%9C%EB%9E%8D-%EC%86%8D%EC%9D%98-%EC%BB%A4%EB%A8%B8%EC%8A%A4</guid>
            <pubDate>Sat, 13 Jan 2024 07:00:32 GMT</pubDate>
            <description><![CDATA[<h2 id="반-년-만의-글이다">반 년 만의 글이다.</h2>
</br>

<p>마지막 포스팅 날짜가 언제인지 보니 작년 7월 1일이다. 저 맘때만 해도 블로그 업로딩을 이렇게 까지 쉴 줄은 몰랐었다. 뭐, 꼭 블로그 업로딩을 주기적으로 해야한다는 필요성은 없지만 이 정도 까지 늦춰질 줄이야... 핑계라면 핑계고 바쁘다면 바쁜 시간 속에 지금에서야 반년 만의 글을 써본다.<br></br></p>
<p>2023년 나의 개발적 일지를 회고식으로 작성해 볼 까 하였지만 타이밍도 늦은감이 없지 않아 있고, 가장 큰 이유는 좀 오글거려서이다. 아무튼 그렇다. </p>
<p>본격적인 글을 작성하기에 앞서 조금 뜬금 없지만 나의 velog를 잊은 시기에 생각보다 방문자가 많이 늘었다. 몇 개의 글 들엔 질문형식의 댓글도 달려있었고 그에 답변을 하는 것 또한 색다른 재밌거리였다. 방문자가 어느정도 쌓이는 대부분의 포스팅은 나의 메인스택인 <strong>Typescript</strong>와 <strong>NestJS</strong>에 관한 글이였고 조금씩 조금씩 NestJS란 프레임워크를 사용하는 분들이 늘고 있다는 생각도 해본다. </p>
<p> NestJS를 처음 시작하였을 때, (물론 나의 서칭 능력의 이슈일 수도 있지만...) 생각보다 국내 개발자 분들이 작성하신 글들이 많지 않았고 더더욱 나와 같은 초보자가 읽었을 때 <em>&quot;이 프레임워크는 도대체 어떤 느낌일까...?&quot;</em> 를 인지시켜주는 글은 찾기 힘들었다. 그래서 그 당시 무슨 생각이었는지 모르겠지만 나조차도 NestJS를 모르면서 괜히 나와 같은 입문자를 위한 &quot;성지&quot;가 될 수 있는 NestJS 블로그를 만들어야겠다고 생각하였다.  그리고 휴식기를 거친 지금부터 다시 이어나가고자 한다.</p>
<p>오래간만에 글을 쓰다보니 주저리주저리 서두가 길었고, 그럼 이제 몇 개의 포스팅으로 시리즈가 만들어질지 아직은 나조차도 가늠이 안되는 몇 개월 간의 기록을 남겨보고자 한다.</p>
</br>

<h2 id="첫-프로젝트-그리고-o2o-서비스">첫 프로젝트, 그리고 <code>o2o</code> 서비스</h2>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/84b308cd-3d7f-4a9b-8479-173a280c7035/image.png" alt=""></p>
<p>블로그 포스팅이 멈춰 진 해당 시점, 우연한 기회로 인연이 닫게 된 대학 창업팀의 IT 개발자분과 대화하는 시간을 가졌다. 비전공자이며 독학으로 서버 개발을 공부해오던 나로썬 처음 대면으로 누군가와 개발에 관해 얘기를 나눈 시간이였고 오가는 대화 속에 <strong>&quot;프로젝트&quot;</strong> 참여에 점점 가까워지고 있었다. 그 당시를 생각해보면 팀 프로젝트를 참여하는 것에 끌리는 것도 있었지만 단순 아는 사람들끼리 모여서 하는 프로젝트라기 보단, 아직은 작지만 <strong>&quot;투자&quot;</strong>를 받은 창업 사업단 이란 것에 조금 더 끌림이 있었다. 평소 스타트업이나 서비스 기획에 있어서도 관심이 많았던 나로썬 개발자로서든, 혹은 사회 초년생으로서든 한 단계 시야를 넓힐 수 있는 기회가 될 것임엔 분명하였다. </p>
<p><span style="color: gray"><em>(회사의 세부적 내용, 제휴를 맺은 매장의 규모, 투자 단계및 방향성 등에 관한 내용은 생략하도록 하겠습니다)</em></span> </p>
<p>그렇게 난 <span style="color: tomato">&quot;식음료 온라인 주문 결제 서비스&quot;</span> <strong>미리</strong>에 <strong>NestJS 서버 개발</strong>로 합류하게 되었다.</p>
</br>

<h3 id="o2o-서비스와-스마트-오더-서비스-소개">&gt; <code>o2o</code> 서비스와 스마트 오더 (서비스 소개)</h3>
<p>진행중인(아직은 마무리가 아니므로...) 플랫폼 서비스는 &quot;o2o 서비스&quot;, 조금 더 명확하겐 <strong>&quot;스마트 오더 서비스&quot;</strong>이다. 유저가 앱을 통해 특정 매장의 식음료 제품을 <em>결제-주문</em> 한 뒤, 매장에서 해당 제품을 수령하는 서비스이다. </p>
<p>아마 이 글을 읽으시는 분들은 바로 알아차리시겠지만,  이 서비스엔 유저와 마주하게 되는 <strong>&quot;앱&quot;</strong>이 존재해야 할 것이고, 주문을 접수하게 되는 매장의 <strong>&quot;포스기(웹)&quot;</strong>가 존재해야 할 것이다.  </p>
<p>이에 따라 개발 팀은 &quot;<u>앱 클라이언트 - 앱 서버</u>&quot;, &quot;<u>웹 클라이언트 - 웹 서버</u>&quot;로 분리해 진행하게 되었다 (+추가로 어드민 페이지 또한 존재한다). </p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/9f44ae86-2694-495a-a5f8-28eec40be325/image.png" alt=""></p>
<p>이 중 난 <strong>&quot;앱 서버 개발&quot;</strong> 파트를 맡게 되었고, <strong>NestJS</strong> 프레임워크로 진행하게 되었다. </p>
</br>

<h3 id="어떠한-기능이-요구되는가">&gt; 어떠한 기능이 요구되는가</h3>
<p>지금에서야 옅은 미소라도 띌 수 있지만 처음 프로젝트를 맡을 당시엔 어느정도 무서움도 존재하였다. 실제 유저가 사용하게 될 서비스, 결제가 진행되는 서비스 ... 이런 점을 다 떠나서 초기 안으로 구축된 와이어프레임(db, 피그마 등...)을 보았을 때 이걸 내가 만드는게 가능한가에 대한 의문이었다. <span style="color:gray">(조금 tmi로 얘기하자면 배포까진 아니더라도 투자처에서 요구하는 시연 테스트 <em><strong>마감 기한</strong></em> 이 존재하였다)</span></p>
<p>앱 서버 개발을 나와 다른 한 분 이렇게 2명이 참여하게 되었지만 인원 수의 부족으로(여러 사정으로 인원을 모집하는 것에 제약이 있었다) 사실상 초기 멤버로 와이프레임 및 디비 설계를 담당하신 앱 서버 파트너 분 께선 디비 설계 및 구축에 시간을 더 많이 할애할 수 밖에 없었다. </p>
<p>이에 따라 앱 서버 개발에 대한 베이스 구축 및 전반적 API 개발을 담당하게 되었고, 구현해야 할 기능은 대표적으로 아래와 같았다. (정말 눈에 보이는 기능에 대해서만 기술하자면 아래와 같다)</p>
</br>

<ul>
<li><p>회원가입 및 인증 처리를 통한 로그인 프로세스 구현</p>
</li>
<li><p>위치 정보 설정 및 탐색에 따른 조회</p>
</li>
<li><p>무한스크롤을 구현하기 위한 페이징 api 설계및 구현</p>
</li>
<li><p>매장 및 메뉴 등 유저에게 제공해야 할 정보에 대한 조회 api 구현</p>
</li>
<li><p>장바구니 설계부터 pg사 연동을 통한 결제 시스템 도입 및 주문 프로세스 api 구현 </p>
</li>
<li><p>웹 서버와의 서버 to 서버 소통을 통한 주문 접수/취소/물품 수령 기능 구현</p>
</li>
<li><p>FCM(Firebase Cloud Messaging)을 활용한 푸시 알림 서비스 기능 구현</p>
</li>
<li><p>리뷰 생성/수정/삭제 기능 구현
....
....</p>
</li>
</ul>
</br>

<p>포괄적으로 구현해야 할 기능을 정의한다면(API 중심) 위와 같이 말할 수 있을 거 같다. 하지만 이 글을 보시고 계시는 서버 개발자 분들은 아시겠지만... 위의 기능 구현 하나하나에 더 많은 <strong>세부적 장치</strong>들이 가지를 뻗으며 필수적으로 요하게 된다. 이에 대해선 아래에서 더 자세히 언급하도록 하겠다.</p>
</br>

<h3 id="실전이다-api-설계만이-끝이-아니다">&gt; 실전이다, api 설계만이 끝이 아니다.</h3>
<p>다음 내용에서 조금은 더 &quot;개발적&quot; 접근에서 얘기를 하겠지만, <span style="color:green">기획적 측면</span>에서 접근하더라도 실제 운영될 목적으로 설계되고 만들어져야 할 서비스에선 api 설계 그 이상으로 중요시 생각되어야 할 여러 이슈 및 프로세스들이 존재하기 마련이다. 지금에서야 <em>&quot;존재하기 마련이다.&quot;</em> 라고 하였지만 규모 있는 프로젝트에 처음 도전하는 나로써는 그 당시 이 모든 것들이 미지와의 만남이었다. </p>
<p>무엇을 얘기하는 것일까? </p>
<p>간단한 예시를 들어보자면, <span style="color:green"><strong>결제-주문 프로세스</strong></span>를 구축한다고 하자. </p>
<p><span style="color:slategray"><em>&quot;pg사 연동해서 결제 처리 맡기고, 주문 로직은 트랜잭션을 통해 일관성 보장해주고... 그러면 되는 것 아니야?&quot;</em></span></p>
<p>그렇다. 위와 같은 식으로 처리될 것이다. <strong>&quot;하지만&quot;</strong> 이것이 전부는 아니고, 전부가 되어선 절대 안된다. &quot;기능 구현&quot;에 목적을 두어선 안되는 상황이기 때문이다.</p>
<p>계좌 간의 이체 등을 다루는 금융 서비스 까지는 아니더라도 우리의 앱 서비스를 통해 엄연히 <span style="color:red"><strong>&quot;금융 거래&quot;</strong></span>가 이루어진다. 돈이 오고가는 상황에서 발생하는 문제는 유저에게든 서비스 제공자에게든 불편한 상황 (규모가 클 수록 최악의 상황...)으로 이어지게 된다. </p>
<p>이에 따라 실주문 이전, 쿠폰및 포인트 그리고 최종 금액의 데이터 일치성을 체킹하기 위한 <span style="color:green"><strong>&quot;가주문&quot;</strong></span> 생성이 선수될 필요가 있었다. </p>
<p>더하여, 메인 결제-주문 프로세스 하나를 좋은 설계로 잘 구축하였다고 해서 이것 하나만을 믿기엔 불안하지 않을까 생각하였다. 어떤 이유로든지 분명히 메인 프로세스가 터질 수 있다고 생각을 하였고, 결제는 이루어졌지만 주문이 들어가지 않은 상황에 대한 체킹을 할 필요가 있었다. 하지만 이는 클라이언트와의 소통으로 이루어지는 것이 아니므로 별도의 <span style="color:green"><strong>&quot;주문서 누락 체킹 배치 프로세서&quot;</strong></span>란 안전장치를 요하게 되었다. </p>
<p>내가 이 프로젝트 유지 보수를 할지, 이 앱 서비스에 어느 정도의 유저가 모일지 이 모든 것은 미지수이지만 그러한 상황과, 더하여 <u>나의 능력과는 관계없이</u> <strong>&quot;엔지니어&quot;</strong>로써 이는 <strong>당연히 취해야 할 제스처</strong>라고 생각하였고 지금도 변함이 없다.</p>
</br>

<h3 id="추가-서비스를-만드는건-프로그래밍만이-아니다">&gt; (+추가) 서비스를 만드는건 프로그래밍만이 아니다.</h3>
<p>(거의 다 완성이 되어가는 이 시점에서)프로젝트를 진행하며 프로그래밍 외적으로 나에게 생각보다 큰 메시지로 다가왔던 것은 &quot;<u>서비스 개발을 위한 각 팀의 조화로운 균형과 진정성</u>&quot;이었다. </p>
<p>앞서 언급하였지만 아무래도 학생 창업단 규모의 프로젝트이다 보니 비용의 한계에 따른 문제들이 많았다. (이는 어쩔 수 없는 문제였다) 기획, 디자이너, 개발자가 &quot;하나&quot;가 되어 이 앱에 몰두하는 것이 사실 상 힘들었다. 자체 서비스에 대한 기획은 정해져있었지만 &quot;앱(App) API&quot;에 대한 기능 명세 정의, 사용할 서드 파티(pg사, 소셜 api 등)에 대한 세부적 명세, 그리고 명확한 화면 정의서가 부족하였다. </p>
<p>이에 따라 자연스럽게 개발을 진행하며 애플리케이션 기능 정의에 대한 기획을 동시에 진행하게 되었고 코드를 작성하는 개발 외적으로 신경써야할 부분들이 항상 선수되었다. 비슷한 서비스를 제공하는 다른 <strong>o2o</strong> (스마트 오더) 커머스 플랫폼을 벤치마킹하는 시간을 가져보았으며 여러 기업 사내 블로그를 엄청 많이 뒤졌던거 같다. </p>
<p>서버 개발에 온전히 집중하지 못하였다? 이 뜻으로 말한 것은 전혀 아니다. 오히려 좋은 경험이었다고 본다. 나의 첫 회사가 어떤 도메인 될 지는 모르겠지만 뭐가 되었건 다가올 도메인에 익숙해지는데 오늘의 경험이 큰 도움이 될거라 생각한다. </p>
<p>아무튼, 말하고 싶었던 건 &quot;기획&quot;, &quot;디자인&quot;, &quot;클라이언트 및 서버 단 개발&quot; 등 이러한 모든 과정들이 좋은 방향으로 조화를 이루어 나아갈 때 비로소 <span style="color:red"><strong>&quot;하나&quot;</strong></span>의 괜찮은 서비스가 만들어진다고 확신적으로 느끼게 되었다. </p>
</br>

</br>

<h2 id="서버-개발자로서의-나를-돌아보자">서버 개발자로서의 나를 돌아보자</h2>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/f57a4838-3ce5-44f3-b89c-ece4f8d77b5f/image.png" alt=""></p>
<p>위에선 조금은 서비스 자체에 대한 고찰및 환경적 요소에 대해 얘기를 나눠보았다면 지금부턴 온전히 &quot;서버 개발자&quot;로서의 얘기를 풀어보고자 한다.</p>
<p><span style="color:gray">(세세한 기술적 내용은 다음 포스팅부터 순차적으로 진행할 것이므로 깊게는 들어가지 않겠습니다)</span></p>
<h3 id="nestjs는-그저-도구다">&gt; <code>NestJS</code>는 그저 도구다.</h3>
<p>따지고 보면 더 중요한 것은 typescript, javascript가 될 수도 있겠지만 서버 전체 프로젝트의 근간이 되는 NestJS 프레임워크가 엔지니어링 단계에 있어서 메인 포인트라 할 수 있었다. 글의 서두에 언급하였다시피 여태 독학으로만 NestJS를 공부하였던 나에게 하나의 완성형 서비스를 구축하란 것은 두려움으로 다가온 건 사실이다. 물론, 유데미에서 NestJS 강의를 하나 듣긴 하였지만 인도영어 강의에다가 (값이 싸서 그런지...) 기능 구현 그 이상의 아무런 정보를 제공해주지 않았던 터라 사실상 정글에 던져진 채로 공부하고 있었다. (원래 그런 타입이기도 하다...)</p>
<p>그러면서 자연스래 NestJS를 공부하며 경험한 기능적 문제나 느낀 생각들에 대해 블로그에 기술하게 되었고 (단순한 문법적 설명은 블로깅하지 않는다) 이러한 작지만 의미있던 시간이 이번 프로젝트에 대한 두려움을 오로지 두려움만으로만 그치게 했던거 같다. </p>
<p>소제목에서 언급하였지만 프로젝트를 진행한 처음 시점과 달리 지금에서야 느끼는 생각은 NestJS는 그저 프레임워크일 뿐이였다. 구현해야할 기능마다, 혹은 서비스마다 상이하겠지만 나에게 있어 NestJS는 &quot;전부&quot;라기보단 <strong>&quot;이용&quot;</strong>하는 것에 가까웠다. </p>
<p>그렇다고 해서 NestJS란 프레임워크에 대해 깊이 알 필요없다? 이런 뜻은 절대로 아니다. &quot;<u>클라이언트의 요청 - 서버내 로직 수행 - 응답</u>&quot;으로 이어지는 주기 내에서 NestJS의 라이프사이클은 어떻게 진행되는가를 명확히 알필요가 있었고, 이에 따라 미들웨어의 책임을 도와주는 Enhancer(guard, pipe, interceptor, filter ...)들과 메인 로직들의 역할 분리를 이루어낼 수 있었다. 더불어, 내가 만약 커스텀한 무언가를 만들어내고 싶다면, 그리고 그것이 NestJS 혹은 내부 라이브러리의 source code를 마개조해야할 상황이 일어난다면 이 역시 프레임워크의 내부를 들여다 볼 필요가 충분히 존재하였다. </p>
<p><strong>하지만</strong>, 이렇게 <strong>&quot;NestJS&quot;</strong>란 프레임워크 자체가 제공해주는 기능들, 그리고 라이프사이클에 대한 이해도 및 인지만 깔려있다면 &quot;핵심&quot;은 NestJS 자체가 전혀 아니었다. </p>
<p><span style="color:red"><strong>&quot;백엔드&quot;</strong></span> 자체에 대한 이해도가 더 중요하지 않았나 싶다. 점점 구현해야할 기능이 커지고 도메인(물론 도메인의 단위는 작을지언정...)이 늘어날수록 오히려 NestJS에서 제시하는 기본적 Dependency Injection과 <strong><code>@Injectable()</code></strong> 데코레이터를 통한 계층간의 의존 결합도가 걸림돌이 되기도 하였다.<span style="color:gray"> (아키텍처 관련된 자세한 내용은 다음 포스팅에서 다룰 예정입니다)</span></p>
<p>Enhancer들을 제외한 비즈니스 로직에선 NestJS 의존도를 벗어나는(모듈에게 전부 위임) 설계를 진행하게 되었고 <strong>결국 중요한 것</strong>은 <u>클라이언트와 마주하게 되는 프리젠테이션 단부터 데이터를 저장-관리하는 영속성 영역까지</u> 어떻게 데이터를 매끄럽게 &quot;잘&quot; 전달할 것인가에 대한 고민이었다. 이는 프레임워크 종속적인 얘기가 아닌, 서버 아키텍처및 프로그래밍 관점 지향 전반에 걸친 고민이다. 실제로 프로젝트를 진행하면서 (물론 NestJS 관련글이 적은것도 사실이지만) Nest로 짜여진 코드의 글보다 (Java or Kotlin)Spring으로 소개된 글들을 훨씬 더 많이 참고하였다.</p>
<p>결국 프로젝트를 맞는 시점에서 나는 &quot;코더(Coder)&quot;라기보단 &quot;엔지니어&quot;에 가까워야한다 생각하였고, 언어나 프레임워크는 생산을 위해 활용하는 도구로써 충분하였다.</p>
</br>

<h3 id="클라이언트-단과의-소통-무엇보다-중요하였다">&gt; 클라이언트 단과의 소통, 무엇보다 중요하였다.</h3>
<p>API를 설계하는데 있어서 어느 부분에 가장 많은 시간과 두뇌를 사용했는가?</p>
<p>orm을 통한 crud 쿼리문, 그리고 코드 한 줄, 한 줄 물론 중요한 것임에 분명하지만 사실 이건 어쩌면 서버 개발자 만의 온전한 책임이고 좋은 플로우만 만들어졌다면 문제없이 손가락 노동을 통해 진행될 수 있다.  </p>
<p>그렇다, 중요한 것은 API 설계를 위한 &quot;클라이언트 to 서버&quot;간의 <span style="color:green"><strong>&quot;플로우(flow)&quot;</strong></span> 설계였다. </p>
<p>앱은 유저가 직접적으로 마주하게 되는 서비스이다. <span style="color:green"><strong>&quot;유저 경험(UX)&quot;</strong></span>은 클라이언트와 조금은 더 가까운 얘기일 수 있겠지만 (pm이 따로 존재하지 않는 기획에선) 서버 개발자역시 UX에 민감해야한다 판단하였고 지금도 그렇게 생각한다. 특히나 우리의 도메인에선 더더욱 그럴 필요가 있었다. </p>
<p>⨀ 가게 상세 페이지 - 메뉴 상세 페이지 - 장바구니 - 결제서 - 결제 - 주문 ⨀ 으로 이어지는 화면(UI)에서 유저가 불편함을 겪게 되는 상황을 항상 떠올리고 가정해야했고, 유저의 편안한 사용 경험을 위해 API의 응답을 수정 및 추가하는 작업은 필수적이였다. (모든 API가 원큐에 해결된 적이 단 한번도 존재하지 않았다.) </p>
<p>이러한 과정속에 클라이언트 분과 자주 소통하며 유저의 경험을 증진시키기 위한 API 플로우를 짜는 것에 많은 시간을 투자하였고, 이러한 과정이 가장 힘들면서도 동시에 가장 의미있었지 않았나 싶다. (결국 유즈케이스는 전체 개발팀이 함께 관여를 해야하는 것이 아닐까...)</p>
<p>추후 개별파트로 포스팅 하겠지만 성공및 실패 응답에 대한 통일화 및 구체화, 그리고 스웨거(Swagger)를 통한 문서화 이러한 작업에 힘을 더 많이 쓰기도 하였다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/51336460-e800-4125-b4d1-2d7a95823bd4/image.png" alt=""></p>
</br>

<h3 id="지금-더-중요한-개발은-무엇일까-클린-아키텍처의-정의는-누가-내리는-것일까">&gt; 지금 더 중요한 개발은 무엇일까, 클린 아키텍처의 정의는 누가 내리는 것일까</h3>
<p>개발을 진행하면서 내 머릿속에서 가장 많은 충돌을 불러일으켜 왔고, 선뜻 손가락을 움직이게 할 수 없었던 부분이었다. </p>
<p>아래의 코멘트들을 살펴보자.</p>
<hr>
<p><em>&quot;비지니스 로직에 있어서는 프레임워크및 라이브러리에 종속적이지 않은 설계를 하자.&quot;</em></p>
<p><em>&quot;계층간의 역할과 책임을 분리하고 디커플링 지향의 아키텍쳐를 구성하자&quot;</em> </p>
<p><em>&quot;공통된 로직을 항상 재사용성 있게 분리하도록 하자.&quot;</em></p>
<p>... ...</p>
<hr>
<ul>
<li><p>난 완벽하게 도메인으로부터 라이브러리및 프레임워크 종속성을 벗겨내었는가?</p>
</li>
<li><p>내가 작성한 아키텍쳐의 각 계층은 철저히 분리된 각자의 역할이 존재하고 이는 모든 도메인에서 공통적인가? 그리고 이는 개발 전반적으로 걸친 유명한 아키텍쳐의 설계와 유사하게 돌아가고 있는가?</p>
</li>
<li><p>도메인마다 공통적으로 사용될 함수에 대해 재사용성 있는 분리를 항상 추구하였는가?</p>
<p>  ... ...</p>
</li>
</ul>
</br>

<p>결론은 <span style="color:red"><strong>&quot;아니다&quot;</strong></span></p>
</br>

<p>그럼 난 실패한 설계를 한 것일까? </p>
<p>설령 누군가가 &quot;너의 아키텍쳐와 전반적 설계는 실패했어. 좋지 않은 사례야&quot; 할 지언정 나 스스로는 &quot;아니&quot;라고 말 할 것 같다.</p>
<p>정말로 클린 아키텍처란 무엇일까. </p>
<p>물론 어딘가에 소속되어 개발을 진행하고 있지만 난 엄연한 취준생이며 아직은 프레시한 상태이다.</p>
<p>서비스 개발을 진행하는 사람은 &quot;나&quot;이지, 클린 아키텍처 개론을 저술한 저자가 아니다. &quot;나&quot;의 개발적 능력은 스스로가 가장 잘 알고, 클린 아키텍쳐 개론이 추구하는 어쩌면 이상향적일수 있는 코드를 현 능력으론 구현이 힘들다는 것 또한 인지하고 있다. </p>
<p>이러한 상황에서 &quot;클린 아키텍쳐&quot;는 쫒아야 할 무언가가 아니라 유연하게 참고하며 받아들여야 할 방법론이라 생각이 든다. 정말 언젠가는 클린한 코드를 완벽하게 내 머릿속에 저장하고 받아들여 구현할 수 있다면 얼마나 좋을까? 하지만, 상황에 따라 중요한 것은 그것이 아닐지도 모른다는 것이다.</p>
<p>유저에게 일련의 서비스를 제공해야한다는 측면에선, 그리고 정해진 기한내에 어떠한 목표치를 구현해내야한다는 측면에선 <em>&quot;기능 구현, 성능, 등등 ...&quot;</em> 어쩌면 더 우선순위로 고려해야 할 사항들이 많다. </p>
<p>추구하고자 하는, 혹은 팀이 기준으로 정해놓은 &quot;틀&quot;에서 조금은 예외가 발생하였다고 해서 거기에 &quot;실패&quot;를 들이밀 필요는 없다고 본다. 결국 개발을 진행하는 것은 &quot;팀 멤버&quot;들이고 팀의 능력치와 비용을 고려한 설계가 최선의 설계지 않을까 싶다.</p>
</br>

<h2 id="글을-맺으며">글을 맺으며</h2>
</br>

<h3 id="💢-다음-포스팅-부턴-서버-개발의-과정을-담은-코드-위주의-글들이-올라옵니다">💢 다음 포스팅 부턴 서버 개발의 과정을 담은 코드 위주의 글들이 올라옵니다...</h3>
</br>

<h3 id="기억하지-않아도-기억날-프로젝트-입니다">기억하지 않아도 기억날 프로젝트 입니다.</h3>
<p>시발점치곤 꽤 흥미로웠고 나의 인사이트도 더욱 증가하게 된 시간이었다. 아직 서버 간 통신, 그리고 배포까지 조금의 프로세스가 남았지만 전반적 개발을 마무리하는 이 시점에서 기능을 구현했던 그 당시의 모든 모습들이 하나하나 전부 떠오르는 것 같다. </p>
<p>함께 개발을 진행하였던 열정적인 팀원분들께 고마움을 전하는 것은 물론이거니와, 아무것도 몰랐던 내가 개발자의 형태라도 낼 수 있게 많은 도움을 주신 <strong>&quot;개발바닥 2사로&quot;</strong> 선생님들께 고마움을 전달하고 싶다. 실무자분들께 직접 듣는 배움은 단순 개념적 설명을 넘어 현실적인 문제에 맞딱들였을때에 굉장히 큰 도움으로 다가왔다. </p>
<p>그럼 긴 글 읽어주셔서 너무 감사드리고... 다음 글 부턴 개발 전반적인 내용을 다뤄보도록 하겠습니다.</p>
</br>

<p>이상...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] 주문처리과정을 통해 알아보는 수익률 랭킹 조회 (Part 4_ Redis Sorted Set과 Ranking) #4]]></title>
            <link>https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-4-Redis-Sorted-Set%EA%B3%BC-Ranking-4</link>
            <guid>https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-4-Redis-Sorted-Set%EA%B3%BC-Ranking-4</guid>
            <pubDate>Sat, 01 Jul 2023 14:17:39 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>지난 시간까지 결제주문을 위한 <strong>데이터베이스 설계</strong> + <strong>서비스 로직 작성</strong> + <strong>프로세스 테스트</strong> 를 진행해보았다.</p>
<p>이번시간엔 간단하게 이를 활용해 보고자한다. </p>
<p>그것은 바로 <strong>&quot;랭킹(Ranking) 기능&quot;</strong>이다. </p>
<p>다들 알겠지만 하나의 예로 든다면 전적 검색 사이트(OPGG)나 무신사 블프기간때 볼 수 있는 유저 구매 랭킹등이 이에 해당한다.
그리고, 이러한 랭크 기능은 <strong>&quot;Redis&quot;</strong>로 구현할 수 있다는 말 또한 들어보았을 것이다. 그런데 <strong>인 메모리</strong>(In-memory) 데이터 시스템인 레디스가 어떠한 장점이 있길래 랭크 기능에서 이를 사용하는 것일까?</p>
<p>실제 우리의 서비스에 적용시켜보기 전, 먼저 랭크 기능을 위한 레디스의 특징 및 사용법등을 먼저 익히는 시간을 가져보고 그 후 적용을 시켜보고자 한다.</p>
</br>

<h2 id="💢-redis-sorted-set">💢 <code>Redis Sorted Set</code></h2>
</br>

<h3 id="sorted-set에-대해-인지하고-있는가-feat-시간-복잡도">&gt; Sorted Set에 대해 인지하고 있는가? (Feat. 시간 복잡도)</h3>
<p>랭크 기능을 구현하는데 있어, 어떠한 방식을 적용시켜줄 수 있을까? </p>
<p>그냥 <u>단순히 타입스크립트를 사용하여 이를 적용한다면</u> 아래와 같은 방법을 사용할 것 같다.</p>
<pre><code class="language-tsx">const userList = {
  a: 100,
  b: 90,
  c: 30,
  d: 58,
  e: 73,
  f: 85,
  g: 93,
  h: 3,
  i: 29,
};

const userListArray = Object.entries(userList).map(([key, score]) =&gt; ({ key, score }));

userListArray.sort((b, a) =&gt; a.score - b.score);
console.log(userListArray); // 아래에 결과</code></pre>
<pre><code>[
  { key: &#39;a&#39;, score: 100 },
  { key: &#39;g&#39;, score: 93 },
  { key: &#39;b&#39;, score: 90 },
  { key: &#39;f&#39;, score: 85 },
  { key: &#39;e&#39;, score: 73 },
  { key: &#39;d&#39;, score: 58 },
  { key: &#39;c&#39;, score: 30 },
  { key: &#39;i&#39;, score: 29 },
  { key: &#39;h&#39;, score: 3 }
]</code></pre><p>만약 이런상황에서  특정 <strong><code>key: value</code></strong> 쌍의 value 값을(위의 경우에서 score값을) 변경해야한다면, 이는 어떻게 처리해야할 수 있을까?</p>
<p>생각해보자면, 이런 방법들이 떠오를 것 같다.</p>
<p>특정 key에 해당하는 value(score)값의 변화에 따라 재정렬을 위한 함수를 실행시켜 다시 정렬하는 것이다. 혹은, 기존에 객체는 삭제시켜준 뒤 새로운 객체의 score값을 <strong><code>findIndex</code></strong>등의 메서드를 통해 배열 내의 모든 객체의 score값과 비교 뒤, 이에 따라 다시 정렬된 배열을 구하는 방법이 있을 것이다. </p>
<p>뭐, 구현 불가능한 방법은 전혀 아니지만 일반적으로 랭킹을 사용한다고 하는 것 자체가 많은 데이터(key:value 쌍)에 해당하는 score 비교를 빠르게 해주기 위함이라 생각한다. 즉, 다시 말해서 <strong>&quot;많은 데이터&quot;</strong>가 존재할 것이다. 이것은 서비스의 규모에 따라 수십, 수백만 건이 존재할 수 있다. 즉, 자바스크립트의 힙에 저장되는 일반적 배열 및 객체의 자료구조를 사용할 경우 엄청난 부담이 될 것이다. 딱히 인덱스가 지정되어있는게 아니기 때문에 수많은 비교를 거쳐야하고 결국 시간복잡도 측면에서 굉장히 저조한 성능일 것이다.</p>
</br>

<p>이러한 이유로 우리는 인 메모리 데이터 스토어인 <strong>&quot;레디스&quot;</strong>를 사용하게 되고, 그 중에서도 <code>score</code>란 새로운 개념을 사용하는 <strong>&quot;Sorted Set&quot;</strong>을 사용할 수 있다.</p>
</br>

<p><strong>✔ Set</strong></p>
<p>Sorted Set을 알기전 <strong>&quot;Set&quot;</strong>에대해 간단히 알아보자. 일반적으로 Set이란 자료구조에 대해서 설명할 때 <strong>&quot;List&quot;</strong>와 많이 비교하게 된다. List는 내부적으로 Linked List(연결 리스트)이다. 간단히 설명하자면 <strong>Linked List</strong>는 각 노드가 데이터와 포인터를 가지고 한 줄로 연결되어 있는 방식으로 데이터를 저장하는 자료구조이다. 여기서, <u>각 노드는 다음 노드를 가리키는 포인터를 포함</u>한다. 
즉, List는 삽입 순서에 따라 정렬이 된다. <strong>하지만</strong> Set은 정렬되지 않는 value의 모음이다. 즉, <u>순서를 내부적으로 유지하지 않는다는 뜻</u>이다.
Set은 이러한 이유로 아이템을 POP하거나 PUSH 할 순 없다. 물론, SADD와 SREM을 이용해서 아이템을 추가하거나 삭제하는 과정을 수행한다.</p>
<p><strong>시간 복잡도</strong>(Time Conplexity)관점에선 어떨까?</p>
<p>List의 경우는 위에서도 언급하였다시피 크기와 상관 없이 처음과 마지막에 아이템을 추가하는 것은 O(1)이 된다. 그러나 처음과 끝이아닌 중간 어딘가의 아이템을 찾는데 걸리는 시간은 O(N)이라 할 수 있다. 반면에 Set의 경우는 아이템 추가, 삭제, 확인의 작업에 <strong>O(1)의 복잡도</strong>를 가진다고 할 수 있다.</p>
<p>여기서 우린 일련의 데이터 중 <u>임의의 어딘가의 아이템을 조회</u>하기 위해선 <strong>&quot;Set&quot;</strong>을 사용하는 것이 효율적일 것이라 생각할 수 있다. (물론, Redis에서 Set을 사용하면 더 많은 연산 수행 명령어들을 제공받을 수 있다)</p>
</br>

<p><strong>✔ Sorted Set</strong></p>
<p>Redis에서 Set은 <strong>Sorted</strong> 하게끔, 즉 <strong>&quot;정렬된&quot;</strong> Set data structure를 가질 수 있다는 것에 큰 효과가 있지 않을까 싶다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/3a447167-5f8e-4cee-9f61-10aa4e3f376c/image.png" alt=""></p>
<p>Sorted Set에서의 아이템 <u>추가, 삭제, 확인</u>의 시간 복잡도는 <strong>O(1)</strong>이다. 이는 Set과 마찬가지이다. 또한, redis는 sorted set을 사용하는데 있어 <strong>&quot;<code>zadd</code>&quot;</strong>, <strong>&quot;<code>zrange</code>&quot;</strong>와 같은 명령어를 제공해준다. </p>
<p>우리는 <strong>개발자</strong>이므로 한 가지 생각을 해볼 필요가 있다. (물론, 본인 혼자 든 의문일 순 있지만... )</p>
<p>곧, 현재 진행중인 프로젝트에서 사용하기도 하겠지만 <u>랭킹 기능을 구현하는데 있어</u>서 <strong>&quot;ZRANGE&quot;</strong> 명령어를 사용하게 될 것이다. </p>
<p><strong>하지만</strong>, ZRANGE 자체는 시간복잡도가 O(1)이 <u>아니다</u>.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/98cc635b-d7ff-4b0d-8d99-0c8cc201de6a/image.png" alt=""></p>
<p>레디스 공식문서에서 확인할 수 있듯이 <strong>ZRANGE</strong>의 시간 복잡도는 <strong>&quot;O(log(N) + M)&quot;</strong>이다.</p>
<p>그렇다, 위에서도 언급하였다시피 Sorted Set의 &quot;추가, 삭제, 확인&quot;의 측면에서 시간복잡도가 O(1)인 것이다. 여기서 <strong>&quot;확인&quot;</strong>이라 함은 <u>조회의 측면이 아닌</u>, <strong>멤버 존재 여부 확인</strong>(Exists) 이다. </p>
<p>조금만, 더 깊게 들어가보자.</p>
<p><strong>Sorted Set</strong>은 내부적으로 <u>두 가지 데이터 구조로 저장</u>된다. 이에 대해서 깊게 설명하진 않겠지만 간단히 얘기하자면 메모리 절약의 측면에서 멤버 수와 바이트 길이에 따라 특정 위치까진 <strong>zip list</strong>를 활용하지만 그 위치 이상이 되는 순간 <strong>skip list</strong>를 사용한다. </p>
<p>우리가 흔히 알고있는 Sorted Set의 기능을 사용하게 되는 <u>메인 데이터 구조</u>는 바로 이 <strong>&quot;skip list&quot;</strong>이다. 앞서, 우리가 linked list에 대해 설명하면서 해당 자료 구조의 단점을 알아보았다. 이를 개선하는데서 시작한 skip list는 <strong>&quot;레벨&quot;</strong>이란 개념을 도입함으로써 데이터 탐색 시 모든 노드를 비교해야하는 최악의 상황을 해결해준다. </p>
<p>베이스 개념은 이정도 까지로 해두고, 그래서 왜 &quot;ZRANGE&quot;는 시간 복잡도 <strong>&quot;O(log(N) + M)&quot;</strong>을 가지는 것일까?</p>
<p>ZRANGE 명령어는 정렬된 순서대로 멤버를 가져오는 작업을 수행하는 명령어이다. 이 때, 가져올 범위의 시작과 끝을 지정할 수 있고, 이에 영향을 받는다. </p>
<p>ZRANGE 명령어의 시간복잡도는 <u>두 가지 요소</u>에 의해 영향을 받게된다:</p>
<blockquote>
<ol>
<li><strong>스킵 리스트의 탐색</strong>: Sorted Set의 멤버는 스킵 리스트에 저장되어 있으며, ZRANGE는 가져올 범위의 시작과 끝 위치를 찾기 위해 스킵 리스트를 탐색해야 한다. 이 이유로 스킵 리스트의 탐색은 O(log(N))의 시간복잡도를 가집니다. (N: 원소 갯수)</li>
<li><strong>가져온 멤버의 개수(M)</strong>: ZRANGE 명령어는 지정한 범위 내의 멤버를 가져오는데, 가져온 멤버의 개수에 따라 시간복잡도가 증가한다. 가져온 멤버의 개수가 적을 경우(O(M)), 스킵 리스트의 탐색에 소요되는 시간이 주된 영향을 미친다. 그러나 가져온 멤버의 개수가 많을 경우(O(log(N) + M)), 멤버의 개수에 비례하여 스킵 리스트 탐색 및 결과 반환에 소요되는 시간이 증가하기 마련이다.</li>
</ol>
</blockquote>
<p>Sorted Set이 사용하는 이 <strong>&quot;스킵 리스트&quot;</strong>는 따지고 보면 List, Set, Hashes와 다르게 저장할 수 있는 정확한 멤버수가 사실상 없다. 최대 &quot;레벨&quot;은 32로 정해져있지만 스코어 비교 시 레벨을 그대로 두고 다음 포인터로 계속 진행할 수 있으므로 최대 레벨과는 관계없이 계속해서 저장이 가능하다.</p>
<p>하지만, 시간 복잡도 측면과 &quot;메모리&quot;측면에서의 문제는 존재한다.</p>
<p>이것에 대해 고민해야할 부분은 많은 것 같다. 아직 많은 멤버 수의 데이터 리스트를 다뤄보진 않았지만, 멤버 면수가 많아진다면 &quot;삭제&quot;의 과정에서의 부하도 충분히 고려해볼 필요가 있을거 같다는 생각이 들었다. </p>
</br>

<h3 id="명령어-사용해보기">&gt; 명령어 사용해보기</h3>
<p>추후, 코드에서 사용해볼 명령어가 어떻게 동작하는지 간단히 알아보자.</p>
<pre><code>127.0.0.1:6379&gt; zadd myzip 80 &quot;math&quot;
(integer) 1
127.0.0.1:6379&gt; zadd myzip 90 &quot;english&quot;
(integer) 1
127.0.0.1:6379&gt; zadd myzip 75 &quot;chemistry&quot;
(integer) 1
127.0.0.1:6379&gt; zadd myzip 85 &quot;japanese&quot;
(integer) 1

127.0.0.1:6379&gt; zrevrange myzip 0 -1 withscores
 1) &quot;english&quot;
 2) &quot;90&quot;
 3) &quot;japanese&quot;
 4) &quot;85&quot;
 5) &quot;math&quot;
 6) &quot;80&quot;
 7) &quot;chemistry&quot;
 8) &quot;75&quot;
 9) &quot;Daegyu&quot;
10) &quot;60&quot;

127.0.0.1:6379&gt; zincrby myzip 25 &quot;chemistry&quot;
&quot;100&quot;

127.0.0.1:6379&gt; zrevrange myzip 0 -1 withscores
 1) &quot;chemistry&quot;
 2) &quot;100&quot;
 3) &quot;english&quot;
 4) &quot;90&quot;
 5) &quot;japanese&quot;
 6) &quot;85&quot;
 7) &quot;math&quot;
 8) &quot;80&quot;
 9) &quot;Daegyu&quot;
10) &quot;60&quot;</code></pre><p>우린 랭킹 기능에 위의 명령어를 사용할 것이다.</p>
<p>조금 뜬금없지만 위와 같이 score에 따라 멤버가 정의되는 정렬된 key를 조회할 수 있다는 것을 확인할 수 있음과 동시에 <strong>&quot;key:value&quot;</strong>의 깔끔한 형식의 구조가 아닌 <strong><code>[멤버, 스코어, 멤버, 스코어, ....]</code></strong>의 형식인 것 또한 확인할 수 있다. </p>
<p>이를, 애플리케이션 코드 차원에서 <u>적절히 가공해서</u>(예를 들면 json 객체로..?) 클라이언트에 보내줘야한 생각도 미리 해놓으면 좋을 것이다.</p>
</br>

<h2 id="💢-코드에-적용해보기-rank-구현">💢 코드에 적용해보기 (Rank 구현)</h2>
<p>위에서 알아본 정보를 바탕으로 nestjs에선 어떻게 Redis를 불러올 수 있고, 우리의 결제주문 로직에 랭킹 기능을 어떻게 적용시킬 것인가에 대해 고민해보자.</p>
<p><strong>✔ 사전 설정 (Docker)</strong></p>
<pre><code>// docker.compose.yaml

  redis:
    image: redis
    ports:
      - 6379:6379</code></pre><pre><code>//.env

# Redis Options
REDIS_HOST=redis
REDIS_PORT=6379
</code></pre><h3 id="어떤-모듈을-사용해야-할까">&gt; 어떤 모듈을 사용해야 할까?</h3>
<p>일반적으로 nestjs 공식문서나 주요 글들을 보면 nestjs의 <strong><code>CacheModule</code></strong>을 import하고 store로 <strong><code>redisStore</code></strong>를 사용할 것을 제시한다. (물론 microservice는 다르다)</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/221587e4-7c18-4bdf-96ef-07758db1b19d/image.png" alt=""></p>
<p>엄밀히 말하면 위의 형식은 redis 3.x.x 버전대에서 가능한 케이스다. 현 시점에서 레디스를 설치한다면 4.x.x 버전일 것이고, 위와 같은 형식은 맞지 않다. 물론 해결방법이 없는 것은 아니다. 처음엔 버전을 3.x.x 대로 낮춰서 사용하게 되었고 그 경우 아래와 같이 작성해줄 수 있었다. </p>
<p>현재, 레디스는 공용으로 사용하기 때문에 공통 모듈 (SharedModule)에 import 해주었다.</p>
<pre><code class="language-tsx">// shared.module.ts

import { Module } from &#39;@nestjs/common&#39;;
import { ConfigModule, ConfigService } from &#39;@nestjs/config&#39;;
import { CacheModule } from &#39;@nestjs/cache-manager&#39;;
import * as redisStore from &#39;cache-manager-redis-store&#39;;

@Module({
  imports: [
    CacheModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) =&gt; ({
        store: redisStore,
        host: configService.get&lt;string&gt;(&#39;REDIS_HOST&#39;),
        port: configService.get&lt;number&gt;(&#39;REDIS_PORT&#39;),
      }),
      inject: [ConfigService]
    }),
  ],
  providers: [],
  exports: [CacheModule],
})
export class SharedModule {}</code></pre>
<p>하지만 버전 충돌 뿐 아니라, type 설정등의 이유로 위와 같이 <strong><code>cache-manager</code></strong> 패키지를 사용하는 것엔 <strong>제약이 많았다</strong>. (실제로 후에 필요한 sorted set 명령어를 사용하는 과정에서도 제약이 존재하였다)</p>
<p>그럼에도 이러한 방식으로 사용하고자 할 경우, 아래와 같이 CACHE_MANAGER를 주입받아 사용하면 된다. 그 후 설정한 속성을 통해 클라이언트 등을 불러올 수 있다.</p>
<pre><code class="language-tsx">import { CACHE_MANAGER } from &quot;@nestjs/cache-manager&quot;;
import { Cache } from &quot;cache-manager&quot;;

// ~~
  constructor(
    @Inject(CACHE_MANAGER) private cacheManager: Cache
  ) {}</code></pre>
</br>

<p><strong>✔  그럼 무엇을 사용할 것인가?</strong></p>
<p>범용성이 좋고 현재 npm에서도 다운로드 건수가 많은 <strong><code>@liaoliaots/nestjs-redis</code></strong>와 이를 사용하기 위해 필요한 <strong><code>ioredis</code></strong>를 설치하고 사용하기로 하였다.</p>
<pre><code>Prerequisites
This lib requires Node.js &gt;=12.22.0, NestJS ^9.0.0, ioredis ^5.0.0.

npm install @liaoliaots/nestjs-redis ioredis</code></pre><p>클라이언트를 사용하는 방법엔 두 가지가 있다. 데코레이터를 통해 생성자 주입을 받는 케이스, 모듈에서 제공하는 <code>RedisService</code>를 사용하는 케이스이다. 본인의 경우엔 데코레이터를 통해 <code>Redis Client</code>를 생성하기로 하였다.</p>
<p><strong>Usages (github/lioliaots/nestjs-redis)</strong></p>
<pre><code class="language-tsx">import { Injectable } from &#39;@nestjs/common&#39;;
import { InjectRedis, DEFAULT_REDIS_NAMESPACE } from &#39;@liaoliaots/nestjs-redis&#39;;
import Redis from &#39;ioredis&#39;;

@Injectable()
export class AppService {
  constructor(
    @InjectRedis() private readonly redis: Redis // or // @InjectRedis(DEFAULT_REDIS_NAMESPACE) private readonly redis: Redis
  ) {}

  async set() {
    return await this.redis.set(&#39;key&#39;, &#39;value&#39;, &#39;EX&#39;, 10);
  }
}</code></pre>
</br>

<p><strong>✔ Module 생성</strong></p>
<pre><code class="language-tsx">// redis-ranking.module.ts

import { Module } from &#39;@nestjs/common&#39;;
import { RedisRankingService } from &#39;./redis-ranking.service&#39;;
import { ConfigModule, ConfigService } from &#39;@nestjs/config&#39;;
import { RedisModule } from &#39;@liaoliaots/nestjs-redis&#39;;

@Module({
  imports: [
    RedisModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) =&gt; ({
        config: {
          host: configService.get&lt;string&gt;(&#39;REDIS_HOST&#39;),
          port: configService.get&lt;number&gt;(&#39;REDIS_PORT&#39;),
        }
      }),
      inject: [ConfigService]
    }),
  ],
  providers: [RedisRankingService],
  exports: [RedisRankingService, RedisModule],
})
export class RedisRankingModule {}</code></pre>
<p><code>RedisRankingService</code>는 아래에서 작성될 것입니다.</p>
</br>

<h3 id="랭킹-기능을-구현해보자--응답-가공">&gt; 랭킹 기능을 구현해보자 (+ 응답 가공)</h3>
<hr>
<span style="color:red">
📢 주의: 시리즈 (이전 포스팅들)를 보지 않고 아래의 진행 과정을 읽으실 경우, 어려움이 있으실 것입니다. 꼭, 이전 포스팅들을 읽고 와주시면 감사하겠습니다.
</span>

<hr>
<p><strong>✔ Service</strong></p>
<pre><code class="language-tsx">// redis-ranking.service.ts

import { Injectable } from &quot;@nestjs/common&quot;;
import { InjectRedis } from &quot;@liaoliaots/nestjs-redis&quot;;
import { Redis } from &quot;ioredis&quot;;

@Injectable()
export class RedisRankingService {
  constructor(
    @InjectRedis() private readonly redis: Redis,
  ) {}

  // client를 생성한다.
  getClient() {
    const client = this.redis;
    return client;
  }
}</code></pre>
</br>

<p><strong>✔ ZSET(Sorted Set)에 데이터 추가</strong></p>
<p>멤버-스코어 쌍의 데이터를 ZSET에 추가해보도록 하자. 앞서 알아보았듯이 이는 <strong><code>zadd</code></strong>를 활용할 수 있다.</p>
<p>이 과정은 <code>http</code> 요청과 별개로 독립적 실행을 하기 위해 <strong>standalone application</strong>을 사용하였다. 하지만, 이 방법을 사용할 때는 고려해야할 사항이 많으므로 (데이터 일관성 등등...) 실 서비스에 적용시키고자 할 땐 신중해야할 것이다. 일단은 이 과정에선 독립적 애플리케이션으로써 <strong><code>zadd</code></strong> 연산을 수행하기로 한다.</p>
<pre><code class="language-tsx">// ranking.ts

import { NestFactory } from &quot;@nestjs/core&quot;
import { AppModule } from &quot;../app.module&quot;
import { RedisRankingService } from &quot;../ranking/redis-ranking.service&quot;;
import { UserService } from &quot;../user/user.service&quot;;
import { User } from &quot;../user/model/user.entity&quot;;

(async () =&gt; {
  const app = await NestFactory.createApplicationContext(AppModule);

  const userService = app.get(UserService);

  // ambassador 유저만 찾는다.
  const ambassador: User[] = await userService.find({
    where: {
      is_ambassador: true,
    },
    relations: [&#39;orders&#39;, &#39;orders.order_items&#39;]
  });

  // zadd를 사용하기 위해선 redis client를 불러와야 할 것이다.
  const redisService = app.get(RedisRankingService);
  const client = redisService.getClient();

  for (let i = 0; i &lt; ambassador.length; i++) {
    await client.zadd(&#39;rankings&#39;, ambassador[i].revenue, ambassador[i].name);
  }

  process.exit();
})();</code></pre>
<p>랭킹 기능을 구현하는데 있어, 모든 유저의 랭크를 표시할 필요는 없다. 결국, 우리는 판매 대리인의 <strong>&quot;수익(revenue)&quot;</strong> 현황을 알아볼 것이고 즉, 유저 테이블에서 <strong><code>is_ambassador</code></strong>가 <strong><code>true</code></strong>인 유저에 한해서만 적용시키면 된다.</p>
</br>

<p><strong>✔ ZRANGE를 사용한 정렬</strong></p>
<p>다음으로는 <strong>ZRANGE</strong>를 사용하여 정렬을 시켜준다. </p>
<p>우린 스코어에 따라 내림차순 정렬을 확인하고 싶으므로, <strong><code>ZREVRANGEBYSCORE</code></strong>를 사용할 것이다.</p>
<p>물론, 레디스 6.2.0 버전 이후로 이는 deprecated 되었고 ZRANGE 명령어에 REV와 BYSCORE 옵션을 사용하여 할 것을 권고한다. 이에 대해서도 추후 대처할 필요는 있다고 본다. 하지만 아직은 애플리케이션 레벨에선 충분히 사용해도 무방하다.</p>
<blockquote>
<p>As of Redis version 6.2.0, this command is regarded as deprecated.
It can be replaced by ZRANGE with the REV and BYSCORE arguments when migrating or writing new code.</p>
</blockquote>
<p>기존 RedisRankingService에서 <strong><code>getRanks()</code></strong>메서드 아래에 이를 구현해준다.</p>
<pre><code class="language-tsx">// redis-ranking.service.ts

import { Injectable } from &quot;@nestjs/common&quot;;
import { InjectRedis } from &quot;@liaoliaots/nestjs-redis&quot;;
import { Response } from &quot;express&quot;;
import { Redis } from &quot;ioredis&quot;;

@Injectable()
export class RedisRankingService {
  constructor(
    @InjectRedis() private readonly redis: Redis,
  ) {}

  getClient() {
    const client = this.redis;
    return client;
  }

  async getRankings(res: Response) {
    const client = this.getClient();
    const ranks = await client.zrevrangebyscore(&#39;rankings&#39;, &#39;+inf&#39;, &#39;-inf&#39;, &#39;WITHSCORES&#39;, (err: Error | null, result: string[]) =&gt; {
        res.send(result);
      }
    return ranks;
  }                                              
}</code></pre>
<p><code>zrevrangebyscore</code>는 아래와 같은 형식의 인자를 가진다. 딱히 범위를 표시하진 않을 것이므로 <code>max</code>와 <code>min</code>의 범위는 무한으로 설정해두었다. </p>
<p>그 후, 콜백 내부에 express의 <code>Response</code> 객체를 이용해 결과값에 해당하는 배열을 던져주기로 한다. 컨트롤러의 라우터에서 이를 받게 된다.</p>
<hr>
<pre><code class="language-tsx">zrevrangebyscore(key: RedisKey, max: number | string, min: number | string, withscores: &quot;WITHSCORES&quot;, callback?: Callback&lt;string[]&gt;): Result&lt;string[], Context&gt;;</code></pre>
<hr>
</br>

<p><strong>✔ User layer</strong></p>
<p><strong>유저</strong>(정확히 말하면 ambassador)에 대한 랭킹 조회이므로 해당 처리는 유저 레이어에서 맡기로 하였다. 즉, 위에서 정의한 <code>getRankings()</code>를 유저 서비스단에서 받아와준다.</p>
<pre><code class="language-tsx"> // user.service.ts

  async rankings(res: Response) {
    return await this.redisRankingService.getRankings(res);
  }</code></pre>
<pre><code class="language-tsx">// user.controller.ts

  @UseGuards(JwtAccessAuthGuard)
  @Get(&#39;ambassador/rankings&#39;)
  async rankings(@Res() res: Response) {
    return await this.userService.rankings(res);
  }</code></pre>
<p>이 상태에서 만약 위의 핸들러 함수에서 정의한 url로 요청을 날려보면 어떠한 응답을 받게 될까?</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/f7637842-db8b-4987-bf3b-9fba03db4758/image.png" alt=""></p>
<p>이러한 형식의 응답을 받게 될 것이다. 이는 우리가 앞서 redis-cli를 통한 테스트에서도 확인할 수 있었듯이 <strong>&quot;멤버, 스코어, 멤버, 스코어, ...&quot;</strong> 형태의 배열을 받게 된다.</p>
<p>하지만 우린 이러한 데이터를 그대로 클라이언트에게 넘겨줄 순 없다.</p>
<p>그러므로, 이를 일련의 방법으로 <strong>&quot;가공&quot;</strong> 해야한다.</p>
</br>

<p><strong>✔ 응답 객체 가공하기</strong></p>
<p>다시, <code>RedisRankingService</code>의 <strong><code>getRankings()</code></strong> 함수로 돌아가보자.</p>
<pre><code class="language-tsx">  async getRankings(res: Response) {
    const client = this.getClient();
    const ranks = await client.zrevrangebyscore(&#39;rankings&#39;, &#39;+inf&#39;, &#39;-inf&#39;, &#39;WITHSCORES&#39;, (err: Error | null, result: string[]) =&gt; {
        res.send(result);
      }
    return ranks;
  }     </code></pre>
<p>위 코드로 보면, 결과적으로 응답으로 보내질 데이터는 <strong><code>string[]</code></strong> 타입의 <strong><code>result</code></strong>이다. 이 <code>result</code>를 가공해야할 것이다.</p>
<p>어떻게 접근해야할까?</p>
<p>앞서 말했듯이, 위의 응답 결과는 <strong>[&quot;멤버&quot;, &quot;스코어&quot;, &quot;멤버&quot;, &quot;스코어&quot;, ... , &quot;멤버&quot;, &quot;스코어&quot;]</strong>의 배열 형태이다.</p>
<p>즉, 우리가 필요한 작업은 전체 값은 오브젝트(<code>{}</code>)안에 넣어줌과 동시에 멤버와 스코어를 교차해가며, <strong>멤버</strong>를 오브젝트의 <strong>&quot;key&quot;</strong>로(여기서 key는 sorted set의 key와는 다르다) <strong>스코어</strong>를 오브젝트의 <strong>&quot;value&quot;</strong>로 나타내야한다.</p>
<blockquote>
<pre><code>{ &quot;멤버&quot;: &quot;스코어&quot;, &quot;멤버&quot;: &quot;스코어&quot; ... }</code></pre></blockquote>
<pre><code>
그럼 이를 위한 코드를 바로 확인해보자.

```tsx
  async getRankings(res: Response) {
    const client = this.getClient();
    const ranks = await client.zrevrangebyscore(&#39;rankings&#39;, &#39;+inf&#39;, &#39;-inf&#39;, &#39;WITHSCORES&#39;, (err: Error | null, result: string[]) =&gt; {
      // member 변수 설정
      let member: string;

      res.send(result.reduce((object: {}, r: string) =&gt; {
        // 배열 result의 current string값 `r`이 parseInt시 숫자인지 아닌지 판별 

        // `r`이 숫자가 아닐경우: `r`은 member로 위치한다.
        if (isNaN(parseInt(r))) {
          member = r;
          return object;
        // `r`이 숫자일 경우: `r`은 score로 위치한다.
        } else {
          return {
            ...object,
            [member]: r,
          };
        } 
      }, {}));
    });

    return ranks;
  }</code></pre><p>이렇게 우린 클라이언트에게 json형태의 오브젝트로 응답하기 위한 가공작업까지 마쳤다.</p>
</br>

<h3 id="변화하는-스코어에-대한-변경-반영-feat-eventemitter">&gt; 변화하는 스코어에 대한 변경 반영 (<code>Feat. EventEmitter</code>)</h3>
<p><strong>다음 단계는 무엇인가?</strong></p>
<p>그렇다, <u>변동하는 스코어 값이 랭킹 응답에 반영</u>이 되게끔 해야할 것이다.</p>
<p>이는 아래와 같이 주문 확인 단계에서 구현해 줄 수 있다.</p>
<pre><code class="language-tsx">// order.service.ts

import { BadRequestException, Injectable, NotFoundException } from &#39;@nestjs/common&#39;;
import { AbstractService } from &#39;../shared/abstract.service&#39;;
// ....
import { EventEmitter2 } from &#39;@nestjs/event-emitter&#39;;

@Injectable()
export class OrderService extends AbstractService {
  constructor(
    // ....
    private eventEmitter: EventEmitter2,
  ) {
    super(orderRepository);
  }

  // ...

  async orderConfirm(sourceId: string) {
    const order: Order = await this.findOne({
      where: {
        transaction_id: sourceId,
      },
      relations: [&#39;order_items&#39;, &#39;user&#39;]
    });

    if (!order) {
      throw new NotFoundException(&#39;Order not found&#39;);
    }

    await this.update(order.id, {
      complete: true,
    });

    // 바로 이 부분이다. 우린 EventEmitter를 통해 이를 받아온다.
    await this.eventEmitter.emit(&#39;order.completed&#39;, order);

    return {
      message: &#39;success&#39;,
    }
  }
}</code></pre>
<p>위 <code>orderConfirm</code>로직에 직접 스코어의 변경사항에 대한 부분을 반영해도 무방하고, 다른 서비스 레이어에서 이를 다뤄줘도 무방하지만 우린 <strong>&quot;EventEmitter&quot;</strong>를 이용하기로 한다. 이는 nestjs뿐만 아니라 여러 nodejs 기반 프레임워크에서 사용할 수 있고, 이벤트 처리에 있어 디커플링을 적용하기 위한 굉장히 좋은 방법이다.</p>
<p>우린 <strong>&quot;Listener&quot;</strong>로써 클래스를 명명할 수 있다.</p>
<pre><code class="language-tsx">// order.listener.ts

import { Injectable } from &quot;@nestjs/common&quot;;
import { Order } from &quot;../model/order.entity&quot;;
import { OnEvent } from &quot;@nestjs/event-emitter&quot;;
import { InjectRedis } from &quot;@liaoliaots/nestjs-redis&quot;;
import { Redis } from &quot;@nestjs-modules/ioredis&quot;;

@Injectable()
export class OrderListener {
  constructor(
    @InjectRedis() private readonly redis: Redis
  ) {}

  // `@OnEvent()`에 입력해준 이벤트 명을 통해 다른 레이어와 소통할 수 있다.
  @OnEvent(&#39;order.completed&#39;)
  async handleOrderCompletedEvent(order: Order) {
    const client = this.redis;
    client.zincrby(&#39;rankings&#39;, order.ambassador_revenue, order.user.name);
  }
}</code></pre>
<p>앞서 redis-cli를 통해서도 알 수 있었겠지만 *<em><code>zincrby</code> *</em>인자의 값은 *<em>(key, increment 값, memeber명) *</em>이다. 아마 어렵지 않게 이해 할 수 있을 것이다.</p>
<hr>
<p>참고로, 우리의 경우는 단지 &quot;주문&quot;건에 대해서만 유저의 랭킹을 조회할 것이지만 여러 다른 계층에서 동일한 작업을 진행하고자 할 경우 위와 같이 <code>handle</code> 함수에 직접 엔터티를 받고, <code>zincrby</code>의 인자로 특정 increment값과 member를 하드코딩하기보단 런타임에 동작할 수 있게 끔 맡기는 것이 바람직할 것이다.</p>
<hr>
</br>

<h3 id="postman을-통한-테스트">&gt; <code>Postman</code>을 통한 테스트</h3>
<p>처음 상태는 아니지만 결제 주문을 어느 정도 진행한 전체 판매 대리인에 대한 랭킹을 알아보면 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/a09d7244-da58-4b10-b1b1-018790bd7eba/image.png" alt=""></p>
<p>현재 <strong>firstName: &quot;e&quot;, lastName: &quot;e&quot;</strong>에 해당하는 ambassador의 스코어 값은 &quot;0&quot;인 것을 확인할 수 있다.</p>
<p>하지만, 우린 지난 포스팅에서 확인할 수 있듯이 특정 유저에 대해 판매자 &quot;e&quot;의 대행물품을 결제하는 행위를 진행하였었다. <span style="color:red">(꼭 지난 포스팅을 보고 오셔야합니다...)</span> 그 결과로 22 달러의 수익을 얻은 것 또한 알수 있었다.</p>
<pre><code>    {
        &quot;code&quot;: &quot;hl4oh8&quot;,
        &quot;count&quot;: 1,
        &quot;revenue&quot;: 22
    }</code></pre><p>이미, <strong>결제확인까지 마친 상태이므로</strong> 이는 우리가 설정한 &quot;랭크&quot;에 반영이 되어야 할 것이다. </p>
<p>요청을 통해 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/ed2d20d8-62d2-4f04-a4ed-d19eacbd88b5/image.png" alt=""></p>
<p>기존에 &quot;0&quot;이었던 스코어 값이 &quot;22&quot;가 된 것을 확인할 수 있다.</p>
<p><strong>그럼 한번 더 테스트를 해보자.</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/f4cd2bd9-95dc-40b6-b9a8-4d56d922c73a/image.png" alt=""></p>
<p>위와 같이 특정 유저는 판매대리인 <strong>&quot;e e&quot;</strong>의 링크 코드(code)를 통해<span style="color:red">(이 역시 지난 포스팅을 보고 오셔야 알 수 있습니다)</span> 결제를 진행한다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2fe5d123-7750-4691-8880-68c0611b279e/image.png" alt=""></p>
<p>결제를 생성하였고, 위와 같이 받게 된 트랜잭션 id를 통해 <strong>결제확인</strong> 단계를 진행한다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/4e9371bf-7f0b-4114-a02e-376f71f5ebb2/image.png" alt=""></p>
<p>결제 확인이 완료되었다는 메시지를 받게 되었고, 우린 랭킹에서 이번 결제 구매 역시 반영이 된 것을 확인해 볼 필요가 있다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/54f884a4-ca0d-4c01-afda-50e1a7d39cb9/image.png" alt=""></p>
<p>이번엔 22달러에서 54달러로 총 <strong>32달러</strong>의 score값이 증가하였다. </p>
<p>이 증가 값은 우리가 생성한 <strong><code>order_items</code></strong> 테이블을 통해 확인할 수 있다. </p>
<pre><code>+----+----------------+-------+----------+---------------+--------------------+----------+
| id | product_title  | price | quantity | admin_revenue | ambassador_revenue | order_id |
+----+----------------+-------+----------+---------------+--------------------+----------+
| 87 | minus deserunt |    76 |        3 |           205 |                 22 |       84 |
| 88 | eaque ad       |    26 |        4 |            93 |                 10 |       84 |
+----+----------------+-------+----------+---------------+--------------------+----------+</code></pre><p>구매한 상품 품목 두 가지에 따라 각각 22달러와 10달러의 <strong><code>ambassador_revenue</code></strong>가 생긴 것을 확인할 수 있다. 이 값이 랭킹에 반영이 된 것이다.</p>
</br>

<h2 id="생각정리">생각정리</h2>
<p>정말 길고 긴 포스팅이었다. 사실 쭉 이어지는 흐름의 프로젝트 성 포스팅을 쓰는 건 처음인 것 같다. </p>
<p>아무래도 &quot;결제 주문과 레디스를 통한 랭킹 기능 구현&quot;을 일련의 프로세스 과정을 통해 설명하려는게 목적이었다보니 &quot;정말 좋은 코드&quot;를 작성하고 설명하는데 있어서 많이 부족한 점이 아쉽긴 하다. 본인 스스로도 본인과 같은 nestjs 및 백엔드 입문자의 입장에서 어떻게 글을 작성해야 쉽게 와닿을까를 고민하였고, 항상 그것을 염두해 두고 글을 썼던거 같다. </p>
<p>아무래도 &quot;결제 주문&quot;, &quot;레디스를 통한 캐싱 처리 및 랭크 기능&quot; 등과 같은 로직은 입문자입장에선 쉽게 와닿지가 않는다. 이런 생각에 조금 길긴 하겠지만 아주 간단하지만 전체적인 흐름을 전달하는 글이 있으면 좋지 않을까 생각해보았다. </p>
<p>정신없이 쓴 글이다 보니 부족한 점이 많을것이 분명하고 잘못 이해한 부분도 있을 것이다. 추후 많은 분들의 피드백과 스스로의 개선을 통해 추가적 내용을 수정 및 업로드 할 예정이다.</p>
<hr>
<p>긴 글 읽어주셔서 감사합니다!!!</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] 주문처리과정을 통해 알아보는 수익률 랭킹 조회 (Part 3_ 결제 주문 로직 테스트) #3]]></title>
            <link>https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-3-%EA%B2%B0%EC%A0%9C-%EC%A3%BC%EB%AC%B8-%EB%A1%9C%EC%A7%81-%ED%85%8C%EC%8A%A4%ED%8A%B8-3</link>
            <guid>https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-3-%EA%B2%B0%EC%A0%9C-%EC%A3%BC%EB%AC%B8-%EB%A1%9C%EC%A7%81-%ED%85%8C%EC%8A%A4%ED%8A%B8-3</guid>
            <pubDate>Sat, 01 Jul 2023 14:10:44 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>이번 포스팅은 바로 앞전의 포스팅에서 구현한 결제 주문 비즈니스로직을 테스트하는 과정을 거친다. 이전 포스팅에 이를 모두 나타내고 싶었지만 글이 너무 길어지는 것을 염두해 테스팅부분을 개별 포스팅에 다루고자 한다.</p>
<p>이번 포스팅 뿐만 아니라 이번 시리즈는 앞전의 포스팅들과 모두 연결되므로 꼭 사전에 보고 오시길 바랍니다. 하나의 프로젝트성 시리즈입니다.</p>
<hr>
<p><a href="https://velog.io/@from_numpy/series/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EB%B6%80%ED%84%B0-%EB%9E%AD%ED%82%B9-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80">시리즈 _결제주문과정부터 수익 랭킹 조회 ✔</a></p>
<p><a href="https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-1-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-DB-%EC%84%A4%EA%B3%84">[Part1] DB 설계부 ✔</a></p>
<p><a href="https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-2-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EC%99%80-%EA%B2%B0%EC%A0%9C%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84">[Part2] 결제 주문 비즈니스 로직부 ✔</a></p>
<hr>
</br>

<h2 id="💢-postman을-통한-전체-프로세스-알아보기">💢 <code>Postman</code>을 통한 전체 프로세스 알아보기</h2>
<p>해당 프로세스 전개는 이전 포스팅에서 다뤄보았던 <u>비즈니스 로직의 흐름과 동일</u>하다. </p>
<p>product가 생성되어야 할 것이고, 판매 대리인을 위한 link가 구축된 후 해당 link와 product를 통해서 order와 order_item이 생성되어야할 것이다. 그리고 그 과정에서 Stripe를 통한 결제 시스템이 개입된다.</p>
<h3 id="docker-환경에서-standalone-application을-통한-더미데이터-생성">&gt; <code>docker</code> 환경에서 <code>StandAlone-Application</code>을 통한 더미데이터 생성</h3>
<p>물론 유저, 상품(product), 주문(order), 주문 상품(order_items)을 생성하고 수정및 삭제하는 것과 관련된 핸들러 함수를 다 만들어주긴 하였지만, 일부에 한해서 조금 더 빠른 테스트를 얻고자 더미 데이터를 생성하였다.</p>
<p>NestJS와 TypeORM 환경에서 더미데이터를 생성하는 방법엔 몇 가지가 있다.</p>
<p>일전에 본인도 다뤄보았던 nestjs-seeder와 typeorm-seeding이 그 중 하나이다. 이 둘중에선 왠만하면 typeorm-seeding을 사용하는 것을 권장한다.</p>
<hr>
<p><a href="https://velog.io/@from_numpy/NestJS-nestJS-seeder-%EB%8D%94%EB%AF%B8%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0-feet.-faker-js">nestjs-seeder (블로깅)</a></p>
<p><a href="https://www.npmjs.com/package/typeorm-seeding">npm typeorm-seeding</a></p>
<hr>
<p>이번의 경우엔 위의 두 방법이 아닌, nestjs에서 제공하는 &quot;StandAlone Application&quot; 방법을 사용하여 더미 데이터를 생성할 수 있었다. </p>
<hr>
<p><a href="https://docs.nestjs.com/standalone-applications">nestjs_docs standalone-application ✔</a></p>
<hr>
<p>Standalone Application에 대해 깊게 설명하면 주제를 너무 벗어나므로 간단히 알아보고 바로 더미데이터를 생성한 코드를 확인해보자.</p>
<p>NestJS의 Standalone Application은 NestJS 프레임워크를 사용하여 독립 실행형 애플리케이션을 개발하는 방법을 의미한다. Standalone Application은 NestJS의 모듈 시스템과 런타임 환경을 사용하여 독립적으로 실행되는 애플리케이션을 구축하는 데 사용되는데, 이는 NestJS의 훌륭한 확장성과 모듈성을 제공하면서 독립 실행형 애플리케이션을 개발할 수 있도록 해준다. 즉, 우리가 메인으로 실행하는 AppModule과 독자적으로 실행이되지만 AppModule의 클래스들을 의존성으로 불러올 수 있는 유용한 장점을 지닌다. (물론 동일한 프로세스 아래에서 동작한다)</p>
</br>

<p><strong>✔ ambassador-seeder</strong></p>
<pre><code class="language-tsx">// ambassador.seeder.ts

import { NestFactory } from &quot;@nestjs/core&quot;;
import { AppModule } from &quot;../app.module&quot;;
import { UserService } from &quot;../user/user.service&quot;;
import { faker } from &quot;@faker-js/faker&quot;;
import * as bcrypt from &quot;bcryptjs&quot;;

(async () =&gt; {
  const app = await NestFactory.createApplicationContext(AppModule);

  // 의존성으로 UserService를 불러옴
  const userService = app.get(UserService);
  const password = await bcrypt.hash(&quot;1234&quot;, 12);

  for(let i = 0; i &lt; 30; i++) {
    await userService.save({
      first_name: faker.person.firstName(),
      last_name: faker.person.lastName(),
      email: faker.internet.email(),
      password,
      is_ambassador: true,
    });
  }

  process.exit();
})();
</code></pre>
</br>

<p><strong>✔ order-seeder</strong></p>
<pre><code class="language-tsx">// order.seeder.ts

import { NestFactory } from &quot;@nestjs/core&quot;;
import { AppModule } from &quot;../app.module&quot;;
import { faker } from &quot;@faker-js/faker&quot;;
import { randomInt } from &quot;crypto&quot;;
import { OrderService } from &quot;../order/order.service&quot;;
import { OrderItemService } from &quot;../order/order-item.service&quot;;

(async () =&gt; {
  const app = await NestFactory.createApplicationContext(AppModule);

  const orderService = app.get(OrderService);
  const orderItemService = app.get(OrderItemService)

  for(let i = 0; i &lt; 30; i++) {
    const order = await orderService.save({
      user_id: randomInt(4, 33),
      code: faker.lorem.slug(2),
      ambassador_email: faker.internet.email(),
      first_name: faker.person.firstName(),
      last_name: faker.person.lastName(),
      email: faker.internet.email(),
      complete: true,
    });

    for (let j=0; j &lt; randomInt(1, 5); j++) {
      await orderItemService.save({
        order,
        product_title: faker.lorem.words(2),
        price: randomInt(10, 100),
        quantity: randomInt(1, 5),
        admin_revenue: randomInt(10, 100),
        ambassador_revenue: randomInt(1, 10),
      })
    }
  }

  process.exit();
})();</code></pre>
</br>

<p><strong>✔ product-seeder</strong></p>
<pre><code class="language-tsx">// product.seeder.ts

import { NestFactory } from &quot;@nestjs/core&quot;;
import { AppModule } from &quot;../app.module&quot;;
import { faker } from &quot;@faker-js/faker&quot;;
import { ProductService } from &quot;../product/product.service&quot;;
import { randomInt } from &quot;crypto&quot;;

(async () =&gt; {
  const app = await NestFactory.createApplicationContext(AppModule);

  const productService = app.get(ProductService);

  for(let i = 0; i &lt; 30; i++) {
    await productService.save({
      title: faker.lorem.words(2),
      description: faker.lorem.words(10),
      image: faker.image.url({
        width: 200,
        height: 200,
      }),
      price: randomInt(10, 100),
    });
  }

  process.exit();
})();</code></pre>
</br>

<p>이제 위의 standalone-application (즉, 독립적 애플리케이션)을 실행시켜주어야한다. 현재 <strong>도커환경</strong>에서 전체 애플리케이션을 실행중인데, 이러한 독립적 애플리케이션은 별개적으로 실행시켜야한다. 물론, 도커가 실행할때 동시에 실행시켜주게 한다는 등의 방법이 있을지도 모른다. 일단은 추후 알아보도록 하고, 수동적으로 실행시켜주자.</p>
<p>방법은 아주 간단하다.</p>
<p>현재 실행중인 폴더 디렉토리에서 아래의 명령어를 입력한뒤</p>
<pre><code>docker-compose exec backend(서버 이름) sh</code></pre><p>package.json에서 등록한 실행 명령어대로 (ex _ npm run seed:ambassadors) 입력하면 된다!!!! </p>
<pre><code class="language-ts">  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;nest build&quot;,

    // ... ...

    &quot;seed:ambassadors&quot;: &quot;ts-node src/commands/ambassador.seeder.ts&quot;,
    &quot;seed:products&quot;: &quot;ts-node src/commands/product.seeder.ts&quot;,
    &quot;seed:orders&quot;: &quot;ts-node src/commands/order.seeder.ts&quot;
  },</code></pre>
<p>이로써 독립적인 애플리케이션을 실행할 수 있게 된다.</p>
</br>

<p>주문 데이터는 목록은 추후 확인해보도록 하고 더미데이터로 생성한 user와 product 테이블 레코드를 확인해보자.</p>
</br>

<h3 id="docker-환경에서-데이터베이스-접근">&gt; <code>docker</code> 환경에서 데이터베이스 접근</h3>
<p>더미데이터가 잘 생성되었는지 데이터베이스에서 확인해보자. 물론 mysql workbench를 통해서 바로 알아볼 수 있긴 하지만 터미널을 통해서도 알아볼 수 있다. </p>
<p>이때, 도커환경이라는 점을 고려해서 몇가지 수행 절차를 밟아야하는데 이 역시 아주 간단하다.</p>
<pre><code>C:\Users\ASUS\Desktop\Nest.JS\nest-ambassador&gt;docker ps
CONTAINER ID   IMAGE                     COMMAND                  CREATED      STATUS      PORTS                                NAMES
d55c49c28b62   nest-ambassador-backend   &quot;docker-entrypoint.s…&quot;   7 days ago   Up 7 days   0.0.0.0:8000-&gt;3400/tcp               nest-ambassador-backend-1
a05d06fa807b   mysql:8.0.29              &quot;docker-entrypoint.s…&quot;   7 days ago   Up 7 days   33060/tcp, 0.0.0.0:33066-&gt;3306/tcp  ✔nest-ambassador-db-1
5e1196a22bd6   redis                     &quot;docker-entrypoint.s…&quot;   7 days ago   Up 7 days   0.0.0.0:6379-&gt;6379/tcp               nest-ambassador-redis-1

C:\Users\ASUS\Desktop\Nest.JS\nest-ambassador&gt;docker exec -it nest-ambassador-db-1 bash
bash-4.4# mysql -u root -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 200
Server version: 8.0.29 MySQL Community Server - GPL

Copyright (c) 2000, 2022, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type &#39;help;&#39; or &#39;\h&#39; for help. Type &#39;\c&#39; to clear the current input statement.</code></pre><p>현재 실행중인 디렉토리 경로에서 입력된 컨테이너 NAMES에 해당하는 컨테이너를 실행시켜주면 된다. 그렇게 우린 도커를 통해 빌드된 mysql 컨테이너에 접근할 수 있게 된다.</p>
</br>

<p><strong>✔ User 데이터 확인하기 (더미데이터를 통한 ambassador 추가)</strong></p>
<p>참고로 본인이 직접 회원가입/로그인 프로세스를 거쳐서 생성한 (예를들면 a,b,c ...의 값을 가지는 데이터의 경우) 유저 데이터와 더미 데이터가 섞여있습니다. 양해바랍니다.</p>
<p>여기서 <span style="color:red"><strong>중요한 것</strong></span>은 <strong>is_ambassador 필드의 값이 0이나 1이냐</strong> 입니다. </p>
<pre><code>mysql&gt; SELECT*FROM ambassador.users;
+----+------------+-------------+------------------------------------+--------------------------------------------------------------+---------------+--------------------------------------------------------------+------------------------+
| id | first_name | last_name   | email                              | password                                                     | is_ambassador | currentRefreshToken                                          | currentRefreshTokenExp |
+----+------------+-------------+------------------------------------+--------------------------------------------------------------+---------------+--------------------------------------------------------------+------------------------+
|  1 | a          | a           | a@a.com                            | $2a$12$fsob7o/eY5xx9jeT0iJnJ.8pRDzRLfAIjRkiQynFVgWB6itmj/gKG |             0 | $2a$10$KY2HsZKOhzL6iZEwmFADiu3xsuDtMo6gYaavwVMSvw6zpMWGVcJk. | 2023-07-02 13:17:20    |
|  2 | b          | b           | b@b.com                            | $2a$12$Mpq0BWuuiNc99XAgXmDxouCPGcROcykeLPeLVXNQUgwZqQWITOLtq |             0 | NULL                                                         | NULL                   |
|  3 | c          | c           | c@c.com                            | $2a$12$g8/5k8TW0pZcnzBZsY5Se.m1Qtqj4OFUtLtGabCB.MphOOvCvR5uC |             0 | NULL                                                         | NULL                   |
|  4 | Fritz      | Donnelly    | Dimitri_Littel-Kovacek@hotmail.com | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
|  5 | Henri      | Torp        | Amiya.Smith67@yahoo.com            | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
|  6 | April      | Jacobi      | Erna23@hotmail.com                 | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
|  7 | Helmer     | Daniel      | Gaston.Stroman@hotmail.com         | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
|  8 | Aleen      | Reinger     | Rozella49@gmail.com                | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
|  9 | Colt       | Considine   | Darryl66@gmail.com                 | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 10 | Alexandre  | Bechtelar   | Lorine.Hammes@gmail.com            | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 11 | Hugh       | Johnston    | Irma14@hotmail.com                 | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 12 | Cali       | Berge       | Barbara.Wilkinson@yahoo.com        | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 13 | Brice      | Yundt       | Elton_Littel@yahoo.com             | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 14 | Guido      | Price       | Jayson.Lowe21@hotmail.com          | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 15 | Janick     | Roob        | Sigrid.Bayer@yahoo.com             | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 16 | Aliyah     | Brakus      | Richie.Sawayn4@gmail.com           | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 17 | Junius     | Senger      | Therese55@yahoo.com                | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 18 | Noble      | Graham      | Lyda.Lakin@yahoo.com               | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 19 | Reggie     | Jerde       | Lucious_Stehr@hotmail.com          | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 20 | Melvina    | Welch       | Heather41@gmail.com                | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 21 | Germaine   | Runte       | Wellington21@yahoo.com             | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 22 | Eloisa     | Oberbrunner | Joe.Lueilwitz6@gmail.com           | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 23 | Kobe       | Muller      | Felicita81@hotmail.com             | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 24 | Eden       | Reichert    | Elza.Lowe@gmail.com                | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 25 | Theresa    | Parker      | Austin_Lebsack@yahoo.com           | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 26 | Brando     | Kulas       | Dudley_Bradtke29@gmail.com         | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 27 | Cordia     | Nader       | Myron.Kulas@gmail.com              | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 28 | Graciela   | Conroy      | Benjamin30@gmail.com               | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 29 | Berenice   | Ortiz       | Nathen20@yahoo.com                 | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 30 | Demarco    | Kozey       | Freeman_Quitzon@gmail.com          | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 31 | Ezequiel   | Hudson      | Patrick.Predovic66@yahoo.com       | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 32 | Houston    | Thompson    | Katharina60@yahoo.com              | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 33 | Albert     | Spencer     | Bernardo.Franecki@gmail.com        | $2a$12$Vit5QUl5iKmi5dj/Gg6C7u8IqfJjOR8eEC56/aTGVJnUc6xoUBBki |             1 | NULL                                                         | NULL                   |
| 34 | d          | d           | d@d.com                            | $2a$12$ku/tBqeZ78NEcZCSE5t2dORo.P2sqTSkBLgKCfRZKSnJwxOXlr12a |             0 | NULL                                                         | NULL                   |
| 35 | e          | e           | e@e.com                            | $2a$12$F4OEWAcEIw8u9r0NBg8ZaeLrHIYOke2IS/nOzKz87CorjNKa9F8uu |             1 | $2a$10$g1T3U2csOvmp5paxziYzi.KtPg/Ki8lJ/iFUtZa7XWo4b8NmWHU.q | 2023-07-06 17:55:25    |
| 36 | f          | f           | f@f.com                            | $2a$12$OtpmXwplTVKAPfFh7MF8AeiDjgGhgKt0D//mGY5Kkhs6hRLofgXsm |             0 | $2a$10$5KLfU2ehU2PdRsAKCL2Egu66wrvvZWfjglOQEeK2QNesLtuHDheNO | 2023-07-06 16:22:36    |
| 37 | g          | g           | g@g.com                            | $2a$12$6rj4eVTFW8TMp2yHeQpO/.MvPy9yovR2GGabgcnjcatNox9iBbCi6 |             1 | $2a$10$uEMlnDzXJVA/hIgTxIZk7OA5iequ0CxMh.lXN.oXFceMOzZDceWki | 2023-07-06 17:52:54    |
+----+------------+-------------+------------------------------------+--------------------------------------------------------------+---------------+--------------------------------------------------------------+------------------------+
37 rows in set (0.00 sec)</code></pre></br>

<p><strong>✔ Product 데이터 확인하기</strong></p>
<pre><code>mysql&gt; SELECT*FROM ambassador.product;
+----+-----------------------+----------------------------------------------------------------------------------------------+-------------------------------------------------------+-------+
| id | title                 | description                                                                                  | image                                                 | price |
+----+-----------------------+----------------------------------------------------------------------------------------------+-------------------------------------------------------+-------+
|  2 | title                 | desc 2                                                                                       | https://loremflickr.com/200/200?lock=4730560809271296 |    20 |
|  3 | repellendus est       | possimus cupiditate expedita delectus ullam quisquam animi aperiam iure molestias            | https://picsum.photos/seed/kCLrGh/200/200             |    44 |
|  4 | facilis quos          | architecto officiis officiis omnis libero fugiat minima voluptatum eligendi sed              | https://loremflickr.com/200/200?lock=70498964733952   |    37 |
|  5 | ea autem              | iusto excepturi debitis libero neque nesciunt quam dolore nisi illum                         | https://loremflickr.com/200/200?lock=2189112083742720 |    76 |
|  6 | fugit quod            | quod omnis modi quas officia perspiciatis ipsam eligendi magni nemo                          | https://picsum.photos/seed/uH8iz95x/200/200           |    16 |
|  7 | minus deserunt        | esse necessitatibus eligendi in similique tempora numquam accusantium accusamus cumque       | https://loremflickr.com/200/200?lock=7717237471313920 |    76 |
|  8 | eligendi perspiciatis | magni commodi laborum accusantium consequuntur nam minima nam nam ullam                      | https://picsum.photos/seed/pmfOTq5I0/200/200          |    88 |
|  9 | blanditiis vero       | totam voluptate exercitationem eveniet qui aperiam aperiam nulla dolore quidem               | https://loremflickr.com/200/200?lock=1475789767835648 |    84 |
| 10 | eaque ad              | occaecati tenetur officiis iusto dignissimos cumque nam consequatur non quis                 | https://picsum.photos/seed/Z3uhX3xQtq/200/200         |    26 |
| 11 | beatae modi           | eius minus tempora tempore sit dicta officiis suscipit necessitatibus eum                    | https://loremflickr.com/200/200?lock=7595707689074688 |    77 |
| 12 | ea recusandae         | magni quisquam dicta velit ipsa id nobis consectetur rerum sequi                             | https://loremflickr.com/200/200?lock=3169077566636032 |    19 |
| 13 | tempora tempora       | magnam libero aut earum beatae nostrum debitis veritatis fugiat voluptate                    | https://picsum.photos/seed/B7Wk5/200/200              |    93 |
| 14 | voluptatum numquam    | facere est repellendus quasi laudantium dolorem ipsum aperiam consectetur maxime             | https://loremflickr.com/200/200?lock=2880736291979264 |    83 |
| 15 | dignissimos quis      | omnis incidunt hic labore eligendi id cupiditate eos velit consequuntur                      | https://picsum.photos/seed/wPFIRxWW/200/200           |    10 |
| 16 | aut temporibus        | exercitationem alias voluptate fugit tempora voluptatem blanditiis cum ipsum eligendi        | https://loremflickr.com/200/200?lock=5041792160366592 |    96 |
| 17 | neque eos             | asperiores natus quasi enim nemo tempora ut quas repellat porro                              | https://loremflickr.com/200/200?lock=6906867104088064 |    61 |
| 18 | consectetur laborum   | nemo pariatur quia quas provident eveniet quibusdam eius earum natus                         | https://loremflickr.com/200/200?lock=4125805881851904 |    97 |
| 19 | molestiae pariatur    | unde nisi repudiandae saepe consectetur dolores voluptas eius labore reprehenderit           | https://picsum.photos/seed/koMSAit1/200/200           |    77 |
| 20 | similique inventore   | numquam fuga optio ab maiores itaque ipsa odio rem officiis                                  | https://loremflickr.com/200/200?lock=3596592951066624 |    87 |
| 21 | sequi voluptatibus    | facilis omnis exercitationem repellendus rerum soluta voluptatum doloremque labore quibusdam | https://loremflickr.com/200/200?lock=7970722114699264 |    92 |
| 22 | libero consequatur    | quidem aliquam placeat cupiditate error corrupti rerum voluptates impedit distinctio         | https://loremflickr.com/200/200?lock=8328538599981056 |    60 |
| 23 | blanditiis voluptate  | odit cum ipsum aspernatur officiis iste veniam suscipit perferendis aspernatur               | https://picsum.photos/seed/BnUKk/200/200              |    85 |
| 24 | voluptatem voluptates | odio accusamus natus odio voluptatibus minus impedit saepe unde eligendi                     | https://picsum.photos/seed/iLc8bXe/200/200            |    84 |
| 25 | animi vel             | deleniti voluptatum velit facilis voluptatum suscipit nihil placeat esse nisi                | https://picsum.photos/seed/MZJbogz/200/200            |    73 |
| 26 | officia ratione       | nulla quam excepturi adipisci dolores numquam facilis beatae odio aperiam                    | https://loremflickr.com/200/200?lock=4406963767083008 |    98 |
| 27 | velit deserunt        | aperiam expedita ipsam quos suscipit accusamus magni at temporibus atque                     | https://picsum.photos/seed/2x7v4/200/200              |    23 |
| 28 | accusamus adipisci    | eligendi praesentium nostrum voluptatum cumque laborum debitis velit mollitia sit            | https://loremflickr.com/200/200?lock=5286963041009664 |    59 |
| 29 | praesentium adipisci  | aliquid nam asperiores consequuntur iusto fugit deleniti dignissimos quo adipisci            | https://loremflickr.com/200/200?lock=7432273158733824 |    18 |
| 30 | facilis praesentium   | temporibus non numquam molestias beatae nulla amet provident dolore eaque                    | https://loremflickr.com/200/200?lock=4508827791654912 |    96 |
| 31 | totam eligendi        | ut provident incidunt incidunt nisi ipsa expedita enim velit harum                           | https://loremflickr.com/200/200?lock=4730560809271296 |    26 |
+----+-----------------------+----------------------------------------------------------------------------------------------+-------------------------------------------------------+-------+
30 rows in set (0.01 sec)</code></pre></br>

<h3 id="link-구현부-검증">&gt; <code>Link</code> 구현부 검증</h3>
<p><strong>✔ <code>ambassador</code> 로그인</strong></p>
<p>먼저, link를 생성하고자 하는 유저는 <code>is_ambassador</code>가 true인 유저여야한다. 테스트시 꼭 ambassador endpoint로 로그인 시켜주자.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/9d5d72a7-6f51-4a99-9272-b3c8f932a13d/image.png" alt=""></p>
<p><strong>✔ link 생성하기</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/e3ebb1cd-da4b-4088-b0a3-6850b4ae76d5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/76f460cb-c3fe-4808-9fdd-34b2f37943ac/image.png" alt=""></p>
<p>이것이 무엇을 의미하느냐? </p>
<p>35번 id값을 지닌 유저(ambassador인 유저)가 판매 대리인으로써의 역할을 수행하고 동시에 7, 10번의 id값에 해당하는 product를 상품으로써 가진다는 것을 의미한다. 그리고 생성된 코드값 <strong><code>code</code></strong>는 추후 주문 시 꼭 필요한 데이터로 쓰일 것이다.</p>
</br>

<p><strong>✔ link를 통한 판매 통계 알아보기</strong></p>
<p>이 글을 쓰기 전 이미 몇가지 테스트를 해본 상태이므로 현재 수익이 찍혀있고, 주문 결제가 완료된 데이터가 있는 것은 양해부탁 드립니다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2354fdbf-0c02-4a97-86c9-eff39e0b32b4/image.png" alt=""></p>
<p>첫 번째 통계 데이터로 나온 <code>code:euaydmr</code>의 경우 이미 주문을 수행한 경우이다. 그리고 아래의 <code>code:hl4oh8</code>의 경우가 바로 위에서 생성한 새로운 link 데이터에 해당한다. 아직 코드만 생성이되있고 상품 갯수, 수익은 발생되지 않은 상태이다.</p>
</br>

<p><strong>✔ 중간 매핑 테이블(link_products) 확인해보기</strong></p>
<pre><code>mysql&gt; SELECT*FROM ambassador.link_products;
+----------------+-----------+--------+
| id             | productId | linkId |
+----------------+-----------+--------+
| 00000010000002 |         2 |      1 |
| 00000010000005 |         5 |      1 |
| 00000020000007 |         7 |      2 |
| 00000020000010 |        10 |      2 |
+----------------+-----------+--------+
4 rows in set (0.01 sec)</code></pre></br>

<h3 id="order-구현부-검증--stripe-검증">&gt; <code>Order</code> 구현부 검증 + <code>Stripe</code> 검증</h3>
<p>바로 앞전 포스팅에서 결제 주문 api를 생성하는 과정에서 바디의 Request Dto로 <strong><code>createOrderDto</code></strong> 객체를 준수하게끔 하였다. 해당 요청 객체를 준수하여 바디(전문)를 작성하면 아래와 같다.</p>
<pre><code>POST | http://localhost:8000/api/checkout/orders</code></pre><pre><code class="language-tsx">// body _createOrderDto

{
    &quot;first_name&quot;: &quot;Fritz&quot;,
    &quot;last_name&quot;: &quot;Donnelly&quot;,
    &quot;email&quot;: &quot;Dimitri_Littel-Kovacek@hotmail.com&quot;,
    &quot;address&quot;: &quot;ad&quot;,
    &quot;country&quot;: &quot;korea&quot;,
    &quot;city&quot;: &quot;seoul&quot;,
    &quot;zip&quot;: &quot;123456&quot;,
    &quot;code&quot;: &quot;hl4oh8&quot;,  // --&gt; 해당 코드는 앞서 생성한 링크를 통해 받아온 코드값이다.
    &quot;products&quot;: [
        {
            &quot;products_id&quot;: 7,
            &quot;quantity&quot;: 2
        },
        {
            &quot;products_id&quot;: 10,
            &quot;quantity&quot;: 3
        }
    ]
}</code></pre>
<p>위의 요청에 대한 응답을 확인해보자. 만약 위의 요청 형식에 어긋나는 혹은 틀린 정보가 기입되었다면, 결제 주문 생성에 대한 트랜잭션 처리중의 에러로 빠지게 될 것이고 Invalid Request Exception을 던질 것이다.</p>
<pre><code class="language-tsx">// response

{
    // transaction_id === sourceId
    &quot;id&quot;: &quot;cs_test_b1CJeeE9dMxRo1kWD26imUjR9vaFhVWB4K3fApgve432aMXBvNT387jTpW&quot;,
    &quot;object&quot;: &quot;checkout.session&quot;,
    &quot;after_expiration&quot;: null,
    &quot;allow_promotion_codes&quot;: null,
    &quot;amount_subtotal&quot;: 23000,
    &quot;amount_total&quot;: 23000,
    &quot;automatic_tax&quot;: {
        &quot;enabled&quot;: false,
        &quot;status&quot;: null
    },
    &quot;billing_address_collection&quot;: null,
    &quot;cancel_url&quot;: &quot;http://localhost:5000/error&quot;,
    &quot;client_reference_id&quot;: null,
    &quot;consent&quot;: null,
    &quot;consent_collection&quot;: null,
    &quot;created&quot;: 1688016792,
    &quot;currency&quot;: &quot;usd&quot;,
    &quot;currency_conversion&quot;: null,
    &quot;custom_fields&quot;: [],
    &quot;custom_text&quot;: {
        &quot;shipping_address&quot;: null,
        &quot;submit&quot;: null
    },
    &quot;customer&quot;: null,
    &quot;customer_creation&quot;: &quot;if_required&quot;,
    &quot;customer_details&quot;: null,
    &quot;customer_email&quot;: null,
    &quot;expires_at&quot;: 1688103192,
    &quot;invoice&quot;: null,
    &quot;invoice_creation&quot;: {
        &quot;enabled&quot;: false,
        &quot;invoice_data&quot;: {
            &quot;account_tax_ids&quot;: null,
            &quot;custom_fields&quot;: null,
            &quot;description&quot;: null,
            &quot;footer&quot;: null,
            &quot;metadata&quot;: {},
            &quot;rendering_options&quot;: null
        }
    },
    &quot;livemode&quot;: false,
    &quot;locale&quot;: null,
    &quot;metadata&quot;: {},
    &quot;mode&quot;: &quot;payment&quot;,
    &quot;payment_intent&quot;: null,
    &quot;payment_link&quot;: null,
    &quot;payment_method_collection&quot;: &quot;always&quot;,
    &quot;payment_method_options&quot;: {},
    &quot;payment_method_types&quot;: [
        &quot;card&quot;
    ],
    &quot;payment_status&quot;: &quot;unpaid&quot;,
    &quot;phone_number_collection&quot;: {
        &quot;enabled&quot;: false
    },
    &quot;recovered_from&quot;: null,
    &quot;setup_intent&quot;: null,
    &quot;shipping_address_collection&quot;: null,
    &quot;shipping_cost&quot;: null,
    &quot;shipping_details&quot;: null,
    &quot;shipping_options&quot;: [],
    &quot;status&quot;: &quot;open&quot;,
    &quot;submit_type&quot;: null,
    &quot;subscription&quot;: null,
    &quot;success_url&quot;: &quot;http://localhost:5000/success?source={CHECK_SESSION_ID}&quot;,
    &quot;total_details&quot;: {
        &quot;amount_discount&quot;: 0,
        &quot;amount_shipping&quot;: 0,
        &quot;amount_tax&quot;: 0
    },
    &quot;url&quot;: &quot;https://checkout.stripe.com/c/pay/cs_test_b1CJeeE9dMxRo1kWD26imUjR9vaFhVWB4K3fApgve432aMXBvNT387jTpW#fidkdWxOYHwnPyd1blpxYHZxWjA0S0lsS2hCZnRdRGtwPTJDM3FOdUBqPX1LRH10cnZCbGRIaEt2aVJuVG03aHFcPTFOXDx9ZmFNVEcyc0tuRDV0QX98YUFhSDJKfTFCSnd8THNxSUg1dVVMNTVdakBmdUxHUycpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPydocGlxbFpscWBoJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl&quot;
}</code></pre>
<p>Stripe를 통한 응답으로 많은 정보들이 출력된 것을 확인할 수 있다. 응답 객체의 모든 속성들에 대해 알아보진 않겠다. 아마 실제 결제가 아닌 테스트 환경이므로 대부분의 속성값들이 null일 것으로 예상한다. </p>
<p>우리가 사용할 부분은 가장 위의 <code>id</code>값, 즉 <strong>&quot;transaction_id&quot;</strong> 값이다. 이것은 꼭 기억해두자.</p>
<p>가장 마지막 응답 속성으로 <strong><code>url</code></strong> 주소가 나온것을 확인할 수 있다. 이곳으로 한번 이동해보자.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/f779da0d-6a09-4173-bdac-542e343ada1b/image.png" alt=""></p>
<p>결제창으로 이동한 것을 확인할 수 있다!!!</p>
<p>또한 결제창에서 확인할 수 있듯이 전체 가격(US $)과 주문 상품 이미지, title, description, 갯수(quantity)모두 우리가 링크 생성과 주문을 통해 요청한 그대로 반영된 것을 확인할 수 있다. </p>
<p><span style="color:green">첫 번째 상품 가격: 76$ * 2(quantity)</span></p>
<p><span style="color:green">두 번째 상품 가격: 26$ * 3(quantity)</span></p>
<pre><code class="language-tsx">mysql&gt; SELECT*FROM ambassador.product WHERE id IN(7,10);
+----+----------------+----------------------------------------------------------------------------------------+-------------------------------------------------------+-------+
| id | title          | description                                                                            | image                                                 | price |
+----+----------------+----------------------------------------------------------------------------------------+-------------------------------------------------------+-------+
|  7 | minus deserunt | esse necessitatibus eligendi in similique tempora numquam accusantium accusamus cumque | https://loremflickr.com/200/200?lock=7717237471313920 |    76 |
| 10 | eaque ad       | occaecati tenetur officiis iusto dignissimos cumque nam consequatur non quis           | https://picsum.photos/seed/Z3uhX3xQtq/200/200         |    26 |
+----+----------------+----------------------------------------------------------------------------------------+-------------------------------------------------------+-------+
2 rows in set (0.00 sec)</code></pre>
</br>

<p>orders 테이블에 아래와 같이 주문 생성이 반영된 것을 확인할 수 있다. 하지만 결제 확인절차를 아직 밟지 않았으므로 complete의 상태는 <strong>&quot;0&quot;</strong>이다.
(사실 id=1이 아니지만 임의로 1이라 가정한다)</p>
<pre><code>mysql&gt; SELECT*FROM ambassador.orders WHERE id=1;
+----+--------------------------------------------------------------------+---------+--------+------------------+------------+-----------+------------------------------------+---------+---------+-------+--------+----------+
| id | transaction_id                                                     | user_id | code   | ambassador_email | first_name | last_name | email                              | address | country | city  | zip    | complete |
+----+--------------------------------------------------------------------+---------+--------+------------------+------------+-----------+------------------------------------+---------+---------+-------+--------+----------+
| 1 | cs_test_b1CJeeE9dMxRo1kWD26imUjR9vaFhVWB4K3fApgve432aMXBvNT387jTpW |      35 | hl4oh8 | e@e.com          | Fritz      | Donnelly  | Dimitri_Littel-Kovacek@hotmail.com | ad      | korea   | seoul | 123456 |        0 |
+----+--------------------------------------------------------------------+---------+--------+------------------+------------+-----------+------------------------------------+---------+---------+-------+--------+----------+
1 row in set (0.00 sec)</code></pre></br>

<p>자, 그럼 확인절차를 밟아보자.</p>
<p>잠깐 결제확인(confirm) 라우트 핸들러부를 다시 살펴보면 아래와 같이 <code>transaction_id</code> 즉, sourceId를 통해 요청을 찌르는것을 확인할 수 있다.</p>
<pre><code class="language-tsx">  @Post(&#39;checkout/orders/confirm&#39;)
  async confirm(
    @Body(&#39;sourceId&#39;) sourceId: string,
  ) {
    return await this.orderService.orderConfirm(sourceId);
  }</code></pre>
<p>아래의 요청 경로로 이동해</p>
<pre><code>http://localhost:8000/api/checkout/orders/confirm/</code></pre><p>바디(전문)에 우리가 앞서 결제 생성시 발급받은 <code>transaction_id</code>값을 실어준다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/6417a474-f74e-456d-ab3f-61d080030e52/image.png" alt=""></p>
<p>결제 확인이 잘 수행되었다. </p>
<p>또한 orders 테이블을 보면, 결제 확인 전 false 상태였던 complete 값이 true로 변경된 것 또한 확인할 수 있다.</p>
<pre><code>mysql&gt; SELECT*FROM ambassador.orders WHERE id=1;
+----+--------------------------------------------------------------------+---------+--------+------------------+------------+-----------+------------------------------------+---------+---------+-------+--------+----------+
| id | transaction_id                                                     | user_id | code   | ambassador_email | first_name | last_name | email                              | address | country | city  | zip    | complete |
+----+--------------------------------------------------------------------+---------+--------+------------------+------------+-----------+------------------------------------+---------+---------+-------+--------+----------+
| 1 | cs_test_b1CJeeE9dMxRo1kWD26imUjR9vaFhVWB4K3fApgve432aMXBvNT387jTpW |      35 | hl4oh8 | e@e.com          | Fritz      | Donnelly  | Dimitri_Littel-Kovacek@hotmail.com | ad      | korea   | seoul | 123456 |        1 |
+----+--------------------------------------------------------------------+---------+--------+------------------+------------+-----------+------------------------------------+---------+---------+-------+--------+----------+
1 row in set (0.00 sec)</code></pre></br>

<h2 id="다음포스팅-예고">다음포스팅 예고</h2>
<p>다음포스팅에선 이번 포스팅까지 알아본 결제 프로세스를 바탕으로 레디스의 &quot;Sorted Set&quot;을 활용하여 랭킹 기능을 구현해보도록 하자.</p>
<p>생각정리는 생략하겠다...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] 주문처리과정을 통해 알아보는 수익률 랭킹 조회 (Part 2_ 주문처리와 결제기능 구현) #2]]></title>
            <link>https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-2-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EC%99%80-%EA%B2%B0%EC%A0%9C%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-2-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EC%99%80-%EA%B2%B0%EC%A0%9C%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Thu, 29 Jun 2023 02:51:19 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>바로 직전 포스팅에서 우리가 수행하고자 하는 주문처리과정을 위한 <strong>&quot;데이터베이스 설계&quot;</strong>를 수행하였다. </p>
<hr>
<p><a href="https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-1-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-DB-%EC%84%A4%EA%B3%84">Part 1_ DB 설계 (링크참조)</a></p>
<hr>
<p>이번 포스팅에선 설계한 테이블 구조와 필드 값들을 바탕으로 주문/결제 로직을 수행해보기로 한다.</p>
</br>

<h2 id="💢-사전-로직-설계하기-주문-로직-전">💢 사전 로직 설계하기 (주문 로직 전)</h2>
<p>주문 로직을 수행하는데 있어 테이블 설계도 마찬가지이지만 주문 로직을 설계하는 과정에 <strong>&quot;주문(Order)&quot;</strong>에 해당하는 서비스 레이어만 필요하진 않을 것이다. </p>
<blockquote>
<ol>
<li>먼저 관리자(<code>admin</code>) 권한의 상품(<code>product</code>)이 생성되어있어야 할 것이고</li>
<li>해당 상품은 판매 대리인(<code>ambassador</code>) 개개인의 <code>link</code>에 포함되어야 할 것이다.</li>
<li>이러한 <code>link</code> 정보를 바탕으로 주문 로직(<code>order &amp;  orderItem</code>)을 작성할 수 있고 </li>
<li>최종 결제 및 결제 확인 절차를 밟을 수 있다.</li>
</ol>
</blockquote>
<p>위의 프로세스를 바탕으로 서비스 레이어를 작성해보자.</p>
</br>

<h3 id="abstractservice">&gt; <code>AbstractService</code></h3>
<p>해당 서비스 레이어는 주문 과정에 직접적으로 필요한 유의미한 부분은 아니지만, <code>userService</code>, <code>orderService</code>, <code>productService</code> ... 이러한 모든 서비스 레이어에 거의 <strong>&quot;공통적으로&quot;</strong> 요구되는 <strong>&quot;CRUD&quot;</strong>를  추상화시키기 위해 작성하게 되었다. </p>
<p>굳이, 한번 더 각각의 엔티티 레포지터리에 접근해 crud를 수행하지 않아도 될 것이다.</p>
<pre><code class="language-tsx">// abstract.service.ts

import { Repository } from &quot;typeorm&quot;;

export abstract class AbstractService {
  protected constructor(
    protected readonly repository: Repository&lt;any&gt;
  ) {}

  async save(options) {
    return await this.repository.save(options);
  }

  async find(options) {
    return this.repository.find(options);
  }

  async findOne(options) {
    return this.repository.findOne(options);
  }

  async update(id: number, options) {
    return this.repository.update(id, options);
  }

  async delete(id: number) {
    return this.repository.delete(id);
  }

  async createQueryBuilder(alias: string) {
    return this.repository.createQueryBuilder(alias);
  }
}</code></pre>
</br>

<h3 id="product-layer">&gt; <code>Product</code> Layer</h3>
<p><strong>✔ ProductService</strong></p>
<pre><code class="language-tsx">// product.service.ts

import { Injectable, NotFoundException } from &#39;@nestjs/common&#39;;
import { ProductRepository } from &#39;./repository/product.repository&#39;;
import { AbstractService } from &#39;../shared/abstract.service&#39;;
import { ProductUpdateDto } from &#39;./model/product-update.dto&#39;;
import { Product } from &#39;./model/product.entity&#39;;

@Injectable()
export class ProductService extends AbstractService{
  constructor(
    private readonly productRepository: ProductRepository,
  ) {
    super(productRepository);
  }

  async findProductById(id: number) {
    return await this.findOne({
      where: {
        id: id,
      },
    });
  }

  async updateProductInfo(id: number, data: ProductUpdateDto): Promise&lt;Product&gt; {
    const product = await this.findProductById(id);

    if (!product) {
      throw new NotFoundException(`해당 ${id} 의 유저정보는 존재하지 않습니다.`);
    }

    await this.update(id, data);

    const updatedProduct = await this.findProductById(id);
    return updatedProduct;
  }
}</code></pre>
</br>

<p><strong>✔ ProductController</strong></p>
<pre><code class="language-tsx">// product.controller.ts

import { Body, Controller, Delete, Get, Param, Post, Put, Req, UseGuards, UseInterceptors } from &#39;@nestjs/common&#39;;
import { ProductService } from &#39;./product.service&#39;;
import { ProductCreateDto } from &#39;./model/product-create.dto&#39;;
import { ProductUpdateDto } from &#39;./model/product-update.dto&#39;;
import { JwtAccessAuthGuard } from &#39;../auth/utils/guard/jwt-access.guard&#39;;
import { CacheInterceptor, CacheKey, CacheTTL } from &#39;@nestjs/cache-manager&#39;;
import { ProductCacheService } from &#39;./product-cache.service&#39;;
import { EventEmitter2 } from &#39;@nestjs/event-emitter&#39;;
import { Request } from &#39;express&#39;;

@Controller()
export class ProductController {
  constructor(
    private readonly productService: ProductService,
    private readonly productCacheService: ProductCacheService,
    private eventEmitter: EventEmitter2,
  ) {}

  // no-caching
  @UseGuards(JwtAccessAuthGuard)
  @Get(&#39;admin/products&#39;)
  async all() {
    return await this.productService.find({});
  }

  @UseGuards(JwtAccessAuthGuard)
  @Post(&#39;admin/products&#39;)
  async create(
    @Body() productCreateDto: ProductCreateDto,
  ) {
    const product = await this.productService.save(productCreateDto);
    this.eventEmitter.emit(&#39;product_updated&#39;);
    return product;
  }

  @UseGuards(JwtAccessAuthGuard)
  @Get(&#39;admin/products/:id&#39;)
  async get(@Param(&#39;id&#39;) id: number) {
    return await this.productService.findProductById(id);
  }

  @UseGuards(JwtAccessAuthGuard)
  @Put(&#39;admin/products/:id&#39;)
  async update(
    @Param(&#39;id&#39;) id: number,
    @Body() productUpdateDto: ProductUpdateDto, 
  ) {
    this.eventEmitter.emit(&#39;product_updated&#39;);

    return this.productService.updateProductInfo(id, productUpdateDto);
  }

  @UseGuards(JwtAccessAuthGuard)
  @Delete(&#39;admin/products/:id&#39;)
  async delete(@Param(&#39;id&#39;) id: number) {
    const response = await this.productService.delete(id); 
    this.eventEmitter.emit(&#39;product_updated&#39;);
    return response;
  }

  @CacheKey(&#39;products_cache&#39;)
  @CacheTTL(30 * 60) // 30 minutes
  @UseInterceptors(CacheInterceptor)
  @Get(&#39;ambassador/products/findAll&#39;)
  async findAllWithCache() {
    return this.productService.find({});
  }
}</code></pre>
<p>간단히 <code>product</code>의 생성/조회/수정/삭제(crud)를 위한 핸들러 함수이고, 해당 함수를 실행하는데 있어, <code>admin</code>과 <code>ambassador</code> 경로에 따라 접근 권한을 차등하게 부여하기 위해 <strong><code>@UseGuards(JwtAccessAuthGuard)</code></strong>를 해당 라우트 핸들러 함수에 주입하였다. </p>
<hr>
<p><strong>※ JwtAccessAuthGuard 알아보기</strong></p>
<p><a href="https://velog.io/@from_numpy/NestJS-Implementing-Scopes-for-Multiple-Routes-feat.-JWT-Auth">Implementing Scopes for Multiple Routes _ JWT Guard ✔</a></p>
<hr>
<p><strong>참고로</strong> 위 컨트롤러에 작성된 마지막 메서드인 <code>findAllWithCache</code>는 지금 당장 설명하진 않겠다. 간단히 말하자면 상품 목록 조회를 캐싱처리한 것이고, 이는 레디스를 통해 구현되었다. </p>
<p>하지만, 조회 시에 이미 상품 데이터가 캐싱처리 되었기 때문에 업데이트 로직에서 업데이트를 한다 한들 이는 조회 응답시에 반영되지 않는다. </p>
<p>이를 위해서 우린 <strong>&quot;조회(Read)&quot;</strong>가 <span style="color:red">아닌</span> 나머지 로직(Create, Update, Delete)에 대해서 캐시 무효화 처리를 해줄 필요가 있다. 컨트롤러 단에서 바로 <code>cachemanager</code>를 사용하여 <code>delete</code> 해줄 수도 있겠지만, 아래와 같이 <code>EventEmitter</code>를 통해 이벤트를 던짐으로써 처리한다.</p>
<pre><code class="language-tsx">this.eventEmitter.emit(&#39;product_updated&#39;);</code></pre>
<p><code>product_updated</code>를 던져준 <code>EventEmitter</code>에 대한 구현부는 따로 생략하겠다. 현재 주제에선 조금 벗어나므로, <span style="color:red">바로 다음 포스팅에서 다뤄보도록 하겠다.</span></p>
</br>

<h3 id="link-layer">&gt; <code>Link</code> layer</h3>
<p><code>link</code> layer에선 어떠한 로직을 다루고 처리해야할까?</p>
<p>이전 포스팅의 테이블 설계부에서도 언급하였다시피 <code>link</code>는 <strong>판매 대리인(<code>ambassador</code>)</strong>과 주문을 이어주기 위한 계층이다. 먼저 <code>product</code>를 바탕으로 <code>link</code>를 생성하는 부분이 필요할 것이고, 추후 완료된 <u>주문에 대한 정보</u> (주문 완료 상품 갯수, 판매 대리인 수익...)에 대한 응답을 던져줘야할 부분또한 필요할 것이다. </p>
</br>

<p><strong>✔ LinkService</strong></p>
<p>전체 로직은 아래와 같다.</p>
<pre><code class="language-tsx">// link.service.ts

import { Injectable } from &#39;@nestjs/common&#39;;
import { AbstractService } from &#39;../shared/abstract.service&#39;;
import { LinkRepository } from &#39;./repository/link.repository&#39;;
import { User } from &#39;../user/model/user.entity&#39;;
import { ProductRepository } from &#39;../product/repository/product.repository&#39;;
import { LinkProductRepository } from &#39;./repository/link-product.repository&#39;;
import { In } from &#39;typeorm&#39;;
import { Product } from &#39;../product/model/product.entity&#39;;
import { LinkProduct } from &#39;./model/link-product.entity&#39;;
import { Link } from &#39;./model/link.entity&#39;;
import { Order } from &#39;../order/model/order.entity&#39;;

@Injectable()
export class LinkService extends AbstractService {
  constructor(
    private readonly linkRepository: LinkRepository,
    private readonly productRepository: ProductRepository,
    private readonly linkProductRepository: LinkProductRepository
  ) {
    super(linkRepository);
  }

  async findLinkByUserId(id: number) {
    return await this.find({
      where: {
        user: id,
      },
      relations: [&#39;orders&#39;],
    });
  }

  async createLink(user: User, products: number[]) {
    const newLink: Link =  await this.save({  
      code: Math.random().toString(36).substring(6),
      user,
      linkProducts: products.map(id =&gt; ({ product: { id }}))
    });

    const currentProducts: Product[] = await this.productRepository.find({
      where: {
        id: In(products),
      },
      relations: [&#39;linkProducts&#39;]
    });

    const newLinkProducts: LinkProduct[] = currentProducts
      .map(product =&gt; {
        const lp = new LinkProduct();
        lp.link = newLink;
        lp.product = product;
        return lp;
      })

    await this.linkProductRepository.save(newLinkProducts);

    return newLink;
  }

  async stats(user: User) {
    const links: Link[] = await this.find({
      user,
      relations: [&#39;orders&#39;, &#39;orders.order_items&#39;]
    });

    return links.map(link =&gt; {
      const completedOrders: Order[] = link.orders.filter(o =&gt; o.complete);

      return {
        code: link.code,
        count: completedOrders.length,
        revenue: completedOrders.reduce((s: number, o: Order) =&gt; s + o.ambassador_revenue, 0),
      }
    })
  }

  async findLinkByCode(code: string, relations: string[]) {
    const link = await this.findOne({
      where: {
        code: code,
      },
      relations: relations,
    })
    return link;
  }
}
</code></pre>
<p>그럼 몇개의 중요 메서드에 대해 알아보자.</p>
<p>먼저, <code>link</code>의 생성부이다.</p>
<pre><code class="language-tsx">  // 매개변수의 `products`는 number[]로써 이는 product의 pk(`id`)값이 될 것이다.
  // 이러한 product id값은 POST 요청시 전문에 실을 요청객체이다.
  async createLink(user: User, products: number[]) {
    // 생성될 link의 정보를 저장한다.
    const newLink: Link =  await this.save({
      // 길이가 6인 랜덤한 문자열을 생성 (고유값을 가지게끔한다)
      code: Math.random().toString(36).substring(6),
      // 컨트롤러에서 받아오게 될 해당 user는 요청 프로세스의 유저 객체가 될 것이다.
      // 또한 해당 유저 객체는 쿠키를 통해 인증 검증이 완료된 유저여야 할 것이다.
      user,
      // 중간 매핑 테이블 `linkProduct`에서 product의 `id`값을 받아올 수 있다.
      linkProducts: products.map(id =&gt; ({ product: { id }}))
    });

    // 요청시에 전문에 실은 products id값의 배열을 통해서 product 데이터 자체를 가져올 수 있다.
    const currentProducts: Product[] = await this.productRepository.find({
      where: {
        id: In(products),
      },
      relations: [&#39;linkProducts&#39;]
    });

    // 위에서 구한 `currentProducts`를 바탕으로 각 product를 받아올 수 있고, 
    // 앞서 생성한 새로운 `newLink`와 `product`를 통해 중간 매핑 엔티티 `LinkProduct`를 정의할 수 있다.
    const newLinkProducts: LinkProduct[] = currentProducts
      .map(product =&gt; {
        const lp = new LinkProduct();
        lp.link = newLink;
        lp.product = product;
        return lp;
      })

    // 생성한 `newLinkProducts`를 레포지터리에 접근해 저장한다.
    await this.linkProductRepository.save(newLinkProducts);

    return newLink;
  }</code></pre>
</br>

<p>다음으로는 주문 완료 후, 요청 프로세스의 유저에 따른 링크 정보를 응답해주는 로직이다. 통계라고 생각하면 좋을 것이다.</p>
<pre><code class="language-tsx">  async stats(user: User) {
    const links: Link[] = await this.find({
      user,
      // relations 조건으로 orders 테이블 자체뿐 아니라, order_itmes 테이블도 불러와야한다.
      relations: [&#39;orders&#39;, &#39;orders.order_items&#39;]
    });

    if (links) {
      throw new NotFoundException(`Cannot find ${links} with this ${user}`);
    }

    // 각각의 link에 대한 응답처리
    return links.map(link =&gt; {
      // 주문이 완료된 건에 대해 처리
      const completedOrders: Order[] = link.orders.filter(o =&gt; o.complete);

      return {
        code: link.code,
        count: completedOrders.length,
        revenue: completedOrders.reduce((s: number, o: Order) =&gt; s + o.ambassador_revenue, 0),
      }
    })
  }</code></pre>
</br>

<p><strong>✔ LinkController</strong></p>
<p><code>ProductController</code>와 마찬가지로 <code>admin</code>과 <code>ambassador</code>가 포함된 엔드포인트의 핸들러 함수에는 인증 가드인 <code>JwtAccessAuthGuard</code>를 주입한다.</p>
<pre><code class="language-tsx">// link.controller.ts

import { Body, ClassSerializerInterceptor, Controller, Get, Param, Post, Req, UseGuards, UseInterceptors } from &#39;@nestjs/common&#39;;
import { LinkService } from &#39;./link.service&#39;;
import { JwtAccessAuthGuard } from &#39;../auth/utils/guard/jwt-access.guard&#39;;
import { AuthService } from &#39;../auth/auth.service&#39;;
import { Request } from &#39;express&#39;;
import { User } from &#39;../user/model/user.entity&#39;;

@UseInterceptors(ClassSerializerInterceptor)
@Controller()
export class LinkController {
  constructor(
    private readonly linkService: LinkService,
    private authService: AuthService,
  ) {}

  @UseGuards(JwtAccessAuthGuard)
  @Get(&#39;admin/users/:id/links&#39;)
  async all(@Param(&#39;id&#39;) id: number) {
    return this.linkService.findLinkByUserId(id)
  }

  @UseGuards(JwtAccessAuthGuard)
  @Post(&#39;ambassador/links&#39;)
  async create(
    @Body(&#39;products&#39;) products: number[],
    @Req() req: Request,
  ) {
    // `authService`를 통해 인증 검증이 된 유저를 불러온다. 이는 쿠키를 통해 접근한다.
    const user: User = await this.authService.findUserByAuthenticate(req);

    return await this.linkService.createLink(user, products);
  }

  @UseGuards(JwtAccessAuthGuard)
  @Get(&#39;ambassador/stats&#39;)
  async stats(
    @Req() req: Request
  ) {     
    // `authService`를 통해 인증 검증이 된 유저를 불러온다. 이는 쿠키를 통해 접근한다.
    const user: User = await this.authService.findUserByAuthenticate(req);
    return await this.linkService.stats(user);
  }

  @Get(&#39;checkout/links/:code&#39;)
  async link(@Param(&#39;code&#39;) code: string) {
    return this.linkService.findLinkByCode(code, [&#39;user&#39;, &#39;linkProducts&#39;, &#39;linkProducts.product&#39;]);
  }
}</code></pre>
<br>

<pre><code class="language-tsx">  // auth.service.ts

  async findUserByAuthenticate(req: Request, relations?: string[]): Promise&lt;User&gt; {
    const cookie = req.cookies[&#39;access_token_1&#39;];
    const { id } = await this.jwtService.verifyAsync(cookie);
    const user = await this.userService.findUserById(id, relations);
    return user;
  }</code></pre>
<p><strong>✔ LinkModule</strong></p>
<pre><code class="language-tsx">// link.module.ts

import { Module } from &#39;@nestjs/common&#39;;
import { LinkController } from &#39;./link.controller&#39;;
import { LinkService } from &#39;./link.service&#39;;
import { TypeOrmModule } from &#39;@nestjs/typeorm&#39;;
import { Link } from &#39;./model/link.entity&#39;;
import { TypeOrmExModule } from &#39;../common/repository-module/typeorm-ex.decorator&#39;;
import { LinkRepository } from &#39;./repository/link.repository&#39;;
import { LinkProductRepository } from &#39;./repository/link-product.repository&#39;;
import { LinkProductService } from &#39;./link-product.service&#39;;
import { SharedModule } from &#39;../shared/shared.module&#39;;
import { AuthModule } from &#39;../auth/auth.module&#39;;
import { ProductModule } from &#39;../product/product.module&#39;;
import { ProductRepository } from &#39;../product/repository/product.repository&#39;;

@Module({
  imports: [
    TypeOrmModule.forFeature([Link, LinkModule]),
    TypeOrmExModule.forCustomRepository([LinkRepository, LinkProductRepository, ProductRepository]),
    SharedModule,
    AuthModule,
    ProductModule,
  ],
  controllers: [LinkController],
  providers: [LinkService, LinkProductService],
  exports: [LinkService],
})
export class LinkModule {}</code></pre>
</br>

</br>


<h2 id="💢-주문-로직-설계-with-stripe결제모듈">💢 주문 로직 설계 with Stripe(결제모듈)</h2>
<p>어쩌면 가장 중요할 수 있는 <code>Order</code> 즉, 주문 로직이다. 주문 파트에는 주문 목록 생성뿐 아니라 결제까지 처리되도록 구현해보았다. <code>OrderItem</code>을 불러오는 것 뿐만 아니라, <code>Product</code>, <code>User</code>, <code>Link</code> 등 여러 구현부와 커뮤니케이션이 되어야할 것이다. 그리고 이러한 과정을 최종적으로 <strong>&quot;결제&quot;</strong>에 담아낼 수 있어야 한다.</p>
<h3 id="create-order-with-transaction트랜잭션">&gt; Create <code>Order</code> with <code>Transaction(트랜잭션)</code></h3>
<p><strong>✔ OrderService - 주문 생성(createOrder)</strong></p>
<pre><code class="language-tsx">// order.service.ts

import { BadRequestException, Injectable, NotFoundException } from &#39;@nestjs/common&#39;;
import { AbstractService } from &#39;../shared/abstract.service&#39;;
import { OrderRepository } from &#39;./repository/order.repository&#39;;
import { LinkService } from &#39;../link/link.service&#39;;
import { CreateOrderDto } from &#39;./model/create-order.dto&#39;;
import { Link } from &#39;../link/model/link.entity&#39;;
import { Order } from &#39;./model/order.entity&#39;;
import { ProductService } from &#39;../product/product.service&#39;;
import { OrderItem } from &#39;./model/order-item.entity&#39;;
import { Product } from &#39;../product/model/product.entity&#39;;
import { DataSource } from &#39;typeorm&#39;;
import { StripeService } from &#39;./stripe.service&#39;;

@Injectable()
export class OrderService extends AbstractService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly linkService: LinkService,
    private readonly productService: ProductService,
    private readonly stripeService: StripeService,
    private dataSource: DataSource,
  ) {
    super(orderRepository);
  }

  async createOrder(createOrderDto: CreateOrderDto) {

    // (1)
    const link: Link = await this.linkService.findOne({
      where: {
        code: createOrderDto.code,
      },
      relations: [&#39;user&#39;],
    });

    if (!link) {
      throw new BadRequestException(&#39;Invalid link!&#39;);
    }

    const queryRunner = this.dataSource.createQueryRunner();

    // (2)
    try {
      await queryRunner.connect();
      await queryRunner.startTransaction();

      // (3)
      const newOrder = new Order();
      newOrder.user_id = link.user.id;
      newOrder.ambassador_email = link.user.email;
      newOrder.first_name = createOrderDto.first_name;
      newOrder.last_name = createOrderDto.last_name;
      newOrder.email = createOrderDto.email;
      newOrder.address = createOrderDto.address;
      newOrder.country = createOrderDto.country;
      newOrder.city = createOrderDto.city;
      newOrder.zip = createOrderDto.zip;
      newOrder.code = createOrderDto.code;

      const order: Order = await queryRunner.manager.save(newOrder);

      const line_items = [];

      for (let productsDetail of createOrderDto.products) {
        // (4)
        const product: Product = await this.productService.findOne({
          where: {
            id: productsDetail.products_id,
          },
        });

        // (5)
        const orderItem = new OrderItem();
        orderItem.order = order;
        orderItem.product_title = product.title;
        orderItem.price = product.price;
        orderItem.quantity = productsDetail.quantity;
        orderItem.ambassador_revenue = 0.1 * product.price * productsDetail.quantity;
        orderItem.admin_revenue = 0.9 * product.price * productsDetail.quantity;

        await queryRunner.manager.save(orderItem);

        // (6)
        line_items.push({
          price_data: {
            currency: &#39;usd&#39;,
            product_data: {
              name: product.title,
              description: product.description,
              images: [product.image],
            },
            unit_amount: 100 * product.price,
          },
          quantity: productsDetail.quantity,
        });
      } 

      // (7)
      const source = await this.stripeService.createCheckoutSession(line_items);

      order.transaction_id = source[&#39;id&#39;];

      await queryRunner.manager.save(order);

      // (8)
      await queryRunner.commitTransaction();

      return source;

    // (9)
    } catch (e) {
      console.error(e);
      await queryRunner.rollbackTransaction();

      throw new BadRequestException();
    } finally {
      await queryRunner.release();
    }
  }

 }</code></pre>
<p><strong>주문 생성부</strong>의 전체 로직은 위와 같다. 물론 더 깔끔한 코드가 있고 개선시킬 수 있는 부분이 있을 것이다. </p>
<p>몇 가지 코드를 분석하기 전 <strong><code>createOrder()</code></strong> 메서드의 실행 흐름에 대해 알아보자. 주문 생성로직은 <strong>트랜잭션(transaction)</strong>의 흐름하에 진행된다.</p>
</br>

<hr>
<p><span style="color:green">주문 생성 로직에 <strong>트랜잭션</strong>을 사용한 이유는?</span></p>
<p>트랜잭션을 사용하는 이유는 일반적으로 데이터 일관성과 안전성을 보장하고 예외 상황에 대비하여 롤백을 수행하기 위해서이다. 이를 통해 데이터베이스 상태를 일관되고 안정적으로 유지할 수 있다.</p>
<p>주문 생성 로직에는 단순히 <code>orders</code> 테이블만 관여하는것이 아닌, <u>관련된 여러 테이블이 사용되고 업데이트 된다</u>. 이를 일관성있게 유지하는 작업은 굉장히 중요하다. </p>
<p>우린 nestjs에서 제공하는 <strong><code>Datasource</code></strong>를 통해 <u>트랜잭션을 실행할 수 있고</u> 이에 따라 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)를 보장받을 수 있다.</p>
<hr>
</br>

<p><strong>주문 생성 로직(<code>createOrder()</code>) 플로우 알아보기</strong></p>
<blockquote>
<ol>
<li>앞서 <code>link</code>생성을 통해 발급받은 고유 코드(code)를 주문 요청 시 입력함으로써 해당 코드에 대한 <code>link</code> 객체를 불러온다. 만약 <code>link</code>가 존재하지 않을 경우의 에러처리를 진행해준다.</li>
<li><code>queryRunner</code>를 통해 트랜잭션을 실행한다(아래에서 자세히 설명)</li>
<li>요청 시 바디(전문)에 실게되는 값과 <code>link</code>를 통해 받아온 값들을 일치시키는 과정을 통하여 새로운 주문(<code>order</code>)객체를 생성한다.</li>
<li>각 <code>link</code>마다 존재하는 개별적 product 객체를 받아온다. (요청 products로부터 product 추출) </li>
<li>받아온 개별적 product 데이터를 토대로 주문 상품에 대한 객체를 정의및 생성한다.</li>
<li>사용하게되는 결제 모듈인 <strong>&quot;Stripe&quot;</strong>에서 <u>제시하는 규칙에 맞게</u> 아이템 리스트를 생성한다. 이는 꼭 &quot;<strong><code>line_items</code></strong>&quot;란 이름을 가져야한다.</li>
<li>다음으로 <strong><code>StripeService</code></strong>에서 생성한 &quot;결제 세션&quot;을 바탕으로 <strong>세션 객체</strong>(<code>source</code>로 받아옴)를 불러온 후, 해당 <strong>세션 아이디값</strong>(식별자)을 주문 트랜잭션 아이디값으로써 저장한다.</li>
<li>이렇게 주문 생성 로직을 수행한 후 성공시에(try문) 해당 작업을 커밋 처리한다.</li>
<li>트랜잭션 실패 시에 롤백처리를 하고, 트랜잭션이 끝날경우 데이터베이스 리소스를 해제한다.</li>
</ol>
</blockquote>
</br>

<p>코드 하나하나에 대한 세세한 설명은 생략하고 <u>꼭 알고 넘어가야할 부분</u> 및 위의 <strong><code>createOrder()</code></strong> 메서드에서 생략된 구현 코드에 대해 알아보자.</p>
</br>

<p><strong>✔ CreateOrderDto</strong></p>
<p>해당 부분은 <strong><code>Request Dto</code></strong>로써 주문 생성시의 요청 객체 형식을 제약한다. </p>
<pre><code class="language-tsx">// create-order.dto.ts

import { IsArray, IsEmail, IsNotEmpty, IsString } from &quot;class-validator&quot;;

export class CreateOrderDto {

  @IsNotEmpty()
  @IsString()
  first_name: string;

  @IsNotEmpty()
  @IsString()
  last_name: string;

  @IsNotEmpty()
  @IsEmail()
  email: string;

  @IsNotEmpty()
  @IsString()
  address: string;

  @IsNotEmpty()
  @IsString()
  country: string;

  @IsNotEmpty()
  @IsString()
  city: string;

  @IsNotEmpty()
  @IsString()
  zip: string;

  @IsNotEmpty()
  @IsString()
  code: string;

  @IsNotEmpty()
  @IsArray()
  products: {
    products_id: number;
    quantity: number;
  }[]
}</code></pre>
<p><code>createOrder()</code> 내부에서 새로운 Order 객체를 생성할 시에 사용한 것을 앞서 확인할 수 있었다.</p>
<pre><code class="language-tsx">      const newOrder = new Order();
      newOrder.user_id = link.user.id;
      newOrder.ambassador_email = link.user.email;
      newOrder.first_name = createOrderDto.first_name;
      newOrder.last_name = createOrderDto.last_name;
      newOrder.email = createOrderDto.email;
      newOrder.address = createOrderDto.address;
      newOrder.country = createOrderDto.country;
      newOrder.city = createOrderDto.city;
      newOrder.zip = createOrderDto.zip;
      newOrder.code = createOrderDto.code;</code></pre>
</br>

<h3 id="💡-stripe-결제-모듈을-사용해보자-nestjs">&gt; 💡 <code>Stripe</code> 결제 모듈을 사용해보자 (<code>nestjs</code>)</h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/4dae4c2b-3de1-487c-aa68-116627e11e55/image.png" alt=""></p>
<p><strong>✔ Stripe는 무엇인가?</strong></p>
<p>흔히, 결제 시스템 구축을 위해 많은 경우에 (저도 정확히 모릅니다...) &quot;아임포트&quot;를 통한 <strong>&quot;Paypal(페이팔)&quot;</strong> 결제 시스템을 사용한다. <strong>Stripe</strong>는 비교적? 최근에 만들어진 결제 시스템으로 국내는 아니지만 해외에선 Paypal과 더불어 많이 사용하는 추세에 있다. </p>
<p>현재 진행하는 이 간단한 프로젝트에서 어떤 결제모듈을 쓰는가가 중요하진 않을 것이고, 조금 더 간단하게 접근할 수 있는 결제 모듈을 사용하기로 했다. <u>한국 계좌에 대한 지원을 해주고 있지 않기 때문에 국내에서 실 사용에 대한 문제는 여전하지만</u> (실제로 stripe-developer 페이지에서 일련의 등록 과정에서 해외계좌및 카드만을 요구하는 것을 확인할 수 있었다.) 개발과정에선 확실히 간편한 로직으로 구현할 수 있다 생각이 든다. 또한, 개발과정에서 <u><strong>대시보드</strong>를 지원해주기 때문에</u>(타 결제사도 지원해주는지 사실 모른다) 에러 및,<strong><code>요청-응답</code></strong> 객체의 테스트또한 손 쉽게 확인할 수 있었다.</p>
</br>

<p><strong>✔ 프로젝트에서 어떻게 사용했는가? with NestJS</strong></p>
<p>우리가 수행중인 nestjs 프로젝트에선 어떻게 해당 Stripe 모듈을 사용할 수 있을까?</p>
<p>먼저, Stripe를 설치해주어야한다. 아래의 npm 사이트를 통해 진행할 수 있다. </p>
<hr>
<p><a href="https://www.npmjs.com/package/nestjs-stripe">nestjs-stripe npm</a></p>
<pre><code>npm install --save nestjs-stripe</code></pre><hr>
<hr>
<p><span style="color:red"><strong>하지만!!!</strong></span> 위의 페이지대로 <u>현재 nestjs에서 버전에 적용할 시 제대로 수행되지 않을 것이다</u>. 아래는 위의 npm에서 제시하는 모듈 작성법이다. <code>apiKey</code>는 동일하게 stripe developer 사이트에서 받아올 수 있지만, <strong><code>apiVersion</code></strong>과 같은 경우는 <code>**&#39;2020-08-27&#39;</code><strong>이 <span style="color:red">아닌</span>, **<code>&#39;2022-11-15&#39;</code></strong> 로 작성되어야한다. </p>
<pre><code class="language-tsx">import { Module } from &#39;@nestjs-common&#39;;
import { StripeModule } from &#39;nestjs-stripe&#39;;

@Module({
  imports: [
    StripeModule.forRoot({
      apiKey: &#39;my_secret_key&#39;,
      apiVersion: &#39;2020-08-27&#39;,
    }),
  ],
})
export class AppModule {}</code></pre>
<hr>
<pre><code class="language-tsx">// order.module.ts

import { Module } from &#39;@nestjs/common&#39;;
import { OrderController } from &#39;./order.controller&#39;;
import { OrderService } from &#39;./order.service&#39;;
import { TypeOrmModule } from &#39;@nestjs/typeorm&#39;;
import { Order } from &#39;./model/order.entity&#39;;
import { TypeOrmExModule } from &#39;../common/repository-module/typeorm-ex.decorator&#39;;
import { OrderRepository } from &#39;./repository/order.repository&#39;;
import { OrderItem } from &#39;./model/order-item.entity&#39;;
import { OrderItemRepository } from &#39;./repository/order-item.repository&#39;;
import { OrderItemService } from &#39;./order-item.service&#39;;
import { LinkModule } from &#39;../link/link.module&#39;;
import { ProductModule } from &#39;../product/product.module&#39;;
import { StripeModule } from &#39;nestjs-stripe&#39;;
import { ConfigModule, ConfigService } from &#39;@nestjs/config&#39;;
import { OrderListener } from &#39;./listeners/order.listener&#39;;
import { StripeService } from &#39;./stripe.service&#39;;

@Module({
  imports: [
    TypeOrmModule.forFeature([Order, OrderItem]),
    TypeOrmExModule.forCustomRepository([OrderRepository, OrderItemRepository]),
    LinkModule,
    ProductModule,
    StripeModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) =&gt; ({
        // apiKey는 stripe-developer 사이트에서 발급받을 수 있습니다.
        // test 단계시라면 계좌 등록을 요구하지 않고 발급받을 수 있는 임시 키를 사용하시기 바랍니다.
        apiKey: configService.get&lt;string&gt;(&#39;STRIPE_KEY&#39;),
        // apiVersion 날짜 문자를 꼭 아래와 같이 작성하세요.
        apiVersion: &#39;2022-11-15&#39;
      }),
      inject: [ConfigService]
    }),
  ],
  controllers: [OrderController],
  providers: [OrderService, OrderItemService, OrderListener, StripeService]
})
export class OrderModule {}</code></pre>
</br>

<p>이렇게 모듈단에서 기본적인 설정이 끝났으면 앞서 우리가 확인한 <strong><code>orderService</code></strong> 레이어에서 <strong><code>Stripe</code></strong> 모듈을 사용할 수 있다.</p>
<pre><code class="language-tsx">      // ....       

      const line_items = [];

      for (let productsDetail of createOrderDto.products) {

        // ....

        line_items.push({
          price_data: {
            currency: &#39;usd&#39;,
            product_data: {
              name: product.title,
              description: product.description,
              images: [product.image],
            },
            unit_amount: 100 * product.price,
          },
          quantity: productsDetail.quantity,
        });
      } </code></pre>
<p>위에서 보듯이 <strong><code>line_items</code></strong>라는 빈 배열을 선언한 뒤 해당 빈 배열에 product를 통해 받아온 데이터를 특정한 형식에 맞춰서 삽입해주는 것을 볼 수 있다. </p>
<p><strong><code>line_item</code></strong>는 추후 <strong>&quot;결제 세션&quot;</strong> 생성에 사용되는데 이때 정확히 <strong>&quot;line_items&quot;</strong>라는 이름으로 들어가야한다. 이는 &quot;stripe&quot; 자체에서 <u>제시하는 이름</u>이므로 꼭 <strong><code>line_items</code></strong>라는 명칭으로 사용해야한다.</p>
<hr>
<p>※ Stripe 문서 참조</p>
<p><a href="https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-line_items">[Stripe] create_checkout session-line_itmes - 링크</a></p>
<hr>
<p>또한, <strong><code>nestjs-stripe</code></strong> 모듈의 <u>버전 업그레이드 이후</u>로 기존과는 다른 형식으로 <code>line_items</code>를 채워가야한다. 위에서 사용된 <code>price_data</code>, <code>product_data</code>등과 같은 일종의 형식을 지켜야한다는 것이다. 이 역시 위의🔼 링크에 제시되어 있다. <span style="color:red">(꼭 참조바랍니다)</span></p>
</br>

<p>주문 생성 과정중 마지막으로 알아봐야할 부분이 바로 위에서도 언급하였던 <strong>&quot;결제 세션 생성부&quot;</strong>이다.</p>
<p>이는 주문을 생성한 주문 건에 관해 <strong>transaction_id</strong>를 받게끔 한다.</p>
<pre><code class="language-tsx">// order.service.ts _ createOrder() 구현부 중

const source = await this.stripeService.createCheckoutSession(line_items);

order.transaction_id = source[&#39;id&#39;];</code></pre>
<p><code>createOrder()</code> 내부에서 위와 같이 StripeService의 <strong><code>createCheckoutSession()</code></strong>메서드를 통해 결제 세션 데이터를 받아오고, 이를 <code>source</code>라는 객체에 담는다.</p>
<p>그 후, 우린 <code>source</code> 객체를 통해 고유의 <strong><code>id</code></strong>값을 받아올 수 있고 이를 주문 건의 <strong><code>transaction_id</code></strong>라 명명할 수 있다. 이것은 단순히 주문 시 생성되는 의미없는 PK 아이디값과 다르다. 우린 이 값을 통해 주문 생성 후 <strong>&quot;주문 완료&quot;</strong>를 수행할 수 있게 된다.</p>
<p>그럼 결제 세션 생성부가 작성된 <strong><code>createCheckoutSession()</code></strong> 메서드를 확인해보자.</p>
<pre><code class="language-tsx">// stripe.service.ts

import { Injectable } from &quot;@nestjs/common&quot;;
import { ConfigService } from &quot;@nestjs/config&quot;;
import { InjectStripe } from &quot;nestjs-stripe&quot;;
import Stripe from &#39;stripe&#39;;

@Injectable()
export class StripeService {
  constructor(
    @InjectStripe() private readonly stripeClient: Stripe,
    private configService: ConfigService,
  ) {}

  async createCheckoutSession(line_items: any[]): Promise&lt;any&gt; {
    const session = await this.stripeClient.checkout.sessions.create({
      payment_method_types: [&#39;card&#39;],
      // line_items가 사용됨
      line_items,
      mode: &#39;payment&#39;,
      success_url: `${this.configService.get&lt;string&gt;(&#39;CHECKOUT_URL&#39;)}/success?source={CHECK_SESSION_ID}`,
      cancel_url: `${this.configService.get&lt;string&gt;(&#39;CHECKOUT_URL&#39;)}/error`,
    });

    return session;
  }
}</code></pre>
<p>모두 필요한 값이니 꼭 기입되어야할 것이다. 이는 <strong>stripe-api-reference</strong>에 상세히 소개되어 있으며, 에러시에 대시보드에서도 어떠한 프로퍼티가 추가로 요구되고 잘못되었는지에 대해 알려준다. (정말 좋았다)</p>
<ul>
<li>api key 발급: <a href="https://dashboard.stripe.com/apikeys">stripe/apikeys (링크)</a></li>
</ul>
<pre><code class="language-tsx">// .env

# Stripe Options
CHECKOUT_URL=http://localhost:5000
STRIPE_KEY=sk_test_51NLiNmGcqXAnu87FXXKDn8Xbx0qURSBsk************************RfcHzDhpsMqRJheX00kta8h8i2</code></pre>
</br>

<p><strong>✔ OrderController -create</strong></p>
<pre><code class="language-tsx">  // order.controller.ts

  @Post(&#39;checkout/orders&#39;)
  async create(
    @Body() createOrderDto: CreateOrderDto,
  ) {
    return await this.orderService.createOrder(createOrderDto);
  }</code></pre>
</br>

<h3 id="confirm-order-결제-주문-확인부">&gt; Confirm <code>Order</code> (결제 주문 확인부)</h3>
<p>마지막으로 생성할 구현부는 <strong>&quot;결제주문 확인(Order Confirm)&quot;</strong>에 대한 로직이다. 참고로 주문취소, 환불 등에 관한 추가적인 로직에 대해선 다루지 않겠다. 아마 Stripe 문서를 보고 충분히 할 수 있지 않을까 싶다. 추후, 추가적으로 다뤄보도록 하겠다.</p>
<p>그럼 결제 확인 로직에 대해 알아보자.</p>
<pre><code class="language-tsx">  // order.service.ts

  async orderConfirm(sourceId: string) {
    const order: Order = await this.findOne({
      where: {
        transaction_id: sourceId,
      },
      relations: [&#39;order_items&#39;, &#39;user&#39;]
    });

    if (!order) {
      throw new NotFoundException(&#39;Order not found&#39;);
    }

    await this.update(order.id, {
      complete: true,
    });

    return {
      message: &#39;success&#39;,
    }
  }</code></pre>
<p>간단하다. Stripe를 통한 결제 주문 확인부에서 <span style="color:red">중요한 것</span>은 <strong><code>false</code></strong>였던 <strong>&quot;complete&quot;</strong>의 상태를 <strong><code>true</code></strong>로 변경시켜주는 것이다. </p>
<p>직전 주문 생성부에서 발급받은 <code>transaction_id</code> 즉, 결제세션 아이디를 통해 위의 작업을 수행해 줄 수 있다.</p>
<p>최초 클라이언트에서 서버로 Client Secret을 요청할 때 <strong>Payment Intent</strong>가 <strong><code>Incomplete</code></strong>상태로 시작된다. (이는 Stripe 대시보드에서 확인 가능) 또한 Client Secret Key를 제대로 발급받지 못하면, 프론트단에서 카드 정보를 입력하는 인풋 창 또한 활성화되지 않는다. 그리고 발급받은 Client Secret과 카드 정보를 통해 결제를 완료하게 되면 최초 생성된 <strong>Payment Intent</strong>가 <strong><code>Complete</code></strong> 상태로 변경되며 <u>결제가 완료</u>된다.</p>
<p>해당 관련 내용은 아래의 페이지에서 확인할 수 있다. ⬇⬇</p>
<p>링크: <a href="https://stripe.com/docs/payments/payment-intents/verifying-status">payment-intents/verifying-status ✔ __ stripe.com</a></p>
</br>

<p><strong>✔ OrderController -confirm</strong></p>
<pre><code class="language-tsx">  // order.controller.ts

  @Post(&#39;checkout/orders/confirm&#39;)
  async confirm(
    @Body(&#39;sourceId&#39;) sourceId: string,
  ) {
    return await this.orderService.orderConfirm(sourceId);
  }</code></pre>
</br>

<h2 id="생각정리-및-다음포스팅-예고">생각정리 및 다음포스팅 예고</h2>
<p>📢 여기까지이다. 이렇게 nestjs와 <strong>&quot;Stripe&quot;</strong>를 이용하여 간단한 결제주문 프로세스를 구축해볼 수 있었다. 앞전 포스팅에서 선작업하였던 데이터베이스 설계를 바탕으로 진행하였고, 여러 테이블간의 연관관계를 적절히 맺는 작업이 주요한 포인트였다고 생각한다. 물론, 실제 서비스에선 이보다 더 많은, 더 복잡한 테이블이 존재하고 서로 간의 연관관계를 맺을 것이다. 실제 이커머스에 사용될 수 있는 일부 로직을 간접적으로 체험해봄으로써 어떠한 연결하에 프로세스가 진행되는지를 알게 되는 유용한 시간이었다.</p>
<p><span style="color:red"><strong>하지만!!!</strong></span> 우린 가장 중요한 부분을 수행하지 않았다. 바로 &quot;테스트&quot;이다. 코드로만 봐서는 제대로 된 전개순서 및 과정이 해깔릴 것이고 정말 이 코드가 유효하게 동작하는지 의문이 들 것이다. </p>
<p>다음 포스팅에선 이번 포스팅에서 구현한 비즈니스 로직을 바탕으로 포스트맨을 통한 테스트를 수행해보고자 한다. 또한 실제 Stripe 결제창에 우리의 주문이 반영되는지와 트랜잭션이 유효하게 동작하는지 또한 알아보고자한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] 주문처리과정을 통해 알아보는 수익률 랭킹 조회 (Part 1_ 주문처리를 위한 DB 설계) #1]]></title>
            <link>https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-1-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-DB-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@from_numpy/NestJS-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EA%B3%BC%EC%A0%95%EC%9D%84-%ED%86%B5%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%88%98%EC%9D%B5%EB%A5%A0-%EB%9E%AD%ED%82%B9-%EC%A1%B0%ED%9A%8C-Part-1-%EC%A3%BC%EB%AC%B8%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-DB-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Wed, 28 Jun 2023 04:23:09 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>이번에 NestJS를 활용해 일련의 실용적인 기능 구현을 수행해보는 시간을 가졌다. 앞으로 작성하게 될 내용은 최근에 듣게 된 유데미 사이트의 특정 강의를 참고하여 진행하였다. 조금 뜬금없는 얘기지만, 해당 강의가 2020년 기준 강의인것을 뒤늦게..? 알게 되었고, 현재 nestJS 및 ORM(TypeORM) 버전의 업그레이드로 인해 강의에서 제시하는 방법과 충돌하는 경우가 빈번하였고 동시에 (이런말을 해도 될진 모르겠지만.... 저렴한 강의여서 그런지 모르겠지만 ...) <u>굉장히 부실한 설명 및 내용임을 의심하지 않을 수 없었다</u>. 
뭐, 덕분에 스스로 기능을 더 추가하고 잘못된 로직을 개선할 수 있었고 동시에 앞으로 진행하게 될 내용에 대해 조금은 깊게 생각해볼 수 있었다.</p>
<p>지금부터 소개할 내용에 대해 간단히 설명해보자면 <strong>&quot;간단한 주문 및 결제 서비스 구현과 결제 수익에 따른 랭킹 기능 구현&quot;</strong> 이라 할 수 있을 것 같다.</p>
<p>나와같은 초심자분들에게 내용을 설명하는데 있어 특정 부분(주문 결제 혹은 랭킹 구현)을 중심으로 설명한다기보단, <u span style="color:green">하나의 흐름 아래에 모든 데이터 및 로직들이 어떻게 연관성을 띄고 동작을 하는지</u>를 서술하는 것이 유의미하다고 판단하였다. 이를 하나의 포스팅에 작성하긴 내용이 길어질 것이 분명하므로 위의 설명을 시리즈로 2~3개 분량의 포스팅에 나누어 작성해보고자 한다.</p>
<hr>
<p><strong>💨 이번 시리즈에서 다룰 내용</strong></p>
<blockquote>
<ol>
<li>주문처리를 위한 데이터베이스 설계 - <span style="color:mediumvioletred">이번 포스팅</span><ol start="2">
<li>주문 관련 로직 구현 및 결제 모듈 적용하기</li>
<li>레디스를 사용하여 랭킹 정보 응답하기</li>
</ol>
</li>
</ol>
</blockquote>
<p><strong>💨 사전 정보</strong></p>
<p>이번 포스팅 및 해당 시리즈는 이전에 작성되었던 포스팅을 베이스로 진행합니다. 유저는 관리자(<strong>admin</strong>)와 판매 대리인(<strong>ambassador</strong>)가 생성된 구조로써, 판매 대리인은 상품 판매 시 관리자와 차등한 일정 수익을 얻게 되는 구조 입니다.</p>
<p><strong>admin</strong>과 <strong>ambassador</strong>에 따른 서로 다른 로그인/회원가입 (즉, 인증과 그에 따른 인가)에 대한 부분은 아래의 이전 포스팅에 설명되어있으니 필요하시면 사전 참조 바랍니다..!!!</p>
<p><a href="https://velog.io/@from_numpy/NestJS-Implementing-Scopes-for-Multiple-Routes-feat.-JWT-Auth">[NestJS] Implementing Scopes for Multiple Routes ( feat. JWT Auth ) -- ✔ 링크 참조</a></p>
<hr>
<p>그럼 이번 포스팅에선 제목 및 위의 목차에 따라 가장 베이스가 되고 프레임을 잡아줄 데이터베이스 설계 과정을 알아보고 NestJS에서 Typeorm을 사용하여 이를 표현해보고자 한다.</p>
</br>

<h2 id="💢-데이터베이스-설계하기-rdbms">💢 데이터베이스 설계하기 (RDBMS)</h2>
<h3 id="데이터베이스-wireframe-설계-feat_-fk-constraint">&gt; 데이터베이스 <code>Wireframe</code> 설계 (<code>Feat_ FK Constraint</code>)</h3>
<p>주문및 결제를 수행하는데 있어서 단지 유저와 주문 정보 테이블만 있진 않을 것이다. 유저 테이블과 주문 테이블은 물론이고, <strong>상품</strong>(products)도 존재해야 하고 주문과 상품 목록을 매칭하기 위한 테이블도 존재해야할 것이다.</p>
<p>그 외에도 필요한 테이블이 있을 것이고, 각 테이블마다 어떠한 컬럼을 가져야하는지와 어떻게 다른 테이블과 연관 관계를 지녀야 할지에 대한 고민도 필요할 것이다.</p>
<p><strong>✔ 외래 키(Foreign-Key)는 JOIN 연산에서 꼭 사용되어야 할까?</strong></p>
<p>일반적으로 <strong>JOIN</strong> 과정(연산)에서 <strong>PK</strong>(기본키)와 <strong>FK</strong>(외래키)를 통해 사용하도록 제시되어서 해당 방법이 &quot;필수적&quot;이라 생각할 수 있지만, <u style="color:red">사실 그렇지 않다.</u> 
<strong>일반 컬럼</strong>으로도 충분히 <strong>JOIN</strong>을 할 수 있다. </p>
<p>이러한 논점에서 항상 따라다니는 것은 <strong>&quot;데이터 무결성 원칙(Data Integrity)&quot;</strong>이다. </p>
<p>데이터 무결성에 대한 설명이 이번 포스팅의 주제는 아니므로 아주 간단히 설명하자면 데이터 무결성은 데이터베이스에서 데이터의 <u>정확성과 일관성을 보장하는 원칙</u>을 의미한다. 그리고 이러한 데이터 무결성을 지키는 <strong>&quot;무결성 제약 조건(Integrity Constraint)&quot;</strong>이 존재한다. 흔히 말하는 <code>CRUD</code>와 같은 작업에서 얽혀있는 테이블간에 &quot;제약&quot;을 둠으로써 데이터의 정확성과 일관성을 보장할 수 있다. </p>
<p>무결성 제약조건중, 우린 <strong>&quot;참조 무결성(referential integrity)&quot;</strong>에서 <strong>&quot;외래키 제약조건&quot;</strong>이 사용됨을 알 수 있다. 외래키의 값은 NULL이거나 참조하는 테이블의 기본키(PK)와 동일해야한다는 것이 원칙이다.</p>
<p>자, 그럼 다시 처음으로 되돌아가서 <strong>&quot;JOIN 연산에 있어서 외래키 제약을 가져야하는가?&quot;</strong>에 대해 스스로 대답해보면 &quot;권장은 하지만(어찌됐건 무결성을 지키는 것은 중요하다) 필수적인 않다.&quot;라고 할 수 있을거 같다. </p>
<p>이러한 <strong>&quot;제약&quot;</strong>을 두게 된다면 개발단계에서는 물론이고 추후 운영하는 과정에서도 <u>테이블의 작은 변화 하나하나에 민감하게</u> 된다. PK와 FK로 JOIN이 형성된 테이블간의 수정은 그만큼 어려워지게 된다.</p>
<p>즉, 이번 프로젝트에선 위에서 언급한 내용을 바탕으로 <span style="color:indigo">&quot;부모-자식 테이블의 관계가 명확하거나 참조할 식별자가 기본키여야 하는 경우, 혹은 자주 변경이 일어나지 않는 스키마&quot;</span>에 대해선 <strong>&quot;외래키 제약조건을 사용&quot;</strong>하도록 하였고, 이에 반해 <span style="color:indigo">&quot;참조할 식별자가 기본키일 필요가 없는 경우, 자주 변경이 일어날 수 있는 스키마&quot;</span>에 대해선 <strong>&quot;외래키 제약조건을 사용하지 <u style="color:coral">않도록</u>&quot;</strong>하였다. </p>
<p>그리고 이러한 <code>JOIN</code> 관계와 사용되는 컬럼들을 아래의 ERD 와이어프레임에 반영하였다. &quot;<u>Non-Idenfying relations</u>&quot;와 &quot;<u>Identifying relations</u>&quot;의 관점에서 볼 수 있지 않을까 싶다.</p>
</br>

<p>본격적으로 각 테이블 마다의 세세한 설명에 앞서 먼저 위에서 언급한 내용을 바탕으로 <strong><code>ERD Cloud</code></strong>를 통해 간단히 테이블의 구성과 테이블 마다의 연관 관계를 정의해보았다. 해당 와이어프레임을 바탕으로 진행할 것이다. </p>
</br>

<p><strong>✔ Using ERDCloud (ambassador-order)</strong></p>
<ul>
<li>노란색 열쇠: 식별자 키(PK)</li>
<li>파란색 키: 외래 키(FK) - 외래 키 제약조건 성립</li>
<li>분홍색 키: <u>외래 키 제약조건이 없는 조인 컬럼</u></li>
</ul>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/b10b5a2a-e3f4-4092-9f24-20eacbc0a596/image.png" alt=""></p>
<p><span style="color:dimgrey">(참고로, 아래 테이블 속성에 정의된 타입<code>varchar()</code>에 대한 문자열 길이 값은 무시하시면 감사하겠습니다... 현재 모든 컬럼은 디폴트인 varchar(255)로 설정되어있습니다. 이것이 핵심은 아니므로 일단 해당 값으로 작성해 두었습니다)</span></p>
</br>




<h3 id="user-admin--ambassador">&gt; <code>User</code> (<code>admin &amp;&amp; ambassador</code>)</h3>
<p>주문자에 해당하는 유저 테이블이다. 우리가 진행할 시리즈의 프로젝트 특성 상, 관리자는 주문 프로세스에 참여하지 않는다. <strong><code>ambassador</code></strong>라 명명된 &quot;판매 대리인&quot;이 판매자임과 동시에 구매를 수행할 수 있는 유저가 된다.</p>
<p>즉, 인증을 위한 컬럼을 포함해 해당 <code>ambassador</code>여부를 판별할 수 있는 컬럼이 존재해야 할 것이다.</p>
<p>또한, <code>getter</code> 생성자를 사용하여 응답시에 수익 값을 넘겨줄 수 있도록 <code>revenue</code>를 정의하였고 외부 엔터티에선 해당 값을 이용해 일련의 가공을 할 수 있을 것이다.</p>
<pre><code class="language-tsx">// user.entity.ts

import { Exclude } from &quot;class-transformer&quot;;
import { Order } from &quot;../../order/model/order.entity&quot;;
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from &quot;typeorm&quot;;

@Entity(&#39;users&#39;)
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  first_name: string

  @Column()
  last_name: string;

  @Column({ unique: true })
  email: string;

  @Exclude()
  @Column()
  password: string;

  @Exclude()
  @Column({ nullable: true })
  currentRefreshToken: string;

  @Column({ type: &#39;datetime&#39;, nullable: true })
  currentRefreshTokenExp: Date;

  // ambassador와 admin을 판별하기 위한 불리언값의 필드이다.
  @Column({ default: true })
  is_ambassador: boolean;

  // 외래키 제약조건없이 Order 테이블과 조인관계 형성
  @OneToMany(() =&gt; Order, order =&gt; order.user, {
    createForeignKeyConstraints: false,
  })
  orders: Order[];

  // 추후 주문정보에 따른 판매인 수익 계산에 사용된다.
  get revenue(): number {
    return this.orders.filter(o =&gt; o.complete).reduce((s: number, o: Order) =&gt; s + o.ambassador_revenue, 0);
  }

  get name() {
    return `${this.first_name} ${this.last_name};`
  }
}</code></pre>
<p>유저 테이블은 이정도로 알아보고, 다음으로 진행하자.</p>
</br>

<h3 id="order">&gt; <code>Order</code></h3>
<p>다음으로 소개할 테이블은 주문 프로세스를 위한 <strong>orders</strong> 테이블이다. <code>orders</code> 테이블엔 어떠한 컬럼이 담겨져 있어야 할까? </p>
<p>국가, 주소, 우편 번호, 이메일 등등의 주문시 필요한 기본 정보에 대한 일반 컬럼부터 유저 테이블과 조인하기 위한 컬럼, 주문한 상품(<code>order_itmes</code>)과 조인하기 위한 컬럼, 그리고 추후 설명하겠지만 각 판매자마다의 판매 상품 및 일련 코드를 관리하기 위한 <code>link</code> 테이블과 조인하기 위한 컬럼이 필요할 것이다.</p>
<p>이를 토대로 엔터티를 작성해보자.</p>
<pre><code class="language-tsx">// order.entity.ts

import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from &quot;typeorm&quot;;
import { OrderItem } from &quot;./order-item.entity&quot;;
import { Exclude, Expose } from &quot;class-transformer&quot;;
import { Link } from &quot;../../link/model/link.entity&quot;;
import { User } from &quot;../../user/model/user.entity&quot;;

@Entity(&#39;orders&#39;)
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  // 추후 해당 필드를 통해 결제가 완료된 주문에 대한 처리를 진행할 수 있다.
  @Column({ nullable: true })
  transaction_id: string;

  @Column()
  user_id: number;

  @Column()
  code: string;

  // 판매 대리인 `email`
  @Column()
  ambassador_email: string;

  // 주문자의 `first_name`
  @Exclude()
  @Column()
  first_name: string;

  // 주문자의 `last_name`
  @Exclude()
  @Column()
  last_name: string;

  @Column()
  email: string;

  @Column({ nullable: true })
  address: string;

  @Column({ nullable: true })
  country: string;

  @Column({ nullable: true })
  city: string;

  @Column({ nullable: true })
  zip: string;

  // 결제가 최종 완료된다면 `true`로 변경되어야 할 것이다.
  @Exclude()
  @Column({ default: false })
  complete: boolean;

  // 외래키 제약조건을 통해 `order_items` 테이블과 조인관계를 형성한다.
  @OneToMany(() =&gt; OrderItem, orderItem =&gt; orderItem.order)
  order_items: OrderItem[];

  // 주문 결제를 진행하는데 있어서 `code` 값을 `links` 테이블과 공유하게끔 한다.
  // 이때 외래키 제약조건을 두지 않는다. (참조 하는 값이 기본키가 아님)
  @ManyToOne(() =&gt; Link, link =&gt; link.orders, {
    createForeignKeyConstraints: false,
  })
  @JoinColumn({
    referencedColumnName: &#39;code&#39;,
    name: &#39;code&#39;,
  })
  link: Link;

  // `user_id` 컬럼을 통해 user 테이블과 [N:1]관계를 맺게끔 한다.
  // 외래키 제약조건을 두지 않는다.
  @ManyToOne(() =&gt; User, user =&gt; user.orders, {
    createForeignKeyConstraints: false,
  })
  @JoinColumn({
    name: &#39;user_id&#39;
  })
  user: User;

  @Expose()
  get name() {
    return `${this.first_name} ${this.last_name};`
  }

  // total admin_revenue
  @Expose()
  get total() {
    return this.order_items.reduce((s: number, i: OrderItem) =&gt; s + i.admin_revenue, 0);
  }

  // ambassador_revenue
  get ambassador_revenue(): number {
    return this.order_items.reduce((s: number, i: OrderItem) =&gt; s + i.ambassador_revenue, 0);
  }
}</code></pre>
</br>

<h3 id="orderitem">&gt; <code>OrderItem</code></h3>
<p>해당 테이블은 앞서 정의한 <code>orders</code> 테이블과 직접 연관이 있고, 외래키 제약조건을 통해 생성된 <strong><code>order_items</code></strong> 테이블이다.</p>
<p>이 테이블을 통해서 우린 곧 알아보게 될 <code>products</code> 테이블에서 정의한 상품 정보들을 요청 시 주문 (<code>orders</code>)과정에 받아올 수 있다. </p>
<pre><code class="language-tsx">// order-item.entity.ts

import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from &quot;typeorm&quot;;
import { Order } from &quot;./order.entity&quot;;

@Entity(&#39;order_items&#39;)
export class OrderItem {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  product_title: string;

  @Column()
  price: number;

  @Column()
  quantity: number;

  @Column()
  admin_revenue: number;

  @Column()
  ambassador_revenue: number;

  @ManyToOne(() =&gt; Order, order =&gt; order.order_items, { onDelete: &quot;CASCADE&quot; })
  @JoinColumn({ name: &#39;order_id&#39; })
  order: Order
}</code></pre>
</br>

<h3 id="product">&gt; <code>Product</code></h3>
<p>상품 테이블이다. 상품 테이블은 크게 복잡한 구성은 없도록 하였지만 곧 소개할 <strong><code>links</code></strong> 테이블과 연관 관계를 가져야 한다. <strong><code>links</code></strong> 테이블에 대해 미리 잠깐 설명하자면 <strong>&quot;판매자와 상품을 연결하기 위한 정보의 스키마&quot;</strong>이다. 이 <code>links</code> 테이블을 통해 판매자는 판매할 상품을 관리할 수 있는데, 하나의 링크는 여러 상품(product)을 가질 수 있고, 동시에 하나의 상품 또한 여러 링크에 속해있을 수 있다. 
조금 더 직관적으로 말하자면 관리자를 통해 받게 될 수익성 판매 상품을 여러 유저가 동시에(공유해서) 대리 판매를 할 수 있다는 것이다. </p>
<p>즉, 뒤에 나올 <strong><code>links</code></strong> 테이블과 해당 <strong><code>products</code></strong> 테이블은 <strong>&quot;다대다(N:M)&quot;</strong>관계를 가지게 된다.</p>
<p>일반적으로 Typeorm과 같은 orm에서 &quot;다대다&quot;관계를 설정하는데 있어서 <strong>&quot;<code>@ManyToMany</code>&quot;</strong> 데코레이터를 사용할 것을 제시한다. </p>
<p>하지만, 실무에서는 이를 권장하지 않는다. 예를 들면 자바의 <strong>Hibernate</strong>나 우리가 현재 사용하고 있는 <strong>TypeORM</strong>과 같은 orm 프레임워크는 <code>@ManyToMany</code>를 주입하게 될 시 <u>자동으로 중간 테이블을 만들어준다</u>. 굉장히 편하게 다대다 관계를 생성하게 된다고 생각할 수 있지만 이는 굉장히 허술하다(물론 현재까지는 그렇단 얘기다...) </p>
<p>중간 테이블을 만들고, 기본키와 외래키 쌍을 알아서 매핑해주는 것은 문제가 되지 않지만 실무 레벨에서 필요할 수 있는 추가적 필드에 대한 정의를 지원하지 않는다. 예를 들면 생성 시(<code>create_at</code>), 업데이트 시(<code>updated_at</code>)와 같은 정보를 기입할 수 없다는 것이다. </p>
<p>그로 인해, 수행할 과정에선 <code>@ManyToMany</code>를 사용하지 않고 <strong><code>@OneToMany</code></strong>와 <strong><code>@ManyToOne</code></strong> 사용을 통해 <strong>직접 중간 매핑 테이블을 생성</strong>하도록 하였다.</p>
<hr>
<p>이 내용에 대해선 일전에도 다룬 적이 있었습니다. 이러한 테이블 구조와 로직에 대해 조금 더 알고 싶다면 아래의 시리즈 내용을 참조 바랍니다. ⬇⬇</p>
<p><a href="https://velog.io/@from_numpy/series/NestJS-Typeorm-ManyToMany-%EA%B4%80%EA%B3%84-%EA%B0%9C%EC%84%A0%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%97%AC%EC%A0%95">TypeORM에서 ManyToMany 관계 개선하기 -링크 ✔</a></p>
<hr>
<p>그럼 <code>product</code> 엔터티를 확인해보자.</p>
<pre><code class="language-tsx">// product.entity.ts

import { LinkProduct } from &quot;../../link/model/link-product.entity&quot;;
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from &quot;typeorm&quot;;

@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  description: string;

  @Column()
  image: string;

  @Column()
  price: number;

  // 중간 매핑 테이블인 link_products와 `OneToMany`관계로 조인한다.
  @OneToMany(() =&gt; LinkProduct, (linkProduct) =&gt; linkProduct.product)
  linkProducts: LinkProduct[];
}</code></pre>
</br>

<h3 id="link">&gt; <code>Link</code></h3>
<p><strong><code>links</code></strong> 테이블에선 어떠한 필드가 담겨야하고, 조인관계는 어떻게 정의해야할까?</p>
<p>먼저, 앞서 <code>orders</code> 테이블을 생성할 시에 언급하였고 동시에 정의하였듯이 <code>links</code> 테이블에서도 <strong><code>code</code></strong>가 필요하다. <code>order</code>와 <code>link</code> 테이블은 <u>외래키 제약 조건 없이</u> 해당 <strong><code>code</code></strong>를 참조하며 조인을 한다. 해당 코드값은 추후 따로 생성하겠지만 유니크키여야 한다.</p>
<p>다음으로 유저 테이블과 외래키 제약조건을 통해 조인관계를 지니도록 하고(일대다, 다대일 관계를 형성), 다음으로 소개할 <code>links</code>와 <code>products</code>의 중간 매핑 테이블인 <code>link_products</code>와 외래키 제약 조건을 가지게끔 한다.</p>
<pre><code class="language-tsx">// link.entity.ts

import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from &quot;typeorm&quot;;
import { User } from &quot;../../user/model/user.entity&quot;;
import { LinkProduct } from &quot;./link-product.entity&quot;;
import { Order } from &quot;../../order/model/order.entity&quot;;

@Entity(&#39;links&#39;)
export class Link {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  code: string;

  @ManyToOne(() =&gt; User)
  @JoinColumn({ name: &#39;user_id&#39; })
  user: User;

  @OneToMany(() =&gt; LinkProduct, (linkProduct) =&gt; linkProduct.link, {eager: true})
  linkProducts: LinkProduct[];

  @OneToMany(() =&gt; Order, order =&gt; order.link, {
    createForeignKeyConstraints: false,
  })
  @JoinColumn({
    referencedColumnName: &#39;code&#39;,
    name: &#39;code&#39;,
  })
  orders: Order[];
}</code></pre>
</br>

<h3 id="linkproduct-중간-매핑-테이블">&gt; <code>LinkProduct</code> (중간 매핑 테이블)</h3>
<p><code>links</code>와 <code>products</code> 테이블의 다대다(N:M)관계를 위한 중간 매핑 테이블이다.</p>
<pre><code class="language-tsx">// link-product.entity.ts

import { BeforeInsert, Entity, ManyToOne, PrimaryColumn } from &quot;typeorm&quot;;
import { Link } from &quot;./link.entity&quot;;
import { Product } from &quot;../../product/model/product.entity&quot;;

@Entity(&#39;link_products&#39;)
export class LinkProduct {
  @PrimaryColumn()
  id: string;

  @ManyToOne(() =&gt; Product, product =&gt; product.linkProducts, { onDelete: &#39;CASCADE&#39; })
  product: Product;

  @ManyToOne(() =&gt; Link, link =&gt; link.linkProducts, { onDelete: &#39;CASCADE&#39; })
  link: Link;

  @BeforeInsert()
  private beforeInsert() {
    const linkId = this.link.id;
    const productId = this.product.id;
    this.id = String(linkId).padStart(7, &quot;0&quot;) + String(productId).padStart(7, &quot;0&quot;);  
  }
}</code></pre>
<p>해당 중간 매핑 테이블의 pk(기본키)는 <code>auto-increment</code> key, 즉 typeorm의 <code>@PrimaryGeneratedColumn</code>으로 선언하지 않았다. 코드에서 확인할 수 있듯이 <strong><code>@BeforeInsert</code></strong>를 사용하여 <code>beforeInsert()</code> 내부 실행코드를 엔티티가 db에 삽입되기 전에 실행되도록 하였다. </p>
<p>이렇게 auto-increment key가 아닌 <strong><code>String Unique Key</code></strong>를 사용한 이유는 다양하겠지만 (auto-increment key의 단점이 주된 이유가 될 것이다) 추후 LinkProduct 엔티티에 접근을 해야할 시에 조금 더 빠르고 간단한 코드로써의 접근을 위해서이다. <code>linkId</code>와 <code>productId</code>를 통해 생성한 <code>String Unique Key(id)</code>는 그 자체로써 유의미한 식별 값을 가질 수 있기 때문이다.</p>
<hr>
<p>이에 대해서도 일전에 다룬 적이 있습니다. 아래의 링크를 참조해주세요 ⬇⬇</p>
<p><a href="https://velog.io/@from_numpy/Nest-Typeorm-ManyToMany-%EA%B4%80%EA%B3%84-%EA%B0%9C%EC%84%A0-3-feat.-String-Unique-Key%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%BD%94%EB%93%9C-%EA%B0%9C%EC%84%A0">String Unique Key를 통한 코드 개선 - 링크 ✔</a></p>
<hr>
</br>

<h2 id="다음-포스팅-예고">다음 포스팅 예고</h2>
<p>이렇게 우린 수행할 프로젝트에 대한 전체 테이블 구조를 알아보았다. </p>
<p>해당 테이블 구조를 베이스로 하여 다음 포스팅에서 주문및 결제 수행을 진행해보도록 한다.</p>
</br>



]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] Implementing Scopes for Multiple Routes ( feat. JWT Auth )]]></title>
            <link>https://velog.io/@from_numpy/NestJS-Implementing-Scopes-for-Multiple-Routes-feat.-JWT-Auth</link>
            <guid>https://velog.io/@from_numpy/NestJS-Implementing-Scopes-for-Multiple-Routes-feat.-JWT-Auth</guid>
            <pubDate>Sun, 11 Jun 2023 11:50:54 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>어쩌면 되게 단순하지만 짚고 넘어가면 유용한 사용이 될 거 같기에 글을 남겨본다. 여태껏 nestjs에서 라우트 핸들러 함수를 작성할때 <u>하나의 라우트 핸들러 함수</u>안에 <strong>&quot;하나의 Endpoint&quot;</strong>만 두었었다.  </p>
<p>다들 알다시피, 아래와 같은 형식이다.</p>
<pre><code class="language-tsx">import { Controller, Get } from &#39;@nestjs/common&#39;;

@Controller(&#39;api&#39;)
export class AnimalController {
  @Get(&#39;cats&#39;)
  findAll(): string {
    return &#39;Hello Animal!!&#39;;
  }
}</code></pre>
<p>우리는 위와 같은 <code>GET</code>요청을 수행하기위해 <strong><code>/api/cats</code></strong>라는 <span style="color:red">endpoint</span>에 접근을 하면된다. </p>
<p>여기서 잠깐 생각해보자. 위와 같이 <strong>&quot;Hello Animal!!&quot;</strong>이란 응답을 받길 원하는 <strong>또다른</strong> <code>GET</code> 요청의 라우트 핸들러 함수가 있다고 하자. 그리고 해당 라우트 함수들의 endpoint로는 <em>&quot;dogs, pigs, elephants ...&quot;</em> 로 다양할 수 있다.</p>
<p>그럼 단순하게 생각하면 아래와 같이 작성할 것이다.</p>
<pre><code class="language-tsx">@Controller(&#39;api&#39;)
export class AnimalController {
  @Get(&#39;cats&#39;)
  findAll(): string {
    return &#39;Hello Animal!!&#39;;
  }

  @Get(&#39;dogs&#39;)
  findAll(): string {
    return &#39;Hello Animal!!&#39;;
  }

  @Get(&#39;pigs&#39;)
  findAll(): string {
    return &#39;Hello Animal!!&#39;;
  }

  @Get(&#39;elephants&#39;)
  findAll(): string {
    return &#39;Hello Animal!!&#39;;
  }

  // ... ...
}</code></pre>
<p>얼핏봐도 굉장히 비효율적이다. 가장 눈에 띄는 것은 <span style="color:green">&quot;코드의 중복성&quot;</span>일 것이고, 더 나아가선 공통된 로직을 사용하는데 있어 <span style="color:green">&quot;일관성&quot;</span>과 <span style="color:green">&quot;확장성&quot;</span>을 의심해볼 것이다. 물론 동일한 라우트 핸들러 함수를 사용하는 endpoints가 2개 이상으로 존재하는 상황이 많을진? 모르겠지만 어쨌든 위와 같이 작성하는건 비효율적이라 판단된다. </p>
<p>우리는 이런 상황에서 <strong>&quot;Mutiple Routes&quot;</strong>를 통해 여러개의 endpoints가 동일한 핸들러 함수를 공유할 수 있게끔 만들 수 있다.</p>
<pre><code class="language-tsx">@Controller(&#39;api&#39;)
export class AnimalController {
  @Get([&#39;cats&#39;, &#39;dogs&#39;, &#39;pigs&#39;, &#39;elephants&#39; ...])
  findAll(): string {
    return &#39;Hello Animal!!&#39;;
  }
}</code></pre>
<p>nestjs에서 <code>GET, POST, PUT, DELETE, ...</code> 등의 <code>MethodDecorator</code>는 매개변수로 <strong><code>string | string[]</code></strong> 타입을 받게끔 한다. 즉, 우린 위와 같이 배열로 endpoints를 지정해줌으로써 효율적인 <strong>&quot;Mutiple Routes&quot;</strong>를 구현할 수 있게 된다.</p>
</br>

<h2 id="💥-적용해보기-admin--ambassador">💥 적용해보기 (<code>Admin | Ambassador</code>)</h2>
<p><u>소셜 미디어 서비스 플랫폼</u>(SNS) 혹은 <u>이커머스 플랫폼</u>(E-Commerce)을 예로 들어보자. </p>
<p>이커머스 플랫폼에서는 관리자(admin)가 제품/상품 관리, 주문 처리, 고객 관리 등의 작업을 수행하고, 대리인은 특정 브랜드의 제품을 홍보하고 판매하는 역할을 담당한다. 대리인은 제품에 대한 상세 정보를 제공하고, 고객과의 소통 및 마케팅 활동을 수행한다. 이에 따라 관리자와 대리인은 정해진 분할하에(percentage) 수익을 배분하여 가질 것이다.</p>
<p>대충 스토리는 위와 같고, 그렇다면 관리자와 대리인의 <strong>&quot;인증(Authentication)&quot;</strong> 과정을 별도로 두어야할까?</p>
<p>물론, 별도로 두어도 상관은 없겠지만 앞서 위에서도 언급하였듯이 중복된 라우트 핸들러 함수를 공유해야할 경우, <strong>&quot;Mutilple Routes&quot;</strong>를 통해 동일한 로직이 반복되는 것을 막을 수 있다. </p>
</br>

<h3 id="authcontroller-with-mutilple-routes">&gt; <code>AuthController with Mutilple Routes</code></h3>
<p>그렇담 기존에 관리자를 위한 로그인 및 인증 관련 컨트롤러 로직을 확인해보자. 이는 앞서 이전 포스팅들에서 진행한 &quot;유저 인증 과정&quot;과 동일하고, &quot;2FA(Two-Factor-Authentication)&quot;와 &quot;OAuth&quot;를 제외한 부분이다. </p>
<p>인증 관련 구현 로직에 관한 설명은 포스팅 진행 내용상 생략하겠다.</p>
<p>또한 관리자에 해당하는 endpoint는 <strong>&quot;admin&quot;</strong>으로 지정하고, 대리인(혹은 판매인)에 해당하는 endpoint는 <strong>&quot;ambassador&quot;</strong>로 지정한다.</p>
<hr>
<p><span style="color:green">※ 이전 포스팅</span></p>
<p><a href="https://velog.io/@from_numpy/series/NestJS-Authentication-advanced-part">nestjs - 유저 인증 과정 (심화) ✔</a></p>
<p><span style="color:green">위의 포스팅들중 1, 2 포스팅의 코드를 참조해주시면 되겠습니다. <code>Refresh Token</code> 구현 코드까지 동일합니다.</span></p>
<hr>
<p>그럼, 본격 구현 설명에 앞서 전체 컨트롤러 인증로직을 확인해보자.</p>
<p><strong>✔ AuthController</strong></p>
<pre><code class="language-tsx">import { BadRequestException, Body, Controller, Post, Get, Res, UnauthorizedException, Req, UseGuards, UseInterceptors, ClassSerializerInterceptor, Put } from &#39;@nestjs/common&#39;;
import { Request, Response } from &#39;express&#39;;
import { LoginDto } from &#39;../user/model/user-login.dto&#39;;
import { UserRegisterDto } from &#39;../user/model/user-register.dto&#39;;
import { UserService } from &#39;../user/user.service&#39;;
import { AuthService } from &#39;./auth.service&#39;;
import { RefreshTokenDto } from &#39;./utils/refreshToken.dto&#39;;
import { User } from &#39;../user/model/user.entity&#39;;
import { JwtAccessAuthGuard } from &#39;./utils/guard/jwt-access.guard&#39;;
import { JwtRefreshGuard } from &#39;./utils/guard/jwt-refresh.guard&#39;;
import { UserUpdateDto } from &#39;../user/model/user-update.dto&#39;;
import { JwtService } from &#39;@nestjs/jwt&#39;;

@Controller()
export class AuthController {
  constructor(
    private readonly userService: UserService,
    private readonly authService: AuthService,
    private readonly jwtService: JwtService,
  ) {}

  @Post([&#39;admin/register&#39;, &#39;ambassador/register&#39;])
  async register(
    @Body() userRegisterDto: UserRegisterDto,
    @Req() request: Request,
  ) {
    if (userRegisterDto.password !== userRegisterDto.password_confirm) {
      throw new BadRequestException(&#39;Passwords do not match!&#39;);
    }
    const newUser = await this.userService.createUser(userRegisterDto, request);
    return newUser;
  }

  @Post([&#39;admin/login&#39;, &#39;ambassador/login&#39;])
  async login(
    @Body() loginDto: LoginDto,
    @Res({ passthrough: true }) res: Response,
    @Req() req: Request,
  ) {
    const user = await this.authService.validateUser(loginDto, req);
    const access_token = await this.authService.generateAccessToken(user, req);
    const refresh_token = await this.authService.generateRefreshToken(user);

    await this.userService.setCurrentRefreshToken(refresh_token,user.id);
    res.setHeader(&#39;Authorization&#39;, &#39;Bearer &#39; + [access_token, refresh_token]);
    res.cookie(&#39;access_token_1&#39;, access_token, {
      httpOnly: true,
    });
    res.cookie(&#39;refresh_token_1&#39;, refresh_token, {
      httpOnly: true,
    });

    return {
      message: &#39;login success&#39;,
      access_token: access_token,
      refresh_token: refresh_token,
    };
  }

  @Post([&#39;admin/refresh&#39;, &#39;ambassador/user&#39;])
  async refresh(
    @Body() refreshTokenDto: RefreshTokenDto,
    @Res({ passthrough: true }) res: Response,
    @Req() req: Request,
  ) {
    try {
      const newAccessToken = (await this.authService.refresh(refreshTokenDto, req)).accessToken;
      res.setHeader(&#39;Authorization&#39;, &#39;Bearer &#39; + newAccessToken);
      res.cookie(&#39;access_token_1&#39;, newAccessToken, {
        httpOnly: true,
      });
      res.send({newAccessToken});
    } catch(err) {
      throw new UnauthorizedException(&#39;Invalid refresh-token&#39;);
    }
  }

  @UseInterceptors(ClassSerializerInterceptor)
  @Get([&#39;admin/authenticate&#39;, &#39;ambassador/authenticate&#39;])
  @UseGuards(JwtAccessAuthGuard)
  async user(@Req() req: Request): Promise&lt;User&gt; {
    const cookie = req.cookies[&#39;access_token_1&#39;];
    const { id } = await this.jwtService.verifyAsync(cookie); 
    const verifiedUser: User = await this.userService.findUserById(id);
    return verifiedUser;
  }

  @Post([&#39;admin/logout&#39;, &#39;ambassador/logout&#39;])
  @UseGuards(JwtRefreshGuard)
  async logout(@Req() req: any, @Res({ passthrough: true }) res: Response): Promise&lt;any&gt; {
    await this.userService.removeRefreshToken(req.user.id);
    res.clearCookie(&#39;access_token_1&#39;);
    res.clearCookie(&#39;refresh_token_1&#39;);
    return {
      message: &#39;logout success&#39;
    };
  }

  @UseGuards(JwtAccessAuthGuard)
  @Put([&#39;admin/users/info&#39;, &#39;ambassador/users/info&#39;])
  async updateInfo(
    @Req() request: Request,
    @Body() userUpdateDto: UserUpdateDto,
  ) {
    const cookie = request.cookies[&#39;access_token_1&#39;];
    const { id } = await this.jwtService.verifyAsync(cookie);

    return await this.userService.updateUserInfo(id, userUpdateDto);
  }

  @UseGuards(JwtAccessAuthGuard)
  @Put([&#39;admin/users/password&#39;, &#39;ambassador/users/password&#39;])
  async updatePassword(
    @Req() request: Request,
    @Body(&#39;password&#39;) password: string,
    @Body(&#39;password_confirm&#39;) password_confirm: string,
  ) {

    if (password !== password_confirm) {
      throw new BadRequestException(&#39;Passwords do not match&#39;);
    }

    const cookie = request.cookies[&#39;access_token_1&#39;];
    const { id } = await this.jwtService.verifyAsync(cookie);

    return await this.userService.updateUserPassword(id, password);
  }
}
</code></pre>
</br>

<h3 id="1-register-users-with-mutiple-routes">&gt; 1) <code>Register users with Mutiple Routes</code></h3>
<p>먼저 <strong>회원가입</strong>이다. 우리는 유저를 등록하는데 있어 아래와 같이 엔터티에 <code>ambassador</code> 인지 아닌지를 판별하는 필드를 설정하였다.</p>
<pre><code class="language-tsx">// user.entity.ts

@Entity(&#39;users&#39;)
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  // ... 

  @Column({ default: true })
  is_ambassador: boolean;
}</code></pre>
<p>컨트롤러의 라우트 핸들러 함수 <code>register()</code>는 아래와 같이 작성한다.</p>
<pre><code class="language-tsx">// auth.controller.ts

  @Post([&#39;admin/register&#39;, &#39;ambassador/register&#39;])
  async register(
    @Body() userRegisterDto: UserRegisterDto,
    @Req() request: Request,
  ) {
    if (userRegisterDto.password !== userRegisterDto.password_confirm) {
      throw new BadRequestException(&#39;Passwords do not match!&#39;);
    }
    const newUser = await this.userService.createUser(userRegisterDto, request);
    return newUser;
  }</code></pre>
<p><code>@Post</code>의 매개변수로 배열을 받고, 해당 배열안에 <code>&#39;admin/register&#39;, &#39;ambassador/register&#39;</code>를 요소로 가짐으로써 두 경로(<code>path</code>)에 대한 multiple routes를 구현한다.</p>
<p>그럼 유저 회원가입에 대한 비즈니스로직인 서비스로직(<code>createUser()</code>)을 알아보자.</p>
<pre><code class="language-tsx">// user.service.ts

  async createUser(newUser: UserRegisterDto, request): Promise&lt;User&gt; {
    const userFind: User = await this.findOne({
      where: {
        email: newUser.email,
      }
    });
    if (userFind) {
      throw new HttpException(&#39;UserEmail already used!&#39;, HttpStatus.BAD_REQUEST);
    }

    const saltOrRounds = 12;
    const hashedPassword = await this.hashPassword(newUser.password, saltOrRounds);
    const newHashedUser = await this.save({
      ...newUser,
      password: hashedPassword,
      password_confirm: hashedPassword,
      // ambassador 가입 경로에 대해 is_ambassador를 true로 설정한다.
      is_ambassador: request.path === &#39;/api/ambassador/register&#39;,
    });
    return newHashedUser;
  }</code></pre>
<p>(다른 부분에 대한 설명은 생략)
<code>save()</code> 메서드를 통해 유저 등록을 할 시 <code>admin</code> 경로로 가입 요청을 한 유저에 대해선 <code>is_ambassador === false</code>로 해주어야 할 것이고, <code>ambassador</code> 경로로 요청한 유저에 대해선 <code>is_ambassador === true</code>로 해주어야 할 것이다.</p>
<p>우리는 <code>createUser()</code>의 매개변수로 컨트롤러의 <code>register()</code> 함수로 부터 <code>request: Request</code>를 받아올 수 있고, 이를 통해 <code>path</code>에 접근할 수 있다. </p>
<p>즉, <code>&#39;/api/ambassador/register&#39;</code> path로 가입요청을 한 유저에겐(ambassador) <code>is_ambassador === true</code>를 부여한다.</p>
</br>

<p><strong>✔ Test (Postman)</strong></p>
<p>-- admin
<img src="https://velog.velcdn.com/images/from_numpy/post/05b88b2e-d5ee-4893-a34a-e2e70a95449c/image.png" alt=""></p>
<p>-- ambassador
<img src="https://velog.velcdn.com/images/from_numpy/post/357189a0-42ea-4358-9e5c-9b15e9bdf854/image.png" alt=""></p>
</br>

<h3 id="2-login-with-mutiple-routes">&gt; 2) <code>Login with Mutiple Routes</code></h3>
<p>다음으로 로그인 단계이다. 로그인 단계에서 중요한 것은 <code>admin</code>은 모든 로그인에 접근이 허용되어야 하지만, <code>ambassador</code>는 <code>admin</code> 경로의 로그인엔 접근할 수 없어야 한다. 즉, 이에 대한 <span style="color:red">예외처리</span>가 필요할 것이다.</p>
<p><strong>✔ login - AuthController</strong></p>
<pre><code class="language-tsx">// auth.controller.ts

  @Post([&#39;admin/login&#39;, &#39;ambassador/login&#39;])
  async login(
    @Body() loginDto: LoginDto,
    @Res({ passthrough: true }) res: Response,
    @Req() req: Request,
  ) {
    const user = await this.authService.validateUser(loginDto, req);
    const access_token = await this.authService.generateAccessToken(user, req);
    const refresh_token = await this.authService.generateRefreshToken(user);

    await this.userService.setCurrentRefreshToken(refresh_token,user.id);
    res.setHeader(&#39;Authorization&#39;, &#39;Bearer &#39; + [access_token, refresh_token]);
    res.cookie(&#39;access_token_1&#39;, access_token, {
      httpOnly: true,
    });
    res.cookie(&#39;refresh_token_1&#39;, refresh_token, {
      httpOnly: true,
    });

    return {
      message: &#39;login success&#39;,
      access_token: access_token,
      refresh_token: refresh_token,
    };
  }</code></pre>
<p>위 로직에 대한 설명은 생략하겠다. <code>user</code> 객체를 불러오기 위한 검증 로직인 <code>AuthService</code>의 <code>validateUser()</code> 함수에서 예외처리를 구현해주면 된다.</p>
<pre><code class="language-tsx">// auth.service.ts

  async validateUser(loginDto: LoginDto, request: Request): Promise&lt;User&gt; {
    // request.path를 통해 `admin` 로그인 경로 불러옴
    const adminLogin = request.path === &#39;/api/admin/login&#39;;
    const user = await this.userService.findUserByEmail(loginDto.email);

    if (!user) {
      throw new NotFoundException(&#39;User not found!&#39;);
    }

    if (!await bcrypt.compare(loginDto.password, user.password)) {
      throw new BadRequestException(&#39;Invalid credentials!&#39;);
    }

    // is_ambassador === true이면서 동시에 admin 로그인 경로로 접근한 유저에 관해 접근 권한 예외처리
    if (user.is_ambassador &amp;&amp; adminLogin) {
      throw new UnauthorizedException();
    }

    return user;
  } </code></pre>
</br>

<p><strong>✔ Test (Postman)</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/3d61abc0-82bb-48b7-a424-3427e2ba841f/image.png" alt=""></p>
</br>

<h3 id="3-guard-with-scopes-with-jwt">&gt; 3) <code>Guard with Scopes</code> (with JWT)</h3>
<p>회원가입과 로그인을 수행한 유저를 바탕으로 <code>admin</code>일 경우와 <code>ambassador</code>일 경우에 대한 <u>서로 다른 인증 처리</u>를 해주어야 할 것이다. 우린 기존에 작성하였던 <code>JwtAccessGuard</code>를 수정해 줄 필요가 있다.</p>
</br>

<hr>
<p><strong>✔ 기존 <code>JwtAccessGuard</code></strong></p>
<pre><code class="language-tsx">// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from &quot;@nestjs/common&quot;;
import { JwtService } from &quot;@nestjs/jwt&quot;;

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise&lt;any&gt; {
    const request = context.switchToHttp().getRequest();
    try {
      const access_token = request.cookies[&#39;access_token_1&#39;];
      const user = await this.jwtService.verify(access_token);
      request.user = user;
      return user;
    } catch(err) {
      return false;
    }
  }
}</code></pre>
<hr>
<p>여기서 중요한 점은 현재 <strong>&quot;JWT&quot;</strong>로 생성한 <strong>&quot;access_token(액세스 토큰)&quot;</strong>을 통해 인증을 구현하고 있고, 이를 토대로 <strong>&quot;Authentication Guard&quot;</strong>를 생성하였다는 것이다. </p>
<p>즉, 다시 말해 우리가 <strong><code>admin</code></strong>과 <strong><code>ambassador</code></strong>에 따른 <u>서로 다른 인증을 처리하는</u> &quot;하나의 가드&quot; 구현하고자 한다면, 우리의 액세스 토큰 역시 <span style="color:green">이에 대한 정보를 알고 있어야 한다는 것</span>이다.</p>
<p>그것을 우리는 <strong>&quot;Scope(범위)&quot;</strong>라는 데이터를 통해 해결할 수 있다. 
<span style="color:dimgray">(scope는 토큰 정보에 포함되어야 할 것이다)</span></p>
</br>

<p><strong>✔ Payload</strong></p>
<p>액세스 토큰 정보에 해당하는 <code>Payload</code> interface에 <strong><code>scope</code></strong> 속성을 추가한다.</p>
<pre><code class="language-tsx">// payload.interface.ts
export interface Payload {
  id: number;
  email: string;
  first_name: string;
  last_name: string;
  scope: string;  // scope 추가
  iat?: string;
  exp?: string;
}</code></pre>
<p><strong>✔ 액세스 토큰 생성 로직 - AuthService</strong></p>
<p>액세스 토큰 생성 로직에서 <code>scope</code>에 대한 처리를 해준다.</p>
<pre><code class="language-tsx">// auth.service.ts 

  async generateAccessToken(user: User, request: Request): Promise&lt;string&gt; {
    // adminLogin에 해당하는 path는 많이 사용됨으로, enum 객체로 빼도 될 것이다.
    const adminLogin = request.path === &#39;/api/admin/login&#39;;

    const payload: Payload = {
      id: user.id,
      email: user.email,
      first_name: user.first_name,
      last_name: user.last_name,
      scope: adminLogin ? &#39;admin&#39; : &#39;ambassador&#39;,
    }
    return this.jwtService.signAsync(payload);
  }
</code></pre>
<p>만약 요청 경로(<code>request.path</code>)가 <code>/api/admin/login</code>일 경우 액세스 토큰은 <code>admin</code>이란 데이터를 scope에 포함시키고, 그렇지 않을 경우 <code>ambassador</code>란 데이터를 scope에 포함시키도록 한다.</p>
</br>

<p><strong>✔ 수정 - JwtAccessGuard</strong></p>
<pre><code class="language-tsx">// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from &quot;@nestjs/common&quot;;
import { JwtService } from &quot;@nestjs/jwt&quot;;

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise&lt;any&gt; {
    const request = context.switchToHttp().getRequest();
    try {
      const access_token = request.cookies[&#39;access_token_1&#39;];
      const { scope } = await this.jwtService.verify(access_token);

      const is_ambassador = request.path.toString().indexOf(&#39;api/ambassador&#39;) &gt;= 0;

      return is_ambassador &amp;&amp; scope === &#39;ambassador&#39; || !is_ambassador &amp;&amp; scope === &#39;admin&#39;;
    } catch(err) {
      return false;
    }
  }
}</code></pre>
<p>아래와 같이 진행된다. </p>
<p>1)  생성한 액세스 토큰(로그인시 생성한 cookie를 통해 받아온다)에서 <strong><code>scope</code></strong>를 추출한다.</p>
<pre><code class="language-tsx">const { scope } = await this.jwtService.verify(access_token);</code></pre>
<p>2) 경로(path)를 통한 <strong><code>is_ambassador</code></strong> 값 설정 (indexOf 사용)</p>
<pre><code class="language-tsx">const is_ambassador = request.path.toString().indexOf(&#39;api/ambassador&#39;) &gt;= 0;
</code></pre>
<p>해당 부분은 현재 요청 경로를 <code>toString()</code>을 통해 문자열로 변환한 후 , <strong><code>&#39;api/ambassador&#39;</code></strong>가 포함되어있는지 확인하게 된다. <strong><code>indexOf()</code></strong> 메서드는 문자열 내에서 특정 문자열의 위치를 찾고, 찾은 위치의 인덱스를 반환한다. </p>
<hr>
<p><a href="https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf">String.prototype.indexOf() - mdn web docs</a></p>
<hr>
<p>만약, <code>indexOf()</code>를 통해 반환된 값이 0이상일 경우 <code>&#39;api/ambassador&#39;</code> 문자열을 포함하는 것으로 간주하기 때문에 이때, <strong><code>is_ambassador</code></strong>의 값은 <strong><code>true</code></strong>가 될 것이다.</p>
<p>3) 접근 허용 여부 결정</p>
<pre><code class="language-tsx">return is_ambassador &amp;&amp; scope === &#39;ambassador&#39; || !is_ambassador &amp;&amp; scope === &#39;admin&#39;;
</code></pre>
<p>해당 반환부를 잘 생각해보아야한다. <code>ambassador</code>에 관해서도 접근을 허락하고, 동시에 <code>admin</code>인 경우에도 접근을 허락해야한다.  이때 우린 토큰을 통해 받아온 <code>scope</code>를 활용하여 조정해줄 수 있다.</p>
<p>작성한 코드를 설명하자면 <code>is_ambassdor</code>가 <code>true</code>이고, <code>scope</code>가 <code>ambassador</code>인 경우에 접근을 허용한다. 또는 <code>is_ambassador</code>가 <code>false</code>이고, <code>scope</code>가 <code>admin</code>인 경우에도 접근을 허용한다. </p>
<p>이것이 의미하는게 무엇일까?</p>
<p><code>&#39;api/ambassador&#39;</code> 경로에 대해서는 <code>&#39;ambassador&#39;</code> 스코프를 가져야만 접근이 허용되고, 그 외의 경로에 대해서는 <code>&#39;admin&#39;</code> 스코프를 가져야 접근이 허용된다는 것이다.</p>
<p>조금 더 잘 이해하기 위해 <code>JwtAccessGuard</code>를 사용하여 유저 권한을 확인하는 라우트 핸들러 함수를 살펴보자.</p>
<pre><code class="language-tsx">// auth.controller.ts

  @UseInterceptors(ClassSerializerInterceptor)
  @Get([&#39;admin/authenticate&#39;, &#39;ambassador/authenticate&#39;])
  @UseGuards(JwtAccessAuthGuard)
  async user(@Req() req: Request): Promise&lt;User&gt; {
    const cookie = req.cookies[&#39;access_token_1&#39;];
    const { id } = await this.jwtService.verifyAsync(cookie); 
    const verifiedUser: User = await this.userService.findUserById(id);
    return verifiedUser;
  }</code></pre>
<p>즉, <code>JwtAccessAuthGuard</code>를 사용하는 핸들러 함수인 <code>user()</code> 메서드에서는 <code>&#39;api/ambassador&#39;</code> 경로에 대해서는 <code>&#39;ambassador&#39;</code> 스코프를 가져야만 접근이 허용되고, <u>그 외의 경로에 대해서는</u> <code>&#39;admin&#39;</code> 스코프를 가져야 접근이 허용된다. 이를 위해 JWT 토큰의 <code>scope</code> 값을 검증하고, 요청의 경로에 따라 <code>is_ambassador</code> 변수를 설정하여 접근을 제어하게 된다.</p>
<p>그럼 아래의 테스트 과정을 통해 확인해보자.</p>
</br>

<p><strong>✔ Test (Postman)</strong></p>
<p><strong>1) <code>admin</code> 로그인 유저</strong></p>
<p>먼저, <code>is_ambassador</code>가 false인 admin 유저의 로그인을 진행한다.
<img src="https://velog.velcdn.com/images/from_numpy/post/98413bcf-d5fd-455d-b8a8-04d4edfadeec/image.png" alt=""></p>
<p>해당 유저에 대한 권한 확인을 <code>&#39;/admin/authenticate&#39;</code> 경로 요청으로 확인해본다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/6d4b484a-d494-4649-964c-e7f28db71a4d/image.png" alt=""></p>
<p>잘 검증이 된 것을 확인할 수 있다.</p>
<p>그렇다면, 해당 admin 유저에 대한 권한 확인 요청을 <code>&#39;/ambassador/authenticate&#39;</code>로 수행하면 어떻게 될까?</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/3ea94456-5dd3-4084-91fa-e4fb0a491ed9/image.png" alt=""></p>
<p>우리가 설정한 가드를통해 접근할 수 없다는 응답을 받을 수 있다.</p>
</br>

<p><strong>2) <code>ambassador</code> 유저</strong></p>
<p><code>is_ambassador</code>가 true인 ambassador 유저의 로그인을 진행한다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/1e599eaa-3d92-48a6-b10f-8f71fc0b7f0a/image.png" alt=""></p>
<p>해당 유저에 대한 권한 확인을 <code>&#39;/ambassador/authenticate&#39;</code> 경로 요청으로 확인해본다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/9fef439e-3f5e-42e6-b559-36d987d4da59/image.png" alt=""></p>
<p>잘 검증이 된 것을 확인할 수 있고, 앞서와 마찬가지로 <code>&#39;admin/authenticate&#39;</code>로 요청을 날리게 되면 접근에러 응답을 받게 될 것이다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/80d310aa-a356-4de7-81e8-619975b35c0b/image.png" alt=""></p>
</br>

<h2 id="생각정리">생각정리</h2>
<p>이번 포스팅에선 <strong>&quot;Multiple Routes&quot;</strong>를 통해 서로 다른 <strong>endpoint</strong>가 어떻게 동일한 라우트 핸들러 함수를 공유할 수 있는지에 대해 알아보았고, 동시에 <strong>&quot;하나의 인증가드&quot;</strong>를 사용하는데 있어서 어떠한 과정으로 서로 다른 <strong>endpoint</strong>에 대한 권한 인증처리를 할 수 있는지 역시 알아볼 수 있었다.</p>
<p>더 좋은 예시가 있을 수 있겠지만 <span style="color:dimgray">(학교 홈페이지에서 교수와 학생 인증을 분리하는 것 또한 예시가 될 수 있을 것이다)</span> <code>admin</code> 과 <code>ambassador</code>라는 일련의 예시를 통해 Multiple Routes 과정을 알아볼 수 있었고, 더불어 &quot;Scope&quot;라는 속성을 인증 토큰(액세스 토큰)에 포함시킴으로써 서로 다른 경로의 인증 요청에 대한 유연한 검증을 처리할 수 있었다.</p>
<p>더 좋은 방법이 있는지, 혹은 수정할 사항이 있는지에 대해 더 고민해보기로 하고 여기서 포스팅을 마치겠습니다... </p>
<p>(해당 포스팅또한 진행중이였던 nestjs 인증 시리즈에 추가하도록 하겠습니다)</p>
<hr>
<p><a href="https://velog.io/@from_numpy/series/NestJS-Authentication-advanced-part">nestjs - Authentication (advanced part)</a></p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] Authentication using Google OAuth (+ Session)]]></title>
            <link>https://velog.io/@from_numpy/NestJS-Authentication-using-Google-OAuth-Session</link>
            <guid>https://velog.io/@from_numpy/NestJS-Authentication-using-Google-OAuth-Session</guid>
            <pubDate>Mon, 29 May 2023 09:26:47 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>인증의 마지막? (마지막이 되지 않을 수도 있지만 형식상 이번 포스팅을 잠정 마지막으로 하려한다...) 포스팅에서 다룰 주제는 지난 포스팅의 OAuth2.0 개념에 이어서 실제 <u>NestJS에서 구현</u>해보는 <strong>OAuth2.0</strong>이다.</p>
<hr>
<p><a href="https://velog.io/@from_numpy/OAuth-2.0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0">OAuth 2.0 알아보기 ✔</a></p>
<hr>
<p>여러 소셜 로그인 제공 서비스가 많지만 가장 흔하게 접해볼 수 있는 <strong>&quot;Google-OAuth-Login&quot;</strong>을 다뤄보기로 하였다.</p>
<p>구글링을 통해 확인해볼 수 있는 여러 블로그 또는 자료에서 Google-Login 창을 띄워 로그인을 하는 것 까지에 대한 내용은 많았지만, 조금 더 세밀한 접근에 대해 알 수 없었다. 이번 포스팅에선 단순 로그인 구현을 넘어 실제 인증을 확인하는 것 까지 다뤄보고자 한다.</p>
<hr>
<p><strong>✔ 구현 과정</strong></p>
<blockquote>
<ol>
<li>Google Cloud에서 Client_ID 및 Secret 값 받기 (생략)</li>
<li>PassportModule을 통해 로그인 전략 구축</li>
<li>Session Serializer를 통한 유저 인증 상태 관리 (세션 사용)</li>
<li>DB에 소셜 로그인 정보 저장 및 업데이트</li>
</ol>
</blockquote>
<hr>
</br>

<h2 id="💥-google-oauth-인증-수행하기">💥 <code>Google OAuth</code> 인증 수행하기</h2>
<p><strong>&quot;Google Cloud&quot;</strong>로 부터 받아와야할 필수 값 <strong>(client_id, secret, scope)</strong>을 불러왔다고 가정하고 시작하겠다.</p>
<p>이번 <strong>&quot;Google-OAuth Social Login&quot;</strong>의 핵심은 여태까지 다루었던 다른 인증과는 다르게 <strong>&quot;jwt&quot;</strong>를 사용하지 않고, <span style="color:red"><strong>&quot;session&quot;</strong></span>을 사용하도록 하였다. </p>
<p>여태껏 계속해서 이놈의 <strong>&quot;jwt&quot;</strong>를 활용하여 로컬 로그인 시의 <code>access_token</code>, <code>refresh_token</code>에 사용하였고 더하여 <code>2FA</code>에서도 사용하였다. 하지만, 당연한 얘기겠지만 Google로부터 발급받은 <strong>인증 토큰</strong>은 &quot;jwt&quot;가 아니다. </p>
<p>즉, Google에서 제공받은 인증 토큰(그 중에서도 &quot;<u>access token</u>&quot;)은 로그인 시 유저에 대한 <code>id</code> 정보를 담고 있는 토큰이 아니다. &quot;access token&quot;의 목적은 <strong>&quot;Google API&quot;</strong>에 <u>승인 정보를 제공</u>하기 위해 사용된다. (<code>ID token</code> 따로 존재)</p>
<p>물론, 자체적으로 서명한 &quot;JWT&quot;를 사용하는 방법또한 가능하다. 여태껏 그래왔던 것처럼 직접 JWT 토큰을 생성해서 유저를 인증하는 것이다. 사실 그렇게 수행해도 괜찮을 것 같다는 생각이지만, 너무 &quot;JWT&quot;에 의존하고 있다 느꼈고, 인증 상태에 대해 조금 더 유연하게 처리할 수 있지 않을까 생각해보았다.</p>
</br>

<h3 id="googlestrategy--guard-using-passport">&gt; <code>GoogleStrategy</code> &amp;&amp; <code>Guard</code> (using <code>Passport</code>)</h3>
<p><span style="color:gray">(설치해야할 패키지에 대한 명령어는 생략)</span></p>
<p>먼저, <code>google-passport</code>를 통해 불러온 <code>Strategy</code> 클래스를 만든다. 해당 전략 클래스 내부에서 우린 <code>Google-Authentication</code>에 필요한 필수 환경 요소를 설정해준다. 해당 값들을 직접 작성하기 보단, <code>.env</code>와 같은 전역파일에 작성해주는 것이 좋을 것이다.</p>
<pre><code class="language-ts">// .env

# Google-OAuth2.0
GOOGLE_CLIENT_ID=19288...vgdbff.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-..._maJX
GOOGLE_CALLBACK_URL=http://localhost:4000/api/auth/google/redirect
GOOGLE_SCOPE_PROFILE=profile
GOOGLE_SCOPE_EMAIL=email
</code></pre>
<p><strong>✔ GoogleStrategy</strong></p>
<pre><code class="language-tsx">// google-strategy.ts

import { Injectable } from &quot;@nestjs/common&quot;;
import { PassportStrategy } from &quot;@nestjs/passport&quot;;
import { Profile, Strategy, VerifyCallback } from &quot;passport-google-oauth20&quot;;
import { GoogleAuthenticationService } from &quot;../google-auth.service&quot;;
import { SocialLoginInfoDto } from &quot;../utils/socialLogin-info.dto&quot;;


@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly googleAuthService: GoogleAuthenticationService
  ) {
    super({
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: process.env.GOOGLE_CALLBACK_URL,
      scope: [process.env.GOOGLE_SCOPE_PROFILE, process.env.GOOGLE_SCOPE_EMAIL],
    });
  }

  // refreshToken을 얻고 싶다면 해당 메서드 설정 필수
  authorizationParams(): {[key: string]: string; } {
    return ({
      access_type: &#39;offline&#39;,
      prompt: &#39;select_account&#39;,
    });
  }

  async validate(accessToken: string, refreshToken: string, profile: Profile, done: VerifyCallback): Promise&lt;void&gt; {
    const { name, emails, provider } = profile;
    const socialLoginUserInfo: SocialLoginInfoDto = {
      email: emails[0].value,
      firstName: name.givenName,
      lastName: name.familyName,
      socialProvider: provider,
      externalId: profile.id,
      accessToken,
      refreshToken,
    };
    try {
      const user = await this.googleAuthService.validateAndSaveUser(socialLoginUserInfo);
      console.log(user,&quot;strategy&quot;);
      done(null, user, accessToken);
    } catch (err) {
      done(err, false);
    } 
  }
}</code></pre>
<p>코드에 대해 일일히 주저리주저리 설명하긴 너무 지루할 것이고, 중요한 부분을 짚고 넘어가자. </p>
<p>먼저 <strong><code>authorizationParams()</code></strong> 메서드 설정이다. 위 메서드 설정은 필수가 아니다. 하지만, 만약 <strong>&quot;refresh token&quot;</strong>을 얻고 싶다면 해당 메서드를 정의하고 내부에 <strong><code>access_type: &#39;offline&#39;</code></strong>이란 값을 리턴해야할 것이다. 오프라인 액세스 타입을 지정함으로써 사용자는 액세스 토큰과 함께 리프레시 토큰도 발급받을 수 있다. </p>
<p>리프레시 토큰 발급이 필수는 아니지만, 만약 구글로부터 API 접근에 필요한 access token을 갱신할 수 있는 리플레시 토큰을 받고 싶다면 꼭 위의 코드가 필요하다. 생각보다 <u>해당 설정에 대한 내용을 찾기가 매우 힘들었다</u>. 그러므로 공유해본다.</p>
</br>

<p>이제, 핵심이 되는 <strong><code>validate()</code></strong> 함수이다. 우린 우리가 설정한 <code>scope</code>에 따라 구글을 통해 토큰 및 <strong>&quot;profile&quot;</strong> 정보를 불러올 수 있다. 콘솔을 통해 <code>profile</code> 객체를 출력해보면 아래와 같은 형태를 얻을 수 있을 것이다. 이 데이터를 통해 우린 추후 필요한 작업들을 수행할 수 있게 된다.</p>
<pre><code class="language-ts">{
  id: &#39;106********4944&#39;,
  displayName: &#39;킥코&#39;,
  name: { familyName: &#39;킥&#39;, givenName: &#39;코&#39; },
  emails: [ { value: &#39;a********@gmail.com&#39;, verified: true } ],
  photos: [
    {
      value: &#39;https://lh3.googleusercontent.com/a/AAcHTtcYFrnWmVzI4qayqhqWfmUEGt27y6QMqo8-_l4I4A=s96-c&#39;
    }
  ],
  provider: &#39;google&#39;,
  _raw: &#39;{\n&#39; +
    &#39;  &quot;sub&quot;: &quot;106********4944&quot;,\n&#39; +
    &#39;  &quot;name&quot;: &quot;킥코&quot;,\n&#39; +
    &#39;  &quot;given_name&quot;: &quot;코&quot;,\n&#39; +
    &#39;  &quot;family_name&quot;: &quot;킥&quot;,\n&#39; +
    &#39;  &quot;picture&quot;: &quot;https://lh3.googleusercontent.com/a/AAcHTtcYFrnWmVzI4qayqhqWfmUEGt27y6QMqo8-_l4I4A\\u003ds96-c&quot;,\n&#39; +
    &#39;  &quot;email&quot;: &quot;a********@gmail.com&quot;,\n&#39; +
    &#39;  &quot;email_verified&quot;: true,\n&#39; +
    &#39;  &quot;locale&quot;: &quot;ko&quot;\n&#39; +
    &#39;}&#39;,
  _json: {
    sub: &#39;106********4944&#39;,
    name: &#39;킥코&#39;,
    given_name: &#39;코&#39;,
    family_name: &#39;킥&#39;,
    picture: &#39;https://lh3.googleusercontent.com/a/AAcHTtcYFrnWmVzI4qayqhqWfmUEGt27y6QMqo8-_l4I4A=s96-c&#39;,
    email: &#39;a********@gmail.com&#39;,
    email_verified: true,
    locale: &#39;ko&#39;
  }
}</code></pre>
<p>위의 <code>profile</code> 데이터와 동시에 받게 되는 <code>refreshToken</code> 등을 활용해 <code>user</code> 객체를 리턴하기 전, 일련의 검증 및 추가작업을 수행하기 위해 서비스 로직에서 <code>validateAndSaveUser()</code> 메서드를 거치도록 하였다. 해당 메서드의 인자로써 구글 소셜 로그인을 통해 받게 된 정보의 객체를 받도록 한다.</p>
<pre><code class="language-tsx">async validate(accessToken: string, refreshToken: string, profile: Profile, done: VerifyCallback): Promise&lt;void&gt; {
    const { name, emails, provider } = profile;
    const socialLoginUserInfo: SocialLoginInfoDto = {
      email: emails[0].value,
      firstName: name.givenName,
      lastName: name.familyName,
      socialProvider: provider,
      externalId: profile.id,
      accessToken,
      refreshToken,
    };
    try {
      const user = await this.googleAuthService.validateAndSaveUser(socialLoginUserInfo);
      console.log(user,&quot;strategy&quot;);
      // 3번째 인자로 accessToken을 담아줌으로써, login 요청마다 현재 로그인 상태에 대한 유효성을 검증 받도록 한다.
      done(null, user, accessToken);
    } catch (err) {
      done(err, false);
    } 
  }</code></pre>
</br>

<p><strong>✔ GoogleAuthGuard</strong></p>
<p>다음으로, 위에서 만들어준 전략(Strategy)을 사용할 <strong>&quot;가드(Guard)&quot;</strong>를 생성할 차례이다. 처음에 가드를 어떤 식으로 작성할 지 몰랐지만, &quot;github issue&quot;에서 특정인의 오피니언을 통해 해결할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/174bfdb9-51fd-420c-90df-008b1e10f59a/image.png" alt=""></p>
<pre><code class="language-tsx">// google-auth.guard.ts

import { ExecutionContext, Injectable } from &quot;@nestjs/common&quot;;
import { AuthGuard } from &quot;@nestjs/passport&quot;;

@Injectable()
export class GoogleAuthGuard extends AuthGuard(&#39;google&#39;) {
  constructor() {
    super()
  }
  async canActivate(context: ExecutionContext) {
    const activate = await super.canActivate(context) as boolean;
    const request = context.switchToHttp().getRequest();
    await super.logIn(request);
    return activate;
  }
}</code></pre>
<p>위의 <code>logIn(request)</code> 함수를 호출해줄 필요가 있다. 해당 <strong><code>logIn()</code></strong> 함수는 Passport와 함께 쓰이면서 Passport와 관련된 로그인 동작을 수행할 수 있다.</p>
<pre><code class="language-tsx">export declare type IAuthGuard = CanActivate &amp; {
    logIn&lt;TRequest extends {
        logIn: Function;
    } = any&gt;(request: TRequest): Promise&lt;void&gt;;
    handleRequest&lt;TUser = any&gt;(err: any, user: any, info: any, context: ExecutionContext, status?: any): TUser;
    getAuthenticateOptions(context: ExecutionContext): IAuthModuleOptions | undefined;
};</code></pre>
<p><span style="color:green">만약, 세션 설정을 하였음에도 저장에 문제가 생긴다면 위의 가드 작성을 꼭 고려해볼 필요가 있을 것이다.</span></p>
</br>

<h3 id="base-model-userentity--socialprovider">&gt; Base Model (<code>UserEntity</code> &amp;&amp; <code>SocialProvider</code>)</h3>
<p>서비스 로직을 알아보기 전, 필요한 유저 객체 및 유틸 객체들을 미리 살펴보도록 하자.</p>
<p><strong>✔ UserEntity</strong></p>
<pre><code class="language-tsx">// user.entity.ts

import { Provider } from &#39;../../auth/google-oauth2/utils/provider.enum&#39;;
import { Column, Entity, PrimaryGeneratedColumn } from &#39;typeorm&#39;;

@Entity({name:&#39;users&#39;})
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({name:&#39;firstname&#39;})
  firstName: string;

  @Column({name:&#39;lastname&#39;})
  lastName: string;

  @Column({name:&#39;email&#39;})
  email: string;

  @Column({ nullable: true, default: null })
  password: string;

  @Column({ nullable: true })
  localRefreshToken: string;

  @Column({ type: &#39;datetime&#39;, nullable: true })
  localRefreshTokenExp: Date;

  @Column({ nullable: true })
  twoFactorAuthenticationSecret: string;

  @Column({ default: false })
  isTwoFactorAuthenticationEnabled: boolean;

  // 아래의 프로퍼티부터 소셜 로그인과 관련

  @Column({ default: false })
  isSocialAccountRegistered: boolean;

  @Column({ name: &#39;social_provider&#39;, default: Provider.LOCAL })
  socialProvider: string;

  @Column({ name: &#39;external_id&#39;, nullable: true, default: null })
  externalId: string;

  @Column({ name: &#39;social_refresh_token&#39;, nullable: true, default: null })
  socialProvidedRefreshToken: string;
}
</code></pre>
<p>처음엔 로컬 사용자와 각 소셜 서비스마다의 사용자의 <u>테이블을 분리하는 구조를 계획했으나</u>, 분리시킬 경우 일련의 처리를 수행할 시 불필요하게 테이블마다 접근을 해야하는 단점이 생길 것으로 판단하였다. 즉, 로컬과 소셜로그인을 통한 유저 테이블을 단일 테이블로 두기로 하였다.</p>
</br>

<p><strong>✔ Provider</strong></p>
<pre><code class="language-tsx">export enum Provider {
  LOCAL = &#39;local&#39;,
  GOOGLE = &#39;google&#39;,
  // 추후 다른 소셜 값들또한 작성하면 된다.
}</code></pre>
</br>

<h3 id="googleauthenticationservice">&gt; <code>GoogleAuthenticationService</code></h3>
<p>서비스 로직에선 <span style="color:green">중복된 이메일로 가입된 계정이 존재하는지 판단</span>하고, 만약 존재할 경우와 그렇지 않을 경우로 나누어 작업을 수행해주기로 하였다. </p>
<p>만약, 가입하려는 이메일이 이미 사용된 이메일일 경우 유저에게 &quot;해당 이메일을 사용하는 계정이 이미 존재합니다&quot; 라는 알림을 보냄과 동시에 다른 계정의 사용을 막을 수도 있을 것이다. 또한, 그와 반대로 동일한 이메일이여도 소셜 제공자가 다를 시 추가의 계정을 생성할 수 있게 할 수도 있을 것이다. </p>
<p><span style="color:green">(클라이언트 측에서도 <strong>&quot;<code>localstorage</code>&quot;</strong>와 같은 저장소를 활용하여 해당 기기에 대한 최근 사용 계정들을 유저에게 표시할 수 있을 것이다)</span></p>
<p>이렇듯, <strong>&quot;서비스의 방향성&quot;</strong>, <strong>&quot;서비스의 계획안&quot;</strong> 등에 따라 서로 다른 처리를 해줄 수 있을 것이고, 어떤 것이 옳고 그름에 대한 정답은 없다고 본다. 해당 문제에 대해선 아직 더 고민해 볼 필요가 많을 것 같고 (나아가서 계정 통합 등의 방법도 있을 것이다) 이번 포스팅에서 다루 긴 부족할 것 같다.</p>
</br>


<p><strong>✔ GoogleAuthenticationService</strong></p>
<pre><code class="language-tsx">// google-authentication.service.ts

import { Injectable } from &quot;@nestjs/common&quot;;
import { SocialLoginInfoDto } from &quot;./utils/socialLogin-info.dto&quot;;
import { UsersService } from &quot;../../users/users.service&quot;;
import { Provider } from &quot;./utils/provider.enum&quot;;
import { User } from &quot;../../users/entities/users.entity&quot;;

@Injectable()
export class GoogleAuthenticationService {
  constructor(
    private readonly userService: UsersService,
  ) {}
  async validateAndSaveUser(socialLoginInfoDto: SocialLoginInfoDto): Promise&lt;object | User&gt; {
    const { email, refreshToken } = socialLoginInfoDto;

    const existingUser = await this.userService.findUserByEmail(email);

    if (existingUser) {
      if (existingUser.socialProvider !== Provider.GOOGLE) {
        return {
          existingUser: existingUser,
          msg: &#39;해당 이메일을 사용중인 계정이 존재합니다.&#39;
        }
      } else {
        const updateUserWithRefToken: User = await this.userService.updateSocialUserRefToken(existingUser.id, refreshToken);
        return updateUserWithRefToken;
      }
    }

    const newUser = await this.userService.createSocialUser(socialLoginInfoDto);
    const updateUser = await this.userService.updateSocialUserInfo(newUser.id);

    return updateUser;
  }
}</code></pre>
<p>만약, <code>existingUser</code>가 존재한다면(true라면) 우린 특정 작업을 수행해야할 것이다.</p>
<p>여기서 주의할 점은 본인이 작성한 로직에 따라, <strong><code>validateAndSaveUser()</code></strong> 함수는 <code>GoogleStrategy</code>의 <code>validate()</code> 검증시에 실행되고 해당 검증은 <strong>&quot;로그인 이후, redirect 이전&quot;</strong>에 수행될 것이다. 즉, <strong>&quot;로그인 시&quot;</strong>에 수행된다는 것을 항상 염두해 두어야했다. (더 나은 로직이 분명 존재할 것이다. ㅠㅠ )</p>
<ul>
<li><p><strong><code>existingUser</code>가 true임과 동시에 해당 유저의 <code>provider</code>가 <code>GOOGLE</code>이 아닌 경우</strong>
<span style="color:dimgray">: 즉, 구글을 통한 소셜 가입 유저가 아닌 로컬 혹은 다른 소셜 서비스(kakao, ...) 가입 유저의 이메일 중복성을 체크하는 부분이다.</span></p>
</li>
<li><p><strong><code>provider</code>가 <code>GOOGLE</code>이 아닌 경우</strong>
<span style="color:dimgray">: 해당 경우는 달리 말하면, 그냥 <strong>&quot;로그인&quot;</strong> 하였을 경우이다. 즉, 구글 로그인으로 생성된 유저가 구글 로그인을 수행하였을 때이다. (이것이 위에서 &quot;로그인 시&quot;를 강조한 이유이다...) 해당 경우에 그냥 <code>existingUser</code>를 반환할 수 있지만, 우린 &quot;refreshToken&quot;을 구글로부터 발급받고 있으므로 로그인시마다 업데이트되어 제공되는 해당 토큰 값을 유저 테이블에서도 업데이트 시켜주어야할 것이다.</span></p>
</li>
</ul>
<p>다음으로 <code>if</code>문을 벗어나, <u>로그인 시 사용된 이메일이 유저 테이블내에 존재하지 않는다면</u>, 해당 계정을 생성하고 더하여 소셜 로그인을 사용하고 있는지에 대한 <code>isSocialAccountRegistered</code> 값을 <code>true</code>로 수정토록 한다.</p>
</br>

<p><strong>✔ UserService</strong></p>
<pre><code class="language-tsx">// user.service.ts

import { Injectable, } from &#39;@nestjs/common&#39;;
import { UsersRepository } from &#39;./repositories/users.repository&#39;;
import { User } from &#39;./entities/users.entity&#39;;
import { ConfigService } from &#39;@nestjs/config&#39;;
import { UpdateResult } from &#39;typeorm&#39;;
import { SocialLoginInfoDto } from &#39;../auth/google-oauth2/utils/socialLogin-info.dto&#39;;

@Injectable()
export class UsersService {
  constructor(
    private readonly userRepository: UsersRepository,
    private readonly configService: ConfigService,
  ) {}

  async createSocialUser(socialLoginInfoDto: SocialLoginInfoDto): Promise&lt;User&gt; {
    const { email, firstName, lastName, socialProvider, externalId, refreshToken } = socialLoginInfoDto;

    const newUser: User = await this.userRepository.save({
      email: email,
      firstName: firstName,
      lastName: lastName,
      socialProvider: socialProvider,
      externalId: externalId,
      socialProvidedRefreshToken: refreshToken,
    });
    return await this.userRepository.save(newUser);
  }

  async findUserById(id: number): Promise&lt;User&gt; {
    return await this.userRepository.findOne({
      where: {
        id: id
      },
    })
  }

  async updateSocialUserInfo(id: number) {
    await this.userRepository.update(id, {
      isSocialAccountRegistered: true,
    })
    const updateUser = await this.userRepository.findOne({
      where: {
        id: id,
      },
    });
    return updateUser;
  }

  async updateSocialUserRefToken(id: number, refreshToken: string) {
    await this.userRepository.update(id, {
      socialProvidedRefreshToken: refreshToken,
    })
    const updateUser = await this.userRepository.findOne({
      where: {
        id: id,
      }
    });
    return updateUser;
  }

}
</code></pre>
</br>

<p>&quot;Strategy&quot; 에서 &quot;GoogleAuthService&quot; 로직을 호출하고, 동시에 &quot;GoogleAuthService&quot;에서 &quot;UserService&quot; 로직을 호출하는 구조이기 때문에 위와 같은 처리가 필요하였다. </p>
<p><span style="color:green">Controller layer의 복잡성을 없애려고 위의 방법을 택했지만, 추후 더 고민해봐야할 부분이라 생각이 든다.</span></p>
</br>

<h3 id="sessionserializer를-통한-세션-객체-사용">&gt; <code>SessionSerializer</code>를 통한 세션 객체 사용</h3>
<p>세션 사용 이유에 대한 설명은 위에 언급했으므로 생략하겠다. </p>
<p>세션 직렬화 및 역직렬화를 시작하기 전에 앱 단위 및 모듈 단위의 미들웨어 설정을 해주어야한다.</p>
<p><strong>✔ main.ts</strong></p>
<pre><code class="language-tsx">// main.ts

// `express-session` 미들웨어를 설치해 아래의 코드를 작성할 수 있다.
import * as session from &#39;express-session&#39;;
import * as passport from &#39;passport&#39;;

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix(&quot;api&quot;);
  app.useGlobalPipes(new ValidationPipe({ transform: true }));
  app.use(
    session({
      secret: &#39;my-secret&#39;, // 세션을 암호화하기 위한 암호기 설정
      resave: false, // 모든 request마다 기존에 있던 session에 아무런 변경 사항이 없을 시에도 그 session을 다시 저장하는 옵션
      // saveUnitialized: 초기화되지 않은 세션을 저장할지 여부를 나타낸다.
      saveUninitialized: false,
      // 세션 쿠키에 대한 설정을 나타낸다.
      cookie: {
        maxAge: 60000,  // 1 minute
        httpOnly: true,

      },
    })
  );
  // Passport를 초기화하는 미들웨어, 이를 통해 Passport의 인증/인가를 사용할 수 있다.
  app.use(passport.initialize());
  // Passport 세션을 사용하기 위한 미들웨어이다. 이를 통해 Passport는 세션을 기반으로 사용자의 인증 상태를 유지 관리 할 수 있다.
  app.use(passport.session());


bootstrap();
</code></pre>
<ul>
<li><p>why we set <code>resave == false</code>? 
<span style="color:gray">: 변경 사항도 없는 세션이 매번 다시 저장되는 것은 비효율적이다. (일반 적으로 대부분의 상황에 <code>false</code>이다.</span></p>
<ul>
<li>why we set <code>saveUninitialized == false</code> ?
<span style="color:gray">: 말그대로, <code>uninitialized(초기화 되지 않은)</code> 상태의 세션도 저장할 것인가에 대한 옵션이다. 해당 옵션을 <code>true</code>로 지정할 경우 세션 스토리지에 저장되는 세션의 수가 계속 증가할 것이고 결국 스토리지 사용량에 부하가 전달된다. 공식문서에선 로그인에 세션을 사용하는 경우, 해당 옵션을 <code>false</code>로 할 것을 권장한다.</span></li>
</ul>
</li>
</ul>
<hr>
<p><span style="color:red"><strong>※ Info !</strong></span></p>
<p>nestjs 공식문서에서 session에 대한 설명 중 다음과 같은 내용을 제시한다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/db7b535a-9d4c-4c8a-850f-dbea58aa82bd/image.png" alt=""></p>
<p><span style="color:dimgray"><em>&quot;기본적으로 제공되는 서버 측 세션 저장소는 의도적으로 프로덕션 환경에는 적합하지 않습니다. 대부분의 상황에서 메모리 누수가 발생하며, 단일 프로세스를 넘어서는 확장이 불가능하며, 주로 디버깅 및 개발 목적으로 사용됩니다. 자세한 내용은 공식 저장소에서 확인하세요.&quot;</em></span></p>
<p>공식문서에서 처음 제시하는 방법 및 현재 내가 수행하고 있는 세션 저장 방법은 기본적 제공되는 저장소를 이용한다. 이는, 프로덕션 환경에선 좋은 방법이 아니며, <code>MysqlStore</code>, <code>RedisStore</code>등 적절한 스토어를 설정하여 세션 객체를 저장하는 것이 좋을것이다.</p>
<p>동시에 &quot;단일 프로세스&quot;에 대해서 유효하고, 만약 &quot;nginx&quot; 등을 이용해 로드 밸런싱을 구현하여 <strong>다중 서버 환경</strong>(&quot;멀티 프로세스&quot;)을 사용한다면 세션을 기반으로 하는 데이터의 서버 간 <strong>&quot;동기화&quot;</strong> 문제가 발생할 수 있다. </p>
<p>위와 같은 내용에 대해 아직 공부해본적이 없기 때문에 깊이 다룰 순 없지만 이러한 상황에 대해서도 고려해볼 필요는 충분히 있을 것 같다. <strong>&quot;세션&quot;</strong> 자체가 이번 포스팅의 주제는 아니므로 이정도까지로 넘어가자.</p>
<hr>
</br>

<p><strong>✔ AuthModule (PassportModule session 추가 설정)</strong></p>
<pre><code class="language-tsx">// auth.module.ts

@Module({
  imports: [

    // ...

    PassportModule.register({
      session: true,
    }),

    // ...

  ],
export class AuthModule {}</code></pre>
</br>

<p><strong>✔ SessionSerializer</strong> 💢중요!💢</p>
<p>우린 <code>@nestjs/passport</code> 에서 제공하는 <strong><code>PassportSerializer</code></strong>를 확장받아 <strong>&quot;SessionSerializer&quot;</strong> 클래스를 정의할 수 있다.</p>
<pre><code class="language-tsx">// serializer.ts

import { PassportSerializer } from &quot;@nestjs/passport&quot;;
import { Injectable } from &quot;@nestjs/common&quot;;
import { User } from &quot;../../../users/entities/users.entity&quot;;
import { GoogleAuthenticationService } from &quot;../google-auth.service&quot;;

@Injectable()
export class SessionSerializer extends PassportSerializer {
  constructor(
    private readonly googleAuthService: GoogleAuthenticationService, 
  ) {
    super();
  }

  async serializeUser(user: User, done: (err: any, user?: any) =&gt; void): Promise&lt;any&gt; {
    console.log(user, &quot;serializeUser&quot;); // 테스트 시 확인
    done(null, user);
  }

  async deserializeUser(payload: any, done: (err: any, user?: any) =&gt; void): Promise&lt;any&gt; {
    const user = await this.googleAuthService.findUserById(payload.id);
    console.log(user, &quot;deserializeUser&quot;); // 테스트 시 확인
    return user ? done(null, user) : done(null, null);
  }
}</code></pre>
<p>해당 <code>SessionSerializer</code> 클래스는 <code>serializeUser</code>와 <code>deserilizeUser</code> 메서드를 필수적으로 정의함으로써 사용할 수 있다. </p>
<p>이는 Passport의 <strong>직렬화</strong>(serilization)및 <strong>역직렬화</strong>(deserilization) 매커니즘을 구현하기 위해 사용되며, <u>세션 인증 기반</u>에 필수적으로 사용된다.</p>
<ul>
<li><strong><code>serializeUser</code></strong><ul>
<li>사용자 객체를 직렬화하여 세션에 저장한다. (직렬화 =&gt; 사용자의 상태를 세션에 캡슐화하는 과정)</li>
<li>세션은 일반적으로 서버(메모리, or DB)에 저장되고, 우리가 앞서 설정한 session 설정에 따라 식별자(<strong>Cookie</strong>)는 클라이언트로 전송된다.</li>
<li><code>serializeUser</code>는 로그인 성공 후 호출되며, 사용자 식별자(일반적으로 ID 값)를 세션에 저장한다.
<span style="color:green">==&gt; 하지만 우리의 경우엔 <code>user</code> 객체 자체를 식별자로 담아주었다.  <code>user.id</code>를 식별자로 한 경우 제대로 된 요청 객체를 검증하지 못하였다.</span></li>
</ul>
</li>
</ul>
</br>

<ul>
<li><p><strong><code>deserializeUser</code></strong></p>
<ul>
<li>세션에서 사용자 객체를 역직렬화하여 추출한다. (역직렬화 =&gt; 세션에 저장된 사용자 정보를 사용자 객체로 복원하는 과정)</li>
<li>(우리가 기존에 설정해 준 옵션에 따라서) 요청이 들어올 때마다 세션에서 사용자 정보를 검색하고, 해당 사용자에 대한 데이터베이스 조회 또는 기타 작업을 수행한다.</li>
<li><code>deserializeUser</code>는 요청 핸들러에서 <code>req.user</code>로 사용자에 대한 권한 부여 및 인가를 수행할 수 있게 해준다. ( 추후, 컨트롤러단에서 확인 )</li>
<li>이러한 역직렬화를 통해 인증된 사용자의 상태를 확인하고, 해당 사용자에 대한 권한 부여 및 인가를 수행할 수 있다.</li>
</ul>
<pre><code class="language-tsx">// google-auth.service.ts

async findUserById(id: number) {
  const user = await this.userService.findUserById(id);
  return user;
}
</code></pre>
</li>
</ul>
</br>

<h3 id="요청-핸들러-controller-및-테스트">&gt; 요청 핸들러 (<code>Controller</code>) 및 테스트</h3>
<p>이제 마지막으로 <u>라우트 핸들러</u>를 컨트롤러 단위에서 작성해보고, 우리가 생성한 전략, 가드, 서비스 로직을 테스트할 차례이다. <strong>Google-OAuth Login</strong>시에, 각 구현부들이 <u>언제 개입하고 어떻게 적용되는지</u>에 대해 확인해보자. (간단히 console.log()를 활용해 터미널에서 실행 과정및 순서를 알아볼 것이다)</p>
<p><strong>✔ GoogleAuthenticationController</strong></p>
<pre><code class="language-tsx">// google-auth.controller.ts

import { Controller, Get, Req, UseGuards } from &quot;@nestjs/common&quot;;
import { GoogleAuthGuard } from &quot;./guard/google-guard&quot;;

@Controller(&#39;auth/google&#39;)
export class GoogleAuthenticationController {
  constructor() {}

    // login 라우트 핸들러
    @Get(&#39;/login&#39;)
    @UseGuards(GoogleAuthGuard)
    async handleLogin() {
      return {
        msg: &#39;Google Authentication&#39;,
      }
    }

    // login 성공 시, redirect를 수행할 라우트 핸들러
    @Get(&#39;/redirect&#39;)
    @UseGuards(GoogleAuthGuard)
    async handleRedirect(
      @Req() req: any,
    ) {
      return req.user;
    }

    // session 저장에 따른 유저 객체 인증/인가 테스트
    @Get(&#39;/status&#39;)
    async user(@Req() req: any) {
      if (req.user) {
      console.log(req.user, &quot;Authenticated User&quot;);
        return {
          msg: &quot;Authenticated&quot;,
        } 
      } else {
      console.log(req.user, &quot;User cannot found&quot;);
        return {
          msg: &quot;Not Authenticated&quot;,
        }
      }
    }
}</code></pre>
</br>

<p><strong>✔ 로컬 환경에서 테스트해보기</strong></p>
<ol>
<li><p><a href="http://localhost:4000/api/auth/google/login">http://localhost:4000/api/auth/google/login</a> 으로 접속</p>
<p> <img src="https://velog.velcdn.com/images/from_numpy/post/33d5f774-6621-464d-a349-40318761647d/image.png" alt=""></p>
<p> 위 이미지의 구글 로그인 폼을 확인할 수 있을 것이다.</p>
</li>
<li><p>Redirect 주소로 이동 후, 로그인 성공 </p>
<p> <a href="http://localhost:4000/api/auth/google/redirect?code=4%******************mkQ&amp;scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+openid&amp;authuser=0&amp;prompt=consent">http://localhost:4000/api/auth/google/redirect?code=4%******************mkQ&amp;scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+openid&amp;authuser=0&amp;prompt=consent</a></p>
<p> (리디렉트 주소로 이동할 경우, 위와 같은 주소를 확인할 수 있을 것이다.)</p>
</li>
</ol>
<pre><code class="language-tsx">User {
  id: 3,
  firstName: &#39;코&#39;,
  lastName: &#39;킥&#39;,
  email: &#39;a****@gmail.com&#39;,
  password: null,
  localRefreshToken: null,
  localRefreshTokenExp: null,
  twoFactorAuthenticationSecret: null,
  isTwoFactorAuthenticationEnabled: false,
  isSocialAccountRegistered: true,
  socialProvider: &#39;google&#39;,
  externalId: &#39;106********4944&#39;,
  socialProvidedRefreshToken: &#39;1//0ed**************************7e1nXLfKlU0&#39;
} strategy   // Strategy 먼저 수행
User {
  id: 3,
  firstName: &#39;코&#39;,
  lastName: &#39;킥&#39;,
  email: &#39;a****@gmail.com&#39;,
  password: null,
  localRefreshToken: null,
  localRefreshTokenExp: null,
  twoFactorAuthenticationSecret: null,
  isTwoFactorAuthenticationEnabled: false,
  isSocialAccountRegistered: true,
  socialProvider: &#39;google&#39;,
  externalId: &#39;106********4944&#39;,
  socialProvidedRefreshToken: &#39;1//0ed**************************7e1nXLfKlU0&#39;
} serializeUser  // Strategy 수행 후 --&gt; SessionSerializer 수행</code></pre>
<p>간단히 터미널 창에서 각 로직을 수행시점을 확인해본 결과, 가드가 실행이되며 해당 전략 패턴이 수행되고 그 후, SessionSerializer 내부에서 정의한 직렬화 과정이 수행됨을 알 수 있다.</p>
</br>

<p><strong>✔ User 테이블 확인</strong></p>
<pre><code>mysql&gt; SELECT * FROM `auth-project`.users;
+----+-----------+----------+------------------------+--------------------------------------------------------------+---------------------+------------------------+-------------------------------+----------------------------------+---------------------------+-----------------+-----------------------+---------------------------------------------------------------------------------------------------------+
| id | firstname | lastname | email                  | password                                                     | localRefreshToken | localRefreshTokenExp | twoFactorAuthenticationSecret | isTwoFactorAuthenticationEnabled | isSocialAccountRegistered | social_provider | external_id           | social_refresh_token                                                                                    |
+----+-----------+----------+------------------------+--------------------------------------------------------------+---------------------+------------------------+-------------------------------+----------------------------------+---------------------------+-----------------+-----------------------+---------------------------------------------------------------------------------------------------------+
|  1 | 대규      | 남       | daegyu@gmail.com       | $2b$12$MH/6i6.3XtNc/tyJXMBpie14fBXSKaOAx6J9q0FzUE90B68PAfxuK | NULL                | NULL                   | NULL                          |                                0 |                         0 | local           | NULL                  | NULL                                                                                                    |
|  2 | 린이      | 코       | korin@gmail.com        | $2b$12$VH/usYi6JBSWt5E52zyp9uXVXZzVAA5jkXkAp.vW8XMuqXI2uwAdu | NULL                | NULL                   | NULL                          |                                0 |                         0 | local           | NULL                  | NULL                                                                                                    |
|  3 | 코        | 킥       | a******@gmail.com      | NULL                                                         | NULL                | NULL                   | NULL                          |                                0 |                         1 | google          | 1**********4944       | 1//0edRL**********************KlU0 |
+----+-----------+----------+------------------------+--------------------------------------------------------------+---------------------+------------------------+-------------------------------+----------------------------------+---------------------------+-----------------+-----------------------+---------------------------------------------------------------------------------------------------------+</code></pre><p>(localRefreshToken, twoFactorAuthenticationSecret과 같은 local 유저의 인증과 관련된 컬럼들에 대한 내용은 이전 포스팅들을 참조 바랍니다. (Nestjs auth series =&gt; <a href="https://velog.io/@from_numpy/series/NestJS-Authentication-advanced-part">auth - advanced part ✔(click!)</a>)</p>
<p>Google Login을 통해 가입된 유저가 테이블에 잘 등록된 것을 확인할 수 있다. <code>refresh-token</code>과 같은 정보는 위에 보여지는 것 같이 raw 값 그대로 저장하는 것이 아닌, <code>bcrypt</code>와 같은 라이브러리를 사용해 <u>해시 암호화를 거친 후 저장</u>하는 것이 좋을 것이다.</p>
</br>

<p><strong>✔ 유저 상태 인증 (through Session)</strong></p>
<p><a href="http://localhost:4000/api/auth/google/status">http://localhost:4000/api/auth/google/status</a> 주소로 접속</p>
<ol>
<li><p>세션-쿠키의 만료시간 &quot;1분&quot;이 지나지 않았을 경우 (쿠키 생성)</p>
<p> <img src="https://velog.velcdn.com/images/from_numpy/post/e8ef47c1-4d1f-489b-acd6-563102998499/image.png" alt=""></p>
<pre><code class="language-tsx"> // SessionSerializer의 `deseriliazer` 함수 실행
 User {
   id: 3,
   firstName: &#39;코&#39;,
   lastName: &#39;킥&#39;,
   email: &#39;a*******@gmail.com&#39;,
   password: null,
   localRefreshToken: null,
   localRefreshTokenExp: null,
   twoFactorAuthenticationSecret: null,
   isTwoFactorAuthenticationEnabled: false,
   isSocialAccountRegistered: true,
   socialProvider: &#39;google&#39;,
   externalId: &#39;1********4944&#39;,
   socialProvidedRefreshToken: &#39;1//0eeJIh3G*****************0n9L2nuPY&#39;
 } deserializeUser
 // 컨트롤러의 http://localhost:4000/api/auth/google/status 내부 실행
 User {
   id: 3,
   firstName: &#39;코&#39;,
   lastName: &#39;킥&#39;,
   email: &#39;a*******@gmail.com&#39;,
   password: null,
   localRefreshToken: null,
   localRefreshTokenExp: null,
   twoFactorAuthenticationSecret: null,
   isTwoFactorAuthenticationEnabled: false,
   isSocialAccountRegistered: true,
   socialProvider: &#39;google&#39;,
   externalId: &#39;1********4944&#39;,
   socialProvidedRefreshToken: &#39;1//0eeJIh3G*****************0n9L2nuPY&#39;
 } Authenticated User  // 인증된 유저 객체 반환됨
</code></pre>
</li>
</ol>
<ol start="2">
<li><p>세션-쿠키의 만료시간 &quot;1분&quot;이 지났을 경우 (쿠키 사라짐)</p>
<p> <img src="https://velog.velcdn.com/images/from_numpy/post/b9fc8582-2f19-480e-94d6-b2cc23ed7bf4/image.png" alt=""></p>
</li>
</ol>
<pre><code class="language-tsx">// SessionSerializer는 수행되지 않는다 (만료시간이 지났기 때문)
undefined User cannot found </code></pre>
<pre><code>==&gt; 유저 인증에 실패</code></pre></br>

<h2 id="생각정리">생각정리</h2>
<p>이번 포스팅은 지난 포스팅 <span style="color:green">&quot;OAuth 2.0 개념 알아보기&quot;</span>에 이어서 <strong>Google OAuth</strong>와 <strong>nestjs</strong>를 활용하여 직접 <strong>소셜 로그인</strong>을 구현해보았다.</p>
<p>물론 위의 코드 내용들은 아직은 전체적 흐름을 간략히 잡는 수준 정도일 것이다. 실 서비스에서 소셜 로그인을 통한 유저관리를 하는 과정은 훨씬 복잡할 것이다. 앞서 서두에서도 언급했지만 기존 로컬 유저와 소셜 로그인을 통해 가입한 유저들을 어떻게 관리하느냐에 대한 문제는 계속해서 고민해볼 필요가 있을 것 같다.</p>
<p>또한, jwt가 아닌 &quot;Session&quot;을 통해 유저 인증을 처음 수행해보았고 세션을 통해 저장한 객체와 사용한 Passport 모듈을 어떻게 유연하게 동작하게끔 할 것인지에 대한 고민도 요구되었던 시간이었다.</p>
<p>그럼 이번 포스팅을 이렇게 마무리해보도록 하겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[OAuth 2.0 알아보기]]></title>
            <link>https://velog.io/@from_numpy/OAuth-2.0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@from_numpy/OAuth-2.0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sat, 20 May 2023 10:28:38 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>이전 포스팅, 그리고 그 이전의 포스팅에서 우린 <strong>&quot;인증(Authentication)&quot;</strong>과 해당 인증을 통한 <strong>&quot;인가(Authorization)&quot;</strong>를 알아보았고, 또한 구현해보았다.</p>
<p>사용한 기술로는 JWT를 활용한 <strong>&quot;Access Token&quot;</strong>, 그리고 해당 Access Token을 조금 더 유연하게 갱신하기 위한 <strong>&quot;Refresh Token&quot;</strong>을 경험해보았고 또한 &quot;OTP&quot;와 &quot;Google Authenticator&quot;를 활용한 <strong>&quot;2FA(Two-Factor Authentication)&quot;</strong>을 추가로 수행하였다.</p>
<p>이제 인증의 마지막 단계<span style="color:gray">(물론 마지막이 아닐 수도 있다)</span>로 &quot;OAuth&quot;를 통한 인증을 구현해보고자 한다.</p>
<p>이 글을 작성하기 전, 나에게 있어서 &quot;OAuth&quot;는 단지 <span style="color:green"><em><strong>&quot;그거... google이나 kakao로 로그인 쉽게 하는거 아냐?&quot;</strong></em></span> 딱 이정도였다...
이 상태로는 OAuth 인증을 구현해봤자 아무 의미가 없을것이므로, OAuth의 개념에 대해 알아보는 시간을 가지도록 하였고, 더하여 가장 많이 언급되는 <strong>&quot;OAuth 2.0&quot;</strong>은 무엇인가에 대해 중점적으로 알아보도록 하였다.</p>
<p>이번 포스팅은 코드 적 구현이 아닌 &quot;OAuth 2.0&quot;의 개념과 더불어 어떤 원리로 수행될 수 있는가에 대해 알아본다.</p>
<hr>
<p>💡 <strong>참고한 자료 :</strong></p>
<p><a href="https://youtu.be/DQFv0AxTEgM">Youtube _[NHN] 로그인에 사용하는 OAuth: 과거, 현재 그리고 미래</a></p>
<p><a href="https://youtu.be/hm2r6LtUbk8">Youtube _[생활코딩] - OAuth 2.0</a></p>
<p><a href="https://developer.spotify.com/">Sporify for Developers</a></p>
<hr>
</br>

<h2 id="💥-oauth-10">💥 OAuth 1.0</h2>
<h3 id="✔-oauth-10-인가-프로토콜-작동-계층-">✔ <strong>OAuth 1.0 인가 프로토콜 작동 계층 :)</strong></h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/86d3786a-4bdd-4433-a5f9-3f489730c462/image.png" alt=""></p>
<p><strong>※ OAuth 1.0 Resource Owner</strong></p>
<p>: OAuth를 사용하는 자연인인 “유저”에 해당</p>
<p><strong>※ OAuth 1.0 Client</strong></p>
<p>: Resource Owner에 접근해, Resource를 활용하려하는 애플리케이션</p>
<p><strong>※ OAuth Server</strong></p>
<p>: Resource Owner와 “인증”을 통해 상호작용함으로써, OAuth Client에게 “인가”를 하는 역할</p>
</br>

<h3 id="✔-oauth-10-flow-">✔ OAuth 1.0 flow :)</h3>
<p>가정: 만약 특정 이미지를 불러올 수 있는(다른 어떤 것도 상관없다) 애플리케이션이 있다고 하자.</p>
<p>만약 <code>Resource Owner</code>가 이미지를 다운받으려고 하는 등의 특정 움직임을 보인다. 이 때 <code>OAuth Client</code>는 <code>OAuth Server</code>에게 해당 동작을 위한 API를 찌를 것이다.</p>
<p>이때 <code>Resource Server</code>가 어느 웹 주소에서 “인증”을 하고, 이어서 <code>OAuth Client</code>에 대한 “인가” 작업을 할 수 있는지 응답을 보내주게 된다.</p>
<p>이 응답을 받은 <code>OAuth Client</code>는 <code>Resource Owner</code>에게 <code>302 Redirect</code> 또는 비슷한 웹 기반의 <code>Redirect</code> 방식을 사용해서 <code>Resource Owner</code>를 <code>OAuth Server</code>가 호스팅하는 웹사이트로 보내게 된다.</p>
<p>그렇다면, 이제 우린 해당 웹사이트에서(Google, Naver, KaKao…) 아이디, 비밀번호 입력등과 같은 인증작업및 여러 플로우를 수행하고, 마지막으로 이미지 애플리케이션에 대한 권한을 “인가”하게 된다.</p>
<p>이 인가 작업이 끝나면, <code>OAuth Server</code>는 또 다시 <code>302 Redirect</code>나 웹 기반 <code>Redirect</code>를 사용해서 받은 <strong>인증 값</strong>을 (<strong>토큰</strong>이 될 것이다) <code>OAuth Client</code>에게 넘겨주게 된다.</p>
<p>그리고, 마지막으로 <code>OAuth Client</code>는 이 <strong>“인증 값”</strong> 을 받아서 API 호출을 통해서 <code>OAuth Server</code>로 부터 이 “인가” 권한을 나타내는 “토큰”이라는 string값을 전달받게 된다.</p>
<p>이후로 <code>OAuth Client</code>는 해당 사용자의 리소스에 접근을 하거나 사용자를 대신해서 어떤 서비스에 접근을 할 때, 이 <strong>토큰</strong>을 사용해서 리소스에 접근을 하게 된다.</p>
</br>

<h3 id="✔-oauth-10의-문제점-">✔ OAuth 1.0의 문제점 :)</h3>
<p>뭔가 얼핏 보기에 괜찮은 플로우같다고 느껴진다. 하지만 OAuth 2.0이 나오게 되었다는 뜻은 해당 1.0엔 몇가지 문제점이 존재한다는 뜻이다. 어떤 문제점이 있을까?</p>
<ul>
<li>Scope 개념 없음</li>
<li>역할이 나누어지지 않음</li>
<li>토큰 유효기간의 문제</li>
<li>Client 구현의 복잡성</li>
<li>제한적인 사용 환경</li>
</ul>
<p>=⇒ 자세한 설명은 아래에서 OAuth 2.0에 대해 다루면서 같이 언급</p>
</br>

<h2 id="💥-oauth-20">💥 OAuth 2.0</h2>
<p>OAuth 1.0에서 존재하는 “단점”들을 어떻게 “보완”하여 구현되었는가를 중점으로 OAuth 2.0에 대해 알아보자.</p>
<h3 id="✔-scope-기증-추가">✔ <strong>Scope 기증 추가</strong></h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/3eb85fc5-78d0-4da9-9b06-5c498c8a448f/image.png" alt=""></p>
<p>OAuth 1.0엔 없던 <strong>Scope</strong> 기능이 추가 되었다. </p>
<p>OAuth 1.0에서는 “토큰”이라는 “인가”를 위한 “값”만 있으면 사용자의 <strong>“모든 리소스”</strong>에 접근할 수 있었다.</p>
<p>하지만, 만약 “사진 애플리케이션” 이라 보았을때, 유저가 “사진을 불러오는 권한”은 OAuth에게 “인가”하였지만 “동영상 혹은 문서”로의 접근은 허용하게 하지 않고 싶어할 것이다. (정확히 말하면 그러지 않고 싶어하는게 아니라, 보안상 문서 혹은 영상으로의 인가는 존재할 경우 위험하다)</p>
<p>그렇기 때문에 <strong>OAuth 2.0</strong>에서는 <strong>Scope</strong>라는 기능을 추가해서 해당 <strong>“토큰”</strong>에 대해서 <strong>“얼만큼의 접근 범위가 있는가”</strong>를 나타낼 수 있게 되었다.</p>
</br>

<h3 id="✔-client-복잡성-간소화">✔ Client 복잡성 간소화</h3>
<p>OAuth 2.0에선 클라이언트의 복잡성을 간소화 시키는데 성공하였다. </p>
<p><strong>OAuth 1.0</strong>에선 보안성을 가져가기 위해서 <strong>“암호학적 기반 보안책”</strong>들을 사용하였다.</p>
<p>하지만, 암호학적 보호를 받기 위해서는 “서명”을 하기 위한 “원문”을 만들어야 한다. 이러한 원문을 만들기 위해서는 <code>Http Method</code> 뿐만 아니라, <code>uri</code>, 그리고 여러가지 파라미터가 필요하다. 그리고 파라미터를 정의할때도 어떤 파라미터를 넣고, 넣지 말아야할 것인지, 더하여 어떤 순서로 정렬을 할 것인지에 대한 정확한 <strong>“분류”</strong>가 필요했다.</p>
<p>예를 들면 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/1d6fd037-5699-4941-b73a-e134f1720fc8/image.png" alt=""></p>
</br>

<p>왼쪽과 같은 복잡하지 않은 POST 요청을 하기 위해서, <code>OAuth 1.0 Client</code>는 매번 동일하게 오른쪽과 완벽하게 동일한 서명 원문을 만들어야지만 OAuth 1.0 스펙을 확실하게 구현할 수 있다.</p>
<p>이러한 문제를 <strong>OAuth 2.0</strong>에선 <strong>“Bearer Token”</strong>과 <strong>“TLS(Transport Layer Security)”</strong>를 사용하여 해결할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/5ff4ee16-c4ab-45bd-ae6b-2caeb7b9688a/image.png" alt=""></p>
<p><strong>“Bearer Token”</strong>이란 다른 암호학적 보호나 클라이언트에 바인딩되는 장치들을 모두 배제를 한 채, 해당 “토큰”을 소유하고 있는 것 만으로도 해당 토큰에 대한 사용 권한이 있음을 <strong>“인정”</strong>을 해주는 토큰을 말한다.</p>
<p>이렇게 “소유를 하고 있는 것” 만으로도 “권한”이 생기기 때문에 <strong>“OAuth 2.0”</strong>에서는 <strong>“TLS”</strong>, 가장 대표적인 예로 <strong>“HTTPS”</strong>를 강제를 하게 된다.</p>
</br>

<h3 id="✔-auth와-resource-서버-분리">✔ Auth와 Resource 서버 분리</h3>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/96f4afa0-9e0e-4aae-8842-f9253e7613e0/image.png" alt=""></p>
<p><strong>OAuth 1.0</strong>에서는 <code>OAuth Server</code>가 <strong>“인증”</strong>과 <strong>“리소스 관리”</strong>를 전부 맡아서 하였지만, <strong>OAuth 2.0</strong>에서는 <code>Auth server</code>와 <code>Resource Server</code>를 <strong>분리함으로써</strong> 조금 더 개선된 아키텍쳐를 만들 수 있게 되었다.</p>
<p>이로 인해, 인증 서버는 인증을 담당하고, 리소스 서버는 자신이 가진 리소스를 관리하고 제어한다. 이를 통해 보안성이 강화되고, 클라이언트의 복잡성도 간소화 시킬 수 있게 된다.</p>
<p>( 하지만 처음, <code>OAuth Server</code>에 대해 공부할때는 Resource Server와 Authz Server를 그냥 큰 틀의 Resource Server라 보고 익히는 것이 편하다… )</p>
</br>

<h3 id="✔-token-탈취-문제-개선">✔ Token 탈취 문제 개선</h3>
<p>OAuth 1.0에서는 토큰의 유효기간이 굉장이 길었다. 만약 해당 토큰이 탈취된다면 악성 공격자가 지속적으로 긴 시간을 어뷰징(공격)할 수 있기 때문에 문제가 되었다.</p>
<p><strong>OAuth 2.0</strong>에서는 이러한 문제를 해결하기위해 <strong>“Refresh Token”</strong>이란 개념을 도입하게 된다.</p>
<p>“Access Token”을 통해 리소스에 접근하게 되는데, 이 때 해당 토큰의 유효기간을 굉장히 짧게 가져간다. 하지만 <strong>Server-to-Server</strong> 통신을 통해 발급받은 “Refresh Token”을 사용한다면 새로운 Access Token을 받아서 api와 리소스에 접근할 수 있게 된다.</p>
<p>이렇게 된다면 Access Token이 탈취된다고 하더라도, 해당 토큰의 “짧은 유효시간”만큼만 어뷰징이 가능하기 때문에 보안성이 개선되었다고 볼 수 있다.</p>
</br>

<h3 id="✔-제한적인-사용-환경">✔ 제한적인 사용 환경</h3>
<p><strong>OAuth 1.0</strong>은 <code>301 Redirect</code>와 다양한 웹 브라우저의 Redirect 방식이다. OAuth 1.0은 웹 브라우저 환경에서 동작을 하도록 최적화 되어있고, 다른 환경에서 사용하기는 굉장히 어려운 프로토콜이란 점이다.</p>
<p>다시 한번 OAuth 1.0 동작에 대해 말하자면, 클라이언트 애플리케이션은 사용자를 인증화면으로 리디렉트시키기 위해 <code>301 Redirect</code>를 사용하며, 사용자의 인증이 완료되면 인증 코드를  클라이언트의 애플리케이션으로 리디렉트하기 위해 웹 브라우저의 리디렉트 방식을 활용한다.</p>
<p>이를 통해, OAuth 1.0은 웹 브라우저 환경에서의 인증과 권한 부여에 최적화된 프로토콜로 사용된다.</p>
<p>하지만,  이러한 “웹 브라우저에 최적화”된 프로토콜이 <strong>제한</strong>으로 다가오게 된다는 것이다. 현재 여러 서비스및 서버 구축에선 <strong>“Server-to-Server Interaction(서버 간 상호작용)”</strong>이 필수적으로 요하게 된다.</p>
<p>서버 간 상호작용은 간단히 설명하자면 “한 서버가 다른 서버의 API에 접근하여 사용자의 인증과 권한 부여(인가)를 처리하는 상황을 말한다. 예를 들면 <strong>“백엔드 서비스 간 통신”</strong>이 있을 수 있다. </p>
<p><strong>“소셜 미디어 통합”</strong>을 예로써 생각해보자. </p>
<p>애플리케이션에서 소셜 미디어 서비스 (예: Facebook, Twitter, Google 등)의 기능을 활용하고자 할 때, 백엔드 서비스는 해당 소셜 미디어 서비스의 API에 접근해야한다. 백엔드 서비스는 OAuth 2.0을 사용하여 사용자가 해당 소셜 미디어 서비스에 로그인하고 권한을 부여한 후, API를 호출하여 사용자의 정보를 가져오거나 특정 작업을 수행할 수 있다.</p>
<p>이 뿐만 아니라, 여러 개의 독립적인 백엔드 서비스가 상호작용하는 <strong>“마이크로서비스 아키텍처”</strong>, 여러 개의 백엔드 서비스를 통합하고 클라이언트에게 통일된 인터페이스를 제공하는 중간 계층인 <strong>“API Gateway”</strong>에서도 OAuth 2.0 토큰만이 사용될 수 있다.</p>
</br>

<h3 id="✔-grant-도입-제한적-사용-환경-개선을-위해">✔ Grant 도입 (제한적 사용 환경 개선을 위해)</h3>
<p>위에서 언급된 <strong>OAuth 1.0</strong>의 문제를 개선하기 위해 <strong>OAuth 2.0</strong>은 <strong>“Grant”</strong>라는 개념을 추가하게 된다.</p>
<p><strong>Grant</strong>라는 것은 OAuth 2.0에서 여러가지 사용 환경에 대한 플로우를 나타내는 인증 방식이라 생각하면 좋다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/3e49b556-7ee1-4d39-b2a2-fc23fc329845/image.png" alt=""></p>
<p>OAuth 2.0 은 위의 <strong>Grant_Type</strong>을 통해 해당 결과로 클라이언트에게 <strong><code>access_token</code></strong>을 발급하게 된다.</p>
<p>정의된 <strong>Grant_Type</strong>은 아래와 같다.</p>
<ol>
<li><strong><code>Authorzation Code(인가 코드)</code></strong>: 웹 서버에서 실행되는 앱, 브라우저 기반 및 모바일 앱에서 사용된다. 이 Grant_Type은 인증 코드를 얻고 이를 교환하여 access_token과 refresh_token을 얻는 방식으로 동작한다. 이를 통해 앱은 사용자의 권한을 보다 안전한 방식으로 API에 액세스 할 수 있다. ( OAuth 1.0과 동일 개체 )</li>
<li><strong><code>Password(패스워드)</code></strong>: 사용자 이름과 비밀번호로 로그인하는 경우에 사용된다. 주로 1차앱(First-party app)에서 사용되며, 애플리케이션에 직접적으로 신뢰가 주어지는 경우에만 사용해야 한다. 이 Grant_Type은 사용자의 자격 증명을 서버에 직접 제공하여 access_token을 얻는다.</li>
</ol>
<hr>
<p>   <span style="color:green"><strong>※ First-party app이란?</strong></span></p>
<p> <span style="color:gray">: 1차 앱은 사용자가 직접 상호작용하는 애플리케이션을 말한다. 사용자에게 직접 제공되며, 사용자는 자신의 자격 증명 (이름과 비밀번호)을 사용하여 로그인하고 앱에 접근한다. </span></p>
<p>   <span style="color:green"><strong>※ Third-party app이란?</strong></span></p>
<p>  <span style="color:gray"> : 서드 파티 앱은 서비스 제공 업체나 플랫폼 외부에서 개발된 애플리케이션을 일컫는다. 이러한 앱은 원래 서비스의 소유자나 운영자가 아닌 다른 개인이나 조직에 의해 개발되고 배포된다. OAuth와의 관련성에대해 말하자면, OAuth는 알다시피 해당 서드 파티 앱이 서비스 제공 업체의 API에 안전하게 액세스할 수 있도록 권한을 부여하기 위한 프로토콜이다. OAuth는 사용자의 동의를 통해 서드 파티 앱이 사용자의 계정에 접근하거나 사용자를 대신하여 서비스에 액세스할 수 있는 권한을 부여한다.
    “Password” Grant_Type은 주로 “Firtst-party app”에서 사용되며, 사용자의 자격 증명을 서버에 직접 제공하여 액세스 토큰을 얻는 방식이다. 이는 “Third-party app”에선 권장되지 않는 방식이다. 보안의 측면에서 서드 파티 앱은 OAuth의 다른 Grant_Type을 사용하여 사용자의 인가를 처리하고, 서비스 제공 업체의 API에 접근할 수 있도록 한다. </span></p>
<hr>
<ol start="3">
<li><p><strong><code>Client Credentials(클라이언트 자격 증명)</code></strong>: 사용자가 없이 애플리케이션 자체적으로 API에 액세스해야 할 때 사용된다. 애플리케이션 자체의 인증 정보를 활용하여 access_token을 얻는다.</p>
</li>
<li><p><strong><code>Implicit(암시적 흐름)</code></strong>: 이 Grant_Type은 이전에 시크릿(secret)없이 클라이언트가 사용되었을 때 권장되었으나, 이제는 인가 코드 그랜트를 <strong>PKCE(Proof Key for Code Exchange)</strong>와 함께 사용하는 것으로 대체되었다. 암시적 Grant_Type은 보안 측면에서 취약할 수 있기 때문에 권장되지 않는다.</p>
</li>
</ol>
<p>이러한 Grant가 어떻게 효과적인 동작을 이루어내는 것일까?</p>
<p>사실 상 <code>Resource Owner</code>와 <code>OAuth Client</code>가 동일한 하나의 개체일 때, 복잡한 플로우를 가져가기 보다, 직접적으로 API 호출을 통해 토큰을 발급받을 수 있는 것이 <code>OAuth 2.0</code>의 <strong>Grant</strong>이다.</p>
</br>

<h2 id="💥-resource-owner-client-application-resource-server-승인-과정-및-동작-진행">💥 Resource Owner, Client Application, Resource Server 승인 과정 및 동작 진행</h2>
<p>위에서 <code>OAuth</code> 및 <code>OAuth 2.0</code>에서 등장하는 여러 개념및 특징을 알아보았다. 하지만 여전히 화끈?하게 와닿지가 않는다. </p>
<p>바로 이전에 <code>OAuth 2.0</code>의 <code>Grant</code>에 대해 언급하면서 첫 <strong>Grant_Type</strong>으로 <code>Authorzation Code(인가 코드)</code>에 대해 설명하였다. <strong>refresh_token</strong>을 제외하곤 해당 Grant는 <code>OAuth 1.0</code>과 <code>OAuth 2.0</code>에 동일하게 적용된다.  해당 개념이 사실상 OAuth에 대한 가장 간단한 개념이자 전부를 설명할 수 있을 거 같다. 아래에서 간단히 정리하면</p>
<p><strong>“나의 서비스(My Service _ client application)”</strong>가 <strong>“그들의 서비스(Their Service _ resource server)”</strong>에 접근하기 위해 <strong>“유저(User _ resource owner)”</strong>의 계정 <code>id, pw</code>를 가지고 있는 것이 아닌, 그들의 서비스가 <strong>“access_token”</strong>을 발급함으로써 인가를 가능하게 한다.</p>
<p><strong>“access_token”</strong>은 <code>id, pw</code>가 아닐 뿐더러 그들의 서비스에 대한 모든 요청을 허락하는 것이 아니므로, 부분적 허용(인가 허용)을 가능케 한다.</p>
<p>자, 위의 내용이 기본 <strong><code>OAuth protocol</code></strong>의 베이스이다. 해당 원리를 바탕으로 아래에서 조금 더 구체화 해보자.</p>
</br>

<h3 id="✔-resource-owner의-승인">✔ Resource Owner의 승인</h3>
<p>Resource Server에서 OAuth를 사용할 것을 “등록”하게 되면 Resource Server는 <strong>“Client_id”</strong>와 <strong>“Client Secret”</strong>, 그리고 <strong>“redirect URL”</strong>을 가지게 된다.</p>
<p>동시에, Client Application 또한 <strong>“Client_id”</strong>와 <strong>“Client Secret”</strong>을 얻게 된다.</p>
<p>(Client는 Resource Server가 가지게 되는 redirect url을 준비해놓고 대기시켜놓아야한다)</p>
<p>만약, Resource Server가 A, B, C, D의 기능을 가지고 있다고 하자. 그 때 Client가 A, B의 기능만을 사용하고 싶다고 한다면, 모든 리소스에 대한 접근을 할 수 있게 하는 것 보단 A, B의 기능에 대해서만 인증을 받게 되는 것이 Resource Server와 Client 모두에게 좋을 것이다.</p>
<p>=⇒ 이것또한 앞서 설명하였던 <code>OAuth 2.0</code>의 <strong>“Scope”</strong>에 관한 것이다.</p>
<p>만약, Resource Owner가 Client Application을 통해 Resource Server에서 제공하는 기능(A, B)을 사용하고 싶을 때, Client는 Resource Server로의 인증을 위한 (로그인 인증 폼 등) UI를 띄울 것이다. 이것은 버튼으로 되어있는 요청일 것이다. 해당 버튼의 url은 아래와 같은 형태를 띄게 된다.</p>
<pre><code class="language-tsx">https://resource_server/
    ?client_id=1
    &amp;scope=A,B
    redirect URL:
        https://client/...</code></pre>
<p>Resource Owner가 Resource Server로의 접속을 위의 주소를 통해 하게 되면, Resource Server는 만약 Resource Owner가 로그인 되어있지 않을 시(인증되어 있지 않을 시) 로그인을 요구하는 아래와 같은 페이지를 보내게 된다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/14e8afa1-c53b-458e-8c09-ae0246533ead/image.png" alt=""></p>
<p>만약, Resource Owner가 로그인을 수행 해, 인증에 성공했을 경우 그때서야 Resource Server는 위의 주소에 보여지는 <code>client_id</code>값과 같은 <code>client_id</code> 값을 Resource Server가 가지고 있는지 확인한다.</p>
<p>또한 다음으로 요청 주소의 <code>redirect url</code>값과 Resource Server에서 확인한 <code>client_id</code>가 가지고 있는 <code>redirect url</code>이 같은지 아닌지를 확인하게 된다. 만약 다를 경우, 해당 상태에서 작업을 끝내게 된다. </p>
<p>만약 <code>redirect url</code>이 같을 경우, Resource Server는 Resource Owner에게 <strong>“Scope”</strong>에 해당하는 권한 (A, B)을 Client Application에게 부여 할 것인지를 확인하는 메시지를 전송하게 된다.</p>
<p>예를 들면 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/d571b3cf-5d76-4a16-93d8-22061219a2bb/image.png" alt=""></p>
<p>위에서도 보여지듯이, <strong>“어떠한 기능”</strong>을 <strong>“어떠한 Client”</strong>가 요청하고 있다는 것을 알려준 뒤 <strong>“허용할 것인가? (IsAllow)”</strong>에 대한 선택을 요구하게 된다.</p>
<p>만약, 허용하게 된다면 해당 정보는 Resource Server로 전달될 것이다. </p>
<pre><code class="language-tsx">[Resource Server]

Client id: 1
Client Secret: 2
redirect url:
    https://client/callback
    user id: 1  // user 1이
    scope: a,b  // client가 scope a,b의 기능에 대한 권한을 가짐을 허용 </code></pre>
<p>Resource Server는 이제 위와 같은 정보를 저장하고 있게 될 것이다. (db일 수도 있고,, 파일일 수도 있고..)</p>
<p><strong>자, 이제 우린 Resource Owner로 부터 Resource Server에 접속하는 것에대한 동의를 구하는 과정을 거쳤다.</strong> </p>
<p>그러면, 이제 <strong>Resource Server</strong>가 실제로 <strong>“인증(Authentication)”</strong>을 어떻게 하느냐에 대해 알아보자.</p>
</br>

<h3 id="✔-resource-server의-승인">✔ Resource Server의 승인</h3>
<p>(3자간의 소통인만큼 단순하지 않다는 것을 염두해두자)</p>
<p>위의 과정에서 <strong>“Resource Owner”</strong>가 승인을 하였으므로 이젠 <strong>“Resource Server”</strong>가 승인을 해 주어야 한다. 하지만 여기서 Resource Server가 승인을 하기 위해서 바로 <strong>“access_token”</strong>을 발급하지 않고 그 전에 일련의 절차를 더 거쳐야 한다.</p>
<p>해당 과정 (일련의 절차)에서 등장하는 개념이 바로 <strong>“Authorization Code(인가 코드)”</strong>이다. 이것은 <strong>“임시 비밀번호”</strong>라 불리기도 한다. Resource Server는 해당 Authorization Code를 Resource Owner에게 전달하게 된다. ( ex. <code>Location: [https://client/...?code=3](https://client/...?code=3)</code> ⇒ 이때 <code>Location</code>은 Redirection을 의미하고, 웹 브라우저에게 <code>Location</code> 을 통해 제시된 주소로 이동하란 명령이다, Resource Server to Resource Owner) 이때 <code>code=3</code>이라 하는 것이 Resource Server에서 생성된 임시 비밀번호 <code>authorization code=3</code>인 것이다.</p>
<p>이제 <code>Location</code>을 통한 헤더값(주소)에 의해 Resource Owner(사용자)가 인식하지도 못하게, 은밀하게  <code>[https://client/...?code=3](https://client/...?code=3)</code>해당 주소로 이동을 하게 된다. 즉, <strong>“Client Application”</strong>으로 이동하게 되는 것이다.  그렇게 된다면  Client는 자연스럽게 Resource Server가 생성하였던 <code>authorization code=3</code>이란 값을 갖게 된다.</p>
<p>자,  Resource Server가 Client에게 <code>authorization code</code>를 넘겨줌으로써 <strong>“access_token”</strong>을 발급하기 전 상태까지 도달했다. 이제 Client는 Resource Owner를 거치지 않고, Resource Server에게 <strong>“직접”</strong> 접속할 수 있게 된다. 아래와 같은 주소를 통해서 말이다.</p>
<pre><code class="language-tsx">https://resource.server/token?
    grant_type=authorization_code&amp;  // 위의 개념에서 설명하였지만 authorization_code를 제외하고도 다른 방법들이 있다.
    code=3&amp;
    redirect_url=https://client/...&amp;
    client_id=1&amp;
    client_secret=2  // 중요</code></pre>
<p>Resource Server는 Client로 부터 위의 주소 접속을 받게 되면 유효한 접속인지의 일치성을 판별하게 된다. Resource Server는 기존에 아래와 같은 값을 가지고 있었다. </p>
<pre><code class="language-tsx">Client id: 1
Client Secret: 2
redirect URL:
    https://client/...
user id: 1
    scope: a, b
    authorization code: 3</code></pre>
<p>위의 값을 가진 Resource Server는 Client로 부터 받아온 <code>authorization_code</code>값을 통해 해당 코드에 맞는 유저 데이터를 찾게된다. 또한 해당 유저의 <code>secret</code>값과 <code>redirect</code>주소값을 비교하여 일치할 경우 다음 단계로 넘어간다.</p>
</br>

<h3 id="✔-access-token-발급">✔ Access Token 발급</h3>
<p>이전 단계에서 <strong>“Resource Server”</strong>가 <strong>“Client”</strong>를 <strong>“승인”</strong>하는 단계를 살펴보았다. 해당 단계는 <code>authorization code</code>를 통해 (인증) 수행하였고 그 후엔 Client와 Resource Server에서 사용된 해당 <code>authorization code</code>를 지워줘야한다. 위에서도 언급했다시피 해당 코드는 “임시 비밀번호” 즉, 일회성 코드이므로 오로지 액세스 토큰을 얻기 위한 값이다. 즉, 보안과 인증 과정의 안전성 보장을 위해서 한번 사용되었으면 삭제 혹은 만료 처리를 해주어야 한다.</p>
<p>이제, 드디어 Resource Server는 <strong>“access_token”</strong>을 발급하고 Client에게 <strong>“응답”</strong>하게 된다. Client는 이렇게 해당 access_token값을 내부적으로 저장하게 된다. </p>
<p>만약, Client가 <code>access_token=123</code>이란 access_token을 발급받게 된다면, Resource Server는 해당 <code>access_token=123</code>에 해당하는 user id와 지정된 <strong>“scope”</strong>를 파악하고 해당 정보에 대한 인가를 Client에게 가능케 한다.</p>
</br>

<h3 id="✔-api-호출-with-spotify">✔ API 호출 (with Spotify)</h3>
<p>우린 위에서 <strong>“access_token”</strong>을 발급받기까지의 과정을 진행하였다. 이제 해당 access_token을 활용하여 <strong>“Resource Server”</strong>를 핸들링해야한다. 그것이 곧 <strong>“API”</strong> 과정이다.</p>
<p>간단히 API 호출과정을 진행해보자. 참고로, 현재 코드를 통해 “access_token”을 얻는 과정을 직접 수행하진 않았으므로, 특정 서비스의 튜토리얼 과정을 토대로 알아보고자 한다.</p>
<p>우리가 사용할 서비스(Resource Server)는  <strong>“Spotify”</strong>이다.</p>
<p>먼저 access_token을 얻어보자. spotify developers 튜토리얼 과정에선 “Client Credentials”를 통해 access_token을 요청하는 방법을 설명한다. 세밀한 방법들을 당장 진행해볼 순 없으니 간단히 알아보자. 요청 명령은 아래와 같다.</p>
<pre><code class="language-tsx">curl -X POST &quot;https://accounts.spotify.com/api/token&quot; \
     -H &quot;Content-Type: application/x-www-form-urlencoded&quot; \
     -d &quot;grant_type=client_credentials&amp;client_id=your-client-id&amp;client_secret=your-client-secret&quot;</code></pre>
<p>해당 요청을 수행하면 아래와 같은 응답을 받게 된다. “access_token”이 포함된 것을 확인할 수 있다.</p>
<p>(spotify의 access_token은 1hour 동안 유효하다)</p>
<pre><code class="language-tsx">{
  &quot;access_token&quot;: &quot;BQDBKJ5eo5jxbtpWjVOj7ryS84khybFpP_lTqzV7uV-T_m0cTfwvdn5BnBSKPxKgEb11&quot;,
  &quot;token_type&quot;: &quot;Bearer&quot;,
  &quot;expires_in&quot;: 3600
}</code></pre>
<p>만약 해당 액세스 토큰이 (물론 위의 액세스 토큰 값은 임의로 spotify tutorials에서 보여주는 유효하지 않은 값이다) 정말 유효한 값이라면 해당 요청을 <strong>Header에 Bearer 타입으로 담아 요청을 보내게 될 경우</strong> 아래와 같은 JSON 응답을 받을 것이다.</p>
<p>(Header에 Authorization: Bearer 형식으로 요청을 보내는 것을 권장한다. 물론, query parameter 형식으로도 보낼 수는 있다 ⇒ 하지만 해당 방식은 서버에서 제공할 수도 있고, 제공하지 않을 수도 있다)</p>
<pre><code class="language-tsx">{
  &quot;external_urls&quot;: {
    &quot;spotify&quot;: &quot;https://open.spotify.com/artist/4Z8W4fKeB5YxbusRsdQVPb&quot;
  },
  &quot;followers&quot;: {
    &quot;href&quot;: null,
    &quot;total&quot;: 7625607
  },
  &quot;genres&quot;: [
    &quot;alternative rock&quot;,
    &quot;art rock&quot;,
    &quot;melancholia&quot;,
    &quot;oxford indie&quot;,
    &quot;permanent wave&quot;,
    &quot;rock&quot;
  ],
  &quot;href&quot;: &quot;https://api.spotify.com/v1/artists/4Z8W4fKeB5YxbusRsdQVPb&quot;,
  &quot;id&quot;: &quot;4Z8W4fKeB5YxbusRsdQVPb&quot;,
  &quot;images&quot;: [
    {
      &quot;height&quot;: 640,
      &quot;url&quot;: &quot;https://i.scdn.co/image/ab6761610000e5eba03696716c9ee605006047fd&quot;,
      &quot;width&quot;: 640
    },
    {
      &quot;height&quot;: 320,
      &quot;url&quot;: &quot;https://i.scdn.co/image/ab67616100005174a03696716c9ee605006047fd&quot;,
      &quot;width&quot;: 320
    },
    {
      &quot;height&quot;: 160,
      &quot;url&quot;: &quot;https://i.scdn.co/image/ab6761610000f178a03696716c9ee605006047fd&quot;,
      &quot;width&quot;: 160
    }
  ],
  &quot;name&quot;: &quot;Radiohead&quot;,
  &quot;popularity&quot;: 79,
  &quot;type&quot;: &quot;artist&quot;,
  &quot;uri&quot;: &quot;spotify:artist:4Z8W4fKeB5YxbusRsdQVPb&quot;
}</code></pre>
<p>만약, access_token 값이 없이 요청을 날리게 되면 아래와 같은 응답을 받을 것이고</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/ac675c4b-2af9-4b20-b829-691877eb2762/image.png" alt=""></p>
<p>유효하지 않은 토큰을 날려 보낼시엔 아래와 같은 응답을 받을 것이다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/9000deb2-656b-4cec-92a2-20c1c3d50482/image.png" alt=""></p>
</br>

<h3 id="✔-refresh-token-with-spotify">✔ Refresh Token (with Spotify)</h3>
<p>일전에 OAuth가 아닌 일반 로컬 로그인 인증및 인가 과정에서 “refresh_token”을 직접 구현해 보았었다. 당시에 refresh_token을 통한 인증및 처리 과정들을 가시적으로 알기 위해 여러 설계 이미지를 찾아보았는데 아래의 이미지가 가장 많이 보였었다. </p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/9ca244d4-09b6-4f7b-8ba6-3ccbbb0d72c0/image.png" alt=""></p>
<p>OAuth가 아닌 경우는 Resource Server 하나만으로 생각해도 무방하지만 OAuth에선 위와 같이 <strong>Resource Server</strong>와 <strong>Authorization Server</strong> 두 가지로 구분지어 설명한다. 정확히 말하면 <strong>“OAuth 2.0”</strong>이다.</p>
<p>항상 말하지만, 인증 및 인가에 사용되는 토큰은 오로지 <strong>“Access Token”</strong>이다. 위의 이미지 상 F과정(Invalid Token Error)은 대부분 access_token이 만료될 시 발생한다. 즉, 그 경우에 Client가 가지고 있는 <strong>“Refresh Token”</strong>을 <strong>“Authorization Server”</strong>에 전송함으로써 access_token을 재발급 받는 형식이다. (경우에 따라 Refresh Token도 새로 발급되는 케이스도 있다)</p>
<p>대부분 <strong>“OAuth 2.0”</strong>에서 <strong>“Refresh Token”</strong>은 표준 스펙으로 제시된다. </p>
<hr>
<p><a href="https://developer.spotify.com/documentation/ios/concepts/token-swap-and-refresh#tokenrefreshurl">Token Swap and Refresh | Spotify for Developers</a></p>
<hr>
<p><strong>Spotify Developers</strong>에서도 Refresh Token에 대한 개념을 설명하고 과정을 제시한다.</p>
<p>Request Headers와 Request Body에 알맞은 형식으로써 요청을 날리면</p>
<pre><code class="language-tsx">curl -X POST &quot;https://example.com/v1/swap” -H &quot;Content-Type: application/x-www-form-urlencoded&quot; --data “code=AQDy8...xMhKNA”</code></pre>
<p>아래와 같은 형식의 토큰 정보를 얻게 된다. access_token과 refresh_token이 전부 포함된 응답이다.</p>
<pre><code class="language-tsx">{
 &quot;access_token&quot; : &quot;NgAagA...Um_SHo&quot;,
 &quot;expires_in&quot; : &quot;3600&quot;,
 &quot;refresh_token&quot; : &quot;NgCXRK...MzYjw&quot;
}</code></pre>
<p>이제 우린 <strong>refresh_token</strong>을 얻게 되었으므로, 해당 토큰 값을 또 다시 POST 요청으로 날려준다.</p>
<pre><code class="language-tsx">curl -X POST &quot;https://example.com/v1/refresh&quot; -H &quot;Content-Type: application/x-www-form-urlencoded&quot; --data &quot;refresh_token=NgCXRK...MzYjw&quot;</code></pre>
<p>만약 해당 refresh_token이 올바른(유효한) 값이라면 아래와 같이 access_token을 재발급 받을 수 있게 된다.</p>
<pre><code class="language-tsx">{
 &quot;access_token&quot; : &quot;NgAagA...Um_SHo&quot;,
 &quot;expires_in&quot; : &quot;3600&quot;
}</code></pre>
</br>

<h2 id="생각정리-및-다음-포스팅-예고">생각정리 및 다음 포스팅 예고</h2>
<p>간단하다면 간단히, 자세하다면 자세히 <span style="color:red"><strong>&quot;OAuth 2.0&quot;</strong></span>에 대해 알아보았다. 흔히 알고 있는 OAuth를 통한 로그인 구현(<strong>Federal Identity</strong>)뿐만 아니라 OAuth는(OAuth 2.0) 정말 넓은 범위에서 <u>굉장히 유용한 프로토콜</u>이 아닐까 하는 생각이 드는 시간이었다.</p>
<p>다음 포스팅에선 이번 포스팅에서 다룬 내용및 구현 매커니즘을 토대로 <code>NestJS</code>를 활용해 OAuth2.0 로그인 인증을 수행해보도록 하겠다. 
<span style="color:green">(내용이 마무리되는 대로 업로드 예정입니다)</span></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] Implementing 2FA using OTP (feat. QR & Google-Authenticator )]]></title>
            <link>https://velog.io/@from_numpy/NestJS-Implementing-2FA-using-OTP-feat.-QR-Google-Authenticator</link>
            <guid>https://velog.io/@from_numpy/NestJS-Implementing-2FA-using-OTP-feat.-QR-Google-Authenticator</guid>
            <pubDate>Tue, 16 May 2023 14:36:35 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>이번 포스팅은 이전 포스팅에 이어서 nestjs를 통한 인증 과정에 포함된다. 우린 단순 로그인 로그아웃을 넘어 조금 더 심화적 구현을 알아본다.</p>
<hr>
<p><strong>※ 시리즈 참고</strong></p>
<p><a href="https://velog.io/@from_numpy/series/NestJS-Authentication-advanced-part">NestJS _ Authentication (advanced part) ✔</a></p>
<p><span style="color:green">위 시리즈에서 진행됩니다. 이전 포스팅은 &quot;Refresh Token&quot; 구현하기였습니다. 해당 코드에 이어서 진행되니, 필요한 부분은 미리 참조 바랍니다.</span></p>
<hr>
<p>지난 포스팅 &quot;Refresh-Token 구현하기&quot;에 이어서 이번 포스팅에선 <strong>&quot;2FA(Two-Factor Authentication): 2단계 인증&quot;</strong> 을 추가로 구현해보고자 한다.</p>
<p>보안엔 끝이없고, 2FA를 도입한다하더라도 항상 취약점에서 보안은 뜷리기 마련이다. 하지만, 필요에 따라 분명히 필요한 인증 고도화 작업임은 분명하고, 이번 포스팅에선 <strong>&quot;OTP(One Time Password)&quot;</strong>를 이용하여 nestjs에서 어떻게 <strong>&quot;2FA&quot;</strong>를 구현할 수 있는가에 관해 알아볼 예정이다.</p>
</br>

<h2 id="💥-2fatwo-factor-authentication와-otpone-time-password">💥 <code>2FA(Two-Factor Authentication)</code>와 <code>OTP(One-Time Password</code></h2>
<p>코드를 통한 구현에 바로 들어가기에 앞서 2FA란 무엇이고, 그 중에서도 <code>OTP</code>란 방법에 대해서 간략히 알아보는 시간을 가져보자.</p>
<h3 id="2fa에-대한-정의-및-설명">&gt; <code>2FA</code>에 대한 정의 및 설명</h3>
<p>인증은 컴퓨터 시스템이나 온라인 계정으로의 접근 권한(Access)을 구축할 때 사용자의 신원을 검증하는 프로세스이다. </p>
<p>인증(인증자 및 인증토큰)에는 &quot;3가지&quot; 주요 범주가 존재한다. <span style="color:gray">(추가로 &quot;사용자가 있는 곳&quot; 또한 범주로 포함시킬수도 있다)</span></p>
<blockquote>
<ul>
<li>사용자가 갖고 있는 것: 물리적 액세스 카드, 스마트폰이나 기타 장치 또는 디지털 인증서</li>
</ul>
</blockquote>
<ul>
<li>사용자가 알고 있는 것: 핀 코드 또는 비밀번호</li>
<li>사용자 자신에 관한 것: 지문이나 망막 스캔 같은 생체 인식</li>
</ul>
<p>일반적인, 혹은 과거의 기본 인증은 알고 있듯이 아이디와 비밀번호와 같은  &quot;사용자가 알고 있는 것&quot;에만 기대어 진행되었다. 하지만 이러한 조합으로만의 인증을 수행할 경우, 굉장히 보안에 취약하게 된다. 우리는 이로 인해 추가적 인증 조합을 더하여 보안을 강화할 수 있다. </p>
<p>이러한 <u>해킹 불가능한 인증수단에 대한 논의</u>로 부터 <strong>&quot;2FA&quot;</strong>의 개념이 도입되었고, 현재 우리가 흔히 볼 수 있는 <strong><code>OTP</code></strong>, <strong><code>SMS</code></strong>, <strong><code>E-mail</code></strong>등과 같은 방법으로 보여지고 있다.</p>
<p>물론, &quot;2FA&quot;를 통한 추가 인증 수단을 사용한다고 해서 완벽한 보안은 아닐 것이다. 기술적, 물리적으로 다양한 악성 공격자는 존재할 것이다. 이러한 추가적 내용은 이번 포스팅에선 다루지 않겠다.</p>
</br>

<h3 id="authenticator-application--otp">&gt; <code>Authenticator Application</code> &amp;&amp; <code>OTP</code></h3>
<p>이번 포스팅의 <code>2FA</code> 구현에서, 우린 <strong><code>OTP</code></strong> 를 통한 사용법을 알아볼 것이다. 그 전에 간단히 짚고 넘어가보자.</p>
<p>흔히, 우린 2단계 추가 인증으로 <u>SMS(문자 메시지), 음성 메시지, 푸시 알림</u> 등의 경우를 많이 접하게 된다. 혹은, <u>E-mail</u>을 통한 추가 인증또한 빈번히 사용하게 된다.</p>
<p>하지만, 이메일의 경우 이메일 자체의 해킹위협이 존재하고 휴대전화 인증 또한 이메일보단 좋지만 여전히 취약점이 드러난다. </p>
<p>아래에선 <code>SMS 문자메시지</code>의 특정 취약점에 대해 설명한다. 해당 내용은 참고만 하도록 하겠다. </p>
<hr>
<p>&quot;
<span style="color:gray"><em>일반적으로 인증자 앱은 SMS 문자메시지로 코드를 전송받는 방식보다 조금 더 안전한 것으로 여겨지고 있습니다. 그 이유는 엄밀히 말하면 SMS 문자메시지는 사용자가 가지고 있는 것이라기보다는 사용자에게 전송된 것이기 때문이죠. 그렇기 때문에 해커가 통신사를 속여 휴대폰 번호를 다른 장치로 포트하게 할 가능성도 작긴 하지만 여전히 남아 있으며, 이러한 사기 수법을 &#39;심 스와프(SIM swap)&#39;라고 부릅니다. 해커가 이미 비밀번호를 탈취한 상태라고 가정하면, 심 스와프를 통해 사용자 계정으로 액세스할 수 있게 됩니다. 하지만 인증자 앱에서 생성된 인증 코드는 보통 20~30초 안에 빠르게 만료되고 앱 밖으로 절대 유출되지 않습니다.</em></span>
&quot;</p>
<p>(Dropbox 자료 참고 --&gt; <a href="https://experience.dropbox.com/ko-kr/resources/what-is-2fa">여기 클릭</a>)</p>
<hr>
<p>모든 사례, 모든 서비스에 적용되는 것은 아니겠지만 위와 같은 이유로 <strong>&quot;인증자 앱(Authenticator App)&quot;</strong>을 통한 <strong>&quot;OTP&quot;</strong>를 사용하게 된다.</p>
<p>&quot;인증자 앱&quot;은 디지털 인증 코드를 생성하는 휴대폰 앱을 말한다. 웹사이트나 애플리케이션에 로그인 할 때 앱에 생성된 코드를 사용해 신원을 확인하는 방식이다. 우리가 진행할 코드에서 사용되는 <strong>&quot;Google Authenticator&quot;</strong>가 이에 해당하는 대표적 인증자 앱이다.</p>
<p>위에서도 설명되었지만 인증자 앱에서 생성된 OTP는 일정한 시간 동안만 유효하다. 이로 인해, 악성 공격자가 OTP를 탈취한 경우에도 제한된 시간 내에 접속을 해야 인증에 성공할 수 있다. 물론, 예를들어 QR 코드라고 한다면 악성 공격자가 QR 코드에 직접 접근하여 인증 코드를 얻어내고 스스로 발급까지 해버린다면 소용이 없겠지만, 이는 사실 사용자의 기기 또는 계정에 대한 물리적 접근이 필요한 매우 어려운 케이스이다.</p>
<p>우린, 이렇게 <strong>OTP</strong>를 이용해 조금 더 보안을 강화한 <strong>&quot;2FA&quot;</strong>를 구현할 수 있다.</p>
</br>

<p><span style="color:dimgray">이제, 본격적으로 <strong><code>nestjs</code></strong>와 <strong><code>OTP</code></strong>, <strong><code>Google-Authenticator</code></strong>를 활용해 <strong>&quot;Two-Factor Authentication&quot;</strong>을 구현해보도록 하자.</span></p>
</br>

<h2 id="💥-otp를-통한-2fa-설계및-구현-nestjs">💥 <code>OTP</code>를 통한 <code>2FA</code> 설계및 구현 [NestJS]</h2>
<p>글의 서두에서도 언급하였다시피, 지금부터 진행할 코드는 <u>access-token과 refresh-token을 토대로 한</u>, 1차 인증에 대한 기능이 구현된 코드를 바탕으로 수행한다. (이전 내용에 대한 링크는 상단에 있습니다)</p>
<hr>
<p><span style="color:gray">코드에 들어가기에 앞서, nestjs를 통한 OTP 구현 코드를 찾던 중 도움이 된 글이 있어 공유한다. 이번 포스팅또한 해당 글의 코드를 참조하여 진행하였다.</span> </p>
<p><a href="https://wanago.io/2021/03/08/api-nestjs-two-factor-authentication/">api-nestjs-two-factor-authentication</a></p>
<hr>
</br>

<h3 id="설계-및-구축-진행-순서">&gt; 설계 및 구축 진행 순서</h3>
<p>2차 인증 요소를 구축하는데 있어서 다양한 관점에 따른, 혹은 서비스및 고객의 요구 사항에 따른 다양한 설계가 있을 것이다. 이번 포스팅에선 흔히, 제시되는 일반적 2FA의 요구 설계를 바탕으로 진행한다.</p>
<blockquote>
<p><strong>[진행 과정]</strong></p>
</blockquote>
<ul>
<li>(base) access-token과 refresh-token을 통한 1차인증 구축 (이전 포스팅 참조)</li>
<li>2FA secret-key 및 otp 계정 추가를 위한 url을 생성하고, 현재 stream에 <code>QR</code>코드 이미지를 생성</li>
<li>유저에게 2FA 인증 수단을 사용할지에 대한 api 생성. 해당 2FA 사용여부를 데이터베이스에 저장.</li>
<li>인증 앱에서 발급받은 인증 코드를 통한 2FA 검증 후, 유효할 시 액세스 토큰 생성및 쿠키 생성</li>
<li>2FA 가드 생성을 통한 접근 권한 테스트</li>
</ul>
<p>위의 순서를 토대로 진행하며, 필요한 로직 및 api를 추가로 구체화하는 과정을 수행한다.</p>
</br>

<h3 id="qr-이미지-생성-otplib에-의존한다">&gt; <code>QR</code> 이미지 생성 (<code>otplib</code>에 의존한다)</h3>
<p><span style="color:dimgray"><strong>✔ 2FA - Controller</strong></span></p>
<pre><code class="language-tsx">// twoFactorAuthentication.controller.ts

import { TwoFactorAuthenticationService } from &quot;./twoFactorAuthentication.service&quot;;
import { Body, ClassSerializerInterceptor, Controller, Post, Req, Res, UseGuards, UseInterceptors } from &quot;@nestjs/common&quot;;
import { Response } from &quot;express&quot;;
import { JwtAccessAuthGuard } from &quot;../guard/jwt-access.guard&quot;;
import RequestWithUser from &quot;../interfaces/requestWithUser.interface&quot;;


@Controller(&#39;2fa&#39;)
@UseInterceptors(ClassSerializerInterceptor)
export class TwoFactorAuthenticationController {
  constructor(
    private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
  ) {}

  @Post(&#39;generate&#39;)
  @UseGuards(JwtAccessAuthGuard)
  async register(@Res() res: Response, @Req() request: RequestWithUser) {
    const { otpAuthUrl } = await this.twoFactorAuthenticationService.generateTwoFactorAuthenticationSecret(request.user);

    return await this.twoFactorAuthenticationService.pipeQrCodeStream(res, otpAuthUrl);
  }

}</code></pre>
<p>위의 <code>register</code> 핸들러 함수는 QR 이미지를 클라이언트에게 전송하기 위한 함수이다. 여기서 중요한 핵심은 <strong><code>JwtAccessAuthGuard</code></strong>를 함수에 주입해준다는 것이다. 즉, 해당 가드가 <code>register</code> 요청 runtime에 접근해 해당 요청을 가드 내부에서 설정해준 조건에 따라 평가하게 된다.</p>
<p>이전 포스팅의 내용을 통해 확인할 수 있지만 해당 가드는 아래와 같다.</p>
<pre><code class="language-tsx">// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from &quot;@nestjs/common&quot;;
import { JwtService } from &quot;@nestjs/jwt&quot;;

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise&lt;any&gt; {
    try {
      const request = context.switchToHttp().getRequest();
      const access_token = request.cookies[&#39;access_token&#39;];
      const user = await this.jwtService.verify(access_token);
      request.user = user;
      return user;
    } catch(err) {
      return false;
    }
  }
}</code></pre>
<p>이는, JWT <code>access-token</code>을 통한 인증을 바탕으로 유저에게 특정 요청에 대한 권한이 있는지를 평가하게 된다. </p>
<p>즉, 우린 <strong><code>TwoAuthenticationController</code></strong>에서 생성한 QR을 생성 및 응답하기 위한 <code>register</code> 함수에 해당 가드를 주입함으로써, 해당 요청에 대한 유저의 &quot;권한(Authorization)&quot;이 있는지 확인받을 수 있다.</p>
</br>

<p><span style="color:dimgray"><strong>✔ 2FA - Service</strong></span></p>
<pre><code class="language-tsx">// twoFactorAuthentication.service.ts

import { ConfigService } from &quot;@nestjs/config&quot;;
import { User } from &quot;../../users/entities/users.entity&quot;;
import { UsersService } from &quot;../../users/users.service&quot;;
import { authenticator } from &quot;otplib&quot;;
import { Response } from &quot;express&quot;;
import { toFileStream } from &quot;qrcode&quot;;
import { Injectable } from &quot;@nestjs/common&quot;;

@Injectable()
export class TwoFactorAuthenticationService {
  constructor (
    private readonly userService: UsersService,
    private readonly configService: ConfigService
  ) {}

  public async generateTwoFactorAuthenticationSecret(user: User): Promise&lt;object&gt; {

    // otplib를 설치한 후, 해당 라이브러리를 통해 시크릿 키 생성
    const secret = authenticator.generateSecret();

    // accountName + issuer + secret 을 활용하여 인증 코드 갱신을 위한 인증 앱 주소 설정 
    const otpAuthUrl = authenticator.keyuri(user.email, this.configService.get(&#39;TWO_FACTOR_AUTHENTICATION_APP_NAME&#39;), secret);

    // User 테이블 내부에 시크릿 키 저장 (UserService에 작성)
    await this.userService.setTwoFactorAuthenticationSecret(secret, user.id);

    // 생성 객체 리턴
    return {
      secret,
      otpAuthUrl
    }
  }

  // qrcode의 toFileStream()을 사용해 QR 이미지를 클라이언트에게 응답
  // 이때, Express의 Response 객체를 받아옴으로써 클라이언트에게 응답할 수 있다.
  public async pipeQrCodeStream(stream: Response, otpAuthUrl: string): Promise&lt;void&gt; {
    return toFileStream(stream, otpAuthUrl);
  }

 }</code></pre>
<pre><code>※ // .env에서 url 생성을 위한 issuer string 값 정의 

TWO_FACTOR_AUTHENTICATION_APP_NAME=otpauth://</code></pre></br>

<p><span style="color:dimgray"><strong>✔ UserEntity</strong></span></p>
<pre><code class="language-tsx">// user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from &#39;typeorm&#39;;

@Entity({name:&#39;users&#39;})
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  // ...

  @Column({ nullable: true })
  twoFactorAuthenticationSecret: string;
}</code></pre>
</br>

<p><span style="color:dimgray"><strong>✔ UserService</strong></span></p>
<pre><code class="language-tsx">  // userService.ts
  async setTwoFactorAuthenticationSecret(secret: string, userId: number): Promise&lt;UpdateResult&gt; {
    return this.userRepository.update(userId, {
      twoFactorAuthenticationSecret: secret,
    });
  }</code></pre>
<p>런타임의 요청 중인 유저 객체의 id에 접근해 해당 <code>User</code> 테이블의 otp 시크릿 키 값을 업데이트 해준다. (<code>default</code>: <code>null</code>)</p>
</br>

<p><span style="color:dimgray"><strong>✔ Postman으로 확인하기</strong></span></p>
<ul>
<li><p><code>access-token</code>을 통한 권한 인증에 성공 시
<img src="https://velog.velcdn.com/images/from_numpy/post/b5cdaa4e-7103-4a02-b842-31f3d5ec2794/image.png" alt=""></p>
</li>
<li><p><code>access-token</code>을 통한 권한 인증에 실패 시
<img src="https://velog.velcdn.com/images/from_numpy/post/4a97fdc5-1f75-472d-9394-149ab464d8f0/image.png" alt=""></p>
</li>
</ul>
</br>

<h3 id="2fa-사용-여부-묻기">&gt; <code>2FA</code> 사용 여부 묻기</h3>
<p>이것은 도메인마다, 서비스의 실행 계획에 따라 상이할 것이다. 보안이 굉장히 중요한 금융거래와 같은 서비스에선 무조건 <code>2FA</code>를 통한 추가 인증을 강제할 수도 있다. 물론 모든 요청에 대해선 그러지 않을 거지만....</p>
<p>우린 <code>2FA</code> 인증을 유저에게 선택하게 함으로써 조금 더 유저 친화적인 구현을 수행할 수 있다. 또한, <code>2FA</code>를 사용하는 유저 역시 추후 해당 추가 인증을 원치않을 경우 취소할 수도 있게 해주어야할 것이다.</p>
</br>

<p><span style="color:dimgray"><strong>✔ 2FA - Controller</strong></span></p>
<p>전체적 코드는 아래와 같다. </p>
<pre><code class="language-tsx"> // twoFactorAuthentication.controller.ts

  @Post(&#39;turn-on&#39;)
  @UseGuards(JwtAccessAuthGuard)
  async turnOnTwoFactorAuthentication(
    @Req() req: RequestWithUser,
    @Body() twoFactorAuthenticationCodeDto: TwoFactorAuthenticationCodeDto
  ) {
    const isCodeValidated = await this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
      twoFactorAuthenticationCodeDto.twoFactorAuthenticationCode, req.user
    );
    if (!isCodeValidated) {
      throw new UnauthorizedException(&#39;Invalid Authentication-Code&#39;);
    }
    await this.userService.turnOnTwoFactorAuthentication(req.user.id);

    return {
      msg: &quot;TwoFactorAuthentication turned on&quot;
    }
  }

  @Post(&#39;turn-off&#39;)
  @UseGuards(JwtAccessAuthGuard)
  async turnOffTwoFactorAuthentication(
    @Req() req: RequestWithUser,
    @Body() twoFactorAuthenticationCodeDto: TwoFactorAuthenticationCodeDto
  ) {
    const isCodeValidated = await this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
      twoFactorAuthenticationCodeDto.twoFactorAuthenticationCode, req.user
    );
    if (!isCodeValidated) {
      throw new UnauthorizedException(&#39;Invalid Authentication-Code&#39;);
    }
    await this.userService.turnOffTwoFactorAuthentication(req.user.id);

    return {
      msg: &quot;TwoFactorAuthentication turned off&quot;
    }
  }</code></pre>
<p>클라이언트는 앞서 받은 <code>QR</code> 이미지를 <strong><code>Google Authenticator</code></strong>를 통해 확인한 후, 인증 코드를 발급 받을 수 있다. </p>
<p>그리고 해당 인증 코드를 바디에 실어 보내줌으로써 서버는 요청 받은 인증 코드를 &quot;검증&quot;하게 된다. 이러한 검증에 통과한 유저에게 &quot;2FA&quot; 사용의 &quot;활성화&quot;, 혹은 &quot;비활성화&quot; 여부를 내려주게 된다. (이는 <strong><code>User</code></strong> 테이블에도 반영이 되어야할 것이다)</p>
</br>

<p><span style="color:dimgray"><strong>✔ 2FA - Service</strong></span></p>
<pre><code class="language-tsx">  // twoFactorAuthentication.service.ts 

  public async isTwoFactorAuthenticationCodeValid(twoFactorAuthenticationCode: string, user: User) {
    if (!user.twoFactorAuthenticationSecret) {
      return false; // 혹은 다른 예외 처리 가능
    }

    // otplib에서 불러온 authenticator의 verify 메서드를 사용해 올바른 인증 코드인지를 검증
    // 이때, 클라이언트에서 받아온 인증 코드와 서버에 저장된 시크릿 키를 사용한다.
    return authenticator.verify({
      token: twoFactorAuthenticationCode,
      secret: user.twoFactorAuthenticationSecret,
    })
  }</code></pre>
</br>

<p><span style="color:dimgray">*<em>✔ UserEntity *</em></span></p>
<pre><code class="language-tsx">// user.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from &#39;typeorm&#39;;

@Entity({name:&#39;users&#39;})
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  // ...

  @Column({ nullable: true })
  twoFactorAuthenticationSecret: string;

  // 2fa 사용여부 확인 
  @Column({ default: false })
  isTwoFactorAuthenticationEnabled: boolean;
}</code></pre>
<p><span style="color:dimgray">*<em>✔ UserService *</em></span></p>
<pre><code class="language-tsx">  // user.service.ts

  async turnOnTwoFactorAuthentication(userId: number): Promise&lt;UpdateResult&gt; {
    return await this.userRepository.update(userId, {
      isTwoFactorAuthenticationEnabled: true,
    });
  }

  async turnOffTwoFactorAuthentication(userId: number): Promise&lt;UpdateResult&gt; {
    return await this.userRepository.update(userId, {
      // 유저가 2fa 활성화 여부를 끄게 되면 시크릿값또한 null로 수정하여 준다.
      twoFactorAuthenticationSecret: null,
      isTwoFactorAuthenticationEnabled: false,
    })
  }</code></pre>
</br>

<p><span style="color:dimgray">*<em>✔ JwtAccessAuthGuard 수정하기 *</em></span></p>
<p>기존의 <strong><code>JwtAccessAuthGuard</code></strong>를 수정할 필요가 있었다. 위에서 언급하였듯이,  클라이언트에서 받아온 인증 코드를 수행하는데 있어서 우린 user 테이블에서 정의한 <code>twoFactorAuthenticationSecret</code>에 접근할 필요가 있었다. </p>
<p>그리고 해당 유저는 컨트롤러에서 아래와 같이, 현재 <u>실행컨텍스트</u>에 위치한 <strong><code>request</code></strong>의 <code>user</code>로써 받아올 수 있었다. <strong>(<code>req.user</code>)</strong></p>
<pre><code class="language-tsx">    // turn-on || turn-off

    const isCodeValidated = await this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
      twoFactorAuthenticationCodeDto.twoFactorAuthenticationCode, req.user
    );</code></pre>
</br>

<p>하지만, 지금 사용하고 있는 <code>JwtAccessAuthGuard</code>는 access_token 검증을 위해 user 객체를 Payload 객체로 받아와 준 상태였다.</p>
<p><strong>즉</strong>, 일반적 <u>1차 인증을 위해</u> request의 user 객체를 불러오는 과정에서는 액세스 토큰 검증 및 <code>user.id</code> 호출을 동시에 수행할 수 있는 <strong><code>Payload</code></strong> 객체가 필요하였다. </p>
<pre><code class="language-tsx">// jwt-access.guard.ts

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise&lt;any&gt; {
    try {
      const request = context.switchToHttp().getRequest();
      const access_token = request.cookies[&#39;access_token&#39;];
      const user = await this.jwtService.verify(access_token);

      // 실행컨텍스트의 user를 Payload 객체와 동일시 한다. 
      request.user = user;
      return user;
    } catch(err) {
      return false;
    }
  }
}</code></pre>
<p>이러한 기존의 가드를 그대로 <code>2FA</code> 인증 코드의 검증을 위한 api에 사용하게 될 경우 user에서 온전한 유저 테이블의 컬럼 (엔터티의 속성)을 불러올 수 없는 문제가 생기게 되었다. </p>
<p>즉, <code>twoFactorAuthenticationSecret</code>, <code>isTwoFactorAuthenticationEnabled</code>와 같은 속성에 <u style="color:red">접근할 수 없는 상황</u>이었다. </p>
</br>

<p><em><strong>어떻게 수정할 수 있을까?</strong></em></p>
<pre><code class="language-tsx">// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from &quot;@nestjs/common&quot;;
import { JwtService } from &quot;@nestjs/jwt&quot;;
import { UsersService } from &quot;src/users/users.service&quot;;
import { Payload } from &quot;../payload/payload.interface&quot;;
import { User } from &quot;src/users/entities/users.entity&quot;;

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
    private userService: UsersService,
  ) {}
  async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
    try {
      const request = context.switchToHttp().getRequest();
      const access_token = request.cookies[&#39;access_token&#39;];

      const decodedToken = await this.jwtService.verifyAsync(access_token);

      if (!decodedToken) {
        return false; // 액세스 토큰이 유효하지 않음
      }

      const userId = decodedToken.id;
      const user = await this.userService.findUserById(userId);

      if (!user) {
        return false; // 사용자가 존재하지 않음
      }

      // 사용자 정보를 User 엔터티로 변환하여 할당
      request.user = user;
      return true; // 인증 성공
    } catch (err) {
      return false; // 인증 실패
    }
  }
}</code></pre>
</br>

<p><span style="color:dimgray">*<em>✔ Postman과 Google Authenticator로 확인하기 *</em></span></p>
<p>앞서 응답받은 <strong><code>QR</code></strong> 이미지와 <strong><code>Google Authenticator</code></strong>를 활용해 인증 코드를 받을 수 있다. 참고로 발급되는 인증 코드는 <span style="color:green">**&quot;시간 기반 일회용 비밀번호 (Time-Based One Time Password <em>_TOTP</em> )&quot; **</span>이다. 이를 통해 보안을 강화할 수 있다는 장점이 있다.</p>
<ul>
<li>인증 활성화 api 요청
<img src="https://velog.velcdn.com/images/from_numpy/post/ad7069d6-97e5-4c49-834a-5ab3ff57aea3/image.png" alt="">
Google Authenticator를 통해 받은 인증 코드를 형식에 맞게 요청으로 보내준다.</li>
</ul>
</br>

<ul>
<li><p>데이터베이스 확인</p>
<p>마지막 <code>isTwoFactorAuthenticatedEnabled</code>를 보면 레코드 값이 true로 바뀐 것을 확인할 수 있다. --&gt; 2fA 요청 활성화</p>
<p>```sql
| id | firstname | lastname | email                  | password                                                     | currentRefreshToken                                          | currentRefreshTokenExp | twoFactorAuthenticationSecret | isTwoFactorAuthenticationEnabled |</p>
</li>
<li><p>----+-----------+----------+------------------------+--------------------------------------------------------------+--------------------------------------------------------------+------------------------+-------------------------------+----------------------------------+
|  1 | 대규      | 남       | <a href="mailto:a01032762271@gmail.com">a01032762271@gmail.com</a> | $2b$12$00f9wkeRc1SDnDT7p1uFmOIn8ydMVxJTvZVFZ0ogKlBJeG1fvZ7lG | $2b$10$OthY4ELDCFzGQI1OZvZo8OZ0LvFvLTvp2iDbnc0hw12xVu0TlVvZK | 2023-05-26 15:11:25    | PFFQCLAJF5HD4BLM              |                                1 |```</p>
</li>
</ul>
</br>

<p><strong>&quot;turn-off&quot;</strong> 시에도 동일한 원리로 수행할 수 있다. </p>
</br>

<h3 id="검증-및-cookie-설정">&gt; 검증 및 <code>Cookie</code> 설정</h3>
<p><span style="color:dimgray"><strong>✔ 2FA - Controller</strong></span></p>
<pre><code class="language-tsx">  // twoFactorAuthentication.controller.ts

  @Post(&#39;authenticate&#39;)
  @UseGuards(JwtAccessAuthGuard)
  async authenticate(
    @Req() req: any,
    @Body() twoFactorAuthenticationCodeDto: TwoFactorAuthenticationCodeDto
  ) {
    const isCodeValidated = await this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
      twoFactorAuthenticationCodeDto.twoFactorAuthenticationCode, req.user
    );

    if (!req.user.isTwoFactorAuthenticationEnabled) {
      throw new ForbiddenException(&#39;Two-Factor Authentication is not enabled&#39;);
    }

    if (!isCodeValidated) {
      throw new UnauthorizedException(&#39;Invalid Authentication-Code&#39;);
    }

    req.user.isSecondFactorAuthenticated = true;

    const tfa_accessToken = await this.authService.generateAccessToken(req.user, true);

    req.res.cookie(&#39;2fa_token&#39;, tfa_accessToken, {
      httpOnly: true,
      path: &#39;/&#39;,
    });

    return req.user;
  }</code></pre>
<p>해당 api는 2FA 인증 여부를 활성화한 유저에 한해서만 요청 허용되도록 한다. <code>turn-on</code>, <code>turn-off</code> api 요청과 동일하게 인증 코드 검증은 필수로 선행되어야한다. </p>
<p>여기서 중요한 부분은 해당 토큰을 <code>cookie</code>로써 생성해준다는 것이다. 해당 쿠키에 담을 토큰은 우리가 이전 포스팅에서도 다루었지만 &quot;인증&quot;을 위한 &quot;액세스 토큰&quot; 형식으로 담아 보내지게 된다.</p>
<p>우리는, 여기서 해당 액세스 토큰이 &quot;<u style="color:Red">2차 인증(2FA)를 수행하는 토큰인지 아닌지</u>&quot;를 구분할 수 있어야 한다. 아니, 정확히 말하면 구분할 수 있게 된다면 더 좋을 것이다.</p>
<p>이를 위해, 우린 <strong><code>Payload</code></strong>에 토큰의 2차 인증 수행여부를 확인하는 <strong><code>isSecondFactorAuthenticated</code></strong> 속성을 추가해줄 수 있다.</p>
</br>

<p><span style="color:dimgray"><strong>✔ Payload Interface</strong></span></p>
<pre><code class="language-tsx">// payload.interface.ts

export interface Payload {
  id: number;
  email: string;
  firstName: string;
  lastName: string;
  iat?: string;
  exp?: string;
  // 액세스 토큰의 2차 인증 여부 
  isSecondFactorAuthenticated?: boolean;
}</code></pre>
<p><span style="color:dimgray"><strong>✔ AuthService (generate token)</strong></span></p>
<pre><code class="language-tsx">// auth.service.ts

  async generateAccessToken(user: User, isSecondFactorAuthenticated = false): Promise&lt;string&gt; {
    const payload: Payload = {
      id: user.id,
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
      isSecondFactorAuthenticated: isSecondFactorAuthenticated,
    }
    return this.jwtService.signAsync(payload);
  }</code></pre>
<p><code>isSecondFactorAuthenticated</code> 의 기본값을 <code>false</code>로 고정한다. 앞서 컨트롤러의 핸드러 함수 내부에서 보았듯이, 인증을 통과하게되면 해당 속성을 <code>true</code>로 바꿔줌으로써 해당 액세스 토큰이 2차인증을 사용하는 토큰임을 확인받게 된다.</p>
<pre><code class="language-tsx">// TwoFactorAuthenticationController &gt; authenticate

const accessToken = await this.authService.generateAccessToken(req.user, true);</code></pre>
</br>

<p><span style="color:dimgray"><strong>✔ Postman으로 확인하기</strong></span></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/536a080e-7534-45f9-a890-c6e84443ebe6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/c7bf638d-5bf1-4859-942d-db313ac9354d/image.png" alt=""></p>
<p>쿠키또한 잘 생성된 것을 확인할 수 있다.</p>
<p>또한 <code>console.log(tfa_accessToken);</code> 을 통해 생성한 <code>tfa_accessToken</code> 값을 확인한 후, 해당 값을 <a href="https://jwt.io/">jwt.io ✔</a> 에서 디코딩해보니 아래와 같이 <strong><code>isSecondFactorAuthenticated</code></strong> 값이 <strong><code>true</code></strong>로 변환된 것 또한 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/deb6ddd6-988f-41d8-a17a-542ef3a0c2d7/image.png" alt=""></p>
</br>

<h3 id="2fa-가드-설정">&gt; <code>2FA</code> 가드 설정</h3>
<p>이제 <strong>&quot;2FA&quot;</strong> 인증을 활용할 차례이다. 모든 요청마다 2FA 인증을 요구할 순 없고, 또 특정 api 요청에선 분명히 2FA를 요구할 필요가 있을 것이다. 물론, &quot;turn-on&quot;, &quot;turn-off&quot;를 통해 사용자에게 인증의 선택을 맡긴것도 있지만, &quot;2FA&quot;를 승인한 사용자라고해서 <u>모든 API 요청에 추가 인증을 요구할 필요는 없을 것</u>이다. <u>UX와 보안성, 서비스의 방향성을 고려</u>해서 특정 API에만 &quot;2FA&quot; 인증을 요구하는 <span style="color:green"><strong>&quot;인가(Authorization)&quot;</strong></span>를 구현할 필요가 있다.</p>
</br>

<p><span style="color:dimgray"><strong>✔ 2FA - Strategy with Passport</strong></span></p>
<p>가드가 받은 요청을 토대로 인증을 수행하기 위한 전략 패턴을 사용하고자 한다. 이는 역시, *<em><code>Passport</code> *</em>모듈을 사용하였다.</p>
<pre><code class="language-tsx">// jwt-twoFactorStrategy.strategy.ts

import { Injectable } from &quot;@nestjs/common&quot;;
import { PassportStrategy } from &quot;@nestjs/passport&quot;;
import { Request } from &quot;express&quot;;
import { ExtractJwt, Strategy } from &quot;passport-jwt&quot;;
import { Payload } from &quot;src/auth/payload/payload.interface&quot;;
import { UsersService } from &quot;src/users/users.service&quot;;

@Injectable()
export class JwtTwoFactorStrategy extends PassportStrategy(
  Strategy,
  &#39;jwt-two-factor&#39;
) {
  constructor(
    private readonly userService: UsersService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([(req: Request) =&gt; {
        return req?.cookies?.two_fa_token;
      } ]),
      secretOrKey: process.env.JWT_ACCESS_SECRET
    });
  }

  async validate(payload: Payload) {
    const user = await this.userService.findUserById(payload.id);

    if (!user.isTwoFactorAuthenticationEnabled) {
      return user;
    }
    if (payload.isSecondFactorAuthenticated) {
      return user; 
    }
  }
}</code></pre>
<p><strong><code>super()</code></strong> 내부에서 메서드 내부로 전달되는 옵션을 설정할 수 있다. <code>PassportStrategy</code>의 생성자를 이곳에서 호출하는 것이다. 여기선 <strong><code>JWT</code></strong> 전략을 설정하게 된다.</p>
<p>이 때, 우리가 앞서 <strong><code>authenticate</code></strong> 핸들러 함수에서 설정한 쿠키인 <strong><code>two_fa_token</code></strong>을 호출할 수 있다. </p>
<p><span style="color:green">(잠깐 동안, 그럼 <strong><code>Refresh-Token</code></strong>은 &quot;2FA&quot; 인증과 어떻게 연관지어야하나 생각을 해보았었다. 하지만, 이것은 쓸데없는 생각이였다)</span></p>
<p>이렇게 설정된 옵션은 JWT 전략이 클라이언트의 요청에서 JWT를 추출하고, 비밀 키를 사용하여 토큰의 유효성을 검사할 수 있도록 한다.</p>
<p>핵심 로직은 *<em><code>validate()</code> *</em>내부에서 구현된다.</p>
<ul>
<li><p>먼저, <strong><code>Payload</code></strong>를 통해 <code>id</code>값을 받아와 <code>User</code> 객체를 생성한다.</p>
</li>
<li><p>만약 <strong><code>user.isTwoFactorAuthenticationEnabled === false</code></strong>라면, 바로 <code>user</code> 객체를 리턴하고 종료하게 된다. 이는, 2FA 활성화를 선택하지 않은 유저에 한해서 적용된다. 2FA를 활성화하지 않은 유저 또한 해당 가드를 사용할 요청에 &quot;접근&quot;은 할 수 있어야하기 때문이다.</p>
</li>
<li><p>만약 <strong><code>user.isTwoFactorAuthenticationEnabled === true</code></strong>라면,  다음 <strong><code>if</code></strong> 문으로 넘어간다. <span style="color:red">즉, <code>2FA</code> 인증을 선택한 유저에 한해선 <code>2FA</code> 토큰의 추가 검증을 진행하는 것이다.</span> 만약, 해당 토큰의 검증이 실패한다면 가드에서 요청을 막게 될 것이다.</p>
</li>
</ul>
</br>

<p><span style="color:dimgray"><strong>✔ 2FA - Guard</strong></span></p>
<pre><code class="language-tsx">// jwt-twoFactor.guard.ts

import { Injectable } from &quot;@nestjs/common&quot;;
import { AuthGuard } from &quot;@nestjs/passport&quot;;

@Injectable()
export default class JwtTwoFactorGuard extends AuthGuard(&#39;jwt-two-factor&#39;) {}</code></pre>
</br>

<p><span style="color:dimgray"><strong>✔ 간단한 테스트 api</strong></span></p>
<pre><code class="language-tsx">// test.controller.ts

import { Controller, Get, Req, UseGuards } from &quot;@nestjs/common&quot;;
import JwtTwoFactorGuard from &quot;./auth/2fa/guard/jwt-twoFactor.guard&quot;;
import { User } from &quot;./users/entities/users.entity&quot;;
import { UsersService } from &quot;./users/users.service&quot;;

@Controller(&#39;test&#39;)
export class TwoFATestController {
  constructor(
    private readonly userService: UsersService,
  ) {}

  @UseGuards(JwtTwoFactorGuard)
  @Get(&#39;access-2fa&#39;)
  async accessWithTwoFA(
    @Req() req: any,
  ) {
    const user: User = await this.userService.findUserById(req.user.id);
    return user;
  }
}</code></pre>
<p>간단한 라우트 핸들러 함수에 앞서 생성한 <code>JwtTwoFactorGuard</code>를 주입해본뒤 Postman에서 테스트해보자.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2f686ffb-77c5-4f1e-bdbe-a1a8e7813974/image.png" alt=""></p>
<p>만약, 유효하지 않은 토큰의 경우 (만료시간 경과 등의 검증 실패의 이유로) 해당 요청에서 &quot;<u>401 UnauthorizedException</u>&quot;을 띄울 것이다.</p>
</br>


<p>🍓🍓🍓 <strong>끝!!!!!!!!</strong> 🍓🍓🍓</p>
</br>

<h2 id="생각정리">생각정리</h2>
<p>지난번 &quot;Refresh-Token&quot; 구현하기 및 스케쥴링을 이용한 주기적 토큰 값 갱신 구현에 이어서 이번 포스팅에선 <strong>&quot;Google Authenticator와 OTP를 활용한 2FA 구현하기&quot;</strong>를 주제로 알아보았다. 경험해보기전엔 그냥 단순히 <em>&quot;QR 코드찍고, 인증코드 입력하고, 검증 마치고... 단순하지 않을까?&quot;</em> 생각해보았지만 나처럼 초보의 입장에선 단순하지 않았다.  </p>
<p>nestjs를 활용한 코드적 구현은 둘째치고, 어떻게 <strong>&quot;설계&quot;</strong>를 해야하는가가 가장 큰 난관이였다. 구글링을 통해 잘 구현된 코드, 또한 2FA에 대한 좋은 글들이 분명 있었지만 내 것으로 만들기도 쉽지않았고, 내가 진행하고 있는 코드에 이어서 작성하는 것도 생각보다 난관이 존재하였다.</p>
<p>사실, 가장 해보고 싶었던 구현은 가장 서두의 2FA 개념에 대해 설명할때 아주 짤막하게 언급했었던 <strong>&quot;사용자가 있는 곳(공간적 범주)&quot;</strong>를 고려해 2FA를 구현해보고 싶었다. 이는 결국, <strong>&quot;IP&quot;</strong>가 될 것이고 IP에 따라 2FA 인증을 구현하는 케이스가 좋지 않을까 생각하였다. 
하지만, 데이터베이스 설계의 관점에선 힘들지 않을거라 생각했지만 아직 프록시 서버와 같은 중계서버에 대해 추후 공부하고 난 뒤 경험하는 것을 택하였다.</p>
<p>아무튼, 이렇게 nestjs에 <strong>&quot;2FA&quot;</strong>를 <u>특정 설계관점에 맞추어</u> 구현해볼 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] (+보완) Remove refresh-token data in DB if token has expired (using Task-Scheduling)]]></title>
            <link>https://velog.io/@from_numpy/NestJS-%EB%B3%B4%EC%99%84-refresh-token-%EB%A7%8C%EB%A3%8C%EC%8B%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EC%88%98%EC%A0%95-feat.-task-scheduling-setTimeout</link>
            <guid>https://velog.io/@from_numpy/NestJS-%EB%B3%B4%EC%99%84-refresh-token-%EB%A7%8C%EB%A3%8C%EC%8B%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EC%88%98%EC%A0%95-feat.-task-scheduling-setTimeout</guid>
            <pubDate>Tue, 09 May 2023 16:33:13 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>이번 포스팅은 온전히 이전 포스팅 <strong>&quot;How to implement Refresh-Token using JWT&quot;</strong>을 전제로 이어나간다. (아래 링크 참조)</p>
<hr>
<p><a href="https://velog.io/@from_numpy/NestJS-How-to-implement-Refresh-Token-with-JWT">이전 포스팅 (How to implement Refresh-Token using JWT) ✔ </a></p>
<hr>
<p>물론 더 깊게 들어가면 수정하고 보완해야할 부분이 끝도 없지만 이전 포스팅의 내용에서 기본적인 <strong><code>Refresh-Token</code></strong> 구축에 따른 필요 기능을 구현해보았다. </p>
<p>일반적으로, 클라이언트에서 <strong>&quot;로그아웃&quot;</strong>을 요청하게 되면 데이터베이스 유저 테이블에 저장된 <code>currentRefreshToken(현재 refresh 토큰값)</code>과 <code>currentRefreshTokenExp(refresh 토큰의 만료 시간)</code>을 <strong><code>null</code></strong>로 수정하게끔 하였다. 로그아웃이 된 이상, refresh_token값을 데이터베이스 내에 지니고 있으면 안되기 때문이다.</p>
<p>즉, 우린 이에 따라 아래와 같이 로그아웃 시에, <code>removeRefreshToken()</code> 메서드를 호출해 데이터베이스의 토큰 값을 제거해 <code>null</code>로 만들어 줄 수 있었다.</p>
<pre><code class="language-tsx">  @Post(&#39;logout&#39;)
  @UseGuards(JwtRefreshGuard)
  async logout(@Req() req: any, @Res() res: Response): Promise&lt;any&gt; {
    await this.userService.removeRefreshToken(req.user.id);
    res.clearCookie(&#39;access_token&#39;);
    res.clearCookie(&#39;refresh_token&#39;);
    return res.send({
      message: &#39;logout success&#39;
    });
  }

  // ------------------

    async removeRefreshToken(userId: number): Promise&lt;any&gt; {
    return await this.userRepository.update(userId, {
      currentRefreshToken: null,
      currentRefreshTokenExp: null,
    });
  }</code></pre>
<p><span style="color:red"><strong>하지만!</strong></span> 한 가지 처리해주지 않은 부분이 있다. </p>
<p>이렇게 직접적 로그아웃 요청이 아닌, <span style="color:blue"><strong><code>Refresh-Token</code></strong>이 <strong>만료되었을시에는</strong></span> 어떻게 <u>데이터베이스의 해당 토큰 값을 제거해 줄 수 있을까</u>?</p>
<p>refresh-token이 만료가 되면, 클라이언트 역시 jwt 토큰 정보를 알고 있으므로 로그아웃 요청을 보낼 수 있다. 또한, 쿠키 제거와 같은 작업 또한 클라이언트가 처리할 수 있을 것이다.</p>
<p>하지만, <span style="color:red">api 요청없이 데이터베이스에 접근해 일련의 동작을 수행하는 기능</span>은 서버측에서 자체적으로 처리해주는 것이 바람직하다. 이번 포스팅에선 해당 내용을 다뤄보고자 한다.</p>
<p><span style="color:gray">(즉, 이전 포스팅의 코드대로 실행한다면 refresh-token이 만료되었음에도 불구하고 여전히 데이터베이스 내부에 해당 토큰 값이 남아있게 됩니다)</span></p>
</br>

<h2 id="💥-refresh-token-만료-시-해당-데이터-제거하기">💥 <code>Refresh-Token</code> 만료 시 해당 데이터 제거하기</h2>
<p><span style="color:blue"><strong>두</strong></span> 가지 방법을 통해 알아보고자 한다.</p>
<h3 id="task-scheduling을-통하여-주기적-수정갱신">&gt; <code>Task Scheduling</code>을 통하여 주기적 수정(갱신)</h3>
<p>첫 번째는 <strong>&quot;Task Scheduling&quot;</strong>을 활용한 방법이다.</p>
<p><strong><code>Task Scheduling</code></strong>을 사용하면 고정된 날짜/시간, 반복 주기 또는 지정된 주기 후에 특정 코드(메서드/함수)가 실행하게끔 예약할 수 있다.</p>
<p>간단히 말해서 특정시간에 또는 정기적으로 실행해야 하는 비즈니스 로직(<strong>Task</strong>)을 일련의 조건 및 정의(<strong>Trigger</strong>)를 기반으로 실행(<strong>Scheduling</strong>)하는 것이다.</p>
<p>이를 통해 굳이 컨트롤러 레이어에 접근하지 않고도 서비스 레이어에서 반복된 작업을 수행할 수 있게 된다.</p>
<p><strong>nestjs</strong>에선 패키지를 통해 불러온 <strong><code>ScheduleModule</code></strong>과  <strong><code>@Cron()</code></strong> 데코레이터를 사용하여 이를 구현할 수 있다.</p>
</br>

<p><span style="color:blue">✔ 토큰이 만료된 유저 찾기</span></p>
<p><code>user</code> 테이블 내의 <code>currentRefreshToken</code>에 접근해 해당 만료시간과 현재시간(<code>currentTime</code>)을 비교하는 쿼리를 작성함으로써 만료된 토큰을 지닌 유저 객체를 얻을 수 있다.</p>
<pre><code class="language-tsx">// user.service.ts

  async findUsersWithExpiredTokens(currentTime: number): Promise&lt;User[]&gt; {
    const queryBuilder = this.userRepository.createQueryBuilder(&#39;user&#39;);
    const usersWithExpiredTokens = await queryBuilder
      .where(&#39;user.currentRefreshTokenExp &lt;= :currentTime&#39;, { currentTime: new Date(currentTime) })
      .getMany();
    return usersWithExpiredTokens;
  }</code></pre>
</br>

<p><span style="color:blue">✔ Scheduling 구현하기</span></p>
<pre><code class="language-tsx">  @Cron(CronExpression.EVERY_MINUTE) 
  async removeExpiredTokens() {
    const currentTime = new Date().getTime();
    const usersWithExpiredTokens = await this.userService.findUsersWithExpiredTokens(currentTime);
    console.log(usersWithExpiredTokens);
    for (const user of usersWithExpiredTokens) {
      if (user.currentRefreshToken) {
        await this.userService.removeRefreshToken(user.id); 
      }
    }
  }</code></pre>
<p>실제로 <code>refresh_token</code> 의 데이터에 접근하는데, 1분마다 (<code>CronExpression.EVERY_MINUTE</code>) 접근하게되면 상당히 부하가 많이 갈 것이다. 현재는, 단순 테스트 결과를 확인하기 위해 위와 같은 주기(매 분마다)를 설정하였다. </p>
<p>참고로 <code>refresh_token</code>의 유효 기간또한 5분으로 설정된 상태이다. 이것또한 테스트를 위해서이다. 실제로는 몇일에서 <strong>몇주</strong>(길게 2주)정도가 적당하다.</p>
<p>그럼 위의 코드를 한번 확인해보자.</p>
<p>먼저 <strong><code>findUserWithExpiredTokens()</code></strong>메서드 호출을 위한 <code>currentTime</code>값이 필요하다. 이 때, 해당 메서드 정의 시 <code>currentTime</code>값을 <code>number</code>타입으로써 매개변수에 설정하였으므로 인자에 담을 때도 <code>new Date()</code>로 담는 것이 아닌, <strong><code>new Date().getTime()</code></strong>을 통해 숫자값 형태의 날짜로 담도록 한다.</p>
<p>그렇게 받아온 <code>userWithExpiredTokens</code> 객체를 스케줄링을 통해 호출해보면 어떤 값이 나올까? 우린 <code>console</code>을 통해 확인해 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/0872370e-59c8-4b0c-8c71-e49aa1fba1f3/image.png" alt=""></p>
<p>현재 위와 같이 두 유저가 등록되어있고, 로그인을 통해 *<em><code>refresh-token</code> *</em>값 또한 가진 상태이다. 위에서 짤려서 안보이지만, 토큰의 만료시간은 아래와 같이 약 2분정도 차이가 나는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/7b94e2a3-cb81-497f-aaea-659f076127fb/image.png" alt=""></p>
<p>만약, 이 상황에서 스케쥴링을 구현하면 어떻게 될까? 즉, 우린 어떤 유제 객체 값을 얻을 수 있을까? 스케쥴링의 주기는 <strong>&quot;1분&quot;</strong> 이므로 (매분마다) 1분마다 일련의 쿼리와 함께 해당 데이터 배열이 출력될 것이다.</p>
<pre><code class="language-tsx">console.log(usersWithExpiredTokens);</code></pre>
<pre><code>query: SELECT `User`.`id` AS `User_id`, `User`.`firstname` AS `User_firstname`, `User`.`lastname` AS `User_lastname`, `User`.`email` AS `User_email`, `User`.`password` AS `User_password`, `User`.`currentRefreshToken` AS `User_currentRefreshToken`, `User`.`currentRefreshTokenExp` AS `User_currentRefreshTokenExp` FROM `users` `User` WHERE (`User`.`email` = ?) LIMIT 1 -- PARAMETERS: [&quot;a01032762271@gmail.com&quot;]
query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [&quot;$2b$10$BIAaX8heYela1pscf21FauQN.rEXPJh5diP8/WONWpqHxGtwBrQy6&quot;,&quot;2023-05-09T12:45:04.173Z&quot;,1]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` &lt;= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: [&quot;2023-05-09T12:41:00.006Z&quot;]
[]
query: SELECT `User`.`id` AS `User_id`, `User`.`firstname` AS `User_firstname`, `User`.`lastname` AS `User_lastname`, `User`.`email` AS `User_email`, `User`.`password` AS `User_password`, `User`.`currentRefreshToken` AS `User_currentRefreshToken`, `User`.`currentRefreshTokenExp` AS `User_currentRefreshTokenExp` FROM `users` `User` WHERE (`User`.`email` = ?) LIMIT 1 -- PARAMETERS: [&quot;y_hello@gmail.com&quot;]
query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [&quot;$2b$10$TV.ek.ieTL9IbXzlsw24w.SkHtQnGWMhbKOXPNDPsCVtMJamBbnh6&quot;,&quot;2023-05-09T12:46:59.863Z&quot;,2]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` &lt;= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: [&quot;2023-05-09T12:42:00.005Z&quot;]
[]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` &lt;= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: [&quot;2023-05-09T12:43:00.012Z&quot;]
[]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` &lt;= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: [&quot;2023-05-09T12:44:00.008Z&quot;]
[]
query: SELECT `user`.`id` AS `user_id`, `user`.`firstname` AS `user_firstname`, `user`.`lastname` AS `user_lastname`, `user`.`email` AS `user_email`, `user`.`password` AS `user_password`, `user`.`currentRefreshToken` AS `user_currentRefreshToken`, `user`.`currentRefreshTokenExp` AS `user_currentRefreshTokenExp` FROM `users` `user` WHERE `user`.`currentRefreshTokenExp` &lt;= ? AND `user`.`currentRefreshToken` is not null -- PARAMETERS: [&quot;2023-05-09T12:45:00.011Z&quot;]
[]</code></pre><p>이처럼 <code>usersWithExpiredTokens</code>의 값이 1분 간격으로 <strong><code>[]</code></strong> 즉, <strong>빈 배열</strong>로 출력되는 것을 확인할 수 있다. 즉, 우리가 앞서 <code>UserService</code>의 <strong><code>findUsersWithExpiredTokens()</code></strong> 내부에서 객체를 리턴할 때, <u>토큰이 만료된 유저객체만 반환하도록 쿼리를 수행</u>해주었기 때문이다.</p>
<p>쿼리를 자세히 보면, 마지막 빈 배열이 찍힐 시에 <strong>45분</strong>임을 알 수 있다. <span style="color:gray">(정확한 시각의 차이는 표준 시간 등의 문제로 다릅니다.. 귀찮아서 따로 수정 생략합니다)</span></p>
<p>또한, <code>userId=1</code>의 유저의 <code>currentRefreshTokenExp</code> (만료시간)는 <code>2023-05-09 21:45:04</code>인 것을 확인하였다. 즉, &quot;46&quot;분 부터는 해당 유저의 refresh-token이 만료된 상태이므로 유저 객체가 콘솔에 출력될 것으로 기대된다.</p>
<pre><code>[
  User {
    id: 1,
    firstName: &#39;대규&#39;,
    lastName: &#39;남&#39;,
    email: &#39;a**********@gmail.com&#39;,
    password: &#39;$2b$12$00f9wkeRc1SDnDT7p1uFmOIn8ydMVxJTvZVFZ0ogKlBJeG1fvZ7lG&#39;,
    currentRefreshToken: &#39;$2b$10$BIAaX8heYela1pscf21FauQN.rEXPJh5diP8/WONWpqHxGtwBrQy6&#39;,
    currentRefreshTokenExp: 2023-05-09T12:45:04.000Z
  }
]</code></pre><p><code>userId=2</code>의 유저 또한 refresh-token의 만료시간이 되면 <strong><code>usersWithExpiredTokens</code></strong>에 반영이 될 것이다.</p>
<pre><code>[
  User {
    id: 1,
    firstName: &#39;대규&#39;,
    lastName: &#39;남&#39;,
    email: &#39;a**********@gmail.com&#39;,
    password: &#39;$2b$12$00f9wkeRc1SDnDT7p1uFmOIn8ydMVxJTvZVFZ0ogKlBJeG1fvZ7lG&#39;,
    currentRefreshToken: &#39;$2b$10$BIAaX8heYela1pscf21FauQN.rEXPJh5diP8/WONWpqHxGtwBrQy6&#39;,
    currentRefreshTokenExp: 2023-05-09T12:45:04.000Z
  },
  User {
    id: 2,
    firstName: &#39;**&#39;,
    lastName: &#39;예&#39;,
    email: &#39;y_hello@gmail.com&#39;,
    password: &#39;$2b$12$FgT6Lgv0m4G1vpFSrPlOROEnVZwD.9ffaSS5fxfK0IiQxL5IvpVG.&#39;,
    currentRefreshToken: &#39;$2b$10$TV.ek.ieTL9IbXzlsw24w.SkHtQnGWMhbKOXPNDPsCVtMJamBbnh6&#39;,
    currentRefreshTokenExp: 2023-05-09T12:47:00.000Z
  }
]
</code></pre></br>

<p><span style="color:blue">✔ 토큰 제거 + 검증하기</span></p>
<p>이렇게 얻게 된, 유저 객체를 통해 우린 아래와 같은 작업으로 각 유저의 refresh-token값과 해당 토큰의 만료시간을 제거해 줄 수 있다.</p>
<pre><code class="language-tsx">    for (const user of usersWithExpiredTokens) {
      if (user.currentRefreshToken) {
        await this.userService.removeRefreshToken(user.id); 
      }
    }</code></pre>
<pre><code class="language-tsx">query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [null,null,1]
query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [null,null,2]</code></pre>
<p>실제로 이러한 업데이트 쿼리가 주기적으로 호출됨을 확인할 수 있을 것이다. 또한, 데이터베이스를 직접 확인한다면 아래와 같이 <code>null</code>값으로 수정된 것을 확인할 수 있을 것이다.
<img src="https://velog.velcdn.com/images/from_numpy/post/c0ce9b6c-22d1-413d-94b5-b319177a4c38/image.png" alt=""></p>
</br>

<p>이렇게 우린 &quot;Task Scheduling&quot;을 사용하여 api 요청 없이도, <strong><code>Refresh-Token</code></strong> 만료시에 데이터베이스의 값을 수정할 수 있게 되었다. </p>
<p>앞서 언급하였다시피 임의로 빠른 결과 확인을 위해 토큰 유효기간과 스케쥴링 주기를 설정하였지만, 실제로는 유저 경험과 여러 데이터 및 데이터베이스 부하등을 고려하여 적절한 시간값을 설정해주는 것이 중요하다.</p>
</br>

<h3 id="settimeout을-통한-구현">&gt; <code>SetTimeout()</code>을 통한 구현</h3>
<p>아주 간단하고도 직관적이다. 로그인 시 <code>setTimeout()</code>함수를 사용해 refresh-token의 유효기간이 지난 후, 데이터베이스에서 토큰을 제거하는 작업을 수행해주는 것이다.</p>
<pre><code class="language-tsx">  @Post(&#39;login&#39;)
  @UseFilters(RateLimitFilter)
  async login(
    @Body() loginDto: LoginDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise&lt;any&gt; {
    const user = await this.authService.validateUser(loginDto);
    const access_token = await this.authService.generateAccessToken(user);
    const refresh_token = await this.authService.generateRefreshToken(user);

    await this.userService.setCurrentRefreshToken(refresh_token,user.id);
    res.setHeader(&#39;Authorization&#39;, &#39;Bearer &#39; + [access_token, refresh_token]);
    res.cookie(&#39;access_token&#39;, access_token, {
      httpOnly: true,
    });
    res.cookie(&#39;refresh_token&#39;, refresh_token, {
      httpOnly: true,
    });

    // refresh-token의 유효기간(period)을 불러온다.
    const refreshTokenValidityPeriod: number = await this.authService.getRefreshTokenValidityPeriod();

    // 위에서 불러온 유효기간이 지나면 removeRefreshToken() 함수를 실행하도록 한다.
    setTimeout(async() =&gt; {
      await this.userService.removeRefreshToken(user.id);  
    }, refreshTokenValidityPeriod)

    return {
      message: &#39;login success&#39;,
      access_token: access_token,
      refresh_token: refresh_token,
    };
  }



  // getRefreshTokenValidityPeriod() -- UserService

    async getRefreshTokenValidityPeriod() {
    const currentTime: number = Date.now();
    const refreshTokenExpTime: number = (await this.userService.getCurrentRefreshTokenExp()).getTime();
    const refreshTokenValidityPeriod = refreshTokenExpTime - currentTime;
    return refreshTokenValidityPeriod;
  }</code></pre>
<p>로그인 시에 해당 유저의 <code>id</code> 값을 받아와 해당 <code>id</code>를 지닌 유저 데이터에 접근한다. 이 사실은 변하지 않으므로, <strong><code>setTimeout</code></strong>을 사용한다면 <u>정말 간단하게</u> 해당 유저마다의 refresh-token 만료 시간 후, 테이블내 레코드에서 토큰 데이터를 제거해줄 수 있다.</p>
<pre><code>query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [null,null,1]
query: UPDATE `users` SET `currentRefreshToken` = ?, `currentRefreshTokenExp` = ? WHERE `id` IN (?) -- PARAMETERS: [null,null,2]</code></pre><p>쿼리 또한 잘 수행이 되는 것을 확인 할 수 있다.</p>
</br>

<p>확실히 <strong><code>Task-Scheduling</code></strong>을 사용했을때보다 코드의 양과 복잡성은 줄어들었다. 그렇다면 과연 이것이 더 좋은 방법일까?</p>
<p>물론, 간단한 예시 코드에선 더 좋아보일지 모르지만, 나의 대답은 <strong>&quot;그렇지 않다&quot;</strong>이다.</p>
<p><span style="color:blue"><strong>✔ 어떠한 문제가 존재할까?</strong></span></p>
<p>가장 큰 이유는 <strong>&quot;js의 <code>setTimeout</code>은 코드의 실행 순서를 보장하지 않는다.&quot;</strong> 이다.</p>
<p>지금이야 로그인 api에서 <strong>setTimeout</strong> 내부의 코드가 설정된 토큰의 유효 기간에 맞춰서 적절한 시기에 실행이 되지만, 항상 그렇다고 보장할 순 없다. 라우트 핸들러 함수 내부에서 어떠한 복잡한 코드가 실행될지도 모르고, 얼마나 오래걸릴지도 예측할 수 없다. 물론, 로그인 api의 경우니까 괜찮지 않냐고 단언할 수도 있지만, 그렇다고 <strong>setTimeout</strong>에 이를 맡기기엔 예상치 못한 일이 충분히 벌어질 위험성이 있다고 본다.</p>
<p>반면, <strong><code>Task-Scheduling</code></strong>을 사용하는 방법은 정확한 시간에 작업이 실행되도록 정교한 제어를 가능케한다. <code>setTimeout</code>에 비해 대처와 안정성 측면에서 뛰어난 것임은 분명하다. 또한 컨트롤러의 영향을 받지 않고 서비스 단에서 특정 함수로써 분리 구현을 할 수 있으므로 가독성 또한 좋아질 수 있다. </p>
<p><strong><code>login()</code></strong> 핸들러 함수 내부에 토큰을 디비에서 제거하는 작업을 넣게되면 굉장히 혼동스러운 코드가 될 것이다. </p>
<p>이러한 일련의 이유들로 <strong><code>setTimeout</code></strong>을 통한 방법보단 앞서 수행해보았던 <strong><code>Task-Scheduling</code></strong>이 조금 더 적합한 방법이지않나 생각해본다.</p>
</br>

<h2 id="생각정리">생각정리</h2>
<p>이번 포스팅에선 이전 포스팅에서 미처 구현해보지 못하였던 <strong>&quot;refresh-token 만료 시 데이터베이스 내에서 해당 토큰 및 만료시간 데이터 제거하기&quot;</strong> 에 대한 방법을 알아보았다.</p>
<p><strong>&quot;인증(Authentication)&quot;</strong>은 생각보다 고려해야할 사항이 많다는 것을 새삼 느꼈고, 정말로 좋은 전천후한 방법은 항상 존재하지 않다는 것 또한 깨달았다. 분명 위의 코드또한 깊게 파고 들면 수정하고 보완해야할 것이 많을 것이다. 일단, 어떤 식으로 토큰 만료시 데이터베이스에 접근할 수 있는가를 두 가지 방법을 통해 알아보았고, 이 중 <strong><code>Task-Scheduling</code></strong> 기법은 처음 경험해보아서 더 의미가 있었다. </p>
<p>추가해야할 사항이나, 보완 및 수정 사항은 추후 계속해서 업데이트 할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] How to implement Refresh-Token using JWT?]]></title>
            <link>https://velog.io/@from_numpy/NestJS-How-to-implement-Refresh-Token-with-JWT</link>
            <guid>https://velog.io/@from_numpy/NestJS-How-to-implement-Refresh-Token-with-JWT</guid>
            <pubDate>Wed, 03 May 2023 12:27:39 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하기에-앞서">시작하기에 앞서</h2>
<p>오랜만에 작성하는 포스팅인 것 같다. 최근에 개인적인 일도 있고, 뭔가 쉬어가고 싶어서 천천히 공부를 하며 어떤 주제를 다뤄보며 좋을까 고민을 했었다. 그러던 와중, 예전부터 들어만보았지, 한 번도 다뤄본적이 없었던 <strong>&quot;Refresh-Token&quot;</strong>에 대해 <strong>NestJS</strong>로 구현해보면 좋을것 같다는 생각이 들었다.</p>
<p><strong>NestJS</strong>는 물론이고, 흔히 서버 기술과 관련된 프레임워크를 다루다보면 처음으로 <strong>&quot;인증(Authentication)&quot;</strong>에 관한 내용을 접하게 된다. &quot;인증&quot;을 다루는데 있어서 흔히 <strong>&quot;User Authentication(유저 인증)&quot;</strong>을 처음 접하게 되고, 동시에 &quot;로그인(Log-in) 구현&quot;을 통해 이를 알아보게 된다. </p>
<p>여기서 우린 &quot;세션(session)&quot;, &quot;토큰(token)&quot; 이란 개념을 인증 방법으로써 도입하게 되는데 이번 포스팅에선 토큰 방식의 인증(Token-based Authentication), 그 중에서도 JWT(Json Web Token)을 통해서 인증을 구현해보고자 한다. </p>
<p>단순히 <strong>&quot;Access-Token&quot;</strong>만으로 다뤄보진 않을 것이다. 해당 방법은 많은 강좌 혹은 블로그, 혹은 공식문서에도 기술되어있고 쉽게 구현해볼 수 있다. 본인 또한 일전에 <strong>JWT</strong>를 이용하여(오로지 Access-Token 기능을 통해) 유저 인증 기능을 구현해보았었다. <span style="color:gray"> (아래 링크 참조) </span></p>
<hr>
<p><a href="https://velog.io/@from_numpy/series/JWT-%EC%83%9D%EC%84%B1%EB%B6%80%ED%84%B0-%EA%B6%8C%ED%95%9C%EA%B4%80%EB%A6%AC%EA%B9%8C%EC%A7%80-NestJS">JWT 생성과 토큰 검증 시리즈</a></p>
<hr>
<p>이번 포스팅에선 <strong>&quot;Access-Token&quot;</strong>만이 아닌, <strong>&quot;Refresh-Token&quot;</strong>을 도입함으로써 <u>보안적 측면을 조금 더 강화한 인증을 구현</u>해보고자 한다. JWT를 통해 <strong>&quot;Refresh-Token&quot;</strong>을 적용시키는 것은 물론이고, NestJS에선 이를 어떻게 다룰 수 있는지에 대해 알아볼 것이다.</p>
<p>본격적 내용에 들어가기에 앞서 언급하자면, 이번 포스팅에선 <strong>&quot;어떻게 Refresh-Token을 구현해 내는가?&quot;</strong>에 중점을 둘 것이다. &quot;Refresh-Token&quot;을 구현하였다고해서 보안적 측면에서의 모든 문제를 해결하였고, 궁극적인 방법에 이른 것은 <strong>절대 아니다</strong>. <u>Refresh-Token이라도 허점은 분명히 존재</u>하고, 보완적으로 추가해야할 부분들이 많다. 이러한 내용은 추후 다음 포스팅들에서 다뤄보도록 하겠다.</p>
<p>또한, 지금부터 기술하게 되는 내용 혹은 방법만이 전부는 아닐 것이므로 다양한 생각을 항상 열어둘 필요는 있을 것이다.</p>
</br>

<h2 id="💥-refresh-token-">💥 <code>Refresh Token</code> ?</h2>
<p>우리는 이번 글에선 &quot;NestJS에서 Refresh-Token을 어떻게 구현해볼 수 있는가&quot;에 중점을 맞출 것이므로, Refresh-Token에 대한 깊은 내용까진 설명하진 않을 것이다. 간단히 Refresh-Token은 무엇이고, 왜? 사용하는지에 대해서 알아보자.</p>
<h3 id="refresh-token은-왜-등장하였는가">&gt; Refresh Token은 왜 등장하였는가?</h3>
<p><strong>&quot;Refresh-Token&quot;</strong>은 단어 뜻 그대로 <strong>&quot;새로 고침 토큰&quot;</strong>이다. 정의하는 사람에 따라 다르겠지만, 본인은 이것을 &quot;<u style="color:red">Access-Token의 새로 고침</u>&quot;으로 생각하면 좋을 거 같았다.</p>
<p>우연히 아래와 같은 문구를 보았다.</p>
<p>&quot;&quot;&quot;
<span style="color:gray"><em>It&#39;s important to highlight that the access token is a bearer token.</em></span>
&quot;&quot;&quot;</p>
<p>Access-Token은 <u>Bearer-Token임을 강조하는 것이 중요하다</u>는 뜻이다. 이 말이 의미하는게 무엇일까?</p>
<p><strong>&quot;Bearer-Token&quot;</strong>은 인증된 사용자가 보호된 자원에 접근하는 데 사용되는 토큰이다. Bearer-Token은 인증된 사용자가 보호된 자원에 접근하는 데 사용되는 토큰이다. (Bearer-Token은 &quot;Bearer&quot;라는 프로토콜 헤더에 포함되어 서버로 전송된다) 즉, Access-Token은 Bearer-Token으로써,  <u>식별 정보</u>(identification artifact)대신에 보호된 자원에 액세스할 수 있는 <u>자격 증명 정보</u>(credential artifact)로 작용한다.</p>
<p>이렇게 Access-Token은 보호된 자원에 액세스하는 데 필요한 인증 수단이지만, 해당 토큰을 가지고 있는 사람이라면 &quot;<u>누구든지</u>&quot; 해당 자원에 접근할 수 있게된다. 이러한 특성 때문에 악의적인 사용자가 시스템을 침해하고 Access-Token을 훔쳐서 보호된 자원에 접근하는 것을 막기 위한 <u>적절한 조치가 필요</u>한 것이다.</p>
<p>이러한 &quot;<u>적절한 조치</u>&quot;중 하나로, 우리는 Access-Token의 <strong>&quot;만료시간&quot;</strong>을 <strong>짧게</strong> 설정하는 것이다. 액세스 토큰은 몇 시간 또는 며칠 단위로 정의된 짧은 시간 동안만 유효하게 하는 것이다. 이렇게 하면, 기존의 Access-Token이 탈취당하더라도 새로운 Access-Token만이 자원에 액세스할 수 있으므로, 궁극적인 해결은 아닐지라도 어느 정도 보안을 해준다.</p>
</br>

<p><span style="color:red"><strong>하지만!</strong></span> 위와 같이 Access-Token의 만료시간을 짧게 설정할 경우 보안적 측면에선 좋겠지만, <strong>&quot;유저 경험&quot;</strong> 즉, <strong>UX</strong> 측면에선 굉장히 불편한 문제를 야기한다. </p>
<p>새로운 Access-Token을 받기 위해선, 해당 토큰이 만료될때마다 사용자에게 다시 로그인을 요구하게 된다. 만약 자주 사용하는 채팅 서비스 혹은 쇼핑몰 서비스를 이용할 시, 토큰의 만료에 따라 한 시간마다 계속해서 로그인을 해줘야한다고 생각해보자. 이렇게 계속 Acess-Token이 만료될 때마다 사용자에게 다시 로그인을 요구하는 것은 UX 측면에서 좋지 않은 것이 분명하다.</p>
<p>Access-Token의 &quot;UX&quot; 측면에서 이를 개선하기 위해 등장한 것이 <strong>&quot;Refresh-Token&quot;</strong>이라 할 수 있다. 서버에 저장된 Refresh-Token을 사용하여 새로운 Access-Token을 발급하고, 사용자에게 다시 로그인을 요구하지 않고 보호된 리소스에 액세스할 수 있도록 해준다.</p>
</br>

<h3 id="mechanism-of-refresh-token">&gt; <code>Mechanism of Refresh Token</code></h3>
<p>앞서, Refresh-Token은 왜 등장하게 되었는가를 알아보았으니 이젠 Refresh-Token이 인증(+인가)의 과정에서 어떠한 매커니즘으로 동작하는지에 대해 간단히 알아보자. </p>
<p>아래는 직접 나타내본 Refresh-Token의 동작 매커니즘(진행 순서)이다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/f8ee8df5-d9c9-40c0-9399-4545c80809df/image.png" alt=""></p>
<p>세세한 설명은 굳이 언급하지 않아도 위의 이미지를 통해 진행되는 순서및 동작을 이해할 수 있을것이다. </p>
<p>이제 본격적 NestJS 코드를 통해 Refresh-Token을 구현해보자.</p>
</br>

<h2 id="💥-nestjs에서-refresh-token-구현하기">💥 NestJS에서 <code>Refresh Token</code> 구현하기</h2>
<p>코드 설명에 들어가기에 앞서 JWT 토큰 기반의 인증 절차를 진행하는데 있어서 사용하게 될, <strong><code>Guard</code></strong>, <strong><code>Strategy</code></strong> 및 <strong><code>PassportModule</code></strong>에 대한 세세한 내용은 생략하고 진행하겠다. <span style="color:gray">(하지만 꼭 필요하고, 해당글을 읽는데 있어서 필요한 내용임은 분명합니다 __ 글이 너무 장황해질 것이므로 생략합니다)</span></p>
<h3 id="login-기능-구현하기">&gt; <code>Login</code> 기능 구현하기</h3>
<p>회원가입 로직은 생략하고 진행해보자.  컨트롤러의 <code>login</code> 메서드를 먼저 확인해봄으로써  Refresh-Token이 <code>login</code> 과정에서 어떻게 생성되는지 확인해보자.</p>
<p><span style="color:blue"><strong>✔ <code>login</code> 핸들러 함수 (<code>AuthController</code>)</strong></span></p>
<pre><code class="language-tsx">// auth.controller.ts

  @Post(&#39;login&#39;)
  async login(
    @Body() loginDto: LoginDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise&lt;any&gt; {
    const user = await this.authService.validateUser(loginDto);
    const access_token = await this.authService.generateAccessToken(user);
    const refresh_token = await this.authService.generateRefreshToken(user);

    // 유저 객체에 refresh-token 데이터 저장 
    await this.userService.setCurrentRefreshToken(refresh_token,user.id);

    res.setHeader(&#39;Authorization&#39;, &#39;Bearer &#39; + [access_token, refresh_token]);
    res.cookie(&#39;access_token&#39;, access_token, {
      httpOnly: true,
    });
    res.cookie(&#39;refresh_token&#39;, refresh_token, {
      httpOnly: true,
    });
    return {
      message: &#39;login success&#39;,
      access_token: access_token,
      refresh_token: refresh_token,
    };
  }</code></pre>
<ul>
<li><p>클라이언트에서 입력한 로그인 데이터를 통해 검증한 유저객체 생성</p>
</li>
<li><p>해당 유저객체를 통해 <code>Access-token</code>과 <code>Refresh-token</code> 생성</p>
</li>
<li><p>해당 <code>id</code>의 유저에 해당하는 <code>Refresh-token</code> 데이터를 <code>User</code> 테이블의 특정 컬럼에 저장한다.</p>
</li>
<li><p><code>Bearer</code>  type으로써 토큰을 요청 헤더의 <code>Authorization</code> 필드에 담아 보낸다.</p>
</li>
<li><p>&quot;express&quot;의 response 객체의 <strong><code>cookie</code></strong> 메소드를 사용하여 쿠키를 클라이언트에게 전송한다. 로그인 시엔 access_token에 대한 쿠키와 refresh_token에 대한 쿠키를 둘 다 보내주게 되는데, 이때 옵션으로 <strong><code>httpOnly</code></strong>를 <strong><code>true</code></strong>로 설정한다. 이렇게 함으로써 <code>Javascript</code> 코드에서 쿠키에 접근할 수 없도록 보호한다. (<strong><code>Xss</code></strong>- <code>Cross-site scripting</code>에 대처)</p>
</li>
<li><p>생성한 <code>access_token</code>과 함께 <code>refresh_token</code>을 응답으로 리턴한다.</p>
</li>
</ul>
</br>

<p>이렇게 로그인 라우트 핸들러 함수가 어떤 과정을 지니는지 알아보았다. 이젠 해당 함수가 사용한 서비스 모듈의 메서드들을 하나씩 알아보자.</p>
</br>

<p><span style="color:blue"><strong>✔ <code>login</code>에 필요한 메서드 (by <code>AuthService</code>)</strong></span></p>
<pre><code class="language-tsx">// authService.ts

import { BadRequestException, Injectable, NotFoundException } from &#39;@nestjs/common&#39;;
import * as bcrypt from &#39;bcrypt&#39;;
import { User } from &#39;src/users/entities/users.entity&#39;;
import { UsersService } from &#39;src/users/users.service&#39;;
import { LoginDto } from &#39;./model/login.dto&#39;;
import { Payload } from &#39;./payload/payload.interface&#39;;
import { ConfigService } from &#39;@nestjs/config&#39;;

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UsersService,
    private readonly jwtService: JwtService,  
    private readonly configService: ConfigService,
  ) {}

  async validateUser(loginDto: LoginDto): Promise&lt;User&gt; {
    const user = await this.userService.findUserByEmail(loginDto.email);

    if (!user) {
      throw new NotFoundException(&#39;User not found!&#39;)
    }

    if (!await bcrypt.compare(loginDto.password, user.password)) {
      throw new BadRequestException(&#39;Invalid credentials!&#39;);
    }

    return user;
  } 

  async generateAccessToken(user: User): Promise&lt;string&gt; {
    const payload: Payload = {
      id: user.id,
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
    }
    return this.jwtService.signAsync(payload);
  }

  async generateRefreshToken(user: User): Promise&lt;string&gt; {
    const payload: Payload = {
      id: user.id,
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
    }
    return this.jwtService.signAsync({id: payload.id}, {
      secret: this.configService.get&lt;string&gt;(&#39;JWT_REFRESH_SECRET&#39;),
      expiresIn: this.configService.get&lt;string&gt;(&#39;JWT_REFRESH_EXPIRATION_TIME&#39;),
    });
  }

}</code></pre>
<p><code>Access-Token</code>을 생성하는데 있어서 필요한 옵션값 (<code>secret-key</code>, <code>expiresIn</code>)들은 <code>Refresh-Token</code>과 다르게 직접 지정해주지 않았다. </p>
<p><code>Access-Token</code>에 필요한 정보들은 <code>AuthModule</code> 단에서 <code>JwtModule</code> 설정을 통해서 동적으로 받도록 한다.</p>
<pre><code class="language-tsx">// auth.module.ts

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    TypeOrmExModule.forCustomRepository([UsersRepository]),
    PassportModule.register({}),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) =&gt; ({
        secret: configService.get&lt;string&gt;(&#39;JWT_ACCESS_SECRET&#39;),
        signOptions: {
          expiresIn: configService.get&lt;string&gt;(&#39;JWT_ACCESS_EXPIRATION_TIME&#39;),
        } 
      }),
      inject: [ConfigService],
    }),
    forwardRef(() =&gt; UsersModule),
  ],
  controllers: [AuthController],
  providers: [AuthService, UsersService, JwtRefreshStrategy, JwtAccessAuthGuard, JwtRefreshGuard],
})
export class AuthModule {}
</code></pre>
<p>또한, <strong><code>.env</code></strong> 파일 내부에서 jwt 토큰 생성에 필요한 시크릿 키와 만료시간을 보관해준다.</p>
<pre><code class="language-env"># JWT Options
JWT_ACCESS_SECRET=myaccesssecretkey
JWT_REFRESH_SECRET=myrefreshsecretkey

JWT_ACCESS_EXPIRATION_TIME=20000
JWT_REFRESH_EXPIRATION_TIME=1800000</code></pre>
<p>우리는 <strong><code>@nestjs/config</code></strong>에서 제공하는 <strong><code>ConfigModule</code></strong>의 <strong><code>ConfigService</code></strong>를 통해서 동적으로 해당 옵션값들을 받아올 수 있다.</p>
<p><strong>※</strong> <span style="color:gray">해당 <strong><code>ConfigModule</code></strong>과 <strong><code>ConfigService</code></strong>를 <strong><code>JwtModule</code></strong> 내부에서 사용하기 위해 꼭 <code>imports</code>를 통해 불러오고, 서비스를 의존성 주입해주는 것을 기억하자.</span></p>
</br>


<p>또한, JWT 토큰을 생성하는 과정에 있어서 <code>Access-Token</code>과 <code>Refresh-Token</code> 값의 형태를 <u><strong>다르게</strong></u> 하였다.</p>
<pre><code class="language-ts">     // Access-Token 생성
    return this.jwtService.signAsync(payload);

    // Refresh-Token 생성
    return this.jwtService.signAsync({id: payload.id}, {
      // ~~
    });
</code></pre>
<p><code>Access-Token</code> 생성시엔 <strong><code>Payload</code></strong> 객체를 온전히 받아와주었지만, <code>Refresh-Token</code> 생성시엔 <strong><code>Payload</code>의 <code>id</code>값</strong>만 받아와주었다. 즉, <code>Payload</code>와 매핑한 온전한 유저 데이터를 전부 <u>사용하지 않도록</u> 하였다. </p>
<hr>
<p><strong>※ 참고 - &quot;Payload&quot;</strong></p>
<pre><code class="language-tsx">export interface Payload {
  id: number;
  email: string;
  firstName: string;
  lastName: string;
}</code></pre>
<hr>
<p><code>Refresh-Token</code> 값을 생성하는데있어 위와 같은 룰을 적용한 큰(혹은 정확한) 이유는 없지만, <strong><code>Refresh-Token</code></strong>엔 굳이 유저의 정보가 담겨져 있을 필요가 <span style="color:red">없다고</span> 판단하였다.</p>
<p>즉, 해당 <code>Refresh-Token</code>이 어떠한 유저의 토큰인지를 알 수 있는 <strong>&quot;식별자&quot;</strong>(여기선 <code>id</code>값 선택) 정도만 포함시키도록 하였다. <code>Refresh-Token</code>이 노출되어도 해당 토큰에서 사용자의 정보를 모두 보여주게 되는것 보단, 보안상 유출의 위험이 줄어들 것이다.</p>
</br>

<p><span style="color:blue"><strong>✔ Insert <code>Current-Refresh-Token</code> to Database (UserService) &lt;중요!!&gt;</strong></span></p>
<p>로그인을 통한 인증(Authentication)시에 생성하게 된 <code>Refresh-Token</code>을 데이터베이스에 저장시켜줘야한다. 우린 로그인 라우트 함수에서 아래와 같은 작업을 통해 수행해주었다.</p>
<pre><code class="language-tsx">// auth.controller.ts
// 첫 번째 인자는 생성한 refresh_token, 두 번째는 해당 유저의 id
await this.userService.setCurrentRefreshToken(refresh_token,user.id);</code></pre>
<p>사용하게 된 <code>setCurrentRefreshToken()</code> 메서드는 유저 정보에 관한 처리이므로 <strong><code>UserService</code></strong>에서 작성해주었다.</p>
<pre><code class="language-tsx">  // user.service.ts
  async setCurrentRefreshToken(refreshToken: string, userId: number) {
    const currentRefreshToken = await this.getCurrentHashedRefreshToken(refreshToken);
    const currentRefreshTokenExp = await this.getCurrentRefreshTokenExp();
    await this.userRepository.update(userId, {
      currentRefreshToken: currentRefreshToken,
      currentRefreshTokenExp: currentRefreshTokenExp,
    });
  }</code></pre>
<p><code>getCurrentHashedRefreshToken()</code>, <code>getCurrentRefreshTokenExp()</code>를 통해 현재 Refresh-Token값과 해당 토큰의 만료시간을 받아온다. </p>
<pre><code class="language-tsx">  async getCurrentHashedRefreshToken(refreshToken: string) {
    // 토큰 값을 그대로 저장하기 보단, 암호화를 거쳐 데이터베이스에 저장한다. 
    // bcrypt는 단방향 해시 함수이므로 암호화된 값으로 원래 문자열을 유추할 수 없다. 
    const saltOrRounds = 10;
    const currentRefreshToken = await bcrypt.hash(refreshToken, saltOrRounds);
    return currentRefreshToken;
  }

  async getCurrentRefreshTokenExp(): Promise&lt;Date&gt; {
    const currentDate = new Date();
      // Date 형식으로 데이터베이스에 저장하기 위해 문자열을 숫자 타입으로 변환 (paresInt) 
    const currentRefreshTokenExp = new Date(currentDate.getTime() + parseInt(this.configService.get&lt;string&gt;(&#39;JWT_REFRESH_EXPIRATION_TIME&#39;)));
    return currentRefreshTokenExp;
  }
</code></pre>
  </br>


<p>  또 하나 눈여겨 볼 부분은, 토큰을 저장할 때 <code>typeorm</code>의 <code>save()</code>를 통해 저장하는것이 아니라 <code>update()</code>를 해주도록 하였다. </p>
<p>  그 이유를 설명하기 전에 먼저 <code>User</code> 테이블을 살펴보자.</p>
<pre><code class="language-tsx">  // user.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from &#39;typeorm&#39;;

@Entity({name:&#39;users&#39;})
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    // ...

    @Column({ nullable: true })
    currentRefreshToken: string;

    @Column({ type: &#39;datetime&#39;, nullable: true })
    currentRefreshTokenExp: Date;
}</code></pre>
<p>  보기와 같이, <code>currentRefreshToken</code>과 <code>currentRefrehTokenExp</code>의 값은 <strong><code>null</code></strong>을 <strong>허용</strong>하도록 해주었다. 즉, default값은 <strong><code>null</code></strong>일 것이다.</p>
<p>  이로 인해, 업데이트를 통해 <strong><code>null</code></strong> -&gt; <strong><code>hashed token value</code></strong> -&gt; <strong><code>null(logout)</code></strong>값을 반복해서 가지게 될 것이다. (만료시간 역시 마찬가지다)</p>
</br>

<h3 id="guard-주입을-통한-유저-권한-확인하기">&gt; <code>Guard</code> 주입을 통한 유저 권한 확인하기</h3>
<p>&quot;Refresh-Token&quot;을 통해 &quot;Access-Token&quot;을 재발급 받기에 앞서, 먼저 Access-Token을 통한 <strong>인가(Authorization)</strong>에 접근하는 코드를 구현해보자. 우린 이를 통해 조금 더 실용적이고 눈에 보이는 상황을 만들어 볼 수 있을 것이다.</p>
<p><span style="color:blue"><strong>✔ <code>authenticate</code> 핸들러 함수 (AuthController)</strong></span></p>
<pre><code class="language-tsx">// auth.controller.ts

  @Get(&#39;authenticate&#39;)
  @UseGuards(JwtAccessAuthGuard)
  async user(@Req() req: any, @Res() res: Response): Promise&lt;any&gt; {
    const userId: number = req.user.id; 
    const verifiedUser: User = await this.userService.findUserById(userId);
    return res.send(verifiedUser);
  }</code></pre>
<p><code>authenticate</code> url로 접근해 만약 가드를 통과하면 인증된 유저 객체를 반환하고, 그렇지 않을경우 가드에서 설정한 <code>false</code> 반환으로 인한 에러를 띄우도록 한다.</p>
<p>가드는 아래와 같다. 유저의 권한을 확인하는데 있어서, 오로지 <strong><code>Access-Token</code></strong> 만이 사용된다. <code>Refresh-Token</code>은 무관하다.</p>
</br>

<p><span style="color:blue"><strong>✔ <code>JwtAccessAuthGuard</code> 구현</strong></span></p>
<pre><code class="language-tsx">// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from &quot;@nestjs/common&quot;;
import { JwtService } from &quot;@nestjs/jwt&quot;;

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise&lt;any&gt; {
    try {
      const request = context.switchToHttp().getRequest();
      const access_token = request.cookies[&#39;access_token&#39;];
      const user = await this.jwtService.verify(access_token);
      request.user = user;
      return user;
    } catch(err) {
      return false;
    }
  }
}</code></pre>
<p>일반적으로, 공식문서에서 <strong><code>PassportModule</code></strong>을 통한 <code>JWT</code> 인증 방법으로 <strong>&quot;Guard(가드)&quot;</strong>와 가드가 사용하게 되는 <strong>&quot;Strategy(전략)&quot;</strong>를 통해 구현할 것을 제시한다.</p>
<p>하지만, 너무 해당 방법에 얽매일 필요는 없다고 생각한다. 유저 인증을 하는 로직을 꼭 <strong>&quot;Strategy&quot;</strong>에서 작성해야할까? 그건 아니다. 우린 진작 <code>authService</code>에서 이를 구현해주었고, 또 충분히 서비스단에서 구현해줄 수 있다.</p>
<p>본인은, &quot;Access-Token&quot;에 있어서는 굳이 <strong><code>PassportModule</code></strong>을 <u>사용할 필요가 없다고</u> 생각하여 <strong><code>CanActivate</code></strong>를 확장하여 가드를 작성해주었다.</p>
<p>클라이언트의 요청 시, 쿠키에 담긴 토큰을 검증해주고 만약 해당 토큰이 유효하다면 해당하는 유저객체를 리턴하도록 한다. </p>
<pre><code class="language-tsx">request.user = user;</code></pre>
<p>위에 작성된것 처럼, request의 user 프로퍼티와 우리가 <code>jwtService.verify()</code>를 통해 검증해준 user 객체를 <strong>동일시</strong> 해주는 작업이 <strong>중요!!</strong> 하다.<span style="color:gray"> (이를 수행하지 않는다면 컨트롤러의 라우트 핸들러 함수에서 <code>req.user</code>를 <code>undefined</code>로 받게 될 것이다...)</span></p>
<p>이렇게 작성된 <code>JwtAccessAuthGuard</code>를 통한 권한 인증의 테스트는 추후 포스트맨을 통한 테스트 부분에서 알아보도록 하고 다음 과정으로 넘어가자.</p>
</br>

<h3 id="refresh-token-을-통한-access-token-재발급">&gt; <code>Refresh Token</code> 을 통한 <code>Access Token</code> 재발급</h3>
<p>바로 위에서 언급하였다시피, 마지막에 포스트맨을 통한 전체 인증과정 진행을 보여주겠지만 일단은 앞선 로그인 과정에서 <strong>&quot;Access-Token&quot;</strong>과 <strong>&quot;Refresh Token&quot;</strong>을 둘 다 응답받는다는 것을 알아두자.</p>
<pre><code>JWT_ACCESS_EXPIRATION_TIME=20000</code></pre><p>앞서 <code>Access-Token</code>의 만료시간을 위와 같이 <strong>20초</strong>(20000ms)로 설정해주었다. 즉, 20초가 지난다면 우린 만료시간 설정으로 인해 해당 <code>Access-Token</code>을 사용할 수 없게된다. </p>
<p>자, 이제 우리는 로그인 시 응답받은 <strong><code>refresh_token</code></strong>을 사용할 시간이다.</p>
</br>

<p><span style="color:blue"><strong>✔ <code>refresh()</code> 핸들러 함수 (AuthController)</strong></span></p>
<p>새로운 &quot;access_token&quot;을 받기 위한 라우트 핸들러 함수이다. Body(전문)에 앞서 로그인 시 부여받은 &quot;refresh_token&quot; 값을 전달해 새로운 access_token(<code>newAccessToken</code>)을 응답받을 것이다. 만약 오류가 생긴다면(잘못된 값, refresh_token의 만료시간 경과) 간단히 <code>catch</code>문을 통해 예외처리를 시켜준다.</p>
<hr>
<p>** ※ 참고 - <code>RefreshTokenDto</code>**</p>
<pre><code class="language-tsx">// refresh-token.dto.ts
import { IsNotEmpty } from &quot;class-validator&quot;;

export class RefreshTokenDto {
  @IsNotEmpty()
  refresh_token: string;
}</code></pre>
<hr>
<pre><code class="language-tsx">// auth.controller.ts

  @Post(&#39;refresh&#39;)
  async refresh(
    @Body() refreshTokenDto: RefreshTokenDto,
    @Res({ passthrough: true }) res: Response,
  ) {
    try {
      const newAccessToken = (await this.authService.refresh(refreshTokenDto)).accessToken;
      res.setHeader(&#39;Authorization&#39;, &#39;Bearer &#39; + newAccessToken);
      res.cookie(&#39;access_token&#39;, newAccessToken, {
        httpOnly: true,
      });
      res.send({newAccessToken});
    } catch(err) {
      throw new UnauthorizedException(&#39;Invalid refresh-token&#39;);
    }
  }</code></pre>
<p><strong><code>AuthService</code></strong>에서 정의해준 <strong><code>refresh()</code></strong> 함수를 통해 새로운 <code>newAccessToken</code>을 얻게된다. 그 후, <code>login()</code> 시와 마찬가지로 새로운 토큰을 <strong>응답 헤더</strong>를 통해 클라이언트에게 Bearer type으로 보내주고, <strong><code>cookie</code></strong>에도 설정해준다.</p>
</br>

<p><span style="color:blue"><strong>✔ <code>refresh()</code> 함수 (AuthService)</strong></span></p>
<pre><code class="language-tsx">// auth.service.ts

  async refresh(refreshTokenDto: RefreshTokenDto): Promise&lt;{ accessToken: string }&gt; {
    const { refresh_token } = refreshTokenDto;

    // Verify refresh token
    // JWT Refresh Token 검증 로직
    const decodedRefreshToken = this.jwtService.verify(refresh_token, { secret: process.env.JWT_REFRESH_SECRET }) as Payload;

    // Check if user exists
    const userId = decodedRefreshToken.id;
    const user = await this.userService.getUserIfRefreshTokenMatches(refresh_token, userId);
    if (!user) {
      throw new UnauthorizedException(&#39;Invalid user!&#39;);
    }

    // Generate new access token
    const accessToken = await this.generateAccessToken(user);

    return {accessToken};
  }</code></pre>
<p><code>AuthService</code>에서 작성한 위의 <code>refresh()</code> 메서드의 경우엔 <code>RefreshGuard</code>를 통해 나타낼 수도 있겠지만, 서비스의 메서드를 호출하는 방식으로 직접 구현해보았다. </p>
<p>로그인 시 생성해 발급받은 <code>refresh_token</code>을 <code>jwtService.verify()</code>를 통해 올바른 JWT 형식인지 검증을 하고, <strong><code>Payload</code> 타입으로써 단언</strong>하여준다.
이렇게 함으로써 <code>decodedRefreshToken</code>이 <code>Payload</code> 객체를 준수한 타입을 가짐을 명시한다.</p>
<p><u>새로 발급받을</u> <code>accessToken</code>을 생성하는데에 있어서 우린 앞서 만들어준 <strong><code>generateAccessToken()</code></strong> 메서드를 그대로 사용할 것인데, 이때 인자로써 <code>User</code> 객체가 필요하다.</p>
<p>해당 user 객체는 단순히 불러오기 보단, 데이터베이스 내부 유저 테이블의<code>refresh_token</code>값과 / 요청 시 Body(전문)에 실어준 <code>refresh_token</code>값이 <u style="color:red">일치하는지의 과정</u>을 거친 user 객체를 불러올 필요가 있다.
이 작업을 우린 <code>UserService</code>의 <strong><code>getUserIfRefreshTokenMatches()</code></strong> 메서드를 통해 아래와 같이 정의할 수 있다.</p>
<pre><code class="language-tsx">  // user.service.ts

  async getUserIfRefreshTokenMatches(refreshToken: string, userId: number): Promise&lt;User&gt; {
    const user: User = await this.findUserById(userId);

    // user에 currentRefreshToken이 없다면 null을 반환 (즉, 토큰 값이 null일 경우)
    if (!user.currentRefreshToken) {
      return null;
    }

    // 유저 테이블 내에 정의된 암호화된 refresh_token값과 요청 시 body에 담아준 refresh_token값 비교
    const isRefreshTokenMatching = await bcrypt.compare(
      refreshToken,
      user.currentRefreshToken
    );

    // 만약 isRefreshTokenMatching이 true라면 user 객체를 반환
    if (isRefreshTokenMatching) {
      return user;
    } 
  }</code></pre>
</br>

<p>자, 이제 우린 이렇게 <code>Refresh-Token</code>을 통해 새로 발급받은 <code>Access-Token</code>으로 인증이 필요한 권한 (인가)에 접근할 수 있게 된다. </p>
</br>

<h3 id="guard를-통한-logout-구현">&gt; <code>Guard</code>를 통한 <code>Logout</code> 구현</h3>
<p>로그아웃은 nestjs에서 제시하는, <strong><code>PassportModule</code></strong>을 사용해 <strong><code>Guard</code></strong>와 <strong><code>Strategy</code></strong>로써 유저 권한을 검증할 것이다. </p>
<p>또한 <u>로그아웃시엔</u>, 로그인 시 생성되어 유저 테이블에 저장된 <code>currentRefreshToken</code>값을 <strong><code>null</code></strong>로 수정하고 access-token과 refresh-token에 해당하는 <strong>cookie</strong>를 모두 <strong>삭제</strong>하여준다.</p>
</br>

<p><span style="color:blue"><strong>✔ <code>logout</code> 핸들러 함수 (AuthController)</strong></span></p>
<pre><code class="language-tsx">  // auth.controller.ts

  @Post(&#39;logout&#39;)
  @UseGuards(JwtRefreshGuard)
  async logout(@Req() req: any, @Res() res: Response): Promise&lt;any&gt; {
    await this.userService.removeRefreshToken(req.user.id);
    res.clearCookie(&#39;access_token&#39;);
    res.clearCookie(&#39;refresh_token&#39;);
    return res.send({
      message: &#39;logout success&#39;
    });
  }</code></pre>
<p><span style="color:blue"><strong>✔ <code>JwtRefreshGuard</code> - Guard</strong></span></p>
<pre><code class="language-tsx">// jwt-refresh.guard.ts

import { Injectable } from &#39;@nestjs/common&#39;;
import { AuthGuard } from &#39;@nestjs/passport&#39;;

@Injectable()
export class JwtRefreshGuard extends AuthGuard(&#39;jwt-refresh-token&#39;) {}</code></pre>
<p><span style="color:blue"><strong>✔ <code>JwtRefreshStrategy</code> - Strategy</strong></span></p>
<p>아래는 위의 <code>JwtRefreshGuard</code>가 사용하게 될 전략이다.</p>
<pre><code class="language-tsx">// jwt-refresh.strategy.ts

import { Injectable } from &quot;@nestjs/common&quot;;
import { PassportStrategy } from &quot;@nestjs/passport&quot;;
import { ExtractJwt, Strategy } from &quot;passport-jwt&quot;;
import { UsersService } from &quot;src/users/users.service&quot;;
import { Payload } from &quot;../payload/payload.interface&quot;;
import { Request } from &quot;express&quot;;
import { User } from &quot;src/users/entities/users.entity&quot;;

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, &#39;jwt-refresh-token&#39;) {
  constructor(
    private readonly userService: UsersService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (request) =&gt; {
          return request?.cookies?.refresh_token;
        },
      ]),
      secretOrKey: process.env.JWT_REFRESH_SECRET,
      passReqToCallback: true,
    })
  }

  async validate(req: Request, payload: Payload) {
    const refreshToken = req.cookies[&#39;refresh_token&#39;];
    const user: User = await this.userService.getUserIfRefreshTokenMatches(
      refreshToken,
      payload.id
    );
    return user;
  }
}</code></pre>
<p><strong><code>JwtRefreshStrategy</code></strong>는 <code>Passport</code>의 <strong><code>Strategy</code></strong>를 상속받은 클래스이다.</p>
<p><code>super()</code> 내부에서 해당 <code>Strategy</code>를 상속받는데에 있어서 필요한 설정을 지정할 수 있다.</p>
<p><strong><code>jwtFromRequest</code></strong> 옵션은 HTTP 요청에서 JWT를 추출할 위치를 지정한다. <strong><code>ExtractJwt.fromExtractos()</code></strong> 를 사용하여 <code>request</code> 객체의 쿠키에서 <code>refresh_token</code> 값을 추출하도록 설정하였다.</p>
<p>다음으로 중요한 부분은 <strong><code>passReqToCallback</code></strong>을 <strong><code>true</code></strong>로 설정해야 한다는 것이다.</p>
<p><strong><code>passReqToCallback</code></strong> 옵션은 <strong><code>validate</code></strong> 함수의 첫 번째 인자로 요청(request)객체를 전달할지 여부를 결정한다. 즉, 이 옵션을 <code>true</code>로 설정하면 <code>validate</code> 함수의 <u>첫 번째 인자</u>로 <code>request</code> 객체를 전달할 수 있다.</p>
<p>이에 따라, <code>req.cookies[&#39;refresh_token&#39;]</code> 해당 코드를 사용할 수 있게 된다.</p>
<p>이렇게 요청 시 쿠키를 통해 서버로 전달해준 <code>refreshToken</code> 값이 DB의 테이블내에 저장된 <code>currentRefreshToken</code>과 일치하는지 검증하기 위해 우린 앞서 <code>refresh()</code>에서 다뤄준것과 동일하게 <code>getUserIfRefreshTokenMatches()</code> 함수를 사용하여 비교한다.</p>
<p>이처럼 우린 단순히 <u>서비스 내부에서 토큰 검증을 해줄 수도 있지만</u>, 위와 같이 <code>Passport</code>를 이용해 <strong>&quot;Guard&quot;</strong>와 <strong>&quot;Strategy&quot;</strong>를 통해 검증해 줄 수도 있다.</p>
</br>


<p><em><strong>다시</strong></em>, 컨트롤러에서 정의한 <strong>logout</strong> 함수를 확인해보면</p>
<pre><code class="language-tsx">  // auth.controller.ts

  @Post(&#39;logout&#39;)
  @UseGuards(JwtRefreshGuard)
  async logout(@Req() req: any, @Res() res: Response): Promise&lt;any&gt; {
    await this.userService.removeRefreshToken(req.user.id);
    res.clearCookie(&#39;access_token&#39;);
    res.clearCookie(&#39;refresh_token&#39;);
    return res.send({
      message: &#39;logout success&#39;
    });
  }</code></pre>
<p>아래와 같이 <strong><code>removeRefreshToken</code></strong>의 인자로써 <strong><code>req.user.id</code></strong>를 받는 것을 확인할 수 있다. <code>req</code>는 any 타입이지만, 우리는 가드와 해당 가드가 사용하는 Strategy를 통해 <strong><code>request</code></strong>의 프로퍼티로 <strong><code>user</code></strong> 객체를 불러올 수 있게 되었다. </p>
<p>따로 <code>user.id</code>를 불러오는 함수 혹은 객체를 호출할 필요없이 가드를 통해 깔끔하게 받아오게 된 것이다.</p>
<pre><code class="language-tsx">await this.userService.removeRefreshToken(req.user.id);</code></pre>
</br>

<p><span style="color:blue"><strong>✔ remove refresh-token from user table - (UserService)</strong></span></p>
<p>바로 위에서 언급한 <code>removeRefreshToken</code> 함수를 알아보자. 단순하다. 로그아웃 시 <code>refresh-token</code>과 관련된 데이터 값을 전부 <strong><code>null</code></strong>로 바꿔준다.</p>
<pre><code class="language-tsx">  // user.service.ts
  async removeRefreshToken(userId: number): Promise&lt;any&gt; {
    return await this.userRepository.update(userId, {
      currentRefreshToken: null,
      currentRefreshTokenExp: null,
    });
  }</code></pre>
</br>

<h3 id="💨-postman을-통한-테스트">&gt; 💨 <code>Postman</code>을 통한 테스트</h3>
<p><span style="color:blue">*<em>✔ login *</em></span></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/a00fba04-54fc-48ae-ac95-665ffaf65035/image.png" alt=""></p>
<p>로그인 성공 시에 &quot;Access-Token&quot;과 &quot;Refresh-Token` 값을 받게된다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/78efe414-8faa-41a2-b30c-ae7992db0b31/image.png" alt=""></p>
<p>쿠키에도 잘 저장된 것을 확인할 수 있다.</p>
</br>

<p><span style="color:blue">*<em>✔ login 후 유저 테이블 확인 *</em></span></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/d2e4290c-748b-42bf-bd9d-e86dfee431d7/image.png" alt=""></p>
<p><span style="color:blue">*<em>✔ Access-Token을 통한 권한 인증 *</em></span></p>
<p>인증에 성공하였을 시 (가드를 통과하였을 시) 유저 데이터 반환</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2376786f-f064-47fd-82b5-72bc87aa614d/image.png" alt=""></p>
<p><span style="color:blue">*<em>✔ Access-Token을 통한 권한 인증 - Access-Token의 만료 시간이 지났을 시 *</em></span></p>
<p>지정해준 토큰의 만료시간이 지나게 되면 가드를 통과하지 못하고 권한 접근 에러를 응답한다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/812c5b22-c97b-4fdd-8860-4e6ed6dcd746/image.png" alt=""></p>
<p><span style="color:blue">*<em>✔ Refresh-Token을 통한 Access-Token 재발급 *</em></span></p>
<p>앞서 로그인 시 부여받은 <strong><code>Refresh-Token</code></strong> 값을 통해 새로운 <strong><code>Access-Token</code></strong>을 부여받을 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/e5184ba5-c3e3-434c-aa57-d64bf36bab92/image.png" alt=""></p>
<p><span style="color:blue">*<em>✔ 권한에 재접근 - 새로운 Access Token을 통해 *</em></span></p>
<p>새로 부여받은 Access-Token을 통해 다시 권한에 접근할 수 있다. 새로운 Access-Token은 쿠키와 헤더에 담겨 전달된다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/37af51cc-04c0-488c-91e3-e1a015b5e682/image.png" alt=""></p>
<p><span style="color:blue">*<em>✔ logout - 토큰 삭제 *</em></span></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/223c0ae7-20cc-4741-8ceb-15e92b14ec32/image.png" alt=""></p>
<p>로그아웃 요청이 성공된다면 위와 같이 쿠키가 전부 빈 것을 확인할 수 있다. 동시에 테이블의 토큰 값 및 만료시간 또한 <code>null</code>로 수정되어 있을 것이다.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/2654ff4a-8172-4564-89a3-a6f642061699/image.png" alt=""></p>
</br>

<h2 id="생각정리">생각정리</h2>
<p>이번 포스팅에서 우린 보안적 측면과 UX(유저 경험)를 고려하여 <strong>&quot;Refresh-Token&quot;</strong>을 통한 인증을 구현해보았다.</p>
<p>Refresh-Token을 구현하는데 있어, 세밀한 작업까진 수행하지 않았지만 어떻게 해당 토큰을 관리하고 인가(Authorization)에 적용할 지에 관해 고민해보았다.</p>
<p>NestJS 공식문서에선 JWT 토큰을 통한 인증 구현법을 설명할 때, <strong><code>PassportModule</code></strong>을 통한 Guard와 Strategy 패턴으로써 해당 구현을 제시한다. 또한, NestJS를 통해 처음 인증을 구현할 때 찾아보게 될 여러 블로그들에서 해당 구현법을 제시한다.</p>
<p>물론 좋은 방법은 맞지만, 가드와 전략이 어떻게 소통하는지, 그리고 각각의 책임은 무엇인지에대해 모르고 사용할 경우 사용한것만 못하다는 생각이 들었다.</p>
<p>서비스를 통해 토큰 및 유저의 검증로직을 작성할 수 있지만, 조금 더  가독성있고 간결하고 재사용성을 고려한 코드를 만들기 위해 <strong><code>Passport</code></strong>와 <strong><code>Strategy-Pattern</code></strong>을 사용하게 된다. 이러한 이유를 확실히 알 필요가 있다.</p>
<p>바로 다음 포스팅이 될 진 모르겠지만, 단순 <strong><code>Refresh-Token</code></strong> 구현에서 그치지 않고 조금 더 심화있게 들어가보고 더 나은 유저 인증을 위한 내용을 다뤄보도록 할 예정이다.</p>
<hr>
<p><strong>※ 참고자료</strong></p>
<p><a href="https://wanago.io/2020/09/21/api-nestjs-refresh-tokens-jwt/">Implementing refresh tokens using JWT</a></p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NestJS] Request-Timeout handling with Interceptor & RxJS]]></title>
            <link>https://velog.io/@from_numpy/NestJS-Request-Timeout-handling-with-Interceptor-Rxjs</link>
            <guid>https://velog.io/@from_numpy/NestJS-Request-Timeout-handling-with-Interceptor-Rxjs</guid>
            <pubDate>Tue, 11 Apr 2023 14:43:22 GMT</pubDate>
            <description><![CDATA[<h2 id="💥-시작하기에-앞서">💥 시작하기에 앞서</h2>
<p>공부 중인 나로써는 대용량 데이터및 대규모 트래픽을 경험해보기 쉽지않다. 엇비슷하게 많은 양의 데이터를 조회하는 요청을 경험해보고자 일전에 <strong>&quot;100000&quot;</strong>건의 데이터를 더미데이터로 생성함으로써 요청에 대한 응답을 받는 과정을 수행해보았었다. 그 당시엔 페이지네이션을 통해 응답 데이터를 분할해서 조회해보았지만, 문득 해당 데이터를 전부 한번에 받게 되면 어떨까 생각하였다. </p>
<p>물론, <strong>100000</strong>건의 데이터라는 것이 그리 많은 양의 데이터는 아니지만 한번에 조회하는 것은 일반적인 요청에 비해 소요시간이 많이 드는 작업이란 것은 확실하다. 실제로 본인의 네트워크 및 여러 부가적 상황에 따라 <strong><code>3s</code></strong>에서 길게는 <strong><code>10~15s</code></strong>의 시간이 소요되는 것을 확인할 수 있었다. </p>
<p>일반적으론 위에서 언급한 것처럼, <strong>&quot;페이지네이션&quot;</strong>이라는 작업을 통해 데이터를 불러오는 것이 일반적이지만, 특정 작업 (마이그레이션, 외부API 연동, ... 여러 case)에 따라 요청에 따른 응답 시간이 지연되는 경우는 충분히 일어나게 된다. 더군다나, 특정 요청의 데이터양은 많지 않더라도 특정 API 요청에서 <strong>&quot;동시 다발적으로&quot;</strong> 많은 유저가 몰릴 경우 서버 부하에 따라 응답 시간이 지연되게 된다.</p>
<p>여태 <strong>&quot;유저&quot;</strong>의 입장이었던 나로써는 <strong>클라이언트단</strong>에서 일련의 <strong>&quot;유저 경험&quot;</strong>을 위한 작업에 따라 &quot;<u>로딩 중</u>&quot;인 페이지를 보게 되었고, 혹은 특정 시간 이상 시 &quot;<u>리다이렉트</u>&quot; 과정을 통해 다른 페이지로 이동하는 경우도 경험할 수 있었다. 혹은, 특정 모달을 띄움으로써 유저로써 &quot;응답 지연&quot;임을 확인할 수 있게끔 알려주기도 한다.</p>
<p>흔히, 어디선가 들어본 <span style="color:red"><strong>&quot;408 Request Timeout&quot;</strong></span>이 이에 대한 에러 핸들링이다.</p>
<p>그럼 이때, <strong>&quot;서버&quot;</strong>의 경우엔 <u>어떠한 일련의 처리를 수행하는지</u> 궁금증이 생기기 마련이다. </p>
<p>조금 더 명확히 말하자면, 클라이언트가 위의 같은 일련의 처리를 수행할 수 있도록 서버측에선 지연이 발생하고 있다는 응답을 보내주어야할 것이다. 계속해서 응답 지연이 일어남에도 불구하고 이러한 처리를 해주지 않는다면, 서버의 부하는 계속해서 증가할 것이고 다른 작업및 요청에도 영향을 끼칠 것이다. </p>
<p>결국, 이는 <strong>&quot;유저 경험&quot;</strong>과 치밀하게 연관이 있다. 우리는 이러한 처리를 <span style="color:red"><strong>&quot;Timeout Exception Handling&quot;</strong> </span>이라 부른다. </p>
<p>물론 해당 처리는 클라이언트에서도 중요한 작업이지만, 서버측에서도 이에 못지 않게 중요한 과정에 해당한다. </p>
<p>이번 포스팅에선 <span style="color:red"><strong>&quot;Timeout Exeception Handling&quot;</strong> </span>을 구현해보는 것은 물론이지만, <span style="color:blue"><strong>&quot;RxJS&quot;</strong></span>란 것에 대해서도 알아볼 예정이다. </p>
<p><span style="color:blue"><strong>&quot;RxJS&quot;</strong></span>를 통한 <strong>&quot;리액티브 프로그래밍(Reacitve Programming)&quot;</strong>을 사용해 더 효율적인 핸들링이 가능하다는 것 또한 함께 알아보고자 한다.</p>
<p><em>갑자기 왠 &quot;RxJS&quot;냐</em> 할 수 있지만, <strong>왜?(why)</strong> 해당 라이브러리 및 개념을 설명하게 되었는지 이번 글을 통해서 알게 될 것이다.</p>
</br>

<h2 id="💥-nestjs가-바라본-rxjs">💥 <code>NestJS</code>가 바라본 <code>RxJS</code></h2>
<p><strong><code>Timeout</code> *<em>예외 처리를 구현하는데 있어서 <u>요청 응답 주기에 접근해야하므로</u> 우린 *</em><code>nodejs</code></strong> 및 <strong><code>nestjs</code></strong>에선 <strong>&quot;Middleware&quot;</strong>를 통해 이를 수행할 수 있다.</p>
<p>직접적으로 <code>NestMiddleware</code>를 통한 미들웨어로써 구현하게 되면 <code>AppModule</code>에 불러옴으로써 전역적으로 적용시킬 수 있다는 장점이 있지만, 다른 <code>enhancer</code>와 충돌이 생길 수도 있으며 동시에 각 라우트 핸들러 마다 개별적으로 적용하는데 있어 <u>불편함이 있다</u>.</p>
<p>여태껏 <code>NestJS</code>에서 <strong><code>Middleware</code></strong>와 <strong><code>Interceptor</code></strong>를 소개하고 어떤 것을 지양해야하는지 많이 다뤄보았었고, 이번에도 역시 타임아웃 핸들링을 하는데 있어 <strong><code>Interceptor</code></strong>로써 구현하기로 하였다. </p>
</br>

<h3 id="공식문서에서-제시하는-timeoutinterceptor">&gt; 공식문서에서 제시하는 <code>TimeoutInterceptor</code></h3>
<p>아래는 <strong>&quot;NestJS&quot;</strong> 공식문서에서 예시로써 소개하는 <strong><code>TimeoutInterceptor</code></strong>이다.</p>
<p><em>&quot; The possibility of manipulating the stream using RxJS operators gives us many capabilities. Let&#39;s consider another common use case. Imagine you would like to handle timeouts on route requests. When your endpoint doesn&#39;t return anything after a period of time, you want to terminate with an error response. The following construction enables this: &quot;</em></p>
<p>해당 문구에선 &quot;경로 요청&quot;의 시간 제한(<strong><code>timeout</code></strong>)에 대한 예시를 제시하며 <strong><code>RxJS</code></strong>를 활용하여 <u>스트림을 조작하는 기능</u>과 관련된 사례라 언급한다. </p>
<pre><code class="language-tsx">// timeout.intercept.ts

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from &#39;@nestjs/common&#39;;
import { Observable, throwError, TimeoutError } from &#39;rxjs&#39;;
import { catchError, timeout } from &#39;rxjs/operators&#39;;

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable&lt;any&gt; {
    return next.handle().pipe(
      timeout(5000),
      catchError(err =&gt; {
        if (err instanceof TimeoutError) {
          return throwError(() =&gt; new RequestTimeoutException());
        }
        return throwError(() =&gt; err);
      }),
    );
  };
};</code></pre>
<p>해당 <strong><code>TimeoutInterceptor</code></strong>는 전역, 혹은 특정 컨트롤러 혹은 특정 라우트 핸들러 함수에 적용되어 요청에 대한 응답과정이 <code>5000ms</code>를 초과할 경우 <code>catchError</code>를 통해 <strong><code>RequestTimeoutExceptinon(408 Request Timeout)</code></strong>를 보내준다. 즉, 타임아웃 핸들링을 하는 것이다.</p>
<p>물론, 이번 포스팅에선 <code>TimeoutInterceptor</code>의 기능에 초점을 둘 것이지만 <strong><code>RxJS</code></strong>의 사용또한 짚고 넘어가고자 한다.</p>
<hr>
<p>여태껏, 이전의 포스팅들에서 <code>Interceptor</code>를 다룰 시 인터셉터를 NestJS의 <strong><code>LifeCycle</code></strong>의 측면에서만 바라보았지 <strong><code>RxJS</code></strong>를 사용함으로써 구현하게 되는 <strong><code>Reactive Programming</code></strong> 측면에서 바라본 적은 없었다.</p>
<hr>
<p>위에서 보다 시피, <strong><code>intercept()</code></strong>함수가 반환하는 <strong><code>Observable</code></strong>부터 타임아웃을 처리하는데 사용하는 <code>timeout</code>, <code>throwError</code>, <code>catchError</code> 등등의 연산자들이 <code>rxjs</code> 라이브러리에서 불러온 것을 확인할 수 있다.</p>
<p><span style="color:blue"><strong>그럼 도대체 <code>RxJS</code>는 무엇일까?</strong></span></p>
</br>

<h3 id="rxjs에-대해-알아보자-reactive-x">&gt; <code>RxJS</code>에 대해 알아보자 (<code>Reactive X</code>)</h3>
<p>해당 개념에 대해 너무 깊게 들어가면 끝도없다. 반응형 프로그래밍이란 개념이 등장하고, 이를 <u>함수형 및 선언형 프로그래밍과도 연관</u>지을 수 있다. 동시에 사용할 수 있는 <u>수 많은 오퍼레이터가 존재</u>하고, 이를 전부 다뤄보며 알아가기엔 지금 당장은 무리이다. </p>
<p>해당 파트에선 간단히 이를 알아보고, 우리의 <strong><code>NestJS - Interceptor</code></strong>에 적용할 수 있을정도만 경험해보고자 한다.</p>
<p><strong>✔ RxJS란?</strong></p>
<p>정확히 말하면 <code>Reactive X</code> 라이브러리이며, 이를 사용할 수 있는 언어에 따라 <code>RxJava</code>, <code>RxJS</code>, <code>Rx.NET</code>, <code>RxCpp</code> 등으로 나뉘는 것이다. 즉, <strong><code>RxJS</code></strong>는 자바스크립트를 통해 작성된 <strong><code>ReactiveX</code></strong> 라이브러리이다.</p>
</br>

<p><strong>✔ Observable과 Observer를 통해 알아보는 Reactive Programming</strong></p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/8649405b-af78-4317-84ec-7dcf65b2690d/image.png" alt=""></p>
<p>먼저 그전에 바탕이 되어야하는 개념이 있다. &quot;리액티브 프로그래밍&quot;은 <span style="color:green">&quot;비동기 데이터 스트림&quot;</span>을 활용한 프로그래밍이다. </p>
<p><strong>&quot;Data Stream&quot;</strong>은 <span style="color:green"><em>&quot;일련의 데이터가 연속적으로 흐르는 데이터 흐름&quot;</em></span> 이라 할 수 있고, 이는 데이터를 순차적으로 처리하고 저장하기 위해 사용된다.</p>
<p><strong>&quot;순차적&quot;</strong>이라는 것에 초점을 둘 필요가 있다. 다시 말해, &quot;시간&quot;의 흐름에 따라 데이터가 정렬된다는 것이다. </p>
<p>아주 간단한 코드를 통해 알아보자.</p>
<pre><code class="language-tsx">import { interval, take } from &quot;rxjs&quot;;

const observable$ = interval(1000).pipe(take(4));
const observer = {
   next: (item: number) =&gt; console.log(item),
   error: (err: number) =&gt; console.log(err),
   complete: () =&gt; console.log(&#39;complete&#39;),
};
observable$.subscribe(observer);</code></pre>
<p>단순히, 0부터 시작해 정수형 값을 1씩 늘려가며 4개의 숫자를 1000ms 간격으로 반환하는 코드이다. </p>
<p>결과는 1초 간격으로 *<em><code>0,1,2,3</code> *</em>이 출력될 것이다.</p>
<p>여기서 핵심은 <span style="color:red"><strong><code>Observer</code></strong></span>가 <span style="color:red"><strong><code>Observable</code></strong></span>을 <strong>&quot;subscribe&quot;</strong>하면서 <strong><code>next, error, complete</code></strong> 키워드를 사용하여 이벤트를 처리한다는 것이다.</p>
<p>만약 결과 값을 <code>0, 1, 2, 3</code>이 아닌 각 수의 제곱 수인 <strong><code>0, 1, 4, 9</code></strong>로 받고 싶다면 어떻게 처리해줄까? 아래와 같이 처리해줄 수 있다.</p>
<pre><code class="language-tsx">import { interval, take, map } from &quot;rxjs&quot;;

const observable$ = interval(1000).pipe(
    take(4),
    map(item =&gt; item * item), // `rxjs`의 map() 함수를 적용시킨다.
 );
const observer = {
    next: (item: number) =&gt; console.log(item),
    error: (err: number) =&gt; console.log(err),
    complete: () =&gt; console.log(&#39;complete&#39;),
};

observable$.subscribe(observer);</code></pre>
<p><strong><code>observable</code></strong>의 <strong><code>pipe()</code></strong> 내부에서, <code>map(item =&gt; item * item)</code>이란 코드, 즉 <strong><code>map()</code></strong> 연산자를 사용함으로써 원하는 구현을 할 수 있게 되었다. <u>위 코드에 한해서는</u>, 자바스크립트 내장 함수인 <code>map()</code>과 동일한 기능이지만 이는 <strong><code>rxjs</code></strong>에서 제공하는 연산자이며 엄연히 다르다.</p>
<p>JS의 <code>map()</code>은 배열을 대상으로 작동하고, 새로운 배열을 반환한다. 반면에 RxJS의 <code>map()</code> 연산자는 Observable을 대상으로 작동하며, 새로운 Observable을 반환한다. 더하여 RxJS에서 이러한 연산자는 데이터 스트림에서 발행되는 각 데이터에 대해 순차적으로 적용되며, 다양한 처리를 할 수 있다.</p>
<p>위의 예시를 물론, 아래와 같이 기존 JS 문법 만으로도 충분히 구현할 수 있다.</p>
<pre><code class="language-tsx">const intervalId = setInterval(() =&gt; {
  for (let i = 0; i &lt; 4; i++) {
    console.log(i * i);
  }
  clearInterval(intervalId);
}, 1000);</code></pre>
<p><u><em>무언가 의문이 생길 것이다</em></u>.</p>
<p>얼핏 보더라도 일반적 JS 문법만으로 구현한 코드가 <code>RxJS</code>를 사용한 것보다 더 짧고, 간결해보인다. </p>
<p><u style="color:red">하지만</u> 코드의 양이 작다고, 코드가 짧다고 해서 코드가 깔끔하고 가독성이 좋은 것은 아니다. </p>
<p>위의 코드는 아주 단순한 연산을 위한 코드여서 와닿지 않겠지만, <strong><code>let i = ?</code></strong>과 같이 <u>코드에 노출된 변수들</u>은 더 복잡한 코드에 있어, 분명한 위험 요소로 작용될 수도 있다.<span style="color:gray"> (멀티스레드 환경에서 동시접근에 의한 오류, 상태값 관리 측면에서의 오류 ... 등의 위험이 존재한다.)</span> 하지만, <code>rxjs</code>를 사용하면 <strong>&quot;선언적인&quot;</strong> 방식으로 코드를 작성할 수 있으며 굉장히 <u>직관적</u>이다 할 수 있다.</p>
<hr>
<p><strong><span style="color:red">※ 참고</strong></span></p>
<p>&quot;&quot; &quot;&#39; &quot;&#39;
<strong>Observable</strong>은 <strong><em>&quot;lazy&quot;</em></strong> 하다. 누군가 <strong>구독</strong>(subscribe)을 해야 발행을 시작하게 된다.
                                                              &quot;&quot; &quot;&#39; &quot;&quot;</p>
<p>위는 리액티브 프로그래밍에서 굉장히 중요하게 언급되는 문구이다. 
Observable은 &quot;lazy&quot;하다는 특징을 가진다. <u>Observable이 발행(publish)을 시작하려면, 해당 Observable을 구독(subscribe)해야 한다</u>. 즉, Observable은 데이터를 먼저 생성하거나 발행하지 않고, &quot;구독&quot; 될 때 비로소 이를 수행한다. <strong>&quot;pull&quot;</strong>이 아닌 <strong>&quot;push&quot;</strong>방식으로 동작한다는 것이 이런 의미이다.</p>
<p>이러한 특징은 Observable이 <u>비동기적으로 데이터 스트림을 처리할 때 매우 유용</u>하다.</p>
<p>단순한 처리 뿐만 아니라, <strong>&quot;HTTP 요청&quot;</strong>에 대해서도 그 결과를 받아오는 과정을 Observable을 통해 수행할 수 있다. 추후, 우리가 다룰 부분과 연관지어 가장 의미있는 예시가 아닐까 싶다. 
일반적으로 HTTP 요청은 비동기 처리되고, Observable을 이용하면 해당 비동기 처리를 일반 콜백 함수를 사용하여 처리하는 것 보다 훨씬 직관적이고 가독성있게 처리할 수 있다. 또한, 에러처리에 있어서도 쉽게 구현할 수 있다.</p>
<p>이렇게 &quot;lazy&quot;한 <strong>Observable(Pub)</strong>과 이를 구독하는 <strong>Observer(Sub)</strong>의 관계 속에서 다양한 오퍼레이터와 함께 <u>효과적인 비동기 데이터 스트림 처리</u>를 수행할 수 있게 되는 것이다.</p>
<hr>
<h3 id="nestjs의-interceptor는-왜-rxjs를-사용하는가">&gt; NestJS의 <code>Interceptor</code>는 왜? <code>rxjs</code>를 사용하는가?</h3>
<p>우린 NestJS의 인터셉터와 관련된 몇 가지 포스팅을 다루며 해당 enhancer는 &quot;<u>요청과 응답의 처리를 가로채어 일련의 작업을 수행</u>&quot; 한다는 것을 알고 있다. </p>
<p>또한, 모든 경우가 그렇진 않겠지만 흔히 요청, 응답에 접근하는 과정에 있어서 <strong>&quot;비동기 처리&quot;</strong>에 해당하는 작업이 대부분이다.</p>
<p>일전에 우리가 응답 객체를 제어하기 위해 (원하는 <strong><code>JSON-Serializing</code></strong>을 위해) 아래와 같은 인터셉터를 만들어 본 경험이 있었다. </p>
<hr>
<p><a href="https://velog.io/@from_numpy/Nest%EC%97%90%EC%84%9C-%EC%9D%91%EB%8B%B5%EA%B0%9D%EC%B2%B4%EC%97%90-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%A0%91%EA%B7%BC%ED%95%A0%EA%B9%8C-feat-Interceptor">NestJS에서 응답 객체에 어떻게 접근할까? -- 해당 포스틱 클릭 ✔</a></p>
<hr>
<pre><code class="language-tsx">// reponse-serialize.intercept.ts

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from &quot;@nestjs/common&quot;;
import { instanceToPlain } from &quot;class-transformer&quot;;
import { Observable, timeout } from &quot;rxjs&quot;;
import { map, tap } from &quot;rxjs/operators&quot;;
import logger from &quot;src/test-api/utils/log.util&quot;;
import { User } from &quot;src/user/model/user.entity&quot;;

@Injectable()
export class ResponseSerializeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Observable&lt;any&gt; | Promise&lt;Observable&lt;any&gt;&gt; {
      logger.debug(`Request Accepted`);
      return next.handle().pipe(
        map((data) =&gt; {
          if (isUser) {
            const alteredResponse = instanceToPlain(data);
            logger.debug(`Response Altered : ${JSON.stringify(alteredResponse)}`);
            return alteredResponse;
          }else {
            return data;
          }       
        }),
        tap(() =&gt; logger.debug(`After Handling, Response Sent`))
    )
  }
}

const isUser = (data: any): data is User =&gt; {
  return (&#39;id&#39; in data &amp;&amp; typeof data.id === &quot;number&quot;) 
    &amp;&amp; (&#39;first_name&#39; in data &amp;&amp; typeof data.first_name === &quot;string&quot;)
    &amp;&amp; (&#39;last_name&#39; in data &amp;&amp; typeof data.last_name === &quot;string&quot;) 
    &amp;&amp; (&#39;email&#39; in data &amp;&amp; typeof data.email === &quot;string&quot;)
    &amp;&amp; (&#39;password&#39; in data &amp;&amp; typeof data.password === &quot;string&quot;)
} </code></pre>
<p>간단히 설명하자면 <code>User</code> 데이터를 응답받는 과정에서 일련의 작업을 통해 데이터를 가공시키 위한 인터셉터이다. </p>
<p>우린 <strong>파이프</strong>(<code>pipe()</code>) 안에 <code>rxjs</code>의 <strong><code>map()</code></strong>함수를 담아 해당 내부에서 구현하고자 하는 가공의 작업을 취해줄 수 있고, <strong><code>tap()</code></strong>함수를 통해 부가적 기능을 추가할 수 있다.</p>
</br>

<p>우리가 다루고자 하는 <strong><code>Timeout</code> *<em>인터셉터 또한 마찬가지다. <code>rxjs</code>에서 제공하는 *</em><code>timeout()</code></strong> 연산자를 사용해 타임아웃 제한시간을 설정할 수 있고, <strong><code>catchError()</code></strong>를 통해 타임아웃 발생 시 에러 처리를 진행할 수 있다.</p>
<pre><code class="language-tsx">// timeout.intercept.ts

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from &#39;@nestjs/common&#39;;
import { Observable, throwError, TimeoutError } from &#39;rxjs&#39;;
import { catchError, timeout } from &#39;rxjs/operators&#39;;

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable&lt;any&gt; {
    return next.handle().pipe(
      timeout(5000),
      catchError(err =&gt; {
        if (err instanceof TimeoutError) {
          return throwError(() =&gt; new RequestTimeoutException());
        }
        return throwError(() =&gt; err);
      }),
    );
  };
};</code></pre>
</br>

<p>이처럼 인터셉터에서 리턴 값으로 <code>Observable</code>을 사용하면 RxJS의 연산자를  활용하여 다양한 방식으로 데이터를 처리할 수 있는 장점이 있다.</p>
<p>흔히, 우리가 인터셉터를 <span style="color:green"><em>&quot;HTTP 요청과 응답의 흐름을 가로챈다&quot;</em></span> 라고 표현을 하는데 이는 데이터 스트림의 관점에서 보면 <span style="color:green"><em>&quot;데이터 흐름을 가로챈다&quot;</em></span> 라고 볼 수 있는 것이다.</p>
<p>또한, 이러한 관점에서 보면 <code>Observable</code>임을 나타내는 <strong><code>pipe()</code></strong> 함수 부분을 <span style="color:green"><strong>&quot;발행자(Pub)&quot;</strong></span>이라 볼 수 있고, 해당 Observable을 반환하게 되는 <strong><code>intercept()</code></strong>를 <span style="color:green"><strong>&quot;구독자(Sub)</strong>&quot;</span>이라 볼 수 있다. <span style="color:gray">(인터셉터의 명확한 구독자를 정의하긴 애매하지만 위와 같이 생각할 수도 있지 않을까 싶다)</span></p>
</br>

<p>리액티브 프로그래밍을 더 설명하기엔 끝도 없다. 여태 언급한 개념과 접근법을 바탕으로 아래에서 본격적인 <strong><code>TimeoutInterceptor</code></strong>를 만들어 보도록 하자.</p>
</br>

<h2 id="💥-timeoutinterceptor-를-구현해보자">💥 <code>TimeoutInterceptor</code> 를 구현해보자</h2>
<p>물론,  앞전의 공식문서에서 가져온 <code>TimeoutInterceptor</code> 를 사용해도 무방하지만, 그대로 사용하는 것보단 <u>조금 더 구체화하여 사용</u>하는것이 어떨까 생각이 들었다. </p>
<p>어떻게 <strong><code>Timeout</code></strong> 값을 설정하는지의 관점에 따라 크게 두 가지로 나누어 구현해보았다.</p>
</br>

<h3 id="정적static-timeoutinterceptor-구현하기">&gt; 정적(<code>Static</code>) <code>TimeoutInterceptor</code> 구현하기</h3>
<p>첫 번째는, <strong><code>Static</code></strong>하게 타임아웃값을 받아오는 것이다. 모든 경우에 해당하는 것은 아니겠지만, 특정 요청에 대한 응답을 받는데에 있어서 클라이언트가 <strong>&quot;제한시간&quot;</strong>을 서버측에 직접 요구할 수 있다. 이럴 경우엔 직접 <u>특정 제한시간을 설정</u>해주어야 한다.</p>
<p><strong>✔ <code>Interceptor</code> 구축하기</strong></p>
<p><strong><code>StaticTimeoutInterceptor</code></strong>는 아래와 같이 작성할 수 있다.</p>
<pre><code class="language-tsx">// static-timeout-handle.interceptor.ts

import { CallHandler, ExecutionContext, HttpException, HttpStatus, Injectable, NestInterceptor } from &quot;@nestjs/common&quot;;
import { Reflector } from &quot;@nestjs/core&quot;;
import { catchError, delay, Observable, retry, tap, throwError, timeout } from &quot;rxjs&quot;;
import { TIMEOUT_METADATA_KEY } from &quot;../decorators/timeout-handle.decorator&quot;;

@Injectable()
export class StaticTimeoutInterceptor implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}
  intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Observable&lt;any&gt; | Promise&lt;Observable&lt;any&gt;&gt; {

      const timeoutValue = this.reflector.get&lt;number&gt;(TIMEOUT_METADATA_KEY, context.getHandler());

      // retry-options
      const retryCount = 3;

      return next.handle().pipe(
        timeout(timeoutValue),
        retry(retryCount),
        catchError((error) =&gt; {
          if (error.name === &#39;TimeoutError&#39;) {
            console.log(`Timeout of ${timeoutValue}ms exceeded`);
            return throwError(() =&gt; new HttpException(&#39;Request Timeout&#39;, HttpStatus.REQUEST_TIMEOUT));
          } else {
            return throwError(() =&gt; error)
          }
        }),
        tap(() =&gt; {
          console.log(&#39;Request completed&#39;);
        }),
      );
  }
}</code></pre>
<p>해당 인터셉터를 전역적으로 사용할 수도 있겠지만, 특정 라우트 핸들러 요청에만 사용하는 것에 초점을 맞추었다. 모든 http 요청에 대한 연산 시간 및 조건이 <u>동일하지 않으므로</u>, 각 라우트 핸들러 함수마다 서로 다른 타임아웃 시간을 주입해주도록 하였다. </p>
<p>즉, 인터셉터 내 선언한 <strong><code>timeoutValue</code></strong>는 <strong>&quot;커스텀 데코레이터&quot;</strong>를 통해 받아오게끔 하였다.</p>
<p>파이프라인 내부에서 <span style="color:blue"><strong>&quot;timeout -&gt; retry -&gt; catchError -&gt; tap&quot;</strong></span> 연산자의 순으로 정렬된 것을 확인할 수 있을 것이다.</p>
<p><strong><code>timeout</code></strong> 연산자에 의해 먼저 제한시간을 설정한다. 만약, 제한 시간내에 요청에 대한 응답이 반환될 경우, 바로 <strong><code>tap</code></strong> 으로 넘어간다. 하지만, <code>timeout</code> 제한 시간내에 응답을 성공시키지 못할 경우 <strong><code>retry</code></strong>를 통해 요청 재시도를 수행하고 이에 따라 <strong><code>catchError</code></strong> 를 통해 에러를 처리한다.</p>
<p>그런데 여기서 *<em><code>retry</code> *</em>연산자에 대해 <u>의문을 가져볼 수 있다</u>. 잠깐, 해당 인터셉터를 주입한 10만건의 상품 데이터 요청건에 대한 로그문을 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/from_numpy/post/ac40ac55-68cf-4e94-9ad1-be13d17c0d59/image.png" alt=""></p>
<p>제한 시간으로 설정한 <code>300ms</code> 동안 응답이 전달되지 않자, <code>catchError</code>에서 설정해준 에러 문구가 출력된 것을 볼 수 있다. <strong>&lt;기존 요청 쿼리문 &quot;1&quot; + 재시도 요청 쿼리문 &quot;3&quot;&gt;</strong> 총, <strong>&quot;4&quot;</strong>번의 요청이 찍힌 것을 알 수 있다. </p>
<p>하지만, 보다시피 <u>재시도 요청을 날렸다고해서</u> 이미 <code>timeout</code> 제한 시간을 넘어버려 에러를 띄운 경우에, <u>해당 에러가 응답 성공으로 바뀌지는 않는다</u>. </p>
<p>그렇다면 왜? 굳이 <code>retry</code> 연산자를 추가해준 것일까?</p>
<p>일반적으로 <code>timeout</code>이 발생하면 API 호출이 실패하게 된다. 따라서 <code>retry</code>를 시도하더라도 <code>timeout</code>이 발생하는 경우에는 재시도가 성공할 가능성이 매우 낮다.</p>
<p>하지만, <strong><code>retry</code></strong> 연산자를 사용하는 이유는 <code>timeout</code> 발생에 대한 재요청이라기 보다, 서버의 과부하 등 일시적인 문제 때문에 API 호출이 실패할 수 있기 때문이다. 이러한 경우, 일시적인 문제가 해결되면 API 호출이 다시 성공할 수 있다. </p>
<hr>
<p><strong>가령, 아래와 같이 말이다.</strong>
<img src="https://velog.velcdn.com/images/from_numpy/post/0d4760be-54f0-4160-8508-ac2243eb7aee/image.png" alt=""></p>
<p>일반적으로는, 응답에 성공할 경우 <code>retry</code>가 수행되지 않는다. 하지만 &quot;네트워크 지연, 서버 과부하 등등&quot;과 같은 일련의 이유로 인해 서버에서 일정 시간 내에 요청을 받지 못할 경우 또한 존재한다. 항상 <code>TimeoutError</code>만 존재하는 것이 아니란 얘기이다.</p>
<p>이처럼, 위와 같이 정확히는 알 수 없는 일련의 이유로 재시도 쿼리 요청이 수행되고, 2번의 재시도 끝에 응답 성공에 이르게 된다. </p>
<p>재시도 횟수를 3으로 설정하였지만, 2번의 추가 쿼리 요청만 온 것에 대한 정확한 이유는 알 수 없지만, 2번의 재시도만에 응답에 성공하였고 추가 재시도는 필요없기 때문에 수행되지 않은 것으로 추측된다.</p>
<hr>
<p>에러 핸들링 중, <strong><code>catchError()</code></strong> 부분에선 <code>TimeoutError</code>가 아닌 경우에 대해서도 에러처리를 한다. 해당 에러가 앞서 언급한 &quot;<u>네트워크 지연, 서버 과부하 등등의 일시적인 문제</u>&quot; 라고 볼 수 있다. </p>
</br>

<p><strong>✔ <code>Custom Decorator</code> 생성</strong></p>
<pre><code class="language-tsx">// timeout-decorator.ts

import { SetMetadata } from &quot;@nestjs/common&quot;;

export const TIMEOUT_METADATA_KEY = &quot;timeout&quot;;

export const TimeoutHandler = (ms: number) =&gt; SetMetadata(TIMEOUT_METADATA_KEY, ms);</code></pre>
</br>

<p><strong>✔ 라우트 핸들러에 적용하기</strong></p>
<pre><code class="language-tsx">  @Get(&#39;all&#39;)
  @TimeoutHandler(300) // timeout 시간을 300ms로 설정
  async all() {
    return this.productService.all();
  }</code></pre>
<hr>
<p>*<em>※ 주의! *</em></p>
<p><code>UseInterceptor()</code>가 아닌 커스텀 데코레이터를 통해 인터셉터를 사용 시 NestJS의 모듈은 이를 해석하지 못한다. 즉, 아래와 같이 <strong><code>provider</code></strong>내에 주입시켜 주어야 한다.&#39;</p>
<pre><code class="language-tsx">// app.module.ts

@Module({

  // ...

  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: TimeoutInterceptor,
    },
  ],
})
export class AppModule {}</code></pre>
<hr>
</br>

<h3 id="동적dynamic-timeoutinterceptor-구현하기">&gt; 동적(<code>Dynamic</code>) <code>TimeoutInterceptor</code> 구현하기</h3>
</br>

<p><strong>✔ 정적(<code>Static</code>) 타임아웃 설정의 문제</strong></p>
<p><strong>두</strong> 번째는, <strong><code>Dynamic</code></strong>하게 타임아웃 값을 받아오는 것이다.</p>
<p>앞서 구현해보았던 <strong><code>StaticTimeoutInterceptor</code></strong>는 말 그대로 타임아웃 값을 직접 설정함으로써 나타낼 수 있었다. 이러한 고정 값은, 여러 요청-응답 시도 끝에 얻게된 경험값일 수도 있고, 유저 경험을 위해 클라이언트 측에서 요구한 값일 수도 있다. </p>
<p>하지만, 정적 타임 아웃을 수행할 경우 몇 가지 고려해봐야할 사항이 존재한다. </p>
<p>대용량 레코드 조회, 유저가 몰리는 트래픽 등 충분한 지연이 발생할 것이라 기대되는 요청의 경우, 어느 정도 예상은 할 수 있지만 정확한 지연 시간을 얻기란 쉽지 않다. 네트워크의 상태나 메모리 이슈와 같은 부수적 문제 또한 관여할 수 있기 때문이다.</p>
<p>예를 들어, 실제로 어떤 요청을 수행하는데 있어 <strong>&quot;30초&quot;</strong>의 시간이 소요되었다고 하자. 하지만, 아무도 해당 시간을 정확히 예측하지 못하였다. 이때, 클라이언트 측에서 <strong>&quot;25초&quot;</strong>의 <code>timeout</code> 값을 요구하였다. 그러면 사실 <em>&quot;어짜피&quot;</em> 설정한 제한 시간을 넘어선 요청-응답 수행이므로 <strong><code>timeoutError</code></strong> 오류를 받게 될 것이다. 
그렇다고, <code>timeout</code>을 <strong>&quot;10초, 15초 ..&quot;</strong>로 줄이기엔 너무나 확실한 에러를 받을 것으로 예상되므로 <u>쉽지 않은 결정</u>이다.</p>
<p>이처럼 <span style="color:red"><strong>&quot;불필요한 대기 시간&quot;</strong></span>이 존재하게 된다. </p>
<p>어쩌면 당연할 수 있는 대기 시간이지만, <strong>&quot;유저 경험&quot;</strong>의 측면에서 불필요하고 좋지 못한 타임아웃 설정이라 할 수도 있다. 
<span style="color:gray">(물론 그렇다고 &quot;<u>정적 타임아웃을 쓰면 안된다</u>&quot; 라는 것은 아니다. 상황과 요구에 맞게 필요한 방식을 써야할 것이다.)</span></p>
</br>

<p><strong>✔ 동적(<code>Dynamic</code>)으로 타임아웃 값을 설정해보자.</strong></p>
<p>우린 위에서 언급한 일련의 문제들을 고려하여 <strong>&quot;동적&quot;</strong>으로 타임아웃 값을 얻고자 한다. &quot;<u>불필요한 대기 시간 개선</u>&quot;을 포함해 &quot;<u style="color:red">응답 시간 최적화</u>&quot;의 측면에서도 기대를 할 수 있을 것이다.</p>
<p>동적 타임아웃 값을 얻고자 하는데 있어, 실로 다양한 방법이 존재할 것이다. 아직 실무를 경험해보지 못한 나로써는 어떠한 방법이 있는지는 모르겠지만 <em><strong>&quot;이러한 방식으로도 구현해볼 수 있다?&quot;</strong></em> 정도에 초점을 맞추고 수행해보고자 한다.</p>
<p>그럼 어떻게 동적으로 타임아웃 값을 얻고자 하는가?</p>
<hr>
<p>해당 구현의 방법으로</p>
<p><span style="color:red"><em><strong>&quot;서버 자원(Memory, CPU)&quot;</strong></em></span></p>
<p>을 고려하여 접근하다.</p>
<hr>
<p>왜? 서버 자원을 통해 타임아웃에 접근하였는가?</p>
<p>일반적으로 모니터링(Monitoring)과 같은 서버의 성능 측면에서 대표적으로 고려되는 것에 <strong>&quot;CPU&quot;, &quot;Memory&quot;, &quot;Disk&quot;, &quot;Network&quot;</strong> 가 있다. </p>
<p>하지만, 네트워크에 접근하는 것은 사실상 힘들기 때문에 이를 제외하고 (물론 Disk도 고려하지 않는다) 서버의 성능에 사실상 <strong>&quot;척도&quot;</strong>라고 할 수 있는  <strong>&quot;CPU&quot;</strong>와 <strong>&quot;Memory&quot;</strong>를 통해 timeout 값을 계산해보기로 한다.</p>
<p>우리는 <strong>NodeJS</strong>(NestJS) 환경에서 CPU와 Memory 사용률에 접근하기 위해, <span style="color:green"><strong>&quot;OS Module&quot;</strong></span>을 사용하기로 한다. 정확히 말해선 <code>os-utils</code> 를 사용할 것이다. 큰 차이는 없지만 조금 더 세세한 표현이 가능하다.</p>
<hr>
<p><a href="https://nodejs.org/api/os.html">OS | Node.js ✔</a></p>
<hr>
<p>그럼 먼저 코드를 알아보자.</p>
</br>

<p><strong>✔ <code>DynamicInterceptor</code> 생성</strong></p>
<pre><code class="language-tsx">// dynamic-timeout.interceptor.ts

import { CallHandler, ExecutionContext, HttpException, HttpStatus, Injectable, NestInterceptor } from &quot;@nestjs/common&quot;;
import { catchError, delay, Observable, retry, tap, throwError, timeout } from &quot;rxjs&quot;;
import * as osUtils from &quot;os-utils&quot;;

@Injectable()
export class DynamicTimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler&lt;any&gt;): Observable&lt;any&gt; | Promise&lt;Observable&lt;any&gt;&gt; {
      const maxTimeout = 3000; // 최대 timeout 설정
      const minTimeout = 1000; // 최소 timeout 설정

      // CPU 사용률 계산
      const cpuUsage: number = await new Promise((resolve) =&gt; {
        osUtils.cpuUsage((value) =&gt; {
          resolve(value);
        });
      });

      const memoryUsage = 1 - osUtils.freememPercentage(); // 메모리 사용률 계산

      // timeout 값 계산하기
      const timeoutValue = maxTimeout - Math.round((maxTimeout - minTimeout) * ((cpuUsage + memoryUsage) / 2));

       // retry-options
      const retryCount = 3;

      return next.handle().pipe(
        timeout(timeoutValue),
        retry(retryCount),     
        catchError((error) =&gt; {
          if (error.name === &#39;TimeoutError&#39;) {
            console.log(`Timeout of ${maxTimeout}ms exceeded`);
            return throwError(() =&gt; new HttpException(&#39;Request Timeout&#39;, HttpStatus.REQUEST_TIMEOUT));
          } else {
            return throwError(() =&gt; error)
          }
        }),
        tap(() =&gt; {
          console.log(`Request completed + ${timeoutValue}ms`);
        }),
      );
  }
}</code></pre>
<p><code>return</code> 이후의 반환부는 동일하다. 데이터 스트림의 파이프라인 내 <code>Observable</code>의 연산을 통해 요청에 대한 일련의 처리를 진행한다.</p>
<p>주목해 볼 부분은 <code>timeoutValue</code> 값을 도출해 내는 과정이다.</p>
<p>인터셉터의 <code>intercept</code> 메서드에서는 먼저 <code>os</code> 모듈을 사용하여 현재 시스템의 CPU 사용량과 메모리 사용률을 구한다. 해당 사용률과 설정해 둔, 최소 타임아웃(<code>minTimeout</code>)과 최대 타임아웃(<code>maxTimeout</code>)을 통해 최종 <code>timeoutValue</code>를 동적으로 구하게 된다.</p>
</br>

<p>그럼 해당 부분에 대해 간단히 알아보자.</p>
<ul>
<li><p><span style="color:blue">maxTimeout과 minTimeout은 왜 설정해줄까?</span>
: 그냥 하나의 방법일 뿐이다. 우린 해당 범위를 설정해줌으로써 타임아웃이 무조건 해당 범위안에서 계산되는 값으로써 도출해낼 수 있다. 결국, 해당 <code>maxTimeout</code>과 <code>minTimeout</code>의 값에 따라서 timeoutError를 띄울지, 요청에 대한 응답을 보낼지가 결정된다.
즉, 이에 따라 적절한 최대 및 최소 값을 설정하는 것이 필요하다.</p>
</li>
<li><p><span style="color:blue"><code>os</code> 모듈을 이용한 사용률 계산</span></p>
<ul>
<li><p><strong><code>osUtils.cpuUsage(callback)</code></strong> : CPU의 사용률을 구하는 것이다.(<code>0~1</code> 사이의 값을 가진다, 콜백을 반환한다.)</p>
</li>
<li><p><strong><code>const memoryUsage = 1 - osUtils.freememPercentage();</code></strong> : 사용 가능한 메모리 용량을 통해 현재 사용 중인 메모리 사용률을 구할 수 있다. (<code>0~1</code>)</p>
</li>
<li><p><strong><code>timeoutValue</code> 구하기</strong> : 먼저 <code>(cpuUsage + memoryUsage) / 2</code> 부분은 CPU 사용률과 메모리 사용률의 평균값을 계산한 것이다. 이 값을 통해 CPU 사용률과 메모리 사용률 중 어느 쪽이 더 큰 영향을 미치는지 구분하여 <code>timeoutValue</code>를 계산할 수 있다. <span style="color:gray"> (0~1 사이의 값이 나올 것이다.)</span></p>
</li>
</ul>
<p>그리고 <code>(maxTimeout - minTimeout) * (cpuUsage + memoryUsage) / 2</code>는 timeoutValue가 허용 가능한 범위 내에서 타임아웃 값을 결정하게끔 한다. 정확히 말하면 위 식에서 반올림하여 구한 값이 <strong><code>timeoutValue</code></strong>가 되는 것이다.</p>
<p>하지만, 우린 해당 값을 또 한번 <code>maxTimeout</code>에서 빼주거나 혹은 <code>minTimeout</code>에서 더해준다. 이는, <code>timeoutValue</code>가 &quot;허용 가능&quot;과는 별개로 &quot;실제&quot; 범위 내에서 위치시켜 주기 위함이다.</p>
<p>아래의 최종식이 이렇게 도출된다.</p>
</li>
</ul>
<pre><code class="language-tsx">  const timeoutValue = maxTimeout - Math.round((maxTimeout - minTimeout) * (cpuUsage + memoryUsage) / 2);</code></pre>
</br>

<p><strong>✔ 요청 테스트 하기</strong></p>
<p>위의 코드와 수치(<code>maxTimeout, minTimeout</code>)를 토대로 10만건의 상품 데이터 요청을 날려보면 응답을 잘 받아옴과 동시에</p>
<pre><code class="language-tsx">[Nest] 35700  - 2023. 04. 11. 오후 10:02:45     LOG [NestApplication] Nest application successfully started +11ms
query: SELECT `Product`.`id` AS `Product_id`, `Product`.`title` AS `Product_title`, `Product`.`description` AS `Product_description`, `Product`.`image` AS `Product_image`, `Product`.`price` AS `Product_price` FROM `products` `Product`
Request completed + 705ms</code></pre>
<p><code>tap()</code>에서 받아온 로그를 통해 <code>timeoutValue</code>가 <strong><code>705ms</code></strong>로 설정되었다는 것을 알 수 있다.</p>
<p>이렇게 우린 <strong>&quot;동적&quot;</strong>으로 <strong>&quot;서버의 상태에 맞게끔&quot;</strong> <code>timeout</code> 값을 얻게 될 수 있다.</p>
</br>

<p><strong>✔ 추가적 포인트 - CPU와 Memory에 가중치(weights) 부여</strong></p>
<p>우리의 <strong><code>DynamicTimeInterceptor</code></strong>를 조금 더 <u>구체화</u> 시킬 수도 있다.</p>
<p>앞서 언급하였다시피, CPU 사용률과 메모리 사용률은 서버 부하의 중요한 지표 중 하나이다. 이들을 <strong>&quot;가중치&quot;</strong>로 사용함으로써 <code>timeoutValue</code>를 조금 더 구체화 하는 방법도 있다. CPU 사용률이 매우 높은 애플리케이션의 경우 CPU 가중치를 높게 설정하고, 메모리 사용률이 매우 높은 애플리케이션의 경우 메모리 가중치를 높게 설정하는 것이 적절할 수도 있다. </p>
<pre><code class="language-tsx">
ex) 
      // CPU 사용률과 메모리 사용률에 대한 가중치를 설정
      const cpuWeight = 0.7;
      const memoryWeight = 0.3;

      // timeout 값 계산하기
      const timeoutValue = Math.round((maxTimeout - minTimeout) * (cpuWeight * cpuUsage + memoryWeight * memoryUsage) + minTimeout);

      // retry-options
      // cpu 사용률이 높을때 더 많은 재시도를 보낸다.
      const retryCount = Math.floor(cpuUsage * 3) + 1;</code></pre>
<p>하지만 이러한 방식은 모든 서비스에서 적절한 방식은 아니며, 단순히 가중치를 정하기엔 상당히 많은 이해와 경험이 필요하다. 즉, 초기엔 보수적으로 설정하는 것이 바람직할지도 모른다.</p>
</br>

<h2 id="💥-생각정리">💥 생각정리</h2>
<p>단일 서버에서 처리하기 어려운 대규모 트래픽이나 데이터 처리 및 무거운 연산을 다루는데 있어 실무에선 여러 대의 서버를 하나의 시스템으로 묶는 &quot;<u>클러스터링(Clustering)</u>&quot; 혹은 부하를 분산시켜주는 &quot;<u>로드밸런싱(Load Balancing)</u>&quot;등 다양한 기술을 사용한다. </p>
<p><strong>&quot;Nginx&quot;</strong>와 같은 프록시 서버를 두는 것도 이러한 기술을 위한 방법이다. 어디선가 <strong>&quot;504 Gateway timeout&quot;</strong>이라는 문구를 본 적이 있을 것이다. 이것은 서버와 클라이언트간 proxy 연결 시간이 default 타임을 넘겨서 발생하는 일종의 <strong>&quot;timeoutError&quot;</strong>이다. 이렇게 단순 서버-클라이언트의 관계가 아닌 프록시 서버를 두는 입장에서도 timeout과 관련된 핸들링은 항상 고려해 주어야할 부분이다.</p>
<p>아직 위의 기술들을 접하는 것은 나에게 분명한 &quot;Overhead&quot;지만, 이번 포스팅에서 다룬 <strong>&quot;TimeoutInterceptor&quot;</strong>를 시작으로 이러한 원리에 대해 조금 더 다가갈 수 있었다. </p>
<p>또한, 여태까지 궁금하지만 깊게 찾아본 적이 없었던 &quot;RxJS&quot;에 대해 알아보았고, 동시에** &quot;왜?&quot;** <strong>NestJS의 Interceptor</strong>는 이러한 데이터 스트림을 통한 리액티브 패러다임을 제시하는지에 대해 고민해보는 시간을 가졌다.</p>
<p>그럼 해당 포스팅은 여기서 마무리짓겠다. </p>
<p>추후 수정안 혹은 부가 설명이 있으면 추가해보도록 하겠다.  </p>
]]></description>
        </item>
    </channel>
</rss>