<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Yeop</title>
        <link>https://velog.io/</link>
        <description>코린이</description>
        <lastBuildDate>Sun, 27 Jul 2025 04:38:17 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Yeop</title>
            <url>https://velog.velcdn.com/images/sung-yeop/profile/99202b8d-9f08-419c-a2ad-0f9efe65c58b/image.jfif</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Yeop. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sung-yeop" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[CustomMockService npm 패키지 만들기 - 2]]></title>
            <link>https://velog.io/@sung-yeop/CustomMockService-npm-%ED%8C%A8%ED%82%A4%EC%A7%80-%EB%A7%8C%EB%93%A4%EA%B8%B0-2</link>
            <guid>https://velog.io/@sung-yeop/CustomMockService-npm-%ED%8C%A8%ED%82%A4%EC%A7%80-%EB%A7%8C%EB%93%A4%EA%B8%B0-2</guid>
            <pubDate>Sun, 27 Jul 2025 04:38:17 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이전 포스팅에 이어서 아이디에이션 과정과 개발 과정을 포스팅해보려고 한다</p>
<hr>
<h2 id="1-xmlhttprequest-인터셉트-구현">1. XMLHttpRequest 인터셉트 구현</h2>
<p>이전 포스팅에서 설명한 것과 같이 <code>XMLHttpRequest</code> 방식과 <code>Fetch API</code> 방식을 모두 구현해서 최대한 많은 환경을 커버하는 것을 목표로 하고 있다.</p>
<p>(XMLHttpRequest는 <a href="https://silverlibrary.tistory.com/498">다른 블로거분의 포스팅</a>을 참고하면 어떤 용도로 사용하는지 쉽게 이해할 수 있을 것 같다)</p>
<h3 id="💡-어떻게-사용하도록-할까">💡 어떻게 사용하도록 할까?</h3>
<p>일단 필자가 생각한 Usage는 다음과 같다.</p>
<pre><code class="language-typescript">CustomMockService.get(&quot;/api/v1&quot;, mockData);
CustomMockService.get(&quot;/api/v1&quot;, params, mockData);
CustomMockService.post(&quot;/api/v1&quot;, requestBody, mockData);</code></pre>
<p>위의 방식처럼 <code>endpoint</code>, <code>reuqest</code>, 그리고 반환할 <code>mockData</code> 를 전달받도록 하는게 좋을 것 같다.</p>
<p>다만, 위처럼 받을 경우에는 사용법에 대한 숙지가 덜되어있다면 문제가 발생할수도 있을 것 같았다.</p>
<p>그래서 다음과 같이 객체 형식으로 받는게 더 좋을 것 같다고 판단했다.</p>
<pre><code class="language-typescript">CustomMockService.get({endpoint : &quot;api/v1&quot;, response : mockData});
CustomMockService.get({endpoint : &quot;api/v1&quot;, params, response : mockData});
CustomMockService.post({endpoint : &quot;api/v1&quot;, request : postRequest, response : mockData});</code></pre>
<h3 id="💡-구현-아이디어">💡 구현 아이디어</h3>
<p>구현 아이디어는 다음과 같다.</p>
<p>위처럼 사용하기 위해서는 CustomMockService라는 클래스를 생성하고, 그 내부에서 static으로 객체를 관리하려고 한다.</p>
<p>또한, <code>get</code>, <code>post</code>, <code>patch</code>, <code>delete</code> 는 모두 static으로 외부에 노출시키는게 지금 아이디어로는 적절하다고 생각한다.</p>
<p>따라서 다음과 같이 구현하려고 한다.</p>
<pre><code class="language-typescript">export class CustomMockService {
  private static getMockMapping = new Map&lt;string, any&gt;();
  private static postMockMapping = new Map&lt;string, any&gt;();
  private static patchMockMapping = new Map&lt;string, any&gt;();
  private static deleteMockMapping = new Map&lt;string, any&gt;();
  ...

  static post({
    endPoint,
    request = null,
    response,
  }: {
    endPoint: string;
    request?: any;
    response: any;
  }) {
    this.postMockMapping.set(`${endPoint}`, { request, response });
  }
}</code></pre>
<p>이런식으로 내부에서 Map으로 각 모킹 데이터를 관리하고 키로는 endpoint를 사용하면 될 것 같다.</p>
<hr>
<h2 id="2-xhr-응답-추출하기">2. XHR 응답 추출하기</h2>
<p>XMLHttpRequest를 활용한 API 호출을 Mock으로 대체하기 위해서는 XHR의 라이프사이클을 이해하고 적절한 시점에서 요청 정보를 추출해야 한다.</p>
<p> XHR 요청은 크게 두 단계로 나뉜다.</p>
<blockquote>
</blockquote>
<p>  <strong>1. xhr.open(): 요청 준비 단계</strong>
    - HTTP 메서드와 URL이 설정되는 단계
    - 아직 실제 네트워크 요청은 발생하지 않음
  <strong>2. xhr.send(): 요청 실행 단계</strong>
    - 실제 네트워크 요청이 시작
    - Request Body가 전송된다.</p>
<p>즉, httpMethod와 url, 그리고 params는 <code>xhr.open</code> 에서 추출해야하고 requestbody는 <code>xhr.send</code> 에서 추출해야 한다.</p>
<p>그래서 코드를 작성해보면 다음과 같다.</p>
<pre><code class="language-typescript">// XHR 데이터에서 추출
static patchXHR() {
  const OriginalXHR = window.XMLHttpRequest;
  (window.XMLHttpRequest as any) = function () {
    const xhr = new OriginalXHR();

    let httpMethod: string;
    let requestUrl: string;
    let urlParams: URLSearchParams;

    const originalOpen = xhr.open;
    xhr.open = function (
    m: string,
     u: string,
     async?: boolean,
     user?: string,
     password?: string
    ) {
      httpMethod = m;
      requestUrl = u;
      urlParams = new URLSearchParams(new URL(u).search);

      return originalOpen.call(this, m, u, async || true, user, password);
    };

    const originalSend = xhr.send;
    xhr.send = function (body?: any) {
      const requestBody = body; // 나중에 생각
      const mockData = CustomMockServer.findMockData(httpMethod, requestUrl);

      if (mockData) {
        console.log(&quot;Mock 데이터 존재 O -&gt; 가짜 응답 반환&quot;, mockData);
        CustomMockServer.returnMockResponse(xhr, mockData);
      }

      console.log(&quot;Mock 데이터 X -&gt; 실제 요청 진행&quot;);
      return originalSend.call(this, body);
    };

    return xhr;
  };
}</code></pre>
<pre><code class="language-typescript">// 관련 함수
private static findMockData(httpMethod: string, requestUrl: string) {
  let mapping;

  switch (httpMethod) {
    case &quot;GET&quot;:
      mapping = CustomMockServer.getMockMapping.get(requestUrl);
      break;
    case &quot;POST&quot;:
      mapping = CustomMockServer.postMockMapping.get(requestUrl);
      break;
    case &quot;PATCH&quot;:
      mapping = CustomMockServer.patchMockMapping.get(requestUrl);
      break;
    case &quot;DELETE&quot;:
      mapping = CustomMockServer.deleteMockMapping.get(requestUrl);
      break;
  }
  return mapping;
}

private static returnMockResponse = (xhr: XMLHttpRequest, mockData: any) =&gt; {
  Object.defineProperty(xhr, &quot;readyState&quot;, { value: 4 });
  Object.defineProperty(xhr, &quot;status&quot;, { value: 200 });
  Object.defineProperty(xhr, &quot;responseText&quot;, {
    value: JSON.stringify(mockData.response),
  });

  if (xhr.onreadystatechange) {
    xhr.onreadystatechange.call(xhr, new Event(&quot;readystatechange&quot;));
  }
  if (xhr.onload) {
    xhr.onload.call(xhr, new ProgressEvent(&quot;load&quot;));
  }
};</code></pre>
<p>여기서 한가지 중요한 부분은 목데이터 응답을 생성하는 부분이다.
xhr 객체는 기본적으로 ReadOnly이므로 <code>defineProperty</code> 속성을 통해 강제로 값을 변경해야한다.</p>
<p>물론 위에서 아직 완전한 완성본은 아니기 때문에, <code>status</code> 등의 값은 임의로 200으로 생성하도록 만들어놨다.</p>
<p>이처럼 defineProperty를 통해 목데이터 응답을 실제 서버 응답처럼 변경했다면 이벤트를 발생시킴으로써 다른 라이브러리가 이 변화를 인식할 수 있도록 해야한다.</p>
<p><code>xhr.onreadystatechange</code> 과 <code>xhr.onload</code> 부분이 이에 해당한다.</p>
<p>(<a href="https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest#events">공식문서</a>를 참고해보면 자세히 설명되어있다)</p>
<hr>
<h2 id="3-테스트">3. 테스트</h2>
<p>이제 XHR 부분을 한번 테스트해보자</p>
<p>아쉽게도 일렉트론 환경에서 테스트를 진행해봤는데, 일렉트론에서 IPC 통신 방법을 사용하는 경우에는 API 모킹을 못할 것 같다..</p>
<p>(그 이유는 나중에 다루도록 하겠다)</p>
<p>그래서 일단 이전에 진행했던 웹 어플리케이션 환경에서 테스트를 진행해보자</p>
<pre><code class="language-typescript">async function startApp() {
  // Mock 데이터 등록 먼저 진행하고
  dataMocking();

  // 그 다음 MockServer 실행
  CustomMockServer.run();

  createRoot(document.getElementById(&quot;root&quot;)!).render(
    &lt;StrictMode&gt;
      &lt;App /&gt;
    &lt;/StrictMode&gt;,
  );
}

void startApp();</code></pre>
<pre><code class="language-typescript">import { CustomMockServer } from &quot;@sung-yeop/custom-mock-service&quot;;

export const dataMocking = () =&gt; {
  // POST /auth/login 모킹 (전체 URL)
  CustomMockServer.post({
    endPoint: &quot;https://dev-api.ceo.popi.today/auth/login&quot;,
    response: {
      success: true,
      status: 200,
      data: {
        accessToken:
          &quot;eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJwb3BpX21hbmFnZXJfZGIiLCJzdWIiOiIxIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3NDYzODE4ODYsImV4cCI6MTc0NjM4NTQ4Nn0.ZIcwk29mLjMhMDHz6s_xzWTqN_Z8MXN8iMzu0IliePVlq7zaL_P5iNDwFWA8G7CCiRimFjZBnHgJJUF4IS6qVg&quot;,
      },
      timestamp: &quot;2025-05-05T03:04:46.939793&quot;,
    },
  });

  console.log(&quot;Mock 데이터 등록 완료&quot;);
};</code></pre>
<p>일단 사용해보니 endPoint 앞쪽에 prefix를 제거하는 로직을 추가하지 않아서 전체 엔드포인트로 맞춰놓고 모킹을 시도해봤다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/e238ab5a-be16-429e-9253-f3f69cf63579/image.png" alt=""></p>
<p>결과는 사진과 같이 제대로 API 요청을 인터셉트하여 네트워크 탭에는 요청이 생기지 않는 모습을 볼 수 있었다.</p>
<p>하지만, 라우팅이 제대로 되지 않는 모습을 볼 수 있었다.</p>
<pre><code class="language-typescript">const handleLogin = () =&gt; {
  login({ username, password })
    .then(() =&gt; navigate(&quot;/popup-list&quot;))
    .catch(() =&gt; setIsOpenModal(true));
};</code></pre>
<p>관련 로직을 살펴보면 다음과 같이 login이라는 요청이 성공하면 페이지 라우팅을 진행해야한다.</p>
<p>그래서 패키지 내부에서 로깅을 추가해보니 다음과 같이 콘솔 로그가 찍히는 모습을 볼 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/ba1f5205-b9c9-40a0-a803-e397d085a055/image.png" alt=""></p>
<p>즉, API 모킹은 정상적으로 되었으나 <code>.then</code> 이 작동하지 않는 문제였다.</p>
<pre><code class="language-typescript">const login = async ({ username, password }: LoginRequest) =&gt; {
  try {
    const response = await postLoginMutation.mutateAsync({
      username,
      password,
    });
    console.log(&quot;응답 : &quot;, response);
    setLogin(response.data.accessToken);
  } catch (error) {
    throw new Error(`로그인 오류 ${ErrorMessage(error)}`);
  }
};</code></pre>
<p>위 코드와 같이 내부 <code>login</code> 로직에서 응답을 확인하는 로깅을 찍어봤으나 이또한 로깅이 되지 않았다.</p>
<p>따라서, 비동기 문제라고 판단이 되었고 이를 해결할 방법을 찾아야했다.</p>
<hr>
<h2 id="4-문제-해결">4. 문제 해결</h2>
<p>우리는 이벤트 드리븐 방식으로 목 데이터를 반환하도록 코드를 구성했다.</p>
<p>이전에 소개했던 목데이터 반환 방법을 보면 다음과 같다.</p>
<pre><code class="language-typescript">// 기존에 작성했던 코드
private static returnMockResponse = (xhr: XMLHttpRequest, mockData: any) =&gt; {
  Object.defineProperty(xhr, &quot;readyState&quot;, { value: 4 });
  Object.defineProperty(xhr, &quot;status&quot;, { value: 200 });
  Object.defineProperty(xhr, &quot;responseText&quot;, {
    value: JSON.stringify(mockData.response),
  });

  if (xhr.onreadystatechange) {
    xhr.onreadystatechange.call(xhr, new Event(&quot;readystatechange&quot;));
  }
  if (xhr.onload) {
    xhr.onload.call(xhr, new ProgressEvent(&quot;load&quot;));
  }
};</code></pre>
<blockquote>
<p><strong>XHR 동작 순서</strong></p>
</blockquote>
<ol>
<li>readystatechange (readyState: 1) // OPENED</li>
<li>readystatechange (readyState: 2) // HEADERS_RECEIVED  </li>
<li>readystatechange (readyState: 3) // LOADING</li>
<li>readystatechange (readyState: 4) // DONE</li>
<li>load                             // 성공 완료</li>
<li>loadend                          // 최종 완료 (성공/실패 무관)</li>
</ol>
<p>우선 위 코드에서 Response를 반환하기 위해 <code>defineProperty</code> 를 통해 readyState를 4로 변경하여 <code>DONE</code> 상태로 변경한다.</p>
<p>이후, XHR 객체의 onreadystatechange, onload가 존재할 경우 각 메서드에 매칭되는 이벤트를 발행시키는 방법을 사용했다.</p>
<p>하지만, 위의 코드로는 동작하지 않아서 여러 방면으로 찾아보다가 다음과 같은 라이브러리를 확인할 수 있었다.</p>
<pre><code class="language-javascript">function onloadend() {
  if (!request) {
    return;
  }
  // Prepare the response
  var responseHeaders = &#39;getAllResponseHeaders&#39; in request ? parseHeaders(request.getAllResponseHeaders()) : null;
  var responseData = !responseType || responseType === &#39;text&#39; ||  responseType === &#39;json&#39; ?
      request.responseText : request.response;
  var response = {
    data: responseData,
    status: request.status,
    statusText: request.statusText,
    headers: responseHeaders,
    config: config,
    request: request
  };

  settle(function _resolve(value) {
    resolve(value);
    done();
  }, function _reject(err) {
    reject(err);
    done();
  }, response);

  // Clean up request
  request = null;
}</code></pre>
<p>위 코드는 <a href="https://github.com/axios/axios/blob/main/lib/adapters/xhr.js">Axios Adapter</a> 구현 코드 중 일부인데, 모든 코드를 한번에 보고 이해할 수는 없었으나,<code>onloadend</code> 라는 이벤트가 발생하는 경우 <code>settle</code> 이라는 함수를 통해서 resolve 혹은 reject를 시키는 모습을 볼 수 있다.</p>
<p>그래서 코드를 다음과 같이 <code>onloadend</code> 이벤트를 발행시키도록 수정했다.</p>
<pre><code class="language-typescript"> private static returnMockResponse = (xhr: XMLHttpRequest, mockData: any) =&gt; {
    setTimeout(() =&gt; {
      Object.defineProperty(xhr, &quot;readyState&quot;, {
        value: 4,
        configurable: true,
      });
      Object.defineProperty(xhr, &quot;status&quot;, { value: 200, configurable: true });
      Object.defineProperty(xhr, &quot;statusText&quot;, {
        value: &quot;OK&quot;,
        configurable: true,
      });
      Object.defineProperty(xhr, &quot;responseText&quot;, {
        value: JSON.stringify(mockData.response),
        configurable: true,
      });
      Object.defineProperty(xhr, &quot;response&quot;, {
        value: JSON.stringify(mockData.response),
        configurable: true,
      });

      const readystateEvent = new Event(&quot;readystatechange&quot;);
      if (xhr.onreadystatechange)
        xhr.onreadystatechange.call(xhr, readystateEvent);

      const loadEvent = new ProgressEvent(&quot;load&quot;);
      if (xhr.onload) xhr.onload.call(xhr, loadEvent);

      const loadendEvent = new ProgressEvent(&quot;loadend&quot;); // 추가된 부분
      if (xhr.onloadend) xhr.onloadend.call(xhr, loadendEvent);

      console.log(&quot;Mock 응답 완료&quot;);
    }, 50);
  };</code></pre>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/58b5835c-41e6-4948-a2b8-5d9c77f469af/image.png" alt=""></p>
<p>그 결과 <code>useAuth.ts</code> 에서 response 결과가 잘 나오는 모습을 확인할 수 있었다.</p>
<p>즉, Axios는 Promise 객체를 사용하여 resolve 혹은 reject를 판단하는 과정에서 사용되는 이벤트가 XHR의 <code>loadend</code> 이벤트라는 의미이다.</p>
<p>여기서 개발하면서 느낀 것 중하나가 setTimeout을 이용한 비동기 설정을 추가해야만 실제 서버에서 응답을 받아오는 순서처럼 동작한다는 것이다.</p>
<p>실제 응답 순서를 생각해보면 다음과 같다.</p>
<blockquote>
</blockquote>
<p><code>xhr.open() (요청 설정)</code>
-&gt; <code>xhr.send() (서버로 요청 전송 / 함수 즉시 반환)</code>
-&gt; <code>서버에서 처리 중 (시간 소요 / setTimeout으로 구현)</code>
-&gt; <code>서버가 응답을 보냄</code>
-&gt; <code>xhr.onloadend 등의 이벤트 핸들러 실행</code>
-&gt; <code>응답 데이터를 처리하여 클라이언트에 반환</code></p>
<p>즉, setTimeout을 이용하여 실제 서버 동작과 유사하게 개발하는게 중요하다는 것을 잊지말자</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>지금 생각해보면 목서비스 패키지를 만드는게 꽤 번거로운 작업들이 많은 것 같다.</p>
<p>부수적인 역할이지만 로컬 스토리지에 데이터를 저장하는 기능을 추가할 수도 있을 것 같고, 
endpoint도 쉽게 정의하여 사용할 수 있도록 수정해야하고, status, application 타입, 파일 통신 등의 기능을 제공하기 위해서는 가야할길이 꽤 먼 것 같다.</p>
<p>또한, 실제로 테스팅을 해보니 API 모킹 구현체들을 run이 실행된 이후에 등록되도록 호출해야한다.</p>
<p>관련 컴포넌트를 제공할지 아니면 run에다가 handler들을 모아둔 이후 실행시킬지 고민중이다.</p>
<p>일단 코드를 한쪽에 몰아써서 구현하느라 이해하기 어려운 것 같아서 리팩토링 작업부터 진행하고 XHR을 이용한 모킹부분부터 마무리하도록 해야겠다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CustomMockService npm 패키지 만들기 - 1]]></title>
            <link>https://velog.io/@sung-yeop/CustomMockServer-npm-%ED%8C%A8%ED%82%A4%EC%A7%80-%EB%A7%8C%EB%93%A4%EA%B8%B0-1</link>
            <guid>https://velog.io/@sung-yeop/CustomMockServer-npm-%ED%8C%A8%ED%82%A4%EC%A7%80-%EB%A7%8C%EB%93%A4%EA%B8%B0-1</guid>
            <pubDate>Thu, 24 Jul 2025 13:24:10 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>웹 개발을 진행할때는 주로 MSW를 사용해서 서버 상태를 모킹시켜서 사용하곤 했었다.</p>
<p>하지만, POPI 프로젝트에서 사용자 어플리케이션을 개발할 때, 그리고 이번에 진행하는 Nextron 프로젝트를 진행하면서도 MSW는 동작하지 않았다.</p>
<p>물론 필자가 세팅을 제대로 못한 것일수도 있으나, 개인적으로는 레퍼런스를 많이 찾아봤고 그만큼 시간도 많이 쏟았기 때문에 이정도의 세팅 허들이 있는 것이 마음에 들지 않았다.</p>
<p>솔직히 될지는 모르겠지만, 그래서 API 모킹을 제공하는 라이브러리를 한번 만들어보려고 한다.</p>
<hr>
<h2 id="1-어떻게-설계할-것인가">1. 어떻게 설계할 것인가?</h2>
<p>일단 필자는 Usage를 먼저 생각해보는 것 같다.</p>
<p>즉, <code>내가 만든 라이브러리를 어떻게 호출해서 사용할 것인가?</code> 를 생각해보는 것부터 시작해보려고 한다.</p>
<p>우선 아이디어는 다음과 같다.</p>
<h3 id="💡-mock-server-실행">💡 Mock Server 실행</h3>
<p>사용자는 목서버를 일단 프로젝트 루트 디렉토리에서 실행하도록 설명서를 제공할 것이다.</p>
<p>MSW를 사용하다보니 아무래도 그 영향을 많이 받은 것 같기도하다.</p>
<p>프로젝트 루트에서는 다음과 같이 사용하도록 만들 것이다.</p>
<pre><code class="language-typescript">export default App() {
  CustomMockServer.run({isDevRun : true})

  return ...
}</code></pre>
<ul>
<li>isDevRun은 개발환경에서만 실행할것인지를 설정하는 옵션으로 제공하는게 좋을 것 같다.</li>
<li>Default는 True로 제공하여 <code>CustomMockServer.run()</code> 으로도 간단하게 실행할 수 있도록 만들어보려고 한다.</li>
</ul>
<h3 id="💡-네트워크-요청-가로채기">💡 네트워크 요청 가로채기</h3>
<p>목서버를 동작시킨다면 당연히 네트워크 요청을 가로채서 실제 동작하는 것처럼 만들어야 할 것이다.</p>
<p>웹 애플리케이션에서 API 요청을 가로채는 방법은 크게 3가지가 있다. 각각의 방식은 서로 다른 장단점을 가지고 있으며, 프로젝트의 요구사항에 따라 적절한 방식을 선택해야 한다.</p>
<hr>
<h4 id="🔧-service-worker-방식-msw가-사용하는-방식">🔧 Service Worker 방식 (MSW가 사용하는 방식)</h4>
<p>Service Worker는 브라우저의 네트워크 레이어에서 동작하는 별도의 워커 스레드를 활용한 방식이다.</p>
<pre><code class="language-javascript">// public/mockServiceWorker.js
self.addEventListener(&#39;fetch&#39;, (event) =&gt; {
 const { request } = event;
 if (shouldMock(request.url)) {
   event.respondWith(
     new Response(JSON.stringify(mockData), {
       status: 200,
       headers: { &#39;Content-Type&#39;: &#39;application/json&#39; }
     })
   );
 }
});</code></pre>
<p>이 방식의 가장 큰 특징은 Network 탭에서 실제 HTTP 요청처럼 확인 가능하다는 것이다.</p>
<p>즉, 디버깅이 용이하지만, MSW를 사용해본 사람이라면 별도의 <code>.js</code> 파일을 등록해줘야하는 번거로움이 있고, 필요시 세팅을 추가로 진행해야한다는 문제가 있다.</p>
<ul>
<li>하지만 사용성은 제일 좋긴하다..</li>
</ul>
<hr>
<h4 id="🔧-xmlhttprequest-patching-방식">🔧 XMLHttpRequest Patching 방식</h4>
<p>XMLHttpRequest 클래스를 교체하여 전통적인 AJAX 요청을 가로채는 방식이라고 한다.</p>
<pre><code class="language-javascript">const OriginalXHR = window.XMLHttpRequest;

window.XMLHttpRequest = function() {
  const xhr = new OriginalXHR();
  const originalOpen = xhr.open;
  const originalSend = xhr.send;

  xhr.open = function(method, url) {
    this._method = method;
    this._url = url;
    return originalOpen.apply(this, arguments);
  };

  xhr.send = function(data) {
    if (shouldMock(this._method, this._url)) {
      setTimeout(() =&gt; {
        this.readyState = 4;
        this.status = 200;
        this.responseText = JSON.stringify(mockData);
        if (this.onreadystatechange) this.onreadystatechange();
      }, 0);
      return;
    }
    return originalSend.apply(this, arguments);
  };

  return xhr;
};</code></pre>
<p>찾아본 내용으로는 오래된 라이브러리나 jQuery와 같은 XHR 기반 코드와 호환에 용이하다고 한다.</p>
<p>하지만, Fetch API와는 호환이 되지 않는다고 한다.</p>
<p>필자가 만들려고하는 목서비스 패키지가 레거시까지 지원하는걸 고려한다면 이 코드를 참고해야할 것 같다.</p>
<p>코드를 보면 간단하게 xhr 인스턴스를 <code>window.XMLHttpRequest</code> 에서 받아온 다음, 해당 인스턴스의 <code>xhr.open</code> 과 <code>xhr.send</code> 를 내가 원하는 방식으로 조작해서 return하면 되는 것 같다.</p>
<ul>
<li>찾아보니 해당 방법은 네트워크 탭에서 확인이 어렵다고 한다.</li>
<li>아마도 네트워크 레이어에서 바로 처리되기 때문인 것 같다.</li>
</ul>
<hr>
<h4 id="🔧-fetch-api-patching-방식">🔧 Fetch API Patching 방식</h4>
<p>브라우저의 전역 fetch 함수를 직접 교체하여 요청을 가로채는 방식이다.</p>
<pre><code class="language-javascript">const originalFetch = window.fetch;

window.fetch = function(url, options) {
  if (shouldMock(url, options?.method)) {
    return Promise.resolve(
      new Response(JSON.stringify(mockData), {
        status: 200,
        headers: { &#39;Content-Type&#39;: &#39;application/json&#39; }
      })
    );
  }

  return originalFetch(url, options);
};</code></pre>
<p><code>window.fetch</code> 를 가로채서 <code>originalFetch(url, options)</code> 를 우리가 원하는대로 수정해서 보내주면 되는 것 같다.</p>
<p>어디선가 해본 것 같아서 코드가 낯설지는 않는 느낌이다.</p>
<p>마찬가지로 Fetch API를 가로채는 방식또한 네트워크 레이어에서 진행되기 때문에 네트워크 탭에서는 안보인다고 한다.</p>
<hr>
<h2 id="2-아이디어">2. 아이디어</h2>
<p>필자는 MSW의 번거로운 설정을 피하기 위해 별도의 패키지를 만들어보려고 하는 만큼, 간단한 사용성을 목표로 하고 있다.</p>
<p>따라서, <code>Fetch API</code> 와 <code>XMLHttpRequest Patching</code> 을 모두 지원하는 형태로 구현을 진행하지 않을까 싶다.</p>
<p>이 부분은 우선 만들어보고 테스트를 진행해본 이후, 변경될 수도 있을 것 같다.</p>
<h3 id="💡-네트워크-탭은">💡 네트워크 탭은?</h3>
<p>MSW를 사용하면서 가장 좋았던 부분은 네트워크 탭을 통한 디버깅이다.</p>
<p>실제 API를 호출한 것처럼 네트워크 탭을 통해서 자세한 API Request &amp; Response를 확인할 수 있었던 경험은 그대로 가져가고 싶다.</p>
<p><code>ReactQueryDevTool</code> 을 사용해보면 알겠지만, 캐시 상태를 보여주기 위해 해당 툴은 별도의 UI를 제공한다.</p>
<p>아마도 이런식으로 인터셉트한 네트워크 요청을 별도의 UI를 통해서 제공해주면 어떨까 싶다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>서비스 워커를 별도로 구현하는 것은 개발 공수가 엄청 들어가고, 냉정하게 그런 Low Level에서 동작하는 프로세스를 구현할 실력은 아직 없다고 생각한다.</p>
<p>그래서 우선 될지는 모르겠지만 목서비스 패키지를 한번 만들어보는 과정을 기록하면서 공부해보고, 실패하더라도 왜 실패했는지 원인분석을 해보는 것에 의의를 가지도록 하자 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Zod에서 스키마 정의가 필요한 이유]]></title>
            <link>https://velog.io/@sung-yeop/Zod%EC%97%90%EC%84%9C-%EC%8A%A4%ED%82%A4%EB%A7%88-%EC%A0%95%EC%9D%98%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@sung-yeop/Zod%EC%97%90%EC%84%9C-%EC%8A%A4%ED%82%A4%EB%A7%88-%EC%A0%95%EC%9D%98%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 21 Jul 2025 18:09:40 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>최근에 커스텀 로깅 패키지를 만들면서 <code>apiValidation</code> 이라는 유틸리티를 구현하려고 했다.</p>
<p>아이디어는 간단했다. 제네릭으로 타입을 받고 매개변수로 response를 받아서 타입 체크를 통해 문제가 있는 경우에만 콘솔로그를 찍도록 하는 것이었다.</p>
<p>하지만 구현 과정에서 TypeScript의 아주 기본적인 동작을 놓치고 있었기에 포스팅을 통해 회고해보려고 한다.</p>
<hr>
<h2 id="1-초기-아이디어">1. 초기 아이디어</h2>
<p>개발하다 보면 이런 상황들이 자주 발생한다</p>
<blockquote>
</blockquote>
<p>API 응답이 예상한 타입과 다른데 런타임에서야 발견됨
타입스크립트는 컴파일 타임에만 체크하고 런타임에서는 알 수 없음
매번 수동으로 응답 데이터를 검증하기 번거로움</p>
<pre><code class="language-typescript">export const postA = async (params: ApiRequest) =&gt; {
  const response = await api.post&lt;ApiResponse&gt;(
    &quot;/sample&quot;,
    params
  );
  return response.data;
};</code></pre>
<p>주로 이러한 방식으로 제네릭을 통해 타입 추론이 진행되도록 프론트에서 코드를 작성하는게 일반적이다.</p>
<p>하지만, 만약 백엔드에서 <code>ApiResponse</code> 타입에 맞지 않게 실수로 데이터를 넘겨주는 경우에는 postA 데이터를 사용하는 쪽에서 오류가 발생하게 된다. </p>
<p>(주로 사용하는쪽에서 <code>undefined</code> 가 발생하면 API를 잘못 넘겨주는 경우가 대부분이었다.)</p>
<p>경험상 네트워크 탭으로 API 응답 포맷을 명세와 비교하면서 찾았던 기억이 있다.</p>
<h3 id="💡-원하는-기능">💡 원하는 기능</h3>
<p>그래서 필자는 다음과 같은 기능을 구현해보고 싶었다.</p>
<blockquote>
<p><strong>간단한 제네릭 타입 검증</strong></p>
</blockquote>
<ul>
<li><code>Logger.apiValidation&lt;User&gt;(response)</code> 이런 식으로 사용<blockquote>
</blockquote>
</li>
<li><em>자동 타입 체크*</em></li>
<li>런타임에서 실제 응답이 타입과 일치하는지 확인<blockquote>
</blockquote>
</li>
<li><em>문제 발생시에만 로깅*</em></li>
<li>타입이 맞으면 동작 X / 틀리면 상세한 에러 로그</li>
</ul>
<hr>
<h2 id="2-구현-아이디어---불가능">2. 구현 아이디어 - 불가능</h2>
<p>처음에는 정말 단순하게 생각했던거 같다.</p>
<p>위에도 적었지만 <code>제네릭으로 타입을 받아서 런타임에 검증하면 되지 않을까?</code> 라는 아이디어였다.</p>
<h3 id="💡-이상적인-구현-불가능">💡 이상적인 구현 (불가능)</h3>
<pre><code class="language-typescript">class Logger {
 static apiValidation&lt;T&gt;(response: any): response is T {
   // T 타입의 구조를 알아서 response와 비교하고 싶었음
   const typeInfo = this.extractTypeInfo&lt;T&gt;();
   if(!Logger.validate(response, typeInfo)) {
     console.log(&quot;Api Response와 정의된 타입이 일치하지 않습니다.&quot;);
   };
 }
}

// 사용
const response = await api.get&lt;ApiResponse&gt;(&quot;/endpoint&quot;);
Logger.apiValidation&lt;ApiResponse&gt;(response)</code></pre>
<p>하지만, 여기서 가장 중요한건 <code>타입 스크립트는 컴파일시 타입 내용이 사라진다</code> 이다.</p>
<p>즉, 타입 자체가 값이 아니기 때문에 컴파일 시점에서는 이 타입이 사라지게 된다.</p>
<p>따라서, 타입을 이용한 값 검증 자체가 불가능하다.</p>
<hr>
<h2 id="3-그래서-스키마-정의가-필요한-이유">3. 그래서 스키마 정의가 필요한 이유</h2>
<p>이 시점에서 Zod를 다시 살펴보게 되었다.</p>
<p>왜 이렇게 복잡하게 스키마를 정의해야 하는 걸까?</p>
<pre><code class="language-typescript">import { z } from &#39;zod&#39;;

// 런타임에 존재하는 실제 객체로 스키마 정의
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email()
});

// 타입은 스키마에서 추출
type User = z.infer&lt;typeof UserSchema&gt;;

// 런타임 검증
const result = UserSchema.safeParse(response);</code></pre>
<p>여기서 직접 구현해보니 핵심을 이해할 수 있었다.</p>
<p>Zod는 내가 생각했던 아이디어와 정반대 방향으로 접근한다.</p>
<p>즉, 타입에서 스키마를 만드는 것이 아니라, 스키마에서 타입을 만드는 것이다.</p>
<p>이렇게 하면 UserSchema는 런타임에 실제로 존재하는 JavaScript 객체이기 때문에 실제 데이터와 비교할 수 있게 된다.</p>
<h3 id="💡-왜-스키마-정의가-필요한가">💡 왜 스키마 정의가 필요한가?</h3>
<p>결국 정리해보면 다음과 같다.</p>
<blockquote>
<p><strong>TypeScript 타입 !== 런타임 객체</strong></p>
</blockquote>
<ul>
<li>컴파일 후에는 타입 정보가 완전히 사라진다</li>
<li>런타임 검증을 위해서는 실제 JavaScript 객체가 필요하다!</li>
</ul>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번에 API 검증 유틸리티를 만들어보면서 TypeScript의 타입 시스템과 런타임의 차이점을 명확히 이해할 수 있었다.</p>
<p>타입스크립트는 개발 시점의 안전성을 보장해주지만, 런타임 검증은 별개의 문제였다. </p>
<p>결국 두 영역을 모두 커버하려면 스키마 정의는 필수적이었다...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[커스텀 Logger npm 패키지 구현해보기]]></title>
            <link>https://velog.io/@sung-yeop/%EC%BB%A4%EC%8A%A4%ED%85%80-Logger-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@sung-yeop/%EC%BB%A4%EC%8A%A4%ED%85%80-Logger-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Mon, 21 Jul 2025 13:36:00 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>요즘 개발하면서 디버깅할 때 console.log를 자주 사용하는데, 로깅을 너무 대충찍다보니 관리하는게 꽤 복잡했다</p>
<p>특히 복잡한 프로젝트에서 여러 파일에 걸쳐 로그가 흩어져 있으면, 어느 파일의 어느 함수에서 출력된 건지 추적하기가 쉽지 않았다.</p>
<p>그래서 이번에는 호출 위치를 자동으로 추적해주는 Logger 유틸리티를 만들어보기로 했다</p>
<hr>
<h2 id="1-초기-아이디어">1. 초기 아이디어</h2>
<p>개발하다 보면 이런 상황들이 자주 발생한다</p>
<blockquote>
</blockquote>
<p>여러 곳에서 console.log를 찍어놨는데 어디서 나온 로그인지 모르겠음
시간순으로 로그를 추적하고 싶은데 타임스탬프가 없어서 불편함
로그 레벨을 구분해서 관리하고 싶음</p>
<p>그래서 다음과 같은 기능들이 있으면 좋겠다고 생각했다</p>
<h3 id="💡-원하는-기능들">💡 원하는 기능들</h3>
<blockquote>
<p><strong>파일 위치 자동 추적</strong></p>
</blockquote>
<ul>
<li>어느 파일에서 호출됐는지 알 수 있으면 좋겠음</li>
<li><em>타임스탬프*</em></li>
<li>언제 호출됐는지 시간 정보 제공</li>
<li><em>로그 레벨*</em></li>
<li>INFO, ERROR, WARN으로 구분</li>
<li><em>일관된 포맷*</em></li>
<li>일관화된 로그 출력</li>
</ul>
<hr>
<h2 id="2-stack-trace-활용하기">2. Stack Trace 활용하기</h2>
<p>사실 많은 삽질을 했는데, 결국 찾아보니까 JavaScript의 Error 객체를 사용하면 현재 호출 스택 정보를 가져올 수 있다고 한다.</p>
<p>이를 활용해서 Logger가 어디서 호출되었는지 추적할 수 있다는 레퍼런스를 통해 구현해보기로 했다.</p>
<h3 id="💡-첫-번째-시도">💡 첫 번째 시도</h3>
<p>우선 간단하게 스택 정보를 확인해보자.</p>
<pre><code class="language-typescript">import { LogProps } from &quot;../types/Logger.type&quot;;

export class Logger {
  private static isDev = process.env.NODE_ENV === &quot;development&quot;;

  private static getCallerInfo() {
    const stack = new Error().stack; // 스택 추적
    console.log(&quot;stack : &quot;, stack);
    if (!stack) return null;
    const caller = stack[1];
    return { caller };
  }

  static log({ message, logLevel }: LogProps) {
    const timestamp = new Date().toISOString();
    const callerInfo = Logger.getCallerInfo();

    if (logLevel === &quot;INFO&quot;) {
      console.log(&quot;==============================&quot;);
      console.log(
        &quot;파일 위치:&quot;, callerInfo?.caller,
        &quot;\n&quot;, &quot;Message:&quot;, message,
        &quot;\n&quot;, &quot;TimeStamp:&quot;, timestamp
      );
    }
  }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/ccfd8008-8cbd-4a95-843a-e1ec81380fa4/image.png" alt=""></p>
<p>로그 결과를 살펴보면 파일 위치는 stack[3]에 저장되는 모습을 볼 수 있었다.</p>
<hr>
<h2 id="3-파일-위치-파싱-로직-구현">3. 파일 위치 파싱 로직 구현</h2>
<p>스택 정보를 파싱해서 읽기 쉬운 형태로 변환해보자</p>
<pre><code class="language-typescript">import { LogProps } from &quot;../types/Logger.type&quot;;

export class Logger {
  private static isDev = process.env.NODE_ENV === &quot;development&quot;;

  private static getCallerInfo() {
    const stack = new Error().stack; // 스택 추적
    console.log(&quot;stack : &quot;, stack);
    if (!stack) return null;

    const caller = stack.split(&quot;\n&quot;);
    const parserLineArr = caller[3].replace(/:\d+:\d+\)/, &quot;&quot;).split(&quot;/&quot;);
    const fileLocation =
      parserLineArr.length &gt; 2
        ? parserLineArr[parserLineArr.length - 2].concat(
            &quot;/&quot;,
            parserLineArr[parserLineArr.length - 1]
          )
        : parserLineArr[parserLineArr.length - 1];

    return {
      fileLocation,
    };
  }

  static log({ message, logLevel }: LogProps) {
    const timestamp = new Date().toISOString();
    const callerInfo = Logger.getCallerInfo();

    if (logLevel === &quot;INFO&quot;) {
      console.log(&quot;==============================&quot;);
      console.log(
        &quot;파일 위치:&quot;, callerInfo?.fileLocation,
        &quot;\n&quot;, &quot;Message:&quot;, message,
        &quot;\n&quot;, &quot;TimeStamp:&quot;, timestamp
      );
    }
  }
}</code></pre>
<p>caller[3]에서 마지막 <code>:</code> 으로 시작하는 코드 라인은 제거하고 파일 위치만 가져오도록 구현했다.</p>
<p>로컬에서는 제대로 동작하는 모습을 확인하고 다른 프로젝트에서 적용해보니 다음과 같이 로그가 찍히는 문제가 있었다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/e2a2d352-be10-4531-a644-fdf166df4f66/image.png" alt=""></p>
<p><code>dist/</code> 는 패키지를 빌드하면서 생긴 빌드 결과물을 저장하는 디렉토리인데, 트레이싱을 제대로 못하고 있는 것 같았다.</p>
<hr>
<h2 id="4-빌드-환경-대응하기">4. 빌드 환경 대응하기</h2>
<p>패키지로 배포했을 때 <code>dist/index</code> 같은 빌드된 파일 경로가 나오는 문제를 해결해야 했다.</p>
<h3 id="💡-실제-호출-위치-찾기">💡 실제 호출 위치 찾기</h3>
<p>Logger 자체의 스택이 아닌, 실제로 Logger를 호출한 위치를 찾아야 했다.</p>
<p>따라서, 다음과 같은 로직을 추가했다.</p>
<pre><code class="language-typescript">let targetCaller = caller[3];
for (let i = 3; i &lt; caller.length; i++) {
  if (
    !caller[i].includes(&quot;Logger.util&quot;) &amp;&amp;
    !caller[i].includes(&quot;dist/index&quot;)
  ) {
    targetCaller = caller[i];
    break;
  }
}</code></pre>
<p>기존에는 caller[3]만 추적했으나, 해당 로직을 추가해서 <code>dist/index</code> 와 <code>Logger.util.ts</code> 파일을 제외한 실제 호출 위치를 찾도록 수정했다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/84ca4821-1643-4208-bd81-b2e2c584f372/image.png" alt=""></p>
<p>제대로 호출되는 모습을 볼 수 있었다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번에 Logger 유틸리티를 만들어보면서 JavaScript의 스택 추적 기능을 활용하는 방법을 배울 수 있었다.</p>
<p>처음에는 단순히 로그에 파일 위치만 추가하려고 했는데, 패키지 빌드 환경까지 고려해야한다는 걸 앞으로는 생각해야겠다.</p>
<p>이제부터 이 패키지에서 <code>Logger.api</code>, <code>Logger.state</code> 와 같이 로깅을 조금 더 쉽게할 수 있도록 유틸을 추가해나가면 될 것 같다!</p>
<p>(만들고나서 생각해보니 파일 위치 추적이 필요한가..?)
<img src="https://velog.velcdn.com/images/sung-yeop/post/78355f85-90ee-4cb4-9071-c46653ef40d3/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[FE] 에러핸들러 패키지 만들어보기]]></title>
            <link>https://velog.io/@sung-yeop/FE-%EC%97%90%EB%9F%AC%ED%95%B8%EB%93%A4%EB%9F%AC-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@sung-yeop/FE-%EC%97%90%EB%9F%AC%ED%95%B8%EB%93%A4%EB%9F%AC-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sat, 05 Jul 2025 02:09:33 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이번에 운이좋게도 새로운 프로젝트를 진행하게 되었다.</p>
<p>UI를 Next.js 환경에서 App Router를 사용하여 개발을 진행하는 중이다.</p>
<p>이전에 POPI 프로젝트를 진행하면서 고민했던 부분 중 하나는 <code>에러를 어디서 어떻게 처리하는게 적절할까?</code> 였다.</p>
<p>간단하게 Toast UI를 사용해서 사용자에게 에러가 발생했음을 알려주고 끝내는 것이상으로 코드 베이스에서 조금더 체계적으로 관리해보고 싶은 욕심이 항상 있었다.</p>
<p>해당 프로젝트에서 에러를 조금 더 체계적으로 관리해보려고 했던 고민과 그로인해 패키지를 만들어본 경험을 포스팅을 통해 공유해보고자 한다!</p>
<hr>
<h2 id="1-초기-아이디어">1. 초기 아이디어</h2>
<p>우선 프로젝트에서 피그마 시안을 살펴봤을 때, 특정 에러가 발생하면 아주 비슷한 UI의 에러 페이지가 나타나도록 하는 요구사항이 있었다.</p>
<p>조금더 자세히 말하자면, 다음과 같다</p>
<blockquote>
<p><strong>When Error Occured</strong></p>
</blockquote>
<ul>
<li>A 페이지 Error 발생 -&gt; A` 문구 &amp; A` 버튼 이벤트</li>
<li>B 페이지 Error 발생 -&gt; B` 문구 &amp; B` 버튼 이벤트</li>
</ul>
<p>즉, 필자가 판단하기에는 동일한 에러 페이지를 재사용하는게 좋다고 판단했다</p>
<p>하지만 다음과 같은 문제가 있었다.</p>
<h3 id="💡-callback을-전달할-수-없다">💡 callback을 전달할 수 없다!</h3>
<p>프로젝트는 Next의 App Router 세팅으로 되어있었기 때문에, 페이지 라우팅을 위해서는 <code>useRouter</code> 를 사용하게 된다.</p>
<p>문제는 <code>useRouter</code> 를 사용해서 페이지로 데이터를 넘기기 위해서는 쿼리 파라미터를 사용해야하며, 버튼을 누를 때 발생하는 <code>callback</code> 함수를 넘기기가 애매하다는 것이다.</p>
<h3 id="💡-util-함수-사용">💡 util 함수 사용</h3>
<p>그래서 처음에는 Util함수를 정의해서 이를 해결하려고 했다.</p>
<p>간단하게 아이디어를 공유해보면, 다음과 같이 코드를 작성할 수 있을 것 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">&quot;use client&quot;;
&gt;
import { ErrorCodeType } from &quot;@/constants/Error&quot;;
import { useRouter } from &quot;next/navigation&quot;;
&gt;
export const useErrorRouter = () =&gt; {
  const router = useRouter();
&gt;
  const errorRouter = (errorCode: ErrorCodeType) =&gt; {
    router.push(`/error?errorCode=${errorCode}`);
  };
&gt;
  return { errorRouter };
};</code></pre>
<p>위처럼 에러페이지에 상수로 정의되어있는 <code>errorCode</code> 를 넘겨준다</p>
<p>다음으로 에러 페이지에서는 이 에러 코드를 기반으로 미리 정의되어있는 에러 메세지를 불러올 수 있다</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">&quot;use client&quot;;
&gt;
export default function ErrorPage() {
  const searchParams = useSearchParams();
  const errorCode = searchParams.get(&quot;errorCode&quot;) as ErrorCodeType;
&gt;
  return (
    &lt;div className=&quot;flex flex-col justify-center items-center h-screen&quot;&gt;
      &lt;Image src={failed} width={300} alt=&quot;실패 아이콘&quot; /&gt;
      &lt;p className=&quot;font-large-title-b mt-1 mb-6&quot;&gt;
        {getErrorMessage(errorCode)}
      &lt;/p&gt;
      &lt;GlobalBtn
        title=&quot;Retry&quot;
        onClick={() =&gt; {}}
        className=&quot;mt-16 py-4 px-[115.5px]&quot;
      /&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>사용하는 곳에서는 다음과 같이 사용할 수 있을 것이다</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript"> const { errorRouter, router } = useErrorRouter();
&gt;
 const handle = () =&gt; {
    errorRouter(ERROR_CODES.AUTHENTICATION.INVALID_CREDENTIALS);
 };</code></pre>
<p>여기서 발생하는 문제는 이전에도 언급한 것처럼 버튼을 클릭할 때 발생하는 callback을 넘겨줄 수 없다는 것이다.</p>
<p>커스텀 훅을 별도로 생성하는 경우에는 사용하는 곳마다 서로 다른 인스턴스가 생성되기 때문에 서로 다른 상태를 공유하게 된다.</p>
<p>따라서, 당연히 <code>useErrorRouter</code> 훅 내부에서 callback을 저장하는 상태변수를 선언하더라도 이를 사용하기란 어렵다.</p>
<p>간단하게 생각해보면, 에러를 핸들링하는 지점에서 정의된 callback을 전역상태에 저장하고 Error Page에서 사용하는 방식이 적절해보인다.</p>
<hr>
<h2 id="2-전역-상태로-관리하는게-맞을까">2. 전역 상태로 관리하는게 맞을까?</h2>
<p>본 프로젝트에서는 전역 상태 관리툴로 <code>Zustand</code> 를 사용하고 있다.</p>
<p>아주 간단하게 코드를 작성한다면 Zustand로 Store를 하나 생성해서 에러발생시 callback을 store에 정의하고, Error Page에서 꺼내 사용하면 된다.</p>
<p>하지만 Zustand를 사용하게 되면, 개발자가 너무 쉽게 접근할 수 있을 것 같다는 생각을 했다.</p>
<p>예를 들어, 다른 개발자가 실수로 에러와 관련 없는 곳에서 해당 store에 접근해서 callback을 변경하거나 초기화할 수 있다는 우려가 있었다.</p>
<p>또한 에러 처리라는 특정 도메인에 대한 로직을 전역 상태에 노출시키는 것이 과연 좋은 설계인지에 대한 의문도 들었다.</p>
<p>에러 핸들링은 애플리케이션 전반에서 사용되지만, 그렇다고 해서 모든 컴포넌트에서 자유롭게 접근할 수 있어야 하는 것은 아니라고 생각했다.</p>
<p>그래서 좀 더 엄격하고 체계적으로 에러를 관리할 수 있는 방법이 없을까 고민하게 되었다.</p>
<p>뭔가 에러 처리만을 위한 독립적인 시스템을 만들고 싶었고, 이것이 패키지를 만들어보자는 아이디어로 이어졌다.</p>
<hr>
<h2 id="3-error-handler-구현하기">3. Error Handler 구현하기</h2>
<p>본 프로젝트에서는 <code>React Query</code> 를 사용해서 서버 상태를 관리하고 있다.</p>
<p><code>React Query</code> 를 세팅하는 방식을 살펴보면 <code>children</code> 을 매개변수로 전달받는데, 루트를 전달받도록 세팅한다.</p>
<p>또한, 세팅하기 이전에 <code>queryClient</code> 를 생성해서 매개변수로 전달한다.</p>
<p>필자는 이처럼 React Query를 세팅하는 방식에서 아이디어를 얻어 React Query 세팅하는 방식과 유사하게, <code>Context API</code> 를 사용해서 패키지를 구성하기로 결정했다.</p>
<h3 id="💡-에러핸들러-구성하기">💡 에러핸들러 구성하기</h3>
<p>에러 핸들러에서 사용하는 타입 정의를 살펴보면 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code - Type 정의</strong></p>
</blockquote>
<pre><code class="language-typescript">export interface Configure&lt;T extends Record&lt;string, ConfigDetail&gt;&gt; {
  errorCode: T;
  router: any;
}
&gt;
export type ConfigDetail = {
  key: string;
  mainMsg: string;
  subMsg?: string[];
  icon?: string;
  router: string;
};
&gt;
type Props&lt;T extends Record&lt;string, ConfigDetail&gt;&gt; = {
  children: ReactNode;
  config: Configure&lt;T&gt;;
};</code></pre>
<p>ReactQueryProvider를 세팅할때처럼 최초 Config를 정의하고 받는 부분이 <code>Configure</code> 이다.</p>
<p>Configure에는 ConfigDetail이라는 타입이 정의되어있어야만 하고, 이를 처음으로 받아들인다.</p>
<blockquote>
<p><strong>Sample Code - Provider 구현</strong></p>
</blockquote>
<pre><code class="language-typescript">export const ErrorHandlerProvider = &lt;T extends Record&lt;string, ConfigDetail&gt;&gt;({
  children,
  config,
}: Props&lt;T&gt;) =&gt; {
  const [pendingCallback, setPendingCallback] = useState&lt;Function | null&gt;(null);
  const [btnLabel, setBtnLabel] = useState&lt;string&gt;(&quot;&quot;);
  const [errorConfig, setErrorConfig] = useState&lt;ConfigDetail | null&gt;(null);
&gt;
  const handleError = (
    errorKey: string,
    callback?: Function,
    btnLabel?: string
  ) =&gt; {
    const errorConfig = config.errorCode[errorKey];
    if (!errorConfig) {
      throw new Error(`Error Key &#39;${errorKey}&#39;를 찾지 못했습니다.`);
    }
&gt;
    setErrorConfig(errorConfig);
&gt;
    if (callback) {
      setPendingCallback(() =&gt; callback);
    }
&gt;
    if (btnLabel) {
      setBtnLabel(btnLabel);
    }
&gt;
    config.router.push(errorConfig.router);
  };
&gt;
  const executeCallback = () =&gt; {
    if (pendingCallback) {
      pendingCallback();
      setPendingCallback(null);
    }
  };
&gt;
  const getMainMsg = () =&gt; {
    return errorConfig?.mainMsg ?? &quot;&quot;;
  };
&gt;
  const getSubMsg = () =&gt; {
    return errorConfig?.subMsg ?? [];
  };
&gt;
  return (
    &lt;ErrorHandlerContext.Provider
      value={{ handleError, executeCallback, btnLabel, getMainMsg, getSubMsg }}
    &gt;
      {children}
    &lt;/ErrorHandlerContext.Provider&gt;
  );
};</code></pre>
<p>다음으로 구현된 내용을보면, 최초 세팅에서 받아온 데이터에서 다양한 유틸을 제공한다.</p>
<p><code>handlerError</code> 는 에러를 발생시키는 지점에서, 그리고 <code>executeCallback</code> , <code>btnLabel</code> , <code>getMainMsg</code> , <code>getSubMsg</code> 등은 모두 Error Page에서 사용하려고 한다.</p>
<blockquote>
<p><strong>Sample Code - Router 구현</strong></p>
</blockquote>
<pre><code class="language-typescript">export const useErrorHandler = () =&gt; {
  const context = useContext(ErrorHandlerContext);
  if (!context) {
    throw new Error(&quot;useErrorHandler는 ErrorHandlerProvider안에서 사용하세요!&quot;);
  }
  return context;
};</code></pre>
<p>이를 컨텍스트로 담아서 훅으로 사용할 수 있도록 제공한다.</p>
<blockquote>
<p><strong>Sample Code - Export</strong></p>
</blockquote>
<pre><code class="language-typescript">export { ErrorHandlerProvider } from &quot;./ErrorHandlerProvider&quot;;
export { useErrorHandler } from &quot;./useErrorHandler&quot;;
export type { Configure, ConfigDetail, ErrorHandlerContextType } from &quot;./ErrorHandlerProvider&quot;;</code></pre>
<p>다음으로 이를 export해준다.</p>
<p>사용하는 곳에서 여기서 정의한 타입을 사용할 수 있도록 타입도 같이 Export 해주도록 하자</p>
<hr>
<h2 id="4-github-packages-사용하기">4. Github Packages 사용하기</h2>
<p>다음으로 개발한 패키지를 번들링하기 위해서 <code>Rollup</code> 을 사용해보기로 했다.</p>
<p>라이브러리나 패키지를 만들 때 특히 유용한데, <code>Tree-shaking</code> 이 기본으로 지원되어 불필요한 코드를 제거해주고, 번들 크기를 최소화할 수 있다는 장점이 있다.</p>
<p>이 부분은 다른분들이 진행했던 세팅을 열심히 참고해서 만들었다 😅</p>
<pre><code class="language-javascript">import resolve from &#39;@rollup/plugin-node-resolve&#39;;
import commonjs from &#39;@rollup/plugin-commonjs&#39;;
import typescript from &#39;@rollup/plugin-typescript&#39;;
import peerDepsExternal from &#39;rollup-plugin-peer-deps-external&#39;;

export default {
  input: &#39;src/index.ts&#39;,
  output: [
    {
      file: &#39;dist/index.js&#39;,
      format: &#39;cjs&#39;,
      sourcemap: true,
    },
    {
      file: &#39;dist/index.esm.js&#39;,
      format: &#39;esm&#39;,
      sourcemap: true,
    },
  ],
  plugins: [
    peerDepsExternal(),
    resolve(),
    commonjs(),
    typescript({
      tsconfig: &#39;./tsconfig.json&#39;,
    }),
  ],
  external: [&#39;react&#39;, &#39;react-dom&#39;],
};</code></pre>
<h3 id="💡-packagejson-설정">💡 Package.json 설정</h3>
<p>패키지 배포를 위해서는 package.json도 적절히 설정해야 한다.</p>
<pre><code class="language-yaml">{
  &quot;name&quot;: &quot;@패키지명&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;description&quot;: &quot;Next.js App Router 환경에서 에러를 관리하는 패키지&quot;,
  &quot;main&quot;: &quot;dist/index.js&quot;,
  &quot;module&quot;: &quot;dist/index.esm.js&quot;,
  &quot;types&quot;: &quot;dist/index.d.ts&quot;,
  &quot;files&quot;: [
    &quot;dist&quot;
  ],
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;rollup -c&quot;,
    &quot;prepublishOnly&quot;: &quot;npm run build&quot;
  },
  &quot;peerDependencies&quot;: {
    &quot;react&quot;: &quot;&gt;=18.0.0&quot;,
    &quot;react-dom&quot;: &quot;&gt;=18.0.0&quot;
  },
  &quot;devDependencies&quot;: {
    &quot;@rollup/plugin-commonjs&quot;: &quot;^25.0.0&quot;,
    &quot;@rollup/plugin-node-resolve&quot;: &quot;^15.0.0&quot;,
    &quot;@rollup/plugin-typescript&quot;: &quot;^11.0.0&quot;,
    &quot;rollup&quot;: &quot;^4.0.0&quot;,
    &quot;rollup-plugin-peer-deps-external&quot;: &quot;^2.2.4&quot;,
    &quot;typescript&quot;: &quot;^5.0.0&quot;
  }
}</code></pre>
<p>특히 main, module, types 필드를 통해 각각 CommonJS, ES Module, TypeScript 타입 정의 파일의 진입점을 명시해주는 것이 중요하다.</p>
<h3 id="💡-에러-핸들러를-사용하기">💡 에러 핸들러를 사용하기</h3>
<p>그래서 사용해보면 다음과 같다</p>
<blockquote>
<p><strong>Sample Code - 설정</strong></p>
</blockquote>
<pre><code class="language-typescript">import { ErrorHandlerProvider } from &quot;@배포한 패키지&quot;;
&gt;
export default function CustomErrorHandlerProvider({ children }: Props) {
  const router = useRouter();
&gt;
  const config = useMemo(() =&gt; {
    return {
      router,
      errorCode: ERROR_HANDLER_CONFIG,
    };
  }, [router]);
&gt;
  return (
    &lt;ErrorHandlerProvider config={config}&gt;{children}&lt;/ErrorHandlerProvider&gt;
  );
}</code></pre>
<p>이처럼 config를 설정한 이후 넘겨준다.</p>
<blockquote>
<p><strong>Sample Code - 에러 페이지</strong></p>
</blockquote>
<pre><code class="language-typescript">import { useErrorHandler } from &quot;@배포한 패키지&quot;;
&gt;
function ErrorContent() {
  const { getMainMsg, getSubMsg, btnLabel, executeCallback } =
    useErrorHandler();
&gt;
  const mainMsg = getMainMsg();
  const subMsg = getSubMsg();
&gt;
  return (
    &lt;div className=&quot;flex flex-col justify-center items-center h-screen&quot;&gt;
      &lt;Image src={failed} width={180} alt=&quot;실패 아이콘&quot; /&gt;
      &lt;p className=&quot;font-large-title-b mt-1 mb-6&quot;&gt;{mainMsg}&lt;/p&gt;
      {subMsg.length &gt; 0 &amp;&amp;
        subMsg.map((msg, index) =&gt; (
          &lt;p key={index} className=&quot;font-title1-r&quot;&gt;
            {msg}
          &lt;/p&gt;
        ))}
      &lt;div className=&quot;flex flex-col gap-[12px] mt-16&quot;&gt;
        &lt;GlobalBtn
          title={`${btnLabel}`}
          onClick={executeCallback}
          className=&quot;py-4 px-[115.5px]&quot;
        /&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>다음으로 패키지에서 정의했던 useErrorHandler 를 불러와서 에러페이지에서 사용하도록 수정해봤다.</p>
<p>실제로 에러를 발생시키는 지점에서는 다음과 같이 사용할 수 있다</p>
<blockquote>
<p><strong>Sample Code - 에러 발생</strong></p>
</blockquote>
<pre><code class="language-typescript">const { handleError } = useErrorHandler();
&gt;
const handleLoginError = () =&gt; {
  handleError(
    &#39;LOGIN_FAILED&#39;,
    () =&gt; router.push(&#39;/login&#39;),
    &#39;Retry&#39;
  );
};</code></pre>
<p>이렇게 구성하니 에러 코드와 콜백 함수, 버튼 라벨을 깔끔하게 전달할 수 있게 되었다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 프로젝트를 통해 Next.js App Router 환경에서 에러를 관리하기 위한 패키지를 만들어봤다.</p>
<p>처음에는 단순히 콜백 함수를 페이지 간에 전달하는 문제에서 시작했지만, 결국 에러 핸들링 전용 패키지를 만들게 되었다 😅</p>
<p>Context API를 활용해서 에러 관련 상태를 캡슐화하고, 전역 상태 관리툴을 사용하지 않고도 에러 핸들링만을 위한 독립적인 시스템을 구축할 수 있다는 점이 개인적으로는 만족스러웠다.</p>
<p>Rollup을 사용한 번들링과 Github Packages를 통한 배포 과정도 새로운 경험이었고, 어쩌면 다른 프로젝트에서도 재사용할 수 있는 유용한 도구를 만든 것 같기도 하다.</p>
<p>현업에서는 모노레포 방식을 자주 사용한다고 하는데, 아무래도 하나의 팀에서 여러개의 어플리케이션을 만드는게 아니다보니까 접할기회가 없는게 조금 아쉽다.</p>
<p>스스로는 에러 핸들링 툴을 만들었다고 하지만, 물론 아직 개선할 점들이 많이 있다. (타입정의도 매끄럽게 되지 않았다)</p>
<p>더 나은 설계를 고민해봤다는데에 의의를 두자!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[FE] Prefetch 사용해보기]]></title>
            <link>https://velog.io/@sung-yeop/prefetch-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@sung-yeop/prefetch-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sat, 28 Jun 2025 10:02:29 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>프로젝트에서 사용자 어플리케이션을 개발하고 실제로 사용해보니 약간의 UX 문제를 발견했다.</p>
<p>바텀시트에서 월을 변경할 때마다 로딩 인디케이터가 나타나서 사용자 경험이 매끄럽지 않았던 것이다.</p>
<p>이번 포스팅을 통해, 어떤 문제였고 어떻게 해결했는지 아주 간단하게 소개해보려고 한다!</p>
<hr>
<h2 id="1-problem">1. Problem</h2>
<p>문제 상황은 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/3ca4b549-68ea-431e-929f-2b9ffeb67a0a/image.gif" alt=""></p>
<h3 id="💡-문제-원인">💡 문제 원인</h3>
<p>바텀시트를 열고 월을 변경할 때마다 새로운 데이터를 불러오기 때문에 잠깐동안 로딩 인디케이터가 돌아가는 문제였다.</p>
<p>React Query를 사용하고 기본 캐싱 시간을 5분으로 설정해놨기 때문에 다음과 같이 동작하고 있었다.</p>
<blockquote>
<p><strong>동작 순서</strong></p>
</blockquote>
<ol>
<li>예약 페이지 접근</li>
<li>현재 월 데이터 패치 (6월)</li>
<li>바텀 시트 열기 (6월 데이터 저장되어있음)</li>
<li>7월로 변경</li>
<li>7월 데이터 API 요청 </li>
<li>인디케이터 발생</li>
<li>7월 데이터 캐싱 </li>
<li>데이터가 변경되어 다시 Default 세팅인 6월로 이동</li>
<li>8월로 이동 -&gt; 1~8 과정 반복</li>
</ol>
<p>매번 새로운 월로 이동할 때마다 로딩이 발생하니 UX가 정말 좋지 않았다..</p>
<hr>
<h2 id="2-solve">2. Solve</h2>
<p>찾아보니 React Query에서는 Prefetch 기능을 아주 간단하게 제공하고 있었다.</p>
<p>현재 월 데이터를 가져올 때 미리 전후 월 데이터도 함께 가져와서 캐시에 저장해두면, 사용자가 월을 변경해도 로딩 없이 바로 데이터를 보여줄 수 있다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">export const useGetReservationInfoApi = ({ popupId, yyyyMM }: GetReservationInfoRequest) =&gt; {
  const queryClient = useQueryClient();
&gt;
  const query = useQuery({
    queryKey: [&#39;reservationInfo&#39;, popupId, yyyyMM],
    queryFn: () =&gt; getReservationInfo({ popupId, yyyyMM }),
    staleTime: 5 * 60 * 1000,
    gcTime: 10 * 60 * 1000,
    refetchOnWindowFocus: false,
    refetchOnMount: false,
  });
&gt;
  useEffect(() =&gt; {
    if (query.data &amp;&amp; !query.isLoading) {
      const currentDate = new Date(yyyyMM + &#39;-01&#39;);
&gt;
      const nextMonth = new Date(currentDate);
      nextMonth.setMonth(nextMonth.getMonth() + 1);
      const nextYearMonth = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth() + 1).padStart(2, &#39;0&#39;)}`;
&gt;
      const prevMonth = new Date(currentDate);
      prevMonth.setMonth(prevMonth.getMonth() - 1);
      const prevYearMonth = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, &#39;0&#39;)}`;
&gt;
      queryClient.prefetchQuery({
        queryKey: [&#39;reservationInfo&#39;, popupId, nextYearMonth],
        queryFn: () =&gt; getReservationInfo({ popupId, yyyyMM: nextYearMonth }),
        staleTime: 5 * 60 * 1000,
      });
&gt;
      queryClient.prefetchQuery({
        queryKey: [&#39;reservationInfo&#39;, popupId, prevYearMonth],
        queryFn: () =&gt; getReservationInfo({ popupId, yyyyMM: prevYearMonth }),
        staleTime: 5 * 60 * 1000,
      });
    }
  }, [query.data, query.isLoading, popupId, yyyyMM, queryClient]);
&gt;
  return {
    reservationInfo: query.data?.data,
    isLoading: query.isLoading,
    isError: query.isError,
  };
};</code></pre>
<p>기존에는 useQuery만 있었지만, useEffect를 추가해서 데이터가 로드되면 prefetchQuery로 전후 월 데이터를 미리 가져오도록 수정했다.</p>
<p>사용자가 이전 달로 갈 수도 있고 다음 달로 갈 수도 있기 때문에, 현재 월 기준으로 ±1개월 데이터를 모두 prefetch하도록 했다.</p>
<p>결과는 다음과 같이 로딩 없이 매끄럽게 동작하는 모습을 확인할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/d8cc3164-e6a7-4157-8f35-9c605db6eb9e/image.gif" alt=""></p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>간단한 UX 개선이었지만 사용자 입장에서는 체감되는 차이가 꽤 컸다.</p>
<p>사용자가 다음에 할 행동을 예측해서 미리 데이터를 준비해두면, 마치 데이터가 이미 있었던 것처럼 즉시 보여줄 수 있다.</p>
<p>물론 불필요한 API 호출이 늘어날 수 있으니 적절히 사용하는 것이 중요하겠지만, 이번 경우처럼 사용자가 높은 확률로 접근할 데이터라면 충분히 활용할 만한 기능이라고 생각한다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[FE] 팀 개발을 위한 조건부 렌더링 규칙 만들기]]></title>
            <link>https://velog.io/@sung-yeop/Fallback-UI</link>
            <guid>https://velog.io/@sung-yeop/Fallback-UI</guid>
            <pubDate>Tue, 24 Jun 2025 12:29:37 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>개발을 진행하면서 코드가 꽤 읽기 어려운 경우를 모두 경험해봤을 것이다.</p>
<p>필자의 경우에도 본 프로젝트에서 그런 경험을 가끔 느꼈는데, 그런 경우 대부분 조건문이 하나의 컴포넌트 내에서 많이 적용된 경우라는 것을 알게 되었다.</p>
<p>하나씩 따라가다 보면 물론 이해는 되지만, 어떻게 하면 조금 더 깔끔하게 코드를 작성할 수 있을지 고민했던 경험과 그 결과물을 한번 짤막하게 공유해보려고 한다 👀</p>
<hr>
<h2 id="1-언제-코드가-읽기-힘든가">1. 언제 코드가 읽기 힘든가?</h2>
<p>이전에 말했던 것처럼 필자의 경우에는 조건문 분기가 하나의 컴포넌트 내부에서 많아지는 경우라고 생각한다.</p>
<p>다음 코드를 살펴보자</p>
<blockquote>
<p><strong>Sample Code - 1</strong></p>
</blockquote>
<pre><code class="language-typescript">export default function DashBoardConversionRate() {
  const { data } = useConversionApi();
  const isLowDataExist = data &amp;&amp; data.low.length &gt; 0;
  const isHighDataExist = data &amp;&amp; data.high.length &gt; 0;
&gt;
  return (
    &lt;div className=&quot;flex flex-col&quot; data-testid=&quot;dashboard-conversionRate&quot;&gt;
      &lt;DashBoardTitle title=&quot;구매전환율&quot; /&gt;
      &lt;div className=&quot;flex justify-between&quot;&gt;
        &lt;div className=&quot;relative w-[660px] h-[510px] bg-gray02 rounded-[50px] px-6 flex justify-center&quot;&gt;
          &lt;div className=&quot;mt-7 flex text-gray09 font-medium text-[28px]&quot;&gt;
            하위 상품 TOP 6
          &lt;/div&gt;
          &lt;div
            className={`absolute bottom-6 w-[612px] h-[394px] ${isLowDataExist ? &quot;bg-gray01&quot; : &quot;&quot;} rounded-[40px] flex justify-center`}
          &gt;
            {isLowDataExist ? (
              &lt;ConversionRateChart
                data={data.low}
                barColor=&quot;#FFDCEA&quot;
                lineColor=&quot;#9F9FF8&quot;
                tooltipColorClass={{
                  interested: &quot;text-purple07&quot;,
                  purchased: &quot;text-main03&quot;,
                  rate: &quot;text-blue07&quot;,
                }}
                legendColors={{
                  interested: &quot;#9F9FF8&quot;,
                  purchased: &quot;#FFB4D1&quot;,
                }}
              /&gt;
            ) : (
              &lt;div className=&quot;absolute bottom-6 w-[612px] h-[394px]&quot;&gt;
                &lt;NoDataCompt /&gt;
              &lt;/div&gt;
            )}
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;div className=&quot;relative w-[660px] h-[510px] bg-gray02 rounded-[50px] px-6 flex justify-center&quot;&gt;
          &lt;div className=&quot;mt-7 flex text-gray09 font-medium text-[28px]&quot;&gt;
            상위 상품 TOP 6
          &lt;/div&gt;
          &lt;div
            className={`absolute bottom-6 w-[612px] h-[394px] ${isHighDataExist ? &quot;bg-gray01&quot; : &quot;&quot;} rounded-[40px] flex justify-center`}
          &gt;
            {isHighDataExist ? (
              &lt;ConversionRateChart
                data={data.high}
                barColor=&quot;#C5EFE8&quot;
                lineColor=&quot;#78B0FF&quot;
                tooltipColorClass={{
                  interested: &quot;text-blue07&quot;,
                  purchased: &quot;text-mint07&quot;,
                  rate: &quot;text-main04&quot;,
                }}
                legendColors={{
                  interested: &quot;#78B0FF&quot;,
                  purchased: &quot;#C5EFE8&quot;,
                }}
              /&gt;
            ) : (
              &lt;div className=&quot;absolute bottom-6 w-[612px] h-[394px]&quot;&gt;
                &lt;NoDataCompt /&gt;
              &lt;/div&gt;
            )}
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>위 코드는 우리가 개발했던 대시보드 컴포넌트 중 하나인데, <code>isLowDataExist</code> , <code>isHighDataExist</code> 와 같은 조건을 확인하여 조건부 렌더링을 진행하고 있다.</p>
<p>물론 기능적으로는 문제없지만, 동일한 구조가 두 번 반복되고 있고 조건부 렌더링 로직이 JSX에 직접 포함되어 있어서 코드가 깔끔하지 않다는 느낌이 든다.</p>
<p>다른 예시를 살펴보면 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code - 2</strong></p>
</blockquote>
<pre><code class="language-typescript">export default function EntireItemsScreen() {
  const { hotItems } = useLocalSearchParams&lt;{ hotItems: string }&gt;();
  const { selectedPopUpId } = usePopUpStore();
  const formattedPopularItems = ParseStringToJson(hotItems) as ItemUrlType[];
  const { searchResult, isLoading, hasMore, loadMore } = useSearch();
  const { keyword } = useSearchStore();
  const { allItems, isItemLoading, isItemError, allItemsQuery } = usePopUpDetailAllItemsApi({
    popupId: selectedPopUpId,
  });
&gt;
  const isSearchMode = keyword.trim().length &gt; 0;
&gt;
  const renderSearchBar = useCallback(
    () =&gt; &lt;SearchBarTextInput flag=&quot;ITEM&quot; placeholder=&quot;찾으시는 굿즈가 있나요?&quot; /&gt;,
    [],
  );
&gt;
  const handleLoadMore = useCallback(() =&gt; {
    if (hasMore &amp;&amp; !isLoading) {
      loadMore();
    }
  }, [hasMore, isLoading, loadMore]);
&gt;
  const renderFooter = useCallback(() =&gt; {
    if (!isLoading || !hasMore) return null;
    return (
      &lt;View style={{ padding: 20, alignItems: &#39;center&#39; }}&gt;
        &lt;ActivityIndicator size=&quot;small&quot; color=&quot;white&quot; /&gt;
      &lt;/View&gt;
    );
  }, [isLoading, hasMore]);
&gt;
  const renderEmptyComponent = useCallback(() =&gt; {
    if (isLoading) {
      return (
        &lt;View style={{ padding: 50, alignItems: &#39;center&#39; }}&gt;
          &lt;ActivityIndicator size=&quot;large&quot; color=&quot;white&quot; /&gt;
        &lt;/View&gt;
      );
    }
    return &lt;NoItem title=&quot;검색 결과와 일치하는 상품이 없어요&quot; /&gt;;
  }, [isLoading]);
&gt;
  // ... 다른 useCallback 함수들
&gt;
  const renderSearchContent = useCallback(
    () =&gt; (
      &lt;FlatList
        data={searchResult as PostItemSearch[]}
        renderItem={renderSearchItem}
        numColumns={2}
        columnWrapperStyle={{ justifyContent: &#39;space-between&#39;, gap: 12 }}
        contentContainerStyle={{ paddingBottom: 100, paddingHorizontal: 12 }}
        showsVerticalScrollIndicator={false}
        onEndReached={handleLoadMore}
        onEndReachedThreshold={0.3}
        ListFooterComponent={renderFooter}
        ListEmptyComponent={renderEmptyComponent}
        keyExtractor={keyExtractor}
        ItemSeparatorComponent={itemSeperatorComponent}
        ListHeaderComponent={renderSearchHeader}
      /&gt;
    ),
    [/* 의존성 배열 */],
  );
&gt;
  const renderDefaultContent = useCallback(
    () =&gt; (
      &lt;FlatList
        data={allItems}
        style={{ flex: 1 }}
        renderItem={renderDefaultItem}
        numColumns={2}
        columnWrapperStyle={{ justifyContent: &#39;space-between&#39;, gap: 10 }}
        contentContainerStyle={{ gap: 20, paddingHorizontal: 12, paddingBottom: 100 }}
        showsVerticalScrollIndicator={false}
        keyExtractor={defaultKeyExtractor}
        ListHeaderComponent={renderDefaultHeader}
        onEndReached={handleDefaultLoadMore}
        onEndReachedThreshold={0.3}
        ListFooterComponent={renderDefaultFooter}
      /&gt;
    ),
    [/* 의존성 배열 */],
  );
&gt;
  if (isItemLoading) {
    return &lt;ActivityIndicator /&gt;;
  }
&gt;
  if (isItemError || allItems?.length === 0) {
    return &lt;Text&gt;조회된 데이터가 없습니다.&lt;/Text&gt;;
  }
&gt;
  return (
    &lt;View style={{ backgroundColor: &#39;black&#39;, flex: 1 }}&gt;
      &lt;View style={{ height: 50, marginBottom: 12 }}&gt;{renderSearchBar()}&lt;/View&gt;
      &lt;View style={{ flex: 1 }}&gt;
        {isSearchMode ? renderSearchContent() : renderDefaultContent()}
      &lt;/View&gt;
    &lt;/View&gt;
  );
}</code></pre>
<p>두 번째 샘플 코드는 React Native 환경에서 작성된 내용으로, 컴포넌트의 모든 요소들을 useCallback으로 감싸 함수 재생성을 방지하기 위해 위처럼 작성되어 있다.</p>
<p>또한, 하나의 페이지에서 상품 리스트와 상품 검색 요소들을 모두 보여줘야 하다 보니, <code>isSearchMode</code> 라는 조건에 따라 <code>renderSearchContent()</code> 와 <code>renderDefaultContent()</code> 를 조건부 렌더링하고 있다.</p>
<p>(두 번째 코드는 당연히 컴포넌트 분리가 필요해 보인다.. 😅)</p>
<p>뿐만 아니라, 두 번째 샘플 코드에서는 React Query를 사용하여 로딩 상태 UI 등을 처리하고 있는데 이러한 부분에서도 조건부 렌더링이 계속해서 사용되는 모습을 볼 수 있다.</p>
<p>결국 이런 상황들이 반복되다 보니 코드를 읽기가 점점 어려워지고, 새로운 기능을 추가하거나 수정할 때도 실수할 가능성이 높아진다고 생각했다.</p>
<hr>
<h2 id="2-conditional-component">2. Conditional Component</h2>
<p>그래서 <code>ConditionalComponent</code> 를 하나 만들어봤다.</p>
<p>이걸 만들게 된 계기는 React Native에서 제공하는 <code>Flatlist</code> 컴포넌트를 사용해보니 무한스크롤 관련 기능, 컴포넌트를 분리해서 추가하는 방법이 꽤 맘에 들었기 때문이다.</p>
<p>그래서 구현한 코드는 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">/**
 * @Description
 * 조건부 렌더링을 위한 컴포넌트입니다.
 * - `when` 값이 falsy하거나 빈 배열이면 `fallback`을 렌더링합니다.
 * - `children`이 함수일 경우, `when` 값을 인자로 호출해 렌더링합니다.
 * - 그 외에는 `children` 자체를 그대로 렌더링합니다.
 *
 * @Example
 * &lt;ConditionalComponent when={data} fallback={&lt;div&gt;데이터 없음&lt;/div&gt;}&gt;
 *   {(validData) =&gt; &lt;div&gt;{validData.name}&lt;/div&gt;}
 * &lt;/ConditionalComponent&gt;
 */
&gt;
import React from &quot;react&quot;;
&gt;
function ConditionalComponent&lt;T&gt;({
  when,
  fallback,
  children,
}: {
  when: T | undefined | null | false | boolean;
  fallback?: React.ReactNode;
  children: React.ReactNode | ((_data: T) =&gt; React.ReactNode);
}) {
  if (
    !when ||
    (Array.isArray(when) &amp;&amp; when.length === 0) ||
    Object.keys(when).length === 0
  )
    return &lt;&gt;{fallback}&lt;/&gt;;
&gt;
  if (typeof children === &quot;function&quot; &amp;&amp; typeof when !== &quot;boolean&quot;) {
    return &lt;&gt;{children(when)}&lt;/&gt;;
  }
&gt;
  return &lt;&gt;{children}&lt;/&gt;;
}
&gt;
export default ConditionalComponent;</code></pre>
<p>공통 컴포넌트를 만들때, 가장 중요한건 <code>유연한 타입 추론</code> 이라고 생각한다.</p>
<p>따라서, 제네릭을 사용하여 타입 추론이 제대로 될 수 있게끔 만들어보는데에 집중해봤다.</p>
<p>로직은 아주 간단한데, <code>when</code> 이라는 조건이 truthy이면 <code>children</code> 을 렌더링하고 <code>when</code> 이라는 조건이 falsy이면 <code>fallback</code> 컴포넌트를 렌더링한다.</p>
<p>한가지 도전적으로 만든 내용을 보면 <code>children</code> 이 ReactNode 뿐만 아니라 함수도 받을 수 있다는 것이다.</p>
<p>함수로 받는 경우, data라는 매개변수의 데이터 타입이 T로 추론되는 모습을 볼 수 있다.</p>
<p>이렇게 개발을 진행한 이유는, when이라는 조건에서 추론된 데이터를 children으로 사용하는 경우가 있기 때문이다.</p>
<p>다음 코드를 통해 사용 예시를 살펴보자</p>
<blockquote>
<p><strong>Sample Code - Example</strong></p>
</blockquote>
<pre><code class="language-typescript">const ItemListPage = () =&gt; {
  const popupId = usePopUpReadStore(state =&gt; state.popupId);
  const { data } = useItemListApi({ popupId });
  const navigate = useNavigate();
  const [isModalOpen, setIsModalOpen] = useState&lt;boolean&gt;(false);
&gt;
  return (
    &lt;div className=&quot;py-8 flex flex-col min-h-[calc(100vh-200px)]&quot;&gt;
      &lt;div className=&quot;flex justify-end gap-3 mb-10 px-10&quot;&gt;
        &lt;button
          className=&quot;cursor-pointer px-4 py-2 bg-gray01 border border-gray10 text-gray10 rounded-full text-[20px] font-semibold hover:bg-gray10 hover:text-gray01 transition-colors duration-300&quot;
          onClick={() =&gt; setIsModalOpen(true)}
        &gt;
          전체 상품 등록
        &lt;/button&gt;
        &lt;button
          id=&quot;item-create-button&quot;
          onClick={() =&gt; navigate(&quot;/items/create&quot;)}
          className=&quot;cursor-pointer px-4 py-2 bg-gray01 border border-gray10 text-gray10 rounded-full text-[20px] font-semibold hover:bg-gray10 hover:text-gray01 transition-colors duration-300&quot;
        &gt;
          상품 등록
        &lt;/button&gt;
      &lt;/div&gt;
&gt;
      {isModalOpen &amp;&amp; (
        &lt;ItemCreateExcelModal closeModal={() =&gt; setIsModalOpen(false)} /&gt;
      )}
&gt;
      &lt;ConditionalComponent
        when={data}
        fallback={
          &lt;div className=&quot;flex flex-col items-center justify-center flex-grow&quot;&gt;
            &lt;p className=&quot;text-[32px] text-gray10 font-medium&quot;&gt;
              등록된 상품이 아직 없습니다
            &lt;/p&gt;
            &lt;p className=&quot;mt-4 text-[20px] text-gray10&quot;&gt;
              우측 상단 &lt;span className=&quot;text-main06&quot;&gt;상품 등록 버튼&lt;/span&gt;을
              눌러 상품을 등록해주세요!
            &lt;/p&gt;
          &lt;/div&gt;
        }
      &gt;
        {data =&gt;
          Object.entries(data).map(([k, v]) =&gt; (
            &lt;div key={k}&gt;
              &lt;ItemDisplay displayName={k.toUpperCase()} items={v} /&gt;
            &lt;/div&gt;
          ))
        }
      &lt;/ConditionalComponent&gt;
    &lt;/div&gt;
  );
};
&gt;
export default ItemListPage;</code></pre>
<hr>
<h4 id="when-타입-추론">when 타입 추론</h4>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/3d6e515f-04d7-452f-b645-e420dc39b33b/image.png" alt=""></p>
<h4 id="data-타입-추론">data 타입 추론</h4>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/7170e5f0-9afa-488f-affd-01aa5b373329/image.png" alt=""></p>
<p>코드를 보면 등록된 상품이 이미 있는 경우, <code>Object.entries(data).map((k, v]) =&gt; ...)</code> 를 사용해서 <code>ItemDisplay</code> 컴포넌트들을 렌더링한다.</p>
<p>이처럼 when에서 조건으로 사용된 <code>data</code> 를 사용하여 하위 컴포넌트들을 렌더링하기 때문에 함수형으로도 받을 수 있도록 개발을 진행했다.</p>
<p>또한, 사진을 살펴보면 when에서는 data의 타입 추론이 <code>GetItemListResponse</code> | <code>undefined</code> 로 추론되지만, children쪽에서 data는 <code>GetItemListResponse</code> 로 추론된다.</p>
<p>그 이유는 <code>ConditionalComponent</code> 내부적으로 falsy한 경우에는 fallback을 렌더링하도록 했기 때문이다.</p>
<h3 id="💡-이렇게-사용하면-뭐가-좋을까">💡 이렇게 사용하면 뭐가 좋을까?</h3>
<p>아마 코드를 살펴보면, 누군가는 조건부 렌더링을 사용하는게 더 깔끔하지 않나? 라는 생각을 할 수 있다.</p>
<p>하지만, 이런식으로 fallback 영역과 children 영역을 분리해서 코드를 작성해보면 어느 부분에서 컴포넌트를 분리해야할지 쉽게 눈으로 확인할 수 있다.</p>
<p>이런 방식으로 구조를 생각하면서 UI 레이어에서 작성되는 컴포넌트들을 나눈다면 분명 유지보수에서 이점을 얻을 수 있을거라고 생각한다</p>
<hr>
<h2 id="3-query-component">3. Query Component</h2>
<p>필자는 본 프로젝트에서 모든 API 요청을 React Query를 사용해서 관리했다.</p>
<p>React Query에서 제공해주는 기능이 매력적이라고 생각했기 때문이다.</p>
<p>React Query를 사용해보면 알겠지만, 리액트 쿼리는 조건부 렌더링에 최적화되어있다.</p>
<p>가령 데이터 패치를 받을 때, <code>isLoading</code>, <code>isError</code> 의 boolean 값을 통해 조건부 렌더링을 쉽게 진행할 수 있기 때문이다.</p>
<p>필자는 위에서 작성한 Conditional Component 아이디어를 바탕으로 리액트 쿼리 전용 컴포넌트를 만들어보면 어떨까 생각하여 관련 컴포넌트 개발을 진행해봤다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">/**
 * @description
 * React Query 요청의 로딩, 에러, 빈 데이터 상태를 공통 처리하는 래퍼 컴포넌트입니다.
 * - `data`, `isLoading`, `isError` 상태에 따라 적절한 fallback UI를 렌더링하고,
 * - 데이터가 존재할 경우 `children` 함수로 실제 UI를 렌더링합니다.
 *
 * @example
 * &lt;QueryComponent
 *   data={data}
 *   isLoading={isLoading}
 *   isError={isError}
 * &gt;
 *   {(data) =&gt; &lt;MyList data={data} /&gt;}
 * &lt;/QueryComponent&gt;
 */
&gt;
import React from &quot;react&quot;;
import Skeleton from &quot;../ui/Skeleton&quot;;
import NoDataComp from &quot;../../pages/dashboardPage/views/@common/NoDataComp&quot;;
&gt;
type Props&lt;T&gt; = {
  data: T | undefined;
  isLoading: boolean;
  isError: boolean;
  error?: Error | null;
  loadingFallback?: React.ReactNode;
  errorFallback?: React.ReactNode | ((_error: Error) =&gt; React.ReactNode);
  emptyFallback?: React.ReactNode;
  children: (_data: T) =&gt; React.ReactNode;
};
&gt;
const QueryComponent = &lt;T,&gt;({
  data,
  isLoading,
  isError,
  loadingFallback = (
    &lt;div className=&quot;h-[300px]&quot;&gt;
      &lt;Skeleton /&gt;
    &lt;/div&gt;
  ),
  errorFallback,
  emptyFallback = &lt;NoDataComp /&gt;,
  children,
}: Props&lt;T&gt;) =&gt; {
  if (isLoading) return &lt;&gt;{loadingFallback}&lt;/&gt;;
  if (isError) return &lt;&gt;{errorFallback}&lt;/&gt;;
  if (
    data === null ||
    data === undefined ||
    (Array.isArray(data) &amp;&amp; data.length === 0)
  ) {
    return emptyFallback ? &lt;&gt;{emptyFallback}&lt;/&gt; : null;
  }
  return &lt;&gt;{children(data)}&lt;/&gt;;
};
&gt;
export default QueryComponent;</code></pre>
<p>기본 구조는 위에서 작성한 <code>Conditional Component</code> 와 동일하다</p>
<p>다만, isLoading과 isError에 대한 조건을 받고 그에 해당하는 컴포넌트를 추가로 입력받는다.</p>
<p>사용 예시는 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">type Props = {
  title: string;
  data: GetConversionItemResponse[] | undefined;
  isLoading: boolean;
  isError: boolean;
  barColor: string;
  lineColor: string;
  tooltipColorClass: {
    interested: string;
    purchased: string;
    rate: string;
  };
  legendColors: {
    interested: string;
    purchased: string;
  };
};
&gt;
const ConversionRateCard = ({
  title,
  data,
  isLoading,
  isError,
  barColor,
  lineColor,
  tooltipColorClass,
  legendColors,
}: Props) =&gt; {
  return (
    &lt;div className=&quot;relative w-[660px] h-[510px] bg-gray02 rounded-[50px] px-6 flex justify-center&quot;&gt;
      &lt;div className=&quot;mt-7 flex text-gray09 font-medium text-[28px]&quot;&gt;
        {title}
      &lt;/div&gt;
&gt;
      &lt;QueryComponent
        data={data}
        isLoading={isLoading}
        isError={isError}
        loadingFallback={
          &lt;Skeleton
            className=&quot;absolute bottom-6&quot;
            width=&quot;w-[612px]&quot;
            height=&quot;h-[394px]&quot;
          /&gt;
        }
        errorFallback={
          &lt;NoDataComp
            className=&quot;absolute bottom-6&quot;
            width=&quot;w-[612px]&quot;
            height=&quot;h-[394px]&quot;
          /&gt;
        }
      &gt;
        {data =&gt; (
          &lt;div className=&quot;absolute bottom-6 w-[612px] h-[394px] bg-gray01 rounded-[40px] flex justify-center&quot;&gt;
            &lt;ConversionRateChart
              data={data}
              barColor={barColor}
              lineColor={lineColor}
              tooltipColorClass={tooltipColorClass}
              legendColors={legendColors}
            /&gt;
          &lt;/div&gt;
        )}
      &lt;/QueryComponent&gt;
    &lt;/div&gt;
  );
};
&gt;
export default ConversionRateCard;</code></pre>
<p>Conditional Component와 마찬가지로 포맷이 정해진 상태로 컴포넌트를 구조화시킬 수 있었다.</p>
<h3 id="💡-querycomponent와-conditional-component-분리-이유">💡 QueryComponent와 Conditional Component 분리 이유</h3>
<p>아마 누군가는 <code>이전에 개발한 Conditional Component에 loading과 error에 대한 Props를 Optional로 추가하면 되는거 아닌가?</code> 라고 질문할 수 있다.</p>
<p>물론, 그렇게 진행하게되면 모든 컴포넌트에서 Conditional Component만 사용하여 구조화시킬 수 있다.</p>
<p>하지만, 프론트 개발자라면 공통 컴포넌트를 한번쯤은 만들어봤을 것이다.</p>
<p>필자의 경우 공통으로 사용되는 Modal 컴포넌트를 사용하고 있었는데, 개발을 진행하면서 여러개의 옵션이 붙어야하는 경우를 경험해봤다.</p>
<p>결국 공통으로 사용되는 Modal 컴포넌트를 수정해야하는 상황이 발생했고, 그로인해 생각치도 못한 UI가 깨지는 상황을 겪어본적이 있다.</p>
<p>이러한 경험을 해보고 우연히 <a href="https://velog.io/@teo/folder-structure">관련 Velog</a>를 읽어보고 도메인별로 분리하는게 꽤 괜찮은 방법이라고 생각하게 되어 이처럼 나누게 되었다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 글에서는 조건부 렌더링으로 인해 복잡해진 코드를 어떻게 개선할 수 있을지에 대한 고민과 해결 과정을 공유해봤다.</p>
<p><code>ConditionalComponent</code> 와 <code>QueryComponent</code> 를 만들면서 느낀 점은, 단순히 코드의 길이를 줄이는 것보다는 일관된 구조를 만드는 것이 더 중요하다는 것이었다.</p>
<p>물론 이런 방식이 모든 상황에서 최선의 해답은 아닐 수 있다. </p>
<p>하지만 적어도 팀원들과 함께 개발할 때 &quot;조건부 렌더링은 이런 식으로 처리하자&quot;라는 공통된 규칙을 만들 수 있었던 것 같다.</p>
<p>앞으로도 이런 작은 개선들을 통해 조금 더 읽기 쉽고 유지보수하기 좋은 코드를 작성해보려고 한다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[FE] Query Key 모듈화하여 관리하기]]></title>
            <link>https://velog.io/@sung-yeop/FE-Query-Key-%EB%AA%A8%EB%93%88%ED%99%94%ED%95%98%EC%97%AC-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sung-yeop/FE-Query-Key-%EB%AA%A8%EB%93%88%ED%99%94%ED%95%98%EC%97%AC-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 22 Jun 2025 15:27:47 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>프로젝트가 커지면서 React Query 쿼리키 관리가 점점 복잡해졌다.</p>
<p>여러 파일에 흩어진 쿼리키들을 찾아다니며 캐시를 무효화하는 작업이 어느순간부터 번거롭다고 느껴졌고, 이를 해결하기 위해 <code>Query Key 모듈화</code> 를 도입하게 되었다.</p>
<p>이번 포스팅에서는 실제 프로젝트에서 Query Key 모듈화를 어떻게 적용했는지, 그리고 어떤 개선 효과를 얻었는지 공유해보려고 한다!</p>
<hr>
<h2 id="1-기존에는-어떻게-사용했는가">1. 기존에는 어떻게 사용했는가?</h2>
<p>우선 디렉토리 구조를 소개해보면 다음과 같다.</p>
<blockquote>
<p><strong>디렉토리 구조</strong>
<img src="https://velog.velcdn.com/images/sung-yeop/post/7b578457-752f-4044-8f27-d1311566d04b/image.png" width="300"></p>
</blockquote>
<p>우리팀은 React Query의 훅을 사용하여 API 요청을 처리하는 부분을 <code>src/hooks/api</code> 디렉토리에서 모두 관리하고 있다.</p>
<p>하나의 React Query 코드를 살펴보면 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">// useOrderListApi.ts
export const useGetOrderListApi = ({ size }: { size: number }) =&gt; {
  return useInfiniteQuery({
    queryKey: [&quot;orderItem&quot;],
    queryFn: ({ pageParam }) =&gt;
      getOrderList({ lastOrderItemId: pageParam, size }),
    getNextPageParam: response =&gt; {
      const lastPage = response.data;
&gt;
      if (lastPage.isLast) {
        return undefined;
      }
&gt;
      const lastItem = lastPage.content[lastPage.content.length - 1];
      return lastItem.orderItemId;
    },
    initialPageParam: undefined as number | undefined,
  });
};
&gt;
export const usePostChangeOrderItemStatus = () =&gt; {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ orderItemId, qty, status }: PostChangeOrderItemRequest) =&gt;
      postChangeOrderItemStatus({ orderItemId, qty, status }),
    onSuccess: () =&gt; queryClient.invalidateQueries({ queryKey: [&quot;orderItem&quot;] }),
  });
};</code></pre>
<p>우리는 도메인 혹은 기능 별로 파일을 분리하여 쿼리 훅들을 관리하고 있었다.</p>
<p>위 코드를 간단히 설명하면, <code>useGetOrderListApi</code> 는 발주 요청들을 무한 스크롤로 가져오는 훅이고, <code>usePostChangeOrderItemStatus</code> 는 발주 요청의 상태를 변경하는 훅이다.</p>
<h3 id="💡-캐시-전략">💡 캐시 전략</h3>
<p>기본적으로 우리는 Get 요청을 통해 받아온 모든 서버 상태(response)들을 5분동안 Stale한 상태로 관리하는 캐시 전략을 가지고 있었다.</p>
<blockquote>
<p><strong>Sample Image</strong>
<img src="https://velog.velcdn.com/images/sung-yeop/post/c60d9637-9c09-4f36-89ef-387345e4b062/image.png" alt=""></p>
</blockquote>
<p>여기서 <code>usePostChangeOrderItemStatus</code> 을 통해 <code>포토카드 RETRO</code> 상품의 상태가 <code>승인</code> 혹은 <code>취소</code> 로 변경된다면, 기존에 가지고 있던 발주 리스트 캐시 데이터를 무효화하고 다시 받아와야 한다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">onSuccess: () =&gt; queryClient.invalidateQueries({ queryKey: [&quot;orderItem&quot;] }),</code></pre>
<p>우리는 mutation의 onSuccess 기능을 사용해서 특정 쿼리 데이터를 무시하는 방법을 사용하고 있었다.</p>
<h3 id="💡-문제-인식">💡 문제 인식</h3>
<p>그런데 만약 요구사항이 변경되어서, 완전히 다른 기능의 A라는 mutation이 성공할 때마다 발주 리스트 캐시를 무효화해야 하는 상황이 생겼다고 가정해보자.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">// useExampleApi.ts
export const useExampleApi = () =&gt; {
  const queryClient = useQueryClient();
&gt;
  return useMutation({
    mutationFn: postSample(),
    onSuccess: () =&gt; {
      queryClient.invalidateQueries({ queryKey: [&quot;a&quot;, &quot;orderItem&quot;] });
    },
  });
};</code></pre>
<p>이런 상황에서 무효화할 캐시 데이터인 &quot;orderItem&quot;을 찾으려면 <code>useOrderListApi.ts</code> 파일을 일일이 찾아서 들어가야 한다는 문제가 있었다.</p>
<p>이처럼 React Query를 사용해서 쿼리키를 여러개 정의하다보면 여러 파일에 쿼리키가 흩어지기 때문에 유지보수를 하기 어려운 문제가 발생할 수 있다.</p>
<hr>
<h2 id="2-query-key-모듈화">2. Query Key 모듈화</h2>
<p>쿼리키 모듈화의 핵심은 쿼리키들을 함수로 만들어서 한 곳에서 관리하자는 것이다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">// queryKey.ts
type QueryParams = Record&lt;string, string | number | boolean | undefined&gt;;
&gt;
export const queryKeys = {
  ...
  orderItem: {
    all: [&quot;orderItem&quot;] as const,
    lists: () =&gt; [&quot;orderItem&quot;, &quot;list&quot;] as const,
    list: (popupId: string, params?: QueryParams) =&gt;
      params
        ? ([&quot;orderItem&quot;, &quot;list&quot;, popupId, params] as const)
        : ([&quot;orderItem&quot;, &quot;list&quot;, popupId] as const),
    detail: (orderItemId: string) =&gt;
      [&quot;orderItem&quot;, &quot;detail&quot;, orderItemId] as const,
  },
  ...
}
&gt;
export const QUERY_KEYS = {
  ...
  ORDER_ITEM: {
    INDEX: queryKeys.orderItem.lists,
    LIST: (popupId: string, params?: QueryParams) =&gt;
      queryKeys.orderItem.list(popupId, params),
    DETAIL: (orderItemId: string) =&gt; queryKeys.orderItem.detail(orderItemId),
  },
  ...
};</code></pre>
<hr>
<blockquote>
<p><strong>디렉토리 구조</strong>
<img src="https://velog.velcdn.com/images/sung-yeop/post/8951d6da-a379-4fad-aa86-084b152cb71e/image.png" width="300"></p>
</blockquote>
<p>queryKeys로 쿼리키의 기본 포맷을 정의하고, QUERY_KEYS로 실제 사용할 인터페이스를 제공하는 구조다.</p>
<p>(지금 생각해보면 기본 포맷 정의된 부분을 바로 인터페이스로 제공해도 상관없을 것 같다)</p>
<h3 id="💡-어떻게-사용했는지">💡 어떻게 사용했는지?</h3>
<p>적용한 코드를 살펴보면 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-typescript">export const useGetOrderListApi = ({
  size,
  popupId,
}: {
  size: number;
  popupId: number;
}) =&gt; {
  return useInfiniteQuery({
    queryKey: QUERY_KEYS.ORDER_ITEM.LIST(String(popupId), { size }),
    queryFn: ({ pageParam }) =&gt;
      getOrderList({ lastOrderItemId: pageParam, size, popupId }),
    getNextPageParam: response =&gt; {
      const lastPage = response.data;
&gt;
      if (lastPage.isLast) {
        return undefined;
      }
&gt;
      const lastItem = lastPage.content[lastPage.content.length - 1];
      return lastItem.orderItemId;
    },
    initialPageParam: undefined as number | undefined,
  });
};
&gt;
export const usePatchChangeOrderItemStatus = () =&gt; {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ orderItemId, qty, status }: PatchChangeOrderItemRequest) =&gt;
      patchChangeOrderItemStatus({ orderItemId, qty, status }),
    onSuccess: () =&gt;
      queryClient.invalidateQueries({
        queryKey: QUERY_KEYS.ORDER_ITEM.INDEX(),
      }),
  });
};</code></pre>
<p>이처럼 실제 쿼리키를 사용하는 곳에서는 <code>QUERY_KEYS.ORDER_ITEM.INDEX()</code> 와 같이 함수를 호출하여 쿼리키를 호출하는 방식으로 리팩토링을 진행할 수 있었다.</p>
<p>다시 돌아와서 이전에 가정했던 코드처럼 <code>orderItem</code> 을 캐시 무효화해야했다면, 디렉토리 내에서 여러 파일을 하나씩 찾아보는게 아니라 하나의 파일로 관리된 <code>queryKey.ts</code> 를 살펴보면 된다.</p>
<p>물론, 처음 정의하는 부분에서 쿼리키를 정의해야한다는 오버헤드가 있을 수 있으나 실제로 사용해보면 얻는 유지보수성이 더 높다고 생각한다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>처음에는 쿼리키를 정의하는 오버헤드가 있을 수 있지만, 실제로 사용해보니 얻는 유지보수성이 훨씬 높다고 느꼈다.</p>
<p>특히 중간 규모의 프로젝트에서 유지보수가 더 좋은 것 같다.</p>
<p>결국 엄청난 크기의 프로젝트가 된다면, 한 파일에서 관리한다고하더라도 당연히 복잡해지는건 똑같기 때문이다.</p>
<p>React Query를 사용하는 프로젝트라면 한 번쯤 고려해볼 만한 패턴이라고 생각한다!</p>
<hr>
<h4 id="참고">참고</h4>
<p><a href="https://velog.io/@jihostudy/Tanstack-Query-key%EB%A5%BC-setMutationDefaults%EB%A1%9C-%EB%AA%A8%EB%93%88%ED%99%94%ED%95%98%EC%97%AC-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0#-%EC%9D%B4%EB%9F%AC%ED%95%9C-%EC%9D%B4%EC%9C%A0%EB%A1%9C-querykey%EC%99%80-mutationkey-usemutation-%ED%95%A8%EC%88%98%EB%A5%BC-%EB%AA%A8%EB%91%90-%ED%95%98%EB%82%98%EC%9D%98-%ED%8C%8C%EC%9D%BC%EC%97%90%EC%84%9C-%EA%B4%80%EB%A6%AC%ED%95%98%EB%8A%94-%EB%AA%A8%EB%93%88%ED%99%94-%EB%B0%A9%EB%B2%95%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%98%80%EC%8A%B5%EB%8B%88%EB%8B%A4">[React Query] querykey, mutationKey를 모듈화하여 관리하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[FE] 세상에 완벽한 성능 측정은 없다]]></title>
            <link>https://velog.io/@sung-yeop/lighthouse-ci-test</link>
            <guid>https://velog.io/@sung-yeop/lighthouse-ci-test</guid>
            <pubDate>Sat, 21 Jun 2025 06:42:44 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>여러 개발 커뮤니티에서 진행되는 세션을 살펴보면 간혹 <code>성능 개선</code> 이라는 주제로 진행되는 세션을 볼 수 있다.</p>
<p>하지만, 그러한 세션들은 <code>성능 개선</code> 이라는 주제로 발표를 진행하기 때문에, <code>어떻게</code> 성능 측정을 진행했는지는 간단하게 소개하거나 그냥 넘어가는 경우가 많다.</p>
<p>필자는 이번 프로젝트를 진행하면서 고민했던 <code>성능 측정 방법</code> 을 이번 포스팅을 통해 다뤄보고자 한다.</p>
<hr>
<h2 id="1-무엇을-고려해야-했는가">1. 무엇을 고려해야 했는가?</h2>
<p>우리팀의 프론트엔드 인원은 총 3명으로 구성되어 있으며, 각자 서로 다른 페이지에 대해서 성능 개선을 진행해보고 결과를 공유하고 싶었다.</p>
<p>따라서 우리는 최소한 두 가지 조건을 만족해야 했다.</p>
<blockquote>
</blockquote>
<p><strong>환경 일관성</strong></p>
<ul>
<li>프론트엔드 팀원들이 서로 다른 개발환경에서 측정을 진행해도 동일한 결과가 나와야 한다</li>
</ul>
<hr>
<p><strong>측정 신뢰성</strong></p>
<ul>
<li>여러 번 실행해도 코드를 변경하지 않았다면 당연히 동일한 결과가 나와야 한다</li>
</ul>
<p>이 조건을 만족하면서 의미 있는 성능 측정을 하기 위해서는 단순히 개발자 도구의 <code>Network 탭</code> 을 살펴보거나 <code>Lighthouse</code> 를 한 번 실행하는 것으로는 한계가 있다고 생각했다.</p>
<p>(여기서 환경 일관성이나 측정 신뢰성은 편의를 위해 나름대로 정의한 부분이다)</p>
<p>(이후에도 이 단어들을 재사용해서 포스팅을 진행할 예정이다!)</p>
<hr>
<h2 id="2-생각해본-성능-측정-방법들">2. 생각해본 성능 측정 방법들</h2>
<p>정리하자면, 서로 다른 환경에서 테스트를 진행하더라도 코드가 동일하다면 동일한 결과가 나오는 신뢰성 있는 지표를 원했던 상황이다.</p>
<p>이 요구를 만족하기 위해 여러가지로 찾아봤던 부분을 공유해보려고 한다.</p>
<h3 id="💡-페이지-테스트-사이트-이용">💡 페이지 테스트 사이트 이용</h3>
<p><a href="https://www.webpagetest.org/">https://www.webpagetest.org/</a></p>
<p>WebPageTest는 일관된 측정 환경을 제공하는 온라인 서비스이다.</p>
<p>전 세계 여러 지역의 실제 디바이스와 네트워크 환경에서 테스트할 수 있어서, 이전에 소개했던 <strong>환경 일관성</strong> 측면과 <strong>측정 신뢰성</strong> 측면 모두에서 제일 괜찮은 방법이라고 생각했다.</p>
<p>하지만 우리 프로젝트에는 치명적인 문제가 있었다.</p>
<p>로그인을 진행한 이후 AccessToken을 헤더에 추가해야 하는 페이지들이 대부분이었는데, 이를 위해서는 해당 사이트에서 별도의 스크립트를 작성해야 했다.</p>
<p>더 큰 문제는 실시간 피드백이 어렵다는 점이었다.</p>
<p>코드를 수정하고 바로바로 성능 변화를 확인하기에는 워크플로우가 너무 복잡했으며, 이전에 말한 스크립트를 작성하기 위해 요구하는 허들이 너무 높다고 판단했다.</p>
<h3 id="💡-ec2-사용">💡 EC2 사용</h3>
<p>두 번째로 고려했던 방법은 AWS EC2에 전용 성능 측정 환경을 구축하는 것이었다. </p>
<p>CPU 코어와 메모리를 제한하고, Docker에 Chrome을 설치해서 Lighthouse를 실행하는 방식을 고민해봤다.</p>
<p>이 방법의 장점은 완전히 통제 가능한 환경에서 측정할 수 있다는 것이었다. </p>
<p>네트워크 대역폭, CPU 성능, 메모리 등을 일정하게 유지할 수 있어서 가장 일관된 결과를 얻을 수 있을 것 같았다.</p>
<p>하지만 현실적인 문제들이 많았는데, 코드가 바뀔 때마다 CloudFront에 배포를 다시하고 EC2 내부에서는 측정만 진행하거나, 아니면 EC2 내부에 VSCode를 설치하여 EC2에서 개발을 진행할 수 있도록 구축해야 했다.</p>
<p>팀원 3명이 각자 다른 페이지를 담당하는 상황에서, 모든 사람이 EC2 환경에 접근해서 작업하기에는 복잡도가 너무 높다고 판단했다.</p>
<h3 id="💡-githubaction-사용">💡 GithubAction 사용</h3>
<p>마지막으로 검토한 방법이 GitHub Actions를 활용하는 것이었다. </p>
<p>GitHub에서 제공하는 서버 리소스를 사용해서, 매번 동일한 Ubuntu 환경에서 Chrome을 설치하고 Lighthouse CI를 실행하는 방식이다.</p>
<p>이 방법이 가장 괜찮다고 판단한 이유는 다음과 같다.</p>
<blockquote>
<p>** 1. 환경 일관성**</p>
</blockquote>
<ul>
<li>매번 새로운 Ubuntu 컨테이너에서 실행되기 때문에, 이전 테스트의 캐시나 상태가 영향을 주지 않는다.</li>
<li>GitHub Actions의 runner 환경은 표준화되어 있어서 실행할 때마다 동일한 조건을 보장한다.</li>
</ul>
<hr>
<p><strong>2. 자동화</strong></p>
<ul>
<li>PR을 올리거나 특정 브랜치에 푸시할 때 자동으로 성능 테스트가 실행된다. </li>
<li>별도로 테스트 사이트에 접속하거나 명령어를 실행할 필요가 없다.</li>
</ul>
<hr>
<p><strong>3. 비용</strong></p>
<ul>
<li>GitHub의 public 저장소에서는 Actions를 무료로 사용할 수 있다. </li>
<li>성능 테스트가 몇 분 정도밖에 걸리지 않아서 사용량 제한에도 걸리지 않는다.</li>
</ul>
<hr>
<p><strong>4. 접근성</strong></p>
<ul>
<li>팀원 누구나 PR만 올리면 성능 테스트 결과를 확인할 수 있어서 편하다!</li>
<li>Lighthouse CI를 추가하는건 상대적으로 별로 어렵지 않은 상황이었다.</li>
</ul>
<p>따라서, 우리팀은 GitHub Actions를 사용하는 것으로 결정하고 설정을 추가하여 Lighthouse CI를 도입했다!</p>
<hr>
<h2 id="3-lighthouse-ci-적용기">3. Lighthouse CI 적용기</h2>
<p>Lighthouse CI를 적용하기로 결정했지만, 여기서 다시 마주한 문제는 우리의 운영자 페이지에서는 로그인을 진행해야만 메인 페이지로 접근할 수 있다는 것이었다.</p>
<p>Lighthouse CI는 각 URL을 독립적으로 측정하기 때문에, 매번 새로운 브라우저 세션에서 실행된다.</p>
<blockquote>
<p><strong>인증이 필요한 페이지의 성능 측정 문제</strong></p>
</blockquote>
<ol>
<li>Lighthouse CI 시작 </li>
<li>새 브라우저 세션</li>
<li>로그인 페이지 접근</li>
<li><strong>인증 X</strong></li>
<li>메인 페이지 접근 불가</li>
</ol>
<p><strong>즉, 로그인이 필요한 페이지들을 측정하려면 각 측정마다 인증 과정을 거쳐야 한다는 의미이다.</strong></p>
<h3 id="💡-puppeteer로-인증-자동화하기">💡 Puppeteer로 인증 자동화하기</h3>
<p>이러한 문제를 해결하기 위해 여러 방법을 찾아본 결과, <code>Puppeteer</code> 를 사용하여 성능 측정 이전에 인증 과정을 자동화하는 방법을 선택했다.</p>
<blockquote>
<p><strong>Puppeteer vs Puppeteer-Core</strong>
처음에는 Headless 환경에서 puppeteer-core를 사용해야 하는지 고민했다
간단하게 Puppeteer와 Puppeteer-core를 비교하면 다음과 같다.</p>
</blockquote>
<ul>
<li>puppeteer: Chrome 브라우저 번들 포함 (~300MB)</li>
<li>puppeteer-core: 브라우저 없이 API만 제공 (~13MB)</li>
</ul>
<p>하지만 CI 환경에서 Chrome을 별도로 설치하기로 했기 때문에, 필자는 puppeteer를 그대로 사용하기로 결정했다.</p>
<p>관련 내용은 이후에 조금 더 자세히 설명하려고 한다.</p>
<h3 id="💡-어떻게-동작하나요">💡 어떻게 동작하나요?</h3>
<p>우선 운영자 서비스는 로그인을 진행하면 <code>AccessToken</code> 이 발급된다.</p>
<p>필자는 로그인을 진행한 이후, AccessToken과 관련된 인증 정보를 <code>auth-token.json</code> 이라는 이름으로 파일을 저장하도록 <code>auth-setup.js</code> 를 작성했다.</p>
<blockquote>
<h4 id="1-인증-정보-생성">1. 인증 정보 생성</h4>
</blockquote>
<pre><code class="language-javascript">// auth-setup.js
async function setupAuth() {
  try {
    const response = await fetch(&quot;https://dev-api.ceo.popi.today/auth/login&quot;, {
      method: &quot;POST&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/json&quot;,
      },
      body: JSON.stringify({
        username: process.env.TEST_EMAIL,
        password: process.env.TEST_PASSWORD,
      }),
    });
&gt;
    if (!response.ok) {
      throw new Error(`로그인 실패: ${response.status} ${response.statusText}`);
    }
&gt;
    const data = await response.json();
    console.log(&quot;로그인 응답:&quot;, data);
&gt;
    if (!data.success || !data.data || !data.data.accessToken) {
      console.log(&quot;전체 응답:&quot;, JSON.stringify(data, null, 2));
      throw new Error(&quot;로그인 실패 또는 토큰이 없습니다&quot;);
    }
&gt;
    const accessToken = data.data.accessToken;
    console.log(&quot;=========== JWT 토큰 획득 성공 ===========&quot;);
&gt;
    const authData = {
      accessToken,
      timestamp: new Date().toISOString(),
      expiresIn: data.data.expiresIn || 3600,
    };
&gt;
    const authFilePath = path.join(__dirname, &quot;auth-token.json&quot;);
    fs.writeFileSync(authFilePath, JSON.stringify(authData, null, 2));
    console.log(`토큰 저장 완료: ${authFilePath}`);
&gt;
    return accessToken;
  } catch (error) {
    console.error(&quot;인증 설정 실패:&quot;, error.message);
    throw error;
  }
}</code></pre>
<p>코드를 보면 알겠지만 <code>로그인 API 호출</code> -&gt; <code>응답에서 AccessToken 추출</code> -&gt; <code>auth-token.json 파일에 JSON 형태로 저장</code> 순서로 동작한다.</p>
<p>이 부분은 CI 과정에서 하나의 Step으로 동작하여 미리 실행되도록 할 생각이다.</p>
<blockquote>
<h4 id="2-puppeteer-스크립트-실행">2. Puppeteer 스크립트 실행</h4>
</blockquote>
<pre><code class="language-javascript">module.exports = async (browser, context) =&gt; {
  console.log(&quot;=========== Puppeteer 스크립트 시작 ===========&quot;);
&gt;
  // 토큰 파일에서 accessToken 로드
  const authFilePath = path.join(__dirname, &quot;auth-token.json&quot;);
  let accessToken = &quot;&quot;;
&gt;
  if (fs.existsSync(authFilePath)) {
    const authData = JSON.parse(fs.readFileSync(authFilePath, &quot;utf8&quot;));
    accessToken = authData.accessToken;
    console.log(&quot;토큰 로드 완료&quot;);
  } else {
    throw new Error(&quot;토큰 파일이 없습니다. auth-setup.js를 먼저 실행하세요&quot;);
  }
&gt;
  const page = await browser.newPage();
&gt;
  try {
    // 먼저 onboarding에 가서 인증 상태 설정
    console.log(&quot;onboarding 페이지로 이동&quot;);
    await page.goto(&quot;http://localhost:4173/onboarding&quot;, {
      waitUntil: &quot;domcontentloaded&quot;,
      timeout: 30000,
    });
&gt;
    // localStorage에 인증 정보 설정
    console.log(&quot;localStorage에 인증 정보 설정&quot;);
    await page.evaluate(token =&gt; {
      const authStore = {
        state: {
          accessToken: token,
          isLogin: true,
        },
        version: 0,
      };
      localStorage.setItem(&quot;auth-store&quot;, JSON.stringify(authStore));
      console.log(
        &quot;localStorage 설정 완료:&quot;,
        localStorage.getItem(&quot;auth-store&quot;),
      );
    }, accessToken);
&gt;
    await new Promise(resolve =&gt; setTimeout(resolve, 2000));
    await page.waitForSelector(&quot;body&quot;, { timeout: 10000 });
&gt;
    console.log(&quot;=========== Puppeteer 스크립트 완료 ===========&quot;);
    return page;
  } catch (error) {
    console.error(&quot;Puppeteer 스크립트 실행 중 오류:&quot;, error);
    await page.close();
    throw error;
  }
};</code></pre>
<p>다음으로 Puppeteer 스크립트를 실행시킬 예정이다.</p>
<p>이전 <code>auth-setup.js</code> 단계에서 생성된 <code>auth-token.json</code> 을 읽어와서 토큰 정보를 로컬 스토리지에 주입하는 과정이 포함되어있다.</p>
<p>여기서 주의할 점은 <strong>우리 서비스가 AccessToken을 <code>auth-store</code> 라는 이름으로 localStorage에서 관리하기 때문</strong>에, 동일한 방식으로 토큰을 설정해야 한다는 것이다.</p>
<p>당연히 AccessToken 관리 방식이 달라진다면 그에 맞춰서 코드를 수정해야한다!</p>
<blockquote>
</blockquote>
<h4 id="q-puppeteer를-어디서-사용했나요">Q. Puppeteer를 어디서 사용했나요?</h4>
<p>위 코드를 언뜻보면 그냥 Javascript로 코드를 작성한것처럼 보일 수 있다.</p>
<blockquote>
</blockquote>
<p>필자도 처음에는 Puppeteer를 어떻게 사용해야하는지 많이 찾아봤다.</p>
<blockquote>
</blockquote>
<p>여기서는 이 두가지를 기억하면 될 것 같다.</p>
<blockquote>
</blockquote>
<p><strong>1. Lighthouse는 내부적으로 Puppeteer를 지원한다.</strong>
<strong>2. 위 스크립트는 Lighthouse에게 넘겨줄 내용이다.</strong></p>
<blockquote>
</blockquote>
<p>즉, puppeteer 관련 코드를 별도로 import를 하지 않아도 Lighthouse가 알아서 제공해줄거기 때문에 별도의 import가 필요없다.</p>
<blockquote>
</blockquote>
<p>여기서 puppeteer를 사용한 곳은 <code>async (browser, context)</code> 에서 browser이다.</p>
<blockquote>
</blockquote>
<p>이는 Lighthouse가 puppeteer.launch()로 만든 객체이며, 이후 <code>const page = await browser.newPage();</code> 처럼 browser를 사용한 부분이 모두 puppeteer의 인스턴스를 사용한 부분이라고 이해하면 된다.</p>
<blockquote>
<h4 id="3-lighthouse-config-생성">3. Lighthouse Config 생성</h4>
</blockquote>
<pre><code class="language-javascript">...
const lighthouseConfig = {
      ci: {
        collect: {
          url: [&quot;http://localhost:4173/dashboard&quot;],
          puppeteerScript: &quot;./scripts/lighthouse-puppeteer.js&quot;,
          puppeteerLaunchOptions: {
            executablePath: chromePath,
            args: chromeFlags,
            headless: true,
          },
          settings: {
            preset: &quot;desktop&quot;,
            throttlingMethod: &quot;provided&quot;,
            throttling: {
              rttMs: 0,
              throughputKbps: 0,
              cpuSlowdownMultiplier: 1,
              requestLatencyMs: 0,
              downloadThroughputKbps: 0,
              uploadThroughputKbps: 0,
            },
          },
          numberOfRuns: 3,
        },
        assert: {
          assertions: {
            &quot;categories:performance&quot;: [&quot;warn&quot;, { minScore: 0.8 }],
            &quot;categories:accessibility&quot;: [&quot;error&quot;, { minScore: 0.8 }],
            &quot;categories:best-practices&quot;: [&quot;warn&quot;, { minScore: 0.8 }],
            &quot;categories:seo&quot;: [&quot;warn&quot;, { minScore: 0.8 }],
          },
        },
        upload: {
          target: &quot;temporary-public-storage&quot;,
        },
      },
    };
&gt;
    const configPath = path.join(process.cwd(), &quot;lighthouserc.json&quot;);
    fs.writeFileSync(configPath, JSON.stringify(lighthouseConfig, null, 2));
...</code></pre>
<p>마지막으로, 이전에 만든 스크립트들을 조합하여 <code>lighthouserc.json</code> 을 만드는 과정이다.</p>
<p><code>lighthouserc.json</code> 이 실제로 Lighthouse CI를 동작할때 사용하는 설정 파일이며, 우리는 Puppeteer를 통해 인증 과정을 미리 세팅해야했기 때문에 javascript로 설정 파일을 생성하는 코드를 작성한거라고 이해하면 된다.</p>
<p>여기서 주목할 부분은 <code>lighthouserc.json</code> 속성에 <code>puppeteerScript</code> , <code>puppeteerLaunchOptions</code> 과 같이 puppeteer관련 속성이 있다는 것이다.</p>
<p>이를 통해 위에서 작성한 Puppeteer 스크립트가 실행된다.</p>
<blockquote>
<h4 id="4-lighthouse-ci">4. Lighthouse CI</h4>
</blockquote>
<pre><code class="language-yaml"> - name: 인증 설정
    run: node scripts/auth-setup.js
        env:
            TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
            TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
&gt;
- name: Lighthouse 설정 생성
    run: node scripts/generate-lighthouse-config.js
        env:
            CHROME_PATH: /usr/bin/google-chrome-stable
&gt;
- name: Lighthouse CI 실행
    run: lhci autorun
        env:
            LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
            CHROME_PATH: /usr/bin/google-chrome-stable</code></pre>
<p>CI 단계의 일부를 가져와봤는데 이전에 설정한 <code>auth-setup.js 실행</code> -&gt; <code>lighthouse config 생성</code> -&gt; <code>lighthouse CI 실행</code> 순서로 동작한다.</p>
<hr>
<h2 id="4-lighthouse-ci-도입-후기">4. Lighthouse CI 도입 후기</h2>
<p>위와 같은 방법으로 Lighthouse CI를 우리 프로젝트에 도입할 수 있었다.</p>
<p>하지만, 여러가지 문제가 있었는데 이제부터 한번 다뤄보려고 한다.</p>
<h3 id="💡-제대로-측정이-되고-있는가">💡 제대로 측정이 되고 있는가?</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/ec95f669-0bd3-468b-8380-e6548847841e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/1fc958c6-93f7-4ea1-b85f-a9834d3fe8ac/image.png" alt=""></p>
<p>위 사진은 이전에 소개한 Lighthouse CI의 결과이다.
여기서 측정된 페이지를 살펴보면 <code>localhost:4173/dashboard</code> 인데, <strong>해당 페이지는 로그인을 진행해야만 들어갈 수 있는 페이지이다.</strong></p>
<p>첨부된 사진을 살펴보면 분명 대시보드 페이지에 대한 성능 측정이 완료된 것으로 보인다.</p>
<p>하지만, 실제 제공된 레포트를 들어가면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/085fabfa-ba28-40ec-8bb7-9fbb6db2829c/image.png" alt=""></p>
<p>사진과 같이 <code>/onboarding</code> 페이지로 라우팅이 되어있는 모습을 볼 수 있으며, 캡처된 화면도 onboarding 화면이다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/c8e47fb9-e162-4c11-bd78-0d1ea1dcb196/image.png" alt=""></p>
<p>하지만, 트리맵에 들어가보면 다음과 같이 타겟팅한 <code>dashboard</code> 페이지로 제대로 되어있는 모습을 볼 수 있다.</p>
<p>따라서, 필자가 생각하기에는 세션 정보에 저장된 인증 관련 정보가 성능 측정이 끝나면 사라지기 때문에, <code>onboarding</code> 페이지로 자동 라우팅되어 결과 레포트 주소는 <code>onboarding</code> 으로 보이는게 아닐까하고 추측해본다.</p>
<h3 id="💡-성능-측정-결과가-일관되지-않다">💡 성능 측정 결과가 일관되지 않다</h3>
<p>Lighthouse CI를 선택하면서 고려했던 가장 큰 주안점은 <code>환경 일관성</code> 과 <code>측정 신뢰성</code> 이다.</p>
<p>즉, 서로 다른 팀원이 Github Action을 통해 성능 측정을 진행하더라도 동일한 결과가 측정되어야만 의미가 있는 상황이라는 것이다.</p>
<p>하지만, 결과는 다음 사진과 같았다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/3ae9c1b6-f1fa-4216-901e-ce8e6cbb5d10/image.png" alt=""></p>
<p>보다시피 LCP와 CLS의 차이는 거의 무의미 할정도로 비슷하다.
(여기서 Lighthouse CI는 3번의 측정 이후, 중앙값을 레포트로 제공해준다.)</p>
<p>하지만, 이 Performance 점수를 통해 알 수 있는건 Github 서버의 네트워크 상황에 따라서 성능 측정 결과가 달라질 수 있음을 의미한다.</p>
<p>만약, 그렇다면 Github 서버의 네트워크 환경은 우리가 제어할 수 없기 때문에 성능 측정에 대한 일관성을 보장하기 어렵다고 판단했다.</p>
<hr>
<h2 id="5-그래서-도입한-방법은">5. 그래서 도입한 방법은?</h2>
<p>그래서 우리팀은 <strong>로컬 환경에서 연속으로 5번 Lighthouse를 통해 성능을 측정한 이후, 그 결과의 평균값을 사용하기로 결정했다.</strong></p>
<p>실험적으로 진행해보니 이 방법이 가장 <code>측정 신뢰성</code> 이 높다고 판단했다.</p>
<h3 id="💡-한계점">💡 한계점</h3>
<p>물론 이러한 방식으로 테스트를 진행하면서 얻은 결과를 다른 팀원들과 비교하면서 Performance 점수를 논하기는 어렵다.</p>
<p>왜냐하면 결국 테스트 환경이 다르기 때문에 A 팀원이 측정한 Performance 점수 90점과 B 팀원이 측정한 80점은 네트워크 환경이 같았다면 동일한 점수가 될 가능성이 있기 때문이다.</p>
<blockquote>
<h3 id="대안책">대안책</h3>
</blockquote>
<p><strong>상대적 비교</strong></p>
<ul>
<li>동일한 환경에서 개선 전후를 비교하여 상대적인 성능 향상을 측정</li>
</ul>
<hr>
<p><strong>일관된 측정 환경</strong></p>
<ul>
<li>팀 내에서 측정 방법과 환경을 표준화하여 최대한 일관성 확보</li>
</ul>
<p>결국, 동일한 와이파이를 사용하는 환경에서 동일한 노트북으로 성능 측정을 진행한 이후 팀원끼리 논의하는 방식이 가장 적합하다고 생각한다!</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>성능 개선 측정이라는 주제로 여러가지를 시도해본 결과, 각각의 방법에는 장단점이 있으며, 프로젝트의 상황과 팀의 리소스를 고려하여 가장 적합한 방법을 선택하는 것이 중요하다는걸 몸소 깨달았다.</p>
<p>우리가 경험한 것처럼 이론적으로 완벽해 보이는 방법도 실제 적용해보면 예상치 못한 문제들이 발생할 수 있다는걸 오랜만에 느껴본 것 같다.</p>
<p><code>튜닝의 끝은 순정이다</code> 라는 말이 참 적절한 것 같다..</p>
<p>나중에 다른 프로젝트를 진행한다면, Lighthouse CI는 퍼포먼스가 너무 떨어지지 않는지 검사하는 용도로만 사용해볼 것 같다! 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Playwright를 사용한 E2E 테스트 적용기]]></title>
            <link>https://velog.io/@sung-yeop/playwright-test</link>
            <guid>https://velog.io/@sung-yeop/playwright-test</guid>
            <pubDate>Thu, 05 Jun 2025 14:41:04 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>프로젝트를 진행하면서 E2E 테스트 코드를 작성하여 자동화를 시키기로 결정했다.</p>
<p>우리팀은 프로젝트에서 E2E 테스트를 진행하기 위해 Playwright를 도입하기로 결정했다.</p>
<p>따라서, 이번 포스팅에서는 Playwright를 적용하면서 어떤 고민이 있었고, 어떻게 해결했는지 정리해보려고 한다 </p>
<hr>
<h2 id="1-why-playwright">1. Why Playwright?</h2>
<p>E2E 테스트의 목적부터 정리하고 시작해야할 것 같다.</p>
<p>우리 프로젝트에서는 E2E 테스트의 목적을 다음과 같이 정의하고 시작했다.</p>
<blockquote>
<p><strong>E2E 테스트 목적</strong>
0. 실제 사용자가 사용하는 환경을 대전제로한다.</p>
</blockquote>
<ol>
<li>예상한 시점에 API가 제대로 호출되는지 확인한다.</li>
<li>API의 응답이 우리가 예상한 것과 동일하게 넘어오는지 확인한다.</li>
<li>API의 응답이 발생한 이후, UI가 예상한 것과 동일하게 변화하는지 확인한다.</li>
<li>예외 케이스도 제대로 동작하는지 확인한다.</li>
</ol>
<p>이러한 목적을 가지고 테스트를 진행해야했기 때문에, 실제 Chromium 환경에서 동작하는 <code>Playwright</code> 를 도입하기로 결정했다.</p>
<h3 id="💡-테스트-환경은-어떻게-구성했는가">💡 테스트 환경은 어떻게 구성했는가?</h3>
<p>위에서 말한 것처럼 E2E 테스트는 실제 사용자가 사용하는 환경을 대상으로 진행해야한다.</p>
<p>하지만, 우리는 아직 프로덕션 환경의 배포가 진행되지 않았으며, 당연히 운영 서버에 테스트 코드를 날리는건 위험하다</p>
<p>따라서, 우리는 목데이터가 세팅된 <code>개발 서버</code> 를 대상으로 E2E 테스트를 진행했다.</p>
<p>(개발서버 목데이터는 항상 동일한 데이터로 세팅된다)</p>
<hr>
<h2 id="2-what-issue">2. What Issue?</h2>
<p>테스트해야할 대상이 <code>관리자 서비스</code> 라는 것에서부터 약간의 문제가 있었다.</p>
<p>우선 프로젝트의 메인 시나리오를 소개하면 다음과 같다.</p>
<blockquote>
<p><strong>프로젝트 메인 시나리오</strong></p>
</blockquote>
<ol>
<li>팝업 등록 시나리오</li>
<li>상품 등록 시나리오</li>
<li>상품 조회 시나리오</li>
<li>대시보드 조회 시나리오</li>
<li>발주 요청 조회 시나리오 &amp; 발주 진행 시나리오</li>
</ol>
<p>여기서 중요한건 이 모든 시나리오는 <code>로그인 &amp; 팝업 선택</code> 이라는 동일한 Flow를 사전에 가지고 있다는 것이다</p>
<p>다시 말하면, 팝업 등록 시나리오를 진행하기 위해서는 <code>로그인</code> 을 진행해야한다.</p>
<p>상품 등록 시나리오를 진행하기 위해서는 <code>로그인 &amp; 팝업 선택</code> 을 진행해야한다.</p>
<p>또한, 로그인을 제외한 모든 API 요청에는 헤더에 AccessToken이 필요한데 이를 어떻게 처리해야할지도 고민해야하는 상황이 되었다.</p>
<h3 id="💡-global-setup-설정">💡 global-setup 설정</h3>
<p>로그인을 진행하면 AccessToken은 ResponseBody로, RefreshToken은 쿠키로 발급된다.</p>
<p>다른 모든 API 요청헤더에 AccessToken은 어떻게 추가해야하고, RefreshToken은 어떻게 관리해야할까?</p>
<p>다행히도 Playwright에서는 인증 정보를 사전에 세팅할 수 있도록 설정할 수 있는 방법을 제공하고 있었다.</p>
<p>찾아보니, 다른 분들도 <code>global-setup.ts</code> 라는 파일을 생성하여 맨 처음 실행되도록 설정하는 방법을 사용하는 것 같았다.</p>
<p>그래서 필자가 작성해본 <code>global-Setup.ts</code> 파일을 살펴보면 다음과 같다.</p>
<pre><code class="language-typescript">import { chromium, FullConfig } from &quot;@playwright/test&quot;;
import fs from &quot;fs&quot;;
import process from &quot;process&quot;;

async function globalSetup(config: FullConfig) {
  const authFile = &quot;tests/auth.json&quot;;

  if (fs.existsSync(authFile) &amp;&amp; !process.env.CI) {
    return;
  }

  const browser = await chromium.launch();
  const page = await browser.newPage();

  const baseURL = config.projects?.[0]?.use?.baseURL;

  try {
    await page.goto(`${baseURL}/onboarding`);
    await page.waitForSelector(&#39;input[placeholder*=&quot;아이디를 입력해주세요&quot;]&#39;, {
      timeout: 10000,
    });

    await page.getByPlaceholder(&quot;아이디를 입력해주세요&quot;).fill(&quot;manager1&quot;);
    await page.getByPlaceholder(&quot;비밀번호를 입력해주세요&quot;).fill(&quot;password1&quot;);
    await page.click(&#39;button:has-text(&quot;login&quot;)&#39;);

    await page.waitForTimeout(3000);
    await page.context().storageState({ path: authFile });
  } finally {
    await browser.close();
  }
}

export default globalSetup;</code></pre>
<p>코드는 인증 정보를 <code>tests/auth.json</code> 에 파일 형식으로 저장한다.
이후, 현재 브라우저 컨텍스트의 모든 인증 정보를 위에서 생성한 <code>auth.json</code> 파일로 저장하는 코드를 추가하면 된다.</p>
<h3 id="💡-playwrightconfigts">💡 playwright.config.ts</h3>
<p>위에서 설정한 <code>global-setup.ts</code> 는 모든 테스트 코드가 실행되기 이전에 실행되어야한다.</p>
<p>이러한 실행 순서를 결정하는 작업은 설정 파일에서 진행할 수 있다.</p>
<p>필자가 세팅했던 설정 파일을 살펴보면 다음과 같다.</p>
<pre><code class="language-typescript">import { defineConfig, devices } from &quot;@playwright/test&quot;;
import path from &quot;path&quot;;
import process from &quot;process&quot;;
import dotenv from &quot;dotenv&quot;;

const isCI = !!process.env.CI;
dotenv.config();

export default defineConfig({
  testDir: &quot;./tests&quot;,
  fullyParallel: true,
  forbidOnly: false,
  retries: isCI ? 2 : 0,
  workers: isCI ? 1 : undefined,
  reporter: &quot;html&quot;,
  globalSetup: path.resolve(&quot;./tests/global-setup.ts&quot;),

  use: {
    baseURL: `${isCI ? process.env.VITE_DNS_URL : &quot;http://localhost:3000&quot;}`,
    trace: &quot;on-first-retry&quot;,
    storageState: &quot;tests/auth.json&quot;,
  },

  projects: [
    {
      name: &quot;setup&quot;,
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: &quot;default-helper-func&quot;,
      testMatch: /.*DefaultFlow\.spec\.ts/,
      use: {
        storageState: undefined,
      },
      dependencies: [&quot;setup&quot;],
    },
    {
      name: &quot;core&quot;,
      testMatch: /^(?!.*DefaultFlow).*\.spec\.ts$/,
      use: {
        ...devices[&quot;Desktop Chrome&quot;],
      },
      dependencies: [&quot;default-helper-func&quot;],
    },
  ],

  ...(isCI
    ? {}
    : {
        webServer: {
          command: &quot;npm run dev&quot;,
          url: &quot;http://localhost:3000&quot;,
          reuseExistingServer: true,
        },
      }),
});</code></pre>
<p>여기서 <code>playwright.config.ts</code> 에는 <code>globalSetup</code> 이라는 옵션을 제공한다.</p>
<p>여기에 이전에 생성했던 <code>global-setup.ts</code> 를 추가하면 모든 프로젝트 워커가 실행되기 이전에 딱 한번 실행되어 인증 정보를 생성하고 <code>auth.json</code> 을 저장하게 된다.</p>
<p>다음으로 projects의 <code>setup</code> 부분을 살펴보면 중간에 setup이라는 이름을 가진 파일들을 실행하게 된다.</p>
<p>보다시피, 앞단에서 정의된 프로젝트에 의존성을 갖기 때문에 어떠한 설정이 필요하다면 <code>*.setup.ts</code> 파일을 생성하여 추가하면 된다.</p>
<h3 id="💡-defaultflowspects">💡 DefaultFlow.spec.ts</h3>
<p>다음으로 공통 처리될 부분은 “팝업 리스트 선택”이다.</p>
<p>필자는 <code>로그인 &amp; 팝업 리스트 선택</code> 을 진행하는 테스트 코드를 <code>DefaultFlow.spec.ts</code> 에 정의했고, 위에서 소개한 설정파일을 보면 이는 다른 테스트 코드가 실행되기 이전에 실행된다.</p>
<p>따라서, 이후 core 단계에 정의된 테스트 파일들은 모두 팝업 리스트 페이지까지 이동된 이후 실행됨을 보장한다.</p>
<hr>
<h2 id="3-real-data-vs-test-data---어떻게-테스트할-것인가">3. Real Data vs Test Data - 어떻게 테스트할 것인가?</h2>
<p>필자는 이번에 E2E 테스트 코드를 작성하면서 테스트 환경이라는 상황이라 발생하는 딜레마를 겪을 수 밖에 없었다.</p>
<h3 id="💡-실제-데이터-환경의-딜레마">💡 실제 데이터 환경의 딜레마</h3>
<p>E2E 테스트를 작성하면서 가장 큰 고민 중 하나는 <code>&quot;실제 데이터 환경에서 어떻게 일관된 테스트를 작성할 것인가?&quot;</code> 였다.</p>
<p>우리 프로젝트는 개발 서버의 목데이터를 대상으로 테스트를 진행했는데, 이때 다음과 같은 문제가 발생했다</p>
<blockquote>
</blockquote>
<ol>
<li>팝업이 이미 등록된 상태 vs 팝업이 없는 상태</li>
<li>상품이 등록된 상태 vs 상품이 없는 상태</li>
</ol>
<p>예를들어, 개발 서버 DB에 이미 팝업이 하나라도 등록되어있는 상황이라면 팝업이 등록되지 않는 테스트 케이스를 작성하기가 매우 까다롭다.</p>
<p>개발한 UI를 살펴보면 다음과 같다.</p>
<blockquote>
<h3 id="등록된-팝업이-있는-상태">등록된 팝업이 있는 상태</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/070260a6-217f-40b4-8722-2612ff2a9364/image.png" alt=""></p>
<h3 id="등록된-팝업이-없는-상태">등록된 팝업이 없는 상태</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/956c24bd-e60c-4754-a75e-fb1c02133f94/image.png" alt=""></p>
</blockquote>
<p>팝업이 등록되지 않는 경우에 대한 테스트 케이스를 작성한다면, <code>등록된 팝업이 없습니다.</code> 라는 문구가 발생되는지 여부를 통해 테스트 통과를 시킬 수 있을 것이다.</p>
<p>하지만, 등록된 팝업이 있는 상황에서 아무런 팝업이 등록되지 않는 상황을 테스트하기는 어렵다.</p>
<p>물론, 기존에 개발서버에 등록되어있던 모든 팝업을 삭제하고 그 결과를 확인하는 테스트 코드를 작성할수도 있겠으나 그렇게 진행하면 개발 서버 DB를 밀고 다시 세팅해야하는 번거로움이 있다.</p>
<h3 id="💡-우리가-시도해본-해결-방법들">💡 우리가 시도해본 해결 방법들</h3>
<h4 id="1-별도-계정으로-분리">1. 별도 계정으로 분리</h4>
<pre><code class="language-typescript">// 팝업이 있는 계정용 테스트
await loginAs(&#39;manager1&#39;); // 팝업 등록된 계정</code></pre>
<pre><code class="language-typescript">// 팝업이 없는 계정용 테스트  
await loginAs(&#39;manager2&#39;); // 팝업 없는 계정</code></pre>
<p>이 방법의 문제점은 각 상황별로 별도의 globalSetup이 필요하고, 테스트 케이스 관리가 복잡해진다는 것이었다.</p>
<p>또한, 해당 계정도 서버에 별도로 등록하는 번거로움이 있었다.</p>
<h4 id="2조건부-테스트-우리가-선택한-방법">2.조건부 테스트 (우리가 선택한 방법)</h4>
<pre><code class="language-typescript">test.describe(&quot;헬퍼함수 기능 테스트 - 팝업 리스트 조회 및 대쉬보드 이동&quot;, () =&gt; {
  test(&quot;팝업 리스트 조회가 가능하고 팝업이 있다면, 클릭시 대시보드 페이지로 이동한다. \n 만약 팝업이 없다면, 등록된 팝업이 없다는 문구가 보인다.&quot;, async ({
    page,
  }) =&gt; {
    // given &amp; when - 팝업 리스트 이동시 조회 API 호출
    const [responsePromise] = await Promise.all([
      page.waitForResponse(response =&gt; {
        const url = response.url();
        return url.endsWith(&quot;/popups&quot;) &amp;&amp; response.request().method() === &quot;GET&quot;;
      }),
      page.goto(&quot;/popup-list&quot;),
    ]);

    await page.waitForLoadState(&quot;networkidle&quot;); // 네트워크가 안정화될 때까지 대기 -&gt; waitFor과 동일한 효과 기대 가능

    // then - 조회 결과 검증
    expect(responsePromise.status()).toBe(200);
    const responseBody = await responsePromise.json();
    const numOfPopupFromAPI = responseBody.data.length;

    // 조회된 데이터가 있을 경우와 없을 경우를 if문을 통해 분기 처리
    if (numOfPopupFromAPI !== 0) {
      const allPopups = page.locator(&#39;span[data-testid^=&quot;popup-card-&quot;]&#39;);
      await expect(allPopups.first()).toBeVisible();

      const numOfPopupOnScreen = await allPopups.count();

      expect(numOfPopupFromAPI).toBeGreaterThan(0);
      expect(numOfPopupFromAPI).toBe(numOfPopupOnScreen);

      const firstPopup = allPopups.first();
      await firstPopup.click({ timeout: 3000 });

      await expect(page).toHaveURL(&quot;/dashboard&quot;);
    } else {
      await expect(page.getByText(&quot;등록된 팝업이 없습니다.&quot;)).toBeVisible();
    }
    await page.waitForLoadState(&quot;networkidle&quot;);
  });
});</code></pre>
<p>결국 우리는 테스트 케이스를 분리하지 않고 여러가지 조건(<code>ex. 계정 추가 생성 등</code>)을 추가해야하는 경우에는 분기처리로 없는 경우에대한 테스트 코드만 작성하기로 결정했다.</p>
<h3 id="💡-테스트는-어떻게-작성하면-좋을까">💡 테스트는 어떻게 작성하면 좋을까?</h3>
<p>E2E 테스트를 어떤 방식으로 작성해야 할지 고민하던 중, 팀원의 코드 리뷰 과정에서 좋은 접근법을 발견하게 되었다.</p>
<p>이 방법이 꽤 괜찮은 것 같아서 PPT에 사용된 장표 일부를 소개하고자 한다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/7e3cb957-6fe3-4348-b9db-cc5dbd46af1c/image.png" alt=""></p>
<p>여러가지 고민끝에 생각한 순서는 다음과 같다.</p>
<p>마지막 예외 케이스 작성 단계가 중요한 이유는, 실제 사용자 데이터와 운영 환경에서 발생할 수 있는 다양한 시나리오를 미리 검증할 수 있기 때문이라고 생각한다!</p>
<h3 id="💡-테스트-데이터-관리의-고민">💡 테스트 데이터 관리의 고민</h3>
<p>이런 방식으로 테스트를 작성하다 보니 <code>&quot;과연 이것이 올바른 E2E 테스트일까?&quot;</code> 라는 의문이 들었다.</p>
<p>결국, 정리해보면 E2E 테스트의 <code>Pros and Cons</code> 는 다음과 같다고 볼 수 있다.</p>
<blockquote>
<h3 id="pros">Pros</h3>
</blockquote>
<ul>
<li>실제 데이터 환경에서 테스트하므로 현실적이다.</li>
</ul>
<hr>
<h3 id="cons">Cons</h3>
<ul>
<li>테스트 케이스가 데이터 상태에 의존적이다.</li>
<li>예외 상황 테스트가 어려우며, 테스트 코드가 복잡해진다.</li>
</ul>
<hr>
<h2 id="outro">OUTRO</h2>
<p>필자가 이번에 E2E 테스트 코드를 작성하면서 느낀점을 정리해보면 다음과 같다.</p>
<h3 id="💡-e2e-테스트만으로는-부족하다">💡 E2E 테스트만으로는 부족하다</h3>
<p>E2E 테스트를 작성하면서 느낀 점은 E2E 테스트만으로는 모든 상황을 커버하기 어렵다는 것이었다.</p>
<p>특히, 예외 상황 테스트(서버 에러, 네트워크 오류 등)를 진행하기 위해서는 테스트 코드양이 방대해지며, 이를 유지보수하는게 과연 프로덕션 환경에서 에러를 잘잡아줄지도 약간 의문이 들었다.</p>
<h3 id="💡-우리가-내린-결론">💡 우리가 내린 결론</h3>
<p>따라서 앞으로 테스트 코드를 작성한다면 다음과 같이 작성하는게 더 예외 상황을 잘 잡아주고, 유지보수도 편해질 것이라고 생각한다.</p>
<blockquote>
</blockquote>
<ul>
<li>E2E 테스트: 핵심 사용자 플로우 검증</li>
<li>통합 테스트: API 레벨에서의 비즈니스 로직 검증</li>
<li>단위 테스트: 개별 컴포넌트/함수 검증</li>
</ul>
<p>E2E 테스트는 &quot;행복한 경로&quot;를 중심으로, 나머지 예외 상황은 단위/통합 테스트에서 더 세밀하게 다루는 것이 효율적이라고 생각한다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] 2차 미니프로젝트 회고록]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-2%EC%B0%A8-%EB%AF%B8%EB%8B%88%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-2%EC%B0%A8-%EB%AF%B8%EB%8B%88%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Sat, 05 Apr 2025 05:50:24 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>2차 프로젝트가 끝나고 3일이 지난 이제서야 회고록을 써보려고 한다 😅</p>
<p>프로젝트 기간 동안 많은 트러블 슈팅이 있었고, 이를 해결하기 위해 정말 많은 시간과 노력을 쏟았다. </p>
<p>짧은 기간이었지만 MSA 아키텍처와 클라우드 환경에서의 서비스 구축을 직접 경험하면서 의미 있는 기술적 도전을 할 수 있었다.</p>
<p>이제부터 필자가 프로젝트에서 어떤 파트를 담당했고, 어떤 작업을 했는지 소개해보려고 한다!</p>
<hr>
<h2 id="1-어떤-역할을-맡았는지">1. 어떤 역할을 맡았는지?</h2>
<p>본 프로젝트의 주제는 1차 프로젝트에서 진행했던 다른 팀의 결과물에 MSA를 적용하는 것이었다.</p>
<p>여기서 필자는 <code>AWS 클라우드 아키텍처 및 CI/CD 파이프라인 구축</code> 이라는 DevOps 파트를 맡게 되었다.</p>
<p>사실 AWS와 CI/CD 모두 이번 수업에서 처음 접했던 내용이긴 했지만, 한번 해보면 좋은 경험이 될 것 같아 자진해서 지원해봤다.</p>
<p>하지만, 생각만큼 쉽지는 않았다..</p>
<hr>
<h2 id="2-cicd-구축">2. CI/CD 구축</h2>
<p>우선 CI/CD를 구축하기 위해 AWS EC2에 Jenkins를 직접 설치하는 방법을 사용했다.</p>
<p>수업 시간 중에, 강사님께서 Jenkins의 CI/CD 작업은 많은 프로젝트와 연결되어있고, 프로젝트 규모에 따라 자원을 많이 사용할 수 있기 때문에 별도로 관리하는게 좋다는 말씀해주셨기 때문이다.</p>
<h3 id="💡-파이프라인-동작">💡 파이프라인 동작</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/83ea9240-f029-4cfa-b9d4-808709cc4256/image.png" alt=""></p>
<p>백엔드 파트에서 도메인별로 나눈 프로젝트에는 이와 같은 파일 구조를 가지도록 설계했다.</p>
<p>이후, 파이프라인은 다음과 같은 순서로 동작하도록 설계해봤다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/7e48c13c-21fe-40dd-9468-3edaf52345df/image.png" alt=""></p>
<p>동작 과정을 살펴보면, <code>Git Clone</code> 으로 가져온 프로젝트 파일을 사용해서 <code>Docker Image Build</code> 를 진행하기 때문에 프로젝트 내부에 <code>Dockerfile</code> 이 필요했고, 각 프로젝트마다 파이프라인이 다를 수 있기 때문에 별도의 <code>Jenkinsfile</code> 을 작성해서 추가하는 방법을 사용했다.</p>
<h3 id="💡-문제점">💡 문제점</h3>
<p>CI/CD 파이프라인은 Git Webhook을 사용해서 동작한다.</p>
<p>따라서, 각 프로젝트마다 Git Webhook을 추가해주는데 여기서 Jenkins에서 기본적으로 제공하는 Webhook을 사용하면 상세한 설정이 불가하다.</p>
<p>한가지 예를 들자면, API Gateway Repo에 파이프라인을 작성해두고 AWS Cloud 환경에서 Eureka에 API Gateway가 등록되는 테스트를 진행하고 있었다.</p>
<p>이 과정에서 다른 팀원이 Develop 브랜치에 푸쉬를 하는 상황이 있었는데 파이프라인이 동작해서 재배포가 되는 과정이 꽤 있었다.</p>
<p>그래서 우리는 <code>Generic Webhook Trigger</code> 라는 별도의 플러그인을 사용해서 반자동화를 사용하는 방법을 택했다.</p>
<p>단순히 Develop 브랜치에 Push나 PR이 발생하는 모든 경우에 파이프라인이 동작하는것이 아닌, <code>Deploy</code> 라벨을 추가한 경우에만 파이프라인이 동작되도록 프로젝트를 구성하고 진행했다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/7d6080c5-b692-48a4-b5bc-fd61663f5a3b/image.png" alt=""></p>
<p>물론, 브랜치를 세세하게 나누고 특정 브랜치에 대해서는 완전 자동화를 사용하는 방법도 있겠으나 프로젝트 Rule을 세세하게 정할 시간이 별로 없었기 때문에 이러한 방식으로 해결해봤다.</p>
<h3 id="💡-아쉬운점">💡 아쉬운점</h3>
<p>본 프로젝트를 진행하면서 Jenkinsfile을 실제로 하나씩 모두 작성해서 프로젝트에 넣어줬는데, Groovy로 Jenkinsfile의 구조를 잡아주고 Github Scan을 사용하면 코드의 중복을 최소화해서 작업할 수 있다고 한다.</p>
<p>Groovy 언어를 사용해본적이 없어서 당시에는 적용해볼 엄두가 나지 않았으나, 이런 부분을 적용하지 못한게 아쉬워서 시간남으면 혼자 해보려고 한다</p>
<hr>
<h2 id="3-aws-cloud-architecture">3. AWS Cloud Architecture</h2>
<p>프로젝트를 진행하면서 가장 많이 했던 고민이 아키텍처에 대한 고민인 것 같다.</p>
<p>물론, 최종적으로 사용한 결과물이 완벽하진 않지만 어떤 내용을 고민했는지 공유해보려고 한다.</p>
<p>우선, 프로젝트의 초기 아키텍처를 살펴보면 다음과 같다.</p>
<h3 id="💡-초기-아키텍처">💡 초기 아키텍처</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/b71fb251-29c1-46b6-bf66-67fb93ab4e8f/image.png" alt=""></p>
<p>위 사진과 같이 ECS에 서비스를 여러개 띄우고 ALB 하나가 정의된 Fargate에 대해서 로드 밸런싱을 모두 진행하는 구조로 설계했다.</p>
<p>하지만, 우리가 프로젝트에 MSA를 적용하는 과정에서 Spring Cloud API Gateway, Config Service, Eureka 등등을 사용하는데 이러한 서비스는 Outer Architecture에 속한다고 볼 수 있다. </p>
<p>실제로 사용자에게 서비스를 제공하기 위한 비즈니스 로직이 처리되는 부분이 아니기 때문이다.</p>
<p>중간에 강사님께 피드백을 받는 시간이 종종 있었는데, 이러한 Outer Architecture는 고가용성을 확보하기 위해 EC2에 배포한다고 피드백을 주셨다.</p>
<p>또한, 필자의 경우도 이미 Spring Cloud API Gateway를 사용하는 구조에서 ALB를 사용해 로드밸런싱을 하는게 과연 맞는 선택일까 하는 생각도 가지고 있었다.</p>
<p>그래서 구조를 다시 변경하기 시작했다..!</p>
<p>(이미 파이프라인까지 모두 만들어둔 상태였으나, 피드백을 받고 전체적으로 수정하기로 결정했다..........)</p>
<h3 id="💡-최종-아키텍처">💡 최종 아키텍처</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/a8598dfa-a3f4-4334-8751-138b32b49b0b/image.png" alt=""></p>
<p>이미 Spring Cloud API Gateway를 사용하고 있으니 모든 요청을 API Gateway로 받도록 수정했다.</p>
<p>이후, 여러개의 서비스 인스턴스를 유레카에 등록했다면 라운드로빈 방식으로 라우팅을 진행하지만, 여기서 우리는 상대적으로 많은 트래픽이 몰릴 수 있다고 생각한 User-Service와 Track-Service에 ALB를 사용해서 로드 밸런싱과 오케스트레이션을 진행하도록 구조를 변경했다.</p>
<p>이렇게 구조를 작성한다면 User-Service와 Track-Service는 Eureka의 서비스 디스커버리 기능을 포기하게 된다.</p>
<p>조금 더 자세히 설명하자면 다음과 같다.</p>
<p>우선 우리는 Config 서버에서 API Gateway에 대한 라우팅 정보를 받아와서 동작하도록 설계했다.</p>
<p>그 라우팅 정보는 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yml">spring:
  config:
    activate:
      on-profile: &quot;routes&quot;
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/members/**
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/admin/**
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/auth/**
        - id: track-service
          uri: lb://track-service
          predicates:
            - Path=/tracks/**
        - id: recommendation-service
          uri: lb://recommendation-service
          predicates:
            - Path=/music/**</code></pre>
<p>여기서 user-service와 track-service가 ALB를 사용하도록 하기 위해, prod 환경에서는 다음과 같은 라우팅 정보를 가져오도록 한다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yml">spring:
  config:
    activate:
      on-profile: &quot;routes-prod&quot;
  cloud:
    gateway:
      routes:
        - id: user-service
          # 실제 ALB URI 입력
          uri: http://user-service-alb.region.elb.amazonaws.com 
          predicates:
            - Path=/members/**
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/admin/**
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/auth/**
        - id: track-service
          # 실제 ALB URI 입력
          uri: http://track-service-alb.region.elb.amazonaws.com 
          predicates:
            - Path=/tracks/**
        - id: recommendation-service
          uri: lb://recommendation-service
          predicates:
            - Path=/music/**
      globalcors:
        corsConfigurations:
          &#39;[/**]&#39;:
            allowedOrigins: &quot;*&quot;
            allow-credentials: false
            allowedHeaders:
              - x-requested-with
              - authorization
              - content-type
              - credential
              - X-AUTH-TOKEN
              - X-CSRF-TOKEN
            allowedMethods:
              - POST
              - GET
              - PUT
              - OPTIONS
              - DELETE</code></pre>
<p>이렇게되면 User-Service와 Track-Service는 Eureka에 등록할 필요가 없고, ALB가 대신 ECS 서비스의 여러 태스크 간 로드 밸런싱을 담당하게 된다.</p>
<p>만약, 서비스 자체적인 트래픽에 대해서 로드밸런싱이 필요하다면 API Gateway 앞쪽에 ALB를 하나 더 추가하는 방법도 있을 것이다.</p>
<h3 id="💡-문제점-1">💡 문제점</h3>
<p>가장 큰 문제점은 비용이다. </p>
<p>만약, 실무에서 Cloud Architecture를 구성해야한다면 불필요한 부분은 최대한 제거해서 아키텍처를 구성하는게 맞다.</p>
<p>실제 트래픽에 대한 테스트를 진행한게 아니기 때문에 위처럼 구조를 작성하는게 오히려 불필요한 관리를 더하는 일이 될 수 있으며, Fargate의 경우 메모리 설정을 서비스에 Fit하게 맞춘게 아니라면 메모리를 사용하지 않아도 그대로 비용이 청구된다.</p>
<p>실제로 비용청구 내용을 보니 메모리는 10%만 사용하면서 전체 비용을 청구하는 모습을 볼 수 있었다..</p>
<p>다른 팀의 경우 EC2에 도커를 사용해서 프로젝트를 관리하는 구조를 보여주기도 했는데, 비용과 관리를 생각하면 이번 프로젝트에서는 그러한 구조가 훨씬 더 적절하다는 생각이 들긴한다.</p>
<p>따라서, 본 프로젝트에서는 최대한 여러가지 방법을 시도해봤다는데에 의의를 두려고 한다 👊</p>
<hr>
<h2 id="4-ec2-eureka--ecs">4. EC2 (Eureka) &amp; ECS</h2>
<p>위에서 최종 아키텍처를 보면 알겠지만, EC2에 Eureka 프로젝트를 도커로 띄워서 사용하고 있다.</p>
<p>이때, 하나의 EC2에는 하나의 서비스만 띄우는 구조를 채택했기 때문에 각 EC2에 Spring 프로젝트를 띄우는 경우 Docker의 Host Network를 사용해서 포트를 뚫어줬다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/b963c123-6f55-4914-8a9e-7a77b9bbd824/image.png" alt=""></p>
<p>이런 구조를 사용해서 Spring Cloud의 Eureka, API Gateway, Config Server를 각 EC2에 띄웠다.</p>
<p>그리고 각 서비스는 ECS에서 클러스터를 도메인별로 분리해서, 단순히 컨테이너만 띄우는 Fargate를 사용했다.</p>
<p>그런데 우선 첫번째로 Eureka에 서비스가 등록되지 않는 문제가 발생했다.</p>
<p>로그를 살펴봤을 때, Config Server에서 설정 정보를 패치했음에도 자꾸 localhost에서 정보를 가져오려고 시도했기 때문에 발생한 문제였다.</p>
<p>그래서 bootstrap에 Eureka를 먼저 받도록 설정하여 해결했다.</p>
<h3 id="💡-api-gateway-호출-문제">💡 API Gateway 호출 문제</h3>
<p>EC2에 Spring Cloud API Gateway를 띄우고, Gateway를 통해 ECS의 서비스를 호출하려고 시도해보니 호출이 안되는 문제를 발견했다.</p>
<p>로그를 살펴보니 API Gateway에도 아무런 로그가 찍히지 않는 모습을 볼 수 있었다.</p>
<p>보안정책으로 Gateway 포트를 열어놨음에도 요청이 안들어오는 문제였다.</p>
<p><code>사실 이 문제 때문에 엄청 고생했다.</code></p>
<p><a href="https://joinwithyou.tistory.com/m/109">관련 포스팅</a>을 겨우겨우 찾아 Github Repo에 작성된 코드를 살펴보니 Global CORS 설정이 되어있는 모습을 볼 수 있었다.</p>
<p>그래서 본 프로젝트에도 API Gateway 설정 파일에 GlobalCORS 설정을 추가해서 문제를 해결할 수 있었다.</p>
<p>아무래도 로그가 찍히지 않았다는 것은 Gateway가 CORS 프리플라이트 요청 단계에서 차단되었다는 것을 의미한다고 생각한다.</p>
<p>Postman으로 요청을 넣었음에도 요청이 도달하지 않는다는 것은 API Gateway가 CORS 헤더를 확인하도록 설정되어 있었기 때문이다.</p>
<p>따라서, 이 부분을 모두 허용함으로써 해결할 수 있었다..!</p>
<h3 id="💡-ecs-서비스-호출-문제">💡 ECS 서비스 호출 문제</h3>
<p>위 설정을 마치고 API Gateway의 로그를 보니 요청은 정상적으로 들어온다.</p>
<p>그런데 이제는 요청에서 404가 떠버리는 모습을 볼 수 있었다.</p>
<p>라우팅 테이블은 제대로 등록되어있다고 계속해서 로그가 찍히는데, 해당 라우트 정보로 요청을 진행해도 404가 뜨는 상황이었다.</p>
<p>필자는 이 문제가 Eureka에서 서비스 인스턴스의 정보를 제대로 받아오지 못해서 발생한다고 생각했다.</p>
<p>그래서 Eureka에 등록된 서비스 정보를 찾아보기 시작했다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/ef3ade0d-da2f-46a1-a3cf-b6ba9c9e8e70/image.png" alt=""></p>
<p>위 사진은 EC2로 옮기기 전에 Eureka에 API Gateway가 등록되는 모습이다. (관련 사진을 안찍어놔서 없다..)</p>
<p>보다시피 <code>169.</code> 으로 시작되는 IP가 등록되는 모습을 볼 수 있다.</p>
<p>이 정보는 우리가 띄운 <code>ECS Fargate 컨테이너의 주소</code> 이다.</p>
<p>즉, 우리의 실제 서빙하고 있는 서비스의 Private IP가 아니라 컨테이너에서 사용하는 주소이므로 위 정보를 다시 내려받는다고해도 API Gateway는 정상적으로 서비스에 라우팅을 할 수 없다.</p>
<p>Eureka의 경우 <code>/eureka/apps</code> 로 들어가면 실제 등록된 서비스 인스턴스의 정보를 더 상세하게 확인할 수 있다.</p>
<p>살펴보니 IP Address도 위에서 언급한 Fargate 컨테이너 주소로 등록되어있는 모습을 볼 수 있었다.</p>
<p>따라서, 별도의 코드를 작성해서 Spring 프로젝트가 구동될 때, AWS Private IP를 Eureka에 등록하도록 설정을 변경했다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/cb21be07-e4e7-4688-b4cd-07f36aab21e3/image.png" alt=""></p>
<p>로그를 찍도록 코드를 작성했는데, 실제 Private IP로 등록되는 모습을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/a8df5b9b-39a8-4538-8c01-d7806aa4474c/image.png" alt=""></p>
<p>위 사진이 바로 <code>/eureka/apps</code> 정보에서 확인할 수 있는 서비스 인스턴스의 정보인데, <code>ipAddr</code> 관련 정보가 원하는대로 서비스의 Private IP가 등록된 모습을 볼 수 있었다.</p>
<p>이후, 서비스를 호출하면 제대로 호출이 된다!</p>
<h3 id="💡-문제점-2">💡 문제점</h3>
<p>이렇게 문제점을 해결해보면서 느낀 것은 AWS Cloud 환경에서 Spring Cloud를 적용하는게 꽤 까다롭다는 것을 느꼈다.</p>
<p>실제로 AWS에서도 Gateway 서비스를 제공하고 있기 때문에, 환경에 맞는 서비스를 사용하는게 더 낫다는 생각도 들었다.</p>
<p>다음 프로젝트 때, 기회가 된다면 AWS Gateway를 사용해서 플랫폼 친화적으로 아키텍처를 구성해보고 싶다.</p>
<hr>
<h2 id="5-그-외의-트러블-슈팅">5. 그 외의 트러블 슈팅...</h2>
<p>사실 이것말고도 엄청 많은 문제를 겪었다.</p>
<p>아마 한 스텝을 나가는데 3시간씩 걸린것 같다...</p>
<h3 id="💡-rabbitmq">💡 RabbitMQ</h3>
<p>예를들어 RabbitMQ를 EC2에 설치하는 과정에서 <code>4.0-management</code> Docker 이미지를 가져와서 설치했는데, 이건 찾아보니까 <code>linux/amd64</code> 환경은 지원하지 않는다.</p>
<p>EC2의 환경을 <code>linux/arm64</code> 로 변경하여 RabbitMQ를 직접 설치할 수 있었다.</p>
<h3 id="💡-mac-m3">💡 Mac M3</h3>
<p>필자의 노트북은 맥북 M3칩을 사용하는데, CPU를 M1/M2/M3로 사용하는 환경에서는 도커 이미지를 빌드할 때, 무조건 플랫폼을 명시해줘야 한다.</p>
<p>맥에서 빌드하는 이미지를 빌드하는 환경과 AWS Cloud 인스턴스 환경이 맞지 않는 경우가 대부분이기 때문이다.</p>
<p>따라서, CI/CD를 작성할 때 platform을 명시해주자..!</p>
<blockquote>
<p><strong>◉ 커맨드</strong>
<code>docker build --platform linux/amd64 -t mini2/api-gateway .</code></p>
</blockquote>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 프로젝트는 AWS, Jenkins 등을 활용한 CI/CD 파이프라인 구축부터 클라우드 아키텍처 설계, 배포까지 전적으로 맡아서 진행한 값진 경험이었다.</p>
<p>처음 해보는 것들이 많아 수많은 삽질과 밤샘을 했지만, 그만큼 배움의 깊이도 남달랐다 진짜로..</p>
<p>특히 마지막에 제대로 API Call이 되는 순간은 너무 감격스러웠다.</p>
<p>비록 과정은 고통스러웠지만, 문제를 하나씩 해결해 나가는 과정에서 MSA 아키텍처와 클라우드 환경에 대한 이해도가 크게 높아졌다.</p>
<p>짧은 기간 동안 정말 많은 기술을 경험하고 성장할 수 있어서 힘들었지만 그만큼 보람찼던 프로젝트였다.</p>
<p>이 경험이 앞으로의 개발에서 큰 자산이 될 것이라 확신한다 👊</p>
<p><a href="https://github.com/syncfit-msa">프로젝트 Github</a></p>
<h3 id="📖-참고">📖 참고</h3>
<p><a href="https://joinwithyou.tistory.com/m/109">트러블 슈팅 (feat. Eureka AWS 배포하기)</a>
<a href="https://velog.io/@zayson/Jenkins-CICD-4.-WebHook%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC">[Jenkins CI/CD] 4. WebHook을 이용한 자동 배포</a>
<a href="https://velog.io/@milkskfk5677/CICD-%ED%8A%B9%EC%A0%95-%EB%B8%8C%EB%9E%9C%EC%B9%98%EC%97%90-%EB%8C%80%ED%95%9C-GitHub-Webhook-%EB%B3%B4%EB%82%B4%EA%B8%B0">[CI/CD] Jenkins에서 특정 브랜치에 대한 GitHub Webhook만 처리</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] MSA (9) - Zipkin을 이용한 분산 트레이싱]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-9-Zipkin%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%B6%84%EC%82%B0-%ED%8A%B8%EB%A0%88%EC%9D%B4%EC%8B%B1</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-9-Zipkin%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%B6%84%EC%82%B0-%ED%8A%B8%EB%A0%88%EC%9D%B4%EC%8B%B1</guid>
            <pubDate>Sun, 16 Mar 2025 04:07:24 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이전 포스팅에서는 서킷 브레이커 패턴을 이용하여 마이크로서비스 아키텍처에서 회복성을 확보하는 방법에 대해 살펴봤다.</p>
<p>이번 포스팅에서는 MSA 환경에서 또 다른 중요한 문제인 &#39;디버깅과 모니터링&#39;을 해결하는 분산 트레이싱 시스템인 Zipkin에 대해 알아보고, 실제로 적용해보자 👀</p>
<hr>
<h2 id="1-분산-트레이싱이-필요한-이유">1. 분산 트레이싱이 필요한 이유</h2>
<p>마이크로서비스 아키텍처에서는 하나의 요청이 여러 서비스를 거쳐 처리되기 때문에 문제가 발생했을 때 원인을 찾기가 매우 어렵다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/475d5239-80ce-47a2-a8e6-539bd591ca9f/image.png" alt=""></p>
<p>위 사진은 Order-Service에서 getOrders() 메서드를 호출하는 과정에서 에러가 발생하는 구조이다.</p>
<p>서킷 브레이커를 사용하는 이유가 사용자의 경험을 해치지 않기 위함이었다면, 분산 트레이싱을 사용하는 이유는 개발자의 경험을 해치지 않기 위함이라고 볼 수 있다.</p>
<p>실제로, 위 그림과 같이 여러 서비스가 연계되어 있는 환경에서 디버깅을 진행하려면 여러 가지 어려움이 있다.</p>
<blockquote>
<h3 id="디버깅-문제">디버깅 문제</h3>
<p><code>여러 서비스의 로그를 동시에 확인해야 함</code></p>
</blockquote>
<ul>
<li>각기 다른 서비스에서 발생하는 로그를 한 번에 보기 어려움</li>
</ul>
<hr>
<p><code>요청의 흐름 추적이 어려움</code></p>
<ul>
<li>어떤 서비스에서 어떤 순서로 요청이 처리되었는지 파악하기 힘듦</li>
</ul>
<hr>
<p><code>다양한 기술 스택</code></p>
<ul>
<li>서로 다른 언어나 프레임워크로 개발된 서비스들을 모두 디버깅하기 어려움</li>
</ul>
<p>이런 문제들을 해결하기 위해 분산 트레이싱이라는 개념이 등장했고, 이를 구현한 대표적인 도구 중 하나가 바로 Zipkin인 것이다.</p>
<h3 id="💡-zipkin이란">💡 Zipkin이란?</h3>
<p>그렇다면, Zipkin은 뭘까?</p>
<p>Zipkin은 트위터에서 개발한 오픈소스 분산 트레이싱 시스템으로, 마이크로서비스 아키텍처에서 요청의 흐름을 시각화하고 분석할 수 있게 해준다.</p>
<p>Zipkin의 주요 개념으로는 &#39;Trace&#39;와 &#39;Span&#39;이 있다.</p>
<blockquote>
<h2 id="trace--span">Trace &amp; Span</h2>
<p><code>Trace</code></p>
</blockquote>
<ul>
<li>하나의 요청이 시스템을 통과하는 전체 경로를 나타내는 작업 그룹을 의미한다.</li>
</ul>
<hr>
<p><code>Span</code></p>
<ul>
<li>Trace를 구성하는 개별 작업 단위, 각 서비스에서 처리되는 하나의 작업을 의미한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/44f46f0a-c15d-4ea9-a12d-19910b2ca75f/image.png" alt=""></p>
<p>위 그림에서 볼 수 있듯이, 하나의 Trace ID에 여러 개의 Span ID가 포함된다. </p>
<p>각 Span은 요청이 특정 서비스에서 어떻게 처리되었는지에 대한 정보를 담고 있으며, 개발자는 이를 추적하여 로그를 트레이싱할 수 있다.</p>
<hr>
<h2 id="2-zipkin-설정하기">2. Zipkin 설정하기</h2>
<p>Spring 기반의 마이크로서비스에서는 Zipkin 의존성을 추가하여 분산 트레이싱을 구현할 수 있다.</p>
<p>우선 Zipkin을 설치하기 위해 공식 홈페이지로 접근하면 다음과 같은 화면을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/7fb24033-0b25-46cc-b786-5002d0dbd61d/image.png" alt=""></p>
<p>공식 홈페이지에서 알려주는 것과 같이 Docker에서 zipkin 컨테이너를 띄워서 사용할 수 있다.</p>
<p>하지만, 최근 Zipkin은 영구 저장소로 MySQL과 같은 데이터베이스를 사용하도록 변경되었다. </p>
<p>따라서 필자는 MySQL과 함께 Zipkin을 실행하기 위한 docker-compose 파일을 만들어서 사용해볼 예정이다.</p>
<h3 id="💡-zipkin-대쉬보드-접근">💡 Zipkin 대쉬보드 접근</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/f2a28b69-4fba-44c4-b9a3-bab50a2b2541/image.png" alt=""></p>
<p>Zipkin 홈페이지에서 제공하는 명령어를 입력하여 도커 컨테이너를 띄운 상태라면, 사진과 같이 대쉬보드에 바로 접근해볼 수 있다.</p>
<p>포트바인딩에 사용된 9411 포트로 접근하면 Zipkin에서 제공하는 UI를 이용하여 요청을 추적할 수 있다.</p>
<h3 id="💡-docker-compose-파일-작성하기">💡 Docker Compose 파일 작성하기</h3>
<p>우선 Docker Compose 파일을 살펴보자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">version: &#39;3&#39;
services:
  zipkin:
    image: openzipkin/zipkin
    ports:
      - &quot;9411:9411&quot;
    environment:
      - STORAGE_TYPE=mysql
      - MYSQL_DB=zipkin
      - MYSQL_USER=zipkin
      - MYSQL_PASS=zipkin
      - MYSQL_HOST=mysql
  mysql:
    image: mysql:5.7
    platform: linux/amd64
    volumes:
      - ./initdb.d:/docker-entrypoint-initdb.d
    environment:
      MYSQL_DATABASE: zipkin
      MYSQL_USER: zipkin
      MYSQL_PASSWORD: zipkin
      MYSQL_ROOT_PASSWORD: root
    ports:
      - &quot;3308:3306&quot;</code></pre>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/20c9b304-db69-4959-915e-e43507cca4b1/image.png" alt=""></p>
<p>이 docker-compose 파일에서는 Zipkin 서버와 MySQL 데이터베이스를 함께 실행한다. </p>
<p>MySQL 컨테이너가 시작될 때 필요한 스키마를 자동으로 생성하기 위해 초기화 스크립트를 볼륨으로 마운트하고 있다.</p>
<p>초기화 스크립트(initdb.d 디렉토리에 저장)는 Zipkin이 필요로 하는 테이블 구조를 생성하는 쿼리가 작성되어있으며, <a href="https://github.com/openzipkin/zipkin/blob/master/zipkin-storage/mysql-v1/src/main/resources/mysql.sql">zipkin 스키마</a>에서 제공하는 쿼리를 포함한다.</p>
<h3 id="💡-마이크로서비스에-zipkin-클라이언트-설정하기">💡 마이크로서비스에 Zipkin 클라이언트 설정하기</h3>
<p>Zipkin을 이용해서 트레이싱을 하고싶은 서비스 설정 파일에서 다음과 같은 설정 정보를 추가하자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">management:
  tracing:
    sampling:
      probability: 1.0    # 모든 요청에 대해 트레이싱 활성화
    propagation:
      consume: B3
      produce: B3
  zipkin:
    tracing:
      endpoint: http://localhost:9411/api/v2/spans    # Zipkin 서버 주소
  endpoints:
    web:
      exposure:
        include: health, httptrace, info, metrics, prometheus    # 모니터링 엔드포인트 노출</code></pre>
<p>여기서 눈여겨볼만한 설정은 다음과 같다.</p>
<blockquote>
<p><code>sampling.probability</code></p>
</blockquote>
<ul>
<li>트레이싱할 요청의 비율 (1.0은 모든 요청을 트레이싱)</li>
</ul>
<hr>
<p><code>propagation</code></p>
<ul>
<li>트레이싱 정보를 전달하는 형식 (B3는 Zipkin의 기본 형식)</li>
</ul>
<hr>
<p><code>zipkin.tracing.endpoint</code></p>
<ul>
<li>Zipkin 서버의 주소</li>
<li>여기서는 도커로 띄워놓은 주소를 제공하면 된다.</li>
</ul>
<p>이렇게 설정이 끝나면 실제로 서비스마다 <code>Slf4j</code> 를 사용하여 log를 찍어놓은 내용이 Zipkin에 등록된다.</p>
<p>별도로 코드를 추가하는 등의 작업은 필요없다.</p>
<h3 id="💡-micrometer-의존성-추가하기">💡 Micrometer 의존성 추가하기</h3>
<p>Spring Boot 3.x부터는 기존에 사용되던 Spring Cloud Sleuth가 Micrometer Tracing으로 통합되었다.</p>
<p>따라서 분산 트레이싱을 구현하기 위해서는 Sleuth가 아닌 Micrometer 관련 의존성을 추가해야 한다.</p>
<p>Gradle을 사용한다면 다음과 같이 의존성을 추가하면 된다</p>
<blockquote>
<p><strong>Gradle 의존성 추가</strong></p>
</blockquote>
<pre><code class="language-xml">implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;
implementation &#39;io.micrometer:micrometer-tracing-bridge-brave&#39;
implementation &#39;io.zipkin.reporter2:zipkin-reporter-brave&#39;</code></pre>
<p>의존성을 추가한 후에는 로깅 패턴을 적용하여 로그에 Trace ID와 Span ID가 표시되도록 해야 한다.</p>
<p>이를 위해 application.yml 파일에 다음과 같은 설정을 추가한다</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">logging:
  pattern:
    level: &#39;%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]&#39;</code></pre>
<p>이 설정은 로그 메시지의 레벨 부분에 애플리케이션 이름, Trace ID, Span ID를 함께 표시해준다.</p>
<p>이렇게 하면 여러 서비스의 로그를 모아서 볼 때도 어떤 요청에 속하는 로그인지 쉽게 파악할 수 있다!</p>
<p>여기서 중요한 것은, 위의 의존성과 로깅 패턴을 모두 적용해야 Trace ID와 Span ID가 정상적으로 생성되고, 이 정보가 Zipkin 대시보드에 올바르게 등록된다는 것이다.</p>
<p>단순히 Zipkin 서버만 실행했다고 해서 데이터가 자동으로 수집되는 것이 아니라, 각 마이크로서비스에서 이러한 설정이 제대로 이루어져야 한다.</p>
<p>또한, 이렇게 로깅 패턴을 등록한다면 Zipkin에서 확인한 ID를 가지고와서 CLI 환경에서도 로그를 직접 찾아볼 수도 있을 것이다.</p>
<hr>
<h2 id="3-zipkin으로-분산-트레이싱-확인하기">3. Zipkin으로 분산 트레이싱 확인하기</h2>
<p>모든 설정을 마친 후 서비스들을 실행하면, Zipkin UI를 통해 트레이싱 정보를 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/ad83bfa5-7f9f-43ad-8236-5ff73a2dd84f/image.png" alt=""></p>
<p>여기서 각 요청을 상세히 확인하고 싶다면, SHOW 버튼을 클릭하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/52daae3c-020a-43a7-b487-f48a00646cc4/image.png" alt=""></p>
<p>그러면 사진과 같이 Trace ID, Span ID를 모두 확인할 수 있으며, 요청이 어떤 흐름으로 진행되고 있는지도 확인할 수 있다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 포스팅에서는 마이크로서비스 환경에서 분산 트레이싱을 구현하기 위한 Zipkin 사용법에 대해 알아보았다.</p>
<p>분산 트레이싱은 시스템의 동작을 이해하고 최적화하는 데 필수적인 인사이트를 제공한다.</p>
<p>당연히, 호출 시간이 오래 걸리는 API를 발견한다면 해당 API를 리팩토링하여 성능을 개선시켜야한다는 근거로 사용할 수 있을 것이다.</p>
<p>MSA 환경에서는 서비스 간 통신이 많아질수록 이러한 추적 도구의 중요성이 더욱 커진다는 점을 기억하자 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] MSA (8) - 서킷 브레이커]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-8-%EC%84%9C%ED%82%B7-%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4-g8rj10jm</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-8-%EC%84%9C%ED%82%B7-%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4-g8rj10jm</guid>
            <pubDate>Sat, 15 Mar 2025 11:20:55 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이전 포스팅에서는 Kafka를 이용해서 마이크로서비스 간 데이터 일관성 문제를 해결하는 방법에 대해 살펴봤다.</p>
<p>이번 포스팅에서는 마이크로서비스 아키텍처에서 또 다른 중요한 패턴인 서킷 브레이커(Circuit Breaker)에 대해 알아보고, 실제로 적용해보자 👀</p>
<hr>
<h2 id="1-마이크로서비스의-통신-연쇄-오류-문제">1. 마이크로서비스의 통신 연쇄 오류 문제</h2>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/90ba05a0-7ce9-4799-aba5-ef42dee97be6/image.png" alt=""></p>
<p>마이크로서비스 아키텍처에서는 위 그림처럼 여러 서비스가 API를 통해 통신하는 구조를 가진다. </p>
<p>이런 구조에서는 한 서비스의 장애가 다른 서비스로 전파되는 문제가 발생할 수 있다.</p>
<p>예를 들어 위 사진에서 Order-Service의 getOrders()라는 부분에서만 오류가 발생했다고 해보자. </p>
<p>하지만, 이 오류는 계속 서비스를 타고 올라가서 결국 사용자에게도 에러가 발생했음을 보여줘버린다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/3ef89578-274e-485d-b1b2-5a0e663cd82b/image.png" alt=""></p>
<p>우리는 내부적으로 에러가 발생했음에도 사용자에게는 보여주지 않아야한다.</p>
<p>먼저, 실제로 정상적인 데이터를 가져오지 못하는 에러가 발생하더라도 Fallback Method를 사용해서 사용자에게는 정상적인 응답을 보여주는 방법을 알아보자 👀</p>
<h3 id="💡-오류가-발생했는데-어떻게-정상-응답을-줄-수-있을까">💡 오류가 발생했는데 어떻게 정상 응답을 줄 수 있을까?</h3>
<p>위 내용만 들었을 때는 이런 의문이 들 수 있다. </p>
<p>실제로 정상적인 데이터를 가져오지 못한다면 어떻게 사용자에게 응답을 줄 수 있을까?</p>
<blockquote>
<h3 id="전략">전략</h3>
<p><strong>대체 메시지 제공</strong></p>
</blockquote>
<ul>
<li>오더 서비스를 일시적으로 가져올 수 없다는 안내 메시지를 보여준다. (예: &quot;잠시 후에 다시 시도해주세요&quot;)</li>
</ul>
<hr>
<p><strong>캐싱된 데이터 사용</strong></p>
<ul>
<li>유저 서비스에서 이전에 Order Service에서 데이터를 한번이라도 가져왔다면 그 데이터를 캐싱해놓고 사용한다. 최신 데이터는 아니지만, 사용자는 완전히 빈 응답보다는 약간 오래된 데이터라도 볼 수 있다.</li>
</ul>
<hr>
<p><strong>기본값 반환</strong></p>
<ul>
<li>데이터를 가져오지 못할 경우 빈 리스트 같은 기본값을 반환한다.</li>
</ul>
<p>중요한 것은 <strong>실제로 정상적인 데이터가 아니더라도 사용자에게 에러 메시지를 보여주는 것보다 대체 응답을 제공하는 것이 더 나은 사용자 경험을 제공한다는 점이다.</strong></p>
<p>이러한 패턴을 MSA에서는 회복성(Resilience) 혹은 회복력(Fault Tolerance)이라고 부른다!</p>
<h3 id="💡-간단한-try-catch로-구현하기">💡 간단한 Try-Catch로 구현하기</h3>
<p>가장 기본적인 방법으로 try-catch 문을 사용해서 오류를 처리할 수 있다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Override
public UserDto getUserByUserId(String userId) {
    UserEntity userEntity = userRepository.findByUserId(userId);
&gt;
    if (userEntity == null)
        throw new UsernameNotFoundException(&quot;User not found&quot;);
&gt;
    UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);
&gt;
    log.info(&quot;Before call orders microservice&quot;);
    List&lt;ResponseOrder&gt; ordersList = new ArrayList&lt;&gt;();
&gt;
    try {
        ordersList = orderServiceClient.getOrders(userId);
    } catch (FeignException ex) {
        log.error(ex.getMessage());
    }
&gt;
    userDto.setOrders(ordersList);
&gt;
    log.info(&quot;After called orders microservice&quot;);
&gt;
    return userDto;
}</code></pre>
<p>위 코드에서는 orderServiceClient.getOrders(userId) 호출이 실패하더라도 try-catch를 통해 예외를 잡아내고, 빈 ordersList를 사용하여 응답을 생성한다.</p>
<p>이렇게 하면 사용자는 주문 내역이 비어있는 응답을 받게 되지만, 서비스 자체는 정상적으로 작동한다.</p>
<p>하지만 이 방식은 매우 기본적인 방법이고, 더 복잡한 장애 상황에서는 당연히 부족할 수 있다. </p>
<p>이럴 때 서킷 브레이커 패턴을 사용할 수 있다.</p>
<hr>
<h2 id="2-서킷-브레이커-패턴">2. 서킷 브레이커 패턴</h2>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/b9b16b1f-6b4b-429c-8732-43d8cd9b4fe4/image.png" alt=""></p>
<p>서킷 브레이커는 다음 3가지 상태를 가진다</p>
<blockquote>
<h3 id="서킷-브레이커-상태">서킷 브레이커 상태</h3>
<p><code>Closed (닫힘)</code></p>
</blockquote>
<ul>
<li>정상 상태로, 모든 요청이 대상 서비스로 전달된다.</li>
</ul>
<hr>
<p><code>Open (열림)</code></p>
<ul>
<li>장애 상태로, 요청이 대상 서비스로 전달되지 않고 즉시 폴백(fallback) 메서드가 실행된다.</li>
</ul>
<hr>
<p><code>Half-Open (반열림)</code></p>
<ul>
<li>일정 시간 후 서킷 브레이커가 오픈된 상태에서 일부 요청을 시험적으로 대상 서비스에 전달해보는 상태이다. </li>
<li>이 요청들이 성공하면 서킷 브레이커는 다시 Closed 상태로 돌아가고, 실패하면 다시 Open 상태가 된다.</li>
</ul>
<p>즉, 에러가 임계치 이상으로 자주 발생하면 중간에 서킷브레이커가 동작하여 요청을 뒷단으로 넘기지 않고, 서킷브레이커 선에서 정리하는 방식으로 동작한다.</p>
<p>서킷 브레이커는 단순히 오류가 많이 발생하는 경우 요청을 모두 차단하는 것이 아니라, 실제로는 복구를 위해서 요청을 시험적으로 전달하며 서비스의 복구 여부를 판단한다.</p>
<p>현재 스프링 클라우드 생태계에서는 <code>Resilience4j</code> 가 많이 사용되고 있다.</p>
<p>기존에 사용되던 <code>Netflix Hystrix</code> 가 유지보수 모드로 전환되면서 Resilience4j가 새로운 대안으로 제시된 추세다.</p>
<p><code>Resilience4j</code> 는 서킷 브레이커 외에도 Retry, Bulkhead, RateLimiter, TimeLimiter 등 다양한 회복성 패턴을 제공한다.</p>
<hr>
<h2 id="3-resilience4j-적용하기">3. Resilience4j 적용하기</h2>
<p>이제 실제로 <code>Resilience4j</code> 를 스프링 부트 프로젝트에 적용해보자</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/19c9e951-4e73-4461-84a8-b3739795af3d/image.png" alt=""></p>
<p>우선 위 사진과 같이 <code>Resilience4j</code> 의존성을 프로젝트에 추가하자</p>
<h3 id="💡-서킷-브레이커-설정">💡 서킷 브레이커 설정</h3>
<p>우선, <code>Resilience4j</code> 서킷 브레이커를 설정하는 코드를 살펴보자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Configuration
public class Resilience4JConfig {
    @Bean
    public Customizer&lt;Resilience4JCircuitBreakerFactory&gt; globalCustomConfiguration() {
        CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
                .failureRateThreshold(4)
                .waitDurationInOpenState(Duration.ofMillis(1000))
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(2)
                .build();
&gt;
        TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
                .timeoutDuration(Duration.ofSeconds(4))
                .build();
&gt;
        return factory -&gt; factory.configureDefault(id -&gt; new Resilience4JConfigBuilder(id)
                .timeLimiterConfig(timeLimiterConfig)
                .circuitBreakerConfig(circuitBreakerConfig)
                .build()
        );
&gt;
    }
&gt;
    @Bean
    public Customizer&lt;Resilience4JCircuitBreakerFactory&gt; specificCustomConfiguration1() {
        CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
                .failureRateThreshold(6).waitDurationInOpenState(Duration.ofMillis(1000))
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(3).build();
&gt;
        TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
                .timeoutDuration(Duration.ofSeconds(4)).build();
&gt;
        return factory -&gt; factory.configure(builder -&gt; builder.circuitBreakerConfig(circuitBreakerConfig)
                .timeLimiterConfig(timeLimiterConfig).build(), &quot;circuitBreaker1&quot;);
    }
&gt;
    @Bean
    public Customizer&lt;Resilience4JCircuitBreakerFactory&gt; specificCustomConfiguration2() {
        CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
                .failureRateThreshold(8).waitDurationInOpenState(Duration.ofMillis(1000))
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(4).build();
&gt;
        TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
                .timeoutDuration(Duration.ofSeconds(4)).build();
&gt;
        return factory -&gt; factory.configure(builder -&gt; builder.circuitBreakerConfig(circuitBreakerConfig)
                        .timeLimiterConfig(timeLimiterConfig).build(),
                &quot;circuitBreaker2&quot;);
    }
}</code></pre>
<p>뭔가 복잡해보이지만, 실제로 서킷 브레이커 패턴을 2개 등록해서 그렇게 보이는 것 뿐이다.</p>
<p>코드를 살펴보면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/c6a8da3e-babe-4bff-8136-8346c79e76be/image.png" alt=""></p>
<p>간단하게 설정 정보를 정의하고, 이를 리턴할 때 적용해주는 것이다.</p>
<p>글로벌로 설정할 때는 리턴 과정에서 <code>configureDefault</code> 를 호출하여 설정하고, 특정 조건으로 사용할 서킷 브레이커는 <code>configure</code> 메서들르 호출하여 정의하고 id값을 추가한다는 것을 기억하자</p>
<p>보면 알겠지만, Global로 정의한 것과 Specific으로 등록한 서킷 브레이커 사이에는 이 차이점 말고는 다를게 없다.</p>
<h3 id="💡-서킷-브레이커-사용하기">💡 서킷 브레이커 사용하기</h3>
<p>이제 서킷 브레이커를 서비스에 적용하는 코드는 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Service
@Slf4j
public class UserServiceImpl implements UserService {
    &gt;
    private UserRepository userRepository;
    private OrderServiceClient orderServiceClient;
    private CircuitBreakerFactory circuitBreakerFactory;
    &gt;
    @Autowired
    public UserServiceImpl(UserRepository userRepository, 
                          OrderServiceClient orderServiceClient,
                          CircuitBreakerFactory circuitBreakerFactory) {
        this.userRepository = userRepository;
        this.orderServiceClient = orderServiceClient;
        this.circuitBreakerFactory = circuitBreakerFactory;
    }
    &gt;
    @Override
    public UserDto getUserByUserId(String userId) {
        UserEntity userEntity = userRepository.findByUserId(userId);
&gt;
        if (userEntity == null)
            throw new UsernameNotFoundException(&quot;User not found&quot;);
&gt;
        UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);
&gt;
        log.info(&quot;Before call orders microservice&quot;);
        List&lt;ResponseOrder&gt; ordersList = new ArrayList&lt;&gt;();
        &gt;
        // CircuitBreaker 사용
        CircuitBreaker circuitBreaker = circuitBreakerFactory.create(&quot;circuitBreaker1&quot;);
        ordersList = circuitBreaker.run(() -&gt; orderServiceClient.getOrders(userId),
                throwable -&gt; new ArrayList&lt;&gt;());
&gt;
        userDto.setOrders(ordersList);
&gt;
        log.info(&quot;After called orders microservice&quot;);
&gt;
        return userDto;
    }
    &gt;
    // 다른 메서드들...
}</code></pre>
<p>한가지 기억할 부분은 <code>circuitBreakerFactory</code> 라는 클래스를 의존성 주입받아 사용하고 있다는 것이다.</p>
<p>여기에는 우리가 이전에 설정한 정보들이 모두 저장된 상태이므로, 코드에서 확인할 수 있다시피 <code>.create(&quot;Circuit Breaker ID&quot;);</code> 를 통해서 Circuit Breaker를 적용할 수 있다.</p>
<p>위 코드에서 circuitBreaker.run() 메서드의 첫 번째 인자는 실행할 코드, 두 번째 인자는 장애 발생 시 실행할 폴백 함수이다. </p>
<p>즉, 오더 서비스 호출이 실패하면 빈 리스트를 반환하도록 설정했다.</p>
<p>위 코드를 통해 어떻게 서킷 브레이커를 설정하고, 어떻게 호출해서 사용하는지를 알아두면 괜찮을 것 같다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 포스팅에서는 마이크로서비스 아키텍처에서 회복성을 확보하기 위한 서킷 브레이커 패턴에 대해 알아보았다.</p>
<p>try-catch를 이용한 기본적인 오류 처리부터 Resilience4j를 활용한 서킷 브레이커 구현까지 다양한 방법을 살펴봤다.</p>
<p>서킷 브레이커 패턴은 MSA 환경에서 한 서비스의 장애가 다른 서비스로 전파되는 것을 방지하고, 사용자에게 더 나은 경험을 제공하는 중요한 패턴이다.</p>
<p>마이크로서비스 아키텍처를 설계할 때는 기능 구현뿐만 아니라 이런 회복성 패턴도 함께 고려하는 것이 중요하다는 것을 기억하자 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] MSA (7) - Kafka를 이용하여 MSA 데이터 일관성 문제 해결하기 (3)]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-6-Kafka%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-MSA-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-3</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-6-Kafka%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-MSA-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-3</guid>
            <pubDate>Sat, 15 Mar 2025 10:51:46 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이전 포스팅에서는 Sink Connector만 사용해서 Kafka 토픽에 데이터를 저장하면 DB에 자동 저장되도록 구현을 해봤다.</p>
<p>이번 포스팅에서는 간단하게 Source Connector를 사용해보는 포스팅을 작성해보려고 한다 👀</p>
<hr>
<h2 id="1-source-connecotor-사용-이유">1. Source Connecotor 사용 이유?</h2>
<p>이전 포스팅에서 작성한 내용과 같이 Sink Connector만 사용해도 우리가 원하는 작업을 충분히 수행할 수 있다.</p>
<p>사용자가 주문을 요청하면 주문 정보를 Kafka Topic에 저장하도록 Producer 코드를 작성하고, Sink Connector가 Topic에서 변경 사항이 발생하면 DB에 데이터를 저장하는 식이다.</p>
<p>당연히 각 요청은 Kafka Topic에 저장되기 때문에 비동기 방식으로 데이터를 DB에 저장할 수 있다.</p>
<p>그렇다면 이와 같은 구조에서 Source Connector를 어떻게 사용할 수 있고, 만약 사용한다면 왜 Source Connector를 사용하는걸까?</p>
<h3 id="💡-구조도">💡 구조도</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/dd9e52e1-bcd0-448b-be1e-8e377bc4dc49/image.png" alt=""></p>
<p>필자가 생각하는 구조는 다음과 같다.</p>
<p>만약, Catalog-Service가 SpringBoot 프레임워크 기반의 코드가 아닌 다른 언어로 작성된 API 서비스라고 생각해보자</p>
<p>여기서 개발자는 Catalog 정보는 중요한 정보라고 판단하기 때문에, 동기방식으로 JPA를 사용하여 DB에 데이터를 저장한다고 가정해보자</p>
<p>이때, 위 구조도에서 보다시피 Source Connector는 DB에서 변경된 내용을 감지하여 Kafka의 토픽으로 메세지를 가져온다. </p>
<p>여기서 만약, 개발자가 원한다면 토픽에 가져온 메세지를 이용하여 Order-Service와 관련된 DB에 무언가 작업을 진행할수도 있을 것이다.</p>
<p>이처럼, Source Connector는 특정 대상(ex. DB)에서 변화가 발생하면 Kafka Topic으로 메세지를 전송해주는 역할을 수행할 수 있으며, Sink Connector와 조합하여 다른 서비스와 연관된 여러가지 작업을 수행할 수 있을 것이다.</p>
<h3 id="💡-양방향-구조">💡 양방향 구조</h3>
<p>방금 가정한 내용처럼, Source Connector와 Sink Connector를 같이 사용하여 변화를 계속 감지하고 이를 기반으로 업데이트를 진행하는 구조를 <code>양방향 구조</code> 라고 한다.</p>
<p>이러한 구조를 채택한다면, <strong>데이터 동기화를 자동화시킬 수 있다.</strong></p>
<p>즉, 한쪽 DB의 변경이 다른 DB에도 자동으로 반영되는 방식으로 자동화를 구현할 수 있다는 장점이 있다.</p>
<p>또한, DB를 직접 수정하는 관리 도구나 백오피스 시스템이 있을 때, 그 변경사항을 마이크로서비스에 전파할 수 있을 것이다.</p>
<p>당연히 DB 동기화를 진행한다면, 서비스가 일시적으로 다운되어도 이전 작업 내용을 빠르게 복구할 수 있다는 장점이 있을 것이다.</p>
<hr>
<h2 id="2-source-connector-사용해보기">2. Source Connector 사용해보기</h2>
<p>필자는 이번 포스팅을 통해 간단하게 Source Connector를 등록해보고 동작하는 과정을 살펴보려고 한다.</p>
<p>우선, <a href="https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-5-Kafka%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-MSA-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-2">[LG CNS AM Inspire Camp 1기] MSA (6) - Kafka를 이용하여 MSA 데이터 일관성 문제 해결하기 (2)</a>
에서 포스팅한 내용처럼 Kafka Connector 설치 및 JDBC 커넥터까지 다운로드 된 상태라고 판단하고 진행해보자</p>
<h3 id="💡-kafka-source-connector-등록">💡 Kafka Source Connector 등록</h3>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-json">// [POST] localhost:8083/connectors 
{
 &quot;name&quot;: &quot;my-source-connect&quot;,
 &quot;config&quot;: {
   &quot;connector.class&quot;: &quot;io.confluent.connect.jdbc.JdbcSourceConnector&quot;,
   &quot;connection.url&quot;: &quot;jdbc:mariadb://localhost:3307/mydb&quot;,
   &quot;connection.user&quot;: &quot;root&quot;,
   &quot;connection.password&quot;: &quot;개인 DB 비밀번호&quot;,
   &quot;mode&quot;: &quot;incrementing&quot;,
   &quot;incrementing.column.name&quot;: &quot;id&quot;,
   &quot;table.whitelist&quot;: &quot;users&quot;,
   &quot;topic.prefix&quot;: &quot;my_topic_&quot;,
   &quot;tasks.max&quot;: &quot;1&quot;
 }
}</code></pre>
<hr>
<p><strong>Result View</strong>
<img src="https://velog.velcdn.com/images/sung-yeop/post/7f12fafd-006b-415f-a5f2-86aff8b2e53d/image.png" alt=""></p>
<p>Sample Code로 제공된 JSON 데이터를 <code>localhost:8083/connectors</code> 로 POST 요청을 보내면 내가 지정한 Source Connector가 등록된다.</p>
<p>당연히 Kafka Connector가 켜져있는 상태여야 한다.</p>
<p>이후, 사진 처럼 등록한 커넥터의 상태를 확인했을 때, <code>connector</code> 와 <code>tasks</code> 모두 <code>RUNNING</code> 이 떠야 한다.</p>
<p>만약, Running이 뜨지 않았다면 DB가 제대로 동작하고 있는지, 아이디 비밀번호는 맞는지, 오타는 없었는지 등등.. 로그를 직접 확인하면서 에러를 잡아야한다.</p>
<p>이제 JSON으로 보낸 데이터를 한번 살펴보자</p>
<blockquote>
<p>mode: &quot;incrementing&quot;</p>
</blockquote>
<ul>
<li>증가 방식으로 데이터 변경 감지</li>
<li>즉, ID 값이 증가하는 새 레코드를 감지</li>
</ul>
<hr>
<p>incrementing.column.name: &quot;id&quot;</p>
<ul>
<li>증분 변경을 감지할 컬럼 이름</li>
</ul>
<hr>
<p>table.whitelist: &quot;users&quot;</p>
<ul>
<li>모니터링할 테이블 이름</li>
</ul>
<hr>
<p>topic.prefix: &quot;my_topic_&quot;</p>
<ul>
<li>생성될 Kafka 토픽의 접두사 </li>
<li>따라서, 실제 토픽은 &quot;my_topic_users&quot;가 된다.</li>
</ul>
<hr>
<p>tasks.max: &quot;1&quot;</p>
<ul>
<li>최대 태스크 수 (병렬 처리 정도)</li>
</ul>
<p>만약, 여기서 감지해야할 대상 토픽인 <code>my_topic_users</code> 가 없다면 자동으로 생성된다.</p>
<h3 id="💡-users-테이블에서-쿼리-날려보기">💡 Users 테이블에서 쿼리 날려보기</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/aa89283d-2510-416c-9b19-f196e4c2a474/image.png" alt=""></p>
<p>사진과 같이 user 테이블에서 쿼리를 날려보자</p>
<blockquote>
<p><strong>Result View</strong>
<img src="https://velog.velcdn.com/images/sung-yeop/post/81a73208-168a-49d5-b2ab-fcb93c094da8/image.png" alt=""></p>
</blockquote>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/b6a09401-238d-4817-a8b4-ee49b3dfbb8e/image.png" alt=""></p>
<hr>
<pre><code class="language-json">{
 &quot;schema&quot;: {
   &quot;type&quot;: &quot;struct&quot;,
   &quot;fields&quot;: [
     {
       &quot;type&quot;: &quot;int32&quot;,
       &quot;optional&quot;: false,
       &quot;field&quot;: &quot;id&quot;
     },
     {
       &quot;type&quot;: &quot;string&quot;,
       &quot;optional&quot;: true,
       &quot;field&quot;: &quot;user_id&quot;
     },
     {
       &quot;type&quot;: &quot;string&quot;,
       &quot;optional&quot;: true,
       &quot;field&quot;: &quot;name&quot;
     }
   ],
   &quot;optional&quot;: false,
   &quot;name&quot;: &quot;users&quot;
 },
 &quot;payload&quot;: {
   &quot;id&quot;: 1,
   &quot;user_id&quot;: &quot;test_id1&quot;,
   &quot;name&quot;: &quot;TEST_USER_01&quot;
 }
}</code></pre>
<p>쿼리를 요청하면 my_topic_users 토픽이 생성되고, 해당 토픽에는 위 사진과 같이 메세지가 들어온다.</p>
<p>당연히 이전에 Producer 코드를 작성하면서 살펴봤던 포맷과 동일하게 schema와 payload 부분으로 나뉘어서 메세지가 등록되는 모습을 볼 수 있다!</p>
<p>이제 개발자가 원한다면 이 메세지를 이용해서 다른 DB에 데이터를 동기화시키는 등의 작업을 추가할 수 있을 것이다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 포스팅에서는 간단하게 Source Connector를 등록하고 사용하는 과정을 정리해봤다.</p>
<p>필자가 진행하는 실습 코드에서는 Source Connector를 사용하지 않았기 때문에 직접 DB에 쿼리를 날리고, 토픽에 메세지가 등록되는 과정을 살펴봤다.</p>
<p>이렇게 Source Connector와 Sink Connector를 함께 활용하면 다양한 시스템 간의 데이터 동기화를 효과적으로 구현할 수 있다. </p>
<p>특히 마이크로서비스 아키텍처에서 데이터 일관성 문제를 해결하는 강력한 방법이 될 수 있으니 알아두자 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] MSA (6) - Kafka를 이용하여 MSA 데이터 일관성 문제 해결하기 (2)]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-5-Kafka%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-MSA-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-2</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-5-Kafka%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-MSA-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-2</guid>
            <pubDate>Thu, 13 Mar 2025 01:03:36 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이전 포스팅에서는 Catalog Service에 Kafka를 적용하는 과정을 살펴봤다.</p>
<p>현재 진행과정을 간단하게 리마인드해보면 Kafka 도커 컨테이너를 9092 포트에 매핑해서 띄워둔 상태이고, Kafka를 사용하기 위한 명령어들을 다운로드 받은 상태이다.</p>
<p>이번 포스팅에서는 Order Service에 Kafka가 어떻게 적용되고 있는지 코드를 살펴보고 Kafka Connect를 이용해서 DB에 데이터가 자동으로 저장되도록 해보자 👀</p>
<hr>
<h2 id="1-kafka-사용-이유">1. Kafka 사용 이유</h2>
<p>우선 목표부터 설정하면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/5ff6d71d-886e-41c9-b06d-b149becf82d2/image.png" alt=""></p>
<p>위 구조는 API 통신 포스트에서 사용했던 구조인데, 현재 구조는 각 서비스마다 각기 다른 DB를 갖고 있다.</p>
<p>따라서, 주문 내역을 조회할 때마다 각 DB에 저장된 데이터를 보여주기 때문에 조회할 때마다 결과가 달라지는 문제가 있었다.</p>
<p>이를 해결하기 위해 하나의 DB를 사용할 수 있지만, 단순히 하나의 DB를 사용한다면 병목현상이 생겨 성능 저하가 발생하기 때문에 중간에 메세지 브로커인 Kafka를 사용하기로 결정했다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/260fc877-af57-4dbf-ac55-ead87501e7b2/image.png" alt=""></p>
<p>따라서, 현재 필자가 만들려는 구조는 위 사진과 같다.</p>
<p>중간에 메세지 브로커인 Kafka를 이용해서 각 서비스에서 들어오는 메세지들을 토픽 단위로 적절하게 묶어서 관리하고자 한다.</p>
<p>이후, orders 토픽에 들어있는 메세지들을 DB에 넣고 메세지가 들어올 때마다 중간에 위치한 Sink Connector가 하나의 DB와 동기화 작업을 수행하도록 할 예정이다.</p>
<h2 id="2-kafka-설정하기---order-service">2. Kafka 설정하기 - Order Service</h2>
<p>이전 포스팅에서 Order Service는 사용자가 주문한 내역(메세지)를 토픽에 저장하는 Producer의 역할을 수행한다고 정리했다.</p>
<p>따라서, Order Service에서는 Catalog Service에서 적용한 Consumer 설정이 아닌 Producer 설정을 진행해보자</p>
<h3 id="💡-kafka-설정-추가">💡 Kafka 설정 추가</h3>
<p>우선 코드를 살펴보면 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@EnableKafka
@Configuration
public class KafkaProducerConfig {
    @Bean
    public ProducerFactory&lt;String, String&gt; producerFactory() {
        Map&lt;String, Object&gt; properties = new HashMap&lt;&gt;();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
&gt;
        return new DefaultKafkaProducerFactory&lt;&gt;(properties);
    }
&gt;
    @Bean
    public KafkaTemplate&lt;String, String&gt; kafkaTemplate() {
        return new KafkaTemplate&lt;&gt;(producerFactory());
    }
}</code></pre>
<p>Catalog-Service의 ConsumerConfig와 거의 유사한 모습을 보여준다.</p>
<p>다만, 설정 내용중에서 <code>KEY_SERIALIZER_CLASS_CONFIG</code>, <code>VALUE_SERIALIZER_CLASS_CONFIG</code> 는 <code>StringDeserializer.class</code> 가 아니라 <code>StringSerializer.class</code> 를 사용하고 있다.</p>
<p>즉, 전송가능한 바이트 배열로 바꾸는 데이터가 String임을 의미한다.</p>
<p>간단히 정리하면 다음과 같다.</p>
<blockquote>
<p><strong>직렬화 / 역직렬화</strong>
<code>Producer</code> : String → Byte 배열 (직렬화)
<code>카프카 토픽에 Byte 배열 저장</code>
<code>Consumer</code> : Byte 배열 → String (역직렬화)</p>
</blockquote>
<p>또한, 빈으로 등록된 객체가 <code>ConsumerFactory</code> 가 아니라 <code>ProducerFactory</code> 임을 기억하자</p>
<h3 id="💡-kafka-producer---catalog-토픽">💡 Kafka Producer - catalog 토픽</h3>
<p>Order Service는 Producer 역할을 수행한다고 언급한 바 있다.</p>
<p>그렇다면 주문 내역 메세지를 단순히 하나의 토픽에만 발행하면 될까?</p>
<p>Kafka의 구조상 파티션 하위에 여러개의 토픽을 가질 수 있고, 여러개의 토픽에 메세지를 넣을 수 있다.</p>
<p>즉, 용도가 다르다면 다른 토픽을 사용하는 것이 유지보수에 더 좋을 것이다.</p>
<p>따라서, 주문내역을 DB에 저장할 <code>orders</code> 토픽과 이전 포스팅에서 살펴봤던 <code>example-catalog-topic</code> 2개에 주문 내역 메세지를 넣어주려고 한다.</p>
<p>여기서는 <code>example-catalog-topic</code> 에 메세지를 넣어주는 Producer 코드를 다뤄보려고 한다.</p>
<p>우선 코드를 살펴보자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Service
@Slf4j
public class KafkaProducer {
    private KafkaTemplate&lt;String, String&gt; kafkaTemplate;
&gt;
    @Autowired
    public KafkaProducer(KafkaTemplate&lt;String, String&gt; kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }
&gt;
    public OrderDto send(String topic, OrderDto orderDto) {
        ObjectMapper mapper = new ObjectMapper();
        String jsonInString = &quot;&quot;;
        try {
            jsonInString = mapper.writeValueAsString(orderDto);
        } catch(JsonProcessingException ex) {
            ex.printStackTrace();
        }
&gt;
        kafkaTemplate.send(topic, jsonInString);
        log.info(&quot;Kafka Producer sent data from the Order microservice: &quot; + orderDto);
&gt;
        return orderDto;
    }
}</code></pre>
<p>KafkaProducer는 단순하게 토픽에 데이터를 넣는 역할을 수행한다.</p>
<p>보다시피 Producer는 <code>KafkaTemplate</code> 를 의존성 주입받아 구현 한다.</p>
<p>간단하게 send 메서드를 살펴보면, topic 이름과 topic에 저장할 데이터인 OrderDto를 전달받는다.</p>
<p>과정에서 보다시피 직렬화를 String으로 진행하기 때문에 OrderDto를 String으로 변환한 이후, 이전에 주입받은 <code>KafkaTemplate</code> 을 사용하여 메세지를 발행한다.</p>
<p>send 메서드에서 OrderDto를 반환하고 있는데, void를 사용해도 로직 자체는 무관하다!</p>
<h3 id="💡-kafka-producer---orders-토픽">💡 Kafka Producer - orders 토픽</h3>
<p>방금 설명한 것과 같이 orders 토픽에도 orderDto 메세지를 넣어줘야 한다.</p>
<p>다시말하지만 orders 토픽에 저장된 메세지는 DB에 저장하기 위해 사용되는 데이터이다.</p>
<p>우선 Producer 코드를 살펴보자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Service
@Slf4j
public class OrderProducer {
    private KafkaTemplate&lt;String, String&gt; kafkaTemplate;
&gt;
    List&lt;Field&gt; fields = Arrays.asList(
    new Field(&quot;string&quot;, true, &quot;order_id&quot;),
            new Field(&quot;string&quot;, true, &quot;user_id&quot;),
            new Field(&quot;string&quot;, true, &quot;product_id&quot;),
            new Field(&quot;int32&quot;, true, &quot;qty&quot;),
            new Field(&quot;int32&quot;, true, &quot;unit_price&quot;),
            new Field(&quot;int32&quot;, true, &quot;total_price&quot;));
    Schema schema = Schema.builder()
            .type(&quot;struct&quot;)
            .fields(fields)
            .optional(false)
            .name(&quot;orders&quot;)
            .build();
&gt;
    @Autowired
    public OrderProducer(KafkaTemplate&lt;String, String&gt; kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }
&gt;
    public OrderDto send(String topic, OrderDto orderDto) {
        Payload payload = Payload.builder()
                .order_id(orderDto.getOrderId())
                .user_id(orderDto.getUserId())
                .product_id(orderDto.getProductId())
                .qty(orderDto.getQty())
                .unit_price(orderDto.getUnitPrice())
                .total_price(orderDto.getTotalPrice())
                .build();
&gt;
        KafkaOrderDto kafkaOrderDto = new KafkaOrderDto(schema, payload);
&gt;
        ObjectMapper mapper = new ObjectMapper();
        String jsonInString = &quot;&quot;;
        try {
            jsonInString = mapper.writeValueAsString(kafkaOrderDto);
        } catch(JsonProcessingException ex) {
            ex.printStackTrace();
        }
&gt;
        kafkaTemplate.send(topic, jsonInString);
        log.info(&quot;Order Producer sent data from the Order microservice: &quot; + kafkaOrderDto);
&gt;
        return orderDto;
    }
}</code></pre>
<hr>
<pre><code class="language-java">@Data
@AllArgsConstructor
public class KafkaOrderDto implements Serializable {
    private Schema schema;
    private Payload payload;
}</code></pre>
<hr>
<pre><code class="language-java">@Data
@Builder
public class Schema {
    private String type;
    private List&lt;Field&gt; fields;
    private boolean optional;
    private String name;
}</code></pre>
<hr>
<pre><code class="language-java">@Data
@Builder
public class Payload {
    private String order_id;
    private String user_id;
    private String product_id;
    private int qty;
    private int unit_price;
    private int total_price;
}</code></pre>
<p>마찬가지로 <code>KafkaTemplate</code> 을 의존성 주입받아 사용하고 있다.</p>
<p>여기서 한가지 차이점은 별도의 send 메서드에서 <code>kafkaOrderDto</code> 를 별도로 생성한 이후, 이를 String으로 만들고 있다.</p>
<p>그렇다면 왜 <code>KafkaOrderDto</code> 를 중간에 만들어주는걸까?</p>
<h3 id="💡-kafka-schema와-payload-구조">💡 Kafka Schema와 Payload 구조</h3>
<p>예시 코드에서 볼 수 있듯이, orders 토픽에 메시지를 보낼 때 schema와 payload를 포함한 구조를 사용하고 있다.</p>
<p>이는 Kafka Connect와 Kafka Connect Sink Connector가 데이터를 처리할 때 주로 사용하는 정해진 형식이다.</p>
<p>이렇게 Kafka Connect가 정해진 형식을 사용하는 이유는 스키마를 통해 데이터 형식의 유효성을 검증할 수 있기 때문이다.</p>
<p>또한, Kafka Connect가 자동으로 데이터베이스 테이블 구조에 매핑할 수 있도록 하기 위해서 schema와 payload를 사용한다.</p>
<p>여기서 PayLoad와 Schema를 살펴보면 다음과 같다.</p>
<blockquote>
<p><code>Payload</code></p>
</blockquote>
<ul>
<li>실제 데이터 값을 포함하며, 데이터베이스 테이블의 컬럼명과 정확히 일치하는 필드명을 사용해야 한다.</li>
<li>예시 코드에서 Payload 클래스의 필드들(order_id, user_id, product_id 등)이 실제 DB 테이블의 컬럼명과 일치한다.</li>
</ul>
<hr>
<p><code>Schema</code></p>
<ul>
<li>Kafka Connect가 데이터 타입을 이해하고 데이터베이스 스키마와 매핑하는 데 사용된다.</li>
<li>type: 주로 &quot;struct&quot;를 사용하여 구조화된 데이터임을 나타낸다.</li>
<li>fields: 각 필드의 데이터 타입과의 관계를 정의한다. 필드를 구성하는 리스트는 DB 테이블의 컬럼과 일치시킨다.</li>
<li>name: 주로 대상 테이블명과 일치시킨다.</li>
</ul>
<p>조금 더 나중에 살펴보겠지만, Kafka Connector에서 Sink를 추가할 때 JSON 형태로 POST 요청을 보낸다.</p>
<p>그때 사용하는 JSON의 프로퍼티 중에서 <code>auto.create</code> 와 <code>auto.evolve</code> 에서 정의된 스키마를 활용할 수 있다.</p>
<h3 id="💡-kafka-producer-사용">💡 Kafka Producer 사용</h3>
<p>Kafka Producer는 로직 처리 과정에서 호출하여 사용하면 된다.</p>
<p>우리는 사용자가 주문을 했을 때 Kafka 토픽에 메세지를 저장하면 되기 때문에 createOrder 로직 내부에서 호출하면 된다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@PostMapping(&quot;/{userId}/orders&quot;)
public ResponseEntity&lt;ResponseOrder&gt; createOrder(@PathVariable(&quot;userId&quot;) String userId,
                                                 @RequestBody RequestOrder orderDetails) {
    log.info(&quot;Before add orders data&quot;);
    ModelMapper mapper = new ModelMapper();
    mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
&gt;
    OrderDto orderDto = mapper.map(orderDetails, OrderDto.class);
    orderDto.setUserId(userId);
    orderDto.setOrderId(UUID.randomUUID().toString());
    orderDto.setTotalPrice(orderDetails.getQty() * orderDetails.getUnitPrice());
    ResponseOrder responseOrder = mapper.map(orderDto, ResponseOrder.class);
&gt;
    /* send this order to the kafka */
    kafkaProducer.send(&quot;example-catalog-topic&quot;, orderDto);
    orderProducer.send(&quot;orders&quot;, orderDto);
&gt;
    log.info(&quot;After added orders data&quot;);
    return ResponseEntity.status(HttpStatus.CREATED).body(responseOrder);
}</code></pre>
<p>이제 <code>example-catalog-topic</code> 에 저장된 메세지는 catalog-service에서 리스닝하고 있는 메서드가 동작하여 JPA기반으로 DB를 업데이트할 것이다.</p>
<p>반면, orders 토픽에 들어있는 메세지는 아직 활용되지 않고 있다.</p>
<p>이제부터 orders 토픽에 있는 메세지를 기반으로 DB를 자동으로 업데이트하도록 Kafka Connector를 사용해보자</p>
<hr>
<h2 id="3-kafka-connector-사용하기">3. Kafka Connector 사용하기</h2>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/73e70edc-52da-42fd-bdfe-118113652cf7/image.png" alt=""></p>
<p>이전 포스팅에서 살펴봤던 이미지를 다시한번 확인해보자</p>
<p>여기서 우리는 Kafka Cluster를 기준으로 데이터를 이동시키기 위해서는 Kafka Connect를 사용한다고 했다.</p>
<p>Kafka Connect를 사용하기 위해 우리는 <a href="https://packages.confluent.io/archive/">confluent.io</a>에서 현재 Kafka 버전에 맞는 Connector를 설치해야 한다.</p>
<p><a href="https://docs.confluent.io/platform/current/installation/versions-interoperability.html">호환 버전</a> 사이트에 접근해서 자신이 사용하는 Kafka 버전과 호환되는 Connnector를 설치해야 한다.</p>
<p>필자는 <code>kafka:latest</code> 를 사용하기 때문에 7.9 버전을 다운로드해서 사용했다</p>
<h3 id="💡-kafka-connector-설치-1">💡 Kafka Connector 설치 (1)</h3>
<p>사이트에 접근해서 다운로드해도 상관없지만, curl 명령어를 사용해서 간단하게 다운로드 해보자</p>
<blockquote>
<p><strong>◉ 명령어 - Kafka Connector 명령어 다운</strong>
<code>curl -O http://packages.confluent.io/archive/7.9/confluent-community-7.9.0.tar.gz</code></p>
</blockquote>
<hr>
<p><strong>◉ 명령어 - 압축 해제</strong>
<code>tar xvf confluent-community-7.9.0.tar.gz</code></p>
<p>압축해제를 진행한 이후, 디렉토리를 확인해보면 이전에 Kafka에서 다운로드 받은 것처럼 쉘 스크립트가 저장된 모습을 확인할 수 있다.</p>
<h3 id="💡-kafka-connector-설치-2">💡 Kafka Connector 설치 (2)</h3>
<p>이후, Kafka Connector Sink에서 사용할 JDBC Connector를 다운로드 받아야 한다.</p>
<p>여러가지 커넥터가 있겠지만, 필자는 DB에 데이터를 저장하기 위한 목적으로 사용하기 때문에 JDBC Connector를 설치한다.</p>
<p><a href="https://www.confluent.io/hub/confluentinc/kafka-connect-jdbc">JDBC Connector 설치</a> 사이트에서 다운로드 받으면 된다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/3f2b6bde-016a-4f05-b478-5ee11fba0a67/image.png" alt=""></p>
<p>필자는 Self-Hosted로 다운로드 받았다.</p>
<h3 id="💡-kafka-connector-연결-1">💡 Kafka Connector 연결 (1)</h3>
<p>이전에 압축해제하면서 설치했던 <code>confluent-7.9.0</code> 디렉토리에서 다음 파일을 수정하자</p>
<blockquote>
<p><strong>수정 해야하는 파일</strong>
confluent-7.9.0/etc/kafka/connect-distributed.properties</p>
</blockquote>
<p>해당 파일을 열고 맨 마지막으로 스크롤을 내리면 path를 설정하는 부분이 있다.</p>
<p>여기서 path를 우리가 방금 설치한 JDBC Connector/lib 경로로 수정하자</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/3bf7c1b0-8723-489b-9f67-1086f660ca7d/image.png" alt=""></p>
<h3 id="💡-kafka-connector-연결-2">💡 Kafka Connector 연결 (2)</h3>
<p>다음으로 필자는 orders 토픽에 저장된 데이터를 MariaDB에 저장할 예정이기 때문에 MariaDB 드라이버를 <code>/share/java/kafka/</code>경로에 저장해야 한다.</p>
<p>maven으로 프로젝트를 한번이라도 빌드했다면, <code>m2</code> 라는 디렉토리 하위에 사용한 DB 드라이버가 존재한다.</p>
<p>맥 OS를 사용한다면 <code>open ~/m2</code> 명령어를 입력해서 해당 디렉토리를 열고, <code>~/.m2/repository/org/mariadb/jdbc/mariadb-java-client/2.7.2/mariadb-java-client-2.7.2.jar</code> 경로에 해당하는 드라이버를 복사하자</p>
<p>다음으로 커넥터 명령어가 있는 디렉토리에서 <code>confluent-7.9.0/share/java/kafka</code> 경로에 이전에 복사한 드라이버를 붙여넣기 해주면 된다.</p>
<h3 id="💡-kafka-connector-실행">💡 Kafka Connector 실행</h3>
<p>이제 <code>confluent-7.9.0/</code> 으로 이동하고 다음 명령어를 입력하자</p>
<blockquote>
<p><strong>◉ 커맨드 - Kafka Connector 실행</strong>
<code>./bin/connect-distributed ./etc/kafka/connect-distributed.properties</code></p>
</blockquote>
<p>두 명령어를 동시에 실행시키기 때문에 <code>/confluent-7.9.0</code> 에서 명령어를 입력해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/7a3422a1-2a26-4241-a1e2-c60117314589/image.png" alt=""></p>
<p>이후, 토픽 리스트를 확인해보면 connect 관련 토픽이 3개 생긴 모습을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/a8c39e23-8750-4cc0-bbea-8d9eaa296982/image.png" alt=""></p>
<p>위 사진과 같이 8083포트의 <code>/connector-plugins</code> 엔드포인트로 get 요청을 날리면 현재 등록된 커넥터들을 확인할 수 있다.</p>
<p>여기서 8083은 Kafka Connector가 자체적으로 제공해주는 포트이다.</p>
<p>화면에서 확인할 수 있다시피, 방금 등록한 JdbcSinkConnector와 JdbcSourceConnector가 제대로 등록된 모습을 볼 수 있다.</p>
<h3 id="💡-kafka-connector-등록">💡 Kafka Connector 등록</h3>
<p>이제 위 커넥터를 이용해서 우리가 사용할 DB 정보를 세팅하여 커넥터를 등록해보자</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/f88e77ab-dc83-41a1-86c1-5b10d4aa05d7/image.png" alt=""></p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-json">{
    &quot;name&quot; : &quot;my-order-sink-connect&quot;,
    &quot;config&quot; : {
        &quot;connector.class&quot; : &quot;io.confluent.connect.jdbc.JdbcSinkConnector&quot;,
        &quot;connection.url&quot; : &quot;jdbc:mysql://127.0.0.1:3307:mydb&quot;,
        &quot;connection.user&quot; : &quot;root&quot;,
        &quot;connection.password&quot; :&quot;개인 DB 비밀번호&quot;,
        &quot;auto.create&quot;: &quot;true&quot;,
        &quot;auto.evolve&quot; : &quot;true&quot;,
        &quot;delete.enabled&quot; : &quot;false&quot;,
        &quot;tasks.max&quot; : &quot;1&quot;,
        &quot;topics&quot; : &quot;orders&quot;
    }
}</code></pre>
<p>사진과 같이 8083 포트의 <code>/connectors</code> 엔드포인트로 위 JSON 데이터를 POST 날려주면 우리가 사용할 Sink Connector가 등록된다.</p>
<p>JSON 포맷에서 config쪽을 살펴보면 connector 관련 설정을 추가해주고 있다.</p>
<p>여기서 눈여겨봐야할 것은 <code>topics</code> 부분인데, 우리가 사용할 orders 토픽에서 메세지를 Sink 하겠다는 의미이다.</p>
<p>참고로 생성한 Connector를 삭제하길 원한다면 <code>localhost:8083/connectors/my-order-sink-connect</code> 엔드포인트에 DELETE 메서드로 요청을 보내면 된다.</p>
<hr>
<h2 id="4-kafka-실습">4. Kafka 실습</h2>
<p>이제 Connector 연결까지 완료했으니 실제로 서비스 인스턴스를 여러개 띄운 상태에서 실습을 진행해보자</p>
<h3 id="💡-order-service-실행">💡 Order Service 실행</h3>
<p>우선 실습을 위해 Order Service 인스턴스를 2개 띄우자</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/67e8d116-c2ce-492f-9a4f-9826e7a31ce3/image.png" alt=""></p>
<p>Gateway 설정에서 라운드 로빈 방식으로 라우팅이 진행되도록 설정했기 때문에, 각 요청은 서비스 인스턴스를 번갈아가면서 사용할 것이다.</p>
<h3 id="💡-주문-요청">💡 주문 요청</h3>
<blockquote>
<p><strong>주문 요청</strong>
<img src="https://velog.velcdn.com/images/sung-yeop/post/9518be37-4adf-456c-9a74-0b39b163a1f9/image.png" alt=""></p>
</blockquote>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/4b5fb340-8c3e-4e9b-9b10-aebf4687c82e/image.png" alt=""></p>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/b7747698-0b2e-4534-9ff1-21bc88030204/image.png" alt=""></p>
<p>위 사진은 order-service에 catalog-001, 002, 003 주문을 3번 넣는 과정이다.</p>
<h3 id="💡-orders-토픽-확인">💡 orders 토픽 확인</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/4c7deb73-5d45-4375-8f30-fbc9bc20633b/image.png" alt=""></p>
<p>사진이 잘 보일지 모르겠지만, 각각 요청한 catalog-001, 002, 003이 orders 토픽에 제대로 저장된 모습을 볼 수 있다.</p>
<p>(위에있는 메세지는 테스트하면서 생긴 더미 데이터다 😅)</p>
<h3 id="💡-db-확인">💡 DB 확인</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/3f37c2c8-647b-4546-a4a8-ac01de7ac70f/image.png" alt=""></p>
<p>사진과 같이 주문이 자동으로 들어온 모습을 볼 수 있다!</p>
<h3 id="💡-주의사항">💡 주의사항</h3>
<p>필자는 해당 실습에서 Sink Connector만 등록해서 단방향 처리 (Kafka Topic -&gt; DB)만 구현했다.</p>
<p>만약, JDBC Source Connector를 사용한다면 DB에서 변경사항이 감지되면 Kafka Topic으로 가져오는 기능도 구현할 수 있을 것이다.</p>
<p>하지만, 양방향을 구현하게 될 경우에는 무한 루프를 조심해야한다는 것을 기억하자</p>
<p>이 부분은 다음에 시간되면 별도의 포스팅으로 작성하도록 하겠다!</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 포스팅에서는 Kafka Connector를 세팅하는 방법과 Producer 코드를 어떻게 작성해야하는지까지 살펴봤다.</p>
<p>실제로 필자가 테스트하는 과정에서는 Connector 버전이 맞지 않아서 마지막 DB에 데이터가 저장되지 않는 문제가 있었다.</p>
<p>이 부분에서 Kafka와 Connector 버전의 호환성이 중요하다는 것을 절감했다..😅</p>
<p>그래서 다시 처음부터 Connector를 설정했는데 여러번 하다보니까 막상 어려운 부분은 하나도 없었다.</p>
<p>한 번 익숙해지면 설정 과정이 꽤 직관적이라는 걸 알 수 있었다.</p>
<p>2편의 포스팅을 통해서 Kafka Consumer, Producer를 구성하는 방법과 이를 통해 데이터 일관성을 유지하는 방법에 대해서 기억하면 좋겠다!</p>
<p>특히 마이크로서비스 환경에서 각 서비스의 독립성을 유지하면서도 데이터 정합성을 확보하는 패턴으로 큰 도움이 될 것이다 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] MSA (5) - Kafka를 이용하여 MSA 데이터 일관성 문제 해결하기 (1)]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-4-Kafka%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-MSA-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-4-Kafka%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-MSA-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9D%BC%EA%B4%80%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 12 Mar 2025 09:38:55 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이전 포스팅에서 마이크로서비스 간 통신 방법에 대해 알아봤다. </p>
<p>특히 여러 서비스 인스턴스를 운영할 때 발생하는 데이터 일관성 문제를 다뤘는데, 이번에는 Kafka를 활용해 이 문제를 어떻게 해결할 수 있는지 알아보려고 한다 👀 </p>
<hr>
<h2 id="1-데이터-일관성-문제-리마인드">1. 데이터 일관성 문제 리마인드</h2>
<p>먼저 이전 포스팅에서 살펴본 문제를 다시 생각해보자</p>
<p>여러 인스턴스로 주문 서비스를 운영할 때, 각 인스턴스는 자체 DB를 가지게 된다.</p>
<p>이로 인해 사용자의 주문이 여러 DB에 분산되어 저장되면서 데이터 일관성 문제가 발생한다.</p>
<p>사용자가 총 3개의 주문을 했지만, 서비스를 호출할 때마다 로드 밸런싱에 의해 다른 인스턴스로 요청이 가기 때문에 모든 주문 정보를 한 번에 조회하기 어렵다.</p>
<p><strong>이 문제를 해결하기 위해 메시지 큐를 활용한 이벤트 기반 아키텍처를 도입할 수 있다.</strong></p>
<p>오늘은 그 중에서도 Apache Kafka를 활용한 방법을 알아보자.</p>
<hr>
<h2 id="2-apache-kafka란">2. Apache Kafka란?</h2>
<p>Apache Kafka는 LinkedIn에서 개발한 분산 스트리밍 플랫폼이다.</p>
<p>여러 서비스 간에 메시지를 비동기적으로 주고받을 수 있게 해주며 RabbitMQ에 비해 높은 처리량을 보여준다.</p>
<h3 id="💡-kafka의-주요-개념">💡 Kafka의 주요 개념</h3>
<blockquote>
</blockquote>
<p><code>Topic</code> : 메시지가 저장되는 카테고리
<code>Producer</code> : 토픽에 메시지를 발행하는 어플리케이션
<code>Consumer</code> : 토픽에서 메시지를 구독하는 어플리케이션
<code>Broker</code> : Kafka 서버
<code>Partition</code> : 토픽을 분할하여 병렬 처리를 가능하게 하는 단위</p>
<p>Kafka는 메시지를 순서대로 저장하고, 소비자가 원하는 시점부터 메시지를 읽을 수 있는 특징이 있다. </p>
<p>이러한 특성 때문에 MSA 환경에서 이벤트 소싱(Event Sourcing) 패턴을 구현하는 데 적합하다.</p>
<p>위 개념을 조금 더 자세히 살펴보면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/09d88700-e561-4e75-8a06-99c35e5561d8/image.png" alt=""></p>
<p>Kafka는 공식 사이트에서 최소 3개 이상의 브로커를 사용하길 권장하고 있다.</p>
<p>왜냐하면 n개의 Broker 중에서 1대는 Controller의 기능을 수행하기 때문이다.</p>
<blockquote>
<p><strong>Controller 역할</strong></p>
</blockquote>
<ul>
<li>각 Broker에게 담당 파티션 할당</li>
<li>Broker 정상 동작 모니터링</li>
</ul>
<p>이제 이렇게 여러개의 Broker로 구성된 Kafka Cluster는 Kafka를 구독하는 Kafka-Client Application과 데이터를 주고받게 된다.</p>
<p>Kafka-Client는 간단하게 Kafka Cluster와 데이터를 주고받는 대상이라고 보면 된다. </p>
<p>(필자가 진행하는 실습에서는 각 서비스가 Kafka-Client가 된다.)</p>
<h3 id="💡-kafka-설치-1---명령어-다운">💡 Kafka 설치 (1) - 명령어 다운</h3>
<p>필자는 도커를 이용하여 Kafka 컨테이너를 띄워서 사용할 예정이다.</p>
<p>우선 Kafka 컨테이너에 명령을 보내기 위해서는 관련 쉘 스크립트를 다운받아야 한다.</p>
<p><a href="https://kafka.apache.org/downloads">Kafka 명령어 설치 사이트</a>에서 다음 사진과 같이 Binary를 다운로드 받도록 하자</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/a0ffa009-fb7c-41b4-a6d9-9a3a4ef0130f/image.png" alt=""></p>
<p>다운로드가 완료되면 여러 쉘 스크립트가 저장된 폴더가 하나 생긴다.</p>
<p>우리는 이제 이 폴더에서 쉘 스크립트를 실행하여 명령어를 실행할 예정이다.</p>
<h3 id="💡-kafka-설치-2---컨테이너-실행">💡 Kafka 설치 (2) - 컨테이너 실행</h3>
<p>우선 Docker Hub에서 Kafka 이미지를 pull해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/8802e102-dbc5-42d5-94d0-357ca8a6cd72/image.png" alt=""></p>
<p>필자는 latest 버전으로 pull을 받았다.</p>
<p>이후, 명령어를 입력하여 컨테이너를 실행시키자</p>
<blockquote>
<p><strong>Kafka 컨테이너 실행 명령어</strong>
<code>docker run -d -p 9092:9092 --name broker apache/kafka:latest</code></p>
</blockquote>
<p>Broker라는 이름으로 컨테이너를 실행시켰다.</p>
<p>Kafka는 내부적으로 9092 포트를 사용하기 때문에 localhost의 9092 포트와 바인딩을 시켜줬다.</p>
<hr>
<h2 id="3-kafka-사용해보기">3. Kafka 사용해보기</h2>
<p>우선 지금 작성하는 내용은 Kafka를 실행시켜보고 동작을 확인하기 위함이다.</p>
<p>(프로젝트에 Kafka를 적용하는 부분은 좀있다가 다룰 예정이다.)</p>
<p>이전에 다운로드 받은 Kafka 쉘 스크립트가 위치한 디렉토리의 <code>/bin</code> 으로 이동하자</p>
<p>그리고 다음과 같은 명령어를 입력하면 토픽을 생성할 수 있다.</p>
<p>여기서 필자는 Mac OS를 사용하기 때문에 Window와는 명령어가 다를 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/74d16894-9a12-4f5e-ac89-ef17c384a1aa/image.png" alt=""></p>
<p>사진과 같이 여러 명령어가 있는 모습을 볼 수 있다.</p>
<p>이제 메세지가 저장될 토픽을 다음 커맨드를 입력하여 실행해보자</p>
<h3 id="💡-토픽-생성-및-확인">💡 토픽 생성 및 확인</h3>
<blockquote>
<p><strong>◉ 명령어 - 토픽 생성</strong>
<code>./kafka-topics.sh --create --topic quickstart-events --bootstrap-server localhost:9092 --partitions 1</code></p>
</blockquote>
<hr>
<blockquote>
<p><strong>◉ 명령어 - 토픽 리스트 확인</strong>
<code>./kafka-topics.sh --bootstrap-server localhost:9092 --list</code></p>
</blockquote>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/059c9acc-60d3-4c1c-9c2f-913f8baf48e3/image.png" alt=""></p>
<p>사진과 같이 quickstart-events 토픽이 생성되었음을 확인할 수 있다.</p>
<h3 id="💡-토픽에-메세지-전송하기">💡 토픽에 메세지 전송하기</h3>
<blockquote>
<p><strong>◉ 명령어 - 메세지 발행</strong>
<code>./kafka-console-producer.sh --bootstrap-server localhost:9092 --topic quickstart-events</code></p>
</blockquote>
<hr>
<p><strong>◉ 명령어 - 메세지 확인</strong>
<code>./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic quickstart-events --from-beginning</code></p>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/b0110ff4-8c7a-4991-97c7-1e1d33eacf7a/image.png" alt=""></p>
<p>아마 잘 안보일것 같긴한데, 아래는 메세지 발행 커맨드를 입력한 창이고 좌상단 커맨드창은 메세지 확인 창을 띄워놓은 상태이다.</p>
<p>실제로 메세지 발행 커맨드를 입력하여 가상 쉘 스크립트로 접근하고 메세지를 입력하면 위쪽 상단에서도 실시간으로 입력한 데이터가 저장되는 모습을 볼 수 있다.</p>
<p>여기서 메세지 확인 커맨드에서 <code>--from-beginning</code> 옵션은 메세지 브로커에 저장된 내용을 처음부터 읽어오겠다는 것을 의미한다.</p>
<p>만약 특정 범위부터 읽어오고 싶다면 다음과 같이 오프셋을 추가해주면 된다.</p>
<blockquote>
<p><strong>◉ 명령어 - 메세지 확인 - 특정 라인부터</strong>
<code>./kafka-console-consumer.sh --bootstrap-server localhost:9092 --partition 0 --offset 1 --topic quickstart-events</code></p>
</blockquote>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/3a93f3fa-908e-41c9-b63d-d9dd2100f530/image.png" alt=""></p>
<p>메세지 브로커에는 <code>Hello, World</code> 와 <code>Hi, There!</code> 메세지가 들어있으나 <code>Hi, There!</code> 만 확인되는 모습을 볼 수 있다.</p>
<hr>
<h2 id="4-kafka-적용하기---catalog-service">4. Kafka 적용하기 - Catalog Service</h2>
<p>이제부터 Kafka를 기존에 작업하던 프로젝트에 적용해보자</p>
<p>우선 구조를 보면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/4d612b98-b091-4534-beeb-248d69385ea3/image.png" alt=""></p>
<p>Kafka Cluster에 접근하기 위해서는 Kafka Connect의 Source, Sink가 필요하다.</p>
<p>간단하게 Source는 데이터를 Kafka Cluster에 집어넣을 때 사용되고, Sink는 Kafka Cluster에서 가져올 때 사용된다.</p>
<p>필자가 진행하는 프로젝트에서는 다음과 같이 동작해야 한다.</p>
<blockquote>
<p><strong>동작 순서</strong></p>
</blockquote>
<ol>
<li>User가 상품을 주문한다.</li>
<li>주문 내역이 Order-Service를 통해 Kafka Cluster에 메세지로 저장된다.</li>
<li>주문 내역에 맞춰서 Catalog-Service에서 재고 수량을 차감한다.</li>
<li>Kafka Cluster에 저장된 주문 내역이 하나의 DB에 자동으로 저장된다.</li>
</ol>
<p>즉, 위에서 설명한 내용을 참고했을 때, Kafka Cluster를 기준으로 Catalog-Service는 Consumer, Order-Service는 Producer가 될 것이다.</p>
<p>왜냐하면, Order-Service에서 주문 정보를 생성해서 Kafka Cluster에 메세지를 발행할 것이기 때문에 Producer로 볼 수 있다.</p>
<p>마찬가지로 Kafka Cluster에서 메세지를 가져와서 재고 수량을 차감시킬 것이기 때문에 Catalog-Service는 Consumer로 볼 수 있다.</p>
<p>우선, 이 개념을 이해하고 나머지 실습을 진행하는 것이 중요하다!</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/8aa25ab7-34fe-465d-9b34-e2204e2be5a2/image.png" alt=""></p>
<p>위와 같이 Kafka 의존성을 추가해서 프로젝트를 설정하자</p>
<h3 id="💡-kafka-설정-추가---catalog-service">💡 Kafka 설정 추가 - Catalog Service</h3>
<p>다음으로 Kafka를 사용하기 위해 필요한 설정 정보를 추가해주자</p>
<p>우선 코드를 살펴보면 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@EnableKafka
@Configuration
public class KafkaConsumerConfig {
    @Bean
    public ConsumerFactory&lt;String, String&gt; consumerFactory() {
        Map&lt;String, Object&gt; properties = new HashMap&lt;&gt;();
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, &quot;consumerGroupId&quot;);
        properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, &quot;earliest&quot;);
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
&gt;
        return new DefaultKafkaConsumerFactory&lt;&gt;(properties);
    }
&gt;
    @Bean
    public ConcurrentKafkaListenerContainerFactory&lt;String, String&gt; kafkaListenerContainerFactory() {
        ConcurrentKafkaListenerContainerFactory&lt;String, String&gt; kafkaListenerContainerFactory
                = new ConcurrentKafkaListenerContainerFactory&lt;&gt;();
        kafkaListenerContainerFactory.setConsumerFactory(consumerFactory());
&gt;
        return kafkaListenerContainerFactory;
    }
}</code></pre>
<p>해당 코드는 Catalog-Service에서 Kafka Consumer 설정을 진행하는 코드이다.</p>
<p>중요한 부분을 정리하면 다음과 같다.</p>
<blockquote>
<p><code>@EnableKafka</code></p>
</blockquote>
<ul>
<li>이 어노테이션은 Spring Kafka 기능을 활성화하는 어노테이션이다.</li>
<li>이후에 살펴볼 <code>@KafkaListener</code> 어노테이션을 사용할 수 있게 해준다.</li>
</ul>
<hr>
<p><code>@Configuration</code></p>
<ul>
<li>스프링 설정 클래스임을 의미한다.</li>
</ul>
<hr>
<p><code>BOOTSTRAP_SERVERS_CONFIG</code></p>
<ul>
<li>Kafka 브로커의 주소를 설정한다. </li>
<li>필자는 도커 컨테이너로 Kafka를 띄워놨는데 9092로 포트바인딩을 진행했다.</li>
<li>따라서, <code>localhost:9092</code> 로 설정한다.</li>
</ul>
<hr>
<p><code>GROUP_ID_CONFIG</code></p>
<ul>
<li>Consumer 그룹 ID를 설정한다.</li>
<li>여기서 같은 그룹에 속한 Consumer들은 메시지를 분산해서 처리한다.</li>
</ul>
<hr>
<p><code>AUTO_OFFSET_RESET_CONFIG</code></p>
<ul>
<li>Consumer가 처음 시작할 때 또는 오프셋이 없을 때 어디서부터 메시지를 읽을지 설정한다.</li>
<li><code>earliest</code> 는 토픽의 시작부터 읽는다는 의미이다.</li>
</ul>
<hr>
<p><code>KEY_DESERIALIZER_CLASS_CONFIG</code>, <code>VALUE_DESERIALIZER_CLASS_CONFIG</code></p>
<ul>
<li>Kafka 메시지의 키와 값을 역직렬화하는 방법을 설정한다.</li>
<li>Kafka는 JVM 기반의 Scala라는 언어로 만들어져있기 때문에 Java와 호환성이 좋다.</li>
<li>여기서는 String 타입으로 역직렬화하도록 설정한다.</li>
<li>이 설정이 필요한 주된 이유는 Kafka가 바이트 배열로 메시지를 전송하기 때문이다. </li>
<li>따라서 송신자가 직렬화한 데이터를 수신자가 올바르게 해석하려면 역직렬화 방식을 지정해야 하며, 우리는 String으로 소비하겠다는 의미이다.</li>
</ul>
<hr>
<p><code>kafkaListenerContainerFactory() 메서드</code></p>
<ul>
<li><code>@KafkaListener</code> 어노테이션이 붙은 메서드가 Kafka 메시지를 처리할 때 사용할 컨테이너 팩토리를 설정한다.</li>
<li>앞서 만든 consumerFactory를 사용하여 Consumer 속성을 설정한다.</li>
<li>마지막으로 ConcurrentKafkaListenerContainerFactory는 여러 개의 Consumer 인스턴스를 동시에 실행할 수 있게 해준다.</li>
</ul>
<p>이제 설정이 마무리 되었으니 실제로 토픽을 리스닝하는 클래스를 만들어보자</p>
<h3 id="💡-kafka-토픽-리스닝---catalog-service">💡 Kafka 토픽 리스닝 - Catalog Service</h3>
<p>우선 코드를 살펴보면 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Service
@Slf4j
public class KafkaConsumer {
    CatalogRepository repository;
&gt;
    @Autowired
    public KafkaConsumer(CatalogRepository repository) {
        this.repository = repository;
    }
&gt;
    @KafkaListener(topics = &quot;example-catalog-topic&quot;)
    public void updateQty(String kafkaMessage) {
        Map&lt;Object, Object&gt; map = new HashMap&lt;&gt;();
        ObjectMapper mapper = new ObjectMapper();
        try {
            map = mapper.readValue(kafkaMessage, new TypeReference&lt;Map&lt;Object, Object&gt;&gt;() {});
        } catch (JsonProcessingException ex) {
            ex.printStackTrace();
        }
&gt;
        CatalogEntity entity = repository.findByProductId((String)map.get(&quot;productId&quot;));
        if (entity != null) {
            entity.setStock(entity.getStock() - (Integer)map.get(&quot;qty&quot;));
            repository.save(entity);
        }
    }
}</code></pre>
<p>이 부분은 실제로 Kafka 토픽을 리스닝하고 변화가 감지되면 작동시킬 메서드를 정의한다.</p>
<p>위에서 <code>@EnableKafka</code> 어노테이션을 사용하여 Kafka 기능을 활성화했기 때문에 <code>@KafkaListener</code> 어노테이션을 사용하여 리스닝할 토픽을 지정한다.</p>
<p>여기서 우리는 토픽 이름을 <code>example-catalog-topic</code> 으로 지정할 예정이다.</p>
<p>다음으로 updateQty 메서드를 살펴보면 kafkaMessage라는 매개변수가 String으로 넘어오는 모습을 볼 수 있다.</p>
<p>그 이유는 우리가 이전에 KafkaConfig에서 역직렬화 방법을 String으로 선언했기 때문이다.</p>
<p>이제 해당 데이터를 가지고 Catalog DB에서 상품을 찾아보고 구매 수량에 맞춰 재고를 차감하는 로직이 작성되어 있다.</p>
<h3 id="💡-주의사항">💡 주의사항</h3>
<p>이전에 잠깐 설명했던 Kafka Connect는 실행되고 있지 않다.</p>
<p>즉, 위 로직은 CatalogRepository라는 JPA를 사용해서 동작하고 있는 것이다.</p>
<p>메세지 브로커에서 데이터 변경이 감지되면 자동으로 DB에 데이터를 저장하게끔하길 원한다면 다음 포스팅에서 다룰 Kafka Sink를 사용해야 한다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>분량 조절에 실패해서 아마 2개의 포스팅으로 Kafka 관련 내용을 정리해야할 것 같다.</p>
<p>물론, 코드를 계속 봐야겠지만 이번에 정리한 내용만으로는 아직 Kafka가 무엇인지 감이 잡히지 않을 수도 있다</p>
<p>그래서 다음 포스팅에서 Order Service에 적용되는 Kafka를 다시한번 살펴볼 예정이다.</p>
<p>이번 포스팅을 통해서 무엇을 위해 Kafka를 사용하고, Kafka는 어떻게 사용하고, 스프링 프로젝트에는 Kafka를 어떻게 적용하는지를 유념해서 다시 읽어보면 좋을 것 같다 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] MSA (4) - 서비스 사이에서 API 통신]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-4-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%82%AC%EC%9D%B4%EC%97%90%EC%84%9C-API-%ED%86%B5%EC%8B%A0</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-4-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%82%AC%EC%9D%B4%EC%97%90%EC%84%9C-API-%ED%86%B5%EC%8B%A0</guid>
            <pubDate>Wed, 12 Mar 2025 08:49:06 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이전 포스팅에서는 Spring Cloud Config Server에 대해 알아봤다.</p>
<p>MSA를 채택했다면 서비스 사이에서 데이터를 주고받아야하는 경우가 대부분일 것이다. </p>
<p>따라서, 이번에는 MSA 환경에서 서비스 간 통신 방법에 대해 정리해보려고 한다 👀</p>
<hr>
<h2 id="1-서비스-간-통신의-필요성">1. 서비스 간 통신의 필요성</h2>
<p>MSA 환경에서는 각 서비스가 독립적으로 동작하지만, 실제 비즈니스 로직을 처리하기 위해서는 서비스 간 데이터 교환이 필요하다.</p>
<p>예를 들어, 사용자 서비스에서 주문 정보를 조회하거나, 주문 서비스에서 상품 정보를 가져오는 등의 작업에서 서비스간의 통신이 필요할 것이다.</p>
<p>우리는 서비스 간 통신 방식을 크게 두 가지로 나눌 수 있다</p>
<blockquote>
<p><code>동기 방식</code></p>
</blockquote>
<ul>
<li>HTTP 기반의 REST API 호출 (REST Template, Feign Client)</li>
</ul>
<hr>
<p><code>비동기 방식</code></p>
<ul>
<li>메시지 브로커를 사용한 통신 (AMQP, Kafka 등)</li>
</ul>
<p>이번 포스팅에서는 우선 동기 방식의 통신 방법을 중심으로 알아보려고 한다.</p>
<hr>
<h2 id="2-resttemplate을-이용한-통신">2. RestTemplate을 이용한 통신</h2>
<p>가장 기본적인 서비스 간 통신 방법은 RestTemplate을 사용하는 것이다. </p>
<p>Spring에서 제공하는 RestTemplate을 사용하면 HTTP 요청을 쉽게 만들 수 있다.</p>
<p>우선 코드를 살펴보자</p>
<h3 id="💡-resttemplate-빈-등록">💡 RestTemplate 빈 등록</h3>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Bean
public RestTemplate getRestTemplate() {
    int TIMEOUT = 5000;
&gt;
    RestTemplate restTemplate = new RestTemplateBuilder()
            .setConnectTimeout(Duration.ofMillis(TIMEOUT))
            .setReadTimeout(Duration.ofMillis(TIMEOUT))
            .build();
&gt;
    return restTemplate;
}</code></pre>
<p>DI를 통해 쉽게 RestTemplate를 사용하기 위해 빈으로 등록한 과정이다. </p>
<p>빈은 어디에서 등록해도 상관없기 때문에 각자 적절한 위치에서 등록하자</p>
<h3 id="💡-resttemplate-사용-예시-1">💡 RestTemplate 사용 예시 (1)</h3>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Service
public class UserServiceImpl implements UserService {
    &gt;
    private final RestTemplate restTemplate;
    private final Environment env;
    &gt;
    public UserServiceImpl(RestTemplate restTemplate, Environment env) {
        this.restTemplate = restTemplate;
        this.env = env;
    }
    &gt;
    @Override
    public UserDto getUserByUserId(String userId) {
        // 사용자 정보 조회
        UserEntity userEntity = userRepository.findByUserId(userId);
        &gt;
        if (userEntity == null)
            throw new UsernameNotFoundException(&quot;User not found&quot;);
        &gt;
        UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);
        &gt;
        // 주문 서비스에서 주문 정보 가져오기
        String orderUrl = String.format(&quot;http://127.0.0.1:8000/order-service/%s/orders&quot;, userId);
        ResponseEntity&lt;List&lt;ResponseOrder&gt;&gt; orderListResponse =
                restTemplate.exchange(orderUrl, HttpMethod.GET, null,
                        new ParameterizedTypeReference&lt;List&lt;ResponseOrder&gt;&gt;() {});
        &gt;
        List&lt;ResponseOrder&gt; ordersList = orderListResponse.getBody();
        userDto.setOrders(ordersList);
        &gt;
        return userDto;
    }
}</code></pre>
<p>restTemplate은 <code>exchange</code> 메서드를 호출하여 HTTP 통신을 진행할 수 있다.</p>
<p>코드를 살펴보면 orderUrl을 Order Service에서 제공하는 엔드포인트에 맞춰서 설정하고 HTTP Method 등을 설정하여 요청을 보내고 있다.</p>
<p>여기서 우리가 눈여겨 봐야할 것은 바로 orderUrl을 세팅하는 부분이다.</p>
<h3 id="💡-resttemplate-사용-예시-2">💡 RestTemplate 사용 예시 (2)</h3>
<p>위 코드에서 발생하는 문제점은 바로 OrderUrl이 하드코딩되어있다는 것이다.</p>
<p>이러한 하드코딩을 피하는 방법을 생각해보면 서비스 디스커버리에 등록된 인스턴스를 찾아서 Url을 세팅해주면 될 것이다.</p>
<p>Eureka에서 인스턴스 정보를 뽑아서 사용하기 위해서는 다음과 같은 설정이 필요하다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Bean
@LoadBalanced
public RestTemplate getRestTemplate() {
    int TIMEOUT = 5000;
&gt;
    RestTemplate restTemplate = new RestTemplateBuilder()
                .setConnectTimeout(Duration.ofMillis(TIMEOUT))
                .setReadTimeout(Duration.ofMillis(TIMEOUT))
                .build();
&gt;
    return restTemplate;
}</code></pre>
<p>RestTemplate을 빈으로 등록하는 과정에서 <code>@LoadBalanced</code> 라는 어노테이션을 추가해주면 Eureka에 등록된 서비스 이름으로 API를 호출할 수 있다!</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Service
public class UserServiceImpl implements UserService {
    &gt;
    private final RestTemplate restTemplate;
    private final Environment env;
    &gt;
    public UserServiceImpl(RestTemplate restTemplate, Environment env) {
        this.restTemplate = restTemplate;
        this.env = env;
    }
    &gt;
    @Override
    public UserDto getUserByUserId(String userId) {
        UserEntity userEntity = userRepository.findByUserId(userId);
        &gt;
        if (userEntity == null)
            throw new UsernameNotFoundException(&quot;User not found&quot;);
        &gt;
        UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);
        &gt;
        // 수정된 부분
        String orderUrl = String.format(env.getProperty(&quot;order-service.url&quot;), userId);
        ResponseEntity&lt;List&lt;ResponseOrder&gt;&gt; orderListResponse =
                restTemplate.exchange(orderUrl, HttpMethod.GET, null,
                        new ParameterizedTypeReference&lt;List&lt;ResponseOrder&gt;&gt;() {});
        &gt;
        List&lt;ResponseOrder&gt; ordersList = orderListResponse.getBody();
        userDto.setOrders(ordersList);
        &gt;
        return userDto;
    }
}</code></pre>
<hr>
<pre><code class="language-yaml">spring:
  config:
    import: optional:configserver:http://127.0.0.1:8888/</code></pre>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/9361c944-f1d2-450a-8eba-3c60455ef2cd/image.png" alt=""></p>
<p>필자는 실습 과정에서 Config Server에서 참조하는 Github Repository에서 Order-Service의 URL을 정의하고 있기 때문에 위처럼 order-service.url을 환경변수에 지정해주면 된다.</p>
<p>지정된 URL을 살펴보면 <code>http://ORDER-SERVICE/order-service/%s/orders</code> 로 정의되어있는데 여기서 우리는 한가지 차이점을 기억해야 한다.</p>
<p><strong><code>@LoadBalanced</code> 어노테이션을 사용하면 API Gateway가 아닌 Eureka에서 직접 인스턴스를 찾아서 접근한다는 것을 기억하자</strong></p>
<p>따라서, <code>ORDER-SERVICE</code> 는 Eureka에 등록된 인스턴스 이름을 의미한다.</p>
<p>뒤에 있는 <code>/order-service/%s/orders</code> 는 API의 엔드포인트이다. </p>
<p>(필자는 order-service에서 /order-service라는 prefix를 사용하고 있기 떄문에 사용하는 것이다.)</p>
<p>이렇게 하면 ORDER-SERVICE라는 서비스 이름으로 Eureka에 등록된 서비스를 찾아 요청을 보낸다. </p>
<p>여러 인스턴스가 있을 경우 로드 밸런싱도 자동으로 처리된다는 것을 기억하자!</p>
<hr>
<h2 id="3-feignclient를-이용한-통신">3. FeignClient를 이용한 통신</h2>
<p>RestTemplate보다 더 선언적이고 간편한 방법으로 Feign Client가 있다. </p>
<p><code>OpenFeign</code> 은 Netflix에서 개발한 HTTP 클라이언트로, 인터페이스와 어노테이션만으로 HTTP API 호출을 구현할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/195ab78a-a353-463a-ab07-8a0774d35e2e/image.png" alt=""></p>
<p>우선 OpenFeign을 사용하기 위해서는 해당 의존성을 추가해야 한다.</p>
<h3 id="💡-feign-client-활성화">💡 Feign Client 활성화</h3>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@SpringBootApplication
@EnableFeignClients
public class UserServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }
}</code></pre>
<p>위 코드처럼 BootApplication에서 <code>@EnalbeFeignClients</code> 어노테이션을 추가하여 Feign Client를 사용하겠다는 것을 명시적으로 선언해야 한다.</p>
<h3 id="💡-feign-client-정의">💡 Feign Client 정의</h3>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@FeignClient(name=&quot;order-service&quot;)
public interface OrderServiceClient {
&gt;
    @GetMapping(&quot;/order-service/{userId}/orders&quot;)
    List&lt;ResponseOrder&gt; getOrders(@PathVariable String userId);
}</code></pre>
<hr>
<p><strong>실제 /order-service/{userId}/orders 구현부</strong></p>
<pre><code class="language-java">@GetMapping(&quot;/{userId}/orders&quot;)
public ResponseEntity&lt;List&lt;ResponseOrder&gt;&gt; getOrder(@PathVariable(&quot;userId&quot;) String userId) throws Exception {
    log.info(&quot;Before retrieve orders data&quot;);
    Iterable&lt;OrderEntity&gt; orderList = orderService.getOrdersByUserId(userId);
&gt;
    List&lt;ResponseOrder&gt; result = new ArrayList&lt;&gt;();
    orderList.forEach(v -&gt; {
        result.add(new ModelMapper().map(v, ResponseOrder.class));
    });
&gt;
    log.info(&quot;Add retrieved orders data&quot;);
&gt;
    return ResponseEntity.status(HttpStatus.OK).body(result);
}</code></pre>
<p>이처럼 <code>@FeignClient</code> 어노테이션을 사용하여 Feign Client를 정의할 수 있다.</p>
<p>여기서 어노테이션 부분에서 사용된 name 속성은 Eureka에 등록된 서비스 인스턴스의 이름을 의미한다.</p>
<p>다음으로 <code>@GetMapping</code> 주소는 해당 서비스의 API 엔트포인트를 적어주면 된다.</p>
<p>실제로 Order-Service에서는 <code>ResponseEntity</code> 로 반환하고 있지만, Feign Client는 <code>List&lt;ResponseOrder&gt;</code> 를 반환타입으로 지정하고 있다.</p>
<p><code>ResponseEntity&lt;List&lt;ResponseOrder&gt;&gt;</code> 를 반환하는 것 사이에 불일치가 있는 것처럼 보이지만, 사실 이것은 FeignClient의 동작 방식 때문에 가능한 것이다.</p>
<blockquote>
<p><strong>컨트롤러의 실제 반환 부분</strong></p>
</blockquote>
<pre><code class="language-java">return ResponseEntity.status(HttpStatus.OK).body(result);</code></pre>
<p>위처럼 컨트롤러가 실제로 ResponseEntity를 반환하더라도 Feign Client는 두가지의 편의 기능을 모두 제공한다.</p>
<blockquote>
<p><strong>응답 본문만 원하는 경우</strong></p>
</blockquote>
<pre><code class="language-java">@FeignClient(name=&quot;order-service&quot;)
public interface OrderServiceClient {
&gt;
    @GetMapping(&quot;/order-service/{userId}/orders&quot;)
    List&lt;ResponseOrder&gt; getOrders(@PathVariable String userId);
}</code></pre>
<hr>
<p><strong>응답 전체를 원하는 경우</strong></p>
<pre><code class="language-java">@FeignClient(name=&quot;order-service&quot;, configuration = FeignErrorDecoder.class)
public interface OrderServiceClient {
&gt;
    @GetMapping(&quot;/order-service/{userId}/orders&quot;)
    ResponseEntity&lt;List&lt;ResponseOrder&gt;&gt; getOrders(@PathVariable String userId);
}</code></pre>
<p>따라서, 개발자가 응답 코드도 원한다면 ResponseEntity 타입으로 Feign Client를 정의해주면 된다.</p>
<h3 id="💡-felign-client-사용-예시">💡 Felign Client 사용 예시</h3>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Service
public class UserServiceImpl implements UserService {
    &gt;
    private final OrderServiceClient orderServiceClient;
    &gt;
    public UserServiceImpl(OrderServiceClient orderServiceClient) {
        this.orderServiceClient = orderServiceClient;
    }
    &gt;
    @Override
    public UserDto getUserByUserId(String userId) {
        UserEntity userEntity = userRepository.findByUserId(userId);
        &gt;
        if (userEntity == null)
            throw new UsernameNotFoundException(&quot;User not found&quot;);
        &gt;
        UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);
        &gt;
        // Feign Client를 통한 주문 정보 조회
        List&lt;ResponseOrder&gt; ordersList = orderServiceClient.getOrders(userId);
        userDto.setOrders(ordersList);
        &gt;
        return userDto;
    }
}</code></pre>
<p>여기서 OrderServiceCLient는 Feign Client를 정의한 부분이다.</p>
<h3 id="💡-에러-헨들링">💡 에러 헨들링</h3>
<p>서비스 사이에서 오류가 발생한 경우, 당연히 개발자는 에러를 처리해야 한다.</p>
<p>간단하게 try-catch 블록을 추가해서 에러를 잡아서 해결할 수도 있겠으나, Feign Client를 사용하는 경우 <code>ErrorDecoder</code> 를 구현하여 등록하면 된다.</p>
<p>우선 코드를 살펴보자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Component
public class FeignErrorDecoder implements ErrorDecoder {
    Environment env;
&gt;
    @Autowired
    public FeignErrorDecoder(Environment env) {
        this.env = env;
    }
&gt;
    @Override
    public Exception decode(String methodKey, Response response) {
        switch(response.status()) {
            case 400:
                break;
            case 404:
                if (methodKey.contains(&quot;getOrders&quot;)) {
                    return new ResponseStatusException(HttpStatus.valueOf(response.status()),
//                            &quot;User&#39;s orders is empty&quot;);
                           env.getProperty(&quot;order-service.exception.order-is-empty&quot;));
                }
                break;
            default:
                return new Exception(response.reason());
        }
&gt;
        return null;
    }
}</code></pre>
<p>ErrorDecode를 상속받아서 별도의 Decoder를 구현해주면 된다.</p>
<p>여기서 decode 메서드를 오버라이딩하여 어떻게 처리할지를 결정하면 된다.</p>
<p>methodKey에는 호출한 메서드명이 들어가게 되고, response는 호출 결과를 가져온다. </p>
<p>(status에 접근할 수 있다.)</p>
<p>다음으로 이전에 구현한 Feign Client에 해당 Decoder를 등록해주면 된다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@FeignClient(name=&quot;order-service&quot;, configuration = FeignErrorDecoder.class)
public interface OrderServiceClient {
&gt;
    @GetMapping(&quot;/order-service/{userId}/orders&quot;)
    List&lt;ResponseOrder&gt; getOrders(@PathVariable String userId);
}</code></pre>
<p>이렇게 하면 Feign Client에서 발생하는 모든 예외가 ErrorDecoder를 통해 처리된다!</p>
<p>서비스 간 통신에서 발생하는 다양한 오류 상황을 중앙에서 관리할 수 있어 코드가 더 깔끔해진다는 장점이 있다.</p>
<hr>
<h2 id="6-분산-환경에서-발생하는-통신-문제">6. 분산 환경에서 발생하는 통신 문제</h2>
<p>간단하게 서비스만 구조도에 포함하면 다음과 같은 구조를 갖는다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/5c7e6d73-36d5-45f7-8cdb-be7e8bacc0d7/image.png" alt=""></p>
<p>즉, 여러개의 서비스 인스턴스가 생기면 각 서비스에 매핑되는 DB가 생긴다.</p>
<p>이렇게 되면 다음과 같은 문제가 발생할 수 있다.</p>
<p>우선 위 구조와 동일하게 Order-Service 인스턴스를 2개 생성해보자</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/fe674ba6-d319-4cc7-9fce-7639c4508bd1/image.png" alt=""></p>
<p>다음으로 User가 Catalog-001, Catalog-002, Catalog-003 상품을 주문했다. </p>
<p>이후, 주문 내역 조회를 시도해보면 다음과 같은 화면을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/77cd889c-eee4-4f77-b766-334fe6a5c3f1/image.png" alt=""></p>
<p>처음 조회에는 Catalog-002 상품만 조회되고 있다.</p>
<p>다시한번 동일한 path로 주문 내역 조회 요청을 날리면 다음과 같은 결과를 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/4a66ba32-fe84-43bd-8a5c-64791fd8e114/image.png" alt=""></p>
<p>Catalog-001과 Catalog-002 상품이 조회된다.</p>
<p>실제로 각 서비스의 h2-console에 접근하면 다음과 같은 내용을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/ae29a0fc-439f-4c78-b179-3b495963d71a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/b3fc8250-9055-4726-98c7-a0f8b6cd4df6/image.png" alt=""></p>
<p>사용자 서비스에서 RestTemplate이나 Feign Client를 통해 주문 정보를 조회하면 로드 밸런싱에 의해 두 인스턴스 중 하나에만 요청이 가게 된다.</p>
<p>따라서 첫 번째 인스턴스로 요청이 갔다면 첫 번째와 세 번째 주문만 보이고, 두 번째 인스턴스로 요청이 갔다면 두 번째 주문만 보이게 된다.</p>
<p>그렇다면 이러한 문제를 어떻게 해결할 수 있을까?</p>
<p>이 내용은 다음 포스팅에서 다뤄보려고 한다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 포스팅에서는 RestTemplate과 FeignClient를 사용한 동기 방식의 통신에 대해서 살펴봤다.</p>
<p>FeignClient를 사용할 때는 ErrorDecoder를 통해 효율적인 예외 처리가 가능하다는 점도 알아봤다.</p>
<p>하지만, 여러 서비스 인스턴스에서 발생하는 데이터 일관성 문제는 동기 방식의 통신만으로는 해결하기 어려운 문제다.</p>
<p>이를 해결하기 위해서는 이벤트 기반의 비동기 통신 방식이 필요하다.</p>
<p>다음 포스팅에서는 Kafka를 활용한 이벤트 기반 아키텍처로 데이터 일관성 문제를 해결하는 방법에 살펴보려고 한다.</p>
<p>각 서비스 사이에서 통신을 어떻게 처리하는지는 잘 알아두도록 하자 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] MSA (3) - Config Server & RabbitMQ]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-3-Config-Server</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-3-Config-Server</guid>
            <pubDate>Tue, 11 Mar 2025 11:35:22 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이전 포스팅에서는 API Gateway에 대해서 알아봤다. </p>
<p>API Gateway는 MSA와 같은 분산 시스템 환경에서 요청을 적절하게 라우팅을 해주기 위해 사용했었다.</p>
<p>앞으로도 계속해서 MSA 아키텍처에서 필요한 내용을 하나씩 정리해볼건데, 이번에는 여러가지 서비스의 설정 정보를 한군데에서 관리하도록 도와주는 Config Server에 대해서 정리해보려고 한다 👀</p>
<hr>
<h2 id="1-config-server란">1. Config Server란?</h2>
<p>Config Server는 MSA 환경에서 여러 서비스 인스턴스의 설정 정보를 중앙에서 관리하기 위한 컴포넌트이다. </p>
<p>분산 시스템에서는 각 서비스마다 다양한 환경(개발, 테스트, 운영)에 맞는 설정 정보가 필요한데, 이를 각 서비스에 직접 포함시키면 관리가 복잡해지는 문제가 있다.</p>
<p>예를들어서, JWT 생성과 검증에 사용되는 Secret Key가 변경된 상황을 생각해보자</p>
<p>만약, 모든 서비스에서 JWT 필터를 적용하고 있다면 다른 모든 서비스에서도 Secret Key 관련 설정 정보를 변경하고 다시 빌드하고 도커 허브에 올리고 배포해야하는 번거로운 작업이 될 수 있는 것이다.</p>
<h3 id="💡-config-server의-필요성">💡 Config Server의 필요성</h3>
<p>12factors라는 클라우드 네이티브 애플리케이션 개발 원칙 중 하나는 다음과 같다. </p>
<blockquote>
<h4 id="개발-테스트-운영-환경이-일치하되-간단한-조작으로-유연하게-변경할-수-있어야-한다">&quot;개발, 테스트, 운영 환경이 일치하되, 간단한 조작으로 유연하게 변경할 수 있어야 한다&quot;</h4>
</blockquote>
<p>여기서 Config Server는 하나의 중앙화된 저장소에서 구성 요소를 관리하는 기능을 제공하며, 각 서비스는 시작 시 Config Server로부터 설정 정보를 가져와서 사용한다.</p>
<p>이렇게 하면 설정 정보의 변경이 필요할 때 서비스를 다시 빌드하지 않고도 쉽게 변경할 수 있다.</p>
<p>즉, Config Server는 이런 원칙을 구현하는 데 도움을 준다!</p>
<hr>
<h2 id="2-config-server의-동작-방식">2. Config Server의 동작 방식</h2>
<p>Config Server는 일반적으로 Git Repository를 백엔드 저장소로 사용한다.</p>
<p>설정 파일을 Git에 저장함으로써 버전 관리가 가능하고, 여러 환경에 대한 설정을 비교적 체계적으로 관리할 수 있다는 장점이 있기 때문이다.</p>
<h3 id="💡-계층-구조">💡 계층 구조</h3>
<p>Config Server는 <code>계층 구조</code> 를 사용하여 설정 정보를 관리한다. </p>
<p>조금 더 자세히 정리하면 다음과 같은 우선순위로 설정 정보를 찾는다고 볼 수 있다.</p>
<blockquote>
<p><strong>계층 구조</strong></p>
</blockquote>
<ul>
<li><code>application.yml</code></li>
<li><code>application-{profile}.yml</code></li>
<li><code>{application-name}.yml</code></li>
<li><code>{application-name}-{profile}.yml</code></li>
</ul>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/41083ba8-83fa-41a9-afcf-29771bac0233/image.png" alt=""></p>
<p>따라서 <code>user-service-dev.yml</code> 가 없다면 <code>user-service.yml</code> 을 가져오고, 그것도 없다면 <code>application-dev.yml</code> 을, 마지막으로 <code>application.yml</code> 에서 데이터를 가져온다.</p>
<p>간단하게 생각하면 서비스와 가장 가까운 정보(구체적인 설정 정보)부터 찾아서 가져오려고 시도한다고 이해하면 된다!</p>
<hr>
<h2 id="3-config-server-구현">3. Config Server 구현</h2>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/98fd670b-8ee7-4716-8c68-3659a1be822d/image.png" alt=""></p>
<p>우선 위와 같이 Config Server 관련 의존성을 추가해야 한다.</p>
<p>의존성이 추가되었다면 application.yml에서 config server 설정을 진행해주면 된다.</p>
<p>코드를 살펴보자</p>
<h3 id="💡-config-server-설정">💡 Config Server 설정</h3>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">server:
  port: 8888
&gt;
spring:
#  profiles:
#    active: native
  application:
    name: config-service
  cloud:
    config:
      server:
        native:
          search-locations: file:///Users/yeop/Desktop/LG_CNS_AM_Inspire_Camp/MSA
        git: #default
          uri: # 설정 정보를 관리하는 Github 주소 입력
          default-label: master # 해당 Github의 브랜치 설정
#          username: &lt;github-id&gt;
#          password: &lt;gihub-accessToken&gt;
        bootstrap: true
&gt;
management:
  endpoints:
    web:
      exposure:
        include: health, busrefresh, refresh, metrics</code></pre>
<p>설정 정보를 정리해보면 다음과 같이 정리할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/926bfc27-e36f-4713-a535-7d8e1a490eab/image.png" alt=""></p>
<p>즉, native 설정을 켜주면 search-locations로 지정한 디렉토리에서 설정 파일을 찾고, native 설정이 꺼져있다면 git으로 설정한 디렉토리에 접근하여 설정 파일을 찾는 것이다.</p>
<p>다음으로 BootApplication에서 다음 코드와 같이 <code>@EnableConfigServer</code> 어노테이션을 추가해주면 우선은 마무리 된다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@SpringBootApplication
@EnableConfigServer
public class ConfigServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigServiceApplication.class, args);
    }
}</code></pre>
<h3 id="💡-client-서비스-설정">💡 Client 서비스 설정</h3>
<p>Config Server를 사용하는 클라이언트 서비스는 다음과 같이 설정 정보를 작성해야 한다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">spring:
  config:
    import: optional:configserver:http://127.0.0.1:8888/
  cloud:
    config:
      name: user-service
      ...</code></pre>
<p>위처럼 <code>spring.config.import : ~~</code> 에서 Config Server를 찾아오는 모습이다.</p>
<p>여기서 필자는 configserver가 디스커버리 서비스에서 대상을 찾기 위해 사용하는 검색어인줄 알았으나 관련 내용을 정리해보면 다음과 같다.</p>
<blockquote>
<p><strong>optional:configserver:<a href="http://127.0.0.1:8888/">http://127.0.0.1:8888/</a> 정리</strong></p>
</blockquote>
<hr>
<p><code>-optional:</code></p>
<ul>
<li>Config Server에 연결할 수 없더라도 애플리케이션이 시작할 수 있게 해주는 Prefix이다.</li>
</ul>
<hr>
<p><code>configserver:</code></p>
<ul>
<li>이것은 Spring Cloud Config의 특별 구문으로, Config Server를 사용한다는 의미를 가진다.</li>
</ul>
<hr>
<p><code>http://127.0.0.1:8888/</code></p>
<ul>
<li>실제 Config Server의 URL이다. </li>
<li>이전에 8888 포트를 Config Server로 지정했기 때문에 위와같은 URL을 사용한다.</li>
</ul>
<p>즉, 여기서 <code>configserver</code> 는 Spring Cloud Config Client가 인식하는 특별한 접두사(prefix)인 것이다.</p>
<p>이 접두사는 Spring Boot가 Config Server에 연결해야 함을 알려주는 마커(marker)라고 이해하면 될 것 같다!</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/d4877c1b-1a5f-434c-b66a-f6b93c358866/image.png" alt=""></p>
<p>실제로 서비스를 실행시켜보면 Config Server에서 데이터를 패치받는 모습을 볼 수 있다.</p>
<hr>
<h2 id="4-설정-변경-감지와-적용">4. 설정 변경 감지와 적용</h2>
<p>Config Server의 한 가지 제한사항은 설정 정보가 변경되더라도 서비스를 재시작하지 않으면 변경 사항이 적용되지 않는다는 점이다.</p>
<p>즉, 편하게 사용하려고 Config Server를 사용하지만 위의 설정 그대로 사용한다면 설정 정보가 변경된 순간 서비스를 다시 실행해야하는 불편함이 그대로 존재하게 된다.</p>
<p>실제로 강의에서 실습하는 과정에서 대부분의 시간을 여기에 할애했다..
(MSA를 적용했기 때문에 서비스가 많아서 실습이 꽤 까다로웠다)</p>
<p>그래서 필자는 최종 결론만 정리해보려고 한다 😅</p>
<h3 id="💡-spring-cloud-bus를-이용한-자동-갱신">💡 Spring Cloud Bus를 이용한 자동 갱신</h3>
<p>이전에 언급한 문제를 해결하기 위해 Spring Cloud Bus와 메시지 브로커(RabbitMQ, Kafka 등)를 사용할 수 있다. </p>
<p>설정이 변경되면 Config Server가 메시지 브로커를 통해 모든 서비스에 변경 사항을 알리고, 각 서비스는 자동으로 설정을 갱신하는 것이다.</p>
<p>우선 메세지 브로커를 이용하지 않았을 때의 구조를 살펴보면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/f576bc37-861c-43d4-a6f4-7078207f7afe/image.png" alt=""></p>
<p>위 사진과 같이 Github에서 토큰 정보를 변경하면 Config-Server에는 즉시 반영되지만, Config-Server에서 설정 정보를 받아오는 다른 서비스에는 refresh를 걸어주지 않는이상 기존 토큰 정보를 가지고 있게 된다.</p>
<p>그래서 이러한 문제를 해결하기 위해 메세지 브로커를 적용한 구조를 살펴보면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/b92c0a97-03e5-4737-ab0d-bf594e9c7a65/image.png" alt=""></p>
<p>GitRepository에서 설정 정보가 변경되면 우선 Config-Server에는 바로 반영된다.</p>
<p>여기서 개발자는 <code>/busrefresh</code> 라는 엔드포인트를 호출하여 메세지 브로커에 메세지를 발행시킨다.</p>
<p>RabbitMQ에 메세지가 저장되면, 이를 구독하는 모든 서비스들에게 변경 내용을 알려주고 서비스들은 이 내용으로 설정 정보를 변경하게 된다.</p>
<p>(물론 설정이라는 특성상 모든 설정에 대하여 동적으로 변경이 가능한 것은 아니다.)</p>
<h3 id="💡-spring-cloud-bus--rabbitmq-설정-추가">💡 Spring Cloud Bus &amp; RabbitMQ 설정 추가</h3>
<p>이제부터는 위에서 설명한 구조대로 동작할 수 있도록 설정 정보를 수정해보자</p>
<p>우선 메세지 브로커에 연결해야할 서비스들 (Config Server와 Config Server에서 설정 정보를 가져올 서비스들)에 한가지 의존성을 추가해야한다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/19ea1667-1a43-4d08-82f9-60da71c3ee3b/image.png" alt=""></p>
<p>메세지 브로커를 사용할 서비스들에 위 의존성이 추가되었다면, 메세지 브로커로 사용할 RabbitMQ를 설치해야 한다.</p>
<p>간단하게 진행하기 위해 Docker Hub에 등록된 RabbitMQ 이미지를 사용하도록 하자</p>
<blockquote>
<p><strong>RabbitMQ 컨테이너 실행</strong>
<code>docker run -d -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:4.0-management</code></p>
</blockquote>
<hr>
<ul>
<li>RabbitMQ 이미지는 먼저 Pull을 해야한다.</li>
<li>여기서 웹 서버 포트를 지정해줄 수 있는데 필자는 15672로 지정했다.</li>
</ul>
<h4 id="config-server-설정-추가">Config-Server 설정 추가</h4>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">server:
  port: 8888
&gt;
spring:
#  profiles:
#    active: native
  application:
    name: config-service
  cloud:
    config:
      server:
        native:
          search-locations: file:///Users/yeop/Desktop/LG_CNS_AM_Inspire_Camp/MSA
        git: #default
          uri: # Github 주소
          default-label: master
#          username: &lt;github-id&gt;
#          password: &lt;gihub-accessToken&gt;
        bootstrap: true
  rabbitmq:
    host: 127.0.0.1
    port: 5672
    username: guest
    password: guest
&gt;
management:
  endpoints:
    web:
      exposure:
        include: health, busrefresh, refresh, metrics</code></pre>
<p>사진과 같이 rabbitmq 관련 정보를 추가해주자</p>
<p>우선 로컬 환경에서 진행하기 때문에 127.0.0.1을 사용하고 도커 컨테이너를 띄울때 사용한 5672 포트를 사용하여 바인딩을 시켜준다.</p>
<p>기본 아이디와 비밀번호는 <code>guest</code> 이므로 이 정보도 넣어준다.</p>
<p>여기서 이전에 설명하지 않고 넘어갔던 부분인 <code>management</code> 는 Actuator 기능을 사용하기 위한 설정이다.</p>
<p>우리는 busrefresh 엔드포인트를 호출하여 메세지를 발행해야하기 때문에 사용한다고 일단 알아두자!</p>
<p><strong>마찬가지로 메세지 브로커를 사용할 서비스들에도 위와 같은 rabbitmq 설정을 추가해주면 된다.</strong></p>
<h3 id="💡-rabbitmq-접근--팁">💡 RabbitMQ 접근 &amp; 팁?</h3>
<p>이전에 컨테이너 실행시 설정했던 웹포트 15672로 접근하면 다음과 같은 화면을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/e69d0049-c4a1-4ab5-9aa5-813a310ffb08/image.png" alt=""></p>
<p>이후, busrefresh 엔드포인트를 호출하면 메세지 브로커를 구독하는 서비스에도 변경된 설정 정보가 반영되게 된다!</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/f7fe9b81-647c-43d1-959b-37421eabbb02/image.png" alt=""></p>
<h4 id="여기서-다음-사진을-살펴보자">여기서 다음 사진을 살펴보자</h4>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/4e0c6e6e-74c1-4626-b927-0acd3efa5d53/image.png" alt=""></p>
<p>즉, 8888(Config-Server)가 아니라 메세지 브로커를 구독하는 8000(User-Service)에서 actuator/busrefresh를 호출해도 정상적으로 동작한다.</p>
<p>Config Server에 직접 /actuator/busrefresh를 호출하지 않아도, 메시지 브로커(RabbitMQ)에 연결된 어떤 서비스에서든 /actuator/busrefresh를 호출하면 설정 변경이 모든 서비스에 전파된다.</p>
<p>동작 과정을 살펴보면 다음과 같다.</p>
<blockquote>
<p><strong>메세지 브로커 동작 과정</strong></p>
</blockquote>
<ol>
<li>GitHub에서 설정 변경 발생<blockquote>
</blockquote>
</li>
<li>Config Server는 변경 사항 감지<blockquote>
</blockquote>
</li>
<li>어떤 서비스든(ex: user-service) /actuator/busrefresh 엔드포인트 호출<blockquote>
</blockquote>
</li>
<li>해당 서비스는 Spring Cloud Bus를 통해 메시지 브로커에 메시지 발행<blockquote>
</blockquote>
</li>
<li>메시지 브로커는 동일한 버스에 연결된 모든 다른 서비스(Config Server 포함)에게 이 메시지 전달<blockquote>
</blockquote>
</li>
<li><strong>각 서비스는 메시지를 받고 Config Server에서 새 설정을 가져와 적용</strong></li>
</ol>
<p>정리하면 동일한 메세지 버스에 각 서비스가 연결되어있기 때문에, busrefresh를 호출할 수 있으며, 한 서비스에서 보낸 메세지는 동일한 버스에 연결된 모든 서비스에 전달되기 때문에 user-service에서 호출해도 변경된 설정이 모두 반영될 수 있다는 것을 기억하자!</p>
<hr>
<h2 id="5-암호화--복호화">5. 암호화 / 복호화</h2>
<p>우리는 Github Repository에서 비밀키와 같은 데이터를 관리한다면 아무래도 민감한 정보이기 때문에 암호화된 값으로 저장하는게 보안 측면에서 더 안전한 방법일 것이다.</p>
<p>따라서, Config Server에서 중요한 키들을 관리한다고 하면, 암호화 / 복호화 기능이 Config Server가 시작되는 초기 단계에서 제공되어야 할 것이다.</p>
<p>bootstrap.yml은 Spring Cloud 애플리케이션이 시작될 때 가장 먼저 로드되는 설정 파일이기 때문에, 이 파일을 별도로 생성해서 암호화 / 복호화를 진행해보도록 하자</p>
<h3 id="💡-비대칭키-암호화">💡 비대칭키 암호화</h3>
<p>RSA 알고리즘을 사용하는 비대칭키 암호화를 구현하기 위해, JAVA의 keytool을 이용해서 key-store를 생성할 수 있다.</p>
<p>명령어는 다음과 같다.</p>
<blockquote>
<p><strong>KeyStore 생성</strong>
<code>keytool -genkeypair -alias apiEncryptionKey -keyalg RSA 
  -dname &quot;CN=Config Server,OU=Spring Cloud,O=Organization&quot; 
  -keypass 1q2w3e4r -keystore apiEncryptionKey.jks -storepass 1q2w3e4r</code></p>
</blockquote>
<hr>
<p>  <code>-genkeypair</code> : 공개키/개인키 쌍을 생성
<code>-alias apiEncryptionKey</code> : 키 쌍의 별칭 설정
<code>-keyalg RSA</code> : RSA 알고리즘 사용
<code>-dname</code> : 인증서 소유자 정보
<code>-keypass 1q2w3e4r</code> : 키 비밀번호
<code>-keystore apiEncryptionKey.jks</code> : 생성할 키스토어 파일 이름
<code>-storepass 1q2w3e4r</code> : 키스토어 비밀번호</p>
<hr>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/de16a81f-3298-4cb9-8558-a3919cfd5c2b/image.png" alt=""></p>
<p>위 명령어를 입력하면 사진과 같이 jks 확장자를 가진 파일이 하나 생성되는 모습을 볼 수 있다.</p>
<p>다음으로 Config Server에서 application.yml과 동일한 위치에 bootstrap.yml을 생성하고 다음과 같이 코드를 작성해주자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">encrypt:
    key-store:
        location: file: # 방금 생성한 jks 파일 경로
        password: 1q2w3e4r
        alias: apiEncryptionKey</code></pre>
<p>우리는 Config Server 설정 파일로 등록했기 때문에 8888번 포트에 encrypt 엔드포인트로 plain text를 날려주면 다음과 같은 사진을 볼 수 있다.</p>
<blockquote>
<p><strong>Result View</strong>
<img src="https://velog.velcdn.com/images/sung-yeop/post/ec68ca2d-dd69-4664-8486-1a34eaa84311/image.png" alt=""></p>
</blockquote>
<hr>
<p>우리는 이 값을 깃허브에 저장해서 암호화된 키 값으로 사용하면 된다.</p>
<p>이렇게 사용할 수 있는 이유는 Config Server에서 Github에 저장된 암호화된 Key를 가져올 때, 이전에 생성한 KeyStore를 이용하여 복호화를 진행하기 때문에 결과적으로 decrypt된 값을 사용할 수 있다.</p>
<p>참고로 Github에 암호화된 값을 저장할 때는 <code>{ciper}</code> 라는 Prefix를 붙여서 저장하면 된다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 포스팅에서는 Spring Cloud Config Server에 대해 자세히 알아봤다. </p>
<p>MSA 환경에서 여러 서비스의 설정을 중앙에서 관리하는 Config Server는 개발 생산성과 시스템 안정성을 크게 향상시키는 중요한 컴포넌트다.</p>
<p>Config Server와 메시지 브로커를 함께 사용하면 여러 서비스 간의 설정 정보를 효율적으로 관리하고 동기화할 수 있어, MSA의 복잡성을 줄이는 데 큰 도움이 된다는 것을 기억하자 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] MSA (2) - API Gateway]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-2-API-Gateway</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-2-API-Gateway</guid>
            <pubDate>Sat, 08 Mar 2025 12:06:29 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>이전 포스팅에서 서비스 인스턴스의 정보를 관리하는 서비스 디스커버리에 대해서 정리해봤다.</p>
<p>이번에는 이 정보를 사용하여 여러가지 작업을 수행하는 API Gateway에 대해서 정리해보려고 한다 👀</p>
<hr>
<h2 id="1-api-gateway">1. API Gateway</h2>
<p>API Gateway는 MSA 환경에서 클라이언트와 마이크로서비스 사이에 위치하여 모든 API 요청의 단일 진입점 역할을 하는 컴포넌트이다. </p>
<p>간단하게 구조도를 그려보면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/9b7402e1-3c01-4150-9e7e-b04303872365/image.png" alt=""></p>
<p>즉, Client는 서비스 인스턴스의 실제 주소를 바라보는 것이 아니라 오직 API Gateway만 바라보게 된다.</p>
<p>이후, API Gateway는 Service Discovery(Eureka Server)로부터 서비스의 위치를 파악하고 API 요청을 적절하게 라우팅해주는 역할을 수행한다.</p>
<p>그렇다면 Gateway는 어떻게 사용할 수 있을까?</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/5251ccbd-eddd-439d-b1f3-e11f2617d1cf/image.png" alt=""></p>
<p>우선 사진과 같이 Gateway 의존성을 추가하여 프로젝트를 설정하면서 시작하자</p>
<h3 id="💡-applicationyml">💡 application.yml</h3>
<p>Gateway도 마찬가지로 설정 파일에서 대부분의 작업이 수행된다.</p>
<p>설정 파일에서는 라우팅 정보를 관리하며, 필요시 필터 정보도 추가할 수 있다.</p>
<p>우선 설정 파일을 살펴보자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">server:
  port: 8000
&gt;
spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/login
            - Method=POST
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?&lt;segment&gt;.*), /$\{segment}
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/users
            - Method=POST
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?&lt;segment&gt;.*), /$\{segment}
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/actuator/**
            - Method=GET,POST
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?&lt;segment&gt;.*), /$\{segment}
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/**
            - Method=GET
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?&lt;segment&gt;.*), /$\{segment}
            - AuthorizationHeaderFilter
&gt;        
        - id: order-service
          uri: lb://ORDER-SERVICE
          predicates:
            - Path=/order-service/**
          filters:
            - RewritePath=/order-service/(?&lt;segment&gt;.*), /$\{segment}
&gt;            
      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Cloud Gateway Global Filter
            preLogger: true
            postLogger: true
&gt;
eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:8761/eureka</code></pre>
<p>샘플 코드가 조금 폭력적(?)일수도 있으나 천천히 살펴보자</p>
<p>우선, API Gateway 서버도 서비스 디스커버리에 정보를 추가하도록 eureka 관련 설정을 진행하고 있다.</p>
<p>다만, 여기서 <code>eureka.client.fetch-registry</code> 설정을 true로 해야만 라우팅이 가능하다는 것을 기억하자</p>
<p>그리고 이어서 중요한 설정 정보를 정리하면 다음과 같다.</p>
<blockquote>
<p><strong>server.port</strong></p>
</blockquote>
<ul>
<li>API Gateway가 사용할 포트 번호</li>
</ul>
<hr>
<p><strong>spring.application.name</strong></p>
<ul>
<li>서비스 이름 (eureka에 등록될 때 사용)</li>
</ul>
<hr>
<p><strong>spring.cloud.gateway.routes</strong></p>
<ul>
<li>실제 라우팅 규칙 정의 부분이다.</li>
</ul>
<hr>
<p><strong>id</strong></p>
<ul>
<li>라우트의 고유 식별자이다.</li>
<li>동일한 식별자를 여러번 사용해도 실제로는 상관없다.</li>
</ul>
<hr>
<p><strong>uri</strong></p>
<ul>
<li>요청을 전달할 대상 서비스를 의미한다.</li>
<li>여기서 <strong>lb://는 로드 밸런싱을 의미</strong>한다.</li>
<li>만약, 정적 주소 (ex. <code>http://localhost:8080/~~</code> )를 사용한다면 여러개의 서비스를 등록하더라도 해당 주소로만 요청이 넘어가는 문제가 발생한다.</li>
<li>따라서, 특별한 경우가 아니면 <strong>lb://</strong> 를 prefix로 사용하자</li>
</ul>
<hr>
<p><strong>predicates</strong></p>
<ul>
<li>이 라우트에 적용할 조건을 정의하는 부분이다.</li>
<li>필터, 경로, HTTP 메소드 등이 여기서 정의된다.</li>
</ul>
<hr>
<p><strong>filters</strong></p>
<ul>
<li>요청/응답 변환 필터를 의미한다.</li>
</ul>
<hr>
<p><strong>RemoveRequestHeader</strong></p>
<ul>
<li>특정 헤더를 제거 (예: 쿠키)</li>
</ul>
<hr>
<p><strong>RewritePath</strong></p>
<ul>
<li>URL 경로를 재작성하는 부분이다.</li>
<li>정규표현식을 사용한다.</li>
</ul>
<hr>
<p><strong>AuthorizationHeaderFilter</strong></p>
<ul>
<li>커스텀 인증 필터를 정의하는 부분이다.</li>
</ul>
<hr>
<p><strong>spring.cloud.gateway.default-filters</strong></p>
<ul>
<li>모든 라우트에 적용되는 필터이다.</li>
</ul>
<p>이렇게 정리해보니 아마 독자가 읽기 어려울 것 같다는 생각이 든다.</p>
<p>그래서 하나의 라우트 정보를 사진으로 정리하면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/a09856bb-268e-4c9e-b41c-17f102233981/image.png" alt=""></p>
<hr>
<h2 id="2-api-gateway의-역할--커스텀-필터">2. API Gateway의 역할 &amp; 커스텀 필터</h2>
<p>들어온 요청을 적절하게 라우팅을 해주는 것이 API Gateway의 목표이다.</p>
<p>이전에 모놀리식 서비스를 만들어봤다면, token filter를 만들어서 적절한 요청인지 권한 확인을 진행하는 Spring Security를 적용해본 경험이 있을 것이다.</p>
<p>모든 요청은 API Gateway로 들어오기 때문에, 각 서비스마다 필터를 적용해주는 것보다는 API Gateway에 필터를 적용해서 보안관련 작업을 수행하는 것이 더 효율적이다.</p>
<p>따라서, 위 설정 파일에서도 살펴봤다시피 filters 부분에 여러가지 커스텀 필터를 등록할 수 있다.</p>
<h3 id="💡-filter-동작-과정">💡 Filter 동작 과정</h3>
<p>Client가 요청을 보내면 그 요청에 대해서 우선 pre-filter가 동작한다.
이후, 서비스에서 요청을 모두 처리하고 response를 받게되면 API Gateway에서 설정해준 post-filter가 동작하게 된다.</p>
<p>순서를 작성해보면 다음과 같다.</p>
<blockquote>
<p><strong>Filter 동작 과정 - (1)</strong>
<code>request</code> -&gt; <code>pre-filter</code> -&gt; <code>서비스 인스턴스에서 요청 처리</code> -&gt; <code>post-filter</code> -&gt; <code>response</code></p>
</blockquote>
<p>만약, GlobalFilter와 해당 라우트에 별도의 커스텀 필터도 적용된다면 다음과 같이 동작하게 된다.</p>
<blockquote>
<p><strong>Filter 동작 과정 - (2)</strong>
<code>request</code> -&gt; <code>global-pre-filter</code> -&gt; <code>custom-pre-filter</code> -&gt; <code>서비스 인스턴스에서 요청 처리</code>-&gt; <code>custom-post-filter</code> -&gt; <code>global-post-filter</code></p>
</blockquote>
<p>필터, 인터셉터, AOP를 동시에 사용하는 경우 동작 과정과 비슷하게 진행된다는 것을 기억하자!</p>
<h3 id="💡-customfilter">💡 CustomFilter</h3>
<p>권한 확인과 같은 SecurityFilter를 적용하기 위해서는 커스텀 필터를 직접 구현하고 등록해야 한다.</p>
<p>커스텀 필터의 기본적인 구조는 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Component
@Slf4j
public class CustomFilter extends AbstractGatewayFilterFactory&lt;CustomFilter.Config&gt; {
    public CustomFilter() {
        super(Config.class);
    }
&gt;
    @Override
    public GatewayFilter apply(Config config) {
        // Custom Pre Filter
        return (exchange, chain) -&gt; {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();
&gt;
            log.info(&quot;Custom PRE filter: request id -&gt; {}&quot;, request.getId());
&gt;
            // Custom Post Filter
            return chain.filter(exchange).then(Mono.fromRunnable(() -&gt; {
                log.info(&quot;Custom POST filter: response code -&gt; {}&quot;, response.getStatusCode());
            }));
        };
    }
&gt;
    public static class Config {
        // Put the configuration properties
    }
}</code></pre>
<p>여기서 스프링부트로 Spring Security를 사용해서 JWT 필터를 만들어봤다면 한가지 차이점을 발견했을수도 있다.</p>
<p>바로 <code>OncePerRequestFilter</code> 가 아닌 <code>AbstractGatewayFilterFactory</code> 를 상속한다는 것이다.</p>
<p>여기서 우리는 Spring MVC와 Spring WebFlux에 대해서 이해해야 한다.</p>
<h3 id="💡-spring-mvc-vs-spring-webflux">💡 Spring MVC vs Spring WebFlux</h3>
<p>Spring MVC는 이전에 많이 봐서 이해되지만, Spring WebFlux는 어떤 내용일까?</p>
<p>위에서 상속하는 필터의 내용이 다른 이유는 기반 기술 스택의 차이에서 비롯된다.</p>
<blockquote>
<p><strong>Spring MVC (서블릿 기반)</strong></p>
</blockquote>
<ul>
<li>내장 서버: Tomcat (서블릿 컨테이너)</li>
<li>처리 방식: <strong>동기식</strong>, 블로킹 I/O</li>
<li>필터 타입: OncePerRequestFilter, HandlerInterceptor</li>
<li><strong>요청/응답 객체: HttpServletRequest, HttpServletResponse</strong></li>
</ul>
<blockquote>
<p><strong>Spring WebFlux (리액티브 기반)</strong></p>
</blockquote>
<ul>
<li>내장 서버: Netty (비동기 이벤트 기반)</li>
<li>처리 방식: <strong>비동기식</strong>, 논블로킹 I/O</li>
<li>필터 타입: WebFilter, AbstractGatewayFilterFactory</li>
<li><strong>요청/응답 객체: ServerHttpRequest, ServerHttpResponse</strong></li>
</ul>
<p>Spring Cloud Gateway는 Spring WebFlux 기반으로 구축되어 있기 때문에, 모든 필터 로직이 리액티브 스타일로 작성되어야 한다. </p>
<p>이는 위 코드에서 apply 메서드를 살펴보면 HttpServlet이 아닌, ServerHttp를 사용하고 있는 모습을 볼 수 있다.</p>
<p>마찬가지로 chain.filter(exchange).then(...) 형식의 비동기 처리 방식으로 나타난다.</p>
<p>여기서 필터 체인이 <code>Mono&lt;Void&gt;</code> 타입을 반환하고 있는데, 이를 통해 비동기 처리를 가능하게 한다.
(JavaScript의 Promise와 비슷한 개념이다)</p>
<p>그렇다면 왜 Spring Gateway는 WebFlux 기반으로 구현되었을까?</p>
<blockquote>
<p><strong>성능 이점</strong></p>
</blockquote>
<ul>
<li>높은 동시성 처리: 적은 수의 스레드로 많은 요청을 처리할 수 있다.</li>
<li>자원 효율성: 스레드 블로킹이 적어 CPU와 메모리 사용이 효율적이다.</li>
</ul>
<p>특히 API Gateway는 모든 요청의 진입점으로 높은 부하를 받기 때문에, 이러한 <strong>비동기 모델이 적합</strong>하다.</p>
<p>이러한 이유로 Spring Cloud Gateway에서는 OncePerRequestFilter 대신 AbstractGatewayFilterFactory를 상속받아 필터를 구현하며, 모든 필터 로직이 리액티브 방식으로 작성되어야 한다는 것을 기억하자 👊</p>
<h3 id="💡-authorizationheaderfilter">💡 AuthorizationHeaderFilter</h3>
<p>위 내용을 기반으로 Jwt를 검증하는 코드의 일부는 다음과 같다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@Override
public GatewayFilter apply(Config config) {
    return (exchange, chain) -&gt; {
        ServerHttpRequest request = exchange.getRequest();
&gt;
        if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
            return onError(exchange, &quot;No authorization header&quot;, HttpStatus.UNAUTHORIZED);
        }
&gt;
        String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
        String jwt = authorizationHeader.replace(&quot;Bearer &quot;, &quot;&quot;);
&gt;
        if (!isJwtValid(jwt)) {
            return onError(exchange, &quot;JWT token is not valid&quot;, HttpStatus.UNAUTHORIZED);
        }
&gt;
        return chain.filter(exchange);
    };
}
&gt;
private Mono&lt;Void&gt; onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
    ServerHttpResponse response = exchange.getResponse();
    response.setStatusCode(httpStatus);
    log.error(err);
&gt;
    byte[] bytes = &quot;The requested token is invalid.&quot;.getBytes(StandardCharsets.UTF_8);
    DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
    return response.writeWith(Flux.just(buffer));
}</code></pre>
<hr>
<p><strong><code>return chain.filter</code> 반환 타입</strong>
<img src="https://velog.velcdn.com/images/sung-yeop/post/8f0c4a2c-21f1-4cdd-9f11-831c403e3156/image.png" alt=""></p>
<p>JWT 검증 로직을 apply의 pre-filter 부분에 작성해주면 된다.</p>
<p>여기서 우리가 눈여겨 봐야할 것은 에러를 반환하기 위해 작성된 onError 메서드의 반환 타입이 <code>Mono&lt;Void&gt;</code> 라는 것이다.</p>
<p>또한, post-filter는 별도로 사용하지 않으며 마찬가지로 반환타입이 비동기 처리에 사용되는 <code>Mono</code> 타입이라는 것을 기억해두자!</p>
<h3 id="💡-globalfilter">💡 GlobalFilter</h3>
<p>GlobalFilter는 사진으로 한번 정리해보자
위에서 커스텀 필터 관련 내용을 정리했기 때문에 크게 이해하는데 문제는 없을 것이다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/b1850282-caae-4769-aaba-db194a7c4264/image.png" alt=""></p>
<p>한가지 추가된 내용이라면 application.yml에 작성한 내용을 매개변수로 전달받아서 로그찍는데 사용했다는 것이다.</p>
<hr>
<h2 id="3-테스트">3. 테스트</h2>
<p>우리가 이전에 API Gateway를 설정하는 과정에서 <code>lb://</code> 를 사용하여 라우트의 uri를 정의했던 것을 기억할 것이다.</p>
<p>여기서 lb를 사용하게 되면 기본적으로 라운드 로빈 방식으로 로드밸런싱을 진행한다.</p>
<h3 id="💡-인스턴스-띄우기">💡 인스턴스 띄우기</h3>
<p>여러개의 인스턴스를 띄우고 한번 테스트해보자</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/57562b69-8ece-4e9a-a6ca-421487afa2c7/image.png" alt=""></p>
<p>우선 <code>MY-FIRST-SERVICE</code> 를 3개 띄워서 Eureka에 등록한 상황이다.</p>
<h3 id="💡-동일한-path로-동시에-3개-접속">💡 동일한 Path로 동시에 3개 접속</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/3c954288-40f6-42b0-9154-8a61df560039/image.png" alt=""></p>
<p>다음으로 <code>localhost:8000/first-service/check</code> 로 접근하면 위와 같은 화면이 나온다.</p>
<p>여기서 8000은 Gateway Port Number이다.</p>
<p>서비스에 직접 접근하는게 아니라 Gateway에 접근하면 알아서 라우팅을 진행해줄 것이다.</p>
<p>또한, API Gateway에 first-service관련 설정은 다음과 같다.</p>
<pre><code class="language-yaml">- id: first-service
  uri: lb://MY-FIRST-SERVICE
  predicates:
    - Path=/first-service/**
  filters:
    - AddRequestHeader=first-request, first-request-header-by-yaml
    - AddResponseHeader=first-response, first-response-header-from-yaml
    - CustomFilter</code></pre>
<p>따라서, first-service로 접근하면 API Gateway가 라우팅을 진행해줄 것이다.</p>
<p>위 사진과 같이 path로 들어갔더니 각자 다른 3개의 포트를 사용하고 있는 모습을 볼 수 있다.</p>
<p>실제로 하나의 페이지에서 계속해서 새로고침을 해보면 라운드 로빈 방식으로 로드밸런싱이 진행되기 때문에 포트번호가 번갈아가면서 바뀌는 모습을 볼 수 있다.</p>
<h3 id="💡-인스턴스-하나-종료시키기">💡 인스턴스 하나 종료시키기</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/0432f8a4-cbeb-4a19-bde6-f46c0f374bbf/image.png" alt=""></p>
<p>사진과 같이 인스턴스를 종료시키고 새로고침을 누르다보면 에러페이지가 뜨는 모습을 볼 수 있다.</p>
<p>즉, 다른 사람은 정상적인 페이지를 보고 있으나 특정 클라이언트는 에러 페이지를 보고 있는 모습을 상상할 수 있을 것이다.</p>
<p>이러한 문제가 발생하는 이유는 Service Discovery(Eureka)가 서비스가 종료되었음을 바로 확인하지 못해서 그런것이다.</p>
<p>즉, 풀링 작업을 30초마다 주기적으로 수행하지만, 서비스가 내려가고 풀링이 진행되지 않은 시간동안은 오류 페이지를 보여주는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/84e07c2c-a58d-4412-9810-287af6a1a881/image.png" alt=""></p>
<p>실제로 계속 새로고침을 하다보면 위 사진과 같이 다시 정상적으로 페이지 접속이 가능하다.</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 포스팅에서는 API Gateway의 개념을 살펴보고 설정과 커스텀 필터 구현에 대해서 정리해봤다.</p>
<p>API Gateway는 MSA 환경에서 클라이언트와 서비스 인스턴스 사이의 중간 계층으로, 모든 API 요청의 단일 진입점 역할을 수행한다. </p>
<p>여기서 우리가 중요하게 기억해야 할 내용들을 다시 정리해보면</p>
<blockquote>
<p>API Gateway는 lb:// 형식의 URI를 사용해 동적으로 라우팅이 가능하며, 기본적으로 라운드 로빈 방식의 로드 밸런싱을 제공한다는 것</p>
</blockquote>
<blockquote>
<p>Spring Cloud Gateway는 WebFlux 기반으로 동작하므로, 필터의 반환 타입은 Mono여야 하며, 비동기적인 처리가 가능하다는 것,</p>
</blockquote>
<blockquote>
<p>커스텀 필터를 구현할 때는 OncePerRequestFilter가 아닌 AbstractGatewayFilterFactory를 상속해야 한다는 것</p>
</blockquote>
<p>정도가 있을 것 같다.</p>
<p>이번 포스팅을 통해 Gateway의 기본 개념과 설정 방법을 이해할 수 있으면 좋겠다 👊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LG CNS AM Inspire Camp 1기] MSA (1) - MSA와 서비스 디스커버리]]></title>
            <link>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-1-MSA%EC%99%80-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%94%94%EC%8A%A4%EC%BB%A4%EB%B2%84%EB%A6%AC</link>
            <guid>https://velog.io/@sung-yeop/LG-CNS-AM-Inspire-Camp-1%EA%B8%B0-MSA-1-MSA%EC%99%80-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%94%94%EC%8A%A4%EC%BB%A4%EB%B2%84%EB%A6%AC</guid>
            <pubDate>Sat, 08 Mar 2025 12:04:00 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">INTRO</h1>
<p>오랜만에 포스팅을 쓰는 것 같다 😅</p>
<p>최근 MSA(마이크로서비스 아키텍처) 관련 강의를 들으면서 여러 흥미로운 개념들을 접하게 되었고, 이전보다 더 명확하게 이해하게 된 것 같아 이제부터 정리해보려고 한다.</p>
<p>이번 포스팅에서는 MSA의 기본 개념을 다시 한번 살펴보고, 이 아키텍처에서 핵심적인 역할을 하는 서비스 디스커버리(Service Discovery)에 대해 알아보자 👀</p>
<hr>
<h2 id="1-msa-아키텍처">1. MSA 아키텍처</h2>
<p>MSA 아키텍처는 알다시피, 하나의 서비스로 구현된 모놀리식 서비스와는 다르게 여러개의 서비스로 구성된 아키텍처를 의미한다.</p>
<p>물론, MSA 아키텍처가 모놀리식 아키텍처보다 항상 좋다는 것은 절대 아니다.</p>
<p>상황에 따라서, 모놀리식 아키텍처로 서비스를 구현해야하는 경우도 분명히 존재한다.</p>
<p>하지만, Cloud Native 환경에서 서비스를 구성하는 경우, 주로 MSA 아키텍처가 사용된다.</p>
<h3 id="💡-msa-구조도">💡 MSA 구조도</h3>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/ed2e30d6-9d91-4d4e-abf1-1278d7459775/image.png" alt=""></p>
<p>위 그림에서 볼 수 있듯이, MSA 아키텍처는 각각의 독립적인 서비스(Instance 1, Instance n)들이 API를 통해 통신하며 전체 서비스를 구성한다.</p>
<p>MSA 아키텍처를 사용하면 서비스를 독립적으로 개발, 배포, 확장할 수 있다는 장점이 있다.</p>
<p>이러한 장점은 당연히 필요한 서비스만 선택적으로 스케일링할 수 있으므로 리소스 효율성이 높아진다는 것을 의미한다.</p>
<p>또한, 장애가 발생하더라도 전체 시스템으로 확산되지 않는다는 장점이 존재한다.</p>
<h3 id="💡-msa를-적용하려면">💡 MSA를 적용하려면..?</h3>
<p>하지만 위에서 언급한 장점들 이면에는 다음과 같은 문제를 고려해야 한다.</p>
<blockquote>
<p><strong>고려해야할 내용</strong></p>
</blockquote>
<ul>
<li>분산 시스템 복잡성: 서비스 간 통신, 일관성 유지 등의 문제가 발생한다.</li>
<li>네트워크 지연: 서비스 간 통신이 네트워크를 통해 이루어지므로 지연이 발생할 수 있다.</li>
<li>데이터 일관성: 각 서비스가 독립적인 데이터베이스를 가질 경우, 데이터 일관성 유지가 어려워진다.</li>
<li>운영 복잡성: 다수의 서비스를 모니터링하고 관리하는 것이 복잡해진다.</li>
</ul>
<p>이는 하나의 서비스로 운영하는 모놀리식과 여러 개의 서비스로 운영하는 MSA 간에 발생할 수 있는 주요 차이점이다.</p>
<p>사실, 이런 문제들을 해결하기 위해 서비스 디스커버리, API 게이트웨이, 로드 밸런싱, 서킷 브레이커 등의 다양한 패턴과 기술이 등장하게 되었고, 위에서 살펴본 사진에는 이러한 솔루션들이 모두 포함되어 있는 것이다. 
(그래서 복잡해보일 수 있다)</p>
<p>이제부터 포스팅을 통해 이러한 기술들을 하나씩 정리해나갈 예정이다!</p>
<hr>
<h2 id="2-서비스-디스커버리">2. 서비스 디스커버리</h2>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/f631023f-4ba6-4faf-90d9-dfcd591cac3a/image.png" alt=""></p>
<p>이번 포스팅에서는 서비스 디스커버리라는 개념에 대해서 정리하고 코드로 살펴보려고 한다.</p>
<p>사진을 기준으로 보면 빨간색으로 표시된 부분이다.</p>
<p>그렇다면 서비스 디스커버리는 뭘까?</p>
<h3 id="💡-service-discovery란">💡 Service Discovery란</h3>
<p>MSA 아키텍처 특성상 여러개의 서비스가 클라우드 내에서 동작하고 있을 것이다.</p>
<p>만약, 클라이언트로부터 API 요청이 들어온다면 누군가는 이 요청을 어느 서비스로 보내야할지 알고있어야 한다.</p>
<p>여기서 알고 있어야하는 정보는 다시말하면 라우팅 정보(IP주소 + 포트넘버)를 의미하게 된다.</p>
<p>즉, 서비스 디스커버리는 현재 동작하는 서비스들의 라우팅 정보를 관리하는 역할을 수행하는 서비스이다.</p>
<hr>
<h2 id="3-eureka-server">3. Eureka Server</h2>
<p>Spring 프레임워크에서 Service Discovery로는 Eureka를 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/fa3d4bd3-e7b9-45be-abd2-83cb00eceaa5/image.png" alt=""></p>
<p>Eureka는 Netflix에서 개발한 서비스 디스커버리 도구로, Spring Cloud에 통합되어 있어 Spring 기반 MSA에서 쉽게 사용할 수 있다.</p>
<p>Eureka를 사용하기 위해서는 우선 위 사진과 같이 Eureka Server 의존성을 추가해야 한다.</p>
<h3 id="💡-applicationyml">💡 application.yml</h3>
<p>다음으로 Eureka Server의 설정 파일(application.yml)을 살펴보자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">server:
  port: 8761
&gt;
spring:
  application:
    name: discoveryservice
&gt;
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false</code></pre>
<p>여기서 중요한 설정을 살펴보면 다음과 같다.</p>
<blockquote>
<p><strong>server.port</strong></p>
</blockquote>
<ul>
<li>Eureka 서버가 실행될 포트 번호이다.</li>
<li>기본적으로 8761 포트 번호를 사용한다.</li>
</ul>
<hr>
<p><strong>spring.application.name</strong></p>
<ul>
<li>서비스의 이름을 설정하는 부분이다.</li>
</ul>
<hr>
<p><strong>eureka.client.register-with-eureka</strong></p>
<ul>
<li>Eureka 서버 자신을 Eureka에 등록할지 여부를 결정한다.</li>
<li>서버 자체는 등록할 필요 없으므로 false로 설정한다.</li>
</ul>
<hr>
<p><strong>eureka.client.fetch-registry</strong></p>
<ul>
<li>레지스트리 정보를 로컬에 캐싱할지 여부를 결정한다.</li>
<li>서버 자체는 다른 서비스를 찾을 필요 없으므로 false로 설정한다.</li>
</ul>
<p>물론 위 설정말고도 설정할 수 있는 옵션은 다양하다.</p>
<p>디스커버리 서버는 서비스들의 정보를 주기적으로 받아와서 정상적으로 동작하는 서비스인지 판단하는 풀링을 진행하게 된다.</p>
<p>이때, 사용할 수 있는 옵션이 <code>client:registry-fetch-interval-seconds: 30</code> 이 있는데 default로 30초마다 서비스의 정보를 계속해서 업데이트한다.</p>
<p>이처럼, 만약 커스텀하고 싶은 부분이 있다면 <a href="https://cloud.spring.io/spring-cloud-netflix/reference/html/appendix.html">유레카 설정 정보 페이지</a>를 살펴보고 커스텀하자!</p>
<h3 id="💡-어노테이션-추가">💡 어노테이션 추가</h3>
<p>마지막으로 해당 서비스가 명시적으로 Eureka 서버임을 알려주기 위해서는 다음과 같이 코드를 작성하면 된다.</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-java">@SpringBootApplication
@EnableEurekaServer
public class ServiceDiscoveryApplication {
    public static void main(String[] args) {
        SpringApplication.run(ServiceDiscoveryApplication.class, args);
    }
}</code></pre>
<p>서비스의 BootApplication에 <code>@EnableEurekaServer</code> 라는 어노테이션을 명시적으로 추가해주면 Eureka 서버 설정은 마무리된다.</p>
<hr>
<h2 id="4-eureka-client">4. Eureka Client</h2>
<p>위에서 살펴본 것은 Eureka <strong>Server</strong>이다.</p>
<p>이제부터는 Eureka Client에 대해서 살펴보자</p>
<p>Eureka Client는 Eureka Server에 자신의 정보를 등록하고, 다른 서비스의 정보를 조회하는 역할을 한다.</p>
<p>즉, MSA 환경에서 각 마이크로서비스는 Eureka Client로 동작하게 된다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/5aff50be-efa4-402c-8533-258fc03d29c0/image.png" alt=""></p>
<p>각 서비스의 정보를 Eureka Server에 등록하기 위해서는 위 사진과 같이 Eureka Discovery Client 의존성을 각 서비스마다 추가해야 한다.</p>
<h3 id="💡-applicationyml-1">💡 application.yml</h3>
<p>우선 설정 정보를 살펴보자</p>
<blockquote>
<p><strong>Sample Code</strong></p>
</blockquote>
<pre><code class="language-yaml">server:
  port: 0
&gt;
spring:
  application:
    name: order-service
  ...
&gt;
eureka:
  instance:
    instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka</code></pre>
<p>여기서 중요한 설정 정보를 정리해보면 다음과 같다.</p>
<blockquote>
<p><strong>server.port:0</strong></p>
</blockquote>
<ul>
<li>랜덤 포트를 사용하도록 설정한다. </li>
<li>이렇게 하면 같은 서버에서 여러 인스턴스를 실행할 때 포트 충돌 문제를 방지할 수 있다.</li>
</ul>
<hr>
<p><strong>spring.application.name</strong></p>
<ul>
<li>서비스의 이름을 지정한다.</li>
<li><strong>이 이름은 Eureka에 등록될 때 사용되며, 다른 서비스에서 이 서비스를 찾을 때 사용된다.</strong></li>
</ul>
<hr>
<p><strong>eureka.instance.instance-id</strong></p>
<ul>
<li>Eureka에 등록될 때 사용할 인스턴스 ID를 설정한다. </li>
<li>${random.value}를 사용하여 매번 고유한 ID가 생성되도록 한다.</li>
<li>이렇게 하면 동일한 서비스의 여러 인스턴스를 구분할 수 있다.</li>
</ul>
<hr>
<p><strong>eureka.client.register-with-eureka</strong></p>
<ul>
<li>true로 설정하여 이 서비스를 Eureka 서버에 등록한다.</li>
</ul>
<hr>
<p><strong>eureka.client.fetch-registry</strong> </p>
<ul>
<li>true로 설정하여 Eureka 서버로부터 레지스트리 정보를 가져온다.</li>
<li>이를 통해 서비스 인스턴스에서도 디스커버리 서버의 정보를 가져오기 때문에다른 서비스의 위치를 알 수 있다.</li>
</ul>
<hr>
<p><strong>eureka.client.service-url.defaultZone</strong></p>
<ul>
<li>Eureka 서버의 위치를 지정한다.</li>
<li>여기서는 로컬 Eureka 서버(127.0.0.1:8761)를 사용한다.</li>
</ul>
<p>이러한 설정을 통해 MSA 환경에서 서비스 디스커버리가 가능해지며, 같은 서비스의 여러 인스턴스를 쉽게 확장할 수 있게되는 것이다.</p>
<p>결국, 각 인스턴스는 랜덤 포트와 고유한 인스턴스 ID를 가지며, 모두 동일한 서비스 이름으로 Eureka에 등록되어 클라이언트에서는 논리적으로 하나의 서비스로 보이게 된다.</p>
<p><img src="https://velog.velcdn.com/images/sung-yeop/post/505684c2-7000-494b-b231-75d50015a509/image.png" alt=""></p>
<p>실제로 유레카 서버포트인 <code>localhost:8761</code> 로 접근하면 동일한 서비스가 2개 등록된 모습을 볼 수 있다!</p>
<p>(참고로 스프링부트에서 동일한 서비스를 여러개 띄우기 위해서는 빌드된 jar 파일을 별도로 실행시켜줘야 한다.)</p>
<h3 id="💡-serverport10000">💡 server.port:10000</h3>
<p>만약 서버 포트를 10000으로 고정하면 어떻게될까?</p>
<p>어차피 서비스는 <code>eureka.instance.instance-id</code> 에서 설정한 바와 같이 랜덤 인스턴스 ID가 할당되어 Eureka 서버에 등록되기 때문에 문제가 없다.</p>
<p>하지만, 서버 포트를 10000으로 고정한다면 동일한 서비스를 동일한 서버에서 2개이상 띄우게 되는 경우 포트 충돌이 발생하게 된다.</p>
<p>따라서, 여러개의 인스턴스를 생성하는 서비스라면 포트 번호를 랜덤으로 설정해주는 0번을 주로 사용한다는 것을 기억하자!</p>
<hr>
<h1 id="outro">OUTRO</h1>
<p>이번 포스팅에서는 서비스 디스커버리의 역할을 수행하는 Eureka Server 설정과 인스턴스를 등록하기 위한 Eureka Client 서비스의 설정 파일을 살펴봤다.</p>
<p>이처럼 Eureka 서버와 클라이언트를 설정하면 서비스 간 자동 검색이 가능해지고, 동적으로 서비스 인스턴스 추가/제거가 가능한 MSA 환경을 구축할 수 있다.</p>
<p>막상 보면 크게 어려운 내용은 없으나, 설정 정보가 어떤 역할을 하는지는 기억해두자 👊</p>
]]></description>
        </item>
    </channel>
</rss>