<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>kwanghyun's Blog</title>
    <link>https://mgh3326.tistory.com/</link>
    <description>kwanghyun's Blog</description>
    <language>ko</language>
    <pubDate>Wed, 20 May 2026 09:34:11 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>kwanghyun</managingEditor>
    <image>
      <title>kwanghyun's Blog</title>
      <url>https://tistory1.daumcdn.net/tistory/2871429/attach/bce43f5feb52433b83b0fd6ecbeb04a1</url>
      <link>https://mgh3326.tistory.com</link>
    </image>
    <item>
      <title>OpenClaw + FastAPI 콜백으로 비동기 LLM 파이프라인 만들기</title>
      <link>https://mgh3326.tistory.com/244</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zqJRK/dJMcag5sUFW/pXaTGm3zns7RLdVshFwY10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zqJRK/dJMcag5sUFW/pXaTGm3zns7RLdVshFwY10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zqJRK/dJMcag5sUFW/pXaTGm3zns7RLdVshFwY10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzqJRK%2FdJMcag5sUFW%2FpXaTGm3zns7RLdVshFwY10%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;378&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;Raspberry Pi에서 GPT 분석, 웹훅/콜백으로 결과 적재&lt;/i&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;b&gt;개발 인프라 개선 시리즈&lt;/b&gt;의 &lt;b&gt;Infra-5편&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개발 인프라 개선 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/235&quot;&gt;Infra-1편: Poetry에서 UV로 마이그레이션&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/236&quot;&gt;Infra-2편: Python 3.13 업그레이드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/239&quot;&gt;Infra-3편: Python 3.14 업그레이드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/241&quot;&gt;Infra-4편: Ruff + Pyright 마이그레이션&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Infra-5편: OpenClaw 통합&lt;/b&gt; &amp;larr; 현재 글&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AI 자동매매 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/227&quot;&gt;1편: 한투 API로 실시간 주식 데이터 수집하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/228&quot;&gt;2편: yfinance로 애플&amp;middot;테슬라 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/229&quot;&gt;3편: Upbit으로 비트코인 24시간 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/230&quot;&gt;4편: AI 분석 결과 DB에 저장하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/232&quot;&gt;5편: Upbit 웹 트레이딩 대시보드 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/233&quot;&gt;6편: 실전 운영을 위한 모니터링 시스템 구축&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/234&quot;&gt;7편: 라즈베리파이 홈서버에 자동 HTTPS로 안전하게 배포하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dyAyIq/dJMcaa5ew0e/ASbDDm7wwdqKIQE5qcM9x0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dyAyIq/dJMcaa5ew0e/ASbDDm7wwdqKIQE5qcM9x0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dyAyIq/dJMcaa5ew0e/ASbDDm7wwdqKIQE5qcM9x0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdyAyIq%2FdJMcaa5ew0e%2FASbDDm7wwdqKIQE5qcM9x0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;720&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배경: LLM 분석을 분리해야 하는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 auto_trader 시스템은 Google Gemini API를 직접 호출해서 주식/암호화폐 분석을 수행했습니다. 하지만 몇 가지 문제가 있었습니다:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;문제&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;API 비용/제한&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;여러 API 키를 로테이션해도 429 에러 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;운영 분리&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;분석 서비스 장애가 트레이딩 서비스에 영향&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;유연성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;다른 LLM(GPT, Claude)으로 전환이 어려움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;리소스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;LLM 호출이 메인 서버 리소스 점유&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책: 분석을 별도 서비스로 오프로딩&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Raspberry Pi 5에서 운영 중인 &lt;b&gt;OpenClaw&lt;/b&gt;를 활용하여:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;auto_trader는 &lt;b&gt;분석 요청만&lt;/b&gt; 보냄 (비동기)&lt;/li&gt;
&lt;li&gt;OpenClaw가 &lt;b&gt;GPT/Claude로 분석&lt;/b&gt; 수행&lt;/li&gt;
&lt;li&gt;완료 후 &lt;b&gt;콜백으로 결과 전송&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;auto_trader가 &lt;b&gt;DB에 저장&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OpenClaw란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenClaw는 LLM 에이전트를 실행하고 관리하는 게이트웨이이다.:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;웹훅 기반 실행&lt;/b&gt;: POST &lt;code&gt;/hooks/agent&lt;/code&gt;로 에이전트 트리거&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비동기 처리&lt;/b&gt;: 요청 즉시 202 반환, 백그라운드에서 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;세션 관리&lt;/b&gt;: &lt;code&gt;sessionKey&lt;/code&gt;로 대화 컨텍스트 유지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;다양한 LLM 지원&lt;/b&gt;: OpenAI, Claude, 로컬 모델 등&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아키텍처 개요&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;463&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dhn5R7/dJMcaia80Xd/9Q0kkXTywINHUTsavx71Wk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dhn5R7/dJMcaia80Xd/9Q0kkXTywINHUTsavx71Wk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dhn5R7/dJMcaia80Xd/9Q0kkXTywINHUTsavx71Wk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdhn5R7%2FdJMcaia80Xd%2F9Q0kkXTywINHUTsavx71Wk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;463&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;463&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;auto_trader &amp;rarr; OpenClaw &amp;rarr; callback &amp;rarr; DB 파이프라인&lt;/i&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 흐름&lt;/h3&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;① auto_trader: POST /hooks/agent (분석 요청)
    ├─ message: 프롬프트 + 콜백 URL + JSON 스키마
    ├─ sessionKey: auto-trader:openclaw:{request_id}
    └─ Authorization: Bearer {OPENCLAW_TOKEN}

② OpenClaw: 요청 수신, 즉시 202 반환

③ OpenClaw Agent: GPT/Claude로 분석 실행

④ OpenClaw: POST callback_url (분석 결과)
    ├─ JSON: decision, confidence, reasons, price_analysis
    └─ Authorization: Bearer {CALLBACK_TOKEN}

⑤ auto_trader: 콜백 수신, DB 저장 (StockAnalysisResult)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상관관계 키&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청과 응답을 매칭하기 위한 두 가지 키:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;키&lt;/th&gt;
&lt;th&gt;용도&lt;/th&gt;
&lt;th&gt;예시&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;request_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;콜백 페이로드 식별&lt;/td&gt;
&lt;td&gt;&lt;code&gt;550e8400-e29b-41d4-a716-446655440000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sessionKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenClaw 세션 추적&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auto-trader:openclaw:{request_id}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현 상세&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. OpenClaw 클라이언트 (요청 송신)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/services/openclaw_client.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;class OpenClawClient:
    &quot;&quot;&quot;Client for OpenClaw Gateway webhook (POST /hooks/agent).&quot;&quot;&quot;

    async def request_analysis(
        self,
        prompt: str,
        symbol: str,
        name: str,
        instrument_type: str,
    ) -&amp;gt; str:
        &quot;&quot;&quot;Send an analysis request to OpenClaw.&quot;&quot;&quot;
        if not settings.OPENCLAW_ENABLED:
            raise RuntimeError(&quot;OpenClaw integration is disabled&quot;)

        request_id = str(uuid4())

        message = _build_openclaw_message(
            request_id=request_id,
            prompt=prompt,
            symbol=symbol,
            name=name,
            instrument_type=instrument_type,
            callback_url=self._callback_url,
            callback_token=settings.OPENCLAW_CALLBACK_TOKEN,
        )

        payload = {
            &quot;message&quot;: message,
            &quot;name&quot;: &quot;auto-trader:analysis&quot;,
            &quot;sessionKey&quot;: f&quot;auto-trader:openclaw:{request_id}&quot;,
            &quot;wakeMode&quot;: &quot;now&quot;,
        }

        headers = {&quot;Content-Type&quot;: &quot;application/json&quot;}
        if self._token:
            headers[&quot;Authorization&quot;] = f&quot;Bearer {self._token}&quot;

        async with httpx.AsyncClient(timeout=10) as cli:
            res = await cli.post(self._webhook_url, json=payload, headers=headers)
            res.raise_for_status()

        return request_id&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;uuid4()&lt;/code&gt;로 고유한 &lt;code&gt;request_id&lt;/code&gt; 생성&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sessionKey&lt;/code&gt;에 request_id 포함하여 추적 가능&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wakeMode: &quot;now&quot;&lt;/code&gt;로 즉시 실행 요청&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 메시지 빌더&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def _build_openclaw_message(
    *,
    request_id: str,
    prompt: str,
    callback_url: str,
    callback_token: str | None,
    ...
) -&amp;gt; str:
    callback_schema = {
        &quot;request_id&quot;: request_id,
        &quot;symbol&quot;: symbol,
        &quot;decision&quot;: &quot;buy|hold|sell&quot;,
        &quot;confidence&quot;: 0,  # 0-100 int
        &quot;reasons&quot;: [&quot;...&quot;],
        &quot;price_analysis&quot;: {
            &quot;appropriate_buy_range&quot;: {&quot;min&quot;: 0, &quot;max&quot;: 0},
            ...
        },
        &quot;detailed_text&quot;: &quot;...&quot;,
        &quot;model_name&quot;: &quot;...&quot;,
    }

    callback_headers = &quot;Content-Type: application/json\n&quot;
    if callback_token:
        callback_headers += f&quot;Authorization: Bearer {callback_token}\n&quot;

    return (
        &quot;Analyze the following trading instrument...\n\n&quot;
        f&quot;request_id: {request_id}\n&quot;
        f&quot;symbol: {symbol}\n&quot;
        &quot;USER_PROMPT:\n&quot;
        f&quot;{prompt}\n\n&quot;
        &quot;CALLBACK:\n&quot;
        f&quot;POST {callback_url}\n&quot;
        f&quot;{callback_headers}\n&quot;
        &quot;RESPONSE_JSON_SCHEMA (example):\n&quot;
        f&quot;{json.dumps(callback_schema)}\n&quot;
    )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;메시지 구조:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;분석 대상 정보 (symbol, name, instrument_type)&lt;/li&gt;
&lt;li&gt;원본 프롬프트 (USER_PROMPT)&lt;/li&gt;
&lt;li&gt;콜백 엔드포인트와 인증 헤더&lt;/li&gt;
&lt;li&gt;응답 JSON 스키마 (에이전트가 참조)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 콜백 라우터 (결과 수신)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/routers/openclaw_callback.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;router = APIRouter(prefix=&quot;/api/v1/openclaw&quot;, tags=[&quot;OpenClaw&quot;])


class OpenClawCallbackRequest(BaseModel):
    request_id: str
    symbol: str
    name: str
    instrument_type: str

    decision: Literal[&quot;buy&quot;, &quot;hold&quot;, &quot;sell&quot;]
    confidence: int = Field(ge=0, le=100)
    reasons: list[str] | None = None

    price_analysis: PriceAnalysis
    detailed_text: str | None = None
    model_name: str | None = None


@router.post(&quot;/callback&quot;)
async def openclaw_callback(
    payload: OpenClawCallbackRequest,
    _: None = Depends(_require_openclaw_callback_token),
    db: AsyncSession = Depends(get_db),
) -&amp;gt; dict:
    # 1. 종목 정보 생성/조회
    stock_info = await create_stock_if_not_exists(
        symbol=payload.symbol,
        name=payload.name,
        instrument_type=payload.instrument_type,
        db=db,
    )

    # 2. 분석 결과 저장
    record = StockAnalysisResult(
        stock_info_id=stock_info.id,
        model_name=&quot;openclaw-gpt&quot;,
        decision=payload.decision,
        confidence=payload.confidence,
        appropriate_buy_min=payload.price_analysis.appropriate_buy_range.min,
        ...
    )

    db.add(record)
    await db.commit()
    await db.refresh(record)

    return {
        &quot;status&quot;: &quot;ok&quot;,
        &quot;request_id&quot;: payload.request_id,
        &quot;analysis_result_id&quot;: record.id,
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 콜백 인증&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/I9ZmS/dJMcaivolm8/jURs2RdS5FW9zrUYAvOkx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/I9ZmS/dJMcaivolm8/jURs2RdS5FW9zrUYAvOkx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/I9ZmS/dJMcaivolm8/jURs2RdS5FW9zrUYAvOkx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FI9ZmS%2FdJMcaivolm8%2FjURs2RdS5FW9zrUYAvOkx0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;360&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;2단계 토큰 인증: Gateway 토큰 + Callback 토큰&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;토큰 분리 설계:&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;토큰&lt;/th&gt;
&lt;th&gt;용도&lt;/th&gt;
&lt;th&gt;보관 위치&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OPENCLAW_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gateway 접근&lt;/td&gt;
&lt;td&gt;auto_trader만 보관&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OPENCLAW_CALLBACK_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;콜백 인증&lt;/td&gt;
&lt;td&gt;양쪽 보관&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def _require_openclaw_callback_token(request: Request) -&amp;gt; None:
    expected = settings.OPENCLAW_CALLBACK_TOKEN.strip()

    # Bearer 토큰 또는 커스텀 헤더에서 추출
    provided = _extract_bearer_token(request.headers.get(&quot;authorization&quot;))
    if provided is None:
        provided = request.headers.get(&quot;x-openclaw-token&quot;)

    # 타이밍 공격 방지를 위한 상수 시간 비교
    if not provided or not hmac.compare_digest(provided, expected):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=&quot;Invalid OpenClaw callback token&quot;,
        )&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 미들웨어 allowlist&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콜백 엔드포인트는 &lt;b&gt;세션 인증 우회&lt;/b&gt;가 필요한다.:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app/middleware/auth.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;class AuthMiddleware(BaseHTTPMiddleware):
    # 공개 API 경로 (세션 인증 없이 접근 가능)
    PUBLIC_API_PATHS: ClassVar[list[str]] = [
        &quot;/api/v1/openclaw/callback&quot;,
    ]

    def _is_public_api_path(self, path: str) -&amp;gt; bool:
        return any(
            path.startswith(public_api_path)
            for public_api_path in self.public_api_paths
        )

    async def dispatch(self, request: Request, call_next):
        path = request.url.path

      n    db.commit.assert_awaited_once()&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인증 테스트&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@pytest.mark.asyncio
async def test_callback_rejects_invalid_token():
    request = Mock()
    request.headers = {&quot;authorization&quot;: &quot;Bearer wrong_token&quot;}

    with pytest.raises(HTTPException) as exc_info:
        await _require_openclaw_callback_token(request)

    assert exc_info.value.status_code == 401&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;클라이언트 테스트&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@pytest.mark.asyncio
async def test_request_analysis_sends_correct_payload():
    with patch(&quot;httpx.AsyncClient&quot;) as mock_client:
        mock_response = Mock()
        mock_response.status_code = 202
        mock_client.return_value.__aenter__.return_value.post.return_value = mock_response

        client = OpenClawClient()
        request_id = await client.request_analysis(
            prompt=&quot;Analyze BTC&quot;,
            symbol=&quot;BTC&quot;,
            name=&quot;Bitcoin&quot;,
            instrument_type=&quot;crypto&quot;,
        )

        assert request_id  # UUID 형식 확인
        mock_client.return_value.__aenter__.return_value.post.assert_called_once()&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;트러블슈팅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;405 Method Not Allowed&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증상:&lt;/b&gt; &lt;code&gt;/hooks/agent&lt;/code&gt; 호출 시 405 반환&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인:&lt;/b&gt; OpenClaw 설정에서 hooks 엔드포인트가 활성화되지 않음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# OpenClaw 설정 확인
hooks:
  enabled: true
  path: /hooks/agent&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;401 Unauthorized (콜백)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증상:&lt;/b&gt; 콜백 수신 시 401 반환&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 1:&lt;/b&gt; &lt;code&gt;OPENCLAW_CALLBACK_TOKEN&lt;/code&gt; 미설정&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# .env 확인
