<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>우당탕탕 코딩 블로그</title>
    <link>https://rim08.tistory.com/</link>
    <description>안되는 거 같아도 일단 해보기 고</description>
    <language>ko</language>
    <pubDate>Sun, 5 Apr 2026 21:59:15 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>rim08</managingEditor>
    <image>
      <title>우당탕탕 코딩 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/6811153/attach/30e68a09cef441d7b01fed7d63967003</url>
      <link>https://rim08.tistory.com</link>
    </image>
    <item>
      <title>[토이프로젝트 개발일지 06] 일기 기록 웹 (AWS EC2배포)</title>
      <link>https://rim08.tistory.com/82</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔&amp;nbsp; AWS 배포와 리눅스 배포의 차이점을 잘 몰랐는데, 이번 기회에 공부할 수 있었다.&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;AWS 배포는 리눅스 배포를 &lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;클라우드&lt;/b&gt;&lt;/span&gt;에서 하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 리눅스 서버로 하면 Ubuntu를 설치하고 직접 서버를 관리하며, 보통 IP가 고정이 아니고 외부에서 접근하기 어렵다는 특징이 있다.&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;그래서 AWS(Amazon Web Service)를 이용하여 배포하면 실제 서비스처럼 다른 사람도 쉽게 이용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 인터넷에 공개하므로 IP가 제공되며, 대신 보안 설정이 필요하다. (포트 등)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;AWS EC2 (리눅스 서버)&lt;br /&gt;&amp;rarr; Java 설치&lt;br /&gt;&amp;rarr; DB 설치 or RDS&lt;br /&gt;&amp;rarr; jar 실행&lt;br /&gt;&amp;rarr; 다른 사람도 접속 가능&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 AWS는 비용 때문에 걱정했는데, 다행히 6개월 동안 프리티어로 이용할 수 있는 것 같아 바로 실행하기로 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 많이 들어본 3종류가 등장한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;EC2: 서버 배포(Spring Boot)&lt;br /&gt;RDS: DB 배포 (MariaDB)&lt;br /&gt;S3: 정적 파일(선택)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 학습용 프로젝트이기도 하고 둘 다 하면 월 무료 범위가 초과될 수도 있기 때문에&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 하나에 MariaDB를 직접 설치하는 방식으로 결정했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&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;사용자&amp;nbsp;브라우저 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;darr;&amp;nbsp; http://EC2 퍼블릭 IP:8080 &lt;br /&gt;AWS&amp;nbsp;EC2&amp;nbsp;(t2.micro,&amp;nbsp;Ubuntu) &lt;br /&gt;&amp;nbsp;&amp;nbsp;├──&amp;nbsp;Spring&amp;nbsp;Boot&amp;nbsp;앱&amp;nbsp;(jar&amp;nbsp;실행) &lt;br /&gt;&amp;nbsp;&amp;nbsp;└──&amp;nbsp;MariaDB&amp;nbsp;(EC2에&amp;nbsp;직접&amp;nbsp;설치)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;1. AWS EC2 생성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS 회원 가입 후에 EC2 생성하는 건 아래 글을 그대로 따라했다. (진짜 성공함!!)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://olrlobt.tistory.com/83&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://olrlobt.tistory.com/83&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1775045401686&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;[INFRA] AWS EC2 프리티어 인스턴스 생성하기&quot; data-og-description=&quot;AWS (Amazon Web Services) AWS는 Amazon이 제공하는 클라우드 컴퓨팅 플랫폼 및 인프라 서비스 모음이다. 2006년에 시작된 AWS는 가상 컴퓨터, 스토리지, 데이터베이스, 네트워킹, 분석, 머신 러닝, 모바일, &quot; data-og-host=&quot;olrlobt.tistory.com&quot; data-og-source-url=&quot;https://olrlobt.tistory.com/83&quot; data-og-url=&quot;https://olrlobt.tistory.com/83&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bMcrpE/dJMb9frFITO/jcJQPG4wW4ck8DqKSovxV0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/d9QMWl/dJMb9lMbM7v/zyUa15I1cE9zVXWpnGaVH0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/b63Ay0/dJMb9hC1zWN/u4K3V6WxZB2xTZChg64CEK/img.png?width=2395&amp;amp;height=1472&amp;amp;face=0_0_2395_1472&quot;&gt;&lt;a href=&quot;https://olrlobt.tistory.com/83&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://olrlobt.tistory.com/83&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bMcrpE/dJMb9frFITO/jcJQPG4wW4ck8DqKSovxV0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/d9QMWl/dJMb9lMbM7v/zyUa15I1cE9zVXWpnGaVH0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/b63Ay0/dJMb9hC1zWN/u4K3V6WxZB2xTZChg64CEK/img.png?width=2395&amp;amp;height=1472&amp;amp;face=0_0_2395_1472');&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;[INFRA] AWS EC2 프리티어 인스턴스 생성하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;AWS (Amazon Web Services) AWS는 Amazon이 제공하는 클라우드 컴퓨팅 플랫폼 및 인프라 서비스 모음이다. 2006년에 시작된 AWS는 가상 컴퓨터, 스토리지, 데이터베이스, 네트워킹, 분석, 머신 러닝, 모바일,&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;olrlobt.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 인스턴스 생성할 때 우분투 22.04로 했는데 (프리티어)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 내 노트북에는 우분투 24.04가 설치되어 있어서 그러면 오류나는 거 아닌가 걱정했다가...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 PC(WSL)는 SSH 접속 도구 터미널일 뿐이고, EC2 실제 서버는 따로 있으니까 서로 우분투 버전 달라도 괜찮은 거였다. (생각해 보니 맞는 말)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;785&quot; data-origin-height=&quot;459&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFB72a/dJMb996WBCO/Pkp6GKIPazMdeRr8fqjp3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFB72a/dJMb996WBCO/Pkp6GKIPazMdeRr8fqjp3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFB72a/dJMb996WBCO/Pkp6GKIPazMdeRr8fqjp3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFB72a%2FdJMb996WBCO%2FPkp6GKIPazMdeRr8fqjp3K%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;415&quot; data-origin-width=&quot;785&quot; data-origin-height=&quot;459&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이제는 t2.micro가 프리 티어가 아니라서 기본으로 선택되어 있는 t3.micro로 선택했다. (여기서도 버전 다르면 오류 날까봐 조마조마했는데 다행히 괜찮았음)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;738&quot; data-origin-height=&quot;401&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IWEKM/dJMcad2wkHO/tNO354EKHHs2klxPIsb7M0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IWEKM/dJMcad2wkHO/tNO354EKHHs2klxPIsb7M0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IWEKM/dJMcad2wkHO/tNO354EKHHs2klxPIsb7M0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIWEKM%2FdJMcad2wkHO%2FtNO354EKHHs2klxPIsb7M0%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;738&quot; height=&quot;401&quot; data-origin-width=&quot;738&quot; data-origin-height=&quot;401&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키 페어는 diary-key로 이름 짓고 (이미 만들어 둔 상태에서 다시 캡처하느라...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RSA, .pem으로 설정(기본)한 다음에 다운로드 한다. 이건 절대 분실하면 안 되고, 파일은 프로젝트 파일 쪽으로 옮겼다. (추후 우분투 터미널에서 찾아야 하니까 파일 위치도 알아야 함)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;643&quot; data-origin-height=&quot;574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ljae4/dJMcaiW4BUG/bNA3lhAmKmxUFIFgt5xK9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ljae4/dJMcaiW4BUG/bNA3lhAmKmxUFIFgt5xK9K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ljae4/dJMcaiW4BUG/bNA3lhAmKmxUFIFgt5xK9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fljae4%2FdJMcaiW4BUG%2FbNA3lhAmKmxUFIFgt5xK9K%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;575&quot; height=&quot;513&quot; data-origin-width=&quot;643&quot; data-origin-height=&quot;574&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;530&quot; data-origin-height=&quot;297&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cthts6/dJMcad2wkMP/29ijWefZkogVUrb39rHiVk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cthts6/dJMcad2wkMP/29ijWefZkogVUrb39rHiVk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cthts6/dJMcad2wkMP/29ijWefZkogVUrb39rHiVk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcthts6%2FdJMcad2wkMP%2F29ijWefZkogVUrb39rHiVk%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;450&quot; height=&quot;252&quot; data-origin-width=&quot;530&quot; data-origin-height=&quot;297&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스토리지 설정은 아래와 같이 했는데, 어느 설정을 해도 아래 오류 문구 같은 글이 계속 떠서 식겁했었다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 찾아보니까 오류는 아니었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;766&quot; data-origin-height=&quot;359&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zHiok/dJMcabwUaEh/Zbe9AFi0szoY4uL37gj5f0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zHiok/dJMcabwUaEh/Zbe9AFi0szoY4uL37gj5f0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zHiok/dJMcabwUaEh/Zbe9AFi0szoY4uL37gj5f0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzHiok%2FdJMcabwUaEh%2FZbe9AFi0szoY4uL37gj5f0%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;651&quot; height=&quot;305&quot; data-origin-width=&quot;766&quot; data-origin-height=&quot;359&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AMI는 서버 이미지로, 운영체제 +&amp;nbsp; 기본 설정이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스 저장소는 EC2에 붙는 임시 디스크로, 속도가 빠르고 인스턴스 종료하면 데이터가 날아가는 형태이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 현재 AMI(우분투 22.04)는 임시 디스크도 쓸 수 있는 이미지이지만 선택한 EC2타입(t3.micro)는 임시 디스크 지원을 안 해서 그 기능만 못 쓴다는 뜻이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 프로젝트는 MariaDB를 사용하고 파일은 없거나 조금밖에 없을 예정이므로 인스턴스 저장소가 필요 없다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스 저장소는 초고속 데이터 처리, 캐시 서버, 로그 임시 저장에 주로 쓰이므로 웹 서비스에서는 거의 안 쓰는 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 지금은 무시해도 되는 경고이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 설정하고 인스턴스 생성 누르기!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이제 보안그룹 설정을 해야 한다. 어떤 포트에서 들어오는 걸 허용할지, 어떤 트래픽을 내보낼 수 있는지 설정할 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;사용자 지정 TCP는 8080: 0.0.0.0/0 -&amp;gt; spring Boot 앱&lt;br /&gt;HTTP는 80: 0.0.0.0/0&lt;br /&gt;SSH는 22: (내 IP) -&amp;gt; 서버 접속용&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0.0.0.0/0은 Anyware로, 인터넷 어디서든 누구나 접속 가능하다는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8080을 열지 않으면 사이트가 안 열리므로 꼭 열어주자. (HTTP는 선택, 나중에 Nginx붙이거나 도메인 연결할 때 필요한 거)&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;이때 SSH는 본인만 서버 접속 가능하게 해야 하므로 반드시 자신의 IP로 소스를 설정한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만일 SSH를 0.0.0.0/0으로 열면 보안상 치명적인 문제가 벌어질 수 있으므로 자신의 IP로 제안해야 한다.&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;gt; 결론: 22번은 자신만, 8080은 모두에게 열기&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;733&quot; data-origin-height=&quot;324&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/be63qi/dJMcahKGJ30/plMoBr9adsE1d2wvGFu89K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/be63qi/dJMcahKGJ30/plMoBr9adsE1d2wvGFu89K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/be63qi/dJMcahKGJ30/plMoBr9adsE1d2wvGFu89K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbe63qi%2FdJMcahKGJ30%2FplMoBr9adsE1d2wvGFu89K%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;324&quot; data-origin-width=&quot;733&quot; data-origin-height=&quot;324&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 연결 버튼 누르고 본격적으로 터미널 창에서 실행 시작!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;2. EC2 서버 접속&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 윈도우를 사용하므로 리눅스 명령어를 사용하려면 WSL을 이용해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WSL은 윈도우 안에서 리눅스(우분투)를 실행하는 기능이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치한 다음에 wsl 명령어를 치면 우분투 환경으로 넘어간다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1006&quot; data-origin-height=&quot;645&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VMcZ8/dJMcaiQi2CV/yX747mFMnzgaji82KtXse1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VMcZ8/dJMcaiQi2CV/yX747mFMnzgaji82KtXse1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VMcZ8/dJMcaiQi2CV/yX747mFMnzgaji82KtXse1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVMcZ8%2FdJMcaiQi2CV%2FyX747mFMnzgaji82KtXse1%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;668&quot; height=&quot;548&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1006&quot; data-origin-height=&quot;645&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초록색 표시처럼 환경이 바뀌면 cd 명령어를 이용해서 키를 저장해 둔 위치로 이동한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아까 프로젝트 파일에 넣어놓았으므로 그쪽으로 이동한 다음에, 다음 명령어를 이용해서 키의 설정 권한을 바꾼다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;#키 파일 권한 설정 (Mac/Linux) &lt;br /&gt;chmod 400 diary-key.pem&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;400: 소유자에게 읽기 권한 부여, 그 외 나머지는 전부 권한 없음 (파일 수정 및 삭제, 다른 사람이 읽기 방지)&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;그리고 나서 EC2에 접속하려면&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;#EC2 접속 (퍼블릭 IP는 EC2 콘솔에서 확인) &lt;br /&gt;ssh -i diary-key.pem ubuntu@{EC2_퍼블릭_IP}&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 명령어를 작성하면 된다. 이때 퍼블릭 IP는 아까 AWS의 EC2 설정 창에서 왼쪽 메뉴바에서 인스턴스를 클릭하면 하단에 퍼블릭 IP 주소가 나온다. 그거 그대로 쓰면 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;233&quot; data-origin-height=&quot;155&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JQTv3/dJMcadanlHB/17YhTa9jtRBYaadgx9wUZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JQTv3/dJMcadanlHB/17YhTa9jtRBYaadgx9wUZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JQTv3/dJMcadanlHB/17YhTa9jtRBYaadgx9wUZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJQTv3%2FdJMcadanlHB%2F17YhTa9jtRBYaadgx9wUZk%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;233&quot; height=&quot;155&quot; data-origin-width=&quot;233&quot; data-origin-height=&quot;155&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 연결하면 아래와 같은 메시지가 나오는데, 이 서버가 진짜 맞는지, 처음 보는 서버인데 믿고 연결할 건지 묻는 절차이므로 그냥 yes 입력하면 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])?&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여기까지 하면......&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오류가 발생한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;94&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJc2mM/dJMcabcAVNN/EHF9ZefZfNRmYM1johOYt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJc2mM/dJMcabcAVNN/EHF9ZefZfNRmYM1johOYt1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJc2mM/dJMcabcAVNN/EHF9ZefZfNRmYM1johOYt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJc2mM%2FdJMcabcAVNN%2FEHF9ZefZfNRmYM1johOYt1%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;712&quot; height=&quot;94&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;94&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;WARNING: UNPROTECTED PRIVATE KEY FILE!&lt;br /&gt;Permissions 0555 for 'diary-key.pem' are too open. It is required that your private key files are NOT accessible by others.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키 설정 명령이 안 들어먹힌 것이다......&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한참 해맸다. 분명 chmod 400 했는데 왜 안 되는 건지...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 알고 보니 윈도우에서 파일을 가져오는 경우에는 다르게 접근해야 한다는 것을 깨달았다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WSL에서 mnt/c/... 경로면 권한이 이상할 수 있다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다음과 같이 해결한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775048249023&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cp /mnt/c/프로젝트 위치/diary-key.pem ~/
cd ~
chmod 400 diary-key.pem&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위치를 리눅스 홈으로 해서, key 파일을 그쪽으로 복사한 다음에 거기로 이동해서 chmod를 실행하면...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 권한 설정이 된다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WSL은 리눅스지만, C드라이브는 진짜 리눅스 파일 시스템이 아니기 때문에 보안 권한이 깨지는 것이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러고 나서 다시 실행하면&lt;/p&gt;
&lt;pre id=&quot;code_1775048418022&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ssh -i diary-key.pem ubuntu@내 퍼블릭 IP&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 기존에는 키 파일 권한 때문에 SSH가 막았으나, 이제는 chmod로 정상적으로 권한 설정이 됐으니 다음 단계로 넘어가진다.&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;이렇게 하면 터미널에서 초록 글씨가 ubuntu@ip-...:~$으로 바뀐다. 제대로 우분투를 실행한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이 과정에서 rebooting하라고 뜨는데, 커널 업데이트 됐는데 아직 재부팅 안 해서 적용 안 됐다는 뜻이긴 하지만 지금 당장 안 해도 돼서 일단 무시해도 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 창으로 떠서 마우스로 조작이 안 되고 키보드로만 조작되는데, Tab 키로 버튼 간 이동할 수 있으니까 이거 여러번 눌러서 cancel 선택되면 엔터 치거나, 그냥 간단하게 Esc 누르거나 Ctrl+C 눌러서 빠르게 창을 빠져나가면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;3. EC2에 Java &amp;amp; MariaDB 설치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 명령어를 순서대로 입력한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775048806711&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#패키지 업데이트
sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y

#Java 17 설치
sudo apt install -y openjdk-17-jdk

#Java 버전 확인
java -version

#MariaDB 설치
sudo apt install -y mariadb-server

#MariaDB 시작 &amp;amp; 부팅 시 자동 시작 등록
sudo systemctl start mariadb
sudo systemctl enable mariadb

#MariaDB 보안 설정 (root 비밀번호 설정)
sudo mysql_secure_installation&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 마지막 명령어에서 여러가지 질문이 나오면서 계속 y/n 선택하라고 그러는데, 그건 아래처럼 하면 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Switch to unix_socket authentication [Y/n]&lt;br /&gt;(MariaDB에서 비밀번호 대신 OS 계정으로 로그인 할래?)&lt;br /&gt;=&amp;gt; Y (앞으로 sudo mysql 로 바로 접속 가능. 그냥 Y 선택하는 게 나음)&lt;br /&gt;-&amp;gt; N (이러면 mysql -u root -p 로 매번 비밀번호 입력해야 함)&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Remove anonymous users? (익명 사용자 제거)&lt;br /&gt;-&amp;gt; Y (보안 좋음)&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Disallow root login remotely? (root 외부 접속 차단)&lt;br /&gt;-&amp;gt; Y (보안 좋음)&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Remove test database? (테스트 DB 삭제)&lt;br /&gt;-&amp;gt; Y (깔끔)&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Reload privilege tables? (변경사항 적용)&lt;br /&gt;-&amp;gt; Y&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Change the root password? &lt;br /&gt;-&amp;gt; N (sudo mysql로 접속하게 했는데 여기서 Y 누르면 다시 비밀번호 방식으로 바뀌어서 불편해짐)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이제 mariaDB에 접속해서 DB를 생성하고 앱 전용 계정을 생성한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;db이름에 맞춰 사용자를 설정하는 게 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(지금 설치하는 MariaDB와 원래 노트북에 설치된 MariaDB는 완전히 별개이므로 서로 절대 영향이 없다.)&lt;/p&gt;
&lt;pre id=&quot;code_1775049424768&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- MariaDB 접속
sudo mysql

-- DB 생성
CREATE DATABASE diary_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 앱 전용 계정 생성 (root 직접 사용 지양)
CREATE USER 'diary_user'@'localhost' IDENTIFIED BY '비밀번호설정';
GRANT ALL PRIVILEGES ON diary_db.* TO 'diary_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;4. Spring Boot 빌드 &amp;amp; 업로드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 잠깐 인텔리제이로 돌아와서, (wsl 터미널 창 끄지 말고)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인텔리제이 터미널에 아래 명령어를 입력한다. 실행 파일인 jar 파일을 생성하는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1775049540178&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#인텔리제이 터미널 또는 로컬 터미널에서
./gradlew clean build -x test

#빌드 완료 후 jar 파일 위치
#build/libs/myDiary-0.0.1-SNAPSHOT.jar&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 203515.png&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1pZ8h/dJMcagLMHn2/QTIGj2Ow3kawucFBo2Titk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1pZ8h/dJMcagLMHn2/QTIGj2Ow3kawucFBo2Titk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1pZ8h/dJMcagLMHn2/QTIGj2Ow3kawucFBo2Titk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1pZ8h%2FdJMcagLMHn2%2FQTIGj2Ow3kawucFBo2Titk%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;640&quot; height=&quot;354&quot; data-filename=&quot;스크린샷 2026-03-18 203515.png&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;520&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음에 다시 WSL 창에서 scp 명령어로 jar 파일을 EC2에 업로드 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775049777060&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//엔터 쳤는데 원래는 한 줄에 공백으로만 구분해서 작성함
scp -i diary-key.pem
build/libs/myDiary-0.0.1-SNAPSHOT.jar
ubuntu@{EC2_퍼블릭_IP}:/home/ubuntu/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가뜩이나 WSL에 복붙하기도 잘 안 돼서 하나하나 다 쳐야 하는데 또 여기서 계속 오류가 발생한다.....&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(이전 명령어 다시 하고 싶으면 방향키 &amp;uarr; 누르면 됨)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Warning: Identity file diary.key.pem not accessible: No such file or directory.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 에러 발생하는 이유는 키 파일 경로가 틀렸기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니까 맞게 입력했는데 왜 틀렸냐면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널 창의 초록 글씨를 보자... 아직 별다른 명령을 안 했다면 아직 계속 ubuntu@ip-... 일 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 EC2 서버 안에 있기 때문에, 이 서버 안에는 key 파일이 없으니 당연히 에러가 나는 것이었다. 내 컴퓨터에서 EC2로 파일을 보내야 하는데 EC2에서 EC2로 보내는 꼴이었다.&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;그래서 우선 exit로 빠져나와서 다시 초록 글씨가 되돌아오게 만든 다음에 cd로 내 프로젝트 경로로 이동한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775050187296&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;exit

cd /mnt/c/myStudy/myDiary/myDiary&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런 다음에 방향키 &amp;uarr; 이걸로 이전 명령어 다시 선택하면 (이거 까먹어서 하나하나 계속 쳤던 사람)&lt;/p&gt;
&lt;pre id=&quot;code_1775050272001&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;scp -i ~/diary-key.pem build/libs/myDiary-0.0.1-SNAPSHOT.jar ubuntu@퍼블릭IP:/home/ubuntu&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WSL -&amp;gt; EC2로 무사히 파일이 전송된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;5. application.yml 운영 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2에서 운영용 설정 파일을 별도로 만든다. DB 비밀번호와 같은 민감한 정보를 jar 파일 안에 넣지 않기 위해서이다.&lt;/p&gt;
&lt;pre id=&quot;code_1775050677021&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#EC2에서 설정 파일 생성
vi /home/ubuntu/application-prod.yml&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;vi에서 i 눌러서 입력 모드로 들어간 다음에 Ctrl + Shift + V 하거나 마우스 우클릭으로 복붙할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1775050839316&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#application-prod.yml (EC2에만 존재, Git에 올리면 안 됨)
spring:
  datasource:
    url: jdbc:mariadb://localhost:3306/diary_db
    username: diary_user
    password: 아까 설정한 비밀번호
    driver-class-name: org.mariadb.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
  jackson:
    serialization:
      write-dates-as-timestamps: false
    time-zone: Asia/Seoul

jwt:
  secret: &quot;원래 설정한 키&quot;
  expiration: 86400000

server:
  port: 8080&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 Esc 누른 다음에&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;:wq&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력하면 저장후 나갈 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 앱 실행&lt;/h2&gt;
&lt;pre id=&quot;code_1775050987667&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#운영 프로파일로 실행
java -jar
  -Dspring.config.location=/home/ubuntu/application-prod.yml
  /home/ubuntu/diary-0.0.1-SNAPSHOT.jar&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면...!&lt;/p&gt;
&lt;p 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;blockquote data-ke-style=&quot;style3&quot;&gt;ERROR 17105 --- [ main] o.s.b.d.LoggingFailureAnalysisReporter :&lt;br /&gt;*** APPLICATION FAILED TO START ***&lt;br /&gt;Description: Failed to bind properties under 'spring.jackson.serialization' to java.util.Map&amp;lt;tools.jackson.databind.SerializationFeature, java.lang.Boolean&amp;gt;:&lt;br /&gt;Reason: failed to convert java.lang.String to tools.jackson.databind.SerializationFeature (caused by java.lang.IllegalArgumentException: No enum constant tools.jackson.databind.SerializationFeature.write-dates-as-timestamps)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 설정 파일에서 쓴 json 직렬화 때문이다. 대문자 enum 이름만 허용하니 아래처럼 바꿔야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775051232828&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;jackson:
    serialization:
      WRITE_DATES_AS_TIMESTAMPS: false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;vi application-prod.yml 열어서 다시 수정하고 :wq 저장한 다음에 다시 java - jar myDiary-0.0.1-SNAPSHOT.jar로 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이렇게 하면.........&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 오류난다.(제발)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Reason: failed to convert java.lang.String to tools.jackson.databind.SerializationFeature (caused by java.lang.IllegalArgumentException: No enum constant tools.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대문자 바꿔도 이것대로 또 오류나는 걸 보니 그게 문제가 아니었나 보다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 찾아보니 Jackson 패키지가 잘못되었다고 한다.&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;가장 빠른 해결 방법은... 그냥 yml에서 직렬화 관련 코드를 다 삭제하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정 없어도 Spring Boot 기본 설정으로 정상 동작하기 때문에 괜찮다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 그 전에 백엔드에서 Map으로서 json으로 가게 하고, 프론트엔드에서 json 관련 try 예외 처리 문을 만들었으니 어느 정도는 괜찮을 것 같아 이 설정을 지워 버렸다. 로컬에선 이게 있어도 정상 작동돼서 남겨 두었고, EC2 yml에서만 지웠다.&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;근본적인 문제 원인은 스프링 부트 4.0.3을 썼는데 4.x 버전은 아직 안정적이 아니라 Jackson 패키지가 꼬일 수 있다고 한다. 그래서 3.x로 낮추면 해결될 수도 있다고 하는데...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서는 이미 정상 Jackson 라이브러리가 캐싱돼서 문제 없이 돌아가는 것이고, EC2에서는 새로 build해서 dependency가 새로 다운로드 되므로 잘못된 조합이 설치(tools.jackson같은 거)돼서 영향을 미칠 수도 있다고 한다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하여튼 추후에는 버전 점검도 같이 해보기로 하고, 이번에는 일단 직렬화 설정을 빼도 실제 테스트 시 프론트에서 오류가 나지 않아서 그냥 빼기로 했다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 백그라운드 실행&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;pre id=&quot;code_1775052644926&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo vi /etc/systemd/system/diary.service&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;vi로 열어서 아래 코드 복붙한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775052662259&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Unit]
Description=Diary Spring Boot App
After=network.target mariadb.service

[Service]
User=ubuntu
ExecStart=java -jar \
  -Dspring.config.location=/home/ubuntu/application-prod.yml \
  /home/ubuntu/diary-0.0.1-SNAPSHOT.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 후 나간 다음 아래 명령어를 입력한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775052694817&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#서비스 등록 &amp;amp; 시작
sudo systemctl daemon-reload
sudo systemctl start diary
sudo systemctl enable diary   #부팅 시 자동 시작

#실행 상태 확인
sudo systemctl status diary

#로그 확인
sudo journalctl -u diary -f&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이제 오류까지 무사히 해결한다면.......&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인터넷 주소에 퍼블릭 IP 치면 접속 완료~!!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1255&quot; data-origin-height=&quot;948&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2iDcU/dJMcafMS5Ct/FRc96yBqhEgSOQrE51ZkM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2iDcU/dJMcafMS5Ct/FRc96yBqhEgSOQrE51ZkM0/img.png&quot; data-alt=&quot;감동적이다8ㅁ8&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2iDcU/dJMcafMS5Ct/FRc96yBqhEgSOQrE51ZkM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2iDcU%2FdJMcafMS5Ct%2FFRc96yBqhEgSOQrE51ZkM0%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;793&quot; height=&quot;599&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1255&quot; data-origin-height=&quot;948&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;감동적이다8ㅁ8&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데......&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입하려니까 &amp;lt;또다시 오류 발생&amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;failed to fetch가 떴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 프론트와 백엔드의 연결 문제이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CORS 문제 또는 API 주소 문제인데, 개발자도구 F12로 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1775052115590&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//CORS 에러인 경우
Access to fetch at 'http://...' from origin 'http://...' has been blocked by CORS

//요청 자체 실패인 경우
Failed to fetch&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;난 아래였다. 프론트가 서버에 못 붙는 상태라는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하여튼 코드를 수정하고 다시 jar파일을 업로드해서 실행해야 하므로 현재 EC2에서 실행중인 스프링부트는 Ctrl + C를 통해 종료한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오류 원인: 아직 코드에서 로컬 주소로 되어 있어서&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 그 전에 작성했던 api.js를 수정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트와 백엔드가 같은 EC2 서버(같은 포트 8080)에서 돌아가고 있으니까, 빈 문자열로 바꾸면 현재 접속한 서버로 자동으로 요청을 보낼 수 있다. 이렇게 하면 어느 환경에서 열든(로컬이든 EC2든) 자기 자신한테 요청을 보내서 따로 수정할 필요가 없다.&lt;/p&gt;
&lt;pre id=&quot;code_1775052278640&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//기존 - 로컬 주소 하드코딩
const BASE_URL = 'http://localhost:8080';

//빈 문자열로 변경
const BASE_URL = '';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재배포 순서는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1775052430771&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#1. 인텔리제이 터미널에서 jar 다시 빌드
./gradlew clean build -x test

#2. WSL에서 EC2로 업로드 (이때 또 우분투로 되어있으니 exit로 나가고 실행)
scp -i diary-key.pem
    build/libs/myDiary-0.0.1-SNAPSHOT.jar
    ubuntu@{EC2_퍼블릭_IP}:/home/ubuntu/

#3. EC2에서 서비스 재시작
ssh -i diary-key.pem ubuntu@{EC2_퍼블릭_IP}
sudo systemctl restart diary&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;1. AWS 계정 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. EC2 t3.micro 생성 (Ubuntu, 보안그룹 22/8080 오픈)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. EC2 접속 후 Java 17 + MariaDB 설치&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. MariaDB에 db 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 로컬에서 jar 빌드 후 scp로 업로드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. application-prod.yml 작성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7. java -jar 로 실행 확인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8. systemd 서비스 등록으로 상시 가동&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-시작 화면 (로그인 화면)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1915&quot; data-origin-height=&quot;909&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIq32S/dJMcacP8nge/emNE3IxbWeDOE6j5CfRD9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIq32S/dJMcacP8nge/emNE3IxbWeDOE6j5CfRD9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIq32S/dJMcacP8nge/emNE3IxbWeDOE6j5CfRD9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIq32S%2FdJMcacP8nge%2FemNE3IxbWeDOE6j5CfRD9k%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;814&quot; height=&quot;386&quot; data-origin-width=&quot;1915&quot; data-origin-height=&quot;909&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-회원가입 화면&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1914&quot; data-origin-height=&quot;906&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PUBEL/dJMcahcPtYH/4bgsPkuYasFymyLEQz4ZBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PUBEL/dJMcahcPtYH/4bgsPkuYasFymyLEQz4ZBK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PUBEL/dJMcahcPtYH/4bgsPkuYasFymyLEQz4ZBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPUBEL%2FdJMcahcPtYH%2F4bgsPkuYasFymyLEQz4ZBK%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;820&quot; height=&quot;388&quot; data-origin-width=&quot;1914&quot; data-origin-height=&quot;906&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-메인 화면 (캘린더)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;908&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byUSfa/dJMcadBqBth/Z8UQYwvoT5eTjrjxSxCBAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byUSfa/dJMcadBqBth/Z8UQYwvoT5eTjrjxSxCBAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byUSfa/dJMcadBqBth/Z8UQYwvoT5eTjrjxSxCBAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyUSfa%2FdJMcadBqBth%2FZ8UQYwvoT5eTjrjxSxCBAk%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;827&quot; height=&quot;396&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;908&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-날짜별 일기 목록 화면&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1916&quot; data-origin-height=&quot;904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0jHQV/dJMcagdWwOG/fdgfeneosBIhe6DNkFe8t0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0jHQV/dJMcagdWwOG/fdgfeneosBIhe6DNkFe8t0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0jHQV/dJMcagdWwOG/fdgfeneosBIhe6DNkFe8t0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0jHQV%2FdJMcagdWwOG%2FfdgfeneosBIhe6DNkFe8t0%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;820&quot; height=&quot;387&quot; data-origin-width=&quot;1916&quot; data-origin-height=&quot;904&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-상세 조회 화면&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1913&quot; data-origin-height=&quot;905&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RgaIY/dJMcabwUcNY/GkfhJboaSfShvrHGgeSUW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RgaIY/dJMcabwUcNY/GkfhJboaSfShvrHGgeSUW0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RgaIY/dJMcabwUcNY/GkfhJboaSfShvrHGgeSUW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRgaIY%2FdJMcabwUcNY%2FGkfhJboaSfShvrHGgeSUW0%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;805&quot; height=&quot;381&quot; data-origin-width=&quot;1913&quot; data-origin-height=&quot;905&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-글 작성 화면 (수정 시엔 기존의 제목, 내용 불러와서 여기에 표시됨)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1910&quot; data-origin-height=&quot;903&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vWhDF/dJMcac3DNAb/SMMYNhxW1ckYI6gsIKcRlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vWhDF/dJMcac3DNAb/SMMYNhxW1ckYI6gsIKcRlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vWhDF/dJMcac3DNAb/SMMYNhxW1ckYI6gsIKcRlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvWhDF%2FdJMcac3DNAb%2FSMMYNhxW1ckYI6gsIKcRlK%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;796&quot; height=&quot;376&quot; data-origin-width=&quot;1910&quot; data-origin-height=&quot;903&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-삭제는 알림으로&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;978&quot; data-origin-height=&quot;511&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cXIvJa/dJMcafMS52u/ot32as2cakzXWdRkjTD7c1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cXIvJa/dJMcafMS52u/ot32as2cakzXWdRkjTD7c1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cXIvJa/dJMcafMS52u/ot32as2cakzXWdRkjTD7c1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcXIvJa%2FdJMcafMS52u%2Fot32as2cakzXWdRkjTD7c1%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;712&quot; height=&quot;372&quot; data-origin-width=&quot;978&quot; data-origin-height=&quot;511&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-사실 모바일로 주로 이용할 거라 그에 더 잘 맞는 것 같아서 목업해 봄 ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.canva.com/templates&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.canva.com&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1775054972755&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Free templates | Canva&quot; data-og-description=&quot;Browse through our professionally designed selection of free templates and customize a design for any occasion.&quot; data-og-host=&quot;www.canva.com&quot; data-og-source-url=&quot;https://www.canva.com/templates&quot; data-og-url=&quot;https://www.canva.com/templates/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bJqeuy/dJMb9frFJqG/C5eGVqKHkAE2fkfxdp4CuK/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://www.canva.com/templates&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.canva.com/templates&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bJqeuy/dJMb9frFJqG/C5eGVqKHkAE2fkfxdp4CuK/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&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;Free templates | Canva&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Browse through our professionally designed selection of free templates and customize a design for any occasion.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.canva.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d03crS/dJMcacvQ4lg/azAzArBgEa07i470HTdgL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d03crS/dJMcacvQ4lg/azAzArBgEa07i470HTdgL0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d03crS/dJMcacvQ4lg/azAzArBgEa07i470HTdgL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd03crS%2FdJMcacvQ4lg%2FazAzArBgEa07i470HTdgL0%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;662&quot; height=&quot;662&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vgAUY/dJMcad2wnku/jkPTuavknUT2iLkz7W2SdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vgAUY/dJMcad2wnku/jkPTuavknUT2iLkz7W2SdK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vgAUY/dJMcad2wnku/jkPTuavknUT2iLkz7W2SdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvgAUY%2FdJMcad2wnku%2FjkPTuavknUT2iLkz7W2SdK%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;645&quot; height=&quot;645&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;3.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/drq1a5/dJMcafe3NGP/g3KXzNCM7Vsc1ceQRNqbP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/drq1a5/dJMcafe3NGP/g3KXzNCM7Vsc1ceQRNqbP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/drq1a5/dJMcafe3NGP/g3KXzNCM7Vsc1ceQRNqbP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdrq1a5%2FdJMcafe3NGP%2Fg3KXzNCM7Vsc1ceQRNqbP1%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;665&quot; height=&quot;665&quot; data-filename=&quot;3.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일 &amp;amp; 웹 무사히 연동된다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 기본적인 구현과 배포까지 모두 완료~!~!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 목표 완전 달성했습니다... 뿌듯하네요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에서 추가로 CI/CD 자동화 구축하거나 환경 변수 등록하는 것도 있는데 그건 추후에 더 공부해 봐야겠다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 만드는데 성공했으니까 메모장으로 잘 써야겠다 ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트는 여기서 끝~~~&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;007&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/007.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/007.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;</description>
      <category> Project/myDiary - 일기 기록 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/82</guid>
      <comments>https://rim08.tistory.com/82#entry82comment</comments>
      <pubDate>Wed, 1 Apr 2026 23:52:02 +0900</pubDate>
    </item>
    <item>
      <title>[토이프로젝트 개발일지 05] 일기 기록 웹 (js, localhost test)</title>
      <link>https://rim08.tistory.com/81</link>
      <description>&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;src/main/resources/static/ &lt;br /&gt;├──&amp;nbsp;index.html&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;larr;&amp;nbsp;로그인&amp;nbsp;/&amp;nbsp;회원가입 &lt;br /&gt;├──&amp;nbsp;main.html&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;larr;&amp;nbsp;메인&amp;nbsp;(캘린더) &lt;br /&gt;├──&amp;nbsp;diary.html&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;larr;&amp;nbsp;날짜별&amp;nbsp;일기&amp;nbsp;목록 &lt;br /&gt;├──&amp;nbsp;write.html&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;larr;&amp;nbsp;글&amp;nbsp;작성&amp;nbsp;/&amp;nbsp;수정 &lt;br /&gt;├──&amp;nbsp;detail.html&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;larr;&amp;nbsp;상세&amp;nbsp;조회 &lt;br /&gt;├──&amp;nbsp;css/ &lt;br /&gt;│&amp;nbsp;&amp;nbsp;&amp;nbsp;└──&amp;nbsp;style.css&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;larr;&amp;nbsp;공통&amp;nbsp;스타일 &lt;br /&gt;└──&amp;nbsp;js/ &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;├──&amp;nbsp;api.js&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;larr;&amp;nbsp;fetch&amp;nbsp;공통&amp;nbsp;모듈&amp;nbsp;(API&amp;nbsp;연동&amp;nbsp;교체&amp;nbsp;포인트) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;├──&amp;nbsp;index.js &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;├──&amp;nbsp;main.js &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;├──&amp;nbsp;diary.js &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;├──&amp;nbsp;write.js &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;└──&amp;nbsp;detail.js&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;595&quot; data-origin-height=&quot;573&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dqrWzB/dJMcaiQi0ub/SftassUdReXI02Y470In6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dqrWzB/dJMcaiQi0ub/SftassUdReXI02Y470In6K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dqrWzB/dJMcaiQi0ub/SftassUdReXI02Y470In6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdqrWzB%2FdJMcaiQi0ub%2FSftassUdReXI02Y470In6K%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;402&quot; height=&quot;387&quot; data-origin-width=&quot;595&quot; data-origin-height=&quot;573&quot;/&gt;&lt;/span&gt;&lt;/figure&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;1. API 공통 모듈&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;★ API 연동 교체 포인트 ★ &lt;br /&gt;- BASE_URL: 백엔드 서버 주소 (개발 중엔 '' 로 두면 같은 origin으로 요청) &lt;br /&gt;- 모든 fetch 호출은 이 파일의 함수를 통해서만 진행 &lt;br /&gt;- JWT 토큰은 localStorage에 저장 후 헤더에 자동 첨부&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;api.js&lt;/blockquote&gt;
