<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Cloud.log</title>
    <link>https://dev-cloud.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Fri, 22 May 2026 11:03:14 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>devCloud</managingEditor>
    <image>
      <title>Cloud.log</title>
      <url>https://tistory1.daumcdn.net/tistory/5375257/attach/ef9a9b4a3415433f8cd2b679498342bf</url>
      <link>https://dev-cloud.tistory.com</link>
    </image>
    <item>
      <title>[설계의사결정] 시리즈 목록</title>
      <link>https://dev-cloud.tistory.com/507</link>
      <description>&lt;div&gt;
&lt;style&gt;
  .tistory-post {
    max-width: 780px;
    margin: 0 auto;
    line-height: 1.8;
    color: #222;
    font-size: 16px;
  }

  .tistory-post h1 {
    margin-top: 10px;
    margin-bottom: 14px;
    font-size: 34px;
    font-weight: 800;
    line-height: 1.4;
    color: #111;
  }

  .tistory-post h2 {
    margin-top: 52px;
    margin-bottom: 18px;
    font-size: 26px;
    border-bottom: 2px solid #222;
    padding-bottom: 8px;
    color: #111;
  }

  .tistory-post p {
    margin: 14px 0;
  }

  .tistory-post strong {
    color: #111;
  }

  .tistory-post blockquote {
    margin: 28px 0;
    padding: 22px 24px;
    border-left: 5px solid #2563eb;
    background: #f5f9ff;
    border-radius: 10px;
    color: #374151;
  }

  .tistory-post .post-header {
    margin-bottom: 46px;
    border-bottom: 3px solid #222;
    padding-bottom: 14px;
  }

  .tistory-post .badge {
    display: inline-block;
    background: #2563eb;
    color: #fff;
    padding: 5px 13px;
    border-radius: 5px;
    font-size: 13px;
    font-weight: 700;
    margin-bottom: 14px;
  }

  .tistory-post .toc {
    background: #f8fafc;
    border: 1px solid #e5e7eb;
    border-radius: 12px;
    padding: 28px;
    margin-bottom: 50px;
  }

  .tistory-post .toc-title {
    font-size: 18px;
    font-weight: 700;
    margin-bottom: 18px;
    color: #111;
  }

  .tistory-post .toc ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .tistory-post .toc li {
    margin-bottom: 12px;
  }

  .tistory-post .toc a {
    color: #2563eb;
    text-decoration: none;
    font-weight: 500;
  }

  .tistory-post .series-card {
    border: 1px solid #e5e7eb;
    border-radius: 16px;
    padding: 28px;
    margin-bottom: 28px;
    background: #fff;
    transition: all 0.2s ease;
  }

  .tistory-post .series-card:hover {
    border-color: #bfdbfe;
    box-shadow: 0 8px 24px rgba(37,99,235,0.08);
  }

  .tistory-post .series-number {
    display: inline-block;
    background: #eff6ff;
    color: #2563eb;
    padding: 4px 10px;
    border-radius: 999px;
    font-size: 13px;
    font-weight: 700;
    margin-bottom: 16px;
  }

  .tistory-post .series-title {
    font-size: 24px;
    font-weight: 800;
    color: #111;
    margin-bottom: 16px;
    line-height: 1.5;
  }

  .tistory-post .series-desc {
    color: #4b5563;
    margin-bottom: 22px;
  }

.tistory-post .detail-link {
  display: inline-block;
  text-decoration: none;
  color: #374151;
  font-size: 15px;
  font-weight: 600;
  margin-top: 4px;
  transition: all 0.15s ease;
}

.tistory-post .detail-link:hover {
  color: #2563eb;
}

  .tistory-post .aside-box {
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 5px solid #2563eb;
    border-radius: 12px;
    padding: 20px 22px;
    margin: 32px 0;
  }
&lt;/style&gt;
&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;
&lt;div class=&quot;post-header&quot;&gt;&lt;span class=&quot;badge&quot;&gt;ARCHITECTURE&lt;/span&gt;
&lt;h1&gt;[설계 의사결정] 시리즈 목록&lt;/h1&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;단순히 기능을 구현하는 것보다 더 중요했던 건 &amp;ldquo;왜 이 방식을 선택했는가?&amp;rdquo;에 대한 고민이었다.&lt;/blockquote&gt;
&lt;br /&gt;
&lt;div class=&quot;toc&quot;&gt;
&lt;div class=&quot;toc-title&quot;&gt;목차&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;#series1&quot;&gt;1. 이미지 업로드 정책 결정 과정&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#series2&quot;&gt;2. 페이지네이션 방식 선택 과정&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#series3&quot;&gt;3. 독서 리마인더 알림 구현 방식 선택 과정&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#series4&quot;&gt;4. 국내 도서 검색 API 선택 과정&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#series5&quot;&gt;5. JWT + Redis 인증 설계&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class=&quot;aside-box&quot;&gt;이 시리즈는 Booktine 개발 과정에서 실제로 고민했던 설계 선택 과정들을 정리한 글이다. 단순 구현 방법보다는 &amp;ldquo;왜 이 구조를 선택했는가&amp;rdquo;를 중심으로 기록했다.&lt;/div&gt;
&lt;div id=&quot;series1&quot; class=&quot;series-card&quot;&gt;
&lt;div class=&quot;series-number&quot;&gt;01&lt;/div&gt;
&lt;div class=&quot;series-title&quot;&gt;이미지 업로드 정책 결정 과정&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;Booktine에는 책 표지 이미지와 프로필 이미지 두 가지 이미지가 존재한다. 이미지를 저장하는 방식으로 외부 URL 직접 저장, 서버에서 S3 재업로드, Presigned URL을 통한 클라이언트 직접 업로드 세 가지를 비교했다.&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;이미지 종류별로 특성이 달라 각각 다른 방식을 선택하게 된 과정을 정리했다.&lt;/div&gt;
&lt;a class=&quot;detail-link&quot; href=&quot;https://dev-cloud.tistory.com/488&quot;&gt; &amp;rarr; 자세히 보기 &lt;/a&gt;&lt;/div&gt;
&lt;div id=&quot;series2&quot; class=&quot;series-card&quot;&gt;
&lt;div class=&quot;series-number&quot;&gt;02&lt;/div&gt;
&lt;div class=&quot;series-title&quot;&gt;페이지네이션 방식 선택 과정&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;게시물, 메모, 추천 도서 목록 등 목록 조회가 많은 서비스 특성상 페이지네이션 도입이 필요했다.&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;오프셋 기반과 커서 기반을 비교했고, Spring Data의 &lt;code&gt;Page&amp;lt;T&amp;gt;&lt;/code&gt;와 &lt;code&gt;Slice&amp;lt;T&amp;gt;&lt;/code&gt; 중 어떤 방식이 Booktine에 적합한지 선택한 과정을 정리했다.&lt;/div&gt;
&lt;a class=&quot;detail-link&quot; href=&quot;https://dev-cloud.tistory.com/490&quot;&gt; &amp;rarr; 자세히 보기 &lt;/a&gt;&lt;/div&gt;
&lt;div id=&quot;series3&quot; class=&quot;series-card&quot;&gt;
&lt;div class=&quot;series-number&quot;&gt;03&lt;/div&gt;
&lt;div class=&quot;series-title&quot;&gt;독서 리마인더 알림 구현 방식 선택 과정&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;사용자가 설정한 시간에 맞춰 서버가 알림을 전달하는 리마인더 기능을 구현하면서 &lt;code&gt;@Scheduled + SSE&lt;/code&gt;, 이메일, WebSocket, FCM 등 여러 방식을 비교했다.&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;단방향 알림 전달이라는 요구사항에 맞는 방식을 선택한 과정을 정리했다.&lt;/div&gt;
&lt;a class=&quot;detail-link&quot; href=&quot;https://dev-cloud.tistory.com/491&quot;&gt; &amp;rarr; 자세히 보기 &lt;/a&gt;&lt;/div&gt;
&lt;div id=&quot;series4&quot; class=&quot;series-card&quot;&gt;
&lt;div class=&quot;series-number&quot;&gt;04&lt;/div&gt;
&lt;div class=&quot;series-title&quot;&gt;국내 도서 검색 API 선택 과정&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;도서 검색 기능 구현을 위해 알라딘, 카카오, 네이버, 국립중앙도서관, 도서관 정보나루 API를 비교했다.&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;표지 이미지 제공 여부, 신간/베스트셀러 데이터, JSON 응답 지원 등을 기준으로 검토하고 최종 API를 선택한 과정을 정리했다.&lt;/div&gt;
&lt;a class=&quot;detail-link&quot; href=&quot;https://dev-cloud.tistory.com/504&quot;&gt; &amp;rarr; 자세히 보기 &lt;/a&gt;&lt;/div&gt;
&lt;div id=&quot;series5&quot; class=&quot;series-card&quot;&gt;
&lt;div class=&quot;series-number&quot;&gt;05&lt;/div&gt;
&lt;div class=&quot;series-title&quot;&gt;JWT + Redis 인증 설계&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;Booktine의 대부분의 기능이 인증을 전제로 하기 때문에 인증 방식 선택이 중요한 설계 결정이었다.&lt;/div&gt;
&lt;div class=&quot;series-desc&quot;&gt;세션 방식과 JWT를 비교하고, Refresh Token 강제 무효화 문제를 해결하기 위해 Redis를 도입한 과정을 정리했다.&lt;/div&gt;
&lt;a class=&quot;detail-link&quot; href=&quot;https://dev-cloud.tistory.com/506&quot;&gt; &amp;rarr; 자세히 보기 &lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;</description>
      <category>  PROJECT/[Spring Boot, React] 독서 습관 관리 서비스</category>
      <category>개발</category>
      <category>프로젝트</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/507</guid>
      <comments>https://dev-cloud.tistory.com/507#entry507comment</comments>
      <pubDate>Thu, 21 May 2026 22:05:16 +0900</pubDate>
    </item>
    <item>
      <title>[설계의사결정] JWT + Redis 인증</title>
      <link>https://dev-cloud.tistory.com/506</link>
      <description>&lt;div&gt;
