<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>행복한 시지프 - 서버</title>
    <link>https://happysisyphe-server.tistory.com/</link>
    <description>행복한 시지프, 서버 개발 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Thu, 6 Aug 2026 03:24:34 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>행복한 시지프 - 서버</managingEditor>
    <image>
      <title>행복한 시지프 - 서버</title>
      <url>https://tistory1.daumcdn.net/tistory/8516329/attach/3f9755dfcb2940c38ce007096eff9dcd</url>
      <link>https://happysisyphe-server.tistory.com</link>
    </image>
    <item>
      <title>DB 를 Grafana 와 연동해서 대시보드 만들기</title>
      <link>https://happysisyphe-server.tistory.com/8</link>
      <description>&lt;h2 data-heading=&quot;들어가며&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 내에 분업을 하다보면, 데이터가 잘 공유되는 것이 중요합니다. 유저는 몇명인지, 하루에 몇명이 사용하는지, 우리가 세운 가설이 동작하고 있는지 등이요. 하지만, DB 는 보통 개발자의 몫이고, 실제로 확인하고 설계하는 것은 PM/PD 의 역할입니다. 매번 주요 데이터를 조회해서 전달을 해주기가 어렵고, 좋은 협업이 아닙니다. 팀 내 누구나 데이터에 접근 가능하게 하는 구조가 필요합니다. 그것이 바로 대시보드가 필요한 이유입니다. 오늘은 DB 를Grafana 대시보드로 표현하는 과정을 써봅니다.&lt;/p&gt;
&lt;h2 data-heading=&quot;0. 원리 그리기&quot; data-ke-size=&quot;size26&quot;&gt;0. 원리 시각화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 전체 원리를 그려봅니다. 일반적인 애플리케이션의 흐름과 Grafana 시각화 흐름이 동일하다는 것에 주목했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 가 있고, DB 를 직접 요청하는 WAS 가 있고, WAS 에 API 요청하는 Client 가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;애플리케이션을 그렇게 구성했듯, Grafana 도 동일합니다. 똑같은 DB 가 있고, 그 DB 에 SELECT 를 날리는 Grafana 서버가 있습니다. 그리고 조회 결과물을 활용해서 대시보드 화면을 그리는 Client 가 존재하죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시각화 해보면 아래와 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mermaid-diagram.svg&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;267&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rw2g6/dJMcadvZwpK/evxqPzq1OcWZmNEBUz1eK1/tfile.svg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rw2g6/dJMcadvZwpK/evxqPzq1OcWZmNEBUz1eK1/tfile.svg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rw2g6/dJMcadvZwpK/evxqPzq1OcWZmNEBUz1eK1/tfile.svg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Frw2g6%2FdJMcadvZwpK%2FevxqPzq1OcWZmNEBUz1eK1%2Ftfile.svg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1140&quot; height=&quot;267&quot; data-filename=&quot;mermaid-diagram.svg&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;267&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-heading=&quot;1. DB 계정 생성 및 권한 부여&quot; data-ke-size=&quot;size26&quot;&gt;1. DB 계정 생성 및 권한 부여&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 목적은 Grafana 가 저희의 DB 에 접근 가능하게 만드는 것입니다. DB 는 권한이 중요하고, 함부로 UPDATE 나 DELETE 권한을 주면 데이터 유실 위험이 생깁니다. 역할을 하는 만큼 최소한의 권한만 부여해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana 에서 DB 를 조회해서 대시보드를 만들 것이기 때문에, SELECT 권한만 부여합니다. SELECT 용 계정이 없기 때문에 새로 DB 계정을 만들고 권한을 부여합니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE USER 'grafana_readonly'@'%' IDENTIFIED BY 'password';

GRANT SELECT ON detoxmate_prod.* TO 'grafana_readonly'@'%';

FLUSH PRIVILEGES;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한이 잘 부여됐는지 학인까지 합니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SHOW GRANTS FOR 'grafana_readonly'@'%';
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-heading=&quot;2. Grafana 에서 MySQL Data Source 연동&quot; data-ke-size=&quot;size26&quot;&gt;2. Grafana 에서 MySQL Data Source 연동&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana 를 위한 DB 계정이 생성되었고, 이제 Grafana 에서 연동을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana 에서 Data sources 탭으로 갑니다. Add new data source 버튼을 통해 mysql 을 연결합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결이 완료되었다면, 아래 리스트에 추가됩니다. 이제 Grafana 가 저희 DB 에 SELECT 할 수 있는 상태가 된 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;생성된 이미지 1.png&quot; data-origin-width=&quot;1738&quot; data-origin-height=&quot;905&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btMxfB/dJMcai5aABf/qhBthe81lEay01kew7cPXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btMxfB/dJMcai5aABf/qhBthe81lEay01kew7cPXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btMxfB/dJMcai5aABf/qhBthe81lEay01kew7cPXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtMxfB%2FdJMcai5aABf%2FqhBthe81lEay01kew7cPXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1738&quot; height=&quot;905&quot; data-filename=&quot;생성된 이미지 1.png&quot; data-origin-width=&quot;1738&quot; data-origin-height=&quot;905&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-heading=&quot;3. 대시보드 만들기&quot; data-ke-size=&quot;size26&quot;&gt;3. 대시보드 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 대시보드를 만들어봅시다. Dashboard 탭에서 아래처럼 표를 만들 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1915&quot; data-origin-height=&quot;924&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bp1Gin/dJMcai5aAB7/Cke8UQTCTy3ueOq8N5kYT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bp1Gin/dJMcai5aAB7/Cke8UQTCTy3ueOq8N5kYT0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bp1Gin/dJMcai5aAB7/Cke8UQTCTy3ueOq8N5kYT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbp1Gin%2FdJMcai5aAB7%2FCke8UQTCTy3ueOq8N5kYT0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1915&quot; height=&quot;924&quot; data-origin-width=&quot;1915&quot; data-origin-height=&quot;924&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-heading=&quot;어떤 대시보드를 만들 것인가?&quot; data-ke-size=&quot;size23&quot;&gt;어떤 대시보드를 만들 것인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제부터 중요한 것은, 기술보다는 비즈니스 감각입니다. 어떤 데이터를 대시보드화해서 봐야할까? 팀 내에서 보고자하는 지표를 선정합니다. 팀의 가설이 무엇인지, 주요 지표가 무엇인지에 따라 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 가설은 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;타겟 유저가 지인과 그룹을 만들고 인증을 시작했으면, 2주 내 평균 스크린타임이 감소할 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 핵심적으로 봐야할 지표는 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유저별 평균 스크린타임 감소율&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 서비스에 유저가 유입되고 실제로 사용하는지를 보조적으로 봐야합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;누적 유저 수&lt;/li&gt;
&lt;li&gt;누적 그룹 수&lt;/li&gt;
&lt;li&gt;일일 인증 수&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희는 이렇게 4가지 지표를 보기로 했고, 대시보드를 구성했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;생성된 이미지 1 (1).png&quot; data-origin-width=&quot;1849&quot; data-origin-height=&quot;851&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/G5akT/dJMcacRqQHC/aDuEmbVXYds1uFhOUhOhTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/G5akT/dJMcacRqQHC/aDuEmbVXYds1uFhOUhOhTK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/G5akT/dJMcacRqQHC/aDuEmbVXYds1uFhOUhOhTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FG5akT%2FdJMcacRqQHC%2FaDuEmbVXYds1uFhOUhOhTK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1849&quot; height=&quot;851&quot; data-filename=&quot;생성된 이미지 1 (1).png&quot; data-origin-width=&quot;1849&quot; data-origin-height=&quot;851&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-heading=&quot;query 에 무엇을 넣어야 하는가?&quot; data-ke-size=&quot;size23&quot;&gt;query 에 무엇을 넣어야 하는가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 각각 대시보드를 구체적으로 어떻게 구성하는지 보도록 할게요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표현하고자 하는 데이터가 어떤 것이냐에 따라서 구성 방법이 다릅니다. 가령 바 그래프를 표현하려면, x 축은 시간, y 축은 수가 되겠죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, query 결과로 이런 테이블이 나와야 합니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 72px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px; width: 33.1395%;&quot;&gt;time&lt;/td&gt;
&lt;td style=&quot;height: 18px; width: 66.7442%;&quot;&gt;value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px; width: 33.1395%;&quot;&gt;2026-07-01&lt;/td&gt;
&lt;td style=&quot;height: 18px; width: 66.7442%;&quot;&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px; width: 33.1395%;&quot;&gt;2026-07-02&lt;/td&gt;
&lt;td style=&quot;height: 18px; width: 66.7442%;&quot;&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px; width: 33.1395%;&quot;&gt;2026-07-03&lt;/td&gt;
&lt;td style=&quot;height: 18px; width: 66.7442%;&quot;&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 query 는 이런 식으로 써서, 2개의 column 을 가진 table 을 뽑습니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SELECT
  day AS time,
  SUM(new_users) OVER (ORDER BY day) AS value
FROM (
  SELECT
    DATE(created_at) AS day,
    COUNT(*) AS new_users
  FROM users
  GROUP BY DATE(created_at)
) daily
ORDER BY time
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-heading=&quot;마치며&quot; data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현업에서 일하면서 백엔드 개발자가 구성한 대시보드를 수도 없이 보았습니다. 실제로 인프라를 구축해본 적은 없지요. 뒷단에 어떻게 돌아가고 있을지 상상하는게 어려웠습니다. 인프라란 그런 영역인 것 같습니다. 상상하기 어려워서 막연한 두려움이 있지만, 해보면 생각보다 간단한 작업인 경우가 많더라고요. AI 를 활용하면, Step by Step 으로 가이드를 받기도 쉬우니, 난이도가 한결 쉬워진 것 같습니다. 비즈니스적으로 필요한 요구사항을 하나씩 수행해 봐야겠습니다.&lt;/p&gt;</description>
      <category>서버</category>
      <author>행복한 시지프 - 서버</author>
      <guid isPermaLink="true">https://happysisyphe-server.tistory.com/8</guid>
      <comments>https://happysisyphe-server.tistory.com/8#entry8comment</comments>
      <pubDate>Sat, 25 Jul 2026 09:49:07 +0900</pubDate>
    </item>
    <item>
      <title>Promtail, Loki, Grafana 로그 파이프라인 디버깅 기록</title>
      <link>https://happysisyphe-server.tistory.com/7</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;thumbnail_1783234476705.jpg&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cciziY/dJMb99NHcwu/t8IMjCBIlyHVvtkof5Mnu0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cciziY/dJMb99NHcwu/t8IMjCBIlyHVvtkof5Mnu0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cciziY/dJMb99NHcwu/t8IMjCBIlyHVvtkof5Mnu0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcciziY%2FdJMb99NHcwu%2Ft8IMjCBIlyHVvtkof5Mnu0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;720&quot; data-filename=&quot;thumbnail_1783234476705.jpg&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-heading=&quot;들어가며&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-heading=&quot;들어가며&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디톡스메이트 서비스는 서버 에러를 Grafana에서 확인하고, Grafana Alert를 통해 Discord로 알림을 받고 있습니다. 그런데 며칠 동안 에러 알림이 오지 않았습니다. 이상해서 Grafana를 확인해보니, prod 백엔드 로그가 더 이상 쌓이지 않고 있었습니다. 처음에는 &quot;백엔드 로그가 안 찍히나?&quot;라고 생각했지만, 실제 원인은 백엔드 코드가 아니라 로그 수집 파이프라인과 디스크 용량 문제였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Grafana에 로그가 보이지 않는 상황에서 어디부터 확인했고, 어떤 단서로 원인을 좁혀갔는지 정리한 디버깅 기록입니다.&lt;/p&gt;
