<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>차곡차곡</title>
    <link>https://wing1008.tistory.com/</link>
    <description>휘발을 막기 위한 기록</description>
    <language>ko</language>
    <pubDate>Sun, 12 Jul 2026 18:45:47 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>wing1008</managingEditor>
    <image>
      <title>차곡차곡</title>
      <url>https://tistory1.daumcdn.net/tistory/7244618/attach/ca5e6b15ba804c6cacc51e6691c64758</url>
      <link>https://wing1008.tistory.com</link>
    </image>
    <item>
      <title>Feature Flag 패턴이란?</title>
      <link>https://wing1008.tistory.com/86</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Feature Flag는 왜 필요할까?&lt;/h2&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&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2052&quot; data-origin-height=&quot;1060&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Kvzul/dJMcaayUtIN/u5My6qtUdrE3NBVbwq8pO0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Kvzul/dJMcaayUtIN/u5My6qtUdrE3NBVbwq8pO0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Kvzul/dJMcaayUtIN/u5My6qtUdrE3NBVbwq8pO0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKvzul%2FdJMcaayUtIN%2Fu5My6qtUdrE3NBVbwq8pO0%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;2052&quot; height=&quot;1060&quot; data-origin-width=&quot;2052&quot; data-origin-height=&quot;1060&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이런 문제를 해결하기 위해 사용하는 방법 중 하나가 Feature Flag다. Feature Flag는 배포된 코드의 동작을 런타임에 바꿀 수 있게 해주는 방법이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Feature Flag의 핵심은 &amp;ldquo;코드를 다시 배포하지 않고 동작을 바꿀 수 있다&amp;rdquo;는 점이다. Feature Flag를 사용하면&lt;/span&gt;&lt;span&gt;&amp;nbsp;기능 공개 시점을 배포 시점과 분리할 수 있고, 특정 사용자에게만 기능을 열 수도 있고, 문제가 생긴 기능을 빠르게 끌 수도 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Feature Flag 사용 사례&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;운영 중 시스템 동작을 바꾸는 스위치&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nfndd/dJMcahLxv1x/C94NCUdVZkaDTu3s4GtVGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nfndd/dJMcahLxv1x/C94NCUdVZkaDTu3s4GtVGk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nfndd/dJMcahLxv1x/C94NCUdVZkaDTu3s4GtVGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fnfndd%2FdJMcahLxv1x%2FC94NCUdVZkaDTu3s4GtVGk%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;1426&quot; height=&quot;1024&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;배포와 릴리즈의 분리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Feature Flag의 가장 큰 특징은 배포와 릴리즈를 분리할 수 있다는 점이다. &lt;/span&gt;&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;일반적인 상황에서는 이 둘이 거의 동시에 일어난다. 코드를 배포하면 기능도 함께 열린다. 하지만&amp;nbsp;&lt;/span&gt;&lt;span&gt;Feature Flag를 사용하면 코드는 먼저 배포하고, 기능 노출은 나중에 결정할 수 있다.&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;당근에서도 Feature Flag 패턴을 활용하여 배포와 릴리즈 시점을 분리하여 배포를 진행한다. (&lt;a href=&quot;https://medium.com/daangn/%EB%A7%A4%EC%9D%BC-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%8A%94-%ED%8C%80%EC%9D%B4-%EB%90%98%EB%8A%94-%EC%97%AC%EC%A0%95-2-feature-toggle-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-b52c4a1810cd&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;관련 링크&lt;/a&gt;)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;내가 작업한 코드가 mainline에 들어갔지만 아직 테스트 중이라 사용자에게 노출되면 안 되는 상황이다. 하지만 이 상태에서 다른 동료가 배포를 진행하면 mainline에 있는 내 코드도 함께 배포된다. 때문에 &lt;/span&gt;&lt;span&gt;테스트가 끝날 때까지 팀원이 배포를 기다리거나, 이미 merge한 코드를 revert해야 한다. 이러한 방식들은 자주 배포하는 팀에는 부담스러운 방식일 수 밖에 없다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그렇다면 코드는 mainline에 포함하고 배포하되, 사용자에게 노출되지 않도록 설정할 수는 없을까?&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;fun getHome(user: User): HomeResponse {
    if (featureFlag.isEnabled(&quot;home.new-layout&quot;, user)) {
    	return newHomeService.getHome(user) 
    } 
    return legacyHomeService.getHome(user) 
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;플래그가 꺼져 있으면 기존 홈 화면만 동작한다. 신규 홈 화면 코드는 운영 환경에 올라가 있지만 사용자에게는 보이지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이처럼 Feature Flag 방식을 활용하면 배포와 릴리즈를 분리할 수 있기 때문에 피쳐들을 배포 시점까지 오랫동안 브랜치에 쌓아두지 않게 해준다. 변경사항을 작게 나눠 mainline에 계속 통합할 수 있고, 실제 공개 여부는 나중에 결정할 수 있다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, Feature Flag의 가치는&amp;nbsp;&lt;/span&gt;&lt;span&gt;기능을 배포하지 않는 것이 아니라, 배포는 하되 아직 릴리즈하지 않는 상태를 만들 수 있다는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;장애 대응을 위한 스위치로 사용&lt;/h4&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;pre id=&quot;code_1781437421204&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun getHome(user: User): HomeResponse {
    val banners = bannerService.getBanners(user)
    val recommendations =
    if (featureFlag.isEnabled(&quot;home.recommendation.enabled&quot;)) {
    	recommendationService.getRecommendations(user)
    } else {
    	emptyList()
    }
    
    return HomeResponse(
        banners = banners,
        recommendations = recommendations,
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 플래그는 신규 기능 릴리즈용 플래그와 성격이 다르다. 이러한 성격을 띄는 Feature Flag를 킬 스위치 라고 부른다. 킬 스위치는 릴리즈용 플래그와 달리 장기적으로 유지하기도 한다. 장애 상황에서 특정 기능을 빠르게 끄기 위한 운영 장치이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 킬 스위치를 만들 때는 반드시 fallback이 있어야 한다. Feature Flag는 기능을 꺼주는 스위치일 뿐이다. 기능을 껐을 때 시스템이 안전하게 동작하도록 만드는 것은 별도의 설계 문제다. 스위치만 있고 우회 경로가 없다면, 그것은 킬 스위치가 아니라 또 다른 장애 포인트가 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;때문에 추천 영역을 끄면 화면이 어떻게 보여야 하는지, 외부 API 호출을 끄면 데이터는 어디에 남길지, 나중에 재처리할 수 있는지 같은 fallback 설계가 먼저 있어야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1781440315787&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun publishOrderCompleted(event: OrderCompletedEvent) { 
    if (!featureFlag.isEnabled(&quot;crm.order-completed-publish&quot;)) { 
        pendingCrmEventRepository.save(event) 
        return 
    } 
    crmClient.publish(event) 
}&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;span&gt;실험 설정을 코드 배포 없이 바꾸기&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;A/B 테스트를 하기 위해서는 보통 조건을 코드에 직접 매핑해야 한다. 때문에 실험 조건이 조금만 바뀌어도 코드 수정과 배포가 필요하다. 배포 주기가 2주라면, 작은 실험 하나를 수정하는 데도 리드타임이 길어질 수밖에 없다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1781437580949&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{ 
    &quot;control&quot;: { 
        &quot;title&quot;: &quot;추천 콘텐츠&quot;, 
        &quot;initialCount&quot;: 30 
    }, 
    &quot;treatment&quot;: { 
        &quot;title&quot;: &quot;회원님을 위한 추천&quot;, 
        &quot;initialCount&quot;: 10 
    } 
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;위와 같이 값이 코드에 하드코딩되어 있으면 실험을 조정할 때마다 배포가 필요하다. 반대로 Remote Configuration으로 관리하면 배포 없이 실험 설정을 바꿀 수 있다. &lt;/span&gt;&lt;span&gt;이때 Feature Flag는 &amp;ldquo;이 기능을 켤 것인가&amp;rdquo;를 결정하고, Remote Configuration은 &amp;ldquo;이 기능을 어떤 값으로 동작시킬 것인가&amp;rdquo;를 결정한다. &lt;/span&gt;&lt;span&gt;실제 서비스에서는 둘이 함께 쓰이는 경우가 많다. (&lt;a href=&quot;https://techblog.lycorp.co.jp/en/20231005a&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;관련 링크&lt;/a&gt;)&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;특정 대상에게만 기능 제공하기&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;모든 기능이 모든 사용자에게 동시에 열릴 필요는 없다. 내부 직원에게만 실험용으로 먼저 열어볼 수도 있고(CBT), 특정 조직이나 특정 앱 버전 이상에서만 기능을 제공할 수도 있다. 유료 요금제나 관리자 권한을 가진 사용자에게만 보여야 하는 기능도 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;이런 경우 Feature Flag는 단순한 boolean 값이 아니라, 현재 요청의 context를 기반으로 평가된다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1781448087910&quot; class=&quot;pgsql&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;val context = FeatureContext( 
    userId = user.id, 
    organizationId = user.organizationId, 
    appVersion = request.appVersion, 
    plan = user.plan, 
) 

if (featureFlag.isEnabled(&quot;dashboard.new-admin&quot;, context)) { 
    return newAdminDashboardService.getDashboard(user) 
} 
return legacyDashboardService.getDashboard(user)&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;여기서 중요한 것은 &amp;ldquo;누구에게 켤 것인가&amp;rdquo;를 코드에 명시적으로 박아두지 않는다는 점이다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;코드에는 플래그 평가 지점만 남기고, 실제 대상 조건은 외부 설정으로 관리한다. 그래야 운영 중에도 대상 조직을 추가하거나, 특정 앱 버전만 제외하거나, 내부 사용자에게만 기능을 열 수 있기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;안전하게 마이그레이션하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;기존 구조에서 새로운 구조로 점진적으로 마이그레이션할 때도 Feature Flag를 사용할 수 있다. &lt;/span&gt;&lt;span&gt;기존 로직을 한 번에 모두 제거하고 새 로직으로 바꾸는 것은 위험하다. 때문에 기존 로직과 신규 로직을 함께 두고, 플래그로 실행 경로를 제어하는 방식으로 코드를 작성할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1781440182732&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun transfer(command: TransferCommand): TransferResult { 
	if (featureFlag.isEnabled(&quot;transfer.use-new-usecase&quot;)) { 
    	return newTransferUseCase.execute(command) 
    } 
    return legacyTransferService.transfer(command) 
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이처럼 기존 시스템을 한 번에 갈아엎는 대신, 기능 단위로 조금씩 안전하게 새 구조로 옮겨가도록 제어할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Feature Flag 관리&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;플래그 서빙 경로는 가벼워야 한다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Feature Flag는 사용자 요청 흐름 안에서 사용된다. &lt;/span&gt;&lt;span&gt;그래서 플래그 값을 확인할 때마다 DB를 조회하거나, 복잡한 어드민 서버를 호출하게 된다면 Feature Flag 자체가 병목이 될 수 있고, 플래그 시스템 장애가 서비스 장애로 전파될 수 있다. &lt;/span&gt;&lt;span&gt;때문에 요청 처리 경로에서는 가능한 한 가볍게 사용해야 한다. 로컬 캐시나 인메모리 설정을 사용하고, 설정 변경은 이벤트나 주기적 동기화로 반영하는 방식이 일반적이다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉, &lt;/span&gt;&lt;span&gt;Feature Flag는 런타임 제어 장치이기 때문에 실시간성이 중요하며 동시에 사용자 요청 흐름을 느리게 만들면 안 된다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;플래그는 운영 데이터다&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Feature Flag는 특히 관리가 소홀해지기 쉽다. &lt;/span&gt;&lt;span&gt;따라서 플래그에는 최소한 아래 정보가 필요하며 공유되어야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;이 플래그는 어떤 목적으로 만들어졌는가?
누가 소유자인가?
기본값은 무엇인가?
어떤 환경에서 사용할 수 있는가?
누가 변경할 수 있는가?
언제 제거해야 하는가?
변경 이력은 남고 있는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;특히 변경 이력은 중요하다. &lt;/span&gt;&lt;span&gt;장애가 발생하면 보통 최근 배포부터 확인하여 변경 사항을 파악하지만, Feature Flag를 사용하는 시스템에서는 최근 플래그 변경도 함께 확인해야 한다. &lt;/span&gt;&lt;span&gt;배포는 없었는데 서비스 동작이 바뀌었다면, 원인은 플래그 변경일 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;플래그는 반드시 정리해야 한다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Feature Flag는 편리하지만 코드에 분기를 남긴다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;if (featureFlag.isEnabled(&quot;some-feature&quot;)) {
    newPath()
} else {
    oldPath()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;플래그가 하나일 때는 괜찮다. 하지만 시간이 지나면서 이런 분기가 늘어나면 코드 경로가 복잡해지며 이미 사용하지 않는 레거시 경로들이 계속 남아 있을 수도 있다. &lt;/span&gt;&lt;span&gt;때문에 사용하지 않는 Feature Toggle은 바로 제거해야 하며&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;장기 운영 플래그라면 왜 계속 유지해야 하는지 설명이 있어야 한다. &lt;/span&gt;&lt;span&gt;Feature Flag를 잘 쓴다는 것은 플래그를 많이 만드는 팀이 아니라, 필요 없어진 플래그를 잘 지우고 관리하는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;정리&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Feature Flag는 배포된 코드의 동작을 런타임에 제어하기 위한 장치다. 이를 통해 배포와 기능 공개를 분리할 수 있으며, 문제가 발생한 기능을 즉시 비활성화하는 등 다양한 작업을 보다 안전하고 유연하게 수행할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Feature Flag를 사용하는 것만큼이나 이를 체계적으로 관리하는 것도 중요하다. 무분별하게 추가하면 어떤 플래그가 어떤 기능을 제어하는지 파악하기 어려워지고, 운영 중에도 배포 없이 서비스의 동작이 변경될 수 있어 장애 원인 분석과 문제 추적이 복잡해질 수 있다. 또한 더 이상 사용하지 않는 Feature Flag를 방치하면 코드의 복잡도가 높아지고 유지보수 비용도 함께 증가한다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;실습 코드&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/wldks1008/feature-flag-pattern&quot;&gt;https://github.com/wldks1008/feature-flag-pattern&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1781440812617&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - wldks1008/feature-flag-pattern: feature flag 패턴 실습&quot; data-og-description=&quot;feature flag 패턴 실습. Contribute to wldks1008/feature-flag-pattern development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/wldks1008/feature-flag-pattern&quot; data-og-url=&quot;https://github.com/wldks1008/feature-flag-pattern&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/tJFUK/dJMb8RR0Aok/gqrvi03ADUMP815DDGYpbK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/AQ8cq/dJMb8ZvJ6dK/wep3jKgC7NCSVMbP7yyxB1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/wldks1008/feature-flag-pattern&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/wldks1008/feature-flag-pattern&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/tJFUK/dJMb8RR0Aok/gqrvi03ADUMP815DDGYpbK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/AQ8cq/dJMb8ZvJ6dK/wep3jKgC7NCSVMbP7yyxB1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&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;GitHub - wldks1008/feature-flag-pattern: feature flag 패턴 실습&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;feature flag 패턴 실습. Contribute to wldks1008/feature-flag-pattern development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;참고자료&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.getunleash.io/get-started/what-is-a-feature-flag&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.getunleash.io/get-started/what-is-a-feature-flag&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1781444689500&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;What is a Feature Flag? Complete Guide to Feature Flags&quot; data-og-description=&quot;Feature flags let you release, test, and manage features without redeploying. Here's what they are, why teams use them, and how they work.&quot; data-og-host=&quot;docs.getunleash.io&quot; data-og-source-url=&quot;https://docs.getunleash.io/get-started/what-is-a-feature-flag&quot; data-og-url=&quot;https://docs.getunleash.io/get-started/what-is-a-feature-flag&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cIJIsz/dJMb8WeIspN/Ctt5yZYNYsHEmRwmwi3571/img.png?width=2052&amp;amp;height=1060&amp;amp;face=0_0_2052_1060,https://scrap.kakaocdn.net/dn/mbmyV/dJMb9frNZKp/OKFRBz7vhtVUxyrYnMHMHk/img.png?width=1428&amp;amp;height=1056&amp;amp;face=0_0_1428_1056,https://scrap.kakaocdn.net/dn/bnnbTW/dJMb9gxt2TU/ZPIi2rknVd4LkwoXb1wBk1/img.png?width=1426&amp;amp;height=1024&amp;amp;face=0_0_1426_1024&quot;&gt;&lt;a href=&quot;https://docs.getunleash.io/get-started/what-is-a-feature-flag&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.getunleash.io/get-started/what-is-a-feature-flag&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cIJIsz/dJMb8WeIspN/Ctt5yZYNYsHEmRwmwi3571/img.png?width=2052&amp;amp;height=1060&amp;amp;face=0_0_2052_1060,https://scrap.kakaocdn.net/dn/mbmyV/dJMb9frNZKp/OKFRBz7vhtVUxyrYnMHMHk/img.png?width=1428&amp;amp;height=1056&amp;amp;face=0_0_1428_1056,https://scrap.kakaocdn.net/dn/bnnbTW/dJMb9gxt2TU/ZPIi2rknVd4LkwoXb1wBk1/img.png?width=1426&amp;amp;height=1024&amp;amp;face=0_0_1426_1024');&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;What is a Feature Flag? Complete Guide to Feature Flags&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Feature flags let you release, test, and manage features without redeploying. Here's what they are, why teams use them, and how they work.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.getunleash.io&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/feature-flag/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://tech.kakaopay.com/post/feature-flag/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1781440588314&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;피처 플래그 개발기: 실시간 데이터 동기화를 향한 여정 | 카카오페이 기술 블로그&quot; data-og-description=&quot;원활한 배포 및 장애 대응을 위한 피처 플래그를 개발하며 실시간 데이터 동기화를 위해 직면한 문제를 해결하기 위한 여정을 공유합니다.&quot; data-og-host=&quot;tech.kakaopay.com&quot; data-og-source-url=&quot;https://tech.kakaopay.com/post/feature-flag/&quot; data-og-url=&quot;https://tech.kakaopay.com/post/feature-flag/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/BpEtb/dJMb8Yp34OT/nkCxDKb2ka29zEZ0jaycV0/img.jpg?width=480&amp;amp;height=319&amp;amp;face=0_0_480_319,https://scrap.kakaocdn.net/dn/pwbtp/dJMb8ZvJ6cZ/NAndQ3nRRrkzteGH6XQRfK/img.jpg?width=480&amp;amp;height=319&amp;amp;face=0_0_480_319&quot;&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/feature-flag/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://tech.kakaopay.com/post/feature-flag/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/BpEtb/dJMb8Yp34OT/nkCxDKb2ka29zEZ0jaycV0/img.jpg?width=480&amp;amp;height=319&amp;amp;face=0_0_480_319,https://scrap.kakaocdn.net/dn/pwbtp/dJMb8ZvJ6cZ/NAndQ3nRRrkzteGH6XQRfK/img.jpg?width=480&amp;amp;height=319&amp;amp;face=0_0_480_319');&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;피처 플래그 개발기: 실시간 데이터 동기화를 향한 여정 | 카카오페이 기술 블로그&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;원활한 배포 및 장애 대응을 위한 피처 플래그를 개발하며 실시간 데이터 동기화를 위해 직면한 문제를 해결하기 위한 여정을 공유합니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;tech.kakaopay.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://medium.com/daangn/%EB%A7%A4%EC%9D%BC-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%8A%94-%ED%8C%80%EC%9D%B4-%EB%90%98%EB%8A%94-%EC%97%AC%EC%A0%95-2-feature-toggle-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-b52c4a1810cd&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/daangn/%EB%A7%A4%EC%9D%BC-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%8A%94-%ED%8C%80%EC%9D%B4-%EB%90%98%EB%8A%94-%EC%97%AC%EC%A0%95-2-feature-toggle-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-b52c4a1810cd&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1781440610126&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;매일 배포하는 팀이 되는 여정(2) &amp;mdash; Feature Toggle 활용하기&quot; data-og-description=&quot;효율적이고 안정적인 배포를 위해 고민했던 것 중 하나인 Feature Toggle(Feature Flag)에 대한 이야기&quot; data-og-host=&quot;medium.com&quot; data-og-source-url=&quot;https://medium.com/daangn/%EB%A7%A4%EC%9D%BC-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%8A%94-%ED%8C%80%EC%9D%B4-%EB%90%98%EB%8A%94-%EC%97%AC%EC%A0%95-2-feature-toggle-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-b52c4a1810cd&quot; data-og-url=&quot;https://medium.com/daangn/%EB%A7%A4%EC%9D%BC-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%8A%94-%ED%8C%80%EC%9D%B4-%EB%90%98%EB%8A%94-%EC%97%AC%EC%A0%95-2-feature-toggle-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-b52c4a1810cd&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dSm1x3/dJMb9cBQA75/ktK5BDDUuCh83bELpSGK1K/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://medium.com/daangn/%EB%A7%A4%EC%9D%BC-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%8A%94-%ED%8C%80%EC%9D%B4-%EB%90%98%EB%8A%94-%EC%97%AC%EC%A0%95-2-feature-toggle-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-b52c4a1810cd&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://medium.com/daangn/%EB%A7%A4%EC%9D%BC-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%8A%94-%ED%8C%80%EC%9D%B4-%EB%90%98%EB%8A%94-%EC%97%AC%EC%A0%95-2-feature-toggle-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-b52c4a1810cd&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dSm1x3/dJMb9cBQA75/ktK5BDDUuCh83bELpSGK1K/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&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;매일 배포하는 팀이 되는 여정(2) &amp;mdash; Feature Toggle 활용하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;효율적이고 안정적인 배포를 위해 고민했던 것 중 하나인 Feature Toggle(Feature Flag)에 대한 이야기&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;medium.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://techblog.lycorp.co.jp/en/20231005a&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://techblog.lycorp.co.jp/en/20231005a&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1781440642988&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;How We Rearchitected A/B Testing at LINE NEWS&quot; data-og-description=&quot;A/B tests play a crucial role in decision-making at LINE NEWS as they help determine what modificati...&quot; data-og-host=&quot;techblog.lycorp.co.jp&quot; data-og-source-url=&quot;https://techblog.lycorp.co.jp/en/20231005a&quot; data-og-url=&quot;https://techblog.lycorp.co.jp/en/20231005a&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/btt3gz/dJMb9g5jCsd/e0sCkr0hXbvvEjw1wU5KLk/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/eOVLv/dJMb9c9GruL/NwKXRnRtYWoeF1dfKVJ4Qk/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/hz9lt/dJMb9b30CP5/eX1XNaS704R9wyLFb8bsLK/img.jpg?width=2000&amp;amp;height=2000&amp;amp;face=0_0_2000_2000&quot;&gt;&lt;a href=&quot;https://techblog.lycorp.co.jp/en/20231005a&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://techblog.lycorp.co.jp/en/20231005a&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/btt3gz/dJMb9g5jCsd/e0sCkr0hXbvvEjw1wU5KLk/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/eOVLv/dJMb9c9GruL/NwKXRnRtYWoeF1dfKVJ4Qk/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/hz9lt/dJMb9b30CP5/eX1XNaS704R9wyLFb8bsLK/img.jpg?width=2000&amp;amp;height=2000&amp;amp;face=0_0_2000_2000');&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 We Rearchitected A/B Testing at LINE NEWS&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;A/B tests play a crucial role in decision-making at LINE NEWS as they help determine what modificati...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;techblog.lycorp.co.jp&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://techblog.lycorp.co.jp/en/deployment-strategies-by-feature-toggle&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://techblog.lycorp.co.jp/en/deployment-strategies-by-feature-toggle&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1781440663980&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;Easier, Flexible, and Lower Resource Cost Deployment Strategies by Feature Toggle&quot; data-og-description=&quot;It's crucial to have various deployment strategies to ensure that new versions of your software is d...&quot; data-og-host=&quot;techblog.lycorp.co.jp&quot; data-og-source-url=&quot;https://techblog.lycorp.co.jp/en/deployment-strategies-by-feature-toggle&quot; data-og-url=&quot;https://techblog.lycorp.co.jp/en/deployment-strategies-by-feature-toggle&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/j9HKe/dJMb9g5jCso/DOtcXzfRlgf0Cl6sRoCJkk/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/clGs0T/dJMb8T98lO1/6W1aTZl9fdLc1ijPmdPyBk/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/cMttV3/dJMb9b30CQc/4mTaUSyzIUQeGkLPoPatnk/img.png?width=1035&amp;amp;height=591&amp;amp;face=0_0_1035_591&quot;&gt;&lt;a href=&quot;https://techblog.lycorp.co.jp/en/deployment-strategies-by-feature-toggle&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://techblog.lycorp.co.jp/en/deployment-strategies-by-feature-toggle&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/j9HKe/dJMb9g5jCso/DOtcXzfRlgf0Cl6sRoCJkk/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/clGs0T/dJMb8T98lO1/6W1aTZl9fdLc1ijPmdPyBk/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/cMttV3/dJMb9b30CQc/4mTaUSyzIUQeGkLPoPatnk/img.png?width=1035&amp;amp;height=591&amp;amp;face=0_0_1035_591');&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;Easier, Flexible, and Lower Resource Cost Deployment Strategies by Feature Toggle&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;It's crucial to have various deployment strategies to ensure that new versions of your software is d...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;techblog.lycorp.co.jp&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>Etc</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/86</guid>
      <comments>https://wing1008.tistory.com/86#entry86comment</comments>
      <pubDate>Sat, 13 Jun 2026 23:22:51 +0900</pubDate>
    </item>
    <item>
      <title>mTLS에 대해 알아보자</title>
      <link>https://wing1008.tistory.com/85</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;mTLS란?&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span data-subtree=&quot;aimfl,mfl&quot; data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot;&gt;TLS 기반인 HTTPS를 떠올리면, 클라이언트가 서버를 신뢰할 수 있는지 검증하는 구조를 떠올린다. 브라우저가 특정 사이트에 접속할 때 서버는 인증서를 제시하고, 브라우저는 해당 인증서가 신뢰할 수 있는 인증기관, 즉 CA에 의해 발급되었는지 확인한다. 이 과정을 통해 사용자는 내가 접속한 서버가 진짜 브라우저가 맞는가를 검증할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span data-subtree=&quot;aimfl,mfl&quot; data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot;&gt;하지만 서버 입장에서는 반대 방향의 검증이 필요할 때가 있다. 즉, &quot;이 요청을 보낸 클라이언트가 정말 허용된 시스템인가&quot;를 네트워크 연결 단계에서 확인하고 싶은 경우이다. 이때 사용하는 방식이 mTLS이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;mTLS(상호 TLS)는Mutual TLS의 약자로, 말 그대로 상호 TLS 인증을 의미한다. 일반 TSL가 주로 클라이언트가 서버를 검증하는 구조라면, mTLS는&amp;nbsp; 클라이언트와 서버가 서로의 인증서를 검증한다. 이 경우, 서버 역시 클라이언트 인증서를 검증함으로써 &quot;이 요청이 허용된 시스템에서 온 요청인가&quot;를 판단할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;기본 용어&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;인증서와 개인키&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;인증서는 어떤 주체의 공개키와 그 주체의 정보를 담고 있는 문서다. 인증서에는 보통 주체 이름, 발급자, 공개키, 서명 알고리즘 등이 포함된다.&amp;nbsp;&lt;/span&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;인증서는 공개되어도 된다. 중요한 것은 인증서에 대응되는 개인키이다. 개인키는 인증서의 공개키와 쌍을 이루는 비밀 키이다. 서버가 자신의 인증서를 제시할 때, 실제로 중요한 것은 자신이 이 인증서의 주인인 것을 증명하는 것이다. 그리고 이 증명은 개인키로 서명하는 과정을 통해 이루어진다. 때문에 개인키는 절대로 외부로 유출되어서는 안된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;TLS handshake에서는 CertificateVerify 메시지는 인증서에 대응하는 개인키를 실제로 소유하고 있음을 증명하는 역할을 한다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;CA&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;CA는 Certifiacte Authority의 약자이며 인증서를 발급하고 서명하는 기관이다. 클라이언트나 서버는 상대방의 인증서를 직접 믿는 것이 아니라 해당 인증서를 발급한 CA를 신뢰한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;예를 들어 서버 인증서가 Internal Root CA 또는 Internal Intermediate CA에 의해 발급되었다면, 클라이언트의 truststore에는 해당 CA 인증서가 들어 있어야 한다. 그래야 클라이언트가 서버 인증서의 신뢰 체인을 검증할 수 있다.&lt;/span&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;mTLS에서는 조직이 직접 CA 역할을 하는 경우가 많다.&lt;br /&gt;&lt;/b&gt;mTLS는 보통 불특정 다수를 위한 구조가 아니다. 때문에 &quot;아무나 접속 가능한 HTTPS&quot;가 아니라 &quot;사전에 허용된 시스템만 접속 가능한 HTTPS&quot;가 필요하다. 그래서 조직이 직접 사설 CA를 만들고, 그 CA로 클라이언트 인증서와 서버 인증서를 발급한다.&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;keystore와 truststore&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;&lt;b&gt;Keystore&lt;/b&gt;은 &quot;내가 제시할 인증서와 개인키&quot;를 보관하는 저장소이다. 서버 입장에서 keystore에는 서버 인증서와 서버 개인키가 들어간다. 클라이언트 입장에서 Keystore에는 클라이언트 인증서와 클라이언트 개인키가 들어간다. 즉, keystore는 나의 신분증과 신분증을 증명할 개인키를 보관하는 곳이다.&lt;/span&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&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 data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;&lt;b&gt;Truststore&lt;/b&gt;은 &quot;내가 신뢰할 CA 인증서&quot;를 보관하는 저장소이다. 클라이언트 입장에서 truststore에는 서버 인증서를 검증할 수 있는 CA 인증서가 들어간다. 서버 입장에서 truststore에는 클라이언트 인증서를&amp;nbsp; 검증할 수 있는 CA 인증서가 들어간다. 즉, truststore는 상대방의 신분증을 검증하기 위해 내가 신뢰하는 발급기관 목록이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 서버 인증서를 검증하려면, 클라이언트 truststore에 서버 인증서를 발급한 CA 체인이 있어야 한다. 반대로 서버가 클라이언트 인증서를 검증하려면 서버 truststore에 클라이언트 인증서를 발급한 CA 체인이 있어야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;TLS와 mTLS의 차이&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;TLS의 동작&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;일반적인 TLS는 보통 다음과 같은 방식으로 동작한다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1332&quot; data-origin-height=&quot;472&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c1Uamr/dJMcabLdFnw/4G5XpZzICBkyKnPu7Gjy7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c1Uamr/dJMcabLdFnw/4G5XpZzICBkyKnPu7Gjy7K/img.png&quot; data-alt=&quot;출처: CLOUDFLARE&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c1Uamr/dJMcabLdFnw/4G5XpZzICBkyKnPu7Gjy7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc1Uamr%2FdJMcabLdFnw%2F4G5XpZzICBkyKnPu7Gjy7K%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;1332&quot; height=&quot;472&quot; data-origin-width=&quot;1332&quot; data-origin-height=&quot;472&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: CLOUDFLARE&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;클라이언트가 서버에 접속하면 서버는 자신의 인증서를 클라이언트에게 전달한다. 클라이언트는 이 인증서가 신뢰할 수 있는 CA에 의해 발급되었는지 확인한다. 인증서의 도메인, 유효기간, 서명 체인 등이 정상이라면 클라이언트는 서버를 신뢰하고 암호화된 통신을 시작한다.&amp;nbsp;&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 style=&quot;color: #666666;&quot; data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;(TLS의 동작 방식에 대한 자세한 글은 &lt;a href=&quot;https://wing1008.tistory.com/50&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;해당 글&lt;/a&gt;에 포스팅되어 있으니, 궁금한 점이 있다면 참고하길 바란다.)&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;mTLS의 동작&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 400; margin: 0px; text-decoration: none; border-bottom: 0px rgb(10, 10, 10);&quot; data-subtree=&quot;aimfl,mfl&quot;&gt;mTLS는 서버도 클라이언트에게 인증서를 요구한다. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1232&quot; data-origin-height=&quot;541&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsmQ10/dJMcagFJ6W3/OiBf69plQ4R92yIWkKQ381/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsmQ10/dJMcagFJ6W3/OiBf69plQ4R92yIWkKQ381/img.png&quot; data-alt=&quot;출처: CLOUDFLARE&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsmQ10/dJMcagFJ6W3/OiBf69plQ4R92yIWkKQ381/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsmQ10%2FdJMcagFJ6W3%2FOiBf69plQ4R92yIWkKQ381%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;1232&quot; height=&quot;541&quot; data-origin-width=&quot;1232&quot; data-origin-height=&quot;541&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: CLOUDFLARE&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 자신의 인증서를 서버에게 전달할 때, 인증서만 보내는 것이 아니라 자신이 해당 인증서의 개인키를 실제로 가지고 있음을 증명해야 한다. 이를 위해 handshake transcript에 대해 개인키로 서명한 CertificateVerify 메세지를 함께 보낸다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 클라이언트 인증서가 신뢰할 수 있는 CA에서 발급되었는지, 유효기간이 지나지 않았는지, 사용 목적이 client authentication에 적합한지, 폐기된 인증서는 아닌지 등을 검증한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 서버와 클라이언트가 서로의 인증서를 정상적으로 검증하면 이후 통신은 협상된 세션 키를 통해 암호화된다. 이 시점 이후 HTTP 요청과 응답이 오간다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시스템적으로 mTLS를 구축하는 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;mTSL 적용 구간 정하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템적으로 mTLS를 구축하는 방법은 여러가지가 있겠지만, 이번 글에서는 크게 2가지 방법에 대해서 다뤄보고자 한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 외부 클라이언트와 API Gateway 사이에 mTSL를 적용하는 방식&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 방식에서는 gateway가 클라이언트 인증서를 검증하고, 내부 애플리케이션으로는 검증된 요청만 전달한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 NGINX에서는 `ssl_verify_client on` 으로 클라이언트 인증서 검증을 활성화할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780148334847&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;server {
    listen 443 ssl;
    server_name api.example.com;

    # 서버 인증서
    ssl_certificate     /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/server.key;

    # 클라이언트 인증서를 검증할 CA
    ssl_client_certificate /etc/nginx/certs/client-ca.crt;
    ssl_verify_client on;
    ssl_verify_depth 2;

    location / {
        proxy_set_header X-Client-Verify $ssl_client_verify;
        proxy_set_header X-Client-DN $ssl_client_s_dn;
        proxy_set_header X-Client-Fingerprint $ssl_client_fingerprint;

        proxy_pass http://backend;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 주의할 점이 있다. 애플리케이션이 X-Client-DN 같은 헤더를 신뢰한다면, 외부에서 들어온 동일 이름의 헤더를 반드시 제거해야 한다. 그렇지 않으면 공격자가 직접 X-Client-DN 헤더를 조작해 인증된 클라이언트인 것처럼 위장할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Gateway에서 mTLS를 종료하는 구조라면 외부 요청의 인증서 관련 헤더를 먼저 제거후&amp;nbsp;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;Gateway가 검증한 인증서 정보만 새 헤더로 주입하는 작업이 필요하다. 또한 뒷단의 서버에서는&lt;/span&gt; gateway에서 온 트래픽만 신뢰하도록 해야한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 애플리케이션 서버가 직접 mTLS를 처리하는 방식&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot, Tomcat, Netty 같은 애플리케이션 런타임이 직접 클라이언트 인증서를 검증한다. 이 경우 애플리케이션 설정에서 keystore와 truststore를 관리해야 한다.&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;Spring Boot 애플리케이션이 직접 mTLS를 처리한다면 `server.ssl.*` 설정을 사용할 수 있다. `server.ssl.client-auth`는 클라이언트 인증 모드를 의미하며, NEED는 클라이언트 인증이 필수이고, WANT는 클라이언트 인증서를 요청하지만 필수는 아닌 모드이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1780148116284&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;server:
  port: 8443
  ssl:
    enabled: true

    # 서버가 자신을 증명하기 위해 사용할 인증서와 개인키
    key-store: classpath:server.p12
    key-store-password: changeit
    key-store-type: PKCS12
    key-alias: server

    # 서버가 클라이언트 인증서를 검증하기 위해 신뢰할 CA
    trust-store: classpath:server-truststore.p12
    trust-store-password: changeit
    trust-store-type: PKCS12

    # 클라이언트 인증서 필수
    client-auth: need&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정에서, key-store는 서버 자신의 인증서와 개인키를 담는다. trust-store는 클라이언트 인증서를 검증하기 위해 신뢰할 CA 인증서를 담는다. client-auth: need 를 설정하면 클라이언트 인증서를 제시하지 않는 요청은 TLS handshake 단계에서 실패한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션이 외부 mTLS API를 호출하는 클라이언트라면 반대로 클라이언트의 keystore에는 클라이언트의 인증서와 개인키가 들어가고, truststore에는 호출 대상 서버 인증서를 검증할 CA가 들어간다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CA 전략 정하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mTLS에서 가장 중요한 것은 &quot;무엇을 신뢰할 것인가&quot;이다.&amp;nbsp;사설 CA를 사용할 때는 보통 다음과 같은 구조를 둔다.&lt;/p&gt;
&lt;pre id=&quot;code_1780146593347&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Root CA
  └── Intermediate CA
        ├── server.example.internal 인증서
        ├── client-a 인증서
        └── client-b 인증서&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Root CA는 최대한 안전하게 보관하고, 실제 서버/클라이언트 인증서 발급은 Intermediate CA가 담당하는 구조가 일반적이다. 이렇게 하면 Intermediate CA 교체나 폐기 시 영향 범위를 줄일 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버와 클라이언트의 keystore/truststore 구성하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mTLS에서는 서버와 클라이언트 양쪽 모두 keystore와 truststore가 필요하다.&lt;/p&gt;
&lt;pre id=&quot;code_1780146890933&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Server]
- keystore   : 서버 인증서 + 서버 개인키
- truststore : 클라이언트 인증서를 발급한 CA 인증서

[Client]
- keystore   : 클라이언트 인증서 + 클라이언트 개인키
- truststore : 서버 인증서를 발급한 CA 인증서&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;PKIX path building failed 에러&lt;/h4&gt;
&lt;pre id=&quot;code_1780147046520&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;javax.net.ssl.SSLHandshakeException:
  PKIX path building failed:
  sun.security.provider.certpath.SunCertPathBuilderException:
  unable to find valid certification path to requested target&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 에러는, Java 환경에서 발생할 수 있는 대표적인 인증서 에러이다. 해당 에러는 Java가 상대방 인증서를 검증하기 위해 신뢰 가능한 인증 경로를 만들려고 했지만 실패했다는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java 입장에서는 상대방이 제시한 인증서에서 시작해 중간 CA를 거쳐, 최종적으로 자신이 신뢰하는 trust anchor까지 도달해야 한다. 하지만 'PKIX path building failed' 에러의 경우, 상대방 인증서를 받긴 했지만 해당 인증서를 내가 신뢰하는 CA까지 연결할 수 없어 TSL handshake에 실패했다는 것을 의미한다.&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;mTLS에서는 해당 에러가 발생할 원인의 경우의 수가 더 많다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 클라이언트가 서버 인증서를 신뢰하지 못하는 경우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 애플리케이션의 truststore에 서버 인증서를 발급한 CA가 없으면 실패한다. 이 경우, 에러 로그는 보통 API를 호출하는 Java 어플리케이션 쪽에 남게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 서버가 클라이언트 인증서를 신뢰하지 못하는 경우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 truststore에 클라이언트 인증서를 발급한 CA가 없으면 실패한다. 이 경우 클라이언트 입장에서는 단순히 handshake failure, connection reset, bad certificate, certificate required 같은 에러만 볼 수도 있다. 실제 원인은 서버나 gateway 로그에 남는 경우가 많다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. 인증서 체인이 불완전한 경우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상대방이 leaf 인증서만 보내고 intermediate 인증서를 보내지 않으면, 검증자가 trust anchor까지 경로를 만들지 못할 수도 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1780147600669&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;받은 인증서:
server.example.com 인증서만 있음

필요한 체인:
server.example.com &amp;rarr; Intermediate CA &amp;rarr; Root CA

결과:
Intermediate CA를 찾지 못해 path building 실패&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우, 해결책은 서버가 full chain을 보내도록 설정하거나, 검증자 truststore에 필요한 intermediate CA를 추가하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;4. 다른 truststore를 보고 있는 경우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증서를 분명 import 했는데도 계속 에러가 난다면, 애플리케이션이 실제로 그 truststore를 보고 있지 않을 가능성이 있다. 이 경우, &quot;실제 실행 중인 프로세스가 어떤 truststore를 사용하는가&quot;를 확인해야 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 외에도 여러 원인이 존재할 수 있다. 특히 mTLS는 양방향 검증이기 때문에 하나의 인증서 에러에 대해 정말 다양한 이유가 존재할 수 있다. 때문에 이를 해결할 때는 무작정 인증서를 Import하기 보다는 어느 방향의 검증이 실패했는지 먼저 찾고, &quot;내 truststore 문제인지, 상대방 truststore 문제인지&quot; 구분하는 것이 중요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;번외)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Root CA와 Intermediate CA&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Root CA&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Root CA는 인증서 신뢰 체계의 최상위 인증 기관이다. Root CA는 더 위에 있는 다른 CA에게 인증받지 않는다. 그래서 보통 자기 자신이 자기 자신을 서명한다. 이를 self-signed certificate, 즉 자체 서명 인증서라고 한다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Intermediate CA&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Intermediate CA는 Root CA 아래에 있는 중간 인증 기관이다. Root CA가 직접 서버 인증서나 클라이언트 인증서를 발급할 수도 있지만, 실무에서는 보통 그렇게 하지 않는다. 가장 큰 이유는 보안과 운영 안정성 때문이다. Root CA는 신뢰 체계의 최상위라서 매우 중요하다, Root CA의 개인키가 유출되면 그 Root CA가 서명한 모든 인증서 체계가 흔들릴 수 있다. 그래서 Root CA는 보통 최대한 안전하게 보관한다. 대신 Root CA가 Intermediate CA 인증서를 발급하고, Intermediate CA가 실제 서버/클라이언트 인증서를 발급한다. 만약, 클라이언트 인증서 발급 체계에 문제가 생기면 해당 CA만 교체하거나 폐기하면 되므로, Root CA 전체를 바꾸는 것보다 영향 범위가 훨씬 작아지게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조는 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1780149173042&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;위계 구조&amp;gt;
Root CA
  └── Intermediate CA
        └── Server Certificate
        
&amp;lt;예시&amp;gt;
MyCompany Root CA
  ├── MyCompany Server Intermediate CA
  │     ├── api-a.internal 서버 인증서
  │     └── api-b.internal 서버 인증서
  │
  └── MyCompany Client Intermediate CA
        ├── batch-client 인증서
        └── partner-client 인증서&lt;/code&gt;&lt;/pre&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;Server Certificate는 Intermediate CA가 서명한다.&lt;/li&gt;
&lt;li&gt;Intermediate CA Certificate는 Root CA가 서명한다.&lt;/li&gt;
&lt;li&gt;Root&amp;nbsp;CA&amp;nbsp;Certificate는&amp;nbsp;자기&amp;nbsp;자신이&amp;nbsp;서명한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 서버 인증서를 검증할 때는 다음 경로를 따라 올라간다.&lt;/p&gt;
&lt;pre id=&quot;code_1780149221113&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;api.my-service.com 인증서
  &amp;rarr; MyCompany Intermediate CA
    &amp;rarr; MyCompany Root CA&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 클라이언트나 서버의 truststore에 MyCompany Root CA가 존재한다면 신뢰할 수 있는 요청으로 판단하는 것이다.&lt;/p&gt;</description>
      <category>Computer Science/네트워크</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/85</guid>
      <comments>https://wing1008.tistory.com/85#entry85comment</comments>
      <pubDate>Fri, 29 May 2026 20:56:56 +0900</pubDate>
    </item>
    <item>
      <title>Hibernate Envers - 간편하게 엔티티 변경 이력 관리하기</title>
      <link>https://wing1008.tistory.com/84</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 코드를 실행하다가 신기한 상황을 발견했다. 서비스 로직에서 A 테이블의 데이터를 update하고 있었는데, 트랜잭션이 끝난 뒤 확인해보니 A의 히스토리 이력을 저장하는 B 테이블에도 데이터가 자동으로 쌓이고 있었다. 때문에 나는 당연히 아래와 같은 코드를 찾기 시작했다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;historyRepository.save(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 아무리 찾아봐도 B 테이블에 위와 같이 명시적으로 직접 저장하는 로직은 없었다. 도대체 어떻게 이 데이터를 저장하는 걸까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, 애플리케이션 서비스 코드가 히스토리를 직접 저장한 것이 아니었다. 해당 엔티티에 @Audited가 붙어 있었고, Hibernate Envers가 Hibernate의 flush 과정에 끼어들어 변경 이력을 자동으로 저장하고 있었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hibernate Envers&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;@Audited가 붙은 엔티티의 insert, update, delete 이력을 별도의 audit table에 자동으로 저장해주는 Hibernate 모듈&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Envers는 무엇을 해결하려는 기술일까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션을 운영하다 보면 현재 데이터만으로는 부족한 경우가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 주문 테이블이 있다고 해보자. 해당 테이블에는 현재 주문 상태만 남는다. 그런데 서비스를 운영하며 오는 CS 문의라던가, 오류 대응이라던가 여러 가지 상황에 의해 현재 상태뿐 아니라 다음과 같은 질문에 답해야 할 때가 많다.&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;/li&gt;
&lt;li&gt;취소되기 전 상태는 무엇이었을까?&lt;/li&gt;
&lt;li&gt;배송지는 언제 변경되었을까?&lt;/li&gt;
&lt;li&gt;한 트랜잭션 안에서 주문과 결제가 함께 변경되었을까?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;12709&quot; data-start=&quot;12618&quot; data-ke-size=&quot;size16&quot;&gt;이런 요구사항을 처리하기 위해서 보통 히스토리 테이블을 따로 만들어 데이터의 변경 이력을 관리하는 편이다.&lt;/p&gt;
&lt;pre id=&quot;code_1778166184461&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void cancelOrder(Long orderId) {
    PurchaseOrder order = orderRepository.findById(orderId)
            .orElseThrow();

    order.cancel();

    orderHistoryRepository.save(
            PurchaseOrderHistory.from(order)
    );
}&lt;/code&gt;&lt;/pre&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-end=&quot;2182&quot; data-start=&quot;2166&quot; data-ke-size=&quot;size16&quot;&gt;문제는 여기서 끝나지 않는다. 어떤 곳에서는 히스토리를 저장하고, 어떤 곳에서는 깜빡할 수 있다. 히스토리 저장 로직이 비즈니스 로직과 섞여서 코드가 복잡해질 수도 있다. 여러 엔티티가 한 트랜잭션에서 함께 변경될 때, 하나의 변경 묶음으로 추적하기도 쉽지 않다.&lt;/p&gt;
&lt;p data-end=&quot;2368&quot; data-start=&quot;2320&quot; data-ke-size=&quot;size16&quot;&gt;Hibernate Envers는 이런 문제를 해결하기 위한 Hibernate 모듈이다.&lt;/p&gt;
&lt;h3 data-end=&quot;2368&quot; data-start=&quot;2320&quot; data-ke-size=&quot;size23&quot;&gt;Hibernate Envers와 Spring Data Envers는 다르다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름이 비슷해서 헷갈릴 수 있는데, 역할을 나눠서 이해하는 것이 좋다. 먼저 &lt;b&gt;Hibernate Envers&lt;/b&gt;가 실제 변경 이력을 저장한다. 즉, 엔티티 변경을 감지하고, REVINFO와 audit table에 insert SQL을 추가하는 주체는 Hibernate Envers다.&lt;/p&gt;
&lt;p data-end=&quot;2983&quot; data-start=&quot;2886&quot; data-ke-size=&quot;size16&quot;&gt;반면 &lt;b&gt;Spring Data Envers&lt;/b&gt;는 Spring Data JPA 환경에서 Envers 이력을 Repository 스타일로 조회할 수 있게 도와주는 기능에 가깝다. 예를 들어 Hibernate Envers만 사용해도 AuditReader로 이력을 조회할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1778166311860&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;AuditReader auditReader = AuditReaderFactory.get(entityManager);

List&amp;lt;Number&amp;gt; revisions = auditReader.getRevisions(PurchaseOrder.class, orderId);

PurchaseOrder oldOrder = auditReader.find(
        PurchaseOrder.class,
        orderId,
        revisions.get(0)
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Spring Data JPA를 쓰고 있다면 다음처럼 Repository에서 이력을 조회하고 싶을 수 있다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이때 사용하는 것이 Spring Data Envers다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778166324525&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface PurchaseOrderRepository
        extends JpaRepository&amp;lt;PurchaseOrder, Long&amp;gt;,
                RevisionRepository&amp;lt;PurchaseOrder, Long, Integer&amp;gt; {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data Envers를 사용하려면 spring-data-envers 의존성을 추가하고, Repository가 RevisionRepository를 확장하도록 만들며, 대상 엔티티에는 @Audited가 붙어 있어야 한다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;REV와 REVTYPE&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 &lt;b&gt;REV&lt;/b&gt;는 revision 번호다. Envers에서 revision은 단순히 audit row 하나의 id라기보다는, &lt;b&gt;하나의 트랜잭션에서 발생한 감사 대상 변경 묶음&lt;/b&gt;에 가깝다. 예를 들어 하나의 트랜잭션에서 주문과 결제 정보가 함께 변경되었다고 해보자. 만약 PurchaseOrder와 Payment가 모두 @Audited 대상이라면, 두 변경은 같은 트랜잭션 안에서 발생했으므로 같은 revision으로 묶일 수 있다. 따라서 revision을 보면 단순히 &amp;ldquo;이 row가 몇 번째로 바뀌었는가?&amp;rdquo;뿐 아니라, &amp;ldquo;어떤 트랜잭션에서 어떤 엔티티들이 함께 바뀌었는가?&amp;rdquo;라는 관점으로도 볼 수 있다.&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;REVTYPE&lt;/b&gt;은 변경 종류를 나타낸다. Hibernate 문서 기준으로 REVTYPE 값은 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;7160&quot; data-start=&quot;7088&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-end=&quot;7160&quot; data-start=&quot;7116&quot;&gt;
&lt;tr data-end=&quot;7130&quot; data-start=&quot;7116&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;7120&quot; data-start=&quot;7116&quot;&gt;0&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;7130&quot; data-start=&quot;7120&quot;&gt;INSERT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;7145&quot; data-start=&quot;7131&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;7135&quot; data-start=&quot;7131&quot;&gt;1&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;7145&quot; data-start=&quot;7135&quot;&gt;UPDATE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;7160&quot; data-start=&quot;7146&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;7150&quot; data-start=&quot;7146&quot;&gt;2&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;7160&quot; data-start=&quot;7150&quot;&gt;DELETE&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Audit 테이블 설정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 설정 값으로는 REVINFO라는 테이블이지만, Envers의 테이블 이름은 바꿀 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 엔티티별 audit table 이름은 @AuditTable로 지정할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1778402153246&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
@Audited
@Table(name = &quot;orders&quot;)
@AuditTable(value = &quot;order_history&quot;)
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    private String status;

    private String receiverName;

    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 기본 이름인 orders_AUD 대신 order_history 테이블에 이력이 저장된다. 전체 audit table suffix도 아래와 같은 설정을 통해 바꿀 수도 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1778402179817&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring.jpa.properties.org.hibernate.envers.audit_table_suffix=_history&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;save()가 없는데 왜 update가 발생할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 다시 처음 코드로 돌아가보자.&lt;/p&gt;
&lt;pre id=&quot;code_1778399817951&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void cancelOrder(Long orderId) {
    PurchaseOrder order = orderRepository.findById(orderId)
            .orElseThrow();

    order.cancel();

    // orderRepository.save(order) 없음
    // orderHistoryRepository.save(...) 없음
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에는 save()가 없다. 그런데도 원본 주문 테이블은 update되고, audit table에는 이력이 insert된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hibernate는 변경 내용을 persistence context 안에 모아두었다가 flush 시점에 DB와 동기화한다. 즉, &lt;span&gt;트랜잭션 commit 직전 flush 발생하면, &lt;/span&gt;&lt;span&gt;Hibernate가 dirty checking 수행하게 되면서&amp;nbsp;&lt;/span&gt;&lt;span&gt;변경된 필드를 감지하고 update SQL 생성되는 흐름인 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;audit table insert는 누가 할까?&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원본 테이블 update는 dirty checking으로 설명된다. 그러면 audit table insert는 누가 할까?&lt;/p&gt;
&lt;p data-end=&quot;9262&quot; data-start=&quot;9241&quot; data-ke-size=&quot;size16&quot;&gt;바로 Hibernate Envers다. Envers는 Hibernate의 이벤트 흐름에 붙어 있다. 엔티티 insert, update, delete가 발생하면 Envers listener가 해당 변경을 보고 audit work를 만든다.&lt;/p&gt;
&lt;p data-end=&quot;9262&quot; data-start=&quot;9241&quot; data-ke-size=&quot;size16&quot;&gt;트랜잭션 commit 시점에 flush가 발생하면 Hibernate는 PurchaseOrder가 변경되었음을 감지한다. 그 결과 원본 테이블에 update SQL이 만들어진다. 그리고 이 엔티티가 @Audited 대상이므로 Envers가 revision 정보를 만들고 audit table insert를 추가한다.&lt;/p&gt;
&lt;p data-end=&quot;9262&quot; data-start=&quot;9241&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;9262&quot; data-start=&quot;9241&quot; data-ke-size=&quot;size16&quot;&gt;정리하면, 전체적인 흐름은 아래와 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;9262&quot; data-start=&quot;9241&quot;&gt;원본 엔티티 변경&lt;/li&gt;
&lt;li data-end=&quot;9262&quot; data-start=&quot;9241&quot;&gt;Hibernate dirty checking&lt;/li&gt;
&lt;li data-end=&quot;9262&quot; data-start=&quot;9241&quot;&gt;원본 테이블 update SQL 생성&lt;/li&gt;
&lt;li data-end=&quot;9262&quot; data-start=&quot;9241&quot;&gt;Envers listener가 변경 감지&lt;/li&gt;
&lt;li data-end=&quot;9262&quot; data-start=&quot;9241&quot;&gt;REVINFO insert 생성&lt;/li&gt;
&lt;li data-end=&quot;9262&quot; data-start=&quot;9241&quot;&gt;audit table insert 생성&lt;/li&gt;
&lt;li data-end=&quot;9262&quot; data-start=&quot;9241&quot;&gt;같은&amp;nbsp;트랜잭션&amp;nbsp;안에서&amp;nbsp;함께&amp;nbsp;반영&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Envers의 단점과 주의할 점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Envers는 편리하지만, 모든 상황에 만능은 아니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. audit table의 created_at을 잘못 해석하기 쉽다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Envers를 사용할 때 헷갈리는 지점이 있다. 바로 audit table의 created_at이다.&lt;/p&gt;
&lt;p data-end=&quot;10960&quot; data-start=&quot;10908&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 Order 엔티티에 createdAt, updatedAt이 있다고 하자. 그러면 audit table에도 created_at, updated_at 컬럼이 생길 수 있다. 이때 order_history.created_at을 보고 &amp;ldquo;이 히스토리 row가 생성된 시간&amp;rdquo;이라고 생각하면 안 된다. &lt;b&gt;audit table의 created_at은 history row 생성 시간이 아니다&lt;/b&gt;. 원본 엔티티의 createdAt 값이다. 즉, 해당 revision 시점에 엔티티가 가지고 있던 createdAt 값이 복사된 것이다. 따라서 이력 발생 시각을 보려면 revision table의 timestamp를 봐야 한다.&lt;/p&gt;
&lt;p data-end=&quot;10960&quot; data-start=&quot;10908&quot; data-ke-size=&quot;size16&quot;&gt;따라서 변경 이력 화면에서 &amp;ldquo;변경 발생 시간&amp;rdquo;을 보여주고 싶다면 audit table의 created_at으로 정렬하면 안 된다. revision table을 join해서 revision timestamp를 봐야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. bulk update/delete는 audit이 누락될 수 있다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Envers는 Hibernate entity lifecycle과 event listener 흐름에 기대어 동작한다. 따라서 다음처럼 엔티티를 조회하고 값을 바꾸는 방식은 audit이 남는다.&lt;/p&gt;
&lt;pre id=&quot;code_1778402535947&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void cancelOrder(Long orderId) {
    Order order = orderRepository.findById(orderId)
            .orElseThrow();

    order.cancel();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 JPQL bulk update는 다르다.&lt;/p&gt;
&lt;pre id=&quot;code_1778402548190&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Modifying
@Query(&quot;&quot;&quot;
    update Order o
       set o.status = :status
     where o.status = :targetStatus
&quot;&quot;&quot;)
int bulkUpdateStatus(
        @Param(&quot;status&quot;) String status,
        @Param(&quot;targetStatus&quot;) String targetStatus
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 쿼리는 엔티티를 하나씩 영속 상태로 올려서 dirty checking을 수행하는 방식이 아니다. DB에 직접 update query를 실행하는 방식에 가깝다. 그 결과 원본 테이블은 바뀌었는데 audit table에는 이력이 남지 않을 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;15807&quot; data-start=&quot;15600&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.hibernate.org/orm/6.0/userguide/html_single/#entity-inheritance&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Hibernate 문서&lt;/a&gt;에서도 bulk operation은 entity lifecycle event dispatch 방식 때문에 Envers에서 지원되지 않으며, audit history가 불완전해질 수 있으므로 피해야 한다고 적혀있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1968&quot; data-origin-height=&quot;282&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bddHy2/dJMcajozggD/t659yf7k18PkJ4IHaZTJfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bddHy2/dJMcajozggD/t659yf7k18PkJ4IHaZTJfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bddHy2/dJMcajozggD/t659yf7k18PkJ4IHaZTJfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbddHy2%2FdJMcajozggD%2Ft659yf7k18PkJ4IHaZTJfK%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;1968&quot; height=&quot;282&quot; data-origin-width=&quot;1968&quot; data-origin-height=&quot;282&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;15807&quot; data-start=&quot;15600&quot; data-ke-size=&quot;size16&quot;&gt;따라서 Envers를 사용하는 프로젝트에서는 다음 코드를 조심해야 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;15807&quot; data-start=&quot;15600&quot;&gt;@Modifying JPQL update/delete&lt;/li&gt;
&lt;li data-end=&quot;15807&quot; data-start=&quot;15600&quot;&gt;CriteriaUpdate/Delete&lt;/li&gt;
&lt;li data-end=&quot;15807&quot; data-start=&quot;15600&quot;&gt;native SQL update/delete&lt;/li&gt;
&lt;li data-end=&quot;15807&quot; data-start=&quot;15600&quot;&gt;대량&amp;nbsp;변경&amp;nbsp;배치&amp;nbsp;쿼리&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. audit table 데이터가 계속 늘어난다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Envers의 기본 전략은 audit row를 insert만 한다. 즉, Envers는 변경될 때마다 audit row를 추가한다. 변경이 많은 엔티티라면 audit table이 빠르게 커진다. audit table이 커지면 조회 성능, 인덱스 크기, 보관 정책을 함께 고민해야 한다. 단순히 @Audited만 붙이고 끝내면 나중에 audit table이 예상보다 커질 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;17073&quot; data-start=&quot;17050&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JPA&amp;amp;JDBC</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/84</guid>
      <comments>https://wing1008.tistory.com/84#entry84comment</comments>
      <pubDate>Sun, 3 May 2026 20:19:30 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Batch] Spring Batch 아키텍쳐와 전체 흐름 이해하기</title>
      <link>https://wing1008.tistory.com/82</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Batch는 무엇을 해결하려는 기술일까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션을 운영하다보면 사용자의 요청에 즉시 응답하는 API 처리와는 조금 다른 종류의 작업이 필요해진다. 매일 새벽 주문 데이터를 정산하거나, 대량의 회원 데이터를 읽어서 상태를 갱신하거나, 외부 시스템에서 받은 파일을 검증한 뒤 데이터베이스에 적재하는 작업들을 예시로 들 수 있다. 이러한 작업들은 보통 대량의 데이터를 처리해야 하고, 일정한 주기로 반복 실행되며, 실패했을 때 어디까지 처리했는지 추적할 수 있어야 한다.&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;Spring Batch는 이런 배치 작업을 안정적으로 만들기 위한 프레임워크다. 공식 문서에서도 Spring Batch를 대량 데이터 처리를 위한 경량이면서 포괄적인 배치 프레임워크로 설명하고 있다. 다만, Spring Batch 자체가 스케쥴러는 아니다. Quartz, Kubernetes CronJob 과 같은 스케쥴러가 특정 시점에 배치 실행을 트리거하고, Spring Batch는 실제 배치 처리와 상태 관리, 트랜잭션, 재시작, 재시도 등과 같은 기능을 담당한다고 보는 것이 좋다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Batch의 아키텍쳐&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1640&quot; data-origin-height=&quot;1166&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b59lkB/dJMcadn9Dyv/KzZYtwX3TkKUrvX9v2YxNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b59lkB/dJMcadn9Dyv/KzZYtwX3TkKUrvX9v2YxNk/img.png&quot; data-alt=&quot;Spring Batch 아키텍쳐&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b59lkB/dJMcadn9Dyv/KzZYtwX3TkKUrvX9v2YxNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb59lkB%2FdJMcadn9Dyv%2FKzZYtwX3TkKUrvX9v2YxNk%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;1640&quot; height=&quot;1166&quot; data-origin-width=&quot;1640&quot; data-origin-height=&quot;1166&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Spring Batch 아키텍쳐&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Batch의 실행 흐름을 아주 단순하게 표현하면 아래와 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Scheduler -&amp;gt; JobLauncher/JobOperator -&amp;gt; Job -&amp;gt; Step1 -&amp;gt; . . . -&amp;gt; StepN&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 그림에서 검은색 선은 실제 처리 흐름을 의미한다. Scheduler가 배치를 실행하면 JobLauncer가 Job을 실행하고, Job은 자신에게 정의된 Step들을 순서대로 실행한다. 각 Step은 실제 데이터를 읽고, 가공하고, 쓰는 역할을 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빨간색 선은 배치 실행 정보가 저장되는 흐름을 의미한다. Spring Batch는 단순히 코드를 실행하고 끝내는 것이 아니라, &quot;어떤 Job이 실행되었는지&quot;, &quot;성공했는지 실패했는지&quot;, &quot;몇 건을 읽었고 몇 건을 썼는지&quot;, &quot;어디까지 처리했는지&quot;와 같은 메타데이터를 JobRepository를 통해 저장한다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Job&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Job은 하나의 배치 작업 전체를 의미한다. 예를 들어 &quot;매일 새벽 주문 정산 배치&quot;라는 작업이 있다고 해보자. 이 작업 전체가 하나의 Job이 된다. 하지만 실제 주문 정산은 여러 단계로 나눌 수 있다. (정산 대상 주문 조회, 주문 금액 계산, 정산 결과 저장, 정산 완료 알림 발송 등)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Batch에서 Job은 이런 여러 단계를 하나로 묶는 최상위 단위이다. 즉, Job은 전체 배치 프로세스를 캡슐화하는 엔티티이며, 하나 이상의 Step을 담는 컨테이너이다. 또한 Job은 어떤 Step을 어떤 순서로 실행할지, 재시작 가능한 작업인지 같은 전역 설정을 가진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;736&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLHfNa/dJMcafzyoTc/NscvklTL2IDGhAWfldfgx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLHfNa/dJMcafzyoTc/NscvklTL2IDGhAWfldfgx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLHfNa/dJMcafzyoTc/NscvklTL2IDGhAWfldfgx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLHfNa%2FdJMcafzyoTc%2FNscvklTL2IDGhAWfldfgx0%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;1244&quot; height=&quot;736&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;736&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서, simpleJob은 총 2개의 Step으로 구성된다. start()는 첫번째로 실행할 Step을 의미하고, next()는 다음에 실행할 Step을 의미한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JobInstance와 JobExecution&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Job이 배치라는 프로그램 그 자체라면, JobInstance는 특정 조건으로 실행되는 배치 대상을 의미하며 JobExecution은 JobInstance를 실제로 실행한 시도를 의미한다.&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;예를 들어, 매일 한번씩 실행되는 Job이 있다고 생각해보자. 이때, 날짜가 JobParameters로 들어간다면, 각 날짜별 실행은 서로 다른 JobInstance가 된다. 즉, 같은 Job이라도 식별 파라미터가 다르면 처리 대상이 다르기 때문에 다른 JobInstance가 되는 것이다. JobInstance는 Job + identifying JobParameters 조합으로 정의된다. 즉, JobInstance는 이 Job이 어떤 조건의 데이터를 처리하는가를 나타내는 단위이며 때문에 같은 Job이여도 파라미터가 다르면 다른 JobInstance이다.&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;만약에 이렇게 매일 배치를 돌리다가, 2026-04-19일자에 배치가 실패했다고 가정해보자. 이후 같은 파라미터로 다시 실행하면 새로운 JobExecution이 생성되게 된다. 즉, 같은 식별 파라미터로 다시 실행하면 같은 JobInstance아래에 새로운 JobExecution이 생성되는 형태인 것이다. 즉, JobExecution은 Job을 실행하려는 단일 시도(single attempt)이다. 또한 같은 식별 파라미터로 실패한 JobInstance를 다시 실행하면, 기존 JobInstance는 그대로 유지되고 새로운 JobExecution이 만들어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;JobInstance와 JobExecution을 나누는 이유?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 나누는 이유는 바로 &quot;재시작&quot; 때문이다. 총 100만건을 처리햐아하는 정산 배치에서 70만건까지만 처리하고 실패한 상태라고 가정해보자. 이때 다시 실행할 때에는 2가지 선택지가 존재한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 처음부터 다시 처리하기&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;Spring Batch는 이 2번째 방식을 지원하기 위해 JobInstance와 JobExecution을 구분한다. &lt;b&gt;같은 JobInstance로 다시 실행한다&lt;/b&gt;는 것은 '새로운 날짜의 정산이 아니라 이전에 실패했던 정산을 다시 실행하는걸로 인식을 하고 이전 실행 상태를 참고해야겠다'라는 의미가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면, &lt;b&gt;새로운 JobInstance로 실행한다&lt;/b&gt;는 것은 '완전히 새로운 처리 대상이기 때문에 이전 상태를 참고하지 않고 처음부터 시작해야겠다.' 라는 의미가 되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 같은 JobInstance를 사용하면 이전 실행의 상태, 즉 ExecutionContext를 사용할 수 있다는 것을 의미하고, 새로운 JobInstance를 사용하면 처음부터 시작한다는 의미이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JobRepository의 필요성&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1828&quot; data-origin-height=&quot;830&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJtLCl/dJMcahKT5XX/KnYdNAfK7DJbpGoOleLz2k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJtLCl/dJMcahKT5XX/KnYdNAfK7DJbpGoOleLz2k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJtLCl/dJMcahKT5XX/KnYdNAfK7DJbpGoOleLz2k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJtLCl%2FdJMcahKT5XX%2FKnYdNAfK7DJbpGoOleLz2k%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;1828&quot; height=&quot;830&quot; data-origin-width=&quot;1828&quot; data-origin-height=&quot;830&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Batch의 중요한 특징 중 하나는 실행 상태를 저장한다는 점이다. Spring Batch는 JobRepository를 통해 어떤 Job/JobParameters 이(로) 실행되었는지, Job의 성공유무, 각 Step이 성공했는지 실패했는지, 몇 건을 읽고 처리하고 저장했는지, 어디까지 처리했는지, 재시작할 떄 필요한 상태는 무엇인지 등과 같은 정보를 저장한다. 그리고 이런 정보들은 Spring Batch 메타데이터 테이블에 저장된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 메타데이터 테이블은 Java 도메인 객체와 밀접하게 매핑되며, JobInstance, JobExecution, JobParameters, StepExecution은 각각 BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION, BATCH_JOB_EXECUTION_PARAMS, BATCH_STEP_EXECUTION에 매핑된다. 또한 ExecutionContext는 Job과 Step 각각의 context 테이블에 저장된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫번째 사진에서 JobRepository와 Database가 연결되어 있는 이유가 바로 이러한 이유때문이다. Spring Batch는 실행 중 계속해서 JobRepository를 통해 메타데이터를 저장하고 갱신한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Step은 Job을 구성하는 실제 처리 단위이다. Job이 &quot;주문 정산 배치&quot;라면, Step은 그 안에 포함된 &quot;주문 읽기&quot;, &quot;정산 계산하기&quot;, &quot;정산 결과 저장하기&quot; 같은 개별 단계다. 즉, Step은 배치 작업의 독립적이고 순차적인 단계를 캡슐화하는 도메인 객체라고 이해하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 Job은 하나 이상의 Step으로 구성되며, Step은 실제 배치 처리를 정의하고 제어하는데 필요한 정보를 가진다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Tasklet 방식&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Step의 대표적인 처리 방식은 크게 2가지가 있다. 그 중 Tasklet 방식은 단순한 작업을 하나의 execute() 메서드 안에서 처리한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776612142776&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean
public Step cleanupStep(
        JobRepository jobRepository,
        PlatformTransactionManager transactionManager
) {
    return new StepBuilder(&quot;cleanupStep&quot;, jobRepository)
            .tasklet((contribution, chunkContext) -&amp;gt; {
                // 파일 삭제, 임시 테이블 정리, API 호출 등
                return RepeatStatus.FINISHED;
            }, transactionManager)
            .build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TaskletStep은 저장 프로시저 호출, 스크립트 실행, SQL update 같은 작업처럼 chunk 방식으로 표현하기 어색한 경우에 사용할 수 있다. Tasklet의 execute 메서드는 FINISHED를 반환하거나 예외가 발생할 때까지 TaskletStep에 의해 반복 호출될 수 있고, 각 호출은 트랜잭션으로 감싸진다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;chunk 방식&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두번째는 chunk 방식이다. 대량의 데이터를 읽고, 가공하고, 쓰는 배치 작업에서 가장 일반적으로 사용된다. 대용량 데이터를 처리하는 배치 프로세스 특성상, 대상 데이터들을 하나의 트랜잭션으로 처리하기에는 어려움이 있기 때문에 대상 데이터를 chunk 단위로 쪼개어 트랜잭션 동작을 수행한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;729&quot; data-origin-height=&quot;404&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eyNRA0/dJMcag6hSSe/Dxlm3WylbkhPRkAUEx5BLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eyNRA0/dJMcag6hSSe/Dxlm3WylbkhPRkAUEx5BLk/img.png&quot; data-alt=&quot;chunk-oriented processing&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eyNRA0/dJMcag6hSSe/Dxlm3WylbkhPRkAUEx5BLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeyNRA0%2FdJMcag6hSSe%2FDxlm3WylbkhPRkAUEx5BLk%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;729&quot; height=&quot;404&quot; data-origin-width=&quot;729&quot; data-origin-height=&quot;404&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;chunk-oriented processing&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 그림을 보면, stepExecution 안에서, ItemReader, ItemProcessor, ItemWriter가 순서대로 호출된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터를 하나씩 읽고, 설정된 commit interval 만큼 chunk를 만든 뒤, 해당 chunk를 트랜잭션 경계 안에서 ItemWriter로 쓰고 커밋하는 방식이다.&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;b&gt;ItemReader&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말 그대로 데이터를 읽는 역할을 한다. JdbcCursorItemReader, JpaPagingItemReader와 같은 구현체를 사용할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ItemReader의 핵심은 read() 메서드이다. 데이터를 하나씩 반환하다가 더 이상 읽을 데이터가 없으면 null을 반환한다. 정리하면 ItemReader는 Step의 입력을 한 item씩 가져오는 추상화이며, 더 이상 제공할 item이 없을 때 null을 반환하는 방식으로 동작한다.&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;b&gt;ItemProcessor&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ItemProcessor는 읽은 데이터를 가공하는 역할을 한다. 예를 들어 회원 데이터를 읽은 뒤 휴면 회원 여부를 판단하거나, 주문 데이터를 읽은 뒤 정산 금액을 계산하거나, 유효하지 않은 데이터를 필터링할 수 있다. Processor는 선택사항이다. 단순히 읽은 데이터를 그대로 저장하는 흐름이라면, 생략할 수 있다. 또한 process()에서 null을 반환하면 해당 item은 writer로 전달되지 않는다. 즉, ItemProcessor는 item에 비즈니스 처리를 적용하거나 변환하는 진입점이며, Null 변환 시 해당 item이 write 대상에서 제외된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;ItemWriter&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 ItemWriter는 데이터를 쓰는 역할을 한다. DB에 insert/update를 하거나, 외부 API로 전송하거나, Kafka로 메세지를 발행할 수 있다. 중요한 점은 ItemWriter가 ItemReader처럼 item을 1건씩 받을 때마다 바로 호출되는 것이 아니라는 점이다. Spring Batch는 ItemReader와 ItemProcessor를 통해 처리한 데이터를 chunk size만큼 모아두었다가, 그 묶음을 ItemWriter에 한 번에 전달한다. 예를 들어 chunk size가 100이라면 reader와 processor는 100번 동작하지만, writer는 100건이 담긴 chunk를 받아 1번 호출된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 실제 저장 방식이 SQL 한 번으로 끝나는지, 내부적으로 여러 번 저장하는지는 사용하는 writer 구현체에 따라 달라진다.&lt;/p&gt;
&lt;pre id=&quot;code_1776609111440&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Override
public void write(Chunk&amp;lt;? extends Settlement&amp;gt; chunk) {
    for (Settlement settlement : chunk) {
        settlementRepository.save(settlement);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 위와 같이 구현을 했다면, Spring Batch는 Writer를 한 번 호출했지만, Writer 내부에서는 save()를 여러번 호출하는 방식으로 동작하게 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ExecutionContext란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ExecutionContext는 배치 실행 중 필요한 상태 값을 저장하는 공간이다.&lt;/p&gt;
&lt;p data-end=&quot;9979&quot; data-start=&quot;9834&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 파일을 읽다가 10,000번째 라인에서 실패했다고 해보자. 재시작할 때 처음부터 다시 읽는 것이 아니라, 가능하다면 10,001번째 라인부터 다시 시작하고 싶을 수 있다. 이런 재시작 정보를 저장하는 데 ExecutionContext가 사용된다.&lt;/p&gt;
&lt;p data-end=&quot;10017&quot; data-start=&quot;9981&quot; data-ke-size=&quot;size16&quot;&gt;ExecutionContext는 크게 두 종류로 볼 수 있다. &lt;b&gt;Job ExecutionContext&lt;/b&gt;는 Job 실행 범위에서 사용하는 context다. 여러 Step 사이에서 공유해야 하는 값이 있다면 Job context를 사용할 수 있다. &lt;b&gt;Step ExecutionContext&lt;/b&gt;는 특정 Step 실행 범위에서 사용하는 context다. 해당 Step의 처리 위치, reader 상태, 중간 통계 같은 정보를 저장할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;10017&quot; data-start=&quot;9981&quot; data-ke-size=&quot;size16&quot;&gt;공식 문서에 따르면 Step 범위의 ExecutionContext는 Step의 commit point마다 저장되고, Job 범위의 ExecutionContext는 Step 실행 사이에 저장된다. 또한 재시작 기능을 위해 context에 저장되는 non-transient 값은 직렬화 가능해야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;10017&quot; data-start=&quot;9981&quot; data-ke-size=&quot;size16&quot;&gt;맨 위의 사진에서 각 StepExecution 아래에 Execution Context가 있고, JobExecution 내부에도 별도의 Execution Context가 있는 이유가 바로 이 범위 차이 때문이다.&lt;/p&gt;
&lt;h2 data-end=&quot;10017&quot; data-start=&quot;9981&quot; data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Batch의 전체 구조는 처음 보면 복잡해 보이지만, 큰 흐름은 단순하다. 흐름을 하나씩 따라가보자.,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 외부에서 배치 실행 요청이 들어온다. 이것은 스케줄러일 수도 있고, 커맨드라인 실행일 수도 있고, 운영자가 어드민 화면에서 실행한 요청일 수도 있다. JobLauncher는 실행할 Job과 JobParameters를 받아 Job 실행을 시작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 JobRepository를 통해 JobInstance와 JobExecution 정보가 생성되거나 조회된다. 이때 같은 Job이라도 식별 가능한 JobParameters가 다르면 새로운 JobInstance가 만들어진다. 같은 JobInstance가 이전에 실패했고 다시 실행된다면 새로운 JobExecution이 생성된다.&lt;/p&gt;
&lt;p data-end=&quot;12851&quot; data-start=&quot;12832&quot; data-ke-size=&quot;size16&quot;&gt;그다음 실제 Job이 실행된다. Job은 자신에게 정의된 Flow에 따라 Step을 실행한다. 각 Step이 시작될 때 StepExecution이 생성되고, Step 처리 상태가 JobRepository에 저장된다. 처리 중 실패하면 현재 상태와 실패 정보가 저장된다. 재시작 가능한 Job이라면 다음 실행 때 저장된 메타데이터와 ExecutionContext를 기반으로 이어서 처리할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;12851&quot; data-start=&quot;12832&quot; data-ke-size=&quot;size16&quot;&gt;마지막 Step까지 정상 완료되면 JobExecution의 상태가 COMPLETED가 된다. 중간에 복구되지 않은 예외가 발생하면 FAILED가 된다.&lt;/p&gt;</description>
      <category>Spring</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/82</guid>
      <comments>https://wing1008.tistory.com/82#entry82comment</comments>
      <pubDate>Sun, 19 Apr 2026 23:36:22 +0900</pubDate>
    </item>
    <item>
      <title>JpaPagingItemReader의 transacted 옵션</title>
      <link>https://wing1008.tistory.com/79</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;JpaPagingItemReader&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Batch에서 대용량 데이터를 처리할 때 가장 먼저 부딪히는 문제는 &amp;ldquo;데이터를 어떻게 안정적으로, 그리고 효율적으로 읽어올 것인가&amp;rdquo;이다. 이때 등장하는 대표적인 Reader가 바로 &lt;b&gt;JpaPagingItemReader&lt;/b&gt;다. 이름 그대로 JPA를 기반으로 데이터를 &lt;b&gt;페이징 방식으로 나눠서 읽어오는 Reader&lt;/b&gt;다.&lt;br /&gt;조금 더 쉽게 말하면, 데이터베이스에 있는 데이터를 한 번에 전부 가져오는 것이 아니라, &lt;b&gt;일정 개수씩 끊어서 가져오는 역할을 하는 컴포넌트&lt;/b&gt;다. 예를 들어 100만 건의 데이터를 처리해야 한다고 했을 때, 한 번에 전부 메모리에 올리는 것이 아니라 1000건씩 나눠서 가져오는 방식이다. 이렇게 하면 메모리 사용량을 안정적으로 유지하면서도 대용량 처리가 가능해진다.&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 Batch는 기본적으로 Reader &amp;rarr; Processor &amp;rarr; Writer 구조로 동작한다. 이 중 Reader는 &amp;ldquo;데이터를 어디서 어떻게 가져올 것인가&amp;rdquo;를 담당한다. 만약 Reader가 한 번에 모든 데이터를 가져오게 되면, &lt;b&gt;메모리 부족(OOM), GC 과부하, 처리 속도 저하&lt;/b&gt;와 같은 문제가 발생할 수 있다.&lt;br /&gt;JpaPagingItemReader는 이런 문제를 해결하기 위해 &lt;b&gt;페이징 기반 조회 전략&lt;/b&gt;을 사용한다. 즉, 데이터를 쪼개서 가져오기 때문에 안정적인 처리가 가능하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JpaPagingItemReader의 핵심은 &amp;ldquo;페이지 단위 조회&amp;rdquo;다. 내부적으로는 다음과 같은 흐름으로 동작한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;첫 번째 페이지 조회 (offset 0, limit N)&lt;/li&gt;
&lt;li&gt;데이터를 하나씩 Reader로 반환&lt;/li&gt;
&lt;li&gt;해당 페이지 처리가 끝나면 다음 페이지 조회 (offset N, limit N)&lt;/li&gt;
&lt;li&gt;이 과정을 반복&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은, &lt;b&gt;JPA의 setFirstResult / setMaxResults를 이용해서 offset 기반 페이징을 수행한다는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2046&quot; data-origin-height=&quot;1140&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eB4Ppy/dJMcadaqcsy/K1EoXkPAiFu731kisG0iEk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eB4Ppy/dJMcadaqcsy/K1EoXkPAiFu731kisG0iEk/img.png&quot; data-alt=&quot;출처: 카카오페이 기술 블로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eB4Ppy/dJMcadaqcsy/K1EoXkPAiFu731kisG0iEk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeB4Ppy%2FdJMcadaqcsy%2FK1EoXkPAiFu731kisG0iEk%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;2046&quot; height=&quot;1140&quot; data-origin-width=&quot;2046&quot; data-origin-height=&quot;1140&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;때문에 Limit Offset이 가지고 있는 태생적인 한계를 지니고 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Transacted 옵션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JpaPagingItemReader를 보면 setTransacted(true/false) 옵션이 있는데, 해당&amp;nbsp;옵션은 &lt;b&gt;Reader가 데이터를 읽을 때 트랜잭션을 걸고 읽을지를 설정&lt;/b&gt;이다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. transacted = true (Default)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 값이 true일 경우 Reader가 JPA 조회를 할 때 자체 EntityTransaction을 열고 커밋하는 방식으로 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, JpaPagingItemReader는 JPA 조회를 위해 별도의 EntityManager를 만들고, 기존 Spring managed transaction과 독립적인 새 트랜잭션 안에서 entity access를 수행한다.&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;transacted=true인 경우 페이지마다 읽기용 트랜잭션이 새로 생성되고 종료되기 때문에, 해당 트랜잭션에서 조회한 엔티티를 계속 유지할 필요가 없다. 따라서 내부적으로 페이지 조회가 끝나면 영속성 컨텍스트를 clear하여 관리 중이던 엔티티들을 정리한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;626&quot; data-origin-height=&quot;338&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDmkrE/dJMcabwW3bc/j3HlLZKbDRlZxw0dwQr3mK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDmkrE/dJMcabwW3bc/j3HlLZKbDRlZxw0dwQr3mK/img.png&quot; data-alt=&quot;JpaPagingItemReader의 doReadPage() 구현&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDmkrE/dJMcabwW3bc/j3HlLZKbDRlZxw0dwQr3mK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDmkrE%2FdJMcabwW3bc%2Fj3HlLZKbDRlZxw0dwQr3mK%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;626&quot; height=&quot;338&quot; data-origin-width=&quot;626&quot; data-origin-height=&quot;338&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JpaPagingItemReader의 doReadPage() 구현&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;내부 구현을 살펴보면 위와 같다. 보다 싶이, 새로운 페이지를 조회하기 전에 EntityManager를 flush하고 clear하여 영속성 컨텍스트를 정리한 뒤, 깨끗한 상태에서 현재 페이지 데이터를 조회하도록 구현이 되어 있다.&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;하지만, 해당 옵션을 true로 설정할 경우, 주의해야할 점이 있다. ItemProcessor에서 Reader가 읽어온 엔티티를 변경하면, 그 변경이 &lt;b&gt;Writer가 아니라 Reader의 다음 페이지 조회 시점의 flush()로 DB에 반영될 수 있다.&lt;/b&gt; 즉, &amp;ldquo;Reader는 읽기만 한다&amp;rdquo;고 생각했는데 transacted=true 때문에 Reader의 flush()가 예상치 못한 update를 발생시킬 수 있는 것이다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;2. transacted = false&lt;/h4&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;반면, 해당 설정을 false로 설정하게 되면, Reader가 자체 트랜잭션을 만들지 않고 조회한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Reader가 별도의 트랜잭션을 사용하지 않고, Step에서 사용 중인 영속성 컨텍스트를 함께 사용한다. 이 상태에서 clear()를 호출하면 Reader가 조회한 데이터뿐만 아니라, 현재 컨텍스트에서 관리 중이던 모든 엔티티가 함께 제거될 수 있다. 따라서 이러한 부작용을 방지하기 위해 전체를 초기화하는 대신, 조회한 엔티티만 선택적으로 분리하는 detach() 방식을 사용한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;746&quot; data-origin-height=&quot;500&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BBV2B/dJMcadhfeHv/nEwyDom8f748md8cM98pe0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BBV2B/dJMcadhfeHv/nEwyDom8f748md8cM98pe0/img.png&quot; data-alt=&quot;JpaPagingItemReader의 doReadPage() 구현&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BBV2B/dJMcadhfeHv/nEwyDom8f748md8cM98pe0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBBV2B%2FdJMcadhfeHv%2FnEwyDom8f748md8cM98pe0%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;746&quot; height=&quot;500&quot; data-origin-width=&quot;746&quot; data-origin-height=&quot;500&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JpaPagingItemReader의 doReadPage() 구현&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;transacted가 false이면 Reader가 자체 EntityTransaction을 만들지 않는다. 따라서 Spring Batch 소스상 `!transacted` 분기에서는 쿼리 결과를 가져온 뒤 각 엔티티를 entityManager.detach(entity)로 분리해서 결과 목록에 넣는다. 즉 쿼리 결과로 가져온 엔티티를 JPA 관리 상태에서 떼어내서 넘겨주는 것이다.&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;해당 옵션은 Reader는 정말 조회만 하게 만들고 싶을 때(Writer에서 저장 여부를 명시적으로 통제) 사용하면 유용하다고 한다. 다만, detach를 통해 영속성 컨텍스트의 관리에서 벗어났기 때문에 detached entity, lazy loading와 같은 예외 상황을 만나지 않도록 주의를 해야한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;영속성 엔티티와 Transacted 옵션&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 배치 개발을 하다가, 정말 예상하지 못한 예외를 겪게 되었다. 해당 예외의 원인은 앞서 설명한 transacted 옵션과 연관이 있었는데 이에 대해서는 밑에서 좀 더 자세히 설명하도록 하겠다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DTO Projection 사용 시 발생하는 &amp;ldquo;Non-entity object&amp;rdquo; 예외&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;transacted 옵션을 false로 사용하는 경우, JpaPagingItemReader는 조회된 결과를 영속성 컨텍스트에 계속 쌓아두지 않기 위해 각 엔티티를 detach() 하는 방식으로 관리한다. 이 방식은 메모리 사용을 줄이고, 불필요한 변경 감지를 방지하는 데 효과적이다.&lt;br /&gt;하지만 이때 한 가지 주의해야 할 점이 있다. 바로 &lt;b&gt;DTO projection을 사용할 경우 예상치 못한 예외가 발생할 수 있다는 점&lt;/b&gt;이다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;예를 들어 다음과 같이 JPQL에서 DTO를 직접 생성하는 projection을 사용한다고 가정해보자.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;SELECT new com.example.dto.MemberDto(m.id, m.name)
FROM Member m&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 쿼리는 엔티티가 아닌 DTO 객체를 반환한다. 즉, 반환된 객체는 JPA가 관리하는 영속 엔티티가 아니라 단순한 자바 객체다.&lt;br /&gt;문제는 transacted=false일 때 Reader 내부에서 수행되는 detach() 로직이다. detach()는 영속성 컨텍스트에 등록된 엔티티를 준영속 상태로 전환하는 메서드인데, DTO는 애초에 영속성 컨텍스트에 포함되지 않은 객체이기 때문에 이 대상이 될 수 없다.&lt;br /&gt;따라서 DTO에 대해 detach()를 호출하게 되면, JPA는 해당 객체를 관리 대상 엔티티로 인식할 수 없고, 그 결과 Not an entity와 같은 예외가 발생하게 된다.&lt;br /&gt;즉, JPA는 엔티티에 대해서만 영속성 컨텍스트를 통해 상태를 관리할 수 있으며, DTO는 이러한 관리 대상이 아니기 때문에 detach()의 적용 대상이 될 수 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;어떻게 해결할 수 있을까?&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;DTO Projection을 포기하자.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 방법은 DTO projection을 사용하지 않고 엔티티를 그대로 조회하는 것이다. 즉, Reader에서는 엔티티를 조회하고, 이후 Processor 단계에서 DTO로 변환하는 방식을 사용하는 것이다.&lt;br /&gt;이 경우 조회된 객체는 영속성 컨텍스트의 관리 대상이 되므로, 이후 detach()가 호출되더라도 문제가 발생하지 않고 정상적으로 준영속 상태로 전환된다.&lt;br /&gt;다만 이 방식은 한 가지 단점이 있다. 엔티티 전체를 조회하게 되므로, 실제로 필요한 컬럼만 선택적으로 조회하는 DTO projection에 비해 쿼리 효율이 떨어질 수 있다. 즉, 불필요한 컬럼까지 함께 조회하게 되어 커버링 인덱스를 활용하기 어렵거나, 전반적인 쿼리 성능 측면에서 불리해질 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;다른 Reader 사용&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;꼭 DTO projection을 사용해야 한다면 JpaPagingItemReader 대신 다른 Reader를 사용하는 것도 방법이다.대표적으로 JdbcPagingItemReader를 사용하는 방식이 있다.&lt;br /&gt;JdbcPagingItemReader는 JPA의 영속성 컨텍스트를 사용하지 않고, JDBC를 기반으로 데이터를 조회한 뒤 RowMapper를 통해 원하는 형태로 매핑하는 구조를 가진다. 따라서 조회 결과가 엔티티가 아닌 DTO라 하더라도, 영속성 컨텍스트와의 충돌 없이 안전하게 사용할 수 있다.&lt;br /&gt;특히 DTO projection이 필요한 경우, 필요한 컬럼만 선택적으로 조회할 수 있기 때문에 쿼리 효율 측면에서도 유리하다. 즉, 불필요한 컬럼을 포함한 엔티티 전체 조회가 아닌, 실제로 필요한 데이터만 조회할 수 있어 커버링 인덱스를 활용하기에도 적합하다.&lt;br /&gt;다만 이 방식은 JPA의 장점을 사용할 수 없다는 단점이 있다. 예를 들어 JPQL을 활용한 추상화된 쿼리 작성이나, 엔티티 기반의 도메인 모델을 그대로 활용하기 어렵고, SQL을 직접 작성해야 하기 때문에 유지보수 측면에서 부담이 증가할 수 있다.&lt;br /&gt;따라서 DTO projection이 반드시 필요하고, 쿼리 성능이 중요한 상황이라면 JdbcPagingItemReader와 같은 JDBC 기반 Reader를 사용하는 것이 더 적절한 선택이 될 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;커스텀 Reader 구현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커스텀 Reader를 직접 구현하여 detach() 로직을 제어하는 것이다. 앞서 살펴본 것처럼 transacted=false 환경에서 JpaPagingItemReader는 조회한 객체를 개별적으로 detach() 하는 방식으로 영속성 컨텍스트를 정리한다. 문제는 이 과정이 &amp;ldquo;조회 결과가 엔티티일 것&amp;rdquo;을 전제로 동작한다는 점이다. 따라서 DTO projection처럼 엔티티가 아닌 객체를 반환하는 경우에는 내부 detach() 로직과 충돌이 발생할 수 있다.&lt;br /&gt;이러한 경우에는 Reader를 직접 구현하거나, 기존 JpaPagingItemReader를 확장하여 detach() 동작을 제어하는 방법을 고려할 수 있다. 예를 들어 조회 결과가 실제 엔티티인 경우에만 detach()를 수행하고, DTO인 경우에는 해당 로직을 건너뛰도록 처리하는 방식이다.&lt;br /&gt;이 방식의 장점은 JPA 기반 조회를 유지하면서도, DTO projection 사용 시 발생하는 예외를 직접 우회할 수 있다는 점이다. 또한 프로젝트 상황에 맞게 Reader 동작을 세밀하게 제어할 수 있기 때문에, 단순히 엔티티 조회나 JDBC 기반 Reader로 전환하기 어려운 경우에는 하나의 대안이 될 수 있다.&lt;br /&gt;다만 이 방법은 구현 복잡도가 높다는 단점이 있다. JpaPagingItemReader의 내부 동작 방식과 트랜잭션 처리 흐름, 영속성 컨텍스트 관리 방식까지 모두 이해한 상태에서 구현해야 하며, 잘못 구현할 경우 메모리 누수나 조회 일관성 문제로 이어질 수 있다. 또한 Spring Batch나 JPA 버전에 따라 내부 구현 차이에 영향을 받을 수 있어 유지보수 부담도 큰 편이다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JPA&amp;amp;JDBC</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/79</guid>
      <comments>https://wing1008.tistory.com/79#entry79comment</comments>
      <pubDate>Sun, 5 Apr 2026 23:43:40 +0900</pubDate>
    </item>
    <item>
      <title>2026 카카오그룹 신입 공채 합격 후기</title>
      <link>https://wing1008.tistory.com/77</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;338&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vXo4b/dJMcagqxs1p/y8xCusV48PllTxyZxjqVIK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vXo4b/dJMcagqxs1p/y8xCusV48PllTxyZxjqVIK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vXo4b/dJMcagqxs1p/y8xCusV48PllTxyZxjqVIK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvXo4b%2FdJMcagqxs1p%2Fy8xCusV48PllTxyZxjqVIK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;338&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;338&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;9월부터 시작되었던 카카오그룹 신입 공채가 드디어 12월 17일을 기점으로 끝이 났다!!&lt;br /&gt;이번 카카오그룹 신입 공채에는, 총 6개의 그룹사가 참여하였다. 그 중 지원자는 1개의 그룹사를 선택해서 지원해야하는 형식이었고 각 그룹사마다 서류 문항이나 진행 일정들에서 살짝씩 차이가 존재했다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;서류 작성 -&amp;gt; 1차 코딩 테스트 -&amp;gt; 2차 코딩 테스트 -&amp;gt; 1차 면접 -&amp;gt; 2차 면접 -&amp;gt; 입사&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 그룹의 신입 공채 전형은 굉장히 긴 편이다. 심지어 다른 회사들과 달리 코딩 테스트가 무려 2번이나 존재한다. 전체적인 전형 과정의 틀 자체는 모든 그룹사가 동일했지만 세부적인 부분에서는 각각의 그룹사 별로 상이했다. &lt;span style=&quot;color: #9d9d9d;&quot;&gt;(심지어 최종 합격 발표 시간도 다 상이했다..!)&lt;/span&gt;&lt;br /&gt;많은 고민 끝에 나는 카카오페이에 지원하였고, 기나긴 전형을 거쳐 &lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;&quot;합격&quot;&lt;/b&gt;&lt;/span&gt;이라는 결과까지 얻을 수 있었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전형 진행&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1차 코딩 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 7문제가 나왔고, 이번 1차 코딩 테스트에서는 감독이 없이 진행되었다. 시간 역시 5시간으로 길게 주어졌고 감독이 없고 시험 시간도 긴 만큼 시험 도중 화장실도 자유롭게 갔다 올 수 있었다.&lt;br /&gt;1차 코딩 테스트의 컷은 각 그룹사마다 다른 것 같았다. &lt;span style=&quot;color: #333333;&quot;&gt;이건 2차 때도 마찬가지였다. 아무래도 각 그룹사별 TO나 지원자 숫자도 다 다르다 보니 자연스럽게 컷에도 영향이 미쳤던 것 같다.&lt;/span&gt;&lt;br /&gt;국내 IT 기업의 코딩 테스트들 중 가장 어렵다고 소문이 자자한 악명(?)에 걸맞게 1차 코딩테스트 문제 난이도 역시 굉장히 어렵게 출제되었다. 진짜 지금까지 봤던 코딩테스트 문제들 중 가장 어려웠던 것 같다. 문제 길이 자체가 굉장히 길고 호흡도 매우 길었으며 시간 복잡도 등 생각할 것이 굉장히 많았다. &lt;span style=&quot;color: #9d9d9d;&quot;&gt;(특히 아직도 기억에 남는 것이 5번 문제였는데.. 진짜 어마무시한 호흡의 빡 구현이었다...)&lt;/span&gt;&lt;br /&gt;시간이 지나 자세히는 기억이 안나지만, 나는 7문제 중 총 4문제를 제출했었지만 그 중 1문제는 정말 자신이 없었기에 대충 3솔~3.5솔로 예상했고 카카오 신입 공채에 같이 지원했던 소마 16기 단톡방을 보니 다들 너무 잘 풀어서 기대를 안하고 있었는데...&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1346&quot; data-origin-height=&quot;286&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BuPKL/dJMb99SwpaD/ZdeAXaE7l8qM4zOXHk4Fh1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BuPKL/dJMb99SwpaD/ZdeAXaE7l8qM4zOXHk4Fh1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BuPKL/dJMb99SwpaD/ZdeAXaE7l8qM4zOXHk4Fh1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBuPKL%2FdJMb99SwpaD%2FZdeAXaE7l8qM4zOXHk4Fh1%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;611&quot; height=&quot;130&quot; data-origin-width=&quot;1346&quot; data-origin-height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;천운이 내렸는지 합격을 했다...! 제출한 4문제 중 완벽히 풀었다고 생각했던 3문제가 정말 올솔이라 합격을 한 것 같다. 아마 내가 거의 턱걸이 합격이지 않을까 싶다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2차 코딩 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전의 카카오 공채에서는 API 구현처럼 과제 형식의 시험이 주어졌다고 들었는데 이번 신입 공채에서는 코딩테스트 5문제와 CS 10문제로 구성되었다. 시험 시간은 1차와 마찬가지로 5시간으로 동일했지만 감독관의 감독이 존재했다. 1차 때 감독을 하지 않는다는 점을 악용하여 AI를 사용해서 푼 지원자들을 2차에서 거르기 위함인 것 같다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;1차 코딩테스트 합격이 발표된 후 2차 코딩테스트까지의 남은 일정은 5일로 굉장히 빠듯했다. 때문에 남은 기간 동안은 코테 감각을 끌어올리기 위해 매일 백준 골드 이상 문제를 3~5문제씩 풀었고 CS는 나름대로 자신이 있었기 때문에 가볍게 복습하는 식으로만 대비를 했다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;2차 테스트에서 CS는 그렇게 어렵게 나오지 않았던 것 같다. 좀 생소한 개념이 나오더라도 추론을 바탕으로 충분히 쉽게 풀 수 있도록 출제되었다. 때문에 합불의 영향은 CS문제의 비중보다는 코딩 테스트 5문제 중 얼마나 많은 점수를 획득했냐가 관건이었을 것 같다.&lt;br /&gt;반면, 2차 코딩 테스트 문제는 역시 쉽지 않았다. 전체적인 난이도는 개인적으로는 1차에 비해서는 쉽게 나왔다고 생각하지만, 시간 복잡도나 예외 케이스 등 굉장히 자잘하게 신경써야할 것이 굉장히 많았다.&lt;br /&gt;예를 들어서 2번 문제의 경우에는 얼핏 보면 이분 탐색 문제 같지만, 문제의 조건에 의해 이분탐색이 아닌 DP로 풀었어야 했으며 4번 문제 역시 단순한 누적합 문제라고 생각되지만 주어진 정수 범위 조건에 의해 long 타입의 최대 범위도 벗어나기 때문에 최적화가 필요한 문제였다. 하지만 아무리 고민해도 4번 문제를 완벽하게 푸는 방법이 생각나지 않았고 시간관계 상 부분점수라도 받자는 생각에 강제 형변환을 시켜 주어진 테스트 케이스는 다 통과 시킨채로 시험을 종료했다.&lt;br /&gt;시험을 종료했을 때, 그래도 1차에 비해 나름 나쁘지 않게 봤다는 생각은 있었지만, 워낙 코테를 잘하는 사람들이 많았기에 불안했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1482&quot; data-origin-height=&quot;236&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/65jxw/dJMcaajAhx6/rHtKJKnaJVLkk1qZ9gJrX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/65jxw/dJMcaajAhx6/rHtKJKnaJVLkk1qZ9gJrX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/65jxw/dJMcaajAhx6/rHtKJKnaJVLkk1qZ9gJrX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F65jxw%2FdJMcaajAhx6%2FrHtKJKnaJVLkk1qZ9gJrX0%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;634&quot; height=&quot;101&quot; data-origin-width=&quot;1482&quot; data-origin-height=&quot;236&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 대망의 20일.. 2차 코딩테스트도 합격하게 되었다!!&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;b&gt;코딩테스트는 진짜 미리 대비해두자!&lt;/b&gt;&lt;/u&gt; 카카오 뿐만 아니라 대부분 기업의 서류 단계 이후의 첫 관문은 코딩 테스트이다. 서류를 통과한 직후 코딩테스트를 부랴부랴 대비하기엔 시간도 매우 빠듯하고 카카오처럼 난이도가 높은 기업이라면 짧은 시간 안에 대비가 불가능하다고 생각한다. 올 한해 2월부터 꾸준히 백준과 프로그래머스를 풀어왔기 때문에 통과할 수 있었다고 생각한다. 본인이 코딩테스트에서 자꾸 떨어진다면 당장의 프로젝트 구현보다는 알고리즘 공부에 집중하는 것이 좋을 것 같다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1차 면접&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차 인터뷰는 직무 인터뷰 위주로 진행된다고 안내를 받았기 때문에 CS 지식과 제출했던 포트폴리오를 기반으로 면접 준비를 했다. 직무 인터뷰였기 때문에 인성 질문은 따로 준비하지 않았고 간단하게 자기소개 정도만 준비를 했다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;일단은 내가 수행했던 프로젝트들과 CS에서는 최대한 깊게 공부하고 되짚어보는 방식으로 대비를 하였다. 나올만한 CS 빈출 질문들을 뽑아서 바로바로 오개념 없이 말할 수 있도록 준비를 했고 포폴의 경우에는 프로젝트에서 &quot;왜&quot; 내가 이런 식으로 설계했는지, 전체적인 나의 사고 과정 흐름에 집중해서 답변을 할 수 있도록 연습했다. 어떠한 문제를 해결하기 위해서 이렇게 리팩토링 또는 최적화를 했는지, 그리고 만약 트래픽 규모가 커진다면 어떤 문제가 발생할 수 있고 또 이를 어떻게 대응할 수 있는지 등 최대한 꼼꼼하게 정리하였고, 그 와중에 CS 지식과 연결지을 수 있다면 최대한 연결 지으면서 답변할 수 있도록 연습을 했다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;전체적인 준비는 노션에 내가 프로젝트에서 구현한 내용, 트라블슈팅 과정들을 기록하면서 정리하며 준비하였다. 다행히 프로젝트 진행 과정에서 기술 블로그를 꾸준히 작성하고 있었기 때문에 당시 나의 사고를 쉽게 복기할 수 있었고 잊어버렸던 개념들에 대해서는 빠르게 보완하면서 대비할 수 있었다.&lt;br /&gt;면접을 준비하면서 노션에 작성했던 답변들을 통으로 암기하려고 하진 않았고, 중요한 키워드 중심으로 오개념 없이 답변하도록 연습을 했다. 예를 들어, SSE 통신 시 Thread Block 여부 -&amp;gt; Tomcat NIO Selector -&amp;gt; Non-Blocking -&amp;gt; I/O Multiplexing 이런 흐름으로 키워드 중심으로 공부를 하고 가장 밑바닥에는 CS 지식을 연결 지으면서 답변할 수 있도록 했다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;그리고 대망의 면접날... 직무기반면접이라는 안내와는 다르게, 인성질문 비중도 굉장히 높았다. 체감상 인성, 직무 질문이 반반씩 나왔다고 느꼈다. 인성질문은 지원동기, 왜 카카오페이인지 그리고 의사소통을 어떻게 하는지를 검증하기 위한 보편적인 질문들이 많았다. 하지만 1차면접에서 인성질문이 나올 것이라는 예상을 전혀 못했기 때문에 즉석에서 답변을 약간 횡설수설(?)하면서 말을 했는데 이를 면접관님들께서 솔직함으로 좋게 봐주신 듯 하다.&lt;br /&gt;기술면접 역시 사실 예상하지 못했던 부분에서 많이 질문이 들어왔다. 가장 최신에 한 프로젝트이자 포폴의 가장 1면에 적혀있는 소마 프로젝트에서 질문이 많이 나올 것이라 생각했는데, 면접관님들께서는 작년에 했던 '한끼족보' 프로젝트에 관심이 더 많으셨다..ㅋㅋ 그래도 내가 어떤 식으로 고민했는지, 어떻게 문제를 해결할 수 있을 지, 내가 아는 범위 내에서 최대한 답하려고 노력했고 또 주어진 상황에 대해 어떤 태도로 접근할 것인지도 솔직하게 말하고 나왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자세한 면접 내용은 대외비이기에,,, 간단하게 언급만 하자면 나의 경우에는 인성 질문 그리고 제출한 포트폴리오 기반의 질문이 주를 이루었고 면접이 끝날때 쯤 잠깐의 2차 코딩테스트 문제 기반의 알고리즘 질문이 나왔다.&lt;br /&gt;신기하게도 나에게는 CS 질문이 하나도 들어오지 않았다. 내심 CS 질문이 들어오길 원했는데, 해주시지 않으셔서 살짝(?) 아쉬웠다. 카카오 본사와 달리 카카오페이만의 면접 특징인지 아니면 나에게만 특수했던 상황인지는 잘 모르겠지만, CS 공부도 미리미리 해두자! 결국 기술을 깊게 파다보면 그 끝은 CS로 귀결되더라... 특히 올해 소마 프로젝트를 하면서 CS의 중요성을 정말 많이 깨달았다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;1차 면접 결과는 언제 발표되는지 안내되어 있지 않았기 때문에, 평소처럼 생활하다가 갑작스럽게 합격 메일을 받게 되었다.&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;1442&quot; data-origin-height=&quot;206&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4VbJH/dJMcaacOSPH/KaXKJyqj3tMkVvjJKMeIgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4VbJH/dJMcaacOSPH/KaXKJyqj3tMkVvjJKMeIgK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4VbJH/dJMcaacOSPH/KaXKJyqj3tMkVvjJKMeIgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4VbJH%2FdJMcaacOSPH%2FKaXKJyqj3tMkVvjJKMeIgK%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;664&quot; height=&quot;95&quot; data-origin-width=&quot;1442&quot; data-origin-height=&quot;206&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정말 예상하지 못했던 날짜에 합격 메일을 받게되었고 때문인지 정말 기뻤다. 사실 답변에 있어서 부족함도 많았고 시간이 지날수록 아쉬움도 커졌기 때문에 기대를 어느정도 내려놓고 있었던 상황이었다.&lt;br /&gt;정말 마지막 관문만 남았구나 라는 생각과 함께 지금까지 내가 해왔던 개발이 틀리지 않았구나 라는 확신도 어느정도 얻게 되었던 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2차 면접&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오페이의 경우에는 1차 면접 합격 발표가 난 후, 2차 면접까지 2주라는 굉장히 긴 텀이 존재했다. &lt;s&gt;솔직히 기다리면서 너무 힘들었다...ㅎ&lt;/s&gt; 2차 면접의 경우에는 인터뷰가 어떤 식으로 진행되는지에 관한 안내가 딱히 존재하지 않았다. 하지만, 이전의 카카오 신입 공채 후기들을 읽어보았을 때, 2차 면접은 컬쳐핏 위주의 면접일 것이라 생각했다. 때문에 2차 면접 대비는 기술 공부보다는 지금까지의 내 협업과 내 개발 태도를 정말 깊이 되돌아보는 시간을 가졌다. 2차에도 물론 기술 질문이 나올 수는 있기 때문에 1차때 정리했던 노션을 계속 읽어보았고 또 1차 면접때 스스로 부족했다고 느꼈던 부분들에 대해서 다시 복습하고 보충하는 식으로 기술 면접을 준비했다. 다만, 기술보다는 컬쳐핏에 비중을 두어 나는 어떤 개발자가 되고 싶은지, 또 어떨 때 성취감을 느끼고 성장에 대한 동기부여를 얻는지 스스로를 돌아보는 시간에 더 많이 쏟았다. 2차 면접의 경우에는 진솔됨이 가장 중요하다고 생각했기 때문에 답변은 딱히 준비하지는 않았고, 그저 내 경험과 스스로를 돌아보는데만 집중을 했다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;2차 면접의 경우 CTO 1분과 HR 1분 이렇게 2:1로 면접이 진행되었다. 역시 예상대로 아주 잠깐의 기술 질문과 그 이후에는 내가 어떻게 공부하고 협업하는지, 컬쳐핏 위주의 질문들이 많이 나왔다. 후회없이 내 가치관을 솔직하게 말씀드렸고 또 전날에 잠을 거의 못 잔 영향인지.. 긴장을 많이 하지 않고 편안하게 답변을 할 수 있었다. 면접이 끝났을 때는 드디어 이 기나긴 전형이 끝이 났구나 라는 후련함이 가장 컸었던 거 같다. ㅋㅋ&lt;br /&gt;그리고 대망의 17일.. 메일 한통이 날라오는데..&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1406&quot; data-origin-height=&quot;144&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dUpEj7/dJMcacIt2zT/WJmii3xOjkvMcHi86OUudk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dUpEj7/dJMcacIt2zT/WJmii3xOjkvMcHi86OUudk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dUpEj7/dJMcacIt2zT/WJmii3xOjkvMcHi86OUudk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdUpEj7%2FdJMcacIt2zT%2FWJmii3xOjkvMcHi86OUudk%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;1406&quot; height=&quot;144&quot; data-origin-width=&quot;1406&quot; data-origin-height=&quot;144&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;긴장되는 가슴을 부여잡고 메일을 확인하자마자 너무 기뻐서 소리 지렀다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 합격&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YbfYq/dJMcac9u9gx/iDgvMTKGTeEs3t5Kpplhbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YbfYq/dJMcac9u9gx/iDgvMTKGTeEs3t5Kpplhbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YbfYq/dJMcac9u9gx/iDgvMTKGTeEs3t5Kpplhbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYbfYq%2FdJMcac9u9gx%2FiDgvMTKGTeEs3t5Kpplhbk%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;2914&quot; height=&quot;1640&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1640&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;사실 아직도 믿기지가 않는다.&lt;/s&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2025년을 마무리하며...&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2025년을 돌아보면, 사실 정말 힘들었고 마음고생도 많았던 한 해였다. 취업 준비를 본격적으로 시작하면서 최종 탈락은 물론 전환 탈락까지 겪었고, 그 과정에서 후반부에는 스트레스가 극에 달했었다. 자존감은 많이 낮아졌고, 무엇보다 스스로에 대한 믿음이 크게 흔들렸던 시간이었다. 하지만, 결국 이렇게 가장 간절하게 원했던 카카오 그룹사 신입공채에 합격하게 되어 너무 기쁘다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;입사 전까지는 마음껏 쉬고, 신나게 놀 생각이다. 지금까지 열심히 달려온 나에게 고생했다고 말해주고 싶다.&lt;/p&gt;</description>
      <category>끄적끄적</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/77</guid>
      <comments>https://wing1008.tistory.com/77#entry77comment</comments>
      <pubDate>Tue, 23 Dec 2025 15:23:18 +0900</pubDate>
    </item>
    <item>
      <title>B+Tree</title>
      <link>https://wing1008.tistory.com/72</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;B-Tree&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B-Tree는 하나의 노드에 여러 데이터가 저장될 수 있다. 따라서 같은 노드 내에서 데이터를 탐색할 때는 포인터를 따라가는 방식이 아니라, 디스크(또는 메모리)에 연속적으로 저장된 인덱스를 순차적으로 확인하는 방식으로 탐색이 이루어진다. 이때, 특정 노드의 데이터(key)가 K개라면, 자식 노드의 개수는 K+1개여야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;특징&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1001&quot; data-origin-height=&quot;471&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Y2UC9/btsQXZKk7I9/dDgAyGKEYa0Au7s6ArdDL1/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Y2UC9/btsQXZKk7I9/dDgAyGKEYa0Au7s6ArdDL1/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Y2UC9/btsQXZKk7I9/dDgAyGKEYa0Au7s6ArdDL1/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FY2UC9%2FbtsQXZKk7I9%2FdDgAyGKEYa0Au7s6ArdDL1%2Fimg.webp&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;1001&quot; height=&quot;471&quot; data-origin-width=&quot;1001&quot; data-origin-height=&quot;471&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 위의 B-Tree 구조에서 33이라는 값이 존재하는지 확인한다고 가정해보자. 루트 노드에는 20, 40이 저장되어 있는 상태이기 때문에 루트 노드에는 해당 값이 존재하지 않아 자식 노드를 가리키는 포인터의 존재 유무를 확인한다. 20과 40 사이의 범위에 해당하는 자식 노드를 가리키는 포인터가 있는 것을 확인했다면, 포인터를 따라가 자식 노드에 도달한다. 자식 노드에 존재하는 30, 33 값들은 디스크 상에서 순차적으로 배치되어 있기 때문에 빠르게 탐색할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B-Tree는 트리가 편향되지 않도록 항상 균형을 유지한다. 즉, 모든 리프 노드들은 같은 레벨에 존재해야하는 Balanced Tree이다. 때문에 최악의 경우에도 O(logN)의 시간 복잡도가 보장된다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;B+ Tree&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;B+ Tree는 인덱스 구현에 널리 사용되는 자료구조로, 기존 B-Tree를 확장한 형태이다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;690&quot; data-origin-height=&quot;417&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MaDJJ/btsQZRxa6Gi/HyMsLF3XCefaJhbGNStmpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MaDJJ/btsQZRxa6Gi/HyMsLF3XCefaJhbGNStmpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MaDJJ/btsQZRxa6Gi/HyMsLF3XCefaJhbGNStmpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMaDJJ%2FbtsQZRxa6Gi%2FHyMsLF3XCefaJhbGNStmpk%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;690&quot; height=&quot;417&quot; data-origin-width=&quot;690&quot; data-origin-height=&quot;417&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;B+ Tree는 데이터베이스 인덱스 구현에 널리 사용되는 자료구조로, 기존 B-Tree를 확장한 형태이다. 가장 큰 특징은 모든 실제 데이터가 리프 노드에만 저장된다는 점이다.&amp;nbsp; 반면, 리프 코드가 아닌 내부 노드(Interval Node)에는 데이터를 찾기 위한 키(Key)와 자식 노드를 가리키는 포인터만 저장된다. 이러한 구조 덕분에 메모리 사용 효율이 높으며, 또한 리프 노드들이 연결 리스트로 서로 연결되어 있기 때문에 범위 검색 성능이 우수하다. 다만, 탐색을 위해서는 무조건 리프노드까지 내려가야한다는 단점도 존재한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;삽입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B+ Tree에서 데이터 삽입은 항상 &lt;b&gt;리프 노드&lt;/b&gt;에서 이루어지며, 삽입 시 노드는 항상 &lt;b&gt;정렬된 상태&lt;/b&gt;를 유지한다. 만약 삽입으로 인해 노드가 넘치게 되면, 가운데 Key를 기준으로 노드를 좌우로 분할하고, 가운데 Key는 부모 노드로 승격된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;685&quot; data-origin-height=&quot;627&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vVirU/btsQYdH4la2/ZvGrXmvWH5IK5DzO9VJDeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vVirU/btsQYdH4la2/ZvGrXmvWH5IK5DzO9VJDeK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vVirU/btsQYdH4la2/ZvGrXmvWH5IK5DzO9VJDeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvVirU%2FbtsQYdH4la2%2FZvGrXmvWH5IK5DzO9VJDeK%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;685&quot; height=&quot;627&quot; data-origin-width=&quot;685&quot; data-origin-height=&quot;627&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 값 45를 삽입한다고 가정해 보자. 루트 노드에 15, 25가 저장되어 있다면, 45는 이보다 큰 값이므로 가장 오른쪽 자식 노드로 내려간다. 그러나 이 노드에 삽입할 경우 노드가 넘치게 되므로, 가운데 Key인 35를 부모 노드로 승격시킨다. 이때 35가 루트로 올라가면서 루트 노드 역시 넘치게 되고, 다시 가운데 Key인 25가 승격된다. 이처럼 삽입 과정에서는 연쇄적인 분할과 승격이 발생할 수 있으며, 이를 통해 트리의 균형이 유지된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;삭제&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;리프노드에만 존재하며 재조정도 필요 없는 경우&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;583&quot; data-origin-height=&quot;490&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MK0yI/btsQYQflzAS/MourK25neP8Y8TJ8vGe6Nk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MK0yI/btsQYQflzAS/MourK25neP8Y8TJ8vGe6Nk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MK0yI/btsQYQflzAS/MourK25neP8Y8TJ8vGe6Nk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMK0yI%2FbtsQYQflzAS%2FMourK25neP8Y8TJ8vGe6Nk%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;583&quot; height=&quot;490&quot; data-origin-width=&quot;583&quot; data-origin-height=&quot;490&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리프 노드에 존재하는 값인 40을 삭제한 후에도 최소 개수 조건을 만족하므로 재조정이 일어나지 않는다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;리포노드에만 존재하고 재조정이 필요한 경우&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;557&quot; data-origin-height=&quot;734&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lhf4F/btsQXOBNcr8/TcrbkEHZCckZTaeBrzs8eK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lhf4F/btsQXOBNcr8/TcrbkEHZCckZTaeBrzs8eK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lhf4F/btsQXOBNcr8/TcrbkEHZCckZTaeBrzs8eK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flhf4F%2FbtsQXOBNcr8%2FTcrbkEHZCckZTaeBrzs8eK%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;557&quot; height=&quot;734&quot; data-origin-width=&quot;557&quot; data-origin-height=&quot;734&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B+ Tree에서 값을 삭제할 때는 항상 최소 개수 조건을 만족해야 한다. 그러나 예를 들어 값 5를 삭제하는 순간 해당 노드가 최소 개수 조건을 만족하지 못한다면 트리의 균형을 유지하기 위해 재조정이 이루어진다. 각 노드가 가져야 하는 최소 Key의 개수는, 노드의 최대 자식 수를 M이라고 했을 때 &amp;lceil;M/2&amp;rceil; - 1로 정의된다.&lt;/p&gt;
&lt;p data-end=&quot;533&quot; data-start=&quot;250&quot; data-ke-size=&quot;size16&quot;&gt;재조정 과정은 크게 두 단계로 나뉜다. 먼저, 키 수가 여유 있는 형제 노드로부터 도움을 받는다. 일반적으로 왼쪽 형제 노드부터 확인하여 여유 키가 있으면 하나를 빌려오고, 그렇지 않다면 오른쪽 형제 노드에서 빌려온다. 만약 형제 노드 모두 최소 조건만을 만족하고 있어 빌릴 수 없는 경우에는 부모 노드의 분리자 키를 내려받고, 현재 노드와 형제 노드를 하나로 병합한다. 이 과정에서 부모 노드의 키가 줄어들게 되며, 부모 노드 역시 최소 조건을 만족하지 못한다면 동일한 재조정 과정이 상위로 전파된다.&lt;/p&gt;
&lt;p data-end=&quot;718&quot; data-start=&quot;535&quot; data-ke-size=&quot;size16&quot;&gt;현재 상황에서는 삭제된 값 5가 가장 왼쪽 노드에 존재했기 때문에, 왼쪽 형제가 없어 오른쪽 형제 노드의 도움을 받게 된다. 이처럼 B+ Tree의 삭제 과정은 단순히 값을 제거하는 데서 끝나지 않고, 최소 개수 조건을 만족하도록 형제 노드와 부모 노드까지 포함한 연쇄적인 재조정 과정을 거쳐 트리의 균형을 유지한다.&lt;/p&gt;
&lt;h4 data-end=&quot;718&quot; data-start=&quot;535&quot; data-ke-size=&quot;size20&quot;&gt;삭제 대상이 내부 노드의 인덱스이고 재조정이 필요하지 않는 경우&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;785&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oFCuJ/btsQZxZ52pT/LDOX6Nz7SAykXZIsf6JGOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oFCuJ/btsQZxZ52pT/LDOX6Nz7SAykXZIsf6JGOk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oFCuJ/btsQZxZ52pT/LDOX6Nz7SAykXZIsf6JGOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoFCuJ%2FbtsQZxZ52pT%2FLDOX6Nz7SAykXZIsf6JGOk%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;500&quot; height=&quot;785&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;785&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 그림에서 값 45를 삭제하는 경우를 살펴보자. 45가 삭제되더라도 이는 맨 오른쪽 리프 노드에서 발생하므로 해당 노드는 여전히 최소 개수 조건을 만족한다. 따라서 별도의 재조정 과정은 필요하지 않다.&lt;/p&gt;
&lt;p data-end=&quot;346&quot; data-start=&quot;157&quot; data-ke-size=&quot;size16&quot;&gt;다만 주의할 점은, 45가 내부 노드(Interval Node) 의 인덱스로도 사용되고 있었다는 것이다. 이 경우 삭제된 45 대신 같은 노드 안에 존재하는 다음 Key인 55가 새로운 인덱스로 올라가게 된다. 즉, 리프 노드의 데이터 삭제는 트리 구조 자체를 흔들지는 않지만, 내부 노드의 인덱스 값은 변경될 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;346&quot; data-start=&quot;157&quot; data-ke-size=&quot;size16&quot;&gt;정리하면, 삭제된 키가 내부 노드의 인덱스였을 경우, 그 인덱스는 해당 리프 노드의 새로운 최소값으로 교체된다. 만약 해당 리프 노드가 완전히 비거나 병합되는 상황이라면, 인접 리프 노드의 최소값이 새로운 인덱스로 사용된다.&lt;/p&gt;
&lt;h4 data-end=&quot;346&quot; data-start=&quot;157&quot; data-ke-size=&quot;size20&quot;&gt;삭제 대상이 내부 노드의 인덱스이고&amp;nbsp;재조정이 필요한 경우&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;449&quot; data-origin-height=&quot;964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvtIlc/btsQ1kZX20v/WMR5DKl5uNYYf5D86iW25K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvtIlc/btsQ1kZX20v/WMR5DKl5uNYYf5D86iW25K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvtIlc/btsQ1kZX20v/WMR5DKl5uNYYf5D86iW25K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvtIlc%2FbtsQ1kZX20v%2FWMR5DKl5uNYYf5D86iW25K%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;449&quot; height=&quot;964&quot; data-origin-width=&quot;449&quot; data-origin-height=&quot;964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값 25를 삭제하는 상황을 가정해보자. 25를 삭제하면 해당 노드가 최소 개수 조건을 만족하지 않게 되므로 트리의 균형을 맞추기 위한 재조정이 필요하다. 먼저 형제 노드로부터 키를 빌려서 문제를 해결하려 시도하지만, 형제 노드 역시 여유 키가 충분하지 않으므로 병합(merge)을 수행하게 된다.&lt;/p&gt;
&lt;p data-end=&quot;372&quot; data-start=&quot;238&quot; data-ke-size=&quot;size16&quot;&gt;이때 삭제된 값이 내부 노드의 인덱스였던 경우, 그 자리는 중위 계승자(inorder successor)로 대체되어 내부 노드의 키를 유지한다. 이렇게 연쇄적인 병합과 계승자 대체 과정을 거치면서 B+ Tree의 균형이 유지된다.&lt;/p&gt;
&lt;h2 data-end=&quot;372&quot; data-start=&quot;238&quot; data-ke-size=&quot;size26&quot;&gt;번외) InnoDB에서 세컨더리 인덱스가 프라이머리 키를 저장하는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InnoDB 테이블의 모든 세컨더리 인덱스는 레코드의 물리적 주소가 아니라 프라이머리 키 값을 저장하도록 구현되어 있다. 그 이유를 이해하려면 먼저 InnoDB의 구조를 살펴볼 필요가 있다.&lt;/p&gt;
&lt;p data-end=&quot;479&quot; data-start=&quot;316&quot; data-ke-size=&quot;size16&quot;&gt;InnoDB 테이블은 클러스터드 인덱스(Primary Key 기반 B+Tree) 구조로 저장된다. 각 데이터 레코드는 클러스터드 인덱스에 따라 정렬되고, 데이터 자체가 인덱스 노드에 포함된다. 즉, 프라이머리 키를 기준으로 레코드가 실제 디스크 상의 페이지 단위로 배치된다.&lt;/p&gt;
&lt;p data-end=&quot;700&quot; data-start=&quot;481&quot; data-ke-size=&quot;size16&quot;&gt;이때 세컨더리 인덱스를 설계할 때 레코드의 물리 주소를 저장한다고 가정해보자. 만약 클러스터드 테이블의 데이터가 이동하면, 즉 레코드가 저장된 물리 페이지가 바뀌거나 페이지 스플릿(split)이 발생하면, 해당 레코드의 주소가 바뀌게 된다. 이렇게 되면 세컨더리 인덱스에 저장된 주소값도 모두 갱신해야 한다. 이는 테이블에 세컨더리 인덱스가 많을수록 매우 큰 오버헤드를 발생시킨다.&lt;/p&gt;
&lt;p data-end=&quot;896&quot; data-start=&quot;702&quot; data-ke-size=&quot;size16&quot;&gt;실제로 InnoDB에서는 페이지가 가득 찼을 때 B+Tree의 삽입 과정과 유사하게 &lt;b&gt;페이지 분할(Page Split)&lt;/b&gt;이 발생한다. 기존 페이지의 절반 정도 레코드가 새 페이지로 이동하게 되면서, 물리 주소가 바뀌고, 만약 세컨더리 인덱스가 주소값을 직접 참조하고 있었다면 모든 관련 인덱스를 갱신해야 한다. 이는 성능상 매우 부담스럽다.&lt;/p&gt;
&lt;p data-end=&quot;1132&quot; data-start=&quot;898&quot; data-ke-size=&quot;size16&quot;&gt;반면, 프라이머리 키 값은 고유하며 변동이 거의 없기 때문에 세컨더리 인덱스가 이를 참조하도록 설계하면 주소값 변경 문제를 피할 수 있다. 세컨더리 인덱스에서 프라이머리 키를 통해 클러스터드 인덱스를 조회하면, 항상 최신 레코드 위치를 찾을 수 있다. 즉, 세컨더리 인덱스가 주소 대신 프라이머리 키를 저장하는 것은 InnoDB의 클러스터드 구조와 페이지 단위 레코드 이동을 고려한 설계상의 선택이라고 볼 수 있다.&lt;/p&gt;</description>
      <category>Computer Science/자료구조&amp;amp;알고리즘</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/72</guid>
      <comments>https://wing1008.tistory.com/72#entry72comment</comments>
      <pubDate>Wed, 1 Oct 2025 21:39:01 +0900</pubDate>
    </item>
    <item>
      <title>커스텀 메트릭으로 AI 응답 품질 판별 기준을 만들어보자</title>
      <link>https://wing1008.tistory.com/71</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Edukit 서비스는 교사들을 위해 AI를 활용하여 학생부 작성 업무를 돕는 서비스다. 하지만 이 서비스를 만드는 구성원은 현직 교사가 아닌 개발자 3명이었기 때문에, AI가 생성하는 응답의 품질을 명확하게 판단하기 어려웠다. 이를 보완하기 위해 설문조사를 통해 만족도를 수집했지만, 운영 서버에 새 기능을 배포할 때마다 질문을 초기화해야 하는 번거로움이 있었고, 사용자 수에 비해 응답률도 높지 않아 한계가 있었다. 이러한 상황이 반복되면서 서비스 개선 과정에서도 응답 품질에 대한 확신을 점점 잃게 되었다.&lt;br /&gt;그러던 중 소마 전담 멘토링을 통해&amp;nbsp;&lt;b&gt;커스텀 메트릭&lt;/b&gt;이라는 개념을 알게 되었고, 이를 활용하면 실제 사용 패턴을 기반으로 AI 응답 품질을 정량적으로 확인할 수 있지 않을까? 라는 생각을 하게 되었다. 본 글에서는 이러한 기능을 추가한 과정을 다뤄보고자 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;커스텀 메트릭 적용&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;794&quot; data-origin-height=&quot;1110&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/G3i51/btsQIJzduuf/vf3IOResvRHQJp6GEAWpgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/G3i51/btsQIJzduuf/vf3IOResvRHQJp6GEAWpgK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/G3i51/btsQIJzduuf/vf3IOResvRHQJp6GEAWpgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FG3i51%2FbtsQIJzduuf%2Fvf3IOResvRHQJp6GEAWpgK%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;794&quot; height=&quot;1110&quot; data-origin-width=&quot;794&quot; data-origin-height=&quot;1110&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스는 위와 같이 각 생기부 작성 항목 별로 학생의 특성을 입력하고 생성 버튼을 누르면 3가지 버전의 AI 생성 응답 값을 제공하고 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2316&quot; data-origin-height=&quot;710&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blfoqS/btsQIBnDxRv/paYueOscOyPhmhDcB7K17k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blfoqS/btsQIBnDxRv/paYueOscOyPhmhDcB7K17k/img.png&quot; data-alt=&quot;실제 최종본을 저장하는 부분&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blfoqS/btsQIBnDxRv/paYueOscOyPhmhDcB7K17k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblfoqS%2FbtsQIBnDxRv%2FpaYueOscOyPhmhDcB7K17k%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;2316&quot; height=&quot;710&quot; data-origin-width=&quot;2316&quot; data-origin-height=&quot;710&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;그리고 이렇게 생성된 3가지 버전 중 마음에 드는 문장이나 버전을 선택해서 사용자가 해당 학생에 대한 최종본을 저장할 수 있는 플로우이다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;첫번째 가설&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&amp;lt;가설&amp;gt;&lt;br /&gt;AI 응답 품질이 좋을수록 API 실제 호출 대비 실제 응답 완성률이 높을 것이다.&lt;br /&gt;&lt;br /&gt;&amp;lt;측정 방법&amp;gt;&lt;br /&gt;완성률 = (최종 저장 완료 수) / (AI 생성 요청 수) &amp;times; 100%&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI를 이용하여 생성 요청을 보낸 호출 횟수 대비 실제 최종본을 저장하는 API 호출 응답 횟수가 적다면, AI 응답 품질이 좋지 않다는 가설을 세웠고 따라서 AI 생성 API 응답 호출과 생기부 최종 완료 저장본 개수를 커스텀 메트릭으로 추가하여 완성율을 계산한 뒤, 그라파나 대시보드에 시각화하였다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;구현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커스텀 메트릭은 비즈니스 로직과는 분리해서 구현하는 것이 유지보수 성과 코드 가독성 면에서 좋을 것이라 판단하였고 따라서 AOP를 활용하여 비즈니스 로직과 부가 로직이 명확히 구분되도록 분리하였다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class StudentRecordMetricsAspect {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final MeterRegistry meterRegistry;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final String COMPLETION_METRIC = &quot;student_record_completion_total&quot;;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final String AI_GENERATION_REQUEST_METRIC = &quot;student_record_ai_generation_requests_total&quot;;

&amp;nbsp;&amp;nbsp; 	public void recordCompletion(final StudentRecordType type, final String description) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (isCompleted(type, description)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;meterRegistry.counter(COMPLETION_METRIC,
&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;&quot;type&quot;, type.name(), &quot;action&quot;, &quot;completion&quot;)
&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;.increment();
&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;public void recordAIGenerationRequest(final StudentRecordType type) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;meterRegistry.counter(AI_GENERATION_REQUEST_METRIC,
&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;&quot;type&quot;, type.name(), &quot;action&quot;, &quot;ai_generation&quot;)
&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;.increment();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean isCompleted(final StudentRecordType type, final String description) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (description == null || description.trim().isEmpty()) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return false;
&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;int minBytes = (type == StudentRecordType.SUBJECT) ? 1000 : 750;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return description.getBytes(StandardCharsets.UTF_8).length &amp;gt;= minBytes;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;단순히 사용자의 저장 시도만 카운트하는 방식으로는 실제로 의미 있는 지표를 얻기 어렵다고 판단하였다. 사용자가 AI가 생성한 결과를 단순히 확인만 하고 저장하지 않을 수도 있고, 의미 없는 내용을 입력한 뒤 저장하는 경우도 존재하기 때문이다.&lt;br /&gt;이를 보완하기 위해 updateStudentRecord()와 같은 저장 메서드에 커스텀 메트릭 수집 로직을 적용하였다. 다만 저장된 최종본의 바이트 수가 기준 바이트 수의 절반을 넘지 못한다면, 유효하지 않은 저장으로 판단하여 지표에서 제외하였다. 구체적으로 일반 항목은 기준 바이트 수를 1500바이트로 설정하고, 이의 절반인 750바이트를 최소 유효 기준으로 삼았다. 반면 SUBJECT(세부능력특기사항) 항목의 경우 기본 바이트 수 제한이 2000바이트이므로, 기준을 1000바이트로 설정하였다.&lt;br /&gt;최종적으로 완성률 계산식은 다음과 같이 정의된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;(student_record_completion_total / student_record_ai_generation_requests_total) * 100&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 student_record_ai_generation_requests_total은 AI가 생성 요청을 받은 횟수이고, student_record_completion_total은 실제로 데이터베이스에 최종본을 저장한 횟수다. 예를 들어 AI 생성 요청이 총 100번 있었고, 그 중 70번은 사용자가 유요한 저장을 시도했으면 응답 완성률은 70%인 것이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1364&quot; data-origin-height=&quot;614&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bOuOtS/btsQI6gRN0Z/KdQmmMrHKtHrSlIsBnR5rk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bOuOtS/btsQI6gRN0Z/KdQmmMrHKtHrSlIsBnR5rk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bOuOtS/btsQI6gRN0Z/KdQmmMrHKtHrSlIsBnR5rk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbOuOtS%2FbtsQI6gRN0Z%2FKdQmmMrHKtHrSlIsBnR5rk%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;1364&quot; height=&quot;614&quot; data-origin-width=&quot;1364&quot; data-origin-height=&quot;614&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;두번째 가설&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&amp;lt;가설&amp;gt;&lt;br /&gt;AI 품질이 좋을수록 재생성 요청 비율이 낮을 것이다.&lt;br /&gt;&lt;br /&gt;&amp;lt;측정 방법&amp;gt;&lt;br /&gt;- 동일 recordId에 대한 연속 AI 생성 요청 횟수 &lt;br /&gt;- 재생성률 = (재생성 요청 수) / (첫 생성 요청 수)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 자신이 작성하고자 하는 페이지에 들어가 AI 요청을 한다. 이때, 해당 요청이 같은 recordId로&amp;nbsp; 요청된다면, 이는 재요청을 보냈음을 의미한다. 여기서 만약 같은 생기부 항목에 대해 재요청 횟수가 많다면 이는 곧 AI 응답 품질이 마음에 들지 않았기 떄문이라는 가설을 세웠다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;구현&lt;/h4&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public void recordFirstGeneration(final StudentRecordType type) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;meterRegistry.counter(AI_FIRST_GENERATION_METRIC,
&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;&quot;type&quot;, type.name(), &quot;action&quot;, &quot;first_generation&quot;)
&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;.increment();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void recordRegeneration(final StudentRecordType type) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;meterRegistry.counter(AI_REGENERATION_METRIC,
&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;&quot;type&quot;, type.name(), &quot;action&quot;, &quot;regeneration&quot;)
&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;.increment();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Around(&quot;@annotation(com.edukit.common.annotation.AIGenerationMetrics)&quot;)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public Object collectAIGenerationMetrics(final ProceedingJoinPoint joinPoint) throws Throwable {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Object[] args = joinPoint.getArgs();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (args.length &amp;gt;= 3) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;long recordId = (Long) args[1];
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;StudentRecordType recordType = (StudentRecordType) args[2];

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;try {
&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;boolean isFirstGeneration = generationTrackingService.isFirstGeneration(recordId);

&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;// 전체 AI 생성 요청 카운트
&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;metricsService.recordAIGenerationRequest(recordType);

&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;// 첫 생성 vs 재생성 구분 메트릭
&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;if (isFirstGeneration) {
&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;metricsService.recordFirstGeneration(recordType);
&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;log.debug(&quot;First generation request for recordId: {}&quot;, recordId);
&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;} else {
&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;metricsService.recordRegeneration(recordType);
&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;log.debug(&quot;Regeneration request for recordId: {}&quot;, recordId);
&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;} catch (Exception e) {
&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;log.warn(&quot;Error collecting AI generation metrics for recordId: {}&quot;, recordId, e);
&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;return joinPoint.proceed();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;트라블 슈팅&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재요청 비율을 집계하기 위해서는 같은 recordId에 대해 생성 요청이 들어왔을 때, 그 요청이 첫번째 요청인지 재요청인지를 판별해야하는 것이 필수적이었다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final ConcurrentHashMap&amp;lt;Long, GenerationInfo&amp;gt; generationCounts = new ConcurrentHashMap&amp;lt;&amp;gt;();

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public boolean isFirstGeneration(long recordId) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;GenerationInfo info = generationCounts.compute(recordId, (key, existing) -&amp;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;if (existing == null) {
&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;return new GenerationInfo(1, LocalDateTime.now());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&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;existing.incrementCount();
&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;return existing;
&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;boolean isFirst = info.getCount() == 1;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.debug(&quot;RecordId: {}, Generation count: {}, Is first: {}&quot;, recordId, info.getCount(), isFirst);

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return isFirst;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 요청으로 들어온 recordId를 키로 하여 ConcurrentHashMap에 저장하고, 해당 키가 이미 존재하면 재요청으로, 존재하지 않으면 최초 요청으로 판별하는 방식으로 로직을 구현했다. 그러나 배포 후 확인해 보니 재요청 비율이 정상적으로 집계되지 않는 문제가 발생했다. 원인은 서비스가 API 서버 2대에서 동작하는 분산 환경이었기 때문이다. 같은 recordId에 대한 두 번째 요청이라 하더라도, 첫 번째 요청과 다른 서버로 전달된다면 해당 서버의 메모리에 있는 ConcurrentHashMap에는 해당 키가 존재하지 않는다. 그 결과 서버는 이를 다시 최초 요청으로 잘못 판별하게 되어, 집계가 올바르게 이루어지지 않았다. 이를 위해 생각했던 방안은 아래와 같았다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;1. DB에 테이블을 따로 만들어 횟수를 추적하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 해결책은 DB에 횟수 추적용 테이블을 두고, 요청이 들어올 때마다 해당 레코드에 X-Lock을 걸어 카운트를 집계하는 방식이었다. 하지만 우리 서비스는 특정 기간에 AI 생기부 생성 요청이 집중되는 특성이 있어, 요청마다 DB를 직접 갱신하고 조회하는 방식은 큰 부하를 유발할 수 있다고 판단했다. 또한, 카운트를 추적하기 위해 커넥션 풀과 Lock을 점유하면 다른 I/O 작업까지 지연될 수 있어, 결국 전체 API 응답 속도가 저하될 우려가 있어 이 방안은 기각했다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. Redis 활용 - 분산락 기반 카운팅&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단일 인스턴스 메모리 기반 카운팅은 분산 환경에서 정확한 집계를 보장하지 못하므로,&amp;nbsp; 중앙집중형 카운터를 사용해 모든 인스턴스가 동일한 카운터를 공유하도록 했다. 가장 단순한 방법은 Redis의 원자적 `INCR` 연산으로 카운트를 증가시키는 것이다. 이때, 사용이 끝난 키를 자동으로 삭제하여 메모리를 관리하기 위해 EXPIRE를 설정해 두었다. 인터뷰와 설문조사를 통해 선생님들이 하나의 생기부 항목을 작성하는데 평균 1~3일 정도가 걸린다는 점을 반영하여 TTL은 3일로 설정하였으며 해당 기간이 지나면 집계가 자동 초기화될 수 있도록 구현하였다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;newCount = INCR(key)
if newCount == 1:
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;EXPIRE(key, ttl)
return newCount == 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 이렇게 PR을 올라고 보니 CodeRabbit이 아래와 같은 리뷰를 남겨주었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;790&quot; data-origin-height=&quot;477&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zzojY/btsQG2UwmPk/aS21YGzBj5FWnjuK7QVsi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zzojY/btsQG2UwmPk/aS21YGzBj5FWnjuK7QVsi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zzojY/btsQG2UwmPk/aS21YGzBj5FWnjuK7QVsi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzzojY%2FbtsQG2UwmPk%2FaS21YGzBj5FWnjuK7QVsi0%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;790&quot; height=&quot;477&quot; data-origin-width=&quot;790&quot; data-origin-height=&quot;477&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 리뷰를 간단히 요약하면, INCR와 EXPIRE를 한 번의 원자적 실행으로 묶어야 안전하다는 것이다. 지금의 로직과 같이,&amp;nbsp;두&amp;nbsp;연산을&amp;nbsp;따로&amp;nbsp;실행하면&amp;nbsp;어떤&amp;nbsp;문제가&amp;nbsp;생길&amp;nbsp;수&amp;nbsp;있을까?&amp;nbsp;&amp;nbsp;&lt;br /&gt;예를 들어, 클라이언트가 `INCR` 요청을 보내고 정상 응답을 받았다. 하지만 직후 네트워크가 끊겨 `EXPIRE` 명령이 Redis에 도달하지 못한다면 어떻게 될까? 이 경우 해당 키에는 TTL이 설정되지 않아 영구 키로 남게 되어 메모리 공간을 영구히 차지하고, 집계가 계속 누적되어 버리는 문제가 발생하게 된다. 비슷하게,`INCR`는 성공했지만 프로세스가 재시작되어 `EXPIRE` 호출이 수행되지 못하는 경우에도 같은 문제가 발생한다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;이 문제를 해결하기 위해 `INCR`과 `EXPIRE`를 Redis 서버 내에서 단일 원자적 명령으로 실행하도록 Lua 스크립트를 적용했다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;private static final String LUA_SCRIPT =
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;local ttl = tonumber(ARGV[1]) or 0 &quot; +
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;local c = redis.call('INCR', KEYS[1]) &quot; +
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;if c == 1 and ttl &amp;gt; 0 then &quot; +
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;&amp;nbsp;&amp;nbsp;redis.call('EXPIRE', KEYS[1], ttl) &quot; +
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;end &quot; +
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;return c&quot;;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private static final DefaultRedisScript&amp;lt;Long&amp;gt; INCR_EXPIRE_SCRIPT;


&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;static {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;DefaultRedisScript&amp;lt;Long&amp;gt; script = new DefaultRedisScript&amp;lt;&amp;gt;();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;script.setScriptText(LUA_SCRIPT);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;script.setResultType(Long.class);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;INCR_EXPIRE_SCRIPT = script;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private final StringRedisTemplate redisTemplate;

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public boolean isFirstGeneration(long recordId) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;String key = getCountKey(recordId);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Long newCount = redisTemplate.execute(
&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;INCR_EXPIRE_SCRIPT,
&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;Collections.singletonList(key),
&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;String.valueOf(ttlSeconds)
&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;if (newCount == null) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.warn(&quot;Redis script returned null for key: {}&quot;, key);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return false;
&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;boolean isFirst = newCount == 1L;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (isFirst) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.debug(&quot;RecordId: {}, Generation count set to 1 (first)&quot;, recordId);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.debug(&quot;RecordId: {}, Generation count: {} (regeneration)&quot;, recordId, newCount);
&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;return isFirst;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lua 스크립트는 서버에서 하나의 트랜잭션처럼 실행되므로, &amp;ldquo;스크립트 전송 &amp;rarr; 서버에서 `INCR + EXPIRE` 실행 &amp;rarr; 응답 수신&amp;rdquo; 전체 과정이 원자적으로 보장된다. 이 방식으로 `EXPIRE` 누락 문제를 방지할 수 있게 되었으며 분산 환경에서도 정확한 카운팅이 가능하도록 구현하였다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결과&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1364&quot; data-origin-height=&quot;750&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qsoka/btsQGZKbxJn/PvYRRB3FhNNHpDiKCW7zt0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qsoka/btsQGZKbxJn/PvYRRB3FhNNHpDiKCW7zt0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qsoka/btsQGZKbxJn/PvYRRB3FhNNHpDiKCW7zt0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqsoka%2FbtsQGZKbxJn%2FPvYRRB3FhNNHpDiKCW7zt0%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;1364&quot; height=&quot;750&quot; data-origin-width=&quot;1364&quot; data-origin-height=&quot;750&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 각 생활기록부 항목 별로 재요청 비율을 시각화하였다. 이를 통해 재요청 비율이 상대적으로 높은 세부능력특기사항에 대한 AI 응답 품질이 떨어짐을 파악할 수 있고 이를 기반으로 AI 프롬프팅을 고도화하거나 개선점을 고민하는 등 서비스를 발전시킬 수 있는 정량적인 지표로 활용할 수 있을 것이라 판단된다.&lt;/p&gt;</description>
      <category>Observability</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/71</guid>
      <comments>https://wing1008.tistory.com/71#entry71comment</comments>
      <pubDate>Fri, 19 Sep 2025 00:04:46 +0900</pubDate>
    </item>
    <item>
      <title>SELECT ... FOR UPDATE 동작 파헤치기</title>
      <link>https://wing1008.tistory.com/70</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://wing1008.tistory.com/43&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;이전의&lt;/span&gt;&lt;/a&gt; &lt;a href=&quot;https://wing1008.tistory.com/44&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;글들&lt;/span&gt;&lt;/a&gt;에서 트랜잭션 격리 수준에 따라 데이터 일관성이 어떻게 관리되는지 살펴보았다. 특히 Repeatable Read 격리 수준에서 Undo Log를 활용하여 어떻게 데이터 일관성을 보장하는지에 대해 다루었는데, 설명이 다소 간략해 아쉬운 부분이 있었다. 이번 글에서는 그 내용을 보완하여, InnoDB가 Consistent Read와 Read View를 통해 일관성을 유지하는 과정을 좀 더 자세히 살펴보고자 한다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실습 환경은 아래 그림과 같다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1332&quot; data-origin-height=&quot;492&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HSw3S/btsQAGqEJPz/e1r56TrWGRO7hewfc3Q0E0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HSw3S/btsQAGqEJPz/e1r56TrWGRO7hewfc3Q0E0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HSw3S/btsQAGqEJPz/e1r56TrWGRO7hewfc3Q0E0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHSw3S%2FbtsQAGqEJPz%2Fe1r56TrWGRO7hewfc3Q0E0%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;1332&quot; height=&quot;492&quot; data-origin-width=&quot;1332&quot; data-origin-height=&quot;492&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스에는 users 테이블과 wallet 테이블, 총 두 개의 테이블이 존재하며 각각의 테이블에 3개의 데이터가 저장되어 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;단일 트랜잭션 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 기본적인 단일 트랜잭션 상황을 가정해보자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lgLbL/btsQCmdn2M3/RKO0phWTUNVsel7sNCQjR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lgLbL/btsQCmdn2M3/RKO0phWTUNVsel7sNCQjR0/img.png&quot; data-origin-width=&quot;928&quot; data-origin-height=&quot;636&quot; style=&quot;width: 45.8358%;&quot; data-is-animation=&quot;false&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lgLbL/btsQCmdn2M3/RKO0phWTUNVsel7sNCQjR0/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlgLbL%2FbtsQCmdn2M3%2FRKO0phWTUNVsel7sNCQjR0%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;928&quot; height=&quot;636&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DwWQI/btsQBEr8s8f/skVxxxSLGgPhsXrLPxEY7k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DwWQI/btsQBEr8s8f/skVxxxSLGgPhsXrLPxEY7k/img.png&quot; data-origin-width=&quot;766&quot; data-origin-height=&quot;454&quot; style=&quot;width: 53.0014%;&quot; data-is-animation=&quot;false&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DwWQI/btsQBEr8s8f/skVxxxSLGgPhsXrLPxEY7k/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDwWQI%2FbtsQBEr8s8f%2FskVxxxSLGgPhsXrLPxEY7k%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;766&quot; height=&quot;454&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 트랜잭션에서 wallet 테이블의 Money 칼럼 값이 100보다 큰 레코드에 대해 각각 10씩 차감한 뒤 commit을 수행한다. 이후 또 다른 트랜잭션에서 동일한 조건(Money &amp;gt;= 100)으로 데이터를 조회하면, 조건을 만족하는 레코드는 Id=3인 데이터만 반환된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다중 트랜잭션 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 2개의 트랜잭션이 동시에 실행되는 상황을 살펴보자.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IgdTw/btsQCCNO9Fx/SKq7959071k2sQcJbnzQGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IgdTw/btsQCCNO9Fx/SKq7959071k2sQcJbnzQGk/img.png&quot; data-origin-width=&quot;926&quot; data-origin-height=&quot;642&quot; style=&quot;width: 61.1994%;&quot; data-is-animation=&quot;false&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IgdTw/btsQCCNO9Fx/SKq7959071k2sQcJbnzQGk/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIgdTw%2FbtsQCCNO9Fx%2FSKq7959071k2sQcJbnzQGk%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;926&quot; height=&quot;642&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Fo62H/btsQBiJAkT9/5Euw3C5VC6N8IWfzshW4Kk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Fo62H/btsQBiJAkT9/5Euw3C5VC6N8IWfzshW4Kk/img.png&quot; data-origin-width=&quot;754&quot; data-origin-height=&quot;850&quot; style=&quot;width: 37.6378%;&quot; data-is-animation=&quot;false&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Fo62H/btsQBiJAkT9/5Euw3C5VC6N8IWfzshW4Kk/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFo62H%2FbtsQBiJAkT9%2F5Euw3C5VC6N8IWfzshW4Kk%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;754&quot; height=&quot;850&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션1에서 wallet 테이블의 money 칼럼 값이 100보다 큰 레코드에 대해 각각 10씩 차감했지만 아직 commit을 하지 않은 상태에서 다른 트랜잭션(트랜잭션2)에서 money&amp;gt;=100 조건으로 조회했을 때는 언두로그를 통해 변경 이전의 값, 즉 차감되기 전 상태의 레코드가 조회된다.&lt;br /&gt;여기까지는 이전 글에서도 다루었던, 기본적인 동작이다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1Dxoq/btsQANv9M1T/RSeQlKYGR2xKkOTEKiFDi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1Dxoq/btsQANv9M1T/RSeQlKYGR2xKkOTEKiFDi0/img.png&quot; data-origin-width=&quot;1016&quot; data-origin-height=&quot;692&quot; style=&quot;width: 61.2757%;&quot; data-is-animation=&quot;false&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1Dxoq/btsQANv9M1T/RSeQlKYGR2xKkOTEKiFDi0/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1Dxoq%2FbtsQANv9M1T%2FRSeQlKYGR2xKkOTEKiFDi0%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;1016&quot; height=&quot;692&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ASZKw/btsQBlzyjUM/yTC9cGKfckLYOWXPbNCgyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ASZKw/btsQBlzyjUM/yTC9cGKfckLYOWXPbNCgyK/img.png&quot; data-origin-width=&quot;738&quot; data-origin-height=&quot;820&quot; style=&quot;width: 37.5615%;&quot; data-is-animation=&quot;false&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ASZKw/btsQBlzyjUM/yTC9cGKfckLYOWXPbNCgyK/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FASZKw%2FbtsQBlzyjUM%2FyTC9cGKfckLYOWXPbNCgyK%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;738&quot; height=&quot;820&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션1에서 wallet 테이블의 money 칼럼 값이 100보다 큰 레코드에 대해 각각 10씩 차감했지만 아직 commit을 하지 않은 상태에서 트랜잭션2는 user 테이블에 대해 select for update 구문을 통해 조회하는 쿼리를 실행하였다. 이후 트랜잭션1은 commit 되었고, 트랜잭션2에서 wallet 테이블에서 money&amp;gt;=100 조건으로 조회하는 쿼리를 실행하였다. 이전 예시의 결과와 다르게 트랜잭션1에 의해 변경된 값들이 반영되어 조회가 되었다.&amp;nbsp;&lt;br /&gt;처음에 나는 user 테이블에서 데이터를 읽어올 때, &lt;span style=&quot;color: #333333;&quot;&gt;for update 조건을 붙이면 잠금을 사용한 읽기를 하여 테이블의 값을 직접 읽어오고, 이후 트랜잭션1이 커밋되고 트랜잭션2에서 money&amp;gt;=100 조건으로 조회를 하는 시점에 언두 로그를 통해 데이터를 조회하기 때문에 여전히 트랜젹션1이 변경하기 전의 상황의 값들을 조회할 것이라고 생각했다. 이 추측이 틀린 이유는 무엇일까?&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Undo Log와 Read View 이 2가지 개념을 혼동하였기 때문이다. 이에 대해 자세히 알아보자.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;트랜잭션의 고유 번호는 어떻게 부여될까?&lt;/span&gt;&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;모든 InnoDB의 트랜잭션은 고유한 트랜잭션 번호(순차적으로 증가하는 값)을 가지며, 언두 영역에 백업된 모든 레코드에는 변경을 발생시킨 트랜잭션의 번호가 포함되어 있다. ... REAPEATABLE READ 격리 수준에서는 MVCC를 보장하기 위해 실행 중인 트랜잭션 가운데 가장 오래된 트랜잭션 번호보다 트랜잭션 번호가 앞선 언두 영역의 데이터는 삭제할 수 없다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;위 내용은 RealMySQL 1권의 5장에 기술되어 있는 내용이다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;여기서, &quot;모든 트랜잭션은 '고유한', '순차적으로 증가하는' 트랜잭션 번호를 가진다.&quot; 라는 문구에 의해, 트랜잭션이 begin 할 때, 즉시 테이블의 기본키 값처럼 트랜잭션 고유 번호가 증가하는 것이라 생각했었다.&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만, 이는 잘못된 오해이다. 트랜잭션이 시작될 때 무조건 번호를 받는 게 아니라, &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;쓰기 작업(INSERT, UPDATE, DELETE 등)을 실제로 수행할 때&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;고유&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;ID가 할당&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;된다. 때문에 단순히 Select만 실행하는 Read-Only 트랜잭션에서는 트랜잭션 ID를 소모하지 않는다. 이는 &lt;/span&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/information-schema-innodb-trx-table.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;공식&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;/span&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-performance-ro-txn.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;문서&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;span style=&quot;color: #333333;&quot;&gt;에도 아래와 같이 기술되어 있다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;InnoDB assigns a transaction ID only to read-write transactions. Read-only transactions that do not modify data do not receive a transaction ID.&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Read View 와 Undo Log&lt;/h4&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;1. Read View&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InnoDB에서 Read View와 Undo Log는 모두 MVCC(Multi-Version Concurrency Control) 기반으로 동작하지만, 그 목적과 역할이 다르다. Read View는 &lt;b&gt;InnoDB에서 일관된 읽기(Consistent Read)를 제공하기 위해 사용하는&lt;/b&gt; &lt;b&gt;스냅샷 구조체&lt;/b&gt;다. 쉽게 말해, 트랜잭션이 SELECT 쿼리를 실행할 때 해당 시점의 데이터 상태를 보장하기 위해 생성되는 것이다. Read View에는 현재 트랜잭션이 볼 수 있는 가장 최신의 커밋된 트랜잭션 ID가 포함되어 있어, 현재 트랜잭션이 볼 수 없는 트랜잭션들을 추적하여, 해당 트랜잭션들이 변경한 데이터를 읽지 않도록 할 수 있다. 또한, 현재 트랜잭션 ID 정보를 포함하여 자신이 수행한 작업이 다른 트랜잭션에 영향을 미치지 않도록 해준다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;2. Undo Log&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Undo Log는 &lt;b&gt;데이터 변경 작업을 취소할 수 있도록 이전 상태를 기록하는 로그&lt;/b&gt;다. 주로 트랜잭션 롤백 시 사용되며, 데이터의 이전 버전을 복원하는 데 활용된다. 즉, Undo Log를 통해, InnoDB는 트랜잭션이 중단되었을 때 데이터의 무결성을 유지하며, 이전 상태로 복원할 수 있는 것이다.&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Read View는 &quot;언제 시점을 데이터를 볼 것인가&quot;를 결정하는 즉, 트랜잭션이 볼 수 있는 데이터 상태를 정의하는 구조, Undo Log는 &quot;그 시점의 실제 데이터&quot;를 제공하는 저장소인 것이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;정리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 돌아가서, 처음 상황을 살펴보자. 트랜잭션 간의 흐름을 그려보면 아래와 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;1002&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rfVYf/btsQEGvPlet/ElD38aKbBDIoDuGszeIbH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rfVYf/btsQEGvPlet/ElD38aKbBDIoDuGszeIbH1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rfVYf/btsQEGvPlet/ElD38aKbBDIoDuGszeIbH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrfVYf%2FbtsQEGvPlet%2FElD38aKbBDIoDuGszeIbH1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1390&quot; height=&quot;1002&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;1002&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REPEATABLE READ 격리 수준에서는 트랜잭션 내 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;&lt;b&gt;첫 번째 읽기에서 생성된 스냅샷을 계속 사용&lt;/b&gt;&lt;/span&gt;&lt;/a&gt;한다.&lt;br /&gt;트랜잭션1이 Update 쿼리를 실행하는 순간, 해당 트랜잭션의 번호가 증가하게 되며 이때의 트랜잭션 고유 번호를 10이라고 가정해보자. 이어서 트랜잭션2에서 Read View가 생성되는 시점을 살펴보면, select ... for update 쿼리를 실행할 때, 일반 select문이 아니므로 Read View가 생성되지 않는다. 또한, Lock을 거는 작업이므로 트랜잭션 ID가 증가하여 11 이라는 트랜잭션 고유 번호가 부여되게 된다. 이후 트랜잭션1이 Commit을 하게 된다. 이후, money &amp;gt;= 100 조건으로 조회를 할 때에는, 일반 select문이므로 Read View가 생성되게 되는데 트랜잭션1이 Commit되었으므로 &lt;b&gt;트랜잭션1의 변경사항이 가시적이므로 언두 로그를 사용하지 않고 현재 데이터의 테이블을 읽어오는 것&lt;/b&gt;이다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;즉, MVCC는 &lt;b&gt;Read View를 통해 &quot;현재 버전이 보여도 되는가?&quot;를 먼저 판단하고, 안 되는 경우에만 과거 버전(Undo Log)을 찾아가는 방식&lt;/b&gt;이다. 이에 관한 내용도 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-multi-versioning.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;공식 문서&lt;/span&gt;&lt;/a&gt;에서 찾을 수 있었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;When a secondary index record is delete-marked or the secondary index page is updated by a newer transaction, InnoDB looks up the database record in the clustered index. In the clustered index, the record's DB_TRX_ID is checked, and the correct version of the record is retrieved from the undo log if the record was modified after the reading transaction was initiated.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;# 한 줄 요약&lt;/b&gt;&lt;br /&gt;&lt;b&gt;&amp;gt; Secondary index가 삭제되거나 갱신되어도, InnoDB는 clustered index에서 DB_TRX_ID를 확인하고, 필요하면 undo log를 통해 트랜잭션 시작 시점에 맞는 데이터를 읽는다.&lt;/b&gt;&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이어서 다음 상황을 살펴보자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0sed3/btsQEOf9S5s/IcBSOvbNnCWo9NdiyzZSz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0sed3/btsQEOf9S5s/IcBSOvbNnCWo9NdiyzZSz1/img.png&quot; data-origin-width=&quot;930&quot; data-origin-height=&quot;668&quot; style=&quot;width: 66.5643%;&quot; data-is-animation=&quot;false&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0sed3/btsQEOf9S5s/IcBSOvbNnCWo9NdiyzZSz1/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0sed3%2FbtsQEOf9S5s%2FIcBSOvbNnCWo9NdiyzZSz1%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;930&quot; height=&quot;668&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kFMC6/btsQFbWEDcw/LgjuEd90sf7RfCOKIQsF90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kFMC6/btsQFbWEDcw/LgjuEd90sf7RfCOKIQsF90/img.png&quot; data-origin-width=&quot;756&quot; data-origin-height=&quot;1120&quot; style=&quot;width: 32.2729%;&quot; data-is-animation=&quot;false&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kFMC6/btsQFbWEDcw/LgjuEd90sf7RfCOKIQsF90/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkFMC6%2FbtsQFbWEDcw%2FLgjuEd90sf7RfCOKIQsF90%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;756&quot; height=&quot;1120&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.&lt;br /&gt;트랜잭션1에서 update쿼리를 통해 &lt;span style=&quot;color: #333333;&quot;&gt;wallet 테이블의 money 칼럼 값이 100보다 큰 레코드에 대해 각각 10씩 차감하였다. 이때, 트랜잭션 고유 번호가 증가하여 할당되며, 이때의 할당 받은 번호를 10이라고 가정하자. 또한, 데이터가 변경되는 작업이므로 변경 이전 값은 Undo Log에 기록된다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;2. &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;트랜잭션1이 커밋되기 전에 트랜잭션2에서 select 쿼리를 통해 wallet 테이블의 값을 읽어왔다. 이때의 경우에는 비잠금 select문이기 때문에 트랜잭션 번호가 할당되지 않은 채로 Read View가 생성된다. 즉, 해당 select문이 해당 트랜잭션에서 처음으로 실행되는 select문이기 때문에 그 시점의 &quot;데이터베이스 상태&quot;를 기준으로 스냅샷이 결정된다. 아직, 트랜잭션1(번호:10)이 commit되기 이전이기 때문에 visible하지 않아 언두 로그에서 데이터를 읽어오게 된다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;3.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;트랜잭션1이 commit되었다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;4. &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이후, 트랜잭션1에서 commit을 수행하고 이어서 트랜잭션2에서 select ... for update 구문을 통해서 money&amp;gt;=100 조건으로 조회하는 쿼리를 실행하였다. 이때는 for update 조건을 통한 읽기 작업이기 때문에 트랜잭션 번호 11이 부여된다. 하지만, &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;for update의 경우&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;에는 &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;현재 최신의 데이터를 기준으로 동작&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;한다. 따라서 for update를 활용한 select 구문은 read view를 무시하고 현재의 커밋된 데이터를 조회한다.&lt;/span&gt;&lt;br /&gt;5.&lt;br /&gt;이어서 두번째 비잠금 select문을 수행하였다. 2번 단계 실행되던 select문과 동일한 트랜잭션에서 실행되는 쿼리이기 때문에 일관된 읽기(consistent read)를 보장해야 한다. 이를 위해, MySQL InnoDB에서는 동일한 트랜잭션에서는 첫 번째 일관된 읽기가 생성한 스냅샷(데이터 상태)을 그대로 읽는다. 때문에, 이 두 번째 SELECT도&lt;b&gt; 첫 번째 SELECT 시점의 데이터를 그대로 읽어 트랜잭션1에서 commit한 변경 내역을 볼 수 없다.&lt;/b&gt;&lt;br /&gt;즉, InnoDB의 REPEATABLE READ 격리 수준에서 트랜잭션 내 모든 SELECT는 첫 번째 SELECT 시점의 &quot;데이터 스냅샷&quot;을 기반으로 읽는다. 이를 통해 &lt;b&gt;트랜잭션 내에서 같은 쿼리를 반복해도 항상 같은 결과&lt;/b&gt;를 얻을 수 있게 되는 것이다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Computer Science/데이터베이스</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/70</guid>
      <comments>https://wing1008.tistory.com/70#entry70comment</comments>
      <pubDate>Wed, 17 Sep 2025 00:50:04 +0900</pubDate>
    </item>
    <item>
      <title>메시징 패턴 (feat. Pub/Sub, Queue, Event Stream)</title>
      <link>https://wing1008.tistory.com/69</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;메시징이란&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시징은 발신자와 수신자를 분리하는 방법이다. 메시징 시스템은 가장 단순하게 말하면 시스템의 한 부분에서 다른 부분으로 정보를 전달하는 것을 의미한다. 생산자가 메시지를 보내면, 브로커가 이를 저장하고 라우팅하며, 소비자가 가져가서 필요한 작업을 수행한다. 이 과정에서 서비스는 다른 서비스가 작업을 완료할 때까지 기다리지 않는다. 단지 메시지를 전달하고 곧바로 다음 일을 진행한다. 메시지는 브로커에 안전하게 저장되며, 수신자는 준비가 되었을 때 메시지를 처리한다. 만약 수신자가 실패하더라도 메시지는 대기 상태로 남아 있다. 하지만 모든 메시징 시스템이 동일한 동작 방식을 제공하는 것은 아니다. 이번 글에서는 자주 나타나는 3가지 주요 패턴에 대해 살펴 보고자한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 개념&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1895&quot; data-origin-height=&quot;1000&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/etDB7O/btsQrsfpwX9/97pWO4MswCgeUvjycW8BhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/etDB7O/btsQrsfpwX9/97pWO4MswCgeUvjycW8BhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/etDB7O/btsQrsfpwX9/97pWO4MswCgeUvjycW8BhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FetDB7O%2FbtsQrsfpwX9%2F97pWO4MswCgeUvjycW8BhK%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;1895&quot; height=&quot;1000&quot; data-origin-width=&quot;1895&quot; data-origin-height=&quot;1000&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;생산자, 소비자, 브로커&lt;/h4&gt;
&lt;p data-end=&quot;288&quot; data-start=&quot;157&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생산자(Producer)&lt;/b&gt; 는 메시지를 생성하는 모든 구성 요소를 말한다. 보통 큐나 토픽에 메시지를 푸시하며, 사용자 활동을 발행하는 프론트엔드 서비스일 수도 있고, 이후 처리할 작업을 큐에 넣는 백엔드 서비스일 수도 있다.&lt;/p&gt;
&lt;p data-end=&quot;390&quot; data-start=&quot;290&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;소비자(Consumer)&lt;/b&gt; 는 시스템에서 메시지를 가져와 처리한다. 데이터베이스에 기록을 추가하거나, 알림을 전송하거나, 백그라운드 계산을 시작하는 등의 역할을 수행한다.&lt;/p&gt;
&lt;p data-end=&quot;553&quot; data-start=&quot;392&quot; data-ke-size=&quot;size16&quot;&gt;이들 사이에는 메시지를 수신하고, 저장하며, 전달하는 &lt;b&gt;브로커(Broker)&lt;/b&gt; 또는 중개자가 존재한다. 브로커가 없다면 생산자와 소비자는 직접 연결되어 강하게 결합된다. 그러나 브로커를 두면 양쪽이 분리되어 서로 독립적으로 확장할 수 있고, 장애에도 더 강한 구조를 만들 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;메세지&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지는 메시징 모델에 따라 &lt;b&gt;큐(queue)&lt;/b&gt; 또는 &lt;b&gt;토픽(topic)&lt;/b&gt; 으로 구성된다. &lt;b&gt;큐&lt;/b&gt;는 &lt;b&gt;단일 소비 라인&lt;/b&gt;을 의미하며, &lt;b&gt;하나의 메시지는 단일 소비자만&lt;/b&gt; 가져갈 수 있다. 반면 &lt;b&gt;토픽&lt;/b&gt;은 &lt;b&gt;여러 구독자에게 동시에 메시지를 전달&lt;/b&gt;하며, &lt;b&gt;각 구독자는 메시지의 복사본을 개별적으로 수신&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-end=&quot;968&quot; data-start=&quot;752&quot; data-ke-size=&quot;size16&quot;&gt;또한 처리를 확장하기 위해 토픽을 &lt;b&gt;파티션(partition)&lt;/b&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;&amp;lt;전달 의미론&amp;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-end=&quot;1522&quot; data-start=&quot;1099&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1214&quot; data-start=&quot;1099&quot;&gt;&lt;b&gt;At-most-once&lt;/b&gt;&lt;br /&gt;메시지가 최대 한 번만 전달되는 의미이다. 메시지가 한 번 전달되거나 아예 전달되지 않을 수 있다. 재시도가 없기 때문에 빠르지만, 메시지 손실의 위험이 크다.&lt;/li&gt;
&lt;li data-end=&quot;1335&quot; data-start=&quot;1216&quot;&gt;&lt;b&gt;At-least-once&lt;/b&gt;&lt;br /&gt;메시지가 최소 한 번 이상 전달되는 의미이다. 재시도가 발생하기 때문에 메시지가 중복 전달될 수 있다. 안정적인 접근 방식이지만, 소비자가 중복 처리를 고려해야 한다.&lt;/li&gt;
&lt;li data-end=&quot;1522&quot; data-start=&quot;1337&quot;&gt;&lt;b&gt;Exactly-once&lt;/b&gt;&lt;br /&gt;메시지가 정확히 한 번만 전달되는 의미이다. 각 메시지가 손실이나 중복 없이 한 번만 처리된다. 가장 강력한 보장이지만 구현이 까다롭다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1517&quot; data-origin-height=&quot;1000&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AkqCB/btsQtRLrMz5/wM7D2AY0REmPNleti0euO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AkqCB/btsQtRLrMz5/wM7D2AY0REmPNleti0euO1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AkqCB/btsQtRLrMz5/wM7D2AY0REmPNleti0euO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAkqCB%2FbtsQtRLrMz5%2FwM7D2AY0REmPNleti0euO1%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;1517&quot; height=&quot;1000&quot; data-origin-width=&quot;1517&quot; data-origin-height=&quot;1000&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많은 시스템은 현실적인 이유로 최소 한 번(At-least-once) 보장에 만족하고, 중복 문제는 다운스트림 단계에서 멱등성 처리를 통해 해결한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;메시징 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;메시지 큐&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 큐는 하나의 생산자가 작업을 큐(Queue)에 넣고 하나 이상의 소비자가 이를 가져와 처리하는 방식이다. 이 모델은 &lt;b&gt;Point-to-Point 메시징&lt;/b&gt;이라고 하며, 백엔드 시스템에서 핵심 인프라 중 하나로 널리 사용된다. 서비스가 작업을 백그라운드로 오프로드하거나, 작업자를 여러 인스턴스에 분배하거나, 다운스트림의 느린 처리로부터 동기 실행을 분리해야 할 때 메시지 큐가 적절한 해결책이 된다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;작동 방식&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2053&quot; data-origin-height=&quot;1000&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxqzVG/btsQuqUhl4E/ZuADRyL41c8kBLljRmosvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxqzVG/btsQuqUhl4E/ZuADRyL41c8kBLljRmosvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxqzVG/btsQuqUhl4E/ZuADRyL41c8kBLljRmosvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxqzVG%2FbtsQuqUhl4E%2FZuADRyL41c8kBLljRmosvK%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;2053&quot; height=&quot;1000&quot; data-origin-width=&quot;2053&quot; data-origin-height=&quot;1000&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생산자는 큐에 메시지를 푸시한다. 각 메시지는 일반적으로 하나의 개별 작업 단위를 의미한다. 소비자는 큐에서 메시지를 가져와 처리하며, 작업이 끝나면 처리 완료(Ack)를 시스템에 알릴 수 있다. 즉, 메시지는 큐에 저장되고 준비된 소비자가 천천히 가져가 처리하는 구조이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;주요 동작 특성&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;975&quot; data-start=&quot;585&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;670&quot; data-start=&quot;585&quot;&gt;&lt;b&gt;단일 소비(Single Consumer)&lt;/b&gt;&lt;br /&gt;각 메시지는 한 번만 처리된다. 한 소비자가 처리하면 큐에서 사라지고, 다른 소비자는 가져갈 수 없다.&lt;/li&gt;
&lt;li data-end=&quot;782&quot; data-start=&quot;672&quot;&gt;&lt;b&gt;FIFO(First In, First Out)&lt;/b&gt;&lt;br /&gt;메시지가 들어온 순서대로 처리되는 것을 목표로 한다. 다만, 시스템 설정에 따라 순서가 완전히 보장되지 않을 수 있다.&lt;/li&gt;
&lt;li data-end=&quot;883&quot; data-start=&quot;784&quot;&gt;&lt;b&gt;재시도 및 재전달(Retry &amp;amp; Redelivery)&lt;/b&gt;&lt;br /&gt;소비자가 메시지를 처리하는 도중 오류가 발생하거나 서비스가 잠시 중단되며, 큐는 메시지를 다시 시도할 수 있다.&lt;/li&gt;
&lt;li data-end=&quot;975&quot; data-start=&quot;885&quot;&gt;&lt;b&gt;DLQ(Dead Letter Queue)&lt;/b&gt;&lt;br /&gt;메시지가 여러 번 시도에도 계속 실패하면, 별도의 큐(DLQ)로 보내져 검토 및 수동 처리가 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-end=&quot;990&quot; data-start=&quot;977&quot; data-ke-size=&quot;size20&quot;&gt;사용 사례&lt;/h4&gt;
&lt;p data-end=&quot;1313&quot; data-start=&quot;991&quot; data-ke-size=&quot;size16&quot;&gt;메시지 큐는 각 메시지가 한 번씩만 처리되어야 하는 작업에 적합하다. 예를 들어, 웹 요청에서 꼭 필요한 작업이 아닌 일부 처리를 큐에 넣으면 메인 요청 처리 속도를 느리지 않게 유지할 수 있다. 여러 소비자가 같은 큐에서 메시지를 가져가도록 구성하면 작업을 여러 인스턴스가 나눠 처리하므로 서버를 쉽게 늘려 처리량을 높일 수 있다. 또한, 사용자가 갑자기 몰리거나 작업이 한꺼번에 생겨도, 큐가 잠시 메시지를 저장해 천천히 처리하도록 도와 뒤쪽 시스템(메시지를 받아 처리하는 서비스)이 과부하에 걸리는 것을 방지할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-end=&quot;546&quot; data-start=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;하지만 메시지 큐를 사용할 때는 몇 가지 주의할 점이 있다. 먼저 &lt;b&gt;스케일링(Scaling)&lt;/b&gt; 문제를 들 수 있다. 단순히 소비자(Worker)를 늘리면 작업 처리 속도가 빨라질 것 같지만, 메시지 순서가 깨질 수 있다. 따라서 작업 순서가 중요한 경우, 여러 소비자가 동시에 메시지를 처리할 때는 순서를 유지하기 위한 추가적인 조정이나 관리가 필요하다.&lt;/p&gt;
&lt;p data-end=&quot;546&quot; data-start=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;또한 &lt;b&gt;재시도(Retries)&lt;/b&gt;에도 주의해야 한다. 실패한 메시지를 재시도할 때 지연 처리나 중복 제거가 제대로 이루어지지 않으면 시스템이 중복 작업으로 과부하될 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;546&quot; data-start=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로 &lt;b&gt;Stuck Consumers&amp;nbsp;문제&lt;/b&gt;도 있다. 소비자가 메시지 처리 중 멈춰 있으면 큐가 새로운 메시지를 처리하지 못해 전체 처리 흐름이 지연될 수 있다. 따라서 반드시 &lt;b&gt;헬스 체크(Health Check)와 타임아웃을 설정&lt;/b&gt;해, 문제가 발생한 소비자를 감지하고 조치할 수 있도록 해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Pub/Sub&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pub/Sub은 Publish/Subscribe(발행/구독)의 약자이며, &lt;b&gt;하나의 메시지를 여러 수신자가 동시에 받는 메시징 패턴&lt;/b&gt;을 의미한다. 즉, Pub/Sub의 핵심은 하나의 메시지에 여러 구독자가 존재한다는 점이다. 메시지 큐가 단일 작업자에게 메시지를 전달하는 것과 달리, Pub/Sub 시스템은 메시지를 여러 독립적인 구독자에게 Fan-out(분배)한다. 각 구독자는 메시지의 복사본을 받아, 다른 구독자의 처리 속도와 상관없이 자신만의 속도로 처리할 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;작동 방식&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1480&quot; data-origin-height=&quot;1000&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dNG0co/btsQtHvx3a2/Is4e9e24oGJVCGo93S0LQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dNG0co/btsQtHvx3a2/Is4e9e24oGJVCGo93S0LQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dNG0co/btsQtHvx3a2/Is4e9e24oGJVCGo93S0LQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdNG0co%2FbtsQtHvx3a2%2FIs4e9e24oGJVCGo93S0LQ0%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;1480&quot; height=&quot;1000&quot; data-origin-width=&quot;1480&quot; data-origin-height=&quot;1000&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생산자는 특정 토픽(Topic)에 메시지를 발행한다. 토픽은 브로드캐스트 채널처럼 동작하며, 해당 토픽을 구독하고 있는 모든 구독자는 메시지의 복사본을 받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일부 Pub/Sub 시스템(Kafka)에서는 소비자 그룹을 구성할 수 있다. 한 그룹 내의 여러 소비자는 메시지를 나눠 가져가면서 그룹 내에서 로드를 공유한다. 동시에 여러 그룹이 존재하면 각 그룹은 독립적으로 모든 메시지를 받아 처리한다. 즉, 모든 그룹은 메시지를 브로드캐스트로 받고, 그룹 내에서는 메시지를 큐처럼 나눠 처리하는 하이브로드 구조가 만들어진다.&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;977&quot; data-end=&quot;990&quot;&gt;사용 사례와 트레이드오프&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pub/Sub 패턴은 여러 시스템이 동일한 이벤트에 반응해야 할 때 특히 유용하다. 예를 들어 사용자가 사진을 업로드하면, 한 발행으로 다음 작업이 동시에 트리거 된다. 이처럼 이벤트 브로드캐스팅(Event Broadcasting)이 필요한 경우 Pub/Sub 패턴이 적합하다. 또한 실시간 알림, 뉴스 피드 업데이트, 주식 시세 실시간 반영 등 즉시 반응이 필요한 시스템에도 유용하게 사용된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, Pub/Sub 패턴을 사용할 때 아래와 같은 몇 가지 고려사항이 존재한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 &lt;b&gt;백프레셔(Backpressure)&lt;/b&gt; 문제를 들 수 있다. 구독자 중 한 명이 메시지를 처리하는 속도가 느려지면, 시스템은 메시지를 잠시 버퍼에 저장할지, 전체 처리를 느리게 조정할지, 아니면 일부 메시지를 버릴지를 결정해야 한다. 이러한 선택은 메시지의 내구성과 지연에 직접적인 영향을 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;b&gt;재생 가능성(Replayability)&lt;/b&gt; 문제도 존재한다. 모든 Pub/Sub 시스템이 이전 메시지를 저장하는 것은 아니며, 일부 시스템은 실시간으로만 메시지를 전달하고 이미 전달된 메시지는 다시 가져올 수 없다. 이 때문에 구독자가 잠시 오프라인 상태였다면 놓친 메시지를 재생할 수 없는 경우가 생길 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 &lt;b&gt;전달 보장(Delivery guarantees)&lt;/b&gt;도 시스템마다 크게 다르다. 메시지가 한 번도 전달되지 않을 수 있는 &amp;ldquo;최대 한 번(At-most-once)&amp;rdquo;부터, 중복 전달될 수 있는 &amp;ldquo;최소 한 번(At-least-once)&amp;rdquo;, 그리고 정확히 한 번만 전달되는 &amp;ldquo;정확히 한 번(Exactly-once)&amp;rdquo;까지 다양하다. 따라서 적절한 브로커를 선택하고 설정을 신중하게 해야 시스템의 신뢰성과 안정성을 확보할 수 있다.&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;h4 data-ke-size=&quot;size20&quot;&gt;이벤트 스트림이란?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 스트림은 시스템에서 일어난 일을 시간 순서대로 기록한 로그라고 생각하면 쉽다. 한 번 기록된 이벤트는 절대 바뀌거나 삭제되지 않는다. 즉, 큐나 Pub/Sub처럼 단순히 메시지를 전달하는 것이 아니라, 전체 사건의 히스토리를 그대로 보존하는 구조다. 소비자는 지금 발생하는 이벤트만 볼 수도 있고 필요하다면 이전 이벤트를 다시 읽어 재생할 수도 있다. 새로운 이벤트는 항상 스트림의 끝에 추가되며, 스트림 안에서는 순서가 유지된다. 또한 이벤트는 처리 후에도 사라지지 않고 정해진 기간 동안 계속 저장되어 필요할 때 언제든 참조할 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;동작 방식&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;822&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b8OCsh/btsQtcJj5uF/HFbPuY5yzTr0XEXGDrk9i1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b8OCsh/btsQtcJj5uF/HFbPuY5yzTr0XEXGDrk9i1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b8OCsh/btsQtcJj5uF/HFbPuY5yzTr0XEXGDrk9i1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb8OCsh%2FbtsQtcJj5uF%2FHFbPuY5yzTr0XEXGDrk9i1%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;822&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;822&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 스트림은 시스템에서 발생한 사건을 &lt;b&gt;시간 순서대로 기록한 불변 로그&lt;/b&gt;로 구성된다. 생산자(Producer)가 새로운 이벤트를 생성하면, 이벤트는 항상 스트림의 끝에 추가된다. 중요한 점은 이벤트가 소비된 후에도 &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;이벤트 스트림에서는 소비자는 자신이 지금까지 어디까지 데이터를 읽었는지를 offset이라는 위치 정보로 관리한다. Offset은 스트림 내 이벤트의 순서를 나타내는 번호로 각 소비자가 마지막으로 처리한 이벤트가 몇 번째인지 기록하는 역할을 한다. 이를 통해 여러 소비자가 동시에 같은 스트림을 읽더라고 서로의 속도나 처리 상태에 영향을 주지 않고 독립적으로 데이터를 처리할 수 있다. 또한, 소비자가 버그로 인해 일부 데이터를 잘못 처리했거나 새로운 로직을 적용해야 하는 경우, offset을 원하는 지점으로 되돌려 과거 이벤트를 다시 읽고 처리할 수 있다. 마찬가지로 소비자가 중간에 다운되거나 네트워크 문제로 메시지를 놓쳤을 때도, 마지막으로 기록된 offset부터 다시 시작할 수 있어 데이터 손실 없이 복구가 가능하다. 이처럼 Offset 덕분에 각 소비자는 자신만의 속도와 필요에 맞게 데이터를 읽고 처리할 수 있으며 이벤트 스트림의 핵심 장점은 재생 가능성과 유연성을 구현할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 구조 덕분에 이벤트 스트림은 여러 가지 장점을 제공한다. 먼저 과거 데이터를 다시 처리하여 버그를 수정하거나 시스템 상태를 복원할 수 있는 재처리 가능성이 있다. 또한 언제 어떤 일이 발생했는지 정확히 추적하여 문제의 원인을 파악할 수 있는 Time-travel Debugging도 가능하다. 비즈니스 활동을 완전하게 기록할 수 있어 규제 준수, 분석, 사기 탐지 등 다양한 목적으로 활용할 수 있는 데이터 감사(Audit) 기능도 갖추고 있다. 더 나아가 여러 소비자가 독립적으로 데이터를 읽을 수 있어, 서로 조율하지 않아도 자신만의 속도로 스트림을 처리할 수 있다. 결국 이벤트 스트림은 단순히 메시지를 전달하는 것을 넘어, 데이터의 이력과 맥락을 기반으로 시스템을 보다 유연하고 신뢰성 있게 운영할 수 있도록 해준다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 메시징 패턴은 모든 상황에서 완벽하게 작동하지 않는다. 각각은 서로 다른 목적을 가지고 있으며, 각기 다른 장점과 한계, 그리고 트레이드오프를 가진다. 이러한 차이를 이해하는 것이 취약한 시스템과 안정적이고 적응 가능한 시스템을 구분하는 핵심이다.&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 110px;&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: 20px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 20px;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px;&quot;&gt;&lt;b&gt;메시지 큐 (Queue)&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px;&quot;&gt;&lt;b&gt;Pub/Sub&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px;&quot;&gt;이벤트 스트림 (Event Stream)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;&lt;b&gt;전달 모델&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;Point-to-Point, &lt;br /&gt;하나의 생산자 -&amp;gt; 하나의 소비자&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;일대다,&lt;br /&gt;하나의 메시지가 여러 구독자에게 브로드캐스트&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;일대다 가능,&lt;br /&gt;소비자가 지속 로그에서 자신만의 속도로 읽음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;&lt;b&gt;순서 보장&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;FIFO -&amp;gt; 순서 보장 추구&lt;br /&gt;재시도 시 순서가 깨질 수도 있음&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;구독자 간 전역 순서 보장 없음&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;각 파티션 내에서 강한 순서 보존&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;&lt;b&gt;재생 가능성&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;확인되면 메시지 폐기, 외부 저장소 없으면 재생 불가&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;브로커 구현에 따라 다름 -&amp;gt; 일부 제한적 재생 가능&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;일급 재생 가능,&lt;br /&gt;offset/timestamp로 되감기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;&lt;b&gt;지연 시간 &amp;amp; 처리량&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;낮은 지연, 단일 소비자와 최적, 병렬 소비자로 처리량 확장 가능, 순서 제한 있음&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;낮은 지연, 보통 처리량&lt;br /&gt;느린 구독자/대규모 팬아웃 시 HOL 블로킹, 백프레셔 발생 가능&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;높은 처리량, 초당 수백만 메세지 가능&lt;br /&gt;버퍼링/배칭/파티셔닝에 따라 지연 증가 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;단순, 신속, 단일 작업 처리 적합&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;이벤트 브로드캐스트, 여러 구독자 동시 처리 가능&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 18px;&quot;&gt;재생 가능성, 감사 가능, 다수 소비자 독립적 처리 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고자료&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.bytebytego.com/p/messaging-patterns-explained-pub&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://blog.bytebytego.com/p/messaging-patterns-explained-pub&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1757505396567&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;Messaging Patterns Explained: Pub-Sub, Queues, and Event Streams&quot; data-og-description=&quot;What happens when the downstream service is overloaded? Or slow? Or down entirely?&quot; data-og-host=&quot;blog.bytebytego.com&quot; data-og-source-url=&quot;https://blog.bytebytego.com/p/messaging-patterns-explained-pub&quot; data-og-url=&quot;https://blog.bytebytego.com/p/messaging-patterns-explained-pub&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/S7TR5/hyZIMU2ZAU/v43qgoWkueWuVSS2HJ1r8K/img.jpg?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/GrRHj/hyZIW4p3Pm/kZogz5VEQKhP8WA0DLpKK1/img.jpg?width=1600&amp;amp;height=800&amp;amp;face=0_0_1600_800,https://scrap.kakaocdn.net/dn/b8fTYa/hyZISnqv3V/BI2GQK7ry3vSoxMa1agol1/img.jpg?width=1456&amp;amp;height=1698&amp;amp;face=0_0_1456_1698&quot;&gt;&lt;a href=&quot;https://blog.bytebytego.com/p/messaging-patterns-explained-pub&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://blog.bytebytego.com/p/messaging-patterns-explained-pub&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/S7TR5/hyZIMU2ZAU/v43qgoWkueWuVSS2HJ1r8K/img.jpg?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/GrRHj/hyZIW4p3Pm/kZogz5VEQKhP8WA0DLpKK1/img.jpg?width=1600&amp;amp;height=800&amp;amp;face=0_0_1600_800,https://scrap.kakaocdn.net/dn/b8fTYa/hyZISnqv3V/BI2GQK7ry3vSoxMa1agol1/img.jpg?width=1456&amp;amp;height=1698&amp;amp;face=0_0_1456_1698');&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;Messaging Patterns Explained: Pub-Sub, Queues, and Event Streams&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;What happens when the downstream service is overloaded? Or slow? Or down entirely?&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;blog.bytebytego.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;</description>
      <category>Observability</category>
      <author>wing1008</author>
      <guid isPermaLink="true">https://wing1008.tistory.com/69</guid>
      <comments>https://wing1008.tistory.com/69#entry69comment</comments>
      <pubDate>Wed, 10 Sep 2025 21:06:13 +0900</pubDate>
    </item>
  </channel>
</rss>