<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>hyang_do.log</title>
        <link>https://velog.io/</link>
        <description>개발자 희망생</description>
        <lastBuildDate>Sat, 02 Aug 2025 13:39:15 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>hyang_do.log</title>
            <url>https://velog.velcdn.com/images/hyang_do/profile/8d90332e-9b7f-43c0-8da8-1a234ab5dc5a/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. hyang_do.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hyang_do" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Hybrid Cloud Architecture란? 단순히 퍼블릭+프라이빗이 아니다]]></title>
            <link>https://velog.io/@hyang_do/Hybrid-Cloud-Architecture%EB%9E%80-%EB%8B%A8%EC%88%9C%ED%9E%88-%ED%8D%BC%EB%B8%94%EB%A6%AD%ED%94%84%EB%9D%BC%EC%9D%B4%EB%B9%97%EC%9D%B4-%EC%95%84%EB%8B%88%EB%8B%A4</link>
            <guid>https://velog.io/@hyang_do/Hybrid-Cloud-Architecture%EB%9E%80-%EB%8B%A8%EC%88%9C%ED%9E%88-%ED%8D%BC%EB%B8%94%EB%A6%AD%ED%94%84%EB%9D%BC%EC%9D%B4%EB%B9%97%EC%9D%B4-%EC%95%84%EB%8B%88%EB%8B%A4</guid>
            <pubDate>Sat, 02 Aug 2025 13:39:15 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-하이브리드-클라우드를-쓰게-되었을까">왜 하이브리드 클라우드를 쓰게 되었을까?</h2>