&lt;pre id=&quot;code_1775042762278&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const BASE_URL = 'http://localhost:8080'; // 백엔드와 같은 서버면 빈 문자열, 분리 시 'http://localhost:8080'

/* localStorage 토큰/유저 관리 */
const Auth = {
    getToken:    () =&amp;gt; localStorage.getItem('accessToken'),
    getUserId:   () =&amp;gt; Number(localStorage.getItem('userId')),
    getNickname: () =&amp;gt; localStorage.getItem('nickname'),
    save: ({ accessToken, userId, nickname }) =&amp;gt; {
        localStorage.setItem('accessToken', accessToken);
        localStorage.setItem('userId',      userId);
        localStorage.setItem('nickname',    nickname);
    },
    clear: () =&amp;gt; {
        localStorage.removeItem('accessToken');
        localStorage.removeItem('userId');
        localStorage.removeItem('nickname');
    },
    isLoggedIn: () =&amp;gt; !!localStorage.getItem('accessToken'),
};

/* 공통 fetch 래퍼 */
async function request(method, path, body = null) {
    const headers = { 'Content-Type': 'application/json' };
    const token = Auth.getToken();
    if (token) headers['Authorization'] = `Bearer ${token}`;

    const res = await fetch(BASE_URL + path, {
        method,
        headers,
        credentials: 'include', //CORS 자격증명 포함
        body: body ? JSON.stringify(body) : null,
    });

    // 인증 만료 시 로그인 화면으로
    if (res.status === 403 || res.status === 401) {
        Auth.clear();
        location.href = '/index.html';
        return;
    }

    const text = await res.text();

    if (!res.ok) { //응답 바디가 없는 경우 (204 No Content 등) 처리
        // GlobalExceptionHandler가 내려준 { message: &quot;...&quot; } 형태
        const data = text ? JSON.parse(text) : null;
        throw new Error(data?.message || '오류가 발생했습니다.');
    }
    try { //성공 응답: JSON 파싱 시도, 실패하면 텍스트 그대로 반환
        return text ? JSON.parse(text) : null;
    } catch {
        return text; // &quot;수정되었습니다.&quot; 같은 순수 문자열은 그냥 반환
    }

    return data;
}

