<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jhwest.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 20 Jul 2026 15:04:16 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jhwest.log</title>
            <url>https://velog.velcdn.com/images/jhwest-dev/profile/4e60fe00-4e0a-451f-8d52-95a10d5a7e6f/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jhwest.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jhwest-dev" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[TIL] Node.js(Express + TypeScript)로 프로젝트 구조 잡기]]></title>
            <link>https://velog.io/@jhwest-dev/Node.jsExpress-TypeScript%EB%A1%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0-%EC%9E%A1%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/Node.jsExpress-TypeScript%EB%A1%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0-%EC%9E%A1%EA%B8%B0</guid>
            <pubDate>Mon, 20 Jul 2026 15:04:16 GMT</pubDate>
            <description><![CDATA[<p>Python FastAPI로 백엔드 개발을 해오다가, 이번 팀 프로젝트에서 Node.js + Express + TypeScript 조합으로 전환하게 됐다. 같은 REST API를 만드는 건데 프레임워크가 달라지니까 &quot;이건 Express에서는 어떻게 하지?&quot;라는 질문이 계속 나왔다.</p>
<p>오늘은 FastAPI에서 익숙했던 구조를 Express에서 어떻게 대응시켰는지 정리해본다.</p>
<hr>
<h2 id="폴더-구조-매핑">폴더 구조 매핑</h2>
<p>FastAPI에서는 보통 이런 구조로 개발했다.</p>
<pre><code>app/
├── api/          # 라우터 (엔드포인트)
├── core/         # 설정, DB 연결
├── schema/       # Pydantic 모델 (요청/응답 검증)
├── models/       # ORM/ODM 모델
├── service/      # 비즈니스 로직
└── util/         # 공통 유틸</code></pre><p>Express + TypeScript에서는 이렇게 대응시켰다.</p>
<pre><code>src/
├── api/
│   ├── routes/        # 라우터 (URL 매핑만)
│   └── controllers/   # 컨트롤러 (요청 처리 + 응답)
├── core/
│   ├── config/        # 환경변수, Swagger 설정
│   ├── db/            # DB 연결
│   ├── middlewares/    # 인증, 에러 핸들러
│   └── security/      # JWT 관련
├── models/            # Mongoose 스키마
├── schemas/           # Zod 검증 스키마
├── services/          # 비즈니스 로직
├── types/             # TypeScript 타입 선언
└── server.ts          # 진입점</code></pre><p>가장 큰 차이는 <strong>controller 레이어의 유무</strong>다. FastAPI에서도 service 레이어는 분리해서 사용했지만, 라우터 함수가 곧 컨트롤러 역할을 했다. Express에서는 route, controller, service 세 단계로 명시적으로 나뉜다.</p>
<hr>
<h2 id="route-→-controller-→-service-패턴">route → controller → service 패턴</h2>
<h3 id="fastapi에서는-route--service">FastAPI에서는 (route + service)</h3>
<p>라우터 함수가 컨트롤러 역할까지 했다. service는 별도로 분리했지만, 라우터에서 직접 service를 호출하고 응답을 반환했다.</p>
<pre><code class="language-python"># router (컨트롤러 역할 포함)
@router.post(&quot;/login&quot;)
async def login(request: LoginRequest):       # 검증 자동
    result = await auth_service.login(request.code)
    return result                              # return만 하면 끝

# service
async def login(code: str):
    token = await get_kakao_token(code)
    user = await find_or_create_user(...)
    return {&quot;accessToken&quot;: token}</code></pre>
<h3 id="express에서는-route--controller--service">Express에서는 (route + controller + service)</h3>
<p>controller가 추가로 들어간다.</p>
<p><strong>route</strong> — URL이랑 함수 연결만</p>
<pre><code class="language-typescript">router.post(&quot;/login&quot;, loginHandler);</code></pre>
<p><strong>controller</strong> — 요청 파싱 + 서비스 호출 + 응답 반환</p>
<pre><code class="language-typescript">export const loginHandler = async (req, res, next) =&gt; {
    try {
        const { code } = loginSchema.parse(req.body);
        const result = await login(code);
        res.status(200).json(result);
    } catch (err) {
        next(err);
    }
};</code></pre>
<p><strong>service</strong> — 순수 비즈니스 로직 (FastAPI의 service와 동일)</p>
<pre><code class="language-typescript">export const login = async (code: string) =&gt; {
    const kakaoToken = await getKakaoToken(code);
    const { user, isNewUser } = await findOrCreateUser(...);
    return { accessToken, userId, ... };
};</code></pre>
<p>FastAPI에서는 라우터가 검증, 응답 반환까지 자동으로 해줘서 controller가 필요 없었다. Express는 그런 자동화가 없어서 controller 레이어가 그 역할을 대신한다. 결국 service 로직 자체는 거의 동일하고, <strong>HTTP 처리 방식만 다른 셈이다.</strong></p>
<hr>
<h2 id="fastapi가-자동으로-해주던-것들">FastAPI가 자동으로 해주던 것들</h2>
<p>Express로 오면서 가장 체감했던 건 <strong>FastAPI가 얼마나 많은 걸 자동으로 해줬는지</strong>였다.</p>
<h3 id="1-요청-검증">1. 요청 검증</h3>
<pre><code class="language-python"># FastAPI — 파라미터 타입만 쓰면 자동 검증
async def login(request: LoginRequest):</code></pre>
<pre><code class="language-typescript">// Express — 수동으로 해야 함
const { code } = loginSchema.parse(req.body);</code></pre>
<p>FastAPI는 Pydantic 모델을 파라미터에 넣으면 자동 검증 + 422 응답까지 해줬다. Express는 Zod로 직접 검증하고, 에러 핸들러에서 422를 반환하도록 직접 만들어줘야 한다.</p>
<h3 id="2-에러-처리">2. 에러 처리</h3>
<pre><code class="language-python"># FastAPI — HTTPException 던지면 끝
raise HTTPException(status_code=401, detail=&quot;인증 실패&quot;)</code></pre>
<pre><code class="language-typescript">// Express — try/catch + next(err) 필수
try {
    // ...
} catch (err) {
    next(err);
}</code></pre>
<h3 id="3-응답-타입">3. 응답 타입</h3>
<pre><code class="language-python"># FastAPI — response_model로 자동 필터링
@router.post(&quot;/login&quot;, response_model=Token)</code></pre>
<pre><code class="language-typescript">// Express — 인터페이스로 수동 매핑
const response: LoginResponse = {
    accessToken: result.accessToken,
    userId: result.userId,
    // ...
};
res.status(200).json(response);</code></pre>
<hr>
<h2 id="라이브러리-대응표">라이브러리 대응표</h2>
<table>
<thead>
<tr>
<th>역할</th>
<th>FastAPI (Python)</th>
<th>Express (Node.js)</th>
</tr>
</thead>
<tbody><tr>
<td>프레임워크</td>
<td>FastAPI</td>
<td>Express</td>
</tr>
<tr>
<td>요청 검증</td>
<td>Pydantic</td>
<td>Zod</td>
</tr>
<tr>
<td>ODM</td>
<td>Beanie (MongoDB)</td>
<td>Mongoose</td>
</tr>
<tr>
<td>JWT</td>
<td>python-jose</td>
<td>jsonwebtoken</td>
</tr>
<tr>
<td>환경변수</td>
<td>pydantic-settings</td>
<td>dotenv + env.ts</td>
</tr>
<tr>
<td>비밀 저장소</td>
<td>azure-keyvault-secrets</td>
<td>@azure/keyvault-secrets</td>
</tr>
</tbody></table>
<p>Beanie에서 Mongoose로 전환하면서 느낀 차이도 있다. Beanie는 <code>Document</code> 클래스가 타입 + 스키마 + ODM 역할을 동시에 했는데, Mongoose는 <strong>인터페이스(타입) + 스키마(검증) + 모델(DB 조작)</strong>이 분리되어 있다.</p>
<pre><code class="language-typescript">// 1. 타입 정의
interface IUser extends Document {
    name: string;
    kakao_id: string;
}

// 2. 스키마 정의
const userSchema = new Schema&lt;IUser&gt;({
    name: { type: String, required: true },
    kakao_id: { type: String, required: true, unique: true },
});

// 3. 모델 생성
export const UserModel = model&lt;IUser&gt;(&quot;User&quot;, userSchema);</code></pre>
<p>Beanie에서는 한 클래스로 끝났던 걸 세 단계로 나눠야 하지만, 오히려 각 역할이 명확해지는 장점이 있었다.</p>
<hr>
<h2 id="types-폴더의-역할--dts-파일은-왜-만들어야-하나">types 폴더의 역할 — .d.ts 파일은 왜 만들어야 하나?</h2>
<p>TypeScript를 쓰면서 가장 낯설었던 건 <code>types/</code> 폴더에 <code>.d.ts</code> 파일을 직접 만들어줘야 하는 상황이었다.</p>
<p>JavaScript 라이브러리는 세 가지 유형이 있다.</p>
<table>
<thead>
<tr>
<th>유형</th>
<th>예시</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>타입 내장</td>
<td>mongoose, zod</td>
<td>바로 사용 가능</td>
</tr>
<tr>
<td>@types 패키지 있음</td>
<td>ws → @types/ws</td>
<td><code>npm install -D @types/ws</code></td>
</tr>
<tr>
<td>타입 없음</td>
<td>y-websocket, y-protocols</td>
<td>직접 <code>.d.ts</code> 작성</td>
</tr>
</tbody></table>
<p>타입이 없는 라이브러리를 import하면 에러가 난다.</p>
<pre><code class="language-typescript">import { WebsocketProvider } from &quot;y-websocket&quot;;
// ❌ Cannot find module &#39;y-websocket&#39; or its corresponding type declarations.</code></pre>
<p>이럴 때 직접 타입 선언 파일을 만들어줘야 한다.</p>
<pre><code class="language-typescript">// src/types/y-websocket.d.ts
declare module &quot;y-websocket&quot; {
    export class WebsocketProvider {
        constructor(serverUrl: string, roomname: string, doc: any);
        destroy(): void;
    }
}</code></pre>
<p>Python은 타입이 없어도 실행되지만, TypeScript는 타입을 모르면 컴파일 자체가 안 되기 때문이다.</p>
<p>또 하나 유용했던 건 Express의 <code>Request</code> 타입 확장이다. 인증 미들웨어에서 <code>req.user</code>를 넣어주는데, Express 기본 타입에는 <code>user</code>가 없다.</p>
<pre><code class="language-typescript">// src/types/express/index.d.ts
declare global {
    namespace Express {
        interface Request {
            user?: {
                userId: string;
                name: string;
            };
        }
    }
}
export {};</code></pre>
<p>이걸 만들어두면 <code>(req as any).user</code> 대신 <code>req.user</code>로 타입 안전하게 쓸 수 있다.</p>
<hr>
<h2 id="느낀-점">느낀 점</h2>
<ul>
<li>FastAPI가 정말 많은 걸 자동화해주고 있었다는 걸 Express를 쓰면서 체감했다.</li>
<li>대신 Express는 자유도가 높아서 구조를 원하는 대로 잡을 수 있다.</li>
<li>TypeScript의 타입 시스템은 처음에 번거롭지만, 프로젝트가 커질수록 안전망 역할을 해준다.</li>
<li>FastAPI와 Express는 겉보기엔 다르지만 <strong>route → 비즈니스 로직 → DB</strong>라는 큰 흐름은 동일하다. 하나를 알면 다른 하나도 금방 적응할 수 있다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Node.js 백엔드 초기 세팅 & Azure 배포]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-Node.js-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%B4%88%EA%B8%B0-%EC%84%B8%ED%8C%85-Azure-%EB%B0%B0%ED%8F%AC</link>
            <guid>https://velog.io/@jhwest-dev/TIL-Node.js-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%B4%88%EA%B8%B0-%EC%84%B8%ED%8C%85-Azure-%EB%B0%B0%ED%8F%AC</guid>
            <pubDate>Wed, 15 Jul 2026 15:51:21 GMT</pubDate>
            <description><![CDATA[<p>부트캠프 미니프로젝트2 <strong>구움(Gooum)</strong>의 백엔드를 Python(FastAPI)에서 Node.js + TypeScript로 전환하고, Azure App Service에 GitHub Actions로 자동 배포까지 연결했다.</p>
<p>핵심 기능이 <strong>실시간 채팅</strong>, <strong>동시 문서 편집</strong>이라 다수 클라이언트가 동시 접속해서 이벤트를 계속 주고받는 구조인데, Node.js의 이벤트 루프 기반 비동기 I/O가 이런 워크로드에 잘 맞고 <code>Socket.io</code>, <code>Yjs</code> 등 관련 라이브러리 생태계도 npm 쪽이 두터워서 스택을 바꾸기로 결정했다.</p>
<hr>
<h2 id="1-로컬-nodejs--typescript-환경-세팅">1. 로컬 Node.js + TypeScript 환경 세팅</h2>
<h3 id="패키지-초기화-및-설치">패키지 초기화 및 설치</h3>
<pre><code class="language-bash">npm init -y

# 개발용 도구
npm install -D typescript tsx @types/node @types/express

# 운영용 라이브러리
npm install express dotenv</code></pre>
<p>Node.js는 패키지를 프로젝트 폴더 안 <code>node_modules</code>에 설치하기 때문에 폴더 자체가 이미 격리된 환경이다. 파이썬의 <code>venv</code> 같은 가상환경 개념이 따로 필요 없다.</p>
<p><code>npm install</code>할 때 <code>-D</code>(<code>--save-dev</code>) 옵션을 붙이면 <code>devDependencies</code>(개발용)로, 안 붙이면 <code>dependencies</code>(운영용)로 <code>package.json</code>에 기록된다.</p>
<h3 id="tsconfigjson">tsconfig.json</h3>
<pre><code class="language-json">{
  &quot;compilerOptions&quot;: {
    &quot;target&quot;: &quot;ES2022&quot;,
    &quot;module&quot;: &quot;CommonJS&quot;,
    &quot;rootDir&quot;: &quot;./src&quot;,
    &quot;outDir&quot;: &quot;./dist&quot;,
    &quot;esModuleInterop&quot;: true,
    &quot;forceConsistentCasingInFileNames&quot;: true,
    &quot;strict&quot;: true,
    &quot;skipLibCheck&quot;: true
  },
  &quot;include&quot;: [&quot;src/**/*&quot;]
}</code></pre>
<p>TypeScript 컴파일러(<code>tsc</code>)가 <code>.ts</code> 파일을 어떻게 <code>.js</code>로 변환할지 정하는 설정 파일이다.</p>
<table>
<thead>
<tr>
<th>옵션</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>target</code></td>
<td>컴파일된 JS가 어떤 자바스크립트 버전 문법을 쓸지 (<code>ES2022</code>)</td>
</tr>
<tr>
<td><code>module</code></td>
<td>모듈 시스템 방식. <code>CommonJS</code>로 지정해 배포/라이브러리 호환성을 우선함</td>
</tr>
<tr>
<td><code>rootDir</code></td>
<td>컴파일 대상 소스 파일들이 있는 루트 폴더 (<code>src/</code>)</td>
</tr>
<tr>
<td><code>outDir</code></td>
<td>컴파일 결과물(<code>.js</code>)이 출력될 폴더 (<code>dist/</code>)</td>
</tr>
<tr>
<td><code>esModuleInterop</code></td>
<td><code>import express from &#39;express&#39;</code> 같은 ES Module 스타일 import 문법을 CommonJS 라이브러리에서도 에러 없이 쓸 수 있게 해줌</td>
</tr>
<tr>
<td><code>forceConsistentCasingInFileNames</code></td>
<td>파일명 대소문자를 엄격하게 체크 (OS별로 대소문자 구분이 달라 생기는 오류 방지)</td>
</tr>
<tr>
<td><code>strict</code></td>
<td>타입 검사를 엄격하게 적용 (null 체크, 암시적 any 금지 등)</td>
</tr>
<tr>
<td><code>skipLibCheck</code></td>
<td><code>node_modules</code> 안 라이브러리의 타입 정의 파일까지는 검사하지 않아 컴파일 속도를 높임</td>
</tr>
<tr>
<td><code>include</code></td>
<td>컴파일 대상 파일 범위 지정 (<code>src/</code> 하위 전체)</td>
</tr>
</tbody></table>
<h3 id="packagejson">package.json</h3>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;gooum-be&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;description&quot;: &quot;&quot;,
  &quot;main&quot;: &quot;index.js&quot;,
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;tsx src/server.ts&quot;,
    &quot;build&quot;: &quot;tsc&quot;,
    &quot;start&quot;: &quot;node dist/server.js&quot;
  },
  &quot;repository&quot;: {
    &quot;type&quot;: &quot;git&quot;,
    &quot;url&quot;: &quot;git+https://github.com/ureca-Gooum/Gooum-BE.git&quot;
  },
  &quot;keywords&quot;: [],
  &quot;author&quot;: &quot;&quot;,
  &quot;license&quot;: &quot;ISC&quot;,
  &quot;type&quot;: &quot;commonjs&quot;,
  &quot;bugs&quot;: {
    &quot;url&quot;: &quot;https://github.com/ureca-Gooum/Gooum-BE/issues&quot;
  },
  &quot;homepage&quot;: &quot;https://github.com/ureca-Gooum/Gooum-BE#readme&quot;,
  &quot;devDependencies&quot;: {
    &quot;@types/express&quot;: &quot;^5.0.6&quot;,
    &quot;@types/node&quot;: &quot;^26.1.1&quot;,
    &quot;ts-node&quot;: &quot;^10.9.2&quot;,
    &quot;tsx&quot;: &quot;^4.23.1&quot;,
    &quot;typescript&quot;: &quot;^7.0.2&quot;
  },
  &quot;dependencies&quot;: {
    &quot;dotenv&quot;: &quot;^17.4.2&quot;,
    &quot;express&quot;: &quot;^5.2.1&quot;
  }
}</code></pre>
<ul>
<li><code>npm run dev</code> : 로컬 개발 중 TS 파일 바로 실행 (<code>tsx</code> 사용, 별도 설정 없이 esbuild 기반으로 즉시 실행됨)</li>
<li><code>npm run build</code> : TS → JS 컴파일 (<code>dist/</code> 생성)</li>
<li><code>npm start</code> : 컴파일된 JS로 실제 서버 구동 (배포 환경에서 사용)</li>
<li><code>&quot;type&quot;: &quot;commonjs&quot;</code> : 모듈 시스템을 CommonJS로 명시. <code>tsx</code>가 <code>import</code> 문법을 알아서 처리해주고, <code>tsc</code> 빌드나 일부 라이브러리와의 호환성을 위해 <code>module</code>(ESM)이 아닌 <code>commonjs</code>로 고정</li>
<li><code>ts-node</code>는 devDependencies에 남아있지만 실제 실행은 <code>tsx</code>로 하고 있어 사실상 미사용 상태 (정리 대상)</li>
</ul>
<h3 id="기본-서버-코드-srcserverts">기본 서버 코드 (src/server.ts)</h3>
<pre><code class="language-ts">import express, { Request, Response } from &quot;express&quot;;
import dotenv from &quot;dotenv&quot;;

dotenv.config();

const app = express();
// Azure가 임의로 부여하는 포트(process.env.PORT)를 우선적으로 사용하도록 설정!
const PORT = process.env.PORT || 8000;

app.use(express.json());

// 배포 테스트용 핑 API
app.get(&quot;/ping&quot;, (req: Request, res: Response) =&gt; {
  res.status(200).json({
    message: &quot;pong! Node.js TS server is running on Azure!&quot;,
  });
});

app.listen(PORT, () =&gt; {
  console.log(`🚀 Server is running on port ${PORT}`);
});</code></pre>
<ul>
<li><code>dotenv.config()</code>는 다른 코드가 <code>process.env</code>를 사용하기 전에 가장 먼저 실행되어야 하므로 파일 최상단에 둔다.</li>
<li><code>process.env.PORT || 8000</code> : 로컬에서는 8000번을 쓰고, 배포 환경(Azure)에서는 플랫폼이 지정한 포트를 우선 사용하도록 유연하게 작성.</li>
</ul>
<h3 id="gitignore">.gitignore</h3>
<pre><code># 환경 변수 및 비밀값
.env
.env.local

# 의존성 패키지 폴더
node_modules/

# 빌드 결과물
dist/
out/
build/

# 개발 도구 및 IDE 설정
.vscode/
.idea/

# OS 및 임시 파일
.DS_Store
Thumbs.db
npm-debug.log*</code></pre><hr>
<h2 id="2-azure-app-service-생성">2. Azure App Service 생성</h2>
<ul>
<li>게시(Publish): 코드(Code)</li>
<li>런타임 스택: Node 20 LTS 또는 Node 22 LTS</li>
<li>운영체제: Linux</li>
<li>요금제: 테스트 단계면 Free(F1) 또는 최소 사양
<img src="https://velog.velcdn.com/images/jhwest-dev/post/0ac2d7d9-95f2-43a8-a964-3c940df9d70b/image.png" alt=""></li>
</ul>
<blockquote>
<p>기존에 다른 스택(Python 등)으로 쓰던 App Service가 있다면, 설정만 바꾸는 것보다 새로 생성하는 편이 안전하다. 내부 빌드 엔진이나 환경변수가 이전 스택 기준으로 남아 있어 오류가 나기 쉽다.</p>
</blockquote>
<hr>
<h2 id="3-github-actions로-자동-배포-연결">3. GitHub Actions로 자동 배포 연결</h2>
<ol>
<li>Azure Portal → App Service → <strong>배포 센터(Deployment Center)</strong></li>
<li>원본(Source): <strong>GitHub</strong> 선택 후 계정 연동</li>
<li>배포할 <strong>레포지토리 / 브랜치</strong>(예: <code>main</code>) 선택 후 저장
<img src="https://velog.velcdn.com/images/jhwest-dev/post/692d6a9e-b035-49f3-9849-00eeea4fa252/image.png" alt=""></li>
</ol>
<p>저장하면 Azure가 자동으로 <code>.github/workflows/*.yml</code> 워크플로 파일을 레포지토리에 생성해준다. 이후 해당 브랜치에 <code>push</code>할 때마다 아래 과정이 자동으로 실행된다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/ef070bd9-39e7-4f74-8f9d-edccdec33ff4/image.png" alt=""></p>
<ol>
<li>코드 checkout</li>
<li>Node.js 설치</li>
<li><code>npm install</code> → <code>npm run build</code></li>
<li>빌드 결과물 아티팩트 업로드</li>
<li>Azure App Service에 배포</li>
</ol>
<p><code>package.json</code>에 <code>build</code> 스크립트가 정확히 정의돼 있어야 3번 단계가 정상 동작한다.</p>
<hr>
<h2 id="4-시작-명령startup-command-설정">4. 시작 명령(Startup Command) 설정</h2>
<p>Azure Portal → App Service → <strong>구성(Configuration)</strong> → <strong>일반 설정(General settings)</strong> → 시작 명령
<img src="https://velog.velcdn.com/images/jhwest-dev/post/8b6d4e5e-cd4f-4c93-98b7-6a770ccd894e/image.png" alt=""></p>
<p>포트는 명령어에 별도로 적을 필요 없다. 코드에서 <code>process.env.PORT</code>를 리슨하도록 작성했다면 Azure가 내부적으로 지정한 포트와 자동으로 맞물린다.</p>
<hr>
<h2 id="5-포트-설정-필요한-경우만">5. 포트 설정 (필요한 경우만)</h2>
<p>Azure Linux App Service는 기본적으로 내부 포트를 <strong>8080</strong>으로 사용한다. 코드에서 <code>process.env.PORT || 8000</code>으로 작성해뒀다면 별도 설정 없이 자동으로 맞춰지지만, 만약 배포 후 502 Bad Gateway가 발생한다면 아래처럼 명시적으로 지정한다.</p>
<ul>
<li>Azure Portal → 구성(Configuration) → <strong>애플리케이션 설정(Application settings)</strong></li>
<li>이름: <code>WEBSITES_PORT</code></li>
<li>값: 실제 코드에서 사용 중인 포트 번호 (예: <code>8080</code>)</li>
</ul>
<hr>
<h2 id="6-배포-확인">6. 배포 확인</h2>
<pre><code>https://&lt;앱이름&gt;.azurewebsites.net/ping</code></pre><pre><code class="language-json">{ &quot;message&quot;: &quot;pong! Node.js TS server is running!&quot; }</code></pre>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/38ea2091-65cf-4d54-99c4-bb8626828a41/image.png" alt=""></p>
<p>Log stream에서 아래 메시지가 뜨면 정상 기동 상태다.</p>
<pre><code>🚀 Server is running on port 8080</code></pre><p><img src="https://velog.velcdn.com/images/jhwest-dev/post/45e85aa1-818d-4118-9147-55d465b3a24c/image.png" alt=""></p>
<hr>
<h2 id="요약">요약</h2>
<ol>
<li><code>tsx</code>로 TS 실행 환경 구성, <code>dotenv</code>로 비밀값 분리, <code>.gitignore</code>로 민감 정보 제외</li>
<li>Azure에 Node.js 런타임(Linux)으로 App Service 생성</li>
<li>배포 센터에서 GitHub 레포 연결 → push할 때마다 GitHub Actions가 자동 빌드/배포</li>
<li>시작 명령은 <code>node dist/server.js</code>, 포트는 코드에서 <code>process.env.PORT</code>로 동적으로 대응</li>
<li>필요 시 <code>WEBSITES_PORT</code> 환경변수로 포트 명시</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] JWT 토큰 ]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-JWT-%ED%86%A0%ED%81%B0</link>
            <guid>https://velog.io/@jhwest-dev/TIL-JWT-%ED%86%A0%ED%81%B0</guid>
            <pubDate>Sun, 12 Jul 2026 13:00:46 GMT</pubDate>
            <description><![CDATA[<p>React + Express로 JWT 인증 시스템을 직접 구현해보면서 배운 내용을 정리한다.</p>
<h2 id="0-사용한-패키지">0. 사용한 패키지</h2>
<table>
<thead>
<tr>
<th>패키지</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://www.npmjs.com/package/jsonwebtoken"><code>jsonwebtoken</code></a></td>
<td>JWT 토큰 생성(<code>jwt.sign</code>)·검증(<code>jwt.verify</code>)·디코딩(<code>jwt.decode</code>)</td>
</tr>
<tr>
<td><a href="https://www.npmjs.com/package/express"><code>express</code></a></td>
<td>백엔드 서버 프레임워크</td>
</tr>
<tr>
<td><a href="https://www.npmjs.com/package/cors"><code>cors</code></a></td>
<td>프론트(5173)·백엔드(3001) 간 CORS 허용 설정</td>
</tr>
<tr>
<td><a href="https://www.npmjs.com/package/cookie-parser"><code>cookie-parser</code></a></td>
<td>요청에 담긴 쿠키를 <code>req.cookies</code>로 파싱</td>
</tr>
<tr>
<td><code>react</code></td>
<td>프론트엔드 UI (로그인 폼, 인증 상태 관리)</td>
</tr>
</tbody></table>
<pre><code class="language-bash"># 백엔드
npm install express jsonwebtoken cors cookie-parser

# 프론트엔드는 fetch API만 사용, 별도 HTTP 라이브러리 없음</code></pre>
<h2 id="1-jwt란">1. JWT란?</h2>
<p>JWT(JSON Web Token)는 두 당사자 간 정보를 안전하게 전송하기 위한 개방형 표준(RFC 7519)이다.</p>
<p><strong>구조: <code>Header.Payload.Signature</code></strong></p>
<table>
<thead>
<tr>
<th>구성요소</th>
<th>내용</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>Header</td>
<td>토큰 타입 + 알고리즘 (<code>{&quot;alg&quot;:&quot;HS256&quot;,&quot;typ&quot;:&quot;JWT&quot;}</code>)</td>
<td>Base64 인코딩</td>
</tr>
<tr>
<td>Payload</td>
<td>실제 데이터 (userId, username, exp 등)</td>
<td>Base64 인코딩, <strong>암호화 아님</strong></td>
</tr>
<tr>
<td>Signature</td>
<td>Header + Payload + Secret Key로 만든 해시값</td>
<td>Secret 없이는 위조 불가</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ <strong>가장 중요한 포인트</strong>: Base64 인코딩 ≠ 암호화. 누구나 디코딩해서 payload를 볼 수 있다. 따라서 JWT payload에는 비밀번호 같은 민감 정보를 절대 넣으면 안 된다.</p>
</blockquote>
<p><strong>JWT의 핵심 장점: Stateless(무상태)</strong>
서버가 세션을 별도 저장하지 않고 토큰 자체에 정보가 담겨있어서, 서버를 여러 대로 확장해도 세션 동기화 문제가 없다.</p>
<h2 id="2-토큰을-어디에-저장할까-localstorage-vs-cookie">2. 토큰을 어디에 저장할까? localStorage vs Cookie</h2>
<h3 id="localstorage-방식">localStorage 방식</h3>
<ul>
<li><strong>전송</strong>: <code>Authorization: Bearer &lt;token&gt;</code> 헤더에 직접 담아서 전송</li>
<li><strong>장점</strong>: 구현이 간단, 클라이언트에서 자유롭게 접근 가능</li>
<li><strong>단점</strong>: <strong>XSS 공격에 취약</strong> — 악성 스크립트가 삽입되면 <code>localStorage.getItem()</code>으로 토큰을 그대로 탈취 가능</li>
</ul>
<h3 id="httponly-cookie-방식">HttpOnly Cookie 방식</h3>
<ul>
<li><strong>전송</strong>: <code>credentials: &quot;include&quot;</code> 옵션으로 브라우저가 자동 전송</li>
<li><strong>장점</strong>: <code>httpOnly: true</code> 설정 시 <strong>JavaScript로 절대 접근 불가</strong> → XSS로부터 토큰 자체는 안전</li>
<li><strong>단점</strong>: CSRF(사이트 간 요청 위조) 공격에는 별도 방어가 필요 (<code>sameSite</code> 옵션으로 완화)<h3 id="쿠키-옵션-정리">쿠키 옵션 정리</h3>
</li>
</ul>
<pre><code>httpOnly: true   // JS 접근 차단 (XSS 방어의 핵심)
secure: false    // 개발환경(false) / 프로덕션(true, HTTPS 필수)
sameSite: &quot;lax&quot;  // CSRF 방어 (strict/lax/none)
maxAge: 3600000  // 만료시간(ms), 토큰 만료시간과 맞춤
path: &quot;/&quot;        // 쿠키 사용 가능 경로</code></pre><p><strong>결론</strong>: XSS는 HttpOnly 쿠키로 막고, CSRF는 sameSite + 서버 측 검증으로 막는 게 실무 정석 조합.</p>
<h2 id="4-로그인--인증-검증-흐름">4. 로그인 ~ 인증 검증 흐름</h2>
<h3 id="1-서버-로그인-시-토큰-발급-serverjs">(1) 서버: 로그인 시 토큰 발급 (<code>server.js</code>)</h3>
<p><code>jsonwebtoken</code> 패키지에서 <code>jwt.sign</code>, <code>jwt.verify</code>, <code>jwt.decode</code> 세 함수를 가져와 사용한다.</p>
<pre><code class="language-js">import jwt from &quot;jsonwebtoken&quot;;</code></pre>
<pre><code class="language-js">app.post(&quot;/api/login&quot;, (req, res) =&gt; {
    const { username, password, storageType = &quot;localStorage&quot; } = req.body;

    const user = users.find(
        (u) =&gt; u.username === username &amp;&amp; u.password === password,
    );

    if (!user) {
        return res.status(401).json({
            success: false,
            message: &quot;사용자 이름 또는 비밀번호가 잘못되었습니다.&quot;,
        });
    }

    const payload = {
        userId: user.id,
        username: user.username,
        role: user.role,
    };

    const options = {
        expiresIn: &quot;1h&quot;,
        issuer: &quot;jwt-education-server&quot;,
        subject: user.username,
    };

    const token = jwt.sign(payload, JWT_SECRET, options);

    if (storageType === &quot;cookie&quot;) {
        res.cookie(&quot;jwt_token&quot;, token, {
            httpOnly: true,
            secure: false,
            sameSite: &quot;lax&quot;,
            maxAge: 60 * 60 * 1000,
            path: &quot;/&quot;,
        });
        res.json({ success: true, storageType: &quot;cookie&quot;, user });
    } else {
        res.json({ success: true, storageType: &quot;localStorage&quot;, token, user });
    }
});</code></pre>
<p><code>storageType</code> 값 하나로 쿠키 발급과 JSON 응답을 분기하는 구조가 핵심이다. 로그인 로직 자체(비밀번호 검증, 토큰 서명)는 동일하고, <strong>토큰을 어떻게 클라이언트에 넘겨줄지</strong>만 달라진다.</p>
<h3 id="2-서버-토큰-검증-미들웨어-verifytoken">(2) 서버: 토큰 검증 미들웨어 (<code>verifyToken</code>)</h3>
<pre><code class="language-js">const verifyToken = (req, res, next) =&gt; {
    let token = null;

    // 1. 쿠키에서 토큰 확인
    if (req.cookies &amp;&amp; req.cookies.jwt_token) {
        token = req.cookies.jwt_token;
    }
    // 2. Authorization 헤더에서 토큰 확인
    else {
        const authHeader = req.headers[&quot;authorization&quot;];
        if (authHeader) {
            token = authHeader.split(&quot; &quot;)[1]; // &quot;Bearer &lt;token&gt;&quot; 중 토큰만 추출
        }
    }

    if (!token) {
        return res.status(401).json({ success: false, message: &quot;인증 토큰이 제공되지 않았습니다&quot; });
    }

    try {
        const decoded = jwt.verify(token, JWT_SECRET);
        req.user = decoded;
        next();
    } catch (err) {
        if (err.name === &quot;TokenExpiredError&quot;) {
            return res.status(401).json({ success: false, message: &quot;토큰이 만료되었습니다. 다시 로그인해주세요.&quot; });
        }
        if (err.name === &quot;JsonWebTokenError&quot;) {
            return res.status(401).json({ success: false, message: &quot;유효하지 않은 토큰입니다&quot; });
        }
        return res.status(401).json({ success: false, message: &quot;토큰 검증 중 오류가 발생했습니다.&quot; });
    }
};</code></pre>
<p><strong>쿠키를 먼저 확인하고, 없으면 헤더를 확인</strong>하는 순서로 짜여있어서 두 저장 방식을 하나의 미들웨어로 동시에 지원한다. <code>jwt.verify()</code>가 던지는 에러 이름(<code>TokenExpiredError</code>, <code>JsonWebTokenError</code>)으로 실패 원인을 구분해서 메시지를 다르게 내려주는 부분도 눈여겨볼 만하다.</p>
<h3 id="3-클라이언트-요청마다-인증-정보-자동-첨부-apijs">(3) 클라이언트: 요청마다 인증 정보 자동 첨부 (<code>api.js</code>)</h3>
<pre><code class="language-js">const getAuthHeaders = () =&gt; {
    const headers = { &quot;Content-Type&quot;: &quot;application/json&quot; };

    // localStorage 방식일 때만 헤더에 토큰을 직접 담는다
    if (currentStorageType === &quot;localStorage&quot;) {
        const token = getToken();
        if (token) {
            headers[&quot;Authorization&quot;] = `Bearer ${token}`;
        }
    }
    // 쿠키 방식은 credentials: &quot;include&quot; 설정만으로 자동 전송됨
    return headers;
};

const getFetchOptions = (method = &quot;GET&quot;, body = null) =&gt; {
    const options = {
        method,
        headers: getAuthHeaders(),
        credentials: &quot;include&quot;, // 쿠키 포함 요청 (CORS 시 필수)
    };
    if (body) {
        options.body = typeof body === &quot;string&quot; ? body : JSON.stringify(body);
    }
    return options;
};</code></pre>
<p><code>getProfile</code>, <code>getAdminData</code>, <code>refreshToken</code> 등 인증이 필요한 API는 전부 이 <code>getFetchOptions()</code>를 통해 호출하도록 통일해서, 저장 방식이 바뀌어도 각 API 함수 코드는 건드릴 필요가 없게 설계되어 있다.</p>
<pre><code class="language-js">export const getProfile = async () =&gt; {
    const response = await fetch(`${API_BASE_URL}/profile`, getFetchOptions(&quot;GET&quot;));
    const data = await response.json();

    if (data.success) {
        return { success: true, user: data.user };
    } else {
        if (response.status === 401) {
            removeToken(); // 만료/무효 토큰은 즉시 폐기
        }
        return { success: false, message: data.message || &quot;프로필 조회 실패&quot; };
    }
};</code></pre>
<p>401 응답이 오면 클라이언트가 알아서 <code>removeToken()</code>을 호출해 로컬에 남은 만료 토큰을 정리하는 흐름도 실무적으로 유용했다.</p>
<h3 id="jwtdecode-vs-jwtverify">jwt.decode() vs jwt.verify()</h3>
<ul>
<li><code>jwt.decode()</code>: 서명 검증 없이 <strong>디코딩만</strong> 함 (위조된 토큰도 그냥 읽힘)</li>
<li><code>jwt.verify()</code>: 서명 검증 + 디코딩 → <strong>실제 인증 로직에는 반드시 이것만 사용</strong></li>
</ul>
<pre><code class="language-js">// 교육용 디코딩 엔드포인트 - 실제 서비스에서는 절대 노출 금지
app.post(&quot;/api/decode-token&quot;, (req, res) =&gt; {
    const decoded = jwt.decode(token, { complete: true });
    // complete: true → Header, Payload, Signature 모두 반환
    res.json({ success: true, decoded });
});</code></pre>
<h3 id="⚠️-주의-라이브러리마다-decode의-동작이-다르다">⚠️ 주의: 라이브러리마다 <code>decode()</code>의 동작이 다르다</h3>
<p>예전에 Python으로 JWT를 구현했을 때 <strong>PyJWT</strong>를 썼는데, 거기서는 <code>decode()</code>가 기본적으로 <strong>서명 검증까지 포함</strong>해서 동작한다. Node의 <code>jsonwebtoken</code>과 정반대라 헷갈리기 딱 좋은 부분이라 따로 정리한다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Node.js <code>jsonwebtoken</code></th>
<th>Python <code>PyJWT</code></th>
</tr>
</thead>
<tbody><tr>
<td><code>decode()</code></td>
<td>검증 <strong>안 함</strong> (그냥 파싱만)</td>
<td>기본값으로 서명 <strong>검증함</strong> (<code>verify_signature=True</code>)</td>
</tr>
<tr>
<td><code>verify()</code></td>
<td>검증 + 디코딩</td>
<td>별도 <code>verify()</code> 메서드 없음, <code>decode()</code>가 그 역할까지 담당</td>
</tr>
<tr>
<td>검증 없이 보고 싶을 때</td>
<td><code>decode()</code> 그대로 사용</td>
<td><code>decode(token, options={&quot;verify_signature&quot;: False})</code>처럼 <strong>명시적으로 꺼야 함</strong></td>
</tr>
</tbody></table>
<pre><code class="language-python">import jwt

# PyJWT: decode()가 기본적으로 서명 검증까지 수행
decoded = jwt.decode(token, SECRET_KEY, algorithms=[&quot;HS256&quot;])  # 여기서 이미 검증됨

# 검증 없이 payload만 보고 싶다면 명시적으로 꺼야 함
unsafe_decoded = jwt.decode(token, options={&quot;verify_signature&quot;: False})</code></pre>
<p><strong>정리</strong>: 같은 &quot;decode&quot;라는 이름이라도 라이브러리마다 검증 포함 여부가 다르니, 새 언어/라이브러리로 JWT를 다룰 때는 <strong>공식 문서에서 decode 함수가 서명 검증을 포함하는지 반드시 확인</strong>하는 습관을 들이는 게 중요하다. (<code>jsonwebtoken</code>은 이름 그대로 decode=안전하지 않음, verify=안전함으로 명확히 분리되어 있지만, PyJWT는 이름만 봐서는 구분이 안 되기 때문에 실수하기 쉽다.)</p>
<h2 id="5-토큰-갱신refresh">5. 토큰 갱신(Refresh)</h2>
<p>만료 전에 유효한 토큰으로 새 토큰을 재발급받는 흐름도 구현했다. <code>verifyToken</code> 미들웨어를 그대로 재사용해서 &quot;현재 토큰이 유효한 사람만 갱신 가능&quot;하도록 설계한 점이 인상적이었다.</p>
<pre><code class="language-js">app.post(&quot;/api/refresh&quot;, verifyToken, (req, res) =&gt; {
    const { storageType = &quot;localStorage&quot; } = req.body;

    const payload = {
        userId: req.user.userId,
        username: req.user.username,
        role: req.user.role,
    };

    const options = {
        expiresIn: &quot;1h&quot;,
        issuer: &quot;jwt-education-server&quot;,
        subject: req.user.username,
    };

    const newToken = jwt.sign(payload, JWT_SECRET, options);

    if (storageType === &quot;cookie&quot;) {
        res.cookie(&quot;jwt_token&quot;, newToken, {
            httpOnly: true,
            secure: false,
            sameSite: &quot;lax&quot;,
            maxAge: 60 * 60 * 1000,
            path: &quot;/&quot;,
        });
        res.json({ success: true, storageType: &quot;cookie&quot; });
    } else {
        res.json({ success: true, token: newToken, storageType: &quot;localStorage&quot; });
    }
});</code></pre>
<p>라우트 앞에 <code>verifyToken</code>을 붙이기만 하면 인증 체크가 자동으로 적용된다는 점에서, Express 미들웨어 체이닝이 왜 편한지 체감할 수 있었다.</p>
<h2 id="6-마무리">6. 마무리</h2>
<ul>
<li><strong>Base64 ≠ 암호화</strong> 
&quot;토큰이 암호화됐으니 안전하겠지&quot;라고 착각하기 쉬운데, payload는 jwt.io 같은 곳에서 누구나 디코딩 가능하다.</li>
<li><strong>XSS는 저장 위치(HttpOnly), CSRF는 전송 방식(sameSite/CSRF 토큰)</strong>으로 막는다</li>
<li>CORS <code>credentials</code> 설정은 <strong>클라이언트 + 서버 양쪽 다</strong> 필요하다</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]  웹 보안 - CORS, XSS, CSRF, SQL Injection]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-%EC%9B%B9-%EB%B3%B4%EC%95%88-CORS-XSS-CSRF-SQL-Injection</link>
            <guid>https://velog.io/@jhwest-dev/TIL-%EC%9B%B9-%EB%B3%B4%EC%95%88-CORS-XSS-CSRF-SQL-Injection</guid>
            <pubDate>Sun, 12 Jul 2026 13:00:06 GMT</pubDate>
            <description><![CDATA[<p>오늘은 브라우저의 CORS 정책부터 시작해서, 실제 발생했던 대형 보안 사고 사례, 그리고 SQL Injection / XSS / CSRF 같은 대표적인 웹 취약점까지 실습을 통해 공부했다. </p>
<h2 id="1-cors-cross-origin-resource-sharing">1. CORS (Cross-Origin Resource Sharing)</h2>
<h3 id="cors란">CORS란?</h3>
<p>브라우저가 한 출처(Origin)에서 실행 중인 웹 애플리케이션이 <strong>다른 출처의 리소스</strong>에 접근할 수 있도록 허용하는 메커니즘.</p>
<h3 id="동일-출처-정책-same-origin-policy-sop">동일 출처 정책 (Same-Origin Policy, SOP)</h3>
<p>브라우저는 보안을 위해 SOP를 적용하며, 두 URL이 아래 세 가지가 모두 같아야 같은 출처로 간주한다.</p>
<ul>
<li>프로토콜 (http vs https)</li>
<li>도메인 (example.com vs api.example.com)</li>
<li>포트 (3000 vs 8080)</li>
</ul>
<h3 id="실습으로-확인한-것">실습으로 확인한 것</h3>
<ul>
<li><strong>다른 출처 요청</strong>: GitHub API처럼 CORS를 허용해둔 서버는 다른 도메인에서도 정상적으로 응답을 받을 수 있음</li>
<li><strong>같은 출처 요청</strong>: 애초에 CORS 제한 자체가 없음</li>
<li><strong>CORS 미설정 서버</strong>: 다른 포트로 요청 시 브라우저가 요청 자체를 차단 (Failed to fetch 형태의 에러)</li>
<li><strong>CORS 허용 서버</strong>: 서버가 <code>Access-Control-Allow-Origin</code> 헤더를 설정해두면 다른 출처에서도 요청 가능</li>
</ul>
<h3 id="기억할-점">기억할 점</h3>
<ul>
<li>CORS는 <strong>브라우저의 보안 정책</strong>이라 서버 간 통신(server-to-server)에는 적용되지 않는다</li>
<li>결국 CORS 문제 해결의 열쇠는 <strong>서버 쪽 헤더 설정</strong>에 있다</li>
<li>개발 환경에서는 프록시로 우회 가능하지만, 프로덕션에서는 서버에서 반드시 CORS 헤더를 제대로 설정해야 함</li>
</ul>
<hr>
<h2 id="2-xmlhttprequest-vs-fetch-api">2. XMLHttpRequest vs Fetch API</h2>
<h3 id="xmlhttprequest-xhr">XMLHttpRequest (XHR)</h3>
<p>브라우저와 서버가 비동기로 데이터를 주고받는 전통적인 방식 (Ajax의 핵심 기술). <code>onload</code>, <code>onerror</code> 같은 이벤트 콜백 기반으로 동작.</p>
<h3 id="fetch-api">Fetch API</h3>
<p>XHR의 현대적 대안. <strong>Promise 기반</strong>이라 <code>.then()</code> 체이닝이나 <code>async/await</code>와 훨씬 잘 어울리고 문법도 간결하다.</p>
<h3 id="promise-체이닝">Promise 체이닝</h3>
<pre><code class="language-js">fetch(url)
  .then(res =&gt; res.json())
  .then(data =&gt; console.log(data))
  .catch(err =&gt; console.error(err));</code></pre>
<p><code>.then()</code>으로 비동기 흐름을 연결하고, <code>.catch()</code>로 에러를 한 곳에서 처리할 수 있다는 게 핵심.</p>
<h3 id="비교-정리">비교 정리</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>XHR</th>
<th>Fetch</th>
</tr>
</thead>
<tbody><tr>
<td>문법</td>
<td>다소 복잡</td>
<td>간결</td>
</tr>
<tr>
<td>비동기 처리</td>
<td>콜백 기반</td>
<td>Promise 기반</td>
</tr>
<tr>
<td>브라우저 지원</td>
<td>레거시 포함</td>
<td>모던 브라우저 위주</td>
</tr>
<tr>
<td>부가 기능</td>
<td>진행률 추적 등</td>
<td>상대적으로 단순</td>
</tr>
</tbody></table>
<p>→ 실무에서는 대부분 <strong>Fetch API</strong>를 권장.</p>
<hr>
<h2 id="3-react에서의-cors-처리">3. React에서의 CORS 처리</h2>
<ul>
<li>React에서는 보통 <code>Fetch API</code>나 <code>axios</code>로 API를 호출하고, <code>useEffect</code>로 컴포넌트 마운트 시점에 데이터를 불러온다</li>
<li>CORS 에러가 발생하면 <code>vite.config.js</code> 등에서 <strong>개발 서버 프록시</strong>를 설정해 우회할 수 있음</li>
<li>단, 프록시는 <strong>개발 환경 전용</strong>이며, 실서비스에서는 결국 서버에서 <code>Access-Control-Allow-Origin</code> 헤더를 제대로 설정해야 한다</li>
<li>민감한 데이터를 다루는 API일수록 CORS 허용 도메인을 신뢰할 수 있는 출처로 제한하는 것이 중요</li>
</ul>
<hr>
<h2 id="4-sql-injection--xss--csrf-실습">4. SQL Injection / XSS / CSRF 실습</h2>
<h3 id="sql-injection">SQL Injection</h3>
<p>사용자 입력을 그대로 쿼리에 끼워 넣으면, <code>&#39; OR &#39;1&#39;=&#39;1&#39;</code> 같은 입력값이 조건문을 항상 참으로 만들어버려 인증을 무력화시킬 수 있다.</p>
<ul>
<li><strong>취약한 방식</strong>: 문자열 그대로 쿼리에 삽입</li>
<li><strong>방어 방식</strong>: <strong>Prepared Statement(준비된 구문)</strong> 사용 — 쿼리 구조(<code>?</code>)와 값을 분리해서 바인딩하면, 입력값은 항상 &#39;문자열&#39;로만 취급되어 명령어로 실행되지 않는다</li>
</ul>
<h3 id="xss-cross-site-scripting">XSS (Cross-Site Scripting)</h3>
<p>악성 스크립트를 페이지에 삽입해 사용자 브라우저에서 실행시키는 공격.</p>
<ul>
<li><strong>취약한 방식</strong>: 사용자 입력을 검증 없이 그대로 화면에 렌더링</li>
<li><strong>방어 방식</strong>: <code>&lt;</code>, <code>&gt;</code>, <code>&quot;</code>, <code>&#39;</code>, <code>&amp;</code> 같은 특수문자를 HTML 엔티티로 <strong>이스케이프 처리</strong> → 코드가 아닌 순수 텍스트로 인식되게 만듦</li>
<li>React/Vue 등은 기본적으로 XSS를 방어해주지만, <code>innerHTML</code>을 직접 다룰 때는 주의 필요</li>
</ul>
<h3 id="csrf-cross-site-request-forgery">CSRF (Cross-Site Request Forgery)</h3>
<p>사용자가 의도하지 않은 요청이 자신도 모르게 실행되는 공격.</p>
<ul>
<li><strong>방어 방식</strong>: 서버가 발급한 <strong>CSRF 토큰</strong>을 요청에 포함시키고, 서버는 세션에 저장된 토큰과 일치하는지 검증</li>
<li>그 외 <code>SameSite</code> 쿠키 속성, Referer 헤더 검증, 중요 작업 시 재인증 요구도 함께 사용</li>
</ul>
<h3 id="인증세션-관리-모범-사례">인증/세션 관리 모범 사례</h3>
<ul>
<li>비밀번호는 <strong>bcrypt, Argon2</strong> 등으로 해시 + 솔트 처리</li>
<li>세션 ID는 안전하게 생성하고 적절한 만료 시간 설정, HTTPS 필수</li>
<li>JWT 사용 시 서명 검증 + 짧은 만료시간 + Refresh Token 분리</li>
<li>2FA/MFA로 인증 강화, 무차별 대입 공격 방지를 위한 계정 잠금 기능</li>
</ul>
<hr>
<h2 id="오늘의-한-줄-요약">오늘의 한 줄 요약</h2>
<blockquote>
<p>CORS는 브라우저의 보안장치일 뿐이고, 진짜 보안은 <strong>서버의 입력값 검증 + 인증/인가 + 암호화 + 지속적인 모니터링</strong>에서 나온다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] React + TypeScript로 실패 분석 앱 만들기🔍]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-%EB%A6%AC%EC%95%A1%ED%8A%B8</link>
            <guid>https://velog.io/@jhwest-dev/TIL-%EB%A6%AC%EC%95%A1%ED%8A%B8</guid>
            <pubDate>Sun, 05 Jul 2026 14:49:40 GMT</pubDate>
            <description><![CDATA[<h2 id="1-왜-만들었나-">1. 왜 만들었나 ?</h2>
<p>나는 항상 벼락치기를 한다.
시험 날이 되면 매번 후회한다. &quot;아 미리 할걸...&quot;</p>
<p>근데 이게 왜 반복되는지 궁금했다.
그냥 의지력 부족인건지, 아니면 다른 이유가 있는건지.</p>
<p>그래서 실패를 기록하고 분석하는 앱을 만들어봤다.</p>
<p>처음에는 다른 사람들과 비교하는 기능도 기획했는데 결국 빼버렸다.
남들과 경쟁하고 싶은 게 아니라 그냥 어제의 나보다 나아지고 싶었기 때문이다.</p>
<p>그래서 이 앱의 핵심은 하나다.
<strong>&quot;나는 언제, 왜 포기하는가&quot;</strong></p>
<hr>
<h2 id="2-어떤-앱인가-">2. 어떤 앱인가 ?</h2>
<p>작심삼일 수사대는 목표 달성 과정에서 <strong>실패 원인을 기록</strong>하고, <strong>AI가 탐정처럼 반복되는 습관의 패턴을 분석</strong>해주는 서비스다.</p>
<p>기존 습관 앱들은 성공을 기록하는 데 집중하는데 이 앱은 반대로 <strong>실패에 집중</strong>했다.</p>
<hr>
<h2 id="3-기술-스택">3. 기술 스택</h2>
<ul>
<li><strong>Language</strong>: TypeScript</li>
<li><strong>Framework</strong>: React</li>
<li><strong>Styling</strong>: Tailwind CSS</li>
<li><strong>State Management</strong>: Zustand</li>
<li><strong>HTTP</strong>: Axios</li>
<li><strong>차트</strong>: recharts</li>
<li><strong>달력</strong>: react-calendar</li>
<li><strong>백엔드</strong>: FastAPI + MongoDB</li>
<li><strong>AI</strong>: Gemini API</li>
<li><strong>배포</strong>: Vercel + Azure</li>
</ul>
<hr>
<h2 id="4-주요-기능">4. 주요 기능</h2>
<p><strong>1. 카카오 로그인</strong></p>
<ul>
<li>로그인은 카카오 로그인 API 를 사용했고, JWT 토큰을 이용해 사용자 인증처리를 구현했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/1e032af5-a9ec-4dac-93e3-123b6fdd5b5e/image.png" alt=""></li>
</ul>
<p><strong>2. 목표 생성 및 기록</strong></p>
<ul>
<li>목표를 생성하고 매일 성공/실패를 체크하는 기능이다.
실패하면 이유를 입력하면 Gemini API가 자동으로 카테고리를 분류해준다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/2b81f788-d4ec-40a9-9908-03564f8dcc12/image.png" alt=""></li>
</ul>
<p><strong>3. 기록 달력</strong></p>
<ul>
<li>react-calendar를 커스텀해서 날짜별로 성공/실패/혼합 동그라미를 표시했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/f700d6d0-cd77-4392-946b-aa76afb4f1e5/image.png" alt=""></li>
</ul>
<p><strong>4. 분석 대시보드</strong></p>
<ul>
<li>recharts로 요일별 실패율 바 차트와 실패 원인 도넛 차트를 구현했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/cd1c4fea-8589-4731-bb54-6512a77ad351/image.png" alt=""></li>
</ul>
<p><strong>5. AI 수사 브리핑</strong></p>
<ul>
<li><p>홈: 최근 7일 실패 기록 기반 동기부여 메시지
<img src="https://velog.velcdn.com/images/jhwest-dev/post/6fe1fad0-2a54-4522-89c9-98d1538dd85c/image.png" alt=""></p>
</li>
<li><p>분석: 요일별 실패율 + 실패 키워드 기반 인사이트
<img src="https://velog.velcdn.com/images/jhwest-dev/post/332efee7-ec48-4260-aadf-275c4ca5d09d/image.png" alt=""></p>
</li>
</ul>
<p>하루에 한 번만 생성하고 DB에 캐싱해서 API 비용을 절약했다. </p>
<p><strong>6. 다크모드 / 반응형</strong></p>
<ul>
<li>CSS 변수를 재정의하는 방식으로 다크모드를 구현했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/58c68ac9-905f-438b-a817-cd357e73edb5/image.png" alt=""></li>
</ul>
<pre><code class="language-css">.dark {
    --color-bg-primary: #1f1b18;
    --color-text-primary: #f5f1eb;
}</code></pre>
<ul>
<li>모바일에서는 하단 네비바로 전환했다.<img src="https://velog.velcdn.com/images/jhwest-dev/post/472000ea-463c-4c69-82ff-a0dd55efabd4/image.png" width="400" />


</li>
</ul>
<hr>
<h2 id="5-zustand로-전역-상태-관리">5. Zustand로 전역 상태 관리</h2>
<p>로그인 후 accessToken, nickname, theme을 어디서든 꺼내 쓸 수 있어야 했다.
Zustand를 사용해서 전역으로 관리했다.</p>
<pre><code class="language-typescript">import { create } from &quot;zustand&quot;;
import { persist } from &quot;zustand/middleware&quot;;

interface AuthStore {
    accessToken: string | null;
    nickname: string | null;
    theme: string | null;
    setAuth: (accessToken: string, nickname: string, theme: string) =&gt; void;
    clearAuth: () =&gt; void;
}

export const useAuthStore = create&lt;AuthStore&gt;()(
    persist(
        (set) =&gt; ({
            accessToken: null,
            nickname: null,
            theme: null,
            setAuth: (accessToken, nickname, theme) =&gt;
                set({ accessToken, nickname, theme }),
            clearAuth: () =&gt;
                set({ accessToken: null, nickname: null, theme: null }),
        }),
        {
            name: &quot;auth-storage&quot;, // localStorage 키 이름
        },
    ),
);
</code></pre>
<p><code>persist</code> 미들웨어를 사용해서 새로고침해도 로그인 상태가 유지되게 했다.
Redux보다 코드가 훨씬 간결해서 소규모 프로젝트에 적합했다.</p>
<hr>
<h2 id="6-react-calendar-커스텀">6. react-calendar 커스텀</h2>
<p>react-calendar 기본 스타일이 너무 밋밋해서 전부 커스텀했다.</p>
<p>Tailwind를 쓰고 있었는데 react-calendar 기본 스타일을 덮어쓰려면 별도 CSS 파일이 필요했다.</p>
<p>날짜 숫자가 <code>abbr</code> 태그로 감싸져 있어서 동그라미를 표시하려면 <code>abbr</code> 에 스타일을 적용해야 한다는 걸 한참 헤매다 알았다.</p>
<pre><code class="language-css">/* 성공한 날 → 초록 동그라미 */
.react-calendar__tile.tile-success abbr {
    display: inline-flex;
    justify-content: center;
    align-items: center;
    width: 36px;
    height: 36px;
    border-radius: 50%;
    background-color: #4caf50;
    color: white;
}

/* 실패한 날 → 빨간 동그라미 */
.react-calendar__tile.tile-fail abbr {
    background-color: #f44336;
}

/* 성공 + 실패 혼합 → 반반 동그라미 */
.react-calendar__tile.tile-partial abbr {
    background: linear-gradient(90deg, #4caf50 50%, #f44336 50%);
}</code></pre>
<p><code>tileClassName</code> prop으로 날짜마다 클래스를 붙여서 구현했다.</p>
<pre><code class="language-typescript">const tileClassName = ({ date }: { date: Date }) =&gt; {
    const status = getDateStatus(dateStr)
    if (status === &#39;all_success&#39;) return &#39;tile-success&#39;
    if (status === &#39;all_fail&#39;) return &#39;tile-fail&#39;
    if (status === &#39;partial&#39;) return &#39;tile-partial&#39;
    return null
}</code></pre>
<hr>
<h2 id="7-아직-해결하지-못한-부분">7. 아직 해결하지 못한 부분</h2>
<p><strong>날짜 처리</strong></p>
<p>DB에는 UTC로 저장하고 응답할 때 KST로 변환하는 게 표준이라고 알고 있는데
기록을 등록할 때는 KST 기준으로 날짜를 저장해야 해서 어떤 곳은 UTC, 어떤 곳은 KST로 처리하다 보니 헷갈리는 부분이 많다.</p>
<p>예를 들면 새벽 1시에 기록하면 UTC로는 전날이 되어버리는 문제가 있었다.</p>
<pre><code class="language-typescript">// KST 기준으로 오늘 날짜 계산
const getKoreaToday = () =&gt; {
    const today = new Date()
    const koreaToday = new Date(today.getTime() + 9 * 60 * 60 * 1000)
    return koreaToday.toISOString().split(&#39;T&#39;)[0]
}</code></pre>
<p>날짜 처리를 더 명확하게 정리할 필요가 있을 것 같다.</p>
<hr>
<h2 id="8-마무리">8. 마무리</h2>
<p>그동안 배운 것들을 참고해 혼자서 화면을 구현해보니 확실히 어느 정도 감을 잡게 된 것 같다. 수업 시간에 이해가 되지 않았던 부분들도 직접 부딪혀보니 훨씬 잘 이해됐다.</p>
<p>특히 Tailwind는 처음에는 불편하다고 생각했는데 직접 프로젝트에 적용해보니 훨씬 편하게 느껴졌다.</p>
<p>아직 계획한 기능을 다 구현하지 못했고, 데이터 로딩도 느리고 사용자 경험이 불편한 부분도 많다. 앞으로 찬찬히 개선해나갈 예정이다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Redux 미들웨어 + 코드 스플리팅]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-Redux-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4-%EC%BD%94%EB%93%9C-%EC%8A%A4%ED%94%8C%EB%A6%AC%ED%8C%85</link>
            <guid>https://velog.io/@jhwest-dev/TIL-Redux-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4-%EC%BD%94%EB%93%9C-%EC%8A%A4%ED%94%8C%EB%A6%AC%ED%8C%85</guid>
            <pubDate>Tue, 23 Jun 2026 07:31:49 GMT</pubDate>
            <description><![CDATA[<hr>
<h1 id="🔍-redux-미들웨어">🔍 Redux 미들웨어</h1>
<h2 id="1-redux가-필요한-이유">1. Redux가 필요한 이유</h2>
<p>React에서 state는 컴포넌트 안에 있다. 근데 여러 컴포넌트가 같은 데이터를 써야 하면?</p>
<pre><code>App
├── Header      ← 여기서도 로그인 정보 필요
├── Sidebar     ← 여기서도 로그인 정보 필요
└── Main
    └── Profile ← 여기서도 로그인 정보 필요</code></pre><p>Redux는 state를 컴포넌트 밖, <strong>Store</strong>라는 창고에 모아둔다. 모든 컴포넌트가 여기서 꺼내 쓴다.</p>
<blockquote>
<p>Store의 state는 직접 바꾸면 안 된다. 반드시 <code>dispatch → Reducer</code>를 통해서만 바꿔야 한다.</p>
</blockquote>
<pre><code>버튼 클릭 → dispatch(액션) → Reducer → 새 state → 화면 업데이트</code></pre><ul>
<li><strong>Store</strong> — state 보관 창고. 앱에 딱 하나.</li>
<li><strong>Reducer</strong> — &quot;이 액션이 오면 state를 이렇게 바꿔라&quot; 규칙서. 역할별로 여러 개 만들고 <code>combineReducers</code>로 합친다.</li>
<li><strong>dispatch</strong> — &quot;야 Reducer야, 이 액션 처리해줘!&quot; 하고 전달하는 함수.</li>
</ul>
<hr>
<h2 id="2-store는-어디에-있나">2. Store는 어디에 있나?</h2>
<p>앱 최상단에서 딱 한 번 만들어진다.</p>
<pre><code class="language-js">// index.js
const store = createStore(rootReducer, applyMiddleware(logger, thunk));

ReactDOM.render(
  &lt;Provider store={store}&gt; // 전체 앱을 감싸서 Store를 공급
    &lt;App /&gt;
  &lt;/Provider&gt;,
  document.getElementById(&#39;root&#39;)
);</code></pre>
<p><code>combineReducers</code>로 역할별 Reducer를 하나로 합친다:</p>
<pre><code class="language-js">// modules/index.js
const rootReducer = combineReducers({
  counter,  // 카운터 담당
  sample,   // post, users 담당
  loading,  // 로딩 상태 담당
});</code></pre>
<hr>
<h2 id="3-미들웨어가-필요한-이유">3. 미들웨어가 필요한 이유</h2>
<p>dispatch는 기본적으로 <strong>객체(액션)</strong> 만 받는다. 즉시 Reducer로 보내버린다.
근데 &quot;1초 뒤에 실행&quot;, &quot;API 응답 오면 실행&quot; 같은 걸 하려면 끼워넣을 공간이 없다.
미들웨어는 <code>dispatch</code>와 <code>Reducer</code> 사이에 자리를 만들어준다.</p>
<pre><code>dispatch(액션) → logger → thunk → Reducer → 새 state</code></pre><p>미들웨어는 Store를 만들 때 등록한다:</p>
<pre><code class="language-js">const store = createStore(
  rootReducer,
  applyMiddleware(logger, thunk) // 순서대로 통과
);</code></pre>
<hr>
<h2 id="4-logger-미들웨어---직접-만들어보기개념-이해용">4. logger 미들웨어 - 직접 만들어보기(개념 이해용)</h2>
<p>실무에서는 redux-logger 라이브러리를 쓰면 된다. 
여기서는 미들웨어가 내부적으로 어떻게 동작하는지 이해하기 위해 직접 만들어봤다.</p>
<p>미들웨어 구조는 함수가 3겹으로 중첩된 형태다. Redux가 store → next → action 순서로 하나씩 넘겨주는 구조에 맞춰진 것이다.</p>
<pre><code class="language-js">const loggerMiddleware = (store) =&gt; (next) =&gt; (action) =&gt; {
  // store  : Redux가 처음에 넘겨줌 (상태 조회용)
  // next   : Redux가 두 번째로 넘겨줌 (다음 미들웨어 or Reducer로 넘기는 함수)
  // action : 실제 dispatch될 때 넘겨줌
  console.log(&quot;이전 상태&quot;, store.getState()); // 아직 안 바뀜
  console.log(&quot;액션&quot;, action);
  next(action); // ← 없으면 Reducer에 도달 안 함!
  console.log(&quot;다음 상태&quot;, store.getState()); // 바뀐 후
};</code></pre>
<blockquote>
<p><code>next(action)</code>을 호출해야 다음 미들웨어 또는 Reducer로 넘어간다. 이게 없으면 액션이 거기서 멈춘다.</p>
</blockquote>
<hr>
<h2 id="5-thunk">5. Thunk</h2>
<p>dispatch가 호출될 때 thunk 미들웨어가 이걸 확인한다: &quot;넘어온 게 함수야, 객체야?&quot;</p>
<pre><code class="language-js">// thunk 내부 동작 (개념)
if (typeof action === &quot;function&quot;) {
  action(dispatch); // 함수면 → thunk가 직접 실행시켜줌
} else {
  next(action);     // 객체면 → 그냥 Reducer로 보냄
}</code></pre>
<p>평소엔 dispatch({ type: &quot;INCREASE&quot; }) 처럼 객체를 넘기면 Reducer로 직행한다.
근데 함수를 넘기면 thunk가 가로채서 실행시켜준다. 덕분에 아래와 같이 가능해진다.</p>
<pre><code class="language-js">// counter.js — increaseAsync를 풀어서 읽으면
export const increaseAsync = () =&gt; { // 1. increaseAsync() 호출하면
  return (dispatch) =&gt; {             // 2. 함수를 반환 (thunk가 이걸 실행)
    setTimeout(() =&gt; {
      dispatch(increase());          // 3. 1초 뒤에 dispatch
    }, 1000);
  }
};</code></pre>
<hr>
<h2 id="6-connect와-useeffect">6. connect와 useEffect</h2>
<p><strong>useEffect</strong> — 컴포넌트가 화면에 나타났을 때 실행할 것을 지정한다.</p>
<pre><code class="language-js">useEffect(() =&gt; {
  getPost(1);   // 화면 뜨자마자 API 호출
  getUsers(1);
}, []); // [] = 처음 화면 떴을 때 딱 한 번만 실행</code></pre>
<p>두 번째 인자에 따라 실행 시점이 달라진다:</p>
<ul>
<li><code>useEffect(() =&gt; {...})</code> — 매 렌더링마다 실행</li>
<li><code>useEffect(() =&gt; {...}, [])</code> — 처음 한 번만 실행</li>
<li><code>useEffect(() =&gt; {...}, [count])</code> — count가 바뀔 때마다 실행</li>
</ul>
<p><strong>connect</strong> — Redux Store와 컴포넌트를 연결한다. 두 가지 일을 한다:</p>
<pre><code class="language-js">export default connect(
  ({ sample, loading }) =&gt; ({
    post: sample.post,                      // Store에서 꺼내서 props로
    loadingPost: loading[&quot;sample/GET_POST&quot;],
  }),
  { getPost, getUsers }, // dispatch 연결
)(SampleContainer);</code></pre>
<hr>
<h2 id="7-전체-비동기-흐름">7. 전체 비동기 흐름</h2>
<pre><code>1. 화면 뜸
   → useEffect 실행 → getPost(1) 호출

2. dispatch(thunk 함수)
   → logger: 콘솔에 찍음
   → thunk: &quot;함수네!&quot; → 실행시켜줌
   → loading: true → 로딩 스피너 표시

3. API 호출 (axios)
   → 서버 응답 대기 중...

4. dispatch({ type: GET_POST_SUCCESS, payload: data })
   → Reducer가 state 업데이트
   → loading: false → 로딩 스피너 사라짐

5. connect가 변화 감지
   → 새 state를 props로 전달
   → 화면 리렌더링 → 데이터 표시 🎉</code></pre><p><img src="https://velog.velcdn.com/images/jhwest-dev/post/063d4201-faaf-4a6c-bd90-30879a83f1c4/image.png" alt=""><img src="https://velog.velcdn.com/images/jhwest-dev/post/8157a725-c105-493c-a5b0-93515ad44d0c/image.png" alt=""></p>
<hr>
<h1 id="🔍-코드-스플리팅">🔍 코드 스플리팅</h1>
<h3 id="왜-필요할까">왜 필요할까?</h3>
<p>React 앱을 빌드하면 모든 JS가 <strong>하나의 파일</strong>로 합쳐진다.
앱이 커질수록 사용자가 첫 접속 시 받아야 하는 파일이 무거워진다.
한 번도 방문하지 않는 페이지 코드까지 미리 다운로드하는 건 낭비다.</p>
<blockquote>
<p><strong>코드 스플리팅:</strong> 필요한 코드만 그때그때 로드해서 초기 로딩 속도를 높인다.</p>
</blockquote>
<hr>
<h3 id="1단계-정적-import-스플리팅-없음">1단계: 정적 import (스플리팅 없음)</h3>
<pre><code class="language-js">import notify from &quot;./notify&quot;; // 앱 시작할 때 무조건 다운로드</code></pre>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/e1e10337-22c4-40be-ae8d-1297f5b58a79/image.png" alt=""></p>
<p>버튼을 한 번도 안 눌러도 <code>notify.js</code>는 이미 다운로드 돼 있다. 낭비.</p>
<hr>
<h3 id="2단계-동적-import">2단계: 동적 import</h3>
<pre><code class="language-js">const onClick = () =&gt; {
  import(&quot;./notify&quot;).then((result) =&gt; result.default());
};</code></pre>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/fd0501c7-0185-4a66-96fa-7ace4de6c472/image.png" alt="">
<img src="https://velog.velcdn.com/images/jhwest-dev/post/7ab93546-1876-4662-9dc0-422902907376/image.png" alt=""></p>
<p>버튼을 <strong>클릭하는 그 순간</strong> 처음으로 <code>notify.js</code>를 다운로드한다.
<code>import()</code>는 Promise를 반환하므로 <code>.then()</code>으로 완료 후 처리한다.</p>
<hr>
<h3 id="3단계-reactlazy--suspense">3단계: React.lazy + Suspense</h3>
<p>컴포넌트를 스플리팅할 때 사용한다.</p>
<pre><code class="language-js">const SplitMe = React.lazy(() =&gt; import(&quot;./SplitMe&quot;));</code></pre>
<p><code>React.lazy</code>는 이 컴포넌트가 화면에 처음 나타날 때 그제야 다운로드한다.
다운로드가 끝나기 전까지 React는 &quot;아직 준비 안 됨&quot; 상태다. 이때 <strong>Suspense</strong>가 대신 fallback UI를 보여준다.</p>
<pre><code class="language-jsx">// 식당에 앉자마자 음식 나오기 전 물 먼저 주는 것과 같다
&lt;Suspense fallback={&lt;div&gt;loading...&lt;/div&gt;}&gt;
  {visible &amp;&amp; &lt;SplitMe /&gt;}
&lt;/Suspense&gt;</code></pre>
<p>단점: SSR 미지원, Suspense로 반드시 감싸야 함.</p>
<hr>
<h3 id="4단계-loadablecomponent">4단계: @loadable/component</h3>
<p>3단계의 단점을 보완한 라이브러리.</p>
<pre><code class="language-js">const SplitMe = loadable(() =&gt; import(&quot;./SplitMe&quot;), {
  fallback: &lt;div&gt;loading...&lt;/div&gt;,
});</code></pre>
<ul>
<li>Suspense 없이 <code>fallback</code>을 옵션으로 바로 넘김</li>
<li><strong>SSR 지원</strong></li>
<li><code>preload()</code> 기능 제공</li>
</ul>
<pre><code class="language-js">// 마우스 올리는 순간 미리 다운로드 → 클릭 시 즉각 표시
const onMouseOver = () =&gt; {
  SplitMe.preload();
};</code></pre>
<blockquote>
<p><strong>preload:</strong> 사용자가 마우스를 올리는 순간 파일을 미리 받아놓는다. 실제 클릭 시 이미 로드되어 있어 즉각 표시 → UX 향상.</p>
</blockquote>
<hr>
<h3 id="코드-스플리팅-단계-정리">코드 스플리팅 단계 정리</h3>
<table>
<thead>
<tr>
<th>단계</th>
<th>방식</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>1단계</td>
<td>정적 import</td>
<td>무조건 미리 다운로드 (낭비)</td>
</tr>
<tr>
<td>2단계</td>
<td>동적 import</td>
<td>클릭 시 다운로드, 함수/모듈용</td>
</tr>
<tr>
<td>3단계</td>
<td>React.lazy</td>
<td>클릭 시 다운로드, 컴포넌트용, Suspense 필요</td>
</tr>
<tr>
<td>4단계</td>
<td>loadable</td>
<td>SSR 지원 + preload 가능, 실무 권장</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] React Context API와 Redux]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-React-Context-API%EC%99%80-Redux</link>
            <guid>https://velog.io/@jhwest-dev/TIL-React-Context-API%EC%99%80-Redux</guid>
            <pubDate>Mon, 22 Jun 2026 07:25:52 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>오늘은 Context API와 Redux를 배웠다. 둘 다 컴포넌트 간 데이터 공유 문제를 해결하지만 목적이 다르다.
Context API는 props drilling을 피하기 위한 <strong>값 전달 메커니즘</strong>이고, Redux는 앱 전체 상태를 체계적으로 관리하는 <strong>상태 관리 라이브러리</strong>다.</p>
</blockquote>
<hr>
<h2 id="props-drilling-문제">Props Drilling 문제</h2>
<p>컴포넌트 트리가 깊어지면 중간 컴포넌트들이 해당 데이터를 실제로 사용하지 않더라도 props를 계속 전달해야 하는 <strong>Props Drilling</strong> 문제가 생긴다.</p>
<pre><code class="language-jsx">// ❌ Props Drilling
function App() {
  const [color, setColor] = useState(&#39;black&#39;);

  return &lt;Parent color={color} setColor={setColor} /&gt;;
}

function Parent({ color, setColor }) {
  // Parent는 color를 쓰지도 않는데 받아서 넘겨야 함
  return &lt;Child color={color} setColor={setColor} /&gt;;
}

function Child({ color, setColor }) {
  return &lt;div style={{ background: color }} onClick={() =&gt; setColor(&#39;red&#39;)} /&gt;;
}</code></pre>
<p>Context를 쓰면 중간 단계 없이 필요한 컴포넌트가 <strong>직접 값을 꺼내 쓸 수 있다.</strong></p>
<blockquote>
<p>Context API는 상태 관리를 해주는 게 아니다. 상태는 여전히 <code>useState</code>로 따로 관리하고, Context는 그 값을 props 없이 트리 전체에 전달해주는 통로 역할이다.</p>
</blockquote>
<hr>
<h2 id="context-api-핵심-구조">Context API 핵심 구조</h2>
<p>Context는 크게 세 단계로 나뉜다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>createContext</code></td>
<td>Context 객체 생성</td>
</tr>
<tr>
<td><code>Provider</code></td>
<td>하위 컴포넌트에 값 공급</td>
</tr>
<tr>
<td><code>useContext</code> / <code>Consumer</code></td>
<td>Provider의 값을 소비</td>
</tr>
</tbody></table>
<hr>
<h2 id="1단계--createcontext로-context-생성">1단계 — createContext로 Context 생성</h2>
<pre><code class="language-jsx">// contexts/Colors.jsx
import { createContext, useState } from &#39;react&#39;;

// createContext(기본값)
// 기본값은 Provider 없이 useContext를 사용할 때만 적용됨
// 실제로는 타입 힌트 / 자동완성 용도로 쓰는 경우가 많음
const ColorContext = createContext({
  state: { color: &#39;black&#39;, subColor: &#39;red&#39; },
  actions: {
    setColor: () =&gt; {},
    setSubColor: () =&gt; {},
  },
});</code></pre>
<blockquote>
<p><strong>state / actions 분리 패턴</strong></p>
<ul>
<li><code>state</code>: 읽기 전용 데이터 (color, subColor)</li>
<li><code>actions</code>: 상태를 변경하는 함수 (setColor, setSubColor)</li>
</ul>
<p>&quot;무엇을 보여줄지&quot;와 &quot;무엇을 바꿀 수 있는지&quot;를 분리하면 컴포넌트 역할이 명확해진다.</p>
</blockquote>
<hr>
<h2 id="2단계--provider로-상태-공급">2단계 — Provider로 상태 공급</h2>
<pre><code class="language-jsx">// contexts/Colors.jsx (이어서)

const ColorProvider = ({ children }) =&gt; {
  const [color, setColor] = useState(&#39;black&#39;);
  const [subColor, setSubColor] = useState(&#39;red&#39;);

  // createContext의 기본값 구조와 동일하게 맞춰야
  // Consumer / useContext에서 일관성 있게 사용 가능
  const value = {
    state: { color, subColor },
    actions: { setColor, setSubColor },
  };

  return (
    &lt;ColorContext.Provider value={value}&gt;
      {children}
    &lt;/ColorContext.Provider&gt;
  );
};

// Consumer는 ColorContext에 내장된 컴포넌트를 꺼내 쓰는 것
const { Consumer: ColorConsumer } = ColorContext;

export { ColorProvider, ColorConsumer };
export default ColorContext;</code></pre>
<hr>
<h2 id="3단계--app에서-provider로-감싸기">3단계 — App에서 Provider로 감싸기</h2>
<pre><code class="language-jsx">// App.jsx
import { ColorProvider } from &#39;./contexts/Colors&#39;;
import { SelectColors } from &#39;./components/SelectColors&#39;;
import { ColorBox } from &#39;./components/ColorBox&#39;;

function App() {
  return (
    // ColorProvider로 감싸면 내부의 모든 컴포넌트에서 Context 값에 접근 가능
    &lt;ColorProvider&gt;
      &lt;div&gt;
        &lt;SelectColors /&gt;
        &lt;ColorBox /&gt;
      &lt;/div&gt;
    &lt;/ColorProvider&gt;
  );
}</code></pre>
<p>Provider가 없다면 <code>SelectColors</code>와 <code>ColorBox</code>가 color를 공유하려면 App에 <code>useState</code>를 두고 props로 각각 내려줘야 한다.</p>
<hr>
<h2 id="context-값-소비하기--두-가지-방법">Context 값 소비하기 — 두 가지 방법</h2>
<h3 id="방법-1-usecontext-hook-권장-✅">방법 1: useContext Hook (권장 ✅)</h3>
<pre><code class="language-jsx">// components/ColorBox.jsx
import { useContext } from &#39;react&#39;;
import ColorContext from &#39;../contexts/Colors&#39;;

export const ColorBox = () =&gt; {
  // useContext 한 줄로 Context 값을 꺼내 일반 변수처럼 사용
  // ColorBox는 색상을 보여주기만 하므로 state만 필요
  const { state } = useContext(ColorContext);

  return (
    &lt;div&gt;
      {/* 왼쪽 클릭으로 선택한 색상 */}
      &lt;div style={{ width: &#39;64px&#39;, height: &#39;64px&#39;, background: state.color }} /&gt;
      {/* 오른쪽 클릭으로 선택한 색상 */}
      &lt;div style={{ width: &#39;32px&#39;, height: &#39;32px&#39;, background: state.subColor }} /&gt;
    &lt;/div&gt;
  );
};</code></pre>
<h3 id="방법-2-consumer--render-props-패턴-구버전">방법 2: Consumer — render props 패턴 (구버전)</h3>
<pre><code class="language-jsx">// components/SelectColors.jsx
import { ColorConsumer } from &#39;../contexts/Colors&#39;;

const colors = [&#39;red&#39;, &#39;orange&#39;, &#39;yellow&#39;, &#39;green&#39;, &#39;blue&#39;, &#39;indigo&#39;, &#39;violet&#39;];

export const SelectColors = () =&gt; {
  return (
    &lt;div&gt;
      &lt;h2&gt;색상을 선택하세요. 왼쪽 클릭 혹은 오른쪽 클릭으로&lt;/h2&gt;

      {/* render props 패턴: 자식으로 함수를 전달, 함수의 인자로 Context 값이 들어옴 */}
      &lt;ColorConsumer&gt;
        {({ actions }) =&gt; (
          &lt;div style={{ display: &#39;flex&#39; }}&gt;
            {colors.map((color) =&gt; (
              &lt;div
                key={color}
                style={{ background: color, width: &#39;24px&#39;, height: &#39;24px&#39;, cursor: &#39;pointer&#39; }}
                // 왼쪽 클릭: 큰 사각형 색상 변경
                onClick={() =&gt; actions.setColor(color)}
                // 오른쪽 클릭: 작은 사각형 색상 변경
                onContextMenu={(e) =&gt; {
                  e.preventDefault(); // 브라우저 기본 우클릭 메뉴 차단
                  actions.setSubColor(color);
                }}
              /&gt;
            ))}
          &lt;/div&gt;
        )}
      &lt;/ColorConsumer&gt;
    &lt;/div&gt;
  );
};</code></pre>
<h3 id="두-방법-비교">두 방법 비교</h3>
<table>
<thead>
<tr>
<th></th>
<th>Consumer</th>
<th>useContext</th>
</tr>
</thead>
<tbody><tr>
<td>방식</td>
<td>render props (함수를 자식으로 전달)</td>
<td>Hook</td>
</tr>
<tr>
<td>코드</td>
<td>중첩 depth 깊음, 장황</td>
<td>한 줄, 깔끔</td>
</tr>
<tr>
<td>사용 가능 컴포넌트</td>
<td>클래스 + 함수형</td>
<td>함수형 전용</td>
</tr>
<tr>
<td>권장 여부</td>
<td>레거시 코드 / 클래스 컴포넌트</td>
<td>✅ 현재 권장 방식</td>
</tr>
</tbody></table>
<hr>
<h2 id="전체-파일-구조-정리">전체 파일 구조 정리</h2>
<pre><code>src/
├── contexts/
│   └── Colors.jsx       ← createContext + Provider + Consumer 정의
├── components/
│   ├── ColorBox.jsx     ← useContext로 state 소비 (읽기)
│   └── SelectColors.jsx ← Consumer로 actions 소비 (쓰기)
└── App.jsx              ← Provider로 트리 감싸기</code></pre><hr>
<h2 id="정리">정리</h2>
<ul>
<li><strong>Props Drilling 문제</strong> → Context로 해결. 중간 컴포넌트 없이 필요한 곳에서 바로 꺼내 씀</li>
<li><strong>createContext(기본값)</strong> → 기본값은 Provider 없을 때만 적용. 타입 힌트 용도</li>
<li><strong>state / actions 분리</strong> → 읽기 데이터와 변경 함수를 나눠서 역할을 명확하게</li>
<li><strong>Provider</strong> → 최상위에서 감싸면 하위 모든 컴포넌트에서 Context 접근 가능</li>
<li><strong>useContext vs Consumer</strong> → 함수형 컴포넌트라면 useContext, 클래스 컴포넌트라면 Consumer</li>
</ul>
<blockquote>
<p>Context는 &quot;전역 상태가 필요한데 Redux는 무겁다&quot; 싶을 때 딱 좋다. 단, 자주 바뀌는 값에 쓰면 불필요한 리렌더링이 생길 수 있으니 주의!</p>
</blockquote>
<hr>
<h1 id="redux">Redux</h1>
<h2 id="redux란">Redux란?</h2>
<p>앱 전체의 상태(state)를 <strong>하나의 저장소(store)</strong> 에서 관리하는 상태 관리 라이브러리다.</p>
<h3 id="redux의-3가지-원칙">Redux의 3가지 원칙</h3>
<ol>
<li><strong>스토어는 하나</strong> — 앱 전체의 상태를 단일 스토어에서 관리</li>
<li><strong>상태는 읽기 전용</strong> — 오직 액션(Action)을 통해서만 상태 변경 가능</li>
<li><strong>변화는 순수 함수(리듀서)로만</strong> — 리듀서는 이전 state + action을 받아 새 state를 반환</li>
</ol>
<h3 id="데이터-흐름-단방향">데이터 흐름 (단방향)</h3>
<pre><code>사용자 이벤트 → dispatch(action) → Reducer → 새 state → 화면 업데이트</code></pre><hr>
<h2 id="context-api-vs-redux">Context API vs Redux</h2>
<table>
<thead>
<tr>
<th></th>
<th>Context API</th>
<th>Redux</th>
</tr>
</thead>
<tbody><tr>
<td>성격</td>
<td><strong>값 전달 메커니즘</strong></td>
<td><strong>상태 관리 라이브러리</strong></td>
</tr>
<tr>
<td>설치</td>
<td>React 내장</td>
<td>별도 라이브러리 필요</td>
</tr>
<tr>
<td>목적</td>
<td>Props Drilling 해결</td>
<td>체계적인 전역 상태 관리</td>
</tr>
<tr>
<td>상태 관리</td>
<td><code>useState</code> / <code>useReducer</code>로 따로 관리</td>
<td>Reducer + Store에서 통합 관리</td>
</tr>
<tr>
<td>상태 변경 로직</td>
<td>컴포넌트/파일에 분산</td>
<td>Reducer 한 곳에 집중</td>
</tr>
<tr>
<td>디버깅</td>
<td>추적 어려움</td>
<td>Redux DevTools로 흐름 추적 가능</td>
</tr>
<tr>
<td>Time Travel</td>
<td>불가</td>
<td>과거 state로 되돌아가 재현 가능</td>
</tr>
<tr>
<td>적합 규모</td>
<td>중소 규모</td>
<td>대규모</td>
</tr>
</tbody></table>
<hr>
<h2 id="redux-핵심-개념">Redux 핵심 개념</h2>
<h3 id="action--무슨-일이-일어났는가">Action — 무슨 일이 일어났는가</h3>
<p>상태를 어떻게 바꿀지 설명하는 객체. <code>type</code> 필드는 필수다.</p>
<pre><code class="language-jsx">// 액션 타입 상수 — 오타 방지 + 자동완성을 위해 상수로 관리
const INCREASE = &#39;counter/INCREASE&#39;;
const DECREASE = &#39;counter/DECREASE&#39;;

// 액션 생성자 — dispatch에 넘길 액션 객체를 만들어주는 함수
export const increase = () =&gt; ({ type: INCREASE });
export const decrease = () =&gt; ({ type: DECREASE });</code></pre>
<h3 id="reducer--새-state를-반환하는-순수-함수">Reducer — 새 state를 반환하는 순수 함수</h3>
<p>현재 state와 action을 받아 새로운 state를 반환한다. 직접 state를 수정하면 안 되고, 반드시 새 객체를 반환해야 한다 (불변성 유지).</p>
<pre><code class="language-jsx">// 기본 switch/case 패턴
function counter(state = initialState, action) {
  switch (action.type) {
    case INCREASE:
      return { number: state.number + 1 };
    case DECREASE:
      return { number: state.number - 1 };
    default:
      return state; // 해당 없는 액션은 state 그대로 반환
  }
}</code></pre>
<p><code>handleActions</code>를 쓰면 switch/case 없이 더 깔끔하게 작성할 수 있다.</p>
<pre><code class="language-jsx">import { createAction, handleActions } from &#39;redux-actions&#39;;

export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);

// handleActions(핸들러맵, initialState)
const counter = handleActions(
  {
    [INCREASE]: (state, action) =&gt; ({ number: state.number + 1 }),
    [DECREASE]: (state, action) =&gt; ({ number: state.number - 1 }),
  },
  initialState,
);</code></pre>
<hr>
<h2 id="스토어-설정">스토어 설정</h2>
<h3 id="combinereducers--여러-리듀서를-하나로-합치기">combineReducers — 여러 리듀서를 하나로 합치기</h3>
<p>Redux 스토어는 리듀서를 하나만 받는다. 기능이 많아지면 리듀서도 여러 개로 나뉘는데, <code>combineReducers</code>로 하나로 합쳐준다.</p>
<pre><code class="language-jsx">// modules/index.jsx
import { combineReducers } from &#39;redux&#39;;
import counter from &#39;./counter&#39;;
import todos from &#39;./todos&#39;;

// 객체 키 이름이 state의 슬라이스 이름이 됨
// state.counter, state.todos 로 접근 가능
const rootReducer = combineReducers({
  counter,
  todos,
});

export default rootReducer;</code></pre>
<h3 id="configurestore--provider로-앱-감싸기">configureStore + Provider로 앱 감싸기</h3>
<pre><code class="language-jsx">// main.jsx
import { configureStore } from &#39;@reduxjs/toolkit&#39;;
import { Provider } from &#39;react-redux&#39;;
import rootReducer from &#39;./modules/index&#39;;

const store = configureStore({ reducer: rootReducer });

createRoot(document.getElementById(&#39;root&#39;)).render(
  &lt;Provider store={store}&gt;
    &lt;App /&gt;
  &lt;/Provider&gt;
);</code></pre>
<p><code>Provider</code>로 감싸면 하위 모든 컴포넌트에서 <code>useSelector</code> / <code>useDispatch</code>로 스토어에 접근할 수 있다.</p>
<blockquote>
<p><strong>Redux DevTools</strong>
<code>@redux-devtools/extension</code>을 연결하고 크롬 확장 &quot;Redux DevTools&quot;를 설치하면,
액션이 dispatch될 때마다 어떤 액션이 발생했고 state가 어떻게 바뀌었는지 실시간으로 확인할 수 있다.
&quot;Time Travel Debugging&quot;으로 과거 state로 되돌아가 버그를 재현할 수도 있다.</p>
</blockquote>
<hr>
<h2 id="컴포넌트에서-redux-사용하기">컴포넌트에서 Redux 사용하기</h2>
<h3 id="ui-컴포넌트-vs-컨테이너-컴포넌트-분리-원칙">UI 컴포넌트 vs 컨테이너 컴포넌트 분리 원칙</h3>
<p>Redux를 쓸 때는 두 가지 역할을 분리하는 게 관례다.</p>
<table>
<thead>
<tr>
<th></th>
<th>UI 컴포넌트</th>
<th>컨테이너 컴포넌트</th>
</tr>
</thead>
<tbody><tr>
<td>역할</td>
<td>화면 렌더링</td>
<td>Redux 연결</td>
</tr>
<tr>
<td>Redux 의존</td>
<td>❌ import 안 함</td>
<td>✅ useSelector, useDispatch 사용</td>
</tr>
<tr>
<td>재사용성</td>
<td>높음</td>
<td>낮음</td>
</tr>
<tr>
<td>예시</td>
<td><code>Counter.jsx</code></td>
<td><code>CounterContainer.jsx</code></td>
</tr>
</tbody></table>
<pre><code class="language-jsx">// components/Counter.jsx — UI 컴포넌트 (Redux 모름)
export const Counter = ({ number, onIncrease, onDecrease }) =&gt; {
  return (
    &lt;div&gt;
      &lt;h1&gt;{number}&lt;/h1&gt;
      &lt;button onClick={onIncrease}&gt;1 더하기&lt;/button&gt;
      &lt;button onClick={onDecrease}&gt;1 빼기&lt;/button&gt;
    &lt;/div&gt;
  );
};</code></pre>
<pre><code class="language-jsx">// containers/CounterContainer.jsx — 컨테이너 (Redux 연결 담당)
import { useSelector, useDispatch } from &#39;react-redux&#39;;
import { useCallback } from &#39;react&#39;;
import { increase, decrease } from &#39;../modules/counter&#39;;
import { Counter } from &#39;../components/Counter&#39;;

export const CounterContainer = () =&gt; {
  // useSelector: 스토어의 state에서 필요한 값만 선택
  const number = useSelector((state) =&gt; state.counter.number);

  // useDispatch: 액션을 스토어에 전달하는 dispatch 함수 반환
  const dispatch = useDispatch();

  const onIncrease = useCallback(() =&gt; dispatch(increase()), [dispatch]);
  const onDecrease = useCallback(() =&gt; dispatch(decrease()), [dispatch]);

  return (
    &lt;Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease} /&gt;
  );
};</code></pre>
<hr>
<h2 id="immer로-불변성-쉽게-관리하기">immer로 불변성 쉽게 관리하기</h2>
<p>배열/객체가 중첩된 복잡한 state를 다룰 때, 불변성을 직접 지키면 코드가 길고 복잡해진다. <code>immer</code>의 <code>produce</code>를 쓰면 마치 직접 수정하는 것처럼 코드를 작성해도 내부적으로 불변성을 지켜준다.</p>
<pre><code class="language-jsx">// modules/todos.jsx
import { createAction, handleActions } from &#39;redux-actions&#39;;
import { produce } from &#39;immer&#39;;

const CHANGE_INPUT = &#39;todos/CHANGE_INPUT&#39;;
const INSERT     = &#39;todos/INSERT&#39;;
const TOGGLE     = &#39;todos/TOGGLE&#39;;
const REMOVE     = &#39;todos/REMOVE&#39;;

export const changeInput = createAction(CHANGE_INPUT, (input) =&gt; input);
export const insert      = createAction(INSERT, (text) =&gt; ({ id: id++, text, done: false }));
export const toggle      = createAction(TOGGLE, (id) =&gt; id);
export const remove      = createAction(REMOVE, (id) =&gt; id);

let id = 3;

const initialState = {
  input: &#39;&#39;,
  todos: [
    { id: 1, text: &#39;리덕스 기초 배우기&#39;, done: false },
    { id: 2, text: &#39;리액트와 리덕스 사용하기&#39;, done: true },
  ],
};

const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, { payload: input }) =&gt;
      produce(state, (draft) =&gt; { draft.input = input; }),

    [INSERT]: (state, { payload: todo }) =&gt;
      produce(state, (draft) =&gt; { draft.todos.push(todo); }),

    [TOGGLE]: (state, { payload: id }) =&gt;
      produce(state, (draft) =&gt; {
        const todo = draft.todos.find((t) =&gt; t.id === id);
        todo.done = !todo.done;
      }),

    [REMOVE]: (state, { payload: id }) =&gt;
      produce(state, (draft) =&gt; {
        const index = draft.todos.findIndex((t) =&gt; t.id === id);
        draft.todos.splice(index, 1);
      }),
  },
  initialState,
);

export default todos;</code></pre>
<hr>
<h2 id="useactions-커스텀-훅--dispatch-연결-자동화">useActions 커스텀 훅 — dispatch 연결 자동화</h2>
<p>액션 생성자가 많아지면 <code>useCallback</code>을 여러 번 반복해야 한다. <code>bindActionCreators</code>와 커스텀 훅으로 한 번에 묶을 수 있다.</p>
<pre><code class="language-jsx">// lib/useActions.js
import { bindActionCreators } from &#39;redux&#39;;
import { useDispatch } from &#39;react-redux&#39;;
import { useMemo } from &#39;react&#39;;

// bindActionCreators: 액션 생성자를 dispatch와 묶어주는 redux 내장 함수
// 호출 시 자동으로 dispatch(actionCreator(...))가 실행되는 함수가 만들어짐
export default function useActions(actions, deps) {
  const dispatch = useDispatch();
  return useMemo(
    () =&gt; {
      if (Array.isArray(actions)) {
        return actions.map((a) =&gt; bindActionCreators(a, dispatch));
      }
      return bindActionCreators(actions, dispatch);
    },
    deps ? [dispatch, ...deps] : deps,
  );
}</code></pre>
<pre><code class="language-jsx">// containers/TodosContainer.jsx
import { useSelector } from &#39;react-redux&#39;;
import { changeInput, insert, toggle, remove } from &#39;../modules/todos&#39;;
import useActions from &#39;../lib/useActions&#39;;
import { Todos } from &#39;../components/Todos&#39;;

export const TodosContainer = () =&gt; {
  const { input, todos } = useSelector(({ todos }) =&gt; ({
    input: todos.input,
    todos: todos.todos,
  }));

  // useActions 덕분에 useCallback 4번 쓸 필요 없이 한 줄로 해결
  const [onChangeInput, onInsert, onToggle, onRemove] = useActions(
    [changeInput, insert, toggle, remove],
    [],
  );

  return (
    &lt;Todos
      input={input}
      todos={todos}
      onChangeInput={onChangeInput}
      onInsert={onInsert}
      onToggle={onToggle}
      onRemove={onRemove}
    /&gt;
  );
};</code></pre>
<hr>
<h2 id="전체-파일-구조-정리-1">전체 파일 구조 정리</h2>
<pre><code>src/
├── modules/
│   ├── index.jsx     ← combineReducers로 루트 리듀서 생성
│   ├── counter.jsx   ← 카운터 액션 타입 / 액션 생성자 / 리듀서
│   └── todos.jsx     ← 할 일 목록 액션 타입 / 액션 생성자 / 리듀서
├── containers/
│   ├── CounterContainer.jsx  ← Redux 연결 (useSelector, useDispatch)
│   └── TodosContainer.jsx    ← Redux 연결 + useActions 커스텀 훅
├── components/
│   ├── Counter.jsx   ← UI 컴포넌트 (Redux 모름)
│   └── Todos.jsx     ← UI 컴포넌트 (Redux 모름)
└── main.jsx          ← configureStore + Provider로 앱 감싸기</code></pre><hr>
<h2 id="정리-1">정리</h2>
<ul>
<li><strong>Action</strong> → 상태 변경을 설명하는 객체. <code>type</code> 필드 필수</li>
<li><strong>Reducer</strong> → <code>(state, action) =&gt; newState</code>. 반드시 새 객체 반환 (불변성)</li>
<li><strong>Store</strong> → 앱 전체에 하나. <code>configureStore</code>로 생성, <code>Provider</code>로 공급</li>
<li><strong>combineReducers</strong> → 여러 리듀서를 하나로 합쳐 스토어에 전달</li>
<li><strong>useSelector</strong> → 스토어에서 필요한 state만 선택</li>
<li><strong>useDispatch</strong> → 액션을 스토어에 전달하는 dispatch 함수</li>
<li><strong>handleActions</strong> → switch/case 없이 리듀서를 깔끔하게 작성</li>
<li><strong>immer</strong> → 복잡한 중첩 state도 직접 수정하듯 불변성 유지</li>
<li><strong>bindActionCreators</strong> → 액션 생성자와 dispatch를 자동으로 묶기</li>
<li><strong>UI / 컨테이너 분리</strong> → UI 컴포넌트는 Redux를 몰라야 재사용성이 높아짐</li>
</ul>
<blockquote>
<p>Redux는 처음엔 개념이 많아서 복잡해 보이지만, &quot;액션으로만 상태를 바꾼다&quot;는 원칙 덕분에 코드가 커져도 흐름을 추적하기 쉽다. DevTools의 Time Travel이 생각보다 꽤 강력하다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] React Todo 앱 만들기 (1)]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-React-Todo-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-1-qwesbezs</link>
            <guid>https://velog.io/@jhwest-dev/TIL-React-Todo-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-1-qwesbezs</guid>
            <pubDate>Thu, 18 Jun 2026 08:44:48 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/b9d2590a-0675-435b-93bf-eaac1112ff49/image.png" alt=""></p>
<p>오늘 수업에서는 React 개념들을 총동원한 Todo 앱을 만들어봤다. useState보다 복잡한 상태 관리를 위해 useReducer + Immer를 쓰고, React Router로 다중 페이지도 구성했다.</p>
<hr>
<h2 id="목차">목차</h2>
<ul>
<li>Action Type 상수</li>
<li>useReducer + Immer</li>
<li>useCallback</li>
<li>useMemo</li>
<li>props를 객체로 묶어서 스프레드로 전달하는 방식</li>
<li>BrowserRouter &amp; 동적 라우팅</li>
<li>Todo 앱 만들기</li>
</ul>
<hr>
<h2 id="action-type-상수">Action Type 상수</h2>
<pre><code class="language-js">const ADD_TODO = &quot;ADD_TODO&quot;;
const TOGGLE_TODO = &quot;TOGGLE_TODO&quot;;
const DELETE_TODO = &quot;DELETE_TODO&quot;;</code></pre>
<p>dispatch를 쓸 때 <code>&#39;ADD_TODO&#39;</code>처럼 문자열을 직접 넣으면 오타가 나도 에러가 안 뜨고 그냥 조용히 동작을 안 한다.
상수로 빼두면 오타가 났을 때 <code>&#39;ADD_TODO is not defined&#39;</code>처럼 에러가 바로 나서 어디가 잘못됐는지 바로 알 수 있다.
reducer 쪽이랑 dispatch 호출하는 쪽이 같은 상수를 쓰니까 일관성도 생긴다.</p>
<hr>
<h2 id="usereducer--immer">useReducer + Immer</h2>
<pre><code class="language-js">import { produce } from &quot;immer&quot;;

const todoReducer = (state, action) =&gt; {
    return produce(state, (draft) =&gt; {
        switch (action.type) {
            case ADD_TODO:
                draft.todos.push({
                    id: Date.now(),
                    text: action.payload.text,
                    completed: false,
                    priority: action.payload.priority || &quot;medium&quot;,
                    category: action.payload.category || &quot;기타&quot;,
                });
                break;

            case TOGGLE_TODO: {
                const todo = draft.todos.find((t) =&gt; t.id == action.payload);
                if (todo) todo.completed = !todo.completed;
                break;
            }

            case DELETE_TODO:
                draft.todos = draft.todos.filter((t) =&gt; t.id !== action.payload);
                break;
        }
    });
};</code></pre>
<p><code>useReducer</code>는 상태를 바꾸는 로직을 한 군데 모아두는 방식이다. 상태가 복잡해질수록 useState보다 훨씬 관리하기 편하다.</p>
<p>여기에 Immer를 같이 쓰면 <code>draft</code>를 직접 수정하듯이 코드를 쓸 수 있다.
실제로는 원본 state를 건드리는 게 아니라 Immer가 알아서 새 상태를 만들어주는 거라 불변성은 지켜진다.</p>
<table>
<thead>
<tr>
<th></th>
<th>기존 방식</th>
<th>Immer</th>
</tr>
</thead>
<tbody><tr>
<td>배열 추가</td>
<td><code>[...state.todos, newTodo]</code></td>
<td><code>draft.todos.push(newTodo)</code></td>
</tr>
<tr>
<td>중첩 수정</td>
<td>spread를 여러 번 중첩</td>
<td><code>draft.a.b.c = value</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="usecallback">useCallback</h2>
<pre><code class="language-js">const addTodo = useCallback((todo) =&gt; {
    dispatch({ type: ADD_TODO, payload: todo });
}, []);

const toggleTodo = useCallback((id) =&gt; {
    dispatch({ type: TOGGLE_TODO, payload: id });
}, []);</code></pre>
<p><code>useCallback</code>은 함수를 메모이제이션해주는 훅이다.
<code>dispatch</code>는 useReducer가 항상 같은 참조를 보장해주기 때문에 의존성 배열을 <code>[]</code>로 써도 된다.
덕분에 이 함수들은 컴포넌트가 처음 렌더링될 때 딱 한 번만 만들어지고,
자식 컴포넌트에 넘겨도 참조가 바뀌지 않아서 불필요한 리렌더링을 줄일 수 있다.</p>
<hr>
<h2 id="usememo">useMemo</h2>
<pre><code class="language-js">const filteredTodos = useMemo(() =&gt; {
    return state.todos.filter((todo) =&gt; {
        const matchFilter =
            state.filter === &quot;all&quot; ||
            (state.filter === &quot;active&quot; &amp;&amp; !todo.completed) ||
            (state.filter === &quot;completed&quot; &amp;&amp; todo.completed);

        const matchesSearch = todo.text
            .toLowerCase()
            .includes(state.searchQuery.toLowerCase());

        return matchFilter &amp;&amp; matchesSearch;
    });
}, [state.todos, state.filter, state.searchQuery]);</code></pre>
<p>필터링을 Todos 페이지 안에서 하면 관련 없는 이유로 리렌더링이 일어날 때도 매번 다시 계산한다.
App에서 <code>useMemo</code>로 미리 계산해두면 <code>todos</code>, <code>filter</code>, <code>searchQuery</code> 이 세 값 중 하나라도 바뀔 때만 다시 계산하고, 그 외엔 이전 결과를 그대로 쓴다.</p>
<hr>
<h2 id="props를-객체로-묶어서-스프레드로-전달하는-방식">props를 객체로 묶어서 스프레드로 전달하는 방식</h2>
<pre><code class="language-js">const todoProps = {
    todos: state.todos,
    filteredTodos,
    filter: state.filter,
    searchQuery: state.searchQuery,
    addTodo,
    toggleTodo,
    deleteTodo,
    updateTodo,
    setFilter,
    setSearchQuery,
    clearCompleted,
};

// 사용할 때
&lt;Todos {...todoProps} /&gt;</code></pre>
<p>자식한테 넘길 props가 많아지면 하나씩 쓰는 게 너무 길어진다.
객체로 묶어서 스프레드로 한 번에 넘기면 코드가 훨씬 깔끔해진다.
물론 props가 몇 개 안 된다면 그냥 직접 넘기는 게 낫다.</p>
<hr>
<h2 id="browserrouter--동적-라우팅">BrowserRouter &amp; 동적 라우팅</h2>
<pre><code class="language-jsx">// App.jsx
&lt;BrowserRouter&gt;
    &lt;Navigation /&gt;
    &lt;Routes&gt;
        &lt;Route path=&quot;/&quot; element={&lt;Home todos={state.todos} /&gt;} /&gt;
        &lt;Route path=&quot;/todos&quot; element={&lt;Todos {...todoProps} /&gt;} /&gt;
        &lt;Route path=&quot;/statistics&quot; element={&lt;Statistics todos={state.todos} /&gt;} /&gt;
        &lt;Route path=&quot;/posts&quot; element={&lt;PostList posts={POSTS} /&gt;} /&gt;
        &lt;Route path=&quot;/posts/:id&quot; element={&lt;PostDetail posts={POSTS} /&gt;} /&gt;
    &lt;/Routes&gt;
&lt;/BrowserRouter&gt;</code></pre>
<p><code>BrowserRouter</code>로 감싸면 URL이 바뀔 때 페이지 새로고침 없이 해당 컴포넌트로 전환된다.
보통 <code>main.jsx</code>에서 쓰는데 <code>App.jsx</code>에서 써도 된다.</p>
<p><code>/posts/:id</code>처럼 <code>:id</code> 부분을 동적으로 처리하면 게시글마다 다른 URL을 가질 수 있다.
해당 페이지에서는 <code>useParams()</code>로 URL에 있는 값을 꺼내 쓴다.</p>
<pre><code class="language-js">// PostDetail.jsx
import { useParams } from &quot;react-router-dom&quot;;

const { id } = useParams(); // URL의 :id 값
const post = posts.find((p) =&gt; p.id === parseInt(id)); // URL 파라미터는 문자열이라 parseInt 필요</code></pre>
<hr>
<h2 id="todo-앱-만들기">Todo 앱 만들기</h2>
<h3 id="전체-라우팅-구조">전체 라우팅 구조</h3>
<pre><code>/             -&gt; Home       (요약 통계 + 이동 버튼)
/todos        -&gt; Todos      (할 일 CRUD + 필터/검색)
/statistics   -&gt; Statistics (우선순위/카테고리별 통계)
/posts        -&gt; PostList   (게시글 목록)
/posts/:id    -&gt; PostDetail (동적 라우팅으로 상세 보기)</code></pre><h3 id="폴더-구조">폴더 구조</h3>
<pre><code>src/
├── components/
│   ├── Navigation.jsx   # 상단 공통 네비게이션
│   ├── TodoForm.jsx     # 할 일 입력 폼
│   ├── TodoList.jsx     # 할 일 목록 렌더링
│   ├── TodoItem.jsx     # 개별 할 일 항목 (수정/삭제/토글)
│   └── FilterBar.jsx    # 필터 버튼 + 검색창
├── pages/
│   ├── Home.jsx
│   ├── Todos.jsx
│   ├── Statistics.jsx
│   ├── PostList.jsx
│   └── PostDetail.jsx
└── App.jsx</code></pre><h3 id="주요-코드-설명">주요 코드 설명</h3>
<p><strong>App.jsx — 상태 관리 허브</strong></p>
<p><code>todos</code>, <code>filter</code>, <code>searchQuery</code> 전체 상태를 useReducer 하나로 관리한다.
useCallback으로 만든 함수들과 useMemo로 계산한 filteredTodos를 todoProps로 묶어서 Todos 페이지에 넘겨준다.</p>
<pre><code class="language-jsx">const [state, dispatch] = useReducer(todoReducer, initialState);

const todoProps = {
    todos: state.todos,
    filteredTodos,
    filter: state.filter,
    // ...
};</code></pre>
<p><strong>TodoForm.jsx — 입력 폼</strong></p>
<pre><code class="language-jsx">export const TodoForm = memo(({ addTodo }) =&gt; {
    const [text, setText] = useState(&quot;&quot;);
    const [priority, setPriority] = useState(&quot;medium&quot;);
    const [category, setCategory] = useState(&quot;기타&quot;);

    const handleSubmit = useCallback(
        (e) =&gt; {
            e.preventDefault();
            if (text.trim()) {
                addTodo({ text: text.trim(), priority, category });
                setText(&quot;&quot;);
                setPriority(&quot;medium&quot;);
                setCategory(&quot;기타&quot;);
            }
        },
        [text, priority, category, addTodo],
    );
    // ...
});</code></pre>
<p><code>React.memo</code>로 감싸서 addTodo 참조가 바뀌지 않는 한 리렌더링되지 않게 했다.
입력값은 굳이 App까지 올릴 필요가 없어서 로컬 useState로 관리한다.</p>
<p><strong>TodoItem.jsx — 수정 모드 전환</strong></p>
<pre><code class="language-jsx">const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);

const handleSave = () =&gt; {
    if (editText.trim()) {
        updateTodo(todo.id, { text: editText });
        setIsEditing(false);
    }
};

const handleCancel = () =&gt; {
    setEditText(todo.text); // 원본으로 되돌리기
    setIsEditing(false);
};</code></pre>
<p><code>isEditing</code>으로 보기 모드와 수정 모드를 전환한다.
취소를 누르면 <code>todo.text</code>(원본 텍스트)로 다시 세팅해서 수정 전 상태로 돌아간다.</p>
<p><strong>TodoList.jsx — 이중 메모이제이션</strong></p>
<pre><code class="language-jsx">export const TodoList = memo(({ filteredTodos, toggleTodo, deleteTodo, updateTodo }) =&gt; {
    const memoizedTodos = useMemo(() =&gt; {
        return filteredTodos.map((todo) =&gt; (
            &lt;TodoItem key={todo.id} todo={todo} ... /&gt;
        ));
    }, [filteredTodos, toggleTodo, deleteTodo, updateTodo]);

    // ...
});</code></pre>
<ul>
<li><code>memo</code> : TodoList 자체가 리렌더링될 필요가 있는지 확인</li>
<li><code>useMemo</code> : TodoItem 배열을 새로 만들 필요가 있는지 확인</li>
</ul>
<p><strong>Navigation.jsx — displayName</strong></p>
<pre><code class="language-jsx">export const Navigation = memo(() =&gt; {
    // ...
});

Navigation.displayName = &quot;Navigation&quot;;</code></pre>
<p><code>memo()</code>로 감싸면 React DevTools에서 컴포넌트 이름이 <code>&quot;memo(Anonymous)&quot;</code>로 뜬다.
<code>displayName</code>을 지정해두면 DevTools에서 제대로 된 이름으로 보여서 디버깅할 때 편하다.</p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>개념</th>
<th>핵심 요약</th>
</tr>
</thead>
<tbody><tr>
<td>Action Type 상수</td>
<td>오타를 즉시 에러로 잡기 위한 문자열 상수</td>
</tr>
<tr>
<td>useReducer + Immer</td>
<td>복잡한 상태 관리, draft를 직접 수정하듯 쓸 수 있음</td>
</tr>
<tr>
<td>useCallback</td>
<td>함수를 메모이제이션해서 불필요한 리렌더링 방지</td>
</tr>
<tr>
<td>useMemo</td>
<td>관련 상태가 바뀔 때만 재계산, 그 외엔 캐싱된 값 사용</td>
</tr>
<tr>
<td>todoProps 패턴</td>
<td>여러 props를 객체로 묶어 스프레드로 한 번에 전달</td>
</tr>
<tr>
<td>BrowserRouter</td>
<td>페이지 새로고침 없이 라우팅, <code>/posts/:id</code>로 동적 라우팅</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 리액트 컴포넌트 예제 : 가위바위보 & 숫자 맞추기 게임]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%98%88%EC%A0%9C-%EA%B0%80%EC%9C%84%EB%B0%94%EC%9C%84%EB%B3%B4-%EC%88%AB%EC%9E%90-%EB%A7%9E%EC%B6%94%EA%B8%B0-%EA%B2%8C%EC%9E%84</link>
            <guid>https://velog.io/@jhwest-dev/TIL-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%98%88%EC%A0%9C-%EA%B0%80%EC%9C%84%EB%B0%94%EC%9C%84%EB%B3%B4-%EC%88%AB%EC%9E%90-%EB%A7%9E%EC%B6%94%EA%B8%B0-%EA%B2%8C%EC%9E%84</guid>
            <pubDate>Tue, 16 Jun 2026 15:45:27 GMT</pubDate>
            <description><![CDATA[<p>오늘 수업에서 React 컴포넌트와 useState를 사용해 가위바위보 게임을 만들어봤다. 복습도 할 겸 해당 코드를 응용해서 숫자 맞추기 게임을 직접 만들어봤는데, 구조는 비슷하지만 로직을 새로 짜면서 확실히 더 이해가 된 것 같다.</p>
<hr>
<h2 id="가위바위보">가위바위보</h2>
<h3 id="컴포넌트-분리">컴포넌트 분리</h3>
<p>UI를 역할별로 나눠서 관리했다. <code>App.jsx</code>가 전체 게임 상태를 관리하고, <code>Button</code>과 <code>Card</code>는 각각 버튼과 카드 UI만 담당했다.</p>
<h3 id="usestate로-상태-관리">useState로 상태 관리</h3>
<p>게임에 필요한 상태들을 <code>useState</code>로 관리했다.</p>
<pre><code class="language-jsx">const [userChoice, setUserChoice] = useState(null);
const [computerChoice, setComputerChoice] = useState(null);
const [result, setResult] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);</code></pre>
<h3 id="props로-데이터-전달">props로 데이터 전달</h3>
<p>부모 컴포넌트(<code>App</code>)에서 자식 컴포넌트(<code>Card</code>, <code>Button</code>)로 props를 통해 데이터를 전달했다.</p>
<pre><code class="language-jsx">&lt;Card userTitle=&quot;유저&quot; choice={userChoice} result={result} type=&quot;user&quot; /&gt;</code></pre>
<h3 id="css-module">CSS Module</h3>
<p>CSS Module을 사용해 클래스명이 겹치지 않도록 스타일을 컴포넌트별로 분리했다.</p>
<pre><code class="language-jsx">import css from &quot;./css/App.module.css&quot;;

&lt;div className={css.container}&gt;</code></pre>
<hr>
<h2 id="숫자-맞추기-게임-만들기">숫자 맞추기 게임 만들기</h2>
<p>가위바위보 코드를 응용해서 1~10 사이의 숫자를 맞추는 게임을 만들었다. 컴퓨터가 랜덤으로 숫자를 정해두고, 유저가 버튼을 클릭해서 맞추는 방식이다. 힌트로 &quot;더 높아요 ⬆️ / 더 낮아요 ⬇️&quot;를 보여주고, 시도 횟수와 정답률도 표시된다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/753726eb-8152-4f58-b57f-d30e2587d635/image.png" alt=""></p>
<hr>
<h2 id="폴더-구조">폴더 구조</h2>
<pre><code>src/
├── components/
│   ├── NumberButtons.jsx   # 숫자 버튼
│   ├── ResultMessage.jsx   # 결과 메시지
│   └── ScoreBoard.jsx      # 점수판
├── css/
│   ├── App.module.css
│   ├── NumberButtons.module.css
│   └── ScoreBoard.module.css
└── App.jsx</code></pre><hr>
<h2 id="주요-코드-설명">주요 코드 설명</h2>
<h3 id="appjsx--게임-상태-관리">App.jsx — 게임 상태 관리</h3>
<pre><code class="language-jsx">const [userChoice, setUserChoice] = useState(null);
const [computerChoice, setComputerChoice] = useState(
    Math.floor(Math.random() * 10) + 1
);
const [result, setResult] = useState(null);
const [count, setCount] = useState(0);</code></pre>
<p>가위바위보와 다르게 컴퓨터 선택을 게임 시작 시 바로 정해두고, 유저가 맞출 때까지 유지한다.</p>
<h3 id="결과-판단-로직">결과 판단 로직</h3>
<pre><code class="language-jsx">const numberChoiceResult = (user, computer) =&gt; {
    if (user === computer) return &quot;정답이에요 🎉&quot;;
    if (user &gt; computer) return &quot;더 낮아요 ⬇️&quot;;
    return &quot;더 높아요 ⬆️&quot;;
};</code></pre>
<p>가위바위보의 <code>determineWinner</code> 함수를 응용해서 숫자 비교 로직으로 바꿨다.</p>
<h3 id="정답률-계산">정답률 계산</h3>
<pre><code class="language-jsx">const correctRate =
    result === &quot;정답이에요 🎉&quot; ? Math.round((1 / count) * 100) + &quot; %&quot; : &quot;-&quot;;</code></pre>
<p>정답을 맞췄을 때만 정답률을 계산한다. 1번에 맞추면 100%, 5번에 맞추면 20%.</p>
<h3 id="resultmessagejsx--결과-메시지-컴포넌트">ResultMessage.jsx — 결과 메시지 컴포넌트</h3>
<pre><code class="language-jsx">export const ResultMessage = ({ result, choice }) =&gt; {
    let resultSubMessage = &quot;버튼을 클릭해 보세요!&quot;;
    if (result === &quot;정답이에요 🎉&quot;) resultSubMessage = &quot;게임을 다시 시작해 보세요!&quot;;
    if (result === &quot;더 높아요 ⬆️&quot;) resultSubMessage = &quot;보다 높은 숫자를 골라보세요.&quot;;
    if (result === &quot;더 낮아요 ⬇️&quot;) resultSubMessage = &quot;보다 낮은 숫자를 골라보세요.&quot;;

    return (
        &lt;div&gt;
            &lt;h3&gt;{result}&lt;/h3&gt;
            &lt;p&gt;{result === &quot;정답이에요 🎉&quot; ? resultSubMessage : `${choice || &quot;&quot;} ${resultSubMessage}`}&lt;/p&gt;
        &lt;/div&gt;
    );
};</code></pre>
<p>결과에 따라 다른 안내 메시지를 보여준다. 정답이 아닐 때는 유저가 선택한 숫자도 함께 표시된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 리액트 시작하기]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/TIL-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 16 Jun 2026 07:22:35 GMT</pubDate>
            <description><![CDATA[<h2 id="react란">React란?</h2>
<p>React는 <strong>Facebook(현 Meta)</strong> 이 2013년에 오픈소스로 공개한 <strong>UI 구축을 위한 JavaScript 라이브러리</strong>다.
지속적으로 데이터가 변화하는 대규모 애플리케이션을 효율적으로 구축하기 위해 만들어졌으며, 현재 가장 널리 사용되는 프론트엔드 기술 중 하나다.</p>
<hr>
<h2 id="프레임워크-vs-라이브러리">프레임워크 vs 라이브러리</h2>
<p>React를 제대로 이해하려면 먼저 이 둘의 차이를 알아야 한다.</p>
<h3 id="프레임워크-framework">프레임워크 (Framework)</h3>
<ul>
<li>뼈대, 골조</li>
<li>개발자가 <strong>정해진 규칙 안에서</strong> 코드를 작성해야 함</li>
<li><strong>주도권이 프레임워크</strong>에 있음</li>
<li>ex) Angular, Next.js, Django, Spring</li>
</ul>
<h3 id="라이브러리-library">라이브러리 (Library)</h3>
<ul>
<li>도서관에서 책을 꺼내오듯 <strong>필요한 기능만 가져다 쓰는</strong> 방식</li>
<li>특정 기능을 수행하는 도구로서, 개발자가 원하는 시점에 직접 호출</li>
<li><strong>주도권이 개발자</strong>에게 있음</li>
<li>ex) <strong>React</strong>, Lodash, Axios</li>
</ul>
<blockquote>
<p>✅ <strong>React는 라이브러리다.</strong>
라우팅(React Router), 상태관리(Redux, Zustand) 등은 React가 직접 제공하지 않고 별도 라이브러리를 조합해서 사용한다.
이것이 프레임워크인 Angular와의 가장 큰 차이점이다.</p>
</blockquote>
<hr>
<h2 id="react의-핵심-개념">React의 핵심 개념</h2>
<h3 id="1-spa-single-page-application">1. SPA (Single Page Application)</h3>
<p>React는 <strong>SPA</strong> 방식으로 동작한다.</p>
<p>전통적인 웹(MPA)은 페이지를 이동할 때마다 서버에서 새로운 HTML을 받아와 화면 전체를 새로 그린다.
반면 SPA는 <strong>하나의 HTML 파일</strong> 안에서 JavaScript가 필요한 부분만 동적으로 교체하기 때문에
페이지 깜빡임 없이 앱처럼 부드럽게 동작한다.</p>
<p><strong>MPA vs SPA 비교</strong></p>
<table>
<thead>
<tr>
<th></th>
<th>MPA (전통 방식)</th>
<th>SPA (React 방식)</th>
</tr>
</thead>
<tbody><tr>
<td>페이지 이동</td>
<td>서버에서 새 HTML 수신</td>
<td>JS가 화면만 교체</td>
</tr>
<tr>
<td>속도</td>
<td>첫 로딩 빠름, 이동마다 느림</td>
<td>첫 로딩 느림, 이동 후 빠름</td>
</tr>
<tr>
<td>사용자 경험</td>
<td>페이지 깜빡임 있음</td>
<td>앱처럼 부드러움</td>
</tr>
<tr>
<td>예시</td>
<td>일반 블로그, 뉴스 사이트</td>
<td>Gmail, 트위터, 유튜브</td>
</tr>
</tbody></table>
<hr>
<h3 id="2-컴포넌트-component">2. 컴포넌트 (Component)</h3>
<p>React 애플리케이션은 <strong>컴포넌트 단위</strong>로 구성된다.
UI를 독립적이고 재사용 가능한 조각으로 나눠서 개발하는 것이 핵심이다.</p>
<p><strong>좋은 컴포넌트의 조건</strong></p>
<ul>
<li>단독으로 기능을 수행할 수 있어야 한다.</li>
<li><strong>재사용이 가능</strong>하면서 최소한 하나의 기능이 내장되어 있어야 한다.</li>
<li>화면만 보여주는 컴포넌트(Presentational Component)만 있을 수도 있지만, 데이터 처리나 이벤트 핸들링 없이 UI만 그리는 컴포넌트는 좋은 설계라고 보기 어렵다.</li>
</ul>
<pre><code class="language-jsx">// 예시: 버튼 컴포넌트
function Button({ label, onClick }) {
  return &lt;button onClick={onClick}&gt;{label}&lt;/button&gt;;
}</code></pre>
<hr>
<h3 id="3-virtual-dom">3. Virtual DOM</h3>
<p>일반적인 DOM 조작은 변경이 생길 때마다 브라우저가 전체 렌더링을 다시 수행해서 느리다.</p>
<p>React는 <strong>Virtual DOM(가상 DOM)</strong> 을 사용해 이 문제를 해결한다.</p>
<p><strong>동작 방식</strong></p>
<ol>
<li>상태(state)가 변경되면 새로운 Virtual DOM을 생성한다.</li>
<li>이전 Virtual DOM과 새 Virtual DOM을 <strong>비교(Diffing)</strong> 한다.</li>
<li>실제로 변경된 부분만 찾아 <strong>Real DOM에 최소한으로 반영</strong>한다.</li>
</ol>
<blockquote>
<p>이 과정을 <strong>재조정(Reconciliation)</strong> 이라고 한다.
덕분에 데이터가 빈번하게 바뀌는 대규모 앱에서도 빠른 성능을 유지할 수 있다.</p>
</blockquote>
<hr>
<h3 id="4-라이프사이클-life-cycle">4. 라이프사이클 (Life Cycle)</h3>
<p>React 컴포넌트는 생성되고 → 업데이트되고 → 사라지는 <strong>생명주기</strong>를 가진다.</p>
<h4 id="클래스형-컴포넌트-방식-구-방식">클래스형 컴포넌트 방식 (구 방식)</h4>
<pre><code class="language-jsx">class MyComponent extends React.Component {
  componentDidMount() { /* 마운트 후 실행 */ }
  componentDidUpdate() { /* 업데이트 후 실행 */ }
  componentWillUnmount() { /* 언마운트 전 실행 */ }
  render() { return &lt;div&gt;Hello&lt;/div&gt;; }
}</code></pre>
<h4 id="함수형-컴포넌트-방식-현재-표준">함수형 컴포넌트 방식 (현재 표준)</h4>
<pre><code class="language-jsx">import { useEffect } from &#39;react&#39;;

function MyComponent() {
  useEffect(() =&gt; {
    // 마운트 시 실행
    return () =&gt; {
      // 언마운트 시 실행 (클린업)
    };
  }, []); // 의존성 배열

  return &lt;div&gt;Hello&lt;/div&gt;;
}</code></pre>
<blockquote>
<p>현재는 <strong>함수형 컴포넌트 + Hooks</strong> 방식이 표준이다.
클래스형은 레거시 코드에서 마주칠 수 있으므로 읽을 줄은 알아야 한다.</p>
</blockquote>
<hr>
<h2 id="프로젝트-시작하기">프로젝트 시작하기</h2>
<h3 id="방법-1-create-react-app-cra">방법 1: Create React App (CRA)</h3>
<pre><code class="language-bash">npx create-react-app my-app
cd my-app
npm start</code></pre>
<ul>
<li>설정 없이 바로 시작할 수 있어 입문자에게 적합하다.</li>
<li>다만 빌드 속도가 느리고 불필요한 설정이 많아 <strong>최근에는 잘 사용하지 않는 추세</strong>다.</li>
</ul>
<h3 id="방법-2-vite-권장-⭐">방법 2: Vite (권장) ⭐</h3>
<pre><code class="language-bash">npm create vite@latest   # 최신 Vite 버전으로 프로젝트 생성</code></pre>
<ul>
<li>CRA보다 <strong>빌드 속도가 훨씬 빠르다.</strong></li>
<li>가볍고 설정이 유연해서 현재 가장 많이 사용되는 방식이다.</li>
<li>실행 후 프레임워크(React), 언어(JS/TS) 등을 선택하면 된다.</li>
</ul>
<ol>
<li>터미널 창에 명령어 입력 (npm create vite@latest) &gt; 프로젝트 이름 입력 &gt; react 선택 &gt; JavaScript 선택
<img src="https://velog.velcdn.com/images/jhwest-dev/post/94db0e4e-eda0-4001-9a7c-34b584a183a8/image.png" alt=""></li>
<li>npm run dev 입력 시 아래와 이미지와 같이 초기화면이 뜬다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/215f2575-e996-4bff-b4b8-00cc72927ee8/image.png" alt=""></li>
</ol>
<hr>
<h2 id="폴더-구조">폴더 구조</h2>
<p>Vite 기준으로 생성된 기본 폴더 구조는 다음과 같다.</p>
<pre><code>my-app/
├── public/              # 정적 파일 (빌드 시 그대로 복사됨)
│   └── favicon.ico      # 거의 바뀌지 않는 이미지 (파비콘 등)
│
├── src/                 # 실제 개발이 이루어지는 폴더
│   ├── assets/          # 개발 중 변경될 수 있는 이미지, 폰트 등
│   ├── components/      # 재사용 가능한 컴포넌트 모음
│   ├── pages/           # 라우트별 페이지 컴포넌트
│   ├── App.jsx          # 루트 컴포넌트
│   └── main.jsx         # 진입점 (ReactDOM.render)
│
├── index.html           # 앱의 HTML 껍데기
├── vite.config.js       # Vite 설정 파일
└── package.json         # 프로젝트 정보 및 의존성 목록</code></pre><h3 id="public-vs-srcassets-차이">public vs src/assets 차이</h3>
<table>
<thead>
<tr>
<th></th>
<th><code>public/</code></th>
<th><code>src/assets/</code></th>
</tr>
</thead>
<tbody><tr>
<td>용도</td>
<td>거의 바뀌지 않는 정적 파일</td>
<td>개발 중 변경될 수 있는 리소스</td>
</tr>
<tr>
<td>예시</td>
<td>파비콘, robots.txt</td>
<td>배경 이미지, 아이콘, 폰트</td>
</tr>
<tr>
<td>처리 방식</td>
<td>빌드 시 그대로 복사</td>
<td>Vite가 최적화(압축, 해시) 처리</td>
</tr>
<tr>
<td>참조 방법</td>
<td><code>/파일명</code> 으로 절대경로</td>
<td><code>import</code> 구문으로 가져옴</td>
</tr>
</tbody></table>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>개념</th>
<th>핵심 요약</th>
</tr>
</thead>
<tbody><tr>
<td>React</td>
<td>UI 구축을 위한 JavaScript 라이브러리</td>
</tr>
<tr>
<td>SPA</td>
<td>하나의 HTML에서 JS가 화면을 동적으로 교체, 앱처럼 부드러운 UX</td>
</tr>
<tr>
<td>컴포넌트</td>
<td>재사용 가능한 UI 단위, 최소 하나의 기능 내장</td>
</tr>
<tr>
<td>Virtual DOM</td>
<td>변경된 부분만 Real DOM에 반영해 성능 최적화</td>
</tr>
<tr>
<td>라이프사이클</td>
<td>마운트 → 업데이트 → 언마운트, 현재는 useEffect로 관리</td>
</tr>
<tr>
<td>프로젝트 시작</td>
<td>Vite 사용 권장 (<code>npm create vite@latest</code>)</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 피그마 컴포넌트 시스템 정리 (컴포넌트, 인스턴스, 베리언트, 인터랙션)]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-%ED%94%BC%EA%B7%B8%EB%A7%88-%EA%B0%9C%EB%85%90%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@jhwest-dev/TIL-%ED%94%BC%EA%B7%B8%EB%A7%88-%EA%B0%9C%EB%85%90%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Thu, 11 Jun 2026 08:14:39 GMT</pubDate>
            <description><![CDATA[<h3 id="컴포넌트-component">컴포넌트 (Component)</h3>
<p>재사용 가능한 디자인 요소. 자주 쓰는 UI를 컴포넌트로 만들어두면 효율적으로 작업할 수 있다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/5d4f3554-679b-4129-8854-3350046f31cb/image.png" alt=""></p>
<hr>
<h3 id="인스턴스-instance">인스턴스 (Instance)</h3>
<p>컴포넌트를 복사하면 생기는 일시적인 복사본.</p>
<ul>
<li>기본적으로 <strong>부모(원본) 컴포넌트의 속성을 따라간다</strong></li>
<li>단, 인스턴스에서 <strong>개별적으로 속성을 변경하면 부모 변경사항이 반영되지 않는다</strong></li>
</ul>
<hr>
<h3 id="에셋-assets">에셋 (Assets)</h3>
<p>에셋 탭에서 내가 만든 컴포넌트를 불러와 사용할 수 있다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/89932824-27f8-40c0-8abf-841fad366a1d/image.png" alt=""></p>
<hr>
<h3 id="메인-컴포넌트로-푸시-push-to-main-component">메인 컴포넌트로 푸시 (Push to Main Component)</h3>
<p>다른 페이지에서 에셋으로 컴포넌트를 만든 후, 메인 페이지로 이동시킬 수 있다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/297041c4-5712-4a17-a587-69a3a15f2cc4/image.png" alt=""></p>
<hr>
<h3 id="create-multiple-components">Create Multiple Components</h3>
<p>여러 요소를 한 번에 컴포넌트로 만드는 기능. 각각 일일이 만들지 않아도 되어서 작업 속도가 빨라진다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/15b041b6-c339-4713-93f5-afc8582740c7/image.png" alt=""></p>
<hr>
<h3 id="베리언트-variant">베리언트 (Variant)</h3>
<p>컴포넌트에 <strong>속성(Property)을 부여해서 다양한 변형을 관리</strong>하는 기능.</p>
<ul>
<li>속성을 정의하면 그 조합만큼 자동으로 변형 세트가 생성된다</li>
<li>에셋에서 인스턴스를 가져온 후, <strong>속성 패널에서 원하는 값으로 바꾸는 것만으로 디자인 변경 가능</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/0dfdc67c-b21c-4fe1-ab36-516e156c39b9/image.png" alt=""></p>
<hr>
<h3 id="인터랙션-interaction">인터랙션 (Interaction)</h3>
<p>피그마에서 컴포넌트에 동작을 부여하는 기능. 프로토타입처럼 실제 앱과 유사한 UX를 구현할 수 있다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/58b7097a-16d2-4004-b6a6-e5fbefec0533/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 피그마 시작하기 - 툴 사용법 & 서비스 기획 실습]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-%ED%94%BC%EA%B7%B8%EB%A7%88-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-%ED%88%B4-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B8%B0%ED%9A%8D-%EC%8B%A4%EC%8A%B5</link>
            <guid>https://velog.io/@jhwest-dev/TIL-%ED%94%BC%EA%B7%B8%EB%A7%88-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-%ED%88%B4-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B8%B0%ED%9A%8D-%EC%8B%A4%EC%8A%B5</guid>
            <pubDate>Mon, 08 Jun 2026 08:11:56 GMT</pubDate>
            <description><![CDATA[<p>그동안은 피그마 화면을 협업하면서 보기만 했는데, 오늘은 내가 직접 만들어봤다. 
막상 해보니 생각보다 어렵지 않았다. 
굳이 비유하자면 PPT 만드는 것과 비슷한 느낌? 
물론 컨스트레인트, 오토레이아웃 같은 개념은 PPT엔 없는 피그마만의 것들이라 신기하기도 했다.
오늘 배운 것들을 아래와 같이 정리해본다.</p>
<hr>
<h2 id="1-피그마-시작하기">1. 피그마 시작하기</h2>
<ol>
<li>사이트 접속 및 로그인
<img src="https://velog.velcdn.com/images/jhwest-dev/post/438a6d6a-1996-462a-87d0-d63f40708cd0/image.png" alt=""></li>
<li>팀 만들기
<img src="https://velog.velcdn.com/images/jhwest-dev/post/92629af4-4330-49c3-99c0-c49aa42824a8/image.png" alt=""></li>
<li>초안(Draft) 만들기
<img src="https://velog.velcdn.com/images/jhwest-dev/post/91429278-0ee4-43df-b805-a5a65883122f/image.png" alt="">
<img src="https://velog.velcdn.com/images/jhwest-dev/post/1443c7df-23bd-4580-a28d-8902f8dd4115/image.png" alt=""></li>
</ol>
<hr>
<h2 id="2-그룹-vs-프레임-vs-컴포넌트-차이">2. 그룹 vs 프레임 vs 컴포넌트 차이</h2>
<table>
<thead>
<tr>
<th></th>
<th>그룹 (Group)</th>
<th>프레임 (Frame)</th>
<th>컴포넌트 (Component)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>역할</strong></td>
<td>단순 묶음</td>
<td>레이아웃 단위</td>
<td>재사용 단위</td>
</tr>
<tr>
<td><strong>크기</strong></td>
<td>내부 요소에 따라 자동 조절</td>
<td>고정 크기 설정 가능</td>
<td>고정 크기 설정 가능</td>
</tr>
<tr>
<td><strong>특징</strong></td>
<td>클리핑 없음</td>
<td>클리핑 가능, 컨스트레인트·오토레이아웃 동작</td>
<td>인스턴스로 복제, 원본 수정 시 전체 반영</td>
</tr>
<tr>
<td><strong>언제 쓰나</strong></td>
<td>임시로 요소 묶을 때</td>
<td>화면/섹션 구성할 때</td>
<td>버튼, 카드 등 반복 요소</td>
</tr>
<tr>
<td><img src="https://velog.velcdn.com/images/jhwest-dev/post/d909f3f7-3787-4441-89bb-07e9087354dd/image.png" alt=""></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<hr>
<h2 id="3-컨스트레인트constraints란">3. 컨스트레인트(Constraints)란?</h2>
<blockquote>
<p>컨스트레인트는 <strong>프레임 크기가 변할 때 내부 요소의 위치와 크기가 어떻게 반응할지 설정하는 기능</strong>이다. </p>
</blockquote>
<p>쉽게 말해 요소를 상하좌우 어디에 고정할지, 아니면 프레임에 맞게 늘어날지를 정해주는 것이다. 
반응형 디자인을 구현할 때 핵심적으로 쓰인다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/f26ef34a-1724-4182-9e53-f231d34c2752/image.png" alt=""></p>
<hr>
<h2 id="4-오토레이아웃auto-layout이란">4. 오토레이아웃(Auto Layout)이란?</h2>
<blockquote>
<p>오토레이아웃은 <strong>요소들 사이의 간격과 방향을 자동으로 정렬해주는 기능</strong>이다.</p>
</blockquote>
<p>버튼 안의 텍스트가 길어지면 버튼 크기가 자동으로 늘어나고, 목록에 항목을 추가하면 간격을 유지하면서 자동으로 밀려난다.</p>
<p>개발로 치면 CSS의 Flexbox와 거의 동일한 개념으로, 방향(가로/세로), 간격(gap), 정렬(align)을 설정할 수 있다.</p>
<p>컨스트레인트와 헷갈릴 수 있는데 아래와 같이 구분하면 쉽다.</p>
<p><strong>컨스트레인트 →</strong> 프레임 기준으로 요소의 위치를 고정
*<em>오토레이아웃 → *</em>요소들끼리의 간격과 정렬을 자동으로 유지</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/a65c401b-9a08-4205-b068-7e26b67f7dda/image.png" alt=""></p>
<hr>
<h2 id="5-서비스-디자인-기획-실습">5. 서비스 디자인 기획 실습</h2>
<p>운동도 공부도 해야 한다는 건 안다. 
근데 막상 시작이 안 되고, 미루고, 결국 벼락치기를 반복한다. 
이 사이클이 반복되는 이유가 뭔지 알고 싶었다.</p>
<p>_기존 습관 앱은 전부 &quot;오늘도 성공!&quot; 에만 집중한다. _</p>
<p>그래서 기획한 서비스가 <strong>작심삼일 해부소</strong>다.
실패를 기록하고, 패턴을 분석하고, 내가 무너지는 순간을 데이터로 보여주는 사이트이다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/b34161e3-c889-42f5-bbb8-e06106089fa3/image.png" alt=""></p>
<hr>
<h2 id="6-페르소나-만들기">6. 페르소나 만들기</h2>
<p>서비스 기획에 이어 피그마로 페르소나도 직접 만들어봤다.
꾸준함을 원하지만 매번 같은 패턴으로 실패하는 26세 직장인 김지현을 설정했다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/dbf7fb14-9982-4280-bfd8-96f3d9fbc87a/image.png" alt=""></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>툴 사용법을 익히는 것도 중요하지만, <strong>&quot;무엇을 만들 것인가&quot;</strong>가 더 중요하다는 걸 느꼈다.</p>
<p>작심삼일 해부소는 그냥 실습 과제가 아니라 진짜 만들어보고 싶은 서비스다.
앞으로 배우는 것들을 하나씩 적용해서 직접 완성해보는 게 목표다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] CX · UX/UI · 디자인 시스템]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-CX-%EB%94%94%EC%9E%90%EC%9D%B8%EB%B6%80%ED%84%B0-%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@jhwest-dev/TIL-CX-%EB%94%94%EC%9E%90%EC%9D%B8%EB%B6%80%ED%84%B0-%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Fri, 05 Jun 2026 08:04:06 GMT</pubDate>
            <description><![CDATA[<h2 id="1-cx-customer-experience-디자인이란">1. CX (Customer Experience) 디자인이란?</h2>
<p>CX는 단순한 화면 디자인이 아니라 사용자가 서비스를 경험하는 <strong>모든 순간을 설계</strong>하는 것이다. UI/UX는 CX의 하위 개념이다.</p>
<blockquote>
<p>💡 핵심은 <strong>계속 쓰게 만드는 것</strong>이다. 사용자가 앱을 다시 열고 싶게 만드는 경험 설계가 최우선이다.</p>
</blockquote>
<h4 id="온보딩의-중요성">온보딩의 중요성</h4>
<p>첫 경험이 전부다. 온보딩을 어떻게 설계하느냐에 따라 사용자의 재방문율이 크게 달라진다.<br>가이드와 튜토리얼을 충실히 설계하자.</p>
<h4 id="모바일-vs-웹--배치는-다르다">모바일 vs 웹  배치는 다르다</h4>
<p>모바일과 웹은 화면 크기뿐 아니라 사용 맥락, 인터랙션 방식, 정보 우선순위 배치가 완전히 다르다.<br>각각의 기기에 맞는 설계가 필요하다.</p>
<hr>
<h2 id="2-색color-설계">2. 색(Color) 설계</h2>
<h4 id="색의-역할">색의 역할</h4>
<ul>
<li><strong>브랜드 아이덴티티 각인</strong> - 서비스를 기억하게 만드는 시각적 각인</li>
<li><strong>마케팅 관점에서 클릭 유도</strong> - 할인 정보, 프로모션 배너 등</li>
<li><strong>방향 제시</strong> - 구매하기 버튼, 다음 단계 안내 등 </li>
</ul>
<h4 id="키-컬러는-생각보다-적게-쓴다">키 컬러는 생각보다 적게 쓴다</h4>
<p>키 컬러(Primary Color)는 포인트로만 사용한다. 
스타벅스나 이마트도 매장에서 초록색·노란색이 넘쳐나지 않는다. 로고, 간판, 핵심 CTA 버튼 등 꼭 필요한 곳에만 집중 배치하기 때문에 오히려 눈에 잘 띄는 것이다.</p>
<h4 id="색의-종류">색의 종류</h4>
<table>
<thead>
<tr>
<th>종류</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Primary Color</strong></td>
<td>핵심 액션, 브랜드 아이덴티티</td>
</tr>
<tr>
<td><strong>Secondary Color</strong></td>
<td>보조 강조 색상</td>
</tr>
<tr>
<td><strong>Neutrals</strong></td>
<td>배경, 구조, 텍스트</td>
</tr>
<tr>
<td><strong>Semantic Color</strong></td>
<td>성공 · 경고 · 에러 등 상태 표현</td>
</tr>
<tr>
<td><strong>Extended Palettes</strong></td>
<td>브랜드 확장 팔레트</td>
</tr>
</tbody></table>
<br>

<h4 id="알아두면-좋은-것들">알아두면 좋은 것들</h4>
<p><strong>1. 검은색은 검은색이 아니다</strong> : 그림자와 오버레이에 순수한 <code>#000000</code> 대신 브랜드 컬러가 살짝 섞인 어두운 색을 사용하면 훨씬 자연스럽고 세련된 결과가 나온다.
<strong>2. 그림자에 다양한 색을 시도해보자</strong> : 검정 그림자 외에도 보라, 파랑 등 다른 색을 써보면 깊이감과 개성을 줄 수 있다.
<strong>3. 돈 관련 프로젝트는 보수적으로</strong> : 금융 서비스에서는 화려한 색보다 신뢰감 있는 중립적 색상 설계가 더 중요하다.</p>
<hr>
<h2 id="3-사용성-usability">3. 사용성 (Usability)</h2>
<p>좋은 사용성이란 사용자가 기대하는 동작을 그대로 구현하는 것이다.</p>
<ul>
<li><strong>엔터키로 로그인</strong> - 버튼 클릭 없이도 동작해야 한다</li>
<li><strong>정보를 모두 입력한 후 다음 단계로</strong> - 필수 입력값이 빠진 상태에서 다음 단계로 넘어가지 않도록</li>
<li><strong>스켈레톤 UI</strong> - 로딩 중에도 레이아웃을 유지해 사용자의 불안감을 줄여준다</li>
</ul>
<hr>
<h2 id="4-디자인-시스템">4. 디자인 시스템</h2>
<h4 id="디자인-시스템-장점-4가지-⭐">디자인 시스템 장점 4가지 ⭐</h4>
<ol>
<li><strong>시각적 일관성</strong> : 동일한 형태로 제작, 유사한 모양의 구성 요소들이 일관된 시각적 패턴을 형성</li>
<li><strong>기능적 일관성</strong> : 같은 동작은 항상 같은 방식으로 작동</li>
<li><strong>내부 일관성</strong> : 서비스 내부에서 서로 다른 영역 간의 통일성</li>
<li><strong>외부 일관성</strong> : 타 서비스나 플랫폼 관습에 맞춰 사용자 기대를 충족</li>
</ol>
<h4 id="디자인-시스템의-구조-⭐">디자인 시스템의 구조 ⭐</h4>
<ol>
<li><strong>개요 (Overview)</strong> : 일련의 디자인 원칙(Design Principles)을 문서화</li>
<li><strong>파운데이션 (Foundation)</strong> : 가장 기초가 되는 디자인 요소의 모음 (아이콘, 폰트 크기, 색상, 폰트)</li>
<li><strong>컴포넌트 (Component)</strong> : 디자인 시스템에서 재사용 가능한 구성 요소</li>
<li><strong>패턴 (Pattern)</strong> : 사용자가 목표를 달성하려고 사용하는 방법의 모범 사례</li>
<li><strong>기타</strong> : 기업에 따라 커뮤니티를 구성하거나 자료를 공유하는 링크 제공</li>
</ol>
<hr>
<h2 id="5-아토믹-디자인-atomic-design-⭐">5. 아토믹 디자인 (Atomic Design) ⭐</h2>
<p>브래드 프로스트(Brad Frost)가 고안한 디자인 시스템으로, 디자이너와 개발자 사이의 협업이 용이하도록 UI를 원자 단위부터 계층적으로 구성한다.</p>
<pre><code>원자(Atom) → 분자(Molecule) → 유기체(Organism) → 템플릿(Template) → 페이지(Page)</code></pre><ul>
<li><strong>원자</strong> : 버튼, 인풋, 아이콘 등 더 이상 쪼갤 수 없는 최소 단위</li>
<li><strong>분자</strong> : 원자가 모여 하나의 역할을 하는 단위 (ex. 검색창 = 인풋 + 버튼)</li>
<li><strong>유기체</strong> : 분자가 모여 독립적인 영역을 이루는 단위 (ex. 헤더, 카드 리스트)</li>
<li><strong>템플릿</strong> : 유기체를 배치한 페이지 레이아웃</li>
<li><strong>페이지</strong> : 실제 콘텐츠가 채워진 최종 화면</li>
</ul>
<hr>
<h2 id="6-디자인-프로세스-방법론">6. 디자인 프로세스 방법론</h2>
<h4 id="더블-다이아몬드-double-diamond-⭐">더블 다이아몬드 (Double Diamond) ⭐</h4>
<p>문제를 발산과 수렴 두 단계로 반복하며 접근하는 방법론이다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>핵심 질문</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>발견 · 정의</td>
<td><strong>무엇을 / 왜?</strong></td>
<td>문제를 탐색하고 정의하는 단계</td>
</tr>
<tr>
<td>개발 · 전달</td>
<td><strong>어떻게?</strong></td>
<td>해결책을 탐색하고 구현하는 단계</td>
</tr>
</tbody></table>
<h4 id="애자일-프로세스-agile-process-⭐">애자일 프로세스 (Agile Process) ⭐</h4>
<p>사용자(이때의 사용자는 진짜 사용자가 아닌 <strong>해당 제품을 요구한 회사</strong>)의 요구사항을 끊임없이 반영하고 점진적으로 제품을 업데이트하여 서비스가 추구하는 가치를 구현하는 프로세스</p>
<h4 id="린-프로세스-lean-process-⭐">린 프로세스 (Lean Process) ⭐</h4>
<p>기업이 가정한 시장 상황을 테스트하기 위해 프로토타입을 만들고, <strong>일반 사용자</strong>의 피드백을 받아 빠르게 발전시키는 프로세스</p>
<blockquote>
<p>📝 <strong>차이 포인트:</strong> 애자일의 &#39;사용자&#39;는 클라이언트(요청 기업), 린의 &#39;사용자&#39;는 실제 최종 사용자다.</p>
</blockquote>
<hr>
<h2 id="7-와이어프레임-wireframe-⭐">7. 와이어프레임 (Wireframe) ⭐</h2>
<p>디자인의 뼈대를 잡는 단계로, 색상이나 비주얼 없이 레이아웃과 구조, 정보 우선순위를 결정한다.<br>실제 개발 전에 방향을 확인하고 수정 비용을 줄이는 데 핵심적인 역할을 한다.</p>
<table>
<thead>
<tr>
<th>종류</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Lo-fi (저충실도)</strong></td>
<td>손스케치 수준, 빠른 아이디어 검증에 적합</td>
</tr>
<tr>
<td><strong>Hi-fi (고충실도)</strong></td>
<td>실제 UI에 가까운 상세 구성, 개발 전 최종 확인용</td>
</tr>
</tbody></table>
<hr>
<h2 id="마치며">마치며</h2>
<p>오늘 수업이 꽤 흥미로웠다. 디자인이 단순히 예쁜 것을 만드는 게 아니라, 사용자가 편하게 쓸 수 있어야 한다는 것. 그리고 색상도 그냥 쓰는 게 아니라 Primary, Semantic 등 역할이 정해져 있다는 게 신기했다. 앞으로 프로젝트할 때 디자인을 어떻게 접근해야 할지 감이 좀 잡힌 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[포롱(PORONG) 미니 프로젝트 회고 - 관리자 페이지 & 로그인/회원가입 개발기]]></title>
            <link>https://velog.io/@jhwest-dev/%ED%8F%AC%EB%A1%B1PORONG-%EB%AF%B8%EB%8B%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0-%EA%B4%80%EB%A6%AC%EC%9E%90-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A1%9C%EA%B7%B8%EC%9D%B8%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/%ED%8F%AC%EB%A1%B1PORONG-%EB%AF%B8%EB%8B%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0-%EA%B4%80%EB%A6%AC%EC%9E%90-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A1%9C%EA%B7%B8%EC%9D%B8%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Fri, 05 Jun 2026 06:54:23 GMT</pubDate>
            <description><![CDATA[<h2 id="프로젝트-소개">프로젝트 소개</h2>
<p><strong>포롱</strong>은 팝업스토어 정보를 한 곳에서 탐색하고, 예약·리뷰·키링 수집까지 할 수 있는 웹 서비스이다.
팝업스토어 정보를 인스타그램이나 각종 SNS에서 일일이 찾아야 하는 불편함을 해소하고자 기획했다.</p>
<ul>
<li><strong>슬로건:</strong> 찾고, 모으고, 포롱.</li>
<li><strong>팀:</strong> 이주현 · 고유정 · 서지현 (유레카 4기)</li>
<li><strong>GitHub:</strong> <a href="https://github.com/Po-Rong">https://github.com/Po-Rong</a></li>
</ul>
<hr>
<h2 id="내가-맡은-기능">내가 맡은 기능</h2>
<ul>
<li>로그인 / 회원가입</li>
<li>관리자(판매자) 페이지<ul>
<li>대시보드 요약 카드 (이번달 예약 수 · 평균 별점 · 총 리뷰)</li>
<li>팝업스토어 등록 · 수정 · 삭제</li>
<li>내 팝업스토어 목록 조회</li>
<li>팝업별 후기 탭 조회</li>
<li>예약 고객 조회 · 예약 취소 (날짜별 페이징)</li>
</ul>
</li>
</ul>
<hr>
<h2 id="구현-방법">구현 방법</h2>
<h3 id="1-진입-제한---판매자만-접근-가능">1. 진입 제한 - 판매자만 접근 가능</h3>
<p>관리자 페이지는 <code>seller</code> 권한을 가진 사용자만 접근할 수 있어야 했다.
페이지 로드 시 로컬스토리지에서 로그인 정보를 꺼내 권한을 확인하고, 조건에 맞지 않으면 즉시 메인 페이지로 튕겨낸다.</p>
<pre><code class="language-javascript">const user = JSON.parse(localStorage.getItem(&quot;loginUser&quot;));

if (!user || user.role !== &quot;seller&quot;) {
    alert(&quot;판매자만 접근 가능합니다.&quot;);
    location.href = &quot;/index.html&quot;;
}</code></pre>
<p>로그인하지 않은 경우(<code>!user</code>)와 일반 사용자인 경우 모두 같은 로직으로 처리했다.</p>
<hr>
<h3 id="2-대시보드-요약-카드">2. 대시보드 요약 카드</h3>
<p>판매자가 페이지에 들어오면 이번달 예약 수, 평균 별점, 총 리뷰 수를 한눈에 볼 수 있도록 요약 카드를 구현했다.
API 한 번 호출로 3가지 데이터를 한 번에 받아서 각 카드에 뿌렸다.</p>
<pre><code class="language-javascript">function loadSummary() {
    fetch(`${API}/admin/summary?seller_id=${user.userId}`)
        .then((res) =&gt; res.json())
        .then((data) =&gt; {
            document.getElementById(&quot;statMonthlyReservation&quot;).textContent = data.monthlyReservationCount;
            document.getElementById(&quot;statAverageRating&quot;).textContent = data.averageRating;
            document.getElementById(&quot;statTotalReview&quot;).textContent = data.totalReviewCount;
        });
}</code></pre>
<hr>
<h3 id="3-팝업스토어-목록---수정삭제-이벤트-분리">3. 팝업스토어 목록 - 수정/삭제 이벤트 분리</h3>
<p>팝업 카드 전체를 클릭하면 상세 페이지로 이동하는데, 카드 안에 수정/삭제 버튼도 함께 있었다.
버튼 클릭 시 카드 클릭 이벤트도 같이 실행되는 문제가 있어서 <code>event.stopPropagation()</code>으로 이벤트 버블링을 막았다.</p>
<pre><code class="language-javascript">div.innerHTML = `
    &lt;button onclick=&quot;event.stopPropagation(); location.href=&#39;/pages/popup-edit.html?id=${popup.id}&#39;&quot;&gt;수정&lt;/button&gt;
    &lt;button onclick=&quot;event.stopPropagation(); deletePopup(${popup.id})&quot;&gt;삭제&lt;/button&gt;
`;</code></pre>
<p>삭제 후에는 페이지 전체를 reload하지 않고 <code>loadPopupList()</code>만 다시 호출해서 목록을 갱신하였다.</p>
<pre><code class="language-javascript">function deletePopup(popupId) {
    if (!confirm(&quot;정말 삭제하시겠습니까?&quot;)) return;

    fetch(`${API}/popups/${popupId}?seller_id=${user.userId}`, {
        method: &quot;DELETE&quot;,
    })
        .then((res) =&gt; res.json().then((data) =&gt; ({ ok: res.ok, data })))
        .then(({ ok, data }) =&gt; {
            if (ok) {
                alert(data.message);
                loadPopupList();
            }
        });
}</code></pre>
<hr>
<h3 id="4-이미지-저장---로컬-업로드-방식">4. 이미지 저장 - 로컬 업로드 방식</h3>
<p>팝업 등록·수정 시 이미지를 어떻게 저장하고 불러올지가 가장 큰 고민이었다.
배포 환경이 아니다 보니 S3 같은 클라우드 스토리지를 쓸 수 없었고, DB에 직접 저장하는 것도 부담이었다.</p>
<p>백엔드 프로젝트 루트의 <code>/uploads</code> 폴더에 이미지를 저장하고, 해당 파일의 URL을 DB에 저장하는 방식으로 구현했다.
파일명은 UUID로 중복을 방지하고, 쿼리스트링이 포함된 파일명은 전처리로 제거했다.</p>
<pre><code class="language-java">private String saveImage(MultipartFile file) {
    try {
        String uploadDir = System.getProperty(&quot;user.dir&quot;) + &quot;/uploads/&quot;;

        File dir = new File(uploadDir);
        if (!dir.exists()) {
            dir.mkdirs();
        }

        // 쿼리스트링 제거
        String originalFilename = file.getOriginalFilename();
        if (originalFilename != null &amp;&amp; originalFilename.contains(&quot;?&quot;)) {
            originalFilename = originalFilename.split(&quot;\\?&quot;)[0];
        }

        String fileName = UUID.randomUUID() + &quot;_&quot; + originalFilename;
        file.transferTo(new File(uploadDir + fileName));

        return &quot;http://localhost:8080/uploads/&quot; + fileName;
    } catch (IOException e) {
        throw new RuntimeException(&quot;이미지 저장 실패&quot;, e);
    }
}</code></pre>
<hr>
<h3 id="5-후기-목록---팝업별-그룹화--탭">5. 후기 목록 - 팝업별 그룹화 + 탭</h3>
<p>전체 후기를 팝업별로 탭으로 나눠서 볼 수 있도록 구현했다.
API에서 받은 전체 후기를 <code>popupTitle</code> 기준으로 객체에 그룹화한 뒤, 탭을 동적으로 생성했다.</p>
<pre><code class="language-javascript">// 팝업별로 그룹화
const grouped = {};
data.forEach((review) =&gt; {
    const key = review.popupTitle;
    if (!grouped[key]) {
        grouped[key] = { popupTitle: review.popupTitle, reviews: [] };
    }
    grouped[key].reviews.push(review);
});

// 전체 탭
const allTab = document.createElement(&quot;button&quot;);
allTab.textContent = `전체 (${data.length})`;
allTab.addEventListener(&quot;click&quot;, () =&gt; renderReviewCards(data, &quot;all&quot;));

// 팝업별 탭
groups.forEach((group, groupIndex) =&gt; {
    const tab = document.createElement(&quot;button&quot;);
    tab.textContent = `${group.popupTitle} (${group.reviews.length})`;
    tab.addEventListener(&quot;click&quot;, () =&gt; renderReviewCards(group.reviews, groupIndex));
});</code></pre>
<p>탭 클릭 시 <code>renderReviewCards()</code>에 해당 후기 배열만 넘겨서 다시 렌더링하는 방식이다.</p>
<hr>
<h3 id="6-예약-목록---날짜별-그룹--더보기-페이징">6. 예약 목록 - 날짜별 그룹 + 더보기 페이징</h3>
<p>예약 목록을 전부 불러오면 데이터가 너무 많아 UX가 나빠질 것 같았다.
일반적인 row 단위 페이징 대신 <strong>날짜 단위 페이징</strong>으로 구현했다.</p>
<p>백엔드에서 <code>DISTINCT DATE(reserve_date)</code>로 날짜 목록만 페이징해서 가져오고, 각 날짜별 예약자 목록을 따로 조회해서 묶는 방식이다.
더보기 버튼은 <code>hasNext</code> 값으로 다음 데이터가 있는지 판단해서 표시 여부를 결정한다.</p>
<pre><code class="language-javascript">function loadReservationList(page = 0) {
    fetch(`${API}/reservations?seller_id=${user.userId}&amp;year=${selectedYear}&amp;month=${selectedMonth}&amp;page=${page}&amp;size=2`)
        .then((res) =&gt; res.json())
        .then((data) =&gt; {
            if (page === 0) reservationList.innerHTML = &quot;&quot;; // 첫 페이지면 초기화

            data.content.forEach(({ date, reservations }) =&gt; {
                const group = document.createElement(&quot;div&quot;);
                group.innerHTML = `&lt;p class=&quot;date-label&quot;&gt;${formatFullDate(date)}&lt;/p&gt;`;
                // 날짜별 예약 카드 렌더링
            });

            if (data.hasNext) {
                const btn = document.createElement(&quot;button&quot;);
                btn.textContent = &quot;더보기&quot;;
                btn.onclick = () =&gt; {
                    reservationPage++;
                    loadReservationList(reservationPage);
                };
                reservationList.appendChild(btn);
            }
        });
}</code></pre>
<p><code>page === 0</code>일 때만 목록을 초기화해서 더보기를 눌렀을 때 기존 목록 아래에 자연스럽게 추가되도록 했다.</p>
<hr>
<h3 id="7-드래그-스크롤">7. 드래그 스크롤</h3>
<p>가로 스크롤 카드 리스트에서 마우스로 드래그해서 스크롤할 수 있도록 구현했다.
핵심은 드래그 중에 카드 클릭 이벤트가 실행되지 않도록 막는 부분이다.</p>
<pre><code class="language-javascript">function initDragScroll(containerId) {
    const container = document.getElementById(containerId);
    let isDown = false;
    let isDragging = false;
    let startX, scrollLeft;

    container.addEventListener(&quot;mousedown&quot;, (e) =&gt; {
        isDown = true;
        isDragging = false;
        startX = e.pageX - container.offsetLeft;
        scrollLeft = container.scrollLeft;
    });

    container.addEventListener(&quot;mousemove&quot;, (e) =&gt; {
        if (!isDown) return;
        isDragging = true;
        const walk = (e.pageX - container.offsetLeft - startX) * 1.5;
        container.scrollLeft = scrollLeft - walk;
    });

    // 드래그 중 클릭 방지
    container.addEventListener(&quot;click&quot;, (e) =&gt; {
        if (isDragging) {
            e.stopPropagation();
            isDragging = false;
        }
    }, true);
}</code></pre>
<p><code>isDragging</code> 플래그로 드래그 여부를 추적하고, 드래그 중일 때는 <code>click</code> 이벤트를 <code>stopPropagation()</code>으로 막았다.</p>
<hr>
<h2 id="회고">회고</h2>
<p><strong>잘한 점</strong></p>
<p>기획부터 개발, 디자인까지 팀원 셋이 역할 구분 없이 다 직접 해냈다. 생각보다 많은 기능을 구현할 수 있었고, 끝까지 완성했다는 것 자체가 잘한 것 같다.</p>
<p><strong>아쉬운 점</strong></p>
<p>이번 프로젝트를 하면서 AI 의존도가 많이 높았던 것 같다. 모르는 게 생기면 스스로 찾아보기보다 AI한테 먼저 물어보는 습관이 생긴 것 같아서 아쉽다. 앞으로는 충분히 공부해서 내가 방향을 먼저 제안하고, AI를 도구로 활용하는 방식으로 발전해 나가야겠다.</p>
<p>그래도 AI 덕분에 이전이라면 구현하는 데 오래 걸렸을 것들을 빠르게 만들어낼 수 있었고, 그 시간을 사용자 경험 개선에 더 쏟을 수 있었던 건 분명한 장점이었다.</p>
<p><strong>배운 점</strong></p>
<p>개발은 기능 구현으로 끝나지 않는다는 걸 배웠다. 다 만들었다고 생각했는데 테스트하면서 새로운 버그가 나오고, 사용자 입장에서 보면 고칠 부분이 계속 생겼다. 좋은 서비스는 끊임없는 개선에서 나온다는 걸 몸소 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 카카오 맵 API 리서치 & 사용법 정리]]></title>
            <link>https://velog.io/@jhwest-dev/TIL-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A7%B5-API-%EB%A6%AC%EC%84%9C%EC%B9%98-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@jhwest-dev/TIL-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A7%B5-API-%EB%A6%AC%EC%84%9C%EC%B9%98-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Tue, 26 May 2026 14:09:43 GMT</pubDate>
            <description><![CDATA[<p>현재 학원에서 미니 프로젝트를 진행 중이다. 프로젝트에 도입 예정인 기능 중 주소 데이터를 지도에 마킹하는 기능이 있어 간단하게 리서치해봤다. 직접 담당하는 기능은 아니지만, 카카오 맵 API가 어떻게 동작하는지 궁금하여 직접 사용해보며 사용법을 정리했다.</p>
<hr>
<h2 id="1-카카오-앱-생성-및-설정">1. 카카오 앱 생성 및 설정</h2>
<p><strong>1. <a href="https://developers.kakao.com/">https://developers.kakao.com/</a> 사이트 접속 &gt; 로그인 &gt; 앱 &gt; 앱 생성</strong>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/d73a5235-8706-4a58-8696-11ffe2ee8fae/image.png" alt=""></p>
<p><strong>2. 생성한 앱 클릭 &gt; 카카오맵 클릭 &gt; 사용 설정 활성화</strong>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/0a2efdbf-31ab-4f87-80df-4cd283512ead/image.png" alt=""></p>
<p><strong>3. 플랫폼 키 선택 &gt; JavaScript 키 추가 버튼 클릭</strong>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/fbce93a7-6522-4209-946f-fe3ab116bb0e/image.png" alt=""></p>
<p><strong>4. 키 이름 입력 &gt; JavaScript SDK 도메인 입력 후 저장</strong></p>
<p>도메인은 <a href="http://127.0.0.1:5500">http://127.0.0.1:5500</a> 으로 입력했다.
카카오 맵 API는 등록된 도메인에서만 동작하기 때문에 반드시 입력해야 한다.
로컬 개발 환경에서는 VS Code의 Live Server 확장을 사용했는데, Live Server가 기본적으로 127.0.0.1:5500 포트로 실행되기 때문에 해당 주소를 등록했다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/e89bd88e-787d-4a73-b493-7a73dec36495/image.png" alt=""></p>
<p><strong>5. 생성된 키 값 복사</strong>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/1aa3438c-ef6e-4e76-bd48-0b95bd8f97e5/image.png" alt=""></p>
<hr>
<h2 id="2-지도에-주소-마킹하기">2. 지도에 주소 마킹하기</h2>
<p>발급받은 JavaScript 키를 appkey 파라미터에 넣고, 원하는 좌표에 커스텀 마커를 표시하는 예제다.</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
    &lt;head&gt;
        &lt;meta charset=&quot;utf-8&quot; /&gt;
        &lt;title&gt;빵집 마커 띄우기&lt;/title&gt;
    &lt;/head&gt;
    &lt;body&gt;
        &lt;div id=&quot;map&quot; style=&quot;width: 100%; height: 500px&quot;&gt;&lt;/div&gt;

        &lt;!-- 카카오 맵 SDK 로드 (appkey에 발급받은 JavaScript 키 입력) --&gt;
        &lt;script
            type=&quot;text/javascript&quot;
            src=&quot;//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은_JavaScript_키&quot;
        &gt;&lt;/script&gt;

        &lt;script&gt;
            // 지도를 표시할 div와 옵션 설정
            var mapContainer = document.getElementById(&quot;map&quot;),
                mapOption = {
                    center: new kakao.maps.LatLng(37.566826, 126.9786567), // 서울시청 기준
                    level: 4, // 지도 확대 레벨 (숫자가 작을수록 확대)
                };

            // 지도 생성
            var map = new kakao.maps.Map(mapContainer, mapOption);

            // 1. 마커 이미지 경로, 크기, 기준점 설정
            var imageSrc = &quot;/assets/images/bakery.png&quot;,
                imageSize = new kakao.maps.Size(64, 64), // 마커 이미지 크기 (가로 64, 세로 64)
                // ⭐ 가로 정중앙(32), 세로 바닥(64)을 지도의 좌표점과 일치시킴
                imageOption = { offset: new kakao.maps.Point(32, 64) };

            // 2. 마커 이미지 객체 생성
            var markerImage = new kakao.maps.MarkerImage(
                    imageSrc,
                    imageSize,
                    imageOption,
                ),
                markerPosition = new kakao.maps.LatLng(37.566826, 126.9786567);

            // 3. 마커 생성 후 지도에 표시
            var marker = new kakao.maps.Marker({
                position: markerPosition,
                image: markerImage,
            });

            marker.setMap(map);
        &lt;/script&gt;
    &lt;/body&gt;
&lt;/html&gt;</code></pre>
<hr>
<h2 id="3-실행-결과">3. 실행 결과</h2>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/fda038c6-f925-4bb3-8bac-bc187a7423f0/image.png" alt=""></p>
<hr>
<h2 id="4-느낀-점">4. 느낀 점</h2>
<p>생각보다 초기 설정이 간단하고 공식 문서도 잘 정리되어 있어서 도입 난이도는 낮을 것 같다.
다만 JavaScript 키가 소스코드에 그대로 노출된다는 점이 신경 쓰였다. 순수 HTML/JS 환경에서는 Vite나 Webpack 같은 번들러가 없기 때문에 환경변수로 키를 숨기는 것이 불가능하다. 대신 아래 두 가지 방법을 조합해서 최소한의 보안을 유지할 수 있다.</p>
<ol>
<li>*<em>도메인 허용 설정으로 제한 *</em>
카카오 디벨로퍼스에서 허용 도메인을 엄격하게 설정하면, 키가 노출되더라도 등록되지 않은 도메인에서는 사용할 수 없다.</li>
<li><strong>config.js 분리 + .gitignore 등록</strong> 
키를 별도 파일로 분리하고 .gitignore에 추가해 깃헙에 올라가지 않도록 막는다.</li>
</ol>
<p>미니 프로젝트 수준에서는 이 두 가지 조합으로 충분할 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[4편 - [Spring Boot] 피자 가게 - 회원 관리 (회원가입 / 로그인)]]></title>
            <link>https://velog.io/@jhwest-dev/Spring-Boot-%ED%9A%8C%EC%9B%90-%EA%B4%80%EB%A6%AC-%EA%B0%80%EC%9E%85-%EB%A1%9C%EA%B7%B8%EC%9D%B8</link>
            <guid>https://velog.io/@jhwest-dev/Spring-Boot-%ED%9A%8C%EC%9B%90-%EA%B4%80%EB%A6%AC-%EA%B0%80%EC%9E%85-%EB%A1%9C%EA%B7%B8%EC%9D%B8</guid>
            <pubDate>Wed, 20 May 2026 07:41:20 GMT</pubDate>
            <description><![CDATA[<hr>
<h2 id="이-글에서-다룰-내용">이 글에서 다룰 내용</h2>
<ol>
<li>프로젝트 구조 설명</li>
<li>회원가입 백엔드<ul>
<li>DB 테이블 생성</li>
<li>MemberVO</li>
<li>DTO 생성 (Request / Response)</li>
<li>MemberMapper</li>
<li>MemberService</li>
<li>MemberController</li>
</ul>
</li>
<li>회원가입 프론트</li>
<li>로그인 백엔드<ul>
<li>MemberService</li>
<li>MemberController</li>
</ul>
</li>
<li>로그인 프론트</li>
<li>공통 기능 (common.js)</li>
<li>동작 확인</li>
</ol>
<hr>
<h2 id="1-프로젝트-구조-설명">1. 프로젝트 구조 설명</h2>
<h3 id="백엔드">백엔드</h3>
<pre><code>pizza-shop/
├── src/main/java/
│   └── com.pizzashop/
│       ├── controller/
│       │   ├── MenuController.java
│       │   └── MemberController.java
│       ├── dto/
│       │   ├── request/
│       │   │   ├── MemberRegisterRequest.java
│       │   │   └── MemberLoginRequest.java
│       │   └── response/
│       │       ├── ApiResponse.java
│       │       ├── MemberLoginResponse.java
│       ├── mapper/
│       │   ├── MenuMapper.java
│       │   └── MemberMapper.java
│       ├── service/
│       │   ├── MenuService.java
│       │   └── MemberService.java
│       └── vo/
│           ├── MenuVO.java
│           └── MemberVO.java</code></pre><table>
<thead>
<tr>
<th>패키지</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>controller</code></td>
<td>클라이언트 요청을 받아 응답을 반환</td>
</tr>
<tr>
<td><code>dto/request</code></td>
<td>클라이언트에서 받는 요청 데이터</td>
</tr>
<tr>
<td><code>dto/response</code></td>
<td>클라이언트에게 보내는 응답 데이터</td>
</tr>
<tr>
<td><code>mapper</code></td>
<td>MyBatis를 통해 DB와 직접 통신</td>
</tr>
<tr>
<td><code>service</code></td>
<td>비즈니스 로직 처리 (중복 확인, 비밀번호 검증 등)</td>
</tr>
<tr>
<td><code>vo</code></td>
<td>DB 테이블과 1:1 매핑되는 데이터 객체</td>
</tr>
</tbody></table>
<blockquote>
<p>Controller → Service → Mapper → DB 순서로 요청이 흘러간다.</p>
</blockquote>
<h3 id="프론트엔드">프론트엔드</h3>
<pre><code>PIZZA-SHOP/
├── css/
│   ├── style.css
│   └── auth.css
├── js/
│   ├── common.js
│   ├── menu.js
│   └── auth.js
├── index.html
├── login.html
└── register.html</code></pre><table>
<thead>
<tr>
<th>파일</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>auth.css</code></td>
<td>로그인 / 회원가입 페이지 전용 스타일</td>
</tr>
<tr>
<td><code>common.js</code></td>
<td>공통 기능 (updateNav, logout)</td>
</tr>
<tr>
<td><code>auth.js</code></td>
<td>로그인 / 회원가입 API 연동</td>
</tr>
<tr>
<td><code>login.html</code></td>
<td>로그인 페이지</td>
</tr>
<tr>
<td><code>register.html</code></td>
<td>회원가입 페이지</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-회원가입-백엔드">2. 회원가입 백엔드</h2>
<p>요청 흐름은 다음과 같다.</p>
<pre><code>클라이언트 → Controller → Service → Mapper → DB</code></pre><h3 id="db-테이블-생성">DB 테이블 생성</h3>
<p>회원 정보를 저장할 <code>members</code> 테이블을 먼저 생성한다.</p>
<pre><code class="language-sql">CREATE TABLE IF NOT EXISTS members (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(100) NOT NULL,
    name VARCHAR(50) NOT NULL,
    role VARCHAR(20) DEFAULT &#39;USER&#39;,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);</code></pre>
<table>
<thead>
<tr>
<th>컬럼</th>
<th>타입</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>id</code></td>
<td>INT</td>
<td>고유번호 (자동 증가)</td>
</tr>
<tr>
<td><code>username</code></td>
<td>VARCHAR(50)</td>
<td>로그인 아이디 (중복 불가)</td>
</tr>
<tr>
<td><code>password</code></td>
<td>VARCHAR(100)</td>
<td>비밀번호 (실무에서는 암호화 필요)</td>
</tr>
<tr>
<td><code>name</code></td>
<td>VARCHAR(50)</td>
<td>닉네임</td>
</tr>
<tr>
<td><code>role</code></td>
<td>VARCHAR(20)</td>
<td>권한 (USER / ADMIN, 기본값 USER)</td>
</tr>
<tr>
<td><code>created_at</code></td>
<td>TIMESTAMP</td>
<td>가입일 (자동 저장)</td>
</tr>
</tbody></table>
<hr>
<h3 id="membervo">MemberVO</h3>
<p>DB 테이블과 1:1로 매핑되는 데이터 객체다.</p>
<pre><code class="language-java">@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberVO {
    private int id;           // 고유번호
    private String username;  // 로그인 아이디
    private String password;  // 비밀번호 (실무에서는 암호화 필요)
    private String name;      // 닉네임
    private String role;      // 권한 (USER / ADMIN)
    private String createdAt; // 가입일
}</code></pre>
<table>
<thead>
<tr>
<th>어노테이션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@Data</code></td>
<td>getter, setter, toString 자동 생성</td>
</tr>
<tr>
<td><code>@NoArgsConstructor</code></td>
<td>기본 생성자 자동 생성</td>
</tr>
<tr>
<td><code>@AllArgsConstructor</code></td>
<td>전체 필드 생성자 자동 생성</td>
</tr>
</tbody></table>
<hr>
<h3 id="dto-생성">DTO 생성</h3>
<p>클라이언트와 주고받는 데이터를 명확하게 분리하기 위해 DTO를 사용한다.</p>
<blockquote>
<p>VO를 그대로 사용하면 클라이언트가 <code>role</code> 같은 민감한 필드를 임의로 넘길 수 있어 보안에 취약하다. DTO를 사용하면 필요한 필드만 주고받을 수 있다.</p>
</blockquote>
<p><strong>MemberRegisterRequest</strong> - 회원가입 요청 데이터</p>
<pre><code class="language-java">@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberRegisterRequest {
    private String username;
    private String password;
    private String name;
}</code></pre>
<p><strong>MemberLoginRequest</strong> - 로그인 요청 데이터</p>
<pre><code class="language-java">@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberLoginRequest {
    private String username;
    private String password;
}</code></pre>
<p><strong>ApiResponse</strong> - 공통 응답 데이터</p>
<pre><code class="language-java">@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse {
    private String result;  // &quot;ok&quot; 또는 &quot;fail&quot;
    private String message; // 성공 또는 실패 메시지
}</code></pre>
<p><strong>MemberLoginResponse</strong> - 로그인 응답 데이터</p>
<pre><code class="language-java">@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberLoginResponse {
    private String result;   // 실행 결과
    private String name;     // 사용자 이름
    private String role;     // 사용자 역할 (USER / ADMIN)
    private String username; // 사용자 아이디
}</code></pre>
<blockquote>
<p>로그인 응답에 <code>password</code>는 절대 포함하면 안 된다.</p>
</blockquote>
<hr>
<h3 id="membermapper">MemberMapper</h3>
<pre><code class="language-java">@Mapper
public interface MemberMapper {

    // 아이디 중복 체크 / 로그인 시 회원 조회
    @Select(&quot;SELECT * FROM members WHERE username = #{username}&quot;)
    MemberVO findByUsername(String username);

    // 회원가입 INSERT
    // id, role, created_at은 DB에서 자동 생성되므로 쿼리에서 제외
    @Insert(&quot;INSERT INTO members(username, password, name) &quot;
            + &quot;VALUES (#{username}, #{password}, #{name})&quot;)
    @Options(useGeneratedKeys = true, keyProperty = &quot;id&quot;)
    int insert(MemberVO member);
}</code></pre>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>findByUsername</code></td>
<td>아이디로 회원 조회 (중복 체크 / 로그인에 사용)</td>
</tr>
<tr>
<td><code>insert</code></td>
<td>회원 정보 DB에 저장</td>
</tr>
</tbody></table>
<blockquote>
<p><code>@Options(useGeneratedKeys = true, keyProperty = &quot;id&quot;)</code> 는 INSERT 후 자동 생성된 id 값을 MemberVO.id에 다시 넣어준다.</p>
</blockquote>
<hr>
<h3 id="memberservice---회원가입">MemberService - 회원가입</h3>
<pre><code class="language-java">@Service
public class MemberService {

    @Autowired
    private MemberMapper memberMapper;

    // 1. 같은 아이디가 DB에 존재하는지 조회
    // 2. 있으면 false 반환 -&gt; Controller에서 &quot;중복 아이디&quot; 응답
    // 3. 없으면 INSERT 실행 -&gt; 성공 시 true 반환
    public boolean register(MemberRegisterRequest request) {
        MemberVO existing = memberMapper.findByUsername(request.getUsername());
        if (existing != null) {
            return false; // 중복 아이디 -&gt; 가입 거절
        }

        // Request -&gt; VO 변환
        MemberVO member = new MemberVO();
        member.setUsername(request.getUsername());
        member.setPassword(request.getPassword());
        member.setName(request.getName());

        return memberMapper.insert(member) &gt; 0;
    }
}</code></pre>
<blockquote>
<p><code>role</code>과 <code>created_at</code>은 DB에서 자동으로 설정되므로 따로 넣지 않아도 된다.</p>
</blockquote>
<hr>
<h3 id="membercontroller---회원가입">MemberController - 회원가입</h3>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api/member&quot;)
@CrossOrigin(origins = &quot;*&quot;)
public class MemberController {

    @Autowired
    private MemberService memberService;

    // POST /api/member/register
    // 요청 Body : {&quot;username&quot;: &quot;hong&quot;, &quot;password&quot;: &quot;1234&quot;, &quot;name&quot;: &quot;홍길동&quot;}
    // 응답 : {&quot;result&quot;: &quot;ok&quot;, &quot;message&quot;: &quot;회원가입 완료&quot;}
    @PostMapping(&quot;/register&quot;)
    public ApiResponse register(@RequestBody MemberRegisterRequest request) {
        boolean ok = memberService.register(request);
        if (ok) {
            return new ApiResponse(&quot;ok&quot;, &quot;회원가입 완료&quot;);
        } else {
            return new ApiResponse(&quot;fail&quot;, &quot;이미 사용 중인 아이디입니다.&quot;);
        }
    }
}</code></pre>
<table>
<thead>
<tr>
<th>어노테이션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@RestController</code></td>
<td>Controller + ResponseBody, JSON 자동 반환</td>
</tr>
<tr>
<td><code>@RequestMapping</code></td>
<td>공통 URL prefix <code>/api/member</code> 설정</td>
</tr>
<tr>
<td><code>@CrossOrigin</code></td>
<td>프론트에서 API 호출 허용 (CORS 설정)</td>
</tr>
<tr>
<td><code>@PostMapping</code></td>
<td>POST 요청 처리</td>
</tr>
<tr>
<td><code>@RequestBody</code></td>
<td>JSON 요청 Body를 DTO로 변환</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-회원가입-프론트">3. 회원가입 프론트</h2>
<h3 id="registerhtml">register.html</h3>
<pre><code class="language-html">&lt;div class=&quot;auth-container&quot;&gt;
    &lt;div class=&quot;auth-box&quot;&gt;
        &lt;h2&gt;회원가입&lt;/h2&gt;
        &lt;div class=&quot;form-group&quot;&gt;
            &lt;label&gt;아이디&lt;/label&gt;
            &lt;input type=&quot;text&quot; id=&quot;regId&quot; placeholder=&quot;아이디를 입력하세요&quot; /&gt;
        &lt;/div&gt;
        &lt;div class=&quot;form-group&quot;&gt;
            &lt;label&gt;비밀번호&lt;/label&gt;
            &lt;input type=&quot;password&quot; id=&quot;regPw&quot; placeholder=&quot;비밀번호를 입력하세요&quot; /&gt;
        &lt;/div&gt;
        &lt;div class=&quot;form-group&quot;&gt;
            &lt;label&gt;이름&lt;/label&gt;
            &lt;input type=&quot;text&quot; id=&quot;regName&quot; placeholder=&quot;이름을 입력하세요&quot; /&gt;
        &lt;/div&gt;
        &lt;button class=&quot;btn-submit&quot; onclick=&quot;register()&quot;&gt;회원가입&lt;/button&gt;
        &lt;p class=&quot;auth-link&quot;&gt;
            이미 계정이 있으신가요? &lt;a href=&quot;login.html&quot;&gt;로그인&lt;/a&gt;
        &lt;/p&gt;
    &lt;/div&gt;
&lt;/div&gt;</code></pre>
<h3 id="authjs---회원가입">auth.js - 회원가입</h3>
<pre><code class="language-javascript">const AUTH_API = &quot;http://localhost:8080/api/member&quot;;

function register() {
    const id = document.getElementById(&quot;regId&quot;).value.trim();
    const pw = document.getElementById(&quot;regPw&quot;).value.trim();
    const name = document.getElementById(&quot;regName&quot;).value.trim();

    if (!id || !pw || !name) {
        alert(&quot;모든 항목을 입력해 주세요.&quot;);
        return;
    }

    fetch(`${AUTH_API}/register`, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ username: id, password: pw, name: name }),
    })
        .then((res) =&gt; res.json())
        .then((data) =&gt; {
            if (data.result === &quot;ok&quot;) {
                alert(&quot;가입 완료! 로그인 페이지로 이동합니다.&quot;);
                setTimeout(() =&gt; {
                    location.href = &quot;login.html&quot;;
                }, 1500);
            } else {
                alert(&quot;가입 실패 : 아이디를 확인해 주세요.&quot;);
            }
        })
        .catch(() =&gt; alert(&quot;서버 오류&quot;));
}</code></pre>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>입력값 검증</td>
<td>빈 값이면 alert 후 종료</td>
</tr>
<tr>
<td><code>fetch</code> POST 요청</td>
<td>username, password, name을 JSON으로 전송</td>
</tr>
<tr>
<td>응답 처리</td>
<td><code>result === &quot;ok&quot;</code> 면 로그인 페이지로 이동</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-로그인-백엔드">4. 로그인 백엔드</h2>
<h3 id="memberservice---로그인">MemberService - 로그인</h3>
<pre><code class="language-java">// 1. 아이디로 회원 조회
// 2. 비밀번호 일치하면 MemberVO 반환 (로그인 성공)
// 3. 아이디가 없거나 비밀번호 틀리면 null 반환 (로그인 실패)
public MemberVO login(String username, String password) {
    MemberVO member = memberMapper.findByUsername(username);
    if (member != null &amp;&amp; member.getPassword().equals(password)) {
        return member;
    }
    return null;
}</code></pre>
<h3 id="membercontroller---로그인">MemberController - 로그인</h3>
<pre><code class="language-java">// POST /api/member/login
// 요청 Body : {&quot;username&quot;: &quot;hong&quot;, &quot;password&quot;: &quot;1234&quot;}
// 응답(성공) : {&quot;result&quot;: &quot;ok&quot;, &quot;name&quot;: &quot;홍길동&quot;, &quot;role&quot;: &quot;USER&quot;, &quot;username&quot;: &quot;hong&quot;}
// 응답(실패) : {&quot;result&quot;: &quot;fail&quot;, &quot;name&quot;: null, &quot;role&quot;: null, &quot;username&quot;: null}
@PostMapping(&quot;/login&quot;)
public MemberLoginResponse login(@RequestBody MemberLoginRequest request) {
    MemberVO member = memberService.login(request.getUsername(), request.getPassword());
    if (member != null) {
        return new MemberLoginResponse(&quot;ok&quot;, member.getName(), member.getRole(), member.getUsername());
    } else {
        return new MemberLoginResponse(&quot;fail&quot;, null, null, null);
    }
}</code></pre>
<hr>
<h2 id="5-로그인-프론트">5. 로그인 프론트</h2>
<h3 id="loginhtml">login.html</h3>
<pre><code class="language-html">&lt;div class=&quot;auth-container&quot;&gt;
    &lt;div class=&quot;auth-box&quot;&gt;
        &lt;h2&gt;로그인&lt;/h2&gt;
        &lt;div class=&quot;form-group&quot;&gt;
            &lt;label&gt;아이디&lt;/label&gt;
            &lt;input type=&quot;text&quot; id=&quot;loginId&quot; placeholder=&quot;아이디를 입력하세요&quot; /&gt;
        &lt;/div&gt;
        &lt;div class=&quot;form-group&quot;&gt;
            &lt;label&gt;비밀번호&lt;/label&gt;
            &lt;input type=&quot;password&quot; id=&quot;loginPw&quot; placeholder=&quot;비밀번호를 입력하세요&quot; /&gt;
        &lt;/div&gt;
        &lt;button class=&quot;btn-submit&quot; onclick=&quot;login()&quot;&gt;로그인&lt;/button&gt;
        &lt;p class=&quot;auth-link&quot;&gt;
            계정이 없으신가요? &lt;a href=&quot;register.html&quot;&gt;회원가입&lt;/a&gt;
        &lt;/p&gt;
    &lt;/div&gt;
&lt;/div&gt;</code></pre>
<h3 id="authjs---로그인">auth.js - 로그인</h3>
<pre><code class="language-javascript">function login() {
    const id = document.getElementById(&quot;loginId&quot;).value.trim();
    const pw = document.getElementById(&quot;loginPw&quot;).value.trim();

    if (!id || !pw) {
        alert(&quot;아이디와 비밀번호를 입력해 주세요.&quot;);
        return;
    }

    fetch(`${AUTH_API}/login`, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ username: id, password: pw }),
    })
        .then((res) =&gt; res.json())
        .then((data) =&gt; {
            if (data.result === &quot;ok&quot;) {
                localStorage.setItem(&quot;loginUser&quot;, data.name);
                localStorage.setItem(&quot;loginRole&quot;, data.role);
                localStorage.setItem(&quot;loginUserId&quot;, data.username);
                alert(`${data.name}님 환영합니다😊`);
                setTimeout(() =&gt; {
                    location.href = &quot;index.html&quot;;
                }, 1500);
            } else {
                alert(&quot;아이디 또는 비밀번호가 틀렸습니다.&quot;);
            }
        })
        .catch(() =&gt; alert(&quot;서버 오류&quot;));
}</code></pre>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>입력값 검증</td>
<td>빈 값이면 alert 후 종료</td>
</tr>
<tr>
<td><code>fetch</code> POST 요청</td>
<td>username, password를 JSON으로 전송</td>
</tr>
<tr>
<td>로그인 성공</td>
<td>name, role, username을 localStorage에 저장 후 메인으로 이동</td>
</tr>
<tr>
<td>로그인 실패</td>
<td>오류 메시지 alert</td>
</tr>
</tbody></table>
<blockquote>
<p>로그인 성공 시 <code>localStorage</code>에 사용자 정보를 저장해두면 다른 페이지에서도 로그인 상태를 유지할 수 있다.</p>
</blockquote>
<hr>
<h2 id="6-공통-기능-commonjs">6. 공통 기능 (common.js)</h2>
<pre><code class="language-javascript">// nav 업데이트
function updateNav() {
    const isLoggedIn = localStorage.getItem(&quot;loginUser&quot;);
    const loginRole = localStorage.getItem(&quot;loginRole&quot;);
    const navArea = document.getElementById(&quot;navArea&quot;);

    if (isLoggedIn) {
        const adminMenu = loginRole === &quot;ADMIN&quot; ? `&lt;a href=&quot;admin.html&quot;&gt;메뉴 관리&lt;/a&gt;` : &quot;&quot;;
        const cartMenu = loginRole === &quot;USER&quot; ? `&lt;a href=&quot;cart.html&quot;&gt;장바구니&lt;/a&gt;` : &quot;&quot;;

        navArea.innerHTML = `
            &lt;a href=&quot;index.html&quot;&gt;메뉴&lt;/a&gt;
            ${adminMenu}
            ${cartMenu}
            &lt;a href=&quot;#&quot; onclick=&quot;logout()&quot;&gt;로그아웃&lt;/a&gt;
        `;
    }
}

// 로그아웃
function logout() {
    localStorage.clear();
    location.href = &quot;login.html&quot;;
}</code></pre>
<blockquote>
<p>로그인 상태면 역할에 따라 nav가 동적으로 바뀐다. ADMIN이면 메뉴 관리, USER면 장바구니가 추가된다.</p>
</blockquote>
<hr>
<h2 id="7-동작-확인">7. 동작 확인</h2>
<p>📸 <em>[스크린샷 - 회원가입 화면]</em>
아이디, 비밀번호, 이름을 입력하고 회원가입 버튼을 클릭하면 가입 완료 알림이 뜨고 자동으로 로그인 페이지로 이동한다. </p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/635ecf05-c3a4-4d7a-aa1b-674608ada6cb/image.png" alt=""></p>
<p>📸 *[스크린샷 - 로그인 화면]<br>*아이디와 비밀번호를 입력하고 로그인 버튼을 클릭하면 환영 메시지가 뜨고 메인 페이지로 이동한다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/a0eae89d-6867-43bb-8ae8-81aa5d147ff0/image.png" alt=""></p>
<p>📸 <em>[스크린샷 - 로그인 성공 후 nav 변경된 화면]</em>
로그인 성공 시 localStorage에 사용자 정보가 저장되고, nav가 역할에 따라 동적으로 변경된다. USER면 장바구니, ADMIN이면 메뉴 관리 링크가 추가된다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/58ec7245-e004-496a-a579-fedcb6867674/image.png" alt=""></p>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 글에서는 회원가입과 로그인 기능을 백엔드와 프론트엔드로 나눠서 구현해봤다.</p>
<ul>
<li>DTO로 요청/응답 데이터를 명확하게 분리</li>
<li>MyBatis로 DB 연동</li>
<li>localStorage로 로그인 상태 유지</li>
</ul>
<p>다음 편에서는 관리자 페이지에서 메뉴 CRUD 기능을 구현할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[3편 - [Spring Boot] 피자 가게 - 프론트 연동하기 (HTML + CSS + JavaScript)]]></title>
            <link>https://velog.io/@jhwest-dev/Spring-Boot-%ED%94%BC%EC%9E%90-%EA%B0%80%EA%B2%8C-%ED%94%84%EB%A1%A0%ED%8A%B8-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/Spring-Boot-%ED%94%BC%EC%9E%90-%EA%B0%80%EA%B2%8C-%ED%94%84%EB%A1%A0%ED%8A%B8-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 18 May 2026 14:58:03 GMT</pubDate>
            <description><![CDATA[<h2 id="이-글에서-다룰-내용">이 글에서 다룰 내용</h2>
<ol>
<li>프로젝트 구조 설명</li>
<li>index.html 생성</li>
<li>style.css 생성</li>
<li>menu.js 생성</li>
<li>CRUD 기능 구현<ul>
<li>전체 메뉴 조회 (GET)</li>
</ul>
</li>
</ol>
<br>
---


<h2 id="1-프로젝트-구조-설명">1. 프로젝트 구조 설명</h2>
<pre><code>PIZZA-SHOP/
├── css/
│   └── style.css
├── js/
│   └── menu.js
└── index.html</code></pre><hr>
<h2 id="2-indexhtml-생성">2. index.html 생성</h2>
<ul>
<li>index.html은 피자 메뉴 목록을 보여주는 메인 페이지다.</li>
</ul>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang=&quot;ko&quot;&gt;
    &lt;head&gt;
        &lt;meta charset=&quot;UTF-8&quot; /&gt;
        &lt;title&gt;PIZZA&lt;/title&gt;
        &lt;link rel=&quot;stylesheet&quot; href=&quot;css/style.css&quot; /&gt;
    &lt;/head&gt;
    &lt;body&gt;
        &lt;!-- 메뉴 목록 화면 --&gt;
        &lt;header&gt;
            &lt;h1&gt;PIZZA SHOP&lt;/h1&gt;
            &lt;nav id=&quot;navArea&quot;&gt;
                &lt;a href=&quot;index.html&quot;&gt;메뉴&lt;/a&gt;
                &lt;a href=&quot;login.html&quot;&gt;로그인&lt;/a&gt;
                &lt;a href=&quot;register.html&quot;&gt;회원가입&lt;/a&gt;
            &lt;/nav&gt;
        &lt;/header&gt;

        &lt;div class=&quot;hero&quot;&gt;
            &lt;h2&gt;오늘의 피자 한 입 🍕&lt;/h2&gt;
            &lt;p&gt;맛있는 피자를 즐겨보세요.&lt;/p&gt;
        &lt;/div&gt;

        &lt;div class=&quot;filter&quot;&gt;
            &lt;button class=&quot;active&quot; onclick=&quot;filterMenu(&#39;전체&#39;, this)&quot;&gt;전체&lt;/button&gt;
            &lt;button onclick=&quot;filterMenu(&#39;클래식&#39;, this)&quot;&gt;클래식&lt;/button&gt;
            &lt;button onclick=&quot;filterMenu(&#39;프리미엄&#39;, this)&quot;&gt;프리미엄&lt;/button&gt;
            &lt;button onclick=&quot;filterMenu(&#39;사이드&#39;, this)&quot;&gt;사이드&lt;/button&gt;
        &lt;/div&gt;

        &lt;div class=&quot;menu-grid&quot; id=&quot;menuGrid&quot;&gt;&lt;/div&gt;

        &lt;footer&gt;2026 Pizza Shop. All rights reserved.&lt;/footer&gt;
        &lt;script src=&quot;js/menu.js&quot;&gt;&lt;/script&gt;
    &lt;/body&gt;
&lt;/html&gt;</code></pre>
<table>
<thead>
<tr>
<th>태그</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>&lt;header&gt;</code></td>
<td>로고 + 네비게이션 메뉴</td>
</tr>
<tr>
<td><code>&lt;div class=&quot;hero&quot;&gt;</code></td>
<td>메인 배너 영역</td>
</tr>
<tr>
<td><code>&lt;div class=&quot;filter&quot;&gt;</code></td>
<td>카테고리 필터 버튼</td>
</tr>
<tr>
<td><code>&lt;div class=&quot;menu-grid&quot;&gt;</code></td>
<td>메뉴 카드가 동적으로 들어오는 영역</td>
</tr>
</tbody></table>
<blockquote>
<p><code>&lt;div class=&quot;menu-grid&quot; id=&quot;menuGrid&quot;&gt;</code> 는 비어있는 상태로 시작하고 menu.js에서 API를 호출해 동적으로 메뉴 카드를 채워준다.</p>
</blockquote>
<hr>
<h2 id="3-stylecss-생성">3. style.css 생성</h2>
<p>style.css는 피자 쇼핑몰의 전체 디자인을 담당한다.</p>
<pre><code class="language-css">/* 공통 스타일 */
:root {
    --color-bg: #fffdf0;
    --color-dark: #2b1f1d;
    --color-primary: #ff5252;
    --color-accent: #ffb74d;
    --color-light: #ffffff;
    --color-border: #f0e6db;
    --color-text: #4a3b32;
    --color-muted: #a1887f;
    --font-main: &quot;Ansungtangmyeon&quot;, &quot;Comic Sans MS&quot;, &quot;Apple SD Gothic Neo&quot;, sans-serif;
    --radius: 20px;
    --shadow: 0 8px 24px rgba(235, 190, 160, 0.2);
    --transition: 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}</code></pre>
<blockquote>
<p>CSS 변수(<code>:root</code>)를 사용하면 색상, 그림자, 애니메이션 등을 한 곳에서 관리할 수 있어 유지보수가 편리하다.</p>
</blockquote>
<table>
<thead>
<tr>
<th>변수</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>--color-bg</code></td>
<td>페이지 배경색 (따뜻한 아이보리)</td>
</tr>
<tr>
<td><code>--color-accent</code></td>
<td>주요 포인트 색상 (주황)</td>
</tr>
<tr>
<td><code>--color-primary</code></td>
<td>강조 색상 (빨강)</td>
</tr>
<tr>
<td><code>--shadow</code></td>
<td>카드 그림자 스타일</td>
</tr>
<tr>
<td><code>--transition</code></td>
<td>호버 애니메이션 곡선</td>
</tr>
</tbody></table>
<hr>
<h3 id="헤더--네비게이션">헤더 / 네비게이션</h3>
<pre><code class="language-css">header {
    background: var(--color-bg);
    padding: 20px 40px;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

nav a {
    color: var(--color-primary);
    text-decoration: none;
    margin-left: 20px;
    font-size: 15px;
    font-weight: bold;
}</code></pre>
<blockquote>
<p><code>display: flex</code> + <code>justify-content: space-between</code>으로 로고는 왼쪽, 네비게이션은 오른쪽에 배치한다.</p>
</blockquote>
<hr>
<h3 id="필터-버튼">필터 버튼</h3>
<pre><code class="language-css">.filter {
    display: flex;
    justify-content: center;
    gap: 10px;
    padding: 30px 20px 10px;
}

.filter button {
    padding: 8px 20px;
    border: 2px solid var(--color-accent);
    border-radius: 20px;
    background: var(--color-bg);
    color: var(--color-accent);
    cursor: pointer;
}

.filter button:hover,
.filter button.active {
    background: var(--color-accent);
    color: var(--color-bg);
}</code></pre>
<table>
<thead>
<tr>
<th>상태</th>
<th>스타일</th>
</tr>
</thead>
<tbody><tr>
<td>기본</td>
<td>흰 배경 + 주황 테두리</td>
</tr>
<tr>
<td>hover / active</td>
<td>주황 배경 + 흰 글씨</td>
</tr>
</tbody></table>
<hr>
<h3 id="메뉴-그리드">메뉴 그리드</h3>
<pre><code class="language-css">.menu-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
    gap: 24px;
    padding: 20px 40px 60px;
    max-width: 1100px;
    margin: 0 auto;
}</code></pre>
<blockquote>
<p><code>repeat(auto-fill, minmax(230px, 1fr))</code>를 사용하면 별도의 미디어 쿼리 없이도 화면 크기에 따라 자동으로 열 수가 조정되는 반응형 그리드가 완성된다.</p>
</blockquote>
<hr>
<h3 id="메뉴-카드">메뉴 카드</h3>
<pre><code class="language-css">.menu-card {
    background: var(--color-light);
    border-radius: 12px;
    overflow: hidden;
    box-shadow: var(--shadow);
    transition: var(--transition);
}

.menu-card:hover {
    transform: translateY(-4px);
}

.menu-card .img-area {
    background: var(--color-light);
    height: 160px;
    overflow: hidden;
    text-align: center;
    line-height: 160px;
    font-size: 60px;
}</code></pre>
<table>
<thead>
<tr>
<th>속성</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>overflow: hidden</code></td>
<td>이미지가 카드 밖으로 삐져나오지 않게 함</td>
</tr>
<tr>
<td><code>transition</code></td>
<td>호버 시 부드러운 애니메이션</td>
</tr>
<tr>
<td><code>transform: translateY(-4px)</code></td>
<td>호버 시 카드가 살짝 위로 뜨는 효과</td>
</tr>
<tr>
<td><code>line-height: 160px</code></td>
<td>이미지 없을 때 이모지를 세로 중앙 정렬</td>
</tr>
</tbody></table>
<hr>
<hr>
<h2 id="4-menujs-생성">4. menu.js 생성</h2>
<p>menu.js는 API를 호출해 메뉴 데이터를 받아오고, 화면에 카드를 동적으로 렌더링하는 역할을 한다.</p>
<pre><code class="language-javascript">const API = &quot;http://localhost:8080/api/menu&quot;;
let allMenus = []; // 전체 메뉴 캐시

window.onload = function () {
    loadMenus();
    updateNav();
};</code></pre>
<blockquote>
<p><code>allMenus</code>에 전체 메뉴를 캐싱해두면 카테고리 필터링 시 API를 다시 호출하지 않아도 된다.</p>
</blockquote>
<hr>
<h3 id="전체-메뉴-불러오기">전체 메뉴 불러오기</h3>
<pre><code class="language-javascript">function loadMenus() {
    fetch(API)
        .then((res) =&gt; res.json())
        .then((data) =&gt; {
            allMenus = data;
            renderMenus(data);
        })
        .catch((err) =&gt; console.error(&quot;메뉴 불러오기 실패: &quot;, err));
}</code></pre>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>fetch(API)</code></td>
<td>GET 요청으로 메뉴 목록을 가져옴</td>
</tr>
<tr>
<td><code>res.json()</code></td>
<td>응답을 JSON으로 파싱</td>
</tr>
<tr>
<td><code>allMenus = data</code></td>
<td>필터링에 사용할 전체 메뉴 캐싱</td>
</tr>
<tr>
<td><code>renderMenus(data)</code></td>
<td>화면에 카드 렌더링</td>
</tr>
</tbody></table>
<hr>
<h3 id="카드-렌더링">카드 렌더링</h3>
<pre><code class="language-javascript">function renderMenus(menus) {
    const grid = document.getElementById(&quot;menuGrid&quot;);
    grid.innerHTML = &quot;&quot;;

    if (menus.length === 0) {
        grid.innerHTML = &#39;&lt;p style=&quot;text-align:center; color:#999; padding:40px;&quot;&gt;메뉴가 없습니다.&lt;/p&gt;&#39;;
        return;
    }

    menus.forEach((menu) =&gt; {
        const card = document.createElement(&quot;div&quot;);
        card.className = &quot;menu-card&quot;;

        const imgContent = menu.imgUrl
            ? `&lt;img src=&quot;${menu.imgUrl}&quot; alt=&quot;${menu.name}&quot; style=&quot;width:100%; height:160px; object-fit:contain; display:block;&quot;&gt;`
            : getCategoryEmoji(menu.category);

        card.innerHTML = `
            &lt;div class=&quot;img-area&quot;&gt;${imgContent}&lt;/div&gt;
            &lt;div class=&quot;info&quot;&gt;
                &lt;div class=&quot;category&quot;&gt;${menu.category}&lt;/div&gt;
                &lt;h3&gt;${menu.name}&lt;/h3&gt;
                &lt;div class=&quot;price&quot;&gt;${menu.price.toLocaleString()}원&lt;/div&gt;
                &lt;div class=&quot;btn-group&quot;&gt;
                    &lt;button onclick=&quot;order(&#39;${menu.name}&#39;, ${menu.price})&quot;&gt;주문하기&lt;/button&gt;
                    &lt;button onclick=&quot;addToCart(${menu.id}, &#39;${menu.name}&#39;, ${menu.price})&quot;&gt;장바구니 담기&lt;/button&gt;
                &lt;/div&gt;
            &lt;/div&gt;
        `;
        grid.appendChild(card);
    });
}</code></pre>
<blockquote>
<p><code>imgUrl</code>이 있으면 이미지를, 없으면 카테고리별 이모지를 대신 보여준다.</p>
</blockquote>
<pre><code class="language-javascript">function getCategoryEmoji(category) {
    if (category == &quot;클래식&quot;) return &quot;⭐&quot;;
    if (category == &quot;프리미엄&quot;) return &quot;👑&quot;;
    if (category == &quot;사이드&quot;) return &quot;🍟&quot;;
}</code></pre>
<hr>
<h3 id="카테고리-필터링">카테고리 필터링</h3>
<pre><code class="language-javascript">function filterMenu(category, event) {
    document.querySelectorAll(&quot;.filter button&quot;).forEach((btn) =&gt; {
        btn.classList.remove(&quot;active&quot;);
    });
    event.target.classList.add(&quot;active&quot;);

    if (category === &quot;전체&quot;) {
        renderMenus(allMenus);
    } else {
        const filtered = allMenus.filter((m) =&gt; m.category === category);
        renderMenus(filtered);
    }
}</code></pre>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>classList.remove(&quot;active&quot;)</code></td>
<td>모든 버튼에서 active 제거</td>
</tr>
<tr>
<td><code>event.target.classList.add(&quot;active&quot;)</code></td>
<td>클릭한 버튼에만 active 추가</td>
</tr>
<tr>
<td><code>allMenus.filter(...)</code></td>
<td>캐싱된 데이터에서 카테고리 필터링 (API 재호출 없음)</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-crud-기능-구현---①-전체-메뉴-조회-get">5. CRUD 기능 구현 - ① 전체 메뉴 조회 (GET)</h2>
<h3 id="전체-메뉴-조회-get">전체 메뉴 조회 (GET)</h3>
<p>페이지가 로드되면 자동으로 API를 호출해 전체 메뉴를 가져온다.
<img src="https://velog.velcdn.com/images/jhwest-dev/post/6f15a30b-0bab-47fc-bcd1-8babca2bd267/image.png" alt=""></p>
<p>카테고리 버튼을 클릭하면 해당 카테고리만 필터링되어 보여진다.</p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/2b87126c-b1ad-4f9c-a28f-a191fb2dec81/image.png" alt=""></p>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 글에서는 피자 쇼핑몰 프론트엔드의 기본 구조를 만들어봤다.</p>
<ul>
<li>index.html로 화면 구조 잡기</li>
<li>style.css로 디자인 입히기</li>
<li>menu.js로 API 연동 및 동적 렌더링</li>
</ul>
<p>다음 편에서는 로그인/회원가입 기능을 구현할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[2편 - [Spring Boot] 피자 가게 - 메뉴 CRUD 구현하기 (백엔드 + Postman 테스트)]]></title>
            <link>https://velog.io/@jhwest-dev/Spring-Boot-%ED%94%BC%EC%9E%90-%EA%B0%80%EA%B2%8C-CRUD-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jhwest-dev/Spring-Boot-%ED%94%BC%EC%9E%90-%EA%B0%80%EA%B2%8C-CRUD-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 14 May 2026 07:23:27 GMT</pubDate>
            <description><![CDATA[<hr>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/f3ac4988-e02c-497a-98e0-0f02ead00c59/image.png" alt=""></p>
<p>학원에서 위 이미지와 같이 Spring Boot로 coffee shop 페이지를 만들어 보았다.<br>배운 내용을 제대로 익히기 위해 복습 겸 피자 가게 페이지를 직접 만들어보려 한다.</p>
<h2 id="이-글에서-다룰-내용">이 글에서 다룰 내용</h2>
<ul>
<li>피자 메뉴 전체 조회</li>
<li>메뉴 단건 조회</li>
<li>메뉴 등록</li>
<li>메뉴 수정</li>
<li>메뉴 삭제</li>
</ul>
<h2 id="사용-기술-스택">사용 기술 스택</h2>
<ul>
<li>Spring Boot</li>
<li>MySQL</li>
<li>Lombok</li>
<li>Postman</li>
<li>MyBatis</li>
</ul>
<hr>
<h2 id="1-mysql-에서-db-생성-및-테이블-생성">1. MySQL 에서 DB 생성 및 테이블 생성</h2>
<p><strong>1. 쿼리 작성 및 실행</strong></p>
<pre><code class="language-sql">-- pizza_db 생성
create database if not exists pizza_db default character set utf8mb4;

-- pizza_db 사용
use pizza_db;

-- menu 테이블 생성
create table menu(
    id int auto_increment primary key,
    name varchar(100) not null,
    size varchar(10) not null,
    price int not null,
    category varchar(50) not null,
    img_url varchar(255)
)
</code></pre>
<p><strong>2. pizza_db의 menu 테이블 생성 완료</strong>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/92bf8cd9-c8df-4555-910e-77ce999cf3ad/image.png" alt=""></p>
<hr>
<h2 id="2-applicationproperties-설정-db-연결-mybatis">2. application.properties 설정 (DB 연결, MyBatis)</h2>
<pre><code class="language-java"># 프로젝트 이름
spring.application.name=pizza-shop

# DB 연결 설정
# url - 어떤 DB에 연결할지 (localhost:3306 = 내 컴퓨터의 MySql_db = 사용 할 DB 이름)
spring.datasource.url=jdbc:mysql://localhost:3306/pizza_db?useSSL=false&amp;serverTimezone=Asia/Seoul

# username/password = MySql 로그인 정보
spring.datasource.username=&lt;username&gt;
spring.datasource.password=&lt;password&gt;

# driver = Mysql 전용 드라이버 클래스 (Mysql 8.x 버전용)
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# Mybatis 설정
# VO 클래스가 있는 패키지 등록 -&gt; 쿼리 결과를 VO 객체로 자동 변환해줌
mybatis.type-aliases-package=com.example.demo.vo
# DB 컬럼명 (img_url)을 Java 필드명(imgUrl)으로 자동 변환 (언더스코어 -&gt; 카멜케이스)
mybatis.configuration.map-underscore-to-camel-case=true
</code></pre>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/76d16dc8-5334-4267-9548-0877ec9e036f/image.png" alt=""></p>
<hr>
<h2 id="3-프로젝트-생성-및-패키지-생성">3. 프로젝트 생성 및 패키지 생성</h2>
<ol>
<li>controller - 클라이언트 요청을 받고 응답을 반환</li>
<li>service - 비즈니스 로직 담당</li>
<li>vo - 데이터를 담는 객체 (DB 테이블과 매핑)</li>
<li>mapper - DB에 접근해서 쿼리 실행
<img src="https://velog.velcdn.com/images/jhwest-dev/post/5da3fa21-6ac9-4929-b51a-a042798d6301/image.png" alt=""></li>
</ol>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/b0b190b1-a9a8-49b2-af7d-08b130fb90a5/image.png" alt=""></p>
<hr>
<h2 id="4-menuvo-클래스-생성">4. MenuVO 클래스 생성</h2>
<ul>
<li>DB 테이블과 매핑되는 데이터 객체<pre><code class="language-java">package com.pizzashop.vo;
</code></pre>
</li>
</ul>
<p>import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;</p>
<p>@Data
@NoArgsConstructor
@AllArgsConstructor
public class MenuVO {</p>
<pre><code>private int id; // 고유 아이디
private String name; // 메뉴명
private String size; // 피자 사이즈 
private int price; // 가격
private String category; // 카테고리
private String imgUrl; // 이미지 URL</code></pre><p>}</p>
<pre><code>| 어노테이션 | 기능 |
|-----------|------|
| `@Data` | getter, setter, toString 등을 자동 생성 |
| `@NoArgsConstructor` | 기본 생성자 자동 생성 (매개변수 없음) |
| `@AllArgsConstructor` | 전체 필드 생성자 자동 생성 (모든 필드 포함) |

#### @Data
```java
// 이걸 직접 안 써도 됨
public String getName() { return name; }
public void setName(String name) { this.name = name; }</code></pre><h4 id="noargsconstructor">@NoArgsConstructor</h4>
<pre><code class="language-java">// 이걸 자동 생성
public MenuVO() {}</code></pre>
<h4 id="allargsconstructor">@AllArgsConstructor</h4>
<pre><code class="language-java">// 이걸 자동 생성
public MenuVO(int id, String name, String size, int price, String category, String imgUrl) {}</code></pre>
<hr>
<h2 id="5-menumapper-인터페이스-생성">5. MenuMapper 인터페이스 생성</h2>
<h4 id="mapper를-인터페이스로-생성하는-이유">Mapper를 인터페이스로 생성하는 이유</h4>
<p>직접 구현 코드를 작성하지 않아도 MyBatis가 자동으로 구현체를 만들어주기 때문이다.
개발자는 어떤 쿼리를 실행할지만 정의하면 된다.</p>
<h4 id="mapper">@Mapper</h4>
<p>MyBatis에게 &quot;이 인터페이스가 Mapper입니다&quot; 라고 알려주는 어노테이션이다.
Spring이 자동으로 구현체를 생성하고 Bean으로 등록해준다.</p>
<h4 id="optionsusegeneratedkeys--true-keyproperty--id">@Options(useGeneratedKeys = true, keyProperty = &quot;id&quot;)</h4>
<p>INSERT 후 DB에서 자동 생성된 id 값을 MenuVO 객체에 자동으로 담아주는 옵션이다.</p>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>useGeneratedKeys = true</code></td>
<td>DB에서 자동 생성된 키(id)를 가져올지 여부</td>
</tr>
<tr>
<td><code>keyProperty = &quot;id&quot;</code></td>
<td>가져온 키를 어느 필드에 담을지 지정</td>
</tr>
</tbody></table>
<pre><code class="language-java">package com.pizzashop.mapper;

import java.util.List;

import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

import com.pizzashop.vo.MenuVO;

@Mapper
public interface MenuMapper {

//    메뉴 전체 조회
    @Select(&quot;SELECT * FROM menu ORDER BY id ASC&quot;)
    List&lt;MenuVO&gt; findAll();

//    메뉴 단건 조회
    @Select(&quot;SELECT * FROM menu WHERE id = #{id}&quot;)
    MenuVO findById(int id);

//    메뉴 등록
    @Insert(&quot;INSERT INTO menu (name, size, price, category, img_url) VALUES (#{name}, #{size}, #{price}, #{category}, #{imgUrl})&quot;)
    @Options(useGeneratedKeys = true, keyProperty = &quot;id&quot;)
    boolean insert(MenuVO menu);

//    메뉴 수정
    @Update(&quot;UPDATE menu SET name=#{name}, size=#{size}, price=#{price}, category=#{category}, img_url=#{imgUrl} WHERE id=#{id}&quot;)
    boolean update(MenuVO menu);

//    메뉴 삭제
    @Delete(&quot;DELETE FROM menu WHERE id=#{id}&quot;)
    boolean delete(int id);

}
</code></pre>
<hr>
<h2 id="6-menuservice-클래스-생성">6. MenuService 클래스 생성</h2>
<h4 id="service-클래스란">Service 클래스란?</h4>
<p>Controller와 Mapper 사이에서 비즈니스 로직을 처리하는 계층이다.
Controller는 요청/응답만 담당하고, 실제 처리 로직은 Service에서 담당한다.</p>
<pre><code>클라이언트 → Controller → Service → Mapper → DB</code></pre><h4 id="autowired">@Autowired</h4>
<p>Spring이 자동으로 객체(Bean)를 찾아서 주입해주는 어노테이션이다.
직접 new로 객체를 생성할 필요 없이 Spring이 알아서 넣어준다.</p>
<pre><code class="language-java">// @Autowired 없으면 직접 생성해야 함
MenuMapper menuMapper = new MenuMapper();

// @Autowired 있으면 Spring이 알아서 주입
@Autowired
private MenuMapper menuMapper;</code></pre>
<h4 id="service">@Service</h4>
<p>Spring에게 &quot;이 클래스가 Service입니다&quot; 라고 알려주는 어노테이션이다.
자동으로 Bean으로 등록되어 @Autowired로 주입받을 수 있다.</p>
<pre><code class="language-java">package com.pizzashop.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.pizzashop.mapper.MenuMapper;
import com.pizzashop.vo.MenuVO;

@Service
public class MenuService {
// @Autowired = Spring이 알아서 MenuMapper 객체를 여기에 주입해줌(직접 new를 할 필요 없음)
        @Autowired
        private MenuMapper menuMapper;

//        전체 메뉴 조회
        public List&lt;MenuVO&gt; getAll() {return menuMapper.findAll();}

//        단건 메뉴 조회
        public MenuVO getById(int id) {return menuMapper.findById(id);}

//        메뉴 등록 / 수정 / 삭제
//        insert 결과가 1이상이면 true(성공), 0이면 false(실패)
        public boolean add(MenuVO menu) {return menuMapper.insert(menu);}
        public boolean update(MenuVO menu) {return menuMapper.update(menu);}
        public boolean delete(int id) {return menuMapper.delete(id);}

}
</code></pre>
<hr>
<h2 id="7-menuconroller-클래스-생성">7. MenuConroller 클래스 생성</h2>
<pre><code>package com.pizzashop.controller;

import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.CrossOrigin;
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.RestController;

import com.pizzashop.service.MenuService;
import com.pizzashop.vo.MenuVO;

@RestController
@RequestMapping(&quot;/api/menu&quot;)
@CrossOrigin(origins=&quot;*&quot;)
public class MenuController {

    @Autowired
    private MenuService menuService;

//    메뉴 조회
    @GetMapping
    public List&lt;MenuVO&gt; getAll() {
        return menuService.getAll();
    }

//    단건 조회
    @GetMapping(&quot;/{id}&quot;)
    public MenuVO getById(@PathVariable(&quot;id&quot;) int id) {
        return menuService.getById(id);
    }

//    메뉴 등록
    @PostMapping
    public Map&lt;String, Object&gt; add(@RequestBody MenuVO menu) {
        boolean success = menuService.add(menu);
        if(success) return Map.of(&quot;result&quot;, &quot;ok&quot;, &quot;message&quot;, &quot;등록 완료&quot;);
        return Map.of(&quot;result&quot;, &quot;fail&quot;, &quot;message&quot;, &quot;등록 실패&quot;);
    }

//    메뉴 수정
    @PutMapping(&quot;/{id}&quot;)
    public Map&lt;String, Object&gt; update(@PathVariable(&quot;id&quot;) int id, @RequestBody MenuVO menu) {
        menu.setId(id);
        boolean success = menuService.update(menu);
        if(success) return Map.of(&quot;result&quot;, &quot;ok&quot;, &quot;message&quot;, &quot;수정 완료&quot;);
        return Map.of(&quot;result&quot;, &quot;fail&quot;, &quot;message&quot;, &quot;수정 실패&quot;);
    }

//    메뉴 삭제
    @DeleteMapping(&quot;/{id}&quot;)
    public Map&lt;String, Object&gt; delete(@PathVariable(&quot;id&quot;) int id) {
        boolean success = menuService.delete(id);
        if(success) return Map.of(&quot;result&quot;, &quot;ok&quot;, &quot;message&quot;, &quot;삭제 완료&quot;);
        return Map.of(&quot;result&quot;, &quot;fail&quot;, &quot;message&quot;, &quot;삭제 실패&quot;);
    }
}</code></pre><hr>
<h2 id="8-postman으로-api-실행해보기">8. Postman으로 API 실행해보기</h2>
<ol>
<li>메뉴 등록
<em>(이미지 URL은 피자스쿨의 이미지 URL 사용)</em></li>
</ol>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/5d299a97-b0f6-4937-848b-08576ce24a2e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/b7c57e5b-4f7e-4604-8c72-ee392d0ba18f/image.png" alt="">
2. 전체 메뉴 조회
<img src="https://velog.velcdn.com/images/jhwest-dev/post/04edb70c-32ac-44f9-b9ca-81b309d72533/image.png" alt="">
3. 단건 메뉴 조회
<img src="https://velog.velcdn.com/images/jhwest-dev/post/7a11ea8f-d025-40a7-b9aa-c688691c6d66/image.png" alt="">
4. 메뉴 수정
<img src="https://velog.velcdn.com/images/jhwest-dev/post/a7ee5ada-6728-42b0-8b22-2e10283e8e60/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/2a22cb43-576d-407e-9d2e-a5c32b17c77d/image.png" alt="">
5. 메뉴 삭제
<img src="https://velog.velcdn.com/images/jhwest-dev/post/1ed3fef2-ea05-4129-ad1c-c04b79aee674/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/e3ecff35-ec24-4582-b634-e58054116d67/image.png" alt=""></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 글에서는 피자 가게 메뉴 CRUD를 직접 구현해봤다.</p>
<p>학원에서 배운 내용을 다시 만들어보니 전체적인 흐름이 더 명확하게 이해됐다.</p>
<pre><code>클라이언트 → Controller → Service → Mapper → DB</code></pre><p>VO, Mapper, Service, Controller 각각의 역할을 이해하고 나니 Spring Boot의 구조가 훨씬 익숙하게 느껴졌다.</p>
<p>다음 글에서는 피자 가게 프론트 연동을 해볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[1편 - [Spring Boot] 개발 환경 세팅 (feat. MySQL, JDK, Lombok, Postman)]]></title>
            <link>https://velog.io/@jhwest-dev/Spring-Boot-%EA%B0%9C%EB%B0%9C-%ED%99%98%EA%B2%BD-%EC%84%B8%ED%8C%85%EB%B6%80%ED%84%B0-CRUD-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80-feat.-MySQL-MyBatis-Lombok-Postman</link>
            <guid>https://velog.io/@jhwest-dev/Spring-Boot-%EA%B0%9C%EB%B0%9C-%ED%99%98%EA%B2%BD-%EC%84%B8%ED%8C%85%EB%B6%80%ED%84%B0-CRUD-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80-feat.-MySQL-MyBatis-Lombok-Postman</guid>
            <pubDate>Wed, 13 May 2026 16:33:27 GMT</pubDate>
            <description><![CDATA[<h1 id="개발-환경-세팅">개발 환경 세팅</h1>
<h2 id="1-jdk-jdk-17-lts-버전-설치">1. JDK (JDK 17-LTS 버전) 설치</h2>
<ol>
<li><p>아래 사이트 접속 &gt; Other Downloads 클릭 
<a href="https://adoptium.net/">https://adoptium.net/</a>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/d9f8d900-c72a-4b2c-abc5-0cef8c41a0ae/image.png" alt=""></p>
</li>
<li><p>JDK 17-LTS 탭 클릭 &gt; 운영체제에 맞는 JDK 설치
<img src="https://velog.velcdn.com/images/jhwest-dev/post/313a5fed-7af3-4dd3-94e0-e7fdcc4a651a/image.png" alt=""></p>
</li>
</ol>
<hr>
<h2 id="2-스프링부트-설치">2. 스프링부트 설치</h2>
<ol>
<li><p>아래 사이트 접속 &gt; ECLIPSE 탭 선택 &gt; 운영체제에 맞는 프로그램 설치
<a href="https://spring.io/tools">https://spring.io/tools</a>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/bb44b78c-0043-4aad-a1fd-7560e3e7ca24/image.png" alt=""></p>
</li>
<li><p>압축 풀고  → 경로 설정 - Launch
<img src="https://velog.velcdn.com/images/jhwest-dev/post/08d3c6d6-715e-4fd5-990b-9401e288d077/image.png" alt=""></p>
</li>
</ol>
<hr>
<h2 id="3-롬복-설치">3. 롬복 설치</h2>
<ol>
<li><p>아래 사이트 접속 &gt; 파일 다운로드
<a href="https://projectlombok.org/download">https://projectlombok.org/download</a>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/8b755795-7bd4-4767-931c-bf0051275930/image.png" alt=""></p>
</li>
<li><p>Specify locatio...버튼 클릭 &gt; 이전에 설치한 SpringToolsForEclipse.exe 파일 선택 &gt; instaal / Up... 버튼 클릭 &gt; Quit Inst... 버튼 클릭
<img src="https://velog.velcdn.com/images/jhwest-dev/post/31d5881d-249d-4f41-8033-fa9b8e2153d3/image.png" alt=""></p>
<br>
## ⚠️ 롬복 설치 시 아래와 같은 에러가 난다면?
![](https://velog.velcdn.com/images/jhwest-dev/post/c18922a7-c71c-4e55-bf0a-d7ada61647eb/image.png)
</li>
<li><p>관리자 권한으로 cmd창 열기 &gt; lombok.jar가 있는 디렉토리로 이동
<img src="https://velog.velcdn.com/images/jhwest-dev/post/e5d5d7fc-cee7-40b4-99cb-936d1e4b8cf6/image.png" alt="">
<img src="https://velog.velcdn.com/images/jhwest-dev/post/af11c49a-7473-49d4-87fc-bb0f9cea0b3d/image.png" alt=""></p>
</li>
<li><p>java -jar lombok.jar 명령어 실행 &gt; 설치 재시도
<img src="https://velog.velcdn.com/images/jhwest-dev/post/a263dcc1-3b61-488f-9ae4-715b560135b3/image.png" alt=""></p>
</li>
</ol>
<hr>
<h2 id="4-mysql-설치">4. MySQL 설치</h2>
<ol>
<li><p>아래 사이트 접속 &gt; 다운로드 버튼 클릭
<a href="https://dev.mysql.com/downloads/installer/">https://dev.mysql.com/downloads/installer/</a>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/3343e1aa-65b6-4422-a0bd-84296d1fa480/image.png" alt=""></p>
</li>
<li><p>Custom &gt; Next 클릭
<img src="https://velog.velcdn.com/images/jhwest-dev/post/3ccd3957-2dbc-4fa8-93d3-93ae054925e4/image.png" alt=""></p>
</li>
<li><p>왼쪽 Available Products에서 오른쪽으로 이동 &gt; Next
✅ MySQL Server 8.0.46 - X64
✅ MySQL Workbench 8.0.47 - X64</p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/jhwest-dev/post/fad3def9-d3ae-4e98-b503-8b05b627a6c8/image.png" alt=""></p>
<ol start="4">
<li><p>설치 완료 후 &gt; Execute 클릭
<img src="https://velog.velcdn.com/images/jhwest-dev/post/f6b35834-fac6-4dcf-8fe8-f85385db3e1a/image.png" alt=""></p>
</li>
<li><p>root 계정의 비밀번호를 설정 
(이 비밀번호는 나중에 DB 접속 시 반드시 필요하므로 반드시 기억/기록해두기)
<img src="https://velog.velcdn.com/images/jhwest-dev/post/2e1bd715-0e1c-42be-8e97-2e111de7837d/image.png" alt=""></p>
</li>
<li><p>이후 나오는 화면들은 모두 기본값 유지 후 Next 클릭</p>
<br>

</li>
</ol>
<h2 id="⚠️-mysql-삭제-후-재설치-시-주의사항">⚠️ MySQL 삭제 후 재설치 시 주의사항</h2>
<p>단순히 프로그램만 제거하면 재설치 시 오류가 발생할 수 있다.
<strong>아래 순서대로</strong> 완전히 삭제 후 재설치</p>
<h3 id="1-프로그램-제거">1. 프로그램 제거</h3>
<p><strong>제어판 → 프로그램 추가/제거</strong>에서 아래 항목을 모두 제거</p>
<ul>
<li>MySQL Server</li>
<li>MySQL Workbench</li>
<li>MySQL Installer</li>
</ul>
<h3 id="2-잔여-파일-삭제">2. 잔여 파일 삭제</h3>
<pre><code>C:\Program Files\MySQL
C:\ProgramData\MySQL</code></pre><blockquote>
<p>💡 <code>ProgramData</code>는 숨김 폴더
탐색기 상단 <strong>보기 → 숨긴 항목 체크</strong> 후 접근</p>
</blockquote>
<h3 id="3-재부팅-후-재설치-진행-✅">3. 재부팅 후 재설치 진행 ✅</h3>
<hr>
<h2 id="5-postman-설치">5. Postman 설치</h2>
<ol>
<li>아래 사이트 접속 &gt; 다운로드 &gt; 로그인 후 실행
<a href="https://www.postman.com/downloads/">https://www.postman.com/downloads/</a>
<img src="https://velog.velcdn.com/images/jhwest-dev/post/98ae3a97-91db-4747-ae2d-12ab4dc14e08/image.png" alt=""><br>
## ⚠️ Postman 삭제 후 재설치 시 주의사항

</li>
</ol>
<p>단순히 프로그램만 제거하면 재설치 시 오류가 발생할 수 있다.
<strong>아래 순서대로</strong> 완전히 삭제 후 재설치</p>
<h3 id="1-프로그램-제거-1">1. 프로그램 제거</h3>
<p><strong>제어판 → 프로그램 추가/제거</strong>에서 아래 항목을 제거</p>
<ul>
<li>Postman</li>
</ul>
<h3 id="2-잔여-파일-삭제-1">2. 잔여 파일 삭제</h3>
<pre><code>C:\Users\[사용자명]\AppData\Roaming\Postman
C:\Users\[사용자명]\AppData\Local\Postman</code></pre><blockquote>
<p>💡 <code>AppData</code>는 숨김 폴더!
탐색기 상단 <strong>보기 → 숨긴 항목 체크</strong> 후 접근</p>
</blockquote>
<h3 id="3-재설치-진행-✅">3. 재설치 진행 ✅</h3>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 백트래킹 - 부분집합, 순열, 조합]]></title>
            <link>https://velog.io/@jhwest-dev/Java-%EB%B0%B1%ED%8A%B8%EB%9E%98%ED%82%B9-%EB%B6%80%EB%B6%84%EC%A7%91%ED%95%A9%EA%B3%BC-%EC%88%9C%EC%97%B4</link>
            <guid>https://velog.io/@jhwest-dev/Java-%EB%B0%B1%ED%8A%B8%EB%9E%98%ED%82%B9-%EB%B6%80%EB%B6%84%EC%A7%91%ED%95%A9%EA%B3%BC-%EC%88%9C%EC%97%B4</guid>
            <pubDate>Fri, 08 May 2026 13:54:39 GMT</pubDate>
            <description><![CDATA[<h2 id="백트래킹이란">백트래킹이란?</h2>
<p>백트래킹(Backtracking)은 <strong>가능한 모든 경우를 탐색</strong>하되, 조건에 맞지 않으면 <strong>되돌아가서 다른 경우를 시도</strong>하는 알고리즘이다.</p>
<p>쉽게 말하면 이렇다.</p>
<blockquote>
<p>&quot;일단 해봐. 안 되면 되돌아와서 다른 거 해봐.&quot;</p>
</blockquote>
<p>재귀 함수로 구현하며, <strong>선택 → 재귀 → 선택 취소</strong> 패턴이 핵심이다.</p>
<pre><code>선택      →    재귀 호출    →    선택 취소 (백트래킹)
visited[i] = true → permutation(...) → visited[i] = false</code></pre><hr>
<h2 id="부분집합-vs-순열-vs-조합">부분집합 vs 순열 vs 조합</h2>
<table>
<thead>
<tr>
<th></th>
<th>순서</th>
<th>개수</th>
</tr>
</thead>
<tbody><tr>
<td>부분집합</td>
<td>상관 없음</td>
<td>상관 없음</td>
</tr>
<tr>
<td>순열</td>
<td>상관 있음</td>
<td>r개</td>
</tr>
<tr>
<td>조합</td>
<td>상관 없음</td>
<td>r개</td>
</tr>
</tbody></table>
<h3 id="예시-1-2-3-에서-2개-뽑기">예시 {1, 2, 3} 에서 2개 뽑기</h3>
<p><strong>순열</strong></p>
<ul>
<li>가짓수 : 6개</li>
<li>예시 : {1,2}, {1,3}, {2,1}, {2,3}, {3,1}, {3,2}</li>
</ul>
<p><strong>조합</strong></p>
<ul>
<li>가짓수 : 3개</li>
<li>예시 : {1,2}, {1,3}, {2,3}</li>
</ul>
<h3 id="한-줄-요약">한 줄 요약</h3>
<ul>
<li><strong>부분집합</strong> : 각 원소를 포함할지 말지 결정하며 나올 수 있는 모든 집합 (빈 집합 포함!)</li>
<li><strong>순열</strong> : r개의 원소를 나열 (순서 중요!)</li>
<li><strong>조합</strong> : r개의 원소를 선택 (순서는 중요하지 않음)</li>
</ul>
<hr>
<h2 id="부분집합-subset">부분집합 (Subset)</h2>
<h3 id="개념">개념</h3>
<p><code>{1, 2, 3}</code> 의 모든 부분집합은 다음과 같다.</p>
<pre><code>{}, {1}, {2}, {3}, {1,2}, {1,3}, {2,3}, {1,2,3}  → 총 8가지 (2^3)</code></pre><p>각 원소에 대해 <strong>포함 / 미포함</strong> 두 가지 선택만 한다.</p>
<h3 id="동작-흐름">동작 흐름</h3>
<pre><code>1 포함 여부 결정
├── 포함 O → 2 포함 여부 결정
│           ├── 포함 O → 3 포함 여부 결정 → {1,2,3} 출력
│           └── 포함 X → 3 포함 여부 결정 → {1,2} 출력
└── 포함 X → 2 포함 여부 결정
            ├── 포함 O → 3 포함 여부 결정 → {1,3} 출력
            └── 포함 X → ...</code></pre><h3 id="코드">코드</h3>
<pre><code class="language-java">static int[] arr = {1, 2, 3};
static boolean[] selected = new boolean[arr.length];
static int count = 0;

static void printSubset() {
    count = 0;
    subset(0);
    System.out.println(&quot;\n총 &quot; + count + &quot; 가지&quot;);
}

static void subset(int depth) {
    // 기저 조건 : 모든 원소에 대해 선택 여부를 결정했으면 출력
    if (depth == arr.length) {
        count++;
        System.out.print(&quot;{ &quot;);
        for (int i = 0; i &lt; arr.length; i++) {
            if (selected[i]) System.out.print(arr[i] + &quot; &quot;);
        }
        System.out.println(&quot;}&quot;);
        return;
    }

    // 현재 원소를 포함하는 경우
    selected[depth] = true;
    subset(depth + 1);

    // 현재 원소를 포함하지 않는 경우 (백트래킹)
    selected[depth] = false;
    subset(depth + 1);
}</code></pre>
<h3 id="핵심-포인트">핵심 포인트</h3>
<ul>
<li><code>visited</code> 없이 <code>selected</code> 배열만으로 포함 여부를 결정</li>
<li>depth가 arr.length에 도달하면 하나의 부분집합 완성</li>
<li>포함 O / 포함 X 두 갈래로 재귀 → 총 2^n 가지</li>
</ul>
<hr>
<h2 id="순열-permutation">순열 (Permutation)</h2>
<h3 id="개념-1">개념</h3>
<p><code>{1, 2, 3}</code> 에서 2개를 뽑아 순서 있게 나열한다.</p>
<pre><code>{1,2}, {1,3}, {2,1}, {2,3}, {3,1}, {3,2}  → 총 6가지 (3P2)</code></pre><p><strong>순서가 다르면 다른 경우</strong>다. {1,2} 와 {2,1} 은 다르다.</p>
<h3 id="동작-흐름-1">동작 흐름</h3>
<pre><code>depth=0 선택       depth=1 선택        결과
─────────────────────────────────────────
1 선택     ──→     2 선택    ──→   {1,2} ✅
           ──→     3 선택    ──→   {1,3} ✅
2 선택     ──→     1 선택    ──→   {2,1} ✅
           ──→     3 선택    ──→   {2,3} ✅
3 선택     ──→     1 선택    ──→   {3,1} ✅
           ──→     2 선택    ──→   {3,2} ✅</code></pre><h3 id="코드-1">코드</h3>
<pre><code class="language-java">static int count = 0;

// 편의 함수 : 배열과 r값만 넘기면 순열 실행
static void printPermutation(int[] arr, int r) {
    int[] result = new int[r];
    boolean[] visited = new boolean[arr.length];
    count = 0;

    permutation(arr, result, visited, 0, r);
    System.out.println(&quot;\n총 &quot; + count + &quot; 가지&quot;);
}

static void permutation(int[] arr, int[] result, boolean[] visited, int depth, int r) {
    // 기저 조건 : r개를 모두 선택했으면 출력
    if (depth == r) {
        count++;
        for (int i = 0; i &lt; r; i++) {
            System.out.print(result[i] + &quot; &quot;);
        }
        System.out.println();
        return;
    }

    // 원본 배열의 원소를 하나씩 시도
    for (int i = 0; i &lt; arr.length; i++) {
        // 이미 선택된 원소는 건너뜀
        if (visited[i]) continue;

        // arr[i]를 선택 : result에 넣고 visited 표시
        visited[i] = true;
        result[depth] = arr[i];

        // 다음 깊이로 재귀 호출
        permutation(arr, result, visited, depth + 1, r);

        // 선택 취소 (백트래킹)
        visited[i] = false;
    }
}</code></pre>
<h3 id="핵심-포인트-1">핵심 포인트</h3>
<ul>
<li><code>visited</code> 배열로 같은 원소를 두 번 쓰는 것을 방지</li>
<li>depth == r 이 되면 r개 선택 완료 → 출력</li>
<li>재귀 후 <code>visited[i] = false</code> 로 되돌려야 다른 경우 탐색 가능</li>
</ul>
<hr>
<h2 id="조합-combination">조합 (Combination)</h2>
<h3 id="개념-2">개념</h3>
<p><code>{1, 2, 3}</code> 에서 2개를 뽑는다. 순서는 상관없다.</p>
<pre><code>{1,2}, {1,3}, {2,3}  → 총 3가지 (3C2)</code></pre><p><strong>순서가 달라도 같은 경우</strong>다. {1,2} 와 {2,1} 은 같다.</p>
<h3 id="순열과의-차이점">순열과의 차이점</h3>
<p>순열은 모든 원소를 매번 처음부터 탐색하지만, 조합은 <strong>이미 선택한 원소보다 뒤에 있는 원소만 탐색</strong>한다.</p>
<pre><code>순열 : 1 선택 후 → {2, 3} 탐색 / 2 선택 후 → {1, 3} 탐색  (앞으로도 감)
조합 : 1 선택 후 → {2, 3} 탐색 / 2 선택 후 → {3} 탐색     (앞으로 안 감)</code></pre><p>이게 핵심이다. <code>start</code> 변수로 탐색 시작 위치를 앞으로 당기지 않는다.</p>
<h3 id="동작-흐름-2">동작 흐름</h3>
<pre><code>depth=0 선택       depth=1 선택        결과
─────────────────────────────────────────
1 선택(start=1) ──→  2 선택  ──→   {1,2} ✅
               ──→  3 선택  ──→   {1,3} ✅
2 선택(start=2) ──→  3 선택  ──→   {2,3} ✅
3 선택(start=3) ──→  (탐색할 원소 없음, 종료)</code></pre><h3 id="코드-2">코드</h3>
<pre><code class="language-java">static int count = 0;

// 편의 함수 : 배열과 r값만 넘기면 조합 실행
static void printCombination(int[] arr, int r) {
    int[] result = new int[r];
    count = 0;

    combination(arr, result, 0, 0, r);
    System.out.println(&quot;\n총 &quot; + count + &quot; 가지&quot;);
}

static void combination(int[] arr, int[] result, int start, int depth, int r) {
    // 기저 조건 : r개를 모두 선택했으면 출력
    if (depth == r) {
        count++;
        for (int i = 0; i &lt; r; i++) {
            System.out.print(result[i] + &quot; &quot;);
        }
        System.out.println();
        return;
    }

    // start부터 탐색 (이전 원소는 다시 보지 않음)
    for (int i = start; i &lt; arr.length; i++) {
        result[depth] = arr[i];

        // 다음 탐색은 i+1부터 (중복 방지)
        combination(arr, result, i + 1, depth + 1, r);
    }
}</code></pre>
<h3 id="핵심-포인트-2">핵심 포인트</h3>
<ul>
<li><code>visited</code> 배열이 필요 없음 → <code>start</code>로 탐색 범위를 제한</li>
<li>재귀 호출 시 <code>i + 1</code> 을 넘겨서 현재 원소보다 뒤만 탐색</li>
<li>백트래킹은 자동으로 됨 (result 덮어쓰기 방식)</li>
</ul>
<hr>
<h2 id="세-가지-비교-정리">세 가지 비교 정리</h2>
<table>
<thead>
<tr>
<th></th>
<th>부분집합</th>
<th>순열</th>
<th>조합</th>
</tr>
</thead>
<tbody><tr>
<td>핵심 변수</td>
<td><code>selected[]</code></td>
<td><code>visited[]</code></td>
<td><code>start</code></td>
</tr>
<tr>
<td>탐색 범위</td>
<td>전체 (포함/미포함)</td>
<td>전체 (visited 체크)</td>
<td>start 이후만</td>
</tr>
<tr>
<td>결과 수</td>
<td>2^n</td>
<td>nPr</td>
<td>nCr</td>
</tr>
<tr>
<td>백트래킹</td>
<td>selected = false</td>
<td>visited = false</td>
<td>자동 (덮어쓰기)</td>
</tr>
</tbody></table>
<hr>
<h2 id="마무리">마무리</h2>
<p>백트래킹의 핵심은 <strong>선택 → 재귀 → 선택 취소</strong> 패턴이다.</p>
<p>세 가지 모두 같은 뼈대를 가지고 있고, 차이는 <strong>어떻게 중복을 방지하느냐</strong>다.</p>
<ul>
<li>부분집합 : 포함/미포함 두 갈래로 나눔</li>
<li>순열 : visited로 이미 쓴 원소 막음</li>
<li>조합 : start로 탐색 범위를 앞으로 당기지 않음</li>
</ul>
<p>이 패턴에 익숙해지면 백트래킹 문제의 대부분을 풀 수 있다.</p>
]]></description>
        </item>
    </channel>
</rss>