<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>luislki_.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 13 Apr 2026 13:35:02 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>luislki_.log</title>
            <url>https://velog.velcdn.com/images/luislki_/profile/6e68ee01-5ce5-4460-9aa6-54edf398a496/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. luislki_.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/luislki_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[React] 다음 주소검색 사용하기]]></title>
            <link>https://velog.io/@luislki_/React-%EB%8B%A4%EC%9D%8C-%EC%A3%BC%EC%86%8C%EA%B2%80%EC%83%89-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@luislki_/React-%EB%8B%A4%EC%9D%8C-%EC%A3%BC%EC%86%8C%EA%B2%80%EC%83%89-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 13 Apr 2026 13:35:02 GMT</pubDate>
            <description><![CDATA[<p>폼에서 주소를 직접 입력받으면 표기가 제각각이라 관리가 번거롭다.
그래서 이번에는 다음 주소검색(카카오 우편번호 서비스) 을 붙여서 사용자가 주소를 검색 후 선택하도록 구성했다.</p>
<h5 id="다음-우편번호-서비스-가이드-httpspostcodemapdaumnetguide">*다음 우편번호 서비스 가이드 <a href="https://postcode.map.daum.net/guide">https://postcode.map.daum.net/guide</a></h5>
<hr>
<h3 id="1-스크립트-추가하기">1. 스크립트 추가하기</h3>
<p>먼저 다음 주소검색 스크립트를 불러와야 한다.</p>
<pre><code class="language-html">&lt;script src=&quot;//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js&quot;&gt;&lt;/script&gt;</code></pre>
<p>이 스크립트를 불러오면 <code>window.daum.Postcode</code> 를 사용할 수 있다.</p>
<hr>
<h3 id="2-typescript-전역-타입-선언하기">2. TypeScript 전역 타입 선언하기</h3>
<p>다음 주소검색은 npm 패키지를 import 해서 쓰는 방식이 아니라,
외부 스크립트를 로드한 뒤 <code>window.daum.Postcode</code> 전역 객체를 사용하는 방식이다.</p>
<p>그래서 별도 import는 필요 없고, TypeScript에서는 아래처럼 전역 타입 선언을 추가해두면 된다</p>
<pre><code class="language-ts">declare global {
  interface Window {
    daum?: {
      Postcode: new (options: {
        oncomplete: (data: DaumPostcodeData) =&gt; void;
        onclose?: (state: &quot;FORCE_CLOSE&quot; | &quot;COMPLETE_CLOSE&quot;) =&gt; void;
      }) =&gt; {
        open: () =&gt; void;
      };
    };
  }
}</code></pre>
<p><code>any</code> 로 넓게 선언할 수도 있지만,
실제로 사용하는 <code>Postcode</code> 구조만 명시해두면 자동완성과 타입 체크 측면에서 더 안전하다.</p>
<p align="center">
  <img src="https://velog.velcdn.com/images/luislki_/post/61aaaabc-e37b-40c2-b295-adad01919f4b/image.png" width="400" />
</p>


<hr>
<h3 id="3-기본-사용법">3. 기본 사용법</h3>
<p>주소 검색은 아래처럼 열 수 있다.</p>
<pre><code class="language-ts">new window.daum.Postcode({
  oncomplete: (data) =&gt; {
    console.log(data);
  },
}).open();</code></pre>
<hr>
<h3 id="4-유틸-함수로-분리하기">4. 유틸 함수로 분리하기</h3>
<pre><code class="language-ts">type SelectedRegion = {
  addressLabel: string | null;
  region1DepthName: string | null;
  region2DepthName: string | null;
  region3DepthName: string | null;
};

type OpenDaumAddressSearchParams = {
  onSelect: (region: SelectedRegion) =&gt; void;
};

export const openDaumAddressSearch = ({
  onSelect,
}: OpenDaumAddressSearchParams) =&gt; {
  if (!window.daum?.Postcode) {
    throw new Error(&quot;Daum Postcode script is not loaded.&quot;);
  }

  new window.daum.Postcode({
    oncomplete: (data) =&gt; {
      onSelect({
        addressLabel: data.roadAddress || data.jibunAddress || null,
        region1DepthName: data.sido || null,
        region2DepthName: data.sigungu || null,
        region3DepthName: data.bname || null,
      });
    },
  }).open();
};</code></pre>
<hr>
<h3 id="5-컴포넌트에서-사용하기">5. 컴포넌트에서 사용하기</h3>
<p>버튼 클릭 시 주소검색을 열고, 선택한 값을 상태에 반영하면 된다.</p>
<pre><code class="language-ts">const handleOpenAddressSearch = async () =&gt; {
  try {
    openDaumAddressSearch({
      onSelect: (selectedRegion) =&gt; {
        setField(&quot;mainRegion&quot;, {
          ...selectedRegion,
          // 사용자에게 따로 입력받을 상세 주소
          detailAddress: mainRegion?.detailAddress ?? null,
        });
      },
    });
  } catch (error) {
    console.error(error);
    alert(&quot;주소 검색 창을 열지 못했습니다.&quot;);
  }
};</code></pre>
<hr>
<h3 id="6-정리">6. 정리</h3>
<p>다음 주소검색 사용 흐름은 단순하다.</p>
<ol>
<li>스크립트 로드</li>
<li><code>window.daum</code> 전역 타입 선언</li>
<li><code>new window.daum.Postcode()</code> 생성</li>
<li><code>open()</code> 으로 팝업 열기</li>
<li><code>oncomplete</code> 에서 선택 결과 받기</li>
<li>필요한 형태로 상태 저장</li>
</ol>
<p>주소를 직접 입력받는 것보다 훨씬 안정적이고,
폼에서도 재사용하기 좋다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] Prisma ORM 사용 이유 & 기본 사용법]]></title>
            <link>https://velog.io/@luislki_/Next.js-Prisma-ORM-%EC%82%AC%EC%9A%A9-%EC%9D%B4%EC%9C%A0-%EA%B8%B0%EB%B3%B8-%EC%82%AC%EC%9A%A9%EB%B2%95</link>
            <guid>https://velog.io/@luislki_/Next.js-Prisma-ORM-%EC%82%AC%EC%9A%A9-%EC%9D%B4%EC%9C%A0-%EA%B8%B0%EB%B3%B8-%EC%82%AC%EC%9A%A9%EB%B2%95</guid>
            <pubDate>Mon, 06 Apr 2026 16:05:57 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/263c7fab-231e-4d7d-b5e9-dd24068105c6/image.png" alt=""></p>
<p>Next.js 프로젝트를 진행하면서 데이터베이스를 코드로 다루기 위해 Prisma를 사용했다.
Prisma는 Node.js 와 TypeScript 환경에서 사용할 수 있는 <code>ORM</code>이며, 타입 안전한 데이터 접근, 스키마 기반 모델링, <code>마이그레이션</code> 관리, <code>GUI</code> 기반 데이터 확인 도구까지 함께 제공한다.
Prisma 공식 문서에서도 Prisma ORM을 타입 안전한 데이터 접근, 마이그레이션, 시각적 데이터 편집 기능을 제공하는 ORM으로 설명한다.</p>
<h6 id="ormobject-relational-mapping--rdbms의-테이블을-코드-상의-객체처럼-다룰-수-있게-해주는-기술">*ORM(Object Relational Mapping) : RDBMS의 테이블을 코드 상의 객체처럼 다룰 수 있게 해주는 기술</h6>
<h6 id="마이그레이션migration--db-스키마-변경-사항을-기록하고-적용하는-작업">*마이그레이션(Migration) : DB 스키마 변경 사항을 기록하고 적용하는 작업</h6>
<h6 id="guigraphical-user-interface--아이콘-버튼-창-같은-시각적-요소를-통해-사용자가-기능을-쉽게-조작할-수-있도록-만든-인터페이스">*GUI(Graphical User Interface) : 아이콘, 버튼, 창 같은 시각적 요소를 통해 사용자가 기능을 쉽게 조작할 수 있도록 만든 인터페이스</h6>
<p><a href="https://www.prisma.io/docs/orm?utm_source=chatgpt.com">Prisma ORM 공식 문서</a></p>
<h3 id="prisma란-무엇인가">Prisma란 무엇인가</h3>
<p>Prisma는 공식적으로 Node.js와 TypeScript를 위한 차세대 ORM으로 소개된다.
PostgreSQL, MySQL, SQL Server, MongoDB, CockroachDB 등 여러 데이터베이스를 지원하며, 핵심 구성은 보통 세 가지로 나뉜다.
<strong>Prisma Client, Prisma Migrate, Prisma Studio</strong>가 대표적이다.</p>
<p>각 역할은 다음과 같다.</p>
<ul>
<li>Prisma Client: Prisma schema를 기반으로 생성되는 타입 안전한 ORM 인터페이스. 코드에서 <code>findMany</code>, <code>create</code>, <code>update</code> 같은 방식으로 DB를 다룰 수 있다.</li>
<li>Prisma Migrate: 스키마 변경을 migration 파일로 관리하고, 개발 및 운영 환경에 반영하는 도구.</li>
<li>Prisma Studio: 브라우저 기반 GUI에서 데이터를 조회하고 수정할 수 있는 도구.</li>
</ul>
<p>즉, Prisma는 단순한 &quot;Query 헬퍼&quot; 가 아니라, 데이터 모델 정의부터 쿼리 실행, 변경 이력 관리까지 한 흐름으로 가져가는 도구로 취급한다.</p>
<hr>
<h3 id="nextjs-에서-prisma를-쓰는-이유">Next.js 에서 Prisma를 쓰는 이유</h3>
<p>Next.js는 프론트엔드 프레임워크처럼 보이지만, 실제로는 서버 컴포넌트, 서버 액션, Route Handler, API Route 등 서버 로직도 함께 다루는 풀스택 구조에 가깝다. Prisma는 이런 환경에서 특히 잘 맞는다.
Prisma 공식 가이드도 Next.js에서 Prisma를 설정하고, 마이그레이션을 다루고, 배포하는 흐름을 별도로 제공한다.</p>
<p><a href="https://www.prisma.io/docs/guides/frameworks/nextjs?utm_source=chatgpt.com">해당 공식 문서</a></p>
<p>Prisma를 선택한 이유는 크게 네 가지다.</p>
<h4 id="1-타입-안정성이-좋다-💭">1. 타입 안정성이 좋다 <a href="https://www.prisma.io/docs/orm?utm_source=chatgpt.com">💭</a></h4>
<p>Prisma Client는 Prisma schema를 바탕으로 자동 생성된다.
그래서 모델 필드명이나 타입이 바뀌면 TypeScript에서 바로 감지할 수 있는 경우가 많다.
문자열 기반 쿼리를 직접 흩뿌리는 것보다 훨씬 안정적이다.
Prisma는 Prisma Client를 auto-generated, type-safe ORM Interface로 설명한다.</p>
<h4 id="2-모델-구조를-코드로-관리할-수-있다-💭">2. 모델 구조를 코드로 관리할 수 있다 <a href="https://www.prisma.io/docs/orm/reference/prisma-schema-reference?utm_source=chatgpt.com">💭</a></h4>
<p>Prisma에서는 <code>schema.prisma</code> 파일에 데이터 모델을 정의한다.
이 말은 즉, 데이터베이스 구조가 코드 레벨에서 관리된다는 뜻이다. 모델 정의가 프로젝트 안에 명시적으로 남기 때문에, 구조를 파악하기도 쉽고 팀원과 공유하기도 좋다.
Prisma schema API는 datasource, generator, model 등을 Prisma schema 언어로 정의하도록 안내한다.</p>
<h4 id="3-마이그레이션-이력을-남길-수-있다-💭">3. 마이그레이션 이력을 남길 수 있다 <a href="https://www.prisma.io/docs/orm/prisma-migrate?utm_source=chatgpt.com">💭</a></h4>
<p>스키마 변경이 생길 때마다 migration 파일을 생성해 이력을 남길 수 있다.
단순히 &quot;테이블 바뀜&quot;이 아닌 어떤 변경이 어떤 순서로 있었는지 Git에 함께 남길 수도 있다는 뜻이다.
Prisma Migrate는 SQL migration 파일 히스토리를 생성하고, Git 저장소에서 관리할 수 있게 해 준다.</p>
<h4 id="4-nextjs와-공식-가이드가-잘-갖춰져-있다💭">4. Next.js와 공식 가이드가 잘 갖춰져 있다<a href="https://www.prisma.io/docs/guides/frameworks/nextjs?utm_source=chatgpt.com">💭</a></h4>
<p>Prisma는 Next.js용 공식 가이드를 제공하고, App Router 환경과 배포 흐름까지 다룬다. 
즉, 레퍼런스가 잘 정리되어 있고 문제가 생겼으 때 공식 자료를 찾기도 쉽다.</p>
<h3 id="prisma-기본-구조">Prisma 기본 구조</h3>
<p>Prisma를 처음 붙이면 보통 프로젝트 안에 <code>prisma/schema.prisma</code> 파일이 생긴다.
여기서 datasource, generator, model을 정의한다. Prisma 공식 문서는 <code>prisma init</code> 실행 시 Prisma 프로젝트 자산을 초기화하고, <code>prisma.config.ts</code> 역시 Prisma CLI 설정 파일로 사용할 수 있다고 설명한다.</p>
<h5 id="예시-코드">예시 코드</h5>
<pre><code class="language-prisma">model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}</code></pre>
<p>이렇게 정의한 뒤 Prisma Client를 생성하면 코드에서 다음처럼 사용할 수 있다.</p>
<pre><code class="language-ts">import { PrismaClient } from &quot;@prisma/client&quot;;

const prisma = new PrismaClient();

const users = await prisma.user.findMany();

const createdUser = await prisma.user.create({
  data: {
    email: &quot;test@example.com&quot;,
    name: &quot;Lui&quot;,
  },
});</code></pre>
<p>Prisma Client는 schema를 기준으로 생성되기 때문에, 모델 이름과 필드에 맞춰 타입이 따라온다. 그게 Prisma를 사용하는 가장 크게 체감한 장점 중 하나다.</p>
<hr>
<h3 id="prisma를-사용할-때의-기본-흐름">Prisma를 사용할 때의 기본 흐름</h3>
<p>Next.js 프로젝트에서 Prisma를 사용할 때 보통 흐름은 아래와 같다.</p>
<ol>
<li>Prisma 설치 및 초기화</li>
<li><code>schema.prisma</code> 에 모델 정의</li>
<li>Prisma Client 생성</li>
<li>스키마를 DB에 반영</li>
<li>코드에서 Prisma Client 사용</li>
<li>데이터 확인이 필요하면 Prisma Studio 실행</li>
</ol>
<p>이 흐름은 Prisma CLI와 Next.js 공식 가이드에서 안내하는 전형적인 패턴과 맞닿아 있다.</p>
<hr>
<h3 id="db-push-와-migrate-dev-차이">db push 와 migrate dev 차이</h3>
<p><strong>npx prisma db push</strong>
<code>db push</code>는 현재 Prisma schema 상태를 migration 없이 db에 반영한다.
이는 프로토타이핑이나 로컬 개발에 적합하다.</p>
<p><strong>npx prisma migrate dev</strong>
<code>migrate dev</code>는 스키마 변경을 기반으로 migration 파일 히스토리를 생성하고 개발 DB 스키마를 Prisma schema와 동기화 시켜준다.</p>
<p>즉, 협업이나 운영 배포까지 생각한다면 보통 <code>migrate dev</code> 가 더 적절하다.
빠른 실험은 <code>db push</code> , 이력 관리가 필요한 개발은 <code>migrate dev</code> 라고 구분하면 이해하기 쉽다.</p>
<h4 id="shadow-database는-왜-생기나">*shadow database는 왜 생기나</h4>
<p>Prisma를 쓰다 보면 <code>migrate dev</code> 실행 중 shadow database 관련 메시지를 볼 수 있다.
이건 Prisma가 문제를 일으키는 게 아니라, 스키마 드리프트나 잠재적인 데이터 손실 가능성을 감지하기 위해 임시 데이터베이스를 사용하는 동작이다.
Prisma 문서에 따르면 shadow database는 <code>prisma migrate dev</code> 실행 시 자동 생성 및 삭제되며, migration 문제 감지에 사용된다.</p>
<p>사고를 미리 막기위해 검사하는 안전장치 정도로 이해하면 된다.</p>
<hr>
<h3 id="자주-쓰는-prisma-명령어-정리-💭">자주 쓰는 Prisma 명령어 정리 <a href="https://www.prisma.io/docs/cli?utm_source=chatgpt.com">💭</a></h3>
<ol>
<li>Prisma 초기화<pre><code class="language-prisma">npx prisma init</code></pre>
</li>
</ol>
<ul>
<li>Prisma 프로젝트 자산 초기화</li>
<li>보통 <code>prisma/</code> 디렉터리와 기본 설정 파일들이 생성됨</li>
<li>프로젝트 셋업 명령</li>
</ul>
<ol start="2">
<li>Prisma Client 생성<pre><code class="language-prisma">npx prisma generate</code></pre>
</li>
</ol>
<ul>
<li>Prisma schema를 바탕으로 Prisma Client를 생성</li>
<li>모델 수정 후 Client를 다시 생성해야할 때 자주 사용</li>
<li>artifacts 생성 명령</li>
</ul>
<ol start="3">
<li>스키마를 DB에 바로 반영<pre><code class="language-prisma">npx prisma db push</code></pre>
</li>
</ol>
<ul>
<li>마이그레이션 없이 스키마 상태를 DB에 반영</li>
<li>프로토타이핑이나 로컬 초기 개발에 유용</li>
</ul>
<ol start="4">
<li>개발용 migration 생성 및 적용<pre><code class="language-prisma">npx prisma migrate dev</code></pre>
</li>
</ol>
<ul>
<li>스키마 변경을 기준으로 마이그레이션 파일을 만들고 개발 DB에 적용한다.</li>
<li>개발 환경에서 가장 자주 쓰는 명령어 중 하나다.</li>
</ul>
<ol start="5">
<li>운영 환경에 migration 적용</li>
</ol>
<pre><code class="language-prisma">npx prisma migrate deploy</code></pre>
<ul>
<li>이미 생성된 migration 파일들을 운영 데이터베이스에 적용한다.(supabase 같은)</li>
<li>Prisma Migrate는 개발과 운영에서 migration history를 다루는 역할을 한다.</li>
</ul>
<ol start="6">
<li>데이터 GUI 확인<pre><code class="language-prisma">npx prisma studio</code></pre>
</li>
</ol>
<ul>
<li>브라우저 GUI에서 데이터를 조회/수정 가능</li>
<li>테이블 상태를 빨리 확인할 때 편하다</li>
</ul>
<ol start="7">
<li>현재 DB 기준으로 schema 가져오기<pre><code class="language-prisma">npx prisma db pull</code></pre>
</li>
</ol>
<ul>
<li>이미 존재하는 데이터베이스 구조를 기준으로 Prisma schema를 업데이트할 때 사용</li>
</ul>
<ol start="8">
<li>schema 포맷 정리<pre><code class="language-prisma">npx prisma format</code></pre>
</li>
</ol>
<ul>
<li><code>schema.prisma</code> 파일을 포맷팅한다.</li>
</ul>
<ol start="10">
<li>migration 차이 비교</li>
</ol>
<pre><code class="language-prisma">npx prisma migrate diff</code></pre>
<ul>
<li>migration 간 차이나 schema 차이를 비교할 때 사용한다.</li>
<li>스키마 상태 점검에 유용</li>
</ul>
<h3 id="실제-자주-쓰게-되었던-흐름">실제 자주 쓰게 되었던 흐름</h3>
<p>실제 개발에 사용해보니 이러한 흐름으로 가게 되었다.</p>
<ul>
<li>모델을 수정한다.</li>
<li>빠르게 반영 -&gt; <code>npx prisma db push</code> 사용</li>
<li>구조가 확정되었음 -&gt; <code>npx prisma migrate dev --name...</code> 로 migration 파일을 남긴다</li>
<li>필요하면 <code>npx prisma generate</code>로 Client를 다시 생성하고, <code>npx prisma studio</code> 로 데이터를 눈으로 확인한다.</li>
</ul>
<pre><code class="language-bash">npx prisma init
npx prisma generate
npx prisma db push
npx prisma migrate dev --name init
npx prisma studio</code></pre>
<hr>
<h3 id="마무리">마무리</h3>
<p>Prisma를 사용하면서 가장 좋았던 점은, 코드와 함께 관리하는 구조로 바꿔준다는 점이었다.</p>
<p>모델을 schema로 정의하고, Client를 통해 타입 안전하게 접근하고, migration 파일로 이력을 남기고, Studio로 바로 데이터를 확인할 수 있다는 점이 Next.Js 프로젝트와 잘 맞았다.</p>
<p>특히 Next.js처럼 프론트와 서버 로직이 가까이 붙어있는 환경에서는 Prisma가 더 편하게 느껴졌다.
DB 구조와 애플리케이션 코드가 따로 노는 게 아니라, 하나의 흐름으로 연결되었기 때문이다. </p>
<p>개인적으로는 DB를 처음부터 서버 환경에서만 직접 관리하는 방식보다, 로컬에서 먼저 검증하고 migration 파일로 변경 이력을 남기는 방식이 더 안정적이라고 느꼈다.
배포된 DB에 바로 둩어 수정하는 방식은 빠를 수는 있지만, 구조 변경이 누적될수록 추적과 관리가 어려워질 수 있다.
Prisma를 사용한 이유도 바로 이런 점 때문이었고, 단순한 쿼리 편의성보다 스키마를 더 일관되게 관리할 수 있다는 점이 특히 크게 느껴졌다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[리스트형 예약 화면에 달력 추가: FullCalendar 적용기]]></title>
            <link>https://velog.io/@luislki_/%EB%A6%AC%EC%8A%A4%ED%8A%B8%ED%98%95-%EC%98%88%EC%95%BD-%ED%99%94%EB%A9%B4%EC%97%90-%EB%8B%AC%EB%A0%A5-%EC%B6%94%EA%B0%80-FullCalendar-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@luislki_/%EB%A6%AC%EC%8A%A4%ED%8A%B8%ED%98%95-%EC%98%88%EC%95%BD-%ED%99%94%EB%A9%B4%EC%97%90-%EB%8B%AC%EB%A0%A5-%EC%B6%94%EA%B0%80-FullCalendar-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Wed, 01 Apr 2026 12:45:19 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/f44a0e79-18f8-49cb-8cf8-96219c958c45/image.png" alt=""></p>
