<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Leo 개발 블로그</title>
    <link>https://farmer-eom.tistory.com/</link>
    <description>씨앗을 심듯 공부한 내용을 기록합니다.</description>
    <language>ko</language>
    <pubDate>Sun, 23 Aug 2026 15:34:57 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Leoo</managingEditor>
    <image>
      <title>Leo 개발 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/8525006/attach/07ce0be8472b44dd9dc8212084f84264</url>
      <link>https://farmer-eom.tistory.com</link>
    </image>
    <item>
      <title>Redis 분산락은 안전할까? - 쿨럭 드리프트와 펜싱토큰</title>
      <link>https://farmer-eom.tistory.com/6</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 동료에게 이런 말을 들었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;RedLock은 노드마다 시계가 어긋나면 락이 깨질 수 있다.&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;생각해보니 정말 일리있는 말인것 같다. RedLock은 독립적인 Redis 노드를 이용해 과반수 이상의 키를 소유했는지 여부로 판단하는데 여러 노드 간의 시계열이 어긋나면 TTL에 의해서 키가 사라지고 락이 깨질 수 있을것 같다.&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;a href=&quot;https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1786861586528&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;How to do distributed locking &amp;mdash; Martin Kleppmann&amp;rsquo;s blog&quot; data-og-description=&quot;How to do distributed locking Published by Martin Kleppmann on 08 Feb 2016. As part of the research for my book, I came across an algorithm called Redlock on the Redis website. The algorithm claims to implement fault-tolerant distributed locks (or rather, &quot; data-og-host=&quot;martin.kleppmann.com&quot; data-og-source-url=&quot;https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html&quot; data-og-url=&quot;https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/0sXlH/dJMb9hDhq8p/lUk1lSp86ZNfZ7L8ck1Zr0/img.png?width=1100&amp;amp;height=400&amp;amp;face=0_0_1100_400,https://scrap.kakaocdn.net/dn/csuELW/dJMb9jODaHf/TIGyHK64xDEHuhSkEkzoOk/img.png?width=1100&amp;amp;height=400&amp;amp;face=0_0_1100_400&quot;&gt;&lt;a href=&quot;https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/0sXlH/dJMb9hDhq8p/lUk1lSp86ZNfZ7L8ck1Zr0/img.png?width=1100&amp;amp;height=400&amp;amp;face=0_0_1100_400,https://scrap.kakaocdn.net/dn/csuELW/dJMb9jODaHf/TIGyHK64xDEHuhSkEkzoOk/img.png?width=1100&amp;amp;height=400&amp;amp;face=0_0_1100_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;How to do distributed locking &amp;mdash; Martin Kleppmann&amp;rsquo;s blog&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;How to do distributed locking Published by Martin Kleppmann on 08 Feb 2016. As part of the research for my book, I came across an algorithm called Redlock on the Redis website. The algorithm claims to implement fault-tolerant distributed locks (or rather,&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;martin.kleppmann.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마틴 클레프만의 글을 정리해보면 아래와 같이 정리 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서로 다른 redis 노드에 키와 TTL을 저장한다. 만약 `PX 3000` 을 보내면 redis는 만료될 절대 시간을 계산해서 저장한다. 그리고 그 계산은 노드의 시계를 기준으로 동작한다. 만약 노드가 3개라면 3개의 시계가 존재하며 그 3개의 시계는 서로 맞추지 않는다.(물론 엘라스틱 캐시나 NTP 설정을 통해서 어느정도 맞긴 한다.)&amp;nbsp; 이 경우 TTL을 1초로 했다면 정말로 3개 노드의 시계이 딱 맞을까?&amp;nbsp; 이 시계이 어그러지면 어떻게 될까?&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;h2 data-ke-size=&quot;size26&quot;&gt;1. 시나리오&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;초당 1,000 요청, 선착순 쿠폰 3만장, 오픈 후 30초 이내 소진&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;위 상황에서 고려해야할 항목은 아래와 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정확도 : 3만장인데 3만 900장이 발급된다면 사고다.&lt;/li&gt;
