<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>내일이 기대되는 오늘</title>
    <link>https://journal9185.tistory.com/</link>
    <description>geun-00의 흔적 보관소</description>
    <language>ko</language>
    <pubDate>Sun, 28 Jun 2026 09:19:32 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>이런개발</managingEditor>
    <image>
      <title>내일이 기대되는 오늘</title>
      <url>https://tistory1.daumcdn.net/tistory/7485347/attach/51c140ecc9f44364b9122961efb9f784</url>
      <link>https://journal9185.tistory.com</link>
    </image>
    <item>
      <title>[Spring] WebSocket 메시지 처리 효율화: Redis 기반 비동기 저장</title>
      <link>https://journal9185.tistory.com/145</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring WebSocket과 STOMP, 그리고 Redis Pub/Sub 기반의 1대1 채팅 기능을 구현하였습니다. 그리고 기존에는 다음과 같이 동기적으로 `@MessageMapping`에서 메시지를 받자마자 RDBMS에 저장한 다음 Redis에 발행하는 구조를 가졌습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;380&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IrtWA/dJMcad1Pvym/T1vWkHWvh5UKAgx6rvmRc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IrtWA/dJMcad1Pvym/T1vWkHWvh5UKAgx6rvmRc0/img.png&quot; data-alt=&quot;동기적으로 메시지를 DB에 저장&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IrtWA/dJMcad1Pvym/T1vWkHWvh5UKAgx6rvmRc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIrtWA%2FdJMcad1Pvym%2FT1vWkHWvh5UKAgx6rvmRc0%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;332&quot; height=&quot;380&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;380&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;동기적으로 메시지를 DB에 저장&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 구조는 `1 메시지 + 1 DB 커넥션`으로 인해 전체적인 메시징 응답 속도를 저하시켜 병목 현상을 야기할 수 있겠다는 생각이 들었습니다. 이를 해결하기 위해 Redis의 List 자료구조를 메시지 큐로 사용해, 메시지 저장을 비동기로 분리하여 개선하는 과정을 정하고자 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; 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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 큐(Message Queue, MQ)는 프로세스 또는 프로그램 간에 데이터를 교환할 때 사용하는 통신 방법 중 하나로, 메시지를 파이프라인에 임시로 저장해두었다가 나중에 처리할 수 있게 하는 비동기 통신 메커니즘을 의미합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;메시지 큐의 핵심 개념&lt;/u&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;생산자(Producer)&lt;/b&gt; : 데이터를 생성하여 큐에 보내는 주체&lt;/li&gt;