<h3 id="1-fullcalendar를-붙여보자">1. FullCalendar를 붙여보자</h3>
<p>현재 프로젝트에서 기존 예약 관리 화면은 탭과 검색, 그리고 예약 카드 리스트 중심으로 구성되어 있었다.
탭별로 요청, 진행 중, 완료, 취소/거절 상태를 나눠 조회할 수 있었지만, 사용자는 예약을 시간 흐름으로 파악하기 어려웠다.</p>
<p>특히 다음과 같은 불편함이 있었다.</p>
<ul>
<li>특정 날짜에 어떤 예약이 몰려 있는지 한눈에 보기 어렵다</li>
<li>탭을 바꿀 때마다 상태별 예약 흐름은 보이지만, 실제 일정 분포는 파악하기 힘들다</li>
<li>리스트만으로는 오늘/이번 달/특정 날짜 기준의 맥락이 약하다</li>
</ul>
<p>그래서 예약 관리 화면에 월간 달력을 추가하고, 현재 탭/검색 조건에 맞는 예약을 달력에도 함께 표시하는 구조를 만들기로 했다.</p>
<hr>
<h3 id="2-왜-react-day-picker-대신-fullcalendar를-선택했는가">2. 왜 react-day-picker 대신 FullCalendar를 선택했는가</h3>
<p>처음에는 단순한 날자 선택기 성격의 라이브러리도 고려했지만, 원하는 UI는 단순한 date picker가 아니었다.</p>
<p>내가 원한건:</p>
<ul>
<li>달력이 화면에서 어느정도 비중 있게 보일 것</li>
<li>날짜마다 예약 일정이 표시될  것</li>
<li>날짜 클릭 시 해당 날짜의 예약만 리스트에서 다시 볼 수 있을것</li>
<li>일정 클릭 시 기존 예약 상세 모달까지 연결될 것</li>
</ul>
<p>즉, 단순히 날짜를 고르는 컴포넌트보다 <strong>일정을 보여주는 월간 캘린더</strong>가 필요했다.</p>
<p>그래서 날짜 선택기보다는 이벤트 렌더링이 중심인 <strong>FullCalendar</strong>가 더 적합하다고 판단했다.</p>
<hr>
<h3 id="3-fullcalendar-사용법">3. FullCalendar 사용법</h3>
<h4 id="1-설치">1) 설치</h4>
<pre><code class="language-bash">npm install @fullcalendar/core @fullcalendar/react @fullcalendar/daygrid @fullcalendar/interaction</code></pre>
<p>이번 구현에서는 월간 달력이 필요했기 때문에 <code>daygrid</code> 플러그인을 사용했고, 날짜 클릭 기능을 위해 <code>interaction</code> 플러그인도 함께 설치했다.</p>
<p><code>core</code> : 캘린더 자체의 기본 동작 기반
<code>react</code> : React 컴포넌트로 화면에 렌더
<code>daygrid</code> : 월간 달력 UI 표시(<code>initialView=&quot;dayGridMonth</code>) 
<code>interaction</code> : 날짜 클릭, 이벤트 클릭 같은 동작 연결</p>
<h4 id="2-기본-import">2) 기본 import</h4>
<pre><code class="language-ts">import FullCalendar from &quot;@fullcalendar/react&quot;;
import dayGridPlugin from &quot;@fullcalendar/daygrid&quot;;
import interactionPlugin, {
  type DateClickArg,
} from &quot;@fullcalendar/interaction&quot;;
import koLocale from &quot;@fullcalendar/core/locales/ko&quot;;
import type { EventClickArg, EventInput } from &quot;@fullcalendar/core&quot;;</code></pre>
<ul>
<li><code>FullCalendar</code> : React에서 실제로 렌더링할 캘린더 컴포넌트</li>
<li><code>dayGridPlugin</code> : 월간 달력처럼 칸 형태로 보여주는 뷰</li>
<li><code>interactionPlugin</code> : 날짜 클릭, 선택 같은 상호작용 처리</li>
<li><code>koLocale</code> : 달력을 한국어로 표시하기 위한 locale</li>
<li><code>EventInput</code>, <code>EventClickArg</code>, <code>DateClickArg</code> : 이벤트 데이터와 클릭 핸들러 타입 정의</li>
</ul>
<h4 id="3-기본-렌더링-방식">3) 기본 렌더링 방식</h4>
<p>FullCalendar는 <code>&lt;FullCalendar /&gt;</code> 컴포넌트 옵션을 props 형태로 전달해 사용하는 구조다.</p>
<pre><code class="language-ts">&lt;FullCalendar
  plugins={[dayGridPlugin, interactionPlugin]}
  locales={[koLocale]}
  locale=&quot;ko&quot;
  initialView=&quot;dayGridMonth&quot;
  height=&quot;auto&quot;
  events={events}
  dateClick={handleDateClick}
  eventClick={handleEventClick}
  headerToolbar={{
    left: &quot;prev,next today&quot;,
    center: &quot;title&quot;,
    right: &quot;&quot;,
  }}
  buttonText={{
    today: &quot;오늘&quot;,
  }}
  dayMaxEvents={2}
  fixedWeekCount={false}
/&gt;</code></pre>
<p>여기서 자주 사용한 옵션은 다음과 같다.</p>
<ul>
<li><code>plugins</code> : 사용할 플러그인 등록</li>
<li><code>initialView</code> : 처음 보여줄 달력 형태 지정 (dayGridMonth)</li>
<li><code>events</code> : 달력에 표시할 이벤트 데이터 배열</li>
<li><code>dateClick</code> : 날짜 칸 클릭 시 실행할 함수</li>
<li><code>eventClick</code> : 일정 클릭 시 실행할 함수</li>
<li><code>headerToolbar</code> : 상단 이전/다음/오늘 버튼과 제목 배치</li>
<li><code>dayMaxEvents</code> : 한 날짜에 일정이 많을 때 최대 몇 개까지 보여줄지 설정</li>
<li><code>fixedWeekCount</code> : 항상 6주를 채우지 않고 실제 주 수만큼만 렌더링</li>
</ul>
<p>즉, FullCalendar는 정적인 달력 컴포넌트가 아니라
옵션과 데이터로 동작을 조합하는 캘린더 엔진에 가깝다.</p>
<h4 id="4-날짜-클릭과-이벤트-클릭은-어떻게-쓰는가">4) 날짜 클릭과 이벤트 클릭은 어떻게 쓰는가</h4>
<p>이번 구현에서는 클릭 이벤트를 두 가지로 나눠서 사용했다.</p>
<h4 id="날짜-클릭">날짜 클릭</h4>
<p>날짜 칸 제체를 클릭하면, 그 날짜를 선택된 날짜 상태로 저장하고 리스트에서는 그 날짜의 예약만 다시 보여주도록 했다.</p>
<pre><code class="language-ts">const handleDateClick = (arg: DateClickArg) =&gt; {
  const clickedDate = arg.dateStr;
  onSelectDate(selectedDate === clickedDate ? null : clickedDate);
};</code></pre>
<p>즉, 날짜를 한 번 클릭하면 해당 날짜로 필터링되고, 
같은 날짜를 다시 클릭하면 선택이 해제되도록 만들었다.</p>
<h4 id="이벤트-클릭">이벤트 클릭</h4>
<p>달력 안에 표시된 일정 이벤트를 클릭하면
기존에 카드 클릭 시 사용하던 예약 상세 모달을 그대로 열도록 연결했다.</p>
<pre><code class="language-ts">const handleEventClick = (arg: EventClickArg) =&gt; {
  const reservationId = Number(arg.event.extendedProps.reservationId);

  if (Number.isNaN(reservationId)) return;

  onClickEvent(reservationId);
};</code></pre>
<p>여기서 <code>reservationId</code>는 이벤트 생성 시 <code>extendedProps</code>에 넣어둔 값을 다시 꺼낸 것이다.</p>
<p>해당 방식 덕분에 두 경우 모두 같은 예약 상세 흐름을 재사용할 수 있었다.</p>
<h4 id="5-예약-리스트와-달력-연결">5) 예약 리스트와 달력 연결</h4>
<p>이번 구현에서 가장 중요했던 건
달력과 리스트가 같은 데이터를 봐야 한다는 점이었다.
그래서 예약 데이터는 아래 순서로 가공했다.</p>
<ol>
<li><code>viewItems</code> : 탭, 날짜 범위 검색, 정렬까지 반영된 최종 예약 목록</li>
<li><code>calendarEvents</code> : <code>viewItems</code>를 FullCalendar용 이벤트 배열로 변환한 값</li>
<li><code>visibleItems</code> : 사용자가 달력에서 특정 날짜를 선택했을 때 - 그 날짜에 해당하는 예약만 다시 걸러낸 목록 </li>
</ol>
<h5 id="예시-코드">예시 코드</h5>
<pre><code class="language-ts">const viewItems = useMemo(() =&gt; {
  // 탭 + 검색 + 정렬 적용
  return items;
}, [...]);

const calendarEvents = useMemo(() =&gt; {
  return toReservationCalendarEvents(viewItems);
}, [viewItems]);

const visibleItems = useMemo(() =&gt; {
  if (!selectedDate) return viewItems;

  return viewItems.filter(
    (item) =&gt;
      pickYmdFromLocalDateTime(item.timeSlot.startDt) === selectedDate,
  );
}, [viewItems, selectedDate]);</code></pre>
<p>이렇게 구성하면 </p>
<ul>
<li>달력은 월 전체 예약 맥락을 보여주고</li>
<li>리스트는 선택된 날짜 기준으로 더 좁게 보여줄 수 있다</li>
</ul>
<p>즉, 같은 데이터를 두 방식으로 보여주되 역할은 다르게 나누는 구조가 된다.</p>
<hr>
<h3 id="4-해당-프로젝트-화면에서-fullcalendar가-맡은-역할">4. 해당 프로젝트 화면에서 FullCalendar가 맡은 역할</h3>
<p>FullCalendar는 단순히 달력 UI만 담당한 것이 아니라, 예약 관리 화면 안에서 다음 역할을 맡도록 설계했다.</p>
<ul>
<li>현재 탭/검색 조건에 맞는 예약을 달력에 이벤트로 표시</li>
<li>날짜 클릭 시 해당 날짜의 예약만 리스트에 표시</li>
<li>이벤트 클릭 시 기존 예약 상세 모달 열기</li>
<li>달력 접기/펼치기 상태 제어</li>
</ul>
<p>즉, 기존 리스트형 화면에 달력을 하나 더 붙인 것이 아니라
<strong>같은 예약 데이터를 다른 방식으로 시각화하는 보조 뷰</strong>로 사용했다.</p>
<hr>
<h3 id="5-컴포넌트-구조를-어떻게-나눴는가">5. 컴포넌트 구조를 어떻게 나눴는가</h3>
<p>처음부터 페이지 안에 FullCalendar를 직접 넣기보다, 달력 섹션을 별도 컴포넌트로 분리했다.</p>
<h4 id="분리한-이유">분리한 이유</h4>
<p>예약 관리 페이지는 이미 다음 책임을 가지고 있었다.</p>
<ul>
<li>탭 상태 관리</li>
<li>검색 조건 관리</li>
<li>예약 리스트 조회</li>
<li>예약 상세 모달 상태 관리</li>
</ul>
<p>여기에 달력까지 직접 넣으면 페이지 컴포넌트가 너무 비대해지기 때문에 역할을 나눴다.
심지어 유저와 아티스트의 페이지가 나눠져있어 동일한 컴포넌트를 사용해야했었기 때문에 분리는 더욱 필요했다.</p>
<h4 id="구조">구조</h4>
<ul>
<li><p><code>ArtistReservationPage</code> / <code>MyReservationPage</code></p>
<ul>
<li>탭, 검색, 선택 날짜, 모달 상태 관리</li>
<li>예약 데이터를 필터링/정렬</li>
<li>달력에 넘길 이벤트 배열 생성</li>
</ul>
</li>
<li><p><code>ReservationCalendarSection</code></p>
<ul>
<li>FullCalendar 렌더링</li>
<li>날짜 클릭, 이벤트 클릭 전달</li>
<li>접기/펼치기 UI 담당</li>
</ul>
</li>
<li><p><code>reservationCalendarUtils</code></p>
<ul>
<li>예약 DTO를 FullCalendar 이벤트 형식으로 변환</li>
</ul>
</li>
</ul>
<p>Page : 상태와 데이터 가공에 집중
Component : 표현과 인터랙션에 집중</p>
<hr>
<h3 id="6-예약-데이터를-달력-이벤트로-바꾸는-과정">6. 예약 데이터를 달력 이벤트로 바꾸는 과정</h3>
<p>FullCalendar는 이벤트 데이터를 다음과 같은 형태로 받는다.</p>
<pre><code class="language-TS">{
  id: &quot;1&quot;,
  title: &quot;확정 · 트럼펫 레슨&quot;,
  start: &quot;2026-04-15T10:00:00&quot;,
  end: &quot;2026-04-15T11:00:00&quot;
}</code></pre>
<p>하지만 내가 갖고 있던 예약 데이터는 예약 도메인 중심 DTO였다.
그래서 예약 응답을 FullCalendar 이벤트 형식으로 변환하는 유틸을 만들었다.</p>
<p>변환 시 사용한 값들은 기본적으로 예약 아이디, 상태, 레슨 제목, 시작일, 종료일이었고,
기존 상태칩 UI에서 쓰던 상수와 컬러그룹을 재사용해 달력 이벤트 색상도 예약 상태와 일관되게 맞췄다.</p>
<hr>
<h3 id="7-구현-중-타입-문제">7. 구현 중 타입 문제</h3>
<p>처음에는 캘린더 유틸의 인자 타입을 <code>ArtistReservationSummaryResp[]</code> 정도로 고정해두었다.
하지만 유저 예약화면에는 쓰이지 않는 타입이라 타입 에러가 발생했다.</p>
<p>문제는 캘린더가 실제로 필요한 필드는 많지 않은데, 유틸 설계를 특정 화면의 DTO에 너무 치우쳐 설계되었다는 점이다.
그래서 상단에 언급한 값들을 들고가며 <strong>캘린더에 필요한 최소 공통 필드만 가지는 타입으로 유틸 입력을 일반화</strong>시켰다.</p>
<p>이 과정을 통해 &quot;화면용 DTO를 그대로 유틸에 사용&quot;하는 것보다 &quot;실제로 필요한 최소 구조를 기준으로 타입을 설계하는 것&quot;이 공통 요소에서는 더 낫다는것을 느꼈다.</p>
<hr>
<h3 id="8-날짜-클릭과-이벤트-클릭은-어떻게-연결했는가">8. 날짜 클릭과 이벤트 클릭은 어떻게 연결했는가</h3>
<p>이번 구현에서 FullCalendar의 인터렉션은 두 가지였다.</p>
<h4 id="1-날짜-클릭">1) 날짜 클릭</h4>
<p>날짜를 클릭하면 <code>selectedDate</code> 상태를 바꾸고,
리스트에서는 이 날짜와 일치하는 예약만 다시 보여주도록 했다.</p>
<p>즉, </p>
<ul>
<li>달력 = 월 전체 일정 맥락 제공</li>
<li>리스트 = 선택한 날짜 기준 상세 목록 제공</li>
</ul>
<p>이런 역할 분리가 되도록 구성했다.</p>
<h4 id="2-이벤트-클릭">2) 이벤트 클릭</h4>
<p>달력에 표시된 예약 이벤트를 클릭하면
기존에 카드 클릭 시 열리던 예약 상세 모달을 그대로 재사용했다.</p>
<p>이벤트의 <code>extendeedProps</code> 에 <code>reservationId</code>를 넣어두고,
이 값을 이용해 기존 예약 상세 흐름과 연결했다.</p>
<p>이렇게 하니 카드에서 보든 달력에서 보든 결국 같은 상세 모달로 진입하게 되어 UX가 자연스러워 졌다.</p>
<hr>
<h3 id="마무리">마무리</h3>
<p>이번 작업은 단순히 달력을 하나 붙이는 작업이라기보다, 기존 예약 리스트를 시간 흐름으로 다시 보여주는 구조를 만드는 과정에 가까웠다.
FullCalendar를 처음 적용해보면서 생각보다 고려할 부분이 많았지만, 그 과정 덕분에 데이터 가공 기준과 공통 컴포넌트 분리에 대해 더 많이 정리해볼 수 있었다.
다음에는 이번 흐름을 바탕으로 특정 레슨 일정 강조나 시간 충돌 확인 같은 기능도 붙여보려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 첫 시작(feat. newProject)]]></title>
            <link>https://velog.io/@luislki_/Next.js-%EC%B2%AB-%EA%B1%B8%EC%9D%8Cfeat.-newProject</link>
            <guid>https://velog.io/@luislki_/Next.js-%EC%B2%AB-%EA%B1%B8%EC%9D%8Cfeat.-newProject</guid>
            <pubDate>Thu, 19 Mar 2026 14:25:30 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/b8fc528c-ad1a-431f-95e8-a9eae2a1b4e5/image.png" alt=""></p>
<h3 id="nextjs를-사용하게-된-이유">Next.js를 사용하게 된 이유</h3>
<p>현재 진행하려는 프로젝트에 대해 조금 고민이 있었다. </p>
<ol>
<li><p>일단, 새로운걸 접해보고 싶었다.
음악 전공에서 디자인 업무, 그리고 그 시작점에서 개발 공부까지 1년째.
개발과는 너무나도 머나먼 여정을 걸어온 나에게 빡센 공부였지만, 
계속 사용하던 프레임워크만 사용할 수는 없었다.</p>
</li>
<li><p>가성비 (?)
원래 백엔드를 GO로, 프론트는 원래 하던대로 React - ts를 사용할 예정이었지만
따지고보면 그렇게 큰 프로젝트는 아니라는 생각에 Next.js로 퉁치는게 가성비가 좋다고 느꼈다.
가성비가 좋다는것은, 백엔드를 전혀 모르는 언어로 구성하고 아는것을 붙이는것보다,
차라리 아는것을 응용하고 현업에서 요구하는 스택을 쌓는게 더 낫지 않느냐의 문제였다.</p>
</li>
</ol>
<hr>
<h3 id="react와-nextjs는-뭐가-다른가">React와 Next.js는 뭐가 다른가</h3>
<h4 id="react">React</h4>
<p>React는 UI를 컴포넌트로 만드는 라이브러리다.</p>
<p>예를 들면:</p>
<ul>
<li>버튼 컴포넌트</li>
<li>카드 컴포넌트</li>
<li>화면 컴포넌트</li>
</ul>
<p>이런 식으로 UI를 조립하는 도구라고 보면 된다.</p>
<h4 id="nextjs">Next.js</h4>
<p>Next.js는 React를 기반으로 페이지 구조와 앱 기능을 더 쉽게 관리하게 해주는 프레임 워크다.</p>
<p>즉 비유하면:</p>
<ul>
<li>React = 부품 만들기</li>
<li>Next.js = 부품으로 집 짓는 규칙까지 포함한 설계 도구</li>
</ul>
<p>그래서 Next.js 안에서는 React 컴포넌트를 그대로 쓰지만,(jsx, tsx)
추가로 &quot;페이지는 어디에 두는지&quot;, &quot;URL은 어떻게 생기는지&quot; 같은 규칙이 생긴다.\</p>
<hr>
<h3 id="오늘-한-일">오늘 한 일</h3>
<ol>
<li>Next 프로젝트 생성
처음에 <code>npx create-next-app@latest 프로젝트 명</code> 으로 프로젝트를 만들었다.</li>
</ol>
<p>이 과정에서:</p>
<ul>
<li>React</li>
<li>Next</li>
<li>TypeScript</li>
<li>Tailwind</li>
<li>ESLint</li>
</ul>
<p>같은 기본 세팅이 같이 설치된다.
즉, 그냥 빈 폴더를 만든 게 아니라
<strong>Next.js 개발을 시작할 수 있는 기본 환경</strong>을 만들었다.</p>
<ol start="2">
<li><p>개발 서버 실행
기존 React 프로젝트와 동일하게 <code>npm run dev</code> 로 서버를 실행했고,
이걸 켜면 <code>localhost:3000</code> 에서 지금 만든 앱을 바로 볼 수 있다.</p>
</li>
<li><p>app 폴더 확인
Next.js App Router를 쓰면 <code>app</code>  폴더가 핵심이 된다.</p>
</li>
</ol>
<p>여기서 중요한건:</p>
<ul>
<li>app/page.tsx</li>
<li>app/plan/page.tsx</li>
<li>app/summary/page.tsx</li>
</ul>
<p>같이 폴더 구조 자체가 URL 경로가 된다는 점이다.
이게 React Router랑 다른 핵심 포인트 중 하나다.</p>
<ol start="4">
<li>features 폴더 구성
그 다음에 <code>features</code> 폴더를 만들었다.</li>
</ol>
<p>이건 Next.js가 강제하는 구조는 아니고,
내가 프로젝트를 보기 좋게 정리하기 위해 직접 만든 구조다.</p>
<p>예를 들면:</p>
<ul>
<li>features/landing</li>
<li>features/plan</li>
<li>features/summary</li>
<li>features/pdf</li>
</ul>
<p>이렇게 기능 단위로 나누면,
나중에 파일이 많아져도 덜 헷갈린다.</p>
<hr>
<h3 id="nextjs에서-제일-중요한-개념-pagetsx">Next.js에서 제일 중요한 개념: page.tsx</h3>
<p><strong><code>page</code>는 무엇인가</strong>
해당 경로에서 실제로 보여줄 페이지 컴포넌트 파일이다.</p>
<p>예를 들어</p>
<pre><code>app/
  page.tsx
  plan/
    page.tsx
  summary/
    page.tsx</code></pre><p>이 구조는 URL로 보면 이렇게 된다.</p>
