<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>choi-hyk.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Sun, 31 May 2026 08:24:30 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>choi-hyk.log</title>
            <url>https://velog.velcdn.com/images/choi-hyk/profile/5800b0e9-9717-4248-9fe1-fa1bd8308def/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. choi-hyk.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/choi-hyk" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[인터페이스 설계 전략 (백엔드)]]></title>
            <link>https://velog.io/@choi-hyk/%EB%B0%B1%EC%97%94%EB%93%9C-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@choi-hyk/%EB%B0%B1%EC%97%94%EB%93%9C-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Sun, 31 May 2026 08:24:30 GMT</pubDate>
            <description><![CDATA[<h2 id="🖥️-시작하기에-앞서">🖥️ 시작하기에 앞서</h2>
<p>이전 글에서는 프론트엔드 관점에서 백엔드 인터페이스를 어떻게 받아와야 하는지 정리했다.</p>
<p>프론트엔드는 백엔드가 제공하는 <strong>요청/응답 스키마, 에러 코드, 상태 코드, 이벤트 이름</strong>을 기준으로 화면을 만든다. 그래서 프론트엔드가 API 응답을 화면마다 임의로 재해석하기 시작하면 유지보수가 어려워진다.</p>
<p>결국 프론트엔드는 백엔드 인터페이스에 전적으로 의존한다.</p>
<p>따라서 백엔드는 프론트엔드가 추측하지 않아도 되는 명확한 패턴으로 응답을 전달해야 한다. 성공 응답, 에러 응답, 이벤트 payload, 상태 코드가 기능마다 다르게 구성되면 프론트엔드는 매번 새로운 예외 처리를 만들 수밖에 없다.</p>
<p>그렇다면 반대로 백엔드는 어떤 인터페이스를 제공해야 할까?</p>
<p>단순히 API 응답 타입을 예쁘게 만드는 것만으로는 부족하다. 백엔드 API는 최종적으로 프론트엔드에 노출되는 인터페이스지만, 그 뒤에는 <strong>데이터베이스, 외부 서비스, 도메인 로직, 인증, 캐시</strong> 같은 여러 내부 인터페이스가 얽혀 있다.</p>
<p>이 내부 구조가 정리되어 있지 않으면 API 인터페이스도 오래 안정적으로 유지되기 어렵다.</p>
<p>처음에는 컨트롤러에서 데이터베이스를 조회하고, 필요한 외부 API를 호출하고, 응답을 만들어도 크게 문제가 없어 보인다. 하지만 기능이 늘어나면 한 API가 여러 <code>provider</code>를 직접 알고, 여러 서비스가 서로를 호출하고, 공통 로직이 여기저기 복사된다.</p>
<p>이때부터 백엔드 코드는 단순히 길어지는 것이 아니라 의존 방향이 흐려진다.</p>
<p>이번 글에서는 백엔드 인터페이스를 API, 데이터베이스, 외부 서비스 관점으로 나누고, 이들을 어떤 책임으로 연결해야 하는지 정리해보려고 한다.</p>
<hr>
<h2 id="🧩-백엔드가-다루는-세-가지-인터페이스">🧩 백엔드가 다루는 세 가지 인터페이스</h2>
<p>백엔드에서 인터페이스라고 하면 보통 API만 떠올리기 쉽다.</p>
<p>하지만 실제로 백엔드가 다루는 인터페이스는 하나가 아니다. 크게 보면 다음 세 가지를 생각할 수 있다.</p>
<ul>
<li>API 인터페이스 레이어</li>
<li>데이터베이스 인터페이스 레이어</li>
<li>외부 서비스 인터페이스 레이어</li>
</ul>
<p>API 인터페이스 레이어는 외부 요청 주체가 직접 접근하는 경계이다. 요청을 받고, 응답을 내려주고, 에러를 정해진 형태로 반환한다. OpenAPI 문서로 관리되는 부분도 대부분 이 레이어에 해당한다.</p>
<p>데이터베이스 인터페이스 레이어는 애플리케이션이 데이터를 저장하고 조회하기 위해 사용하는 경계이다. ORM, repository, query builder, raw SQL 등 구현 방식은 다를 수 있지만 핵심은 같다. 애플리케이션 코드가 데이터 저장소를 어떤 방식으로 다룰지 정하는 내부 계약이다.</p>
<p>외부 서비스 인터페이스 레이어는 결제, 메일, 알림, 파일 저장소, 인증 제공자 같은 외부 시스템과 연결되는 경계이다. 외부 API의 요청/응답 구조, 인증 방식, 실패 케이스, 재시도 정책을 감싸는 역할을 한다.</p>
<pre><code class="language-text">Client
  ↓
API Interface Layer
  ↓
Application / Domain Service
  ↓
Database Provider
External Service Provider</code></pre>
<p>여기서 중요한 것은 API 인터페이스가 혼자 존재하지 않는다는 점이다.</p>
<p>백엔드가 최종적으로 보여주는 것은 API 인터페이스이지만, 그 API는 데이터베이스와 외부 서비스에서 가져온 값을 조합해서 만들어진다. 따라서 좋은 API 인터페이스를 만들려면 내부 provider들을 어떤 기준으로 연결할지 먼저 정해야 한다.</p>
<hr>
<h2 id="📄-api-인터페이스는-최종-출력이다">📄 API 인터페이스는 최종 출력이다</h2>
<p>백엔드에서 API 응답 스키마는 최종 출력에 가깝다.</p>
<p>데이터베이스에서 조회한 값, 외부 서비스에서 가져온 값, 내부 정책을 적용한 값이 최종적으로 API 응답 스키마를 통해 밖으로 나간다. 따라서 응답 스키마는 단순히 데이터를 담는 객체가 아니라, 사용자가 접근할 수 있는 데이터의 경계이다.</p>
<p>예를 들어 FastAPI와 Pydantic을 사용한다면 응답 스키마를 다음처럼 명시할 수 있다.</p>
<pre><code class="language-python">from pydantic import BaseModel, EmailStr


class UserProfileResponse(BaseModel):
    id: int
    email: EmailStr
    name: str
    plan: str
    email_verified: bool</code></pre>
<p>이 스키마에 포함된 필드는 API를 호출하는 사용자가 접근 가능한 데이터가 된다. 그래서 응답 스키마는 명확해야 한다. 어떤 필드를 제공할지, 어떤 이름으로 제공할지, 어떤 타입으로 제공할지를 백엔드가 의도적으로 결정해야 한다.</p>
<p>응답 스키마를 만들 때는 최소한 성능, 보안, 데이터 적절성을 함께 봐야 한다.</p>
<ul>
<li>성능: 응답에 포함된 필드를 만들기 위해 불필요한 join, 추가 query, 외부 API 호출이 발생하지 않는가?</li>
<li>보안: password hash, token, 내부 식별자, 관리자 메모 같은 민감한 값이 노출되지 않는가?</li>
<li>데이터 적절성: 이 API의 목적에 맞는 데이터만 포함되어 있는가?</li>
</ul>
<p>API 인터페이스는 내부 구현을 그대로 노출하는 창구가 아니라, 외부에서 접근해도 되는 데이터만 정리해서 제공하는 최종 출력이어야 한다.</p>
<p>그래서 백엔드에서는 API 응답을 만들 때 다음 질문을 먼저 해야 한다.</p>
<ul>
<li>이 필드는 이 API의 목적에 필요한 값인가?</li>
<li>이 필드는 내부 DB 컬럼명을 그대로 노출하고 있지는 않은가?</li>
<li>외부 서비스의 응답 구조를 그대로 API로 흘려보내고 있지는 않은가?</li>
<li>이 필드를 만들기 위해 과한 비용의 조회나 외부 호출이 필요하지 않은가?</li>
<li>이 필드가 사용자에게 노출되어도 보안상 문제가 없는가?</li>
</ul>
<p>API 인터페이스가 최종 출력이라는 말은 단순히 response DTO를 만들라는 뜻이 아니다.</p>
<p>데이터베이스와 외부 서비스의 세부 구현을 숨기고, 외부에 공개 가능한 데이터만 명확한 규약으로 제공하는 책임이 API 레이어에 있다는 뜻이다.</p>
<p>이 관점은 HTTP API에만 적용되지 않는다.</p>
<p>SSE, Socket, WebSocket 같은 이벤트 기반 인터페이스도 마찬가지로 최종 출력 인터페이스이다. 요청/응답 형태가 아니더라도 외부 사용자는 서버가 보내는 이벤트 이름, payload schema, 상태 값, 에러 이벤트를 기준으로 동작을 해석한다.</p>
<p>따라서 SSE나 Socket을 구성할 때도 API 응답 스키마와 같은 기준이 필요하다.</p>
<ul>
<li>이벤트 이름은 도메인 기준으로 예측 가능해야 한다.</li>
<li>payload schema는 명확하게 고정되어야 한다.</li>
<li>내부 DB 모델이나 외부 서비스 응답을 그대로 흘려보내지 않아야 한다.</li>
<li>이벤트 payload에 포함되는 데이터도 성능, 보안, 데이터 적절성을 기준으로 판단해야 한다.</li>
<li>실패 이벤트나 종료 이벤트도 정상 이벤트와 같은 인터페이스로 관리해야 한다.</li>
</ul>
<p>즉, HTTP API는 <code>response schema</code>를 통해 인터페이스를 제공하고, SSE나 Socket은 <code>event schema</code>를 통해 인터페이스를 제공한다. 형태만 다를 뿐, 백엔드가 외부에 공개하는 최종 출력이라는 점은 같다.</p>
<hr>
<h2 id="🧱-데이터베이스와-외부-서비스는-provider로-감싼다">🧱 데이터베이스와 외부 서비스는 provider로 감싼다</h2>
<p>백엔드에서 책임 분리를 하려면 먼저 <code>provider</code>의 기준을 명확히 해야 한다.</p>
<p>여기서 provider는 특정 외부 자원에 접근하는 구체적인 구현을 감싼 객체라고 볼 수 있다. 데이터베이스 provider는 DB 조회와 저장을 담당하고, 외부 서비스 provider는 외부 API 호출을 담당한다.</p>
<p>예를 들어 사용자 정보를 조회하는 코드가 있다고 해보자.</p>
<pre><code class="language-ts">class UserRepository {
    async findById(userId: number) {
        return db.user.findUnique({
            where: { id: userId },
        });
    }
}</code></pre>
<p>이 코드는 데이터베이스 접근을 담당한다. 사용자 프로필 API가 이 repository를 사용할 수는 있지만, repository가 API 응답 형태나 서비스 정책까지 알아서는 안 된다.</p>
<p>외부 결제 서비스를 호출하는 코드도 마찬가지이다.</p>
<pre><code class="language-ts">class PaymentProvider {
    async getSubscription(userId: number) {
        const response = await paymentClient.get(`/subscriptions/${userId}`);

        return {
            plan: response.data.plan,
            status: response.data.status,
            expiredAt: response.data.expired_at,
        };
    }
}</code></pre>
<p>이 provider의 책임은 결제 서비스와 통신하고, 애플리케이션이 다루기 쉬운 형태로 외부 응답을 변환하는 것이다. 여기서도 최종 API 응답을 만들어서는 안 된다.</p>
<p>provider는 외부 세계와 연결되는 가장 낮은 경계이다.</p>
<p>따라서 provider는 다음 책임에 집중하는 편이 좋다.</p>
<ul>
<li>데이터베이스 또는 외부 서비스에 접근한다.</li>
<li>외부 시스템의 응답을 애플리케이션 내부에서 다룰 수 있는 형태로 변환한다.</li>
<li>외부 시스템의 장애, 인증 실패, 타임아웃 같은 실패를 명확한 에러로 바꾼다.</li>
<li>도메인 정책이나 API 응답 정책을 직접 결정하지 않는다.</li>
</ul>
<p>이 기준이 없으면 provider가 점점 커진다. 처음에는 단순한 API 호출 함수였지만, 어느 순간 할인 정책을 계산하고, 사용자 권한을 확인하고, 프론트엔드 응답 필드까지 조립하게 된다.</p>
<p>그렇게 되면 provider는 더 이상 provider가 아니라 여러 책임이 섞인 서비스가 된다.</p>
<hr>
<h2 id="🔗-중간-서비스-레이어가-필요한-이유">🔗 중간 서비스 레이어가 필요한 이유</h2>
<p>provider가 외부 자원과의 연결을 담당한다면, 여러 provider를 조합하는 책임은 어디에 있어야 할까?</p>
<p>이때 필요한 것이 <strong>application service</strong> 또는 <strong>use case service</strong> 같은 중간 서비스 레이어이다. 이름은 팀마다 다를 수 있지만 역할은 비슷하다.</p>
<p>중간 서비스 레이어는 API 요청을 처리하기 위해 필요한 provider들을 조합하고, 도메인 정책을 적용한 뒤, API 레이어가 응답으로 만들 수 있는 결과를 반환한다.</p>
<p>예를 들어 사용자 프로필 API를 만든다고 해보자.</p>
<pre><code class="language-ts">class UserProfileService {
    constructor(
        private readonly userRepository: UserRepository,
        private readonly paymentProvider: PaymentProvider,
        private readonly authProvider: AuthProvider,
    ) {}

    async getProfile(userId: number) {
        const user = await this.userRepository.findById(userId);
        const subscription = await this.paymentProvider.getSubscription(userId);
        const emailStatus = await this.authProvider.getEmailStatus(userId);

        return {
            id: user.id,
            name: user.name,
            emailVerified: emailStatus.verified,
            plan: subscription.plan,
        };
    }
}</code></pre>
<p>이 코드는 여러 provider를 조합한다. 사용자 기본 정보는 DB에서 가져오고, 구독 정보는 결제 provider에서 가져오고, 이메일 인증 상태는 인증 provider에서 가져온다.</p>
<p>여기서 중요한 점은 <strong>API 컨트롤러가 이 provider들을 직접 모두 알지 않아도 된다는 것</strong>이다.</p>
<pre><code class="language-ts">class UserController {
    constructor(private readonly userProfileService: UserProfileService) {}

    async getProfile(request: Request) {
        const profile = await this.userProfileService.getProfile(
            request.user.id,
        );

        return {
            data: profile,
        };
    }
}</code></pre>
<p>컨트롤러는 HTTP 요청과 응답에 집중한다. 어떤 provider를 몇 개 호출해야 하는지, 어떤 순서로 조합해야 하는지, 어떤 내부 정책을 적용해야 하는지는 service가 담당한다.</p>
<p>이렇게 나누면 각 레이어의 책임이 비교적 명확해진다.</p>
<ul>
<li>controller: HTTP 요청과 응답을 다룬다.</li>
<li>service: use case 흐름과 도메인 정책을 조합한다.</li>
<li>provider/repository: DB 또는 외부 서비스와 연결한다.</li>
<li>util: 여러 곳에서 반복되는 순수 계산 또는 변환을 담당한다.</li>
</ul>
<p>책임 분리는 파일을 많이 나누는 것이 아니다. 코드를 보고 &quot;이 변경은 어디에서 해야 하는가&quot;를 예측할 수 있게 만드는 것이다.</p>
<hr>
<h2 id="🔁-공통-기능은-util로-빼되-정책은-service에-둔다">🔁 공통 기능은 util로 빼되, 정책은 service에 둔다</h2>
<p>여러 서비스에서 반복해서 쓰는 기능이 생기면 <code>util</code>로 옮기는 것이 좋다.</p>
<p>하지만 무엇이든 util로 빼면 오히려 구조가 흐려진다. util은 가능하면 외부 상태에 의존하지 않는 순수한 기능이어야 한다.</p>
<p>예를 들어 날짜 포맷 변환, 금액 계산, 문자열 정규화 같은 기능은 util로 옮기기 좋다.</p>
<pre><code class="language-ts">function formatMoney(amount: number, currency: string) {
    return new Intl.NumberFormat(&quot;ko-KR&quot;, {
        style: &quot;currency&quot;,
        currency,
    }).format(amount);
}</code></pre>
<p>반대로 다음과 같은 코드는 util로 빼기 애매하다.</p>
<pre><code class="language-ts">async function canCancelOrder(orderId: number) {
    const order = await orderRepository.findById(orderId);
    const payment = await paymentProvider.getPayment(order.paymentId);

    return order.status === &quot;PAID&quot; &amp;&amp; payment.status !== &quot;REFUNDED&quot;;
}</code></pre>
<p>이 코드는 단순 유틸이 아니다. DB를 조회하고, 외부 결제 서비스를 호출하고, 주문 취소 정책을 판단한다. 이런 코드를 util로 빼면 이름은 util이지만 실제로는 여러 provider를 몰래 알고 있는 서비스가 된다.</p>
<p>이런 로직은 <code>OrderService</code> 같은 use case service에 두는 편이 낫다.</p>
<pre><code class="language-ts">class OrderService {
    constructor(
        private readonly orderRepository: OrderRepository,
        private readonly paymentProvider: PaymentProvider,
    ) {}

    async canCancelOrder(orderId: number) {
        const order = await this.orderRepository.findById(orderId);
        const payment = await this.paymentProvider.getPayment(order.paymentId);

        return order.status === &quot;PAID&quot; &amp;&amp; payment.status !== &quot;REFUNDED&quot;;
    }
}</code></pre>
<p>util과 service의 차이를 구분하는 기준은 의존성이다.</p>
<p>외부 상태 없이 입력값만으로 결과를 만들 수 있다면 util에 가깝다. DB, 외부 API, 인증 정보, 현재 사용자, 트랜잭션 같은 컨텍스트가 필요하다면 service에 가깝다.</p>
<hr>
<h2 id="🚨-책임-분리가-무너지면-생기는-문제">🚨 책임 분리가 무너지면 생기는 문제</h2>
<p>백엔드 코드에서 가장 조심해야 할 구조 중 하나는 여러 서비스가 서로를 직접 사용하는 구조이다.</p>
<p>예를 들어 <code>UserService</code>가 <code>OrderService</code>를 호출하고, <code>OrderService</code>가 다시 <code>UserService</code>를 호출하는 구조를 생각해볼 수 있다.</p>
<pre><code class="language-text">UserService → OrderService
OrderService → UserService</code></pre>
<p>처음에는 편하다. 이미 만들어둔 메서드를 가져다 쓰면 되기 때문이다. 하지만 이런 호출이 늘어나면 서비스 간 의존 방향이 금방 꼬인다.</p>
<p>더 위험한 경우는 다음과 같다.</p>
<pre><code class="language-text">UserService → OrderService → PaymentService → UserService</code></pre>
<p>이 구조는 <strong>순환 의존성</strong>을 만들기 쉽다. 어떤 서비스를 수정하려고 해도 다른 서비스의 초기화 순서, 테스트 더블, 트랜잭션 범위, 예외 처리 방식까지 함께 고려해야 한다.</p>
<p><strong>의존성 역전</strong>도 비슷한 문제를 만든다.</p>
<p>원래는 고수준 정책이 저수준 구현을 사용해야 한다. 예를 들어 <code>OrderService</code>는 주문 정책을 담당하고, <code>PaymentProvider</code>는 결제 외부 API 호출을 담당한다. 그런데 <code>PaymentProvider</code>가 <code>OrderService</code>의 정책을 알아야 하거나, repository가 service를 호출하기 시작하면 의존 방향이 뒤집힌다.</p>
<pre><code class="language-text">좋은 방향
OrderService → PaymentProvider

위험한 방향
PaymentProvider → OrderService</code></pre>
<p>provider가 service를 알기 시작하면 provider는 더 이상 외부 연결 경계가 아니다. 특정 use case에 종속된 구현이 된다. 그러면 다른 use case에서 재사용하기 어렵고, 외부 서비스 변경이 도메인 서비스 전체로 퍼진다.</p>
<p>따라서 여러 서비스가 서로를 직접 사용해야 할 것 같다면 먼저 질문해야 한다.</p>
<ul>
<li>이 로직은 정말 기존 service를 호출해야 하는가?</li>
<li>공통으로 필요한 순수 로직이라면 util로 분리할 수 있는가?</li>
<li>여러 provider를 조합하는 별도의 use case service가 필요한가?</li>
<li>한 서비스가 너무 많은 책임을 가지고 있지는 않은가?</li>
<li>provider가 service의 정책을 알고 있지는 않은가?</li>
</ul>
<p>서비스를 무조건 작게 나누는 것이 정답은 아니다. 하지만 의존 방향이 흐려지는 순간부터 코드는 빠르게 관리하기 어려워진다.</p>
<hr>
<h2 id="🔒-api-응답은-내부-모델을-그대로-내보내지-않는다">🔒 API 응답은 내부 모델을 그대로 내보내지 않는다</h2>
<p>데이터베이스 인터페이스와 API 인터페이스를 분리해야 하는 이유는 응답 모델에서도 드러난다.</p>
<p>예를 들어 DB의 user 테이블이 다음 필드를 가진다고 해보자.</p>
<pre><code class="language-python">from datetime import datetime
from pydantic import BaseModel, EmailStr


class UserEntity(BaseModel):
    id: int
    email: EmailStr
    password_hash: str
    name: str
    plan_code: str
    created_at: datetime
    updated_at: datetime</code></pre>
<p>이 entity를 API 응답으로 그대로 반환하면 문제가 생긴다.</p>
<pre><code class="language-python">return user</code></pre>
<p>당장은 빠르지만, API 인터페이스가 DB 스키마에 강하게 묶인다. <code>password_hash</code> 같은 민감한 필드가 노출될 위험도 있고, <code>plan_code</code>처럼 내부 코드값이 그대로 전달될 수도 있다.</p>
<p>API 응답은 명시적으로 만들어야 한다.</p>
<pre><code class="language-python">from typing import Literal


class UserResponse(BaseModel):
    id: int
    email: EmailStr
    name: str
    plan: Literal[&quot;free&quot;, &quot;pro&quot;, &quot;enterprise&quot;]


def to_user_response(user: UserEntity) -&gt; UserResponse:
    return UserResponse(
        id=user.id,
        email=user.email,
        name=user.name,
        plan=map_plan_code(user.plan_code),
    )</code></pre>
<p>이 변환은 단순한 보일러플레이트가 아니다. DB 인터페이스와 API 인터페이스 사이의 경계를 명확히 하는 코드이다.</p>
<p>백엔드에서도 DB 모델과 API 응답 모델은 구분해야 한다. DB 모델은 저장 구조를 표현하고, API 응답 모델은 외부에 공개할 데이터 계약을 표현한다.</p>
<p>이 둘을 같은 타입으로 취급하면 저장소 변경이 곧 API 변경이 된다.</p>
<hr>
<h2 id="⚠️-에러-스키마에서-결합도를-바라보는-관점">⚠️ 에러 스키마에서 결합도를 바라보는 관점</h2>
<p>정상 응답 스키마만큼 중요한 것이 에러 응답 스키마이다.</p>
<p>에러 응답도 최종 API 인터페이스의 일부이다. 성공했을 때 어떤 데이터를 내려줄지 정하는 것처럼, 실패했을 때 어떤 코드와 메시지, 상세 정보를 내려줄지도 명확하게 정해야 한다.</p>
<p>예를 들어 다음처럼 공통 에러 스키마를 둘 수 있다.</p>
<pre><code class="language-python">from typing import Any
from pydantic import BaseModel


class ErrorResponse(BaseModel):
    code: str
    message: str
    details: dict[str, Any] | None = None</code></pre>
<p>문제는 이 에러 스키마를 어디에서 구성할 것인가이다.</p>
<p>가장 단순한 방법은 service 안에서 에러 응답을 직접 만드는 것이다.</p>
<pre><code class="language-python">class UserService:
    async def get_profile(self, user_id: int):
        user = await self.user_repository.find_by_id(user_id)

        if user is None:
            raise ApiError(
                status_code=404,
                code=&quot;USER_NOT_FOUND&quot;,
                message=&quot;User not found.&quot;,
            )

        return build_user_profile(user)</code></pre>
<p>이 방식은 구현하기 쉽다. 하지만 service 안에 <code>status_code</code>, <code>code</code>, <code>message</code> 같은 API 에러 스키마 정보가 직접 들어간다. service 레이어가 최종 API 인터페이스를 고려하게 되는 것이다.</p>
<p>이 경우 API 라우터와 service의 결합도는 올라간다.</p>
<p>그렇다고 에러 스키마 구성을 전부 라우터로 옮기면 문제가 사라질까? 그렇지 않다. 라우터는 service에서 발생할 수 있는 모든 실패 경우의 수를 알아야 한다.</p>
<pre><code class="language-python">@router.get(&quot;/users/{user_id}&quot;)
async def get_user_profile(user_id: int):
    try:
        return await user_service.get_profile(user_id)
    except UserNotFoundError:
        return ErrorResponse(
            code=&quot;USER_NOT_FOUND&quot;,
            message=&quot;User not found.&quot;,
        )
    except SubscriptionExpiredError:
        return ErrorResponse(
            code=&quot;SUBSCRIPTION_EXPIRED&quot;,
            message=&quot;Subscription expired.&quot;,
        )</code></pre>
<p>이렇게 되면 라우터가 service 내부의 실패 케이스를 모두 고려해야 한다. 라우터와 service의 결합도는 여전히 높다. 위치만 바뀌었을 뿐, 최종 출력 인터페이스와 중간 service 레이어 사이의 결합이 사라진 것은 아니다.</p>
<p>나는 이 지점에서 결합도를 무조건 낮추려는 접근보다, 결합도가 올라갈 수밖에 없다는 사실을 먼저 인정하는 편이 낫다고 생각한다.</p>
<p>API 라우터는 최종 출력 인터페이스이다. 중간 service 레이어는 데이터베이스와 외부 서비스를 통합해서 use case를 완성한다. 이 둘은 필연적으로 맞물릴 수밖에 없다. service에서 어떤 실패가 발생할 수 있는지 알아야 API는 올바른 에러 스키마를 제공할 수 있다.</p>
<p>외부 서비스나 데이터베이스 연동도 비슷하다.</p>
<p>결제 서비스를 연동하면 해당 결제사의 SDK나 API 에러 코드에 의존하게 된다. 데이터베이스를 사용하면 <strong>ORM, driver, transaction</strong> 처리 방식에 의존하게 된다. 최종 출력 인터페이스가 있는 지점은 항상 특정 구현과 높은 결합도를 가질 수밖에 없다.</p>
<p>중요한 것은 결합도를 없애는 것이 아니라 결합되는 지점을 명확히 관리하는 것이다.</p>
<p>그래서 에러 스키마도 별도의 handler 계층으로 분리해서 도메인 또는 service 단위로 관리하는 편이 좋다. handler는 service에서 발생한 실패 조건을 API 에러 스키마로 바꾸는 전담 계층이다.</p>
<pre><code class="language-python">class UserNotFoundError(Exception):
    pass


class ApiException(Exception):
    def __init__(self, status_code: int, response: ErrorResponse):
        self.status_code = status_code
        self.response = response


class UserErrorHandler:
    @staticmethod
    def user_not_found() -&gt; ApiException:
        return ApiException(
            status_code=404,
            response=ErrorResponse(
                code=&quot;USER_NOT_FOUND&quot;,
                message=&quot;User not found.&quot;,
            ),
        )

    @staticmethod
    def payment_unavailable() -&gt; ApiException:
        return ApiException(
            status_code=503,
            response=ErrorResponse(
                code=&quot;PAYMENT_UNAVAILABLE&quot;,
                message=&quot;Payment service is unavailable.&quot;,
            ),
        )</code></pre>
<p>service는 provider와 database를 조합하다가 실패 조건을 만나면 해당 도메인의 error handler를 호출한다.</p>
<pre><code class="language-python">class UserService:
    def __init__(
        self,
        user_repository: UserRepository,
        payment_provider: PaymentProvider,
        error_handler: UserErrorHandler,
    ):
        self.user_repository = user_repository
        self.payment_provider = payment_provider
        self.error_handler = error_handler

    async def get_profile(self, user_id: int):
        user = await self.user_repository.find_by_id(user_id)

        if user is None:
            raise self.error_handler.user_not_found()

        try:
            subscription = await self.payment_provider.get_subscription(user_id)
        except PaymentProviderError:
            raise self.error_handler.payment_unavailable()

        return build_user_profile(user, subscription)</code></pre>
<p>라우터는 service가 반환한 정상 결과를 내려주고, <code>ApiException</code>은 공통 exception handler에서 최종 응답으로 변환한다.</p>
<pre><code class="language-python">@router.get(&quot;/users/{user_id}&quot;)
async def get_user_profile(user_id: int):
    return await user_service.get_profile(user_id)


@app.exception_handler(ApiException)
async def handle_api_exception(request: Request, error: ApiException):
    return JSONResponse(
        status_code=error.status_code,
        content=error.response.model_dump(),
    )</code></pre>
<p>이 구조에서도 결합도는 존재한다. <code>UserService</code>는 실패 조건을 알고 있고, <code>UserErrorHandler</code>는 그 실패 조건을 API 에러 스키마로 바꾸는 방법을 안다. 결국 service와 최종 API 인터페이스 사이의 결합은 사라지지 않는다.</p>
<p>하지만 결합되는 지점이 명확하다.</p>
<p>service는 provider와 database를 조합하는 흐름을 유지하고, 에러 스키마 생성 규칙은 handler에 모인다. router는 service 내부 실패 케이스를 전부 나열하지 않고, 공통 exception handler를 통해 최종 응답만 처리한다.</p>
<p>따라서 <strong>에러 스키마를 설계할 때의 목표는 결합도를 0으로 만드는 것이 아니다. 어떤 도메인에서 어떤 에러가 발생하고, 그 에러가 어떤 API 스키마로 변환되는지 한 곳에서 추적할 수 있게 만드는 것</strong>이다.</p>
<hr>
<h2 id="⚡-성능을-고려한-반환-형식">⚡ 성능을 고려한 반환 형식</h2>
<p>백엔드가 명확한 인터페이스를 제공한다는 말이 <strong>데이터를 정해진 형식으로 전달하기만 하면 된다는 뜻은 아니다.</strong></p>
<p>이상적으로는 백엔드와 프론트엔드가 각자의 책임만 지키면 된다. 백엔드는 안정적인 인터페이스를 제공하고, 프론트엔드는 그 인터페이스를 받아 <code>UI</code>를 구성한다. 하지만 현실적으로 백엔드는 프론트엔드가 데이터를 받은 뒤 어떤 비용을 치르는지도 고려할 수밖에 없다.</p>
<p>예를 들어 백엔드가 엄청난 양의 데이터를 한 번에 내려준다고 해보자. 서버 입장에서는 단순히 조회 결과를 반환한 것처럼 보일 수 있다. 하지만 프론트엔드에서 그 트래픽을 받고 파싱하고 상태에 반영하고 화면에 렌더링하는 데 수초가 걸린다면, <strong>이 문제는 프론트엔드만의 문제가 아니다.</strong></p>
<p>이 경우 백엔드 단위에서도 조치가 필요하다. <code>페이지네이션</code>, <code>cursor 기반 조회</code>, <code>필드 축소</code>, <code>요약 응답</code>, <code>lazy loading</code>을 고려한 <code>API 분리</code>처럼 라우터 반환 형식을 더 구체적으로 설계해야 한다.</p>
<p>개발자가 처음부터 모든 데이터 크기와 화면 처리 비용을 예측해서 구성하면 좋겠지만, 현실적으로는 어렵다. 그래서 성능 문제가 드러나는 순간 바로 고도화 기법을 적용할 수 있도록 인터페이스를 구성해야되는 것이다.</p>
<hr>
<h2 id="😘-마무리">😘 마무리</h2>
<p>외부 요청 주체는 백엔드 API 인터페이스에 의존한다. 그래서 백엔드는 사용자가 신뢰할 수 있는 안정적인 API 인터페이스를 제공해야 한다.</p>
<p>하지만 좋은 API 인터페이스는 응답 타입만 잘 만든다고 생기지 않는다. API 뒤에 있는 <strong>데이터베이스 인터페이스</strong>와 <strong>외부 서비스 인터페이스</strong>가 명확하게 분리되어 있어야 한다.</p>
<p>나는 백엔드 인터페이스를 설계할 때 다음 기준을 먼저 떠올리려고 한다.</p>
<pre><code class="language-text">API는 최종 출력이다.
DB와 외부 서비스는 provider로 감싼다.
여러 provider를 조합하는 책임은 중간 service에 둔다.
공통 순수 로직은 util로 분리한다.
service끼리 직접 얽히기 시작하면 의존 방향을 다시 본다.
에러 스키마는 결합도를 인정하고 handler 계층에서 관리한다.</code></pre>
<p>결국 <strong>백엔드에서 인터페이스를 설계한다는 것은 내부 구현을 감추는 동시에, 각 구현이 어디에 속해야 하는지 정하는 일</strong>이다.</p>
<p>따라서 구현을 시작할 때마다 먼저 확인해야 한다.</p>
<p>지금 작성하는 코드는 <code>provider</code>인가, 여러 <code>provider</code>를 조합하는 <code>service</code>인가, 아니면 사용자에게 공개할 <code>API 인터페이스</code>인가?</p>
<p><strong>이 질문에 답할 수 있으면 백엔드 코드는 훨씬 예측 가능한 방향으로 정리된다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인터페이스 설계 전략 (프론트엔드)]]></title>
            <link>https://velog.io/@choi-hyk/%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C</link>
            <guid>https://velog.io/@choi-hyk/%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C</guid>
            <pubDate>Sat, 16 May 2026 08:56:55 GMT</pubDate>
            <description><![CDATA[<h1 id="🖥️-시작하기에-앞서">🖥️ 시작하기에 앞서...</h1>
<p>프론트엔드와 백엔드는 여러 인터페이스 레이어를 통해 연결된다.</p>
<p>프론트엔드에서 API를 다루다 보면 타입을 어떻게 관리해야 할지 애매한 순간이 많다.</p>
<p>백엔드에서 내려주는 응답 타입을 그대로 써야 하는지, 프론트엔드에서 사용하기 편한 타입을 따로 만들어야 하는지, 에러 메시지는 서버에서 내려주는 문구를 그대로 보여줘도 되는지 같은 문제들이 계속 생긴다.</p>
<p>처음에는 단순하게 생각할 수 있다.</p>
<p>백엔드에서 데이터를 내려주면 프론트엔드에서 적당히 타입을 만들고, 화면에 필요한 형태로 바꿔서 사용하면 된다. 작은 프로젝트에서는 이 방식도 크게 문제 없어 보인다. 그런데 API가 늘어나고, 화면이 늘어나고, 에러 케이스가 늘어나면 금방 관리가 어려워진다.</p>
<p>결국 프론트엔드의 API 인터페이스 관리는 백엔드 명세를 얼마나 잘 받아오느냐에서 시작한다고 생각한다.</p>
<p>이번 글에서는 프론트엔드 관점에서 백엔드 인터페이스를 어떻게 받아와야 하는지 정리해보려고 한다. 다음 글에서는 반대로 백엔드에서 좋은 인터페이스를 어떻게 설계해야 하는지 다뤄볼 예정이다.</p>
<hr>
<h2 id="🧩-프론트엔드는-백엔드-인터페이스에-의존한다">🧩 프론트엔드는 백엔드 인터페이스에 의존한다</h2>
<p>프론트엔드는 백엔드 인터페이스에 강하게 의존한다.</p>
<p>프론트엔드는 백엔드가 내려주는 요청/응답 스키마, 에러 코드, 상태 코드, 페이지네이션 구조, 이벤트 이름을 기준으로 화면과 상태를 구성한다. 따라서 프론트엔드에서 백엔드 규약과 별개로 개별 패턴을 만들기 시작하면 유지보수가 어려워진다.</p>
<p>인터페이스를 너무 복잡하게 생각할 필요는 없다. 기본적으로 인터페이스는 데이터를 송신하고 수신할 때 정의하는 규약이라고 볼 수 있다. 백엔드에서 API, SSE, Socket 같은 트래픽을 통해 데이터를 전달하는 것도 인터페이스이고, 프론트엔드에서 데이터를 UI로 출력해 사용자에게 전달하는 것도 인터페이스이다.</p>
<p>이 관점에서 보면 백엔드와 프론트엔드는 서로 다른 형태의 인터페이스를 최종 산출한다고 볼 수 있다. 백엔드는 API, SSE, Socket 같은 트래픽 기반 인터페이스를 산출하고, 프론트엔드는 최종적으로 사용자가 마주하는 UI 인터페이스를 산출한다.</p>
<p>즉, 백엔드의 핵심 산출물이 안정적인 API 타입과 트래픽 계약이라면, 프론트엔드의 핵심 산출물은 그 데이터를 바탕으로 만들어지는 UI 출력이다. 이 둘은 형태는 다르지만 같은 철학을 가진다. 결국 인터페이스는 한쪽이 만든 데이터를 다른 쪽이 예측 가능한 방식으로 해석하게 만드는 규약이다.</p>
<p>예를 들어 사용자 조회 API는 다음과 같은 응답을 내려준다고 해보자.</p>
<pre><code class="language-json">{
    &quot;id&quot;: 1,
    &quot;name&quot;: &quot;choi&quot;,
    &quot;status&quot;: &quot;active&quot;
}</code></pre>
<p>어떤 화면은 이 응답을 그대로 사용하고, 어떤 화면은 자체 타입으로 바꾸고, 또 다른 화면은 일부 필드만 골라서 새로운 객체를 만든다면 처음에는 편할 수 있다. 하지만 API 응답이 바뀌는 순간 어떤 화면이 어떤 타입에 의존하고 있는지 추적해야 한다.</p>
<p>그래서 프론트엔드가 가장 먼저 지켜야 하는 원칙은 백엔드 인터페이스를 임의로 재해석하지 않는 것이다.</p>
<p>프론트엔드는 백엔드가 정의한 강한 규약을 <strong>전달받아서 사용하는 방식</strong>으로 관리해야 한다.</p>
<hr>
<h2 id="📄-프론트엔드-인터페이스-정의-기준">📄 프론트엔드 인터페이스 정의 기준</h2>
<p>이런 패턴을 적용할 때 좋은 방법은 가능하면 <code>OpenAPI</code> 같은 API 스키마 문서를 기준으로 인터페이스를 생성하는 것이다.</p>
<p>프론트엔드에서 API 타입, 요청 함수, 응답 타입, 에러 타입을 손으로 맞추기 시작하면 언젠가 백엔드 규약과 어긋난다. 반대로 OpenAPI 명세를 기준으로 타입과 API client를 생성하고, 프론트엔드에서 임의로 재해석하지 않으면 API 인터페이스 관리는 훨씬 편해진다.</p>
<pre><code class="language-text">백엔드 OpenAPI 명세
→ 타입 생성
→ API client 생성
→ 프론트엔드에서 사용</code></pre>
<p>이 흐름이 만들어지면 프론트엔드는 백엔드 API가 어떤 요청을 받고, 어떤 응답을 내려주고, 어떤 에러 코드를 반환할 수 있는지 명세를 기준으로 확인할 수 있다.</p>
<p>물론 이 방식은 백엔드 명세가 정확해야 의미가 있다. OpenAPI 문서가 실제 구현과 다르면 프론트엔드는 잘못된 규약을 강하게 따르게 된다. 따라서 백엔드에서는 OpenAPI 문서를 단순한 Swagger 문서가 아니라 실제 인터페이스 계약으로 관리해야 한다.</p>
<p>프론트엔드 입장에서는 이 계약을 기준으로 삼고, 필요한 변환이 있다면 정해진 계층에서 일관되게 처리하는 것이 좋다.</p>
<p>다만 BFF(Backend For Frontend)처럼 화면 요구사항을 위해 프론트엔드에 더 맞는 API 계층을 따로 두는 경우도 있다. 이 경우에도 핵심은 같다. 프론트엔드가 임의로 해석하는 것이 아니라, BFF가 제공하는 인터페이스를 명세로 관리해야 한다.</p>
<hr>
<h2 id="🧱-api-타입-vs-ui-타입">🧱 API 타입 VS UI 타입</h2>
<p>프론트에서 API 타입을 정의할 때 주의해야 할 점이 있다.</p>
<p>백엔드에서 가져온 타입을 UI를 위해 무조건 다시 정의하는 방식은 이상한 구조가 될 수 있다. OpenAPI를 통해 이미 <code>UserResponse</code> 타입을 생성했는데, 화면마다 비슷한 <code>User</code> 타입을 다시 만들기 시작하면 타입이 두 벌로 관리된다.</p>
<p>그러면 백엔드 스키마가 바뀌었을 때 어떤 타입을 고쳐야 하는지 흐려진다.</p>
<p>따라서 API 타입과 UI 타입은 역할이 다를 때만 분리해야 한다. 그리고 프론트엔드 타입 정의 패턴에서는 보통 API 타입, UI 타입, Props 타입을 구분해서 생각하는 것이 좋다.</p>
<ul>
<li>API 타입: 백엔드 인터페이스를 그대로 표현하는 타입</li>
<li>UI 타입: 화면 표현을 위해 가공된 타입</li>
<li>Props 타입: 특정 컴포넌트가 외부로부터 받는 입력 타입</li>
</ul>
<p>예를 들어 다음 타입은 백엔드 인터페이스를 그대로 표현한다.</p>
<pre><code class="language-ts">type UserResponse = {
    id: number;
    name: string;
    createdAt: string;
};</code></pre>
<p>이 타입을 화면에서 그대로 사용해도 충분하다면 굳이 새로운 타입을 만들 필요는 없다.</p>
<p>하지만 화면에서 날짜를 표시 문자열로 바꾸거나, 여러 필드를 조합해서 <code>displayName</code>을 만들거나, 선택 상태를 위해 <code>isSelected</code> 같은 값을 추가해야 한다면 UI 타입을 따로 둘 수 있다.</p>
<pre><code class="language-ts">type UserViewModel = {
    id: number;
    displayName: string;
    createdDateText: string;
    isSelected: boolean;
};</code></pre>
<p>중요한 것은 개발자가 이 두 타입의 역할을 반드시 구분해서 알고 있어야 한다는 점이다.</p>
<p>API 타입은 백엔드 규약을 따르는 타입이고, UI 타입은 화면 요구사항을 표현하는 타입이다. Props 타입은 컴포넌트 경계의 입력 계약이다. 이 구분 없이 타입을 다시 만들면 인터페이스를 관리하는 것이 아니라, 인터페이스를 복제해서 관리하는 문제가 생긴다.</p>
<p>백엔드가 API 타입을 통해 트래픽 인터페이스를 안정적으로 제공하듯, 프론트엔드는 그 타입을 바탕으로 UI 인터페이스를 안정적으로 출력해야 한다. API 타입과 UI 타입은 같은 타입은 아니지만, 둘 다 인터페이스를 안정적으로 유지하기 위한 도구라는 점에서는 같은 철학을 가진다.</p>
<p>파일 위치도 이 기준에 맞춰 생각할 수 있다.</p>
<ul>
<li>API 타입: API client, API gateway, generated schema처럼 API 경계에 응집시킨다.</li>
<li>Props 타입: 해당 props를 사용하는 컴포넌트나 훅 근처에 둔다.</li>
<li>UI 타입: 해당 화면 모델을 사용하는 컴포넌트나 페이지 근처에 둔다.</li>
</ul>
<p>이 방식은 타입의 사용 범위가 좁을수록 효과적이다. 특정 컴포넌트에서만 쓰는 <code>UserCardProps</code>를 전역 types 파일에 두면 오히려 추적이 어려워진다. 반대로 여러 페이지에서 공유되는 UI 타입이라면 해당 feature나 domain 단위의 model 파일로 올리는 편이 낫다.</p>
<p>인터페이스 변경 전략도 함께 생각해야 한다. 인터페이스는 한 번 정의해도 언젠가 바뀐다. 필드를 바로 삭제하기보다는 deprecated 상태를 거치고, 새 필드를 추가한 뒤 프론트엔드 반영이 끝난 후 제거하는 흐름이 필요하다.</p>
<hr>
<h2 id="🔁-네이밍-컨벤션-변환-처리">🔁 네이밍 컨벤션 변환 처리</h2>
<p>백엔드와 프론트엔드의 네이밍 컨벤션이 항상 같지는 않다.</p>
<p>예를 들어 백엔드에서는 Python, DB, FastAPI 스타일 때문에 <code>snake_case</code>를 사용하는 경우가 많고, 프론트엔드에서는 JavaScript/TypeScript 스타일상 <code>camelCase</code>를 사용하는 경우가 많다.</p>
<pre><code class="language-json">{
    &quot;user_id&quot;: 1,
    &quot;created_at&quot;: &quot;2026-05-16T10:00:00Z&quot;
}</code></pre>
<p>프론트엔드에서는 보통 다음 형태가 더 자연스럽다.</p>
<pre><code class="language-ts">type User = {
    userId: number;
    createdAt: string;
};</code></pre>
<p>이 변환 자체는 문제가 아니다. 문제는 이 변환을 화면마다 제각각 처리하는 것이다.</p>
<p>네이밍 변환은 API client, adapter, schema parser 같은 한 지점에서 일관되게 처리해야 한다. 그래야 백엔드 스키마가 바뀌었을 때 수정 범위가 명확해진다.</p>
<p>핵심은 네이밍을 어디에서 변환하든, 변환 규칙이 명세와 함께 관리되어야 한다는 점이다.</p>
<hr>
<h2 id="🚨-인터페이스-에러예외-처리-클린-패턴">🚨 인터페이스 에러/예외 처리 클린 패턴</h2>
<p>프론트엔드에서 에러 처리를 할 때 가장 위험한 방식은 백엔드가 내려주는 <code>message</code>를 그대로 기준으로 삼는 것이다.</p>
<p>에러 메시지는 사람이 읽는 값이고, 에러 코드는 프로그램이 판단하는 값이다. 프론트엔드는 화면 상태, 입력 폼, 토스트, 번역 문구를 결정해야 하므로 <code>message</code>보다 <code>code</code>를 기준으로 처리하는 편이 좋다.</p>
<p>예를 들어 백엔드는 다음처럼 안정적인 에러 코드를 내려준다.</p>
<pre><code class="language-json">{
    &quot;error&quot;: {
        &quot;code&quot;: &quot;USER_NOT_FOUND&quot;,
        &quot;message&quot;: &quot;User not found.&quot;,
        &quot;status&quot;: 404
    }
}</code></pre>
<p>프론트엔드는 이 코드를 도메인별 enum으로 관리하고, 필요한 경우 보통 사용하는 번역 도구인 i18n에 매핑한다.</p>
<pre><code class="language-ts">export enum UserErrorCode {
    USER_NOT_FOUND = &quot;USER_NOT_FOUND&quot;,
}

export enum AuthErrorCode {
    INVALID_PASSWORD = &quot;INVALID_PASSWORD&quot;,
    EMAIL_ALREADY_EXISTS = &quot;EMAIL_ALREADY_EXISTS&quot;,
}

type ApiErrorCode = UserErrorCode | AuthErrorCode;

const errorI18nKeyMap: Record&lt;ApiErrorCode, string&gt; = {
    [UserErrorCode.USER_NOT_FOUND]: &quot;error.user.notFound&quot;,
    [AuthErrorCode.INVALID_PASSWORD]: &quot;error.auth.invalidPassword&quot;,
    [AuthErrorCode.EMAIL_ALREADY_EXISTS]: &quot;error.auth.emailAlreadyExists&quot;,
};

const message = t(errorI18nKeyMap[UserErrorCode.USER_NOT_FOUND]);</code></pre>
<p>이 패턴의 장점은 역할이 분리된다는 점이다. 백엔드는 에러 코드라는 안정적인 스키마를 제공하고, 프론트엔드는 해당 코드를 기준으로 화면 정책과 번역 문구를 결정한다. 즉, 에러 코드 enum은 백엔드 에러 스키마와 프론트엔드 i18n 리소스를 연결하는 중간 계층이 된다.</p>
<p><code>500</code>대 서버 에러는 프론트엔드에서 구체적인 원인을 모두 관리할 필요가 적다. 서버 내부 장애, DB 장애, 외부 API 장애는 클라이언트가 해결할 수 있는 영역이 아니기 때문이다. 이 경우에는 공통 장애 메시지나 재시도 버튼 정도로 처리할 수 있다.</p>
<p>하지만 <code>400</code>, <code>401</code>, <code>403</code>, <code>404</code>, <code>409</code>, <code>422</code> 같은 <code>400</code>대 에러는 사용자의 입력, 권한, 요청 상태와 직접 연결된다. 따라서 프론트엔드에서 반드시 구체적으로 고려해야 한다.</p>
<p>다만 그 기준은 프론트엔드가 임의로 만든 패턴이 아니라, 백엔드에서 내려주는 에러 코드여야 한다.</p>
<hr>
<h2 id="🔗-api에서-고려해야하는-결합도와-의존성">🔗 API에서 고려해야하는 결합도와 의존성</h2>
<p>API는 요청과 응답이 명확하기 때문에 SSE나 Socket보다 단순해 보인다. 하지만 API도 상태 조건에 따라 프론트엔드와 백엔드가 강하게 결합되는 경우가 많다.</p>
<p>예를 들어 로그인 횟수 제한을 생각해볼 수 있다.</p>
<pre><code class="language-json">{
    &quot;error&quot;: {
        &quot;code&quot;: &quot;LOGIN_ATTEMPT_LIMIT_EXCEEDED&quot;,
        &quot;message&quot;: &quot;Login attempt limit exceeded.&quot;,
        &quot;status&quot;: 429,
        &quot;details&quot;: {
            &quot;retryAfterSeconds&quot;: 300
        }
    }
}</code></pre>
<p>이 경우 프론트엔드는 단순히 에러 메시지를 보여주는 것으로 끝나지 않는다. 로그인 버튼을 비활성화할지, 남은 시간을 표시할지, 비밀번호 재설정 링크를 보여줄지, 일정 시간이 지나면 다시 요청할 수 있게 할지 결정해야 한다.</p>
<p>이런 케이스는 백엔드와 프론트엔드가 매우 강하게 결합된다.</p>
<p>결합도가 높아지는 이유는 일반적인 API 응답에 부가적인 필드가 추가되고, 프론트엔드가 그 조건을 기준으로 화면을 설계해야 하기 때문이다. 기본적으로 인터페이스는 데이터를 주고받기 위한 규약이고, 프론트엔드는 해당 데이터를 UI에 출력하는 역할을 한다. 그런데 출력하기 전에 조건 처리와 분기 처리가 들어가기 시작하면 복잡도와 결합도가 함께 증가한다.</p>
<p>즉, 단순히 데이터를 보여주는 것이 아니라 <code>이 상태라면 버튼을 막는다</code>, <code>이 조건이라면 안내 문구를 바꾼다</code>, <code>이 값이 있으면 재시도 시간을 보여준다</code> 같은 정책이 프론트엔드에 들어온다. 이런 순간부터 프론트엔드는 백엔드의 도메인 정책을 더 깊게 알게 된다. 따라서 개발자는 이런 결합이 필요한 기능인지, 어떤 필드를 인터페이스로 노출해야 하는지, 해당 필드가 화면에서 어떻게 분기되는지 반드시 고려해야 한다.</p>
<p>비슷한 예시는 많다.</p>
<pre><code class="language-text">로그인 횟수 제한
결제 진행 상태
주문 취소 가능 여부
쿠폰 적용 가능 조건
상품 재고 또는 예약 가능 상태
사용자 권한 또는 플랜 제한
약관 동의 필요 상태
이메일 인증 또는 휴대폰 인증 필요 상태</code></pre>
<p>이런 기능들은 백엔드의 정책과 프론트엔드의 UI가 강하게 연결된다. 결제 상태가 <code>PENDING</code>, <code>PAID</code>, <code>FAILED</code>, <code>CANCELLED</code> 중 무엇인지에 따라 버튼, 안내 문구, 재시도 가능 여부가 달라진다. 주문 취소 가능 여부도 단순히 화면에서 판단할 수 없다. 백엔드가 현재 주문 상태와 정책을 기준으로 취소 가능 여부를 내려줘야 한다.</p>
<p>즉, 이런 기능은 결합도를 낮추는 것만이 정답이 아니다.</p>
<p>유지보수 관점에서는 강한 결합이 불리할 수 있지만, 서비스 정책을 정확히 구현하기 위해 반드시 필요한 결합도 있다. 결국 이것은 개발자가 인정하고 관리해야 하는 트레이드오프에 가깝다.</p>
<p>다만 이런 결합은 단순한 기술적 결합이라기보다, 서비스 정책을 프론트엔드와 공유하기 위한 도메인 결합에 가깝다. 프론트엔드가 백엔드의 구현 세부사항에 의존하는 것은 위험하지만, 서비스 상태와 정책 자체를 인터페이스로 전달받는 것은 반드시 필요한 경우가 많다.</p>
<p>중요한 것은 이 결합을 숨기지 않고 인터페이스 규약으로 명시하는 것이다.</p>
<p>따라서 상태 조건 기반 API는 에러 코드뿐 아니라 <code>details</code> 스키마까지 함께 정의해야 한다.</p>
<pre><code class="language-text">LOGIN_ATTEMPT_LIMIT_EXCEEDED
- retryAfterSeconds
- maxAttempts
- lockedUntil</code></pre>
<p>이렇게 정의해야 프론트엔드가 백엔드 정책을 추측하지 않고, 명시된 인터페이스를 기준으로 화면 상태를 관리할 수 있다.</p>
<p>또한 이런 결합도 높은 패턴은 도메인별로 관리되어야 한다. 개발자는 어떤 기능이 기존 패턴에 부가적인 속성이나 필드를 추가하는지 알고 있어야 한다. 예를 들어 결제 도메인에서는 <code>paymentStatus</code>, <code>retryable</code>, <code>failedReason</code> 같은 값이 중요할 수 있고, 인증 도메인에서는 <code>retryAfterSeconds</code>, <code>remainingAttempts</code>, <code>lockedUntil</code> 같은 값이 중요할 수 있다.</p>
<p>이런 필드들은 한 번 프론트엔드 인터페이스로 노출되면 쉽게 바꾸기 어렵다. 따라서 초기 설계 단계에서 도메인별 상태 패턴을 최대한 구체적으로 정의하고, 이후에는 가능하면 변경되지 않도록 관리해야 한다. 필요한 확장은 가능해야 하지만, 기존 필드의 의미가 바뀌거나 화면마다 다른 방식으로 해석되는 상황은 피해야 한다.</p>
<hr>
<h2 id="🔌-api가-아닌-이벤트-기반의-인터페이스">🔌 API가 아닌 이벤트 기반의 인터페이스</h2>
<p>지금까지는 HTTP API를 중심으로 인터페이스를 설명했다.</p>
<p>HTTP API는 요청과 응답이 비교적 명확하다. <code>request</code>, <code>response</code>, <code>error</code> 스키마를 정의하고, OpenAPI 같은 명세를 기준으로 관리할 수 있다. 따라서 REST API나 HTTP 기반 API 정의 방법은 사실상 디펙토로 자리잡았다.</p>
<p>하지만 SSE, Socket, WebSocket 같은 이벤트 기반 트래픽은 이야기가 조금 다르다.</p>
<p>이런 방식들은 API처럼 명확한 디팩토가 강하게 정해져 있지 않다. REST API가 OpenAPI를 중심으로 계약을 관리하듯, 이벤트 기반 인터페이스에서는 AsyncAPI 같은 명세 도구도 존재한다. 다만 일반적인 HTTP API와 비교하면 팀마다 이벤트 이름, payload 구조, 재연결 처리, 실패 처리 방식이 훨씬 다르게 설계된다. 그래서 복잡도가 쉽게 증가한다.</p>
<p>API는 보통 한 번 요청하고 한 번 응답받으면 흐름이 끝난다. 반면 SSE나 Socket은 연결이 유지된 상태에서 여러 이벤트가 순서대로 오간다. 이벤트가 중복으로 들어올 수도 있고, 연결이 끊겼다가 다시 붙을 수도 있고, 마지막으로 받은 이벤트 이후부터 복구해야 할 수도 있다.</p>
<p>따라서 이벤트 기반 트래픽은 API보다 관리하기가 훨씬 까다롭다.</p>
<p>관리하기 위해서는 단순히 payload 타입만 정의하면 안 된다. 먼저 각 이벤트의 payload schema를 정의하고, 그 다음에 이벤트 흐름을 함께 정의해야 한다.</p>
<pre><code class="language-text">payload schema
event flow
reconnect strategy
failure event
completion event</code></pre>
<p>예를 들어 작업 진행 상태를 SSE로 전달한다면 다음처럼 이벤트 흐름을 먼저 잡아야 한다.</p>
<pre><code class="language-text">job.started
→ job.progress.updated
→ job.completed

job.started
→ job.progress.updated
→ job.failed</code></pre>
<p>그리고 각 이벤트는 일정한 envelope와 payload schema를 가져야 한다.</p>
<pre><code class="language-json">{
    &quot;event&quot;: &quot;job.progress.updated&quot;,
    &quot;version&quot;: 1,
    &quot;id&quot;: &quot;evt-123&quot;,
    &quot;occurredAt&quot;: &quot;2026-05-16T10:00:00Z&quot;,
    &quot;payload&quot;: {
        &quot;jobId&quot;: &quot;job-1&quot;,
        &quot;progress&quot;: 40,
        &quot;status&quot;: &quot;running&quot;
    }
}</code></pre>
<p>즉, API 인터페이스가 요청/응답/에러 스키마 중심이라면, 이벤트 기반 인터페이스는 payload schema와 event flow 중심으로 설계해야 한다.</p>
<p>그리고 이런 이벤트 기반 트래픽은 남용하면 안 된다고 생각한다. 단순 조회나 일반적인 CRUD 요청까지 Socket이나 SSE로 처리하면 오히려 상태 관리와 디버깅이 어려워진다. 실시간성이 필요하거나, 서버가 지속적으로 상태를 밀어줘야 하는 경우에만 사용하는 것이 좋다.</p>
<h2 id="😘-마무리">😘 마무리</h2>
<p>프론트엔드 API 인터페이스 관리는 결국 백엔드 인터페이스를 잘 받아오는 문제에 가깝다.</p>
<p>프론트엔드에서 개별적으로 API 타입과 에러 처리 패턴을 만들기 시작하면 당장은 빠를 수 있다. 하지만 API가 늘어나고, 예외 케이스가 늘어나고, 화면이 늘어나면 결국 백엔드 규약과 프론트엔드 구현이 어긋나기 쉽다.</p>
<p>이번 글은 프론트엔드가 백엔드 인터페이스를 어떻게 받아와야 하는지에 초점을 맞췄다.</p>
<p>다음 글에서는 반대로 백엔드에서 좋은 인터페이스를 어떻게 설계해야 하는지 다뤄보려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Junior 답게]]></title>
            <link>https://velog.io/@choi-hyk/Junior-%EB%8B%B5%EA%B2%8C</link>
            <guid>https://velog.io/@choi-hyk/Junior-%EB%8B%B5%EA%B2%8C</guid>
            <pubDate>Sat, 16 May 2026 06:16:14 GMT</pubDate>
            <description><![CDATA[<h1 id="다시-취준-개발자로-돌아오며">다시 취준 개발자로 돌아오며</h1>
<p>1년도 안 되는 기간 동안 주니어 개발자로 실무를 경험했다.</p>
<p>길게 일했다고 말하기에는 애매한 시간이다. 그렇다고 아무것도 남지 않은 시간은 아니었다. 실제 서비스 코드를 보고, 기능을 만들고, 코드 리뷰를 받고, 일정 안에서 선택해야 하는 상황을 경험했다.</p>
<p>그리고 이제 다시 취준 개발자의 자리로 돌아왔다.</p>
<p>처음에는 아쉬운 생각이 많이 들었다. 좀 더 일해볼걸 그랬나, 좀 더 잘할 수 있지 않았을까 같은 생각이 계속 들었다. 그런데 계속 그렇게만 생각하고 있으면 앞으로 무엇을 해야 하는지 정리되지 않았다.</p>
<p>아직 잘 정리된 것은 아니지만, 지금 내가 주니어 개발자로서 느끼고 있는 생각을 솔직하게 적어보고 싶었다.</p>
<p>내가 생각하는 <strong>Junior 답게</strong>는 부족함을 합리화하는 말이 아니다. 오히려 반대에 가깝다. 아직 모르는 것이 많다는 사실을 인정하고, 그렇기 때문에 더 작게 나누고, 더 많이 확인하고, 더 깊게 탐구하려는 태도에 가깝다.</p>
<hr>
<h2 id="ai가-코드를-쓰는-시대">AI가 코드를 쓰는 시대</h2>
<p>요즘 개발 환경은 정말 빠르게 바뀌고 있다.</p>
<p>이전에는 개발자가 직접 코드를 작성하는 시간이 개발 업무의 큰 비중을 차지했다. 하지만 이제는 AI가 코드 작성의 많은 부분을 대신한다. 간단한 자동완성을 넘어, 요구사항을 설명하면 여러 파일을 수정하고, 테스트를 제안하고, 기능 단위의 구현까지 진행한다.</p>
<p>물론 모든 회사와 모든 프로젝트가 같은 속도로 변하고 있다고 단정할 수는 없다. 하지만 적어도 내가 체감하는 개발 문화에서는 <strong>코드를 얼마나 직접 많이 작성하는가</strong>보다 <strong>AI에게 무엇을 맡기고, 결과를 어떻게 검증하는가</strong>가 더 중요해지고 있다.</p>
<p>어쩌면 개발자가 코드 스크립트를 단 1줄도 직접 작성하지 않아도 되는 시대가 오고 있는 것일 수도 있다.</p>
<p>이 흐름은 분명 편리하다. 하지만 주니어 개발자 입장에서는 꽤 큰 불안으로 다가온다.</p>
<hr>
<h2 id="주니어가-느끼는-벽">주니어가 느끼는 벽</h2>
<p>시니어 개발자는 AI를 사용할 때 이미 많은 배경지식을 가지고 있다.</p>
<p>수많은 코드베이스를 봤고, 장애를 겪었고, 설계가 무너지는 경험도 해봤을 것이다. 그래서 AI가 만든 코드를 봤을 때 무엇이 위험한지, 어떤 부분을 의심해야 하는지, 이 구조가 나중에 어떤 문제를 만들 수 있는지 비교적 빠르게 판단할 수 있다.</p>
<p>반면 주니어는 그렇지 않다.</p>
<p>AI가 빠르게 코드를 만들어도 그 코드가 좋은 코드인지 판단하기 어렵다. 동작은 하지만 왜 동작하는지 모를 수 있고, 테스트는 통과하지만 설계상 위험한 지점을 놓칠 수도 있다. 여러 파일이 한 번에 바뀌면 변경 이유를 따라가는 것조차 쉽지 않다.</p>
<p>여기서 벽이 느껴진다.</p>
<p>실무에서는 속도와 효율이 중요하다. 회사는 결과를 만들어야 하고, 개발자는 주어진 일정 안에서 기능을 완성해야 한다. 그런데 AI가 속도와 효율을 크게 끌어올릴수록, 경험이 부족한 주니어는 그 기준을 바로 따라가기 어렵다.</p>
<p>단순히 &quot;AI를 잘 쓰면 된다&quot;로 해결되는 문제가 아니다.</p>
<p>AI를 잘 쓰기 위해서도 결국 문제를 이해하는 힘이 필요하다. 코드를 읽는 힘이 필요하다. 결과를 검증하는 힘이 필요하다. 그리고 이 힘은 단기간에 생기지 않는다.</p>
<p>그래서 주니어는 이런 질문을 하게 된다.</p>
<blockquote>
<p>AI가 대부분의 코드를 작성하는 시대에, 나는 어떤 가치를 만들 수 있을까?</p>
</blockquote>
<hr>
<h2 id="시니어와-같은-방식으로-ai를-쓰면-안-되는-이유">시니어와 같은 방식으로 AI를 쓰면 안 되는 이유</h2>
<p>나는 주니어가 시니어와 같은 방식으로 에이전틱 코딩을 하려고 하면 오히려 더 위험해질 수 있다고 생각한다.</p>
<p>시니어는 큰 단위의 작업을 AI에게 맡길 수 있다. 이미 머릿속에 전체 구조가 있고, 변경 범위와 위험 지점을 어느 정도 예상할 수 있기 때문이다. AI가 여러 파일을 한 번에 수정해도 어떤 의도로 바뀌었는지, 무엇을 다시 확인해야 하는지 파악할 가능성이 높다.</p>
<p>하지만 주니어에게 같은 방식은 부담이 크다.</p>
<p>여러 파일이 한 번에 바뀌면 변경 흐름을 놓치기 쉽다. 코드가 동작하더라도 어떤 판단이 들어갔는지 이해하지 못할 수 있다. 모르는 기술 스택과 익숙하지 않은 코드 스타일이 섞이면, 어느 순간 AI가 만든 결과를 이해하기보다 그냥 받아들이게 된다.</p>
<p>이게 가장 위험하다고 생각한다.</p>
<p>AI를 활용했지만 내가 성장하지 못하는 상황이다. 결과물은 있는데 내 판단 기준은 남지 않는 상황이다.</p>
<p>그러면 다음에 비슷한 문제가 왔을 때 다시 AI에게 크게 맡기는 수밖에 없다. 문제를 해결한 것 같지만, 실제로는 문제를 이해하는 능력이 쌓이지 않은 것이다.</p>
<hr>
<h2 id="주니어에게-필요한-에이전틱-코딩-방식">주니어에게 필요한 에이전틱 코딩 방식</h2>
<p>내가 생각하는 주니어의 에이전틱 코딩은 <strong>병렬로 많이 만들기</strong>보다 <strong>작게 나누고 깊게 확인하기</strong>에 가깝다.</p>
<p>AI 도구는 여러 작업을 동시에 처리할 수 있다. 여러 에이전트에게 각각 다른 파일을 맡기고, 기능을 병렬로 구현하게 할 수도 있다. 숙련된 개발자에게는 이 방식이 큰 생산성으로 이어질 수 있다.</p>
<p>하지만 주니어에게는 항상 좋은 방식이 아닐 수 있다.</p>
<p>병렬로 많은 코드가 생성되면 검증해야 할 양도 같이 늘어난다. 이해하지 못한 변경이 쌓이면 나중에는 어디서 문제가 생겼는지 찾기 어려워진다. 특히 실무 코드베이스에서는 작은 변경 하나가 상태 관리, API 계약, UI 동작, 테스트, 배포 환경까지 영향을 줄 수 있다.</p>
<p>그리고 개발자는 영향을 줄 수 있는 부분들을 판단하고 검증을 해야한다. 그리고 주니어에게는 사실상 이러한 검증이 불가능에 가깝다.</p>
<p>그래서 주니어에게는 오히려 고전적인 <strong>분할-정복</strong>과 같이 작게 나누고 다시 병합하는 과정이 더 중요하다고 생각한다. 이 방식은 빠르지 않을 수 있다.</p>
<p>하지만 주니어에게는 이 느린 과정이 필요하다. AI가 코드를 대신 작성해 주는 시대일수록, 주니어는 코드 작성량보다 이해의 밀도를 높여야 한다. 내가 무엇을 바꿨는지, 왜 바꿨는지, 어떤 위험이 남았는지를 설명할 수 있어야 한다.</p>
<p>그렇지 않으면 AI가 만든 결과는 내 실력이 아니라 지나간 출력값에 가까워진다.</p>
<hr>
<h2 id="경험-기반으로-많이-시도하기">경험 기반으로 많이 시도하기</h2>
<p>주니어에게는 경험의 양도 필요하다.</p>
<p>이론적으로 좋은 구조를 아는 것과 실제 코드베이스에서 그 구조를 적용하는 것은 다르다. 개인 프로젝트에서 잘 동작하던 방식이 실무에서는 맞지 않을 수 있고, 반대로 처음에는 이상해 보였던 회사의 코드 구조가 나름의 이유를 가지고 있을 수도 있다.</p>
<p>그래서 주니어는 많은 코드, 많은 스택, 많은 케이스를 직접 탐구해야 한다고 생각한다.</p>
<p>다만 여기서 중요한 것은 무작정 많이 만드는 것이 아니다. 하나를 만들더라도 다음 질문을 남겨야 한다.</p>
<pre><code class="language-text">왜 이 구조를 선택했는가?
다른 방식은 왜 선택하지 않았는가?
이 코드는 어떤 상황에서 깨질 수 있는가?
AI가 제안한 코드 중 내가 이해하지 못한 부분은 무엇인가?
다음에 같은 문제를 만나면 어떤 순서로 확인할 것인가?</code></pre>
<p>이런 질문이 남아야 경험이 쌓인다.</p>
<p>단순히 AI에게 기능을 만들게 하고, 동작하면 넘어가는 방식은 당장은 빠를 수 있다. 하지만 주니어에게 필요한 성장으로 이어지기는 어렵다. 결국 중요한 것은 많이 만드는 것이 아니라, 많이 판단해보는 것이다.</p>
<hr>
<h2 id="앞으로-지키고-싶은-기준">앞으로 지키고 싶은 기준</h2>
<p>다시 취준 개발자로 돌아온 지금, 나는 앞으로의 성장을 조금 다르게 바라보려고 한다.</p>
<p>이전에는 더 많은 기술을 알고, 더 많은 기능을 만들어야 한다고 생각했다. 물론 지금도 필요하다. 하지만 이제는 그보다 먼저 내가 어떤 방식으로 문제를 바라보고 있는지 확인하고 싶다.</p>
<p>AI가 코드를 만들어 주는 시대에도 개발자의 판단은 사라지지 않는다. 오히려 더 중요해질 수 있다. 무엇을 만들지, 왜 그렇게 나눌지, 어떤 결과를 믿을지, 어디까지 검증할지를 결정하는 일은 여전히 개발자의 몫이기 때문이다.</p>
<p>주니어는 시니어처럼 많은 경험을 가지고 있지 않다.</p>
<p>하지만 그렇기 때문에 더 많이 질문할 수 있고, 더 작은 단위로 파고들 수 있고, 실패를 경험으로 바꾸는 훈련을 할 수 있다. AI가 개발의 기본 도구가 된 시대라면, 주니어에게 필요한 것은 AI를 무작정 빠르게 쓰는 능력이 아니라 AI와 함께 더 정확히 배우는 능력일지도 모른다.</p>
<p>앞으로 다시 개발자로 성장해 가는 과정에서 이 기준을 계속 확인해보려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[GitHub Pages] 디자인 개선 및 서버리스로 변경]]></title>
            <link>https://velog.io/@choi-hyk/GitHub-Pages-%EB%94%94%EC%9E%90%EC%9D%B8-%EA%B0%9C%EC%84%A0-%EB%B0%8F-%EC%84%9C%EB%B2%84%EB%A6%AC%EC%8A%A4%EB%A1%9C-%EB%B3%80%EA%B2%BD</link>
            <guid>https://velog.io/@choi-hyk/GitHub-Pages-%EB%94%94%EC%9E%90%EC%9D%B8-%EA%B0%9C%EC%84%A0-%EB%B0%8F-%EC%84%9C%EB%B2%84%EB%A6%AC%EC%8A%A4%EB%A1%9C-%EB%B3%80%EA%B2%BD</guid>
            <pubDate>Sun, 28 Dec 2025 08:28:39 GMT</pubDate>
            <description><![CDATA[<h1 id="🖥️-github-pages-리펙토링">🖥️ Github Pages 리펙토링</h1>
<p>아주 오랜만에 <strong>Github Pages</strong> 글을 작성하는 것 같다. 이번 글에는 최근에 내가 진행한 GitHub Pages 디자인 개선과 서버리스 구조로 변경한 작업을 정리해보려고 한다.</p>
<h2 id="🛠️-serverless">🛠️ Serverless...</h2>
<p>원래는 백엔드를 하나 두어서, 일정과, Velog 데이터 및 Github 데이터를 가져오도록 구성을 해두었다. 그런데  AWS 프리티어로 구동 중이다 보니 관리도 너무 힘들고, 서버가 먹통이 되거나 이런 현상이 많았다. 그래서 과감하게 일정 페이지를 삭제하고 단순히 렌더링 타임에 Velog 및 Github 데이터를 호출하도록 바꾸었다. 구매해둔 DNS는 다른 프로젝트에서 사용할 예정이다.</p>
<h2 id="📌-velog-데이터-가져오기">📌 Velog 데이터 가져오기</h2>
<p>GitHub 데이터는 단순히 내 계정의 정보와 public repo 정보를 가져오면 되므로, API를 호출하면 된다. 그런데 문제는 Velog 데이터를 가져오는 것이었다. Velog는 RSS로 가져오거나, GraphQL로 가져오는 방식 두개가 있는데, 상세한 정보를 가져오고 싶으면 GraphQL을 가져와야 한다. 나는 글의 태그들도 가져오기를 원해서 GraphQL 방식을 채택하였다. <strong>그런데 Velog의 GraphQL은 브라우저 호출을 허용하지 않기에 다른 방법을 찾아야 했다.</strong></p>
<p>나는 예전에 구성한 <a href="https://github.com/choi-hyk/velog_sync">VelogSync</a> 프로젝트로 내 Github repo에 Velog 글들을 자동 백업을 하고 있다.</p>
<p>관련글</p>
<ul>
<li><a href="https://velog.io/@choi-hyk/Mini-Project-Velog-Backup-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-%EB%A7%8C%EB%93%A4%EA%B8%B0">[Mini Project] Velog Backup 프로그램 만들기</a></li>
</ul>
<p>따라서 해당 repo의 정보를 가져오는 방식으로 구성을 하였다. 간단하게 해결이되어서 다행이지만, 일단 시간당 60회 호출 제한이 있긴 해서, 완전하지는 않다. 이 부분은 나중에 개선하기로 하겠다.</p>
<h2 id="디자인-개선">디자인 개선</h2>
<p>디자인 개선은 Codex CLI의 힘을 빌러서 진행했다. Codex CLI에 최근 트렌드에 맞춰서 디자인 개선을 진행하도록 프롬프팅을 하고 작업을 하였다. </p>
<h3 id="profile-page">Profile Page</h3>
<img width="1920" height="1080" alt="Image" src="https://github.com/user-attachments/assets/0ae752ff-b615-45a4-8497-e3dfa2d3bdc4" />

<h3 id="github-page">Github Page</h3>
<img width="1920" height="1080" alt="Image" src="https://github.com/user-attachments/assets/3610afc2-e17a-426e-bf2b-1898164f4509" />

<h3 id="velog-page">Velog Page</h3>
<img width="1920" height="1080" alt="Image" src="https://github.com/user-attachments/assets/fe236b1f-be49-4568-a3c7-9c9670c965cd" />
<img width="1920" height="1080" alt="Image" src="https://github.com/user-attachments/assets/17fa2bfb-e8e2-4326-9959-30cb2c141abc" />

<p><strong>Codex CLI</strong>를 써보니 나같은 디자인 잼병이나 프론트에 익숙하지 않은 주니어 개발자 혹은 백엔드 개발자한테 매우 좋은 툴인 것 같다. 백엔드를 구현할 때도 사용을 하지만, 프론트를 구현할 때 강력한 기능을 제공하는 것 같다. 로그인 페이지나 이번에 구성한 프로필 혹은 기타 카드로 이루어진 매우 심플한 페이지를 구성할 때, 시간 단축과 세련된 디자인을 제시해준다. <strong>Codex CLI</strong>에 대해서는 따로 글로 작성해서 MCP 도구 세팅과 유용한 사용법을 정리할 생각이다.</p>
<h2 id="😁-마무리">😁 마무리</h2>
<p>이렇게 서버리스로 바꾸고 디자인 개선을 진행해보니, 한결 마음이 편해진 것 같다. AWS도 빨리 정리해서 지금 진행중인 HippoBox 프로젝트에서 해당 DNS와 서버를 사용하도록 바꿔야 겠다.</p>
<p><strong>Github Page</strong>
<a href="https://choi-hyk.github.io./#/profile">choi-hyk.github.io</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[HippoBox] HippoBox 시작하기]]></title>
            <link>https://velog.io/@choi-hyk/Project-HippoBox-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@choi-hyk/Project-HippoBox-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 20 Dec 2025 09:00:59 GMT</pubDate>
            <description><![CDATA[<h1 id="최신-트렌드를-익히기-위한-프로젝트">최신 트렌드를 익히기 위한 프로젝트...</h1>
<p>너무 오랜만에 Velog 글을 작성하는 것 같다. 거의 2개월 만에 작성하는 것 같은데, 바빠서 작성을 못한 것은 아니고, 뭔가 열정이 없어져서 미루다 보니 이렇게 된 것 같다.</p>
<p>그래도 최근에 개인 프로젝트를 하나 시작해서 해당 프로젝트에 대해서 글을 작성할 생각이다. 시작한 프로젝트는 HippoBox라는 이름의 프로젝트이다. 프로젝트 내용은 <strong>여러 AI 서비스에서 사용 가능한 지식 베이스 서비스</strong>를 구성하는 것이다.</p>
<p>프로젝트를 시작한 이유는 최신 개발 트렌드를 익히고, AI 서비스와 접목시킬 수 있는 프로젝트가 뭐가 있을지 고민해 보았을 때, 지식 베이스를 통합시켜 서비스를 제공한다는 것이 괜찮은 아이디어 같아서 진행하게 되었다. 그리고 Github의 유명 오픈소스들을 토대로 워크플로우를 구성할 생각이다. 이 과정에서 영어 실력도 좀 늘리고, 실무에서 진행하는 패턴들을 익혀서 적용할 생각이다.</p>
<hr>
<h2 id="🛠️-stack">🛠️ Stack</h2>
<p>일단 <code>FastAPI</code>를 기반으로 빠르게 <strong>MCP 서버</strong>를 제공하는 것이 목표이다 (사실 이미 백엔드 환경은 얼추 구현함...). <code>FastAPI</code>에서 MCP 서버를 구성하도록 도와주는 라이브러리를 찾아보니 대표적으로 <code>FastMCP</code>와 <code>fastapi-mcp</code> 두 개가 있었다. <code>FastMCP</code>는 간단하게 도구 기능을 <code>FastAPI</code> 객체에 주입하여 MCP 엔드포인트를 만들어주는 라이브러리이다. <code>fastapi-mcp</code>는 라우터 자체를 도구로 호출할 수 있도록 해주는 라이브러리이다. 나는 <code>fastapi-mcp</code>를 선택하였으며, 이유는 단순히 MCP를 통해 서비스를 호출하는 것이 아닌, 프론트 서비스도 같이 제공하기 위해서이다. 그러기 위해서 API와 MCP 호출을 통합시키는 것이 개발에 편할 것이라고 판단하였다.</p>
<p>프론트 서비스는 아직 스택을 정하진 않았다. <strong>Svelte</strong>와 <strong>React</strong> 중에 고민 중인데, 아마 <strong>React</strong>로 진행할 것 같다.</p>
<hr>
<h2 id="🛠️-서비스-구조">🛠️ 서비스 구조</h2>
<p>서비스 구조는 매우 간단하다. 단순하게 지식 베이스 저장소를 제공하는데, 일단 데이터베이스와 벡터 데이터베이스 두 개를 제공하여, 자신의 지식 베이스에서 임베딩 검색이 가능하도록 하는 것이 주된 목적이다.</p>
<p>따라서 사용자는 ChatGPT, Claude, Cursor 등 여러 가지 AI 플랫폼에서 자신의 지식을 관리할 수 있게 하는 것이 최종적인 프로젝트 목표이다. 일단 개발자 위주의 서비스 제공이지만, 추후에 해당 AI 서비스들이 확장되어서, 일반 사용자들도 여러 가지 AI 서비스들에서 자신의 저장된 지식을 제한 없이 사용할 수 있게 하는 것이 최종적인 목적이다.</p>
<hr>
<h2 id="😘-마무리">😘 마무리</h2>
<p>앞으로 해당 프로젝트에 대해서 글을 작성할 생각이다. 현재는 일단 <code>User</code>와 <code>Knowledge</code> API는 구성이 완료되었고, MCP 호출 동작도 확인한 상태이다. 해당 내용은 다음 글에서 다룰 예정이다.</p>
<p><a href="https://github.com/HippoBox">Github 프로젝트 URL</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[FastAPI] sync/async 의 논리적 구조]]></title>
            <link>https://velog.io/@choi-hyk/Python-syncasync-%EC%9D%98-%EB%85%BC%EB%A6%AC%EC%A0%81-%EA%B5%AC%EC%A1%B0</link>
            <guid>https://velog.io/@choi-hyk/Python-syncasync-%EC%9D%98-%EB%85%BC%EB%A6%AC%EC%A0%81-%EA%B5%AC%EC%A1%B0</guid>
            <pubDate>Thu, 09 Oct 2025 08:58:43 GMT</pubDate>
            <description><![CDATA[<h1 id="🖥️-시작하기에-앞서">🖥️ 시작하기에 앞서...</h1>
<p>이제 회사에서 본격적으로 개발 일을 시작한지 1달 반이 다 되어간다. 현재 <code>Svelte</code>와 <code>FastAPI</code> 기반의 Monolothic 구조의 프로젝트를 유지보수 하고 있는데, 해당 프로젝트에서 이해가 안되는 부분이 매우 많이 있다. 특히 <code>FastAPI</code>의 <code>coroutine</code>을 통한 라우터 설정에 애를 먹고 있는데, 규모가 큰 오픈소스다 보니까, 어떤 기준으로 해당 함수는 <code>async</code>를 통해 코루틴 처리를 하였는지, 어떤 함수는 일반 함수 정의를 통해 <code>thread pool</code>로 관리하는지 이해가 안되고 있다. </p>
<p>아마도 나 같은 신입 개발자들은 이러한 동시성 관리가 익숙치 않을 것이다. 신입 개발자들은 다른 WAS 프레임워크들 또한 이러한 동시성 관리, 더 나아가 <code>Python</code> 기반이 아닌 <code>Spring Boot</code> 같은 다른 언어 진영의 병렬 처리 같은 물리적 구조를 고려한 프로그래밍을 경험할 기회가 없다. 본인이 개인 프로젝트나, 백엔드 서비스를 개발한 경험이 있어도 실무에서 요구하는 것 과는 분명히 차이가 있을 것이다. </p>
<p>물론 현재 나는 <code>FastAPI</code>를 사용 중이어서, 고 수준의 병렬 처리는 고려할 상황이 아니지만, 개발을 하면서, 성능 개선을 위해 동시성 강화같은 이슈를 처리를 하면 벽이 느껴진다... 심지어 이러한 동시성 관리와 성능 개선을 위해 <code>ThreadPool</code>을 사용해 블록킹 함수를 추가를 하면, 이러한 수정이 오히려 전체 프로젝트의 성능에 어떠한 영향을 미칠지 모르겠는 경우가 허다하다. 그래서 이번 시리즈에서는 <code>ASGI</code> 기반의 서버인 <code>FastAPI</code>가 어떤식으로 동시성을 관리하는지와 다른 서버 프레임워크들과 비교를 통해 어떤 식으로 요청을 받고, 처리하는지 정리를 해보려고 한다. </p>
<hr>
<h2 id="🛠️-blocking-vs-non-blocking">🛠️ Blocking vs Non-Blocking</h2>
<p>알아보기 전에 <code>Blocking</code>과 <code>Non-Blocking</code>에 대해서 자세히 알아볼 필요가 있다. 많은 사람들이 오해를 하는 것이 <code>Blocking</code> 함수와 <code>Non-Blocking</code> 함수의 구분 방법이다. 두 함수는 개발자가 저수준의 구현을 통해서 <code>Blocking</code>, <code>Non-Blocking</code>을 설정을 하는 것이 아닌, 기존에 존재하는 라이브러리나 구문을 통해서 설정된다. 또한 기본적인 함수들은 동기 실행을 가정한다. 여기서 가장 오해하는 부분이 동기와 <code>Blocking</code> 그리고 비동기와 <code>Non-Blocking</code>의 관계이다. 앞에서 이야기한 <strong>&quot;기본적인 함수들은 동기 실행을 가정한다&quot;</strong> 의 의미는 기본적인 함수들은 모두 동기로 처리된다는 의미이다. 당연한 이야기지만, 여기에 <code>Blocking</code>과 <code>Non-Blocking</code>을 고려해보자.</p>
<h3 id="cpu-bound-vs-io-bound">CPU bound vs I/O bound</h3>
<p><code>Blocking</code>은 <strong>I/O 바운드</strong> 를 통한 <strong>쓰레드의 대기 상태</strong>를 의미한다. 그러면 <strong>I/O 바운드</strong>가 아닌 <strong>CPU 바운드</strong>를 생각해보자 만약 개발자가 루프문을 $O(n^3)$ 의 시간복잡도 동안 실행한다고 해보자. 이 상황에서도 <code>Blocking</code> 이라고 할 수 있는가? 루프문을 실행하는 동안 같은 쓰레드 내의 다른 함수들은 실행되지 않지만, <strong>분명 해당 쓰레드는 실행 중</strong>이다. 이러한 쓰레드 내의 작업을 <strong>CPU 바운드</strong>라고 한다. 뭔가 거창하게 설명했지만, 그냥 일반적인 함수 실행이다... </p>
<h3 id="비동기와-non-blocking의-논리적-실행">비동기와 Non-Blocking의 논리적 실행</h3>
<p>그러면 다시 <code>Blocking</code> 함수로 돌아가서 <strong>I/O 바운드</strong>는 정확히 무엇을 의미를 할까? 우리가 서버 환경에서 클라이언트로부터 요청을 받고, 데이터베이스에서 사용자의 정보를 확인한다고 해보자.</p>
<pre><code class="language-python">def get_user_from_db(username: str):
    conn = sqlite3.connect(&quot;users.db&quot;)
    cursor = conn.cursor()
    cursor.execute(&quot;SELECT username, password FROM users WHERE username = ?&quot;, (username,))
    user = cursor.fetchone()
    conn.close()
    return user

@app.post(&quot;/login/blocking&quot;)
def login_blocking(username: str, password: str):
    user = get_user_from_db(username)
    if not user or user[1] != password:
        raise HTTPException(status_code=401, detail=&quot;Invalid credentials&quot;)
    return {&quot;message&quot;: f&quot;Welcome, {username}!&quot;}</code></pre>
<p>위의 함수는 동기 상태로 정의된 <code>FastAPI</code> 라우터이다. 위의 <code>get_user_from_db()</code>에서 내부 <strong>sqlite db</strong>에서 DML을 실행 중이다. 이때, <code>cursor.execute(&quot;SELECT username, password FROM users WHERE username = ?&quot;, (username,))</code> 는  CPU 바운드인가 I/O 바운드인가? 답은 <strong>I/O 바운드</strong>이다. 해당 함수를 실행을 하면, 현재 쓰레드는 추가적인 작업이 필요한지를 생각해보면, 전혀 아니다. <strong>현재 쓰레드에서는 단순히 해당 함수가 끝나길 기다릴 것이다. 즉 <code>Waiting</code> 상태가 된다.</strong> 그리고 <strong>CPU는 해당 DML을 수행하고 있는 DBMS가 점유</strong>할 것이다. 그리고 작업이 끝나면, 함수를 반환하고, 다시 현 쓰레드를 실행할 것이다. 다시 말해, <strong>서버의 스레드는 멈춰 있고, DBMS 프로세스가 디스크에서 데이터를 읽거나 쓰는 작업을 수행</strong>하는 것이다. 작업이 완료되면 DBMS는 결과를 반환하고, 커널은 대기 중이던 쓰레드를 깨워 이전의 함수 실행 지점부터 코드를 이어서 수행한다. 즉, 코드 상으로는 함수가 멈춰 있는 것처럼 보이지만, 실제로는 쓰레드가 CPU를 전혀 사용하지 않고, 외부 자원(디스크)의 응답을 기다리는 <strong>I/O Bound + Blocking</strong> 상황이 발생한 것이다. 그러면 생각해보자 여기서 어떻게 성능을 개선 할 수가 있을까?</p>
<p>위의 코드처럼 라우터에 1개의 요청 또는 1개의 <code>Blocking</code> 함수만 있으면 별로 상관이 없을 것이다. 이번에는 <code>Blocking</code> 함수가 여러 개가 있다고 생각해보자. </p>
<pre><code class="language-python">def get_user_from_db(username: str):
    conn = sqlite3.connect(&quot;users.db&quot;)
    cursor = conn.cursor()
    time.sleep(1)
    cursor.execute(&quot;SELECT username, password, info, history FROM users WHERE username = ?&quot;, (username,))
    user = cursor.fetchone()  
    conn.close()
    return user

@app.post(&quot;/login/blocking&quot;)
def login_blocking(username: str, password: str):
    user = get_user_from_db(username)
    if not user or user[1] != password:
        raise HTTPException(status_code=401, detail=&quot;Invalid credentials&quot;)
    return {&quot;message&quot;: f&quot;Welcome, {username}!&quot;}


@app.post(&quot;/info/blocking&quot;)
def get_user_info_blocking(username: str):
    user = get_user_from_db(username)
    if not user:
        raise HTTPException(status_code=404, detail=&quot;User not found&quot;)
    return {&quot;username&quot;: user[0], &quot;info&quot;: user[2]}


@app.post(&quot;/history/blocking&quot;)
def get_user_history_blocking(username: str):
    user = get_user_from_db(username)
    if not user:
        raise HTTPException(status_code=404, detail=&quot;User not found&quot;)
    return {&quot;username&quot;: user[0], &quot;history&quot;: user[3]}</code></pre>
<p>위의 함수에 사용자 3명이 동시다발적으로 3개의 요청을 각각 보낸다고 생각해보자. </p>
<blockquote>
<p><code>FastAPI</code> 에서는 일반 def 요청은 쓰레드 풀의 개별적인 쓰레드 워커에서 실행된다. 해당 부분은 다음 글에서 자세히 설명을 하고 지금은 단일 쓰레드에서 실행되는 것으로 가정 하겠다.</p>
</blockquote>
<p>8000포트에서 Listen 중인 상태로 프로세스를 실행하고, 사용자1이 <code>login_blocking()</code>, 사용자2가 <code>get_user_info_blocking()</code> 그리고 사용자3이 <code>get_user_history_blocking()</code>을 순서대로 요청을 보냈다고 가정하자. 또한 DML 실행시간은 1초라고 가정하자. 그리고 <strong>Task Queue</strong>에는 실행 프로세스의 쓰레드가 Task로 들어간다고 가정하자. 여기서 고려해야 되는 부분은,<strong>쓰레드가 하나이므로 만약 쓰레드가 Waiting 상태가 되면, 요청을 받지 못한다는 것이다.</strong> 간단히 생각해보면, 이미 실행 중인 프로세스에 추가적인 작업이 쌓이는 것을 생각하면 된다. 메인 워커 쓰레드를 $W$이라 하겠다. 또한 각 Task Queue에는 쓰레드 단위로 Task가 들어간다고 가정하겠다. </p>
<p>OS 수준에서는 1개의 프로세스와 1개의 단일 쓰레드를 <strong>Task Queue</strong>에서 실행 중이지만, 프로세스 관점에서는 프로세스에 추가적인 작업이 쌓이고 있다. 사용자1의 <code>login_blocking()</code>가 들어오는 순간 현재 쓰레드는 약 1초간 <code>Waiting</code> 상태가 될 것이다. <code>Waiting</code> 이 되는 1초 동안 나머지 2개의 요청을 받았다고 가정하자. 또한 2개의 코어로 병렬 처리가 된다고 가정하자.</p>
<img width="817" height="749" alt="Image" src="https://github.com/user-attachments/assets/ad66382d-5aaa-421e-a2ef-d9a71f8e1318" />

<p>위의 다이어그램은 Request를 받았을 때, 요청과 단일 쓰레드가 어떻게 처리되는지 보여준다. 현재 Blocking 함수는 <code>time.sleep()</code>, <code>cursor.execute()</code> 가 존재한다.  또한 $T_n$에서 $n$ 은 초 단위라고 가정을 하겠다. <code>accept queue</code>는 생소할텐데, listen 상태의 소켓을 지니고 있는 프로세스가 보유하는 큐로 아직 애플리케이션 레벨에서 <code>accept()</code> 호출로 가져가지 않은 연결들이 일시적으로 쌓여 있는 공간이다. 위의 그림을 보면, 당연하게도 $R_2$, $R_3$는 $R_1$이 처리되어야지 순서대로 처리될 것이다. 그러면 약 $T_4$에 모든 처리가 완료 될 것이다. 2개의 코어가 존재해도 $W$ 가 waiting이 되어 있으면, DB process가 실행 중일때 나머지 코어에 $W$를 실행하지 못할 것이다.</p>
<p>그러면 여기서 개선을 어떻게 할까? <code>FastAPI</code> 는 ASGI 기반이다. 이 말은 모든 요청을 async 인터페이스로 받는 웹 서버를 가정하는 프레임워크란 뜻이다. 위의 다이어그램을 봤을 때, 비동기로 처리를 한다고 하면, $R_1$ 처리 중에 다른 요청을 처리 할 수 있게 해야 한다. 여기서 사용하는 것이 바로 <code>async</code> 함수 내의 <code>await</code> 구문이다. 그리고 해당 구문으로 <code>Non-Blocking</code>의 진정한 의미를 알 수 있는데, 바로 <strong>실행 제어권을 반납하는 것</strong>이다. 매우 간단하다. <code>async</code> 함수 내에서 <code>await</code>를 만나면 해당 함수를 <code>Non-Blocking</code> 으로 실행하겠다는 의미이다. 그럼 여기서 드는 생각이, 결국에는 <code>FastAPI</code> 같은 ASGI 기반의 웹서버는 애플리케이션 수준에서 비동기를 지원하는 것이다. 즉 <strong>단일 쓰레드의 코루틴 내에서 비동기를 지원하여 동시성을 강화하는 것</strong>이다. 위의 다이어그램이 비동기로 처리될때, 차이점은 <strong>I/O 바운드 작업시에 $W$를 Waiting 상태에 빠지지 않도록하는 Non-Blocking 처리</strong>만 존재한다. 그러면 이게 어떻게 가능할까? 단일 쓰레드 내에서도 스케줄러같은 실행 처리를 도와주는 로직이 있는 것일까?</p>
<h3 id="coroutine">Coroutine</h3>
<p>Coroutine이 바로 이 비동기 처리를 구현하는 기법이다. OS는 Context switch같은 기법을 통해 동시성을 강화한다. 그럼 OS가 비동기를 처리한다고 할 수 있을까? 절대 아니다. 이유는 <strong>동시성 강화는 비동기 처리가 아니기 때문이다. 하지만 비동기 처리는 동시성을 강화하는 기법 중 한가지이다</strong>. OS가 동시성을 강화하는 이유는 여러가지의 작업을 효울적으로 처리하기 위해서다. 이를 통해 사용자는 작업이 동시에 이루어지는 환상을 만들어준다. <strong>하드웨어적인 관점으로 자원을 최소한으로 사용하여 가장 효율적인 스케줄을 통해 프로세스를 관리하는 것</strong> 이것이 목적이다. 그러면 Coroutine을 통한 비동기 처리는 무엇이 목적일까? 말한대로 동시성 강화가 목적이다. 하지만 하드웨어적인 관점에서 굳이 애플리케이션 수준에서 자원의 효율성같은 요소를 신경쓰지는 않을 것이다. 주요 목적은 위에서 말한 것 처럼, <strong>I/O 바운드 작업시에 코루틴 쓰레드를 Waiting 상태에 빠지지 않도록하는 Non-Blocking 처리를 하는 것이다.</strong> 이를 통해 Waiting이라는 요소를 제외하고 실행이 가능하다. 그리고 이것은 <strong>Event Loop를 통해 단일 쓰레드의 Call Stack의 Task switch로 이루어진다.</strong> 이는 OS 수준의 Context switch와 비슷한 기법이다. Event Loop 는 CPU 스케줄러 그리고 Task Switch는 Context switch로 비유할 수 있을 것이다. </p>
<blockquote>
<p>Coroutine은 OS처럼 물리적인 스케줄링을 수행하는 것이 아난, 단일 스레드 내부에서 실행 흐름을 논리적으로 전환(switch)하여 동시성을 달성하는 방식</p>
</blockquote>
<blockquote>
<p>OS의 Context switch가 커널이 직접 개입하여 CPU 레지스터, 프로그램 카운터, 스택 포인터 등 하드웨어 상태를 저장하고 복원하는 무거운 전환이라면, Coroutine의 Task switch는 단지 함수의 실행 위치와 로컬 상태를 저장하고 이벤트 루프가 다음 코루틴을 재개(resume)하는 가벼운 사용자 레벨 전환이라 할 수 있다.</p>
</blockquote>
<p>따라서 <strong>Coroutine은 커널이 아닌 애플리케이션 레벨에서 구현된 경량화된 동시성 메커니즘</strong>이며, Context switch의 하드웨어적 문맥 교환에 대응되는 소프트웨어적 제어 흐름 교환(Control-flow switching) 이라고 할 수 있다.</p>
<p>위의 다이어그램을 통해 비동기 처리가 구현된 다이어그램을 살펴보겠다. 이를 위해서 <code>async</code> 함수에서 <code>time.sleep()</code> 은  <code>await asyncio.sleep()</code> 로 바꾸고, DML 함수도 비동기 처리를 해야 한다. 이렇게 비동기 처리가 완료되면 다이어그램을 아래와 같을 것이다.</p>
<img width="1016" height="754" alt="Image" src="https://github.com/user-attachments/assets/c1df682b-aa75-42fe-be77-4b4bfe25b7f1" />

<p><code>asyncio sleep()</code> 과 <code>cursor.execute()</code> 같은 I/O 바운드가 실행되면, Event Loop는 등록된 다른 코루틴을 실행한다. 즉 위의 그림에서 coroutine1 이 <code>asyncio sleep()</code>을 통해 대기 상태에 들어가면  Event Loop는 Accept Queue에서 바로 $R_2$를 가져와서 coroutine2로 실행을 한다. 이때 오해를 하면 안되는 것이, coroutine은 <strong>병렬 실행이 절대 아니란 점</strong>이다. 위에 그림만 보면 오해를 할 수도 있지만, coroutine이 실행되는 로직은 기존에 실행 중이던 coroutine이 I/O 바운드로 인해 대기 상태에 들어갔을때만, 실행이 되는 <strong>동시성 강화</strong>이다.  따라서 위에서 OS 수준의 Context switch로 비유한 이유가 바로 이러한 Event Loop를 통한 coroutine 실행 관리 로직 때문이다. 이러한 동시성 강화를 통해 약 $T_{1.5}$ 에 모든 실행이 완료되는 것을 볼 수 있다. 기억하자 <strong>coroutine은 단일 쓰레드 내에서 이루어지는 비동기를 통한 동시성 강화 기법</strong>이라는 것을.</p>
<blockquote>
<p>비동기 코루틴은 단일 스레드 내에서 오직 하나의 Call Stack 위에서만 실행되며, 동시에 여러 coroutine이 CPU를 점유하는 일은 없다.</p>
</blockquote>
<p>OS 수준에서는 단순히 $W$를 실행하고 있으면 되고 이러한 비동기 처리는 애플리케이션 수준의 쓰레드 내부에서 전부 이루어지는 추상화가 ASGI 아키텍처의 철학이다. </p>
<hr>
<h2 id="😘-마무리">😘 마무리</h2>
<p><strong>비동기 모델에서의 동시성 강화는 OS 수준의 선점형 스케줄링(preemptive scheduling) 이 아닌, Event Loop를 중심으로 한 협력형(Cooperative) 스케줄링</strong> 에 의해 이루어지고 Event Loop는 OS의 CPU 스케줄러에 대응되는 역할을 수행하며, coroutine 간 전환(Task Switching)은 커널 수준의 Context Switch 대신 사용자 레벨에서 수행되는 <strong>가벼운 실행 흐름 전환(Control-flow switching) 으로 처리된다.</strong> 이번 글에서는 FastAPI를 비롯한 비동기 처리가 논리적으로 어떻게 이루어지는 지와 OS 수준과 애플리케이션 수준에서 헷갈리지 않도록 설명을 해보았다. 다음 글에서는 FastAPI의 비동기 처리 로직을 코드를 통해 알아보고 일반 def 선언은 어떻게 처리되는지 그리고 lifespan을 통한 coroutine 처리를 심도있게 다뤄보겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Python] Pydantic 부시기]]></title>
            <link>https://velog.io/@choi-hyk/Python-Pydantic-%EB%B6%80%EC%85%94%EB%B2%84%EB%A6%AC%EA%B8%B0</link>
            <guid>https://velog.io/@choi-hyk/Python-Pydantic-%EB%B6%80%EC%85%94%EB%B2%84%EB%A6%AC%EA%B8%B0</guid>
            <pubDate>Wed, 10 Sep 2025 15:11:21 GMT</pubDate>
            <description><![CDATA[<h1 id="pydantic-✨">Pydantic ✨</h1>
<p>오늘은 <code>Pydantic</code>에 대해서 알아보려고 한다. 최근에 오픈소스로 이루어진 프로젝트들을 보면, 백엔드 서버를 <code>FastAPI</code>를 사용하는 경우가 많은데, Pydantic은 <code>FastAPI</code>에서 데이터 스키마를 정의하고 데이터 직렬화/역직렬화를 위해 많이 사용하는 라이브러리이다. 최근 회사에서 오픈소스를 활용한 프로젝트를 유지보수하는 업무를 하고 있는데, 해당 프로젝트가 나는 별로 사용해본 적이 없는 <code>Svelte</code>를 프론트로 사용 중이고 백엔드는 <code>FastAPI</code> 기반의 <strong>Monolithic</strong> 아키텍처를 구성하고 있다. 당연하게도 해당 프로젝트에서 백엔드는 <code>Pydnatic</code>으로 DTO를 구성하고 있다. <code>Pydantic</code>은 얼핏보면 간단해 보이지만, 수 많은 ASGI 코드들과 정의되어 있는 스키마를 보면 어지러워 질때가 있다. 그래서 해당 라이브러리에 익숙해질 필요를 느껴서 이렇게 정리를 해보려고 한다.</p>
<p>찾아보니 <code>Pydantic</code>은 처음에는 <strong>Samuel Colvin</strong> 이라는 사람이 2018년 쯤에 Python 환경에서 타입 힌트화를 통한 데이터 무결성 보장과 타입 직렬화/역직렬화를 지원하기 위해 만들었다고 한다. <del>GPT 피셜</del>. 나중에 FastAPI에서 공식적으로 Pydantic을 스키마 라이브러리로 채택하면서 널리 쓰이게 됐다고 한다. 특히, 데이터 스키마 정의를 통해 API 기반의 백엔드 서버의 라우터 문서화 <strong>(OpenAPI/Swagger)</strong> 를 자동화 하는 점이 큰 장점이다.  </p>
<hr>
<h2 id="사용법-🛠️">사용법 🛠️</h2>
<p>거두절미하고 바로 사용법을 알아보겠다. Pydantic을 써보면서 느낀점은 사용자 입맛대로 강력한 데이터 강제성을 주입 시킬 수 있다. 참고로 사용한 <code>Pydantic</code> 버전은 2.9.2 이다.</p>
<h3 id="basemodel">BaseModel</h3>
<pre><code class="language-python">class User(BaseModel):
    id: int = Field(
        default_factory=lambda: int(uuid.uuid4()),
        description=&quot;사용자의 고유 ID&quot;,
    )
    name: str = Field(
        ..., min_length=1, max_length=20, description=&quot;사용자의 이름 (1~20자)&quot;
    )
    email: str = Field(..., description=&quot;사용자의 이메일 주소&quot;)
    age: Optional[int] = Field(None, ge=0, description=&quot;사용자의 나이 (0 이상)&quot;)

    def __str__(self):
        return f&quot;User(id={self.id}, name=&#39;{self.name}&#39;, email=&#39;{self.email}&#39;, age={self.age})&quot;

    def to_model_dump(self):
        return self.model_dump()

    @classmethod
    def from_model_dump(cls, data):
        return cls.model_validate(data)

    @model_validator(mode=&quot;before&quot;)
    def check_email(cls, values):
        email = values.get(&quot;email&quot;)
        if email and &quot;@&quot; not in email:
            raise ValueError(&quot;Invalid email address&quot;)
        return values
</code></pre>
<p>이제 위의 코드를 보면 좀 어지러워 질텐데, 일단 <code>User</code> 스키마만 살펴보자.</p>
<pre><code class="language-python">class User(BaseModel):
    id: int 
    name: str
    email: str 
    age: int</code></pre>
<p>위의 스키마를 최대한 간단하게 정의하면 이렇게 작성할 수 있다. 먼저 <code>BaseModel</code> 은 <code>Pydantic</code>에서 해당 클래스가 스키마라는 것을 정의해주는 기본 클래스이다. 해당 클래스를 상속 함으로서 <code>User</code> 는 <code>Pydantic</code> 의 데이터 검증과 직렬화/역직렬화를 사용 가능하다. </p>
<pre><code class="language-python">user = User(id=&quot;123&quot;, name=&quot;Alice&quot;, email=&quot;user@example.com, age=&quot;25&quot;)
print(user) </code></pre>
<p>위 처럼 <code>User</code>를 정의했다고 생각해보자, 현재 <code>id</code> 와 <code>age</code> 는 <code>int</code> 형인데 <code>str</code> 형이 할당 되어있다. 마치 <code>JavaScript</code> 의 타입 캐스팅 처럼 <code>Pydantic</code> 은 바꿀 수 있는 타입은 알아서 바꿔 준다. 위의 경우에는 문제 없이 <code>int</code> 형으로 바뀔 것이다. 그러나 만약 &quot;one two three&quot; 같은 것이 할당되어 있으면, <code>ValidationError</code>를 발생 시킨다.</p>
<h3 id="field">Field</h3>
<pre><code class="language-python">class User(BaseModel):
    id: int = Field(
        default_factory=lambda: int(uuid.uuid4()),
        description=&quot;사용자의 고유 ID&quot;
    )
    name: str = Field(
        ..., min_length=1, max_length=20, description=&quot;사용자의 이름 (1~20자)&quot;
    )
    email: str = Field(
        ..., description=&quot;사용자의 이메일 주소&quot;
    )
    age: Optional[int] = Field(
        None, ge=0, le=150, description=&quot;사용자의 나이 (0~150)&quot;
    )</code></pre>
<p>이번에는 <code>Field</code>에 대해서 알아보자. <code>Field</code>는 일종의 데이터 명세서로 단순히 타입만 지정했을 때보다 훨씬 세밀하게 제약조건과 메타데이터를 설정할 수 있게 해준다. 위 코드를 보면, 모든 필드가 <code>description</code> 을 통해 필드 설명을 제공 중이다. 이 값은 문서화가 되었을 때, API 설명 부분에 자동을 할당된다.</p>
<p>각 필드를 살펴보면, <code>id</code>의 <code>default_factory</code> 를 볼 수 있는데, 해당 인자는 해당 필드를 동적으로 값을 생성한다는 의미이다. <code>default</code> 도 있는데, 해당 값은 동적이 아니라 정해진 값을 생성해주는 인자이다. 참고로 밑에 처럼 <code>Field</code>를 사용 안하고, <code>default</code> 선언도 가능하다.</p>
<pre><code class="language-python">class User(BaseModel):
    id: int = 10</code></pre>
<p>다음으로는 <code>...</code> 을 볼 수 있는데, 해당 값은 필수 인자라는 뜻이다. 따라서 해당 스키마를 정의할 때, 해당 값들을 할당하지 않고 정의하면 <code>ValidationError</code> 가 발생한다. </p>
<p>그 밖에도 여러가지 인자가 있는데, 밑에 표로 정리한 것을 살펴보면 이해가 편할 것이다.</p>
<h4 id="field-주요-인자-정리">Field 주요 인자 정리</h4>
<table>
<thead>
<tr>
<th>인자</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><strong>default</strong></td>
<td>기본값 지정</td>
<td><code>Field(0)</code></td>
</tr>
<tr>
<td><strong>default_factory</strong></td>
<td>동적으로 기본값 생성 (함수 실행 결과)</td>
<td><code>Field(default_factory=lambda: uuid.uuid4())</code></td>
</tr>
<tr>
<td><strong>... (Ellipsis)</strong></td>
<td>필수(required) 필드 지정</td>
<td><code>Field(...)</code></td>
</tr>
<tr>
<td><strong>title</strong></td>
<td>필드 제목 (문서화용)</td>
<td><code>Field(..., title=&quot;User ID&quot;)</code></td>
</tr>
<tr>
<td><strong>description</strong></td>
<td>필드 설명 (문서화용)</td>
<td><code>Field(..., description=&quot;사용자의 고유 ID&quot;)</code></td>
</tr>
<tr>
<td><strong>gt / ge</strong></td>
<td>숫자 크기 제한 (&gt;) / (≥)</td>
<td><code>Field(..., gt=0)</code></td>
</tr>
<tr>
<td><strong>lt / le</strong></td>
<td>숫자 크기 제한 (&lt;) / (≤)</td>
<td><code>Field(..., le=100)</code></td>
</tr>
<tr>
<td><strong>min_length / max_length</strong></td>
<td>문자열 길이 제한</td>
<td><code>Field(..., min_length=1, max_length=20)</code></td>
</tr>
<tr>
<td><strong>pattern</strong></td>
<td>정규식 패턴 검증</td>
<td><code>Field(..., pattern=r&quot;^[a-z0-9]+$&quot;)</code></td>
</tr>
<tr>
<td><strong>alias</strong></td>
<td>입력 받을 때 다른 키 이름 허용</td>
<td><code>Field(..., alias=&quot;user_id&quot;)</code></td>
</tr>
<tr>
<td><strong>deprecated</strong></td>
<td>필드가 더 이상 쓰이지 않음을 표시</td>
<td><code>Field(..., deprecated=True)</code></td>
</tr>
<tr>
<td><strong>examples</strong></td>
<td>API 문서에 예시 값 표시</td>
<td><code>Field(..., examples=[&quot;alice@example.com&quot;])</code></td>
</tr>
</tbody></table>
<h3 id="typing">Typing</h3>
<p><code>Pydantic</code> 은 <code>typing</code> 모듈의 정의 타입들을 사용하는데, 대표적으로 <code>Optional</code>이 있다.</p>
<pre><code class="language-python">class User(BaseModel):
    id: int = Field(..., description=&quot;사용자 ID (필수)&quot;)
    name: str = Field(..., min_length=1, max_length=20, description=&quot;사용자 이름&quot;)
    age: Optional[int] = Field(None, ge=0, le=150, description=&quot;나이 (없으면 None)&quot;)
    phone: Union[str, int, None] = Field(
        None, description=&quot;전화번호 (문자열 또는 숫자 허용, 없으면 None)&quot;
    )
    role: Literal[&quot;admin&quot;, &quot;user&quot;, &quot;guest&quot;] = Field(
        &quot;user&quot;, description=&quot;권한 (admin, user, guest 중 하나)&quot;
    )
    tags: List[str] = Field(default_factory=list, description=&quot;사용자 태그 목록&quot;)
    preferences: Dict[str, str] = Field(
        default_factory=dict, description=&quot;사용자 환경설정&quot;
    )</code></pre>
<p>이런식으로 타입 정의가 가능한데, 참고로 <code>Union</code> 보다는 간단하게 파이프 연산자를 사용하는 것을 추천한다. 다른 타입도 많은데, <code>FastAPI</code> 데이터 스키마에서는 이 정도면 사용하는 것 같다.</p>
<h3 id="method">Method</h3>
<p><code>Pydantic</code> 은 기본 <code>Method</code> 기능을 제공한다. 오픈소스 코드에서도 이러한 기본 함수를 적극적으로 활용하고 있어서, 반드시 알아둬야 된다.</p>
<h4 id="1-__str__">1. <strong><code>__str__</code></strong></h4>
<p>→ 객체를 print 했을 때 사람이 읽기 좋은 문자열 반환</p>
<pre><code class="language-python">from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    email: str

    def __str__(self):
        return f&quot;User(id={self.id}, name={self.name}, email={self.email})&quot;

u = User(id=1, name=&quot;Alice&quot;, email=&quot;alice@example.com&quot;)
print(u)  
# 출력: User(id=1, name=Alice, email=alice@example.com)</code></pre>
<h4 id="2-model_dump">2. <strong><code>model_dump()</code></strong></h4>
<p>→ 객체 → dict 직렬화</p>
<pre><code class="language-python">data = u.model_dump()
print(data)
# 출력: {&#39;id&#39;: 1, &#39;name&#39;: &#39;Alice&#39;, &#39;email&#39;: &#39;alice@example.com&#39;}</code></pre>
<h4 id="3-model_validate">3. <strong><code>model_validate()</code></strong></h4>
<p>→ dict → 객체 (검증 포함)</p>
<pre><code class="language-python">user_dict = {&quot;id&quot;: 2, &quot;name&quot;: &quot;Bob&quot;, &quot;email&quot;: &quot;bob@example.com&quot;}
u2 = User.model_validate(user_dict)
print(u2)
# 출력: User(id=2, name=Bob, email=bob@example.com)</code></pre>
<p>위의 함수들을 통해 <code>Pydantic</code>의 핵심 기능인, 데이터 검증과 <strong>직렬화/역직렬화</strong>를 간편하게 적용 가능하다.</p>
<h4 id="4-model_validator">4. <strong><code>@model_validator</code></strong></h4>
<p>→ 모델 생성 시 비즈니스 규칙 검증</p>
<pre><code class="language-python">from pydantic import model_validator

class User(BaseModel):
    id: int
    name: str
    email: str

    @model_validator(mode=&quot;before&quot;)
    def check_email(cls, values):
        email = values.get(&quot;email&quot;)
        if email and &quot;@&quot; not in email:
            raise ValueError(&quot;Invalid email address&quot;)
        return values

# 올바른 입력
User(id=3, name=&quot;Charlie&quot;, email=&quot;charlie@example.com&quot;)

# 잘못된 입력 → 예외 발생
User(id=4, name=&quot;Dave&quot;, email=&quot;invalid-email&quot;)
# ValueError: Invalid email address</code></pre>
<p>해당 함수는 <code>model_validate</code> 기능을 제공한다는 의미로 데코레이터로 정의 가능하다. 옆에 <code>(mode=&quot;before&quot;)</code> 는 스키마가 정의되기 전에 실행되는 함수라는 뜻이다. 따라서 이러한 검증 함수를 여러가지 만들 수가 있다.</p>
<h3 id="model-nested">Model Nested</h3>
<p>이제 <code>Pydantic</code>의 가장 강력한 기법이라 볼 수 있는 중첩을 알아보자.</p>
<pre><code class="language-python">class ProjectConfig(BaseModel):
    owner: User
    members: List[User]</code></pre>
<p>위의 방식 처럼 중첩을 사용해서 상위 스키마를 제공이 가능하다. 당연한 기능 같지만, <code>Pydantic</code>의 <code>BaseModel</code> 은 <code>Dict</code> 타입을 위에서 살펴본 모델 검증 과정을 통해 객체형으로 바꿔준다. </p>
<p>만약 위의 스키마대로 <code>Dict</code> 타입을 정의했다고 해보자</p>
<pre><code class="language-python">config = {
    &quot;owner&quot;: {
        &quot;name&quot;: &quot;Alice&quot;,
        &quot;email&quot;: &quot;alice@example.com&quot;
    },
    &quot;members&quot;: [
        {&quot;name&quot;: &quot;Bob&quot;, &quot;email&quot;: &quot;bob@example.com&quot;},
        {&quot;name&quot;: &quot;Charlie&quot;, &quot;email&quot;: &quot;charlie@example.com&quot;}
    ]
}</code></pre>
<p>위에서 정의된 <code>config</code> 에서 <code>members[0]</code> 를 살펴보려면 <code>config[&quot;members&quot;][0]</code> 으로 접근이 가능하다.</p>
<pre><code class="language-python">project = ProjectConfig.model_validate(config)</code></pre>
<p>이제 <code>BaseModel</code>의 <code>model_validate</code> 로 변환을 해보자. 그러면 <code>project</code>는 객체가 되어서 
<code>config.members[0]</code> 으로 접근이 가능하게 된다. 수 많은 <code>FastAPI</code>를 사용한 오픈소스 에서는 이러한 형태로 강력한 타입 설정을 하여, 관리를 하고있다. <code>FastAPI</code> 에서는 <code>resquest</code> 와 <code>response</code> 에 스키마를 설정하여, 자동으로 <code>JSON</code>이 직렬화 된 데이터를 객체 형태로 받게된다. </p>
<hr>
<h2 id="fastapi">FastAPI</h2>
<pre><code class="language-python">from typing import List
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class User(BaseModel):
    id: int 
    name: str 
    email: str 
    age: Optional[int]

class ProjectConfig(BaseModel):
    owner: User
    members: List[User]

class ProjectResponse(BaseModel):
    owner_name: str
    member_count: int

@app.post(&quot;/projects&quot;, response_model=ProjectResponse)
async def create_project(project: ProjectConfig):
    return ProjectResponse(
        owner_name=project.owner.name,
        member_count=len(project.members)
    )</code></pre>
<p>위의 코드를 보면, <code>create_project</code>에서 <code>project</code> 를 <code>ProjectConfig</code> 로 받고 있다. 만약에 클라이언트가 <code>Dict</code> 형태로 인자를 보내게 되면, 위의 포스트 라우터는 자동으로 <code>project</code>를 검증 및 변환하여 <code>ProjectConfig</code> 로 만들어 준다.</p>
<p>경로 옆 <code>response_model</code> 은 응답 타입도 정해주는 설정이다. 반환 값으로 <code>ProjectResponse</code> 스키마대로 반환을 하고 있다. 클라이언트는 해당 응답을 받으면, <code>Dict</code> 형태로 받게 된다. 이렇게도 쓸 수 있다.</p>
<pre><code class="language-python">@app.post(&quot;/projects&quot;, response_model=ProjectResponse)
async def create_project(project: ProjectConfig):
    return {
        &quot;owner_name&quot;: project.owner.name,
        &quot;member_count&quot;: len(project.members)
    }</code></pre>
<p><code>FastAPI</code> 에서 <code>Dict</code> 형으로 반환을 해도, 자동으로 스키마를 감지해준다. 컨벤션에 맞게 두가지를 조율해서 사용하면 될 것 같다. 보통의 API에서는 클라이언트는 항상 <code>JSON</code> 으로 직렬화 된 데이터를 보내므로, 이러한 데이터를 검증하고 좀 더 쉽게 관리가 가능하다. </p>
<p>참고로 상속 기능이 있긴 하지만, 상속 기능은 사용하면 너무 복잡해져서 많이 보진 못한 것 같다. 그래서 설명은 넘어가겠다.</p>
<hr>
<h2 id="마무리-😁">마무리 😁</h2>
<p>오늘은 <code>Pydantic</code>에 대해서 알아보았다. 처음에는 그냥 단순한 타입 정의 라이브러리라 생각하고, 찾아보지 않았다가, 실수나 타입 불일치 오류를 많이 보게 되었는데, 이번 기회에 제대로 알아보고 작업을 할 수 있을 것 같다. 기회가 되면, 회사에서 사용하는 스텍이나 라이브러리들을 하나씩 정리해서 학습을 해야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Design Pattern] Bridge Pattern]]></title>
            <link>https://velog.io/@choi-hyk/Design-Pattern-Bridge-Pattern</link>
            <guid>https://velog.io/@choi-hyk/Design-Pattern-Bridge-Pattern</guid>
            <pubDate>Sun, 31 Aug 2025 07:51:56 GMT</pubDate>
            <description><![CDATA[<h1 id="bridge-pattern-🌉">Bridge Pattern 🌉</h1>
<p>이번에는 <code>Bridge Pattern</code> 에 대해서 알아보겠다. Bridge Pattern은 말 그대로 클래스와 클래스를 가교(Bridge)라는 관계로 정의하는 패턴이다. 한번 생각해보자, 우리가 어떠한 클래스를 상속을 통해 구현을 할때, 깊이 1에 있는 클래스들은 해당 클래스의 원형을 그대로 따라갈 것이다. 근데 만약에, 부모 클래스가(깊이 0) 새로운 개념의 서브 클래스를 생성한다 생각해보자. </p>
<img width="610" height="189" alt="Image" src="https://github.com/user-attachments/assets/efba71ae-dd25-430c-b038-8eecff148e32" />

<p>위의 이미지는 GOF 책에서 예시로 든 사용자 인터페이스 툴킷인 <code>Winodow</code> 클래스의 <strong>클래스 폭발</strong>을 보여준다. 툴킷인 <code>Window</code> 클래스를 사용해서 우리가 각 플랫폼의 특성이 반영된 <code>XWindow</code> 와 <code>PMWindow</code> 플랫폼을 구현했다고 해보자, 해당 구현만 존재하면 사용하는데는 문제가 없을 것이다. 그런데, <code>Window</code> 구현자가 새로운 기능을 담은 <code>Window</code> 인 <code>IconWindow</code> 를 출시 했다. 그러면 우리는 기존의 <code>XWindow</code> 와 <code>PMWindow</code> 를 다시 <code>IconWindow</code> 에 상속 받아서 해당 <code>Icon</code> 기능이 포함된 클래스들을 재정의 해야 한다. 매우 번거롭지 않은가?</p>
<p>그래서 사용되는 패턴이 <strong>Bridge Pattern</strong>이다.</p>
<blockquote>
<p>구현에서 추상을 분리하여, 이들이 독립적으로 다양성을 가질 수 있도록 합니다.</p>
</blockquote>
<p>구현에서 추상을 분리한다는 것은, 구현체와 추상으로 생성된 추가 클래스들을 분리한다는 것이다. 참고로 <strong>Bridge Pattern</strong>은 <strong>핸들/구현부(Handle/Body)</strong> 라는 이름으로도 불린다.</p>
<hr>
<h2 id="언제-사용하나-📌">언제 사용하나? 📌</h2>
<p>책에서는 위에서 말한 예시로 Bridge Pattern을 설명한다. </p>
<img width="576" height="359" alt="Image" src="https://github.com/user-attachments/assets/f9be37fd-6e35-4cd0-bf37-5c98a4402e4b" />

<p>이미지를 보면, <code>Window</code> 의 추상 클래스로 <code>IconWindow</code>, <code>TransientWindow</code> 가 설정되어 있고, <code>Window</code>는 <code>imp</code> 라는 구현체 인스턴스를 가지게 된다. 이 <code>imp</code> 는 <code>WindowImp</code> 를 참조하게 된다. <code>IconWindow</code>, <code>TransientWindow</code>는 기존의 <code>Winodw</code> 에서 제공하는 <code>DrawText()</code> 와 <code>DrawRect()</code> 로 자신들이 제공하는 기능을 구현하고 있다. 여기서 해당 패턴의 핵심이 나오는데, 바로 <code>WindowImp</code>는 <code>DrawRect()</code>를 4개의 <code>DevDrawLine()</code> 으로 구현 중이다. 이것이 <strong>Bridge Pattern</strong> 의 구현부의 역할이다. 구현부는 가장 <strong>저수준의 구현</strong>을 제공하고, 추상부는 해당 구현체들을 활용해서 실질적인 동작을 수행한다. 그리고 이러한 저수준의 구현을 하나의 클래스로 정의하면 해당 클래스의 서브 클래싱을 통해 여러가지 플랫폼에서 활용이 가능하다. </p>
<p>이렇게 함으로써 얻는 가장 큰 이점은, 기능(추상화 계층)과 플랫폼(구현 계층)을 각각 독립적으로 관리할 수 있다는 점이다. 기능이 늘어날 때마다 모든 플랫폼별 클래스를 다시 작성해야 하는 클래스 폭발 문제를 피할 수 있고, 새로운 플랫폼을 지원하는 것도 훨씬 수월하다.</p>
<hr>
<h2 id="구조-🏗️">구조 🏗️</h2>
<img width="600" height="246" alt="Image" src="https://github.com/user-attachments/assets/b7413f5c-919a-422a-a53b-64932b149974" />

<p>구조는 위의 예시를 이해했으면, 바로 파악이 될것이다. 정리하자면, <strong>Bridge Pattern</strong>은 상속으로 인해 <strong>기능 × 플랫폼 조합이 기하급수적으로 늘어나는 문제를 해결하기 위해, 추상 계층과 구현 계층을 분리하고, 이를 가교(imp)로 연결하는 방식</strong>이다. 이 덕분에 기능과 구현을 <strong>분리된 축(axis)</strong>으로 관리할 수 있어 확장성과 유지보수성이 크게 향상된다.</p>
<p>여기서 핵심 포인트는 <strong>추상은 고수준 동작을 정의, 구현은 저수준 세부사항을 담당, 그리고 둘은 런타임에 조합된다 라는 구조</strong>다.</p>
<hr>
<h2 id="구현-💻">구현 💻</h2>
<pre><code class="language-cpp">#include &lt;iostream&gt;
#include &lt;memory&gt;
#include &lt;string&gt;
#include &lt;algorithm&gt;

// -------- Primitive --------
struct Point { int x{}, y{}; };

// -------- Implementor --------
class WindowImp {
public:
    virtual ~WindowImp() = default;
    virtual void DeviceRect(int x0, int y0, int x1, int y1) = 0;
    virtual void DeviceText(const char* s, int x, int y) = 0;
};

// -------- Concrete Implementors --------
class XWindowImp : public WindowImp {
public:
    void DeviceRect(int x0, int y0, int x1, int y1) override {
        int x = std::min(x0, x1);
        int y = std::min(y0, y1);
        int w = std::abs(x1 - x0);
        int h = std::abs(y1 - y0);
        std::cout &lt;&lt; &quot;[X] Rect (&quot; &lt;&lt; x &lt;&lt; &quot;,&quot; &lt;&lt; y &lt;&lt; &quot;) w=&quot; &lt;&lt; w &lt;&lt; &quot; h=&quot; &lt;&lt; h &lt;&lt; &quot;\n&quot;;
    }
    void DeviceText(const char* s, int x, int y) override {
        std::cout &lt;&lt; &quot;[X] Text \&quot;&quot; &lt;&lt; s &lt;&lt; &quot;\&quot; @(&quot; &lt;&lt; x &lt;&lt; &quot;,&quot; &lt;&lt; y &lt;&lt; &quot;)\n&quot;;
    }
};

class PMWindowImp : public WindowImp {
public:
    void DeviceRect(int x0, int y0, int x1, int y1) override {
        int left   = std::min(x0, x1);
        int right  = std::max(x0, x1);
        int bottom = std::min(y0, y1);
        int top    = std::max(y0, y1);
        std::cout &lt;&lt; &quot;[PM] Rect L=&quot; &lt;&lt; left &lt;&lt; &quot; R=&quot; &lt;&lt; right
                  &lt;&lt; &quot; B=&quot; &lt;&lt; bottom &lt;&lt; &quot; T=&quot; &lt;&lt; top &lt;&lt; &quot;\n&quot;;
    }
    void DeviceText(const char* s, int x, int y) override {
        std::cout &lt;&lt; &quot;[PM] Text \&quot;&quot; &lt;&lt; s &lt;&lt; &quot;\&quot; @(&quot; &lt;&lt; x &lt;&lt; &quot;,&quot; &lt;&lt; y &lt;&lt; &quot;)\n&quot;;
    }
};

// -------- Abstraction --------
class Window {
public:
    explicit Window(std::unique_ptr&lt;WindowImp&gt; imp) : imp_(std::move(imp)) {}
    virtual ~Window() = default;

    // 고수준 API
    virtual void DrawRect(const Point&amp; p1, const Point&amp; p2) {
        imp_-&gt;DeviceRect(p1.x, p1.y, p2.x, p2.y);
    }
    virtual void DrawText(const std::string&amp; s, const Point&amp; at) {
        imp_-&gt;DeviceText(s.c_str(), at.x, at.y);
    }
    virtual void DrawContents() = 0; 

protected:
    WindowImp* imp() { return imp_.get(); }

private:
    std::unique_ptr&lt;WindowImp&gt; imp_; 
};

// -------- Refined Abstractions --------
class IconWindow : public Window {
public:
    IconWindow(std::unique_ptr&lt;WindowImp&gt; imp, std::string iconName)
        : Window(std::move(imp)), icon_(std::move(iconName)) {}
    void DrawContents() override {
        DrawText((&quot;ICON:&quot; + icon_), {0, 0});
        DrawRect({0, 0}, {32, 32});
    }
private:
    std::string icon_;
};

class TransientWindow : public Window {
public:
    explicit TransientWindow(std::unique_ptr&lt;WindowImp&gt; imp)
        : Window(std::move(imp)) {}
    void DrawContents() override {
        DrawText(&quot;Transient&quot;, {8, 16});
        DrawRect({4, 4}, {128, 64});
    }
};

// -------- Client --------
int main() {
    // 런타임에 구현 선택 → 같은 추상도 다른 구현과 조합 가능
    IconWindow w1(std::make_unique&lt;XWindowImp&gt;(), &quot;app.png&quot;);
    TransientWindow w2(std::make_unique&lt;PMWindowImp&gt;());

    w1.DrawContents(); // X 구현으로 그리기
    w2.DrawContents(); // PM 구현으로 그리기
    return 0;
}</code></pre>
<p>전체 코드는 이렇게 되는데, 책에서 제시한 코드는 기능이 너무 많아서 간단하게 <code>DrawRect()</code>와 <code>DrawText()</code>만 구현을 했다. 그리고 Refined Abstraction으로 <code>IconWinodw</code> 만 구현을 했다.</p>
<h4 id="implementor">Implementor</h4>
<pre><code class="language-cpp">// -------- Implementor --------
class WindowImp {
public:
    virtual ~WindowImp() = default;
    virtual void DeviceRect(int x0, int y0, int x1, int y1) = 0;
    virtual void DeviceText(const char* s, int x, int y) = 0;
};

// -------- Concrete Implementors --------
class XWindowImp : public WindowImp {
public:
    void DeviceRect(int x0, int y0, int x1, int y1) override {
        std::cout &lt;&lt; &quot;[X] Rect (&quot; &lt;&lt; x0 &lt;&lt; &quot;,&quot; &lt;&lt; y0
                  &lt;&lt; &quot;)-(&quot; &lt;&lt; x1 &lt;&lt; &quot;,&quot; &lt;&lt; y1 &lt;&lt; &quot;)\n&quot;;
    }
    void DeviceText(const char* s, int x, int y) override {
        std::cout &lt;&lt; &quot;[X] Text \&quot;&quot; &lt;&lt; s &lt;&lt; &quot;\&quot; @(&quot; &lt;&lt; x &lt;&lt; &quot;,&quot; &lt;&lt; y &lt;&lt; &quot;)\n&quot;;
    }
};

class PMWindowImp : public WindowImp {
public:
    void DeviceRect(int x0, int y0, int x1, int y1) override {
        std::cout &lt;&lt; &quot;[PM] Rect (&quot; &lt;&lt; x0 &lt;&lt; &quot;,&quot; &lt;&lt; y0
                  &lt;&lt; &quot;)-(&quot; &lt;&lt; x1 &lt;&lt; &quot;,&quot; &lt;&lt; y1 &lt;&lt; &quot;)\n&quot;;
    }
    void DeviceText(const char* s, int x, int y) override {
        std::cout &lt;&lt; &quot;[PM] Text \&quot;&quot; &lt;&lt; s &lt;&lt; &quot;\&quot; @(&quot; &lt;&lt; x &lt;&lt; &quot;,&quot; &lt;&lt; y &lt;&lt; &quot;)\n&quot;;
    }
};</code></pre>
<p>Bridge Pattern의 <strong>Implementation(구현부)</strong> 는 <code>WindowImp</code>라는 인터페이스를 중심으로 구성된다. 이 클래스는 <code>DeviceRect</code>, <code>DeviceText</code>와 같이 플랫폼 의존적인 저수준 API(Application Programming Interface)를 정의한다. 그리고 실제 구현은 <code>XWindowImp</code>, <code>PMWindowImp</code>에서 이루어진다. 예를 들어 <code>XWindowImp</code>는 X 윈도우 시스템 호출을, <code>PMWindowImp</code>는 프레젠테이션 매니저 호출을 각각 캡슐화한다. 즉, <strong>어떻게 그릴 것인가</strong>라는 부분을 담당하는 것이 바로 구현부이며, 추상부와 독립적으로 교체하거나 확장할 수 있다.</p>
<h4 id="abstraction">Abstraction</h4>
<pre><code class="language-cpp">// -------- Abstraction --------
class Window {
public:
    explicit Window(std::unique_ptr&lt;WindowImp&gt; imp) : imp_(std::move(imp)) {}
    virtual ~Window() = default;

    virtual void DrawRect(const Point&amp; p1, const Point&amp; p2) {
        imp_-&gt;DeviceRect(p1.x, p1.y, p2.x, p2.y);
    }
    virtual void DrawText(const std::string&amp; s, const Point&amp; at) {
        imp_-&gt;DeviceText(s.c_str(), at.x, at.y);
    }
    virtual void DrawContents() = 0;

protected:
    WindowImp* imp() { return imp_.get(); }

private:
    std::unique_ptr&lt;WindowImp&gt; imp_;
};</code></pre>
<p><strong>Abstraction(추상부)</strong>는 <code>Window</code> 클래스가 담당한다. <code>Window</code>는 클라이언트에 노출되는 고수준 인터페이스를 정의하며, <code>DrawRect</code>, <code>DrawText</code> 같은 메서드를 통해 기능을 제공한다. 하지만 직접 그리기를 수행하지 않고, 내부에 <code>std::unique_ptr&lt;WindowImp&gt;</code>를 보관해 실제 동작을 구현부에 위임한다. 이렇게 하면 클라이언트는 <code>Window</code>의 API만 이용하면 되고, 저수준 동작은 구현부에서 알아서 처리된다.</p>
<h4 id="refined-abstraction">Refined Abstraction</h4>
<pre><code class="language-cpp">// -------- Refined Abstractions --------
class IconWindow : public Window {
public:
    IconWindow(std::unique_ptr&lt;WindowImp&gt; imp, std::string iconName)
        : Window(std::move(imp)), icon_(std::move(iconName)) {}
    void DrawContents() override {
        DrawText((&quot;ICON:&quot; + icon_), {0, 0});
        DrawRect({0, 0}, {32, 32});
    }
private:
    std::string icon_;
};

class TransientWindow : public Window {
public:
    explicit TransientWindow(std::unique_ptr&lt;WindowImp&gt; imp)
        : Window(std::move(imp)) {}
    void DrawContents() override {
        DrawText(&quot;Transient&quot;, {8, 16});
        DrawRect({4, 4}, {128, 64});
    }
};</code></pre>
<p><code>IconWindow</code>와 <code>TransientWindow</code> 같은 <strong>Refined Abstraction</strong>은 <code>Window</code>를 상속받아 고수준의 행위를 구체화한다. 예를 들어 <code>IconWindow</code>는 아이콘을 그리는 동작을 정의하고, <code>TransientWindow</code>는 임시 창을 그리는 방식을 정의한다. 하지만 이들도 직접 저수준 연산을 구현하지 않고, <code>imp()</code>를 통해 내부의 <code>WindowImp</code>에 작업을 위임한다. 이렇게 추상부는 “무엇을 할 것인지”를 정의하고, 구현부는 “어떻게 할 것인지”를 책임지게 되는 구조가 된다. 물론 나는 <code>IconWindow</code> 만 구현을 한 상태이다.</p>
<h4 id="client">Client</h4>
<pre><code class="language-cpp">// -------- Client --------
int main() {
    IconWindow w1(std::make_unique&lt;XWindowImp&gt;(), &quot;app.png&quot;);
    TransientWindow w2(std::make_unique&lt;PMWindowImp&gt;());

    w1.DrawContents(); // X 플랫폼 구현으로 동작
    w2.DrawContents(); // PM 플랫폼 구현으로 동작
    return 0;
}</code></pre>
<p>마지막으로 클라이언트는 실행 시점에 <code>IconWindow</code>나 <code>TransientWindow</code>를 생성하면서 원하는 구현체(<code>XWindowImp</code> 혹은 <code>PMWindowImp</code>)를 주입할 수 있다. 이렇게 <strong>런타임 조합(Runtime Composition)</strong> 을 활용하면, 기능 축(추상)과 플랫폼 축(구현)을 완전히 독립적으로 확장할 수 있으며, 기능 × 플랫폼 조합에 따라 모든 클래스를 미리 만들어야 하는 클래스 폭발 문제를 방지할 수 있다.</p>
<hr>
<h1 id="마무리-😘">마무리 😘</h1>
<p><strong>Bridge Pattern</strong>은 <strong>추상과 구현을 분리해서 독립적으로 확장할 수 있도록 만들어주는 구조적 패턴</strong>이다. 예시에서 보았듯이, <strong>추상화 계층과 구현 계층을 분리해두면 새로운 기능을 추가하더라도 클래스가 불필요하게 늘어나지 않고 훨씬 유연하게 확장할 수 있다</strong>. 즉, 어댑터 패턴이 기존 인터페이스의 불일치를 해결하기 위한 사후적 접근이었다면, <strong>Bridge Pattern은 처음부터 확장을 고려한 선제적 설계 방식</strong>이라고 볼 수 있다.</p>
<p>다음 글에서는 마찬가지로 구조 패턴 중 하나인 <code>Composite Pattern</code>을 다뤄볼 생각이다. <code>Composite Pattern</code>은 객체들을 <strong>트리 구조로 묶어서 부분-전체 계층을 표현</strong>하는 데 초점이 맞춰져 있다. 즉, <strong>개별 객체와 객체 집합을 동일한 방식으로 다룰 수 있게 해주는 패턴</strong>인데, 이를 통해 복잡한 계층 구조도 단순하게 다룰 수 있는 장점이 있다.</p>
<p><a href="https://www.cs.unc.edu/~stotts/GOF/hires/pat4bfso.htm">[참고] Bridge Pattern</a> </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LLM] LoRA (Low Rank Adaptation)]]></title>
            <link>https://velog.io/@choi-hyk/LLM-LoRA-Low-Rank-Adaptation</link>
            <guid>https://velog.io/@choi-hyk/LLM-LoRA-Low-Rank-Adaptation</guid>
            <pubDate>Sun, 31 Aug 2025 06:37:05 GMT</pubDate>
            <description><![CDATA[<h1 id="🪶-lora-low-rank-adaptation">🪶 LoRA (Low Rank Adaptation)</h1>
<p>이번에는 저번 글에서 말한 것 처럼<strong>LoRA(Low Rank Adaptation)</strong> 에 대해서 알아보겠다.</p>
<p><a href="https://arxiv.org/abs/2106.09685">「LoRA: Low-Rank Adaptation of Large Language Models (Hu et al., 2021)」</a></p>
<p><strong>LoRA</strong>는 위의 논문 <strong>「LoRA: Low-Rank Adaptation of Large Language Models (Hu et al., 2021)</strong> 에서 제시된 기존의 Fine-tuning을 개선한 방식이다. <strong>LoRA</strong>가 제시되기 이전에는 Fine-tuning은 전체의 파라미터를 조정하는 <strong>Full Fine-Tuning</strong>으로 이루어졌다. 이름만 들어도 전체 파라미터를 조정한다는 말에서 알 수 있듯, 비용이 어마어마하게 많이 들었다. 그래서 해당 논문에서는 LoRA를 사용해 저차원 부분행렬을 통해 일부 가중치만 조정하는 기법을 소개한다.</p>
<p>따라서 해당 논문을 바탕으로 LoRA에 대해서 정리를 해보겠다.</p>
<hr>
<h2 id="📘-introduction-of-lora">📘 Introduction of LoRA</h2>
<p>논문에서는 LoRA가 제시된 이유로 기존의 파인튜닝 방식인 Full Fine-Tuning의 단점을 먼저 이야기한다. 크게 두 가지 단점이 있었는데, <strong>첫 번째로 엄청난 양의 연산 비용을 요구한다</strong>는 점이다. 두 번째로 <strong>테스트를 위해 각 파라미터를 조정할 때 모델을 저장하여 성능 지표를 측정해야 한다</strong>는 점이다. 이는 <strong>GPT-2와 RoBERTa</strong>와 같은 LLM이 적은 기간 내에 계속 출시되면서 파라미터 수가 급격히 증가해 점점 어려워졌다. 이를 완화하기 위해 일부 파라미터만 저장하는 <strong>Adapting</strong>이라는 기법이 있었으나, 모델의 깊이가 증가하면 성능이 떨어지는 문제가 있었다.</p>
<img width="323" height="282" alt="Image" src="https://github.com/user-attachments/assets/9c74354d-1049-4606-a38f-dcc470aa8790" />  

<p>위 그림은 LoRA를 간단히 설명하는 그림이다. 그림을 보면 $d$ 차원의 $x$ 입력이 각각 Pretrained로 설정된 가중치 $W$와 $A$에 input으로 들어간다. 여기서 LoRA의 핵심 개념을 알 수 있는데, 바로 기존의 $W$는 <strong>freeze</strong> 시키고 입력을 새로운 layer에 입력으로 넣어 저차원 공간으로 축소(Down Projection)하는 행렬 $A \in \mathbb{R}^{r \times d}$를 거친다는 점이다. 참고로 $d$는 출력 값의 차원이고, $r$은 Down Projection 했을 때의 차원이다. 이렇게 $d$ 차원의 입력을 $r$ 차원으로 줄여낸 뒤, 다시 $B \in \mathbb{R}^{d \times r}$ 행렬을 통해 원래 출력 차원 $d$로 확장(Up Projection)한다. 결국 전체 업데이트 행렬은 $\Delta W = BA$ 형태가 된다.</p>
<p>$$
h = W_0 x + BAx
$$</p>
<p>위의 식이 최종적으로 LoRA가 가중치를 구하는 방법이다. 기존의 방법인 Full Fine-Tuning은 다음과 같다.</p>
<p>$$
h = W_0 x
$$</p>
<p>위 식은 가중치 전체를 조정하는 <strong>Full Fine-Tuning</strong>을 나타낸다.</p>
<p>이 그림은 <strong>원래 가중치 $W_0$는 동결시키고, 작은 두 개의 행렬 $A, B$만 학습</strong>해서 기존 선형 변환 결과에 보정값을 더해주는 구조를 단순하게 보여준다. 이를 통해 큰 모델 전체를 건드리지 않고도 파라미터 효율적인 학습이 가능하다는 것이 LoRA의 핵심이다.</p>
<p>여기서 핵심은 바로 $r$인데, 논문에서는 파라미터가 175B이고 출력 차원 수가 12,288인 GPT-3에서도 $r = 1$ 또는 $r = 2$ 정도의 매우 작은 값으로도 성능이 유지된다고 한다. 사실 이렇게 들으면, 왜 성능이 유지되는지 의문이 될 정도로 터무니없게 차이가 크다.</p>
<p>논문에서는 이러한 원리를 LoRA를 고안할 때 영감을 받은 <strong>「Measuring the Intrinsic Dimension of Objective Landscapes (ICLR 2018)」</strong>, 그리고 <strong>「Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning (ACL 2021)」</strong>에서 언급한 <strong>LLM 모델의 Fine-Tuning은 저차원 공간에서 이루어진다</strong>는 이유로 설명한다. 두 논문에서는 <strong>거대한 네트워크라도 학습할 때는 저차원 공간(subspace)에서만 움직여도 충분하다는 발견</strong>, 그리고 <strong>대규모 언어모델 파인튜닝에서도 실제로 필요한 변화는 낮은 intrinsic dimension 안에서 발생한다는 것을 실험적으로 입증</strong>했다고 한다. 따라서 사실상 Fine-Tuning은 학습할 입력값에 대해 전체 차원을 사용할 필요가 없다는 것이다.</p>
<p>LoRA는 기본적으로 <strong>기존 파인튜닝보다 더 일반화된 방식</strong>이다. 풀 파인튜닝이 전체 파라미터를 학습하거나 일부만 선택해서 학습하는 것이라면, LoRA는 한 단계 더 나아가서 <strong>가중치 행렬 업데이트가 꼭 풀랭크(full-rank)일 필요가 없다</strong>는 점에 주목한다. 즉, <strong>모든 가중치 행렬에 LoRA를 적용하고 bias까지 학습하며, rank $r$을 원래 가중치 행렬의 랭크 수준으로 높게 잡아버리면 사실상 풀 파인튜닝과 같은 표현력을 회복할 수 있다.</strong> 다시 말해, <strong>학습 가능한 파라미터 수를 늘릴수록 LoRA는 풀 파인튜닝에 점점 가까워진다.</strong></p>
<hr>
<h2 id="📊-results">📊 Results</h2>
<img width="842" height="348" alt="Image" src="https://github.com/user-attachments/assets/14d1ef61-3963-4c20-a4e9-b4f9c6a1fed3" />  

<p><img src="https://velog.velcdn.com/images/choi-hyk/post/0c0f6136-a4c5-40ab-82de-b90b66ea701a/image.png" alt=""></p>
<p>실험 결과를 보면 LoRA가 왜 이렇게 주목받는지 바로 알 수 있다. 먼저 평가 지표에 대해서 설명하겠다.</p>
<p><strong>WikiSQL은 자연어 질문을 SQL 쿼리로 바꾸는 데이터셋</strong>이다. 예를 들어 <strong>2010년에 개봉한 영화 제목 알려줘</strong>라는 문장이 들어오면 모델은 그걸 SQL 쿼리 형태로 바꿔야 한다. 그래서 단순히 언어 이해만 보는 게 아니라 <strong>데이터베이스 쿼리까지 연결하는 능력</strong>을 평가한다.</p>
<p><strong>MultiNLI는 두 문장의 의미적 관계를 따지는 데이터셋</strong>이다. <strong>나는 점심을 먹었다</strong>와 <strong>나는 밥을 안 먹었다</strong>는 모순, <strong>나는 점심을 먹었다</strong>와 <strong>나는 음식을 먹었다</strong>는 함의, 그리고 서로 관련 없는 문장은 중립으로 분류한다. 결국 모델이 <strong>문장 간 의미를 얼마나 정확히 파악하는지를 측정</strong>한다.</p>
<p><strong>SAMSum은 대화 요약 데이터셋</strong>이다. 메신저 대화처럼 짧은 대화가 주어지고, 모델은 그걸 요약해야 한다. 예를 들어 <strong>A: 오늘 뭐해? B: 영화 볼 건데. A: 같이 가자</strong>라는 대화가 있으면 <strong>A와 B가 같이 영화를 보기로 했다</strong>라고 요약하는 식이다. <strong>짧은 대화를 읽고 핵심만 뽑아내는 능력</strong>을 평가한다. 그리고 이런 요약 과제 성능을 볼 때 쓰는 게 R1, R2, RL이다. R1은 정답 요약과 단어 단위로 얼마나 겹치는지를 보는 지표이고, R2는 연속된 두 단어 bigram이 겹친 비율을 본다. RL은 최장 공통 부분 수열(Longest Common Subsequence)을 기반으로 해서 문장 구조 자체가 비슷한지를 평가한다. 결국 R1은 단어 겹침, R2는 구 겹침, RL은 문장 구조 겹침이라고 보면 된다.</p>
<p>우선 <strong>WikiSQL</strong> 결과부터 보면, Full Fine-Tuning은 가장 높은 성능을 보여주지만 파라미터 수가 엄청나다. 반면에 LoRA는 <strong>훨씬 적은 파라미터만 학습했음에도 불구하고 Full Fine-Tuning에 거의 근접한 정확도</strong>를 달성했다. Adapter(H)도 LoRA와 비슷하게 좋은 성능을 보이지만, Prefix 계열(PrefixEmbed, PrefixLayer)은 상대적으로 낮은 정확도를 보인다.</p>
<p><strong>MultiNLI-matched</strong> 결과는 더 극적이다. Full Fine-Tuning이 여전히 좋은 성능을 내지만, LoRA와 Adapter(H)는 보다 높은 정확도를 <strong>훨씬 더 효율적인 파라미터 사용</strong>으로 달성했다. 특히 LoRA는 실질적으로 Full Fine-Tuning 수준의 성능을 뛰어넘으면서도 필요한 파라미터 수는 압도적으로 적다.</p>
<p>그리고 <strong>SAMSum</strong>에서도 LoRA는 Full Fine-Tuning보다 더 높은 수준의 정확도를 보였다.</p>
<p>즉, LoRA는 단순히 파라미터를 줄이는 수준이 아니라, <strong>적은 자원으로도 풀 파인튜닝급 성능을 낼 수 있다</strong>는 걸 명확히 보여준다. 이런 점에서 실제 대규모 모델을 다룰 때 LoRA가 가지는 실용성은 엄청나다고 할 수 있다.</p>
<p>그리고 나는 FT가 항상 좋은 줄 알았는데, 찾아보니 과적합으로 인해 오히려 FT의 성능이 안 좋아질 수도 있다고 한다. 입력 데이터가 적을 경우 FT는 과적합이 일어날 가능성이 있지만 LoRA는 데이터셋이 적어도 적절한 학습이 가능하다.</p>
<hr>
<h1 id="📝-마무리">📝 마무리</h1>
<p>오늘은 LoRA에 대해 논문과 실험 결과를 중심으로 정리해 보았다. LoRA는 단순히 파라미터 효율성을 제공하는 수준을 넘어, 실제로 Full Fine-Tuning에 맞먹거나 그 이상의 성능을 적은 자원으로 달성할 수 있음을 보여준다. 특히, 데이터셋 크기가 제한적이거나 리소스가 부족한 상황에서 매우 강력한 대안이 될 수 있다.</p>
<p>다음 글에서는 오늘 정리한 내용을 바탕으로 <strong>실제 코드를 통해 LoRA를 활용한 파인튜닝 방법</strong>을 자세히 살펴보겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Mini Project] Velog Backup 프로그램 만들기]]></title>
            <link>https://velog.io/@choi-hyk/Mini-Project-Velog-Backup-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@choi-hyk/Mini-Project-Velog-Backup-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sat, 30 Aug 2025 14:36:55 GMT</pubDate>
            <description><![CDATA[<h1 id="📦-velog-backup-프로그램-만들기">📦 Velog Backup 프로그램 만들기</h1>
<p>오늘은 갑자기 미니 프로젝트를 하고 싶어져서, Velog 포스트들을 백업해주는 프로그램을 만들려고 한다. 갑자기 미니 프로젝트를 하는 이유는 딱히 없다. 그냥 해보고 싶어졌다. 프로그램 목적은 사용자의 이름을 환경변수로 주면 GrphQL로 Velog 정보를 가져와서 시리즈 별 디렉토리에 포스트들을 저장하는 방식이다.</p>
<p>그리고 <strong>Python Package Index</strong> 를 통해 배포를 해보고 <strong>GitHub Actions</strong> 로 자동화 까지 가능하도록 구현할 생각이다.</p>
<hr>
<h2 id="🔍-graphql">🔍 GraphQL</h2>
<p>내가 GitHub Pages를 만들 때, 그때도 Velog 포스트들을 가져와서 GitHub Pages에 출력 해주는 API를 만들었다. 그떄는 RSS를 사용해서 가져왔다. RSS는 근데 시리즈랑 프로필에 대한 상세한 정보가 없어서, 단순하게 내가 제목에 대괄호로 자체 태그를 만드는 걸 이용해서 포스트들의 시리즈를 구분했다. </p>
<p>이번 프로젝트는 다른 사람들이 전부 사용 가능하도록 GraphQL을 활용해서 시리즈를 추출할 생각이다. 참고로 GraphQL은 <strong>Facebook(현 Meta)</strong> 가 2012년 개발해서, 2015년 공개한 API 쿼리 언어라고 한다. 클라이언트가 필요한 데이터만 정확히 요청할 수 있도록 설계된 데이터 질의 언어인데, 서버와 클라이언트 간 데이터 통신을 더 유연하고 효율적으로 만들어 준다...</p>
<p>이번에 처음 써보는데, 클라이언트가 원하는 데이터만 응답해주는 것이 특징이다. 내가 느낀 건, GraphQL은 마치 &quot;필요한 만큼만 담아오는 주문표&quot; 같은 느낌이다. REST API에서는 <code>/posts</code> 요청하면 정해진 형식대로 모든 데이터가 쏟아지는데, GraphQL은 <code>title</code>이나 <code>tags</code>만 원하면 그것만 딱 주고, <code>series</code>까지 원하면 그것도 같이 준다. 그래서 불필요한 데이터 전송이 줄고, 필요한 관계형 데이터도 한 번에 가져올 수 있다. 대신 스키마랑 쿼리를 직접 설계해야 해서, 초반에는 좀 낯설고 복잡하게 느껴질 수도 있다. 하지만 익숙해지면 데이터 흐름이 훨씬 깔끔해지고, 특히 내가 이번에 시리즈 정보까지 정리해서 가져오려는 것처럼, RSS보다 훨씬 세밀하게 제어할 수 있는 게 장점이다.</p>
<pre><code class="language-python">import requests

ENDPOINT = &quot;https://v2.velog.io/graphql&quot;

def gql(query: str, variables: dict | None = None) -&gt; dict:
    &quot;&quot;&quot;
    GraphQL 쿼리를 실행하는 함수

    Args:
        query (str): GraphQL 쿼리 문자열
        variables (dict | None, optional): 쿼리 변수

    Returns:
        data[&quot;data&quot;] (dict): GraphQL 응답 데이터
    &quot;&quot;&quot;
    payload = {&quot;query&quot;: query, &quot;variables&quot;: variables or {}}
    res = requests.post(ENDPOINT, json=payload, timeout=15)
    res.raise_for_status()
    data = res.json()
    if &quot;errors&quot; in data:
        msgs = &quot;; &quot;.join(e.get(&quot;message&quot;, &quot;&quot;) for e in data[&quot;errors&quot;])
        raise RuntimeError(f&quot;GraphQL 오류: {msgs}&quot;)
    return data[&quot;data&quot;]</code></pre>
<p>위의 코드는 Velog의 정보를 가져오는 GraphQL 실행 함수이다. 저기 <code>payload</code> 에 내가 원하는 데이터의 정보를 넣게 되면, 해당 정보를 응답해 준다.</p>
<h3 id="query">QUERY</h3>
<pre><code class="language-python">PROFILE_QUERY = &quot;&quot;&quot;
query UserProfile($username: String!) {
    user(username: $username) {
        id
        username
        profile {
            display_name
            thumbnail
        }
    }
}
&quot;&quot;&quot;

LIST_QUERY = &quot;&quot;&quot;
query Posts($username: String!, $limit: Int!, $cursor: ID) {
    posts(username: $username, limit: $limit, cursor: $cursor) {
        id
        url_slug
    }
}
&quot;&quot;&quot;

DETAIL_QUERY = &quot;&quot;&quot;
query ReadPost($username: String!, $slug: String!) {
    post(username: $username, url_slug: $slug) {
        id
        url_slug
        title
        thumbnail
        tags
        series { name }
        released_at
        updated_at
        is_markdown
        body
        likes
    }
}
&quot;&quot;&quot;</code></pre>
<p>앞에서 말한 것 처럼 GraphQL에서는 Route가 없고, 클라이언트가 무슨 payload를 보내느냐에 따라 오는 응답이 달라진다. 나는 Velog 사용자의 프로필과, 모든 포스트 정보, 그리고 각 포스트의 컨텐츠 3개가 필요하다. 프로필은 <code>PROFILE_QUERY</code>를 통해 요청이 가능했다. 간단하게 Velog 유저 이름을 보내면 프로필 정보를 보내준다. 다음은 <code>LIST_QUERY</code>를 통해서 모든 포스트 정보를 가져왔다. 해당 쿼리가 제일 복잡한데, 이유는 GraphQL은 리스트를 요청할때 한번에 요청이 가능한 한도가 정해져 있어서 <code>cursor</code> 와 <code>limit</code>로 메세지 큐를 보내는 것처럼 잘라서 받아야 한다. 그래서 <code>cursor</code> 와 <code>limit</code> 가 <code>LIST_QUERY</code>를 보면 설정되어 있다. 마지막으로 <code>DETAIL_QUERY</code>는 <code>url_slug</code> 라는 <code>LIST_QUERY</code>에서 가져온 포스트들의 url로 해당 포스트의 컨텐츠를 가져온다. 이렇게 모든 정보를 가져오면 이제 간단하다. 각 시리즈들을 폴더로 만들고, 해당 폴더 안에 시리즈에 해당하는 포스트들을 md파일로 생성하면 된다. 매우 고맙게도 GraphQL은 응답을 md파일로 해줘서 매우 편했다. <del>RSS를 사용할때는 html형식을 md로 바꿔야해서 짜증이 났다.</del></p>
<hr>
<h2 id="⚙️-pyprojecttoml">⚙️ pyproject.toml</h2>
<p>이제 해당 프로젝트를 빌드를 하고, 빌드 파일을 배포해보겠다. 파이썬은 배포 환경이 매우 잘 되어 있는데 빌드를 <strong>PyPI</strong>에 업로드 하며 우리가 흔히 파이썬 패키지를 다운 받을 때 사용하는 <code>pip install</code>이 가능하다. </p>
<p>파이썬에서 패키지를 배포할 때는 <code>pyproject.toml</code> 파일을 작성해야 한다. 이게 일종의 <strong>패키지 설정서</strong> 역할을 하는데, 프로젝트 이름부터 버전, 의존성, 빌드 방식까지 전부 여기에 정의한다. 내가 작성한 항목들을 하나씩 보면 이렇다:</p>
<pre><code class="language-bash">[project]
name = &quot;velog_sync&quot;
version = &quot;0.1.0&quot;
description = &quot;Velog 글을 Markdown으로 백업 (시리즈별 폴더) — velog_sync PyPI 패키지 실행&quot;
readme = &quot;README.md&quot;
requires-python = &quot;&gt;=3.10&quot;
authors = [{ name = &quot;choi-hyk&quot;, email = &quot;blindlchoil@gmail.com&quot; }]
license = { file = &quot;LICENSE&quot; }   
classifiers = [
  &quot;License :: OSI Approved :: MIT License&quot;,
]
dependencies = [
  &quot;requests&gt;=2.32.0&quot;,
  &quot;tzdata&gt;=2024.1&quot;   
]

[project.scripts]
velog-sync = &quot;velog_sync:main&quot;

[tool.setuptools]
py-modules = [&quot;velog_sync&quot;]

[build-system]
requires = [&quot;setuptools&gt;=68&quot;, &quot;wheel&quot;]
build-backend = &quot;setuptools.build_meta&quot;</code></pre>
<hr>
<h3 id="project">[project]</h3>
<h4 id="name-velog_sync">name: <code>&quot;velog_sync&quot;</code></h4>
<ul>
<li>PyPI에 올라갈 패키지 이름. <code>pip install velog-sync</code> 할 때 쓰이는 이름이다.</li>
<li>참고로 언더바 ( _ ) 는 하이픈 ( - )으로 바뀐다<h4 id="version-010">version: <code>&quot;0.1.0&quot;</code></h4>
</li>
<li>패키지 버전. SemVer(주버전.부버전.패치버전) 규칙을 따른다.  </li>
</ul>
<h4 id="description-패키지-간단-설명">description: 패키지 간단 설명.</h4>
<h4 id="readme-readmemd">readme: <code>&quot;README.md&quot;</code></h4>
<ul>
<li>PyPI 페이지에 표시될 문서.  </li>
</ul>
<h4 id="requires-python-310">requires-python: <code>&quot;&gt;=3.10&quot;</code></h4>
<ul>
<li>파이썬 최소 버전. 여기서는 Python 3.10 이상만 지원하도록 했다.  </li>
</ul>
<h4 id="authors-작성자-정보-이름과-이메일을-적을-수-있다">authors: 작성자 정보. 이름과 이메일을 적을 수 있다.</h4>
<h4 id="license---file--license-">license = { file = &quot;LICENSE&quot; }</h4>
<ul>
<li>라이선스 파일을 명시해준다.</li>
</ul>
<h4 id="classifiers">classifiers</h4>
<ul>
<li>라이선스의 종류를 명시해준다.</li>
</ul>
<h4 id="dependencies">dependencies:</h4>
<ul>
<li><code>requests&gt;=2.32.0</code>: HTTP 요청용 라이브러리  </li>
<li><code>tzdata&gt;=2024.1</code>: 타임존 데이터용 라이브러리</li>
<li>패키지를 설치할 때 자동으로 같이 설치된다.</li>
</ul>
<hr>
<h3 id="projectscripts">[project.scripts]</h3>
<pre><code class="language-bash">velog-sync = &quot;velog_sync:main&quot;</code></pre>
<p>이 프로젝트가 단일 파이썬 파일(velog_sync.py)로 구성되어 있다는 걸 명시한다. 패키지 디렉토리 구조가 아니라 .py 모듈을 main 함수로 실행하면 위와 같이 적는다. 함수는 본인이 알아서 설정 가능하다.</p>
<hr>
<h3 id="build-system">[build-system]</h3>
<pre><code class="language-bash">requires = [&quot;setuptools&gt;=68&quot;, &quot;wheel&quot;]
build-backend = &quot;setuptools.build_meta&quot;</code></pre>
<p>빌드할 때 어떤 툴을 사용할지 지정한다. <code>setuptools</code>와 <code>wheel</code>이 필요하다고 정의했고, <code>setuptools.build_meta</code>를 빌드 백엔드로 사용한다고 명시했다. 이 설정 덕분에 <code>python -m build</code> 명령으로 <code>.tar.gz</code>와 <code>.whl</code> 빌드 파일을 만들 수 있다.</p>
<p>여기서 중요한 건, <code>build-system.requires</code>에 적은 패키지들이 실제 실행 환경에 필요한 건 아니라는 점이다. 이건 어디까지나 <strong>빌드 과정에서만 필요한 도구</strong>라서, 패키지를 설치하는 사람 입장에서는 신경 쓸 필요가 없다. 그리고 <code>setuptools.build_meta</code>는 일종의 빌드 엔진 역할을 하는데, <code>pip install .</code> 같은 명령을 실행했을 때 내부적으로 <code>build_wheel</code>, <code>build_sdist</code> 같은 함수를 호출해서 배포 파일을 만들어준다.</p>
<hr>
<h2 id="🚀-pypi-배포하기">🚀 PyPI 배포하기</h2>
<p>배포를 하려면 PyPI에 계정을 만들고, Token을 받아서 등록을 해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/choi-hyk/post/cc358957-8460-4e96-a68a-22a30a7ac5cd/image.png" alt=""></p>
<p>이제 해당 토큰을 자신의 로컬에 등록을 하면 된다.</p>
<pre><code>이 토큰을 사용하세요.
이 API 토큰을 사용하려면:

__token__에 사용자 이름을 설정합니다
pypi- 접두사를 포함하여 비밀번호를 토큰 값으로 설정하세요
예를 들어, 프로젝트를 PyPI에 업로드하기 위해 Twine을 사용하는 경우, $HOME/.pypirc 파일을 다음과 같이 설정하세요:

[pypi]
  username = __token__
  password = TOKEN</code></pre><p>위의 설정을 보고 로컬에 등록을 하면 로컬에서 배포가 가능하다. 먼저 빌드를 통해 코드를 배포 가능한 형태인 <code>.tar.gz</code>, <code>.whl</code>로 만들어야 한다.</p>
<h3 id="1-빌드">1. 빌드</h3>
<p><code>python -m build</code> 를 실행하면 dist/ 디렉토리에 아래와 같은 파일이 생긴다.</p>
<ul>
<li>velog_sync-0.1.0.tar.gz (소스 배포본)</li>
<li>velog_sync-0.1.0-py3-none-any.whl (휠 파일)</li>
</ul>
<hr>
<h3 id="2-업로드">2. 업로드</h3>
<p>이제 twine을 사용해서 PyPI에 업로드한다:</p>
<pre><code class="language-bash">twine upload dist/*</code></pre>
<p>여기서 <code>.pypirc</code> 파일에 등록해둔 토큰이 자동으로 사용된다. 업로드가 성공하면 PyPI 패키지 페이지에 바로 반영된다. </p>
<hr>
<h3 id="3-설치-확인">3. 설치 확인</h3>
<p>업로드가 끝나면 실제로 잘 올라갔는지 pip로 설치해본다:</p>
<pre><code class="language-bash">pip install velog-sync</code></pre>
<p>설치가 잘 되고, 내가 지정한 <code>velog-sync</code> 명령어까지 정상 실행되면 배포 완료다.</p>
<hr>
<h2 id="🤖-github-actions-배포">🤖 GitHub Actions 배포</h2>
<pre><code class="language-yaml">name: Publish to PyPI   # 워크플로우 이름 (GitHub Actions 탭에 표시됨)

on:
    push:
        tags: [&quot;v*&quot;]    # 태그가 v로 시작하는 커밋이 push될 때 실행됨 (예: v0.1.0, v1.0.0)

jobs:
    pypi-publish:
        name: Upload release to PyPI  # 잡 이름
        runs-on: ubuntu-latest        # 실행 환경: 최신 Ubuntu GitHub Runner 사용

        permissions:
            contents: read            # 리포지토리 컨텐츠 읽기 권한
            id-token: write           # OIDC(OpenID Connect) 토큰 발급 권한 → PyPI에 인증용

        steps:
            # 1. 코드 체크아웃
            - uses: actions/checkout@v4
              # GitHub Actions 런너에 현재 레포지토리 코드 가져오기

            # 2. Python 설치
            - uses: actions/setup-python@v5
              with:
                  python-version: &quot;3.12&quot;   # 파이썬 3.12 환경 구성

            # 3. 빌드 단계
            - name: Build
              run: |
                  python -m pip install --upgrade pip  # pip 최신화
                  pip install build                    # build 패키지 설치
                  python -m build                      # pyproject.toml 기반으로 dist/에 빌드 산출물 생성

            # 4. PyPI 업로드
            - name: Publish to PyPI
              uses: pypa/gh-action-pypi-publish@release/v1
              with:
                  skip-existing: true  # 이미 업로드된 파일이 있으면 스킵(중복 업로드 방지)</code></pre>
<p>위와 같이 구성이 가능한데, 살펴볼 점은 태그랑 인증 방법이다. GitHub Actions는 태그 설정을 통해 배포 자동화가 이루어진다.  예를 들어 <code>git tag v0.1.0</code> 을 하게 되면, 바뀐 버전이 해당 액션으로 자동 배포가 이루어진다.</p>
<p>다음은 PyPI의 인증 방식인데, 기존에 로컬에서는 Token을 발급받아서, 배포를 하였는데, PyPI는 GitHub Actions와 같이 자동화 툴들을 위해  <strong>PyPI Trusted Publisher</strong>라는 방법을 제공한다. 예전처럼 <code>.pypirc</code>에 비밀번호 저장하는 게 아니라, GitHub OIDC(OpenID Connect) 토큰을 이용해서 <strong>PyPI Trusted Publisher</strong>로 인증한다. 즉, GitHub 저장소와 PyPI 계정을 연결해두면 비밀번호/토큰 노출 없이 안전하게 배포 가능하다.  <strong>PyPI Trusted Publisher</strong> 를 사용하려면 자신의 PyPI 계정에 해당 GitHub repo를 등록하면 된다.</p>
<img width="838" height="217" alt="Image" src="https://github.com/user-attachments/assets/678f20aa-f48b-470c-a03c-3005dca06da4" />

<p>난 이렇게 등록을 하였다. </p>
<p>실행을 할때는 패치된 버전의 코드와 <code>pyproject.toml</code> 의 버전을 올리고 push와 push tag를 해줘야 한다. 참고로 <code>git tag</code> 명령어를 통해 tag를 등록하고 기존의 푸쉬 방법 처럼 <code>git push origin v0.1.0</code> 과 같은 방법으로 배포를 해줄 수 있다. 이때 주의할 점은 반드시 패치된 버전의 코드와 <code>pyproject.toml</code> 의 버전을 푸쉬해 놓은 상태여야 한다.</p>
<hr>
<h2 id="🔄-github-actions로-velog-sync-자동화-하기">🔄 GitHub Actions로 velog-sync 자동화 하기</h2>
<p>이제 로컬 배포와 GitHub Actions 배포도 구성을 하였으니, 실제로 사용자들이 쓸 수 있도록 GitHub Actions의 yml 파일을 제공하면 된다. 로컬에서 사용할 사람은 로컬에서 실행해서 백업을 진행하면 되고 나는 사용자들이 매일 03:00 시에 자동으로 Velog 포스트들을 GitHub repo에 업로드 되도록 yml 파일을 구성하였다.</p>
<pre><code class="language-yml">name: velog-sync (daily KST 03:00)

on:
    schedule:
        - cron: &quot;0 18 * * *&quot; # 매일 03:00 KST
    workflow_dispatch: {}

permissions:
    contents: write

jobs:
    sync:
        runs-on: ubuntu-latest
        environment: velog_sync
        steps:
            - name: Checkout
              uses: actions/checkout@v4

            - name: Set up Python
              uses: actions/setup-python@v5
              with:
                  python-version: &quot;3.11&quot;

            - name: Install velog-sync
              run: |
                  python -m pip install --upgrade pip
                  pip install velog-sync

            - name: Run velog-sync
              env:
                  VELOG_USERNAME: ${{ vars.VELOG_USERNAME }}
              run: velog-sync

            - name: Configure Git
              run: |
                  git config user.name &quot;github-actions[bot]&quot;
                  git config user.email &quot;41898282+github-actions[bot]@users.noreply.github.com&quot;

            - name: Rebase with remote main
              run: |
                  git pull --rebase --autostash origin main

            - name: Commit if changed
              env:
                  TZ: Asia/Seoul
              run: |
                  if [ -n &quot;$(git status --porcelain)&quot; ]; then
                    DATE_KST=&quot;$(date +&#39;%Y-%m-%d %H:%M:%S %Z&#39;)&quot;
                    git add -A
                    git commit -m &quot;chore: velog sync @ ${DATE_KST}&quot;
                    git push
                  else
                    echo &quot;No changes to commit.&quot;
                  fi</code></pre>
<p>yml 파일에서는 내가 만든 패키지인 <code>velog-sync</code>를 다운받고 해당 패키지를 사용해서 등록한 유저 환경변수를 통해 GitHub에 업로드 해준다.</p>
<img width="846" height="824" alt="Image" src="https://github.com/user-attachments/assets/3196d68c-5232-404d-806f-31739d6b9677" />

<p>배포가 완료된 모습이다. 아래 링크에서 확인 가능하다.
<a href="https://github.com/choi-hyk/Velog">https://github.com/choi-hyk/Velog</a></p>
<hr>
<h1 id="🏁-마무리">🏁 마무리</h1>
<p>오늘은 velog-sync라는 패키지를 만들고 배포까지 해보았는데, repo를 확인하고 이슈가 등록되면 개선해 나갈 생각이다. 그리고 지금은 Velog 가 조회수를 보여주는 API가 없지만, access_token을 통해 조회수를 확인 가능하다고 들었다. 그래서 해당 패키지에 access_token을 등록하여 조회수를 확인하는 기능을 넣고 싶다. 해당 패키지는 아래 링크에서 확인 가능하고, 이슈가 있으면 언제든지 등록을 해주길 바란다.</p>
<p><a href="https://pypi.org/project/velog-sync/">[PyPI] velog-sync</a>
<a href="https://github.com/choi-hyk/velog_sync">[GitHub] velog-sync</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Design Pattern] Adapter Pattern]]></title>
            <link>https://velog.io/@choi-hyk/Design-Pattern-Adapter-Pattern</link>
            <guid>https://velog.io/@choi-hyk/Design-Pattern-Adapter-Pattern</guid>
            <pubDate>Sun, 24 Aug 2025 08:29:55 GMT</pubDate>
            <description><![CDATA[<h1 id="adapter-pattern-🪛">Adapter Pattern 🪛</h1>
<p>이번에는 <code>Adapter Pattern</code>에 대해서 알아보겠다. GOF 디자인패턴 책에서는 구조패턴을 설명할 때 <strong>Adapter Pattern</strong> 을 제일 먼저 설명한다. <strong>Adapter Pattern</strong>은 말 그대로 기존의 클래스 인터페이스에 다른 라이브러리나 인터페이스를 결합하기 위해 사용하는 패턴이다. 그래서 구조는 매우 직관적이다. 기존에 우리가 사용할 인터페이스와 결합할 인터페이스를 다중 상속 받는 <strong>클래스 어댑터</strong>를 생각해 볼 수 있고, 다른 방법으로는 결합할 인터페이스를 인스턴스로 가지고 있는 <strong>객체 어댑터</strong>를 생각해 볼 수 있다</p>
<blockquote>
<p>클래스의 인터페이스를 사용자가 기대하는 인터페이스 형태로 적응(변한)시킵니다. 서로 일치하지 않는 인터페이스를 갖는 클래스들을 함께 동작시킵니다.</p>
</blockquote>
<hr>
<h2 id="언제-사용하나-📌">언제 사용하나? 📌</h2>
<p>책에서는 어댑터 패턴을 <code>Shape</code>라는 그래픽을 관리하는 클래스에 <code>TextView</code> 기능을 결합하는 예제로 설명을 한다.</p>
<img width="626" height="236" alt="Image" src="https://github.com/user-attachments/assets/a31f7502-6532-41d8-92a3-caadc49ebae5" />

<p>위의 그림은 <strong>객체 어댑터를</strong> 표현하고 있다. 그 이유는 <code>TextShape</code>가 <code>TextView</code>를 상속하지 않고 포함(Composition) 하고 있기 때문이다. 즉, TextShape 안에 <code>TextView</code> 인스턴스를 멤버 변수로 두고, <code>Shape</code>의 인터페이스를 구현하면서 내부적으로 <code>TextView</code>의 기능을 호출해주는 방식이다.</p>
<p>반면에 <strong>클래스 어댑터</strong> 방식이라면 <code>TextShape</code>가 <code>Shape</code>를 상속함과 동시에 <code>TextView</code>도 상속받아야 한다. 즉, 다중 상속을 이용해서 <code>TextView</code> 기능을 바로 가져오는 구조이다. 하지만 이렇게 하면 유연성이 떨어지고, 언어 제약(자바는 다중 상속 불가) 때문에 현실적으로 잘 안 쓰이는 경우가 많다.</p>
<hr>
<h2 id="구조-🏗️">구조 🏗️</h2>
<h4 id="클래스-어댑터">클래스 어댑터</h4>
<img width="543" height="197" alt="Image" src="https://github.com/user-attachments/assets/37a23640-e4f8-4e9b-9439-b43edd57c22c" />

<h4 id="객체-어댑터">객체 어댑터</h4>
<img width="524" height="197" alt="Image" src="https://github.com/user-attachments/assets/67f292f1-5c1f-495a-bccd-59f669cf7ad6" />

<p>구조는 매우 간단하다. 클래스 어댑터는 상속(Inheritance) 을 이용해서 구현하고, 객체 어댑터는 합성(Composition) 을 이용해서 구현한다. 즉, 클래스 어댑터는 이미 존재하는 클래스를 직접 상속받아 새로운 인터페이스를 맞추는 방식이고, 객체 어댑터는 기존 클래스를 멤버 변수로 두고 그 객체의 기능을 위임(delegate)하는 방식이다.</p>
<p>클래스 어댑터는 상속을 쓰는 만큼 <strong>기존 클래스의 세부 구현에 강하게 묶인다.</strong> 대신 <strong>성능상 조금 더 단순하고 직접적이다.</strong></p>
<p>객체 어댑터는 <strong>합성을 쓰기 때문에 더 유연하고, 다른 클래스와도 쉽게 조합할 수 있다. 다형성을 활용하기에도 적합하다.</strong></p>
<p>정리하면, <strong>&quot;빠르고 단순하게&quot;라면 클래스 어댑터</strong>, <strong>&quot;유연하고 확장성 있게&quot;라면 객체 어댑터</strong>를 쓰는 게 맞다. </p>
<hr>
<h2 id="구현-💻">구현 💻</h2>
<pre><code class="language-cpp">#include &lt;iostream&gt;
#include &lt;string&gt;
#include &lt;memory&gt;

using namespace std;

struct Point { int x{}, y{}; };
struct Size  { int w{}, h{}; };
struct Rect  { int x1{}, y1{}, x2{}, y2{}; };

ostream&amp; operator&lt;&lt;(ostream&amp; os, const Rect&amp; r) {
    return os &lt;&lt; &quot;Rect{(&quot; &lt;&lt; r.x1 &lt;&lt; &quot;,&quot; &lt;&lt; r.y1 &lt;&lt; &quot;) ~ (&quot; &lt;&lt; r.x2 &lt;&lt; &quot;,&quot; &lt;&lt; r.y2 &lt;&lt; &quot;)}&quot;;
}

class Manipulate;
class TextManipulator;

class Shape{
    public:
        ~Shape()  = default;
        virtual void boundingBox() const = 0;
        virtual unique_ptr&lt;Manipulate&gt; createManipulate() const = 0;
};

class Manipulate {
    public:
        Manipulate() = default;               
        virtual ~Manipulate() = default;
        virtual void manipulate() const {
            std::cout &lt;&lt; &quot;Shape 조작\n&quot;;
        }
};

class TextManipulator : public Manipulate
{
    public:
        void manipulate() const override {
            std::cout &lt;&lt; &quot;TextShape 조작\n&quot;;
        }
};

class Line : public Shape
{
    public:
        Line(Point p1, Point p2) : p1_(p1), p2_(p2) {}

        void boundingBox() const override {
            Rect r{
                min(p1_.x, p2_.x),
                min(p1_.y, p2_.y),
                max(p1_.x, p2_.x),
                max(p1_.y, p2_.y)
            };
            std::cout &lt;&lt; &quot;[Line] boundingBox = &quot; &lt;&lt; r &lt;&lt; &quot;\n&quot;;
        }

        unique_ptr&lt;Manipulate&gt; createManipulate() const override {
            return make_unique&lt;Manipulate&gt;();
        }

    private:
        Point p1_{}, p2_{};
};

class TextView{
    public: 
        virtual ~TextView() = default;
        Point getOrigin() const { return origin_; }
        Size  getExtent() const { return extent_; }

        virtual bool isEmpty() const = 0;

    protected:
        void setOrigin(Point p) { origin_ = p; }
        void setExtent(Size s)  { extent_ = s; }

    private:
        Point origin_{0, 0};
        Size  extent_{0, 0};
};

class TextShape : public Shape, private TextView
{
    public:
        TextShape(Point origin, Size extent, bool empty = false) : empty_(empty) {
            setOrigin(origin);
            setExtent(extent);
        }

    void boundingBox() const override {
        Point o = getOrigin();
        Size  s = getExtent();
        Rect r{o.x, o.y, o.x + s.w, o.y + s.h};
        cout &lt;&lt; &quot;[TextShape] origin=(&quot; &lt;&lt; o.x &lt;&lt; &quot;,&quot; &lt;&lt; o.y
            &lt;&lt; &quot;), extent=(&quot; &lt;&lt; s.w &lt;&lt; &quot;,&quot; &lt;&lt; s.h &lt;&lt; &quot;) -&gt; boundingBox = &quot;
            &lt;&lt; r &lt;&lt; &quot;\n&quot;;
        }

        unique_ptr&lt;Manipulate&gt; createManipulate() const override {
            return make_unique&lt;TextManipulator&gt;();
        }

        bool isEmpty() const override {
            return empty_;
        }

    private:
        bool empty_{false};
};

int main() {
    unique_ptr&lt;Shape&gt; s1 = make_unique&lt;Line&gt;(Point{10, 5}, Point{2, 20});
    s1-&gt;boundingBox();
    s1-&gt;createManipulate()-&gt;manipulate();

    unique_ptr&lt;Shape&gt; s2 = make_unique&lt;TextShape&gt;(Point{100, 200}, Size{50, 20});
    s2-&gt;boundingBox();
    s2-&gt;createManipulate()-&gt;manipulate();

    return 0;
}</code></pre>
<p>책에서 예제로 든 <code>Shape</code>에 <code>TextView</code>를 결합하는 <strong>클래스 어댑터</strong>이다. <code>Shape</code> 는 2개의 기능을 제공하는데 <code>Shape</code>를 생성하면 경계선 박스를 만드는 함수 <code>boundingBox()</code> 그리고 <code>Shape</code>를 이동시키거나 조작하는 조작기를 생성하는 <code>createManipulate()</code> 이 2가지의 기능을 제공한다. 이때 기존에 원래 존재하는 <code>Line</code>은 <code>Shape</code>의 기능을 그대로 상속받아 구현하고 있다. 우리는 <code>TextShape</code>라는 어댑터를 통해 <code>TextView</code>를 <code>Shape</code>에서 사용할 수 있도록 하는 것이 목표이다.</p>
<h4 id="adaptee">Adaptee</h4>
<pre><code class="language-cpp">class TextView{
    public: 
        virtual ~TextView() = default;
        Point getOrigin() const { return origin_; }
        Size  getExtent() const { return extent_; }

        virtual bool isEmpty() const = 0;

    protected:
        void setOrigin(Point p) { origin_ = p; }
        void setExtent(Size s)  { extent_ = s; }

    private:
        Point origin_{0, 0};
        Size  extent_{0, 0};
};</code></pre>
<p><code>TextView</code>는 3개의 기능이 존재하는데, 자신의 위치와 크기를 알려주는<code>getOrigin()</code>, <code>getExtent()</code> 두가지 기능과 텍스트가 채워져 있는지 아닌지를 알려주는 <code>isEmpty()</code>가 있다. 따라서 Target인 <code>Shape</code> 가 제공하는 두가지 기능인  <code>boundingBox()</code> 와 <code>createManipulator()</code>를 연동하기 위해서 기존의 <code>TextView</code>의 기능을 적절히 조합해서 만들거나 아예 새로운 코드를 넣어서 기능을 연동시켜야 한다.</p>
<h4 id="adapter">Adapter</h4>
<pre><code class="language-cpp">class TextShape : public Shape, private TextView
{
    public:
        TextShape(Point origin, Size extent, bool empty = false) : empty_(empty) {
            setOrigin(origin);
            setExtent(extent);
        }

    void boundingBox() const override {
        Point o = getOrigin();
        Size  s = getExtent();
        Rect r{o.x, o.y, o.x + s.w, o.y + s.h};
        cout &lt;&lt; &quot;[TextShape] origin=(&quot; &lt;&lt; o.x &lt;&lt; &quot;,&quot; &lt;&lt; o.y
            &lt;&lt; &quot;), extent=(&quot; &lt;&lt; s.w &lt;&lt; &quot;,&quot; &lt;&lt; s.h &lt;&lt; &quot;) -&gt; boundingBox = &quot;
            &lt;&lt; r &lt;&lt; &quot;\n&quot;;
        }

        unique_ptr&lt;Manipulate&gt; createManipulate() const override {
            return make_unique&lt;TextManipulator&gt;();
        }

        bool isEmpty() const override {
            return empty_;
        }

    private:
        bool empty_{false};
};</code></pre>
<p><code>TextShape</code>는 말한 것 처럼 다중상속을 통해 <code>Shape</code> 와 <code>TextView</code>를 받고 있다. 여기서 중요한 점이 Adaptee인 TextView는 Private로 해야 한다. 이유는 당연히 Target이 Adaptee를 Adater를 통해 사용할 때 내부의 구조를 알 필요가 없기 때문이다. <code>boundingBox()</code>를 보면 <code>TextView</code>의 <code>getOrigin()</code> 와 <code>getExtent()</code>를 사용해서 위치와 크기를 얻고 경계 박스를 구현하는 것으로 연동을 완료했다. 그런데 <code>createManipulator()</code>는 기존의 기능으로 연동이 불가능 하므로 새로운 <code>TextManipulator</code>를 생성해서 연동해야 한다.</p>
<h4 id="textmanipulator">TextManipulator</h4>
<pre><code class="language-cpp">class TextManipulator : public Manipulate
{
    public:
        void manipulate() const override {
            std::cout &lt;&lt; &quot;TextShape 조작\n&quot;;
        }
};</code></pre>
<p>이렇게 만든 <code>TextManipulator</code>를 통해 완벽히 연동이 되었다. 이제 클라이언트는 기존에 Shape를 이용하는 방식으로 TextView를 이용가능하다.</p>
<h4 id="client">Client</h4>
<pre><code class="language-cpp">int main() {
    unique_ptr&lt;Shape&gt; s1 = make_unique&lt;Line&gt;(Point{10, 5}, Point{2, 20});
    s1-&gt;boundingBox();
    s1-&gt;createManipulate()-&gt;manipulate();

    unique_ptr&lt;Shape&gt; s2 = make_unique&lt;TextShape&gt;(Point{100, 200}, Size{50, 20});
    s2-&gt;boundingBox();
    s2-&gt;createManipulate()-&gt;manipulate();

    return 0;
}</code></pre>
<p><code>s1</code>으로 <code>Line</code>을 만들고 <code>boundingBox()</code> 와 <code>createManipulate()</code>를 사용하고 있다. 그리고 <code>s2</code>로 <code>TextShape</code>를 만들고 똑같이 <code>boundingBox()</code> 와 <code>createManipulate()</code>를 사용하고 있다. 이렇게 완벽히 연동이 되었다. </p>
<p>이번에는 객체 어댑터는 어떻게 구현하는지 알아보자.</p>
<h4 id="adapter-1">Adapter</h4>
<pre><code class="language-cpp">class TextShape : public Shape{
public: 
    TextShape(shared_ptr&lt;TextView&gt; tv) : tv_(std::move(tv)) {}

    void boundingBox() const override {
        Point o = tv_-&gt;getOrigin();
        Size  s = tv_-&gt;getExtent();
        Rect r{o.x, o.y, o.x + s.w, o.y + s.h};
        cout &lt;&lt; &quot;[TextShape(ObjectAdapter)] origin=(&quot; &lt;&lt; o.x &lt;&lt; &quot;,&quot; &lt;&lt; o.y
            &lt;&lt; &quot;), extent=(&quot; &lt;&lt; s.w &lt;&lt; &quot;,&quot; &lt;&lt; s.h &lt;&lt; &quot;) -&gt; boundingBox = &quot;
            &lt;&lt; r &lt;&lt; &quot;\n&quot;;
    }

    unique_ptr&lt;Manipulate&gt; createManipulate() const override {
        return make_unique&lt;TextManipulator&gt;();
    }

    bool empty() const { return tv_-&gt;isEmpty(); }

private:
    shared_ptr&lt;TextView&gt; tv_;
};</code></pre>
<p><code>TextShape</code>는 <code>TextView</code>를 공유 포인터로 생성하면서 생성된다. 따라서 <code>TextView</code>를 합성하여 인스턴스로 가지고 있다.</p>
<h4 id="client-1">Client</h4>
<pre><code class="language-cpp">int main() {
    unique_ptr&lt;Shape&gt; s1 = make_unique&lt;Line&gt;(Point{10, 5}, Point{2, 20});
    s1-&gt;boundingBox();
    s1-&gt;createManipulate()-&gt;manipulate();

    auto tv = make_shared&lt;SimpleTextShape&gt;(Point{100, 200}, Size{50, 20});
    unique_ptr&lt;Shape&gt; s2 = make_unique&lt;TextShape&gt;(tv);
    s2-&gt;boundingBox();
    s2-&gt;createManipulate()-&gt;manipulate();

    return 0;
}</code></pre>
<p>따라서 먼저 <code>TextShape</code>를 생성한 다음 <code>Shape</code>에 주입을 해야 한다. 만약에 <code>TextView</code> 여러개의 서브클래스로 다양한 기능이 있다고 해보자. </p>
<pre><code class="language-cpp">class SimpleTextView : public TextView {
public:
    explicit SimpleTextView(Point origin, Size extent, bool empty = false)
        : empty_(empty)
    {
        setOrigin(origin);
        setExtent(extent);
    }

    bool isEmpty() const override { return empty_; }

private:
    bool empty_{false};
};</code></pre>
<p>이렇게 <code>SimpleTextView</code>라는 TextView의 기능을 확장해주는 서브클래스를 바로 주입이 가능하다. 그러면 우리는 객체 어댑터를 통해 TextShape를 여러가지 형태로 만들 수 있을 것이다. 이것이 클래스 어댑터에는 없는 객체 어댑터의 장점이다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>어댑터 패턴은 서로 다른 인터페이스를 가진 클래스들을 연결해주는 역할을 한다고 보면 된다. 클래스 어댑터는 상속으로, 객체 어댑터는 합성으로 풀어내는데, 결국 상황에 따라 어떤 방식을 선택할지가 달라진다. 내가 글에서 보여준 것처럼, <code>Shape</code>와 <code>TextView</code>를 연동할 때도 두 가지 방식 모두 동작은 되지만, 유연성과 확장성을 생각하면 <strong>객체 어댑터 쪽이 좀 더 현실적이</strong>라고 할 수 있다.</p>
<p>다음 글에서는 구조 패턴 중에서 <code>Bridge Pattern</code>을 소개할 생각이다. 브리지 패턴은 이름처럼 <strong>추상과 구현을 분리해서 독립적으로 확장할 수 있게</strong> 만들어주는 패턴인데, 어댑터 패턴과 비교하면 더 일반화된 구조를 갖는다. 즉, 인터페이스 불일치를 해결하는 게 목적이었던 어댑터와 달리, <strong>브리지는 애초에 확장 가능성을 열어두는 구조 설계</strong>에 초점이 맞춰져 있다</p>
<p><a href="https://www.cs.unc.edu/~stotts/GOF/hires/pat4afso.htm">[참고] Adapter Pattern</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LLM] Fine-tuning]]></title>
            <link>https://velog.io/@choi-hyk/LLM-Fine-tuning</link>
            <guid>https://velog.io/@choi-hyk/LLM-Fine-tuning</guid>
            <pubDate>Sat, 23 Aug 2025 07:37:30 GMT</pubDate>
            <description><![CDATA[<h1 id="🖥️-fine-tuning">🖥️ Fine-tuning</h1>
<p>Transformer 구조를 이해했으니, 이제 자연스럽게 <strong>&quot;그럼 이렇게 만들어진 모델을 어떻게 내 태스크에 맞게 쓰는가?&quot;</strong> 라는 질문이 생긴다. 바로 여기서 <strong>Fine-tuning(파인튜닝)</strong> 이라는 개념이 나온다.</p>
<p>LLM이나 Transformer 모델은 처음에 <strong>Corpus</strong> 로 학습된다. 이 과정에서 모델은 언어의 전반적인 패턴, 문법, 의미 관계를 배우게 되는데, 이걸 <strong>사전학습(Pre-training)</strong> 이라고 한다. 앞의 글에서 살펴본 <strong>Transformer</strong> 과정이 사전학습을 진행하는 과정이다. 하지만 이렇게 학습된 모델을 바로 특정 태스크에 쓰기는 어렵다. 그 이유는 <strong>모든 도메인 지식</strong>을 아우를 수는 없기 때문이다. <strong>Corpus</strong>를 통해 LLM들은 방대한 양의 지식을 가지고 있지만 새로 생성된 지식이나, 특정 구조에 맞춰서 입력을 만들어야하는 모델이 필요하다면, 기존의 LLM 모델들을 자신의 목적에 맞게 업그레이드(?) 하고 싶을 것이다.</p>
<p>그래서 하는 게 바로 <strong>Fine-tuning</strong>이다. <del>물론 엄밀히 말하면 업그레이드는 아니다.</del> 원리를 간단히 말하면 이렇다.</p>
<p><strong>Transformer 내부는 기본적으로 Attention, FFN, Embedding 같은 블록으로 구성</strong>되어 있고, 이 블록들 안에는 수많은 <strong>가중치(Weight)</strong> 가 들어있다. Pre-training에서 이미 이 가중치들이 언어를 잘 다룰 수 있게 학습되어 있다. Fine-tuning에서는 이렇게 <strong>이미 구성된 LLM</strong> 에 <strong>내 태스크 데이터셋을 다시 넣고, 역전파(Backpropagation)</strong> 를 통해 가중치를 조금씩 조정한다. 여기서 역전파를 많이 들어봤을텐데. 사실 Pre-training 과정에서 이미 모델은 역전파를 통해 가중치를 조정한다. 파인튜닝은 이러한 역전파를 새로운 데이터셋으로 시도하는 것이라 보면 된다. <strong>Transformer</strong> 에서 모델을 완성하기 위해 <strong>최초의 Corpus로부터 각 layer의 가중치를 업데이트 하는 것을 반복하는 것이 Pre-training에서 진행되는 과정</strong> 이고, <strong>Fine-tuning은 완성된 모델을 다시 원하는 출력을 만드는 모델로 바꾸기 위해 새로운 입력을 넣고 가중치를 업데이트 하는 것을 반복하는 것</strong> 이다. 따라서 이전 글의 Transformer의 구조만 잘 이해하고 있으면, Fine-tuning은 이해하기 쉬울 것이다. 그러므로 이번 글에서는 Fine-tuning 자체 보다는 <strong>역전파(Backpropagation)</strong> 의 과정을 설명할 생각이다.</p>
<hr>
<h2 id="🖇️-역전파backpropagation">🖇️ 역전파(Backpropagation)</h2>
<p>사실 ** Fine-tuning이라는 개념은 Transformer가 등장하기 전부터 존재했다.** 예전에는 CNN 같은 컴퓨터 비전 모델을 학습할 때도, 대규모 데이터셋으로 학습된 모델의 가중치를 가져와 새로운 이미지 분류 작업에 맞게 일부 층만 조정하는 방식으로 활용했다. 즉, fine-tuning 자체는 오래된 개념이지만, 현대 LLM에서는 <strong>Transformer 아키텍처 위에서 이루어진다는 점이 다르다.</strong> 따라서 우리는 현대 모델들이 사용하는Transformer 구조를 기반으로 이해하면 된다. </p>
<p>Transformer를 기반으로 하는 Fine-tuning을 구체적으로 보면 <strong>Self-Attention, Multi-head Attention</strong> 같은 구조는 그대로 두고, 내부 가중치 행렬 $W^Q, W^K, W^V$, 그리고 Feed Forward Network(FFN)의 $W_1, W_2$ 같은 파라미터들이 <strong>다시 학습 대상</strong>이 된다. 이때 학습 과정은 Pre-training 때와 똑같이 <strong>순전파 → 손실 계산 → 역전파 → 가중치 업데이트</strong>로 돌아간다. 차이는 단지 <strong>데이터셋의 목적</strong>이다. Pre-training 때는 일반 텍스트 전체, Fine-tuning 때는 특정 태스크에 맞는 데이터라는 점이 다르다.</p>
<p>용어들에 대해서 헷갈릴 것 같은데, 정리를 해보겠다. </p>
<ul>
<li><p><strong>순전파(Forward Propagation)</strong>: 입력 데이터를 모델에 넣어서 예측값을 뽑아내는 과정. Transformer라면 입력 토큰이 Self-Attention, Multi-head Attention, FFN 등을 거쳐서 최종 출력 확률로 바뀌는 걸 의미.</p>
</li>
<li><p><strong>손실(Loss)</strong>: 모델의 예측과 정답 사이의 차이를 수치로 나타낸 값. <strong>Cross-Entropy</strong> 같은 걸 많이 쓰고, <strong>이 값이 클수록 모델이 정답과 멀리 있는 것.</strong></p>
</li>
<li><p><strong>역전파(Backpropagation)</strong>: 손실 값을 기준으로 <strong>어떤 가중치가 얼마나 잘못했는지</strong> 를 계산해서 뒤로 흘려보내는 과정. 각 층의 $W^Q, W^K, W^V, W_1, W_2$ 같은 파라미터가 손실에 얼마나 기여했는지 <strong>기울기를 구함.</strong></p>
</li>
<li><p><strong>가중치 업데이트(Weight Update)</strong>: 역전파로 구한 기울기를 바탕으로 실제 파라미터 값을 조금씩 수정하는 단계. 보통 <strong>SGD, Adam 같은 옵티마이저</strong> 가 이 역할을 담당.</p>
</li>
</ul>
<p>손실 계산부터 어떻게 파인튜닝이 진행되는지 살펴보자</p>
<h3 id="손실-계산">손실 계산</h3>
<p>예측 확률 $p$와 정답 레이블 $y$를 비교해서 손실 $\ell$을 구한다.</p>
<ul>
<li>Cross-Entropy 손실 기준으로는 $\ell = -\sum y \log p$.</li>
</ul>
<p>음의 로그 함수를 사용해서, 정답 클래스의 $p$를 음의 로그 스케일로 보면, 정답 확률이 0에 가까워 지면, 손실 $\ell$은 무한대로 증가하고 1에 가까워지면 $\ell$은 0에 가까워질 것이다.</p>
<hr>
<h3 id="오차-신호gradient-계산">오차 신호(Gradient) 계산</h3>
<p>손실을 로짓 $z$에 대해 미분하면</p>
<p>   $$
   \frac{\partial \ell}{\partial z} = p - y
   $$</p>
<p>이 값이 나온다 (값을 도출하는 것은 논문이나 다른 글에서 확인바람...). 이게 바로 역전파로 흘러가는 오차 신호다. 로짓 $z$ 는 <strong>예측 확률 $p$ 를 소프트맥스를 하여 확률 분포로 나타내기 직전의 상태</strong> 이다. 따라서 확률 데이터가 아닌, 단순히 <strong>점수(score)로서 정답과 얼마나 가까운지를 상대적으로 보여주는 값</strong> 이라 보면 된다. 따라서 <strong>&quot;손실률에 대해 로짓을 미분했다&quot;</strong> 는 것은, <strong>로짓 값이 바뀔 때 손실이 얼마나 영향을 받는지를 수치로 나타내는 것</strong> 이다.</p>
<p>클래스를 개(dog), 고양이(cat), 소(cow)로 두고 예시를 들어보자. 정답은 고양이라고 하자.</p>
<h4 id="1-모델이-낸-로짓-점수">1. 모델이 낸 로짓 (점수)</h4>
<p>$$
z = [2.0,; 1.0,; -0.5]
$$</p>
<ul>
<li>개 = 2.0</li>
<li>고양이 = 1.0</li>
<li>소 = -0.5</li>
</ul>
<p>로짓은 아직 확률이 아니라 “점수” 같은 거라 보면 된다. 여기서는 개가 가장 높은 점수를 가지고 있다. 따라서 오차가 있는 출력 값이다.</p>
<h4 id="2-소프트맥스-→-확률-분포">2. 소프트맥스 → 확률 분포</h4>
<p>$$
p = \text{softmax}(z) = [0.62,; 0.34,; 0.04]
$$</p>
<ul>
<li>개일 확률 = 62%</li>
<li>고양이일 확률 = 34%</li>
<li>소일 확률 = 4%</li>
</ul>
<p>정답은 고양이인데, 모델은 개가 더 맞다고 본 상황이므로 이를 통해서 오차 신호를 계산할 수 있다.</p>
<h4 id="3-오차-신호-계산">3. 오차 신호 계산</h4>
<p>$$
p-y = [0.62-0,; 0.34-1,; 0.04-0] = [0.62,; -0.66,; 0.04]
$$</p>
<h4 id="4-해석">4. 해석</h4>
<ul>
<li><strong>개(dog): $+0.62$</strong> → 정답이 아닌데 너무 높게 잡았다. 점수를 내려야 한다.</li>
<li><strong>고양이(cat): $-0.66$</strong> → 정답인데 확률이 낮다. 점수를 올려야 한다.</li>
<li><strong>소(cow): $+0.04$</strong> → 정답이 아닌데 살짝 점수를 줬다. 조금 줄여야 한다.</li>
</ul>
<p>결국 $p-y$는 단순한 차이가 아니라, <strong>가중치를 어느 방향으로 바꿔야 하는지 알려주는 오차 신호</strong>다.</p>
<ul>
<li>정답 클래스는 음수 → 점수를 올려야 한다.</li>
<li>오답 클래스는 양수 → 점수를 내려야 한다.</li>
</ul>
<hr>
<h3 id="역전파-진행">역전파 진행</h3>
<p>이 오차 신호가 FFN, Multi-head Attention, Embedding까지 거꾸로 내려가면서 각 파라미터의 기울기 $\frac{\partial \ell}{\partial W}$를 계산한다.</p>
<h4 id="경사하강법으로-가중치-업데이트">경사하강법으로 가중치 업데이트</h4>
<p>계산된 기울기를 바탕으로 옵티마이저가 가중치를 업데이트한다. 가장 기본적인 SGD는 다음과 같다.</p>
<p>   $$
   W \leftarrow W - \eta \cdot \frac{\partial \ell}{\partial W}
   $$</p>
<ul>
<li>$\eta$: 학습률(learning rate).</li>
<li>기울기가 양수면 가중치를 줄이고, 음수면 가중치를 늘려서 손실을 줄이는 방향으로 움직인다.</li>
</ul>
<p>위의 예시로 다시 살펴보면 이렇게 된다.</p>
<p>$$
g ;=; p-y ;=; [,0.62,; -0.66,; 0.04,]
$$</p>
<p>$$
\frac{\partial \ell}{\partial b} = g,\qquad
$$</p>
<p>$$
W \leftarrow W - \eta,\frac{\partial \ell}{\partial W}
\quad,\quad
b \leftarrow b - \eta,\frac{\partial \ell}{\partial b}
$$</p>
<p>예를 들어 $\eta=0.1$이면</p>
<p>$$
b&#39; = b - 0.1,[,0.62,; -0.66,; 0.04,]
= \big[b_1-0.062,; b_2+0.066,; b_3-0.004\big]
$$</p>
<ul>
<li><strong>개(dog)</strong>: $g_1=+0.62\Rightarrow b_1$ 감소(점수↓)</li>
<li><strong>고양이(cat)</strong>: $g_2=-0.66\Rightarrow b_2$ 증가(점수↑)</li>
<li><strong>소(cow)</strong>: $g_3=+0.04\Rightarrow b_3$ 소폭 감소(점수↓)</li>
</ul>
<h4 id="g_k0-오답을-과대평가-⇒-z_k-감소">$g_k&gt;0$ (오답을 과대평가) ⇒ $z_k$ <strong>감소</strong></h4>
<h4 id="g_k0-정답을-과소평가-⇒-z_k-증가">$g_k&lt;0$ (정답을 과소평가) ⇒ $z_k$ <strong>증가</strong></h4>
<p>이후 $z&#39;$에 softmax를 다시 적용하면, <strong>고양이 확률이 올라가고 개/소는 내려가는</strong> 방향으로 조정된다.</p>
<p>한마디로 요약하자면</p>
<p><strong>정답인데 확률을 높여야 된다!</strong></p>
<ul>
<li>로짓 $z$를 크게 만들어서 손실률을 줄여야 하므로 기울기 $g$가 음수 </li>
<li>기울기가 음수 이므로 경사하강법 $W -\eta{g}$ 적용 했을 때, <strong>가중치가 증가 함</strong> </li>
</ul>
<p><strong>오답인데 확률이 너무 높다!</strong></p>
<ul>
<li>로짓 $z$를 작게 만들어서 손실률을 줄여야 하므로 기울기 $g$가 양수</li>
<li>기울기가 양수이므로  경사하강법 $W -\eta{g}$ 적용 했을 때, <strong>가중치가 감소 함</strong> </li>
</ul>
<hr>
<h3 id="반복">반복</h3>
<p>이 과정을 수천~수억 번 반복하면서 모델은 점점 내 데이터셋에 맞게 적응한다. 결국 Fine-tuning은 <strong>손실을 계산하고, 그걸 역전파로 풀어내서, 경사하강법으로 가중치를 조금씩 고쳐나가는 과정</strong>이다. 새로운 모델을 처음부터 만드는 게 아니라, 이미 언어 감각을 익힌 Transformer를 내가 원하는 태스크에 맞게 조금씩 조율하는 작업이라고 보면 된다. 그리고 이러한 반복을 <strong>epoch</strong> 이라고 한다</p>
<hr>
<h1 id="😘-마무리">😘 마무리</h1>
<p>이번에는 파인튜닝의 역전파의 기본 원리에 대해 알아보았다. 다음글에서는 현대 모델에서 표준으로 자리잡은 파인튜닝 기법인 <strong>LoRA(Low-Rank Adaptation)</strong> 를 살펴보고, 실제 코드와 함께 파인튜닝 과정을 정리해보겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LLM] Transformer]]></title>
            <link>https://velog.io/@choi-hyk/LLM-Transformer</link>
            <guid>https://velog.io/@choi-hyk/LLM-Transformer</guid>
            <pubDate>Sun, 17 Aug 2025 09:03:08 GMT</pubDate>
            <description><![CDATA[<h1 id="🖱️-transformer">🖱️ Transformer</h1>
<p>오늘은 현대 LLM의 모델들이 활용중인 가장 중요한 요소인 <strong>Transformer</strong>에 대해서 알아보겠다. Transformer는 2017년 Google에서 발표한 <strong>「Attention is All you need」</strong> 논문에서 소개된 모델이다.</p>
<p><a href="https://arxiv.org/abs/1706.03762">Attention is All you need</a></p>
<p>해당 논문에서는 Transformer 아키텍처가 고안된 이유를 RNN의 단점을 서술하면서 설명하고, Transformer 아키텍처의 구성 방식을 각 layer를 기준으로 설명을 한다. 내용이 너무 어려워서 여러가지 영상이랑, 해석본도 찾아보면서 최대한 정리를 해보았다...</p>
<hr>
<h2 id="✏️-transformer가-고안된-이유">✏️ Transformer가 고안된 이유</h2>
<p><strong>「Attention is All you need」</strong> 에서는 먼저 RNN의 단점을 이야기하는데, RNN은 3가지의 주요 단점이 있다.</p>
<ul>
<li><p><strong>순차적 처리</strong>: RNN은 단어를 순서대로 처리하는 구조 때문에 병렬 처리가 불가능하다. 이는 대규모 데이터 학습에 많은 시간이 소요되는 원인이 된다.</p>
</li>
<li><p><strong>장기 의존성(Long-term Dependency) 문제</strong>: 문장이 길어질수록 초반부 단어의 정보가 점차 희미해진다. 이로 인해 문장의 앞부분에 있는 중요한 맥락 정보를 활용하기 어렵다.</p>
</li>
<li><p><strong>고정된 컨텍스트 벡터</strong>: RNN은 문장 전체의 정보를 하나의 고정된 크기 벡터에 압축하는데, 이 과정에서 정보 손실이 발생하여 복잡한 문장의 의미를 온전히 담기 어렵다.</p>
</li>
</ul>
<p>간단하게 RNN 문장이 길어질수록 성능이 떨어지고, 문장을 순서대로 처리를 해야되기 때문에, 병렬처리가 불가능하다. 또한 RNN과 더불어 <strong>CNN(Convolutional Neural Network)</strong> 이라는 신경망 기술에 대해서도 설명을 하였는데, 간단하게 CNN은 &quot;합성곱 신경망&quot;이라는 기술이다. CNN은 이미지 및 비디오와 같은 2차원 또는 3차원 데이터 처리에 특화된 딥러닝 모델인데, 데이터의 특징을 추출하여 분류나 탐지 같은 작업에 강점을 가진다. 하지만 CNN 또한 문장 내 단어 간의 <strong>장기적인 의존 관계</strong> 를 학습하는 데는 한계가 있었다.</p>
<p>즉, RNN은 순차 처리와 장기 의존성 문제, CNN은 문장의 전체 맥락을 포착하기 어렵다는 문제를 가지고 있었다.</p>
<p>따라서 Transformer는 에서 설명한 RNN과 CNN의 문제점인 병럴처리, 장기 의존성 그리고 다양한 문장의 표현을 고려한 모델이다. </p>
<hr>
<h2 id="🛠️-구조">🛠️ 구조</h2>
<img width="1520" height="2239" alt="Image" src="https://github.com/user-attachments/assets/d643741a-915e-4c5f-9aaf-a6fdeef848e3" />

<p>위의 구조를 보면 머리가 좀 아파올 것 같은데, layer 별로 나눠서 이해해보면 좀 괜찮을 것이다. 그림은 Transformer에서 사용하는 <strong>Encoder와 Decoder</strong>의 구조를 나타낸 그림이다. <strong>Encoder는 입력이 주어지면, 해당 입력을 기반으로 입력 데이터들 관의 관계를 파악</strong>하고, <strong>Decoder는 해당 입력과 Encoder에서 생성된 관계를 통해 다음 데이터를 예측</strong>한다. 이제 이 그림을 각 layer마다 살펴보겠다.</p>
<hr>
<h3 id="input-embedding">Input Embedding</h3>
<p>제일 먼저 Encoding에서 보이는 <strong>Input Embedding</strong> 은 자연어를 <strong>Tokenizer</strong> 해서 벡터화를 한 데이터이다.<br><strong>Tokenizer</strong> 와 Embedding은 추후에 다른 글로 알아보도록하고, </p>
<p>Embedding으로 벡터화를 하게 되면 각 토큰의 특징을 나타내는 차원이 생긴다. 예를 들어서 <code>바나나</code> 라는 토큰이 있으면, 해당 토큰은 여러개의 특징으로 나타낼 수 있다. </p>
<p>각 특징은 벡터의 한 요소로 표현되며, 예를 들어 바나나라는 단어가 4차원 벡터로 임베딩 되었다고 하면,</p>
<p>바나나 → [0.12, -0.87, 0.33, 0.55]</p>
<p>이런 식으로 수치화된다. 이 벡터의 각 값은 단순히 숫자가 아니라, 의미적인 특징을 압축한 값이다. 어떤 값은 과일과 관련된 의미를, 또 어떤 값은 음식이라는 카테고리적 의미를, 또 다른 값은 다른 단어들과의 관계 속에서 파생된 의미를 담고 있다.</p>
<p>이렇게 만들어진 Input Embedding은 이후 <strong>Positional Encoding</strong> 과 결합된다. Transformer는 RNN처럼 순차적으로 단어를 처리하지 않기 때문에, 단어의 순서 정보를 따로 제공해야 한다. 이때 사용하는 것이 Positional Encoding(위치 인코딩) 인데, 잠시 후에 알아보겠다.</p>
<hr>
<h3 id="attention">Attention</h3>
<p>Attention은 사실 Transformer에서 고안된 기법이 아니다. Attention은 벡터화 되어서, 각 특징들을 벡터로 나타내어진 자연어의 관계를 파악하는 기법이다. <strong>「Neural Machine Translation by Jointly Learning to Align and Translate」이라는 2014년 논문에서 RNN을 통한 기계번역을 개선하기 위해 고안된 기술</strong>이라고 한다.</p>
<p>그렇다면 드는 생각이, Transformer는 기존의 딥러닝 모델들과는 무엇이 다르냐는 것이다. Attention을 적용하는 기법에서 <strong>Transformer는 Self Attention과 Multi Head Attention 이라는 발전된 기법을 사용</strong>한다.</p>
<p>먼저 Attention의 기본 원리에 대해서 알아보자.</p>
<p>$$
Attention(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V
$$</p>
<p>위의 식은 <strong>「Attention is All you need」</strong> 에서 설명하는 Attention을 구하는 식이다. </p>
<p><strong>Attention</strong>은 크게 세 단계로 진행된다. 먼저 입력 데이터는 <strong>Query(Q)</strong>, <strong>Key(K)</strong>, <strong>Value(V)</strong> 세 가지로 변환된다. 이때 <strong>Query는 현재 단어가 &quot;무엇을 찾고 있는가&quot;</strong> 를 나타내고, <strong>Key는 &quot;어떤 정보를 가지고 있는가&quot;</strong> 를, <strong>Value는 &quot;그 정보 자체&quot;</strong> 를 의미한다. </p>
<p>예를 들어서 검색창에 <strong>LLM과 관련된 논문</strong> 을 입력하면 해당 입력이 $Q$ 가 될 것이다. 그리고 검색 이후 나온 여러가지 웹 사이트와 논문들은 $K$ 가 되고 실제 논문 데이터는 $V$ 가 될 것이다. </p>
<p>이러한 <strong>$Query(Q)$</strong>, <strong>$Key(K)$</strong>, <strong>$Value(V)$</strong> 를 만드는 방식이 가중치 <strong>$W$</strong> 를 적용하는 것이다. 입력 임베딩 $X \in \mathbb{R}^{n \times d_{\text{model}}}$에 대해, 각각의 행렬은 다음과 같이 정의된다.</p>
<p>$$
Q = XW^Q, \quad K = XW^K, \quad V = XW^V
$$</p>
<p>여기서 $W^Q \in \mathbb{R}^{d_{\text{model}} \times d_k}$, $W^K \in \mathbb{R}^{d_{\text{model}} \times d_k}$, $W^V \in \mathbb{R}^{d_{\text{model}} \times d_v}$ 는 모두 학습 가능한 파라미터이다.</p>
<p>즉, 하나의 입력 임베딩이 들어오더라도 서로 다른 가중치 행렬과 곱해지면서, <strong>질문을 하는 벡터(Q)</strong>, <strong>조건을 제공하는 벡터(K)</strong>, <strong>실제 정보를 전달하는 벡터(V)</strong> 로 투영된다.</p>
<p>그리고 $W^Q \in \mathbb{R}^{d_{\text{model}} \times d_k}$, $W^K \in \mathbb{R}^{d_{\text{model}} \times d_k}$, $W^V \in \mathbb{R}^{d_{\text{model}} \times d_v}$ 에서 보통 $d_k$, $d_v$는 $\frac{d_\text{model}}h$ 로 차원을 계산하게 되는데, 이와 관련해서 왜 가중치들의 집합의 크기가 $d_{\text{model}}\times d_k$, $d_{\text{model}}\times d_v$ 가 되는지 그리고 $h$가 무엇인지 궁금할 것이다. 일단 기억만 하고 있어라, 뒤에 <strong>Multi Head Attention</strong>에서 $h$ 가 무엇이고 집합의 크기가 왜 저렇게 나오는지, 그리고 차원을 맞추는 이유가 무엇인지 설명하겠다.</p>
<p>$Q$, $K$, $V를 구하는$ 과정은 단순한 선형 변환으로 보일 수 있지만, 학습을 통해 $W^Q, W^K, W^V$가 점차적으로 최적화되면서, <strong>Attention이 각 단어 간의 관계를 더 정교하게 파악할 수 있도록 만드는 핵심 장치</strong>가 된다. 결국 이 단계는 <strong>&quot;입력 임베딩을 서로 다른 관점에서 바라보는 방법&quot;을 모델이 스스로 학습하는 과정</strong> 이라고 이해할 수 있다.</p>
<p>그리고 이러한 가중치들은 최종적으로 <strong>디코더를 통해 생성된 확률 분포를 기반으로 손실 계산을 수행</strong>하고, 이 손실을 줄이기 위해 <strong>역전파와 가중치 조정 과정을 거쳐 업데이트된다.</strong> 이렇게 조정된 가중치는 모델의 성능을 향상시키며, 다음 번 예측의 정확도를 높이는 데 사용된다.</p>
<p><strong>새롭게 조정된 가중치로 모델은 다시 한번 입력 데이터를 받아 순전파를 시작</strong>한다. 참고로 순전파는 우리가 오늘 알아보는 Transformer의 과정이다. 인코더와 디코더는 업데이트된 가중치를 활용하여 입력 데이터의 문맥을 다시 파악하고, 디코더는 이를 기반으로 <strong>더 정확한 확률 분포를 생성</strong>한다. 이 반복적인 훈련 과정은 모델이 충분히 학습되어 <strong>손실값이 더 이상 줄어들지 않을 때까지 계속된다.</strong></p>
<p>이러한 가중치를 생성 및 조정하는 방법인 역전파는 다음글인 Fine-tuning에서 심도있게 정리해보겠다. 지금은 Attention에 집중해보도록 하자.</p>
<p>Attention은 위 세 가지 $Query(Q)$, $Key(K)$, $Value(V)$ 의 데이터들의 관계를 파악하는 기법이다. 다시 식으로 돌아가서, 각 데이터들은 임베딩 되어서 벡터로 표현된다고 했다. 그리고 각 벡터들은 차원(특징)을 가진다 했다. 여기서 하나의 $Q$는 여러 개의 $K$를 가질 것이다. 그리고 $Q$와 $K$의 개수는 $n{\text{ (토큰의 개수)}}\times d_k{\text{ (차원의 개수)}}$ 가 된다. 그리고 하나의 $Q$는 입력 값 $X$에서 생성된 같은 차원의 모든 $K$와 <strong>내적을 수행하여 유사도를 계산하고, 그 결과를 기반으로 각 $V$에 가중치를 부여하여 최종 Attention 출력을 만든다.</strong>  ${K^T}$는 전치 행렬을 의미한다. 전치 행렬로 만드는 이유는 아래의 식으로 설명하겠다.</p>
<p>$$
Q \in \mathbb{R}^{n \times d_k}, \quad K \in \mathbb{R}^{n \times d_k}
$$</p>
<p>위에서 말했다 싶이 $Q$와 $K$의 개수는 $n{\text{ (토큰의 개수)}}\times d_k{\text{ (차원의 개수)}}$ 이다. 이때 내적을 하기 위해서 행렬 곱을 하게 된다. 토큰의 행렬에서 행은 $n$을 열은 $d_k$를 나타낸다. 하지만 위의 크기로는 $Q$, $K$는 행렬곱을 하지 못한다. 따라서 전치를 통해 크기를 맞춰준다.</p>
<p>$$
K^T \in \mathbb{R}^{d_k \times n}
$$</p>
<p>$$
QK^T \in \mathbb{R}^{n \times n}
$$</p>
<p>이렇게 크기가 맞춰진 $Q$ 와 $K$는 $n\times n$ 크기의 어텐션 스코어(attention score)로 변환된다.</p>
<p>$$
\frac{QK^T}{\sqrt{d_k}}
$$</p>
<p>위의 식은 $Q$를 $K$와 내적을 한 값을 $\sqrt{d_k}$로 Scaling하는 것을 나타낸다. </p>
<p><strong>내적</strong> 은 $Query$가 $Key$와 얼마나 잘 맞는지를 나타내는 척도이며, 일종의 <strong>유사도(similarity) 점수</strong>라고 볼 수 있다. 고등학교때 배운 내적을 생각해보자</p>
<p>$Q = (1, 2, 3)$
$K_1 = (2, 0, 1)$
$K_2 = (-2, -1, 1)$
$K_3 = (0, 2, 2)$</p>
<p>이렇게 1개의 $Q$에 3개의 $K$가 있다고 해보자. 해당 벡터는 위에서 이야기한 가중치가 적용되어 3개의 차원으로 이루어진 값이다. 즉 ${d_k}$는 3이다.</p>
<p>$Q \cdot K_1 = 5$
$Q \cdot K_2 = -1$
$Q \cdot K_3 = 10$</p>
<p><em>행렬로 나타낸 경우</em></p>
<p>$$
QK^T = 
\begin{bmatrix}1 &amp; 2 &amp; 3\end{bmatrix}
\begin{bmatrix}
2 &amp; -2 &amp; 0 \
0 &amp; -1 &amp; 2 \
1 &amp; 1 &amp; 2
\end{bmatrix}
= \begin{bmatrix}5 &amp; -1 &amp; 10\end{bmatrix}
$$</p>
<p>위에서 내적의 결과를 보면, $K_3$가 10으로 $Q$ 와 가장 유사하다. 그리고 $K_2$가 -1로 가장 관련이 없다.</p>
<p>$\sqrt{d_k}$는 <strong>스케일링(scaling)</strong> 을 의미한다 앞에서 계산한 내적 결과는 $d_k$의 크기가 커질수록 값이 점점 커지게 된다 만약 차원이 수백 차원 이상으로 커진다면 내적 값은 지나치게 커지고 $softmax$ 함수에 넣었을 때 기울기가 매우 가팔라져 작은 차이에도 확률 분포가 한쪽으로 치우쳐 버린다</p>
<p>이를 방지하기 위해 내적 값을 차원의 제곱근으로 나누어 <strong>정규화(normalization)</strong> 를 해준다 예를 들어 위에서 $d_k = 3$이므로 $\sqrt{d_k} = \sqrt{3} \approx 1.73$ 이 된다</p>
<p>그럼 각각의 내적 값은 다음과 같이 스케일링된다</p>
<ul>
<li>$\frac{Q \cdot K_1}{\sqrt{3}} = \frac{5}{1.73} \approx 2.89$</li>
<li>$\frac{Q \cdot K_2}{\sqrt{3}} = \frac{-1}{1.73} \approx -0.58$</li>
<li>$\frac{Q \cdot K_3}{\sqrt{3}} = \frac{10}{1.73} \approx 5.77$</li>
</ul>
<p>이 과정을 거치면 값의 크기가 안정화되어 $Softmax$에 넣었을 때 적절한 확률 분포를 얻게 된다 즉 스케일링은 <strong>내적 값이 차원 수에 비례해 과도하게 커지는 문제를 제어하는 장치</strong>라고 이해하면 된다</p>
<p>그러면 이제 $Q$로부터 각 3개의 $K$의 관계를 알게 되었다. 그 다음으로 적용되는 것이 $softmax$이다. $softmax$는  $\frac{QK^T}{\sqrt{d_k}}$ 에서 나온 값 들을 전부 합 하였을 때, 1로 만들어주는 함수이다. 위의 경우에서는 $K_3$가 1에서 가장 많은 비율을 차지할 것이다. 그리고 $K_2$가 가장 적은 비율을 차지할 것이다. </p>
<p>마지막으로 가중합을 $V$에 적용하여 최종적인 정보의 관계를 생성하게 된다.</p>
<p>정리하자면, 특정 Query가 여러 Key들과 얼마나 관련성이 있는지를 Softmax를 통해 확률 값으로 바꾸게 되고, 이 확률 값이 바로 Attention에서 말하는 <strong>가중치(weight)</strong> 가 된다. 그리고 이 가중치는 Value $V$ 벡터에 곱해져 최종적으로 중요한 정보는 크게, 덜 중요한 정보는 작게 반영되도록 조절한다.</p>
<p>결과적으로 Attention 메커니즘은 “<strong>Query와 Key의 내적으로 구한 유사도를 스케일링 후 Softmax로 정규화하여, Value에 가중합을 적용하는 과정</strong>”이라고 정리할 수 있다.</p>
<h3 id="self-attention">Self Attention</h3>
<p>그렇다면 Self Attention은 무엇일까? 앞에서 예시는 검색엔진처럼 Query는 질문, Key는 문서의 제목, Value는 실제 내용으로 비유했다. 하지만 Self-Attention은 그 대상이 외부 데이터가 아니라, <strong>같은 문장 안의 토큰들끼리 서로를 참고하는 방식이다</strong>. 사실 앞에서 이야기한 &quot;하나의 $Q$는 입력 값 $X$에서 생성된 같은 차원의 모든 $K$와 <strong>내적을 수행하여 유사도를 계산한다&quot;</strong> 가 바로 Self Attention을 나타내는 말이었다. 그냥 Attention은 외부 데이터에서 $Q$, $K$, $V$를 각각 생성한다.</p>
<p>하나의 문장 <code>나는 학교에 간다</code>가 있다고 하면, 각 단어가 동시에 Q, K, V의 역할을 수행한다.</p>
<p>&quot;나는&quot; → Query를 만들고, Key와 Value도 만든다
&quot;학교에&quot; → Query, Key, Value를 모두 가진다
&quot;간다&quot; → 역시 Query, Key, Value를 가진다</p>
<p>이렇게 되면 문장 안에서 각 단어는 다른 단어와 자신 사이의 관련성을 계산할 수 있다. 이를 통해 처음에 말한 RNN에서 실현하지 못한 장기 의존성 그리고 다양한 문장의 표현을 해결이 가능하다. 그러면 마지막 목적인 병럴처리는 어떻게 실현이 가능할까? 바로 Multi Head Attention에 그 정답이 있다.</p>
<h3 id="multi-head-attention">Multi Head Attention</h3>
<img width="835" height="1282" alt="Image" src="https://github.com/user-attachments/assets/1991d59d-4881-4bba-8ac1-9e3b7c5e8be2" />

<p>먼저 Multi Head Attention에 대해서 알아보려면 head가 무엇인지 알아야 한다. 처음에 나는 차원이랑 헤드가 헷갈렸는데, 차원은 위에서 이야기 한 것처럼 각 토큰의 특징을 나타낸 것이다. </p>
<p>$d_{model} = 512$라면, 각 단어 토큰은 512개의 숫자로 표현된다.</p>
<p>&quot;바나나&quot;라는 토큰이 들어오면 벡터</p>
<p>$$
(0.12, -0.33, 0.98, \dots, 0.21) \in \mathbb{R}^{512}
$$</p>
<p>이런 식으로 512차원의 공간에서 하나의 점으로 표현된다. 즉 $\mathbb{R}^{512}$ 는 모델의 용량이 된다.</p>
<p>헤드는 <strong>Self-Attention을 여러 번 나눠서 병렬로 돌리는 단위</strong>이다.
예를 들어, $d_{model} = 512$이고 Head 수가 $h = 8$이라면,
각 Head는 차원을 나눠서</p>
<p>$$
d_{head} = \frac{d_{model}}{h} = 64
$$</p>
<p>의 크기로 Attention 연산을 한다.
전체 임베딩 512차원을 <strong>8개의 시각(Head)으로 쪼개서 동시에 바라본다</strong>고 이해하면 된다.</p>
<ul>
<li><strong>차원(dimension)</strong> → 토큰 벡터의 &quot;특징 공간&quot; 크기 (정보량)</li>
<li><strong>헤드(head)</strong> → 그 특징 공간을 &quot;여러 시각&quot;으로 분할해 병렬로 학습하는 방법</li>
</ul>
<p>즉 <strong>Multi Head Attention</strong>은 $h$번 만큼 Self Attention을 하는 것이다. 이를 통해 병렬처리를 실현 가능하다. </p>
<p>여기서 앞에서 이야기한 가중치의 크기와 $d_k, d_v$를 $\frac{d_{model}}{h}$로 맞추는 이유가 여기서 나온다.</p>
<p>$$
X \in \mathbb{R}^{n \times d_{\text{model}}}
$$</p>
<p>위의 식처럼 입력값 $X$의 크기는 $n$개의 토큰에서 차원 ($d_{\text{model}}$)을 곱한 값이다. 그러면 각 $Q$, $K$, $V$를 생성하기 위해 가중치와 곱하게 되는데 이때 $h$개 ($d_k$, $d_v$) head로 Self Attention을 하게 된다. 그 뜻은 원래 차원 각각에 $h$개의 특징 추가시키는 것이다. 위의 예시인 경우 512개의 차원에서 8개의 특징을 추가하여 512와 8을 곱한 98,304개가 된다.   </p>
<p>따라서 가중치들의 크기는 $d_{\text{model}}\times d_k$, $d_{\text{model}}\times d_v$  </p>
<p>$$
Q = XW^Q, \quad K = XW^K, \quad V = XW^V
$$</p>
<p>위에서 본 해당 식에서 각 행렬 곱을 하게 되면 
$$(n \times d_{\text{model}}) \times (d_{\text{model}} \times d_k) = (n \times d_k)$$
$$(n \times d_{\text{model}}) \times (d_{\text{model}} \times d_k) = (n \times d_v)$$</p>
<p>위의 예시에서 Self Attention의 각 차원은 64개 일것이다. 그리고 8번의 Self Attention을 하면 총 <strong>512개의 차원이 다시 완성되는 것이다</strong>. 이렇게 해야지 다음 과정인 Residual Connection(잔차 연결) 같은 구조에서 입력($d_{model}$)과 출력($d_{model}$)을 그대로 더할 수 있고, 다음 Feed-Forward Layer도 <strong>동일한 차원에서 작동할 수 있게 된다.</strong> 이렇게 $h$로 나누는 방법으로 차원을 맞추는 것이다.</p>
<p>$$
MultiHead(Q, K, V) = Concat(head_1, \dots, head_h)W^O
$$</p>
<p>$$
\text{where } head_i = Attention(QW_i^Q, KW_i^K, VW_i^V)
$$</p>
<p>위의 식은 Self Attention 이 $head$번 만큼 일어나고 각 결과를 더해서 <strong>Multi Head Attention</strong>을 구성하는 것을 나타낸다.</p>
<p><strong>Multi Head Attention</strong>까지 실행하면, 아마 입력값에 대해 모델이 충분히 이해했다고 생각할 것이다. 하지만 하나 빠트린것이 있는데, 바로 입력 값의 위치에 대한 정보이다.</p>
<hr>
<h3 id="positional-encoding">Positional Encoding</h3>
<p>자연어에서 입력값의 위치는 매우 중요하다. 그런데 위치를 고려하게 되면 RNN에서 장기 의존성(Long-term Dependency)와 같이 위치에 따른 정보 손실과 순차적 처리로 인해 병렬처리가 불가능하다. 이러한 위치에 따른 정보를 Transformer에서는 다른 기법으로 적용하였는데, 바로 <strong>Positional Encoding</strong>으로 적용을 한 것이다.</p>
<p>Transformer는 RNN처럼 순차적으로 단어를 처리하지 않기 때문에, 입력 토큰의 <strong>순서 정보</strong>가 사라지는 문제가 있다. 즉, &quot;나는 학교에 간다&quot;라는 문장이 들어와도, Transformer 입장에서는 단순히 4개의 벡터 집합일 뿐 &quot;나는 → 학교에 → 간다&quot;라는 순서 관계를 알 수 없다.</p>
<p>이를 해결하기 위해 <strong>Positional Encoding(위치 인코딩)</strong>을 추가한다. Positional Encoding은 각 토큰의 임베딩 벡터에 &quot;해당 토큰이 문장 내 몇 번째 위치에 있는지&quot;를 수학적으로 표현한 벡터를 더해주는 방식이다.</p>
<p>$$
Z = X + PE
$$</p>
<p>$X$는 원래의 단어 임베딩, $PE$는 위치 정보를 담은 인코딩 벡터이다.</p>
<p>논문에서 제안한 Positional Encoding은 <strong>사인(sin)과 코사인(cos)</strong> 함수를 이용한다.
특정 위치 $pos$와 차원 $i$에 대해 다음과 같이 정의된다.</p>
<p>$$
PE_{(pos, 2i)} = \sin \left( \frac{pos}{10000^{2i/d_{model}}} \right)
$$</p>
<p>$$
PE_{(pos, 2i+1)} = \cos \left( \frac{pos}{10000^{2i/d_{model}}} \right)
$$</p>
<p>여기서</p>
<ul>
<li>$pos$ : 단어의 위치 (0번, 1번, 2번 …)</li>
<li>$i$ : 임베딩 벡터의 차원 인덱스</li>
<li>$d_{model}$ : 임베딩 벡터의 총 차원 수</li>
</ul>
<p>즉, 짝수 차원은 사인 함수, 홀수 차원은 코사인 함수로 값을 넣어준다.
나도 정확한 원리는 모르지만 사인 코사인으로 파형을 생성해서 무한한 길이도 위치를 알아낼 수 있는 형태로 바꿔진다고 한다. <del>잘 모름</del></p>
<hr>
<h3 id="feed-forward-network-ffn">Feed Forward Network (FFN)</h3>
<p>마지막으로 Multi-Head Attention과 Positional Encoding을 거친 후의 출력은 그대로 다음 레이어로 전달되지 않고, 한 번 더 <strong>Feed Forward Network(포지션별 전결합 신경망)</strong>을 거치게 된다. FFN은 모든 위치(pos)에 대해 동일하게 적용되는 두 개의 선형 변환과 비선형 활성화 함수로 구성된다.</p>
<p>수식으로 표현하면 다음과 같다.</p>
<p>$$
FFN(x) = \max(0, xW_1 + b_1)W_2 + b_2
$$</p>
<p>여기서 $W_1, W_2$와 $b_1, b_2$는 학습 가능한 가중치와 편향이다. 중간에 들어가는 $\max(0, \cdot)$는 ReLU 함수로, 비선형성을 부여하여 모델이 더 복잡한 패턴을 학습할 수 있게 한다.</p>
<p>FFN의 특징은 <strong>각 토큰 위치마다 동일한 네트워크가 독립적으로 적용</strong>된다는 것이다. 즉, 입력이 10개의 토큰이든 20개의 토큰이든, 각 토큰 벡터는 똑같은 FFN 구조를 거쳐 변환된다. 이로 인해 모델은 위치에 무관하게 동일한 변환을 수행하면서도, Attention으로 이미 반영된 단어 간의 관계를 기반으로 비선형적인 특징을 학습할 수 있다.</p>
<p>간단히 말해 Attention이 <strong>단어들 사이의 관계</strong> 를 학습하는 단계라면, FFN은 그 관계로부터 <strong>복잡한 패턴을 추출하고 강화하는 단계</strong>라고 볼 수 있다.</p>
<p>이 과정을 거친 출력은 다시 Residual Connection(잔차 연결)과 Layer Normalization을 통해 안정화되고, 다음 Encoder Layer로 전달된다. Encoder는 이런 구조를 여러 층 쌓아 올려 강력한 표현 학습 능력을 얻게 된다.</p>
<h3 id="masking">Masking</h3>
<p>이제 <strong>Masking</strong>에 대해서 알아보겠다. 마스킹은 decoder에만 적용되는 기법이다. Decoder는 해당 입력과 Encoder에서 생성된 관계를 통해 다음 데이터를 예측한다... 라고 위에서 설명했다. 그런데 생각해보자 Encoder는 단순히 모든 입력에 관한 관계를 정의하는 것이라서, Encoder로 생성된 정보들은 전부 Self Attention 을 통해 조금이라도 각자의 정보를 가지고 있을 것이다.</p>
<p>하지만 Decoder는 다르다. Decoder는 <strong>순차적 예측(Autoregressive Generation)</strong> 을 수행해야 한다. 예를 들어, <code>나는 학교에 간다</code>라는 문장을 생성한다고 할 때, 첫 번째 단계에서는 <code>나는</code>만 알고 있어야 하며, 두 번째 단계에서는 <code>나는 학교에</code>까지만 알고 있어야 한다. 만약 Decoder가 앞으로 나올 단어 <code>간다</code>를 미리 참고해버린다면, 모델은 학습 과정에서 미래 정보를 엿보는 <strong>정보 누수(Information Leakage)</strong> 가 발생하게 된다. 이를 해결해 주는 것이 Masking 기법이다.</p>
<p>Masking은 미래 시점의 단어를 가려서 현재 시점 이전의 단어들만 보이도록 하는 역할을 한다. 수학적으로는 Attention의 Softmax 단계에서, 미래 단어 위치에 -∞ 값을 추가하여 확률이 0이 되도록 만든다. 이렇게 하면 Decoder는 항상 앞에서 생성된 단어까지만 참고해서 다음 단어를 예측하게 된다.</p>
<hr>
<h3 id="add--norm">Add &amp; Norm</h3>
<img width="1520" height="2239" alt="Image" src="https://github.com/user-attachments/assets/d643741a-915e-4c5f-9aaf-a6fdeef848e3" />

<p>그림을 다시 한번 봐보자 MultiHead Attention과 Feed Forward를 하고 나서 Add &amp; Norm이라는 과정이 있고, 화살표 하나는 MultiHead Attention과 Feed Forward를 하지 않고 Add &amp; Norm을 향하고 있다. </p>
<p>MultiHead Attention과 Feed Forward를 하고 나면 원래 입력 정보 $X$가 변질 되거나 학습지 되지 않아, 아예 다른 출력이 나올 수도 있다. 또한 각 layer를 지날때마다 데이터 분포 층이 뒤틀릴 수도 있다. 이를 해결 해 주는 것이 Add &amp; Norm이고, 따라서 각 layer를 지나고 나서 적용을 해준다.</p>
<p>즉, <strong>Add &amp; Norm은 &quot;입력 + 출력&quot;을 합쳐서 정규화하는 과정</strong>이다.Add &amp; Norm은 <strong>Residual Connection + Layer Normalization</strong>로 이루어진다. </p>
<p>먼저 Residual Connection은 레이어의 입력 $X$와 해당 레이어의 출력 $F(X)$를 더한다.</p>
<p>$$
Y = X + F(X)
$$</p>
<p>이렇게 더하면, 깊은 네트워크에서도 원래 입력 정보 $X$가 손실되지 않고 그대로 흘러갈 수 있다.</p>
<p>그 다음에 Layer Normalization을 적용한다. 평균과 분산을 구해서 정규화하는 거다.</p>
<p>$$
\text{LayerNorm}(Y) = \frac{Y - \mu}{\sigma} \cdot \gamma + \beta
$$</p>
<p>최종 출력은 이렇게 된다.</p>
<p>$$
\text{Output} = \text{LayerNorm}(X + F(X))
$$</p>
<hr>
<h3 id="backpropagation">BackPropagation</h3>
<p>이제 위에서 구한 값을 통해 역전파를 진행한다. 이러한 역전파는 수천~수억번을 반복하여 모델을 최적화 하게 된다. 앞에서 말한 것처럼 역전파는 다음 글에서 알아보겠다.</p>
<hr>
<h3 id="decoder">Decoder</h3>
<p>위의 그림을 보면 디코더에서는 두 가지 입력이 필요하다.
첫 번째는 <strong>아웃풋 임베딩(Output Embedding)</strong>, 두 번째는 <strong>인코더의 출력값</strong>이다.</p>
<p><strong>아웃풋 임베딩</strong>은 지금까지 생성한 단어들을 임베딩한 것이다. 예를 들어 번역 모델에서 이미 “I go”까지 만들었다면, 이게 디코더 입력으로 들어간다. 디코더는 이걸 바탕으로 다음 단어가 뭔지 예측한다.</p>
<p><strong>인코더 출력값</strong>은 원문 문장의 의미 표현이다. 디코더는 단순히 자기 자신(아웃풋 임베딩)만 보고는 번역을 할 수 없다. 원래 문장 정보도 같이 참고해야 한다. 그래서 중간에 <strong>Cross-Attention</strong> 이라는 중간단계에서 인코더 출력값을 Key, Value로 삼고, 디코더 쪽에서 만든 Query와 결합한다. 이렇게 해야 “출력 문장이 원문을 잘 반영하도록” 만들 수 있다.</p>
<h4 id="디코더만-사용하는-모델들">디코더만 사용하는 모델들</h4>
<p>GPT 같은 모델은 <strong>디코더만 사용하는 구조</strong>다. 인코더-디코더 구조가 아니기 때문에, <strong>아웃풋 임베딩만 가지고 학습한다.</strong> 방식은 다음과 같다. 디코더 모델은 입력 문장을 바로 디코더에 넣는다. 그리고 나서 마스크드 셀프 어텐션(Masked Self-Attention)으로, 앞으로 올 단어는 못 보고 과거 단어만 참고한다. 이렇게 해서 언어 모델링이 가능하다. 즉, 앞 단어가 주어졌을 때 다음 단어를 예측하는 방식이다. 따라서 디코더 생성형 모델이 사용하는 방식이다. 우리가 GPT를 통해 자연어를 입력하고 GPT가 응답을 생성하는 것이 이러한 토크나이저와 마스크드 셀프 어텐션으로 다음 단어의 확률을 통해 만드는 것이다.</p>
<hr>
<h2 id="😁-마무리">😁 마무리</h2>
<p>이번에는 Transformer에 대해서 알아보았는데, 사실 이 글을 5일에 걸쳐서 쓴것 같다. 중간에 틀린 내용도 고치고 논문 내용도 다시 살펴보느라 오래 걸렸는데, 이렇게 한번 정리하니 확실히 이해가 잘되는 것 같다. 다음에는 fine-tuning에 대해서 알아보겠다. 사실 우리가 LLM을 사용하면 이미 Transformer 가 적용된 LLM을 파인튜닝하거나 RAG, 프롬프팅을 적용하여 사용한다. 따라서 파인튜닝이야 말로 LLM을 실전에 사용할 수 있는 핵심적인 기술이다. 다음 글에서 파인튜닝에 대해서 심도있게 다뤄보겠다.</p>
<p><a href="https://www.youtube.com/watch?v=6s69XY025MU">[참고] Attention/Transformer 시각화로 설명, 임커밋 (YouTube)</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LLM] Overview]]></title>
            <link>https://velog.io/@choi-hyk/LLM-Overview</link>
            <guid>https://velog.io/@choi-hyk/LLM-Overview</guid>
            <pubDate>Sat, 16 Aug 2025 07:48:18 GMT</pubDate>
            <description><![CDATA[<h1 id="📖-overview">📖 Overview...</h1>
<p>이전 글에 <strong>Prompt Engineering</strong>과 <strong>Chunking</strong>에 대해서 정리를 했었는데, <strong>LLM의 기초</strong>부터 Velog에 정리를 해야 할 필요를 느꼈다. <strong>LLM의 개념</strong>에 대해서는 예전에 책으로 몇 번 보고, 영상이나 강의자료로 가볍게 본 기억이 있는데, 이번에 제대로 기초부터 다시 공부해서 정리해 보려고 한다.</p>
<p>이번 글에서는 간단한 <strong>LLM의 역사</strong>와 <strong>기본적인 원리</strong>를 간단하게 정리해 보고, 다음 글에서 <strong>Transformer</strong>에 대해서 심도 있게 다룰 생각이다.</p>
<hr>
<h2 id="📜-llm-history">📜 LLM History</h2>
<p><strong>LLM(Large Language Model)</strong> 은 처음 들어보면, 엄청나게 복잡한 알고리즘과 원리로 동작하는 것처럼 보인다. 하지만 <strong>기본적인 원리</strong>는 엄청 간단하다고 한다. <strong>뒷말 잇기</strong>를 생각해 보면 이해가 될 텐데, 만약 이러한 문장을 보았다고 하자.</p>
<p><code>나는 늦게 일어나서 학교까지 ~</code></p>
<p>나는 뒤에 <strong><code>뛰어갔다</code></strong> 를 넣으면 자연스러울 것 같다. 아마도 <strong><code>택시를 타고 갔다</code></strong> 도 괜찮을 것 같다. 그럼 <strong>LLM</strong>이 볼 때는 어떻게 생각을 할까? LLM은 수많은 예시를 가지고 있고, 각 예시는 <strong>확률</strong>을 가지고 있다.</p>
<table>
<thead>
<tr>
<th>후보 단어</th>
<th>확률(%)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>뛰어갔다</strong></td>
<td><strong>35%</strong></td>
</tr>
<tr>
<td><strong>택시를</strong></td>
<td><strong>25%</strong></td>
</tr>
<tr>
<td><strong>걸어갔다</strong></td>
<td><strong>15%</strong></td>
</tr>
<tr>
<td><strong>버스를</strong></td>
<td><strong>10%</strong></td>
</tr>
<tr>
<td><strong>지각했다</strong></td>
<td><strong>5%</strong></td>
</tr>
<tr>
<td><strong>기타</strong></td>
<td><strong>10%</strong></td>
</tr>
</tbody></table>
<p>만약 이러한 확률을 가지고 있다고 해보자. <strong>LLM</strong>은 아마도 이러한 확률 테이블에서 가장 적절한 후보 단어를 골라서 문장을 생성해 낼 것이다.</p>
<p>이것이 바로 <strong>기본적인 LLM의 동작</strong>이다. 이러한 원리는 <strong>1950년대</strong>부터 고안이 되었는데, 그 유명한 <strong>튜링 머신 테스트</strong>가 이러한 <strong>자연어 생성 메커니즘</strong>에 부합하는 기계를 찾는 테스트이다.</p>
<p>이후 <strong>1990년대</strong>에는 <strong>통계적 언어 모델</strong>이 등장했다. <strong>N-gram</strong>이라는 모델을 사용해 이전 단어들을 보고 다음 단어의 확률을 계산하는 방식이었다. 하지만 긴 문맥을 처리하지 못하고 데이터가 커질수록 <strong>희소성 문제</strong>가 발생했다.</p>
<p><strong>2010년대 초반</strong>, <strong>RNN(Recurrent Neural Network)</strong> 과 <strong>LSTM(Long Short-Term Memory)</strong> 같은 신경망 모델이 <strong>NLP</strong>에 도입되면서 조금 더 긴 문맥을 다룰 수 있게 되었지만, 여전히 <strong>학습 속도</strong>와 <strong>긴 시퀀스 처리</strong>에서 한계가 있었다.</p>
<blockquote>
<p>참고로 <strong>RNN</strong>은 <strong>순환 신경망</strong> 기술로 연쇄적인 데이터를 처리하기 위해 <strong>이전 상태</strong>를 입력으로 받아서 출력을 만들어 내는 <strong>뉴런 구조</strong>에서 착안한 기술이다.</p>
</blockquote>
<p>결정적인 전환점은 <strong>2017년 Transformer</strong>의 등장이다. <strong>「Attention is All You Need」</strong> 라는 논문에서 제안된 Transformer 구조는 <strong>병렬 처리</strong>가 가능하면서도 <strong>긴 문맥</strong>을 효과적으로 학습할 수 있게 했다. 그 이후 <strong>BERT, GPT, T5</strong> 같은 모델들이 등장하며 <strong>언어 모델의 패러다임</strong>을 완전히 바꾸었다.</p>
<hr>
<h2 id="🤖-bert--gpt--t5">🤖 BERT / GPT / T5</h2>
<p><strong>Transformer</strong>는 입력된 시퀀스의 모든 단어를 <strong>병렬로 처리</strong>할 수 있다. 이러한 병렬 처리를 가능하게 하는 것이 바로 <strong>멀티헤드 어텐션(MultiHead Attention)</strong>이다. <strong>멀티헤드 어텐션</strong>은 단어들의 <strong>관계</strong>를 파악하는 기법이다.</p>
<p><strong>Transformer</strong>는 크게 두 가지의 구조로 구성된다. <strong>인코더(Encoder)</strong>와 <strong>디코더(Decoder)</strong> 두 개의 절차로 구성되는데, 이를 어떻게 사용하느냐에 따라 <strong>BERT, GPT, T5</strong>와 같은 다양한 모델이 탄생했다.</p>
<h3 id="bert-bidirectional-encoder-representations-from-transformers">BERT (Bidirectional Encoder Representations from Transformers)</h3>
<h5 id="bidirectional-→-양방향">Bidirectional → 양방향</h5>
<p><strong>BERT</strong>는 <strong>구글</strong>에서 개발한 모델로, <strong>인코더만</strong>으로 구성되어 있다. <strong>양방향(Bidirectional)</strong> 으로 문맥을 학습하는 것이 특징이다. 예를 들어, <code>나는 늦게 일어나서 학교까지 뛰어갔다</code>라는 문장이 있을 때, <strong>BERT</strong>는 &quot;나는 늦게 일어나서&quot;와 &quot;학교까지&quot;라는 <strong>양쪽의 문맥</strong>을 모두 고려하여 <strong><code>뛰어갔다</code></strong>라는 단어를 이해한다. 이는 문장의 <strong>의미를 이해하고 분류하는 과제(NLU, Natural Language Understanding)</strong> 에 매우 효과적이다.</p>
<h3 id="gpt-generative-pre-trained-transformer">GPT (Generative Pre-trained Transformer)</h3>
<p><strong>GPT</strong>는 <strong>OpenAI</strong>에서 개발한 모델로, <strong>디코더만</strong>으로 구성되어 있다. <strong>GPT</strong>는 <strong>단방향(Unidirectional)</strong> 으로 학습하며, 문맥을 기반으로 <strong>다음 단어를 예측</strong>하는 방식이다. 위 예시에서, GPT는 <code>나는 늦게 일어나서 학교까지</code>라는 문장이 주어졌을 때, <strong>이전 단어들</strong>만을 참고하여 <strong><code>뛰어갔다</code></strong>를 예측한다. 이 구조는 새로운 문장을 <strong>생성하는 과제(NLG, Natural Language Generation)</strong> 에 뛰어나다.</p>
<h3 id="t5-text-to-text-transfer-transformer">T5 (Text-to-Text Transfer Transformer)</h3>
<p><strong>T5</strong>는 <strong>구글</strong>에서 개발한 모델로, <strong>인코더와 디코더</strong>를 모두 사용한다. <strong>T5</strong>의 가장 큰 특징은 모든 자연어 처리 문제를 <strong>&quot;텍스트를 텍스트로 바꾸는(text-to-text)&quot;</strong> 형식으로 통일했다는 점이다. 예를 들어, <strong>문장 분류, 요약, 번역</strong> 등 모든 과제를 <strong>질문과 답변 텍스트 쌍</strong>으로 변환하여 학습한다.</p>
<p>자세한 원리는 다음 글인 <strong><code>Transformer</code></strong>에서 심도 있게 다뤄보도록 하겠다.</p>
<hr>
<h2 id="🌟-emergence">🌟 Emergence</h2>
<p>그런데 우리가 궁금한 것은 이러한 소위 말하는 <strong>LLM 혁명</strong>이 어떻게 왔냐는 것이다. 아마 <strong>LLM</strong>에 관심이 많으면 <strong>창발적 능력</strong>이라는 말을 많이 들어봤을 텐데, 답은 여기에 있다.</p>
<p><strong>창발적 능력</strong>이란 <strong>작은 모델</strong>에서는 전혀 보이지 않던 능력이, <strong>모델의 규모</strong>가 일정 임계치를 넘었을 때 <strong>갑작스럽게 도약</strong>하듯 나타나는 현상을 의미한다.</p>
<img width="1129" height="833" alt="Image" src="https://github.com/user-attachments/assets/0f419745-167a-4a72-ad47-a56d8ed5341b" />  

<p>해당 그래프는 <strong>OpenAI</strong>가 <strong>2017년</strong>에 발표한 논문 <strong>「Learning to Generate Reviews and Discovering Sentiment Neurons」</strong> 에서 나온 결과인데, <strong>LSTM(Long Short-Term Memory)</strong> 기반의 언어 모델이 갑자기 <strong>긍정/부정 감정(sentiment)</strong>을 구분하는 능력을 갖추게 된 것이다. 이것이 초기 <strong>창발적 능력</strong>의 증거라고 보는 견해가 많다.</p>
<p>이러한 <strong>창발적 능력</strong>은 <strong>GPT-2</strong>에서도 관찰되었다는 견해가 있지만, 실제로 놀라운 성능을 보여준 것은 <strong>GPT-3</strong>부터였다. 바로 <strong><code>In Context Learning</code></strong>이라는 <strong>Prompt Engineering</strong>의 핵심이 되는 현상이 일어난 것이다.</p>
<p>이때부터 <strong>Microsoft</strong>가 <strong>OpenAI</strong>에 본격적으로 눈길을 돌렸다. 이미 <strong>2019년</strong>에 <strong>Azure 클라우드</strong>를 통해 일부 협력 관계를 맺고 있었지만, 이러한 <strong>창발적 능력</strong>으로 인한 <strong>LLM의 능력 극대화</strong>는 충격적으로 다가왔을 것이다.</p>
<p><strong>OpenAI</strong>의 CEO인 <strong>샘 알트먼</strong>과 연구진들은 이러한 <strong>창발적 능력</strong>을 <strong>2017년</strong>에 알게 되어 <strong>GPT-1부터 3까지 Zero-shot, Few-shot</strong> 등 여러 가지 현상을 관찰하고 개선하면서 지금에 이르렀다. 참고로 <strong>Zero-shot</strong>과 <strong>Few-shot</strong>은 각각 <strong>GPT-2</strong>와 <strong>GPT-3</strong>에서 처음 체계적으로 입증되었다고 한다.</p>
<hr>
<h2 id="📌-마무리">📌 마무리</h2>
<p>이번 글에서는 <strong>LLM의 역사</strong>와 <strong>기본 원리</strong>, 그리고 <strong>창발적 능력</strong>의 개념까지 정리해 보았다. 다음 글에서는 <strong>LLM의 핵심 구조 Transformer</strong>를 심도 있게 다룰 예정이다. 또한 <strong>LLM이 학습을 하는 방식</strong>도 다룰 생각이다.</p>
<p><a href="https://openai.com/index/unsupervised-sentiment-neuron/">[참고] Unsupervised sentiment neuron</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Design Pattern] Decorator Pattern]]></title>
            <link>https://velog.io/@choi-hyk/Design-Pattern-Decorator-Pattern</link>
            <guid>https://velog.io/@choi-hyk/Design-Pattern-Decorator-Pattern</guid>
            <pubDate>Fri, 15 Aug 2025 10:26:47 GMT</pubDate>
            <description><![CDATA[<h1 id="decorator-pattern-🎨">Decorator Pattern 🎨</h1>
<p>오랜만에 디자인패턴 글을 써보는데, 최근에 IPP로 회사에 가서 이것저것 하고 정신이 없어서 글 쓰는 것을 잊고 있었다.</p>
<p>앞으로는 일주일에 한번은 디자인패턴 글을 쓸 생각이다. 어쨌든 저번 프로토타입 패턴을 마지막으로 <strong>생성패턴은 전부 정리를 완료</strong>했고, 오늘부터는 <strong>장식자 패턴을 시작으로 구조 패턴을 차례대로 정리</strong>해보겠다.</p>
<p><strong>Decorator Pattern</strong>은 이름에서 알 수 있다시피, <strong>어떤 객체를 장식을 하는 패턴</strong>이다. 참고로 장식자 패턴을 포함한 <strong>Structure Patterns</strong>는 <strong>여러 개의 객체로 이루어진 구조를 정의</strong>해주는 패턴이다. 간단하게 <strong>보편적인 설계도를 정의한 것</strong>이라 보면 된다. 따라서 코드를 작성할 때, <strong>클래스나 함수를 정의하고 객체의 생명주기를 관리하는 방법에는 생성패턴</strong>이 사용된다면, <strong>전체적인 구조를 정의하고, 하나의 모듈로 동작하는 기능을 구현할 때는 구조패턴</strong>을 사용할 경우가 생길 것이다.</p>
<p>다시 돌아가서 장식자 패턴은 <strong>객체에 동적으로 새로운 책임(기능)을 추가하는 방식</strong>으로, <strong>상속의 대안</strong>으로 사용된다. 이 패턴은 <strong>기존 객체의 구조를 변경하지 않고 기능을 확장</strong>할 수 있다.</p>
<blockquote>
<p><strong>객체에 동적으로 새로운 책임을 추가할 수 있게 합니다. 기능을 추가하려면, 서브클래스를 생성하는 것보다 융통성 있는 방법을 제공합니다</strong></p>
</blockquote>
<p>GOF 책에서 보면 많은 패턴들이 <strong>서브클래스를 생성하는 것을 대체하고 효율적으로 기능을 추가하기 위해 고안</strong>된 것임을 알 수 있다.
그렇다면 여기서 <strong>동적</strong>은 무엇을 뜻하는 것일까?</p>
<p>여기서 동적은 실제로 컴퓨터공학에서 말하는 <strong><code>Dynamic</code></strong>을 의미한다.
만약 객체를 서브클래스로 기능과 책임을 만들 경우, <strong>컴파일 타임이나 빌드 타임에 &quot;정적&quot;으로 기능과 책임을 담당하는 클래스</strong>를 생성해야 한다. 하지만 장식자 패턴은 <strong>동적으로 실제 런타임 환경에서 이러한 추가 기능 클래스를 추가 가능</strong>하다. 또한 장식자 패턴은 <strong><code>Wrapper</code></strong>라고도 불리는데, <strong>객체를 감싸서 추가적인 기능이나 책임을 부여하는 구조</strong> 때문에 이렇게 불린다.</p>
<p><strong>책임을 부여하는 것</strong>이 장식자 패턴에서 가장 중요한 점인데, <strong>장식받는 객체는 자신의 기능만 신경 쓰면 되고</strong>, 나머지 장식을 하는 객체들의 구현과 기능은 신경 쓸 필요가 없다. 따라서 <strong><code>Decorator</code>가 사용되는 순간 장식받는 객체는 <code>Decorator</code>의 멤버 변수로 들어가서 기능 호출만 받으면 된다.</strong></p>
<hr>
<h2 id="언제-사용하나-📌">언제 사용하나? 📌</h2>
<p>책에서는 장식자 패턴을 <strong><code>TextView</code> 컴포넌트를 감싸서 기능을 <code>BorderDecorator</code>와 <code>ScrollDecorator</code>로 예시</strong>를 들었다.</p>
<img width="524" height="220" alt="Image" src="https://github.com/user-attachments/assets/cd5a7c25-28c9-46ef-84a9-6485f3defdc8" />  

<p>해당 이미지를 보면, 기존의 <strong><code>TextView</code>에 <code>BorderDecorator</code>와 <code>ScrollDecorator</code>로 감싸서 컴포넌트를 이루는 것</strong>을 나타낸다.</p>
<img width="578" height="312" alt="Image" src="https://github.com/user-attachments/assets/d910b2ca-ca2e-4e82-8739-85a9a61d5f73" />  

<p>위 사진을 보면 <strong>동적으로 어떻게 기능을 추가하는지</strong> 이해가 될 것이다. 바로 <strong><code>VisualComponent</code>라는 클래스가 <code>TextView</code>를 서브클래스로 가지고 있는데, 해당 클래스가 <code>Decorator</code>라는 클래스를 서브클래싱</strong>하여 관리를 한다.</p>
<p><strong><code>Decorator</code>는 <code>Draw()</code>로 원하는 컴포넌트를 그리면 된다.</strong> 여기서 중요한 점이 있는데, <strong><code>TextView</code>는 이러한 <code>Decorator</code>들을 알 필요가 없다.</strong> <code>TextView</code>를 정의하고, 만약 테두리를 그리고 싶으면 <strong><code>TextView</code>를 <code>Decorator</code>에 넘겨주고 해당 클래스에서 장식을 해준다.</strong></p>
<p>따라서 <strong><code>TextView</code>는 자신의 기능인 텍스트뷰 그리기만 신경</strong>을 쓰면 된다.</p>
<hr>
<h2 id="구조-🏗️">구조 🏗️</h2>
<img width="645" height="285" alt="Image" src="https://github.com/user-attachments/assets/b0938fb6-00ba-417f-9dfe-d8db2d85ff14" />  

<p><strong><code>ConcreteDecorator</code>들은 <code>Operation()</code>으로 자신만의 기능과 함께 <code>ConcreteComponent</code>의 <code>Operation()</code>을 호출</strong>할 것이다. 이렇게 <strong><code>ConcreteComponent</code>는 그저 자신의 기능만 호출하고 추가 기능 확장에 대해서는 신경 쓸 필요가 없다.</strong></p>
<hr>
<h2 id="구현-💻">구현 💻</h2>
<pre><code class="language-cpp">#include &lt;iostream&gt;
#include &lt;memory&gt;

class VisualComponent {
public:
    virtual void Draw() = 0;
    virtual ~VisualComponent() {}
};

class TextView : public VisualComponent {
public:
    void Draw() override {
        std::cout &lt;&lt; &quot;기본 텍스트 뷰 그리기&quot; &lt;&lt; std::endl;
    }
};

class Decorator : public VisualComponent {
protected:
    std::unique_ptr&lt;VisualComponent&gt; _component;
public:
    Decorator(std::unique_ptr&lt;VisualComponent&gt; component)
        : _component(std::move(component)) {} 
    void Draw() override {
        if (_component) {
            _component-&gt;Draw();
        }
    }
};

class BorderDecorator : public Decorator {
private:
    int _width;
    void DrawBorder() {
        std::cout &lt;&lt; &quot;테두리 그리기&quot; &lt;&lt; std::endl;
    }
public:
    BorderDecorator(std::unique_ptr&lt;VisualComponent&gt; component, int width)
        : Decorator(std::move(component)), _width(width) {}

    void Draw() override {
        Decorator::Draw();
        DrawBorder();
    }
};

class ScrollDecorator : public Decorator {
private:
    void DrawScroll() {
        std::cout &lt;&lt; &quot;스크롤바 그리기&quot; &lt;&lt; std::endl;
    }
public:
    ScrollDecorator(std::unique_ptr&lt;VisualComponent&gt; component)
        : Decorator(std::move(component)) {}

    void Draw() override {
        Decorator::Draw();
        DrawScroll();
    }
};

int main() {
    auto textView = std::make_unique&lt;TextView&gt;();
    std::cout &lt;&lt; &quot;\n--- 기본 TextView ---&quot; &lt;&lt; std::endl;
    textView-&gt;Draw();

    auto textViewWithBorder = std::make_unique&lt;BorderDecorator&gt;(std::move(textView), 1);
    std::cout &lt;&lt; &quot;\n--- 테두리 추가된 TextView ---&quot; &lt;&lt; std::endl;
    textViewWithBorder-&gt;Draw();

    auto textViewWithBoth = std::make_unique&lt;ScrollDecorator&gt;(
        std::make_unique&lt;BorderDecorator&gt;(
            std::make_unique&lt;TextView&gt;(), 1));
    std::cout &lt;&lt; &quot;\n--- 테두리와 스크롤 모두 추가된 TextView ---&quot; &lt;&lt; std::endl;
    textViewWithBoth-&gt;Draw();

    return 0;
}</code></pre>
<p>위의 코드에서 중요하게 볼 점은, 바로 <strong><code>Decorator</code>들이 <code>VisualComponent* _component;</code>로 장식할 객체인 <code>TextView</code>를 멤버 변수로 받는 것</strong>이다. 이를 통해서 <strong><code>TextView</code>는 만약 <code>Decorator</code>를 사용하고 싶지 않으면, 해당 객체들을 만들 필요가 없다.</strong></p>
<p>다시 상기시키자면, <strong>장식자 패턴에서 가장 중요한 점은 바로 책임 전가</strong>이다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>Decorator 패턴은 기존 객체의 구조를 변경하지 않고, 런타임에 동적으로 새로운 기능과 책임을 부여할 수 있는 디자인 패턴이다. 상속 대신 객체를 감싸는 방식(Wrapper)을 사용하여 기능을 확장하므로, 필요할 때만 선택적으로 기능을 조합할 수 있고 클래스 폭발 문제를 피할 수 있다는 장점이 있다. 하지만 장식이 중첩될수록 구조가 복잡해지고, 디버깅이 어려워질 수 있으며, 너무 많은 데코레이터가 사용되면 유지보수 비용이 증가할 수 있다는 단점이 있다. 결국 이 패턴은 <strong>기능 확장이 빈번하고, 유연한 구조가 필요한 UI 컴포넌트나 모듈성 높은 시스템</strong>에서 특히 효과적으로 사용된다.</p>
<p>다음 글에서는 <strong>구조 패턴</strong> 중 <strong>Adapter Pattern</strong>에 대해 알아볼 예정이다. Adapter 패턴은 호환되지 않는 인터페이스를 가진 클래스들이 함께 동작할 수 있도록 연결하는 패턴으로, 기존 코드를 수정하지 않고 새로운 환경에 맞출 수 있다는 장점이 있다.</p>
<p><a href="https://www.cs.unc.edu/~stotts/GOF/hires/pat4dfso.htm">[참고] Decorator Pattern</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LLM] Chunking]]></title>
            <link>https://velog.io/@choi-hyk/LLM-Chunking</link>
            <guid>https://velog.io/@choi-hyk/LLM-Chunking</guid>
            <pubDate>Thu, 14 Aug 2025 03:26:50 GMT</pubDate>
            <description><![CDATA[<h1 id="chunking-🔧">Chunking 🔧</h1>
<p>이번에는 저번 Prompt Engineering에 이어서 Chunking에 대해서 알아보겠다.</p>
<p>청킹은 자연어를 특정 크기로 나누는 것을 의미한다. 각 나누어진 단위를 <strong>청크(chunk)</strong>라고 하며, 이러한 기법을 적용하는 도구를 <strong>텍스트 분할기(Text splitters)</strong>라고 한다. 따라서 텍스트 분할을 어떻게 하느냐에 따라서 청킹이 구성되는 방식이 달라진다. 중요한 점은 이러한 텍스트 분할은 분할을 적용하려는 자연어의 종류에 따라 나뉜다는 것이다.</p>
<h2 id="텍스트-분할기-text-splitters-📑">텍스트 분할기 (Text Splitters) 📑</h2>
<p>텍스트 분할은 <strong>RAG</strong>(Retrieval-Augmented Generation, 검색 증강 생성)에서 사용되는 핵심 기법이다. 긴 문서를 모델이 처리하기 쉬운 작은 단위인 청크로 나누어, 효율적인 검색과 정확한 답변 생성을 가능하게 한다.</p>
<p>문서를 나누는 구체적인 이유는 다음과 같다.</p>
<ul>
<li><strong>다양한 문서 길이 처리:</strong> 실제 문서들은 길이가 제각각이다. 분할을 통해 모든 문서를 일관된 크기로 처리할 수 있다.</li>
<li><strong>모델 한계 극복:</strong> 대부분의 임베딩 모델과 언어 모델은 입력 크기 제한이 있다. 분할을 통해 이 제한을 초과하는 문서를 처리할 수 있다.</li>
<li><strong>표현 품질 향상:</strong> 긴 문서는 임베딩 품질이 저하될 수 있다. 분할을 통해 각 섹션에 더 집중된, 정확한 표현을 생성할 수 있다.</li>
<li><strong>검색 정확도 향상:</strong> 정보 검색 시스템에서 분할은 검색 결과의 세분성을 높여, 질의와 관련된 문서 섹션을 더 정확하게 찾아낼 수 있게 한다.</li>
<li><strong>연산 자원 최적화:</strong> 작은 텍스트 청크로 작업하면 메모리 효율이 높아지고, 처리 작업을 병렬화하기 쉬워진다.</li>
</ul>
<p>텍스트 분할을 구현하는 방법은 여러 가지가 있다.</p>
<h3 id="길이를-기반으로-분할-length-based-📝">길이를 기반으로 분할 (Length-based) 📝</h3>
<p>가장 직관적인 방법으로, 정해진 길이(문자 또는 토큰 수)를 기준으로 문서를 나눈다.</p>
<ul>
<li><strong>특징:</strong> 구현이 간단하고, 청크 크기가 일관적이며, 모델의 요구사항에 맞추기 쉽다.</li>
<li><strong>유형:</strong> <strong>토큰 기반</strong>은 언어 모델에 유용하도록 토큰 수를 기준으로 나누고, <strong>문자 기반</strong>은 텍스트 유형에 관계없이 일관된 문자를 기준으로 나눈다.</li>
</ul>
<h3 id="텍스트-구조를-기반으로-분할-text-structured-based-✨">텍스트 구조를 기반으로 분할 (Text-structured based) ✨</h3>
<p>문단, 문장, 단어 등 텍스트의 계층적 구조를 활용하여 자연스러운 언어 흐름을 유지하며 분할한다.</p>
<ul>
<li><strong>특징:</strong> 문맥적 일관성을 보존하고, 텍스트의 세분화 수준에 맞게 조절한다.</li>
</ul>
<h3 id="문서-구조를-기반으로-분할-document-structured-based-📚">문서 구조를 기반으로 분할 (Document-structured based) 📚</h3>
<p>HTML, Markdown, JSON 등 문서 자체의 내재된 구조를 활용하여 분할한다.</p>
<ul>
<li><strong>특징:</strong> 문서의 논리적 조직을 보존하고, 각 청크 내의 문맥을 유지하며, 검색이나 요약 같은 후속 작업에 더 효과적이다.</li>
<li><strong>유형:</strong> Markdown의 헤더(<code>#</code>, <code>##</code>), HTML의 태그, JSON의 객체나 배열 등을 기준으로 분할한다.</li>
</ul>
<h3 id="의미적-유사도를-기반으로-분할-semantic-meaning-based-🤔">의미적 유사도를 기반으로 분할 (Semantic meaning based) 🤔</h3>
<p>텍스트의 내용적 의미를 직접 분석하여 분할한다.</p>
<ul>
<li><strong>특징:</strong> 의미적 변화가 큰 지점을 찾아 분할한다. 이를 통해 의미적으로 더욱 응집된 청크를 만들고, 후속 작업의 품질을 향상시킨다.</li>
<li><strong>예시:</strong> <strong>슬라이딩 윈도우(Sliding Window)</strong> 방식을 사용하여 문장 그룹의 임베딩을 생성하고, 임베딩 간의 유의미한 차이를 비교하여 분할 지점을 찾는다.</li>
</ul>
<p>참고로, 슬라이딩 윈도우는 특정 데이터를 어떠한 단위(윈도우)로 나누고, 그 데이터 단위들을 순회(슬라이딩)하며 작업을 진행하는 기법이다. 의미적 유사도 기반 분할의 경우, 특정 단위의 문장(윈도우)의 의미를 분석하여 임베딩을 하고, 다음 문장으로 넘어간 뒤(슬라이딩), 다시 해당 문장을 임베딩하여 임베딩된 문장 사이의 차이점을 알아내 분할 지점을 찾는 방식이다.</p>
<hr>
<h2 id="청크-chunk-🧩">청크 (Chunk) 🧩</h2>
<p>텍스트 분할을 진행할 때 고려해야 하는 점은 자연어를 청크로 나누는 단위인 <strong>청크 크기(Chunk Size)</strong>와, 나누어진 청크 사이의 관계인 <strong>청크 중첩(Chunk Overlap)</strong>을 유지하는 것이다. 인접한 청크 사이에 청크 중첩을 적용하여 문맥적 연속성을 유지시켜주는 작업을 진행한다.</p>
<p>청크 크기와 청크 중첩, 이 두 변수를 적용하여 텍스트 분할을 진행하게 되는데, 해당 변수의 크기에 따라 자연어를 분석하는 정도가 달라진다. 중요한 점은, 해당 자연어가 어떠한 종류의 자연어인지가 중요한데, 각 종류에 따라 문장의 복잡도와 구조가 다르기 때문이다.</p>
<p>청크 크기에 따라 좋은 성능을 보이는 문서들을 표로 정리하면 다음과 같다. 이는 문서의 구조와 내용적 특성을 기반으로 한다.</p>
<table>
<thead>
<tr>
<th align="left">특징</th>
<th align="left">작은 청크 크기(Small Chunk Size)</th>
<th align="left">큰 청크 크기(Large Chunk Size)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>적합한 문서 종류</strong></td>
<td align="left">질문-답변(FAQ) 문서, 기술 문서의 API 설명, 법률 조항, 단순 사실 목록</td>
<td align="left">학술 논문, 연구 보고서, 소설, 에세이, 복잡한 기술 매뉴얼, 심층 뉴스 기사</td>
</tr>
<tr>
<td align="left"><strong>효율성 근거</strong></td>
<td align="left">정확한 검색, 노이즈 감소, 컨텍스트 창 효율</td>
<td align="left">문맥 보존, 관계 추론 용이, 종합적 답변 가능</td>
</tr>
</tbody></table>
<p>청크 크기를 결정할 때는 문서의 내용이 얼마나 독립적인지와 문맥이 얼마나 중요한지를 기준으로 판단하는 것이 중요하다. 정형화되고 독립적인 내용이 많은 문서는 작은 청크가, 문맥적 흐름과 논리적 관계가 중요한 문서는 큰 청크가 더 효율적이다.</p>
<p>또한, 청크 오버랩은 보통 청크 크기의 <strong>10~20%</strong> 를 사용한다. 문서 구조와 내용에 따라 적절한 청크 크기를 찾아내고 10~20% 비율 안에서 최적의 오버랩 비율을 찾아내는 것이 좋은 텍스트 분할기를 만드는 방법이라 볼 수 있겠다.</p>
<p>다음 시간에는 <code>LangChain</code> 환경에서 간단한 청킹 실습을 진행해 보겠다.</p>
<p><a href="https://python.langchain.com/docs/concepts/text_splitters/">[참고] LangChain Text splitters</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LLM] Prompt Engineering]]></title>
            <link>https://velog.io/@choi-hyk/LLM-Promp-Engineering</link>
            <guid>https://velog.io/@choi-hyk/LLM-Promp-Engineering</guid>
            <pubDate>Wed, 13 Aug 2025 06:44:57 GMT</pubDate>
            <description><![CDATA[<h2 id="💻-prompt-engineering">💻 Prompt Engineering</h2>
<p>저번에는 LangChain을 통해서 Prompt Engineering을 적용하는 법을 알아보았는데, 사실 Prompt Engineering 최근 LLM을 구성하는데 있어서 매우 중요한 기술이다. 따라서 
<a href="https://www.promptingguide.ai/kr">Prompt Engineering Guide</a>에서 학습 한 내용을 내 나름대로 정리해 보았다.</p>
<h1 id="프롬프팅-엔지니어링이란-🤖">프롬프팅 엔지니어링이란? 🤖</h1>
<p>프롬프팅 엔지니어링은 사람이 모델의 프롬프트를 개발하는 행위를 이야기한다. 따라서 비교적 최근에 활발히 연구되고 있는 분야다. 프롬프팅 엔지니어링은 <strong>LLM</strong>(Large Language Model, 거대 언어 모델)의 역량을 향상시키고, LLM 및 기타 도구와 인터페이스를 형성할 수 있다. </p>
<p>또한 중요한 점은 바로 하드웨어적이 변경 없이 오직 내부의 자연어로 처리된 프롬프팅을 통해 성능을 개선한다는 점이다. 사실 지금은 크게 와 닿지는 않는다...</p>
<hr>
<h2 id="매개변수-parameters-⚙️">매개변수 (Parameters) ⚙️</h2>
<p>매개변수는 프롬프팅 엔지니어링에서 사용하는 용어들과 더불어 성능을 조절할 수 있는 변수들이다. 프레임워크, 도구를 사용해서 프롬프팅을 할 때 이러한 변수들을 사용해서 성능과 출력 결과를 조절하게 된다.</p>
<h3 id="temperature">Temperature</h3>
<p>모델이 생성하는 텍스트의 <strong>무작위성</strong>을 조절하는 매개변수다.</p>
<ul>
<li>낮은 값을 설정하면 확률이 가장 높은 단어를 선택하여 <strong>결정적이고 사실적인</strong> 응답을 생성한다. 이는 질의응답과 같은 작업에 적합하다.</li>
<li>높은 값을 설정하면 다양한 단어의 선택 가중치를 높여 <strong>다양하고 창의적인</strong> 응답을 촉진한다. 이는 시 창작과 같은 작업에 유용하다.</li>
</ul>
<h3 id="top-p">Top-p</h3>
<p>온도와 유사하게 텍스트 생성의 <strong>결정성</strong>을 제어하는 매개변수다.</p>
<ul>
<li>낮은 값을 설정하면 정확하고 사실적인 답변을 생성한다.</li>
<li>높은 값을 설정하면 보다 다양한 응답을 유도한다.</li>
<li>일반적으로 온도(temperature)와 Top-p 중 <strong>하나만</strong> 조정하는 것이 권장된다.</li>
</ul>
<h3 id="최대-길이-max-length">최대 길이 (Max Length)</h3>
<p>모델이 생성할 수 있는 <strong>최대 토큰(단어) 수</strong>를 설정하는 매개변수다. 길고 불필요한 응답을 방지하고 비용을 관리하는 데 도움이 된다.</p>
<h3 id="정지-시퀀스-stop-sequences">정지 시퀀스 (Stop Sequences)</h3>
<p>모델의 텍스트 생성을 <strong>중단시키는 특정 문자열</strong>이다. 응답의 길이와 구조를 제어하는 데 사용된다.</p>
<h3 id="빈도-페널티-frequency-penalty">빈도 페널티 (Frequency Penalty)</h3>
<p>이미 생성된 단어가 다시 등장할 확률을 낮추는 매개변수다. 값이 높을수록 모델의 응답에서 단어의 반복을 방지한다.</p>
<h3 id="존재-페널티-presence-penalty">존재 페널티 (Presence Penalty)</h3>
<p>이미 한 번이라도 등장한 단어에 동일한 페널티를 적용하여 <strong>반복을 방지</strong>하는 매개변수다.</p>
<ul>
<li>값이 높을수록 다양한 텍스트를 생성하는 데 도움이 되고, 낮은 값은 사실 기반의 집중적인 응답에 적합하다.</li>
<li>빈도 페널티와 존재 페널티 중 <strong>하나만</strong> 조정하거나 둘 다 조정하지 않는 것이 일반적인 권장 사항이다.</li>
</ul>
<hr>
<h2 id="프롬프팅-기법-prompting-techniques-✨">프롬프팅 기법 (Prompting Techniques) ✨</h2>
<h3 id="zero-shot">Zero-shot</h3>
<p>Zero-shot은 모델에게 예시를 제공하지 않고, 질문과 지시만으로 답변을 유도하는 기법이다. 모델의 사전 학습된 지식을 활용하여 직접적인 답변을 생성한다.</p>
<pre><code class="language-prompt">텍스트를 중립, 부정 또는 긍정으로 분류합니다.
텍스트: 휴가는 괜찮을 것 같아요.
감정:</code></pre>
<pre><code class="language-output">중립</code></pre>
<p>이 프롬프트는 별도의 배경 정보나 예시 없이 모델에게 바로 질문을 던진다. 예제를 제시하지 않지만 모델의 사전 학습된 지식을 통해서 답변을 하게 된다. LLM은 뛰어난 제로샷 능력을 보여준다고 한다. 하지만 복잡한 작업에서는 개선이 필요하다.</p>
<h3 id="few-shot">Few-shot</h3>
<p>Few-shot은 원하는 답변의 패턴을 모델에게 학습시키기 위해 질문과 함께 몇 가지 예시를 제공하는 기법이다. 이를 통해 모델은 예시의 형식을 모방하여 답변을 생성한다.</p>
<pre><code class="language-prompt">정말 멋지네요! // 긍정
이건 나쁘다! // 부정
와우 그 영화 정말 멋졌어요! // 긍정
정말 끔찍한 쇼였어! // 부정</code></pre>
<p>이 프롬프트는 답변을 유도해서 정확한 출력을 하게 한다.</p>
<pre><code class="language-prompt">정말 멋지네요! // 부정
이건 나쁘다! // 긍정
와우 그 영화 정말 멋졌어요! // 긍정
정말 끔찍한 쇼였어! //</code></pre>
<p>현재 대규모 모델들은 이러한 Zero-shot, Few-shot 전부 가능하며, 위와 같이 무작위로 입력된 프롬프트에 대해서도 정확한 답변을 가져올 수 있다고 한다. 하지만 Few-shot 프롬프트도 복잡한 추론 작업에서는 완벽한 기술은 아니다.</p>
<pre><code class="language-prompt">이 집합 {15, 32, 5, 13, 82, 7, 1}에서 홀수를 모두 더하면 짝수이다.
A:</code></pre>
<pre><code class="language-output">네, 이 집합의 홀수를 모두 더하면 짝수인 107입니다.</code></pre>
<p>만약 질의 결과가 이렇게 나왔을 때는 다른 방식으로 프롬프팅을 진행해야 한다.</p>
<pre><code class="language-prompt">이 집합 {4, 8, 9, 15, 12, 2, 1}에서 홀수의 합은 짝수입니다.
A: 답은 거짓입니다.
이 집합 {17, 10, 19, 4, 8, 12, 24}에서 홀수의 합은 짝수입니다.
A: 정답은 참입니다.
이 집합 {16, 11, 14, 4, 8, 13, 24}에서 홀수의 합은 짝수입니다.
A: 답은 참입니다.
이 집합 {17, 9, 10, 12, 13, 4, 2}에서 홀수의 합은 짝수입니다.
A: 답은 거짓입니다.
이 집합 {15, 32, 5, 13, 82, 7, 1}에서 홀수의 합은 짝수입니다.
A:</code></pre>
<pre><code class="language-output">답은 참입니다.</code></pre>
<p>위의 결과로 프롬프팅을 해도 안 되는 경우가 존재할 것이다. 이럴 때 필요한 것이 <strong>Chain-of-Thought</strong>다.</p>
<h3 id="chain-of-thought-cot-🧠">Chain-of-Thought (CoT) 🧠</h3>
<p>CoT는 모델에게 &quot;단계별로 생각하라&quot;는 지시를 내려 복잡한 문제를 논리적인 사고 과정을 통해 해결하도록 유도하는 기법이다. 이를 통해 모델의 추론 오류를 줄이고 정확도를 높인다.</p>
<p>이 기법이 프롬프트 엔지니어링의 핵심이되는 추론 과정을 탑재한 기법이다.</p>
<p>앞으로 나오는 기법들은 해당 CoT를 활용해서 추론 과정을 바꾸고 여러가지 도구를 중간에 넣어서 성능을 향상시킨 것이다.</p>
<img width="940" height="473" alt="Image" src="https://github.com/user-attachments/assets/8dd72947-614d-42be-85ed-e93bb831263e" />

<pre><code class="language-prompt">집합 {4, 8, 9, 15, 12, 2, 1}에서 홀수를 모두 더하면 짝수야.
답변: 홀수(9, 15, 1)를 모두 더하면 25가 돼. 위의 명제는 거짓이야.
집합 {17, 10, 19, 4, 8, 12, 24}에서 홀수를 모두 더하면 짝수야.
답변: 홀수(17, 19)를 모두 더하면 36이 돼. 위의 명제는 참이야.
집합 {16, 11, 14, 4, 8, 13, 24}에서 홀수를 모두 더하면 짝수야.
답변: 홀수(11, 13)를 모두 더하면 24가 돼. 위의 명제는 참이야.
집합 {17, 9, 10, 12, 13, 4, 2}에서 홀수를 모두 더하면 짝수야.
답변: 홀수(17, 9, 13)를 모두 더하면 39가 돼. 위의 명제는 거짓이야.
집합 {15, 32, 5, 13, 82, 7, 1}에서 홀수를 모두 더하면 짝수야.
답변:</code></pre>
<pre><code class="language-output">홀수(15, 5, 13, 7, 1)를 모두 더하면 41이 돼. 위의 명제는 거짓이야.</code></pre>
<p>위의 프롬프팅과 같이 추론 과정을 입력하면 모델이 훨씬 정확한 답변을 하게 된다.</p>
<h3 id="zero-shot-cot-prompting-🚀">Zero-shot CoT Prompting 🚀</h3>
<p><img src="https://velog.velcdn.com/images/choi-hyk/post/ca29a440-a5fa-4486-ab82-c3ba713b3934/image.png" alt=""></p>
<p>모델의 성능이 향상되면서, <code>Let&#39;s think step by step</code>과 같은 질의 예시가 없는 Zero-shot이어도 모델이 추론 도출을 진행한다는 것이다.</p>
<p>현재의 LLM들은 이러한 제로샷 능력을 가지고 있다고 보면 된다.</p>
<pre><code class="language-prompt">나는 시장에 가서 사과 10개를 샀어. 사과 2개를 이웃에게 주고, 2개를 수리공에게 주었어. 그리고 사과 5개를 더 사서 1개는 내가 먹었어. 나는 몇 개의 사과를 가지고 있었니?</code></pre>
<pre><code class="language-output">사과 11개</code></pre>
<p>여기서 <strong>Zero-shot CoT Prompting</strong>을 적용하면 이렇게 구현할 수 있다.</p>
<pre><code class="language-prompt">나는 시장에 가서 사과 10개를 샀어. 나는 사과 2개를 이웃에게 주고, 2개를 수리공에게 주었어.
그리고 사과 5개를 더 사서 1개는 내가 먹었어.
사과가 몇 개나 남았니?
단계별로 생각해 보자.</code></pre>
<pre><code class="language-output">먼저 사과 10개로 시작했어.
이웃에게 2개, 수리공에게 2개를 나누어 주었으므로 사과가 6개가 남았어.
그런 다음 사과 5개를 더 사서 이제 사과가 11개가 되었어.
마지막으로 사과 1개를 먹었으므로 사과 10개가 남게 된다.</code></pre>
<h3 id="self-consistency-🔄">Self-Consistency 🔄</h3>
<p>Self-Consistency는 CoT를 확장한 기법으로, 같은 질문에 대해 여러 가지 다른 사고 경로를 탐색하여 다양한 답변을 생성한 뒤, 가장 일관된 답변을 최종 결과로 선택하는 방식이다.</p>
<p>참고로 디코딩은 LLM의 사고를 바탕으로 실제 텍스트를 선택해서 결과를 내놓는 과정이다. LLM에서 많이 쓰이는 디코딩 방식은 크게 세 가지로 요약할 수 있다. 이 방식들은 각각 속도, 품질, 다양성 측면에서 서로 다른 장단점을 가지고 있으며, 해결하고자 하는 문제의 성격에 따라 적절한 방식을 선택하여 사용한다.</p>
<p><strong>탐욕 알고리즘 (Greedy Decoding)</strong></p>
<p>가장 단순하고 빠른 방식으로, 매 단계에서 확률이 <strong>가장 높은 단어 하나만을</strong> 선택한다.</p>
<ul>
<li><strong>속도:</strong> 매우 빠르다.</li>
<li><strong>결과:</strong> 매번 동일한 결과를 생성하며, 다양성이 전혀 없다.</li>
<li><strong>장점:</strong> 계산 비용이 매우 낮아 리소스 소모가 적다.</li>
<li><strong>단점:</strong> 지역 최적해(local optimum)에 빠져 최적의 문맥을 놓칠 수 있다.</li>
</ul>
<p><strong>빔 서치 (Beam Search)</strong></p>
<p>매 단계에서 가장 확률이 높은 **K개의 단어(빔)**를 동시에 추적하며, 여러 가능한 경로를 탐색한다. K개의 경로 중 최종적으로 가장 높은 확률의 문장을 선택한다.</p>
<ul>
<li><strong>속도:</strong> 탐욕 알고리즘보다 느리다.</li>
<li><strong>결과:</strong> 탐욕 알고리즘보다 더 나은 품질의 결과를 생성할 가능성이 높다.</li>
<li><strong>장점:</strong> 탐욕 알고리즘의 한계를 보완하며, 전체적인 문맥을 고려한다.</li>
<li><strong>단점:</strong> 계산 비용이 높고, 생성된 문장이 다소 반복적이거나 일관성이 떨어질 수 있다.</li>
</ul>
<p><strong>샘플링 (Sampling)</strong></p>
<p>확률이 가장 높은 단어 하나만 선택하는 것이 아니라, 예측된 확률 분포에 기반하여 <strong>무작위로 단어를 선택하는 방식</strong>이다.</p>
<ul>
<li><strong>속도:</strong> 탐욕 알고리즘과 비슷하거나 조금 더 느리다.</li>
<li><strong>결과:</strong> 매번 다른 결과물을 생성하며, 다양성과 창의성이 높다.</li>
<li><strong>장점:</strong> 창의적인 글쓰기나 Self-Consistency와 같이 여러 다른 추론 경로를 생성하는 데 필수적이다.</li>
<li><strong>단점:</strong> 때때로 의미 없는 문장이나 논리적이지 않은 결과물을 생성할 수 있다.</li>
</ul>
<p>만약 탐욕 알고리즘 디코딩으로 자연어를 생성한다면, 확률이 높은 결과로 나아가는 특징이 있지만, 만약 지역 최적해에 빠지게 되면 최악의 결과를 내놓게 된다. 따라서 이를 보완한 것이 샘플링을 사용하는 Self-Consistency이다.</p>
<pre><code class="language-prompt">내가 6살이었을 때 내 여동생은 내 나이의 절반인 나이였어.
지금 나는 70살이라면 여동생은 몇 살일까?</code></pre>
<pre><code class="language-output">35</code></pre>
<p>위의 프롬프팅을 CoT를 적용할 수 있다.</p>
<pre><code class="language-prompt">Q: 과수원에는 15그루의 나무가 있어. 과수원의 일꾼들이 오늘 과수원에 나무를 심을 거야. 나무 심기가 끝나면 21그루의 나무가 있을 거야. 오늘 과수원 일꾼들은 몇 그루의 나무를 심었을까?
A: 15그루로 시작합니다. 나중에 나무가 21그루가 됩니다. 그 차이가 그들이 심은 나무의 수일 것입니다. 따라서 그들은 21 - 15 = 6그루의 나무를 심었어야 합니다. 정답은 6입니다.
Q: 주차장에 3대의 차량이 있고, 2대의 차량이 더 도착하면 주차장에 몇 대의 차량이 있을까?
A: 주차장에 이미 3대의 차량이 있습니다. 2대가 더 도착합니다. 이제 3 + 2 = 5대의 차량이 있습니다. 정답은 5입니다.
Q: 지호는 초콜릿을 32개, 여동생은 42개를 가지고 있었어. 둘이 35개를 먹었다면 총 몇 개가 남았을까?
A: 레아는 초콜릿 32개, 레아의 여동생은 42개를 가지고 있었습니다. 즉, 원래 32개 + 42개 = 74개의 초콜릿이 있었습니다. 35개를 먹었습니다. 따라서 총 74 - 35 = 39개의 초콜릿이 남아 있습니다. 정답은 39입니다.
Q: 선우는 막대 사탕을 20개 가지고 있었어. 그는 두리에게 막대 사탕을 몇 개 주었어. 이제 선우는 막대 사탕 12개를 가지고 있어. 선우는 두리에게 몇 개의 막대 사탕을 줬을까?
A: 선우는 막대 사탕 20개를 가지고 있었습니다. 이제 선우는 12개만 가지고 있으므로, 나머지는 두리에게 주었을 것입니다. 선우가 두리에게 준 막대 사탕의 수는 20 - 12 = 8개다. 정답은 8개다.
Q: 내가 6살이었을 때 내 여동생은 내 나이의 절반인 나이였어. 지금 나는 70살이고 내 여동생은 몇 살일까?
A:</code></pre>
<p>이렇게 CoT를 진행하면 샘플링으로 여러 개의 답이 나올 것이다.</p>
<pre><code class="language-output">내가 6살이었을 때 여동생은 내 나이의 절반이었으므로 3살이었다. 이제 나는 70살이므로 여동생은 70 - 3 = 67살이다. 정답은 67이다.</code></pre>
<pre><code class="language-output">화자가 6살이었을 때 여동생은 화자의 절반 나이인 3살이었다. 이제 화자가 70살이 되었으므로 여동생은 70 - 3 = 67살이 된다. 정답은 67이다.</code></pre>
<pre><code class="language-output">내가 6살이었을 때 여동생은 내 나이의 절반이었으므로 3살이고, 이제 나는 70살이므로 여동생은 70/2 = 35살이다. 정답은 35이다.</code></pre>
<p>디코딩으로 3개의 출력이 나왔고 이제 <strong>Self-Consistency</strong>는 다수결 합의를 통해 67과 35 중 더 많은 67을 선택하게 된다.</p>
<hr>
<h3 id="generative-knowledge-prompting-💡">Generative knowledge prompting 💡</h3>
<p>Generative knowledge prompting은 말 그대로 프롬프팅에 사용할 지식을 미리 생성하는 기법이다.</p>
<pre><code class="language-prompt">골프의 목적 중 하나는 다른 사람보다 더 높은 점수를 얻기 위해 노력하는 것이다.
예, 아니오?</code></pre>
<pre><code class="language-output">예.</code></pre>
<p>골프는 홀에 공을 넣어서, 타수가 최저가 되게 해야 한다. 이때 타수는 점수로 계산되므로, 점수가 낮도록 노력해야 된다. 따라서 해당 출력은 오답이다. 이를 개선하려면 위와 같은 형태의 질문이 들어왔을 때 해당 주제의 전반적인 지식을 지시하여 지식 수준을 높이는 프롬프팅이 가능하다.</p>
<p>만약 LLM에게 해당 질문을 하기 전에, <code>골프에 대한 지식을 알려줘</code>라고 입력하면 골프에 대한 지식을 출력할 것이다.</p>
<pre><code class="language-prompt">Input: 그리스는 멕시코보다 크다.
Knowledge: 그리스는 약 131,957 제곱 킬로미터이고, 멕시코는 약 1,964,375 제곱 킬로미터로 멕시코가 그리스보다 1389% 더 크다.
Input: 안경은 항상 김이 서린다.
Knowledge: 안경 렌즈에는 땀, 호흡 및 주변 습도에서 나오는 수증기가 차가운 표면에 닿아 식은 다음 작은 액체 방울로 변하여 안개처럼 보이는 막을 형성할 때 응결이 발생한다. 특히 외부 공기가 차가울 때는 호흡에 비해 렌즈가 상대적으로 차가워진다.
Input: 물고기는 생각할 수 있다.
Knowledge: 물고기는 보기보다 훨씬 더 똑똑하다. 기억력과 같은 많은 영역에서 물고기의 인지 능력은 인간이 아닌 영장류를 포함한 &#39;고등&#39; 척추동물과 비슷하거나 그 이상이다. 물고기의 장기 기억력은 복잡한 사회적 관계를 추적하는 데 도움이 된다.
Input: 평생 담배를 피우는 것의 일반적인 결과는 폐암에 걸릴 확률이 정상보다 높다는 것입니다.
Knowledge: 평생 동안 하루 평균 담배를 한 개비 미만으로 꾸준히 피운 사람은 비흡연자보다 폐암으로 사망할 위험이 9배 높았다. 하루에 한 개비에서 열 개비 사이의 담배를 피운 사람들은 폐암으로 사망할 위험이 비흡연자보다 거의 12배 높았다.
Input: 돌은 조약돌과 같은 크기다.
Knowledge: 조약돌은 퇴적학의 우든-웬트워스 척도에 따라 입자 크기가 4~64밀리미터인 암석 덩어리다. 조약돌은 일반적으로 과립(직경 2~4밀리미터)보다는 크고 자갈(직경 64~256밀리미터)보다는 작은 것으로 간주된다.
Input: 골프의 목적 중 하나는 다른 사람보다 더 높은 점수를 얻기 위해 노력하는 것이다.
Knowledge:</code></pre>
<p>위의 출력 마지막 질문에 답을 하기 전에 LLM은 골프에 대한 지식을 생성할 것이다.</p>
<pre><code class="language-knowledge"># Knowledge 1
골프의 목적은 최소의 스트로크로 한 세트의 홀을 플레이하는 것이다. 골프 라운드는 일반적으로 18홀로 구성된다.
각 홀은 표준 골프 코스에서 라운드 중 한 번씩 플레이된다. 각 스트로크는 1점으로 계산되며 총 스트로크 수를 사용하여 게임의 승자를 결정한다.

# Knowledge 2
골프는 경쟁하는 선수(또는 골퍼)가 여러 종류의 클럽을 사용하여 가장 적은 수의 스트로크로 코스에 있는 한 세트의 홀에 공을 치는 정밀한 클럽 앤 볼 스포츠다.
각 홀에서 기록한 총 타수를 합산하여 계산하는 점수가 최저가 되도록 코스를 완주하는 것이 목표다.
가장 낮은 점수를 기록한 플레이어가 게임에서 승리한다.</code></pre>
<p>이렇게 지식이 생성이 되면, LLM은 훨씬 쉽게 추론이 가능할 것이다.</p>
<hr>
<h3 id="prompt-chaining-⛓️">Prompt Chaining ⛓️</h3>
<p>프롬프트 체이닝 기법은 LLM의 작업을 하위 작업으로 나누는 것이다. 만약 하나의 작업에 대해 여러 개의 하위 작업으로 나누어지게 된다면, 각 응답을 서로 활용해서 보완이 가능하다. 이러한 연쇄적인 작용을 <strong>프롬프트 체이닝</strong>이라고 한다.</p>
<p>프롬프트 체이닝은 이러한 성능 개선뿐만 아니라 안전성과 작업의 직관성, 투명성을 높여줘서 사람 기준에서 디버깅과 분석을 하기에 용이하게 한다. 특히 문서 기반 자료를 분석할 때 유용한데, 아래 예시가 있다.</p>
<pre><code class="language-prompt"># Prompt 1
You are a helpful assistant. Your task is to help answer a question given a document. The first step is to extract quotes relevant to the question from the document, delimited by ####. Please output the list of quotes using &lt;quotes&gt;&lt;/quotes&gt;. Respond with &quot;No relevant quotes found!&quot; if no relevant quotes were found.
####
{{document}}
####</code></pre>
<p>이렇게 인용문을 <code>####</code> 기준으로 나누어서 생성해 달라고 지시할 수 있다.</p>
<pre><code class="language-prompt">Given a set of relevant quotes (delimited by &lt;quotes&gt;&lt;/quotes&gt;) extracted from a document and the original document (delimited by ####), please compose an answer to the question. Ensure that the answer is accurate, has a friendly tone, and sounds helpful.
####
{{document}}
####
&lt;quotes&gt;
- Chain-of-thought (CoT) prompting[27]
- Generated knowledge prompting[37]
- Least-to-most prompting[38]
- Self-consistency decoding[39]
- Complexity-based prompting[41]
- Self-refine[42]
- Tree-of-thought prompting[43]
- Maieutic prompting[45]
- Directional-stimulus prompting[46]
- Textual inversion and embeddings[59]
- Using gradient descent to search for prompts[61][62][63][64]
- Prompt injection[65][66][67]
&lt;/quotes&gt;</code></pre>
<p>그러면 이렇게 출력이 나오게 되면 다시 해당 인용문을 사용해서 프롬프팅할 수 있다.</p>
<pre><code class="language-output">The prompting techniques mentioned in the document include:
1. Chain-of-thought (CoT) prompting[27]
2. Generated knowledge prompting[37]
3. Least-to-most prompting[38]
4. Self-consistency decoding[39]
5. Complexity-based prompting[41]
6. Self-refine[42]
7. Tree-of-thought prompting[43]
8. Maieutic prompting[45]
9. Directional-stimulus prompting[46]
10. Textual inversion and embeddings[59]
11. Using gradient descent to search for prompts[61][62][63][64]
12. Prompt injection[65][66][67]
Each of these techniques employs unique strategies to enhance or specify the interactions with large language models to produce the desired outcomes.</code></pre>
<p>이제 모델은 인용문과 문서를 기반으로 응답하여 훨씬 정확한 답변을 할 것이다.</p>
<hr>
<h3 id="tree-of-thoughts-tot-🌳">Tree of Thoughts (ToT) 🌳</h3>
<p>해당 기법은 기존의 CoT에서 여러 가지의 의견을 가진 추론 과정을 생성하는 방법을 사용한다. 입력이 들어오면 위의 그림과 같이 여러 개의 추론 과정을 생성해낸다. 이때, 추론 과정에서 생성된 생각들을 평가하여 가장 높은 확률의 생각을 판단한다.</p>
<img width="1083" height="550" alt="Image" src="https://github.com/user-attachments/assets/20a63b8c-6f5b-4989-9412-615566e6e326" />

<img width="845" height="244" alt="Image" src="https://github.com/user-attachments/assets/76bfbc54-0121-40be-8997-174af2905d39" />

<p>위의 그림에서 <strong>Propose Prompt</strong>는 프롬프팅이 추론하여 생각을 생성하는 과정이다. <strong>Value prompt</strong>는 값을 평가하여 각 생각들의 정답 도달 확률을 구하게 된다.
<img width="803" height="307" alt="Image" src="https://github.com/user-attachments/assets/92b1c71e-8ca3-4919-9e02-5b817eb9b095" /></p>
<p>연구 결과를 보면 ToT가 다른 기법들보다 월등히 뛰어나다고 한다. 그러나 연산이 매우 오래 걸려서 고도화된 작업이나 퍼즐 문제 등 복잡한 연산 문제를 처리하는 데 적절하다.</p>
<p><a href="https://www.promptingguide.ai/kr">[참고] Prompt Engineering Guide</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LangChain] Managing Conversation History]]></title>
            <link>https://velog.io/@choi-hyk/LangChain-Managing-Conversation-History</link>
            <guid>https://velog.io/@choi-hyk/LangChain-Managing-Conversation-History</guid>
            <pubDate>Tue, 12 Aug 2025 08:00:59 GMT</pubDate>
            <description><![CDATA[<h2 id="🗂️-managing-conversation-history">🗂️ Managing Conversation History</h2>
<p>이번에는 저번에 이어서 대화 맥락을 유지시켜 주는 trimmer 기능에 대해서 알아보겠다. Chatbot은 지금 하는 대화와 이전에 나눈 대화도 기억해서 사용자와 대화해야 한다. 이것을 가능하게 해주는 것이 trimmer 함수이다.</p>
<hr>
<h3 id="✂️-trim_messages">✂️ trim_messages</h3>
<pre><code class="language-python">trimmer = trim_messages(
    max_tokens=512,
    strategy=&quot;last&quot;,
    token_counter=model,
    include_system=True,
    allow_partial=False,
    start_on=&quot;human&quot;,
)

messages = [
    SystemMessage(content=&quot;you&#39;re a good assistant&quot;),
    HumanMessage(content=&quot;hi! my name is HYK&quot;),
    AIMessage(content=&quot;hi! HYK!&quot;),
    HumanMessage(content=&quot;My favorite color is blue.&quot;),
    AIMessage(content=&quot;nice color!&quot;),
    HumanMessage(content=&quot;My favorite movie is DarkKnight.&quot;),
    AIMessage(content=&quot;nice movie!&quot;),
    HumanMessage(content=&quot;whats 2 + 2&quot;),
    AIMessage(content=&quot;4&quot;),
    HumanMessage(content=&quot;thanks&quot;),
    AIMessage(content=&quot;no problem!&quot;),
    HumanMessage(content=&quot;having fun?&quot;),
    AIMessage(content=&quot;yes!&quot;),
]

trimmer.invoke(messages)</code></pre>
<p>위의 코드는 메시지 트리머를 정의한 코드이다. 토큰을 충분히 크게 주어서 이전 대화를 최대한 많이 기억할 수 있도록 하였다. 만약 토큰을 적게 할당하면 이전에 나눈 많은 양의 대화를 잊을 것이다. <code>messages</code> 변수는 Chatbot에게 메시지를 주입하기 위해 설정한 배열이다.</p>
<p>이를 통해 Chatbot은 해당 대화 내용을 기억하고 있게 된다.</p>
<pre><code class="language-python">def call_model(state: State):
    trimmed_messages = trimmer.invoke(state[&quot;messages&quot;])
    prompt = prompt_template.invoke(
        {&quot;messages&quot;: trimmed_messages, &quot;language&quot;: state[&quot;language&quot;]}
    )
    response = model.invoke(prompt)
    return {&quot;messages&quot;: response}</code></pre>
<p>이렇게 모델을 정의할 때, <code>trimmed_messages = trimmer.invoke(state[&quot;messages&quot;])</code>를 삽입해 준다.</p>
<pre><code class="language-python">query = &quot;What&#39;s my name?.&quot;
language = &quot;Korean&quot;
input_messages = messages + [HumanMessage(query)]
output = app.invoke(
    {&quot;messages&quot;: input_messages, &quot;language&quot;: language},
    config,
)
output[&quot;messages&quot;][-1].pretty_print()

query = &quot;What&#39;s my favorite color?&quot;
language = &quot;English&quot;
input_messages = messages + [HumanMessage(query)]
output = app.invoke(
    {&quot;messages&quot;: input_messages, &quot;language&quot;: language},
    config,
)
output[&quot;messages&quot;][-1].pretty_print()

query = &quot;내가 가장 좋아하는 영화는?&quot;
input_messages = messages + [HumanMessage(query)]
language = &quot;Korean&quot;
output = app.invoke(
    {&quot;messages&quot;: input_messages, &quot;language&quot;: language},
    config,
)</code></pre>
<p>마지막으로 <code>input_messages = messages + [HumanMessage(query)]</code>를 통해 정의한 메시지를 주입해 주면 된다.</p>
<h4 id="💬-answer">💬 answer</h4>
<pre><code>================================== Ai Message ==================================

당신의 이름은 HYK입니다.
================================== Ai Message ==================================

Your favorite color is blue.
================================== Ai Message ==================================

당신이 가장 좋아하는 영화는 다크 나이트입니다.</code></pre><p>이렇게 trimmer까지 구현을 완료했고, 다음 시간에는 RAG(Retrieval Augmented Generation)의 개념에 대해서 알아보겠다.</p>
<p><a href="https://python.langchain.com/docs/tutorials/chatbot/">[참고] LangChain Build a Chatbot</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LangChain] Prompt Engineering]]></title>
            <link>https://velog.io/@choi-hyk/LangChain-Prompting-Engineering</link>
            <guid>https://velog.io/@choi-hyk/LangChain-Prompting-Engineering</guid>
            <pubDate>Tue, 12 Aug 2025 07:40:39 GMT</pubDate>
            <description><![CDATA[<h2 id="🎯-prompt-engineering">🎯 Prompt Engineering</h2>
<p>저번 시간에 이어서 이번에는 Chatbot을 프롬프팅해서 지시를 내리는 작업을 하겠다. 프롬프팅은 간단하다. 프롬프팅의 기능으로는 자신이 원하는 스타일의 모델을 생성 가능하도록 자연어 지시를 내리는 것이다. 또한 언어를 설정하는 기능도 존재한다.</p>
<hr>
<h3 id="📝-prompting-template">📝 Prompting Template</h3>
<pre><code class="language-python">import os
from typing import Sequence
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
from typing_extensions import Annotated, TypedDict
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, AIMessage
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, MessagesState, StateGraph
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

load_dotenv()
key = os.getenv(&quot;GOOGLE_API_KEY&quot;)
if not key:
    raise EnvironmentError(&quot;GOOGLE_API_KEY not found in .env&quot;)

model = init_chat_model(&quot;gemini-2.5-flash&quot;, model_provider=&quot;google_genai&quot;)

prompt_template = ChatPromptTemplate.from_messages(
    [
        (
            &quot;system&quot;,
            &quot;You are a happy assistant. Answer all questions with a smile.&quot;,
        ),
        MessagesPlaceholder(variable_name=&quot;messages&quot;),
    ]
)


class State(TypedDict):
    messages: Annotated[Sequence[BaseMessage], add_messages]


workflow = StateGraph(state_schema=State)


def call_model(state: State):
    prompt = prompt_template.invoke(state)
    response = model.invoke(prompt)
    return {&quot;messages&quot;: response}


workflow.add_edge(START, &quot;model&quot;)
workflow.add_node(&quot;model&quot;, call_model)

memory = MemorySaver()
app = workflow.compile(checkpointer=memory)

config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;abc123&quot;}}

query = &quot;Hi! I&#39;m HYK.&quot;
input_messages = [HumanMessage(query)]
output = app.invoke(
    {
        &quot;messages&quot;: input_messages,
    },
    config,
)
output[&quot;messages&quot;][-1].pretty_print()

query = &quot;What&#39;s my name?&quot;
input_messages = [HumanMessage(query)]
output = app.invoke(
    {
        &quot;messages&quot;: input_messages,
    },
    config,
)
output[&quot;messages&quot;][-1].pretty_print()

query = &quot;How are you today?&quot;
input_messages = [HumanMessage(query)]
output = app.invoke(
    {
        &quot;messages&quot;: input_messages,
    },
    config,
)
output[&quot;messages&quot;][-1].pretty_print()</code></pre>
<p>위의 코드를 살펴보자.</p>
<pre><code class="language-python">prompt_template = ChatPromptTemplate.from_messages(
    [
        (
            &quot;system&quot;,
            &quot;You are a happy assistant. Answer all questions with a smile.&quot;,
        ),
        MessagesPlaceholder(variable_name=&quot;messages&quot;),
    ]
)</code></pre>
<p>일단 나는 행복한 느낌의 답변을 생성하는 프롬프팅을 하였다.</p>
<pre><code class="language-python">def call_model(state: State):
    prompt = prompt_template.invoke(state)
    response = model.invoke(prompt)
    return {&quot;messages&quot;: response}</code></pre>
<p><code>call_model()</code>에 prompt를 넣어서 구동을 시켜보자.</p>
<h4 id="💡-answer">💡 Answer</h4>
<pre><code>================================== Ai Message ==================================

Hello HYK! It&#39;s so lovely to meet you! 😊 I&#39;m thrilled to be your happy assistant today! How can I help you?
================================== Ai Message ==================================

Why, your name is HYK! 😄 It&#39;s a pleasure to remember! Is there anything else I can help you with, HYK?
================================== Ai Message ==================================

Oh, I&#39;m absolutely wonderful today, thank you for asking! 😊 I&#39;m bubbling with positive energy and ready to assist you with a big smile! How about you, HYK? I hope you&#39;re having a fantastic day too!
</code></pre><p>이렇게 억지로 이모티콘을 쓰면서 행복해하는 모델을 볼 수 있다.</p>
<hr>
<h3 id="🌐-prompting-language">🌐 Prompting Language</h3>
<p>이제 원하는 언어를 지시해 보겠다.</p>
<pre><code class="language-python">prompt_template = ChatPromptTemplate.from_messages(
    [
        (
            &quot;system&quot;,
            &quot;You are a happy assistant. Answer all questions with a smile and in {language}.&quot;,
        ),
        MessagesPlaceholder(variable_name=&quot;messages&quot;),
    ]
)</code></pre>
<p>이렇게 마지막에 <code>{language}</code>로 말해 달라고 하면 모델을 정의할 때 들어간 언어 변수로 답변을 해주게 된다.</p>
<pre><code class="language-python">def call_model(state: State):
    prompt = prompt_template.invoke(state)
    response = model.invoke(prompt)
    return {&quot;messages&quot;: response}</code></pre>
<p>이제 언어를 각 메시지마다 설정해 보자.</p>
<pre><code class="language-python">query = &quot;Hi! I&#39;m HYK.&quot;
language = &quot;Korean&quot;
input_messages = [HumanMessage(query)]
output = app.invoke(
    {&quot;messages&quot;: input_messages, &quot;language&quot;: language},
    config,
)
output[&quot;messages&quot;][-1].pretty_print()

query = &quot;What&#39;s my name?&quot;
language = &quot;Spanish&quot;
input_messages = [HumanMessage(query)]
output = app.invoke(
    {&quot;messages&quot;: input_messages, &quot;language&quot;: language},
    config,
)
output[&quot;messages&quot;][-1].pretty_print()

query = &quot;How are you today?&quot;
input_messages = [HumanMessage(query)]
language = &quot;Japanese&quot;
output = app.invoke(
    {&quot;messages&quot;: input_messages, &quot;language&quot;: language},
    config,
)</code></pre>
<h4 id="💡-answer-1">💡 Answer</h4>
<pre><code>================================== Ai Message ==================================

안녕하세요, HYK님! 만나 뵙게 되어 정말 반갑습니다! 😊
================================== Ai Message ==================================

¡Claro que sí, HYK! ¡Tu nombre es HYK! 😊 ¡Es un placer conocerte!
================================== Ai Message ==================================

こんにちは！私はとても元気です、ありがとうございます！😊 HYKさんもお元気ですか？</code></pre><p>이렇게 여러 가지 언어로 답변을 해주는 것을 볼 수 있다. 다음 시간에는 trimming을 통해 성능을 향상시키는 방법을 알아보겠다.</p>
<p><a href="https://python.langchain.com/docs/tutorials/chatbot/">실습 출처: Build a Chatbot</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[LangChain] Chatbot으로 LangChain 시작하기]]></title>
            <link>https://velog.io/@choi-hyk/LangChain-Chatbot%EC%9C%BC%EB%A1%9C-LangChain-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@choi-hyk/LangChain-Chatbot%EC%9C%BC%EB%A1%9C-LangChain-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 12 Aug 2025 03:36:42 GMT</pubDate>
            <description><![CDATA[<h2 id="🚀-langchain-시작하기">🚀 LangChain 시작하기</h2>
<p>LangChain은 요즘 핫한 AI 애플리케이션 개발을 도와주는 오픈소스 라이브러리이다. 간단하게 말해 LangChain은 <strong>LLM(Large Language Model)</strong> 앱을 빠르게 조립하는 파이프라인 프레임워크인데, 프롬프트 설계부터 도구 호출, 검색 연동까지 구성 요소를 작은 블록처럼 연결하는 것이 핵심이다.</p>
<img width="643" height="432" alt="Image" src="https://github.com/user-attachments/assets/0336d833-4f52-4a0e-a62d-ef1d7fcc6acc" />

<p>위 그림은 LangChain에서 사용하는 도구들을 나눈 것이라 생각하면 된다.
한번 역할을 간단하게 살펴보겠다.</p>
<p>코드 환경에서는 <strong>LangChain을 통해 개발</strong>을 진행하게 된다. 그리고 LangGraph를 통해 HIP(Human In the Loop)이라는 방법으로 고도화 작업이 가능하다고 하는데,
이 부분은 나중에 알아보도록 하자.</p>
<p>또한 <strong>LangSmith를 통해 품질 모니터링과 테스트</strong> 같은 활동이 가능하다.</p>
<p>마지막으로 <strong>LangGraph Platform은 실제 제품화를 위한 API 추출과 Assistant화</strong>를 도와준다고 한다.</p>
<p>나는 일단 LangChain을 통해 코드 환경에서 간단한 실습을 진행하고, 프로젝트화를 통해 기능을 넣을 생각이다.</p>
<hr>
<h3 id="💬-chatbot-만들기">💬 Chatbot 만들기</h3>
<p>이제 Chatbot 실습을 진행하겠다.</p>
<pre><code class="language-txt">langchain
langchain-core
langgraph&gt;0.2.27
dotenv</code></pre>
<p>먼저 필요한 패키지다.</p>
<pre><code class="language-bash">pip install -qU &quot;langchain[google-genai]&quot;</code></pre>
<p>이 명령은 google-genai를 사용하게 해주는 LangChain 키트를 설치하는 명령어다.
이 명령어는 꼭 별도로 설치해야 한다. 그렇지 않으면 의존성 오류가 발생한다.</p>
<pre><code class="language-python">import os
from dotenv import load_dotenv

load_dotenv()

key = os.getenv(&quot;GOOGLE_API_KEY&quot;)
if not key:
    raise EnvironmentError(&quot;GOOGLE_API_KEY not found in .env&quot;)

from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, AIMessage

model = init_chat_model(&quot;gemini-2.5-flash&quot;, model_provider=&quot;google_genai&quot;)

resp = model.invoke(
    [
        HumanMessage(content=&quot;Hello, my name is choihyeok&quot;),
        AIMessage(content=&quot;Hello choihyeok! How can I assist you today?&quot;),
        HumanMessage(content=&quot;What&#39;s my name?&quot;),
    ]
)
print(resp.content)</code></pre>
<p>위 코드를 보면 <code>from langchain.chat_models import init_chat_model</code>을 통해 Chatbot 모델 기능을 사용 가능하다. 여기에 API 키를 넣어 사용하면 되며, 여기서는 Google-Gemini 2.25-flash 모델을 사용했다.</p>
<p><code>invoke</code>를 통해 모델에 메시지를 삽입하고, <code>HumanMessage</code>와 <code>AIMessage</code>로 사람과 AI의 대화를 구분한다. 마지막에 <code>resp.content</code>를 출력하면 텍스트 응답만 확인할 수 있다.</p>
<h4 id="📌-실행-예시">📌 실행 예시</h4>
<pre><code class="language-bash">Your name is **choihyeok**.</code></pre>
<hr>
<p>그런데 우리가 원하는 것은 <strong>프로세스 환경에서 실시간 대화</strong>다.</p>
<pre><code class="language-python">from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, MessagesState, StateGraph

workflow = StateGraph(state_schema=MessagesState)

def call_model(state: MessagesState):
    response = model.invoke(state[&quot;messages&quot;])
    return {&quot;messages&quot;: response}

workflow.add_edge(START, &quot;model&quot;)
workflow.add_node(&quot;model&quot;, call_model)

memory = MemorySaver()
app = workflow.compile(checkpointer=memory)

config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;abc123&quot;}}

query = &quot;Hi! I&#39;m HYK.&quot;
input_messages = [HumanMessage(query)]
output = app.invoke({&quot;messages&quot;: input_messages}, config)
output[&quot;messages&quot;][-1].pretty_print()

query = &quot;What&#39;s my name?&quot;
input_messages = [HumanMessage(query)]
output = app.invoke({&quot;messages&quot;: input_messages}, config)
output[&quot;messages&quot;][-1].pretty_print()</code></pre>
<p><code>call_model</code> 메서드를 선언해 state를 설정하고, LangGraph의 State 기능을 통해 대화 상태를 기억하게 한다.</p>
<p>여기서 LangGraph의 강력한 기능을 알 수 있는데, 바로 State로 워크플로를 관리하는 것이다. LangGraph는 하나의 프로세스를 정의해서 해당 모델이 어떠한 상태를 가지고 있는지 정의한다.</p>
<pre><code class="language-python">workflow = StateGraph(state_schema=MessagesState)</code></pre>
<p>해당 코드가 핵심인데, <code>StateGraph</code>를 <code>MessagesState</code>로 정의하는 워크프로를 생성한다는 의미이다. 이제 모델은 해당 워크플로안에서 유지되면서 메세지를 받게된다.</p>
<pre><code class="language-python">workflow.add_edge(START, &quot;model&quot;)
workflow.add_node(&quot;model&quot;, call_model)
memory = MemorySaver()
app = workflow.compile(checkpointer=memory)</code></pre>
<p>워크플로는 노드와 엣지 형태로 모델의 시작점과 진행 사항을 연결 및 유지 해준다.</p>
<p>위와 같이 구현하면 모델은 대화를 메모리에 저장하며 진행한다. 다만 현재는 프로세스를 종료하면 쓰레드가 정리되어 기록이 사라진다.</p>
<pre><code class="language-python">config = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;abc123&quot;}}</code></pre>
<p>이 코드로 쓰레드를 설정해 대화 세션을 구분할 수 있다.</p>
<h4 id="📌-실행-예시-1">📌 실행 예시</h4>
<pre><code>================================== Ai Message ==================================
Hi HYK! It&#39;s nice to meet you.
How can I help you today?

================================== Ai Message ==================================
Your name is HYK! You told me that when you first introduced yourself.

================================== Ai Message ==================================
I don&#39;t know your name. As an AI, I don&#39;t have access to personal information about you or your identity.
How can I help you today?

================================== Ai Message ==================================
Your name is HYK! I remember you told me that when we first started chatting.</code></pre><p>위 출력에서 보듯, 첫 번째 메시지에서 이름을 알려줬지만 세번째 쓰레드에서는 이름을 모른다.</p>
<p>기본적인 구현은 위와 같으며, 다음에는 <strong>프롬프트 엔지니어링</strong>을 알아보겠다.</p>
<p><a href="https://python.langchain.com/docs/tutorials/chatbot/">[참고] LangChain Build a Chatbot</a></p>
]]></description>
        </item>
    </channel>
</rss>