&lt;h2 data-heading=&quot;로그가 Discord까지 도달하는 흐름&quot; data-ke-size=&quot;size26&quot;&gt;로그가 Discord까지 도달하는 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 현재 서비스에서 로그가 어떻게 생성되고, Grafana와 Discord까지 도달하는지 큰 그림을 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2880&quot; data-origin-height=&quot;716&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1qV4O/dJMcagTFzgK/ZIRxZI0UXWiakG33hD1EzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1qV4O/dJMcagTFzgK/ZIRxZI0UXWiakG33hD1EzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1qV4O/dJMcagTFzgK/ZIRxZI0UXWiakG33hD1EzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1qV4O%2FdJMcagTFzgK%2FZIRxZI0UXWiakG33hD1EzK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2880&quot; height=&quot;716&quot; data-origin-width=&quot;2880&quot; data-origin-height=&quot;716&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana는 Spring Boot 로그 파일을 직접 읽지 않습니다. Spring Boot가 Logback으로 파일 로그를 남기고, Promtail이 이 파일을 읽어 Loki로 보냅니다. Grafana는 Loki에 쌓인 로그를 조회하고, Alert 조건에 걸리면 Discord로 알림을 보냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Grafana에 로그가 보이지 않는다는 것은 단순히 &quot;백엔드가 로그를 안 찍는다&quot;는 뜻이 아닙니다. 로그가 생성되고, 수집되고, 저장되고, 조회되는 과정 중 어딘가가 끊겼다는 뜻입니다.&lt;/p&gt;
&lt;h2 data-heading=&quot;문제 디버깅 과정&quot; data-ke-size=&quot;size26&quot;&gt;문제 디버깅 과정&lt;/h2&gt;
&lt;h3 data-heading=&quot;1. 프로세스가 살아 있는지 확인&quot; data-ke-size=&quot;size23&quot;&gt;1. 프로세스가 살아 있는지 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 WAS, Promtail, Loki 컨테이너가 살아 있는지 확인했습니다.&lt;/p&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;docker ps -a | grep -E 'detoxmate-prod|promtail|loki'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 모두 Up 상태였습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.65116%;&quot;&gt;Container ID&lt;/td&gt;
&lt;td style=&quot;width: 17.2093%;&quot;&gt;Image&lt;/td&gt;
&lt;td style=&quot;width: 14.0698%;&quot;&gt;Command&lt;/td&gt;
&lt;td style=&quot;width: 10.3488%;&quot;&gt;Created&lt;/td&gt;
&lt;td style=&quot;width: 9.18605%;&quot;&gt;Status&lt;/td&gt;
&lt;td style=&quot;width: 27.5581%;&quot;&gt;Ports&lt;/td&gt;
&lt;td style=&quot;width: 11.8605%;&quot;&gt;Name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.65116%;&quot;&gt;78af5ba75eb8&lt;/td&gt;
&lt;td style=&quot;width: 17.2093%;&quot;&gt;detoxmate:prod&lt;/td&gt;
&lt;td style=&quot;width: 14.0698%;&quot;&gt;java -jar app.jar&lt;/td&gt;
&lt;td style=&quot;width: 10.3488%;&quot;&gt;11 days ago&lt;/td&gt;
&lt;td style=&quot;width: 9.18605%;&quot;&gt;Up 11 days&lt;/td&gt;
&lt;td style=&quot;width: 27.5581%;&quot;&gt;0.0.0.0:8081-&amp;gt;8081/tcp, :::8081-&amp;gt;8081/tcp&lt;/td&gt;
&lt;td style=&quot;width: 11.8605%;&quot;&gt;detoxmate-prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.65116%;&quot;&gt;4ee90453ec91&lt;/td&gt;
&lt;td style=&quot;width: 17.2093%;&quot;&gt;grafana/loki:latest&lt;/td&gt;
&lt;td style=&quot;width: 14.0698%;&quot;&gt;/usr/bin/loki -conf...&lt;/td&gt;
&lt;td style=&quot;width: 10.3488%;&quot;&gt;4 weeks ago&lt;/td&gt;
&lt;td style=&quot;width: 9.18605%;&quot;&gt;Up 4 weeks&lt;/td&gt;
&lt;td style=&quot;width: 27.5581%;&quot;&gt;0.0.0.0:3100-&amp;gt;3100/tcp, :::3100-&amp;gt;3100/tcp&lt;/td&gt;
&lt;td style=&quot;width: 11.8605%;&quot;&gt;loki&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.65116%;&quot;&gt;19eb656fe48b&lt;/td&gt;
&lt;td style=&quot;width: 17.2093%;&quot;&gt;grafana/promtail:latest&lt;/td&gt;
&lt;td style=&quot;width: 14.0698%;&quot;&gt;/usr/bin/promtail -...&lt;/td&gt;
&lt;td style=&quot;width: 10.3488%;&quot;&gt;2 months ago&lt;/td&gt;
&lt;td style=&quot;width: 9.18605%;&quot;&gt;Up 3 weeks&lt;/td&gt;
&lt;td style=&quot;width: 27.5581%;&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;width: 11.8605%;&quot;&gt;promtail&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다음으로 Promtail 자체 로그를 확인했습니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;2. Promtail 로그 확인&quot; data-ke-size=&quot;size23&quot;&gt;2. Promtail 로그 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana에 로그가 안 보일 때 Promtail을 먼저 보는 이유는 Promtail이 로그 파이프라인의 입구이기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;docker logs promtail 2&amp;gt;&amp;amp;1 | tail -200
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 에러가 확인되었습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;level=error ts=2026-05-05T11:34:57.032284734Z caller=positions.go:179 msg=&quot;error writing positions file&quot; error=&quot;open /tmp/.positions.yaml9015774036798675289: no space left on device&quot;  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 no space left on device입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Promtail은 로그 파일을 읽으면서 &quot;어디까지 읽었는지&quot;를 positions 파일에 저장합니다. 그런데 디스크 공간이 부족하면 이 상태 파일을 쓸 수 없습니다. 이 단서 때문에 문제를 Promtail 설정 문제가 아니라 서버 디스크 부족 문제로 다시 바라보게 되었습니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;3. 디스크 용량 확인&quot; data-ke-size=&quot;size23&quot;&gt;3. 디스크 용량 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스크 상태를 확인했습니다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;df -h
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 다음과 같았습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Filesystem        Size  Used Avail Use% Mounted on  
/dev/nvme0n1p1    8.0G  7.8G  183M  98% /  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 루트 디스크가 98% 사용 중이었습니다. Promtail이 상태 파일을 쓰지 못한 이유가 디스크 부족일 가능성이 높아졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루트 파일시스템에서 큰 용량을 차지하는 항목을 찾았습니다.&lt;/p&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;sudo du -h -x / | sort -h | tail -30
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과 중 눈에 띄는 항목은 다음이었습니다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;1.2G    /var/lib/docker/containers/4ee90453ec91261079736b2a65be3e043fd4c554f7dc95d1836079165bd81730  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/var/lib/docker/containers/... 경로이므로 Docker 컨테이너 관련 파일입니다. 어떤 컨테이너인지 확인했습니다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;docker inspect 4ee90453ec91261079736b2a65be3e043fd4c554f7dc95d1836079165bd81730 --format '{{.Name}} {{.Image}}'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 다음과 같았습니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;/loki sha256:d060bcc2feb7d60999bdf4032b56f5fd2d8bcdd013ce3134050a91c6cbe68d75  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 Loki 컨테이너 디렉터리가 1.2GB를 차지하고 있었습니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;4. Loki 컨테이너 로그 파일 확인&quot; data-ke-size=&quot;size23&quot;&gt;4. Loki 컨테이너 로그 파일 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Loki 컨테이너가 무엇 때문에 커졌는지 확인했습니다.&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;docker inspect loki --format '{{.LogPath}}'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 다음과 같았습니다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;/var/lib/docker/containers/4ee90453ec91261079736b2a65be3e043fd4c554f7dc95d1836079165bd81730/4ee90453ec91261079736b2a65be3e043fd4c554f7dc95d1836079165bd81730-json.log  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파일은 Loki에 저장된 애플리케이션 로그 데이터가 아니라, docker logs loki로 볼 수 있는 Loki 컨테이너 자체의 로그입니다. 어떤 로그가 쌓이고 있는지 확인했습니다.&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;docker logs --tail 50 loki
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출력은 다음과 같은 형태였습니다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;uploading table index_20613  
finished uploading table index_20613  
cleaning up unwanted indexes from table index_20613  
...  
starting recalculate owned streams job  
completed recalculate owned streams job  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Loki 프로세스 자체의 운영 로그입니다. Loki가 index table을 업로드하고 정리하는 작업을 info level로 stdout에 출력하고 있었고, Docker는 이 stdout을 기본 json-file 로그로 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 중요하지 않은 로그가 쌓이고 있었고, Docker 로그 로테이션이 설정되어 있지 않아 이 파일이 계속 커졌습니다. 그 결과 루트 디스크가 거의 가득 찼고, Promtail이 상태 파일을 쓰지 못하면서 Grafana에 새 로그가 보이지 않게 되었습니다.&lt;/p&gt;
&lt;h2 data-heading=&quot;해결책&quot; data-ke-size=&quot;size26&quot;&gt;해결책&lt;/h2&gt;
&lt;h3 data-heading=&quot;1. 즉시 Loki의 Docker 로그 파일 비우기&quot; data-ke-size=&quot;size23&quot;&gt;1. 즉시 Loki의 Docker 로그 파일 비우기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Loki 컨테이너의 Docker 로그 파일을 비워 디스크 공간을 확보했습니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;sudo truncate -s 0 &quot;$(docker inspect loki --format '{{.LogPath}}')&quot;  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 명령은 Loki 컨테이너 자체의 stdout 로그 파일만 비웁니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;2. Docker 로그 로테이션 설정&quot; data-ke-size=&quot;size23&quot;&gt;2. Docker 로그 로테이션 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉시 복구만으로는 같은 문제가 다시 생길 수 있습니다. Docker가 컨테이너 stdout/stderr 로그를 무제한으로 저장하지 않도록 로그 로테이션을 설정했습니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;sudo tee /etc/docker/daemon.json &amp;gt; /dev/null &amp;lt;&amp;lt;'EOF'
{
  &quot;log-driver&quot;: &quot;json-file&quot;,
  &quot;log-opts&quot;: {
    &quot;max-size&quot;: &quot;50m&quot;,
    &quot;max-file&quot;: &quot;3&quot;
  }
}
EOF&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그 파일이 최대 50MB가 되도록 하고, 그 파일은 3개까지 유지합니다. 아예 제거할 수도 있겠지만, 이런 디버깅을 위해서 최근 로그는 남겨둡니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;3. Loki 로그 레벨 낮추기&quot; data-ke-size=&quot;size23&quot;&gt;3. Loki 로그 레벨 낮추기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 로그의 문제가 info 수준의 로그를 모두 찍고 있기 때문입니다. docker-compose.yml 파일에서 Loki 로그 레벨을 warn으로 낮췄습니다.&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;command:  
  - -config.file=/etc/loki/local-config.yaml  
  - -log.level=warn  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 후 Loki만 다시 적용했습니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;docker-compose up -d loki&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-heading=&quot;결과&quot; data-ke-size=&quot;size26&quot;&gt;결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조치 후 Grafana에 prod 로그가 다시 정상적으로 쌓이기 시작했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Pasted image 20260705114622.png&quot; data-origin-width=&quot;2394&quot; data-origin-height=&quot;1342&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HH2j0/dJMcaiqnEsO/mjLsKX2x2CKxxSoHSW8Kv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HH2j0/dJMcaiqnEsO/mjLsKX2x2CKxxSoHSW8Kv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HH2j0/dJMcaiqnEsO/mjLsKX2x2CKxxSoHSW8Kv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHH2j0%2FdJMcaiqnEsO%2FmjLsKX2x2CKxxSoHSW8Kv0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2394&quot; height=&quot;1342&quot; data-filename=&quot;Pasted image 20260705114622.png&quot; data-origin-width=&quot;2394&quot; data-origin-height=&quot;1342&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-heading=&quot;마치며&quot; data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAS - Promtail - Loki - Grafana - Discord 로 이어지는 로그 시스템을 파악해보면서 단계별로 디버깅을 해보았습니다. 눈에 보이는 것은 Grafana 와 Discord 이지만, 내부에 많은 시스템이 숨어있습니다. 로그를 읽고 분류하는 것은 Promtail, 로그가 검색 가능하게 만드는 것은 Loki, GUI 담당은 Grafana, Alert System 은 Grafana Alert, Webhook 은 Discord 가 담당합니다. 장애는 외부에 드러나지만, 내부 어딘가에 문제가 생긴 것입니다. 내부 문제를 찾을 땐 암묵지가 필요합니다. 외부 현상을 보고, 어떤 시스템이 문제가 생겼겠거니 하는 추론이 가능해야 합니다. 학습을 통해 암묵지를 꾸준히 명시지화 해야, 빠른 장애 대응이 가능해집니다. 이번 학습을 통해 로그 시스템에 대한 이해도를 높일 수 있었습니다.&lt;/p&gt;</description>
      <category>서버</category>
      <category>grafana</category>
      <category>Loki</category>
      <category>promtail</category>
      <author>행복한 시지프 - 서버</author>
      <guid isPermaLink="true">https://happysisyphe-server.tistory.com/7</guid>
      <comments>https://happysisyphe-server.tistory.com/7#entry7comment</comments>
      <pubDate>Sun, 5 Jul 2026 15:55:00 +0900</pubDate>
    </item>
    <item>
      <title>DB 테이블 스키마 변경 전략</title>
      <link>https://happysisyphe-server.tistory.com/6</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;thumbnail_20260222135921.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ls0Up/dJMcaiWCyZv/ZRnRkaOWoV2PS2uEBB4bnK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ls0Up/dJMcaiWCyZv/ZRnRkaOWoV2PS2uEBB4bnK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ls0Up/dJMcaiWCyZv/ZRnRkaOWoV2PS2uEBB4bnK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fls0Up%2FdJMcaiWCyZv%2FZRnRkaOWoV2PS2uEBB4bnK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;800&quot; data-filename=&quot;thumbnail_20260222135921.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;들어가며&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드 서비스 개발을 하다보면, 테이블 스키마를 변경해야 하는 일이 발생합니다. Column 을 추가하거나, PK 를 바꾸거나, 인덱스를 추가하는 등이요. 그냥 ALTER TABLE &amp;hellip; ADD COLUMN 으로 추가하면 될까요? 데이터베이스 수업 시간에 스키마 변경은 비싼 비용, O(n) 연산이라고 공부했습니다. 이제 과연 맞을지, 어떻게 Column 을 추가해야 할지 학습하고 적용해봅니다.&lt;/p&gt;