<ul>
<li>app/page.tsx → /</li>
<li>app/plan/page.tsx → /plan</li>
<li>app/summary/page.tsx → /summary</li>
</ul>
<p>즉 폴더 이름이 URL이 되고,
그 안의 <code>page.tsx</code> 가 그 화면의 진입점이 되는것이다.</p>
<p>→ 결국은 <strong>폴더가 경로다</strong> 정도로 이해하고 넘어갔다.</p>
<hr>
<h3 id="그럼-pagetsx-안에-모든-걸-다-쓰나">그럼 page.tsx 안에 모든 걸 다 쓰나?</h3>
<p>그렇진 않다.</p>
<p><code>page.tsx</code>는 페이지의 입구 역할만 하게 두었다.
실제 화면 내용은 <code>features</code>폴더에 분리해서 넣는게 깔끔해보였다.</p>
<p>예를 들어 
app/page.tsx</p>
<pre><code class="language-ts">import LandingPage from &quot;@/features/landing/components/LandingPage&quot;;
// @/ = 일종의 alias. 상대 경로를 적지 않아도 루트부터 시작하는 깔끔한 경로 표기

export default function Page() {
  return &lt;LandingPage /&gt;;
}</code></pre>
<p>여기서 실제 랜딩 화면은 <code>LandingPage.tsx</code> 가 담당한다.</p>
<p>즉 역할 분리는 이렇다.</p>
<ul>
<li>app/.../page.tsx = URL 연결용</li>
<li>features/... = 실제 화면 구현</li>
</ul>
<p>이렇게 분리하면 나중에 파일이 커져도 나름 뭐...OK</p>
<hr>
<h3 id="이번에-만든-폴더-구조에-대해서">이번에 만든 폴더 구조에 대해서</h3>
<pre><code>app/
  page.tsx
  plan/page.tsx
  summary/page.tsx

features/
  landing/components/
  odyssey-plan/components/
  plan/model/
  plan/store/
  summary/components/
  summary/model/
  pdf/components/
  pdf/model/</code></pre><h4 id="app">app</h4>
<p>라우트 진입점.
즉 URL 경로 담당.</p>
<h4 id="features">features</h4>
<p>기능별 묶음.
랜딩, 플랜 작성, 요약, PDF 같은 실제 도메인 단위.</p>
<h4 id="componemts">componemts</h4>
<p>화면 조각들
ex: <code>LandingPage</code>, <code>SummaryPage</code>, 카드, 버튼, 섹션 등.</p>
<h4 id="model">model</h4>
<p>타입, 상수, 계산 함수(Utils) 같은 것들.</p>
<h4 id="store">store</h4>
<p>zustand 같은 상태 관리 파일.</p>
<p>즉 한마디로 하면:
app은 길 안내, features는 실제 방 구조라고 보면 된다.</p>
<hr>
<h3 id="nextjs를-처음-접하며-이해한-점">Next.js를 처음 접하며 이해한 점</h3>
<p>처음에는 <code>page.tsx</code> 가 여러개 생기고, <code>app</code> 폴더와 <code>features</code> 폴더가 도잇에 존재하는 구조가 낯설었다.
React에서는 보통 라우팅을 직접 설정하고, 컴포넌트 파일을 자유롭게 배치하는 경우가 많았기 때문에 &quot;왜 같은 이름의 파일이 여러 개 인가&quot;라는 혼란이 있었다.</p>
<p>하지만 App Router 구조를 이해하고 나니 흐름이 정리됐다.</p>
<ul>
<li><code>app</code> 폴더는 URL과 직접 연결되는 라우트 진입점</li>
<li><code>page.tsx</code> 는 각 경로에서 실제로 렌더링될 페이지</li>
<li><code>features</code> 폴더는 기능 단위로 화면과 로직을 정리하기 위한 사용자 정의 구조 </li>
</ul>
<p>즉 <code>app</code>은 경로를 연결하는 입구이고, <code>features</code> 는 실제 페이지 내용을 구성하는 공간이라는 점을 이해하면서 구조가 명확하게 보이기 시작했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TanStack Query] 검색 조건, URL, 서버 상태 나눠서 관리하기]]></title>
            <link>https://velog.io/@luislki_/TanStack-Query-%EA%B2%80%EC%83%89-%EC%A1%B0%EA%B1%B4-URL-%EC%84%9C%EB%B2%84-%EC%83%81%ED%83%9C-%EB%82%98%EB%88%A0%EC%84%9C-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@luislki_/TanStack-Query-%EA%B2%80%EC%83%89-%EC%A1%B0%EA%B1%B4-URL-%EC%84%9C%EB%B2%84-%EC%83%81%ED%83%9C-%EB%82%98%EB%88%A0%EC%84%9C-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Mar 2026 15:14:34 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/52784170-629e-46ed-8b65-e0dfae49913c/image.png" alt=""></p>
<h3 id="1-도입">1. 도입</h3>
<p>검색 화면을 만들다 보면 생각보다 상태가 단순하지 않다.
처음에는 <code>useState</code> 로 검색어와 필터만 관리하면 될 것처럼 보이지만, 조건이 늘어날수록 문제가 생긴다.</p>
<p>예를들어 이런 것들이다.</p>
<ul>
<li>사용자가 필터를 바꾸는 중인데 아직 검색은 실행하지 않은 상태</li>
<li>검색 결과에 실제 반영된 조건과 화면에 임시로 선택된 조건이 다른 상태</li>
<li>새로고침하거나 뒤로가기를 했을 때 검색 조건이 유지되어야 하는 문제</li>
<li>서버에서 받아온 검색 결과는 어떤 상태로 봐야 하는지에 대한 문제</li>
</ul>
<p>이번 글에서는 개인 프로젝트에서 검색 기능을 구연하며
<strong>검색 조건, URL 상태, 서버 상태</strong>를 각각 어떻게 나눠서 관리했는지 정해보려 한다.</p>
<hr>
<h3 id="2-왜-상태를-나눠서-생각해야-했을까">2. 왜 상태를 나눠서 생각해야 했을까</h3>
<p>처음에는 검색 조건을 모두 하나의 state 로 관리하면 된다고 생각했다.
하지만 실제로 구현하다 보니 이 방식은 금방 한계가 드러났다.</p>
<p>검색 화면에는 크게 세 종류의 상태가 섞여 있었다.</p>
<ol>
<li>사용자가 지금 입력/선택 중인 값(Draft)</li>
<li>실제로 검색에 적용된 값</li>
<li>서버에서 받아온 검색 결과</li>
</ol>
<p>이 셋을 하나로 묶어버리면 문제가 생긴다.</p>
<p>예를 들어 사용자가 드롭다운에서 악기를 이것저것 선택하는  순간마다 바로 서버 요청이 나가면 UX가 산만해진다.</p>
<p>반대로 적용 버튼을 두면, 지금 화면에 보이는 선택값과 실제 결과를 만든 조건이 달라질 수 있다,</p>
<p>즉, 검색 화면은 단순한 입력 폼이 아니라 <strong>&quot;편집 중인 조건&quot; 과 &quot;적용된 조건&quot; 이 공존하는 구조</strong>였다.</p>
<p>그래서 나는 상태를 다음처럼 분리했다.</p>
<ul>
<li>draft state: 사용자가 현재 수정 중인 검색 조건</li>
<li>applied state: 실제 검색 요청에 반영된 조건</li>
<li>server state: TanStack Query가 관리하는 검색 결과</li>
<li>URL state: 새로고침, 공유, 뒤로가기까지 고려한 검색 조건의 표현</li>
</ul>
<hr>
<h3 id="3-어떤-상태를-어디에-둘지-기준-세우기">3. 어떤 상태를 어디에 둘지 기준 세우기</h3>
<p>핵심은 &quot;이 값은 누구의 책임인가?&quot;를 먼저 정하는것이다.</p>
<h4 id="3-1-draft-state-아직-확정되지-않은-사용자의-입력">3-1. draft state: 아직 확정되지 않은 사용자의 입력</h4>
<p>검색창의 입력값, 드롭다운에서 선택 중인 값, 날짜 범위 등은 사용자가 아직 조정 중일 수 있다.</p>
<p>이 값들은 바로 서버 요청으로 이어질 필요가 없다.
그래서 로컬 상태로 두고, 사용자가 검색 버튼을 누르거나 적용 액션을 했을 때만 실제 검색 조건으로 반영되도록 했다.</p>
<h5 id="이하-예시">이하 예시</h5>
<pre><code class="language-ts">const [draft, setDraft] = useState({
  keyword: &quot;&quot;,
  instrumentCategory: &quot;ALL&quot;,
  instIds: [],
  styleTagIds: [],
});</code></pre>
<p>이 draft는 말 그대로 편집 중인 임시 상태다.</p>
<hr>
<h4 id="3-2-applied-state-실제-검색-요청--기준">3-2. applied state: 실제 검색 요청  기준</h4>
<p>검색 버튼을 눌렀을 때만 draft를 applied로 복사한다.
이 applied state가 실제 검색 결과를 만들 기준이 된다.</p>
<pre><code class="language-ts">const [applied, setApplied] = useState({
  keyword: &quot;&quot;,
  instrumentCategory: &quot;ALL&quot;,
  instIds: [],
  styleTagIds: [],
});

const onSearch = () =&gt; {
  setApplied(draft);
};</code></pre>
<p>이렇게 하면 사용자가 조건을 바꾸는 동안에는 결과가 흔들리지 않고, 
원하는 시점에서만 검색을 다시 실행할 수 있다.</p>
<hr>
<h4 id="3-3-server-state-검색-결과는-tanstack-query에게-맡기기">3-3. server state: 검색 결과는 TanStack Query에게 맡기기</h4>
<p>검색 결과는 프론트 로컬 상태가 아니라 <strong>서버 상태</strong>다.
직접<code>useState</code>로 들고 있을 필요가 없다.</p>
<p>이런 데이터는 TanStack Query가 더 잘한다.</p>
<ul>
<li>캐싱</li>
<li>로딩/에러 상태 관리</li>
<li>쿼리 키 기반 재요청</li>
<li>동일 조건 재방문 시 데이터 재사용</li>
</ul>
<p>예를 들어 검색 결과는 applied state를 기준으로 쿼리 키를 구성할 수 있다.</p>
<pre><code class="language-ts">const { data, isLoading, isError } = useQuery({
  queryKey: [&quot;lessonSearch&quot;, applied],
  queryFn: () =&gt; getLessonsReq(applied),
});</code></pre>
<p>이렇게 하면 &quot;무슨 조건으로 조회한 결과인지&quot;가 queryKey에 그대로 남는다.
즉, 결과 데이터의 책임은 TanStack Query가 가지게 된다.</p>
<hr>
<h3 id="4-url까지-관리해야-했던-이유">4. URL까지 관리해야 했던 이유</h3>
<p>여기서 끝내면 아쉬운 문제가 남는다. 사실 해당 글을 작성하게 된 가장 큰 요인이다.</p>
<p>검색 조건을 로컬 상태에만 두면 이런 일이 발생한다.</p>
<ul>
<li>새로고침하면 조건이 사라진다</li>
<li>뒤로가기를 하면 이전 검색 상태가 복원되지 않는다</li>
<li>링크를 공유해도 같은 검색 결과를 재현할 수 없다</li>
</ul>
<p>검색 페이지라면 이런 UX는 피할 수 있으면 피해야 한다고 생각한다.
사용자는 검색 결과를 하나의 &quot;상태&quot;가 아니라 거의 &quot;페이지&quot;처럼 인식하기 때문이다.</p>
<p>그래서 적용된 검색 조건은 URL 에도 반영하도록 했다.</p>
<h5 id="예시">예시</h5>
<pre><code class="language-ts">?keyword=flute&amp;instrumentCategory=WOODWIND&amp;instIds=1&amp;instIds=2</code></pre>
<p>이렇게 하면 URL만 봐도 현재 어떤 조건으로 검색 중인지 알 수 있고,
브라우저 히스토리와도 자연스럽게 연결된다.</p>
<hr>
<h3 id="5-draft는-로컬에-applied는-url에-두는-구조">5. draft는 로컬에, applied는 URL에 두는 구조</h3>
<p>여기서 중요한 포인트는 
<strong>모든 상태를 URL에 넣지 않았다는 점</strong>이다.</p>
<p>사용자가 드롭다운을 열고 이것저것 만지는 순간마다 URL이 계속 바뀌면
히스토리도 지저분해지고 UX도 좋지 않다.</p>
<p>그래서 기준을 이렇게 잡았다.</p>
<ul>
<li>draft state: 컴포넌트 로컬 상태</li>
<li>applied state: URL과 동기화</li>
<li>server state: Tanstack Query</li>
</ul>
<p>즉, 사용자가 &quot;검색 실행&quot;을 한 시점의 조건만 URL에 반영했다.</p>
<p>흐름은 대략 이렇다.</p>
<ol>
<li>사용자가 draft를 수정한다</li>
<li>검색 버튼을 누른다</li>
<li>draft를 applied로 확정한다</li>
<li>applied를 URLSearchParams로 직렬화한다</li>
<li>TanStack Query가 applied 기준으로 서버 요청을 보낸다</li>
</ol>
<p>이 구조 덕분에 입력 중 상태와 적용된 상태를 깔끔하게 분리할 수 있었다.</p>
<hr>
<h3 id="6-배열-필터는-특히--url-설계가-중요했다">6. 배열 필터는 특히  URL 설계가 중요했다</h3>
<p>문자열 검색이 하나만 다루면 쉬운데, 
실제 검색에서는 배열 조건이 꼭 등장한다.</p>
<p>내 경우에는 이런 값들이 있었다.</p>
<ul>
<li><code>instIds: number[]</code></li>
<li><code>styleTagIds: number[]</code></li>
</ul>
<p>이런 배열 조건은 URL에 어떻게 표현할지부터 정해야 한다.
나는 반복 키 방식으로 맞췄다.</p>
<pre><code class="language-ts">instIds=1&amp;instIds=2&amp;instIds=3</code></pre>
<p>이 방식은 프론트에서도 직관적이고, 백엔드에서 <code>@RequestParam List&lt;Long&gt;</code> 혹은 비슷한 구조로 받기도 편하다.</p>
<p>예를들어 <code>qs</code> 같은 라이브러리를 사용하면 이런 식으로 다룰 수 있다.</p>
<pre><code class="language-ts">qs.stringify(
  { instIds: [1, 2, 3] },
  { arrayFormat: &quot;repeat&quot; }
);</code></pre>
<p>배열 필터를 다루는 순간 URL 설계는 부가 기능이 아닌 <strong>검색 API와 프론트 상태 구조를 연결하는 인터페이스</strong>가 된다. </p>
<hr>
<h3 id="7-이번-구조에서-얻은-장점">7. 이번 구조에서 얻은 장점</h3>
<p><strong>1. 검색 실행 시점이 명확해졌다</strong>
사용자가 입력하는 순간마다 서버 요청이 나가지 않고,
적용된 조건만 기준이 되니 동작이 안정적이었다.</p>
<p><strong>2. 새로고침/뒤로가기/공유에 대응할 수 있었다</strong>
검색 조건이 URL에 남기 때문에 
브라우저 히스토리와 사용 경험이 자연스럽게 연결되었다.</p>
<p><strong>3. 상태의 책임이 분리되었다</strong></p>
<ul>
<li>편집 중인 값은 로컬 상태</li>
<li>적용된 조건은 URL</li>
<li>결과 데이터는 TanStack Query</li>
</ul>
<p>이 구분 덕분에 코드의 역할도 선명해졌다.</p>
<hr>
<h3 id="마무리">마무리</h3>
<p>검색 화면은 생각보다 상태가 많은 UI다.
특히 필터가 여러 개 붙고, URL 공유와 브라우저 히스토리까지 고려하기 시작하면 단순히 <code>useState</code> 몇 개로 끝나지 않는다.</p>
<p>이번 구현에서 내가 정리한 기준은 </p>
<ul>
<li>사용자가 편집 중인 값은 로컬 상태</li>
<li>실제로 검색에 반영된 값은 URL과 동기화</li>
<li>검색 결과는 TanStack Query가 관리</li>
</ul>
<p>결국 중요한 건 상태를 많이 쓰느냐 적게 쓰느냐가 아니라,
<strong>각 상태의 책임을 어디까지로 볼 것인가</strong>였다.</p>
<p>검색 기능은 이전에도 구현해봤지만,
막상 구조를 제대로 생각해서 나누려고 하니 지저분해지는 영역이라는 생각이 들었다.
그래서 처음부터 역할을 나눠두고 상황을 고려하는것이 중요하다고 느껴졌다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] qs 사용하기 (feat.query-string)]]></title>
            <link>https://velog.io/@luislki_/React-qs-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-feat.query-string</link>
            <guid>https://velog.io/@luislki_/React-qs-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-feat.query-string</guid>
            <pubDate>Fri, 13 Mar 2026 15:15:07 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/ae7f7192-09d5-4f9a-a1ed-6257bfd53136/image.png" alt=""></p>