<p>최근 많은 기업들이 클라우드로 전환하고 있다.<br>하지만 완전히 퍼블릭 클라우드로 넘어가기도 어렵고,<br>온프레미스(내부 서버)만 고수하기엔 유연성이 부족하다.</p>
<p>특히 다음과 같은 상황에서는 <strong>단일 클라우드 구조가 한계를 가진다.</strong></p>
<ul>
<li><strong>기존 레거시 시스템과의 연계가 필수</strong></li>
<li><strong>금융, 의료 등 민감 데이터를 외부로 보내면 안 되는 경우</strong></li>
<li><strong>비용은 아끼고 싶지만, 확장성도 챙기고 싶은 경우</strong></li>
</ul>
<p>그래서 등장한 게 바로 <strong>Hybrid Cloud Architecture</strong>다.</p>
<hr>
<h2 id="hybrid-cloud란">Hybrid Cloud란?</h2>
<blockquote>
<p>퍼블릭 클라우드 + 프라이빗 클라우드 + 온프레미스(자체 서버)를 조합한 인프라 구조</p>
</blockquote>
<p>각 환경은 API나 VPN, 메시 네트워크 등을 통해 서로 연결되며,<br>필요에 따라 서로 워크로드를 넘기거나 리소스를 공유할 수 있다.</p>
<hr>
<h2 id="구조-예시-한눈에-보는-하이브리드-클라우드">구조 예시: 한눈에 보는 하이브리드 클라우드</h2>
<p><img src="https://velog.velcdn.com/images/hyang_do/post/a2d3ade0-5300-46a5-aab5-ea608b4fd444/image.png" alt="">
출처: <a href="https://www.tierpoint.com/blog/hybrid-cloud-networking/">https://www.tierpoint.com/blog/hybrid-cloud-networking/</a></p>
<hr>
<h2 id="구성-요소-설명">구성 요소 설명</h2>
<h3 id="✅-1-on-premise--data-center">✅ 1. On-Premise / Data Center</h3>
<ul>
<li>내부 데이터베이스, 레거시 시스템, ERP 등 여전히 중요한 서비스들이 여기에 있음</li>
<li>보안, 규제 대응 측면에서 여전히 필수적</li>
</ul>
<h3 id="✅-2-public-cloud">✅ 2. Public Cloud</h3>
<ul>
<li>AWS, GCP, Azure 등 확장성과 탄력성을 보장하는 외부 클라우드</li>
<li>실험적 기능, AI/ML, 대용량 연산 등은 여기에 배포하는 것이 유리함</li>
</ul>
<h3 id="✅-3-private-cloud">✅ 3. Private Cloud</h3>
<ul>
<li>자체 클라우드 인프라 (OpenStack, VMware 등)</li>
<li>퍼블릭보다 보안이 강화된 클라우드 환경</li>
<li>민감한 데이터를 이쪽에만 보관하도록 분리 가능</li>
</ul>
<hr>
<h2 id="실제-상황-예시">실제 상황 예시</h2>
<blockquote>
<p>예를 들어, 한 금융 기업의 아키텍처를 보자.</p>
</blockquote>
<table>
<thead>
<tr>
<th>서비스 영역</th>
<th>배포 위치</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>로그인 / 인증</td>
<td>온프레미스</td>
<td>내부 보안 규정 때문</td>
</tr>
<tr>
<td>머신러닝 추천</td>
<td>퍼블릭 클라우드 (GCP)</td>
<td>GPU 자원이 필요하고 유연한 확장 필요</td>
</tr>
<tr>
<td>계정정보 관리</td>
<td>프라이빗 클라우드</td>
<td>데이터 주권 관련 법령 대응</td>
</tr>
</tbody></table>
<hr>
<h2 id="다른-아키텍처와-뭐가-다른가">다른 아키텍처와 뭐가 다른가?</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>온프레미스</th>
<th>퍼블릭 클라우드</th>
<th><strong>하이브리드 클라우드</strong></th>
</tr>
</thead>
<tbody><tr>
<td>보안</td>
<td>매우 강함</td>
<td>상대적으로 낮음</td>
<td>민감 정보는 내부에서 보호 가능</td>
</tr>
<tr>
<td>확장성</td>
<td>낮음</td>
<td>매우 유연함</td>
<td>필요한 영역만 퍼블릭으로 확장</td>
</tr>
<tr>
<td>운영 복잡도</td>
<td>낮음</td>
<td>중간</td>
<td>높음 (조정 관리 필요)</td>
</tr>
<tr>
<td>초기 비용</td>
<td>높음</td>
<td>낮음</td>
<td>중간 (기존 인프라 재활용 가능)</td>
</tr>
<tr>
<td>규제 대응</td>
<td>용이</td>
<td>어려움</td>
<td>상황에 맞게 대응 가능</td>
</tr>
<tr>
<td>대표 예시</td>
<td>정부기관, 병원</td>
<td>스타트업, 온라인 서비스</td>
<td>금융, 대기업, 공공기관</td>
</tr>
</tbody></table>
<hr>
<h2 id="단점은-없을까">단점은 없을까?</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>복잡한 아키텍처</strong></td>
<td>클라우드 종류가 많아질수록 설계와 운영 복잡도 증가</td>
</tr>
<tr>
<td><strong>네트워크 연결 이슈</strong></td>
<td>퍼블릭-프라이빗 간 안정적 통신을 위해 VPN, MPLS 등의 구성 필요</td>
</tr>
<tr>
<td><strong>운영 자동화 어려움</strong></td>
<td>CI/CD, 모니터링, 비용 추적 도구를 각각 통합해야 할 수 있음</td>
</tr>
</tbody></table>
<hr>
<h2 id="✅-그래서-언제-쓰면-좋을까">✅ 그래서 언제 쓰면 좋을까?</h2>
<blockquote>
<p>다음 중 하나라도 해당된다면 하이브리드 클라우드는 꽤 좋은 선택일 수 있다:</p>
</blockquote>
<ul>
<li>데이터 위치나 처리 방식에 대한 법적 제약이 있는 경우 (예: GDPR, 국내 금융 규제)</li>
<li>내부 서버를 전부 버릴 순 없지만, 새로운 서비스는 클라우드에서 빠르게 만들고 싶은 경우</li>
<li>트래픽이 몰리는 시점에만 확장하고 싶은 경우 (→ 퍼블릭으로 오토스케일)</li>
</ul>
<hr>
<h2 id="요약-정리">요약 정리</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>하이브리드 정의</td>
<td>퍼블릭 + 프라이빗 + 온프레미스를 조합한 인프라 구조</td>
</tr>
<tr>
<td>장점</td>
<td>유연한 리소스 활용, 보안성 확보, 기존 인프라 재활용</td>
</tr>
<tr>
<td>단점</td>
<td>운영 복잡도, 비용 추적 어려움, 인력 역량 필요</td>
</tr>
<tr>
<td>도입 추천 상황</td>
<td>금융/공공기관, 레거시 시스템 존재, 유연성+보안 둘 다 필요한 경우</td>
</tr>
</tbody></table>
<hr>
<h2 id="마무리">마무리</h2>
<p>하이브리드 클라우드는 단순히 &quot;두 개 다 쓰자&quot;가 아니라,<br><strong>&quot;어떤 자산은 안에서 지키고, 어떤 기능은 밖으로 확장하자&quot;</strong>는 전략이다.</p>
<p>클라우드를 도입하면서도,<br>내부 시스템을 포기할 수 없는 조직이라면,<br><strong>가장 현실적인 절충안</strong>이 바로 이 하이브리드 클라우드 구조다. 다음 프로젝트에서 위 구조를 </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[email callback 처리 정리]]></title>
            <link>https://velog.io/@hyang_do/email-callback-%EC%B2%98%EB%A6%AC-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@hyang_do/email-callback-%EC%B2%98%EB%A6%AC-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Thu, 24 Jul 2025 14:24:45 GMT</pubDate>
            <description><![CDATA[<p>API 요청이 실패했을 때 아무런 안내 없이 사용자에게 500 에러를 던지는 건 친절하지 않다.<br><strong>실제 요청한 사용자의 이메일로 실패 이유를 직접 안내하는 구조</strong>를 아래와 같이 구현했다.</p>
<hr>
<h2 id="인증-사용자-기반-이메일-전송-구조">인증 사용자 기반 이메일 전송 구조</h2>
<h3 id="컨트롤러-예시">컨트롤러 예시</h3>
<pre><code class="language-java">@GetMapping(&quot;/brave/search&quot;)
public ResponseEntity&lt;ApiResponse&lt;BraveSearchResponseDto&gt;&gt; searchBrave(
    @RequestParam String query,
    @AuthenticationPrincipal CustomUserPrincipal customUserPrincipal) {

    String email = customUserPrincipal.getEmail();
    BraveSearchResponseDto result = braveSearchService.search(query, email);
    return ResponseEntity.ok(ApiResponse.ok(result));
}</code></pre>
<p><code>@AuthenticationPrincipal</code>을 통해 로그인한 사용자의 이메일을 추출하고,<br>그 이메일을 <code>BraveSearchService.search(query, email)</code>에 전달한다.</p>
<hr>
<h2 id="💣-bravesearchservice---실패-시-사용자-이메일로-알림">💣 BraveSearchService - 실패 시 사용자 이메일로 알림</h2>
<pre><code class="language-java">public BraveSearchResponseDto search(String query, String email) {
    HttpHeaders headers = new HttpHeaders();
    headers.set(&quot;Accept&quot;, &quot;application/json&quot;);
    headers.set(&quot;X-Subscription-Token&quot;, braveSearchProperties.getNextKey());

    HttpEntity&lt;Void&gt; requestEntity = new HttpEntity&lt;&gt;(headers);
    String url = braveSearchProperties.getUrl() + &quot;?q=&quot; + query + &quot;&amp;count=5&quot;;

    try {
        ResponseEntity&lt;BraveSearchResponseDto&gt; response = restTemplate.exchange(
                url, HttpMethod.GET, requestEntity, BraveSearchResponseDto.class);
        return response.getBody();
    } catch (Exception e) {
        notifyCallback(email, &quot;Brave Search 요청 실패: &quot; + e.getMessage());
        throw e;
    }
}</code></pre>
<hr>
<h2 id="실패-알림-콜백-전송-메서드">실패 알림 콜백 전송 메서드</h2>
<pre><code class="language-java">private void notifyCallback(String email, String reason) {
    Map&lt;String, String&gt; body = Map.of(&quot;email&quot;, email, &quot;reason&quot;, reason);

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_JSON);

    HttpEntity&lt;Map&lt;String, String&gt;&gt; request = new HttpEntity&lt;&gt;(body, headers);

    try {
        restTemplate.postForEntity(&quot;http://localhost:8081/internal/failure-callback&quot;, request, Void.class);
    } catch (Exception ex) {
        System.err.println(&quot;콜백 전송 실패: &quot; + ex.getMessage());
    }
}</code></pre>
<hr>
<h2 id="콜백-수신-서버-구성">콜백 수신 서버 구성</h2>
<h3 id="콜백-요청-수신-컨트롤러">콜백 요청 수신 컨트롤러</h3>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/internal&quot;)
public class CallbackController {

    private final CallbackMailService callbackMailService;

    @PostMapping(&quot;/failure-callback&quot;)
    public ResponseEntity&lt;Void&gt; receiveFailureCallback(@RequestBody FailureCallbackRequest request) {
        callbackMailService.sendFailureEmail(request.email(), request.reason());
        return ResponseEntity.ok().build();
    }
}</code></pre>
<h3 id="콜백-request-record">콜백 Request record</h3>
<pre><code class="language-java">public record FailureCallbackRequest(String email, String reason) {}</code></pre>
<hr>
<h2 id="사용자-이메일로-실패-안내-전송">사용자 이메일로 실패 안내 전송</h2>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class CallbackMailService {

    private final JavaMailSender mailSender;

    public void sendFailureEmail(String to, String reason) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(to);
        message.setSubject(&quot;[DevMountain] 요청 실패 안내&quot;);
        message.setText(reason);
        mailSender.send(message);
    }
}</code></pre>
<hr>
<h2 id="이메일-전송-설정-applicationyml">이메일 전송 설정 (application.yml)</h2>
<pre><code class="language-yaml">spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: your-email@gmail.com
    password: your-app-password
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: true</code></pre>
<hr>
<h2 id="✅-마무리-요약">✅ 마무리 요약</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>실패 감지 위치</td>
<td>BraveSearchService에서 직접 감지</td>
</tr>
<tr>
<td>알림 대상</td>
<td>요청을 보낸 인증 사용자</td>
</tr>
<tr>
<td>메일 전송 시점</td>
<td>API 호출 실패 시 즉시 콜백</td>
</tr>
<tr>
<td>포맷</td>
<td>사용자 이메일 + 실패 사유 전송</td>
</tr>
<tr>
<td>장점</td>
<td>사용자 입장에서 무응답보다 훨씬 낫고, 시스템 신뢰도 향상</td>
</tr>
</tbody></table>
<hr>
<h2 id="실제-적용-예시-흐름">실제 적용 예시 흐름</h2>
<ol>
<li>로그인된 사용자 A가 <code>/brave/search?q=Spring 강의</code> 요청</li>
<li>BraveSearchService에서 Brave API 호출 실패</li>
<li>Callback 서버로 A의 이메일과 실패 사유 전달</li>
<li>A에게 &quot;[DevMountain] 요청 실패 안내&quot; 메일 전송</li>
</ol>
<hr>
<p>이렇게 구성하면 서비스 운영 신뢰도가 확실히 높아진다. 피드백 내용에 맞춰 실패할 경우에 예외 처리 사항으로 구현하면 될 듯 하다. 지금까지 구현을 중심으로만 생각한 자신에게 좀 더 생각해 볼 여지를 만들 수 있었다. 기본적인 기능이지만 사용자 중심 대응으로 매우 유의미한 개선이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[피드백 정리]]></title>
            <link>https://velog.io/@hyang_do/%ED%94%BC%EB%93%9C%EB%B0%B1-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@hyang_do/%ED%94%BC%EB%93%9C%EB%B0%B1-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Tue, 15 Jul 2025 13:09:48 GMT</pubDate>
            <description><![CDATA[<ol>
<li>재시도 / 예외 처리 기술</li>
</ol>
<p>목표: 네트워크 지연, 429 오류 등 발생 시 요청 유실 없이 재시도 및 복구</p>
<p>문제    추천 기술    설명
재시도    resilience4j-retry    예외 발생 시 자동 재시도 (@Retry 어노테이션 사용 가능)
백오프 재시도    Spring Retry + Exponential Backoff    실패 시 일정 간격 증가시키며 재시도
실패 저장    Redis, Kafka, DB 로그 테이블    실패 요청을 큐 또는 로그 테이블에 저장 후 나중에 재처리
비동기 재처리    Spring @Async, Scheduler    실패 요청은 별도 비동기 태스크로 재시도 또는 알림 전송
알림    Slack Webhook / Email 발송    일정 실패율 초과 시 관리자 알림</p>
<ol start="2">
<li>순차적 처리 / 초당 허용량 제한 (Rate Limit 대응)</li>
</ol>
<p>목적    추천 기술    설명
Rate Limit 제한    resilience4j-ratelimiter    초당 요청 제한을 구성 (10 req/sec 등)
Queue 기반 처리    Redis Queue, Kafka, RabbitMQ    요청을 큐에 적재 후, 초당 5개씩 꺼내 처리
스케줄러 처리    Spring @Scheduled(fixedRate = 200)    0.2초 간격으로 요청 처리하여 초당 5개 유지
분산 처리    Redis + Lua Script    분산 환경에서도 초당 처리 수 제어 가능 (SETNX + TTL 등 활용)</p>
<ol start="3">
<li>대기열 서비스 (Queue) 기술</li>
</ol>
<p>목적    추천 기술    설명
대기열 구현    Kafka (추천), Redis List, RabbitMQ    고속 대기열 시스템 (다수의 요청을 적재 후 처리)
순차 소비    Kafka Consumer, Redis Consumer    초당 처리량을 제한하면서 순차적으로 요청 처리
모니터링    Prometheus + Grafana    현재 큐 상태, 소비 속도 등을 실시간 모니터링</p>
<p>단순한 구조면 Redis List → BRPOP / LPOP 로 구현하는 것도 가능</p>
<ol start="4">
<li>배치 중복 실행 방지</li>
</ol>
<p>문제    추천 기술    설명
동시 실행 방지    Redis SETNX, DB Lock (FOR UPDATE)    배치 시작 시 Lock을 설정, 중복 실행 방지
Spring Batch 병렬 처리    Spring Batch + TaskExecutor or Partitioning    멀티스레드 또는 파티셔닝 처리 시 설정 필요
중복 데이터 제거    Set 저장, DISTINCT, GROUP BY    데이터 레벨 중복 제거
상태 저장    BatchJobStatus Table or Redis Flag    배치 상태를 기록하고 중복 시작 방지</p>
<ol start="5">
<li>API 응답 지연 최적화 기술</li>
</ol>
<p>문제    추천 기술    설명
병렬 요청 처리    CompletableFuture, Spring @Async, WebClient    여러 외부 API 병렬로 호출하여 응답 지연 최소화
타임아웃 제어    WebClient / RestTemplate timeout 설정    느린 응답 차단 및 빠른 실패 유도
캐싱    Redis Cache, Caffeine    동일 요청에 대해 캐시 응답 제공
응답 지연 감시    Spring Actuator + Micrometer + Prometheus    API 응답 시간 지표 수집, SLA 분석 가능</p>
<ol start="6">
<li>예측 방어 설계 및 실패 보완 기술</li>
</ol>
<p>문제    기술    설명
Fallback 처리    resilience4j-circuitbreaker, fallbackMethod    실패 시 다른 API나 캐시 응답으로 전환
비동기 메시지 처리    Kafka, Redis Stream    요청을 큐에 넣고 나중에 처리
알림 시스템    Slack Webhook, Email, SMS 연동    문제 발생 시 실시간 알림 전송
부하 테스트    k6, nGrinder, JMeter    실 환경 유사 부하 테스트로 병목 파악</p>
<p>요약표</p>
<p>목적    기술 이름
재시도    Resilience4j Retry, Spring Retry
RateLimit 제어    Resilience4j RateLimiter, Redis
요청 대기열 처리    Kafka, Redis Queue, RabbitMQ
배치 중복 방지    Redis SETNX, DB Lock
병렬 API 호출    CompletableFuture, WebClient
응답 캐싱    Redis, Caffeine
장애 대응    CircuitBreaker, FallbackMethod
알림    Slack Webhook, Email, Line Notify
응답 지연 모니터링    Prometheus + Grafana
실패 요청 저장    Redis, Kafka, RDB Log Table</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Devmountain을 위한 Brave 키 발급 방법 안내]]></title>
            <link>https://velog.io/@hyang_do/Devmountain%EC%9D%84-%EC%9C%84%ED%95%9C-Brave-%ED%82%A4-%EB%B0%9C%EA%B8%89-%EB%B0%A9%EB%B2%95-%EC%95%88%EB%82%B4</link>
            <guid>https://velog.io/@hyang_do/Devmountain%EC%9D%84-%EC%9C%84%ED%95%9C-Brave-%ED%82%A4-%EB%B0%9C%EA%B8%89-%EB%B0%A9%EB%B2%95-%EC%95%88%EB%82%B4</guid>
            <pubDate>Thu, 03 Jul 2025 00:35:08 GMT</pubDate>
            <description><![CDATA[<h1 id="브레이브-검색-api-키-발급-가이드">브레이브 검색 API 키 발급 가이드</h1>
<p>이 글에서는 Devmountain에서 키를 발급 받아서 PRO 등급을 경험해보실 사용자 분들께 Brave Search API 키(Subscription Token)를 발급받는 과정을 단계별로 안내합니다.</p>
<hr>
<h2 id="🔎-소개">🔎 소개</h2>
<p>Brave Search API는 전 세계 웹 검색 결과를 손쉽게 활용할 수 있는 RESTful API입니다. AI 챗봇, 검색 기능, 데이터 분석 등 다양한 프로젝트에 통합하여 사용할 수 있습니다.</p>
<p><strong>주요 특징</strong></p>
<ul>
<li><strong>프라이버시 중심</strong>: 사용자 트래킹 없이 검색 결과 제공</li>
<li><strong>다양한 기능</strong>: 웹, 이미지, 요약(Summarizer) 등</li>
<li><strong>간단한 인증</strong>: <code>X-Subscription-Token</code> 헤더 방식</li>
</ul>
<hr>
<h2 id="🛠️-사전-준비">🛠️ 사전 준비</h2>
<hr>
<h2 id="1-회원가입sign-up">1. 회원가입(Sign up)</h2>
<h3 id="brave-search-api-페이지-방문-httpsbravecomkosearchapi">Brave Search API 페이지 방문: <a href="https://brave.com/ko/search/api/">https://brave.com/ko/search/api/</a></h3>
<p><img src="https://velog.velcdn.com/images/hyang_do/post/fd6e02ed-906f-4f40-ba90-e062577ff762/image.png" alt=""></p>
<ol>
<li>상단 메뉴에서 <strong><code>가입(Register)</code></strong> 클릭</li>
<li><strong>Register new account</strong> 폼에 아래 정보를 입력
<img src="https://velog.velcdn.com/images/hyang_do/post/96a8f7d7-383f-4f1b-8440-020f8f6db3b2/image.png" alt=""><ul>
<li><strong>Email</strong>: 사용하실 이메일 주소</li>
<li><strong>Password</strong>: 비밀번호</li>
<li><strong>Verify password</strong>: 비밀번호 확인 입력</li>
<li><strong>Full name</strong>: 이름</li>
<li><strong>Company name</strong>: (선택) 회사명</li>
<li><strong>Where did you hear about Brave Search API?</strong>: 해당 옵션 선택</li>
</ul>
</li>
<li>&quot;Register&quot; 버튼 클릭 후 이메일 인증 진행</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hyang_do/post/349fc5d6-634f-4ea1-a182-8ee08553cdf4/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyang_do/post/da467ab7-b92b-449c-9bdc-42c9d45e1ae2/image.png" alt=""></p>
<pre><code class="language-txt"># 입력 예시
Email: user@example.com
Password: ********
Full name: 홍길동
Company name: ABC Corp
Where did you hear about Brave Search API?: Other</code></pre>
<hr>
<h2 id="2-로그인login">2. 로그인(Login)</h2>
<ol>
<li>이메일 인증 완료 후, 다시 <strong><code>로그인(Login)</code></strong> 페이지로 이동</li>
<li><strong>Email</strong>과 <strong>Password</strong> 입력 후 로그인</li>
<li>다시 로그인을 위한 이메일 인증을 진행하면 끝!</li>
</ol>
<hr>
<h2 id="3-api-대시보드api-dashboard-접속">3. API 대시보드(API Dashboard) 접속</h2>
<ol>
<li>로그인 후 자동으로 API Dashboard로 이동하거나, 상단 메뉴의 <strong><code>Subscriptions</code></strong> 클릭</li>
<li>Data for Search에서 Free 플랜 구독!</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hyang_do/post/5c8dafb3-db30-4b9a-b32e-b80b0d2dae9b/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyang_do/post/53b01bc8-5b41-4b6b-b2e5-7c252b345f70/image.png" alt=""></p>
<p>카드 정보를 입력하셔도 비용이 나가진 않으니 안심하세요!</p>
<h3 id="구독-완료-후-api-key-발급">구독 완료 후 api key 발급!</h3>
<ol>
<li>좌측에 API Keys 클릭</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hyang_do/post/21567c29-7f18-45f3-a8e8-283ada075d54/image.png" alt=""></p>
<ol start="2">
<li><p>Add API Keys 클릭
<img src="https://velog.velcdn.com/images/hyang_do/post/b883025c-5ed1-4e25-9cbd-2cca655e42ce/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyang_do/post/549c04e9-d2d7-42f4-a775-4dfdc2593e3d/image.png" alt=""></p>
</li>
<li><p>api 키 복사하기!
<img src="https://velog.velcdn.com/images/hyang_do/post/da197675-6eda-407d-a7ea-54fd1ec1eeb6/image.png" alt="">
copy 버튼 클릭 후 저희들에게 주신다면 PRO 등급으로 업그레이드 해드립니다!</p>
</li>
</ol>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[외부 연동 서비스 리소스 제한 이슈 ]]></title>
            <link>https://velog.io/@hyang_do/%EB%B0%B0%ED%8F%AC-%EB%B0%8F-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91-key-%EC%9D%91%EB%8B%B5-%EC%A7%80%EC%97%B0-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@hyang_do/%EB%B0%B0%ED%8F%AC-%EB%B0%8F-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91-key-%EC%9D%91%EB%8B%B5-%EC%A7%80%EC%97%B0-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Tue, 01 Jul 2025 07:46:18 GMT</pubDate>
            <description><![CDATA[<h1 id="brave-search--youtube-api-key-순환-구조-설계">Brave Search &amp; YouTube API Key 순환 구조 설계</h1>
<h2 id="문제-상황">문제 상황</h2>
<p>많은 무료 API는 무료 플랜에 대해 요청 횟수 제한(Rate Limit)을 둔다. 대표적으로 YouTube Data API, Brave Search API 등은 하루, 혹은 초당 요청 횟수를 제한 하는 경우가 생긴다. 무료 유저로서는 슬픈 상황이다. 물론 지원 예산이 있긴 하지만 가능하면 이런 비용 절약 측면에서 설계를 하는 것도 좋다는 생각이 들어서 이번에 실험해보게 되었다.</p>
<ul>
<li>기존에는 <code>.env</code>나 <code>application.yml</code> 같은 설정 파일에 <strong>하나의 API 키만 고정</strong>해 사용하는 방식으로 인해 키가 만료되거나 한도에 도달하면 서버를 닫아야 한다는 점이다.</li>
<li>문제는 요청 한도를 초과하면 <code>429 Too Many Requests</code> 에러가 발생하고, 서버를 재시작하지 않으면 해결할 방법이 없다.</li>
</ul>
<p>예시 오류 로그:</p>
<pre><code class="language-json">{&quot;code&quot;:&quot;RATE_LIMITED&quot;,&quot;rate_limit&quot;:1,&quot;rate_current&quot;:1,&quot;quota_limit&quot;:2000,&quot;quota_current&quot;:111}</code></pre>
<p>이는 서비스 중단을 유발하며, 테스트나 운영 환경에서는 큰 문제가 될 것이다.</p>
<hr>
<h2 id="키-순환key-rotation-전략">키 순환(Key Rotation) 전략</h2>
<ul>
<li>YouTube 및 Brave Search API에는 하루 또는 초당 요청 한도가 존재
RateLimit으로 제한을 할수도 있지만 1초에 한 번 사용할 수 있으면 테스트나 배포 상황에서 요청에 대한 처리 속도가 너무 느리다. 따라서 key swapping을 통해 하나의 키를 사용할 때 생기는 제약을 넘는 기능을 만들 수 있다. 물론 유료로 결제하면 훨씬 좋겠지만 비용을 최대한 절약할 수 있으면 절약해서 프로젝트를 진행하는 것도 좋은 경험일 것 같다. 따라서 이번 프로젝트에서는 하나의 키가 아닌 여러 키를 순환하면서 사용하는 기능을 추가해볼 것이다.</li>
</ul>
<p>API 키가 여러 개 있을 때 이를 <strong>자동으로 순환</strong>해서 사용하는 방식이 필요하다. 가장 단순한 방법은 <strong>큐(Queue)</strong> 자료구조를 사용할 것이다</p>
<p>하지만 단순 JSON 파일이나 메모리 기반 큐는 <strong>서버 재시작 시 초기화</strong>되고, 설정 변경 시 반영이 어려우며, 운영 중 키를 수정하기 어려운 문제를 해결해 줄 좋은 툴이 있다.</p>
<hr>
<h2 id="apache-zookeeper란">Apache Zookeeper란?</h2>
<p>간단히 요약하자면 Zookeeper는 Apache 재단에서 제공하는 <strong>분산 환경을 위한 설정 관리 시스템</strong>이라고 한다.</p>
<p><a href="https://zookeeper.apache.org/">아파치 공식 사이트</a></p>
<h3 id="특징">특징</h3>
<ul>
<li>Znode라 불리는 트리 기반 Key-Value 저장소 제공</li>
<li>클러스터 기반으로 높은 가용성 보장</li>
<li>분산 락, 설정 동기화, Watcher(감시) 기능 제공</li>
</ul>
<h3 id="그래서-왜-씀">그래서 왜 씀?</h3>
<ul>
<li><p><strong>서버 재시작 없이 설정값 변경 가능 (중요!)</strong></p>
</li>
<li><p>여러 인스턴스 간에 설정을 공유할 수 있지만 지금 프로젝트에서 쓰이는 기능은 아니다</p>
</li>
<li><p>실시간 반영 및 감지 기능 제공 (Watcher) -&gt; 편리하다</p>
</li>
<li><p>관련 자료는 특별한 내용은 찾지는 못했지만 기존 세팅에 관해서는 <a href="https://sinna94.tistory.com/entry/ZooKeeper-%EC%99%80-Spring-Cloud-ZooKeeper">ZooKeeper 와 Spring Cloud ZooKeeper</a> 위 사이트를 참고했다.</p>
</li>
</ul>
<hr>
<h2 id="spring-cloud-zookeeper-키-순환-구조-상세-구현">Spring Cloud Zookeeper 키 순환 구조 상세 구현</h2>
<h3 id="buildgradle-설정">build.gradle 설정</h3>
<pre><code>ext {
    springCloudVersion = &quot;2025.0.0&quot;
}

dependencyManagement {
    imports {
        mavenBom &quot;org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}&quot;
    }
}

dependencies {
    // Zookeeper + Curator
    implementation &#39;org.springframework.cloud:spring-cloud-starter-zookeeper-config&#39;
    implementation &#39;org.apache.curator:curator-framework:5.5.0&#39;
    implementation &#39;org.apache.curator:curator-recipes:5.5.0&#39;
}
</code></pre><ul>
<li>이 설정이면 가능하다. 이전에 쓰던 버전이랑 호환성은 잘 체크해야 한다. 이 곳에서 내 프로젝트의 springboot version과 맞는 springcloud version을 찾을 수 있다.
<a href="https://spring.io/projects/spring-cloud">Spring Cloud 2025.0.0</a></li>
</ul>
<h3 id="docker-compose-설정">docker-compose 설정</h3>
<pre><code class="language-java">
  zookeeper:
    image: zookeeper:3.8
    ports:
        - &quot;2181:2181&quot;
    networks:
      - devmountain-net</code></pre>
<h3 id="zookeeper-설정-구성-zookeeperconfigjava">Zookeeper 설정 구성 (<code>ZookeeperConfig.java</code>)</h3>
<p>Zookeeper와의 연결을 설정하고, 설정된 키의 변경 사항을 실시간으로 감지하기 위한 Watcher를 등록하는 구성 파일</p>
<pre><code class="language-java">@Configuration
public class ZookeeperConfig {

    @Value(&quot;${spring.cloud.zookeeper.connect-string}&quot;)
    private String connectString;

    @Bean
    public CuratorFramework curatorFramework() {
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString(connectString)
                .retryPolicy(new ExponentialBackoffRetry(1000, 3))
                .build();
        client.start();
        return client;
    }

    @Bean(initMethod = &quot;registerWatcher&quot;)
    public BraveKeyWatcher braveKeyWatcher(CuratorFramework client, BraveSearchProperties properties) {
        return new BraveKeyWatcher(client, properties);
    }

    @Bean(initMethod = &quot;registerWatcher&quot;)
    public McpKeyWatcher mcpKeyWatcher(CuratorFramework client) {
        return new McpKeyWatcher(client);
    }
}</code></pre>
<ul>
<li><code>CuratorFramework</code>: Zookeeper 클라이언트를 생성하고 연결한다</li>
<li><code>BraveKeyWatcher</code>: 설정 변경을 감지하고 키 값을 실시간으로 갱신한다.</li>
<li><code>McpKeyWatcher</code>: MCP 서버 설정 변경을 감지하고 환경 변수를 갱신한 뒤 MCP 서버를 재시작</li>
</ul>
<hr>
<h3 id="api-키-속성-관리-bravesearchpropertiesjava">API 키 속성 관리 (<code>BraveSearchProperties.java</code>)</h3>
<p>Zookeeper로부터 Brave API 키를 가져와 관리하는 클래스. 키 값이 변경될 때 실시간으로 반영된다</p>
<pre><code class="language-java">@Getter
@Setter
@Component
@ConfigurationProperties(prefix = &quot;brave.search.api&quot;)
public class BraveSearchProperties {

    private String key;
}</code></pre>
<ul>
<li>Zookeeper의 <code>/config/devmountain/brave.search.api.key</code>에 연결된 값을 실시간으로 갱신하여 애플리케이션에 제공하여 처리하도록 한다.</li>
</ul>
<hr>
<h3 id="watcher를-통한-실시간-갱신-bravekeywatcherjava-mcpkeywatcherjava">Watcher를 통한 실시간 갱신 (<code>BraveKeyWatcher.java</code>, <code>McpKeyWatcher.java</code>)</h3>
<p>Zookeeper의 키 변경 이벤트를 감지하는 Watcher 클래스</p>
<p><strong>Brave Search API 키 갱신:</strong></p>
<pre><code class="language-java">@Slf4j
@RequiredArgsConstructor
public class BraveKeyWatcher implements CuratorWatcher {

    private final CuratorFramework client;
    private final BraveSearchProperties properties;

    public void registerWatcher() {
        try {
            client.getData().usingWatcher(this).forPath(&quot;/config/devmountain/brave.search.api.key&quot;);
        } catch (Exception e) {
            log.error(&quot;Failed to register watcher&quot;, e);
        }
    }

    @Override
    public void process(WatchedEvent event) {
        if (event.getType() == Event.EventType.NodeDataChanged) {
            try {
                byte[] newData = client.getData().usingWatcher(this).forPath(event.getPath());
                String newKey = new String(newData);
                properties.setKey(newKey);
                log.info(&quot;Brave Search API key updated: {}&quot;, newKey);
            } catch (Exception e) {
                log.error(&quot;Failed to update Brave API key&quot;, e);
            }
        }
    }
}</code></pre>
<p><strong>MCP 서버 환경 변수 갱신 및 재시작:</strong></p>
<pre><code class="language-java">@Slf4j
@RequiredArgsConstructor
public class McpKeyWatcher implements CuratorWatcher {

    private final CuratorFramework client;

    public void registerWatcher() {
        try {
            client.getData().usingWatcher(this).forPath(&quot;/config/devmountain/mcp.server.env&quot;);
        } catch (Exception e) {
            log.error(&quot;Failed to register watcher&quot;, e);
        }
    }

    @Override
    public void process(WatchedEvent event) {
        if (event.getType() == Event.EventType.NodeDataChanged) {
            try {
                byte[] newData = client.getData().usingWatcher(this).forPath(event.getPath());
                String newEnv = new String(newData);
                System.setProperty(&quot;MCP_SERVER_ENV&quot;, newEnv);
                restartMcpServer();
                log.info(&quot;MCP 서버 환경 변수 업데이트 및 서버 재시작: {}&quot;, newEnv);
            } catch (Exception e) {
                log.error(&quot;Failed to update MCP server environment&quot;, e);
            }
        }
    }

    private void restartMcpServer() {
        // MCP 서버 재시작 로직 구현
    }
}</code></pre>
<ul>
<li>Brave Search API 키는 실시간으로 업데이트한다.</li>
<li>MCP 서버는 환경 변수가 변경될 때마다 자동으로 재시작된다</li>
</ul>
<hr>
<h2 id="정리-및-주요-포인트">정리 및 주요 포인트</h2>
<h2 id="정리-및-주요-포인트-비교">정리 및 주요 포인트 비교</h2>
<table>
<thead>
<tr>
<th>비교 항목</th>
<th>이전 방식</th>
<th>변경된 방식</th>
</tr>
</thead>
<tbody><tr>
<td><strong>API 키 관리 위치</strong></td>
<td><code>.env</code> 또는 <code>application.yml</code>에 단일 키 고정</td>
<td>Zookeeper의 Znode(<code>/config/devmountain/...</code>)에 다중 키 저장 및 관리</td>
</tr>
<tr>
<td><strong>키 수</strong></td>
<td>하나</td>
<td>Queue 자료구조로 순환</td>
</tr>
<tr>
<td><strong>키 변경 반영</strong></td>
<td>키 변경 시 서버 재시작 필요</td>
<td>Watcher 감지 시 실시간 반영 (서버 재시작 불필요)</td>
</tr>
<tr>
<td><strong>Rate Limit 초과 시 대응</strong></td>
<td><code>429 Too Many Requests</code> 발생 → 서버 재시작 후 복구</td>
<td>현재 키 사용 한도 초과 시 즉각적으로 대응하여 키에서 제거</td>
</tr>
<tr>
<td><strong>MCP 서버 환경 변수 갱신</strong></td>
<td>환경 변수 수정 → 수동 재배포/재시작</td>
<td>Zookeeper Watcher 감지 → <code>System.setProperty</code> 후 자동 재시작 로직 실행</td>
</tr>
<tr>
<td><strong>가용성 및 확장성</strong></td>
<td>낮음 (단일 포인트 장애, 수동 관리)</td>
<td>높음 (분산 설정 관리, 자동화된 키 순환 및 감시)</td>
</tr>
<tr>
<td><strong>장애 대응 속도</strong></td>
<td>느림 (수동 확인 및 재시작)</td>
<td>빠름 (Watcher 이벤트 기반 자동 처리)</td>
</tr>
</tbody></table>
<ul>
<li><p><strong>실시간 키 갱신</strong>: Watcher가 Zookeeper 설정 변경을 즉각 감지하여 서버 재시작 없이 키 변경 가능</p>
</li>
<li><p><strong>MCP 서버 자동 관리</strong>: MCP 서버 환경 설정 변경 시 서버 자동 재시작</p>
</li>
<li><p><strong>Zookeeper 사용 이유</strong>:</p>
<ul>
<li>고가용성 및 높은 신뢰성</li>
<li>서버 재시작 없이 설정 변경 반영 가능</li>
<li>Watcher 기능으로 즉시 갱신</li>
</ul>
</li>
</ul>
<p>이 구조는 API Rate Limit 관리 및 환경 변수 관리에 매우 효과적이며, 운영 중 무중단 키 관리 및 설정 변경에 최적화할 수 있었다.</p>
<h3 id="소감">소감</h3>
<p>생각보다 Spring zookeeper 구현 자료들이 없어서 공식 자료를 참고를 많이 했다. 확실히 Zookeeper는 배포 단계에서 매우 유용한 설정이 될 것이다. 무엇보다 여러 인스턴스를 띄우면 이 진가가 잘 발휘될 것 같다. 확실히 이건 테스트를 해 볼 필요도 없는 명백한 기능 향상 방향이라 이전이랑 비교하는게 맞을지는 모르겠지만 정량적 지표를 사용하는게 좋다고 생각하니 나중에 표로 정리할 예정이다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DevMountain AI 프로젝트 연계 MCP + RAG 총정리]]></title>
            <link>https://velog.io/@hyang_do/DevMountain-AI-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%97%B0%EA%B3%84-MCP-RAG-%EC%B4%9D%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@hyang_do/DevMountain-AI-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%97%B0%EA%B3%84-MCP-RAG-%EC%B4%9D%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 23 Jun 2025 08:00:02 GMT</pubDate>
            <description><![CDATA[<p>튜터님에게 MVP 시연 후 피드백 들은 사항에 대해 정리한 뒤 내가 맡을 분야는 MCP 클라이언트 구축 후(MCP 서버는 추가), 유튜브 같은 영상 플랫폼(mcp 서버를 활용해 강의 데이터를 가져오기) 였다. MCP에 대한 개념을 확실히 알아둬야 하는 것도 좋고 요즘 핫한 ai 기능에 대한 경험이 있다는 것은 분명 신입 개발자로서 취업 전 해보기 좋은 프로젝트 과제라고 생각했다.</p>
<h2 id="1-사전-지식-공부">1. 사전 지식 공부</h2>
<p>시작하기 전 참고 자료들을 사용하기 위해서
<a href="https://www.youtube.com/watch?v=7oQYAcza3f8">https://www.youtube.com/watch?v=7oQYAcza3f8</a>
<a href="https://www.youtube.com/watch?v=w5YVHG1j3Co&amp;t=784s">https://www.youtube.com/watch?v=w5YVHG1j3Co&amp;t=784s</a>
<a href="https://www.youtube.com/watch?v=kXuRJXEzrE0">https://www.youtube.com/watch?v=kXuRJXEzrE0</a>
위 자료들을 참고 했다.</p>
<h2 id="2-개념-정리">2. 개념 정리</h2>
<p>우선은 사전 지식 및 작업들을 진행하고 mcp client에 대한 간단한 지식들을 정리했다. MCP(Model Context Protocal) 에 대한 정보는 다른 사이트에서도 많이 정리했지만 여기서 한 번 더 정리하자면 AI 모델과 외부 데이터(유튜브, 트위터, 노션 등등 다양한 데이터)를 표준화된 방식으로 연결하는 프로토콜이라고 한다. 즉, ai가 사용하기 쉬운 툴이라고 생각하면 되는 것이다.</p>
<h2 id="3-공식자료-및-강의-자료를-참고하여-작성">3. 공식자료 및 강의 자료를 참고하여 작성</h2>
<p><a href="https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html">https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html</a>
위 공식 자료를 참고하여 구현하려고 했는데 </p>
<pre><code class="language-java">implementation &#39;org.springframework.ai:spring-ai-mcp-client’</code></pre>
<p>이런 의존성을 설치했는데  
import org.springframework.ai 에서 가져올 수 있는 라이브러리가 없다는 것이었다. 확인해보니 
<a href="https://repo.spring.io/ui/native/milestone/org/springframework/ai/spring-ai-starter-mcp-client/">https://repo.spring.io/ui/native/milestone/org/springframework/ai/spring-ai-starter-mcp-client/</a> 
위 사이트에 내용이 전부 없는 깡통 의존성 패키지였다. 찾아보니 1.0.0 버전 별로 라이브러리 불러오는 것도 이름이 달라서 조심해야 한다.
<a href="https://repo.spring.io/ui/native/milestone/org/springframework/">https://repo.spring.io/ui/native/milestone/org/springframework/</a> 위 링크를 참고하고 milestone에서 있는 지 없는 지 무조건 확인한 다음에 넣어야 한다!</p>
<h3 id="✅-옳은-의존성-설치-m6-버전">✅ 옳은 의존성 설치 (M6 버전)</h3>
<pre><code>implementation &#39;org.springframework.ai:spring-ai-mcp-client-spring-boot-starter&#39;</code></pre><p><img src="https://velog.velcdn.com/images/hyang_do/post/1bc47255-efcd-494d-b6ef-c161218b2056/image.png" alt=""></p>
<ul>
<li>위 캡쳐본을 확인하면 성공적으로 됐다.
또한 이전 버전은 M4 버전 이었으므로 build 할 때 이전 pgvector 및 openai 라이브러리 import도 수정해야 한다.</li>
</ul>
<hr>
<h2 id="4-1-m4에서-m6로-버전-이동">4-1 M4에서 M6로 버전 이동</h2>
<h3 id="1-spring-ai-배포-버전-해결-m4-vs-m6-추가-문제">1. Spring AI 배포 버전 해결 (M4 vs M6 추가 문제)</h3>
<ul>
<li><strong>문제 상황</strong>: Gradle에서 spring-ai-mcp-client 버전을 M4에선 tool 설정으로 client 기능을 사용할 수 없다고 함. </li>
<li><strong>결정</strong>: 따라서 이전에 쓰던 M4 세팅을 M6으로 변경함. 이에 맞춰 빌드도 ./gradlew clean build --refresh-dependencies  이걸로 계속 빌드를 진행했다. (의존성 및 여러 오류가 많이 날 수도 있으니 그냥 의존성 원본을 올리자면...)</li>
</ul>
<pre><code class="language-groovy">ext {
        springAiVersion = &quot;1.0.0-M6&quot;
    }
dependencyManagement {
    imports {
        mavenBom &quot;org.springframework.ai:spring-ai-bom:${springAiVersion}&quot;
    }
}
dependencies {
    implementation &#39;org.springframework.ai:spring-ai-openai-spring-boot-starter&#39;
    implementation &#39;org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter&#39;
    implementation &#39;org.springframework.ai:spring-ai-mcp-client-spring-boot-starter&#39;
  }</code></pre>
<ul>
<li><strong>결론</strong>: 공식문서에서도 꼼꼼한 체크와 다른 블로그에서 어떤 방식으로 구현했는지 참고했다면 빠르게 진행이 가능했을 것 같다. (여기서 너무 시간을 잡아먹음) </li>
</ul>
<hr>
<h3 id="4-2-잘못된-자료-참고를-하고-실패한-내용들">4-2 잘못된 자료 참고를 하고 실패한 내용들</h3>
<h4 id="openaichatclient-클래스-인식-불가">OpenAIChatClient 클래스 인식 불가</h4>
<ul>
<li>import org.springframework.ai.chat.client.OpenAIChatClient 시 심볼을 인식 못 함</li>
<li>→ 원인: 최신 spring-ai 버전에서 ChatClient.create(...) 방식으로만 생성 가능</li>
</ul>
<h4 id="chatrequest--chatresponse-클래스가-없다는-오류">ChatRequest / ChatResponse 클래스가 없다는 오류</h4>
<ul>
<li>ChatClient는 .prompt().user().call() 등 DSL 방식으로만 사용해야 함</li>
<li>직접 객체 생성 방식은 Spring AI 구조에 맞지 않음</li>
</ul>
<hr>
<h2 id="5-사용하는-mcp-서버zubeid-youtube-mcp-server">5. 사용하는 MCP 서버(zubeid-youtube-mcp-server)</h2>
<ul>
<li>우리가 사용하려는 서버는 <a href="https://lobehub.com/ko/mcp/zubeidhendricks-youtube-mcp-server?activeTab=deployment">https://lobehub.com/ko/mcp/zubeidhendricks-youtube-mcp-server?activeTab=deployment</a> 위 링크의 서버를 사용할 것이다</li>
<li><em>유의할 점*</em>: 지금까지 학습한 MCP 서버는 Spring 용인데 이번에 사용할 것은 node.js라서 사용할 때 유의하며 진행해야 했다</li>
</ul>
<p>✅ 현재 사용하는 YouTube MCP 구조</p>
<ul>
<li>MCP 서버: npx zubeid-youtube-mcp-server로 실행되는 Node.js 애플리케이션<pre><code>npx -y zubeid-youtube-mcp-server # 위 커맨드로 실행 자세한 사용법은 링크 참고!</code></pre></li>
<li>Spring 통합 방식: spring.ai.mcp.client.stdio.connections.youtube 설정으로 STDIO 연결<pre><code>spring:
ai:
  mcp:
    client:
      type: SYNC
      stdio:
        root-change-notification: true
        connections:
          youtube:
            command: npx
            args:
              - -y
              - zubeid-youtube-mcp-server
            env:
              YOUTUBE_API_KEY: 실제 API키 발급하기
              YOUTUBE_TRANSCRIPT_LANG: ko</code></pre><h3 id="youtube-api-키-발급-방법">Youtube API 키 발급 방법</h3>
</li>
</ul>
<ol>
<li>console.cloud.google.com 에 방문하기 -&gt; 프로젝트 생성 후 youtube 검색
<img src="https://velog.velcdn.com/images/hyang_do/post/81d02c29-3e1e-4843-afd3-e7cc79b24096/image.png" alt=""></li>
</ol>
<p><img src="https://velog.velcdn.com/images/hyang_do/post/43051e04-023e-46a8-a22f-350bb4dd2b3d/image.png" alt=""></p>
<ul>
<li>지금은 만들어서 관리지만 저기 버튼을 누르면 생성 가능!</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hyang_do/post/5dab8ec7-28f0-4261-96f0-0d44c2032759/image.png" alt=""></p>
<ul>
<li>우측 하단에 키를 확인할 수 있으니 여기서 발급 받을 것!</li>
</ul>
<hr>
<p>이제 구현을 위해 youtube MCP server에 접근한다.</p>
<pre><code>git clone https://github.com/hugecookie/youtube-mcp-server.git</code></pre><p>이후에 폴더에 들어가서 </p>
<pre><code>npm install
npm run dev</code></pre><p>가져온 프로젝트는 내가 사용하기 위해 커스텀한 프로젝트 이므로 용도에 따라 server.ts 파일을 확인하여 수정이 가능하다. 이를 확인할 것!</p>
<p>이후 여러 과정들은 <a href="https://github.com/hugecookie/Devmountain/pull/71">youtube mcp server 설정 관련 정보</a> 여기서 확인하면 될 것이다!</p>
<h3 id="의사결정에-고민한-점">의사결정에 고민한 점</h3>
<ol>
<li>버전에 대한 고민 M4가 지금까지 사용하던 버전이었지만 tools에 대한 기능을 ai가 직접 판단하여 사용할 지 설정하는 것은 M6의 mcp client 설정이 생기고 난 뒤부터 가능하다는 것을 고려하고 구현할 기능에 대한 요구사항을 다시 한 번 확인하고 결정했다.</li>
<li>Spring AI에 대한 mcp 서버 설정은 많이 나와있지만 node.js 파일의 형태는 해석하는 것에 많은 시간이 들었다. 확실히 최근에 핫한 내용은 오류 상황이나 설정들이 많고 자료가 충분하지 않아 구현하는 것에 어려움이 많았다.</li>
<li></li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[기능 구현 사항 정리]]></title>
            <link>https://velog.io/@hyang_do/MVP-%EA%B3%BC%EC%A0%95-%EC%A4%91-%EC%9C%A0%ED%8A%9C%EB%B8%8C-mcp-%EC%84%9C%EB%B2%84</link>
            <guid>https://velog.io/@hyang_do/MVP-%EA%B3%BC%EC%A0%95-%EC%A4%91-%EC%9C%A0%ED%8A%9C%EB%B8%8C-mcp-%EC%84%9C%EB%B2%84</guid>
            <pubDate>Fri, 13 Jun 2025 12:39:43 GMT</pubDate>
            <description><![CDATA[<ul>
<li>개발자가 강의를 검색하고 추천받을 수 있는 온라인 학습 플랫폼 Devmountain!
이 글에서는 실제 구현 과정 중 내가 맡게 된 기능들(토스 API 연동, CI/CD, Docker, BraveSearch)에 대해서 구현 사항과 관련 내용들을 정리했다.</li>
</ul>
<hr>
<h3 id="왜-토스페이api를-사용할까">왜 토스페이API를 사용할까?</h3>
<p> 간단하다. <code>가장 편리하고 편하게 사용</code>할 수 있는 결제 방식이다. 무엇보다 결제에 관한 기능들을 한 번씩 테스트 해보는 것은 이후에 좋은 경험이 될 것이다.</p>
<ol>
<li>토스페이 결제 시스템</li>
</ol>
<p>1.1 API 연동 흐름
<img src="https://velog.velcdn.com/images/hyang_do/post/0dc3027d-429e-4a31-bfab-b2485ce1ee9b/image.png" alt=""></p>
<ul>
<li>클라이언트 결제 요청 → 서버에서 토스 API 호출</li>
<li>결제 승인/실패 → 콜백으로 응답 받아 처리</li>
</ul>
<hr>
<h4 id="toss-결제-흐름-설명-구현-코드-기반">Toss 결제 흐름 설명 (구현 코드 기반)</h4>
<ol>
<li>클라이언트(프론트)에서 결제 요청</li>
</ol>
<ul>
<li>사용자가 결제를 요청하면, orderId, amount, orderName, customerEmail 등의 정보가 포함된 결제 요청이 생성됨.</li>
<li>이 요청은 Toss 결제 페이지로 리디렉션되며 사용자는 Toss UI에서 결제를 진행함.</li>
</ul>
<hr>
<ol start="2">
<li>Toss에서 결제 완료 후 콜백 처리</li>
</ol>
<ul>
<li>사용자가 결제를 완료하면 Toss에서 successUrl 또는 failUrl 로 리디렉션 됨.</li>
<li>이때 클라이언트는 리디렉션된 URL에서 paymentKey, orderId, amount 값을 추출해서 서버로 전달함.<pre><code>// 클라이언트에서 받은 paymentKey, orderId, amount 를 기반으로 서버 요청
paymentService.confirmPayment(paymentKey, orderId, amount);</code></pre></li>
</ul>
<hr>
<ol start="3">
<li>서버에서 결제 승인 API 호출</li>
</ol>
<ul>
<li>TossApiClient 클래스에서 Toss의 결제 승인 API(/v1/payments/confirm)에 요청.</li>
<li>헤더에 Authorization: Basic {secretKey}가 포함되고, JSON body로 paymentKey, orderId, amount 전송.<pre><code>// TossApiClient.java
public TossPaymentResponse confirmPayment(String paymentKey, String orderId, int amount) {
  // HTTP 요청 구성 후 Toss로 전송
}</code></pre></li>
</ul>
<hr>
<ol start="4">
<li>결제 승인 후 DB 저장
 •    Toss에서 성공 응답을 받으면 PaymentService에서 응답 내용을 파싱.
 •    결제 결과(SUCCESS/FAIL)에 따라 DB에 저장.
 •    Payment 테이블의 result, payment_key, orderID, userId 등을 저장.<pre><code>// PaymentService.java
paymentRepository.save(new Payment(...)); // JPA 엔티티 저장</code></pre></li>
</ol>
<hr>
<ol start="5">
<li>회원 등급 변경 처리 (FREE → PRO)</li>
</ol>
<ul>
<li>결제가 성공하고 유료 상품이라면, 해당 유저의 membership_level을 PRO로 변경.<pre><code>// PaymentService.java
user.setMembershipLevel(PRO);
userRepository.save(user);</code></pre></li>
</ul>
<hr>
<p><img src="https://velog.velcdn.com/images/hyang_do/post/b960979c-1709-40b4-94fd-f0dcf4f3c26d/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyang_do/post/e4d555f6-598e-4a02-afb6-37009b7dd74f/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyang_do/post/03738f65-6bf0-4688-b477-4dc5acbe64a6/image.png" alt=""></p>
<ol start="6">
<li>응답 결과 리턴</li>
</ol>
<ul>
<li>성공 시: 결제 완료 메시지 및 결제 상세 정보 리턴</li>
<li>실패 시: 에러 메시지 전송</li>
</ul>
<hr>
<h3 id="2-brave-search를-이용한-외부-강의-검색-기능-정리">2. Brave Search를 이용한 외부 강의 검색 기능 정리</h3>
<h4 id="21-brave-search란">2.1 Brave Search란?</h4>
<ul>
<li>Brave Search는 GPT가 생성한 쿼리를 기반으로 실시간 웹 검색을 수행하는 외부 API</li>
<li>최신 트렌드, 외부 정보 기반의 보완 검색이 가능하다.</li>
<li>브레이브 서치를 사용한 이유: 무엇보다 <code>무료로 기능을 사용</code>할 수 있다는 장점이 있다. (무료로 월 2000회 까지 가능 및 1초에 1번 가능). 게다가 <code>쿼리 기반 검색</code>을 진행하는 우리 프로젝트에 맞춰 간단한 쿼리로도 <code>많은 검색 정보</code>를 찾을 수 있는 브레이브 서치가 적절하다고 판단했다.
<a href="https://brave.com/ko/search/api">브레이브 서치 api 링크</a></li>
</ul>
<hr>
<h4 id="22-gpt와의-연결-구조-코드-흐름-기반">2.2 GPT와의 연결 구조 (코드 흐름 기반)</h4>
<ol>
<li>사용자 입력 처리</li>
</ol>
<ul>
<li>사용자가 챗봇에 &quot;나는 개발자를 시작해보려고 하는 사람이야. 지금 백엔드 개발자를 해보기 위해 기초적인 강의 자료들을 찾고 있어.&quot;처럼 질의함</li>
<li>해당 요청은 GPT가 키워드를 추출하고 Brave Search에 전달</li>
</ul>
<ol start="2">
<li>BraveSearchService</li>
</ol>
<ul>
<li>BraveSearchService.searchBrave(String query)
→ GPT가 만든 쿼리 문자열을 사용해 외부 Brave Search API 호출
→ BraveSearchResponseDto 형태로 결과 파싱</li>
</ul>
<pre><code>BraveSearchResponseDto response = braveSearchService.searchBrave(userQuery);</code></pre><ol start="3">
<li>결과 반환 및 가공</li>
</ol>
<ul>
<li>BraveSearchResponseDto에는 title, url, snippet 등 주요 정보 포함</li>
<li>유저 정보가 회원이거나 PRO 유저라면 브레이브 서치 검색결과도 같이 포함해서 조회하도록 수정</li>
</ul>
<ol start="4">
<li>LectureRecommendationService</li>
</ol>
<ul>
<li>내부 DB 조회 후 유사 강의 조회 후 BraveSearchService 호출하는 로직 포함되어 있음</li>
</ul>
<hr>
<h4 id="23-검색-결과-활용-방식">2.3 검색 결과 활용 방식</h4>
<ul>
<li>Brave 검색 결과는 프론트에 그대로 전달되거나, 요약되어 챗봇 응답에 삽입됨.</li>
<li>예시 흐름:</li>
<li>&quot;요즘 인기 있는 강의 알려줘&quot; → 회원 정보 판단 (GUEST가 아닐 경우) → Brave Search 활용 → 최신 강의 링크 요약 제공</li>
</ul>
<hr>
<h2 id="3-docker로-프로젝트-환경-표준화하기">3. Docker로 프로젝트 환경 표준화하기</h2>
<h3 id="31-도커-환경-구성">3.1 도커 환경 구성</h3>
<ul>
<li>프로젝트 전체를 컨테이너 기반으로 구성하여 개발 환경을 통일했습니다.</li>
<li><code>docker-compose.yaml</code>을 통해 여러 서비스를 한 번에 실행합니다.</li>
<li>구성된 서비스:<ul>
<li>PostgreSQL (벡터 DB 지원: <code>ankane/pgvector</code>)</li>
<li>Redis 및 Redis Stack</li>
<li>Spring Boot 백엔드 애플리케이션</li>
<li>프론트엔드는 추후 추가 예정</li>
</ul>
</li>
</ul>
<h3 id="32-dockerfile--docker-compose-설명">3.2 Dockerfile &amp; docker-compose 설명</h3>
<h4 id="dockerfile">Dockerfile</h4>
<ul>
<li>Spring Boot 프로젝트를 빌드하고 실행하기 위한 설정입니다.</li>
<li>주요 구성:<ul>
<li>JDK 기반 이미지에서 애플리케이션 빌드</li>
<li>빌드된 JAR 파일 복사 및 실행</li>
<li><code>EXPOSE 8080</code> 포트 설정</li>
</ul>
</li>
</ul>
<h4 id="docker-composeyaml">docker-compose.yaml</h4>
<ul>
<li><code>build</code>: 도커 이미지 빌드 경로 지정</li>
<li><code>depends_on</code>: 의존 서비스 정의</li>
<li><code>volumes</code>: DB 데이터 영속성 유지</li>
<li><code>networks</code>: 서비스 간 통신을 위한 네트워크 설정</li>
</ul>
<pre><code class="language-yaml">services:
  postgres:
    image: ankane/pgvector:latest
    ports:
      - &quot;${POSTGRES_PORT}:5432&quot;
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - devmountain-postgres-volume:/var/lib/postgresql/data

  redis:
    image: redis:7.2
    ports:
      - &quot;${REDIS_PORT}:6379&quot;

  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - &quot;${APP_PORT}:8080&quot;
    environment:
      SPRING_PROFILES_ACTIVE: prod
      POSTGRES_HOST: postgres
      REDIS_HOST: redis</code></pre>
<h3 id="33-로컬-vs-운영-환경-구성">3.3 로컬 vs 운영 환경 구성</h3>
<ul>
<li>.env 파일을 통해 환경 변수들을 분리 관리합니다.</li>
<li>주요 설정:</li>
<li>SPRING_PROFILES_ACTIVE=dev 또는 prod</li>
<li>Redis/DB 포트, 비밀번호 등 설정</li>
<li>운영 환경에서는 동일한 Docker 구성을 AWS EC2, Lightsail 등에 배포하여 사용할 수 있습니다.</li>
</ul>
<hr>
<h2 id="4-cicd-자동화-구축기-github-actions-기반">4. CI/CD 자동화 구축기 (GitHub Actions 기반)</h2>
<h3 id="41-cicd의-필요성">4.1 CI/CD의 필요성</h3>
<ul>
<li>테스트 자동화로 코드 안정성을 확보할 수 있습니다.</li>
<li>배포를 반복 가능한 절차로 전환하여 실수를 줄일 수 있습니다.</li>
<li>협업 중에도 병합 이후 바로 테스트/배포되므로 생산성이 향상됩니다.</li>
</ul>
<h3 id="42-github-actions-워크플로우-구성">4.2 GitHub Actions 워크플로우 구성</h3>
<h4 id="ci-테스트-및-빌드-자동화-ciyml">CI: 테스트 및 빌드 자동화 (<code>ci.yml</code>)</h4>
<ul>
<li>develop 또는 main 브랜치에 push될 때 실행됩니다.</li>
<li>주요 단계:<ul>
<li>코드 체크아웃</li>
<li>JDK 17 설정 (corretto)</li>
<li>Gradle 빌드 (<code>test</code> 제외)</li>
<li>Gradle 테스트 실행</li>
</ul>
</li>
</ul>
<pre><code class="language-yaml">name: CI - Build and Test

on:
  push:
    branches:
      - &quot;main&quot;
      - &quot;develop&quot;

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          java-version: &#39;17&#39;
          distribution: &#39;corretto&#39;

      - name: Build
        run: ./gradlew clean build -x test

      - name: Run Tests
        run: ./gradlew test</code></pre>
<h4 id="cd-ec2-서버-배포-자동화-cdyml">CD: EC2 서버 배포 자동화 (<code>cd.yml</code>)</h4>
<ul>
<li>main 브랜치에 push될 때 실행됩니다.</li>
<li>주요 단계:<ul>
<li><code>.env</code>, <code>application-prod.yml</code> 파일 생성</li>
<li>SSH 키 설정</li>
<li>EC2 원격 접속 후 git pull, build, docker-compose로 재배포</li>
</ul>
</li>
</ul>
<pre><code class="language-yaml">name: CD - Deploy to EC2

on:
  push:
    branches:
      - &quot;main&quot;

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v3

      - name: Create .env file
        run: echo &quot;${{ secrets.DOTENV_FILE }}&quot; &gt; .env

      - name: Create application-prod.yml
        run: |
          mkdir -p src/main/resources
          echo &quot;${{ secrets.APPLICATION_PROD_YML }}&quot; &gt; src/main/resources/application-prod.yml

      - name: Set up SSH Key
        run: |
          mkdir -p ~/.ssh
          echo &quot;${{ secrets.EC2_PRIVATE_KEY }}&quot; &gt; ~/.ssh/devmountain-key.pem
          chmod 600 ~/.ssh/devmountain-key.pem

      - name: Deploy to EC2
        run: |
          ssh -o StrictHostKeyChecking=no -i ~/.ssh/devmountain-key.pem ubuntu@${{ secrets.EC2_HOST }} &lt;&lt; &#39;EOF&#39;
            cd ~/devmountain
            git reset --hard HEAD
            git clean -fd
            git pull origin main
            docker-compose down
            ./gradlew build -x test
            docker-compose up -d --build
          EOF</code></pre>
<h3 id="43-자동-테스트-빌드-배포까지">4.3 자동 테스트, 빌드, 배포까지!</h3>
<ul>
<li>GitHub Actions를 통해 CI와 CD를 완전히 자동화했</li>
<li>Docker 이미지 빌드부터 AWS EC2에 배포까지 한 번에 처리</li>
<li>관련된 기본 설정들은 깃허브 페이지에서 가능
  <img src="https://velog.velcdn.com/images/hyang_do/post/daf1a716-de02-4438-a8bd-ee33002911d8/image.png" alt=""></li>
<li>위 페이지에서 시크릿 설정을 추가할 수 있다.</li>
</ul>
<hr>
<ol start="5">
<li>마무리 및 회고</li>
</ol>
<ul>
<li><p>실제 서비스를 만들면서 느낀 점
개발자는 시간과의 싸움이 중요하다고 느꼈다. 내가 구현했다고 끝나는 것이 아니라 다른 사람들이 구현한 내용을 총 관리해야하는 리더의 역할이 중요함을 느꼈다. 지금 팀의 리더나 부리더 같은 경우 이에 관한 관리가 많이 부족한 상태임을 알고 있지만 자신이 맡은 임무도 제대로 끝내지 못해서 다른 사람들의 작업에 대해 왈가왈부 하는 것에 상당히 부담감을 느끼는 것이 좋지 않은 상황임을 인지했다. 내 개인적으로도 직접 회의를 이끌어가며 호응을 이끌어 내려 해도 다들 의견을 표출하지 않으려는 상황이 많이 답답하고 힘들었다. 하지만 분명 나도 완벽한</p>
</li>
<li><p>개선해야 할 점 &amp; 다음 목표
이후에는 각자 맡은 업무를 언제까지 끝낼 수 있을지 (마감일), 매일 진행 상황에 대해 브리핑을 진행하도록 회의 방향을 바꿀 것을 제안했고 모두가 이 제안을 받아들여 좀 더 협업을 소통을 통해 진행하도록 계획했다. 다음 목표의 경우 이제 MCP 클라이언트 기능을 써 볼 예정이다. 그리고 기능 개선과 직접 서버 배포 설정을 해 볼 예정이다.</p>
</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[최종프로젝트에 토스 API 연결하기!]]></title>
            <link>https://velog.io/@hyang_do/%EC%B5%9C%EC%A2%85%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%ED%86%A0%EC%8A%A4-API-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyang_do/%EC%B5%9C%EC%A2%85%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%ED%86%A0%EC%8A%A4-API-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 04 Jun 2025 11:51:22 GMT</pubDate>
            <description><![CDATA[<h3 id="1-toss-payments-api-연동-중-마주친-문제들과-해결-방법">1. Toss Payments API 연동 중 마주친 문제들과 해결 방법</h3>
<p>최근 DevMountain 프로젝트에서 Toss Payments를 연동하며 여러 문제와 상황들이 겹쳤다. 이에 대해 간단하게 정리해두고 다음에 비슷한 상황이 생기면 아래와 같은 방법으로 해결할 예정이다.</p>
<hr>
<h3 id="2-연동-목표">2. 연동 목표</h3>
<pre><code>•    사용자가 버튼 클릭 → 주문 생성 → Toss 결제창 호출
•    orderId, amount, customerName, successUrl 등의 파라미터 포함
•    결제 성공 시 서버에 결과 저장</code></pre><hr>
<h3 id="3-api-호출-전-기본-설정">3. API 호출 전 기본 설정</h3>
<pre><code>const tossPayments = window.TossPayments(&quot;test_ck_...&quot;);
tossPayments.requestPayment(&#39;카드&#39;, {
  amount: 10000,
  orderId: &quot;ORDER_12345&quot;,
  ...
});</code></pre><p>✅ 요구 조건
Toss는 orderId에 다음 조건을 강제합니다:
    •    영문 대소문자, 숫자, 특수문자(-, _)만 허용
    •    길이: 6자 이상, 64자 이하</p>
<hr>
<h2 id="💀-문제-1-숫자형-orderid로-인해-요청-실패">💀 문제 1: 숫자형 orderId로 인해 요청 실패</h2>
<p><img src="https://velog.velcdn.com/images/hyang_do/post/3117e2c8-8f16-46eb-87ad-2c8033682945/image.png" alt=""></p>
<p>📌 에러 메시지</p>
<pre><code>Uncaught (in promise) t: `orderId`는 영문 대소문자, 숫자, 특수문자(-, _) 만 허용합니다. 6자 이상 64자 이하여야 합니다.</code></pre><p>⚠️ 원인
    •    백엔드에서 Long 타입의 order.getId()를 그대로 JSON으로 넘김 → 숫자형 ID가 들어감
    •    Toss는 순수 숫자만 있는 ID를 허용하지 않음</p>
<p>✅ 해결</p>
<p>// OrderResponseDto
public static OrderResponseDto from(Order order) {
    return new OrderResponseDto(&quot;ORDER_&quot; + order.getId(), ...);
}</p>
<p>→ orderId를 문자열로 가공하여 프론트에 전달하도록 변경</p>
<hr>
<h2 id="💀-문제-2-클라이언트-키-작동-안함-오류">💀 문제 2: 클라이언트 키 작동 안함 오류</h2>
<p>📌 에러 메시지</p>
<pre><code>App.jsx:46 주문 생성 실패 ReferenceError: orderId is not defined at onClick </code></pre><p>⚠️ 원인
    •    공식 사이트에서 하라던 클라이언트 키로는 작동 안함 (버전마다 다른 키를 가지고 있으므로 업데이트가 안된 것 같음)</p>
<p>✅ 해결
    •    상세하게 검색해서 최신 버전의 클라이언트 키를 찾음
<img src="https://velog.velcdn.com/images/hyang_do/post/5537fe3d-2385-4f05-acf7-f0e6a493c140/image.png" alt=""></p>
<hr>
<p>🔍 그 외 여러 작은 생각해 둘 요소들
    •    Toss 테스트 키는 test_ck_...로 시작함
    •    실제 결제 요청은 반드시 successUrl, failUrl이 올바르게 설정되어야 정상 동작
    •    프론트에서 Toss SDK가 로드되기 전에 requestPayment 호출 시 오류 발생하므로 useEffect로 스크립트 주입을 함.</p>
<hr>
<h2 id="4-결론">4. 결론</h2>
<p>Toss API는 사용법이 비교적 간단한 편이지만, 사소한 형식이나 파라미터 미스매치로 에러가 자주 발생합니다. 특히 orderId 포맷 요구사항은 반드시 지켜야 하며, 응답 구조를 프론트와 백엔드에서 일치시켜야 한다!</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[팀 프로젝트 구현 과정 정리]]></title>
            <link>https://velog.io/@hyang_do/%ED%8C%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%ED%98%84-%EA%B3%BC%EC%A0%95-%EC%A0%95%EB%A6%AC-%ED%8F%90%EA%B8%B0-%EB%90%A8</link>
            <guid>https://velog.io/@hyang_do/%ED%8C%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%ED%98%84-%EA%B3%BC%EC%A0%95-%EC%A0%95%EB%A6%AC-%ED%8F%90%EA%B8%B0-%EB%90%A8</guid>
            <pubDate>Thu, 29 May 2025 14:15:02 GMT</pubDate>
            <description><![CDATA[<h3 id="도메인-설정"><strong>도메인 설정</strong></h3>
<ul>
<li><strong>도메인</strong>: <strong>DevMountain</strong><ul>
<li><strong>목적</strong>: 개발자가 다양한 강의를 검색, 주문, 리뷰할 수 있는 온라인 학습 플랫폼 개발</li>
</ul>
</li>
</ul>
<h3 id="필수-기능mvp-설정"><strong>필수 기능(MVP) 설정</strong></h3>
<ul>
<li>도메인에 맞는 필수 기능을 설정하여 프로젝트의 최소 기능을 정의합니다. 이는 전체적인 프로젝트의 뼈대를 형성하는 기능입니다.<ul>
<li><strong>사용자</strong><ul>
<li>회원가입, 로그인(Oauth 2.0, 토큰 or 세션), Spring Security(인증,인가)</li>
</ul>
</li>
<li>채팅방 기능 (AI한테 채팅 가능, 사람 X)<ul>
<li>회원:채팅방 = 1:N 관계 (추천은 하루 최대 3번까지 제한 두기)</li>
<li>단계적인 추천</li>
<li>대화 형식을 통해 정보들을 더 추가하기<ul>
<li>해본 언어 물어보기</li>
<li>같이 배우고 싶은 언어</li>
<li>최종 결과 출력</li>
</ul>
</li>
<li>채팅에 원하는 강의 카테고리나 내용을 말하면 가장 높은 유사도를 가진 강의를 추천해 줌 → log로 남기기</li>
<li>웹소켓을 통해 프론트에 계속 요청을 보내서 글자를 하나씩 출력하는 방식으로 진행</li>
<li>응답값에 강의 이미지도 같이 출력하기 (S3)</li>
<li>DB에 채팅 기록 저장</li>
</ul>
</li>
<li>(보류) 유료 구독 기능<ul>
<li>결제 서비스 API(토스페이)를 활용</li>
<li>결제 시 다양한 기능 (이미지와 함께 요청, 채팅방 제한 없음, 개인 프롬프트 설정 가능) 등등을 이용할 수 있음</li>
<li>역할이 유료 유저로 변경 (FREE → PRO)</li>
<li>유료 유저는 개인 설정 및 채팅방 내용을 기억한 상태로 유지</li>
</ul>
</li>
<li>채팅기능: 강의 추천 AI 챗봇 기능 (웹소켓)<ul>
<li>AI 모델 결정 &amp; 적용</li>
<li>데이터 크롤링 &amp; S3 저장 (튜터님 질문)</li>
<li>Spring AI를 MCP 서버에 올려 배포 (PostgreSQL의 pg vector 사용하기)</li>
</ul>
</li>
<li>프론트 구현<ul>
<li>기본적인 로그인 및 회원가입 후 챗봇과의 채팅기능까지 구현</li>
</ul>
</li>
</ul>
</li>
<li>부가기능<ul>
<li>모니터링 (Grafana + Prometheus을 통해서 slack에 경보 시스템 구축하기) {다음주까지 가능할 듯?}</li>
<li>CI/CD 배포 (Jenkins or Git Hub Action)</li>
<li>Docker</li>
<li>프론트엔드</li>
<li>브레이브 서치</li>
<li>테스트 코드 작성 (각자 맡은 파트 알아서 만들기)</li>
<li>크롤링 자동화 적용 (스케쥴러 사용)</li>
<li>많이 추천 받은 강의 및 추천 받은 강의 클릭 수, 리뷰 데이터 등을 활용해 랭킹 기능을 만들어보기</li>
<li>리뷰, 추천 커뮤니티가 가능하다면 랭킹 기능도 같이 구현 (시간이 널널하다면 해보기)</li>
<li>동시성 이슈로 무료 AI에 많은 요청이 생기면 부하가 생기기 때문에 이를 해결할 방법 (모니터링과 연계해서 확인하기)</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[팀 스파르타 최종 프로젝트 기술 스택 시나리오 정리]]></title>
            <link>https://velog.io/@hyang_do/%ED%8C%80-%EC%8A%A4%ED%8C%8C%EB%A5%B4%ED%83%80-%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B8%B0%EC%88%A0-%EC%8A%A4%ED%83%9D-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@hyang_do/%ED%8C%80-%EC%8A%A4%ED%8C%8C%EB%A5%B4%ED%83%80-%EC%B5%9C%EC%A2%85-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B8%B0%EC%88%A0-%EC%8A%A4%ED%83%9D-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Thu, 29 May 2025 00:56:53 GMT</pubDate>
            <description><![CDATA[<hr>
<ol>
<li>사용자 회원가입 및 로그인</li>
</ol>
<p>사용자가 사이트에 처음 접속하여 회원가입 또는 로그인을 시도한다.</p>
<p>적용 기술
    •    Spring Security: 사용자 인증/인가 처리
    •    OAuth 2.0 (Google, Kakao): 소셜 로그인 기능
    •    세션 또는 JWT 방식 중 선택: 인증 상태 유지 방식</p>
<p>흐름 예시
    •    사용자가 Google 로그인을 클릭하면 OAuth 인증이 수행됨
    •    로그인 성공 시, 서버에서 세션 생성 또는 JWT 발급
    •    이후 사용자 요청에 세션 또는 토큰을 포함하여 인증 처리</p>
<hr>
<ol start="2">
<li>강의 업로드 및 조회</li>
</ol>
<p>멘토가 강의를 업로드하고, 사용자는 강의를 탐색 및 조회한다.</p>
<p>적용 기술
    •    Spring Boot + JPA: 강의 CRUD
    •    AWS S3: 강의 썸네일 및 영상 파일 저장소
    •    Redis: 강의 조회수 캐싱 처리
    •    Redis 락 또는 Lua Script: 좋아요 동시성 제어</p>
<p>흐름 예시
    •    강의 등록 시 영상 파일은 S3에 저장됨
    •    강의 상세 진입 시 조회수는 Redis를 통해 증가
    •    좋아요 클릭 시 Redis를 통해 중복 처리 방지</p>
<hr>
<ol start="3">
<li>장바구니 및 주문 처리</li>
</ol>
<p>사용자가 강의를 장바구니에 담고 일부 강의만 선택하여 결제한다.</p>
<p>적용 기술
    •    JPA: Cart, Order, OrderItem 도메인 구성
    •    Redis: 장바구니 항목 캐시 또는 선택 항목 임시 저장
    •    PG사 API: 토스페이, 카카오페이 등 결제 연동</p>
<p>흐름 예시
    •    사용자가 강의를 장바구니에 담고 선택
    •    선택된 강의만으로 주문(Order)과 주문 상세(OrderItem) 생성
    •    PG사 API와 연동하여 결제 승인 처리</p>
<hr>
<ol start="4">
<li>리뷰 작성 및 관리</li>
</ol>
<p>수강한 강의에 대한 리뷰를 작성하고 평가한다.</p>
<p>적용 기술
    •    Spring Boot + JPA: 리뷰 CRUD 처리
    •    Redis: 리뷰 좋아요 수 처리
    •    MySQL: 리뷰 평점 및 리뷰어 수 계산</p>
<p>흐름 예시
    •    리뷰 등록 시 강의 ID 기준으로 연결 저장
    •    좋아요 클릭 시 Redis에 저장, 주기적으로 DB 반영</p>
<p>⸻</p>
<ol start="5">
<li>실시간 채팅</li>
</ol>
<p>수강 중인 강의에 대해 멘토와 실시간 소통한다.</p>
<p>적용 기술
    •    WebSocket: 양방향 통신
    •    Redis Pub/Sub: 메시지 브로드캐스트 처리
    •    MySQL: 메시지 영구 저장</p>
<p>흐름 예시
    •    사용자가 질문 입력 → 서버로 전송 → Redis Pub/Sub로 전달
    •    모든 참여자에게 브로드캐스트되고, 메시지는 DB에 저장됨</p>
<hr>
<ol start="6">
<li>AI 강의 추천 챗봇</li>
</ol>
<p>사용자가 원하는 주제나 필요를 입력하면 적절한 강의를 추천받는다.</p>
<p>적용 기술
    •    LLM API (Claude, GPT 등)
    •    메타데이터 임베딩 및 유사도 검색</p>
<p>흐름 예시
    •    사용자가 텍스트 입력 → 챗봇이 의도 파악
    •    사전 임베딩된 강의 데이터와 유사도 비교 → 강의 추천</p>
<p>⸻</p>
<ol start="7">
<li>인기 강의 캐싱</li>
</ol>
<p>메인 화면에 인기 강의 목록을 노출한다.</p>
<p>적용 기술
    •    Redis Sorted Set + TTL: 인기 순위 저장
    •    스케줄러: 매일 점수 기반으로 캐시 갱신</p>
<p>흐름 예시
    •    강의 조회수, 장바구니 수 등을 점수로 환산
    •    Redis에 저장된 인기 순위를 기반으로 메인에 노출</p>
<hr>
<ol start="8">
<li>CI/CD 및 운영</li>
</ol>
<p>코드 변경 시 자동으로 서버에 배포되고 상태를 모니터링한다.</p>
<p>적용 기술
    •    GitHub Actions: 테스트, 빌드, 배포 자동화
    •    Docker: 어플리케이션 컨테이너화
    •    Nginx + EC2: 실제 배포 환경
    •    Grafana + Prometheus: 시스템 상태 모니터링, Slack 경보 연동</p>
<p>흐름 예시
    •    코드 푸시 시 GitHub Actions가 실행되어 빌드 및 테스트 수행
    •    Docker 이미지를 빌드하여 EC2 서버에 배포
    •    상태 정보는 Grafana 대시보드에서 실시간 확인 가능</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[determinUser에서 궁금한 점]]></title>
            <link>https://velog.io/@hyang_do/determinUser%EC%97%90%EC%84%9C-%EA%B6%81%EA%B8%88%ED%95%9C-%EC%A0%90</link>
            <guid>https://velog.io/@hyang_do/determinUser%EC%97%90%EC%84%9C-%EA%B6%81%EA%B8%88%ED%95%9C-%EC%A0%90</guid>
            <pubDate>Tue, 27 May 2025 01:34:23 GMT</pubDate>
            <description><![CDATA[<p>처음에는 WebSocket 접속 시 클라이언트의 고유 ID를 식별하기 위해 determineUser() 메서드에서 다음과 같이 UUID를 반환하는 방식으로 구현했다.</p>
<pre><code class="language-java">return () -&gt; UUID.randomUUID().toString();</code></pre>
<p>처음에는 이 방식으로도 충분할 것이라고 생각했다. UUID는 고유하고 충돌이 없기 때문에, 각 클라이언트 접속을 고유하게 식별할 수 있다고 보았기 때문이다.</p>
<p>하지만 실제 테스트 중 예상치 못한 문제가 발생했다. 클라이언트가 한 번 WebSocket에 연결할 때, determineUser()가 한 번만 호출되지 않고 여러 번 호출되는 현상이 있었고, 그 결과 매 호출 시마다 서로 다른 UUID가 생성되어 같은 사용자임에도 서로 다른 세션 ID로 처리되는 문제가 발생했다.</p>
<p>로그를 통해 확인해보니, determineUser()는 핸드셰이크 과정 중에 조건에 따라 여러 번 호출될 수 있으며, 이 중 일부는 ServletServerHttpRequest를 기반으로 하지 않아 HTTP 세션 정보를 얻지 못하는 경우가 있었다. 이 때 UUID를 새로 생성하는 fallback 로직이 동작하게 되며, 결국 한 사용자가 여러 명으로 인식되는 현상이 생긴 것이다.</p>
<pre><code>D1E7AAF2225DCAA33695177EB50FDAED
D1E7AAF2225DCAA33695177EB50FDAED
D1E7AAF2225DCAA33695177EB50FDAED</code></pre><p>이 문제를 해결하기 위해, HTTP 세션이 존재하는 경우에는 세션 ID를 고정적으로 반환하고, 없는 경우는 연결을 제한하거나 로그를 통해 추적하는 방식으로 수정하였다. 핵심은 동일한 사용자에게는 항상 동일한 세션 ID가 할당되도록 하는 것이며, 이를 위해 HttpSession에서 얻은 ID를 사용해야 한다는 결론에 도달했다.</p>
<p>즉, UUID 기반의 동적 생성 방식은 Spring WebSocket의 복잡한 핸드셰이크 로직 하에서는 예측 불가능한 결과를 초래할 수 있으므로, 가능한 경우에는 고정된 세션 기반 식별자를 사용하는 것이 안정적인 설계임을 알 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CLAUDE 사용해보기]]></title>
            <link>https://velog.io/@hyang_do/CLAUDE-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@hyang_do/CLAUDE-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Mon, 12 May 2025 13:02:07 GMT</pubDate>
            <description><![CDATA[<p>&quot;CLAUDE&quot;는 미국의 AI 스타트업 Anthropic이 개발한 차세대 인공지능 어시스턴트로, 자연어 처리와 코드 생성, 멀티모달 처리 등 다양한 기능을 제공하는 모델입니다. 현재 최신 버전은 Claude 3.7 Sonnet이며, 개발자와 일반 사용자를 위한 다양한 도구와 API를 지원합니다.</p>
<p>⸻
Claude의 주요 사용 원인</p>
<ol>
<li><p>비즈니스 자동화
 •    고객 응대 챗봇: 고객 문의에 자동으로 응답 (ex. FAQ, 주문/배송 문의)
 •    요약봇: 긴 회의록이나 이메일을 요약
 •    이메일 자동 작성: 답장 초안 생성, 비즈니스 제안 작성</p>
</li>
<li><p>개발 보조
 •    코드 리뷰: Pull Request에 대한 자동 리뷰 (Codereview Rabbit 같은 도구에 내장)
 •    코드 설명/요약: 기존 코드에 대한 설명 생성
 •    문서화 자동화: 함수, 클래스, API 문서 자동 작성</p>
</li>
<li><p>콘텐츠 생성
 •    블로그 글, 뉴스 초안 작성
 •    SNS 문구 생성
 •    제품 설명 생성: 쇼핑몰 등에서 상품 설명 자동 작성</p>
</li>
<li><p>교육/연구
 •    스터디 파트너: 학습 질문 응답, 개념 정리
 •    자료 요약: 논문, 보고서 요약
 •    언어 학습: 외국어 대화, 문법 교정</p>
</li>
<li><p>데이터 처리
 •    CSV/Excel 요약 및 분석
 •    비정형 데이터 구조화: 이메일, 메모 등에서 중요한 정보 추출
 •    텍스트 분류/라벨링: 감정 분석, 카테고리 분류</p>
</li>
</ol>
<p>⸻</p>
<p>Claude가 특히 주목받는 이유
    •    긴 컨텍스트 처리: 최대 200,000 tokens까지 긴 문서도 이해 가능 (GPT-4보다 큼)
    •    인간 친화적 답변: 윤리적 기준을 더 엄격히 적용해, 조심스럽고 신뢰감 있는 대답
    •    빠른 처리 속도: 실시간 응답에서 성능이 좋음</p>
<p>⸻</p>
<p>정리하면 Claude는 <strong>“긴 문서를 이해하고 요약하거나, 자연스러운 대화를 통해 지적 작업을 도와주는 AI”</strong>로 생각하시면 되고, 다양한 SaaS 제품, 챗봇 서비스, 개발자 도구 등에 통합되어 사용됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[S3 서비스 기능에 관한 고찰]]></title>
            <link>https://velog.io/@hyang_do/S3-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B8%B0%EB%8A%A5%EC%97%90-%EA%B4%80%ED%95%9C-%EA%B3%A0%EC%B0%B0</link>
            <guid>https://velog.io/@hyang_do/S3-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B8%B0%EB%8A%A5%EC%97%90-%EA%B4%80%ED%95%9C-%EA%B3%A0%EC%B0%B0</guid>
            <pubDate>Wed, 07 May 2025 12:18:19 GMT</pubDate>
            <description><![CDATA[<hr>
<h3 id="고민하게-된-계기">고민하게 된 계기</h3>
<p>Spring 프로젝트에서 파일 업로드 기능을 만들다 보면, 흔히 로컬 파일 시스템에 저장하거나 클라우드 서비스인 <strong>AWS S3</strong>를 사용하게 된다.
이번 과제에서는 S3에 이미지를 업로드하는 기능을 구현하는 것이 목표였다.</p>
<p>S3는 Amazon Web Services에서 제공하는 <strong>객체 저장소(Object Storage)</strong>로,
정적 파일(이미지, 영상, HTML 등)을 저장하고, 언제든 접근 가능하게 해준다.</p>
<p>초기에는 이렇게 단순히 생각했다:</p>
<blockquote>
<p>“<code>MultipartFile</code> 하나 받아서 <code>amazonS3.putObject()</code> 호출하면 끝 아닌가?”</p>
</blockquote>
<p>하지만 실무나 강의 예제를 보면 S3 관련 코드를 단일 클래스에 몰아넣지 않고,
<code>S3Service</code>, <code>S3Uploader</code>, <code>S3InfraService</code> 등으로 <strong>명확하게 나누는 패턴</strong>을 자주 쓴다.</p>
<p>그래서 다음과 같은 의문이 생겼다:</p>
<ul>
<li>&quot;단순 업로드 기능인데 왜 구조를 나눌까?&quot;</li>
<li>&quot;실제로 유지보수나 테스트에서 차이가 있나?&quot;</li>
<li>&quot;그냥 하나의 서비스로 만들면 안 되나?&quot;</li>
</ul>
<p>이 의문에서 출발해 구현하며 겪은 <strong>고민과 해결 과정</strong>을 정리해보았다.</p>
<hr>
<h3 id="2-왜-구조를-나눠야-할까">2. 왜 구조를 나눠야 할까?</h3>
<h4 id="핵심-포인트">핵심 포인트</h4>
<p><strong>S3Service</strong>는 <em>비즈니스 로직</em>을,
<strong>S3InfraService</strong>는 <em>외부 시스템 연동</em>을 책임지도록 분리한다.</p>
<p>이렇게 하면 얻는 이점이 명확하다:</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>관심사 분리</strong></td>
<td>S3 호출 자체는 인프라 역할, 도메인 로직과 분리</td>
</tr>
<tr>
<td><strong>유지보수 용이</strong></td>
<td>인프라 로직 수정 시 서비스 영향 ↓</td>
</tr>
<tr>
<td><strong>확장성 확보</strong></td>
<td>S3 외에 다른 저장소 연동 시 교체 쉬움</td>
</tr>
<tr>
<td><strong>테스트 용이</strong></td>
<td>외부 시스템 없이도 단위 테스트 가능</td>
</tr>
</tbody></table>
<hr>
<h3 id="3-패키지-및-클래스-구조">3. 패키지 및 클래스 구조</h3>
<pre><code class="language-plaintext">└── s3
    ├── S3Service.java       # 비즈니스 서비스 (컨트롤러 호출 대상)
    └── S3InfraService.java  # AWS SDK 직접 호출</code></pre>
<hr>
<h3 id="4-클래스-예시">4. 클래스 예시</h3>
<h4 id="s3service--비즈니스-로직"><code>S3Service</code> – 비즈니스 로직</h4>
<pre><code class="language-java">@RequiredArgsConstructor
@Service
public class S3Service {
    private final S3InfraService s3InfraService;

    public String upload(MultipartFile file) {
        return s3InfraService.uploadFile(file, &quot;images/&quot;);
    }
}</code></pre>
<h4 id="s3infraservice--s3-통신"><code>S3InfraService</code> – S3 통신</h4>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class S3InfraService {
    private final AmazonS3 amazonS3;
    private final String bucketName = &quot;your-bucket-name&quot;;

    public String uploadFile(MultipartFile file, String dirName) {
        String fileName = dirName + UUID.randomUUID();
        amazonS3.putObject(new PutObjectRequest(bucketName, fileName, file.getInputStream(), null)
                           .withCannedAcl(CannedAccessControlList.PublicRead));
        return amazonS3.getUrl(bucketName, fileName).toString();
    }
}</code></pre>
<hr>
<h3 id="5-테스트에서도-분리의-이점">5. 테스트에서도 분리의 이점</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>S3Service</code> 테스트</td>
<td><code>S3InfraService</code>를 Mock 처리 가능</td>
</tr>
<tr>
<td>테스트 속도</td>
<td>외부 호출 없이 빠르고 안정적</td>
</tr>
<tr>
<td>비용/위험</td>
<td>실제 S3 접근 안 하므로 안전 (요금 없음)</td>
</tr>
</tbody></table>
<h4 id="예시">예시</h4>
<pre><code class="language-java">@ExtendWith(MockitoExtension.class)
class S3ServiceTest {
    @InjectMocks
    private S3Service s3Service;

    @Mock
    private S3InfraService s3InfraService;

    @Test
    void uploadFile_returnsUrl() {
        given(s3InfraService.uploadFile(any(), anyString()))
            .willReturn(&quot;https://s3.amazonaws.com/bucket/file.png&quot;);

        String result = s3Service.upload(mockFile);
        assertThat(result).contains(&quot;https://s3.amazonaws.com&quot;);
    }
}</code></pre>
<hr>
<h2 id="결론">결론</h2>
<ul>
<li>다시 한 번 개발의 방향성을 생각하게 된 계기였다. 그러나 용도에 따라 이 기능을 분리하여 사용하지 않고 하나로만 사용하는 경우가 생기기도 한다. 지금은 단일책임원칙을 지켜서 만들려고 만든 것이지만, 이게 꼭 필요한가에 대해서는 추후에 개발하는 방향성과 목표에 따라 좀 더 고민해보는게 좋다고 생각한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Lv3-12: S3 이미지 업로드 API 구현 정리]]></title>
            <link>https://velog.io/@hyang_do/Lv3-12-S3-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-API-%EA%B5%AC%ED%98%84-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@hyang_do/Lv3-12-S3-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-API-%EA%B5%AC%ED%98%84-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Wed, 07 May 2025 12:08:00 GMT</pubDate>
            <description><![CDATA[<h3 id="1-사용-기술-및-목적">1. 사용 기술 및 목적</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>AWS S3</strong></td>
<td>이미지 파일 저장을 위한 스토리지 서비스</td>
</tr>
<tr>
<td><strong>Spring Cloud AWS</strong></td>
<td>S3 클라이언트 구성 및 연동</td>
</tr>
<tr>
<td><strong>멀티파트 업로드</strong></td>
<td><code>MultipartFile</code> 사용한 파일 업로드 처리</td>
</tr>
</tbody></table>
<hr>
<h3 id="2-📄-주요-클래스-요약">2. 📄 주요 클래스 요약</h3>
<h4 id="✅-s3controller">✅ <code>S3Controller</code></h4>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/api/s3&quot;)
public class S3Controller {

    private final S3Service s3Service;

    @PostMapping(&quot;/upload&quot;)
    public String upload(@RequestParam MultipartFile file) {
        return s3Service.upload(file);
    }
}</code></pre>
<h4 id="✅-s3service">✅ <code>S3Service</code></h4>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class S3Service {

    private final S3Adapter s3Adapter;

    public String upload(MultipartFile file) {
        return s3Adapter.upload(file);
    }
}</code></pre>
<h4 id="✅-s3adapter">✅ <code>S3Adapter</code></h4>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class S3Adapter {

    private final S3Client s3Client;
    private final S3Properties properties;

    public String upload(MultipartFile file) {
        String key = UUID.randomUUID() + &quot;.&quot; + extractExtension(file.getOriginalFilename());

        try {
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(properties.getBucket())
                .key(key)
                .contentType(file.getContentType())
                .acl(ObjectCannedACL.PUBLIC_READ)
                .build();

            s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));

            return properties.getUrl() + &quot;/&quot; + key;
        } catch (IOException e) {
            throw new RuntimeException(&quot;S3 업로드 실패&quot;, e);
        }
    }

    private String extractExtension(String filename) {
        return Optional.ofNullable(filename)
            .filter(f -&gt; f.contains(&quot;.&quot;))
            .map(f -&gt; f.substring(filename.lastIndexOf(&quot;.&quot;) + 1))
            .orElse(&quot;&quot;);
    }
}</code></pre>
<hr>
<h3 id="✅-완성-결과">✅ 완성 결과</h3>
<ul>
<li>클라이언트에서 파일 업로드 시 S3에 저장됨</li>
<li>고유 key로 저장 후 public URL 반환</li>
<li>업로드된 이미지 URL을 클라이언트에 제공하여 활용 가능</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Lv3-11: EC2 + Health Check API 구현 정리]]></title>
            <link>https://velog.io/@hyang_do/Lv3-11-EC2-Health-Check-API-%EA%B5%AC%ED%98%84-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@hyang_do/Lv3-11-EC2-Health-Check-API-%EA%B5%AC%ED%98%84-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Wed, 07 May 2025 12:07:27 GMT</pubDate>
            <description><![CDATA[<h3 id="1-사용-기술-및-목적">1. 사용 기술 및 목적</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>EC2</strong></td>
<td>Spring Boot 애플리케이션 배포용 인스턴스</td>
</tr>
<tr>
<td><strong>탄력적 IP</strong></td>
<td>고정된 외부 IP로 접근 가능하도록 설정</td>
</tr>
<tr>
<td><strong>Health Check API</strong></td>
<td>서버 상태를 확인하는 용도로 <code>/health</code> 엔드포인트 제공</td>
</tr>
</tbody></table>
<hr>
<h3 id="2-📄-주요-클래스-요약">2. 📄 주요 클래스 요약</h3>
<h4 id="✅-healthcheckcontroller">✅ <code>HealthCheckController</code></h4>
<pre><code class="language-java">@RestController
public class HealthCheckController {

    @GetMapping(&quot;/health&quot;)
    public String healthCheck() {
        return &quot;OK&quot;;
    }
}</code></pre>
<hr>
<h3 id="3-ec2-설정-요약">3. EC2 설정 요약</h3>
<ul>
<li>EC2 인스턴스 생성 (Ubuntu 24.04)</li>
<li>보안 그룹: 8080 포트 인바운드 허용</li>
<li>탄력적 IP 할당 및 인스턴스에 연결</li>
<li><code>.jar</code> 파일을 <code>scp</code> 명령어로 업로드 후 <code>java -jar</code> 실행</li>
<li><code>/health</code> 경로 접근 시 OK 반환 확인</li>
</ul>
<hr>
<h3 id="✅-완성-결과">✅ 완성 결과</h3>
<ul>
<li>EC2에서 Spring 애플리케이션 정상 실행</li>
<li>외부 IP를 통한 <code>/health</code> 접근 가능</li>
<li>서버 상태 확인 및 배포 성공 확인용으로 유용</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[Lv3-10: QueryDSL 기반 검색 기능 정리]]></title>
            <link>https://velog.io/@hyang_do/Lv3-10-QueryDSL-%EA%B8%B0%EB%B0%98-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@hyang_do/Lv3-10-QueryDSL-%EA%B8%B0%EB%B0%98-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Fri, 02 May 2025 12:06:34 GMT</pubDate>
            <description><![CDATA[<h3 id="1-사용-기술-및-목적">1. 사용 기술 및 목적</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>QueryDSL</strong></td>
<td>동적 쿼리 생성, 타입 안정성 확보</td>
</tr>
<tr>
<td><strong>Projections</strong></td>
<td>필요한 필드만 선택적으로 반환 (DTO 직접 매핑)</td>
</tr>
<tr>
<td><strong>페이징 처리</strong></td>
<td><code>Pageable</code>, <code>PageImpl</code> 사용</td>
</tr>
<tr>
<td><strong>동적 조건 처리</strong></td>
<td><code>BooleanExpression</code> 활용 (<code>null</code> 조건 자동 무시)</td>
</tr>
<tr>
<td><strong>조인 및 집계</strong></td>
<td><code>leftJoin</code>, <code>groupBy</code>, <code>countDistinct</code> 등 사용</td>
</tr>
</tbody></table>
<hr>
<h3 id="2-📄-주요-클래스-요약">2. 📄 주요 클래스 요약</h3>
<h4 id="✅-todosearchcond-검색-조건-dto">✅ <code>TodoSearchCond</code> (검색 조건 DTO)</h4>
<pre><code class="language-java">record TodoSearchCond(String keyword, LocalDateTime startDate, LocalDateTime endDate, String nickname)</code></pre>
<h4 id="✅-todosearchresponse-검색-응답-dto">✅ <code>TodoSearchResponse</code> (검색 응답 DTO)</h4>
<pre><code class="language-java">record TodoSearchResponse(String title, long managerCount, long commentCount)</code></pre>
<h4 id="✅-todoqueryrepositoryimpl-querydsl-로직-구현">✅ <code>TodoQueryRepositoryImpl</code> (QueryDSL 로직 구현)</h4>
<pre><code class="language-java">List&lt;TodoSearchResponse&gt; contents = queryFactory
    .select(Projections.constructor(TodoSearchResponse.class,
        todo.title,
        manager.countDistinct(),
        comment.countDistinct()))
    .from(todo)
    .leftJoin(todo.managers, manager)
    .leftJoin(manager.user, user)
    .leftJoin(todo.comments, comment)
    .where(
        containsTitle(cond.keyword()),
        betweenCreatedAt(cond.startDate(), cond.endDate()),
        containsNickname(cond.nickname()))
    .groupBy(todo.id)
    .offset(pageable.getOffset())
    .limit(pageable.getPageSize())
    .fetch();</code></pre>
<h4 id="✅-동적-조건-메서드">✅ 동적 조건 메서드</h4>
<pre><code class="language-java">private BooleanExpression containsTitle(String keyword) {
    return keyword != null ? todo.title.containsIgnoreCase(keyword) : null;
}</code></pre>
<h4 id="✅-count-쿼리-페이징용-total-count-계산">✅ Count 쿼리 (페이징용 total count 계산)</h4>
<pre><code class="language-java">Long total = queryFactory
    .select(todo.countDistinct())
    .from(todo)
    ...
    .fetchOne();</code></pre>
<hr>
<h3 id="3-controller-및-service-연동">3. Controller 및 Service 연동</h3>
<ul>
<li><code>TodoService</code>에 <code>searchTodos()</code> 메서드 작성</li>
<li><code>TodoController</code>에서 <code>/api/todos/search</code> GET API 제공</li>
<li><code>Pageable</code> 기반 결과 반환</li>
</ul>
<hr>
<h3 id="✅-완성-결과">✅ 완성 결과</h3>
<ul>
<li>검색 조건별 필터링</li>
<li>title, nickname, createdAt 조건 조합</li>
<li>페이징 + 결과 개수 카운트</li>
<li>필요한 데이터만 DTO로 반환</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring 6기] 심화프로젝트 KPT 회고 (with 기술적 통찰 & 팀 프로젝트 경험) ]]></title>
            <link>https://velog.io/@hyang_do/Spring-6%EA%B8%B0-%EC%8B%AC%ED%99%94%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-KPT-%ED%9A%8C%EA%B3%A0-with-%EA%B8%B0%EC%88%A0%EC%A0%81-%ED%86%B5%EC%B0%B0-%ED%8C%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B2%BD%ED%97%98</link>
            <guid>https://velog.io/@hyang_do/Spring-6%EA%B8%B0-%EC%8B%AC%ED%99%94%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-KPT-%ED%9A%8C%EA%B3%A0-with-%EA%B8%B0%EC%88%A0%EC%A0%81-%ED%86%B5%EC%B0%B0-%ED%8C%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B2%BD%ED%97%98</guid>
            <pubDate>Tue, 29 Apr 2025 10:13:28 GMT</pubDate>
            <description><![CDATA[<hr>
<h3 id="✅-keep--잘했던-점">✅ Keep – <strong>잘했던 점</strong></h3>
<ul>
<li><p><strong>다른 사람의 코드를 통해 많은 인사이트 획득</strong><br>평소에는 나만의 코드에만 집중했지만, 이번에는 출중한 팀원들의 코드를 보며<br>“이런 상황엔 이렇게 풀 수도 있구나!” 하는 다양한 패턴과 기술을 배울 수 있었어요.<br>내가 생각하지 못했던 방식에 눈이 트이는 값진 경험이었습니다.</p>
</li>
<li><p><strong>적극적인 코드 리뷰 문화</strong><br>단순한 기능 확인을 넘어, 설계 방향과 리팩토링 포인트까지 논의하며 협업의 질을 높였어요.<br>리뷰를 통해 문제 해결력도 덩달아 성장!</p>
</li>
<li><p><strong>Git을 활용한 협업</strong><br>브랜치 전략, PR 관리 등 Git 기반 협업이 원활하게 진행되었고, 충돌 없이 흐름을 유지할 수 있었어요.</p>
</li>
<li><p><strong>테스트 코드 작성 및 커버리지 확보</strong><br>테스트 코드를 작성하고 커버리지를 30% 이상 확보해 안정적인 개발 기반을 마련했습니다.</p>
</li>
</ul>
<hr>
<h3 id="❗-problem--아쉬웠던-점">❗ Problem – <strong>아쉬웠던 점</strong></h3>
<ul>
<li><p><strong>코드 스타일 차이로 인한 협업 불편</strong><br>팀원 간 코드 포맷이나 네이밍 규칙이 일관되지 않아 리뷰 시간에 의사소통 비용이 생겼어요.</p>
</li>
<li><p><strong>푸시 알림 기능 구현 실패</strong><br>SSE 등 새로운 기술을 적용하고 싶었지만, 시간 부족으로 인해 프로젝트에 반영하지 못한 점이 아쉬움으로 남아요.</p>
</li>
<li><p><strong>기술 적용에 대한 시간적 제약</strong><br>새로운 기술 도입에 대한 실험적 시도가 부족했고, 도전이 위축됐던 것 같아요.</p>
</li>
</ul>
<hr>
<h3 id="🚀-try--다음에-시도해보고-싶은-것">🚀 Try – <strong>다음에 시도해보고 싶은 것</strong></h3>
<ul>
<li><p><strong>DB vs 메모리 Grouping 벤치마크 실험하기</strong><br>다음엔 대량 데이터 처리 시,<br><code>DB GROUP BY</code>로 직접 묶는 방식과<br>데이터를 가져와 메모리에서 처리하는 방식을 각각 구현하여 <strong>성능 차이를 체계적으로 측정</strong>하고 싶어요.</p>
</li>
<li><p><strong>주요 기능에 대한 시간 예산 미리 확보하기</strong><br>핵심 기능(예: 푸시 알림 등)을 반드시 구현할 수 있도록 프로젝트 초기부터 시간 분배 전략을 명확히 세워둘 예정이에요.</p>
</li>
<li><p><strong>코드 리뷰 기준 정립 및 병합 정책 강화</strong><br>“리뷰 기준표”를 만들고, merge 전에 반드시 리뷰를 통과하도록 하는 팀 문화를 시도해 보고 싶습니다.</p>
</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 심화 프로젝트 Store CRUD 구현]]></title>
            <link>https://velog.io/@hyang_do/Spring-%EC%8B%AC%ED%99%94-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-Store-CRUD-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@hyang_do/Spring-%EC%8B%AC%ED%99%94-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-Store-CRUD-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Thu, 24 Apr 2025 10:30:00 GMT</pubDate>
            <description><![CDATA[<h3 id="📌-제목">📌 제목</h3>
<blockquote>
<p>Spring Boot 3 + JPA 기반 가게(Store) CRUD 구현기 (with JWT, S3 업로드)</p>
</blockquote>
<hr>
<h3 id="🗂️-목차">🗂️ 목차</h3>
<ol>
<li>개요 및 사용 기술 스택</li>
<li>도메인 설계 요약 (Entity + Enum)</li>
<li>가게 생성(Create)</li>
<li>가게 조회(Read)<ul>
<li>단건 조회 (메뉴 포함)</li>
<li>키워드 검색 (폐업 상태 제외)</li>
</ul>
</li>
<li>가게 수정(Update)<ul>
<li>정보 수정</li>
<li>이미지 변경 (S3 업로드)</li>
</ul>
</li>
<li>가게 폐업 처리 (→ 상태 변경)</li>
<li>예외 처리 및 Validation</li>
<li>마무리 및 느낀 점</li>
</ol>
<hr>
<h3 id="✍️-예시-콘텐츠-3-가게-생성">✍️ 예시 콘텐츠 (3. 가게 생성)</h3>
<h4 id="✅-요구사항">✅ 요구사항</h4>
<ul>
<li>사장님(ROLE_owner)만 가게 생성 가능</li>
<li>사장님은 최대 3개까지만 가게 운영 가능</li>
<li>오픈/마감 시간, 최소 주문금액 등 설정 필수</li>
</ul>
<h4 id="request-dto-storerequestjava">Request DTO (<code>StoreRequest.java</code>)</h4>
<pre><code class="language-java">public record StoreRequest(
    @Size(max = 30) String name,
    @Size(max = 20) String category,
    @Size(max = 255) String description,
    @Pattern(regexp = &quot;\\d{2,3}-\\d{3,4}-\\d{4}&quot;) String phone,
    @Min(0) Integer minPrice,
    @Pattern(regexp = &quot;^\\d{2}:\\d{2}$&quot;) String shopOpen,
    @Pattern(regexp = &quot;^\\d{2}:\\d{2}$&quot;) String shopClose,
    String address,
    String storeImgUrl,
    String status // &quot;OPEN&quot;, &quot;CLOSED&quot;, &quot;TERMINATED&quot;
) { ... }</code></pre>
<h4 id="api-예시-storecontroller">API 예시 (<code>StoreController</code>)</h4>
<pre><code class="language-java">@Operation(summary = &quot;가게 생성&quot;, security = {@SecurityRequirement(name = &quot;bearer-key&quot;)})
@PostMapping
public ResponseEntity&lt;StoreResponse&gt; createStore(
        @Valid @RequestBody StoreRequest request,
        Authentication authentication) {
    User user = getUser(authentication);
    StoreResponse response = storeService.createStore(request, user);
    return ResponseEntity.ok(response);
}</code></pre>
<h4 id="예외-처리">예외 처리</h4>
<ul>
<li><code>NO_AUTH_FOR_STORE_CREATION</code> → 사장님 권한 아님</li>
<li><code>TOO_MANY_STORES</code> → 3개 초과 운영 시</li>
</ul>
<hr>
<h2 id="💬-느낀-점-마무리-부분">💬 느낀 점 (마무리 부분)</h2>
<pre><code>Spring Boot 3로 넘어오면서 S3 SDK 버전도 바뀌면서 다시 기억을 되새길 수 있어 좋앗습니다.
Swagger를 활용한 문서화, Redis 블랙리스트 검사 등도 함께 다뤄볼 수 있어 유익했습니다.</code></pre><hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[심화프로젝트 전에 정한 내용]]></title>
            <link>https://velog.io/@hyang_do/%EC%8B%AC%ED%99%94%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A0%84%EC%97%90-%EC%A0%95%ED%95%9C-%EB%82%B4%EC%9A%A9</link>
            <guid>https://velog.io/@hyang_do/%EC%8B%AC%ED%99%94%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A0%84%EC%97%90-%EC%A0%95%ED%95%9C-%EB%82%B4%EC%9A%A9</guid>
            <pubDate>Mon, 21 Apr 2025 12:14:58 GMT</pubDate>
            <description><![CDATA[<h2 id="작업-전-유의할-점">작업 전 유의할 점!</h2>
<h3 id="1-테스트-코드">1. 테스트 코드</h3>
<ul>
<li>기능별 테스트 코드 반드시 작성 (작성하지 않으면 분명 피드백 받을 것입니다.)</li>
</ul>
<h3 id="2-불필요한-코드-정리">2. 불필요한 코드 정리</h3>
<ul>
<li>사용하지 않는 <code>import</code>, <code>repository</code>, 코드 등은 제거</li>
</ul>
<h3 id="3-공통-패키지-구조-정리">3. 공통 패키지 구조 정리</h3>
<ul>
<li><code>Timestamped</code>, <code>CommonRequestDto</code> 등은 <code>common</code> 패키지로 정리</li>
</ul>
<h3 id="4-코드-컨벤션-통일">4. 코드 컨벤션 통일</h3>
<ul>
<li><strong>들여쓰기, 함수명, 리턴 타입, 파라미터 등</strong> 형식 통일</li>
<li>팀원 간 합의된 네이밍 및 포맷 유지</li>
</ul>
<h3 id="5-도메인-설계">5. 도메인 설계</h3>
<ul>
<li><strong>admin 전용 기능은 해당 도메인 내부에 포함</strong><br>(ex. <code>post.admin</code>, <code>user.admin</code> 등으로 처리)<br>→ 별도 <code>admin</code> 도메인 만들지 않기!</li>
</ul>
<h3 id="6-삭제-방식-통일">6. 삭제 방식 통일</h3>
<ul>
<li>기본적으로 <strong>명시적 삭제(직접적 삭제)</strong> 사용</li>
<li>예외적으로 논리 삭제 시에는 팀원과 협의하여 결정</li>
</ul>
<h3 id="7-git-브랜치-전략">7. Git 브랜치 전략</h3>
<ul>
<li><strong>기능 단위로 브랜치 분리하여 작업</strong></li>
<li>PR은 <strong>2명 이상 Approve 시 Merge</strong></li>
<li>Merge 후 반드시 팀원에게 공유</li>
</ul>
<h3 id="8-참고-자료">8. 참고 자료</h3>
<ul>
<li><a href="https://velog.io/@chwogus/Git-%EC%BD%94%EB%93%9C%EB%A6%AC%EB%B7%B0-%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95">코드리뷰하는 방법 - Velog</a></li>
</ul>
<h3 id="9-응답-코드-형식">9. 응답 코드 형식</h3>
<ul>
<li><img src="https://velog.velcdn.com/images/hyang_do/post/f7653408-a9c3-4bcc-aa06-1b9d1ff21fec/image.png" alt=""></li>
</ul>
<hr>
<p> 이후 내용들은 내일 팀프로젝트 과제 발제 이후에 더 자세히 배울 예정이다!
github issue, PR은 다같이 한 번더 공부해서 완벽히 이해한 다음 작업을 진행할 것!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] CH 4 개인 프로젝트 요구사항 정리 Lv 5]]></title>
            <link>https://velog.io/@hyang_do/Spring-Boot-CH-4-%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9A%94%EA%B5%AC%EC%82%AC%ED%95%AD-%EC%A0%95%EB%A6%AC-Lv-5</link>
            <guid>https://velog.io/@hyang_do/Spring-Boot-CH-4-%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9A%94%EA%B5%AC%EC%82%AC%ED%95%AD-%EC%A0%95%EB%A6%AC-Lv-5</guid>
            <pubDate>Sat, 19 Apr 2025 07:35:52 GMT</pubDate>
            <description><![CDATA[<hr>
<h3 id="🔍-1-문제-인식-및-정의">🔍 1. 문제 인식 및 정의</h3>
<p>기존 어드민 API 요청 로깅은 <code>AdminLoggingInterceptor</code>와 <code>AdminApiLoggingAspect</code> 두 곳에 분산되어 있었음.  </p>
<ul>
<li>Interceptor에서 요청 정보를 로깅하고  </li>
<li>AOP에서 응답을 로깅하는 방식이었지만,<br>✅ 두 곳 모두에서 <strong>중복된 로깅 포맷 처리, 필드 추출, 직접 로그 출력</strong>이 발생하고 있었음</li>
</ul>
<h4 id="내가-생각한-개선할-점-정리">내가 생각한 개선할 점 정리:</h4>
<ul>
<li>로깅 책임이 분산되어 있고, 코드 중복 발생</li>
<li>테스트 불가능한 로깅 로직이 여러 클래스에 퍼져 있음</li>
<li>로깅 포맷 변경 시 모든 클래스 수정 필요</li>
<li>ObjectMapper 직접 호출, <code>log.info(...)</code> 직접 반복</li>
</ul>
<hr>
<h3 id="💡-2-해결-방안">💡 2. 해결 방안</h3>
<h4 id="2-1-의사결정-과정">2-1. 의사결정 과정</h4>
<p>기존 구조를 유지하면서도 <strong>로깅 책임을 완전히 분리</strong>하기 위해 다음과 같은 구조를 설계:</p>
<ul>
<li><strong>Interceptor</strong>: 요청 정보를 수집하고 <code>RequestLogContext</code>에 담아 <code>ThreadLocal</code>에 저장</li>
<li><strong>AOP</strong>: 로깅 실행만 담당하고, 로깅 내용은 전부 <code>LoggingUtils</code>에 위임</li>
<li><strong>LoggingUtils</strong>: 로그 포맷, 바디 직렬화 등 모든 로깅 출력을 전담</li>
<li><strong>RequestLogContextHolder</strong>: AOP ↔ Interceptor 사이의 안전한 데이터 전달 책임</li>
</ul>
<h4 id="2-2-해결-과정">2-2. 해결 과정</h4>
<ul>
<li><code>RequestLogContext</code> 객체 생성: 요청 시간, URI, 메서드, 사용자 ID 등 포함</li>
<li><code>RequestLogContextHolder</code>: ThreadLocal 기반으로 요청별 Context 보관</li>
<li><code>LoggingUtils</code>: <code>ObjectMapper</code>를 이용해 args/response를 JSON으로 출력</li>
<li><code>AdminApiLoggingAspect</code> → LoggingUtils 호출만 하도록 리팩토링</li>
<li><code>AdminLoggingInterceptor</code> → Context만 set/clear</li>
</ul>
<hr>
<h3 id="✅-3-해결-완료">✅ 3. 해결 완료</h3>
<h4 id="3-1-회고">3-1. 회고</h4>
<ul>
<li>로깅 책임이 명확하게 분리되어 가독성, 유지보수성이 크게 향상</li>
<li>Interceptor는 &quot;데이터 수집&quot;, AOP는 &quot;흐름 제어&quot;, LoggingUtils는 &quot;로깅 처리&quot;라는 역할 구분이 생김</li>
<li>ObjectMapper가 한 곳으로 모여 JSON 직렬화 예외 처리도 쉬워짐</li>
</ul>
<h4 id="3-2-전후-비교">3-2. 전후 비교</h4>
<table>
<thead>
<tr>
<th>항목</th>
<th>개선 전</th>
<th>개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>로그 출력 위치</td>
<td>여러 클래스 직접 log.info</td>
<td>LoggingUtils 단일 진입점</td>
</tr>
<tr>
<td>중복 포맷 로직</td>
<td>O (Interceptor + AOP 중복)</td>
<td>X</td>
</tr>
<tr>
<td>테스트 가능성</td>
<td>낮음</td>
<td>LoggingUtils 단위 테스트 가능</td>
</tr>
<tr>
<td>확장성 (DB 저장 등)</td>
<td>어려움</td>
<td>LoggingUtils 확장 가능</td>
</tr>
</tbody></table>
<hr>
<h3 id="📌-참고-로그-예시">📌 참고 로그 예시</h3>
<pre><code>🟡 [REQUEST] [2025-04-19T16:29:58.572894] DELETE /admin/comments/2, userId=1, role=ADMIN, body=[2]
🟢 [RESPONSE] [2025-04-19T16:29:58.572894] DELETE /admin/comments/2, userId=1, result=null</code></pre><hr>
]]></description>
        </item>
    </channel>
</rss>