OPENCLAW_CALLBACK_TOKEN=your_token_here&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 2:&lt;/b&gt; 미들웨어 allowlist 누락&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# auth.py PUBLIC_API_PATHS 확인
PUBLIC_API_PATHS = [&quot;/api/v1/openclaw/callback&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;422 Unprocessable Entity (콜백)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증상:&lt;/b&gt; 콜백 페이로드 검증 실패&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;일반적인 원인:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;# confidence가 float으로 전송됨
{&quot;confidence&quot;: 0.62}  # ❌ 실패

# 올바른 형식: 0-100 정수
{&quot;confidence&quot;: 62}    # ✅ 성공&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스키마 확인:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;confidence: int = Field(ge=0, le=100)  # 0-100 정수&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5432 Connection Refused (DB)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증상:&lt;/b&gt; 콜백 수신은 성공, DB 저장 실패&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인:&lt;/b&gt; PostgreSQL 연결 불가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Docker로 PostgreSQL 실행
docker compose up -d postgres

# 연결 테스트
docker compose exec postgres psql -U user -d database&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;보안 고려사항&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;현재 방식의 한계&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# TODO(security): Replace OPENCLAW_CALLBACK_TOKEN with HMAC-signed callbacks.
# Why: the callback token is embedded into OpenClaw messages and may appear in
# logs/session history.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;토큰이 메시지에 평문으로 포함됨&lt;/li&gt;
&lt;li&gt;OpenClaw 세션 히스토리에 토큰이 남을 수 있음&lt;/li&gt;
&lt;li&gt;로그 시스템에 토큰 노출 가능성&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;향후 개선: HMAC 서명 방식&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 개선된 콜백 인증 (계획)
def verify_callback(request: Request) -&amp;gt; None:
    timestamp = request.headers.get(&quot;x-timestamp&quot;)
    signature = request.headers.get(&quot;x-signature&quot;)
    body = await request.body()

    # 1. 타임스탬프 검증 (5분 이내)
    if abs(time.time() - int(timestamp)) &amp;gt; 300:
        raise HTTPException(400, &quot;Request too old&quot;)

    # 2. HMAC 서명 검증
    expected = hmac.new(
        SECRET_KEY.encode(),
        f&quot;{timestamp}.{body.decode()}&quot;.encode(),
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        raise HTTPException(401, &quot;Invalid signature&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토큰 대신 서명 전송 (비밀 키 노출 없음)&lt;/li&gt;
&lt;li&gt;타임스탬프로 리플레이 공격 방지&lt;/li&gt;
&lt;li&gt;본문 무결성 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;환경 설정:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;.env&lt;/code&gt;에 &lt;code&gt;OPENCLAW_*&lt;/code&gt; 변수 설정&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;OPENCLAW_ENABLED=true&lt;/code&gt; 활성화&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; SSH 터널 설정 (개발 환경)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;코드 배포:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;app/services/openclaw_client.py&lt;/code&gt; 배포&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;app/routers/openclaw_callback.py&lt;/code&gt; 배포&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;app/middleware/auth.py&lt;/code&gt; allowlist 확인&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;app/main.py&lt;/code&gt;에 라우터 등록&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;테스트:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 헬스체크 확인 (&lt;code&gt;/health&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; OpenClaw 훅 연결 테스트&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 콜백 수신 테스트&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; DB 저장 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;보안:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;OPENCLAW_TOKEN&lt;/code&gt;과 &lt;code&gt;OPENCLAW_CALLBACK_TOKEN&lt;/code&gt; 분리&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 토큰 로테이션 계획 수립&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; HMAC 서명 방식 전환 계획 (TODO)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OpenClaw 통합의 장점&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;분석 실행&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;auto_trader에서 직접&lt;/td&gt;
&lt;td&gt;OpenClaw로 오프로딩&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;LLM 선택&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Gemini 고정&lt;/td&gt;
&lt;td&gt;GPT, Claude 등 유연 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;장애 격리&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;분석 실패 &amp;rarr; 서비스 영향&lt;/td&gt;
&lt;td&gt;분석 분리, 서비스 안정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;확장성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단일 서버 제한&lt;/td&gt;
&lt;td&gt;분석 서버 독립 스케일&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다음 단계&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;HMAC 서명 방식으로 전환&lt;/b&gt;: 토큰 노출 위험 제거&lt;/li&gt;
&lt;li&gt;&lt;b&gt;재시도 로직 추가&lt;/b&gt;: 콜백 실패 시 자동 재시도&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모니터링 대시보드&lt;/b&gt;: request_id로 분석 요청 추적&lt;/li&gt;
&lt;li&gt;&lt;b&gt;배치 분석&lt;/b&gt;: 여러 종목 동시 분석 요청&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/&quot;&gt;FastAPI Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.python-httpx.org/&quot;&gt;httpx - Async HTTP Client&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/3/library/hmac.html&quot;&gt;HMAC - Python Docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로젝트 저장소:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GitHub: &lt;a href=&quot;https://github.com/mgh3326/auto_trader&quot;&gt;github.com/mgh3326/auto_trader&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PR: &lt;a href=&quot;https://github.com/mgh3326/auto_trader/pull/113&quot;&gt;#113 - OpenClaw integration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문이나 피드백은 이슈로 남겨주세요!&lt;/p&gt;</description>
      <category>Programming/Python</category>
      <category>claude</category>
      <category>FastAPI</category>
      <category>GPT</category>
      <category>HMAC</category>
      <category>llm파이프라인</category>
      <category>OpenClaw</category>
      <category>RaspberryPI</category>
      <category>비동기처리</category>
      <category>웹훅</category>
      <category>콜백</category>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/244</guid>
      <comments>https://mgh3326.tistory.com/244#entry244comment</comments>
      <pubDate>Wed, 4 Feb 2026 17:32:11 +0900</pubDate>
    </item>
    <item>
      <title>소개 페이지 (About)</title>
      <link>https://mgh3326.tistory.com/pages/%EC%86%8C%EA%B0%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-About</link>
      <description>&lt;h1&gt;About&lt;/h1&gt;
&lt;p&gt;안녕하세요! 백엔드 개발자 &lt;strong&gt;광현&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;h2&gt;관심 분야&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;  Python / FastAPI&lt;/li&gt;
&lt;li&gt;  AI 기반 자동매매 시스템&lt;/li&gt;
&lt;li&gt;  홈서버 / 라즈베리파이&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;연락처&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;GitHub: github.com/mgh3326&lt;/li&gt;
&lt;li&gt;Email: &lt;a href=&quot;mailto:mgh3326@gmail.com&quot;&gt;mgh3326@gmail.com&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;블로그 소개&lt;/h2&gt;
&lt;p&gt;AI 자동매매 시스템 개발 과정과 개발 인프라&lt;br&gt;개선 경험을 공유합니다.&lt;/p&gt;</description>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/pages/%EC%86%8C%EA%B0%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-About</guid>
      <pubDate>Sat, 31 Jan 2026 18:37:07 +0900</pubDate>
    </item>
    <item>
      <title>개인정보처리방침</title>
      <link>https://mgh3326.tistory.com/pages/%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4%EC%B2%98%EB%A6%AC%EB%B0%A9%EC%B9%A8</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;kwanghyun's Blog(이하 &quot;블로그&quot;)는 방문자의 개인정보를&lt;br /&gt;중요시하며, 개인정보보호법을 준수합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 수집하는 개인정보&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Google Analytics를 통한 방문 통계 (익명)&lt;/li&gt;
&lt;li&gt;댓글 작성 시: 이름, 이메일 (선택)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 개인정보 이용 목적&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;블로그 운영 및 개선&lt;/li&gt;
&lt;li&gt;댓글 알림&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 개인정보 보유 기간&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;댓글 삭제 요청 시 즉시 삭제&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 연락처&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이메일: &lt;a href=&quot;mailto:mgh3326@gmail.com&quot;&gt;mgh3326@gmail.com&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시행일: 2026년 1월 31일&lt;/p&gt;</description>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/pages/%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4%EC%B2%98%EB%A6%AC%EB%B0%A9%EC%B9%A8</guid>
      <pubDate>Sat, 31 Jan 2026 18:35:20 +0900</pubDate>
    </item>
    <item>
      <title>Ruff + Pyright로 Python 코드 품질 도구 통합하기</title>
      <link>https://mgh3326.tistory.com/241</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;610&quot; data-origin-height=&quot;610&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k4td0/dJMcadguKQ6/rkTXpvfktZA6KYJbp45l61/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k4td0/dJMcadguKQ6/rkTXpvfktZA6KYJbp45l61/img.png&quot; data-alt=&quot;Ruff + Pyright 마이그레이션&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k4td0/dJMcadguKQ6/rkTXpvfktZA6KYJbp45l61/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk4td0%2FdJMcadguKQ6%2FrkTXpvfktZA6KYJbp45l61%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;610&quot; height=&quot;610&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;610&quot; data-origin-height=&quot;610&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Ruff + Pyright 마이그레이션&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;Black, isort, flake8, mypy에서 Ruff + Pyright로 통합&lt;/i&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;b&gt;개발 인프라 개선 시리즈&lt;/b&gt;의 &lt;b&gt;Infra-4편&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개발 인프라 개선 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/235&quot;&gt;Infra-1편: Poetry에서 UV로 마이그레이션&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/236&quot;&gt;Infra-2편: Python 3.13 업그레이드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/237&quot;&gt;Infra-3편: Python 3.14 업그레이드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Infra-4편: Ruff + Pyright 마이그레이션&lt;/b&gt; &amp;larr; 현재 글&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AI 자동매매 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/227&quot;&gt;1편: 한투 API로 실시간 주식 데이터 수집하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/228&quot;&gt;2편: yfinance로 애플&amp;middot;테슬라 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/229&quot;&gt;3편: Upbit으로 비트코인 24시간 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/230&quot;&gt;4편: AI 분석 결과 DB에 저장하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/232&quot;&gt;5편: Upbit 웹 트레이딩 대시보드 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/233&quot;&gt;6편: 실전 운영을 위한 모니터링 시스템 구축&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/234&quot;&gt;7편: 라즈베리파이 홈서버에 자동 HTTPS로 안전하게 배포하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vVShw/dJMb99ZuI6z/pYY15RkZTTkeK1X3UFuxik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vVShw/dJMb99ZuI6z/pYY15RkZTTkeK1X3UFuxik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vVShw/dJMb99ZuI6z/pYY15RkZTTkeK1X3UFuxik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvVShw%2FdJMb99ZuI6z%2FpYY15RkZTTkeK1X3UFuxik%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;600&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기존 도구들의 문제점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Python 코드 품질 관리를 위해 보통 여러 도구를 조합해서 사용한다.:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;도구&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;문제점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Black&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;코드 포맷터&lt;/td&gt;
&lt;td&gt;설정 파일이 분산됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;isort&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;import 정렬&lt;/td&gt;
&lt;td&gt;Black과 충돌 가능성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;flake8&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;린터&lt;/td&gt;
&lt;td&gt;느린 속도, 플러그인 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;mypy&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;타입 체커&lt;/td&gt;
&lt;td&gt;느린 속도, 복잡한 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;우리 프로젝트의 기존 상태:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Makefile (기존)
lint:
    uv run flake8 app/ tests/ --max-line-length=88 --extend-ignore=E203,W503
    uv run black --check app/ tests/
    uv run isort --check-only app/ tests/
    uv run mypy app/ --ignore-missing-imports

format:
    uv run black app/ tests/
    uv run isort app/ tests/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;4개의 도구를 별도로 실행&lt;/b&gt; &amp;rarr; 느린 CI 속도&lt;/li&gt;
&lt;li&gt;&lt;b&gt;설정이 여러 곳에 분산&lt;/b&gt; &amp;rarr; pyproject.toml, .flake8, setup.cfg 등&lt;/li&gt;
&lt;li&gt;&lt;b&gt;도구 간 버전/설정 충돌&lt;/b&gt; &amp;rarr; Black과 isort 스타일 불일치&lt;/li&gt;
&lt;li&gt;&lt;b&gt;mypy의 느린 속도&lt;/b&gt; &amp;rarr; 대규모 코드베이스에서 체감됨&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원래 목표: ty (Astral의 타입 체커)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 처음에는 &lt;b&gt;ty&lt;/b&gt;를 사용하고 싶었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ty란?&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Astral에서 개발 중인 &lt;b&gt;Rust 기반 타입 체커&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Ruff, UV와 같은 회사에서 개발 (Astral)&lt;/li&gt;
&lt;li&gt;mypy, Pyright보다 &lt;b&gt;10-100배 빠름&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;향후 Ruff와 통합 예정&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;# ty 사용 예시 (향후)
uv run ty check app/

# 또는 Ruff 통합 후
uv run ruff check --select=TY app/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하지만 마이그레이션 시점(2025년 12월)에는:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ty가 아직 정식 릴리즈되지 않음&lt;/li&gt;
&lt;li&gt;첫 릴리즈(v0.0.9)가 2026년 1월 5일에 나옴&lt;/li&gt;
&lt;li&gt;현재(2026년 1월 25일) v0.0.13까지 릴리즈되었지만 여전히 초기 단계&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;Ruff + Pyright&lt;/b&gt; 조합을 선택했습니다. ty가 안정화되면 Pyright &amp;rarr; ty로 전환할 계획이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ruff와 Pyright란?&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l1JQI/dJMcaaYoGrC/FpqBS7xvbOgf5vwLCDmJq1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l1JQI/dJMcaaYoGrC/FpqBS7xvbOgf5vwLCDmJq1/img.png&quot; data-alt=&quot;도구 비교&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l1JQI/dJMcaaYoGrC/FpqBS7xvbOgf5vwLCDmJq1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl1JQI%2FdJMcaaYoGrC%2FFpqBS7xvbOgf5vwLCDmJq1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;308&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;308&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;도구 비교&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;기존 4개 도구 &amp;rarr; Ruff + Pyright 2개로 통합 (ty 안정화 전까지)&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Ruff&lt;/b&gt; (Astral 개발):&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Rust로 작성된 초고속 린터 + 포맷터&lt;/li&gt;
&lt;li&gt;Black, isort, flake8, pyupgrade 등을 &lt;b&gt;하나로 통합&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;기존 도구 대비 &lt;b&gt;10-100배 빠름&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pyright&lt;/b&gt; (Microsoft 개발):&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TypeScript로 작성된 고성능 타입 체커&lt;/li&gt;
&lt;li&gt;VS Code Python 확장에 내장&lt;/li&gt;
&lt;li&gt;mypy 대비 &lt;b&gt;5-10배 빠름&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;임시 선택&lt;/b&gt;: ty가 안정화되면 교체 예정&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마이그레이션 계획&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Phase별 접근&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안전한 마이그레이션을 위해 4단계로 진행했습니다:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Phase 1: Ruff 도입 (기존 도구와 병행)
    &amp;darr;
Phase 2: Pyright 도입 (기존 mypy와 병행)
    &amp;darr;
Phase 3: CI 개선 (Ruff + Pyright로 전환)
    &amp;darr;
Phase 4: 정리 (기존 도구 제거)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 원칙:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 단계에서 롤백 가능하도록 설계&lt;/li&gt;
&lt;li&gt;기존 도구와 새 도구 결과 비교 후 전환&lt;/li&gt;
&lt;li&gt;CI가 통과한 후에만 다음 단계 진행&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Phase 1: Ruff 도입&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 설치 및 설정&lt;/h3&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;# Ruff 설치
uv add --group dev ruff&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;pyproject.toml 설정:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;[tool.ruff]
target-version = &quot;py314&quot;
line-length = 88
exclude = [
    &quot;.venv&quot;,
    &quot;alembic/versions&quot;,
    &quot;__pycache__&quot;,
    &quot;data/stocks_info&quot;,
    &quot;data/coins_info&quot;,
]

[tool.ruff.lint]
select = [
    &quot;E&quot;,      # pycodestyle errors
    &quot;W&quot;,      # pycodestyle warnings
    &quot;F&quot;,      # Pyflakes
    &quot;I&quot;,      # isort
    &quot;B&quot;,      # flake8-bugbear
    &quot;C4&quot;,     # flake8-comprehensions
    &quot;UP&quot;,     # pyupgrade
]
ignore = [
    &quot;E203&quot;,   # Whitespace before ':' (black compatible)
    &quot;E501&quot;,   # Line too long (formatter handles this)
    &quot;B008&quot;,   # FastAPI Depends() in default arguments
    &quot;E712&quot;,   # SQLAlchemy == True comparison
    &quot;B904&quot;,   # raise ... from err (FastAPI HTTPException pattern)
]

[tool.ruff.lint.per-file-ignores]
&quot;app/core/celery_app.py&quot; = [&quot;E402&quot;]  # Delayed import for Celery signals
&quot;tests/conftest.py&quot; = [&quot;E402&quot;]        # Delayed import after env setup

[tool.ruff.lint.isort]
known-first-party = [&quot;app&quot;]

[tool.ruff.format]
quote-style = &quot;double&quot;
indent-style = &quot;space&quot;
skip-magic-trailing-comma = false
line-ending = &quot;auto&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 주요 설정 설명&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;lint.select - 활성화할 규칙:&lt;/b&gt;&lt;br /&gt;| 코드 | 기존 도구 | 역할 |&lt;br /&gt;|------|----------|------|&lt;br /&gt;| E, W | pycodestyle | PEP 8 스타일 검사 |&lt;br /&gt;| F | Pyflakes | 논리 오류 검출 |&lt;br /&gt;| I | isort | import 정렬 |&lt;br /&gt;| B | flake8-bugbear | 버그 가능성 검출 |&lt;br /&gt;| C4 | flake8-comprehensions | 컴프리헨션 최적화 |&lt;br /&gt;| UP | pyupgrade | 최신 문법으로 자동 변환 |&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;lint.ignore - 예외 처리:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# E712: SQLAlchemy에서는 == True 비교가 필요
query.filter(User.is_active == True)  # noqa 없이 허용

# B008: FastAPI Depends()는 기본값에 사용해도 됨
def get_user(db: Session = Depends(get_db)):  # OK

# B904: FastAPI HTTPException은 from err 없이 사용
raise HTTPException(status_code=404)  # OK&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 Makefile 업데이트 (병행 기간)&lt;/h3&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;# 기존 명령어 유지
lint-legacy:
    uv run flake8 app/ tests/
    uv run black --check app/ tests/
    uv run isort --check-only app/ tests/

# 새 명령어 추가
lint-ruff:
    uv run ruff check app/ tests/
    uv run ruff format --check app/ tests/&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.4 코드 자동 수정&lt;/h3&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;# 린트 에러 자동 수정
uv run ruff check --fix app/ tests/

# 코드 포맷팅
uv run ruff format app/ tests/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;pyupgrade (UP) 규칙으로 자동 변환된 예시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# Before (Python 3.9 스타일)
from typing import Optional, List, Dict, Union

def get_users(ids: Optional[List[int]] = None) -&amp;gt; Dict[str, Union[int, str]]:
    pass

# After (Python 3.10+ 스타일)
def get_users(ids: list[int] | None = None) -&amp;gt; dict[str, int | str]:
    pass&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Phase 2: Pyright 도입&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 설치 및 설정&lt;/h3&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;# Pyright 설치
uv add --group dev pyright&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;pyproject.toml 설정:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;[tool.pyright]
pythonVersion = &quot;3.14&quot;
pythonPlatform = &quot;All&quot;
typeCheckingMode = &quot;basic&quot;

include = [&quot;app&quot;]
exclude = [
    &quot;**/__pycache__&quot;,
    &quot;.venv&quot;,
    &quot;alembic&quot;,
    &quot;tests&quot;,
    &quot;data&quot;,
]

# 점진적 마이그레이션 설정
reportMissingImports = &quot;warning&quot;
reportMissingTypeStubs = false
reportUnknownMemberType = false
reportUnknownParameterType = false
reportUnknownVariableType = false
reportUnknownArgumentType = false

# 특정 라이브러리 이슈 완화
reportAttributeAccessIssue = false  # ta library (no type stubs)
reportGeneralTypeIssues = false     # SQLAlchemy async session
reportArgumentType = false          # dict.get() returns Unknown | None
reportCallIssue = false             # Pydantic Field overloads&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 typeCheckingMode 이해&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모드&lt;/th&gt;
&lt;th&gt;엄격도&lt;/th&gt;
&lt;th&gt;용도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;검사 없음&lt;/td&gt;
&lt;td&gt;타입 검사 비활성화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;basic&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;점진적 도입 시작점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;standard&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;일반적인 프로젝트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;strict&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;완벽한 타입 안전성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;최고&lt;/td&gt;
&lt;td&gt;모든 규칙 활성화&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;basic 모드 선택 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;외부 라이브러리에 타입 스텁이 없는 경우가 많음&lt;/li&gt;
&lt;li&gt;점진적으로 strict 모드로 전환 가능&lt;/li&gt;
&lt;li&gt;주요 타입 에러만 잡으면서 생산성 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 주요 타입 에러 수정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;수정 전:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;# app/services/upbit.py
async def get_candles(self, market: str, count: int = 200):
    response = await self.client.get(url, params=params)
    return response.json()  # Unknown type&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;수정 후:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;async def get_candles(self, market: str, count: int = 200) -&amp;gt; list[dict]:
    response = await self.client.get(url, params=params)
    data: list[dict] = response.json()
    return data&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.4 타입 무시 주석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어쩔 수 없이 타입 에러를 무시해야 하는 경우:&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;# 특정 줄만 무시
from app.services.telegram import TelegramService  # type: ignore[import-not-found]

# 또는 pyright 전용
from external_lib import something  # pyright: ignore[reportMissingImports]&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Phase 3: CI 개선&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 GitHub Actions 워크플로우&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  lint:
    runs-on: ubuntu-latest
    env:
      PYTHON_VERSION: &quot;3.14&quot;

    steps:
    - uses: actions/checkout@v4

    - name: Set up Python ${{ env.PYTHON_VERSION }}
      uses: actions/setup-python@v6
      with:
        python-version: ${{ env.PYTHON_VERSION }}

    - name: Install UV
      run: pip install uv

    - name: Load cached venv
      uses: actions/cache@v3
      with:
        path: .venv
        key: venv-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/uv.lock') }}

    - name: Install dependencies
      run: uv sync --group dev

    - name: Run Ruff linter
      run: uv run ruff check app/ tests/

    - name: Run Ruff formatter check
      run: uv run ruff format --check app/ tests/

    - name: Run Pyright
      run: uv run pyright app/

  test:
    needs: lint  # lint 통과 후 테스트 실행
    runs-on: ubuntu-latest
    # ... 기존 테스트 설정 ...&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 lint &amp;rarr; test 의존성&lt;/h3&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;test:
  needs: lint  # 핵심!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;효과:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;린트 실패 시 테스트 실행 안 함 (리소스 절약)&lt;/li&gt;
&lt;li&gt;빠른 피드백 (린트는 테스트보다 훨씬 빠름)&lt;/li&gt;
&lt;li&gt;PR에서 명확한 실패 지점 파악&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;205&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4aPw4/dJMcaajM1LJ/kvFoOmD5TNKdBzTo1K7zkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4aPw4/dJMcaajM1LJ/kvFoOmD5TNKdBzTo1K7zkk/img.png&quot; data-alt=&quot;CI 파이프라인&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4aPw4/dJMcaajM1LJ/kvFoOmD5TNKdBzTo1K7zkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4aPw4%2FdJMcaajM1LJ%2FkvFoOmD5TNKdBzTo1K7zkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;205&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;205&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;CI 파이프라인&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;lint &amp;rarr; test &amp;rarr; security 순서로 실행되는 CI 파이프라인&lt;/i&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Phase 4: 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 기존 도구 제거&lt;/h3&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;# 기존 도구 제거
uv remove --group dev black isort flake8 mypy&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 최종 Makefile&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;.PHONY: lint format typecheck

lint: ## Run linting checks (Ruff + Pyright)
    uv run ruff check app/ tests/
    uv run ruff format --check app/ tests/
    uv run pyright app/

format: ## Format code with Ruff
    uv run ruff format app/ tests/
    uv run ruff check --fix app/ tests/

typecheck: ## Run Pyright type checking
    uv run pyright app/&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.3 최종 의존성&lt;/h3&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;[dependency-groups]
dev = [
    &quot;ruff&amp;gt;=0.14.10&quot;,
    &quot;pyright&amp;gt;=1.1.407&quot;,
    &quot;bandit&amp;gt;=1.7.0,&amp;lt;2.0.0&quot;,
    &quot;safety&amp;gt;=3.7.0,&amp;lt;3.8.0&quot;,
    &quot;playwright&amp;gt;=1.56.0&quot;,
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Before (6개 도구):&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;black, isort, flake8, mypy, bandit, safety&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;After (4개 도구):&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ruff, pyright, bandit, safety&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;성능 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;린트 속도 비교&lt;/h3&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;# 기존 도구 (flake8 + black + isort)
time make lint-legacy
# real    0m4.823s

# Ruff
time uv run ruff check app/ tests/
# real    0m0.312s&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과: 15배 빠름!&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;타입 체크 속도 비교&lt;/h3&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;# mypy
time uv run mypy app/
# real    0m12.456s

# Pyright
time uv run pyright app/
# real    0m2.134s&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과: 6배 빠름!&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CI 실행 시간&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;개선&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lint job&lt;/td&gt;
&lt;td&gt;42초&lt;/td&gt;
&lt;td&gt;18초&lt;/td&gt;
&lt;td&gt;&lt;b&gt;57% 감소&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체 CI&lt;/td&gt;
&lt;td&gt;3분 12초&lt;/td&gt;
&lt;td&gt;2분 28초&lt;/td&gt;
&lt;td&gt;&lt;b&gt;23% 감소&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dFK5ab/dJMcadALiSu/guoZ2YQQUB4nzKhPI8J611/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dFK5ab/dJMcadALiSu/guoZ2YQQUB4nzKhPI8J611/img.png&quot; data-alt=&quot;성능 비교&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dFK5ab/dJMcadALiSu/guoZ2YQQUB4nzKhPI8J611/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdFK5ab%2FdJMcadALiSu%2FguoZ2YQQUB4nzKhPI8J611%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;360&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;성능 비교&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;기존 도구 vs Ruff + Pyright 속도 비교&lt;/i&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마이그레이션 결과&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;변경된 파일 수&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;수량&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;변경된 파일&lt;/td&gt;
&lt;td&gt;101개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;추가된 라인&lt;/td&gt;
&lt;td&gt;5,000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;삭제된 라인&lt;/td&gt;
&lt;td&gt;3,200+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;타입 힌트 개선&lt;/td&gt;
&lt;td&gt;200+ 곳&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;pyupgrade로 현대화된 코드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Union &amp;rarr; | 연산자:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;# 101개 파일에서 자동 변환
Optional[str] &amp;rarr; str | None
Union[int, str] &amp;rarr; int | str
List[dict] &amp;rarr; list[dict]
Dict[str, Any] &amp;rarr; dict[str, Any]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;새로 발견된 잠재적 버그&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ruff의 &lt;code&gt;B&lt;/code&gt; (bugbear) 규칙으로 발견:&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;# 발견된 이슈: 가변 기본 인자
def process(items: list = []):  # B006: 위험!
    items.append(1)
    return items

# 수정
def process(items: list | None = None):
    if items is None:
        items = []
    items.append(1)
    return items&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;향후 계획&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. ty로 전환 (최우선)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ty&lt;/b&gt;가 안정화되면 Pyright에서 ty로 전환할 계획이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ty의 장점:&lt;/b&gt;&lt;br /&gt;| 특징 | Pyright | ty |&lt;br /&gt;|------|---------|-----|&lt;br /&gt;| 언어 | TypeScript | &lt;b&gt;Rust&lt;/b&gt; |&lt;br /&gt;| 속도 | 빠름 | &lt;b&gt;10-100배 더 빠름&lt;/b&gt; |&lt;br /&gt;| Ruff 통합 | 별도 실행 | &lt;b&gt;향후 통합 예정&lt;/b&gt; |&lt;br /&gt;| 개발사 | Microsoft | &lt;b&gt;Astral (Ruff, UV와 동일)&lt;/b&gt; |&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ty의 주요 기능:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;교집합 타입(Intersection types)&lt;/b&gt;: 더 정밀한 타입 표현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정교한 타입 좁히기&lt;/b&gt;: 조건문에서 더 스마트한 타입 추론&lt;/li&gt;
&lt;li&gt;&lt;b&gt;도달성 분석&lt;/b&gt;: 도달 불가능한 코드 감지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;풍부한 진단&lt;/b&gt;: 컨텍스트 정보가 포함된 에러 메시지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;언어 서버&lt;/b&gt;: 코드 네비게이션, 자동완성, 자동 임포트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;전환 타이밍:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ty가 v1.0 또는 안정 버전에 도달하면 전환&lt;/li&gt;
&lt;li&gt;현재 v0.0.13 (2026년 1월 21일 기준)으로 빠르게 개발 중&lt;/li&gt;
&lt;li&gt;Ruff 통합이 완료되면 도구 하나로 린트 + 타입 체크 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;# 현재 (Ruff + Pyright)
uv run ruff check app/ tests/
uv run pyright app/

# 향후 (Ruff + ty 통합)
uv run ty check app/
# 또는
uv run ruff check app/ tests/  # 린트 + 타입 체크 통합&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;마이그레이션 계획:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;현재: Ruff + Pyright (안정적)
    &amp;darr; ty v1.0 릴리즈 시
중간: Ruff + ty (Pyright 대체)
    &amp;darr; Ruff-ty 통합 완료 시
최종: Ruff (올인원)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Pyright strict 모드 전환 (ty 전환 전까지)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ty 전환 전까지 Pyright를 점진적으로 강화:&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# 현재
[tool.pyright]
typeCheckingMode = &quot;basic&quot;

# 중간 목표
typeCheckingMode = &quot;standard&quot;

# 최종 목표 (ty 전환 전)
typeCheckingMode = &quot;strict&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. pre-commit 훅 추가&lt;/h3&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.14.10
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;마이그레이션 전:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 기존 린트/타입 체크 모두 통과 확인&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; CI 파이프라인 정상 동작 확인&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 롤백 계획 수립&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Phase 1 (Ruff):&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; Ruff 설치 및 pyproject.toml 설정&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 기존 도구와 결과 비교&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 린트 에러 수정 또는 ignore 설정&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; Makefile 병행 명령어 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Phase 2 (Pyright):&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; Pyright 설치 및 pyproject.toml 설정&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 기존 mypy와 결과 비교&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 타입 에러 수정 또는 ignore 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Phase 3 (CI):&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; GitHub Actions 워크플로우 업데이트&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; lint job 추가 (Ruff + Pyright)&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; test job에 needs: lint 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Phase 4 (정리):&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 기존 도구 의존성 제거&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; pyproject.toml 정리&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; Makefile 최종 버전 적용&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 문서 업데이트 (CLAUDE.md, README.md)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ruff + Pyright 마이그레이션 소감&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 좋았던 점:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;압도적인 속도 향상&lt;/b&gt; - 린트 15배, 타입 체크 6배 빠름&lt;/li&gt;
&lt;li&gt;&lt;b&gt;설정 통합&lt;/b&gt; - pyproject.toml 하나로 모든 설정 관리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자동 코드 현대화&lt;/b&gt; - pyupgrade 규칙으로 최신 문법 적용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;잠재적 버그 발견&lt;/b&gt; - bugbear 규칙으로 위험한 패턴 검출&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주의할 점:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;점진적 도입 권장&lt;/b&gt; - 한 번에 모든 규칙 적용하면 수정량이 폭발&lt;/li&gt;
&lt;li&gt;&lt;b&gt;팀원 합의 필요&lt;/b&gt; - 새 도구 도입은 팀 전체 동의 필수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;IDE 설정 동기화&lt;/b&gt; - VS Code에서 Ruff/Pyright 확장 설치 필요&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;권장 사항&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✅ Ruff + Pyright 도입 추천:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.10+ 프로젝트&lt;/li&gt;
&lt;li&gt;CI 속도 개선이 필요한 경우&lt;/li&gt;
&lt;li&gt;설정 파일 분산이 복잡한 경우&lt;/li&gt;
&lt;li&gt;최신 Python 문법을 적용하고 싶은 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;⚠️ 신중히 검토:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.9 이하 프로젝트 (pyupgrade 규칙 조정 필요)&lt;/li&gt;
&lt;li&gt;mypy 플러그인을 많이 사용하는 경우&lt;/li&gt;
&lt;li&gt;기존 CI에 강하게 결합된 lint 설정이 있는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 우리 프로젝트는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ UV로 의존성 관리 (Infra-1편)&lt;/li&gt;
&lt;li&gt;✅ Python 3.13 (Infra-2편)&lt;/li&gt;
&lt;li&gt;✅ Python 3.14 (Infra-3편)&lt;/li&gt;
&lt;li&gt;✅ Ruff + Pyright (Infra-4편)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 인프라 개선으로는 &lt;b&gt;pre-commit 훅 설정&lt;/b&gt;과 &lt;b&gt;Pyright strict 모드 전환&lt;/b&gt;을 검토 중이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astral.sh/ruff/&quot;&gt;Ruff Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astral.sh/ruff/rules/&quot;&gt;Ruff Rules Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://microsoft.github.io/pyright/&quot;&gt;Pyright Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astral.sh/ty/&quot;&gt;ty Documentation&lt;/a&gt; - Astral의 Rust 기반 타입 체커&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/astral-sh/ty&quot;&gt;ty GitHub Repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astral.sh/uv/&quot;&gt;UV Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로젝트 저장소:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GitHub: &lt;a href=&quot;https://github.com/mgh3326/auto_trader&quot;&gt;github.com/mgh3326/auto_trader&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문이나 피드백은 이슈로 남겨주세요!&lt;/p&gt;</description>
      <category>Programming/Python</category>
      <category>GitHub Actions CI</category>
      <category>pyproject.toml</category>
      <category>Pyright</category>
      <category>Python 린터</category>
      <category>ruff</category>
      <category>UV 패키지 매니저</category>
      <category>개발 인프라 개선</category>
      <category>정적 분석</category>
      <category>코드 품질</category>
      <category>타입 체커</category>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/241</guid>
      <comments>https://mgh3326.tistory.com/241#entry241comment</comments>
      <pubDate>Sun, 25 Jan 2026 18:26:51 +0900</pubDate>
    </item>
    <item>
      <title>프릳츠 디카페인 원두 V60 드립 레시피 | Kingrinder K6 + 하리오 V60-02</title>
      <link>https://mgh3326.tistory.com/240</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;610&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tMJfL/dJMcaia3NJF/5dBfdJqmfWqm915CSZ3Fv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tMJfL/dJMcaia3NJF/5dBfdJqmfWqm915CSZ3Fv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tMJfL/dJMcaia3NJF/5dBfdJqmfWqm915CSZ3Fv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtMJfL%2FdJMcaia3NJF%2F5dBfdJqmfWqm915CSZ3Fv0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1160&quot; height=&quot;610&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;610&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카페인 없이도 맛있는 커피를 즐기고 싶을 때, 프릳츠의 디카페인 원두는 좋은 선택이다. &quot;FULL TIME COFFEE LOVER&quot;라는 문구가 적힌 파란색 패키지처럼, 하루 종일 커피를 마시고 싶은 사람들을 위한 원두다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btuO9o/dJMcadHx86z/EFshOZ8TGKfHEeF6qPlkK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btuO9o/dJMcadHx86z/EFshOZ8TGKfHEeF6qPlkK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btuO9o/dJMcadHx86z/EFshOZ8TGKfHEeF6qPlkK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtuO9o%2FdJMcadHx86z%2FEFshOZ8TGKfHEeF6qPlkK0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;600&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 Kingrinder K6 그라인더와 하리오 V60-02 드리퍼로 프릳츠 디카페인을 맛있게 추출하는 레시피를 정리한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디카페인 원두의 특성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디카페인 원두는 일반 원두와 추출 특성이 다르다. 카페인 제거 과정에서 원두의 셀룰로스 구조가 변하면서 물이 더 빠르게 침투하고, 추출도 빨리 진행된다. 같은 세팅으로 내리면 과다추출되기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 보정하려면 두 가지를 조정한다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;물 온도를 낮추기&lt;/b&gt; (92~94&amp;deg;C)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분쇄도를 굵게 잡기&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기본 레시피&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목표:&lt;/b&gt; 깔끔하고 단맛 중심, 쓴맛 과다 방지&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;세팅&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;원두&lt;/td&gt;
&lt;td&gt;20g&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;물&lt;/td&gt;
&lt;td&gt;300g (비율 1:15)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;물 온도&lt;/td&gt;
&lt;td&gt;92~94&amp;deg;C&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;분쇄도 (K6)&lt;/td&gt;
&lt;td&gt;85~95 클릭&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;목표 시간&lt;/td&gt;
&lt;td&gt;2:45 ~ 3:15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;V60 기준 중간에서 약간 굵은 정도의 분쇄도다. K6 기준 90 클릭 근처에서 시작해서 맛을 보고 조정하면 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;푸어링 스케줄&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;484&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OmvET/dJMcai9UwyH/KE3tsybFLDQe4FMWXZS8m0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OmvET/dJMcai9UwyH/KE3tsybFLDQe4FMWXZS8m0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OmvET/dJMcai9UwyH/KE3tsybFLDQe4FMWXZS8m0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOmvET%2FdJMcai9UwyH%2FKE3tsybFLDQe4FMWXZS8m0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1160&quot; height=&quot;484&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;484&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;준비&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필터를 충분히 린스해서 종이 냄새를 제거하고, 서버와 드리퍼를 예열한다. 예열에 쓴 물은 버린다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 블루밍 (0:00)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;50g&lt;/b&gt; 투입&lt;/li&gt;
&lt;li&gt;원두 전체를 골고루 적신 뒤 가볍게 스월 한 번 (또는 젓기 1~2회)&lt;/li&gt;
&lt;li&gt;0:40까지 가스 배출 대기&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 1차 푸어 (0:40)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;150g&lt;/b&gt;까지 천천히, 가는 물줄기로 투입&lt;/li&gt;
&lt;li&gt;수위를 너무 높이지 않도록 주의&lt;/li&gt;
&lt;li&gt;끝나면 가볍게 드리퍼를 톡 치거나 스월 (선택)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 2차 푸어 (1:20)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;230g&lt;/b&gt;까지 투입&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 마무리 푸어 (1:55)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;300g&lt;/b&gt;까지 투입&lt;/li&gt;
&lt;li&gt;마지막에 아주 가볍게 스월 한 번 (벽면 가루 정리 + 평탄화)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;완전 드립 종료: 2:45~3:15&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;맛으로 튜닝하기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;436&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brdKgT/dJMcafSWVGZ/AxM3bdYNyfo6vZAxb4EnTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brdKgT/dJMcafSWVGZ/AxM3bdYNyfo6vZAxb4EnTk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brdKgT/dJMcafSWVGZ/AxM3bdYNyfo6vZAxb4EnTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrdKgT%2FdJMcafSWVGZ%2FAxM3bdYNyfo6vZAxb4EnTk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1160&quot; height=&quot;436&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;436&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추출 결과가 마음에 안 들면 아래 기준으로 조정한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;쓴맛/텁텁/탄 느낌 (과다추출)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;분쇄 더 굵게 (+5 클릭)&lt;/li&gt;
&lt;li&gt;물 온도 낮추기 (-1~2&amp;deg;C)&lt;/li&gt;
&lt;li&gt;스월/교반 줄이기&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시큼/밍밍/바디 얇음 (과소추출)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;분쇄 더 곱게 (-5 클릭)&lt;/li&gt;
&lt;li&gt;물 온도 높이기 (+1~2&amp;deg;C)&lt;/li&gt;
&lt;li&gt;총 물량 늘리기 (310&lt;del&gt;320g, 비율 1:15.5&lt;/del&gt;1:16)&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;바디는 괜찮은데 단맛 부족&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;분쇄도는 유지&lt;/li&gt;
&lt;li&gt;붓는 속도를 더 천천히&lt;/li&gt;
&lt;li&gt;마지막 스월은 &quot;아주 살짝&quot;만&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프릳츠 디카페인은 저녁이나 카페인에 민감한 날에도 커피를 즐길 수 있게 해주는 원두다. 디카페인 특유의 추출 특성만 이해하고 세팅을 조금 조정하면, 일반 원두 못지않은 맛을 뽑아낼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로스팅 프로파일(초콜릿/너티 계열인지 과일향 계열인지)과 현재 추출 결과(쓴쪽/신쪽/밍밍)에 따라 K6 클릭을 더 정밀하게 맞출 수 있으니, 몇 번 내려보면서 본인 취향에 맞는 스윗스팟을 찾아보자.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;사용 장비: Kingrinder K6, 하리오 V60-02, 프릳츠 디카페인 200g&lt;/i&gt;&lt;/p&gt;</description>
      <category>커피</category>
      <category>V60드립</category>
      <category>V60레시피</category>
      <category>디카페인원두</category>
      <category>분쇄도세팅</category>
      <category>커피추출튜닝</category>
      <category>킹그라인더k6</category>
      <category>푸어링스케줄</category>
      <category>프릳츠디카페인</category>
      <category>프릳츠원두</category>
      <category>핸드드립레시피</category>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/240</guid>
      <comments>https://mgh3326.tistory.com/240#entry240comment</comments>
      <pubDate>Sun, 25 Jan 2026 12:41:19 +0900</pubDate>
    </item>
    <item>
      <title>Python 3.14 업그레이드: t-strings, Free-threading, 그리고 성능 개선</title>
      <link>https://mgh3326.tistory.com/239</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJPuyL/dJMcaiu5tyr/A22epwUpLJ1i6FBg287Qz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJPuyL/dJMcaiu5tyr/A22epwUpLJ1i6FBg287Qz1/img.png&quot; data-alt=&quot;Python 3.14 업그레이드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJPuyL/dJMcaiu5tyr/A22epwUpLJ1i6FBg287Qz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJPuyL%2FdJMcaiu5tyr%2FA22epwUpLJ1i6FBg287Qz1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;378&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Python 3.14 업그레이드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;Python 3.14 &quot;Pi Release&quot; - t-strings, Free-threading, 성능 개선&lt;/i&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;b&gt;개발 인프라 개선 시리즈&lt;/b&gt;의 &lt;b&gt;Infra-3편&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개발 인프라 개선 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/235&quot;&gt;Infra-1편: Poetry에서 UV로 마이그레이션&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/236&quot;&gt;Infra-2편: Python 3.13 업그레이드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Infra-3편: Python 3.14 업그레이드&lt;/b&gt; &amp;larr; 현재 글&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AI 자동매매 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/227&quot;&gt;1편: 한투 API로 실시간 주식 데이터 수집하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/228&quot;&gt;2편: yfinance로 애플&amp;middot;테슬라 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/229&quot;&gt;3편: Upbit으로 비트코인 24시간 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/230&quot;&gt;4편: AI 분석 결과 DB에 저장하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/232&quot;&gt;5편: Upbit 웹 트레이딩 대시보드 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/233&quot;&gt;6편: 실전 운영을 위한 모니터링 시스템 구축&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/234&quot;&gt;7편: 라즈베리파이 홈서버에 자동 HTTPS로 안전하게 배포하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHRAUO/dJMcaaqkxC8/QMfo0JDsfpGTDqleFBppYk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHRAUO/dJMcaaqkxC8/QMfo0JDsfpGTDqleFBppYk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHRAUO/dJMcaaqkxC8/QMfo0JDsfpGTDqleFBppYk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHRAUO%2FdJMcaaqkxC8%2FQMfo0JDsfpGTDqleFBppYk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;600&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Python 3.14 - &quot;Pi Release&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Python 3.14가 2025년 10월에 정식 릴리즈되었습니다. 버전 번호가 원주율(&amp;pi; &amp;asymp; 3.14159...)과 같아서 &lt;b&gt;&quot;Pi Release&quot;&lt;/b&gt;라는 별명이 붙었습니다!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blZTXD/dJMcadtMfq8/uBf9GmJGrp5bYfkAZshTQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blZTXD/dJMcadtMfq8/uBf9GmJGrp5bYfkAZshTQ0/img.png&quot; data-alt=&quot;Python 버전 타임라인&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blZTXD/dJMcadtMfq8/uBf9GmJGrp5bYfkAZshTQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblZTXD%2FdJMcadtMfq8%2FuBf9GmJGrp5bYfkAZshTQ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;270&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;270&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Python 버전 타임라인&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;Python 버전별 릴리즈 및 EOL 타임라인 (3.14 추가)&lt;/i&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 3.13에서 바로 3.14로?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 Infra-2편에서 Python 3.13으로 업그레이드한 지 1년밖에 되지 않았는데, 왜 벌써 3.14로 업그레이드한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번 업그레이드를 결정한 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;t-strings&lt;/b&gt;: SQL 인젝션 방지 등 보안 강화에 유용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Free-threading 공식 지원&lt;/b&gt;: GIL 없이 멀티스레딩 성능 극대화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;3-5% 성능 향상&lt;/b&gt;: Tail-call interpreter로 체감 가능한 속도 개선&lt;/li&gt;
&lt;li&gt;&lt;b&gt;지연된 어노테이션 평가&lt;/b&gt;: 순환 참조 없이 타입 힌팅 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Zstandard 압축&lt;/b&gt;: 기본 라이브러리로 고성능 압축 지원&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Python 3.14의 주요 변경사항&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Template Strings (t-strings) - PEP 750&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Python 3.14의 가장 혁신적인 기능이다. f-string과 비슷하지만, &lt;b&gt;문자열 처리를 커스터마이징&lt;/b&gt;할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기본 사용법:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;from string.templatelib import Template, Interpolation

name = &quot;Alice&quot;
template = t&quot;Hello, {name}!&quot;

# Template 객체 반환 (문자열이 아님!)
print(type(template))  # &amp;lt;class 'string.templatelib.Template'&amp;gt;

# 정적/동적 부분에 개별 접근 가능
for part in template:
    print(part)
# 출력:
# Hello,
# Interpolation('Alice', 'name', None, '')
# !&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실전 활용 - SQL 인젝션 방지:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from string.templatelib import Template, Interpolation

def safe_sql(template: Template) -&amp;gt; tuple[str, list]:
    &quot;&quot;&quot;SQL 인젝션을 방지하는 안전한 쿼리 생성&quot;&quot;&quot;
    query_parts = []
    params = []

    for part in template:
        if isinstance(part, Interpolation):
            query_parts.append(&quot;?&quot;)  # placeholder
            params.append(part.value)
        else:
            query_parts.append(part)

    return &quot;&quot;.join(query_parts), params

# 사용 예시
user_input = &quot;'; DROP TABLE users; --&quot;  # 악의적인 입력
query, params = safe_sql(t&quot;SELECT * FROM users WHERE name = {user_input}&quot;)

print(query)   # SELECT * FROM users WHERE name = ?
print(params)  # [&quot;'; DROP TABLE users; --&quot;]
# SQL 인젝션 완전 차단!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HTML 이스케이프:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import html
from string.templatelib import Template, Interpolation

def safe_html(template: Template) -&amp;gt; str:
    &quot;&quot;&quot;XSS를 방지하는 안전한 HTML 생성&quot;&quot;&quot;
    parts = []
    for part in template:
        if isinstance(part, Interpolation):
            parts.append(html.escape(str(part.value)))
        else:
            parts.append(part)
    return &quot;&quot;.join(parts)

user_comment = &quot;&amp;lt;script&amp;gt;alert('XSS')&amp;lt;/script&amp;gt;&quot;
safe_output = safe_html(t&quot;&amp;lt;div&amp;gt;{user_comment}&amp;lt;/div&amp;gt;&quot;)
# &amp;lt;div&amp;gt;&amp;amp;lt;script&amp;amp;gt;alert('XSS')&amp;amp;lt;/script&amp;amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Free-threaded Python 공식 지원 (PEP 779)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Python 3.13에서 실험적이었던 Free-threading이 &lt;b&gt;공식 지원&lt;/b&gt;으로 승격되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GIL(Global Interpreter Lock) 없이 진정한 병렬 처리:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import threading
import time

def cpu_intensive(n: int) -&amp;gt; int:
    &quot;&quot;&quot;CPU 집약적인 작업&quot;&quot;&quot;
    total = 0
    for i in range(n):
        total += i * i
    return total

# Free-threaded 빌드에서는 진정한 병렬 처리!
threads = []
start = time.time()

for _ in range(4):
    t = threading.Thread(target=cpu_intensive, args=(10_000_000,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f&quot;실행 시간: {time.time() - start:.2f}초&quot;)
# Free-threaded: ~1초 (4배 빠름)
# 일반 Python: ~4초 (순차 실행과 동일)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;InterpreterPoolExecutor로 더 간단하게:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;from concurrent.futures import InterpreterPoolExecutor

def analyze_stock(symbol: str) -&amp;gt; dict:
    # CPU 집약적인 분석 작업
    return {&quot;symbol&quot;: symbol, &quot;result&quot;: &quot;...&quot;}

symbols = [&quot;005930&quot;, &quot;000660&quot;, &quot;035420&quot;, &quot;068270&quot;]

# 각 인터프리터가 독립적인 GIL을 가짐 (또는 GIL 없음)
with InterpreterPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(analyze_stock, symbols))&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Tail-call Interpreter (3-5% 성능 향상)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 인터프리터 구현으로 &lt;b&gt;전반적인 성능이 3-5% 향상&lt;/b&gt;되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 최적화:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;개별 Python opcode를 작은 C 함수로 구현&lt;/li&gt;
&lt;li&gt;Tail call 최적화로 함수 호출 오버헤드 감소&lt;/li&gt;
&lt;li&gt;Clang 19+, x86-64/AArch64에서 최적의 성능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;모듈별 성능 개선:&lt;/b&gt;&lt;br /&gt;| 모듈/함수 | 개선 폭 |&lt;br /&gt;|----------|--------|&lt;br /&gt;| &lt;code&gt;base64.b16decode()&lt;/code&gt; | 6배 빠름 |&lt;br /&gt;| &lt;code&gt;uuid.uuid3/uuid5()&lt;/code&gt; | 40% 빠름 |&lt;br /&gt;| &lt;code&gt;uuid.uuid4()&lt;/code&gt; | 30% 빠름 |&lt;br /&gt;| &lt;code&gt;difflib.IS_LINE_JUNK()&lt;/code&gt; | 2배 빠름 |&lt;br /&gt;| 파일 읽기 (캐시된) | 15% 빠름 |&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 지연된 어노테이션 평가 (PEP 649, 749)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타입 힌팅에서 &lt;b&gt;전방 참조 문자열이 더 이상 필요 없다.!&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Before (Python 3.13 이하):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;from __future__ import annotations  # 필수

class Node:
    def __init__(self, value: int):
        self.value = value
        self.next: &quot;Node&quot; = None  # 문자열로 감싸야 함

    def append(self, node: &quot;Node&quot;) -&amp;gt; &quot;Node&quot;:
        self.next = node
        return node&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;After (Python 3.14):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;# from __future__ import annotations 불필요!

class Node:
    def __init__(self, value: int):
        self.value = value
        self.next: Node = None  # 그냥 사용 가능!

    def append(self, node: Node) -&amp;gt; Node:
        self.next = node
        return node&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;annotationlib으로 어노테이션 검사:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;from annotationlib import get_annotations, Format

def greet(name: str) -&amp;gt; str:
    return f&quot;Hello, {name}&quot;

# 값으로 평가
annotations = get_annotations(greet, format=Format.VALUE)
print(annotations)  # {'name': &amp;lt;class 'str'&amp;gt;, 'return': &amp;lt;class 'str'&amp;gt;}

# 문자열로 가져오기
annotations = get_annotations(greet, format=Format.STRING)
print(annotations)  # {'name': 'str', 'return': 'str'}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. Zstandard 압축 지원 (PEP 784)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;표준 라이브러리에서 Zstandard 압축을 지원한다.!&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from compression import zstd
import json

# 대용량 데이터 압축
data = json.dumps({&quot;prices&quot;: list(range(100000))}).encode()

# 압축
compressed = zstd.compress(data)
print(f&quot;원본: {len(data):,} bytes&quot;)
print(f&quot;압축: {len(compressed):,} bytes&quot;)
print(f&quot;압축률: {len(compressed) / len(data) * 100:.1f}%&quot;)

# 압축 해제
decompressed = zstd.decompress(compressed)
assert data == decompressed&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;tarfile, zipfile에서도 지원:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;import tarfile

# .tar.zst 아카이브 생성
with tarfile.open(&quot;backup.tar.zst&quot;, &quot;w|zst&quot;) as tar:
    tar.add(&quot;data/&quot;)

# .tar.zst 아카이브 읽기
with tarfile.open(&quot;backup.tar.zst&quot;, &quot;r|zst&quot;) as tar:
    tar.extractall(&quot;restored/&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. UUID v6, v7, v8 지원 (RFC 9562)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시간 기반 정렬이 가능한 UUID v7:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;import uuid

# UUID v7: 시간 기반, 정렬 가능
id1 = uuid.uuid7()
id2 = uuid.uuid7()
id3 = uuid.uuid7()

# 생성 순서대로 정렬됨!
print(sorted([id3, id1, id2]) == [id1, id2, id3])  # True

# UUID v6: UUID v1의 개선 버전
id_v6 = uuid.uuid6()

# UUID v8: 커스텀 데이터 포함
custom_data = b&quot;\x12\x34\x56\x78\x9a\xbc\xde\xf0\x12\x34\x56\x78\x9a\xbc\xde\xf0&quot;
id_v8 = uuid.uuid8(custom_data)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;데이터베이스 PK로 UUID v7 활용:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import UUID
import uuid

class StockAnalysis(Base):
    __tablename__ = &quot;stock_analysis&quot;

    # UUID v7: 시간순 정렬 + 고유성
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid7)
    # 인덱스 성능 향상: 시간순 삽입으로 B-tree 효율적&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. except 괄호 생략 가능 (PEP 758)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여러 예외를 괄호 없이 처리:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# Python 3.14
try:
    response = await fetch_data()
except TimeoutError, ConnectionError:  # 괄호 없이!
    print(&quot;네트워크 오류 발생&quot;)

# 기존 방식도 계속 지원
try:
    response = await fetch_data()
except (TimeoutError, ConnectionError):  # 괄호 사용
    print(&quot;네트워크 오류 발생&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 향상된 오류 메시지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;더 친절한 SyntaxError:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 키워드 오타 감지
whille True:  # typo!
    pass
# SyntaxError: invalid syntax. Did you mean 'while'?

# 잘못된 할당
x = 10
if x = 20:  # = 대신 ==
    pass
# SyntaxError: invalid syntax. Maybe you meant '==' instead of '='?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TypeError 명확화:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;s = set()
s.add({&quot;key&quot;: &quot;value&quot;})
# TypeError: cannot use 'dict' as a set element (unhashable type: 'dict')
# 이전: TypeError: unhashable type: 'dict'&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. pdb 원격 디버깅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행 중인 프로세스에 디버거 연결:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 실행 중인 Python 프로세스 PID 확인
ps aux | grep python
# PID: 12345

# 원격 디버거 연결
python -m pdb -p 12345&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;asyncio 태스크 내검:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 실행 중인 asyncn&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Deprecations 주의사항&lt;/h2&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.14에서 제거된 API&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# asyncio.get_event_loop() - RuntimeError 발생!
# Before (3.13 이하)
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

# After (3.14)
asyncio.run(main())
# 또는
async with asyncio.Runner() as runner:
    runner.run(main())&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;향후 제거 예정&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;버전&lt;/th&gt;
&lt;th&gt;제거 예정 API&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3.15&lt;/td&gt;
&lt;td&gt;&lt;code&gt;asyncio.iscoroutinefunction()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.16&lt;/td&gt;
&lt;td&gt;asyncio 정책 시스템&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.17&lt;/td&gt;
&lt;td&gt;&lt;code&gt;typing.ByteString&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;체크리스트&lt;/h2&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;업그레이드 전:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 주요 의존성이 Python 3.14 지원하는지 확인&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;asyncio.get_event_loop()&lt;/code&gt; 사용 여부 확인&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 현재 환경에서 모든 테스트 통과&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;업그레이드 중:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;.python-version&lt;/code&gt; 파일 업데이트 (3.13 &amp;rarr; 3.14)&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;pyproject.toml&lt;/code&gt;의 &lt;code&gt;requires-python&lt;/code&gt; 업데이트&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;uv lock&lt;/code&gt;으로 lockfile 재생성&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; GitHub Actions 워크플로우 업데이트&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;업그레이드 후:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 로컬 환경에서 테스트 실행&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; CI/CD 파이프라인 통과 확인&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 프로덕션 배포 후 모니터링&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Python 3.14 업그레이드 소감&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Python 3.13에서 3.14로의 업그레이드는 &lt;b&gt;예상보다 훨씬 수월&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 마음에 드는 기능:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;t-strings&lt;/b&gt;: SQL 인젝션, XSS 방지가 언어 레벨에서 지원&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능 향상&lt;/b&gt;: 별도 최적화 없이 4-6% 빠름&lt;/li&gt;
&lt;li&gt;&lt;b&gt;지연된 어노테이션&lt;/b&gt;: &lt;code&gt;from __future__ import annotations&lt;/code&gt; 안녕!&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실제 적용할 기능:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UUID v7으로 DB PK 변경 검토&lt;/li&gt;
&lt;li&gt;t-strings로 동적 SQL 쿼리 안전하게 생성&lt;/li&gt;
&lt;li&gt;Zstandard로 대용량 로그 압축&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;권장 사항&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✅ Python 3.14 업그레이드 추천:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이미 Python 3.13을 사용 중인 프로젝트&lt;/li&gt;
&lt;li&gt;t-strings의 보안 기능이 필요한 경우&lt;/li&gt;
&lt;li&gt;성능 개선이 필요한 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;⚠️ 신중히 검토:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;asyncio.get_event_loop()&lt;/code&gt; 를 많이 사용하는 레거시 코드&lt;/li&gt;
&lt;li&gt;아직 3.14 미지원 라이브러리에 의존하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다음 단계&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 우리 프로젝트는:&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ UV로 의존성 관리 (Infra-1편)&lt;/li&gt;
&lt;li&gt;✅ Python 3.13 (Infra-2편)&lt;/li&gt;
&lt;li&gt;✅ Python 3.14 (Infra-3편)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/3/whatsnew/3.14.html&quot;&gt;Python 3.14 공식 릴리즈 노트&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peps.python.org/pep-0750/&quot;&gt;PEP 750 - Template Strings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peps.python.org/pep-0779/&quot;&gt;PEP 779 - Free-threaded CPython&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peps.python.org/pep-0649/&quot;&gt;PEP 649 - Deferred Evaluation of Annotations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peps.python.org/pep-0784/&quot;&gt;PEP 784 - Adding Zstandard to the Standard Library&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로젝트 저장소:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GitHub: &lt;a href=&quot;https://github.com/mgh3326/auto_trader&quot;&gt;github.com/mgh3326/auto_trader&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문이나 피드백은 이슈로 남겨주세요!&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Programming/Python</category>
      <category>cpython</category>
      <category>Free-threading</category>
      <category>Python 3.14</category>
      <category>Python 성능 개선</category>
      <category>Python 업그레이드</category>
      <category>Python 인프라</category>
      <category>Python 최신 기능</category>
      <category>t-strings</category>
      <category>개발 인프라 개선</category>
      <category>백엔드 개발</category>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/239</guid>
      <comments>https://mgh3326.tistory.com/239#entry239comment</comments>
      <pubDate>Fri, 19 Dec 2025 18:05:39 +0900</pubDate>
    </item>
    <item>
      <title>다중 브로커 통합 포트폴리오: 토스 증권 수동 잔고 연동하기</title>
      <link>https://mgh3326.tistory.com/238</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/clwOth/dJMcadNXTJ2/pvpMTczMWwWhybyuLSBJo1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/clwOth/dJMcadNXTJ2/pvpMTczMWwWhybyuLSBJo1/img.png&quot; data-alt=&quot;통합 포트폴리오 시스템&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/clwOth/dJMcadNXTJ2/pvpMTczMWwWhybyuLSBJo1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FclwOth%2FdJMcadNXTJ2%2FpvpMTczMWwWhybyuLSBJo1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;378&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;통합 포트폴리오 시스템&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 AI 기반 자동매매 시스템 시리즈의 &lt;b&gt;10편&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;전체 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/227&quot;&gt;1편: 한투 API로 실시간 주식 데이터 수집하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/228&quot;&gt;2편: yfinance로 애플&amp;middot;테슬라 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/229&quot;&gt;3편: Upbit으로 비트코인 24시간 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/230&quot;&gt;4편: AI 분석 결과 DB에 저장하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/232&quot;&gt;5편: Upbit 웹 트레이딩 대시보드 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/233&quot;&gt;6편: 실전 운영을 위한 모니터링 시스템 구축&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/234&quot;&gt;7편: 라즈베리파이 홈서버에 자동 HTTPS로 안전하게 배포하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/235&quot;&gt;8편: JWT 인증 시스템으로 안전한 웹 애플리케이션 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/237&quot;&gt;9편: KIS 국내/해외 주식 자동 매매 시스템 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;10편: 다중 브로커 통합 포트폴리오 시스템 구축하기&lt;/b&gt; &amp;larr; 현재 글&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c8cpvc/dJMcadHcNUY/RqEyinkJbkDrx3RsESBa30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c8cpvc/dJMcadHcNUY/RqEyinkJbkDrx3RsESBa30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c8cpvc/dJMcadHcNUY/RqEyinkJbkDrx3RsESBa30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc8cpvc%2FdJMcadHcNUY%2FRqEyinkJbkDrx3RsESBa30%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;600&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 통합 포트폴리오인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 투자를 하다 보면 여러 증권사에 분산 투자하는 경우가 많습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;나의 현실:
├── 한국투자증권 (KIS) - API 연동 ✅
│   ├── 삼성전자 50주 @ 68,200원
│   └── SK하이닉스 20주 @ 185,500원
│
└── 토스증권 - API 없음 ❌
    ├── 삼성전자 30주 @ 71,000원
    └── AAPL 10주 @ $180&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;분산된 보유 현황&lt;/b&gt;: 같은 종목을 여러 브로커에서 보유&lt;/li&gt;
&lt;li&gt;&lt;b&gt;평단가 계산 불가&lt;/b&gt;: 통합 평단가를 알 수 없음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AI 분석 비효율&lt;/b&gt;: 실제 보유량을 모르면 정확한 분석 불가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;수동 관리 필요&lt;/b&gt;: 토스 잔고 변경 시 수동으로 기록해야 함&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이번 편에서 만들 것&lt;/h3&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;통합 포트폴리오 시스템
├── 수동 잔고 등록 대시보드
│   ├── 브로커 계좌 관리 (토스, 한투, 업비트)
│   ├── 종목별 잔고 등록/수정/삭제
│   └── 대량 등록 (Bulk Import)
│
├── 통합 평단가 계산
│   ├── KIS 보유 + 토스 보유 = 통합 평단가
│   └── 브로커별 평단가 비교
│
├── 가격 전략 시스템
│   ├── 다양한 매수/매도 전략
│   └── 시뮬레이션 (Dry-run)
│
└── 토스 알림 서비스
    ├── 매수/매도 추천 알림
    └── 예상 수익 계산&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시스템 아키텍처&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;463&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qeVcL/dJMcagxcLqE/mQEp12fISE2tPnVUPHVunk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qeVcL/dJMcagxcLqE/mQEp12fISE2tPnVUPHVunk/img.png&quot; data-alt=&quot;통합 포트폴리오 아키텍처&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qeVcL/dJMcagxcLqE/mQEp12fISE2tPnVUPHVunk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqeVcL%2FdJMcagxcLqE%2FmQEp12fISE2tPnVUPHVunk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;463&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;463&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;통합 포트폴리오 아키텍처&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;다중 브로커 통합 포트폴리오 시스템 구조&lt;/i&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 설계 원칙&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. KIS는 API, 나머지는 수동&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# KIS: 실시간 API로 자동 조회
kis_holdings = await kis_client.fetch_my_stocks()

# 토스: DB에 수동 등록된 데이터 조회
manual_holdings = await manual_service.get_holdings_by_user(user_id)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 통합 평단가 = 가중 평균&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 삼성전자 예시
# KIS: 50주 @ 68,200원
# 토스: 30주 @ 71,000원

total_value = (50 * 68,200) + (30 * 71,000)  # 5,540,000원
total_quantity = 50 + 30  # 80주
combined_avg = total_value / total_quantity  # 69,250원&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 매도는 KIS 보유분만&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 토스는 API가 없으므로 주문 불가
# KIS 보유분 50주 내에서만 매도 가능
if requested_quantity &amp;gt; kis_quantity:
    raise Error(&quot;KIS 보유 수량 초과&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;데이터베이스 설계&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c0uIlm/dJMcagDZo3m/kqq5smYDRjyIehjzSQCCm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c0uIlm/dJMcagDZo3m/kqq5smYDRjyIehjzSQCCm0/img.png&quot; data-alt=&quot;수동 잔고 ERD&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c0uIlm/dJMcagDZo3m/kqq5smYDRjyIehjzSQCCm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc0uIlm%2FdJMcagDZo3m%2Fkqq5smYDRjyIehjzSQCCm0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;420&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;수동 잔고 ERD&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;수동 잔고 관리 ERD&lt;/i&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;브로커 계좌 테이블&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/models/manual_holdings.py

class BrokerType(str, enum.Enum):
    &quot;&quot;&quot;브로커 타입&quot;&quot;&quot;
    kis = &quot;kis&quot;      # 한국투자증권
    toss = &quot;toss&quot;    # 토스증권
    upbit = &quot;upbit&quot;  # 업비트 (암호화폐)


class MarketType(str, enum.Enum):
    &quot;&quot;&quot;시장 타입&quot;&quot;&quot;
    KR = &quot;KR&quot;          # 국내주식
    US = &quot;US&quot;          # 해외주식
    CRYPTO = &quot;CRYPTO&quot;  # 암호화폐


class BrokerAccount(Base):
    &quot;&quot;&quot;브로커 계좌 테이블

    사용자별 브로커 계좌를 관리
    &quot;&quot;&quot;

    __tablename__ = &quot;broker_accounts&quot;
    __table_args__ = (
        UniqueConstraint(
            &quot;user_id&quot;, &quot;broker_type&quot;, &quot;account_name&quot;,
            name=&quot;uq_broker_account&quot;
        ),
    )

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    user_id: Mapped[int] = mapped_column(
        ForeignKey(&quot;users.id&quot;, ondelete=&quot;CASCADE&quot;),
        nullable=False,
        index=True
    )
    broker_type: Mapped[BrokerType] = mapped_column(
        Enum(BrokerType, name=&quot;broker_type&quot;),
        nullable=False
    )
    account_name: Mapped[str] = mapped_column(
        Text, nullable=False, default=&quot;기본 계좌&quot;
    )
    is_mock: Mapped[bool] = mapped_column(
        Boolean, default=False, nullable=False
    )  # 모의투자 계좌 여부
    is_active: Mapped[bool] = mapped_column(
        Boolean, default=True, nullable=False
    )

    # Relationships
    holdings: Mapped[list[&quot;ManualHolding&quot;]] = relationship(
        back_populates=&quot;broker_account&quot;,
        cascade=&quot;all, delete-orphan&quot;  # 계좌 삭제 시 보유 종목도 삭제
    )&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;수동 보유 종목 테이블&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;class ManualHolding(Base):
    &quot;&quot;&quot;수동 등록 보유 종목 테이블

    외부 브로커의 보유 종목을 수동으로 등록
    &quot;&quot;&quot;

    __tablename__ = &quot;manual_holdings&quot;
    __table_args__ = (
        UniqueConstraint(
            &quot;broker_account_id&quot;, &quot;ticker&quot;, &quot;market_type&quot;,
            name=&quot;uq_holding_ticker&quot;
        ),
    )

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    broker_account_id: Mapped[int] = mapped_column(
        ForeignKey(&quot;broker_accounts.id&quot;, ondelete=&quot;CASCADE&quot;),
        nullable=False,
        index=True
    )
    ticker: Mapped[str] = mapped_column(Text, nullable=False, index=True)
    market_type: Mapped[MarketType] = mapped_column(
        Enum(MarketType, name=&quot;market_type&quot;),
        nullable=False
    )
    quantity: Mapped[float] = mapped_column(
        Numeric(18, 8), nullable=False
    )
    avg_price: Mapped[float] = mapped_column(
        Numeric(18, 8), nullable=False
    )
    display_name: Mapped[str | None] = mapped_column(
        Text, nullable=True
    )  # &quot;버크셔 해서웨이 B&quot; 같은 사용자 정의 표시명

    # Relationships
    broker_account: Mapped[&quot;BrokerAccount&quot;] = relationship(
        back_populates=&quot;holdings&quot;
    )&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;종목 별칭 테이블&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토스에서는 &quot;버크셔 해서웨이 B&quot;로 표시되지만, 실제 티커는 &quot;BRK.B&quot;이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;class StockAlias(Base):
    &quot;&quot;&quot;종목 별칭 테이블

    토스 등에서 사용하는 종목 별칭을 정규 티커에 매핑
    예: &quot;버크셔 해서웨이 B&quot; -&amp;gt; &quot;BRK.B&quot;
    &quot;&quot;&quot;

    __tablename__ = &quot;stock_aliases&quot;
    __table_args__ = (
        UniqueConstraint(&quot;alias&quot;, &quot;market_type&quot;, name=&quot;uq_alias_market&quot;),
    )

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    ticker: Mapped[str] = mapped_column(Text, nullable=False, index=True)
    market_type: Mapped[MarketType] = mapped_column(
        Enum(MarketType, name=&quot;market_type&quot;),
        nullable=False
    )
    alias: Mapped[str] = mapped_column(Text, nullable=False, index=True)
    source: Mapped[str] = mapped_column(
        Text, nullable=False, default=&quot;user&quot;
    )  # toss, user, kis&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 예시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;hsp&quot;&gt;&lt;code&gt;# 별칭 등록
await alias_service.create({
    &quot;ticker&quot;: &quot;BRK.B&quot;,
    &quot;market_type&quot;: &quot;US&quot;,
    &quot;alias&quot;: &quot;버크셔 해서웨이 B&quot;,
    &quot;source&quot;: &quot;toss&quot;
})

# 별칭으로 티커 조회
ticker = await alias_service.resolve_alias(
    &quot;버크셔 해서웨이 B&quot;, MarketType.US
)  # &amp;rarr; &quot;BRK.B&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;통합 포트폴리오 서비스&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 데이터 구조&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/services/merged_portfolio_service.py

@dataclass
class HoldingInfo:
    &quot;&quot;&quot;단일 브로커의 보유 정보&quot;&quot;&quot;
    broker: str      # &quot;kis&quot;, &quot;toss&quot; 등
    quantity: float
    avg_price: float


@dataclass
class ReferencePrices:
    &quot;&quot;&quot;참조 평단가 정보&quot;&quot;&quot;
    kis_avg: float | None = None
    kis_quantity: int = 0
    toss_avg: float | None = None
    toss_quantity: int = 0
    combined_avg: float | None = None
    total_quantity: int = 0


@dataclass
class MergedHolding:
    &quot;&quot;&quot;통합 보유 종목 정보&quot;&quot;&quot;
    ticker: str
    name: str
    market_type: str

    # 브로커별 보유 정보
    holdings: list[HoldingInfo]
    kis_quantity: int = 0
    kis_avg_price: float = 0.0
    toss_quantity: int = 0
    toss_avg_price: float = 0.0

    # 통합 정보
    combined_avg_price: float = 0.0
    total_quantity: int = 0
    current_price: float = 0.0
    evaluation: float = 0.0
    profit_loss: float = 0.0
    profit_rate: float = 0.0

    # AI 분석 정보
    analysis_id: int | None = None
    last_analysis_decision: str | None = None
    analysis_confidence: int | None = None&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;가중 평균 평단가 계산&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;class MergedPortfolioService:
    &quot;&quot;&quot;통합 포트폴리오 서비스&quot;&quot;&quot;

    @staticmethod
    def calculate_combined_avg(holdings: list[HoldingInfo]) -&amp;gt; float:
        &quot;&quot;&quot;가중 평균 평단가 계산

        공식: &amp;Sigma;(수량 &amp;times; 평단가) / &amp;Sigma;(수량)
        &quot;&quot;&quot;
        total_value = 0.0
        total_quantity = 0.0

        for h in holdings:
            total_value += h.quantity * h.avg_price
            total_quantity += h.quantity

        if total_quantity == 0:
            return 0.0

        return total_value / total_quantity&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;KIS + 수동 잔고 병합&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def _build_merged_portfolio(
    self,
    user_id: int,
    market_type: MarketType,
    kis_client: KISClient,
) -&amp;gt; list[MergedHolding]:
    &quot;&quot;&quot;KIS 보유 종목과 수동 등록 종목을 병합&quot;&quot;&quot;
    merged: dict[str, MergedHolding] = {}

    # 1. KIS 보유 종목 조회 및 적용
    kis_stocks = await self._fetch_kis_holdings(kis_client, market_type)
    self._apply_kis_holdings(merged, kis_stocks, market_type)

    # 2. 수동 등록 보유 종목 적용
    await self._apply_manual_holdings(merged, user_id, market_type)

    # 3. 통합 정보 계산 (평단가, 수익률 등)
    self._finalize_holdings(merged)

    # 4. AI 분석 결과 및 거래 설정 첨부
    await self._attach_analysis_and_settings(merged)

    return list(merged.values())&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;수동 잔고 적용 로직&lt;/h3&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;async def _apply_manual_holdings(
    self,
    merged: dict[str, MergedHolding],
    user_id: int,
    market_type: MarketType,
) -&amp;gt; None:
    &quot;&quot;&quot;수동 등록 보유 종목 적용&quot;&quot;&quot;
    manual_holdings = await self.manual_holdings_service.get_holdings_by_user(
        user_id, market_type=market_type
    )

    for holding in manual_holdings:
        ticker = holding.ticker
        broker_type = holding.broker_account.broker_type.value  # &quot;toss&quot;
        qty = int(holding.quantity)
        avg_price = float(holding.avg_price)
        name = holding.display_name or ticker

        # 기존 MergedHolding에 추가하거나 새로 생성
        merged_holding = merged.get(ticker)
        if not merged_holding:
            n    reference_prices: ReferencePrices,
) -&amp;gt; dict[str, ExpectedProfit]:
    &quot;&quot;&quot;예상 수익 계산 (각 평단가 기준별)&quot;&quot;&quot;
    ref = reference_prices
    results: dict[str, ExpectedProfit] = {}

    # 한투 평단가 기준 수익
    if ref.kis_avg and ref.kis_avg &amp;gt; 0:
        profit = (sell_price - ref.kis_avg) * quantity
        percent = (sell_price - ref.kis_avg) / ref.kis_avg * 100
        results[&quot;based_on_kis_avg&quot;] = ExpectedProfit(
            amount=round(profit, 2),
            percent=round(percent, 2),
        )

    # 토스 평단가 기준 수익
    if ref.toss_avg and ref.toss_avg &amp;gt; 0:
        profit = (sell_price - ref.toss_avg) * quantity
        percent = (sell_price - ref.toss_avg) / ref.toss_avg * 100
        results[&quot;based_on_toss_avg&quot;] = ExpectedProfit(
            amount=round(profit, 2),
            percent=round(percent, 2),
        )

    # 통합 평단가 기준 수익
    if ref.combined_avg and ref.combined_avg &amp;gt; 0:
        profit = (sell_price - ref.combined_avg) * quantity
        percent = (sell_price - ref.combined_avg) / ref.combined_avg * 100
        results[&quot;based_on_combined_avg&quot;] = ExpectedProfit(
            amount=round(profit, 2),
            percent=round(percent, 2),
        )

    return results&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;매수/매도 API&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시뮬레이션 (Dry-run) 모드&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# app/routers/trading.py

@router.post(&quot;/api/buy&quot;, response_model=OrderSimulationResponse)
async def buy_order(
    data: BuyOrderRequest,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_authenticated_user),
):
    &quot;&quot;&quot;매수 주문

    dry_run=True: 시뮬레이션 (기본값)
    dry_run=False: 실제 주문 실행
    &quot;&quot;&quot;
    kis_client = KISClient()
    portfolio_service = MergedPortfolioService(db)
    price_service = TradingPriceService()

    ticker = data.ticker.upper()

    # 1. KIS 보유 정보 조회
    kis_info = await get_kis_holding_for_ticker(
        kis_client, ticker, data.market_type
    )

    # 2. 현재가 조회
    current_price = await _get_current_price(
        kis_client, ticker, data.market_type
    )

    # 3. 참조 평단가 조회 (KIS + 수동 잔고 통합)
    ref = await portfolio_service.get_reference_prices(
        current_user.id, ticker, data.market_type,
        kis_holdings=kis_info if kis_info.get(&quot;quantity&quot;, 0) &amp;gt; 0 else None,
    )

    # 4. 매수 가격 계산 (선택한 전략에 따라)
    try:
        result = price_service.calculate_buy_price(
            reference_prices=ref,
            current_price=current_price,
            strategy=data.price_strategy,
            discount_percent=data.discount_percent,
            manual_price=data.manual_price,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    # 5. 시뮬레이션 모드면 결과만 반환
    if data.dry_run:
        return OrderSimulationResponse(
            status=&quot;simulated&quot;,
            order_price=result.price,
            price_source=result.price_source,
            current_price=current_price,
            reference_prices=ReferencePricesResponse(**ref.to_dict()),
        )

    # 6. 실제 주문 실행
    if data.market_type == MarketType.KR:
        order_result = await kis_client.order_korea_stock(
            stock_code=ticker,
            order_type=&quot;buy&quot;,
            quantity=data.quantity,
            price=int(result.price),
        )
    else:
        # 해외주식 주문...
        pass

    return OrderSimulationResponse(
        status=&quot;executed&quot;,
        order_price=result.price,
        price_source=result.price_source,
        order_result=order_result,
    )&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;매도 수량 검증&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def validate_sell_quantity(
    self,
    kis_quantity: int,
    requested_quantity: int,
) -&amp;gt; tuple[bool, str | None]:
    &quot;&quot;&quot;매도 수량 검증 (KIS 보유분 내에서만 매도 가능)

    토스는 API가 없으므로 KIS 보유분만 매도 가능
    &quot;&quot;&quot;
    if requested_quantity &amp;lt;= 0:
        return False, &quot;매도 수량은 0보다 커야 한다.&quot;

    if kis_quantity &amp;lt;= 0:
        return False, &quot;KIS 보유분이 없어 매도할 수 없다.&quot;

    if requested_quantity &amp;gt; kis_quantity:
        return False, f&quot;KIS 보유 수량({kis_quantity}주)을 초과할 수 없다.&quot;

    return True, None&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;토스 알림 서비스&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;매수/매도 추천 알림&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토스에 보유 중인 종목에 대해 AI 분석 결과를 텔레그램으로 알림:&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/services/toss_notification_service.py

@dataclass
class TossNotificationData:
    &quot;&quot;&quot;토스 알림 데이터&quot;&quot;&quot;
    ticker: str
    name: str
    current_price: float
    toss_quantity: int
    toss_avg_price: float
    kis_quantity: int | None = None
    kis_avg_price: float | None = None
    recommended_price: float = 0.0
    recommended_quantity: int = 1
    expected_profit: float = 0.0
    profit_percent: float = 0.0
    currency: str = &quot;원&quot;
    market_type: str = &quot;국내주식&quot;


class TossNotificationService:
    &quot;&quot;&quot;토스 보유 종목 알림 서비스&quot;&quot;&quot;

    async def should_notify_toss(
        self,
        user_id: int,
        ticker: str,
        market_type: MarketType,
        kis_holdings: dict | None = None,
    ) -&amp;gt; tuple[bool, ReferencePrices | None]:
        &quot;&quot;&quot;토스 알림을 보내야 하는지 확인&quot;&quot;&quot;
        ref = await self.portfolio_service.get_reference_prices(
            user_id, ticker, market_type, kis_holdings
        )

        # 토스 보유분이 있으면 알림 대상
        if ref.toss_quantity &amp;gt; 0:
            return True, ref

        return False, None

    async def notify_buy_recommendation(
        self,
        data: TossNotificationData,
    ) -&amp;gt; bool:
        &quot;&quot;&quot;토스 매수 추천 알림 발송&quot;&quot;&quot;
        if data.toss_quantity &amp;lt;= 0:
            return False

        notifier = get_trade_notifier()
        return await notifier.notify_toss_buy_recommendation(
            symbol=data.ticker,
            korean_name=data.name,
            current_price=data.current_price,
            toss_quantity=data.toss_quantity,
            toss_avg_price=data.toss_avg_price,
            kis_quantity=data.kis_quantity,
            kis_avg_price=data.kis_avg_price,
            recommended_price=data.recommended_price,
            recommended_quantity=data.recommended_quantity,
            currency=data.currency,
            market_type=data.market_type,
        )&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI 분석 결과 처리&lt;/h3&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;async def process_analysis_result(
    self,
    user_id: int,
    ticker: str,
    name: str,
    market_type: MarketType,
    decision: str,  # buy/sell/hold
    current_price: float,
    recommended_buy_price: float | None = None,
    recommended_sell_price: float | None = None,
    recommended_quantity: int = 1,
    kis_holdings: dict | None = None,
) -&amp;gt; bool:
    &quot;&quot;&quot;AI 분석 결과를 처리하여 토스 알림 발송&quot;&quot;&quot;

    # hold면 알림 안 함
    if decision.lower() == &quot;hold&quot;:
        return False

    # 토스 보유 확인
    should_notify, ref = await self.should_notify_toss(
        user_id, ticker, market_type, kis_holdings
    )

    if not should_notify or not ref:
        return False

    # 알림 데이터 구성
    data = TossNotificationData(
        ticker=ticker,
        name=name,
        current_price=current_price,
        toss_quantity=ref.toss_quantity,
        toss_avg_price=ref.toss_avg or 0,
        kis_quantity=ref.kis_quantity if ref.kis_quantity &amp;gt; 0 else None,
        kis_avg_price=ref.kis_avg if ref.kis_avg else None,
        recommended_quantity=recommended_quantity,
        currency=&quot;$&quot; if market_type == MarketType.US else &quot;원&quot;,
        market_type=&quot;해외주식&quot; if market_type == MarketType.US else &quot;국내주식&quot;,
    )

    # 매수 추천
    if decision.lower() == &quot;buy&quot; and recommended_buy_price:
        data.recommended_price = recommended_buy_price
        return await self.notify_buy_recommendation(data)

    # 매도 추천
    elif decision.lower() == &quot;sell&quot; and recommended_sell_price:
        data.recommended_price = recommended_sell_price

        # 예상 수익 계산 (토스 평단가 기준)
        if ref.toss_avg and ref.toss_avg &amp;gt; 0:
            data.profit_percent = (
                (recommended_sell_price - ref.toss_avg)
                / ref.toss_avg * 100
            )
            data.expected_profit = (
                (recommended_sell_price - ref.toss_avg)
                * recommended_quantity
            )

        return await self.notify_sell_recommendation(data)

    return False&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;웹 대시보드&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;수동 잔고 관리 UI&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b5kQbS/dJMcabimXAZ/Sj4Xn4tLj2xxgzbTWgpNo1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b5kQbS/dJMcabimXAZ/Sj4Xn4tLj2xxgzbTWgpNo1/img.png&quot; data-alt=&quot;수동 잔고 대시보드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b5kQbS/dJMcabimXAZ/Sj4Xn4tLj2xxgzbTWgpNo1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb5kQbS%2FdJMcabimXAZ%2FSj4Xn4tLj2xxgzbTWgpNo1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;412&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;412&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;수동 잔고 대시보드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;수동 잔고 관리 대시보드&lt;/i&gt;&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- app/templates/manual_holdings_dashboard.html --&amp;gt;

&amp;lt;div class=&quot;container py-4&quot;&amp;gt;
    &amp;lt;h2 class=&quot;mb-3&quot;&amp;gt;
        &amp;lt;i class=&quot;bi bi-wallet2 text-primary&quot;&amp;gt;&amp;lt;/i&amp;gt; 수동 잔고 관리
    &amp;lt;/h2&amp;gt;
    &amp;lt;p class=&quot;text-muted&quot;&amp;gt;
        토스 증권 등 외부 브로커의 보유 종목을 수동으로 등록하여
        통합 포트폴리오를 관리한다.
    &amp;lt;/p&amp;gt;

    &amp;lt;!-- 요약 카드 --&amp;gt;
    &amp;lt;div class=&quot;row mb-4&quot;&amp;gt;
        &amp;lt;div class=&quot;col-md-4&quot;&amp;gt;
            &amp;lt;div class=&quot;card&quot;&amp;gt;
                &amp;lt;div class=&quot;card-body text-center&quot;&amp;gt;
                    &amp;lt;h5 class=&quot;text-muted&quot;&amp;gt;등록된 브로커 계좌&amp;lt;/h5&amp;gt;
                    &amp;lt;h3 id=&quot;account-count&quot; class=&quot;fw-bold&quot;&amp;gt;-&amp;lt;/h3&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;col-md-4&quot;&amp;gt;
            &amp;lt;div class=&quot;card&quot;&amp;gt;
                &amp;lt;div class=&quot;card-body text-center&quot;&amp;gt;
                    &amp;lt;h5 class=&quot;text-muted&quot;&amp;gt;수동 등록 종목&amp;lt;/h5&amp;gt;
                    &amp;lt;h3 id=&quot;holding-count&quot; class=&quot;fw-bold&quot;&amp;gt;-&amp;lt;/h3&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;col-md-4&quot;&amp;gt;
            &amp;lt;div class=&quot;card&quot;&amp;gt;
                &amp;lt;div class=&quot;card-body text-center&quot;&amp;gt;
                    &amp;lt;h5 class=&quot;text-muted&quot;&amp;gt;시장별 종목&amp;lt;/h5&amp;gt;
                    &amp;lt;h3 id=&quot;market-summary&quot; class=&quot;fw-bold&quot;&amp;gt;-&amp;lt;/h3&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;!-- 브로커 계좌 관리 --&amp;gt;
    &amp;lt;div class=&quot;card&quot;&amp;gt;
        &amp;lt;div class=&quot;card-header d-flex justify-content-between&quot;&amp;gt;
            &amp;lt;span&amp;gt;&amp;lt;i class=&quot;bi bi-bank&quot;&amp;gt;&amp;lt;/i&amp;gt; 브로커 계좌&amp;lt;/span&amp;gt;
            &amp;lt;button class=&quot;btn btn-sm btn-primary&quot; onclick=&quot;showAddAccountModal()&quot;&amp;gt;
                &amp;lt;i class=&quot;bi bi-plus&quot;&amp;gt;&amp;lt;/i&amp;gt; 계좌 추가
            &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;card-body&quot;&amp;gt;
            &amp;lt;div id=&quot;accounts-list&quot; class=&quot;row&quot;&amp;gt;
                &amp;lt;!-- 동적으로 계좌 카드 렌더링 --&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;!-- 수동 보유 종목 테이블 --&amp;gt;
    &amp;lt;div class=&quot;card&quot;&amp;gt;
        &amp;lt;div class=&quot;card-header d-flex justify-content-between&quot;&amp;gt;
            &amp;lt;span&amp;gt;&amp;lt;i class=&quot;bi bi-list-ul&quot;&amp;gt;&amp;lt;/i&amp;gt; 수동 등록 보유 종목&amp;lt;/span&amp;gt;
            &amp;lt;div&amp;gt;
                &amp;lt;select id=&quot;market-filter&quot; class=&quot;form-select form-select-sm&quot;&amp;gt;
                    &amp;lt;option value=&quot;&quot;&amp;gt;전체 시장&amp;lt;/option&amp;gt;
                    &amp;lt;option value=&quot;KR&quot;&amp;gt;국내주식&amp;lt;/option&amp;gt;
                    &amp;lt;option value=&quot;US&quot;&amp;gt;해외주식&amp;lt;/option&amp;gt;
                &amp;lt;/select&amp;gt;
                &amp;lt;button class=&quot;btn btn-sm btn-success&quot; onclick=&quot;showAddHoldingModal()&quot;&amp;gt;
                    &amp;lt;i class=&quot;bi bi-plus&quot;&amp;gt;&amp;lt;/i&amp;gt; 종목 추가
                &amp;lt;/button&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;card-body&quot;&amp;gt;
            &amp;lt;table class=&quot;table table-hover&quot;&amp;gt;
                &amp;lt;thead&amp;gt;
                    &amp;lt;tr&amp;gt;
                        &amp;lt;th&amp;gt;종목&amp;lt;/th&amp;gt;
                        &amp;lt;th&amp;gt;브로커&amp;lt;/th&amp;gt;
                        &amp;lt;th class=&quot;text-end&quot;&amp;gt;수량&amp;lt;/th&amp;gt;
                        &amp;lt;th class=&quot;text-end&quot;&amp;gt;평단가&amp;lt;/th&amp;gt;
                        &amp;lt;th class=&quot;text-end&quot;&amp;gt;평가금액&amp;lt;/th&amp;gt;
                        &amp;lt;th class=&quot;text-center&quot;&amp;gt;시장&amp;lt;/th&amp;gt;
                        &amp;lt;th class=&quot;text-center&quot;&amp;gt;관리&amp;lt;/th&amp;gt;
                    &amp;lt;/tr&amp;gt;
                &amp;lt;/thead&amp;gt;
                &amp;lt;tbody id=&quot;holdings-list&quot;&amp;gt;
                    &amp;lt;!-- 동적으로 보유 종목 렌더링 --&amp;gt;
                &amp;lt;/tbody&amp;gt;
            &amp;lt;/table&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;브로커 배지 스타일&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;.broker-badge {
    font-size: 0.75rem;
    padding: 0.25rem 0.5rem;
}
.broker-toss {
    background-color: #0064FF;  /* 토스 브랜드 컬러 */
    color: white;
}
.broker-kis {
    background-color: #FF6B00;  /* 한투 브랜드 컬러 */
    color: white;
}
.broker-upbit {
    background-color: #093687;  /* 업비트 브랜드 컬러 */
    color: white;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JavaScript 구현&lt;/h3&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;// 보유 종목 렌더링
function renderHoldings() {
    const tbody = document.getElementById('holdings-list');

    tbody.innerHTML = holdings.map(h =&amp;gt; {
        const evaluation = h.quantity * h.avg_price;
        const currency = h.market_type === 'US' ? '$' : '원';

        return `
            &amp;lt;tr&amp;gt;
                &amp;lt;td&amp;gt;
                    &amp;lt;strong&amp;gt;${h.display_name || h.ticker}&amp;lt;/strong&amp;gt;
                    ${h.display_name ? `&amp;lt;br&amp;gt;&amp;lt;small class=&quot;text-muted&quot;&amp;gt;${h.ticker}&amp;lt;/small&amp;gt;` : ''}
                &amp;lt;/td&amp;gt;
                &amp;lt;td&amp;gt;
                    &amp;lt;span class=&quot;badge broker-${h.broker_type}&quot;&amp;gt;
                        ${getBrokerName(h.broker_type)}
                    &amp;lt;/span&amp;gt;
                &amp;lt;/td&amp;gt;
                &amp;lt;td class=&quot;text-end&quot;&amp;gt;${h.quantity.toLocaleString()}&amp;lt;/td&amp;gt;
                &amp;lt;td class=&quot;text-end&quot;&amp;gt;${formatPrice(h.avg_price, h.market_type)}&amp;lt;/td&amp;gt;
                &amp;lt;td class=&quot;text-end&quot;&amp;gt;${formatPrice(evaluation, h.market_type)}&amp;lt;/td&amp;gt;
                &amp;lt;td class=&quot;text-center&quot;&amp;gt;
                    &amp;lt;span class=&quot;badge ${h.market_type === 'KR' ? 'bg-primary' : 'bg-success'}&quot;&amp;gt;
                        ${h.market_type === 'KR' ? '국내' : '해외'}
                    &amp;lt;/span&amp;gt;
                &amp;lt;/td&amp;gt;
                &amp;lt;td class=&quot;text-center&quot;&amp;gt;
                    &amp;lt;button class=&quot;btn btn-sm btn-outline-primary&quot;
                            onclick=&quot;showEditHoldingModal(${h.id})&quot;&amp;gt;
                        &amp;lt;i class=&quot;bi bi-pencil&quot;&amp;gt;&amp;lt;/i&amp;gt;
                    &amp;lt;/button&amp;gt;
                &amp;lt;/td&amp;gt;
            &amp;lt;/tr&amp;gt;
        `;
    }).join('');
}

// 브로커명 변환
function getBrokerName(type) {
    const names = { toss: '토스', kis: '한투', upbit: '업비트' };
    return names[type] || type;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 대시보드 통합&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;KIS 트레이딩 대시보드에 브로커 표시 추가&lt;/h3&gt;
&lt;pre class=&quot;django&quot;&gt;&lt;code&gt;&amp;lt;!-- 종목 행에 브로커 배지 추가 --&amp;gt;
&amp;lt;td&amp;gt;
    &amp;lt;strong&amp;gt;${stock.name}&amp;lt;/strong&amp;gt;
    &amp;lt;div class=&quot;mt-1&quot;&amp;gt;
        &amp;lt;!-- KIS 보유분 --&amp;gt;
        {% if stock.kis_quantity &amp;gt; 0 %}
        &amp;lt;span class=&quot;badge broker-kis me-1&quot;&amp;gt;
            한투 ${stock.kis_quantity}주
        &amp;lt;/span&amp;gt;
        {% endif %}

        &amp;lt;!-- 토스 보유분 --&amp;gt;
        {% if stock.toss_quantity &amp;gt; 0 %}
        &amp;lt;span class=&quot;badge broker-toss&quot;&amp;gt;
            토스 ${stock.toss_quantity}주
        &amp;lt;/span&amp;gt;
        {% endif %}
    &amp;lt;/div&amp;gt;
&amp;lt;/td&amp;gt;

&amp;lt;!-- 통합 평단가 표시 --&amp;gt;
&amp;lt;td class=&quot;text-end&quot;&amp;gt;
    &amp;lt;div&amp;gt;${formatPrice(stock.combined_avg_price)}&amp;lt;/div&amp;gt;
    {% if stock.kis_quantity &amp;gt; 0 and stock.toss_quantity &amp;gt; 0 %}
    &amp;lt;small class=&quot;text-muted&quot;&amp;gt;
        한투: ${formatPrice(stock.kis_avg_price)}
    &amp;lt;/small&amp;gt;
    {% endif %}
&amp;lt;/td&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단위 테스트&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# tests/test_manual_holdings_service.py

@pytest.mark.asyncio
async def test_calculate_combined_avg():
    &quot;&quot;&quot;가중 평균 평단가 계산 테스트&quot;&quot;&quot;
    from app.services.merged_portfolio_service import (
        MergedPortfolioService, HoldingInfo
    )

    holdings = [
        HoldingInfo(broker=&quot;kis&quot;, quantity=50, avg_price=68200),
        HoldingInfo(broker=&quot;toss&quot;, quantity=30, avg_price=71000),
    ]

    result = MergedPortfolioService.calculate_combined_avg(holdings)

    # 기대값: (50*68200 + 30*71000) / 80 = 69,250
    assert result == pytest.approx(69250, rel=1e-2)


@pytest.mark.asyncio
async def test_create_holding(db_session, test_user):
    &quot;&quot;&quot;수동 잔고 생성 테스트&quot;&quot;&quot;
    service = ManualHoldingsService(db_session)

    # 계좌 생성
    account = await service.broker_service.create(test_user.id, {
        &quot;broker_type&quot;: &quot;toss&quot;,
        &quot;account_name&quot;: &quot;토스 계좌&quot;,
    })

    # 보유 종목 등록
    holding = await service.create_holding(
        user_id=test_user.id,
        data={
            &quot;broker_type&quot;: &quot;toss&quot;,
            &quot;account_name&quot;: &quot;토스 계좌&quot;,
            &quot;ticker&quot;: &quot;005930&quot;,
            &quot;market_type&quot;: &quot;KR&quot;,
            &quot;quantity&quot;: 30,
            &quot;avg_price&quot;: 71000,
        }
    )

    assert holding.ticker == &quot;005930&quot;
    assert holding.quantity == 30
    assert holding.avg_price == 71000


@pytest.mark.asyncio
async def test_sell_quantity_validation():
    &quot;&quot;&quot;매도 수량 검증 테스트&quot;&quot;&quot;
    from app.services.trading_price_service import TradingPriceService

    service = TradingPriceService()

    # KIS 50주, 요청 30주 &amp;rarr; OK
    valid, msg = service.validate_sell_quantity(50, 30)
    assert valid is True

    # KIS 50주, 요청 60주 &amp;rarr; 초과
    valid, msg = service.validate_sell_quantity(50, 60)
    assert valid is False
    assert &quot;초과&quot; in msg

    # KIS 0주 &amp;rarr; 매도 불가
    valid, msg = service.validate_sell_quantity(0, 10)
    assert valid is False
    assert &quot;보유분이 없어&quot; in msg&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;통합 테스트&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# tests/test_trading_integration.py

@pytest.mark.asyncio
async def test_merged_portfolio_with_manual_holdings(
    db_session, test_user, mock_kis_client
):
    &quot;&quot;&quot;통합 포트폴리오 테스트 (KIS + 수동 잔고)&quot;&quot;&quot;

    # 1. 수동 잔고 등록
    manual_service = ManualHoldingsService(db_session)
    await manual_service.create_holding(
        user_id=test_user.id,
        data={
            &quot;broker_type&quot;: &quot;toss&quot;,
            &quot;ticker&quot;: &quot;005930&quot;,
            &quot;market_type&quot;: &quot;KR&quot;,
            &quot;quantity&quot;: 30,
            &quot;avg_price&quot;: 71000,
        }
    )

    # 2. 통합 포트폴리오 조회
    portfolio_service = MergedPortfolioService(db_session)
    holdings = await portfolio_service.get_merged_portfolio_domestic(
        test_user.id, mock_kis_client
    )

    # 3. 삼성전자 확인
    samsung = next((h for h in holdings if h.ticker == &quot;005930&quot;), None)
    assert samsung is not None

    # KIS 50주 + 토스 30주 = 80주
    assert samsung.total_quantity == 80
    assert samsung.kis_quantity == 50
    assert samsung.toss_quantity == 30

    # 통합 평단가 계산 확인
    expected_avg = (50 * 68200 + 30 * 71000) / 80
    assert samsung.combined_avg_price == pytest.approx(expected_avg, rel=1e-2)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마이그레이션&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# alembic/versions/xxx_add_manual_holdings.py

def upgrade() -&amp;gt; None:
    # 브로커 타입 Enum 생성
    op.execute(&quot;CREATE TYPE broker_type AS ENUM ('kis', 'toss', 'upbit')&quot;)
    op.execute(&quot;CREATE TYPE market_type AS ENUM ('KR', 'US', 'CRYPTO')&quot;)

    # 브로커 계좌 테이블
    op.create_table(
        'broker_accounts',
        sa.Column('id', sa.BigInteger(), primary_key=True),
        sa.Column('user_id', sa.BigInteger(),
                  sa.ForeignKey('users.id', ondelete='CASCADE')),
        sa.Column('broker_type',
                  postgresql.ENUM('kis', 'toss', 'upbit',
                                  name='broker_type', create_type=False)),
        sa.Column('account_name', sa.Text(), nullable=False,
                  server_default='기본 계좌'),
        sa.Column('is_mock', sa.Boolean(), server_default='false'),
        sa.Column('is_active', sa.Boolean(), server_default='true'),
        sa.Column('created_at', sa.TIMESTAMP(timezone=True),
                  server_default=sa.func.now()),
        sa.Column('updated_at', sa.TIMESTAMP(timezone=True),
                  server_default=sa.func.now()),
        sa.UniqueConstraint('user_id', 'broker_type', 'account_name',
                           name='uq_broker_account'),
    )

    # 수동 보유 종목 테이블
    op.create_table(
        'manual_holdings',
        sa.Column('id', sa.BigInteger(), primary_key=True),
        sa.Column('broker_account_id', sa.BigInteger(),
                  sa.ForeignKey('broker_accounts.id', ondelete='CASCADE')),
        sa.Column('ticker', sa.Text(), nullable=False),
        sa.Column('market_type',
                  postgresql.ENUM('KR', 'US', 'CRYPTO',
                                  name='market_type', create_type=False)),
        sa.Column('quantity', sa.Numeric(18, 8), nullable=False),
        sa.Column('avg_price', sa.Numeric(18, 8), nullable=False),
        sa.Column('display_name', sa.Text(), nullable=True),
        sa.Column('created_at', sa.TIMESTAMP(timezone=True),
                  server_default=sa.func.now()),
        sa.Column('updated_at', sa.TIMESTAMP(timezone=True),
                  server_default=sa.func.now()),
        sa.UniqueConstraint('broker_account_id', 'ticker', 'market_type',
                           name='uq_holding_ticker'),
    )

    # 인덱스 생성
    op.create_index('ix_broker_accounts_user_id', 'broker_accounts', ['user_id'])
    op.create_index('ix_manual_holdings_ticker', 'manual_holdings', ['ticker'])


def downgrade() -&amp;gt; None:
    op.drop_table('manual_holdings')
    op.drop_table('broker_accounts')
    op.execute('DROP TYPE market_type')
    op.execute('DROP TYPE broker_type')&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 사용 예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 토스 잔고 등록&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 1. 수동 잔고 관리 페이지 접속
https://your-domain.com/manual-holdings/

# 2. 토스증권 계좌 추가
# - 브로커: 토스증권
# - 계좌 이름: 토스 메인

# 3. 보유 종목 등록
# - 종목코드: 005930
# - 종목명: 삼성전자
# - 수량: 30주
# - 평단가: 71,000원&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 통합 포트폴리오 확인&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 국내주식 트레이딩 페이지 접속
https://your-domain.com/kis-domestic-trading/

# 삼성전자 확인:
# - 한투: 50주 @ 68,200원
# - 토스: 30주 @ 71,000원 (뱃지 표시)
# - 통합 평단가: 69,250원
# - 총 보유: 80주&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 토스 기준 매수 전략&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;매수 전략: &quot;최저 평단가 -3%&quot;

참조 평단가:
- 한투: 68,200원
- 토스: 71,000원
- 최저: 68,200원 (한투)

계산된 매수가: 68,200 &amp;times; 0.97 = 66,154원
&amp;rarr; 한투에서 66,154원에 지정가 매수 주문&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. Telegram 알림&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;  **국내주식 AI 분석 완료**

종목: 삼성전자 (005930)
결정: BUY
신뢰도: 75%

  **토스 보유 알림**
토스 보유: 30주 @ 71,000원
한투 보유: 50주 @ 68,200원
통합 평단가: 69,250원

  추천 매수가: 66,154원 (최저 평단가 -3%)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배운 교훈&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;여러 브로커를 통합하면 투자 판단이 달라진다&quot;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;그냥 한투 잔고만 보면 되지&quot;라고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제로는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;평단가가 다름&lt;/b&gt;: 토스에서 고점에 샀다면 평단가가 높음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;물타기 전략 변화&lt;/b&gt;: 통합 평단가 기준으로 물타기해야 정확함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;매도 타이밍 달라짐&lt;/b&gt;: 토스 평단가 기준으로는 이미 수익권&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시스템의 강점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 유연한 수동 등록:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토스, 한투, 업비트 등 다양한 브로커 지원&lt;/li&gt;
&lt;li&gt;종목별 별칭 관리로 편리한 입력&lt;/li&gt;
&lt;li&gt;대량 등록 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 정확한 통합 분석:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가중 평균으로 정확한 평단가 계산&lt;/li&gt;
&lt;li&gt;브로커별 보유 현황 한눈에 확인&lt;/li&gt;
&lt;li&gt;AI 분석에 통합 정보 반영&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 다양한 가격 전략:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재가, 평단가 기반 등 8가지 전략&lt;/li&gt;
&lt;li&gt;시뮬레이션 모드로 안전한 테스트&lt;/li&gt;
&lt;li&gt;브로커별 예상 수익 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 실시간 알림:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토스 보유 종목 분석 시 자동 알림&lt;/li&gt;
&lt;li&gt;매수/매도 추천 및 예상 수익 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 우리의 자동매매 시스템은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ 데이터 수집 (한투/Upbit/yfinance)&lt;/li&gt;
&lt;li&gt;✅ AI 분석 (Gemini)&lt;/li&gt;
&lt;li&gt;✅ DB 저장 및 정규화&lt;/li&gt;
&lt;li&gt;✅ 암호화폐 웹 대시보드&lt;/li&gt;
&lt;li&gt;✅ 모니터링 (Grafana Stack)&lt;/li&gt;
&lt;li&gt;✅ 프로덕션 배포 (HTTPS + 24시간)&lt;/li&gt;
&lt;li&gt;✅ JWT 인증 + RBAC&lt;/li&gt;
&lt;li&gt;✅ 국내/해외 주식 자동 매매&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;다중 브로커 통합 포트폴리오&lt;/b&gt; &amp;larr; 완성!&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;추가로 고려할 수 있는 기능:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토스 잔고 자동 동기화 (스크래핑)&lt;/li&gt;
&lt;li&gt;포트폴리오 리밸런싱 제안&lt;/li&gt;
&lt;li&gt;세금 계산 (양도소득세)&lt;/li&gt;
&lt;li&gt;백테스팅 시스템&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://apiportal.koreainvestment.com/&quot;&gt;한국투자증권 OpenAPI 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/&quot;&gt;FastAPI 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.sqlalchemy.org/&quot;&gt;SQLAlchemy 2.0 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/mgh3326/auto_trader&quot;&gt;전체 프로젝트 코드 (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/mgh3326/auto_trader/pull/86&quot;&gt;PR #86: 외부 브로커 수동 잔고 등록 및 통합 포트폴리오 기능 구현&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Programming/Python</category>
      <category>FastAPI</category>
      <category>sqlalchemy</category>
      <category>가격전략</category>
      <category>다중브로커통합</category>
      <category>수동잔고관리</category>
      <category>자동매매시스템</category>
      <category>토스증권</category>
      <category>통합포트폴리오</category>
      <category>투자대시보드</category>
      <category>한투API</category>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/238</guid>
      <comments>https://mgh3326.tistory.com/238#entry238comment</comments>
      <pubDate>Mon, 1 Dec 2025 20:51:58 +0900</pubDate>
    </item>
    <item>
      <title>KIS 국내/해외 주식 자동 매매 시스템 구축하기: Celery + AI 분석 기반 스마트 트레이딩</title>
      <link>https://mgh3326.tistory.com/237</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0CoN3/dJMcahXbE5P/ptV2xUyNPpl0rmcioV1Z21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0CoN3/dJMcahXbE5P/ptV2xUyNPpl0rmcioV1Z21/img.png&quot; data-alt=&quot;KIS 자동 매매 시스템&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0CoN3/dJMcahXbE5P/ptV2xUyNPpl0rmcioV1Z21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0CoN3%2FdJMcahXbE5P%2FptV2xUyNPpl0rmcioV1Z21%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;378&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;KIS 자동 매매 시스템&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 AI 기반 자동매매 시스템 시리즈의 &lt;b&gt;9편&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;전체 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/227&quot;&gt;1편: 한투 API로 실시간 주식 데이터 수집하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/228&quot;&gt;2편: yfinance로 애플&amp;middot;테슬라 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/229&quot;&gt;3편: Upbit으로 비트코인 24시간 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/230&quot;&gt;4편: AI 분석 결과 DB에 저장하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/232&quot;&gt;5편: Upbit 웹 트레이딩 대시보드 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/233&quot;&gt;6편: 실전 운영을 위한 모니터링 시스템 구축&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/234&quot;&gt;7편: 라즈베리파이 홈서버에 자동 HTTPS로 안전하게 배포하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/235&quot;&gt;8편: JWT 인증 시스템으로 안전한 웹 애플리케이션 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;9편: KIS 국내/해외 주식 자동 매매 시스템 구축하기&lt;/b&gt; &amp;larr; 현재 글&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;지금까지의 여정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 지금까지:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ 한투/yfinance/Upbit API로 데이터 수집&lt;/li&gt;
&lt;li&gt;✅ AI 분석 자동화 (Gemini)&lt;/li&gt;
&lt;li&gt;✅ DB 저장 및 정규화&lt;/li&gt;
&lt;li&gt;✅ 웹 대시보드 구축 (암호화폐)&lt;/li&gt;
&lt;li&gt;✅ Grafana 관찰성 스택으로 모니터링&lt;/li&gt;
&lt;li&gt;✅ 라즈베리파이에 HTTPS 배포&lt;/li&gt;
&lt;li&gt;✅ JWT 인증 + RBAC&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;까지 완성했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MOHgw/dJMcad1uFaM/CQeDFhbCs6Tn1wf87b9w51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MOHgw/dJMcad1uFaM/CQeDFhbCs6Tn1wf87b9w51/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MOHgw/dJMcad1uFaM/CQeDFhbCs6Tn1wf87b9w51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMOHgw%2FdJMcad1uFaM%2FCQeDFhbCs6Tn1wf87b9w51%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;600&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;새로운 과제: 주식 자동 매매&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;b&gt;암호화폐 자동 매매 시스템&lt;/b&gt;을 &lt;b&gt;주식&lt;/b&gt;으로 확장한다.!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;현재 상태:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# Upbit 암호화폐: 자동 매매 가능 ✅
https://your-domain.com/upbit-trading/

# KIS 주식: 조회만 가능 ❌
# 매수/매도 자동화 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목표:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# 국내 주식 자동 매매 대시보드
https://your-domain.com/kis-domestic-trading/

# 해외 주식 자동 매매 대시보드
https://your-domain.com/kis-overseas-trading/&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 주식 자동 매매인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;암호화폐 vs 주식 매매의 차이:&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;암호화폐 (Upbit)&lt;/th&gt;
&lt;th&gt;주식 (KIS)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;거래 시간&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;24/7&lt;/td&gt;
&lt;td&gt;국내 9:00-15:30, 해외 23:30-06:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;최소 거래 단위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;금액 기반 (5,000원~)&lt;/td&gt;
&lt;td&gt;주 단위 (1주~)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;주문 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;즉시 체결 위주&lt;/td&gt;
&lt;td&gt;지정가 주문 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;API 복잡도&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단순&lt;/td&gt;
&lt;td&gt;매우 복잡 (국내/해외 별도)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;시장 특성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;변동성 높음&lt;/td&gt;
&lt;td&gt;상대적 안정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주식 자동 매매의 핵심 과제:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;분할 매수/매도&lt;/b&gt;: 목표 가격대에 분산 주문&lt;/li&gt;
&lt;li&gt;&lt;b&gt;종목별 설정&lt;/b&gt;: 종목마다 다른 매수 수량/금액&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비동기 처리&lt;/b&gt;: 여러 종목 동시 분석 및 주문&lt;/li&gt;
&lt;li&gt;&lt;b&gt;진행 상황 추적&lt;/b&gt;: 긴 작업의 실시간 모니터링&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시스템 아키텍처&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;463&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/clxNIq/dJMcaaqdxdg/6g2OrlzUZ4KkgFYKWYo3Xk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/clxNIq/dJMcaaqdxdg/6g2OrlzUZ4KkgFYKWYo3Xk/img.png&quot; data-alt=&quot;KIS 자동 매매 아키텍처&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/clxNIq/dJMcaaqdxdg/6g2OrlzUZ4KkgFYKWYo3Xk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FclxNIq%2FdJMcaaqdxdg%2F6g2OrlzUZ4KkgFYKWYo3Xk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;463&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;463&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;KIS 자동 매매 아키텍처&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;Celery 기반 비동기 자동 매매 시스템 구조&lt;/i&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전체 구조&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;사용자
  &amp;darr;
웹 대시보드 (FastAPI + Jinja2)
  ├─ /kis-domestic-trading/   (국내주식)
  └─ /kis-overseas-trading/   (해외주식)
  &amp;darr;
FastAPI 라우터
  ├─ 보유 주식 조회 API
  ├─ AI 분석 요청 API
  ├─ 매수/매도 주문 API
  └─ 종목별 설정 API
  &amp;darr;
Celery 비동기 태스크
  ├─ 전체 종목 분석 태스크
  ├─ 전체 종목 매수 태스크
  ├─ 전체 종목 매도 태스크
  ├─ 종목별 자동 실행 태스크
  │   (분석 &amp;rarr; 매수 &amp;rarr; 매도)
  └─ 개별 종목 태스크
      ├─ 분석
      ├─ 매수
      └─ 매도
  &amp;darr;
KIS API + AI 분석
  ├─ KISClient (한국투자증권 API)
  ├─ KISAnalyzer (국내주식 분석)
  └─ YahooAnalyzer (해외주식 분석)
  &amp;darr;
PostgreSQL + Redis
  ├─ StockAnalysisResult (분석 결과)
  └─ SymbolTradeSettings (종목별 설정)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 컴포넌트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 웹 대시보드:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;보유 주식 현황 표시&lt;/li&gt;
&lt;li&gt;예수금/평가금액 표시&lt;/li&gt;
&lt;li&gt;자동 매매 버튼 (분석/매수/매도)&lt;/li&gt;
&lt;li&gt;진행 상황 실시간 표시&lt;/li&gt;
&lt;li&gt;종목별 설정 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Celery 태스크:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;백그라운드에서 오래 걸리는 작업 처리&lt;/li&gt;
&lt;li&gt;진행 상황 실시간 업데이트&lt;/li&gt;
&lt;li&gt;작업 실패 시 자동 재시도&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 종목별 설정:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;매수 수량 설정&lt;/li&gt;
&lt;li&gt;주문할 가격대 수 설정 (1~4개)&lt;/li&gt;
&lt;li&gt;활성화/비활성화&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;데이터베이스 설계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;종목별 거래 설정 테이블&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/me0DL/dJMcabQbZwB/EdPjvchX3PdKQKL2NLsVi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/me0DL/dJMcabQbZwB/EdPjvchX3PdKQKL2NLsVi0/img.png&quot; data-alt=&quot;종목 설정 ERD&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/me0DL/dJMcabQbZwB/EdPjvchX3PdKQKL2NLsVi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fme0DL%2FdJMcabQbZwB%2FEdPjvchX3PdKQKL2NLsVi0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;360&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;종목 설정 ERD&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;종목별 거래 설정 ERD&lt;/i&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# app/models/symbol_trade_settings.py

class SymbolTradeSettings(Base):
    &quot;&quot;&quot;종목별 거래 설정 테이블&quot;&quot;&quot;

    __tablename__ = &quot;symbol_trade_settings&quot;
    __table_args__ = (
        UniqueConstraint(&quot;user_id&quot;, &quot;symbol&quot;, name=&quot;uq_user_symbol&quot;),
    )

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    user_id: Mapped[int] = mapped_column(
        ForeignKey(&quot;users.id&quot;, ondelete=&quot;CASCADE&quot;),
        nullable=False,
        index=True
    )
    symbol: Mapped[str] = mapped_column(Text, nullable=False, index=True)
    instrument_type: Mapped[InstrumentType] = mapped_column(
        Enum(InstrumentType), nullable=False
    )

    # 핵심 설정: 주문당 매수 수량
    buy_quantity_per_order: Mapped[float] = mapped_column(
        Numeric(18, 8), nullable=False
    )

    # 주문할 가격대 수 (1~4)
    # 1: appropriate_buy_min만
    # 2: appropriate_buy_min, appropriate_buy_max
    # 3: + buy_hope_min
    # 4: 전체 4개 가격대 (기본값)
    buy_price_levels: Mapped[int] = mapped_column(default=4, nullable=False)

    # 해외주식 거래소 코드 (NASD, NYSE 등)
    exchange_code: Mapped[str | None] = mapped_column(Text, nullable=True)

    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    note: Mapped[str | None] = mapped_column(Text, nullable=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설계 포인트:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;사용자별 종목 설정&lt;/b&gt;: &lt;code&gt;user_id + symbol&lt;/code&gt; 유니크 제약&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유연한 가격대 설정&lt;/b&gt;: &lt;code&gt;buy_price_levels&lt;/code&gt;로 1~4개 가격대 선택&lt;/li&gt;
&lt;li&gt;&lt;b&gt;거래소 구분&lt;/b&gt;: 해외주식은 &lt;code&gt;exchange_code&lt;/code&gt;로 NASDAQ/NYSE 구분&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용자 기본 설정 테이블&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;class UserTradeDefaults(Base):
    &quot;&quot;&quot;사용자별 기본 거래 설정&quot;&quot;&quot;

    __tablename__ = &quot;user_trade_defaults&quot;

    user_id: Mapped[int] = mapped_column(
        ForeignKey(&quot;users.id&quot;), unique=True, index=True
    )

    # 코인 기본 매수 금액 (KRW)
    crypto_default_buy_amount: Mapped[float] = mapped_column(
        Numeric(18, 2), default=10000
    )n    const data = await response.json();

    if (data.success) {
        // 진행 상황 폴링 시작
        pollTaskStatus(data.task_id, 'analyze');
    }
}

// 진행 상황 폴링
async function pollTaskStatus(taskId, type) {
    const progressBar = document.getElementById(`${type}-progress-bar`);
    const statusText = document.getElementById(`${type}-status`);

    const poll = async () =&amp;gt; {
        const response = await fetch(`/kis-domestic-trading/api/analyze-task/${taskId}`);
        const result = await response.json();

        if (result.state === 'PROGRESS') {
            // 진행 상황 업데이트
            const progress = result.progress;
            progressBar.style.width = `${progress.percentage}%`;
            progressBar.textContent = `${progress.percentage}%`;
            statusText.textContent = progress.status;

            // 계속 폴링
            setTimeout(poll, 1000);
        } else if (result.state === 'SUCCESS') {
            // 완료
            progressBar.style.width = '100%';
            statusText.textContent = result.result.message;
            showAlert('success', '분석이 완료되었습니다.');
            loadMyStocks();  // 데이터 새로고침
        } else if (result.state === 'FAILURE') {
            // 실패
            showAlert('danger', `오류: ${result.error}`);
        }
    };

    poll();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LTZ2U/dJMcahCR6oU/kafTKhXC0jH4XYf0DX1ntk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LTZ2U/dJMcahCR6oU/kafTKhXC0jH4XYf0DX1ntk/img.png&quot; data-alt=&quot;진행 상황 표시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LTZ2U/dJMcahCR6oU/kafTKhXC0jH4XYf0DX1ntk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLTZ2U%2FdJMcahCR6oU%2FkafTKhXC0jH4XYf0DX1ntk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;240&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;240&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;진행 상황 표시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;실시간 진행 상황 표시 UI&lt;/i&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;매수/매도 로직 구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI 분석 기반 분할 매수&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cXzMvm/dJMcachgkZw/tuo9zXaRFGGmPkLkzKK83K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cXzMvm/dJMcachgkZw/tuo9zXaRFGGmPkLkzKK83K/img.png&quot; data-alt=&quot;매수 로직 플로우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cXzMvm/dJMcachgkZw/tuo9zXaRFGGmPkLkzKK83K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcXzMvm%2FdJMcachgkZw%2Ftuo9zXaRFGGmPkLkzKK83K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;420&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;매수 로직 플로우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;AI 분석 기반 분할 매수 플로우&lt;/i&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/services/kis_trading_service.py

async def process_kis_domestic_buy_orders_with_analysis(
    kis_client: KISClient,
    symbol: str,
    current_price: float,
    avg_buy_price: float
) -&amp;gt; Dict[str, Any]:
    &quot;&quot;&quot;분석 결과 기반 국내 주식 분할 매수&quot;&quot;&quot;

    # 1. 기본 조건: 현재가가 평균 매수가보다 1% 낮아야 함
    if avg_buy_price &amp;gt; 0:
        target_price = avg_buy_price * 0.99
        if current_price &amp;gt;= target_price:
            return {
                'success': False,
                'message': f&quot;1% 매수 조건 미충족: 현재가 {current_price} &amp;gt;= 목표가 {target_price}&quot;
            }

    # 2. DB에서 최신 AI 분석 결과 조회
    analysis = await service.get_latest_analysis_by_symbol(symbol)
    if not analysis:
        return {'success': False, 'message': &quot;분석 결과 없음&quot;}

    # 3. 종목 설정 확인
    settings = await settings_service.get_by_symbol(symbol)
    if not settings or not settings.is_active:
        return {'success': False, 'message': &quot;종목 설정 없음 - 매수 건너뜀&quot;}

    # 4. AI가 제안한 매수 가격대 추출
    all_buy_prices = []
    if analysis.appropriate_buy_min:
        all_buy_prices.append((&quot;적정매수(하한)&quot;, analysis.appropriate_buy_min))
    if analysis.appropriate_buy_max:
        all_buy_prices.append((&quot;적정매수(상한)&quot;, analysis.appropriate_buy_max))
    if analysis.buy_hope_min:
        all_buy_prices.append((&quot;희망매수(하한)&quot;, analysis.buy_hope_min))
    if analysis.buy_hope_max:
        all_buy_prices.append((&quot;희망매수(상한)&quot;, analysis.buy_hope_max))

    # 5. 설정된 가격대 수만큼 선택 (1~4개)
    buy_prices = all_buy_prices[:settings.buy_price_levels]

    # 6. 조건에 맞는 가격 필터링
    # - 평균 매수가의 99%보다 낮고
    # - 현재가보다 낮은 가격
    threshold_price = avg_buy_price * 0.99 if avg_buy_price &amp;gt; 0 else float('inf')
    valid_prices = [
        (name, price) for name, price in buy_prices
        if price &amp;lt; threshold_price and price &amp;lt; current_price
    ]

    if not valid_prices:
        return {'success': False, 'message': &quot;조건에 맞는 매수 가격 없음&quot;}

    # 7. 분할 매수 주문 실행
    quantity = int(settings.buy_quantity_per_order)
    success_count = 0

    for name, price in valid_prices:
        result = await kis_client.order_korea_stock(
            symbol=symbol,
            order_type=&quot;buy&quot;,
            quantity=quantity,
            price=int(price)
        )

        if result and result.get('rt_cd') == '0':
            success_count += 1

        await asyncio.sleep(0.2)  # API 호출 간격

    return {
        'success': success_count &amp;gt; 0,
        'message': f&quot;{success_count}개 주문 성공 (설정: {settings.buy_price_levels}개 가격대)&quot;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;매수 가격대 설정 이해&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 분석 결과에서 4가지 매수 가격대를 제공한다.:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;가격대&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;우선순위&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appropriate_buy_min&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;적정 매수가 하한&lt;/td&gt;
&lt;td&gt;1 (최우선)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appropriate_buy_max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;적정 매수가 상한&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;buy_hope_min&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;희망 매수가 하한&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;buy_hope_max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;희망 매수가 상한&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;buy_price_levels&lt;/code&gt; 설정 예시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;buy_price_levels = 2인 경우:
&amp;rarr; appropriate_buy_min, appropriate_buy_max 두 가격에만 주문

buy_price_levels = 4인 경우 (기본값):
&amp;rarr; 4개 가격대 모두에 주문&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI 분석 기반 분할 매도&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def process_kis_domestic_sell_orders_with_analysis(
    kis_client: KISClient,
    symbol: str,
    current_price: float,
    avg_buy_price: float,
    balance_qty: int
) -&amp;gt; Dict[str, Any]:
    &quot;&quot;&quot;분석 결과 기반 국내 주식 분할 매도&quot;&quot;&quot;

    # 1. AI 분석 결과 조회
    analysis = await service.get_latest_analysis_by_symbol(symbol)
    if not analysis:
        return {'success': False, 'message': &quot;분석 결과 없음&quot;}

    # 2. AI가 제안한 매도 가격대 추출
    sell_prices = []
    if analysis.appropriate_sell_min:
        sell_prices.append(analysis.appropriate_sell_min)
    if analysis.appropriate_sell_max:
        sell_prices.append(analysis.appropriate_sell_max)
    if analysis.sell_target_min:
        sell_prices.append(analysis.sell_target_min)
    if analysis.sell_target_max:
        sell_prices.append(analysis.sell_target_max)

    # 3. 매도 조건 필터링
    # - 평균 매수가의 101% 이상 (최소 1% 수익)
    # - 현재가 이상
    min_sell_price = avg_buy_price * 1.01
    valid_prices = [
        p for p in sell_prices
        if p &amp;gt;= min_sell_price and p &amp;gt;= current_price
    ]
    valid_prices.sort()

    # 4. 조건 미충족 시 현재가로 전량 매도 시도
    if not valid_prices:
        if current_price &amp;gt;= min_sell_price:
            # 이미 목표 수익 달성 &amp;rarr; 전량 매도
            result = await kis_client.order_korea_stock(
                symbol=symbol,
                order_type=&quot;sell&quot;,
                quantity=balance_qty,
                price=int(current_price)
            )
            if result and result.get('rt_cd') == '0':
                return {'success': True, 'message': &quot;목표가 도달로 전량 매도&quot;}
        return {'success': False, 'message': &quot;매도 조건 미충족&quot;}

    # 5. 분할 매도 주문 실행
    split_count = len(valid_prices)
    qty_per_order = balance_qty // split_count

    success_count = 0
    remaining_qty = balance_qty

    for i, price in enumerate(valid_prices):
        is_last = (i == len(valid_prices) - 1)
        qty = remaining_qty if is_last else qty_per_order

        if qty &amp;lt; 1:
            continue

        result = await kis_client.order_korea_stock(
            symbol=symbol,
            order_type=&quot;sell&quot;,
            quantity=qty,
            price=int(price)
        )

        if result and result.get('rt_cd') == '0':
            success_count += 1
            remaining_qty -= qty

        await asyncio.sleep(0.2)

    return {
        'success': success_count &amp;gt; 0,
        'message': f&quot;{success_count}건 분할 매도 주문 완료&quot;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;종목별 자동 실행 (All-in-One)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;분석 &amp;rarr; 매수 &amp;rarr; 매도 순차 실행&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@shared_task(name=&quot;kis.run_per_domestic_stock_automation&quot;, bind=True)
def run_per_domestic_stock_automation(self) -&amp;gt; dict:
    &quot;&quot;&quot;국내 주식 종목별 자동 실행 (분석 &amp;rarr; 매수 &amp;rarr; 매도)&quot;&quot;&quot;

    async def _run() -&amp;gt; dict:
        kis = KISClient()
        analyzer = KISAnalyzer()

        my_stocks = await kis.fetch_my_stocks()
        results = []

        for index, stock in enumerate(my_stocks, 1):
            code = stock.get('pdno')
            name = stock.get('prdt_name')

            stock_steps = []

            # 1단계: AI 분석
            self.update_state(
                state='PROGRESS',
                meta={'status': f'{name} 분석 중...'}
            )
            try:
                await analyzer.analyze_stock_json(name)
                stock_steps.append({
                    'step': '분석',
                    'result': {'success': True}
                })
            except Exception as e:
                stock_steps.append({
                    'step': '분석',
                    'result': {'success': False, 'error': str(e)}
                })
                # 분석 실패 시 매수/매도 건너뜀
                results.append({'name': name, 'steps': stock_steps})
                continue

            # 2단계: 매수 주문
            self.update_state(
                state='PROGRESS',
                meta={'status': f'{name} 매수 주문 중...'}
            )
            try:
                buy_result = await process_kis_domestic_buy_orders_with_analysis(
                    kis, code, current_price, avg_price
                )
                stock_steps.append({'step': '매수', 'result': buy_result})
            except Exception as e:
                stock_steps.append({
                    'step': '매수',
                    'result': {'success': False, 'error': str(e)}
                })

            # 매수 후 잔고/평단가 최신화
            latest_holdings = await kis.fetch_my_stocks()
            latest = next((s for s in latest_holdings if s.get('pdno') == code), None)
            if latest:
                refreshed_qty = int(latest.get('hldg_qty'))
                refreshed_avg_price = float(latest.get('pchs_avg_pric'))
                refreshed_current_price = float(latest.get('prpr'))

            # 3단계: 매도 주문
            self.update_state(
                state='PROGRESS',
                meta={'status': f'{name} 매도 주문 중...'}
            )
            try:
                sell_result = await process_kis_domestic_sell_orders_with_analysis(
                    kis, code, refreshed_current_price,
                    refreshed_avg_price, refreshed_qty
                )
                stock_steps.append({'step': '매도', 'result': sell_result})
            except Exception as e:
                stock_steps.append({
                    'step': '매도',
                    'result': {'success': False, 'error': str(e)}
                })

            results.append({'name': name, 'code': code, 'steps': stock_steps})

        return {
            'status': 'completed',
            'message': '종목별 자동 실행 완료',
            'results': results
        }

    return asyncio.run(_run())&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 포인트:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;순차 실행&lt;/b&gt;: 분석 &amp;rarr; 매수 &amp;rarr; 매도 순서 보장&lt;/li&gt;
&lt;li&gt;&lt;b&gt;잔고 최신화&lt;/b&gt;: 매수 후 평단가/수량 재조회하여 매도에 반영&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단계별 결과&lt;/b&gt;: 각 단계의 성공/실패 기록&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;웹 대시보드 구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;국내주식 대시보드 UI&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3Rgn0/dJMcai2N6dE/kxvMvUiSKKIuE0SyT6Qok1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3Rgn0/dJMcai2N6dE/kxvMvUiSKKIuE0SyT6Qok1/img.png&quot; data-alt=&quot;국내주식 대시보드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3Rgn0/dJMcai2N6dE/kxvMvUiSKKIuE0SyT6Qok1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3Rgn0%2FdJMcai2N6dE%2FkxvMvUiSKKIuE0SyT6Qok1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;412&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;412&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;국내주식 대시보드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;KIS 국내주식 자동 매매 대시보드&lt;/i&gt;&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- app/templates/kis_domestic_trading_dashboard.html --&amp;gt;

&amp;lt;div class=&quot;container py-4&quot;&amp;gt;
    &amp;lt;!-- 자산 요약 --&amp;gt;
    &amp;lt;div class=&quot;card&quot;&amp;gt;
        &amp;lt;div class=&quot;card-body&quot;&amp;gt;
            &amp;lt;div class=&quot;row text-center&quot;&amp;gt;
                &amp;lt;div class=&quot;col-md-4&quot;&amp;gt;
                    &amp;lt;h5 class=&quot;text-muted&quot;&amp;gt;예수금&amp;lt;/h5&amp;gt;
                    &amp;lt;h3 id=&quot;krw-balance&quot; class=&quot;fw-bold&quot;&amp;gt;- 원&amp;lt;/h3&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;div class=&quot;col-md-4&quot;&amp;gt;
                    &amp;lt;h5 class=&quot;text-muted&quot;&amp;gt;보유 종목 수&amp;lt;/h5&amp;gt;
                    &amp;lt;h3 id=&quot;stock-count&quot; class=&quot;fw-bold&quot;&amp;gt;- 개&amp;lt;/h3&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;div class=&quot;col-md-4&quot;&amp;gt;
                    &amp;lt;h5 class=&quot;text-muted&quot;&amp;gt;총 평가 금액&amp;lt;/h5&amp;gt;
                    &amp;lt;h3 id=&quot;total-evaluation&quot; class=&quot;fw-bold&quot;&amp;gt;- 원&amp;lt;/h3&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;!-- 자동 매매 제어 --&amp;gt;
    &amp;lt;div class=&quot;card&quot;&amp;gt;
        &amp;lt;div class=&quot;card-header&quot;&amp;gt;
            &amp;lt;i class=&quot;bi bi-robot&quot;&amp;gt;&amp;lt;/i&amp;gt; 자동 매매 제어
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;card-body&quot;&amp;gt;
            &amp;lt;div class=&quot;row g-2&quot;&amp;gt;
                &amp;lt;div class=&quot;col-md-3&quot;&amp;gt;
                    &amp;lt;button class=&quot;btn btn-primary btn-action&quot; onclick=&quot;analyzeStocks()&quot;&amp;gt;
                        &amp;lt;i class=&quot;bi bi-search&quot;&amp;gt;&amp;lt;/i&amp;gt; 전체 종목 AI 분석
                    &amp;lt;/button&amp;gt;
                    &amp;lt;div id=&quot;analyze-progress&quot; class=&quot;progress mt-2&quot; style=&quot;display: none;&quot;&amp;gt;
                        &amp;lt;div id=&quot;analyze-progress-bar&quot; class=&quot;progress-bar&quot; style=&quot;width: 0%&quot;&amp;gt;0%&amp;lt;/div&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;div class=&quot;col-md-3&quot;&amp;gt;
                    &amp;lt;button class=&quot;btn btn-success btn-action&quot; onclick=&quot;executeBuyOrders()&quot;&amp;gt;
                        &amp;lt;i class=&quot;bi bi-cart-plus&quot;&amp;gt;&amp;lt;/i&amp;gt; 자동 매수 주문
                    &amp;lt;/button&amp;gt;
                    &amp;lt;div id=&quot;total-estimated-cost&quot; class=&quot;mt-2 small text-success&quot;&amp;gt;
                        &amp;lt;i class=&quot;bi bi-calculator&quot;&amp;gt;&amp;lt;/i&amp;gt;
                        &amp;lt;span id=&quot;estimated-cost-text&quot;&amp;gt;&amp;lt;/span&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;div class=&quot;col-md-3&quot;&amp;gt;
                    &amp;lt;button class=&quot;btn btn-danger btn-action&quot; onclick=&quot;executeSellOrders()&quot;&amp;gt;
                        &amp;lt;i class=&quot;bi bi-cart-dash&quot;&amp;gt;&amp;lt;/i&amp;gt; 자동 매도 주문
                    &amp;lt;/button&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;div class=&quot;col-md-3&quot;&amp;gt;
                    &amp;lt;button class=&quot;btn btn-warning btn-action&quot; onclick=&quot;runPerStockAutomation()&quot;&amp;gt;
                        &amp;lt;i class=&quot;bi bi-collection-play&quot;&amp;gt;&amp;lt;/i&amp;gt; 종목별 분석&amp;rarr;매수&amp;rarr;매도
                    &amp;lt;/button&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;!-- 보유 종목 목록 --&amp;gt;
    &amp;lt;div class=&quot;card&quot;&amp;gt;
        &amp;lt;div class=&quot;card-header d-flex justify-content-between&quot;&amp;gt;
            &amp;lt;span&amp;gt;&amp;lt;i class=&quot;bi bi-list-ul&quot;&amp;gt;&amp;lt;/i&amp;gt; 보유 종목&amp;lt;/span&amp;gt;
            &amp;lt;button class=&quot;btn btn-sm btn-outline-primary&quot; onclick=&quot;loadMyStocks()&quot;&amp;gt;
                &amp;lt;i class=&quot;bi bi-arrow-clockwise&quot;&amp;gt;&amp;lt;/i&amp;gt; 새로고침
            &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;card-body&quot;&amp;gt;
            &amp;lt;div class=&quot;table-responsive&quot;&amp;gt;
                &amp;lt;table class=&quot;table table-hover&quot;&amp;gt;
                    &amp;lt;thead&amp;gt;
                        &amp;lt;tr&amp;gt;
                            &amp;lt;th&amp;gt;종목명&amp;lt;/th&amp;gt;
                            &amp;lt;th class=&quot;text-end&quot;&amp;gt;수량&amp;lt;/th&amp;gt;
                            &amp;lt;th class=&quot;text-end&quot;&amp;gt;현재가&amp;lt;/th&amp;gt;
                            &amp;lt;th class=&quot;text-end&quot;&amp;gt;평균 매수가&amp;lt;/th&amp;gt;
                            &amp;lt;th class=&quot;text-end&quot;&amp;gt;수익률&amp;lt;/th&amp;gt;
                            &amp;lt;th class=&quot;text-center&quot;&amp;gt;AI 분석&amp;lt;/th&amp;gt;
                            &amp;lt;th class=&quot;text-center&quot;&amp;gt;설정&amp;lt;/th&amp;gt;
                            &amp;lt;th class=&quot;text-center&quot;&amp;gt;액션&amp;lt;/th&amp;gt;
                        &amp;lt;/tr&amp;gt;
                    &amp;lt;/thead&amp;gt;
                    &amp;lt;tbody id=&quot;stocks-table-body&quot;&amp;gt;
                        &amp;lt;!-- JavaScript로 동적 생성 --&amp;gt;
                    &amp;lt;/tbody&amp;gt;
                &amp;lt;/table&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;종목별 설정 모달&lt;/h3&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- 종목 설정 모달 --&amp;gt;
&amp;lt;div class=&quot;modal fade&quot; id=&quot;settingsModal&quot; tabindex=&quot;-1&quot;&amp;gt;
    &amp;lt;div class=&quot;modal-dialog&quot;&amp;gt;
        &amp;lt;div class=&quot;modal-content&quot;&amp;gt;
            &amp;lt;div class=&quot;modal-header&quot;&amp;gt;
                &amp;lt;h5 class=&quot;modal-title&quot;&amp;gt;
                    &amp;lt;i class=&quot;bi bi-gear&quot;&amp;gt;&amp;lt;/i&amp;gt; &amp;lt;span id=&quot;settings-stock-name&quot;&amp;gt;&amp;lt;/span&amp;gt; 설정
                &amp;lt;/h5&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div class=&quot;modal-body&quot;&amp;gt;
                &amp;lt;form id=&quot;settings-form&quot;&amp;gt;
                    &amp;lt;input type=&quot;hidden&quot; id=&quot;settings-symbol&quot;&amp;gt;

                    &amp;lt;div class=&quot;mb-3&quot;&amp;gt;
                        &amp;lt;label class=&quot;form-label&quot;&amp;gt;주문당 매수 수량 (주)&amp;lt;/label&amp;gt;
                        &amp;lt;input type=&quot;number&quot; class=&quot;form-control&quot;
                               id=&quot;settings-quantity&quot; min=&quot;1&quot; step=&quot;1&quot; required&amp;gt;
                        &amp;lt;small class=&quot;text-muted&quot;&amp;gt;
                            각 가격대에 이 수량만큼 주문한다.
                        &amp;lt;/small&amp;gt;
                    &amp;lt;/div&amp;gt;

                    &amp;lt;div class=&quot;mb-3&quot;&amp;gt;
                        &amp;lt;label class=&quot;form-label&quot;&amp;gt;주문할 가격대 수&amp;lt;/label&amp;gt;
                        &amp;lt;select class=&quot;form-select&quot; id=&quot;settings-price-levels&quot;&amp;gt;
                            &amp;lt;option value=&quot;1&quot;&amp;gt;1개 (적정매수 하한만)&amp;lt;/option&amp;gt;
                            &amp;lt;option value=&quot;2&quot;&amp;gt;2개 (적정매수 하한/상한)&amp;lt;/option&amp;gt;
                            &amp;lt;option value=&quot;3&quot;&amp;gt;3개 (+ 희망매수 하한)&amp;lt;/option&amp;gt;
                            &amp;lt;option value=&quot;4&quot; selected&amp;gt;4개 (전체 가격대)&amp;lt;/option&amp;gt;
                        &amp;lt;/select&amp;gt;
                        &amp;lt;small class=&quot;text-muted&quot;&amp;gt;
                            AI가 분석한 가격대 중 몇 개에 주문할지 선택한다.
                        &amp;lt;/small&amp;gt;
                    &amp;lt;/div&amp;gt;

                    &amp;lt;div class=&quot;mb-3&quot;&amp;gt;
                        &amp;lt;label class=&quot;form-label&quot;&amp;gt;메모&amp;lt;/label&amp;gt;
                        &amp;lt;textarea class=&quot;form-control&quot; id=&quot;settings-note&quot; rows=&quot;2&quot;&amp;gt;&amp;lt;/textarea&amp;gt;
                    &amp;lt;/div&amp;gt;

                    &amp;lt;div class=&quot;form-check form-switch&quot;&amp;gt;
                        &amp;lt;input class=&quot;form-check-input&quot; type=&quot;checkbox&quot;
                               id=&quot;settings-active&quot; checked&amp;gt;
                        &amp;lt;label class=&quot;form-check-label&quot;&amp;gt;자동 매매 활성화&amp;lt;/label&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/form&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div class=&quot;modal-footer&quot;&amp;gt;
                &amp;lt;button type=&quot;button&quot; class=&quot;btn btn-secondary&quot; data-bs-dismiss=&quot;modal&quot;&amp;gt;취소&amp;lt;/button&amp;gt;
                &amp;lt;button type=&quot;button&quot; class=&quot;btn btn-primary&quot; onclick=&quot;saveSettings()&quot;&amp;gt;저장&amp;lt;/button&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예상 비용 계산&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 예상 매수 비용 계산
function calculateEstimatedCost(stocks) {
    let totalCost = 0;
    let configuredCount = 0;

    stocks.forEach(stock =&amp;gt; {
        if (stock.settings_quantity &amp;amp;&amp;amp; stock.settings_active) {
            const levels = stock.settings_price_levels || 4;
            // 현재가 기준으로 예상 비용 계산
            const estimatedCost = stock.current_price * stock.settings_quantity * levels;
            totalCost += estimatedCost;
            configuredCount++;
        }
    });

    if (configuredCount &amp;gt; 0) {
        document.getElementById('total-estimated-cost').style.display = 'block';
        document.getElementById('estimated-cost-text').textContent =
            `예상 최대 비용: ${totalCost.toLocaleString()}원 (${configuredCount}개 종목)`;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해외주식 자동 매매&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;국내주식과의 차이점&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;국내주식&lt;/th&gt;
&lt;th&gt;해외주식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;통화&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;KRW&lt;/td&gt;
&lt;td&gt;USD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;거래소&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;코스피/코스닥&lt;/td&gt;
&lt;td&gt;NASDAQ/NYSE/AMEX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;거래 시간&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;09:00-15:30&lt;/td&gt;
&lt;td&gt;23:30-06:00 (미국)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;소수점 거래&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;일부 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;API 엔드포인트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;국내주식 전용&lt;/td&gt;
&lt;td&gt;해외주식 전용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해외주식 라우터&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/routers/kis_overseas_trading.py

router = APIRouter(prefix=&quot;/kis-overseas-trading&quot;, tags=[&quot;KIS Overseas Trading&quot;])

@router.get(&quot;/api/my-stocks&quot;)
async def get_my_overseas_stocks(db: AsyncSession = Depends(get_db)):
    &quot;&quot;&quot;보유 해외 주식 조회 API&quot;&quot;&quot;
    kis = KISClient()

    my_stocks = await kis.fetch_my_overseas_stocks()

    # 달러 예수금 조회
    margin = await kis.inquire_integrated_margin()
    usd_balance = margin.get(&quot;usd_balance&quot;, 0)

    # 분석 결과 및 설정 조회
    stock_service = StockAnalysisService(db)
    settings_service = SymbolTradeSettingsService(db)

    processed_stocks = []
    for stock in my_stocks:
        code = stock.get(&quot;ovrs_pdno&quot;)
        analysis = await stock_service.get_latest_analysis_by_symbol(code)
        settings = await settings_service.get_by_symbol(code)

        processed_stocks.append({
            &quot;code&quot;: code,
            &quot;name&quot;: stock.get(&quot;ovrs_item_name&quot;),
            &quot;quantity&quot;: float(stock.get(&quot;ovrs_cblc_qty&quot;, 0)),
            &quot;current_price&quot;: float(stock.get(&quot;now_pric2&quot;, 0)),  # USD
            &quot;avg_price&quot;: float(stock.get(&quot;pchs_avg_pric&quot;, 0)),
            &quot;profit_rate&quot;: float(stock.get(&quot;evlu_pfls_rt&quot;, 0)) / 100.0,
            &quot;analysis_decision&quot;: analysis.decision if analysis else None,
            &quot;settings_quantity&quot;: float(settings.buy_quantity_per_order) if settings else None,
        })

    return {
        &quot;success&quot;: True,
        &quot;usd_balance&quot;: usd_balance,
        &quot;stocks&quot;: processed_stocks
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해외주식 분석기&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 해외주식은 YahooAnalyzer 사용
async def _analyze_overseas_stock_async(symbol: str) -&amp;gt; Dict[str, object]:
    &quot;&quot;&quot;단일 해외 주식 분석&quot;&quot;&quot;

    from app.analysis.service_analyzers import YahooAnalyzer
    analyzer = YahooAnalyzer()  # Yahoo Finance 데이터 기반

    try:
        result, _ = await analyzer.analyze_stock_json(symbol)

        # Telegram 알림
        if hasattr(result, 'decision'):
            notifier = get_trade_notifier()
            await notifier.notify_analysis_complete(
                symbol=symbol,
                korean_name=symbol,  # 해외주식은 한글명 없음
                decision=result.decision,
                confidence=result.confidence,
                market_type=&quot;해외주식&quot;,
            )

        return {&quot;status&quot;: &quot;completed&quot;, &quot;symbol&quot;: symbol}
    finally:
        await analyzer.close()&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Telegram 알림 연동&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;분석 완료 알림&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/monitoring/trade_notifier.py

async def notify_analysis_complete(
    self,
    symbol: str,
    korean_name: str,
    decision: str,
    confidence: float,
    reasons: List[str],
    market_type: str,
):
    &quot;&quot;&quot;AI 분석 완료 알림&quot;&quot;&quot;

    decision_emoji = {
        &quot;buy&quot;: &quot; &quot;,
        &quot;sell&quot;: &quot; &quot;,
        &quot;hold&quot;: &quot;⏸️&quot;
    }.get(decision, &quot;❓&quot;)

    message = f&quot;&quot;&quot;
{decision_emoji} **{market_type} AI 분석 완료**

종목: {korean_name} ({symbol})
결정: {decision.upper()}
신뢰도: {confidence}%

  근거:
{chr(10).join(f'&amp;bull; {r}' for r in reasons[:3])}
&quot;&quot;&quot;

    await self.send_telegram_message(message)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주문 체결 알림&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def notify_order_placed(
    self,
    symbol: str,
    order_type: str,  # &quot;buy&quot; or &quot;sell&quot;
    quantity: int,
    price: float,
    market_type: str,
):
    &quot;&quot;&quot;주문 접수 알림&quot;&quot;&quot;

    emoji = &quot; &quot; if order_type == &quot;buy&quot; else &quot; &quot;
    action = &quot;매수&quot; if order_type == &quot;buy&quot; else &quot;매도&quot;

    message = f&quot;&quot;&quot;
{emoji} **{market_type} {action} 주문 접수**

종목: {symbol}
수량: {quantity:,}주
가격: {price:,.0f}원
&quot;&quot;&quot;

    await self.send_telegram_message(message)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단위 테스트&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# tests/test_kis_trading_service.py

import pytest
from app.services.kis_trading_service import (
    process_kis_domestic_buy_orders_with_analysis
)

@pytest.mark.asyncio
async def test_buy_condition_not_met():
    &quot;&quot;&quot;1% 매수 조건 미충족 테스트&quot;&quot;&quot;
    result = await process_kis_domestic_buy_orders_with_analysis(
        kis_client=mock_kis,
        symbol=&quot;005930&quot;,
        current_price=70000,  # 현재가
        avg_buy_price=70000   # 평단가 = 현재가 (조건 미충족)
    )

    assert result['success'] is False
    assert &quot;1% 매수 조건 미충족&quot; in result['message']

@pytest.mark.asyncio
async def test_no_settings_skip_buy():
    &quot;&quot;&quot;종목 설정 없으면 매수 건너뜀&quot;&quot;&quot;
    result = await process_kis_domestic_buy_orders_with_analysis(
        kis_client=mock_kis,
        symbol=&quot;NO_SETTINGS_STOCK&quot;,
        current_price=50000,
        avg_buy_price=60000
    )

    assert result['success'] is False
    assert &quot;종목 설정 없음&quot; in result['message']&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;통합 테스트&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# tests/test_kis_tasks.py

@pytest.mark.asyncio
async def test_celery_analyze_task():
    &quot;&quot;&quot;Celery 분석 태스크 테스트&quot;&quot;&quot;
    from app.tasks.kis import analyze_domestic_stock_task

    result = analyze_domestic_stock_task.delay(&quot;삼성전자&quot;)

    # 태스크 완료 대기
    task_result = result.get(timeout=60)

    assert task_result['status'] == 'completed'
    assert '삼성전자' in task_result.get('name', '')&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배포 및 운영&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Celery 워커 실행&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 개발 환경
celery -A app.core.celery_app worker --loglevel=info

# 프로덕션 (systemd)
sudo tee /etc/systemd/system/celery-worker.service &amp;gt; /dev/null &amp;lt;&amp;lt;EOF
[Unit]
Description=Celery Worker
After=network.target redis.service

[Service]
Type=simple
User=autotrader
WorkingDirectory=/home/autotrader/auto_trader
ExecStart=/home/autotrader/.local/bin/uv run celery -A app.core.celery_app worker --loglevel=info
Restart=always

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl enable celery-worker
sudo systemctl start celery-worker&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Flower 모니터링&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Celery 모니터링 대시보드
celery -A app.core.celery_app flower --port=5555

# 접속: http://localhost:5555&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/X7KzH/dJMcaaRh1YB/X3KkokUT9YgQYu6An0qtxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/X7KzH/dJMcaaRh1YB/X3KkokUT9YgQYu6An0qtxk/img.png&quot; data-alt=&quot;Flower 모니터링&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/X7KzH/dJMcaaRh1YB/X3KkokUT9YgQYu6An0qtxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FX7KzH%2FdJMcaaRh1YB%2FX3KkokUT9YgQYu6An0qtxk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;360&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Flower 모니터링&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;Flower로 Celery 태스크 모니터링&lt;/i&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 사용 예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 종목 설정 후 자동 매매&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 1. 대시보드 접속
https://your-domain.com/kis-domestic-trading/

# 2. 삼성전자 설정
# - 매수 수량: 5주
# - 가격대: 2개 (적정매수 하한/상한)

# 3. &quot;종목별 분석&amp;rarr;매수&amp;rarr;매도&quot; 클릭
# - AI 분석 실행
# - 분석 결과 기반 분할 매수 주문
# - 수익 목표 기반 분할 매도 주문&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 예상 결과&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[삼성전자] AI 분석 완료
- 결정: BUY
- 신뢰도: 75%
- 적정매수가: 68,000원 ~ 70,000원

[삼성전자] 매수 주문 2건 접수
- 68,000원 x 5주
- 70,000원 x 5주
- 예상 비용: 690,000원

[삼성전자] 매도 주문 2건 접수
- 75,000원 x 5주
- 78,000원 x 5주
- 예상 수익: +8%&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;보안 주의사항&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 키 보호&lt;/h3&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# .env 파일
KIS_APP_KEY=your_app_key
KIS_APP_SECRET=your_app_secret
KIS_ACCOUNT_NO=12345678-01

# 절대 코드에 하드코딩하지 말 것!&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주문 실행 권한&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 인증된 사용자만 주문 가능
@router.post(&quot;/api/buy-orders&quot;)
async def execute_buy_orders(
    current_user: User = Depends(require_role(UserRole.trader))
):
    &quot;&quot;&quot;Trader 이상 권한 필요&quot;&quot;&quot;
    ...&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Rate Limiting&lt;/h3&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;# API 호출 간격 유지
await asyncio.sleep(0.2)  # 200ms 간격

# KIS API는 초당 10회 제한이 있음&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배운 교훈&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;자동 매매는 편리하지만, 설정이 핵심이다&quot;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;AI가 알아서 다 해주겠지&quot;라고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제로는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;종목별 특성이 다름&lt;/b&gt;: 삼성전자와 바이오주는 완전히 다른 전략 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;리스크 관리 필수&lt;/b&gt;: 설정 없는 종목 자동 매수는 위험&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분할 주문의 중요성&lt;/b&gt;: 한 번에 전량 매수/매도하면 슬리피지 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시스템의 강점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. AI 기반 가격 분석:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단순 기술적 분석이 아닌 AI의 종합 판단&lt;/li&gt;
&lt;li&gt;4가지 가격대 제안으로 분할 매수/매도&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 종목별 세밀한 설정:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 종목 특성에 맞는 수량/가격대 설정&lt;/li&gt;
&lt;li&gt;활성화/비활성화로 유연한 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 비동기 처리:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 종목 동시 분석&lt;/li&gt;
&lt;li&gt;실시간 진행 상황 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 우리의 자동매매 시스템은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ 데이터 수집 (한투/Upbit/yfinance)&lt;/li&gt;
&lt;li&gt;✅ AI 분석 (Gemini)&lt;/li&gt;
&lt;li&gt;✅ DB 저장 및 정규화&lt;/li&gt;
&lt;li&gt;✅ 암호화폐 웹 대시보드&lt;/li&gt;
&lt;li&gt;✅ 모니터링 (Grafana Stack)&lt;/li&gt;
&lt;li&gt;✅ 프로덕션 배포 (HTTPS + 24시간)&lt;/li&gt;
&lt;li&gt;✅ JWT 인증 + RBAC&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;국내/해외 주식 자동 매매&lt;/b&gt; &amp;larr; 완성!&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;추가로 고려할 수 있는 기능:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정기 자동 실행 (크론 스케줄러)&lt;/li&gt;
&lt;li&gt;포트폴리오 리밸런싱&lt;/li&gt;
&lt;li&gt;손절/익절 조건 설정&lt;/li&gt;
&lt;li&gt;백테스팅 시스템&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://apiportal.koreainvestment.com/&quot;&gt;한국투자증권 OpenAPI 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.celeryq.dev/&quot;&gt;Celery 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/tutorial/background-tasks/&quot;&gt;FastAPI 백그라운드 태스크&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/mgh3326/auto_trader&quot;&gt;전체 프로젝트 코드 (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/mgh3326/auto_trader/pull/84&quot;&gt;PR #84: KIS 국내/해외 주식 자동 매매 기능 추가&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Programming/Python</category>
      <category>ai주식분석</category>
      <category>Celery비동기처리</category>
      <category>FastAPI</category>
      <category>KIS자동매매</category>
      <category>국내주식자동매매</category>
      <category>분할매수전략</category>
      <category>자동매매대시보드</category>
      <category>주식자동매매</category>
      <category>한국투자증권API</category>
      <category>해외주식자동매매</category>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/237</guid>
      <comments>https://mgh3326.tistory.com/237#entry237comment</comments>
      <pubDate>Sun, 30 Nov 2025 15:34:17 +0900</pubDate>
    </item>
    <item>
      <title>Python 3.13 업그레이드: 새로운 기능과 성능 개선 도입하기</title>
      <link>https://mgh3326.tistory.com/236</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XECyj/dJMcahCP6st/R1mD7plnIktO7hz4630vi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XECyj/dJMcahCP6st/R1mD7plnIktO7hz4630vi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XECyj/dJMcahCP6st/R1mD7plnIktO7hz4630vi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXECyj%2FdJMcahCP6st%2FR1mD7plnIktO7hz4630vi0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;378&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;b&gt;개발 인프라 개선 시리즈&lt;/b&gt;의 &lt;b&gt;Infra-2편&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개발 인프라 개선 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/235&quot;&gt;Infra-1편: Poetry에서 UV로 마이그레이션&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Infra-2편: Python 3.13 업그레이드&lt;/b&gt; &amp;larr; 현재 글&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AI 자동매매 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/227&quot;&gt;1편: 한투 API로 실시간 주식 데이터 수집하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/228&quot;&gt;2편: yfinance로 애플&amp;middot;테슬라 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/229&quot;&gt;3편: Upbit으로 비트코인 24시간 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/230&quot;&gt;4편: AI 분석 결과 DB에 저장하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/232&quot;&gt;5편: Upbit 웹 트레이딩 대시보드 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/233&quot;&gt;6편: 실전 운영을 위한 모니터링 시스템 구축&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/234&quot;&gt;7편: 라즈베리파이 홈서버에 자동 HTTPS로 안전하게 배포하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLGw9o/dJMcabikvt4/M6buhgDLYSO26S9M95TYMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLGw9o/dJMcabikvt4/M6buhgDLYSO26S9M95TYMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLGw9o/dJMcabikvt4/M6buhgDLYSO26S9M95TYMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLGw9o%2FdJMcabikvt4%2FM6buhgDLYSO26S9M95TYMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;600&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 Python 3.13인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Python 3.13이 2024년 10월에 정식 릴리즈되었습니다. 새로운 버전은 항상 흥미롭지만, &lt;b&gt;프로덕션 환경에서 바로 도입해야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 업그레이드를 결정한 이유:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✅ 도입 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;성능 개선&lt;/b&gt;: 전역 인터프리터 락(GIL) 개선으로 멀티스레딩 성능 향상&lt;/li&gt;
&lt;li&gt;&lt;b&gt;새로운 기능&lt;/b&gt;: 개선된 에러 메시지, 타입 힌팅 강화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안&lt;/b&gt;: 최신 보안 패치 및 취약점 수정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;생태계&lt;/b&gt;: 주요 라이브러리들의 3.13 지원 완료&lt;/li&gt;
&lt;li&gt;&lt;b&gt;미래 대비&lt;/b&gt;: 3.11, 3.12는 2026~2027년 EOL (End of Life)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;⚠️ 고려사항:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일부 라이브러리 호환성 문제 가능&lt;/li&gt;
&lt;li&gt;CI/CD 파이프라인 수정 필요&lt;/li&gt;
&lt;li&gt;팀원 로컬 환경 업데이트 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 우리 프로젝트는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UV를 사용하여 의존성 관리가 간단함&lt;/li&gt;
&lt;li&gt;Docker 기반 배포로 환경 재현성 보장&lt;/li&gt;
&lt;li&gt;테스트 커버리지가 높아 회귀 버그 조기 발견 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &lt;b&gt;지금이 업그레이드하기 좋은 시기&lt;/b&gt;라고 판단했습니다!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k00xN/dJMcacasGc1/5F6VjLczEanbQTLK3xhSv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k00xN/dJMcacasGc1/5F6VjLczEanbQTLK3xhSv1/img.png&quot; data-alt=&quot;Python 버전 타임라인&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k00xN/dJMcacasGc1/5F6VjLczEanbQTLK3xhSv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk00xN%2FdJMcacasGc1%2F5F6VjLczEanbQTLK3xhSv1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;480&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Python 버전 타임라인&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;Python 버전별 릴리즈 및 EOL 타임라인&lt;/i&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Python 3.13의 주요 변경사항&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 성능 개선&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JIT 컴파일러 실험적 지원 (PEP 744):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# Python 3.13부터 --enable-experimental-jit 옵션으로 활성화 가능
# 반복 실행되는 코드에서 최대 2배 성능 향상

def calculate_fibonacci(n):
    if n &amp;lt;= 1:
        return n
    return calculate_fibonacci(n-1) + calculate_fibonacci(n-2)

# JIT 컴파일러가 핫스팟을 감지하여 네이티브 코드로 컴파일&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GIL 개선 (PEP 703):&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;멀티스레딩 성능 향상&lt;/li&gt;
&lt;li&gt;CPU 바운드 작업에서 체감 가능한 속도 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 타입 힌팅 강화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TypedDict의 읽기 전용 키 (PEP 705):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;from typing import TypedDict, ReadOnly

class User(TypedDict):
    id: ReadOnly[int]  # 읽기 전용 필드
    name: str
    email: str

user: User = {&quot;id&quot;: 1, &quot;name&quot;: &quot;Alice&quot;, &quot;email&quot;: &quot;alice@example.com&quot;}
user[&quot;name&quot;] = &quot;Bob&quot;  # OK
user[&quot;id&quot;] = 2  # 타입 체커에서 에러!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;@override 데코레이터 추가 (PEP 698):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from typing import override

class BaseAnalyzer:
    def analyze(self, symbol: str) -&amp;gt; dict:
        pass

class UpbitAnalyzer(BaseAnalyzer):
    @override
    def analyze(self, symbol: str) -&amp;gt; dict:
        # 부모 메서드를 오버라이드한다는 의도를 명시
        return {&quot;symbol&quot;: symbol}

    @override
    def analyse(self, symbol: str) -&amp;gt; dict:
        # 오타! 타입 체커가 에러 발생
        pass&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 에러 메시지 개선&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;더 명확한 에러 메시지:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# Python 3.12
&amp;gt;&amp;gt;&amp;gt; x = {&quot;name&quot;: &quot;Alice&quot;}
&amp;gt;&amp;gt;&amp;gt; x[&quot;age&quot;]
KeyError: 'age'

# Python 3.13
&amp;gt;&amp;gt;&amp;gt; x = {&quot;name&quot;: &quot;Alice&quot;}
&amp;gt;&amp;gt;&amp;gt; x[&quot;age&quot;]
KeyError: 'age'
Did you mean: 'name'?  # 제안 추가!&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 보안 개선&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SSL/TLS 기본 설정 강화&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hashlib&lt;/code&gt;의 SHA-256 성능 개선&lt;/li&gt;
&lt;li&gt;메모리 안전성 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;업그레이드 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 호환성 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업그레이드 전에 프로젝트 의존성이 Python 3.13을 지원하는지 확인:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 주요 라이브러리 호환성 확인
uv pip list | grep -E &quot;fastapi|uvicorn|sqlalchemy|alembic|pytest&quot;

# 결과:
# fastapi 0.116.1 - ✅ Python 3.13 지원
# uvicorn 0.35.0 - ✅ Python 3.13 지원
# sqlalchemy 2.0.36 - ✅ Python 3.13 지원
# alembic 1.14.0 - ✅ Python 3.13 지원
# pytest 8.3.4 - ✅ Python 3.13 지원&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 라이브러리 3.13 지원 현황 (2024년 12월 기준):&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;라이브러리&lt;/th&gt;
&lt;th&gt;버전&lt;/th&gt;
&lt;th&gt;3.13 지원&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FastAPI&lt;/td&gt;
&lt;td&gt;0.116.1+&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLAlchemy&lt;/td&gt;
&lt;td&gt;2.0+&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pytest&lt;/td&gt;
&lt;td&gt;8.0+&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pandas&lt;/td&gt;
&lt;td&gt;2.2+&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;numpy&lt;/td&gt;
&lt;td&gt;2.0+&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pydantic&lt;/td&gt;
&lt;td&gt;2.10+&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 로컬 환경 업그레이드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Python 3.13 설치:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;# macOS (Homebrew)
brew install python@3.13

# 또는 pyenv 사용
pyenv install 3.13.0
pyenv global 3.13.0

# Ubuntu/Debian
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update
sudo apt install python3.13 python3.13-dev

# 버전 확인
python3.13 --version
# Python 3.13.0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로젝트 설정 업데이트:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;# 1. .python-version 파일 업데이트
echo &quot;3.13&quot; &amp;gt; .python-version

# 2. UV 재설치 (최신 버전으로)
pip install --upgrade uv

# 3. 가상환경 재생성
rm -rf .venv
uv sync&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. pyproject.toml 업데이트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Before (Python 3.12):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;[project]
name = &quot;auto-trader&quot;
version = &quot;0.1.0&quot;
requires-python = &quot;&amp;gt;=3.12&quot;
dependencies = [
    &quot;fastapi&amp;gt;=0.116.1,&amp;lt;0.117.0&quot;,
    # ...
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;After (Python 3.13):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;[project]
name = &quot;auto-trader&quot;
version = &quot;0.1.0&quot;
requires-python = &quot;&amp;gt;=3.13&quot;  # ✅ 변경
dependencies = [
    &quot;fastapi&amp;gt;=0.116.1,&amp;lt;0.117.0&quot;,
    # ... (동일)
]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. uv.lock 업데이트&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# UV가 Python 3.13용 의존성으로 자동 업데이트
uv lock

# 변경사항 확인
git diff uv.lock

# 주요 변경사항:
# - 모든 패키지의 wheel이 cp313으로 변경
# - Python 3.13에서만 사용 가능한 최적화된 바이너리 사용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;lockfile 차이:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.12용 wheel: &lt;code&gt;cp312-cp312-manylinux_2_17_x86_64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Python 3.13용 wheel: &lt;code&gt;cp313-cp313-manylinux_2_17_x86_64.whl&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. Dockerfile 업데이트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Dockerfile.api (Before):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;FROM python:3.12-slim AS builder

# UV 설치
RUN pip install --upgrade pip &amp;amp;&amp;amp; pip install uv

WORKDIR /app

# 의존성 설치
COPY pyproject.toml uv.lock README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen

# 앱 코드 복사
COPY . .

# 런타임 스테이지
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /app /app

CMD [&quot;uv&quot;, &quot;run&quot;, &quot;uvicorn&quot;, &quot;app.main:app&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8000&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Dockerfile.api (After):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;FROM python:3.13-slim AS builder  # ✅ 3.13으로 변경

# UV 설치
RUN pip install --upgrade pip &amp;amp;&amp;amp; pip install uv

WORKDIR /app

# 의존성 설치
COPY pyproject.toml uv.lock README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen

# 앱 코드 복사
COPY . .

# 런타임 스테이지
FROM python:3.13-slim  # ✅ 3.13으로 변경
WORKDIR /app
COPY --from=builder /app /app

CMD [&quot;uv&quot;, &quot;run&quot;, &quot;uvicorn&quot;, &quot;app.main:app&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8000&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동일하게 Dockerfile.ws도 업데이트:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vbnet&quot;&gt;&lt;code&gt;FROM python:3.13-slim AS builder  # WebSocket 서버도 3.13
# ... (나머지 동일)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. GitHub Actions 업데이트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;.github/workflows/test.yml (Before):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.12']  # 3.12만 지원

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install UV
        run: pip install uv

      - name: Install dependencies
        run: uv sync --group test

      - name: Run tests
        run: uv run pytest tests/ -v --cov&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;.github/workflows/test.yml (After):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.13']  # ✅ 3.13으로 변경

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install UV
        run: pip install uv

      - name: Install dependencies
        run: uv sync --group test

      - name: Run tests
        run: uv run pytest tests/ -v --cov&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;test-monitoring-stack.yml도 동일하게 업데이트:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;jobs:
  test:
    strategy:
      matrix:
        python-version: ['3.13']  # ✅ 변경&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 문서 업데이트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CLAUDE.md 업데이트:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;## 개발 환경 설정

### 필수 요구사항
- Python 3.13+  # ✅ 변경
- UV (의존성 관리)
- PostgreSQL (데이터베이스)
- Redis (캐싱)&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트 및 검증&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 로컬 테스트&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 1. 의존성 재설치
rm -rf .venv
uv sync --all-groups

# 2. 단위 테스트
make test-unit
# ✅ 118 passed, 0 failed

# 3. 통합 테스트 (선택사항)
make test-integration
# ✅ 12 passed, 0 failed

# 4. 전체 테스트 + 커버리지
make test-cov
# ✅ 130 passed
# Coverage: 87%&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Docker 이미지 빌드&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# API 서버 이미지 빌드
docker build -f Dockerfile.api -t auto-trader-api:3.13 .

# WebSocket 서버 이미지 빌드
docker build -f Dockerfile.ws -t auto-trader-ws:3.13 .

# 이미지 확인
docker images | grep auto-trader
# auto-trader-api   3.13   ...   150MB
# auto-trader-ws    3.13   ...   145MB&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이미지 크기 비교:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.12: 148MB (API), 143MB (WS)&lt;/li&gt;
&lt;li&gt;Python 3.13: 150MB (API), 145MB (WS)&lt;/li&gt;
&lt;li&gt;차이: +2MB (약 1.4% 증가 - 무시 가능)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 로컬 Docker Compose 테스트&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 전체 스택 시작
docker compose up -d

# 서비스 상태 확인
docker compose ps
# NAME           IMAGE               STATUS
# postgres       postgres:16         Up
# redis          redis:7             Up
# api            auto-trader-api     Up
# websocket      auto-trader-ws      Up

# API 헬스체크
curl http://localhost:8000/health
# {&quot;status&quot;: &quot;healthy&quot;, &quot;python_version&quot;: &quot;3.13.0&quot;}

# 로그 확인
docker compose logs api | grep -i &quot;python&quot;
# INFO:     Using Python 3.13.0&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. CI/CD 파이프라인 검증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GitHub Actions에서 자동 테스트:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# PR 생성 후 CI 실행
gh pr create --title &quot;Python 3.13으로 업그레이드&quot; \
             --body &quot;Python 3.12에서 3.13으로 업그레이드&quot;

# CI 상태 확인
gh pr checks

# 출력 예시:
# ✅ Tests (3.13) - All tests passed
# ✅ Lint - No issues found
# ✅ Security - No vulnerabilities
# ✅ Docker Build - Images built successfully&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dw4pBB/dJMcaaRfT9f/2eN0YGh3SkPCFvcZRCUPT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dw4pBB/dJMcaaRfT9f/2eN0YGh3SkPCFvcZRCUPT0/img.png&quot; data-alt=&quot;CI/CD 파이프라인 성공&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dw4pBB/dJMcaaRfT9f/2eN0YGh3SkPCFvcZRCUPT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdw4pBB%2FdJMcaaRfT9f%2F2eN0YGh3SkPCFvcZRCUPT0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;480&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;CI/CD 파이프라인 성공&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;GitHub Actions에서 Python 3.13 테스트 성공&lt;/i&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;발생한 이슈와 해결&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이슈 1: Docker 이미지 캐시 무효화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제:&lt;/b&gt;&lt;br /&gt;Python 버전을 변경했지만 Docker가 이전 3.12 이미지를 캐시에서 사용.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;에러 메시지:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;docker build -f Dockerfile.api .
# Step 1/10 : FROM python:3.13-slim
# Using cache
# (실제로는 3.12 이미지 사용 중)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 캐시 없이 재빌드
docker build --no-cache -f Dockerfile.api -t auto-trader-api:3.13 .

# 또는 기존 이미지 삭제 후 재빌드
docker rmi python:3.12-slim python:3.13-slim
docker build -f Dockerfile.api -t auto-trader-api:3.13 .&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이슈 2: uv.lock 충돌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제:&lt;/b&gt;&lt;br /&gt;여러 팀원이 동시에 &lt;code&gt;uv lock&lt;/code&gt;을 실행하여 lockfile 충돌 발생.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 1. 최신 main 브랜치 pull
git checkout main
git pull origin main

# 2. 내 브랜치에 main merge
git checkout my-branch
git merge main

# 3. 충돌 시 main의 uv.lock 우선 사용
git checkout --theirs uv.lock

# 4. 로컬에서 uv lock 재생성
uv lock

# 5. 테스트 후 커밋
uv sync
make test
git add uv.lock
git commit -m &quot;Resolve uv.lock conflict&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이슈 3: 일부 타입 힌팅 에러&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제:&lt;/b&gt;&lt;br /&gt;Python 3.13의 타입 체커가 더 엄격해져서 기존 코드에서 경고 발생.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# Before (3.12에서는 OK)
def analyze(data: dict) -&amp;gt; dict:
    return {&quot;result&quot;: data.get(&quot;value&quot;)}

# After (3.13에서 mypy 경고)
# error: Need type annotation for &quot;data&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;julia&quot;&gt;&lt;code&gt;from typing import Dict, Any

def analyze(data: Dict[str, Any]) -&amp;gt; Dict[str, Any]:
    return {&quot;result&quot;: data.get(&quot;value&quot;)}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이슈 4: GitHub Actions setup-python 캐시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제:&lt;/b&gt;&lt;br /&gt;GitHub Actions가 Python 3.12 캐시를 재사용하여 3.13 설치 실패.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;- name: Set up Python 3.13
  uses: actions/setup-python@v5
  with:
    python-version: '3.13'
    cache: 'pip'  # pip 캐시는 자동으로 Python 버전별로 분리됨&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는 캐시 무효화:&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;# GitHub Actions 캐시 삭제
gh cache delete &amp;lt;cache-key&amp;gt;

# 또는 workflow에서 캐시 키에 Python 버전 포함
- uses: actions/cache@v3
  with:
    path: .venv
    key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}-py313&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;성능 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 애플리케이션 시작 시간&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;측정 방법:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# Python 3.12
time uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 &amp;amp;
# real    0m2.341s

# Python 3.13
time uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 &amp;amp;
# real    0m2.298s&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.12: 2.341초&lt;/li&gt;
&lt;li&gt;Python 3.13: 2.298초&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개선: 약 2% 빠름&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 테스트 실행 시간&lt;/h3&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;# Python 3.12
time uv run pytest tests/ -v
# 130 passed in 18.42s

# Python 3.13
time uv run pytest tests/ -v
# 130 passed in 17.91s&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.12: 18.42초&lt;/li&gt;
&lt;li&gt;Python 3.13: 17.91초&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개선: 약 3% 빠름&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. API 응답 시간 (벤치마크)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Upbit 분석 API 벤치마크:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# Python 3.12
ab -n 100 -c 10 http://localhost:8000/api/upbit/analyze/BTC-KRW
# Requests per second: 12.34 [#/sec]
# Time per request: 81.03 [ms] (mean)

# Python 3.13
ab -n 100 -c 10 http://localhost:8000/api/upbit/analyze/BTC-KRW
# Requests per second: 12.71 [#/sec]
# Time per request: 78.67 [ms] (mean)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.12: 81.03ms (평균)&lt;/li&gt;
&lt;li&gt;Python 3.13: 78.67ms (평균)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개선: 약 3% 빠름&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 메모리 사용량&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# Python 3.12
docker stats auto-trader-api
# CONTAINER      CPU %   MEM USAGE / LIMIT    MEM %
# api            0.5%    142.3MiB / 512MiB    27.8%

# Python 3.13
docker stats auto-trader-api
# CONTAINER      CPU %   MEM USAGE / LIMIT    MEM %
# api            0.4%    138.7MiB / 512MiB    27.1%&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.12: 142.3MB&lt;/li&gt;
&lt;li&gt;Python 3.13: 138.7MB&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개선: 약 2.5% 메모리 절감&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZAged/dJMcabWVql3/dKto4nEkBhNiMmfG4nkDPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZAged/dJMcabWVql3/dKto4nEkBhNiMmfG4nkDPk/img.png&quot; data-alt=&quot;성능 비교 그래프&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZAged/dJMcabWVql3/dKto4nEkBhNiMmfG4nkDPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZAged%2FdJMcabWVql3%2FdKto4nEkBhNiMmfG4nkDPk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;480&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;성능 비교 그래프&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;Python 3.12 vs 3.13 성능 비교&lt;/i&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Python 버전 업그레이드를 고려 중이라면 다음 체크리스트를 참고하세요:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;업그레이드 전:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 주요 의존성이 새 Python 버전을 지원하는가?&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 팀원들에게 업그레이드 계획을 공유했는가?&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 현재 환경에서 모든 테스트가 통과하는가?&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 롤백 계획이 있는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;업그레이드 중:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;.python-version&lt;/code&gt; 파일 업데이트&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;pyproject.toml&lt;/code&gt;의 &lt;code&gt;requires-python&lt;/code&gt; 업데이트&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;code&gt;uv.lock&lt;/code&gt; 재생성 (&lt;code&gt;uv lock&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; Dockerfile에서 베이스 이미지 업데이트&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; GitHub Actions 워크플로우 업데이트&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 문서(README, CLAUDE.md 등) 업데이트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;업그레이드 후:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 로컬 환경에서 테스트 실행 확인&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; Docker 이미지 빌드 및 실행 확인&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; CI/CD 파이프라인 통과 확인&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; 프로덕션 배포 후 모니터링&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;팀원 온보딩 가이드&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;팀원이 해야 할 작업&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Python 3.13 설치:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;# macOS
brew install python@3.13

# Ubuntu/Debian
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update
sudo apt install python3.13

# pyenv 사용자
pyenv install 3.13.0
pyenv global 3.13.0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 프로젝트 업데이트:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 최신 코드 pull
git pull origin main

# 기존 가상환경 삭제
rm -rf .venv

# 새로운 가상환경 생성 및 의존성 설치
uv sync --all-groups

# Python 버전 확인
python --version
# Python 3.13.0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Docker 이미지 재빌드:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 기존 이미지 삭제
docker compose down
docker rmi auto-trader-api auto-trader-ws

# 새로운 이미지 빌드
docker compose build

# 서비스 시작
docker compose up -d&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. IDE 설정 업데이트:&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;VSCode:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// .vscode/settings.json
{
  &quot;python.defaultInterpreterPath&quot;: &quot;.venv/bin/python&quot;,
  &quot;python.analysis.typeCheckingMode&quot;: &quot;basic&quot;,
  &quot;python.languageServer&quot;: &quot;Pylance&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PyCharm:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;Preferences &amp;gt; Project &amp;gt; Python Interpreter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;기존 인터프리터 삭제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Add Interpreter &amp;gt; Existing Environment&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.venv/bin/python&lt;/code&gt; 선택&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;롤백 계획&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 Python 3.13으로 전환 후 문제가 발생하면:&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 1: Git Revert&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 업그레이드 커밋 확인
git log --oneline | grep &quot;Python 3.13&quot;
# abc1234 Python 3.13으로 업그레이드

# 해당 커밋 revert
git revert abc1234

# 또는 브랜치 전체 롤백
git checkout main
git reset --hard &amp;lt;before-upgrade-commit&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 2: 수동 롤백&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 1. Python 버전 파일 복구
echo &quot;3.12&quot; &amp;gt; .python-version

# 2. pyproject.toml 복구
sed -i 's/&amp;gt;=3.13/&amp;gt;=3.12/' pyproject.toml

# 3. uv.lock 재생성
uv lock

# 4. 가상환경 재생성
rm -rf .venv
uv sync

# 5. Docker 이미지 재빌드
docker compose build&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 3: 이전 Docker 이미지 사용&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 이전 이미지 태그로 롤백
docker pull your-registry/auto-trader-api:python3.12
docker pull your-registry/auto-trader-ws:python3.12

# docker-compose.yml에서 이미지 태그 변경
# image: auto-trader-api:python3.13 &amp;rarr; auto-trader-api:python3.12

docker compose up -d&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Python 3.13 업그레이드 소감&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;버전 업그레이드가 필요할까?&quot;라는 의구심이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;호환성 확인: 주요 라이브러리가 모두 3.13 지원&lt;/li&gt;
&lt;li&gt;UV 덕분에 의존성 관리가 간단함&lt;/li&gt;
&lt;li&gt;Docker 기반 배포로 환경 재현성 보장&lt;/li&gt;
&lt;li&gt;테스트 커버리지 높아 회귀 버그 조기 발견&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 &lt;b&gt;예상보다 훨씬 쉽게&lt;/b&gt; 업그레이드할 수 있었습니다!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;체감한 효과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개발 경험 개선:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;더 명확한 에러 메시지 (오타 제안 등)&lt;/li&gt;
&lt;li&gt;타입 힌팅 강화로 버그 조기 발견&lt;/li&gt;
&lt;li&gt;성능 향상 (2~3%)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;미래 대비:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Python 3.11, 3.12는 2026~2027년 EOL&lt;/li&gt;
&lt;li&gt;최신 보안 패치 적용&lt;/li&gt;
&lt;li&gt;새로운 기능 활용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;권장 사항&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✅ Python 3.13 업그레이드 추천:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주요 라이브러리가 3.13을 지원하는 프로젝트&lt;/li&gt;
&lt;li&gt;UV, Poetry 같은 현대적 패키지 관리자 사용&lt;/li&gt;
&lt;li&gt;Docker 기반 배포로 환경 재현성 보장&lt;/li&gt;
&lt;li&gt;테스트 커버리지가 높은 프로젝트&lt;/li&gt;
&lt;li&gt;Python 3.11 이하를 사용 중인 프로젝트 (EOL 임박)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;⚠️ 신중히 검토:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;레거시 라이브러리에 크게 의존하는 경우&lt;/li&gt;
&lt;li&gt;팀원 대부분이 Python 3.13 전환을 원하지 않는 경우&lt;/li&gt;
&lt;li&gt;프로덕션 환경에서 Python 버전 업그레이드가 어려운 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 우리 프로젝트는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ UV로 의존성 관리 (Infra-1편)&lt;/li&gt;
&lt;li&gt;✅ Python 3.13 최신 버전 (Infra-2편)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;추가로 고려할 인프라 개선:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CI/CD 파이프라인 최적화 (캐싱, 병렬화)&lt;/li&gt;
&lt;li&gt;멀티스테이지 Docker 빌드 개선&lt;/li&gt;
&lt;li&gt;Pre-commit hooks 도입 (코드 품질 자동 검사)&lt;/li&gt;
&lt;li&gt;Renovate/Dependabot으로 자동 의존성 업데이트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 가장 중요한 것은:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;필요한 개선을 점진적으로 적용하며 프로젝트를 발전시키는 것&quot;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론보다 실전, 계획보다 실행!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.python.org/3.13/whatsnew/3.13.html&quot;&gt;Python 3.13 공식 릴리즈 노트&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peps.python.org/pep-0703/&quot;&gt;PEP 703 - Making the GIL Optional&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peps.python.org/pep-0744/&quot;&gt;PEP 744 - JIT Compilation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/astral-sh/uv&quot;&gt;UV 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/mgh3326/auto_trader/pull/77&quot;&gt;실제 업그레이드 PR&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로젝트 저장소:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GitHub: &lt;a href=&quot;https://github.com/mgh3326/auto_trader&quot;&gt;github.com/mgh3326/auto_trader&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Python 3.13 업그레이드 PR: &lt;a href=&quot;https://github.com/mgh3326/auto_trader/pull/77&quot;&gt;#77&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문이나 피드백은 이슈로 남겨주세요!&lt;/p&gt;</description>
      <category>Programming/Python</category>
      <category>AI 자동매매 시스템</category>
      <category>ci/cd 파이프라인</category>
      <category>docker 배포</category>
      <category>JIT&amp;middot;GIL 성능 개선</category>
      <category>Python 3.13</category>
      <category>UV 의존성 관리</category>
      <category>개발 인프라 개선</category>
      <category>백엔드 성능 최적화</category>
      <category>타입 힌팅&amp;middot;타입 체킹</category>
      <category>파이썬 버전 업그레이드</category>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/236</guid>
      <comments>https://mgh3326.tistory.com/236#entry236comment</comments>
      <pubDate>Mon, 24 Nov 2025 20:52:13 +0900</pubDate>
    </item>
    <item>
      <title>JWT 인증 시스템으로 안전한 웹 애플리케이션 구축하기: 회원가입부터 역할 기반 접근 제어까지</title>
      <link>https://mgh3326.tistory.com/235</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNXA05/dJMcagRsvWh/15HXy5WY6ag4MD2ti9miJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNXA05/dJMcagRsvWh/15HXy5WY6ag4MD2ti9miJk/img.png&quot; data-alt=&quot;JWT 인증 시스템&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNXA05/dJMcagRsvWh/15HXy5WY6ag4MD2ti9miJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNXA05%2FdJMcagRsvWh%2F15HXy5WY6ag4MD2ti9miJk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;378&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JWT 인증 시스템&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 AI 기반 자동매매 시스템 시리즈의 &lt;b&gt;8편&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;전체 시리즈:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/227&quot;&gt;1편: 한투 API로 실시간 주식 데이터 수집하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/228&quot;&gt;2편: yfinance로 애플&amp;middot;테슬라 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/229&quot;&gt;3편: Upbit으로 비트코인 24시간 분석하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/230&quot;&gt;4편: AI 분석 결과 DB에 저장하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/232&quot;&gt;5편: Upbit 웹 트레이딩 대시보드 구축하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/233&quot;&gt;6편: 실전 운영을 위한 모니터링 시스템 구축&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mgh3326.tistory.com/234&quot;&gt;7편: 라즈베리파이 홈서버에 자동 HTTPS로 안전하게 배포하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;8편: JWT 인증 시스템으로 안전한 웹 애플리케이션 구축하기&lt;/b&gt; &amp;larr; 현재 글&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zqDGB/dJMcagRsxtX/7LR8MpFStte1nJh9XdUaUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zqDGB/dJMcagRsxtX/7LR8MpFStte1nJh9XdUaUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zqDGB/dJMcagRsxtX/7LR8MpFStte1nJh9XdUaUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzqDGB%2FdJMcagRsxtX%2F7LR8MpFStte1nJh9XdUaUK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;600&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;지금까지의 여정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 지금까지:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ 한투/yfinance/Upbit API로 데이터 수집&lt;/li&gt;
&lt;li&gt;✅ AI 분석 자동화 (Gemini)&lt;/li&gt;
&lt;li&gt;✅ DB 저장 및 정규화&lt;/li&gt;
&lt;li&gt;✅ 웹 대시보드 구축&lt;/li&gt;
&lt;li&gt;✅ Grafana 관찰성 스택으로 모니터링&lt;/li&gt;
&lt;li&gt;✅ 라즈베리파이에 HTTPS 배포&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;까지 완성했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;새로운 과제: 보안&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템을 인터넷에 배포했으니 이제 &lt;b&gt;누가 접근할 수 있는지 제어&lt;/b&gt;해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 상태:&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;https://your-domain.com  # 아무나 접속 가능  
https://your-domain.com/api/stocks  # 아무나 API 호출 가능  &lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실제로 마주한 보안 문제들&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 1: 무방비한 API 엔드포인트&lt;/h4&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 아무나 데이터 조회 가능
curl https://your-domain.com/api/stocks
# &amp;rarr; 200 OK (모든 종목 데이터 노출)

# 아무나 분석 요청 가능
curl -X POST https://your-domain.com/api/analyze/비트코인
# &amp;rarr; 200 OK (AI 분석 비용 발생)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;무제한 API 호출 &amp;rarr; AI 비용 폭탄  &lt;/li&gt;
&lt;li&gt;민감한 거래 정보 노출&lt;/li&gt;
&lt;li&gt;악의적인 사용자의 서비스 악용&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 2: 사용자별 권한 구분 불가&lt;/h4&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;누가 이 분석을 요청했나?
누가 이 거래를 실행했나?
&amp;rarr; 알 수 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자 추적 불가&lt;/li&gt;
&lt;li&gt;책임 소재 불분명&lt;/li&gt;
&lt;li&gt;감사(Audit) 로그 부재&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 3: 관리 기능 노출&lt;/h4&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 아무나 시스템 설정 변경 가능?
# 아무나 다른 사용자 정보 조회 가능?
&amp;rarr; 이건 절대 안 됨!&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  해결책: JWT 기반 인증 + 역할 기반 접근 제어 (RBAC)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 구축할 시스템:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;JWT (JSON Web Token) 인증&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태를 저장하지 않는(Stateless) 토큰 기반 인증&lt;/li&gt;
&lt;li&gt;Access Token (15분) + Refresh Token (7일)&lt;/li&gt;
&lt;li&gt;토큰 블랙리스트로 강제 로그아웃&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;역할 기반 접근 제어 (RBAC)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Admin: 모든 권한&lt;/li&gt;
&lt;li&gt;Trader: 거래 실행 가능&lt;/li&gt;
&lt;li&gt;Analyst: 분석 조회만 가능&lt;/li&gt;
&lt;li&gt;Viewer: 읽기 전용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안 강화&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;bcrypt 비밀번호 해싱&lt;/li&gt;
&lt;li&gt;Rate Limiting (분당 5회 제한)&lt;/li&gt;
&lt;li&gt;세션 관리 (Redis)&lt;/li&gt;
&lt;li&gt;보안 로깅&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;인증 시스템 아키텍처&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;515&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMfpJQ/dJMcaaX0Vj3/Ii2CIqTkKxnqFOP4xVvCQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMfpJQ/dJMcaaX0Vj3/Ii2CIqTkKxnqFOP4xVvCQK/img.png&quot; data-alt=&quot;인증 시스템 아키텍처&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMfpJQ/dJMcaaX0Vj3/Ii2CIqTkKxnqFOP4xVvCQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMfpJQ%2FdJMcaaX0Vj3%2FIi2CIqTkKxnqFOP4xVvCQK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;515&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;515&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;인증 시스템 아키텍처&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;JWT + Redis 기반 인증 시스템 구조&lt;/i&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 컴포넌트&lt;/h3&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;사용자
  &amp;darr;
FastAPI 애플리케이션
  ├─ 인증 라우터 (/auth)
  │   ├─ POST /register (회원가입)
  │   ├─ POST /login (로그인)
  │   ├─ POST /refresh (토큰 갱신)
  │   └─ POST /logout (로그아웃)
  │
  ├─ 인증 미들웨어
  │   ├─ JWT 토큰 검증
  │   ├─ 사용자 인증
  │   └─ 역할 권한 확인
  │
  ├─ 보호된 API 엔드포인트
  │   ├─ /api/stocks (읽기: Viewer+)
  │   ├─ /api/trade (거래: Trader+)
  │   └─ /admin/* (관리: Admin)
  │
  └─ 보안 계층
      ├─ bcrypt (비밀번호 해싱)
      ├─ Redis (토큰 저장)
      └─ Rate Limiting&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인증 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 회원가입 및 로그인:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;[사용자] &amp;rarr; POST /auth/register
           ├─ 이메일, 사용자명, 비밀번호
           ├─ bcrypt로 비밀번호 해싱
           └─ User 테이블에 저장

[사용자] &amp;rarr; POST /auth/login
           ├─ 사용자명, 비밀번호
           ├─ 비밀번호 검증
           ├─ Access Token 생성 (15분)
           ├─ Refresh Token 생성 (7일)
           └─ Redis에 Refresh Token 저장&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 인증된 요청:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;[사용자] &amp;rarr; GET /api/stocks
           ├─ Authorization: Bearer &amp;lt;access_token&amp;gt;
           ├─ JWT 토큰 검증
           ├─ 사용자 권한 확인
           └─ 응답 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 토큰 갱신:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;[사용자] &amp;rarr; POST /auth/refresh
           ├─ refresh_token 전송
           ├─ Redis에서 토큰 검증
           ├─ 새 Access Token 발급
           └─ Refresh Token 재발급 (선택)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 로그아웃:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[사용자] &amp;rarr; POST /auth/logout
           ├─ Refresh Token 전송
           ├─ Redis에서 토큰 삭제 (블랙리스트)
           └─ 강제 로그아웃&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JWT (JSON Web Token) 이해하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JWT란?&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON 형태의 데이터를 안전하게 전송하기 위한 토큰 표준 (RFC 7519)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JWT 구조:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJib2IiLCJleHAiOjE3MDk1MTIxNjd9.jKVUwDEHNWLkoDcJZvtgnxCyVbEN1Ulq0vZcxAJvSSk

&amp;darr; 디코딩하면

Header (헤더):
{
  &quot;alg&quot;: &quot;HS256&quot;,
  &quot;typ&quot;: &quot;JWT&quot;
}

Payload (페이로드):
{
  &quot;sub&quot;: &quot;bob&quot;,        # 사용자명
  &quot;exp&quot;: 1709512167,   # 만료 시간
  &quot;type&quot;: &quot;access&quot;     # 토큰 타입
}

Signature (서명):
HMACSHA256(
  base64UrlEncode(header) + &quot;.&quot; + base64UrlEncode(payload),
  SECRET_KEY
)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Access Token vs Refresh Token&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특성&lt;/th&gt;
&lt;th&gt;Access Token&lt;/th&gt;
&lt;th&gt;Refresh Token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;목적&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;API 요청 인증&lt;/td&gt;
&lt;td&gt;Access Token 갱신&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;유효기간&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;15분 (짧음)&lt;/td&gt;
&lt;td&gt;7일 (길음)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;저장 위치&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;메모리/LocalStorage&lt;/td&gt;
&lt;td&gt;HttpOnly Cookie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;노출 위험&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;높음 (매 요청마다 전송)&lt;/td&gt;
&lt;td&gt;낮음 (갱신 시에만)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;탈취 시 피해&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;15분 내로 제한&lt;/td&gt;
&lt;td&gt;7일 내로 제한&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;저장소&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;없음 (Stateless)&lt;/td&gt;
&lt;td&gt;Redis (검증 필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 두 개의 토큰을 사용하나?&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;보안과 편의성 균형:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Access Token이 짧으면 &amp;rarr; 안전하지만 자주 로그인 필요&lt;/li&gt;
&lt;li&gt;Refresh Token으로 &amp;rarr; 로그인 유지하면서도 안전&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;탈취 시 피해 최소화:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Access Token 탈취 &amp;rarr; 최대 15분간만 사용 가능&lt;/li&gt;
&lt;li&gt;Refresh Token 탈취 &amp;rarr; Redis에서 즉시 무효화 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현: 회원가입 및 로그인&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 비밀번호 해싱 (bcrypt)&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/auth/security.py
from passlib.context import CryptContext

# bcrypt로 비밀번호 해싱 (강력한 암호화)
PASSWORD_CONTEXT = CryptContext(schemes=[&quot;bcrypt&quot;], deprecated=&quot;auto&quot;)

def get_password_hash(password: str) -&amp;gt; str:
    &quot;&quot;&quot;비밀번호를 bcrypt로 해싱&quot;&quot;&quot;
    return PASSWORD_CONTEXT.hash(password)

def verify_password(plain_password: str, hashed_password: str) -&amp;gt; bool:
    &quot;&quot;&quot;비밀번호 검증&quot;&quot;&quot;
    return PASSWORD_CONTEXT.verify(plain_password, hashed_password)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;bcrypt의 특징:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;느린 해싱&lt;/b&gt;: 무차별 대입 공격(Brute Force) 방어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Salt 자동 생성&lt;/b&gt;: 동일한 비밀번호도 다른 해시값&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Work Factor&lt;/b&gt;: 해싱 횟수 조절 가능 (기본 12 rounds)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. JWT 토큰 생성&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/auth/security.py
import jwt
from datetime import datetime, timedelta, timezone
from app.core.config import settings

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -&amp;gt; str:
    &quot;&quot;&quot;Access Token 생성 (15분)&quot;&quot;&quot;
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + timedelta(
        minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES  # 15
    )
    to_encode.update({&quot;exp&quot;: expire, &quot;type&quot;: &quot;access&quot;})

    # SECRET_KEY로 서명
    encoded_jwt = jwt.encode(
        to_encode,
        settings.SECRET_KEY,
        algorithm=settings.ALGORITHM  # HS256
    )
    return encoded_jwt

def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) -&amp;gt; str:
    &quot;&quot;&quot;Refresh Token 생성 (7일)&quot;&quot;&quot;
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + timedelta(
        days=settings.REFRESH_TOKEN_EXPIRE_DAYS  # 7
    )
    to_encode.update({&quot;exp&quot;: expire, &quot;type&quot;: &quot;refresh&quot;})

    encoded_jwt = jwt.encode(
        to_encode,
        settings.SECRET_KEY,
        algorithm=settings.ALGORITHM
    )
    return encoded_jwt&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 회원가입 API&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/auth/router.py
from fastapi import APIRouter, HTTPException, status
from app.auth.schemas import UserCreate, UserResponse
from app.auth.security import get_password_hash

router = APIRouter(prefix=&quot;/auth&quot;, tags=[&quot;authentication&quot;])

@router.post(&quot;/register&quot;, response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit(&quot;5/minute&quot;)  # Rate Limiting: 분당 5회
async def register(
    user_data: UserCreate,
    db: AsyncSession = Depends(get_db),
) -&amp;gt; UserResponse:
    &quot;&quot;&quot;
    회원가입

    - 이메일, 사용자명 중복 확인
    - 비밀번호 bcrypt 해싱
    - 기본 역할: Viewer
    &quot;&quot;&quot;
    # 중복 확인
    result = await db.execute(select(User).where(User.username == user_data.username))
    if result.scalar_one_or_none():
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=&quot;Username already registered&quot;,
        )

    # 사용자 생성
    hashed_password = get_password_hash(user_data.password)
    db_user = User(
        email=user_data.email,
        username=user_data.username,
        role=UserRole.viewer,  # 기본 역할
        hashed_password=hashed_password,
        is_active=True,
    )

    db.add(db_user)
    await db.commit()
    await db.refresh(db_user)

    return UserResponse.model_validate(db_user)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;보안 포인트:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Rate Limiting으로 무차별 가입 방지&lt;/li&gt;
&lt;li&gt;비밀번호는 절대 평문 저장하지 않음&lt;/li&gt;
&lt;li&gt;기본 역할은 최소 권한 (Viewer)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 로그인 API&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@router.post(&quot;/login&quot;, response_model=Token)
@limiter.limit(&quot;5/minute&quot;)
async def login(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: AsyncSession = Depends(get_db),
) -&amp;gt; Token:
    &quot;&quot;&quot;
    로그인

    - 사용자명, 비밀번호 검증
    - Access Token + Refresh Token 발급
    - Refresh Token을 Redis에 저장
    &quot;&quot;&quot;
    # 사용자 조회
    result = await db.execute(
        select(User).where(User.username == form_data.username)
    )
    user = result.scalar_one_or_none()

    # 사용자 없음 또는 비밀번호 불일치
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=&quot;Incorrect username or password&quot;,
            headers={&quot;WWW-Authenticate&quot;: &quot;Bearer&quot;},
        )

    # 비활성화된 사용자
    if not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=&quot;Inactive user&quot;
        )

    # JWT 토큰 생성
    access_token = create_access_token(data={&quot;sub&quot;: user.username})
    refresh_token = create_refresh_token(data={&quot;sub&quot;: user.username})

    # Refresh Token을 Redis에 저장 (토큰 검증 및 무효화용)
    await save_refresh_token(
        user_id=user.id,
        refresh_token=refresh_token,
        expires_in=settings.REFRESH_TOKEN_EXPIRE_DAYS * 86400  # 7일 (초 단위)
    )

    return Token(
        access_token=access_token,
        refresh_token=refresh_token,
        token_type=&quot;bearer&quot;
    )&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Redis 기반 토큰 관리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 Redis인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Refresh Token은 왜 DB/Redis에 저장해야 하나?&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;강제 로그아웃 필요&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JWT는 Stateless &amp;rarr; 발급 후 서버에서 제어 불가&lt;/li&gt;
&lt;li&gt;Redis에 저장 &amp;rarr; 로그아웃 시 삭제하여 무효화&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안 강화&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;탈취된 토큰 즉시 차단&lt;/li&gt;
&lt;li&gt;사용자별 활성 세션 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;빠른 조회&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis의 O(1) 조회 속도&lt;/li&gt;
&lt;li&gt;매 API 요청마다 검증하므로 속도 중요&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Redis 토큰 저장 구조&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/auth/token_repository.py
from app.core.redis_client import get_redis
import hashlib

async def save_refresh_token(
    user_id: int,
    refresh_token: str,
    expires_in: int
) -&amp;gt; None:
    &quot;&quot;&quot;Refresh Token을 Redis에 저장&quot;&quot;&quot;
    redis = await get_redis()

    # 토큰 해시 (보안 강화)
    token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()

    # Redis 키: refresh_token:{user_id}:{token_hash}
    key = f&quot;refresh_token:{user_id}:{token_hash}&quot;

    # 저장 (TTL: 7일)
    await redis.setex(key, expires_in, refresh_token)

async def get_valid_refresh_token(
    user_id: int,
    refresh_token: str
) -&amp;gt; bool:
    &quot;&quot;&quot;Redis에서 Refresh Token 검증&quot;&quot;&quot;
    redis = await get_redis()
    token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
    key = f&quot;refresh_token:{user_id}:{token_hash}&quot;

    # 토큰 존재 여부 확인
    stored_token = await redis.get(key)
    return stored_token is not None

async def revoke_refresh_token(
    user_id: int,
    refresh_token: str
) -&amp;gt; None:
    &quot;&quot;&quot;Refresh Token 무효화 (로그아웃)&quot;&quot;&quot;
    redis = await get_redis()
    token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
    key = f&quot;refresh_token:{user_id}:{token_hash}&quot;

    # Redis에서 삭제
    await redis.delete(key)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Redis 키 구조:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;refresh_token:1:abc123def456...  &amp;rarr; &quot;eyJhbGciOiJIUzI1NiIs...&quot;
refresh_token:1:789xyz012abc...  &amp;rarr; &quot;eyJhbGciOiJIUzI1NiIs...&quot;
refresh_token:2:def456ghi789...  &amp;rarr; &quot;eyJhbGciOiJIUzI1NiIs...&quot;

TTL: 604800 seconds (7일)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;역할 기반 접근 제어 (RBAC)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할 정의&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/models/trading.py
from enum import Enum

class UserRole(str, Enum):
    &quot;&quot;&quot;사용자 역할 (계층 구조)&quot;&quot;&quot;
    admin = &quot;admin&quot;      # 최고 권한 (모든 것)
    trader = &quot;trader&quot;    # 거래 실행 가능
    analyst = &quot;analyst&quot;  # 분석 조회만
    viewer = &quot;viewer&quot;    # 읽기 전용

class User(Base):
    __tablename__ = &quot;users&quot;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    email: Mapped[str] = mapped_column(unique=True, index=True)
    username: Mapped[str] = mapped_column(unique=True, index=True)
    hashed_password: Mapped[str]
    role: Mapped[UserRole] = mapped_column(default=UserRole.viewer)
    is_active: Mapped[bool] = mapped_column(default=True)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할 계층 구조&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BWujU/dJMcah3Ua0W/BDqDsTeuS271uTrlKN1Of0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BWujU/dJMcah3Ua0W/BDqDsTeuS271uTrlKN1Of0/img.png&quot; data-alt=&quot;역할 계층 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BWujU/dJMcah3Ua0W/BDqDsTeuS271uTrlKN1Of0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBWujU%2FdJMcah3Ua0W%2FBDqDsTeuS271uTrlKN1Of0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;360&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;360&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;역할 계층 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;Admin &amp;rarr; Trader &amp;rarr; Analyst &amp;rarr; Viewer 권한 상속&lt;/i&gt;&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/auth/role_hierarchy.py
class RoleHierarchy:
    &quot;&quot;&quot;
    역할 계층 구조

    Admin &amp;rarr; Trader &amp;rarr; Analyst &amp;rarr; Viewer
    (상위 역할은 하위 역할의 권한 포함)
    &quot;&quot;&quot;
    HIERARCHY = {
        UserRole.viewer: 0,
        UserRole.analyst: 1,
        UserRole.trader: 2,
        UserRole.admin: 3,
    }

    @classmethod
    def has_permission(cls, user_role: UserRole, required_role: UserRole) -&amp;gt; bool:
        &quot;&quot;&quot;사용자 역할이 요구 역할 이상인지 확인&quot;&quot;&quot;
        return cls.HIERARCHY[user_role] &amp;gt;= cls.HIERARCHY[required_role]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Trader 역할은 Analyst, Viewer 권한도 가짐&lt;/li&gt;
&lt;li&gt;Admin 역할은 모든 권한 가짐&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;권한 데코레이터&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/auth/dependencies.py
from fastapi import Depends, HTTPException, status
from app.auth.security import verify_token

async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: AsyncSession = Depends(get_db),
) -&amp;gt; User:
    &quot;&quot;&quot;JWT 토큰에서 현재 사용자 조회&quot;&quot;&quot;
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail=&quot;Could not validate credentials&quot;,
        headers={&quot;WWW-Authenticate&quot;: &quot;Bearer&quot;},
    )

    try:
        # JWT 토큰 디코딩
        payload = jwt.decode(
            token,
            settings.SECRET_KEY,
            algorithms=[settings.ALGORITHM]
        )
        username: str = payload.get(&quot;sub&quot;)
        if username is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    # 사용자 조회
    result = await db.execute(select(User).where(User.username == username))
    user = result.scalar_one_or_none()

    if user is None:
        raise credentials_exception

    return user

def require_role(required_role: UserRole):
    &quot;&quot;&quot;특정 역할 이상 요구하는 의존성&quot;&quot;&quot;
    async def role_checker(
        current_user: User = Depends(get_current_user)
    ) -&amp;gt; User:
        if not RoleHierarchy.has_permission(current_user.role, required_role):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f&quot;Requires {required_role.value} role or higher&quot;
            )
        return current_user

    return role_checker&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 엔드포인트에 권한 적용&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/routers/stocks.py
from fastapi import APIRouter, Depends
from app.auth.dependencies import require_role
from app.models.trading import UserRole

router = APIRouter(prefix=&quot;/api/stocks&quot;, tags=[&quot;stocks&quot;])

@router.get(&quot;/&quot;)
async def get_stocks(
    current_user: User = Depends(require_role(UserRole.viewer))  # Viewer 이상
):
    &quot;&quot;&quot;
    모든 종목 조회

    권한: Viewer 이상 (모든 사용자)
    &quot;&quot;&quot;
    return {&quot;stocks&quot;: [...]}

@router.post(&quot;/analyze&quot;)
async def analyze_stock(
    symbol: str,
    current_user: User = Depends(require_role(UserRole.analyst))  # Analyst 이상
):
    &quot;&quot;&quot;
    종목 분석 요청

    권한: Analyst 이상 (AI 비용 발생)
    &quot;&quot;&quot;
    return {&quot;analysis&quot;: {...}}

@router.post(&quot;/trade&quot;)
async def execute_trade(
    order_data: dict,
    current_user: User = Depends(require_role(UserRole.trader))  # Trader 이상
):
    &quot;&quot;&quot;
    거래 실행

    권한: Trader 이상 (실제 거래 실행)
    &quot;&quot;&quot;
    return {&quot;order_id&quot;: &quot;...&quot;}

@router.delete(&quot;/users/{user_id}&quot;)
async def delete_user(
    user_id: int,
    current_user: User = Depends(require_role(UserRole.admin))  # Admin만
):
    &quot;&quot;&quot;
    사용자 삭제

    권한: Admin 전용
    &quot;&quot;&quot;
    return {&quot;deleted&quot;: user_id}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Rate Limiting으로 무차별 공격 방어&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SlowAPI를 사용한 Rate Limiting&lt;/h3&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# app/auth/router.py
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

@router.post(&quot;/register&quot;)
@limiter.limit(&quot;5/minute&quot;)  # IP당 분당 5회 제한
async def register(...):
    ...

@router.post(&quot;/login&quot;)
@limiter.limit(&quot;5/minute&quot;)  # IP당 분당 5회 제한
async def login(...):
    ...

@router.post(&quot;/refresh&quot;)
@limiter.limit(&quot;10/minute&quot;)  # IP당 분당 10회 제한
async def refresh_token(...):
    ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Rate Limiting 전략:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;회원가입/로그인: 분당 5회 (무차별 대입 방지)&lt;/li&gt;
&lt;li&gt;토큰 갱신: 분당 10회 (정상 사용 허용)&lt;/li&gt;
&lt;li&gt;API 호출: 분당 60회 (서비스 악용 방지)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;효과:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Brute Force 공격 차단&lt;/li&gt;
&lt;li&gt;DDoS 공격 완화&lt;/li&gt;
&lt;li&gt;서버 리소스 보호&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;보안 로깅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구조화된 보안 로그&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# app/auth/router.py
import logging

logger = logging.getLogger(__name__)

def _security_log_extra(request: Request, **kwargs) -&amp;gt; dict:
    &quot;&quot;&quot;보안 로그 메타데이터&quot;&quot;&quot;
    return {
        &quot;client_ip&quot;: request.client.host if request.client else None,
        &quot;user_agent&quot;: request.headers.get(&quot;user-agent&quot;),
        **kwargs,
    }

@router.post(&quot;/login&quot;)
async def login(request: Request, ...):
    # ... 로그인 로직 ...

    # 성공 로그
    logger.info(
        f&quot;User '{user.username}' logged in successfully&quot;,
        extra=_security_log_extra(
            request,
            user_id=user.id,
            username=user.username,
            event=&quot;login_success&quot;
        )
    )

    # 실패 로그
    logger.warning(
        f&quot;Failed login attempt for username: {form_data.username}&quot;,
        extra=_security_log_extra(
            request,
            username=form_data.username,
            event=&quot;login_failed&quot;
        )
    )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;로그 예시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;timestamp&quot;: &quot;2025-01-15T10:30:45Z&quot;,
  &quot;level&quot;: &quot;INFO&quot;,
  &quot;message&quot;: &quot;User 'bob' logged in successfully&quot;,
  &quot;client_ip&quot;: &quot;192.168.1.100&quot;,
  &quot;user_agent&quot;: &quot;Mozilla/5.0...&quot;,
  &quot;user_id&quot;: 42,
  &quot;username&quot;: &quot;bob&quot;,
  &quot;event&quot;: &quot;login_success&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;보안 이벤트 추적:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로그인 성공/실패&lt;/li&gt;
&lt;li&gt;토큰 갱신&lt;/li&gt;
&lt;li&gt;권한 없는 접근 시도&lt;/li&gt;
&lt;li&gt;비정상적인 활동 패턴&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인증 흐름 테스트&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# tests/test_auth.py
import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_register_login_flow(client: AsyncClient):
    &quot;&quot;&quot;회원가입 &amp;rarr; 로그인 흐름 테스트&quot;&quot;&quot;

    # 1. 회원가입
    register_response = await client.post(
        &quot;/auth/register&quot;,
        json={
            &quot;email&quot;: &quot;test@example.com&quot;,
            &quot;username&quot;: &quot;testuser&quot;,
            &quot;password&quot;: &quot;SecurePass123!&quot;
        }
    )
    assert register_response.status_code == 201

    # 2. 로그인
    login_response = await client.post(
        &quot;/auth/login&quot;,
        data={
            &quot;username&quot;: &quot;testuser&quot;,
            &quot;password&quot;: &quot;SecurePass123!&quot;
        }
    )
    assert login_response.status_code == 200
    tokens = login_response.json()
    assert &quot;access_token&quot; in tokens
    assert &quot;refresh_token&quot; in tokens

    # 3. 인증된 요청
    access_token = tokens[&quot;access_token&quot;]
    protected_response = await client.get(
        &quot;/api/stocks&quot;,
        headers={&quot;Authorization&quot;: f&quot;Bearer {access_token}&quot;}
    )
    assert protected_response.status_code == 200

@pytest.mark.asyncio
async def test_invalid_credentials(client: AsyncClient):
    &quot;&quot;&quot;잘못된 인증 정보 테스트&quot;&quot;&quot;
    response = await client.post(
        &quot;/auth/login&quot;,
        data={
            &quot;username&quot;: &quot;nonexistent&quot;,
            &quot;password&quot;: &quot;wrongpassword&quot;
        }
    )
    assert response.status_code == 401

@pytest.mark.asyncio
async def test_unauthorized_access(client: AsyncClient):
    &quot;&quot;&quot;인증 없이 보호된 엔드포인트 접근&quot;&quot;&quot;
    response = await client.get(&quot;/api/stocks&quot;)
    assert response.status_code == 401  # Unauthorized

@pytest.mark.asyncio
async def test_role_based_access(client: AsyncClient):
    &quot;&quot;&quot;역할 기반 접근 제어 테스트&quot;&quot;&quot;

    # Viewer 계정으로 로그인
    login_response = await client.post(
        &quot;/auth/login&quot;,
        data={&quot;username&quot;: &quot;viewer&quot;, &quot;password&quot;: &quot;password&quot;}
    )
    viewer_token = login_response.json()[&quot;access_token&quot;]

    # 읽기 권한: 성공
    read_response = await client.get(
        &quot;/api/stocks&quot;,
        headers={&quot;Authorization&quot;: f&quot;Bearer {viewer_token}&quot;}
    )
    assert read_response.status_code == 200

    # 거래 권한: 실패 (Viewer는 거래 불가)
    trade_response = await client.post(
        &quot;/api/trade&quot;,
        headers={&quot;Authorization&quot;: f&quot;Bearer {viewer_token}&quot;},
        json={&quot;symbol&quot;: &quot;BTC&quot;, &quot;amount&quot;: 100}
    )
    assert trade_response.status_code == 403  # Forbidden&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Rate Limiting 테스트&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 로그인 5회 시도 (성공)
for i in {1..5}; do
  curl -X POST http://localhost:8000/auth/login \
    -d &quot;username=test&amp;amp;password=test&quot;
done

# 6번째 시도 (실패 - Rate Limit)
curl -X POST http://localhost:8000/auth/login \
  -d &quot;username=test&amp;amp;password=test&quot;
# &amp;rarr; 429 Too Many Requests&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 사용 예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 웹 브라우저에서 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프론트엔드 (JavaScript):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 로그인
async function login(username, password) {
  const response = await fetch('https://your-domain.com/auth/login', {
    method: 'POST',
    headers: {'Content-Type': 'application/x-www-form-urlencoded'},
    body: `username=${username}&amp;amp;password=${password}`
  });

  const data = await response.json();

  // Access Token은 메모리에 저장
  localStorage.setItem('access_token', data.access_token);

  // Refresh Token은 HttpOnly Cookie에 저장 (보안)
  // (백엔드에서 Set-Cookie 헤더로 설정)
}

// 인증된 API 요청
async function getStocks() {
  const token = localStorage.getItem('access_token');

  const response = await fetch('https://your-domain.com/api/stocks', {
    headers: {
      'Authorization': `Bearer ${token}`
    }
  });

  if (response.status === 401) {
    // Access Token 만료 &amp;rarr; Refresh Token으로 갱신
    await refreshToken();
    return getStocks();  // 재시도
  }

  return response.json();
}

// 토큰 갱신
async function refreshToken() {
  const refresh_token = getCookie('refresh_token');  // HttpOnly Cookie

  const response = await fetch('https://your-domain.com/auth/refresh', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({refresh_token})
  });

  const data = await response.json();
  localStorage.setItem('access_token', data.access_token);
}

// 로그아웃
async function logout() {
  const refresh_token = getCookie('refresh_token');

  await fetch('https://your-domain.com/auth/logout', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({refresh_token})
  });

  localStorage.removeItem('access_token');
  // Refresh Token은 서버에서 삭제됨
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 명령줄 (curl)에서 사용&lt;/h3&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;# 1. 로그인
curl -X POST https://your-domain.com/auth/login \
  -d &quot;username=bob&amp;amp;password=SecurePass123!&quot; \
  | jq -r '.access_token' &amp;gt; token.txt

# 2. 인증된 요청
ACCESS_TOKEN=$(cat token.txt)
curl https://your-domain.com/api/stocks \
  -H &quot;Authorization: Bearer $ACCESS_TOKEN&quot;

# 3. 토큰 갱신
REFRESH_TOKEN=$(cat refresh_token.txt)
curl -X POST https://your-domain.com/auth/refresh \
  -H &quot;Content-Type: application/json&quot; \
  -d &quot;{\&quot;refresh_token\&quot;: \&quot;$REFRESH_TOKEN\&quot;}&quot; \
  | jq -r '.access_token' &amp;gt; token.txt

# 4. 로그아웃
curl -X POST https://your-domain.com/auth/logout \
  -H &quot;Content-Type: application/json&quot; \
  -d &quot;{\&quot;refresh_token\&quot;: \&quot;$REFRESH_TOKEN\&quot;}&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;환경 변수 설정&lt;/h2&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# JWT 설정
SECRET_KEY=your-super-secret-key-change-this-in-production!  # 반드시 변경!
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7

# Rate Limiting
RATELIMIT_ENABLED=true
RATELIMIT_STORAGE_URL=redis://localhost:6379/0

# Redis (토큰 저장)
REDIS_URL=redis://localhost:6379/0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;보안 주의사항:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;SECRET_KEY&lt;/code&gt;는 반드시 강력한 무작위 문자열로 변경
&lt;pre class=&quot;perl&quot;&gt;&lt;code&gt;# 안전한 SECRET_KEY 생성
openssl rand -hex 32&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;프로덕션에서는 환경 변수로 주입 (코드에 하드코딩 금지)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;보안 체크리스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;필수 보안 사항&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;비밀번호 해싱&lt;/b&gt;: bcrypt 사용 (평문 저장 절대 금지)&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;JWT 서명&lt;/b&gt;: SECRET_KEY로 토큰 서명 (위조 방지)&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;HTTPS&lt;/b&gt;: 모든 통신 암호화 (Caddy 자동 HTTPS)&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;Rate Limiting&lt;/b&gt;: 무차별 대입 공격 방어&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;Refresh Token 저장&lt;/b&gt;: Redis에 저장하여 강제 로그아웃 가능&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;Role-Based Access Control&lt;/b&gt;: 최소 권한 원칙&lt;/li&gt;
&lt;li&gt;&lt;input checked=&quot;checked&quot; disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;보안 로깅&lt;/b&gt;: 의심스러운 활동 추적&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;추가 보안 강화&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;2FA (Two-Factor Authentication)&lt;/b&gt;: OTP 추가 인증&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;IP Whitelisting&lt;/b&gt;: 특정 IP만 접근 허용&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;API Key&lt;/b&gt;: 서비스 간 인증&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;CORS 설정&lt;/b&gt;: 허용된 도메인만 요청 허용&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;SQL Injection 방어&lt;/b&gt;: SQLAlchemy ORM 사용 (자동 방어)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;disabled&quot; type=&quot;checkbox&quot; /&gt; &lt;b&gt;XSS 방어&lt;/b&gt;: 입력 값 검증 및 이스케이프&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배운 교훈&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 인증 시스템 구축을 통해 가장 크게 배운 점:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;보안은 선택이 아니라 필수다&quot;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;로컬에서만 쓸 건데 굳이 인증이 필요한가?&quot;라고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 인터넷에 배포하는 순간:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;무작위 IP에서 API 호출 시도&lt;/li&gt;
&lt;li&gt;알 수 없는 사용자의 무차별 대입 공격&lt;/li&gt;
&lt;li&gt;서비스 악용으로 인한 비용 폭탄&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 것을 경험하면서 &lt;b&gt;보안의 중요성&lt;/b&gt;을 뼈저리게 느꼈습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JWT vs Session 비교&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 JWT를 선택했나?&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특성&lt;/th&gt;
&lt;th&gt;JWT&lt;/th&gt;
&lt;th&gt;Session&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;상태&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Stateless (서버에 저장 안 함)&lt;/td&gt;
&lt;td&gt;Stateful (서버에 세션 저장)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;확장성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;높음 (서버 간 공유 불필요)&lt;/td&gt;
&lt;td&gt;낮음 (세션 공유 필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;성능&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;빠름 (DB 조회 불필요)&lt;/td&gt;
&lt;td&gt;느림 (매 요청마다 DB 조회)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;보안&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;토큰 탈취 시 위험&lt;/td&gt;
&lt;td&gt;서버에서 즉시 무효화 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;크기&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;큰 편 (모든 정보 포함)&lt;/td&gt;
&lt;td&gt;작음 (ID만 저장)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;우리의 선택:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JWT의 장점 (Stateless, 확장성)&lt;/li&gt;
&lt;li&gt;Session의 장점 (강제 로그아웃)&lt;/li&gt;
&lt;li&gt;&amp;rarr; &lt;b&gt;하이브리드&lt;/b&gt;: JWT + Redis (Refresh Token 저장)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실전에서 체감한 효과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Before (인증 없음):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[새벽 3시] 알 수 없는 IP에서 AI 분석 API 1000회 호출
&amp;rarr; Google Gemini API 비용 $50 발생  
&amp;rarr; 누가 호출했는지 모름
&amp;rarr; 차단 방법 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;After (JWT 인증 + Rate Limiting):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;[새벽 3시] 알 수 없는 IP에서 로그인 시도
&amp;rarr; Rate Limiting: 분당 5회 제한
&amp;rarr; 5번 실패 후 차단
&amp;rarr; 보안 로그 기록
&amp;rarr; 비용 발생 없음 ✅&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;비용 절감 효과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인증 시스템 구현 전후:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;AI API 비용&lt;/b&gt;: $100/월 &amp;rarr; $20/월 (80% 절감)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버 리소스&lt;/b&gt;: CPU 80% &amp;rarr; 20% (정상 사용자만)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;관리 시간&lt;/b&gt;: 주 5시간 &amp;rarr; 주 1시간 (자동화)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 우리의 자동매매 시스템은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ 데이터 수집 (한투/Upbit/yfinance)&lt;/li&gt;
&lt;li&gt;✅ AI 분석 (Gemini)&lt;/li&gt;
&lt;li&gt;✅ DB 저장 및 정규화&lt;/li&gt;
&lt;li&gt;✅ 웹 대시보드&lt;/li&gt;
&lt;li&gt;✅ 모니터링 (Grafana Stack)&lt;/li&gt;
&lt;li&gt;✅ 프로덕션 배포 (HTTPS + 24시간)&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;JWT 인증 + RBAC&lt;/b&gt; &amp;larr; 완성!&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;추가로 고려할 수 있는 기능:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OAuth 2.0 소셜 로그인 (Google, GitHub)&lt;/li&gt;
&lt;li&gt;2FA (TOTP, SMS)&lt;/li&gt;
&lt;li&gt;API Key 관리 (서비스 간 인증)&lt;/li&gt;
&lt;li&gt;Webhook 인증&lt;/li&gt;
&lt;li&gt;감사 로그 대시보드&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://jwt.io/&quot;&gt;JWT 공식 사이트&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fastapi.tiangolo.com/tutorial/security/&quot;&gt;FastAPI Security 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://owasp.org/www-project-top-ten/&quot;&gt;OWASP Top 10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/pyca/bcrypt/&quot;&gt;bcrypt 라이브러리&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/laurents/slowapi&quot;&gt;SlowAPI (Rate Limiting)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/mgh3326/auto_trader&quot;&gt;전체 프로젝트 코드 (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/mgh3326/auto_trader/pull/76&quot;&gt;PR #76: JWT Authentication System&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Programming/Python</category>
      <category>access token</category>
      <category>api 보안</category>
      <category>FastAPI 보안</category>
      <category>JWT</category>
      <category>jwt 인증</category>
      <category>Rate limiting</category>
      <category>RBAC</category>
      <category>Redis 토큰 관리</category>
      <category>refresh token</category>
      <category>백엔드 개발</category>
      <author>kwanghyun</author>
      <guid isPermaLink="true">https://mgh3326.tistory.com/235</guid>
      <comments>https://mgh3326.tistory.com/235#entry235comment</comments>
      <pubDate>Sun, 23 Nov 2025 21:45:07 +0900</pubDate>
    </item>
  </channel>
</rss>