<h3 id="qs란">qs란,</h3>
<p>객체(object)를 query string으로 변환하거나, query string 을 다시 객체로 파싱(parse)하는 라이브러리다.</p>
<p>쉽게 말하면 프론트에서 이렇게 관리하는 데이터를</p>
<pre><code class="language-typescript">{
  keyword: &quot;전공&quot;,
  styleTagIds: [1, 2],
  instIds: [3, 7],
}</code></pre>
<p>이런 URL 쿼리 형태로 바꿔주는 역할을 한다.</p>
<pre><code class="language-typescript">keyword=%ED%94%8C%EB%A3%BB&amp;styleTagIds=1&amp;styleTagIds=2&amp;instIds=3&amp;instIds=7</code></pre>
<p>React에서 검색 필터를 구현하다 보면
검색 조건은 보통 객체 형태로 관리하지만, GET 요청을 보낼 때는 결국 query string 형태로 바꿔야한다.</p>
<p>처음에느 단순해 보이지만, 필터가 많아지고 배열 형태의 값이 생기면 직접 조립하는 코드가 지저분해진다.</p>
<hr>
<h3 id="qs를-쓰기-전-직접-query-string을-조립하던-코드예시">qs를 쓰기 전: 직접 query string을 조립하던 코드(예시)</h3>
<p>처음에는 <code>URLSearchParams</code> 로도 충분하다고 생각했다.</p>
<pre><code class="language-typescript">export const searchLessonReq = async (params: {
  keyword?: string;
  mode?: string;
  styleTagIds?: number[];
  instCategory?: string;
  instIds?: number[];
  from?: string;
  to?: string;
  daysOfWeek?: number[];
  timeParts?: string[];
}) =&gt; {
  const searchParams = new URLSearchParams();

  if (params.keyword) searchParams.append(&quot;keyword&quot;, params.keyword);
  if (params.mode) searchParams.append(&quot;mode&quot;, params.mode);
  if (params.instCategory) {
    searchParams.append(&quot;instCategory&quot;, params.instCategory);
  }
  if (params.from) searchParams.append(&quot;from&quot;, params.from);
  if (params.to) searchParams.append(&quot;to&quot;, params.to);

  params.styleTagIds?.forEach((id) =&gt; {
    searchParams.append(&quot;styleTagIds&quot;, String(id));
  });

  params.instIds?.forEach((id) =&gt; {
    searchParams.append(&quot;instIds&quot;, String(id));
  });

  params.daysOfWeek?.forEach((day) =&gt; {
    searchParams.append(&quot;daysOfWeek&quot;, String(day));
  });

  params.timeParts?.forEach((tp) =&gt; {
    searchParams.append(&quot;timeParts&quot;, tp);
  });

  return axios.get(`/api/lessons?${searchParams.toString()}`);
};</code></pre>
<p>동작은 하지만, </p>
<ul>
<li>조건이 하나 추가될 때마다 <code>append()</code> 를 직접 써야 한다</li>
<li>배열 필드는 전부 <code>foreach</code> 로 반복해야 한다</li>
<li>빈 값, 없는 값 처리까지 계속 신경 써야 한다</li>
<li>필터가 많아질수록 코드가 길어진다</li>
<li>결국 &quot;검색 요청&quot;보다 &quot;문자열 조립&quot; 코드가 더 눈에 들어온다</li>
</ul>
<p>즉, 비즈니스 로직보다 직렬화 코드가 존재감이 더 커지는 상황이 발생한다.</p>
<hr>
<h3 id="qs-적용-후예시">qs 적용 후(예시)</h3>
<p>해당 문제를 줄이기 위해 <code>qs</code> 를 도입했다.</p>
<pre><code class="language-bash">npm i qs</code></pre>
<p>적용 후 코드는 이렇게 바뀌었다.</p>
<pre><code class="language-typescript">export const searchLessonReq = async (params: {
  keyword?: string;
  mode?: string;
  styleTagIds?: number[];
  instCategory?: string;
  instIds?: number[];
  from?: string;
  to?: string;
  daysOfWeek?: number[];
  timeParts?: string[];
}) =&gt; {
  const query = qs.stringify(params, {
    arrayFormat: &quot;repeat&quot;,
    skipNulls: true,
  });

  return axios.get(`/api/lessons?${query}`);
};</code></pre>
<p>훨씬 간단해졌다.</p>
<p>이제는 query string을 직접 조립하지 않고,
검색 조건 객체를 만들고 <code>qs.stringify()</code>에 넘기는 것만으로 끝낸다.</p>
<p>이렇게 되면 조건이 늘어나도 params 만 관리하면 되고,
직렬화 방식은 라이브러리에 맡길 수 있다.</p>
<hr>
<h3 id="arrayformat-repeat-를-사용한-이유">arrayFormat: &quot;repeat&quot; 를 사용한 이유</h3>
<p>배열 파라미터가 많은 검색 API를 구현한다고 가정하자.</p>
<p>예를들어 <code>styleTagIds: [1, 2]</code> 를 query string으로 만들 때, 배열은 여러 방식으로 표현할 수 있다.</p>
<p>repat 방식</p>
<pre><code class="language-ts">styleTagIds=1&amp;styleTagIds=2</code></pre>
<p>braket 방식</p>
<pre><code class="language-ts">styleTagIds[]=1&amp;styleTagIds[]=2</code></pre>
<p>comma 방식</p>
<pre><code class="language-ts">styleTagIds=1,2</code></pre>
<p>이번에는 이 중에서 <code>repeat</code> 방식을 사용했다.</p>
<pre><code class="language-ts">qs.stringify(
  { styleTagIds: [1, 2] },
  { arrayFormat: &quot;repeat&quot; }
);</code></pre>
<p>해당 방식을 선택한 이유는 Spring 백엔드에서 다음과 같이 받고 있었기 때문이다.</p>
<pre><code class="language-java">@RequestParam(required = false) List&lt;Long&gt; styleTagIds
@RequestParam(required = false) List&lt;Long&gt; instIds
@RequestParam(required = false) List&lt;Integer&gt; daysOfWeek
@RequestParam(required = false) List&lt;String&gt; timeParts</code></pre>
<p>즉, 프론트에서 같은 key를 반복하는 형태로 보내면 Spring이 <code>List&lt;T&gt;</code> 로 자연스럽게 바인딩할 수 있다.</p>
<hr>
<h3 id="skipnulls-true-도-같이-유용했다">skipNulls: true 도 같이 유용했다</h3>
<p>검색 필터는 항상 모든 값이 다 들어오지 않는다.</p>
<p>예를 들면 이런 경우가 있다.</p>
<pre><code class="language-ts">const params = {
  keyword: undefined,
  mode: undefined,
  styleTagIds: [1, 2],
  instIds: undefined,
};</code></pre>
<p>이런 상태에서 필요 없는 값까지 query string에 포함 시키고 싶지는 않았다.</p>
<p>그래서 <code>skipNulls: true</code> 옵션도 같이 사용했다.</p>
<pre><code class="language-ts">const query = qs.stringify(params, {
  arrayFormat: &quot;repeat&quot;,
  skipNulls: true,
});</code></pre>
<p>이렇게 하면 null 이나 undefined인 값은 제외되고,
실제로 검색에 들어온 값만  query string에 포함된다.</p>
<p>결과적으로 요청 자체가 더 깔끔해진다.</p>
<hr>
<h3 id="query-string--qs">query-string &amp; qs</h3>
<p>비슷한 역할을 하는 라이브러리로 <code>query-string</code>도 있다.</p>
<p>둘 다 객체와 query string을 다룰 수 있지만, 사용감은 조금 다르다.</p>
<p><code>query-string</code></p>
<ul>
<li>비교적 가볍다</li>
<li>프론트에서 URL 쿼리를 읽고 쓰기에 깔끔한 편이다</li>
<li>단순한 query string 처리에는 충분히 편하다</li>
</ul>
<p><code>qs</code></p>
<ul>
<li>배열 직렬화 옵션이 더 유연하다</li>
<li>중첩 객체나 복잡한 구조 대응력이 더 좋다</li>
<li>백엔드와 맞물리는 직렬화 제어에 더 강하다</li>
</ul>
<p>이번 경우에는 단순히 주소창 쿼리를 다루는 것보다
배열 파라미터를 Spring 형식에 맞게 안정적으로 보내는 것이 더 중요했기 때문에 본인은 <code>qs</code>를 션택했다.</p>
<hr>
<h3 id="정리">정리</h3>
<p>이번에 <code>qs</code>를 도입하면서 좋아진 점은 분명했다.</p>
<ul>
<li>query string 조립 코드가 짧아졌다</li>
<li>배열 필드를 직접 반복문으로 처리하지 않아도 된다</li>
<li>검색 조건이 늘어나도 params 객체만 관리하면 된다</li>
<li>Spring 백엔드와 맞는 형태로 배열 파라미터를 보낼 수 있다</li>
<li>null, undifined 값도 깔끔하게 제외할 수 있다</li>
</ul>
<p>검색 필터가 많아질수록 문제는 조건관리보다 
<strong>그 조건을 query string으로 어떻게 직렬화할 것인가</strong> 쪽에서 더 자주 터진다.</p>
<p>이번에는 <code>qs</code>를 통해 그 부분을 훨씬 단순하게 정리할 수 있었다.</p>
<hr>
<h3 id="마무리">마무리</h3>
<p>처음엔 <code>URLSearchParams</code>로도 충분해 보였다.
하지만 배열 필터가 늘어나고, 백엔드와 맞는 형태까지 고려해야 하니 코드가 금방 길어졌다.</p>
<p><code>qs</code>를 도입한 뒤에는
직접 문자열을 조립하는 대신 <strong>객첸 중심으로 조건을 관리하고, 직렬화는 라이브러리에 맡기는 구조</strong>로 정리할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[QueryDSL Q클래스 실종 이유: 환경변수 - Gradle 까지 ]]></title>
            <link>https://velog.io/@luislki_/QueryDSL-Q%ED%81%B4%EB%9E%98%EC%8A%A4-%EC%8B%A4%EC%A2%85-%EC%9D%B4%EC%9C%A0-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-Gradle-%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@luislki_/QueryDSL-Q%ED%81%B4%EB%9E%98%EC%8A%A4-%EC%8B%A4%EC%A2%85-%EC%9D%B4%EC%9C%A0-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-Gradle-%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Thu, 12 Mar 2026 16:30:04 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-상황">문제 상황</h3>
<p>QueryDSL을 적용하려고 했지만, 가장 먼저 막힌 부분은 <code>QUser</code> 같은 Q클래스가 보이지 않는 문제였다.</p>
<p>예를 들어 RepositoryImpl 에서 해당 코드를 쓰고 싶은데</p>
<pre><code class="language-java">QUser user = QUser.user;</code></pre>
<p>IDE에서는 <code>QUser</code> 자체를 찾지 못했다.</p>
<p>처음에는 QueryDSL 문법 문제라고 생각했지만, 실제 원인은 Q 클래스 생성 자체가 안 된 것이었다.</p>
<hr>
<h3 id="원인-정리">원인 정리</h3>
<p>문제를 추적해보니, 단순히 import 문제가 아니라 여러 설정이 겹쳐있었다.</p>
<ol>
<li><p>의존성 필요</p>
<pre><code class="language-gradle">implementation &#39;com.querydsl:querydsl-jpa:5.1.0:jakarta&#39;
annotationProcessor &#39;com.querydsl:querydsl-apt:5.1.0:jakarta&#39;
annotationProcessor &#39;jakarta.annotation:jakarta.annotation-api&#39;
annotationProcessor &#39;jakarta.persistence:jakarta.persistence-api&#39;</code></pre>
</li>
<li><p>Config 필요</p>
</li>
</ol>
<pre><code class="language-java">@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}</code></pre>
<ol start="3">
<li>로컬 Java 환경변수 문제
가장 크게 시간을 잡아먹은 건 사실 로컬 Java 환경변수 문제였다.</li>
</ol>
<p>Gradle 실행 시 아래와 같은 에러가 발생했다.</p>
<ul>
<li><code>JAVA_HOME is not set</code></li>
<li><code>java command could not be found</code></li>
</ul>
<p>즉, <code>gradlew</code> 는 실행됐지만, Java를 찾지 못하는 상태였다.</p>
<p align="center">
  <img src="https://velog.velcdn.com/images/luislki_/post/79b9e456-8908-4455-9902-c3a34fe12c1d/image.png" width="500" />
</p>

<hr>
<h3 id="해결-과정">해결 과정</h3>
<ol>
<li><p>InteliJ에서 실제 JDK 경로 확인</p>
</li>
<li><p>JAVA_HOME 설정
사용자 환경 변수에 값을 추가했다</p>
</li>
<li><p>Path 설정
처음에는 %JAVA_HOME%\bin을 넣었지만, 환경에 따라 문자열 그대로 남아 Java를 찾지 못하는 문제가 있었다.
최종적으로는 절대경로를 직접 넣는 방식으로 해결했다.</p>
</li>
<li><p>Gradle 빌드 재실행
환경변수 설정 후 다음 명령으로 컴파일을 다시 수행했다.</p>
<pre><code class="language-Bash">.\gradlew.bat clean compileJava --info</code></pre>
<p>그 결과 Q 클래스가 생성되었고, <code>build/generated/sources/annotationProcessor/java/main</code> 경로 아래에서 확인할 수 있었다.</p>
</li>
</ol>
<hr>
<h3 id="느낀점">느낀점</h3>
<p>처음에는 QueryDSL 문법이 문제라고 생각했지만, 실제로는 다음 세 가지가 얽혀 있었다.</p>
<ul>
<li>Gradle 의존성 설정</li>
<li>annotation processor 동작 여부</li>
<li>Java 환경변수 설정</li>
</ul>
<p>그 결과 Q 클래스가 생성되었고, build/generated/sources/annotationProcessor/java/main 경로 아래에서 확인할 수 있었다.</p>
<hr>
<h3 id="정리">정리</h3>
<p>이번 문제의 핵심은 QueryDSL Q 클래스가 자동 생성되지 않았다는 점이었다.
원인은 annotation processor 설정과 Java 환경변수 문제였고,
Gradle 설정 수정 + JAVA_HOME/Path 설정 + 재빌드를 통해 해결할 수 있었다.</p>
<p>결과적으로 QueryDSL 자체보다도, 개발 환경과 빌드 시스템을 정확히 이해하는 과정이 더 중요하다는 걸 많이 배운 작업이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[QueryDSL 사용해보기]]></title>
            <link>https://velog.io/@luislki_/QueryDSL-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@luislki_/QueryDSL-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Thu, 12 Mar 2026 16:09:16 GMT</pubDate>
            <description><![CDATA[<h3 id="querydsl이란">QueryDSL이란,</h3>
<p>QueryDSL은 JPA에서 문자열 기반 JPQL을 길게 작성하는 대신,
Q타입을 이용해 자바 코드로 쿼리를 구성할 수 있도록 도와주는 라이브러리다.</p>
<p>즉, 쿼리를 문자열로 직접 조립하는 대신 코드로 안전하게 조건식을 만들고 조합할 수 있다.</p>
<p>기존 JPQL은 레퍼지토리에 이런식으로 문자열 쿼리를 작성한다.</p>
<pre><code class="language-java">@Query(&quot;&quot;&quot;
select ap
from ArtistProfile ap
where (:applyKeyword = false or ...)
  and (:applyInstIds = false or ...)
&quot;&quot;&quot;)</code></pre>
<p>반면 QueryDSL은 조건을 코드로 조립한다.</p>
<pre><code class="language-java">.where(
    keywordCondition(req.keyword(), user, artistProfile),
    instCategoryCondition(req.instCategory(), artistInstrument, artistProfile),
    instIdsCondition(req.instIds(), artistInstrument, artistProfile),
    styleTagIdsCondition(req.styleTagIds(), artistStyleMap, artistProfile)
)</code></pre>
<p>이렇게 하면 어떤 조건이 붙는지가 메서드 이름만으로도 드러난다.</p>
<hr>
<h3 id="왜-querydsl--사용을-고려했는가">왜 QueryDSL  사용을 고려했는가</h3>
<p>검색 자체가 동적 검색이라는 점이 핵심이엇다.
모든 사용자가 같은 조건으로 검색하는 것이 아니라, 어떤 사용자는 키워드만 넣고, 어떤 사용자는 조건1, 조건2까지 함께 선택할 수 있었다.</p>
<p>즉, 조건이 고정된 조회가 아니라 있을 수도 있고 없을 수도 있는 조건이 많았다.</p>
<ul>
<li>keyword가 있으면 이름/전공명 검색</li>
<li>instCategory가 있으면 해당 카테고리 악기 보유 여부 검색</li>
<li>instIds가 있으면 해당 악기 보유 여부 검색</li>
<li>styleTagIds가 있으면 해당 스타일 태그 보유 여부 검색</li>
</ul>
<p>이런 경우  JPQL은 결국 문자열 안에 여러 분기 조건과  boolean 플래그가 들어가게 된다.
반면 QueryDSL은 조건을 메서드로 나눠서, <strong>있으면 적용하고 없으면 제외하는 방식</strong>으로 구성할 수 있다.</p>
<p>그래서 검색 조건이 계속 늘어날 가능성이 있는 구조에서 유지보수가 가능한 형태를 만드는것에 더 가까운 선택을 한것이다.</p>
<hr>
<h3 id="사용-준비하기">사용 준비하기</h3>
<p>QueryDSL은 먼저 몇 가지 준비가 필요하다.</p>
<h4 id="의존성-추가">의존성 추가</h4>
<p>Gradle 기준으로 QueryDSL 관련 의존성을 추가했다.</p>
<pre><code class="language-gradle">implementation &quot;com.querydsl:querydsl-jpa:5.0.0:jakarta&quot;
annotationProcessor &quot;com.querydsl:querydsl-apt:5.0.0:jakarta&quot;
annotationProcessor &quot;jakarta.annotation:jakarta.annotation-api&quot;
annotationProcessor &quot;jakarta.persistence:jakarta.persistence-api&quot;</code></pre>
<h4 id="q타입-생성-설정">Q타입 생성 설정</h4>
<p>QueryDSL은 엔티티를 기반으로 <code>QUser</code> , <code>QArtistProfile</code> 같은 Q타입을 생성해서 사용한다.
즉, 단순 라이브러리 추가만으로 끝나는 게 아니라 빌드 시 annotation processor가 Q클래스를 생성할 수 있도록 설정해야 했다.</p>
<p>이후 빌드를 수행하면 <code>build/generated/...</code> 아래에 Q타입이 생성되고,
코드에서는 이런 식으로 사용할 수 있다.</p>
<pre><code class="language-java">QArtistProfile artistProfile = QArtistProfile.artistProfile;
QUser user = QUser.user;</code></pre>
<h4 id="jpaqueryfactory-빈-등록">JPAQueryFactory 빈 등록</h4>
<p>QueryDSL 쿼리를 작성하려면 <code>JPAQueryFactory</code> 가 필요해서 설정 클래스를 통해 Bean으로 등록했다.</p>
<pre><code class="language-java">@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}</code></pre>
<h4 id="custom-repository-분리">Custom Repository 분리</h4>
<p>기본 JpaRepository 만으로는 QueryDSL 커스텀 구현을 담기 어려워서,
검색 메서드는 별도의 Custom Repository 인터페이스와 구현 글래스로 분리했다.</p>
<p>예를 들어 구조는 다음처럼 잡았다.</p>
<ul>
<li><code>ArtistProfileRepository</code></li>
<li><code>ArtistProfileRepositoryCustom</code></li>
<li><code>ArtistProfileRepositoryImpl</code>
해당 방식으로 분리하여 단순 CRUD와 검색용 동적 쿼리의 역할이 섞이지 않아 오히려 더 명확했다.</li>
</ul>
<hr>
<h3 id="querydsl의-장점">QueryDSL의 장점</h3>
<p>이번 구현에서 체감한 장점은 다음과 같았다.</p>
<p><strong>1. 동적 조건 분기가 자연스럽다</strong>
조건이 없으면 <code>null</code> 반환, 있으면 <code>BooleanExpression</code> 반환방식으로 작성할 수 있다.
*BooleanExpression이란, QueryDSL 참/거짓 조건식이다.
<code>user.username.containsIgnoreCase(keyword)</code> 해당 식을 SQL으로 보면 이렇다.</p>
<pre><code class="language-sql">lower(username) like &#39;%keyword%&#39;</code></pre>
<p> 즉, where 절에 들어갈 수 있는 조건식 잭체이다.</p>
<p> <strong>2. 가독성이 좋다</strong>
 검색 조건별 메서드를 분리할 수 있어서, 어떤 조건이 적용되는지 구조적으로 읽힌다.</p>
<p> <strong>3. 확장성이 좋다</strong>
 나중에 다른 필터 조건을 추가할 때도 where(...)에 조건 메서드 하나 더 넣으면 된다.</p>
<hr>
<h3 id="row-dto-를-분리했다">Row DTO 를 분리했다</h3>
<p> 이번 구현에서는 최종 응답 DTO 하나만 사용하지 않고,
 조회 과정에서 사용하는 중간 DTO를 따로 두었다.</p>
<p> 예를 들어,</p>
<ul>
<li><code>ArtistSearchRow</code> : 검색 결과의 기본 정보</li>
<li><code>ArtistInstrumentRow</code> : 카드에 붙일 악기 목록 정보</li>
<li><code>ArtistSeachResponse</code> : 최종 응답 DTO</li>
</ul>
<p> 해당 방식으로 나눈 이유는,
 조회 시점의 데이터 구조와 최종 응답 구조가 완전히 같지 않았기 대문이다.</p>
<p> 검색 1차 결과는 기본 정보 중심이었고,
 악기 정보는 별도로 조회해 <code>artistProfileId</code> 기준으로 묶어야 했다.
 (검색 조건에 매칭된 정보만이 아니라, 아티스트가 보유한 악기 목록을 카드에 함께 보여줘야 했기 때문)</p>
<p> 즉, 레퍼지토리에서는 DB 조회에 적합한 row 단위 DTO를 사용하고,
 서비스에서는 그것들을 조립해서 최종 응답 DTO로 변환하는 식으로 역할을 분리했다.</p>
<hr>
<h3 id="사용하면서-느낀-아쉬운점">사용하면서 느낀 아쉬운점</h3>
<p>물론 QueryDSL이 만능은 아니다.</p>
<p><strong>1. 처음에는 구조가 더 복잡해 보인다</strong>
JPQL은 Repository 메서드 하나에 바로 쓸 수 있지만,
QueryDSL은 custom repository, impl 클래스, Q타입 생성 등 준비할 것이 더 많다.
즉, 단순 조회에서는 오히려 과한 선택이 될 수 있다.</p>
<p><strong>2. 작은 조회까지 전부 QueryDSL로 갈 필요는 없다</strong>
조건이 거의 없고 고정된 조회라면 JPQL이나 Spring Data JPA 메서드 쿼리로도 충분하다.</p>
<hr>
<h3 id="프로젝트에서의-적용-의미">프로젝트에서의 적용 의미</h3>
<p> 이번 검색 구현은 QueryDSL을 처음 직접 적용한 사례였다.
 단순히 새로운 기술을 써봤다 보다, 검색 조건이 계속 늘어날 수 있는 구조에서 왜 이 도구가 필요한지를 실제로 체감한 작업이었다.</p>
<p> 특히 JPQL로도 구현 가능한 상황이었지만,
 이번에는 현재 기능뿐 아니라 추후 확장 가능성까지 고려해 선택한 설계라는 점에서 의미가 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@RequestParam 대신 @ModelAttribute 를 선택한 이유]]></title>
            <link>https://velog.io/@luislki_/RequestParam-%EB%8C%80%EC%8B%A0-ModelAttribute-%EB%A5%BC-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@luislki_/RequestParam-%EB%8C%80%EC%8B%A0-ModelAttribute-%EB%A5%BC-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Thu, 12 Mar 2026 15:09:03 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/1c1030dd-e08d-4c7e-9544-dbd462db0a63/image.png" alt=""></p>