&lt;li&gt;줄을 세워야 한다 :&amp;nbsp; 선착순이기 때문에 모든 요청이 그 뒤에 서게 된다.&lt;/li&gt;
&lt;li&gt;멈춰도 시간은 흐른다 : 락을 쥔 프로세스가 멈춰도 TTL은 만료된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 시나리오 테스트를 위해서 2번 측정을 진행했다.(로컬 테스트라 요청 수 자체는 조절했다..)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정확성 : 수량을 100으로 두고 소진시킨다. -&amp;gt; 초과 발급 발생 여부 확인&lt;/li&gt;
&lt;li&gt;성능 : 수량을 100만으로 두고 절대 소진되지 않게 한다. -&amp;gt; 모든 요청에 락 경합이 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 쿨럭 드리프트 - 시계는 어긋난다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴퓨터의 시계는 똑같이 맞췄더라고 하더라도 시간이 지나면 벌어진다. ( 물리부품이기 때문에 개체마다 주기가 미세하게 다르고 온도와 환경에 따라 변한다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 보통은 NTP를 이용해서 맞추는 데, 맞추는 방식이 두 가지다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;slew : 시계를 살짝 빠르게/느리게 돌려서 서서히 맞춘다.&lt;/li&gt;
&lt;li&gt;step : 현재 시각 = 정확한 시각 으로 확 바꾼다. &lt;b&gt;시간이 튀거나 뒤로간다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2.1&amp;nbsp; 락이 깨지는 이유&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis의 TTL은 &lt;b&gt;절대 시각으로 저장된다. `PX 3000`&amp;nbsp;&lt;/b&gt;을 주면 3초 뒤가 아니라 현재 시각 + 3000ms 라는 타임스탬프를 기록하고, 만료 검사는 그값을 그 노드의 시스템 시간과 비교한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;클라이언트: &quot;TTL 3초를 잡았고 획득에 200ms 썼으니 2.768초는 안전하다&quot; &lt;br /&gt;노드 C: &quot;내 시계로는 이미 만료 시각이 지났다&quot;&amp;nbsp;&lt;br /&gt;-&amp;gt; 클라이언트와 노드가 서로 소통하지 않는다.&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;Redis 노드 끼리는 시간을 맞추지 않는다. 각자 따로 NTP를 볼 뿐이며, RedLock 프로토콜에도 시각을 동기화하는 단계가 없다. 맞추는 과정에서 사고가 나는게 아니라,&amp;nbsp;&lt;b&gt;맞추는 과정이 아예 없어서&amp;nbsp;&lt;/b&gt;각 노드가 제멋대로 만료를 판정한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;12:00:00 A가 3대 전부 획득. TTL 3초&lt;br /&gt;&lt;br /&gt;12:00:01 node-2, node-3의 NTP가 step으로 +5초 점프 &lt;br /&gt;&amp;rarr; 기록된 만료 시각을 이미 지났다 &lt;br /&gt;&amp;rarr; 두 노드에서 락 키가 사라진다 &lt;br /&gt;&amp;nbsp;ㄴ A에게 아무도 알려주지 않는다&lt;br /&gt;&lt;br /&gt;12:00:01 B가 node-2, node-3 획득 &amp;rarr; 2/3 과반 &amp;rarr; &quot;락 획득 성공&quot; A와 B가 동시에 임계 구역에 있다&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;&lt;b&gt;2.2 락 정상 동작 VS 클럭 드리프트 발생&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 로컬에서 테스트를 하고 있기 때문에 시계를 돌리는 건 생각보다 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너에서 `date -s` 를 하면 커널의 &lt;b&gt;CLOCK_REALTIME&lt;/b&gt;을 공유하기 떄문에 호스트와 다른 컨테이너까지 전부 영향 받는다. 리눅스에 time namespace가 있긴 하짐나&amp;nbsp;&lt;b&gt;CLOCK_MONOTONIC과 CLOCK_BOOTTIME&amp;nbsp;&lt;/b&gt;만 가상화하고 &lt;b&gt;CLOCK_REALTIME&lt;/b&gt;은 의도적으로 제외한다. 하필 Redis의 만료 판정을 보는 시계가 저 값이다.&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;그래서 시계 대신 TTL을 건드려서 테스트 환경을 만들었다.&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;blockquote data-ke-style=&quot;style2&quot;&gt;TTL을 1000ms로 줬는데, 100ms 만에 키가 사라진다. 왜? redis 노드별 시간이 어긋났기때문이다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 63.4879%; height: 60px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 8.3333%; height: 20px; text-align: center;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 28.4032%; height: 20px; text-align: center;&quot;&gt;방법&lt;/td&gt;
&lt;td style=&quot;width: 26.7518%; height: 20px; text-align: center;&quot;&gt;결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 8.3333%; height: 20px; text-align: center;&quot;&gt;A&lt;/td&gt;
&lt;td style=&quot;width: 28.4032%; height: 20px; text-align: center;&quot;&gt;노드 시계를 900ms 앞으로 돌린다&lt;/td&gt;
&lt;td style=&quot;width: 26.7518%; height: 20px; text-align: center;&quot;&gt;100ms 만에 만료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 8.3333%; height: 20px; text-align: center;&quot;&gt;B&lt;/td&gt;
&lt;td style=&quot;width: 28.4032%; height: 20px; text-align: center;&quot;&gt;그 노드에만 TTL을 100ms로 준다&lt;/td&gt;
&lt;td style=&quot;width: 26.7518%; height: 20px; text-align: center;&quot;&gt;100ms 만에 만료&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;p data-ke-size=&quot;size16&quot;&gt;밖에서 보기엔 A와 B가 동일하다. 관측되는건 &quot;언제 키가 사라졌나&quot; 이므로&amp;nbsp; 둘다 100ms이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 3개의 redis 노드 중 2개에만 준다. 왜냐하면 전부 100ms로 TTL을 설정하면 그건 그냥&amp;nbsp;&lt;b&gt;TTL을 100ms로 준 것이고 노드 끼리 어긋난 상황이 아니다.&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;T=0 A 가 3/3 키 획득&lt;br /&gt;--------------------------------------------------&lt;br /&gt;T=100 노드2, 노드3 만 만료 (노드1 은 살아있음) &lt;br /&gt;&amp;rarr; A 는 아직 노드1 을 갖고 있다 &lt;br /&gt;--------------------------------------------------&lt;br /&gt;T=100 B 가 락 획득 시도 &amp;rarr; 노드1 실패, 노드2 성공, 노드3 성공 &lt;br /&gt;&amp;rarr; 2/3 과반 &amp;rarr; &quot;획득 성공&quot; &lt;br /&gt;A: 노드1 보유&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; - 둘 다 자기가 락을 가졌다고 믿는다 &lt;br /&gt;B: 노드2+노드3 보유&amp;nbsp; - 과반이 두 번 만들어졌다&lt;br /&gt;&lt;br /&gt;결과적으로 임계 영역이 깨진다.&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;실험 설계&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수를 하나만 두기 위해 나머지는 전부 고정했다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 114px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;락&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;RedLock(독립 노드 3대)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;쓰기 방식&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;읽고-계산하고-쓰기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;TTL&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;1000ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;락 타임아웃&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;2000ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;부하&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;40 VU X 25초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;쿠폰 수량&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;100장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;time skew&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;900ms&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;p data-ke-size=&quot;size16&quot;&gt;RedLock 코드&lt;/p&gt;
&lt;pre id=&quot;code_1786896250170&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.study.redisallpack.infrastructure.lock

import com.study.config.redis.RedLockNodes
import com.study.redisallpack.domain.lock.DistributedLock
import com.study.redisallpack.domain.lock.Fence
import com.study.redisallpack.domain.lock.FenceGenerator
import com.study.redisallpack.domain.lock.LockAcquisitionException
import com.study.redisallpack.domain.lock.LockType
import io.lettuce.core.ScriptOutputType
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Component
import io.lettuce.core.SetArgs
import io.lettuce.core.api.StatefulRedisConnection
import java.time.Duration
import java.util.UUID
import java.util.concurrent.ThreadLocalRandom

@Component
@ConditionalOnProperty(name = [&quot;redis.redlock.enabled&quot;], havingValue = &quot;true&quot;, matchIfMissing = true)
class RedLock(
    private val nodes: RedLockNodes,
    private val fenceGenerator: FenceGenerator,
) : DistributedLock {

    private val log = LoggerFactory.getLogger(javaClass)

    override val type = LockType.REDLOCK

    override fun &amp;lt;T&amp;gt; withLock(key: String, wait: Duration, lease: Duration, block: (Fence) -&amp;gt; T): T {
        require(nodes.size &amp;gt; 0) { &quot;RedLock 노드가 없습니다. redis.redlock.nodes 를 설정하세요.&quot; }

        val token = UUID.randomUUID().toString()
        val deadline = System.nanoTime() + wait.toNanos()

        while (true) {
            val validity = tryAcquireQuorum(key, token, lease)

            if (validity != null) {
                log.debug(&quot;[RedLock] 획득 key={} 유효={}ms&quot;, key, validity.toMillis())

                return try {
                    block(Fence(fenceGenerator.next(key), validity))
                } finally {
                    releaseAll(key, token)
                }
            }

            if (System.nanoTime() &amp;gt;= deadline) {
                throw LockAcquisitionException(key, type, &quot;과반을 얻지 못했습니다. key=$key 노드=${nodes.size}&quot;)
            }

            Thread.sleep(ThreadLocalRandom.current().nextLong(RETRY_JITTER_MILLIS))
        }
    }

    private fun tryAcquireQuorum(key: String, token: String, lease: Duration): Duration? {
        val startedAt = System.nanoTime()

        val futures = nodes.connections.mapIndexed { i, conn -&amp;gt;
            val px = (lease.toMillis() - nodes.skewOf(i)).coerceAtLeast(1)
            conn.async().set(key, token, SetArgs.Builder.nx().px(px))
        }

        val acquired = futures.count { runCatching { it.get() }.getOrNull() == OK }

        val elapsed = Duration.ofNanos(System.nanoTime() - startedAt)
        val validity = validity(lease, elapsed)

        if (acquired &amp;gt;= nodes.quorum &amp;amp;&amp;amp; !validity.isNegative &amp;amp;&amp;amp; !validity.isZero) {
            return validity
        }

        log.debug(
            &quot;[RedLock] 실패 key={} 획득={}/{} 과반={} 경과={}ms 유효={}ms&quot;,
            key, acquired, nodes.size, nodes.quorum, elapsed.toMillis(), validity.toMillis(),
        )

        releaseAll(key, token)

        return null
    }

    private fun releaseAll(key: String, token: String) {
        nodes.connections.forEach { conn -&amp;gt;
            runCatching {
                conn.async().eval&amp;lt;Long&amp;gt;(UNLOCK_SCRIPT, ScriptOutputType.INTEGER, arrayOf(key), token)
            }.onFailure { log.debug(&quot;[RedLock] 해제 실패 key={} {}&quot;, key, it.message) }
        }
    }

    companion object {
        private const val OK = &quot;OK&quot;

        const val CLOCK_DRIFT_FACTOR = 0.01
        const val CLOCK_DRIFT_ALLOWANCE_MILLIS = 2L

        fun drift(lease: Duration): Duration =
            Duration.ofMillis((lease.toMillis() * CLOCK_DRIFT_FACTOR).toLong() + CLOCK_DRIFT_ALLOWANCE_MILLIS)

        fun validity(lease: Duration, elapsed: Duration): Duration = lease - elapsed - drift(lease)

        private const val RETRY_JITTER_MILLIS = 20L

        private val UNLOCK_SCRIPT = &quot;&quot;&quot;
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            else
                return 0
            end
        &quot;&quot;&quot;.trimIndent()
    }
}&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;br /&gt;정상적인 상황&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 72px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;HTTP 200(발급 성공)&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;100 Req&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;DB 실제 발급&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;100 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;남은 수량&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;0 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;허수 발급&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;0 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;소진 이후 응답&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;41 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;락 타임아웃&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;440 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;총 요청&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;581 건&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1897&quot; data-origin-height=&quot;903&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sPtHq/dJMcahlh37c/Rb9fXmvTuAxRL8yWafyxT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sPtHq/dJMcahlh37c/Rb9fXmvTuAxRL8yWafyxT0/img.png&quot; data-alt=&quot;정상적인 상황&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sPtHq/dJMcahlh37c/Rb9fXmvTuAxRL8yWafyxT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsPtHq%2FdJMcahlh37c%2FRb9fXmvTuAxRL8yWafyxT0%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;1897&quot; height=&quot;903&quot; data-origin-width=&quot;1897&quot; data-origin-height=&quot;903&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 클럭 드리프트가 발생하지 않은 정상적인 상황의 결과는 쿠폰 100장이 모두 정상적으로 소진되었다.&lt;/p&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;클럭 드리프트가 발생한 상황&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%; height: 152px;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;HTTP 200(발급 성공)&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;103 Req&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;DB 실제 발급&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;100 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;남은 수량&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 18px; text-align: center;&quot;&gt;0 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;허수 발급&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;3 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;소진 이후 응답&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;199 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;락 타임아웃&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;344 건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;총 요청&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;646 건&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1897&quot; data-origin-height=&quot;903&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ednpmr/dJMcadXp51F/9J0BY5urnBQQ8gbRPDrsE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ednpmr/dJMcadXp51F/9J0BY5urnBQQ8gbRPDrsE1/img.png&quot; data-alt=&quot;쿨럭 드리프트가 발생한 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ednpmr/dJMcadXp51F/9J0BY5urnBQQ8gbRPDrsE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fednpmr%2FdJMcadXp51F%2F9J0BY5urnBQQ8gbRPDrsE1%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;1897&quot; height=&quot;903&quot; data-origin-width=&quot;1897&quot; data-origin-height=&quot;903&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;&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;RedLock을 이용해서 분산락을 구현한 경우, 정상적인 경우와 클럭 드리프트가 발생한 케이스를 비교해보면 결과가 확실하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정확성이 박살낫다 - 실제 DB로 인해서 발급 자체는 100건이지만 HTTP 요청이 200으로 3명에게 허위 발급되었다.&lt;/li&gt;
&lt;li&gt;지표상으로는 좋아 보인다. - 락 타임아웃이 440 -&amp;gt; 344건으로 줄었다. 그럴만 한게 락이 더 빨리 풀렸으니 대기가 짧아져 지표상으로는 좋아보인다.&lt;/li&gt;
&lt;li&gt;DB 숫자는 멀쩡하다 - 락 경합이 깨졌기 때문에 아래와 같은 상황에서 유저는 쿠폰을 정상 발급 받았다고 전달 받을 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;A: SELECT issued=99 &amp;rarr; remaining=1&amp;gt;0 통과 &amp;rarr; issued=100 쓰기 &lt;br /&gt;B: SELECT issued=99 &amp;rarr; remaining=1&amp;gt;0 통과 &amp;rarr; issued=100 쓰기 &lt;br /&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; &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; &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;uarr; A의 증가를 덮어쓴다&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;&lt;b&gt;2.3 시계를 맞춰도 깨진다 - GC&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 redis 노드 간 시계가 깨지는 경우는 많지 않은것 같다. 여기에 NTP를 잘 맞추거나, AWS를 사용하고 있다면 ElasticCache를 쓰면 더욱 그렇다.(RedLock으로 분산락을 구현하는 경우가 많을까? 이건 잘 모르겠다. 클러스터도 아닌 서로 다른 독립적인 Redis 노드가 필요한데?)&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;NTP가 완벽하고 3개의 노드의 시계가 나노초까지 일치한다고 가정해보자.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;12:00:00.000 A가 락 획득. TTL 3초&lt;br /&gt;12:00:00.010 A의 JVM이 Full GC 시작 &lt;br /&gt;&amp;mdash; Stop-The-World 스레드 전부 정지 &amp;mdash;&lt;br /&gt;12:00:03.000 TTL 만료. 락 해제 (시계는 정확하다. 예정대로 만료된 것뿐이다) &lt;br /&gt;12:00:03.100 B가 락 획득. 정당한 획득이다 &lt;br /&gt;12:00:05.000 A가 깨어난다. 자기가 5초간 멈췄다는 걸 모른다 &lt;br /&gt;A: 쿠폰 발급 실행 &amp;larr; B와 동시에 임계 구역&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;위 케이스에서는 A와 B 모두 락을 정당하게 획득했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 프로세스가 멈추게 되면 동일하게 락 경합이 깨지게 될 수 있다. 그리고 멈추는 원인은 GC 뿐만이 아니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 58px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;원인&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;규모&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;JVM Full GC / 대용량 힙 STW&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 20px;&quot;&gt;수백 ms ~ 수 초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;컨테이너 CPU throottling&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;수백 ms ~ 수 초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;메모리 페이징 스왑&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;~ 수 초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;기타&lt;/td&gt;
&lt;td style=&quot;width: 50%; 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;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;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;락이 안 풀리게 하겠다&quot; -&amp;gt; &quot;풀려도 상관 없게 하겠다.&quot;&amp;nbsp;&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;&lt;b&gt;2.4&amp;nbsp; 펜싱 토큰&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;펜싱 토큰은 락을 획득 할때마다 발급되는&amp;nbsp;&lt;b&gt;단조 증가하는 번호로&amp;nbsp;&lt;/b&gt;쓰기 요청에 함께 실어 보내고, 보호 대상 자원이 &quot;내가 본 것보다 작거나 같은 번호는 거부한다&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;조건&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;뜻&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;왜 필요한가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;락 회득 시점에 발급&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;일을 시작하기 전에 이미 손에 쥔다&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;멈춘 뒤에 받으면 최신 번호를 받아버려 의미가 없다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;단조 증가&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;절대 되돌아가지 않는다.&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;되감기면 지난 차례의 쓰기가 다시 통과한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;자원이 검사&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;락이 아니라 쓰기 대상이 판단한다&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; 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;p data-ke-size=&quot;size16&quot;&gt;락 경합 실패했던 상황에 토큰을 넣으면 아래와 같이 방어가 가능하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;12:00:00 A가 락 획득 &amp;rarr; 토큰 5&amp;larr; 뚫리기 전에 이미 받았다 &lt;br /&gt;12:00:00 A: 작업 중... &lt;br /&gt;12:00:00.1 노드2, 노드3 조기 만료 (클럭 드리프트) &lt;br /&gt;12:00:00.1 B가 과반 획득 &amp;rarr; 토큰 6 &amp;rarr; 발급, lastFence = 6 &lt;br /&gt;12:00:00.4 A: 작업 끝, issue(5) 호출 5 &amp;lt;= 6 &amp;rarr; 거부&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;A는 여전히 자기가 락을 가진걸로 알고 있지만 토큰이 6보다 작기 때문에 거부된다.&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RedLock 에서 락 획득은 과반의 노드가 내 키를 갖고 있는 상태다. 그런데, 각 노드가 알 고 있는 값은 &lt;b&gt;누가 내 키를 SET 했는가? 뿐이다.&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;node-1: &quot;lock:coupon 키에 UUID-abc 가 들어있다&quot;&lt;br /&gt;node-2: &quot;lock:coupon 키에 UUID-abc 가 들어있다&quot; &lt;br /&gt;node-3: &quot;lock:coupon 키에 UUID-abc 가 들어있다&quot;&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;따라서, RedLock으로 분산락을 구현하고 펜싱 토큰까지 넣기 위해서는 외부 도움이 필요하다.&lt;/p&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;외부 툴&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;순서를 만드는 방법&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;펜싱 토큰&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;Zookeeper&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;합의 로그(zxid)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;로그에서 자연스럽게 추출됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;etcd&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;합의 로그(revision)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; 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;p data-ke-size=&quot;size16&quot;&gt;조금 쉽게 구현하려면 RDB 낙관적 락을 이용해서 토큰을 발급할 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;UPDATE fence_sequence SET token = token + 1 &lt;br /&gt;WHERE fence_key = :key AND token = :expected&amp;nbsp; &amp;nbsp; -- 내가 읽은 값이 그대로일 때만&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;`token`이 버전 역할을 수행하면 된다. 단조 증가하므로 별도의 `version` 컬럼도 필요없다.&lt;/p&gt;
&lt;pre id=&quot;code_1787152599731&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 현재 토큰을 읽는다.
val current = repository.findByFenceKey(key) ?: create(key)

// 토큰 비교 := 낙관적 락
if (repository.bumpIfUnchanged(key, current.token) &amp;gt; 0) {
	return current.token + 1
}&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;b&gt;2.5&amp;nbsp; 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;펜싱토큰을 구현(제공)하기 위한 방법은 여러 가지 있지만 일반적으로는 아래와 같이 3가지 방법이 사용된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis를 이용한 펜싱 토큰 : INCR, 원자적, 재시도 없으나 failover시 되 감김&lt;/li&gt;
&lt;li&gt;Rdb를 이용한 펜싱 토큰 : 낙관적 락, 재시도 있으나 되감기지 않음&lt;/li&gt;
&lt;li&gt;Zookeeper를 이용한 펜싱 토큰 : 순차 znode 생성, 되감기지 않으나 과반수 이상의 fsync가 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그중 Redis와 Rdb를 이용한 펜싱 토큰으로 분산락의 락을 유지하고 성능을 측정해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Redis 펜싱 토큰 분산락 결과&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;565&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wk9OR/dJMcaiqSLC8/QX01AakBU4pQ3ETmndPWx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wk9OR/dJMcaiqSLC8/QX01AakBU4pQ3ETmndPWx1/img.png&quot; data-alt=&quot;Redis 펜싱 토큰&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wk9OR/dJMcaiqSLC8/QX01AakBU4pQ3ETmndPWx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwk9OR%2FdJMcaiqSLC8%2FQX01AakBU4pQ3ETmndPWx1%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;1894&quot; height=&quot;565&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;565&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Redis 펜싱 토큰&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;517&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4Nzr3/dJMcaa0HPDy/tlqTdnfvrs5mb1K4wqAeQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4Nzr3/dJMcaa0HPDy/tlqTdnfvrs5mb1K4wqAeQk/img.png&quot; data-alt=&quot;토큰 발급 P95&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4Nzr3/dJMcaa0HPDy/tlqTdnfvrs5mb1K4wqAeQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4Nzr3%2FdJMcaa0HPDy%2FtlqTdnfvrs5mb1K4wqAeQk%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;1894&quot; height=&quot;517&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;517&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;토큰 발급 P95&lt;/figcaption&gt;
&lt;/figure&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;HTTP 200 : 100건&lt;/li&gt;
&lt;li&gt;DB 실제 : 100건&lt;/li&gt;
&lt;li&gt;허수 발급 : 0건&lt;/li&gt;
&lt;li&gt;펜싱 토큰에 의한 쓰기 차단 : 45건&lt;/li&gt;
&lt;li&gt;락타임아웃 : 348건&lt;/li&gt;
&lt;li&gt;P95 토큰 발급 : 0.81ms&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Rdb 펜싱 토큰 분산락 결과&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;832&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cfbXuu/dJMcacYxr9A/TszuXVEHiAvsbrBi7MQlGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cfbXuu/dJMcacYxr9A/TszuXVEHiAvsbrBi7MQlGK/img.png&quot; data-alt=&quot;RDB 펜싱 토큰&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cfbXuu/dJMcacYxr9A/TszuXVEHiAvsbrBi7MQlGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcfbXuu%2FdJMcacYxr9A%2FTszuXVEHiAvsbrBi7MQlGK%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;1894&quot; height=&quot;832&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;832&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;RDB 펜싱 토큰&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;514&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AOblp/dJMcadQBz0F/8dx6sHIrdVfO1wqsddvwHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AOblp/dJMcadQBz0F/8dx6sHIrdVfO1wqsddvwHK/img.png&quot; data-alt=&quot;토큰 발급 P95&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AOblp/dJMcadQBz0F/8dx6sHIrdVfO1wqsddvwHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAOblp%2FdJMcadQBz0F%2F8dx6sHIrdVfO1wqsddvwHK%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;1894&quot; height=&quot;514&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;514&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;토큰 발급 P95&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTTP 200 : 100건&lt;/li&gt;
&lt;li&gt;DB 실제 : 100건&lt;/li&gt;
&lt;li&gt;허수 발급 : 0건&lt;/li&gt;
&lt;li&gt;펜싱 토큰에 의한 쓰기 차단 : 54건&lt;/li&gt;
&lt;li&gt;락타임아웃 : 351건&lt;/li&gt;
&lt;li&gt;P95 토큰 발급 : 7.91ms(Redis와 비교했을때 10배 정도 느려짐)&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락은 자기가 만료된 걸 모르는 클라이언트를 막을 방법이 원리적으로 없어보인다. 그래서 Zookeeper, RDB, 별도 Redis 등의 외부 도구를 이용해서 락이 깨지더라도 쓰기를 막도록 해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 RedLock을 이용한 경우 외부 도구를 어떤 도구를 쓸지 판단해야하는데&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 구분할 수 있겠다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;상황&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;빠르고 가끔 틀려도 되는것(단순 중복 작업 방지)&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;Redis INCR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;틀리면 안되고 자원이 DB 안&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;낙관적 락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;틀리면 안되고 자원이 DB 밖&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;Zookeeper&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>
      <author>Leoo</author>
      <guid isPermaLink="true">https://farmer-eom.tistory.com/6</guid>
      <comments>https://farmer-eom.tistory.com/6#entry6comment</comments>
      <pubDate>Thu, 20 Aug 2026 01:27:26 +0900</pubDate>
    </item>
    <item>
      <title>코루틴 완정 정복(1) (feat, Virtual Thread)</title>
      <link>https://farmer-eom.tistory.com/5</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;시작하며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이전 글 포스팅을 짚어보면, Block VS Reactive(WebFlux) 비교, 가상 스레드(Virtual Thread)와 `synchronized` 까지 다루었었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 그 이유는 내가 그때까지는 Java를 사용했었기 때문이다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 지금은 자연스럽게 코틀린을 사용하고 있고 자연스럽게 관심은 코루틴과 가상스레드의 비교가 너무 하고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 포스팅에서는 JDK 25 기준 Virtual Thread와 Coroutine 을 비교하고 실측까지 해보려고 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개념 정리 먼저!&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모두가 알고 있는 내용들이지만 Coroutine에 들어가기 전에 간단한 개념 정리부터 하려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`lanuch`/ `async` 같은 구체적인 API로 들어가기 전에, 그 밑바탕이 되는 두 가지부터 짚고 가야한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;suspend - 코틀린이 제공하는 유일한 문법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`launch`/ 'async`는 라이브러리 함수지만 `suspend`는 진짜 코틀린 언어 키워드이다. `suspend fun`으로 선언하면 컴파일러가 그 함수를&amp;nbsp;&lt;b&gt;일시 중단하고 나중에 재개할 수 있는 형태&lt;/b&gt;로 바꿔준다.&lt;/p&gt;
&lt;pre id=&quot;code_1785170044490&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;suspend fun fetchUser(id: Long): User {
    delay(100)          // 여기서 &quot;일시 중단&quot;될 수 있다
    return userRepository.findById(id)
}&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;size14&quot;&gt;내부적으로는 컴파일러가 이 함수를 어떻게 처리하는지 쉽게 표현하면 아래와 같이 표현할 수 있겠다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;지금까지 진행한 게임 진행 상태(몇 스테이지, 몇번째 단계)와 갖고 있는 아이템을&amp;nbsp;&lt;b&gt;세이브 파일&lt;/b&gt;에 저장한다.&lt;/li&gt;
&lt;li&gt;게임기를 그냥 끈다.&lt;/li&gt;
&lt;li&gt;나중에 세이브 파일을 불러오면(어떤 PC에서든), 처음부터가 아니라 &lt;b&gt;저장해둔 그 지점부터 정확히 이어서&amp;nbsp;&lt;/b&gt;시작한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`suspend fun`이 딱 이렇게 동작한다.&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;세이브 파일 -&amp;nbsp;&lt;/b&gt;컴파일러가 만든 Continuation 객체로서 그냥&amp;nbsp;&lt;b&gt;어디서부터 다시 시작해야 하는지 다 적어놓은 메모 한 장&lt;/b&gt;이라고 생각하면 된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;몇 번째 지점까지 왔는지 -&amp;nbsp;&lt;/b&gt;그 메모 안의 진행 위치(`label`이 딱 이 개념이다)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그때까지 모은 아이템 -&amp;nbsp;&lt;/b&gt;그때까지의 지역변수들, 이것도 세이브 파일 안에 같이 적혀 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;게임기를 끈다 -&amp;nbsp;&lt;/b&gt;함수가 스레드를 반납하고 그냥 `return`해버리는 것. 게임기(스레드)는 꺼져도 세이브 파일(언속)은 안 사라진다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;어떤 PC에서든 -&amp;nbsp;&lt;/b&gt;`delay`가 끝났을 때, 어떤 스레드라도 그 세이브 파일을 들고 이어서 재개할 수 있다.(곡 처음 그 스레드일 필요가 없다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;즉, `delay(100)` 같은 지점(suspension point)에서&amp;nbsp;&lt;/b&gt;함수는 멈춰서 기다리는게 아니라&amp;nbsp;&lt;b&gt;세이브하고 스레드를 완전히 놓아준 뒤 나중에이어하는걸로 이해하면 된다.&lt;/b&gt; 반면에 `Thread.sleep()`은 세이브 파일을 만들지 않는다. 따라서&amp;nbsp;&lt;b&gt;게임기를 켠 채로 일시정지 화면만 띄워놓고 계속 붙잡고 있는 것&lt;/b&gt;과 같아서 그동안 그 스레드로는 아무것도 못 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;suspend 디컴파일&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;실제 코드에서 decomile 한 결과를 보면 좀더 자세히 확인 할 수 잇다.&lt;/p&gt;
&lt;pre id=&quot;code_1785573550216&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;suspend fun fetchUser(id: Long): String {
    val token = fetchToken(id)   // suspend 호출 1
    delay(100)                   // suspend 호출 2
    return &quot;user-$id-$token&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1785573643215&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;static Object fetchUser$suspendImpl(BasicsService $this, long id, Continuation $completion) {
    label27: {
        // 넘어온 게 이미 이 함수의 상태 머신이면(=재개), 그걸 그대로 쓴다
        if ($completion instanceof &amp;lt;fetchUser$1&amp;gt; $continuation) {
            // label의 최상위 비트가 켜져 있으면 &quot;재개 중&quot;이라는 표시 &amp;rarr; 비트 끄고 진행
            if (($continuation.label &amp;amp; Integer.MIN_VALUE) != 0) {
                $continuation.label -= Integer.MIN_VALUE;
                break label27;
            }
        }
        // 최초 진입이면 세이브 파일(상태 머신)을 새로 만든다
        $continuation = new ContinuationImpl($this, $completion) {
            long J$0;        // id 저장 슬롯 (J = long)
            Object L$0;      // token 저장 슬롯 (L = Object)
            Object result;   // 재개될 때 받은 결과
            int label;       // 몇 번째 지점까지 왔는지

            public final Object invokeSuspend(Object $result) {
                this.result = $result;
                this.label |= Integer.MIN_VALUE;   // &quot;재개 중&quot; 비트를 켜고
                return BasicsService.fetchUser$suspendImpl($this, 0L, this);  // 자기 자신을 넘겨 재호출
            }
        };
    }

    Object $result = $continuation.result;
    Object var7 = IntrinsicsKt.getCOROUTINE_SUSPENDED();   // = SUSPENDED
    String token;
    Object var10000;
    switch ($continuation.label) {
        case 0:
            ResultKt.throwOnFailure($result);
            $continuation.J$0 = id;              // id를 세이브 파일에 저장
            $continuation.label = 1;
            var10000 = $this.fetchToken(id, $continuation);  // 세이브 파일을 콜백으로 넘김
            if (var10000 == var7) return var7;   // 멈추면 그냥 리턴(게임기 끄기)
            break;
        case 1:
            id = $continuation.J$0;              // 재개: 저장해둔 id 복원
            ResultKt.throwOnFailure($result);
            var10000 = $result;
            break;
        case 2:
            id = $continuation.J$0;              // 재개: id, token 둘 다 복원
            token = (String) $continuation.L$0;
            ResultKt.throwOnFailure($result);
            return &quot;user-&quot; + id + &quot;-&quot; + token;   // 최종 결과
        default:
            throw new IllegalStateException(&quot;call to 'resume' before 'invoke' with coroutine&quot;);
    }

    // case 0 또는 1을 통과한 뒤 공통으로 실행되는 부분 (두 번째 suspend 호출)
    token = (String) var10000;
    $continuation.L$0 = token;                   // token을 세이브 파일에 저장
    $continuation.J$0 = id;
    $continuation.label = 2;
    if (DelayKt.delay(100L, $continuation) == var7) {
        return var7;                             // delay가 멈추면 리턴
    } else {
        return &quot;user-&quot; + id + &quot;-&quot; + token;       // 안 멈췄으면 바로 결과
    }
}&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;size14&quot;&gt;조금 복잡해보이지만, 앞서 세이브 파일 비유로 짚은 뼈대는 그대로다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;`suspend fun`은 파라미터가 하나 더 있다. - `Continuation $completion`. 원본 `suspend fun fetchUser(id: Long): String`이 `Object fetchUser(long, Continuation)`으로, 파라미터에 continuation이 붙고 반환 타입도 `Object`로 바뀐 게 그대로 보인다. 이게 세이브파일이라고 할 수 있다.&lt;/li&gt;
&lt;li&gt;함수 몸통이 `switch(label)`로 쪼개진다.&amp;nbsp; - `label`이 진행 위치, 지역변수(id, token)는 상태 머신 필드의 (`J$0`, `L$0`)로 옮겨진다.&amp;nbsp; 스택이 아니라 힙에 사는 세이브 파일 안에 저장된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;나중에 `delay(100)`이 끝나면 스케줄러가 세이브 파일의 `invokeSuspend(result)`를 호출하고 그안에서 `fetchUser$suspendImpl`이 자기 자신(`this`)을 넘겨 재호출된다. `switch`가 case2로 점프해 저장해준 id/token으로 결과를 만든다. 이때 재개 스레드는 처음 멈춘 스레드와 달라도 상관 없다. (상태가 스택이 아니라 힙 객체로 들고 다닌다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span&gt;한&lt;/span&gt; &lt;span&gt;가지&lt;/span&gt; &lt;span&gt;제약이&lt;/span&gt; &lt;span&gt;있다&lt;/span&gt; &amp;mdash; &lt;b&gt;`suspend fun`&lt;/b&gt;&lt;span&gt;&lt;b&gt;은&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;다른&lt;/b&gt;&lt;/span&gt;&lt;b&gt; `suspend fun` &lt;/b&gt;&lt;span&gt;&lt;b&gt;안에서나&lt;/b&gt;&lt;/span&gt;&lt;b&gt;, &lt;/b&gt;&lt;span&gt;&lt;b&gt;코루틴&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;빌더&lt;/b&gt;&lt;/span&gt;&lt;b&gt;(`launch`/`async`/`runBlocking`) &lt;/b&gt;&lt;span&gt;&lt;b&gt;안에서만&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;호출할&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;수&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;있다&lt;/b&gt;&lt;/span&gt;&lt;b&gt;.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;span&gt;일반&lt;/span&gt; &lt;span&gt;함수에서&lt;/span&gt; &lt;span&gt;그냥&lt;/span&gt; &lt;span&gt;호출은&lt;/span&gt; &lt;span&gt;안&lt;/span&gt; &lt;span&gt;된다&lt;/span&gt;(&lt;span&gt;컴파일&lt;/span&gt; &lt;span&gt;에러&lt;/span&gt;). &lt;span&gt;이게&lt;/span&gt; &lt;span&gt;앞서&lt;/span&gt; Virtual Thread&lt;span&gt;와&lt;/span&gt; &lt;span&gt;비교할&lt;/span&gt; &lt;span&gt;때&lt;/span&gt; &lt;span&gt;언급했던&lt;/span&gt; suspend &lt;span&gt;전염&lt;/span&gt;(coloring)이며,&amp;nbsp; suspend &lt;span&gt;함수를&lt;/span&gt; &lt;span&gt;하나&lt;/span&gt; &lt;span&gt;쓰기&lt;/span&gt; &lt;span&gt;시작하면&lt;/span&gt; &lt;span&gt;그걸&lt;/span&gt; &lt;span&gt;호출하는&lt;/span&gt; &lt;span&gt;함수도&lt;/span&gt; suspend&lt;span&gt;가&lt;/span&gt; &lt;span&gt;돼야&lt;/span&gt; &lt;span&gt;하고&lt;/span&gt;, &lt;span&gt;그&lt;/span&gt; &lt;span&gt;위도&lt;/span&gt; &lt;span&gt;계속&lt;/span&gt; &lt;span&gt;그래야&lt;/span&gt; &lt;span&gt;하는&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;그래서 코루틴이 뭔데?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;여기까지 `suspend`(멈췄다 재개할 수 있는 함수)만 봤는데, 그럼 코루틴이 뭘까?&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;suspend 함수 - &lt;/b&gt;멈췄다 이어갈 수 있는 함수라는 설계도이며, 그냥 선언만으로는 아무것도 실행되지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;coroutine(코루틴) - &lt;/b&gt;그 suspend 함수를 실제로 실행하는 하나의 작업 단위를 말한다. 스레드와 비슷하지만 훨씬 가볍고 스레드를 붙잡지 않고 멈출 수 있다. 마치 Virtual Trhead 처럼!&lt;/li&gt;
&lt;li&gt;&lt;b&gt;코루틴 빌더 -&amp;nbsp;&lt;/b&gt;코루틴을 실제로 만들어서 시작시키는 함수를 말한다. 바로 `launch`, `async`, `runBlocking`이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;coroutineScope - 구조화된 동시성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;coroutineScope는 여러 코루틴을 하나로 묶어서 관리하는 울타리정도 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&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;그중 하나가&amp;nbsp;&lt;b&gt;실패&lt;/b&gt;하면 나머지는 어떻게 될까?(계속 진행할까? 멈춰야할까?)&lt;/li&gt;
&lt;li&gt;모든 코루틴이 다 끝났다는걸 어떻게 알 수 있을까?&amp;nbsp;&lt;/li&gt;
&lt;li&gt;하나라도 잘못되면 나머지 자원을 정리해야하는데 누가 해야할까?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`coroutineScope`는 문법 차원에서 강제로 해결한다.&amp;nbsp;&lt;b&gt;coroutineScope{ ... } 블록으로 감싸면, 그 안에서 시작한 모든 코루틴이 이 울타리에 묶여서 :&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&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;/li&gt;
&lt;li&gt;블록을 벗어난 코루틴은 존재할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이렇게 코루틴의 생명주기를 코드 블록 범위에 묶는 걸&amp;nbsp; Structured Concurrency 라고 부르고, coroutineScope가 그 역할을 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;사실, coroutineScope도&amp;nbsp;&lt;b&gt;suspend 함수다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1785575197268&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public suspend fun &amp;lt;R&amp;gt; coroutineScope(block: suspend CoroutineScope.() -&amp;gt; R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont -&amp;gt;
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새로운 coroutineScope를 만든다. - Job 만큼은 새로운 자식으로 만든다.(그래서 스코프 안은 바깥과 같은 스레드 설정에서 돌지만, 취소/실패는 이 스코프 울타리 안에서 관리된다.)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Job은 코루틴 하나의 생명주기를 연결하는 손잡이&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;그 스코프 안에서 `launch`/`async` 로 시직한 자식 코루틴들이 전부 끝날 때 까지 자신도 끝나지 않고 기다린다.&lt;/li&gt;
&lt;li&gt;자식 중 하나라도 실패하면 나머지 자식들도 전부 취소하고, 그 예외를 `coroutineScope` 자신의 예외로 던진다. 반대로 `supervisorScope`는 자식이 실패해도 형제를 취소하진 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;launch vs async&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1785575684845&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;suspend fun launchVsAsync(): Map&amp;lt;String, Any?&amp;gt; {
    var launchSideEffectRan = false
    val launchElapsed = measureTimeMillis {
        coroutineScope {
            launch {
                delay(100)
                launchSideEffectRan = true
            }
        }
    }

    var asyncResult = 0
    val asyncElapsed = measureTimeMillis {
        coroutineScope {
            val deferred = async {
                delay(100)
                42
            }
            asyncResult = deferred.await()
        }
    }

    return mapOf(
        &quot;launch&quot; to mapOf(&quot;elapsedMs&quot; to launchElapsed, &quot;sideEffectRan&quot; to launchSideEffectRan),
        &quot;async&quot; to mapOf(&quot;elapsedMs&quot; to asyncElapsed, &quot;result&quot; to asyncResult),
    )
}&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;size14&quot;&gt;실제 호출 결과&lt;/p&gt;
&lt;pre id=&quot;code_1785575821401&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{&quot;launch&quot;:{&quot;elapsedMs&quot;:108,&quot;sideEffectRan&quot;:true},&quot;async&quot;:{&quot;elapsedMs&quot;:101,&quot;result&quot;:42}}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`launch`&lt;span&gt;는&lt;/span&gt; &lt;span&gt;결과가&lt;/span&gt; &lt;span&gt;필요&lt;/span&gt; &lt;span&gt;없는&lt;/span&gt; fire-and-forget(&lt;span&gt;그래도&lt;/span&gt; `coroutineScope`&lt;span&gt;가&lt;/span&gt; &lt;span&gt;끝날&lt;/span&gt; &lt;span&gt;때까지는&lt;/span&gt; &lt;span&gt;기다려준다&lt;/span&gt;), `async`&lt;span&gt;는&lt;/span&gt; `await()`&lt;span&gt;로&lt;/span&gt; &lt;span&gt;결과를&lt;/span&gt; &lt;span&gt;직접&lt;/span&gt; &lt;span&gt;받아야&lt;/span&gt; &lt;span&gt;하는&lt;/span&gt; &lt;span&gt;경우에&lt;/span&gt; &lt;span&gt;쓴다&lt;/span&gt;. &lt;span&gt;둘&lt;/span&gt; &lt;span&gt;다&lt;/span&gt; `coroutineScope` &lt;span&gt;안에서&lt;/span&gt; &lt;span&gt;자식이&lt;/span&gt; &lt;span&gt;끝날&lt;/span&gt; &lt;span&gt;때까지는&lt;/span&gt; &lt;span&gt;스코프가&lt;/span&gt; &lt;span&gt;종료되지&lt;/span&gt; &lt;span&gt;않는다&lt;/span&gt; &amp;mdash; &lt;span&gt;이게&lt;/span&gt; &lt;span&gt;바로&lt;/span&gt; &lt;span&gt;다음에&lt;/span&gt; &lt;span&gt;나올&lt;/span&gt;&amp;nbsp;&lt;span&gt;&lt;b&gt;구조화된&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;동시성&lt;/b&gt;&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Dispatcher와 Spring에서 스레드&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Dispatcher는 이 코루틴이 어느 스레드(풀)에서 실행할지 정하는 담당자이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Dispatcher.Default - CPU를 많이 사용하는 계산 작업 (대략 CPU 코어 수)&lt;/li&gt;
&lt;li&gt;Dispatcher.IO - 블로킹 IO(파일, 네트워크, JDBC 작업(기본 64)&lt;/li&gt;
&lt;li&gt;커스텀(Executor.asCoroutineDispatcher()) - 커스텀 스레드풀(정한 만큼)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;추가로 Dispatcher.Default와 Dispatcher.IO는 같은 스레드풀을 공유하고 동시성 한도만 다르다. (약 210만개 물리적 스레드풀 개수)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그렇다면 특정 코루틴이 어디서 실행되는지도 정리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;1. &lt;b&gt;명시하면 그게 최우선&lt;/b&gt;&amp;nbsp;&amp;mdash; `launch(Dispatchers.IO) { }`, `withContext(Dispatchers.Default) { }`처럼 직접 넘기면 그 Dispatcher의 풀에서 돈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;2. &lt;b&gt;안 넘기면 부모를 물려받는다&lt;/b&gt;&amp;nbsp;&amp;mdash; 아무것도 안 주면, 지금 실행 중인 바깥 코루틴의 Dispatcher를 그대로 상속한다. (앞서 `coroutineScope`가 &quot;바깥 컨텍스트를 물려받는다&quot;고 한 게 이거다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;3.&amp;nbsp;&lt;span&gt;&lt;b&gt;최상단이면&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;시작&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;방식의&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;기본값&lt;/b&gt;&lt;/span&gt;&amp;nbsp;&amp;mdash; `runBlocking`&lt;span&gt;은&lt;/span&gt; &lt;span&gt;자기를&lt;/span&gt; &lt;span&gt;호출한&lt;/span&gt; &lt;span&gt;스레드&lt;/span&gt;, `GlobalScope.launch`&lt;span&gt;는&lt;/span&gt; `Dispatchers.Default`, Spring&lt;span&gt;의&lt;/span&gt; suspend &lt;span&gt;컨트롤러는&lt;/span&gt; &lt;span&gt;프레임워크가&lt;/span&gt; &lt;span&gt;정한&lt;/span&gt; &lt;span&gt;것&lt;/span&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그렇다면&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Spring MVC의 suspend&amp;nbsp; 컨트롤러는 어떻게 처리 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span&gt;실제로&lt;/span&gt; spring-web&lt;span&gt;의&lt;/span&gt; `CoroutinesUtils` &lt;span&gt;구현을&lt;/span&gt; &lt;span&gt;뜯어보면&lt;/span&gt;, Spring&lt;span&gt;은&lt;/span&gt; suspend &lt;span&gt;컨트롤러를&lt;/span&gt; &lt;b&gt;`Dispatchers.Unconfined`&lt;/b&gt;&lt;span&gt;&lt;b&gt;로&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;시작&lt;/b&gt;&lt;/span&gt;&lt;span&gt;한다&lt;/span&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`Unconfined`&lt;span&gt;는&lt;/span&gt;&amp;nbsp;&lt;span&gt;&lt;b&gt;자기&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;전용&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;스레드&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;풀이&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;없는&lt;/b&gt;&lt;/span&gt;&amp;nbsp;&lt;span&gt;특수한&lt;/span&gt; &lt;span&gt;디스패처라&lt;/span&gt;, &lt;span&gt;지금&lt;/span&gt; &lt;span&gt;호출한&lt;/span&gt; &lt;span&gt;그&lt;/span&gt; &lt;span&gt;스레드에서&lt;/span&gt; &lt;span&gt;그냥&lt;/span&gt; &lt;span&gt;시작하고&lt;/span&gt;, &lt;span&gt;첫&lt;/span&gt; suspension &lt;span&gt;뒤엔&lt;/span&gt; &lt;span&gt;재개시킨&lt;/span&gt; &lt;span&gt;쪽&lt;/span&gt; &lt;span&gt;스레드에서&lt;/span&gt; &lt;span&gt;이어간다&lt;/span&gt;.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1785577938498&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[요청 도착]
   &amp;darr;
① Tomcat 스레드 풀 (http-nio-8081-exec-N)   &amp;larr; 요청 받고 코루틴 시작
   &amp;darr;  (코루틴 안에서 withContext(Dispatchers.IO) 등을 만나면)
② Dispatcher가 정한 풀 (DefaultDispatcher-worker-N 등)  &amp;larr; 실제 작업 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- &lt;b&gt;요청 받는 스레드&lt;/b&gt;&amp;nbsp;= Tomcat 스레드 풀 &amp;rarr; `server.tomcat.threads.max`(기본 200)로 조정&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;-&amp;nbsp;&lt;span&gt;&lt;b&gt;코루틴&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;작업&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;스레드&lt;/b&gt;&lt;/span&gt;&amp;nbsp;= &lt;span&gt;코드에서&lt;/span&gt; `withContext(Dispatcher)`&lt;span&gt;로&lt;/span&gt; &lt;span&gt;지정한&lt;/span&gt; &lt;span&gt;풀&lt;/span&gt; &amp;rarr; Dispatcher(&lt;span&gt;커스텀이면&lt;/span&gt; &lt;span&gt;풀&lt;/span&gt; &lt;span&gt;크기&lt;/span&gt;)&lt;span&gt;로&lt;/span&gt; &lt;span&gt;조정&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;Spring&lt;/b&gt;&lt;span&gt;&lt;b&gt;은&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;코루틴용&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;스레드&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;풀을&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;따로&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;만들지&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;않는다&lt;/b&gt;&lt;/span&gt;&lt;b&gt;(`Unconfined`) &lt;/b&gt;&lt;span&gt;그래서&lt;/span&gt; &lt;span&gt;코루틴이&lt;/span&gt; &lt;span&gt;도는&lt;/span&gt; &lt;span&gt;풀&lt;/span&gt;&lt;span&gt;을&lt;/span&gt; &lt;span&gt;조정하고&lt;/span&gt; &lt;span&gt;싶으면&lt;/span&gt; Spring &lt;span&gt;설정이&lt;/span&gt; &lt;span&gt;아니라&lt;/span&gt;, &lt;span&gt;코드에서&lt;/span&gt; &lt;span&gt;직접&lt;/span&gt; `withContext`&lt;span&gt;로&lt;/span&gt; Dispatcher&lt;span&gt;를&lt;/span&gt; &lt;span&gt;지정하고&lt;/span&gt; &lt;span&gt;그걸&lt;/span&gt; &lt;span&gt;조정해야&lt;/span&gt; &lt;span&gt;한다&lt;/span&gt;.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;만약 컨트롤러부터 아무도 Dispatcher를 만나지 않는다면?&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;컨트롤러도, 그 안의 서비스도 `withContext`를 안 붙이면 그 코루틴은 처음부터 끝까지 `undefined`로 흐른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그럼 실제로 어떤 스레드에서 돌까?&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 88px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;코루틴 안에서 만나는것&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;그 뒤 도는 스레드&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;suspension이 아예 없음(시작한 스레드에서 쭉 끝남)&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;Tomcat 스레드&lt;/b&gt;에서 시작부터 끝까지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;withContext(Dispatcher)를 만남&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;그 지점부터 &lt;b&gt;해당 Dispatcher 스레드&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Dispatcher 없이 delay 등 논블로킹 대기만 만남&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;kotlinx 내부 스케줄러 스레드&lt;/b&gt;(kotlinx.coroutines.DefaultExecutor)에서 재개될 수&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;p data-ke-size=&quot;size14&quot;&gt;&lt;span&gt;&lt;b&gt;첫&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;번째&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &quot;suspension&lt;/b&gt;&lt;span&gt;&lt;b&gt;이&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;아예&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;없음&lt;/b&gt;&lt;/span&gt;&lt;b&gt;&quot;&lt;/b&gt;&amp;nbsp;&amp;mdash; &lt;span&gt;이게&lt;/span&gt; &lt;span&gt;헷갈리는데&lt;/span&gt;, suspend&lt;span&gt;를&lt;/span&gt; &lt;span&gt;붙였다고&lt;/span&gt; &lt;span&gt;항상&lt;/span&gt; &lt;span&gt;멈추는&lt;/span&gt; &lt;span&gt;게&lt;/span&gt; &lt;span&gt;아니다&lt;/span&gt;. suspend&lt;span&gt;는&lt;/span&gt; &quot;&lt;span&gt;멈출&lt;/span&gt; &lt;span&gt;수&lt;/span&gt; &lt;span&gt;있는&lt;/span&gt; &lt;span&gt;능력&lt;/span&gt;&quot;&lt;span&gt;일&lt;/span&gt; &lt;span&gt;뿐이고&lt;/span&gt;, &lt;span&gt;실행&lt;/span&gt; &lt;span&gt;중에&lt;/span&gt; &lt;span&gt;실제로&lt;/span&gt; &lt;span&gt;기다릴&lt;/span&gt; &lt;span&gt;일&lt;/span&gt;(delay/await/&lt;span&gt;논블로킹&lt;/span&gt; IO)&lt;span&gt;을&lt;/span&gt; &lt;span&gt;한&lt;/span&gt; &lt;span&gt;번도&lt;/span&gt; &lt;span&gt;안&lt;/span&gt; &lt;span&gt;만나면&lt;/span&gt; &lt;span&gt;그냥&lt;/span&gt; &lt;span&gt;일반&lt;/span&gt; &lt;span&gt;함수처럼&lt;/span&gt; &lt;span&gt;시작한&lt;/span&gt; &lt;span&gt;스레드에서&lt;/span&gt; &lt;span&gt;쭉&lt;/span&gt; &lt;span&gt;돌다가&lt;/span&gt; &lt;span&gt;끝난다&lt;/span&gt;. &lt;span&gt;그게&lt;/span&gt; &lt;span&gt;여기&lt;/span&gt; Tomcat &lt;span&gt;스레드다&lt;/span&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span&gt;근데&lt;/span&gt; &lt;span&gt;이&lt;/span&gt; suspension &lt;span&gt;없음&lt;/span&gt;&lt;span&gt;이&lt;/span&gt; &lt;span&gt;사실&lt;/span&gt;&amp;nbsp;&lt;span&gt;&lt;b&gt;정반대인&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;두&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;상황을&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;span&gt;&lt;b&gt;뭉뚱그린다&lt;/b&gt;&lt;/span&gt;&lt;b&gt;:&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1785578799423&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 기다릴 게 없어서 빨리 끝남 &amp;rarr; 안전
suspend fun add(a: Int, b: Int): Int = a + b   // Tomcat 스레드 잠깐 쓰고 바로 반납

// 블로킹으로 스레드를 오래 붙잡음 &amp;rarr; 위험 (블로킹은 suspension이 아니다!)
suspend fun getUser(): User {
    Thread.sleep(1000)                  // Tomcat 스레드를 1초 통째로 점유
    return jdbcRepository.findById(id)  // JDBC 블로킹도 마찬가지
}&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;size14&quot;&gt;따라서, undefined인데 블로킹 호출이면 Tomcat 워커 스레드를 통째로 붙잡기 때문에 코루틴을 쓰는 이유가 사라지므로 유의해야한다.&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;Virtual Thread VS Coroutine&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 Virtual Thread와 Coroutine은 굉장히 많은 비교가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(Virtual Thread는 JDK 기능이므로 코틀린에서 사용 할 수 있다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 2개 모두 해결하고 있는 문제는 동일하다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;블로킹 IO 대기 중 스레드 낭비를 어떻게 해결할까?&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Thread - JVM이 알아서 스레드 언마운트/마운트를 통해 재사용하도록 한다&lt;/li&gt;
&lt;li&gt;Coroutine - 컴파일러가 suspend 함수를 상태 머신으로 바꿔서 스레드를 점유하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2개의 성능은 사실 어느정도 많이 관측됐으나 나도 한번 해보았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1665&quot; data-origin-height=&quot;729&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cSFpoU/dJMcabZuVQ8/EEh5ziWAdMfskveAMd1Rik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cSFpoU/dJMcabZuVQ8/EEh5ziWAdMfskveAMd1Rik/img.png&quot; data-alt=&quot;성능 테스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cSFpoU/dJMcabZuVQ8/EEh5ziWAdMfskveAMd1Rik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcSFpoU%2FdJMcabZuVQ8%2FEEh5ziWAdMfskveAMd1Rik%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;1665&quot; height=&quot;729&quot; data-origin-width=&quot;1665&quot; data-origin-height=&quot;729&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;&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;blocking 함수 VS virtual Thread 함수 VS coroutine 함수를 비교해보았다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 64px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;클라이언트측 평균 응답 대기 시간&lt;/td&gt;
&lt;td&gt;P95&lt;/td&gt;
&lt;td&gt;처리량&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;blocking&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;824ms&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;885ms&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;949 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;virtual-thread&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;208ms&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;223ms&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;3,345 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;coroutine&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;208ms&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;220ms&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;3,338 req/s&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;p data-ke-size=&quot;size16&quot;&gt;예상대로 blocking은 처리량은 낮고, 응답 대기 시간을 길었고 Virtual Thread와 coroutine 사이에 차이는 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;어떤걸 선택해야할까?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&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;size14&quot;&gt;&lt;b&gt;Virtual Thread&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;기존 블로킹 코드가 많다&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JDBC, 레거시 블로킹 HTTP 클라이언트 등, 코드 한 줄도 안고치고 `spring.thread.virtual.enabled=true` 하나로 스레드 효율을 얻을 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;suspend 전염
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;suspend 함수는 suspend 함수안에서만 호출이 가능하기 때문에 코루틴 도입은 호출 체인 전체(컨트롤러 -&amp;gt; 서비스 -&amp;gt; 리포지토리)를 다시 써야한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;Coroutine&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;coroutineScope와 supervisorScope처럼 하나가 실패하면 나머진 확실히 취소하고 혹은 반대로 서로 독립적으로 두고 싶을때 사용해야한다. Virtual Thread에서는 이런 기능을 제공하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Flow 같은 스트림 처리, 명시적취소
&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;</description>
      <author>Leoo</author>
      <guid isPermaLink="true">https://farmer-eom.tistory.com/5</guid>
      <comments>https://farmer-eom.tistory.com/5#entry5comment</comments>
      <pubDate>Sun, 2 Aug 2026 01:02:45 +0900</pubDate>
    </item>
    <item>
      <title>Redis 조회, 100번 나눠 부르지 말고 한 번에 부르자 &amp;mdash; MGET</title>
      <link>https://farmer-eom.tistory.com/4</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;트래픽 급증으로 인한 장애&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세일이 시작이 되자 트래픽이 확 튀었다. 어느정도 예상했던 범위이지만 특정 페이지에서&amp;nbsp; 조회가 급격히 증가하면서 APM 상에서 API 응답 지연이 눈의 띄게 확인됐다. 해당 페이지는 여러 항목을 한 화면에 모아 보여주는 페이지기 때문에 Redis 캐시에서 값을 꽤 여러개 읽어와야했는데, 트래픽이 평소의 몇 배로 튀니 그 지연이 드러났다.&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;pre id=&quot;code_1785064827678&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;```kotlin
val values = keys.map { key -&amp;gt; redis.get(key) }
```&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;캐시에서 읽어야 할 키를 `하나씩, 순서대로` 조회하고 있었다.&amp;nbsp; 평소 트래픽에선 키가 몇 개 안 되니 티가 안 났는데, 세일 트래픽이 몰리면서 동시에 처리해야 할 조회 수가 늘어나자 이 방식의 비효율이 그대로 API 응답 지연으로 튀어나왔다.&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;b&gt;Redis가 느린 게 아니라, 네트워크 왕복(RTT)을 필요한 것보다 훨씬 많이 만들고 있었고, MGET을 이용해 이를 개선할 수 있었다.&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;실험 환경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 앱으로 같은 조건(로컬 Redis, 미리 캐싱해준 키 1000개)에서 세 가지 클라이언트를 비교했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Jedis : `GET` 키 개수 만큼 반복 호출 VS `MGET` 한 번&lt;/li&gt;
&lt;li&gt;Lettuce : `GET`키 개수 만큼 반복 호출 VS `MGET` 또는 파이프라인으로 한 번에 전송&lt;/li&gt;
&lt;li&gt;Redission : `RBucket.get()` 을 키 개수만큰 반복 호출 VS `RBatch`로 한 번에 전송&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jedis - `GET` 키 개수 만큼 반복 호출&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피크 응답 시간 - 1.70s&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1890&quot; data-origin-height=&quot;262&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c99xA2/dJMcad3TlrL/AraoteUfKWW8IzsCeuxRFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c99xA2/dJMcad3TlrL/AraoteUfKWW8IzsCeuxRFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c99xA2/dJMcad3TlrL/AraoteUfKWW8IzsCeuxRFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc99xA2%2FdJMcad3TlrL%2FAraoteUfKWW8IzsCeuxRFK%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;1890&quot; height=&quot;262&quot; data-origin-width=&quot;1890&quot; data-origin-height=&quot;262&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;jedis - `MGET` 한 번 호출&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피크 응답 시간 - 16.3ms&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2970&quot; data-origin-height=&quot;524&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdnIrE/dJMcagsPJsq/GfXkrMIyI6p8tyM6mpwkv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdnIrE/dJMcagsPJsq/GfXkrMIyI6p8tyM6mpwkv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdnIrE/dJMcagsPJsq/GfXkrMIyI6p8tyM6mpwkv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdnIrE%2FdJMcagsPJsq%2FGfXkrMIyI6p8tyM6mpwkv1%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;2970&quot; height=&quot;524&quot; data-origin-width=&quot;2970&quot; data-origin-height=&quot;524&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;Lettuce - `GET` 키 개수 만큼 반복 호출&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피크 응답 시간 - 4.03s&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2970&quot; data-origin-height=&quot;524&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EzTJ3/dJMcai5bkcK/z2LRN82NkDMfC4lYciCUW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EzTJ3/dJMcai5bkcK/z2LRN82NkDMfC4lYciCUW0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EzTJ3/dJMcai5bkcK/z2LRN82NkDMfC4lYciCUW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEzTJ3%2FdJMcai5bkcK%2Fz2LRN82NkDMfC4lYciCUW0%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;2970&quot; height=&quot;524&quot; data-origin-width=&quot;2970&quot; data-origin-height=&quot;524&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;Lettuce - `MGET` 또는 파이프라인으로 한 번에 전송&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피크 응답 시간 - 32.0ms&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;225&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kA0AY/dJMcah6qCHm/dtNLn8FVbXGQkiaRur7y31/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kA0AY/dJMcah6qCHm/dtNLn8FVbXGQkiaRur7y31/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kA0AY/dJMcah6qCHm/dtNLn8FVbXGQkiaRur7y31/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkA0AY%2FdJMcah6qCHm%2FdtNLn8FVbXGQkiaRur7y31%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;1280&quot; height=&quot;225&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;225&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;p data-ke-size=&quot;size16&quot;&gt;Redisson - RBucket.get()` 을 키 개수만큰 반복 호출&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피크 응답 시간 - 2.51s&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2970&quot; data-origin-height=&quot;524&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HeGId/dJMcaaGez4j/iXXERMnkp2sQfzpeipAQ90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HeGId/dJMcaaGez4j/iXXERMnkp2sQfzpeipAQ90/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HeGId/dJMcaaGez4j/iXXERMnkp2sQfzpeipAQ90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHeGId%2FdJMcaaGez4j%2FiXXERMnkp2sQfzpeipAQ90%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;2970&quot; height=&quot;524&quot; data-origin-width=&quot;2970&quot; data-origin-height=&quot;524&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;Redisson - RBatch`로 한 번에 전송&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피크 응답 시간 - 16.1ms&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2970&quot; data-origin-height=&quot;524&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzBVxL/dJMcabE1GYD/RjViDyRNxClkG0ir1Xw560/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzBVxL/dJMcabE1GYD/RjViDyRNxClkG0ir1Xw560/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzBVxL/dJMcabE1GYD/RjViDyRNxClkG0ir1Xw560/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzBVxL%2FdJMcabE1GYD%2FRjViDyRNxClkG0ir1Xw560%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;2970&quot; height=&quot;524&quot; data-origin-width=&quot;2970&quot; data-origin-height=&quot;524&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;/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 자체는 매우 빠르다. 단일 스레드로 커맨드를 처리하는데, `GET` 하나 처리하는 데 걸리는 시간이 마이크로초 단위다. 그런데 실제 지연은 Redis가 일하는 시간이 아니라&amp;nbsp;&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;사실 굉장히 단순한 내용인데, AI로 개발하고 검토하지 않는다면 놓치기 쉬운 부분일것 같다.&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;MGET, 파이프라인, RBatch&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 로컬 환경에서 테스트할 때는 여러 redis 클라이언트를 이용했고, 유사하지만 서로 다른 함수를 사용했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`MGET` : Redis가 기본 제공하는 커맨드 자체, 여러 키의 값을 한 번에 달라는 하나의 명령이다. 사용이 간단하지만 `GET`&amp;nbsp; 조회시에 만 사용할 수 있다.&lt;/li&gt;
&lt;li&gt;`파이프라인` : 클라이언트가 여러 명령(꼭 같은 종류가 아니어도 됨 - `SET`, `GET`, `INCR` 섞어도 됨)을 큐에 쌓아뒀다가 한 번에 전송하는 더 일반적인 기법이다. `MGET`에서 많이 사용한다.&lt;/li&gt;
&lt;li&gt;`RBatch` : Redisson이 제공하는 파이프라인 격 기능이다. Redisson 자체는 사실 단순 커맨드 클라이언트가 아니라&amp;nbsp; 분산락, 분산 맵, 분산 큐 같은 고 수준 자바 객체를 Redis 위에 얹어 주는 라이브러리이다. 그 안에서 여러 연산을 모았다가 한 번에 보내는 `RBatch`가 있고, 원리는 파이프라인과 똑같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;프로토콜 레벨에서의 동작 차이&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3가지 명령어 모두 결과는 1번은 RTT로 동일하지만 Redis 서버 입장에서&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;MGET - 서버 입장에서 명령어 하나에 불과하다. 클라이언트가 `MGET Key1 Key2 Key3`를 한 줄로 보내면, Redis가 이걸 하나의 커맨드로 받아서 내부적으로 각 키를 찾은 뒤 배열 하나로 응답한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1720&quot; data-origin-height=&quot;1212&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bswJxO/dJMcaijTDq6/8EgXPANRZkr6nMeyju7SP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bswJxO/dJMcaijTDq6/8EgXPANRZkr6nMeyju7SP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bswJxO/dJMcaijTDq6/8EgXPANRZkr6nMeyju7SP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbswJxO%2FdJMcaijTDq6%2F8EgXPANRZkr6nMeyju7SP1%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;1720&quot; height=&quot;1212&quot; data-origin-width=&quot;1720&quot; data-origin-height=&quot;1212&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;파이프라인/RBatch - 서버 입장에서는 여전히 명렁어가 N개다. 다만 클라이언트가 한 명령 보내고 응답 기다렸다가 다음 명령 보내는 걸 생략하고 N개 명령을 한꺼번에 소켓에 밀어넣은 뒤 응답이 순서대로 돌아오는 걸 한꺼번에 읽는다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1720&quot; data-origin-height=&quot;1212&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dyG8K0/dJMcaaGeAt9/EtYEauOre7RAhnoK7YzDpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dyG8K0/dJMcaaGeAt9/EtYEauOre7RAhnoK7YzDpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dyG8K0/dJMcaaGeAt9/EtYEauOre7RAhnoK7YzDpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdyG8K0%2FdJMcaaGeAt9%2FEtYEauOre7RAhnoK7YzDpk%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;1720&quot; height=&quot;1212&quot; data-origin-width=&quot;1720&quot; data-origin-height=&quot;1212&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;따라서, Redis는 여전히 `GET`을 3번 처리한다. 다만 클라이언트가 매번 응답을 기다리지 않고 몰아서 보내고 받기 때문에 RTT만 1번으로 줄어든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면&amp;nbsp;&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;MGET -&amp;nbsp;&lt;/b&gt;서버가 여러 개 조회라는 의미의 명령 하나를 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파이프라인/RBatch -&amp;nbsp;&lt;/b&gt;서버는 여전히 명령을 N번 처리하지만, 클라이언트가 왕복을 매번 기다리지 않고 몰아서 보낸다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;MGET의 우위&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 MGET, 파이프라인, RBatch 모두 RTT를 1번으로 줄이는 효과는 모두 똑같았다. 다만 순수 조회만 필요한 경우 `MGET`이 좀더 중요한 장점이 존재한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로토콜 오보헤드가 좀더 적다 - MGET은 Redis입장에서 1번의 명령이지만, 파이프라인은 N번의 명령이다.&lt;/li&gt;
&lt;li&gt;MGET은 원자적(atomic)이다. - Redis가 하나의 명령으로 처리하기 때문에 그 사이에 다른 클라이언트의 명령이 끼어들 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원자적(atomic)이라는 장점이 조금 중요한데, 그렇다면&amp;nbsp;&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;Redis의 이벤트 루프는 클라이언트 소켓에서&amp;nbsp;&lt;b&gt;한 번에 읽어들인 만큼(보통 수 KB 버퍼) &lt;/b&gt;을 파싱해서 그안에 완성된 명령이 여러 개 있으면 전부 연달아 처리 한 뒤에야 다음 클라이언트로 넘어간다. 파이프라인 명령들이 한 번의 `write()`로 통째로 전송돼 Redis가 한 번에 읽을 수 있는 크기라면 사실상 끊기지 않고 처리돼 끼어들 틈이 없다.&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;그런데 파이프라인이 너무 커서 한 번의 소켓 읽기로 다 못 읽거나, 네트워크 상황 때문에 여러 TCP 세그먼트로 쪼개져서 도착하면 아래와 같은 상황이 가능해진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1720&quot; data-origin-height=&quot;1402&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzdsMP/dJMcagl2tVt/YlTMTQ5tt1onMdTYOVjlc1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzdsMP/dJMcagl2tVt/YlTMTQ5tt1onMdTYOVjlc1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzdsMP/dJMcagl2tVt/YlTMTQ5tt1onMdTYOVjlc1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzdsMP%2FdJMcagl2tVt%2FYlTMTQ5tt1onMdTYOVjlc1%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;1720&quot; height=&quot;1402&quot; data-origin-width=&quot;1720&quot; data-origin-height=&quot;1402&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;그렇기 때문에 Redis 공식 문서도 파이프라인은&amp;nbsp;&lt;b&gt;원자성을 보장하지 않는다.&lt;/b&gt; 라고 명시하고 있다. 작은 GET 몇백 개 정도는 실무적으로 거의 항상 한 방에 처리되니 체감상 문제없지만,&amp;nbsp;&lt;b&gt;절대 안&amp;nbsp; 끼어든다&lt;/b&gt;. 는 보장이 아니라 대체로 그렇게 동작하는 구현 상 경향이다. 따라서 진짜 원자성이 필요하면 `MULTI`/`EXEC` 트랜잭션이나 Lua 스크립트를 써야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Pipelining is not atomic. Commands in a pipeline can partially succeed.&lt;/blockquote&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;만약 Redis Cluster를 쓴다면 `MGET`은 순순히 동작하지 않을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis Cluster는 전체 키 공간을&amp;nbsp;&lt;b&gt;16384개의 해시 슬롯으로 나누고, 각 노드가 슬롯 일부를 담당한다.&lt;/b&gt; 어떤 키가 어느 슬롯에 속하는지는 `CRC16(key) % 16384`로 정해진다.&amp;nbsp; 그런데 `MGET key1 key2 key3` 같은&amp;nbsp;&lt;b&gt;멀티키 명령은 관련된 키가 전부 같은 솔롯(=같은 노드)에 있어야만 실행된다.&amp;nbsp;&lt;/b&gt; key1 노드A, Key2 노드 B에 있으면 아래와 같이 에러가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1785079673600&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/*
* 실제 jedis 내부 getSlot 함수에서 CRC16 연산 코드 확인
*/
  public static int getSlot(String key) {
    if (key == null) {
      throw new NullPointerException(&quot;Slot calculation of null is impossible&quot;);
    }

    key = JedisClusterHashTag.getHashTag(key);
    // optimization with modulo operator with power of 2 equivalent to getCRC16(key) % 16384
    return getCRC16(key) &amp;amp; (16384 - 1);
  }&lt;/code&gt;&lt;/pre&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;2424&quot; data-origin-height=&quot;844&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PYLTL/dJMcajpr1EH/XrSSanCdtKCbLKKq04HtJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PYLTL/dJMcajpr1EH/XrSSanCdtKCbLKKq04HtJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PYLTL/dJMcajpr1EH/XrSSanCdtKCbLKKq04HtJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPYLTL%2FdJMcajpr1EH%2FXrSSanCdtKCbLKKq04HtJk%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;2424&quot; height=&quot;844&quot; data-origin-width=&quot;2424&quot; data-origin-height=&quot;844&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;pre id=&quot;code_1785078175368&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(error) CROSSSLOT Keys in request don't hash to the same slot&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;클러스터 환경에선 조회하려는 키 500개가 여러 노드에 흩어져 있을 가능성이 높고, 그러면 `MGET` 한 방으로는 끝나지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 경우, 해시 태그(hash tag)를 이용하면 된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`{}`로 감싼 부분이 있으면 Redis는&amp;nbsp;&lt;b&gt;그 안의 문자열만&lt;/b&gt; 가지고 슬롯을 계산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1785079134919&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;user:1000:profile   &amp;rarr; 전체 문자열로 슬롯 계산
user:{1000}:profile &amp;rarr; &quot;{1000}&quot; 부분만으로 슬롯 계산
user:{1000}:points  &amp;rarr; 위와 똑같은 슬롯 (같은 &quot;{1000}&quot;)&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_1785080609478&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; @GetMapping(&quot;/crossslot&quot;)
    fun crossslot(
        @RequestParam(defaultValue = &quot;key1&quot;) key1: String,
        @RequestParam(defaultValue = &quot;key2&quot;) key2: String,
    ): CrossSlotResult {
        val slot1 = JedisClusterCRC16.getSlot(key1)
        val slot2 = JedisClusterCRC16.getSlot(key2)
        log.info(&quot;GET /crossslot key1={} (slot={}) key2={} (slot={})&quot;, key1, slot1, key2, slot2)

        return try {
            Jedis(&quot;127.0.0.1&quot;, 7001).use { jedis -&amp;gt;
                val values = jedis.mget(key1, key2)
                log.info(&quot;crossslot succeeded: sameSlot={} values={}&quot;, slot1 == slot2, values)
                CrossSlotResult(key1, slot1, key2, slot2, slot1 == slot2, values = values)
            }
        } catch (e: JedisDataException) {
            log.warn(&quot;crossslot rejected by cluster: {}&quot;, e.message)
            CrossSlotResult(key1, slot1, key2, slot2, slot1 == slot2, error = e.message)
        } catch (e: JedisConnectionException) {
            log.error(&quot;failed to connect to redis cluster at 127.0.0.1:7001&quot;, e)
            CrossSlotResult(
                key1, slot1, key2, slot2, slot1 == slot2,
                error = &quot;클러스터에 연결할 수 없습니다. docker/redis-cluster-demo.sh 로 먼저 클러스터를 띄워주세요: ${e.message}&quot;,
            )
        }
    }&lt;/code&gt;&lt;/pre&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;1685&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mtui1/dJMcacD3ncj/aWf0wtHDNoKHEOjK9dbKgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mtui1/dJMcacD3ncj/aWf0wtHDNoKHEOjK9dbKgK/img.png&quot; data-alt=&quot;테스트코드 - Slot 에러 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mtui1/dJMcacD3ncj/aWf0wtHDNoKHEOjK9dbKgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fmtui1%2FdJMcacD3ncj%2FaWf0wtHDNoKHEOjK9dbKgK%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;1685&quot; height=&quot;54&quot; data-origin-width=&quot;1685&quot; data-origin-height=&quot;54&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트코드 - Slot 에러 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1685&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgNJFw/dJMcagsPKVI/xkEgiiGeibcU99lRDJ3j41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgNJFw/dJMcagsPKVI/xkEgiiGeibcU99lRDJ3j41/img.png&quot; data-alt=&quot;테스트코드 - 해시태그를 사용한 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgNJFw/dJMcagsPKVI/xkEgiiGeibcU99lRDJ3j41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcgNJFw%2FdJMcagsPKVI%2FxkEgiiGeibcU99lRDJ3j41%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;1685&quot; height=&quot;54&quot; data-origin-width=&quot;1685&quot; data-origin-height=&quot;54&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 같은 사용자에 대한 키들을 `user:{1000}:profile` , `user:{1000}:points` 처럼&amp;nbsp;&lt;b&gt;같은 해시 태그로 묶는다면&lt;/b&gt; 그 키들은 항상 같은 슬롯(같은 노드)에 모이게 되고, `MGET`으로 안전하게 한 번에 조회할 수 있다.&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;&lt;b&gt;그리고 흥미로운점이 하나 있었는데, Redis 클라이언트 별로 MGET 호출에 대한&amp;nbsp; 크로스슬롯 반응이 완전히 달랐다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`Jedis(노드 하나에 직접 연결)` - 크로스 슬롯 에러 발생((error) CROSSSLOT Keys in request don't hash to the same slot)&lt;/li&gt;
&lt;li&gt;`JedisCluster` - 클라이언트가 자체 에러 발생(JedisClusterOperationException: Keys must belong to same hashslot.)&lt;/li&gt;
&lt;li&gt;`Lettuce(RedisAdvancedClusterCommands)` - 키 단위로 쪼개서 각 노드에 개별 조회보내서 결과를 원래 순서로 합쳐서 성공&lt;/li&gt;
&lt;li&gt;`Redisson` - 키 단위로 쪼개서 각 노드에 개별 조회보내서 결과를 원래 순서로 합쳐서 성공&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뿐만 아니라 파이프라인/RBatch도&amp;nbsp; 키 단위로 쪼개서 각 노드에 개별 조회 후 결과를 합쳐서 보여주기 때문에 동일한 원리로 성공시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;클라이언트 별&amp;nbsp; MGET&amp;nbsp; 및 파이프라인/RBatch 확인&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Jedis - 실패&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCXDGB/dJMcahefZ6p/AXyUcUEmIpA3KCmn6mPBy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCXDGB/dJMcahefZ6p/AXyUcUEmIpA3KCmn6mPBy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCXDGB/dJMcahefZ6p/AXyUcUEmIpA3KCmn6mPBy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCXDGB%2FdJMcahefZ6p%2FAXyUcUEmIpA3KCmn6mPBy1%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;1784&quot; height=&quot;54&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&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;JedisCluster - 실패&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dYpLvJ/dJMcabE1IOq/xVXSGASwvFMCm3hiZFnkpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dYpLvJ/dJMcabE1IOq/xVXSGASwvFMCm3hiZFnkpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dYpLvJ/dJMcabE1IOq/xVXSGASwvFMCm3hiZFnkpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdYpLvJ%2FdJMcabE1IOq%2FxVXSGASwvFMCm3hiZFnkpk%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;1784&quot; height=&quot;54&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&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;Lettuce - 성공&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b8sxY9/dJMcagfir8r/a9oWY0aOYFODbGr6k6w8Ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b8sxY9/dJMcagfir8r/a9oWY0aOYFODbGr6k6w8Ok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b8sxY9/dJMcagfir8r/a9oWY0aOYFODbGr6k6w8Ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb8sxY9%2FdJMcagfir8r%2Fa9oWY0aOYFODbGr6k6w8Ok%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;1784&quot; height=&quot;54&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&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;Redisson - 성공&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cPXiHj/dJMcaf8sB4r/5mObLCzPBXr8velf66hjhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cPXiHj/dJMcaf8sB4r/5mObLCzPBXr8velf66hjhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cPXiHj/dJMcaf8sB4r/5mObLCzPBXr8velf66hjhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcPXiHj%2FdJMcaf8sB4r%2F5mObLCzPBXr8velf66hjhK%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;1784&quot; height=&quot;54&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1sRJc/dJMcajiOtIU/hzmpDSKuU3tubWjvab9nKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1sRJc/dJMcajiOtIU/hzmpDSKuU3tubWjvab9nKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1sRJc/dJMcajiOtIU/hzmpDSKuU3tubWjvab9nKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1sRJc%2FdJMcajiOtIU%2FhzmpDSKuU3tubWjvab9nKK%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;1784&quot; height=&quot;54&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;54&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;br /&gt;정리,&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 76px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;일반 클라이언트 사용 시&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;클러스터 클라이언트 사용 시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;b&gt;MGET&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;CROSSSLOT 에러 발생&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;슬롯별로 분할 후 결과 병합&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;b&gt;Pipeline / RBatch&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;CROSSSLOT은 없지만 MOVED 응답 발생&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;슬롯별로 분배 후 결과 병합&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;사용자 관점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;명령 실패 또는 일부 실패&lt;/td&gt;
&lt;td style=&quot;height: 18px;&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;p data-ke-size=&quot;size16&quot;&gt;단,&amp;nbsp;파이프라인/RBatch&amp;nbsp;안에&amp;nbsp;그&amp;nbsp;자체로&amp;nbsp;이미&amp;nbsp;멀티키인&amp;nbsp;명령(위&amp;nbsp;3번&amp;nbsp;항목)이&amp;nbsp;있으면,&amp;nbsp;클러스터&amp;nbsp;인지형&amp;nbsp;클라이언트를&amp;nbsp;써도&amp;nbsp;그&amp;nbsp;명령만은&amp;nbsp;CROSSSLOT을&amp;nbsp;피할&amp;nbsp;수&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;p data-ke-size=&quot;size16&quot;&gt;그리고 그런 클라이언트를 쓰더라도 키가 여러 노드에 흩어져 있으면 왕복이 노드 개수만큼(N번) 늘어나는건 마찬가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 왕복들은 순차가 아니라 병렬로 나가기 때문에 체감 지연은 N배가 아닌 그중 제일 느린 응답 하나 만큼 늘어날것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 왕복 1번이라는 이점을 온전히 누리고자 한다면&amp;nbsp;&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 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis Cluster Hash Slot - &lt;a href=&quot;https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Redis Cluster- &lt;a href=&quot;https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Redis 트랜잭션 -&amp;nbsp; &lt;a href=&quot;https://redis.io/docs/latest/develop/using-commands/transactions/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://redis.io/docs/latest/develop/using-commands/transactions/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;파이프라인 원자성&amp;nbsp; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;- Pipeline 원자성 관련 글 :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a href=&quot;https://www.percona.com/blog/pipelining-and-transactions-in-redis-and-valkey/&quot;&gt;https://www.percona.com/blog/pipelining-and-transactions-in-redis-and-valkey/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>Leoo</author>
      <guid isPermaLink="true">https://farmer-eom.tistory.com/4</guid>
      <comments>https://farmer-eom.tistory.com/4#entry4comment</comments>
      <pubDate>Mon, 27 Jul 2026 01:52:48 +0900</pubDate>
    </item>
    <item>
      <title>복잡한 보상 트랜잭션 대신 선택한 Temporal</title>
      <link>https://farmer-eom.tistory.com/3</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 사가 패턴(Saga Pattern)으로 포인트 적립 -&amp;gt; 결제 흐름 같은 프로젝트를 해보면서 여러 가지 서킷, Fallback, 분산락 등을 적용하고&amp;nbsp; 성능 테스트를 해보고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데, 최근 핫한 주제인&amp;nbsp;&lt;b&gt;Temporal&lt;/b&gt;에 대해서 여기저기서 이야기가 많이 들리는것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나도 이론적인 내용만 알고 있었고 실제로 사용해본적은 없는데&amp;nbsp; 보상 트랜잭션 같은걸 처리하면서 Temporal로 한번 해볼까? 라는 생각이 들어서 토이프로젝트로 하나 만들어보면서 공부한 내용을 정리하려고 한다.&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;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&quot;&lt;/span&gt;이거&lt;span&gt; &lt;/span&gt;결국&lt;span&gt; Temporal&lt;/span&gt;이&lt;span&gt; &lt;/span&gt;다&lt;span&gt; &lt;/span&gt;해주는&lt;span&gt; &lt;/span&gt;거&lt;span&gt; &lt;/span&gt;아닌가&lt;span&gt;?&quot;&lt;/span&gt;라는&lt;span&gt; &lt;/span&gt;생각이&lt;span&gt; &lt;/span&gt;들어서&lt;span&gt; &lt;/span&gt;직접&lt;span&gt; &lt;/span&gt;같은&lt;span&gt; &lt;/span&gt;문제를&lt;span&gt; Temporal&lt;/span&gt;로&lt;span&gt; &lt;/span&gt;다시&lt;span&gt; &lt;/span&gt;풀어봤다&lt;span&gt;.&amp;nbsp;&amp;nbsp;&lt;br /&gt;일반적인 보상 트랜잭션과 Temporal을 이용한 방법을 비교해보자!&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Temporal 이 뭐야?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 간단한 문제 상황을 하나 정의해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커머스에서 상품 구매 시, 포인트 적립과 결제 승인 그리고 확정 까지의 과정을 거친다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 여러 단계로 이뤄진 프로세스(포인트 적립 -&amp;gt; 결제 승인 -&amp;gt; 확정)의 단계를 안정적으로 실행하려면 이런 것들을 전부 신경써야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 중간 서버가 죽으면? -&amp;gt; 어디까지 진행됐는지 어딘가에 기록해둬야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 외부 호출이 실패하면? -&amp;gt; 재시도해야 하는데, 얼마나 자주, 몇 번, 어떤 실패는 재시도하면 안 되는지 판단해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 뒷단계가 실패하면? -&amp;gt; 앞 단계를 되돌리는 보상 로직이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 같은 요청이 중복으로 들어오면? -&amp;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;보통 위와 같은 문제가 발생하면 예외 처리 상황과 멱등성 키 등등을 직접 구현한다. 즉, 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;Temporal을 한마디로 정리하면, 서비스의 비즈니스 프로세스(Workflow)를 구현하기 위한 엔진이라고 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Temporal과 맥락은 조금 다르지만 좀 더 큰 단위로 보자면 Airflow가 있겠다. AirFlow도 워크플로우 엔진이지만 AirFlow는 데이터 파이프라인(ETL, Batch)을 실행하기 위한 엔진이라고 볼 수 있겠다.&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;Temporal을 이용한 문제 해결 접근&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Temporal은 &quot;복구/재시도/보상&quot;을 애플리케이션 코드가 아니라&amp;nbsp;&lt;b&gt;플랫폼&lt;/b&gt;이 대신하게 만드는 워크플로우 엔진이다. 핵심 아이디어는 &lt;b&gt;durable execution&amp;nbsp; 철학&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;내가 이해한 durable execution은 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;일반 프로그램은 서버가 종료되면 실행 상태도 함께 사라진다. Temporal은 실행 상태를 이벤트 히스토리로 영속화하여, 서버가 종료되거나 며칠이 지나도 프로그램을 같은 지점에서 안전하게 이어서 실행할 수 있게 해준다.&amp;nbsp;&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;즉, 워크플로우 코드를 평범한 순차 코드처럼 작성한다면 Temporal 서버가 이벤트 소싱 방식으로 실행 이력을 전부 기록해뒀다가, 워커가 죽어도 정확히 멈춘 지점부터 이어서 실행한다.&lt;/p&gt;
&lt;pre id=&quot;code_1783864644662&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;payment()

&amp;darr;

History 저장

&amp;darr;

inventory()

&amp;darr;

History 저장

&amp;darr;

coupon()

&amp;darr;

History 저장&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;따라서, Temporal을 이용한다면&amp;nbsp;&lt;b&gt;복구&lt;/b&gt;가 아니라&amp;nbsp;&lt;b&gt;재생&lt;/b&gt;이라고 표현한다. 저장해준 상태르르 불러오는 게 아니라, 처음부터 다시 실행하면 이미 일어난 일은 건너뛰는 방식으로 같은 지점부터 다시 실행하는 방식이다.&lt;/p&gt;
&lt;pre id=&quot;code_1783864675070&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;payment()

&amp;rarr; 이미 끝났네

inventory()

&amp;rarr; 이미 끝났네

coupon()

&amp;rarr; 아직 안 했네

실행&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;Deterministic(결정론적)&amp;nbsp;&lt;/b&gt;이라고 한다.&amp;nbsp; 같은 히스토리가 주어진 재생할 때마다 정확히 같은 순서로 같은 판단을 내려야 한다.&amp;nbsp; 그렇기 때문에 `Random()`을 직접 쓰거나 `&lt;span&gt;LocalDateTime&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;now&lt;/span&gt;&lt;span&gt;()` 처럼 매번 달라지는 것은 사용하면 재생이 어긋나므로 사용하면 안된다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;그래서 workflow.randomUUID() 같은 함수를 별도로 제공하기도 한다고 한다.&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;지금까지 살펴본 내용으로 Temporal은 장/단점이 명확하다.&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;재시도/보상/멱등성을 코드로 작성하지 않아도 된다.&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`RetryOption`,&amp;nbsp; `Saga`와 같은 선언만 하면 플랫폼이 알아서 처리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;워크플로우 코드가 순차 코드처럼 읽힌다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태 컬럼이나 복구 배치 없이 `try/catch` 만으로 전체 흐름을 읽을 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;실행 이력이 자동으로 남는다.&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Temporal Web UI에서 이 요청이 지금 어느단계인지, 몇번 재시도했는지 그대로 보인다. DB 상태 컬럼을 스캔할 필요가 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;크래시 복구 비용이 매우 적다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새 인프라를 운영해야한다.&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Temporal 서버(+ 백엔드 DB)를 별도로 띄워야 한다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;결정론 제약이 존재한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;워크플로우 코드는 replay를 통해 회복하므로 워크플로우 안에서 직접 HTTP 호출을 하거나 `Random()`을 호출하면 안된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;러닝커브가 높다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Temporal 기본 문법&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;워크플로우 정의&lt;/h4&gt;
&lt;pre id=&quot;code_1784355925657&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@WorkflowInterface
interface PurchaseWorkflow {
    @WorkflowMethod          // 워크플로우의 진입점. 딱 하나만 있어야 한다.
    fun purchase(command: PurchaseCommand): PurchaseStatusView

    @QueryMethod              // 실행 중/완료된 워크플로우의 현재 상태를 조회하는 읽기 전용 메서드.
    fun getStatus(): PurchaseStatusView
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Activity 정의&lt;/h4&gt;
&lt;pre id=&quot;code_1784355969214&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@ActivityInterface
interface PurchaseActivities {
    @ActivityMethod
    fun pay(orderId: String, amount: Long): String
}&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;워크플로우 코드 자체에는 실제 I/O(HTTP 호출, DB 접근)를 두면 안 되고, 전부 Activity로 감싸서 워크플로우가 그 Acitivity를 호출하는 형태로 짠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Spring Boot 연동 - 자동 등록&lt;/h4&gt;
&lt;pre id=&quot;code_1784356120559&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@WorkflowImpl(taskQueues = [&quot;purchase-task-queue&quot;])
class PurchaseWorkflowImpl : PurchaseWorkflow { ... }

@Component
@ActivityImpl(taskQueues = [&quot;purchase-task-queue&quot;])
class PurchaseActivitiesImpl(...) : PurchaseActivities { ... }&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;`taskQueue`만 맞춰주면 `temporal-spring-boot-starter`가 Worker 등록부터 기동까지 알아서 해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;재시도 정책&lt;/h4&gt;
&lt;pre id=&quot;code_1784356177869&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Workflow.newActivityStub(
    PurchaseActivities::class.java,
    ActivityOptions.newBuilder()
        .setScheduleToCloseTimeout(Duration.ofSeconds(30))   // 재시도 포함 전체 제한시간
        .setRetryOptions(
            RetryOptions.newBuilder()
                .setInitialInterval(Duration.ofSeconds(1))     // 첫 재시도까지 대기
                .setBackoffCoefficient(2.0)                     // 지수 백오프 배율
                .setMaximumInterval(Duration.ofSeconds(5))      // 재시도 간격 상한
                .setDoNotRetry(PaymentDeclinedException::class.java.name)  // 이 예외는 재시도 안 함
                .build(),
        )
        .build(),
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;보상(Saga)&lt;/h4&gt;
&lt;pre id=&quot;code_1784356246991&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val saga = Saga(Saga.Options.Builder().setParallelCompensation(false).build())

pointActivities.accumulatePoints(...)
saga.addCompensation {
    pointActivities.revokePoints(...)   // 나중에 실패하면 이 함수가 실행된다
}

try {
    paymentActivities.pay(...)
} catch (e: ActivityFailure) {
    saga.compensate()   // 등록해둔 보상들을 등록 역순으로 실행
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;워크플로우 시작(클라이언트쪽)&lt;/h4&gt;
&lt;pre id=&quot;code_1784356275698&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val workflow = workflowClient.newWorkflowStub(
    PurchaseWorkflow::class.java,
    WorkflowOptions.newBuilder()
        .setWorkflowId(request.orderId)   // 이 ID가 곧 멱등키 역할을 한다
        .setTaskQueue(&quot;purchase-task-queue&quot;)
        .build(),
)

val untyped = WorkflowStub.fromTyped(workflow)
untyped.start(command)
val result = untyped.getResult(3, TimeUnit.SECONDS, PurchaseStatusView::class.java)  // 타임아웃 지정 가능&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;같은 `workflowId`로 다시 시작하면 Temporal이 기존 실행을 그대로 재사용한다.(멱등성 제공)&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;전바적인 Application &amp;lt;-&amp;gt; Temporal 간 플로우는 아래와 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2166&quot; data-origin-height=&quot;1336&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3QsYi/dJMcaaF8i8n/noe3MTtPFKgvl3Su6y6uTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3QsYi/dJMcaaF8i8n/noe3MTtPFKgvl3Su6y6uTK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3QsYi/dJMcaaF8i8n/noe3MTtPFKgvl3Su6y6uTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3QsYi%2FdJMcaaF8i8n%2Fnoe3MTtPFKgvl3Su6y6uTK%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;2166&quot; height=&quot;1336&quot; data-origin-width=&quot;2166&quot; data-origin-height=&quot;1336&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;size14&quot;&gt;&lt;b&gt;1.Worker 내부 구조&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Worker 프로세스는 같은 task queue 이름을 두 개의 별도 poller로 구독한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- WorkFlow Task Poller : 워크플로우를 진행/재개시켜야 할 때 받는 태스크&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- Activity Task Poller : 액티비티를 실제 실행 해야 할 때 받는 태스크&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;현재 내 토이 프로젝트에서는 `purchase-task-queue`같은 하나의 큐 이름을 쓰지만, 서버 입장에선 워크플로우용/액티비용이 논리적으로 분리되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;2.Workflow Task를 전달 받은 경우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Worker가 워크플로우 태스크를 받으면 워크플로우 코드를 실제로 실행하는데, 여기서 중요한 게 &quot;처음부터 다시 실행하는가?&quot; 혹은 &quot;이어서 실행하는가?&quot;를 판단한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;3.Workflow 코드가 Acitivity를 호출&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;워크플로우 코드 안에서 activities를 호출해도 실제 호출이 아닌 커맨드를 만드는것에 해당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- 워크플로우 실행 스레드는 이 호출을 `ScheduleActivityTask`라는 커맨드로 변환&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- 워크플로우 코드 실행은 여기서 suspend - 결과가 올 때 까지 이 지점에서 멈춘다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- Worker는 이번 워크플로우 태스크 처리 결과로 서버에 `RespondWorkflowTaskCompled(commands=[ScheduleActivityTask])를 전달` -&amp;gt; 실제 실행이 아니라 커맨드를 만들었다라고 보고함&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- 서버는 이 커맨드를 받아 `AcitivityTaskScheduled` 이벤트로 기록하고 액티비티 task queue에 태스크를 적재&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;4. Activity Task를 받았을 때 - 실제 실행&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- 같은(또는 다른) worker의 Activity Task Poller가 이 이 태스크를 가져간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- 이번엔 replay 개념이 없다. Activity는 워크플로우와 달리 매 시도마다 실제로 실행된다.(비결정적이며, 따라서 실제 I/O, 외부 API 호출 등을 여기서 수행해야함)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- 실패시 Activity에 설정된 RetryOptions에 따라 서버가 재시도 스케줄링(백오프 포함)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- 완료되면 RespondActivityTaskCompleated(또는 failed)가 서버에 보고&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;5. 결과가 워크플로우로 돌아오는 과정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- 서버는 ActivityTaskCompleted 이벤트를 히스트로리에 기록&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- 서버는 새로운 workFlow Task를 생성해 다시 Task queue에 적재(&quot;이 워크플로우 재개 가능해졌다&quot;라고 표기)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- workflow task poller가 다시 가져가서, 이어서 진행&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;- suspend지점이 액티비티 결과값을 받은 것 처럼 재개되고,워크플로우 코드의 다음줄 부터 계속 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1784358015561&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Workflow Task 도착]
  &amp;rarr; Worker가 워크플로우 코드 실행 (또는 replay)
  &amp;rarr; activity 호출 지점에서 커맨드 생성 후 suspend
  &amp;rarr; 서버에 커맨드 보고 (ActivityTaskScheduled 기록)

[Activity Task 도착]
  &amp;rarr; Worker가 실제 액티비티 메서드 실행 (I/O 포함)
  &amp;rarr; 결과를 서버에 보고 (ActivityTaskCompleted 기록)
  &amp;rarr; 서버가 새 Workflow Task 생성

[Workflow Task 재도착]
  &amp;rarr; suspend됐던 지점부터 재개 (or replay로 그 지점까지 복원)
  &amp;rarr; 다음 로직 진행, 반복&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Temporal 서버 기본 대시보드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Temporal 공식 문서 기반으로 보면 기본적으로 제공하고 있는 매트릭을 기반으로 그라파나 대시보드를 구성해 볼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2960&quot; data-origin-height=&quot;1594&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZuFwu/dJMcacDWRxC/p6hiEVqskymvPJ0gvzuY6k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZuFwu/dJMcacDWRxC/p6hiEVqskymvPJ0gvzuY6k/img.png&quot; data-alt=&quot;Temporal 서버 대시보드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZuFwu/dJMcacDWRxC/p6hiEVqskymvPJ0gvzuY6k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZuFwu%2FdJMcacDWRxC%2Fp6hiEVqskymvPJ0gvzuY6k%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;2960&quot; height=&quot;1594&quot; data-origin-width=&quot;2960&quot; data-origin-height=&quot;1594&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Temporal 서버 대시보드&lt;/figcaption&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;Temporal 서버 주요 매트릭은 아래와 같다.&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;p95&amp;nbsp;지연시간&amp;nbsp;(Overview)&lt;br /&gt;-&amp;nbsp;오퍼레이션별&amp;nbsp;요청량,&amp;nbsp;에러&amp;nbsp;타입별&amp;nbsp;발생량&lt;br /&gt;-&amp;nbsp;오퍼레이션별&amp;nbsp;요청&amp;nbsp;p95&amp;nbsp;지연시간&lt;br /&gt;- DB(persistence) p95 지연시간 &amp;mdash; PostgreSQL 쿼리가 느려지는지(이벤트 히스토리 저장하는 DB로 변경가능)&lt;br /&gt;-&amp;nbsp;태스크&amp;nbsp;큐&amp;nbsp;대기(asyncmatch)&amp;nbsp;p95&amp;nbsp;지연시간&amp;nbsp;&amp;mdash;&amp;nbsp;Worker가&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;p data-ke-size=&quot;size16&quot;&gt;Temporal&amp;nbsp; 서버에 요청하는 애플리케이션 주요 매트릭&lt;br /&gt;-&amp;nbsp;앱&amp;nbsp;Uptime&amp;nbsp;/&amp;nbsp;CPU&amp;nbsp;사용률&amp;nbsp;/&amp;nbsp;JVM&amp;nbsp;Heap&amp;nbsp;/&amp;nbsp;Live&amp;nbsp;Thread&amp;nbsp;수&lt;br /&gt;-&amp;nbsp;워크플로우&amp;nbsp;완료/실패/취소율&amp;nbsp;(`workflow_type`별)&lt;br /&gt;-&amp;nbsp;워크플로우&amp;nbsp;end-to-end&amp;nbsp;p95&amp;nbsp;지연시간&lt;br /&gt;-&amp;nbsp;액티비티&amp;nbsp;실행&amp;nbsp;p95&amp;nbsp;지연시간,&amp;nbsp;실패율&amp;nbsp;(`activity_type`별)&lt;br /&gt;-&amp;nbsp;REST&amp;nbsp;API(`POST&amp;nbsp;/api/v1/purchases`&amp;nbsp;등)&amp;nbsp;p95&amp;nbsp;지연시간&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;Temporal 워크플로우 VS Application Workflow(기존 방식)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 코드를 작성하고 비교해본 느낀 점을 작성해보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Temporal 워크플로우 기반의 Retry, 폴백, 타임아웃 등으로 장애 대응 프로세스를 개발해보니 Spring Batch를 처음 썻을 때의 느낌을 받았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Batch Processor를 Spring Batch로 마이그레이션 할때도 러닝커브가 조금 있었는데, 정해진 프레임워크 안에서 비지니스로직을 녹이는것에 대한 안정감을 느꼈던것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Temporal는 러닝 커브가 좀더 높고 아무래도 Temporal 서버를 별도로 구성해야하기 때문에 인프라 영역에 대한 허들도 존재할것으로 보인다. 그뿐만 아니라 결정론 제약,&amp;nbsp; 버저닝 문제라는 대가를 치른다. 결국 &quot;재시도/보상 로직을 얼마나 자주, 얼마나 복잡하게 손으로 짜야 하는가&quot;가 이 트레이드오프를 넘을 만한 값어치가 있는지를 가르는 기준인 것 같다.&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;- 토이 프로젝트 Temporal github (&lt;a href=&quot;https://github.com/hoyo1744/spring-temporal&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/hoyo1744/spring-temporal&lt;/a&gt;)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;nbsp;[OSS&amp;nbsp;Temporal&amp;nbsp;Service&amp;nbsp;metrics&amp;nbsp;reference](&lt;a href=&quot;https://docs.temporal.io/references/cluster-metrics)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.temporal.io/references/cluster-metrics)&lt;/a&gt;&lt;br /&gt;-&amp;nbsp;[Temporal&amp;nbsp;SDK&amp;nbsp;metrics&amp;nbsp;reference](&lt;a href=&quot;https://docs.temporal.io/references/sdk-metrics)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.temporal.io/references/sdk-metrics)&lt;/a&gt;&lt;br /&gt;-&amp;nbsp;[Monitor&amp;nbsp;Temporal&amp;nbsp;Platform&amp;nbsp;metrics](&lt;a href=&quot;https://docs.temporal.io/self-hosted-guide/monitoring)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.temporal.io/self-hosted-guide/monitoring)&lt;/a&gt;&lt;br /&gt;-&amp;nbsp;[temporalio/dashboards](&lt;a href=&quot;https://github.com/temporalio/dashboards)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/temporalio/dashboards)&lt;/a&gt;&lt;/p&gt;</description>
      <author>Leoo</author>
      <guid isPermaLink="true">https://farmer-eom.tistory.com/3</guid>
      <comments>https://farmer-eom.tistory.com/3#entry3comment</comments>
      <pubDate>Sat, 18 Jul 2026 16:33:05 +0900</pubDate>
    </item>
    <item>
      <title>JVM Lock부터 분산락을 이용한 문제 해결까지</title>
      <link>https://farmer-eom.tistory.com/2</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Lock&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://farmer-eom.tistory.com/1&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://farmer-eom.tistory.com/1&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1783758890617&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Virtual Thread 너 좋다던데?&quot; data-og-description=&quot;최근 보안 이슈가 많이 발생하다보니 회사에서는 외부의 공격으로 부터 페이로드 변조를 검증하는 기능을 추가하게 되었다. 페이로드 검증 기능을 추가하고 보니 피크 타임 기준으로 504 에러가&quot; data-og-host=&quot;farmer-eom.tistory.com&quot; data-og-source-url=&quot;https://farmer-eom.tistory.com/1&quot; data-og-url=&quot;https://farmer-eom.tistory.com/1&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b9TqyP/dJMb9b33TRP/0ADkQUSysklIApJAJBwW3k/img.png?width=700&amp;amp;height=350&amp;amp;face=0_0_700_350,https://scrap.kakaocdn.net/dn/Mk1JX/dJMb9efpVrh/8pQhYgPQLTsvVKoDeOtAE0/img.png?width=700&amp;amp;height=350&amp;amp;face=0_0_700_350,https://scrap.kakaocdn.net/dn/mY6Rp/dJMb9ia2Z5D/xyqvADVf3rHPhK85TivZl0/img.png?width=1502&amp;amp;height=592&amp;amp;face=0_0_1502_592&quot;&gt;&lt;a href=&quot;https://farmer-eom.tistory.com/1&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://farmer-eom.tistory.com/1&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b9TqyP/dJMb9b33TRP/0ADkQUSysklIApJAJBwW3k/img.png?width=700&amp;amp;height=350&amp;amp;face=0_0_700_350,https://scrap.kakaocdn.net/dn/Mk1JX/dJMb9efpVrh/8pQhYgPQLTsvVKoDeOtAE0/img.png?width=700&amp;amp;height=350&amp;amp;face=0_0_700_350,https://scrap.kakaocdn.net/dn/mY6Rp/dJMb9ia2Z5D/xyqvADVf3rHPhK85TivZl0/img.png?width=1502&amp;amp;height=592&amp;amp;face=0_0_1502_592');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Virtual Thread 너 좋다던데?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;최근 보안 이슈가 많이 발생하다보니 회사에서는 외부의 공격으로 부터 페이로드 변조를 검증하는 기능을 추가하게 되었다. 페이로드 검증 기능을 추가하고 보니 피크 타임 기준으로 504 에러가&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;farmer-eom.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 포스팅에서 Virtual Thread 관련된 성능 테스트를 해보고 유의사항을 정리 했었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 유의사항 중에서 가장 기억에 남는 포인트는 Pinning 현상이었던것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 JVM Lock 관련되어서 공부한지 너무 오래되기도 했고, 기존에 Velog에서 Tistory로 옮겨오면서 Virtual Thread 사용 시 문제가 됐었던 Pinning 현상의 주범인 Monitor Lock에 대한 정리와 그 Lock 확장인 분산락과 실제 내 경험을 정리해보려고 한다.&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;Java Object&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;굉장히 기초적인 내용이지만, 아래 설명할 내용의 이해를 돕기 위해서 한 가지 내용을 기억해둬야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java의 최상위인 Object는 내부적으로 Header, Instance Data를 기본적으로 갖는다. 그리고 필요시 JVM에서는 Monitor(ObjectMonitor)를&amp;nbsp; 생성해 연결하여 갖고 있는다.&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;JVM Lock : Synchronized&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JVM Lock에서 가장 쉽게 사용할 수 있는게 synchronized 를 붙이는 방법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;synchronized는 이전 포스팅에서처럼 Montior Lock으로 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 Monitor Lock은 무엇일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;synchronized를 사용하게 되면 아래와 같이 Lock을 얻어 처리하게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1783760741638&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;synchronized
      │
      ▼
Monitor Enter
      │
      ▼
Monitor Lock 획득
      │
      ▼
임계영역 실행
      │
      ▼
Monitor Exit&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 한가지 기억해둔 내용 처럼 Monitor는 Java의 모든 Object가 갖고 있다고 보면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, synchronized를 이용해 Lock을 얻고자 한다면 Monitor 를 이용해 Lock을 얻어 임계영역을 실행하게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1783761027850&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;synchronized(lock) {

    doSomething();

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 synchronzied를 수행하면&amp;nbsp; lock 객체를 이용해 아래와 같이 호출되는것과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1783761061617&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;lock.monitor.enter()

...

lock.monitor.exit()&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;그래서 이를 이전 포스팅에서 봤었던 Pinning 현상을 만들고자 한다면,&amp;nbsp; Carrier Thread가 많은 Virtual Thread를 실행해야하는데 Virtual Thread가 Monitor를 잡고있으니 Monitor 때문에 Carrier Thread를 놓지 못하므로 여러 Virtual Thread를 실행시킬 수 없어 Pinning 현상이 발생하는것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1783761194865&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Virtual Thread

&amp;darr;

Monitor Lock&lt;/code&gt;&lt;/pre&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;JVM Lock : ReetrantLock&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ReetrantLock은 synchronized와 동일하지만 개발자가 직접 락을 제어할 수 있다.&lt;/p&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;간단히 synchronized와 ReetrantLock을 비교해보자.&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: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;synchronized&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;ReetrantLock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;락 해제&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;자동&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;직접 unlock()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;재진입(Reentrant)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;tryLock()&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;timeout&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;Interruptible Lock&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;O (lockInterruptibly())&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;공정 락(Fair Lock)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;사용 난이도&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;쉬움&lt;/td&gt;
&lt;td style=&quot;height: 19px;&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;p data-ke-size=&quot;size16&quot;&gt;ReetrantLock은 말그대로 재진입이 가능한 락인데 아래와 같은 상황에서도 DeadLock 없이 사용이 가능하다.&lt;/p&gt;
&lt;pre id=&quot;code_1783761748677&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void A() {
    lock.lock();

    B();

    lock.unlock();
}

public void B() {
    lock.lock();

    lock.unlock();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 내부적으로 Count를 이용해서 Lock이 관리되고 있기 때문이며 실제로 Lock을 2번해야 count = 0이 되어 실제 락이 해제된다.&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;이전 Virtual Thread에서 ReentrantLock을 사용하면 Pinning 현상이 발생하지 않는걸로 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 Monitor를 사용하지 않기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ReetrantLock은 AQS(AbstractQueuedSynchronizer)를 이용해서 구현이 되어있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Monitor를 이용하면 JVM이 정책을 정하기 때문에 개발자가 바꿀 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 ReentrantLock은 AQS를 이용하고 AQS는 전부 자바 코드로 되어 있기 때문에 JVM이 아니라 정책을 다양하게 가져갈 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 나아가서 ReentrantLock을 사용하면 내부에서 park 명령어를 사용했을때 Carrier Thread를 반환할 수 있기 때문이다.&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;CAS(Compare-And-Swap)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 Lock에 대해서 설명하려면 CAS가 필수적이라 한번 정리한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;CAS는 현재 값이 내가 예상한 값과 같으면 새로운 값으로 변경하는 CPU의 원자 연산이다.&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;CAS가 나온 배경은 멀티스레드환경에서 락을 사용할 경우 Context Switch, OS Scheduler 등 다양한 리소스가 사용되는데 이 비용이 너무 크다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;그래서 락 없이 원자적으로 값을 변경할 수 없을까?&amp;nbsp;&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;위 아이디어에서 등장한게 CAS다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CAS는 CPU가 제공하는 원자 명령어이며 Lock처럼 기다리지도 않는다. 성공/실패만 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 ReetrantLock 내부에서 CAS를 1번 사용해서 Lock 획득 여부를 체크한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래의 코드는 ReetrantLock의 구현 코드 중 일부이며, 코드 내용을 보면&amp;nbsp; compareAndSetState를 사용해서 Lock 획득 여부를 체크한다.&lt;/p&gt;
&lt;pre id=&quot;code_1783764043368&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;        @ReservedStackAccess
        final boolean tryLock() {
            Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, 1)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            } else if (getExclusiveOwnerThread() == current) {
                if (++c &amp;lt; 0) // overflow
                    throw new Error(&quot;Maximum lock count exceeded&quot;);
                setState(c);
                return true;
            }
            return false;
        }&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;CAS와 관련되어서 재밌는 구현이 한 가지 더 있다.&amp;nbsp; 바로 AtomicInteger 구현 내용 중 일부이다.&lt;/p&gt;
&lt;pre id=&quot;code_1783764521364&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@IntrinsicCandidate
    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));
        return v;
    }&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;위 코드는 AtomicInteger의 구현 내용 중 일부이며 여기에서도 CAS를 사용하고 있는걸 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데, 반복문안에서 CAS를 호출하고 있다. 만약 1000개에 스레드에서 CPU 연산을 계속 호출한다면 어떻게 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 AtomicInteger 사용시 CPU 자원사용에 대한 이야기들이 많은데 그 이유가 바로 CAS 때문이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스핀락(Busy Waiting)과 분산락&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 CAS 이야기를 하면 빼놓을 수 없는 이야기가 스핀락이고, 스핀락 하면 나오는 이야기가 분산락이다.&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&gt; &lt;/span&gt;현업에서&lt;span&gt; &lt;/span&gt;분산락은&lt;span&gt; Redis&lt;/span&gt;를&lt;span&gt; &lt;/span&gt;이용해서&lt;span&gt; &lt;/span&gt;구현하고&lt;span&gt; &lt;/span&gt;있다&lt;span&gt;. &lt;/span&gt;실제로&lt;span&gt; &lt;/span&gt;나&lt;span&gt; &lt;/span&gt;또한&lt;span&gt; &lt;/span&gt;배치&lt;span&gt; &lt;/span&gt;중복&lt;span&gt; &lt;/span&gt;실행이나&lt;span&gt; &lt;/span&gt;캐시&lt;span&gt; &lt;/span&gt;스탬피드&lt;span&gt; &lt;/span&gt;현상을&lt;span&gt; &lt;/span&gt;피하기&lt;span&gt; &lt;/span&gt;위해서&lt;span&gt; &lt;/span&gt;분산락을&lt;span&gt; &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;스핀락(Busy Waiting)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;분산락이란?&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;여러 서버(프로세스)에서 공유 자원에 동시에 접근하지 못하도록 보장하는 락이다.&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;사실&lt;span&gt; &lt;/span&gt;스핀락이란&lt;span&gt; &lt;/span&gt;건&lt;span&gt; &lt;/span&gt;분산락을&lt;span&gt; &lt;/span&gt;구현하는&lt;span&gt; &lt;/span&gt;과정에서&lt;span&gt; &lt;/span&gt;발생할&lt;span&gt; &lt;/span&gt;수&lt;span&gt; &lt;/span&gt;있는&lt;span&gt; &lt;/span&gt;락을&lt;span&gt; &lt;/span&gt;기다리는&lt;span&gt; &lt;/span&gt;방식을&lt;span&gt; &lt;/span&gt;말한다&lt;span&gt;. &lt;/span&gt;락을&lt;span&gt; &lt;/span&gt;획득할&lt;span&gt; &lt;/span&gt;때까지&lt;span&gt; &lt;/span&gt;스레드가&lt;span&gt; &lt;/span&gt;잠들지&lt;span&gt; &lt;/span&gt;않고&lt;span&gt; &lt;/span&gt;계속&lt;span&gt; &lt;/span&gt;반복해서&lt;span&gt; &lt;/span&gt;시도하는&lt;span&gt; &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;p data-ke-size=&quot;size16&quot;&gt;스핀락이 왜 발생할까?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 사실 이미 CAS의 문제점을 보면서 이미 확인했다.&lt;/p&gt;
&lt;pre id=&quot;code_1783781567145&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;락 획득 실패
   │
   ├── ① 잠든다 (Block) &amp;mdash; OS에게 &quot;나 재워줘&quot; 요청, 락 풀리면 깨워달라고 등록
   │
   └── ② 계속 확인한다 (Spin) &amp;mdash; CPU 붙잡고 &quot;됐나? 됐나?&quot; 반복 --&amp;gt; CompareAndState 반복&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;span&gt;①(&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;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;스레드를 재우고 꺠우는 건 OS 커널 개입 + Context Switch가 필요하다. 이게 대략 마이크로초 단위의 비용인데, 만약 락이 그 비용보다도 짧은 시간 안에 풀릴 예정이라면?&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1783781972138&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;블로킹 선택 시:
   Context Switch 비용(비쌈) &amp;gt; 실제 기다려야 하는 시간(짧음)
   &amp;rarr; 재우고 깨우는 데 드는 비용이 오히려 손해

스핀 선택 시:
   그냥 몇 번 확인만 하면 곧 락이 풀림
   &amp;rarr; Context Switch 없이 바로 획득 (더 빠름)&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;즉, 스핀락은&amp;nbsp;&lt;b&gt;임계영역이 아주 짧은이라는 가정 위에서 성립하는 최적화&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 최신 JVM 기준으로&amp;nbsp;&lt;b&gt;synchronized&lt;/b&gt;는 적응형 스핀락을 사용한다. &quot;과거 통계를 기반으로 이 락은 보통 금방 풀린다&quot;라고 판단되면 스핀, 안 풀리면 블로킹으로 전환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- OS 커널은 아주 짧은 임계구역을 보호 할 때 스핀락을 쓴다고 한다.&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;-&amp;nbsp;&lt;b&gt;Context Switch 비용이 없다.&lt;/b&gt; 락이 짧은 시간 안에 풀린다면 블로킹보다 훨씬 빠르게 획득할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;nbsp;&lt;b&gt;구현이 단순하다.&lt;/b&gt; CAS 루프 하나로 끝난다. -&amp;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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;nbsp;&lt;b&gt;임계구역이 길어지면 손해가 커진다.&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기다리는 동안 CPU를 계속 쓰기 때문에(Like AtomicInteger) 예상보다 락이 오래 잡혀 있으면 그만큼 CPU 자원을 낭비한다.&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;b&gt;락을 쥔 스레드가 멈추면 최악이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락 소유자가 OS 스케줄러에 의해 선점되거나 어떤 이유로든 멈춰버리면, 나머지 스레드들은 아무 진행도 못하고 헛되이 스핀만 돈다.&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;b&gt;코어가 많고 경쟁이 심할수록 나빠진다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 코어가 동시에 같은 락을 확인하면서 캐시 경합이 발생해 오히려 전체 처리량이 떨어질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 스핀락은 아래 조건을 &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;b&gt;임계구역이 아주 짧다&lt;/b&gt;&amp;nbsp;&amp;mdash; 대기 시간 자체가 Context Switch 비용보다 작아야 스핀이 이득&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;락 경쟁이 적다&lt;/b&gt;&amp;nbsp;&amp;mdash; 대기하는 스레드가 많아지면 스핀 도는 CPU 낭비가 배로 늘어남&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;CPU 코어가 충분히 여유롭다&lt;/b&gt;&amp;nbsp;&amp;mdash; 스핀 도는 스레드가 다른 유용한 작업의 코어를 뺏지 않아야 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;-&amp;nbsp;&lt;/span&gt;&lt;b&gt;락&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;보유&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;시간이&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;예측&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;가능하다&lt;/b&gt;&lt;span&gt;&amp;nbsp;&amp;mdash; &quot;&lt;/span&gt;곧&lt;span&gt; &lt;/span&gt;풀린다&lt;span&gt;&quot;&lt;/span&gt;는&lt;span&gt; &lt;/span&gt;가정이&lt;span&gt; &lt;/span&gt;맞아야&lt;span&gt; &lt;/span&gt;스핀이&lt;span&gt; &lt;/span&gt;블로킹보다&lt;span&gt; &lt;/span&gt;빠름&lt;/p&gt;
&lt;div style=&quot;color: #bbbebf; text-align: start;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 아래 상황에서는 스핀락을 쓰면 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;임계구역이 길거나 시간을 예측할 수 없을 때&lt;/b&gt;&amp;mdash; I/O, 외부 API 호출, DB 쿼리처럼 걸리는 시간이 들쭉날쭉한 작업을 스핀락으로 보호하면, 대기하는 스레드들이 그 시간 내내 CPU를 태우며 헛돈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;경쟁하는 스레드 수가 CPU 코어 수보다 훨씬 많을 때&lt;/b&gt;&amp;nbsp;&amp;mdash; 스핀 도는 스레드들끼리 서로 CPU를 뺏고 뺏기면서, 정작 락을 쥐고 실제 작업을 끝내야 할 스레드가 스케줄링을 못 받는 역효과(Priority Inversion과 비슷한 현상)까지 생길 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;단일 코어 환경&lt;/b&gt;&amp;nbsp;&amp;mdash; 스핀 도는 스레드가 CPU를 붙잡고 있는 동안, 락을 쥔 스레드는 애초에 실행될 기회조차 못 얻는다. 스핀이 오히려 락 해제를 지연시키는 자기모순적 상황이 된다.&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;이래서 JVM의 synchronized가 &quot;무조건 스핀&quot;이 아니라 &lt;b&gt;적응형(adaptive)&lt;/b&gt;&amp;nbsp;스핀을 쓰는 것이다 &amp;mdash; 위 조건이 항상 성립하는 게 아니니, 런타임에 통계를 보고 스핀할지 블로킹할지 그때그때 판단한다.&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;Redis 분산락을 가장 단순하게 구현하면 아래와 같은 모양이된다.&lt;/p&gt;
&lt;pre id=&quot;code_1783783245881&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;while (true) {
    val acquired = redisTemplate.opsForValue().setIfAbsent(key, token, ttl)
    if (acquired == true) break
    Thread.sleep(100)  // 될 때까지 계속 다시 물어본다
}&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;SET NX가 성공할 때까지 계속 재시도하는 이 패턴이 정확히 스핀락과 같은 본질(=될 때까지 반복 확인)을 갖는다. 다만 CPU 대신 &lt;b&gt;Redis를 계속 두드린다&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;&lt;b&gt;여기서 중요한 건, 이건 JVM의 적응형 스핀처럼 &quot;짧은 대기를 노리는 의도적 최적화&quot;가 아니라는 점이다.&lt;/b&gt; 그냥 &quot;Redis 키가 풀렸는지 다시 확인하는&quot; 가장 단순한 구현 방법이 하필 스핀락과 같은 모양이 된 것뿐이다. 그래서 경쟁이 심해지면(대기하는 클라이언트가 많아지면) 스핀락의 단점이 그대로 드러난다 &amp;mdash; 다들 동시에 Redis를 계속 두드리면서 Redis 자체에 불필요한 부하를 준다.(CAS의 경우는 CPU 였지만)&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&gt; &lt;/span&gt;바로&lt;span&gt; Redis &lt;/span&gt;자체는&lt;span&gt; &lt;/span&gt;스핀락이&lt;span&gt; &lt;/span&gt;아니지만&lt;span&gt;(SET NX&lt;/span&gt;는&lt;span&gt; &lt;/span&gt;원자적으로&lt;span&gt; &lt;/span&gt;한&lt;span&gt; &lt;/span&gt;번&lt;span&gt; &lt;/span&gt;시도하고&lt;span&gt; &lt;/span&gt;끝나는&lt;span&gt; &lt;/span&gt;연산이다&lt;span&gt;), &lt;b&gt;Lettuce&lt;/b&gt;&lt;/span&gt;&lt;b&gt;로&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;구현한&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;분산락은&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;스핀락과&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;유사한&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;특성을&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;갖게&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;되는&lt;/b&gt;&lt;span&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;이유&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Lettuce는 Redis 커맨드를 그대로 실행해주는 얇은 클라이언트다. SET, GET, DEL 같은 워시 명령어를 보내고 응답을 받는 역할만 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;- Lettuce는 &quot;락 획득 실패 시 자동으로 해당 채널을 구독하고, 해제 이벤트를 받으면 깨어나서 재시도한다&quot;는 조합 로직을 전혀 제공하지 않고, SET, DEL, SUBSCRIBE는 각각 따로 실행시켜주는 개발 명령어일 뿐이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;- 이 조합 로직을 직접 만들기 위해서는 구독 타이밍과 SET NX 재시도 사이의 race condition 처리, 여러 후보가 동시에 깨어났을 때의 재경쟁, 타임아웃 처리 등 Redission이 이미 만들어 놓은 걸 처음 부터 다시 구현해야한다.&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;&lt;span&gt;반대로 Redission은 Lettuce와 태생이 다르다! Redission은 java.util.concurrent.locks.Lock 인터페이스를 그대로 구현한 RLock을 제공하는 걸 목표로 만들어진 라이브러리다. (마치 ReetrantLock 처럼)&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;&lt;span&gt;ReentrantLock이 실패하면 스핀 대신 스레드를 park() 시켜서 재우는 것처럼, RLock도 실패하면&amp;nbsp;&lt;b&gt;진짜로 스레드를 재운다.&lt;/b&gt;이를 위해 Redission은 내부적으로 아래와 같이 처리한다.&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;&lt;span&gt;1. 락 실패 시, 그 락 전용 Pub/Sub 채널을 구독한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;2.스레드를 블로킹 시킨다.(busy-wait이 아니다.)&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;3.락을 쥔 쪽이 unlock() 할때, DEL과 동시에 그 채널로 publush한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;4.대기 중이던 스레드가 그 메시지를 받으면 그제서야 깨어나서 SET NX를 다시 시도한다.&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;&lt;b&gt;&lt;span&gt;이 구조 자체가 &quot;실패하면 계속 반복 확인&quot;이 아니라 &quot;실패하면 알림을 받을 때 까지 진짜로 잔다&quot; 이기 때문에, 정의상 스핀락이 될 수가 없다.&lt;/span&gt;&lt;/b&gt;&lt;span&gt; (스핀락이 되려면 확인만 반복하고 재우지 않는 구조)&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 Lettuce 분산락 VS Redission 분산락에 대해서 성능 테스트를 해보자.&lt;/p&gt;
&lt;pre id=&quot;code_1783786872813&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;시나리오1(동시성 낮음)
- 커머스 환경에서 이벤트를 오픈했다.
- 사용자가 한번에 몰리는 상황이 아니라 각자 편한 시간에 들어와 여유 있게 쿠폰을 받아 간다.
- 실제로 내 요청과 다른 사람의 요청이 거의 겹치지 않는다.
- K6 VUS = 1, 100건 요청, 락 경쟁 거의 없음
- 예상 : Lettuce &amp;lt; Redssion

시나리오2(동시성 중간)
- 커머스 환경에서 이벤트를 오픈했다.
- 오픈과 동시에 푸시 알림을 보고 거의 동시에 들어와 쿠폰 발급을 누른다.
- K6 VUS = 5 내외, 100건 요청, 락 경쟁 중간
- 예상 : Lettuce &amp;lt; Redssion

시나리오3(동시성 매우 높음)
- 커머스 환경에서 이벤트를 오픈했다.
- 쿠폰을 받기 위해서 대기하고 있던 사람들이 동시에 버튼을 누른다.
- K6 VUS = 50 내외, 100건 요청, 락 경쟁 중간
- 예상 : Lettuce &amp;lt; Redssion&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;Lettuce 분산락 테스트 코드&lt;/p&gt;
&lt;pre id=&quot;code_1783787099747&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
class LettuceLockCouponService(
    private val redisTemplate: StringRedisTemplate,
    private val couponRepository: CouponRepository,
) {
    private val lockTtl = Duration.ofSeconds(3)
    private val retryInterval = 100L

    fun issue(couponId: Long) {
        val key = &quot;lock:coupon:$couponId&quot;
        val token = UUID.randomUUID().toString()

        while (true) {
            val acquired = redisTemplate.opsForValue().setIfAbsent(key, token, lockTtl) ?: false
            if (acquired) break
            Thread.sleep(retryInterval) // Polling: 스핀락처럼 &quot;얻을 때까지 계속 확인&quot;
        }

        try {
            issueInTransaction(couponId)
        } finally {
            // 내가 건 락만 지운다(다른 스레드가 TTL 만료 후 새로 잡은 락을 실수로 지우지 않도록).
            val currentValue = redisTemplate.opsForValue().get(key)
            if (currentValue == token) {
                redisTemplate.delete(key)
            }
        }
    }

    @Transactional
    fun issueInTransaction(couponId: Long) {
        val coupon = couponRepository.findById(couponId).orElseThrow()
        coupon.issue()
        couponRepository.save(coupon)
    }
}&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;Redission 분산락 테스트 코드&lt;/p&gt;
&lt;pre id=&quot;code_1783787118517&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
class RedissonLockCouponService(
    private val redissonClient: RedissonClient,
    private val couponRepository: CouponRepository,
) {
    fun issue(couponId: Long) {
        val lock = redissonClient.getLock(&quot;lock:coupon:$couponId&quot;)

        // waitTime=5s(최대 대기), leaseTime 은 안 줘서 WatchDog 이 TTL 을 자동 연장하게 한다.
        val acquired = lock.tryLock(5, TimeUnit.SECONDS)
        check(acquired) { &quot;락 획득 실패: coupon=$couponId&quot; }

        try {
            issueInTransaction(couponId)
        } finally {
            if (lock.isHeldByCurrentThread) {
                lock.unlock()
            }
        }
    }

    @Transactional
    fun issueInTransaction(couponId: Long) {
        val coupon = couponRepository.findById(couponId).orElseThrow()
        coupon.issue()
        couponRepository.save(coupon)
    }
}&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;&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;아래의 결과는 k6를 이용해 각 시나리오를 테스트했을 때의 결과이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 150px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;시나리오&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;전략&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;avg&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;P50&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;P95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1. 무경쟁 (VUS=1)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Lettuce&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.49ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.16ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;3.34ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1. 무경쟁 (VUS=1)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Redisson&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.82ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.31ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;2.51ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;2. 중간경쟁 (VUS=5)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Lettuce&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;11.85ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.27ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;103.67ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;2. 중간경쟁 (VUS=5)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Redisson&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;4.32ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;3.88ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;6.97ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;3. 고경쟁 (VUS=50)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Lettuce&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;600.23ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;4.32ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;2.61s&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;3. 고경쟁 (VUS=50)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Redisson&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;33.31ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;36.05ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;50.28ms&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;p data-ke-size=&quot;size16&quot;&gt;테스트 결과를 보면 약간? 예상과 빗나간점이 있긴하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 무경쟁 상황에서 Lettuce가 더 빠르다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유를 생각해보면 사실 경쟁이 없기 때문에 우열을 가릴 수 없을것같다. 1ms 내 데이터라 노이즈도 존재하기 때문이다.&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;대부분의 요청은 Lettuce가 빠르다. 즉, 경쟁 없이 빠르게 끝났기 떄문이다. 다만 P95는 Redission이 훨씬 빠르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 아무래도 Lettuce는 경쟁이 시작되면 Polling을 위한 대기 시간이 존재하기 때문이다.&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;중간 경쟁상황에서 Lettuce가 느렸듯이 역시 Redission이 빠른게 확인됐다.&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;결론, 경쟁이 거의 없을때는 우열이 없지만 경쟁이 생기는 순간부터 Redssion을 이용한 분산락 성능이 매우 좋으며 그 격차는 경쟁이 심할 수록 크다.&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;끝판왕! 적응형 스핀락을 적용한 Redis 분산락&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 결과를 보면 Lettuce를 이용한 분산락이 느린 이유는 경쟁이 있을 때 Polling을 위한 대기 시간 때문인것으로 확인된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 상황은 아니지만 아래와 같은 상황이 존재한다면?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 임계 구역이 길거나 경쟁이 심해지는 순간이 있다면? --&amp;gt; 스핀락을 사용하면 CPU을 긴 임계기간동안 계속 차지하면서 스핀락이 성능 저하를 일으킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 임계구역이 매우 짧다. --&amp;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;만약, 위 2가지 상황이 섞여서 존재하는 상황에서 분산락을 써야한다면?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;synchronized 처럼 적응형 방식을 사용해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;synchronized는 락 별로 과거 이력을 실시간으로 추적해서 스핀을 할지 블로킹을 할지 매번 판단한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이 락이 최근에 스핀만으로 잘 풀렸는가? -&amp;gt; 스핀락 잡는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 최근에 스핀에서 블로킹으로 된 적이 많은가? -&amp;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;/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;이를 위해서는 Lettuce와 Redission을 섞어서 사용해야하는데, 그게 불가능하기 때문에 Lettuce 기반 + Pub/Sub 채널을 직접 구현하는 방식으로 진행해보았다.&lt;/p&gt;
&lt;pre id=&quot;code_1783790008401&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
class AdaptiveSpinLockCouponService(
    private val redisTemplate: StringRedisTemplate,
    private val listenerContainer: RedisMessageListenerContainer,
    private val couponRepository: CouponRepository,
) {
    private val lockTtl = Duration.ofSeconds(3)
    private val overallTimeoutNanos = Duration.ofSeconds(5).toNanos()

    private val spinBudget = AdaptiveSpinBudget(
        defaultNanos = Duration.ofMillis(5).toNanos(),
        minNanos = 200_000L, // 0.2ms &amp;mdash; 완전히 0으로 떨어지지 않게 해서, 경쟁이 풀렸을 때 다시 회복할 여지를 남긴다.
        maxNanos = Duration.ofMillis(20).toNanos(),
    )

    private val releaseScript = DefaultRedisScript(
        &quot;&quot;&quot;
        if redis.call('get', KEYS[1]) == ARGV[1] then
            redis.call('del', KEYS[1])
            redis.call('publish', KEYS[2], 'released')
            return 1
        else
            return 0
        end
        &quot;&quot;&quot;.trimIndent(),
        Long::class.java,
    )

    fun issue(couponId: Long) {
        val key = &quot;lock:coupon:adaptive:$couponId&quot;
        val token = UUID.randomUUID().toString()
        val start = System.nanoTime()
        val deadline = start + overallTimeoutNanos
        val budget = spinBudget.current(key)
        val spinDeadline = start + budget

        // 1단계: 이 락 키의 히스토리 기반 스핀 예산만큼 스핀(Busy-Wait).
        while (System.nanoTime() &amp;lt; spinDeadline) {
            if (tryAcquire(key, token)) {
                spinBudget.recordSuccess(key)
                runCriticalSection(couponId, key, token)
                return
            }
            Thread.onSpinWait()
        }
        spinBudget.recordFailure(key)

        // 2단계: 스핀으로 못 잡았다 &amp;rarr; 폴링 대신 해제 알림을 구독해서 대기.
        if (waitForReleaseAndAcquire(key, token, deadline)) {
            runCriticalSection(couponId, key, token)
            return
        }

        throw IllegalStateException(&quot;락 획득 타임아웃: coupon=$couponId&quot;)
    }

    private fun waitForReleaseAndAcquire(
        key: String,
        token: String,
        deadline: Long,
    ): Boolean {
        while (System.nanoTime() &amp;lt; deadline) {
            val latch = CountDownLatch(1)
            val listener = MessageListener { _: Message, _: ByteArray? -&amp;gt; latch.countDown() }
            val topic = ChannelTopic(releaseChannel(key))

            listenerContainer.addMessageListener(listener, topic)
            try {
                // 구독을 먼저 걸고 나서 재확인 &amp;mdash; 스핀 종료와 구독 사이에 풀렸을 가능성을 놓치지 않기 위함.
                if (tryAcquire(key, token)) {
                    return true
                }

                val remainingNanos = deadline - System.nanoTime()
                if (remainingNanos &amp;lt;= 0) return false

                // 통보가 영영 안 오는 경우(락 쥔 쪽이 release 없이 죽은 경우)에 대비해, TTL 주기로는 스스로 깨어난다.
                val waitNanos = minOf(remainingNanos, lockTtl.toNanos())
                latch.await(waitNanos, TimeUnit.NANOSECONDS)
            } finally {
                listenerContainer.removeMessageListener(listener, topic)
            }
        }
        return false
    }

    private fun runCriticalSection(
        couponId: Long,
        key: String,
        token: String,
    ) {
        try {
            issueInTransaction(couponId)
        } finally {
            release(key, token)
        }
    }

    private fun tryAcquire(
        key: String,
        token: String,
    ): Boolean = redisTemplate.opsForValue().setIfAbsent(key, token, lockTtl) ?: false

    private fun release(
        key: String,
        token: String,
    ) {
        redisTemplate.execute(releaseScript, listOf(key, releaseChannel(key)), token)
    }

    private fun releaseChannel(key: String): String = &quot;$key:released&quot;

    @Transactional
    fun issueInTransaction(couponId: Long) {
        val coupon = couponRepository.findById(couponId).orElseThrow()
        coupon.issue()
        couponRepository.save(coupon)
    }
}&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;시나리오전략avgmedp95&lt;/p&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;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;시나리오&lt;/td&gt;
&lt;td&gt;전략&lt;/td&gt;
&lt;td&gt;&lt;b&gt;avg&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;P50&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;P95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1. 무경쟁 (VUS=1)&lt;/td&gt;
&lt;td&gt;Lettuce&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1.49ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1.16ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;3.34ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1. 무경쟁 (VUS=1)&lt;/td&gt;
&lt;td&gt;Redisson&lt;/td&gt;
&lt;td&gt;1.82ms&lt;/td&gt;
&lt;td&gt;1.31ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;2.51ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1. 무경쟁 (VUS=1)&lt;/td&gt;
&lt;td&gt;적응형(신규)&lt;/td&gt;
&lt;td&gt;4.16ms&lt;/td&gt;
&lt;td&gt;2.68ms&lt;/td&gt;
&lt;td&gt;3.62ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. 중간경쟁 (VUS=5)&lt;/td&gt;
&lt;td&gt;Lettuce&lt;/td&gt;
&lt;td&gt;11.85ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1.27ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;103.67ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. 중간경쟁 (VUS=5)&lt;/td&gt;
&lt;td&gt;Redisson&lt;/td&gt;
&lt;td&gt;&lt;b&gt;4.32ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;3.88ms&lt;/td&gt;
&lt;td&gt;6.97ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;2. 중간경쟁 (VUS=5)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;적응형(신규)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;4.76ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;1.93ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;5.51ms&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. 고경쟁 (VUS=50)&lt;/td&gt;
&lt;td&gt;Lettuce&lt;/td&gt;
&lt;td&gt;600.23ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;4.32ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;2.61s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. 고경쟁 (VUS=50)&lt;/td&gt;
&lt;td&gt;Redisson&lt;/td&gt;
&lt;td&gt;&lt;b&gt;33.31ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;36.05ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;50.28ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. 고경쟁 (VUS=50)&lt;/td&gt;
&lt;td&gt;적응형(신규)&lt;/td&gt;
&lt;td&gt;94.37ms&lt;/td&gt;
&lt;td&gt;86.04ms&lt;/td&gt;
&lt;td&gt;211.17ms&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;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;&amp;nbsp;&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;캐시스탬피드를 피하기 위한 방법으로 Soft TTL을 구현하기 위해 분산락을 사용했었다.&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;p data-ke-size=&quot;size16&quot;&gt;&lt;a title=&quot;캐시스템피드를 피하기 위한 Soft TTL&quot; href=&quot;https://kjhoon0330.tistory.com/entry/Spring-Cache-Stampede-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-Two-Level-TTL-Cache-%EA%B5%AC%ED%98%84&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;캐시스템피드를&amp;nbsp;피하기&amp;nbsp;위한&amp;nbsp;Soft&amp;nbsp;TTL&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;
&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>JVM Lock</category>
      <category>lock</category>
      <category>분산락</category>
      <category>캐시</category>
      <author>Leoo</author>
      <guid isPermaLink="true">https://farmer-eom.tistory.com/2</guid>
      <comments>https://farmer-eom.tistory.com/2#entry2comment</comments>
      <pubDate>Sun, 12 Jul 2026 02:22:12 +0900</pubDate>
    </item>
    <item>
      <title>Virtual Thread 너 좋다던데?</title>
      <link>https://farmer-eom.tistory.com/1</link>
      <description>&lt;p data-ke-size=&quot;size18&quot;&gt;최근 보안 이슈가 많이 발생하다보니 회사에서는 외부의 공격으로 부터 페이로드 변조를 검증하는 기능을 추가하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;페이로드 검증 기능을 추가하고 보니 피크 타임 기준으로 504 에러가 급증하는게 확인됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;이 문제를 해결하기 위해서 Read Write 서비스 분리, WebFlux 적용, 페이로드 검증 서비스 분리 등 여러 가지 방법들을 적용하면서 문제를 해결했었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;지금 와서 다시 생각을 해본다면 Virtual Thread를 사용 해 볼 수 있지 않을까? 라는 생각이 든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;이 글의 포스팅 목적은 생각 난 김에 해보자. 그리고 정리해보자! 의 목적이 크다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Virtual Thread 그게 뭐야?&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;Virtual Thread는 JVM이 직접 스케줄링하는 경량 스레드로 기존의 &quot;요청당 스레드&quot; 동기 프로그래밍 모델을 유지하면서도 I/O 대기 중 OS Thread를 점유하지 않도록 설계된 실행 단위를 말한다고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;음.. 어렵지만 아주 좋아보이는구만? 좋은 말은 다 갖다 붙인거 같긴한데 확 다가오지 않는다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;하나씩 뜯어보기 전에 Virtual Thread와 기존 자바 Thread(Platform Thread)를 비교해보자.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;Platform Thread&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바의 전통적인 Thread를 Platform Thread라고 일컽는다. Platform Thread는 new Thread()로 생성하면 OS에게 '커널 스레드 하나 만들어줘' 요청 하는 구조이다. 따라서 Platform Thread 1개는 곧 OS Thread 1개와 맵핑된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;898&quot; data-origin-height=&quot;527&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tQFEG/dJMb99LZpxA/pcZ59c988dEBCSSAkvDgP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tQFEG/dJMb99LZpxA/pcZ59c988dEBCSSAkvDgP1/img.png&quot; data-alt=&quot;Platform Thread&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tQFEG/dJMb99LZpxA/pcZ59c988dEBCSSAkvDgP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtQFEG%2FdJMb99LZpxA%2FpcZ59c988dEBCSSAkvDgP1%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;898&quot; height=&quot;527&quot; data-origin-width=&quot;898&quot; data-origin-height=&quot;527&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Platform Thread&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;294&quot; data-start=&quot;230&quot; data-ke-size=&quot;size16&quot;&gt;전통적인 Spring MVC + Platform Thread 환경에서 요청 하나는 다음과 같은 순서로 처리된다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;589&quot; data-start=&quot;296&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;314&quot; data-start=&quot;296&quot;&gt;클라이언트 요청이 유입된다.&lt;/li&gt;
&lt;li data-end=&quot;353&quot; data-start=&quot;315&quot;&gt;요청마다 하나의 &lt;b&gt;Platform Thread&lt;/b&gt;가 할당된다.&lt;/li&gt;
&lt;li data-end=&quot;422&quot; data-start=&quot;354&quot;&gt;각 Platform Thread는 JVM 내부에서 &lt;b&gt;OS 커널 스레드(OS Thread)&lt;/b&gt; 와 1:1로 매핑된다.&lt;/li&gt;
&lt;li data-end=&quot;450&quot; data-start=&quot;423&quot;&gt;할당된 Thread가 요청 처리를 수행한다.&lt;/li&gt;
&lt;li data-end=&quot;489&quot; data-start=&quot;451&quot;&gt;처리 과정에서 &lt;b&gt;blocking I/O&lt;/b&gt;가 발생할 수 있다.&lt;/li&gt;
&lt;li data-end=&quot;541&quot; data-start=&quot;490&quot;&gt;I/O 대기 동안 해당 OS Thread는 &lt;b&gt;block 상태로 유지되며 점유된다&lt;/b&gt;.&lt;/li&gt;
&lt;li data-end=&quot;589&quot; data-start=&quot;542&quot;&gt;새로운 요청을 처리하기 위해서는 &lt;b&gt;추가적인 Thread 할당이 필요해진다&lt;/b&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;639&quot; data-start=&quot;591&quot; data-ke-size=&quot;size16&quot;&gt;이 구조에서는 Thread가 곧 &lt;b&gt;요청 처리 단위이자 자원 제한 장치&lt;/b&gt;로 동작한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-end=&quot;684&quot; data-start=&quot;646&quot; data-ke-size=&quot;size16&quot;&gt;최악의 케이스1 : CPU Bound + 과도한 Thread 수&lt;/p&gt;
&lt;p data-end=&quot;751&quot; data-start=&quot;686&quot; data-ke-size=&quot;size16&quot;&gt;요청 처리 로직이 CPU 연산 위주이거나, Thread 수가 CPU 처리 능력을 크게 초과하는 경우를 가정해보자.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1137&quot; data-start=&quot;753&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;777&quot; data-start=&quot;753&quot;&gt;1,000개의 요청이 동시에 유입된다.&lt;/li&gt;
&lt;li data-end=&quot;848&quot; data-start=&quot;778&quot;&gt;요청당 하나의 Platform Thread가 할당되어, &lt;b&gt;총 1,000개의 Platform Thread&lt;/b&gt;가 생성된다.&lt;/li&gt;
&lt;li data-end=&quot;903&quot; data-start=&quot;849&quot;&gt;각 Platform Thread는 &lt;b&gt;OS Thread 1,000개&lt;/b&gt;와 1:1로 매핑된다.&lt;/li&gt;
&lt;li data-end=&quot;961&quot; data-start=&quot;904&quot;&gt;CPU가 동시에 실행할 수 있는 Thread 수는 &lt;b&gt;Logical Core 개수&lt;/b&gt;에 불과하다.&lt;/li&gt;
&lt;li data-end=&quot;1024&quot; data-start=&quot;962&quot;&gt;초과된 OS Thread들은 커널 스케줄러에 의해 &lt;b&gt;time-slicing 방식으로 번갈아 실행&lt;/b&gt;된다.&lt;/li&gt;
&lt;li data-end=&quot;1064&quot; data-start=&quot;1025&quot;&gt;이 과정에서 &lt;b&gt;빈번한 Context Switch&lt;/b&gt;가 발생한다.&lt;/li&gt;
&lt;li data-end=&quot;1137&quot; data-start=&quot;1065&quot;&gt;결과적으로 CPU는 실제 비즈니스 로직보다&lt;b&gt;스레드 전환과 스케줄링 오버헤드에 더 많은 자원을 소모하게 된다&lt;/b&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style5&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;p data-end=&quot;1232&quot; data-start=&quot;1192&quot; data-ke-size=&quot;size16&quot;&gt;최악의 케이스2 : I/O Bound + Thread Pool 고갈&lt;/p&gt;
&lt;p data-end=&quot;1279&quot; data-start=&quot;1234&quot; data-ke-size=&quot;size16&quot;&gt;이번에는 요청 대부분이 &lt;b&gt;blocking I/O&lt;/b&gt;를 포함하는 경우를 살펴보자.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1724&quot; data-start=&quot;1281&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1305&quot; data-start=&quot;1281&quot;&gt;1,000개의 요청이 동시에 유입된다.&lt;/li&gt;
&lt;li data-end=&quot;1373&quot; data-start=&quot;1306&quot;&gt;요청당 하나의 Platform Thread가 할당되어 &lt;b&gt;1,000개의 Platform Thread&lt;/b&gt;가 생성된다.&lt;/li&gt;
&lt;li data-end=&quot;1428&quot; data-start=&quot;1374&quot;&gt;각 Platform Thread는 &lt;b&gt;OS Thread 1,000개&lt;/b&gt;와 1:1로 매핑된다.&lt;/li&gt;
&lt;li data-end=&quot;1486&quot; data-start=&quot;1429&quot;&gt;각 요청은 DB 호출이나 외부 API 호출과 같은 &lt;b&gt;blocking I/O 작업&lt;/b&gt;을 수행한다.&lt;/li&gt;
&lt;li data-end=&quot;1559&quot; data-start=&quot;1487&quot;&gt;I/O 대기 동안 해당 OS Thread는 &lt;b&gt;block 상태로 전환되지만, 커널 스레드는 계속 점유된 상태로 유지된다&lt;/b&gt;.&lt;/li&gt;
&lt;li data-end=&quot;1597&quot; data-start=&quot;1560&quot;&gt;I/O 응답이 반환되기 전까지 Thread는 반환되지 않는다.&lt;/li&gt;
&lt;li data-end=&quot;1653&quot; data-start=&quot;1598&quot;&gt;그 결과, &lt;b&gt;Thread Pool의 모든 스레드가 장시간 점유되어 포화 상태로 유지된다&lt;/b&gt;.&lt;/li&gt;
&lt;li data-end=&quot;1724&quot; data-start=&quot;1654&quot;&gt;이후 유입되는 요청은:
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1724&quot; data-start=&quot;1673&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1698&quot; data-start=&quot;1673&quot;&gt;Thread 할당을 기다리며 큐에 쌓이거나&lt;/li&gt;
&lt;li data-end=&quot;1724&quot; data-start=&quot;1702&quot;&gt;타임아웃 또는 요청 거절로 이어진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;1791&quot; data-start=&quot;1726&quot; data-ke-size=&quot;size16&quot;&gt;이 경우 Thread Pool은 더 이상 처리량을 늘려주지 못하고, &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;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;Virtual Thread&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;Virtual Thread는 Java 21에서 정식 도입된 새로운 스레드 모델이다. OS Thread 와 1:1이 아닌 N:M 구조로 JVM이 알아서 스케줄링 한다. 밭일에 비교하자면, Platform Thread는 정규직이라면, Virtual Thread는 일용직 일꾼 정도로 비교할 수 있을것 같다.&lt;/span&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;839&quot; data-origin-height=&quot;530&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l1saN/dJMcafeqapT/US89J1PdmFrbkcnhyXIcGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l1saN/dJMcafeqapT/US89J1PdmFrbkcnhyXIcGk/img.png&quot; data-alt=&quot;Virtual Thread&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l1saN/dJMcafeqapT/US89J1PdmFrbkcnhyXIcGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl1saN%2FdJMcafeqapT%2FUS89J1PdmFrbkcnhyXIcGk%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;839&quot; height=&quot;530&quot; data-origin-width=&quot;839&quot; data-origin-height=&quot;530&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Virtual Thread&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Virtual Thread 환경에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;요청 처리 방식이 Platform Thread와 근본적으로 달라진다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot; data-start=&quot;224&quot; data-end=&quot;723&quot;&gt;
&lt;li data-start=&quot;224&quot; data-end=&quot;242&quot;&gt;클라이언트 요청이 유입된다.&lt;/li&gt;
&lt;li data-start=&quot;243&quot; data-end=&quot;280&quot;&gt;요청마다 하나의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Virtual Thread&lt;/b&gt;가 생성된다.&lt;/li&gt;
&lt;li data-start=&quot;281&quot; data-end=&quot;348&quot;&gt;Virtual Thread는 실행을 위해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Carrier Thread(OS Thread)&lt;/b&gt;&amp;nbsp;에 mount 된다.&lt;/li&gt;
&lt;li data-start=&quot;349&quot; data-end=&quot;383&quot;&gt;Carrier Thread 위에서 요청 처리가 수행된다.&lt;/li&gt;
&lt;li data-start=&quot;384&quot; data-end=&quot;422&quot;&gt;처리 과정에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;blocking I/O&lt;/b&gt;가 발생할 수 있다.&lt;/li&gt;
&lt;li data-start=&quot;423&quot; data-end=&quot;461&quot;&gt;JVM(Project Loom)은 해당 I/O 대기를 감지한다.&lt;/li&gt;
&lt;li data-start=&quot;462&quot; data-end=&quot;528&quot;&gt;I/O 대기 동안&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Virtual Thread는 Carrier Thread에서 unmount 되어 분리된다&lt;/b&gt;.&lt;/li&gt;
&lt;li data-start=&quot;529&quot; data-end=&quot;564&quot;&gt;분리된 Virtual Thread는 대기 상태로 전환된다.&lt;/li&gt;
&lt;li data-start=&quot;565&quot; data-end=&quot;632&quot;&gt;비워진 Carrier Thread는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;즉시 다른 Virtual Thread를 mount 하여 실행을 계속한다&lt;/b&gt;.&lt;/li&gt;
&lt;li data-start=&quot;633&quot; data-end=&quot;723&quot;&gt;I/O가 완료되면, 대기 중이던 Virtual Thread는 다시 Carrier Thread에 mount 되어중단된 지점부터 실행을 재개한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Virtual Thread의 핵심 특징&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 살펴본 Virtual Thread의 동작 방식을 바탕으로 핵심 특징을 정리해보았다.&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;1. I/O 대기 중 OS Thread를 점유하지 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Platform Thread 환경에서는 I/O 대기 중에도 비싼 OS Thread가 점유된 채 아무 일도 하지 못했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread는 I/O 대기가 감지되면 Carrier Thread에서 unmount되어 분리되고, 해당 Carrier Thread는 즉시 다른 Virtual Thread를 실행한다.&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;2. 수십만 개의 동시 생성이 가능하다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Platform Thread는 생성 시 스택 메모리, 레지스터 상태, 컨텍스트 등의 정보를 갖기 때문에 수 천 개만 생성해도 메모리 압박이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Virtual Thread는 JVM 힙에 경량 객체로 존재하며, 필요할 때만 Carrier Thread에 mount되어 실행된다.&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;3. 기존 동기 프로그래밍 모델을 유지한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebFlux 같은 리액티브 프로그래밍은 코드 스타일 자체가 완전히 달라진다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나, Virtual Thread는 기&lt;b&gt;존 MVC 스타일의 동기 코드를 그대로 사용하면서도 내부적으로는 비동기처럼 효율적&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;b&gt;4. Thread Pool이 필요 없다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Platform Thread 시절에는 OS Thread 폭증을 방지하기 위해서 Thread Pool을 사용했다. 하지만 Virtual Thread는 생성/파괴 비용이 매우 저렴하기 때문에 풀링할 이유가 없다. 오히려 풀링하면 동시성을 &lt;b&gt;풀 크기로 제한하게 되어 Virtual Thread의 이점을 깎아먹는다.&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;&lt;b&gt;5. 하위 자원에 대한 보호는 여전히 필요하다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread가 Thread Pool의 필요성을 제거 했다고 해서 모든 자원에 대한 제한이 사라진 것은 아니다. DB 커넥션 풀, 외부 API Rate Limit 같은 하위 자원에 대한 보호는 여전히 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오히려 Virtual Thread 환경에서는 &lt;b&gt;병목 지점이 이동한다.&amp;nbsp;&lt;/b&gt;Platform Thread 시절에는 Thread Pool의 크기가 자연스럽게 동시 요청을 제한했다. 하지만, Virtual Thread는 입구가 거의 무한하기 때문에 DB 커넥션 풀 앞에서 대기가 폭증할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Virtual Thread&amp;nbsp; VS Platform Thread&amp;nbsp; 성능 테스트&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 공부한 내용으로만 본다면 Virtual Thread는 굉~장히 좋은 기술로만 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 진짜로 좋을까? 성능 차이가 얼마나 날까? 분명 좋은게 있으면 안좋은 부분도 있을텐데 뭐가 있을까? 확인해보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ChatGPT에게 Virtual Thread를 테스트하기 위한 코드 예시를 보여달라고 하면 아래와 같은 코드들을 작성을 많이 해준다.&lt;/p&gt;
&lt;pre id=&quot;code_1770301914970&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    public Map&amp;lt;String, Object&amp;gt; simulateLightIo() {
        long startTime = System.currentTimeMillis();

        try {
            Thread.sleep(100); // 100ms delay
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(&quot;Interrupted during I/O simulation&quot;, e);
        }

        long duration = System.currentTimeMillis() - startTime;

        return Map.of(
            &quot;operation&quot;, &quot;light-io&quot;,
            &quot;targetDelayMs&quot;, 100,
            &quot;actualDelayMs&quot;, duration,
            &quot;thread&quot;, getThreadInfo()
        );
    }&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;Thread.sleep()을 이용해 I/O 대기 상황을 만들면 Virtual Thread는 I/O 대기가 발생한 상황을 인지해서 umount/mount를 수행하므로 Platform Thread와 Virtual Thread를 성능 차이를 확인해볼 수 있다.&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;성능 테스트 및 모니터링 도구&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;k6&amp;nbsp;&lt;/li&gt;
&lt;li&gt;grafana, prometheus&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;테스트 시나리오&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;I/O-bound 작업 환경에서의&amp;nbsp; Virtual Thread와 Platform Thread 간 TPS 차이&lt;/li&gt;
&lt;li&gt;Virtual Thread와 Platform Thread 간 메모리 사용량 비교&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 data-ke-size=&quot;size16&quot;&gt;Thread 설정&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;spring.threads.virtual.enabled&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;server.tomcat.threads.max&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;- (무의미)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;server.tomcat.threads.min-spare&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;server.tomcat.accept-count&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Platform Thread 환경에서는 Tomcat Worker Thread 수를 제한하여 일반적인 Thread Pool 구조 사용&lt;/li&gt;
&lt;li&gt;Virtual Thread 환경에서는 요청마다 Virtual Thread가 생성되므로 Tomcat Thread 설정 무의미&lt;/li&gt;
&lt;li&gt;Accept-count는 동일하게 유지하여 요청 큐 조건은 동일&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;K6 부하 시나리오&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ramp-up 1&lt;/td&gt;
&lt;td&gt;30s&lt;/td&gt;
&lt;td&gt;0 &amp;rarr; 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ramp-up 2&lt;/td&gt;
&lt;td&gt;30s&lt;/td&gt;
&lt;td&gt;50 &amp;rarr; 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ramp-up 3&lt;/td&gt;
&lt;td&gt;30s&lt;/td&gt;
&lt;td&gt;100 &amp;rarr; 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Steady&lt;/td&gt;
&lt;td&gt;30s&lt;/td&gt;
&lt;td&gt;200 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ramp-down&lt;/td&gt;
&lt;td&gt;30s&lt;/td&gt;
&lt;td&gt;200 &amp;rarr; 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;I/O-bound 작업 환경&lt;/h3&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;940&quot; data-origin-height=&quot;657&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgetAH/dJMcaf6yVjd/4B00ngWKKGAzoiDBYMRsvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgetAH/dJMcaf6yVjd/4B00ngWKKGAzoiDBYMRsvK/img.png&quot; data-alt=&quot;Virtual Thread TPS &amp;amp;amp; Virtual Users&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgetAH/dJMcaf6yVjd/4B00ngWKKGAzoiDBYMRsvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcgetAH%2FdJMcaf6yVjd%2F4B00ngWKKGAzoiDBYMRsvK%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;565&quot; height=&quot;395&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;657&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Virtual Thread TPS &amp;amp; Virtual Users&lt;/figcaption&gt;
&lt;/figure&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;940&quot; data-origin-height=&quot;611&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IZzqn/dJMcacB0uA9/lkHYusj1MrIljVZYQk4On0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IZzqn/dJMcacB0uA9/lkHYusj1MrIljVZYQk4On0/img.png&quot; data-alt=&quot;Platform Thread &amp;amp;amp; Virtual Users&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IZzqn/dJMcacB0uA9/lkHYusj1MrIljVZYQk4On0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIZzqn%2FdJMcacB0uA9%2FlkHYusj1MrIljVZYQk4On0%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;576&quot; height=&quot;374&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;611&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Platform Thread &amp;amp; Virtual Users&lt;/figcaption&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;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread VS Platform Thread 결과 비교&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 72px;&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;height: 17px;&quot;&gt;지표&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;Virtual Thread&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;Platform Thread&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;TPS&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;~697 req/s&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;~394 req/s&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;1.77x&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;평균 응답시간&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;~105.9 ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;~228.8 ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;2.16x 개선&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;P95 응답시간&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;~112.8 ms&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;~364.8 ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;3.23x 개선&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;메모리 (RSS-JVM) 사용량 비교&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-02-17 오후 5.50.31.png&quot; data-origin-width=&quot;1502&quot; data-origin-height=&quot;592&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Nso2x/dJMcachMAF5/1iK5bABgTUW6oFCKLMqjG1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Nso2x/dJMcachMAF5/1iK5bABgTUW6oFCKLMqjG1/img.png&quot; data-alt=&quot;Plaform Thread VS Virtual Thread&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Nso2x/dJMcachMAF5/1iK5bABgTUW6oFCKLMqjG1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNso2x%2FdJMcachMAF5%2F1iK5bABgTUW6oFCKLMqjG1%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;1502&quot; height=&quot;592&quot; data-filename=&quot;스크린샷 2026-02-17 오후 5.50.31.png&quot; data-origin-width=&quot;1502&quot; data-origin-height=&quot;592&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Plaform Thread VS Virtual Thread&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Platform Thread를 사용한 테스트에서는 약 1.2GB의 OS Stack Overhead(RSS-JVM) 발생&lt;/li&gt;
&lt;li&gt;Virtual Thread를 사용한 테스트에서는 약830MB의 OS Stack Overhead(RSS-JVM) 발생&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread VS Platform Thread 결과 비교&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;342&quot; data-start=&quot;75&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;지표&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Virtual Thread&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Platform Thread&lt;/td&gt;
&lt;td&gt;결과 비교&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;177&quot; data-start=&quot;140&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;155&quot; data-start=&quot;140&quot;&gt;Live Threads&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;161&quot; data-start=&quot;155&quot;&gt;&lt;b&gt;29개&lt;/b&gt;&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;168&quot; data-start=&quot;161&quot;&gt;510개&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;177&quot; data-start=&quot;168&quot;&gt;-481개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;230&quot; data-start=&quot;178&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;188&quot; data-start=&quot;178&quot;&gt;RSS 증가량&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;197&quot; data-start=&quot;188&quot;&gt;&lt;b&gt;+554MB&lt;/b&gt;&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;206&quot; data-start=&quot;197&quot;&gt;+961MB&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;230&quot; data-start=&quot;206&quot;&gt;Platform이 407MB 더 많음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;306&quot; data-start=&quot;231&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;265&quot; data-start=&quot;231&quot;&gt;RSS-JVM (OS Stack Overhead) 증가량&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;274&quot; data-start=&quot;265&quot;&gt;&lt;b&gt;+209MB&lt;/b&gt;&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;283&quot; data-start=&quot;274&quot;&gt;+290MB&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;306&quot; data-start=&quot;283&quot;&gt;Platform이 81MB 더 많음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;342&quot; data-start=&quot;307&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;321&quot; data-start=&quot;307&quot;&gt;RSS-JVM 최종값&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;329&quot; data-start=&quot;321&quot;&gt;&lt;b&gt;540MB&lt;/b&gt;&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;337&quot; data-start=&quot;329&quot;&gt;605MB&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;342&quot; data-start=&quot;337&quot;&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;테스트 결과 정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IO-bound와 메모리 사용량 비교 결과를 정리하기전에(이미 결과는 나왔지만..) 예상 결과를 정리해보자.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;I/O-bound 환경 예상&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Thread는 blocking I/O 대기 중 Carrier Thread(OS Thread)를 점유하지 않고 unmount 되기 때문에 Platform Thread&amp;nbsp; 대비 더 많은 동시 요청을 처리할 수 있으며, 높은 처리량을 기대할 수 있다.&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OS Thread 수 제한 떄문에 발생하던 병목이 감소한다.&lt;/li&gt;
&lt;li&gt;동시 처리량 증가&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;메모리 사용률 예상&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Platform Thread는 OS native stack을 포함하기 떄문에 스레드 수 증가 시 메모리 사용량이 빠르게 증가한다.&lt;/li&gt;
&lt;li&gt;Virtual Thread는 JVM이 관리하는 경량 객체이며 stack frame이 heap에 저장되는 구조를 사용하기 때문에 상대적으로 적은 메모리를 사용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과연 실제 테스트 결과는 어떤가?&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;I/O-bound 환경 결과&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Platform Thread 대비 Virtual Thread 사용 시 &lt;b&gt;1.77배 TPS 개선&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Platform Thread 대비 Virtual Thread 사용 시 &lt;b&gt;P95 기준 3.23배 개선&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;예상 결과와 동일한 결과&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;메모리 사용률 결과&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Platform Thread 대비 Virtual Thread 사용 시 &lt;b&gt;약 80MB 더 적은 메모리 사용&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;예상 결과와 동일한 결과&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Virtual Thread의 한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 살펴 본 결과로만 보면 &quot;Virtual Thread는 무조건 쓰는게 정답&quot;처럼 보인다.&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;실제로 Java 공식 문서에서도 Virtual Thread 사용 시 주의해야 할 점들을 분명히 언급한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(참고: &lt;a href=&quot;https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Java Virtual Thread 공식 문서)&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1771341489006&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Core Libraries&quot; data-og-description=&quot;Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging high-throughput concurrent applications.&quot; data-og-host=&quot;docs.oracle.com&quot; data-og-source-url=&quot;https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD&quot; data-og-url=&quot;https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/clKuHe/dJMb8TB5Y9a/XVHeOt49Nh9YXMBOoag5F1/img.png?width=401&amp;amp;height=321&amp;amp;face=0_0_401_321&quot;&gt;&lt;a href=&quot;https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/clKuHe/dJMb8TB5Y9a/XVHeOt49Nh9YXMBOoag5F1/img.png?width=401&amp;amp;height=321&amp;amp;face=0_0_401_321');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Core Libraries&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging high-throughput concurrent applications.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.oracle.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;Represent Every Concurrent Task as a Virtual Thread; Never Pool Virtual Threads&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;이 내용은 사실 앞서 &lt;b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Thread Pool이 필요 없다.&amp;nbsp;&lt;/b&gt;라는 내용으로 이미 살펴본 내용과 동일하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;왜 Thread Pool이 필요없을까? &lt;span&gt;&amp;nbsp;Virtual Thread &lt;/span&gt;생성/파괴 비용이 매우 저렴하기 때문에 풀링할 이유가 없다. 오히려 풀링하면 동시성을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;풀 크기로 제한하게 되어 Virtual Thread의 이점을 깎아먹는다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Thread Pool(풀링)의 목적은 &quot;만들기 비싼 자원(Platform Thread)&quot;을 재사용해서 생성/파괴 비용을 줄이려는 목적이다.&lt;/li&gt;
&lt;li&gt;하지만, Virtual Thread는 생성/파괴가 매우 저렴하게 설계 됐기 때문에 풀링을 사용하면 오히려 이점이 사라진다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Use Semaphores for Limited Resources&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 살펴본 것처럼 Virtual Thread는 매우 많은 수를 생성할 수 있다.&lt;br /&gt;이는 기존 Platform Thread 환경에서 자연스럽게 존재하던 &amp;ldquo;Thread 수 제한&amp;rdquo;이 사라졌음을 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Thread Pool을 통해 간접적으로 이루어지던 하위 자원 보호가 더 이상 자동으로 보장되지 않는다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Platform Thread 환경&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Platform Thread에서는 Thread Pool 크기가 곧 동시 실행 가능한 작업 수를 제한하는 역할을 한다.&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;Thread Pool = 200&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이라면,&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동시에 최대 200개의 요청만 실행&lt;/li&gt;
&lt;li&gt;결과적으로 DB 호출,외부 API 호출도 약 200개 수준으로 자연스럽게 제한된다.&lt;/li&gt;
&lt;li&gt;Thread Pool 자체가 일종의 Backpressure 역할을 수행하게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Virtual Thread 환경&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread는 매우 가볍기 때문에 요청 수 만큼 Thread를 생성하는 것이 가능하다.&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;요청 50,000개 -&amp;gt; Virtual Thread 50,000개 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 별도의 제한이 없다면 :&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB Connection Pool = 50&lt;/li&gt;
&lt;li&gt;나머지 49,950개의 Thread는 커넥션을 기다리며 대기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과 :&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;불필요한 메모리 점유 증가&lt;/li&gt;
&lt;li&gt;대기 Queue 증가&lt;/li&gt;
&lt;li&gt;Timeout 증가&lt;/li&gt;
&lt;li&gt;외부 API의 경우 Rate Limit 초과&lt;/li&gt;
&lt;li&gt;Retry Storm / Circuit Breaker 발생 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Thread 수 제한이 사라진 대시 하위 자원 보호를 개발자가 직접 설계해야한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;해결방법 : Semaphore 기반 동시성 제한&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 DB, 외부 API와 같은 제한된 자원 앞단에는 명시적인 동시성 제어가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Semaphore를 사용하면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Permit N개만 자원 접근 허용&lt;/li&gt;
&lt;li&gt;나머지는 대기하거나 빠르게 실패&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 접근 Semaphore= 50&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시에 최대 50개의 요청만 DB 호출 수행&lt;/p&gt;
&lt;h3 id=&quot;JSCOR-GUID-68216B85-7B43-423E-91BA-11489B1ACA61&quot; style=&quot;background-color: #ffffff; color: #161513; text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Don't Cache Expensive Reusable Objects in Thread-Local Variables&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread에서도 Thread-Local 자체는 문제 없이 사용할 수 있따.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 트랜잭션 정보, 사용자 ID 등 실행 컨텍스트를 저장하는 용도로는 여전히 유효하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 T&lt;b&gt;hread-Local을 expensive object를 캐싱하는 용도로 사용하는 패턴&lt;/b&gt;은 Virtual Thread 환경에서 문제가 될 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Platform Thread 환경&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Thread Pool 기반 구조에서는 :&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Thread 수가 제한 됨&lt;/li&gt;
&lt;li&gt;Thread가 여러 작업에서 재사용됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Thread-Local에 expensive object를 저장하면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;객체 생성 횟수 감소&lt;/li&gt;
&lt;li&gt;메모리 절약&lt;/li&gt;
&lt;li&gt;성능 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Virtual Thread 환경&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread의 설계 철학은&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;작업(task) 마다 새롭게 생성&lt;/li&gt;
&lt;li&gt;task 간 재사용 비권장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Thread-Local 캐시 재사용 불가&lt;/li&gt;
&lt;li&gt;task 마다 객체 생성&lt;/li&gt;
&lt;li&gt;많은 Virtual Thread -&amp;gt; 객체 대량 생성&lt;/li&gt;
&lt;li&gt;메모리 사용량 증가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Thread-Local 캐싱이 의도와는 반대로 동작하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서에서는 Thread-Local 캐싱을 해야하는 경우 아래와 같이 대안으로 제안하고 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;immutable 객체 사용 ( 예 : DateTimeFormatter)&lt;/li&gt;
&lt;li&gt;static/shared instance&lt;/li&gt;
&lt;li&gt;ScopedValue( Java 21+ )&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;JSCOR-GUID-04C03FFC-066D-4857-85B9-E5A27A875AF9&quot; style=&quot;background-color: #ffffff; color: #161513; text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Avoid Lengthy and Frequent Pinning&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pinning이란?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread가 특정 OS Thread(Carrier Thread)에 고정되어 umount 되지 못하는 상태를 의미한다.&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;Virtual Thread의 기본 동작 방식은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Thread가 blocking 상태(I/O 대기 등)에 들어가면&lt;/li&gt;
&lt;li&gt;JVM Scheduler가 감지&lt;/li&gt;
&lt;li&gt;Virtual Thread를 OS Thread에서 umount&lt;/li&gt;
&lt;li&gt;OS Thread 다른 Virtual Thread 실행에 재사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;I/O 대기 중 OS Thread를 점유하지 않는 것&lt;/b&gt;이 Virtual Thread의 핵심 설계 철학이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pinning이 발생한다면?&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;Virtual Thread가 blocking 상태에 들어간다.&lt;/li&gt;
&lt;li&gt;OS Thread에서 umount 되지 못함&lt;/li&gt;
&lt;li&gt;OS Thread가 계속 해당 Virtual Thread에 묶이게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OS Thread 재사용 불가&lt;/li&gt;
&lt;li&gt;동시성 처리 능력 감소&lt;/li&gt;
&lt;li&gt;Virtual Thread가 Platform Thread 처럼 동작&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pinning은 언제 발생하는가?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java 공식 문서에서는 두 가지 경우를 명시하고 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;synchronized&amp;nbsp;블록/메서드 내부에서 blcoking 연산을 수행할 때&lt;/li&gt;
&lt;li&gt;Native Method 또는 Foreign Function을 실행할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 중요한점은 &lt;b&gt;synchronized 자체가 Pinning을 유발하는것은 아니다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 synchronized 내부에서 Pinning이 발생할는건가?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;synchronized는 바이트코드 레벨에서 monitorenter / montiorexit 으로 변환되며, Monitor Lock을 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Monitor Lock의 특징:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;락 소유권이 OS Thread 기준으로 lock 관리&lt;/li&gt;
&lt;li&gt;락을 가진 thread 상태가 유지되어야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, Virtual Thread가 Monitor Lock을 보유한 상태에서 blocking I/O 수행 시 JVM은 Monitor Lock의 일관성을 유지하기 위해 Virtual Thread를 Carrier Thread에서 분리하지 않는다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pinning 자체가 문제는 아니다.?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java 공식 문서 상에서도 다음과 같이 언급되고 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;performing a blocking operation while inside a synchronized block or method causes the JDK's virtual thread scheduler to block a precious OS thread, may adversely affect server throughput if it is both long-lived and frequent.&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;위 문장에서의 핵심은 &lt;b&gt;long-lived&lt;/b&gt;이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 91px;&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;height: 17px;&quot;&gt;상황&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;Pinning 발생&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;문제 여부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;synchronized 내부에서 짧은 인메모리 연산&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X - 금방 끝나므로 Carrier Thread가 바로 반환됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;synchronized 내부에서 드문 blocking I/O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X - 빈도가 낮아 전체 처리량에 영향 미미&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;synchronized 내부에서 잦고 긴 blocking I/O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O - Carrier Thread가 지속적으로 고갈됨&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;p data-ke-size=&quot;size16&quot;&gt;쉽게 생각해보면 짧은 Pinning이 간헐적으로 발생하는 정도는 나머지 Carrier Thread 들이 충분히 커버할 수 있다. 하지만 길고 잦은 Pinning이 반복되면 사용 가능한 Carrier Thread가 고갈되고, 다른 Virtual Thread 들이 스케줄링 되지 못하며, 결국 Platform Thread를 쓰는 것과 다를 바 없는 상황이 만들어진다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Pinning 해결 방법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pinning이 문제가 된다면 synchronized를 ReentrantLock으로 교체하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ReetrantLock은 synchronized와 달리 Monitor Lock이 아닌 AbstractQueuedSynchronizer(AQS)기반으로 구현되어 있으며, 내부적으로 LockSupport.park()/unpark()를 사용한다. 이 API는 Virtual Thread 스케줄러가 인식할 수 있기 때문에 umount/mount가 정상적으로 동작한다.&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;&lt;b&gt;Before - Pinning 발생 가능&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771585912989&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;synchronized(obj) {
    // 잦고 긴 blocking I/O
    frequentIO();
}&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;After - Pinning 해결&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771585921330&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    frequentIO();
} finally {
    lock.unlock();
}&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;a href=&quot;https://openjdk.org/jeps/491&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;JDK 24에서 개선&lt;/a&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;JDK 24(2025년 3월)에서는 JEP 491: Synchronize Virtual Threads without Pinning이 반영되었다. Monitor Lock의 구현 자체가 Virtual Thread를 인식하도록 변경되어, synchronized 내부에서 blocking 연산을 수행해도 더 이상 Pinning이 발생하지 않는다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;나에게 질의응답 하며 정리하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q1. Virtual Thread를 사용하면 OS Thread에 1:1 맵핑이 안된다?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Platform Thread는 OS Thread와 1:1 맵핑되어 사용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OS Thread는 메모리 스택, 컨텍스트 스위치, 스케줄링 등 매우 비싼 자원이다. 따라서, Virtual Thread가 나오기 전까지는 아래와 같이 대응했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Thread Pool을 만들어서 사용한다&amp;nbsp;&lt;/li&gt;
&lt;li&gt;OS Thread가 I/O 작업에 의해 Blocking 상태로 할당되어 있다. -&amp;gt; 논블로킹/이벤트루프 모델인 WebFlux 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그치만 WebFlux는 러닝 커브가 매우 높다. Virtual Thread 등장 이후에는 아래와 같이 선택지가 좀 더 늘어났다고 생각됐다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Thread Pool을 만들어서 사용한다.&lt;/li&gt;
&lt;li&gt;Virtual Thread를 사용한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;synchronized 안에서 blocking, 네이티브호출, 일부 드라이버/라이브러리 내부적으로 Pinning 유발&lt;/li&gt;
&lt;li&gt;따라서 사용시 주의가 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;I/O 때문에 OS Thread가 묶이는 문제를 피하기 위해 non-blocking/event-loop 모델(WebFlux)이 사용되기도 한다.&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;따라서, Platform Thread는 OS Thread를 1:1로 점유하니 blocking I/O가 많으면 확장성이 Thread Pool에 묶이게 된다. WebFlux를 사용하면 이 문제를 해결할 수 있지만 복잡도가 높다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread는 OS Thread와 1:1로 고정되지 않고 대기 시 umount 되기 때문에&amp;nbsp;&lt;b&gt;동기 코드 스타일을 유질하면서도 동시성을 확장할 수 있는 선택지로 추가되었다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q2. WebFlux VS Virtual Thread&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;WebFlux&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebFlux는 Netty 기반의 Non-blocking/ Event-loop 모델로 요청 처리 전체를 비동기/논 블로킹 파이프라인으로 만들 수 있도록 지원해준다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;목표 : 요청 처리 전체를&amp;nbsp;&lt;b&gt;비동기/논블로킹 파이프라인&lt;/b&gt;으로 만들기&lt;/li&gt;
&lt;li&gt;핵심 : 소수의 이벤트 루프 thread가 콜백/리액티브 체인을 돌리면서 I/O 완료 신호에 반응&lt;/li&gt;
&lt;li&gt;장점 : 스트리밍/backpressure&lt;/li&gt;
&lt;li&gt;전제 : 컨트롤러부터 DB/외부호출 까지 가능한 한 논블로킹 API로 진행되어야 효과가 크다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Virtual Thread&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebFlux는 요청 처리 전체를 비동기/논블로킹 파이프라인으로 만든다면 Virtual Thread는 프로그래밍 모델은 동기 방식을 그대로 사용하되 실행 방식에서만 Virtual Thread를 사용한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;목표 :&amp;nbsp;&lt;b&gt;blocking 코드를 그대로 쓰면서 비동기 처럼 동작하도록 한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;핵심 : 요청 당 Thread 모델을 유지 하되, 그 Thread가 OS Thread가 아니라 JVM이 스케줄링하는 Virtual Thread가 진행하도록 한다.&lt;/li&gt;
&lt;li&gt;장점 : 코드, 디버깅, 트랜잭션 등 기존 MVC 스타일을 그대로 사용할 수 있다.&lt;/li&gt;
&lt;li&gt;전제 : I/O에서 Block되면 JVM이 Carrier Thread를 다른 작업에게 mount/unmount 하기 때문에 효율적이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 WebFlux는 프로그래밍/실행 모델 자체가 이벤트 기반이고 Virtual Thread는 프로그래밍 모델을 동기 방식 그대로 사용 하되, 실행 방식만 Virtual Thread를 사용한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일반적인 REST API + DB/JDBC + 외부 HTTP 호출 중심이라면 MVC + Virtual Thread가 코드 단순성 대비 효과가 좋다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MVC + Virtual Thread를 사용할 때 DB 커넥션 풀이 그대로 병목이 될 가능성이 높다.&lt;/li&gt;
&lt;li&gt;Virtual Thread를 사용하면 더 적은 OS Thread를 이용해 더 많은 요청을 수용할 수 있다.&lt;/li&gt;
&lt;li&gt;다만, DB에서 처리가 병목된다면 마찬가지로 병목이 발생한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;따라서, Virtual Thread를 사용하면 기존 &lt;b&gt;Thread Pool 크기로 인해 요청 처리량이 먼저 제한되지 않고&lt;/b&gt; &lt;b&gt;DB 및 외부 시스템이 허용하는 처리량 한도 내&lt;/b&gt;에서 훨씬 많은 동시 요청을 수용할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;대규모 스트리밍(SSE/WebSocket/대용량 스트림 처리) + BackPressure가 중요한 경우 WebFlux가 강점이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MVC + Platform Thread : Thread Pool 한계 -&amp;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-filename=&quot;스크린샷 2026-02-21 오후 3.11.22.png&quot; data-origin-width=&quot;2666&quot; data-origin-height=&quot;1138&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2AEgR/dJMcagExGoU/AeM9uUKFVhQin3cIkjKFFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2AEgR/dJMcagExGoU/AeM9uUKFVhQin3cIkjKFFk/img.png&quot; data-alt=&quot;MVC + Platform Thread 입구컷&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2AEgR/dJMcagExGoU/AeM9uUKFVhQin3cIkjKFFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2AEgR%2FdJMcagExGoU%2FAeM9uUKFVhQin3cIkjKFFk%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;338&quot; data-filename=&quot;스크린샷 2026-02-21 오후 3.11.22.png&quot; data-origin-width=&quot;2666&quot; data-origin-height=&quot;1138&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;MVC + Platform Thread 입구컷&lt;/figcaption&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;MVC + Virtual Thread : 입구 제한 없음 -&amp;gt; DB앞에서 폭증&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2910&quot; data-origin-height=&quot;1126&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sisLq/dJMcachPdx0/HLSBYHDD8GbX9MP8Rqb8Y1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sisLq/dJMcachPdx0/HLSBYHDD8GbX9MP8Rqb8Y1/img.png&quot; data-alt=&quot;MVC + Virtual Thread DB 커넥션 폭발&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sisLq/dJMcachPdx0/HLSBYHDD8GbX9MP8Rqb8Y1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsisLq%2FdJMcachPdx0%2FHLSBYHDD8GbX9MP8Rqb8Y1%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;829&quot; height=&quot;321&quot; data-origin-width=&quot;2910&quot; data-origin-height=&quot;1126&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;MVC + Virtual Thread DB 커넥션 폭발&lt;/figcaption&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;WebFlux&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2796&quot; data-origin-height=&quot;1138&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d6h86M/dJMcaaxxSkO/W7UkKKaiYDhuWBXeXmuM60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d6h86M/dJMcaaxxSkO/W7UkKKaiYDhuWBXeXmuM60/img.png&quot; data-alt=&quot;WebFlux&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d6h86M/dJMcaaxxSkO/W7UkKKaiYDhuWBXeXmuM60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd6h86M%2FdJMcaaxxSkO%2FW7UkKKaiYDhuWBXeXmuM60%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;919&quot; height=&quot;374&quot; data-origin-width=&quot;2796&quot; data-origin-height=&quot;1138&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;WebFlux&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q3. How To Virtual Thread In Spring MVC&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring MVC 환경에서 HTTP 요청을 기본 Thread가 아닌 Virtual Thread를 사용하도록 설정이 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.2+ 에서 application.properties에 아래 한 줄만 추가하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1771654526074&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring.threads.virtual.enabled=true&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;위 옵션을 활성화하면 Tomcat(또는 Jetty)의 요청 처리 스레드풀이 Virtual Thread 기반 Executor로 교체된다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;동작 원리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서블릿 기반 서버(Tomcat/Jetty)는 기본적으로 요청 1개 = Thread 1개를 할당해서 처리한다. 위 설정을 키면 이 Thread Pool이 Executor.newVirtualThreadPerTaskExecutor()로 대체되어, 요청 마다 새로운 Virtual Thread가 생성된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;기존: 고정 크기 Platform Thread Pool (기본 200개) &lt;br /&gt;변경 후: 요청마다 Virtual Thread 생성 (사실상 무제한)&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q4. Virtual Thread를 OS Thread에 Mount/Unmount는 누가해주는걸까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JVM(Project Loom) 내부의 Virtual Thread Scheduler가 Virtual Thread를 Carrier(OS) Thread 위에 mount하고, I/O 대기 시 unmount 하는 작업을 자동으로 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread Scheduler의 실체는 사실 ForkJoinPool이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본 Carrier Thread 개수 = CPU 논리 코어 개수&lt;/li&gt;
&lt;li&gt;JVM 옵션으로 조정 가능&lt;/li&gt;
&lt;li&gt;Work Strealing 알고리즘으로 Carrier Thread 간 작업 부하를 균등하게 분산한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 Virtual Thread Scheduler 안에서 어떤 로직이 있길래 Mount / Unmount가 진행될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 가능하게 해주는 메커니즘의 핵심은 &lt;b&gt;Continuation&lt;/b&gt;이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;동작&lt;/td&gt;
&lt;td&gt;내부 로직&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mount&lt;/td&gt;
&lt;td&gt;Continuation의 저장된 스택 프레임을 Carrier Thread에 복원하고 실행을 재개한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unmount&lt;/td&gt;
&lt;td&gt;현재 실행 상태(스택 프레임, 지역 변수 등)를 Continuation에 저장하고 Carrier Thread에서 분리한다&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;blockquote data-ke-style=&quot;style2&quot;&gt;Virtual Thread = Runnable 작업 + Continuation(실행 상태 스냅샷)&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;즉, Continuation은 Virtual Thread의 실행 상태를 저장하고 복원하는 체크 포인트 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JVM 블로킹 여부 판단&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;블로킹 포인트&lt;/td&gt;
&lt;td&gt;예시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;네트워크 I/O&lt;/td&gt;
&lt;td&gt;Socket.read(), Socket.write(), HttpClient 호출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;파일 I/O&lt;/td&gt;
&lt;td&gt;FileInputStream.read(), FileChannel 작업&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JDBC 네트워크 대기&lt;/td&gt;
&lt;td&gt;DB 쿼리 실행 후 응답 대기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스레드 대기&lt;/td&gt;
&lt;td&gt;Thread.sleep(), LockSupport.park()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동기화 대기&lt;/td&gt;
&lt;td&gt;Object.wait(), ReentrantLock.lock() 대기&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;* 주의: synchronized 블록 내부에서 블로킹이 발생하면 Unmount가 불가능하다 (Pinning 현상)&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q5. Virtual Thread는 Thread Pool이 필요할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Thread는 기본 설계 철학에서 Thread Pool이 필요하지 않다. 오히려 Thread Pool은 Virtual Thread의 설계를 위반하는 행위이다. 하지만 DB/ 외부 시스템 보호를 위한 자원 풀의 동시성 제한은 여전히 필요하다.&lt;/p&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;Q.6 Proejct Loom이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Project Loom은 Java에서 Virtual Thread와 구조적 동시성(Structured Concurrency)을 도입한 OpenJDK 공식 프로젝트를 말한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Loom은 &quot;직조기&quot;라는 뜻으로, 여러 가닥의 실(수많은 Virtual Thread)을 소수의 날실(Carrier Thread) 위에 짜 올리는 것에 비유한 이름이다.&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;Loom이 도입한 핵심 내용은 아래와 같다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1) Virtual Thread&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JVM이 스케줄링하는 경량 스레드&lt;/li&gt;
&lt;li&gt;수십만 개 생성 가능&lt;/li&gt;
&lt;li&gt;I/O 대기 시 OS Thread를 점유하지 않음&lt;/li&gt;
&lt;li&gt;Java 21에서 정식 도입&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2) Continuation&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Thread의 실행 상태를 저장/복원하는 내부 메커니즘&lt;/li&gt;
&lt;li&gt;mount / unmount를 가능하게 하는 핵심 기술&lt;/li&gt;
&lt;li&gt;스택 프레임을 힙 메모리에 저장했다가 복원하는 방식&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3) Carrier Thread Scheduler&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Thread를 OS Thread(Carrier Thread)에 실어 실행하는 내부 스케줄러&lt;/li&gt;
&lt;li&gt;실체는 ForkJoinPool( Work Stealing방식)&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4) Structured Concurrency(Preview)&lt;/h4&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;StructuredTaskScope를 통해 부모-자식 태스크 관계 명확히 정의&lt;/li&gt;
&lt;li&gt;하나가 실패하면 나머지를 자동 취소하는 등에러 전파가 구조적으로 제어 됨&lt;/li&gt;
&lt;li&gt;Java 21 에서 Preview로 도입&lt;/li&gt;
&lt;/ul&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;
&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;
&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;
&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>java</category>
      <category>PlatformThread</category>
      <category>virtualthread</category>
      <author>Leoo</author>
      <guid isPermaLink="true">https://farmer-eom.tistory.com/1</guid>
      <comments>https://farmer-eom.tistory.com/1#entry1comment</comments>
      <pubDate>Sat, 21 Feb 2026 16:10:16 +0900</pubDate>
    </item>
  </channel>
</rss>