const api = {
    post:   (path, body) =&amp;gt; request('POST',   path, body),
    get:    (path)       =&amp;gt; request('GET',    path),
    put:    (path, body) =&amp;gt; request('PUT',    path, body),
    delete: (path)       =&amp;gt; request('DELETE', path),
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;★원래는 try 부분을 작성 안 했는데, 그랬더니 이후에 글을 수정하거나 회원가입할 때 에러 문구가 발생하는 일이 있었다. 작동은 정상적으로 되는데 에러 문구가 떴다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인: api.js의&amp;nbsp;request&amp;nbsp;함수에서&amp;nbsp;응답을&amp;nbsp;무조건&amp;nbsp;JSON으로&amp;nbsp;파싱하려고&amp;nbsp;하기&amp;nbsp;때문&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 코드:&lt;/p&gt;
&lt;pre id=&quot;code_1775043083326&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const text = await res.text();
const data = text ? JSON.parse(text) : null; // &amp;larr; 여기서 터짐&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 코드에서는 백엔드 Controller가 수정/삭제/회원가입 성공 시 순수 문자열을 내려보내고 있었다.&lt;br /&gt;return&amp;nbsp;ResponseEntity.ok(&quot;수정되었습니다.&quot;);&amp;nbsp;&amp;nbsp;&amp;nbsp;//&amp;nbsp;문자열 &lt;br /&gt;return&amp;nbsp;ResponseEntity.ok(&quot;회원가입이&amp;nbsp;완료되었습니다.&quot;);&amp;nbsp;//&amp;nbsp;문자열 &lt;br /&gt;&lt;br /&gt;그런데 JSON.parse(&quot;수정되었습니다.&quot;)는 유효한 JSON이 아니라서 에러가 난다. (JSON 문자열은 &quot;큰따옴표&quot;로 감싸져야 함)&lt;br /&gt;-&amp;gt; 백엔드 응답을 JSON으로 통일&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;=&amp;gt; 백엔드 API 응답 형태를 일관되게 JSON으로 맞추는 게 RESTful 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;따라서 이전 글에서 백엔드 컨트롤러를 작성할 때 Map 형태로 JSON으로 문자열을 반환한 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1775043221717&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PutMapping(&quot;/{id}&quot;)
public ResponseEntity&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt; update(
        @PathVariable Long id,
        @AuthenticationPrincipal Long userId,
        @RequestBody DiaryUpdateRequest request) {
    diaryService.update(userId, id, request);
    return ResponseEntity.ok(Map.of(&quot;message&quot;, &quot;수정되었습니다.&quot;));
    //return ResponseEntity.ok(&quot;수정되었습니다.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문자열을 반환하는 부분이 회원가입과 수정 부분이라서 그 둘에서만 오류가 났었던 거였다. 그래서 Map으로 반환하도록 수정하고, 프론트에서도 예외처리를 추가하여 더 강하게 보완하니 정상적으로 오류 문구 없이 실행될 수 있었다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나머지 프론트 코드는 많기도 하고 일단 백엔드 공부용이니까 이건 깃허브로만...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 화면 흐름을 정리하자면 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;474&quot; data-origin-height=&quot;317&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tgbFZ/dJMcadVLoCs/xjBER70KROZpyFJYylC4yK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tgbFZ/dJMcadVLoCs/xjBER70KROZpyFJYylC4yK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tgbFZ/dJMcadVLoCs/xjBER70KROZpyFJYylC4yK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtgbFZ%2FdJMcadVLoCs%2FxjBER70KROZpyFJYylC4yK%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;414&quot; height=&quot;277&quot; data-origin-width=&quot;474&quot; data-origin-height=&quot;317&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;☆중요 포인트들&lt;br /&gt;&lt;br /&gt;api.js 한 곳에 fetch 집중:&lt;br /&gt;토큰 첨부, 403 처리, 에러 파싱을 모두 여기서 처리한다.&lt;br /&gt;나중에 BASE_URL이나 토큰 방식이 바뀌어도 이 파일 하나만 수정하면 돼서 간편하다.&lt;br /&gt;&lt;br /&gt;XSS 방지 escapeHtml():&lt;br /&gt;innerHTML로 사용자 입력값을 넣을 때는 반드시 이스케이프가 필요하다.&lt;br /&gt;detail.html의 textContent는 자동으로 이스케이프가 되지만, diary.js의 innerHTML 템플릿 리터럴 안에서는 직접 처리했다.&lt;br /&gt;&lt;br /&gt;작성/수정 화면 공유:&lt;br /&gt;write.html이 URL 파라미터로 date가 오면 작성 모드, id가 오면 수정 모드로 동작한다. (같은 화면을 두 용도로 재사용하는 패턴)&lt;br /&gt;&lt;br /&gt;white-space: pre-wrap:&lt;br /&gt;일기 내용 textarea에 줄바꿈을 입력하면 DB에 \n으로 저장되는데, 상세 화면에서 이 속성이 없으면 줄바꿈이 무시되고 한 줄로 붙어서 보이기 때문에 이 속성을 추가해야 한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. localhost 실행&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;1. 백엔드 서버 실행 (port 8080)&lt;/span&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;span&gt;&lt;span&gt;2. 브라우저에서 http&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;:&lt;/span&gt;&lt;span&gt;//localhost&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;:&lt;/span&gt;&lt;span&gt;8080/index.html 접속&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;rarr; HTML이 정상적으로 보이면 정적 리소스 허용 OK&lt;/span&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;span&gt;3. 로그인 시도&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;rarr; 브라우저 개발자도구(F12) &lt;/span&gt;&lt;span&gt;&amp;rarr; Network 탭에서&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;nbsp; &amp;nbsp; POST /api/users/login 응답이 200이고&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;&amp;nbsp; &amp;nbsp; {&lt;/span&gt;&lt;span&gt; userId&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;,&lt;/span&gt;&lt;span&gt; nickname&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;,&lt;/span&gt;&lt;span&gt; accessToken &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;}&lt;/span&gt;&lt;span&gt; 오는지 확인&lt;/span&gt;&lt;/span&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;span&gt;4. main.html로 넘어간 뒤&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;rarr; GET /api/diaries/calendar 요청 헤더에&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #cc7bf4;&quot;&gt;&amp;nbsp; &amp;nbsp; Authorization&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;:&lt;/span&gt;&lt;span&gt; Bearer eyJ&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;...&lt;/span&gt;&lt;span&gt; 가 붙어있는지 확인&lt;/span&gt;&lt;/span&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;span&gt;&lt;span&gt;위 방법대로 이제 컨트롤러까지 모두 테스트 하기!&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;우선 로그인은 정상적으로 된다. payload에서 아이디와 비밀번호가 제대로 입력돼서 전송된 걸 볼 수 있다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 181255.png&quot; data-origin-width=&quot;610&quot; data-origin-height=&quot;358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JdmPz/dJMcafTDl7B/uc011QTI0FxuX6GDqTbxG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JdmPz/dJMcafTDl7B/uc011QTI0FxuX6GDqTbxG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JdmPz/dJMcafTDl7B/uc011QTI0FxuX6GDqTbxG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJdmPz%2FdJMcafTDl7B%2Fuc011QTI0FxuX6GDqTbxG0%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;610&quot; height=&quot;358&quot; data-filename=&quot;스크린샷 2026-03-18 181255.png&quot; data-origin-width=&quot;610&quot; data-origin-height=&quot;358&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 181334.png&quot; data-origin-width=&quot;611&quot; data-origin-height=&quot;365&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ADBLa/dJMcafTDl8x/4RmaUm0pZk4IjuEk7iFSy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ADBLa/dJMcafTDl8x/4RmaUm0pZk4IjuEk7iFSy0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ADBLa/dJMcafTDl8x/4RmaUm0pZk4IjuEk7iFSy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FADBLa%2FdJMcafTDl8x%2F4RmaUm0pZk4IjuEk7iFSy0%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;611&quot; height=&quot;365&quot; data-filename=&quot;스크린샷 2026-03-18 181334.png&quot; data-origin-width=&quot;611&quot; data-origin-height=&quot;365&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 메인화면에서 캘린더가 띄워질 때, Authorization:&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;Bearer eyJ&lt;/span&gt;...&lt;span style=&quot;text-align: start;&quot;&gt; 가 붙어있는 것도 확인할 수 있다!&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 181507.png&quot; data-origin-width=&quot;629&quot; data-origin-height=&quot;431&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/J13Do/dJMcaaktMLo/GGYI3spCcAnBnsthIosql1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/J13Do/dJMcaaktMLo/GGYI3spCcAnBnsthIosql1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/J13Do/dJMcaaktMLo/GGYI3spCcAnBnsthIosql1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJ13Do%2FdJMcaaktMLo%2FGGYI3spCcAnBnsthIosql1%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;629&quot; height=&quot;431&quot; data-filename=&quot;스크린샷 2026-03-18 181507.png&quot; data-origin-width=&quot;629&quot; data-origin-height=&quot;431&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;622&quot; data-origin-height=&quot;185&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGKDGg/dJMcac3DKfg/NAUZOriI4BXxKJJg1Y3Jsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGKDGg/dJMcac3DKfg/NAUZOriI4BXxKJJg1Y3Jsk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGKDGg/dJMcac3DKfg/NAUZOriI4BXxKJJg1Y3Jsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGKDGg%2FdJMcac3DKfg%2FNAUZOriI4BXxKJJg1Y3Jsk%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;622&quot; height=&quot;185&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;622&quot; data-origin-height=&quot;185&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캘린더에서 각 날짜에 들어가 볼 때, 주소로 잘 매핑되는 걸 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 181636.png&quot; data-origin-width=&quot;1143&quot; data-origin-height=&quot;742&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rLy4G/dJMcaaLysiX/OE9y1i2c34a7OtGbzxMIUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rLy4G/dJMcaaLysiX/OE9y1i2c34a7OtGbzxMIUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rLy4G/dJMcaaLysiX/OE9y1i2c34a7OtGbzxMIUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrLy4G%2FdJMcaaLysiX%2FOE9y1i2c34a7OtGbzxMIUk%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;715&quot; height=&quot;464&quot; data-filename=&quot;스크린샷 2026-03-18 181636.png&quot; data-origin-width=&quot;1143&quot; data-origin-height=&quot;742&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 181750.png&quot; data-origin-width=&quot;1153&quot; data-origin-height=&quot;722&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UjrxU/dJMcadVLo03/Nu8ooBPKmXpjSUhNNrARs0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UjrxU/dJMcadVLo03/Nu8ooBPKmXpjSUhNNrARs0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UjrxU/dJMcadVLo03/Nu8ooBPKmXpjSUhNNrARs0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUjrxU%2FdJMcadVLo03%2FNu8ooBPKmXpjSUhNNrARs0%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;713&quot; height=&quot;446&quot; data-filename=&quot;스크린샷 2026-03-18 181750.png&quot; data-origin-width=&quot;1153&quot; data-origin-height=&quot;722&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 181806.png&quot; data-origin-width=&quot;1162&quot; data-origin-height=&quot;719&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cRQYNl/dJMb99MBSAv/2wtm3TgsRKSAKImiAVqEk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cRQYNl/dJMb99MBSAv/2wtm3TgsRKSAKImiAVqEk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cRQYNl/dJMb99MBSAv/2wtm3TgsRKSAKImiAVqEk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcRQYNl%2FdJMb99MBSAv%2F2wtm3TgsRKSAKImiAVqEk1%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;713&quot; height=&quot;441&quot; data-filename=&quot;스크린샷 2026-03-18 181806.png&quot; data-origin-width=&quot;1162&quot; data-origin-height=&quot;719&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Payload에 모든 내용이 제대로 실려서 POST되어 저장됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러고 메인화면으로 돌아가면 월별로 무슨 날짜에 일기가 저장되었는지 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 18일에 일기 2개를 작성했는데, 앞서 중복은 제외되도록 DISTINCT를 설정했으므로 여기서 18, 17각각 하나씩만 뜨는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 182111.png&quot; data-origin-width=&quot;1146&quot; data-origin-height=&quot;730&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sp3f5/dJMcabRdaDr/HgpMDiD08S614oUjFfRkRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sp3f5/dJMcabRdaDr/HgpMDiD08S614oUjFfRkRk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sp3f5/dJMcabRdaDr/HgpMDiD08S614oUjFfRkRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fsp3f5%2FdJMcabRdaDr%2FHgpMDiD08S614oUjFfRkRk%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;676&quot; height=&quot;431&quot; data-filename=&quot;스크린샷 2026-03-18 182111.png&quot; data-origin-width=&quot;1146&quot; data-origin-height=&quot;730&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일기를 삭제한 경우엔 정상적으로 캘린더에서도 색이 지워진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 182151.png&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;732&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dc4p74/dJMcacvQ0wc/Kb3msKFR65U5ks3bveNqSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dc4p74/dJMcacvQ0wc/Kb3msKFR65U5ks3bveNqSk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dc4p74/dJMcacvQ0wc/Kb3msKFR65U5ks3bveNqSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdc4p74%2FdJMcacvQ0wc%2FKb3msKFR65U5ks3bveNqSk%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;676&quot; height=&quot;427&quot; data-filename=&quot;스크린샷 2026-03-18 182151.png&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;732&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 초기에 계획했던 목표를 거의 다 이루어냈다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security+JWT, 테스트 코드 작성까지 했으니까 ㅎ.ㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 이제 마지막 하나......&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 AWS 배포하기!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 다음 글로~~&lt;/p&gt;</description>
      <category> Project/myDiary - 일기 기록 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/81</guid>
      <comments>https://rim08.tistory.com/81#entry81comment</comments>
      <pubDate>Wed, 1 Apr 2026 20:57:16 +0900</pubDate>
    </item>
    <item>
      <title>[토이프로젝트 개발일지 04] 일기 기록 웹 (Test code / 예외처리통합)</title>
      <link>https://rim08.tistory.com/80</link>
      <description>&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;단위&amp;nbsp;테스트&amp;nbsp;(Unit&amp;nbsp;Test) &lt;br /&gt;&amp;nbsp;&amp;nbsp;└──&amp;nbsp;Service&amp;nbsp;테스트 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;외부&amp;nbsp;의존성(DB,&amp;nbsp;네트워크)을&amp;nbsp;Mockito로&amp;nbsp;가짜&amp;nbsp;객체로&amp;nbsp;대체 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;비즈니스&amp;nbsp;로직만&amp;nbsp;빠르게&amp;nbsp;검증 &lt;br /&gt;&lt;br /&gt;슬라이스&amp;nbsp;테스트&amp;nbsp;(Slice&amp;nbsp;Test)&amp;nbsp;&amp;nbsp; &lt;br /&gt;&amp;nbsp;&amp;nbsp;└──&amp;nbsp;Controller&amp;nbsp;테스트&amp;nbsp;(@WebMvcTest) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;실제&amp;nbsp;HTTP&amp;nbsp;요청/응답&amp;nbsp;형태로&amp;nbsp;검증 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;Service는&amp;nbsp;Mock으로&amp;nbsp;대체 &lt;br /&gt;&lt;br /&gt;통합&amp;nbsp;테스트&amp;nbsp;(Integration&amp;nbsp;Test) &lt;br /&gt;&amp;nbsp;&amp;nbsp;└──&amp;nbsp;Repository&amp;nbsp;테스트&amp;nbsp;(@DataJpaTest) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;실제&amp;nbsp;DB(인메모리&amp;nbsp;H2)로&amp;nbsp;JPA&amp;nbsp;쿼리&amp;nbsp;검증&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본 프로젝트에서 실제 DB는 MariaDB를 사용하지만, 테스트 때는 간단하고 빠르게 적용하기 위해서 H2 인메모리 DB를 사용한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;build.gradle&lt;/blockquote&gt;
&lt;pre id=&quot;code_1775037498921&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
// H2 인메모리 DB - Repository 테스트용
testRuntimeOnly 'com.h2database:h2'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 의존성이 추가되어야 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드 작성 시에는 기존 java 파일 구조와 똑같이 패키지 구조를 잡아야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래야 자동으로 테스트 코드임을 인식해서 실행시켜주기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;service, controller, repository를 테스트할 것이기 때문에 이에 대해서 기존 파일과 똑같이 패키지 구조를 설계하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-19 152918.png&quot; data-origin-width=&quot;339&quot; data-origin-height=&quot;416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bf3got/dJMcad2wgr5/epBK988bMg84mufjhKOByK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bf3got/dJMcad2wgr5/epBK988bMg84mufjhKOByK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bf3got/dJMcad2wgr5/epBK988bMg84mufjhKOByK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbf3got%2FdJMcad2wgr5%2FepBK988bMg84mufjhKOByK%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;339&quot; height=&quot;416&quot; data-filename=&quot;스크린샷 2026-03-19 152918.png&quot; data-origin-width=&quot;339&quot; data-origin-height=&quot;416&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;최종 테스트 패키지 구조&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;src/test/java/com/example/diary&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;├── service &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;│ &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;├── &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;UserServiceTest&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;.&lt;/span&gt;&lt;span&gt;java &amp;larr; 단위 테스트 &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;Mockito&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;│ └── &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;DiaryServiceTest&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;.&lt;/span&gt;&lt;span&gt;java &amp;larr; 단위 테스트 &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;Mockito&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;├── repository&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;│ &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;└── &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;DiaryRepositoryTest&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;.&lt;/span&gt;&lt;span&gt;java &amp;larr; 슬라이스 테스트 &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;@DataJpaTest&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;+&lt;/span&gt;&lt;span&gt; H2&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;└── controller&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp;└── &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;DiaryControllerTest&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;.&lt;/span&gt;&lt;span&gt;java &amp;larr; 슬라이스 테스트 &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;@WebMvcTest&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Service 단위 테스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) UserService 단위 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@ExtendWith(MockitoExtension.class) &lt;br /&gt;&amp;rarr; JUnit5 + Mockito 연동. Spring Context를 띄우지 않아서 빠름. &lt;br /&gt;@Mock&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;: 가짜 객체 생성 (실제 동작 없음, 기본 반환값 null/false/0) &lt;br /&gt;@InjectMocks: @Mock으로 만든 객체들을 주입받는 테스트 대상&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;UserServiceTest.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1775037860611&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.service;

import ...
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.*; //given, then

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @Mock
    private JwtTokenProvider jwtTokenProvider;

    @InjectMocks //UsersService 생성자에 위 @Mock들을 자동 주입
    private UserService userService;

    ...
    
    /* 헬퍼 메서드 */
    private UserJoinRequest createJoinRequest(String loginId, String password, String nickname) {
        UserJoinRequest request = new UserJoinRequest();
        //DTO 필드가 private라서 리플렉션 사용
        setField(request, &quot;loginId&quot;, loginId);
        setField(request, &quot;password&quot;, password);
        setField(request, &quot;nickname&quot;, nickname);
        return request;
    }

    private UserLoginRequest createLoginRequest(String loginId, String password) {
        UserLoginRequest request = new UserLoginRequest();
        setField(request, &quot;loginId&quot;, loginId);
        setField(request, &quot;password&quot;, password);
        return request;
    }

    //리플렉션으로 private 필드 값 강제 세팅
    //테스트에서 DTO/엔티티의 private 필드를 직접 설정할 때 사용 (실제 코드에선 절대 사용x)
    private void setField(Object target, String fieldName, Object value) {
        try {
            var field =  target.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(target, value);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    private void setUserId(User user, Long id) {
        setField(user, &quot;id&quot;, id);
    }
}&lt;/code&gt;&lt;/pre&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;또한 헬퍼 메서드로 실제 사용자의 리퀘스트 대신에 테스트 리퀘스트를 만들어 준다. 이때 private값은 원래 외부에서 마음대로 바꿀 수 없는데, 여기서는 리플렉션으로 필드 값을 강제 세팅하도록 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 여기서는 회원 가입/로그인을 테스트할 것이기 때문에 그에 맞는 request를 생성하는 메서드를 작성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력이 들어오면 DTO를 생성해서 값을 set하고 반환하는 식이다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(1) 회원가입 테스트&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 정상 작동하는 테스트 코드를 작성하고, 그 다음으로 실패하는 케이스들을 작성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드를 작성할 땐 given-when-then 구조로 작성하면 좋은데, given으로 조건을 세팅한 후 when으로 실행, then으로 검증하는 식이다.&lt;/p&gt;
&lt;pre id=&quot;code_1775038208066&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;회원가입 성공&quot;)
void join_success() {
    //given
    UserJoinRequest request = createJoinRequest(&quot;hong&quot;, &quot;1234&quot;, &quot;홍길동&quot;);
    given(userRepository.existsByLoginId(&quot;hong&quot;)).willReturn(false); // 중복 없음
    given(passwordEncoder.encode(&quot;1234&quot;)).willReturn(&quot;encoded1234&quot;);
    given(userRepository.save(any(User.class))).willReturn(any());

    //when (실행)
    assertThatCode(() -&amp;gt; userService.join(request))
            .doesNotThrowAnyException(); // 예외 없이 통과하면 성공

    //then (검증)
    //save()가 정확히 1번 호출됐는지 확인
    then(userRepository).should(times(1)).save(any(User.class));
}

@Test
@DisplayName(&quot;회원가입 실패 - 아이디 중복&quot;)
void join_fail_duplicateLoginId() {
    //given
    UserJoinRequest request = createJoinRequest(&quot;hong&quot;, &quot;1234&quot;, &quot;홍길동&quot;);
    //existsByLoginId가 true를 반환하도록 설정 (이미 존재하는 아이디)
    given(userRepository.existsByLoginId(&quot;hong&quot;)).willReturn(true);

    //when &amp;amp; then
    //join() 호출 시 IllegalArgumentException이 던져지는지 확인
    assertThatThrownBy(() -&amp;gt; userService.join(request))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(&quot;이미 사용 중인 아이디입니다.&quot;);
}

@Test
@DisplayName(&quot;회원가입 실패 - 비밀번호 4자리 미만&quot;)
void join_fail_shortPassword() {
    //given
    UserJoinRequest request = createJoinRequest(&quot;hong&quot;, &quot;123&quot;, &quot;홍길동&quot;); // 3자리
    given(userRepository.existsByLoginId(&quot;hong&quot;)).willReturn(false);

    //when &amp;amp; then
    assertThatThrownBy(() -&amp;gt; userService.join(request))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(&quot;비밀번호는 4자리 이상이어야 합니다.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 실수로 문자열을 다르게 해서 오류가 났었는데, 오히려 그 덕분에 잘 작동된다는 걸 확인할 수 있었다. 기존 Service에서 설정한 문구와 동일하게 작성해야 오류 없이 정상 검증된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 145226.png&quot; data-origin-width=&quot;1153&quot; data-origin-height=&quot;838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZRnZJ/dJMcaiCNdEz/G4YViPolChbfB23VKKNcsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZRnZJ/dJMcaiCNdEz/G4YViPolChbfB23VKKNcsk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZRnZJ/dJMcaiCNdEz/G4YViPolChbfB23VKKNcsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZRnZJ%2FdJMcaiCNdEz%2FG4YViPolChbfB23VKKNcsk%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;765&quot; height=&quot;556&quot; data-filename=&quot;스크린샷 2026-03-18 145226.png&quot; data-origin-width=&quot;1153&quot; data-origin-height=&quot;838&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(2) 로그인 테스트&lt;/h4&gt;
&lt;pre id=&quot;code_1775038913631&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;로그인 성공 - 토큰 반환&quot;)
void login_success() {
    //given
    UserLoginRequest request = createLoginRequest(&quot;hong&quot;, &quot;1234&quot;);
    User fakeUser = User.create(&quot;hong&quot;, &quot;encoded1234&quot;, &quot;홍길동&quot;);
    setUserId(fakeUser, 1L); //리플렉션으로 id 세팅

    given(userRepository.findByLoginId(&quot;hong&quot;)).willReturn(Optional.of(fakeUser));
    given(passwordEncoder.matches(&quot;1234&quot;, &quot;encoded1234&quot;)).willReturn(true);
    given(jwtTokenProvider.createToken(1L)).willReturn(&quot;fake.jwt.token&quot;);

    //when
    UserLoginResponse response = userService.login(request);

    //then
    assertThat(response.getNickname()).isEqualTo(&quot;홍길동&quot;);
    assertThat(response.getAccessToken()).isEqualTo(&quot;fake.jwt.token&quot;);
}

@Test
@DisplayName(&quot;로그인 실패 - 존재하지 않는 아이디&quot;)
void login_fail_userNotFound() {
    // given
    UserLoginRequest request = createLoginRequest(&quot;nobody&quot;, &quot;1234&quot;);
    given(userRepository.findByLoginId(&quot;nobody&quot;)).willReturn(Optional.empty());

    // when &amp;amp; then
    assertThatThrownBy(() -&amp;gt; userService.login(request))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(&quot;아이디 또는 비밀번호가 틀렸습니다.&quot;);
}

@Test
@DisplayName(&quot;로그인 실패 - 비밀번호 불일치&quot;)
void login_fail_wrongPassword() {
    // given
    UserLoginRequest request = createLoginRequest(&quot;hong&quot;, &quot;wrong&quot;);
    User fakeUser = User.create(&quot;hong&quot;, &quot;encoded1234&quot;, &quot;홍길동&quot;);

    given(userRepository.findByLoginId(&quot;hong&quot;)).willReturn(Optional.of(fakeUser));
    given(passwordEncoder.matches(&quot;wrong&quot;, &quot;encoded1234&quot;)).willReturn(false);

    // when &amp;amp; then
    assertThatThrownBy(() -&amp;gt; userService.login(request))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(&quot;아이디 또는 비밀번호가 틀렸습니다.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인까지 작성한 후, UserServiceTest를 한 번에 실행하면 다음과 같이 정상 작동되는 걸 확인할 수 있다. == 검증 통과, 실제 서비스 로직에서 제대로 구현했다는 뜻&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;392&quot; data-origin-height=&quot;326&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GehSH/dJMcajn6Uz7/qCT1loHlwvFTDZXHKUNLL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GehSH/dJMcajn6Uz7/qCT1loHlwvFTDZXHKUNLL0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GehSH/dJMcajn6Uz7/qCT1loHlwvFTDZXHKUNLL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGehSH%2FdJMcajn6Uz7%2FqCT1loHlwvFTDZXHKUNLL0%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;392&quot; height=&quot;326&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;392&quot; data-origin-height=&quot;326&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2) DiaryService 단위 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서는 @BeforeEach를 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 @Test 메서드 실행 전 매번 먼저 호출되도록 하는 건데, 테스트 간 상태가 공유되지 않도록 픽스처를 새로 만들어야 해서 사용하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 테스트 전체에서 공통으로 쓸 픽스처로 fakeUser, fakeDiary 객체를 만들어 준다.&lt;/p&gt;
&lt;pre id=&quot;code_1775039108160&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.service;
import ...

@ExtendWith(MockitoExtension.class)
class DiaryServiceTest {

    @Mock
    private DiaryRepository diaryRepository;

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private DiaryService diaryService;

    //테스트 전체에서 공통으로 쓸 픽스처
    private User fakeUser;
    private Diary fakeDiary;

    /**
        @BeforeEach: 각 @Test 메서드 실행 전 매번 호출됨
        -&amp;gt; 테스트 간 상태가 공유되지 않도록 픽스처를 새로 만들어야 함
     */
    @BeforeEach
    void setUp() {
        fakeUser = User.create(&quot;hong&quot;, &quot;encoded1234&quot;, &quot;홍길동&quot;);
        setField(fakeUser, &quot;id&quot;, 1L);

        fakeDiary = Diary.create(&quot;테스트 제목&quot;, &quot;테스트 내용&quot;, LocalDate.of(2026, 3, 18), fakeUser);
        setField(fakeDiary, &quot;id&quot;, 10L);
    }
  ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(1) 일기 작성 테스트&lt;/h4&gt;
&lt;pre id=&quot;code_1775039262757&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;일기 작성 성공 - 저장된 일기 id 반환&quot;)
void create_success() {
    //given
    DiaryCreateRequest request = createDiaryRequest(&quot;제목&quot;, &quot;내용&quot;, LocalDate.of(2026, 3, 18));
    given(userRepository.findById(1L)).willReturn(Optional.of(fakeUser));
    given(diaryRepository.save(any(Diary.class))).willReturn(fakeDiary);

    //when
    Long savedId = diaryService.create(1L, request);

    //then
    assertThat(savedId).isEqualTo(10L);
    then(diaryRepository).should(times(1)).save(any(Diary.class));
}

@Test
@DisplayName(&quot;일기 작성 실패 - 존재하지 않는 사용자&quot;)
void create_fail_userNotFound() {
    //given
    DiaryCreateRequest request = createDiaryRequest(&quot;제목&quot;, &quot;내용&quot;, LocalDate.of(2026, 3, 18));
    given(userRepository.findById(999L)).willReturn(Optional.empty());

    //when &amp;amp; then
    assertThatThrownBy(() -&amp;gt; diaryService.create(999L, request))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(&quot;존재하지 않는 사용자입니다.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에는&amp;nbsp; fakeUser로 기존에 설정했던 사용자를 저장해서 정상 검증되고, 아래는 사용자 저장 없이 아이디만 999L로 찾으려고 했기 때문에 존재하지 않는 사용자라고 정상 검증된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(2) 날짜별 목록 조회 테스트&lt;/h4&gt;
&lt;pre id=&quot;code_1775039560285&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;날짜별 일기 목록 조회 성공&quot;)
void getDiariesByDate_success() {
    //given
    LocalDate date = LocalDate.of(2026, 3, 18);
    Diary diary2 = Diary.create(&quot;두번째&quot;, &quot;내용2&quot;, date, fakeUser);
    setField(diary2, &quot;id&quot;, 11L);
    given(diaryRepository.findByUserIdAndDiaryDateOrderByCreatedAtDescIdDesc(1L, date))
            .willReturn(List.of(fakeDiary, diary2));

    //when
    List&amp;lt;DiaryListResponse&amp;gt; result = diaryService.getDiariesByDate(1L, date);

    //then
    assertThat(result).hasSize(2);
    assertThat(result.get(0).getTitle()).isEqualTo(&quot;테스트 제목&quot;);
    assertThat(result.get(1).getTitle()).isEqualTo(&quot;두번째&quot;);
}

@Test
@DisplayName(&quot;날짜별 일기 목록 조회 - 해당 날짜에 일기 없으면 빈 리스트&quot;)
void getDiariesByDate_empty() {
    //given
    LocalDate date = LocalDate.of(2026, 1, 1);
    given(diaryRepository.findByUserIdAndDiaryDateOrderByCreatedAtDescIdDesc(1L, date))
            .willReturn(List.of());

    //when
    List&amp;lt;DiaryListResponse&amp;gt; result = diaryService.getDiariesByDate(1L, date);

    //then
    assertThat(result).isEmpty();
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(3) 상세 조회 테스트&lt;/h4&gt;
&lt;pre id=&quot;code_1775039883387&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;일기 상세 조회 성공&quot;)
void getDiary_success() {
    //given
    given(diaryRepository.findById(10L)).willReturn(Optional.of(fakeDiary));

    //when
    DiaryDetailResponse result = diaryService.getDiary(1L, 10L);

    //then
    assertThat(result.getTitle()).isEqualTo(&quot;테스트 제목&quot;);
    assertThat(result.getContent()).isEqualTo(&quot;테스트 내용&quot;);
}

@Test
@DisplayName(&quot;일기 상세 조회 실패 - 다른 사용자의 일기 접근&quot;)
void getDiary_fail_notOwner() {
    //given
    given(diaryRepository.findById(10L)).willReturn(Optional.of(fakeDiary));

    //when &amp;amp; then
    //fakeDiary의 userId는 1L인데, 2L로 접근 시도
    assertThatThrownBy(() -&amp;gt; diaryService.getDiary(2L, 10L))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(&quot;본인의 글만 접근할 수 있습니다.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(4) 수정 및 삭제&lt;/h4&gt;
&lt;pre id=&quot;code_1775039922889&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/* 수정 */
@Test
@DisplayName(&quot;일기 수정 성공 - Dirty Checking으로 save() 호출 없이 수정&quot;)
void update_success() {
    //given
    DiaryUpdateRequest request = new DiaryUpdateRequest();
    setField(request, &quot;title&quot;, &quot;수정된 제목&quot;);
    setField(request, &quot;content&quot;, &quot;수정된 내용&quot;);

    given(diaryRepository.findById(10L)).willReturn(Optional.of(fakeDiary));

    //when
    diaryService.update(1L, 10L, request);

    //then: 엔티티의 값이 실제로 변경됐는지 확인
    assertThat(fakeDiary.getTitle()).isEqualTo(&quot;수정된 제목&quot;);
    assertThat(fakeDiary.getContent()).isEqualTo(&quot;수정된 내용&quot;);

    //Dirty Checking 방식이므로 save()는 호출되면 안 됨
    then(diaryRepository).should(never()).save(any());
}

/* 삭제 */
@Test
@DisplayName(&quot;일기 삭제 성공&quot;)
void delete_success() {
    //given
    given(diaryRepository.findById(10L)).willReturn(Optional.of(fakeDiary));

    //when
    diaryService.delete(1L, 10L);

    //then
    then(diaryRepository).should(times(1)).delete(fakeDiary);
}

@Test
@DisplayName(&quot;일기 삭제 실패 - 존재하지 않는 일기&quot;)
void delete_fail_diaryNotFound() {
    //given
    given(diaryRepository.findById(999L)).willReturn(Optional.empty());

    //when &amp;amp; then
    assertThatThrownBy(() -&amp;gt; diaryService.delete(1L, 999L))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(&quot;존재하지 않는 글입니다.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 작성한 후, 모두 테스트를 돌려보면 검증이 완료된 걸 확인할 수 있다. == 실제 service 코드 전부 검증 완료!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;542&quot; data-origin-height=&quot;385&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/s3tzF/dJMcagLMDCB/icgCy9z0uFiRKqE9wWgFok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/s3tzF/dJMcagLMDCB/icgCy9z0uFiRKqE9wWgFok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/s3tzF/dJMcagLMDCB/icgCy9z0uFiRKqE9wWgFok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fs3tzF%2FdJMcagLMDCB%2FicgCy9z0uFiRKqE9wWgFok%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;542&quot; height=&quot;385&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;542&quot; data-origin-height=&quot;385&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2. Repository 슬라이스 테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@DataJpaTest &lt;br /&gt;&amp;nbsp;-JPA 관련 Bean만 로딩 (전체 Context X) &amp;rarr; 빠름 &lt;br /&gt;&amp;nbsp;-H2 인메모리 DB 자동 사용 &lt;br /&gt;&amp;nbsp;-각 테스트마다 @Transactional 적용 &amp;rarr; 테스트 후 자동 롤백 &lt;br /&gt;&lt;br /&gt;&amp;nbsp;TestEntityManager &lt;br /&gt;&amp;nbsp;-테스트용 EntityManager &lt;br /&gt;&amp;nbsp;-persist()로 직접 데이터 저장 (Repository 거치지 않고 픽스처 세팅할 때 유용)&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;DiaryRepositoryTest.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1775040312182&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.repository;
import ...
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;

@DataJpaTest
class DiaryRepositoryTest {

    @Autowired
    private TestEntityManager em;

    @Autowired
    private DiaryRepository diaryRepository;

    @Autowired
    private UserRepository userRepository;

    private User user;

    @BeforeEach
    void setUp() {
        //테스트용 유저 저장
        user = User.create(&quot;hong&quot;, &quot;encoded&quot;, &quot;홍길동&quot;);
        em.persist(user);
    }

    @Test
    @DisplayName(&quot;날짜별 일기 목록 조회 - 최신순 정렬 확인&quot;)
    void findByUserIdAndDiaryDate_orderedByCreatedAtDesc() {
        //given
        LocalDate date = LocalDate.of(2026, 3, 18);
        Diary diary1 = Diary.create(&quot;첫번째 일기&quot;, &quot;내용1&quot;, date, user);
        Diary diary2 = Diary.create(&quot;두번째 일기&quot;, &quot;내용2&quot;, date, user); //테스트 실패해서 repository 조건에 id 정렬 추가
        em.persist(diary1);
        em.persist(diary2);
        em.flush(); //영속성 컨텍스트 &amp;rarr; DB 반영

        //when
        List&amp;lt;Diary&amp;gt; result = diaryRepository
                .findByUserIdAndDiaryDateOrderByCreatedAtDescIdDesc(user.getId(), date);

        //then
        assertThat(result).hasSize(2);
        //나중에 저장된 diary2가 최신순으로 앞에 와야 함
        assertThat(result.get(0).getTitle()).isEqualTo(&quot;두번째 일기&quot;);
        assertThat(result.get(1).getTitle()).isEqualTo(&quot;첫번째 일기&quot;);
    }

   ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;★이 테스트에서 오류가 발생했었다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Diary&amp;nbsp;diary1&amp;nbsp;=&amp;nbsp;Diary.create(&quot;첫번째&amp;nbsp;일기&quot;,&amp;nbsp;&quot;내용1&quot;,&amp;nbsp;date,&amp;nbsp;user);&lt;br /&gt;Diary&amp;nbsp;diary2&amp;nbsp;=&amp;nbsp;Diary.create(&quot;두번째&amp;nbsp;일기&quot;,&amp;nbsp;&quot;내용2&quot;,&amp;nbsp;date,&amp;nbsp;user);&amp;nbsp;&lt;br /&gt;이걸 거의 동시 생성으로 봐서, 테스트 결과 두번째가 최신으로 안 올라오고 그냥 그대로 정렬되는 문제가 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이를 해결하기 위해 실제 repository에서 조건을 추가하였다. 생성 시간이 비슷할 시 Id로도 정렬할 수 있도록 조건을 추가했다. 이로써 조건을 명확히 설정해야 여러 테스트에서 모두 검증이 완료될 수 있다는 점을 깨달을 수 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1775040324627&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;//원래는 CreatedAtDesc만 있었는데 IdDesc도 추가
List&amp;lt;Diary&amp;gt; findByUserIdAndDiaryDateOrderByCreatedAtDescIdDesc(Long userId, LocalDate diaryDate);&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;-&amp;gt;&amp;nbsp;테스트&amp;nbsp;성공.&amp;nbsp;두번째&amp;nbsp;작성한&amp;nbsp;그(최신&amp;nbsp;글)이&amp;nbsp;1번째로&amp;nbsp;정렬됨.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style2&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;pre id=&quot;code_1775040427771&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;다른 날짜의 일기는 조회되지 않음&quot;)
void findByUserIdAndDiaryDate_differentDate_notIncluded() {
    // given
    Diary diary1 = Diary.create(&quot;오늘 일기&quot;, &quot;내용&quot;, LocalDate.of(2026, 3, 17), user);
    Diary diary2 = Diary.create(&quot;어제 일기&quot;, &quot;내용&quot;, LocalDate.of(2026, 3, 16), user);
    em.persist(diary1);
    em.persist(diary2);
    em.flush();

    //when
    List&amp;lt;Diary&amp;gt; result = diaryRepository
            .findByUserIdAndDiaryDateOrderByCreatedAtDescIdDesc(user.getId(), LocalDate.of(2026, 3, 17));

    //then
    assertThat(result).hasSize(1);
    assertThat(result.get(0).getTitle()).isEqualTo(&quot;오늘 일기&quot;);
}

@Test
@DisplayName(&quot;다른 유저의 일기는 조회되지 않음&quot;)
void findByUserIdAndDiaryDate_otherUser_notIncluded() {
    //given
    User otherUser = User.create(&quot;other&quot;, &quot;encoded&quot;, &quot;다른사람&quot;);
    em.persist(otherUser);

    LocalDate date = LocalDate.of(2026, 3, 17);
    em.persist(Diary.create(&quot;내 일기&quot;, &quot;내용&quot;, date, user));
    em.persist(Diary.create(&quot;남의 일기&quot;, &quot;내용&quot;, date, otherUser));
    em.flush();

    //when
    List&amp;lt;Diary&amp;gt; result = diaryRepository
            .findByUserIdAndDiaryDateOrderByCreatedAtDescIdDesc(user.getId(), date);

    //then
    assertThat(result).hasSize(1);
    assertThat(result.get(0).getTitle()).isEqualTo(&quot;내 일기&quot;);
}

@Test
@DisplayName(&quot;캘린더 월별 일기 날짜 조회 - 중복 없이 반환&quot;)
void findDiaryDatesByUserAndYearMonth() {
    //given: 같은 날짜에 일기 2개, 다른 날짜에 1개
    LocalDate date1 = LocalDate.of(2026, 3, 17);
    LocalDate date2 = LocalDate.of(2026, 3, 20);
    em.persist(Diary.create(&quot;일기1&quot;, &quot;내용&quot;, date1, user));
    em.persist(Diary.create(&quot;일기2&quot;, &quot;내용&quot;, date1, user)); // 같은 날 2개
    em.persist(Diary.create(&quot;일기3&quot;, &quot;내용&quot;, date2, user));
    em.flush();

    //when
    List&amp;lt;LocalDate&amp;gt; result = diaryRepository
            .findDiaryDatesByUserAndYearMonth(user.getId(), 2026, 3);

    //then
    //DISTINCT 쿼리이므로 date1은 1번만 포함되어야 함
    assertThat(result).hasSize(2);
    assertThat(result).containsExactlyInAnyOrder(date1, date2);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 화면에서 캘린더를 띄울 때 각 날짜에 일기가 1개 이상이라도 있으면 날짜를 초록색으로 칠할 것이므로, 월별 일기 날짜를 조회할 때는 중복 없이 반환하는 게 성능 면에서 좋다. 그래서 DISTINCT 쿼리를 적용해서, 같은 날짜에 일기가 2개 있어도 date는 1번만 포함되도록 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 하면 repository 테스트도 모두 완료~.~&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;403&quot; data-origin-height=&quot;262&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/7QJP6/dJMcab4KEFu/HkilGrDh7nuw46zsYoE5N1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/7QJP6/dJMcab4KEFu/HkilGrDh7nuw46zsYoE5N1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/7QJP6/dJMcab4KEFu/HkilGrDh7nuw46zsYoE5N1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F7QJP6%2FdJMcab4KEFu%2FHkilGrDh7nuw46zsYoE5N1%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;403&quot; height=&quot;262&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;403&quot; data-origin-height=&quot;262&quot;/&gt;&lt;/span&gt;&lt;/figure&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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;3. Controller 슬라이스 테스트&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;blockquote data-ke-style=&quot;style3&quot;&gt;Caused by: java.lang.IllegalArgumentException: JPA metamodel must not be empty &lt;br /&gt;...(뭔가 아무튼 엄청 많은 내용)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론: @WebMvcTest인데 JPA(Auditing)가 같이 뜨면서 충돌남.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜냐하면 JPA 엔티티를 로딩해야 하는데, @WebMvcTest는 Controller만 로딩하기 때문에 JPA 메타데이터가 없어서 터진 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Application.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1775041205944&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@SpringBootApplication
@EnableJpaAuditing //@CreatedDate, @LastModifiedDate 동작을 위해 추가
public class Application {

	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일기에서 시간을 자동으로 세팅하기 위해 어노테이션을 사용했는데, 그걸 사용하려면 위 파일에서 @EnableJpaAuditing를 추가해야 했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이것 때문에 테스트에서는 오류가 난 것이다. (JPA가 필요한 어노테이션임)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결방법은 2가지가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. Auditing 끄기: test에서 비활성화후 TestConfig 따로 만들기&lt;/p&gt;
&lt;pre id=&quot;code_1775041369340&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//테스트에서 비활성화
@WebMvcTest(DiaryController.class)
@Import(TestConfig.class)
class DiaryControllerTest {
}

//TestConfig 작성
@TestConfiguration
public class TestConfig {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Auditing 분리 (제일 깔끔한 방법)&lt;/p&gt;
&lt;pre id=&quot;code_1775041466511&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//기존
@SpringBootApplication
@EnableJpaAuditing
public class Application {
}

//수정 - AuditingConfig로 분리시킴
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}

//그리고 테스트에서 제외
@WebMvcTest(DiaryController.class)
@ImportAutoConfiguration(exclude = JpaAuditingConfig.class)
class DiaryControllerTest {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 내가 선택한 방법은......&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 컨트롤러는 테스트 코드 작성 방법만 익혀보고, 실제로 페이지까지 만들어서 로컬로 배포해서 직접 F12 개발자 모드로 테스트하는 것이다(!)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 원래 컨트롤러 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;일단 이 내용은 다음 글에 작성하고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 예외처리통합부터!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 예외 처리 통합&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이건 해야할 필요성을 잘 몰랐었는데, 이번 기회에 확실히 알게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외 처리 통합이 필수는 아니지만, 현재 코드 상태에서 IllegalArgumentException이 터진다면 Spring에서 기본으로 500 에러를 내려보낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 프론트엔드에서 받았을 때 다음과 같이 나타난다.&lt;/p&gt;
&lt;pre id=&quot;code_1775041910670&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;timestamp&quot;: &quot;2026-03-18T...&quot;,
  &quot;status&quot;: 500,
  &quot;error&quot;: &quot;Internal Server Error&quot;,
  &quot;path&quot;: &quot;/api/diaries/10&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러면 무슨 오류인지 제대로 구분하기 힘들다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 간단한 어노테이션 하나만 사용한다면 무슨 오류인지 정확히 구분할 수 있다. 그 코드는 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;exception/GlobalExceptionHandler.java&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;전역&amp;nbsp;예외&amp;nbsp;처리기 &lt;br /&gt;&amp;nbsp;-&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;@RestControllerAdvice&lt;/b&gt;&lt;/span&gt;: 모든 Controller에서 발생하는 예외를 한 곳에서 처리 &lt;br /&gt;&amp;nbsp;-예외 종류에 따라 적절한 HTTP 상태 코드 + 메시지 반환 &lt;br /&gt;&amp;nbsp;-예외 처리 통합 안 하면 IllegalArgumentException가 터졌을 때 스프링이 기본적으로 500 에러 내려보냄. 프론트에서 500으로만 와서 어떤 오류인지 판별 어려움. &lt;br /&gt;&amp;nbsp;-&amp;gt; @RestControllerAdvice 붙이면 &lt;br /&gt;&amp;nbsp;{ &quot;message&quot;: &quot;존재하지 않는 일기입니다.&quot; }&amp;nbsp;&amp;nbsp;// 400 Bad Request &lt;br /&gt;&amp;nbsp;위처럼 에러 메시지가 나오고 500대신 400으로 정확하게 내려옴.&lt;/p&gt;
&lt;pre id=&quot;code_1775042068208&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.exception;
import ...

@RestControllerAdvice
public class GlobalExceptionHandler {

    /*
         비즈니스 로직 예외 (잘못된 요청)
         -중복 아이디, 비밀번호 불일치, 존재하지 않는 리소스 등
         -500 &amp;rarr; 400 Bad Request로 변환
     */
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt; handleIllegalArgument(IllegalArgumentException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(Map.of(&quot;message&quot;, e.getMessage()));
    }

    /*
         그 외 예상치 못한 서버 예외
         -사용자에게 내부 오류 메시지 노출 방지
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt; handleException(Exception e) {
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Map.of(&quot;message&quot;, &quot;서버 오류가 발생했습니다.&quot;));
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&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;</description>
      <category> Project/myDiary - 일기 기록 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/80</guid>
      <comments>https://rim08.tistory.com/80#entry80comment</comments>
      <pubDate>Wed, 1 Apr 2026 20:19:20 +0900</pubDate>
    </item>
    <item>
      <title>[토이프로젝트 개발일지 03] 일기 기록 웹 (Spring Security, JWT)</title>
      <link>https://rim08.tistory.com/79</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트의 핵심 중 하나인 인증/인가 구현하기!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 간단하게 핵심만 정리하자면 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;인증(Authentication)&lt;/blockquote&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;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;인가 (Authorization)&lt;/blockquote&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;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;JWT&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT는 STATELESS 방식으로 동작하여 서버가 세션 상태를 저장하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션 방식은 로그인 상태를 메모리에 저장하지만, JWT는 토큰 자체에 정보가 담겨 있어 서버는 아무것도 저장하지 않아도 된다. 따라서 서버를 여러 대로 늘려도(스케일 아웃) 인증이 끊기지 않는 장점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;Spring Security&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필터 체인 구조로 이루어져 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SecurityContext에 인증 정보를 저장하여 추후에 필요할 때마다 꺼내서 사용하는 방식으로 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&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;[로그인&amp;nbsp;요청] &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;darr; &lt;br /&gt;UserController&amp;nbsp;&amp;rarr;&amp;nbsp;UserService&amp;nbsp;&amp;rarr;&amp;nbsp;JWT&amp;nbsp;토큰&amp;nbsp;생성&amp;nbsp;&amp;rarr;&amp;nbsp;응답&amp;nbsp;바디에&amp;nbsp;토큰&amp;nbsp;반환 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;br /&gt;[이후&amp;nbsp;모든&amp;nbsp;API&amp;nbsp;요청] &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;darr; &lt;br /&gt;HTTP&amp;nbsp;Header:&amp;nbsp;Authorization:&amp;nbsp;Bearer&amp;nbsp;{token} &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;darr; &lt;br /&gt;JwtFilter&amp;nbsp;(OncePerRequestFilter) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr;&amp;nbsp;토큰&amp;nbsp;유효성&amp;nbsp;검증 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr;&amp;nbsp;SecurityContext에&amp;nbsp;인증&amp;nbsp;정보&amp;nbsp;저장 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;darr; &lt;br /&gt;Controller&amp;nbsp;&amp;rarr;&amp;nbsp;@AuthenticationPrincipal로&amp;nbsp;userId&amp;nbsp;꺼내&amp;nbsp;씀&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 코드 작성 시작~~&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계는 처음에 설계 때 프로젝트 세팅하면서 다 했는데, 다시 한번 정리 할겸 쓰는 내용이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;build.gradle - 의존성 추가&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773974678167&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;dependencies {
    //Spring Security
    implementation 'org.springframework.boot:spring-boot-starter-security'

    //JWT (jjwt 라이브러리)
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    runtimeOnly    'io.jsonwebtoken:jjwt-impl:0.12.3'
    runtimeOnly    'io.jsonwebtoken:jjwt-jackson:0.12.3'
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;application-local.yml - JWT 설정 값&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773974745787&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;jwt:
  secret: &quot;________________________&quot; #256bit 이상 필요
  expiration: 86400000 #24시간 (ms 단위)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 배포 시에는 secret을 환경 변수로 관리해야 한다. 일단 학습용으로 깃허브에 올라가지 않게만 해서 local yml에 작성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음으로는 이제 security 패키지를 만들고, 그 안에 사진과 같은 3가지 파일을 작성한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;785&quot; data-origin-height=&quot;741&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nik1Z/dJMcagSne5d/gLUWH25JZd3cGbDfVDdEk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nik1Z/dJMcagSne5d/gLUWH25JZd3cGbDfVDdEk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nik1Z/dJMcagSne5d/gLUWH25JZd3cGbDfVDdEk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fnik1Z%2FdJMcagSne5d%2FgLUWH25JZd3cGbDfVDdEk1%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;480&quot; height=&quot;453&quot; data-origin-width=&quot;785&quot; data-origin-height=&quot;741&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;2. JWT 유틸리티 클래스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;JWT 토큰 생성 / 검증 / 파싱을 담당하는 클래스이다.&lt;/span&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;span&gt;JWT 구조: Header.Payload.Signature&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Header : 알고리즘 정보 (HS256)&lt;/li&gt;
&lt;li&gt;Payload : claim (userId 등 담고 싶은 데이터)&lt;/li&gt;
&lt;li&gt;Signature: 위변조 방지 서명&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;JwtTokenProvider.java&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 1) 생성자 &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;import할 때는 &lt;span style=&quot;color: #0593d3;&quot;&gt;@Value&lt;/span&gt;에서 주의한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인텔리제이 자동완성에서 lombok이 먼저 뜨는데, application yml 설정을 불러오려면 springframework.bean~의 @Value를 사용해야 정상적으로 작동한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773975151225&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.security;
import ...
import org.springframework.beans.factory.annotation.Value; //lombok 말고 이거!

@Slf4j
@Component
public class JwtTokenProvider {

    private final SecretKey secretKey;
    private final long expiration;

    public JwtTokenProvider(
            @Value(&quot;${jwt.secret}&quot;) String secret,
            @Value(&quot;${jwt.expiration}&quot;) long expiration) {
        //문자열 secret -&amp;gt; HMAC-SHA 알고리즘용 Key 객체로 변환
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expiration = expiration;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;@Slf4j&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-로그를 작성하는 용이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;@Component&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-해당 어노테이션을 쓰면 따로 @Bean으로 등록하지 않아도 Spring이 @compent가 붙은 클래스를 스캔해서 자동으로 클래스를 빈으로 등록해 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Service, @Controller, @Repository 안에도 @Component가 들어있다. 이 어노테이션을 사용한 클래스는 Spring IoC 컨테이너에 의해 Bean으로서 자동 관리된다.&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;우선 생성자를 작성한다. @Value로 키들을 불러온 후, this로 불러온 키들에 각각 값을 적용한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;secretKey에는 HMAC-SHA 알고리즘을 적용하여 key 객체로 변환한다.&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;span style=&quot;color: #0593d3;&quot;&gt; HMAC-SHA&lt;/span&gt; &lt;span style=&quot;color: #0593d3;&quot;&gt;(&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;Hash based Message Authentication Code using SHA)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비밀키와 SHA 해시 함수(SHA-1, SHA-256 등)를 결합하여, 메시지의 무결성과 인증을 보장하는 알고리즘이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;송수신자가 공유한 키를 사용해 데이터 변조 여부를 확인하며, 주로 네트워크 보안 및&amp;nbsp; JWT(JSON Web Token) 서명에 활용된다. 단순 해시 함수보다 키를 사용하므로 훨씬 안전하며, 암호화 속도가 빨라 높은 보안성이 요구되는 인증 시스템에서 선호되는 방식 중 하나이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원본 메시지, 공유된 비밀 키(K), 해시 함수(H)로 구성되며, 작동 방식은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 키 전처리: 비밀 키(K)를 해시 함수의 블록 크기에 맞게 패딩&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 내부 해시: 패딩된 키와 메시지를 결합하여 1차 해시 값을 계산&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 외부 해시: 다른 패딩 값과 1차 해시 값을 결합하여 최종 HMAC 값을 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 무결성 검증: 송신자가 MAC을 함께 전송하면, 수신자는 같은 키로 MAC을 재계산하여 일치 여부를 확인한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2) 토큰 생성&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775034395804&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public String createToken(Long userId) {
    Date now = new Date();
    Date expiryDate = new Date(now.getTime() + expiration);

    return Jwts.builder()
            .subject(String.valueOf(userId)) //payload에 userId 저장
            .issuedAt(now)
            .expiration(expiryDate)
            .signWith(secretKey)    //서명 (위변조 방지)
            .compact();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;subject(payload)에 userId를 문자열로 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 발급 시간, 만료 시간은 자동 세팅되도록 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3) 토큰에서 userId 추출&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775034621314&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public Long getUserId(String token) {
    return Long.parseLong(
            getClaims(token).getSubject()
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;token의 subject에서 userId를 꺼내서 Long으로 변환한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4) 토큰 유효성 검증&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775034715614&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public boolean validateToken(String token) {
    try {
        getClaims(token); //파싱 자체가 곧 검증
        return true;
    } catch (ExpiredJwtException e) {
        log.warn(&quot;만료된 JWT 토큰: {}&quot;, e.getMessage());
    } catch (UnsupportedJwtException e) {
        log.warn(&quot;지원하지 않는 JWT 토큰: {}&quot;, e.getMessage());
    } catch (MalformedJwtException e) {
        log.warn(&quot;잘못된 JWT 형식: {}&quot;, e.getMessage());
    } catch (IllegalArgumentException e) {
        log.warn(&quot;JWT 토큰이 비어있음: {}&quot;, e.getMessage());
    }
    return false;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만료, 위변조, 형식 오류 등을 각각 catch해서 로그를 남긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유효하면 true, 그 외 모두 false를 반환한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5) 공통 내부 메서드&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775034809438&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; private Claims getClaims(String token) {
    return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰을 받아 파싱해서&amp;nbsp; Claims로 반환하는 메서드이다. 위의 다른 메서드에서 이용되는 것이다.&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;span style=&quot;color: #0593d3;&quot;&gt;Claims&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰에 담긴 사용자 정보나 권한 등 핵심적인 데이터 조각을 의미한다. 사용자&amp;nbsp; ID, 토큰 발급 시간(iat), 만료 시간(exp), 권한 등 실제 데이터를 전달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON 객체의 Key-Value 쌍으로 구성된다. Payload 부분에 위치하며, 인증 및 인가에 필요한 정보를 토큰 자체가 포함하여 서버의 세션 관리 부담을 줄이는 역할을 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;3. JWT 필터&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT&amp;nbsp;인증&amp;nbsp;필터 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-OncePerRequestFilter: 요청당 딱 한 번만 실행됨을 보장 -&amp;gt; 이 필터를 상속하기&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;처리&amp;nbsp;흐름 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;1.&amp;nbsp;요청&amp;nbsp;헤더에서&amp;nbsp;&quot;Authorization:&amp;nbsp;Bearer&amp;nbsp;{token}&quot;&amp;nbsp;추출 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;2.&amp;nbsp;토큰&amp;nbsp;유효성&amp;nbsp;검증 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;3.&amp;nbsp;유효하면&amp;nbsp;SecurityContext에&amp;nbsp;인증&amp;nbsp;정보&amp;nbsp;저장 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;4.&amp;nbsp;이후&amp;nbsp;Controller에서&amp;nbsp;@AuthenticationPrincipal로&amp;nbsp;userId&amp;nbsp;꺼내&amp;nbsp;씀&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;JwtAuthenticationFilter.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1775035195781&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.security;
import ...

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //1. 헤더에서 토큰 추출
        String token = resolveToken(request);

        //2. 토큰 유효성 검증 후 SecurityContext에 저장
        if (StringUtils.hasText(token) &amp;amp;&amp;amp; jwtTokenProvider.validateToken(token)) {
            Long userId = jwtTokenProvider.getUserId(token);

            /*
                UsernamePasswordAuthenticationToken(principal, credentials, authorities)
                -principal: 인증된 사용자 식별 정보 -&amp;gt; userId를 그대로 저장
                -credentials: 비밀번호 (인증 후에는 null로 처리)
                -authorities: 권한 목록 (학습용이라 빈 리스트)
             */
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userId, null, List.of());

            //SecurityContext에 저장 -&amp;gt; 이후 어디서든 꺼낼 수 있음
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.debug(&quot;SecurityContext에 인증 정보 저장 완료. userId: {}&quot;, userId);
        }

        //3. 다음 필터로 넘김 (인증 실패해도 넘김 - securityConfig에서 접근 제어)
        filterChain.doFilter(request, response);
    }


    /*
        Authorization 헤더에서 Bearer 토큰 파싱
        -&quot;Bearer eyjhbGc...&quot; -&amp;gt; &quot;eyjhbGc...&quot; 부분만 추출
     */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(&quot;Authorization&quot;);
        if (StringUtils.hasText(bearerToken) &amp;amp;&amp;amp; bearerToken.startsWith(&quot;Bearer &quot;)) {
            return bearerToken.substring(7); //&quot;Bearer &quot;이후 문자열 추출
        }
        return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;OncePerRequestFilter를 상속해서 doFilterInternal를 오버라이드한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;앞서 구현했던 JwtTokenProvider의 메서드를 이용하여 핵심 로직을 구성한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;우선 request를 받아서 헤더에서 토큰을 추출하고, 해당 토큰의 유효성을 검사한다. 이때 앞서 구현했던 토큰 유효성 검사 메서드를 활용하고, 유효할 시 사용자 ID를 추출하는 메서드를 활용한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 검증이 완료되면 SecurityContext에 정보를 저장한다. 이러면 추후에도 정보가 필요할 때마다 계속 꺼내쓸 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로는 다음 필터로 request와 response를 넘겨준다. securityConfig에서 접근 제어 설정을 할 것이기 때문에, 인증이 실패해도 넘긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;4. Security 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 방식을 쓰면 세션을 사용하지 않음 (STATELESS) &lt;br /&gt;&amp;nbsp;-&amp;gt; 서버가 상태를 저장하지 않고 토큰만으로 인증 처리&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;SecurityConfig.java&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여기서는 provider와 마찬가지로 Bean 등록을 할 것이다. 설정 파일이기 때문에 Configuration 어노테이션도 붙여준다.&lt;/p&gt;
&lt;pre id=&quot;code_1775035671164&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.security;
import ...

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                //CSRF 비활성화: REST API + JWT 방식에서는 불필요 (CSRF는 세션/쿠키 기반 인증의 취약점을 막는 용도)
                .csrf(AbstractHttpConfigurer::disable)

                //세션 사용 안 함 (JWT는 STATELESS)
                .sessionManagement(session -&amp;gt;
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                //URL별 접근 권한 설정
                .authorizeHttpRequests(auth -&amp;gt; auth
                        //회원가입, 로그인, 프론트엔드 정적 리소스는 접근 허용해야 함
                        .requestMatchers(HttpMethod.POST, &quot;/api/users/join&quot;).permitAll()
                        .requestMatchers(HttpMethod.POST, &quot;/api/users/login&quot;).permitAll()
                        .requestMatchers(&quot;/&quot;, &quot;/index.html&quot;, &quot;/css/**&quot;, &quot;/js/**&quot;).permitAll()
                        .anyRequest().authenticated() //그 외 모든 요청은 인증 필요
                )

                //JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 삽입
                // -&amp;gt; Spring Security 기본 폼 로그인 필터보다 먼저 JWT 검사
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
    
 /*
        비밀번호 암호화 Bean
        -BCrypt: 단방향 해시 + salt 자동 적용
        -같은 비밀번호여도 매번 다른 해시값 생성 -&amp;gt; 레인보우 테이블 공격 방어
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 이렇게만 코드를 작성했다가, 프론트엔드까지 구현 후에 테스트해 보니 오류가 발생했었다. 로그인을 해도 다른 페이지에 접근이 불가하다는 오류가 발생한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분석 결과, 원인은 2가지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #6164c6;&quot;&gt;&lt;u&gt;원인 1. CORS 설정 누락&lt;/u&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 fetch로 백엔드 API를 호출할 때, 백엔드에 CORS 설정이 없으면 브라우저가 응답을 차단한다. 특히 개발 중에 프론트(8080)와 백엔드가 같은 포트여도 Spring Security가 preflight 요청을 막아버린다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;CORS 설정&lt;br /&gt;- 개발 중: localhost 모든 포트 허용&lt;br /&gt;- 배포 시: allowedOrigins을 실제 도메인으로 교체&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;span style=&quot;color: #6164c6;&quot;&gt;원인&amp;nbsp;2.&amp;nbsp;SecurityConfig가&amp;nbsp;정적&amp;nbsp;리소스를&amp;nbsp;막고&amp;nbsp;있음&lt;/span&gt;&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 코드에서 SecurityConfig는 /api/users/join, /api/users/login 외 모든 요청에 인증을 요구한다. 그런데 main.html, diary.html 등 HTML 파일 자체도 여기에 걸려서 403이 발생하는 것이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;★ 정적 리소스 (HTML/CSS/JS) 전부 허용&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 위 원인을 해결하는 식으로 코드를 수정하면 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 filterChain 메서드에 CORS 설정을 추가하고, authorizeHttpRequests 설정에 정적 리소스는 전부 허용하게 수정한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775036408536&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                //CORS 설정 추가: 브라우저에서 fetch로 백엔드 API 호출할 때 이 설정이 없으면 브라우저가 응답을 차단함
                //FE, BE 같은 8080 포트여도 Spring Security가 preflight 요청 막음
                .cors(cors -&amp;gt; cors.configurationSource(corsConfigurationSource()))
                
                ...

                //URL별 접근 권한 설정
                .authorizeHttpRequests(auth -&amp;gt; auth
                        //회원가입, 로그인, 프론트엔드 정적 리소스는 접근 허용해야 함
                        .requestMatchers(HttpMethod.POST, &quot;/api/users/join&quot;).permitAll()
                        .requestMatchers(HttpMethod.POST, &quot;/api/users/login&quot;).permitAll()
                        .requestMatchers(&quot;/&quot;, &quot;/*.html&quot;, &quot;/css/**&quot;, &quot;/js/**&quot;, &quot;/favicon.ico&quot;).permitAll()
                        .anyRequest().authenticated() //그 외 모든 요청은 인증 필요
                )
		...
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 CORS 설정에 관한 Bean을 추가한다.&lt;/p&gt;
&lt;pre id=&quot;code_1775036466181&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        //허용할 출처 (개발용)
        config.setAllowedOriginPatterns(List.of(&quot;*&quot;));

        //허용할 HTTP 메서드
        config.setAllowedMethods(List.of(&quot;GET&quot;, &quot;POST&quot;, &quot;PUT&quot;, &quot;DELETE&quot;, &quot;OPTIONS&quot;));

        //허용할 헤더
        config.setExposedHeaders(List.of(&quot;*&quot;));

        //Authorization 헤더 노출 (클라이언트에서 읽을 수 있게)
        config.setExposedHeaders(List.of(&quot;Authorization&quot;));

        //자격 증명 포함 허용 (쿠키 등, JWT는 필수는 아니지만 관례상 추가)
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration(&quot;/**&quot;, config); //모든 경로에 적용
        return source;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3가지 파일 작성을 모두 끝내면, Service와 Controller에서 위 내용을 적용하도록 수정하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 전에 작성한 글에서 Service와 Controller는 이미 적용된 후의 코드 내용이다.&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;Service에서는 실제 암호화와 토큰 발급을 붙여서, 회원가입 시 BCrypt로 비밀번호 암호화 후 저장해서 DB에는 &quot;$2a$10$...&quot; 형태의 해시값이 저장되도록 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 시에는 matches(입력된 평문, DB의 해시값) &amp;rarr; 내부적으로 BCrypt 비교해서 검증이 통과하면 JWT 토큰을 발급한다.&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;Controller에서 기존에는 @RequestParam으로 userId를 꺼내 썼는데, 이제는 필터가 SercurityContext에 저장해둔 걸 어노테이션을 이용하여 쉽게 꺼내 쓰도록 바꿀 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;@AuthenticationPrincipal Long userId, // &amp;larr; userId 자동 주입&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;new UsernamePasswordAuthenticationToken(userId, ...) 로 저장할 때 첫 번째 인자가 principal이라서, 이 값을 @AuthenticationPrincipal이 그대로 꺼내준다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&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;span&gt;① 회원가입&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;POST &lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;/&lt;/span&gt;&lt;span&gt;api&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;/&lt;/span&gt;&lt;span&gt;users&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;/&lt;/span&gt;&lt;span&gt;join&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;{&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;loginId&quot;&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;hong&quot;&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;password&quot;&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;1234&quot;&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;nickname&quot;&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;홍길동&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;rarr; 비밀번호 &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;BCrypt&lt;/span&gt;&lt;span&gt; 암호화 후 DB 저장&lt;/span&gt;&lt;/span&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;span&gt;② 로그인 &lt;/span&gt;&lt;span&gt;&lt;span&gt;POST &lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;/&lt;/span&gt;&lt;span&gt;api&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;/&lt;/span&gt;&lt;span&gt;users&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;/&lt;/span&gt;&lt;span&gt;login&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;{&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;loginId&quot;&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;hong&quot;&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;password&quot;&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;1234&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;rarr; 응답&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;{&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;userId&quot;&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #5eeded;&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;nickname&quot;&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;홍길동&quot;&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;accessToken&quot;&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #9be963;&quot;&gt;&quot;eyJhbGc...&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;}&lt;/span&gt;&lt;/span&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;span&gt; &lt;/span&gt;&lt;span&gt;③ 이후 모든 API 요청&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;Header&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;Authorization&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;Bearer&lt;/span&gt;&lt;span&gt; eyJhbGc&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;rarr; &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;JwtAuthenticationFilter&lt;/span&gt;&lt;span&gt;가 토큰 검증&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;rarr; &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;SecurityContext&lt;/span&gt;&lt;span&gt;에 userId&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #5eeded;&quot;&gt;1&lt;/span&gt;&lt;span&gt; 저장&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;rarr; &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;Controller&lt;/span&gt;&lt;span&gt;에서 &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;@AuthenticationPrincipal&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;Long&lt;/span&gt;&lt;span&gt; userId 로 꺼내 씀 &lt;/span&gt;&lt;/span&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;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;④ 토큰 없이 &lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;/&lt;/span&gt;&lt;span&gt;api&lt;/span&gt;&lt;span style=&quot;color: #eaecf0;&quot;&gt;/&lt;/span&gt;&lt;span&gt;diaries 접근 시&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;rarr; &lt;/span&gt;&lt;span style=&quot;color: #5eeded;&quot;&gt;403&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;Forbidden&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color: #fbad60;&quot;&gt;SecurityConfig&lt;/span&gt;&lt;span&gt;의 &lt;/span&gt;&lt;span style=&quot;color: #70b8ff;&quot;&gt;anyRequest&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color: #70b8ff;&quot;&gt;authenticated&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;)&lt;/span&gt;&lt;span&gt; 에 막힘&lt;/span&gt;&lt;span style=&quot;color: #d3d7de;&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&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&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category> Project/myDiary - 일기 기록 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/79</guid>
      <comments>https://rim08.tistory.com/79#entry79comment</comments>
      <pubDate>Wed, 1 Apr 2026 18:53:24 +0900</pubDate>
    </item>
    <item>
      <title>[토이프로젝트 개발일지 02] 일기 기록 웹 (JPA, DTO)</title>
      <link>https://rim08.tistory.com/78</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 단계는 본격적으로 계층 별로 코드 작성하기!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 표대로 domain, repository, service, controller부터 구현한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;myDiary-draw1.png&quot; data-origin-width=&quot;650&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c3bw88/dJMcaaSalRu/4Zgfjr7WHJwmoRioIGh5S1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c3bw88/dJMcaaSalRu/4Zgfjr7WHJwmoRioIGh5S1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c3bw88/dJMcaaSalRu/4Zgfjr7WHJwmoRioIGh5S1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc3bw88%2FdJMcaaSalRu%2F4Zgfjr7WHJwmoRioIGh5S1%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;556&quot; height=&quot;342&quot; data-filename=&quot;myDiary-draw1.png&quot; data-origin-width=&quot;650&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Domain&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 전 글에서 데이터베이스 설계한 대로 Domain 패키지 안에서 엔티티 코드를 작성한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA를 사용하기 때문에 애노테이션만 달아주면 JPA가 자동으로 객체와 실제 데이터베이스 테이블을 연결시켜 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덕분에 개발자는 쿼리를 따로 작성하지 않고 객체 중심 개발을 할 수 있다. (ORM 기술)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;User.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773917007394&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.domain;
import ...

@Entity //jpa 켜야 사용 가능
@Table(name = &quot;users&quot;)
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED) //기본 생성자 자동 생성, 다른 곳(서비스)에서 new 생성 불가
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) //자동 증가++
    private Long id; //PK

    //로그인 ID: null 불가, 중복 불가, 길이 50
    @Column(nullable = false, unique = true, length = 50)
    private String loginId;

    //비밀번호: null 불가 (이후 Spring Security BCrypt 사용)
    @Column(nullable = false)
    private String password;

    //닉네임: null 불가, 길이 50
    @Column(nullable = false, length = 50)
    private String nickname;

    //mappedBy로 Diary 엔티티의 'user' 필드가 주인임을 알림(알림용이라 Column 지정x)
    //cascade: User 삭제 시 연관된 Diary도 함께 삭제
    //orphanRemoval: 컬렉션에서 제거된 Diary도 DB에서 삭제
    @OneToMany(mappedBy = &quot;user&quot;, cascade = CascadeType.ALL, orphanRemoval = true)
    private List&amp;lt;Diary&amp;gt; diaries = new ArrayList&amp;lt;&amp;gt;();

    //기본 말고 세팅용 생성자(회원 가입) -&amp;gt; 정적 팩토리 메서드 패턴 사용 권장
    public static User create(String loginId, String password, String nickname) {
        User user = new User();
        user.loginId = loginId;
        user.password = password;
        user.nickname = nickname;
        return user;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 코드를 작성한 상태에서 DB 연결 없이 실행시키면 바로 오류가 뜬다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle에서 JPA 의존성을 추가해 놓고 막상 DB 연결할 URL 정보가 없으니 터지는 오류이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 미리 MariaDB를 cmd에서 생성하고 application-local.yml에 등록해 놓으면 괜찮지만, 만약 나중에 등록할 예정이라면 테스트용으로 H2를 넣어놓아도 된다. (아니면 jpa 잠시 비활성화 하든가)&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;코드에서 @Table(name = &quot;users&quot;)로 이름을 붙여줬는데, 처음엔 이거 안 했다가 나중에 H2 데이터베이스로 테스트 코드 실행할 때 오류가 났었다. MariaDB는 관대한 편이나 H2는 'user'가 예약어로 엄격히 정해져 있어서 겹쳐서 오류가 났던 것이다. 따라서 이름을 붙여주는 식으로 수정하였더니 정상적으로 H2에서도 실행될 수 있었다.&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;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;정적 팩토리 메서드 패턴&lt;/b&gt;&lt;/span&gt;은 static 메서드를 통해 외부에서 생성자를 간접적으로 호출하도록 하는 디자인 패턴이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 이용해서 기본 생성자는 자동으로 생성하되 외부에선 접근하지 못하도록 한다.&amp;nbsp;단순히 new로 호출할 때보다 이렇게 호출하게 되면 create란 이름으로 그 목적을 확실히 알 수 있어 코드 가독성이 좋아진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 객체 생성을 캡슐화하여 데이터를 은닉할 수 있다. 프로젝트에서 DTO를 사용하다 보니 DTO와 엔티티 간에 형 변환이 자유롭게 이루어져야 하는데, 이 패턴을 이용하면 외부에서 생성자 내부 구현을 전부 모르더라도 쉽게 변환이 가능하다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Diary.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773918089873&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.domain;
import ...

@Entity
@Getter @Setter
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED) //기본 생성자 자동 생성
public class Diary {

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

    //글 제목: null 불가
    @Column(nullable = false)
    private String title;

    //글 내용: null 불가, TEXT 타입으로 긴 내용 저장 가능
    @Column(nullable = false, columnDefinition = &quot;TEXT&quot;)
    private String content;

    @CreatedDate //최초 저장 시 자동으로 현재 시간 세팅
    @Column(updatable = false) //이후 변경 불가
    private LocalDateTime createdAt;

    @LastModifiedDate //수정 시 현재 시간 갱신
    @Column //변경 가능
    private LocalDateTime updatedAt;

    @Column(nullable = false)
    private LocalDate diaryDate; //날짜 (캘린더 구분용)

    //연관 관계의 주인: Diary가 user_id를 FK로 관리
    //LAZY: 일기 조회 시 User 정보를 즉시 불러오지 않음
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;user_id&quot;, nullable = false)
    private User user;

    //생성자
    public static Diary create(String title, String content, LocalDate diaryDate, User user) {
        Diary diary = new Diary();
        diary.title = title;
        diary.content = content;
        diary.diaryDate = diaryDate;
        diary.user = user;
        return diary;
    }

    //수정 메서드 (제목, 내용만 변경 가능)
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
        //수정 시간은 @LastModifiedDate가 자동으로 갱신함
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에서도 마찬가지로 기본 생성자는 @NoArgsConstructor으로 자동 생성하되, create하는 건 정적 팩토리 메서드 패턴으로 관리하여 외부에서 안전하고 쉽게 사용할 수 있게 작성한다. 또한 글은 수정 기능을 제공해야 하므로 수정 메서드도 이곳에 같이 작성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간 관련해서는 애노테이션이 자동으로 세팅해주도록 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 @CreatedDate, @LastModifiedDate 동작을 위해서 Spring Boot 메인에 Auditing을 활성화해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773918353086&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary;
import ...

@SpringBootApplication
@EnableJpaAuditing //@CreatedDate, @LastModifiedDate 동작을 위해 추가
public class Application {
	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Repository&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA는 형식만 맞으면 이름을 지어서 해도 괜찮다. 따로 SQL 쿼리를 작성할 필요 없이 JPA가 알아서 처리해 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; 1) 함수명부터 작성: find, exist 등 &lt;br /&gt;&amp;nbsp; 2) 그러면 관련 함수 타입 자동 완성 (Id (PK)쪽으로 자동 완성해주는 거 선택) &lt;br /&gt;&amp;nbsp; 3) 필요한 변수명으로 바꾸고 override 삭제, 인자도 변경 &lt;br /&gt;&amp;nbsp; (기존 형식에 없는 건 따로 SQL 쿼리 작성해야 함)&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;위 방식을 이용해서, 이후 service에서 사용하게 될 (기능 구현에 필요한데 DB를 이용해야 하는) 메서드를 작성해 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;UserRepository.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773918488267&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface UserRepository extends JpaRepository&amp;lt;User, Long&amp;gt; {
    //로그인 시 아이디로 사용자 조회
    Optional&amp;lt;User&amp;gt; findByLoginId(String loginId);

    //회원가입 시 아이디 중복 체크
    boolean existsByLoginId(String loginId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;DiaryRepository.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773918788081&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface DiaryRepository extends JpaRepository&amp;lt;Diary, Long&amp;gt; {
    //메인 화면 캘린더에서 일기가 존재하는 날짜에만 녹색 표시할 때 사용
    //특정 사용자의 일기가 존재하는 날짜 목록
    @Query(&quot;SELECT DISTINCT d.diaryDate
    	    FROM Diary d
            WHERE d.user.id = :userId
            AND YEAR(d.diaryDate) = :year
            AND MONTH(d.diaryDate) = :month&quot;)
    List&amp;lt;LocalDate&amp;gt; findDiaryDatesByUserAndYearMonth(
            @Param(&quot;userId&quot;) Long userId,
            @Param(&quot;year&quot;) int year,
            @Param(&quot;month&quot;) int month
    );

    //메인 화면에서 캘린더 날짜 클릭 시, 해당 날짜에 전체 글 조회할 때 사용
    //특정 사용자의 특정 날짜 일기 목록 (최신순 정렬)
    List&amp;lt;Diary&amp;gt; findByUserIdAndDiaryDateOrderByCreatedAtDescIdDesc(Long userId, LocalDate diaryDate);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;&lt;u&gt;★ Spring Data JPA의 메서드 이름 규칙 (JPA가 해석 가능한 이름)&lt;/u&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;findBy + 조건 + And + 조건 + OrderBy + 정렬조건&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위처럼 작성하면 JPA가 아래와 같은 SQL로 해석한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773919093282&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * 
FROM diary 
WHERE user_id = ? AND diary_date = ?
ORDER BY created_at DESC, id DESC&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 첫번째 메서드는 JPA가 해석을 못하기 때문에 직접 쿼리로 작성해야 한다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;-JPA가 못하는 것들&lt;br /&gt;1) DISTINCT: 중복 제거 (한 날짜에 여러 글이 저장되면 중복되니까, 하나만 추출하려면 중복 제거해야 함)&lt;br /&gt;2) YEAR(), MONTH() 같은 함수 사용&lt;br /&gt;3) 특정 필드만 조회 (diaryDate만)&lt;br /&gt;&lt;br /&gt;+메서드 이름으로 가능하면 자동 생성 쓰는 게 좋지만, 이런 경우는 @Query 사용하는 게 좋음&lt;br /&gt;1) 집계 (COUNT, SUM)&lt;br /&gt;2) 함수 (YEAR, DATE_FORMAT 등)&lt;br /&gt;3) JOIN 복잡&lt;br /&gt;4) DTO 바로 조회&lt;br /&gt;5) DISTINCT 필요&lt;/blockquote&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Service&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 API 관련 기능 구현부터 먼저 하고, 이후에 Sprnig Security/JWT 코드를 작성한 다음에 service에도 적용시켜 수정하였다.&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;기본적으로는 @Transactional(readOnly = true)를 이용하여 읽기 전용 트랜잭션으로 관리한다. 읽기 전용 트랜잭션은 DB에 스냅샷을 만들지 않아서 성능 최적화가 될 뿐만 아니라 안전하게 작업할 수 있어 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쓰기 작업이 필요한 메서드에만 @Transactional을 달아서 오버라이드하면 쓰기 모드로 바꿀 수 있다.&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;UserService에서는 설계했던 요구사항에 따라 아이디 중복, 비밀번호 4자리 이상 관련하여 검사하는 로직을 구현한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에서 User 생성자를 직접 호출하지 않고 정적 팩토리 메서드를 이용하여 간접적으로 호출한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에 주석처리 된 부분은 security 적용하기 전에 임시로 작성한 부분이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;UserService.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773920217574&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor //final 필드 생성자 자동 생성 (DI)
@Transactional(readOnly = true) //기본은 읽기 전용 트랜잭션 (성능 최적화)
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder; //SpringConfig에서 Bean 등록함
    private final JwtTokenProvider jwtTokenProvider;

    /*
        [회원가입]
        아이디 중복 체크 후 User 엔티티 저장
        비밀번호 암호화는 추후 BCryptPasswordEncoder 적용
        @Transactional &amp;lt;- 오버라이드하면 read에서 write 됨
     */
    @Transactional
    public void join(UserJoinRequest request) {
        //1. 아이디 중복 검사
        if (userRepository.existsByLoginId(request.getLoginId())) {
            throw new IllegalArgumentException(&quot;이미 사용 중인 아이디입니다.&quot;);
        }

        //2. 비밀번호 4자리 이상인지 검사
        if (request.getPassword().length() &amp;lt; 4) {
            throw new IllegalArgumentException(&quot;비밀번호는 4자리 이상이어야 합니다.&quot;);
        }

        //3. 비밀번호 암호화 (Spring Security 적용 시 passwordEncoder.encode() 사용)
        //BCrypt로 비밀번호 암호화 후 저장 -&amp;gt; DB에는 &quot;$2a$10$...&quot; 형태의 해시값이 저장됨
        String encodedPassword = passwordEncoder.encode(request.getPassword());

        User user = User.create(
                request.getLoginId(),
                //request.getPassword(), //암호화 전 임시
                encodedPassword,
                request.getNickname()
        );

        //4. DB에 엔티티 저장
        userRepository.save(user);
    }

    /*
        [로그인]
        아이디/비밀번호 검증 후 응답 반환
     */
    public UserLoginResponse login(UserLoginRequest request) {
        //1. 아이디 검증
        User user = userRepository.findByLoginId(request.getLoginId())
                .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;아이디 또는 비밀번호가 틀렸습니다.&quot;));

        //2. 비밀번호 검증
//        if (!user.getPassword().equals(request.getPassword())) {
//            throw new IllegalArgumentException(&quot;아이디 또는 비밀번호가 틀렸습니다.&quot;);
//        }
        //matches(입력된 평문, DB의 해시값) -&amp;gt; 내부적으로 BCrypt 비교
        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            throw new IllegalArgumentException(&quot;아이디 또는 비밀번호가 틀렸습니다.&quot;);
        }

        //검증 통과 -&amp;gt; JWT 토큰 발급
        String token = jwtTokenProvider.createToken(user.getId());

        //3. 성공 시 응답 반환 (메인 화면에서 닉네임 사용해야 해서)
        return new UserLoginResponse(user.getId(), user.getNickname(), token);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;-&lt;span style=&quot;text-align: left;&quot;&gt;BCrypt matches()&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;passwordEncoder.encode(&quot;12345678&quot;) 처럼 값을 넣었을 때, 매번 다른 해시 값을 만든다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 passwordEncoder.matches(&quot;12345678&quot;, 해시 값)으로 하는 순간, 내부적으로 salt를 분리해서 비교하기 때문에 항상 올바르게 검증된다. (직접 == 비교는 할 수 없다.)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;-인증/인가 방식으로 구현하니 예전에 회원가입/로그인을 구현할 때와 차이점&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;회원가입 때: 비밀번호를 암호화해서 DB에 저장&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;로그인 때: 암호화된 값으로 검증, 통과화면 JWT 토큰을 발급받아서 응답으로 반환 -&amp;gt; 갖고 있으면서 이후에도 다른 페이지 들어갈 때마다 계속 검증함&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;DiaryService.java&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;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;1) 캘린더 월별로 일기 등록한 날짜 조회: 해당 월에 일기가 있는 날짜 목록 반환 -&amp;gt; 프론트에서 녹색으로 표시&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Repository에서 구현했던 메서드를 이용하여 날짜 목록을 List에 담고, DTO에 담아서 반환한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773921107542&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public DiaryCalendarResponse getCalendarDates(Long userId, int year, int month) {
    List&amp;lt;LocalDate&amp;gt; dates = diaryRepository
            .findDiaryDatesByUserAndYearMonth(userId, year, month);
    return new DiaryCalendarResponse(dates);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1773921279586&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//DiaryCalendarResponse.java
@Getter
@AllArgsConstructor
public class DiaryCalendarResponse {
    private List&amp;lt;LocalDate&amp;gt; datesWithDiary;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) 해당&amp;nbsp;날짜&amp;nbsp;별&amp;nbsp;일기&amp;nbsp;전체&amp;nbsp;목록&amp;nbsp;조회&lt;/p&gt;
&lt;pre id=&quot;code_1773921372219&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public List&amp;lt;DiaryListResponse&amp;gt; getDiariesByDate(Long userId, LocalDate date) {
    return diaryRepository
            .findByUserIdAndDiaryDateOrderByCreatedAtDescIdDesc(userId, date)
            .stream()
            .map(DiaryListResponse::from) //엔티티 -&amp;gt; DTO 변환
            .collect(Collectors.toList());
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1773921401078&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//DiaryListResponse.java
@Getter
public class DiaryListResponse {
    private Long id;
    private String title;
    private LocalDateTime createdAt;

    //엔티티를 직접 외부에 노출하지 않고 DTO로 변환
    public static DiaryListResponse from(Diary diary) {
        DiaryListResponse dto = new DiaryListResponse();
        dto.id = diary.getId();
        dto.title = diary.getTitle();
        dto.createdAt = diary.getCreatedAt();
        return dto;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫번째 메서드는 처음부터 List로 담아 DTO로 변환했지만, 이건 각각 DTO로 변환한 다음에 DTO들을 List로 담아서 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는, 첫 번째 메서드는 LocalDate만 조회하기 때문에(레포지토리에서 직접 쿼리 작성한 그거) 엔티티가 아니라서 변환할 게 없기 때문이다. 여기에서 DTO는 그냥 데이터를 감싸는 역할만 한다.&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;반면 두 번째 메서드는 Diary 엔티티를 레포지토리에서 갖고 오기 때문에, 엔티티를 컨트롤러에 그대로 반환하면 안 되니까 DTO로 변환이 필요한 경우이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 DB에서 Diary 엔티티 리스트를 갖고 온 다음, DTO로 하나씩 변환하고 List로 묶어서 반환한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;★ 엔티티를 그대로 반환하면 안 되는 이유&lt;br /&gt;-엔티티 구조 노출됨&lt;br /&gt;-양방향 연관관계 시 무한 JSON 루프 발생할 수 있음&lt;br /&gt;-필요 없는 데이터까지 전송, 오버헤드, 보안 위험(비밀번호가 포함되면 보안 문제)&lt;br /&gt;-추후 API 스펙 변경 어려움&lt;br /&gt;=&amp;gt; DTO로 변환해서 사용!&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;+) 나머지 메서드들&lt;/p&gt;
&lt;pre id=&quot;code_1773922269576&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//일기 상세 조회 (본인 것만 조회 가능)
public DiaryDetailResponse getDiary(Long userId, Long diaryId) {
    Diary diary = findDiaryWithOwnerCheck(userId, diaryId);
    return DiaryDetailResponse.from(diary);
}

//일기 작성
//userId는 JWT 인증 후 SecurityContext에서 꺼내옴
@Transactional
public Long create(Long userId, DiaryCreateRequest request) {
    //1. 사용자 검증
    User user = userRepository.findById(userId)
            .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;존재하지 않는 사용자입니다.&quot;));

    //2. 일기 생성
    Diary diary = Diary.create(
            request.getTitle(),
            request.getContent(),
            request.getDiaryDate(),
            user
    );

    //3. DB에 엔티티 저장하고 일기 id 받아서 컨트롤러에 넘김
    return diaryRepository.save(diary).getId();
}

//일기 수정
//Dirty Checking: 트랜잭션 안에서 엔티티 수정 시,
//별도 save() 호출 없이 자동으로 UPDATE 쿼리 실행
@Transactional
public void update(Long userId, Long diaryId, DiaryUpdateRequest request) {
    Diary diary = findDiaryWithOwnerCheck(userId, diaryId);
    diary.update(request.getTitle(), request.getContent());
    //save() 호출 불필요 -&amp;gt; Dirty Checking이 처리
}

//일기 삭제
@Transactional
public void delete(Long userId, Long diaryId) {
    Diary diary = findDiaryWithOwnerCheck(userId, diaryId);
    diaryRepository.delete(diary);
}

//공통 내부 메서드: 일기 조회 + 사용자 검증 (다른 사용자의 일기 접근 방지)
private Diary findDiaryWithOwnerCheck(Long userId, Long diaryId) {
    Diary diary = diaryRepository.findById(diaryId)
            .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;존재하지 않는 글입니다.&quot;));

    if (!diary.getUser().getId().equals(userId)) {
        throw new IllegalArgumentException(&quot;본인의 글만 접근할 수 있습니다.&quot;);
    }

    return diary;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;b&gt;-Dirty Checking&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;update() 메서드에서 save()를 호출하지 않아도 된다. @Transactional 안에서 JPA가 엔티티 변경을 감지해서, 트랜잭션 커밋 시 자동으로 UPDATE 쿼리를 날려주기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Controller&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[전체&amp;nbsp;인증&amp;nbsp;흐름&amp;nbsp;정리] &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;1.&amp;nbsp;회원가입 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;POST&amp;nbsp; /api/users/join &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;nbsp;&quot;loginId&quot;:&amp;nbsp;&quot;hong&quot;,&amp;nbsp;&quot;password&quot;:&amp;nbsp;&quot;1234&quot;,&amp;nbsp;&quot;nickname&quot;:&amp;nbsp;&quot;홍길동&quot;&amp;nbsp;} &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr;&amp;nbsp;비밀번호&amp;nbsp;BCrypt&amp;nbsp;암호화&amp;nbsp;후&amp;nbsp;DB&amp;nbsp;저장 &lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;2.&amp;nbsp;로그인 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;POST&amp;nbsp; /api/users/login &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{&amp;nbsp;&quot;loginId&quot;:&amp;nbsp;&quot;hong&quot;,&amp;nbsp;&quot;password&quot;:&amp;nbsp;&quot;1234&quot;&amp;nbsp;} &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr;&amp;nbsp;응답:&amp;nbsp;{&amp;nbsp;&quot;userId&quot;:&amp;nbsp;1,&amp;nbsp;&quot;nickname&quot;:&amp;nbsp;&quot;홍길동&quot;,&amp;nbsp;&quot;accessToken&quot;:&amp;nbsp;&quot;eyJhbGc...&quot;&amp;nbsp;} &lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;3.&amp;nbsp;이후&amp;nbsp;모든&amp;nbsp;API&amp;nbsp;요청 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Header:&amp;nbsp;Authorization:&amp;nbsp;Bearer&amp;nbsp;eyJhbGc... &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr;&amp;nbsp;JwtAuthenticationFilter가&amp;nbsp;토큰&amp;nbsp;검증 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr;&amp;nbsp;SecurityContext에&amp;nbsp;userId=1&amp;nbsp;저장 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr; Controller에서 @AuthenticationPrincipal &amp;rarr; Long userId 로 꺼내 씀 &lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;4.&amp;nbsp;토큰&amp;nbsp;없이&amp;nbsp;/api/diaries&amp;nbsp;접근&amp;nbsp;시 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;rarr; 403 Forbidden (SecurityConfig의 anyRequest().authenticated() 에 막힘)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;@AuthenticationPrincipal&amp;nbsp;동작&amp;nbsp;이유: &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;필터에서&amp;nbsp;new&amp;nbsp;UsernamePasswordAuthenticationToken(userId,&amp;nbsp;...)로&amp;nbsp;저장할&amp;nbsp;때마다 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;첫번째&amp;nbsp;인자가&amp;nbsp;principal인데,&amp;nbsp;이&amp;nbsp;값을&amp;nbsp;@AuthenticationPrincipal가&amp;nbsp;그대로&amp;nbsp;꺼내줌&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;UserController.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773922653481&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.controller;
import ...
/*
    [사용자 관련 API]
    POST /api/users/join    : 회원가입
    POST /api/users/login   : 로그인
 */
@RestController
@RequestMapping(&quot;/api/users&quot;)
@RequiredArgsConstructor //final 필드 생성자 자동 생성 (DI)
public class UserController {
    //서비스 로직 사용
    private final UserService userService;

    //회원가입
    @PostMapping(&quot;/join&quot;)
    public ResponseEntity&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt; join(@RequestBody UserJoinRequest request) {
        userService.join(request); //dto로 담아서 전달하고 dto로 받음
        return ResponseEntity.ok(Map.of(&quot;message&quot;, &quot;회원가입이 완료되었습니다.&quot;));
        //return ResponseEntity.ok(&quot;회원가입이 완료되었습니다.&quot;);
    }

    //로그인: JWT 적용 후, 응답 바디에 acessToken 포함
    @PostMapping(&quot;/login&quot;)
    public ResponseEntity&amp;lt;UserLoginResponse&amp;gt; login(@RequestBody UserLoginRequest request) {
        UserLoginResponse response = userService.login(request); //dto로 담아서 전달하고 dto로 받음
        return ResponseEntity.ok(response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 주석 처리한 코드처럼 단순 문자열로 응답을 반환했으나, 이후 프론트엔드에서 json 파싱 오류나서 json처럼 map으로 담아 보내도록 수정했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;DiaryController.java&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 설계대로 구현한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 userId를 쿼리 파라미터로 받는 식으로 구현했다가, 이후 JWT 적용 후 securityContext에 저장된 userId를 애노테이션이 자동으로 꺼내는 방식으로 수정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API에서 ? 뒤는 쿼리 파라미터이므로 주소로 매핑하지 않고 @RequestParam으로 받는다. 그 외에 글의 고유 id가 필요한 {id}부분은 주소로 매핑하고 @PathVariable로 설정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;request dto를 받을 때는 @RequestBody로 받는다. (json)&lt;/p&gt;
&lt;pre id=&quot;code_1773922981090&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package toyProj.myDiary.controller;
import ...
/*
    [일기 관련 API (RESTful API 설계)]

    캘린더 월별로 일기 등록한 날짜 조회
    GET     /api/diaries/calendar?userId=1&amp;amp;year=2026&amp;amp;month=3

    해당 날짜 별 일기 전체 목록 조회
    GET     /api/diaries?userId=1&amp;amp;date=2026-03-17

    일기 상세 조회
    GET     /api/diaries/{id}?userId=1

    일기 작성
    POST    /api/diaries?userId=1

    일기 수정
    PUT     /api/diaries/{id}?userId=1

    일기 삭제
    DELETE  /api/diaries/{id}?userId=1

    !) userId를 쿼리 파라미터로 받는 건 임시 구조
    -&amp;gt; JWT 적용 후, SecurityContext에서 자동으로 꺼내 쓸 예정
    -&amp;gt; @AuthenticationPrincipal: JwtAuthenticationFilter에서 SecurityContext에 저장한
        UsernamePasswordAuthenticationToken의 principal (= userId Long값) 을 꺼냄
 */
@RestController
@RequestMapping(&quot;/api/diaries&quot;)
@RequiredArgsConstructor //final 필드 생성자 자동 생성 (DI)
public class DiaryController {

    //서비스 로직 사용
    private final DiaryService diaryService;

    //캘린더 월별로 일기 등록한 날짜 조회
    @GetMapping(&quot;/calendar&quot;)
    public ResponseEntity&amp;lt;DiaryCalendarResponse&amp;gt; getCalendar(
            //@RequestParam Long userId,
            @AuthenticationPrincipal Long userId, //userId 자동 주입됨
            @RequestParam int year,
            @RequestParam int month) {
        return ResponseEntity.ok(diaryService.getCalendarDates(userId, year, month));
    }

    //해당 날짜 별 일기 전체 목록 조회
    @GetMapping
    public ResponseEntity&amp;lt;List&amp;lt;DiaryListResponse&amp;gt;&amp;gt; getDiariesByDate(
            //@RequestParam Long userId,
            @AuthenticationPrincipal Long userId,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
        return ResponseEntity.ok(diaryService.getDiariesByDate(userId, date));
    }

    //일기 상세 조회
    @GetMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;DiaryDetailResponse&amp;gt; getDiary(
            @PathVariable Long id,
            //@RequestParam Long userId
            @AuthenticationPrincipal Long userId) {
        return ResponseEntity.ok(diaryService.getDiary(userId, id));
    }

    //일기 작성
    @PostMapping
    public ResponseEntity&amp;lt;Long&amp;gt; create(
            //@RequestParam Long userId,
            @AuthenticationPrincipal Long userId,
            @RequestBody DiaryCreateRequest request) {
        Long diaryId = diaryService.create(userId, request);
        return ResponseEntity.ok(diaryId);
    }

    //일기 수정
    @PutMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt; update(
            @PathVariable Long id,
            //@RequestParam Long userId,
            @AuthenticationPrincipal Long userId,
            @RequestBody DiaryUpdateRequest request) {
        diaryService.update(userId, id, request);
        return ResponseEntity.ok(Map.of(&quot;message&quot;, &quot;수정되었습니다.&quot;));
        //return ResponseEntity.ok(&quot;수정되었습니다.&quot;);
    }

    //일기 삭제
    @DeleteMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt; delete(
            @PathVariable Long id,
            //@RequestParam Long userId
            @AuthenticationPrincipal Long userId) {
        diaryService.delete(userId, id);
        return ResponseEntity.ok(Map.of(&quot;message&quot;, &quot;삭제되었습니다.&quot;));
        //return ResponseEntity.ok(&quot;삭제되었습니다.&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security + JWT 정리하려고 쓴 건데 또 길어져서 이건 다음 글로...&lt;/p&gt;</description>
      <category> Project/myDiary - 일기 기록 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/78</guid>
      <comments>https://rim08.tistory.com/78#entry78comment</comments>
      <pubDate>Thu, 19 Mar 2026 21:38:26 +0900</pubDate>
    </item>
    <item>
      <title>[토이프로젝트 개발일지 01] 일기 기록 웹 설계</title>
      <link>https://rim08.tistory.com/77</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;우선 코드 작성하기 전에 개발 목적과 화면 설계부터 정리하고 시작!&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;047&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/047.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/047.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;1. 개발 목적&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;게시판 형식의 일기 기록 웹 사이트를 만들어서 직접 사용한다.&lt;/li&gt;
&lt;li&gt;로그인, 회원가입 기능을 만들고 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;인증/인가&lt;/b&gt;&lt;/span&gt; 구현 방법에 대해 자세히 학습한다. (Spring Security, JWT 등)&lt;/li&gt;
&lt;li&gt;RESTful API를 설계하고 구현한다.&lt;/li&gt;
&lt;li&gt;데이터베이스를 설계하고 구현한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #8a3db6;&quot;&gt;테스트 코드&lt;/span&gt;&lt;/b&gt;를 작성해 본다.&lt;/li&gt;
&lt;li&gt;최종적으로 AWS등을 이용해 &lt;b&gt;&lt;span style=&quot;color: #8a3db6;&quot;&gt;서버를 배포&lt;/span&gt;&lt;/b&gt;하는 방법을 학습한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그동안 여러 개인 프로젝트 /&amp;nbsp; 팀 프로젝트를 하면서 AI나 지도 등 외부 API 연동하는 방법, 기본적인 REST API와 데이터베이스 설계 및 구현 방법은 익혔지만, 인증/인가에 대해서는 정확히 구현해 본 적이 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 프론트엔드에서 로컬 스토리지로 저장해서 로그인 유지하는 것처럼 보이게 하는 방법으로 해서(서버는 로그인 모름) 보안도 취약하고 제대로 구현한 게 아니라고 느꼈다. 그래서 이번에는 제대로 구현해 보기!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 서버 배포도 그동안은 cloudType으로 간단하게 배포해 봤는데, 이번에는 가능하면 AWS를 사용해 보고 싶다. 배포에 관해서도 아직 지식이 부족해서 제대로 공부해 보고 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 테스트 코드 작성하는 것도 해보고 싶다. ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하여튼 그래서 공부용 + 평소에 필요했던 거 만들기로 해서 일기 웹 프로젝트로 결정~~&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;2. 기술 스택&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;BE: Java, Spring Boot, JPA&lt;/li&gt;
&lt;li&gt;FE: HTML, CSS, Javascript -&amp;gt; 같은 폴더에서 간단하게, 백엔드와 API 통신하는 식으로 구현&lt;/li&gt;
&lt;li&gt;Database: MariaDB(실제 DB, 배포용), H2(테스트 코드용)&lt;/li&gt;
&lt;li&gt;인증/인가: Spring Security, JWT&lt;/li&gt;
&lt;li&gt;배포: AWS - EC2 (스프링 서버 배포, 여기에 MariaDB 설치하는 식으로 함(RDS까지 배포하면 무료 사용량 넘칠까 봐))&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-그 외 요구 사항&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB는 변동될 수 있으니 우선 인터페이스로 놓고, 구체적으로는 MariaDB와 H2(단순 테스트용 메모리DB)로 바꾸어서 상황에 맞게 사용할 수 있도록 구현함.&lt;/li&gt;
&lt;li&gt;domain, controller, service, repository 계층으로 패키지 관리함.&lt;/li&gt;
&lt;li&gt;DTO로 필요한 정보만 담아 캡슐화, 보안성을 높이고 오버헤드를 줄이면서 효율적인 데이터 송수신 관리하도록 함. (컨트롤러에서 DTO로 받고 서비스로 넘긴 다음에 다시 DTO로 받음)&lt;/li&gt;
&lt;li&gt;설정 정보
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;application.yml: 기본 정보 작성 (깃허브 올려도 됨)&lt;/li&gt;
&lt;li&gt;application-local.yml: 데이터베이스 정보나 JWT 토큰 등 민감한 정보 작성 (gitignore로 깃허브 올라가지 않게 함)&lt;/li&gt;
&lt;li&gt;application-prod.yml: EC2 우분투 서버에만 존재하는 설정 파일. EC2에 설치한 MariaDB 정보 담음 -&amp;gt; 환경변수로 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;3. 화면 설계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 시작 화면 (로그인 화면)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;처음 웹 사이트 들어가면 화면 상단 중앙에 &amp;lsquo;My Diary&amp;rsquo; 로고가 크게 띄워지다가, 1초 후에 폰트 크기 작아지면서 상단으로 조금 올라가고 중앙에 로그인 UI 나타남.
&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;회원가입은 아이디, 비밀번호, 닉네임 입력. 비밀번호는 4자리 이상이며, 아이디는 다른 사용자와 중복 불가.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) 메인 화면 (로그인 성공 시 나오는 화면)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;화면 중앙에 캘린더 배치(다이어리 수첩 느낌 프론트 꾸미기) &amp;rarr; 오늘 날짜에 하늘색으로 표시됨&lt;/li&gt;
&lt;li&gt;캘린더에서 각 날짜 클릭하면 해당 날짜의 일기 화면으로 이동&lt;/li&gt;
&lt;li&gt;일기가 작성된 날짜엔 녹색으로 표시 (오늘 날짜인데 글 작성하면 하늘색이 우선됨)&lt;/li&gt;
&lt;li&gt;전체적인 테마는 하늘색, 녹색, 핑크색 등 파스텔 톤에 심플하면서 귀여운 테마&lt;/li&gt;
&lt;li&gt;화면 상단 중앙에는 &amp;lsquo;(사용자 닉네임) Diary&amp;rsquo;으로 제목 로고 있음. 상단 우측에는 &amp;lsquo;로그아웃&amp;rsquo;버튼 있음(누르면 로그아웃하고 로그인 화면으로 이동)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3) 일기 화면 (메인 화면의 캘린더 날짜 누르면 나오는 화면)&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;목록 상단 우측에는 &amp;lsquo;글쓰기&amp;rsquo; 버튼 있음. &amp;rarr; 누르면 글 작성 화면으로 이동&lt;/li&gt;
&lt;li&gt;각 글 누르면 상세 조회 화면으로 이동.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4) 글 작성/수정 화면&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;제목과 내용 입력하는 칸 있음. (칸 구분은 그냥 선으로만 함. (핸드폰 메모 앱 같은 UI)&lt;/li&gt;
&lt;li&gt;우측 상단에 &amp;lsquo;저장&amp;rsquo;버튼과 &amp;lsquo;취소&amp;rsquo;버튼 있음. (누르면 글 전체 조회 화면으로 이동)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5) 상세 조회 화면&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;우측 상단에 &amp;lsquo;수정&amp;rsquo; 버튼 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;4. 데이터베이스 설계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 요구사항 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면 설계 내용에 따라, 저장이 필요한 항목들만 추출하여 엔티티 속성으로 담는다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;diary 엔티티
&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;/li&gt;
&lt;li&gt;user 엔티티&lt;br /&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;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;☆처음 고민&lt;br /&gt;: 캘린더의 날짜 별로 일기를 저장해야 하니까, 그러면 캘린더 엔티티도 만들어서 따로 저장하고 연관 관계를 구성해야 하나?&lt;br /&gt;-&amp;gt; diary 엔티티에 날짜 정보를 넣은 다음에, 이후에 캘린더 날짜를 클릭하면 해당 날짜 글만 조회해서 갖고 오는 형식으로 하면 될 듯&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2)&lt;span&gt; 연관 관계&amp;nbsp;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;한 명의 사용자가 여러 일기를 작성하므로 연관 관계는 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;user : diary = 1: N&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;이때 N쪽이 연관 관계의 주인(수정 가능)이기 때문에 외래키(FK)를 갖는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 @ManyToOne이므로 기본 즉시 로딩 패치 전략에서 Lazy 지연 로딩 설정으로 바꾼다. (추후 N+1 문제 대비)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대쪽은 코드 상에서 mapped by를 통해 연결한다. (@OneToMany)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;&lt;span&gt;3) 최종 DB 설계&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 33.3708%; height: 136px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 17.907%; height: 17px; text-align: center;&quot;&gt;[diary]&lt;/td&gt;
&lt;td style=&quot;width: 15.4651%; height: 17px; text-align: center;&quot;&gt;[user]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 17.907%; height: 17px; text-align: center;&quot;&gt;id (PK)&lt;/td&gt;
&lt;td style=&quot;width: 15.4651%; height: 17px; text-align: center;&quot;&gt;id (PK)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 17.907%; height: 17px; text-align: center;&quot;&gt;title&lt;/td&gt;
&lt;td style=&quot;width: 15.4651%; height: 17px; text-align: center;&quot;&gt;loginId&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 17.907%; height: 17px; text-align: center;&quot;&gt;content&lt;/td&gt;
&lt;td style=&quot;width: 15.4651%; height: 17px; text-align: center;&quot;&gt;password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 17.907%; height: 17px; text-align: center;&quot;&gt;createdAt&lt;/td&gt;
&lt;td style=&quot;width: 15.4651%; height: 17px; text-align: center;&quot;&gt;nickname&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 17.907%; height: 17px; text-align: center;&quot;&gt;updatedAt&lt;/td&gt;
&lt;td style=&quot;width: 15.4651%; height: 17px; text-align: center;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 17.907%; height: 17px; text-align: center;&quot;&gt;diaryDate&lt;/td&gt;
&lt;td style=&quot;width: 15.4651%; height: 17px; text-align: center;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 17.907%; height: 17px; text-align: center;&quot;&gt;userId (FK)&lt;/td&gt;
&lt;td style=&quot;width: 15.4651%; height: 17px; text-align: center;&quot;&gt;&amp;nbsp;&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;713&quot; data-origin-height=&quot;373&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xP9wp/dJMcaaq4Nml/OxztqgSX3guqI6x6PgLt0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xP9wp/dJMcaaq4Nml/OxztqgSX3guqI6x6PgLt0K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xP9wp/dJMcaaq4Nml/OxztqgSX3guqI6x6PgLt0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxP9wp%2FdJMcaaq4Nml%2FOxztqgSX3guqI6x6PgLt0K%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;652&quot; height=&quot;341&quot; data-origin-width=&quot;713&quot; data-origin-height=&quot;373&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ ERD 그리는 사이트 (MySQL Workbench에서 create문 추출해서 했음)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dbdiagram.io/home&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dbdiagram.io/home&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1773915344878&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;dbdiagram.io - Database Relationship Diagrams Design Tool&quot; data-og-description=&quot;&quot; data-og-host=&quot;dbdiagram.io&quot; data-og-source-url=&quot;https://dbdiagram.io/home&quot; data-og-url=&quot;https://dbdiagram.io/home&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bcy8qW/dJMb9lMaCxF/mutPiTWFsp6enCstOTt4jk/img.png?width=2003&amp;amp;height=1054&amp;amp;face=0_0_2003_1054,https://scrap.kakaocdn.net/dn/4aV15/dJMb9lk6ett/KTqiZ6e0iTwJewrj47NkX1/img.png?width=2003&amp;amp;height=1054&amp;amp;face=0_0_2003_1054&quot;&gt;&lt;a href=&quot;https://dbdiagram.io/home&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dbdiagram.io/home&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bcy8qW/dJMb9lMaCxF/mutPiTWFsp6enCstOTt4jk/img.png?width=2003&amp;amp;height=1054&amp;amp;face=0_0_2003_1054,https://scrap.kakaocdn.net/dn/4aV15/dJMb9lk6ett/KTqiZ6e0iTwJewrj47NkX1/img.png?width=2003&amp;amp;height=1054&amp;amp;face=0_0_2003_1054');&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;dbdiagram.io - Database Relationship Diagrams Design Tool&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dbdiagram.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 아키텍처 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-전체 흐름&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;myDiary-draw2.png&quot; data-origin-width=&quot;450&quot; data-origin-height=&quot;481&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dperNo/dJMcaiikicG/UKYC0bQ3jF9NOIbFYTvtYk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dperNo/dJMcaiikicG/UKYC0bQ3jF9NOIbFYTvtYk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dperNo/dJMcaiikicG/UKYC0bQ3jF9NOIbFYTvtYk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdperNo%2FdJMcaiikicG%2FUKYC0bQ3jF9NOIbFYTvtYk%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;389&quot; height=&quot;416&quot; data-filename=&quot;myDiary-draw2.png&quot; data-origin-width=&quot;450&quot; data-origin-height=&quot;481&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-애플리케이션 아키텍처&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;myDiary-draw1.png&quot; data-origin-width=&quot;650&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wwnjX/dJMcaiJmyFv/oA1dUk15RIbceLQiWuwDv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wwnjX/dJMcaiJmyFv/oA1dUk15RIbceLQiWuwDv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wwnjX/dJMcaiJmyFv/oA1dUk15RIbceLQiWuwDv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwwnjX%2FdJMcaiJmyFv%2FoA1dUk15RIbceLQiWuwDv1%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;557&quot; height=&quot;343&quot; data-filename=&quot;myDiary-draw1.png&quot; data-origin-width=&quot;650&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ 표 그리는 사이트 (회로 설계 전공 과제에서 엄청 썼던 곳...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://app.diagrams.net/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://app.diagrams.net/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1773915446811&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Flowchart Maker &amp;amp; Online Diagram Software&quot; data-og-description=&quot;Flowchart Maker and Online Diagram Software draw.io is free online diagram software. You can use it as a flowchart maker, network diagram software, to create UML online, as an ER diagram tool, to design database schema, to build BPMN online, as a circuit d&quot; data-og-host=&quot;app.diagrams.net&quot; data-og-source-url=&quot;https://app.diagrams.net/&quot; data-og-url=&quot;https://app.diagrams.net/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://app.diagrams.net/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://app.diagrams.net/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;Flowchart Maker &amp;amp; Online Diagram Software&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Flowchart Maker and Online Diagram Software draw.io is free online diagram software. You can use it as a flowchart maker, network diagram software, to create UML online, as an ER diagram tool, to design database schema, to build BPMN online, as a circuit d&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;app.diagrams.net&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-spring security 아키텍처 (이건 인터넷 표이지만 이대로 흐름 따라감)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;858&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bS3N0o/dJMcabXOJlK/aYDKT70uj2n0YVoaVZZlF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bS3N0o/dJMcabXOJlK/aYDKT70uj2n0YVoaVZZlF0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bS3N0o/dJMcabXOJlK/aYDKT70uj2n0YVoaVZZlF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbS3N0o%2FdJMcabXOJlK%2FaYDKT70uj2n0YVoaVZZlF0%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;626&quot; height=&quot;420&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;858&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;1. 로그인 요청&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;darr;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;UserController&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;darr;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;UserService&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;darr;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;JWT 토큰 생성&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;darr;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;응답 바디(DTO)에 토큰 반환&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;2. 이후 모든 API 요청&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;darr;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;HTTP Header: Authorization: Bearer {token}&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;darr;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;JwtFilter (OncePerRequestFilter)&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;-토큰 유효성 검증한 다음에 SecurityContext에 인증 정보 저장&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;darr;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;Controller&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;-@AuthenticationPrincipal로 userId 꺼내 씀&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;(쿼리 파라미터 대신에, SecurityContext에 저장해두었으니 필요할 때마다 꺼내 쓸 수 있음)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. API 설계 / 명세서&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgsL6p/dJMb99TdTg9/zzAzSKkRWOqAU2Zcqx8fm1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgsL6p/dJMb99TdTg9/zzAzSKkRWOqAU2Zcqx8fm1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgsL6p/dJMb99TdTg9/zzAzSKkRWOqAU2Zcqx8fm1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgsL6p%2FdJMb99TdTg9%2FzzAzSKkRWOqAU2Zcqx8fm1%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;561&quot; height=&quot;191&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;270&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;956&quot; data-origin-height=&quot;844&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EOzxq/dJMcafFVatv/H9nm5aMdq2YvaZSHq8Vfz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EOzxq/dJMcafFVatv/H9nm5aMdq2YvaZSHq8Vfz0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EOzxq/dJMcafFVatv/H9nm5aMdq2YvaZSHq8Vfz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEOzxq%2FdJMcafFVatv%2FH9nm5aMdq2YvaZSHq8Vfz0%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;717&quot; height=&quot;633&quot; data-origin-width=&quot;956&quot; data-origin-height=&quot;844&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노션에서 아래 사진처럼 각 기능마다 request / response를 명세화했다. ㅎㅎ&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1612&quot; data-origin-height=&quot;895&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ddsCAX/dJMcaiP7YpF/0L637R3IRkqFRmmS0hZRpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ddsCAX/dJMcaiP7YpF/0L637R3IRkqFRmmS0hZRpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ddsCAX/dJMcaiP7YpF/0L637R3IRkqFRmmS0hZRpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FddsCAX%2FdJMcaiP7YpF%2F0L637R3IRkqFRmmS0hZRpK%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;712&quot; height=&quot;395&quot; data-origin-width=&quot;1612&quot; data-origin-height=&quot;895&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1) 사용자 API&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-회원가입 / 로그인&lt;/p&gt;
&lt;p&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/cegDBM/dJMb99TdWyg/08GvNsDzkseDoktaBQvKeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cegDBM/dJMb99TdWyg/08GvNsDzkseDoktaBQvKeK/img.png&quot; data-origin-width=&quot;547&quot; data-origin-height=&quot;421&quot; data-is-animation=&quot;false&quot; width=&quot;407&quot; height=&quot;313&quot; style=&quot;width: 50.8453%; margin-right: 10px;&quot; data-widthpercent=&quot;51.44&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cegDBM/dJMb99TdWyg/08GvNsDzkseDoktaBQvKeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcegDBM%2FdJMb99TdWyg%2F08GvNsDzkseDoktaBQvKeK%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;547&quot; height=&quot;421&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nstNe/dJMcagrhnEr/worNSalHk4hD6hHgztOH0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nstNe/dJMcagrhnEr/worNSalHk4hD6hHgztOH0K/img.png&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;455&quot; data-is-animation=&quot;false&quot; width=&quot;421&quot; height=&quot;343&quot; style=&quot;width: 47.9919%;&quot; data-widthpercent=&quot;48.56&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nstNe/dJMcagrhnEr/worNSalHk4hD6hHgztOH0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnstNe%2FdJMcagrhnEr%2FworNSalHk4hD6hHgztOH0K%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;558&quot; height=&quot;455&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;☆처음엔 Map으로 안 하고 그냥 String으로 문자열만 반환했었는데, 그렇게 하니까 프론트엔드에서 json으로 처리하려고 했는데 단순 문자열이라 파싱 오류나서 아래처럼 오류가 났었다.&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1095&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ciBl1p/dJMcadg1NLP/bHN7vtbSozRkdSJSKLBUs0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ciBl1p/dJMcadg1NLP/bHN7vtbSozRkdSJSKLBUs0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ciBl1p/dJMcadg1NLP/bHN7vtbSozRkdSJSKLBUs0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FciBl1p%2FdJMcadg1NLP%2FbHN7vtbSozRkdSJSKLBUs0%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;501&quot; height=&quot;173&quot; data-origin-width=&quot;1095&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
그래서 Map을 이용해서 json처럼 반환하는 걸로 수정한 상태이다. (이에 대한 자세한 설명은 이후에~~)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2) 글 API&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-월별 글 등록 날짜 조회 / 날짜별 글 전체 목록 조회&lt;/p&gt;
&lt;p&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/bwe2c9/dJMcabKjrKI/GpfkjTxTdRIRImXPBks65k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwe2c9/dJMcabKjrKI/GpfkjTxTdRIRImXPBks65k/img.png&quot; data-origin-width=&quot;428&quot; data-origin-height=&quot;338&quot; data-is-animation=&quot;false&quot; width=&quot;317&quot; height=&quot;250&quot; style=&quot;width: 52.187%; margin-right: 10px;&quot; data-widthpercent=&quot;52.8&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwe2c9/dJMcabKjrKI/GpfkjTxTdRIRImXPBks65k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbwe2c9%2FdJMcabKjrKI%2FGpfkjTxTdRIRImXPBks65k%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;428&quot; height=&quot;338&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c5aWkU/dJMcabjeQuQ/fK4dd9IcoHvh0L21KPmsLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c5aWkU/dJMcabjeQuQ/fK4dd9IcoHvh0L21KPmsLk/img.png&quot; data-origin-width=&quot;429&quot; data-origin-height=&quot;379&quot; data-is-animation=&quot;false&quot; width=&quot;301&quot; height=&quot;266&quot; style=&quot;width: 46.6502%;&quot; data-widthpercent=&quot;47.2&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c5aWkU/dJMcabjeQuQ/fK4dd9IcoHvh0L21KPmsLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc5aWkU%2FdJMcabjeQuQ%2FfK4dd9IcoHvh0L21KPmsLk%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;429&quot; height=&quot;379&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-글 상세 조회 / 글 작성&lt;/p&gt;
&lt;p&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/dza9wl/dJMcacblR42/5zqW1kkLtKkt7rDJlsChl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dza9wl/dJMcacblR42/5zqW1kkLtKkt7rDJlsChl0/img.png&quot; data-origin-width=&quot;541&quot; data-origin-height=&quot;461&quot; data-is-animation=&quot;false&quot; style=&quot;width: 41.7857%; margin-right: 10px;&quot; data-widthpercent=&quot;42.28&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dza9wl/dJMcacblR42/5zqW1kkLtKkt7rDJlsChl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdza9wl%2FdJMcacblR42%2F5zqW1kkLtKkt7rDJlsChl0%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;541&quot; height=&quot;461&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mcaEN/dJMcabKjrL1/L5aOrhU8HIwP5dvc3bhSCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mcaEN/dJMcabKjrL1/L5aOrhU8HIwP5dvc3bhSCK/img.png&quot; data-origin-width=&quot;564&quot; data-origin-height=&quot;352&quot; data-is-animation=&quot;false&quot; style=&quot;width: 57.0515%;&quot; data-widthpercent=&quot;57.72&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mcaEN/dJMcabKjrL1/L5aOrhU8HIwP5dvc3bhSCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmcaEN%2FdJMcabKjrL1%2FL5aOrhU8HIwP5dvc3bhSCK%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;564&quot; height=&quot;352&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-글 수정 / 삭제&lt;/p&gt;
&lt;p&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/cuuwI6/dJMcacoTeiV/fSzyrOpX51kU2U9b2q7kEK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cuuwI6/dJMcacoTeiV/fSzyrOpX51kU2U9b2q7kEK/img.png&quot; data-origin-width=&quot;401&quot; data-origin-height=&quot;354&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.2083%; margin-right: 10px;&quot; data-widthpercent=&quot;49.79&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cuuwI6/dJMcacoTeiV/fSzyrOpX51kU2U9b2q7kEK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcuuwI6%2FdJMcacoTeiV%2FfSzyrOpX51kU2U9b2q7kEK%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;401&quot; height=&quot;354&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SLw7x/dJMcafMIczQ/G8YzLaCIui99wiXirwHPrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SLw7x/dJMcafMIczQ/G8YzLaCIui99wiXirwHPrk/img.png&quot; data-origin-width=&quot;401&quot; data-origin-height=&quot;351&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.6289%;&quot; data-widthpercent=&quot;50.21&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SLw7x/dJMcafMIczQ/G8YzLaCIui99wiXirwHPrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSLw7x%2FdJMcafMIczQ%2FG8YzLaCIui99wiXirwHPrk%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;401&quot; height=&quot;351&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 프로젝트 세팅&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Spring의 복잡한 환경 설정을 간단하게 자동으로 해 주는 Spring Boot를 사용하기 위해 아래 사이트에서 파일을 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://start.spring.io/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://start.spring.io&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-17 181442.png&quot; data-origin-width=&quot;1679&quot; data-origin-height=&quot;852&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ebFIbR/dJMcaaECOO5/wPF6zr89KkdkpFuvNILhp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ebFIbR/dJMcaaECOO5/wPF6zr89KkdkpFuvNILhp0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ebFIbR/dJMcaaECOO5/wPF6zr89KkdkpFuvNILhp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FebFIbR%2FdJMcaaECOO5%2FwPF6zr89KkdkpFuvNILhp0%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;760&quot; height=&quot;386&quot; data-filename=&quot;스크린샷 2026-03-17 181442.png&quot; data-origin-width=&quot;1679&quot; data-origin-height=&quot;852&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;▣ 일단 우측 의존성 추가에 신경 쓰느라 지금 봤는데 좌측에 Configuration을 선택하는 게 있었다... 이후에 코드에서 Yml로 바꿨는데 미리 선택할 수 있는 것 같다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;그리고 최신 버전으로 4.0.3 기본으로 선택했는데, 나중에 AWS에서 EC2로 배포할 때 jackson 파일을 사용하는 과정에서 오류가 발생했다. 그게 스프링부트 버전이 높아서 내리면 괜찮다고 하는데, 이미 코드를 작성해 놓은 상태라 혹시 꼬일까 봐 우선 EC2에선 jackson 관련한 yml 설정을 지우는 걸로 했다... 그래도 다행히 프론트엔드에서 파싱 오류가 나진 않아서 괜찮았는데, 추후에 더 테스트 해 보고 방법을 결정해야겠다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성을 추가한 다음에 파일을 생성하고, 인텔리제이에서 해당 프로젝트 폴더의 build.gradle 파일을 선택하면 프로젝트가 열린다. 여기에 JWT와 H2 관련한 의존성을 더 추가한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;build.gradle&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773900279836&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
	id 'java'
	id 'org.springframework.boot' version '4.0.3'
	id 'io.spring.dependency-management' version '1.1.7'
}

group = 'toyProj'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    
	//JWT (jjwt 라이브러리)
	implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
	runtimeOnly    'io.jsonwebtoken:jjwt-impl:0.12.3'
	runtimeOnly    'io.jsonwebtoken:jjwt-jackson:0.12.3'

	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
	annotationProcessor 'org.projectlombok:lombok'
    
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    
	//H2 인메모리 DB - Repository 테스트용
	testRuntimeOnly 'com.h2database:h2'
}

tasks.named('test') {
	useJUnitPlatform()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yml 관해서도 작성한다. (처음엔 여기에 DB와 JWT까지 다 적었다가 깃허브 올릴 때 따로 분리했다.)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;application.yml&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773900417830&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;server:
  port: 8080

spring:
  profiles:
    active: local

  jpa:
    hibernate:
      ddl-auto: update  #개발 중엔 update, 운영에는 validate
    show-sql: true
    properties:
      hibernate:
        format_sql: true

    #LocalDate / LocalDateTime JSON 직렬화 설정
    #이게 없으면 날짜가 배열([2026, 3, 17])로 내려와서 프론트에서 파싱 오류남
    jackson:
      serialization:
        write-dates-as-timestamps: false
      time-zone: Asia/Seoul&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application-local.yml을 같이 실행하기 위해서는 여기에 profiles: active: local을 꼭 적어줘야 한다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;EC2에선 오류나는 jackson 라인을 다 없앴는데 그래도 파싱 오류는 나지 않았다. 아마 프론트에서 따로 코드를 추가했었는데 그 덕분에 괜찮은 것 같다. 아니면 springBoot에서 자동으로 해 줘서 괜찮거나 아직 테스트가 부족해서 오류를 못 잡은 거일 수도 있는데 이 부분은 더 공부를 해야겠다...&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cmd에서 MySQL DB를 생성, 계정과 권한 설정한 걸 토대로 local yml에 그 정보를 적어준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jwt secret에는 토큰 (마음대로 설정, 256bit 이상)과 만료 시간을 적어준다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;cmd&lt;/span&gt;&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773901295754&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;mysql -u -root -p
(비밀번호 입력)

//DB 생성
CREATE DATABASE (DB 이름 입력);

//계정 생성 (예전에 다른 DB에서 쓴 계정이라도 이름과 host가 똑같으면 오류남. DB명대로 새로 만드는 게 나음)
CREATE USER '(user명)'@'localhost' IDENTIFIED BY '(비밀번호)';

//계정에 권한 부여
GRANT ALL PRIVILEGES ON (DB명).* TO '(user명)'@'localhost';

//저장
FLUSH PRIVILEGES;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;application-local.yml&lt;/blockquote&gt;
&lt;pre id=&quot;code_1773900749579&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#절대 깃허브에 올리면 안 됨 -&amp;gt; gitignore에 파일명 추가
spring:
  datasource:
    url: jdbc:mariadb://localhost:3306/____
    username: ____
    password: ____
    driver-class-name: org.mariadb.jdbc.Driver

jwt:
  secret: &quot;_____________________________________&quot; #256bit 이상 필요
  expiration: 86400000 #24시간 (ms 단위)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 하면 설정은 끝났고, 이제 코드 작성을 위해 패키지와 파일을 구성한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인텔리제이에서 아래와 같이 패키지 구조를 설계했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 domain, controller, service, repository 계층으로 나눠지고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 사이에서 데이터를 송수신할 dto,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증/인가를 위한 security,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외처리 통합을 위해 exception 패키지를 추가했다. (&amp;lt;-이거 안 하면 백엔드에서 예외처리 넘겨도 프론트엔드에서 단순히 500에러라고만 받아서 뭔 내용인지 자세히 모르기 때문에, 정확히 400 ~(메시지 오류 내용)등을 받을 수 있도록 코드를 추가했다.)&lt;/p&gt;
&lt;p&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/bmBVWR/dJMcadnKVWk/HzhhMpHY3P1XaULYqY5Y41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmBVWR/dJMcadnKVWk/HzhhMpHY3P1XaULYqY5Y41/img.png&quot; data-origin-width=&quot;741&quot; data-origin-height=&quot;870&quot; data-is-animation=&quot;false&quot; width=&quot;515&quot; height=&quot;605&quot; style=&quot;width: 59.5493%; margin-right: 10px;&quot; data-widthpercent=&quot;60.25&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmBVWR/dJMcadnKVWk/HzhhMpHY3P1XaULYqY5Y41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmBVWR%2FdJMcadnKVWk%2FHzhhMpHY3P1XaULYqY5Y41%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;741&quot; height=&quot;870&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3WbTh/dJMb99MqYbM/Kk0QyD4bmp5CD1eHDXvLOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3WbTh/dJMb99MqYbM/Kk0QyD4bmp5CD1eHDXvLOk/img.png&quot; data-origin-width=&quot;431&quot; data-origin-height=&quot;767&quot; data-is-animation=&quot;false&quot; style=&quot;width: 39.2879%;&quot; data-widthpercent=&quot;39.75&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3WbTh/dJMb99MqYbM/Kk0QyD4bmp5CD1eHDXvLOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3WbTh%2FdJMb99MqYbM%2FKk0QyD4bmp5CD1eHDXvLOk%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;431&quot; height=&quot;767&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이후에 추가한 test 파일들은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;test 코드를 작성할 땐 본 파일 구조와 똑같이 해야 자동으로 그에 맞게 테스트를 돌려주니, 여기에서도 동일하게 패키지 구조를 따랐다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;339&quot; data-origin-height=&quot;416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kVTy7/dJMcahX1Cv1/yoz5M2m90mcVnK14BFjF7k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kVTy7/dJMcahX1Cv1/yoz5M2m90mcVnK14BFjF7k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kVTy7/dJMcahX1Cv1/yoz5M2m90mcVnK14BFjF7k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkVTy7%2FdJMcahX1Cv1%2Fyoz5M2m90mcVnK14BFjF7k%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;292&quot; height=&quot;358&quot; data-origin-width=&quot;339&quot; data-origin-height=&quot;416&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내용이 길어져서 코드 작성은 그다음 글로~.~&lt;/p&gt;</description>
      <category> Project/myDiary - 일기 기록 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/77</guid>
      <comments>https://rim08.tistory.com/77#entry77comment</comments>
      <pubDate>Thu, 19 Mar 2026 19:09:47 +0900</pubDate>
    </item>
    <item>
      <title>[토이프로젝트 개발일지] 약 관리+AI 진단+병원 지도 웹</title>
      <link>https://rim08.tistory.com/76</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;정말 오랜만에 쓰는데... 그동안 졸작했다가 며칠전에는 정처기 필기 봤다가... 이제 남은 시간동안 뭐할까 고민하다가 개인 프로젝트 더 하기로 함 ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 바로 시작!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;01. 구상&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 집에 무슨 약이 있고 소비기한 얼마 남았는지 몰라서 맨날 찾아봐야 됨 -&amp;gt; 약 관리 기능 (CRUD 구성)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 증상이 나타날 때 집에 있는 약 중에서 무얼 어떻게 복용해야 하는지, 그 외에 필요한 약이나 영양제, 병원 등이 무엇이 있는지, 증상의 원인이나 치료법 등이 궁금함 -&amp;gt; 현재 저장된 약 DB + AI LLM 모델 연결해서 자동 진단 추천 기능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 현재 위치 근처에 어떤 병원들이 있는지 -&amp;gt; 카카오 지도 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;위 3가지 기능들을 구현할 것이다. 따라서 페이지도 아래처럼 나뉜다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 메인 페이지: 약 전체 조회 (소비기한 임박순으로 정렬, 웹과 모바일 각각 반응형으로 맞게 그리드로 조절)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1-2. 상세 조회 페이지: 약 상세 정보 조회 (사진, 이름, 소비기한, 남은 수량, 복용법)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1-3. 새 약 페이지: 이름, 기한, 개수, 복용법, 이미지 url 작성 후 등록&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;+삭제는 경고 알림만 띄우기, 수정과 삭제는 비밀번호 검증 후 가능(관리자 권한), 그 외는 여러 사용자가 다 이용 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 진단 페이지: 텍스트폼에 사용자가 증상을 입력 -&amp;gt; 그 아래 AI 진단 결과가 출력&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 병원 페이지: 카카오 지도가 화면에 띄워지고 현재 위치 기반 주변 병원 정보 마커로 표시&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;02. 기술 스택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 늘 그랬듯이 똑같이 사용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드: Spring Boot, MariaDB&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드: html, css, js (한 프로젝트 내에서 간단하게만 구성)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포: cloudType 프리티어&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;전체 구성&lt;/blockquote&gt;
&lt;p&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/d8CK3R/btsPS7aCger/bwXyyI3CtZCvjKUkJgBBg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d8CK3R/btsPS7aCger/bwXyyI3CtZCvjKUkJgBBg0/img.png&quot; data-origin-width=&quot;389&quot; data-origin-height=&quot;693&quot; data-is-animation=&quot;false&quot; style=&quot;width: 47.8389%; margin-right: 10px;&quot; data-widthpercent=&quot;48.4&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d8CK3R/btsPS7aCger/bwXyyI3CtZCvjKUkJgBBg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd8CK3R%2FbtsPS7aCger%2FbwXyyI3CtZCvjKUkJgBBg0%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;389&quot; height=&quot;693&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzV4Em/btsPS2Aq2K7/HJKsnrxSiFGI2oEizVlmz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzV4Em/btsPS2Aq2K7/HJKsnrxSiFGI2oEizVlmz1/img.png&quot; data-origin-width=&quot;374&quot; data-origin-height=&quot;625&quot; data-is-animation=&quot;false&quot; style=&quot;width: 50.9983%;&quot; data-widthpercent=&quot;51.6&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzV4Em/btsPS2Aq2K7/HJKsnrxSiFGI2oEizVlmz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzV4Em%2FbtsPS2Aq2K7%2FHJKsnrxSiFGI2oEizVlmz1%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;374&quot; height=&quot;625&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크게 약관리 + 진단 기능으로 나누었다. 지도는 백엔드가 필요 없으므로 프론트엔드에서만 작성하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;build.gradle&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755145441037&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
	id 'java'
	id 'org.springframework.boot' version '3.5.4'
	id 'io.spring.dependency-management' version '1.1.7'
}

group = 'DoctorHome'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	// JSON 파싱용 Jackson
	implementation 'com.fasterxml.jackson.core:jackson-databind'
	implementation 'io.github.cdimascio:java-dotenv:5.2.2'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
	useJUnitPlatform()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성으로 필요한 것들 넣었는데 여기서 json 파싱은 LLM 모델 답변 파싱할 때 사용하려고 한 거&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 그냥 진단 전체 결과를 가져오는 게 더 나아서 이거 안 쓴 듯&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 그 아래 dotenv는 API 키들을 환경변수 .env 파일에 넣고 이건 git에서 숨김처리하려고 넣은 거였는데 이것도 비공개 레포지토리로 하면서 키를 그냥 입력했기 때문에 필요없어졌음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;03. 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에서 고민이 많았는데, 처음에는 공개 레포지토리로 해서 주요 키나 비밀번호 등을 다 변수 처리했었다. 그런데 이번 프로젝트는 기능 구현에 초점을 두어서 비공개 레포지토리로 바꾼 후 키들을 아래에 다 써주었다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;-주소&lt;br /&gt;1. 로컬 서버: localhost:3306&lt;br /&gt;2. 배포 서버: 클라우드타입에서 mariaDB 배포 주소 그대로 적으면 됨&lt;br /&gt;&lt;br /&gt;-이름과 비밀번호도 로컬에선 로컬로 설정한대로, 배포에서는 배포에서 적용한 것대로(주로 root)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;application.yml&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755146660518&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;gemini:
  api:
    key: (키)

spring:
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher
  datasource:
    url: jdbc:mariadb://(주소)/DoctorHome
    username: (이름)
    password: (비밀번호)
    driver-class-name: org.mariadb.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MariaDBDialect
        format_sql: true

  jackson:
    serialization:
      indent_output: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ddl-update는 개발 단계에서는 create로 하고 CRUD 기능 구현 완료 후에는 update로 바꾸었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;AppConfig&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스에서 모델과 통신할 때 restTemplate를 이용했는데, 이를 위해서는 Appconfig 파일을 추가해서 빈 등록을 해주어야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1755146960933&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class AppConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;04. 도메인 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 크게 연관관계 설정이 필요없기 때문에 한 테이블로만 구성하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;407&quot; data-origin-height=&quot;334&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ElId5/btsPSZQ4QzB/aNfSShJA0TrcreHosSPfyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ElId5/btsPSZQ4QzB/aNfSShJA0TrcreHosSPfyk/img.png&quot; data-alt=&quot;소비기한, 수량, 복용법(처음에 그냥 usage로 했다가 예약어랑 겹쳐서 오류나가지고 수정함...), 이미지 링크, 약 이름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ElId5/btsPSZQ4QzB/aNfSShJA0TrcreHosSPfyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FElId5%2FbtsPSZQ4QzB%2FaNfSShJA0TrcreHosSPfyk%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;375&quot; height=&quot;308&quot; data-origin-width=&quot;407&quot; data-origin-height=&quot;334&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;소비기한, 수량, 복용법(처음에 그냥 usage로 했다가 예약어랑 겹쳐서 오류나가지고 수정함...), 이미지 링크, 약 이름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Medicine.java&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755146210456&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Medicine {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private LocalDate expiryDate;
    private int quantity;

    @Column(length = 500)
    private String medicine_usage; //복용법

    private String imageUrl;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;05. 코드 작성 - 백엔드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이후부터는 순서대로 코드를 작성하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 약 관리 기능인 CRUD부터 만들면 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;controller&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755147168878&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/api/medicines&quot;)
@RequiredArgsConstructor
public class MedicineController {

    private final MedicineService medicineService;

    /* 전체 조회 */
    @GetMapping
    public List&amp;lt;Medicine&amp;gt; getAll(
            @RequestParam(required = false) String search, //약 검색 기능
            @RequestParam(required = false) String sort //소비기한 순 정렬
    ) {
        return medicineService.findAllWithSearchAndSort(search, sort);
    }


    /* ID 조회 */
    @GetMapping(&quot;/{id}&quot;)
    public Medicine getById(@PathVariable Long id) {
        return medicineService.findById(id);
    }

    /* 등록 */
    @PostMapping
    public ResponseEntity&amp;lt;Medicine&amp;gt; create(@RequestBody MedicineRequest request) {
        Medicine saved = medicineService.save(request);
        return ResponseEntity.ok(saved);
    }

    /* 수정 */
    @PutMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;Medicine&amp;gt; update(
            @PathVariable Long id,
            @RequestBody MedicineRequest request
    ) {
        Medicine updated = medicineService.update(id, request);
        return ResponseEntity.ok(updated);
    }

    /* 삭제 */
    @DeleteMapping(&quot;/{id}&quot;)
    public ResponseEntity&amp;lt;Void&amp;gt; delete(@PathVariable Long id) {
        medicineService.delete(id);
        return ResponseEntity.noContent().build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것도 처음에는 이미지 업데이트 때문에 멀티파츠로 작성했다가 너무 복잡해져서 링크만 업데이트하는 것으로 하고 위처럼 변수로만 받도록 바꾸었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;service&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755147299088&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class MedicineService {

    private final MedicineRepository medicineRepository;

    /* 전체 조회 */
    public List&amp;lt;Medicine&amp;gt; findAll() {
        return medicineRepository.findAll();
    }

    public List&amp;lt;Medicine&amp;gt; findAllWithSearchAndSort(String search, String sort) {
        List&amp;lt;Medicine&amp;gt; medicines;

		//검색
        if (search == null || search.isEmpty()) {
            medicines = medicineRepository.findAll();
        } else {
            medicines = medicineRepository.findByNameContainingIgnoreCase(search);
        }

        //소비기한 임박순 정렬
        if (&quot;expiry&quot;.equals(sort)) {
            medicines.sort((a, b) -&amp;gt; {
                if (a.getExpiryDate() == null) return 1;
                if (b.getExpiryDate() == null) return -1;
                return a.getExpiryDate().compareTo(b.getExpiryDate());
            });
        }

        return medicines;
    }

    /* ID 조회 */
    public Medicine findById(Long id) {
        return medicineRepository.findById(id)
                .orElseThrow(() -&amp;gt; new RuntimeException(&quot;약을 찾을 수 없습니다. id=&quot; + id));
    }

    /* 등록 */
    public Medicine save(MedicineRequest request) {
        Medicine medicine = Medicine.builder()
                .name(request.getName())
                .expiryDate(request.getExpiryDate() != null ? request.getExpiryDate() : LocalDate.now())
                .quantity(request.getQuantity())
                .medicine_usage(request.getMedicine_usage())
                .imageUrl(request.getImageUrl())
                .build();

        return medicineRepository.save(medicine);
    }

    /* 수정 */
    public Medicine update(Long id, MedicineRequest request) {
        Medicine medicine = findById(id);
        medicine.setName(request.getName());
        medicine.setExpiryDate(request.getExpiryDate() != null ? request.getExpiryDate() : LocalDate.now());
        medicine.setQuantity(request.getQuantity());
        medicine.setMedicine_usage(request.getMedicine_usage());
        medicine.setImageUrl(request.getImageUrl());

        return medicineRepository.save(medicine);
    }

    /* 삭제 */
    public void delete(Long id) {
        medicineRepository.deleteById(id);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 약 관리 CRUD 기능은 구현 완료된다. 게시판 CRUD 작성을 공부한게 도움이 되었던 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음은 핵심 기능인 AI 진단 기능에 관한 백엔드 코드이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;controller&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755147610930&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/api/diagnosis&quot;)
@RequiredArgsConstructor
public class DiagnosisController {

    private final DiagnosisService diagnosisService;
    private final MedicineService medicineService; //현재 약 정보 넘기기

    @PostMapping
    public ResponseEntity&amp;lt;String&amp;gt; diagnose(@RequestBody DiagnosisRequest request) {
        //현재 등록된 약 이름만 추출
        List&amp;lt;String&amp;gt; registeredMeds = medicineService.findAll()
                .stream()
                .map(Medicine::getName)
                .collect(Collectors.toList());

        //AI 진단 요청 (증상 + 등록된 약)
        String result = diagnosisService.diagnoseWithMedicines(request.getSymptoms(), registeredMeds);

        return ResponseEntity.ok(result);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 등록한 약 정보들을 ai한테 넘겨야 무얼 먹어야 하는지 추천해줄 수 있으므로, 아까 만들었던 서비스에서 이름만 추출하여 진단 서비스로 넘긴다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;service&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 아까 등록했던 restTemplate을 가져오고, Value로 yml에 등록했던 키를 가져온다.&lt;/p&gt;
&lt;pre id=&quot;code_1755147896646&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private final RestTemplate restTemplate;

    @Value(&quot;${gemini.api.key}&quot;)
    private String apiKey;

    private final String modelName = &quot;gemini-2.5-flash&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 사용한 모델은 제미나이 무료 버전이다. 아래 사이트를 참고하면 쉽게 키를 발급받을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://doldol.kr/114&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://doldol.kr/114&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1755147996916&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;Gemini API Key 발급 받기&quot; data-og-description=&quot;Gemini API란 무엇일까요?Gemini API는 Google에서 개발한 최첨단 AI 모델 Gemini를 활용하여 다양한 AI 기능을 구현할 수 있도록 제공하는 API입니다. 강력한 언어 이해 능력, 복잡한 문제 해결 능력, 창의&quot; data-og-host=&quot;doldol.kr&quot; data-og-source-url=&quot;https://doldol.kr/114&quot; data-og-url=&quot;https://doldol.kr/114&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/hrfsg/hyZuFiBGEw/gsh4JzRPXAKQADKt9DoTHK/img.png?width=800&amp;amp;height=1043&amp;amp;face=0_0_800_1043,https://scrap.kakaocdn.net/dn/doZUDt/hyZuzCIbKT/2N0HCAykm72DzgcPcOcI8k/img.png?width=800&amp;amp;height=1043&amp;amp;face=0_0_800_1043,https://scrap.kakaocdn.net/dn/QBu6P/hyZyrbWigN/koKFW3PhQNCPKZOv7NhM70/img.png?width=828&amp;amp;height=1080&amp;amp;face=0_0_828_1080&quot;&gt;&lt;a href=&quot;https://doldol.kr/114&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://doldol.kr/114&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/hrfsg/hyZuFiBGEw/gsh4JzRPXAKQADKt9DoTHK/img.png?width=800&amp;amp;height=1043&amp;amp;face=0_0_800_1043,https://scrap.kakaocdn.net/dn/doZUDt/hyZuzCIbKT/2N0HCAykm72DzgcPcOcI8k/img.png?width=800&amp;amp;height=1043&amp;amp;face=0_0_800_1043,https://scrap.kakaocdn.net/dn/QBu6P/hyZyrbWigN/koKFW3PhQNCPKZOv7NhM70/img.png?width=828&amp;amp;height=1080&amp;amp;face=0_0_828_1080');&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;Gemini API Key 발급 받기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Gemini API란 무엇일까요?Gemini API는 Google에서 개발한 최첨단 AI 모델 Gemini를 활용하여 다양한 AI 기능을 구현할 수 있도록 제공하는 API입니다. 강력한 언어 이해 능력, 복잡한 문제 해결 능력, 창의&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;doldol.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 만약 비공개 레포지토리가 아닌 경우, 키를 환경변수로 관리하고 싶다면 아래처럼 .env 파일을 만든 후에 프로젝트 폴더에 넣어주면 된다.&lt;/p&gt;
&lt;p&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/495Zw/btsPR55EYpa/JfN1154JxB92DZsppNlrz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/495Zw/btsPR55EYpa/JfN1154JxB92DZsppNlrz0/img.png&quot; data-origin-width=&quot;468&quot; data-origin-height=&quot;249&quot; data-is-animation=&quot;false&quot; width=&quot;331&quot; height=&quot;176&quot; style=&quot;width: 65.9208%; margin-right: 10px;&quot; data-widthpercent=&quot;66.7&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/495Zw/btsPR55EYpa/JfN1154JxB92DZsppNlrz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F495Zw%2FbtsPR55EYpa%2FJfN1154JxB92DZsppNlrz0%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;249&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/S1R8C/btsPTn5w95u/u3j1ID8SlJu1k1OYxBCO6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/S1R8C/btsPTn5w95u/u3j1ID8SlJu1k1OYxBCO6K/img.png&quot; data-origin-width=&quot;351&quot; data-origin-height=&quot;374&quot; data-is-animation=&quot;false&quot; style=&quot;width: 32.9164%;&quot; data-widthpercent=&quot;33.3&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/S1R8C/btsPTn5w95u/u3j1ID8SlJu1k1OYxBCO6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FS1R8C%2FbtsPTn5w95u%2Fu3j1ID8SlJu1k1OYxBCO6K%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;351&quot; height=&quot;374&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 방법 사용시 처음에 말했듯이 의존성에 dotenv를 추가해야 하고, Application.java 실행 코드 위에 아래 코드를 추가하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1755148191818&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@SpringBootApplication
public class DoctorHomeApplication {

	public static void main(String[] args) {
        
    	//추가 부분
		Dotenv dotenv = Dotenv.load();
		System.setProperty(&quot;GEMINI_API_KEY&quot;, dotenv.get(&quot;GEMINI_API_KEY&quot;));
        
		SpringApplication.run(DoctorHomeApplication.class, args);
	}

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 서비스 코드로 돌아와서, 모델과 통신하는 부분을 작성해주면 된다. 그중에서 사실 제일 중요한 건 프롬프트 작성 부분이다. 정확하고 자세하게 작성할수록 사용자가 대충 입력해도 결과가 잘 나온다. 여기서는 아래처럼 작성하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1755148348674&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;	//AI에게 보낼 프롬프트
        String prompt = String.format(&quot;&quot;&quot;
            당신은 의사 또는 약사입니다.
            사용자의 증상을 보내드릴테니, 그 증상에 맞는 일반적인 진단, 약, 영양제, 치료법 또는 병원 등을 추천해주세요.
            
            사용자가 이미 가지고 있는 약 목록: %s
            증상: %s

            1. 가지고 있는 약 중에서 증상에 맞는 약이 있으면 추천해주세요.
            2. 없으면 다른 약을 추천하고, 이유를 설명해주세요.
            &quot;&quot;&quot;, medsText, symptom);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;06. 코드 작성 - 프론트엔드&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 메인 페이지&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;index.html&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755148544290&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;약 목록&amp;lt;/title&amp;gt;
    &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;/css/style.css&quot;&amp;gt;
    &amp;lt;script defer src=&quot;/js/main.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
&amp;lt;header&amp;gt;
    &amp;lt;h1&amp;gt;  약 관리&amp;lt;/h1&amp;gt;
    &amp;lt;div class=&quot;header-controls&quot;&amp;gt;
        &amp;lt;input type=&quot;text&quot; id=&quot;search&quot; placeholder=&quot;약 이름 검색...&quot;&amp;gt;
        &amp;lt;button onclick=&quot;location.href='/edit.html'&quot;&amp;gt;+ 새 약 등록&amp;lt;/button&amp;gt;
        &amp;lt;button onclick=&quot;location.href='/diagnosis.html'&quot;&amp;gt;진단하기&amp;lt;/button&amp;gt;
        &amp;lt;button onclick=&quot;location.href='/hospital.html'&quot;&amp;gt;병원찾기&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/header&amp;gt;

&amp;lt;main&amp;gt;
    &amp;lt;div id=&quot;medicine-list&quot; class=&quot;grid&quot;&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 html도 거의 비슷한 형식인데 여기서 중요한 점은 요거!!&lt;/p&gt;
&lt;pre id=&quot;code_1755148583088&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이거 모르고 안 썼다가... 기껏 반응형으로 css 짰는데 모바일에서도 컴퓨터처럼 글씨가 작게 나오는 바람에 다시 수정함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뷰포트를 써줘야 모바일에서도 렌더링이 지원된다고 한다. 실제로 이거 추가해줬더니 이제 모바일에서도 반응형 돼서 크기 조절됨!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 또 주요 코드는 병원 지도 넣기&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;hostpital.html&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755148727329&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script type=&quot;text/javascript&quot; src=&quot;//dapi.kakao.com/v2/maps/sdk.js?appkey=(발급 키)&amp;amp;libraries=services&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;script defer src=&quot;/js/hospital.js&quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;html에서 js 호출 전에 카카오 지도 api 키를 넣어주었다. 이 방법은 예전에 만들었던 프로젝트 참고!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://rim08.tistory.com/72&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://rim08.tistory.com/72&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1755148803143&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;[토이프로젝트 개발일지 03] 산책 경로 저장 기능 추가 (카카오 지도 api)&quot; data-og-description=&quot;더보기사실 기능 추가할 때마다 깃허브에 readme로 쓰고 있었다가... 이제서야 한번에 정리 겸 글 써봄&amp;nbsp;&amp;nbsp;&amp;nbsp;산책(이동) 경로 저장 기능 개발01. 구상25.01.13&amp;nbsp;01.12에 서버 배포를 완료한 뒤, 다음날에 &quot; data-og-host=&quot;rim08.tistory.com&quot; data-og-source-url=&quot;https://rim08.tistory.com/72&quot; data-og-url=&quot;https://rim08.tistory.com/72&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/c4WM0q/hyZuKxt9Ne/L5RvC1OASnhCjhEIrTHb9K/img.png?width=800&amp;amp;height=530&amp;amp;face=0_0_800_530,https://scrap.kakaocdn.net/dn/hu548/hyZvjTRRLe/KL9I7hpQDyzGPwp8Z9B3i0/img.png?width=800&amp;amp;height=530&amp;amp;face=0_0_800_530,https://scrap.kakaocdn.net/dn/QrTZl/hyZylQjQzn/vKkxmdkUMaKWMxwW8fciKK/img.png?width=1697&amp;amp;height=964&amp;amp;face=0_0_1697_964&quot;&gt;&lt;a href=&quot;https://rim08.tistory.com/72&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://rim08.tistory.com/72&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/c4WM0q/hyZuKxt9Ne/L5RvC1OASnhCjhEIrTHb9K/img.png?width=800&amp;amp;height=530&amp;amp;face=0_0_800_530,https://scrap.kakaocdn.net/dn/hu548/hyZvjTRRLe/KL9I7hpQDyzGPwp8Z9B3i0/img.png?width=800&amp;amp;height=530&amp;amp;face=0_0_800_530,https://scrap.kakaocdn.net/dn/QrTZl/hyZylQjQzn/vKkxmdkUMaKWMxwW8fciKK/img.png?width=1697&amp;amp;height=964&amp;amp;face=0_0_1697_964');&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;[토이프로젝트 개발일지 03] 산책 경로 저장 기능 추가 (카카오 지도 api)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;더보기사실 기능 추가할 때마다 깃허브에 readme로 쓰고 있었다가... 이제서야 한번에 정리 겸 글 써봄&amp;nbsp;&amp;nbsp;&amp;nbsp;산책(이동) 경로 저장 기능 개발01. 구상25.01.13&amp;nbsp;01.12에 서버 배포를 완료한 뒤, 다음날에&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;rim08.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 지도 띄우는 js는 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;hospital.js&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755148940665&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;document.addEventListener(&quot;DOMContentLoaded&quot;, () =&amp;gt; {
    const mapContainer = document.getElementById(&quot;map&quot;);
    const hospitalList = document.getElementById(&quot;hospital-list&quot;);

    //지도 기본 옵션 (서울 시청 기준)
    const mapOption = {
        center: new kakao.maps.LatLng(37.5665, 126.9780),
        level: 3
    };

    const map = new kakao.maps.Map(mapContainer, mapOption);
    const ps = new kakao.maps.services.Places();
    const infowindow = new kakao.maps.InfoWindow({ zIndex: 1 });

    //현재 위치 받아오기
    if (navigator.geolocation) {
        navigator.geolocation.getCurrentPosition((position) =&amp;gt; {
            const lat = position.coords.latitude;
            const lon = position.coords.longitude;

            const locPosition = new kakao.maps.LatLng(lat, lon);
            map.setCenter(locPosition);

            //현재 위치 마커 표시
            const marker = new kakao.maps.Marker({ position: locPosition });
            marker.setMap(map);

            //현재 위치를 중심으로 병원 검색
            ps.keywordSearch(&quot;병원&quot;, (data, status) =&amp;gt; {
                if (status === kakao.maps.services.Status.OK) {
                    displayHospitals(data);
                }
            }, { location: locPosition, radius: 5000 });
        });
    } else {
        alert(&quot;위치 정보를 가져올 수 없습니다.&quot;);
    }

    //병원 목록 표시 함수
    function displayHospitals(places) {
        hospitalList.innerHTML = &quot;&quot;;
        places.forEach((place) =&amp;gt; {
            const item = document.createElement(&quot;div&quot;);
            item.className = &quot;hospital-item&quot;;
            item.textContent = `${place.place_name} (${place.phone || &quot;전화번호 없음&quot;})`;
            hospitalList.appendChild(item);

            const marker = new kakao.maps.Marker({
                map: map,
                position: new kakao.maps.LatLng(place.y, place.x)
            });

            kakao.maps.event.addListener(marker, &quot;click&quot;, () =&amp;gt; {
                infowindow.setContent(`&amp;lt;div style=&quot;padding:5px;&quot;&amp;gt;${place.place_name}&amp;lt;/div&amp;gt;`);
                infowindow.open(map, marker);
            });

            item.addEventListener(&quot;click&quot;, () =&amp;gt; {
                const moveLatLon = new kakao.maps.LatLng(place.y, place.x);
                map.panTo(moveLatLon);
                infowindow.setContent(`&amp;lt;div style=&quot;padding:5px;&quot;&amp;gt;${place.place_name}&amp;lt;/div&amp;gt;`);
                infowindow.open(map, marker);
            });
        });
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자의 현재위치를 기반으로 주변 병원 정보를 검색하여 마커로 표시하고, 그 아래에 목록으로도 병원 이름+전화번호 형태로 출력한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 js에서 이번에 추가한 기능은 '관리자 권한 검증'기능이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래는 로그인 기능을 만드는게 더 보안성이 높겠지만, 이 사이트는 로그인 없이도 가능한 기능들이 대다수이므로 굳이 구현하지 않았다. 따라서 주요 버튼인 수정과 삭제 버튼에만 비밀번호를 입력하게끔 팝업을 띄우는 것으로 구현하였다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;detail.js&lt;/blockquote&gt;
&lt;pre id=&quot;code_1755149155812&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    //비밀번호 검증 함수
    function checkPassword() {
        const input = prompt(&quot;비밀번호를 입력하세요:&quot;);
        const correctPassword = &quot;(비밀번호-&amp;gt;나중에 서버 검증으로 보완할수도 있음)&quot;;
        return input === correctPassword;
    }

    //삭제 버튼
    document.getElementById(&quot;delete-btn&quot;).addEventListener(&quot;click&quot;, () =&amp;gt; {
        if (!checkPassword()) {
            alert(&quot;비밀번호가 올바르지 않습니다.&quot;);
            return;
        }

        if (confirm(&quot;정말 삭제하시겠습니까?&quot;)) {
            fetch(`/api/medicines/${id}`, { method: &quot;DELETE&quot; })
                .then(res =&amp;gt; {
                    if (!res.ok) throw new Error(&quot;삭제 실패&quot;);
                    location.href = &quot;/index.html&quot;;
                })
                .catch(err =&amp;gt; alert(&quot;삭제 중 오류가 발생했습니다.&quot;));
        }
    });

    //수정 버튼
    document.getElementById(&quot;edit-btn&quot;).addEventListener(&quot;click&quot;, () =&amp;gt; {
        if (!checkPassword()) {
            alert(&quot;비밀번호가 올바르지 않습니다.&quot;);
            return;
        }
        location.href = `/edit.html?id=${id}`;
    });&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;07. 배포&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 다 작성하고 로컬 서버 실행 후 정상작동까지 확인했다면, 마지막으로 배포만 남았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 기본적인 배포 방법은 마찬가지로 예전에 했던 프로젝트의 배포 내용을 참고하였다. (다 까먹었는데 자세히 써놔서 진짜 다행)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://rim08.tistory.com/71&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://rim08.tistory.com/71&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1755149457971&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;[토이프로젝트 개발일지 02] Spring Boot, MariaDB 배포(CloudType)&quot; data-og-description=&quot;어제 글을 쓰다보니까 진짜로 서버 배포 해봐야겠다 싶어서 온갖 사이트를 찾아다닌 결과...cloudType에서 무료로 가능하다길래 몇번의 실패 끝에 성공함!!그런데 무료 버전은 하루에 1번 중지될 &quot; data-og-host=&quot;rim08.tistory.com&quot; data-og-source-url=&quot;https://rim08.tistory.com/71&quot; data-og-url=&quot;https://rim08.tistory.com/71&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/fwDXn/hyZzyojtB8/LgdLxVR8gIXJos48kxXbOk/img.png?width=797&amp;amp;height=492&amp;amp;face=0_0_797_492,https://scrap.kakaocdn.net/dn/DYkCe/hyZygIfhfG/LOkmuoY4oq3BD1f3GAcei0/img.png?width=797&amp;amp;height=492&amp;amp;face=0_0_797_492,https://scrap.kakaocdn.net/dn/cm6Qgg/hyZykDRYAS/ZCmExKHn2dbMgFWxG9aY31/img.png?width=918&amp;amp;height=848&amp;amp;face=0_0_918_848&quot;&gt;&lt;a href=&quot;https://rim08.tistory.com/71&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://rim08.tistory.com/71&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/fwDXn/hyZzyojtB8/LgdLxVR8gIXJos48kxXbOk/img.png?width=797&amp;amp;height=492&amp;amp;face=0_0_797_492,https://scrap.kakaocdn.net/dn/DYkCe/hyZygIfhfG/LOkmuoY4oq3BD1f3GAcei0/img.png?width=797&amp;amp;height=492&amp;amp;face=0_0_797_492,https://scrap.kakaocdn.net/dn/cm6Qgg/hyZykDRYAS/ZCmExKHn2dbMgFWxG9aY31/img.png?width=918&amp;amp;height=848&amp;amp;face=0_0_918_848');&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;[토이프로젝트 개발일지 02] Spring Boot, MariaDB 배포(CloudType)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;어제 글을 쓰다보니까 진짜로 서버 배포 해봐야겠다 싶어서 온갖 사이트를 찾아다닌 결과...cloudType에서 무료로 가능하다길래 몇번의 실패 끝에 성공함!!그런데 무료 버전은 하루에 1번 중지될&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;rim08.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 방법대로 우선 마리아db를 배포한다. 그러면 주소 복사를 하고 워크벤치에서 등록 시킨 뒤 테이블이 불러와지는 것을 확인해야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 여기서............. 대실수함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아니 난 그저 잘못 입력해서 그거 하나만 지우려고 했는데..... 실수로 아래 버튼 중에서 올 커넥션 삭제를 누른 거임........&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;799&quot; data-origin-height=&quot;577&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cPM1n6/btsPUlzhE0m/QEmPyeYkp9VxS82RzeHmHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cPM1n6/btsPUlzhE0m/QEmPyeYkp9VxS82RzeHmHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cPM1n6/btsPUlzhE0m/QEmPyeYkp9VxS82RzeHmHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcPM1n6%2FbtsPUlzhE0m%2FQEmPyeYkp9VxS82RzeHmHk%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;666&quot; height=&quot;481&quot; data-origin-width=&quot;799&quot; data-origin-height=&quot;577&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;046&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/046.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/046.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;와 이때 진짜 ㄹㅇ 죽고 싶었음...... 설마 진짜 테이블 다 삭제 된건가 그럼 내 졸작 데베도????&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤 제트 무한 눌렀으나 안먹히고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;와........그래서 머리 새하얘져서 덜덜 떨면서 검색해봤는데&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 연결 정보만 지운거고 테이블은 그대로 남아있다고 하길래,,, cmd창으로 확인해봤더니 진짜 남아있었음&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;035&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/035.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/035.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하여튼 그리고 이번에 새로 연결할거 다시 만들음 (두번째 네모칸)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아 그리고 여기서도 원래 로컬 서버로 실행할 땐 user이런식으로 이름짜서 여기서도 그렇게 했다가 오류로 빠꾸먹음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 정석대로 root로 지정해주었더니 성공했습니다 하하&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 그렇게 마리아db까지 배포 완료했으면 이제 중요한 코드 연동하기!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 당연히 깃허브에 이미 push까지 완료되어있는 상태여야 하고&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;a href=&quot;https://docs.cloudtype.io/guide/references/private-repo&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.cloudtype.io/guide/references/private-repo&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1755149863024&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;비공개 Git 저장소 배포 - 클라우드타입 Docs&quot; data-og-description=&quot;클라우드타입은 클라우드 기반 애플리케이션을 빠르게 개발하고 배포할 수 있는 클라우드 애플리케이션 플랫폼입니다.&quot; data-og-host=&quot;docs.cloudtype.io&quot; data-og-source-url=&quot;https://docs.cloudtype.io/guide/references/private-repo&quot; data-og-url=&quot;https://docs.cloudtype.io//guide/references/private-repo&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.cloudtype.io/guide/references/private-repo&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.cloudtype.io/guide/references/private-repo&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;비공개 Git 저장소 배포 - 클라우드타입 Docs&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;클라우드타입은 클라우드 기반 애플리케이션을 빠르게 개발하고 배포할 수 있는 클라우드 애플리케이션 플랫폼입니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.cloudtype.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 오류 안 나면 진짜 끝!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;08. 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 메인 화면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 집에 있는 약들 등록한 상태고 소비기한 순으로 정렬되게 함&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1919&quot; data-origin-height=&quot;895&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cSFtF5/btsPSlAzT3N/aT6AELESlPBrkzXJaKpBsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cSFtF5/btsPSlAzT3N/aT6AELESlPBrkzXJaKpBsK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cSFtF5/btsPSlAzT3N/aT6AELESlPBrkzXJaKpBsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcSFtF5%2FbtsPSlAzT3N%2FaT6AELESlPBrkzXJaKpBsK%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;1919&quot; height=&quot;895&quot; data-origin-width=&quot;1919&quot; data-origin-height=&quot;895&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 상세 화면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 버튼까지 보이게 하느라 조금 축소했는데 하여튼 소비기한, 개수, 복용법 등 정보가 나타남&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1907&quot; data-origin-height=&quot;838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Rqvz5/btsPUkf61g5/jqzfmylT5eZLRS24skPuGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Rqvz5/btsPUkf61g5/jqzfmylT5eZLRS24skPuGk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Rqvz5/btsPUkf61g5/jqzfmylT5eZLRS24skPuGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRqvz5%2FbtsPUkf61g5%2FjqzfmylT5eZLRS24skPuGk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1907&quot; height=&quot;838&quot; data-origin-width=&quot;1907&quot; data-origin-height=&quot;838&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정이나 삭제 버튼 누르면 비밀번호 검증&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1893&quot; data-origin-height=&quot;890&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RNWWf/btsPURkmygl/kU2qBdmKEknN1wxnu8Z0o0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RNWWf/btsPURkmygl/kU2qBdmKEknN1wxnu8Z0o0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RNWWf/btsPURkmygl/kU2qBdmKEknN1wxnu8Z0o0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRNWWf%2FbtsPURkmygl%2FkU2qBdmKEknN1wxnu8Z0o0%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;1893&quot; height=&quot;890&quot; data-origin-width=&quot;1893&quot; data-origin-height=&quot;890&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 등록/수정 화면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면은 동일한데 수정으로 하면 기존 정보 불러와짐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기한은 옆에 달력 아이콘으로 편하게 선택 가능&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1916&quot; data-origin-height=&quot;895&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVZkJH/btsPS4rNaJB/Q9JJc2KVB5ojK306Kd1X60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVZkJH/btsPS4rNaJB/Q9JJc2KVB5ojK306Kd1X60/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVZkJH/btsPS4rNaJB/Q9JJc2KVB5ojK306Kd1X60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVZkJH%2FbtsPS4rNaJB%2FQ9JJc2KVB5ojK306Kd1X60%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;1916&quot; height=&quot;895&quot; data-origin-width=&quot;1916&quot; data-origin-height=&quot;895&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 병원 찾기 화면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위치 허용을 안 하면 아래처럼 기본 서울 시청 주변으로 뜬다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위치 허용하면 자기 집 주변 병원이 마커로 표시되면서 아래에 목록으로 정상 출력된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1913&quot; data-origin-height=&quot;882&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cYiOkV/btsPR9HsUVE/f8WlRKOW5dcOMv3g8RpeXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cYiOkV/btsPR9HsUVE/f8WlRKOW5dcOMv3g8RpeXk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cYiOkV/btsPR9HsUVE/f8WlRKOW5dcOMv3g8RpeXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcYiOkV%2FbtsPR9HsUVE%2Ff8WlRKOW5dcOMv3g8RpeXk%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;1913&quot; height=&quot;882&quot; data-origin-width=&quot;1913&quot; data-origin-height=&quot;882&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. AI 진단 화면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 들어가면 예시와 함께 입력 폼이 나타난다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1919&quot; data-origin-height=&quot;903&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cyHWsB/btsPSzlAZNB/kNQaXLNBNXmxKfDKlCAFk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cyHWsB/btsPSzlAZNB/kNQaXLNBNXmxKfDKlCAFk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cyHWsB/btsPSzlAZNB/kNQaXLNBNXmxKfDKlCAFk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcyHWsB%2FbtsPSzlAZNB%2FkNQaXLNBNXmxKfDKlCAFk0%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;1919&quot; height=&quot;903&quot; data-origin-width=&quot;1919&quot; data-origin-height=&quot;903&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력하면 아래처럼 대략 15~30초 후 진단 결과가 출력된다. 무료 버전 모델인데 이정도면 괜찮은 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 사용자가 가지고 있는 약 중에서 증상에 맞는 걸 추천해주고, 그 외에 다른 판매 약도 추천해준다. 또한 증상에 대해 분석해주며 비타민 영양제, 치료법, 병원 추천까지 해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;kakaotv&quot; data-video-url=&quot;https://tv.kakao.com/v/457258300&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/bdFPmM/hyZuwTv2Kk/QCgmtLjR6rnZBNSUkNcds0/img.jpg?width=1904&amp;amp;height=902&amp;amp;face=0_0_1904_902,https://scrap.kakaocdn.net/dn/T6k3P/hyZuAO8zVh/JhzKYi4GlUK8523x452PvK/img.jpg?width=1904&amp;amp;height=902&amp;amp;face=0_0_1904_902&quot; data-video-width=&quot;860&quot; data-video-height=&quot;407&quot; data-video-origin-width=&quot;860&quot; data-video-origin-height=&quot;407&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-video-play-service=&quot;daum_tistory&quot; data-original-url=&quot;&quot; data-video-title=&quot;&quot;&gt;&lt;iframe src=&quot;https://play-tv.kakao.com/embed/player/cliplink/457258300?service=daum_tistory&quot; width=&quot;860&quot; height=&quot;407&quot; frameborder=&quot;0&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption style=&quot;display: none;&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;+ 모바일 화면&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-08-14 145923.png&quot; data-origin-width=&quot;363&quot; data-origin-height=&quot;787&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgh9az/btsPUcCok6v/mKVT0PS9Hev8SNXLYt1PKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgh9az/btsPUcCok6v/mKVT0PS9Hev8SNXLYt1PKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgh9az/btsPUcCok6v/mKVT0PS9Hev8SNXLYt1PKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbgh9az%2FbtsPUcCok6v%2FmKVT0PS9Hev8SNXLYt1PKk%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;363&quot; height=&quot;787&quot; data-filename=&quot;스크린샷 2025-08-14 145923.png&quot; data-origin-width=&quot;363&quot; data-origin-height=&quot;787&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-08-14 145933.png&quot; data-origin-width=&quot;368&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4DcgE/btsPUSp3wcU/9UaNYQwNgFfiBBmpEVQk70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4DcgE/btsPUSp3wcU/9UaNYQwNgFfiBBmpEVQk70/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4DcgE/btsPUSp3wcU/9UaNYQwNgFfiBBmpEVQk70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4DcgE%2FbtsPUSp3wcU%2F9UaNYQwNgFfiBBmpEVQk70%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;368&quot; height=&quot;768&quot; data-filename=&quot;스크린샷 2025-08-14 145933.png&quot; data-origin-width=&quot;368&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 개인 토이프로젝트 끝~!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어제 19시~23시에 거의 구현 완료하고 오늘 10시~12시에 보완+배포까지 완료했다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목적부터가 집에서 쓰는 거라 이름도 닥터홈으로 했는데(ㅋㅋ) 만들자마자 가족들한테 사이트 보내줬더니&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;난 ai 진단이 핵심 포인트라 생각했는데 다들 약 관리 이야기만 함...ㅋㅋㅋㅠ 그리고 원래는 유통기한에다 정렬 기능 없음, 모바일 크기 안맞음 이었는데 보완점 말해줘서 고친거임 ㅎ.ㅎ 뭔가 다른 사람한테 배포하고 의견듣는건 처음인 것 같는데 재밌네요... 언젠가 진짜 유료 결제해서 서버 만들고 더 많은 사용자한테 배포해보고 싶네여&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;그리고 이번에 ai 모델도 빠르게 연동할 수 있었던건 다 졸작 덕분임... 아직 이것도 완성하진 않고 계속 팀원들이랑 진행중인데 여기서 ai 모델 써보면서 방법 익혔음 물론 졸작도 정말 몇달동안 하면서 엄청나게 많은 시행착오와 위기가 있었지만... 그래도 현재 70%정도는 된 것 같아서 뿌듯합니다... 나중에 무사히 심사 통과되면 그때 한번에 글 써야지//&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;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;019&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/019.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/019.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;</description>
      <category> Project/doctorHome - AI 진단 관리 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/76</guid>
      <comments>https://rim08.tistory.com/76#entry76comment</comments>
      <pubDate>Thu, 14 Aug 2025 14:57:49 +0900</pubDate>
    </item>
    <item>
      <title>[토이프로젝트 개발일지 02] 깃허브 관리 (프로젝트, 이슈, 브랜치)</title>
      <link>https://rim08.tistory.com/75</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 매칭 웹을 만들면서 깃허브로 단순 백업이 아니라 진짜 프로젝트를 진행하는 방식대로 처음 도전해 봤다. 그래서 아직 익숙하진 않지만 몇 가지 주요 기능들을 정리해 볼 겸 쓰는 중...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인텔리제이를 이용하면, 웬만한 기능들은 상단바 탭에서 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 현재 내 프로젝트와 깃허브를 연동시켜준다.&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;그럼 상단바에 Git 탭이 생기면서 여기서 commit, push 등을 할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1148&quot; data-origin-height=&quot;851&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/p215i/btsLUonLuAO/fYQ8nn8JoQKKlAzd3amdD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/p215i/btsLUonLuAO/fYQ8nn8JoQKKlAzd3amdD0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/p215i/btsLUonLuAO/fYQ8nn8JoQKKlAzd3amdD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fp215i%2FbtsLUonLuAO%2FfYQ8nn8JoQKKlAzd3amdD0%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;729&quot; height=&quot;540&quot; data-origin-width=&quot;1148&quot; data-origin-height=&quot;851&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;깃허브로 해당 프로젝트 레포지토리에 들어가서 Project를 선택하면 할 일 목록을 정리할 수 있다. New project -&amp;gt; Board 형식으로 생성하는 것이 보기 편한 것 같아 그걸로 선택하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1414&quot; data-origin-height=&quot;649&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpdn41/btsLVfQ8PoB/RBmt916pzsWefu3baTkDwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpdn41/btsLVfQ8PoB/RBmt916pzsWefu3baTkDwk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpdn41/btsLVfQ8PoB/RBmt916pzsWefu3baTkDwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbpdn41%2FbtsLVfQ8PoB%2FRBmt916pzsWefu3baTkDwk%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;795&quot; height=&quot;365&quot; data-origin-width=&quot;1414&quot; data-origin-height=&quot;649&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 다 끝난 상태인데, 작업할 때는 Todo에서 Add item을 통해 구현할 기능 이름으로 생성하였다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1125&quot; data-origin-height=&quot;863&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d0idEK/btsLUUsTTmu/jzajknQe6uxoQ8hJMbIJT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d0idEK/btsLUUsTTmu/jzajknQe6uxoQ8hJMbIJT0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d0idEK/btsLUUsTTmu/jzajknQe6uxoQ8hJMbIJT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd0idEK%2FbtsLUUsTTmu%2FjzajknQe6uxoQ8hJMbIJT0%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;744&quot; height=&quot;571&quot; data-origin-width=&quot;1125&quot; data-origin-height=&quot;863&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성한 것을 보면 Convert to issue가 있다. 이걸 누르면 해당 이름으로 이슈를 생성할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 상태로는 클릭해도 반응이 없는... 그냥 상태만 확인하는 용인 것 같았다. 그래서 Project 탭에서는 할 일 목록을 한눈에 보도록 하고, 해당 기능의 commit 같은 자세한 상황은 이슈를 생성해서 봐야 되는 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;313&quot; data-origin-height=&quot;390&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/28Z5B/btsLUFJC12N/BkDX7dHRQK9gW65mR28qRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/28Z5B/btsLUFJC12N/BkDX7dHRQK9gW65mR28qRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/28Z5B/btsLUFJC12N/BkDX7dHRQK9gW65mR28qRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F28Z5B%2FbtsLUFJC12N%2FBkDX7dHRQK9gW65mR28qRK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;313&quot; height=&quot;390&quot; data-origin-width=&quot;313&quot; data-origin-height=&quot;390&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이슈에서 해당 기능에 대한 진행상황을 자세히 볼 수 있는데, 여기서 우측의 Development를 누르면 이에 대한 브랜치를 생성할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1041&quot; data-origin-height=&quot;363&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/srDc3/btsLWb8gyPT/rZrELNeHPt4o5CMIWS8pL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/srDc3/btsLWb8gyPT/rZrELNeHPt4o5CMIWS8pL1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/srDc3/btsLWb8gyPT/rZrELNeHPt4o5CMIWS8pL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsrDc3%2FbtsLWb8gyPT%2FrZrELNeHPt4o5CMIWS8pL1%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;814&quot; height=&quot;284&quot; data-origin-width=&quot;1041&quot; data-origin-height=&quot;363&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 프로젝트는 다음과 같은 순서로 한다.&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;master (서버 배포용, 완전 마지막에 확인되면 합침)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;develop (각 기능 구현 완료되면 여기로 합침 -&amp;gt; 나중에 최종적으로 얘를 master로 보내서 합침)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 외 각 기능들은 따로 브랜치 만들어서 완성되면 develop으로 보냄&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;나도 이번엔 이 방식을 체험해 볼 겸 이슈를 생성할 때마다 해당 이름으로 브랜치를 만들어서 작업하고, 완성되면 develop으로 Pull requests를 보냈다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 탭에서 요청들을 확인할 수 있으며, 여기서 충돌이 없으면 merge가 가능하다. 충돌이 있을 경우엔 pull 해서 충돌을 다 해결한 후에 다시 요청해야 한다. 나는 이번에 협력한게 아니고 혼자 작업한거라 따로 충돌은 없었지만... 깃허브에 올라간 코드랑 내 로컬 코드랑 다른 부분이 있는 상태에서 작업하고 요청 보내면 충돌이 일어난다. 따라서 맨 처음에 작업하기 전에 미리 pull해서 최신 버전을 받은 후에 거기서 작업하고 요청을 보내야 하는 것 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;568&quot; data-origin-height=&quot;252&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxFRlS/btsLUUT0L38/HEhWLvUQgJqqfANbotdJTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxFRlS/btsLUUT0L38/HEhWLvUQgJqqfANbotdJTk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxFRlS/btsLUUT0L38/HEhWLvUQgJqqfANbotdJTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxFRlS%2FbtsLUUT0L38%2FHEhWLvUQgJqqfANbotdJTk%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;568&quot; height=&quot;252&quot; data-origin-width=&quot;568&quot; data-origin-height=&quot;252&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 pull해서 받아올 경우, 내가 추가했던 코드는 날아가는 게 아닌지 걱정되었었는데, 인텔리제이 기준으로는 pull 할 시 내가 쓰고 있던 코드 탭이랑 pull 했을 때 충돌난 그전 버전 코드 탭이 동시에 띄워져서 바로 확인하고 수정할 수 있었다. (역시 다 방법이 있는 모양이다..........)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 인텔리제이에서는 아래 탭에서 브랜치를 선택해서 이동할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;582&quot; data-origin-height=&quot;535&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0T064/btsLWyhTMj1/toJIh0LK6HFENvq6iN0DvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0T064/btsLWyhTMj1/toJIh0LK6HFENvq6iN0DvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0T064/btsLWyhTMj1/toJIh0LK6HFENvq6iN0DvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0T064%2FbtsLWyhTMj1%2FtoJIh0LK6HFENvq6iN0DvK%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;549&quot; height=&quot;505&quot; data-origin-width=&quot;582&quot; data-origin-height=&quot;535&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 반영이 안 되면, 콘솔창에서 터미널 -&amp;gt; git Bash에서 직접 checkout 등으로 브랜치를 이동할 수도 있다. 그럼 상단 탭에도 바로 반영이 됐었다.&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;이렇게 각 기능 브랜치에서 작업을 완료하고, 해당 사항을 저장(commit)한다. 인텔리제이에서 commit 버튼을 누르면 자동으로 수정된 파일들이 나타나면서 커밋 메시지를 작성할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;commit 후에 push를 하면 동시에 충돌 검사를 하게 된다... 무사히 되면 다행인 것이다. (안되면 pull ㄱ)&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;push까지 하면 자동으로 pull request 할 건지 알림 메시지가 뜨는데, 클릭하면 바로 깃허브 탭에서 확인할 수 있다. 혼자 작업하는 거라서 내가 요청 보내고 내가 수락하고 합치고 참 재밌었다 (...)&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;아무튼 pull requests로 develop으로 보내고 나면 해당 이슈는 종료하면 된다. 그럼 프로젝트 탭에서도 자동으로 완료 목록으로 이동하게 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 하면 대략적인 흐름 정리는 끝!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 뭐 더 캡처해서 올리고 싶어도 이미 다 끝난 항목이라 좀...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 처음 해봐서 그런지 아직 이슈 정리 능력이 부족한 것 같다. 기능마다 이슈를 만들고 관리해야 한다는 걸 알면서도 자꾸 까먹고 인텔리제이에서 계속 작업하고 있었다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 코드를 작업하다가 갑자기 엔티티나 데이터베이스 등 중요한 거를 수정해야 할 때, 잘못하면 어떻게 되돌아가지? 싶어서 그냥 코드 전체를 그대로 파일로 끌어다 깃허브에 백업하는 식으로 해왔었는데, 이렇게 브랜치를 따로 파니까 더 안전한 것 같았다. 이러면 망해도 그냥 해당 브랜치는 버리고 그전 브랜치로 돌아가면 되지 싶었다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 혼자 해서 이렇지 조만간 졸업작품............. 진행해야 되는데 그땐 이렇게 하는 게 훨씬 좋은 것 같다. 미리 공부해 봤으니 뭐... 지금은 처음이라 잘 모르겠지만 하다 보면 완전 빠르게 슉슉 잘 정리할 수 있지 않을까?!&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignLeft&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;016&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/016.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/016.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 이번 프로젝트는 정말로,,, 쉬어가는 느낌 + 덕질 목적으로 재미 삼아 만든 거였는데 생각보다 공부한 게 되게 많았다. 엔티티 연관관계 설계부터 ERD 추출 방법, api 통신 방식, 비공개 레포지토리 배포, 깃허브 프로젝트 관리까지... 와 은근 뿌듯하네요&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 진짜 이번 프로젝트는 여기서 끝~!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category> Project/charMatch - 매칭 테스트 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/75</guid>
      <comments>https://rim08.tistory.com/75#entry75comment</comments>
      <pubDate>Tue, 21 Jan 2025 20:14:00 +0900</pubDate>
    </item>
    <item>
      <title>[토이프로젝트 개발일지 04] 일기 생성 기능 추가 / 메인 페이지 보완</title>
      <link>https://rim08.tistory.com/73</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;~완성화면 미리보기~&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1016&quot; data-origin-height=&quot;857&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWdinF/btsLVh8l4Yp/4zm2kSc5g36NubiTXzRrT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWdinF/btsLVh8l4Yp/4zm2kSc5g36NubiTXzRrT0/img.png&quot; data-alt=&quot;일기 생성 기능 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWdinF/btsLVh8l4Yp/4zm2kSc5g36NubiTXzRrT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWdinF%2FbtsLVh8l4Yp%2F4zm2kSc5g36NubiTXzRrT0%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;632&quot; height=&quot;533&quot; data-origin-width=&quot;1016&quot; data-origin-height=&quot;857&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;일기 생성 기능 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1159&quot; data-origin-height=&quot;849&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/66MTG/btsLTHHrPNC/TCIMzJTiX1uz3PdtVaaD40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/66MTG/btsLTHHrPNC/TCIMzJTiX1uz3PdtVaaD40/img.png&quot; data-alt=&quot;메인 페이지 보완 (1~2개 한 날은 노란색으로 나타나게 수정)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/66MTG/btsLTHHrPNC/TCIMzJTiX1uz3PdtVaaD40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F66MTG%2FbtsLTHHrPNC%2FTCIMzJTiX1uz3PdtVaaD40%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;634&quot; height=&quot;464&quot; data-origin-width=&quot;1159&quot; data-origin-height=&quot;849&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;메인 페이지 보완 (1~2개 한 날은 노란색으로 나타나게 수정)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&gt;
&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;일기 생성 기능 개발&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;01. 구상&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #8cb3be;&quot;&gt;25.01.14&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지도기능으로 산책 경로까지 추가하니까... 뭔가 하루에 있었던 일을 요약할 겸 일기가 자동으로 생성됐으면 좋겠다는 생각이 들었다. 그래서 이번에는 gpt등 NLP 모델을 이용해서 체크리스트 완료 여부, 이동했던 위치를 받으면 NLP가 그에 맞게 일기를 생성해 주는 걸 도전해 보기로 했다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;02. Hugging Face 모델 키 발급&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단한 토이 프로젝트니까 무료 모델을 쓰려고 했는데, 찾아보니 아래 사이트에서 모델들을 고를 수 있다길래 여기로 회원가입을 해서 키를 발급받았다.&lt;/p&gt;
&lt;figure id=&quot;og_1737422529555&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Hugging Face &amp;ndash; The AI community building the future.&quot; data-og-description=&quot;&quot; data-og-host=&quot;huggingface.co&quot; data-og-source-url=&quot;https://huggingface.co/&quot; data-og-url=&quot;https://huggingface.co/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bkyqvY/hyX4xEZOYm/JjdSFPxQlTU35gQA1LMcf0/img.png?width=1200&amp;amp;height=648&amp;amp;face=0_0_1200_648,https://scrap.kakaocdn.net/dn/LdIIk/hyX4ogXgOy/xty2DtPBXXhlfO22lXpd21/img.png?width=1200&amp;amp;height=648&amp;amp;face=0_0_1200_648&quot;&gt;&lt;a href=&quot;https://huggingface.co/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://huggingface.co/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bkyqvY/hyX4xEZOYm/JjdSFPxQlTU35gQA1LMcf0/img.png?width=1200&amp;amp;height=648&amp;amp;face=0_0_1200_648,https://scrap.kakaocdn.net/dn/LdIIk/hyX4ogXgOy/xty2DtPBXXhlfO22lXpd21/img.png?width=1200&amp;amp;height=648&amp;amp;face=0_0_1200_648');&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;Hugging Face &amp;ndash; The AI community building the future.&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;huggingface.co&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;setting -&amp;gt; Access Tokens -&amp;gt; create new Access token -&amp;gt; Read(필요에 따라 선택 가능, 주로 read만 사용)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 Token name은 그냥 아무거나... 난 프로젝트 기능 관련 이름으로, diary-generator로 하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1466&quot; data-origin-height=&quot;708&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdfG0K/btsLUDqr6NC/zYiLHYYYun5g2TDK26vUR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdfG0K/btsLUDqr6NC/zYiLHYYYun5g2TDK26vUR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdfG0K/btsLUDqr6NC/zYiLHYYYun5g2TDK26vUR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdfG0K%2FbtsLUDqr6NC%2FzYiLHYYYun5g2TDK26vUR1%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;717&quot; height=&quot;346&quot; data-origin-width=&quot;1466&quot; data-origin-height=&quot;708&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Create Token 버튼을 누르면 키가 나온다. 이걸 application.properties에 추가해 준다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 사용할 모델의 링크도 같이 써주면 된다. (+아래 gpt2같이 무료버전을 써봤는데... 이따 결과도 말하겠지만 진짜 텍스트 생성에는 쓸 게 못 된다............)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;836&quot; data-origin-height=&quot;115&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eGh9Eh/btsLT3pU7Z9/7bwM1g6nkb84DJTBk65ikk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eGh9Eh/btsLT3pU7Z9/7bwM1g6nkb84DJTBk65ikk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eGh9Eh/btsLT3pU7Z9/7bwM1g6nkb84DJTBk65ikk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeGh9Eh%2FbtsLT3pU7Z9%2F7bwM1g6nkb84DJTBk65ikk%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;603&quot; height=&quot;83&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;836&quot; data-origin-height=&quot;115&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;03. 코드 작성 (NLP 무료 버전 이용 - 실패)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저번에 지도 위치 추가하는 건 새로고침해도 저장했던 정보가 남아있어야 하기 때문에 데이터베이스가 필요했다. 그래서 domain에서 Entity부터 추가하였었다. 하지만 일기는 자동으로 생성해 주기 때문에 사용자랑 상관이 없고, 딱히 저장할 필요 없이 그냥 새로고침될 때마다 생성되게 하였다. 그래서 체크리스트에 변동이 있거나 지도에 마커를 추가/삭제할 때마다 그 즉시 일기가 바뀌도록 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Entity, Repositoty, DTO 없이 바로 서비스 로직에서 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 아까 말했다시피 무료버전을 사용하기로 한 계획은 망했기 때문에(...) 아래 코드들은 현재 전부 주석처리 해둔 상태다,,,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;service -&amp;gt; DiaryService 생성&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Value를 통해 application.properties에 적어두었던 모델 주소와 키를 불러온다.&lt;/p&gt;
&lt;pre id=&quot;code_1737423629079&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class DiaryService {
	
    @Value(&quot;${huggingface.api.url}&quot;)
    private String apiUrl;
    
    @Value(&quot;${huggingface.api.token}&quot;)
    private String apiToken;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Http 요청 준비로, 헤더 객체를 생성하여 API 호출에 필요한 정보들을 담는다. JSON 형식으로 지정해야 한다. 본문 객체는 맵으로 생성해서, API의 동작을 조절하는 파라미터를 추가한다. temperature은 높을수록 텍스트의 랜덤성이 증가하고, top_p는 샘플링할 확률 분포를 나타내는 값이다. 이 객체들을 HttpEntity로 하나로 담는다. 이때 requestBody를 JSON으로 직렬화하기 위해 objectMapper를 사용한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1737424015128&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// API 요청 준비
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(apiToken);
headers.setContentType(MediaType.APPLICATION_JSON);

Map&amp;lt;String, Object&amp;gt; requestBody = new HashMap&amp;lt;&amp;gt;();
requestBody.put(&quot;inputs&quot;, prompt);
requestBody.put(&quot;parameters&quot;, Map.of(
        &quot;max_length&quot;, 512,
        &quot;temperature&quot;, 0.7,
        &quot;top_p&quot;, 0.9
));

HttpEntity&amp;lt;String&amp;gt; request = new HttpEntity&amp;lt;&amp;gt;(
        objectMapper.writeValueAsString(requestBody),
        headers
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;restTemplete.exchange 메서드를 사용해서 API를 POST로 호출하고, 결과를 ResponseEntity에 담는다. 응답 상태 코드가 200 ok이고 응답 본문이 존재하는 경우, 일기 텍스트를 반환한다.(생성된 일기 텍스트를 확실히 검증하기 위해 generated_text 키가 포함되어 있는지 확인하였다)&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1737424695178&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// API 호출 및 응답 처리
ResponseEntity&amp;lt;List&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt;&amp;gt; response = restTemplate.exchange(
        apiUrl,
        HttpMethod.POST,
        request,
        new ParameterizedTypeReference&amp;lt;List&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt;&amp;gt;() {}
);
System.out.println(&quot;API response: &quot; + response.getBody()); // 응답 디버깅

if (response.getStatusCode() == HttpStatus.OK &amp;amp;&amp;amp; response.getBody() != null) {
    List&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt; responseBody = response.getBody();
    System.out.println(&quot;API response: &quot; + responseBody); // 디버깅용 로그

    if (!responseBody.isEmpty() &amp;amp;&amp;amp; responseBody.get(0).containsKey(&quot;generated_text&quot;)) {
        return responseBody.get(0).get(&quot;generated_text&quot;);
    }
}

return &quot;Diary creation failed.&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트는 아래처럼 작성했다. 처음엔 모르고 한글로 했다가... 내가 보냈던 요청 본문이 응답 본문으로 그대로 되돌아오길래 한참을 고생했다,,,, 그런데 아래처럼 영어로 해도...&lt;/p&gt;
&lt;pre id=&quot;code_1737425039338&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private String buildPrompt(ChallengeChecklist checklist) {
    //프롬프트 생성
    StringBuilder prompt = new StringBuilder();
    String dateStr = checklist.getDate().format(DateTimeFormatter.ofPattern(&quot;yyyy.MM.dd&quot;));

    prompt.append(&quot;I'll tell you what the user did during the day, so please write a message of support along with a brief summary.\n\n&quot;);
    prompt.append(&quot;Today's date is &quot;).append(dateStr);

    //체크리스트 정보 추가
    if (checklist.isTask1Completed() || checklist.isTask2Completed() || checklist.isTask3Completed()) {
        prompt.append(&quot;What the user did:\n&quot;);

        if (checklist.isTask1Completed()) prompt.append(&quot;-study: completed\n&quot;);
        if (checklist.isTask2Completed()) prompt.append(&quot;-Walk: completed\n&quot;);
        if (checklist.isTask3Completed()) prompt.append(&quot;-exercise: completed\n&quot;);
    }

    //위치 정보 추가
    if (!checklist.getLocations().isEmpty()) {
        prompt.append(&quot;\nPlaces the user walked:\n&quot;);
        checklist.getLocations().forEach(location -&amp;gt;
                prompt.append(String.format(&quot;-Latitude %.6f, Longitude %.6f\n&quot;,
                        location.getLatitude(), location.getLongitude()
                ))
        );
    }
    prompt.append(&quot;\nPlease fill out the form based on the information above.&quot;);
    return prompt.toString();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과:&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;482&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bu88Pt/btsLUvsG1CP/XjeGauwljJ9gKPlL6hhq91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bu88Pt/btsLUvsG1CP/XjeGauwljJ9gKPlL6hhq91/img.png&quot; data-alt=&quot;...?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bu88Pt/btsLUvsG1CP/XjeGauwljJ9gKPlL6hhq91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbu88Pt%2FbtsLUvsG1CP%2FXjeGauwljJ9gKPlL6hhq91%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;684&quot; height=&quot;387&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;482&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;...?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;016&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/016.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/016.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랜덤률이 높은가? 싶어서 낮춰봐도&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;865&quot; data-origin-height=&quot;495&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cSItNP/btsLUt9qJaZ/UMCzHKeJIK1t9qwSKWe4L0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cSItNP/btsLUt9qJaZ/UMCzHKeJIK1t9qwSKWe4L0/img.png&quot; data-alt=&quot;......?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cSItNP/btsLUt9qJaZ/UMCzHKeJIK1t9qwSKWe4L0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcSItNP%2FbtsLUt9qJaZ%2FUMCzHKeJIK1t9qwSKWe4L0%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;665&quot; height=&quot;381&quot; data-origin-width=&quot;865&quot; data-origin-height=&quot;495&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;......?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;047&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/047.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/047.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자꾸 헛소리만 한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파라미터를 최대한 조절해 보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-21 111507.png&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;217&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/x1VEG/btsLUVxE6JJ/mEIOJEIe1MW2ADETB4zAfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/x1VEG/btsLUVxE6JJ/mEIOJEIe1MW2ADETB4zAfK/img.png&quot; data-alt=&quot;...........&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/x1VEG/btsLUVxE6JJ/mEIOJEIe1MW2ADETB4zAfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fx1VEG%2FbtsLUVxE6JJ%2FmEIOJEIe1MW2ADETB4zAfK%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;1390&quot; height=&quot;217&quot; data-filename=&quot;스크린샷 2025-01-21 111507.png&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;217&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;...........&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;045&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/045.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/045.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;때려쳐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 한 1시간 동안 계속 모델 바꿔가면서 파라미터도 조절해 보고 프롬프트 양식도 바꿔보고 그랬는데...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 뭐 잘못한 게 아니라면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론: 무료로 텍스트 생성까지 바라지 말자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;04. 코드 작성 (직접 생성 + 카카오 REST API)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 제대로 해보고 싶으면 그때 유료 버전을 사용해 보기로 하고... 뭐 그래도 위에 거 해보면서 실제 다른 모델이랑 어떻게 Http로 통신해서 답을 받아오는지는 알았으니 공부한 셈 쳤다 하하&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암튼 그래서 그냥 내가 직접 작성하기로 함!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 시작 ㄱ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;service -&amp;gt; DiraryService 수정&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그전 코드들은 전부 주석처리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 직접 할 거니까 application.properties에 적었던 모델 주소나 키도 필요 없다. (혹시 몰라서 그냥 두긴 함)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어차피 체크리스트+지도위치 정보 가지고 일기 생성하는 거니까, 체크리스트만 조건으로 나누면 됐었다. 그래서 모든 경우의 수를 다 적었다... (동숲같이 응원 메시지? 느낌으로 적었더니 완성물은 일기가 아니라 한줄평 같이 되어버렸지만 ㅋㅋ)&lt;/p&gt;
&lt;pre id=&quot;code_1737426324610&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; public String generateDiary(ChallengeChecklist checklist) {

        //프롬프트 생성
        StringBuilder prompt = new StringBuilder();
        String dateStr = checklist.getDate().format(DateTimeFormatter.ofPattern(&quot;yyyy년 MM월 dd일&quot;));

        prompt.append(&quot;날짜: &quot;).append(dateStr).append(&quot;\n&quot;);

        //체크리스트 정보 추가
        prompt.append(&quot;오늘은 &quot;);
        if (checklist.isAllCompleted()) {
            prompt.append(&quot;공부, 산책, 운동 모두 완료했어! 대단한데?&quot;);
        } else if (checklist.isTask1Completed() &amp;amp;&amp;amp; checklist.isTask2Completed() &amp;amp;&amp;amp; !checklist.isTask3Completed()) {
            prompt.append(&quot;공부, 산책 완료! 열심히 공부하고 밖에도 나갔으니 이 정도면 잘했어~! 내일은 운동도 해보자!!&quot;);
        }
        .
        .
        .
        } else {
            prompt.append(&quot;잠시 쉬어가는 날이야~ 충전 중...&quot;);
        }&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 위치 정보는 기존에 위도, 경도로 저장되어 있었어서 출력할 때도 일기에 그대로 위도, 경도로 표시되었었다. 이렇게 하면 당연히 사용자(나) 입장에선 이게 대체 어딘지 감도 안 오고 별로 의미도 없어 보였기에... 찾아보니 카카오 맵 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;기존엔 지도 맵 출력+마커관리를 자바스크립트에서 했으니 js키만 발급했었는데, 이번엔 REST API키가 필요하다. 다시 카카오 지도 API 사이트로 가서 해당 키를 복사하고 application.properties에 추가한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;678&quot; data-origin-height=&quot;425&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mUmPb/btsLSN9amct/eV0lZ4MkBgW3GnUsz0nKV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mUmPb/btsLSN9amct/eV0lZ4MkBgW3GnUsz0nKV1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mUmPb/btsLSN9amct/eV0lZ4MkBgW3GnUsz0nKV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmUmPb%2FbtsLSN9amct%2FeV0lZ4MkBgW3GnUsz0nKV1%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;566&quot; height=&quot;355&quot; data-origin-width=&quot;678&quot; data-origin-height=&quot;425&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 해당 REST API의 URL은 아래에서 확인할 수 있다. 이것도 같이 복사해서 format에는 json이라고 적고 추가한다.&lt;/p&gt;
&lt;figure id=&quot;og_1737427062624&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Kakao Developers&quot; data-og-description=&quot;카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.&quot; data-og-host=&quot;developers.kakao.com&quot; data-og-source-url=&quot;https://developers.kakao.com/docs/latest/ko/local/dev-guide#coord-to-address&quot; data-og-url=&quot;https://developers.kakao.com/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/pgMx4/hyX4w7bDLH/RjEyh4LPomTLktTLxPkhnK/img.png?width=800&amp;amp;height=400&amp;amp;face=0_0_800_400,https://scrap.kakaocdn.net/dn/nz0So/hyX4mjbSQY/D2w99cSw5ipkM7MMVmrksK/img.png?width=3840&amp;amp;height=1000&amp;amp;face=0_0_3840_1000,https://scrap.kakaocdn.net/dn/chNQVn/hyX4zv4fNM/zE67WRlVQMuwQOGVZj6mR0/img.png?width=3840&amp;amp;height=1000&amp;amp;face=0_0_3840_1000&quot;&gt;&lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/local/dev-guide#coord-to-address&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developers.kakao.com/docs/latest/ko/local/dev-guide#coord-to-address&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/pgMx4/hyX4w7bDLH/RjEyh4LPomTLktTLxPkhnK/img.png?width=800&amp;amp;height=400&amp;amp;face=0_0_800_400,https://scrap.kakaocdn.net/dn/nz0So/hyX4mjbSQY/D2w99cSw5ipkM7MMVmrksK/img.png?width=3840&amp;amp;height=1000&amp;amp;face=0_0_3840_1000,https://scrap.kakaocdn.net/dn/chNQVn/hyX4zv4fNM/zE67WRlVQMuwQOGVZj6mR0/img.png?width=3840&amp;amp;height=1000&amp;amp;face=0_0_3840_1000');&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;Kakao Developers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developers.kakao.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 js키 추가했던 거 밑에 적어줬다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;897&quot; data-origin-height=&quot;128&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CSGgt/btsLUeY1pBV/Ol9ZxfGbgDVwm03Tx1Xdjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CSGgt/btsLUeY1pBV/Ol9ZxfGbgDVwm03Tx1Xdjk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CSGgt/btsLUeY1pBV/Ol9ZxfGbgDVwm03Tx1Xdjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCSGgt%2FbtsLUeY1pBV%2FOl9ZxfGbgDVwm03Tx1Xdjk%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;699&quot; height=&quot;100&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;897&quot; data-origin-height=&quot;128&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 주소로 queryParam으로 위도, 경도 정보를 보낸다. 난 체크리스트 엔티티에 담겨있는 위치 정보에서 불러왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 아까 실패한() 방식과 비슷하게 HTTP 요청 헤더를 설정하고(여기서는 카카오에서 필요한 정보 담기), restTemplate.exchange로 API를 호출하면서 응답을 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 코드는 아래 문서에서 지정하는 형식대로 적어준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;959&quot; data-origin-height=&quot;649&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceS0Ew/btsLUqLFS0e/GG0fkkxgxEyKkkm9Mfjojk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceS0Ew/btsLUqLFS0e/GG0fkkxgxEyKkkm9Mfjojk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceS0Ew/btsLUqLFS0e/GG0fkkxgxEyKkkm9Mfjojk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceS0Ew%2FbtsLUqLFS0e%2FGG0fkkxgxEyKkkm9Mfjojk%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;595&quot; height=&quot;403&quot; data-origin-width=&quot;959&quot; data-origin-height=&quot;649&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1737427345691&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public String getAddress(double latitude, double longitude) {

    //요청 URL 생성
    String url = UriComponentsBuilder.fromHttpUrl(KAKAO_API_URL)
            .queryParam(&quot;x&quot;, longitude)
            .queryParam(&quot;y&quot;, latitude)
            .toUriString();

    //HTTP 요청 헤더 설정
    HttpHeaders headers = new HttpHeaders();
    headers.set(&quot;Authorization&quot;, &quot;KakaoAK &quot; + kakaoMapsRestApiKey);

    //REST API 호출
    RestTemplate restTemplate = new RestTemplate();
    ResponseEntity&amp;lt;String&amp;gt; response = restTemplate.exchange(url, HttpMethod.GET,
            new org.springframework.http.HttpEntity&amp;lt;&amp;gt;(headers), String.class);

    // JSON 응답에서 주소 추출
    String responseBody = response.getBody();
    if (responseBody != null) {
        return extractAddressFromJson(responseBody);
    }
    return &quot;주소를 찾을 수 없습니다.&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답에서 따로 주소 부분만 추출해서 일기 생성로직에 반환하는 형식이다. objectMapper로 JSON을 파싱 하여 첫 번째 문서 서를 추출하고, 거기서 주소 정보를 다시 추출한다. 아래 문서에 따라 순서대로 들어가서, documents -&amp;gt; address -&amp;gt; address_name을 불러오면 된다,&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;875&quot; data-origin-height=&quot;688&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/62u4F/btsLUTT8M5r/omE5wFqjSaykPyWPDO5Uak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/62u4F/btsLUTT8M5r/omE5wFqjSaykPyWPDO5Uak/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/62u4F/btsLUTT8M5r/omE5wFqjSaykPyWPDO5Uak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F62u4F%2FbtsLUTT8M5r%2FomE5wFqjSaykPyWPDO5Uak%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;653&quot; height=&quot;513&quot; data-origin-width=&quot;875&quot; data-origin-height=&quot;688&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;177&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bj1TIO/btsLVjZmYBo/lC6Fd7480cDc2VYEK20E0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bj1TIO/btsLVjZmYBo/lC6Fd7480cDc2VYEK20E0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bj1TIO/btsLVjZmYBo/lC6Fd7480cDc2VYEK20E0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbj1TIO%2FbtsLVjZmYBo%2FlC6Fd7480cDc2VYEK20E0k%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;559&quot; height=&quot;157&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;177&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1737427534144&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// JSON 응답에서 주소 추출
private static String extractAddressFromJson(String responseBody) {
    try {
        // Jackson 라이브러리를 사용해 JSON 파싱
        com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
        JsonNode rootNode = objectMapper.readTree(responseBody);
        JsonNode documents = rootNode.path(&quot;documents&quot;);

        // 첫 번째 문서에서 주소 정보 추출
        if (documents.isArray() &amp;amp;&amp;amp; !documents.isEmpty()) {
            JsonNode addressNode = documents.get(0).path(&quot;address&quot;);
            return addressNode.path(&quot;address_name&quot;).asText();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return &quot;주소를 찾을 수 없습니다.&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주소 반환이 완료되면, 아까 일기 생성 메서드에서 체크리스트 조건문 다음에 주소 정보가 추가된다. 이것들을 전부 string으로 변환하여 반환하면, 컨트롤러에서 이를 불러오는 형식이다.&lt;/p&gt;
&lt;pre id=&quot;code_1737427886819&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//위치 정보 추가
if (!checklist.getLocations().isEmpty()) {
    prompt.append(&quot;\n\n오늘 갔다왔던 장소들은 여기야!\n&quot;);
    List&amp;lt;Location&amp;gt; locations = checklist.getLocations();
    for (Location location : locations) {
        String address = getAddress(location.getLatitude(), location.getLongitude());
        prompt.append(&quot;-&quot;);
        prompt.append(address);
        prompt.append(&quot;\n&quot;);
    }
} else {
    prompt.append(&quot;\n&quot;);
}
prompt.append(&quot;\n하루 정리 끝! 총평: 언제나 별점 만점이야\n&quot;);
return prompt.toString();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;controller -&amp;gt; ChallengeController 수정&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 작성했던 날자 페이지를 GET 하는 부분에서, 일기 생성 로직을 추가로 불러와서 모델에 담아 보낸다. 이때 갑자기 잘되던 날짜 파싱이 오류가 나서... 큰따옴표를 빼는 작업을 추가하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1737428015428&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@GetMapping(&quot;/date/{date}&quot;)
public String datePage(@PathVariable String date, Model model) {
    date = date.replace(&quot;\&quot;&quot;, &quot;&quot;);
    LocalDate parsedDate = LocalDate.parse(date);
    ChallengeChecklist checklist = challengeService.getChallengeByDate(parsedDate);

    // Location 정보를 DTO로 변환
    List&amp;lt;LocationDTO&amp;gt; locationDTOs = checklist.getLocations().stream()
        .map(location -&amp;gt; {
            LocationDTO dto = new LocationDTO();
            dto.setId(location.getId());
            dto.setLatitude(location.getLatitude());
            dto.setLongitude(location.getLongitude());
            return dto;
        })
        .collect(Collectors.toList());

    //일기 생성
    String diary = diaryService.generateDiary(checklist);
    model.addAttribute(&quot;diary&quot;, diary);

    model.addAttribute(&quot;checklist&quot;, checklist);
    model.addAttribute(&quot;date&quot;, date);
    model.addAttribute(&quot;locations&quot;, locationDTOs);
    model.addAttribute(&quot;kakaoMapsApiKey&quot;, kakaoMapsApiKey);
    return &quot;date&quot;; // date.html
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;templates -&amp;gt; date.html 수정&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 지도 맵, 메인 페이지 되돌아가기 링크 사이에 일기 div를 추가하였다. 컨트롤러에서 보낸 diary 모델을 타임리프로 꺼내면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1737428171016&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div id=&quot;map&quot;&amp;gt;&amp;lt;/div&amp;gt;

&amp;lt;div class=&quot;diary-section&quot;&amp;gt;
    &amp;lt;h2&amp;gt;오늘의 일기&amp;lt;/h2&amp;gt;
    &amp;lt;div class=&quot;diary-content&quot; th:text=&quot;${diary}&quot;&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;a href=&quot;/&quot; class=&quot;back-link&quot;&amp;gt;&amp;larr; 메인 페이지로 이동&amp;lt;/a&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;05. 실행&lt;/h3&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;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;kakaotv&quot; data-video-url=&quot;https://tv.kakao.com/v/452487959&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/cdfEQb/hyX4qeQyQy/HhVYokLslSJ6nFDvJIGcyK/img.jpg?width=1510&amp;amp;height=964&amp;amp;face=0_0_1510_964,https://scrap.kakaocdn.net/dn/b0wZn4/hyX4lkiiae/sLI7yEQtJq1qP1v9GUJOvK/img.jpg?width=1510&amp;amp;height=964&amp;amp;face=0_0_1510_964&quot; data-video-width=&quot;860&quot; data-video-height=&quot;549&quot; data-video-origin-width=&quot;860&quot; data-video-origin-height=&quot;549&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-video-play-service=&quot;daum_tistory&quot; data-original-url=&quot;&quot; data-video-title=&quot;&quot;&gt;&lt;iframe src=&quot;https://play-tv.kakao.com/embed/player/cliplink/452487959?service=daum_tistory&quot; width=&quot;860&quot; height=&quot;549&quot; frameborder=&quot;0&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption style=&quot;display: none;&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&gt;
&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;메인 페이지 보완&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;01. 구상&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #8cb3be;&quot;&gt;&lt;b&gt;25.01.15&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지도, 일기까지 추가하면서 느낀 건데, 메인 페이지에서 현재 3개 모두 완료했을 때만 녹색으로 뜨는 게 불편했다. 사실상 투두보다는 챌린지가 목적이니 그게 한눈에 알아보기는 쉽겠지만... 문제는 오늘이 며칠인지 헷갈린다는 점이었다. 예를 들어 10일에만 모두 완료해서 녹색으로 완료한 상태고, 11일 12일에는 2개만 해서 기본 흰색이라면, 13일에 접속했을 때 순간 착각하고 빈 색인 11일로 들어가 버렸다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 2개까지 해도 나름 잘한 것 같은데(내 생각이지만) 아예 안 한 날이랑 같은 취급받는 것도 좀 그래서(...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 3개 완료 녹색에 추가로 1~2개 완료 시엔 노란색으로 뜨게 수정할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 생각했던 거긴 한데 이걸 수정하려면 메인 엔티티에 column을 추가해야 해서 데이터베이스를 수정해야 하는 상황이 벌어져서... 쫄려서 미루고 있다가 이제서야 도전해 본 것이다,,,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;02. 코드 작성&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;domain -&amp;gt; ChallengeChecklist 수정&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 작성했던 엔티티에서, 원래는 allCompleted만 있었다. 이제 여기에 부분 완료를 저장할 patiallyCompleted를 추가한다.&lt;/p&gt;
&lt;pre id=&quot;code_1737430737688&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
@Table(name = &quot;challenge_checklist&quot;)
@Getter @Setter
public class ChallengeChecklist {
    .
    .
    .
    @Column(nullable = false)
    private boolean allCompleted = false; //모두 완료: 녹색

    @Column(nullable = false)
    private boolean partiallyCompleted = false; //1~2개 완료: 노란색
    .
    .
    .
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;application.properties&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 자세히 알게 된 점인데, 설정에서 ddl-auto를 update로 해놓으면 위처럼 엔티티를 수정해도 데이터베이스에서 자동으로 수정된다는 점이었다. 다행히 내가 걱정했던 엔티티 오류 문제없이 기존에 저장했던 데이터베이스도 그대로 활용할 수 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1737430854276&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring.jpa.hibernate.ddl-auto=update&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 이건 로컬 데이터베이스 기준이고...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 완성하고 나서 클라우드타입으로 재배포했는데 반영이 안 돼서 망했다. 찾아보니 클라우드타입에서 무료버전 기준으로 데이터베이스 자동 업데이트 같은 건 지원하지 않는다고 한다. 그래서 그냥 거기서는 기존 마리아db 배포했던 거 삭제하고 다시 배포해서 그전 기록들 직접 다시 저장했다......&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이렇게 직접 엔티티를 건드리는 방식 말고도 다른 방식을 생각해 보긴 했었다. 메인 페이지 상단에서 완료 날짜 일 수를 계산해서 진행바를 표시했던 것처럼, 날짜 페이지에서도 비슷하게 완료 개수만 계산하려고 했는데 이건 날짜 페이지마다 다 다르니 서비스로직만으로 해결하기 어려웠다ㅠ.ㅠ(걍 내가 몰라서 못한 걸 수도)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;service -&amp;gt; ChallengeService 수정&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 엔티티를 수정했으니 서비스는 수정하기 더 간단했다. 그냥 기존 업데이트 로직에서 부분완료 변수도 같이 set해서 레포지토리에 저장해 주면 된다. 이렇게 하면 컨트롤러도 그대로 사용할 수 있고, 메인페이지 화면만 수정하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1737431304150&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void updateChecklist(LocalDate date, boolean task1, boolean task2, boolean task3) {
    ChallengeChecklist checklist = repository.findByDate(date);

    if (checklist != null) {
        checklist.setTask1Completed(task1);
        checklist.setTask2Completed(task2);
        checklist.setTask3Completed(task3);

        int completedTasks = (task1 ? 1 : 0) + (task2 ? 1 : 0) + (task3 ? 1 : 0);
        checklist.setPartiallyCompleted(completedTasks == 1 || completedTasks == 2);
        checklist.setAllCompleted(completedTasks == 3);

        repository.save(checklist);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;templates -&amp;gt; main.html 수정&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타임리프에서 받아온 challenge 모델에서 변수를 삼항조건으로 판단한다. 이 변수대로 css를 추가하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1737431491531&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div class=&quot;grid-container&quot;&amp;gt;
    &amp;lt;a th:each=&quot;challenge : ${challenges}&quot;
       th:href=&quot;@{'/date/' + ${challenge.date}}&quot;
       class=&quot;date-box&quot;
       th:classappend=&quot;${challenge.allCompleted} ? 'completed' : 
          (${challenge.partiallyCompleted} ? 'partially-completed' : '')&quot;
       th:text=&quot;${#temporals.format(challenge.date, 'MM/dd')}&quot;&amp;gt;
    &amp;lt;/a&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1737431699041&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.completed {
    background-color: #90EE90; /* 녹색 */
    border-color: #70DD70;
}
.partially-completed {
    background-color: #f8f6b4;  /* 노란색 */
    border-color: #e2dc69;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;색상 코드는 아래 페이지에서 확인했다. 예전에 vs였나 안드로이드스튜디오에선 코드에서 바로 컬러팔레트로 지정할 수 있었는데... 인텔리제이에선 모르겠어서 그냥 따로 찾았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://wepplication.github.io/tools/colorPicker/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://wepplication.github.io/tools/colorPicker/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1737431717975&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;HTML 색상표&quot; data-og-description=&quot;온라인 RGB 색상표, HSL, HSV, CSS 칼라코드 모음&quot; data-og-host=&quot;wepplication.github.io&quot; data-og-source-url=&quot;https://wepplication.github.io/tools/colorPicker/&quot; data-og-url=&quot;https://wepplication.github.io/tools/colorPicker/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/PmMfC/hyX4qlAAdY/xW2eOjRld88yJixyHJrde0/img.png?width=480&amp;amp;height=800&amp;amp;face=0_0_480_800&quot;&gt;&lt;a href=&quot;https://wepplication.github.io/tools/colorPicker/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://wepplication.github.io/tools/colorPicker/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/PmMfC/hyX4qlAAdY/xW2eOjRld88yJixyHJrde0/img.png?width=480&amp;amp;height=800&amp;amp;face=0_0_480_800');&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;HTML 색상표&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;온라인 RGB 색상표, HSL, HSV, CSS 칼라코드 모음&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;wepplication.github.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;03. 실행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 3개 완료한 날은 녹색으로, 1~2개 완료한 날은 노란색, 아예 정보가 없는 건 기본 흰색으로 표시된다. 상단에 진행바는 여전히 3개 완료한 녹색일 기준이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-21 122653.png&quot; data-origin-width=&quot;1159&quot; data-origin-height=&quot;849&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6TTpS/btsLTGhwYkE/gLukNts2ApGy4kyAuFWKn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6TTpS/btsLTGhwYkE/gLukNts2ApGy4kyAuFWKn0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6TTpS/btsLTGhwYkE/gLukNts2ApGy4kyAuFWKn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6TTpS%2FbtsLTGhwYkE%2FgLukNts2ApGy4kyAuFWKn0%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;699&quot; height=&quot;512&quot; data-filename=&quot;스크린샷 2025-01-21 122653.png&quot; data-origin-width=&quot;1159&quot; data-origin-height=&quot;849&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;+&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 하고 클라우드타입에서 새로 작성한 카카오 REST API키도 환경 변수로 설정해 주고, DB도 다시 삭제 후 재배포하면 완료~!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 추가한 기능들로도 잘 사용하고 있어서... 아직 더 생각은 안 나지만 나중에 또 필요한 기능이 있으면 추가할 예정,,,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignLeft&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;019&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/019.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/019.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category> Project/Challenge - 100일 챌린지 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/73</guid>
      <comments>https://rim08.tistory.com/73#entry73comment</comments>
      <pubDate>Tue, 21 Jan 2025 13:02:16 +0900</pubDate>
    </item>
    <item>
      <title>[토이프로젝트 개발일지 03] 산책 경로 저장 기능 추가 (카카오 지도 api)</title>
      <link>https://rim08.tistory.com/72</link>
      <description>&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 기능 추가할 때마다 깃허브에 readme로 쓰고 있었다가... 이제서야 한번에 정리 겸 글 써봄&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-16 195520.png&quot; data-origin-width=&quot;909&quot; data-origin-height=&quot;798&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ck8sgv/btsLRkRADPE/fazAHkLUeujMzMc38oVAz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ck8sgv/btsLRkRADPE/fazAHkLUeujMzMc38oVAz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ck8sgv/btsLRkRADPE/fazAHkLUeujMzMc38oVAz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fck8sgv%2FbtsLRkRADPE%2FfazAHkLUeujMzMc38oVAz1%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;660&quot; height=&quot;579&quot; data-filename=&quot;스크린샷 2025-01-16 195520.png&quot; data-origin-width=&quot;909&quot; data-origin-height=&quot;798&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1345&quot; data-origin-height=&quot;892&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBthZC/btsLPNOoGvS/ZymdnqeoqnzwINDIwOXeU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBthZC/btsLPNOoGvS/ZymdnqeoqnzwINDIwOXeU1/img.png&quot; data-alt=&quot;~완성화면 미리보기~&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBthZC/btsLPNOoGvS/ZymdnqeoqnzwINDIwOXeU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBthZC%2FbtsLPNOoGvS%2FZymdnqeoqnzwINDIwOXeU1%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;703&quot; height=&quot;466&quot; data-origin-width=&quot;1345&quot; data-origin-height=&quot;892&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;~완성화면 미리보기~&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&gt;
&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;산책(이동) 경로 저장 기능 개발&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;01. 구상&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #8cb3be;&quot;&gt;&lt;b&gt;25.01.13&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;01.12에 서버 배포를 완료한 뒤, 다음날에 새로운 프로젝트로 현위치 기반 맛집 추천을 해주는 웹을 만들고 싶었다. api를 다루는 방법을 익히고 싶어서 카카오 지도 api를 쓰는 주제를 선택한 거였는데, 막상 해보니 맛집 추천 정도는 백엔드가 필요 없고 프론트만으로도 해볼 수 있길래 포기했다.(백엔드 공부하는게 목적이라,,,)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래도 지도 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;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;02. 카카오 지도 api 키 발급&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://apis.map.kakao.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://apis.map.kakao.com/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 사이트 우측 상단에서 APP KEY 발급을 한다. 그러면 내 애플리케이션 화면에서 앱 키가 나오는데, 여기서 지도 맵은 html 안의 &amp;lt;script&amp;gt; 태그 안에서 사용할 것이므로 JavaScript 키를 복사하여 application.properties에 추가하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;528&quot; data-origin-height=&quot;348&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/twKk3/btsLQp7hZDw/KykbeqFKJacjvQEEkSNHtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/twKk3/btsLQp7hZDw/KykbeqFKJacjvQEEkSNHtk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/twKk3/btsLQp7hZDw/KykbeqFKJacjvQEEkSNHtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtwKk3%2FbtsLQp7hZDw%2FKykbeqFKJacjvQEEkSNHtk%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;454&quot; height=&quot;299&quot; data-origin-width=&quot;528&quot; data-origin-height=&quot;348&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;257&quot; data-origin-height=&quot;19&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/by47YR/btsLQotODOK/8gQlgwKuWIfix2JBp3S5R0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/by47YR/btsLQotODOK/8gQlgwKuWIfix2JBp3S5R0/img.png&quot; data-alt=&quot;깃허브에 올릴 때는 변수로 암호화 시키고, 그냥 내 컴퓨터에서 할 때는 복사한 키 그대로 넣어서 사용했다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/by47YR/btsLQotODOK/8gQlgwKuWIfix2JBp3S5R0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fby47YR%2FbtsLQotODOK%2F8gQlgwKuWIfix2JBp3S5R0%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;352&quot; height=&quot;26&quot; data-origin-width=&quot;257&quot; data-origin-height=&quot;19&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;깃허브에 올릴 때는 변수로 암호화 시키고, 그냥 내 컴퓨터에서 할 때는 복사한 키 그대로 넣어서 사용했다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 플랫폼 설정에서 도메인 등록을 해줘야 지도 이용이 가능하다. Web에서 &lt;span style=&quot;text-align: left;&quot;&gt;http://localhost:8080를 추가하고(로컬 컴퓨터 용) 나중에 서버 배포할 때 그 주소도 넣어줘야 이용할 수 있다.(처음엔 이걸 안 넣어줘서 계속 오류났었다......)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;또 제일 중요한 건 카카오맵 설정에서 활성화 버튼을 ON으로 해줘야 된다!!(이거 몰라서 또 한참 오류남22)&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;826&quot; data-origin-height=&quot;375&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6PPtR/btsLQCk5BVN/JOzGTTnRwstlSSfvfwwh7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6PPtR/btsLQCk5BVN/JOzGTTnRwstlSSfvfwwh7K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6PPtR/btsLQCk5BVN/JOzGTTnRwstlSSfvfwwh7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6PPtR%2FbtsLQCk5BVN%2FJOzGTTnRwstlSSfvfwwh7K%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;630&quot; height=&quot;286&quot; data-origin-width=&quot;826&quot; data-origin-height=&quot;375&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 카카오맵에서의 설정은 끝났고, 처음 메인화면에서 여러 샘플들을 둘러보며 추가하고 싶은 기능에 대해 살펴볼 수 있다. -&amp;gt;&lt;a href=&quot;https://apis.map.kakao.com/web/sample/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://apis.map.kakao.com/web/sample/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1697&quot; data-origin-height=&quot;964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KtmEH/btsLP07TJB2/e6y0lmjw7Eflz90qfhNl3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KtmEH/btsLP07TJB2/e6y0lmjw7Eflz90qfhNl3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KtmEH/btsLP07TJB2/e6y0lmjw7Eflz90qfhNl3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKtmEH%2FbtsLP07TJB2%2Fe6y0lmjw7Eflz90qfhNl3K%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;708&quot; height=&quot;402&quot; data-origin-width=&quot;1697&quot; data-origin-height=&quot;964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 중에서 아래 기능을 추가해볼 것이다. 들어가면 코드 예제도 나와있어서 쉽게 참고할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;929&quot; data-origin-height=&quot;755&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cUCFLp/btsLQoUT6sX/MilzFBgHHjoms9YOuMWvr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cUCFLp/btsLQoUT6sX/MilzFBgHHjoms9YOuMWvr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cUCFLp/btsLQoUT6sX/MilzFBgHHjoms9YOuMWvr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcUCFLp%2FbtsLQoUT6sX%2FMilzFBgHHjoms9YOuMWvr0%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;562&quot; height=&quot;457&quot; data-origin-width=&quot;929&quot; data-origin-height=&quot;755&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;03. 코드 작성&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;domain -&amp;gt; Location 생성&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 ChallengeChecklist Entity에 추가하면 DB까지 바뀌어야 되므로, 새로운 Entity를 만들어서 join시켜줬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적인 id와, 마커를 표시하기 위한 위도/경도를 지정하였다. 그리고 기존 ChallengeChecklist Entity와 연결시킨 것도 추가한다.&lt;/p&gt;
&lt;pre id=&quot;code_1737026045660&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
@Table(name = &quot;locations&quot;)
@Getter @Setter
public class Location {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private Double latitude; //위도

    @Column(nullable = false)
    private Double longitude; //경도

    @ManyToOne
    @JoinColumn(name = &quot;checklist_id&quot;)
    private ChallengeChecklist checklist;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;domain -&amp;gt; ChallengeChecklist 수정&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 ChallengeChecklist Entity는 하나의 날짜 페이지라고 볼 수 있다. 날짜 페이지별로 날짜, 체크리스트 여부를 저장하고 있었는데, 여기에 지도 정보(마커)는 여러개 생성될 수 있다.(산책 간 곳마다 마커로 표시할 거라서) 따라서 @OneToMany로 연결시키고, 마커들을 List에 담았다. (새로 ArrayList로 생성해서 관리하면 수정해도 원본엔 영향을 안 미치도록 할 수 있어 안전하다.) 마커는 List에 추가, 삭제할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1737026487898&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@OneToMany(mappedBy = &quot;checklist&quot;, cascade = CascadeType.ALL, orphanRemoval = true)
List&amp;lt;Location&amp;gt; locations = new ArrayList&amp;lt;&amp;gt;();

public void addLocation(Location location) {
    locations.add(location);
    location.setChecklist(this);
}

public void removeLocation(Location location) {
    locations.remove(location);
    location.setChecklist(null);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;318&quot; data-origin-height=&quot;314&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qjBRH/btsLQ5762Fq/NYeqsQ4kJb4AImVIJLuXI1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qjBRH/btsLQ5762Fq/NYeqsQ4kJb4AImVIJLuXI1/img.png&quot; data-alt=&quot;나중에 완성 후 실행하면 MySQL Workbench로 추가된 것을 볼 수 있다.(parially_completed는 다다음날에 구현한 메인페이지 수정 부분)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qjBRH/btsLQ5762Fq/NYeqsQ4kJb4AImVIJLuXI1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqjBRH%2FbtsLQ5762Fq%2FNYeqsQ4kJb4AImVIJLuXI1%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;416&quot; height=&quot;411&quot; data-origin-width=&quot;318&quot; data-origin-height=&quot;314&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;나중에 완성 후 실행하면 MySQL Workbench로 추가된 것을 볼 수 있다.(parially_completed는 다다음날에 구현한 메인페이지 수정 부분)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;repository -&amp;gt; LocationRepository 생성&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로 Entity를 만들었으므로 레포지토리도 JPA를 상속받는 것으로 하나 더 만들어 준다.&lt;/p&gt;
&lt;pre id=&quot;code_1737026879978&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface LocationRepository extends JpaRepository&amp;lt;Location, Long&amp;gt; {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;service -&amp;gt; ChallengeService 수정&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 checklist에 관한 메서드를 관리했던 서비스 코드에 지도에 관한 메서드도 추가하였다. 마커는 생성과 삭제만 있으면 되고, 이것들 역시 @Transactional로 하여 원자성을 보장하도록 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지도는 각 날짜 페이지마다 있으므로, 날짜를 받아와서 checklist 정보를 담고 있는 레포지토리에서 해당 날짜 페이지 정보를 찾아온 다음, 위치정보를 생성해서 같이 저장해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제는 위치 id를 통해 위치 정보를 담는 레포지토리에서 삭제해준다.&lt;/p&gt;
&lt;pre id=&quot;code_1737026958677&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
    public void addLocation(LocalDate date, Double latitude, Double longitude) {
        ChallengeChecklist checklist = repository.findByDate(date);
        if (checklist != null) {
            Location location = new Location();
            location.setLatitude(latitude);
            location.setLongitude(longitude);

            checklist.addLocation(location);
            repository.save(checklist);
        }
    }

    @Transactional
    public void removeLocation(Long locationId) {
        locationRepository.deleteById(locationId);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;dto -&amp;gt; LocationDTO 생성&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;controller를 작성하기 전에, 데이터 전달 객체인 DTO를 만들어준다. 사실 이걸 모르고 그냥 controller에서 바로 객체를 전달시켰더니, 지도 위치 정보인 JSON을 직렬화하는 과정에서 오류가 생겼다(불필요한 정보까지 담김). 이는 DTO를 만들어서 필요한 정보만 전달시키도록 하면 해결된다.&lt;/p&gt;
&lt;pre id=&quot;code_1737027509074&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter @Setter
public class LocationDTO {
    private Long id;
    private Double latitude;
    private Double longitude;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;controller -&amp;gt; ChallengeController 수정&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.properties에서 선언한 건 @Value로 가져올 수 있다. 이렇게 하면 자동으로 선언한 변수에 복사했던 키가 들어가게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1737027574646&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Value(&quot;${kakao.maps.api.key}&quot;)
    private String kakaoMapsApiKey;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 작성했던 날짜 페이지 컨트롤러에 위치 기능을 추가한다. 우선 checklist 객체에 담겨있는 location 정보를 stream으로 루프를 돌면서, DTO로 변환시킨다. 이후 이거를 model에 담아서 뷰로 전달한다.(api key도 전달해야 한다!)&lt;/p&gt;
&lt;pre id=&quot;code_1737027698320&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@GetMapping(&quot;/date/{date}&quot;)
public String datePage(@PathVariable String date, Model model) {
    date = date.replace(&quot;\&quot;&quot;, &quot;&quot;);
    LocalDate parsedDate = LocalDate.parse(date);
    ChallengeChecklist checklist = challengeService.getChallengeByDate(parsedDate);

    // Location 정보를 DTO로 변환
    List&amp;lt;LocationDTO&amp;gt; locationDTOs = checklist.getLocations().stream()
        .map(location -&amp;gt; {
            LocationDTO dto = new LocationDTO();
            dto.setId(location.getId());
            dto.setLatitude(location.getLatitude());
            dto.setLongitude(location.getLongitude());
            return dto;
        })
        .collect(Collectors.toList());

    model.addAttribute(&quot;locations&quot;, locationDTOs);
    model.addAttribute(&quot;kakaoMapsApiKey&quot;, kakaoMapsApiKey);
    ...
    return &quot;date&quot;; // date.html
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;controller -&amp;gt; LocationController 생성&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위치를 추가, 삭제할 땐 웹 화면에 표시되는 게 아니라 지도 맵에 표시될 것이므로 해당 컨트롤러는 HTTP 상태코드(200 ok)를 반환하도록 @RestController로 하였다. (REST API 요청 처리 컨트롤러)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 날짜에 새로운 위치를 추가(post)하는 건, 우선 요청 URL에서 날짜 정보를 받아서 매핑한 뒤 &lt;b&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;@RequestBody로 요청 본문에서 JSON형식의 위치 데이터를 가져올 것&lt;/span&gt;&lt;/b&gt;이다. (위도, 경도를 Map으로 받아옴)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 컨트롤러처럼 날짜를 받아왔다가 오류가 나서(...) 큰따옴표를 빼도록 파싱하였다. 그리고 서비스에서 구현했던 addLocation으로 저장한 뒤 HTTP 상태 코드 200(ok)를 반환한다. (build()로 본문이 없는 응답 객체(ResponseEntity)를 생성함)&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제는 id를 받아와서 서비스에 전달한 후 마찬가지로 200 ok를 반환한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1737028012479&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/api/locations&quot;)
public class LocationController {

    @Autowired
    private ChallengeService challengeService;

    @PostMapping(&quot;/{date}&quot;)
    public ResponseEntity&amp;lt;?&amp;gt; addLocation(@PathVariable String date,
                                         @RequestBody Map&amp;lt;String, Double&amp;gt; location) {
        date = date.replace(&quot;\&quot;&quot;, &quot;&quot;);
        LocalDate parseDate = LocalDate.parse(date);
        challengeService.addLocation(parseDate, location.get(&quot;latitude&quot;), location.get(&quot;longitude&quot;));
        return ResponseEntity.ok().build();
    }

    @DeleteMapping(&quot;/{locationId}&quot;)
    public ResponseEntity&amp;lt;?&amp;gt; removeLocation(@PathVariable Long locationId) {
        challengeService.removeLocation(locationId);
        return ResponseEntity.ok().build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;★두 컨트롤러가 분리된 이유 - 책임 분리, 확장성&lt;br /&gt;&lt;br /&gt;-ChallengeController: 전체 챌린지를 담당하며 뷰 렌더링 역할&lt;br /&gt;-LocationController: RESTful API 호출 담당&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;★동작 순서&lt;br /&gt;&lt;br /&gt;1. 클라이언트: 위치 마커 추가 -&amp;gt; POST /api/locations/{date}로 요청 보냄&lt;br /&gt;2. LocationController: 데이터를 받아서 서비스를 통해 ChallengeChecklist Entity에 저장 -&amp;gt; 클라이언트에게 200 ok 반환&lt;br /&gt;&lt;br /&gt;3. 클라이언트: 마커를 보고싶으니 GET /date/{date}로 요청&lt;br /&gt;4. ChallengeController: 해당 날짜에 맞는 ChallengeChecklist Entity에 담겨있는 위치 정보를 꺼내 DTO로 변환 -&amp;gt; DTO를 모델에 담아서 뷰로 전달&lt;br /&gt;5.date.html: 뷰를 렌더링하여 클라이언트에게 전달 -&amp;gt; 지도 맵에 위치 마커 표시됨&lt;br /&gt;&lt;br /&gt;=&amp;gt; 지도 맵에서 클릭하면 바로 마커가 보여짐&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;templates -&amp;gt; date.html 수정&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;날짜 페이지에 카카오 맵 api 키를 script로 추가한다. (모델로 전달받음)&lt;/p&gt;
&lt;pre id=&quot;code_1737029334354&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html xmlns:th=&quot;http://www.thymeleaf.org&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;DATE&amp;lt;/title&amp;gt;
    &amp;lt;script type=&quot;text/javascript&quot; th:src=&quot;@{'//dapi.kakao.com/v2/maps/sdk.js?appkey=' + ${kakaoMapsApiKey}}&quot;&amp;gt;&amp;lt;/script&amp;gt;
    &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;/css/style.css&quot;&amp;gt;
&amp;lt;/head&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체크리스트 화면 밑에 지도를 담을 div를 추가한다. 여기서 경로 보기를 누르면 마커끼리 선으로 이어지고, 경로 지우기를 하면 선 및 모든 마커가 삭제된다.(원래는 선만 지워지고 마커는 우클릭으로 하나씩 삭제하는 것으로 했었는데, 이러니까 모바일에서는 우클릭을 할 수 없어 지울 수가 없었음... 그래서 그냥 경로 지우기 버튼 누르면 다 지워지도록 해서 모바일도 편하게 수정할 수 있도록 함)&lt;/p&gt;
&lt;pre id=&quot;code_1737029396365&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div class=&quot;map-controls&quot;&amp;gt;
    &amp;lt;button class=&quot;map-button&quot; onclick=&quot;togglePath()&quot;&amp;gt;경로 보기&amp;lt;/button&amp;gt;
    &amp;lt;button class=&quot;map-button&quot; onclick=&quot;clearPath()&quot;&amp;gt;경로 지우기&amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;div id=&quot;map&quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 자바스크립트 부분은 너무 길어서... 중요한 코드만,,, (대부분은 카카오 사이트에 있는 코드를 활용했다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 자바스크립트에서 아래처럼 쓰면 주석이 아니라 동적으로 추가하는 부분이란 걸 새롭게 알았다. 이렇게하면 렌더링 전에도 오류나는 걸 피할 수 있다고 한다. 렌더링 되면 모델로 넘겼던 위치 객체를 사용할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1737029671371&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 저장된 위치 정보로 마커 생성
var savedLocations = /*[[${locations}]]*/ [];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지도를 클릭하면 마커가 생성된다. 아까 동작 순서에서 설명한 것처럼, LocationController로 요청을 보낸다. 컨트롤러에서 @RequestBody로 받는 것도 여기서 json으로 위치 정보를 실어서 보내기 때문이다. 컨트롤러에서 ok 반환이 오면 마커를 생성하고 위치 정보를 담아 지도에 표시한다. 또한 경로도 업데이트 하고, 마커를 생성할 때마다 페이지가 새로고침되도록 한다.(바로 서버에 반영됨)&lt;/p&gt;
&lt;pre id=&quot;code_1737029854571&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 지도 클릭 이벤트
kakao.maps.event.addListener(map, 'click', function(mouseEvent) {
    var latlng = mouseEvent.latLng;

    fetch(`/api/locations/[[${date}]]`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            latitude: latlng.getLat(),
            longitude: latlng.getLng()
        })
    }).then(response =&amp;gt; {
        if (response.ok) {
            var marker = new kakao.maps.Marker({
                position: latlng,
                map: map
            });
            markers.push({
                marker: marker,
                id: null,
                position: latlng
            });

            // 새 마커가 추가되면 경로도 업데이트
            if (isPathVisible) {
                drawPath();
            }

            location.reload(); // 페이지 새로고침
        }
    });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제도 비슷하다. 우클릭 이벤트가 발생하면, LocationController에 삭제 요청을 보낸다. ok가 반환되면 배열에서 찾아서 해당 마커를 지우고 경로를 업데이트 한 뒤 새로고침 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1737030103718&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 마커 우클릭으로 삭제
markers.forEach(function(markerObj) {
    kakao.maps.event.addListener(markerObj.marker, 'rightclick', function() {
        if (markerObj.id) {
            fetch(`/api/locations/${markerObj.id}`, {
                method: 'DELETE'
            }).then(response =&amp;gt; {
                if (response.ok) {
                    markerObj.marker.setMap(null);
                    markers = markers.filter(m =&amp;gt; m.id !== markerObj.id);

                    // 마커가 삭제되면 경로도 업데이트
                    if (isPathVisible) {
                        drawPath();
                    }

                    location.reload(); // 페이지 새로고침
                }
            });
        }
    });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;날짜 페이지에 처음 들어가면 사용자 기반 현 위치가 나타나게 할 것이다. 만약 브라우저에서 위치 탐색을 허용하지 않았다면 기본 위치인 서울로 나타난다.&lt;/p&gt;
&lt;pre id=&quot;code_1737030312877&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 현재 위치 가져오기 (기존 코드 유지)
function getCurrentLocation() {
    if (navigator.geolocation) {
        navigator.geolocation.getCurrentPosition(
            function(position) {
                initMap(position.coords.latitude, position.coords.longitude);
            },
            function(error) {
                console.error(&quot;현재 위치를 가져올 수 없습니다:&quot;, error);
                initMap(37.566826, 126.978656);
            },
            {
                enableHighAccuracy: true,
                maximumAge: 0,
                timeout: 5000
            }
        );
    } else {
        console.error(&quot;이 브라우저에서는 위치 정보를 지원하지 않습니다.&quot;);
        initMap(37.566826, 126.978656);
    }
}
    
// 페이지 로드 시 실행
getCurrentLocation();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;04. 실행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제일 뿌듯한 장면............&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 이렇게 경로를 자유롭게 추가/경로 보기/1개씩 삭제/전체 삭제를 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하루에 이동한 경로를 저장할 수 있어서 완전 유용하게 쓰고 있다ㅎ.ㅎ&lt;/p&gt;
&lt;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;kakaotv&quot; data-video-url=&quot;https://tv.kakao.com/v/452388987&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/JMsOz/hyX0rFFJ2o/YgjxX0GbwTPzkY1KN21Lxk/img.jpg?width=1694&amp;amp;height=976&amp;amp;face=0_0_1694_976,https://scrap.kakaocdn.net/dn/PIFAs/hyX0wUx4O7/ahlv2jSQR1dwxFIWRGz1kk/img.jpg?width=1694&amp;amp;height=976&amp;amp;face=0_0_1694_976&quot; data-video-width=&quot;860&quot; data-video-height=&quot;495&quot; data-video-origin-width=&quot;860&quot; data-video-origin-height=&quot;495&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-video-play-service=&quot;daum_tistory&quot; data-original-url=&quot;&quot; data-video-title=&quot;&quot;&gt;&lt;iframe src=&quot;https://play-tv.kakao.com/embed/player/cliplink/452388987?service=daum_tistory&quot; width=&quot;860&quot; height=&quot;495&quot; frameborder=&quot;0&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption style=&quot;display: none;&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category> Project/Challenge - 100일 챌린지 웹</category>
      <author>rim08</author>
      <guid isPermaLink="true">https://rim08.tistory.com/72</guid>
      <comments>https://rim08.tistory.com/72#entry72comment</comments>
      <pubDate>Thu, 16 Jan 2025 21:41:22 +0900</pubDate>
    </item>
  </channel>
</rss>