<h3 id="requestparam-가독성-이슈">@RequestParam 가독성 이슈</h3>
<p>검색 기능을 만들면서 검색 조건이 점점 많아졌다.</p>
<p>처음에는 각각 <code>@RequestParam</code> 으로 받을 수도 있었지만, 조건이 늘어날수록 컨트롤러 메서드 시그니처가 복잡해지고 가독성이 떨어질 수 있었다.</p>
<p>예를 들면 이런 식이다.</p>
<pre><code class="language-java">@GetMapping
public ResponseEntity&lt;?&gt; searchArtists(
        @RequestParam(required = false) String keyword,
        @RequestParam(required = false) InstrumentCategory instCategory,
        @RequestParam(required = false) List&lt;Long&gt; instIds,
        @RequestParam(required = false) List&lt;Long&gt; styleTagIds
) {
    ...
}</code></pre>
<p>검색 조건이 더 추가될 가능성까지 생각하면, 이 방식은 유지보수에 불리하다고 판단했다.</p>
<hr>
<h3 id="해결-방식">해결 방식</h3>
<p>검색 조건을 하나의  DTO로 묶고, 컨트롤러에서는 <code>@ModelAttribute</code> 로 받도록 구성했다.</p>
<ul>
<li>DTO<pre><code class="language-java">@GetMapping
public ResponseEntity&lt;?&gt; searchArtists(
      @RequestParam(required = false) String keyword,
      @RequestParam(required = false) InstrumentCategory instCategory,
      @RequestParam(required = false) List&lt;Long&gt; instIds,
      @RequestParam(required = false) List&lt;Long&gt; styleTagIds
) {
  ...
}</code></pre>
</li>
<li>Controller<pre><code class="language-java">@GetMapping
public ResponseEntity&lt;List&lt;ArtistSearchResponse&gt;&gt; searchArtists(
      @ModelAttribute ArtistSearchRequest req
) {
  return ResponseEntity.ok(artistSearchService.searchArtists(req));
}</code></pre>
</li>
</ul>
<hr>
<h3 id="modelattribute가-하는-일">@ModelAttribute가 하는 일</h3>
<p><code>@ModelAttribute</code> 는 요청 파라미터를 보고 DTO 객체를 자동으로 바인딩해준다.
예를 들어 다음과 같은 GET 요청이 들어오면</p>
<pre><code class="language-http">GET /artists?keyword=플룻&amp;instCategory=WOODWIND&amp;instIds=1&amp;instIds=2&amp;styleTagIds=3</code></pre>
<p>스프링은 이를 자동으로 다음과 같은 형태의 객체로 묶어준다.</p>
<pre><code class="language-java">new ArtistSearchRequest(
    &quot;플룻&quot;,
    InstrumentCategory.WOODWIND,
    List.of(1L, 2L),
    List.of(3L)
)</code></pre>
<p>즉, 검색 조건을 하나의 의미있는 객체로 관리할 수 있게 된다.</p>
<hr>
<h3 id="왜-requestbody-가-아닌-modelattribute인가">왜 @RequestBody 가 아닌 @ModelAttribute인가</h3>
<p>이번 검색 API는 GET 요청 기반이다.
일반적으로 GET 요청 검색 조건은 body가 아니라 query parameter로 전달된다.
따라서 해당 조건에서는 <code>@ModelAttribute</code>가 더 자연스럽다.</p>
<hr>
<h3 id="언제-requestparam이-더-낫고-언제-modelattribute가-더-낫냐">언제 @RequestParam이 더 낫고, 언제 @ModelAttribute가 더 낫냐</h3>
<p><code>@RequestParam</code> 이 괜찮은 경우</p>
<ul>
<li>파라미터 개수가 적음</li>
<li>DTO 따로 만들기 애매함</li>
<li>검색 조건이 자주 안바뀜</li>
<li>빠르게 구현할 때</li>
</ul>
<p><code>@ModelAttribute</code> 가 더 Best 인 경우</p>
<ul>
<li>검색 조건이 많음</li>
<li>앞으로 조건이 늘어날 가능성이 큼</li>
<li>컨트롤러 시그니처가 너무 길어짐</li>
<li>서비스/레포까지 검색 조건 객체를 그대로 넘기고 싶음</li>
</ul>
<hr>
<h3 id="정리">정리</h3>
<p>이번 검색 API 에서는 <code>@ModelAttribute</code> 를 사용해 검색 조건을 하나의 DTO로 묶었다.
그 결과:</p>
<ul>
<li>컨트롤러 메서드 시그니처가 단순해졌고</li>
<li>검색 조건 추가 시 확장성이 좋아졌으며</li>
<li>서비스, 레포지토리 계층으로 객체를 그대로 넘길 수 있어 구조도 더 명확해졌다.</li>
</ul>
<p>검색 조건이 늘어나는 조회 API라면 <code>@ModelAttribute</code>는 실용적인 선택이었다고 판단한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] Emotion CSS 사용법 정리]]></title>
            <link>https://velog.io/@luislki_/React-Emotion-CSS-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@luislki_/React-Emotion-CSS-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Wed, 04 Mar 2026 19:39:27 GMT</pubDate>
            <description><![CDATA[<h3 id="emotion-css">Emotion CSS</h3>
<p>Emotion 은 CSS를 JS/TS로 &#39;모듈화&#39; 해서 재사용하고, props/state 기반으로 조건부 스타일을 안전하게 걸 수 있도록 도와준다.</p>
<h3 id="1-설치--세팅">1) 설치 / 세팅</h3>
<pre><code class="language-bash">npm i @emotion/react @emotion/styled</code></pre>
<p><code>@emotion/react</code> : Emotion css 사용시 필수
<code>@emotion/styled</code> : styled 사용 시 import</p>
<hr>
<h3 id="폴더-구조-및-이유">폴더 구조 및 이유</h3>
<h4 id="컴포넌트-코로케이션">컴포넌트-코로케이션</h4>
<p>컴포넌트 폴더 안에 파일을 같이 둔다.</p>
<pre><code>    Button/
      Button.tsx
      styles.ts</code></pre><ul>
<li><code>styles.ts</code>: Emotion css export만 맡는다.</li>
<li>다른 폴더 혹은 더 상위 폴더에 styles 파일을 둘 수도 있지만, 유지보수 및 관리 차원에서 한 컴포넌트당 하나의 styles.ts 파일을 추천한다.</li>
</ul>
<hr>
<h3 id="css--css-prop-stylests-사용">css + css prop (styles.ts) 사용</h3>
<p>일반적인 css 사용법과 비슷해 보이지만, 
스타일을 함수/배열로 조합해서 바로 적용할 수 있어 로직의 가독성 향상을 기대할 수 있다.</p>
<p><strong>Button/styles.ts  파일 예시</strong></p>
<pre><code class="language-ts">import { css } from &quot;@emotion/react&quot;;</code></pre>
<p>해당 import 문을 상단에 정의 후,</p>
<pre><code class="language-ts">export const button = css`
  padding: 12px;
  border: 1px solid #ddd;
`;</code></pre>
<p>해당 형식으로 아래에 css를 작성한다.
<br/></p>
<p><strong>Button/Button.tsx 파일 예시</strong></p>
<pre><code class="language-ts">/** @jsxImportSource @emotion/react */
import * as s from &quot;./styles&quot;;</code></pre>
<p>항상 상단에 해당 import 문 두 줄을 넣는다.
같은 폴더 내 styles 파일을 가져와 별칭을 붙여 사용하기 때문에 필요하다.</p>
<pre><code class="language-ts">export default function Button() {
  return &lt;div css={s.button}&gt;BUTTON&lt;/div&gt;;
}</code></pre>
<p>Button 컴포넌트쪽에서 className처럼 사용 가능하다.
<br/></p>
<hr>
<h3 id="styled-컴포넌트">styled 컴포넌트</h3>
<p>클래스명 + 스타일 파일 + 연결을 Emotion이 컴포넌트 단위로 자동화 해주는 방식.
CSS를 컴포넌트로 포장해서 재사용성이 좋다.</p>
<ul>
<li>컴포넌트 자체가 스타일 덩어리(버튼/인풋/칩/카드)일 때 유리하다.</li>
</ul>
<p><strong>Button/styles.ts  파일 예시</strong></p>
<pre><code class="language-ts">import styled from &quot;@emotion/styled&quot;;</code></pre>
<p>해당 import 문을 상단에 정의 후,</p>
<pre><code class="language-ts">export const Btn = styled.button`
  padding: 12px;
  border: 1px solid #ddd;
`;</code></pre>
<ul>
<li>여기서 styled.button의 button은 <code>&lt;button&gt;&lt;/button&gt;</code> 을 <code>&lt;S.Btn&gt;&lt;/S.Btn&gt;</code>으로 사용할 수 있도록 만들어준다.<pre><code class="language-ts">// 예시
export const Container = styled.div`...`;
export const Header = styled.div`...`;
export const LiveButton = styled.button`...`;</code></pre>
해당 컴포넌트만 사용하면, 요소와 CSS가 이미 포함된 상태에서 깔끔하게 로직 작성이 가능하다.<br/>

</li>
</ul>
<p><strong>Button/Button.tsx 파일 예시</strong></p>
<pre><code class="language-ts">// S = 대문자
/** @jsxImportSource @emotion/react */ -&gt; styled 방식은 css prop을 사용하지 않아 빼도 됨
import * as S from &quot;./styles&quot;;</code></pre>
<p>굳이 대문자 표식을 할 필요는 없으나, 
styled 컴포넌트들은 PascalCase로 export한다고 가정했을 때, 예시문이다.</p>
<pre><code class="language-ts">export default function Button() {
  return &lt;S.Btn&gt;BUTTON&lt;/S.Btn&gt;;
}</code></pre>
<p>해당 방식으로 깔끔하게 CSS가 적용된다.
<br/></p>
<hr>
<h3 id="스타일링-방식-4가지-비교">스타일링 방식 4가지 비교</h3>
<ul>
<li>일반 CSS (className + .css 파일)<ul>
<li>장점: 가장 표준적, 어디서나 동일하게 동작</li>
<li>단점: 규모가 커지면 네이밍/전역 충돌/미사용 CSS관리 비용 커짐,
상태 조합 가독성 떨어짐<br/></li>
</ul>
</li>
<li>Tailwind CSS (유틸리티 클래스 조합)<ul>
<li>장점: CSS 파일 작성 거의 없이 빠르게 UI 구현, 디자인 토큰(간격/색/폰트) 통일이 쉬움, 전역 충돌 적음</li>
<li>단점: className이 길어져 JSX 가독성이 떨어질 수 있고, 팀 컨벤션(추상화 기준/컴포넌트화)이 없으면 난잡해짐<br/></li>
</ul>
</li>
<li>Emotion css + css prop (styles.ts + css 배열 조합)<ul>
<li>장점: css={[base, cond &amp;&amp; extra]}로 조건부 스타일이 깔끔함, 스타일/로직 분리로 PR 리뷰·충돌 감소, 컴포넌트 단위로 영향 범위가 좁음</li>
<li>단점: 프로젝트 설정에 따라 css prop 세팅(pragma 등)이 필요할 수 있음<br/></li>
</ul>
</li>
<li>Emotion styled (스타일 포함 컴포넌트)<ul>
<li>장점: <code>&lt;S.Button /&gt;</code> 처럼 캡슐화되어 재사용/확장(variant)에 강함, props 기반 동적 스타일이 자연스러움</li>
<li>단점: 작은 레이아웃 조각까지 전부 styled로 만들면 컴포넌트 정의가 과밀해질 수 있어 css + css prop과 적절한 혼용이 필요</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[RTMP: 스트리밍 상태 전이 설계 & 구현]]></title>
            <link>https://velog.io/@luislki_/RTMP-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EC%83%81%ED%83%9C-%EC%A0%84%EC%9D%B4-%EC%84%A4%EA%B3%84-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@luislki_/RTMP-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EC%83%81%ED%83%9C-%EC%A0%84%EC%9D%B4-%EC%84%A4%EA%B3%84-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sat, 21 Feb 2026 15:54:47 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/24995be3-2d1c-429d-8a18-97494dca717d/image.png" alt=""></p>
<h3 id="버튼은-uxui-일-뿐-rtmp-이벤트는-실제로-일어나야한다">버튼은 UX/UI 일 뿐, RTMP 이벤트는 실제로 일어나야한다.</h3>
<p>라이브 스트리밍에서 중요하게 여겨야 할 점 중 하나는 &quot;지금 진짜 방송 중인가?&quot; 를 시청자가 믿을 근거다.
그래서 방송 상태를 단순히 버튼 클릭으로 바꾸지 않고, RTMP 송출 이벤트로 LIVE/ENDED를 확정하는 방식으로 설계했다.</p>
<h4 id="목표-시청자가-믿을-수-있는-상태-만들기">목표: 시청자가 믿을 수 있는 상태 만들기</h4>
<p>스트리밍 상태를 아래처럼 단순한 상태 머신으로 정의했다.</p>
<ul>
<li><code>OFF</code>: 방송 없음</li>
<li><code>READY</code>: 스트리머가 방송 제목/소개/카테고리 세팅 완료(송출 전)</li>
<li><code>LIVE</code>: 실제로 RTMP 송출이 시작됨 (OBS → RTMP 서버)</li>
<li><code>ENDED</code>: 실제 송출 종료됨
여기서 핵심은</li>
<li><strong>READY는 사람이 누르는 버튼</strong> 으로 만들 수 있다(의지)</li>
<li><strong>LIVE/ENDED는 실제 송출이 시작/종료됐다는 물리 이벤트</strong>로만 확정해야 한다.(사실)</li>
</ul>
<p>즉, LIVE는 프론트 버튼만으로 바꾸면 신뢰도가 떨어진다.
유저는 &quot;방송 시작&quot;을 눌렀는데 OBS가 송출을 안 했을 수도 있고, RTMP 서버가 죽었을 수도 있고, 온갖 변수가 있다.</p>
<hr>
<h3 id="전체-흐름-요약">전체 흐름 요약</h3>
<ol>
<li>스트리머가 방송 세팅 → 상태를 <code>READY</code>로 둔다.</li>
<li>OBS가 RTMP 서버로 송출 시작</li>
<li>RTMP 서버가 <code>onPublish</code> 훅 호출 -&gt; 백엔드가 READY → LIVE 확정</li>
<li>시청자는 상태 조회 API를 통해(status/statuses) LIVE인 채널만 라이브로 믿고 진입</li>
<li>송출 종료 시 on_publish_done 훅 호출 → LIVE → ENDED</li>
</ol>
<hr>
<h3 id="상태-전이-트리거-rtmp-훅을-백엔드-앤드포인트로-연결">상태 전이 트리거: RTMP 훅을 백엔드 앤드포인트로 연결</h3>
<p>백엔드는 RTMP 서버(Nginx-RTMP 등)에서 송출 시작/종료 이벤트가 발생하면 호출되는 엔드포인트를 제공한다.</p>
<h4 id="rtmpcontroller">RtmpController</h4>
<pre><code class="language-java">   // OBS 송출 시작 시: READY -&gt; LIVE 확정
    @PostMapping(&quot;/on_publish&quot;)
    public ResponseEntity&lt;Void&gt; onPublish(@RequestParam(&quot;channelId&quot;) Long channelId) {
        log.info(&quot;on_publish channelId={}&quot;, channelId);
        liveStreamingService.onPublish(channelId);
        return ResponseEntity.ok().build();
    }

    // OBS 송출 종료 시: LIVE -&gt; ENDED
    @PostMapping(&quot;/on_publish_done&quot;)
    public ResponseEntity&lt;Void&gt; onDone(@RequestParam(&quot;channelId&quot;) Long channelId) {
        log.info(&quot;on_publish_done channelId={}&quot;, channelId);
        liveStreamingService.onDone(channelId);
        return ResponseEntity.ok().build();
    }
}</code></pre>
<h4 id="왜-channelid를-쿼리로-받나">왜 channelId를 쿼리로 받나?</h4>
<p>RTMP 훅이 호출될 때, 스트림명/키만으로는 &quot;어떤 채널인지&quot; 매핑이 애매해질 수 있다.
일단 MVP에서는 채널단위로 상태를 바꾸는게 가장 직관적이라 channelId를 직접 전달했다.</p>
<p>또한 스트림 키 기반 매핑(키 로테이션/만료/중복 처리 등)을 MVP에서 먼저 억지로 끌고 오면 복잡도가 커질 수 있다.
그래서 채널 단위 식별은 channelId로 단순화하고, 대신 streamKey를 함께 받아 이중 검증(채널의 streamKey = 요청 streamKey 일치 여부)을 수행했다.</p>
<ul>
<li>channelId: &quot;어떤 채널의 상태를 바꿀지&quot; 특정</li>
<li>streamKey: 그 채널에 대한 송출이 맞는지 
즉, channelId는 매핑용이고 streamKey 는 인증/검증용이다.</li>
</ul>
<p>스트림 키를 검증하는 부분은 공통 메서드로 분리해서 중복 제거 + 정책(검증 규칙) 단일화 및 유지보수를 용이하게 만들었다.</p>
<pre><code class="language-java">
    // 스트림키 검증 메서드
    private void verifyStreamKeyOrThrow(Channel ch, String reqStreamKey) {
        if (ch.getStreamKey() == null || !ch.getStreamKey().equals(reqStreamKey)) {
            throw new BusinessException(ErrorCode.INVALID_STREAM_KEY);
        }</code></pre>
<hr>
<h3 id="서비스-단에서-가드guard로-상태-머신-지키기">서비스 단에서 &quot;가드(guard)&quot;로 상태 머신 지키기</h3>
<p>컨트롤러는 그냥 RTMP 이벤트가 들어온 사실을 전달할 뿐이고,
상태 전이의 규칙은 서비스에서 강제해야 한다.</p>
<p>서비스 구현에서 반드시 지켜야 하는 최소 규칙:</p>
<ul>
<li><p><code>onPublish(channelId)</code>:</p>
<ul>
<li>현재 상태가 <code>READY</code> 일 때만 <code>LIVE</code>로 전이</li>
<li><code>OFF/ENDED/LIVE</code> 에서 들어오면 무시하거나 예외</li>
</ul>
</li>
<li><p><code>onDone(channelId)</code>:</p>
<ul>
<li>현재 상태가 <code>LIVE</code> 일 때만 <code>ENDED</code>로 전이</li>
<li><code>OFF/ENDED/READY</code> 에서 들어오면 무시하거나 예외</li>
</ul>
</li>
</ul>
<p>해당 가드가 없으면 상태 머신은 그냥 희망 사항이 된다.
현실에서는 이벤트 순서가 깨질 수도 있고 중복 호출 가능성도 있기 때문에 확실하게 처리해줘야한다.
실제 테스트 상황에서 해당 부분 일반 return 을 사용했기 때문에 200OK를 띄우는 문제가 발생해서 바로 예외처리 해주었다.</p>
<hr>
<h3 id="시청자프론트가-믿는-근거-상태-조회-api">시청자/프론트가 믿는 근거: 상태 조회 API</h3>
<p>RTMP 훅으로 LIVE를 확정했을 시, 프론트에서는 해당 상태를 믿고 UI를 분기하면 된다.</p>
<h4 id="streamstatuscontroller">StreamStatusController</h4>
<pre><code class="language-java">package com.example.going.domain.liveStreaming.controller;

import com.example.going.common.enums.StreamStatus;
import com.example.going.domain.liveStreaming.dto.StreamStatusRes;
import com.example.going.domain.liveStreaming.service.LiveStreamingService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/api/streams&quot;)
public class StreamStatusController {

    private final LiveStreamingService liveStreamingService;

    // 방송 준비중 / 방송중 스트리밍 단일 조회
    @GetMapping(&quot;/{channelId}/status&quot;)
    public ResponseEntity&lt;StreamStatus&gt; getStatus(@PathVariable Long channelId) {
        StreamStatus status = liveStreamingService.getStatus(channelId);
        return ResponseEntity.ok(status);
    }