&lt;h1&gt;DDL 알고리즘 (Native DDL)&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블이 짠 하고 변경된다고 생각하기 쉽지만, 다양한 방법이 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Metadata-Only Operation (MySQL 기준, ALGORITHM=INSTANT)&lt;/li&gt;
&lt;li&gt;Full Table Rewrite (MySQL 기준, ALGORITHM=COPY)&lt;/li&gt;
&lt;li&gt;In-Place Rewrite (MySQL 기준, ALGORITHM=INPLACE)&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Metadata-Only Operation&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Table 의 Row 하나하나를 변경하지 않고, 메타데이터만 변경합니다.&lt;/li&gt;
&lt;li&gt;기존 Row 의 물리적 레이아웃을 바꾸지 않을 때만 사용할 수 있습니다. 끝에 Column 을 하나 추가하거나, DEFAULT 값을 변경하거나, 테이블 이름을 변경하거나.&lt;/li&gt;
&lt;li&gt;O(1) 연산입니다. 즉시 처리됩니다.&lt;/li&gt;
&lt;li&gt;Column 을 하나 추가하고, SELECT 했을 때 값이 없으면 DEFAULT 를 반환합니다.&lt;/li&gt;
&lt;li&gt;PostgreSQL 11+, MySQL 8.0+, SQLite 에서 지원합니다.&lt;/li&gt;
&lt;li&gt;기존 Row 형식을 바꿔야 하는 경우, 다음 두가지 방법을 사용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Full Table Rewrite&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가장 고전적인 방식입니다.&lt;/li&gt;
&lt;li&gt;기존 테이블을 유지하지 않고, 새 테이블을 만들어 전체 데이터를 다시 쓰고, 마지막에 Swap 하는 방식입니다.&lt;/li&gt;
&lt;li&gt;데이터 전부를 읽고, 데이터 전부를 다시 쓰기 때문에, 디스크 I/O 가 매우 큽니다.&lt;/li&gt;
&lt;li&gt;일시적으로 디스크가 2배 필요합니다.&lt;/li&gt;
&lt;li&gt;Lock 이 장시간 발생합니다. 스키마 정합성을 유지하기 위해서 Lock 을 겁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;In-Place Rewrite&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 Row 형식을 바꿔야 하는 경우 사용합니다.&lt;/li&gt;
&lt;li&gt;in-place 란, 추가 메모리 공간을 만들지 않고, 기존 공간에서 직접 수정하는 것을 의미합니다. 즉, COPY 하지 않고 수정한다는 의미를 강조한 이름입니다.&lt;/li&gt;
&lt;li&gt;Column 삭제, Column 을 테이블 중간에 추가, Column 타입 변경, 인덱스 생성 등.&lt;/li&gt;
&lt;li&gt;테이블 전체를 새로 복사하지는 않고, 내부 데이터를 재작성합니다.&lt;/li&gt;
&lt;li&gt;Online DDL, 즉 DDL 수행 중에도, DML 이 실행 가능합니다. 다시 말하면, ALTER TABLE 중에, UPDATE, INSERT 가 가능하다. 내부 데이터 수정 후, 최종적으로 보정 작업이 들어갑니다&lt;/li&gt;
&lt;li&gt;테이블 전체 스캔을 하므로, 여전히 디스크 I/O 가 큽니다&lt;/li&gt;
&lt;li&gt;여전히 짧은 Lock 이 발생합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;수동 제어 (OSC Tool)&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Metadata-only Operation 으로 처리가 안 되고, 테이블 규모가 크고 운영 리스크가 크다면, 수동 제어가 필요합니다. ALTER TABLE 알고리즘에 의존하지 않고, 직접 제어합니다. 이를 OSC(Online Schema Change) Tool 이라고 합니다. Online 은 테이블 이관 중에 DML 이 가능하다는 의미입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 조건을 완벽하게 충족할 수 있도록 합니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;무중단 배포할 수 있다&lt;/li&gt;
&lt;li&gt;데이터 이관을 중간에 멈추거나, 가속화 하는 등 제어가 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 과정을 거칩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;직접 새 테이블을 만들고&lt;/li&gt;
&lt;li&gt;데이터를 chunk 단위로 조금씩 복사합니다.&lt;/li&gt;
&lt;li&gt;동시에 발생하는 DML 을 추적합니다. 트리거나 binlog 를 활용합니다.&lt;/li&gt;
&lt;li&gt;마지막에 기조 테이블과 SWAP 합니다.&lt;/li&gt;
&lt;li&gt;임시 테이블 삭제합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적 도구로 MySQL 은 gh-ost , pt-online-schema-change, PostgreSQL 은 pgrill, pg-osc 가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;마치며&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블 스키마 변경이 겉으로는 단순해보이지만, 굉장히 복잡한 작업입니다. DB 는 비용이 비싸므로, 신중하게 접근해야 합니다. 추가로, 정합성을 위해서 Lock 이 필요하므로, 세심하게 접근해야 무중단이 가능합니다. 오늘은 개괄적으로나마 스키마 변경 전략을 알아보았습니다.&lt;/p&gt;</description>
      <category>서버</category>
      <category>db</category>
      <category>DDL 실행</category>
      <category>스키마 변경</category>
      <author>행복한 시지프 - 서버</author>
      <guid isPermaLink="true">https://happysisyphe-server.tistory.com/6</guid>
      <comments>https://happysisyphe-server.tistory.com/6#entry6comment</comments>
      <pubDate>Sun, 22 Feb 2026 23:00:39 +0900</pubDate>
    </item>
    <item>
      <title>Node.js 에서 Date 객체 Time Zone 다루기</title>
      <link>https://happysisyphe-server.tistory.com/5</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;thumbnail_20260209120534.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvKmPj/dJMcaflcEoZ/WIDaIDlrK8mjvbKXtnwuvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvKmPj/dJMcaflcEoZ/WIDaIDlrK8mjvbKXtnwuvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvKmPj/dJMcaflcEoZ/WIDaIDlrK8mjvbKXtnwuvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvKmPj%2FdJMcaflcEoZ%2FWIDaIDlrK8mjvbKXtnwuvk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;800&quot; data-filename=&quot;thumbnail_20260209120534.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로그 성향 분석 서비스를 만들고 싶습니다. 하나의 항목이, &amp;ldquo;아침형 vs 밤형&amp;rdquo; 입니다. 글을 주로 언제 쓰느냐에 따라서 결정합니다. RSS 의 pubDate 를 파싱해서 계산합니다. 이때 버그가 있었습니다. test CI 를 등록하고나서, 이 테스트가 통과하지 않았습니다. 분명 Local 에서는 모든 테스트가 동작했는데, 왜 CI 에서는 통과하지 않을 걸까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 로그를 보니, 테스트 코드 결과에서 hour 가 예상과 달랐던 것입니다. 원인을 분석해보니 Time Zone 문제라는 것을 깨달았고, Node.js 환경에서 어떻게 Time Zone 을 다루어야 하는지 학습하고 적용해보았습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;  [
    {
-     &quot;hour&quot;: 21,
+     &quot;hour&quot;: 12,
      &quot;minute&quot;: 49,
    },
  ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;tz1.png&quot; data-origin-width=&quot;2214&quot; data-origin-height=&quot;1310&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qMjSC/dJMb99ZClcu/Yv9F6iA1HGOz7CNDmANBz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qMjSC/dJMb99ZClcu/Yv9F6iA1HGOz7CNDmANBz0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qMjSC/dJMb99ZClcu/Yv9F6iA1HGOz7CNDmANBz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqMjSC%2FdJMb99ZClcu%2FYv9F6iA1HGOz7CNDmANBz0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2214&quot; height=&quot;1310&quot; data-filename=&quot;tz1.png&quot; data-origin-width=&quot;2214&quot; data-origin-height=&quot;1310&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제상황 구체화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 이러했습니다. 단순한 로직이라 문제가 없다고 판단했습니다. 테스트 코드도 정상적으로 통과했고요.&lt;/p&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;export function parseTimeFromPubDate(pubDate: string): ParsedTime {
  const date = new Date(pubDate);
  return { hour: date.getHours(), minute: date.getMinutes() };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RSS 에 포함된 pubDate 는 RFC 2822 Data Format 형식을 갖습니다. 이런 형식이죠.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Sun, 18 Jan 2026 21:49:08 +0900&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 날짜에서, 시/분을 추출하고 평균을 내어, 평균 글 배포 시간을 보여주려고 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 getHours 메서드에서 발생했습니다. getHours 는 프로세스의 Time Zone 에 따라서 다른 응답을 냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드로 이를 검증해봅시다. 2026-01-01T00:00:00Z 라는 같은 UTC 에서, Date 객체를 만들고 getHours 를 호출하면, 지역마다 다른 응답을 냅니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;import { describe, expect, it } from 'vitest';

process.env.TZ = 'UTC';
it('TZ=UTC일 때, UTC 자정의 getHours()는 0을 반환한다', () =&amp;gt; {
  const date = new Date('2026-01-01T00:00:00Z');
  expect(date.getHours()).toBe(0);
});

process.env.TZ = 'Asia/Seoul';
it('TZ=Asia/Seoul일 때, UTC 자정의 getHours()는 9시를 반환한다', () =&amp;gt; {
  const date = new Date('2026-01-01T00:00:00Z');
  expect(date.getHours()).toBe(9);
});

process.env.TZ = 'America/New_York';
it('TZ=America/New_York일 때, UTC 자정의 getHours()는 19시를 반환한다', () =&amp;gt; {
  const date = new Date('2026-01-01T00:00:00Z');
  expect(date.getHours()).toBe(19);
});

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 익히 들어 아는 사실이었지만, 프론트엔드 개발을 하면서 크게 신경을 썼던 적은 없습니다. 이유를 생각해보니 이랬습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;서버에서 내려준 값을 그대로 사용&lt;/li&gt;
&lt;li&gt;날짜/시간 검증은 서버 API 로 검증&lt;/li&gt;
&lt;li&gt;한국에서만 서비스하기 때문에, getHour 그대로 사용&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 개발하는 것은 달랐습니다. 서버 자체가 다양한 위치에서 뜰 수 있습니다. 특히 저는 Cloudflare Workers 라는 Edge Runtime 을 활용해서 배포하기 때문에, 매 호출마다 다른 지역에서 API 가 호출될 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그만큼 서버에서 TIme Zone 을 고려하는 것은 매우 중요한 태스크였습니다. 이 문제를 해소해보며, Time Zone 에 대한 이해를 높여봅시다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요구사항 정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;pubDate 를 파싱할 때 Time Zone 에 따라서 다른 결과가 나오면 안 된다.&lt;/li&gt;
&lt;li&gt;RSS 의 pubDate 의 time offset 에 맞게 결과가 나와야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TDD/Red - 테스트 코드 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명확한 요구사항이 있는 경우, 테스트 코드를 먼저 작성하는 것이 좋습니다. Red 를 먼저 만들고, 그걸 충족하게 만드는 구현체를 만들면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇을 테스트해야 할까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 Time Zone 에서도 pubDate 파싱 결과가 동일하게 나온다는 것을 테스트하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 Time Zone 을 어떻게 테스트 할 수 있을까요? Node.js 에서 Time Zone 을 강제로 설정하는 방법이 있습니다. process.env 의 TZ 변수를 조정하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 사용 가능합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;process.env.TZ = 'UTC'&lt;/li&gt;
&lt;li&gt;process.env.TZ = 'America/New_York'&lt;/li&gt;
&lt;li&gt;process.env.TZ&amp;nbsp;= 'Asia/Seoul'&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 3개의 TZ 에서, 동일한 결과가 나오는지 테스트할 것입니다. 이때 3개의 파일을 별개로 작성해야 합니다. 왜냐하면, 프로세스 env 를 파일 실행 중간에 변경할 수 없기 때문입니다. 파일마다 다른 워커로 실행이 되기 때문에, 별개 파일 간에는 영향을 주지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 뉴욕 TZ 를 테스트 합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;process.env.TZ = 'America/New_York';

import { describe, expect, it } from 'vitest';
import { parseTimeFromPubDate } from '../../utils/time-analyzer';

describe('parseLocalTime (TZ=America/New_York)', () =&amp;gt; {
  it('+0900 오프셋: pubDate 의 Time Zone 시간을 그대로 추출한다', () =&amp;gt; {
    expect(parseTimeFromPubDate('Sun, 18 Jan 2026 21:49:08 +0900')).toEqual({
      hour: 21,
      minute: 49,
    });
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 서울 TZ 를 테스트 합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;process.env.TZ = 'Asia/Seoul';

import { describe, expect, it } from 'vitest';
import { parseTimeFromPubDate } from '../../utils/time-analyzer';

describe('parseLocalTime (TZ=Asia/Seoul)', () =&amp;gt; {
  it('+0900 오프셋: pubDate 의 Time Zone 시간을 그대로 추출한다', () =&amp;gt; {
    expect(parseTimeFromPubDate('Sun, 18 Jan 2026 21:49:08 +0900')).toEqual({
      hour: 21,
      minute: 49,
    });
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. UTC TZ 를 테스트 합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;process.env.TZ = 'UTC';

import { describe, expect, it } from 'vitest';
import { parseTimeFromPubDate } from '../../utils/time-analyzer';

describe('parseLocalTime (TZ=UTC)', () =&amp;gt; {
  it('+0900 오프셋: pubDate 의 Time Zone 시간을 그대로 추출한다', () =&amp;gt; {
    expect(parseTimeFromPubDate('Sun, 18 Jan 2026 21:49:08 +0900')).toEqual({
      hour: 21,
      minute: 49,
    });
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드를 먼저 작성했습니다. 현재 구현체는 아래와 같으므로, Red 가 발생할 것입니다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;export function parseTimeFromPubDate(pubDate: string): ParsedTime {
  const date = new Date(pubDate);
  return { hour: date.getHours(), minute: date.getMinutes() };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보시는 바와 같이, +09:00 offset 과 일치하는 Seoul 만 성공하고, 나머지 TZ 에서는 실패합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;tz2.png&quot; data-origin-width=&quot;305&quot; data-origin-height=&quot;73&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXUD4v/dJMcaiCcKHB/CKJ8ENfUORqxQz2Fkr6N6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXUD4v/dJMcaiCcKHB/CKJ8ENfUORqxQz2Fkr6N6K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXUD4v/dJMcaiCcKHB/CKJ8ENfUORqxQz2Fkr6N6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXUD4v%2FdJMcaiCcKHB%2FCKJ8ENfUORqxQz2Fkr6N6K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;305&quot; height=&quot;73&quot; data-filename=&quot;tz2.png&quot; data-origin-width=&quot;305&quot; data-origin-height=&quot;73&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TDD/Green - 구현체 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현체는 간단합니다. pubDate 가 이미 작성자의 Local Time Zone 을 지키고 있는 형식이므로, pubDate 의 시간과 분을 그대로 추출해주면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;export function parseTimeFromPubDate(pubDate: string): ParsedTime {
  const match = pubDate.match(/(\\d{2}):(\\d{2}):\\d{2}/);
  return { hour: parseInt(match[1]), minute: parseInt(match[2]) };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정규표현식으로 HH:MM:SS 형식을 찾아서, 시간과 분을 추출해주면 끝입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 정상적으로 통과합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;tz3.png&quot; data-origin-width=&quot;313&quot; data-origin-height=&quot;72&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ukAXX/dJMcabiPkOh/6Wl2kGRkKCHu2lEJE4LPI1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ukAXX/dJMcabiPkOh/6Wl2kGRkKCHu2lEJE4LPI1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ukAXX/dJMcabiPkOh/6Wl2kGRkKCHu2lEJE4LPI1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FukAXX%2FdJMcabiPkOh%2F6Wl2kGRkKCHu2lEJE4LPI1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;313&quot; height=&quot;72&quot; data-filename=&quot;tz3.png&quot; data-origin-width=&quot;313&quot; data-origin-height=&quot;72&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cloudflare Workers CI 에서는 UTC 를 활용하는데요. CI 도 정상적으로 통과하는 것을 확인했습니다. 장애는 정상 해소되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;tz4.png&quot; data-origin-width=&quot;1203&quot; data-origin-height=&quot;603&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bysxxw/dJMcagEq70i/sX2SMTa54sBtSSkl2KXwtK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bysxxw/dJMcagEq70i/sX2SMTa54sBtSSkl2KXwtK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bysxxw/dJMcagEq70i/sX2SMTa54sBtSSkl2KXwtK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbysxxw%2FdJMcagEq70i%2FsX2SMTa54sBtSSkl2KXwtK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1203&quot; height=&quot;603&quot; data-filename=&quot;tz4.png&quot; data-origin-width=&quot;1203&quot; data-origin-height=&quot;603&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Time Zone 문제로 장애가 발생하고, UTC, GMT, KST, ISO 8601, Date 객체의 동작 등 여러 키워드를 공부했습니다. 프론트엔드 개발 할 때도, 이 개념들이 무엇인지 대강 알고는 있었지만, 다룰 일이 많지 않아 명확하게 알 지는 못 했습니다. 드디어 머릿속에 엉켜있던 실을 가지런히 풀어낼 수 있었습니다. 이제는 Time Zone 을 다룰 때, Smell 을 맡을 수 있을 정도가 되었습니다. 서버 코드를 만질 때는, getHours, getMinutes 같은 로컬 시간 메서드 사용을 항상 주의해야 합니다. 문자열을 직접 만지거나, UTC 와 오프셋을 고려하여 처리해주는 습관을 들여야 합니다. 사이드 프로젝트의 작은 장애로, 값싸게 치르고 성장한 것 같습니다.&lt;/p&gt;</description>
      <category>서버</category>
      <category>getHours</category>
      <category>node.js</category>
      <category>timezone</category>
      <category>UTC</category>
      <author>행복한 시지프 - 서버</author>
      <guid isPermaLink="true">https://happysisyphe-server.tistory.com/5</guid>
      <comments>https://happysisyphe-server.tistory.com/5#entry5comment</comments>
      <pubDate>Mon, 9 Feb 2026 21:05:51 +0900</pubDate>
    </item>
    <item>
      <title>Characterization Test 로 안전하게 리팩터링 하기</title>
      <link>https://happysisyphe-server.tistory.com/4</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;thumbnail_20260206154603.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ck6Nad/dJMcac22dbT/wIF1eUGjw1mDz0nxQreOnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ck6Nad/dJMcac22dbT/wIF1eUGjw1mDz0nxQreOnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ck6Nad/dJMcac22dbT/wIF1eUGjw1mDz0nxQreOnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fck6Nad%2FdJMcac22dbT%2FwIF1eUGjw1mDz0nxQreOnk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;800&quot; data-filename=&quot;thumbnail_20260206154603.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사이드 프로젝트로 블로그 분석 서비스를 만들고 있습니다. 서비스를 운영한지 일주일 정도 되었습니다. 서비스를 운영하다보니, 기능을 새로 추가하고, 수정해야 할 일이 많이 생깁니다. 이때 어떻게 안정적으로 기능을 추가하고, 리팩터링 할 수 있을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드에서는 리팩터링 후 UI 와 기능이 정상 동작하는지 확인하기가 간편했고 명확했습니다. 백엔드는 더 다양한 케이스를 다룰 뿐더러, 하나씩 API 를 호출해보는 과정은 좀 더 까다로웠습니다. 또는 API 에는 다른 많은 로직이 섞여있으므로, API 를 통해 하나의 기능을 테스트하기가 번거롭습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 리팩터링을 잘 할 수 있을까 고민하다가, 회귀 방지(Regression Protection)가 핵심이라는 생각이 들었습니다. 회귀 방지를 할 수 있는 흐름을 고민하다가, Characterization Test 를 알게 되었고, 이를 적용해 보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠른 기능 개발이 필요했고, Claude Code 가 이런 로직을 짜주었습니다. 정상 동작을 확인하고, 빠르게 배포했죠. 이후에 코드를 수정하려고 하니, 코드 수정이 어려웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유지보수 하기 좋은 코드가 되기 위해서, 리팩터링이 필수적이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 코드였습니다.&lt;/p&gt;
&lt;pre class=&quot;openscad&quot;&gt;&lt;code&gt;function analyzeWritingTime(posts: RSSPostType[]): TimeAnalysisResult {
  // 생략...

  // 가장 많은 시간대 판별
  let timeCategory: &quot;아침형&quot; | &quot;낮형&quot; | &quot;저녁형&quot; | &quot;밤형&quot;;
  const max = Math.max(
    distribution.morning,
    distribution.afternoon,
    distribution.evening,
    distribution.night
  );

  if (max === distribution.night) {
    timeCategory = &quot;밤형&quot;;
  } else if (max === distribution.evening) {
    timeCategory = &quot;저녁형&quot;;
  } else if (max === distribution.afternoon) {
    timeCategory = &quot;낮형&quot;;
  } else {
    timeCategory = &quot;아침형&quot;;
  }

  return {
    averageWritingTime,
    timeCategory,
    distribution,
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전략 수립&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 전략을 짜야, 제가 자신감을 가지고 리팩터링 하면서, 동일한 결과물을 유지할 수 있을까요. &amp;ldquo;단위 테스트&amp;rdquo; 책 에서 읽은 회귀 방지(Regression Protection)이 떠올랐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소프트웨어에서 회귀란, 이전에 잘 되던 기능이 코드 변경 후 깨지는 것을 말합니다. 회귀 방지는, 리팩터링을 해도 기존과 동일한 결과를 유지하는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해서, 블랙박스, Characterization Test 라는 개념을 적용해보기로 했습니다. Characterize 란 특성을 규명한다는 의미입니다. 현재 코드가 어떤 상태로 동작하는지 규명하는 테스트를 작성하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 내부 로직은 블랙박스로 두고, 입력에 따른 출력만 정확히 테스트 하는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 현재의 동작으로 테스트를 작성하고나서, 내부 로직을 리팩터링 합니다. 그리고 마지막으로 다시 이 테스트가 정상 동작하는지 체크합니다. 이것이 정상 동작한다면, 리팩터링 내성이 있는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩터링 내성이 높다는건, 내부 구현을 바꾸어도 테스트가 깨지지 않는 것을 말합니다. 블랙 박스 테스트는 리팩터링 내성이 높고, 우리가 지향해야 하는 테스트는 대부분 여기에 해당합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 이제 실제로 이 전략을 적용해보시죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Step1. Characterization Test&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 입력과 출력만을 테스트하는 것입니다. 기존 동작을 그대로 테스트해두는 것이지요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 해두면 이런 같은 장점이 생깁니다. 리팩터링 내성이 생겨, 자신감 있는 리팩터링이 가능해 집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 코드를 테스트 해봅시다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;describe('analyzeWritingTime', () =&amp;gt; {
    it('RSS 에서 글 게시 시간을 확인하여, 평균 글쓰기 시간과 시간대 분포를 계산한다.', () =&amp;gt; {
        // Given
        const blogPosts = RSS_BLOG_POSTS;

        // When
        const result = analyzeWritingTime(blogPosts);

        // Then
        expect(result).toEqual({
            averageWritingTime: '22:49',
            timeCategory: '밤형',
            distribution: { morning: 0, afternoon: 0, evening: 3, night: 7 }
        });
    });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 통과시킵니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;test통과1.png&quot; data-origin-width=&quot;520&quot; data-origin-height=&quot;68&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/u7hdL/dJMcaajS3ef/2xLIy0PkTvfLt0w6jZIylk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/u7hdL/dJMcaajS3ef/2xLIy0PkTvfLt0w6jZIylk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/u7hdL/dJMcaajS3ef/2xLIy0PkTvfLt0w6jZIylk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fu7hdL%2FdJMcaajS3ef%2F2xLIy0PkTvfLt0w6jZIylk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;520&quot; height=&quot;68&quot; data-filename=&quot;test통과1.png&quot; data-origin-width=&quot;520&quot; data-origin-height=&quot;68&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Step2. 내부 리팩터링 w/ TDD&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 내부 로직을 리팩터링 해봅니다. 여기에는 TDD 전략을 활용할 것입니다. 내부 로직에 대한 테스트를 먼저 작성하고(Red), 테스트를 가능한 빠르게 통과시키고(Green), 함수/테스트를 리팩터링(Refactor) 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 빠른 피드백을 확보하여, 테스트의 가치를 더욱 키울 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 단위 테스트 코드를 먼저 짠다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;determineTimeCategory 로 분리할 계획입니다. 구현체 없이 테스트만 만들어 Red 를 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;describe('determineTimeCategory', () =&amp;gt; {
    it('시간 분포에 따라 시간 카테고리를 결정한다', () =&amp;gt; {
        // Given
        const distribution = { morning: 0, afternoon: 0, evening: 3, night: 7 };

        // When
        const result = determineTimeCategory(distribution);

        // Then
        expect(result).toBe('밤형');
    })
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 빠르게 함수를 구현하여 테스트를 통과시킨다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 로직을 그대로 옮겨와서, 최대한 빠르게 Green 을 만듭니다. 그대로 로직을 옮겨왔으니, 실패하지 않습니다.&lt;/p&gt;
&lt;pre class=&quot;lua&quot;&gt;&lt;code&gt;export function determineTimeCategory(distribution: TimeDistribution): TimeCategory {
  let timeCategory: &quot;아침형&quot; | &quot;낮형&quot; | &quot;저녁형&quot; | &quot;밤형&quot;;
  const max = Math.max(
    distribution.morning,
    distribution.afternoon,
    distribution.evening,
    distribution.night
  );

  if (max === distribution.night) {
    timeCategory = &quot;밤형&quot;;
  } else if (max === distribution.evening) {
    timeCategory = &quot;저녁형&quot;;
  } else if (max === distribution.afternoon) {
    timeCategory = &quot;낮형&quot;;
  } else {
    timeCategory = &quot;아침형&quot;;
  }

  return timeCategory;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 함수/테스트 코드를 리팩터링 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음에 함수를 리팩터링 합니다. 중복이 많고, 확장성 낮은 코드를 간결하게 바꾸었습니다.&lt;/p&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;const CATEGORY_MAP: { key: keyof TimeDistribution; label: TimeCategory }[] = [
  { key: &quot;night&quot;, label: &quot;밤형&quot; },
  { key: &quot;evening&quot;, label: &quot;저녁형&quot; },
  { key: &quot;afternoon&quot;, label: &quot;낮형&quot; },
  { key: &quot;morning&quot;, label: &quot;아침형&quot; },
];

export function determineTimeCategory(distribution: TimeDistribution): TimeCategory {
  return CATEGORY_MAP.reduce((max, curr) =&amp;gt;
    distribution[curr.key] &amp;gt; distribution[max.key] ? curr : max
  ).label;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 determineTimeCategory 로직이 안전하게 완성되었기에, 기존 함수의 내부 로직을 수정할 준비가 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Step3. 기존 코드 교체&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 기존 코드를 교체해봅니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;export function analyzeWritingTime(posts: RSSPostType[]): TimeAnalysisResult {
  // 생략...

  const timeCategory = determineTimeCategory(distribution);

  return {
    averageWritingTime,
    timeCategory,
    distribution,
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Step4. 기존 테스트 정상 동작 확인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 코드를 바꾸었다면, 처음과 동일한 동작이 나와야겠죠. 블랙박스로 설계한 Characterization Test 를 택했으므로, 내부 로직을 바꾸어도 테스트가 깨지지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;test통과2.png&quot; data-origin-width=&quot;539&quot; data-origin-height=&quot;117&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qMNnO/dJMcacBZ5ei/4Ikh9SAcIuHwwpk8wsEbK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qMNnO/dJMcacBZ5ei/4Ikh9SAcIuHwwpk8wsEbK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qMNnO/dJMcacBZ5ei/4Ikh9SAcIuHwwpk8wsEbK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqMNnO%2FdJMcacBZ5ei%2F4Ikh9SAcIuHwwpk8wsEbK1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;539&quot; height=&quot;117&quot; data-filename=&quot;test통과2.png&quot; data-origin-width=&quot;539&quot; data-origin-height=&quot;117&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 기존 동작과 달라진 것이 없다는 것을 마지막으로 테스트하였고, 안전한 리팩터링을 완수했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현업에서 코드를 수정할 때, 두려움이 생기기 마련입니다. 그럼에도 불구하고, 우리 개발자는 리팩터링을 해야 하죠. 두려움을 줄이는, 용기를 가져다 주는 여러 방법론들이 존재한다는 것을 깨달았습니다. Characterization Test 기반 리팩터링이 그런 예시입니다. 개발 과정에서는 TDD 도 용기를 심어주는 방법이므로, 합쳐서 적용해보았습니다. 자신감이 매우 높은 상태로 리팩터링을 진행했습니다. 이렇게 마음이 편한 리팩터링은 처음인 것 같습니다. 다음에 많이 사용해볼 전략이 될 듯 합니다.&lt;/p&gt;</description>
      <category>서버</category>
      <author>행복한 시지프 - 서버</author>
      <guid isPermaLink="true">https://happysisyphe-server.tistory.com/4</guid>
      <comments>https://happysisyphe-server.tistory.com/4#entry4comment</comments>
      <pubDate>Sat, 7 Feb 2026 00:48:07 +0900</pubDate>
    </item>
    <item>
      <title>NestJS 철학 이해하기 1 : Layered Architecture</title>
      <link>https://happysisyphe-server.tistory.com/3</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;thumbnail_20260201070649.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSgIYd/dJMcafyHBgT/T66rzhAjpdsIF8vop9CDIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSgIYd/dJMcafyHBgT/T66rzhAjpdsIF8vop9CDIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSgIYd/dJMcafyHBgT/T66rzhAjpdsIF8vop9CDIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSgIYd%2FdJMcafyHBgT%2FT66rzhAjpdsIF8vop9CDIK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;800&quot; data-filename=&quot;thumbnail_20260201070649.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 개발을 하면서, Layered Architecture 가 자주 쓰입니다. Spring, NestJS 가 기본적으로 강제하고 있는 방식입니다. 이 아키텍쳐를 그대로 따르는 것이 팀 개발에서 중요하겠지만, 그 전에 이것이 왜 태동했는지 이해해야 합니다. 그것을 이해하지 않으면, 형태만 똑같을 뿐, 본질적인 가치를 훼손할 수 있습니다. 그래서 이번에 Layered Architecture 가 왜 생겼는지, 본질적인 철학은 무엇인지 이해해보려고 합니다. 그리고 그것을 기반으로 NestJS 를 이해해 볼 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Layered Architecture 이전&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Layered Architecture 가 없는 시절, 또는 지금 express 로 개발을 한다면 무슨 일이 벌어졌을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 관심사의 분리가 되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경에 용이한 소프트웨어를 만들기 위해서, 관심사의 분리는 필수적입니다. 응집도가 높고, 결합도가 낮은 소프트웨어를 만들어야 하죠. 한 파일에 있던 코드를 여러 개의 클래스로 나누고, 파일과 폴더를 분리하여 관심사 분리를 해내면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 관심사의 분리를 하면 되겠지만, 또 문제가 있습니다. 상황마다, 사람마다 관심사 분리 기준이 다른다는 것입니다. 아키텍쳐는 일견 예술의 세계이기 때문에, 각자의 방식이 다를 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 관심사를 분리하더라도, 상황마다/사람마다 그 기준이 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람마다 생각하는 기준이 다른건 정말 고통스럽습니다. 누군가는 이 로직을 Business Layer 에 넣자고 하고, 누군가는 Data Layer 에 넣자고 합니다. 이걸 하나하나 조율해가는 과정은 쉽지 않습니다. 이 과정에서, 보편적인 상황에서, 가장 적절하게 관심사의 분리가 지켜지는, 하나의 구조를 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 중 하나가 Layered Architecture 입니다. 기준을 통일시켜서, 대규모 소프트웨어를 만들면서 모두가 똑같이 관심사를 분리하며 협업할 수 있도록 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Layered Architecture 란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 소프트웨어를 여러개의 Layer 로 분리하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개념적 계층으로 Layer 를 분리하면 다음과 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;layered1.png&quot; data-origin-width=&quot;1002&quot; data-origin-height=&quot;111&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cIRP0p/dJMcaiPEZZ8/P9kSmTxuCfnwfFLHaXkBN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cIRP0p/dJMcaiPEZZ8/P9kSmTxuCfnwfFLHaXkBN1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cIRP0p/dJMcaiPEZZ8/P9kSmTxuCfnwfFLHaXkBN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcIRP0p%2FdJMcaiPEZZ8%2FP9kSmTxuCfnwfFLHaXkBN1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1002&quot; height=&quot;111&quot; data-filename=&quot;layered1.png&quot; data-origin-width=&quot;1002&quot; data-origin-height=&quot;111&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Presentation Layer &amp;rarr; Business Layer &amp;rarr; Data Layer &amp;rarr; Domain&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 서버 개발자들에게 익숙한 방식으로, 구현 계층으로 Layer 를 분리하면 다음과 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller &amp;rarr; Service &amp;rarr; Repository &amp;rarr; Entity&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 화살표의 방향이 중요합니다. 화살표는 의존성을 말합니다. 의존성 방향은 한쪽으로만 흐르고, 그 방향은 &amp;ldquo;안정된 의존성 원칙 (Stable Dependencies Principle)&amp;rdquo; 을 따릅니다. 의존성이 무엇인지, 안정성이란 무엇인지 살펴보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;의존성이란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성은 사용관계를 의미합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;A &amp;rarr; B
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;= A 는 B 에 의존한다.&lt;/li&gt;
&lt;li&gt;= A 는 B 를 사용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;class Foo {
	method1() {
		const bar = new Bar()
		bar.do1()
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Foo 는 Bar 에 의존한다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안정성이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안정성은 변경하기 어려운 정도입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로버트 마틴은 &lt;b&gt;불안정성 지표&lt;/b&gt;를 제안합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Ce (Efferent Coupling) = Fan-out = 나가는 의존성 (내가 의존하는 것) &lt;br /&gt;Ca (Afferent Coupling) = Fan-in = 들어오는 의존성 (나를 의존하는 것)&lt;br /&gt;&lt;br /&gt;I = Ce / (Ca + Ce)&lt;br /&gt;I = 0: 완전히 안정&lt;br /&gt;I = 1: 완전히 불안정&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 내가 의존하는 것(Ce)이 많아질수록 불안정적이고, 나를 의존하는 것(Ca)이 많아질수록 안정적이라는 것입니다. 예시 코드를 보시죠&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// 불안정적인 Foo : Foo 가 의존하는 코드는 Bar, Baz 이다

class Foo {
	method1() {
		const bar = new Bar()
		const baz = new Baz()
		
		bar.do()
		baz.do()	
	}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 안정성의 정의를 보면, 변경하기 어려운 정도 입니다. 내가 아무렇게나 구현을 변경해도, Bar, Baz 에 영향을 주지 않습니다. 그러니까, 불안정적이라고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안정한 모듈 예시를 보죠. Bar 의 do 메서드가 변경되면, Foo 와 Foo2 는 영향을 받습니다. Bar 의 변경이 다른 2개의 모듈에 영향을 주게 됩니다. Foo 에 의존하는 다른 모듈이 있다면, 거기까지 파급을 미칠 수 있겠죠. 즉, 파급이 많아지니까, 변경하기가 더 어려운 모듈이 됩니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// 안정적인 Bar : Foo, Foo2 가 Bar 에 의존한다. 

class Foo {
	method1() {
		const bar = new Bar()
		const baz = new Baz()
		
		bar.do()
		baz.do()	
	}
}

class Foo2 {
	method1() {
		const bar = new Bar()
		
		bar.do()
	}
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안정된 의존성 원칙 (Stable Dependencies Principle)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 로버트 마틴은 이 원칙을 제안합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;불안정적 모듈은 안정적 모듈에 의존해야 한다.&lt;/li&gt;
&lt;li&gt;자주 변경되는 것이 변경되지 않는 모듈에 의존해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드 예시로 보면, Bar 가 자주 변경되지 않는, 안정적인 모듈이어야 하고, Foo 가 자주 변경되어도 괜찮은, 불안정적 모듈이어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI/Presentation 부분은 불안정적이니, 여기에 의존하는 것은 없어야 합니다. UI 가 다른 것을 사용하도록 해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 Domain 은 안정적이니, 다른 것들이 Domain 에 의존하게 설계해야 합니다. Domain 이 다른 모듈을 많이 사용하게 하면, 변경에 유연하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 Layered Architecture 로 돌아옵시다. 레이어의 분리와 의존성의 방향이 중요하다고 했습니다. 이제부터 각 레이어가 어떻게 분리되는지 살펴볼게요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;각 Layer 의 역할&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 Layer 는 어떤 역할을 할까요? Layer 는 변경 사유 별로 분리합니다. SOLID 의 SRP 에 따라, 하나의 변경 사유만 가지도록 하고, 로직을 응집합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Presentation Layer&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자와의 상호작용&lt;/li&gt;
&lt;li&gt;입력, 출력&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Business Layer&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;애플리케이션 규칙/비즈니스 로직 구현&lt;/li&gt;
&lt;li&gt;사용자 기준의 작업&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Data Layer&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터 저장, 조회&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Domain&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;핵심 비즈니스 개념&lt;/li&gt;
&lt;li&gt;비즈니스 제약사항&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NestJS 에서의 구현된 Layered Architecture&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NestJS 에서 Layered Architecture 측면의 핵심 개념이 4가지가 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;layered2.png&quot; data-origin-width=&quot;591&quot; data-origin-height=&quot;418&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9R7JW/dJMcabXlIto/u2dzr1fJ722K3kFAfFnxp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9R7JW/dJMcabXlIto/u2dzr1fJ722K3kFAfFnxp0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9R7JW/dJMcabXlIto/u2dzr1fJ722K3kFAfFnxp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9R7JW%2FdJMcabXlIto%2Fu2dzr1fJ722K3kFAfFnxp0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;591&quot; height=&quot;418&quot; data-filename=&quot;layered2.png&quot; data-origin-width=&quot;591&quot; data-origin-height=&quot;418&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Module&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모듈은 DI 컨테이너의 범위를 명시합니다. NestJS 의 핵심은 DI 인데요. DI 를 추상화하여 쉽게 사용할 수 있도록 하는 것이 바로 모듈입니다.&lt;/li&gt;
&lt;li&gt;모듈을 import 하거나 export 하여 외부와 소통할 수 있도록 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Controller&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Presentation Layer 를 담당합니다. HTTP 요청을 받아서, 응답하는 계층입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Provider&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Business Layer 와 Data Layer 를 아울러 Provider 로 묶습니다. 구체적으로 Service, Repository 라는 이름으로 다뤄집니다.&lt;/li&gt;
&lt;li&gt;Provider 말 그대로 제공자이고, 의존성 주입(DI)의 대상입니다.&lt;/li&gt;
&lt;li&gt;그런 의미에서 @Injectable 데코레이터로 Provider 라는 것을 명시합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Entity&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Domain Layer 를 담당합니다.&lt;/li&gt;
&lt;li&gt;데이터 타입/관계를 정의합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NestJS 를 잘 이해하기 위해서, Layered Architecture 에서 시작하여, 의존성, 안정성까지 살펴보고, NestJS 에 적용된 모습까지 살펴보았습니다. 이전에 Layered Architecture 를 그대로 따라만 하다보니, 의문이 드는 것이 많았습니다. 이 로직은 어디 레이어로 가야하는 걸까? Service 와 Repository 는 왜 Provider 이며, Provider 는 왜 @Injectable 이 붙을까? 이번에 근원을 학습하며 의문을 해소해보았고, NestJS 의 철학과 아키텍쳐에 대한 이해가 훨씬 높아졌습니다. NestJS 를 쓰더라도, 또는 간단하게 express, hono 를 쓰더라도 필요에 따라 더 나은 아키텍쳐를 적용할 수 있겠다는 자신감이 생겼습니다.&lt;/p&gt;</description>
      <category>서버</category>
      <category>Layered Architecture</category>
      <category>NestJS</category>
      <category>불안정성지표</category>
      <category>의존성</category>
      <author>행복한 시지프 - 서버</author>
      <guid isPermaLink="true">https://happysisyphe-server.tistory.com/3</guid>
      <comments>https://happysisyphe-server.tistory.com/3#entry3comment</comments>
      <pubDate>Sun, 1 Feb 2026 16:16:02 +0900</pubDate>
    </item>
    <item>
      <title>Gemini API Latency 추적</title>
      <link>https://happysisyphe-server.tistory.com/2</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;thumbnail_20260129164010.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TVxNf/dJMcaaxm5bX/czS5t4HdJukWcDpPwjbDP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TVxNf/dJMcaaxm5bX/czS5t4HdJukWcDpPwjbDP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TVxNf/dJMcaaxm5bX/czS5t4HdJukWcDpPwjbDP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTVxNf%2FdJMcaaxm5bX%2FczS5t4HdJukWcDpPwjbDP1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;800&quot; data-filename=&quot;thumbnail_20260129164010.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog-analyzer.pages.dev/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;블로그 분석 서비스&lt;/a&gt;를 배포했습니다. 베타 테스트로 커뮤니티에 홍보했습니다. 반응이 괜찮았습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;test4.png&quot; data-origin-width=&quot;584&quot; data-origin-height=&quot;75&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbYd9b/dJMcaaKTztD/TRc8vmlyNB8Uf8xRqHpf7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbYd9b/dJMcaaKTztD/TRc8vmlyNB8Uf8xRqHpf7K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbYd9b/dJMcaaKTztD/TRc8vmlyNB8Uf8xRqHpf7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbYd9b%2FdJMcaaKTztD%2FTRc8vmlyNB8Uf8xRqHpf7K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;584&quot; height=&quot;75&quot; data-filename=&quot;test4.png&quot; data-origin-width=&quot;584&quot; data-origin-height=&quot;75&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;test4 (1).png&quot; data-origin-width=&quot;583&quot; data-origin-height=&quot;101&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cFzINl/dJMcahcarPm/ZHfTPtCoK78zJLvEvG5Me0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cFzINl/dJMcahcarPm/ZHfTPtCoK78zJLvEvG5Me0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cFzINl/dJMcahcarPm/ZHfTPtCoK78zJLvEvG5Me0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcFzINl%2FdJMcahcarPm%2FZHfTPtCoK78zJLvEvG5Me0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;583&quot; height=&quot;101&quot; data-filename=&quot;test4 (1).png&quot; data-origin-width=&quot;583&quot; data-origin-height=&quot;101&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;test6.png&quot; data-origin-width=&quot;582&quot; data-origin-height=&quot;127&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dWqzIQ/dJMcadt2tJi/imMykfirQ5VlGbJ4EZLr3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dWqzIQ/dJMcadt2tJi/imMykfirQ5VlGbJ4EZLr3k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dWqzIQ/dJMcadt2tJi/imMykfirQ5VlGbJ4EZLr3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdWqzIQ%2FdJMcadt2tJi%2FimMykfirQ5VlGbJ4EZLr3k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;582&quot; height=&quot;127&quot; data-filename=&quot;test6.png&quot; data-origin-width=&quot;582&quot; data-origin-height=&quot;127&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;광고를 추가하고, 정식 배포를 하려고 하는데요. 한가지 크리티컬한 문제가 있었습니다. Gemini API 를 호출하는데, 평균 응답이 25초 가량 걸린다는 것입니다. 이대로면 이탈률이 매우 높을 것으로 예상되었습니다. 커뮤니티 내에서는 친절한 사용자분들이기 때문에, 시간을 기다려줬겠지만, 시장의 반응은 혹독할 것 같았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 정식 배포 전에, 성능 문제를 해소하고자 했습니다. 성능 분석부터, 병목 지점 확인, 해결책 도출, 테스트, 개선 과정을 이번 글에서 다룹니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;성능 측정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 성능을 측정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 API 성능을 먼저 살펴볼게요. Cloudflare Workers 에서 성능 모니터링 도구를 제공합니다. 아래 데이터를 보면, p25가 24,842ms, p95 는 39,425ms 입니다. 굉장히 느린 성능을 보입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;perf1.png&quot; data-origin-width=&quot;1652&quot; data-origin-height=&quot;888&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIGuWM/dJMcagEm9NS/pKAQyAUGQgQk5QlXqCKdW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIGuWM/dJMcagEm9NS/pKAQyAUGQgQk5QlXqCKdW0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIGuWM/dJMcagEm9NS/pKAQyAUGQgQk5QlXqCKdW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIGuWM%2FdJMcagEm9NS%2FpKAQyAUGQgQk5QlXqCKdW0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1652&quot; height=&quot;888&quot; data-filename=&quot;perf1.png&quot; data-origin-width=&quot;1652&quot; data-origin-height=&quot;888&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;perf2.png&quot; data-origin-width=&quot;1656&quot; data-origin-height=&quot;888&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2hlog/dJMcai3ba2U/vIiNLsZAHnohnA8jS6Hjs0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2hlog/dJMcai3ba2U/vIiNLsZAHnohnA8jS6Hjs0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2hlog/dJMcai3ba2U/vIiNLsZAHnohnA8jS6Hjs0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2hlog%2FdJMcai3ba2U%2FvIiNLsZAHnohnA8jS6Hjs0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1656&quot; height=&quot;888&quot; data-filename=&quot;perf2.png&quot; data-origin-width=&quot;1656&quot; data-origin-height=&quot;888&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 API 는 무슨 과정으로 이루어져 있는지 살펴보아야 합니다. 그리고 각 부분을 하나씩 Divide and conquer 해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 6개 과정으로 이루어져 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;url 을 fetch 하여 html 을 얻어옵니다.&lt;/li&gt;
&lt;li&gt;html 에서 rss 를 추출합니다.&lt;/li&gt;
&lt;li&gt;rss 를 fetch 하여 html 을 얻어옵니다.&lt;/li&gt;
&lt;li&gt;rss 결과물을 파싱합니다.&lt;/li&gt;
&lt;li&gt;파싱한 결과를 가지고 Gemini API 를 호출합니다.&lt;/li&gt;
&lt;li&gt;클라이언트에 응답합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한번에 모든 성능을 개선해도 좋지만, 성능 개선에도 비용이 존재하기 때문에, 가장 싸게 개선할 수 있으면서, 가장 효과적인 Center 를 먼저 찾아내는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 Gemini API 가 병목으로 의심되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini API 성능을 트래킹 해봅시다. Gemini 성능은 Google Cloud Console 의 &lt;a href=&quot;https://console.cloud.google.com/monitoring/metrics-explorer&quot;&gt;Metric Explorer&lt;/a&gt; 탭에서 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;perf3.png&quot; data-origin-width=&quot;1907&quot; data-origin-height=&quot;714&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5iIe1/dJMcagdhlRt/vzrSQfllG4lCnSKqFSOxL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5iIe1/dJMcagdhlRt/vzrSQfllG4lCnSKqFSOxL0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5iIe1/dJMcagdhlRt/vzrSQfllG4lCnSKqFSOxL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5iIe1%2FdJMcagdhlRt%2FvzrSQfllG4lCnSKqFSOxL0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1907&quot; height=&quot;714&quot; data-filename=&quot;perf3.png&quot; data-origin-width=&quot;1907&quot; data-origin-height=&quot;714&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;p50 : 25,166ms&lt;/li&gt;
&lt;li&gt;p95 : 32,716ms&lt;/li&gt;
&lt;li&gt;p99 : 33,387ms&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 핵심을 찌른 것 같습니다. API Latency 의 대부분이 Gemini API Latency 에서 오는 것으로 보입니다. 그 다음에는 Gemini API 의 병목 지점을 확인해보려고 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Gemini API 의 Latency 원인 파악&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini API 의 지연 응답의 원인에는 무엇이 있을까요, 무엇이 느린 응답을 만들까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Google AI Studio 의 로그를 확인하면 됩니다. 어떤 request 가 왔고, 어떤 추론 과정을 거쳐서, 응답했는지 확인할 수 있습니다. LLM 처리한 token 양을 보여줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;perf4.png&quot; data-origin-width=&quot;302&quot; data-origin-height=&quot;201&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjyVGz/dJMcai3ba3j/jBRjwu2chpJ8wGktyfWTo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjyVGz/dJMcai3ba3j/jBRjwu2chpJ8wGktyfWTo0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjyVGz/dJMcai3ba3j/jBRjwu2chpJ8wGktyfWTo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjyVGz%2FdJMcai3ba3j%2FjBRjwu2chpJ8wGktyfWTo0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;302&quot; height=&quot;201&quot; data-filename=&quot;perf4.png&quot; data-origin-width=&quot;302&quot; data-origin-height=&quot;201&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰에는 3가지 종류가 있습니다. input, thought, output 입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;input token : request 로 들어온 text 를 읽고 이해하는 토큰의 양입니다. 1초에 수천~수만 토큰 처리가 가능합니다.&lt;/li&gt;
&lt;li&gt;thought token : 응답을 위해서 내부적으로 생각하고 추론하는 토큰의 양입니다. 1초에 100~200토큰만 처리합니다.&lt;/li&gt;
&lt;li&gt;output token : 응답을 생성하는 토큰의 양입니다. 1초에 100~200토큰만 처리합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;살펴보면, Input 에는 10만 이상이 들어가고, thought 는 2000대, output 은 1000 정도가 쓰이더군요. 각 파트의 토큰양에 따른, response time 을 시뮬레이션 해볼 수 있다면, 예상 목표 성능을 쉽게 달성할 수 있으리라는 생각이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini Canvas 를 활용하여, 빠르게 시뮬레이터를 만들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://gemini.google.com/share/b10074ffa68e&quot;&gt;https://gemini.google.com/share/b10074ffa68e&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 데이터의 토큰 양으로 시뮬레이션 한다면, 36.22초가 걸리더군요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;perf5.png&quot; data-origin-width=&quot;1209&quot; data-origin-height=&quot;774&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uUbJD/dJMcac2Yei7/5Covr549qk1VUBQfSWELq0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uUbJD/dJMcac2Yei7/5Covr549qk1VUBQfSWELq0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uUbJD/dJMcac2Yei7/5Covr549qk1VUBQfSWELq0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuUbJD%2FdJMcac2Yei7%2F5Covr549qk1VUBQfSWELq0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1209&quot; height=&quot;774&quot; data-filename=&quot;perf5.png&quot; data-origin-width=&quot;1209&quot; data-origin-height=&quot;774&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 10초 정도의 응답이 나오기를 원합니다. 그러면 어디까지 줄여야할까요. Output 은 정형화 되어 있기 때문에 토큰을 줄이기 어려웠고, Input 와 thought 를 조절해보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Input 은 16,000 토큰, thought 는 0으로 조절하면, 9.34초가 걸릴 것으로 예상했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;perf6.png&quot; data-origin-width=&quot;1206&quot; data-origin-height=&quot;775&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckaPoj/dJMcadOjAmn/tOxFRcxBGlu1bgFA4fW0m1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckaPoj/dJMcadOjAmn/tOxFRcxBGlu1bgFA4fW0m1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckaPoj/dJMcadOjAmn/tOxFRcxBGlu1bgFA4fW0m1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FckaPoj%2FdJMcadOjAmn%2FtOxFRcxBGlu1bgFA4fW0m1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1206&quot; height=&quot;775&quot; data-filename=&quot;perf6.png&quot; data-origin-width=&quot;1206&quot; data-origin-height=&quot;775&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 Input 과 thought 토큰을 줄일 수 있을지 해결책을 찾아봅시다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Input 토큰 줄이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Input 을 먼저 줄여봅시다. 블로그 분석을 위해서 10개의 블로그 글을 가지고 왔는데요. 그리고 각 글의 모든 정보를 다 넣어주고 있었죠.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { RSSPostType } from &quot;./type&quot;;

export const parseBlogIntoString = ({
  blogPosts,
}: {
  blogPosts: RSSPostType[];
}) =&amp;gt; `
${blogPosts
    .map((blog, index) =&amp;gt; `
### 글 ${index + 1}
- 제목: ${blog.title}
- 작성자: ${blog.author}
- 설명: ${blog.description}
- 링크: ${blog.link}
`
    )
    .join(&quot;\\n&quot;)}
`;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로그 글을 길게 작성하는 경우, 10개의 글을 합치면 토큰이 10만을 넘어가기도 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로그 전문이 필요할까? 생각해보면 그렇지 않았습니다. 블로그에서 문체의 느낌을 위주로 분석하는 것이기 때문에, 앞부분만 가져와도 되었습니다. 앞의 1000자를 잘라서 가지고 왔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 작성자는 매 글마다 동일할 것이므로, 반복문에서 제외했습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { RSSPostType } from './type';

const MAX_DESCRIPTION_LENGTH = 1000;

export const parseBlogIntoString = ({
  blogPosts,
}: {
  blogPosts: RSSPostType[];
}) =&amp;gt; `
작성자: ${blogPosts[0].author}

${blogPosts
    .map(
      (blog, index) =&amp;gt; `
### 글 ${index + 1}
- 제목: ${blog.title}
- 설명: ${blog.description.slice(0, MAX_DESCRIPTION_LENGTH)}
- 링크: ${blog.link}
`
    )
    .join('\\n')}
`;

&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;thought 토큰 줄이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 gemini-2.5-flash 모델을 쓰고 있었습니다. 이는 thinking 모델입니다. 블로그 글을 분석하는데, thinking 모델을 꼭 써야 할까? 생각해볼 수 있습니다. gemini-2.5-flash-lite 는 thinking 모델이 아니어서, 자동으로 thought token 을 0으로 쓰게 됩니다. 이러면, 응답 시간이 5~10초 가량 빨라질 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비슷한 퀄리티만 유지할 수 있다면, 큰 임팩트를 낼 수 있을 것 같았죠. 그렇게 모델을 교체했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 API 를 호출하고, 토큰 사용량과 응답 속도를 보았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;perf7.png&quot; data-origin-width=&quot;298&quot; data-origin-height=&quot;187&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNjfKS/dJMcabiKMDc/7Ad6XBYkLC36uoKCmJlI8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNjfKS/dJMcabiKMDc/7Ad6XBYkLC36uoKCmJlI8K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNjfKS/dJMcabiKMDc/7Ad6XBYkLC36uoKCmJlI8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNjfKS%2FdJMcabiKMDc%2F7Ad6XBYkLC36uoKCmJlI8K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;298&quot; height=&quot;187&quot; data-filename=&quot;perf7.png&quot; data-origin-width=&quot;298&quot; data-origin-height=&quot;187&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 토큰이 확연히 줄었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;perf8.png&quot; data-origin-width=&quot;1348&quot; data-origin-height=&quot;534&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbUb63/dJMcaia6wnD/7SbICvoUDIZSemcUdXFWi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbUb63/dJMcaia6wnD/7SbICvoUDIZSemcUdXFWi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbUb63/dJMcaia6wnD/7SbICvoUDIZSemcUdXFWi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbUb63%2FdJMcaia6wnD%2F7SbICvoUDIZSemcUdXFWi0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1348&quot; height=&quot;534&quot; data-filename=&quot;perf8.png&quot; data-origin-width=&quot;1348&quot; data-origin-height=&quot;534&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 속도도 10초 미만으로 달성할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;퀄리티 유지가 되는가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰을 막 줄이고, 모델을 바꾸는 것을 쉽습니다. 이것이 동일한 퀄리티를 유지할 수 있느냐가 중요한 질문이겠죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 모델에서 나온 응답과의 일치 여부를 보려고 했습니다. MBTI 분석, 컨텐츠 카테고리 비율 등이 내용에 포함되어 있는데요. 이게 이전 모델과 얼마나 일치하는지 보았습니다. 케이스 자체가 많지는 않았으므로, 십수개를 조사했습니다. 대부분 기존 결과와 유사해서, 퀄리티의 문제가 없다고 보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론적으로 살펴보자면, gemini-2.5-flash-lite 도 context window 가 1M 이기 때문에, 긴 input 도 쉽게 처리할 수 있습니다. 또한 thinking 모델이 아닌 Standard 모델도 문서 요약에 능합니다. LLM 의 기본 성질이 next token prediction 이므로, 참조할 글만 있다면, 어렵지 않게 문서를 요약하고, 새로운 글을 만들어낼 수 있습니다. 엄청나게 빠른 속도로요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Standard 모델로 바꾸어도, 퀄리티에 문제가 없다는 결론에 다다르게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드 분야에서 성능을 분석하고, 개선해본 경험은 처음이었습니다. 그렇지만 저는 프론트엔드 성능 분석에 강점이 있습니다. 성능을 개선한다는 것은, 측정 &amp;rarr; 병목 지점 파악 &amp;rarr; 요소별 원인 분석 &amp;rarr; 해결책 도출 &amp;rarr; 적용 &amp;rarr; 재측정 흐름을 타는 것은 동일합니다. 이 멘탈 모델이 중요할 뿐, 프론트엔드냐, 백엔드냐 하는 것은 중요하지 않았습니다. 그래서 서버 성능 측정, LLM 성능 측정, 토큰 성능 측정 등 처음 접하는 분야가 많았지만, 하나씩 학습해가며 성능 개선을 해냈습니다. 이는 일차적 성능 개선이고, 배포 이후 더 개선할 필요성이 있다면 다시 분석과 개선 과정을 거치려고 합니다.&lt;/p&gt;</description>
      <category>서버</category>
      <author>행복한 시지프 - 서버</author>
      <guid isPermaLink="true">https://happysisyphe-server.tistory.com/2</guid>
      <comments>https://happysisyphe-server.tistory.com/2#entry2comment</comments>
      <pubDate>Fri, 30 Jan 2026 01:40:45 +0900</pubDate>
    </item>
    <item>
      <title>From Cloudflare Workers, To Gemini API 에러</title>
      <link>https://happysisyphe-server.tistory.com/1</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;thumbnail_20260201072004.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bwd733/dJMcahpJ8Vc/FtZXpK6cDgKK4GtZK09JG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwd733/dJMcahpJ8Vc/FtZXpK6cDgKK4GtZK09JG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwd733/dJMcahpJ8Vc/FtZXpK6cDgKK4GtZK09JG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbwd733%2FdJMcahpJ8Vc%2FFtZXpK6cDgKK4GtZK09JG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;800&quot; data-filename=&quot;thumbnail_20260201072004.png&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 AI 블로그 분석기 서비스를 만들고 있습니다. Cloudflare Workers 로 API 를 서빙하고 있습니다. LLM API 는 gemini-2.5-flash-light 로 저렴하지만, 강력한 모델을 쓰고 있습니다. 오늘 서비스를 배포하고 바로 에러가 잡히기 시작하여, 분석해보았습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;에러 분석 : User location is not supported for the API use&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 후, 얼마 지나지 않아 Sentry 에 이런 로그가 잡혔습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1258&quot; data-origin-height=&quot;173&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTOmiL/dJMcaajOckL/37U6KdSUFAp5YhZ3f5WdpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTOmiL/dJMcaajOckL/37U6KdSUFAp5YhZ3f5WdpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTOmiL/dJMcaajOckL/37U6KdSUFAp5YhZ3f5WdpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTOmiL%2FdJMcaajOckL%2F37U6KdSUFAp5YhZ3f5WdpK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1258&quot; height=&quot;173&quot; data-origin-width=&quot;1258&quot; data-origin-height=&quot;173&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;상세 로그를 보시죠&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
    &quot;code&quot;: 400,
    &quot;message&quot;: &quot;User location is not supported for the API use.&quot;,
    &quot;status&quot;: &quot;FAILED_PRECONDITION&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;google gemini api 에서 이런 에러가 발생합니다. 왜 지원되지 않는 곳이라고 할까요?&lt;br /&gt;살펴보니, 서버를 Cloudflare Workers 에 배포한 것과 있었습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Cloudflare Workers 는 어떤 것인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Workers 는 Cloudflare 의 서버리스 플랫폼 입니다. 전 세계 300개 이상의 도시에서 Cloudflare 엣지 네트워크가 있습니다. 개발자는 서버를 직접 관리할 필요가 없고, 함수만 배포하면 됩니다. 엣지 방식이다보니, 요청이 올 때만 순간적으로 함수를 실행시키므로, 사이드 프로젝트를 수행하는 이들에게 매우 훌륭한 도구입니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;i&gt;참고 :&lt;/i&gt;&lt;i&gt;&amp;nbsp;&lt;/i&gt;&lt;a href=&quot;https://developers.cloudflare.com/workers/&quot; target=&quot;_self&quot;&gt;&lt;span&gt;&lt;i&gt;https://developers.cloudflare.com/workers/&lt;/i&gt;&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Cloudflare Workers 에서 Gemini API 호출에 간헐적으로 실패하는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 띄운 Workers 에 요청이 들어오면, Cloudflare Workers 는 동적으로 Edge 서버를 찾아서 Worker 를 Start 합니다. 근데 문제는 이 Edge 선택 알고리즘에 있는데요. 바로 &lt;a href=&quot;https://www.cloudflare.com/ko-kr/learning/cdn/glossary/anycast-network/&quot; target=&quot;_self&quot;&gt;&lt;span&gt;Anycast&lt;/span&gt;&lt;/a&gt; 라는 것입니다. 이 알고리즘은 단지 물리적인 거리로만 결정되는 것이 아닙니다. 비용이나, 네트워크 연결, 트래픽을 고려해서 더 효율적인 동선을 짜는거죠. 그러니까 요청이 서울에서 와도, 중국이나, 싱가폴 서버에 서빙될 수도 있는 것입니다.&lt;br /&gt;이게 무엇이 문제가 되냐면, 바로 Gemini 의 미지원 지역과 엇갈릴 수 있다는 것입니다. 가령, Gemini 는 중국에 서비스 하지 않는데요. Cloudflare Workers 가 상하이에 서빙된다면, Gemini API 로의 요청도 상하이에서 오는 것이 되는거죠. 그러니까 요청이 막히고 User Location is not suppoerted for the API use 이 에러가 발생하는 것입니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;i&gt;Gemini 지원 지역 참고 :&amp;nbsp;&lt;/i&gt;&lt;a href=&quot;https://ai.google.dev/gemini-api/docs/available-regions?hl=ko&quot; target=&quot;_self&quot;&gt;&lt;span&gt;&lt;i&gt;https://ai.google.dev/gemini-api/docs/available-regions?hl=ko&lt;/i&gt;&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&lt;i&gt;Cloudflare 서버 지역 참고 : &lt;/i&gt;&lt;a href=&quot;https://www.cloudflare.com/network/&quot; target=&quot;_self&quot;&gt;&lt;span&gt;&lt;i&gt;https://www.cloudflare.com/network/&lt;/i&gt;&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결책 탐색&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 유저가 에러를 겪고 있으니 빠르게 문제를 해소해야 했습니다. 해결법이 몇가지 떠올랐습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Gemini API 에러가 발생하면, 다른 LLM API 로 Fallback 한다.&lt;/b&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;가장 간단한 해결책입니다. 다만 gemini-2.5-flash-light API 만큼, 가성비가 훌륭하면서, 토큰양이 많은 모델이 없습니다. gemini API 를 활용하면, 저렴한 가격으로, 제가 원하는 퀄리티의 분석을 해낼 수 있습니다. 서비스에서 분석의 퀄리티가 가장 중요했으므로, 이것은 포기하기 힘들었습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Workers 와 Gemini API 사이에 Region 이 고정된 프록시 서버를 둔다.&lt;/b&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;이것은 본질적으로 훌륭한 엔지니어링 해결책입니다. 프록시 서버를 AWS Lambda 를 띄우면 됩니다. 하지만 이는 사이드 프로젝트 치고 아키텍쳐가 복잡하고, 비용도 발생하므로, 후순위로 고려하려고 했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Cloudflare Workers 에서 placement 속성을 정하여, 중국에서 떨어진 Region 을 활용한다.&lt;/b&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;다른 해결책을 찾아보다가, Workers 에서 placement 속성을 지정할 수 있다는 것을 알았다. 서울에 고정하면 베스트 이지만, 서울로 고정을 할 수는 없었다. placement.hint 로 대륙을 설정하여, 범위를 한정시켜줍니다. placement.mode = &amp;ldquo;smart&amp;rdquo; 로 지정하여, 그 내에서 최적화를 실행합니다. 아시아-태평양으로 위치를 한정시키면, 여전히 중국이 포함될 수가 있습니다. 그러므로 wnam 이나, eeur, oc 정도로 설정하여, 중국을 피하도록 만들어야 합니다.&lt;/li&gt;
&lt;/ol&gt;
Hint description
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;wnam&lt;/td&gt;
&lt;td&gt;Western North America&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;enam&lt;/td&gt;
&lt;td&gt;Eastern North America&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;weur&lt;/td&gt;
&lt;td&gt;Western Europe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eeur&lt;/td&gt;
&lt;td&gt;Eastern Europe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;apac&lt;/td&gt;
&lt;td&gt;Asia-Pacific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;oc&lt;/td&gt;
&lt;td&gt;Oceania&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;i&gt;출처 : &lt;a href=&quot;https://developers.cloudflare.com/r2/reference/data-location/#available-hints&quot;&gt;https://developers.cloudflare.com/r2/reference/data-location/#available-hints&lt;/a&gt;&lt;/i&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;3번 해결책은 설정값만 바꿔주고 배포하면 되므로, 단기간에 해결하기 좋았습니다. 아래처럼 설정을 변경하고, 문제를 일시적으로 해소했습니다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;placement&quot;: {
  &quot;mode&quot;: &quot;smart&quot;,
  &quot;hint&quot;: &quot;wnam&quot;
},  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;심각한 문제는 해소했습니다만, 또 다른 문제가 발생합니다. 미국으로 region 이 고정되면, Latency 가 증가합니다. 200ms 이상은 증가할 것으로 예상합니다. 다만, 현재 클라이언트에서 오는 API 요청은 한가지 입니다. LLM 을 함께 지르는 API 입니다. 그러므로, 이미 2~3초 이상의 Latency 가 있습니다. 그래서 200ms 가 더해지는 것은 사용자 경험에서 크리티컬 하지 않다고 판단하여, 임시방편을 적용했습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;근본적인 해소를 위해서는 인프라 플랫폼을 변경하거나, Lambda 를 Proxy 서버로 사용하는 방법을 적용해야 할 것입니다. 서비스가 반응이 좋다면, 향후 과제로 수행해 볼 계획입니다.&lt;/p&gt;</description>
      <category>서버</category>
      <category>CloudFlare</category>
      <category>gemini</category>
      <category>workers</category>
      <author>행복한 시지프 - 서버</author>
      <guid isPermaLink="true">https://happysisyphe-server.tistory.com/1</guid>
      <comments>https://happysisyphe-server.tistory.com/1#entry1comment</comments>
      <pubDate>Wed, 28 Jan 2026 01:49:05 +0900</pubDate>
    </item>
  </channel>
</rss>