<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>나만의 개발 기록</title>
    <link>https://aamoos.tistory.com/</link>
    <description>웹개발을 하면서 기억하고 싶은 내용을 정리하는 블로그입니다</description>
    <language>ko</language>
    <pubDate>Sat, 1 Aug 2026 06:58:29 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>JaeSung Kim</managingEditor>
    <image>
      <title>나만의 개발 기록</title>
      <url>https://tistory1.daumcdn.net/tistory/3049955/attach/0cb037e12f8e48e9932620fd1aee0cff</url>
      <link>https://aamoos.tistory.com</link>
    </image>
    <item>
      <title># Docker Compose로 Mattermost 사내 메신저 구축하기</title>
      <link>https://aamoos.tistory.com/798</link>
      <description>&lt;h1&gt;Docker Compose로 Mattermost 사내 메신저 구축하기 (Teams 대체)&lt;/h1&gt;
&lt;p&gt;Teams를 더 이상 쓰지 않게 되면서, 무료 오픈소스 메신저를 자체 서버에 설치해보기로 했다. 여러 후보(Rocket.Chat, Zulip, Matrix) 중 &lt;strong&gt;Mattermost&lt;/strong&gt;를 선택했다. Slack과 가장 비슷한 UX를 제공하고, 오픈소스 Team Edition은 인원 제한 없이 완전 무료이기 때문이다.&lt;/p&gt;
&lt;h2&gt;왜 Mattermost인가&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;2015년부터 개발된 오픈소스 프로젝트로 커뮤니티/문서가 풍부하다.&lt;/li&gt;
&lt;li&gt;채팅, 채널, 파일 업로드, 검색, 멘션, DM, 이모지 반응 등 기본 기능은 전부 무료(Team Edition)이며 인원 제한이 없다.&lt;/li&gt;
&lt;li&gt;LDAP/SSO, 고가용성 클러스터링, 컴플라이언스 리포트 같은 기능만 유료(Professional/Enterprise) 티어에서 제공된다. 일반적인 사내 채팅+파일공유 용도라면 무료판으로 충분하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;docker-compose.yml 작성&lt;/h2&gt;
&lt;p&gt;Mattermost(Team Edition) + PostgreSQL 조합으로 구성했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;version: &amp;quot;3.8&amp;quot;

services:
  postgres:
    image: postgres:15-alpine
    container_name: mattermost-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: mmuser
      POSTGRES_PASSWORD: &amp;lt;비밀번호&amp;gt;
      POSTGRES_DB: mattermost
    volumes:
      - /mnt/data/mattermost/postgres:/var/lib/postgresql/data
    networks:
      - mattermost-network

  mattermost:
    image: mattermost/mattermost-team-edition:latest
    container_name: mattermost
    restart: unless-stopped
    depends_on:
      - postgres
    ports:
      - &amp;quot;8065:8065&amp;quot;
    environment:
      MM_SQLSETTINGS_DRIVERNAME: postgres
      MM_SQLSETTINGS_DATASOURCE: &amp;quot;postgres://mmuser:&amp;lt;비밀번호&amp;gt;@postgres:5432/mattermost?sslmode=disable&amp;amp;connect_timeout=10&amp;quot;
      MM_SERVICESETTINGS_SITEURL: &amp;quot;http://서버IP:8065&amp;quot;
      MM_FILESETTINGS_DRIVERNAME: &amp;quot;local&amp;quot;
      MM_FILESETTINGS_DIRECTORY: &amp;quot;/mattermost/data&amp;quot;
    volumes:
      - /mnt/data/mattermost/config:/mattermost/config
      - /mnt/data/mattermost/data:/mattermost/data
      - /mnt/data/mattermost/logs:/mattermost/logs
      - /mnt/data/mattermost/plugins:/mattermost/plugins
      - /mnt/data/mattermost/client-plugins:/mattermost/client/plugins
      - /mnt/data/mattermost/bleve-indexes:/mattermost/bleve-indexes
    networks:
      - mattermost-network

networks:
  mattermost-network:
    driver: bridge&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;포인트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;포트 충돌 방지&lt;/strong&gt;: 서버에 이미 여러 서비스(Jenkins, GitLab, Elasticsearch 등)가 떠 있었기 때문에, &lt;code&gt;docker ps&lt;/code&gt;로 사용 중인 포트를 먼저 확인하고 8065 포트가 비어있는지 체크했다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;볼륨 경로&lt;/strong&gt;: 루트 파티션 용량이 넉넉하지 않아서(&lt;code&gt;df -h&lt;/code&gt; 확인 결과 71% 사용 중), 별도로 마운트된 대용량 디스크(&lt;code&gt;/mnt/data&lt;/code&gt;) 아래에 데이터를 저장하도록 경로를 잡았다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DB 격리&lt;/strong&gt;: postgres 서비스는 호스트에 포트를 노출하지 않고 컨테이너 내부 네트워크에서만 통신하도록 설정했다. 컨테이너 이름과 데이터 경로가 기존에 떠 있던 다른 postgres/mariadb 컨테이너들과 전혀 겹치지 않아 기존 서비스에 영향이 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;트러블슈팅 1: 권한 에러 (permission denied)&lt;/h2&gt;
&lt;p&gt;처음 &lt;code&gt;docker compose up -d&lt;/code&gt; 실행 시 아래 에러가 반복적으로 발생했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Error: failed to load configuration: could not create config file: open /mattermost/config/config.json: permission denied&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;원인&lt;/strong&gt;: Mattermost 컨테이너는 root가 아닌 &lt;code&gt;UID 2000&lt;/code&gt; 사용자로 실행되는데, &lt;code&gt;mkdir -p&lt;/code&gt;로 만든 호스트 폴더의 소유자가 root(또는 sudo 실행 계정)라서 컨테이너 내부에서 쓰기 권한이 없었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;해결&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo chown -R 2000:2000 /mnt/data/mattermost/config
sudo chown -R 2000:2000 /mnt/data/mattermost/data
sudo chown -R 2000:2000 /mnt/data/mattermost/logs
sudo chown -R 2000:2000 /mnt/data/mattermost/plugins
sudo chown -R 2000:2000 /mnt/data/mattermost/client-plugins
sudo chown -R 2000:2000 /mnt/data/mattermost/bleve-indexes&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이후 재기동하니 정상적으로 마이그레이션이 진행되고 서버가 떴다.&lt;/p&gt;
&lt;h2&gt;초기 설정: 관리자 계정 &amp;amp; 팀 초대&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;첫 계정 생성 시 &lt;strong&gt;사용자명(Username)은 영문 소문자 + 숫자만 가능&lt;/strong&gt;하다. 한글 사용자명 입력 시 에러가 난다.&lt;/li&gt;
&lt;li&gt;팀원 초대는 두 가지 방식이 있다.&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;초대 링크 복사&lt;/strong&gt; → 카톡 등으로 직접 전달&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;관리자 도구 → 사이트 구성 → &amp;quot;누구나 이 팀에 참여할 수 있습니다&amp;quot;&lt;/strong&gt; 토글 활성화 → 서버 주소만 알려주면 팀원이 알아서 가입&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;이메일 초대는 SMTP 서버가 설정되어 있지 않으면 전송되지 않는다(&amp;quot;SMTP가 시스템 콘솔에서 구성되지 않았습니다&amp;quot;). 소규모 팀이면 SMTP 설정 없이 링크/오픈서버 방식으로 충분하다.&lt;/li&gt;
&lt;li&gt;데스크톱 앱에서 초대 링크 클립보드 복사가 안 되는 경우가 있었는데, 이때는 System Console에서 &lt;strong&gt;&amp;quot;공개 서버&amp;quot;를 활성화&lt;/strong&gt;하고 서버 주소만 공유하는 방식으로 우회했다. (관리자 도구 → 사이트 구성 → 인증 → 가입 → 공개 서버: 활성화)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;트러블슈팅 2: 개인 메시지가 실시간으로 안 뜸&lt;/h2&gt;
&lt;p&gt;DM을 보내도 바로 화면에 안 뜨고, 다른 채널로 갔다 와야 보이는 증상이 있었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;확인한 것들&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docker stats&lt;/code&gt;로 CPU/메모리 확인 → 리소스는 충분히 여유로움 (CPU 0.1%대)&lt;/li&gt;
&lt;li&gt;컨테이너 로그에서 웹소켓 에러 확인 → 특이 에러 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 증상은 전형적으로 &lt;strong&gt;웹소켓 연결 문제&lt;/strong&gt;일 때 나타난다. Mattermost는 실시간 메시지 전달을 위해 웹소켓을 쓰는데, &lt;code&gt;MM_SERVICESETTINGS_SITEURL&lt;/code&gt; 값이 실제 접속 주소와 다르면 웹소켓 연결이 불안정해질 수 있어 이 값을 서버 실제 IP로 맞춰주는 것이 첫 번째 점검 포인트였다.&lt;/p&gt;
&lt;h2&gt;트러블슈팅 3: 모바일 푸시 알림이 안 옴&lt;/h2&gt;
&lt;p&gt;모바일 앱에서 &amp;quot;이 서버의 알림을 수신할 수 없습니다&amp;quot; 메시지가 계속 떴다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1차 조치&lt;/strong&gt;: System Console → 환경 → 푸시 알림 서버에서 다음과 같이 설정&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;모바일 푸시 알림: &lt;code&gt;TPNS 연결을 사용하여 iOS 및 Android 앱에 알림을 전송합니다&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;푸시 알림 서버: &lt;code&gt;https://push.mattermost.com&lt;/code&gt; (기본값인 &lt;code&gt;push-test.mattermost.com&lt;/code&gt;이 아니라 test가 빠진 정식 URL로 변경)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 값을 바꾸고 컨테이너를 재시작해도 동일한 에러가 계속 발생했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;진짜 원인&lt;/strong&gt;: 서버 주소가 &lt;code&gt;http://&lt;/code&gt;(HTTPS 아님)였기 때문이다. Mattermost 공식 모바일 앱은 보안 정책상 &lt;strong&gt;서버가 HTTPS로 연결되어 있어야만 푸시 알림 등록을 허용&lt;/strong&gt;한다. 즉 TPNS 설정 자체는 문제가 아니었고, 사이트 자체가 HTTPS가 아닌 것이 근본 원인이었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;해결 방향&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;사내망 + IP 접속이라면 → 자체 서명 SSL 인증서 + Nginx 리버스 프록시 (기기에서 인증서 신뢰 예외 처리 필요)&lt;/li&gt;
&lt;li&gt;사용 가능한 도메인이 있다면 → Let&amp;#39;s Encrypt 무료 인증서로 정식 HTTPS 적용 (권장)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;무료 여부&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;채팅, 채널, DM, 파일 업로드&lt;/td&gt;
&lt;td&gt;무료 (Team Edition)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데스크톱/모바일 알림&lt;/td&gt;
&lt;td&gt;무료, 단 모바일 푸시는 HTTPS 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSO/LDAP, 고가용성, 컴플라이언스&lt;/td&gt;
&lt;td&gt;유료 (Professional/Enterprise)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;소규모 팀의 사내 채팅용으로는 무료 Team Edition만으로 충분했고, docker-compose 하나로 기존에 운영 중이던 다른 서비스들과 완전히 격리된 상태로 손쉽게 추가할 수 있었다. 다만 &lt;strong&gt;모바일 푸시 알림까지 정상적으로 받으려면 HTTPS 적용이 사실상 필수&lt;/strong&gt;라는 점은 미리 알아두면 좋을 것 같다.&lt;/p&gt;
&lt;p&gt;파일 공유/동기화가 추가로 필요하다면 OneDrive 대체제로 &lt;strong&gt;Nextcloud&lt;/strong&gt;를 같은 방식(docker-compose)으로 조합해서 쓸 수 있다.&lt;/p&gt;</description>
      <category>DevOps/Docker</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/798</guid>
      <comments>https://aamoos.tistory.com/798#entry798comment</comments>
      <pubDate>Fri, 31 Jul 2026 10:01:33 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS 실전] 9. FAQ &amp;middot; 시리즈 총정리</title>
      <link>https://aamoos.tistory.com/797</link>
      <description>&lt;h1&gt;[Next.js + TS 실전] 9. FAQ · 시리즈 총정리&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;입문 8편 + 실전 8편으로 작은 메모 앱을 끝까지 밀어 봤습니다.&lt;br&gt;마지막은 &lt;strong&gt;한눈에 지도&lt;/strong&gt;, &lt;strong&gt;자주 막히는 FAQ&lt;/strong&gt;, &lt;strong&gt;다음에 뭐 할지&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;이 글에서 하는 일&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;시리즈 전체 목차 정리&lt;/li&gt;
&lt;li&gt;지금 앱이 할 수 있는 것 체크&lt;/li&gt;
&lt;li&gt;실제로 자주 터지는 에러 Q&amp;amp;A&lt;/li&gt;
&lt;li&gt;학습 로드맵 짧게&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;새 기능 추가는 없습니다.&lt;br&gt;&lt;strong&gt;복습·북마크용&lt;/strong&gt; 글입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;시리즈 지도&lt;/h2&gt;
&lt;h3&gt;입문 &lt;code&gt;[Next.js + TS]&lt;/code&gt;&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;편&lt;/th&gt;
&lt;th&gt;주제&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;왜 Next.js + TypeScript인가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;프로젝트 만들고 폴더 구조&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;컴포넌트 타입 (Memo, props)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;라우팅과 레이아웃&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Server vs Client Component&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;데이터 가져오기 (async, loading/error)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;폼과 Server Action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;정리 + 다음 로드맵&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;실전 &lt;code&gt;[Next.js + TS 실전]&lt;/code&gt;&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;편&lt;/th&gt;
&lt;th&gt;주제&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;메모 수정과 삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Prisma + SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Vercel 배포 (SQLite 한계)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;폼 검증 (zod + useActionState)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;로그인 (Clerk) + userId&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Neon Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;삭제 확인 UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;목록 검색과 정렬&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;FAQ · 총정리 ← 지금&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;우리가 만든 것&lt;/h2&gt;
&lt;p&gt;메모 앱 기준으로 대략 이 정도입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/                 소개
/memos            목록 (+ 검색·정렬)
/memos/new        작성 (+ zod 검증)
/memos/[id]       상세 (+ 삭제 확인)
/memos/[id]/edit  수정&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;기술 축:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;App Router + TypeScript&lt;/li&gt;
&lt;li&gt;Server Component 기본, 필요한 곳만 Client&lt;/li&gt;
&lt;li&gt;Prisma (SQLite → Neon 가능)&lt;/li&gt;
&lt;li&gt;Server Action CRUD&lt;/li&gt;
&lt;li&gt;Clerk 로그인 + 내 메모만&lt;/li&gt;
&lt;li&gt;Vercel 배포 이슈 이해&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;스스로 점검 체크리스트&lt;/h2&gt;
&lt;h3&gt;기본&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; &lt;code&gt;app/page.tsx&lt;/code&gt; = &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;app/memos/page.tsx&lt;/code&gt; = &lt;code&gt;/memos&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; &lt;code&gt;layout.tsx&lt;/code&gt;와 &lt;code&gt;page.tsx&lt;/code&gt; 차이를 설명할 수 있다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; props / &lt;code&gt;Memo&lt;/code&gt; 타입을 붙일 수 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;서버·클라이언트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 기본은 Server Component다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; hooks / &lt;code&gt;onClick&lt;/code&gt;이 있으면 Client로 분리한다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 페이지 전체가 아니라 버튼·폼만 Client로 뺄 수 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;데이터·폼&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 목록은 서버에서 &lt;code&gt;await&lt;/code&gt;로 가져온다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 쓰기는 Server Action + &lt;code&gt;revalidatePath&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; zod로 검증하고 실패 시 상태 객체를 반환한다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;인증·배포&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 미들웨어는 세션 연결, 보호는 &lt;code&gt;auth.protect()&lt;/code&gt; / userId 쿼리&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; SQLite 파일은 Vercel과 안 맞는 이유를 안다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 비밀 값은 &lt;code&gt;.env&lt;/code&gt; / Vercel env에만 둔다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;하나라도 막히면 해당 편만 다시 보면 됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;FAQ (실제로 자주 막히는 것)&lt;/h2&gt;
&lt;h3&gt;Q1. &lt;code&gt;/memos&lt;/code&gt;가 404예요&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;원인 후보:&lt;/strong&gt; 목록 파일에 상세 코드를 붙여 넣음.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;파일&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/memos/page.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos&lt;/code&gt; 목록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/memos/[id]/page.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos/1&lt;/code&gt; 상세&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;상세(수정/삭제) 코드는 &lt;strong&gt;&lt;code&gt;[id]/page.tsx&lt;/code&gt;&lt;/strong&gt; 에 넣어야 합니다.&lt;br&gt;목록에 &lt;code&gt;notFound()&lt;/code&gt;가 있으면 &lt;code&gt;/memos&lt;/code&gt;도 404가 납니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Q2. &lt;code&gt;package.json&lt;/code&gt;이 깨져서 npm이 안 돼요&lt;/h3&gt;
&lt;p&gt;시드 설정을 &lt;strong&gt;별도 객체로 하나 더&lt;/strong&gt; 붙이면 JSON이 깨집니다.&lt;/p&gt;
&lt;p&gt;잘못된 예:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;  &amp;quot;devDependencies&amp;quot;: { ... },
  {
    &amp;quot;prisma&amp;quot;: { &amp;quot;seed&amp;quot;: &amp;quot;...&amp;quot; }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;올바른 예 — 맨 바깥 키:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;  &amp;quot;devDependencies&amp;quot;: { ... },
  &amp;quot;prisma&amp;quot;: {
    &amp;quot;seed&amp;quot;: &amp;quot;npx tsx prisma/seed.ts&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;Q3. &lt;code&gt;userId&lt;/code&gt; 마이그레이션이 실패해요&lt;/h3&gt;
&lt;p&gt;에러 예:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Added the required column &lt;code&gt;userId&lt;/code&gt; without a default value. There are N rows...&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이미 있는 행에 &lt;code&gt;userId&lt;/code&gt;를 뭘로 채울지 몰라서입니다.&lt;/p&gt;
&lt;p&gt;로컬 연습이면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma migrate reset&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;데이터 삭제 후 스키마를 다시 맞춥니다.&lt;br&gt;그다음 시드에 &lt;code&gt;userId&lt;/code&gt;도 넣어야 합니다. (&lt;code&gt;&amp;quot;seed-user&amp;quot;&lt;/code&gt; 등)&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Q4. 시드 메모가 로그인 목록에 안 보여요&lt;/h3&gt;
&lt;p&gt;정상일 수 있습니다.&lt;br&gt;시드 &lt;code&gt;userId&lt;/code&gt;와 Clerk 로그인 &lt;code&gt;userId&lt;/code&gt;가 다르기 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;로그인 후 새로 작성한 메모&lt;/strong&gt;로 확인하세요.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Q5. &lt;code&gt;createRouteMatcher&lt;/code&gt;가 deprecated래요&lt;/h3&gt;
&lt;p&gt;최신 Clerk는 미들웨어에서 경로로 막지 말고,&lt;br&gt;&lt;strong&gt;페이지/레이아웃에서 &lt;code&gt;auth.protect()&lt;/code&gt;&lt;/strong&gt; 하라고 바뀌었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;proxy.ts&lt;/code&gt; (또는 &lt;code&gt;middleware.ts&lt;/code&gt;) → &lt;code&gt;clerkMiddleware()&lt;/code&gt;만&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app/memos/layout.tsx&lt;/code&gt; → &lt;code&gt;await auth.protect()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;Q6. &lt;code&gt;SignedOut&lt;/code&gt; / &lt;code&gt;SignedIn&lt;/code&gt;을 찾을 수 없어요&lt;/h3&gt;
&lt;p&gt;Clerk Core 3 / 최신 SDK에서는 &lt;strong&gt;&lt;code&gt;Show&lt;/code&gt;&lt;/strong&gt; 로 통합되었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;Show when=&amp;quot;signed-out&amp;quot;&amp;gt;...&amp;lt;/Show&amp;gt;
&amp;lt;Show when=&amp;quot;signed-in&amp;quot;&amp;gt;...&amp;lt;/Show&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ClerkProvider&lt;/code&gt;는 &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;을 감싸지 말고 &lt;strong&gt;&lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; 안&lt;/strong&gt;에 두는 형태를 권장합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Q7. &lt;code&gt;parsed.error.flatten()&lt;/code&gt;이 deprecated래요&lt;/h3&gt;
&lt;p&gt;Zod 4:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 예전
parsed.error.flatten().fieldErrors

// 지금
z.flattenError(parsed.error).fieldErrors&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;Q8. &lt;code&gt;revalidatePath&lt;/code&gt;는 뭐예요?&lt;/h3&gt;
&lt;p&gt;“이 경로 화면을 &lt;strong&gt;다시 준비해라&lt;/strong&gt;”는 신호입니다.&lt;br&gt;메모를 추가/수정/삭제한 뒤 목록·상세가 옛 데이터로 남지 않게 합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;redirect&lt;/code&gt; = 이동, &lt;code&gt;revalidatePath&lt;/code&gt; = 그 경로 캐시/데이터 갱신.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Q9. Prisma를 루트에 설치하고 memo-app에서 import했어요&lt;/h3&gt;
&lt;p&gt;앱은 &lt;code&gt;memo-app&lt;/code&gt; 기준입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;패키지: &lt;code&gt;memo-app&lt;/code&gt;에서 &lt;code&gt;npm install&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;스키마: &lt;code&gt;memo-app/prisma&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;명령: &lt;code&gt;cd memo-app&lt;/code&gt; 후 &lt;code&gt;npx prisma ...&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;루트(&lt;code&gt;toyproject&lt;/code&gt;)와 섞이면 &lt;code&gt;PrismaClient&lt;/code&gt; export 에러가 납니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Q10. Vercel에 올렸더니 데이터가 안 남아요 / DB 에러&lt;/h3&gt;
&lt;p&gt;SQLite &lt;code&gt;file:./dev.db&lt;/code&gt;는 서버리스와 안 맞습니다.&lt;br&gt;Neon 등 외부 Postgres + Vercel &lt;code&gt;DATABASE_URL&lt;/code&gt;이 필요합니다. (실전 3·6편)&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Q11. 티스토리에 붙였더니 코드 블록이 안 먹어요&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;마크다운 모드인지 확인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;```prisma&lt;/code&gt; 같은 생소한 언어 태그보다 &lt;code&gt;ts&lt;/code&gt; / &lt;code&gt;bash&lt;/code&gt; 또는 태그 없는 fence&lt;/li&gt;
&lt;li&gt;JSX가 많은 블록은 미리보기에서 한 번 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;채팅에서 복사할 때 코드펜스가 중간에 끊기면, &lt;strong&gt;&lt;code&gt;.md&lt;/code&gt; 파일을 통째로&lt;/strong&gt; 복사하는 편이 안전합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;한 줄로 다시 보는 핵심&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;주제&lt;/th&gt;
&lt;th&gt;한 줄&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Next&lt;/td&gt;
&lt;td&gt;React로 앱 만드는 프레임워크 (폴더 = URL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TS&lt;/td&gt;
&lt;td&gt;데이터·props 계약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server / Client&lt;/td&gt;
&lt;td&gt;기본 서버, 인터랙션만 클라이언트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action&lt;/td&gt;
&lt;td&gt;폼이 부르는 서버 함수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prisma&lt;/td&gt;
&lt;td&gt;DB 쿼리를 타입과 함께&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clerk&lt;/td&gt;
&lt;td&gt;로그인 + &lt;code&gt;userId&lt;/code&gt;로 내 데이터만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neon&lt;/td&gt;
&lt;td&gt;배포용 Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;검색&lt;/td&gt;
&lt;td&gt;상태는 URL 쿼리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;다음에 공부하면 좋은 것&lt;/h2&gt;
&lt;p&gt;우선순위 예시:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;테스트&lt;/strong&gt; — zod 스키마 / Action 단위 테스트&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;이미지 업로드&lt;/strong&gt; — 파일 저장·표시&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;권한·역할&lt;/strong&gt; — admin / member&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;캐시·성능&lt;/strong&gt; — &lt;code&gt;fetch&lt;/code&gt; 옵션, 목록 페이지네이션&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth.js&lt;/strong&gt; — Clerk와 같은 구조를 직접 비교&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;새 시리즈를 쓴다면&lt;br&gt;“메모 앱 고도화” 또는 “포트폴리오용 기능 추가”로 끊는 편이 읽기 좋습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;이 시리즈의 목표는 문서를 다 외우는 게 아니었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;작은 앱 하나를 &lt;strong&gt;만들고 → 저장하고 → 고치고 → 로그인하고 → 배포 이슈를 이해&lt;/strong&gt;하는 것.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;막힐 때마다 이 FAQ로 돌아와 보세요.&lt;br&gt;에러 메시지 한 줄이 보통 위 질문 중 하나에 연결됩니다.&lt;/p&gt;
&lt;p&gt;질문·추가하고 싶은 FAQ가 있으면 댓글로 남겨 주세요.&lt;br&gt;같은 메모 앱 기준으로 이어서 정리할 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;전체 목차 (다시)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;입문 1~8&lt;/strong&gt; · &lt;strong&gt;실전 1~9&lt;/strong&gt;&lt;br&gt;지금 이 글이 실전 시리즈의 매듭입니다.&lt;/p&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/797</guid>
      <comments>https://aamoos.tistory.com/797#entry797comment</comments>
      <pubDate>Fri, 31 Jul 2026 09:51:53 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS 실전] 8. 목록 검색과 정렬</title>
      <link>https://aamoos.tistory.com/796</link>
      <description>&lt;h1&gt;[Next.js + TS 실전] 8. 목록 검색과 정렬&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;메모가 늘어나면 목록만으로는 찾기 어렵습니다.&lt;br&gt;이번 편은 &lt;strong&gt;제목 검색&lt;/strong&gt;과 &lt;strong&gt;정렬&lt;/strong&gt;을 URL 쿼리로 붙입니다.&lt;br&gt;목표: &lt;code&gt;?q=&lt;/code&gt; · &lt;code&gt;?sort=&lt;/code&gt; · 서버에서 Prisma 필터.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;왜 검색 상태를 URL에 두는지 이해하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getMemos&lt;/code&gt;에 검색·정렬 옵션 추가하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos&lt;/code&gt; 페이지에서 &lt;code&gt;searchParams&lt;/code&gt; 읽기&lt;/li&gt;
&lt;li&gt;검색 폼 + 정렬 링크 만들기&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Client에서 배열을 &lt;code&gt;filter&lt;/code&gt;해도 되지만,&lt;br&gt;이번엔 &lt;strong&gt;서버 컴포넌트 + DB 쿼리&lt;/strong&gt;로 갑니다. 데이터가 커져도 같은 패턴입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;왜 URL에 두나&lt;/h2&gt;
&lt;p&gt;예:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/memos?q=Next&amp;amp;sort=oldest&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;장점:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;새로고침해도 검색어 유지&lt;/li&gt;
&lt;li&gt;링크를 공유할 수 있음&lt;/li&gt;
&lt;li&gt;뒤로 가기와 자연스럽게 맞음&lt;/li&gt;
&lt;li&gt;서버 컴포넌트가 &lt;code&gt;searchParams&lt;/code&gt;로 바로 읽음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;useState&lt;/code&gt;만 쓰면 위 이점이 약해집니다.&lt;br&gt;목록 필터의 기본은 &lt;strong&gt;쿼리스트링&lt;/strong&gt;이 무난합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1) 데이터 함수에 옵션 추가&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;의 &lt;code&gt;getMemos&lt;/code&gt;를 확장합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type GetMemosOptions = {
  q?: string;
  sort?: &amp;quot;newest&amp;quot; | &amp;quot;oldest&amp;quot;;
};

export async function getMemos(
  userId: string,
  options: GetMemosOptions = {}
): Promise&amp;lt;Memo[]&amp;gt; {
  const q = options.q?.trim();
  const sort = options.sort ?? &amp;quot;newest&amp;quot;;

  const rows = await prisma.memo.findMany({
    where: {
      userId,
      ...(q
        ? {
            title: {
              contains: q,
              // Postgres면 대소문자 무시에 mode: &amp;quot;insensitive&amp;quot; 가능
              // SQLite는 contains만으로 시작
            },
          }
        : {}),
    },
    orderBy: {
      createdAt: sort === &amp;quot;oldest&amp;quot; ? &amp;quot;asc&amp;quot; : &amp;quot;desc&amp;quot;,
    },
  });

  return rows.map(toMemo);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;포인트:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;userId&lt;/code&gt;는 그대로 (내 메모만)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;q&lt;/code&gt;가 있으면 제목 &lt;code&gt;contains&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sort&lt;/code&gt;로 &lt;code&gt;asc&lt;/code&gt; / &lt;code&gt;desc&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Neon(Postgres)을 쓰면 이렇게 대소문자 무시가 됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;title: { contains: q, mode: &amp;quot;insensitive&amp;quot; }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SQLite에서는 &lt;code&gt;mode: &amp;quot;insensitive&amp;quot;&lt;/code&gt;가 지원되지 않을 수 있습니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Clerk를 아직 안 붙였다면 &lt;code&gt;userId&lt;/code&gt; 인자는 프로젝트 상태에 맞게 조정하세요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2) 목록 페이지에서 searchParams 읽기&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;app/memos/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import Link from &amp;quot;next/link&amp;quot;;
import { auth } from &amp;quot;@clerk/nextjs/server&amp;quot;;
import { redirect } from &amp;quot;next/navigation&amp;quot;;
import MemoCard from &amp;quot;@/components/MemoCard&amp;quot;;
import { getMemos } from &amp;quot;@/lib/memos&amp;quot;;

type MemosPageProps = {
  searchParams: Promise&amp;lt;{ q?: string; sort?: string }&amp;gt;;
};

export default async function MemosPage({ searchParams }: MemosPageProps) {
  const { userId } = await auth();
  if (!userId) redirect(&amp;quot;/&amp;quot;);

  const params = await searchParams;
  const q = params.q ?? &amp;quot;&amp;quot;;
  const sort = params.sort === &amp;quot;oldest&amp;quot; ? &amp;quot;oldest&amp;quot; : &amp;quot;newest&amp;quot;;

  const memos = await getMemos(userId, { q, sort });

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;메모 목록&amp;lt;/h1&amp;gt;

      &amp;lt;p&amp;gt;
        &amp;lt;Link href=&amp;quot;/memos/new&amp;quot;&amp;gt;새 메모 작성&amp;lt;/Link&amp;gt;
      &amp;lt;/p&amp;gt;

      &amp;lt;form&amp;gt;
        &amp;lt;label&amp;gt;
          제목 검색
          &amp;lt;br /&amp;gt;
          &amp;lt;input type=&amp;quot;search&amp;quot; name=&amp;quot;q&amp;quot; defaultValue={q} placeholder=&amp;quot;검색어&amp;quot; /&amp;gt;
        &amp;lt;/label&amp;gt;
        {&amp;quot; &amp;quot;}
        &amp;lt;button type=&amp;quot;submit&amp;quot;&amp;gt;검색&amp;lt;/button&amp;gt;
        {q &amp;amp;&amp;amp; (
          &amp;lt;&amp;gt;
            {&amp;quot; &amp;quot;}
            &amp;lt;Link href={`/memos?sort=${sort}`}&amp;gt;초기화&amp;lt;/Link&amp;gt;
          &amp;lt;/&amp;gt;
        )}
      &amp;lt;/form&amp;gt;

      &amp;lt;p&amp;gt;
        정렬:{&amp;quot; &amp;quot;}
        &amp;lt;Link href={`/memos?q=${encodeURIComponent(q)}&amp;amp;sort=newest`}&amp;gt;최신순&amp;lt;/Link&amp;gt;
        {&amp;quot; | &amp;quot;}
        &amp;lt;Link href={`/memos?q=${encodeURIComponent(q)}&amp;amp;sort=oldest`}&amp;gt;오래된순&amp;lt;/Link&amp;gt;
      &amp;lt;/p&amp;gt;

      &amp;lt;section&amp;gt;
        {memos.length === 0 ? (
          &amp;lt;p&amp;gt;조건에 맞는 메모가 없습니다.&amp;lt;/p&amp;gt;
        ) : (
          memos.map((memo) =&amp;gt; &amp;lt;MemoCard key={memo.id} memo={memo} /&amp;gt;)
        )}
      &amp;lt;/section&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;포인트:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;코드&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;searchParams&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URL &lt;code&gt;?q=&amp;amp;sort=&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;에 action 없음&lt;/td&gt;
&lt;td&gt;같은 &lt;code&gt;/memos&lt;/code&gt;로 GET 제출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name=&amp;quot;q&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;쿼리 키 이름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;defaultValue={q}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;검색 후에도 입력값 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;encodeURIComponent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;한글 검색어 링크 안전&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;auth&lt;/code&gt; / &lt;code&gt;redirect&lt;/code&gt;는 Clerk 기준입니다.&lt;br&gt;미사용이면 기존 &lt;code&gt;getMemos()&lt;/code&gt; 호출 방식에 옵션만 추가하면 됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;동작 흐름&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;/memos?q=Next&amp;amp;sort=oldest
        ↓
page가 searchParams 읽음
        ↓
getMemos(userId, { q: &amp;quot;Next&amp;quot;, sort: &amp;quot;oldest&amp;quot; })
        ↓
Prisma where + orderBy
        ↓
필터된 목록 렌더&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;useEffect&lt;/code&gt;로 클라이언트 fetch를 돌릴 필요가 없습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;검색 폼이 GET인 이유&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;form&amp;gt;
  &amp;lt;input name=&amp;quot;q&amp;quot; /&amp;gt;
  &amp;lt;button type=&amp;quot;submit&amp;quot;&amp;gt;검색&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;기본 method는 &lt;code&gt;GET&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;제출하면 &lt;code&gt;/memos?q=입력값&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Server Action이 아님 (데이터 변경이 아니라 &lt;strong&gt;조회&lt;/strong&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;쓰기(추가/수정/삭제)는 Action,&lt;br&gt;읽기 필터는 쿼리 — 역할이 갈립니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;(선택) 디바운스 실시간 검색&lt;/h2&gt;
&lt;p&gt;입력할 때마다 바로 필터하려면 Client 컴포넌트가 필요합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;useState&lt;/code&gt; + &lt;code&gt;useRouter().push(?q=...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;또는 클라이언트에서 이미 받은 목록을 &lt;code&gt;filter&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;메모가 적을 때는 편하지만,&lt;br&gt;이번 편 목표인 “서버+URL”과 결이 다릅니다.&lt;br&gt;필요해지면 그때 추가하세요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;자주 하는 실수&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;searchParams&lt;/code&gt;를 await 안 함&lt;/strong&gt;&lt;br&gt;→ 최근 Next에서는 Promise인 경우가 많음&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 정렬 링크에서 &lt;code&gt;q&lt;/code&gt;를 빠뜨림&lt;/strong&gt;&lt;br&gt;→ 정렬 바꾸면 검색어가 사라짐. 링크에 &lt;code&gt;q&lt;/code&gt; 유지&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;contains&lt;/code&gt;에 빈 문자열&lt;/strong&gt;&lt;br&gt;→ &lt;code&gt;trim()&lt;/code&gt; 후 없을 때는 where에 넣지 않기&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. 목록을 Client로 통째로 올리고 fetch&lt;/strong&gt;&lt;br&gt;→ 이 편 패턴이면 서버 &lt;code&gt;getMemos&lt;/code&gt;로 충분&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Postgres &lt;code&gt;mode: &amp;quot;insensitive&amp;quot;&lt;/code&gt;를 SQLite에 그대로&lt;/strong&gt;&lt;br&gt;→ 에러 날 수 있음. DB에 맞게&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;목록 필터 상태는 URL 쿼리가 기본이다&lt;/li&gt;
&lt;li&gt;검색은 GET 폼, 쓰기는 Server Action&lt;/li&gt;
&lt;li&gt;Prisma &lt;code&gt;where&lt;/code&gt; / &lt;code&gt;orderBy&lt;/code&gt;로 서버에서 거른다&lt;/li&gt;
&lt;li&gt;검색어와 정렬을 링크에 같이 실어 상태를 유지한다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;다음으로 이어가면 좋은 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;입문 + 실전 FAQ / 시리즈 총정리&lt;/strong&gt; — 지금까지 막혔던 포인트 모음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;검색·정렬까지 오면 메모 앱의 “일상 UX”는 한 바퀴 돈 셈입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;시리즈 목차 (실전)&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;메모 수정과 삭제&lt;/li&gt;
&lt;li&gt;Prisma + SQLite로 DB 붙이기&lt;/li&gt;
&lt;li&gt;Vercel에 배포하기&lt;/li&gt;
&lt;li&gt;폼 검증하기 (zod)&lt;/li&gt;
&lt;li&gt;로그인 붙이기 (Clerk)&lt;/li&gt;
&lt;li&gt;Neon Postgres 연결하기&lt;/li&gt;
&lt;li&gt;삭제 확인 UI&lt;/li&gt;
&lt;li&gt;목록 검색과 정렬 ← 지금&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/796</guid>
      <comments>https://aamoos.tistory.com/796#entry796comment</comments>
      <pubDate>Fri, 31 Jul 2026 09:19:10 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS 실전] 7. 삭제 확인 UI</title>
      <link>https://aamoos.tistory.com/795</link>
      <description>&lt;h1&gt;[Next.js + TS 실전] 7. 삭제 확인 UI&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;지금은 상세 페이지에서 &lt;strong&gt;삭제&lt;/strong&gt;를 누르면 바로 지워집니다.&lt;br&gt;이번 편은 실수로 누르는 걸 막는 &lt;strong&gt;확인 단계&lt;/strong&gt;를 붙입니다.&lt;br&gt;목표: &lt;code&gt;window.confirm&lt;/code&gt; → 작은 Client 버튼 컴포넌트.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;왜 확인이 필요한지 이해하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DeleteMemoButton&lt;/code&gt; 클라이언트 컴포넌트 만들기&lt;/li&gt;
&lt;li&gt;상세 페이지 폼에 연결하기&lt;/li&gt;
&lt;li&gt;(선택) 간단한 모달로 바꾸기&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Server Action·DB는 그대로입니다.&lt;br&gt;&lt;strong&gt;누르는 순간만&lt;/strong&gt; Client에서 한 번 막습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;지금 코드의 문제&lt;/h2&gt;
&lt;p&gt;상세 페이지 대략:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;form action={deleteMemoAction}&amp;gt;
  &amp;lt;input type=&amp;quot;hidden&amp;quot; name=&amp;quot;id&amp;quot; value={memo.id} /&amp;gt;
  &amp;lt;button type=&amp;quot;submit&amp;quot;&amp;gt;삭제&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;한 번 클릭 = 즉시 삭제 + &lt;code&gt;/memos&lt;/code&gt;로 이동.&lt;/p&gt;
&lt;p&gt;실무에서는 보통:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;삭제 클릭&lt;/li&gt;
&lt;li&gt;“정말 삭제할까요?”&lt;/li&gt;
&lt;li&gt;확인 → 그때 서버로 전송&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;접근 두 가지&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;window.confirm&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;코드 최소, 바로 동작&lt;/td&gt;
&lt;td&gt;브라우저 기본 UI라 못생김&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;모달&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;디자인·문구 자유&lt;/td&gt;
&lt;td&gt;컴포넌트가 조금 김&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;이번 편 추천은 &lt;strong&gt;confirm 먼저&lt;/strong&gt;입니다.&lt;br&gt;패턴만 익히고, 나중에 모달로 갈아끼우면 됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1) DeleteMemoButton 만들기&lt;/h2&gt;
&lt;p&gt;경로: &lt;code&gt;components/DeleteMemoButton.tsx&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;quot;use client&amp;quot;;

type DeleteMemoButtonProps = {
  memoTitle: string;
};

export default function DeleteMemoButton({ memoTitle }: DeleteMemoButtonProps) {
  function handleClick(e: React.MouseEvent&amp;lt;HTMLButtonElement&amp;gt;) {
    const ok = window.confirm(
      `&amp;quot;${memoTitle}&amp;quot; 메모를 삭제할까요?\n삭제하면 되돌릴 수 없습니다.`
    );

    if (!ok) {
      e.preventDefault();
    }
  }

  return (
    &amp;lt;button type=&amp;quot;submit&amp;quot; onClick={handleClick}&amp;gt;
      삭제
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;포인트:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;&amp;#39;use client&amp;#39;&lt;/code&gt; → &lt;code&gt;onClick&lt;/code&gt; / &lt;code&gt;window&lt;/code&gt; 사용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;type=&amp;quot;submit&amp;quot;&lt;/code&gt; → 확인했을 때만 폼이 제출됨&lt;/li&gt;
&lt;li&gt;&lt;code&gt;preventDefault()&lt;/code&gt; → 취소하면 서버 Action이 안 불림&lt;/li&gt;
&lt;li&gt;페이지 전체가 아니라 &lt;strong&gt;버튼만&lt;/strong&gt; Client (5편 패턴)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;2) 상세 페이지에 연결&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;app/memos/[id]/page.tsx&lt;/code&gt;의 삭제 부분만 바꿉니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import Link from &amp;quot;next/link&amp;quot;;
import { notFound } from &amp;quot;next/navigation&amp;quot;;
import { deleteMemoAction } from &amp;quot;@/app/memos/actions&amp;quot;;
import { getMemoById } from &amp;quot;@/lib/memos&amp;quot;;
import DeleteMemoButton from &amp;quot;@/components/DeleteMemoButton&amp;quot;;

type MemoDetailPageProps = {
  params: Promise&amp;lt;{ id: string }&amp;gt;;
};

export default async function MemoDetailPage({ params }: MemoDetailPageProps) {
  const { id } = await params;
  const memo = await getMemoById(id);

  if (!memo) {
    notFound();
  }

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;{memo.title}&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;{memo.content}&amp;lt;/p&amp;gt;
      &amp;lt;time dateTime={memo.createdAt}&amp;gt;{memo.createdAt}&amp;lt;/time&amp;gt;

      &amp;lt;p&amp;gt;
        &amp;lt;Link href={`/memos/${memo.id}/edit`}&amp;gt;수정&amp;lt;/Link&amp;gt;
      &amp;lt;/p&amp;gt;

      &amp;lt;form action={deleteMemoAction}&amp;gt;
        &amp;lt;input type=&amp;quot;hidden&amp;quot; name=&amp;quot;id&amp;quot; value={memo.id} /&amp;gt;
        &amp;lt;DeleteMemoButton memoTitle={memo.title} /&amp;gt;
      &amp;lt;/form&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Clerk를 붙였다면 &lt;code&gt;getMemoById(id, userId)&lt;/code&gt; 형태일 수 있습니다.&lt;br&gt;이번 편에서 바꾸는 건 &lt;strong&gt;삭제 버튼&lt;/strong&gt;뿐입니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;동작 확인&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;/memos/어떤id&lt;/code&gt; 상세로 이동&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;삭제&lt;/strong&gt; 클릭&lt;/li&gt;
&lt;li&gt;확인 창에서 &lt;strong&gt;취소&lt;/strong&gt; → 그대로 상세에 머무름&lt;/li&gt;
&lt;li&gt;다시 &lt;strong&gt;삭제&lt;/strong&gt; → &lt;strong&gt;확인&lt;/strong&gt; → 목록으로 이동하고 메모 사라짐&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;(선택) 간단한 모달&lt;/h2&gt;
&lt;p&gt;confirm이 밋밋하면 같은 자리에 모달을 넣을 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;quot;use client&amp;quot;;

import { useState } from &amp;quot;react&amp;quot;;

type DeleteMemoButtonProps = {
  memoTitle: string;
};

export default function DeleteMemoButton({ memoTitle }: DeleteMemoButtonProps) {
  const [open, setOpen] = useState(false);

  return (
    &amp;lt;&amp;gt;
      &amp;lt;button type=&amp;quot;button&amp;quot; onClick={() =&amp;gt; setOpen(true)}&amp;gt;
        삭제
      &amp;lt;/button&amp;gt;

      {open &amp;amp;&amp;amp; (
        &amp;lt;div
          role=&amp;quot;dialog&amp;quot;
          aria-modal=&amp;quot;true&amp;quot;
          style={{
            position: &amp;quot;fixed&amp;quot;,
            inset: 0,
            background: &amp;quot;rgba(0,0,0,0.4)&amp;quot;,
            display: &amp;quot;grid&amp;quot;,
            placeItems: &amp;quot;center&amp;quot;,
          }}
        &amp;gt;
          &amp;lt;div style={{ background: &amp;quot;white&amp;quot;, padding: 16, minWidth: 280 }}&amp;gt;
            &amp;lt;p&amp;gt;
              &amp;lt;strong&amp;gt;{memoTitle}&amp;lt;/strong&amp;gt; 을(를) 삭제할까요?
            &amp;lt;/p&amp;gt;
            &amp;lt;p&amp;gt;삭제하면 되돌릴 수 없습니다.&amp;lt;/p&amp;gt;

            &amp;lt;button type=&amp;quot;button&amp;quot; onClick={() =&amp;gt; setOpen(false)}&amp;gt;
              취소
            &amp;lt;/button&amp;gt;
            {&amp;quot; &amp;quot;}
            &amp;lt;button type=&amp;quot;submit&amp;quot; onClick={() =&amp;gt; setOpen(false)}&amp;gt;
              삭제하기
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
      )}
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;주의:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;바깥 &lt;strong&gt;삭제&lt;/strong&gt;는 &lt;code&gt;type=&amp;quot;button&amp;quot;&lt;/code&gt; (폼 즉시 제출 방지)&lt;/li&gt;
&lt;li&gt;모달 안 &lt;strong&gt;삭제하기&lt;/strong&gt;만 &lt;code&gt;type=&amp;quot;submit&amp;quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이 버튼들은 반드시 &lt;code&gt;&amp;lt;form action={deleteMemoAction}&amp;gt;&lt;/code&gt; 안에 있어야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;접근성·포커스 트랩까지 가면 라이브러리(모달)를 쓰는 편이 낫습니다.&lt;br&gt;학습용으로는 위 수준이면 충분합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;왜 Action은 그대로인가&lt;/h2&gt;
&lt;p&gt;확인 UI는 &lt;strong&gt;브라우저 UX&lt;/strong&gt;입니다.&lt;br&gt;진짜 삭제는 여전히 서버의 &lt;code&gt;deleteMemoAction&lt;/code&gt;이 합니다.&lt;/p&gt;
&lt;p&gt;그래서:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;확인을 우회하는 요청이 올 수도 있음 → Action에서 id/권한 검증은 유지&lt;/li&gt;
&lt;li&gt;UI만 Client, 삭제로직은 Server&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;보안을 confirm에 맡기지 마세요.&lt;br&gt;confirm은 &lt;strong&gt;실수 방지&lt;/strong&gt;용입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;자주 하는 실수&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 페이지 전체를 &lt;code&gt;&amp;#39;use client&amp;#39;&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;→ 버튼만 Client로 분리&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. confirm 취소를 안 막고 submit&lt;/strong&gt;&lt;br&gt;→ &lt;code&gt;preventDefault()&lt;/code&gt; 필요&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 모달의 삭제 버튼을 form 밖에 둠&lt;/strong&gt;&lt;br&gt;→ &lt;code&gt;action&lt;/code&gt;이 연결되지 않음&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;type=&amp;quot;submit&amp;quot;&lt;/code&gt;인 첫 버튼이 모달을 안 거침&lt;/strong&gt;&lt;br&gt;→ 열기 버튼은 &lt;code&gt;type=&amp;quot;button&amp;quot;&lt;/code&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;삭제는 확인 한 단계가 있는 편이 안전하다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;window.confirm&lt;/code&gt;으로 최소 구현 가능하다&lt;/li&gt;
&lt;li&gt;인터랙션만 Client 컴포넌트로 뺀다&lt;/li&gt;
&lt;li&gt;확인 UI ≠ 권한/검증 (Server Action은 그대로)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;다음으로 이어가면 좋은 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;목록 검색·정렬&lt;/li&gt;
&lt;li&gt;입문+실전 &lt;strong&gt;FAQ / 시리즈 총정리&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;시리즈 목차 (실전)&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;메모 수정과 삭제&lt;/li&gt;
&lt;li&gt;Prisma + SQLite로 DB 붙이기&lt;/li&gt;
&lt;li&gt;Vercel에 배포하기&lt;/li&gt;
&lt;li&gt;폼 검증하기 (zod)&lt;/li&gt;
&lt;li&gt;로그인 붙이기 (Clerk)&lt;/li&gt;
&lt;li&gt;Neon Postgres 연결하기&lt;/li&gt;
&lt;li&gt;삭제 확인 UI ← 지금&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/795</guid>
      <comments>https://aamoos.tistory.com/795#entry795comment</comments>
      <pubDate>Fri, 31 Jul 2026 09:10:05 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS 실전] 6. Neon Postgres 연결하기</title>
      <link>https://aamoos.tistory.com/794</link>
      <description>&lt;h1&gt;[Next.js + TS 실전] 6. Neon Postgres 연결하기&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;실전 3편에서 “Vercel에는 SQLite 파일 DB가 안 맞다”고 했습니다.&lt;br&gt;이번 편은 &lt;strong&gt;Neon(Postgres)&lt;/strong&gt; 으로 바꿔, 배포 환경에서도 메모가 남게 합니다.&lt;br&gt;목표: provider 변경 · 마이그레이션 · 로컬/Vercel &lt;code&gt;DATABASE_URL&lt;/code&gt;.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;왜 Neon인지 짧게 이해하기&lt;/li&gt;
&lt;li&gt;Neon 프로젝트 만들고 연결 문자열 받기&lt;/li&gt;
&lt;li&gt;Prisma &lt;code&gt;provider&lt;/code&gt;를 &lt;code&gt;postgresql&lt;/code&gt;로 바꾸기&lt;/li&gt;
&lt;li&gt;마이그레이션을 Postgres에 적용하기&lt;/li&gt;
&lt;li&gt;Vercel 환경 변수에 &lt;code&gt;DATABASE_URL&lt;/code&gt; 넣기&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;앱 코드(&lt;code&gt;lib/memos.ts&lt;/code&gt;, Action, 페이지)는 &lt;strong&gt;거의 안 바꿉니다.&lt;/strong&gt;&lt;br&gt;바꾸는 건 DB 종류와 연결 정보입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;왜 Neon인가&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;선택&lt;/th&gt;
&lt;th&gt;한 줄&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Neon&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;서버리스 Postgres, 무료 티어, Prisma와 궁합 좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;Postgres + 부가 기능이 많음 (이번 편 범위 밖)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로컬 SQLite&lt;/td&gt;
&lt;td&gt;로컬 학습용으로 계속 써도 됨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Neon을 고른 이유:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Vercel 같은 서버리스와 잘 맞음&lt;/li&gt;
&lt;li&gt;Connection string만 바꾸면 Prisma가 거의 그대로 동작&lt;/li&gt;
&lt;li&gt;무료로 시작하기 쉬움&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;UI 메뉴 이름은 자주 바뀝니다.&lt;br&gt;&lt;strong&gt;프로젝트 생성 → Connection string 복사&lt;/strong&gt; 만 기억하면 됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;전체 그림&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;로컬 / Vercel 앱
      ↓
 DATABASE_URL (환경 변수)
      ↓
 Prisma Client
      ↓
 Neon Postgres (클라우드)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;SQLite 때:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DATABASE_URL=&amp;quot;file:./dev.db&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Neon 때:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DATABASE_URL=&amp;quot;postgresql://...&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;1) Neon에서 DB 만들기&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://neon.tech&quot;&gt;https://neon.tech&lt;/a&gt; 가입&lt;/li&gt;
&lt;li&gt;새 프로젝트 생성&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connection string&lt;/strong&gt; 복사&lt;br&gt;(보통 &lt;code&gt;postgresql://...&lt;/code&gt; 형태)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;예시 형태 (값은 본인 것):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;postgresql://USER:PASSWORD@HOST/neondb?sslmode=require&lt;/code&gt;&lt;/pre&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;비밀번호가 들어 있습니다. GitHub에 올리지 마세요.&lt;br&gt;&lt;code&gt;.env&lt;/code&gt; / Vercel Environment Variables에만 둡니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;2) 로컬 &lt;code&gt;.env&lt;/code&gt; 바꾸기&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;memo-app/.env&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DATABASE_URL=&amp;quot;postgresql://USER:PASSWORD@HOST/neondb?sslmode=require&amp;quot;
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이전 SQLite용 &lt;code&gt;file:./dev.db&lt;/code&gt;는 주석 처리하거나 지웁니다.&lt;/p&gt;
&lt;p&gt;로컬에서도 Neon을 쓰면:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PC를 꺼도 데이터가 클라우드에 남음&lt;/li&gt;
&lt;li&gt;팀원과 같은 DB를 볼 수 있음 (주의: 연습용 키 관리)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;원하면 로컬은 SQLite, 배포만 Neon으로 나눌 수도 있습니다.&lt;br&gt;이번 편은 &lt;strong&gt;로컬도 Neon으로 통일&lt;/strong&gt;해서 흐름을 단순하게 갑니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3) Prisma 스키마: provider 변경&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;prisma/schema.prisma&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;generator client {
  provider = &amp;quot;prisma-client-js&amp;quot;
}

datasource db {
  provider = &amp;quot;postgresql&amp;quot;
  url      = env(&amp;quot;DATABASE_URL&amp;quot;)
}

model Memo {
  id        String   @id @default(cuid())
  title     String
  content   String
  userId    String
  createdAt DateTime @default(now())

  @@index([userId])
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;바뀐 핵심은 한 줄입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;provider = &amp;quot;sqlite&amp;quot;  →  provider = &amp;quot;postgresql&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;Memo&lt;/code&gt; 모델은 그대로입니다.&lt;br&gt;(&lt;code&gt;@@index([userId])&lt;/code&gt;는 있으면 조회에 도움이 됩니다.)&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4) 마이그레이션을 Neon에 적용&lt;/h2&gt;
&lt;p&gt;SQLite용 마이그레이션 히스토리가 있으면 Postgres와 충돌할 수 있습니다.&lt;br&gt;연습 단계에서는 아래 둘 중 하나를 고르세요.&lt;/p&gt;
&lt;h3&gt;방법 A) 새 DB에 스키마만 밀어넣기 (가장 단순)&lt;/h3&gt;
&lt;p&gt;테이블이 비어 있는 Neon이라면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma db push
npx prisma generate&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;db push&lt;/code&gt;는 마이그레이션 파일 없이 스키마를 DB에 맞춥니다.&lt;br&gt;프로토타입·학습에 편합니다.&lt;/p&gt;
&lt;h3&gt;방법 B) migrate로 정식 적용&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma migrate dev --name postgres-init&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기존 SQLite 마이그레이션 폴더 때문에 꼬이면:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;prisma/migrations&lt;/code&gt;를 백업/삭제 (로컬 연습 한정)&lt;/li&gt;
&lt;li&gt;다시 &lt;code&gt;migrate dev --name init&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;실서비스에서는 히스토리를 함부로 지우면 안 됩니다.&lt;br&gt;&lt;strong&gt;혼자 쓰는 메모 앱 연습&lt;/strong&gt;이라면 A 또는 “migrations 초기화 후 다시”가 현실적입니다.&lt;/p&gt;
&lt;p&gt;시드:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma db seed&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;시드의 &lt;code&gt;userId: &amp;quot;seed-user&amp;quot;&lt;/code&gt;는 Clerk 로그인 유저와 다릅니다.&lt;br&gt;목록 확인은 &lt;strong&gt;로그인 후 새 메모 작성&lt;/strong&gt;으로 하세요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5) 동작 확인 (로컬)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run dev&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;로그인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos/new&lt;/code&gt;에서 메모 작성&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://console.neon.tech&quot;&gt;Neon Console&lt;/a&gt; → Tables에서 행이 보이는지 확인&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;보이면 &lt;strong&gt;클라우드 DB 연결 성공&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6) Vercel에 DATABASE_URL 넣기&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Vercel Project → &lt;strong&gt;Settings → Environment Variables&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;추가:&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Neon 연결 문자열&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clerk 공개 키&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLERK_SECRET_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clerk 비밀 키&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;Production / Preview 체크&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redeploy&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;빌드 스크립트 권장 (&lt;code&gt;package.json&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;scripts&amp;quot;: {
    &amp;quot;dev&amp;quot;: &amp;quot;next dev&amp;quot;,
    &amp;quot;build&amp;quot;: &amp;quot;prisma generate &amp;amp;&amp;amp; next build&amp;quot;,
    &amp;quot;start&amp;quot;: &amp;quot;next start&amp;quot;,
    &amp;quot;postinstall&amp;quot;: &amp;quot;prisma generate&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Vercel 빌드 머신에서 Prisma Client가 생성되게 합니다.&lt;/p&gt;
&lt;p&gt;프로덕션 마이그레이션은 보통:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma migrate deploy&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;을 CI나 배포 전에 한 번 실행합니다.&lt;br&gt;&lt;code&gt;db push&lt;/code&gt;로만 맞춰 둔 상태면, 배포 전에도 스키마가 Neon에 이미 있어야 합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;코드는 왜 거의 안 바꾸나&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;는 여전히:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;await prisma.memo.findMany({ where: { userId }, ... })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Prisma가 SQL 방언(SQLite vs Postgres)을 감춥니다.&lt;br&gt;그래서 실전 2편에서 &lt;strong&gt;데이터 레이어를 분리&lt;/strong&gt;해 둔 것이 여기서 이득이 됩니다.&lt;/p&gt;
&lt;p&gt;가끔 달라지는 것:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;일부 raw SQL&lt;/li&gt;
&lt;li&gt;타입/기본값 디테일&lt;/li&gt;
&lt;li&gt;연결 풀 (서버리스에서는 Neon의 pooled URL을 쓰는 경우도 있음)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;입문 다음 단계에서는 Connection string 하나로 충분합니다.&lt;br&gt;연결이 불안정하면 Neon 대시보드의 &lt;strong&gt;pooled connection&lt;/strong&gt; 문자열을 써 보세요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;자주 하는 실수&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;.env&lt;/code&gt;의 따옴표/복붙 깨짐&lt;/strong&gt;&lt;br&gt;→ 비밀번호에 특수문자가 있으면 URL 인코딩 필요할 수 있음&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Vercel에 &lt;code&gt;DATABASE_URL&lt;/code&gt; 안 넣음&lt;/strong&gt;&lt;br&gt;→ 로컬만 되고 배포만 실패&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. SQLite &lt;code&gt;dev.db&lt;/code&gt;를 그대로 커밋/배포 기대&lt;/strong&gt;&lt;br&gt;→ Neon으로 바꾼 뒤에는 의미 없음&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;provider&lt;/code&gt;만 바꾸고 migrate/&lt;code&gt;db push&lt;/code&gt;를 안 함&lt;/strong&gt;&lt;br&gt;→ 테이블이 없어 런타임 에러&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Clerk 키는 넣었는데 DB URL만 빠짐 (또는 반대)&lt;/strong&gt;&lt;br&gt;→ 로그인 되는데 저장 실패 / 저장은 되는데 로그인 실패&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. 시드 메모가 로그인 목록에 안 보임&lt;/strong&gt;&lt;br&gt;→ &lt;code&gt;userId: &amp;quot;seed-user&amp;quot;&lt;/code&gt;라서 정상. 새 글로 확인&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Vercel 배포 앱의 DB는 &lt;strong&gt;외부 Postgres&lt;/strong&gt;가 맞다&lt;/li&gt;
&lt;li&gt;Neon = 시작하기 쉬운 서버리스 Postgres&lt;/li&gt;
&lt;li&gt;Prisma는 &lt;code&gt;provider&lt;/code&gt; + &lt;code&gt;DATABASE_URL&lt;/code&gt;만 바꿔도 앱 코드를 유지하기 쉽다&lt;/li&gt;
&lt;li&gt;비밀 값은 &lt;code&gt;.env&lt;/code&gt; / Vercel env에만 둔다&lt;/li&gt;
&lt;li&gt;로컬에서 한 번 쓰고, 같은 URL(또는 별도 prod URL)을 배포에 연결한다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;다음에 이어가면 좋은 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;삭제 확인 UI / 목록 검색 (짧은 UX 보강)&lt;/li&gt;
&lt;li&gt;시리즈 FAQ·총정리&lt;/li&gt;
&lt;li&gt;로컬 SQLite + 배포 Neon을 &lt;code&gt;.env&lt;/code&gt;로 나누는 팀 워크플로&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;시리즈 목차 (실전)&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;메모 수정과 삭제&lt;/li&gt;
&lt;li&gt;Prisma + SQLite로 DB 붙이기&lt;/li&gt;
&lt;li&gt;Vercel에 배포하기&lt;/li&gt;
&lt;li&gt;폼 검증하기 (zod)&lt;/li&gt;
&lt;li&gt;로그인 붙이기 (Clerk)&lt;/li&gt;
&lt;li&gt;Neon Postgres 연결하기 ← 지금&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/794</guid>
      <comments>https://aamoos.tistory.com/794#entry794comment</comments>
      <pubDate>Fri, 31 Jul 2026 09:03:42 +0900</pubDate>
    </item>
    <item>
      <title># 면접 CS 정리 Day 3 (풀스택 / Java&amp;middot;Spring&amp;middot;React)</title>
      <link>https://aamoos.tistory.com/793</link>
      <description>&lt;h1&gt;면접 CS 정리 Day 3 (풀스택 / Java·Spring·React)&lt;/h1&gt;
&lt;h2&gt;Q1. 401 vs 403&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;문제&lt;/strong&gt;&lt;br&gt;HTTP 상태코드 &lt;code&gt;401&lt;/code&gt;이랑 &lt;code&gt;403&lt;/code&gt; 차이?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;답&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;401&lt;/code&gt;: &lt;strong&gt;인증&lt;/strong&gt;이 안 됨 (로그인/토큰 없음·만료)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;403&lt;/code&gt;: 인증은 됐지만 &lt;strong&gt;권한&lt;/strong&gt;이 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;한 줄: 401 = 누구인지 모름 / 403 = 누군진 알지만 못 함.&lt;/p&gt;
&lt;h2&gt;Q2. 복합 인덱스 좌측 접두어&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;문제&lt;/strong&gt;&lt;br&gt;복합 인덱스 &lt;code&gt;(user_id, created_at)&lt;/code&gt;이 있을 때&lt;br&gt;아래 중 인덱스를 잘 타는 것 / 못 타는 것은?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A. &lt;code&gt;WHERE user_id = ?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;B. &lt;code&gt;WHERE created_at = ?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;C. &lt;code&gt;WHERE user_id = ? AND created_at = ?&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;답&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A: &lt;strong&gt;탐&lt;/strong&gt; (왼쪽 컬럼)&lt;/li&gt;
&lt;li&gt;B: &lt;strong&gt;못 탐&lt;/strong&gt; (앞 컬럼 스킵)&lt;/li&gt;
&lt;li&gt;C: &lt;strong&gt;가장 잘 탐&lt;/strong&gt; (인덱스 전부 활용)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;복합 인덱스는 &lt;strong&gt;왼쪽 컬럼부터&lt;/strong&gt; 써야 한다.&lt;/p&gt;
&lt;h2&gt;Q3. Dirty Read&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;문제&lt;/strong&gt;&lt;br&gt;Dirty Read가 뭔지 한 줄, 막는 격리수준 이름은?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;답&lt;/strong&gt;&lt;br&gt;Dirty Read = 다른 트랜잭션이 &lt;strong&gt;커밋하기 전 데이터&lt;/strong&gt;를 읽는 것.&lt;br&gt;막는 수준: &lt;strong&gt;&lt;code&gt;READ COMMITTED&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Q4. HashMap vs ConcurrentHashMap&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;문제&lt;/strong&gt;&lt;br&gt;&lt;code&gt;HashMap&lt;/code&gt;이랑 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 차이? 멀티스레드에서 뭘 써야 하는지.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;답&lt;/strong&gt;&lt;br&gt;&lt;code&gt;HashMap&lt;/code&gt;은 스레드 안전하지 않아서, 여러 스레드가 동시에 쓰면&lt;br&gt;데이터가 깨지거나 예외가 날 수 있다.&lt;br&gt;공유 Map이면 &lt;strong&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt;&lt;/strong&gt;을 쓴다.&lt;/p&gt;
&lt;h2&gt;Q5. @Controller vs @RestController&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;문제&lt;/strong&gt;&lt;br&gt;Spring &lt;code&gt;@Controller&lt;/code&gt;랑 &lt;code&gt;@RestController&lt;/code&gt; 차이?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;답&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@Controller&lt;/code&gt;: 보통 &lt;strong&gt;화면(뷰)&lt;/strong&gt; 반환&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@RestController&lt;/code&gt;: &lt;code&gt;@Controller&lt;/code&gt; + &lt;code&gt;@ResponseBody&lt;/code&gt;&lt;br&gt;→ 반환값이 뷰 이름이 아니라 JSON 등 &lt;strong&gt;응답 본문&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;REST API는 &lt;code&gt;@RestController&lt;/code&gt;를 주로 쓴다.&lt;/p&gt;
&lt;h2&gt;Q6. XSS vs CSRF&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;문제&lt;/strong&gt;&lt;br&gt;XSS랑 CSRF 차이 한 줄씩.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;답&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;XSS&lt;/strong&gt;: 페이지에 &lt;strong&gt;악의적 스크립트를 삽입&lt;/strong&gt;해 실행시키는 공격&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSRF&lt;/strong&gt;: 로그인된 사용자 브라우저로 &lt;strong&gt;원치 않는 요청&lt;/strong&gt;을 보내게 하는 공격&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;XSS 완화 예: 출력 이스케이프, 쿠키 &lt;code&gt;HttpOnly&lt;/code&gt;&lt;br&gt;CSRF 완화 예: CSRF 토큰, 쿠키 &lt;code&gt;SameSite&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Q7. N+1 문제&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;문제&lt;/strong&gt;&lt;br&gt;N+1 문제가 뭔지, 목록 조회할 때 어떻게 발생하고 어떻게 줄이는지?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;답&lt;/strong&gt;&lt;br&gt;목록 조회 &lt;strong&gt;1번&lt;/strong&gt; 후, 각 행마다 연관 데이터를 &lt;strong&gt;N번&lt;/strong&gt; 추가 조회하는 문제.&lt;br&gt;(총 1+N번 쿼리)&lt;/p&gt;
&lt;p&gt;줄이는 방법:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JPA: &lt;code&gt;fetch join&lt;/code&gt;, &lt;code&gt;@EntityGraph&lt;/code&gt;, 배치 조회(&lt;code&gt;IN&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;MyBatis: JOIN 한 방, id 목록 &lt;code&gt;IN&lt;/code&gt; 조회 후 조립&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;오늘 한 줄 암기&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;401 = 인증 X / 403 = 권한 X&lt;/li&gt;
&lt;li&gt;복합 인덱스 &lt;code&gt;(A,B)&lt;/code&gt; → &lt;code&gt;A&lt;/code&gt; OK, &lt;code&gt;B&lt;/code&gt;만 NO&lt;/li&gt;
&lt;li&gt;Dirty Read → &lt;code&gt;READ COMMITTED&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;멀티스레드 Map → &lt;code&gt;ConcurrentHashMap&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;N+1 = 목록 1 + 건별 N → fetch join / JOIN / IN&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;다음 목표&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;복합 인덱스 A도 탄다는 점까지 항상 말하기&lt;/li&gt;
&lt;li&gt;XSS/CSRF 방어 수단 한 가지씩 더 정리&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>기타/CS 질문</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/793</guid>
      <comments>https://aamoos.tistory.com/793#entry793comment</comments>
      <pubDate>Fri, 31 Jul 2026 08:25:13 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS 실전] 5. 로그인 붙이기 (Clerk)</title>
      <link>https://aamoos.tistory.com/792</link>
      <description>&lt;h1&gt;[Next.js + TS 실전] 5. 로그인 붙이기 (Clerk)&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;지금까지 메모 앱은 &lt;strong&gt;누구나&lt;/strong&gt; 같은 목록을 보고 고칠 수 있습니다.&lt;br&gt;이번 편은 &lt;strong&gt;로그인&lt;/strong&gt;을 붙이고, &lt;strong&gt;내 메모만&lt;/strong&gt; 보이게 만듭니다.&lt;br&gt;도구: Clerk (Next.js App Router와 연동이 단순함)&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;왜 직접 인증을 안 짜는지 짧게 이해하기&lt;/li&gt;
&lt;li&gt;Clerk 프로젝트·키·패키지 준비&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ClerkProvider&lt;/code&gt; + 미들웨어 + &lt;code&gt;auth.protect()&lt;/code&gt;로 보호&lt;/li&gt;
&lt;li&gt;내비에 로그인/유저 버튼 넣기&lt;/li&gt;
&lt;li&gt;Prisma &lt;code&gt;Memo&lt;/code&gt;에 &lt;code&gt;userId&lt;/code&gt; 추가 → &lt;strong&gt;내 메모만&lt;/strong&gt; CRUD&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;UI 클릭 경로는 Clerk 대시보드가 자주 바뀝니다.&lt;br&gt;&lt;strong&gt;키 발급 → env → 코드 연결&lt;/strong&gt; 흐름만 정확히 따라가면 됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;왜 Clerk인가&lt;/h2&gt;
&lt;p&gt;직접 만들면 대략 이만큼입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;회원가입 / 로그인 / 세션 / 쿠키&lt;/li&gt;
&lt;li&gt;비밀번호 해시, 이메일 인증&lt;/li&gt;
&lt;li&gt;로그아웃, 비밀번호 찾기…&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;학습 목표가 “인증 프로토콜”이 아니라면,&lt;br&gt;&lt;strong&gt;검증된 서비스 + SDK&lt;/strong&gt;로 붙이는 편이 앱에 집중하기 좋습니다.&lt;/p&gt;
&lt;p&gt;Clerk를 고른 이유:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Next App Router 공식 가이드가 친절함&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;SignInButton /&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;UserButton /&amp;gt;&lt;/code&gt; 같은 UI가 바로 있음&lt;/li&gt;
&lt;li&gt;서버에서 &lt;code&gt;auth()&lt;/code&gt;로 현재 유저 id를 얻기 쉬움&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;나중에 Auth.js 등으로 바꿔도 “보호된 라우트 + userId로 데이터 필터” 개념은 같습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;전체 그림&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;브라우저
  ↓
Clerk (로그인/세션)
  ↓
Next 미들웨어 (세션 연결)
  ↓
app/memos/layout (auth.protect)
  ↓
Server Action / Prisma
  ↓
Memo.userId === 현재 유저만 조회·수정·삭제
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;로그인만 하고 DB를 안 나누면,&lt;br&gt;여전히 &lt;strong&gt;남의 메모가 전부&lt;/strong&gt; 보입니다.&lt;br&gt;이번 편의 진짜 목표는 &lt;strong&gt;userId 연결&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1) Clerk 계정·애플리케이션&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://clerk.com&quot;&gt;https://clerk.com&lt;/a&gt; 가입&lt;/li&gt;
&lt;li&gt;새 Application 생성 (Next.js 선택)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API Keys&lt;/strong&gt;에서 아래 두 값을 복사&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;이름&lt;/th&gt;
&lt;th&gt;용도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;브라우저에 노출돼도 되는 공개 키&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLERK_SECRET_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;서버 전용. GitHub에 올리면 안 됨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;memo-app/.env&lt;/code&gt; (또는 &lt;code&gt;.env.local&lt;/code&gt;)에 추가:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DATABASE_URL=&quot;file:./dev.db&quot;
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;.gitignore&lt;/code&gt;에 &lt;code&gt;.env&lt;/code&gt; / &lt;code&gt;.env.local&lt;/code&gt;이 있는지 다시 확인하세요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2) 패키지 설치&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npm install @clerk/nextjs
&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;
&lt;h2&gt;3) 미들웨어 + 페이지(레이아웃)에서 보호&lt;/h2&gt;
&lt;p&gt;Next.js &lt;strong&gt;16&lt;/strong&gt;은 네트워크 경계 파일 이름이 &lt;code&gt;proxy.ts&lt;/code&gt;인 경우가 있고,&lt;br&gt;&lt;strong&gt;15 이하&lt;/strong&gt;는 보통 &lt;code&gt;middleware.ts&lt;/code&gt;입니다.&lt;br&gt;(공식 문서가 &lt;code&gt;middleware.ts&lt;/code&gt;를 말하면, 버전 기준으로 파일명만 맞추면 됩니다.)&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;proxy.ts&lt;/code&gt; — Clerk 연결만&lt;/h3&gt;
&lt;p&gt;경로: &lt;code&gt;memo-app/proxy.ts&lt;/code&gt; (또는 &lt;code&gt;middleware.ts&lt;/code&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { clerkMiddleware } from &amp;quot;@clerk/nextjs/server&amp;quot;;

export default clerkMiddleware();

export const config = {
  matcher: [
    &amp;quot;/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)&amp;quot;,
    &amp;quot;/(api|trpc)(.*)&amp;quot;,
  ],
};&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;최신 &lt;code&gt;@clerk/nextjs&lt;/code&gt;에서는 &lt;code&gt;createRouteMatcher&lt;/code&gt;로 미들웨어에서 경로를 막는 방식이 &lt;strong&gt;deprecated&lt;/strong&gt;입니다.&lt;br&gt;미들웨어는 Clerk 세션만 연결하고, &lt;strong&gt;보호는 각 페이지/레이아웃에서&lt;/strong&gt; 합니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;&lt;code&gt;app/memos/layout.tsx&lt;/code&gt; — /memos 전체 보호&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { auth } from &amp;quot;@clerk/nextjs/server&amp;quot;;

export default async function MemosLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  await auth.protect();
  return children;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;의미:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/memos&lt;/code&gt;, &lt;code&gt;/memos/new&lt;/code&gt;, &lt;code&gt;/memos/1&lt;/code&gt; … → 이 레이아웃을 거치므로 로그인 필요&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/&lt;/code&gt; (홈) → 레이아웃 밖이라 비로그인 가능&lt;/li&gt;
&lt;li&gt;&lt;code&gt;auth.protect()&lt;/code&gt; → 없으면 로그인 화면으로&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;4) 레이아웃에 ClerkProvider + 버튼&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;app/layout.tsx&lt;/code&gt; (루트 레이아웃):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import Link from &amp;quot;next/link&amp;quot;;
import type { Metadata } from &amp;quot;next&amp;quot;;
import {
  ClerkProvider,
  SignInButton,
  SignUpButton,
  Show,
  UserButton,
} from &amp;quot;@clerk/nextjs&amp;quot;;
import &amp;quot;./globals.css&amp;quot;;

export const metadata: Metadata = {
  title: &amp;quot;Memo App&amp;quot;,
  description: &amp;quot;Next.js + TypeScript 실습&amp;quot;,
};

export default function RootLayout({
  children,
}: Readonly&amp;lt;{ children: React.ReactNode }&amp;gt;) {
  return (
    &amp;lt;html lang=&amp;quot;ko&amp;quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;ClerkProvider&amp;gt;
          &amp;lt;header&amp;gt;
            &amp;lt;nav&amp;gt;
              &amp;lt;Link href=&amp;quot;/&amp;quot;&amp;gt;홈&amp;lt;/Link&amp;gt;
              {&amp;quot; | &amp;quot;}
              &amp;lt;Link href=&amp;quot;/memos&amp;quot;&amp;gt;메모&amp;lt;/Link&amp;gt;
              {&amp;quot; | &amp;quot;}
              &amp;lt;Show when=&amp;quot;signed-out&amp;quot;&amp;gt;
                &amp;lt;SignInButton /&amp;gt; &amp;lt;SignUpButton /&amp;gt;
              &amp;lt;/Show&amp;gt;
              &amp;lt;Show when=&amp;quot;signed-in&amp;quot;&amp;gt;
                &amp;lt;UserButton /&amp;gt;
              &amp;lt;/Show&amp;gt;
            &amp;lt;/nav&amp;gt;
          &amp;lt;/header&amp;gt;
          {children}
        &amp;lt;/ClerkProvider&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컴포넌트&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ClerkProvider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;앱 전체에 Clerk 컨텍스트 (&lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; 안)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Show when=&amp;quot;signed-out&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;로그아웃일 때 보일 UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Show when=&amp;quot;signed-in&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;로그인일 때 보일 UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SignInButton&lt;/code&gt; / &lt;code&gt;SignUpButton&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;로그인·가입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UserButton&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;프로필·로그아웃&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;예전 &lt;code&gt;SignedIn&lt;/code&gt; / &lt;code&gt;SignedOut&lt;/code&gt;은 최신 Clerk에서 &lt;strong&gt;&lt;code&gt;Show&lt;/code&gt;&lt;/strong&gt; 로 바뀌었습니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;code&gt;npm run dev&lt;/code&gt; 후 홈에서 로그인 → &lt;code&gt;/memos&lt;/code&gt; 접근이 되면 1차 성공입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5) Prisma: 메모에 주인(&lt;code&gt;userId&lt;/code&gt;) 붙이기&lt;/h2&gt;
&lt;p&gt;스키마를 바꿉니다. &lt;code&gt;prisma/schema.prisma&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;model Memo {
  id        String   @id @default(cuid())
  title     String
  content   String
  userId    String
  createdAt DateTime @default(now())

  @@index([userId])
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;마이그레이션:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx prisma migrate dev --name add-memo-user-id
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;이미 시드/메모가 있다면 &lt;code&gt;userId&lt;/code&gt; 때문에 충돌할 수 있습니다.&lt;br&gt;연습 단계면 DB를 비우고 다시 만들어도 됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx prisma migrate reset
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;(&lt;code&gt;reset&lt;/code&gt;은 데이터 삭제 + 마이그레이션 + 시드. 로컬에서만.)&lt;/p&gt;
&lt;p&gt;시드(&lt;code&gt;prisma/seed.ts&lt;/code&gt;)에도 임시 &lt;code&gt;userId&lt;/code&gt;가 필요합니다.&lt;br&gt;예: &lt;code&gt;&amp;quot;seed-user&amp;quot;&lt;/code&gt; — 나중에 실제 Clerk 유저 id와는 다릅니다.&lt;br&gt;실습은 &lt;strong&gt;로그인 후 새로 만든 메모&lt;/strong&gt;로 확인하는 편이 낫습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6) 데이터 레이어: 내 것만&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;를 userId 기준으로 바꿉니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import type { Memo } from &amp;quot;@/types/memo&amp;quot;;
import { prisma } from &amp;quot;@/lib/prisma&amp;quot;;

function toMemo(row: {
  id: string;
  title: string;
  content: string;
  createdAt: Date;
}): Memo {
  return {
    id: row.id,
    title: row.title,
    content: row.content,
    createdAt: row.createdAt.toISOString().slice(0, 10),
  };
}

export async function getMemos(userId: string): Promise&amp;lt;Memo[]&amp;gt; {
  const rows = await prisma.memo.findMany({
    where: { userId },
    orderBy: { createdAt: &amp;quot;desc&amp;quot; },
  });
  return rows.map(toMemo);
}

export async function getMemoById(
  id: string,
  userId: string
): Promise&amp;lt;Memo | undefined&amp;gt; {
  const row = await prisma.memo.findFirst({
    where: { id, userId },
  });
  return row ? toMemo(row) : undefined;
}

export async function addMemo(
  userId: string,
  input: { title: string; content: string }
): Promise&amp;lt;Memo&amp;gt; {
  const row = await prisma.memo.create({
    data: {
      userId,
      title: input.title,
      content: input.content,
    },
  });
  return toMemo(row);
}

export async function updateMemo(
  id: string,
  userId: string,
  input: { title: string; content: string }
): Promise&amp;lt;Memo | undefined&amp;gt; {
  try {
    const row = await prisma.memo.update({
      where: { id },
      data: input,
    });
    // 남의 메모면 위에서 이미 다른 유저 것일 수 있으니, 소유권 확인
    if (row.userId !== userId) {
      return undefined;
    }
    return toMemo(row);
  } catch {
    return undefined;
  }
}

export async function deleteMemo(
  id: string,
  userId: string
): Promise&amp;lt;boolean&amp;gt; {
  const existing = await prisma.memo.findFirst({
    where: { id, userId },
  });
  if (!existing) return false;

  await prisma.memo.delete({ where: { id } });
  return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;더 안전한 update 패턴 (추천):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const result = await prisma.memo.updateMany({
  where: { id, userId },
  data: input,
});
if (result.count === 0) return undefined;
return getMemoById(id, userId);
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;updateMany&lt;/code&gt; + &lt;code&gt;where: { id, userId }&lt;/code&gt;면 &lt;strong&gt;남의 id를 알아도&lt;/strong&gt; 수정이 안 됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7) Action / 페이지에서 &lt;code&gt;auth()&lt;/code&gt;로 userId&lt;/h2&gt;
&lt;p&gt;서버에서 현재 유저:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { auth } from &quot;@clerk/nextjs/server&quot;;

const { userId } = await auth();
if (!userId) {
  throw new Error(&quot;로그인이 필요합니다&quot;);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;createMemo&lt;/code&gt; 예시 핵심만:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const { userId } = await auth();
if (!userId) {
  return { ok: false, message: &quot;로그인이 필요합니다&quot;, values };
}

// ... zod 검증 후
await addMemo(userId, parsed.data);
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;목록 페이지:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { auth } from &quot;@clerk/nextjs/server&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { getMemos } from &quot;@/lib/memos&quot;;

export default async function MemosPage() {
  const { userId } = await auth();
  if (!userId) redirect(&quot;/&quot;);

  const memos = await getMemos(userId);
  // ... 기존 UI
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;상세·수정도 &lt;code&gt;getMemoById(id, userId)&lt;/code&gt;로 바꿉니다.&lt;br&gt;없으면 &lt;code&gt;notFound()&lt;/code&gt; — 남 메모 URL을 쳐도 404.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;updateMemoAction&lt;/code&gt; / &lt;code&gt;deleteMemoAction&lt;/code&gt;에도 똑같이 &lt;code&gt;userId&lt;/code&gt;를 넘기세요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;동작 확인 체크리스트&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;로그아웃 상태에서 &lt;code&gt;/memos&lt;/code&gt; → 로그인으로 유도&lt;/li&gt;
&lt;li&gt;가입/로그인 후 메모 작성&lt;/li&gt;
&lt;li&gt;목록에 &lt;strong&gt;내 메모만&lt;/strong&gt; 보임&lt;/li&gt;
&lt;li&gt;(가능하면) 다른 계정으로 로그인 → 이전 계정 메모가 안 보임&lt;/li&gt;
&lt;li&gt;다른 유저 메모 id를 URL에 넣으면 404&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;한 계정만 있어도 1~3이면 기본 연동은 성공입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;자주 하는 실수&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;CLERK_SECRET_KEY&lt;/code&gt;를 클라이언트에 노출&lt;/strong&gt;&lt;br&gt;→ &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; 붙이지 말 것&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 미들웨어만 하고 DB는 그대로&lt;/strong&gt;&lt;br&gt;→ 로그인은 됐는데 데이터는 공유. &lt;code&gt;userId&lt;/code&gt; 필수&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;auth()&lt;/code&gt;를 await 안 함&lt;/strong&gt;&lt;br&gt;→ 최신 SDK는 &lt;code&gt;await auth()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. update를 &lt;code&gt;where: { id }&lt;/code&gt;만 함&lt;/strong&gt;&lt;br&gt;→ id를 알면 남의 글 수정 가능. 반드시 &lt;code&gt;userId&lt;/code&gt; 조건&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. env를 넣고 서버를 안 재시작&lt;/strong&gt;&lt;br&gt;→ &lt;code&gt;.env&lt;/code&gt; 변경 후 &lt;code&gt;npm run dev&lt;/code&gt; 다시&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Root에 Clerk를 설치하고 memo-app에서 import&lt;/strong&gt;&lt;br&gt;→ 패키지·env는 &lt;strong&gt;memo-app&lt;/strong&gt; 기준&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;인증은 직접 구현보다 서비스+SDK가 실무에서 흔하다&lt;/li&gt;
&lt;li&gt;미들웨어 = Clerk 세션 연결 (&lt;code&gt;clerkMiddleware&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;접근 보호 = 레이아웃/페이지에서 &lt;code&gt;auth.protect()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;auth().userId&lt;/code&gt; = 서버에서 “지금 누구인지”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;보호의 핵심은 DB 쿼리에 userId를 넣는 것&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;UI 컴포넌트(&lt;code&gt;SignInButton&lt;/code&gt; 등)와 데이터 소유권은 같이 설계한다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;다음으로 이어가면 좋은 것&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Neon Postgres + Vercel에 &lt;code&gt;CLERK_*&lt;/code&gt; 환경 변수까지 넣어 &lt;strong&gt;배포본 로그인&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;조직(Organization) / 역할(role) — 팀 메모&lt;/li&gt;
&lt;li&gt;Auth.js로 같은 구조를 직접 비교&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;시리즈 목차 (실전)&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;메모 수정과 삭제&lt;/li&gt;
&lt;li&gt;Prisma + SQLite로 DB 붙이기&lt;/li&gt;
&lt;li&gt;Vercel에 배포하기&lt;/li&gt;
&lt;li&gt;폼 검증하기 (zod)&lt;/li&gt;
&lt;li&gt;로그인 붙이기 (Clerk) ← 지금&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/792</guid>
      <comments>https://aamoos.tistory.com/792#entry792comment</comments>
      <pubDate>Thu, 30 Jul 2026 13:05:03 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS 실전] 4. 폼 검증하기 (zod)</title>
      <link>https://aamoos.tistory.com/791</link>
      <description>&lt;h1&gt;[Next.js + TS 실전] 4. 폼 검증하기 (zod)&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 검증은 &lt;code&gt;if (!title)&lt;/code&gt; + &lt;code&gt;throw new Error&lt;/code&gt; 수준이었습니다.&lt;br /&gt;이번 편은 &lt;b&gt;필드별 메시지&lt;/b&gt;를 폼 아래에 보여 줍니다.&lt;br /&gt;목표: zod 스키마 &amp;middot; Server Action 반환값 &amp;middot; &lt;code&gt;useActionState&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 &lt;code&gt;throw&lt;/code&gt;만으로는 UX가 약한지 이해하기&lt;/li&gt;
&lt;li&gt;zod로 메모 입력 규칙 정하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;createMemo&lt;/code&gt;가 성공/실패 결과를 &lt;b&gt;반환&lt;/b&gt;하게 바꾸기&lt;/li&gt;
&lt;li&gt;클라이언트 폼에서 &lt;code&gt;useActionState&lt;/code&gt;로 에러 표시하기&lt;/li&gt;
&lt;li&gt;수정 폼에도 같은 패턴 적용하기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB&amp;middot;배포는 건드리지 않습니다.&lt;br /&gt;&lt;b&gt;이미 있는 작성/수정 폼의 품질&lt;/b&gt;만 올립니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;지금 방식의 한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 Action 대략:&lt;/p&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;if (!title || !content) {
  throw new Error(&quot;제목과 내용을 모두 입력하세요&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 필드가 문제인지 모호하다&lt;/li&gt;
&lt;li&gt;화면에는 &lt;code&gt;error.tsx&lt;/code&gt;로 크게 깨지거나, 메시지가 폼과 떨어져 보인다&lt;/li&gt;
&lt;li&gt;HTML &lt;code&gt;required&lt;/code&gt;만 믿으면 브라우저마다 다르고, 서버 검증과 중복된다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하는 UX:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;제목
[                    ]
&amp;rarr; 제목은 1자 이상 입력하세요

내용
[                    ]
&amp;rarr; 내용은 1자 이상 입력하세요&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 흐름&lt;/h2&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;폼 제출
  &amp;darr;
createMemo (Server Action)
  &amp;darr;
zod로 검증
  ├─ 실패 &amp;rarr; { ok: false, errors, values } 반환 (페이지 유지)
  └─ 성공 &amp;rarr; DB 저장 후 redirect
  &amp;darr;
useActionState가 state를 받아 에러 표시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 변화:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실패할 때 &lt;b&gt;throw하지 않고 객체를 반환&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;폼이 그 객체를 읽어 메시지를 그림&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1) zod 설치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;memo-app&lt;/code&gt;에서:&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;npm install zod&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;zod = TypeScript와 잘 맞는 &lt;b&gt;스키마 검증 라이브러리&lt;/b&gt;입니다.&lt;br /&gt;&amp;ldquo;이 값은 이런 모양이어야 한다&amp;rdquo;를 한곳에서 선언합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) 스키마 파일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;lib/memo-schema.ts&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import { z } from &quot;zod&quot;;

export const memoFormSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1, &quot;제목은 1자 이상 입력하세요&quot;)
    .max(100, &quot;제목은 100자 이하여야 합니다&quot;),
  content: z
    .string()
    .trim()
    .min(1, &quot;내용은 1자 이상 입력하세요&quot;)
    .max(5000, &quot;내용은 5000자 이하여야 합니다&quot;),
});

export type MemoFormInput = z.infer&amp;lt;typeof memoFormSchema&amp;gt;;

export type MemoFormState = {
  ok: boolean;
  message?: string;
  errors?: {
    title?: string[];
    content?: string[];
  };
  values?: {
    title: string;
    content: string;
  };
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;코드&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;z.object&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;폼 필드 묶음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;min&lt;/code&gt; / &lt;code&gt;max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;길이 규칙 + 한국어 메시지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;z.infer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;스키마에서 TS 타입 자동 추출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MemoFormState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Action이 폼에 돌려줄 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;errors.title&lt;/code&gt;을 배열로 둔 이유: zod가 필드당 메시지를 배열로 주기 때문입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3) &lt;code&gt;createMemo&lt;/code&gt;를 &amp;ldquo;결과 반환&amp;rdquo;형으로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/memos/actions.ts&lt;/code&gt;의 생성 액션을 바꿉니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;&quot;use server&quot;;

import { z } from &quot;zod&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { revalidatePath } from &quot;next/cache&quot;;
import { addMemo, deleteMemo, updateMemo } from &quot;@/lib/memos&quot;;
import { memoFormSchema, type MemoFormState } from &quot;@/lib/memo-schema&quot;;

export async function createMemo(
  _prevState: MemoFormState,
  formData: FormData
): Promise&amp;lt;MemoFormState&amp;gt; {
  const values = {
    title: String(formData.get(&quot;title&quot;) ?? &quot;&quot;),
    content: String(formData.get(&quot;content&quot;) ?? &quot;&quot;),
  };

  const parsed = memoFormSchema.safeParse(values);

  if (!parsed.success) {
    return {
      ok: false,
      // Zod 4: parsed.error.flatten() 대신 z.flattenError
      errors: z.flattenError(parsed.error).fieldErrors,
      values,
    };
  }

  await addMemo(parsed.data);

  revalidatePath(&quot;/memos&quot;);
  redirect(&quot;/memos&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바뀐 점:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;인자가 &lt;code&gt;(prevState, formData)&lt;/code&gt; &amp;mdash; &lt;code&gt;useActionState&lt;/code&gt; 규약&lt;/li&gt;
&lt;li&gt;&lt;code&gt;safeParse&lt;/code&gt; &amp;mdash; 실패해도 throw하지 않음&lt;/li&gt;
&lt;li&gt;실패 시 &lt;code&gt;errors&lt;/code&gt; + 사용자가 친 &lt;code&gt;values&lt;/code&gt;를 돌려줌 (입력 유지)&lt;/li&gt;
&lt;li&gt;성공 시만 &lt;code&gt;redirect&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;deleteMemoAction&lt;/code&gt;은 이번 편에서 그대로 둬도 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4) 작성 폼을 Client로 (에러 표시)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;useActionState&lt;/code&gt;는 훅이라 &lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;가 필요합니다.&lt;br /&gt;페이지 전체를 클라이언트로 올리기보다, &lt;b&gt;폼만&lt;/b&gt; 분리합니다. (5편 패턴)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;components/MemoForm.tsx&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useActionState } from &quot;react&quot;;
import type { MemoFormState } from &quot;@/lib/memo-schema&quot;;

type MemoFormProps = {
  action: (
    prevState: MemoFormState,
    formData: FormData
  ) =&amp;gt; Promise&amp;lt;MemoFormState&amp;gt;;
  initialTitle?: string;
  initialContent?: string;
  submitLabel?: string;
  hiddenFields?: Record&amp;lt;string, string&amp;gt;;
};

const initialState: MemoFormState = { ok: true };

export default function MemoForm({
  action,
  initialTitle = &quot;&quot;,
  initialContent = &quot;&quot;,
  submitLabel = &quot;저장&quot;,
  hiddenFields,
}: MemoFormProps) {
  const [state, formAction, pending] = useActionState(action, initialState);

  const titleValue = state.values?.title ?? initialTitle;
  const contentValue = state.values?.content ?? initialContent;

  return (
    &amp;lt;form action={formAction}&amp;gt;
      {hiddenFields &amp;amp;&amp;amp;
        Object.entries(hiddenFields).map(([name, value]) =&amp;gt; (
          &amp;lt;input key={name} type=&quot;hidden&quot; name={name} value={value} /&amp;gt;
        ))}

      &amp;lt;p&amp;gt;
        &amp;lt;label&amp;gt;
          제목
          &amp;lt;br /&amp;gt;
          &amp;lt;input
            type=&quot;text&quot;
            name=&quot;title&quot;
            defaultValue={titleValue}
            key={`title-${titleValue}`}
          /&amp;gt;
        &amp;lt;/label&amp;gt;
        {state.errors?.title?.[0] &amp;amp;&amp;amp; (
          &amp;lt;span style={{ color: &quot;crimson&quot; }}&amp;gt;{state.errors.title[0]}&amp;lt;/span&amp;gt;
        )}
      &amp;lt;/p&amp;gt;

      &amp;lt;p&amp;gt;
        &amp;lt;label&amp;gt;
          내용
          &amp;lt;br /&amp;gt;
          &amp;lt;textarea
            name=&quot;content&quot;
            rows={5}
            defaultValue={contentValue}
            key={`content-${contentValue}`}
          /&amp;gt;
        &amp;lt;/label&amp;gt;
        {state.errors?.content?.[0] &amp;amp;&amp;amp; (
          &amp;lt;span style={{ color: &quot;crimson&quot; }}&amp;gt;{state.errors.content[0]}&amp;lt;/span&amp;gt;
        )}
      &amp;lt;/p&amp;gt;

      &amp;lt;button type=&quot;submit&quot; disabled={pending}&amp;gt;
        {pending ? &quot;저장 중...&quot; : submitLabel}
      &amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;useActionState(action, initialState)&lt;/code&gt; &amp;rarr; &lt;code&gt;[state, formAction, pending]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;defaultValue&lt;/code&gt; + &lt;code&gt;key&lt;/code&gt;로 검증 실패 후 입력값 유지&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pending&lt;/code&gt;으로 저장 중 버튼 비활성&lt;/li&gt;
&lt;li&gt;HTML &lt;code&gt;required&lt;/code&gt;는 빼도 됩니다 (서버+zod가 진실)&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSX를 &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; 안에 넣을 때 &lt;code&gt;&amp;amp;lt;&lt;/code&gt; &lt;code&gt;&amp;amp;gt;&lt;/code&gt;로 이스케이프했습니다.&lt;br /&gt;실제 파일에는 일반 &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;을 쓰세요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&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;code&gt;app/memos/new/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import MemoForm from &quot;@/components/MemoForm&quot;;
import { createMemo } from &quot;@/app/memos/actions&quot;;

export default function NewMemoPage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;새 메모&amp;lt;/h1&amp;gt;
      &amp;lt;MemoForm action={createMemo} /&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;/memos/new&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;제목&amp;middot;내용 비우고 저장&lt;/li&gt;
&lt;li&gt;필드 아래 빨간 메시지&lt;/li&gt;
&lt;li&gt;올바르게 입력하면 &lt;code&gt;/memos&lt;/code&gt;로 이동&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6) 수정도 같은 패턴&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;updateMemoAction&lt;/code&gt;도 &lt;code&gt;(prevState, formData)&lt;/code&gt;로 맞춥니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export async function updateMemoAction(
  _prevState: MemoFormState,
  formData: FormData
): Promise&amp;lt;MemoFormState&amp;gt; {
  const id = String(formData.get(&quot;id&quot;) ?? &quot;&quot;).trim();
  const values = {
    title: String(formData.get(&quot;title&quot;) ?? &quot;&quot;),
    content: String(formData.get(&quot;content&quot;) ?? &quot;&quot;),
  };

  if (!id) {
    return { ok: false, message: &quot;메모 id가 없습니다&quot;, values };
  }

  const parsed = memoFormSchema.safeParse(values);

  if (!parsed.success) {
    return {
      ok: false,
      errors: z.flattenError(parsed.error).fieldErrors,
      values,
    };
  }

  const updated = await updateMemo(id, parsed.data);

  if (!updated) {
    return { ok: false, message: &quot;메모를 찾을 수 없습니다&quot;, values };
  }

  revalidatePath(&quot;/memos&quot;);
  revalidatePath(`/memos/${id}`);
  redirect(`/memos/${id}`);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/memos/[id]/edit/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { notFound } from &quot;next/navigation&quot;;
import MemoForm from &quot;@/components/MemoForm&quot;;
import { updateMemoAction } from &quot;@/app/memos/actions&quot;;
import { getMemoById } from &quot;@/lib/memos&quot;;

type EditMemoPageProps = {
  params: Promise&amp;lt;{ id: string }&amp;gt;;
};

export default async function EditMemoPage({ params }: EditMemoPageProps) {
  const { id } = await params;
  const memo = await getMemoById(id);

  if (!memo) {
    notFound();
  }

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;메모 수정&amp;lt;/h1&amp;gt;
      &amp;lt;MemoForm
        action={updateMemoAction}
        initialTitle={memo.title}
        initialContent={memo.content}
        hiddenFields={{ id: memo.id }}
        submitLabel=&quot;수정 저장&quot;
      /&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작성&amp;middot;수정이 &lt;b&gt;같은 &lt;code&gt;MemoForm&lt;/code&gt;&lt;/b&gt;을 씁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;useActionState&lt;/code&gt; 짧게&lt;/h2&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;const [state, formAction, pending] = useActionState(action, initialState);&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;state&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;마지막 Action 반환값 (에러&amp;middot;values)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;formAction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;form action={...}&amp;gt;&lt;/code&gt;에 넘김&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pending&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;제출 진행 중 여부&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전 &lt;code&gt;useFormState&lt;/code&gt;와 같은 계열입니다.&lt;br /&gt;React 19 / 최신 Next에서는 &lt;code&gt;useActionState&lt;/code&gt;를 쓰면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 스키마를 Action에 두나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저 &lt;code&gt;required&lt;/code&gt;만으로도 빈 값은 막을 수 있습니다.&lt;br /&gt;그래도 &lt;b&gt;서버에서 다시&lt;/b&gt; 검증합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DevTools로 &lt;code&gt;required&lt;/code&gt;를 지우고 제출할 수 있음&lt;/li&gt;
&lt;li&gt;API&amp;middot;Action은 폼 말고도 호출될 수 있음&lt;/li&gt;
&lt;li&gt;규칙(최대 글자 등)을 &lt;b&gt;한 스키마&lt;/b&gt;로 공유 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 zod를 한 번 더 돌리는 &amp;ldquo;즉시 검증&amp;rdquo;은 나중에 추가해도 됩니다.&lt;br /&gt;입문 다음 단계에서는 &lt;b&gt;서버 검증 + 메시지 표시&lt;/b&gt;만으로 충분합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Action 시그니처를 예전 &lt;code&gt;(formData)&lt;/code&gt;만 유지&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;useActionState&lt;/code&gt;는 &lt;code&gt;(prevState, formData)&lt;/code&gt; 필요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 실패 시 계속 &lt;code&gt;throw&lt;/code&gt;&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;error.tsx&lt;/code&gt;로 튕김. 필드 에러는 &lt;code&gt;return { errors }&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 페이지 전체를 &lt;code&gt;'use client'&lt;/code&gt;&lt;/b&gt;&lt;br /&gt;&amp;rarr; 폼만 Client, 페이지는 Server 유지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. &lt;code&gt;safeParse&lt;/code&gt; 대신 &lt;code&gt;parse&lt;/code&gt;&lt;/b&gt;&lt;br /&gt;&amp;rarr; 실패 시 throw. 폼 UX에는 &lt;code&gt;safeParse&lt;/code&gt;가 맞음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. &lt;code&gt;redirect&lt;/code&gt;를 try/catch로 감싸서 에러로 착각&lt;/b&gt;&lt;br /&gt;&amp;rarr; Next의 &lt;code&gt;redirect&lt;/code&gt;는 특수 throw를 씁니다. 성공 경로에서만 호출&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;검증 규칙은 zod 스키마로 한곳에 둔다&lt;/li&gt;
&lt;li&gt;실패는 throw보다 &lt;b&gt;상태 객체 반환&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useActionState&lt;/code&gt;로 에러를 필드 옆에 보여 준다&lt;/li&gt;
&lt;li&gt;폼만 Client Component로 분리한다&lt;/li&gt;
&lt;li&gt;작성&amp;middot;수정은 같은 폼 컴포넌트를 재사용한다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차 (실전)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;메모 수정과 삭제&lt;/li&gt;
&lt;li&gt;Prisma + SQLite로 DB 붙이기&lt;/li&gt;
&lt;li&gt;Vercel에 배포하기&lt;/li&gt;
&lt;li&gt;폼 검증하기 (zod) &amp;larr; 지금&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/791</guid>
      <comments>https://aamoos.tistory.com/791#entry791comment</comments>
      <pubDate>Thu, 30 Jul 2026 10:57:55 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS 실전] 3. Vercel에 배포하기</title>
      <link>https://aamoos.tistory.com/790</link>
      <description>&lt;h1&gt;[Next.js + TS 실전] 3. Vercel에 배포하기&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실전 2편까지 로컬에서 Prisma + SQLite로 메모가 저장됩니다.&lt;br /&gt;이번 편은 &lt;b&gt;다른 사람이 URL로 접속&lt;/b&gt;할 수 있게 배포합니다.&lt;br /&gt;목표: GitHub &amp;rarr; Vercel 배포 &amp;middot; 환경 변수 &amp;middot; SQLite 한계와 대안.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;배포 전 로컬 빌드 확인&lt;/li&gt;
&lt;li&gt;GitHub에 올리고 Vercel 연결&lt;/li&gt;
&lt;li&gt;환경 변수 설정 이해&lt;/li&gt;
&lt;li&gt;&lt;b&gt;왜 Vercel에서 SQLite가 힘든지&lt;/b&gt; 알기&lt;/li&gt;
&lt;li&gt;현실적인 DB 선택지 정리 (Postgres 등)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;버튼 몇 번이면 끝&amp;rdquo;만 말하지 않습니다.&lt;br /&gt;로컬 SQLite 앱을 그대로 Vercel에 올리면 &lt;b&gt;어디서 막히는지&lt;/b&gt;까지 같이 봅니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배포가 되면 뭐가 바뀌나&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;로컬&lt;/th&gt;
&lt;th&gt;배포 후&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;접속&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localhost:3000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://something.vercel.app&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;내 PC&lt;/td&gt;
&lt;td&gt;Vercel 클라우드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma/dev.db&lt;/code&gt; 파일&lt;/td&gt;
&lt;td&gt;&lt;b&gt;파일 DB는 보통 유지 안 됨&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면(Next.js) 배포와 &lt;b&gt;데이터 저장&lt;/b&gt;은 별개입니다.&lt;br /&gt;이번 편의 핵심도 그 구분입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&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;code&gt;memo-app&lt;/code&gt;에서:&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;npm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 없이 끝나면 배포 가능성이 높습니다.&lt;br /&gt;여기서 깨지면 Vercel에서도 거의 같은 에러가 납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 서버만 켜 보는 것과 다릅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;npm run dev&lt;/code&gt; &amp;rarr; 개발용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;npm run build&lt;/code&gt; &amp;rarr; 프로덕션 결과물 만들기&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) GitHub에 올리기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vercel은 GitHub 저장소와 연결하는 방식이 가장 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 git이 없다면 &lt;code&gt;memo-app&lt;/code&gt;에서:&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;git init
git add .
git commit -m &quot;Add memo app with Prisma&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.gitignore&lt;/code&gt;에 아래가 있는지 확인하세요.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;.env
.env*.local
prisma/dev.db
prisma/dev.db-journal
node_modules
.next
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub에 새 저장소를 만들고 push:&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;git remote add origin https://github.com/본인계정/memo-app.git
git branch -M main
git push -u origin main
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.env&lt;/code&gt;는 올리지 않습니다.&lt;br /&gt;DB URL은 Vercel 대시보드에서 따로 넣습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3) Vercel에 프로젝트 연결&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://vercel.com&quot;&gt;https://vercel.com&lt;/a&gt; 가입/로그인 (GitHub 연동 권장)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Add New&amp;hellip; &amp;rarr; Project&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;방금 push한 저장소 Import&lt;/li&gt;
&lt;li&gt;Root Directory가 &lt;code&gt;memo-app&lt;/code&gt;이면 (모노레포인 경우) 그 폴더로 지정&lt;/li&gt;
&lt;li&gt;Framework Preset: &lt;b&gt;Next.js&lt;/b&gt; (보통 자동)&lt;/li&gt;
&lt;li&gt;Deploy&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 배포는 &lt;b&gt;DB 없이도&lt;/b&gt; 프론트가 뜰 수 있지만,&lt;br /&gt;메모 CRUD는 DB URL이 없으면 런타임에서 실패합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4) 왜 SQLite는 Vercel과 안 맞나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 SQLite는 &lt;b&gt;디스크의 한 파일&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vercel(서버리스)은 대략 이렇게 동작합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청마다 짧은 수명의 함수가 뜸&lt;/li&gt;
&lt;li&gt;파일 시스템은 &lt;b&gt;읽기 위주 / 임시&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;인스턴스가 여러 개일 수 있음&lt;/li&gt;
&lt;li&gt;재배포하면 로컬 파일이 날아감&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;file:./dev.db&lt;/code&gt; 방식은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쓰기가 안 되거나&lt;/li&gt;
&lt;li&gt;써도 다음 요청/재배포에 사라지거나&lt;/li&gt;
&lt;li&gt;인스턴스마다 다른 DB를 보는&lt;/li&gt;
&lt;/ul&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;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Vercel에 올릴 앱의 &amp;ldquo;진짜 DB&amp;rdquo;는 파일이 아니라, 외부 데이터베이스 서비스가 필요하다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 학습용 SQLite는 여전히 훌륭합니다.&lt;br /&gt;배포 단계에서만 교체하면 됩니다. (실전 2편에서 &lt;code&gt;lib/memos.ts&lt;/code&gt;만 바꾼 이유)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5) 배포용 DB 선택지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문&amp;middot;실전 시리즈 기준으로 현실적인 선택:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;선택&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;th&gt;추천&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Neon / Supabase (Postgres)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Prisma와 궁합 좋음, 무료 티어 있음&lt;/td&gt;
&lt;td&gt;⭐ 가장 무난&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Turso (libSQL)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;SQLite 호환에 가까운 원격 DB&lt;/td&gt;
&lt;td&gt;SQLite 감각 유지하고 싶을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Prisma Postgres&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Prisma 생태계 연동&lt;/td&gt;
&lt;td&gt;Prisma 문서 따라가기 쉬움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로컬 SQLite만&lt;/td&gt;
&lt;td&gt;배포 CRUD 불가에 가깝음&lt;/td&gt;
&lt;td&gt;화면만 배포 데모용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 방향을 &lt;b&gt;Postgres(Neon 등)로 바꾼다&lt;/b&gt;고 잡고,&lt;br /&gt;구체적인 클릭 경로는 서비스 UI가 자주 바뀌므로 &lt;b&gt;공통 절차&lt;/b&gt;만 적습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6) Postgres로 바꾸는 큰 그림&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) 클라우드에서 DB 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Neon / Supabase 등에서 프로젝트를 만들고&lt;br /&gt;&lt;b&gt;Connection string&lt;/b&gt;을 복사합니다. 예:&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;postgresql://USER:PASSWORD@HOST/DB?sslmode=require
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) 스키마 provider 변경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;prisma/schema.prisma&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;datasource db {
  provider = &quot;postgresql&quot;
  url      = env(&quot;DATABASE_URL&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Memo&lt;/code&gt; 모델은 그대로 둬도 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(3) 로컬 &lt;code&gt;.env&lt;/code&gt;도 잠시 Postgres로&lt;/h3&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;DATABASE_URL=&quot;postgresql://...&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음:&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;npx prisma migrate dev --name postgres-init
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(4) Vercel 환경 변수&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vercel Project &amp;rarr; &lt;b&gt;Settings &amp;rarr; Environment Variables&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Postgres 연결 문자열&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Production / Preview에 체크하고 저장한 뒤 &lt;b&gt;Redeploy&lt;/b&gt;.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(5) 코드는?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;, Server Action, 페이지는 &lt;b&gt;그대로&lt;/b&gt;인 경우가 많습니다.&lt;br /&gt;바뀌는 건 주로:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;provider&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;migrate 대상 DB&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 데이터 레이어를 나눠 둔 보답입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7) Vercel 빌드에서 Prisma 자주 하는 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;package.json&lt;/code&gt;에 빌드 직전 generate를 넣는 경우가 많습니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;next dev&quot;,
    &quot;build&quot;: &quot;prisma generate &amp;amp;&amp;amp; next build&quot;,
    &quot;start&quot;: &quot;next start&quot;,
    &quot;postinstall&quot;: &quot;prisma generate&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;postinstall&lt;/code&gt;: &lt;code&gt;npm install&lt;/code&gt; 직후 클라이언트 생성&lt;/li&gt;
&lt;li&gt;&lt;code&gt;build&lt;/code&gt; 앞 &lt;code&gt;prisma generate&lt;/code&gt;: Vercel 빌드 환경에서 타입/엔진 준비&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이그레이션을 배포 파이프라인에 넣을지는 팀마다 다릅니다.&lt;br /&gt;혼자 연습이면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;로컬(또는 CI)에서 &lt;code&gt;prisma migrate deploy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;앱은 Vercel에 배포&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서로 시작하는 편이 단순합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8) 배포 후 체크리스트&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;https://프로젝트.vercel.app&lt;/code&gt; 접속&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos&lt;/code&gt; 목록&lt;/li&gt;
&lt;li&gt;새 메모 작성 &amp;rarr; 새로고침 후에도 남는지&lt;/li&gt;
&lt;li&gt;수정 / 삭제&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안 되면 Vercel &lt;b&gt;Deployments &amp;rarr; 해당 배포 &amp;rarr; Logs / Functions&lt;/b&gt; 에서&lt;br /&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt;, Prisma 에러를 확인합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. &lt;code&gt;.env&lt;/code&gt;를 GitHub에 올림&lt;/b&gt;&lt;br /&gt;&amp;rarr; 비밀 유출. 올렸다면 키 교체 + git 기록 정리 필요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Vercel에 &lt;code&gt;DATABASE_URL&lt;/code&gt;을 안 넣음&lt;/b&gt;&lt;br /&gt;&amp;rarr; 로컬에만 있어서 배포 환경에서 DB 연결 실패&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. SQLite 파일 그대로 배포 기대&lt;/b&gt;&lt;br /&gt;&amp;rarr; 서버리스와 구조적으로 안 맞음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. Root Directory 잘못 지정&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;toyproject&lt;/code&gt; 루트를 배포해 &lt;code&gt;memo-app&lt;/code&gt;을 못 찾음&lt;br /&gt;&amp;rarr; Vercel에서 Root를 &lt;code&gt;memo-app&lt;/code&gt;으로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. &lt;code&gt;npm run build&lt;/code&gt;를 로컬에서 안 해보고 바로 배포&lt;/b&gt;&lt;br /&gt;&amp;rarr; 같은 에러를 Vercel 로그에서만 헤맴&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Vercel 배포 = Git 연결 + 빌드 + (필요 시) 환경 변수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;화면 배포&lt;/b&gt;와 &lt;b&gt;DB&lt;/b&gt;는 별개다&lt;/li&gt;
&lt;li&gt;SQLite 파일은 로컬용, Vercel에는 외부 DB가 맞다&lt;/li&gt;
&lt;li&gt;Prisma면 &lt;code&gt;provider&lt;/code&gt; + &lt;code&gt;DATABASE_URL&lt;/code&gt;만 바꿔도 앱 코드는 유지하기 쉽다&lt;/li&gt;
&lt;li&gt;배포 전에 &lt;code&gt;npm run build&lt;/code&gt;로 먼저 검증한다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실습을 어디까지 하면 되나 (추천)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간이 없다면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;GitHub + Vercel로 &lt;b&gt;앱 화면&lt;/b&gt;만 배포해 보기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos&lt;/code&gt;가 DB 때문에 에러 나는 걸 &lt;b&gt;로그로 확인&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;그래서 외부 DB가 필요하다&amp;rdquo;를 글로 정리&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간이 있다면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;4&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Neon(또는 Supabase) Postgres 연결&lt;/li&gt;
&lt;li&gt;migrate + Vercel &lt;code&gt;DATABASE_URL&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;CRUD가 배포 URL에서 동작하는지 확인&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로그에는 둘 다 솔직히 적어도 좋습니다.&lt;br /&gt;완벽 배포보다 &lt;b&gt;왜 SQLite가 안 되는지&lt;/b&gt;를 쓰는 편이 독자에게 더 가치가 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈를 마치며 (실전 1~3)&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;편&lt;/th&gt;
&lt;th&gt;한 줄&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;수정&amp;middot;삭제로 CRUD 완성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Prisma + SQLite로 영속화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Vercel 배포 + 외부 DB 필요성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문 8편 + 실전 3편이면:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React 경험 &amp;rarr; Next App Router + TS &amp;rarr; CRUD &amp;rarr; DB &amp;rarr; 배포 이슈까지&lt;/p&gt;
&lt;/blockquote&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Neon Postgres 연결 실습 (스크린샷 중심)&lt;/li&gt;
&lt;li&gt;폼 검증 (zod + &lt;code&gt;useActionState&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;로그인 (Clerk / Auth.js)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차 (실전)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;메모 수정과 삭제&lt;/li&gt;
&lt;li&gt;Prisma + SQLite로 DB 붙이기&lt;/li&gt;
&lt;li&gt;Vercel에 배포하기 &amp;larr; 지금&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/790</guid>
      <comments>https://aamoos.tistory.com/790#entry790comment</comments>
      <pubDate>Thu, 30 Jul 2026 10:48:06 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS 실전] 2. Prisma + SQLite로 DB 붙이기</title>
      <link>https://aamoos.tistory.com/789</link>
      <description>&lt;h1&gt;[Next.js + TS 실전] 2. Prisma + SQLite로 DB 붙이기&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실전 1편까지 CRUD는 완성됐습니다.&lt;br /&gt;문제는 &lt;b&gt;서버를 재시작하면 메모가 사라진다는 것&lt;/b&gt;입니다.&lt;br /&gt;이번 편은 Prisma + SQLite로 &lt;b&gt;진짜 저장&lt;/b&gt;을 붙입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Prisma + SQLite 설치&amp;middot;초기화&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Memo&lt;/code&gt; 테이블(스키마) 정의&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/prisma.ts&lt;/code&gt; 클라이언트 만들기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;를 배열 &amp;rarr; DB 쿼리로 교체&lt;/li&gt;
&lt;li&gt;시드(초기 데이터) 넣고 재시작해도 남는지 확인&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지&amp;middot;Server Action 코드는 &lt;b&gt;거의 안 건드립니다.&lt;/b&gt;&lt;br /&gt;바뀌는 건 주로 &lt;code&gt;lib/memos.ts&lt;/code&gt;입니다. 이게 이번 편의 핵심입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 Prisma + SQLite인가&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;선택&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;SQLite&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;파일 하나로 동작. 로컬 학습에 제일 단순&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Prisma&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;스키마 &amp;rarr; 타입 생성. TypeScript와 궁합이 좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 PostgreSQL로 바꿀 때도, 스키마의 &lt;code&gt;provider&lt;/code&gt;와 &lt;code&gt;DATABASE_URL&lt;/code&gt;만 바꾸면 비슷한 코드로 갈 수 있습니다.&lt;br /&gt;지금은 &lt;b&gt;로컬에서 영속화&lt;/b&gt;가 목표입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 그림&lt;/h2&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;페이지 / Server Action
        &amp;darr;
   lib/memos.ts   &amp;larr; 함수 이름은 그대로
        &amp;darr;
   lib/prisma.ts
        &amp;darr;
   SQLite 파일 (prisma/dev.db)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;getMemos&lt;/code&gt;, &lt;code&gt;addMemo&lt;/code&gt; 같은 &lt;b&gt;함수 시그니처를 유지&lt;/b&gt;하면&lt;br /&gt;&lt;code&gt;actions.ts&lt;/code&gt;와 페이지는 그대로 두어도 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&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;code&gt;memo-app&lt;/code&gt; 폴더에서:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;npm install @prisma/client
npm install -D prisma
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기화 (SQLite):&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;npx prisma init --datasource-provider sqlite
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성되는 것:&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;prisma/schema.prisma
.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.env&lt;/code&gt; 예시:&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;DATABASE_URL=&quot;file:./dev.db&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;file:./dev.db&lt;/code&gt;는 &lt;code&gt;prisma/&lt;/code&gt; 폴더 기준 상대 경로입니다.&lt;br /&gt;실제 DB 파일은 &lt;code&gt;prisma/dev.db&lt;/code&gt;에 생깁니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.env&lt;/code&gt;는 Git에 올리지 마세요.&lt;br /&gt;&lt;code&gt;create-next-app&lt;/code&gt;이 &lt;code&gt;.gitignore&lt;/code&gt;에 &lt;code&gt;.env*&lt;/code&gt;를 넣어 둔 경우가 많습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) 스키마 정의&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;prisma/schema.prisma&lt;/code&gt;를 아래처럼 맞춥니다.&lt;/p&gt;
&lt;pre class=&quot;puppet&quot;&gt;&lt;code&gt;generator client {
  provider = &quot;prisma-client-js&quot;
}

datasource db {
  provider = &quot;sqlite&quot;
  url      = env(&quot;DATABASE_URL&quot;)
}

model Memo {
  id        String   @id @default(cuid())
  title     String
  content   String
  createdAt DateTime @default(now())
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;필드&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;기본 키&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@default(cuid())&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;고유 id 자동 생성 (&lt;code&gt;Date.now()&lt;/code&gt; 대체)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DateTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DB에는 날짜 타입, 화면에는 문자열로 변환해서 씀&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@default(now())&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;생성 시각 자동&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블을 만들고 Prisma Client를 생성합니다.&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;npx prisma migrate dev --name init
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공하면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;prisma/dev.db&lt;/code&gt; 생성&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prisma/migrations/&lt;/code&gt; 생성&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@prisma/client&lt;/code&gt; 타입이 갱신됨&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3) Prisma Client 싱글톤&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 개발 모드에서는 Hot Reload 때문에&lt;br /&gt;&lt;code&gt;PrismaClient&lt;/code&gt;를 매번 &lt;code&gt;new&lt;/code&gt; 하면 연결이 쌓일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;lib/prisma.ts&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { PrismaClient } from &quot;@prisma/client&quot;;

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== &quot;production&quot;) {
  globalForPrisma.prisma = prisma;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발에서만 &lt;code&gt;globalThis&lt;/code&gt;에 붙여 두는 패턴입니다.&lt;br /&gt;공식 문서에서도 비슷한 예시를 씁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4) &lt;code&gt;lib/memos.ts&lt;/code&gt;를 DB로 교체&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열과 &lt;code&gt;delay&lt;/code&gt;를 지우고, Prisma로 바꿉니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;import type { Memo } from &quot;@/types/memo&quot;;
import { prisma } from &quot;@/lib/prisma&quot;;

function toMemo(row: {
  id: string;
  title: string;
  content: string;
  createdAt: Date;
}): Memo {
  return {
    id: row.id,
    title: row.title,
    content: row.content,
    createdAt: row.createdAt.toISOString().slice(0, 10),
  };
}

export async function getMemos(): Promise&amp;lt;Memo[]&amp;gt; {
  const rows = await prisma.memo.findMany({
    orderBy: { createdAt: &quot;desc&quot; },
  });

  return rows.map(toMemo);
}

export async function getMemoById(id: string): Promise&amp;lt;Memo | undefined&amp;gt; {
  const row = await prisma.memo.findUnique({
    where: { id },
  });

  return row ? toMemo(row) : undefined;
}

export async function addMemo(input: {
  title: string;
  content: string;
}): Promise&amp;lt;Memo&amp;gt; {
  const row = await prisma.memo.create({
    data: {
      title: input.title,
      content: input.content,
    },
  });

  return toMemo(row);
}

export async function updateMemo(
  id: string,
  input: { title: string; content: string }
): Promise&amp;lt;Memo | undefined&amp;gt; {
  try {
    const row = await prisma.memo.update({
      where: { id },
      data: {
        title: input.title,
        content: input.content,
      },
    });

    return toMemo(row);
  } catch {
    return undefined;
  }
}

export async function deleteMemo(id: string): Promise&amp;lt;boolean&amp;gt; {
  try {
    await prisma.memo.delete({
      where: { id },
    });
    return true;
  } catch {
    return false;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;함수 이름&amp;middot;반환 타입은 그대로&lt;/b&gt; &amp;rarr; Action/페이지 수정 최소&lt;/li&gt;
&lt;li&gt;&lt;code&gt;toMemo&lt;/code&gt;로 DB &lt;code&gt;Date&lt;/code&gt; &amp;rarr; 화면용 &lt;code&gt;string&lt;/code&gt; 변환&lt;/li&gt;
&lt;li&gt;없는 id update/delete는 Prisma가 에러 &amp;rarr; &lt;code&gt;try/catch&lt;/code&gt;로 &lt;code&gt;undefined&lt;/code&gt; / &lt;code&gt;false&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;가짜 &lt;code&gt;delay&lt;/code&gt;는 제거 (로딩 UI는 DB 속도에 맡김)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;types/memo.ts&lt;/code&gt;의 &lt;code&gt;Memo&lt;/code&gt; 타입은 &lt;b&gt;그대로 둡니다.&lt;/b&gt;&lt;br /&gt;DB 모델과 화면 모델을 &lt;code&gt;toMemo&lt;/code&gt;로 연결하는 방식입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5) 초기 데이터(시드) 넣기 (선택)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB가 비어 있으면 목록이 텅 빕니다.&lt;br /&gt;연습용 데이터를 넣고 싶다면:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;prisma/seed.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { PrismaClient } from &quot;@prisma/client&quot;;

const prisma = new PrismaClient();

async function main() {
  await prisma.memo.createMany({
    data: [
      {
        title: &quot;Next.js 시작&quot;,
        content: &quot;App Router로 프로젝트 만들었다.&quot;,
      },
      {
        title: &quot;TypeScript props&quot;,
        content: &quot;MemoCard에 타입을 붙였다.&quot;,
      },
    ],
  });
}

main()
  .then(() =&amp;gt; prisma.$disconnect())
  .catch(async (e) =&amp;gt; {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;package.json&lt;/code&gt; &lt;b&gt;맨 바깥&lt;/b&gt;에 &lt;code&gt;prisma&lt;/code&gt; 키를 추가합니다.&lt;br /&gt;(주의: &lt;code&gt;{ &quot;prisma&quot;: ... }&lt;/code&gt;를 파일에 따로 하나 더 붙이면 JSON이 깨집니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예:&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;memo-app&quot;,
  &quot;scripts&quot;: { &quot;...&quot;: &quot;...&quot; },
  &quot;dependencies&quot;: { &quot;...&quot;: &quot;...&quot; },
  &quot;devDependencies&quot;: { &quot;...&quot;: &quot;...&quot; },
  &quot;prisma&quot;: {
    &quot;seed&quot;: &quot;npx tsx prisma/seed.ts&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;tsx&lt;/code&gt;가 없으면:&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;npm install -D tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시드 전에 반드시 마이그레이션을 먼저 하세요. (&lt;code&gt;Memo&lt;/code&gt; 테이블이 있어야 함)&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;npx prisma migrate dev --name init
npx prisma db seed
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는 Prisma Studio로 직접 넣어도 됩니다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;npx prisma studio
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 테이블을 GUI로 볼 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6) 동작 확인&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;npm run dev&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos/new&lt;/code&gt;에서 메모 추가&lt;/li&gt;
&lt;li&gt;터미널에서 &lt;b&gt;서버 중지 후 다시&lt;/b&gt; &lt;code&gt;npm run dev&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos&lt;/code&gt;에 방금 메모가 &lt;b&gt;남아 있으면&lt;/b&gt; 성공&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전(메모리 배열)이라면 여기서 사라졌을 겁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Action / 페이지는?&lt;/h2&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;&lt;code&gt;createMemo&lt;/code&gt;, &lt;code&gt;updateMemoAction&lt;/code&gt;, &lt;code&gt;deleteMemoAction&lt;/code&gt;은&lt;br /&gt;이미 &lt;code&gt;addMemo&lt;/code&gt; / &lt;code&gt;updateMemo&lt;/code&gt; / &lt;code&gt;deleteMemo&lt;/code&gt;만 호출합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt; 구현만 바뀌었을 뿐입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 좋은 설계입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI &amp;middot; Action &amp;middot; 데이터 레이어를 나누면,&lt;br /&gt;저장소만 바꿔도 화면 코드는 버틸 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;.gitignore&lt;/code&gt; 확인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래가 무시되는지 확인하세요.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;.env
.env*.local
prisma/dev.db
prisma/dev.db-journal
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 파일과 환경 변수는 저장소에 올리지 않는 편이 안전합니다.&lt;br /&gt;팀원/다른 PC에서는 &lt;code&gt;migrate&lt;/code&gt;로 스키마를 다시 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;npx prisma migrate dev
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. &lt;code&gt;migrate&lt;/code&gt;를 안 돌림&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;prisma.memo&lt;/code&gt;가 없거나 테이블이 없어 에러&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. &lt;code&gt;lib/prisma.ts&lt;/code&gt; 없이 매 파일에서 &lt;code&gt;new PrismaClient()&lt;/code&gt;&lt;/b&gt;&lt;br /&gt;&amp;rarr; 개발 중 연결 폭주 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. &lt;code&gt;createdAt&lt;/code&gt;을 Date 그대로 JSX에 뿌림&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;toMemo&lt;/code&gt;로 문자열 변환 권장 (기존 UI와 맞춤)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. &lt;code&gt;.env&lt;/code&gt;의 &lt;code&gt;DATABASE_URL&lt;/code&gt; 오타&lt;/b&gt;&lt;br /&gt;&amp;rarr; DB 파일을 못 찾아 실패&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. 예전에 쓰던 id &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;2&lt;/code&gt;를 하드코딩한 링크&lt;/b&gt;&lt;br /&gt;&amp;rarr; 이제 id는 &lt;code&gt;cuid()&lt;/code&gt;라 긴 문자열입니다. UI의 Link는 &lt;code&gt;memo.id&lt;/code&gt;를 쓰면 문제 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;6. loading이 너무 짧아져서 &amp;ldquo;로딩이 안 보여요&amp;rdquo;&lt;/b&gt;&lt;br /&gt;&amp;rarr; 정상입니다. 원하면 &lt;code&gt;loading.tsx&lt;/code&gt;는 그대로 두고, 느린 네트워크에서만 눈에 띕니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메모리 배열은 학습용, 실서비스는 DB가 필요하다&lt;/li&gt;
&lt;li&gt;Prisma = 스키마 + 타입 안전한 쿼리&lt;/li&gt;
&lt;li&gt;SQLite = 로컬에 파일 하나로 시작하기 좋다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 레이어만 바꾸면&lt;/b&gt; CRUD UI/Action은 재사용된다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;toMemo&lt;/code&gt;로 DB 모델과 화면 타입을 연결한다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Next.js + TS 실전] 3. Vercel에 배포하기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;빌드&amp;middot;환경 변수&lt;/li&gt;
&lt;li&gt;SQLite의 한계와 배포 시 선택지&lt;/li&gt;
&lt;li&gt;메모 앱을 URL로 열어보기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 DB까지 붙였으니, 다음은 &lt;b&gt;다른 사람이 접속할 수 있게&lt;/b&gt; 만드는 단계입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 한 줄: Vercel 같은 서버리스 환경에서는 SQLite 파일 DB가 잘 안 맞을 수 있습니다.&lt;br /&gt;다음 편에서 &amp;ldquo;왜&amp;rdquo;와 대안(예: Postgres)을 같이 정리합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차 (실전)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;메모 수정과 삭제&lt;/li&gt;
&lt;li&gt;Prisma + SQLite로 DB 붙이기 &amp;larr; 지금&lt;/li&gt;
&lt;li&gt;Vercel 배포 (예정)&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;입문&amp;middot;실전 1편과의 연결&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;이전&lt;/th&gt;
&lt;th&gt;이번&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;const memos = [...]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma.memo.findMany&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Date.now()&lt;/code&gt; id&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cuid()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버 재시작 &amp;rarr; 데이터 소멸&lt;/td&gt;
&lt;td&gt;서버 재시작 &amp;rarr; 데이터 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delay()&lt;/code&gt;로 로딩 연습&lt;/td&gt;
&lt;td&gt;실제 I/O&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 이름은 같고, &lt;b&gt;속이 DB로 바뀐 것&lt;/b&gt;이 이번 편의 전부입니다.&lt;/p&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/789</guid>
      <comments>https://aamoos.tistory.com/789#entry789comment</comments>
      <pubDate>Thu, 30 Jul 2026 10:15:15 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS 실전] 1. 메모 수정과 삭제</title>
      <link>https://aamoos.tistory.com/788</link>
      <description>&lt;h1&gt;[Next.js + TS 실전] 1. 메모 수정과 삭제&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문 시리즈(1~8편)에서 &lt;b&gt;추가&lt;/b&gt;까지 했다면, 이번 편은 &lt;b&gt;수정&amp;middot;삭제&lt;/b&gt;로 CRUD를 마칩니다.&lt;br /&gt;목표: &lt;code&gt;updateMemo&lt;/code&gt; / &lt;code&gt;deleteMemo&lt;/code&gt; &amp;middot; Server Action 확장 &amp;middot; 수정 페이지.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;에 수정&amp;middot;삭제 함수 추가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;actions.ts&lt;/code&gt;에 Server Action 2개 추가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos/[id]/edit&lt;/code&gt; 수정 페이지 만들기&lt;/li&gt;
&lt;li&gt;상세 페이지에 수정 링크 &amp;middot; 삭제 버튼 붙이기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7편에서 만든 &lt;b&gt;추가(create)&lt;/b&gt; 흐름을 그대로 확장합니다.&lt;br /&gt;새 개념보다 &lt;b&gt;같은 패턴 반복&lt;/b&gt;이 목표입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CRUD 중 지금 어디까지?&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;상태&lt;/th&gt;
&lt;th&gt;이번 편&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Create&lt;/td&gt;
&lt;td&gt;✅ 7편&lt;/td&gt;
&lt;td&gt;&amp;mdash;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read&lt;/td&gt;
&lt;td&gt;✅ 4~6편&lt;/td&gt;
&lt;td&gt;&amp;mdash;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Create를 이미 Server Action으로 했으니, Update/Delete도 &lt;b&gt;폼 + Server Action&lt;/b&gt;으로 가면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1) 데이터 함수: 수정&amp;middot;삭제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt; 하단에 추가합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export async function updateMemo(
  id: string,
  input: { title: string; content: string }
): Promise&amp;lt;Memo | undefined&amp;gt; {
  await delay(300);

  const index = memos.findIndex((memo) =&amp;gt; memo.id === id);
  if (index === -1) {
    return undefined;
  }

  memos[index] = {
    ...memos[index],
    title: input.title,
    content: input.content,
  };

  return memos[index];
}

export async function deleteMemo(id: string): Promise&amp;lt;boolean&amp;gt; {
  await delay(300);

  const index = memos.findIndex((memo) =&amp;gt; memo.id === id);
  if (index === -1) {
    return false;
  }

  memos.splice(index, 1);
  return true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;updateMemo&lt;/code&gt; &amp;rarr; 못 찾으면 &lt;code&gt;undefined&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deleteMemo&lt;/code&gt; &amp;rarr; 못 찾으면 &lt;code&gt;false&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;는 그대로 두고 &lt;b&gt;제목&amp;middot;내용만&lt;/b&gt; 바꿉니다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) Server Action 확장&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/memos/actions.ts&lt;/code&gt;에 두 함수를 추가합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;&quot;use server&quot;;

import { redirect } from &quot;next/navigation&quot;;
import { revalidatePath } from &quot;next/cache&quot;;
import { addMemo, deleteMemo, updateMemo } from &quot;@/lib/memos&quot;;

export async function createMemo(formData: FormData) {
  const title = String(formData.get(&quot;title&quot;) ?? &quot;&quot;).trim();
  const content = String(formData.get(&quot;content&quot;) ?? &quot;&quot;).trim();

  if (!title || !content) {
    throw new Error(&quot;제목과 내용을 모두 입력하세요&quot;);
  }

  await addMemo({ title, content });

  revalidatePath(&quot;/memos&quot;);
  redirect(&quot;/memos&quot;);
}

export async function updateMemoAction(formData: FormData) {
  const id = String(formData.get(&quot;id&quot;) ?? &quot;&quot;).trim();
  const title = String(formData.get(&quot;title&quot;) ?? &quot;&quot;).trim();
  const content = String(formData.get(&quot;content&quot;) ?? &quot;&quot;).trim();

  if (!id || !title || !content) {
    throw new Error(&quot;제목과 내용을 모두 입력하세요&quot;);
  }

  const updated = await updateMemo(id, { title, content });

  if (!updated) {
    throw new Error(&quot;메모를 찾을 수 없습니다&quot;);
  }

  revalidatePath(&quot;/memos&quot;);
  revalidatePath(`/memos/${id}`);
  redirect(`/memos/${id}`);
}

export async function deleteMemoAction(formData: FormData) {
  const id = String(formData.get(&quot;id&quot;) ?? &quot;&quot;).trim();

  if (!id) {
    throw new Error(&quot;삭제할 메모 id가 없습니다&quot;);
  }

  const deleted = await deleteMemo(id);

  if (!deleted) {
    throw new Error(&quot;메모를 찾을 수 없습니다&quot;);
  }

  revalidatePath(&quot;/memos&quot;);
  redirect(&quot;/memos&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7편과 비교:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;create&lt;/th&gt;
&lt;th&gt;update&lt;/th&gt;
&lt;th&gt;delete&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;입력&lt;/td&gt;
&lt;td&gt;title, content&lt;/td&gt;
&lt;td&gt;id, title, content&lt;/td&gt;
&lt;td&gt;id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;성공 후&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos/[id]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;revalidate&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos&lt;/code&gt;, &lt;code&gt;/memos/[id]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정은 &lt;b&gt;목록 + 상세&lt;/b&gt; 둘 다 갱신하는 게 안전합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3) 수정 페이지: &lt;code&gt;/memos/[id]/edit&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더와 파일을 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;app/memos/[id]/edit/page.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;import { notFound } from &quot;next/navigation&quot;;
import { getMemoById } from &quot;@/lib/memos&quot;;
import { updateMemoAction } from &quot;@/app/memos/actions&quot;;

type EditMemoPageProps = {
  params: Promise&amp;lt;{ id: string }&amp;gt;;
};

export default async function EditMemoPage({ params }: EditMemoPageProps) {
  const { id } = await params;
  const memo = await getMemoById(id);

  if (!memo) {
    notFound();
  }

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;메모 수정&amp;lt;/h1&amp;gt;

      &amp;lt;form action={updateMemoAction}&amp;gt;
        &amp;lt;input type=&quot;hidden&quot; name=&quot;id&quot; value={memo.id} /&amp;gt;

        &amp;lt;p&amp;gt;
          &amp;lt;label&amp;gt;
            제목
            &amp;lt;br /&amp;gt;
            &amp;lt;input
              type=&quot;text&quot;
              name=&quot;title&quot;
              defaultValue={memo.title}
              required
            /&amp;gt;
          &amp;lt;/label&amp;gt;
        &amp;lt;/p&amp;gt;

        &amp;lt;p&amp;gt;
          &amp;lt;label&amp;gt;
            내용
            &amp;lt;br /&amp;gt;
            &amp;lt;textarea
              name=&quot;content&quot;
              rows={5}
              defaultValue={memo.content}
              required
            /&amp;gt;
          &amp;lt;/label&amp;gt;
        &amp;lt;/p&amp;gt;

        &amp;lt;button type=&quot;submit&quot;&amp;gt;저장&amp;lt;/button&amp;gt;
      &amp;lt;/form&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;새 메모&lt;/b&gt;와 거의 같은 폼, &lt;code&gt;defaultValue&lt;/code&gt;로 기존 값 채움&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hidden&lt;/code&gt; input으로 &lt;code&gt;id&lt;/code&gt;를 Action에 전달&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'use client'&lt;/code&gt; 없음 &amp;rarr; 서버 컴포넌트 + Server Action 조합&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/memos/1/edit&lt;/code&gt; 에서 제목을 바꾸고 저장하면 상세 페이지 내용이 바뀌어야 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4) 상세 페이지에 수정&amp;middot;삭제 UI&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/memos/[id]/page.tsx&lt;/code&gt;를 아래처럼 확장합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import Link from &quot;next/link&quot;;
import { notFound } from &quot;next/navigation&quot;;
import { deleteMemoAction } from &quot;@/app/memos/actions&quot;;
import { getMemoById } from &quot;@/lib/memos&quot;;

type MemoDetailPageProps = {
  params: Promise&amp;lt;{ id: string }&amp;gt;;
};

export default async function MemoDetailPage({ params }: MemoDetailPageProps) {
  const { id } = await params;
  const memo = await getMemoById(id);

  if (!memo) {
    notFound();
  }

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;{memo.title}&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;{memo.content}&amp;lt;/p&amp;gt;
      &amp;lt;time dateTime={memo.createdAt}&amp;gt;{memo.createdAt}&amp;lt;/time&amp;gt;

      &amp;lt;p&amp;gt;
        &amp;lt;Link href={`/memos/${memo.id}/edit`}&amp;gt;수정&amp;lt;/Link&amp;gt;
      &amp;lt;/p&amp;gt;

      &amp;lt;form action={deleteMemoAction}&amp;gt;
        &amp;lt;input type=&quot;hidden&quot; name=&quot;id&quot; value={memo.id} /&amp;gt;
        &amp;lt;button type=&quot;submit&quot;&amp;gt;삭제&amp;lt;/button&amp;gt;
      &amp;lt;/form&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;수정&lt;/b&gt; &amp;rarr; Link로 edit 페이지 이동&lt;/li&gt;
&lt;li&gt;&lt;b&gt;삭제&lt;/b&gt; &amp;rarr; 별도 &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; + &lt;code&gt;deleteMemoAction&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제는 &lt;code&gt;onClick&lt;/code&gt; + &lt;code&gt;fetch&lt;/code&gt;가 아니라 &lt;b&gt;폼 제출&lt;/b&gt;로 처리합니다.&lt;br /&gt;7편 추가와 같은 Server Action 패턴입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동작 흐름&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;수정&lt;/h3&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;/memos/1/edit 에서 저장
    &amp;darr;
updateMemoAction
    &amp;darr;
updateMemo (배열에서 해당 항목 교체)
    &amp;darr;
revalidatePath(&quot;/memos&quot;, &quot;/memos/1&quot;)
    &amp;darr;
redirect(&quot;/memos/1&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;삭제&lt;/h3&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;/memos/1 에서 [삭제] 클릭
    &amp;darr;
deleteMemoAction
    &amp;darr;
deleteMemo (배열에서 splice)
    &amp;darr;
revalidatePath(&quot;/memos&quot;)
    &amp;darr;
redirect(&quot;/memos&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 삭제도 form + action인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클릭 핸들러로도 할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;&quot;use client&quot;;
// onClick &amp;rarr; fetch('/api/...') ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 지금 앱은 &lt;b&gt;Server Action 중심&lt;/b&gt;입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;API 라우트를 따로 안 만들어도 됨&lt;/li&gt;
&lt;li&gt;create/update/delete가 &lt;b&gt;같은 파일&amp;middot;같은 패턴&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;서버에서만 데이터 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 &amp;ldquo;삭제 전 확인(confirm)&amp;rdquo;을 넣을 때 클라이언트 버튼이 필요해질 수 있습니다.&lt;br /&gt;입문+실전 1편에서는 &lt;b&gt;가장 단순한 form 삭제&lt;/b&gt;로 충분합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 수정 폼에 &lt;code&gt;id&lt;/code&gt; hidden을 빼먹음&lt;/b&gt;&lt;br /&gt;&amp;rarr; Action에서 어떤 메모를 고칠지 모릅니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. &lt;code&gt;value&lt;/code&gt; 대신 controlled input을 서버에서 쓰려 함&lt;/b&gt;&lt;br /&gt;&amp;rarr; 서버 폼은 &lt;code&gt;defaultValue&lt;/code&gt;가 맞습니다 (&lt;code&gt;value&lt;/code&gt; + onChange는 Client)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. update 후 &lt;code&gt;revalidatePath('/memos/1')&lt;/code&gt;만 하고 목록은 안 함&lt;/b&gt;&lt;br /&gt;&amp;rarr; 목록 카드 제목이 옛날로 남을 수 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. &lt;code&gt;action={deleteMemoAction()}&lt;/code&gt; 처럼 호출&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;action={deleteMemoAction}&lt;/code&gt; (함수 참조)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. 삭제 후 상세 URL에 그대로 머무름&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;redirect('/memos')&lt;/code&gt;로 보내는 게 자연스럽습니다&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CRUD는 Create 패턴을 Update/Delete로 확장하면 된다&lt;/li&gt;
&lt;li&gt;수정&amp;middot;삭제도 Server Action + &lt;code&gt;FormData&lt;/code&gt; + hidden &lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;수정 페이지는 &lt;code&gt;/memos/[id]/edit&lt;/code&gt; + &lt;code&gt;defaultValue&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;revalidatePath&lt;/code&gt;는 바뀌는 경로를 모두 호출&lt;/li&gt;
&lt;li&gt;지금은 메모리 배열, 나중에 DB로 함수만 교체&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Next.js + TS 실전] 2. Prisma + SQLite로 DB 붙이기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 재시작해도 메모가 남게&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;의 배열을 DB 쿼리로 교체&lt;/li&gt;
&lt;li&gt;CRUD를 &amp;ldquo;진짜 저장&amp;rdquo;으로 바꾸기&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차 (실전)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;메모 수정과 삭제 &amp;larr; 지금&lt;/li&gt;
&lt;li&gt;Prisma + SQLite로 DB 붙이기 (예정)&lt;/li&gt;
&lt;li&gt;Vercel 배포 (예정)&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;입문 시리즈와의 연결&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;입문&lt;/th&gt;
&lt;th&gt;실전 1편&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;addMemo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;updateMemo&lt;/code&gt;, &lt;code&gt;deleteMemo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;createMemo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;updateMemoAction&lt;/code&gt;, &lt;code&gt;deleteMemoAction&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/memos/new&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos/[id]/edit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;추가 후 목록&lt;/td&gt;
&lt;td&gt;수정 후 상세, 삭제 후 목록&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문 7편을 이해했다면, 이번 편은 &lt;b&gt;복붙&amp;middot;수정 수준&lt;/b&gt;으로 따라올 수 있습니다.&lt;/p&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/788</guid>
      <comments>https://aamoos.tistory.com/788#entry788comment</comments>
      <pubDate>Thu, 30 Jul 2026 09:38:15 +0900</pubDate>
    </item>
    <item>
      <title># 면접 CS 정리 Day 2 (풀스택 / Java&amp;middot;Spring&amp;middot;React)</title>
      <link>https://aamoos.tistory.com/787</link>
      <description>&lt;h1&gt;면접 CS 정리 Day 2 (풀스택 / Java&amp;middot;Spring&amp;middot;React)&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q1. Spring Bean 스코프&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;Spring Bean 기본 스코프는 뭐고, &lt;code&gt;singleton&lt;/code&gt;이랑 &lt;code&gt;prototype&lt;/code&gt; 차이는?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본 스코프는 &lt;b&gt;&lt;code&gt;singleton&lt;/code&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;singleton&lt;/code&gt;: 컨테이너당 Bean &lt;b&gt;1개&lt;/b&gt; 공유&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prototype&lt;/code&gt;: &lt;b&gt;가져올 때마다&lt;/b&gt; 새 객체 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;singleton Service에 요청별 상태(&lt;code&gt;currentUserId&lt;/code&gt; 필드 등)를 넣으면&lt;br /&gt;여러 요청이 같은 인스턴스를 공유해서 위험하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q2. Checked vs Unchecked Exception&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;&lt;code&gt;Checked Exception&lt;/code&gt;이랑 &lt;code&gt;Unchecked Exception&lt;/code&gt; 차이?&lt;br /&gt;&lt;code&gt;@Transactional&lt;/code&gt; 기본 롤백이랑 어떻게 연결되는지?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Checked&lt;/b&gt;: &lt;code&gt;IOException&lt;/code&gt;, &lt;code&gt;SQLException&lt;/code&gt; 등 &amp;mdash; 컴파일러가 처리 강제&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Unchecked&lt;/b&gt;: &lt;code&gt;RuntimeException&lt;/code&gt; 계열 &amp;mdash; &lt;code&gt;IllegalArgumentException&lt;/code&gt;, &lt;code&gt;NPE&lt;/code&gt;, 보통의 &lt;code&gt;BusinessException&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Transactional&lt;/code&gt; 기본:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;unchecked&lt;/b&gt; &amp;rarr; 롤백&lt;/li&gt;
&lt;li&gt;&lt;b&gt;checked&lt;/b&gt; &amp;rarr; 롤백 안 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;checked도 롤백하려면&lt;br /&gt;&lt;code&gt;@Transactional(rollbackFor = Exception.class)&lt;/code&gt;&lt;br /&gt;(&lt;code&gt;@Rollback&lt;/code&gt; 아님)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q3. MyBatis &lt;code&gt;#{}&lt;/code&gt; vs &lt;code&gt;${}&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;MyBatis에서 &lt;code&gt;#{}&lt;/code&gt;이랑 &lt;code&gt;${}&lt;/code&gt; 차이? 왜 기본은 &lt;code&gt;#{}&lt;/code&gt;를 쓰는지?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;#{}&lt;/code&gt;: PreparedStatement &lt;b&gt;파라미터 바인딩&lt;/b&gt; &amp;rarr; SQL Injection 방어에 유리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;${}&lt;/code&gt;: 문자열 &lt;b&gt;그대로 치환&lt;/b&gt; &amp;rarr; 위험&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본은 &lt;code&gt;#{}&lt;/code&gt;.&lt;br /&gt;&lt;code&gt;${}&lt;/code&gt;는 테이블명&amp;middot;컬럼명&amp;middot;ORDER BY처럼 바인딩이 안 될 때만,&lt;br /&gt;사용자 입력을 직접 넣지 말고 화이트리스트로 제한한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q4. INNER JOIN vs LEFT JOIN&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;&lt;code&gt;INNER JOIN&lt;/code&gt;이랑 &lt;code&gt;LEFT JOIN&lt;/code&gt; 차이? 매칭 안 되는 행 기준으로.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;INNER JOIN&lt;/code&gt;: 양쪽 테이블에서 &lt;b&gt;매칭되는 행만&lt;/b&gt; (교집합)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LEFT JOIN&lt;/code&gt;: &lt;b&gt;왼쪽 테이블 전부&lt;/b&gt; + 오른쪽이 없으면 &lt;code&gt;NULL&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q5. accessToken / refreshToken&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;JWT 로그인에서 accessToken이랑 refreshToken을 왜 나누는지?&lt;br /&gt;access는 짧게, refresh는 길게 두는 이유는?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;br /&gt;JWT는 탈취되면 만료 전까지 쓰일 수 있다.&lt;br /&gt;그래서 API에 쓰는 &lt;b&gt;accessToken은 짧게&lt;/b&gt; 두어 피해 구간을 줄이고,&lt;br /&gt;만료 시 &lt;b&gt;refreshToken으로 access를 재발급&lt;/b&gt;한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q6. useEffect 의존성 배열&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;React &lt;code&gt;useEffect&lt;/code&gt; 의존성 배열이 &lt;code&gt;[]&lt;/code&gt; / 없음 / &lt;code&gt;[id]&lt;/code&gt;일 때 각각 언제 실행되나?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;[]&lt;/code&gt; &amp;rarr; &lt;b&gt;마운트 시 1번&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;없음 &amp;rarr; &lt;b&gt;매 렌더마다&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;[id]&lt;/code&gt; &amp;rarr; 마운트 + &lt;b&gt;&lt;code&gt;id&lt;/code&gt; 변경 시&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q7. DB 중복 신청 방지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;같은 휴가/출퇴근이 동시에 두 번 신청되는 걸 막으려면&lt;br /&gt;앱 클릭 방지 말고 DB에서 쓸 수 있는 방법은?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;br /&gt;&lt;code&gt;(user_id, 날짜)&lt;/code&gt; 같은 컬럼 조합에 &lt;b&gt;UNIQUE 제약&lt;/b&gt;을 건다.&lt;br /&gt;동시 요청이 와도 하나는 중복 키 에러로 막힌다.&lt;br /&gt;(프론트 연속클릭 방지만으론 부족 &amp;mdash; DB가 최종 방어선)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;오늘 한 줄 암기&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Bean 기본 스코프 = &lt;code&gt;singleton&lt;/code&gt; / &lt;code&gt;prototype&lt;/code&gt; = 매번 새로&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@Transactional&lt;/code&gt; 기본 롤백 = unchecked만&lt;/li&gt;
&lt;li&gt;MyBatis &lt;code&gt;#{}&lt;/code&gt; 바인딩 / &lt;code&gt;${}&lt;/code&gt; 치환&lt;/li&gt;
&lt;li&gt;access 짧게 + refresh로 재발급&lt;/li&gt;
&lt;li&gt;중복 방지 = UNIQUE 제약&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>기타/CS 질문</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/787</guid>
      <comments>https://aamoos.tistory.com/787#entry787comment</comments>
      <pubDate>Thu, 30 Jul 2026 09:25:50 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS] 8. 정리 + 다음 학습 로드맵</title>
      <link>https://aamoos.tistory.com/786</link>
      <description>&lt;h1&gt;[Next.js + TS] 8. 정리 + 다음 학습 로드맵&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1~7편에서 작은 &lt;b&gt;메모 앱&lt;/b&gt;을 만들며 Next.js + TypeScript의 핵심 흐름을 따라 왔습니다.&lt;br /&gt;마지막 편은 &lt;b&gt;복습&lt;/b&gt;, &lt;b&gt;지금 앱의 한계&lt;/b&gt;, &lt;b&gt;다음에 공부할 순서&lt;/b&gt;를 정리합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈를 마치면서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 React만으로도 화면은 만들 수 있다는 이야기에서 시작했습니다.&lt;br /&gt;끝날 즈음에는 이런 흐름을 직접 만들었을 겁니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;/              &amp;rarr; 소개
/memos         &amp;rarr; 목록 (async fetch + loading/error)
/memos/new     &amp;rarr; 작성 (Server Action)
/memos/[id]    &amp;rarr; 상세&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React 경험이 있는 사람이 &lt;b&gt;Next.js App Router + TypeScript&lt;/b&gt;로 넘어올 때 필요한 것만 골라 다뤘습니다.&lt;br /&gt;고급 패턴 전부를 다룬 시리즈는 아닙니다.&lt;br /&gt;대신 &amp;ldquo;실제 앱을 한 바퀴 도는 감각&amp;rdquo;을 목표로 했습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1~7편 한눈에 복습&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;편&lt;/th&gt;
&lt;th&gt;핵심 한 줄&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Next = React 위의 앱 프레임워크, TS = 타입 계약, App Router = 폴더가 URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;create-next-app&lt;/code&gt;, &lt;code&gt;app/page.tsx&lt;/code&gt;가 곧 홈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Memo&lt;/code&gt; 타입 + &lt;code&gt;MemoCard&lt;/code&gt; props&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/memos&lt;/code&gt;, &lt;code&gt;/memos/[id]&lt;/code&gt;, &lt;code&gt;layout&lt;/code&gt;에 공통 내비&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;기본은 Server, hooks/이벤트만 Client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;async&lt;/code&gt; 페이지, &lt;code&gt;loading.tsx&lt;/code&gt;, &lt;code&gt;error.tsx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Server Action + &lt;code&gt;FormData&lt;/code&gt; + &lt;code&gt;revalidatePath&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 편의 연결고리는 이겁니다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;타입(Memo)
  &amp;darr;
컴포넌트(MemoCard)
  &amp;darr;
라우팅(/memos, /memos/[id])
  &amp;darr;
서버/클라이언트 분리(LikeButton)
  &amp;darr;
데이터 가져오기(getMemos)
  &amp;darr;
데이터 쓰기(createMemo)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번에 외우려 하지 말고, &lt;b&gt;URL 하나가 어떤 파일&amp;middot;어떤 데이터&amp;middot;어떤 컴포넌트로 이어지는지&lt;/b&gt;만 다시 따라가 보세요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;지금 만든 앱 구조&lt;/h2&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;memo-app/
  app/
    layout.tsx          &amp;rarr; 공통 내비
    page.tsx            &amp;rarr; /
    memos/
      page.tsx          &amp;rarr; 목록
      loading.tsx
      error.tsx
      actions.ts        &amp;rarr; Server Action
      new/page.tsx      &amp;rarr; 작성
      [id]/page.tsx     &amp;rarr; 상세
  components/
    MemoCard.tsx
    LikeButton.tsx
  lib/
    memos.ts            &amp;rarr; 데이터 함수
  types/
    memo.ts&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 App Router 앱의 &lt;b&gt;작은 정석&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;app/&lt;/code&gt; = 라우트 + 페이지&lt;/li&gt;
&lt;li&gt;&lt;code&gt;components/&lt;/code&gt; = UI 조각&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/&lt;/code&gt; = 데이터/비즈니스 로직&lt;/li&gt;
&lt;li&gt;&lt;code&gt;types/&lt;/code&gt; = 타입 정의&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스스로 점검하는 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래를 설명할 수 있으면 1~7편은 제대로 따라온 겁니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Next.js / App Router&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;app/page.tsx&lt;/code&gt;와 URL &lt;code&gt;/&lt;/code&gt;의 관계를 설명할 수 있다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;[id]&lt;/code&gt; 폴더가 동적 라우트인 이유를 안다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;layout.tsx&lt;/code&gt;와 &lt;code&gt;page.tsx&lt;/code&gt; 차이를 안다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;Link&lt;/code&gt;를 쓰는 이유를 안다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TypeScript&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;Memo&lt;/code&gt; 타입이 왜 필요한지 안다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 컴포넌트 props에 타입을 붙일 수 있다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;Promise&amp;lt;Memo[]&amp;gt;&lt;/code&gt; 같은 async 반환 타입의 의미를 안다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Server / Client&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; Server Component가 기본값인 이유를 안다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;'use client'&lt;/code&gt;가 필요한 경우를 2가지 이상 말할 수 있다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 페이지 전체가 아니라 버튼만 Client로 분리할 수 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;useEffect&lt;/code&gt; fetch 대신 서버에서 &lt;code&gt;await&lt;/code&gt;하는 이유를 안다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;loading.tsx&lt;/code&gt;가 언제 보이는지 안다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;error.tsx&lt;/code&gt;가 왜 &lt;code&gt;&quot;use client&quot;&lt;/code&gt;인지 대략 안다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Server Action&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;&quot;use server&quot;&lt;/code&gt;의 역할을 안다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;FormData&lt;/code&gt;와 input &lt;code&gt;name&lt;/code&gt;의 관계를 안다&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;revalidatePath&lt;/code&gt;와 &lt;code&gt;redirect&lt;/code&gt;가 각각 뭘 하는지 안다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나라도 막히면 해당 편만 다시 보면 됩니다.&lt;br /&gt;처음부터 다시 읽을 필요는 없습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;지금 앱에 아직 없는 것 (정상)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시리즈 앱은 &lt;b&gt;학습용&lt;/b&gt;입니다.&lt;br /&gt;실서비스와 비교하면 아래가 비어 있습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;없는 것&lt;/th&gt;
&lt;th&gt;왜 아직 안 했나&lt;/th&gt;
&lt;th&gt;다음에&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DB&lt;/td&gt;
&lt;td&gt;메모리 배열로 흐름만 연습&lt;/td&gt;
&lt;td&gt;Prisma, Drizzle, Supabase 등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인증&lt;/td&gt;
&lt;td&gt;범위 밖&lt;/td&gt;
&lt;td&gt;NextAuth, Clerk, Supabase Auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;수정/삭제&lt;/td&gt;
&lt;td&gt;추가만 다룸&lt;/td&gt;
&lt;td&gt;Server Action 확장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;폼 에러 UI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;throw&lt;/code&gt;만 소개&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useActionState&lt;/code&gt;, zod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스타일링&lt;/td&gt;
&lt;td&gt;최소&lt;/td&gt;
&lt;td&gt;Tailwind, CSS Module&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;배포&lt;/td&gt;
&lt;td&gt;로컬만&lt;/td&gt;
&lt;td&gt;Vercel 등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;테스트&lt;/td&gt;
&lt;td&gt;범위 밖&lt;/td&gt;
&lt;td&gt;Vitest, Playwright&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;이게 없어서 실패&amp;rdquo;가 아니라, &lt;b&gt;의도적으로 빼 둔 것&lt;/b&gt;입니다.&lt;br /&gt;한 번에 다 넣으면 Next의 핵심이 묻힙니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;React만 쓸 때와 비교 (최종 정리)&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;주제&lt;/th&gt;
&lt;th&gt;React만&lt;/th&gt;
&lt;th&gt;Next App Router&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;라우팅&lt;/td&gt;
&lt;td&gt;react-router 설정&lt;/td&gt;
&lt;td&gt;폴더 = URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;첫 데이터&lt;/td&gt;
&lt;td&gt;클라이언트 fetch 흔함&lt;/td&gt;
&lt;td&gt;서버 &lt;code&gt;await&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인터랙션&lt;/td&gt;
&lt;td&gt;전부 클라이언트&lt;/td&gt;
&lt;td&gt;필요한 조각만 Client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;폼 전송&lt;/td&gt;
&lt;td&gt;API 라우트 + fetch&lt;/td&gt;
&lt;td&gt;Server Action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;공통 UI&lt;/td&gt;
&lt;td&gt;Layout 컴포넌트 직접 구성&lt;/td&gt;
&lt;td&gt;&lt;code&gt;layout.tsx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React 지식은 &lt;b&gt;버리는 게 아니라 그대로 씁니다.&lt;/b&gt;&lt;br /&gt;바뀐 건 &amp;ldquo;어디서 실행되고, 어떤 파일이 페이지인지&amp;rdquo; 규칙입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 학습 로드맵 (추천 순서)&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;메모 수정/삭제&lt;/b&gt; Server Action 추가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;폼 검증&lt;/b&gt; &amp;mdash; 빈 값 말고 필드별 에러 메시지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Tailwind&lt;/b&gt;로 목록&amp;middot;카드 UI 정리 (이미 템플릿에 있으면 활용)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; &amp;ldquo;읽기 + 쓰기&amp;rdquo;가 있는 CRUD 입문&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계: 데이터 영속화&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;4&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;SQLite + Prisma&lt;/b&gt; 또는 &lt;b&gt;Supabase&lt;/b&gt;로 DB 연결&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;의 메모리 배열을 DB 쿼리로 교체&lt;/li&gt;
&lt;li&gt;서버 재시작해도 데이터가 남게 만들기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; &amp;ldquo;장난감 앱&amp;rdquo;에서 &amp;ldquo;저장되는 앱&amp;rdquo;으로&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3단계: 인증과 권한&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;7&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;로그인 붙이기&lt;/li&gt;
&lt;li&gt;&amp;ldquo;내 메모만 보기&amp;rdquo; 같은 간단한 권한&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 혼자 쓰는 앱 &amp;rarr; 사용자별 앱&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4단계: 배포와 운영&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;9&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Vercel&lt;/b&gt;에 배포&lt;/li&gt;
&lt;li&gt;환경 변수 (&lt;code&gt;.env&lt;/code&gt;) 다루기&lt;/li&gt;
&lt;li&gt;빌드/프리뷰/프로덕션 차이 이해&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 로컬에서 끝나는 프로젝트 &amp;rarr; 다른 사람도 접속 가능&lt;/p&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;Middleware, Route Handlers (&lt;code&gt;app/api/...&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;캐싱 전략 (&lt;code&gt;revalidate&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt; 옵션)&lt;/li&gt;
&lt;li&gt;Parallel Routes, Intercepting Routes&lt;/li&gt;
&lt;li&gt;React Query와 Server Component 조합&lt;/li&gt;
&lt;li&gt;E2E 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한꺼번에 X. &lt;b&gt;2단계 DB 붙이기&lt;/b&gt;까지 가면 실무 감각이 확 올라갑니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 묻는 &amp;ldquo;다음에 뭐 보면 돼요?&amp;rdquo;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q. 공식 문서부터 볼까요?&lt;/b&gt;&lt;br /&gt;&amp;rarr; 네. &lt;a href=&quot;https://nextjs.org/docs&quot;&gt;Next.js Docs&lt;/a&gt;의 App Router 섹션을 이 시리즈와 병행하면 좋습니다.&lt;br /&gt;이미 만든 앱 파일 이름과 문서 용어가 1:1로 맞을 겁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q. TypeScript를 더 깊게?&lt;/b&gt;&lt;br /&gt;&amp;rarr; 지금은 &lt;code&gt;type&lt;/code&gt;, props, &lt;code&gt;Promise&amp;lt;T&amp;gt;&lt;/code&gt;면 충분합니다.&lt;br /&gt;필요해질 때 &lt;code&gt;Pick&lt;/code&gt;, &lt;code&gt;Omit&lt;/code&gt;, &lt;code&gt;zod&lt;/code&gt; 스키마를 보면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q. Pages Router도 배워야 해요?&lt;/b&gt;&lt;br /&gt;&amp;rarr; 새 프로젝트면 App Router만으로 충분합니다.&lt;br /&gt;레거시 코드를 읽을 일이 생기면 그때 비교하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q. 포트폴리오로 써도 돼요?&lt;/b&gt;&lt;br /&gt;&amp;rarr; DB + 배포 + 수정/삭제까지 붙이면 &amp;ldquo;Next 입문&amp;rdquo; 이상으로 보입니다.&lt;br /&gt;지금 상태는 &amp;ldquo;학습 완주&amp;rdquo; 증거로는 좋고, 포트폴리오는 1~2단계 더 가면 좋습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 시리즈에서 가져갈 것 (마지막)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Next.js는 React를 대체하지 않는다. &lt;b&gt;앱 규칙&lt;/b&gt;을 더한다.&lt;/li&gt;
&lt;li&gt;TypeScript는 &lt;b&gt;데이터&amp;middot;props&amp;middot;함수&lt;/b&gt;의 계약이다.&lt;/li&gt;
&lt;li&gt;App Router는 &lt;b&gt;폴더&amp;middot;page&amp;middot;layout&lt;/b&gt;으로 URL과 화면을 만든다.&lt;/li&gt;
&lt;li&gt;Server Component가 기본이고, Client는 &lt;b&gt;꼭 필요할 때만&lt;/b&gt; 쓴다.&lt;/li&gt;
&lt;li&gt;데이터는 서버에서 &lt;code&gt;await&lt;/code&gt;하고, 폼은 Server Action으로 처리할 수 있다.&lt;/li&gt;
&lt;li&gt;작은 앱 하나를 끝까지 밀어보는 게, 예제 조각 10개보다 낫다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 전체 목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 Next.js + TypeScript인가&lt;/li&gt;
&lt;li&gt;프로젝트 만들고 폴더 구조 이해하기&lt;/li&gt;
&lt;li&gt;TypeScript로 컴포넌트 타입 잡기&lt;/li&gt;
&lt;li&gt;라우팅과 레이아웃&lt;/li&gt;
&lt;li&gt;Server Component vs Client Component&lt;/li&gt;
&lt;li&gt;데이터 가져오기&lt;/li&gt;
&lt;li&gt;폼과 서버 액션 입문&lt;/li&gt;
&lt;li&gt;정리 + 다음 학습 로드맵 &amp;larr; 지금&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8편까지 오면 &amp;ldquo;Next.js가 뭔지 들어 본&amp;rdquo; 수준이 아니라,&lt;br /&gt;&lt;b&gt;직접 만든 앱 하나&lt;/b&gt;가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 새 글을 읽기보다,&lt;br /&gt;&lt;b&gt;메모 수정&amp;middot;DB&amp;middot;배포&lt;/b&gt; 중 하나를 골라 손으로 붙여 보는 게 가장 빠릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시리즈가 도움이 됐다면 댓글로&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 편이 제일 헷갈렸는지&lt;/li&gt;
&lt;li&gt;다음에 다뤄 줬으면 하는 주제&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;남겨 주세요.&lt;br /&gt;같은 메모 앱을 이어서 확장하는 후속 시리즈도 이어갈 수 있습니다.&lt;/p&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/786</guid>
      <comments>https://aamoos.tistory.com/786#entry786comment</comments>
      <pubDate>Wed, 29 Jul 2026 16:04:03 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS] 7. 폼과 서버 액션 입문</title>
      <link>https://aamoos.tistory.com/785</link>
      <description>&lt;h1&gt;[Next.js + TS] 7. 폼과 서버 액션 입문&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6편까지는 메모를 &lt;b&gt;읽기만&lt;/b&gt; 했습니다.&lt;br /&gt;이번 편은 폼으로 메모를 &lt;b&gt;추가&lt;/b&gt;합니다.&lt;br /&gt;목표: Server Action &amp;middot; &lt;code&gt;FormData&lt;/code&gt; &amp;middot; &lt;code&gt;revalidatePath&lt;/code&gt;로 목록 갱신.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Server Action이 뭔지 짧게 이해하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;createMemo&lt;/code&gt; 액션 만들기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos/new&lt;/code&gt; 작성 폼 페이지 만들기&lt;/li&gt;
&lt;li&gt;저장 후 &lt;code&gt;/memos&lt;/code&gt; 목록이 갱신되는지 확인하기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB는 아직 없습니다.&lt;br /&gt;메모리 배열에 넣고, &lt;b&gt;새로고침 없이 목록이 바뀌는 흐름&lt;/b&gt;만 익힙니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 Server Action인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에 흔했던 흐름:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;클라이언트에서 &lt;code&gt;fetch('/api/memos', { method: 'POST' ... })&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;API 라우트에서 저장&lt;/li&gt;
&lt;li&gt;다시 목록 fetch&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Router에서는 더 단순한 길이 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폼이 서버 함수를 &lt;b&gt;직접&lt;/b&gt; 호출한다 &amp;rarr; &lt;b&gt;Server Action&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;별도의 API 라우트를 꼭 안 만들어도 됨&lt;/li&gt;
&lt;li&gt;서버에서만 실행됨 (비밀키/DB 접근에 유리)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;form action={createMemo}&amp;gt;&lt;/code&gt; 로 연결 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전부 Action으로 대체하라는 뜻은 아닙니다.&lt;br /&gt;&lt;b&gt;폼 제출&amp;middot;간단한 mutation&lt;/b&gt;부터 배우기 좋습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1) 데이터 레이어에 &amp;ldquo;추가&amp;rdquo; 붙이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;에 생성 함수를 추가합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;import type { Memo } from &quot;@/types/memo&quot;;

const memos: Memo[] = [
  {
    id: &quot;1&quot;,
    title: &quot;Next.js 시작&quot;,
    content: &quot;App Router로 프로젝트 만들었다.&quot;,
    createdAt: &quot;2026-07-29&quot;,
  },
  {
    id: &quot;2&quot;,
    title: &quot;TypeScript props&quot;,
    content: &quot;MemoCard에 타입을 붙였다.&quot;,
    createdAt: &quot;2026-07-29&quot;,
  },
];

function delay(ms: number) {
  return new Promise((resolve) =&amp;gt; setTimeout(resolve, ms));
}

export async function getMemos(): Promise&amp;lt;Memo[]&amp;gt; {
  await delay(800);
  return memos;
}

export async function getMemoById(id: string): Promise&amp;lt;Memo | undefined&amp;gt; {
  await delay(500);
  return memos.find((memo) =&amp;gt; memo.id === id);
}

export async function addMemo(input: {
  title: string;
  content: string;
}): Promise&amp;lt;Memo&amp;gt; {
  await delay(300);

  const memo: Memo = {
    id: String(Date.now()),
    title: input.title,
    content: input.content,
    createdAt: new Date().toISOString().slice(0, 10),
  };

  memos.unshift(memo); // 맨 앞에 추가
  return memo;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;는 실습용으로 &lt;code&gt;Date.now()&lt;/code&gt; 사용&lt;/li&gt;
&lt;li&gt;서버를 재시작하면 배열은 초기값으로 돌아갑니다 (메모리라서 정상)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) Server Action 파일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;app/memos/actions.ts&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;&quot;use server&quot;;

import { redirect } from &quot;next/navigation&quot;;
import { revalidatePath } from &quot;next/cache&quot;;
import { addMemo } from &quot;@/lib/memos&quot;;

export async function createMemo(formData: FormData) {
  const title = String(formData.get(&quot;title&quot;) ?? &quot;&quot;).trim();
  const content = String(formData.get(&quot;content&quot;) ?? &quot;&quot;).trim();

  if (!title || !content) {
    throw new Error(&quot;제목과 내용을 모두 입력하세요&quot;);
  }

  await addMemo({ title, content });

  revalidatePath(&quot;/memos&quot;);
  redirect(&quot;/memos&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어볼 것:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;코드&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&quot;use server&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;이 파일의 함수는 서버에서 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FormData&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;이 보낸 값 묶음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;formData.get(&quot;title&quot;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;name=&quot;title&quot;&lt;/code&gt; 입력값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;revalidatePath(&quot;/memos&quot;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;목록 캐시/화면을 다시 준비&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redirect(&quot;/memos&quot;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;저장 후 목록으로 이동&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&quot;use server&quot;&lt;/code&gt;는 파일 맨 위에 둡니다.&lt;br /&gt;(함수 단위로 쓸 수도 있지만, 입문에서는 파일 단위가 단순합니다.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3) 작성 페이지: &lt;code&gt;/memos/new&lt;/code&gt;&lt;/h2&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;app/memos/new/page.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;import { createMemo } from &quot;@/app/memos/actions&quot;;

export default function NewMemoPage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;새 메모&amp;lt;/h1&amp;gt;

      &amp;lt;form action={createMemo}&amp;gt;
        &amp;lt;p&amp;gt;
          &amp;lt;label&amp;gt;
            제목
            &amp;lt;br /&amp;gt;
            &amp;lt;input type=&quot;text&quot; name=&quot;title&quot; required /&amp;gt;
          &amp;lt;/label&amp;gt;
        &amp;lt;/p&amp;gt;

        &amp;lt;p&amp;gt;
          &amp;lt;label&amp;gt;
            내용
            &amp;lt;br /&amp;gt;
            &amp;lt;textarea name=&quot;content&quot; rows={5} required /&amp;gt;
          &amp;lt;/label&amp;gt;
        &amp;lt;/p&amp;gt;

        &amp;lt;button type=&quot;submit&quot;&amp;gt;저장&amp;lt;/button&amp;gt;
      &amp;lt;/form&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;'use client'&lt;/code&gt; 없음 &amp;rarr; 서버 컴포넌트 페이지&lt;/li&gt;
&lt;li&gt;&lt;code&gt;action={createMemo}&lt;/code&gt; 로 서버 함수 연결&lt;/li&gt;
&lt;li&gt;input의 &lt;b&gt;&lt;code&gt;name&lt;/code&gt;&lt;/b&gt; 이 &lt;code&gt;FormData&lt;/code&gt; 키와 같아야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;http://localhost:3000/memos/new&lt;/code&gt; 에서 작성 &amp;rarr; 저장하면 &lt;code&gt;/memos&lt;/code&gt;로 이동하고 새 메모가 보여야 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4) 목록에서 &amp;ldquo;새 메모&amp;rdquo; 링크&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/memos/page.tsx&lt;/code&gt;에 링크를 하나 추가합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import Link from &quot;next/link&quot;;
import MemoCard from &quot;@/components/MemoCard&quot;;
import { getMemos } from &quot;@/lib/memos&quot;;

export default async function MemosPage() {
  const memos = await getMemos();

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;메모 목록&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;
        &amp;lt;Link href=&quot;/memos/new&quot;&amp;gt;새 메모 작성&amp;lt;/Link&amp;gt;
      &amp;lt;/p&amp;gt;

      &amp;lt;section&amp;gt;
        {memos.map((memo) =&amp;gt; (
          &amp;lt;MemoCard key={memo.id} memo={memo} /&amp;gt;
        ))}
      &amp;lt;/section&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하면 &lt;code&gt;layout&lt;/code&gt; 내비에도 &lt;code&gt;/memos/new&lt;/code&gt;를 넣어도 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동작 흐름 (한눈에)&lt;/h2&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;/memos/new 에서 제출
    &amp;darr;
createMemo (Server Action)
    &amp;darr;
addMemo (메모리 배열에 push)
    &amp;darr;
revalidatePath(&quot;/memos&quot;)
    &amp;darr;
redirect(&quot;/memos&quot;)
    &amp;darr;
목록이 새 데이터로 다시 렌더&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 &lt;code&gt;useState&lt;/code&gt;로 목록을 직접 고치지 않아도 됩니다.&lt;br /&gt;&lt;b&gt;서버가 데이터를 바꾸고, 해당 경로를 다시 그리게&lt;/b&gt; 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최소 검증만 넣은 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은:&lt;/p&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;if (!title || !content) {
  throw new Error(&quot;...&quot;);
}&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;필드별 에러 메시지 표시&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useActionState&lt;/code&gt; 같은 패턴&lt;/li&gt;
&lt;li&gt;zod 등 스키마 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;으로 확장합니다.&lt;br /&gt;입문 편에서는 &lt;b&gt;폼 &amp;rarr; 서버 &amp;rarr; 목록 갱신&lt;/b&gt; 한 바퀴가 목표입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. input에 &lt;code&gt;name&lt;/code&gt;을 안 줌&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;formData.get(&quot;title&quot;)&lt;/code&gt;이 비어 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. &lt;code&gt;&quot;use server&quot;&lt;/code&gt;를 클라이언트 컴포넌트 한가운데에 무작정 붙임&lt;/b&gt;&lt;br /&gt;&amp;rarr; Action은 서버 파일(또는 서버 함수)로 분리하세요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. &lt;code&gt;revalidatePath&lt;/code&gt;를 빼먹음&lt;/b&gt;&lt;br /&gt;&amp;rarr; 저장은 됐는데 목록이 옛 화면일 수 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 서버 재시작 후 메모가 사라짐&lt;/b&gt;&lt;br /&gt;&amp;rarr; 메모리 배열이라 정상입니다. DB 붙이면 유지됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. &lt;code&gt;action={createMemo()}&lt;/code&gt; 처럼 호출해 버림&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;action={createMemo}&lt;/code&gt; (함수 참조)여야 합니다&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Server Action은 폼이 호출하는 서버 함수다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&quot;use server&quot;&lt;/code&gt; + &lt;code&gt;FormData&lt;/code&gt; + &lt;code&gt;name&lt;/code&gt; 속성이 기본 세트다&lt;/li&gt;
&lt;li&gt;저장 후 &lt;code&gt;revalidatePath&lt;/code&gt;로 목록을 갱신한다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;redirect&lt;/code&gt;로 다음 화면으로 보낸다&lt;/li&gt;
&lt;li&gt;지금은 메모리, 나중엔 DB로 &lt;code&gt;addMemo&lt;/code&gt;만 바꾸면 된다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Next.js + TS] 8. 정리 + 다음 학습 로드맵&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1~7편 한눈에 복습&lt;/li&gt;
&lt;li&gt;지금 앱에서 부족한 점 (DB, 인증, 배포)&lt;/li&gt;
&lt;li&gt;다음에 공부하면 좋은 순서&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시리즈 마무리다운 &lt;b&gt;체크리스트 + 로드맵&lt;/b&gt;으로 끝냅니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 Next.js + TypeScript인가&lt;/li&gt;
&lt;li&gt;프로젝트 만들고 폴더 구조 이해하기&lt;/li&gt;
&lt;li&gt;TypeScript로 컴포넌트 타입 잡기&lt;/li&gt;
&lt;li&gt;라우팅과 레이아웃&lt;/li&gt;
&lt;li&gt;Server Component vs Client Component&lt;/li&gt;
&lt;li&gt;데이터 가져오기&lt;/li&gt;
&lt;li&gt;폼과 서버 액션 입문 &amp;larr; 지금&lt;/li&gt;
&lt;li&gt;정리 + 다음 학습 로드맵&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/785</guid>
      <comments>https://aamoos.tistory.com/785#entry785comment</comments>
      <pubDate>Wed, 29 Jul 2026 15:30:06 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS] 6. 데이터 가져오기</title>
      <link>https://aamoos.tistory.com/784</link>
      <description>&lt;h1&gt;[Next.js + TS] 6. 데이터 가져오기&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5편까지는 &lt;code&gt;lib/memos.ts&lt;/code&gt;의 배열을 &lt;b&gt;동기적으로&lt;/b&gt; 읽었습니다.&lt;br /&gt;이번 편은 서버 컴포넌트에서 데이터를 &lt;b&gt;기다리는(async)&lt;/b&gt; 방식으로 바꿉니다.&lt;br /&gt;목표: &lt;code&gt;async&lt;/code&gt; 페이지 &amp;middot; 로딩 UI &amp;middot; 에러 UI.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;React의 &lt;code&gt;useEffect&lt;/code&gt; + fetch와 Next 서버 fetch의 차이 이해하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getMemos&lt;/code&gt; / &lt;code&gt;getMemoById&lt;/code&gt;를 async로 바꾸기&lt;/li&gt;
&lt;li&gt;목록&amp;middot;상세 페이지를 &lt;code&gt;async&lt;/code&gt;로 고치기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loading.tsx&lt;/code&gt; / &lt;code&gt;error.tsx&lt;/code&gt; 맛보기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB나 외부 API를 당장 붙이지 않아도 됩니다.&lt;br /&gt;&lt;b&gt;&amp;ldquo;가져오는 동안 기다린다&amp;rdquo;&lt;/b&gt; 흐름만 잡으면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 useEffect로 안 가져오나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vite/CRA에서 흔했던 패턴:&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;&quot;use client&quot;;

useEffect(() =&amp;gt; {
  fetch(&quot;/api/memos&quot;)
    .then((res) =&amp;gt; res.json())
    .then(setMemos);
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 초기 흐름이 이렇게 됩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;빈 화면(또는 로딩) 먼저 그림&lt;/li&gt;
&lt;li&gt;JS 실행&lt;/li&gt;
&lt;li&gt;그제야 요청&lt;/li&gt;
&lt;li&gt;다시 그림&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Router 서버 컴포넌트에서는:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;export default async function MemosPage() {
  const memos = await getMemos();
  return ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;서버에서 데이터를 기다림&lt;/li&gt;
&lt;li&gt;완성된 HTML을 보냄&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목록처럼 &lt;b&gt;첫 화면에 꼭 필요한 데이터&lt;/b&gt;는 서버에서 가져오는 쪽이 자연스럽습니다.&lt;br /&gt;클릭 이후 추가 로드, 검색어 디바운스 같은 건 클라이언트 fetch가 맞는 경우도 많습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1) 데이터 함수를 async로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt;를 아래처럼 바꿉니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;import type { Memo } from &quot;@/types/memo&quot;;

const memos: Memo[] = [
  {
    id: &quot;1&quot;,
    title: &quot;Next.js 시작&quot;,
    content: &quot;App Router로 프로젝트 만들었다.&quot;,
    createdAt: &quot;2026-07-29&quot;,
  },
  {
    id: &quot;2&quot;,
    title: &quot;TypeScript props&quot;,
    content: &quot;MemoCard에 타입을 붙였다.&quot;,
    createdAt: &quot;2026-07-29&quot;,
  },
];

function delay(ms: number) {
  return new Promise((resolve) =&amp;gt; setTimeout(resolve, ms));
}

export async function getMemos(): Promise&amp;lt;Memo[]&amp;gt; {
  await delay(800); // 로딩 UI를 보기 위한 가짜 대기
  return memos;
}

export async function getMemoById(id: string): Promise&amp;lt;Memo | undefined&amp;gt; {
  await delay(500);
  return memos.find((memo) =&amp;gt; memo.id === id);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;export const memos&lt;/code&gt; 직접 노출 대신 &lt;b&gt;함수로만&lt;/b&gt; 가져오게 바꿈&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Promise&amp;lt;Memo[]&amp;gt;&lt;/code&gt;로 반환 타입을 명시&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delay&lt;/code&gt;는 실습용입니다. 실제 DB/API 자리라고 생각하면 됩니다&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 컴포넌트에서는 자기 프로젝트의 &lt;code&gt;/api&lt;/code&gt;를 다시 fetch하기보다,&lt;br /&gt;이런 &lt;b&gt;데이터 함수를 직접 await&lt;/b&gt; 하는 방식을 먼저 쓰는 게 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) 목록 페이지를 async로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/memos/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import MemoCard from &quot;@/components/MemoCard&quot;;
import { getMemos } from &quot;@/lib/memos&quot;;

export default async function MemosPage() {
  const memos = await getMemos();

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;메모 목록&amp;lt;/h1&amp;gt;

      &amp;lt;section&amp;gt;
        {memos.map((memo) =&amp;gt; (
          &amp;lt;MemoCard key={memo.id} memo={memo} /&amp;gt;
        ))}
      &amp;lt;/section&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바뀐 점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;async function&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await getMemos()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;더 이상 &lt;code&gt;import { memos }&lt;/code&gt;가 아님&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3) 상세도 동일하게&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/memos/[id]/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { notFound } from &quot;next/navigation&quot;;
import { getMemoById } from &quot;@/lib/memos&quot;;

type MemoDetailPageProps = {
  params: Promise&amp;lt;{ id: string }&amp;gt;;
};

export default async function MemoDetailPage({ params }: MemoDetailPageProps) {
  const { id } = await params;
  const memo = await getMemoById(id);

  if (!memo) {
    notFound();
  }

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;{memo.title}&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;{memo.content}&amp;lt;/p&amp;gt;
      &amp;lt;time dateTime={memo.createdAt}&amp;gt;{memo.createdAt}&amp;lt;/time&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;getMemoById&lt;/code&gt; 앞에 &lt;code&gt;await&lt;/code&gt;만 추가하면 됩니다.&lt;br /&gt;(이미 &lt;code&gt;async&lt;/code&gt; 페이지였다면 그대로 두면 됩니다.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4) 로딩 UI: &lt;code&gt;loading.tsx&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 기다리는 동안 보여줄 파일을 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;app/memos/loading.tsx&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export default function MemosLoading() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;p&amp;gt;메모를 불러오는 중...&amp;lt;/p&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/memos&lt;/code&gt;로 이동하면 잠깐 이 문구가 보였다가 목록이 뜹니다.&lt;br /&gt;(&lt;code&gt;delay(800)&lt;/code&gt; 덕분에 눈에 보입니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;loading.tsx&lt;/code&gt;는 같은 세그먼트(&lt;code&gt;memos&lt;/code&gt;)의 &lt;code&gt;page.tsx&lt;/code&gt;를 감싸는 &lt;b&gt;즉시 로딩 UI&lt;/b&gt;입니다.&lt;br /&gt;상세(&lt;code&gt;memos/[id]&lt;/code&gt;)에도 따로 두고 싶으면:&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;app/memos/[id]/loading.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 추가하면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5) 에러 UI: &lt;code&gt;error.tsx&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;app/memos/error.tsx&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 경계는 클라이언트 컴포넌트여야 합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;&quot;use client&quot;;

type MemosErrorProps = {
  error: Error;
  reset: () =&amp;gt; void;
};

export default function MemosError({ error, reset }: MemosErrorProps) {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;메모를 불러오지 못했습니다&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;{error.message}&amp;lt;/p&amp;gt;
      &amp;lt;button type=&quot;button&quot; onClick={() =&amp;gt; reset()}&amp;gt;
        다시 시도
      &amp;lt;/button&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일부러 에러를 내려면 &lt;code&gt;getMemos&lt;/code&gt; 안에 잠깐 넣어 볼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export async function getMemos(): Promise&amp;lt;Memo[]&amp;gt; {
  await delay(800);
  throw new Error(&quot;샘플 에러입니다&quot;);
  // return memos;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인 후 &lt;b&gt;꼭 &lt;code&gt;throw&lt;/code&gt;는 지우세요.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;외부 API를 fetch할 때는&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 HTTP API라면 서버 컴포넌트에서 이렇게 씁니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export default async function MemosPage() {
  const res = await fetch(&quot;https://api.example.com/memos&quot;, {
    next: { revalidate: 60 }, // 60초 캐시 예시
  });

  if (!res.ok) {
    throw new Error(&quot;메모를 가져오지 못했습니다&quot;);
  }

  const memos: Memo[] = await res.json();
  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 시리즈는 로컬 배열 + &lt;code&gt;delay&lt;/code&gt;로 &lt;b&gt;같은 async 흐름&lt;/b&gt;을 연습합니다.&lt;br /&gt;나중에 API URL만 바꿔 끼우면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 서버 페이지인데 &lt;code&gt;useEffect&lt;/code&gt;로 다시 fetch&lt;/b&gt;&lt;br /&gt;&amp;rarr; 초기 목록이면 &lt;code&gt;async&lt;/code&gt; + &lt;code&gt;await&lt;/code&gt;를 먼저&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. &lt;code&gt;await&lt;/code&gt; 없이 Promise를 map에 넣음&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;const memos = await getMemos()&lt;/code&gt; 필수&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. &lt;code&gt;loading.tsx&lt;/code&gt;에 &lt;code&gt;'use client'&lt;/code&gt; + useState로 직접 로딩 관리&lt;/b&gt;&lt;br /&gt;&amp;rarr; 세그먼트 &lt;code&gt;loading.tsx&lt;/code&gt;만으로도 충분한 경우가 많음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. &lt;code&gt;error.tsx&lt;/code&gt;를 서버 컴포넌트로 작성&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;error.tsx&lt;/code&gt;는 &lt;code&gt;&quot;use client&quot;&lt;/code&gt; 필요&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫 화면 데이터는 서버 컴포넌트에서 &lt;code&gt;await&lt;/code&gt;하는 편이 자연스럽다&lt;/li&gt;
&lt;li&gt;데이터 함수는 &lt;code&gt;async&lt;/code&gt; + &lt;code&gt;Promise&amp;lt;타입&amp;gt;&lt;/code&gt;으로 계약한다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loading.tsx&lt;/code&gt;는 기다리는 동안의 UI&lt;/li&gt;
&lt;li&gt;&lt;code&gt;error.tsx&lt;/code&gt;는 실패한 동안의 UI (클라이언트)&lt;/li&gt;
&lt;li&gt;지금은 가짜 delay, 나중엔 DB/API로 교체하면 된다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Next.js + TS] 7. 폼과 서버 액션 입문&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메모 추가 폼 만들기&lt;/li&gt;
&lt;li&gt;Server Action으로 데이터 넣기&lt;/li&gt;
&lt;li&gt;목록이 갱신되는 흐름 맛보기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽기만 하던 앱에 &lt;b&gt;쓰기&lt;/b&gt;를 붙입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 Next.js + TypeScript인가&lt;/li&gt;
&lt;li&gt;프로젝트 만들고 폴더 구조 이해하기&lt;/li&gt;
&lt;li&gt;TypeScript로 컴포넌트 타입 잡기&lt;/li&gt;
&lt;li&gt;라우팅과 레이아웃&lt;/li&gt;
&lt;li&gt;Server Component vs Client Component&lt;/li&gt;
&lt;li&gt;데이터 가져오기 &amp;larr; 지금&lt;/li&gt;
&lt;li&gt;폼과 서버 액션 입문&lt;/li&gt;
&lt;li&gt;정리 + 다음 학습 로드맵&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/784</guid>
      <comments>https://aamoos.tistory.com/784#entry784comment</comments>
      <pubDate>Wed, 29 Jul 2026 15:15:21 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS] 5. Server Component vs Client Component</title>
      <link>https://aamoos.tistory.com/783</link>
      <description>&lt;h1&gt;[Next.js + TS] 5. Server Component vs Client Component&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4편까지는 목록&amp;middot;상세&amp;middot;링크만 있었습니다. 전부 &lt;b&gt;읽기&lt;/b&gt;에 가깝습니다.&lt;br /&gt;이번 편은 App Router에서 제일 헷갈리는 부분,&lt;br /&gt;&lt;b&gt;서버 컴포넌트 / 클라이언트 컴포넌트&lt;/b&gt; 경계를 나눕니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;기본값이 서버 컴포넌트인 이유 이해하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'use client'&lt;/code&gt;가 필요한 순간 정리하기&lt;/li&gt;
&lt;li&gt;좋아요 버튼을 &lt;b&gt;작은 클라이언트 컴포넌트&lt;/b&gt;로 분리하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MemoCard&lt;/code&gt;(서버)가 그 버튼을 조립하는 패턴 익히기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목표는 &amp;ldquo;전부 &lt;code&gt;'use client'&lt;/code&gt;로 만들기&amp;rdquo;가 아닙니다.&lt;br /&gt;&lt;b&gt;꼭 필요한 곳만&lt;/b&gt; 클라이언트로 내리는 감각을 잡는 겁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한 줄 정리&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;Server Component&lt;/th&gt;
&lt;th&gt;Client Component&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;기본?&lt;/td&gt;
&lt;td&gt;App Router 기본값&lt;/td&gt;
&lt;td&gt;파일 맨 위에 &lt;code&gt;'use client'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 위치&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;브라우저(+필요 시 서버에서 사전 렌더)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hooks (&lt;code&gt;useState&lt;/code&gt; 등)&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클릭/입력 이벤트&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 fetch&lt;/td&gt;
&lt;td&gt;자연스러움&lt;/td&gt;
&lt;td&gt;가능하지만 목적이 다름&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React(Vite/CRA)에 익숙하면 전부 클라이언트처럼 느껴집니다.&lt;br /&gt;Next App Router는 &lt;b&gt;기본을 서버&lt;/b&gt;에 두고, 인터랙션만 브라우저로 보냅니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 기본이 서버인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 만든 &lt;code&gt;MemosPage&lt;/code&gt;, &lt;code&gt;MemoCard&lt;/code&gt;에는 아직:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;useState&lt;/code&gt; 없음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;onClick&lt;/code&gt; 없음&lt;/li&gt;
&lt;li&gt;브라우저 API 없음&lt;/li&gt;
&lt;/ul&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;서버에서 HTML을 만들어 보내면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫 화면을 빨리 그릴 수 있고&lt;/li&gt;
&lt;li&gt;불필요한 JS를 클라이언트로 덜 보내고&lt;/li&gt;
&lt;li&gt;(나중에) DB/시크릿에 가까운 작업을 서버에 두기 쉽습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;React 컴포넌트 = 무조건 브라우저&amp;rdquo;가 아니라는 점이 핵심입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언제 &lt;code&gt;'use client'&lt;/code&gt;가 필요한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래가 필요하면 클라이언트 컴포넌트입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;useState&lt;/code&gt;, &lt;code&gt;useEffect&lt;/code&gt;, &lt;code&gt;useRef&lt;/code&gt; 같은 hooks&lt;/li&gt;
&lt;li&gt;&lt;code&gt;onClick&lt;/code&gt;, &lt;code&gt;onChange&lt;/code&gt; 같은 이벤트&lt;/li&gt;
&lt;li&gt;&lt;code&gt;window&lt;/code&gt;, &lt;code&gt;localStorage&lt;/code&gt; 같은 브라우저 API&lt;/li&gt;
&lt;li&gt;일부 브라우저 전용 라이브러리&lt;/li&gt;
&lt;/ul&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;잘못된 습관:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;&quot;use client&quot;; // 습관적으로 파일 맨 위에 붙임

export default function MemosPage() {
  // 사실 hooks도 이벤트도 없음
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지 전체를 클라이언트로 올리면,&lt;br /&gt;서버 컴포넌트의 이점을 스스로 버리는 셈입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실습 아이디어: 좋아요 버튼&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모 카드에 &amp;ldquo;좋아요&amp;rdquo;를 눌러 숫자가 바뀌게 해 봅시다.&lt;br /&gt;이건 &lt;code&gt;useState&lt;/code&gt;가 필요하므로 &lt;b&gt;클라이언트&lt;/b&gt;가 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;code&gt;MemoCard&lt;/code&gt; 전체를 &lt;code&gt;'use client'&lt;/code&gt;로 바꾸지 않습니다.&lt;/p&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;MemoCard          &amp;larr; 서버 (제목, 내용, 링크)
└─ LikeButton     &amp;larr; 클라이언트 (클릭, state)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큰 덩어리는 서버, &lt;b&gt;인터랙션 조각만&lt;/b&gt; 클라이언트.&lt;br /&gt;이게 App Router에서 자주 쓰는 패턴입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1) &lt;code&gt;LikeButton&lt;/code&gt; 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;components/LikeButton.tsx&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useState } from &quot;react&quot;;

export default function LikeButton() {
  const [likes, setLikes] = useState(0);

  return (
    &amp;lt;button type=&quot;button&quot; onClick={() =&amp;gt; setLikes((prev) =&amp;gt; prev + 1)}&amp;gt;
      좋아요 {likes}
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반드시 파일 &lt;b&gt;맨 위&lt;/b&gt;에 &lt;code&gt;&quot;use client&quot;&lt;/code&gt; (또는 &lt;code&gt;'use client'&lt;/code&gt;)가 있어야 합니다.&lt;br /&gt;이 한 줄이 &amp;ldquo;이 파일은 클라이언트 컴포넌트 경계&amp;rdquo;라는 표시입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) 서버 &lt;code&gt;MemoCard&lt;/code&gt;에 조립&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;components/MemoCard.tsx&lt;/code&gt;는 &lt;code&gt;'use client'&lt;/code&gt;를 &lt;b&gt;붙이지 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import Link from &quot;next/link&quot;;
import type { Memo } from &quot;@/types/memo&quot;;
import LikeButton from &quot;@/components/LikeButton&quot;;

type MemoCardProps = {
  memo: Memo;
};

export default function MemoCard({ memo }: MemoCardProps) {
  return (
    &amp;lt;article&amp;gt;
      &amp;lt;h2&amp;gt;
        &amp;lt;Link href={`/memos/${memo.id}`}&amp;gt;{memo.title}&amp;lt;/Link&amp;gt;
      &amp;lt;/h2&amp;gt;
      &amp;lt;p&amp;gt;{memo.content}&amp;lt;/p&amp;gt;
      &amp;lt;time dateTime={memo.createdAt}&amp;gt;{memo.createdAt}&amp;lt;/time&amp;gt;
      &amp;lt;div&amp;gt;
        &amp;lt;LikeButton /&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/article&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 컴포넌트가 클라이언트 컴포넌트를 &lt;b&gt;자식으로 import&lt;/b&gt;하는 것은 OK입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로, 클라이언트 컴포넌트가 서버 컴포넌트를 같은 방식으로 막 import해서 서버 전용 이점을 유지하긴 어렵습니다.&lt;br /&gt;(필요하면 &lt;code&gt;children&lt;/code&gt;으로 서버 결과를 넘기는 패턴을 씁니다. 지금은 몰라도 됩니다.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동작 확인&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;npm run dev&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos&lt;/code&gt;에서 &lt;b&gt;좋아요&lt;/b&gt; 클릭&lt;/li&gt;
&lt;li&gt;숫자만 올라가면 성공&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지를 새로고침하면 숫자는 0으로 돌아갑니다.&lt;br /&gt;지금은 메모리 state만 쓰기 때문입니다. (저장은 7편 쪽에서 다룹니다.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;경계를 가르는 기준 (실전용)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고민될 때 이 순서로 물어보세요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;hooks / 이벤트가 필요한가?&lt;/b&gt;&lt;br /&gt;&amp;rarr; 없으면 서버&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파일 전체가 아니라 버튼/입력만 필요한가?&lt;/b&gt;&lt;br /&gt;&amp;rarr; 작은 클라이언트 컴포넌트로 분리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 읽기가 주목적인가?&lt;/b&gt;&lt;br /&gt;&amp;rarr; 페이지/카드는 서버에 두는 쪽을 먼저 생각&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;좋은 방향
─────────
page (Server)
  └─ MemoCard (Server)
       └─ LikeButton (Client)

피하고 싶은 방향
─────────
page (Client)  &amp;larr; 페이지 통째로
  └─ 모든 것&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. &lt;code&gt;&quot;use client&quot;&lt;/code&gt;를 함수 안에 씀&lt;/b&gt;&lt;br /&gt;&amp;rarr; 반드시 파일 최상단 (import보다 위)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 서버 컴포넌트에서 &lt;code&gt;useState&lt;/code&gt; 사용&lt;/b&gt;&lt;br /&gt;&amp;rarr; 에러 납니다. state가 필요하면 그 부분만 클라이언트로 분리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. &lt;code&gt;Link&lt;/code&gt;가 있다고 무조건 클라이언트&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;next/link&lt;/code&gt;는 서버 컴포넌트에서도 사용 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 샘플이라서 전부 클라이언트로 통일&lt;/b&gt;&lt;br /&gt;&amp;rarr; 나중에 습관이 됩니다. 처음부터 경계를 나누는 연습을 하세요&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;App Router 기본은 Server Component다&lt;/li&gt;
&lt;li&gt;hooks/이벤트가 필요할 때만 Client Component다&lt;/li&gt;
&lt;li&gt;페이지 전체가 아니라 &lt;b&gt;인터랙션 조각&lt;/b&gt;에 &lt;code&gt;'use client'&lt;/code&gt;를 붙인다&lt;/li&gt;
&lt;li&gt;서버 카드 + 클라이언트 버튼 조합이 기본 패턴이다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Next.js + TS] 6. 데이터 가져오기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 컴포넌트에서 &lt;code&gt;fetch&lt;/code&gt; 하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loading.tsx&lt;/code&gt; / &lt;code&gt;error.tsx&lt;/code&gt; 맛보기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lib/memos.ts&lt;/code&gt; 하드코딩에서 한 걸음 나아가기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 로컬 배열을 읽고 있습니다.&lt;br /&gt;다음 편에서 &lt;b&gt;데이터를 가져오는 방식&lt;/b&gt;을 정리합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 Next.js + TypeScript인가&lt;/li&gt;
&lt;li&gt;프로젝트 만들고 폴더 구조 이해하기&lt;/li&gt;
&lt;li&gt;TypeScript로 컴포넌트 타입 잡기&lt;/li&gt;
&lt;li&gt;라우팅과 레이아웃&lt;/li&gt;
&lt;li&gt;Server Component vs Client Component &amp;larr; 지금&lt;/li&gt;
&lt;li&gt;데이터 가져오기&lt;/li&gt;
&lt;li&gt;폼과 서버 액션 입문&lt;/li&gt;
&lt;li&gt;정리 + 다음 학습 로드맵&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/783</guid>
      <comments>https://aamoos.tistory.com/783#entry783comment</comments>
      <pubDate>Wed, 29 Jul 2026 15:08:02 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS] 4. 라우팅과 레이아웃</title>
      <link>https://aamoos.tistory.com/782</link>
      <description>&lt;h1&gt;[Next.js + TS] 4. 라우팅과 레이아웃&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3편에서 홈에 샘플 카드를 붙였습니다.&lt;br /&gt;이번 편은 &lt;b&gt;URL로 화면을 나누고&lt;/b&gt;, 공통 내비는 &lt;code&gt;layout&lt;/code&gt;에 둡니다.&lt;br /&gt;목표: &lt;code&gt;/memos&lt;/code&gt; 목록 &amp;middot; &lt;code&gt;/memos/[id]&lt;/code&gt; 상세 &amp;middot; 상단 링크.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;샘플 데이터를 공용 파일로 분리하기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos&lt;/code&gt; 목록 페이지 만들기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos/[id]&lt;/code&gt; 상세 페이지 만들기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MemoCard&lt;/code&gt;에 상세 링크 달기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;layout.tsx&lt;/code&gt;에 간단한 내비 넣기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Router 핵심은 다시 이겁니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더 = URL&lt;br /&gt;&lt;code&gt;page.tsx&lt;/code&gt; = 화면&lt;br /&gt;&lt;code&gt;layout.tsx&lt;/code&gt; = 공통 틀&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 홈에만 두면 부족한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 &lt;code&gt;/&lt;/code&gt; 하나에 목록이 다 있습니다.&lt;br /&gt;실제 앱에서는 보통 이렇게 나눕니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;소개/홈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/memos&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;메모 목록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/memos/1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;id가 1인 메모 상세&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Router로 직접 구성하던 일을, Next에서는 &lt;b&gt;폴더를 만드는 것&lt;/b&gt;으로 해결합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&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;b&gt;같은 데이터&lt;/b&gt;를 봐야 합니다.&lt;br /&gt;&lt;code&gt;app/page.tsx&lt;/code&gt; 안에만 두면 상세에서 못 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;lib/memos.ts&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import type { Memo } from &quot;@/types/memo&quot;;

export const memos: Memo[] = [
  {
    id: &quot;1&quot;,
    title: &quot;Next.js 시작&quot;,
    content: &quot;App Router로 프로젝트 만들었다.&quot;,
    createdAt: &quot;2026-07-29&quot;,
  },
  {
    id: &quot;2&quot;,
    title: &quot;TypeScript props&quot;,
    content: &quot;MemoCard에 타입을 붙였다.&quot;,
    createdAt: &quot;2026-07-29&quot;,
  },
];

export function getMemoById(id: string): Memo | undefined {
  return memos.find((memo) =&amp;gt; memo.id === id);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;memos&lt;/code&gt;: 전체 목록&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getMemoById&lt;/code&gt;: id로 하나 찾기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 DB/API는 없습니다.&lt;br /&gt;&lt;b&gt;라우팅 연습용 가짜 데이터&lt;/b&gt;로 충분합니다. (6편에서 fetch로 바꿉니다.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) 목록 페이지: &lt;code&gt;/memos&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더와 파일을 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;app/
  memos/
    page.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/memos/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import MemoCard from &quot;@/components/MemoCard&quot;;
import { memos } from &quot;@/lib/memos&quot;;

export default function MemosPage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;메모 목록&amp;lt;/h1&amp;gt;

      &amp;lt;section&amp;gt;
        {memos.map((memo) =&amp;gt; (
          &amp;lt;MemoCard key={memo.id} memo={memo} /&amp;gt;
        ))}
      &amp;lt;/section&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 &lt;code&gt;http://localhost:3000/memos&lt;/code&gt; 를 열면 목록이 보여야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;memos&lt;/code&gt;라는 &lt;b&gt;폴더 이름&lt;/b&gt;이 곧 URL 경로입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3) 상세 페이지: &lt;code&gt;/memos/[id]&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 구간은 대괄호 폴더로 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;app/
  memos/
    page.tsx
    [id]/
      page.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;/memos/1&lt;/code&gt; &amp;rarr; &lt;code&gt;params.id === &quot;1&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos/2&lt;/code&gt; &amp;rarr; &lt;code&gt;params.id === &quot;2&quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/memos/[id]/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { notFound } from &quot;next/navigation&quot;;
import { getMemoById } from &quot;@/lib/memos&quot;;

type MemoDetailPageProps = {
  params: Promise&amp;lt;{ id: string }&amp;gt;;
};

export default async function MemoDetailPage({ params }: MemoDetailPageProps) {
  const { id } = await params;
  const memo = getMemoById(id);

  if (!memo) {
    notFound();
  }

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;{memo.title}&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;{memo.content}&amp;lt;/p&amp;gt;
      &amp;lt;time dateTime={memo.createdAt}&amp;gt;{memo.createdAt}&amp;lt;/time&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;코드&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[id]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URL의 가변 구간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;params&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;그 구간 값이 들어옴&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;await params&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;최근 Next에서는 params가 Promise인 경우가 많음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;notFound()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;없는 id면 404&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;http://localhost:3000/memos/1&lt;/code&gt; 이 열리고,&lt;br /&gt;&lt;code&gt;/memos/999&lt;/code&gt; 는 404가 나면 성공입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전마다 &lt;code&gt;params&lt;/code&gt;가 동기 객체인 템플릿도 있습니다.&lt;br /&gt;타입이 &lt;code&gt;Promise&lt;/code&gt;라고 나오면 &lt;code&gt;await&lt;/code&gt; 하면 되고, 아니면 &lt;code&gt;params.id&lt;/code&gt;를 그대로 쓰면 됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4) 카드에서 상세로 이동: &lt;code&gt;Link&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;도 되지만, Next에서는 &lt;code&gt;Link&lt;/code&gt;를 씁니다.&lt;br /&gt;클라이언트 쪽 이동을 더 부드럽게 처리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;components/MemoCard.tsx&lt;/code&gt;를 수정합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import Link from &quot;next/link&quot;;
import type { Memo } from &quot;@/types/memo&quot;;

type MemoCardProps = {
  memo: Memo;
};

export default function MemoCard({ memo }: MemoCardProps) {
  return (
    &amp;lt;article&amp;gt;
      &amp;lt;h2&amp;gt;
        &amp;lt;Link href={`/memos/${memo.id}`}&amp;gt;{memo.title}&amp;lt;/Link&amp;gt;
      &amp;lt;/h2&amp;gt;
      &amp;lt;p&amp;gt;{memo.content}&amp;lt;/p&amp;gt;
      &amp;lt;time dateTime={memo.createdAt}&amp;gt;{memo.createdAt}&amp;lt;/time&amp;gt;
    &amp;lt;/article&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목록에서 제목을 누르면 상세로 가야 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&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;code&gt;/memos&lt;/code&gt;로 옮겼으니, 홈은 가볍게 둡니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import Link from &quot;next/link&quot;;

export default function HomePage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;Memo App&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;Next.js + TypeScript 시리즈 실습 프로젝트&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;
        &amp;lt;Link href=&quot;/memos&quot;&amp;gt;메모 목록 보기&amp;lt;/Link&amp;gt;
      &amp;lt;/p&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6) 공통 내비: &lt;code&gt;layout.tsx&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 페이지 위에 링크를 두고 싶다면 &lt;code&gt;app/layout.tsx&lt;/code&gt;에 넣습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;children&lt;/code&gt; 위에 내비만 추가하면 됩니다. (폰트 설정 등은 기존 것 유지)&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import Link from &quot;next/link&quot;;
import type { Metadata } from &quot;next&quot;;
import &quot;./globals.css&quot;;

export const metadata: Metadata = {
  title: &quot;Memo App&quot;,
  description: &quot;Next.js + TypeScript 실습&quot;,
};

export default function RootLayout({
  children,
}: Readonly&amp;lt;{
  children: React.ReactNode;
}&amp;gt;) {
  return (
    &amp;lt;html lang=&quot;ko&quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;header&amp;gt;
          &amp;lt;nav&amp;gt;
            &amp;lt;Link href=&quot;/&quot;&amp;gt;홈&amp;lt;/Link&amp;gt;
            {&quot; | &quot;}
            &amp;lt;Link href=&quot;/memos&quot;&amp;gt;메모&amp;lt;/Link&amp;gt;
          &amp;lt;/nav&amp;gt;
        &amp;lt;/header&amp;gt;
        {children}
      &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;이미 Geist 폰트 등이 있다면 &lt;b&gt;내비만 추가&lt;/b&gt;해도 됩니다.&lt;br /&gt;중요한 건 위치입니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;layout
├─ header/nav   &amp;larr; 모든 페이지 공통
└─ children     &amp;larr; page.tsx 내용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/memos&lt;/code&gt;, &lt;code&gt;/memos/1&lt;/code&gt; 어디로 가든 위 링크가 보여야 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;지금 폴더 구조&lt;/h2&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;app/
  layout.tsx          &amp;rarr; 공통 내비
  page.tsx            &amp;rarr; /
  memos/
    page.tsx          &amp;rarr; /memos
    [id]/
      page.tsx        &amp;rarr; /memos/:id
components/
  MemoCard.tsx
lib/
  memos.ts
types/
  memo.ts&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. &lt;code&gt;app/memos.tsx&lt;/code&gt; 파일만 만듦&lt;/b&gt;&lt;br /&gt;&amp;rarr; 목록은 &lt;code&gt;app/memos/page.tsx&lt;/code&gt; 여야 &lt;code&gt;/memos&lt;/code&gt;가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. &lt;code&gt;[id]&lt;/code&gt;를 일반 폴더명 &lt;code&gt;id&lt;/code&gt;로 만듦&lt;/b&gt;&lt;br /&gt;&amp;rarr; 대괄호가 있어야 동적 라우트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 상세에서 목록 데이터를 또 하드코딩&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;lib/memos.ts&lt;/code&gt;로 한곳에서 관리하세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. &lt;code&gt;Link&lt;/code&gt;에 &lt;code&gt;href=&quot;#&quot;&lt;/code&gt;&lt;/b&gt;&lt;br /&gt;&amp;rarr; 반드시 &lt;code&gt;/memos/${memo.id}&lt;/code&gt;처럼 실제 경로를 넣습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;App Router는 폴더로 URL을 만든다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;[id]&lt;/code&gt;는 동적 세그먼트다&lt;/li&gt;
&lt;li&gt;목록/상세가 같은 데이터를 쓰면 &lt;code&gt;lib&lt;/code&gt;로 분리한다&lt;/li&gt;
&lt;li&gt;페이지 이동은 &lt;code&gt;Link&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;모든 페이지 공통 UI는 &lt;code&gt;layout.tsx&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Next.js + TS] 5. Server Component vs Client Component&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본이 서버 컴포넌트인 이유&lt;/li&gt;
&lt;li&gt;언제 &lt;code&gt;'use client'&lt;/code&gt;가 필요한지&lt;/li&gt;
&lt;li&gt;클릭 같은 인터랙션은 어디에 두는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 &amp;ldquo;읽기 전용 페이지&amp;rdquo; 중심입니다.&lt;br /&gt;다음 편에서 &lt;b&gt;서버/클라이언트 경계&lt;/b&gt;를 나눕니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 Next.js + TypeScript인가&lt;/li&gt;
&lt;li&gt;프로젝트 만들고 폴더 구조 이해하기&lt;/li&gt;
&lt;li&gt;TypeScript로 컴포넌트 타입 잡기&lt;/li&gt;
&lt;li&gt;라우팅과 레이아웃 &amp;larr; 지금&lt;/li&gt;
&lt;li&gt;Server Component vs Client Component&lt;/li&gt;
&lt;li&gt;데이터 가져오기&lt;/li&gt;
&lt;li&gt;폼과 서버 액션 입문&lt;/li&gt;
&lt;li&gt;정리 + 다음 학습 로드맵&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/782</guid>
      <comments>https://aamoos.tistory.com/782#entry782comment</comments>
      <pubDate>Wed, 29 Jul 2026 14:52:47 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS] 3. TypeScript로 컴포넌트 타입 잡기</title>
      <link>https://aamoos.tistory.com/781</link>
      <description>&lt;h1&gt;[Next.js + TS] 3. TypeScript로 컴포넌트 타입 잡기&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편에서 프로젝트를 띄웠다면, 이번 편은 &lt;b&gt;타입을 붙인 컴포넌트&lt;/b&gt;를 만듭니다.&lt;br /&gt;목표: &lt;code&gt;Memo&lt;/code&gt; 타입 정의 &amp;rarr; &lt;code&gt;MemoCard&lt;/code&gt; props 타입 &amp;rarr; 홈에 샘플 메모 보여 주기.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;메모 데이터 모델(&lt;code&gt;Memo&lt;/code&gt;) 타입 만들기&lt;/li&gt;
&lt;li&gt;props에 타입이 있는 &lt;code&gt;MemoCard&lt;/code&gt; 컴포넌트 만들기&lt;/li&gt;
&lt;li&gt;홈 페이지에 샘플 데이터로 카드 렌더하기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고급 타입(제네릭 마법, utility type 잔치)은 하지 않습니다.&lt;br /&gt;&lt;b&gt;props / 데이터 모델&lt;/b&gt;만 확실하게 잡습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 컴포넌트에 타입을 붙이나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React만 쓸 때도 props는 넘어갑니다.&lt;br /&gt;문제는 &amp;ldquo;이 컴포넌트가 &lt;b&gt;뭘 받는지&lt;/b&gt;&amp;rdquo;가 코드만 봐서는 흐릿하다는 점입니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 타입 없이
function MemoCard({ title, content }) {
  return &amp;lt;article&amp;gt;...&amp;lt;/article&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;title&lt;/code&gt;이 string인지?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;createdAt&lt;/code&gt;도 넘겨야 하는지?&lt;/li&gt;
&lt;li&gt;오타로 &lt;code&gt;titel&lt;/code&gt;을 넘기면?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TypeScript를 붙이면 &lt;b&gt;받는 쪽과 주는 쪽이 같은 계약&lt;/b&gt;을 공유합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1) 데이터 모델부터: &lt;code&gt;Memo&lt;/code&gt; 타입&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에 타입 전용 파일을 하나 둡니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;types/memo.ts&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;export type Memo = {
  id: string;
  title: string;
  content: string;
  createdAt: string;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;type&lt;/code&gt;으로 객체의 모양을 고정한다&lt;/li&gt;
&lt;li&gt;나중에 API/DB에서 같은 형태를 쓰면 &lt;b&gt;한곳에서만&lt;/b&gt; 바꾸면 된다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;를 &lt;code&gt;number&lt;/code&gt;로 할지 &lt;code&gt;string&lt;/code&gt;으로 할지는 팀마다 다르지만, 이 시리즈는 &lt;b&gt;string&lt;/b&gt;으로 통일한다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) props 타입: &lt;code&gt;MemoCard&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트 폴더를 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로: &lt;code&gt;components/MemoCard.tsx&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import type { Memo } from &quot;@/types/memo&quot;;

type MemoCardProps = {
  memo: Memo;
};

export default function MemoCard({ memo }: MemoCardProps) {
  return (
    &amp;lt;article&amp;gt;
      &amp;lt;h2&amp;gt;{memo.title}&amp;lt;/h2&amp;gt;
      &amp;lt;p&amp;gt;{memo.content}&amp;lt;/p&amp;gt;
      &amp;lt;time dateTime={memo.createdAt}&amp;gt;{memo.createdAt}&amp;lt;/time&amp;gt;
    &amp;lt;/article&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어볼 것:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;코드&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;import type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;타입만 가져옴 (런타임 코드 아님)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MemoCardProps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;이 컴포넌트가 받는 props 계약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{ memo }: MemoCardProps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;구조 분해 + 타입 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;memo.title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Memo&lt;/code&gt;에 있는 필드만 자동완성됨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실수로 &lt;code&gt;memo.titlle&lt;/code&gt;을 쓰면 에디터/타입이 바로 에러를 냅니다.&lt;br /&gt;1편에서 말했던 &amp;ldquo;안전벨트&amp;rdquo;가 여기서 체감됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3) 홈에서 샘플 데이터로 연결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/page.tsx&lt;/code&gt;를 아래처럼 바꿉니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import MemoCard from &quot;@/components/MemoCard&quot;;
import type { Memo } from &quot;@/types/memo&quot;;

const sampleMemos: Memo[] = [
  {
    id: &quot;1&quot;,
    title: &quot;Next.js 시작&quot;,
    content: &quot;App Router로 프로젝트 만들었다.&quot;,
    createdAt: &quot;2026-07-29&quot;,
  },
  {
    id: &quot;2&quot;,
    title: &quot;TypeScript props&quot;,
    content: &quot;MemoCard에 타입을 붙였다.&quot;,
    createdAt: &quot;2026-07-29&quot;,
  },
];

export default function HomePage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;Memo App&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;샘플 메모 목록&amp;lt;/p&amp;gt;

      &amp;lt;section&amp;gt;
        {sampleMemos.map((memo) =&amp;gt; (
          &amp;lt;MemoCard key={memo.id} memo={memo} /&amp;gt;
        ))}
      &amp;lt;/section&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인 포인트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;sampleMemos: Memo[]&lt;/code&gt; &amp;rarr; 배열 원소가 전부 &lt;code&gt;Memo&lt;/code&gt;여야 함&lt;/li&gt;
&lt;li&gt;필드 하나 빠지거나 이름 틀리면 &lt;b&gt;페이지에서 바로 빨간줄&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key={memo.id}&lt;/code&gt;는 React 리스트 렌더링 기본기 (타입과는 별개, 필수)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;npm run dev&lt;/code&gt;로 &lt;code&gt;http://localhost:3000&lt;/code&gt;을 보면 카드 두 개가 보여야 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;props를 펼치는 다른 방식 (참고)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 &lt;code&gt;memo&lt;/code&gt; 객체 하나를 통째로 넘겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가끔은 필드를 하나씩 받기도 합니다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;type MemoCardProps = {
  title: string;
  content: string;
  createdAt: string;
};&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;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 &lt;b&gt;하나의 모델&lt;/b&gt;이면 객체로 넘긴다 (&lt;code&gt;memo: Memo&lt;/code&gt;)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필드가 늘어날 때마다 props를 하나씩 추가하는 것보다,&lt;br /&gt;모델만 고치면 되어서 유지보수가 쉽습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;@/&lt;/code&gt; 는 뭐지?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;create-next-app&lt;/code&gt;에서 import alias를 켰다면:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import type { Memo } from &quot;@/types/memo&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@/&lt;/code&gt;는 보통 프로젝트 루트를 가리킵니다.&lt;br /&gt;&lt;code&gt;tsconfig.json&lt;/code&gt;의 &lt;code&gt;paths&lt;/code&gt;에 정의되어 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상대 경로 지옥(&lt;code&gt;../../../types/memo&lt;/code&gt;)을 피하려고 쓰는 설정입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. &lt;code&gt;Memo&lt;/code&gt;를 값처럼 import&lt;/b&gt;&lt;br /&gt;&amp;rarr; 타입만 쓸 때는 &lt;code&gt;import type { Memo }&lt;/code&gt; 권장&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. props 타입을 안 쓰고 매개변수만 적음&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;({ memo })&lt;/code&gt;만 쓰면 &lt;code&gt;memo&lt;/code&gt;가 &lt;code&gt;any&lt;/code&gt;에 가까워져 이득이 사라짐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. &lt;code&gt;key&lt;/code&gt;를 인덱스(&lt;code&gt;index&lt;/code&gt;)로만 씀&lt;/b&gt;&lt;br /&gt;&amp;rarr; 샘플에선 당장 안 터져도, 목록이 바뀌면 버그 나기 쉬움. &lt;code&gt;id&lt;/code&gt; 사용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. &lt;code&gt;types&lt;/code&gt; 폴더 위치 착각&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;app/types&lt;/code&gt;가 아니라 프로젝트 루트의 &lt;code&gt;types/&lt;/code&gt; (이 시리즈 기준)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터 모양은 &lt;code&gt;type&lt;/code&gt;으로 먼저 고정한다&lt;/li&gt;
&lt;li&gt;컴포넌트 props에도 같은 타입을 연결한다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Memo[]&lt;/code&gt;로 샘플을 만들면 잘못된 데이터가 즉시 드러난다&lt;/li&gt;
&lt;li&gt;모델 하나면 props도 객체 하나로 넘기는 편이 단순하다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Next.js + TS] 4. 라우팅과 레이아웃&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;/memos&lt;/code&gt; 목록 페이지 만들기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/memos/[id]&lt;/code&gt; 상세 페이지&lt;/li&gt;
&lt;li&gt;&lt;code&gt;layout.tsx&lt;/code&gt;에 간단한 내비 넣기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 홈에 샘플이 붙어 있는 상태입니다.&lt;br /&gt;다음 편에서 &lt;b&gt;URL별로 화면을 나누기&lt;/b&gt; 시작합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 Next.js + TypeScript인가&lt;/li&gt;
&lt;li&gt;프로젝트 만들고 폴더 구조 이해하기&lt;/li&gt;
&lt;li&gt;TypeScript로 컴포넌트 타입 잡기 &amp;larr; 지금&lt;/li&gt;
&lt;li&gt;라우팅과 레이아웃&lt;/li&gt;
&lt;li&gt;Server Component vs Client Component&lt;/li&gt;
&lt;li&gt;데이터 가져오기&lt;/li&gt;
&lt;li&gt;폼과 서버 액션 입문&lt;/li&gt;
&lt;li&gt;정리 + 다음 학습 로드맵&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/781</guid>
      <comments>https://aamoos.tistory.com/781#entry781comment</comments>
      <pubDate>Wed, 29 Jul 2026 14:41:08 +0900</pubDate>
    </item>
    <item>
      <title># [Next.js + TS] 2. 프로젝트 만들고 폴더 구조 이해하기</title>
      <link>https://aamoos.tistory.com/780</link>
      <description>&lt;h1&gt;[Next.js + TS] 2. 프로젝트 만들고 폴더 구조 이해하기&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편에서 &amp;ldquo;왜 Next + TS인지&amp;rdquo;를 봤다면, 이번 편은 &lt;b&gt;실제로 프로젝트를 띄우고 폴더를 열어보는&lt;/b&gt; 시간입니다.&lt;br /&gt;목표: &lt;code&gt;create-next-app&lt;/code&gt; &amp;rarr; 실행 &amp;rarr; &lt;code&gt;app/page.tsx&lt;/code&gt; 문구 바꾸기까지.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 할 일&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Next.js + TypeScript 프로젝트 생성&lt;/li&gt;
&lt;li&gt;로컬에서 실행해 보기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app/&lt;/code&gt; 폴더 구조 읽기&lt;/li&gt;
&lt;li&gt;홈 화면 문구를 바꿔 &amp;ldquo;여기가 페이지다&amp;rdquo;를 몸으로 익히기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치만 하고 끝내면 금방 까먹습니다.&lt;br /&gt;&lt;b&gt;한 줄이라도 직접 고쳐 보는 것&lt;/b&gt;이 이번 편의 실습입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사전 준비&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래가 있으면 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Node.js (LTS 권장, 18 이상)&lt;/li&gt;
&lt;li&gt;터미널 (PowerShell, cmd, 또는 VS Code/Cursor 터미널)&lt;/li&gt;
&lt;li&gt;코드 에디터&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전 확인:&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;node -v
npm -v&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자가 나오면 OK입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 원하는 폴더로 이동한 뒤:&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;npx create-next-app@latest memo-app&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문에 대략 이렇게 답하면 이 시리즈와 맞습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;질문&lt;/th&gt;
&lt;th&gt;추천 답&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ESLint?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tailwind CSS?&lt;/td&gt;
&lt;td&gt;Yes (있어도 되고, 없어도 됨)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/&lt;/code&gt; directory?&lt;/td&gt;
&lt;td&gt;No (처음엔 &lt;code&gt;app/&lt;/code&gt;이 루트에 있는 편이 단순)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App Router?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turbopack?&lt;/td&gt;
&lt;td&gt;Yes여도 무방&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;import alias (&lt;code&gt;@/*&lt;/code&gt;)?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성이 끝나면:&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;cd memo-app
npm run dev&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 &lt;code&gt;http://localhost:3000&lt;/code&gt; 을 엽니다.&lt;br /&gt;Next 기본 시작 화면이 보이면 성공입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 이 명령어인가&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;npx&lt;/code&gt;: 패키지를 전역 설치하지 않고 실행&lt;/li&gt;
&lt;li&gt;&lt;code&gt;create-next-app&lt;/code&gt;: Next 공식 스캐폴딩 도구&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@latest&lt;/code&gt;: 최신 템플릿 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 &lt;code&gt;package.json&lt;/code&gt;을 손으로 조립해도 되지만,&lt;br /&gt;처음에는 &lt;b&gt;공식 템플릿으로 시작하는 게&lt;/b&gt; 삽질이 적습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;폴더 구조, 지금 볼 것만&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 프로젝트에서 &lt;b&gt;지금은 이것만&lt;/b&gt; 보면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;memo-app/
  app/
    layout.tsx
    page.tsx
    globals.css
  public/
  package.json
  tsconfig.json
  next.config.ts   (또는 next.config.mjs)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;app/&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Router의 중심입니다.&lt;br /&gt;&lt;b&gt;페이지와 레이아웃이 여기 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;app/page.tsx&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/&lt;/code&gt; (홈) 화면입니다.&lt;br /&gt;이 파일의 return JSX가 브라우저에 보입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;app/layout.tsx&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 페이지를 감싸는 &lt;b&gt;공통 틀&lt;/b&gt;입니다.&lt;br /&gt;&lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, 공통 폰트, 나중에 넣을 헤더 같은 것이 여기로 옵니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;app/globals.css&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전역 스타일입니다.&lt;br /&gt;이번 시리즈에서는 깊게 안 들어가도 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;package.json&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크립트와 의존성 목록입니다.&lt;br /&gt;&lt;code&gt;npm run dev&lt;/code&gt;가 여기서 정의됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;tsconfig.json&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TypeScript 설정입니다.&lt;br /&gt;지금은 &amp;ldquo;TS가 켜져 있다&amp;rdquo; 정도만 알면 충분합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;public/&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지, 파비콘처럼 &lt;b&gt;그대로 서빙할 정적 파일&lt;/b&gt; 자리입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 파일 열어보기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) &lt;code&gt;app/layout.tsx&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대략 이런 형태입니다. (버전마다 세부 표현은 조금 다를 수 있음)&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export default function RootLayout({
  children,
}: Readonly&amp;lt;{
  children: React.ReactNode;
}&amp;gt;) {
  return (
    &amp;lt;html lang=&quot;en&quot;&amp;gt;
      &amp;lt;body&amp;gt;{children}&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;포인트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;children&lt;/code&gt; = 각 &lt;code&gt;page.tsx&lt;/code&gt;가 들어가는 자리&lt;/li&gt;
&lt;li&gt;홈이든 &lt;code&gt;/memos&lt;/code&gt;든, 결국 이 레이아웃 안에 렌더됩니다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) &lt;code&gt;app/page.tsx&lt;/code&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 템플릿에는 데모 UI가 잔뜩 들어 있습니다.&lt;br /&gt;우리는 &lt;b&gt;메모 앱의 홈&lt;/b&gt;으로 바꿀 겁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실습: 홈 화면 문구 바꾸기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/page.tsx&lt;/code&gt;를 아래처럼 간단하게 교체합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export default function HomePage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;Memo App&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;Next.js + TypeScript 시리즈 실습 프로젝트&amp;lt;/p&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 후 브라우저를 보면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;제목이 &lt;b&gt;Memo App&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;아래 설명 문구&lt;/li&gt;
&lt;/ul&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;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/page.tsx&lt;/code&gt;를 고치면 &lt;code&gt;/&lt;/code&gt;가 바뀐다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React에서 컴포넌트를 수정하는 감각과 같습니다.&lt;br /&gt;차이점은 &lt;b&gt;이 파일이 곧 라우트&lt;/b&gt;라는 점입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;layout&lt;/code&gt;과 &lt;code&gt;page&lt;/code&gt; 관계 (그림으로)&lt;/h2&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;layout.tsx
└─ page.tsx   &amp;larr; 지금 고친 홈&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 &lt;code&gt;/memos&lt;/code&gt;를 만들면:&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;layout.tsx
├─ page.tsx          &amp;rarr; /
└─ memos/page.tsx    &amp;rarr; /memos&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 헤더를 &lt;code&gt;layout.tsx&lt;/code&gt;에 넣으면 두 페이지에 다 보입니다.&lt;br /&gt;(헤더는 4편에서 본격적으로 다룹니다.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자주 하는 실수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 서버가 안 떠 있는데 브라우저만 새로고침&lt;/b&gt;&lt;br /&gt;&amp;rarr; 터미널에서 &lt;code&gt;npm run dev&lt;/code&gt;가 켜져 있는지 확인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 다른 폴더의 파일을 수정&lt;/b&gt;&lt;br /&gt;&amp;rarr; 지금 실행 중인 &lt;code&gt;memo-app&lt;/code&gt; 안의 &lt;code&gt;app/page.tsx&lt;/code&gt;인지 확인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 저장을 안 함&lt;/b&gt;&lt;br /&gt;&amp;rarr; 에디터에 흰 점(미저장) 표시가 있으면 Ctrl+S&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 포트가 이미 사용 중&lt;/b&gt;&lt;br /&gt;&amp;rarr; &lt;code&gt;localhost:3000&lt;/code&gt; 대신 터미널에 나온 다른 포트(예: 3001)로 접속&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Next 프로젝트는 &lt;code&gt;create-next-app&lt;/code&gt;으로 시작하는 게 가장이다&lt;/li&gt;
&lt;li&gt;App Router의 중심은 &lt;code&gt;app/&lt;/code&gt;이다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;page.tsx&lt;/code&gt; = 그 경로의 화면&lt;/li&gt;
&lt;li&gt;&lt;code&gt;layout.tsx&lt;/code&gt; = 화면들을 감싸는 공통 틀&lt;/li&gt;
&lt;li&gt;홈을 바꾸려면 &lt;code&gt;app/page.tsx&lt;/code&gt;를 고치면 된다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Next.js + TS] 3. TypeScript로 컴포넌트 타입 잡기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;props에 타입 붙이기&lt;/li&gt;
&lt;li&gt;메모 카드 같은 작은 컴포넌트 만들기&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Memo&lt;/code&gt; 타입을 실제로 화면에 연결하기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 편부터 &amp;ldquo;폴더만 있는 상태&amp;rdquo;에서 &lt;b&gt;타입이 있는 UI 조각&lt;/b&gt;으로 넘어갑니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 Next.js + TypeScript인가&lt;/li&gt;
&lt;li&gt;프로젝트 만들고 폴더 구조 이해하기 &amp;larr; 지금&lt;/li&gt;
&lt;li&gt;TypeScript로 컴포넌트 타입 잡기&lt;/li&gt;
&lt;li&gt;라우팅과 레이아웃&lt;/li&gt;
&lt;li&gt;Server Component vs Client Component&lt;/li&gt;
&lt;li&gt;데이터 가져오기&lt;/li&gt;
&lt;li&gt;폼과 서버 액션 입문&lt;/li&gt;
&lt;li&gt;정리 + 다음 학습 로드맵&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/780</guid>
      <comments>https://aamoos.tistory.com/780#entry780comment</comments>
      <pubDate>Wed, 29 Jul 2026 14:33:30 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js + TS] 1. 왜 Next.js + TypeScript인가</title>
      <link>https://aamoos.tistory.com/779</link>
      <description>&lt;h1&gt;[Next.js + TS] 1. 왜 Next.js + TypeScript인가&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React는 써 봤고, 이제 Next.js와 TypeScript를 &lt;b&gt;제대로&lt;/b&gt; 잡고 싶은 사람을 위한 시리즈입니다.&lt;br /&gt;App Router 기준으로 작은 &lt;b&gt;메모 앱&lt;/b&gt;을 만들며 개념과 실습을 같이 갑니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 시리즈에서 하는 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React만으로도 화면은 만들 수 있습니다.&lt;br /&gt;다만 실제 서비스에 가까워질수록 이런 질문이 생깁니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;페이지(라우팅)는 어떻게 나눌까?&lt;/li&gt;
&lt;li&gt;SEO / 초기 로딩은?&lt;/li&gt;
&lt;li&gt;서버에서 데이터를 가져오는 게 나을까, 클라이언트에서 할까?&lt;/li&gt;
&lt;li&gt;props랑 API 응답 타입이 자꾸 틀어지는데?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Next.js&lt;/b&gt;는 React 위에 &lt;b&gt;앱에 필요한 뼈대&lt;/b&gt;를 올려 준 프레임워크이고,&lt;br /&gt;&lt;b&gt;TypeScript&lt;/b&gt;는 그 뼈대와 데이터를 &lt;b&gt;타입이 있는 계약&lt;/b&gt;으로 붙잡아 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시리즈는 이론만 늘어놓지 않습니다.&lt;br /&gt;매 편 &lt;b&gt;왜 쓰는지 &amp;rarr; 핵심 개념 &amp;rarr; 따라 치는 코드 &amp;rarr; 정리&lt;/b&gt; 순서로 갑니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;React만 쓸 때 vs Next.js&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;React (CRA/Vite 등)&lt;/th&gt;
&lt;th&gt;Next.js (App Router)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;역할&lt;/td&gt;
&lt;td&gt;UI 라이브러리&lt;/td&gt;
&lt;td&gt;React 기반 풀스택 프레임워크&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;라우팅&lt;/td&gt;
&lt;td&gt;react-router 등을 직접 구성&lt;/td&gt;
&lt;td&gt;폴더 구조로 페이지 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;렌더링&lt;/td&gt;
&lt;td&gt;기본적으로 클라이언트 중심&lt;/td&gt;
&lt;td&gt;서버/클라이언트 컴포넌트를 나눔&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터&lt;/td&gt;
&lt;td&gt;useEffect + fetch가 흔함&lt;/td&gt;
&lt;td&gt;서버에서 fetch, Server Actions 등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;배포 관점&lt;/td&gt;
&lt;td&gt;SPA로 시작하는 경우가 많음&lt;/td&gt;
&lt;td&gt;페이지/레이아웃 단위로 최적화하기 쉬움&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 줄로 말하면:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React = 부품&lt;br /&gt;Next.js = 그 부품으로 집을 짓는 설계도 + 공구&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React를 안다면 JSX, props, state, hooks는 그대로 씁니다.&lt;br /&gt;Next.js는 그 위에 &lt;b&gt;파일 기반 라우팅&lt;/b&gt;, &lt;b&gt;서버 컴포넌트&lt;/b&gt;, &lt;b&gt;데이터/폼 처리&lt;/b&gt; 같은 규칙을 더합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 TypeScript를 같이 쓰나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TypeScript는 &amp;ldquo;어려운 문법 모음&amp;rdquo;이 아니라, &lt;b&gt;실수 예방용 안전벨트&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 메모 객체:&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;type Memo = {
  id: string;
  title: string;
  content: string;
  createdAt: string;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 정해 두면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;memo.titlle&lt;/code&gt; 같은 오타를 에디터가 바로 잡아 줍니다&lt;/li&gt;
&lt;li&gt;컴포넌트 props가 뭐가 필요한지 읽지 않아도 보입니다&lt;/li&gt;
&lt;li&gt;API 응답 형태가 바뀌면 쓰는 쪽이 깨져서 빨리 알아챕니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React만 쓸 때도 TS는 유용하지만,&lt;br /&gt;Next.js처럼 &lt;b&gt;페이지 &amp;middot; 레이아웃 &amp;middot; 서버/클라이언트 경계&lt;/b&gt;가 늘어날수록 타입의 이득이 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시리즈에서는 고급 타입 마법을 하지 않습니다.&lt;br /&gt;&lt;b&gt;props, 데이터 모델, 함수 인자/반환&lt;/b&gt; 정도만 확실하게 잡습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;App Router를 한눈에&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 13 이후 기본은 &lt;b&gt;App Router&lt;/b&gt;입니다.&lt;br /&gt;핵심은 단순합니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;app/
  layout.tsx   &amp;rarr; 공통 레이아웃 (헤더, 폰트 등)
  page.tsx     &amp;rarr; &quot;/&quot; 페이지
  memos/
    page.tsx   &amp;rarr; &quot;/memos&quot; 목록
    [id]/
      page.tsx &amp;rarr; &quot;/memos/123&quot; 상세&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;폴더 이름&lt;/b&gt;이 URL이 됩니다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;page.tsx&lt;/code&gt;가 있어야 그 경로가 페이지로 열립니다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;layout.tsx&lt;/code&gt;는 하위 페이지를 감싸는 공통 UI입니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 다룰 &lt;b&gt;Server Component / Client Component&lt;/b&gt;도 이 구조 위에서 이해하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 이것만 기억하면 충분합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;URL = 폴더&lt;br /&gt;화면 = &lt;code&gt;page.tsx&lt;/code&gt;&lt;br /&gt;공통 틀 = &lt;code&gt;layout.tsx&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실습 예고: 메모 앱&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로 만들 것은 거창한 SaaS가 아닙니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;메모 목록 보기&lt;/li&gt;
&lt;li&gt;메모 상세 보기&lt;/li&gt;
&lt;li&gt;메모 추가하기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 작은 흐름 안에 라우팅, 타입, 서버/클라이언트, 데이터, 폼이 다 들어갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;예제용 코드 조각&amp;rdquo;보다 &lt;b&gt;이어지는 앱 하나&lt;/b&gt;로 가면, 복습할 때 훨씬 편합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 편에서 가져갈 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Next.js는 React를 대체하는 게 아니라, React로 앱을 만들기 위한 프레임워크다&lt;/li&gt;
&lt;li&gt;TypeScript는 문법이 목적이 아니라, 데이터와 props의 계약을 고정한다&lt;/li&gt;
&lt;li&gt;App Router는 폴더 = URL, &lt;code&gt;page&lt;/code&gt; = 화면, &lt;code&gt;layout&lt;/code&gt; = 공통 틀이다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 편 예고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Next.js + TS] 2. 프로젝트 만들고 폴더 구조 이해하기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;create-next-app&lt;/code&gt;으로 프로젝트 생성&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app/&lt;/code&gt;, &lt;code&gt;page.tsx&lt;/code&gt;, &lt;code&gt;layout.tsx&lt;/code&gt; 직접 열어보기&lt;/li&gt;
&lt;li&gt;첫 화면 문구를 바꿔 보며 &amp;ldquo;여기가 홈이다&amp;rdquo;를 몸으로 익히기&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈 목차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 Next.js + TypeScript인가 &amp;larr; 지금&lt;/li&gt;
&lt;li&gt;프로젝트 만들고 폴더 구조 이해하기&lt;/li&gt;
&lt;li&gt;TypeScript로 컴포넌트 타입 잡기&lt;/li&gt;
&lt;li&gt;라우팅과 레이아웃&lt;/li&gt;
&lt;li&gt;Server Component vs Client Component&lt;/li&gt;
&lt;li&gt;데이터 가져오기&lt;/li&gt;
&lt;li&gt;폼과 서버 액션 입문&lt;/li&gt;
&lt;li&gt;정리 + 다음 학습 로드맵&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문이나 &amp;ldquo;이 부분 더 풀어서&amp;rdquo; 싶은 구간 있으면 댓글로 남겨 주세요.&lt;br /&gt;다음 편에서 실제로 프로젝트를 띄웁니다.&lt;/p&gt;</description>
      <category>frontend/NextJs</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/779</guid>
      <comments>https://aamoos.tistory.com/779#entry779comment</comments>
      <pubDate>Wed, 29 Jul 2026 14:26:11 +0900</pubDate>
    </item>
    <item>
      <title># 면접 CS 정리 Day 1 (풀스택 / Java&amp;middot;Spring&amp;middot;React)</title>
      <link>https://aamoos.tistory.com/778</link>
      <description>&lt;h1&gt;면접 CS 정리 Day 1 (풀스택 / Java&amp;middot;Spring&amp;middot;React)&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q1. PUT vs PATCH&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;HTTP &lt;code&gt;PUT&lt;/code&gt;과 &lt;code&gt;PATCH&lt;/code&gt;의 차이, 실무에서 각각 언제 쓰는가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;PUT&lt;/code&gt;: 리소스 &lt;b&gt;전체 교체&lt;/b&gt;. 빠진 필드가 있으면 null로 덮일 수 있음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PATCH&lt;/code&gt;: &lt;b&gt;부분 수정&lt;/b&gt;. 요청으로 온 필드만 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성은 보통 &lt;code&gt;POST&lt;/code&gt;, 전체 수정은 &lt;code&gt;PUT&lt;/code&gt;, 일부 수정은 &lt;code&gt;PATCH&lt;/code&gt;.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q2. 생성자 주입을 선호하는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;Spring에서 필드 주입(&lt;code&gt;@Autowired&lt;/code&gt; on field)보다 생성자 주입을 선호하는 이유는?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;테스트 시 mock 객체를 생성자로 넣기 쉽다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;final&lt;/code&gt;로 필수 의존성을 명확히 하고 불변으로 유지할 수 있다&lt;/li&gt;
&lt;li&gt;의존성 누락 시 객체 생성이 실패해서 문제를 빨리 발견한다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;br /&gt;생성자 주입이 순환참조를 &amp;ldquo;막아 주는&amp;rdquo; 것은 아니다.&lt;br /&gt;순환이면 기동 시 바로 실패해서 설계를 고치게 만든다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q3. 인덱스가 있는데도 느린 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;인덱스를 만들었는데도 쿼리가 느릴 때 의심할 원인 2가지는?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;조건 때문에 &lt;b&gt;인덱스를 못 탐&lt;/b&gt; (함수 감싸기, &lt;code&gt;LIKE '%keyword'&lt;/code&gt;, 타입 불일치)&lt;/li&gt;
&lt;li&gt;선택도가 낮거나, 가져와야 할 행이 너무 많음&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행계획(&lt;code&gt;EXPLAIN&lt;/code&gt;)에서 &lt;code&gt;type = ALL&lt;/code&gt;, &lt;code&gt;key = NULL&lt;/code&gt; 여부를 먼저 본다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q4. GET으로 데이터를 바꾸면 안 되는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;왜 &lt;code&gt;GET&lt;/code&gt;으로 서버 상태를 변경하면 안 되는가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;GET&lt;/code&gt;은 &lt;b&gt;safe&lt;/b&gt;해야 한다 &amp;rarr; 조회만, 상태 변경 X&lt;/li&gt;
&lt;li&gt;브라우저 프리페치, 크롤러, 캐시가 &lt;code&gt;GET&lt;/code&gt;을 자동 호출할 수 있음&lt;/li&gt;
&lt;li&gt;URL/로그/히스토리에 남아 의도치 않은 변경&amp;middot;노출이 생길 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경은 &lt;code&gt;POST&lt;/code&gt; / &lt;code&gt;PUT&lt;/code&gt; / &lt;code&gt;PATCH&lt;/code&gt; / &lt;code&gt;DELETE&lt;/code&gt;를 사용한다.&lt;br /&gt;(&lt;code&gt;POST&lt;/code&gt;가 무조건 더 보안에 강하다기보다, &lt;b&gt;메서드 의미&lt;/b&gt;가 다르다.)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q5. React list의 key&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;리스트 렌더링에서 &lt;code&gt;key&lt;/code&gt;에 &lt;code&gt;id&lt;/code&gt;를 쓰고 &lt;code&gt;index&lt;/code&gt;를 잘 안 쓰는 이유는?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;br /&gt;&lt;code&gt;key&lt;/code&gt;는 React가 어떤 항목을 재사용/추가/삭제할지 구분하는 식별자다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;index&lt;/code&gt;를 key로 쓰면 삽입&amp;middot;삭제&amp;middot;순서 변경 시 항목이 잘못 매칭되어&lt;br /&gt;input 값이 밀리거나 불필요한 리렌더가 발생할 수 있다.&lt;br /&gt;그래서 고유한 &lt;code&gt;id&lt;/code&gt;를 사용한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q6. 트랜잭션 전파 REQUIRED&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;&lt;code&gt;@Transactional&lt;/code&gt;의 기본 propagation &lt;code&gt;REQUIRED&lt;/code&gt;는&lt;br /&gt;트랜잭션이 있을 때 / 없을 때 각각 어떻게 동작하는가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션 &lt;b&gt;없음&lt;/b&gt; &amp;rarr; 새로 시작&lt;/li&gt;
&lt;li&gt;트랜잭션 &lt;b&gt;있음&lt;/b&gt; &amp;rarr; 기존 트랜잭션에 &lt;b&gt;참여&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 줄: &lt;b&gt;있으면 쓰고, 없으면 만든다.&lt;/b&gt;&lt;br /&gt;보너스: &lt;code&gt;REQUIRES_NEW&lt;/code&gt;는 기존 트랜잭션과 분리해 무조건 새 트랜잭션을 연다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q7. CORS&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;br /&gt;CORS가 무엇인가? Spring에서 정석으로 어떻게 해결하는가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;&lt;br /&gt;브라우저가 &lt;b&gt;다른 Origin&lt;/b&gt;(스킴 + 호스트 + 포트)으로 요청할 때&lt;br /&gt;보안상 제한하는 정책이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 허용 Origin/메서드/헤더를 설정한다.&lt;br /&gt;(Spring: &lt;code&gt;CorsConfiguration&lt;/code&gt;, Security CORS 등)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 개발에서는 프론트 개발 서버 프록시로 우회하기도 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;오늘 한 줄 암기&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;PUT = 전체 교체 / PATCH = 부분 수정&lt;/li&gt;
&lt;li&gt;GET = 조회만 (safe)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;REQUIRED&lt;/code&gt; = 있으면 참여, 없으면 생성&lt;/li&gt;
&lt;li&gt;React &lt;code&gt;key&lt;/code&gt; = 고유 id&lt;/li&gt;
&lt;li&gt;CORS = Origin이 다를 때 브라우저 제한&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>기타/CS 질문</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/778</guid>
      <comments>https://aamoos.tistory.com/778#entry778comment</comments>
      <pubDate>Wed, 29 Jul 2026 14:12:47 +0900</pubDate>
    </item>
    <item>
      <title>Claude Code / Cursor 개발 필수 스킬 4가지 설정법</title>
      <link>https://aamoos.tistory.com/777</link>
      <description>&lt;h1&gt;Claude Code / Cursor 개발 필수 스킬 4가지 설정법&lt;/h1&gt;
&lt;p&gt;AI 코딩 에이전트(Claude Code, Cursor)를 쓸 때 &amp;quot;그냥 시키는 대로 코드를 뱉는&amp;quot; 상태에서 벗어나게 해주는 스킬 4개를 정리했다. 각 스킬은 서로 다른 축을 커버하기 때문에 겹치지 않고 같이 쓸 수 있다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;스킬&lt;/th&gt;
&lt;th&gt;관여하는 영역&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Karpathy Guidelines&lt;/td&gt;
&lt;td&gt;사고방식 전반 (가정 명시, 최소 수정, 검증 기준)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ponytail&lt;/td&gt;
&lt;td&gt;코드량 (과잉설계 방지, YAGNI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caveman&lt;/td&gt;
&lt;td&gt;말투/응답 토큰량 (불필요한 설명 압축)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Superpowers&lt;/td&gt;
&lt;td&gt;개발 프로세스 자체 (기획 → 계획 → TDD → 검증)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;1. Karpathy Guidelines&lt;/h2&gt;
&lt;p&gt;Andrej Karpathy가 지적한 &amp;quot;LLM 코딩의 흔한 실수 패턴&amp;quot;을 CLAUDE.md 파일 하나로 방지하는 원칙 모음이다. 별도 프로그램이 아니라 순수 텍스트 파일이라 이식성이 가장 좋다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;핵심 원칙&lt;/strong&gt;&lt;/p&gt;
&lt;ul&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;p&gt;&lt;strong&gt;Claude Code 설정&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -o CLAUDE.md https://raw.githubusercontent.com/forrestchang/andrej-karpathy-skills/main/CLAUDE.md&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;프로젝트 루트에 두면 세션 시작 시 자동으로 읽힌다. 이미 CLAUDE.md가 있다면 덮어쓰지 말고 뒤에 이어붙이기(append) 하는 것을 권장한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cursor 설정&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p .cursor/rules
curl -o .cursor/rules/karpathy.mdc https://raw.githubusercontent.com/forrestchang/andrej-karpathy-skills/main/CLAUDE.md&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;프로젝트 전체가 아니라 모든 프로젝트에 적용하고 싶다면 홈 디렉토리의 &lt;code&gt;~/.cursor/rules/&lt;/code&gt;에 넣거나, Cursor Settings → Rules → User Rules에 내용을 붙여넣으면 된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;커스터마이징&lt;/strong&gt;&lt;br&gt;받은 파일 그대로 쓰지 말고 스택, 명령어, 컨벤션, 금지사항을 추가해서 120줄 이내로 다듬는 것이 좋다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. Ponytail&lt;/h2&gt;
&lt;p&gt;&amp;quot;이 코드 진짜 필요해?&amp;quot;를 강제로 묻는 YAGNI 결정 사다리 스킬이다. 과잉설계 함정(예: 날짜 피커 라이브러리 대신 &lt;code&gt;&amp;lt;input type=&amp;quot;date&amp;quot;&amp;gt;&lt;/code&gt;)을 잡아내는 데 특화되어 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Claude Code 설정&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/plugin marketplace add DietrichGebert/ponytail
/plugin add ponytail@ponytail&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;설치 후 첫 세션에서 hook 신뢰(trust) 여부를 확인하라는 프롬프트가 뜨면 승인한다. node가 PATH에 잡혀 있어야 정상 작동하며, 정상 작동 시 상태줄에 &lt;code&gt;[PONYTAIL]&lt;/code&gt; 뱃지가 표시된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cursor 설정&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p .cursor/rules
# 저장소의 .cursor/rules 안 규칙 파일을 복사&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude Code의 hook 기반 자동 활성화·상태줄 표시는 재현되지 않지만, 핵심 규칙 내용은 동일하게 적용된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;리뷰 기능&lt;/strong&gt;&lt;br&gt;코드 작성 후 &lt;code&gt;/ponytail-review&lt;/code&gt;를 입력하면 과잉설계된 부분만 짚어준다. 단, 버그·보안 이슈는 다루지 않으므로 일반 코드 리뷰와 병행해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. Caveman&lt;/h2&gt;
&lt;p&gt;Claude의 응답 자체를 압축하는 스킬이다. 코드를 줄이는 게 아니라 서론·반복 설명·과한 안내문 같은 텍스트를 없애 출력 토큰을 줄인다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;설치 (Skills CLI, 여러 툴 공유 가능)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx skills add https://github.com/juliusbrussee/caveman --skill caveman -g&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;글로벌(&lt;code&gt;-g&lt;/code&gt;) 설치 시 여러 호환 툴이 공유하는 폴더에 들어가 Cursor 등에서도 인식될 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cursor 개별 설정&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p .cursor/rules
# 저장소 규칙 파일을 .cursor/rules/에 복사&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;참고&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;lite / full / ultra 강도를 선택해서 저장 가능&lt;/li&gt;
&lt;li&gt;위험한 작업(테이블 삭제 등)에서는 자동으로 정상 모드로 돌아옴&lt;/li&gt;
&lt;li&gt;팀원과 세션을 공유하거나 PR 코멘트로 남길 계획이면 lite 모드를 권장&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;4. Superpowers&lt;/h2&gt;
&lt;p&gt;코드 스타일이 아니라 &lt;strong&gt;개발 프로세스 전체&lt;/strong&gt;를 강제하는 프레임워크다. Jesse Vincent(obra)가 만들었으며 14개 스킬이 세트로 묶여 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;핵심 흐름&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brainstorming(요구사항 탐색) → writing-plans(계획 작성)
→ TDD(실패하는 테스트 먼저 작성) → 실행 → verification(완료 검증)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Claude Code 설정&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/plugin install superpowers@claude-plugins-official&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Cursor 설정&lt;/strong&gt;&lt;br&gt;Cursor 마켓플레이스에서 &amp;quot;Superpowers&amp;quot; 검색 후 설치하거나, 채팅창에 아래 명령 입력:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/add-plugin superpowers&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Cursor 팀이 공식 인증(Verified)한 몇 안 되는 플러그인이라 설치가 가장 간단하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;설치 확인 방법&lt;/strong&gt;&lt;br&gt;새 기능을 요청했을 때 AI가 바로 코드를 쓰지 않고 요구사항을 먼저 되묻는다면 정상 작동 중인 것이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;스코프(적용 범위) 정리&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;Claude Code&lt;/th&gt;
&lt;th&gt;Cursor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;전역(모든 프로젝트)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.claude/skills/&lt;/code&gt; 또는 플러그인 설치&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.cursor/rules/&lt;/code&gt; 또는 Settings → User Rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프로젝트 단위&lt;/td&gt;
&lt;td&gt;프로젝트 루트 &lt;code&gt;CLAUDE.md&lt;/code&gt; 또는 &lt;code&gt;./.claude/skills/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;프로젝트 루트 &lt;code&gt;.cursor/rules/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;플러그인/스킬은 한 번 설정해두면 이후 세션마다 자동으로 로드되며, 매번 다시 설정할 필요는 없다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;추천 조합&lt;/h2&gt;
&lt;p&gt;넷 다 무조건 설치하기보다는 필요에 따라 조합하는 것이 좋다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;기본 3종&lt;/strong&gt;: Karpathy + Ponytail + (Claude Code 내장) &lt;code&gt;/code-review&lt;/code&gt; — 사고방식 → 코드 최소화 → 검증의 파이프라인 구성&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;프로세스까지 강제하고 싶다면&lt;/strong&gt;: Superpowers 추가 — 다만 Karpathy의 검증 기준 원칙과 일부 겹치므로 굳이 둘 다 넣을 필요는 없음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;토큰 비용이 부담될 때만&lt;/strong&gt;: Caveman 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;스킬 폴더는 dotfiles처럼 작고 계속 줄어드는 방향으로 관리하는 것이 이상적이다. 안 쓰는 스킬은 지우고, 부족한 부분은 직접 CLAUDE.md에 써넣는 것이 장기적으로 더 낫다.&lt;/p&gt;</description>
      <category>AI</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/777</guid>
      <comments>https://aamoos.tistory.com/777#entry777comment</comments>
      <pubDate>Wed, 22 Jul 2026 16:45:44 +0900</pubDate>
    </item>
    <item>
      <title>git 수정한 파일이 없는데 intelij에 staging에 올라가있는경우</title>
      <link>https://aamoos.tistory.com/776</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;intelij를 사용하다가 상단에 Contents have differences only in line separators 알림이 보이고, staging에 수정하지 않은 파일들이 올라갔을경우 아래 명령어를 터미널에 입력하여 해결합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779860902243&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#1. Git core.autocrlf 설정 (권장)
git config --global core.autocrlf true

#2. 현재 스테이징된 찌꺼기 파일들 한 번에 정리하기
git add --renormalize .&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;429&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/T46F4/dJMcacpKbha/rj0fZ08QwQAqTsg2ncEhk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/T46F4/dJMcacpKbha/rj0fZ08QwQAqTsg2ncEhk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/T46F4/dJMcacpKbha/rj0fZ08QwQAqTsg2ncEhk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FT46F4%2FdJMcacpKbha%2Frj0fZ08QwQAqTsg2ncEhk0%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;429&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;429&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>backend/오류모음</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/776</guid>
      <comments>https://aamoos.tistory.com/776#entry776comment</comments>
      <pubDate>Wed, 27 May 2026 14:49:45 +0900</pubDate>
    </item>
    <item>
      <title>커서 AI 세팅</title>
      <link>https://aamoos.tistory.com/775</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;모델선택&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;216&quot; data-origin-height=&quot;259&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cemPIY/dJMcacCW6hC/ysJehpnNDwT7DxgASVUBZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cemPIY/dJMcacCW6hC/ysJehpnNDwT7DxgASVUBZ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cemPIY/dJMcacCW6hC/ysJehpnNDwT7DxgASVUBZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcemPIY%2FdJMcacCW6hC%2FysJehpnNDwT7DxgASVUBZ1%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;216&quot; height=&quot;259&quot; data-origin-width=&quot;216&quot; data-origin-height=&quot;259&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;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,0,0&quot;&gt;Claude 3.5 Sonnet:&lt;/b&gt; 현재 코딩에서 가장 똑똑하다고 평가받는 모델입니다. 기본으로 설정하는 걸 추천합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,1,0&quot;&gt;GPT-4o:&lt;/b&gt; 논리적인 설명이나 일반적인 질문에 강점이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;필수 성능 세팅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.cursorignore&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 프로젝트 루트에 해당 파일을 생성후 cursor가 인덱싱 하지 않을 파일을 설정합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1777441093188&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 빌드 결과물 제외
dist/
build/

# 의존성 라이브러리 제외 (커서가 이미 구조를 알고 있으므로 인덱싱에서 제외하면 가벼워짐)
node_modules/

# 환경 변수 및 보안 파일 (가장 중요!)
.env
*.pem
keys/

# 로그 파일
*.log&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;.cursorrules&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 프로젝트 루트에 해당 파일을 생성후 사용자가 프롬프트를 적어 규칙을 지정함&lt;/p&gt;
&lt;pre id=&quot;code_1777441314452&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;- 모든 답변은 한국어로 친절하게 설명해줘.
- 코드 변경 전후의 핵심 차이점을 간단히 요약해줘.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Thinking 끄기&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;433&quot; data-origin-height=&quot;235&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FzJRN/dJMcabKPOcf/PCk21Pwt7DByTLrb7eo5KK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FzJRN/dJMcabKPOcf/PCk21Pwt7DByTLrb7eo5KK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FzJRN/dJMcabKPOcf/PCk21Pwt7DByTLrb7eo5KK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFzJRN%2FdJMcabKPOcf%2FPCk21Pwt7DByTLrb7eo5KK%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;433&quot; height=&quot;235&quot; data-origin-width=&quot;433&quot; data-origin-height=&quot;235&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Thinking을 켜놓으면 그만큼 토큰이 많이 소모됨, 정말 풀리지않는 문제일경우만 Thinking 키기&lt;/p&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;h3 data-ke-size=&quot;size23&quot;&gt;Chat 10개정도 쌓이면 새로운 Chat 열어서 하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Chat에 내용이 너무 많이 쌓이면 ai가 이전 대화도 읽어서 실행하기 때문에 그만큼 토큰도 많이 소모됨&lt;/p&gt;</description>
      <category>AI</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/775</guid>
      <comments>https://aamoos.tistory.com/775#entry775comment</comments>
      <pubDate>Wed, 29 Apr 2026 14:42:09 +0900</pubDate>
    </item>
    <item>
      <title>커서 AI 외부 데이터 수집차단 설정 방법</title>
      <link>https://aamoos.tistory.com/774</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;오른쪽 상단 톱니바퀴 버튼 클릭&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;217&quot; data-origin-height=&quot;33&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qbva5/dJMcaarA3q9/Wx4isXHk8ySKfsaKgoXoo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qbva5/dJMcaarA3q9/Wx4isXHk8ySKfsaKgoXoo0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qbva5/dJMcaarA3q9/Wx4isXHk8ySKfsaKgoXoo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqbva5%2FdJMcaarA3q9%2FWx4isXHk8ySKfsaKgoXoo0%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;217&quot; height=&quot;33&quot; data-origin-width=&quot;217&quot; data-origin-height=&quot;33&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;General탭에서 맨하단에 privacy를 Privacy Mode로 변경&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1226&quot; data-origin-height=&quot;237&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zIBNG/dJMcaiC8hNQ/NDJOtHOJlNefTVHAldZkaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zIBNG/dJMcaiC8hNQ/NDJOtHOJlNefTVHAldZkaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zIBNG/dJMcaiC8hNQ/NDJOtHOJlNefTVHAldZkaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzIBNG%2FdJMcaiC8hNQ%2FNDJOtHOJlNefTVHAldZkaK%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;1226&quot; height=&quot;237&quot; data-origin-width=&quot;1226&quot; data-origin-height=&quot;237&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;</description>
      <category>AI</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/774</guid>
      <comments>https://aamoos.tistory.com/774#entry774comment</comments>
      <pubDate>Wed, 29 Apr 2026 14:33:41 +0900</pubDate>
    </item>
    <item>
      <title>월배당 계산기 (자산 시뮬레이터)</title>
      <link>https://aamoos.tistory.com/773</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stock-ten-iota.vercel.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stock-ten-iota.vercel.app/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777010560153&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;월배당 자산 시뮬레이터&quot; data-og-description=&quot;월배당 ETF 배당 재투자와 장기 자산 성장을 계산해보세요. 일반계좌&amp;middot;ISA계좌별 세후 수익 시뮬레이션.&quot; data-og-host=&quot;stock-ten-iota.vercel.app&quot; data-og-source-url=&quot;https://stock-ten-iota.vercel.app/&quot; data-og-url=&quot;https://stock-ten-iota.vercel.app/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/1Qdyr/dJMb88e3eIA/aDMI9MA80jVZ7v4djh5ig1/img.png?width=1376&amp;amp;height=768&amp;amp;face=0_0_1376_768,https://scrap.kakaocdn.net/dn/p7Cdl/dJMb9aKHE6A/M5JnudcjgkaQctXas5JwE1/img.png?width=1376&amp;amp;height=768&amp;amp;face=0_0_1376_768&quot;&gt;&lt;a href=&quot;https://stock-ten-iota.vercel.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://stock-ten-iota.vercel.app/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/1Qdyr/dJMb88e3eIA/aDMI9MA80jVZ7v4djh5ig1/img.png?width=1376&amp;amp;height=768&amp;amp;face=0_0_1376_768,https://scrap.kakaocdn.net/dn/p7Cdl/dJMb9aKHE6A/M5JnudcjgkaQctXas5JwE1/img.png?width=1376&amp;amp;height=768&amp;amp;face=0_0_1376_768');&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;월배당 자산 시뮬레이터&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;월배당 ETF 배당 재투자와 장기 자산 성장을 계산해보세요. 일반계좌&amp;middot;ISA계좌별 세후 수익 시뮬레이션.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;stock-ten-iota.vercel.app&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1559&quot; data-origin-height=&quot;980&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Acq87/dJMcafzCny8/kiyk8ACkvkDCp0dEVJGnKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Acq87/dJMcafzCny8/kiyk8ACkvkDCp0dEVJGnKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Acq87/dJMcafzCny8/kiyk8ACkvkDCp0dEVJGnKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAcq87%2FdJMcafzCny8%2Fkiyk8ACkvkDCp0dEVJGnKk%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;1559&quot; height=&quot;980&quot; data-origin-width=&quot;1559&quot; data-origin-height=&quot;980&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;배당주에 관심이있어서 월배당 자산 시뮬레이터를 React, Typescript 바이브 코딩으로 만들어봤습니다.&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;h3 data-ke-size=&quot;size23&quot;&gt;사용법&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;336&quot; data-origin-height=&quot;165&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4nGH4/dJMcafl4Wyw/rhm2ZmjjldazpjXekzTfa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4nGH4/dJMcafl4Wyw/rhm2ZmjjldazpjXekzTfa0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4nGH4/dJMcafl4Wyw/rhm2ZmjjldazpjXekzTfa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4nGH4%2FdJMcafl4Wyw%2Frhm2ZmjjldazpjXekzTfa0%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;336&quot; height=&quot;165&quot; data-origin-width=&quot;336&quot; data-origin-height=&quot;165&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;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;326&quot; data-origin-height=&quot;417&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpSRka/dJMcaipzblU/sBXABwimkudbxjOC9ekGOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpSRka/dJMcaipzblU/sBXABwimkudbxjOC9ekGOk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpSRka/dJMcaipzblU/sBXABwimkudbxjOC9ekGOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcpSRka%2FdJMcaipzblU%2FsBXABwimkudbxjOC9ekGOk%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;326&quot; height=&quot;417&quot; data-origin-width=&quot;326&quot; data-origin-height=&quot;417&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가 버튼을 클릭해서 배당주의 종목명 티커 투자하고자하는 계좌, 가격 등을 입력합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;차트로 표현&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1159&quot; data-origin-height=&quot;1036&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bRL4iA/dJMb99MS3es/8xkRP4yEFbkNX3efRDTwZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bRL4iA/dJMb99MS3es/8xkRP4yEFbkNX3efRDTwZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bRL4iA/dJMb99MS3es/8xkRP4yEFbkNX3efRDTwZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbRL4iA%2FdJMb99MS3es%2F8xkRP4yEFbkNX3efRDTwZk%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;1159&quot; height=&quot;1036&quot; data-origin-width=&quot;1159&quot; data-origin-height=&quot;1036&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1148&quot; data-origin-height=&quot;569&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sdmmV/dJMcac3U90z/3jxCkWoTHm5kVYz4FRlkCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sdmmV/dJMcac3U90z/3jxCkWoTHm5kVYz4FRlkCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sdmmV/dJMcac3U90z/3jxCkWoTHm5kVYz4FRlkCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsdmmV%2FdJMcac3U90z%2F3jxCkWoTHm5kVYz4FRlkCK%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;1148&quot; height=&quot;569&quot; data-origin-width=&quot;1148&quot; data-origin-height=&quot;569&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;시뮬레이터이기 때문에 100프로 정확하지는 않습니다. 주식 가격이 계속 변동되고 배당금도 변하기때문에&lt;/p&gt;</description>
      <category>기타</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/773</guid>
      <comments>https://aamoos.tistory.com/773#entry773comment</comments>
      <pubDate>Fri, 24 Apr 2026 15:06:14 +0900</pubDate>
    </item>
    <item>
      <title>Intelij Smart Tomcat 설정 방법</title>
      <link>https://aamoos.tistory.com/772</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;간혹 프로젝트를 하다보면 Spring boot가 아닌 레거시 프로젝트를 진행할때가 있는데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 프로젝트를 실행하기 위해 smart tomcat을 설치하고 설정하는법을 포스팅 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;File - Settings&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;986&quot; data-origin-height=&quot;728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYXEvy/dJMcaf7j6gk/KlRCrJfxpIQtup0wRtMiI1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYXEvy/dJMcaf7j6gk/KlRCrJfxpIQtup0wRtMiI1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYXEvy/dJMcaf7j6gk/KlRCrJfxpIQtup0wRtMiI1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYXEvy%2FdJMcaf7j6gk%2FKlRCrJfxpIQtup0wRtMiI1%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;986&quot; height=&quot;728&quot; data-origin-width=&quot;986&quot; data-origin-height=&quot;728&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;- SmartTomcat 플러그인 installed&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;351&quot; data-origin-height=&quot;124&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/I6biV/dJMcacv2bEd/6lzZUkvRSTBe4xN5Fe5kdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/I6biV/dJMcacv2bEd/6lzZUkvRSTBe4xN5Fe5kdK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/I6biV/dJMcacv2bEd/6lzZUkvRSTBe4xN5Fe5kdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FI6biV%2FdJMcacv2bEd%2F6lzZUkvRSTBe4xN5Fe5kdK%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;124&quot; data-origin-width=&quot;351&quot; data-origin-height=&quot;124&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오른쪽 상단에 점 클릭후 Edit ... 클릭&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;282&quot; data-origin-height=&quot;567&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DcCI1/dJMcabcLWV9/COYxEMeoUGNiYTl190t8Vk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DcCI1/dJMcabcLWV9/COYxEMeoUGNiYTl190t8Vk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DcCI1/dJMcabcLWV9/COYxEMeoUGNiYTl190t8Vk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDcCI1%2FdJMcabcLWV9%2FCOYxEMeoUGNiYTl190t8Vk%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;282&quot; height=&quot;567&quot; data-origin-width=&quot;282&quot; data-origin-height=&quot;567&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 플러스 버튼 클릭후 Smart Tomcat 클릭&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;553&quot; data-origin-height=&quot;583&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sQ8hN/dJMcajoh3Ux/uBPuDaYWoqKoUaSAxiUHHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sQ8hN/dJMcajoh3Ux/uBPuDaYWoqKoUaSAxiUHHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sQ8hN/dJMcajoh3Ux/uBPuDaYWoqKoUaSAxiUHHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsQ8hN%2FdJMcajoh3Ux%2FuBPuDaYWoqKoUaSAxiUHHk%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;553&quot; height=&quot;583&quot; data-origin-width=&quot;553&quot; data-origin-height=&quot;583&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Tomcat Server: 오른쪽에 폴더모양 클릭후 pc에 설치한 Apache Tomcat을 선택해서 설정합니다.&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;527&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bI3uVB/dJMcadBByPj/Kammi92wAzqZbLZxwS09zk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bI3uVB/dJMcadBByPj/Kammi92wAzqZbLZxwS09zk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bI3uVB/dJMcadBByPj/Kammi92wAzqZbLZxwS09zk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbI3uVB%2FdJMcadBByPj%2FKammi92wAzqZbLZxwS09zk%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;940&quot; height=&quot;527&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;527&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;만약 apache tomcat이 없을경우&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 링크 접속후 download에서 받고자하는 버전의 톰캣을 다운로드합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://tomcat.apache.org/download-90.cgi&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://tomcat.apache.org/download-90.cgi&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;191&quot; data-origin-height=&quot;217&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cv1rzN/dJMcagkRuzN/KgL0RhAAlDEmH8kXc8pCD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cv1rzN/dJMcagkRuzN/KgL0RhAAlDEmH8kXc8pCD1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cv1rzN/dJMcagkRuzN/KgL0RhAAlDEmH8kXc8pCD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcv1rzN%2FdJMcagkRuzN%2FKgL0RhAAlDEmH8kXc8pCD1%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;191&quot; height=&quot;217&quot; data-origin-width=&quot;191&quot; data-origin-height=&quot;217&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;587&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/StcgI/dJMb990n93U/kL5UvNbztjFKnLpPb975m1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/StcgI/dJMb990n93U/kL5UvNbztjFKnLpPb975m1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/StcgI/dJMb990n93U/kL5UvNbztjFKnLpPb975m1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FStcgI%2FdJMb990n93U%2FkL5UvNbztjFKnLpPb975m1%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;587&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;587&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;- Context Path는 톰캣을 실행했을때 맨뒤에 처음에 붙는 root context 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;nbsp; Server Port는 8080포트로 띄우겠다 이런 뜻입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VM options는 톰캣에 환경변수를 설정할수 있는데, 저가 설정한거는 로컬에서 톰캣을 실행했을때, log4j2-dev.xml 개발 로그 설정 파일을 바라보겠다 이런 뜻입니다.&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;118&quot; data-origin-height=&quot;27&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpZVNM/dJMcaffe7nU/dzUQMF8Kp8NlqaLeNYYOP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpZVNM/dJMcaffe7nU/dzUQMF8Kp8NlqaLeNYYOP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpZVNM/dJMcaffe7nU/dzUQMF8Kp8NlqaLeNYYOP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcpZVNM%2FdJMcaffe7nU%2FdzUQMF8Kp8NlqaLeNYYOP0%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;118&quot; height=&quot;27&quot; data-origin-width=&quot;118&quot; data-origin-height=&quot;27&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 플레이 버튼 누르면 정상적으로 실행이 됩니다. 만약 안되면 build가 안되서 그럴경우가 대부분이므로, rebuild를 다시한후 실행하면 됩니다.&lt;/p&gt;</description>
      <category>backend</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/772</guid>
      <comments>https://aamoos.tistory.com/772#entry772comment</comments>
      <pubDate>Thu, 16 Apr 2026 15:48:32 +0900</pubDate>
    </item>
    <item>
      <title>Intelij 단축키 설정</title>
      <link>https://aamoos.tistory.com/771</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 검색 및 이동&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;shift 2번&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 파일설정 클래스등 모든 것을 검색&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;718&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/smf9B/dJMcagFcSz5/MTx74eFYqZ9Jabnhzlmi9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/smf9B/dJMcagFcSz5/MTx74eFYqZ9Jabnhzlmi9K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/smf9B/dJMcagFcSz5/MTx74eFYqZ9Jabnhzlmi9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fsmf9B%2FdJMcagFcSz5%2FMTx74eFYqZ9Jabnhzlmi9K%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;673&quot; height=&quot;718&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;718&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + N&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Java 클래스 파일을 이름으로 검색&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;665&quot; data-origin-height=&quot;709&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bORvVw/dJMcafTODvV/tZtSs2GaN8nkMGjzIwOLl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bORvVw/dJMcafTODvV/tZtSs2GaN8nkMGjzIwOLl0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bORvVw/dJMcafTODvV/tZtSs2GaN8nkMGjzIwOLl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbORvVw%2FdJMcafTODvV%2FtZtSs2GaN8nkMGjzIwOLl0%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;709&quot; data-origin-width=&quot;665&quot; data-origin-height=&quot;709&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + Shift + N&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span data-path-to-node=&quot;6,3,2,0&quot;&gt;모든 형식의 파일을 검색&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;667&quot; data-origin-height=&quot;716&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TfIxA/dJMcaf7j4ZO/I07KUfh3Ta5fTzWCNai4Wk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TfIxA/dJMcaf7j4ZO/I07KUfh3Ta5fTzWCNai4Wk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TfIxA/dJMcaf7j4ZO/I07KUfh3Ta5fTzWCNai4Wk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTfIxA%2FdJMcaf7j4ZO%2FI07KUfh3Ta5fTzWCNai4Wk%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;667&quot; height=&quot;716&quot; data-origin-width=&quot;667&quot; data-origin-height=&quot;716&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + E&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-path-to-node=&quot;6,4,2,0&quot;&gt;- 최근에 열었던 파일 리스트 확인&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;495&quot; data-origin-height=&quot;434&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NH9aN/dJMcabqmg6E/NTVLvCJcRFEYbSE73V12bK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NH9aN/dJMcabqmg6E/NTVLvCJcRFEYbSE73V12bK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NH9aN/dJMcabqmg6E/NTVLvCJcRFEYbSE73V12bK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNH9aN%2FdJMcabqmg6E%2FNTVLvCJcRFEYbSE73V12bK%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;495&quot; height=&quot;434&quot; data-origin-width=&quot;495&quot; data-origin-height=&quot;434&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + B&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 메서드나 변수가 정의된 곳으로 이동 (Go to Declaration)&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- test(); 에 커서를 두고 ctrl+B를 누르면 아래 test 정의된곳으로 커서가 감&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;487&quot; data-origin-height=&quot;412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bH8lAD/dJMcaf7j43R/MhKeX2Zv591Cf7VQrrw1oK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bH8lAD/dJMcaf7j43R/MhKeX2Zv591Cf7VQrrw1oK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bH8lAD/dJMcaf7j43R/MhKeX2Zv591Cf7VQrrw1oK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbH8lAD%2FdJMcaf7j43R%2FMhKeX2Zv591Cf7VQrrw1oK%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;487&quot; height=&quot;412&quot; data-origin-width=&quot;487&quot; data-origin-height=&quot;412&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 코드 편집&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + Space bar&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span data-path-to-node=&quot;10,1,2,0&quot;&gt;가장 기본적인 자동 완성&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;387&quot; data-origin-height=&quot;89&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dNnuAf/dJMcah5ay3t/K4iuOmfI3m4vAzkHNvZq30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dNnuAf/dJMcah5ay3t/K4iuOmfI3m4vAzkHNvZq30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dNnuAf/dJMcah5ay3t/K4iuOmfI3m4vAzkHNvZq30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdNnuAf%2FdJMcah5ay3t%2FK4iuOmfI3m4vAzkHNvZq30%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;387&quot; height=&quot;89&quot; data-origin-width=&quot;387&quot; data-origin-height=&quot;89&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + Shift + Enter&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span data-path-to-node=&quot;10,2,2,0&quot;&gt;세미콜론(;)이나 중괄호({})를 자동으로 채우고 줄바꿈&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + Alt + L&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-path-to-node=&quot;10,3,2,0&quot;&gt;- 설정된 포맷에 맞춰 코드를 깨끗하게 정리 (Reformat Code)&lt;/span&gt; &lt;span data-path-to-node=&quot;10,3,2,0&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span data-path-to-node=&quot;10,3,2,0&quot;&gt;Ctrl + D&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-path-to-node=&quot;10,3,2,0&quot;&gt;- 현재 커서가 있는 줄을 바로 아래에 복사 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + Y&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span data-path-to-node=&quot;10,5,2,0&quot;&gt;현재 커서가 있는 줄을 삭제&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + / (주석)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-path-to-node=&quot;10,6,2,0&quot;&gt;// 주석 처리 및 해제&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Alt + Enter&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span data-path-to-node=&quot;10,7,1,0&quot;&gt;&lt;/span&gt;&lt;span data-path-to-node=&quot;10,7,2,0&quot;&gt;에러 수정 제안이나 Quick Fix 실행 (가장 중요!)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span data-path-to-node=&quot;10,7,2,0&quot;&gt;3. 리팩토링&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Shift + F6&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;403&quot; data-origin-height=&quot;314&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/34Sir/dJMcafM4qzI/eA4YK45B1OFMzmGhxk3rZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/34Sir/dJMcafM4qzI/eA4YK45B1OFMzmGhxk3rZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/34Sir/dJMcafM4qzI/eA4YK45B1OFMzmGhxk3rZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F34Sir%2FdJMcafM4qzI%2FeA4YK45B1OFMzmGhxk3rZk%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;314&quot; data-origin-width=&quot;403&quot; data-origin-height=&quot;314&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 변수, 메서드, 클래스 이름을 전체적으로 안전하게 변경&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + Alt + V&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;127&quot; data-origin-height=&quot;39&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ApZDs/dJMcacpftho/4CxlSFQTYtMIo5e0RDvAaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ApZDs/dJMcacpftho/4CxlSFQTYtMIo5e0RDvAaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ApZDs/dJMcacpftho/4CxlSFQTYtMIo5e0RDvAaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FApZDs%2FdJMcacpftho%2F4CxlSFQTYtMIo5e0RDvAaK%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;127&quot; height=&quot;39&quot; data-origin-width=&quot;127&quot; data-origin-height=&quot;39&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;값을 먼저 입력하고 ctrl + Alt + V를 누르면 자동완성&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;277&quot; data-origin-height=&quot;43&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWqs7n/dJMcahKRVUp/AWO7nQO20NdEFnuSWaSx80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWqs7n/dJMcahKRVUp/AWO7nQO20NdEFnuSWaSx80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWqs7n/dJMcahKRVUp/AWO7nQO20NdEFnuSWaSx80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWqs7n%2FdJMcahKRVUp%2FAWO7nQO20NdEFnuSWaSx80%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;277&quot; height=&quot;43&quot; data-origin-width=&quot;277&quot; data-origin-height=&quot;43&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;span data-path-to-node=&quot;14,2,2,0&quot;&gt;리턴값이나 식을 변수로 추출 (Introduce Variable)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + Alt + M&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;281&quot; data-origin-height=&quot;23&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/7CiOF/dJMcaaLJST5/JCbwFhmqQGkFILHejUVKu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/7CiOF/dJMcaaLJST5/JCbwFhmqQGkFILHejUVKu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/7CiOF/dJMcaaLJST5/JCbwFhmqQGkFILHejUVKu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F7CiOF%2FdJMcaaLJST5%2FJCbwFhmqQGkFILHejUVKu0%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;281&quot; height=&quot;23&quot; data-origin-width=&quot;281&quot; data-origin-height=&quot;23&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;162&quot; data-origin-height=&quot;32&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bx3OhP/dJMcagyn7gM/Gd1M56Vg2HXrlycGF6SmEk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bx3OhP/dJMcagyn7gM/Gd1M56Vg2HXrlycGF6SmEk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bx3OhP/dJMcagyn7gM/Gd1M56Vg2HXrlycGF6SmEk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbx3OhP%2FdJMcagyn7gM%2FGd1M56Vg2HXrlycGF6SmEk%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;162&quot; height=&quot;32&quot; data-origin-width=&quot;162&quot; data-origin-height=&quot;32&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;416&quot; data-origin-height=&quot;72&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d724Ef/dJMb99MMWGP/9zf2fiiqvO74pn2Th1UK40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d724Ef/dJMb99MMWGP/9zf2fiiqvO74pn2Th1UK40/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d724Ef/dJMb99MMWGP/9zf2fiiqvO74pn2Th1UK40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd724Ef%2FdJMb99MMWGP%2F9zf2fiiqvO74pn2Th1UK40%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;72&quot; data-origin-width=&quot;416&quot; data-origin-height=&quot;72&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span data-path-to-node=&quot;14,3,2,0&quot;&gt;특정 코드 블록을 별도 메서드로 분리&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span data-path-to-node=&quot;14,2,2,0&quot;&gt;Ctrl + Alt + P&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-path-to-node=&quot;14,2,2,0&quot;&gt;- 메서드 내부의 값을 파라미터로 변경 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span data-path-to-node=&quot;14,2,2,0&quot;&gt;4. 디버깅 및 실행&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Shift + F10&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 프로젝트 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Shift + F9&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 디버깅 모드 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ctrl + F8&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 브레이크 포인트(Breakpoint) 설정/해제&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;F8 / F7&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Step Over (다음 줄) / Step Into (메서드 안으로)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 보너스&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Multi-Cursor&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Alt + Click 또는 Alt + Shift + Insert를 이용해 여러 줄을 동시에 수정할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;489&quot; data-origin-height=&quot;282&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lbiFb/dJMcaaSv69f/hgmFlOy3Yrs88ZPmstg8D0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lbiFb/dJMcaaSv69f/hgmFlOy3Yrs88ZPmstg8D0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lbiFb/dJMcaaSv69f/hgmFlOy3Yrs88ZPmstg8D0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlbiFb%2FdJMcaaSv69f%2FhgmFlOy3Yrs88ZPmstg8D0%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;489&quot; height=&quot;282&quot; data-origin-width=&quot;489&quot; data-origin-height=&quot;282&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Key Promoter X 플러그인&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;979&quot; data-origin-height=&quot;718&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TiUSq/dJMcaiXfEhe/6lP4irEfsvRs5ftmRqpCq0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TiUSq/dJMcaiXfEhe/6lP4irEfsvRs5ftmRqpCq0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TiUSq/dJMcaiXfEhe/6lP4irEfsvRs5ftmRqpCq0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTiUSq%2FdJMcaiXfEhe%2F6lP4irEfsvRs5ftmRqpCq0%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;979&quot; height=&quot;718&quot; data-origin-width=&quot;979&quot; data-origin-height=&quot;718&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;- 해당 플러그인 install을 하면 마우스 클릭시 오른쪽 하단 팝업으로 단축키를 알려줘서 자연스럽게 익힐수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 예를들면 ctrl + 마우스왼쪽 클릭으로 메서드를 클릭해서 들어갔을경우 Ctrl + B로 들어갈수 있다고 알려주네요&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;319&quot; data-origin-height=&quot;84&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dwJSOY/dJMcabYa9z4/EQE4MyymTwVx8jlTlvpSG1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dwJSOY/dJMcabYa9z4/EQE4MyymTwVx8jlTlvpSG1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dwJSOY/dJMcabYa9z4/EQE4MyymTwVx8jlTlvpSG1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdwJSOY%2FdJMcabYa9z4%2FEQE4MyymTwVx8jlTlvpSG1%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;319&quot; height=&quot;84&quot; data-origin-width=&quot;319&quot; data-origin-height=&quot;84&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;</description>
      <category>backend</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/771</guid>
      <comments>https://aamoos.tistory.com/771#entry771comment</comments>
      <pubDate>Thu, 16 Apr 2026 15:32:09 +0900</pubDate>
    </item>
    <item>
      <title>Error occurred during initialization of VMjava.lang.Error: Properties init: Could not determine current working directory.at jdk.internal.util.SystemProps$Raw.platformProperties(java.base@17.0.18/Native Method)at jdk.internal.util.SystemProps$Raw.&amp;lt;init&amp;gt;(j</title>
      <link>https://aamoos.tistory.com/770</link>
      <description>&lt;pre id=&quot;code_1771835112468&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Error occurred during initialization of VM
java.lang.Error: Properties init: Could not determine current working directory.
at jdk.internal.util.SystemProps$Raw.platformProperties(java.base@17.0.18/Native Method)
at jdk.internal.util.SystemProps$Raw.&amp;lt;init&amp;gt;(java.base@17.0.18/SystemProps.java:234)
at jdk.internal.util.SystemProps.initProperties(java.base@17.0.18/SystemProps.java:54)
at java.lang.System.initPhase1(java.base@17.0.18/System.java:2106)&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;ssh에서 java -version을 입력했을때 위와 같은 에러가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이경우 java 자체의 결함이기보단 현재 터미널에 위치한 디렉터리가 갑자기 삭제되었거나 그럴때 터미널이 위치한 폴더를 시스템이 찾지 못했을때 생기는 에러입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1771835205045&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cd /
java -version&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상위 폴더나 루트 디렉터리로 이동후 다시 입력을 해보면 정상적으로 java version이 표기가 됩니다.&lt;/p&gt;</description>
      <category>DevOps</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/770</guid>
      <comments>https://aamoos.tistory.com/770#entry770comment</comments>
      <pubDate>Mon, 23 Feb 2026 17:27:07 +0900</pubDate>
    </item>
    <item>
      <title>Vue3 기본문법</title>
      <link>https://aamoos.tistory.com/768</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. App.vue&lt;/h3&gt;
&lt;pre id=&quot;code_1768374292842&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;h1&amp;gt;Hello Vue 3&amp;lt;/h1&amp;gt;
&amp;lt;/template&amp;gt;

&amp;lt;style scoped&amp;gt;
&amp;lt;/style&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;p data-ke-size=&quot;size16&quot;&gt;- script setup -&amp;gt; Vue3 권장 문법&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- template -&amp;gt; 화면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- style scoped -&amp;gt; 이 컴포넌트에만 스타일 적용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 반응형 데이터 (ref)&lt;/h3&gt;
&lt;pre id=&quot;code_1768374430892&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { ref } from &quot;vue&quot;;

const count = ref(0);
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;h1&amp;gt;Count: {{ count }}&amp;lt;/h1&amp;gt;
&amp;lt;/template&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;- ref(값) -&amp;gt; 반응형&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- template에서는 .value 안씀&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 이벤트 바인딩 (click)&lt;/h3&gt;
&lt;pre id=&quot;code_1768374513020&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { ref } from &quot;vue&quot;;

const count = ref(0);

function increment(){
  count.value++;
}

&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;h1&amp;gt;Count: {{ count }}&amp;lt;/h1&amp;gt;
  &amp;lt;button @click=&quot;increment&quot; &amp;gt;+&amp;lt;/button&amp;gt;
&amp;lt;/template&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;- @click = v-on:click&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- script에서는 .value로 값을 뽑음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 버튼 클릭시 count를 1씩 증가&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;216&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Wkffz/dJMcaacXqBP/HpvMU17tkLk1GRkxk27Fp1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Wkffz/dJMcaacXqBP/HpvMU17tkLk1GRkxk27Fp1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Wkffz/dJMcaacXqBP/HpvMU17tkLk1GRkxk27Fp1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWkffz%2FdJMcaacXqBP%2FHpvMU17tkLk1GRkxk27Fp1%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;332&quot; height=&quot;216&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;216&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. v-model (양방향 바인딩)&lt;/h3&gt;
&lt;pre id=&quot;code_1768374663424&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { ref } from &quot;vue&quot;;

const name = ref(&quot;&quot;);
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;input v-model=&quot;name&quot; placeholder=&quot;이름 입력&quot; /&amp;gt;
  &amp;lt;p&amp;gt;안녕하세요, {{ name }}&amp;lt;/p&amp;gt;
&amp;lt;/template&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;- v-model = input &amp;lt;-&amp;gt; state 자동 연결&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- form에서 필수 문법&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;270&quot; data-origin-height=&quot;90&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c82i0M/dJMcaiPxTOU/8lwbC3Q7vdzYKKDfHsUsS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c82i0M/dJMcaiPxTOU/8lwbC3Q7vdzYKKDfHsUsS0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c82i0M/dJMcaiPxTOU/8lwbC3Q7vdzYKKDfHsUsS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc82i0M%2FdJMcaiPxTOU%2F8lwbC3Q7vdzYKKDfHsUsS0%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;270&quot; height=&quot;90&quot; data-origin-width=&quot;270&quot; data-origin-height=&quot;90&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 조건 렌더링 (v-if)&lt;/h3&gt;
&lt;pre id=&quot;code_1768374803883&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { ref } from &quot;vue&quot;;

const isLogin = ref(false);
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;button @click=&quot;isLogin = !isLogin&quot;&amp;gt;
    로그인 토글
  &amp;lt;/button&amp;gt;

  &amp;lt;p v-if=&quot;isLogin&quot;&amp;gt;로그인 상태&amp;lt;/p&amp;gt;
  &amp;lt;p v-else&amp;gt;로그아웃 상태&amp;lt;/p&amp;gt;
&amp;lt;/template&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;- 기본값 false이므로 처음 화면은 로그아웃 상태, 버튼 클릭시 로그인 상태로 변경됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 조간이 자주 바뀌면 v-show 고려,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. reactive vs ref&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- ref : 원시값 (number, string, boolean)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- reactive : 객체, 폼데이터&lt;/p&gt;
&lt;pre id=&quot;code_1768375150201&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { reactive, ref } from &quot;vue&quot;;

const count = ref(0);

const user = reactive({
  name: &quot;&quot;,
  age: 0,
})
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;p&amp;gt;count: {{ count }}&amp;lt;/p&amp;gt;

  &amp;lt;input v-model=&quot;user.name&quot; placeholder=&quot;이름&quot; /&amp;gt;
  &amp;lt;input v-model=&quot;user.age&quot; type=&quot;number&quot; placeholder=&quot;이름&quot; /&amp;gt;

  &amp;lt;p&amp;gt;{{ user.name }} // {{ user.age }}&amp;lt;/p&amp;gt;
&amp;lt;/template&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;- reactive는 .value 못씀&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 객체는 왠만하면 reactive&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. computed&lt;/h3&gt;
&lt;pre id=&quot;code_1768375296099&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { computed, ref } from &quot;vue&quot;;

const count = ref(0);

const doubleCount = computed(() =&amp;gt; {
  return count.value * 2;
})

&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;button @click=&quot;count++&quot;&amp;gt;&amp;lt;/button&amp;gt;
  &amp;lt;p&amp;gt;count: {{ count }}&amp;lt;/p&amp;gt;
  &amp;lt;p&amp;gt;double: {{ doubleCount }}&amp;lt;/p&amp;gt;
&amp;lt;/template&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 값 가공은 computed&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- computed는 캐싱효과가 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-1. computed 캐싱&lt;/h3&gt;
&lt;pre id=&quot;code_1768375476911&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { computed, ref } from &quot;vue&quot;;

const count = ref(0);

const doubleCount = computed(() =&amp;gt; {
  console.log(&quot;computed&quot;)
  return count.value * 2;
})

const test = () =&amp;gt; {
  console.log(&quot;method&quot;);
  return count.value * 2;
}

&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;button @click=&quot;count++&quot;&amp;gt;&amp;lt;/button&amp;gt;
  &amp;lt;p&amp;gt;count: {{ count }}&amp;lt;/p&amp;gt;
  &amp;lt;p&amp;gt;double: {{ doubleCount }}&amp;lt;/p&amp;gt;
  &amp;lt;p&amp;gt;double: {{ doubleCount }}&amp;lt;/p&amp;gt;
  &amp;lt;p&amp;gt;double: {{ test() }}&amp;lt;/p&amp;gt;
  &amp;lt;p&amp;gt;double: {{ test() }}&amp;lt;/p&amp;gt;
&amp;lt;/template&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;- 위에 코드를 실행후 network console로 확인을하면 computed로 선언한 함수는 2번 호출했는데 1번이 console로 찍히고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수를 2번 호출시는 2번 console이 찍힘&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;206&quot; data-origin-height=&quot;65&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oiI3X/dJMcacPnsap/iM4iJaYKa4BkKDbzS4FQmK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oiI3X/dJMcacPnsap/iM4iJaYKa4BkKDbzS4FQmK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oiI3X/dJMcacPnsap/iM4iJaYKa4BkKDbzS4FQmK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoiI3X%2FdJMcacPnsap%2FiM4iJaYKa4BkKDbzS4FQmK%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;206&quot; height=&quot;65&quot; data-origin-width=&quot;206&quot; data-origin-height=&quot;65&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;h3 data-ke-size=&quot;size23&quot;&gt;8. Watch&lt;/h3&gt;
&lt;pre id=&quot;code_1768375711243&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { ref, watch } from &quot;vue&quot;;

const count = ref(0);

watch(count, (newVal, oldVal) =&amp;gt; {
  console.log(&quot;변경 : &quot;, oldVal, &quot;-&amp;gt;&quot;, newVal);
})

&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;button @click=&quot;count++&quot;&amp;gt;&amp;lt;/button&amp;gt;
  &amp;lt;p&amp;gt;{{ count }}&amp;lt;/p&amp;gt;
&amp;lt;/template&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;- count 값이 변경될때 watch가 동작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. v-for&lt;/h3&gt;
&lt;pre id=&quot;code_1768375877869&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { ref } from &quot;vue&quot;;

const todos = ref([
  { id: 1, text: &quot;Vue 공부&quot; },
  { id: 2, text: &quot;TypeScript 공부&quot; },
]);
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;ul&amp;gt;
    &amp;lt;li v-for=&quot;todo in todos&quot; :key=&quot;todo.id&quot;&amp;gt;
      {{ todo.text }}
    &amp;lt;/li&amp;gt;
  &amp;lt;/ul&amp;gt;
&amp;lt;/template&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;- key 필수, id 없으면 에러&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9-1. computed + v-for&lt;/h3&gt;
&lt;pre id=&quot;code_1768375915327&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { ref, computed } from &quot;vue&quot;;

const todos = ref([
  { id: 1, text: &quot;Vue&quot;, done: true },
  { id: 2, text: &quot;TS&quot;, done: false },
]);

const doneTodos = computed(() =&amp;gt;
  todos.value.filter(t =&amp;gt; t.done)
);
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;h3&amp;gt;완료&amp;lt;/h3&amp;gt;
  &amp;lt;ul&amp;gt;
    &amp;lt;li v-for=&quot;t in doneTodos&quot; :key=&quot;t.id&quot;&amp;gt;
      {{ t.text }}
    &amp;lt;/li&amp;gt;
  &amp;lt;/ul&amp;gt;
&amp;lt;/template&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- todo 리스트에서 done이 true인 항목만 필터링해서 화면에 뿌림&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10. props &amp;amp; emit&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Parent.vue&lt;/h4&gt;
&lt;pre id=&quot;code_1768453312470&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup&amp;gt;
import { ref } from 'vue'
import Child from './Child.vue' // 자식 컴포넌트 불러오기

const myTitle = ref(&quot;부모가 주는 선물&quot;)
const myCount = ref(0)

// 자식이 보낸 신호를 받았을 때 실행할 함수
const handleIncrease = () =&amp;gt; {
  myCount.value++
  console.log(&quot;자식의 신호를 받았습니다!&quot;)
}
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;div style=&quot;border: 2px solid #35495e; padding: 20px;&quot;&amp;gt;
    &amp;lt;h1&amp;gt; &amp;zwj; &amp;zwj;  여기는 부모 컴포넌트&amp;lt;/h1&amp;gt;
    &amp;lt;p&amp;gt;현재 부모의 숫자: {{ myCount }}&amp;lt;/p&amp;gt;

    &amp;lt;Child 
      :childTitle=&quot;myTitle&quot;
      :count=&quot;myCount&quot;
      @increase-count=&quot;handleIncrease&quot; 
    /&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Child.vue&lt;/h4&gt;
&lt;pre id=&quot;code_1768453343809&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script setup&amp;gt;
// 1. Props 정의: 부모에게 받을 데이터 이름표를 만듭니다.
const props = defineProps({
  childTitle: String, // 타입 지정 (String, Number 등)
  count: Number
})

// 2. Emits 정의: 부모에게 보낼 신호 이름을 정합니다.
const emit = defineEmits(['increase-count'])

// 버튼 클릭 시 실행할 함수
const sendSignal = () =&amp;gt; {
  // 'increase-count'라는 신호를 부모에게 발사! (필요하면 두 번째 인자로 데이터 전달 가능)
  emit('increase-count')
}
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;div style=&quot;border: 2px solid #42b883; padding: 10px; margin: 10px;&quot;&amp;gt;
    &amp;lt;h3&amp;gt;  여기는 자식 컴포넌트&amp;lt;/h3&amp;gt;
    &amp;lt;p&amp;gt;받은 제목: {{ childTitle }}&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;받은 숫자: {{ count }}&amp;lt;/p&amp;gt;
    
    &amp;lt;button @click=&quot;sendSignal&quot;&amp;gt;숫자 올려달라고 조르기 (Emit)&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>frontend/Vue</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/768</guid>
      <comments>https://aamoos.tistory.com/768#entry768comment</comments>
      <pubDate>Thu, 15 Jan 2026 13:56:13 +0900</pubDate>
    </item>
    <item>
      <title>TypeScript 기본문법 정리</title>
      <link>https://aamoos.tistory.com/767</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;TypeScript = 자바스크립트에 타입을 붙인 것&lt;/p&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;- 협업 / 대규모 프로젝트 필수&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- TS는 &quot;문법&quot;이 아니라 &quot; 안전장치&quot;라고 생각하면됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 타입 붙이기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Javascript&lt;/h3&gt;
&lt;pre id=&quot;code_1768371959747&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let count = 0;
count = &quot;hello&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;h3 data-ke-size=&quot;size23&quot;&gt;Typescript&lt;/h3&gt;
&lt;pre id=&quot;code_1768372010563&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let count: number = 0;
count = &quot;hello&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;h3 data-ke-size=&quot;size23&quot;&gt;자주 쓰는 기본 타입&lt;/h3&gt;
&lt;pre id=&quot;code_1768372125237&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let userName: string = &quot;kim&quot;
let age: number = 30;
let isLogin: boolean = true;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Typescript 타입 추론&lt;/h3&gt;
&lt;pre id=&quot;code_1768372160975&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let price = 100;&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;- pice 뒤에 : number를 선언하지 않아도 값을 보고 자동으로 타입을 추론함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 배열 &amp;amp; 객체&lt;/h2&gt;
&lt;pre id=&quot;code_1768372213023&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let numbers: number[] = [1, 2, 3];
let users: string[] = [&quot;kim&quot;, &quot;lee&quot;];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. interface / type&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;interface&lt;/h3&gt;
&lt;pre id=&quot;code_1768372251862&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface User {
  id: number;
  email: string;
  isAdmin: boolean;
}

const user: User = {
  id: 1,
  email: &quot;test@test.com&quot;,
  isAdmin: false,
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-1 interface를 써야하는 상황&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;DTO / API 응답 구조&lt;/h4&gt;
&lt;pre id=&quot;code_1768373197190&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface UserDto {
  id: number;
  email: string;
  name: string;
}

interface ApiResponse&amp;lt;T&amp;gt; {
  success: boolean;
  data: T;
  message?: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Vue props 정의&lt;/h4&gt;
&lt;pre id=&quot;code_1768373256694&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface User {
  id: number;
  email: string;
}

const props = defineProps&amp;lt;{
  user: User;
  isAdmin: boolean;
}&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;implements&lt;/h3&gt;
&lt;pre id=&quot;code_1768373297015&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface LoginService {
  login(email: string, password: string): Promise&amp;lt;void&amp;gt;;
}

class AuthService implements LoginService {
  async login(email: string, password: string) {
    // ...
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;확장 (extends)&lt;/h4&gt;
&lt;pre id=&quot;code_1768373332121&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface BaseEntity {
  id: number;
  createdAt: string;
}

interface User extends BaseEntity {
  email: string;
}&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;h3 data-ke-size=&quot;size23&quot;&gt;type&lt;/h3&gt;
&lt;pre id=&quot;code_1768372306091&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type User = {
  id: number;
  email: string;
  isAdmin: boolean;
};&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;gt; interface&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;union, 함수 타입 -&amp;gt; type&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-2. type를 써야하는 상황&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Union 타입&lt;/h4&gt;
&lt;pre id=&quot;code_1768373401288&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type Status = &quot;idle&quot; | &quot;loading&quot; | &quot;error&quot;;
let status: Status = &quot;loading&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;함수 타입 정의&lt;/h4&gt;
&lt;pre id=&quot;code_1768373442379&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type LoginFn = (email: string, password: string) =&amp;gt; Promise&amp;lt;void&amp;gt;;
const login: LoginFn = async (email, password) =&amp;gt; {
  // ...
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;조합 타입 (intersection)&lt;/h4&gt;
&lt;pre id=&quot;code_1768373472115&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type WithToken = {
  accessToken: string;
};

type User = {
  id: number;
  email: string;
};

type AuthedUser = User &amp;amp; WithToken;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;제네릭 + 조건 타입&lt;/h4&gt;
&lt;pre id=&quot;code_1768373494794&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type ApiResult&amp;lt;T&amp;gt; = 
  T extends string ? { value: string } : { value: number };&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 함수 타입&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. 기본 함수&lt;/h3&gt;
&lt;pre id=&quot;code_1768372413935&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function add(a: number, b: number): number {
  return a + b;
}&lt;/code&gt;&lt;/pre&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;4-2. 화살표 함수&lt;/h3&gt;
&lt;pre id=&quot;code_1768372443722&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const add = (a: number, b: number): number =&amp;gt; {
  return a + b;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-3. return 없는 함수&lt;/h3&gt;
&lt;pre id=&quot;code_1768372461088&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function log(msg: string): void {
  console.log(msg);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Optional / Nullable&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-1. Optional&lt;/h3&gt;
&lt;pre id=&quot;code_1768372496988&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface User {
  id: number;
  name?: string; // 있어도 되고 없어도 됨
}

const u1: User = { id: 1 };
const u2: User = { id: 2, name: &quot;kim&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;- name뒤에 ?를 붙이면 값이 있을수도 있고 없을수도 있다라는 의미임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-2. null / undefined&lt;/h3&gt;
&lt;pre id=&quot;code_1768372543113&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let token: string | null = null;

token = &quot;abc&quot;;
token = null; // OK&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;- token 값의 타입이 string이거나 null일수 있다 라는 의미&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. Union 타입&lt;/h2&gt;
&lt;pre id=&quot;code_1768372593148&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let status: &quot;idle&quot; | &quot;loading&quot; | &quot;success&quot; | &quot;error&quot;;
status = &quot;loading&quot;;
status = &quot;done&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;- 상태값에 done을 선언하지 않아서 컴파일 에러&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. any&lt;/h2&gt;
&lt;pre id=&quot;code_1768372720272&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let data: any = 123;
data.toUpperCase(); // 런타임 에러&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;- any는 어떤타입이든 들어올수 있다라고 설정하는거여서, 사용하면 typescript 쓰는 의미가 없음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 123을 대문자로 바꾸려니 런타임 에러 발생&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. Unknown&lt;/h2&gt;
&lt;pre id=&quot;code_1768372815291&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let data: unknown;

if (typeof data === &quot;string&quot;) {
  data.toUpperCase();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- unknown은 타입을 아직 모른다는걸 typescript에게 솔직하게 말하는 타입&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. any vs unknown&lt;/h2&gt;
&lt;pre id=&quot;code_1768372935045&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let data: any;

data = 123;
data = &quot;hello&quot;;

data.toUpperCase();&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;- any로 선언을하면 toUpperCase 컴파일은 통과하지만, 런타임에서 에러가 발생함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768372970313&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let data: unknown;

data = 123;
data = &quot;hello&quot;;

data.toUpperCase();&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;- unknown으로 선언하면 toUpperCase 컴파일에서 에러가 발생함, 즉 unknown은 확인하고 쓰라는걸 강제함&lt;/p&gt;</description>
      <category>frontend/Typescript</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/767</guid>
      <comments>https://aamoos.tistory.com/767#entry767comment</comments>
      <pubDate>Wed, 14 Jan 2026 15:43:37 +0900</pubDate>
    </item>
    <item>
      <title>ubuntu 서버 최초 세팅</title>
      <link>https://aamoos.tistory.com/766</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 서버 접속&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(기본 계정 : ubuntu)&lt;/p&gt;
&lt;pre id=&quot;code_1768356567387&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ssh ubuntu@server_ip&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 시스템업데이트&lt;/h3&gt;
&lt;pre id=&quot;code_1768356613192&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt update
sudo apt upgrade -y&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 타임존 설정(선택이지만 추천)&lt;/h3&gt;
&lt;pre id=&quot;code_1768356638032&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo timedatectl set-timezone Asia/Seoul
timedatectl&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. sudo 권한 일반 사용자 계정 생성&lt;/h3&gt;
&lt;pre id=&quot;code_1768356809768&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo adduser username
sudo usermod -aG sudo username
groups username&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4-1. sudo 계정 생성후 정상확인&lt;/h4&gt;
&lt;pre id=&quot;code_1768356854351&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;su - username
sudo ls&lt;/code&gt;&lt;/pre&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;h3 data-ke-size=&quot;size23&quot;&gt;5. ROOT 계정 SSH 로그인 비활성화&lt;/h3&gt;
&lt;pre id=&quot;code_1768356904335&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;vim /etc/ssh/sshd_config

PermitRootLogin no # 해당 부분 no로 설정 yes면 root로 바로 로그인 가능한 상태임&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(선택) 로그인 유지시간 설정&lt;/h3&gt;
&lt;pre id=&quot;code_1768357315607&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;vim /etc/ssh/sshd_config

ClientAliveInterval 300
ClientAliveCountMax 0&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;ClientAliveInterval 300 : **300초(5분)**마다 클라이언트 응답 확인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ClientAliveCountMax 0 : 응답 없으면 &lt;b&gt;즉시 세션 종료&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: &lt;b&gt;5분 동안 아무 작업 없으면 자동 로그아웃&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(선택) 패스워드 오류 허용 횟수 (로그인 실패 제한)&lt;/h3&gt;
&lt;pre id=&quot;code_1768357383307&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;MaxAuthTries 3&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;패스워드 3번 틀리면 즉시 접속 종료&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(선택) 로그인 시도 제한&lt;/h3&gt;
&lt;pre id=&quot;code_1768357558926&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;LoginGraceTime 30&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;b&gt;30초 안에 인증 못 하면 강제 종료&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;운영서버에서 가장 많이 쓰는 조합&lt;/h3&gt;
&lt;pre id=&quot;code_1768357622052&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 로그인 유지시간
ClientAliveInterval 300
ClientAliveCountMax 0

# 인증 보안
MaxAuthTries 3
LoginGraceTime 30

# root 로그인 차단 (이미 설정했을 가능성 높음)
PermitRootLogin no&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;- 만약 추가적으로 ssh 파일로만 로그인하고싶으면 아래 옵션을 설정하면 된다. id password 로그인 x&lt;/p&gt;
&lt;pre id=&quot;code_1768357726662&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 패스워드 로그인 허용 여부 (권장: 키 로그인만)
PasswordAuthentication no&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-1 ssh 설정파일 정상적으로 변경되었는지 확인후 restart&lt;/h3&gt;
&lt;pre id=&quot;code_1768357105780&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo sshd -t
sudo systemctl restart ssh&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;sudo sshd -t : sshd_config에 문법에 오류가 없으면 아무것도 안뜸&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sudo systemctl restart ssh : ssh 재시작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 방화벽(UFW) 설정&lt;/h3&gt;
&lt;pre id=&quot;code_1768363497259&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp   # 또는 변경 포트
sudo ufw enable
sudo ufw status verbose&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-1. 웹서버 방화벽 설정&lt;/h3&gt;
&lt;pre id=&quot;code_1768363646947&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo ufw allow 80/tcp
sudo ufw allow 443/tcp&lt;/code&gt;&lt;/pre&gt;</description>
      <category>DevOps</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/766</guid>
      <comments>https://aamoos.tistory.com/766#entry766comment</comments>
      <pubDate>Wed, 14 Jan 2026 13:07:52 +0900</pubDate>
    </item>
    <item>
      <title>npm : 'npm' 용어가 cmdlet, 함수, 스크립트 파일 또는 실행할 수 있는 프로그램 이름으로 인식되지 않습니다. 이름이 정확한지 확인하고 경로가 포함된 경우 경로가 올바른지 검증한 다음 다시 시도하십시오.</title>
      <link>https://aamoos.tistory.com/765</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;125&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTKvJC/dJMcahXrtfZ/ckrDAVIsbktAzwO75s0ko0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTKvJC/dJMcahXrtfZ/ckrDAVIsbktAzwO75s0ko0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTKvJC/dJMcahXrtfZ/ckrDAVIsbktAzwO75s0ko0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTKvJC%2FdJMcahXrtfZ%2FckrDAVIsbktAzwO75s0ko0%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;1244&quot; height=&quot;125&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;125&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;컴퓨터를 포맷한후 npm명령어가 먹지않아서 해결방법 포스팅합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;npm 명령어를 쓰다가 해당 에러가 발생할경우, cmd창에 아래명령어를 쳐서 확인합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1767948979703&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;node -v&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;576&quot; data-origin-height=&quot;59&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/D45rt/dJMcacV6Fjo/sQKGghE2WgodMZ8unotRz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/D45rt/dJMcacV6Fjo/sQKGghE2WgodMZ8unotRz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/D45rt/dJMcacV6Fjo/sQKGghE2WgodMZ8unotRz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FD45rt%2FdJMcacV6Fjo%2FsQKGghE2WgodMZ8unotRz1%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;576&quot; height=&quot;59&quot; data-origin-width=&quot;576&quot; data-origin-height=&quot;59&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;gt; node js가 설치가 안되어있으면 node js를 다운로드합니다.&lt;/p&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;a href=&quot;https://nodejs.org/ko/download&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nodejs.org/ko/download&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1768356213787&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;Node.js &amp;mdash; Node.js&amp;reg; 다운로드&quot; data-og-description=&quot;Node.js&amp;reg; is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.&quot; data-og-host=&quot;nodejs.org&quot; data-og-source-url=&quot;https://nodejs.org/ko/download&quot; data-og-url=&quot;https://nodejs.org/ko/download&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/hgJfM/dJMb8SXq8iM/9UeODXgBtGGfwabNkAkng0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bjrH6c/dJMb9hCUDkO/b9Dj5lVNzHybMN6RRfFQqK/img.png?width=224&amp;amp;height=256&amp;amp;face=0_0_224_256&quot;&gt;&lt;a href=&quot;https://nodejs.org/ko/download&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nodejs.org/ko/download&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/hgJfM/dJMb8SXq8iM/9UeODXgBtGGfwabNkAkng0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bjrH6c/dJMb9hCUDkO/b9Dj5lVNzHybMN6RRfFQqK/img.png?width=224&amp;amp;height=256&amp;amp;face=0_0_224_256');&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;Node.js &amp;mdash; Node.js&amp;reg; 다운로드&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Node.js&amp;reg; is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;nodejs.org&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;powershell에서 관리자 권한으로 실행&lt;/p&gt;
&lt;pre id=&quot;code_1767949232089&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Get-ExecutionPolicy
Set-ExecutionPolicy RemoteSigned
npm -v&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;363&quot; data-origin-height=&quot;39&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvHT7f/dJMcaajG35k/pR3SKIGPkXwimzsTDWbOm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvHT7f/dJMcaajG35k/pR3SKIGPkXwimzsTDWbOm0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvHT7f/dJMcaajG35k/pR3SKIGPkXwimzsTDWbOm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvHT7f%2FdJMcaajG35k%2FpR3SKIGPkXwimzsTDWbOm0%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;39&quot; data-origin-width=&quot;363&quot; data-origin-height=&quot;39&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;932&quot; data-origin-height=&quot;131&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OfoZv/dJMcaiWipWt/QhYz0qcUG2IPXk2Gz1WQhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OfoZv/dJMcaiWipWt/QhYz0qcUG2IPXk2Gz1WQhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OfoZv/dJMcaiWipWt/QhYz0qcUG2IPXk2Gz1WQhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOfoZv%2FdJMcaiWipWt%2FQhYz0qcUG2IPXk2Gz1WQhk%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;932&quot; height=&quot;131&quot; data-origin-width=&quot;932&quot; data-origin-height=&quot;131&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;이후에 npm명령어가 정상적으로 동작하는것을 볼수있습니다.&lt;/p&gt;</description>
      <category>frontend</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/765</guid>
      <comments>https://aamoos.tistory.com/765#entry765comment</comments>
      <pubDate>Wed, 14 Jan 2026 11:04:16 +0900</pubDate>
    </item>
    <item>
      <title>reactJs 확장프로그램 devtool 설치</title>
      <link>https://aamoos.tistory.com/764</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1765796958149&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;React Developer Tools - Chrome 웹 스토어&quot; data-og-description=&quot;Adds React debugging tools to the Chrome Developer Tools. Created from revision 3cde211b0c on 10/20/2025.&quot; data-og-host=&quot;chromewebstore.google.com&quot; data-og-source-url=&quot;https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi&quot; data-og-url=&quot;https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/KDXvJ/hyZPriI5bx/TbGRW5yg8caez4o0H6x4h0/img.jpg?width=128&amp;amp;height=128&amp;amp;face=0_0_128_128&quot;&gt;&lt;a href=&quot;https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/KDXvJ/hyZPriI5bx/TbGRW5yg8caez4o0H6x4h0/img.jpg?width=128&amp;amp;height=128&amp;amp;face=0_0_128_128');&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;React Developer Tools - Chrome 웹 스토어&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Adds React debugging tools to the Chrome Developer Tools. Created from revision 3cde211b0c on 10/20/2025.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;chromewebstore.google.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;해당 사이트에서 devtools 확장프로그램 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App.jsx&lt;/p&gt;
&lt;pre id=&quot;code_1765797165663&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import Counter from &quot;@/components/Counter&quot;;
import UserCard from &quot;@/components/UserCard&quot;;

function App() {
  const user = {
    name: &quot;Jaesung&quot;,
    age: 30,
    role: &quot;Frontend Dev&quot;,
  };

  return (
    &amp;lt;div style={{ padding: 20 }}&amp;gt;
      &amp;lt;h1&amp;gt;React DevTools Test&amp;lt;/h1&amp;gt;

      &amp;lt;Counter initial={5} /&amp;gt;
      &amp;lt;Counter /&amp;gt;

      &amp;lt;UserCard user={user} /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default App;&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;components/Counter.jsx&lt;/p&gt;
&lt;pre id=&quot;code_1765797180846&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useState } from &quot;react&quot;;

function Counter({ initial = 0 }) {
    const [count, setCount] = useState(initial);

    return (
        &amp;lt;div style={{ border: &quot;1px solid #ccc&quot;, padding: 12, marginBottom: 12 }}&amp;gt;
            &amp;lt;h3&amp;gt;Counter&amp;lt;/h3&amp;gt;
            &amp;lt;p&amp;gt;count: {count}&amp;lt;/p&amp;gt;
            &amp;lt;button onClick={() =&amp;gt; setCount((c) =&amp;gt; c + 1)}&amp;gt;+&amp;lt;/button&amp;gt;
            &amp;lt;button onClick={() =&amp;gt; setCount((c) =&amp;gt; c - 1)}&amp;gt;-&amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
    );
}

export default Counter;&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;components/UserCard.jsx&lt;/p&gt;
&lt;pre id=&quot;code_1765797196315&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function UserCard({ user }) {
    return (
        &amp;lt;div style={{ border: &quot;1px solid #aaa&quot;, padding: 12 }}&amp;gt;
            &amp;lt;h3&amp;gt;UserCard&amp;lt;/h3&amp;gt;
            &amp;lt;p&amp;gt;name: {user.name}&amp;lt;/p&amp;gt;
            &amp;lt;p&amp;gt;age: {user.age}&amp;lt;/p&amp;gt;
            &amp;lt;p&amp;gt;role: {user.role}&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
    );
}

export default UserCard;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;741&quot; data-origin-height=&quot;655&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GB8La/dJMcab3OKJ9/YtZzDT50koDvER9bIZKrEk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GB8La/dJMcab3OKJ9/YtZzDT50koDvER9bIZKrEk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GB8La/dJMcab3OKJ9/YtZzDT50koDvER9bIZKrEk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGB8La%2FdJMcab3OKJ9%2FYtZzDT50koDvER9bIZKrEk%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;655&quot; data-origin-width=&quot;741&quot; data-origin-height=&quot;655&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;568&quot; data-origin-height=&quot;94&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WedgJ/dJMcaajxey5/KWFhSOjckIpTMqhRFCxARk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WedgJ/dJMcaajxey5/KWFhSOjckIpTMqhRFCxARk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WedgJ/dJMcaajxey5/KWFhSOjckIpTMqhRFCxARk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWedgJ%2FdJMcaajxey5%2FKWFhSOjckIpTMqhRFCxARk%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;94&quot; data-origin-width=&quot;568&quot; data-origin-height=&quot;94&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;537&quot; data-origin-height=&quot;307&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tXJib/dJMcadAvN3Z/lXr7fPryh3jRuwghFg5Wzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tXJib/dJMcadAvN3Z/lXr7fPryh3jRuwghFg5Wzk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tXJib/dJMcadAvN3Z/lXr7fPryh3jRuwghFg5Wzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtXJib%2FdJMcadAvN3Z%2FlXr7fPryh3jRuwghFg5Wzk%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;537&quot; height=&quot;307&quot; data-origin-width=&quot;537&quot; data-origin-height=&quot;307&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;531&quot; data-origin-height=&quot;275&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dADjbH/dJMcaf6dDg3/3BfT3cyryrV40VzekKI7pK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dADjbH/dJMcaf6dDg3/3BfT3cyryrV40VzekKI7pK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dADjbH/dJMcaf6dDg3/3BfT3cyryrV40VzekKI7pK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdADjbH%2FdJMcaf6dDg3%2F3BfT3cyryrV40VzekKI7pK%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;531&quot; height=&quot;275&quot; data-origin-width=&quot;531&quot; data-origin-height=&quot;275&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;535&quot; data-origin-height=&quot;255&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b720pC/dJMcafLUQvJ/CItbhQWnS2IYEVzPuK1g80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b720pC/dJMcafLUQvJ/CItbhQWnS2IYEVzPuK1g80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b720pC/dJMcafLUQvJ/CItbhQWnS2IYEVzPuK1g80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb720pC%2FdJMcafLUQvJ%2FCItbhQWnS2IYEVzPuK1g80%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;535&quot; height=&quot;255&quot; data-origin-width=&quot;535&quot; data-origin-height=&quot;255&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;개발자도구 - Components를 클릭해보면 각 컴포넌트의 props hooks등을 확인할수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>frontend/React</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/764</guid>
      <comments>https://aamoos.tistory.com/764#entry764comment</comments>
      <pubDate>Mon, 15 Dec 2025 20:16:10 +0900</pubDate>
    </item>
    <item>
      <title>reactJs vsCode eslint 플러그인 설정</title>
      <link>https://aamoos.tistory.com/763</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;플러그인 선택후 eslint 설치&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;346&quot; data-origin-height=&quot;229&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lVIoo/dJMcah31Y50/TH3QxCp8wVEiTvhIdktnE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lVIoo/dJMcah31Y50/TH3QxCp8wVEiTvhIdktnE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lVIoo/dJMcah31Y50/TH3QxCp8wVEiTvhIdktnE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlVIoo%2FdJMcah31Y50%2FTH3QxCp8wVEiTvhIdktnE0%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;346&quot; height=&quot;229&quot; data-origin-width=&quot;346&quot; data-origin-height=&quot;229&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;eslint.config.js&lt;/p&gt;
&lt;pre id=&quot;code_1765796771669&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{js,jsx}'],
    extends: [
      js.configs.recommended,
      reactHooks.configs.flat.recommended,
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        ecmaVersion: 'latest',
        ecmaFeatures: { jsx: true },
        sourceType: 'module',
      },
    },
    rules: {
      'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
      'react/prop-types': 'off',
    },
  },
])&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;- rule안에 react/prop-types off로 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>frontend/React</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/763</guid>
      <comments>https://aamoos.tistory.com/763#entry763comment</comments>
      <pubDate>Mon, 15 Dec 2025 20:07:42 +0900</pubDate>
    </item>
    <item>
      <title>reactJs 프로젝트 경로 src '@' 설정</title>
      <link>https://aamoos.tistory.com/762</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;vite.config.js&lt;/p&gt;
&lt;pre id=&quot;code_1765796102247&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { defineConfig } from &quot;vite&quot;;
import react from &quot;@vitejs/plugin-react&quot;;
import { resolve } from &quot;path&quot;;

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      &quot;@&quot;: resolve(&quot;src&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;루트디렉터리에 jsconfig.json 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- vite.config.js만 생성하도 컴파일 에러가 나지않지만, Eslint 설정이 빡세게 되어있거나, idel에서 에러가 날경우가 있어서 아래 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;jsconfig.json&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1765796130880&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;compilerOptions&quot;: {
        &quot;baseUrl&quot;: &quot;.&quot;,
        &quot;paths&quot;: {
            &quot;@/*&quot;: [
                &quot;src/*&quot;
            ]
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;304&quot; data-origin-height=&quot;273&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSTbyE/dJMcachlNC9/XbgI0pL58aM5UIJQl7lsyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSTbyE/dJMcachlNC9/XbgI0pL58aM5UIJQl7lsyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSTbyE/dJMcachlNC9/XbgI0pL58aM5UIJQl7lsyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSTbyE%2FdJMcachlNC9%2FXbgI0pL58aM5UIJQl7lsyk%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;304&quot; height=&quot;273&quot; data-origin-width=&quot;304&quot; data-origin-height=&quot;273&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;components/Header.jsx&lt;/p&gt;
&lt;pre id=&quot;code_1765796247611&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Header() {
    return (
        &amp;lt;header&amp;gt;
            &amp;lt;h1&amp;gt;My App Header&amp;lt;/h1&amp;gt;
        &amp;lt;/header&amp;gt;
    );
}

export default Header;&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;App.jsx&lt;/p&gt;
&lt;pre id=&quot;code_1765796261734&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import Header from &quot;@/components/Header&quot;;

function App() {
  return &amp;lt;Header /&amp;gt;;
}

export default App;&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;index.css, app.css는 삭제&lt;/p&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_1765796305686&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm run dev&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;328&quot; data-origin-height=&quot;165&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cH3rfw/dJMcahXhxEu/PkEakbdZj60PICYZqHanvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cH3rfw/dJMcahXhxEu/PkEakbdZj60PICYZqHanvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cH3rfw/dJMcahXhxEu/PkEakbdZj60PICYZqHanvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcH3rfw%2FdJMcahXhxEu%2FPkEakbdZj60PICYZqHanvK%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;328&quot; height=&quot;165&quot; data-origin-width=&quot;328&quot; data-origin-height=&quot;165&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>frontend/React</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/762</guid>
      <comments>https://aamoos.tistory.com/762#entry762comment</comments>
      <pubDate>Mon, 15 Dec 2025 19:58:53 +0900</pubDate>
    </item>
    <item>
      <title>react 프로젝트 세팅</title>
      <link>https://aamoos.tistory.com/761</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;노드 설치 및 확인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;vsCode를 실행한후 아래 명령어를 실행한다.&lt;/p&gt;
&lt;pre id=&quot;code_1765792597252&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;node -v
npm -v&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;328&quot; data-origin-height=&quot;73&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nEgn1/dJMcagcZTiq/0X94KiDr6RmUbJ5sBBTphK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nEgn1/dJMcagcZTiq/0X94KiDr6RmUbJ5sBBTphK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nEgn1/dJMcagcZTiq/0X94KiDr6RmUbJ5sBBTphK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnEgn1%2FdJMcagcZTiq%2F0X94KiDr6RmUbJ5sBBTphK%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;328&quot; height=&quot;73&quot; data-origin-width=&quot;328&quot; data-origin-height=&quot;73&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;- 설치가 되어있지않으면 node js 설치를 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nodejs.org/en/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nodejs.org/en/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1765792691503&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;Node.js &amp;mdash; Run JavaScript Everywhere&quot; data-og-description=&quot;Node.js&amp;reg; is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.&quot; data-og-host=&quot;nodejs.org&quot; data-og-source-url=&quot;https://nodejs.org/en/&quot; data-og-url=&quot;https://nodejs.org/en/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/7u0Pi/hyZPs9JzTm/7vMDQ52kk5etgd7CnDqGXk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bm7Pak/hyZPusXdhH/dWrN0Z4pSWEtAjT7Atr3bK/img.png?width=224&amp;amp;height=256&amp;amp;face=0_0_224_256&quot;&gt;&lt;a href=&quot;https://nodejs.org/en/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nodejs.org/en/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/7u0Pi/hyZPs9JzTm/7vMDQ52kk5etgd7CnDqGXk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bm7Pak/hyZPusXdhH/dWrN0Z4pSWEtAjT7Atr3bK/img.png?width=224&amp;amp;height=256&amp;amp;face=0_0_224_256');&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;Node.js &amp;mdash; Run JavaScript Everywhere&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Node.js&amp;reg; is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;nodejs.org&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;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;vsCode 터미널에서 프로젝트를 생성할 폴더로 이동후 아래 명령어를 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1765792776477&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm create vite@latest 프로젝트명&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;182&quot; data-origin-height=&quot;225&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bK25hb/dJMcajgqyuj/d4AWODHtqbcAcNQNcEgrRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bK25hb/dJMcajgqyuj/d4AWODHtqbcAcNQNcEgrRk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bK25hb/dJMcajgqyuj/d4AWODHtqbcAcNQNcEgrRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbK25hb%2FdJMcajgqyuj%2Fd4AWODHtqbcAcNQNcEgrRk%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;182&quot; height=&quot;225&quot; data-origin-width=&quot;182&quot; data-origin-height=&quot;225&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- React 선택&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;257&quot; data-origin-height=&quot;232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bO2zcw/dJMcacPbHxm/ku6gWBd2ee8vEkYMsGorZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bO2zcw/dJMcacPbHxm/ku6gWBd2ee8vEkYMsGorZ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bO2zcw/dJMcacPbHxm/ku6gWBd2ee8vEkYMsGorZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbO2zcw%2FdJMcacPbHxm%2Fku6gWBd2ee8vEkYMsGorZ1%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;257&quot; height=&quot;232&quot; data-origin-width=&quot;257&quot; data-origin-height=&quot;232&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Typescript나 JavaScript 사용하고자 하는 언어 선택&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;285&quot; data-origin-height=&quot;75&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cd0xIn/dJMcadAvMRQ/6j2hy4bdothvf4CBQIkfI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cd0xIn/dJMcadAvMRQ/6j2hy4bdothvf4CBQIkfI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cd0xIn/dJMcadAvMRQ/6j2hy4bdothvf4CBQIkfI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcd0xIn%2FdJMcadAvMRQ%2F6j2hy4bdothvf4CBQIkfI0%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;285&quot; height=&quot;75&quot; data-origin-width=&quot;285&quot; data-origin-height=&quot;75&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- no 선택&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; rolldown-vite는 Rust로 만든 차세대 번들러임 , rollup을 대체하려는 목적&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;247&quot; data-origin-height=&quot;47&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dRcUQ4/dJMcad1z4fV/PY2eYb7o0QXesRVN1OWADK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dRcUQ4/dJMcad1z4fV/PY2eYb7o0QXesRVN1OWADK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dRcUQ4/dJMcad1z4fV/PY2eYb7o0QXesRVN1OWADK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdRcUQ4%2FdJMcad1z4fV%2FPY2eYb7o0QXesRVN1OWADK%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;247&quot; height=&quot;47&quot; data-origin-width=&quot;247&quot; data-origin-height=&quot;47&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- yes 선택&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;280&quot; data-origin-height=&quot;219&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bs3y5g/dJMcaaqi1Wa/uRlOjgvyuMRIllqFZkX7ck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bs3y5g/dJMcaaqi1Wa/uRlOjgvyuMRIllqFZkX7ck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bs3y5g/dJMcaaqi1Wa/uRlOjgvyuMRIllqFZkX7ck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbs3y5g%2FdJMcaaqi1Wa%2FuRlOjgvyuMRIllqFZkX7ck%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;280&quot; height=&quot;219&quot; data-origin-width=&quot;280&quot; data-origin-height=&quot;219&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 프로젝트안에 접속후 코드를 보고 싶으면 해당 경로로 이동후 아래 명령어를 입력한다.&lt;/p&gt;
&lt;pre id=&quot;code_1765793130116&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cd 프로젝트경로
code .&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;209&quot; data-origin-height=&quot;251&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cPmrF6/dJMcabW26JG/rh78rJpGN5U4u6qWwkUfpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cPmrF6/dJMcabW26JG/rh78rJpGN5U4u6qWwkUfpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cPmrF6/dJMcabW26JG/rh78rJpGN5U4u6qWwkUfpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcPmrF6%2FdJMcabW26JG%2Frh78rJpGN5U4u6qWwkUfpk%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;209&quot; height=&quot;251&quot; data-origin-width=&quot;209&quot; data-origin-height=&quot;251&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;</description>
      <category>frontend/React</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/761</guid>
      <comments>https://aamoos.tistory.com/761#entry761comment</comments>
      <pubDate>Mon, 15 Dec 2025 19:06:48 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 13. dotenv 적용해서 spring 프로퍼티 중요정보 .env 파일로 분리하기</title>
      <link>https://aamoos.tistory.com/757</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;spring 프로퍼티를 설정할때 중요정보를 보여주지 않고 따로 환경설정 파일로 분리하고 싶을때가 있을것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때 dotenv를 적용해서 env파일로 분리하는 방법을 아래 내용에 작성하려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle.kts&lt;/p&gt;
&lt;pre id=&quot;code_1739853946425&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//dotenv
implementation (&quot;io.github.cdimascio:java-dotenv:5.2.2&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;403&quot; data-origin-height=&quot;427&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lBdkh/btsMljNtTk2/Pe3p2Fa67Oc6FvQpxWQiyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lBdkh/btsMljNtTk2/Pe3p2Fa67Oc6FvQpxWQiyK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lBdkh/btsMljNtTk2/Pe3p2Fa67Oc6FvQpxWQiyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlBdkh%2FbtsMljNtTk2%2FPe3p2Fa67Oc6FvQpxWQiyK%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;427&quot; data-origin-width=&quot;403&quot; data-origin-height=&quot;427&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루트 디렉터리 경로에 .env 파일 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.env&lt;/p&gt;
&lt;pre id=&quot;code_1739854033740&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SPRING_PROFILES_ACTIVE=local
SECRET=jwt시크릿키&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;application.yml&lt;/p&gt;
&lt;pre id=&quot;code_1739854054083&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  config:
    import: optional:file:.env[.properties]
  profiles:
    active: ${SPRING_PROFILES_ACTIVE}  # 기본적으로 local 프로파일 사용 (운영에서는 prod로 변경)

jwt:
  expiration_time: 86400000 # 1일
  secret: ${secret}

logging:
  level:
    org.hibernate.SQL: DEBUG  # Hibernate 쿼리 로그 출력&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;config 아래 import 부분을 추가, .env에 설정한 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;application.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739854110080&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management

import io.github.cdimascio.dotenv.Dotenv
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing


@SpringBootApplication
@EnableJpaAuditing
class ManagementApplication

fun main(args: Array&amp;lt;String&amp;gt;) {
	runApplication&amp;lt;ManagementApplication&amp;gt;(*args)
	val dotenv = Dotenv.load()
	System.out.println(&quot;SPRING_PROFILES_ACTIVE: &quot; + dotenv.get(&quot;SPRING_PROFILES_ACTIVE&quot;));
	System.out.println(&quot;SECRET: &quot; + dotenv.get(&quot;SECRET&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;test 코드를 만들어도 되지만 간단하게 테스트 하기위해서 applicaiton.kt에 해당 코드 작성&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;596&quot; data-origin-height=&quot;43&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDsFt1/btsMnolYx0K/oN04MkcdCl7ruzsSshTYs1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDsFt1/btsMnolYx0K/oN04MkcdCl7ruzsSshTYs1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDsFt1/btsMnolYx0K/oN04MkcdCl7ruzsSshTYs1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDsFt1%2FbtsMnolYx0K%2FoN04MkcdCl7ruzsSshTYs1%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;596&quot; height=&quot;43&quot; data-origin-width=&quot;596&quot; data-origin-height=&quot;43&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;.gitignore&lt;/p&gt;
&lt;pre id=&quot;code_1739854203334&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.env*&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 깃에 커밋시 해당 환경설정 파일이 commit되면 안되므로 맨 아래 라인에 .env*을 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;git ignore에 .env*을 포함해도 .env파일이 git staging에 보일경우 안보이게 하는법&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. .env 파일을 백업하고 프로젝트에 있는 .env 파일을 삭제&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 아래 명령어 intelij 터미널에 입력&lt;/p&gt;
&lt;pre id=&quot;code_1739854818234&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;git rm .env --cached
git add .
git commit -m &quot;remove .env file from git&quot;
git push&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;3. 이후 다시 루트 디렉터리에 백업한 .env파일을 생성한후 git - commit 클릭&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;401&quot; data-origin-height=&quot;183&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1Nf4j/btsMlFJuqVi/Y14CFhMiJTxJ5y0sEKUFkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1Nf4j/btsMlFJuqVi/Y14CFhMiJTxJ5y0sEKUFkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1Nf4j/btsMlFJuqVi/Y14CFhMiJTxJ5y0sEKUFkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1Nf4j%2FbtsMlFJuqVi%2FY14CFhMiJTxJ5y0sEKUFkk%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;183&quot; data-origin-width=&quot;401&quot; data-origin-height=&quot;183&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;env 파일을 변경해도 커밋 changes에 안보이는것을 확인할수 있음&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/757</guid>
      <comments>https://aamoos.tistory.com/757#entry757comment</comments>
      <pubDate>Tue, 18 Feb 2025 14:01:42 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 12. Render 무료 호스팅서버에 Spring boot + Kotlin 배포하기</title>
      <link>https://aamoos.tistory.com/756</link>
      <description>&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;백엔드 무료서버가 몇가지있지만 그중 Render라는 무료 서버 호스팅 사이트에 배포하는 방법 관련 내용입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이전 포스팅한 간단한 프로젝트를 Render에 배포해보겠습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기본적으로 docker hub 회원가입 및 도커가 설치 되어있다는 기준으로 진행하겠습니다.&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;intelij 프로젝트에서 gradle build 클릭&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;843&quot; data-origin-height=&quot;170&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVo9pI/btsMjCL1HOK/kiKiYuMjtAKIP6MWnKLErk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVo9pI/btsMjCL1HOK/kiKiYuMjtAKIP6MWnKLErk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVo9pI/btsMjCL1HOK/kiKiYuMjtAKIP6MWnKLErk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVo9pI%2FbtsMjCL1HOK%2FkiKiYuMjtAKIP6MWnKLErk%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;843&quot; height=&quot;170&quot; data-origin-width=&quot;843&quot; data-origin-height=&quot;170&quot;/&gt;&lt;/span&gt;&lt;/figure&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;DockerFile&lt;/p&gt;
&lt;pre id=&quot;code_1739515448666&quot; class=&quot;dockerfile&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;FROM azul/zulu-openjdk:17-latest
VOLUME /tmp
COPY build/libs/*.jar app.jar
ENTRYPOINT [&quot;java&quot;,&quot;-jar&quot;,&quot;/app.jar&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;404&quot; data-origin-height=&quot;264&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIlCIz/btsMjBfg9vq/K0riv7ykEXEe3aCPjU7Ll0/tfile.dat&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIlCIz/btsMjBfg9vq/K0riv7ykEXEe3aCPjU7Ll0/tfile.dat&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIlCIz/btsMjBfg9vq/K0riv7ykEXEe3aCPjU7Ll0/tfile.dat&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIlCIz%2FbtsMjBfg9vq%2FK0riv7ykEXEe3aCPjU7Ll0%2Ftfile.dat&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;404&quot; height=&quot;264&quot; data-origin-width=&quot;404&quot; data-origin-height=&quot;264&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;intelij 터미널에 아래 명령어 순서대로 입력&lt;/p&gt;
&lt;pre id=&quot;code_1739515467447&quot; class=&quot;properties&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;docker build -t 프로젝트명 .
docker images
docker tag 프로젝트명:latest         도커아이디명/프로젝트명
docker push 도커아이디명/프로젝트명&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;docker hub 사이트 접속&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://hub.docker.com/&quot;&gt;https://hub.docker.com/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1739515481701&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;Docker Hub Container Image Library | App Containerization&quot; data-og-description=&quot;Increase your reach and adoption on Docker Hub With a Docker Verified Publisher subscription, you'll increase trust, boost discoverability, get exclusive data insights, and much more.&quot; data-og-host=&quot;hub.docker.com&quot; data-og-source-url=&quot;https://hub.docker.com/&quot; data-og-url=&quot;https://hub.docker.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/kqSh0/hyYfI0mPnA/59Knn0B7Vkao5jkXHr0T8k/img.png?width=2560&amp;amp;height=1030&amp;amp;face=0_0_2560_1030,https://scrap.kakaocdn.net/dn/bBLTUz/hyYf5VzF5X/bYkuobQ9uCcqEOmTutvVv0/img.png?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000&quot;&gt;&lt;a href=&quot;https://hub.docker.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://hub.docker.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/kqSh0/hyYfI0mPnA/59Knn0B7Vkao5jkXHr0T8k/img.png?width=2560&amp;amp;height=1030&amp;amp;face=0_0_2560_1030,https://scrap.kakaocdn.net/dn/bBLTUz/hyYf5VzF5X/bYkuobQ9uCcqEOmTutvVv0/img.png?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_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;Docker Hub Container Image Library | App Containerization&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Increase your reach and adoption on Docker Hub With a Docker Verified Publisher subscription, you'll increase trust, boost discoverability, get exclusive data insights, and much more.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;hub.docker.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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1441&quot; data-origin-height=&quot;263&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UtFoc/btsMiIZ4Iqo/itsJwNGcHcSg1UoohBvrh1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UtFoc/btsMiIZ4Iqo/itsJwNGcHcSg1UoohBvrh1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UtFoc/btsMiIZ4Iqo/itsJwNGcHcSg1UoohBvrh1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUtFoc%2FbtsMiIZ4Iqo%2FitsJwNGcHcSg1UoohBvrh1%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;1441&quot; height=&quot;263&quot; data-origin-width=&quot;1441&quot; data-origin-height=&quot;263&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;- docker hub사이트에 접속해보면 이미지가 push가 잘된것을 볼수 있습니다.&lt;/p&gt;
&lt;p 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;render 사이트에 접속을 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dashboard.render.com/&quot;&gt;https://dashboard.render.com/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1739515547662&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;Cloud Application Hosting for Developers | Render&quot; data-og-description=&quot;Render is a unified cloud to build and run all your apps and websites with free SSL, global CDN, private networks and automatic deploys from Git.&quot; data-og-host=&quot;dashboard.render.com&quot; data-og-source-url=&quot;https://dashboard.render.com/&quot; data-og-url=&quot;https://dashboard.render.com/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/byOOnY/hyYfLiutUl/Xi3H7qyaC0ZQMRJdqZ1MD1/img.png?width=3840&amp;amp;height=2146&amp;amp;face=0_0_3840_2146,https://scrap.kakaocdn.net/dn/b36ZzX/hyYfDdGV9d/ktokRUf2A2NcH5FeKKKqyk/img.png?width=3840&amp;amp;height=2146&amp;amp;face=0_0_3840_2146&quot;&gt;&lt;a href=&quot;https://dashboard.render.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dashboard.render.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/byOOnY/hyYfLiutUl/Xi3H7qyaC0ZQMRJdqZ1MD1/img.png?width=3840&amp;amp;height=2146&amp;amp;face=0_0_3840_2146,https://scrap.kakaocdn.net/dn/b36ZzX/hyYfDdGV9d/ktokRUf2A2NcH5FeKKKqyk/img.png?width=3840&amp;amp;height=2146&amp;amp;face=0_0_3840_2146');&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;Cloud Application Hosting for Developers | Render&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Render is a unified cloud to build and run all your apps and websites with free SSL, global CDN, private networks and automatic deploys from Git.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dashboard.render.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&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1486&quot; data-origin-height=&quot;478&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZCf1U/btsMjhVw3Xs/SeijrMaSOHDLlFDc3Q9ISk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZCf1U/btsMjhVw3Xs/SeijrMaSOHDLlFDc3Q9ISk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZCf1U/btsMjhVw3Xs/SeijrMaSOHDLlFDc3Q9ISk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZCf1U%2FbtsMjhVw3Xs%2FSeijrMaSOHDLlFDc3Q9ISk%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;1486&quot; height=&quot;478&quot; data-origin-width=&quot;1486&quot; data-origin-height=&quot;478&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Overview - Create New Service&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;543&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FsBXp/btsMhSJgowD/az8ujn2PPyQUrerCFuutRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FsBXp/btsMhSJgowD/az8ujn2PPyQUrerCFuutRk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FsBXp/btsMhSJgowD/az8ujn2PPyQUrerCFuutRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFsBXp%2FbtsMhSJgowD%2Faz8ujn2PPyQUrerCFuutRk%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;1426&quot; height=&quot;543&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;543&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Web 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;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1444&quot; data-origin-height=&quot;433&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPm9xL/btsMjJqs5uf/PdDH585xmmdBCIcmIwV0B0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPm9xL/btsMjJqs5uf/PdDH585xmmdBCIcmIwV0B0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPm9xL/btsMjJqs5uf/PdDH585xmmdBCIcmIwV0B0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPm9xL%2FbtsMjJqs5uf%2FPdDH585xmmdBCIcmIwV0B0%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;1444&quot; height=&quot;433&quot; data-origin-width=&quot;1444&quot; data-origin-height=&quot;433&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;docker hub에 push한 image url을 입력후 Connect&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1410&quot; data-origin-height=&quot;727&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/br8OF7/btsMh3DWvRT/KudEVDIgPcQY1KUU3brgbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/br8OF7/btsMh3DWvRT/KudEVDIgPcQY1KUU3brgbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/br8OF7/btsMh3DWvRT/KudEVDIgPcQY1KUU3brgbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbr8OF7%2FbtsMh3DWvRT%2FKudEVDIgPcQY1KUU3brgbk%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;1410&quot; height=&quot;727&quot; data-origin-width=&quot;1410&quot; data-origin-height=&quot;727&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Region은 가장 가까운 Singapore로 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Instance Type는 Free로 설정&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1385&quot; data-origin-height=&quot;277&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNMXXz/btsMka8VPfB/uUI0riKrfHtd9vjIMemDX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNMXXz/btsMka8VPfB/uUI0riKrfHtd9vjIMemDX1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNMXXz/btsMka8VPfB/uUI0riKrfHtd9vjIMemDX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNMXXz%2FbtsMka8VPfB%2FuUI0riKrfHtd9vjIMemDX1%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;1385&quot; height=&quot;277&quot; data-origin-width=&quot;1385&quot; data-origin-height=&quot;277&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SPRING_PROFILES_ACTIVE -&amp;gt; prod로 설정이후 Deploy Web 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;배포 완료화면&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;653&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NmFjs/btsMjDjO4dQ/QPC9zG9c8fFiZVKRILiyf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NmFjs/btsMjDjO4dQ/QPC9zG9c8fFiZVKRILiyf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NmFjs/btsMjDjO4dQ/QPC9zG9c8fFiZVKRILiyf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNmFjs%2FbtsMjDjO4dQ%2FQPC9zG9c8fFiZVKRILiyf1%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;1350&quot; height=&quot;653&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;653&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 로그를 보면 application.yml prod를 바라보게 설정이 된것을 볼수 있음&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1065&quot; data-origin-height=&quot;822&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TW1pC/btsMiJSofyD/ooD7lpSAX29iKIKjcVc3x1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TW1pC/btsMiJSofyD/ooD7lpSAX29iKIKjcVc3x1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TW1pC/btsMiJSofyD/ooD7lpSAX29iKIKjcVc3x1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTW1pC%2FbtsMiJSofyD%2FooD7lpSAX29iKIKjcVc3x1%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;1065&quot; height=&quot;822&quot; data-origin-width=&quot;1065&quot; data-origin-height=&quot;822&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;525&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lmdr5/btsMjJqzh1k/PgZQO3prtY29tJb2Ju2WH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lmdr5/btsMjJqzh1k/PgZQO3prtY29tJb2Ju2WH1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lmdr5/btsMjJqzh1k/PgZQO3prtY29tJb2Ju2WH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flmdr5%2FbtsMjJqzh1k%2FPgZQO3prtY29tJb2Ju2WH1%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;728&quot; height=&quot;525&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;525&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포한 서버의 신규 사용자 등록 api 호출&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/756</guid>
      <comments>https://aamoos.tistory.com/756#entry756comment</comments>
      <pubDate>Fri, 14 Feb 2025 16:26:12 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 11. Render 무료 호스팅서버 Postgres 데이터베이스 생성</title>
      <link>https://aamoos.tistory.com/755</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;render 사이트에 접속을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dashboard.render.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dashboard.render.com/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1739513139360&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;Cloud Application Hosting for Developers | Render&quot; data-og-description=&quot;Render is a unified cloud to build and run all your apps and websites with free SSL, global CDN, private networks and automatic deploys from Git.&quot; data-og-host=&quot;dashboard.render.com&quot; data-og-source-url=&quot;https://dashboard.render.com/&quot; data-og-url=&quot;https://dashboard.render.com/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/byOOnY/hyYfLiutUl/Xi3H7qyaC0ZQMRJdqZ1MD1/img.png?width=3840&amp;amp;height=2146&amp;amp;face=0_0_3840_2146,https://scrap.kakaocdn.net/dn/b36ZzX/hyYfDdGV9d/ktokRUf2A2NcH5FeKKKqyk/img.png?width=3840&amp;amp;height=2146&amp;amp;face=0_0_3840_2146&quot;&gt;&lt;a href=&quot;https://dashboard.render.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dashboard.render.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/byOOnY/hyYfLiutUl/Xi3H7qyaC0ZQMRJdqZ1MD1/img.png?width=3840&amp;amp;height=2146&amp;amp;face=0_0_3840_2146,https://scrap.kakaocdn.net/dn/b36ZzX/hyYfDdGV9d/ktokRUf2A2NcH5FeKKKqyk/img.png?width=3840&amp;amp;height=2146&amp;amp;face=0_0_3840_2146');&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;Cloud Application Hosting for Developers | Render&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Render is a unified cloud to build and run all your apps and websites with free SSL, global CDN, private networks and automatic deploys from Git.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dashboard.render.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;1. 메인 - project에 들어가서 Create new proejct를 클릭&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;451&quot; data-origin-height=&quot;149&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kMEbu/btsMjf4rf0w/fYp5JZWuCGd9qsTEyq9hmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kMEbu/btsMjf4rf0w/fYp5JZWuCGd9qsTEyq9hmk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kMEbu/btsMjf4rf0w/fYp5JZWuCGd9qsTEyq9hmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkMEbu%2FbtsMjf4rf0w%2FfYp5JZWuCGd9qsTEyq9hmk%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;451&quot; height=&quot;149&quot; data-origin-width=&quot;451&quot; data-origin-height=&quot;149&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;2. 프로젝트 이름 작성후&amp;nbsp; create&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;574&quot; data-origin-height=&quot;463&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PKfu0/btsMhTuAmtd/wR4JG0wpkoZs0KQh7GeXzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PKfu0/btsMhTuAmtd/wR4JG0wpkoZs0KQh7GeXzk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PKfu0/btsMhTuAmtd/wR4JG0wpkoZs0KQh7GeXzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPKfu0%2FbtsMhTuAmtd%2FwR4JG0wpkoZs0KQh7GeXzk%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;574&quot; height=&quot;463&quot; data-origin-width=&quot;574&quot; data-origin-height=&quot;463&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;201&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDYCkp/btsMkdEu4zN/D7b25A1J9LT0rSbS1uKPM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDYCkp/btsMkdEu4zN/D7b25A1J9LT0rSbS1uKPM0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDYCkp/btsMkdEu4zN/D7b25A1J9LT0rSbS1uKPM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDYCkp%2FbtsMkdEu4zN%2FD7b25A1J9LT0rSbS1uKPM0%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;1426&quot; height=&quot;201&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;201&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. Overview 클릭 - 오른쪽에 플러스 버튼 클릭 - Create New Service 클릭&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1453&quot; data-origin-height=&quot;544&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dDaMWy/btsMisDd6bM/9Aq56C7cOEfZixjVMlDlR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dDaMWy/btsMisDd6bM/9Aq56C7cOEfZixjVMlDlR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dDaMWy/btsMisDd6bM/9Aq56C7cOEfZixjVMlDlR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdDaMWy%2FbtsMisDd6bM%2F9Aq56C7cOEfZixjVMlDlR1%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;1453&quot; height=&quot;544&quot; data-origin-width=&quot;1453&quot; data-origin-height=&quot;544&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;4. render는 database를 Postgres를 지원함, 하단에 Postgres 클릭&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. Postgres를 설정하는 화면&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1454&quot; data-origin-height=&quot;1130&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OPkKY/btsMjOyjxHa/1MTIhBWt7YtoiH1jH2lvP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OPkKY/btsMjOyjxHa/1MTIhBWt7YtoiH1jH2lvP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OPkKY/btsMjOyjxHa/1MTIhBWt7YtoiH1jH2lvP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOPkKY%2FbtsMjOyjxHa%2F1MTIhBWt7YtoiH1jH2lvP1%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;1454&quot; height=&quot;1130&quot; data-origin-width=&quot;1454&quot; data-origin-height=&quot;1130&quot;/&gt;&lt;/span&gt;&lt;/figure&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;Name : postgres 프로젝트 이름 (render에서 사용하는거라 아무거나 적어도 무방)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Databse : 데이터베이스명&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;User: 사용자명&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Region: 지역 선택 (가장 가까운 지역이 싱가폴이라서 고름)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Plan Options: Free로 선택해야함, 그래야 돈 안나감&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. info에 들어가면 생성된 database 정보를 볼수 있음&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1359&quot; data-origin-height=&quot;746&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UUJgp/btsMjFBCmMh/FwC1Qy9gMUQroEm40JJb41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UUJgp/btsMjFBCmMh/FwC1Qy9gMUQroEm40JJb41/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UUJgp/btsMjFBCmMh/FwC1Qy9gMUQroEm40JJb41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUUJgp%2FbtsMjFBCmMh%2FFwC1Qy9gMUQroEm40JJb41%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;1359&quot; height=&quot;746&quot; data-origin-width=&quot;1359&quot; data-origin-height=&quot;746&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;- External Database Url 옆에 눈모양 클릭후 jdbc url 복사&lt;/p&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.prod.yml&lt;/p&gt;
&lt;pre id=&quot;code_1739514772612&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://호스트명:5432/데이터베이스
    username: 사용자명
    password: 패스워드

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: false
    properties:
      hibernate:
        format_sql: false&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 위에 복사한 url에서 필요한 정보들 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;application.yml&lt;/p&gt;
&lt;pre id=&quot;code_1739515090524&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  profiles:
    active: prod  # 기본적으로 local 프로파일 사용 (운영에서는 prod로 변경)

jwt:
  expiration_time: 86400000 # 1일
  secret: 시크릿키

logging:
  level:
    org.hibernate.SQL: DEBUG  # Hibernate 쿼리 로그 출력&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;prod로 설정해보고 application 시작&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;135&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YqRo1/btsMiHtsgcn/1Fk5ysvJQxpQlGRaeAhI80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YqRo1/btsMiHtsgcn/1Fk5ysvJQxpQlGRaeAhI80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YqRo1/btsMiHtsgcn/1Fk5ysvJQxpQlGRaeAhI80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYqRo1%2FbtsMiHtsgcn%2F1Fk5ysvJQxpQlGRaeAhI80%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;1350&quot; height=&quot;135&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;135&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 시작시 로그를 보면 h2가 아니라 postgres가 뜨는것을 볼수 있음&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1134&quot; data-origin-height=&quot;862&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wza5M/btsMjPYhhss/8uaK2IasiGfKCLVJ0gbK2k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wza5M/btsMjPYhhss/8uaK2IasiGfKCLVJ0gbK2k/img.png&quot; data-alt=&quot;-&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wza5M/btsMjPYhhss/8uaK2IasiGfKCLVJ0gbK2k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwza5M%2FbtsMjPYhhss%2F8uaK2IasiGfKCLVJ0gbK2k%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;1134&quot; height=&quot;862&quot; data-origin-width=&quot;1134&quot; data-origin-height=&quot;862&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;- 이전에 만들었던 사용자 등록 api 호출&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;549&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckhEoX/btsMjL2T5DB/73r1B9o2PX2BrRC5iw6EC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckhEoX/btsMjL2T5DB/73r1B9o2PX2BrRC5iw6EC0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckhEoX/btsMjL2T5DB/73r1B9o2PX2BrRC5iw6EC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FckhEoX%2FbtsMjL2T5DB%2F73r1B9o2PX2BrRC5iw6EC0%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;728&quot; height=&quot;549&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;549&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;render 무료호스팅으로 생성한 postgres가 정상적으로 연결된것을 확인할수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/755</guid>
      <comments>https://aamoos.tistory.com/755#entry755comment</comments>
      <pubDate>Fri, 14 Feb 2025 15:35:47 +0900</pubDate>
    </item>
    <item>
      <title>[코프링]  10. application.yml 운영, 로컬 분리하기, postgres 설정</title>
      <link>https://aamoos.tistory.com/754</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 application.yml이 하나였는데 로컬, 운영으로 분리해보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;387&quot; data-origin-height=&quot;236&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZEPQM/btsMjgCgnpu/AVaszm57cWRGiNXoGkUjBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZEPQM/btsMjgCgnpu/AVaszm57cWRGiNXoGkUjBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZEPQM/btsMjgCgnpu/AVaszm57cWRGiNXoGkUjBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZEPQM%2FbtsMjgCgnpu%2FAVaszm57cWRGiNXoGkUjBk%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;387&quot; height=&quot;236&quot; data-origin-width=&quot;387&quot; data-origin-height=&quot;236&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;application.yml&lt;/p&gt;
&lt;pre id=&quot;code_1739514080915&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  profiles:
    active: local  # 기본적으로 local 프로파일 사용 (운영에서는 prod로 변경)

jwt:
  expiration_time: 86400000 # 1일
  secret: jwt 시크릿키&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-local.yml&lt;/p&gt;
&lt;pre id=&quot;code_1739514091552&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  h2:
    console:
      enabled: true
      path: /h2-console

  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:management
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    database-platform: org.hibernate.dialect.H2Dialect
    properties:
      hibernate:
        format_sql: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서는 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;application-prod.yml&lt;/p&gt;
&lt;pre id=&quot;code_1739514150082&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://호스트명:5432/데이터베이스명
    username: 사용자명
    password: 패스워드

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: false
    database-platform: org.hibernate.dialect.PostgreSQLDialect
    properties:
      hibernate:
        format_sql: false

logging:
  level:
    org.hibernate.SQL: DEBUG  # Hibernate 쿼리 로그 출력&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영에서는 postgres 데이터베이스를 바라보게 설정&lt;/p&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&lt;/p&gt;
&lt;pre id=&quot;code_1739514176609&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation(&quot;org.postgresql:postgresql&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;posgresql 의존성 추가&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/754</guid>
      <comments>https://aamoos.tistory.com/754#entry754comment</comments>
      <pubDate>Fri, 14 Feb 2025 15:23:10 +0900</pubDate>
    </item>
    <item>
      <title>jsch를 활용한 spring java에서 ssh command 명령어 실행하기</title>
      <link>https://aamoos.tistory.com/753</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;jsch란?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSCH는&amp;nbsp;Java에서&amp;nbsp;SSH&amp;nbsp;프로토콜을&amp;nbsp;지원하는&amp;nbsp;라이브러리로,&amp;nbsp;SFTP,&amp;nbsp;SCP,&amp;nbsp;포트&amp;nbsp;포워딩,&amp;nbsp;터널링&amp;nbsp;등의&amp;nbsp;기능을&amp;nbsp;제공합니다. &lt;br /&gt;Spring에서는&amp;nbsp;JSCH를&amp;nbsp;활용하여&amp;nbsp;원격&amp;nbsp;서버에&amp;nbsp;접속하고&amp;nbsp;파일을&amp;nbsp;업로드/다운로드하는&amp;nbsp;등의&amp;nbsp;작업을&amp;nbsp;수행할&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;pom.xml&lt;/p&gt;
&lt;pre id=&quot;code_1739506083147&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;dependency&amp;gt;
	&amp;lt;groupId&amp;gt;com.jcraft&amp;lt;/groupId&amp;gt;
	&amp;lt;artifactId&amp;gt;jsch&amp;lt;/artifactId&amp;gt;
	&amp;lt;version&amp;gt;0.1.55&amp;lt;/version&amp;gt;
&amp;lt;/dependency&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;build.gradle&lt;/p&gt;
&lt;pre id=&quot;code_1739506134919&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation group: 'com.jcraft', name: 'jsch', version: '0.1.55'&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;/p&gt;
&lt;pre id=&quot;code_1739506351480&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;

...

@ResponseBody
@PostMapping(&quot;/sshCommand.json&quot;)

    public String sshCommand(@RequestBody Map&amp;lt;String, Object&amp;gt; params) {

        String host = &quot;ssh ip를 입력&quot;;  // SSH 서버의 호스트
        int port = port를 입력;
        String user = &quot;로그인 아이디 입력&quot;;     // SSH 로그인 사용자
        String password = &quot;로그인 패스워드 입력&quot;;  // SSH 로그인 비밀번호
        String command = &quot;df -h&quot;;  // ssh command에 입력할 명령어 입력

        Session session = null;
        ChannelExec channel = null;

        try {
            // JSch 객체 생성
            JSch jsch = new JSch();
            session = jsch.getSession(user, host, port);
            session.setPassword(password);

            // SSH 연결 시 호스트 키 확인을 건너뛰기
            session.setConfig(&quot;StrictHostKeyChecking&quot;, &quot;no&quot;);

            // 연결 타임아웃 설정
        	session.connect(10000);

       		//session.connect();



            // 명령어 실행을 위한 채널 열기
            channel = (ChannelExec) session.openChannel(&quot;exec&quot;);
            channel.setCommand(command);  // 쉘 스크립트 실행 명령 설정
            channel.setErrStream(System.err);

            // 명령어 실행
            InputStream inputStream = channel.getInputStream();
            channel.connect();

            // 실행 결과 읽기
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);  // 명령어 실행 결과 출력
            }
            channel.disconnect();
            session.disconnect();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return &quot;ok&quot;;  // 명령어 실행 후 홈으로 리다이렉트

    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- command에는 ssh 명령어 입력창에 입력되는 명령어를 입력합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1739507294467&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//예제 home으로 이동한후 test.sh파일을 실행해라
cd /home &amp;amp;&amp;amp; ./test.sh&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;session time out 현상이 있어서 아래 코드를 추가하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래코드는 session time out을 10초까지 기다린다는 의미입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1739506464721&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;session.connect(10000);&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;jsch 0.1.55 socket is not established error가 발생할경우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- host, post, user, password를 다시 한번 확인해서 입력을 해야 합니다. 해당 정보가 맞지않거나 방화벽이 허용되지 않아, 웹 소켓 연결이 안될경우 발생하는 에러입니다.&lt;/p&gt;</description>
      <category>backend/스프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/753</guid>
      <comments>https://aamoos.tistory.com/753#entry753comment</comments>
      <pubDate>Fri, 14 Feb 2025 13:23:38 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 9. Spring Kotlin Jpa Querydsl Security Jwt 적용</title>
      <link>https://aamoos.tistory.com/752</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;jwt secret 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jwtsecret.com/generate&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://jwtsecret.com/generate&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1739171774099&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;JwtSecret.com - Generate JWT Secrets Online&quot; data-og-description=&quot;Full secret is hidden for security.&quot; data-og-host=&quot;jwtsecret.com&quot; data-og-source-url=&quot;https://jwtsecret.com/generate&quot; data-og-url=&quot;https://jwtsecret.com/generate&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://jwtsecret.com/generate&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://jwtsecret.com/generate&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;JwtSecret.com - Generate JWT Secrets Online&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Full secret is hidden for security.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;jwtsecret.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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;803&quot; data-origin-height=&quot;440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wimh3/btsMd4OQDcr/HRGoLc788C7aKpA2P7PTLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wimh3/btsMd4OQDcr/HRGoLc788C7aKpA2P7PTLK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wimh3/btsMd4OQDcr/HRGoLc788C7aKpA2P7PTLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwimh3%2FbtsMd4OQDcr%2FHRGoLc788C7aKpA2P7PTLK%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;803&quot; height=&quot;440&quot; data-origin-width=&quot;803&quot; data-origin-height=&quot;440&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 위사이트 접속후 32자 클릭, Generate 선택후 복사&lt;/p&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&lt;/p&gt;
&lt;pre id=&quot;code_1739171846435&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  # H2 Console 설정
  h2:
    console:
      enabled: true  # H2 Console을 사용할지 여부
      path: /h2-console  # H2 Console의 접근 경로

  # 데이터베이스 설정
  datasource:
    driver-class-name: org.h2.Driver  # H2 드라이버 사용
    url: jdbc:h2:mem:management  # 메모리 내 데이터베이스 (테스트용)
    username: sa  # 접속할 사용자명
    password:  # 비밀번호 (없으면 공백으로 설정)

  # JPA 설정
  jpa:
    hibernate:
      ddl-auto: create  # 테이블 자동 생성 및 업데이트 (설정에 따라 'none', 'update', 'create', 'create-drop' 등이 가능)
    show-sql: true  # SQL 쿼리를 로그에 출력
    database-platform: org.hibernate.dialect.H2Dialect  # H2 데이터베이스용 Hibernate Dialect 설정
    properties:
      hibernate:
        format_sql: true  # SQL을 보기 쉽게 포맷

jwt:
  expiration_time: 86400000 #1일
  secret: 위에 복사한 key를 여기에 넣으세요&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jwt, expiration_time, secret 추가&lt;/p&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;129&quot; data-origin-height=&quot;32&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btU6yl/btsMcsXBVq1/XL3GA0GW6wm2Zt0XrBgp91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btU6yl/btsMcsXBVq1/XL3GA0GW6wm2Zt0XrBgp91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btU6yl/btsMcsXBVq1/XL3GA0GW6wm2Zt0XrBgp91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtU6yl%2FbtsMcsXBVq1%2FXL3GA0GW6wm2Zt0XrBgp91%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;129&quot; height=&quot;32&quot; data-origin-width=&quot;129&quot; data-origin-height=&quot;32&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;136&quot; data-origin-height=&quot;67&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c9fRnY/btsMdKQCdL6/9X9aBhV9rUHoFRxOlER6N0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c9fRnY/btsMdKQCdL6/9X9aBhV9rUHoFRxOlER6N0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c9fRnY/btsMdKQCdL6/9X9aBhV9rUHoFRxOlER6N0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc9fRnY%2FbtsMdKQCdL6%2F9X9aBhV9rUHoFRxOlER6N0%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;136&quot; height=&quot;67&quot; data-origin-width=&quot;136&quot; data-origin-height=&quot;67&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;238&quot; data-origin-height=&quot;119&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/crKWMT/btsMbXX3dHd/qllu6Kj3RTBK8q8JkyZiZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/crKWMT/btsMbXX3dHd/qllu6Kj3RTBK8q8JkyZiZ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/crKWMT/btsMbXX3dHd/qllu6Kj3RTBK8q8JkyZiZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcrKWMT%2FbtsMbXX3dHd%2Fqllu6Kj3RTBK8q8JkyZiZ0%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;238&quot; height=&quot;119&quot; data-origin-width=&quot;238&quot; data-origin-height=&quot;119&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;219&quot; data-origin-height=&quot;121&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cWz8W3/btsMcfK04RJ/6MaPm510hsMCHvvtHufMg1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cWz8W3/btsMcfK04RJ/6MaPm510hsMCHvvtHufMg1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cWz8W3/btsMcfK04RJ/6MaPm510hsMCHvvtHufMg1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcWz8W3%2FbtsMcfK04RJ%2F6MaPm510hsMCHvvtHufMg1%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;219&quot; height=&quot;121&quot; data-origin-width=&quot;219&quot; data-origin-height=&quot;121&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;build.gradle.kts&lt;/p&gt;
&lt;pre id=&quot;code_1739171972603&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
	id(&quot;org.springframework.boot&quot;) version &quot;3.4.2&quot;
	id(&quot;io.spring.dependency-management&quot;) version &quot;1.1.7&quot;
	kotlin(&quot;jvm&quot;) version &quot;1.9.25&quot;
	kotlin(&quot;plugin.spring&quot;) version &quot;1.9.25&quot;
	kotlin(&quot;plugin.jpa&quot;) version &quot;1.9.25&quot;
	kotlin(&quot;kapt&quot;) version &quot;1.9.25&quot;
}

group = &quot;com.contact&quot;
version = &quot;0.0.1-SNAPSHOT&quot;

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

repositories {
	mavenCentral()
}

dependencies {
	implementation(&quot;org.springframework.boot:spring-boot-starter-data-jpa&quot;)
	implementation(&quot;org.springframework.boot:spring-boot-starter-web&quot;)
	implementation(&quot;org.jetbrains.kotlin:kotlin-stdlib-jdk8&quot;)
	implementation(&quot;org.jetbrains.kotlin:kotlin-reflect&quot;)
	implementation(&quot;com.fasterxml.jackson.module:jackson-module-kotlin&quot;)
	implementation(&quot;org.springframework.boot:spring-boot-starter-validation&quot;)

	//jwt
	implementation(&quot;io.jsonwebtoken:jjwt-api:0.11.5&quot;)
	implementation(&quot;io.jsonwebtoken:jjwt-impl:0.11.5&quot;)
	implementation(&quot;io.jsonwebtoken:jjwt-jackson:0.11.5&quot;)

	// QueryDSL 의존성 추가
	implementation(&quot;com.querydsl:querydsl-jpa:5.1.0:jakarta&quot;)
	kapt(&quot;com.querydsl:querydsl-apt:5.1.0:jakarta&quot;)
	kapt(&quot;jakarta.annotation:jakarta.annotation-api&quot;)
	kapt(&quot;jakarta.persistence:jakarta.persistence-api&quot;)

	implementation (&quot;org.springframework.boot:spring-boot-starter-security&quot;)

	runtimeOnly(&quot;com.h2database:h2&quot;)
	testImplementation(&quot;org.springframework.boot:spring-boot-starter-test&quot;)
}

// Querydsl 설정부 추가 - start
val generated = file(&quot;src/main/generated&quot;)

// querydsl QClass 파일 생성 위치를 지정
tasks.withType&amp;lt;JavaCompile&amp;gt; {
	options.generatedSourceOutputDirectory.set(generated)
}

// kotlin source set 에 querydsl QClass 위치 추가
sourceSets {
	main {
		kotlin.srcDirs += generated
	}
}

// gradle clean 시에 QClass 디렉토리 삭제
tasks.named(&quot;clean&quot;) {
	doLast {
		generated.deleteRecursively()
	}
}
kapt {
	generateStubs = 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;- 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;- jjwt-api (io.jsonwebtoken:jjwt-api:0.11.5)&lt;br /&gt;JWT를 생성, 서명, 검증할 수 있는 API를 제공하는 라이브러리&lt;br /&gt;예를&amp;nbsp;들어,&amp;nbsp;JWT를&amp;nbsp;생성할&amp;nbsp;때&amp;nbsp;Jwts.builder()&amp;nbsp;같은&amp;nbsp;기능을&amp;nbsp;사용할&amp;nbsp;수&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;- jjwt-impl (io.jsonwebtoken:jjwt-impl:0.11.5) &lt;br /&gt;jjwt-api에서 제공하는 기능의 실제 구현체&lt;br /&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;- jjwt-jackson (io.jsonwebtoken:jjwt-jackson:0.11.5)&lt;br /&gt;JSON 파싱을 위해 Jackson을 활용하는 구현체&lt;br /&gt;JWT의 Payload(내용) 을 JSON 형식으로 변환하거나, JSON을 객체로 변환할 때 필요&lt;br /&gt;Jackson을&amp;nbsp;사용하여&amp;nbsp;claims(클레임,&amp;nbsp;JWT의&amp;nbsp;데이터)&amp;nbsp;정보를&amp;nbsp;다룰&amp;nbsp;수&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;User.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172234703&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.entity

import com.contact.management.audit.BaseEntity
import com.contact.management.dto.SignUpRequest
import com.contact.management.dto.UserUpdateRequest
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.springframework.security.crypto.password.PasswordEncoder

@Entity
@Table(name = &quot;users&quot;)
class User (
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long?= null,
    var password: String,
    var name: String,
    var age: Int,
    var email: String,

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;ROLE&quot;, nullable = false)
    var role: RoleType // Adding the role field

) : BaseEntity(){
    companion object {
        fun from(request: SignUpRequest, encoder: PasswordEncoder) = User(
            password = encoder.encode(request.password),
            name = request.name,
            age = request.age,
            email = request.email,
            role = RoleType.ROLE_USER
        )
    }

    fun update(request: UserUpdateRequest, encoder: PasswordEncoder){
        this.password = encoder.encode(request.password)
        this.name = request.name
        this.age = request.age
    }

}&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;- 사용자 권한 role 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RoleType.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172575506&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.entity

enum class RoleType {
    ROLE_USER,  // 일반 사용자
    ROLE_ADMIN  // 관리자
}&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtAccessDeniedHandler.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172309940&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.jwt

import jakarta.servlet.ServletException
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.stereotype.Component
import java.io.IOException

/**
 *  필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러 return
 */
@Component
class JwtAccessDeniedHandler : AccessDeniedHandler {
    @Throws(IOException::class, ServletException::class)
    override fun handle(request: HttpServletRequest, response: HttpServletResponse, accessDeniedException: AccessDeniedException) {
        response.sendError(HttpServletResponse.SC_FORBIDDEN)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이 코드는 Spring Security에서 JWT 기반 인증 및 권한 관리를 할 때, 사용자가 필요한 권한이 없을 경우 403 Forbidden 응답을 반환하는 역할을 하는 클래스&lt;/p&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;1. 사용자가 보호된 API 엔드포인트에 요청 &lt;br /&gt;2. Spring Security가 사용자의 JWT 토큰을 검증 &lt;br /&gt;3. 사용자가 필요한 권한이 없으면 JwtAccessDeniedHandler의 handle() 메서드가 호출됨 &lt;br /&gt;4. 클라이언트에게 403 Forbidden 응답 반환&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtAuthenticationEntryPoint.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172327034&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.jwt

import jakarta.servlet.ServletException
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component
import java.io.IOException

/**
 *  유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 에러 리턴
 */
@Component
class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
    @Throws(IOException::class, ServletException::class)
    override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
    }
}&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;JwtAuthenticationEntryPoint는 로그인하지 않은 사용자가 보호된 API에 접근하면 401 응답을 반환&lt;br /&gt;@Component를 사용하여 Spring Bean으로 등록되며, Security 설정에서 사용됩&lt;br /&gt;AuthenticationEntryPoint 인터페이스를 구현하여 Spring Security에서 인증되지 않은 요청을 처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtAuthFilter.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172351508&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.jwt

import com.contact.management.security.CustomUserDetailsService
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletException
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.filter.OncePerRequestFilter
import java.io.IOException

class JwtAuthFilter(
    private val customUserDetailsService: CustomUserDetailsService,
    private val jwtUtil: JwtUtil
) : OncePerRequestFilter() { // OncePerRequestFilter -&amp;gt; 한 번 실행 보장

    @Throws(ServletException::class, IOException::class)
    override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
        val authorizationHeader = request.getHeader(&quot;Authorization&quot;)

        // JWT가 헤더에 있는 경우
        if (authorizationHeader != null &amp;amp;&amp;amp; authorizationHeader.startsWith(&quot;Bearer &quot;)) {
            val token = authorizationHeader.substring(7)
            // JWT 유효성 검증
            if (jwtUtil.validateToken(token)) {
                val userId = jwtUtil.getUserId(token)

                // 유저와 토큰 일치 시 userDetails 생성
                val userDetails: UserDetails = customUserDetailsService.loadUserByUsername(userId.toString())

                val usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.authorities
                )
                SecurityContextHolder.getContext().authentication = usernamePasswordAuthenticationToken
            }
        }

        filterChain.doFilter(request, response) // 다음 필터로 넘기기
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. HTTP 요청의 &quot;Authorization&quot; 헤더에서 JWT를 추출. &lt;br /&gt;2. JWT가 &quot;Bearer &quot;로 시작하는지 확인. &lt;br /&gt;3. JWT의 유효성을 검사. &lt;br /&gt;4. 유효하면 userId를 추출하여 UserDetails 객체를 생성. &lt;br /&gt;5. UsernamePasswordAuthenticationToken을 생성하고 SecurityContextHolder에 저장. &lt;br /&gt;6. 인증이 완료된 요청으로 설정한 후, 다음 필터로 넘김.&lt;/p&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;JWT&amp;nbsp;인증을&amp;nbsp;처리하는&amp;nbsp;필터로,&amp;nbsp;요청을&amp;nbsp;가로채어&amp;nbsp;JWT를&amp;nbsp;검증하고&amp;nbsp;SecurityContext에&amp;nbsp;인증&amp;nbsp;정보를&amp;nbsp;저장하는&amp;nbsp;역할. &lt;br /&gt;JWT를&amp;nbsp;헤더에서&amp;nbsp;추출하고&amp;nbsp;유효성을&amp;nbsp;검사한&amp;nbsp;후,&amp;nbsp;해당&amp;nbsp;사용자&amp;nbsp;정보를&amp;nbsp;SecurityContextHolder에&amp;nbsp;저장하여&amp;nbsp;Spring&amp;nbsp;Security가&amp;nbsp;인증된&amp;nbsp;사용자로&amp;nbsp;인식하도록&amp;nbsp;설정. &lt;br /&gt;Spring&amp;nbsp;Security&amp;nbsp;설정에서&amp;nbsp;필터로&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;JwtUtil.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172369160&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.jwt

import com.contact.management.exception.CommonException
import com.contact.management.exception.CommonExceptionCode
import com.contact.management.security.CustomUserDto
import io.jsonwebtoken.*
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.security.Key
import java.time.ZonedDateTime
import java.util.*

/**
 * [JWT 관련 메서드를 제공하는 클래스]
 */
@Component
class JwtUtil(
    @Value(&quot;\${jwt.secret}&quot;) secretKey: String,
    @Value(&quot;\${jwt.expiration_time}&quot;) private val accessTokenExpTime: Long
) {
    private val log: Logger = LoggerFactory.getLogger(JwtUtil::class.java)
    private val key: Key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))

    /**
     * Access Token 생성
     * @param user
     * @return Access Token String
     */
    fun createAccessToken(user: CustomUserDto): String {
        return createToken(user, accessTokenExpTime)
    }

    /**
     * JWT 생성
     * @param user
     * @param expireTime
     * @return JWT String
     */
    private fun createToken(user: CustomUserDto, expireTime: Long): String {
        val claims: Claims = Jwts.claims().apply {
            put(&quot;id&quot;, user.id)
            put(&quot;email&quot;, user.email)
            put(&quot;authorities&quot;, user.role)
        }

        val now = ZonedDateTime.now()
        val tokenValidity = now.plusSeconds(expireTime)

        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(Date.from(now.toInstant()))
            .setExpiration(Date.from(tokenValidity.toInstant()))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact()
    }

    /**
     * Token에서 User ID 추출
     * @param token
     * @return User ID
     */
    fun getUserId(token: String): Long {
        val id = parseClaims(token)[&quot;id&quot;, Integer::class.java]  // Read the claim as an Integer
        return id?.toLong() ?: throw CommonException(CommonExceptionCode.INVALID_AUTH_TOKEN) // Convert Integer to Long
    }

    /**
     * JWT 검증
     * @param token
     * @return IsValidate
     */
    fun validateToken(token: String): Boolean {
        return try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)
            true
        } catch (e: io.jsonwebtoken.security.SecurityException) {
            log.info(&quot;Invalid JWT Token&quot;, e)
            false
        } catch (e: MalformedJwtException) {
            log.info(&quot;Invalid JWT Token&quot;, e)
            false
        } catch (e: ExpiredJwtException) {
            log.info(&quot;Expired JWT Token&quot;, e)
            false
        } catch (e: UnsupportedJwtException) {
            log.info(&quot;Unsupported JWT Token&quot;, e)
            false
        } catch (e: IllegalArgumentException) {
            log.info(&quot;JWT claims string is empty.&quot;, e)
            false
        }
    }

    /**
     * JWT Claims 추출
     * @param accessToken
     * @return JWT Claims
     */
    fun parseClaims(accessToken: String): Claims {
        return try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).body
        } catch (e: ExpiredJwtException) {
            e.claims
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 JWT(JSON Web Token) 관련 유틸리티 클래스로,&lt;br /&gt;JWT&amp;nbsp;생성 &lt;br /&gt;JWT&amp;nbsp;검증 &lt;br /&gt;JWT에서&amp;nbsp;사용자&amp;nbsp;정보&amp;nbsp;추출 &lt;br /&gt;하는 기능을 제공&lt;br /&gt;Spring&amp;nbsp;Security&amp;nbsp;기반으로&amp;nbsp;JWT&amp;nbsp;인증을&amp;nbsp;처리하는&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;JWT 인증 흐름&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가&amp;nbsp;로그인&amp;nbsp;&amp;rarr;&amp;nbsp;JwtUtil.createAccessToken(user)&amp;nbsp;실행&amp;nbsp;&amp;rarr;&amp;nbsp;JWT&amp;nbsp;생성&amp;nbsp;후&amp;nbsp;반환. &lt;br /&gt;클라이언트가&amp;nbsp;JWT를&amp;nbsp;Authorization&amp;nbsp;헤더에&amp;nbsp;담아&amp;nbsp;요청. &lt;br /&gt;JwtAuthFilter가&amp;nbsp;요청을&amp;nbsp;가로채&amp;nbsp;JwtUtil.validateToken(token)&amp;nbsp;실행&amp;nbsp;&amp;rarr;&amp;nbsp;JWT가&amp;nbsp;유효하면&amp;nbsp;SecurityContext에&amp;nbsp;인증&amp;nbsp;정보&amp;nbsp;저장. &lt;br /&gt;Spring&amp;nbsp;Security는&amp;nbsp;SecurityContext에서&amp;nbsp;인증된&amp;nbsp;사용자&amp;nbsp;정보를&amp;nbsp;가져와&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;CustomUserDetails.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172405233&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.security

import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails

class CustomUserDetails(
    private val member: CustomUserDto // Change from CustomUserInfoDto to CustomUserDto
) : UserDetails {

    override fun getAuthorities(): Collection&amp;lt;GrantedAuthority&amp;gt; {
        // RoleType에 따라 권한 리스트를 생성
        val roles = listOf(member.role).map { role -&amp;gt; SimpleGrantedAuthority(role.name) }
        return roles
    }

    override fun getPassword(): String = member.password // Ensure the DTO has password if needed

    override fun getUsername(): String = member.id.toString() // or member.name if that's preferred

    override fun isAccountNonExpired(): Boolean = true

    override fun isAccountNonLocked(): Boolean = true

    override fun isCredentialsNonExpired(): Boolean = true

    override fun isEnabled(): Boolean = true
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CustomUserDetails 클래스는 사용자 정보를 Spring Security에 맞는 형태로 변환하여 제공&lt;br /&gt;이를 통해 Spring Security의 인증(Authentication) 및 인가(Authorization) 절차가 정상적으로 처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CustomUserDetailsService.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172426264&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.security

import com.contact.management.entity.User
import com.contact.management.repository.UserRepository
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional(readOnly = true)
class CustomUserDetailsService(
    private val userRepository: UserRepository
) : UserDetailsService {

    override fun loadUserByUsername(id: String): UserDetails {
        val user: User = userRepository.findById(id.toLong())
            .orElseThrow { UsernameNotFoundException(&quot;해당하는 유저가 없습니다.&quot;) }

        // entity -&amp;gt; dto 변환
        val dto = CustomUserDto.from(user)

        return CustomUserDetails(dto)
    }
}&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;DB에서&amp;nbsp;사용자&amp;nbsp;조회&amp;nbsp;(UserRepository&amp;nbsp;이용) &lt;br /&gt;찾은&amp;nbsp;User&amp;nbsp;엔티티를&amp;nbsp;CustomUserDto로&amp;nbsp;변환 &lt;br /&gt;Spring&amp;nbsp;Security의&amp;nbsp;UserDetails&amp;nbsp;객체(CustomUserDetails)로&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;CustomUserDto.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172445880&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.security

//내부에서 사용하는 dto
import com.contact.management.entity.RoleType
import com.contact.management.entity.User

data class CustomUserDto(
    val id: Long?= null,
    var password: String,
    val name: String,
    val age: Int,
    val email: String,
    val role: RoleType
) {
    companion object {
        // Factory method for creating CustomUserDto from User entity
        fun from(user: User): CustomUserDto {
            return CustomUserDto(
                id = user.id,
                password = user.password,
                name = user.name,
                age = user.age,
                email = user.email,
                role = user.role
            )
        }
    }
}&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;SecurityConfig.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739172472301&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.security

import com.contact.management.jwt.JwtAccessDeniedHandler
import com.contact.management.jwt.JwtAuthFilter
import com.contact.management.jwt.JwtAuthenticationEntryPoint
import com.contact.management.jwt.JwtUtil
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.autoconfigure.security.servlet.PathRequest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
//@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)         //PreAuthorize 사용하기위한
class SecurityConfig(
    private val customUserDetailsService: CustomUserDetailsService,
    private val jwtUtil: JwtUtil,
    private val accessDeniedHandler: JwtAccessDeniedHandler,
    private val authenticationEntryPoint: JwtAuthenticationEntryPoint
) {

    companion object {
        private val AUTH_WHITELIST = arrayOf(
            &quot;/api/users/**&quot;, // Allows unrestricted access to this endpoint
        )
    }

    @Bean
    @ConditionalOnProperty(name = [&quot;spring.h2.console.enabled&quot;], havingValue = &quot;true&quot;)
    fun configureH2ConsoleEnable(): WebSecurityCustomizer {
        return WebSecurityCustomizer { web -&amp;gt; web.ignoring().requestMatchers(PathRequest.toH2Console()) }
    }

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .csrf { it.disable() }
            .cors { }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests {
                it
                    .requestMatchers(*AUTH_WHITELIST).permitAll()
                    .requestMatchers(&quot;/api/test/users/**&quot;).hasAuthority(&quot;ROLE_USER&quot;)   //USER 역할만 접근 허용
                    .requestMatchers(&quot;/api/test/admin/**&quot;).hasAuthority(&quot;ROLE_ADMIN&quot;)  // ADMIN 역할만 접근 허용
                    .anyRequest().authenticated()
            }
            .exceptionHandling {
                it.authenticationEntryPoint(authenticationEntryPoint)
                    .accessDeniedHandler(accessDeniedHandler)
            }
            .addFilterBefore(JwtAuthFilter(customUserDetailsService, jwtUtil), UsernamePasswordAuthenticationFilter::class.java)
            .formLogin { it.disable() }
            .httpBasic { it.disable() }
        return http.build()
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder = 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;JWT&amp;nbsp;기반&amp;nbsp;인증&amp;nbsp;적용&amp;nbsp;(JwtAuthFilter&amp;nbsp;추가) &lt;br /&gt;세션&amp;nbsp;사용&amp;nbsp;안&amp;nbsp;함&amp;nbsp;(STATELESS&amp;nbsp;설정) &lt;br /&gt;CSRF&amp;nbsp;및&amp;nbsp;기본&amp;nbsp;로그인&amp;nbsp;기능&amp;nbsp;비활성화 &lt;br /&gt;예외&amp;nbsp;처리&amp;nbsp;설정&amp;nbsp;(JwtAccessDeniedHandler,&amp;nbsp;JwtAuthenticationEntryPoint) &lt;br /&gt;H2&amp;nbsp;콘솔&amp;nbsp;보안&amp;nbsp;설정&amp;nbsp;(ConditionalOnProperty) &lt;br /&gt;비밀번호&amp;nbsp;암호화&amp;nbsp;(BCryptPasswordEncoder)&lt;/p&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;CSRF 보호 비활성화 (csrf { it.disable() })&lt;br /&gt;JWT&amp;nbsp;기반&amp;nbsp;인증에서는&amp;nbsp;CSRF&amp;nbsp;토큰이&amp;nbsp;필요하지&amp;nbsp;않음. &lt;br /&gt;CORS 설정 (cors { })&lt;br /&gt;기본&amp;nbsp;CORS&amp;nbsp;설정&amp;nbsp;(필요시&amp;nbsp;CorsFilter&amp;nbsp;추가&amp;nbsp;가능). &lt;br /&gt;세션 미사용 (SessionCreationPolicy.STATELESS)&lt;br /&gt;JWT&amp;nbsp;기반&amp;nbsp;인증&amp;nbsp;방식에서는&amp;nbsp;세션을&amp;nbsp;사용하지&amp;nbsp;않음. &lt;br /&gt;기본 로그인 방식 비활성화&lt;br /&gt;formLogin&amp;nbsp;{&amp;nbsp;it.disable()&amp;nbsp;}&amp;nbsp;&amp;rarr;&amp;nbsp;기본&amp;nbsp;폼&amp;nbsp;로그인&amp;nbsp;X. &lt;br /&gt;httpBasic&amp;nbsp;{&amp;nbsp;it.disable()&amp;nbsp;}&amp;nbsp;&amp;rarr;&amp;nbsp;HTTP&amp;nbsp;Basic&amp;nbsp;인증&amp;nbsp;X.&lt;/p&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 data-ke-size=&quot;size16&quot;&gt;1. 사용자 로그인 요청 &lt;br /&gt;/api/users/login&amp;nbsp;엔드포인트로&amp;nbsp;로그인&amp;nbsp;요청&amp;nbsp;(AUTH_WHITELIST에&amp;nbsp;포함됨). &lt;br /&gt;2. 로그인 성공 시 JWT 토큰 발급. &lt;br /&gt;요청 헤더에 JWT 포함 &lt;br /&gt;클라이언트는&amp;nbsp;이후&amp;nbsp;요청마다&amp;nbsp;Authorization:&amp;nbsp;Bearer&amp;nbsp;&amp;lt;JWT&amp;gt;&amp;nbsp;헤더를&amp;nbsp;포함하여&amp;nbsp;보냄. &lt;br /&gt;3. JwtAuthFilter에서 JWT 검증 &lt;br /&gt;JWT가&amp;nbsp;유효하면&amp;nbsp;SecurityContext에&amp;nbsp;사용자&amp;nbsp;정보&amp;nbsp;저장. &lt;br /&gt;4. 요청 인증 처리 &lt;br /&gt;SecurityFilterChain에서&amp;nbsp;설정한&amp;nbsp;대로&amp;nbsp;인증이&amp;nbsp;필요한&amp;nbsp;요청인지&amp;nbsp;확인&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;UserController.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739173400693&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.controller

import com.contact.management.dto.LoginRequest
import com.contact.management.dto.SignUpRequest
import com.contact.management.dto.UserResponse
import com.contact.management.dto.UserUpdateRequest
import com.contact.management.service.QuerydslUserService
import com.contact.management.service.UserService
import jakarta.validation.Valid
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.*
import kotlin.math.log

@RestController
@RequestMapping(&quot;/api/users&quot;)
class UserController(
     private val userService: UserService
    ,private val querydslUserService: QuerydslUserService
) {

    @GetMapping
    fun getUsers(): ResponseEntity&amp;lt;List&amp;lt;UserResponse&amp;gt;&amp;gt; {
        val users = userService.getAllUsers()
        return ResponseEntity.ok(users)
    }

    @GetMapping(&quot;/{id}&quot;)
    fun getUser(@PathVariable id: Long): ResponseEntity&amp;lt;UserResponse&amp;gt; {
        return ResponseEntity.ok(userService.getUserById(id))
    }

    @PostMapping
    fun createUser(@Valid @RequestBody request: SignUpRequest): ResponseEntity&amp;lt;UserResponse&amp;gt; {
        val createdUser = userService.createdUser(request)
        return ResponseEntity
            .status(HttpStatus.CREATED) // 201 Created
            .header(HttpHeaders.LOCATION, &quot;/api/users/${createdUser.id}&quot;) // 생성된 리소스의 URI 반환
            .body(createdUser)
    }

    @PutMapping(&quot;/{id}&quot;)
    fun updateUser(@PathVariable id: Long,@Valid @RequestBody request: UserUpdateRequest): ResponseEntity&amp;lt;UserResponse&amp;gt; {
        val updatedUser = userService.updateUser(id, request)
        return ResponseEntity.ok(updatedUser)
    }

    @DeleteMapping(&quot;/{id}&quot;)
    fun deleteUser(@PathVariable id: Long): ResponseEntity&amp;lt;Void&amp;gt; {
        userService.deleteUser(id)
        return ResponseEntity.noContent().build() // 204 No Content
    }

    @GetMapping(&quot;/paged&quot;)
    fun getUsersWithPaging(pageable: Pageable, @RequestParam searchVal: String?): Page&amp;lt;UserResponse&amp;gt; {
        // 페이징된 사용자 목록 반환
        return querydslUserService.getUsersWithPaging(pageable, searchVal)
    }

    @PostMapping(&quot;/login&quot;)
    fun login(@Valid @RequestBody loginRequest: LoginRequest): ResponseEntity&amp;lt;String&amp;gt;{
        val token = userService.login(loginRequest)
        return ResponseEntity.ok(token)
    }
}&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;/login 추가&lt;/p&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.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739173421410&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.service

import com.contact.management.dto.LoginRequest
import com.contact.management.dto.SignUpRequest
import com.contact.management.dto.UserResponse
import com.contact.management.dto.UserUpdateRequest
import com.contact.management.entity.QUser
import com.contact.management.entity.User
import com.contact.management.exception.CommonException
import com.contact.management.exception.CommonExceptionCode.EMAIL_ALREADY_EXISTS
import com.contact.management.exception.CommonExceptionCode.USER_NOT_FOUND
import com.contact.management.jwt.JwtUtil
import com.contact.management.repository.UserRepository
import com.contact.management.security.CustomUserDto
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class UserService(
     private val userRepository: UserRepository
    ,private val queryFactory: JPAQueryFactory
    ,private val encoder: PasswordEncoder
    ,private val jwtUtil: JwtUtil
) {

    @Transactional(readOnly = true)
    fun getAllUsers() : List&amp;lt;UserResponse&amp;gt; {
        return userRepository.findAll().map { UserResponse.from(it) }
    }

    @Transactional(readOnly = true)
    fun getUserById(id: Long): UserResponse {
        val user = userRepository.findById(id).orElseThrow{ CommonException(USER_NOT_FOUND) }
        return UserResponse.from(user)
    }

    fun createdUser(request: SignUpRequest): UserResponse {
        userRepository.findByEmail(request.email)?.let {
            throw CommonException(EMAIL_ALREADY_EXISTS)
        }

        val user = userRepository.save(User.from(request, encoder))
        return UserResponse.from(user)
    }

    // 사용자 업데이트
    fun updateUser(id: Long, request: UserUpdateRequest): UserResponse{
        val user = userRepository.findById(id).orElseThrow{ CommonException(USER_NOT_FOUND) }

        user.update(request, encoder)

        return UserResponse.from(user)
    }

    fun deleteUser(id: Long){
        if(!userRepository.existsById(id)){
            throw CommonException(USER_NOT_FOUND)
        }
        userRepository.deleteById(id)
    }

    @Transactional(readOnly = true)
    fun getUsersWithPaging(pageable: Pageable): Page&amp;lt;UserResponse&amp;gt; {
        val qUser = QUser.user

        // JPAQueryFactory를 사용하여 쿼리 작성
        val query = queryFactory
            .selectFrom(qUser)
            .orderBy(qUser.id.asc()) // ID 기준으로 오름차순 정렬 (필요에 따라 변경 가능)

        // 실제 데이터 조회
        val users = query
            .offset(pageable.offset) // 페이지의 시작 인덱스
            .limit(pageable.pageSize.toLong()) // 페이지 크기
            .fetch() // 결과 가져오기

        // 총 개수를 구하는 쿼리 작성 (countQuery)
        val countQuery = queryFactory
            .select(qUser.count()) // 총 개수 계산
            .from(qUser)
        val total = countQuery.fetchOne() ?: 0L // fetchOne()으로 단일 값 가져오기

        // Page 객체로 반환
        val userDtos = users.map { UserResponse.from(it) }
        return PageImpl(userDtos, pageable, total)
    }

    @Transactional(readOnly = true)
    fun login(request: LoginRequest): String {
        val email = request.email
        val password = request.password
        val user = userRepository.findByEmail(email) ?: throw CommonException(USER_NOT_FOUND)

        // 암호화된 password를 디코딩한 값과 입력한 패스워드 값이 다르면 예외를 던짐
        if (!encoder.matches(password, user.password)) {
            throw CommonException(USER_NOT_FOUND)
        }

        val info = CustomUserDto.from(user)
        return jwtUtil.createAccessToken(info)
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;login 메소드 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인시 해당 계정이 존재하지않는경우 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;USER_NOT_FOUND&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암호화된 패스워드를 디코딩한 값과 입력한 패스워드값이 다를경우 USER_NOT_FOUND ERROR&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통과시 jwt access token 생성 후 return&lt;/p&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&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;714&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mGjom/btsMbFXyhSA/xOx75b9iZz54gCkWLcmAS1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mGjom/btsMbFXyhSA/xOx75b9iZz54gCkWLcmAS1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mGjom/btsMbFXyhSA/xOx75b9iZz54gCkWLcmAS1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmGjom%2FbtsMbFXyhSA%2FxOx75b9iZz54gCkWLcmAS1%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;714&quot; height=&quot;506&quot; data-origin-width=&quot;714&quot; data-origin-height=&quot;506&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;504&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsP9NH/btsMdlwPIdO/JHjZ6bKkQnXTwjTZFBeNPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsP9NH/btsMdlwPIdO/JHjZ6bKkQnXTwjTZFBeNPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsP9NH/btsMdlwPIdO/JHjZ6bKkQnXTwjTZFBeNPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsP9NH%2FbtsMdlwPIdO%2FJHjZ6bKkQnXTwjTZFBeNPK%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;504&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;504&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;token값 return&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TestController.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739173735643&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.controller

import com.contact.management.dto.LoginRequest
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController

@RestController
class TestController {

    @GetMapping(&quot;/test&quot;)
    fun test(): String{
        return &quot;ok&quot;;
    }

    @PostMapping(&quot;/api/test/user&quot;)
//    @PreAuthorize(&quot;hasRole('ROLE_USER')&quot;)
    fun userTest(@RequestBody loginRequest: LoginRequest): LoginRequest {
        return loginRequest
    }

    @PostMapping(&quot;/api/test/admin&quot;)
//    @PreAuthorize(&quot;hasRole('ROLE_ADMIN')&quot;)
    fun adminTest(@RequestBody loginRequest: LoginRequest): LoginRequest {
        return loginRequest
    }

}&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;users 계정으로 /api/test/user 호출&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;727&quot; data-origin-height=&quot;473&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tHc2W/btsMcYvPqu0/2sx7v1ZWMerMvq4GKwmow0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tHc2W/btsMcYvPqu0/2sx7v1ZWMerMvq4GKwmow0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tHc2W/btsMcYvPqu0/2sx7v1ZWMerMvq4GKwmow0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtHc2W%2FbtsMcYvPqu0%2F2sx7v1ZWMerMvq4GKwmow0%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;727&quot; height=&quot;473&quot; data-origin-width=&quot;727&quot; data-origin-height=&quot;473&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;user계정으로 /api/test/admin 호출&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;445&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kPFQW/btsMet2zBlc/y4fwJ8I4BLSDn6lvOyuuk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kPFQW/btsMet2zBlc/y4fwJ8I4BLSDn6lvOyuuk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kPFQW/btsMet2zBlc/y4fwJ8I4BLSDn6lvOyuuk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkPFQW%2FbtsMet2zBlc%2Fy4fwJ8I4BLSDn6lvOyuuk1%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;739&quot; height=&quot;445&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;445&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;user 계정으로 admin api를 호출하였을때, 403이 나와야할것 같은데, 401이 나옴 원인 파악중..&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/752</guid>
      <comments>https://aamoos.tistory.com/752#entry752comment</comments>
      <pubDate>Tue, 11 Feb 2025 14:27:42 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 8. Companion Object - 팩토리 메서드 패턴 설정</title>
      <link>https://aamoos.tistory.com/751</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;팩토리&amp;nbsp;메서드&amp;nbsp;패턴이란? &lt;br /&gt;팩토리 메서드 패턴은 객체 생성을 캡슐화하여, 객체 생성 로직을 별도의 메서드로 분리하는 디자인 패턴 이를 통해 객체 생성 방식을 통제하고, 생성 과정이 변경되더라도 클라이언트 코드의 수정을 최소화&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존방식 (팩토리 메서드 미적용)&lt;/p&gt;
&lt;pre id=&quot;code_1739153088363&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val user = User(
    password = encoder.encode(signUpRequest.password),
    name = signUpRequest.name,
    age = signUpRequest.age,
    email = signUpRequest.email
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 기존 방식의코드는 api 메소드 마다 user 값을 setting 해주려면 하나하나 전부 setting 해줘야하는 단점이 있다.&lt;/p&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_1739153128218&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val user = User.from(signUpRequest, encoder)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 팩토리 메서드를 적용하면 api 메서드 마다 .from을 호출하는식으로 캡슐화를 해서, 코드도 더 간결해 보인다.&lt;/p&gt;
&lt;p 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;from 메서드는 SignUpRequest DTO와 PasswordEncoder를 받아, User 객체를 생성하는 역할&lt;br /&gt;비밀번호는 평문으로 저장하면 보안상 문제가 있기 때문에, encoder.encode(request.password)를 통해 암호화된 비밀번호를 저장하는 로직을 캡슐화&lt;br /&gt;이를 통해 User 객체를 직접 생성하지 않고, from 메서드를 통해 일관된 방식으로 안전하게 객체를 생성&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;- 팩토리&amp;nbsp;메서드&amp;nbsp;패턴의&amp;nbsp;장점&amp;nbsp;적용&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;객체&amp;nbsp;생성을&amp;nbsp;캡슐화&lt;br /&gt;User 객체의 생성 로직이 한 곳(from)에 모여 있어, 객체 생성을 단순화하고 코드 중복을 방지&lt;br /&gt;유지보수성&amp;nbsp;증가&lt;br /&gt;만약&amp;nbsp;User&amp;nbsp;생성&amp;nbsp;방식이&amp;nbsp;바뀌어도&amp;nbsp;from&amp;nbsp;메서드만&amp;nbsp;수정하면&amp;nbsp;되므로,&amp;nbsp;다른&amp;nbsp;코드에&amp;nbsp;영향을&amp;nbsp;주지&amp;nbsp;않습니다.&lt;br /&gt;의미&amp;nbsp;있는&amp;nbsp;생성&amp;nbsp;메서드&amp;nbsp;제공&lt;br /&gt;from(request, encoder)는 User 객체를 생성하는 의미를 직관적으로 전달&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;그러면 이전에 작성한 코드에서 팩토리 메서드 패턴을 적용해 보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;User.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739151863801&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.entity

import com.contact.management.audit.BaseEntity
import com.contact.management.dto.SignUpRequest
import com.contact.management.dto.UserUpdateRequest
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.springframework.security.crypto.password.PasswordEncoder

@Entity
@Table(name = &quot;users&quot;)
class User (
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long?= null,
    var password: String,
    var name: String,
    var age: Int,
    var email: String
) : BaseEntity(){
    companion object {
        fun from(request: SignUpRequest, encoder: PasswordEncoder) = User(
            password = encoder.encode(request.password),
            name = request.name,
            age = request.age,
            email = request.email
        )
    }

    fun update(request: UserUpdateRequest, encoder: PasswordEncoder){
        this.password = encoder.encode(request.password)
        this.name = request.name
        this.age = request.age
    }

}&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;UserController.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739152053369&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.controller

import com.contact.management.dto.SignUpRequest
import com.contact.management.dto.UserResponse
import com.contact.management.dto.UserUpdateRequest
import com.contact.management.service.QuerydslUserService
import com.contact.management.service.UserService
import jakarta.validation.Valid
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping(&quot;/api/users&quot;)
class UserController(
     private val userService: UserService
    ,private val querydslUserService: QuerydslUserService
) {

    @GetMapping
    fun getUsers(): ResponseEntity&amp;lt;List&amp;lt;UserResponse&amp;gt;&amp;gt; {
        val users = userService.getAllUsers()
        return ResponseEntity.ok(users)
    }

    @GetMapping(&quot;/{id}&quot;)
    fun getUser(@PathVariable id: Long): ResponseEntity&amp;lt;UserResponse&amp;gt; {
        return ResponseEntity.ok(userService.getUserById(id))
    }

    @PostMapping
    fun createUser(@Valid @RequestBody request: SignUpRequest): ResponseEntity&amp;lt;UserResponse&amp;gt; {
        val createdUser = userService.createdUser(request)
        return ResponseEntity
            .status(HttpStatus.CREATED) // 201 Created
            .header(HttpHeaders.LOCATION, &quot;/api/users/${createdUser.id}&quot;) // 생성된 리소스의 URI 반환
            .body(createdUser)
    }

    @PutMapping(&quot;/{id}&quot;)
    fun updateUser(@PathVariable id: Long,@Valid @RequestBody request: UserUpdateRequest): ResponseEntity&amp;lt;UserResponse&amp;gt; {
        val updatedUser = userService.updateUser(id, request)
        return ResponseEntity.ok(updatedUser)
    }

    @DeleteMapping(&quot;/{id}&quot;)
    fun deleteUser(@PathVariable id: Long): ResponseEntity&amp;lt;Void&amp;gt; {
        userService.deleteUser(id)
        return ResponseEntity.noContent().build() // 204 No Content
    }

    @GetMapping(&quot;/paged&quot;)
    fun getUsersWithPaging(pageable: Pageable, @RequestParam searchVal: String?): Page&amp;lt;UserResponse&amp;gt; {
        // 페이징된 사용자 목록 반환
        return querydslUserService.getUsersWithPaging(pageable, searchVal)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 기존에 dto를 UserDto 하나만 사용해서 실제 api를 만들때는 그렇게 하지 않기때문에 나눴습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserDto -&amp;gt; SignUpRequest, UserUpdateRequest&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SignUpRequest -&amp;gt; 사용자 등록할때 사용하는 요청 파라미터&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserUpdateRequest -&amp;gt; 사용자 수정할때 사용하는 요청 파라미터&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserResponse 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SignUpRequest.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739152190287&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.dto

import jakarta.validation.constraints.Email
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size

data class SignUpRequest(
    val id: Long?= null,

    @field:Size(min = 8, message = &quot;비밀번호는 최소 8자 이상이어야 합니다.&quot;)
    @field:Pattern(
        regexp = &quot;^(?=.*[0-9])(?=.*[!@#\$%^&amp;amp;*()_+\\-=\\[\\]{};':\&quot;\\\\|,.&amp;lt;&amp;gt;/?]).{8,}\$&quot;,
        message = &quot;비밀번호는 최소 1개 이상의 숫자와 특수문자를 포함해야 합니다.&quot;
    )
    val password:String,

    @field:NotBlank(message = &quot;이름은 필수 입력 항목입니다.&quot;)
    @field:Size(min = 2, max = 50, message = &quot;이름은 2~50자 사이여야 합니다.&quot;)
    val name: String,

    @field:Min(value = 0, message = &quot;나이는 0 이상이어야 합니다.&quot;)
    @field:Max(value = 150, message = &quot;나이는 150 이하여야 합니다.&quot;)
    val age: Int,

    @field:NotBlank(message = &quot;이메일은 필수 입력 항목입니다.&quot;)
    @field:Email(message = &quot;올바른 이메일 형식을 입력하세요.&quot;)
    val email: String
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- validation을 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserUpdate.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739152202193&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.dto

import jakarta.validation.constraints.*

data class UserUpdateRequest(

    @field:Size(min = 8, message = &quot;비밀번호는 최소 8자 이상이어야 합니다.&quot;)
    @field:Pattern(
        regexp = &quot;^(?=.*[0-9])(?=.*[!@#\$%^&amp;amp;*()_+\\-=\\[\\]{};':\&quot;\\\\|,.&amp;lt;&amp;gt;/?]).{8,}\$&quot;,
        message = &quot;비밀번호는 최소 1개 이상의 숫자와 특수문자를 포함해야 합니다.&quot;
    )
    val password:String,

    @field:NotBlank(message = &quot;이름은 필수 입력 항목입니다.&quot;)
    @field:Size(min = 2, max = 50, message = &quot;이름은 2~50자 사이여야 합니다.&quot;)
    val name: String,

    @field:Min(value = 0, message = &quot;나이는 0 이상이어야 합니다.&quot;)
    @field:Max(value = 150, message = &quot;나이는 150 이하여야 합니다.&quot;)
    val age: Int,

    @field:NotBlank(message = &quot;이메일은 필수 입력 항목입니다.&quot;)
    @field:Email(message = &quot;올바른 이메일 형식을 입력하세요.&quot;)
    val email: String
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- validation을 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserResponse.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739152541846&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.dto

import com.contact.management.entity.User
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.Size
import org.springframework.security.crypto.password.PasswordEncoder

data class UserResponse(
    val id: Long?= null,
    val name: String,
    val age: Int,
    val email: String
){
    companion object {
        fun from(user: User) = UserResponse(
            id = user.id,
            name = user.name,
            age = user.age,
            email = user.email
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- UserResponse 추가&lt;/p&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.kt&lt;/p&gt;
&lt;pre id=&quot;code_1739152368336&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.service

import com.contact.management.dto.SignUpRequest
import com.contact.management.dto.UserResponse
import com.contact.management.dto.UserUpdateRequest
import com.contact.management.entity.QUser
import com.contact.management.entity.User
import com.contact.management.exception.CommonException
import com.contact.management.exception.CommonExceptionCode.EMAIL_ALREADY_EXISTS
import com.contact.management.exception.CommonExceptionCode.USER_NOT_FOUND
import com.contact.management.repository.UserRepository
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class UserService(
     private val userRepository: UserRepository
    ,private val queryFactory: JPAQueryFactory
    ,private val encoder: PasswordEncoder
) {

    @Transactional(readOnly = true)
    fun getAllUsers() : List&amp;lt;UserResponse&amp;gt; {
        return userRepository.findAll().map { UserResponse.from(it) }
    }

    @Transactional(readOnly = true)
    fun getUserById(id: Long): UserResponse {
        val user = userRepository.findById(id).orElseThrow{ CommonException(USER_NOT_FOUND) }
        return UserResponse.from(user)
    }

    fun createdUser(request: SignUpRequest): UserResponse {
        userRepository.findByEmail(request.email)?.let {
            throw CommonException(EMAIL_ALREADY_EXISTS)
        }

        val user = userRepository.save(User.from(request, encoder))
        return UserResponse.from(user)
    }

    // 사용자 업데이트
    fun updateUser(id: Long, request: UserUpdateRequest): UserResponse{
        val user = userRepository.findById(id).orElseThrow{ CommonException(USER_NOT_FOUND) }

        user.update(request, encoder)

        return UserResponse.from(user)
    }

    fun deleteUser(id: Long){
        if(!userRepository.existsById(id)){
            throw CommonException(USER_NOT_FOUND)
        }
        userRepository.deleteById(id)
    }

    @Transactional(readOnly = true)
    fun getUsersWithPaging(pageable: Pageable): Page&amp;lt;UserResponse&amp;gt; {
        val qUser = QUser.user

        // JPAQueryFactory를 사용하여 쿼리 작성
        val query = queryFactory
            .selectFrom(qUser)
            .orderBy(qUser.id.asc()) // ID 기준으로 오름차순 정렬 (필요에 따라 변경 가능)

        // 실제 데이터 조회
        val users = query
            .offset(pageable.offset) // 페이지의 시작 인덱스
            .limit(pageable.pageSize.toLong()) // 페이지 크기
            .fetch() // 결과 가져오기

        // 총 개수를 구하는 쿼리 작성 (countQuery)
        val countQuery = queryFactory
            .select(qUser.count()) // 총 개수 계산
            .from(qUser)
        val total = countQuery.fetchOne() ?: 0L // fetchOne()으로 단일 값 가져오기

        // Page 객체로 반환
        val userDtos = users.map { UserResponse.from(it) }
        return PageImpl(userDtos, pageable, total)
    }

}&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;User.kt에서 작성한 from, update를 호출하게 service단에서 변경&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;passwordEncoder는 아직 Bean으로 등록을 하지않아서, 다음 포스팅에 security 적용하면서 작성할 예정입니다.&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/751</guid>
      <comments>https://aamoos.tistory.com/751#entry751comment</comments>
      <pubDate>Mon, 10 Feb 2025 11:09:03 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] Kotlin Spring Boot Api에서 파라미터 not null, null 체크</title>
      <link>https://aamoos.tistory.com/750</link>
      <description>&lt;pre id=&quot;code_1739150989797&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.basic_kotlin_2025.controller.response

import com.example.basic_kotlin_2025.model.http.UserRequest
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping(&quot;/api/response&quot;)
class ResponseApiController {

    // 1. get 4xx
    // http://localhost:8080/api/response
    @GetMapping(&quot;/get-mapping&quot;)
    fun getMapping(@RequestParam age: Int?): ResponseEntity&amp;lt;String&amp;gt; {   //age 값이 없을수도 있다.

        // 1. age &amp;gt; 20 -&amp;gt; 400 error
        if(age == null){
            return ResponseEntity.status(400).body(&quot;age 값이 누락되었습니다.&quot;)
        }

        // 2. age &amp;lt; 20 -&amp;gt; 400 error
        if(age &amp;lt; 20){
            return ResponseEntity.status(400).body(&quot;age 값은 20보다 커야 합니다.&quot;)
        }
     
        //return ResponseEntity.ok(&quot;OK&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 만약 이러한 스타일의 api를 kotlin스럽게 코딩을 한다면 아래 코드 처럼 변경할수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1739151074649&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.basic_kotlin_2025.controller.response

import com.example.basic_kotlin_2025.model.http.UserRequest
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping(&quot;/api/response&quot;)
class ResponseApiController {

    // 1. get 4xx
    // http://localhost:8080/api/response
    @GetMapping(&quot;/get-mapping&quot;)
    fun getMapping(@RequestParam age: Int?): ResponseEntity&amp;lt;String&amp;gt; {   //age 값이 없을수도 있다.

       return age?.let{

            if(age &amp;lt; 20){
                return ResponseEntity.status(400).body(&quot;age 값은 20보다 커야 합니다.&quot;)
            }

           ResponseEntity.ok(&quot;OK&quot;)
            // age not null
        }?: kotlin.run {
            //age is null
            return ResponseEntity.status(400).body(&quot;age 값이 누락되었습니다.&quot;)
        }
        
        //return ResponseEntity.ok(&quot;OK&quot;)
    }

}&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_1739151133085&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;return age?.let{

            if(age &amp;lt; 20){
                return ResponseEntity.status(400).body(&quot;age 값은 20보다 커야 합니다.&quot;)
            }

           ResponseEntity.ok(&quot;OK&quot;)
            // age not 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;- 이 코드는 age값이 null이 아닐경우 타는 부분&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1739151163214&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;?: kotlin.run {
            //age is null
            return ResponseEntity.status(400).body(&quot;age 값이 누락되었습니다.&quot;)
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이 코드는 age값이 null일 경우 타는 부분&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/750</guid>
      <comments>https://aamoos.tistory.com/750#entry750comment</comments>
      <pubDate>Mon, 10 Feb 2025 10:33:24 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 7. Spring Kotlin Jpa Querydsl BooleanExpression 동적쿼리 적용</title>
      <link>https://aamoos.tistory.com/749</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;UserController.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738916453570&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@GetMapping(&quot;/paged&quot;)
fun getUsersWithPaging(pageable: Pageable, @RequestParam searchVal: String?): Page&amp;lt;UserDto&amp;gt; {
    // 페이징된 사용자 목록 반환
    return querydslUserService.getUsersWithPaging(pageable, searchVal)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 지난번에 추가한 controller 해당 api에 @RequestParam으로 검색어를 받는부분을 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QuerydslUserService.kt&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1738916517986&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.service

import com.contact.management.dto.UserDto
import com.contact.management.entity.QUser
import com.contact.management.repository.UserRepository
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class QuerydslUserService(
    private val queryFactory: JPAQueryFactory
) {

    @Transactional(readOnly = true)
    fun getUsersWithPaging(pageable: Pageable, searchVal: String?): Page&amp;lt;UserDto&amp;gt; {
        val qUser = QUser.user
        var predicate: BooleanExpression = qUser.isNotNull

        searchVal?.let {
            predicate = predicate.and(qUser.name.containsIgnoreCase(searchVal))
        }

        // JPAQueryFactory를 사용하여 쿼리 작성
        val query = queryFactory
            .selectFrom(qUser)
            .where(predicate)
            .orderBy(qUser.id.asc()) // ID 기준으로 오름차순 정렬 (필요에 따라 변경 가능)

        // 실제 데이터 조회
        val users = query
            .offset(pageable.offset) // 페이지의 시작 인덱스
            .limit(pageable.pageSize.toLong()) // 페이지 크기
            .fetch() // 결과 가져오기

        // 총 개수를 구하는 쿼리 작성 (countQuery)
        val countQuery = queryFactory
            .select(qUser.count()) // 총 개수 계산
            .from(qUser)
            .where(predicate)
        val total = countQuery.fetchOne() ?: 0L // fetchOne()으로 단일 값 가져오기

        // Page 객체로 반환
        val userDtos = users.map { UserDto(it.id, it.name, it.email) }
        return PageImpl(userDtos, pageable, total)
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;searchVal 값을 null 허용을 하고 searchVal?.let을 선언해서 검색어가 &quot;&quot; 이거나 null일때는 통과, 그 외는 containsIgnoreCase를 사용해서 이름 like 검색을 하게 추가 하였음&lt;/p&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;searchVal null인경우&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;741&quot; data-origin-height=&quot;1103&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AbgPS/btsMbDDTYfe/bHSks0e0WnKRL5wgO7k8L0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AbgPS/btsMbDDTYfe/bHSks0e0WnKRL5wgO7k8L0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AbgPS/btsMbDDTYfe/bHSks0e0WnKRL5wgO7k8L0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAbgPS%2FbtsMbDDTYfe%2FbHSks0e0WnKRL5wgO7k8L0%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;1103&quot; data-origin-width=&quot;741&quot; data-origin-height=&quot;1103&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;searchVal 빈값인 경우&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;724&quot; data-origin-height=&quot;1112&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cCMXwu/btsMcsoaxMT/QARVjMWpFU6EKcnNZa5nWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cCMXwu/btsMcsoaxMT/QARVjMWpFU6EKcnNZa5nWk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cCMXwu/btsMcsoaxMT/QARVjMWpFU6EKcnNZa5nWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcCMXwu%2FbtsMcsoaxMT%2FQARVjMWpFU6EKcnNZa5nWk%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;724&quot; height=&quot;1112&quot; data-origin-width=&quot;724&quot; data-origin-height=&quot;1112&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;searchVal -&amp;gt; aa로 보낸경우&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;746&quot; data-origin-height=&quot;1113&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKhz1K/btsMbaoDSNO/xnNLWIrIhubIAMIYuqO58K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKhz1K/btsMbaoDSNO/xnNLWIrIhubIAMIYuqO58K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKhz1K/btsMbaoDSNO/xnNLWIrIhubIAMIYuqO58K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKhz1K%2FbtsMbaoDSNO%2FxnNLWIrIhubIAMIYuqO58K%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;746&quot; height=&quot;1113&quot; data-origin-width=&quot;746&quot; data-origin-height=&quot;1113&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;aa@naver.com 데이터가 조회됨&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/749</guid>
      <comments>https://aamoos.tistory.com/749#entry749comment</comments>
      <pubDate>Fri, 7 Feb 2025 17:28:14 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 6. Spring Kotlin Jpa Querydsl 적용, 페이징 예제</title>
      <link>https://aamoos.tistory.com/748</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738913842246&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
	id(&quot;org.springframework.boot&quot;) version &quot;3.4.2&quot;
	id(&quot;io.spring.dependency-management&quot;) version &quot;1.1.7&quot;
	kotlin(&quot;jvm&quot;) version &quot;1.9.25&quot;
	kotlin(&quot;plugin.spring&quot;) version &quot;1.9.25&quot;
	kotlin(&quot;plugin.jpa&quot;) version &quot;1.9.25&quot;
	kotlin(&quot;kapt&quot;) version &quot;1.9.25&quot;
}

group = &quot;com.contact&quot;
version = &quot;0.0.1-SNAPSHOT&quot;

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

repositories {
	mavenCentral()
}

dependencies {
	implementation(&quot;org.springframework.boot:spring-boot-starter-data-jpa&quot;)
	implementation(&quot;org.springframework.boot:spring-boot-starter-web&quot;)
	implementation(&quot;org.jetbrains.kotlin:kotlin-stdlib-jdk8&quot;)
	implementation(&quot;org.jetbrains.kotlin:kotlin-reflect&quot;)
	implementation(&quot;com.fasterxml.jackson.module:jackson-module-kotlin&quot;)
	implementation(&quot;org.springframework.boot:spring-boot-starter-validation&quot;)

	// QueryDSL 의존성 추가
	implementation(&quot;com.querydsl:querydsl-jpa:5.1.0:jakarta&quot;)
	kapt(&quot;com.querydsl:querydsl-apt:5.1.0:jakarta&quot;)
	kapt(&quot;jakarta.annotation:jakarta.annotation-api&quot;)
	kapt(&quot;jakarta.persistence:jakarta.persistence-api&quot;)

	runtimeOnly(&quot;com.h2database:h2&quot;)
	testImplementation(&quot;org.springframework.boot:spring-boot-starter-test&quot;)
}

// Querydsl 설정부 추가 - start
val generated = file(&quot;src/main/generated&quot;)

// querydsl QClass 파일 생성 위치를 지정
tasks.withType&amp;lt;JavaCompile&amp;gt; {
	options.generatedSourceOutputDirectory.set(generated)
}

// kotlin source set 에 querydsl QClass 위치 추가
sourceSets {
	main {
		kotlin.srcDirs += generated
	}
}

// gradle clean 시에 QClass 디렉토리 삭제
tasks.named(&quot;clean&quot;) {
	doLast {
		generated.deleteRecursively()
	}
}
kapt {
	generateStubs = 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;설명&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. Query-dsl 종속성 추가&lt;/p&gt;
&lt;pre id=&quot;code_1738913985953&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation(&quot;com.querydsl:querydsl-jpa:5.1.0:jakarta&quot;)
kapt(&quot;com.querydsl:querydsl-apt:5.1.0:jakarta&quot;)
kapt(&quot;jakarta.annotation:jakarta.annotation-api&quot;)
kapt(&quot;jakarta.persistence:jakarta.persistence-api&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;com.querydsl:querydsl-jpa: 이는 QueryDSL JPA 모듈로, QueryDSL을 사용하여 유형이 안전한 쿼리를 작성하기 위한 JPA 관련 기능을 제공&lt;br /&gt;com.querydsl:querydsl-apt: 이 주석 프로세서는 빌드 프로세스 중에 QueryDSL Q-클래스(즉, JPA 엔터티를 나타내는 클래스)를 생성&lt;br /&gt;jakarta.annotation-api&amp;amp;jakarta.persistence-api : JPA 엔터티를 처리하는 데 사용되는 Jakarta 지속성과 관련된 필수&lt;/p&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. Q-class 생성&lt;/p&gt;
&lt;pre id=&quot;code_1738914083919&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val generated = file(&quot;src/main/generated&quot;)

tasks.withType&amp;lt;JavaCompile&amp;gt; {
	options.generatedSourceOutputDirectory.set(generated)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 Q-클래스에 대한 디렉토리 설정 : 생성된 QueryDSL Q-클래스가 저장될 위치를 지정&lt;/p&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. 생성된 소스를 Kotlin 소스 세트에 추가&lt;/p&gt;
&lt;pre id=&quot;code_1738914165869&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sourceSets {
	main {
		kotlin.srcDirs += generated
	}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 생성된 QueryDSL Q-클래스가 Kotlin 소스 세트에 추가되어 프로젝트의 나머지 부분에서 사용 generatedKotlin이 코드를 컴파일할 때 고려하는 디렉토리 목록에 디렉토리를 추가&lt;/p&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. Gradle에서 생성된 파일 정리 Clean:&lt;/p&gt;
&lt;pre id=&quot;code_1738914191122&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;tasks.named(&quot;clean&quot;) {
	doLast {
		generated.deleteRecursively()
	}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;gradle clean시 Qclass 디렉토리를 삭제&lt;/p&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;querydsl 패키지 생성후 QuerydslConfog.kt 생성&lt;/p&gt;
&lt;pre id=&quot;code_1738914364135&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.querydsl

import com.querydsl.jpa.impl.JPAQueryFactory
import jakarta.persistence.EntityManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class QueryDslConfig(
    val em: EntityManager
) {

    @Bean
    fun queryFactory(): JPAQueryFactory {
        return JPAQueryFactory(em)
    }
}&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 패키지에 QuerydslUserSerivce.kt 생성&lt;/p&gt;
&lt;pre id=&quot;code_1738914461097&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.service

import com.contact.management.dto.UserDto
import com.contact.management.entity.QUser
import com.contact.management.repository.UserRepository
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class QuerydslUserService(
    private val queryFactory: JPAQueryFactory
) {

    @Transactional(readOnly = true)
    fun getUsersWithPaging(pageable: Pageable): Page&amp;lt;UserDto&amp;gt; {
        val qUser = QUser.user

        // JPAQueryFactory를 사용하여 쿼리 작성
        val query = queryFactory
            .selectFrom(qUser)
            .orderBy(qUser.id.asc()) // ID 기준으로 오름차순 정렬 (필요에 따라 변경 가능)

        // 실제 데이터 조회
        val users = query
            .offset(pageable.offset) // 페이지의 시작 인덱스
            .limit(pageable.pageSize.toLong()) // 페이지 크기
            .fetch() // 결과 가져오기

        // 총 개수를 구하는 쿼리 작성 (countQuery)
        val countQuery = queryFactory
            .select(qUser.count()) // 총 개수 계산
            .from(qUser)
        val total = countQuery.fetchOne() ?: 0L // fetchOne()으로 단일 값 가져오기

        // Page 객체로 반환
        val userDtos = users.map { UserDto(it.id, it.name, it.email) }
        return PageImpl(userDtos, pageable, total)
    }

}&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;UserController.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738914484946&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.controller

import com.contact.management.dto.UserDto
import com.contact.management.service.QuerydslUserService
import com.contact.management.service.UserService
import jakarta.validation.Valid
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping(&quot;/api/users&quot;)
class UserController(
     private val userService: UserService
    ,private val querydslUserService: QuerydslUserService
) {

    @GetMapping
    fun getUsers(): ResponseEntity&amp;lt;List&amp;lt;UserDto&amp;gt;&amp;gt; {
        val users = userService.getAllUsers()
        return ResponseEntity.ok(users)
    }

    @GetMapping(&quot;/{id}&quot;)
    fun getUser(@PathVariable id: Long): ResponseEntity&amp;lt;UserDto&amp;gt; {
        return ResponseEntity.ok(userService.getUserById(id))
    }

    @PostMapping
    fun createUser(@Valid @RequestBody userDto: UserDto): ResponseEntity&amp;lt;UserDto&amp;gt; {
        val createdUser = userService.createdUser(userDto)
        return ResponseEntity
            .status(HttpStatus.CREATED) // 201 Created
            .header(HttpHeaders.LOCATION, &quot;/api/users/${createdUser.id}&quot;) // 생성된 리소스의 URI 반환
            .body(createdUser)
    }

    @PutMapping(&quot;/{id}&quot;)
    fun updateUser(@PathVariable id: Long,@Valid @RequestBody userDto: UserDto): ResponseEntity&amp;lt;UserDto&amp;gt; {
        val updatedUser = userService.updateUser(id, userDto)
        return ResponseEntity.ok(updatedUser)
    }

    @DeleteMapping(&quot;/{id}&quot;)
    fun deleteUser(@PathVariable id: Long): ResponseEntity&amp;lt;Void&amp;gt; {
        userService.deleteUser(id)
        return ResponseEntity.noContent().build() // 204 No Content
    }

    @GetMapping(&quot;/paged&quot;)
    fun getUsersWithPaging(pageable: Pageable): Page&amp;lt;UserDto&amp;gt; {
        // 페이징된 사용자 목록 반환
        return querydslUserService.getUsersWithPaging(pageable)
    }
}&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;getUsersWithPaging 메소드 추가&lt;/p&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/users/paged 호출하면 아래형식으로 return됨&lt;/p&gt;
&lt;pre id=&quot;code_1738914599856&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;content&quot;: [
        {
            &quot;id&quot;: 1,
            &quot;name&quot;: &quot;aa&quot;,
            &quot;email&quot;: &quot;aa@domain.com&quot;
        },
        {
            &quot;id&quot;: 2,
            &quot;name&quot;: &quot;bb&quot;,
            &quot;email&quot;: &quot;bb@domain.com&quot;
        },
        {
            &quot;id&quot;: 3,
            &quot;name&quot;: &quot;cc&quot;,
            &quot;email&quot;: &quot;cc@domain.com&quot;
        },
        {
            &quot;id&quot;: 4,
            &quot;name&quot;: &quot;dd&quot;,
            &quot;email&quot;: &quot;dd@domain.com&quot;
        },
        {
            &quot;id&quot;: 5,
            &quot;name&quot;: &quot;ee&quot;,
            &quot;email&quot;: &quot;ee@domain.com&quot;
        },
        {
            &quot;id&quot;: 6,
            &quot;name&quot;: &quot;ff&quot;,
            &quot;email&quot;: &quot;ff@domain.com&quot;
        },
        {
            &quot;id&quot;: 7,
            &quot;name&quot;: &quot;gg&quot;,
            &quot;email&quot;: &quot;gg@domain.com&quot;
        },
        {
            &quot;id&quot;: 8,
            &quot;name&quot;: &quot;hh&quot;,
            &quot;email&quot;: &quot;hh@domain.com&quot;
        },
        {
            &quot;id&quot;: 9,
            &quot;name&quot;: &quot;ii&quot;,
            &quot;email&quot;: &quot;ii@domain.com&quot;
        },
        {
            &quot;id&quot;: 10,
            &quot;name&quot;: &quot;jj&quot;,
            &quot;email&quot;: &quot;jj@domain.com&quot;
        },
        {
            &quot;id&quot;: 11,
            &quot;name&quot;: &quot;kk&quot;,
            &quot;email&quot;: &quot;kk@domain.com&quot;
        },
        {
            &quot;id&quot;: 12,
            &quot;name&quot;: &quot;ll&quot;,
            &quot;email&quot;: &quot;ll@domain.com&quot;
        },
        {
            &quot;id&quot;: 13,
            &quot;name&quot;: &quot;mm&quot;,
            &quot;email&quot;: &quot;mm@domain.com&quot;
        },
        {
            &quot;id&quot;: 14,
            &quot;name&quot;: &quot;nn&quot;,
            &quot;email&quot;: &quot;nn@domain.com&quot;
        },
        {
            &quot;id&quot;: 15,
            &quot;name&quot;: &quot;oo&quot;,
            &quot;email&quot;: &quot;oo@domain.com&quot;
        },
        {
            &quot;id&quot;: 16,
            &quot;name&quot;: &quot;pp&quot;,
            &quot;email&quot;: &quot;pp@domain.com&quot;
        },
        {
            &quot;id&quot;: 17,
            &quot;name&quot;: &quot;qq&quot;,
            &quot;email&quot;: &quot;qq@domain.com&quot;
        },
        {
            &quot;id&quot;: 18,
            &quot;name&quot;: &quot;rr&quot;,
            &quot;email&quot;: &quot;rr@domain.com&quot;
        },
        {
            &quot;id&quot;: 19,
            &quot;name&quot;: &quot;ss&quot;,
            &quot;email&quot;: &quot;ss@domain.com&quot;
        },
        {
            &quot;id&quot;: 20,
            &quot;name&quot;: &quot;tt&quot;,
            &quot;email&quot;: &quot;tt@domain.com&quot;
        }
    ],
    &quot;pageable&quot;: {
        &quot;pageNumber&quot;: 0,
        &quot;pageSize&quot;: 20,
        &quot;sort&quot;: {
            &quot;empty&quot;: true,
            &quot;sorted&quot;: false,
            &quot;unsorted&quot;: true
        },
        &quot;offset&quot;: 0,
        &quot;paged&quot;: true,
        &quot;unpaged&quot;: false
    },
    &quot;last&quot;: true,
    &quot;totalElements&quot;: 20,
    &quot;totalPages&quot;: 1,
    &quot;first&quot;: true,
    &quot;size&quot;: 20,
    &quot;number&quot;: 0,
    &quot;sort&quot;: {
        &quot;empty&quot;: true,
        &quot;sorted&quot;: false,
        &quot;unsorted&quot;: true
    },
    &quot;numberOfElements&quot;: 20,
    &quot;empty&quot;: false
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/748</guid>
      <comments>https://aamoos.tistory.com/748#entry748comment</comments>
      <pubDate>Fri, 7 Feb 2025 16:51:10 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 5. Spring Jpa Auditing 적용</title>
      <link>https://aamoos.tistory.com/747</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;audit란?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔터티(Entity)의 생성 및 변경 이력을 자동으로 감지. 이를 통해 데이터 변경 내역을 자동으로 기록할 수 있고, 수동으로 createdAt, updatedAt 등을 설정할 필요가 없음&lt;/p&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_1738909499360&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.entity

import jakarta.persistence.*
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import java.time.LocalDateTime

@Entity
@Table(name = &quot;users&quot;)
class User2 (
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long?= null,
    var name: String,
    var email: String,
    @CreatedDate
    @Column(updatable = false)
    var createdAt: LocalDateTime,

    @LastModifiedDate
    var updatedAt: LocalDateTime,
    
    @CreatedBy
    @Column(updatable = false)
    var createdBy: String,
    
    @LastModifiedBy
    var updatedBy: String
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; entity를 생성할때 등록자, 수정자, 등록일, 수정일을 추가하고싶을때 수많은 entity에 동일한 코드가 들어가게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;audit를 적용하면 중복된 코드들을 하나의 entity로 관리를 할수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;audit 적용 예제&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;172&quot; data-origin-height=&quot;72&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btInbE/btsL9Oz7N9E/oP95bWzo40qUfmIkHqwmd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btInbE/btsL9Oz7N9E/oP95bWzo40qUfmIkHqwmd0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btInbE/btsL9Oz7N9E/oP95bWzo40qUfmIkHqwmd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtInbE%2FbtsL9Oz7N9E%2FoP95bWzo40qUfmIkHqwmd0%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;172&quot; height=&quot;72&quot; data-origin-width=&quot;172&quot; data-origin-height=&quot;72&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;audit 패키지를 생성후 아래 .kt 파일들을 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AuditorAwareConfig.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738909665642&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.audit

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.domain.AuditorAware
import java.util.Optional

@Configuration
class AuditorAwareConfig {
    @Bean
    fun auditorProvider(): AuditorAware&amp;lt;String&amp;gt; {
        return AuditorAware { Optional.of(&quot;system&quot;) } // 실제 환경에서는 SecurityContext에서 가져오기
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 아직 spring security를 적용하기 전이므로 system이라는 값을 하드코딩 하였음, security를 적용한 이후에는 로그인한 사용자의 아이디를 가져오게 변경해야함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BaseEntity.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738909723913&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.audit

import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime

@EntityListeners(AuditingEntityListener::class)
@MappedSuperclass
abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    lateinit var createdAt: LocalDateTime

    @LastModifiedDate
    lateinit var updatedAt: LocalDateTime

    @CreatedBy
    @Column(updatable = false)
    lateinit var createdBy: String

    @LastModifiedBy
    lateinit var updatedBy: String
}&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;Application.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738909750180&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing

@SpringBootApplication
@EnableJpaAuditing
class ManagementApplication

fun main(args: Array&amp;lt;String&amp;gt;) {
	runApplication&amp;lt;ManagementApplication&amp;gt;(*args)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 프로젝트를 생성할때 만들어지는 Application.kt 파일에 @EnableJpaAuditing을 추가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;User.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738909791933&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.entity

import com.contact.management.audit.BaseEntity
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table

@Entity
@Table(name = &quot;users&quot;)
class User (
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long?= null,
    var name: String,
    var email: String
) : BaseEntity()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;audit를 적용하고자 하는 entity 뒤에 : BaseEntity()를 추가해준다.&lt;/p&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;436&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YgETp/btsMbZfngNt/rndG6lbiPnU8yKPo97xO2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YgETp/btsMbZfngNt/rndG6lbiPnU8yKPo97xO2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YgETp/btsMbZfngNt/rndG6lbiPnU8yKPo97xO2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYgETp%2FbtsMbZfngNt%2FrndG6lbiPnU8yKPo97xO2K%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;436&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;436&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 이전에 만든 사용자 등록 api를 호출&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;722&quot; data-origin-height=&quot;295&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IcXCb/btsMakyxCCe/NcYjSbz1lbgvhdGrPsJox1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IcXCb/btsMakyxCCe/NcYjSbz1lbgvhdGrPsJox1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IcXCb/btsMakyxCCe/NcYjSbz1lbgvhdGrPsJox1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIcXCb%2FbtsMakyxCCe%2FNcYjSbz1lbgvhdGrPsJox1%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;722&quot; height=&quot;295&quot; data-origin-width=&quot;722&quot; data-origin-height=&quot;295&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;USERS 테이블을 조회하면 날짜와 하드 코딩한 system이 등록자, 수정자에 insert가 된것을 확인할수 있음&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/747</guid>
      <comments>https://aamoos.tistory.com/747#entry747comment</comments>
      <pubDate>Fri, 7 Feb 2025 15:34:14 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 4. @Valid를 사용한 REST API validation 처리</title>
      <link>https://aamoos.tistory.com/746</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;의존성 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle dependencies 안에 아래 코드 추가&lt;/p&gt;
&lt;pre id=&quot;code_1738833586686&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation(&quot;org.springframework.boot:spring-boot-starter-validation&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;build.gradle.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738833601715&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
	id(&quot;org.springframework.boot&quot;) version &quot;3.4.2&quot;
	id(&quot;io.spring.dependency-management&quot;) version &quot;1.1.7&quot;
	id(&quot;java&quot;)
	id(&quot;org.jetbrains.kotlin.jvm&quot;) version &quot;1.9.25&quot;
	id(&quot;org.jetbrains.kotlin.plugin.jpa&quot;) version &quot;1.9.25&quot;
	id(&quot;org.jetbrains.kotlin.plugin.spring&quot;) version &quot;1.9.25&quot;
	id(&quot;org.jetbrains.kotlin.kapt&quot;) version &quot;1.9.25&quot;
}

group = &quot;com.contact&quot;
version = &quot;0.0.1-SNAPSHOT&quot;

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

repositories {
	mavenCentral()
}

dependencies {
	implementation(&quot;org.springframework.boot:spring-boot-starter-data-jpa&quot;)
	implementation(&quot;org.springframework.boot:spring-boot-starter-web&quot;)
	implementation(&quot;org.jetbrains.kotlin:kotlin-stdlib-jdk8&quot;)
	implementation(&quot;org.jetbrains.kotlin:kotlin-reflect&quot;)
	implementation(&quot;com.fasterxml.jackson.module:jackson-module-kotlin&quot;)
	implementation(&quot;org.springframework.boot:spring-boot-starter-validation&quot;)
	runtimeOnly(&quot;com.h2database:h2&quot;)
	testImplementation(&quot;org.springframework.boot:spring-boot-starter-test&quot;)
}

tasks.withType&amp;lt;Test&amp;gt; {
	useJUnitPlatform()
}

tasks.withType&amp;lt;org.jetbrains.kotlin.gradle.tasks.KotlinCompile&amp;gt; {
	kotlinOptions {
		jvmTarget = &quot;17&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;UserDto.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738833657998&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.dto

import jakarta.validation.constraints.Email
import jakarta.validation.constraints.Size

data class UserDto(
    val id: Long?= null,

    @field:Size(min = 2, max = 50, message = &quot;이름은 2~50자 사이여야 합니다.&quot;)
    val name: String,

    @field:Email(message = &quot;올바른 이메일 형식을 입력하세요.&quot;)
    val email: String
)&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;GlobalExceptionHandler.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738833905229&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.exception

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(CommonException::class)
    fun handleCommonException(e: CommonException): ResponseEntity&amp;lt;ErrorResponse&amp;gt; {
        val errorResponse = ErrorResponse(
            status = e.exceptionCode.status.value(),
            error = e.exceptionCode.message,
            message = e.message ?: &quot;예외 발생&quot;
        )
        return ResponseEntity(errorResponse, e.exceptionCode.status)
    }

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity&amp;lt;ErrorResponse&amp;gt; {
        val errors = ex.bindingResult.fieldErrors.associate { it.field to (it.defaultMessage ?: &quot;잘못된 값&quot;) }

        val errorResponse = ErrorResponse(
            status = HttpStatus.BAD_REQUEST.value(),
            error = &quot;Validation Failed&quot;,
            message = &quot;입력값이 올바르지 않습니다.&quot;,
            errors = errors
        )

        return ResponseEntity(errorResponse, HttpStatus.BAD_REQUEST)
    }
}&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;gt; handlerValdationExceptions를 추가함, vadliation에 걸렸을때 errorResponse에 400 BAD_REQUEST, 에러 내용을 return 하게 설정함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserController.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738833986008&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.controller

import com.contact.management.dto.UserDto
import com.contact.management.service.UserService
import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping(&quot;/api/users&quot;)
class UserController(
    private val userService: UserService
) {

    @GetMapping
    fun getUsers(): ResponseEntity&amp;lt;List&amp;lt;UserDto&amp;gt;&amp;gt; {
        val users = userService.getAllUsers()
        return ResponseEntity.ok(users)
    }

    @GetMapping(&quot;/{id}&quot;)
    fun getUser(@PathVariable id: Long): ResponseEntity&amp;lt;UserDto&amp;gt; {
        return ResponseEntity.ok(userService.getUserById(id))
    }

    @PostMapping
    fun createUser(@Valid @RequestBody userDto: UserDto): ResponseEntity&amp;lt;UserDto&amp;gt; {
        val createdUser = userService.createdUser(userDto)
        return ResponseEntity
            .status(HttpStatus.CREATED) // 201 Created
            .header(HttpHeaders.LOCATION, &quot;/api/users/${createdUser.id}&quot;) // 생성된 리소스의 URI 반환
            .body(createdUser)
    }

    @PutMapping(&quot;/{id}&quot;)
    fun updateUser(@PathVariable id: Long,@Valid @RequestBody userDto: UserDto): ResponseEntity&amp;lt;UserDto&amp;gt; {
        val updatedUser = userService.updateUser(id, userDto)
        return ResponseEntity.ok(updatedUser)
    }

    @DeleteMapping(&quot;/{id}&quot;)
    fun deleteUser(@PathVariable id: Long): ResponseEntity&amp;lt;Void&amp;gt; {
        userService.deleteUser(id)
        return ResponseEntity.noContent().build() // 204 No Content
    }
}&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;gt; createUser, updateUser api에 @Valid를 추가&lt;/p&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~50자가 아닌경우&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;714&quot; data-origin-height=&quot;541&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsTssp/btsL8mjsaC7/VuF6iOgvr1qDMZAbDNUfNK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsTssp/btsL8mjsaC7/VuF6iOgvr1qDMZAbDNUfNK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsTssp/btsL8mjsaC7/VuF6iOgvr1qDMZAbDNUfNK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsTssp%2FbtsL8mjsaC7%2FVuF6iOgvr1qDMZAbDNUfNK%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;714&quot; height=&quot;541&quot; data-origin-width=&quot;714&quot; data-origin-height=&quot;541&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;533&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vPne2/btsMaghuILf/lJXKFSYQ7p5Kfwa5oOWhvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vPne2/btsMaghuILf/lJXKFSYQ7p5Kfwa5oOWhvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vPne2/btsMaghuILf/lJXKFSYQ7p5Kfwa5oOWhvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvPne2%2FbtsMaghuILf%2FlJXKFSYQ7p5Kfwa5oOWhvk%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;533&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;533&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;만약 GlobalException에 handleValidationExceptions를 적용하지않았다면&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;494&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baQpFO/btsMav6y6ge/gYxhw675q6hfp5PaCkchkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baQpFO/btsMav6y6ge/gYxhw675q6hfp5PaCkchkK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baQpFO/btsMav6y6ge/gYxhw675q6hfp5PaCkchkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaQpFO%2FbtsMav6y6ge%2FgYxhw675q6hfp5PaCkchkK%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;494&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;494&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; GlobalExcpetionHandler에 등록했을때와 비교했을때 에러 메시지가 부족&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/746</guid>
      <comments>https://aamoos.tistory.com/746#entry746comment</comments>
      <pubDate>Thu, 6 Feb 2025 18:37:56 +0900</pubDate>
    </item>
    <item>
      <title>[코프링] 3. GlobalException handler를 이용한 restApi 에러 처리</title>
      <link>https://aamoos.tistory.com/745</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 구조&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;221&quot; data-origin-height=&quot;127&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tysds/btsL7Clwtxy/QCGaqU22jNfckroPFQ2kHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tysds/btsL7Clwtxy/QCGaqU22jNfckroPFQ2kHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tysds/btsL7Clwtxy/QCGaqU22jNfckroPFQ2kHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ftysds%2FbtsL7Clwtxy%2FQCGaqU22jNfckroPFQ2kHk%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;221&quot; height=&quot;127&quot; data-origin-width=&quot;221&quot; data-origin-height=&quot;127&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;147&quot; data-origin-height=&quot;121&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpj0WP/btsL9qqoz5D/R7KC2AUfipq7a3ZMbMKUJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpj0WP/btsL9qqoz5D/R7KC2AUfipq7a3ZMbMKUJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpj0WP/btsL9qqoz5D/R7KC2AUfipq7a3ZMbMKUJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbpj0WP%2FbtsL9qqoz5D%2FR7KC2AUfipq7a3ZMbMKUJ1%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;147&quot; height=&quot;121&quot; data-origin-width=&quot;147&quot; data-origin-height=&quot;121&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;165&quot; data-origin-height=&quot;101&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DFtpA/btsL7qr2wZY/p4Iyw2epC4hHrIA0fc7aQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DFtpA/btsL7qr2wZY/p4Iyw2epC4hHrIA0fc7aQ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DFtpA/btsL7qr2wZY/p4Iyw2epC4hHrIA0fc7aQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDFtpA%2FbtsL7qr2wZY%2Fp4Iyw2epC4hHrIA0fc7aQ1%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;165&quot; height=&quot;101&quot; data-origin-width=&quot;165&quot; data-origin-height=&quot;101&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;CommonException.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738744689358&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.exception

class CommonException(val exceptionCode: CommonExceptionCode) : RuntimeException()&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;CommonExceptionCode.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738744722223&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.exception

import org.springframework.http.HttpStatus

enum class CommonExceptionCode(
    val status: HttpStatus,
    val message: String,
) {
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, &quot;사용자를 찾을 수 없습니다.&quot;),
    EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, &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;ErrorResponse.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738744739407&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.exception

import com.fasterxml.jackson.annotation.JsonInclude
import java.time.LocalDateTime

@JsonInclude(JsonInclude.Include.NON_NULL) // null 필드는 JSON 응답에서 제외
data class ErrorResponse(
    val timestamp: LocalDateTime = LocalDateTime.now(),
    val status: Int,
    val error: String,
    val message: String,
    val errors: Map&amp;lt;String, String&amp;gt;? = null // validation 에러 처리를 위한 필드 추가
)&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;GlobalExceptionHandler.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738744770141&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.exception

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(CommonException::class)
    fun handleCommonException(e: CommonException): ResponseEntity&amp;lt;ErrorResponse&amp;gt; {
        val errorResponse = ErrorResponse(
            status = e.exceptionCode.status.value(),
            error = e.exceptionCode.message,
            message = e.message ?: &quot;예외 발생&quot;
        )
        return ResponseEntity(errorResponse, e.exceptionCode.status)
    }
}&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;gt; api에서 CommonException을 throw 하였을때 선언한 httpStatus, message를 return 하게 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserController.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738744881163&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.controller

import com.contact.management.dto.UserDto
import com.contact.management.service.UserService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping(&quot;/api/users&quot;)
class UserController(
    private val userService: UserService
) {

    @GetMapping
    fun getUsers(): ResponseEntity&amp;lt;List&amp;lt;UserDto&amp;gt;&amp;gt; {
        val users = userService.getAllUsers()
        return ResponseEntity.ok(users)
    }

    @GetMapping(&quot;/{id}&quot;)
    fun getUser(@PathVariable id: Long): ResponseEntity&amp;lt;UserDto&amp;gt; {
        return ResponseEntity.ok(userService.getUserById(id))
    }

     @PostMapping
    fun createUser(@RequestBody userDto: UserDto): ResponseEntity&amp;lt;UserDto&amp;gt; {
        val createdUser = userService.createdUser(userDto)
        return ResponseEntity
            .status(HttpStatus.CREATED) // 201 Created
            .header(HttpHeaders.LOCATION, &quot;/api/users/${createdUser.id}&quot;) // 생성된 리소스의 URI 반환
            .body(createdUser)
    }

    @PutMapping(&quot;/{id}&quot;)
    fun updateUser(@PathVariable id: Long, @RequestBody userDto: UserDto): ResponseEntity&amp;lt;UserDto&amp;gt; {
        val updatedUser = userService.updateUser(id, userDto)
        return ResponseEntity.ok(updatedUser)
    }

    @DeleteMapping(&quot;/{id}&quot;)
    fun deleteUser(@PathVariable id: Long): ResponseEntity&amp;lt;Void&amp;gt; {
        userService.deleteUser(id)
        return ResponseEntity.noContent().build() // 204 No Content
    }
}&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;rest api 설계 원칙&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. request, response 파라미터는 dto를 사용한다. (entity 사용 x)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;why : entity는 테이블 생성에 영향을 주기때문에 분리함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 간혹 api를 전부 @PostMapping으로 모두 선언해서 설계하는 경우가 있는데, 이는 올바른 설계가 아님&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. api가 정상적인경우는 데이터를 return, 정상적이지 않는경우 status, message, timeStamp 등 에러메시지를 return&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. api는 버전을 통한 관리가 이뤄줘야함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;why : 버전관리가 안되고 기존 api를 변경이되면 api를 사용하고 있던 client들에게 장애가 발생할수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex) 기존 api -&amp;gt; /api/v1/users&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경된 api -&amp;gt; /api/v2/users&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. api를 설계할때 명칭은 계층적으로 만들어야함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex) /api/v1/car/suv/genesis&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;UserDto.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738744905646&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.dto

data class UserDto(
    val id: Long?= null,
    val name: String,
    val email: String
)&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;UserRepository.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738744921447&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.repository

import com.contact.management.entity.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.Optional

@Repository
interface UserRepository : JpaRepository&amp;lt;User, Long&amp;gt;{
    fun findByEmail(username: String): 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;-&amp;gt; 사용자 등록시 이메일 중복 체크를 위해 findByEmail을 생성함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; Kotlin에서는 Optional보다는 User?(nullable)로 반환, Optional 사용 시 let 블록 오작동&lt;/p&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.kt&lt;/p&gt;
&lt;pre id=&quot;code_1738744934102&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.contact.management.service

import com.contact.management.dto.UserDto
import com.contact.management.entity.User
import com.contact.management.exception.CommonException
import com.contact.management.exception.CommonExceptionCode.EMAIL_ALREADY_EXISTS
import com.contact.management.exception.CommonExceptionCode.USER_NOT_FOUND
import com.contact.management.repository.UserRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class UserService(
    private val userRepository: UserRepository
) {

    @Transactional(readOnly = true)
    fun getAllUsers() : List&amp;lt;UserDto&amp;gt; {
        return userRepository.findAll().map { UserDto(it.id, it.name, it.email) }
    }

    @Transactional(readOnly = true)
    fun getUserById(id: Long): UserDto {
        val user = userRepository.findById(id).orElseThrow{ CommonException(USER_NOT_FOUND) }
        return UserDto(user.id, user.name, user.email)
    }

    fun createdUser(userDto: UserDto): UserDto {
        userRepository.findByEmail(userDto.email)?.let {
            throw CommonException(EMAIL_ALREADY_EXISTS)
        }

        val user = userRepository.save(User(name = userDto.name, email = userDto.email))
        return UserDto(user.id, user.name, user.email)
    }

    // 사용자 업데이트
    fun updateUser(id: Long, userDto: UserDto): UserDto{
        val updatedUser = userRepository.findById(id).orElseThrow{ CommonException(USER_NOT_FOUND) }

        // 엔티티의 속성 수정
        updatedUser.name = userDto.name
        updatedUser.email = userDto.email
        // DB에 업데이트 로직

        return UserDto(
            id = updatedUser.id,
            name = updatedUser.name,
            email = updatedUser.email
        )
    }

    fun deleteUser(id: Long){
        if(!userRepository.existsById(id)){
            throw CommonException(USER_NOT_FOUND)
        }
        userRepository.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;-&amp;gt; 넘어온 id에 해당하는 사용자가 없을경우 CommonException(USER_NOT_FOUND)로 예외처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 사용자 등록시 이메일 계정이 이미 등록되어있는경우 CommonException(EMAIL_ALREADY_EXISTS)로 예외처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; getAllUsers, getUserById는 데이터를 조회만 하기 떄문에 @Transactional(readOnly = true)를 적용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;why : 이렇게 하면 변경감지를 하지않으므로 성능향상&lt;/p&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; @Transactional(readOnly = true)를 적용을 하면 데이터 변경이 반영되지 않음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;postman&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 등록&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;469&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yMNnZ/btsL8J5nowJ/4zngg5t3dJ6dDNlmPuTrj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yMNnZ/btsL8J5nowJ/4zngg5t3dJ6dDNlmPuTrj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yMNnZ/btsL8J5nowJ/4zngg5t3dJ6dDNlmPuTrj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyMNnZ%2FbtsL8J5nowJ%2F4zngg5t3dJ6dDNlmPuTrj1%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;469&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;469&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;201 새로운 리소스(사용자)가 성공적으로 생성되었음을 명확하게 표시 또한&amp;nbsp;Location&amp;nbsp;헤더에&amp;nbsp;생성된&amp;nbsp;리소스의&amp;nbsp;URL을&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;709&quot; data-origin-height=&quot;486&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d3e5i6/btsL80fW8E0/aY2WWwrJL2PoBrDZlF9ODk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d3e5i6/btsL80fW8E0/aY2WWwrJL2PoBrDZlF9ODk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d3e5i6/btsL80fW8E0/aY2WWwrJL2PoBrDZlF9ODk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd3e5i6%2FbtsL80fW8E0%2FaY2WWwrJL2PoBrDZlF9ODk%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;709&quot; height=&quot;486&quot; data-origin-width=&quot;709&quot; data-origin-height=&quot;486&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;400 Bad Request&lt;/td&gt;
&lt;td&gt;잘못된 요청 (유효성 검사 실패)&lt;/td&gt;
&lt;td&gt;클라이언트가 유효하지 않은 데이터를 보냈을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;409 Conflict&lt;/td&gt;
&lt;td&gt;리소스 충돌 (중복 데이터)&lt;/td&gt;
&lt;td&gt;데이터베이스의 고유 값 제약 조건을 위반할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;718&quot; data-origin-height=&quot;509&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/01GhX/btsL73iSHbB/0On7Sj4x16HRq6kpqWrZSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/01GhX/btsL73iSHbB/0On7Sj4x16HRq6kpqWrZSk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/01GhX/btsL73iSHbB/0On7Sj4x16HRq6kpqWrZSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F01GhX%2FbtsL73iSHbB%2F0On7Sj4x16HRq6kpqWrZSk%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;718&quot; height=&quot;509&quot; data-origin-width=&quot;718&quot; data-origin-height=&quot;509&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;697&quot; data-origin-height=&quot;291&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LIw9k/btsL7fYG9Eb/cSpOZJM7eZqK1hJoM8kKN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LIw9k/btsL7fYG9Eb/cSpOZJM7eZqK1hJoM8kKN1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LIw9k/btsL7fYG9Eb/cSpOZJM7eZqK1hJoM8kKN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLIw9k%2FbtsL7fYG9Eb%2FcSpOZJM7eZqK1hJoM8kKN1%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;697&quot; height=&quot;291&quot; data-origin-width=&quot;697&quot; data-origin-height=&quot;291&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;474&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsPGUk/btsL8V5vC8U/Qok8802LnUQkG5oawtWUB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsPGUk/btsL8V5vC8U/Qok8802LnUQkG5oawtWUB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsPGUk/btsL8V5vC8U/Qok8802LnUQkG5oawtWUB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsPGUk%2FbtsL8V5vC8U%2FQok8802LnUQkG5oawtWUB1%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;474&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;474&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;특정 사용자 조회 (없는 id)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;721&quot; data-origin-height=&quot;497&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcR1Hy/btsL8INaSN0/AMA09pmRnNRt2UkZ2FyWv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcR1Hy/btsL8INaSN0/AMA09pmRnNRt2UkZ2FyWv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcR1Hy/btsL8INaSN0/AMA09pmRnNRt2UkZ2FyWv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcR1Hy%2FbtsL8INaSN0%2FAMA09pmRnNRt2UkZ2FyWv1%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;721&quot; height=&quot;497&quot; data-origin-width=&quot;721&quot; data-origin-height=&quot;497&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;404 Not Found는 요청한 리소스(사용자)가 존재하지 않을 때 사용하는 표준 상태 코드&lt;/p&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;719&quot; data-origin-height=&quot;472&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceQ9d7/btsL7rdubSY/rSI1vWhxoKCkibVSqHU5W0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceQ9d7/btsL7rdubSY/rSI1vWhxoKCkibVSqHU5W0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceQ9d7/btsL7rdubSY/rSI1vWhxoKCkibVSqHU5W0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceQ9d7%2FbtsL7rdubSY%2FrSI1vWhxoKCkibVSqHU5W0%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;719&quot; height=&quot;472&quot; data-origin-width=&quot;719&quot; data-origin-height=&quot;472&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJ7ACU/btsL9anN1b0/tv9fOuvUkjceK916sPpc71/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJ7ACU/btsL9anN1b0/tv9fOuvUkjceK916sPpc71/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJ7ACU/btsL9anN1b0/tv9fOuvUkjceK916sPpc71/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJ7ACU%2FbtsL9anN1b0%2Ftv9fOuvUkjceK916sPpc71%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;400&quot; data-origin-width=&quot;712&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;리소스를 삭제할 때 204 No Content 응답 코드를 사용하는 것이 일반적&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;204&amp;nbsp;No&amp;nbsp;Content를&amp;nbsp;사용하는&amp;nbsp;이유 &lt;br /&gt;성공적으로&amp;nbsp;삭제되었음을&amp;nbsp;나타냄:&amp;nbsp;요청이&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;않음. &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;처리할&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&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;455&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/C5jlm/btsL87lqSLh/Yk3AMTKMIMRJviYouTGVxK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/C5jlm/btsL87lqSLh/Yk3AMTKMIMRJviYouTGVxK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/C5jlm/btsL87lqSLh/Yk3AMTKMIMRJviYouTGVxK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FC5jlm%2FbtsL87lqSLh%2FYk3AMTKMIMRJviYouTGVxK%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;720&quot; height=&quot;455&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;455&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>backend/코프링</category>
      <author>JaeSung Kim</author>
      <guid isPermaLink="true">https://aamoos.tistory.com/745</guid>
      <comments>https://aamoos.tistory.com/745#entry745comment</comments>
      <pubDate>Wed, 5 Feb 2025 18:10:03 +0900</pubDate>
    </item>
  </channel>
</rss>