    // 방송 준비중 / 방송중 스트리밍 리스트 (+channelId 검색 가능)
    @GetMapping(&quot;/statuses&quot;)
    public ResponseEntity&lt;List&lt;StreamStatusRes&gt;&gt; getStatuses(
            @RequestParam(required = false) StreamStatus status,
            @RequestParam(required = false) List&lt;Long&gt; channelIds
    ) {
        return ResponseEntity.ok(liveStreamingService.getStatuses(status, channelIds));
    }
}</code></pre>
<p>이렇게 해두면 프론트는:</p>
<ul>
<li><code>LIVE</code>: 영상 + 채팅UI</li>
<li><code>READY</code>: 방송 준비중</li>
<li><code>OFF/ENDED</code>: 오프라인/종료
같이 상태 기반으로 정직하게 화면을 그릴 수 있다.</li>
</ul>
<hr>
<h3 id="테스트-시나리오-진짜-live로-바뀌는지-확인">테스트 시나리오: &quot;진짜 LIVE로 바뀌는지&quot; 확인</h3>
<ol>
<li>RTMP 훅을 직접 로컬에서 실행<pre><code class="language-bash">POST http://localhost:8080/api/live-streaming/rtmp/on_publish?name=...
POST http://localhost:8080/api/live-streaming/rtmp/on_publish_done?name=...</code></pre>
</li>
<li>상태 조회로 확인<pre><code class="language-bash">GET http://localhost:8080/api/streams/1/status</code></pre>
</li>
<li>LIVE 리스트 조회<pre><code class="language-bash">GET http://localhost:8080/api/streams/statuses?status=LIVE
GET http://localhost:8080/api/streams/statuses?status=LIVE&amp;channelIds=1,2</code></pre>
</li>
</ol>
<hr>
<h3 id="정리-왜-이-방식이-안전한가">정리: 왜 이 방식이 안전한가?</h3>
<ul>
<li>프론트 버튼은 &quot;방송 의지&quot; 만 표현한다 → <code>READY</code></li>
<li>실제 송출 여부는 RTMP 서버가 제일 정확히 안다 → <code>LIVE/ENDED</code></li>
<li>시청자는 상태 조회 API만 믿으면 된다</li>
<li>서비스 가드로 상태 머신을 강제하면, 이벤트가 이상하게 들어와도 시스템이 덜 죽는다.</li>
</ul>
<p>해당 설계의 목표는
<strong>&quot;시청자가 들어갔는데 방송이 없어서 서비스 품질이 깨지는 일을 줄이기&quot; 이다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Hls.js 재생 : Safari 네이티브 분기부터 Vite 프록시로 CORS 해결]]></title>
            <link>https://velog.io/@luislki_/Hls.js-%EC%9E%AC%EC%83%9D-Safari-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EB%B6%84%EA%B8%B0%EB%B6%80%ED%84%B0-Vite-%ED%94%84%EB%A1%9D%EC%8B%9C%EB%A1%9C-CORS-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@luislki_/Hls.js-%EC%9E%AC%EC%83%9D-Safari-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EB%B6%84%EA%B8%B0%EB%B6%80%ED%84%B0-Vite-%ED%94%84%EB%A1%9D%EC%8B%9C%EB%A1%9C-CORS-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Thu, 19 Feb 2026 16:47:06 GMT</pubDate>
            <description><![CDATA[<h3 id="목표">목표</h3>
<ul>
<li>Safari: 브라우저 네이티브 HLS로 재생(<code>&lt;video src=&quot;m3u8&quot;&gt;</code>)</li>
<li>Chrome/Edge/Firefox: hls.js(MSE) 로 재생</li>
<li>외부 m3u8 테스트 시 CORS 문제를 피하기 위해 Vite dev proxy로 우회</li>
</ul>
<hr>
<h2 id="1-safari가-아닌-상태에서-safari-native-hls로-찍힘">1) Safari가 아닌 상태에서 Safari native HLS로 찍힘</h2>
<h3 id="증상">증상</h3>
<p>Chrome에서 실행했는데 콘솔에 해당 로그가 뜸</p>
<ul>
<li>[MODE] Safari native HLS</li>
<li>또는 Native HLS(maybe)</li>
</ul>
<h3 id="원인">원인</h3>
<pre><code class="language-js">const canNativePlay = videoEl.canPlayType(&quot;application/vnd.apple.mpegurl&quot;);
if (canNativePlay) {
  // native...
}</code></pre>
<p><code>canPlayType()</code>은 반환값이 <code>&quot;&quot; | &quot;maybe&quot; | &quot;probably&quot;</code> 인데,
Chrome 에서도 <code>&quot;maybe&quot;</code> 가 나오는 경우가 있어서 truthy로 판단되어 네이티브 분기로 들어가는 오탐이 발생했다.</p>
<h3 id="해결">해결</h3>
<p>Safari일 때만 네이티브 분기로 고정한다.</p>
<pre><code class="language-js">const ua = navigator.userAgent;
const isSafari = /Safari/.test(ua) &amp;&amp; !/Chrome|Chromium|Edg|OPR/.test(ua);

const canNativePlay = videoEl.canPlayType(&quot;application/vnd.apple.mpegurl&quot;);

if (isSafari &amp;&amp; canNativePlay) {
  // Safari 네이티브
} else {
  // 나머지는 hls.js
}</code></pre>
<p>point: 네이티브 가능 여부가 아니라 Safari 여부를 먼저 확정해서 우연 재생을 제거</p>
<h2 id="2-safari-분기-막고-hlsjs로-타니-cors-에러-발생">2) Safari 분기 막고 hls.js로 타니 CORS 에러 발생</h2>
<p> 결국 CORS 에러가 발생해버렸는데...</p>
<h3 id="증상-1">증상</h3>
<p>Chrome에서 hls.js(MSE)로 재생 시도하면 콘솔에 아래 에러:</p>
<ul>
<li>Access to XMLHttpRequest ... has been blocked by CORS policy</li>
<li>[ERROR] networkError manifestLoadError 또는 manifestParsingError</li>
</ul>
<p>모든 경로에서 에러가 나는건 아니었다.
나중에 프로젝트 상황에서 한번 더 지켜봐야 할듯.</p>
<h3 id="원인-1">원인</h3>
<p>hls.js는 m3u8/세그먼트(ts, m4s)를 XHR/fetch로 직접 요청한다.
즉, 서버가 <code>Access-Control-Allow-Origin</code> 헤더를 제공하지 않으면 브라우저가 차단한다.</p>
<p>반면 <code>&lt;video src=&quot;...m3u8&quot;&gt;</code> 네이티브 로딩은 브라우저 미디어 파이프라인에서 처리되어 겉보기엔 &quot;되는 것처럼&quot; 보이는 케이스가 생길 수 있는데, hls.js는 <strong>CORS가 필수</strong>다.</p>
<h3 id="결론">결론</h3>
<p>운영/개발 모두 &quot;브라우저에서 hls.js로 재생&quot; 하려면:</p>
<ul>
<li>서버가 CORS 허용을 해주거나</li>
<li>같은 origin으로 보이게 reverse proxy(프록시)를 둬야한다.</li>
</ul>
<hr>
<h2 id="3-해결-시도---bite-dev-proxy로-우회">3) 해결 시도 - Bite dev proxy로 우회</h2>
<p>개발 환경에서는 백엔드 없이도 Vite 서버가 프록시 역할을 할 수 있다.</p>
<h3 id="설정-viteconfigjs프로젝트-루트에-생성">설정: <code>vite.config.js</code>(프로젝트 루트에 생성)</h3>
<pre><code class="language-js">import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
  server: {
    proxy: {
      &quot;/hls-proxy&quot;: {
        target: &quot;https://d2zihajmogu5jn.cloudfront.net&quot;,
        changeOrigin: true,
        secure: true,
        rewrite: (path) =&gt; path.replace(/^\/hls-proxy/, &quot;&quot;),
      },
    },
  },
});</code></pre>
<h3 id="사용법">사용법</h3>
<p>원본 m3u8:
<a href="https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8">https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8</a></p>
<p>프록시 경로로 변경:
/hls-proxy/bipbop-advanced/bipbop_16x9_variant.m3u8</p>
<h3 id="최종-정상-상태---계속-요청이-발생함">최종 정상 상태 - 계속 요청이 발생함</h3>
<p>정상 패턴 (Network 탭)</p>
<p>재생이 정상이라면 요청이 계속 발생한다:</p>
<ul>
<li>prog_index.m3u8 (playlist) 반복 요청 (200/304 가능)</li>
<li>세그먼트 파일 요청 (200 또는 206 Range)</li>
<li>자막이 있으면 fileSequence*.webvtt 연속 요청</li>
</ul>
<p>즉 HLS는 구조적으로 “m3u8(목차) + 조각(세그먼트)”를 계속 받아서 재생하므로 요청이 계속 생기는 게 정상이다.</p>
<hr>
<h3 id="운영-시-프록시-필요-여부">운영 시 프록시 필요 여부</h3>
<ul>
<li>프록시는 CORS 우회를 위해 자주 사용</li>
<li>운영에서 보통<ol>
<li>CDN/미디어 서버에서 CORS 허용 헤더 설정(정석)</li>
<li>같은 도메인으로 <code>/hls/*</code> 경로를 reverse proxy(Nginx) 로 붙여서 CORS 회피</li>
<li>권한/토큰 기반이면 백엔드가 서명 URL 발급 또는 프록시로 보호</li>
</ol>
</li>
</ul>
<p>즉 &quot;프록시 필수&quot; 보다 브라우저가 접근 가능한 형태(CORS/동일 오리진)로 제공하는게 필수.</p>
<h3 id="참고-사항">참고 사항</h3>
<p>테스트 가능한 m3u8 경로</p>
<ul>
<li><a href="https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8">https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8</a></li>
<li><a href="https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8">https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8</a></li>
<li><a href="https://mnmedias.api.telequebec.tv/m3u8/29880.m3u8">https://mnmedias.api.telequebec.tv/m3u8/29880.m3u8</a></li>
<li><a href="http://184.72.239.149/vod/smil:BigBuckBunny.smil/playlist.m3u8">http://184.72.239.149/vod/smil:BigBuckBunny.smil/playlist.m3u8</a></li>
<li><a href="http://www.streambox.fr/playlists/test_001/stream.m3u8">http://www.streambox.fr/playlists/test_001/stream.m3u8</a></li>
</ul>
<p>해당 경로는 CORS 관련 처리가 필요(버튼 누를 시 proxy 링크로 변환해서 들어감)</p>
<ul>
<li><a href="https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8">https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Hls.js 찍먹: 브라우저 m3u8 재생까지]]></title>
            <link>https://velog.io/@luislki_/Hls.js-%EC%B0%8D%EB%A8%B9-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-m3u8-%EC%9E%AC%EC%83%9D%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@luislki_/Hls.js-%EC%B0%8D%EB%A8%B9-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-m3u8-%EC%9E%AC%EC%83%9D%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Thu, 19 Feb 2026 16:37:41 GMT</pubDate>
            <description><![CDATA[<h2 id="hlsjs란">Hls.js란,</h2>
<h4 id="hlshttp-live-streaming">HLS(HTTP Live Streaming)</h4>
<p>애플이 2009년 공개한 HTTP 기반 적응형 비트레이트(ABR) 스트리밍 방식이다.
특별한 전용 프로토콜이 아니라 그냥 HTTP로 파일을받아 재생하는 구조라서 웹/모바일/미디어서버에서 널리 지원되고 지금도가장 대중적인 스트리밍 포맷 중 하나다.</p>
<p>HLS의 행심 아이디어는 영상 한 덩어리를 내려받는 대신,</p>
<ul>
<li>m3u8(플레이리스트) 파일을 먼저 받고</li>
<li>그 안에 적힌 조각(segment) 들을 순서대로 요청해서 재생하는 스트리밍 방식</li>
</ul>
<p>즉, &quot;재생목록(m3u8)을 따라가면서 segment들을 계속 가져와 재생하는 방식&quot; 이라서</p>
<ul>
<li>네트워크가 느려지면 낮은 화질 세그먼트로 바꾸고(ABR)</li>
<li>다시 빨라지면 높은 화질로 복귀하는 식의 끊김 최소화가 가능하다.</li>
</ul>
<p>참고로 <code>master.m3u8</code>에는 여러 화질(variant)이 들어갈 수 있고,
플레이어는 상황에 따라 적절한 variant를 선택해 재생한다.</p>
<h4 id="그래서-왜-필요한가">그래서 왜 필요한가?</h4>
<p>Safari 는 HLS를 네이티브로 재생할 수 있지만(브라우저가 m3u8을 직접 이해함)
Chrome/Edge/Firefox 는 기본 지원이 약해서 hls.js가 m3u8을 파싱하고 세그먼트를 받아 MSE(Media Source Extensions)로 video에 붙여 재생하게 된다.</p>
<ul>
<li>Js가 m3u8을 파싱</li>
<li>세그먼트 다운로드</li>
<li>디코딩 가능한 형태로 이어 붙여 </li>
<li><code>&lt;video&gt;</code> 에 주입해야함</li>
</ul>
<p>해당 과정을 구현해주는 라이브러리가 hls.js 이다.</p>
<h4 id="그냥-mp4-를-내려받니-않고-hls-를-쓰는-이유">그냥 mp4 를 내려받니 않고 HLS 를 쓰는 이유</h4>
<p>라이브/스트리밍에서 HLS를 쓰는 이유는 </p>
<ol>
<li>끊김을 줄이고</li>
<li>네트워크에 맞춰 품질을 자동으로 바꾸고</li>
<li>배포/운영을 쉽게 만들기 때문.</li>
</ol>
<hr>
<p><strong>HLS는 &quot;끊김 적게 + 빠른 시작 + 라이브에 최적 + CDN 운영 쉬움 + 품질/트랙 확장 쉬움&quot; 때문에 사용한다.</strong>
그리고 그걸 브라우저에서 재생하려면 Safari는 네이티브로 되고, 나머지는 hls.js(MSE)가 필요하다.</p>
<hr>
<h2 id="hlsjs-기본-사용-흐름">hls.js 기본 사용 흐름</h2>
<p>hls.js로 재생하는 기본 패턴은 거의 고정이다.
일단, <code>hls.js</code> 를 설치한다.</p>
<pre><code class="language-bash">npm i hls.js
# 또는
yarn add hls.js
pnpm add hls.js</code></pre>
<h4 id="선택있으면-편한것들">선택(있으면 편한것들)</h4>
<ul>
<li>video.js: 기본 컨트롤/스킨/플러그인 생태계</li>
</ul>
<pre><code class="language-bash">npm i video.js
# (TS 프로젝트면 타입도 보통 같이 오지만, 필요하면 @types/video.js 확인)</code></pre>
<p>하지만 hls.js 학습용 미니 프로젝트 상황에서는 굳이 설치하지 않고 순수 <code>&lt;video&gt;</code> + 커스텀 버튼으로 가서 이해하도록 하겠다.</p>
<ul>
<li><p>HLS 테스트용 로컬 서버 필요하면(m3u8/ts를 로컬에 두고 테스트 할 때 CORS/경로 문제 덜 겪게)</p>
<pre><code class="language-bash">npm i -D serve
# 또는 vite dev server로 public 폴더에 두고 테스트 가능</code></pre>
<p>현재 상황에서는 해당 선택 파트를 제외하고 학습을 진행하도록 할것이다.</p>
</li>
<li><p><code>Hls.isSupported()</code> (MSE 되는 브라우저인지)</p>
</li>
<li><p><code>hls.loadSource(m3u8)</code></p>
</li>
<li><p><code>hls.attachMedia(videoEl)</code></p>
</li>
</ul>
<p>Safari는 분기해서 네이티브로 처리한다.</p>
<pre><code class="language-javascript">import Hls from &quot;hls.js&quot;;

function mountHls(videoEl, src) {
    // Safari: native HLS
  if (videoEl.canPlayType(&quot;application/vnd.apple.mpegurl&quot;)){
      videoEl.src = src;
    return() =&gt; {videoEl.src=&quot;&quot;;};
  }

  // Others: Hls.js via MSE
  if (Hls.isSupported()) {
      const hls = new Hls({
       // 학습용 디버그.로깅하기 좋은 옵션들
         enableWorker: true,
           lowLatencyMode: true, 
     });

    hls.loadSource(src);
    hls.attachMedia(videoEl);

    hls.on(Hls.Events.MANIFEST_PARSED, () =&gt; {
    // autoplay는 브라우저 정책상 실패 가능성 있어서 사용자 제스처 필요
        videoEl.play().catch(() =&gt; {});  
    });

    return () =&gt; hls.destroy();
  }

  console.warn(&quot;HLS가 해당 브라우저를 지원하지 않습니다.&quot;);
  return () =&gt; {};
}
</code></pre>
<h4 id="화질퀄리티-선택하는-법">화질(퀄리티) 선택하는 법</h4>
<p>HLS에 여러 화질이 있으면, <code>hls.levels</code>에 들어온다.</p>
<ul>
<li>자동(ABR): <code>hls.currentLevel = -1</code></li>
<li>특정 화질 고정: <code>hls.currentLevel = index</code></li>
</ul>
<pre><code class="language-javascript">hls.on(Hls.Events.MANIFEST_PARSED, () =&gt; {
    console.log(hls.levels.map((l, i) =&gt; ({
    i,
      height: l.height,
      bitrate: l.bitrate,
    })));
});

// ex: 720p 고정
const idx = hls.levels.findIndex(l =&gt; l.height === 720);
if (idx &gt;= 0) hls.currentLevel = idx;

// 자동 되돌리기
hls.currentLevel = -1;</code></pre>
<p>Auto + 360/720/1080 드롭다운 정도만 있어도 학습상황에서 도움이 된다.</p>
<h4 id="에러처리복구-패턴은-사실-정답이-있다">에러처리(복구) 패턴은 사실 정답이 있다.</h4>
<p>hls.js는 에러 이벤트에서 fatal 여부를 준다</p>
<ul>
<li><p>네트워크 계열 fatal → <code>hls.startLoad()</code> 재시도</p>
</li>
<li><p>미디어/디코딩 계열 fatal → <code>hls.recoverMediaError()</code></p>
</li>
<li><p>그 외 → <code>destroy()</code> 이후 새로 로드 (상단에 이미 사용한 흔적 있음)</p>
<pre><code class="language-javascript">hls.on(Hls.Events.ERROR, (event, data) =&gt; {
console.log(&quot;HLS error:&quot;, data.type, data.details, &quot;fatal:&quot;, data.fatal);

if (!data.fatal) return;

switch (data.type) {
 case Hls.ErrorTypes.NETWORK_ERROR:
   hls.startLoad();
   break;
 case Hls.ErrorTypes.MEDIA_ERROR:
   hls.recoverMediaError();
   break;
 default:
   hls.destroy();

}
})</code></pre>
<h4 id="디버깅은-해당-순서로-보면-빠르다">디버깅은 해당 순서로 보면 빠르다</h4>
<p>HLS 재생이 안된다 - 대부분 이쪽에서 걸림</p>
</li>
</ul>
<p>1) m3u8이 네트워크로 받아지나?</p>
<ul>
<li>DevTools → Network에서 *.m3u8 요청 확인</li>
</ul>
<p>2) 세그먼트 요청이 이어지나?</p>
<ul>
<li>*.ts / *.m4s 요청이 연달아 나가야 정상</li>
</ul>
<p>3) CORS </p>
<ul>
<li>가장 흔한 함정</li>
<li>m3u8/세그먼트가 다른 도메인이면 CORS 헤더 필요</li>
</ul>
<h4 id="학습-시-목표-체크-리스트">학습 시 목표 체크 리스트</h4>
<ul>
<li>m3u8 URL 입력 → 재생</li>
<li>Safari 네이티브 분기 처리</li>
<li>Quality(Auto/고정) 드롭다운</li>
<li>Error 상태 표시 + recover 동작 확인</li>
<li>이벤트 로그/Network로 흐름 추적 가능</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker Redis 실행 & redis-cli 접속하기]]></title>
            <link>https://velog.io/@luislki_/Docker-Redis-%EC%8B%A4%ED%96%89-redis-cli-%EC%A0%91%EC%86%8D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@luislki_/Docker-Redis-%EC%8B%A4%ED%96%89-redis-cli-%EC%A0%91%EC%86%8D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 19 Feb 2026 09:34:25 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/bf41e914-412f-4759-8220-f7de2e08abdd/image.png" alt=""></p>
<h3 id="1-docker-desktop-준비">1) Docker Desktop 준비</h3>
<ul>
<li>docker run은 Docker 엔진이 켜져있어야 동작</li>
</ul>
<h3 id="2-redis-컨테이너-실행">2) Redis 컨테이너 실행</h3>
<p>Redis 서버를 도커 컨테이너로 띄움</p>
<pre><code class="language-bash">docker run -d --name going-redis -p 6379:6379 redis:7</code></pre>
<p><code>--name going-redis</code> : 컨테이너 이름 지정</p>
<p><code>-p 6379:6379</code> : 내 PC(host) 6379 → 컨테이너 6379 포트 연결</p>
<p><code>redis:7</code> : Redis 7 버전 이미지 사용 (버전 생략 가능)</p>
<h3 id="3-실행-확인">3) 실행 확인</h3>
<pre><code class="language-bash">docker ps</code></pre>
<ul>
<li>going-redis 컨테이너가 Up</li>
<li>포트도 매핑됨</li>
</ul>
<p>→ 즉, 내 PC에서 <a href="http://localhost:6379">localhost:6379</a> 로 Redis 접근 가능한 상태</p>
<h3 id="4-keys-명령--redis-cli-안에서-처리할것">4) keys 명령 = redis-cli 안에서 처리할것</h3>
<ul>
<li>Bash 창에 바로 치면 리눅스 쉘 명령으로 실행하게 됨.</li>
</ul>
<h3 id="5-redis-cli-접속">5) redis-cli 접속</h3>
<p>컨테이너 안의 Redis CLI로 들어감</p>
<pre><code class="language-bash">docker exec -it going-redis redis-cli</code></pre>
<p>그러면 프롬프트가 </p>
<pre><code class="language-makefile">127.0.0.1:6379&gt;</code></pre>
<p>로 바뀌고, 이제부터 Redis 명령이 동작</p>
<p>연결됐는지 확인하려면 PING 명령어 치면 PONG 이 옴. 귀여움</p>
<h3 id="6-redis-데이터키-확인">6) Redis 데이터(키) 확인</h3>
<p>redis-cli 안에서 </p>
<pre><code class="language-bash">KEYS *
KEYS refreshToken*</code></pre>
<p>했을때, 전체 키가 나옴 (현재 refreshToken)</p>
<ul>
<li>Redis는 정상 동작 중</li>
<li>refreshToken 관련 데이터가 저장되어있음</li>
<li>토큰값이 나오는건 아님</li>
</ul>
<h3 id="7-토큰값을-확인하려면">7) 토큰값을 확인하려면</h3>
<pre><code class="language-bash">TYPE refreshToken:test</code></pre>
<ul>
<li><p>test는 키데이터 명</p>
</li>
<li><p>string 일 때</p>
</li>
</ul>
<pre><code class="language-bash">GET refreshToken:test</code></pre>
<ul>
<li>hash 일 때</li>
</ul>
<pre><code class="language-bash">HGETALL refreshToken:test</code></pre>
<hr>
<h3 id="자주-사용하는-도커-관리-명령">자주 사용하는 도커 관리 명령</h3>
<ul>
<li>컨테이너 로그</li>
</ul>
<pre><code class="language-bash">docker logs going-redis</code></pre>
<ul>
<li>컨테이너 중지/시작</li>
</ul>
<pre><code class="language-bash">docker stop going-redis
docker start going-redis</code></pre>
<ul>
<li>컨테이너 삭제</li>
</ul>
<pre><code class="language-bash">docker rm -f going-redis</code></pre>
<hr>
<h3 id="추가---도커의-image란">추가 - 도커의 image란,</h3>
<p>프로그램을 실행하기 위한 완제품 패키지(템플릿)</p>
<p>Redis를 예로 들면, Redis 서버를 실행하는 데 필요한 것들이 한 덩어리로 들어있는 설치본 + 실행환경</p>
<h3 id="이미지image-vs-컨테이너container">이미지(image) vs 컨테이너(container)</h3>
<p>헷갈릴 수 있어서 정리하자면,</p>
<ul>
<li>Image = 레시피가 포함된 틀<ul>
<li>Redis가 어떻게 설치돼 있고, 어떤 파일들이 있고, 기본 실행 명령이 뭔지 정의된 템플릿</li>
<li>한 번 다운받아두면 계속 재사용 가능</li>
</ul>
</li>
<li>Container = 실제로 만들어진 제품(실행 중인 인스턴스)<ul>
<li>이미지를 실행(run) 해서 만들어진 실제 프로세스</li>
<li>컨테이너를 여러 개 띄울 수도 있음(같은 이미지로 복제 가능)</li>
</ul>
</li>
</ul>
<pre><code class="language-bash">docker run -d --name going-redis -p 6379:6379 redis</code></pre>
<p>그래서 해당 명령어의 의미는</p>
<ol>
<li>redis 이미지가 로컬에 없으면 다운로드하고</li>
<li>그 이미지로 컨테이너(going-redis)를 하나 만들어서</li>
<li>실행한다</li>
</ol>
<p><strong>이미지에 포함되는 것</strong></p>
<ul>
<li>OS 파일 일부(최소한의 리눅스 기반)</li>
<li>Redis 실행 파일(redis-server, redis-cli)</li>
<li>기본 설정 파일</li>
<li>컨테이너가 시작될 때 뭘 실행할지에 대한 기본 명령(ENTRYPOINT/CMD)</li>
</ul>
<p>그래서 내 컴퓨터에 Redis를 따로 설치하지 않아도 이미지 안에 다 들어있으니까 바로 실행 가능</p>
<p><strong>이미지 확인/관리 명령</strong></p>
<ul>
<li>내가 가진 이미지 목록 보기:</li>
</ul>
<pre><code class="language-bash">docker images</code></pre>
<ul>
<li>지금 실행 중인 컨테이너 보기:</li>
</ul>
<pre><code class="language-bash">docker ps</code></pre>
<ul>
<li>redis 이미지가 정학히 어떤 태그인지 보기:</li>
</ul>
<pre><code class="language-bash">docker images redis</code></pre>
<p><strong>요약</strong></p>
<p>이미지 = 실행 환경까지 포함된 프로그램 패키지(템플릿)</p>
<p>컨테이너 = 그 이미지를 실행해서 만들어진 실제 실행 중인 프로세스</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[HTML/CSS] 이상한 기본 스타일 없애기]]></title>
            <link>https://velog.io/@luislki_/HTMLCSS-%EC%9D%B4%EC%83%81%ED%95%9C-%EA%B8%B0%EB%B3%B8-%EC%8A%A4%ED%83%80%EC%9D%BC-%EC%97%86%EC%95%A0%EA%B8%B0</link>
            <guid>https://velog.io/@luislki_/HTMLCSS-%EC%9D%B4%EC%83%81%ED%95%9C-%EA%B8%B0%EB%B3%B8-%EC%8A%A4%ED%83%80%EC%9D%BC-%EC%97%86%EC%95%A0%EA%B8%B0</guid>
            <pubDate>Fri, 13 Feb 2026 16:25:05 GMT</pubDate>
            <description><![CDATA[<h4 id="리스트-점-제거">리스트 점 제거</h4>
<pre><code class="language-css">list-style: none;</code></pre>
<h4 id="링크-밑줄-제거">링크 밑줄 제거</h4>
<pre><code class="language-css">text-decoration: none;</code></pre>
<h4 id="버튼인풋-기본-테두리-제거">버튼/인풋 기본 테두리 제거</h4>
<pre><code class="language-css">border: none;</code></pre>
<h4 id="포커스-테두리아웃라인-제거">포커스 테두리(아웃라인) 제거</h4>
<pre><code class="language-css">outline: none;</code></pre>
<h4 id="배경-제거">배경 제거</h4>
<pre><code class="language-css">background: none;</code></pre>
<h4 id="이미지요소-드래그-방지브라우저-드래그">이미지/요소 드래그 방지(브라우저 드래그)</h4>
<pre><code class="language-css">-webkit-user-drag: none;</code></pre>
<h4 id="스크롤바-숨기기브라우저별">스크롤바 숨기기(브라우저별)</h4>
<pre><code class="language-css">/* Chrome/Safari */
::-webkit-scrollbar { display: none; }