&lt;li&gt;&lt;b&gt;큐(Queue)&lt;/b&gt; : 데이터가 저장되는 임시 저장소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;소비자(Consumer)&lt;/b&gt; : 큐에서 데이터를 가져와 실제 처리를 담당하는 주체&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유) 우체통 : 우체통(큐)에 편지(데이터)를 넣어두면(생산), 나중에 우체부가 수거하여 배달(소비)하는 방식&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✔️ 메시지 큐 종류와 특징&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 큐의 종류는 다양하게 있는데, 그 중 많이 쓰이는 것 같은 RabbitMQ, Kafka, Redis에 대해 정리해 보겠습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 146px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 12.7132%; height: 17px;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 26.3178%; height: 17px;&quot;&gt;&lt;b&gt;RabbitMQ&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 27.6357%; height: 17px;&quot;&gt;&lt;b&gt;Apache Kafka&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;b&gt;Redis (List/Stream)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 12.7132%; height: 21px;&quot;&gt;&lt;b&gt;처리 성능&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3178%; height: 21px;&quot;&gt;중&lt;/td&gt;
&lt;td style=&quot;width: 27.6357%; height: 21px;&quot;&gt;최상&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;최상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 65px;&quot;&gt;
&lt;td style=&quot;width: 12.7132%; height: 65px;&quot;&gt;&lt;b&gt;주요 특징&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3178%; height: 65px;&quot;&gt;- 데이터 전달 보장&lt;br /&gt;- 관리 UI 제공&lt;/td&gt;
&lt;td style=&quot;width: 27.6357%; height: 65px;&quot;&gt;- 고처리량&lt;br /&gt;- 파티셔닝 지원&lt;br /&gt;- 데이터 영속성&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 65px;&quot;&gt;- 인메모리 기반 초고속 처리&lt;br /&gt;- 메모리 제한 및 데이터 유실 가능성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 43px;&quot;&gt;
&lt;td style=&quot;width: 12.7132%; height: 43px;&quot;&gt;&lt;b&gt;사용 사례&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3178%; height: 43px;&quot;&gt;- 적당한 규모&lt;br /&gt;- 복잡한 메시징 시나리오&lt;/td&gt;
&lt;td style=&quot;width: 27.6357%; height: 43px;&quot;&gt;- 대규모 실시간 데이터 파이프라인&lt;br /&gt;- 로그 수집&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 43px;&quot;&gt;- 가벼운 메시징&lt;br /&gt;- 영속성보다는 속도가 중요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;✔️ Redis를 선택한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 RabbitMQ나 Kafka의 경우 높은 학습 곡선과 추가적인 인프라 구축 비용을 필요로 하기 때문에, 개인 프로젝트 규모를 생각해보면 오버 엔지니어링이 될 것 같았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Redis&lt;/b&gt;는 설치나 사용 방법이 간단하고 이미 프로젝트에서 Pub/Sub이나 캐싱 처리에 사용하고 있기 때문에 적절하다고 생각했습니다. 하지만 Redis는 &lt;b&gt;데이터 유실 가능성이 존재&lt;/b&gt;하기 때문에 추후 RabbitMQ나 Kafka를 학습하며 적용해 보는 것도 좋을 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ Redis를 메시지 큐로 활용하는 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis의 자료 구조 중 하나인 list는 큐로 사용하기 적절한 자료 구조입니다. 큐의 tail과 head에서 데이터를 넣고 뺄 수 있는 `LPUSH`, `LPOP`, `RPUSH`, `RPOP` 커맨드를 사용해 메시지 큐를 직접 구현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis 기반 메시지 큐를 사용하여 최종 구현하고자 하는 구조는 다음과 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;599&quot; data-origin-height=&quot;530&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTUsfu/dJMb99SIOYB/cZvLwFVkelsqZiEbK89vZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTUsfu/dJMb99SIOYB/cZvLwFVkelsqZiEbK89vZ0/img.png&quot; data-alt=&quot;메시지 큐를 이용한 비동기 처리&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTUsfu/dJMb99SIOYB/cZvLwFVkelsqZiEbK89vZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTUsfu%2FdJMb99SIOYB%2FcZvLwFVkelsqZiEbK89vZ0%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;599&quot; height=&quot;530&quot; data-origin-width=&quot;599&quot; data-origin-height=&quot;530&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;b&gt;곧바로 응답&lt;/b&gt;을 한다는 것입니다. DB INSERT 작업은 주기적으로 실행되는 배치 작업에 의해 일괄 저장되기 때문에 `1 메시지 + 1 DB 커넥션` 구조를 크게 개선할 수 있을 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 로직은 다음과 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1769169418208&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void addMessageToQueueAndCache(Long roomId, ChatMessageResDto message) {
	//메시지 큐 저장(RPUSH)
    redisTemplate.opsForList().rightPush(&quot;chat:queue&quot;, message);

	//캐시 저장
    String cacheKey = &quot;chat:cache:&quot; + roomId;
    redisTemplate.opsForList().leftPush(cacheKey, message);
    redisTemplate.opsForList().trim(cacheKey, 0, 99);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 메시지를 list에 저장합니다. 여기서 저장한 메시지는 배치 처리 로직에서 순서대로 꺼내서 저장하기 때문에 `RPUSH` 후 `LPOP` 또는 `LPUSH` 후 `RPOP` 순서로 가면 될 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 캐시에 메시지를 저장하는 이유는 데이터 정합성을 위함입니다. 채팅방 입장 후 DB에 저장된 전체 메시지들을 화면에 뿌려주기 위해 DB 조회를 할 텐데, 비동기 배치 작업이 이루어지기 전 새로고침이나 나갔다 오는 경우 메시지를 즉시 조회하지 못하는 경우가 발생할 수 있습니다. 따라서 DB + 캐시 조회를 통해 완전한 전체 메시지를 응답합니다. 다음은 그 로직입니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1769170370753&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 메시지 기록 조회(커서 기반)
 *
 * @param lastMessageId 마지막 조회 메시지
 * @param roomId        채팅방
 * @param pageSize      조회 개수
 */
public ChatMessagesResDto getMessageHistories(Long lastMessageId, Long roomId, int pageSize) {
	//DB 저장된 메시지 목록
    List&amp;lt;ChatMessageResDto&amp;gt; fetchedMessages = chatRepositoryFacade.getMessages(lastMessageId, roomId, pageSize);
    //DB + 캐시 = 최종 반환할 메시지 목록
    List&amp;lt;ChatMessageResDto&amp;gt; resultMessages = new ArrayList&amp;lt;&amp;gt;(fetchedMessages);

    if (lastMessageId == null) {
    	//캐시에 저장된 메시지 목록
        List&amp;lt;Object&amp;gt; cachedRaw = chatRedisService.getCachedRaw(roomId);

        if (cachedRaw != null &amp;amp;&amp;amp; !cachedRaw.isEmpty()) {
            List&amp;lt;ChatMessageResDto&amp;gt; cachedData =
                    cachedRaw.stream()
                             .map(chatRedisService::convert)
                             .filter(m -&amp;gt; fetchedMessages.isEmpty() || m.getTimestamp().isAfter(fetchedMessages.get(0).getTimestamp()))
                             .toList();
                             
			//캐시가 더 최근 메시지이기 때문에 앞에 저장
            resultMessages.addAll(0, cachedData);
        }
    }

    boolean hasMore = resultMessages.size() &amp;gt; pageSize;

    if (hasMore) {
        resultMessages.remove(resultMessages.size() - 1);
    }

    return new ChatMessagesResDto(resultMessages, hasMore);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 핵심 로직은 배치 작업 입니다. 정기 배치 작업을 통해 사용자의 메시지를 잊지 않고 DB에 저장해줍니다.&lt;/p&gt;
&lt;pre id=&quot;code_1769170607045&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Scheduled(fixedDelay = 30000)
@Transactional
public void flushMessagesToDB() {
    String queueKey = &quot;chat:queue&quot;;
    String backupKey = &quot;chat:queue:backup&quot;;

    if (!redisTemplate.hasKey(queueKey)) return;

    redisTemplate.rename(queueKey, backupKey);

    List&amp;lt;Object&amp;gt; rawMessages = redisTemplate.opsForList().range(backupKey, 0, -1);
    if (rawMessages == null || rawMessages.isEmpty()) return;

    List&amp;lt;ChatMessage&amp;gt; entities = convertToEntities(rawMessages);
    
    if (!entities.isEmpty()) {
        chatRepositoryFacade.saveAllChatMessages(entities);
        redisTemplate.delete(backupKey);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치 작업의 주기로 얼마가 적당할 지 잘 모르겠어 일단 30초마다 동작하도록 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 주의할 점은 `backupKey`라는 별도 키를 사용해 배치 작업을 수행하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;`backupKey`를 사용하지 않는다면?&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;`range(0, -1)`은 (왼쪽부터) 모든 데이터를 가져옵니다. (하나씩 `LPOP`하기 보다는 한번에 가져오는 것이 효율적)&lt;/li&gt;
&lt;li&gt;그리고 배치 작업 도중에도 얼마든지 새로운 데이터가 메시지 큐에 저장될 수 있습니다. 즉, `range(0, -1)` 이후 들어온 메시지는 같이 저장되지 못하고 삭제되는 문제가 발생합니다.&lt;/li&gt;
&lt;li&gt;따라서 기존 큐를 백업 큐로 아예 이름을 바꿔, 일종의 스냅샷을 만드는 효과를 가집니다. 그동안 새로 들어오는 메시지는 다시 생성된 기존 큐의 이름으로 쌓이게 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막은 JPA의 `saveAll`로 일괄 저장 하는데, `JdbcTemplate`을 사용해서 `batch insert`로 처리해도 좋을 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 요약&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; Before :&lt;/b&gt; WebSocket 실시간 채팅 메시지를 &lt;b&gt;동기적&lt;/b&gt;으로 DB에 바로 저장&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  After :&lt;/b&gt; 메시지를 잠시 큐에 쌓아두었다가 &lt;b&gt;비동기적&lt;/b&gt;으로 DB에 일괄 저장&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  ️How :&lt;/b&gt; &lt;b&gt;Redis List&lt;/b&gt; 자료 구조를 메시지 큐로 사용&lt;/p&gt;</description>
      <category>Spring</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/145</guid>
      <comments>https://journal9185.tistory.com/145#entry145comment</comments>
      <pubDate>Fri, 23 Jan 2026 21:38:32 +0900</pubDate>
    </item>
    <item>
      <title>[SQL] MySQL 격리 수준</title>
      <link>https://journal9185.tistory.com/144</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 트랜잭션 격리 수준이란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;트랜잭션의 격리 수준(isolation level)&lt;/u&gt;이란 &lt;b&gt;여러 트랜잭션이 동시에 처리될 때&lt;/b&gt; 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;격리 수준은 크게 `READ UNCOMMITTED`, `READ COMMITTED`, `REPEATABLE READ`, `SERIALIZABLE`로 4가지로 나뉘며, 순서대로 뒤로 갈수록 각 트랜잭션 간의 데이터 격리(고립) 정도가 높아지고 동시 처리 성능이 떨어진다고 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ READ UNCOMMITTED&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`READ UNCOMMITTED` 격리 수준에서는 다음 시퀀스 다이어그램과 같이 각 트랜잭션에서의 변경 내용이 `commit`이나 `rollback` 여부에 상관없이 다른 트랜잭션이 보입니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;318&quot; data-origin-height=&quot;628&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FpmRA/dJMcabv3e1y/xy11M3gg8zxBq2CZYiZpD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FpmRA/dJMcabv3e1y/xy11M3gg8zxBq2CZYiZpD0/img.png&quot; data-alt=&quot;READ UNCOMMITTED&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FpmRA/dJMcabv3e1y/xy11M3gg8zxBq2CZYiZpD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFpmRA%2FdJMcabv3e1y%2Fxy11M3gg8zxBq2CZYiZpD0%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;318&quot; height=&quot;628&quot; data-origin-width=&quot;318&quot; data-origin-height=&quot;628&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;READ UNCOMMITTED&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 사용자 B는 사용자 A가 `INSERT`한 정보를 커밋되지 않은 상태에서도 조회할 수 있습니다. 문제는 사용자 A가 처리 도중 문제가 발생해 `INSERT`된 내용을 롤백하더라도 여전히 사용자 B는 조회한 결과(Lee)가 정상적인 정보라고 생각하고 계속 처리할 것이라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 &lt;b&gt;어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상&lt;/b&gt;을 &lt;u&gt;더티 리드(Dirty read)&lt;/u&gt;라 하고, 더티 리드가 허용되는 격리 수준이 `READ UNCOMMITTED`인 것입니다. 더티 리드 현상은 데이터가 나타났다가 사라졌다 하는 현상을 초래하므로 `READ UNCOMMITTED`는 정합성에 문제가 많은 격리 수준입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ READ COMMITTED&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`READ COMMITTED` 격리 수준에서는 다음 시퀀스 다이어그램과 같이 어떤 트랜잭션에서 데이터를 변경했더라도 `commit`된 데이터만 다른 트랜잭션에서 조회할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;503&quot; data-origin-height=&quot;823&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caFDKm/dJMcaaqnbZE/so4QM9lxxyiIZb4ojBeOg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caFDKm/dJMcaaqnbZE/so4QM9lxxyiIZb4ojBeOg0/img.png&quot; data-alt=&quot;READ COMMITTED&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caFDKm/dJMcaaqnbZE/so4QM9lxxyiIZb4ojBeOg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcaFDKm%2FdJMcaaqnbZE%2Fso4QM9lxxyiIZb4ojBeOg0%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;503&quot; height=&quot;823&quot; data-origin-width=&quot;503&quot; data-origin-height=&quot;823&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;READ COMMITTED&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 B는 `select` 쿼리 결과를 &lt;b&gt;테이블이 아니라 언두 영역에 백업된 레코드에서&lt;/b&gt; 가져옵니다. `READ COMMITTED` 격리 수준에서는 어떤 트랜잭션에서 변경한 내용이 커밋되기 전까지는 다른 트랜잭션에서 그러한 변경 내역을 조회할 수 없기 때문입니다. 사용자 A가 변경된 내용을 커밋하면 그때부터는 다른 트랜잭션에서도 백업된 언두 레코드가 아니라 새롭게 변경된 테이블에서 값을 읽게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  `READ COMMITTED` 격리 수준에서는 `NON-REPEATABLE READ`라는 부정합 문제가 발생할 수 있습니다. 다음은 그 예시입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;405&quot; data-origin-height=&quot;792&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cLEcnd/dJMcaacP2el/FjEBG7Qg3c9lIMmX0CsvWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cLEcnd/dJMcaacP2el/FjEBG7Qg3c9lIMmX0CsvWk/img.png&quot; data-alt=&quot;NON-REPEATABLE READ&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cLEcnd/dJMcaacP2el/FjEBG7Qg3c9lIMmX0CsvWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcLEcnd%2FdJMcaacP2el%2FFjEBG7Qg3c9lIMmX0CsvWk%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;405&quot; height=&quot;792&quot; data-origin-width=&quot;405&quot; data-origin-height=&quot;792&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;NON-REPEATABLE READ&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 B는 똑같은 두 번의 똑같은 `SELECT` 쿼리를 실행했을 때 결과가 각각 다릅니다. 정상적인 상황처럼 보이지만, &lt;b&gt;하나의 트랜잭션 내에서 똑같은 `SELECT` 쿼리를 실행했을 때는 항상 같은 결과를 가져와야 한다&lt;/b&gt;는 `REPEATABLE READ` 정합성에 어긋나게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;트랜잭션 A&lt;/b&gt;에서 입금과 출금 처리가 계속 진행될 때 &lt;b&gt;트랜잭션 B&lt;/b&gt;에서 오늘 입금된 금액의 총합을 조회한다고 가정했을 때, `REPEATABLE READ`가 보장되지 않기 때문에 &lt;b&gt;트랜잭션 B&lt;/b&gt;의 총합을 계산하는 `SELECT` 쿼리는 실행될 때마다 다른 결과를 가져오게 될 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 것은 &lt;b&gt;사용 중인 트랜잭션의 격리 수준에 의해 실행되는 SQL 문장이 어떤 결과를 가져오게 되는지를 정확히 예측할 수 있어야 한다는 것&lt;/b&gt;입니다. 그리고 이를 위해서는 각 트랜잭션의 격리 수준이 어떻게 작동하는지 아는 것이 중요합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  트랜잭션 내에서 실행되는 `SELECT` 문장과 트랜잭션 없이 실행되는 `SELECT` 문장에는 차이가 있습니다.&lt;br /&gt;`READ COMMITTED` 격리 수준에서는 트랜잭션 내, 외부에서 실행되는 `SELECT` 문장의 차이가 거의 없습니다. 하지만 다음에 설명할 `REPEATABLE READ` 격리 수준에서는 기본적으로 `SELECT` 문장도 트랜잭션 범위 내에서만 작동합니다.&amp;nbsp;&lt;br /&gt;즉, `START TRANSACTION`(or BEGIN) 명령으로 트랜잭션을 시작한 상태에서 동일한 쿼리를 반복해서 실행하면 항상 동일한 결과를 보게 됩니다.(다른 트랜잭션에서 그 데이터를 변경 후 커밋한다고 해도)&lt;br /&gt;별로 중요하지 않아 보이지만 이런 문제로 데이터 정합성이 깨지고 애플리케이션에 버그가 발생하면 찾아내기가 쉽지 않을 수 있습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ REPEATABLE READ&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`REPEATABLE READ` 격리 수준은 MySQL의 InnoDB 스토리지 엔진에서 기본으로 사용되는 격리 수준입니다. InnoDB 스토리지 엔진은 트랜잭션이 롤백될 가능성에 대비해 변경되기 전 레코드를 언두(Undo) 공간에 백업해 두고 실제 레코드 값을 변경합니다. 이러한 변경 방식을 &lt;b&gt;MVCC&lt;/b&gt;라고 하며, `REPEATABLE READ`는 MVCC를 위해 &lt;b&gt;언두 영역에 백업된 이전 데이터&lt;/b&gt;를 이용해 동일 트랜잭션 내에서는 동일한 결과를 보여줄 수 있도록 보장합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  사실 `READ COMMITTED`도 MVCC를 이용해 커밋되기 전의 데이터를 보여줍니다. `REPEATABLE READ`와 차이는 언두 영역에 백업된 레코드의 여러 버전 가운데 몇 번째 이전 버전까지 찾아 들어가냐 하느냐에 있습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 InnoDB의 트랜잭션은 &lt;b&gt;고유한 트랜잭션 번호&lt;/b&gt;(순차적으로 증가하는 값)를 가지며, 언두 영역에 백업된 모든 레코드에는 &lt;b&gt;변경을 발생시킨 트랜잭션의 번호&lt;/b&gt;가 포함되어 있습니다. 그리고 언두 영역의 백업된 데이터는 InnoDB 스토리지 엔진이 불필요하다고 판단되는 시점에 주기적으로 삭제합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;`REPEATABLE READ` 격리 수준에서는 MVCC를 보장하기 위해 실행 중인 트랜잭션 가운데 가장 오래된 트랜잭션 번호보다 트랜잭션 번호가 앞선 언두 영역의 데이터는 삭제할 수가 없습니다. 그렇다고 가장 오래된 트랜잭션 번호 이전의 트랜잭션에 의해 변경된 모든 언두 데이터가 필요한 것은 아니며, 더 정확히는 특정 트랜잭션 번호의 구간 내에서 백업된 언두 데이터가 보존되어야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`REPEATABLE READ` 격리 수준은 다음 시퀀스 다이어그램과 같이 작동합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;485&quot; data-origin-height=&quot;973&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FmFV5/dJMcafrGsKf/KvCAM75k2vStnHdXvDRkx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FmFV5/dJMcafrGsKf/KvCAM75k2vStnHdXvDRkx1/img.png&quot; data-alt=&quot;REPEATABLE READ&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FmFV5/dJMcafrGsKf/KvCAM75k2vStnHdXvDRkx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFmFV5%2FdJMcafrGsKf%2FKvCAM75k2vStnHdXvDRkx1%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;485&quot; height=&quot;973&quot; data-origin-width=&quot;485&quot; data-origin-height=&quot;973&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;REPEATABLE READ&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 A가 변경 및 커밋을 했지만, 사용자 B는 사용자 A의 변경 전후 각각 한 번씩 `SELECT`했을 때 항상 같은 결과를 받습니다. 사용자 B가 `TX-ID=10`의 트랜잭션 번호를 부여받았고, 그때부터 사용자 B의 10번 트랜잭션 안에서 실행되는 모든 `SELECT` 쿼리는 트랜잭션 번호가 10보다 작은 트랜잭션 번호에서 변경한 것만 보게 되는 것입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  언두 영역에 백업된 데이터는 하나의 레코드에 대해 얼마든지 존재할 수 있습니다. 한 사용자가 트랜잭션 시작 후 장시간 트랜잭션을 종료하지 않으면 언두 영역이 백업된 데이터로 무한정 커질 수도 있습니다. 이렇게 언두에 백업된 레코드가 많아지면 MySQL 서버의 처리 성능이 떨어질 수 있습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`REPEATABLE READ` 격리 수준에서도 부정합이 발생할 수 있습니다. 다음은 그 예시로, 사용자 A가 `INSERT`를 실행하는 도중에 사용자 B가 `SELECT ... FOR UPDATE` 쿼리로 테이블을 조회했을 때 어떻게 되는지 보여줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;571&quot; data-origin-height=&quot;775&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEnYF9/dJMcacV1kXy/a3BVGnzeNAohXoNCu5O72K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEnYF9/dJMcacV1kXy/a3BVGnzeNAohXoNCu5O72K/img.png&quot; data-alt=&quot;PHANTOM READ&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEnYF9/dJMcacV1kXy/a3BVGnzeNAohXoNCu5O72K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEnYF9%2FdJMcacV1kXy%2Fa3BVGnzeNAohXoNCu5O72K%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;571&quot; height=&quot;775&quot; data-origin-width=&quot;571&quot; data-origin-height=&quot;775&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;PHANTOM READ&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 B는 `REPEATABLE READ`에서와 같이 두 번의 `SELECT` 쿼리 결과는 똑같아야 할 것입니다. 하지만 쿼리 결과는 서로 다른데, 이렇게 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드가 보였다 안 보였다 하는 현상을 `PHANTOM READ`라고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`SELECT ... FOR UPDATE` 또는 `SELECT ... LOCK IN SHARE MODE`로 조회되는 레코드는 언두 영역의 변경 전 데이터를 가져오는 것이 아니라 현재 레코드의 값을 가져오게 되는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ SERIALIZABLE&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`SERIALIZABLE` 격리 수준은 가장 단순하면서 동시에 가장 엄격한 격리 수준입니다. 그만큼 동시 처리 성능도 다른 트랜잭션 격리 수준보다 떨어집니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InnoDB 테이블에서 기본적으로 순수한 `SELECT` 작업은 아무런 레코드 잠금도 설정하지 않고 실행되는데 반해, `SERIALIZABLE` 격리 수준에서는 &lt;b&gt;읽기 작업도 공유 잠금(읽기 잠금)을 획득&lt;/b&gt;해야만 하며, 동시에 다른 트랜잭션은 그러한 레코드를 변경하지 못하게 됩니다. 즉, &lt;b&gt;한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서는 절대 접근할 수 없습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`SERIALIZABLE` 격리 수준에서는 일반적인 DBMS에서 일어나는 `PHANTOM READ` 문제가 발생하지 않습니다. 하지만 InnoDB 스토리지 엔진에서는 갭 락과 넥스키 락 덕분에 `REPEATABLE READ` 격리 수준에서도 이미 `PHANTOM READ`가 발생하지 않기 때문에 굳이 `SERIALIZABLE`을 사용할 필요가 없을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✔️ 정리&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style7&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;DIRTY READ&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;NON-REPEATABLE READ&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;SERIALIZABLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;READ UNCOMMITTED&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;✅ 발생&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;✅ 발생&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;✅ 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;READ COMMITTED&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;❌ 없음&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;✅ 발생&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;✅ 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;REPEATABLE READ&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;❌ 없음&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;❌ 없음&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;✅ 발생 (InnoDB는 ❌)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;SERIALIZABE&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;❌ 없음&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;❌ 없음&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;❌ 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;  참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.yes24.com/product/goods/103415627&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Real MySQL 8.0 1권&lt;/a&gt;&lt;/p&gt;</description>
      <category>SQL</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/144</guid>
      <comments>https://journal9185.tistory.com/144#entry144comment</comments>
      <pubDate>Fri, 26 Dec 2025 16:20:03 +0900</pubDate>
    </item>
    <item>
      <title>[SQL] 실행 계획 분석해 성능 개선하기(MariaDB)</title>
      <link>https://journal9185.tistory.com/143</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 기존 쿼리 및 실행 계획 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 쿼리 &amp;darr;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1766479412872&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;analyze
select a1_0.accommodation_id                   as '숙소 ID',
       a1_0.title                              as '숙소 제목',
       max(ap1_0.price)                        as '가격',
       coalesce(avg(r2_0.rating), 0.0)         as '평균 평점',
       ai1_0.image_url                         as '썸네일',
       w1_0.wishlist_id is not null            as '위시리스트 등록 여부',
       w1_0.wishlist_id                        as '위시리스트 ID',
       w1_0.name                               as '위시리스트 이름',
       coalesce(count(r1_0.reservation_id), 0) as '예약 수',
       ac1_0.code_name                         as '지역명',
       ac1_0.area_code                         as '지역 코드'
from accommodations as a1_0
		 # 1
         join accommodation_prices as ap1_0
              on ap1_0.accommodation_id = a1_0.accommodation_id
                  and ap1_0.season = 'PEAK'
                  and ap1_0.day_type = 'WEEKEND'
         # 2
         join accommodation_images as ai1_0
              on ai1_0.accommodation_id = a1_0.accommodation_id
                  and ai1_0.thumbnail = true
         # 3
         join sigungu_codes as sc1_0 on sc1_0.sigungu_code = a1_0.sigungu_code
         join area_codes as ac1_0 on ac1_0.area_code = sc1_0.area_code
         # 4
         left join reservations as r1_0 on r1_0.accommodation_id = a1_0.accommodation_id
         left join reviews as r2_0 on r2_0.reservation_id = r1_0.reservation_id
         # 5
         left join wishlist_accommodations as wa1_0 on wa1_0.accommodation_id = a1_0.accommodation_id
         left join wishlists as w1_0
                   on w1_0.wishlist_id = wa1_0.wishlist_id
                       and w1_0.member_id = 114
group by a1_0.accommodation_id,
         w1_0.wishlist_id
# 6         
order by count(r1_0.reservation_id) desc,
         avg(r2_0.rating) desc;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능 개선 대상은 에어비앤비 클론코딩 프로젝트로, 숙소와 관련된 테이블들을 모두 조인한 다음 필요한 컬럼들만 `select`하고 있습니다. 쿼리를 간단하게 설명하면 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;가격 테이블과 조인합니다. 숙소 하나당 주중/주말 &amp;amp; 비수기/성수기로 총 네 가지 조합의 가격이 각각 존재합니다.&lt;/li&gt;
&lt;li&gt;이미지 테이블과 조인합니다. 숙소 하나당 N개의 이미지가 있고 반드시 하나의 썸네일이 존재합니다.&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;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 쿼리의 `analyze`에 의해 실행된 실행 계획은 다음과 같습니다. (쿼리 실행에 앞서 숙소 10만 개와 이미지 10장씩 `insert`하였습니다. 즉 숙소 10만 개, 가격 40만 개, 이미지 100만 개가 저장되어 있습니다.)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2492&quot; data-origin-height=&quot;335&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1q3hJ/dJMcaaw7mkv/opvpR9AYa2SaPxTJmjFDzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1q3hJ/dJMcaaw7mkv/opvpR9AYa2SaPxTJmjFDzk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1q3hJ/dJMcaaw7mkv/opvpR9AYa2SaPxTJmjFDzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1q3hJ%2FdJMcaaw7mkv%2FopvpR9AYa2SaPxTJmjFDzk%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;2492&quot; height=&quot;335&quot; data-origin-width=&quot;2492&quot; data-origin-height=&quot;335&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리 실행 시간만 &lt;b&gt;5.6초&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;u&gt;&lt;b&gt;풀스캔 및 정렬 처리&lt;/b&gt;&lt;/u&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;숙소 테이블(a1_0)을 &lt;b&gt;풀스캔&lt;/b&gt;(type=ALL)하면서 &lt;b&gt;Using temporary; Using filesort&lt;/b&gt;가 발생했습니다. 인덱스를 전혀 사용하지 못해 결과를 바로 &lt;b&gt;스트리밍 하지&lt;/b&gt; 못하고 조인 결과를 임시 테이블에 저장 후, 결과를 다시 정렬 처리했음을 의미합니다.&lt;/li&gt;
&lt;li&gt;이는 &lt;b&gt;order by를 처리하는 방법 중 가장 속도가 떨어지는 방법&lt;/b&gt;으로 분명한 튜닝 대상으로 볼 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;+ 임시 테이블이 필요한 쿼리&lt;/b&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;- ORDER BY와 GROUP BY에 명시된 컬럼이 다른 쿼리&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;- ORDER BY나 GROUP BY에 명시된 컬럼이 조인의 순서상 첫 번째 테이블이 아닌 쿼리&lt;br /&gt;- DISTINCT와 ORDER BY가 동시에 쿼리에 존재하는 경우 or DISTINCT가 인덱스로 처리되지 못하는 쿼리&lt;br /&gt;- UNION이나 UNION DISTINCT가 사용된 쿼리 (select_type이 UNION RESULT인 경우)&lt;br /&gt;- 실행 계획에서 select_type이 DERIVED인 쿼리&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt;비효율적인 필터링&lt;/b&gt;&lt;/u&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;r_filtered를 보면 가격 테이블(ap1_0)은 25.0, 이미지 테이블(ai1_0)은 9.84라고 나와 있습니다. 이는 가격은 4개 중 1개만 사용되어 3개는 버려지고, 이미지는 10개 중 1개만 사용되어 9개는 버려짐을 의미합니다.&lt;/li&gt;
&lt;li&gt;테이블 설계상 가격은 시기별 하나만 가져와도 되고, 이미지도 마찬가지로 10개 중 썸네일 하나만 가져오면 되기 때문에 모두 스캔하고 나머지를 버릴 필요 없이 하나만 가져오도록 튜닝이 필요해 보입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;u&gt;불필요한 조인&lt;/u&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예약(r1_0)과 후기(r2_0) 테이블을 예약 수와 평균 평점, 즉 집계를 위해 조인하고 있습니다.&lt;/li&gt;
&lt;li&gt;지금은 예약과 후기에 대한 데이터가 많지 않아 성능에 영향은 없지만, 데이터가 쌓일수록 지금과 같은 실시간 집계는 분명 부담이 될 수 있으므로 개선이 필요해 보입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 성능 개선 시도&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 계획을 분석해 발견한 문제점들을 개선해 보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣ 인덱스 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2번 문제를 해결하기 위해 &lt;b&gt;인덱스를 추가&lt;/b&gt;하겠습니다. 인덱스 추가 대상 테이블은 가격과 이미지입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1766392609459&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 가격
create index idx_prices_seasonal
on accommodation_prices (accommodation_id, season, day_type, price);

# 이미지
create index idx_images_thumbnail
on accommodation_images (accommodation_id, thumbnail, image_url);&lt;/code&gt;&lt;/pre&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-origin-width=&quot;2164&quot; data-origin-height=&quot;336&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byCbD1/dJMcaajzNdF/pKVmUTxpGyYq3c9QwEli6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byCbD1/dJMcaajzNdF/pKVmUTxpGyYq3c9QwEli6K/img.png&quot; data-alt=&quot;실행 계획 - 인덱스 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byCbD1/dJMcaajzNdF/pKVmUTxpGyYq3c9QwEli6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyCbD1%2FdJMcaajzNdF%2FpKVmUTxpGyYq3c9QwEli6K%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;2164&quot; height=&quot;336&quot; data-origin-width=&quot;2164&quot; data-origin-height=&quot;336&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;드디어 r_filtered가 각각 25.0, 9.84에서 모두 100으로 맞춰졌습니다. 즉 인덱스 추가로 인해 모두 스캔한 후 필요 없는 행을 버리는 대신 필요한 행 하나만 가져오게 되었습니다. 추가로 Using index, &lt;b&gt;커버링 인덱스&lt;/b&gt;로 동작하는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 인덱스 추가만으로 실행 시간이 기존 &lt;b&gt;5.687초에서 2.067초로 개선&lt;/b&gt;이 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣ 반정규화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1번과 3번 문제를 해결하기 위해 반정규화를 해보겠습니다. 기존 쿼리의 문제점이 항상 예약과 후기 테이블을 조인해서 숙소의 예약 수, 평균 평점과 같은 집계를 수행하는 것이었습니다. 이를 &lt;b&gt;반정규화&lt;/b&gt;로 조인 및 임시 테이블 정렬 비용을 줄이기 위해 기존 숙소 테이블에 다음 필드를 추가했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1766393937098&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;alter table accommodations
add column reservation_count int default 0,   # 예약 수
add column average_rating double default 0.0; # 평균 평점&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 되면 쿼리는 다음과 같이 바뀝니다. 더 이상 예약과 후기 테이블까지 조인해서 `avg`, `count` 같은 집계함수를 사용할 필요 없이 바로 숙소 테이블에서 `select`할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1766454269162&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;select a1_0.accommodation_id        as '숙소 ID',
       a1_0.title                   as '숙소 제목',
       ap1_0.price                  as '가격',
       a1_0.average_rating          as '평균 평점',
       ai1_0.image_url              as '썸네일',
       w1_0.wishlist_id is not null as '위시리스트 등록 여부',
       w1_0.wishlist_id             as '위시리스트 ID',
       w1_0.name                    as '위시리스트 이름',
       a1_0.reservation_count       as '예약 수',
       ac1_0.code_name              as '지역명',
       ac1_0.area_code              as '지역 코드'
from accommodations as a1_0
         join accommodation_prices as ap1_0
              on ap1_0.accommodation_id = a1_0.accommodation_id
                  and ap1_0.season = 'PEAK'
                  and ap1_0.day_type = 'WEEKEND'
         join accommodation_images as ai1_0
              on ai1_0.accommodation_id = a1_0.accommodation_id
                  and ai1_0.thumbnail = true
         join sigungu_codes as sc1_0 on sc1_0.sigungu_code = a1_0.sigungu_code
         join area_codes as ac1_0 on ac1_0.area_code = sc1_0.area_code
         left join wishlist_accommodations as wa1_0 on wa1_0.accommodation_id = a1_0.accommodation_id
         left join wishlists as w1_0
                   on w1_0.wishlist_id = wa1_0.wishlist_id
                       and w1_0.member_id = 114
order by a1_0.reservation_count desc,
         a1_0.average_rating desc;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;  반정규화 시&amp;nbsp;&lt;/span&gt;&lt;b&gt;데이터 정합성&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;을 지키는 것이 중요하므로 다음과 같은 추가 배치 작업이 필요합니다.&lt;/span&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1766458218520&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
@Scheduled(cron = &quot;0 */30 * * * *&quot;) // 30분마다 실행
public void refreshStats() {
    accommodationRepository.refreshStats();
}

----------------------------------------------------------------

@Modifying
@Query(value = &quot;&quot;&quot;
        UPDATE accommodations a
        LEFT JOIN (
            SELECT accommodation_id, COUNT(*) AS cnt
            FROM reservations
            WHERE status != 'CANCELED'
            GROUP BY accommodation_id
        ) AS res ON res.accommodation_id = a.accommodation_id
        LEFT JOIN (
            SELECT r.accommodation_id, ROUND(AVG(rv.rating), 2) AS avg_rating
            FROM reservations r
            JOIN reviews rv ON r.reservation_id = rv.reservation_id
            GROUP BY r.accommodation_id
        ) AS rev ON rev.accommodation_id = a.accommodation_id
        SET a.reservation_count = coalesce(res.cnt, 0),
            a.average_rating = coalesce(rev.avg_rating, 0.0)
        &quot;&quot;&quot;, nativeQuery = true)
void refreshStats();&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 `order by` 컬럼에 인덱스를 추가해 Using filesort를 없애고 인덱스를 이용해 정렬을 처리하도록 해보았습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1766455338776&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;create index idx_reservation_count
    on accommodations (reservation_count DESC, average_rating DESC);&lt;/code&gt;&lt;/pre&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-origin-width=&quot;2249&quot; data-origin-height=&quot;292&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kO7YG/dJMcabQkjRK/29kqF5rZkWKxYcPL9LCwJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kO7YG/dJMcabQkjRK/29kqF5rZkWKxYcPL9LCwJK/img.png&quot; data-alt=&quot;실행 계획 - 반정규화&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kO7YG/dJMcabQkjRK/29kqF5rZkWKxYcPL9LCwJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkO7YG%2FdJMcabQkjRK%2F29kqF5rZkWKxYcPL9LCwJK%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;2249&quot; height=&quot;292&quot; data-origin-width=&quot;2249&quot; data-origin-height=&quot;292&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;실행시간만 보면 1초 미만으로 개선이 되었습니다. 또한 예약 및 후기 테이블 조인이 사라졌고, `group by`를 사용하지 않아 임시 테이블(Using temporary) 사용을 하지 않습니다. 그러나 Filesort는 여전히 남아 있습니다. 원인을 파악하기 위해 위에서 만든 인덱스를 확인해 보았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1958&quot; data-origin-height=&quot;243&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/egDNZg/dJMcagxkxj0/7rpwkokuNuQ6GR3RTGHQw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/egDNZg/dJMcagxkxj0/7rpwkokuNuQ6GR3RTGHQw1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/egDNZg/dJMcagxkxj0/7rpwkokuNuQ6GR3RTGHQw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FegDNZg%2FdJMcagxkxj0%2F7rpwkokuNuQ6GR3RTGHQw1%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;1958&quot; height=&quot;243&quot; data-origin-width=&quot;1958&quot; data-origin-height=&quot;243&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 정렬 기준 컬럼으로 만든 인덱스는 &lt;b&gt;카디널리티가 너무 낮습니다&lt;/b&gt;. 하지만 애초에 근본적인 원인은 `where` 조건 없이 &lt;b&gt;풀스캔 후 정렬을 시도&lt;/b&gt;하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3️⃣ 통계 테이블&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트 설계 상 DB에서 풀스캔해서 가져온 다음 서버에서 직접 지역별로 인기순 TOP N(8) 숙소를 추출하고 있습니다. 다음은 해당 코드입니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1766479678779&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public List&amp;lt;MainAccResDto&amp;gt; getAccommodations(Long memberId) {
    LocalDate now = LocalDate.now();
    Season season = dateManager.getSeason(now);
    DayType dayType = dateManager.getDayType(now);

	// 풀스캔
    List&amp;lt;MainAccListQueryDto&amp;gt; accommodations = accommodationQueryRepository.getAreaAccommodations(season, dayType, memberId);

	// 지역별 그룹핑 후 반환
    return accommodations
            .stream()
            .collect(groupingBy(
                    MainAccListQueryDto::getAreaKey,
                    collectingAndThen(
                            toList(),
                            dtos -&amp;gt; dtos.stream()
                                        .map(MainAccListResDto::from)
                                        .limit(8).toList()
                    )
            ))
            .entrySet()
            .stream()
            .map(entry -&amp;gt; new MainAccResDto(
                    entry.getKey().areaName(),
                    entry.getKey().areaCode(),
                    entry.getValue())
            )
            .toList();
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 DB에서 풀스캔해서 가져온 다음 서버에서 직접 지역별 인기순 TOP N을 추출하는 설계는 인덱스 문제보다 구조&lt;b&gt;&amp;nbsp;자체의 문제&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;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;장점&lt;/span&gt; : 쿼리마다 인덱스 활용 및 풀스캔 회피, 최소 행 조회 가능&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;단점&lt;/span&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;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;장점&lt;/span&gt; : 쿼리 한 번으로 목표(지역별 TOP N) 달성 가능&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;단점&lt;/span&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;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;장점&lt;/span&gt; : 풀스캔 없이 단순 select 조회 가능, 조인 및 정렬 비용 최적화&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;단점&lt;/span&gt; : 추가 배치 작업 필요, 실시간성 감소&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지역별 인기 숙소는 실시간성이 중요한 정보는 아니기에 `쿼리 한번 + 풀스캔 X`을 모두 만족할 수 있는 &lt;b&gt;통계 테이블&lt;/b&gt;을 추가하기로 하였습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통계용 테이블은 다음과 같이 정의했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1766476753118&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;create table accommodation_stats
(
    stat_id           bigint auto_increment comment '고유 ID' primary key,
    accommodation_id  bigint           not null comment '숙소',
    area_code         varchar(255)     not null comment '지역 코드',
    area_name         varchar(255)     not null comment '지역명',
    title             varchar(255)     not null comment '숙소 제목',
    average_rating    double default 0 null comment '평균 평점',
    reservation_count int    default 0 null comment '예약 수',
    thumbnail_url     varchar(700)     not null comment '숙소 썸네일'
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 되면 API 시 실행되는 쿼리 및 실행 계획은 다음과 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1766477151003&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;select as1_0.accommodation_id       as '숙소 ID',
       as1_0.title                  as '숙소 제목',
       ap1_0.price                  as '가격',
       as1_0.average_rating         as '평균 평점',
       as1_0.thumbnail_url          as '썸네일',
       w1_0.wishlist_id is not null as '위시리스트 등록 여부',
       w1_0.wishlist_id             as '위시리스트 ID',
       w1_0.name                    as '위시리스트 이름',
       as1_0.reservation_count      as '예약 수',
       as1_0.area_name              as '지역명',
       as1_0.area_code              as '지역 코드'
from accommodation_stats as1_0
         join accommodation_prices ap1_0
              on ap1_0.accommodation_id = as1_0.accommodation_id
                  and ap1_0.season = 'PEAK'
                  and ap1_0.day_type = 'WEEKDAY'
         left join wishlist_accommodations wa1_0
                   on wa1_0.accommodation_id = as1_0.accommodation_id
         left join wishlists w1_0
                   on w1_0.wishlist_id = wa1_0.wishlist_id
                       and w1_0.member_id = 114;&lt;/code&gt;&lt;/pre&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-origin-width=&quot;2065&quot; data-origin-height=&quot;223&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DkdGJ/dJMb99SwhVX/btakXhstL1C9A7m3HPrGkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DkdGJ/dJMb99SwhVX/btakXhstL1C9A7m3HPrGkK/img.png&quot; data-alt=&quot;실행 계획 - 통계 테이블&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DkdGJ/dJMb99SwhVX/btakXhstL1C9A7m3HPrGkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDkdGJ%2FdJMb99SwhVX%2FbtakXhstL1C9A7m3HPrGkK%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;2065&quot; height=&quot;223&quot; data-origin-width=&quot;2065&quot; data-origin-height=&quot;223&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;시간이 0.001초로 매우 단축되었습니다. `type=ALL`로 풀스캔이 발생했지만 10만 개 전체가 아닌 인기 있는 128개 행만을 풀스캔 한 것이므로 성능에는 전혀 영향이 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1766480304208&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class AccommodationStatisticsService {

    private final EntityManager em;

	// 매일 새벽 2시 실행
    @Scheduled(cron = &quot;* * 2 * * *&quot;)
    public void refreshStats() {
        log.info(&quot;지역별 인기 숙소 TOP N 통계 갱신&quot;);
        String sql = &quot;&quot;&quot;
                TRUNCATE TABLE accommodation_stats;
                
                INSERT INTO accommodation_stats (accommodation_id, area_code, area_name, title, average_rating, reservation_count, thumbnail_url)
                SELECT
                    ranked.accommodation_id,
                    ac.area_code,
                    ac.code_name,
                    ranked.title,
                    ranked.average_rating,
                    ranked.reservation_count,
                    ai.image_url
                FROM (
                    SELECT
                        a.accommodation_id,
                        a.title,
                        a.average_rating,
                        a.reservation_count,
                        sc.area_code,
                        ROW_NUMBER() OVER (
                            PARTITION BY sc.area_code
                            ORDER BY a.reservation_count DESC, a.average_rating DESC
                        ) AS rn
                    FROM accommodations a
                    JOIN sigungu_codes sc ON sc.sigungu_code = a.sigungu_code
                ) ranked
                JOIN area_codes ac ON ac.area_code = ranked.area_code
                JOIN accommodation_images ai
                ON ai.accommodation_id = ranked.accommodation_id
                AND ai.thumbnail = true
                WHERE ranked.rn &amp;lt;= 8
                &quot;&quot;&quot;;

        em.createNativeQuery(sql)
          .executeUpdate();
    }

	// 매 30분 단위(정각, 30분) 실행
    @Scheduled(cron = &quot;0 */30 * * * *&quot;)
    public void refreshRecentStats() {
        log.info(&quot;숙소 반정규화 통계 필드 갱신 - 최근 변경&quot;);
        String sql = &quot;&quot;&quot;
                UPDATE accommodations a
                SET
                    a.reservation_count = (
                        SELECT COUNT(*)
                        FROM reservations r
                        WHERE r.accommodation_id = a.accommodation_id
                          AND r.status != 'CANCELED'
                    ),
                    a.average_rating = COALESCE((
                        SELECT ROUND(AVG(rv.rating), 2)
                        FROM reviews rv
                        JOIN reservations rs ON rv.reservation_id = rs.reservation_id
                        WHERE rs.accommodation_id = a.accommodation_id
                    ), 0.0)
                WHERE a.accommodation_id IN (
                    SELECT DISTINCT accommodation_id
                    FROM reservations
                    WHERE updated_at &amp;gt;= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
                    UNION
                    SELECT DISTINCT rs.accommodation_id
                    FROM reviews rv
                    JOIN reservations rs ON rv.reservation_id = rs.reservation_id
                    WHERE rv.updated_at &amp;gt;= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
                )
                &quot;&quot;&quot;;
        em.createNativeQuery(sql)
          .executeUpdate();
    }

	// 매일 새벽 3시 실행
    @Scheduled(cron = &quot;0 0 3 * * *&quot;)
    public void refreshAllStats() {
        log.info(&quot;숙소 반정규화 통계 필드 갱신 - 전체&quot;);
        String sql = &quot;&quot;&quot;
                UPDATE accommodations a
                SET
                    a.reservation_count = COALESCE((
                        SELECT COUNT(*)
                        FROM reservations r
                        WHERE r.accommodation_id = a.accommodation_id
                          AND r.status != 'CANCELED'
                    ), 0),
                    a.average_rating = COALESCE((
                        SELECT ROUND(AVG(rv.rating), 2)
                        FROM reviews rv
                        JOIN reservations rs ON rv.reservation_id = rs.reservation_id
                        WHERE rs.accommodation_id = a.accommodation_id
                    ), 0.0)
                &quot;&quot;&quot;;
        em.createNativeQuery(sql)
          .executeUpdate();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 성능 개선 정리&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 93px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 9.37986%; text-align: center; height: 21px;&quot;&gt;Version&lt;/td&gt;
&lt;td style=&quot;width: 11.7054%; text-align: center; height: 21px;&quot;&gt;개선점&lt;/td&gt;
&lt;td style=&quot;width: 49.7287%; text-align: center; height: 21px;&quot;&gt;특징&lt;/td&gt;
&lt;td style=&quot;width: 12.5193%; text-align: center; height: 21px;&quot;&gt;쿼리 실행 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 9.37986%; text-align: center; height: 17px;&quot;&gt;0&lt;/td&gt;
&lt;td style=&quot;width: 11.7054%; text-align: center; height: 17px;&quot;&gt;x&lt;/td&gt;
&lt;td style=&quot;width: 49.7287%; text-align: center; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;인덱스 사용 x&lt;br /&gt;풀스캔 및 정렬 비용 발생&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.5193%; text-align: center; height: 17px;&quot;&gt;5.687s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 9.37986%; text-align: center; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 11.7054%; text-align: center; height: 17px;&quot;&gt;인덱스 추가&lt;/td&gt;
&lt;td style=&quot;width: 49.7287%; text-align: center; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;커버링 인덱스 및 버리는 행 최소화&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;풀스캔 및 정렬 비용 발생 &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.5193%; text-align: center; height: 17px;&quot;&gt;2.067s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 9.37986%; text-align: center; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 11.7054%; text-align: center; height: 17px;&quot;&gt;반정규화&lt;/td&gt;
&lt;td style=&quot;width: 49.7287%; text-align: center; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;group by 제거(임시 테이블 및 집계함수 사용 x)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffc1c8; color: #333333; text-align: center;&quot;&gt;풀스캔 및 정렬 비용 발생&lt;/span&gt;&lt;span style=&quot;background-color: #ffc1c8; color: #333333; text-align: center;&quot;&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffc1c8; color: #333333; text-align: center;&quot;&gt;정합성 관리 추가 로직 필요&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.5193%; text-align: center; height: 17px;&quot;&gt;0.730s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 9.37986%; text-align: center; height: 21px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 11.7054%; text-align: center; height: 21px;&quot;&gt;통계 테이블&lt;/td&gt;
&lt;td style=&quot;width: 49.7287%; text-align: center; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;풀스캔(발생하지만 성능 영향 x) 및 정렬 비용 제거&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;정합성 관리 추가 로직 필요&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.5193%; text-align: center; height: 21px;&quot;&gt;0.001s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>SQL</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/143</guid>
      <comments>https://journal9185.tistory.com/143#entry143comment</comments>
      <pubDate>Tue, 23 Dec 2025 17:42:14 +0900</pubDate>
    </item>
    <item>
      <title>[Redis] 캐시 스탬피드 현상</title>
      <link>https://journal9185.tistory.com/142</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 프로젝트에서 레디스를 사용하여 캐시를 사용하였을 때 발생한 &lt;b&gt;캐시 스탬피드 현상&lt;/b&gt;에 대한 해결 과정을 기록하고자 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 캐싱 대상은 AI 후기 요약 결과로 다음과 같습니다. 캐싱을 적용하여 기본적인 응답 시간 감소와 함께 LLM을 호출하는 횟수를 줄여 토큰 비용 감소를 기대했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1765439276681&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Cacheable(value = &quot;postReviewSummary&quot;, key = &quot;#postId&quot;)
public String summarizePostReviews(Long postId) {
    List&amp;lt;Review&amp;gt; reviews = reviewQueryRepository.findTop30ByPostId(postId);

    if (reviews.isEmpty()) {
        return &quot;후기가 없습니다.&quot;;
    }

    String reviewsText = reviews.stream()
                                .map(Review::getComment)
                                .collect(Collectors.joining(&quot;\n&quot;));

    return chatClient.prompt()
                     .system(reviewSummaryPrompt)
                     .user(&quot;후기:\n&quot; + reviewsText)
                     .call()
                     .content();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 k6로 캐싱 전후의 캐시 적중률을 비교해 보겠습니다. 스크립트는 다음과 같이 10명의 사용자가 5번씩 동시에 요청을 보내는 간단한 시나리오를 구성했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1765456747527&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import http from &quot;k6/http&quot;;

export const options = {
  scenarios: {
    cachingTest: {
      executor: &quot;per-vu-iterations&quot;,
      vus: 10,
      iterations: 5,
    },
  },
};

const POST_ID = 1;

export default function () {
  const url = `http://localhost:8080/api/v1/posts/${POST_ID}/reviews/summary/ai`;
  http.get(url);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;907&quot; data-origin-height=&quot;399&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bOIoP8/dJMb995ZPLq/idspWh1QDQIZu1AxmkdgQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bOIoP8/dJMb995ZPLq/idspWh1QDQIZu1AxmkdgQ0/img.png&quot; data-alt=&quot;캐싱 전&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bOIoP8/dJMb995ZPLq/idspWh1QDQIZu1AxmkdgQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbOIoP8%2FdJMb995ZPLq%2FidspWh1QDQIZu1AxmkdgQ0%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;907&quot; height=&quot;399&quot; data-origin-width=&quot;907&quot; data-origin-height=&quot;399&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;캐싱 전&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시 미스 : 50번&lt;/li&gt;
&lt;li&gt;캐시 히트 : 0번&lt;/li&gt;
&lt;li&gt;캐시 적중률 : 0%&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;968&quot; data-origin-height=&quot;398&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dPbyVe/dJMcaihyW5O/e3WJlj1xCuc84tugIOV75K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dPbyVe/dJMcaihyW5O/e3WJlj1xCuc84tugIOV75K/img.png&quot; data-alt=&quot;캐싱 후&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dPbyVe/dJMcaihyW5O/e3WJlj1xCuc84tugIOV75K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdPbyVe%2FdJMcaihyW5O%2Fe3WJlj1xCuc84tugIOV75K%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;968&quot; height=&quot;398&quot; data-origin-width=&quot;968&quot; data-origin-height=&quot;398&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;캐싱 후&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시 미스 : 10번&lt;/li&gt;
&lt;li&gt;캐시 히트 : 40번&lt;/li&gt;
&lt;li&gt;캐시 적중률 : 80%&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;124&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BrOGr/dJMcaiojFuF/6pLisIink8WskkU9KNL4v0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BrOGr/dJMcaiojFuF/6pLisIink8WskkU9KNL4v0/img.png&quot; data-alt=&quot;캐싱 메트릭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BrOGr/dJMcaiojFuF/6pLisIink8WskkU9KNL4v0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBrOGr%2FdJMcaiojFuF%2F6pLisIink8WskkU9KNL4v0%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;1548&quot; height=&quot;124&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;124&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;`캐시 미스 횟수 = LLM 호출 횟수`이기 때문에 50번에서 10번으로 줄은 것은 기대한 대로 동작하였습니다. 하지만 캐시 미스가 10번 발생한 것에 의문을 가졌습니다. 이왕이면 &lt;b&gt;첫 요청 한 번만 캐시 미스가 발생&lt;/b&gt;해 LLM을 호출해 캐싱하고, &lt;b&gt;이후 나머지 요청은 캐시값을 사용하는 구조&lt;/b&gt;를 만들 수 있으면 좋겠다고 생각했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; 캐싱 메트릭을 확인하기 위해서 `RedisCacheManager` 수동 빈 등록 대신(했다면) 다음과 같은 설정이 필요했습니다.&lt;br /&gt;
&lt;pre id=&quot;code_1766220761840&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  cache:
    type: redis
    cache-names: postReviewSummary, {캐시 이름}, ...
    redis:
      enable-statistics: true​&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 원인 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 위와 같인 현상이 발생한 원인을 처음 동시에 요청하는 &lt;b&gt;N개의 스레드에서 모두 캐시 미스&lt;/b&gt;가 발생했기 때문이라고 생각하고 이에 대해 알아보니 &lt;b&gt;캐시 스탬피드 현상&lt;/b&gt;과 관련이 있음을 알게 되었습니다. 캐시 스탬피드 현상을 시퀀스 다이어그램으로 나타내면 다음과 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;468&quot; data-origin-height=&quot;561&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d6bElv/dJMcachk1pq/b5jSxb1mBIVMqHHkHXzthK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d6bElv/dJMcachk1pq/b5jSxb1mBIVMqHHkHXzthK/img.png&quot; data-alt=&quot;캐시 스탬피드 시퀀스 다이어그램&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d6bElv/dJMcachk1pq/b5jSxb1mBIVMqHHkHXzthK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd6bElv%2FdJMcachk1pq%2Fb5jSxb1mBIVMqHHkHXzthK%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;468&quot; height=&quot;561&quot; data-origin-width=&quot;468&quot; data-origin-height=&quot;561&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;캐시 스탬피드 시퀀스 다이어그램&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 캐시 스탬피드 현상을 해결할 수 있는 방법에는 지터, 분산락, PER 알고리즘 등 여러 가지가 있었는데 &lt;b&gt;동시 요청&lt;/b&gt;으로 비싼 비용의 LLM 호출을 중복 실행하는 것을 방지하는 것이 중요하다고 생각하여 &lt;b&gt;분산락&lt;/b&gt;을 적용해 보았습니다. 나머지 방법들은 밑에서 간략하게 정리해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`Redisson`을 사용해 분산락을 적용할 것이기 때문에 의존성을 추가합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1765609761589&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation(&quot;org.redisson:redisson-spring-boot-starter:3.41.0&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산락을 적용한 서비스 로직은 다음과 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1765609812827&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public String summarizePostReviews(Long postId) {
    String lockKey = &quot;lock:postReviewSummary:&quot; + postId;
    RLock lock = redissonClient.getLock(lockKey);

    String cachedSummary = getCachedSummary(postId);
    if (cachedSummary != null) {
        log.info(&quot;캐시 히트 (락 전): postId={}&quot;, postId);
        return cachedSummary;
    }

    try {
        if (!lock.tryLock(10, 15, TimeUnit.SECONDS)) {
            log.error(&quot;락 획득 실패: postId={}&quot;, postId);
            throw new RuntimeException(&quot;락 획득 실패&quot;);
        }

        log.info(&quot;{} 락 획득!&quot;, lockKey);

        cachedSummary = getCachedSummary(postId);
        if (cachedSummary != null) {
            log.info(&quot;캐시 히트 (락 후): postId={} - 다른 스레드가 생성함&quot;, postId);
            return cachedSummary;
        }

        log.info(&quot;캐시 미스 - LLM 호출: postId={}&quot;, postId);
        List&amp;lt;Review&amp;gt; reviews = reviewQueryRepository.findTop30ByPostId(postId);

        if (reviews.isEmpty()) {
            cachedSummary = &quot;후기가 없습니다.&quot;;
        } else {
            String reviewsText = reviews.stream()
                                        .map(Review::getComment)
                                        .collect(Collectors.joining(&quot;\n&quot;));

            cachedSummary = chatClient.prompt()
                                      .system(reviewSummaryPrompt)
                                      .user(&quot;후기:\n&quot; + reviewsText)
                                      .call()
                                      .content();
        }

        Objects.requireNonNull(cacheManager.getCache(&quot;postReviewSummary&quot;)).put(postId, cachedSummary);
        log.info(&quot;캐싱 완료: postId={}&quot;, postId);

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException(&quot;락 획득 중 인터럽트&quot;, e);
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
            log.info(&quot;{} 락 해제&quot;, lockKey);
        }
    }

    return cachedSummary;
}

private String getCachedSummary(Long postId) {
    Cache cache = cacheManager.getCache(&quot;postReviewSummary&quot;);
    if (cache != null) {
        return cache.get(postId, String.class);
    }
    return null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락을 적용했을 때 주의할 점은 `log.info(&quot;캐시 히트 (락 후): ...&quot;)` 부분의 if문이었습니다. &lt;b&gt;현재 스레드가 락을 획득하는 시간에 다른 스레드가 이미 락을 획득하여 먼저 캐싱을 했을 가능성&lt;/b&gt;도 고려를 해야 합니다. 즉 락을 획득했다고 해서 무조건 LLM/DB를 불러오는 게 아닌 한번 더 동시 상황을 Double-Check 하는 것입니다.&lt;/p&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-origin-width=&quot;554&quot; data-origin-height=&quot;1095&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uEp0c/dJMcafd4qmA/9Z2pbpN8QKqkKjS3YUGY60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uEp0c/dJMcafd4qmA/9Z2pbpN8QKqkKjS3YUGY60/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uEp0c/dJMcafd4qmA/9Z2pbpN8QKqkKjS3YUGY60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuEp0c%2FdJMcafd4qmA%2F9Z2pbpN8QKqkKjS3YUGY60%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;554&quot; height=&quot;1095&quot; data-origin-width=&quot;554&quot; data-origin-height=&quot;1095&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 다시 k6로 테스트를 해보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;943&quot; data-origin-height=&quot;410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oUVT6/dJMcajgpQua/U4bKEEOdE58cxmNErAdNIk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oUVT6/dJMcajgpQua/U4bKEEOdE58cxmNErAdNIk/img.png&quot; data-alt=&quot;캐싱 + 분산락&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oUVT6/dJMcajgpQua/U4bKEEOdE58cxmNErAdNIk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoUVT6%2FdJMcajgpQua%2FU4bKEEOdE58cxmNErAdNIk%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;943&quot; height=&quot;410&quot; data-origin-width=&quot;943&quot; data-origin-height=&quot;410&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;캐싱 + 분산락&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;124&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdgT9b/dJMcaiu3jwt/wNdZo9kKiZOpSvbd9zpZ3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdgT9b/dJMcaiu3jwt/wNdZo9kKiZOpSvbd9zpZ3k/img.png&quot; data-alt=&quot;캐싱 메트릭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdgT9b/dJMcaiu3jwt/wNdZo9kKiZOpSvbd9zpZ3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdgT9b%2FdJMcaiu3jwt%2FwNdZo9kKiZOpSvbd9zpZ3k%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;1548&quot; height=&quot;124&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;124&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;캐싱 메트릭&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시 미스 : 1번&lt;/li&gt;
&lt;li&gt;캐시 히트 : 49번&lt;/li&gt;
&lt;li&gt;캐시 적중률 : 98%&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메트릭을 보면 총 50번 요청했지만 캐시 히트 49번, 캐시 미스 11번으로 총 60 요청 발생했다고 나옵니다. 이는 요청 중 일부가 위에서 언급한 &lt;b&gt;현재 스레드가 락을 획득하는 시간에 다른 스레드가 이미 락을 획득하여 먼저 캐싱을 한 경우&lt;/b&gt;가 발생했기 때문입니다. 즉 Double-Check로 인해 일부 요청이 캐시를 두 번 조회하기 때문입니다. 또한 p(90), p(95)와 같이 중요한 지표는 오히려 시간이 늘어난 것을 확인할 수 있는데, 이는 락의 타임아웃 설정(`waitTime(10s)`, `leaseTime(15s)`)으로 인한 어쩔 수 없는 &lt;b&gt;트레이드오프인&lt;/b&gt; 것 같습니다. k6 테스트의 극단적인 시나리오를 생각해 본다면 LLM을 호출하는 비용을 줄이는 것이 더 좋다고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어찌 됐든 결과적으로 &lt;b&gt;분산락을 구현하여 캐시 적중률을 높임&lt;/b&gt;으로써 LLM 호출 비용을 줄이는 목표에 달성할 수 있게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; 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;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣ 지터(Jitter)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;지터&lt;/b&gt;는 전자공학/네트워크에서 사용되는 개념으로 &quot;&lt;span style=&quot;background-color: #ffffff; color: #333d4b; text-align: left;&quot;&gt;전자 신호를 읽는 과정에서 발생하는 짧은 지연 시간&quot;을 의미합니다. 저는 이것을 캐시에서는 &lt;b&gt;랜덤 TTL&lt;/b&gt;로 이해했고&amp;nbsp; &lt;span style=&quot;background-color: #ffffff; color: #333d4b; text-align: left;&quot;&gt;캐시 만료 시간을 무작위로 조금 지연시키면, 캐시 스탬피드 상황에서도 부하를 균등하게 분산시킬 수 있다고 합니다. &lt;span style=&quot;background-color: #ffffff; color: #333d4b; text-align: left;&quot;&gt;예를 들어 &lt;span style=&quot;background-color: #ffffff; color: #333d4b; text-align: left;&quot;&gt;캐시 만료 시간에&lt;/span&gt; 0~10초 사이의 무작위 지연 시간을 추가하면, 부하가 10초에 걸쳐 분산되는 것입니다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333d4b; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333d4b; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333d4b; text-align: left;&quot;&gt;2️⃣ 선계산 기법&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;캐시가 만료되기 전에 미리 데이터를 갱신하는 기법&lt;/b&gt;으로, 기존에는 &lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;여러 요청이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;동시에&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;캐시 만료로 판단하고 DB 조회나 LLM 호출 등&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;트래픽 폭증이 발생하며 중복 읽기와 중복 쓰기가 발생&lt;/b&gt;하는 문제가 있었습니다. 이것을 방지하기 위해 &lt;b&gt;선계산 기법&lt;/b&gt;에서는 `남은 TTL - 랜덤값 &amp;gt; 0`이면 캐싱값을 그대로 사용하고 그렇지 않으면 캐시를 갱신하는 방식으로 동작합니다. &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;즉 캐시가 완전히 만료되기 전에&amp;nbsp;&lt;/span&gt;&lt;b&gt;일부 요청이 미리 캐시를 갱신하도록 유도&lt;/b&gt;하여 캐시 갱신을 분산하도록 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3️⃣ PER 알고리즘&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PER(&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #242424; text-align: start;&quot;&gt;&lt;b&gt;Probabilistic Early Recomputation) 알고리즘&lt;/b&gt;은 &lt;span style=&quot;background-color: #ffffff; color: #242424; text-align: start;&quot;&gt;캐시가 만료되기 전 언제 캐시를 갱신하는 것이 최적일지 계산하는 메커니즘으로, 다음 공식을 기반으로 합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1765678590095&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;currentTime - ( timeToCompute * beta * log(rand()) ) &amp;gt; expiry&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;`currentTime` : 현재 시각(currentTimeMillis)&lt;/li&gt;
&lt;li&gt;`timeToCompute` : 캐시된 값을 다시 계산하는 데 걸리는 시간&lt;/li&gt;
&lt;li&gt;`beta` : 갱신 확률을 조절하는 역할로, 값이 클수록 캐시 만료 시점보다 더 일찍, 더 자주 갱신을 시도(일반적으로 1.0 설정)&lt;/li&gt;
&lt;li&gt;`rand()` : 0~1 사이의 랜덤값을 반환하는 함수&lt;/li&gt;
&lt;li&gt;`expirt` : 캐시 만료 시각&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 공식은 캐시가 실제로 만료되기 전에 확률적으로 미리 재계산을 수행할지 결정합니다. 계산 비용(timeToCompute)이 클수록, 그리고 만료 시각에 가까울수록 재계산 확률이 높아집니다. 이를 통해 여러 요청이 동시에 만료된 캐시를 재계산하려는 캐시 스탬피드 현상을 방지합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;  참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://toss.tech/article/25301&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://toss.tech/article/25301&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Cache_stampede&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://en.wikipedia.org/wiki/Cache_stampede&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jhzlo.tistory.com/69&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://jhzlo.tistory.com/69&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://medium.com/@taesulee93/spring-data-redis-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-per-probabilistic-early-recomputation-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%BA%90%EC%8B%9C-%EC%8A%A4%ED%83%AC%ED%94%BC%EB%93%9C-%ED%98%84%EC%83%81-%ED%95%B4%EA%B2%B0-275cac51e29e&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/@taesulee93/spring-data-redis-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-per-probabilistic-early-recomputation-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%BA%90%EC%8B%9C-%EC%8A%A4%ED%83%AC%ED%94%BC%EB%93%9C-%ED%98%84%EC%83%81-%ED%95%B4%EA%B2%B0-275cac51e29e&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>트러블슈팅</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/142</guid>
      <comments>https://journal9185.tistory.com/142#entry142comment</comments>
      <pubDate>Sun, 14 Dec 2025 11:39:10 +0900</pubDate>
    </item>
    <item>
      <title>Spring AI 개발 일지 (4) - RAG 개념 정리</title>
      <link>https://journal9185.tistory.com/140</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ RAG&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM은 학습한 지식 안에서만 답변할 수 있기 때문에 특정 도메인 지식이 필요한 경우에는 전혀 엉뚱하거나 틀린 정보를 말하는 Hallucination이 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 문제를 해결하기 위해 &lt;b&gt;RAG(Retrieval-Augmented Generation)&lt;/b&gt;라는 개념이 등장했습니다. RAG는 외부 데이터를 검색해 LLM에게 특정 &lt;b&gt;맥락(context)&lt;/b&gt;을 제공하여, 보다 정확하고 신뢰할 수 있는 답변을 생성하도록 합니다.&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ RAG 파이프라인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring AI에서 지원하는 대표적인 RAG 흐름은 다음과 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2262&quot; data-origin-height=&quot;1865&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baAwFD/dJMcahiAEd9/K4mvMtkszqel1rCLoGcUVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baAwFD/dJMcahiAEd9/K4mvMtkszqel1rCLoGcUVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baAwFD/dJMcahiAEd9/K4mvMtkszqel1rCLoGcUVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaAwFD%2FdJMcahiAEd9%2FK4mvMtkszqel1rCLoGcUVK%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;2262&quot; height=&quot;1865&quot; data-origin-width=&quot;2262&quot; data-origin-height=&quot;1865&quot;/&gt;&lt;/span&gt;&lt;/figure&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;데이터 색인 (Data Indexing)&lt;/b&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;문서 Chunking&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PDF 또는 텍스트 문서 업로드&lt;/li&gt;
&lt;li&gt;텍스트 추출 및 정제&lt;/li&gt;
&lt;li&gt;LLM이 이해하기 좋은 단위로 Chunking(Text Splitting)&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;각 chunk에 대해 임베딩 생성(벡터화)&lt;/li&gt;
&lt;li&gt;메타데이터 추가&lt;/li&gt;
&lt;li&gt;벡터 저장소에 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;질의응답 (Data Retrieval &amp;amp; Generation)&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;유사한 문서 chunk 검색&lt;/li&gt;
&lt;li&gt;검색된 문서를 LLM에게 컨텍스트로 전달&lt;/li&gt;
&lt;li&gt;최종 응답 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ Spring AI RAG 핵심 컴포넌트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring AI는 텍스트 청킹 전략, 임베딩 모델 연동, 벡터 저장소 연동, 메타데이터 기반 필터링, LLM 프롬프트 구성 및 요청 처리와 같은 RAG 시스템의 복잡한 요소들을 &lt;b&gt;표준화된 컴포넌트로 추상화&lt;/b&gt;해줍니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✔️ EmbeddingModel&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임베딩은 LLM과 검색 시스템을 연결하는 첫 번째 단계입니다. Spring AI는 `EmbeddingModel`이라는 텍스트를 벡터로 변환하는 인터페이스를 제공해 다양한 벤더의 임베딩 모델을 쉽게 연동할 수 있게 해줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;636&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zHT69/dJMcagqrWFL/ovhYskKl31nIFGTJcjWCh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zHT69/dJMcagqrWFL/ovhYskKl31nIFGTJcjWCh0/img.png&quot; data-alt=&quot;EmbeddingModel&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zHT69/dJMcagqrWFL/ovhYskKl31nIFGTJcjWCh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzHT69%2FdJMcagqrWFL%2FovhYskKl31nIFGTJcjWCh0%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;1390&quot; height=&quot;636&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;636&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;EmbeddingModel&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✔️ VectorStore&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`VectorStore`는 문서 벡터 저장 및 검색을 위한 인터페이스로, 임베딩된 문서를 저장하고 질문과 유사한 문서를 빠르게 검색해주는 핵심 컴포넌트입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;592&quot; data-origin-height=&quot;513&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czTsKt/dJMcagDZ57U/HVO1u6JdFWKyxXzvuxHu30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czTsKt/dJMcagDZ57U/HVO1u6JdFWKyxXzvuxHu30/img.png&quot; data-alt=&quot;VectorStore&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czTsKt/dJMcagDZ57U/HVO1u6JdFWKyxXzvuxHu30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FczTsKt%2FdJMcagDZ57U%2FHVO1u6JdFWKyxXzvuxHu30%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;592&quot; height=&quot;513&quot; data-origin-width=&quot;592&quot; data-origin-height=&quot;513&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;VectorStore&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추상화가 잘 되어 있어 처음에는 인메모리 기반의 `SimpleVectorStore`로 개발하다가 서비스 확장 시 `PgVector`, `Milvus`, `Qdrant` 등 외부 DB로 코드 변경 없이 쉽게 전환할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✔️ TextSplitter&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM은 긴 문서를 한 번에 처리하지 못하기 때문에 문서를 적절한 길이로 나누는 작업이 필요합니다. 이를 위해 Spring AI는 `TextSplitter`를 제공합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1240&quot; data-origin-height=&quot;1486&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mHlTD/dJMb99LCXAJ/aRPEZ7aRLjbNbGJsX1iWC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mHlTD/dJMb99LCXAJ/aRPEZ7aRLjbNbGJsX1iWC0/img.png&quot; data-alt=&quot;TextSplitter&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mHlTD/dJMb99LCXAJ/aRPEZ7aRLjbNbGJsX1iWC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmHlTD%2FdJMb99LCXAJ%2FaRPEZ7aRLjbNbGJsX1iWC0%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;1240&quot; height=&quot;1486&quot; data-origin-width=&quot;1240&quot; data-origin-height=&quot;1486&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;TextSplitter&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✔️ FilterExpression&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 유사한 문서를 찾는 것만으로는 부족할 수 있기 때문에, Spring AI는 문서 메타데이터를 기반으로 조건 필터링을 지원합니다. 이를 통해 SQL의 WHERE 절과 유사한 방식으로 필터링할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;487&quot; data-origin-height=&quot;928&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AdZRz/dJMcaajsX6t/9O2c5uN1f1KscpK3aLCVGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AdZRz/dJMcaajsX6t/9O2c5uN1f1KscpK3aLCVGK/img.png&quot; data-alt=&quot;FilterExpression&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AdZRz/dJMcaajsX6t/9O2c5uN1f1KscpK3aLCVGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAdZRz%2FdJMcaajsX6t%2F9O2c5uN1f1KscpK3aLCVGK%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;487&quot; height=&quot;928&quot; data-origin-width=&quot;487&quot; data-origin-height=&quot;928&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;FilterExpression&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;  참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.sionic.ai/spring-ai-series-2#1ebb25e3-2acc-80ea-bd33-c61be025f426&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://blog.sionic.ai/spring-ai-series-2#1ebb25e3-2acc-80ea-bd33-c61be025f426&lt;/a&gt;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/140</guid>
      <comments>https://journal9185.tistory.com/140#entry140comment</comments>
      <pubDate>Sun, 14 Dec 2025 10:54:01 +0900</pubDate>
    </item>
    <item>
      <title>Spring AI 개발 일지 (3) - 챗봇 구현</title>
      <link>https://journal9185.tistory.com/141</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring AI를 사용해 RAG 없이 단순 대화 챗봇을 구현해보겠습니다.&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; 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 id=&quot;code_1764747426583&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation 'org.springframework.ai:spring-ai-starter-model-chat-memory-repository-jdbc'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 Spring AI 의존성만 추가하면 인메모리 기반의 레포지토리 빈이 등록됩니다. 대화 내역을 DB에 저장하기 위해서 jdbc 의존성을 추가했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764747445400&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  ai:
    chat:
      memory:
        repository:
          jdbc:
            initialize-schema: never&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 스키마 생성 기능을 사용할 경우 오류가 자주 발생한다고 해 never로 설정한 다음 직접 스키마를 생성하였습니다. 스키마 정의는 &lt;a href=&quot;https://github.com/spring-projects/spring-ai/tree/main/memory/repository/spring-ai-model-chat-memory-repository-jdbc/src/main/resources/org/springframework/ai/chat/memory/repository/jdbc&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring AI GitHub&lt;/a&gt;에 DB별로 정의되어 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣ 빈 등록&lt;/h3&gt;
&lt;pre id=&quot;code_1764765021997&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class AIConfig {

    @Bean
    public ChatClient openAiChatClient(ChatModel openAiChatModel) {
        return ChatClient.create(openAiChatModel);
    }

    @Bean
    public ChatMemory loginChatMemory(JdbcChatMemoryRepository jdbcChatMemoryRepository) {
        return MessageWindowChatMemory.builder()
                                      .chatMemoryRepository(jdbcChatMemoryRepository)
                                      .build();
    }

    @Bean
    public ChatMemory anonymousChatMemory() {
        return MessageWindowChatMemory.builder().build();
    }

    @Bean
    public MessageChatMemoryAdvisor loginMemoryAdvisor(ChatMemory loginChatMemory) {
        return MessageChatMemoryAdvisor.builder(loginChatMemory).build();
    }

    @Bean
    public MessageChatMemoryAdvisor anonymousMemoryAdvisor(ChatMemory anonymousChatMemory) {
        return MessageChatMemoryAdvisor.builder(anonymousChatMemory).build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring AI는 `ChatMemory`라는 대화 상태를 관리하는 추상화된 인터페이스를 제공하며, 기본 구현체로 `MessageWindowChatMemory`가 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;696&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dmc1Ik/dJMcaihvh0S/MbOkNFFOuAvCWv79uxZhMK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dmc1Ik/dJMcaihvh0S/MbOkNFFOuAvCWv79uxZhMK/img.png&quot; data-alt=&quot;ChatMemory&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dmc1Ik/dJMcaihvh0S/MbOkNFFOuAvCWv79uxZhMK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdmc1Ik%2FdJMcaihvh0S%2FMbOkNFFOuAvCWv79uxZhMK%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;788&quot; height=&quot;696&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;696&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ChatMemory&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인한 사용자나 하지 않은 사용자나 똑같이 챗봇과 대화를 할 수 있되, 로그인하지 않은 사용자는 DB에 저장하지 않고 메모리에 저장하길 원했기 때문에 인증용, 미인증용 `ChatMemory` 빈 두개를 등록했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  빌더를 통해 `MessageWindowChatMemory`를 생성할 때 `ChatMemoryRepository`를 지정하지 않으면 `InMemoryChatMemoryRepository` 구현체가 기본으로 정해집니다.&lt;br /&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1309&quot; data-origin-height=&quot;1232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cBi4x6/dJMcaacHkBX/MXT26MEQUsdvhGxP27jmEk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cBi4x6/dJMcaacHkBX/MXT26MEQUsdvhGxP27jmEk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cBi4x6/dJMcaacHkBX/MXT26MEQUsdvhGxP27jmEk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcBi4x6%2FdJMcaacHkBX%2FMXT26MEQUsdvhGxP27jmEk%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;1309&quot; height=&quot;1232&quot; data-origin-width=&quot;1309&quot; data-origin-height=&quot;1232&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`MessageChatMemoryAdvisor`는 LLM에게 프롬프트를 전달하기 전에 사용자 메시지를 저장하고, LLM으로부터 받은 응답 메시지를 저장하는 과정을 자동화 해줍니다. 역시 인증용은 jdbc, 미인증용은 인메모리에 저장하도록 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 코드 구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✔️ 컨트롤러&lt;/h3&gt;
&lt;pre id=&quot;code_1764766735501&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/api/chat-bot&quot;)
@RequiredArgsConstructor
public class ChatbotController {

    private final ChatbotService chatbotService;

    @PostMapping
    public Flux&amp;lt;String&amp;gt; postMessage(@CurrentMemberId(required = false) Long memberId,
                                    @RequestBody ChatbotReqDto reqDto,
                                    HttpSession session) {
        return chatbotService.postMessage(memberId, reqDto.message(), session);
    }

    @GetMapping
    public ResponseEntity&amp;lt;List&amp;lt;ChatbotHistoryResDto&amp;gt;&amp;gt; getMessages(@CurrentMemberId(required = false) Long memberId,
                                                                  HttpSession session) {
        List&amp;lt;ChatbotHistoryResDto&amp;gt; response = chatbotService.getMessages(memberId, session);
        return ResponseEntity.ok(response);
    }
}

public record ChatbotReqDto(@NotBlank String message) { }&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;✔️ 서비스&lt;/h3&gt;
&lt;pre id=&quot;code_1764766824448&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class ChatbotService {

    private final ChatClient chatClient;
    private final ChatMemory loginChatMemory;
    private final ChatMemory anonymousChatMemory;
    private final MessageChatMemoryAdvisor loginMemoryAdvisor;
    private final MessageChatMemoryAdvisor anonymousMemoryAdvisor;

    public Flux&amp;lt;String&amp;gt; postMessage(Long memberId, String message, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        MessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;

        return chatClient.prompt()
                         .user(message)
                         .advisors(adv -&amp;gt; adv
                                 .advisors(advisor)
                                 .param(ChatMemory.CONVERSATION_ID, conversationId)
                         )
                         .stream()
                         .content();
    }

    public List&amp;lt;ChatbotHistoryResDto&amp;gt; getMessages(Long memberId, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        ChatMemory chatMemory = isLogin ? loginChatMemory : anonymousChatMemory;

        return chatMemory.get(conversationId)
                         .stream()
                         .map(message -&amp;gt; {
                             String content = message.getText();
                             MessageType messageType = message.getMessageType();
                             return new ChatbotHistoryResDto(content, messageType);
                         })
                         .toList();
    }

    private String getConversationId(HttpSession session) {
        String sessionKey = &quot;conversationId&quot;;
        String conversationId = (String) session.getAttribute(sessionKey);

        if (!StringUtils.hasText(conversationId)) {
            conversationId = UUID.randomUUID().toString();
            session.setAttribute(sessionKey, conversationId);
        }

        return conversationId;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ChatMemory`는 `conversationId` 값으로 어떤 대화에 속한 메시지인지를 구분합니다. 인증 사용자인 경우 고유Id로, 미인증 사용자인 경우 임시 구분용이면 되기 때문에 세션에 uuid로 저장했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;  문제 발생&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`MessageWindowChatMemory` 클래스의 Javadoc을 보면 다음과 같이 설명되어 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;473&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAxHjr/dJMb99Zabg3/9mhkqCj2ZZqNOVnb65xtxK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAxHjr/dJMb99Zabg3/9mhkqCj2ZZqNOVnb65xtxK/img.png&quot; data-alt=&quot;MessageWindowChatMemory&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAxHjr/dJMb99Zabg3/9mhkqCj2ZZqNOVnb65xtxK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAxHjr%2FdJMb99Zabg3%2F9mhkqCj2ZZqNOVnb65xtxK%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;673&quot; height=&quot;473&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;473&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;MessageWindowChatMemory&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;&lt;u&gt;메시지 수가 최대 크기를 초과하면 이전 메시지가 제거됩니다&lt;/u&gt;.&quot;라고 적혀 있습니다. 이는 LLM 호출 시 전달되는 컨텍스트를 적절히 제한해서 &lt;b&gt;비용 절감 + 성능 안정&lt;/b&gt;을 위함이며, &lt;b&gt;최근 N개의 데이터만을 활용해 멀티턴을 구현&lt;/b&gt;합니다. 즉 과거의 오래된 메시지를 제거하여 저장 공간을 과도하게 사용하지 않고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 N개는 `maxMessages` 필드값이며, 기본값은 20으로 설정되어 있습니다. 즉 20개의 메시지만 저장을 하는 것이고, 챗봇과 10번 대화를 주고 받고나서 11번째 대화부터는 과거 대화 기록이 하나씩 제거됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 멀티턴 대화만을 위해서라면 굳이 건드릴 필요는 없지만, 사용자에게 과거 전체 대화 내역을 보여줄 수 있어야 한다고 생각해 DB와 메모리에 전체 메시지를 저장하도록 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; 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;먼저 메모리 또는 DB에 저장하기 위한 인터페이스를 정의했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764850977022&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface ChatbotHistoryMemory {
    void save(String conversationId, Message message);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 인메모리용 레포지토리 구현체입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764851041950&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class InMemoryChatbotHistoryMemory implements ChatbotHistoryMemory {

    Map&amp;lt;String, List&amp;lt;ChatbotHistoryDto&amp;gt;&amp;gt; chatbotHistoryStore = new ConcurrentHashMap&amp;lt;&amp;gt;();

    @Override
    public void save(String conversationId, Message message) {
        chatbotHistoryStore.putIfAbsent(conversationId, new ArrayList&amp;lt;&amp;gt;());
        chatbotHistoryStore.get(conversationId).add(ChatbotHistoryDto.of(message));
    }
}

---------

public record ChatbotHistoryDto(
        MessageType type,
        String text,
        LocalDateTime createdAt) {

    public static ChatbotHistoryDto of(Message message) {
        return new ChatbotHistoryDto(message.getMessageType(), message.getText(), LocalDateTime.now());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리에 저장하기 때문에 단순 Map과 DTO를 사용해 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 DB(Jpa) 저장용 레포지토리 구현체입니다. Jpa를 사용하기 때문에 엔티티를 만들고 JpaRepository에게 위임하도록 했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764851397586&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class JpaChatbotHistoryMemory implements ChatbotHistoryMemory {

    private final ChatbotHistoryRepository chatbotHistoryRepository;

    @Override
    public void save(String conversationId, Message message) {
        chatbotHistoryRepository.save(ChatbotHistory.of(conversationId, message));
    }
}

--------------

public interface ChatbotHistoryRepository extends JpaRepository&amp;lt;ChatbotHistory, Long&amp;gt; {
}

--------------

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = &quot;chatbot_histories&quot;)
public class ChatbotHistory extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;chatbot_history_id&quot;)
    private Long id;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;message_type&quot;, nullable = false)
    private MessageType type;

    @Column(name = &quot;text&quot;, nullable = false)
    private String text;

    @Column(name = &quot;conversation_id&quot;, nullable = false)
    private String conversationId;

    public static ChatbotHistory of(String conversationId, Message message) {
        return new ChatbotHistory(message.getMessageType(), message.getText(), conversationId);
    }

    private ChatbotHistory(MessageType type, String text, String conversationId) {
        this.type = type;
        this.text = text;
        this.conversationId = conversationId;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 기존에 `MessageChatMemoryAdvisor`를 사용해 `maxMessages` 만큼만 메시지를 저장했다면, 이제는 모든 메시지를 저장할 수 있어야 하기 때문에 `MessageChatMemoryAdvisor` 코드를 그대로 가져와 추가로 커스텀하였습니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1764853032022&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class CustomMessageChatMemoryAdvisor implements BaseChatMemoryAdvisor {

    private final ChatMemory chatMemory;
    private final ChatbotHistoryMemory chatbotHistoryMemory; // 추가

    private final String defaultConversationId;
    private final int order;
    private final Scheduler scheduler;

    private CustomMessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int order,
                                           Scheduler scheduler, ChatbotHistoryMemory chatbotHistoryMemory) {
        Assert.notNull(chatMemory, &quot;chatMemory cannot be null&quot;);
        Assert.hasText(defaultConversationId, &quot;defaultConversationId cannot be null or empty&quot;);
        Assert.notNull(scheduler, &quot;scheduler cannot be null&quot;);
        Assert.notNull(chatbotHistoryMemory, &quot;chatbotHistoryMemory cannot be null&quot;);
        this.chatMemory = chatMemory;
        this.defaultConversationId = defaultConversationId;
        this.order = order;
        this.scheduler = scheduler;
        this.chatbotHistoryMemory = chatbotHistoryMemory;
    }

    @Override
    public int getOrder() {
        return this.order;
    }

    @Override
    public Scheduler getScheduler() {
        return this.scheduler;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
        String conversationId = getConversationId(chatClientRequest.context(), this.defaultConversationId);

        // 1. Retrieve the chat memory for the current conversation.
        List&amp;lt;Message&amp;gt; memoryMessages = this.chatMemory.get(conversationId);

        // 2. Advise the request messages list.
        List&amp;lt;Message&amp;gt; processedMessages = new ArrayList&amp;lt;&amp;gt;(memoryMessages);
        processedMessages.addAll(chatClientRequest.prompt().getInstructions());

        // 3. Create a new request with the advised messages.
        ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
                                                                        .prompt(chatClientRequest.prompt().mutate()
                                                                                                 .messages(processedMessages)
                                                                                                 .build())
                                                                        .build();

        // 4. Add the new user message to the conversation memory.
        UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
        this.chatMemory.add(conversationId, userMessage);

        // 추가
        chatbotHistoryMemory.save(conversationId, userMessage);

        return processedChatClientRequest;
    }

    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
        List&amp;lt;Message&amp;gt; assistantMessages = new ArrayList&amp;lt;&amp;gt;();
        if (chatClientResponse.chatResponse() != null) {
            assistantMessages = chatClientResponse.chatResponse()
                                                  .getResults()
                                                  .stream()
                                                  .map(g -&amp;gt; (Message) g.getOutput())
                                                  .toList();
        }
        String conversationId = this.getConversationId(chatClientResponse.context(), this.defaultConversationId);
        this.chatMemory.add(conversationId, assistantMessages);

        // 추가
        assistantMessages.forEach(assistantMessage -&amp;gt; chatbotHistoryMemory.save(conversationId, assistantMessage));

        return chatClientResponse;
    }

    @Override
    public Flux&amp;lt;ChatClientResponse&amp;gt; adviseStream(ChatClientRequest chatClientRequest,
                                                 StreamAdvisorChain streamAdvisorChain) {
        // Get the scheduler from BaseAdvisor
        Scheduler scheduler = this.getScheduler();

        // Process the request with the before method
        return Mono.just(chatClientRequest)
                   .publishOn(scheduler)
                   .map(request -&amp;gt; this.before(request, streamAdvisorChain))
                   .flatMapMany(streamAdvisorChain::nextStream)
                   .transform(flux -&amp;gt; new ChatClientMessageAggregator().aggregateChatClientResponse(flux,
                           response -&amp;gt; this.after(response, streamAdvisorChain)));
    }

    public static CustomMessageChatMemoryAdvisor.Builder builder(ChatMemory chatMemory) {
        return new CustomMessageChatMemoryAdvisor.Builder(chatMemory);
    }

    public static final class Builder {

        private String conversationId = ChatMemory.DEFAULT_CONVERSATION_ID;

        private int order = Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER;

        private Scheduler scheduler = BaseAdvisor.DEFAULT_SCHEDULER;

        private ChatMemory chatMemory;
        private ChatbotHistoryMemory chatbotHistoryMemory; // 추가

        private Builder(ChatMemory chatMemory) {
            this.chatMemory = chatMemory;
        }

        /**
         * Set the conversation id.
         *
         * @param conversationId the conversation id
         * @return the builder
         */
        public CustomMessageChatMemoryAdvisor.Builder conversationId(String conversationId) {
            this.conversationId = conversationId;
            return this;
        }

        /**
         * Set the order.
         *
         * @param order the order
         * @return the builder
         */
        public CustomMessageChatMemoryAdvisor.Builder order(int order) {
            this.order = order;
            return this;
        }

        public CustomMessageChatMemoryAdvisor.Builder scheduler(Scheduler scheduler) {
            this.scheduler = scheduler;
            return this;
        }

        // 추가
        public CustomMessageChatMemoryAdvisor.Builder chatbotHistoryMemory(ChatbotHistoryMemory chatbotHistoryMemory) {
            this.chatbotHistoryMemory = chatbotHistoryMemory;
            return this;
        }

        /**
         * Build the advisor.
         *
         * @return the advisor
         */
        public CustomMessageChatMemoryAdvisor build() {
            // 추가
            if (this.chatbotHistoryMemory == null) {
                this.chatbotHistoryMemory = new InMemoryChatbotHistoryMemory();
            }
            return new CustomMessageChatMemoryAdvisor(this.chatMemory, this.conversationId, this.order, this.scheduler, this.chatbotHistoryMemory);
        }

    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ChatbotHistoryMemory` 인터페이스를 필드로 가져 `before`와 `after`에서 각각 사용자와 LLM 메시지를 저장하는 로직을 추가하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빈 등록도 다음과 같이 수정하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764853213194&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class AIConfig {

    ...
    
    @Bean
    public CustomMessageChatMemoryAdvisor loginMemoryAdvisor(ChatMemory loginChatMemory, ChatbotHistoryMemory chatbotHistoryMemory) {
        return CustomMessageChatMemoryAdvisor.builder(loginChatMemory)
                                             .chatbotHistoryMemory(chatbotHistoryMemory)
                                             .build();
    }

    @Bean
    public CustomMessageChatMemoryAdvisor anonymousMemoryAdvisor(ChatMemory anonymousChatMemory) {
        return CustomMessageChatMemoryAdvisor.builder(anonymousChatMemory).build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 로직에서는 어드바이저를 주입받는 로직만 수정하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764853677837&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    ...
    
    private final CustomMessageChatMemoryAdvisor loginMemoryAdvisor;
    private final CustomMessageChatMemoryAdvisor anonymousMemoryAdvisor;

    public Flux&amp;lt;String&amp;gt; postMessage(Long memberId, String message, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        CustomMessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;

        return chatClient.prompt()
                         .user(message)
                         .advisors(adv -&amp;gt; adv
                                 .advisors(advisor)
                                 .param(ChatMemory.CONVERSATION_ID, conversationId)
                         )
                         .stream()
                         .content();
    }
    
    ...&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;✔️ 메시지 조회&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 조회 같은 경우 서비스에서 `ChatbotHistoryMemory`를 직접 사용하는 대신 이를 참조하고 있는 `CustomMessageChatMemoryAdvisor`에 추가하기로 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;`&lt;/span&gt;ChatbotHistoryMemory` 인터페이스에 조회 메서드를 추가했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764854309953&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface ChatbotHistoryMemory {
    void save(String conversationId, Message message);
    List&amp;lt;ChatbotHistoryDto&amp;gt; getMessages(String conversationId); // 추가
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인메모리용 구현체 구현입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764854453733&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class InMemoryChatbotHistoryMemory implements ChatbotHistoryMemory {

    Map&amp;lt;String, List&amp;lt;ChatbotHistoryDto&amp;gt;&amp;gt; chatbotHistoryStore = new ConcurrentHashMap&amp;lt;&amp;gt;();

    ...

    @Override
    public List&amp;lt;ChatbotHistoryDto&amp;gt; getMessages(String conversationId) {
        return new ArrayList&amp;lt;&amp;gt;(chatbotHistoryStore.getOrDefault(conversationId, List.of()));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 구현체 구현입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764854810486&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class JpaChatbotHistoryMemory implements ChatbotHistoryMemory {

    private final ChatbotHistoryRepository chatbotHistoryRepository;

    ...

    @Override
    public List&amp;lt;ChatbotHistoryDto&amp;gt; getMessages(String conversationId) {
        return chatbotHistoryRepository.findAllByConversationId(conversationId)
                                       .stream()
                                       .map(ChatbotHistoryDto::of)
                                       .toList();
    }
}

------------

public record ChatbotHistoryDto(
        MessageType type,
        String text,
        LocalDateTime createdAt) {

    ...

    public static ChatbotHistoryDto of(ChatbotHistory chatbotHistory) {
        return new ChatbotHistoryDto(chatbotHistory.getType(), chatbotHistory.getText(), chatbotHistory.getCreatedAt());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`CustomMessageChatMemoryAdvisor`에 `getMessages()` 메서드를 추가하였습니다. 참조하고 있는 `ChatbotHistoryMemory`에게 위임합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764855155530&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class CustomMessageChatMemoryAdvisor implements BaseChatMemoryAdvisor {

    private final ChatMemory chatMemory;
    private final ChatbotHistoryMemory chatbotHistoryMemory;

    ...

    private CustomMessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int order,
                                           Scheduler scheduler, ChatbotHistoryMemory chatbotHistoryMemory) {
        ...
    }

	// 추가
    public List&amp;lt;ChatbotHistoryDto&amp;gt; getMessages(String conversationId) {
        return chatbotHistoryMemory.getMessages(conversationId);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 로직에서는 위 메서드를 그대로 호출해서 반환합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1764892691758&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public List&amp;lt;ChatbotHistoryDto&amp;gt; getMessages(Long memberId, HttpSession session) {
    boolean isLogin = memberId != null;

    String conversationId = isLogin ? memberId.toString() : getConversationId(session);

    CustomMessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;
    return advisor.getMessages(conversationId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 수정된 서비스 로직은 다음과 같습니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1764892722758&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import project.airbnb.clone.config.ai.ChatbotHistoryDto;
import project.airbnb.clone.config.ai.CustomMessageChatMemoryAdvisor;
import reactor.core.publisher.Flux;

import java.util.List;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class ChatbotService {

    private final ChatClient chatClient;
    private final CustomMessageChatMemoryAdvisor loginMemoryAdvisor;
    private final CustomMessageChatMemoryAdvisor anonymousMemoryAdvisor;

    public Flux&amp;lt;String&amp;gt; postMessage(Long memberId, String message, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        CustomMessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;

        return chatClient.prompt()
                         .user(message)
                         .advisors(adv -&amp;gt; adv
                                 .advisors(advisor)
                                 .param(ChatMemory.CONVERSATION_ID, conversationId)
                         )
                         .stream()
                         .content();
    }

    public List&amp;lt;ChatbotHistoryDto&amp;gt; getMessages(Long memberId, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        CustomMessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;
        return advisor.getMessages(conversationId);
    }

    private String getConversationId(HttpSession session) {
        String sessionKey = &quot;conversationId&quot;;
        String conversationId = (String) session.getAttribute(sessionKey);

        if (!StringUtils.hasText(conversationId)) {
            conversationId = UUID.randomUUID().toString();
            session.setAttribute(sessionKey, conversationId);
        }

        return conversationId;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/141</guid>
      <comments>https://journal9185.tistory.com/141#entry141comment</comments>
      <pubDate>Fri, 5 Dec 2025 08:59:16 +0900</pubDate>
    </item>
    <item>
      <title>Spring AI 개발 일지 (2) - OpenAI 사용해 후기 요약 구현해보기</title>
      <link>https://journal9185.tistory.com/139</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 벡터 DB, RAG와 같은 임베딩 기술 없이 LLM만 사용하여 Spring AI 기술을 사용해보기 위해 간단한 게시글에 대한 후기 요약 기능을 구현해보겠습니다.&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; 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 id=&quot;code_1763534808853&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation(&quot;org.springframework.ai:spring-ai-starter-model-openai&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣ 설정 파일 추가&lt;/h3&gt;
&lt;pre id=&quot;code_1763534888548&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4.1-mini
          temperature: 0.0
          max-tokens: 1024&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://platform.openai.com/settings/organization/api-keys&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;OpenAI Platform&lt;/a&gt;에서 API Key를 발급 받았습니다. (최소 5$ 결제 필요)&lt;/li&gt;
&lt;li&gt;위에서 설정한 ChatOptions 설정은 스프링부트 자동 설정에 의해 ChatModel 구현체에게 전달됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2769&quot; data-origin-height=&quot;1022&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLbptr/dJMcaa4KTLr/ojrcyvBHXdyNCnurT7HKm1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLbptr/dJMcaa4KTLr/ojrcyvBHXdyNCnurT7HKm1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLbptr/dJMcaa4KTLr/ojrcyvBHXdyNCnurT7HKm1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLbptr%2FdJMcaa4KTLr%2FojrcyvBHXdyNCnurT7HKm1%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;2769&quot; height=&quot;1022&quot; data-origin-width=&quot;2769&quot; data-origin-height=&quot;1022&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;956&quot; data-origin-height=&quot;911&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bb9Mkw/dJMcahCOjeE/oZ6bwEBq85XP8MKwjFbiw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bb9Mkw/dJMcahCOjeE/oZ6bwEBq85XP8MKwjFbiw1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bb9Mkw/dJMcahCOjeE/oZ6bwEBq85XP8MKwjFbiw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbb9Mkw%2FdJMcahCOjeE%2FoZ6bwEBq85XP8MKwjFbiw1%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;956&quot; height=&quot;911&quot; data-origin-width=&quot;956&quot; data-origin-height=&quot;911&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3️⃣ 빈 등록&lt;/h3&gt;
&lt;pre id=&quot;code_1763535926405&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class AIConfig {

    @Bean
    public ChatClient openAiChatClient(OpenAiChatModel openAiChatModel) {
        return ChatClient.create(openAiChatModel);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  `ChatClient`&lt;br /&gt;- `ChatModel`을 한번 감싸 고수준에서 기능을 제공하는 객체로, 프롬프트 조립, 호출 등 메서드 체이닝을 지원합니다.&lt;br /&gt;- 필요한 경우 `ChatModel`을 직접 주입받아 더 세밀한 제어를 할 수 있습니다.&lt;br /&gt;-&amp;nbsp; `ChatClient`는 인터페이스이며, 기본 구현체로 `DefaultChatClient`를 제공합니다.&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여기서 주입받은 `ChatModel` 구현체는 추가한 의존성에 따라 달라집니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;각각 다른 `ChatModel`을 주입받고 여러 `ChatClient`를 빈으로 등록하면 필요에 따라 다른 `ChatModel`을 사용할 수 있을 것 같습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 비즈니스 로직&lt;/h2&gt;
&lt;pre id=&quot;code_1763547067068&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class ReviewSummaryService {

    private final ChatClient chatClient;
    private final ReviewQueryRepository reviewQueryRepository;

    @Value(&quot;${custom.ai.review-summary-prompt}&quot;)
    private String reviewSummaryPrompt;

    public String summarizeReviews(Long postId) {
        List&amp;lt;Review&amp;gt; reviews = reviewQueryRepository.findTop30ByPostId(postId);

        if (reviews.isEmpty()) {
            return &quot;후기가 없습니다.&quot;;
        }

        String reviewsText = reviews.stream()
                                    .map(Review::getComment)
                                    .collect(Collectors.joining(&quot;\n&quot;));

        return chatClient.prompt()
                         .system(reviewSummaryPrompt)
                         .user(&quot;후기:\n&quot; + reviewsText)
                         .call()
                         .content();
    }
}&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;게시글 ID를 전달받아 JPA로 최근 등록된 30개의 후기를 조회합니다.&lt;/li&gt;
&lt;li&gt;후기 엔티티에서 내용만 추출해 AI가 후기를 하나씩 인식할 수 있도록 문자열을 만듭니다.&lt;/li&gt;
&lt;li&gt;`ChatClient`를 사용해 AI에게 요청을 보내 응답값을 반환합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 `SystemMessage`에 사용할 프롬프트 메시지 내용은 다음과 같이 환경변수로 관리하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1763547372158&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;custom:
  ai:
    review-summary-prompt: |
      너는 후기 요약 전문가야.
      주어진 후기를 읽고 핵심 내용만 3~4문단으로 요약해줘.
      각 문단에는 장점, 단점, 반복적으로 언급된 특징, 긍정/부정 비율을 포함하고,
      사용자가 장비 선택에 참고할 수 있도록 구체적이고 자연스러운 문장으로 작성해줘.&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Spring</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/139</guid>
      <comments>https://journal9185.tistory.com/139#entry139comment</comments>
      <pubDate>Wed, 19 Nov 2025 21:34:06 +0900</pubDate>
    </item>
    <item>
      <title>Spring AI 개발 일지 (1) - Spring AI 소개와 핵심 모델</title>
      <link>https://journal9185.tistory.com/138</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ Spring AI란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring AI는 Spring 개발자들을 위한 LLM 통합 도구로, AI와 연관된 도구들 쉽게 통합하도록 하는 프레임워크입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-ai/reference/index.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring AI 공식문서&lt;/a&gt;에 의하면 &quot;Spring AI는 AI 애플리케이션 개발의 기반이 되는 추상화를 제공하여 최소한의 코드 변경으로 구성 요소를 쉽게 교체할 수 있다&quot;고 소개하며, 대표적으로 다음과 같은 기능을 제공한다고 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AI 모델 통합 API 제공
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OpenAI, Anthropic, Microsoft, Amazon, Google, Ollama 등 주요 AI Model Provider를 지원&lt;/li&gt;
&lt;li&gt;Chat Completion, Embedding, Text to Image, Text to Speech 등 다양한 모델 타입을 하나의 일관된 API로 제공&lt;/li&gt;
&lt;li&gt;모델 출력값을 바로 POJO로 매핑하는 Structured Output 기능 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Vector Store 추상화 및 벡터 데이터베이스 지원
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Chroma, Elasticsearch, Neo4j, Qdrant, Redis 등 주요 벡터 DB 지원&lt;/li&gt;
&lt;li&gt;텍스트를 청크로 나누고, 벡터화해서 저장하는 등의 작업들을 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;ChatClient API
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ChatClient, EmbeddingClient와 같은 컴포넌트를 사용해 모델 호출을 HTTP 요청과 같이 간단한 처리 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외에도 AI 호출 모니터링, 챗 메모리 관리, RAG 등을 제공하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ Spring AI 핵심 모델&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣ Prompt&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`Prompt` 클래스는 Spring AI에서 모델에 보낼 메시지와 모델 파라미터 옵션(`ChatOptions`)을 감싸는 역할을 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;682&quot; data-origin-height=&quot;284&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MPcOc/dJMcafLLclg/HbskXituhrFV6pGMLYqJU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MPcOc/dJMcafLLclg/HbskXituhrFV6pGMLYqJU1/img.png&quot; data-alt=&quot;Prompt&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MPcOc/dJMcafLLclg/HbskXituhrFV6pGMLYqJU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMPcOc%2FdJMcafLLclg%2FHbskXituhrFV6pGMLYqJU1%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;682&quot; height=&quot;284&quot; data-origin-width=&quot;682&quot; data-origin-height=&quot;284&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Prompt&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 Prompt는 &quot;어떤 메시지를 보낼지&quot;와 &quot;어떤 옵션으로 보낼지&quot;를 담고 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣ ChatOptions&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ChatOptions`는 LLM 호출 시 사용할 다양한 파라미터를 정의한 인터페이스로, 대부분의 LLM에서 공통으로 사용될 수 있는 옵션들만 포함하고 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;619&quot; data-origin-height=&quot;102&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c1vpnG/dJMcabih8eA/9p9rFbDUk4mFNiKqfp4KvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c1vpnG/dJMcabih8eA/9p9rFbDUk4mFNiKqfp4KvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c1vpnG/dJMcabih8eA/9p9rFbDUk4mFNiKqfp4KvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc1vpnG%2FdJMcabih8eA%2F9p9rFbDUk4mFNiKqfp4KvK%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;619&quot; height=&quot;102&quot; data-origin-width=&quot;619&quot; data-origin-height=&quot;102&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;824&quot; data-origin-height=&quot;964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uN2ua/dJMcacuJy8i/XLMRutTrDURIpetqC2ml30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uN2ua/dJMcacuJy8i/XLMRutTrDURIpetqC2ml30/img.png&quot; data-alt=&quot;ChatOptions&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uN2ua/dJMcacuJy8i/XLMRutTrDURIpetqC2ml30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuN2ua%2FdJMcacuJy8i%2FXLMRutTrDURIpetqC2ml30%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;824&quot; height=&quot;964&quot; data-origin-width=&quot;824&quot; data-origin-height=&quot;964&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ChatOptions&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`maxTokens`, `temperature`, `stopSequences`와 같이 `ChatOptions`가 제공하는 속성은 벤더 간 자동 변환됩니다. 반면 `seed`, `logitBias`와 같은 정의되지 않은 추가 옵션에 대해서는 직접 매핑이 필요합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3️⃣ ChatModel&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ChatModel`은 LLM과의 기본적인 상호작용을 담당하는 인터페이스로, Spring AI 동작의 기반이 되는 핵심 컴포넌트입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1252&quot; data-origin-height=&quot;1190&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ds11y2/dJMcaaDGm9z/5wOp85YSAbvom4pYTQ5Dy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ds11y2/dJMcaaDGm9z/5wOp85YSAbvom4pYTQ5Dy0/img.png&quot; data-alt=&quot;ChatModel&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ds11y2/dJMcaaDGm9z/5wOp85YSAbvom4pYTQ5Dy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fds11y2%2FdJMcaaDGm9z%2F5wOp85YSAbvom4pYTQ5Dy0%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;1252&quot; height=&quot;1190&quot; data-origin-width=&quot;1252&quot; data-origin-height=&quot;1190&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ChatModel&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ChatModel` 인터페이스의 구현체는 `ChatResponse`라는 공통 응답 객체를 반환합니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;864&quot; data-origin-height=&quot;1078&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pUV9x/dJMcabvPdkr/xqln9KhHkWO0YNKKkb9Eu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pUV9x/dJMcabvPdkr/xqln9KhHkWO0YNKKkb9Eu1/img.png&quot; data-alt=&quot;ChatResponse&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pUV9x/dJMcabvPdkr/xqln9KhHkWO0YNKKkb9Eu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpUV9x%2FdJMcabvPdkr%2Fxqln9KhHkWO0YNKKkb9Eu1%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;864&quot; height=&quot;1078&quot; data-origin-width=&quot;864&quot; data-origin-height=&quot;1078&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ChatResponse&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;944&quot; data-origin-height=&quot;472&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqCoJ2/dJMcabo3ysW/wXyJvrzuNFD1vcVFayjOVk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqCoJ2/dJMcabo3ysW/wXyJvrzuNFD1vcVFayjOVk/img.png&quot; data-alt=&quot;ChatResponseMetadata&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqCoJ2/dJMcabo3ysW/wXyJvrzuNFD1vcVFayjOVk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqCoJ2%2FdJMcabo3ysW%2FwXyJvrzuNFD1vcVFayjOVk%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;944&quot; height=&quot;472&quot; data-origin-width=&quot;944&quot; data-origin-height=&quot;472&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ChatResponseMetadata&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ChatResponse` 응답 객체 안에는 모델의 출력 메시지 외에도 사용된 프롬프트, 모델 파라미터, 응답 시간 등의 메타 정보도 포함되어 있어서, 후처리나 로깅, 디버깅 시에도 유용하게 활용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 내부 동작 순서 정리&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;691&quot; data-origin-height=&quot;791&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sBBms/dJMcabih82z/DmqQVlfPwuciLCvDzmIar1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sBBms/dJMcabih82z/DmqQVlfPwuciLCvDzmIar1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sBBms/dJMcabih82z/DmqQVlfPwuciLCvDzmIar1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsBBms%2FdJMcabih82z%2FDmqQVlfPwuciLCvDzmIar1%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;691&quot; height=&quot;791&quot; data-origin-width=&quot;691&quot; data-origin-height=&quot;791&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;입력으로 받은 `Prompt`를 벤더의 API 형식에 맞게 변환&lt;/li&gt;
&lt;li&gt;변환된 메시지를 사용하여 벤더의 API를 호출&lt;/li&gt;
&lt;li&gt;벤더로부터 받은 응답을 `ChatResponse` 형식으로 변환하여 반환&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;  참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.sionic.ai/spring-ai-series-1#1ebb25e3-2acc-80ea-bd33-c61be025f426&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://blog.sionic.ai/spring-ai-series-1#1ebb25e3-2acc-80ea-bd33-c61be025f426&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=up4TrRvLI-E&amp;amp;list=PLJkjrxxiBSFCgcsP_pzuntmqC3AlTMWFx&amp;amp;index=2&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/watch?v=up4TrRvLI-E&amp;amp;list=PLJkjrxxiBSFCgcsP_pzuntmqC3AlTMWFx&amp;amp;index=2&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-ai/reference/index.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-ai/reference/index.html&lt;/a&gt;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/138</guid>
      <comments>https://journal9185.tistory.com/138#entry138comment</comments>
      <pubDate>Wed, 19 Nov 2025 11:47:29 +0900</pubDate>
    </item>
    <item>
      <title>[Infra] 무중단 배포 (롤링, 블루-그린, 카나리)</title>
      <link>https://journal9185.tistory.com/137</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 사전 지식&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;&lt;b&gt;1. CI/CD&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffc9af;&quot;&gt;&lt;b&gt;CI&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CI (Continuous Integration)&lt;/b&gt;는 &lt;b&gt;지속적 통합&lt;/b&gt;이라는 뜻으로, 개발자를 위해 빌드와 테스트를 자동화하는 과정을 의미합니다. CI는 변경 사항을 자동으로 테스트해 애플리케이션에 문제가 없다는 것을 보장합니다. 그리고 코드를 정기적으로 빌드하고, 테스트하여 여러 명이 동시에 작업을 하는 경우 충돌을 방지하고 모니터링할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 코드 변경 사항이 깃허브와 같은 코드 저장소에 업로드되면 CI를 시작하고, CI 도중 문제가 생기면 실패하므로 코드의 오류도 쉽게 파악할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffc9af;&quot;&gt;CD&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CD는 CI 작업을 끝낸 다음 실행하는 작업으로, 배포 준비가 된 코드를 서버에 배포하는 작업을 자동화합니다. CI가 통과되면 개발자가 수작업으로 코드를 배포하지 않아도 자동으로 배포가 되어, CD는 &lt;b&gt;지속적 제공과 지속적 배포&lt;/b&gt;라는 의미를 가집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;b&gt;지속적 제공 (Continuous Delivery)&lt;/b&gt;&lt;/u&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;u&gt;&lt;b&gt;지속적 배포 (Continuous Deploy)&lt;/b&gt;&lt;/u&gt; :&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지속적 제공을 통해 성공적으로 병합한 코드 내역을 AWS와 같은 배포 환경으로 보내는 것을 의미합니다. 릴리즈(Release)라고도 하며, 지속적 배포는 지속적 제공의 다음 단계까지 자동화합니다. 즉 개발자가 애플리케이션에 변경 사항을 커밋한 후 애플리케이션을 자동으로 배포되어 적용됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;5231&quot; data-origin-height=&quot;1428&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qIIoE/dJMb99Y229K/G57jB0tpNyo3MikTO6QZ90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qIIoE/dJMb99Y229K/G57jB0tpNyo3MikTO6QZ90/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qIIoE/dJMb99Y229K/G57jB0tpNyo3MikTO6QZ90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqIIoE%2FdJMb99Y229K%2FG57jB0tpNyo3MikTO6QZ90%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;5231&quot; height=&quot;1428&quot; data-origin-width=&quot;5231&quot; data-origin-height=&quot;1428&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;2. 리버스 프록시 &amp;amp; 로드 밸런싱&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3198&quot; data-origin-height=&quot;2250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BkIxi/dJMcaap7lpe/kjhFZeDhAeKTnW9lKcscE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BkIxi/dJMcaap7lpe/kjhFZeDhAeKTnW9lKcscE1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BkIxi/dJMcaap7lpe/kjhFZeDhAeKTnW9lKcscE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBkIxi%2FdJMcaap7lpe%2FkjhFZeDhAeKTnW9lKcscE1%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;3198&quot; height=&quot;2250&quot; data-origin-width=&quot;3198&quot; data-origin-height=&quot;2250&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;b&gt;리버스 프록시(Reverse Proxy)&lt;/b&gt;&lt;/u&gt; : &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;클라이언트 요청을 받아서 내부 서버로 전달하고, 응답도 대신 받아 전달&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;b&gt;로드 밸런싱(Load Balancing)&lt;/b&gt;&lt;/u&gt; : 특정 알고리즘에 의해 여러 백엔드 서버로 트래픽을 분산시키는 역할, 로드밸런서에 의해 동작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근에는 서버 앞단에 &lt;b&gt;Nginx&lt;/b&gt;를 두어 리버스 프록시와 로드 밸런서 역할을 함께 수행하도록 구성하는 방식이 널리 사용되고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 무중단 배포 방식&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;u&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;&lt;b&gt;1. 롤링 배포&lt;/b&gt;&lt;/span&gt;&lt;/u&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2056&quot; data-origin-height=&quot;1392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/C01rm/dJMcagRparo/Sk1wPKeE1lET3sS9cSirUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/C01rm/dJMcagRparo/Sk1wPKeE1lET3sS9cSirUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/C01rm/dJMcagRparo/Sk1wPKeE1lET3sS9cSirUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FC01rm%2FdJMcagRparo%2FSk1wPKeE1lET3sS9cSirUK%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;2056&quot; height=&quot;1392&quot; data-origin-width=&quot;2056&quot; data-origin-height=&quot;1392&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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;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;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;새 버전을 배포할 때 인스턴스 수가 감소하기 때문에 서비스 처리 용량을 고려해야 한다.&lt;/li&gt;
&lt;li&gt;배포가 진행되는 동안 구버전과 신버전이 공존하기 때문에 호환성 문제가 발생할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;2. 블루-그린 배포&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2157&quot; data-origin-height=&quot;2327&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eNYqeT/dJMcafrqjTJ/IelXkkJeIXm2spOmSK1BG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eNYqeT/dJMcafrqjTJ/IelXkkJeIXm2spOmSK1BG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eNYqeT/dJMcafrqjTJ/IelXkkJeIXm2spOmSK1BG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeNYqeT%2FdJMcafrqjTJ%2FIelXkkJeIXm2spOmSK1BG0%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;2157&quot; height=&quot;2327&quot; data-origin-width=&quot;2157&quot; data-origin-height=&quot;2327&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Blue(구버전)와 Green(버전) 두 환경을 준비해, &lt;b&gt;새 버전이 완전히 준비되면 트래픽을 한 번에 Green으로 전환하는 방식&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;
&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;/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;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;3. 카나리 배포&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3604&quot; data-origin-height=&quot;2032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Duhcc/dJMcahitvgc/9YI9wusRYhiz6Hoen5BLx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Duhcc/dJMcahitvgc/9YI9wusRYhiz6Hoen5BLx1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Duhcc/dJMcahitvgc/9YI9wusRYhiz6Hoen5BLx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDuhcc%2FdJMcahitvgc%2F9YI9wusRYhiz6Hoen5BLx1%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;3604&quot; height=&quot;2032&quot; data-origin-width=&quot;3604&quot; data-origin-height=&quot;2032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&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;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;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;/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;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;  참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=sIPU_VkrguI&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/watch?v=sIPU_VkrguI&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@znftm97/%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%ED%99%98%EA%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0#span-stylecolor0b6e995-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EB%B0%A9%EC%8B%9Dspan&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@znftm97/%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%ED%99%98%EA%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0#span-stylecolor0b6e995-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EB%B0%A9%EC%8B%9Dspan&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.samsungsds.com/kr/insights/1256264_4627.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.samsungsds.com/kr/insights/1256264_4627.html&lt;/a&gt;&lt;/p&gt;</description>
      <category>Infra</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/137</guid>
      <comments>https://journal9185.tistory.com/137#entry137comment</comments>
      <pubDate>Sat, 15 Nov 2025 13:23:34 +0900</pubDate>
    </item>
    <item>
      <title>[Spring WebSocket] STOMP - convertAndSendToUser 알고 사용하기</title>
      <link>https://journal9185.tistory.com/136</link>
      <description>&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Stomp 기술 중 `SimpMessageSendingOperations.convertAndSendToUser()` 메서드를 사용해 특정 사용자에게만 메시지를 전달하는 과정에서의 시행착오와 디버깅을 통해 해결하는 과정을 정리하고자 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1546&quot; data-origin-height=&quot;423&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Y75wg/dJMcain5xeW/9lbveKGSmJyVfOK0qetyqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Y75wg/dJMcain5xeW/9lbveKGSmJyVfOK0qetyqK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Y75wg/dJMcain5xeW/9lbveKGSmJyVfOK0qetyqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FY75wg%2FdJMcain5xeW%2F9lbveKGSmJyVfOK0qetyqK%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;1546&quot; height=&quot;423&quot; data-origin-width=&quot;1546&quot; data-origin-height=&quot;423&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 채팅 서비스 설계상 사용자 A가 사용자 B에게 채팅 요청을 보내면 사용자 B에게 채팅 요청을 수락 또는 거절할 수 있는 메시지를 보내는 이벤트를 발행하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이벤트를 받아 처리하는 서비스 로직에서 `convertAndSendToUser()` 메서드를 사용하고 있습니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1762177606102&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class ChatEventListener {

    private final ChatNotifyService chatNotifyService;

    @EventListener
    public void handleChatRequestCreatedEvent(ChatRequestCreatedEvent event) {
        chatNotifyService.sendChatRequestNotification(event.chatRequest());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class ChatNotifyService {

    private final SimpMessageSendingOperations messageTemplate;

    public void sendChatRequestNotification(ChatRequest chatRequest) {
        StompChatRequestNotification notification = StompChatRequestNotification.builder()
                                                                                .requestId(chatRequest.getRequestId())
                                                                                .senderId(chatRequest.getSenderId())
                                                                                .senderName(chatRequest.getSenderName())
                                                                                .senderProfileImage(chatRequest.getSenderProfileImage())
                                                                                .expiresAt(chatRequest.getExpiresAt())
                                                                                .build();
        messageTemplate.convertAndSendToUser(
                String.valueOf(chatRequest.getReceiverId()),
                &quot;/queue/chat-requests&quot;,
                notification);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;`convertAndSendToUser()` 메서드는 &lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;첫 번째&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;인자로 `String user`를 받고 있습니다. 따라서 사용자의 식별자(Id)를 전달하면 되겠거니 하고 Id를 전달해 봤습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 WebSocket 설정 클래스는 다음과 같이 구성했습니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1762218490811&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint(&quot;/connect&quot;)
                .setAllowedOrigins(&quot;http://localhost:3000&quot;)
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes(&quot;/publish&quot;)
                .enableSimpleBroker(&quot;/topic&quot;, &quot;/queue&quot;);
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 아무리 해봐도 프론트에서 이를 전달받지 못했습니다. 그래서 내부적으로 어떤 식으로 처리되는지 알아봐야겠다고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; 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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣ SimpMessagingTemplate&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1903&quot; data-origin-height=&quot;602&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CGi7t/dJMcajN4llp/NoKw8UcJvQwvnY2r2FCyE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CGi7t/dJMcajN4llp/NoKw8UcJvQwvnY2r2FCyE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CGi7t/dJMcajN4llp/NoKw8UcJvQwvnY2r2FCyE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCGi7t%2FdJMcajN4llp%2FNoKw8UcJvQwvnY2r2FCyE0%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;1903&quot; height=&quot;602&quot; data-origin-width=&quot;1903&quot; data-origin-height=&quot;602&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  `this.destinationPrefix`는 `/user/`로 디폴트 값이 설정되어 있는데, 설정 클래스에서 따로 지정할 수 있습니다.&lt;br /&gt;
&lt;pre id=&quot;code_1762222426705&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    //...
    registry.setUserDestinationPrefix(&quot;/custom&quot;); //커스텀 설정
}&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣ UserDestinationMessageHandler&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1309&quot; data-origin-height=&quot;728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/be4BRK/dJMcagw2DUm/yXo6ceV5kk04kP4quNXYT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/be4BRK/dJMcagw2DUm/yXo6ceV5kk04kP4quNXYT1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/be4BRK/dJMcagw2DUm/yXo6ceV5kk04kP4quNXYT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbe4BRK%2FdJMcagw2DUm%2FyXo6ceV5kk04kP4quNXYT1%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;1309&quot; height=&quot;728&quot; data-origin-width=&quot;1309&quot; data-origin-height=&quot;728&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3️⃣ DefaultUserDestinationResolver&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1281&quot; data-origin-height=&quot;938&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6Eb22/dJMcagjvufa/9wjIh0B0ML0cY3YbJSDAQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6Eb22/dJMcagjvufa/9wjIh0B0ML0cY3YbJSDAQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6Eb22/dJMcagjvufa/9wjIh0B0ML0cY3YbJSDAQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6Eb22%2FdJMcagjvufa%2F9wjIh0B0ML0cY3YbJSDAQK%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;1281&quot; height=&quot;938&quot; data-origin-width=&quot;1281&quot; data-origin-height=&quot;938&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4️⃣ DefaultSimpUserRegistry&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;209&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/O9gjv/dJMcaap3Chj/0rDwtctn9qeuzXVBYT46TK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/O9gjv/dJMcaap3Chj/0rDwtctn9qeuzXVBYT46TK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/O9gjv/dJMcaap3Chj/0rDwtctn9qeuzXVBYT46TK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FO9gjv%2FdJMcaap3Chj%2F0rDwtctn9qeuzXVBYT46TK%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;792&quot; height=&quot;209&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;209&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`this.users`에 아무런 정보도 저장되어 있지 않습니다. 결론적으로 `DefaultUserDestinationResolver`에서 빈 sessionIds를 반환하고 `UserDestinationMessageHandler`에서는 `targetDestinations`가 비어있어 메시지 전송 로직을 호출하지 못하고 return이 되고 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;1274&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4Jiqu/dJMcagcJTIg/A0U2sv8SgDHPoGPQILESpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4Jiqu/dJMcagcJTIg/A0U2sv8SgDHPoGPQILESpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4Jiqu/dJMcagcJTIg/A0U2sv8SgDHPoGPQILESpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4Jiqu%2FdJMcagcJTIg%2FA0U2sv8SgDHPoGPQILESpk%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;1842&quot; height=&quot;1274&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;1274&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론만 먼저 정리하자면 위와 같은 문제를 해결하기 위해 `JwtHandshakeInterceptor`라는 클래스와 `StompHandshakeHandler`라는 클래스를 추가했습니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1762248355886&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class JwtHandshakeInterceptor implements HandshakeInterceptor {

    private final JwtProvider jwtProvider;

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map&amp;lt;String, Object&amp;gt; attributes) throws Exception {
        String token = getTokenFromQuery(request);

        if (token != null) {
            jwtProvider.validateToken(token);
            Long id = jwtProvider.getId(token);
            attributes.put(&quot;userId&quot;, id);

            return true;
        }

        return false;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { /*do nothing*/ }

    private String getTokenFromQuery(ServerHttpRequest request) {
        String query = request.getURI().getQuery();
        if (query != null) {
            String[] params = query.split(&quot;&amp;amp;&quot;);
            for (String param : params) {
                if (param.startsWith(&quot;token=&quot;)) {
                    return param.substring(6);
                }
            }
        }
        return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1762248394446&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;
    private final JwtHandshakeInterceptor jwtHandshakeInterceptor; //추가

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint(&quot;/connect&quot;)
                .setAllowedOrigins(&quot;http://localhost:3000&quot;)
                .addInterceptors(jwtHandshakeInterceptor) //추가
                .setHandshakeHandler(new StompHandshakeHandler()) //추가
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes(&quot;/publish&quot;)
                .enableSimpleBroker(&quot;/topic&quot;, &quot;/queue&quot;);
        registry.setUserDestinationPrefix(&quot;/user&quot;);
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }

	//추가
    private static class StompHandshakeHandler extends AbstractHandshakeHandler {

        @Override
        protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map&amp;lt;String, Object&amp;gt; attributes) {
            return () -&amp;gt; attributes.get(&quot;userId&quot;).toString();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 프론트 쪽에서는 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;WebSocket&lt;/span&gt;을 연결할 때 JWT를 파라미터로 전달합니다. SockJS가 WebSocket 연결 초기 요청(핸드셰이크) 시 커스텀 HTTP 헤더를 전달하지 않기 때문에 서버에서 헤더로는 읽을 수가 없었습니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1762251277143&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const client = new Client({
  webSocketFactory: () =&amp;gt;
    new SockJS(
      `${
        process.env.NEXT_PUBLIC_API_BASE_URL
      }/connect?token=${encodeURIComponent(accessToken)}`
    ),
  connectHeaders: {
    Authorization: `Bearer ${accessToken}`,
  },
  debug: (str) =&amp;gt; {
    console.log(&quot;STOMP Debug:&quot;, str);
  },
  heartbeatIncoming: 4000,
  heartbeatOutgoing: 4000,
  reconnectDelay: 0,
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런 다음 위 클래스들이 어떤 과정으로 호출되는지 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 &lt;b&gt;WebSocket은 초기 TCP 기반의 3-way handshake 과정을 반드시 한번 거칩니다&lt;/b&gt;. 본격적인 handshake 과정 전 `HandshakeInterceptor`의 구현체(들)가 동작하며, 여기서 위에서 등록한 `JwtHandshakeInterceptor`가 동작하게 됩니다. 그리고 이 과정에서 `attributes`에 원하는 값을 추가로 저장할 수 있습니다. `attributes`는 같은 세션에서 공유되는 Map 저장소입니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2388&quot; data-origin-height=&quot;686&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kR0Jt/dJMcakzrA77/CFYDUl0krv0FhlUPwOKqrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kR0Jt/dJMcakzrA77/CFYDUl0krv0FhlUPwOKqrK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kR0Jt/dJMcakzrA77/CFYDUl0krv0FhlUPwOKqrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkR0Jt%2FdJMcakzrA77%2FCFYDUl0krv0FhlUPwOKqrK%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;2388&quot; height=&quot;686&quot; data-origin-width=&quot;2388&quot; data-origin-height=&quot;686&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 본격적인 handshake를 수행하는 메서드에서는 &lt;b&gt;upgrade 요청으로 WebSocket 프로토콜로 전환&lt;/b&gt;하는데, 이때 `determineUser`를 호출해 user를 얻고 upgrade 요청에 user를 넘기고 있습니다. 그리고 `determineUser`는 위에서 직접 만든 클래스가 동작합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1854&quot; data-origin-height=&quot;560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZKAgW/dJMcaiBCXpA/5dQgpBG684QK7BxrS6KWw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZKAgW/dJMcaiBCXpA/5dQgpBG684QK7BxrS6KWw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZKAgW/dJMcaiBCXpA/5dQgpBG684QK7BxrS6KWw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZKAgW%2FdJMcaiBCXpA%2F5dQgpBG684QK7BxrS6KWw0%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;1854&quot; height=&quot;560&quot; data-origin-width=&quot;1854&quot; data-origin-height=&quot;560&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 세션이기 때문에 `attributes`에는 `HandshakeInterceptor` 과정에서 저장된 값들이 그대로 담겨 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqzk4o/dJMcacak8OZ/aUzztz85B0IyVIxmchxKD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqzk4o/dJMcacak8OZ/aUzztz85B0IyVIxmchxKD1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqzk4o/dJMcacak8OZ/aUzztz85B0IyVIxmchxKD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbqzk4o%2FdJMcacak8OZ%2FaUzztz85B0IyVIxmchxKD1%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;1842&quot; height=&quot;392&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;392&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 `SessionConnectedEvent`를 발행하고 `DefaultSimpUserRegistry`에서 이 이벤트를 받아서 users에 저장합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1992&quot; data-origin-height=&quot;728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TFZ5s/dJMcaeTwW0Z/k8JCIRlcBatIq9tzHIvts0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TFZ5s/dJMcaeTwW0Z/k8JCIRlcBatIq9tzHIvts0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TFZ5s/dJMcaeTwW0Z/k8JCIRlcBatIq9tzHIvts0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTFZ5s%2FdJMcaeTwW0Z%2Fk8JCIRlcBatIq9tzHIvts0%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;1992&quot; height=&quot;728&quot; data-origin-width=&quot;1992&quot; data-origin-height=&quot;728&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1281&quot; data-origin-height=&quot;1736&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lIVIG/dJMcaawPkWW/SM5U3DR3vHl4bsGTgHlKLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lIVIG/dJMcaawPkWW/SM5U3DR3vHl4bsGTgHlKLk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lIVIG/dJMcaawPkWW/SM5U3DR3vHl4bsGTgHlKLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlIVIG%2FdJMcaawPkWW%2FSM5U3DR3vHl4bsGTgHlKLk%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;1281&quot; height=&quot;1736&quot; data-origin-width=&quot;1281&quot; data-origin-height=&quot;1736&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제는 `DefaultSimpUserRegistry`에서 세션에 해당하는 user 정보를 가져올 수 있기 때문에 `UserDestinationMessageHandler`에서 이후 로직을 정상적으로 처리할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;1274&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKPA4h/dJMcaacwxvl/nMqLY0azcvUaSKIsAOSNv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKPA4h/dJMcaacwxvl/nMqLY0azcvUaSKIsAOSNv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKPA4h/dJMcaacwxvl/nMqLY0azcvUaSKIsAOSNv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKPA4h%2FdJMcaacwxvl%2FnMqLY0azcvUaSKIsAOSNv0%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;1842&quot; height=&quot;1274&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;1274&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;  정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 디버깅 과정을 통해 WebSocket 기술을 이해하는 데 많은 도움이 되었습니다. 중요한 점은 스프링은 모든 과정 사이사이에 개발자가 직접 커스텀한 기능을 구현할 수 있도록 많은 기능을 지원하고 있기 때문에 디버깅을 하면서 적절히 커스텀이 필요한 부분을 찾는 과정인 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은&amp;nbsp;고민을&amp;nbsp;하고&amp;nbsp;계시는&amp;nbsp;다른&amp;nbsp;분들에게&amp;nbsp;조금이나마&amp;nbsp;도움이&amp;nbsp;되었으면&amp;nbsp;좋겠고,&amp;nbsp;더&amp;nbsp;좋은&amp;nbsp;의견&amp;nbsp;주시면&amp;nbsp;감사하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.6em 0.5em 0.6em 0.5em; margin: 0.5em 0em; color: #000; border-left: 10px solid #0f2443; border-bottom: 2px solid #e5e5e5; font-weight: bold;&quot; data-ke-size=&quot;size26&quot;&gt;  참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=MPQHvwPxDUw&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/watch?v=MPQHvwPxDUw&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nijy.tistory.com/267#Principal%EC%9D%84%20%EC%A7%81%EC%A0%91%20%EC%A0%95%EC%9D%98%ED%95%98%EC%97%AC%20%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%20%EB%B0%A9%EB%B2%95-1&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nijy.tistory.com/267#Principal%EC%9D%84%20%EC%A7%81%EC%A0%91%20%EC%A0%95%EC%9D%98%ED%95%98%EC%97%AC%20%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%20%EB%B0%A9%EB%B2%95-1&lt;/a&gt;&lt;/p&gt;</description>
      <category>트러블슈팅</category>
      <author>이런개발</author>
      <guid isPermaLink="true">https://journal9185.tistory.com/136</guid>
      <comments>https://journal9185.tistory.com/136#entry136comment</comments>
      <pubDate>Tue, 4 Nov 2025 21:46:52 +0900</pubDate>
    </item>
  </channel>
</rss>