&lt;style&gt;
  .tistory-post {
    max-width: 780px;
    margin: 0 auto;
    line-height: 1.8;
    color: #222;
    font-size: 16px;
  }

  .tistory-post h1 {
    margin-top: 10px;
    margin-bottom: 14px;
    font-size: 32px;
    font-weight: 800;
    line-height: 1.4;
    color: #111;
  }

  .tistory-post h2 {
    margin-top: 52px;
    margin-bottom: 18px;
    font-size: 26px;
    border-bottom: 2px solid #222;
    padding-bottom: 8px;
    color: #111;
  }

  .tistory-post h3 {
    margin-top: 36px;
    margin-bottom: 14px;
    font-size: 21px;
    color: #222;
  }

  .tistory-post p {
    margin: 14px 0;
  }

  .tistory-post strong {
    font-weight: 700;
    color: #111;
  }

  .tistory-post code {
    background: #f3f4f6;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 0.95em;
  }

  .tistory-post table {
    width: 100%;
    border-collapse: collapse;
    margin: 20px 0 30px;
    font-size: 15px;
  }

  .tistory-post th,
  .tistory-post td {
    border: 1px solid #d1d5db;
    padding: 12px;
    text-align: left;
    vertical-align: top;
  }

  .tistory-post th {
    background: #f3f4f6;
    font-weight: 700;
  }

  .tistory-post ul {
    padding-left: 22px;
    margin: 14px 0 24px;
  }

  .tistory-post li {
    margin: 8px 0;
  }

  .tistory-post blockquote {
    margin: 24px 0;
    padding: 20px 24px;
    border-left: 5px solid #2563eb;
    background: #f5f9ff;
    border-radius: 10px;
    color: #333;
  }

  .tistory-post .post-header {
    margin-bottom: 42px;
    border-bottom: 3px solid #333;
    padding-bottom: 12px;
  }

  .tistory-post .badge {
    display: inline-block;
    background: #2563eb;
    color: #fff;
    padding: 4px 12px;
    border-radius: 4px;
    font-size: 14px;
    font-weight: 700;
    margin-bottom: 14px;
  }

  .tistory-post .toc {
    background: #f8f9fa;
    border-radius: 10px;
    padding: 26px;
    margin-bottom: 50px;
    border: 1px solid #e5e7eb;
  }

  .tistory-post .toc-title {
    margin: 0 0 16px;
    font-size: 18px;
    font-weight: 700;
    color: #111;
  }

  .tistory-post .toc ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .tistory-post .toc li {
    margin-bottom: 10px;
  }

  .tistory-post .toc a {
    color: #2563eb;
    text-decoration: none;
    font-weight: 500;
  }

  .tistory-post .aside-box {
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 5px solid #2563eb;
    border-radius: 10px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .highlight-box {
    background: #f9fafb;
    border: 1px solid #e5e7eb;
    border-radius: 10px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .diagram-wrap {
    text-align: center;
    margin: 30px 0;
  }

  .diagram-wrap img {
    width: 320px;
    max-width: 100%;
    border-radius: 10px;
  }

  .diagram-desc {
    margin-top: 16px;
    padding: 16px 18px;
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 4px solid #2563eb;
    border-radius: 10px;
    text-align: left;
    font-size: 15px;
    line-height: 1.7;
    color: #374151;
  }

  .diagram-desc strong {
    color: #111827;
  }
&lt;/style&gt;
&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;
&lt;div class=&quot;post-header&quot;&gt;&lt;span class=&quot;badge&quot;&gt;AUTH&lt;/span&gt;
&lt;h1&gt;JWT + Redis 인증 설계 - 세션 대신 JWT를 선택한 이유&lt;/h1&gt;
&lt;/div&gt;
&lt;div class=&quot;toc&quot;&gt;
&lt;p class=&quot;toc-title&quot; 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;a href=&quot;#section1&quot;&gt;1. 배경&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section2&quot;&gt;2. 인증 방식 비교&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section3&quot;&gt;3. 세션을 선택하지 않은 이유&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section4&quot;&gt;4. 결정 &amp;mdash; JWT + Redis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section5&quot;&gt;5. JWT의 한계&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Booktine은 로그인 기반 기능이 많은 서비스다. 따라서 인증 구조를 어떻게 설계할지가 서비스 전체 구조에 큰 영향을 줬다.&lt;/blockquote&gt;
&lt;h2 id=&quot;section1&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 배경&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Booktine은 로그인한 사용자만 이용할 수 있는 기능이 많다. 독서 기록 작성, 메모, 진행률 관리 등 대부분의 핵심 기능이 인증을 전제로 한다. 따라서 어떤 방식으로 인증을 처리할지가 중요한 설계 결정 중 하나였다.&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;div class=&quot;highlight-box&quot;&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;프론트엔드(React)와의 연동 편의성&lt;/li&gt;
&lt;li&gt;구현 복잡도&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;br&gt;
&lt;h2 id=&quot;section2&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 인증 방식 비교&lt;/b&gt;&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;width: 69px;&quot;&gt;방식&lt;/th&gt;
&lt;th style=&quot;width: 429px;&quot;&gt;설명&lt;/th&gt;
&lt;th style=&quot;width: 185px;&quot;&gt;확장성&lt;/th&gt;
&lt;th style=&quot;width: 96px;&quot;&gt;구현 난이도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 69px;&quot;&gt;&lt;b&gt;세션&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 429px;&quot;&gt;서버 메모리에 인증 정보를 저장하고, 클라이언트는 세션 ID만 갖는다.&lt;/td&gt;
&lt;td style=&quot;width: 185px;&quot;&gt;낮음 (서버 상태 유지 필요)&lt;/td&gt;
&lt;td style=&quot;width: 96px;&quot;&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 69px;&quot;&gt;&lt;b&gt;JWT&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 429px;&quot;&gt;인증 정보를 토큰에 담아 클라이언트가 직접 보관한다.&lt;/td&gt;
&lt;td style=&quot;width: 185px;&quot;&gt;높음 (Stateless)&lt;/td&gt;
&lt;td style=&quot;width: 96px;&quot;&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;aside-box&quot;&gt;세션은 서버 상태를 유지해야 하고, JWT는 Stateless 구조라는 차이가 가장 핵심이었다.&lt;/div&gt;
    &lt;br&gt;
&lt;h2 id=&quot;section3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 세션을 선택하지 않은 이유&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션은 구현이 단순하고 Spring Security와의 기본 연동이 잘 되어 있다는 장점이 있다. 하지만 구조를 고려했을 때 몇 가지 문제가 있다고 판단했다.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&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;React 기반 SPA와의 연동에서 CORS 설정이 복잡해질 수 있다.&lt;/li&gt;
&lt;li&gt;REST API 구조에서는 Stateless 방식이 더 자연스럽다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 프론트엔드가 React로 분리된 구조이기 때문에, 쿠키 기반 세션보다 토큰 기반 인증이 연동하기 더 편리하다고 판단했다.&lt;/p&gt;
    &lt;br&gt;
&lt;h2 id=&quot;section4&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 결정 - JWT + Redis&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Access Token / Refresh Token 발급&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT는 Access Token과 Refresh Token 두 가지를 발급하는 구조로 설계했다. Access Token은 만료 시간을 짧게 설정해 탈취 위험을 줄이고, 만료 시 Refresh Token으로 재발급받도록 했다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;297&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HFT4g/dJMcadIGpE6/EMM3krncI6lsEMyTef2lpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HFT4g/dJMcadIGpE6/EMM3krncI6lsEMyTef2lpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HFT4g/dJMcadIGpE6/EMM3krncI6lsEMyTef2lpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHFT4g%2FdJMcadIGpE6%2FEMM3krncI6lsEMyTef2lpk%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;538&quot; height=&quot;297&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;297&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;aside-box&quot;&gt;JwtProvider에서는 Access Token과 Refresh Token 생성, Claim 파싱, 만료 검증 등의 핵심 JWT 로직을 담당한다.&lt;/div&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;540&quot; data-origin-height=&quot;312&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cBHCE8/dJMcaf7DsHV/EFVeSgdPitSdWOfImixiDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cBHCE8/dJMcaf7DsHV/EFVeSgdPitSdWOfImixiDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cBHCE8/dJMcaf7DsHV/EFVeSgdPitSdWOfImixiDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcBHCE8%2FdJMcaf7DsHV%2FEFVeSgdPitSdWOfImixiDk%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;540&quot; height=&quot;312&quot; data-origin-width=&quot;540&quot; data-origin-height=&quot;312&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;aside-box&quot;&gt;JWT 내부 Claim 정보를 추출하고, 토큰 유효성 검사를 수행하는 로직이다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Refresh Token을 Redis에 저장한 이유&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT는 기본적으로 서버가 상태를 저장하지 않기 때문에, 한번 발급된 토큰을 서버에서 강제로 무효화하기 어렵다. 이 문제를 해결하기 위해 Refresh Token을 Redis에 저장했다.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로그아웃 시 Redis에서 Refresh Token을 삭제하면 재발급이 불가능해진다.&lt;/li&gt;
&lt;li&gt;Redis TTL 기능으로 만료 처리를 자동화할 수 있다.&lt;/li&gt;
&lt;li&gt;메모리 기반 저장소라 조회 속도가 빠르다.&lt;/li&gt;
&lt;li&gt;RDB에 저장하는 방식보다 DB 부담이 적다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도 Repository 클래스 없이 &lt;code&gt;StringRedisTemplate&lt;/code&gt;을 &lt;code&gt;AuthService&lt;/code&gt;에서 직접 사용하는 방식으로 구현했다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;898&quot; data-origin-height=&quot;419&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brkmDu/dJMcadBU2Oc/xhkQaN9JKQvqbs0LrRlT7k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brkmDu/dJMcadBU2Oc/xhkQaN9JKQvqbs0LrRlT7k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brkmDu/dJMcadBU2Oc/xhkQaN9JKQvqbs0LrRlT7k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrkmDu%2FdJMcadBU2Oc%2FxhkQaN9JKQvqbs0LrRlT7k%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;898&quot; height=&quot;419&quot; data-origin-width=&quot;898&quot; data-origin-height=&quot;419&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;aside-box&quot;&gt;Refresh Token은 Redis에 TTL과 함께 저장된다. 이를 통해 만료 시간을 자동으로 관리할 수 있다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 유지 여부에 따라 Refresh Token의 TTL도 다르게 설정했다. 로그인 유지를 선택하지 않으면 최대 12시간으로 제한한다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/P3XuV/dJMcadhInr9/DELEjXoZLDhhIkj7eqaHgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/P3XuV/dJMcadhInr9/DELEjXoZLDhhIkj7eqaHgk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/P3XuV/dJMcadhInr9/DELEjXoZLDhhIkj7eqaHgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FP3XuV%2FdJMcadhInr9%2FDELEjXoZLDhhIkj7eqaHgk%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;710&quot; height=&quot;158&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;158&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;aside-box&quot;&gt;로그인 유지 여부에 따라 Refresh Token의 TTL을 다르게 설정해 보안성과 사용자 편의성 사이의 균형을 맞췄다.&lt;/div&gt;
&lt;div class=&quot;diagram-wrap&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1328&quot; data-origin-height=&quot;1402&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQHnnX/dJMcajhTifH/UcV8ea1mv37FNYyZ74RVlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQHnnX/dJMcajhTifH/UcV8ea1mv37FNYyZ74RVlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQHnnX/dJMcajhTifH/UcV8ea1mv37FNYyZ74RVlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQHnnX%2FdJMcajhTifH%2FUcV8ea1mv37FNYyZ74RVlK%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;385&quot; height=&quot;406&quot; data-origin-width=&quot;1328&quot; data-origin-height=&quot;1402&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;diagram-desc&quot;&gt;&lt;b&gt;로그인 및 토큰 발급 흐름&lt;/b&gt;&lt;br /&gt;사용자가 로그인에 성공하면 서버는 Access Token과 Refresh Token을 생성한다. Refresh Token은 Redis에 TTL과 함께 저장되고, Access Token은 클라이언트에 전달된다.&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;로그아웃 및 Access Token 블랙리스트&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그아웃 시에는 Redis에서 Refresh Token을 삭제하고, 현재 Access Token을 블랙리스트에 등록한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Access Token은 만료 전까지 유효하기 때문에, 블랙리스트에 등록해 재사용을 막는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블랙리스트의 TTL은 Access Token의 남은 만료 시간으로 설정했다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;837&quot; data-origin-height=&quot;297&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qQLIw/dJMcaaFjQ9A/EsAso8TB8txviwBuQAvar1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qQLIw/dJMcaaFjQ9A/EsAso8TB8txviwBuQAvar1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qQLIw/dJMcaaFjQ9A/EsAso8TB8txviwBuQAvar1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqQLIw%2FdJMcaaFjQ9A%2FEsAso8TB8txviwBuQAvar1%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;837&quot; height=&quot;297&quot; data-origin-width=&quot;837&quot; data-origin-height=&quot;297&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;aside-box&quot;&gt;로그아웃 시 Refresh Token을 Redis에서 제거하고, 현재 Access Token을 블랙리스트에 등록한다.&lt;/div&gt;
&lt;div class=&quot;diagram-wrap&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1233&quot; data-origin-height=&quot;837&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2IOrt/dJMcahYHtFL/nA5JeZWSc9ProdtfXhww91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2IOrt/dJMcahYHtFL/nA5JeZWSc9ProdtfXhww91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2IOrt/dJMcahYHtFL/nA5JeZWSc9ProdtfXhww91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2IOrt%2FdJMcahYHtFL%2FnA5JeZWSc9ProdtfXhww91%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;616&quot; height=&quot;418&quot; data-origin-width=&quot;1233&quot; data-origin-height=&quot;837&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;diagram-desc&quot;&gt;&lt;b&gt;로그아웃 및 토큰 무효화 흐름&lt;/b&gt;&lt;br /&gt;로그아웃 요청이 들어오면 Refresh Token은 Redis에서 삭제된다. 동시에 현재 Access Token은 블랙리스트에 등록되어 만료 전까지 재사용되지 못하도록 차단된다.&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtFilter에서는 매 요청마다 블랙리스트 등록 여부를 확인한다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;675&quot; data-origin-height=&quot;183&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/co57AO/dJMcaaFjQ9G/D80Jr9AcKakOT84TKIqtQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/co57AO/dJMcaaFjQ9G/D80Jr9AcKakOT84TKIqtQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/co57AO/dJMcaaFjQ9G/D80Jr9AcKakOT84TKIqtQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fco57AO%2FdJMcaaFjQ9G%2FD80Jr9AcKakOT84TKIqtQk%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;675&quot; height=&quot;183&quot; data-origin-width=&quot;675&quot; data-origin-height=&quot;183&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;aside-box&quot;&gt;JwtFilter는 요청마다 Access Token이 블랙리스트에 등록됐는지 확인해 무효화된 토큰의 접근을 차단한다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Access Token 재발급&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Refresh Token 검증 후 Redis에 저장된 값과 일치하는 경우에만 새 Access Token을 발급한다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;599&quot; data-origin-height=&quot;206&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Br040/dJMcadPtZg0/KkMMKk58kGRuitGjIjKFXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Br040/dJMcadPtZg0/KkMMKk58kGRuitGjIjKFXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Br040/dJMcadPtZg0/KkMMKk58kGRuitGjIjKFXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBr040%2FdJMcadPtZg0%2FKkMMKk58kGRuitGjIjKFXK%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;599&quot; height=&quot;206&quot; data-origin-width=&quot;599&quot; data-origin-height=&quot;206&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;aside-box&quot;&gt;Refresh Token 검증 후 Redis 저장값과 비교해 유효한 경우에만 Access Token을 재발급한다.&lt;/div&gt;
&lt;div class=&quot;diagram-wrap&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1273&quot; data-origin-height=&quot;1478&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lvAPM/dJMcai4j67s/g8UDDKsBRJkkaGRjza5KpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lvAPM/dJMcai4j67s/g8UDDKsBRJkkaGRjza5KpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lvAPM/dJMcai4j67s/g8UDDKsBRJkkaGRjza5KpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlvAPM%2FdJMcai4j67s%2Fg8UDDKsBRJkkaGRjza5KpK%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;543&quot; height=&quot;630&quot; data-origin-width=&quot;1273&quot; data-origin-height=&quot;1478&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;diagram-desc&quot;&gt;&lt;b&gt;Access Token 재발급 흐름&lt;/b&gt;&lt;br /&gt;클라이언트는 만료된 Access Token 대신 Refresh Token으로 재발급 요청을 보낸다. 서버는 Redis에 저장된 Refresh Token과 비교 검증 후 새로운 Access Token을 발급한다.&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;선택 이유&lt;/b&gt;&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;React 기반 SPA와 토큰 기반 인증 연동이 자연스럽다.&lt;/li&gt;
&lt;li&gt;Stateless 구조로 서버 확장에 유리하다.&lt;/li&gt;
&lt;li&gt;Redis TTL로 Refresh Token 만료를 자동 처리할 수 있다.&lt;/li&gt;
&lt;li&gt;로그아웃 시 Refresh Token 삭제 + Access Token 블랙리스트 등록으로 강제 무효화가 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
    &lt;br&gt;
&lt;h2 id=&quot;section5&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. JWT의 한계&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Access Token은 만료 전까지 서버에서 강제로 무효화할 수 없다. 따라서 탈취된 경우에도 만료 시간까지는 유효하게 사용될 수 있다는 단점이 있다.&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;다만 Access Token의 만료 시간을 짧게 설정하고, 로그아웃 시 블랙리스트에 등록하는 방식으로 이 문제를 최소화했다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;현재 Booktine 서비스 규모에서는 JWT + Redis 방식이 확장성과 구현 복잡도 사이에서 가장 적절한 균형이라고 판단했다.&lt;/div&gt;
&lt;/div&gt;</description>
      <category>  PROJECT/[Spring Boot, React] 독서 습관 관리 서비스</category>
      <category>Access Token</category>
      <category>JWT</category>
      <category>Redis</category>
      <category>refresh token</category>
      <category>개발</category>
      <category>인증 구현</category>
      <category>일상</category>
      <category>프로젝트</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/506</guid>
      <comments>https://dev-cloud.tistory.com/506#entry506comment</comments>
      <pubDate>Wed, 13 May 2026 20:58:29 +0900</pubDate>
    </item>
    <item>
      <title>[프로젝트 개요] 프로젝트 소개 및 기획</title>
      <link>https://dev-cloud.tistory.com/505</link>
      <description>&lt;div class=&quot;booktine-post&quot;&gt;
&lt;style&gt;
    .booktine-post {
      max-width: 800px;
      margin: 0 auto;
      padding: 24px 18px;
      background: #ffffff;
      color: #2D2D2D;
      font-size: 16px;
      line-height: 1.8;
      word-break: keep-all;
    }

    .booktine-post h2 {
      font-size: 26px;
      margin: 42px 0 22px;
      color: #2D2D2D;
      font-weight: 700;
      scroll-margin-top: 80px;
    }

    .booktine-post h3 {
      font-size: 20px;
      margin: 34px 0 16px;
      padding-left: 14px;
      border-left: 5px solid #7BC8A4;
      color: #2D2D2D;
      font-weight: 700;
      scroll-margin-top: 80px;
    }

    .booktine-post p {
      margin: 0 0 20px;
    }

    .booktine-post hr {
      border: 0;
      border-top: 1px solid #E5E5E5;
      margin: 36px 0;
    }

    .booktine-post ul {
      margin: 0 0 24px 20px;
      padding: 0;
    }

    .booktine-post li {
      margin-bottom: 8px;
    }

    .booktine-post table {
      width: 100%;
      border-collapse: collapse;
      margin: 20px 0 32px;
      font-size: 15px;
    }

    .booktine-post th {
      background: #A8D8C2;
      color: #2D2D2D;
      padding: 12px;
      border: 1px solid #D6E8DE;
      text-align: left;
      font-weight: 700;
    }

    .booktine-post td {
      padding: 12px;
      border: 1px solid #E5E5E5;
      vertical-align: top;
    }

    .booktine-post tbody tr:nth-child(odd) {
      background: #ffffff;
    }

    .booktine-post tbody tr:nth-child(even) {
      background: #F7FAF8;
    }

    .booktine-post .badge {
      display: inline-block;
      padding: 2px 8px;
      margin: 0 2px;
      border-radius: 999px;
      background: #E8F5EF;
      color: #2D2D2D;
      font-size: 14px;
      font-weight: 500;
      white-space: nowrap;
    }

    .booktine-post .image-placeholder {
      margin: 20px 0 30px;
      padding: 48px 20px;
      border: 1px dashed #A8D8C2;
      border-radius: 12px;
      background: #F7FAF8;
      text-align: center;
      color: #6B6B6B;
    }

    .booktine-post .priority-title {
      font-weight: 700;
      margin-top: 24px;
      margin-bottom: 8px;
    }

    .booktine-post .toc-box {
      background: #E8F5EF;
      border: 1px solid #A8D8C2;
      border-radius: 14px;
      padding: 24px;
      margin-bottom: 40px;
    }

    .booktine-post .toc-title {
      font-size: 22px;
      font-weight: 700;
      margin-bottom: 18px;
      color: #2D2D2D;
    }

    .booktine-post .toc-list {
      list-style: none;
      margin: 0;
      padding: 0;
    }

    .booktine-post .toc-list li {
      margin-bottom: 10px;
    }

    .booktine-post .toc-list a {
      color: #2D2D2D;
      text-decoration: none;
      transition: all 0.2s ease;
    }

    .booktine-post .toc-list a:hover {
      color: #4E9B77;
    }

    .booktine-post .toc-sub {
      padding-left: 14px;
      font-size: 15px;
    }
  
    .booktine-post .github-button {
      display: inline-block;
      margin: 0 0 28px;
      padding: 10px 18px;
      background: #2D2D2D;
      color: #ffffff;
      border-radius: 10px;
      text-decoration: none;
      font-size: 15px;
      font-weight: 600;
    }
  
  	.booktine-post .github-button:hover {
      opacity: 0.85;
    }
  
  &lt;/style&gt;
&lt;div class=&quot;toc-box&quot;&gt;
&lt;div class=&quot;toc-title&quot;&gt;목차&lt;/div&gt;
&lt;ul class=&quot;toc-list&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;#intro&quot;&gt;프로젝트 소개&lt;/a&gt;&lt;/li&gt;
&lt;li class=&quot;toc-sub&quot;&gt;&lt;a href=&quot;#background&quot;&gt;기획 배경&lt;/a&gt;&lt;/li&gt;
&lt;li class=&quot;toc-sub&quot;&gt;&lt;a href=&quot;#goal&quot;&gt;서비스 목표&lt;/a&gt;&lt;/li&gt;
&lt;li class=&quot;toc-sub&quot;&gt;&lt;a href=&quot;#target&quot;&gt;타겟 사용자&lt;/a&gt;&lt;/li&gt;
&lt;li class=&quot;toc-sub&quot;&gt;&lt;a href=&quot;#scope&quot;&gt;기능 범위 결정 과정&lt;/a&gt;&lt;/li&gt;
&lt;li class=&quot;toc-sub&quot;&gt;&lt;a href=&quot;#schedule&quot;&gt;개발 일정 설계&lt;/a&gt;&lt;/li&gt;
&lt;li class=&quot;toc-sub&quot;&gt;&lt;a href=&quot;#features&quot;&gt;주요 기능&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#tech-stack&quot;&gt;기술 스택&lt;/a&gt;&lt;/li&gt;
&lt;li class=&quot;toc-sub&quot;&gt;&lt;a href=&quot;#backend&quot;&gt;Backend&lt;/a&gt;&lt;/li&gt;
&lt;li class=&quot;toc-sub&quot;&gt;&lt;a href=&quot;#frontend&quot;&gt;Frontend&lt;/a&gt;&lt;/li&gt;
&lt;li class=&quot;toc-sub&quot;&gt;&lt;a href=&quot;#infra&quot;&gt;Infra&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#architecture&quot;&gt;시스템 아키텍처&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#erd&quot;&gt;ERD&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id=&quot;intro&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;프로젝트 소개&lt;/b&gt;&lt;/h2&gt;
&lt;a class=&quot;github-button&quot; title=&quot;프로젝트 링크&quot; href=&quot;https://github.com/c1oud-dev/Booktine&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt; GitHub 바로가기 &lt;/a&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1248&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dT79ej/dJMcadhGH7h/2emiZvqN3Ju3GwaqqcNg2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dT79ej/dJMcadhGH7h/2emiZvqN3Ju3GwaqqcNg2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dT79ej/dJMcadhGH7h/2emiZvqN3Ju3GwaqqcNg2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdT79ej%2FdJMcadhGH7h%2F2emiZvqN3Ju3GwaqqcNg2K%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;1920&quot; height=&quot;1248&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1248&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;!-- 이미지 --&gt;
&lt;h3 id=&quot;background&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;기획 배경&lt;/b&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;그러다 문득 &quot;내가 원하는 대로 직접 만들면 되겠다&quot;는 생각이 들었다. 마침 백엔드 개발자를 목표로 하고 있었고, 내가 실제로 쓰고 싶은 서비스를 만드는 게 가장 좋은 공부라고 생각했다. 읽은 책의 기록과 메모, 월간&amp;middot;연간 독서 목표, 통계 시각화, 도서 추천, 독서 커뮤니티까지 내가 원하는 기능을 하나씩 직접 설계하고 구현한 것이 Booktine이다.&lt;/p&gt;
&lt;h3 id=&quot;goal&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;서비스 목표&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;독서 기록을 한 곳에서 관리하고, 목표를 설정하고, 얼마나 읽었는지 한눈에 파악할 수 있는 서비스를 만드는 것이 목표였다. 단순한 기록 저장을 넘어 독서 습관을 만들고 유지하는 데 실질적으로 도움이 되는 서비스를 지향했다.&lt;/p&gt;
&lt;h3 id=&quot;target&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;타겟 사용자&lt;/b&gt;&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;scope&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;기능 범위 결정 과정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능을 P1 / P2 / P3 세 단계로 나눠 우선순위를 정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;priority-title&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;P1 - 핵심 기능 (반드시 구현)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;독서 기록 서비스의 본질에 해당하는 기능들이다. 회원가입&amp;middot;로그인, 게시물 CRUD, 메모 CRUD, 목표 설정, 기본 통계가 여기에 해당한다. 이 기능들 없이는 서비스 자체가 성립하지 않는다고 판단했다.&lt;/p&gt;
&lt;p class=&quot;priority-title&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;priority-title&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;P2 - 보완 기능 (있으면 좋은 기능)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;P1만으로는 서비스가 단조롭기 때문에 사용성을 높여주는 기능들을 P2로 분류했다. 키워드 검색, 독서 상태 필터, 장르별 통계, 도서 추천이 여기에 해당한다. 필수는 아니지만 빠지면 아쉬운 기능들이다.&lt;/p&gt;
&lt;p class=&quot;priority-title&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;priority-title&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;P3 - 확장 기능 (시간이 남으면 구현)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커뮤니티, 리마인더, 관리자 기능은 독서 기록 서비스의 핵심과는 거리가 있다고 판단해 P3로 미뤘다. 서비스의 본질적인 기능을 먼저 완성한 뒤 시간이 남을 때 구현하는 방향으로 결정했다. 독서 챌린지는 기획은 해뒀지만 최종적으로 구현하지 않았다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;schedule&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;개발 일정 설계&lt;/b&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;D1 ~ D9는 백엔드 전체 구현, D9-1은 리팩토링, D10 ~ D12는 프론트엔드 구현, D13 ~ D14는 배포 및 마무리로 일정을 구성했다.&lt;/p&gt;
&lt;h3 id=&quot;features&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;주요 기능&lt;/b&gt;&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;기능&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;독서 기록 (Book Note)&lt;/td&gt;
&lt;td&gt;독서 상태(읽는 중 / 완독 / 일시정지 / 읽고 싶음) 관리, 별점&amp;middot;한줄평&amp;middot;장르 기반 노트 작성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메모 관리&lt;/td&gt;
&lt;td&gt;도서 단위로 메모 작성&amp;middot;수정&amp;middot;삭제, 책을 읽으며 남긴 문장&amp;middot;감상&amp;middot;요약 정리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;목표 및 통계 (Progress)&lt;/td&gt;
&lt;td&gt;월간&amp;middot;연간 독서 목표 설정, 장르 분포&amp;middot;월별 완독&amp;middot;연간 요약 시각화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도서 추천&lt;/td&gt;
&lt;td&gt;알라딘 API 연동, 베스트셀러&amp;middot;장르 기반 추천, 저장 및 목록 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;독서 커뮤니티&lt;/td&gt;
&lt;td&gt;게시글 작성&amp;middot;수정&amp;middot;삭제, 댓글&amp;middot;대댓글&amp;middot;좋아요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;리마인더&lt;/td&gt;
&lt;td&gt;독서 리마인더 등록, SSE 기반 실시간 알림 수신&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;관리자&lt;/td&gt;
&lt;td&gt;사용자&amp;middot;게시글&amp;middot;장르&amp;middot;문의 목록 조회, 장르 추가&amp;middot;삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;기술 스택&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;Backend&lt;/b&gt;&lt;/h3&gt;
&lt;table style=&quot;height: 252px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 27px;&quot;&gt;
&lt;th style=&quot;width: 93px; height: 27px;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;width: 234px; height: 27px;&quot;&gt;기술&lt;/th&gt;
&lt;th style=&quot;width: 472px; height: 27px;&quot;&gt;선택 이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 54px;&quot;&gt;
&lt;td style=&quot;width: 93px; height: 54px;&quot;&gt;Language&lt;/td&gt;
&lt;td style=&quot;width: 234px; height: 54px;&quot;&gt;&lt;span class=&quot;badge&quot;&gt;Java 21&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;GraalVM&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 472px; height: 54px;&quot;&gt;Record, Switch Expression 등 최신 문법 활용, GraalVM 네이티브 이미지로 시작 시간 단축&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 29px;&quot;&gt;
&lt;td style=&quot;width: 93px; height: 29px;&quot;&gt;Framework&lt;/td&gt;
&lt;td style=&quot;width: 234px; height: 29px;&quot;&gt;&lt;span class=&quot;badge&quot;&gt;Spring Boot 3.3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 472px; height: 29px;&quot;&gt;Spring Security 6, Virtual Thread 등 최신 기능 활용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 29px;&quot;&gt;
&lt;td style=&quot;width: 93px; height: 29px;&quot;&gt;Persistence&lt;/td&gt;
&lt;td style=&quot;width: 234px; height: 29px;&quot;&gt;&lt;span class=&quot;badge&quot;&gt;Spring Data JPA&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;QueryDSL&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 472px; height: 29px;&quot;&gt;동적 쿼리를 타입 안전하게 작성하기 위해 QueryDSL 도입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 29px;&quot;&gt;
&lt;td style=&quot;width: 93px; height: 29px;&quot;&gt;Database&lt;/td&gt;
&lt;td style=&quot;width: 234px; height: 29px;&quot;&gt;&lt;span class=&quot;badge&quot;&gt;MySQL 8&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;H2&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 472px; height: 29px;&quot;&gt;관계형 데이터 구조에 적합, 로컬에서는 H2로 빠르게 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 26px;&quot;&gt;
&lt;td style=&quot;width: 93px; height: 26px;&quot;&gt;Auth&lt;/td&gt;
&lt;td style=&quot;width: 234px; height: 26px;&quot;&gt;&lt;span class=&quot;badge&quot;&gt;JWT&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;Redis&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 472px; height: 26px;&quot;&gt;Stateless 인증 구조, Redis로 Refresh Token 및 블랙리스트 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 29px;&quot;&gt;
&lt;td style=&quot;width: 93px; height: 29px;&quot;&gt;Storage&lt;/td&gt;
&lt;td style=&quot;width: 234px; height: 29px;&quot;&gt;&lt;span class=&quot;badge&quot;&gt;AWS S3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 472px; height: 29px;&quot;&gt;프로필 이미지, 게시물 표지 이미지 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 29px;&quot;&gt;
&lt;td style=&quot;width: 93px; height: 29px;&quot;&gt;External&lt;/td&gt;
&lt;td style=&quot;width: 234px; height: 29px;&quot;&gt;&lt;span class=&quot;badge&quot;&gt;Aladin API&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;SMTP&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 472px; height: 29px;&quot;&gt;도서 검색&amp;middot;추천 연동, 이메일 인증&amp;middot;비밀번호 재설정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;Frontend&lt;/b&gt;&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;기술&lt;/th&gt;
&lt;th&gt;선택 이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;&lt;span class=&quot;badge&quot;&gt;React 18&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;TypeScript&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;컴포넌트 재사용성과 타입 안전성 확보&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Styling&lt;/td&gt;
&lt;td&gt;&lt;span class=&quot;badge&quot;&gt;Tailwind CSS v3&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;shadcn/ui&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;유틸리티 기반으로 빠른 UI 구성, shadcn/ui로 일관된 컴포넌트 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;&lt;span class=&quot;badge&quot;&gt;Axios&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;인터셉터로 Bearer Token 자동 주입 및 토큰 재발급 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;nbsp;Infra&lt;/b&gt;&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;기술&lt;/th&gt;
&lt;th&gt;선택 이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compute&lt;/td&gt;
&lt;td&gt;&lt;span class=&quot;badge&quot;&gt;AWS EC2&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;Docker&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;컨테이너 기반으로 운영 환경 일관성 확보&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;&lt;span class=&quot;badge&quot;&gt;AWS RDS&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;MySQL 8&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;관리형 DB로 운영 부담 최소화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache&lt;/td&gt;
&lt;td&gt;&lt;span class=&quot;badge&quot;&gt;AWS ElastiCache&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;Redis&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;토큰 저장, 블랙리스트, 인증 코드, 실패/잠금 정보 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage / CDN&lt;/td&gt;
&lt;td&gt;&lt;span class=&quot;badge&quot;&gt;AWS S3&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;CloudFront&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;정적 파일 서빙 및 이미지 CDN 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;&lt;span class=&quot;badge&quot;&gt;ALB&lt;/span&gt; &lt;span class=&quot;badge&quot;&gt;HTTPS&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;도메인 기반 라우팅, SSL 인증서 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;시스템 아키텍처&lt;/b&gt;&lt;/h2&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1344&quot; data-origin-height=&quot;839&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TIBhK/dJMcaaFie06/2S0ACYcmcv7Xb1cUKLmi9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TIBhK/dJMcaaFie06/2S0ACYcmcv7Xb1cUKLmi9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TIBhK/dJMcaaFie06/2S0ACYcmcv7Xb1cUKLmi9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTIBhK%2FdJMcaaFie06%2F2S0ACYcmcv7Xb1cUKLmi9k%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;1344&quot; height=&quot;839&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1344&quot; data-origin-height=&quot;839&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;!-- 이미지 --&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트(React)는 CloudFront를 통해 정적 파일을 서빙받고, API 요청은 ALB를 거쳐 EC2의 Spring Boot 컨테이너로 전달된다. 모든 요청은 &lt;span class=&quot;badge&quot;&gt;JwtFilter&lt;/span&gt;에서 토큰 검증 및 블랙리스트 확인을 거치고, &lt;span class=&quot;badge&quot;&gt;Spring Security&lt;/span&gt;에서 인증&amp;middot;인가를 처리한다. 리마인더 알림은 &lt;span class=&quot;badge&quot;&gt;SSE&lt;/span&gt;로 클라이언트와 직접 연결되며, 외부 연동으로 &lt;span class=&quot;badge&quot;&gt;Google OAuth2&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;알라딘 API&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;SMTP&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;AWS S3&lt;/span&gt;를 사용한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;ERD&lt;/b&gt;&lt;/h2&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3644&quot; data-origin-height=&quot;3122&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cCgx7c/dJMcabEaB0C/vJ8bhjyked2ZbBOZAkyccK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cCgx7c/dJMcabEaB0C/vJ8bhjyked2ZbBOZAkyccK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cCgx7c/dJMcabEaB0C/vJ8bhjyked2ZbBOZAkyccK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcCgx7c%2FdJMcabEaB0C%2FvJ8bhjyked2ZbBOZAkyccK%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;3644&quot; height=&quot;3122&quot; data-origin-width=&quot;3644&quot; data-origin-height=&quot;3122&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;!-- 이미지 --&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 11개 테이블로 구성된다. &lt;span class=&quot;badge&quot;&gt;users&lt;/span&gt;를 중심으로 &lt;span class=&quot;badge&quot;&gt;posts&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;memos&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;monthly_goals&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;annual_goals&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;reminders&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;recommendations&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;inquiries&lt;/span&gt;가 연결되고, 커뮤니티 기능은 &lt;span class=&quot;badge&quot;&gt;community_posts&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;community_comments&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;community_likes&lt;/span&gt;로 분리했다. 장르는 &lt;span class=&quot;badge&quot;&gt;genres&lt;/span&gt; 테이블로 독립 관리하며 관리자가 추가&amp;middot;삭제할 수 있다.&lt;/p&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p id=&quot;retrospective&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1차 개발 돌아보기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2025년 초에 &lt;span class=&quot;badge&quot;&gt;Java 17&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;Spring Boot 3.3&lt;/span&gt;, &lt;span class=&quot;badge&quot;&gt;React 18&lt;/span&gt;로 같은 주제를 한 번 구현한 적이 있다. 당시에는 세션 기반 인증, Thymeleaf 서버 사이드 렌더링, Render 무료 배포를 사용했고, 설계보다 구현에 급했던 코드였다. 1차 코드를 다시 봤을 때 수정으로 해결할 수 있는 수준이 아니라고 판단했다. 그래서 코드를 처음부터 다시 짰다. JWT 인증, AWS 인프라, 도메인 중심 설계, 보안 점검까지 이번 2차 개발에서 달라진 부분들이다.&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차 개발 기록은 아래 링크에서 확인할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a class=&quot;github-button&quot; href=&quot;https://dev-cloud.tistory.com/439&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt; 1차 개발 기록 보러가기 &lt;/a&gt;&lt;/p&gt;</description>
      <category>  PROJECT/[Spring Boot, React] 독서 습관 관리 서비스</category>
      <category>개발</category>
      <category>일상</category>
      <category>토이 프로젝트</category>
      <category>프로젝트</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/505</guid>
      <comments>https://dev-cloud.tistory.com/505#entry505comment</comments>
      <pubDate>Tue, 12 May 2026 11:37:52 +0900</pubDate>
    </item>
    <item>
      <title>[설계의사결정] 국내 도서 검색 API 선택 과정</title>
      <link>https://dev-cloud.tistory.com/504</link>
      <description>&lt;div&gt;
&lt;style&gt;
  .tistory-post {
    max-width: 780px;
    margin: 0 auto;
    line-height: 1.8;
    color: #222;
    font-size: 16px;
  }

  .tistory-post h1 {
    margin-top: 10px;
    margin-bottom: 14px;
    font-size: 32px;
    font-weight: 800;
    line-height: 1.4;
    color: #111;
  }

  .tistory-post h2 {
    margin-top: 52px;
    margin-bottom: 18px;
    font-size: 26px;
    border-bottom: 2px solid #222;
    padding-bottom: 8px;
    color: #111;
  }

  .tistory-post h3 {
    margin-top: 36px;
    margin-bottom: 14px;
    font-size: 21px;
    color: #222;
  }

  .tistory-post p {
    margin: 14px 0;
  }

  .tistory-post strong {
    font-weight: 700;
    color: #111;
  }

  .tistory-post code {
    background: #f3f4f6;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 0.95em;
  }

  .tistory-post table {
    width: 100%;
    border-collapse: collapse;
    margin: 20px 0 30px;
    font-size: 15px;
  }

  .tistory-post th,
  .tistory-post td {
    border: 1px solid #d1d5db;
    padding: 12px;
    text-align: left;
    vertical-align: top;
  }

  .tistory-post th {
    background: #f3f4f6;
    font-weight: 700;
  }

  .tistory-post ul {
    padding-left: 22px;
    margin: 14px 0 24px;
  }

  .tistory-post li {
    margin: 8px 0;
  }

  .tistory-post blockquote {
    margin: 24px 0;
    padding: 20px 24px;
    border-left: 5px solid #2563eb;
    background: #f5f9ff;
    border-radius: 10px;
    color: #333;
  }

  .tistory-post .post-header {
    margin-bottom: 42px;
    border-bottom: 3px solid #333;
    padding-bottom: 12px;
  }

  .tistory-post .badge {
    display: inline-block;
    background: #2563eb;
    color: #fff;
    padding: 4px 12px;
    border-radius: 4px;
    font-size: 14px;
    font-weight: 700;
    margin-bottom: 14px;
  }

  .tistory-post .toc {
    background: #f8f9fa;
    border-radius: 10px;
    padding: 26px;
    margin-bottom: 50px;
    border: 1px solid #e5e7eb;
  }

  .tistory-post .toc-title {
    margin: 0 0 16px;
    font-size: 18px;
    font-weight: 700;
    color: #111;
  }

  .tistory-post .toc ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .tistory-post .toc li {
    margin-bottom: 10px;
  }

  .tistory-post .toc a {
    color: #2563eb;
    text-decoration: none;
    font-weight: 500;
  }

  .tistory-post .aside-box {
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 5px solid #2563eb;
    border-radius: 10px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .highlight-box {
    background: #f9fafb;
    border: 1px solid #e5e7eb;
    border-radius: 10px;
    padding: 18px 20px;
    margin: 24px 0;
  }
&lt;/style&gt;
&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;
&lt;div class=&quot;post-header&quot;&gt;&lt;span class=&quot;badge&quot;&gt;PROJECT&lt;/span&gt;
&lt;h1&gt;국내 도서 검색 API 선택 과정&lt;/h1&gt;
&lt;/div&gt;
&lt;div class=&quot;toc&quot;&gt;
&lt;p class=&quot;toc-title&quot; 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;a href=&quot;#section1&quot;&gt;1. 왜 도서 API를 비교하게 됐나?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section2&quot;&gt;2. 국내에서 사용 가능한 도서 API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section3&quot;&gt;3. API별 비교&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section4&quot;&gt;4. 알라딘 Open API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section5&quot;&gt;5. 카카오 책 검색 API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section6&quot;&gt;6. 네이버 책 검색 API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section7&quot;&gt;7. 국립중앙도서관 Open API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section8&quot;&gt;8. 도서관 정보나루 API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section9&quot;&gt;9. 최종 선택: 알라딘 API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section10&quot;&gt;10. 마무리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZxP9f/dJMcaicdvdj/zuU8Ol6t4kLFkGfY8oA480/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZxP9f/dJMcaicdvdj/zuU8Ol6t4kLFkGfY8oA480/img.png&quot; style=&quot;width: 18.7142%; margin-right: 10px;&quot; data-origin-width=&quot;225&quot; data-origin-height=&quot;225&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;19.21&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZxP9f/dJMcaicdvdj/zuU8Ol6t4kLFkGfY8oA480/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZxP9f%2FdJMcaicdvdj%2FzuU8Ol6t4kLFkGfY8oA480%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;225&quot; height=&quot;225&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJvRqp/dJMcacQy2k9/8kBvbV6tOuOHKRPtOQSVKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJvRqp/dJMcacQy2k9/8kBvbV6tOuOHKRPtOQSVKK/img.png&quot; style=&quot;width: 50.6383%; margin-right: 10px;&quot; data-origin-width=&quot;322&quot; data-origin-height=&quot;119&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;51.97&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJvRqp/dJMcacQy2k9/8kBvbV6tOuOHKRPtOQSVKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJvRqp%2FdJMcacQy2k9%2F8kBvbV6tOuOHKRPtOQSVKK%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;322&quot; height=&quot;119&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/twehk/dJMcabjRGeQ/kX9kK6EKnm8OUmx9lcRKeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/twehk/dJMcabjRGeQ/kX9kK6EKnm8OUmx9lcRKeK/img.png&quot; style=&quot;width: 28.0834%;&quot; width=&quot;387&quot; height=&quot;258&quot; data-origin-width=&quot;1151&quot; data-origin-height=&quot;767&quot; data-is-animation=&quot;false&quot; data-filename=&quot;blob&quot; data-widthpercent=&quot;28.82&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/twehk/dJMcabjRGeQ/kX9kK6EKnm8OUmx9lcRKeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ftwehk%2FdJMcabjRGeQ%2FkX9kK6EKnm8OUmx9lcRKeK%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;1151&quot; height=&quot;767&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zgs1q/dJMcai4gokA/KlcRWIjPuJnCTfPKzkdkEk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zgs1q/dJMcai4gokA/KlcRWIjPuJnCTfPKzkdkEk/img.jpg&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;215&quot; data-is-animation=&quot;false&quot; style=&quot;width: 31.2943%; margin-right: 10px;&quot; data-widthpercent=&quot;31.7&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zgs1q/dJMcai4gokA/KlcRWIjPuJnCTfPKzkdkEk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fzgs1q%2FdJMcai4gokA%2FKlcRWIjPuJnCTfPKzkdkEk%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;512&quot; height=&quot;215&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eb38Cb/dJMcac35g6f/l49CtsM87YswVimPzaG240/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eb38Cb/dJMcac35g6f/l49CtsM87YswVimPzaG240/img.png&quot; data-origin-width=&quot;785&quot; data-origin-height=&quot;153&quot; data-is-animation=&quot;false&quot; style=&quot;width: 67.4236%;&quot; data-widthpercent=&quot;68.3&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eb38Cb/dJMcac35g6f/l49CtsM87YswVimPzaG240/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Feb38Cb%2FdJMcac35g6f%2Fl49CtsM87YswVimPzaG240%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;785&quot; height=&quot;153&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;

&lt;blockquote data-ke-style=&quot;style3&quot;&gt;독서 습관 관리 서비스를 개발하고 있는데 &amp;ldquo;도서 데이터를 어디서 가져올 것인가?&amp;rdquo; 에 대한 고민이 생겼다.&lt;/blockquote&gt;
&lt;h2 id=&quot;section1&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 왜 도서 API를 비교하게 됐나?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순히 제목만 검색되면 될 줄 알았다. 그런데 막상 구현하려고 보니 생각보다 고려해야 할 요소가 많았다.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&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;li&gt;JSON 지원 여부&lt;/li&gt;
&lt;li&gt;API 관리 상태&lt;/li&gt;
&lt;li&gt;키 발급 속도&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 독서 서비스에서는 단순 텍스트보다 &lt;b&gt;책 표지 이미지의 중요도가 굉장히 크다. &lt;/b&gt;UI 완성도 자체가 달라지기 때문이다. 이번 글에서는 국내에서 사용할 수 있는 대표적인 도서 검색 API들을 비교하고, 최종적으로 왜 알라딘 API를 선택했는지 정리해보려고 한다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section2&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 국내에서 사용 가능한 도서 API&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;국내에서 사용할 수 있는 대표적인 도서 관련 API는 아래 정도가 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;알라딘 Open API&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;도서 서비스 개발에서 가장 많이 사용되는 편&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;카카오 책 검색 API&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;카카오 검색 기반&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;네이버 책 검색 API&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;네이버 검색 결과 기반&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;국립중앙도서관 Open API&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;공공 데이터 중심&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;도서관 정보나루 API&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;도서관 대출/인기 데이터 제공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;교보문고 / 영풍문고&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;공식 Open API 미제공&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;선택지가 많았는데 이 중에 무슨 API를 선택할지 고민이었다. 특히 의외였던 부분은 국내 대형 서점인 &lt;b&gt;교보문고, 영풍문고가 공식 Open API를 제공하지 않는다는 점&lt;/b&gt;이었다. 그래서 실제 서비스에서는 결국 알라딘 API를 사용하는 경우가 꽤 많았다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. API별 비교&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발하면서 중요하게 봤던 기준들을 중심으로 정리해봤다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;width: 154px;&quot;&gt;API&lt;/th&gt;
&lt;th style=&quot;width: 97px;&quot;&gt;표지 이미지&lt;/th&gt;
&lt;th style=&quot;width: 140px;&quot;&gt;신간/베스트셀러&lt;/th&gt;
&lt;th style=&quot;width: 88px;&quot;&gt;가격 정보&lt;/th&gt;
&lt;th style=&quot;width: 107px;&quot;&gt;응답 형식&lt;/th&gt;
&lt;th style=&quot;width: 97px;&quot;&gt;키 발급&lt;/th&gt;
&lt;th style=&quot;width: 97px;&quot;&gt;관리 상태&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 154px;&quot;&gt;&lt;b&gt;알라딘 Open API&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 140px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 88px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 107px;&quot;&gt;JSON / XML&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;비교적 빠름&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;활발&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 154px;&quot;&gt;&lt;b&gt;카카오 책 검색 API&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 140px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;width: 88px;&quot;&gt;일부&lt;/td&gt;
&lt;td style=&quot;width: 107px;&quot;&gt;JSON&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;매우 빠름&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;활발&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 154px;&quot;&gt;&lt;b&gt;네이버 책 검색 API&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 140px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;width: 88px;&quot;&gt;일부&lt;/td&gt;
&lt;td style=&quot;width: 107px;&quot;&gt;JSON / XML&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;빠름&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;보통&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 154px;&quot;&gt;&lt;b&gt;국립중앙도서관 API&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;부족&lt;/td&gt;
&lt;td style=&quot;width: 140px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;width: 88px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;width: 107px;&quot;&gt;XML 중심&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;느린 편&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;다소 보수적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 154px;&quot;&gt;&lt;b&gt;도서관 정보나루 API&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;제한적&lt;/td&gt;
&lt;td style=&quot;width: 140px;&quot;&gt;대출 기반 인기 정보&lt;/td&gt;
&lt;td style=&quot;width: 88px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;width: 107px;&quot;&gt;JSON / XML&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;보통&lt;/td&gt;
&lt;td style=&quot;width: 97px;&quot;&gt;운영 중&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section4&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 알라딘 Open API&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로는 가장 서비스 개발 친화적이었다. 특히 좋았던 부분은 아래였다.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&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;신간 / 베스트셀러 API 제공&lt;/li&gt;
&lt;li&gt;가격 정보 제공&lt;/li&gt;
&lt;li&gt;JSON 응답 지원&lt;/li&gt;
&lt;li&gt;실제 도서 서비스에서 많이 사용됨&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&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;div class=&quot;highlight-box&quot;&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;li&gt;카테고리별 도서&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 데이터가 필요해지는데, 알라딘 API는 이런 부분이 꽤 잘 되어 있었다. 무엇보다 좋았던 건 &lt;b&gt;JSON 응답&lt;/b&gt;이었다. Spring Boot에서 DTO 매핑할 때도 편했고, Android나 React 쪽에서도 다루기 수월했다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;실제로 구현하면서 느낀 건 &lt;b&gt;&amp;ldquo;도서 검색만 필요한 API가 아니라, 도서 서비스 자체를 만들기 좋은 API&amp;rdquo; &lt;/b&gt;에 가까웠다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section5&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 카카오 책 검색 API&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 API는 사용성 자체는 굉장히 좋았다. 키 발급도 빠르고, 문서도 비교적 깔끔한 편이다. 검색 품질도 나쁘지 않았다. 다만 아쉬웠던 점은&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;신간 API 없음&lt;/li&gt;
&lt;li&gt;베스트셀러 API 없음&lt;/li&gt;
&lt;li&gt;도서 서비스 특화 기능 부족&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;&amp;ldquo;검색 API&amp;rdquo;에 더 가까운 느낌&lt;/b&gt;이었다. 책 검색 기능만 필요한 경우에는 충분히 좋은 선택이라고 생각한다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section6&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 네이버 책 검색 API&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이버도 기본적인 검색 기능은 제공한다. 예전부터 존재하던 API라 자료도 꽤 많다. 다만 실제로 비교해보면&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&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;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라는 인상이 있었다. 그리고 최근에는 개발자들이 예전만큼 적극적으로 사용하는 분위기는 아니었다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section7&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 국립중앙도서관 Open API&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 공공 API라서 가장 신뢰도가 높을 줄 알았다. 그런데 실제 서비스 관점에서는 아쉬운 부분이 꽤 있었다. 대표적으로&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&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;XML 응답 중심&lt;/li&gt;
&lt;li&gt;UI 친화적인 데이터가 적음&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 XML 응답은 생각보다 개발 피로도가 컸다. 물론 Spring에서는 XML 파싱도 가능하지만, 실제 프론트엔드까지 고려하면 JSON 기반 API가 훨씬 편했다. 그리고 독서 서비스에서는 결국 &lt;b&gt;&amp;ldquo;보여지는 경험&amp;rdquo;이 중요한데&lt;/b&gt;, 표지 이미지가 부실한 점이 꽤 크게 느껴졌다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section8&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;8. 도서관 정보나루 API&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 API는 조금 성격이 다르다. 일반적인 도서 검색보다는&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&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;li&gt;지역별 대출 데이터&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&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;div class=&quot;highlight-box&quot;&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;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쪽에서 더 활용도가 높아 보였다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section9&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;9. 최종 선택: 알라딘 API&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로는 알라딘 API를 선택했다. 이유는 단순했다. &lt;b&gt;독서 서비스에서 필요한 요소들을 가장 균형 있게 제공했기 때문이다.&lt;/b&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;div class=&quot;highlight-box&quot;&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;li&gt;JSON 응답 지원&lt;/li&gt;
&lt;li&gt;실제 서비스 UI 구성에 적합&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도서 서비스는 결국 &amp;ldquo;검색&amp;rdquo;보다 &amp;ldquo;탐색 경험&amp;rdquo;이 더 중요하다고 생각한다.&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;div class=&quot;highlight-box&quot;&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;li&gt;저장하고 기록하는 흐름&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 자연스럽게 이어져야 한다. 그 관점에서 알라딘 API가 가장 실용적인 선택이었다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section10&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;10. 마무리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순히 &amp;ldquo;책 검색 API 하나 붙이면 끝&amp;rdquo;이라고 생각했는데, 실제로는 서비스 방향에 따라 선택 기준이 꽤 달랐다.&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;div class=&quot;aside-box&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;단순 검색 기능만 필요하다면 &amp;rarr; 카카오&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;공공 데이터/통계 활용이 목적이라면 &amp;rarr; 정보나루&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실제 독서 서비스 개발이라면 &amp;rarr; 알라딘&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쪽이 더 적합하다고 느꼈다. 도서 API는 생각보다 선택지가 제한적인 분야라, 처음 설계 단계에서 충분히 비교해보고 결정하는 걸 추천한다.&lt;/p&gt;
&lt;/div&gt;</description>
      <category>  PROJECT/[Spring Boot, React] 독서 습관 관리 서비스</category>
      <category>API비교</category>
      <category>restapi</category>
      <category>springboot</category>
      <category>개발</category>
      <category>도서API</category>
      <category>도서검색API</category>
      <category>독서서비스</category>
      <category>백엔드</category>
      <category>알라딘api</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/504</guid>
      <comments>https://dev-cloud.tistory.com/504#entry504comment</comments>
      <pubDate>Fri, 8 May 2026 16:43:14 +0900</pubDate>
    </item>
    <item>
      <title>[Infrastructure] Spring Boot &amp;times; Docker &amp;times; AWS 배포 흐름 한 번에 이해하기</title>
      <link>https://dev-cloud.tistory.com/503</link>
      <description>&lt;div&gt;
&lt;style&gt;
  .tistory-post {
    max-width: 780px;
    margin: 0 auto;
    line-height: 1.8;
    color: #222;
    font-size: 16px;
  }

  .tistory-post h1 {
    margin-top: 10px;
    margin-bottom: 14px;
    font-size: 32px;
    font-weight: 800;
    line-height: 1.4;
    color: #111;
  }

  .tistory-post h2 {
    margin-top: 52px;
    margin-bottom: 18px;
    font-size: 26px;
    border-bottom: 2px solid #222;
    padding-bottom: 8px;
    color: #111;
  }

  .tistory-post h3 {
    margin-top: 36px;
    margin-bottom: 14px;
    font-size: 21px;
    color: #222;
  }

  .tistory-post h4 {
    margin-top: 28px;
    margin-bottom: 12px;
    font-size: 18px;
    color: #222;
  }

  .tistory-post p {
    margin: 14px 0;
  }

  .tistory-post strong {
    font-weight: 700;
    color: #111;
  }

  .tistory-post code {
    background: #f3f4f6;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 0.95em;
  }

  .tistory-post pre {
    background: #1f2937;
    color: #f9fafb;
    padding: 18px;
    border-radius: 10px;
    overflow-x: auto;
    line-height: 1.7;
    font-size: 14px;
    margin: 18px 0 28px;
  }

  .tistory-post pre code {
    background: transparent;
    padding: 0;
    color: inherit;
    font-size: inherit;
  }

  .tistory-post table {
    width: 100%;
    border-collapse: collapse;
    margin: 20px 0 30px;
    font-size: 15px;
  }

  .tistory-post th,
  .tistory-post td {
    border: 1px solid #d1d5db;
    padding: 12px;
    text-align: left;
    vertical-align: top;
  }

  .tistory-post th {
    background: #f3f4f6;
    font-weight: 700;
  }

  .tistory-post ul,
  .tistory-post ol {
    padding-left: 22px;
    margin: 14px 0 24px;
  }

  .tistory-post li {
    margin: 8px 0;
  }

  .tistory-post blockquote {
    margin: 24px 0;
    padding: 20px 24px;
    border-left: 5px solid #2563eb;
    background: #f5f9ff;
    border-radius: 10px;
    color: #333;
  }

  .tistory-post .post-header {
    margin-bottom: 42px;
    border-bottom: 3px solid #333;
    padding-bottom: 12px;
  }

  .tistory-post .badge {
    display: inline-block;
    background: #2563eb;
    color: #fff;
    padding: 4px 12px;
    border-radius: 4px;
    font-size: 14px;
    font-weight: 700;
    margin-bottom: 14px;
  }

  .tistory-post .toc {
    background: #f8f9fa;
    border-radius: 10px;
    padding: 26px;
    margin-bottom: 50px;
    border: 1px solid #e5e7eb;
  }

  .tistory-post .toc-title {
    margin: 0 0 16px;
    font-size: 18px;
    font-weight: 700;
    color: #111;
  }

  .tistory-post .toc a {
    color: #2563eb;
    text-decoration: none;
    font-weight: 500;
  }

  .tistory-post .aside-box {
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 5px solid #2563eb;
    border-radius: 10px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .tip-box {
    background: #f0fdf4;
    border: 1px solid #bbf7d0;
    border-left: 5px solid #22c55e;
    border-radius: 10px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .warn-box {
    background: #fff7ed;
    border: 1px solid #fed7aa;
    border-left: 5px solid #f97316;
    border-radius: 10px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .summary-box {
    background: #f9fafb;
    border: 1px solid #e5e7eb;
    border-radius: 10px;
    padding: 22px;
    margin-top: 28px;
  }
&lt;/style&gt;
&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;
&lt;div class=&quot;post-header&quot;&gt;&lt;span class=&quot;badge&quot;&gt;DEPLOYMENT&lt;/span&gt;
&lt;h1&gt;Spring Boot 앱을 Docker로 AWS에 배포하는 전체 흐름 정리&lt;/h1&gt;
&lt;/div&gt;
&lt;div class=&quot;toc&quot;&gt;
&lt;p class=&quot;toc-title&quot; 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;a href=&quot;#section1&quot;&gt;Docker가 왜 필요한가?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section2&quot;&gt;Docker와 AWS의 차이&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section3&quot;&gt;AWS 서비스 역할 정리&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section4&quot;&gt;전체 아키텍처 흐름&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section5&quot;&gt;Dockerfile 작성과 배포&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section6&quot;&gt;배포 방식 비교&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section7&quot;&gt;마치며&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;129&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/06p0Y/dJMcad2Xm1i/vOqrxVmZJAIHGDtXF9ki1k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/06p0Y/dJMcad2Xm1i/vOqrxVmZJAIHGDtXF9ki1k/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/06p0Y/dJMcad2Xm1i/vOqrxVmZJAIHGDtXF9ki1k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F06p0Y%2FdJMcad2Xm1i%2FvOqrxVmZJAIHGDtXF9ki1k%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;500&quot; height=&quot;129&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;129&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Docker와 AWS를 처음 접하면 가장 헷갈리는 부분이 있다. &amp;ldquo;Docker를 쓰는 건가? AWS를 쓰는 건가?&amp;rdquo; 이 둘의 관계부터 실제 배포 흐름까지 한 번에 정리해보자.&lt;/blockquote&gt;
&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;Docker가 무엇인지 알고 싶다면  &lt;br /&gt;
&lt;figure id=&quot;og_1778213626385&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Docker&quot; data-og-description=&quot;  목차도커란?핵심 개념VM과 Docker 차이배포한다고 가정할 때기본 명령어참고 링크  도커란?컨테이너 기반 가상화 도구 (2013년 등장)리눅스의 프로세스 격리 기술을 활용해 앱 실행 환경을 컨&quot; data-og-host=&quot;dev-cloud.tistory.com&quot; data-og-source-url=&quot;https://dev-cloud.tistory.com/445&quot; data-og-url=&quot;https://dev-cloud.tistory.com/445&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bQMoNv/dJMb85WXznL/rYoo9V57s7nvvNxmPa88ik/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/lY0cy/dJMb82eRdpp/RYKQRMbvcw0zWdAue37g50/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/rrhj6/dJMb8YpZBse/hqWFAUgRKUWRzGZnKjSXOK/img.jpg?width=564&amp;amp;height=499&amp;amp;face=0_0_564_499&quot;&gt;&lt;a href=&quot;https://dev-cloud.tistory.com/445&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-cloud.tistory.com/445&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bQMoNv/dJMb85WXznL/rYoo9V57s7nvvNxmPa88ik/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/lY0cy/dJMb82eRdpp/RYKQRMbvcw0zWdAue37g50/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/rrhj6/dJMb8YpZBse/hqWFAUgRKUWRzGZnKjSXOK/img.jpg?width=564&amp;amp;height=499&amp;amp;face=0_0_564_499');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Docker&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  목차도커란?핵심 개념VM과 Docker 차이배포한다고 가정할 때기본 명령어참고 링크  도커란?컨테이너 기반 가상화 도구 (2013년 등장)리눅스의 프로세스 격리 기술을 활용해 앱 실행 환경을 컨&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-cloud.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section1&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. Docker가 왜 필요한가?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 앱이 로컬에서는 잘 돌아가는데, 서버에 올리면 갑자기 오류가 나는 경우가 있다. 이런 문제는 대부분 &lt;b&gt;실행 환경 차이&lt;/b&gt; 때문에 발생한다. Java 버전이 다르거나, OS 환경이 다르거나, 환경 변수 설정이 달라서 문제가 생기는 것이다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;개발자들 사이에서 자주 나오는 말이 있다.&lt;br /&gt;&lt;b&gt;&amp;ldquo;내 컴퓨터에선 됐는데?&amp;rdquo;&lt;/b&gt;&lt;br /&gt;Docker는 바로 이 문제를 해결하기 위해 등장했다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker는 앱과 실행 환경을 하나의 이미지로 묶는다. 즉, 어디서 실행하든 동일한 환경을 보장할 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Docker를 비유하면?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레시피(코드)만 전달하는 게 아니라, 주방 환경까지 통째로 박스에 담아 보내는 개념이라고 보면 된다.&lt;/p&gt;
&lt;div class=&quot;tip-box&quot;&gt;Docker 이미지에는 앱뿐 아니라 실행에 필요한 JDK, 설정, 라이브러리까지 포함된다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section2&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. Docker와 AWS의 차이&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 많이 헷갈리는 부분이 바로 이것이다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Docker&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;앱을 패키징하고 실행하는 방식&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;AWS&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;서버, DB, 네트워크 등을 제공하는 인프라&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 둘 중 하나를 선택하는 개념이 아니다. &lt;b&gt;AWS라는 인프라 위에서 Docker 컨테이너를 실행하는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;Docker는 &lt;b&gt;&amp;ldquo;어떻게 실행할 것인가&amp;rdquo;&lt;/b&gt;, AWS는 &lt;b&gt;&amp;ldquo;어디서 실행할 것인가&amp;rdquo;&lt;/b&gt;에 가깝다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. AWS 서비스 역할 정리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 프로젝트를 AWS로 배포할 때 자주 사용하는 서비스들은 다음과 같다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;서비스&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;EC2&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Docker 컨테이너가 실행되는 서버&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;RDS&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;MySQL 같은 데이터베이스 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;ElastiCache&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Redis 캐시 서버 제공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;S3&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;파일 저장소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;CloudFront&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;CDN 기반 빠른 정적 파일 배포&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class=&quot;tip-box&quot;&gt;실무에서는 보통 EC2 + RDS + Redis + S3 조합을 많이 사용한다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section4&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 전체 아키텍처 흐름&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;개발자 코드 작성
        &amp;darr;
Docker 이미지 빌드
        &amp;darr;
DockerHub 또는 ECR 업로드
        &amp;darr;
EC2 서버에서 이미지 pull
        &amp;darr;
Docker 컨테이너 실행
        &amp;darr;
RDS / Redis / S3 연결
        &amp;darr;
사용자 요청 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자의 요청이 들어오면 Spring Boot 앱이 데이터를 처리하고, 필요한 경우 RDS(MySQL), Redis, S3와 통신하게 된다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;실제 서비스는 단순히 &amp;ldquo;Spring Boot 하나 실행&amp;rdquo;으로 끝나지 않는다. DB, 캐시, 파일 저장소 등 여러 서비스가 함께 동작한다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section5&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. Dockerfile 작성과 배포&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 앱을 Docker 이미지로 만들기 위해 &lt;code&gt;Dockerfile&lt;/code&gt;을 작성한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Dockerfile 예시&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;# Build Stage
FROM eclipse-temurin:17-jdk-alpine AS builder

WORKDIR /app

COPY . .

RUN ./gradlew bootJar

# Run Stage
FROM eclipse-temurin:17-jre-alpine

WORKDIR /app

COPY --from=builder /app/build/libs/*.jar app.jar

EXPOSE 8080

ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;tip-box&quot;&gt;Multi-stage Build를 사용하면 최종 이미지 크기를 줄일 수 있다.&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Docker 이미지 빌드&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;docker build -t my-spring-app .&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;DockerHub 업로드&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;docker push your-dockerhub-id/my-spring-app&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;EC2에서 실행&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;docker pull your-dockerhub-id/my-spring-app

docker run -d -p 80:8080 your-dockerhub-id/my-spring-app&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;warn-box&quot;&gt;DB 비밀번호 같은 민감 정보는 Dockerfile에 직접 작성하지 말고, 환경 변수로 관리하는 것이 안전하다.&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;application.yml 예시&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: ${SPRING_DATASOURCE_URL}
    username: ${SPRING_DATASOURCE_USERNAME}
    password: ${SPRING_DATASOURCE_PASSWORD}

  data:
    redis:
      host: ${SPRING_REDIS_HOST}
      port: 6379&lt;/code&gt;&lt;/pre&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section6&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 배포 방식 비교&lt;/b&gt;&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;JAR 직접 배포&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단순하다&lt;/td&gt;
&lt;td&gt;환경 차이 문제 발생 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Docker 배포&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;환경 일관성 보장&lt;/td&gt;
&lt;td&gt;Docker 학습 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Docker Compose&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;여러 컨테이너 관리 편리&lt;/td&gt;
&lt;td&gt;운영 환경에선 관리 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;PaaS (Railway 등)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;배포가 매우 쉽다&lt;/td&gt;
&lt;td&gt;커스터마이징 제한&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Docker Compose 예시&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;version: '3.8'

services:
  app:
    image: my-spring-app
    ports:
      - &quot;80:8080&quot;

  db:
    image: mysql:8

  redis:
    image: redis:7&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;aside-box&quot;&gt;운영 환경에서는 DB와 Redis를 직접 컨테이너로 운영하기보다, RDS와 ElastiCache 같은 관리형 서비스를 사용하는 경우가 많다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section7&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker는 앱을 어떻게 패키징할지 결정하는 기술이고, AWS는 그 컨테이너가 실제로 실행되는 인프라다. 둘은 경쟁 관계가 아니라 함께 사용하는 조합이다.&lt;/p&gt;
&lt;div class=&quot;summary-box&quot;&gt;&lt;b&gt;정리하면&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Docker는 실행 환경을 통째로 패키징한다&lt;/li&gt;
&lt;li&gt;AWS는 서버와 인프라를 제공한다&lt;/li&gt;
&lt;li&gt;Spring Boot + Docker + AWS 조합은 실무에서도 매우 많이 사용된다&lt;/li&gt;
&lt;li&gt;다음 단계로는 GitHub Actions를 이용한 CI/CD 자동 배포까지 공부해보면 좋다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>  CS &amp;amp; Infra/Infrastructure</category>
      <category>AWS</category>
      <category>docker</category>
      <category>EC2</category>
      <category>RDS</category>
      <category>Spring boot</category>
      <category>개발</category>
      <category>도커</category>
      <category>배포</category>
      <category>일상</category>
      <category>코딩</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/503</guid>
      <comments>https://dev-cloud.tistory.com/503#entry503comment</comments>
      <pubDate>Fri, 8 May 2026 13:12:18 +0900</pubDate>
    </item>
    <item>
      <title>[Web] REST API, RESTful API 개념</title>
      <link>https://dev-cloud.tistory.com/502</link>
      <description>&lt;div&gt;
&lt;style&gt;
  .tistory-post {
    max-width: 780px;
    margin: 0 auto;
    line-height: 1.8;
    color: #222;
    font-size: 16px;
  }

  .tistory-post h1 {
    margin-top: 10px;
    margin-bottom: 14px;
    font-size: 32px;
    font-weight: 800;
    line-height: 1.4;
    color: #111;
  }

  .tistory-post h2 {
    margin-top: 52px;
    margin-bottom: 18px;
    font-size: 26px;
    border-bottom: 2px solid #222;
    padding-bottom: 8px;
    color: #111;
  }

  .tistory-post h3 {
    margin-top: 36px;
    margin-bottom: 14px;
    font-size: 21px;
    color: #222;
  }

  .tistory-post p {
    margin: 14px 0;
  }

  .tistory-post strong {
    font-weight: 700;
    color: #111;
  }

  .tistory-post code {
    background: #f3f4f6;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 0.95em;
  }

  .tistory-post pre {
    background: #1f2937;
    color: #f9fafb;
    padding: 18px;
    border-radius: 10px;
    overflow-x: auto;
    line-height: 1.6;
    font-size: 14px;
    margin: 18px 0 28px;
  }

  .tistory-post pre code {
    background: transparent;
    padding: 0;
    color: inherit;
    font-size: inherit;
  }

  .tistory-post table {
    width: 100%;
    border-collapse: collapse;
    margin: 20px 0 30px;
    font-size: 15px;
  }

  .tistory-post th,
  .tistory-post td {
    border: 1px solid #d1d5db;
    padding: 12px;
    text-align: left;
    vertical-align: top;
  }

  .tistory-post th {
    background: #f3f4f6;
    font-weight: 700;
  }

  .tistory-post ul {
    padding-left: 22px;
    margin: 14px 0 24px;
  }

  .tistory-post li {
    margin: 8px 0;
  }

  .tistory-post blockquote {
    margin: 24px 0;
    padding: 20px 24px;
    border-left: 5px solid #2563eb;
    background: #f5f9ff;
    border-radius: 10px;
    color: #333;
  }

  .tistory-post .post-header {
    margin-bottom: 42px;
    border-bottom: 3px solid #333;
    padding-bottom: 12px;
  }

  .tistory-post .badge {
    display: inline-block;
    background: #2563eb;
    color: #fff;
    padding: 4px 12px;
    border-radius: 4px;
    font-size: 14px;
    font-weight: 700;
    margin-bottom: 14px;
  }

  .tistory-post .toc {
    background: #f8f9fa;
    border-radius: 10px;
    padding: 26px;
    margin-bottom: 50px;
    border: 1px solid #e5e7eb;
  }

  .tistory-post .toc-title {
    margin: 0 0 16px;
    font-size: 18px;
    font-weight: 700;
    color: #111;
  }

  .tistory-post .toc ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .tistory-post .toc li {
    margin-bottom: 10px;
  }

  .tistory-post .toc a {
    color: #2563eb;
    text-decoration: none;
    font-weight: 500;
  }

  .tistory-post .aside-box {
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 5px solid #2563eb;
    border-radius: 10px;
    padding: 18px 20px;
    margin: 24px 0;
  }
&lt;/style&gt;
&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;
&lt;div class=&quot;post-header&quot;&gt;&lt;span class=&quot;badge&quot;&gt;WEB&lt;/span&gt;
&lt;h1&gt;REST API, RESTful API 개념과 차이&lt;/h1&gt;
&lt;/div&gt;
&lt;div class=&quot;toc&quot;&gt;
&lt;p class=&quot;toc-title&quot; 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;a href=&quot;#section1&quot;&gt;1. API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section2&quot;&gt;2. REST&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section3&quot;&gt;3. REST API vs RESTful API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section4&quot;&gt;4. REST API 설계 방법&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section5&quot;&gt;5. HTTP 상태 코드도 중요하다&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section6&quot;&gt;6. 마치며&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zVsZd/dJMcacXj1z9/tAIE4IlaAW9txUDWQmCpb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zVsZd/dJMcacXj1z9/tAIE4IlaAW9txUDWQmCpb1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zVsZd/dJMcacXj1z9/tAIE4IlaAW9txUDWQmCpb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzVsZd%2FdJMcacXj1z9%2FtAIE4IlaAW9txUDWQmCpb1%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;468&quot; height=&quot;263&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;blockquote data-ke-style=&quot;style3&quot;&gt;백엔드 공부를 시작하면 가장 먼저 마주치는 단어, REST API. 근데 RESTful API는 또 뭔가? 같은 건가 다른 건가? 오늘 한 번에 정리해보자.&lt;/blockquote&gt;
&lt;h2 id=&quot;section1&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. API&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API를 이해하려면 먼저 API가 뭔지 알아야 한다. &lt;b&gt;API(Application Programming Interface)&lt;/b&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;ldquo;이 데이터 줘&amp;rdquo; 하고 요청하고, 서버가 &amp;ldquo;여기 있어&amp;rdquo; 하고 응답하는 그 창구가 바로 API다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;프론트엔드와 백엔드가 데이터를 주고받는 연결 지점이 바로 API다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section2&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. REST&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;REST(Representational State Transfer)&lt;/b&gt;는 웹 통신을 위한 &lt;b&gt;아키텍처 스타일(설계 원칙)&lt;/b&gt;이다. 2000년에 &lt;b&gt;로이 필딩(Roy Fielding)&lt;/b&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;REST 자체는 프로토콜이나 표준이 아니다. &amp;ldquo;이런 방식으로 설계하면 좋다&amp;rdquo;는 &lt;b&gt;가이드라인&lt;/b&gt;에 가깝다. REST가 제시하는 핵심 원칙은 다음과 같다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;원칙&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;클라이언트-서버 구조&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;클라이언트와 서버는 역할을 분리한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;무상태(Stateless)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;각 요청은 독립적이며 서버는 클라이언트 상태를 저장하지 않는다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;캐시 가능(Cacheable)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;응답은 캐시될 수 있어야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;계층화 시스템&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;중간 서버를 거치는지 여부를 클라이언트가 알 필요 없다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;일관된 인터페이스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;URI와 HTTP 메서드 등 일관된 방식으로 통신한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;코드 온 디맨드 (선택)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;필요하면 서버가 실행 가능한 코드를 전달할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 평소에 자주 접하는 카카오맵 API, 네이버 로그인, 공공데이터포털 등도 모두 REST API 방식으로 제공된다. 즉, 이미 우리 주변에서 널리 쓰이고 있는 방식이다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;이 원칙들을 잘 지키며 설계된 API가 바로 REST API다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. REST API vs RESTful API&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 말하면 실무에서는 두 단어를 &lt;b&gt;혼용해서 쓰는 경우가 대부분&lt;/b&gt;이다. 하지만 엄밀하게 구분하면 차이가 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;REST API&lt;/th&gt;
&lt;th&gt;RESTful API&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;REST 원칙을 기반으로 만든 API&lt;/td&gt;
&lt;td&gt;REST 원칙을 충실히 지킨 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;뉘앙스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;REST 스타일을 따른다고 주장하는 API&lt;/td&gt;
&lt;td&gt;REST 6가지 원칙을 제대로 준수한 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;현실&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;원칙 일부만 지켜도 REST API라고 부름&lt;/td&gt;
&lt;td&gt;완전한 준수가 기준&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;모든 RESTful API는 REST API지만, 모든 REST API가 RESTful한 것은 아니다. &lt;/b&gt;로이 필딩 본인도 &amp;ldquo;대부분의 REST API는 사실 RESTful하지 않다&amp;rdquo;고 말할 정도로, 완전한 RESTful 설계는 생각보다 까다롭다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section4&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. REST API 설계 방법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API는 &lt;b&gt;URI + HTTP 메서드&lt;/b&gt; 조합으로 행위를 표현한다. 먼저 아키텍처부터 살펴보자.&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;아키텍처&lt;/b&gt;&lt;/h4&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;rest api.jfif&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tE5WO/dJMcahxApZ9/aXsSmtJXJnpi24OLwQn8k0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tE5WO/dJMcahxApZ9/aXsSmtJXJnpi24OLwQn8k0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tE5WO/dJMcahxApZ9/aXsSmtJXJnpi24OLwQn8k0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtE5WO%2FdJMcahxApZ9%2FaXsSmtJXJnpi24OLwQn8k0%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;1024&quot; height=&quot;559&quot; data-filename=&quot;rest api.jfif&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;클라이언트(웹 앱, 모바일 앱, 프론트엔드)가 HTTP 요청(GET, POST, PUT, DELETE)을 인터넷을 통해 서버로 전송하고, 서버는 API 엔드포인트에서 요청을 받아 비즈니스 로직을 처리한 뒤 HTTP 상태 코드와 JSON 데이터를 응답으로 돌려주는 REST API의 전체 흐름이다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API는 데이터의 생성(Create), 조회(Read), 수정(Update), 삭제(Delete), 즉 &lt;b&gt;CRUD&lt;/b&gt;를 HTTP 메서드로 표현한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;HTTP 메서드&lt;/b&gt;&lt;/h4&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;메서드&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;데이터 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;데이터 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PUT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;데이터 전체 수정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PATCH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;데이터 일부 수정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELETE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;데이터 삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;URI 설계 예시&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시판 API를 설계한다고 해보자.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;GET    /posts         &amp;rarr; 게시글 목록 조회
GET    /posts/{id}    &amp;rarr; 특정 게시글 조회
POST   /posts         &amp;rarr; 게시글 작성
PUT    /posts/{id}    &amp;rarr; 게시글 전체 수정
PATCH  /posts/{id}    &amp;rarr; 게시글 일부 수정
DELETE /posts/{id}    &amp;rarr; 게시글 삭제&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;응답 예시 (JSON)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;GET /posts/1&lt;/code&gt; 요청 시 서버가 돌려주는 응답은 보통 이런 형태다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;{
  &quot;id&quot;: 1,
  &quot;title&quot;: &quot;REST API란 무엇인가&quot;,
  &quot;content&quot;: &quot;REST API는 ...&quot;,
  &quot;createdAt&quot;: &quot;2025-01-01&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;aside-box&quot;&gt;응답 데이터는 주로 JSON 형식으로 주고받으며, 프론트엔드에서 이 데이터를 파싱해서 화면에 보여준다.&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;URI 설계 시 주의할 점&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;❌ /getPosts          &amp;rarr; 동사 사용 금지
❌ /posts/delete/1    &amp;rarr; URI에 행위를 넣지 않는다
❌ /Posts             &amp;rarr; 대문자 사용 지양
❌ /posts/            &amp;rarr; 마지막 슬래시(/) 사용 지양

✅ /posts
✅ /posts/{id}
✅ /posts/{postId}/comments&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;aside-box&quot;&gt;REST에서는 행위를 URI에 넣지 않고, HTTP 메서드로 표현하는 것이 핵심이다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section5&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. HTTP 상태 코드도 중요하다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API는 응답에 적절한 HTTP 상태 코드를 함께 보내야 한다. 단순히 200만 사용하는 것은 RESTful한 설계라고 보기 어렵다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;코드&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;예시&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;200 OK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;요청 성공&lt;/td&gt;
&lt;td&gt;GET, PUT, PATCH 성공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;201 Created&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;리소스 생성 성공&lt;/td&gt;
&lt;td&gt;POST 성공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;204 No Content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;성공했지만 반환 데이터 없음&lt;/td&gt;
&lt;td&gt;DELETE 성공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;400 Bad Request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;잘못된 요청&lt;/td&gt;
&lt;td&gt;필수 파라미터 누락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;401 Unauthorized&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;인증 필요&lt;/td&gt;
&lt;td&gt;로그인 안 한 사용자&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;403 Forbidden&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;권한 없음&lt;/td&gt;
&lt;td&gt;다른 사람 글 삭제 시도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;404 Not Found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;리소스 없음&lt;/td&gt;
&lt;td&gt;존재하지 않는 게시글 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;500 Internal Server Error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버 오류&lt;/td&gt;
&lt;td&gt;서버 내부 에러&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section6&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API는 단순히 HTTP로 데이터를 주고받는 것 그 이상이다. URI와 HTTP 메서드를 어떻게 조합하느냐, 상태 코드를 얼마나 의미 있게 사용하느냐에 따라 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;RESTful API는 그 원칙을 잘 지킨 API를 부르는 말이고, 현실에서는 두 단어가 혼용되는 경우가 많다는 것도 알아두면 좋다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;다음 글에서는 Spring Boot로 실제 REST API를 만들어보는 과정을 다뤄볼 예정이다.&lt;/div&gt;
&lt;/div&gt;</description>
      <category>  CS &amp;amp; Infra/Web</category>
      <category>REST API</category>
      <category>restful api</category>
      <category>개발</category>
      <category>일상</category>
      <category>코딩</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/502</guid>
      <comments>https://dev-cloud.tistory.com/502#entry502comment</comments>
      <pubDate>Fri, 8 May 2026 11:17:49 +0900</pubDate>
    </item>
    <item>
      <title>[Kotlin] 코틀린 개요</title>
      <link>https://dev-cloud.tistory.com/501</link>
      <description>&lt;div&gt;
&lt;style&gt;
  .tistory-post {
    max-width: 780px;
    margin: 0 auto;
    line-height: 1.8;
    color: #222;
    font-size: 16px;
  }

  .tistory-post h1 {
    margin-top: 10px;
    margin-bottom: 14px;
    font-size: 32px;
    font-weight: 800;
    line-height: 1.4;
    color: #111;
  }

  .tistory-post h2 {
    margin-top: 52px;
    margin-bottom: 18px;
    font-size: 26px;
    border-bottom: 2px solid #222;
    padding-bottom: 8px;
    color: #111;
  }

  .tistory-post h3 {
    margin-top: 36px;
    margin-bottom: 14px;
    font-size: 21px;
    color: #222;
  }

  .tistory-post p {
    margin: 14px 0;
  }

  .tistory-post strong {
    font-weight: 700;
    color: #111;
  }

  .tistory-post code {
    background: #f3f4f6;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 0.95em;
  }

  .tistory-post pre {
    background: #1f2937;
    color: #f9fafb;
    padding: 18px;
    border-radius: 10px;
    overflow-x: auto;
    line-height: 1.6;
    font-size: 14px;
    margin: 18px 0 28px;
  }

  .tistory-post pre code {
    background: transparent;
    padding: 0;
    color: inherit;
    font-size: inherit;
  }

  .tistory-post table {
    width: 100%;
    border-collapse: collapse;
    margin: 20px 0 30px;
    font-size: 15px;
  }

  .tistory-post th,
  .tistory-post td {
    border: 1px solid #d1d5db;
    padding: 12px;
    text-align: left;
    vertical-align: top;
  }

  .tistory-post th {
    background: #f3f4f6;
    font-weight: 700;
  }

  .tistory-post ul {
    padding-left: 22px;
    margin: 14px 0 24px;
  }

  .tistory-post li {
    margin: 8px 0;
  }

  .tistory-post blockquote {
    margin: 24px 0;
    padding: 20px 24px;
    border-left: 5px solid #6366f1;
    background: #f5f7ff;
    border-radius: 10px;
    color: #333;
  }

  .tistory-post .post-header {
    margin-bottom: 42px;
    border-bottom: 3px solid #333;
    padding-bottom: 12px;
  }

  .tistory-post .badge {
    display: inline-block;
    background: #5b5fc7;
    color: #fff;
    padding: 4px 12px;
    border-radius: 4px;
    font-size: 14px;
    font-weight: 700;
    margin-bottom: 14px;
  }

  .tistory-post .toc {
    background: #f8f9fa;
    border-radius: 10px;
    padding: 26px;
    margin-bottom: 50px;
    border: 1px solid #e5e7eb;
  }

  .tistory-post .toc-title {
    margin: 0 0 16px;
    font-size: 18px;
    font-weight: 700;
    color: #111;
  }

  .tistory-post .toc ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .tistory-post .toc li {
    margin-bottom: 10px;
  }

  .tistory-post .toc a {
    color: #2563eb;
    text-decoration: none;
    font-weight: 500;
  }

  .tistory-post .aside-box {
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 5px solid #2563eb;
    border-radius: 10px;
    padding: 18px 20px;
    margin: 24px 0;
  }
&lt;/style&gt;
&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;
&lt;div class=&quot;post-header&quot;&gt;&lt;span class=&quot;badge&quot;&gt;KOTLIN&lt;/span&gt;
&lt;h1&gt;코틀린(Kotlin) 개요&lt;/h1&gt;
&lt;/div&gt;
&lt;div class=&quot;toc&quot;&gt;
&lt;p class=&quot;toc-title&quot; 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;a href=&quot;#section1&quot;&gt;1. 코틀린이 무엇인가?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section2&quot;&gt;2. 왜 Kotlin을 쓰는가?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section3&quot;&gt;3. 어디에 쓰이나?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section4&quot;&gt;4. 자프링 vs 코프링&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section5&quot;&gt;5. 어떤 걸 선택해야 할까?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section6&quot;&gt;6. 마치며&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;188&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sLRkA/dJMcajoxyL2/wic2xWvL5jcwxuQN34lF91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sLRkA/dJMcajoxyL2/wic2xWvL5jcwxuQN34lF91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sLRkA/dJMcajoxyL2/wic2xWvL5jcwxuQN34lF91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsLRkA%2FdJMcajoxyL2%2Fwic2xWvL5jcwxuQN34lF91%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;419&quot; height=&quot;314&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;188&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;Java는 들어봤는데, Kotlin은 뭔가요?&lt;br /&gt;요즘 Android 개발이나 백엔드에서 심심찮게 보이는 그 언어, 오늘 제대로 알아보자.&lt;/blockquote&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section1&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 코틀린이 무엇인가?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린(Kotlin)은 &lt;b&gt;JetBrains&lt;/b&gt;가 만든 정적 타입 프로그래밍 언어다. 2016년에 공식 출시됐고, 2017년에는 구글이 &lt;b&gt;Android 공식 개발 언어&lt;/b&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;Java와 100% 호환&lt;/b&gt;된다는 점이다. Kotlin 코드는 JVM 위에서 동작하기 때문에, 기존 Java 라이브러리를 그대로 사용할 수 있고, 하나의 프로젝트 안에서 Java와 Kotlin을 함께 사용하는 것도 가능하다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;Kotlin은 Java 생태계를 그대로 활용하면서도, 더 현대적인 문법과 안전성을 제공하는 언어다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section2&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 왜 Kotlin을 쓰는가?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한마디로 요약하면 &lt;b&gt;Java보다 코드가 훨씬 간결하고, 안전하다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java로 10줄 써야 할 코드를 Kotlin은 3줄로 끝내는 경우가 많다. 거기다 언어 차원에서 NPE(NullPointerException)를 방어하는 문법을 제공해서 런타임 에러도 줄어든다.&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;코틀린이 어디에 쓰이는가&amp;rdquo;에 집중해보자.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 어디에 쓰이나?&lt;/b&gt;&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;분야&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Android 개발&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Google 공식 언어. Jetpack Compose도 Kotlin 기반&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;백엔드 (Spring)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Spring Boot 5부터 Kotlin을 공식 지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;멀티플랫폼&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Kotlin Multiplatform으로 iOS, Web도 커버 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드 개발자라면 특히 &lt;b&gt;자프링 vs 코프링&lt;/b&gt; 이야기를 한 번쯤 들어봤을 것이다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section4&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 자프링 vs 코프링&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;자프링(Javaspring)&lt;/b&gt;은 Java + Spring의 조합, &lt;b&gt;코프링(Kopring)&lt;/b&gt;은 Kotlin + Spring의 조합을 부르는 말이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 Spring 프레임워크를 쓰지만, 어떤 언어로 작성하느냐에 따라 코드 스타일이 꽤 달라진다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;코드로 비교해보기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;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;&lt;b&gt;자프링 (Java + Spring)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int age;

    protected User() {}

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public int getAge() { return age; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;코프링 (Kotlin + Spring)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Entity
class User(
    val name: String,
    val age: Int,

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 기능인데 코드량 차이가 상당하다. Kotlin의 &lt;code&gt;data class&lt;/code&gt;나 기본 생성자 문법 덕분에 보일러플레이트가 확 줄어든다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section5&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 어떤 걸 선택해야 할까?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답은 없다. 다만 각각의 특징을 정리하면 이렇다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;자프링이 유리한 경우&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;팀 내 Java 숙련도가 높을 때&lt;/li&gt;
&lt;li&gt;레거시 Java 코드베이스를 유지보수할 때&lt;/li&gt;
&lt;li&gt;Spring 관련 레퍼런스와 예제가 Java 기반일 때&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class=&quot;aside-box&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;코프링이 유리한 경우&lt;/b&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;Null Safety와 간결한 문법의 장점을 활용하고 싶을 때&lt;/li&gt;
&lt;li&gt;Android 개발과 백엔드를 함께 진행할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section6&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kotlin은 단순히 &amp;ldquo;Java의 대체제&amp;rdquo;가 아니다. Java 생태계를 그대로 활용하면서도 더 현대적인 언어 기능을 사용할 수 있다는 점에서, Java를 아는 개발자라면 배워두면 확실히 무기가 하나 더 생기는 느낌이다.&lt;/p&gt;
&lt;div class=&quot;aside-box&quot;&gt;다음 글에서는 Kotlin의 기본 문법을 하나씩 파헤쳐볼 예정이다.&lt;/div&gt;
&lt;/div&gt;</description>
      <category>☕ Backend/Kotlin</category>
      <category>Kotiln</category>
      <category>개발</category>
      <category>일상</category>
      <category>자프링</category>
      <category>코딩</category>
      <category>코틀린</category>
      <category>코프링</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/501</guid>
      <comments>https://dev-cloud.tistory.com/501#entry501comment</comments>
      <pubDate>Fri, 8 May 2026 10:35:43 +0900</pubDate>
    </item>
    <item>
      <title>[리팩토링] 유지보수성을 높이기 위한 가독성 리팩토링 기록</title>
      <link>https://dev-cloud.tistory.com/500</link>
      <description>&lt;div&gt;
&lt;style&gt;
  .tistory-post {
    max-width: 780px;
    margin: 0 auto;
    line-height: 1.8;
    color: #222;
    font-size: 16px;
  }

  .tistory-post h1 {
    margin-top: 10px;
    margin-bottom: 14px;
    font-size: 28px;
    font-weight: 800;
    color: #111;
  }

  .tistory-post h2 {
    margin-top: 48px;
    margin-bottom: 18px;
    font-size: 26px;
    border-bottom: 2px solid #222;
    padding-bottom: 8px;
  }

  .tistory-post h3 {
    margin-top: 34px;
    margin-bottom: 14px;
    font-size: 21px;
  }

  .tistory-post p {
    margin: 14px 0;
  }

  .tistory-post code {
    background: #f3f4f6;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 0.95em;
  }

  .tistory-post pre {
  background: #f8fafc;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  padding: 16px 18px;
  overflow-x: auto;
  line-height: 1.7;
  font-size: 14px;
  margin: 18px 0 10px;
}

.tistory-post pre code {
  background: transparent;
  padding: 0;
  color: #111827;
  font-size: inherit;
  font-family: Consolas, Monaco, monospace;
}

.tistory-post .caption {
  margin-top: 0;
  margin-bottom: 28px;
  color: #6b7280;
  font-size: 14px;
  text-align: center;
}

  .tistory-post ul,
  .tistory-post ol {
    padding-left: 22px;
    margin: 14px 0 24px;
  }

  .tistory-post li {
    margin: 8px 0;
  }

  .tistory-post .post-header {
    margin-bottom: 40px;
    border-bottom: 3px solid #333;
    padding-bottom: 10px;
  }

  .tistory-post .badge {
    display: inline-block;
    background: #0066ff;
    color: #fff;
    padding: 3px 10px;
    border-radius: 3px;
    font-size: 14px;
    font-weight: 700;
  }

  .tistory-post .toc {
    background: #f8f9fa;
    border-radius: 8px;
    padding: 25px;
    margin-bottom: 50px;
    border: 1px solid #eee;
  }

  .tistory-post .toc-title {
    margin: 0 0 15px 0;
    font-weight: 700;
    font-size: 18px;
    color: #000;
  }

  .tistory-post .toc ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .tistory-post .toc li {
    margin-bottom: 10px;
  }

  .tistory-post .toc a {
    color: #2563eb;
    text-decoration: none;
    font-weight: 500;
  }

  .tistory-post .note-box {
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 5px solid #2563eb;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .image-box {
    margin: 18px 0 30px;
  }

  .tistory-post .caption {
    margin-top: 8px;
    color: #6b7280;
    font-size: 14px;
  }
&lt;/style&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div class=&quot;tistory-post&quot;&gt;
&lt;div class=&quot;post-header&quot;&gt;&lt;span class=&quot;badge&quot;&gt;REFACTORING&lt;/span&gt;
&lt;h1&gt;중복 코드와 매직 스트링 제거로 가독성 개선하기&lt;/h1&gt;
&lt;/div&gt;
&lt;div class=&quot;toc&quot;&gt;
&lt;p class=&quot;toc-title&quot; 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;a href=&quot;#section1&quot;&gt;1. 왜 가독성 리팩토링을 했는가&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section2&quot;&gt;2. 공통적으로 발견한 문제 패턴&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section3&quot;&gt;3. 구체적인 개선 사례&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section4&quot;&gt;4. 돌아보며&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id=&quot;section1&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 왜 가독성 리팩토링을 했는가&lt;/b&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;컨트롤러마다 &lt;code&gt;SecurityUtils.getCurrentUserId()&lt;/code&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;기능 개발 중에는 &amp;ldquo;일단 돌아가게&amp;rdquo; 하는 것이 우선이다 보니 이런 부분들이 자연스럽게 쌓인다. 하지만 이런 코드를 그대로 두면 나중에 수정할 때마다 먼저 코드를 해석하는 데 시간을 쓰게 된다.&lt;/p&gt;
&lt;div class=&quot;note-box&quot;&gt;그래서 기능 리팩토링과 별개로, 코드의 흐름을 더 빠르게 파악할 수 있도록 가독성 리팩토링을 따로 진행했다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section2&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 공통적으로 발견한 문제 패턴&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인을 하나씩 열어보니 비슷한 문제가 반복되고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;중복 코드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 사용자 ID를 꺼내는 코드가 컨트롤러 메서드마다 그대로 복사되어 있었다. 로직 자체는 한 줄이지만, 같은 코드가 여러 곳에 있으면 나중에 변경할 때 모든 위치를 찾아야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;매직 스트링&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의미를 알 수 있는 문자열이더라도 리터럴이 코드 여러 곳에 흩어져 있으면 관리가 어려워진다. 오타가 발생해도 컴파일 타임에 잡히지 않고, 키 형식을 바꾸려면 모든 참조 지점을 찾아 수정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;메서드 비대&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 메서드가 생성, 검증, 매핑을 한꺼번에 처리하는 경우가 있었다. 메서드 이름만 봐서는 정확히 무엇을 하는지 알기 어렵고, 내부 구현을 끝까지 읽어야 흐름을 파악할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;모호한 네이밍&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반환하는 값이 무엇인지 이름만으로 추론하기 어려운 경우가 있었다. 예를 들어 &lt;code&gt;getUsers&lt;/code&gt;처럼 단순한 이름은 단건 조회인지, 목록 조회인지, 페이지 조회인지 구분하기 어렵다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 구체적인 개선 사례&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3-1. getCurrentUserId() 헬퍼 추출&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러마다 아래와 같이 현재 로그인한 사용자 ID를 꺼내는 코드가 반복됐다.&lt;/p&gt;
&lt;pre id=&quot;code_1778158839654&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Long userId = SecurityUtils.getCurrentUserId();&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;컨트롤러 메서드마다 &lt;code&gt;SecurityUtils.getCurrentUserId()&lt;/code&gt;를 직접 호출하던 코드다.&lt;/p&gt;
&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;memo&lt;/code&gt;, &lt;code&gt;post&lt;/code&gt;, &lt;code&gt;progress&lt;/code&gt;, &lt;code&gt;reminder&lt;/code&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;code&gt;SecurityUtils&lt;/code&gt;의 메서드 시그니처가 바뀌거나, 예외 처리 방식을 통일하고 싶을 때 모든 컨트롤러를 뒤져야 한다. 그래서 각 컨트롤러에 &lt;code&gt;getCurrentUserId()&lt;/code&gt; private 헬퍼 메서드를 추출해 호출 지점을 하나로 모았다.&lt;/p&gt;
&lt;pre id=&quot;code_1778158858294&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private Long getCurrentUserId() {
    return SecurityUtils.getCurrentUserId();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SecurityUtils.getCurrentUserId()&lt;/code&gt; 호출을 private 헬퍼 메서드로 감싼 코드다.&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3-2. 매직 스트링을 상수로 분리&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AuthService&lt;/code&gt;에서 이메일 인증 목적을 구분하는 문자열이 리터럴로 사용되고 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1778158888459&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 변경 전
redisService.save(&quot;signup:&quot; + email, code);
redisService.save(&quot;password-reset:&quot; + email, code);&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;signup&lt;/code&gt;, &lt;code&gt;password-reset&lt;/code&gt; 문자열을 Redis key 생성에 직접 사용하던 코드다.&lt;/p&gt;
&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;signup&lt;/code&gt;, &lt;code&gt;password-reset&lt;/code&gt; 같은 문자열은 의미를 어느 정도 알 수 있다. 하지만 코드 여러 곳에 흩어져 있으면 오타가 생겨도 컴파일 타임에 잡히지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 나중에 키 형식을 바꾸려면 모든 참조 지점을 찾아 수정해야 한다. 그래서 인증 목적을 나타내는 문자열을 상수로 분리했다.&lt;/p&gt;
&lt;pre id=&quot;code_1778158900219&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private static final String SIGNUP_PURPOSE = &quot;signup&quot;;
private static final String PASSWORD_RESET_PURPOSE = &quot;password-reset&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;인증 목적 문자열을 &lt;code&gt;SIGNUP_PURPOSE&lt;/code&gt;, &lt;code&gt;PASSWORD_RESET_PURPOSE&lt;/code&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;br /&gt;
&lt;h2 id=&quot;section4&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 돌아보며&lt;/b&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;하지만 전체 코드를 훑고 나니 코드를 읽는 흐름은 확실히 달라졌다. 메서드 하나를 열었을 때 무엇을 하는지 더 빠르게 파악할 수 있었고, 같은 패턴이 어디 있는지 찾으러 다니는 시간도 줄었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작은 그대로지만 읽히는 속도가 빨라진 느낌이었다. 결국 가독성 리팩토링은 지금 당장의 나보다 나중에 다시 코드를 읽을 나를 위한 작업이라고 느꼈다.&lt;/p&gt;
&lt;div class=&quot;note-box&quot;&gt;정리하면, 가독성 리팩토링은 기능을 바꾸는 작업은 아니지만 유지보수 비용을 줄이는 작업이다. 코드를 더 쉽게 읽고, 더 안전하게 수정할 수 있게 만드는 것도 중요한 개발 작업이다.&lt;/div&gt;
&lt;/div&gt;</description>
      <category>개발</category>
      <category>리팩토링</category>
      <category>일상</category>
      <category>코딩</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/500</guid>
      <comments>https://dev-cloud.tistory.com/500#entry500comment</comments>
      <pubDate>Thu, 7 May 2026 22:02:36 +0900</pubDate>
    </item>
    <item>
      <title>[트러블슈팅] JWT secret 키 길이 부족으로 인한 WeakKeyException</title>
      <link>https://dev-cloud.tistory.com/499</link>
      <description>&lt;div&gt;
&lt;style&gt;
  .tistory-post {
    max-width: 780px;
    margin: 0 auto;
    line-height: 1.8;
    color: #222;
    font-size: 16px;
  }

  .tistory-post h1 {
    margin-top: 10px;
    margin-bottom: 14px;
    font-size: 28px;
    font-weight: 800;
    color: #111;
  }

  .tistory-post h2 {
    margin-top: 48px;
    margin-bottom: 18px;
    font-size: 26px;
    border-bottom: 2px solid #222;
    padding-bottom: 8px;
  }

  .tistory-post h3 {
    margin-top: 34px;
    margin-bottom: 14px;
    font-size: 21px;
  }

  .tistory-post p {
    margin: 14px 0;
  }

  .tistory-post code {
    background: #f3f4f6;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 0.95em;
  }

  .tistory-post pre {
    background: #1f2937;
    color: #f9fafb;
    padding: 18px;
    border-radius: 8px;
    overflow-x: auto;
    line-height: 1.6;
    font-size: 14px;
    margin: 18px 0 28px;
  }

  .tistory-post pre code {
    background: transparent;
    padding: 0;
    color: inherit;
    font-size: inherit;
  }

  .tistory-post table {
    width: 100%;
    border-collapse: collapse;
    margin: 18px 0 28px;
    font-size: 15px;
  }

  .tistory-post th,
  .tistory-post td {
    border: 1px solid #d1d5db;
    padding: 12px;
    vertical-align: top;
  }

  .tistory-post th {
    background: #f3f4f6;
    font-weight: 700;
    text-align: left;
  }

  .tistory-post ul,
  .tistory-post ol {
    padding-left: 22px;
    margin: 14px 0 24px;
  }

  .tistory-post li {
    margin: 8px 0;
  }

  .tistory-post .post-header {
    margin-bottom: 40px;
    border-bottom: 3px solid #333;
    padding-bottom: 10px;
  }

  .tistory-post .badge {
    display: inline-block;
    background: #0066ff;
    color: #fff;
    padding: 3px 10px;
    border-radius: 3px;
    font-size: 14px;
    font-weight: 700;
  }

  .tistory-post .toc {
    background: #f8f9fa;
    border-radius: 8px;
    padding: 25px;
    margin-bottom: 50px;
    border: 1px solid #eee;
  }

  .tistory-post .toc-title {
    margin: 0 0 15px 0;
    font-weight: 700;
    font-size: 18px;
    color: #000;
  }

  .tistory-post .toc ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .tistory-post .toc li {
    margin-bottom: 10px;
  }

  .tistory-post .toc a {
    color: #2563eb;
    text-decoration: none;
    font-weight: 500;
  }

  .tistory-post .note-box {
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 5px solid #2563eb;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .problem-box {
    background: #fff7f7;
    border: 1px solid #fecaca;
    border-left: 5px solid #ef4444;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .solution-box {
    background: #f0fdf4;
    border: 1px solid #bbf7d0;
    border-left: 5px solid #22c55e;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .image-box {
    margin: 18px 0 30px;
  }

  .tistory-post .caption {
    margin-top: 8px;
    color: #6b7280;
    font-size: 14px;
  }
&lt;/style&gt;
&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;
&lt;div class=&quot;post-header&quot;&gt;&lt;span class=&quot;badge&quot;&gt;TROUBLESHOOTING&lt;/span&gt;
&lt;h1&gt;JWT secret 키 길이 부족으로 인한 WeakKeyException 해결&lt;/h1&gt;
&lt;/div&gt;
&lt;div class=&quot;toc&quot;&gt;
&lt;p class=&quot;toc-title&quot; 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;a href=&quot;#section1&quot;&gt;1. 구현 배경&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section2&quot;&gt;2. 문제 상황&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section3&quot;&gt;3. 원인&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section4&quot;&gt;4. 해결&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section5&quot;&gt;5. 왜 시작 시점에 터지는가&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section6&quot;&gt;6. 배운 점&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id=&quot;section1&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 구현 배경&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 기반 인증 시스템을 구현했다. &lt;code&gt;JwtProvider&lt;/code&gt;에 &lt;code&gt;@PostConstruct&lt;/code&gt;로 &lt;code&gt;init()&lt;/code&gt; 메서드를 작성해 애플리케이션 시작 시 secret 키를 초기화하도록 했고, &lt;code&gt;application.yml&lt;/code&gt;에 &lt;code&gt;jwt.secret&lt;/code&gt; 값을 설정했다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;721&quot; data-origin-height=&quot;168&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UYscI/dJMcag6uRDX/wJFwW5vq6U00UykJzOLcz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UYscI/dJMcag6uRDX/wJFwW5vq6U00UykJzOLcz0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UYscI/dJMcag6uRDX/wJFwW5vq6U00UykJzOLcz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUYscI%2FdJMcag6uRDX%2FwJFwW5vq6U00UykJzOLcz0%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;687&quot; height=&quot;160&quot; data-origin-width=&quot;721&quot; data-origin-height=&quot;168&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;note-box&quot;&gt;JWT 서명에는 secret 키가 사용된다. 이 키는 토큰이 변조되지 않았는지 검증하는 기준이 되기 때문에, 사용하는 알고리즘이 요구하는 최소 길이를 만족해야 한다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section2&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 문제 상황&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션을 실행하자마자 서버가 시작되지 않았고, &lt;code&gt;jwtProvider&lt;/code&gt; 빈 생성 과정에서 초기화 메서드 실행에 실패했다.&lt;/p&gt;
&lt;div class=&quot;problem-box&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Error creating bean with name 'jwtProvider': Invocation of init method failed&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스택 트레이스를 따라가보니 근본 원인은 &lt;code&gt;WeakKeyException&lt;/code&gt;이었다. 지정한 secret 키의 길이가 JWT HMAC-SHA 알고리즘에서 요구하는 보안 기준을 충족하지 못했다.&lt;/p&gt;
&lt;div class=&quot;problem-box&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;
        io.jsonwebtoken.security.WeakKeyException: The specified key byte array is 152 bits&lt;br /&gt;
        which is not secure enough for any JWT HMAC-SHA algorithm.
      &lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 원인&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 &lt;code&gt;application.yml&lt;/code&gt;에 설정한 &lt;code&gt;jwt.secret&lt;/code&gt; 기본값이 너무 짧았기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;app.jwt.secret=${JWT_SECRET:change-me-for-local}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본값으로 사용한 &lt;code&gt;change-me-for-local&lt;/code&gt;은 길이가 짧아 HMAC-SHA256 알고리즘의 최소 요구 키 길이인 &lt;b&gt;256비트(32바이트)&lt;/b&gt;를 만족하지 못했다.&lt;/p&gt;
&lt;div class=&quot;note-box&quot;&gt;HMAC-SHA256을 사용할 경우 secret 키는 최소 32바이트 이상이어야 한다. 그보다 짧은 키를 사용하면 jjwt가 &lt;code&gt;WeakKeyException&lt;/code&gt;을 발생시켜 애플리케이션 시작을 막는다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section4&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 해결&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 &lt;code&gt;jwt.secret&lt;/code&gt; 값을 32자 이상으로 변경하는 것이다. 로컬 환경에서는 충분한 길이의 임시 secret 값을 설정해 서버가 정상적으로 기동되도록 했다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;jwt:
  secret: booktine-secret-key-for-jwt-signing-must-be-32bytes-or-longer&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;solution-box&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;secret 키 길이를 충분히 늘린 뒤 서버가 정상적으로 기동되었고, &lt;code&gt;JwtProvider&lt;/code&gt; 초기화도 성공했다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 환경에서 사용할 secret 키는 직접 작성하기보다, 아래와 같은 명령어로 충분히 긴 랜덤 값을 생성하는 것이 안전하다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;openssl rand -base64 64&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성한 값은 코드나 설정 파일에 직접 작성하지 않고 환경변수로 분리한 뒤, &lt;code&gt;application.yml&lt;/code&gt;에서 참조하도록 구성한다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;jwt:
  secret: ${JWT_SECRET}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 환경은 &lt;code&gt;application-local.yml&lt;/code&gt;에 별도로 관리하거나, &lt;code&gt;.env&lt;/code&gt; 파일로 분리해두는 방식이 깔끔하다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section5&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 왜 런타임이 아니라 시작 시점에 터지는가&lt;/b&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;이유는 &lt;code&gt;jjwt&lt;/code&gt;가 의도적으로 초기화 시점에 키 유효성을 검증하기 때문이다. &lt;code&gt;@PostConstruct&lt;/code&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;fail-fast&lt;/b&gt;라고 부른다.&lt;/p&gt;
&lt;div class=&quot;note-box&quot;&gt;fail-fast는 문제가 있는 설정이나 상태를 가능한 한 빠른 시점에 발견하고 차단하는 방식이다. 보안 설정처럼 잘못된 상태로 서버가 실행되면 위험해질 수 있는 영역에서는 특히 중요한 패턴이다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스펙 미달 키로 서명된 토큰이 실제 트래픽에서 사용되는 상황 자체를 막는다는 점에서, jjwt의 초기화 시점 검증은 보안 측면에서도 올바른 설계라고 볼 수 있다.&lt;/p&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section6&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 배운 점&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HMAC-SHA256 기준 secret 키는 최소 32바이트, 즉 256비트 이상이어야 한다.&lt;/li&gt;
&lt;li&gt;jjwt는 스펙 미달 키를 런타임이 아니라 초기화 시점에 차단한다.&lt;/li&gt;
&lt;li&gt;운영 환경에서는 secret 값을 설정 파일에 직접 작성하지 말고 환경변수로 분리해야 한다.&lt;/li&gt;
&lt;li&gt;운영 secret은 직접 작성한 문자열보다 충분한 길이의 랜덤 값을 사용하는 것이 안전하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;note-box&quot;&gt;정리하면, JWT secret은 단순한 문자열 설정값이 아니라 토큰의 신뢰성을 결정하는 보안 요소다. 알고리즘이 요구하는 최소 길이를 만족하고, 운영 환경에서는 반드시 환경변수로 안전하게 관리해야 한다.&lt;/div&gt;
&lt;/div&gt;</description>
      <category>  PROJECT/[Spring Boot, React] 독서 습관 관리 서비스</category>
      <category>JWT</category>
      <category>jwt secret</category>
      <category>WeakKeyException</category>
      <category>개발</category>
      <category>일상</category>
      <category>트러블슈팅</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/499</guid>
      <comments>https://dev-cloud.tistory.com/499#entry499comment</comments>
      <pubDate>Thu, 7 May 2026 21:49:46 +0900</pubDate>
    </item>
    <item>
      <title>[트러블슈팅] 커뮤니티 좋아요 상태 불일치로 인한 409 오류</title>
      <link>https://dev-cloud.tistory.com/498</link>
      <description>&lt;div&gt;
&lt;style&gt;
  .tistory-post {
    max-width: 780px;
    margin: 0 auto;
    line-height: 1.8;
    color: #222;
    font-size: 16px;
  }

  .tistory-post h1 {
    margin-top: 10px;
    margin-bottom: 14px;
    font-size: 28px;
    font-weight: 800;
    color: #111;
  }

  .tistory-post h2 {
    margin-top: 48px;
    margin-bottom: 18px;
    font-size: 26px;
    border-bottom: 2px solid #222;
    padding-bottom: 8px;
  }

  .tistory-post h3 {
    margin-top: 34px;
    margin-bottom: 14px;
    font-size: 21px;
  }

  .tistory-post p {
    margin: 14px 0;
  }

  .tistory-post code {
    background: #f3f4f6;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 0.95em;
  }

  .tistory-post table {
    width: 100%;
    border-collapse: collapse;
    margin: 18px 0 28px;
    font-size: 15px;
  }

  .tistory-post th,
  .tistory-post td {
    border: 1px solid #d1d5db;
    padding: 12px;
    vertical-align: top;
  }

  .tistory-post th {
    background: #f3f4f6;
    font-weight: 700;
    text-align: left;
  }

  .tistory-post ul,
  .tistory-post ol {
    padding-left: 22px;
    margin: 14px 0 24px;
  }

  .tistory-post li {
    margin: 8px 0;
  }

  .tistory-post .post-header {
    margin-bottom: 40px;
    border-bottom: 3px solid #333;
    padding-bottom: 10px;
  }

  .tistory-post .badge {
    display: inline-block;
    background: #0066ff;
    color: #fff;
    padding: 3px 10px;
    border-radius: 3px;
    font-size: 14px;
    font-weight: 700;
  }

  .tistory-post .toc {
    background: #f8f9fa;
    border-radius: 8px;
    padding: 25px;
    margin-bottom: 50px;
    border: 1px solid #eee;
  }

  .tistory-post .toc-title {
    margin: 0 0 15px 0;
    font-weight: 700;
    font-size: 18px;
    color: #000;
  }

  .tistory-post .toc ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .tistory-post .toc li {
    margin-bottom: 10px;
  }

  .tistory-post .toc a {
    color: #2563eb;
    text-decoration: none;
    font-weight: 500;
  }

  .tistory-post .note-box {
    background: #f8fafc;
    border: 1px solid #dbeafe;
    border-left: 5px solid #2563eb;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .problem-box {
    background: #fff7f7;
    border: 1px solid #fecaca;
    border-left: 5px solid #ef4444;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .solution-box {
    background: #f0fdf4;
    border: 1px solid #bbf7d0;
    border-left: 5px solid #22c55e;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 24px 0;
  }

  .tistory-post .image-box {
    margin: 18px 0 30px;
  }

  .tistory-post .caption {
    margin-top: 8px;
    color: #6b7280;
    font-size: 14px;
  }
&lt;/style&gt;
&lt;/div&gt;
&lt;div class=&quot;tistory-post&quot;&gt;
&lt;div class=&quot;post-header&quot;&gt;&lt;span class=&quot;badge&quot;&gt;TROUBLESHOOTING&lt;/span&gt;
&lt;h1&gt;커뮤니티 좋아요 상태 불일치로 인한 409 오류&lt;/h1&gt;
&lt;/div&gt;
&lt;div class=&quot;toc&quot;&gt;
&lt;p class=&quot;toc-title&quot; 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;a href=&quot;#section1&quot;&gt;1. 문제 상황&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section2&quot;&gt;2. 원인 분석&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section3&quot;&gt;3. 해결 방법&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section4&quot;&gt;4. 적용 결과&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section5&quot;&gt;5. 배운 점&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id=&quot;section1&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 문제 상황&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커뮤니티 게시글에서 좋아요 버튼을 클릭했을 때 간헐적으로 &lt;code&gt;409 Conflict&lt;/code&gt; 오류가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 동시에 같은 요청이 여러 번 발생하는 동시성 문제를 의심했다. 하지만 재현 조건을 확인해보니 특정 상황에서 일관되게 발생하는 문제였다.&lt;/p&gt;
&lt;div class=&quot;image-box&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SbXwJ/dJMcad2WV5H/KndBMrJW7jCUsMnxsKck51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SbXwJ/dJMcad2WV5H/KndBMrJW7jCUsMnxsKck51/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SbXwJ/dJMcad2WV5H/KndBMrJW7jCUsMnxsKck51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSbXwJ%2FdJMcad2WV5H%2FKndBMrJW7jCUsMnxsKck51%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;1158&quot; height=&quot;240&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;240&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;커뮤니티 댓글창에서 좋아요 버튼을 클릭하는 화면&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;image-box&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;472&quot; data-origin-height=&quot;191&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ffh0s/dJMcafmeqFE/oWi2ltntWlN2fl8lKadG61/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ffh0s/dJMcafmeqFE/oWi2ltntWlN2fl8lKadG61/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ffh0s/dJMcafmeqFE/oWi2ltntWlN2fl8lKadG61/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFfh0s%2FdJMcafmeqFE%2FoWi2ltntWlN2fl8lKadG61%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;472&quot; height=&quot;191&quot; data-origin-width=&quot;472&quot; data-origin-height=&quot;191&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;좋아요 요청 이후 화면에서 확인된 &lt;code&gt;409 Conflict&lt;/code&gt; 오류&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;image-box&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;682&quot; data-origin-height=&quot;33&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Hpict/dJMcagrSRqV/Wb4MkmbsoX7QgYgwB2AZ81/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Hpict/dJMcagrSRqV/Wb4MkmbsoX7QgYgwB2AZ81/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Hpict/dJMcagrSRqV/Wb4MkmbsoX7QgYgwB2AZ81/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHpict%2FdJMcagrSRqV%2FWb4MkmbsoX7QgYgwB2AZ81%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;682&quot; height=&quot;33&quot; data-origin-width=&quot;682&quot; data-origin-height=&quot;33&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;브라우저 개발자 도구 Console에서 확인한 좋아요 요청 실패 로그&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;problem-box&quot;&gt;좋아요 요청 자체는 서버로 정상 전송되었지만, 서버 기준으로는 이미 좋아요가 존재하는 상태였기 때문에 중복 요청으로 판단되어 409 오류가 발생했다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section2&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 원인 분석&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 좋아요 상태를 백엔드가 아닌 &lt;code&gt;localStorage&lt;/code&gt;에 저장해 관리하고 있었기 때문이다.&lt;/p&gt;
&lt;div class=&quot;image-box&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;892&quot; data-origin-height=&quot;422&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LPe0O/dJMcacbXgMo/WnYdWlBSoXRaa93SXdVO6k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LPe0O/dJMcacbXgMo/WnYdWlBSoXRaa93SXdVO6k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LPe0O/dJMcacbXgMo/WnYdWlBSoXRaa93SXdVO6k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLPe0O%2FdJMcacbXgMo%2FWnYdWlBSoXRaa93SXdVO6k%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;892&quot; height=&quot;422&quot; data-origin-width=&quot;892&quot; data-origin-height=&quot;422&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;localStorage&lt;/code&gt;를 기준으로 좋아요한 게시글 ID를 조회하고 저장하던 초기 코드&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &lt;code&gt;localStorage&lt;/code&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;code&gt;localStorage&lt;/code&gt;가 초기화된 경우&lt;/li&gt;
&lt;li&gt;백엔드 DB의 좋아요 상태와 클라이언트 저장 상태가 달라진 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;code&gt;localStorage&lt;/code&gt;에는 좋아요를 누르지 않은 상태로 저장되어 있지만, 백엔드에는 이미 좋아요 데이터가 존재할 수 있다. 이 상태에서 프론트엔드가 다시 좋아요 요청을 보내면 서버는 중복 좋아요 요청으로 판단하고 &lt;code&gt;409 Conflict&lt;/code&gt;를 반환한다.&lt;/p&gt;
&lt;div class=&quot;note-box&quot;&gt;결국 근본 원인은 서버 상태와 동기화되어야 하는 좋아요 정보를 클라이언트 저장소인 &lt;code&gt;localStorage&lt;/code&gt;를 기준으로 판단한 것이었다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 해결 방법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 &lt;code&gt;localStorage&lt;/code&gt; 기반 상태 관리를 제거하고, 백엔드에서 &lt;code&gt;isLiked&lt;/code&gt; 필드를 직접 내려주는 방식으로 전환하는 것이었다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3-1. CommunityPostResponse에 isLiked 필드 추가&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 게시글 응답 DTO인 &lt;code&gt;CommunityPostResponse&lt;/code&gt;에 &lt;code&gt;isLiked&lt;/code&gt; 필드를 추가했다. 이를 통해 프론트엔드는 localStorage가 아니라 서버가 내려준 좋아요 상태를 기준으로 버튼 상태를 렌더링할 수 있다.&lt;/p&gt;
&lt;div class=&quot;image-box&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;771&quot; data-origin-height=&quot;538&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceoUca/dJMcaaZu1Wy/MJVNCv2ut6w9JafznVpqhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceoUca/dJMcaaZu1Wy/MJVNCv2ut6w9JafznVpqhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceoUca/dJMcaaZu1Wy/MJVNCv2ut6w9JafznVpqhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceoUca%2FdJMcaaZu1Wy%2FMJVNCv2ut6w9JafznVpqhk%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;512&quot; height=&quot;357&quot; data-origin-width=&quot;771&quot; data-origin-height=&quot;538&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;CommunityPostResponse&lt;/code&gt;에 &lt;code&gt;isLiked&lt;/code&gt; 필드를 추가하고, &lt;code&gt;from()&lt;/code&gt; 메서드에서 좋아요 상태를 포함해 응답하도록 수정한 코드다.&lt;/p&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3-2. 목록 조회 시 N+1 방지&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 목록을 조회할 때 게시글마다 &lt;code&gt;existsByPostIdAndUserId()&lt;/code&gt;를 호출하면 게시글 수만큼 좋아요 조회 쿼리가 추가로 발생한다. 이는 전형적인 N+1 문제로 이어질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 방지하기 위해 사용자가 좋아요한 게시글 ID를 한 번에 조회하는 메서드를 추가했다.&lt;/p&gt;
&lt;div class=&quot;image-box&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;775&quot; data-origin-height=&quot;76&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nao6X/dJMcagemWwT/EMIcm6OzdHeWP4uWKIsT31/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nao6X/dJMcagemWwT/EMIcm6OzdHeWP4uWKIsT31/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nao6X/dJMcagemWwT/EMIcm6OzdHeWP4uWKIsT31/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fnao6X%2FdJMcagemWwT%2FEMIcm6OzdHeWP4uWKIsT31%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;775&quot; height=&quot;76&quot; data-origin-width=&quot;775&quot; data-origin-height=&quot;76&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 좋아요한 게시글 ID 목록을 한 번에 조회하는 &lt;code&gt;findPostIdsByUserId()&lt;/code&gt; 쿼리 메서드다.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;image-box&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1052&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qHpCB/dJMcad2WV9A/vSkDGqObYGLpXxeyJYCnf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qHpCB/dJMcad2WV9A/vSkDGqObYGLpXxeyJYCnf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qHpCB/dJMcad2WV9A/vSkDGqObYGLpXxeyJYCnf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqHpCB%2FdJMcad2WV9A%2FvSkDGqObYGLpXxeyJYCnf1%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;621&quot; height=&quot;223&quot; data-origin-width=&quot;1052&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;getPosts()&lt;/code&gt;에서 사용자가 좋아요한 게시글 ID를 &lt;code&gt;Set&lt;/code&gt;으로 변환한 뒤, 각 게시글의 &lt;code&gt;isLiked&lt;/code&gt; 값을 일괄 처리하는 코드다.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;note-box&quot;&gt;게시글마다 좋아요 여부를 개별 조회하지 않고, 좋아요한 게시글 ID 목록을 한 번에 가져온 뒤 &lt;code&gt;Set&lt;/code&gt;으로 확인하면 N+1 문제를 줄일 수 있다.&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3-3. 프론트 localStorage 제거&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드에서는 기존의 &lt;code&gt;localStorage&lt;/code&gt; 기반 좋아요 상태 관리를 제거했다. 대신 백엔드 응답에 포함된 &lt;code&gt;isLiked&lt;/code&gt; 값을 기준으로 좋아요 버튼 상태를 렌더링하도록 수정했다.&lt;/p&gt;
&lt;div class=&quot;image-box&quot;&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AavdY/dJMcaiDebwf/BEyyqg4HMk2Xn9LTBaeqW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AavdY/dJMcaiDebwf/BEyyqg4HMk2Xn9LTBaeqW1/img.png&quot; width=&quot;613&quot; height=&quot;396&quot; data-origin-width=&quot;725&quot; data-origin-height=&quot;468&quot; data-is-animation=&quot;false&quot; style=&quot;width: 48.0106%; margin-right: 10px;&quot; data-widthpercent=&quot;48.63&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AavdY/dJMcaiDebwf/BEyyqg4HMk2Xn9LTBaeqW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAavdY%2FdJMcaiDebwf%2FBEyyqg4HMk2Xn9LTBaeqW1%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;725&quot; height=&quot;468&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rMGu7/dJMcagk6Mys/CSLzML8dCArTAE3UYGHFNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rMGu7/dJMcagk6Mys/CSLzML8dCArTAE3UYGHFNk/img.png&quot; data-origin-width=&quot;733&quot; data-origin-height=&quot;448&quot; data-is-animation=&quot;false&quot; style=&quot;width: 50.7074%;&quot; data-widthpercent=&quot;51.37&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rMGu7/dJMcagk6Mys/CSLzML8dCArTAE3UYGHFNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrMGu7%2FdJMcagk6Mys%2FCSLzML8dCArTAE3UYGHFNk%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;733&quot; height=&quot;448&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;

&lt;p class=&quot;caption&quot; style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;수정 후 &lt;code&gt;toggleLike&lt;/code&gt; 함수다. 좋아요 상태를 &lt;code&gt;localStorage&lt;/code&gt;가 아니라 백엔드에서 내려준 &lt;code&gt;isLiked&lt;/code&gt; 기준으로 처리한다.&lt;/p&gt;
&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section4&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 적용 결과&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;좋아요 토글 시 발생하던 &lt;code&gt;409 Conflict&lt;/code&gt; 오류가 해소되었다.&lt;/li&gt;
&lt;li&gt;다른 기기나 브라우저에서도 좋아요 상태가 정확하게 반영되었다.&lt;/li&gt;
&lt;li&gt;목록 조회 시 불필요한 개별 좋아요 조회 쿼리를 줄여 N+1 문제를 방지했다.&lt;/li&gt;
&lt;li&gt;클라이언트 상태가 아니라 서버 상태를 기준으로 UI를 렌더링하게 되었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;solution-box&quot;&gt;좋아요 상태의 기준을 &lt;code&gt;localStorage&lt;/code&gt;에서 백엔드 응답의 &lt;code&gt;isLiked&lt;/code&gt;로 변경하면서, 서버와 클라이언트 간 상태 불일치 문제를 해결할 수 있었다.&lt;/div&gt;
&lt;br /&gt;
&lt;h2 id=&quot;section5&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 배운 점&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요처럼 서버 상태와 동기화가 필요한 데이터는 클라이언트 저장소만으로 관리하면 안 된다. &lt;code&gt;localStorage&lt;/code&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를 렌더링하고, 사용자 액션에 따라 서버에 변경 요청을 보내는 역할에 집중하는 것이 안전하다.&lt;/p&gt;
&lt;div class=&quot;note-box&quot;&gt;정리하면, 서버와 동기화되어야 하는 상태는 클라이언트 저장소를 기준으로 판단하지 말고, 백엔드 응답 값을 기준으로 관리해야 한다.&lt;/div&gt;
&lt;/div&gt;</description>
      <category>  PROJECT/[Spring Boot, React] 독서 습관 관리 서비스</category>
      <category>409에러</category>
      <category>개발</category>
      <category>일상</category>
      <category>좋아요 기능</category>
      <category>트러블슈팅</category>
      <author>devCloud</author>
      <guid isPermaLink="true">https://dev-cloud.tistory.com/498</guid>
      <comments>https://dev-cloud.tistory.com/498#entry498comment</comments>
      <pubDate>Thu, 7 May 2026 21:27:35 +0900</pubDate>
    </item>
  </channel>
</rss>