/* Firefox */
scrollbar-width: none;</code></pre>
<h4 id="커서-숨기기">커서 숨기기</h4>
<pre><code class="language-css">caret-color: transparent;</code></pre>
<h4 id="-react에서-기본-브라우저-margin-padding-없애기">+ React에서 기본 브라우저 margin, padding 없애기</h4>
<pre><code class="language-css">/*app.css*/

html,body {
  margin: 0;
  padding: 0;
}</code></pre>
<p>더 깔끔하게 가고싶으면</p>
<pre><code class="language-css">*, *::before, *::after {
  box-sizing: border-box;
}

body {
  margin: 0;          /* body 기본 8px 제거 */
}

h1, h2, h3, p, ul, ol {
  margin: 0;          /* 제목/문단/리스트 기본 margin 제거 */
  padding: 0;         /* ul/ol 기본 padding 제거 */
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React Router] Route Guard 정리 (Zustand Principal 기반)]]></title>
            <link>https://velog.io/@luislki_/React-Router-Route-Guard-%EC%A0%95%EB%A6%AC-Zustand-Principal-%EA%B8%B0%EB%B0%98</link>
            <guid>https://velog.io/@luislki_/React-Router-Route-Guard-%EC%A0%95%EB%A6%AC-Zustand-Principal-%EA%B8%B0%EB%B0%98</guid>
            <pubDate>Fri, 13 Feb 2026 10:26:15 GMT</pubDate>
            <description><![CDATA[<h3 id="why-route-guard">Why Route Guard?</h3>
<p>사용자는 항상 내가 원하는 대로 동작하지 않는다. 
내가 &quot;로그인 버튼을 눌러서 로그인 하고 마이페이지는 드롭다운 내의 메뉴로 접근하세요&quot; 라는 흐름을 만들어 놨어도 사용자가 주소창에 경로를 직접 입력해서 특정 페이지(ex.마이페이지)로 들어올 수 있다.
<img src="https://velog.velcdn.com/images/luislki_/post/482b3173-6cb6-485c-ad82-5f5dc00874b5/image.png" alt=""></p>
<p>UI상에서는 숨겨져 있거나 버튼이 막혀있고, 백엔드에서 막아주고 있는 상태여도
URL 직접 접근은 언제든 가능하기 때문에 해당 문제들이 발생한다.</p>
<ul>
<li>인증이 없는 상태에서 페이지가 렌더되며 내부 API가 연달아 호출됨<ul>
<li>401/403이 계속 발생하거나, 화면이 깨진 상태로 보임<ul>
<li>경우에 따라선 빈 화면 / 무한 로딩 / 에러 토스트 반복 같은 UX가 발생</li>
<li>&quot;이 페이지는 접근 가능한가?&quot; 라는 책임이 컴포넌트에 분산되어
각 페이지마다 인증 체크를 반복하게 됨</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>그래서 &quot;이 경로는 로그인한 사용자만 접근 가능&quot; 같은 규칙을 <strong>라우팅 레벨에서 중앙집중적으로 차단</strong> 하기위해 Route Guard를 사용한다.</p>
<hr>
<h3 id="액션-가드-vs-라우트-가드">&quot;액션 가드&quot; vs &quot;라우트 가드&quot;</h3>
<p><strong>액션 가드(Action Guard)</strong></p>
<ul>
<li>&quot;예약하기&quot;, &quot;좋아요&quot;, &quot;결제하기&quot; 같은 행동 버튼 클릭 시점에 로그인 체크</li>
<li>로그인이 아니면 로그인 화면으로 보내고, 로그인 후 원래 화면으로 복귀</li>
</ul>
<p><strong>장점</strong>: 특정 기능만 보호 가능, UX가 직관적
<strong>단점</strong>: 라우트 자체는 접근 가능(페이지 단에서 한 번 더 막아야 완벽)</p>
<p><strong>라우트 가드(Route Guard)</strong></p>
<ul>
<li>로그인 필수 페이지 접근 자체를 차단</li>
<li>보호된 라우트는 <code>&lt;ProtectedRoute&gt;&lt;Page/&gt;&lt;/ProtectedRoute&gt;</code> 형태로 감싸서 진입을 막음</li>
</ul>
<p><strong>장점</strong>: 페이지 단에서 확실하게 막힘
하나만 사용하는 경우도 있지만, 결국에는 UX를 위해서는 프로젝트를 진행할때는 항상 2가지 전부 사용을 하는 모습이다.</p>
<hr>
<h3 id="protectedroute-구현-zustand-principal--bootstrap">ProtectedRoute 구현 (Zustand principal + bootstrap)</h3>
<ul>
<li>새로고침 직후엔 <code>isAuthenticated = false</code> 일 수 있다.
  → 토큰이 있으면 <code>bootstrap()</code> 으로 principal 복구 시도 후 판정해야 안정적</li>
<li>redirect는 <code>&lt;Navigate /&gt;</code> 사용 (SPA 유지)</li>
<li>state로 &quot;원래 가려던 경로&quot; 를 넘기면 로그인 후 자동 복귀 가능</li>
</ul>
<p><code>&lt;ProtectedRoute&gt;&lt;Page/&gt;&lt;/ProtectedRoute&gt;</code> 형태로 사용하기 때문에 </p>
<pre><code class="language-typescript">function ProtectedRoute({ children }: { children: React.ReactNode })</code></pre>
<ul>
<li>children : 해당 props가 반드시 있고  그 값은 리액트가 렌더 할  수있는 무언가여야 한다.</li>
<li>React.ReactNode : wrapper 컴포넌트에서 children 타입은 보통 이걸 쓰던데, 아래 요소들을 포함하기 때문이라고 한다.<ul>
<li>JSX 엘리먼트 <code>&lt;div /&gt;</code> , <code>&lt;MyComp /&gt;</code></li>
<li>문자열</li>
<li>숫자</li>
<li>배열</li>
<li>null </li>
</ul>
</li>
</ul>
<pre><code class="language-typescript">  const loc = useLocation();
// 인증 boolean
  const isAuthenticated = usePrincipalState((s) =&gt; s.isAuthenticated);
// 복구 시도 시 필요
  const bootstrap = usePrincipalState((s) =&gt; s.bootstrap);
  const [ready, setReady] = useState(false);

useEffect(() =&gt; {
    (async () =&gt; {
      await bootstrap(); // 토큰 존재 시 principal 복구 시도
      setReady(true);
    })();
  }, []); // 의존성 배열 없이 마운트 1회만

// 로딩중 화면
if (!ready) return &lt;div style={{ padding: 16 }}&gt;로딩중...&lt;/div&gt;;

// 인증되지 않은 상황에서는 로그인 화면으로 넘기고, 로그인 후 원래 본인이 접근하려던 곳으로 보내줌
if (!isAuthenticated) {
    return &lt;Navigate to=&quot;/signin&quot; replace state={{ from: loc }} /&gt;;
  }

  return &lt;&gt;{children}&lt;/&gt;;</code></pre>
<h3 id="라우터에-적용-page-전체-보호">라우터에 적용 (Page 전체 보호)</h3>
<p>Page가 <code>&lt;Outlet /&gt;</code> 을 렌더한다면, 상위에서 한 번만 감싸면 하위 라우트가 전부 보호된다.</p>
<pre><code class="language-typescript">&lt;Route
  element={
    &lt;ProtectedRoute&gt;
      &lt;Page /&gt;
    &lt;/ProtectedRoute&gt;
  }
&gt;
// ...여기있는 라우트들은 보호 대상    
&lt;/Route&gt;</code></pre>
<h4 id="-추가-이미-로그인-했는데-로그인-화면이-다시-뜨는-문제">+ (추가) 이미 로그인 했는데 로그인 화면이 다시 뜨는 문제</h4>
<p>뒤로가기 등으로 로그인 페이지에 다시 접근 가능한 상황에서는
로그인 페이지 자체에서 &quot;로그인 상태면 즉시 튕기기&quot; 처리를 하면 된다.</p>
<pre><code class="language-typescript">const isAuthenticated = usePrincipalState((s) =&gt; s.isAuthenticated);

 useEffect(() =&gt; {
    if (isAuthenticated) {
      navigate(fromPath, { replace: true });
    }
  }, [isAuthenticated]);
</code></pre>
<hr>
<h3 id="think">Think</h3>
<ul>
<li><strong>URL 직접 접근 차단</strong> : 버튼을 숨겨도 주소창으로 들어올 수 있으니 &quot;페이지 단(라우팅 레벨)&quot;에서 먼저 막기</li>
<li><strong>새로고침/재접속 대응</strong> : 토큰이 있어도 상태가 비어 있을 수 있어서 bootstrap() 으로 복구 후 판정(깜빡임 및 오판 방지)</li>
<li><strong>원래 가려던 곳으로 복귀</strong> : <code>/signin</code>으로 보낼 때 <code>state.from</code> 저장 → 로그인 성공 시 <code>navigate(from, { replace:true })</code></li>
<li><strong>뒤로가기 UX</strong> : 로그인 성공/리다이렉트는 <code>replace:true</code>로 히스토리에서 로그인 페이지 제거(뒤로가면 로그인창 재등장 방지)</li>
<li><strong>이미 로그인인데 로그인 페이지 노출 방지</strong> : 뒤로가기로 로그인 진입해도 로그인 상태면 즉시 from으로 튕기기 </li>
<li><strong>액션 가드도 같이</strong> : &quot;행동&quot; 은 클릭 시점에도 한 번 더 체크(로그인 전인데 UI 플로우만 진행되는 것 방지)</li>
<li><strong>로딩/에러 표현 최소화</strong> : guard 판정 중엔 잛은 로딩(스켈레톤 혹은 text)만, alert 너무 많지 않게, 토스트 혹은 인라인 안내로</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] N+1 문제 및 해결(@EntityGraph vs fetch join) ]]></title>
            <link>https://velog.io/@luislki_/JPA-N1-%EB%AC%B8%EC%A0%9C-%EB%B0%8F-%ED%95%B4%EA%B2%B0EntityGraph-vs-fetch-join</link>
            <guid>https://velog.io/@luislki_/JPA-N1-%EB%AC%B8%EC%A0%9C-%EB%B0%8F-%ED%95%B4%EA%B2%B0EntityGraph-vs-fetch-join</guid>
            <pubDate>Thu, 05 Feb 2026 07:57:25 GMT</pubDate>
            <description><![CDATA[<h3 id="n1-문제란">N+1 문제란,</h3>
<p><img src="https://velog.velcdn.com/images/luislki_/post/debf9c18-e95c-49f9-970d-08a9b9030bba/image.png" alt=""></p>
<ul>
<li><p>N+1은 리스트 쿼리 1번(+1) 실행 후,
리스트 안의 각 엔티티에서 LAZY 연관 필드를 접근할 때마다 N번의 추가 쿼리가 나가는 현상이다.</p>
</li>
<li><p>&quot;부모 리스트 조회&quot; 1번</p>
</li>
<li><p>&quot;자식/연관 엔티티 로딩&quot; N번
→ 총 1 + N(또는 1 + 2N, 1 + 3N ...) 쿼리가 발생한다.</p>
</li>
</ul>
<p>N+1은 데이터 정합성을 깨는 동시성 문제라기보다, 요청당 DB 쿼리를 폭증시켜 지연/타임아웃을 유발하는 성능 문제다.
  다만 지연이 커지면 사용자 재시도나 중복 요청을 유발해, 결과적으로 동시성 이슈가 더 잘 드러나는 환경을 만들 수 있다.</p>
<h3 id="왜-생기는가lazy--반복-접근-패턴">왜 생기는가(LAZY + 반복 접근 패턴)</h3>
<p>JPA에서 연관관계를 LAZY로 두면, 연관 엔티티는 처음 조회 시점에서는 실제로 가져오지 않고 프록시(대리 객체) 만 넣어둔다.</p>
<p>그리고 코드에서 연관 필드를 &quot;진짜로&quot; 접근하는 순간에,
그제서야 DB에 쿼리를 날려서 로딩한다.</p>
<p>문제는 &quot;리스트 조회 + 루프/스트림에서 연관 접근&quot; 조합이 너무 흔하다는 것.</p>
<p>예시)</p>
<ul>
<li>예약 리스트 10개 조회(1쿼리)</li>
<li>각 예약에서 timeSlot, lesson을 LAZY로 읽음 (각 10 쿼리)</li>
<li>결과: 1 + 10 + 10 = 21쿼리</li>
</ul>
<h3 id="entitygraph와-fetch-join">EntityGraph와 Fetch Join</h3>
<p>N+1을 해결하는 핵심은 단순하다.
LAZY연관을 나중에 하나씩 가져오지 말고, 처음 조회할 때 필요한 연관을 같이 가져오면 된다.
이걸 대표적으로 해결하는 방법이 Fetch Join 과 @EntityGraph다.</p>
<h4 id="fetch-joinjpql로-강제-함께-로딩">Fetch Join(JPQL로 강제 함께 로딩)</h4>
<p>fetch Joi은 JPQL에서 join fetch 를 직접 써서, 연관 엔티티를 무조건 같이 로딩하게 만드는 방식이다.</p>
<pre><code class="language-java">@Query(&quot;&quot;&quot;
select r from LessonReservation r
join fetch r.timeSlot
join fetch r.lesson
where r.user.userId = :userId
&quot;&quot;&quot;)
List&lt;LessonReservation&gt; findMyReservationsFetch(Long userId);</code></pre>
<p><strong>특징</strong></p>
<ul>
<li>어떤 연관을 가져올지 쿼리에 명시적으로 드러남</li>
<li>복잡한 조건/조인/튜닝이 필요하 경우 제어가 강함</li>
<li>대신 JPQL이 길어질 수 있고, 상황에 따라 distinct나 페이징 문제가 생길 수 있음(특히 컬렉션 조인)</li>
</ul>
<h4 id="entitygraph-조회-메서드에-옵션처럼-붙이는-방식">@EntityGraph (조회 메서드에 &#39;옵션&#39;처럼 붙이는 방식)</h4>
<p>@EntityGraph는 &quot;이 메서드로 조회할 때는 이 연관들을 같이 가져와&quot;를 Repository 메서드에 선언하는 방식이다.</p>
<pre><code class="language-java">@EntityGraph(attributePaths = {&quot;timeSlot&quot;, &quot;lesson&quot;})
List&lt;LessonReservation&gt; findAllByUser_UserIdOrderByRequestedDtDesc(Long userId);</code></pre>
<p><strong>특징</strong></p>
<ul>
<li>기존 네이밍 쿼리(findAllBy...)를 유지하면서 N+1을 쉽게 막을 수 있음</li>
<li>&quot;해당 메서드는 항상 이 연관이 필요하다&quot; 같은 경우에 가독성과 유지보수가 좋음</li>
<li>내부적으로는 JPA가 해당 연관을 한번에 로딩하도록 쿼리를 굿겅한다 (보통 join 형태)</li>
</ul>
<h3 id="둘의-차이-요약">둘의 차이 요약</h3>
<h4 id="entitygraph">@EntityGraph</h4>
<ul>
<li>AKA fetch join의 간편 ver.</li>
<li>특정 API/List 에서는항상 필요한 연관이 고정되어 있음</li>
<li>메서드 네이밍 쿼리를 유지하고 싶음</li>
<li>특히 ManyToOne, OneToOne 같은 to-one  연관을 같이 로딩할 때 깔끔</li>
</ul>
<h4 id="fetch-join">Fetch Join</h4>
<ul>
<li>조건/조인 구조가 복잡해서 쿼리를 직접 컨트롤하고 싶음</li>
<li>성능 튜닝을 위해 &quot;어떤 join을 하는지&quot;를 코드에서 명확히 보고 싶음</li>
<li>필요할 때만 선택적으로 fetch 해야함</li>
</ul>
<h3 id="주의사항">주의사항</h3>
<h4 id="to-one은-비교적-안전">to-one은 비교적 안전</h4>
<ul>
<li>ManyToOne, OneToOne은 결과 row가 크게 뻥튀기 되지 않아서 
@EntityGraph든 fetch join이든 적용이 상대적으로 안전하다.</li>
</ul>
<h4 id="to-many컬렉션-fetch는-조심">to-many(컬렉션) fetch는 조심</h4>
<ul>
<li>OneToMany같은 컬렉션을 fetch join 하면 결과 row가 중복되거나,
페이징이 깨질 수 있는 문제가 자주 생긴다.</li>
<li>이런 경우는 DTO 프로젝션, 배치 패치 사이즈, 별도 조회 등 전략을 같이 고려해야 한다.</li>
</ul>
<h4 id="결론">결론</h4>
<ul>
<li>항상 필요한 to-one 연관을 같이 가져오겠다 → @EntityGraph가 가장 간단</li>
<li>쿼리를 직접 설계해서 가져오는 걸 통제해야겠다 → fetch join이 낫다</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Tanstack Query] React Query 조금 더 알고 사용하기 - queryKey와 캐시 동기화]]></title>
            <link>https://velog.io/@luislki_/Tanstack-Query-React-Query-%EC%A1%B0%EA%B8%88-%EB%8D%94-%EC%95%8C%EA%B3%A0-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-queryKey%EC%99%80-%EC%BA%90%EC%8B%9C-%EB%8F%99%EA%B8%B0%ED%99%94</link>
            <guid>https://velog.io/@luislki_/Tanstack-Query-React-Query-%EC%A1%B0%EA%B8%88-%EB%8D%94-%EC%95%8C%EA%B3%A0-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-queryKey%EC%99%80-%EC%BA%90%EC%8B%9C-%EB%8F%99%EA%B8%B0%ED%99%94</guid>
            <pubDate>Thu, 29 Jan 2026 13:22:57 GMT</pubDate>
            <description><![CDATA[<p align="center">
  <img src="https://velog.velcdn.com/images/luislki_/post/5595be5d-7c62-4d2e-97f1-9d2efc58f44b/image.png" width="420" />
</p>

<h3 id="querykey를-미리-설계하는-이유">queryKey를 미리 설계하는 이유</h3>
<p>React Query(TanstackQuery)는 단순히 &quot;API 호출을 편하게 해주는 라이브러리&quot; 가 아니라 
<strong>서버 상태(server state)</strong>를 캐시로 관리하는 도구다.</p>
<p>그래서 React Query 를 조금 더 알고 쓰려면 useQuery 문법보다 먼저 이해해야하는게 있다. </p>
<p><strong>queryKey = 캐시에 저장되는 데이터의 주소(식별자)</strong></p>
<p>이 글에서는 React Query의 핵심 개념과,
특히 queryKey를 미리 세팅 (키 팩토리/컨벤션화) 해두면 얻는 장점을 정리해보려고 한다.</p>
<p>사실, 해당 내용을 인지하기 전 개인프로젝트에서 queryKey 관련으로 골머리 아픈 상황이 생겼고, 이를 방지하기 위해(다시는 그런 일을 겪지 않기 위해...) 정리하기로 마음 먹었다.</p>
<h3 id="1-react-query가-다루는건-서버-상태다">1) React Query가 다루는건 &quot;서버 상태&quot;다</h3>
<p>프론트에서 상태는 크게 두 종류로 나뉜다.</p>
<ul>
<li>UI 상태: 모달 열림, 탭 선택, 입력 중인 폼 값, 드롭다운 오픈 등</li>
<li>서버 상태: 서버에서 받아온 목록/상세 데이터, 그리고 그 데이터의 최신성/동기화</li>
</ul>
<p>React Query 는 후자인 <strong>서버 상태</strong> 를 캐시로 다루는 데 특화돼 있다.</p>
<ul>
<li>같은 데이터를 여러 화면에 써도 캐시를 공유</li>
<li>필요할 때만 재조회(refetch)</li>
<li>수정/삭제/토글 후 invalidate로 다시 맞추기</li>
</ul>
<h3 id="2-querykey는-데이터-주소다">2) queryKey는 &quot;데이터 주소&quot;다</h3>
<p>React Query에서 queryKey는 어떤 데이터를 캐시에 저장할지 결정한다.</p>
<ul>
<li>주소가 같으면 같은 캐시를 쓴다</li>
<li>주소가 다르면 캐시가 분리된다</li>
</ul>
<p>그래서 queryKey를 대충 쓰면</p>
<ul>
<li>캐시가 중복 저장되고 </li>
<li>invalidate가 제대로 안 먹고</li>
<li>화면마다 데이터가 제각각 보일 수 있다</li>
</ul>
<p>반대로 queryKey를 잘 설계하면 </p>
<ul>
<li>데이터 흐름이 안정적이고</li>
<li>동기화가 명확해지고</li>
<li>코드도 읽기 쉬워진다</li>
</ul>
<h3 id="3-querykey를-미리-세팅-하는-방법--keys-factory">3) queryKey를 &quot;미리 세팅&quot; 하는 방법 : Keys Factory</h3>
<p>나는 queryKey를 컴포넌트에서 직접 하드코딩하지 않고, 한 파일에 모아두는 방식을 사용했다.</p>
<pre><code class="language-typescript">export const lessonKeys = {
  // 내 레슨 목록(정렬 기준을 key에 포함)
  myList: (sort: LessonSort) =&gt; [&quot;myLessons&quot;, sort] as const,

  // 내 레슨 상세
  myDetail: (lessonId: number) =&gt; [&quot;lessons&quot;, &quot;me&quot;, &quot;detail&quot;, lessonId] as const,

  // 타임슬롯(기간 포함)
  myTimeSlots: (lessonId: number, from: string, to: string) =&gt;
    [&quot;lessons&quot;, &quot;me&quot;, &quot;detail&quot;, lessonId, &quot;time-slots&quot;, from, to] as const,

  // 반복 규칙
  myRecurrence: (lessonId: number) =&gt;
    [&quot;lessons&quot;, &quot;me&quot;, &quot;detail&quot;, lessonId, &quot;recurrence&quot;] as const,
};
</code></pre>
<h4 id="as-const-를-붙이는-이유">as const 를 붙이는 이유</h4>
<ul>
<li>queryKey 타입이 &quot;그냥 string[]&quot;로 퍼지지 않게 고정해 준다.</li>
<li>invalidate/setQueryData에서 키를 일관되게 쓰기 쉬워진다.</li>
</ul>
<p align="center">
  <img 
    src="https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExZXN5MWJlYjBlOTd0aDBwbnJwb2ZtODZ6dmM0N2pseHNnOWEyNGRlOSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/jR02MShfuA0Pw83pZs/giphy.gif"
    width="300"
  />
</p>

<p>참고 *: 프로젝트 중간에 컨벤션을 도입하면 기존에 쓰던 키(prefix)와 섞이기 쉬운데, 이때 같은 데이터를 서로 다른 key로 캐싱하게 되면서 invalidate가 먹지 않는 문제가 생길 수 있다.</p>
<p>그런의미에서 초반에 잡아두고 가는것이 좋다고 판단되었다.</p>
<h3 id="4-key를-미리-세팅해두면-얻는-장점">4) key를 미리 세팅해두면 얻는 장점</h3>
<ul>
<li>키가 곧 API 문서/도메인 구조가 된다
myList, myDetail, myTimeSlots 처럼 키 이름이 정리돼 있으면 프로젝트에서 어떤 데이터들이 존재하는지 한 눈에 보인다</li>
<li>invalidate 범위를 설계하기 쉬워진다</li>
</ul>
<p>예를 들어 내 레슨 목록만 갱신하고 싶다면</p>
<pre><code class="language-typescript">qc.invalidateQueries({ queryKey: lessonKeys.myList(sort) });
</code></pre>
<p>정렬 캐시를 한 번에 갱신하고 싶으면 prefix를 기준으로 </p>
<pre><code class="language-typescript">qc.invalidateQueries({ queryKey: [&quot;myLessons&quot;] });
</code></pre>
<p>invalidate 전략을 코드로 설명가능하게 된다.</p>
<ul>
<li><p>협업에서 실수(키 불일치)를 크게 줄인다
키를 하드코딩하면 사람이 늘어날수록 &quot;미묘하게 다른 배열&quot;이 생긴다.
keys factory로 통일하면 팀 전체가 같은 key를 쓰게되면서 해당 문제를 줄일 수 있다.</p>
</li>
<li><p>리팩토링이 용이하다
나중에 key구조를 바꿔야 해도 한 파일만 수정하면 된다.</p>
</li>
</ul>
<h3 id="5-usequery-읽기서버---캐시">5) useQuery: 읽기(서버 -&gt; 캐시)</h3>
<p>키를 미리 세팅해두면 useQuery는 오히려 단순해진다.</p>
<pre><code class="language-typescript">export function useMyLessonList(sort: LessonSort) {
  return useQuery({
    queryKey: lessonKeys.myList(sort),
    queryFn: async (): Promise&lt;LessonSummary[]&gt; =&gt; {
      const resp = await getMyLessonsReq();
      return resp.data.data;
    },
  });
}</code></pre>
<p>여기서 핵심은 </p>
<ul>
<li>queryKey = 캐시 주소</li>
<li>queryFn = 서버에서 가져오는 방법</li>
</ul>
<h3 id="6-usemutation-쓰기서버-변경---캐시-동기화">6) useMutation: 쓰기(서버 변경 -&gt; 캐시 동기화)</h3>
<p>서버 데이터를 바꾸는 작업은 useMutation이 담당한다.</p>
<pre><code class="language-typescript">export function useToggleMyLessonStatus(sort: LessonSort) {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: ({ lessonId, next }: { lessonId: number; next: LessonStatus }) =&gt;
      updateMyLessonReq(lessonId, { status: next }),

    onSuccess: (_data, vars) =&gt; {
      // 목록 + 상세 캐시를 다시 최신화
      qc.v({ queryKey: lessonKeys.myList(sort) });
      qc.invalidateQueries({ queryKey: lessonKeys.myDetail(vars.lessonId) });
    },
  });
}</code></pre>
<p>여기서 중요한 건 &quot;쓰기 성공 이후 어떤 캐시를 갱신할지&quot;가 key로 드러난다는 점이다.</p>
<h3 id="정리">정리</h3>
<p>React Query를 &quot;조금 더 알고&quot; 사용한다는 말은 결국 이 말로 요약된다.</p>
<ul>
<li>서버 상태는 캐시로 관리한다</li>
<li>queryKey는 데이터의 주소다</li>
<li>key를 미리 세팅(컨벤션화)하면 동기화/리팩토링/협업이 쉬워진다</li>
</ul>
<p>여기서 나는 미리 세팅 혹은, keys를 파일로 분리할 생각을 하지 못했었고 코드와 파일이 늘어날 수록 이전에 설정해놓은 key를 다시 설정한다던가 하는 실수를 해서 상태를 최신화 시키지 못하는 상황이 일어났었다.
다시는 그로 인해 쓸데없이 정신력 깎아먹는 시간을 만들지 않도록 성장해야겠다!</p>
<p align="center">
  <img 
    src="https://velog.velcdn.com/images/luislki_/post/c4a98bc2-98bb-45a8-a5f7-893fb6087417/image.png"
    width="250"
  />
  끝
</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[
DTO는 record가 답일까]]></title>
            <link>https://velog.io/@luislki_/DTO%EB%8A%94-record%EA%B0%80-%EB%8B%B5%EC%9D%BC%EA%B9%8C</link>
            <guid>https://velog.io/@luislki_/DTO%EB%8A%94-record%EA%B0%80-%EB%8B%B5%EC%9D%BC%EA%B9%8C</guid>
            <pubDate>Wed, 14 Jan 2026 12:56:39 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/0ec7b239-4af5-4427-be52-838f61f6eabf/image.png" alt="">
개인 프로젝트를 진행하다가 DTO에 관해 조금 더 확실하게 정리를 하고 가면 좋을것같다는 생각이 들었다.</p>
<p>웹개발 수업으로 처음 스프링 프레임워크를 접했을 때, 수업은 JSP / MyBatis 위주로 진행되었다. DTO도 자연스럽게 class로 만들게 되었는데, 프레임워크의 편의성(JPA 같은 것들)보다 ‘뜨거운 커스텀의 맛’을 먼저 보는 바람에 당시에는 계속 “이게 뭔데…” 스탠스를 유지할 수밖에 없었다.</p>
<p><img src="https://velog.velcdn.com/images/luislki_/post/88802eae-5e5a-43ad-9367-95aad6aa6107/image.png" alt=""></p>
<p>그러다 이후에 record로 DTO를 작성할 기회가 많아졌고, 문득 이런 생각이 들었다.</p>
<p>“예전엔 class로 만들었는데, 지금은 record로도 만들잖아? 그럼 둘은 정확히 뭐가 다르지?”</p>
<p>이 글은 class DTO와 record DTO의 차이, 그리고 내 프로젝트에서 어떤 기준으로 선택하면 좋은지를 정리해보는 글이다.</p>
<h3 id="dto">DTO</h3>
<p>DTO(Data Transfer Object)는 말 그대로 데이터를 전달하기 위한 객체다.
여기서 중요한 포인트는 &quot;객체&quot;가 아닌 전달(Transfer)이다.</p>
<p>즉, DTO는 보통 이런 상황에서 등장한다.</p>
<ul>
<li>클라이언트(프론트) &lt;-&gt; 서버(백엔드) 가 데이터를 주고받을 때</li>
<li>컨트롤러 &lt;-&gt; 서비스/도메인 사이에서 데이터를 옮길 때</li>
</ul>
<p>DTO의 역할은 간단하다.
&quot;필요한 데이터만, 약속된 형태로, 안전하게 전달하기.&quot;</p>
<h3 id="왜-dto를-따로-만들까">왜 DTO를 따로 만들까?</h3>
<ol>
<li>필요 없는 정보까지 노출될 가능성
예: User 엔티티에 password, role, 내부 상태값이 있는데 응답으로 그대로 나가버릴 수도 있음</li>
<li>API 응답 스펙이 엔티티에 끌려다님
화면에 필요한 응답은 바뀌는데, 그때마다 DB 설계(Entity)를 건드릴 순 없음</li>
<li>화면에 맞게 가공해서 보내기 어렵다
현재 진행하고있는 프로젝트에서 이름 + 악기 + 레슨 가격을 한 번에 내려줘야하는 상황이 있는데, 엔티티 구조가 그대로면 비효율적이다.</li>
</ol>
<p>그래서 DTO는 &quot;엔티티/도메인과 별개로&quot; API 계약서 역할을 해준다.</p>
<h4 id="택배-상자를-예로-들어보자">택배 상자를 예로 들어보자.</h4>
<p>엔티티가 &quot;집 안의 물건 전체&quot;라면, DTO는 &quot;택배 상자&quot; 같은 느낌이다.</p>
<ul>
<li>집(서버 내부)에는 물건이 많고, 민감한 것도 있고, 정리도 제각각이다.</li>
<li>근데 택배(응답(Response)/요청(Request))로 보낼 때는<ul>
<li>필요한 것만</li>
<li>정해진 포장 방식으로</li>
<li>안전하게 보내야함
즉, DTO는 &quot;택배 상자에 담을 목록 + 포장 규격&quot;이다.</li>
</ul>
</li>
</ul>
<h4 id="결론적으로-dto는-필요한-정보만-담는-약속된-그릇이다">결론적으로, DTO는 &quot;필요한 정보만 담는 약속된 그릇&quot;이다.</h4>
<ul>
<li>DTO = 데이터 전달 목적</li>
<li>Entity = DB와 도메인 모델링이 목적</li>
</ul>
<p>여기까지는 이해했고, 해당 목적에 맞게 프로젝트에 적용할 수 있다.
하지만 class vs record 사용방식 차이를 설명하라고 한다면? 
<img src="https://velog.velcdn.com/images/luislki_/post/51fee343-6480-4d7f-b397-1b712d239232/image.png" alt="">
&quot;편하니까 record.&quot; 라고 밖에 대답 못하겠다.</p>
<h3 id="그래서-class-dto와-record-dto는-뭐가-다를까">그래서 class DTO와 record DTO는 뭐가 다를까?</h3>
<p>record를 사용하면 class 로 만들 때보다 코드가 짧아지고, getter 만들 필요도 없고, IDE가 자동으로 다 해주는 느낌이라 편리하다.</p>
<p>하지만 계속 증식하는 DTO를 바라보고 있자니 record가 편한 이유는 단순히 &quot;짧아서&quot;가 아니었다.
DTO의 목적(전달/계약)에 더 잘 맞는 방향으로 코드를 강제하기 때문이다.</p>
<p>핵심 차이는 
<em>class DTO는 &#39;가변 객체&#39;가 되기 쉽고, record DTO는 불변 계약을 기본값으로 가진다.</em></p>
<h4 id="1-가장-큰-차이-불변immutable-vs-가변mutable">1) 가장 큰 차이: “불변(Immutable)” vs “가변(Mutable)&quot;</h4>
<p>DTO는 아까 말했듯 &quot;필요한 데이터만 약속된 형태로 전달&quot; 하는 역할이다.
그 말은 곧, 한번 만들어진 DTO가 중간에 바뀌지 않는 게 안전하다는 뜻이기도 하다.</p>
<h4 id="class-dto가변이-되기-쉬움">class DTO(가변이 되기 쉬움)</h4>
<p>class는 기본적으로 setter를 열어두기 쉽다.</p>
<ul>
<li>처음에는 &quot;바인딩이 편하니까&quot;</li>
<li>나중에는 &quot;값 수정할 일이 있을 수도 있으니까&quot;</li>
<li>그러다가 어느 순간 DTO가 중간에 값이 바뀌는 객체가 되어버린다.</li>
</ul>
<p>이게 왜 문제냐면, DTO는 원래 &quot;계약서&quot;인데
계약서가 중간에 수정되면 어디서 값이 바뀌었는지 추적이 어려워진다.</p>
<h4 id="record-dto불변이-기본값">record DTO(불변이 기본값)</h4>
<p>record는 생성 시점에 값이 정해지고, 그 이후에는 바꿀 수 없다.
즉, DTO를 record로 작성하는 순간 코드 자체가 &quot;이 DTO는 전달용이고, 한 번 만들어지면 변하면 안된다&quot; 고 말하는 셈이다.</p>
<p>DTO목적에 딱 맞는 성질을 언어 차원에서 기본값으로 제공하는 게 record의 제일 큰 장점이다.</p>
<h4 id="2-계약서-로서의-명확함--생성자-한-방에-스펙이-고정됨">2) &quot;계약서&quot; 로서의 명확함 : 생성자 한 방에 스펙이 고정됨</h4>
<p>class DTO는 필드가 많아질 수록 해당 패턴이 흔하다.</p>
<ul>
<li>기본 생성자 만드로</li>
<li>setter로 하나씩 채우고</li>
<li>값이 다 채워졌는지 확신은 없음
record는 생성자 한 번으로 스펙이 확정된다.</li>
<li>어떤 값이 DTO를 구성하는지 한 줄에 보이고</li>
<li>필수 값이 빠지면 컴파일/실행 단계에서 바로 티가 난다.
&quot;DTO는 계약&quot; 이라는 관점에서 record가 훨씬 직관적이다.</li>
</ul>
<h4 id="3-보일러플레이트-짧아져서-편하다-는-사실-맞다">3) 보일러플레이트: &quot;짧아져서 편하다&quot; 는 사실 맞다</h4>
<p>record는 다음을 자동으로 제공한다 </p>
<ul>
<li>getter(정확히는 component accessor)</li>
<li>equals / hashCode</li>
<li>toString</li>
<li>canonical constructor
그래서 코드량이 줄고 실수 포인트도 줄어든다.</li>
</ul>
<p>하지만 중요한건 &quot;코드가 짧다&quot;가 아니라 
DTO가 DTO답게 유지되도록 돕는다는 점이다.(일관성 유지)</p>
<h4 id="그럼에도-class-dto가-존재하는-이유">그럼에도 class DTO가 존재하는 이유</h4>
<p>record로 대부분 커버가 되긴 하지만, 프로젝트를 진행하다 보면 DTO가 &quot;계약서&quot;가 아닌 상태/과정/확장에 가까워지는 순간이 잇다.
그럴때는 class가 더 자연스럽거나, 팀/도구 환경상 class가 편한 경우가 생긴다.</p>
<h4 id="1-단계적으로-채워지는-요청임시저장--draft--step-form">1) 단계적으로 채워지는 요청(임시저장 / Draft / Step Form)</h4>
<p>예를 들어 신청서처럼 </p>
<ul>
<li>소개 먼저 저장</li>
<li>경력 나중에 저장</li>
<li>1선택 추가</li>
<li>2태그 추가
이런 식으로 값이 순차적으로 쌓이는 요청은, DTO가 이미 &quot;완성된 계약&quot; 이라기 보다 &quot;작성 중인 문서&quot;가 된다.
record는 불변이라 매 단계마다 새 객체를 만들어야 하고, 경우에 따라 null/Optional이 많아져 오히려 복잡해질 수 있다.
이럴 땐 class가 더 자연스럽다.</li>
</ul>
<h4 id="2-상속버전-분기-같은-확장-구조가-필요할-때">2) 상속/버전 분기 같은 &quot;확장 구조&quot;가 필요할 때</h4>
<p>record는 상속이 불가능하다.
공통 베이스 DTO를 두고 v1/v2 로 확장한다거나, 계증 구조를 활용하는 설계를 택하면 class를 써야한다.</p>
<h4 id="3-dto에-변환조립-책임을-섞는-스타일일-때">3) DTO에 &quot;변환/조립 책임&quot;을 섞는 스타일일 때</h4>
<p>예전에 내가 만들었던 DTO처럼 요청 DTO 안에 toUser(), toOAuth2User() 같은 변환 메서드를 넣는 경우가 있다.
이건 DTO가 순수 전달 객체라기보다 “조립/변환기” 역할까지 가지게 되는 형태라서, class로 두는 편이 자연스럽게 느껴질 수 있다.
(물론 record에서도 메서드는 가능하지만, 설계 의도 자체가 달라진다.)</p>
<h3 id="내가-이해한-결론">내가 이해한 결론</h3>
<p><img src="https://velog.velcdn.com/images/luislki_/post/4a4294f1-54ca-4f9b-884d-d4ff7377be51/image.png" alt=""></p>
<p>잘 쓰면 class DTO든 record DTO든 결과는 거의 같다.
생성자 한 번에 값을 채워서(new response 같은) &quot;완성된 DTO&quot;를 만들고, 이후에는 건드리지 않는다면 둘 다 안전하게 DTO 역할을 한다.</p>
<p>차이는 기능 차이보다 <strong>실수 방지(가드레일)</strong> 에 가깝다.
record는 기본이 불변이라 setter 기반의 &quot;미완성 DTO/중간 수정&quot; 패턴이 구조적으로 나오기 어렵고, 코드만 봐도 &quot;이건 계약/전달용 데이터 묶음&quot; 이라는 의도가 명확하다.</p>
<p>반대로 class는 유연한 만큼 팀이 규칙을 안 세우면 DTO가 점점 &quot;상태를 가진 객체&quot; 처럼 변질되기 쉽다.
그래서 나는 기본값을 record로 두고, &quot;작성 중&quot; 흐름이 필요한 경우 class를 고려하기로 했다.</p>
<p>결국 record가 편한 이유도 “짧아서”라기보다, DTO를 DTO답게 유지하게 해주는 기본값이기 때문이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] 중복 시간 자동 채우기(BaseTimeEntity)]]></title>
            <link>https://velog.io/@luislki_/JPA%EC%A4%91%EB%B3%B5-%EC%8B%9C%EA%B0%84-%EC%9E%90%EB%8F%99-%EC%B1%84%EC%9A%B0%EA%B8%B0BaseTimeEntity</link>
            <guid>https://velog.io/@luislki_/JPA%EC%A4%91%EB%B3%B5-%EC%8B%9C%EA%B0%84-%EC%9E%90%EB%8F%99-%EC%B1%84%EC%9A%B0%EA%B8%B0BaseTimeEntity</guid>
            <pubDate>Sat, 10 Jan 2026 11:07:38 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/luislki_/post/6f1625b4-0df5-40ce-8a07-09e9c719142e/image.png" alt=""></p>
<h2 id="spring-data-jpa-auditing으로-createdat--updatedat-자동-채우기">Spring Data JPA Auditing으로 createdAt / updatedAt 자동 채우기</h2>
<p>엔티티마다 생성일/수정일 컬럼을 매번 넣기 귀찮을 때, 공통 베이스 클래스를 만들어두면 편하다.</p>
<p>@MappedSuperclass + @CreatedDate / @LastModifiedDate를 쓰면 저장/수정 시점에 시간이 자동으로 채워진다.</p>
<h3 id="--예시">- 예시</h3>
<pre><code class="language-java">@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class) // Auditing 동작 스위치
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(name = &quot;create_dt&quot;, nullable = false, updatable = false)
    private LocalDateTime createDt;

    @LastModifiedDate
    @Column(name = &quot;update_dt&quot;)
    private LocalDateTime updateDt;
}
</code></pre>
<h3 id="--사용방법">- 사용방법</h3>
<p>엔티티에서 상속만 하면, 해당 엔티티 테이블에 create_dt, update_dt 컬럼이 포함된다.</p>
<pre><code class="language-java">@Entity
public class Lesson extends BaseTimeEntity {
    @Id @GeneratedValue
    private Long lessonId;

    // ...
}</code></pre>
<h3 id="--반드시-필요한-설정--auditing-활성화">- 반드시 필요한 설정 : Auditing 활성화</h3>
<p>Spring Boot에서 Auditing이 자동으로 돌려면 설정 클래스(또는 메인 클래스)에 아래를 추가해야 한다.</p>
<ul>
<li><p>메인 클래스 </p>
<pre><code class="language-java">@EnableJpaAuditing
@SpringBootApplication
public class MuzinApplication { }</code></pre>
<p>혹은,</p>
</li>
<li><p>설정 클래스(config 파일 생성) 분리</p>
<pre><code class="language-java">@Configuration
@EnableJpaAuditing
public class JpaConfig { }</code></pre>
</li>
</ul>
<h3 id="주의할-점">주의할 점</h3>
<ul>
<li>@MappedSuperclass는 테이블로 생성되지 않음 (상속받은 엔티티 테이블에 컬럼이 생김)</li>
<li>@CreatedDate, @LastModifiedDate는 <strong>JPA Auditing 활성화(@EnableJpaAuditing)</strong>가 없으면 안 채워짐</li>
<li>createDt는 updatable=false로 두는 게 일반적으로 안전 (생성일은 수정되면 안 되니까)</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>