<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Icarus</title>
    <link>https://icarus8050.tistory.com/</link>
    <description>e-mail :icarus8050@naver.com

Github :https://github.com/icarus8050

Blog (이전 블로그) :https://blog.naver.com/icarus8050</description>
    <language>ko</language>
    <pubDate>Tue, 7 Jul 2026 19:52:41 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Icarus8050</managingEditor>
    <image>
      <title>Icarus</title>
      <url>https://tistory1.daumcdn.net/tistory/3179802/attach/de7a768f4f3442b2a9aacb9dcafc3fb6</url>
      <link>https://icarus8050.tistory.com</link>
    </image>
    <item>
      <title>Kotlin inline 함수의 noinline과 crossinline</title>
      <link>https://icarus8050.tistory.com/171</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Kotlin 코드를 읽다 보면 noinline, crossinline이라는 키워드를 종종 마주친다. 자주 보긴 하는데, 막상 직접 inline 함수를 작성하다 컴파일 에러가 나면 &quot;어, 이거 어떤 상황에 뭘 붙여야 하더라?&quot; 하고 매번 다시 찾아보게 된다. 헷갈릴 때마다 또 검색하지 않으려고 정리해둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. inline 함수와 람다 인라이닝의 기본&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;inline 함수는 호출부에 함수 본문이 그대로 펼쳐진다. 람다 파라미터도 마찬가지로 호출부에 인라이닝되기 때문에, 람다는 &lt;b&gt;함수 객체로 존재하지 않는다&lt;/b&gt;.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun doSomething(block: () -&amp;gt; Unit) {
    block()
}

fun caller() {
    doSomething { println(&quot;hello&quot;) }
    // 컴파일 후엔 사실상 아래와 같다
    // println(&quot;hello&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 인라이닝 덕분에 두 가지 특성이 생긴다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;람다 객체 생성 비용 제거&lt;/b&gt; (성능 이점) &amp;mdash; 일반 람다는 매 호출마다 함수 객체를 생성하지만, inline 람다는 그렇지 않다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;non-local return 허용&lt;/b&gt; &amp;mdash; 람다 안에서 return을 쓰면 람다를 호출한 함수가 아니라 &lt;b&gt;람다를 감싼 외부 함수에서 리턴&lt;/b&gt;된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun doSomething(block: () -&amp;gt; Unit) {
    block()
}

fun caller() {
    doSomething {
        return  // caller() 자체에서 return됨 (non-local return)
    }
    println(&quot;이 줄은 실행 안 됨&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;non-local return은 forEach 같은 inline 함수를 for 루프처럼 자연스럽게 쓸 수 있게 해준다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;fun findUser(users: List&amp;lt;User&amp;gt;, targetId: Long): User? {
    users.forEach {
        if (it.id == targetId) return it  // findUser에서 바로 리턴
    }
    return null
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;다만 이 특성은 동시에 &lt;b&gt;뒤에서 볼 crossinline이 필요해지는 원인&lt;/b&gt;이기도 하다. 람다 안의 return이 외부 함수를 종료시킨다는 약속 때문에, 람다가 다른 컨텍스트로 캡처되는 상황에서 문제가 생기기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 두 가지 특성이 noinline과 crossinline이 존재하는 근본 이유다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. crossinline &amp;mdash; non-local return을 금지한다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 필요한가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;inline 함수 안에서 람다 파라미터는 &lt;b&gt;직접 호출(block())만 인라이닝 가능&lt;/b&gt;하다. 그 외의 사용 &amp;mdash; 다른 람다 안에서 호출하거나, 다른 함수에 인자로 넘기거나, 변수에 저장하는 것 &amp;mdash; 은 모두 금지된다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun runInThread(block: () -&amp;gt; Unit) {
    Thread {
        block()  // ❌ 컴파일 에러
    }.start()
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여기서 Thread { ... }에 전달된 { block() }은 &lt;b&gt;별개의 람다&lt;/b&gt;다. 즉 block을 그 안에서 호출하는 건 &quot;직접 호출&quot;이 아니라 &lt;b&gt;다른 실행 컨텍스트로 캡처되는 호출&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;컴파일러가 이를 막는 이유는 non-local return의 위험 때문이다. block이 일반 inline 람다라면 안에서 return을 쓸 수 있어야 하고, 그 return은 &quot;&lt;b&gt;runInThread를 호출한 함수에서 return&lt;/b&gt;&quot;을 의미한다. 그런데 block()이 Thread의 Runnable 안으로 들어가 &lt;b&gt;별도 스레드에서 나중에 실행&lt;/b&gt;된다면, 그 시점에 호출자 함수는 이미 끝났을 수 있다. 거기서 호출자 함수를 종료시키는 return은 물리적으로 불가능하다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;fun caller() {
    runInThread {
        return  // 어떤 스레드에서, 언제, 무엇을 종료시킬 것인가?
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;컴파일러는 이 모순을 막기 위해 &lt;b&gt;&quot;다른 람다 안으로 캡처되는 inline 람다&quot;를 아예 금지&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결: crossinline&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;crossinline을 붙이면 &quot;이 람다는 non-local return을 포기한다&quot;고 컴파일러에게 약속하는 것이고, 그 대가로 다른 람다 안으로 캡처될 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun runInThread(crossinline block: () -&amp;gt; Unit) {
    Thread {
        block()  // ✅ OK
    }.start()
}

fun caller() {
    runInThread {
        // return  // ❌ 여전히 컴파일 에러: crossinline 람다 안에서는 non-local return 불가
        println(&quot;OK&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. noinline &amp;mdash; 인라이닝 자체를 제외한다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 필요한가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;inline 람다는 함수 객체로 존재하지 않기 때문에, &lt;b&gt;람다를 변수에 저장하거나 다른 함수에 전달하거나 반환할 수 없다&lt;/b&gt;. 람다를 &quot;값&quot;으로 다뤄야 한다면 인라이닝을 포기해야 하고, 그게 noinline이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// ❌ 컴파일 에러
inline fun wrong(block: () -&amp;gt; Unit): () -&amp;gt; Unit {
    return block  // 인라이닝된 람다는 반환할 수 없음
}

// ✅ noinline으로 해결
inline fun right(noinline block: () -&amp;gt; Unit): () -&amp;gt; Unit {
    return block
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용 예&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;inline 함수에 람다 파라미터가 여러 개 있고, 그 중 일부만 다른 함수로 넘겨야 할 때 사용한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun doSomething(
    block1: () -&amp;gt; Unit,
    noinline block2: () -&amp;gt; Unit
) {
    block1()              // 인라이닝됨
    saveForLater(block2)  // block2는 함수 객체로 존재하므로 전달 가능
}

fun saveForLater(action: () -&amp;gt; Unit) { /* ... */ }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 세 키워드 비교&lt;/h2&gt;
&lt;div&gt;키워드인라이닝non-local return람다를 값으로 다루기
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 91px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;키워드&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;인라이닝&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;non-local return&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;람다를 값으로 다루기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;inline (기본)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;noinline&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;crossinline&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 두 가지 축의 조합이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;인라이닝 여부&lt;/b&gt; &amp;rarr; 람다 객체 생성 비용 / 람다를 값으로 다룰 수 있는지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;non-local return 허용 여부&lt;/b&gt; &amp;rarr; 람다가 다른 컨텍스트로 캡처될 수 있는지&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 실무에서 마주치는 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Executor에 작업 제출&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun submitAsync(executor: Executor, crossinline task: () -&amp;gt; Unit) {
    executor.execute { task() }  // 람다 안에서 task 호출 &amp;rarr; crossinline 필수
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;재시도 + 폴백&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun &amp;lt;T&amp;gt; retryWithFallback(
    maxAttempts: Int,
    crossinline action: () -&amp;gt; T,           // 재시도 루프 안에서 실행 &amp;rarr; crossinline
    noinline fallback: (Throwable) -&amp;gt; T    // 다른 함수로 전달 &amp;rarr; noinline
): T {
    repeat(maxAttempts - 1) {
        try {
            return action()
        } catch (e: Exception) { /* 재시도 */ }
    }
    return runFallback(fallback)
}

fun &amp;lt;T&amp;gt; runFallback(fb: (Throwable) -&amp;gt; T): T = fb(RuntimeException(&quot;failed&quot;))&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하나의 함수에서 crossinline과 noinline이 함께 쓰이는 예다.&lt;/p&gt;</description>
      <category>Development/Kotlin</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/171</guid>
      <comments>https://icarus8050.tistory.com/171#entry171comment</comments>
      <pubDate>Sat, 9 May 2026 18:26:31 +0900</pubDate>
    </item>
    <item>
      <title>Spring Data JPA Custom Query Method는 왜 SimpleJpaRepository의 @Transactional을 상속받지 못할까</title>
      <link>https://icarus8050.tistory.com/170</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SimpleJpaRepository에는 클래스 레벨 @Transactional(readOnly = true)와 쓰기 메서드(save, delete*)에 메서드 레벨 @Transactional이 붙어 있다.&lt;/li&gt;
&lt;li&gt;이 어노테이션들은 &lt;b&gt;SimpleJpaRepository에 실제로 정의된 메서드를 호출할 때만&lt;/b&gt; 적용된다.&lt;/li&gt;
&lt;li&gt;findByName(...), @Query(&quot;...&quot;) 같은 &lt;b&gt;사용자 정의(custom) query method&lt;/b&gt;는 SimpleJpaRepository에 시그니처 자체가 없기 때문에, Spring Data가 사용하는 RepositoryAnnotationTransactionAttributeSource의 fallback 경로에서 조기 종료되어 트랜잭션 어트리뷰트가 매칭되지 않는다.&lt;/li&gt;
&lt;li&gt;Repository proxy에 들러붙는 또 다른 interceptor인 TransactionInterceptor 역시 같은 이유로 매칭에 실패한다 (자세한 메커니즘은 본문 &amp;sect;3에서 설명).&lt;/li&gt;
&lt;li&gt;결과: custom query method는 별도 선언이 없는 한 @Transactional이 적용되지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 배경: Repository Proxy에는 두 개의 TransactionInterceptor가 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA Repository는 JDK Proxy로 만들어진 advice chain을 가진다. 이 chain에 들어가는 트랜잭션 관련 advice는 두 가지 경로로 구성된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Path A. TransactionInterceptor&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@EnableTransactionManagement이 import하는 ProxyTransactionManagementConfiguration에서 컨테이너 기동 시 한 번 만들어진다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// AbstractTransactionManagementConfiguration 에서 설정
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public TransactionAttributeSource transactionAttributeSource() {
	// Accept protected @Transactional methods on CGLIB proxies, as of 6.0
	AnnotationTransactionAttributeSource tas = new AnnotationTransactionAttributeSource(false);
	// Apply default rollback rule, as of 6.2
	if (this.enableTx != null &amp;amp;&amp;amp; this.enableTx.getEnum(&quot;rollbackOn&quot;) == RollbackOn.ALL_EXCEPTIONS) {
		tas.addDefaultRollbackRule(RollbackRuleAttribute.ROLLBACK_ON_ALL_EXCEPTIONS);
	}
	return tas;
}

// ProxyTransactionManagementConfiguration 에서 설정
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public TransactionInterceptor transactionInterceptor(TransactionAttributeSource transactionAttributeSource) {
	TransactionInterceptor interceptor = new TransactionInterceptor();
	interceptor.setTransactionAttributeSource(transactionAttributeSource);
	if (this.txManager != null) {
		interceptor.setTransactionManager(this.txManager);
	}
	return interceptor;
}

// ProxyTransactionManagementConfiguration 에서 설정
@Bean(name = TransactionManagementConfigUtils.TRANSACTION_ADVISOR_BEAN_NAME)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public BeanFactoryTransactionAttributeSourceAdvisor transactionAdvisor(
		TransactionAttributeSource transactionAttributeSource, TransactionInterceptor transactionInterceptor) {

	BeanFactoryTransactionAttributeSourceAdvisor advisor = new BeanFactoryTransactionAttributeSourceAdvisor();
	advisor.setTransactionAttributeSource(transactionAttributeSource);
	advisor.setAdvice(transactionInterceptor);
	if (this.enableTx != null) {
		advisor.setOrder(this.enableTx.&amp;lt;Integer&amp;gt;getNumber(&quot;order&quot;));
	}
	return advisor;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 빈에 적용 가능. TransactionAttributeSourcePointcut 이 매칭되는 Bean/Method 에만 advice 가 끼어든다.&lt;/li&gt;
&lt;li&gt;매칭 조건: 메서드 또는 (선언/타깃) 클래스에 @Transactional 이 발견되는가.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Path B. Per-Repository TransactionInterceptor&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;각 Repository 빈이 만들어질 때 TransactionalRepositoryProxyPostProcessor 가 추가한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Override
public void postProcess(ProxyFactory factory, RepositoryInformation repositoryInformation) {

	TransactionInterceptor transactionInterceptor = new TransactionInterceptor();
	transactionInterceptor.setTransactionAttributeSource(
			new RepositoryAnnotationTransactionAttributeSource(repositoryInformation, enableDefaultTransactions));
	transactionInterceptor.setTransactionManagerBeanName(transactionManagerName);
	transactionInterceptor.setBeanFactory(beanFactory);
	transactionInterceptor.afterPropertiesSet();
	factory.addAdvice(transactionInterceptor);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Repository proxy마다 새 인스턴스가 만들어진다.&lt;/li&gt;
&lt;li&gt;핵심: 표준 AnnotationTransactionAttributeSource가 아니라 **RepositoryAnnotationTransactionAttributeSource**가 들어간다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 TransactionInterceptor 클래스지만 인스턴스가 다르고, 들고 있는 TransactionAttributeSource도 다르다. 이게 &quot;@Transactional 마킹 유무에 따라 동작이 달라 보이는&quot; 현상의 정체다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. computeTransactionAttribute()는 언제 호출되나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;TransactionInterceptor가 메서드 호출을 가로챌 때, 어트리뷰트 lookup 이 트리거된다.&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;JDK Proxy.invoke()
 └─ TransactionInterceptor.invoke(MethodInvocation)
   └─ TransactionAspectSupport#invokeWithinTransaction()
     └─ getTransactionAttributeSource().getTransactionAttribute(method, targetClass)
       └─ AbstractFallbackTransactionAttributeSource#getTransactionAttribute()
         ├─ attributeCache(MethodClassKey) HIT &amp;rarr; 그대로 반환
         └─ MISS &amp;rarr; computeTransactionAttribute(method, targetClass) &amp;larr; 여기
              └─ 결과를 attributeCache에 put
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Proxy 생성 시점에는 호출되지 않는다. postProcess()는 wiring 만 한다.&lt;/li&gt;
&lt;li&gt;런타임에 각 메서드 첫 호출 시 단 1회 실행되고, 이후엔 MethodClassKey 기준으로 캐시된다.&lt;/li&gt;
&lt;li&gt;주의: null 반환도 캐시된다. 트랜잭션 어트리뷰트를 찾지 못한 경우, NULL_TRANSACTION_ATTRIBUTE가 캐시에 저장되어 같은 메서드의 두 번째 호출부터는 lookup 자체가 일어나지 않는다. 디버깅 시 &quot;왜 브레이크포인트가 한 번만 걸리지?&quot;의 답이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 두 interceptor 모두 왜 custom query method 를 잡지 못하나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Path A와 Path B를 각각 따져보자.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1. Path B (RepositoryAnnotationTransactionAttributeSource)의 lookup 로직&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;표준 AnnotationTransactionAttributeSource에 한 단계가 더 붙은 형태다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Override
protected TransactionAttribute computeTransactionAttribute(Method method, Class&amp;lt;?&amp;gt; targetClass) {
	// Don't allow no-public methods as required.
	if (allowPublicMethodsOnly() &amp;amp;&amp;amp; !Modifier.isPublic(method.getModifiers())) {
		return null;
	}

	// The method may be on an interface, but we need attributes from the target class.
	// If the target class is null, the method will be unchanged.
	Method specificMethod = ClassUtils.getMostSpecificMethod(method, userClass);

	// If we are dealing with method with generic parameters, find the original method.
	specificMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
    
	TransactionAttribute txAtt = null;

	// (1) user interface 메서드의 @Transactional
	txAtt = findTransactionAttribute(specificMethod);

	if (txAtt != null) {
		return txAtt;
	}

	// (2) declaring class 레벨 @Transactional
	txAtt = findTransactionAttribute(specificMethod.getDeclaringClass());

	if (txAtt != null) {
		return txAtt;
	}

	if (!enableDefaultTransactions) {
		return null;
	}

	// (3) 구현 클래스(SimpleJpaRepository)에서 동일 시그니처 lookup
	Method targetClassMethod = repositoryInformation.getTargetClassMethod(method);

	if (targetClassMethod.equals(method)) {
		return null;  // &amp;larr; 핵심 분기
	}

	// (4) 구현체 메서드의 @Transactional
	txAtt = findTransactionAttribute(targetClassMethod);

	if (txAtt != null) {
		return txAtt;
	}

	// (5) 구현체 클래스의 @Transactional
	txAtt = findTransactionAttribute(targetClassMethod.getDeclaringClass());

	if (txAtt != null) {
		return txAtt;
	}

	return null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;getTargetClassMethod(method)는 &quot;user interface에 선언된 메서드와 같은 시그니처를 갖는 구현 클래스(SimpleJpaRepository)의 메서드&quot;를 찾는다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구현체에 동일 메서드가 있으면 다른 Method 객체를 반환 &amp;rarr; (4), (5)로 진행 &amp;rarr; SimpleJpaRepository의 어노테이션이 적용된다.&lt;/li&gt;
&lt;li&gt;없으면 입력 메서드를 그대로 반환 &amp;rarr; targetClassMethod.equals(method) == true &amp;rarr; null 반환, 종료.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Custom query method는 후자다. 여기서 Path B는 끝난다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2. 그럼 Path A는 왜 못 잡나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여기서 자연스러운 질문이 생긴다. &quot;Path B가 fallback에서 끊긴다 치자. Path A의 표준 AnnotationTransactionAttributeSource도 결국 같은 AbstractFallbackTransactionAttributeSource를 상속하는데, 이건 왜 SimpleJpaRepository 의 클래스 레벨 @Transactional(readOnly = true)를 매칭시키지 못하는가?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;답은 fallback이 검사하는 타입 계층에 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표준 fallback 순서는 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;specificMethod (가장 구체적인 메서드)의 어노테이션&lt;/li&gt;
&lt;li&gt;specificMethod.getDeclaringClass()의 어노테이션&lt;/li&gt;
&lt;li&gt;method (원본 인터페이스 메서드)의 어노테이션&lt;/li&gt;
&lt;li&gt;method.getDeclaringClass()의 어노테이션&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 getMostSpecificMethod(method, targetClass)가 무엇을 반환하느냐다. Repository proxy의 targetClass는 SimpleJpaRepository이지만, custom query method는 SimpleJpaRepository에 시그니처가 없다. 따라서 가장 구체적인 메서드는 user interface의 메서드 그대로다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 fallback이 순회하는 대상은:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;user interface 메서드 &amp;rarr; 어노테이션 없음&lt;/li&gt;
&lt;li&gt;user interface (= UserRepository) &amp;rarr; 어노테이션 없음&lt;/li&gt;
&lt;li&gt;(1과 동일)&lt;/li&gt;
&lt;li&gt;(2와 동일)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;UserRepository는 JpaRepository 인터페이스 계층을 상속할 뿐, SimpleJpaRepository 구현 클래스를 상속하지 않는다. 따라서 SimpleJpaRepository의 클래스 레벨 @Transactional(readOnly = true)는 fallback이 닿는 타입 계층 어디에도 들어오지 않는다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리: Path B는 &quot;구현체에 같은 시그니처가 없으면 조기 종료&quot;라는 명시적 분기 때문에, Path A는 &quot;fallback이 인터페이스 계층만 훑고 구현 클래스에 도달하지 못하기 때문에&quot; 매칭에 실패한다. 두 interceptor의 실패 이유는 미묘하게 다르지만, 결과적으로 custom query method는 어느 쪽으로도 트랜잭션 어트리뷰트를 받지 못한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 메서드 종류별 @Transactional 상속 여부&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드 종류 출처 SimpleJpaRepository의 어트리뷰트 상속&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;save, delete*&lt;/td&gt;
&lt;td&gt;CrudRepository 선언 + SimpleJpaRepository에서 override (메서드 레벨 @Transactional 직접 부여)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;findById, findAll, count, existsById 등 read-only CRUD&lt;/td&gt;
&lt;td&gt;CrudRepository 선언 + SimpleJpaRepository override (메서드 어노 없음, 클래스 레벨만 적용)&lt;/td&gt;
&lt;td&gt;✅ &amp;mdash; 클래스 레벨 @Transactional(readOnly = true) 상속&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;findByName, @Query(&quot;...&quot;), derived query&lt;/td&gt;
&lt;td&gt;user interface에만 존재&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@Modifying 붙은 custom query&lt;/td&gt;
&lt;td&gt;user interface에만 존재&lt;/td&gt;
&lt;td&gt;❌ &amp;mdash; 별도 @Transactional 없으면 실행 시 InvalidDataAccessApiUsageException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove'/'persist' call 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 왜 이렇게 설계되었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional은 메서드 단위 메타데이터다. 클래스 레벨 어노테이션이 자식 메서드 시그니처에 자동으로 &quot;상속&quot;되지 않는 이유는 명확하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SimpleJpaRepository의 @Transactional(readOnly = true)는 이 클래스에 실제로 정의된 메서드 호출에 한해 의미를 가진다.&lt;/li&gt;
&lt;li&gt;Custom query method는 SimpleJpaRepository에 정의된 메서드가 아니다. 이름이 같다는 보장도 없고, 같다 한들 의미가 같다는 보장도 없다.&lt;/li&gt;
&lt;li&gt;Spring Data가 fallback 경로를 만든 건 CRUD 메서드 override 한정 편의 기능이지, 사용자가 새로 만든 메서드까지 자동으로 묶어주려는 의도가 아니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 디버깅 진입 포인트&lt;/h2&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;컨테이너 기동 시 global interceptor 생성: ProxyTransactionManagementConfiguration#transactionInterceptor&lt;/li&gt;
&lt;li&gt;Repository proxy 빌드 시 per-repo interceptor 추가: TransactionalRepositoryProxyPostProcessor#postProcess&lt;/li&gt;
&lt;li&gt;런타임 어트리뷰트 lookup: AbstractFallbackTransactionAttributeSource#getTransactionAttribute&lt;/li&gt;
&lt;li&gt;Spring Data 전용 lookup 로직: RepositoryAnnotationTransactionAttributeSource#computeTransactionAttribute&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TransactionInterceptor 인스턴스가 두 개라는 사실은, 위 두 생성 지점에서 System.identityHashCode(this.transactionAttributeSource)를 비교해보면 즉시 확인된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 동작 때문에 lookup 메서드의 브레이크포인트는 메서드당 단 한 번만 걸린다는 점도 기억해두자. 같은 메서드를 재호출하며 디버깅하려면 attributeCache를 직접 비우거나, 다른 메서드 호출로 전환해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Development/Spring</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/170</guid>
      <comments>https://icarus8050.tistory.com/170#entry170comment</comments>
      <pubDate>Mon, 4 May 2026 00:45:05 +0900</pubDate>
    </item>
    <item>
      <title>Consistency Core 정리</title>
      <link>https://icarus8050.tistory.com/169</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;강한 일관성을 보장하려면 노드 간 합의(consensus)가 필요하고, 합의는 노드 수가 늘어날수록 비용이 폭증한다. 그렇다고 일관성을 포기하면 데이터 정합성이 깨지고, 합의 비용을 감수하면 확장성이 천장에 부딪힌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 딜레마에 대한 정형화된 해법이 Consistency Core(일관성 코어) 패턴이다. 이미 우리가 쓰고 있는 Kafka, Kubernetes, HDFS 같은 시스템들이 모두 이 패턴의 구체적인 구현체다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 글에서는 Consistency Core 가 무엇이고, 왜 필요하며, Kafka KRaft 를 중심으로 실제로 어떻게 구현되는지 정리해본다.&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;분산 합의 알고리즘(Raft, Paxos)은 quorum, 즉 과반수 노드의 동의가 있어야 진행된다. 노드 수가 늘어나면 quorum 확보 비용이 어떻게 변하는지 보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드 수 Quorum 합의 특성&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;빠르고 안정적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;운영하기 좋은 균형점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;합의 속도 저하 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;사실상 불가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;100대 노드가 모든 결정을 합의로 처리한다고 상상해보자. 매 결정마다 51대 이상의 응답을 기다려야 하고, 한 노드만 느려도 전체가 대기한다. 네트워크 round trip이 노드 수에 비례해 늘어나고, 장애 복구는 더 오래 걸린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;그렇다고 강한 일관성을 포기할 수도 없다. 클러스터 멤버십, 리더 정보, 설정 같은 정보는 모든 노드가 같은 view를 가져야 시스템이 안전하게 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 아이디어: Consistency Core&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;강한 일관성이 정말로 필요한 부분만 작은 클러스터로 분리하고, 나머지는 그 코어를 참조하면서 약한 일관성으로 운영한다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;                  ┌──────────────────────┐
                  │  Consistency Core    │
                  │  (3~5 노드, Raft)    │
                  │  - 강한 일관성       │
                  │  - 적은 데이터       │
                  │  - 모든 결정의 진실  │
                  └──────────┬───────────┘
                             │ (메타데이터 조회/구독)
              ┌──────────────┼──────────────┐
              │              │              │
         ┌────▼────┐    ┌────▼────┐    ┌────▼────┐
         │ Worker  │    │ Worker  │    │ Worker  │
         │ Node 1  │    │ Node 2  │    │ ... N   │
         └─────────┘    └─────────┘    └─────────┘
            (대량 데이터, 약한 일관성, 수평 확장)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;코어는 작게 유지해서 합의 비용을 낮추고, 워커는 코어의 결정을 따르면서 자유롭게 수평 확장한다. 책임이 명확하게 분리되는 게 핵심이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;무엇이 코어에 들어가고 무엇이 워커로 빠지는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;코어가 책임지는 것 (강한 일관성 필요, 양은 적음)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클러스터 멤버십: 어떤 노드가 살아있는가&lt;/li&gt;
&lt;li&gt;Leader/Owner 정보: 각 파티션의 리더는 누구인가&lt;/li&gt;
&lt;li&gt;설정/스키마: 토픽 설정, 권한&lt;/li&gt;
&lt;li&gt;글로벌 카운터: producer ID, transaction ID&lt;/li&gt;
&lt;li&gt;Lock/Lease: 분산 락, 임시 소유권&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 데이터들의 공통점은 양은 적지만 모두가 동일한 view를 가져야 안전하다는 점이다. 두 노드가 동시에 자기를 leader 라고 믿으면 split-brain 이 발생하고, 같은 producer ID 가 두 곳에서 발급되면 데이터 정합성이 깨진다.&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;워커가 책임지는 것 (양은 많고, 약간의 stale read 허용 가능)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제 메시지/데이터&lt;/li&gt;
&lt;li&gt;사용자 트래픽 처리&lt;/li&gt;
&lt;li&gt;대용량 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;반대로 이런 데이터들은 양이 압도적으로 많지만 노드별로 잠시 다른 view 를 가져도 큰 문제가 안 된다. 정확히 말하면 다른 메커니즘(예: leader-follower replication)으로 일관성을 따로 보장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 사례&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kafka (KRaft)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka 의 KRaft 가 Consistency Core 패턴의 교과서적 구현이다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;KRaft Controllers (3~5대) &amp;larr; Consistency Core
  - 토픽/파티션 메타데이터
  - Leader election
  - Broker 등록 정보
  - Configuration

Brokers (수십~수백대) &amp;larr; Workers
  - 실제 메시지 데이터
  - Producer/Consumer 처리
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;컨트롤러 quorum은 Raft로 강한 일관성을 보장하지만, broker는 그렇지 않다. Broker는 컨트롤러로부터 metadata를 fetch해서 자기 메모리에 캐시하고, 그 정보를 바탕으로 자기 partition의 데이터를 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;KRaft 이전에는 ZooKeeper가 외부 Consistency Core 역할을 했다. KRaft는 그걸 Kafka 내부로 흡수한 것이다. 외부 의존성을 줄이고, 메타데이터 처리 경로를 단순화한 결과물이다.&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;Kubernetes&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;etcd (3~5대) &amp;larr; Consistency Core
  - Pod, Service, ConfigMap 정의
  - 클러스터 상태
  - Lease (leader election용)

kubelet (수천 노드) &amp;larr; Workers
  - 실제 컨테이너 실행
  - 사용자 워크로드
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;API server는 etcd를 거쳐 모든 결정을 내리고, kubelet은 그 결정을 받아서 자기 노드에서 실행한다. 수천 대의 워커 노드가 있어도 etcd는 3~5대로 유지된다.&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;HDFS&lt;/h3&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;NameNode (1~2대 HA) &amp;larr; Consistency Core
  - 파일/디렉터리 메타데이터
  - 블록 위치 정보

DataNode (수백~수천대) &amp;larr; Workers
  - 실제 파일 블록 저장
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;NameNode가 모든 메타데이터의 진실의 근원이고, DataNode는 실제 데이터를 저장한다. NameNode가 SPOF가 되는 문제 때문에 HA 구성이 필수가 되었고, 이것도 결국 작은 코어를 더 안전하게 만드는 방향으로 진화한 사례다.&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;비교: Cassandra의 다른 선택&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Cassandra는 Consistency Core를 의도적으로 두지 않는다. Gossip 프로토콜로 모든 노드가 P2P로 상태를 교환한다. 이건 다른 trade-off다. Single point of bottleneck은 없지만, 강한 일관성을 보장하기 어렵고 운영 복잡도가 올라간다. Metadata-heavy하지 않고 write-heavy한 워크로드엔 더 적합한 모델이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이게 시사하는 바는, Consistency Core가 만능이 아니라 특정 trade-off를 선택한 결과라는 점이다. 워크로드 특성에 따라 다른 선택지가 더 나을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;KRaft에서 본 두 layer의 leader election&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Consistency Core 패턴을 깊이 이해하려면 Kafka KRaft가 leader election을 어떻게 처리하는지 보는 게 좋다. 여기에는 &lt;b&gt;두 층위의 leader election&lt;/b&gt;이 있는데, 이 둘을 구분하지 못하면 동작 모델이 흐려진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Layer 1: Controller Quorum 내부의 Raft Leader Election&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;누가&lt;/b&gt;: 컨트롤러 노드들끼리 (3~5대)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;무엇을&lt;/b&gt;: 컨트롤러 quorum 의 active controller 결정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;방법&lt;/b&gt;: Raft 알고리즘 (term 증가 &amp;rarr; vote 요청 &amp;rarr; quorum 동의)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;언제&lt;/b&gt;: 컨트롤러 기동, active controller 장애&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Term을 올리고 표를 받고 과반을 확보하는 Raft의 정통 election 절차를 따른다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Layer 2: 각 Topic Partition 의 Leader 결정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;누가&lt;/b&gt;: Active controller가 결정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;무엇을&lt;/b&gt;: 각 partition의 leader broker와 ISR&lt;/li&gt;
&lt;li&gt;&lt;b&gt;방법&lt;/b&gt;: &lt;b&gt;선거가 아니라 active controller의 단독 결정 (assignment)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;언제&lt;/b&gt;: Topic 생성, broker 장애, partition reassignment&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Broker는 컨트롤러에게 &quot;내가 leader 할게요&quot;라고 요청하지 않는다. 컨트롤러가 메타데이터와 정책을 보고 일방적으로 결정하고, broker들은 metadata log를 fetch하다가 자기 역할 변경을 인지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Broker 장애 시의 흐름을 보면 이 모델이 명확해진다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. Broker 1이 죽음 (이 broker가 partition-0의 leader였음)

2. Active Controller가 broker 1의 죽음을 감지
   - Heartbeat 타임아웃으로 fenced 상태 전환

3. Active Controller가 영향받는 partition 스캔
   - ISR이었던 [1,2,3] 중 broker 1 제외한 [2,3]에서 새 leader 선택
   - preferred replica 순서로 &amp;rarr; broker 2

4. 새 결정을 metadata log에 record로 append
   PartitionChangeRecord(
     topic=UUID-A, partition=0,
     leader=2,           &amp;larr; 새 leader
     isr=[2,3],
     leaderEpoch=1       &amp;larr; epoch 증가
   )

5. Controller quorum 내부 Raft 합의 &amp;rarr; commit

6. 모든 broker가 변경을 fetch하고 자기 역할 인지
   - Broker 2: &quot;이제 내가 partition-0의 leader구나&quot;
   - Broker 3: &quot;leader가 broker 2로 바뀌었네, 거기로 fetch해야지&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 임명 모델인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;왜 Layer 2는 선거가 아니라 임명일까. 두 가지 이유가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;첫째, Consistency Core가 이미 권위 있는 진실의 근원이다. Controller 는 모든 broker 의 상태, ISR, leader 정보를 알고 있다. 이미 가진 정보로 결정할 수 있는 일을 굳이 broker 들끼리 분산 합의로 풀 이유가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;둘째, ISR 정책이 결정론적이다. Kafka 는 leader 후보 선택에 명확한 규칙이 있다. ISR에 속한 broker만 후보가 되고, preferred replica order 에 따라 선택된다. 같은 metadata 상태에서 controller가 계산하면 항상 같은 답이 나온다. 결정론적인 일에 굳이 vote 메커니즘을 쓸 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;일관성 코어가 권위를 갖고 결정하면, 워커 입장의 복잡한 분산 합의를 피할 수 있다. 덕분에 수만 개의 partition에 대한 leader 결정을 모두 broker 끼리 선거를 할 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;패턴의 trade-off&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;얻는 것&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;워커 노드는 거의 무한 수평 확장 가능&lt;/li&gt;
&lt;li&gt;코어는 작아서 합의가 빠름&lt;/li&gt;
&lt;li&gt;책임 분리가 명확 (메타데이터 vs 데이터)&lt;/li&gt;
&lt;li&gt;운영 모델이 단순 (한 곳만 보면 시스템 상태를 알 수 있음)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;잃는 것&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;코어가 critical path에 있음 &amp;rarr; 장애 시 전체 영향
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HA 구성 필수 (3~5대 quorum)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;코어 용량이 시스템 전체의 천장이 됨
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메타데이터를 무한정 코어에 넣을 수 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;워커는 코어 정보를 캐시해서 쓰므로 약간의 stale read 발생
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;eventual consistency를 받아들이는 설계 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;모든 메타데이터 변경이 코어를 거쳐야 하므로 throughput 한계 존재&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;관련 패턴들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Consistency Core는 단독으로 쓰이지 않고, 다음 패턴들과 조합되어 등장한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Replicated Log: 코어 내부에서 합의를 이루는 메커니즘 (Raft)&lt;/li&gt;
&lt;li&gt;Lease: 코어가 워커에게 임시 권한을 부여하는 방식&lt;/li&gt;
&lt;li&gt;Generation Clock / Epoch: leader 변경을 안전하게 처리&lt;/li&gt;
&lt;li&gt;Heartbeat: 코어가 워커의 생사 확인&lt;/li&gt;
&lt;li&gt;Gossip Dissemination: 코어 결정을 워커들에게 전파&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Raft, etcd, KRaft가 모두 이 패턴들의 조합으로 만들어진 것이고, 그래서 Patterns of Distributed Systems 카탈로그를 통독하면 분산 시스템의 큰 그림이 잡힌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://martinfowler.com/articles/patterns-of-distributed-systems/&quot;&gt;Martin Fowler - Patterns of Distributed Systems&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://martinfowler.com/articles/patterns-of-distributed-systems/consistent-core.html&quot;&gt;Consistent Core&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/169</guid>
      <comments>https://icarus8050.tistory.com/169#entry169comment</comments>
      <pubDate>Sun, 3 May 2026 15:54:09 +0900</pubDate>
    </item>
    <item>
      <title>WAL Compaction 설계기</title>
      <link>https://icarus8050.tistory.com/168</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&quot;30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법&quot; 책으로 스터디를 하면서 머릿속으로만 이해했던 개념들을 직접 코드로 옮겨보고 싶었다. 그래서 작은 인메모리 KV 스토어 &lt;a href=&quot;https://github.com/icarus8050/peacock&quot;&gt;peacock&lt;/a&gt;을 만들기 시작했다. WAL을 붙이고, 시간이 지나니 자연스럽게 &quot;로그가 무한히 자라는 문제&quot;에 부딪혔다. 이 글은 그 문제를 풀어가며 마주친 설계 결정들과, 그것을 어떻게 정리했는지에 대한 기록이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;WAL이 뭔가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;Write-Ahead Log&lt;/b&gt;는 이름 그대로 &quot;변경을 메모리에 적용하기 전에 디스크에 먼저 기록한다&quot;는 패턴이다. 데이터베이스 엔진의 거의 표준적인 영속성 메커니즘이다. PostgreSQL, MySQL의 InnoDB, RocksDB, LevelDB, etcd 등 안정성을 따지는 거의 모든 storage system이 어떤 형태로든 WAL을 갖고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 왜 필요한지는 단순한 시나리오로 명확해진다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;Put(&quot;a&quot;, 1)  &amp;rarr; 메모리 맵에 적용
Put(&quot;b&quot;, 2)  &amp;rarr; 메모리 맵에 적용
☠️ 크래시
재시작 &amp;rarr; 메모리는 휘발됐다. 데이터 손실.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;메모리만 가진 시스템의 운명이다. 영속성을 더하려면 디스크에 뭔가 남겨야 하는데, 매 쓰기마다 메모리 맵 전체를 디스크에 dump하는 건 비용이 크다. 그래서 차선의 답은 &lt;b&gt;&quot;변경 자체&quot;를 디스크에 append&lt;/b&gt;하는 것이다. 메모리 맵을 통째로 저장하지 않고, &quot;이런 일이 일어났다&quot;는 사실만 기록한다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;Put(&quot;a&quot;, 1)  &amp;rarr; ① 디스크 로그에 &quot;Put a=1&quot; entry append
              ② 메모리 맵에 적용
Put(&quot;b&quot;, 2)  &amp;rarr; ① 디스크 로그에 &quot;Put b=2&quot; entry append
              ② 메모리 맵에 적용
☠️ 크래시
재시작 &amp;rarr; 디스크 로그를 처음부터 끝까지 읽어 entry 순서대로 다시 적용
       &amp;rarr; 메모리 맵이 크래시 직전 상태로 복원됨&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이게 WAL의 핵심 아이디어다. &lt;b&gt;메모리 상태는 휘발성이지만, 그 상태를 만든 변경 이력은 디스크에 영속화돼 있다.&lt;/b&gt; 재시작 시 이력을 처음부터 다시 적용하면 마지막 상태가 재구성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;메모리에 적용하기 전에 로그가 먼저 디스크에 도달해야 한다. 만약 메모리에 먼저 쓰고 로그를 나중에 쓴다면, 두 단계 사이에 크래시 시 메모리에는 적용됐지만 로그엔 없는 변경이 생긴다 &amp;mdash; 재시작 시 이 변경이 사라져 inconsistency가 발생한다. 그래서 디스크 로그가 항상 &quot;선행&quot;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;peacock의 KV는 이 패턴 그대로다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;PUT(k, v) &amp;rarr;  ① WAL에 entry append (디스크에 fsync까지)
            ② 메모리 맵에 적용
GET(k)    &amp;rarr;  메모리 맵에서 직접 lookup&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 entry는 다음 바이너리 레이아웃으로 직렬화된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;TotalLen(4) | CRC32(4) | Op(1) | Index(8) | TimeStamp(8) | DataLen(4) | Data(var)
                        &amp;uarr;─────────────── CRC32 대상 ──────────────────&amp;uarr;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;TotalLen&lt;/code&gt;: entry body의 바이트 수. 다음 entry로 점프할 때 사용.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CRC32&lt;/code&gt;: 본문 무결성 체크. 디스크가 일부만 fsync된 상태에서 크래시한 경우(tail truncation)를 감지한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Op&lt;/code&gt;: &lt;code&gt;OpPut&lt;/code&gt; 또는 &lt;code&gt;OpDelete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Data&lt;/code&gt;: KV 레이어가 정의한 페이로드 (&lt;code&gt;KeyLen | Key | Value&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여러 entry가 하나의 segment 파일(&lt;code&gt;wal-NNNNNNNNNN.log&lt;/code&gt;)에 append되고, segment가 일정 크기를 넘기면 다음 시퀀스 파일로 &lt;b&gt;roll&lt;/b&gt;된다. 파일 하나가 무한히 커지지 않게 자르는 단위다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;인메모리 맵은 그저 빠른 lookup만 신경 쓰면 되고, 영속성은 WAL이 다 알아서 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;그런데 이 단순함이 곧 한계가 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어쩌다 compaction까지 왔나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 키를 100번 덮어쓰면 WAL에는 100개 entry가 쌓인다. 99개는 replay 시 읽히기만 하고 버려지는 죽은 데이터다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;Put(&quot;counter&quot;, 1)   &amp;rarr; entry 0
Put(&quot;counter&quot;, 2)   &amp;rarr; entry 1
...
Put(&quot;counter&quot;, 1000) &amp;rarr; entry 999&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;재시작하면 1000개 entry를 다 읽어 999번의 무의미한 덮어쓰기를 거친 뒤에야 최종 상태(&lt;code&gt;counter=1000&lt;/code&gt;)에 도달한다. 디스크 점유와 startup 시간이 운영 시간에 비례해 선형으로 증가한다.&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;&amp;nbsp;Snapshot&lt;/b&gt;은 주기적으로 메모리 맵 전체를 디스크에 덤프하는 방식이다. snap만 있으면 그 시점 상태를 즉시 복원할 수 있어 단순하다. 그런데 snapshot을 만드는 동안 메모리 맵의 상태를 일관되도록 잠궈야 하는 게 문제점이 있다. 쓰기 잠금을 걸면 stall이 발생하고, deepCopy로 우회하면 큰 맵에서 메모리 spike와 복제 stall이 따라온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;Compaction&lt;/b&gt;은 로그가 롤링되어 더 이상 Write 되지 않는 WAL segment 파일을 읽어서 최신 상태의 엔트리 로그만 남긴 새 파일을 Write한다. 과거의 segment는 더 이상 쓰이지 않으니 메모리 맵과 어떤 자원도 공유하지 않기 때문에 제거할 수 있다. 이 과정은 백그라운드 goroutine이 디스크만 I/O하고 끝낸다. 덕분에 writer를 잠그는 시간을 최소화 할 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;사이드 프로젝트 성격의 간단한 서버라 Snapshot 방식으로도 충분하지만 이왕 해보는거 Compaction 방식으로 구현해 보고 싶어서 이 방식을 택했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Phase 1: 매니페스트 도입&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;compaction을 하려면 &quot;지금 살아있는 segment 파일은 정확히 어떤 것들인가?&quot;를 파악해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;첫 시도는 디렉터리에 있는 &lt;code&gt;wal-*.log&lt;/code&gt;를 모두 시퀀스 번호로 읽는 것이었다. 평상시엔 동작한다. 그런데 compaction 도중 죽으면 끔찍해진다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;T0: [1.log, 2.log, 3.log, 4.log, 5.log] (활성=5)
T1: 압축 시작 &amp;rarr; 새 체크포인트 작성 중
T2: ☠️ 크래시
디스크 상태:
  - 1.log ~ 4.log: 옛 데이터
  - 5.log: 활성
  - tmp 또는 부분 체크포인트 파일: 알 수 없는 상태&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이걸 디스크 스캔으로 다시 열면, &quot;어디까지가 권위 있는 데이터인가&quot;를 알 수 없다. 부분 파일을 합법적인 segment로 오인하면 데이터가 망가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;그래서 manifest파일에 segment 목록을 관리하도록 했다. 이 파일이 &quot;지금 살아있는 segment는 정확히 이것뿐이다&quot;를 체크할 수 있게 해준다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;manifest 파일 (v1) 레이아웃:
  Magic         (4)  &quot;PCKM&quot;
  Version       (2)  uint16, 1
  Reserved      (2)  uint16, 0 고정
  Generation    (8)  uint64, 갱신마다 단조 증가
  SegmentCount  (4)  uint32
  Records       SegmentCount &amp;times; Seq(int64, 8)
  CRC32         (4)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 파일이 가리키지 않는 모든 디스크 상의 &lt;code&gt;wal-*.log&lt;/code&gt;는 고아로 간주해 무시한다. compaction 도중 죽어 부분 파일이 떠 있어도 매니페스트가 그것을 가리키지 않으면 무시된다. 덕분에 &quot;어떤 파일이 진짜인가&quot;라는 모호함이 사라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Phase 2: Compaction&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매니페스트가 자리잡으면 compaction의 큰 그림은 단순하다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;1. 과거의 segment를 읽어 키별 최신 값을 빌드
2. 결과를 새 체크포인트 파일에 직렬화
3. 매니페스트 갱신 &amp;rarr; {checkpointSeq, [active]}
4. 과거의 segment 파일 제거&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;체크포인트의 결정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;처음엔 가볍게 가정했다. segment 파일이 [1, 2, 3, 4, 5(활성화 상태)] 있다고 생각했을 때, &quot;압축 결과도 그냥 또 하나의 segment 파일이지.&quot; 즉 봉인 [1..4]를 압축하면 새 segment seq를 부여해서(예: max+1=6) &lt;code&gt;wal-6.log&lt;/code&gt;로 저장.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하지만 활성이 5인데 압축 결과가 6이라면 파일명이 의미상 1..5의 데이터를 담고 있는데 현재 Write 중인 5보다 높은 seq를 가지므로 &quot;더 새로운 데이터&quot;라는 직관에 어긋난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 가지 옵션을 두고 고민했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;옵션 (a) 옛 source의 seq 상속 + 덮어쓰기.&lt;/b&gt; 활성 세그먼트 이전의 max seq를 그대로 받아 컴팩션하고 그 자리를 덮어쓴다. 즉, 4.log가 압축본으로 바뀐다. 하지만 운영자가 디스크를 봤을 때 &lt;code&gt;wal-4.log&lt;/code&gt;가 원본인지 압축본인지 식별 불가능하다. 중간에 예상치 못한 문제로 종료가 되면 WAL 로그 자체가 깨질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;옵션 (b) max+1 새 seq.&lt;/b&gt; 압축 결과가 새 seq를 받는다. 모든 파일이 다른 seq를 가져 운영 식별이 명확하지만, WAL의 &lt;code&gt;seq&lt;/code&gt; 필드가 두 의미를 짊어지게 된다 &amp;mdash; &quot;활성 식별&quot;과 &quot;다음 할당 카운터&quot;. 분리하려면 두 필드 필요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;옵션 (c) 별도 명명: &lt;code&gt;*.checkpoint&lt;/code&gt;.&lt;/b&gt; 압축 결과는 &lt;code&gt;wal-N.checkpoint&lt;/code&gt;라는 다른 이름. seq 공간 자체가 분리.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Before: [1.log, 2.log, 3.log, 4.log, 5.log]  활성=5
After:  [4.checkpoint, 5.log]                활성=5, seq counter=5
다음 roll: [4.checkpoint, 5.log, 6.log]      활성=6&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체크포인트 seq는 흡수한 마지막 봉인 seq를 상속하지만, 파일명 suffix가 달라 활성과 절대 충돌하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(c)를 택한 결정적 이유는 &quot;체크포인트는 segment와 의미가 다르다&quot;는 인식이었다. 체크포인트는 스냅샷이고 segment는 이력이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;비용은 매니페스트 포맷 변경 한 번. 매니페스트가 체크포인트의 존재를 알아야 하니 v1에 &lt;code&gt;CheckpointSeq&lt;/code&gt; 필드를 추가한 v2를 만들었다.&lt;/p&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;manifest v2:
  Magic | Version=2 | Reserved | Generation | CheckpointSeq(8) | SegmentCount | Seq... | CRC
                                              &amp;uarr; v2 신규&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;0이면 &quot;체크포인트 없음&quot;, &amp;gt; 0이면 &lt;code&gt;wal-NNN.checkpoint&lt;/code&gt;가 매니페스트 segments보다 먼저 replay된다.&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;Compaction commit point&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;매니페스트 갱신은 단일 commit point가 되어야 한다. 압축 commit은 단일 syscall이 아니다. 여러 단계가 순차적으로 일어난다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;1. tmp 파일에 체크포인트 entry들 쓰기 (bufio + Sync)
2. tmp.Close
3. os.Rename(tmp &amp;rarr; wal-N.checkpoint)
4. writeManifest(새 매니페스트)   &amp;larr; tmp + rename + dir-fsync
5. 옛 segment / 옛 체크포인트 unlink&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POSIX는 단일 syscall만 atomic하므로 이 묶음을 통째로 atomic하게 만들 수 없다. 그런데 매니페스트 갱신(4)을 commit point로 정의하면 어디서 죽어도 안전하다.&lt;/p&gt;
&lt;table style=&quot;height: 160px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;죽는 시점&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;디스크 상태&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;매니페스트&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1 도중&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;부분 tmp&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;tmp는 다음 시도에서 &lt;code&gt;O_TRUNC&lt;/code&gt;로 덮임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1 후, 3 전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;완전한 tmp&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;tmp는 다음에 덮임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;3 도중&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;rename atomic &amp;mdash; 과거 또는 새 버전 체크포인트&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;어느 쪽이든 매니페스트 밖 &amp;rarr; 무시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;3 후, 4 전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;완전한 새 버전 체크포인트&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;매니페스트 밖 &amp;rarr; 고아 무시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;4 도중&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;atomic&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거 또는 새 버전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;둘 다 정확&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;4 후, 5 전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거 segment 잔존&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;새 버전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;매니페스트 밖&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;5 도중&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;일부 unlink&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;새 버전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;매니페스트&amp;nbsp;밖&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;매니페스트 갱신 이전에 죽으면 압축은 무효, 이후에 죽으면 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동시 호출 보호&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;(*WAL).Compact&lt;/code&gt;는 공개 API다. 두 goroutine이 동시에 호출하면 같은 sealed 파일을 두 번 처리하고 매니페스트 갱신이 충돌한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;goroutine A: BeginCompaction() &amp;rarr; sealed=[1,2,3,4] 스냅샷
goroutine B: BeginCompaction() &amp;rarr; sealed=[1,2,3,4] 같은 스냅샷
A: 파일 read + checkpoint write
B: 파일 read + checkpoint write
A: CommitCompaction([1,2,3,4]) &amp;rarr; 성공
B: CommitCompaction([1,2,3,4]) &amp;rarr; 검증 실패 (sealed에 더 이상 없음)
   &amp;rarr; B의 체크포인트는 매니페스트 밖이라 고아로 남음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;정확성은 무너지지 않지만 디스크 leak와 운영 잡음이 발생한다. 이를 해결하기 위해 WAL에 &lt;code&gt;compacting bool&lt;/code&gt; 필드를 두고 락 안에서 체크하도록 했다.&lt;/p&gt;
&lt;pre class=&quot;go&quot;&gt;&lt;code&gt;func (w *WAL) beginCompaction(trigger int) (compactionPlan, bool) {
    w.mu.Lock()
    defer w.mu.Unlock()

    if w.closed || w.compacting {
        return compactionPlan{}, false
    }
    w.compacting = true
    return plan, true
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;압축 본체는 &lt;code&gt;compacting&lt;/code&gt; 플래그가 true인 상태에서 락 밖으로 나가서 진행된다. 그래서 Append/roll은 압축의 read 단계와 동시 진행 가능하고(자원이 다르니까), 충돌 구간은 commit 시점뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Development/Distributed System 만들어보기 [Peacock]</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/168</guid>
      <comments>https://icarus8050.tistory.com/168#entry168comment</comments>
      <pubDate>Sun, 3 May 2026 04:39:39 +0900</pubDate>
    </item>
    <item>
      <title>Paxos 합의 알고리즘</title>
      <link>https://icarus8050.tistory.com/167</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Paxos는 Leslie Lamport가 1989년에 제안한 합의 알고리즘으로, 이런 환경에서도 &lt;b&gt;단 하나의 값만 안전하게 결정&lt;/b&gt;되도록 보장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Raft, Kafka(KRaft), etcd 등 현대 분산 시스템의 합의 메커니즘을 이해하려면, 그 원조인 Paxos를 먼저 이해하는 것이 중요하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결하려는 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;분산 환경에서 노드들이 &quot;이 값을 X로 하자&quot;라고 제안할 때, 일부 노드가 다운되거나 메시지가 지연되더라도 &lt;b&gt;단 하나의 값만 최종 결정&lt;/b&gt;되어야 한다. 두 노드가 동시에 다른 값을 제안하더라도, 시스템 전체는 반드시 하나만 선택해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;세 가지 역할&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Paxos에는 세 가지 역할이 있으며, 하나의 노드가 여러 역할을 동시에 수행할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;Client &amp;rarr; Proposer &amp;rarr; Acceptor(A, B, C) &amp;rarr; Learner
         (값 제안)     (투표/수락)         (결과 학습)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Proposer&lt;/b&gt;는 클라이언트의 요청을 받아 &quot;이 값을 합의하자&quot;고 제안을 시작하는 역할이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Acceptor&lt;/b&gt;는 제안을 수락하거나 거부하는 투표자 역할이다. 과반수(majority)의 Acceptor가 수락해야 값이 결정된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Learner&lt;/b&gt;는 최종 결정된 값을 전달받아 학습하는 역할이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;두 단계 프로토콜&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Paxos는 &lt;b&gt;Prepare-Promise&lt;/b&gt;와 &lt;b&gt;Accept-Accepted&lt;/b&gt; 두 단계로 동작한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1단계: Prepare &amp;rarr; Promise&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Proposer가 고유한 제안 번호(proposal number) N을 선택하고, 모든 Acceptor에게 Prepare(N) 메시지를 보낸다. 이것은 &quot;나 N번 제안을 하려고 하는데, 받아줄 수 있어?&quot;라는 요청이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Acceptor는 N이 자신이 지금까지 본 제안 번호 중 가장 크면, 두 가지를 약속한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;N보다 작은 번호의 제안은 앞으로 수락하지 않겠다.&lt;/li&gt;
&lt;li&gt;이전에 수락했던 제안이 있으면 그 정보(제안 번호와 값)를 Proposer에게 돌려준다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 응답이 Promise이다. 만약 N이 이미 본 번호보다 작으면, Acceptor는 무시하거나 거부한다.&lt;/p&gt;
&lt;pre class=&quot;gherkin&quot;&gt;&lt;code&gt;Proposer                    Acceptor A, B, C
   |                              |
   |--- Prepare(N=1) -----------&amp;gt;|
   |                              | N=1 &amp;gt; 0 이므로 OK
   |&amp;lt;-- Promise(N=1) ------------|
   |   (이전 수락 값 없음)          |
   |   과반수 Promise 수신!        |
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계: Accept &amp;rarr; Accepted&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Proposer가 과반수의 Acceptor로부터 Promise를 받으면, 다음 단계로 진행한다. 이때 &lt;b&gt;제안할 값을 결정하는 규칙&lt;/b&gt;이 핵심이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Promise 응답 중에 이전에 수락된 값이 있으면, &lt;b&gt;가장 높은 제안 번호에 연결된 값&lt;/b&gt;을 자신의 제안 값으로 사용해야 한다.&lt;/li&gt;
&lt;li&gt;이전에 수락된 값이 하나도 없으면, 자신이 원래 제안하려던 값을 사용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Proposer가 Accept(N, value) 메시지를 Acceptor들에게 보내고, Acceptor는 그 사이에 N보다 큰 번호의 Prepare를 받지 않았다면 이 제안을 수락한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과반수의 Acceptor가 수락하면, 해당 값이 **최종 결정(chosen)**된다.&lt;/p&gt;
&lt;pre class=&quot;gherkin&quot;&gt;&lt;code&gt;Proposer                    Acceptor A, B, C
   |                              |
   |--- Accept(N=1, V=&quot;X&quot;) -----&amp;gt;|
   |                              | N=1이 아직 유효하므로 수락
   |&amp;lt;-- Accepted(N=1) -----------|
   |   과반수 Accepted!           |
   |   &quot;X&quot;가 최종 결정됨            |
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;충돌 시나리오: 두 Proposer가 동시에 제안할 때&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적인 경우는 단순하지만, 핵심은 &lt;b&gt;충돌이 발생했을 때 어떻게 안전성을 보장하는가&lt;/b&gt;이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오: P1이 &quot;X&quot;를, P2가 &quot;Y&quot;를 제안&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Step 1.&lt;/b&gt; P1이 Prepare(N=1)을 A, B에게 보내고 과반수 Promise를 확보한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Step 2.&lt;/b&gt; P1이 Accept(1, &quot;X&quot;)를 보내기 전에, P2가 Prepare(N=2)를 B, C에게 보내서 과반수 Promise를 확보한다. B는 이제 N=2에 Promise했으므로 N=1 이하의 제안을 수락하지 않겠다고 약속한 상태다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Step 3.&lt;/b&gt; P1이 Accept(1, &quot;X&quot;)를 보내면, A는 수락하지만 &lt;b&gt;B는 거부&lt;/b&gt;한다 (이미 N=2에 Promise했으므로). P1은 과반수 수락에 실패한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Step 4.&lt;/b&gt; P2가 Accept(2, &quot;Y&quot;)를 B, C에게 보내면, 둘 다 수락한다. 과반수 달성으로 &lt;b&gt;&quot;Y&quot;가 최종 결정&lt;/b&gt;된다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;시간 &amp;rarr;

P1: Prepare(1) &amp;rarr; A,B Promise   Accept(1,&quot;X&quot;) &amp;rarr; A 수락, B 거부! &amp;rarr; 실패
P2:              Prepare(2) &amp;rarr; B,C Promise     Accept(2,&quot;Y&quot;) &amp;rarr; B,C 수락 &amp;rarr; &quot;Y&quot; 결정!

핵심: 제안 번호가 더 큰 쪽이 이긴다.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;안전성의 핵심: 이전에 수락된 값 이어받기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 값이 결정된 후 새로운 Proposer가 나타나면 어떻게 될까?&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오: &quot;Y&quot;가 결정된 후 P3가 &quot;Z&quot;를 제안하려는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B와 C가 Accept(2, &quot;Y&quot;)를 수락하여 &quot;Y&quot;가 결정된 상태에서, P3가 Prepare(N=3)을 모든 Acceptor에게 보낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 Acceptor는 Promise와 함께 &lt;b&gt;이전에 수락했던 값&lt;/b&gt;을 돌려준다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;A: Promise(3) + 이전 수락 정보 (1, &quot;X&quot;)&lt;/li&gt;
&lt;li&gt;B: Promise(3) + 이전 수락 정보 (2, &quot;Y&quot;)&lt;/li&gt;
&lt;li&gt;C: Promise(3) + 이전 수락 정보 (2, &quot;Y&quot;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;P3는 이 중에서 &lt;b&gt;가장 높은 제안 번호(2)에 연결된 값 &quot;Y&quot;&lt;/b&gt;를 자신의 제안 값으로 사용해야 한다. 원래 &quot;Z&quot;를 제안하고 싶었지만, Accept(3, &quot;Y&quot;)를 보내야 한다.&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;P3가 받은 Promise 응답:
  A &amp;rarr; (1, &quot;X&quot;)
  B &amp;rarr; (2, &quot;Y&quot;)
  C &amp;rarr; (2, &quot;Y&quot;)

규칙: max(proposal number)에 연결된 값 선택
  max = 2 &amp;rarr; 값 = &quot;Y&quot;

&amp;there4; P3는 Accept(3, &quot;Y&quot;)를 전송 &amp;rarr; &quot;Y&quot;가 다시 확인됨
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 규칙이 Paxos 안전성의 핵심이다. &lt;b&gt;과반수끼리는 반드시 최소 하나의 Acceptor가 겹치기 때문에&lt;/b&gt;, 새로운 Proposer는 항상 이전에 결정된 값을 발견하게 된다. 한번 결정된 값은 절대 바뀌지 않는다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Paxos 알고리즘의 한계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Livelock - Safety는 보장하지만, Liveness는 보장하지 못한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Basic Paxos를 그대로 사용하면 트래픽이 큰 시스템에서 심각한 성능 문제가 발생한다. Proposer 간 충돌이 반복되면서 라이브락(livelock)이 발생할 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 P1이 Prepare(1)로 과반수 Promise를 받고 Accept를 보내려는 순간, P2가 Prepare(2)를 보내서 Acceptor들의 약속을 덮어쓴다. P1이 실패하고 다시 Prepare(3)을 보내면, 이번엔 P2의 Accept가 거부된다. 이렇게 서로 끊임없이 상대방을 무효화하면서 &lt;b&gt;아무도 값을 결정하지 못하는 상태&lt;/b&gt;가 반복될 수 있다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;Proposer A: prepare(n=1) &amp;rarr; 과반 Promise 획득
Proposer B: prepare(n=2) &amp;rarr; 과반 Promise 획득 (A의 n=1 무효화)
Proposer A: accept(n=1)  &amp;rarr; 거절됨 &amp;rarr; prepare(n=3) 재시도
Proposer B: accept(n=2)  &amp;rarr; 거절됨 &amp;rarr; prepare(n=4) 재시도
...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 성능 한계&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2라운드 메시지 교환&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Basic Paxos에서 하나의 값에 합의하려면 최소 &lt;b&gt;2라운드의 메시지 교환&lt;/b&gt;이 필요하다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Round 1: Proposer &amp;rarr; Acceptor  (Prepare)
         Acceptor &amp;rarr; Proposer  (Promise)

Round 2: Proposer &amp;rarr; Acceptor  (Accept)
         Acceptor &amp;rarr; Proposer  (Accepted)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;각 라운드는 네트워크 왕복(RTT) 1회와 Acceptor 측의 디스크 fsync를 수반한다. 합의 한 건당 &lt;b&gt;2 RTT + 2 fsync&lt;/b&gt;가 최소 비용이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Multi-Paxos에서 안정적인 Leader가 확보되면 Prepare 단계를 생략하여 1라운드로 줄일 수 있다. 하지만 Leader가 교체되면 다시 2라운드로 돌아가며, Leader 교체가 잦은 불안정한 환경에서는 이 최적화의 효과가 반감된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Proposer 충돌에 의한 Throughput 저하&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Paxos의 기본 구조에서는 &lt;b&gt;모든 노드가 Proposer가 될 수 있다.&lt;/b&gt; 이론적으로는 유연하지만, 실제로 여러 Proposer가 동시에 활동하면 충돌이 잦아지고 재시도가 늘어나면서 throughput이 급격히 떨어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;지리적 분산 환경에서의 Latency&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Quorum 기반 알고리즘의 특성상, 과반수 응답을 기다려야 한다. 노드가 서울, 도쿄, 버지니아에 분산되어 있다면, 가장 느린 quorum 멤버의 RTT가 전체 합의 latency를 결정한다. 이 문제는 Paxos에만 국한되지 않지만, 2라운드 구조가 latency를 더 증폭시킨다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결책: Multi-Paxos과 Raft&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 문제를 해결하기 위한 아이디어는 &lt;b&gt;리더를 하나 선출하고, 리더만 Proposer 역할을 하도록 제한&lt;/b&gt;하는 것이다. 이 아이디어를 Paxos에 적용한 것이 Multi-Paxos이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;리더가 안정적으로 유지되는 동안에는 Prepare 단계를 매번 반복할 필요가 없다. 리더가 한 번 Prepare로 과반수의 Promise를 확보하면, 이후의 요청들은 Accept 단계만으로 처리할 수 있다. &lt;b&gt;2단계 프로토콜이 사실상 1단계로 줄어드는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[Basic Paxos]
  매 요청마다: Prepare &amp;rarr; Promise &amp;rarr; Accept &amp;rarr; Accepted  (2 RTT)

[Multi-Paxos (리더 안정 시)]
  최초 1회: Prepare &amp;rarr; Promise
  이후:     Accept &amp;rarr; Accepted  (1 RTT)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 구조에서는 충돌이 원천적으로 발생하지 않는다. 클라이언트 요청이 모두 리더를 통해 직렬화되기 때문이다. 리더가 다운되었을 때만 새 리더를 선출하고, 그 과정에서 잠깐의 충돌이 있을 수 있지만 정상 운영 중에는 충돌 없이 높은 처리량을 유지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Raft는 Multi-Paxos의 리더 중심 구조를 프로토콜 자체에 내장하면서, 리더 선출, 로그 복제, 안전성을 하나의 통합된 프로토콜로 명확하게 정의했다. &quot;이해하기 쉬운 합의 알고리즘&quot;을 목표로 설계되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;현재 대부분의 분산 시스템은 Raft를 선택한다. etcd, Consul, CockroachDB, TiKV, Kafka(KRaft) 등이 모두 Raft 기반이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 정리&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Paxos는 두 단계 프로토콜이다.&lt;/b&gt; Prepare-Promise로 제안 권한을 확보하고, Accept-Accepted로 값을 결정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;제안 번호가 안전성의 기반이다.&lt;/b&gt; 더 큰 번호의 Prepare가 오면 이전 약속이 무효화되어, 항상 최신 제안이 우선한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이전에 수락된 값을 이어받는 규칙이 핵심이다.&lt;/b&gt; Promise 응답에서 가장 높은 제안 번호의 값을 사용해야 하므로, 한번 결정된 값은 절대 바뀌지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;과반수의 교집합이 안전성을 보장한다.&lt;/b&gt; 어떤 두 과반수든 최소 하나의 Acceptor가 겹치므로, 결정된 값은 반드시 새로운 Proposer에게 전달된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Basic Paxos는 실무에서 그대로 쓰기 어렵다.&lt;/b&gt; 라이브락 문제 때문에 Multi-Paxos(리더 선출)로 확장하여 사용하며, Raft는 이 구조를 처음부터 프로토콜에 내장한 것이다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/167</guid>
      <comments>https://icarus8050.tistory.com/167#entry167comment</comments>
      <pubDate>Sat, 4 Apr 2026 13:49:00 +0900</pubDate>
    </item>
    <item>
      <title>Raft Figure 8 문제 파헤치기 - &amp;quot;과반수 복제 = 커밋&amp;quot;이 위험한 이유</title>
      <link>https://icarus8050.tistory.com/166</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;분산 시스템에서 합의(Consensus) 알고리즘은 여러 노드가 하나의 일관된 상태를 유지하도록 보장하는 핵심 메커니즘이다. Raft는 이해하기 쉬운 합의 알고리즘을 목표로 설계되었지만, 그 안에는 깊이 생각하지 않으면 놓칠 수 있는 미묘한 안전성 문제가 숨어 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그중 대표적인 것이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Figure 8 문제&lt;/b&gt;다. 이 글에서는 Raft 논문의 Figure 8이 보여주는 시나리오를 단계별로 분석하고, 해결 규칙이 왜 필요한지, 그리고 이것이 Kafka(KRaft)와 어떻게 다른지까지 살펴본다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Raft의 기본 커밋 규칙&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Raft에서 로그 엔트리가 커밋되려면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;과반수(majority) 노드에 복제&lt;/b&gt;되어야 한다. 리더가 엔트리를 기록하고, 팔로워들에게 AppendEntries RPC로 복제한 뒤, 과반수 이상이 응답하면 커밋으로 간주한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 규칙은 직관적이고 단순하다. 하지만&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&quot;이전 텀의 엔트리를 새 리더가 복제하는 경우&quot;&lt;/b&gt;에는 이 규칙만으로 안전성을 보장할 수 없다. Figure 8이 바로 이 상황을 보여준다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Figure 8 시나리오 분석&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;5개의 노드(S1~S5)가 있는 클러스터를 가정한다. 각 단계에서 어떤 일이 벌어지는지 살펴보자.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;(a) 텀 2 &amp;mdash; S1이 리더&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;S1이 리더로 선출되어 인덱스 2에 텀 2 엔트리를 기록한다. S2에게만 복제한 뒤 S1이 다운된다.&lt;/p&gt;
&lt;pre style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;         idx 1    idx 2
S1 ★   [  1  ]  [  2  ]    &amp;larr; 리더, 다운됨
S2     [  1  ]  [  2  ]    &amp;larr; 복제 받음
S3     [  1  ]
S4     [  1  ]
S5     [  1  ]&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 시점에서 인덱스 2(텀 2)는 S1, S2 두 곳에만 있다. 과반수(3)에 도달하지 못했으므로 미커밋 상태다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;(b) 텀 3 &amp;mdash; S5가 리더&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;S5가 새 리더로 선출되어 인덱스 2에 텀 3 엔트리를 기록한다. 아직 어디에도 복제하지 않은 상태다.&lt;/p&gt;
&lt;pre style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;         idx 1    idx 2
S1     [  1  ]  [  2  ]    &amp;larr; 다운 상태
S2     [  1  ]  [  2  ]
S3     [  1  ]
S4     [  1  ]
S5 ★   [  1  ]  [  3  ]    &amp;larr; 새 리더&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;(c) 텀 4 &amp;mdash; S1이 다시 리더&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;S1이 복귀하여 텀 4의 리더가 된다. S1은 인덱스 3에 텀 4 엔트리를 기록한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이때 S3의 로그가 뒤처져 있으므로 AppendEntries의 일관성 검사(consistency check) 과정에서 nextIndex가 줄어들면서, 인덱스 2의 텀 2 엔트리가 S3에 복제된다.&lt;/p&gt;
&lt;pre style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;         idx 1    idx 2    idx 3
S1 ★   [  1  ]  [  2  ]  [  4  ]    &amp;larr; 리더
S2     [  1  ]  [  2  ]
S3     [  1  ]  [  2  ]              &amp;larr; 복제 받음!
S4     [  1  ]
S5     [  1  ]  [  3  ]&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;인덱스 2(텀 2)가 S1, S2, S3 세 곳에 존재한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;과반수를 달성했다!&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;만약 여기서 &quot;과반수에 복제되었으니 커밋&quot;이라고 판단하면 어떻게 될까?&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;(d) 문제 발생 &amp;mdash; S5가 텀 5 리더&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;S1이 다시 다운되고, S5가 텀 5의 리더로 선출된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;S5가 리더로 선출될 수 있는 이유가 중요하다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Raft의 투표 규칙은 마지막 로그의 텀을 먼저 비교&lt;/b&gt;한다. S5의 마지막 로그 텀은 3이고, S2와 S3의 마지막 로그 텀은 2다. 텀 3 &amp;gt; 텀 2이므로 S5가 &quot;더 최신&quot;으로 판단되어 투표를 받을 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;S5가 리더가 되면 자신의 로그가 진실(truth)이 된다. S5의 인덱스 2에는 텀 3 엔트리가 있으므로, S1, S2, S3의 텀 2 엔트리는 텀 3 엔트리로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;덮어씌워진다&lt;/b&gt;.&lt;/p&gt;
&lt;pre style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;         idx 1    idx 2    idx 3
S1     [  1  ]  [  2  ]  [  4  ]    &amp;larr; 다운, 복귀 시 덮어씌워짐
S2     [  1  ]  [  3  ]             &amp;larr; 덮어씌워짐!
S3     [  1  ]  [  3  ]             &amp;larr; 덮어씌워짐!
S4     [  1  ]  [  3  ]             &amp;larr; 덮어씌워짐!
S5 ★   [  1  ]  [  3  ]             &amp;larr; 리더&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(c)에서 커밋되었다고 판단한 인덱스 2(텀 2) 엔트리가 사라졌다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이것은 Raft의 핵심 안전성 속성인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&quot;커밋된 로그는 절대 유실되지 않는다&quot;를 정면으로 위반&lt;/b&gt;한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;해결 규칙: 이전 텀의 엔트리를 직접 커밋하지 않는다&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 방지하기 위해 Raft는 다음 규칙을 도입한다.&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #000000; color: #333333; text-align: center;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Raft는 이전 텀의 로그 엔트리를 복제본 수를 세는 방식으로 커밋하지 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;새 리더는 반드시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;현재 텀의 엔트리&lt;/b&gt;를 로그에 추가하고, 이것을 과반수에 복제하여 커밋해야 한다. Raft의 로그는 순차적이므로, 현재 텀의 엔트리가 커밋되면 그 앞의 이전 텀 엔트리들도 간접적으로 커밋된다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;no-op: 현재 텀의 빈 엔트리&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;새 리더가 취임 직후 사용하는 &quot;현재 텀의 엔트리&quot;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;no-op(no operation)&lt;/b&gt;이라 한다. 말 그대로 아무 작업도 하지 않는 빈 엔트리지만, 현재 텀 번호가 찍혀 있다는 것이 핵심이다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;(e) 규칙 적용 후 &amp;mdash; 안전한 시나리오&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(c)에서 S1은 이전 텀(텀 2)의 인덱스 2를 직접 커밋하지 않는다. 대신 인덱스 3에 텀 4 엔트리를 과반수(S1, S2, S3)에 복제한다.&lt;/p&gt;
&lt;pre style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;         idx 1    idx 2    idx 3
S1 ★   [  1  ]  [  2  ]  [  4  ]    &amp;larr; 리더
S2     [  1  ]  [  2  ]  [  4  ]    &amp;larr; 텀 4 복제 완료
S3     [  1  ]  [  2  ]  [  4  ]    &amp;larr; 텀 4 복제 완료
S4     [  1  ]
S5     [  1  ]  [  3  ]&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;텀 4 엔트리가 과반수에 복제되어 커밋되면, 로그 순서에 의해 인덱스 2(텀 2)도 간접 커밋된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 상태에서 S5가 리더에 도전하면 어떻게 될까? S2, S3의 마지막 로그 텀은 4이고, S5의 마지막 로그 텀은 3이다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;텀 4 &amp;gt; 텀 3이므로 S2, S3은 S5에게 투표하지 않는다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;S5는 과반수 투표를 받을 수 없어 리더 선출 자체가 불가능하다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;(d) 상황이 원천 차단&lt;/b&gt;된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;커밋되지 않은 엔트리의 운명&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;만약 S1이 텀 4 엔트리를 과반수에 복제하기 전에 다운되면 어떻게 될까?&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;인덱스 2는 미커밋 상태이므로,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;누가 리더가 되느냐에 따라 텀 2 엔트리 또는 텀 3 엔트리 중 하나가 채택&lt;/b&gt;된다. Raft의 투표 규칙이 자연스럽게 더 최신 로그를 가진 쪽이 리더가 되도록 유도하고, 리더의 로그가 곧 진실이 된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;어느 쪽이 선택되든 미커밋 엔트리이므로 안전성을 위반하지 않는다. Raft가 보장하는 것은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&quot;커밋된 로그는 절대 유실되지 않는다&quot;&lt;/b&gt;이지, &quot;복제된 로그가 유실되지 않는다&quot;가 아니기 때문이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Kafka(KRaft)는 다르게 동작한다&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;같은 합의 문제를 Kafka는 다른 방식으로 해결한다. Kafka에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;하이워터마크(High Watermark)&lt;/b&gt;를 기준으로 커밋 여부를 판단하는데, 하이워터마크가 팔로워의 fetch 요청을 통해 비동기적으로 전파된다는 점이 Raft와 다르다.&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;Raft에서는 리더가 AppendEntries 응답으로 팔로워의 복제 상태를 실시간으로 파악하고 commitIndex를 직접 계산한다. 반면 Kafka에서는 팔로워가 리더에게 fetch 요청을 보내면서 자신의 현재 오프셋을 알려주고, 리더가 이를 보고 하이워터마크를 갱신한 뒤 다음 fetch 응답에 실어 보내는 구조다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 때문에 팔로워가 알고 있는 하이워터마크는 항상 리더보다&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;최소 한 라운드 이상 뒤처져&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;있다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Log Truncation&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;새 리더로 승격된 팔로워는 자신의 하이워터마크까지만 커밋이 확정된 것으로 판단하고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;그 이후의 로그는 잘라낸다(truncate)&lt;/b&gt;. Raft처럼 no-op을 써서 이전 엔트리를 살리는 방식을 택하지 않았다.&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;Log truncation으로 인해 잘려나간 메시지는 유실될 수 있다. 하지만 이것이 실제 &quot;데이터 손실&quot;로 이어지는지는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;프로듀서의&lt;span&gt;&amp;nbsp;&lt;/span&gt;acks&lt;span&gt;&amp;nbsp;&lt;/span&gt;설정&lt;/b&gt;에 따라 결정된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;acks 설정동작유실 가능성&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;acks=0&lt;/td&gt;
&lt;td&gt;프로듀서가 응답을 기다리지 않음&lt;/td&gt;
&lt;td&gt;유실 인지 불가, 재전송 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acks=1&lt;/td&gt;
&lt;td&gt;리더 기록 시점에 성공 응답&lt;/td&gt;
&lt;td&gt;유실 사실 인지 못함 &amp;rarr; 실질적 데이터 유실&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acks=all&lt;/td&gt;
&lt;td&gt;ISR 전체 복제 후 성공 응답&lt;/td&gt;
&lt;td&gt;리더 다운 시 타임아웃 &amp;rarr; 프로듀서가 재전송 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Kafka의 설계 철학은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;유실 방지의 책임을 프로듀서에게 선택권을 주는 것&lt;/b&gt;이다. 처리량이 중요하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;acks=1, 안정성이 중요하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;acks=all을 선택하는 트레이드오프를 제공한다.&lt;/p&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/166</guid>
      <comments>https://icarus8050.tistory.com/166#entry166comment</comments>
      <pubDate>Sat, 4 Apr 2026 03:40:50 +0900</pubDate>
    </item>
    <item>
      <title>Go + Fiber로 서버 초기 세팅하며 배운 것들 정리</title>
      <link>https://icarus8050.tistory.com/165</link>
      <description>&lt;h1&gt;Go + Fiber로 서버 만들기: Java 개발자가 Go를 시작하며 배운 것들&lt;/h1&gt;
&lt;p&gt;Go와 Fiber 프레임워크를 사용하여 고가용성 분산 시스템 서버의 초기 구조를 설계하면서 학습한 내용을 정리한다.&lt;br&gt;&lt;a href=&quot;https://github.com/icarus8050/peacock&quot;&gt;https://github.com/icarus8050/peacock&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;프로젝트 구조&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;peacock/
├── main.go             # 진입점 (config → server → handler 연결)
├── config/
│   └── config.go       # 환경변수 기반 설정 관리
├── server/
│   └── server.go       # Fiber 앱 생성 + Graceful Shutdown
└── handler/
    ├── handler.go      # 라우트 등록 중앙 관리
    └── health.go       # 헬스체크 엔드포인트 (/health, /ready)&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func main() {
    cfg := config.Load()
    srv := server.New(cfg)
    handler.Register(srv.App)
    if err := srv.Start(); err != nil {
        log.Fatal(err)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;go mod tidy&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;go mod tidy&lt;/code&gt;는 Go 모듈 의존성을 정리하는 명령어다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;코드에서 &lt;code&gt;import&lt;/code&gt;한 외부 패키지를 &lt;code&gt;go.mod&lt;/code&gt;에 추가하고 다운로드한다.&lt;/li&gt;
&lt;li&gt;더 이상 사용하지 않는 패키지는 &lt;code&gt;go.mod&lt;/code&gt;에서 제거한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;go.sum&lt;/code&gt; 파일을 생성하여 의존성의 체크섬(해시값)을 기록한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;go.sum&lt;/code&gt;은 다른 환경에서 동일한 의존성이 다운로드되었는지 검증하는 역할을 하므로, &lt;code&gt;go.mod&lt;/code&gt;과 함께 버전 관리에 포함하는 것이 좋다.&lt;/p&gt;
&lt;h2&gt;구조체 (struct)&lt;/h2&gt;
&lt;p&gt;Go에는 &lt;code&gt;class&lt;/code&gt; 키워드가 없다. 대신 &lt;strong&gt;구조체(struct) + 메서드&lt;/strong&gt;로 동일한 역할을 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Config struct {
    Port            string
    ReadTimeout     time.Duration
    WriteTimeout    time.Duration
    ShutdownTimeout time.Duration
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Java와 비교하면 다음과 같다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public class Config {
    public String port;
    public Duration readTimeout;
    public Duration writeTimeout;
    public Duration shutdownTimeout;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;*&lt;/code&gt;는 &lt;strong&gt;포인터&lt;/strong&gt;를 의미하며, 값 자체를 복사하는 게 아니라 원본 객체의 메모리 주소를 참조한다. Java에서 객체 변수가 참조(reference)를 갖는 것과 같은 개념이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type Server struct {
    App *fiber.App      // Fiber 앱에 대한 포인터
    Cfg *config.Config  // Config에 대한 포인터
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;생성자&lt;/h3&gt;
&lt;p&gt;Go에는 생성자 문법이 없다. 관례적으로 &lt;code&gt;New()&lt;/code&gt; 함수가 생성자 역할을 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func New(cfg *config.Config) *Server {
    app := fiber.New(fiber.Config{
        ReadTimeout:  cfg.ReadTimeout,
        WriteTimeout: cfg.WriteTimeout,
    })
    return &amp;amp;Server{App: app, Cfg: cfg}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&amp;amp;Server{...}&lt;/code&gt;는 Server 구조체를 만들고 그 포인터를 반환한다는 뜻이다.&lt;/p&gt;
&lt;h3&gt;gofmt&lt;/h3&gt;
&lt;p&gt;Go에는 &lt;code&gt;gofmt&lt;/code&gt;라는 공식 포매터가 있어서, 코드 저장 시 자동으로 포맷을 정리해 준다. 구조체 필드의 타입 정렬도 &lt;code&gt;gofmt&lt;/code&gt;가 자동으로 처리하므로 작성 시 포맷을 신경 쓸 필요가 없다.&lt;/p&gt;
&lt;h2&gt;패키지와 파일명&lt;/h2&gt;
&lt;p&gt;Go에서 &lt;strong&gt;파일명과 패키지명은 관계가 없다.&lt;/strong&gt; 규칙은 하나뿐이다: &lt;strong&gt;같은 디렉토리의 모든 &lt;code&gt;.go&lt;/code&gt; 파일은 같은 패키지명을 사용해야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;handler/
├── handler.go    → package handler
└── health.go     → package handler&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;파일명이 무엇이든, 같은 디렉토리이므로 둘 다 &lt;code&gt;package handler&lt;/code&gt;다.&lt;/p&gt;
&lt;h2&gt;리시버 (Receiver)&lt;/h2&gt;
&lt;p&gt;메서드가 어떤 타입에 속하는지를 지정하는 문법이다. Java의 &lt;code&gt;this&lt;/code&gt;와 비슷한 역할을 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func (s *Server) Start() error {
    s.Cfg.Port       // Java의 this.cfg.port 와 동일
    s.App.Listen(...)
}&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Go&lt;/th&gt;
&lt;th&gt;Java&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s *Server&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;this&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s.Cfg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;this.cfg&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;*Server&lt;/code&gt;에서 &lt;code&gt;*&lt;/code&gt;는 포인터 리시버로, 원본 인스턴스를 참조하여 내부 필드를 수정할 수 있다. &lt;code&gt;*&lt;/code&gt; 없이 &lt;code&gt;Server&lt;/code&gt;로 쓰면 값이 복사되어 원본에 영향을 줄 수 없다.&lt;/p&gt;
&lt;h3&gt;리시버의 제약&lt;/h3&gt;
&lt;p&gt;리시버는 &lt;strong&gt;같은 패키지 안에서 정의된 구조체만&lt;/strong&gt; 사용할 수 있다. 다른 패키지의 타입에 메서드를 추가하려면 &lt;strong&gt;래핑(wrapping)&lt;/strong&gt;이나 &lt;strong&gt;임베딩&lt;/strong&gt;으로 해결한다.&lt;/p&gt;
&lt;h2&gt;임베딩 (Embedding)&lt;/h2&gt;
&lt;p&gt;구조체 안에 다른 구조체를 &lt;strong&gt;필드 이름 없이&lt;/strong&gt; 넣는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;type ServerConfig struct {
    *config.Config  // 임베딩
}

sc := &amp;amp;ServerConfig{Config: config.Load()}
sc.Port  // Config의 필드에 바로 접근 가능&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Java의 상속과 비슷하지만, Go에서는 &lt;strong&gt;조합(composition)&lt;/strong&gt;이라는 다른 개념이다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Java 상속&lt;/th&gt;
&lt;th&gt;Go 임베딩&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;관계&lt;/td&gt;
&lt;td&gt;&amp;quot;is-a&amp;quot; (ServerConfig &lt;strong&gt;는&lt;/strong&gt; Config이다)&lt;/td&gt;
&lt;td&gt;&amp;quot;has-a&amp;quot; (ServerConfig &lt;strong&gt;가&lt;/strong&gt; Config을 &lt;strong&gt;갖고 있다&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다형성&lt;/td&gt;
&lt;td&gt;부모 타입으로 사용 가능&lt;/td&gt;
&lt;td&gt;불가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다중&lt;/td&gt;
&lt;td&gt;단일 상속만&lt;/td&gt;
&lt;td&gt;여러 구조체 임베딩 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;에러 처리: if 초기화문&lt;/h2&gt;
&lt;p&gt;Go에는 &lt;code&gt;try/catch&lt;/code&gt;가 없다. 함수가 에러를 &lt;strong&gt;반환값&lt;/strong&gt;으로 돌려주고, &lt;code&gt;if err != nil&lt;/code&gt; 패턴으로 처리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;if err := srv.Start(); err != nil {
    log.Fatal(err)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;;&lt;/code&gt; 앞이 &lt;strong&gt;초기화문&lt;/strong&gt;(함수 실행 및 결과 저장), 뒤가 &lt;strong&gt;조건문&lt;/strong&gt;(에러 여부 확인)이다. 위 코드는 아래와 동일하다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;err := srv.Start()
if err != nil {
    log.Fatal(err)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;초기화문을 사용하면 &lt;code&gt;err&lt;/code&gt; 변수의 스코프가 if 블록 안으로 제한되는 장점이 있다.&lt;/p&gt;
&lt;h2&gt;고루틴 (Goroutine)과 채널 (Channel)&lt;/h2&gt;
&lt;h3&gt;go func()&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;go&lt;/code&gt; 키워드로 &lt;strong&gt;고루틴&lt;/strong&gt;을 생성하여 함수를 비동기로 실행한다. Java의 스레드와 비슷하지만 훨씬 가볍다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go func() {
    s.App.Listen(addr)
}()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;func() { ... }()&lt;/code&gt;는 익명 함수를 즉시 실행하는 문법이며, 마지막 &lt;code&gt;()&lt;/code&gt;가 호출 부분이다.&lt;/p&gt;
&lt;h3&gt;chan과 &amp;lt;- 연산자&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;chan&lt;/code&gt;은 고루틴 간에 데이터를 주고받는 &lt;strong&gt;채널&lt;/strong&gt;이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ch := make(chan string)    // string 채널 생성
ch := make(chan string, 5) // 버퍼 크기 5인 채널

ch &amp;lt;- &amp;quot;hello&amp;quot;   // 채널에 값 보내기 (채널이 왼쪽)
msg := &amp;lt;-ch     // 채널에서 값 받기 (채널이 오른쪽, 값 올 때까지 대기)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;select&lt;/h3&gt;
&lt;p&gt;여러 채널을 동시에 기다리는 문법이다. &lt;strong&gt;먼저 값이 도착한 채널&lt;/strong&gt;의 case가 실행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;select {
case err := &amp;lt;-errCh:    // 서버에 에러가 발생하거나
    return err
case sig := &amp;lt;-quit:     // 종료 시그널(Ctrl+C)이 오거나
    log.Printf(&amp;quot;received signal %s, shutting down...&amp;quot;, sig)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;select&lt;/code&gt;는 채널에 값이 올 때까지 &lt;strong&gt;블로킹&lt;/strong&gt;된다. &lt;code&gt;server.go&lt;/code&gt;에서 고루틴이 서버를 별도로 실행하고, &lt;code&gt;Start()&lt;/code&gt; 함수는 &lt;code&gt;select&lt;/code&gt;에서 대기 상태에 진입한다. 이 대기가 없으면 &lt;code&gt;main&lt;/code&gt; 함수가 바로 끝나면서 프로그램이 종료된다.&lt;/p&gt;
&lt;h2&gt;defer&lt;/h2&gt;
&lt;p&gt;함수가 &lt;strong&gt;끝날 때 실행할 코드를 예약&lt;/strong&gt;하는 키워드다. Java의 &lt;code&gt;finally&lt;/code&gt;와 비슷하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;ctx, cancel := context.WithTimeout(context.Background(), s.Cfg.ShutdownTimeout)
defer cancel()  // 함수 종료 시 cancel() 자동 호출 → 타이머 리소스 해제&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cancel()&lt;/code&gt;은 &lt;code&gt;context.WithTimeout&lt;/code&gt;이 반환하는 컨텍스트 취소 함수다. &lt;code&gt;defer&lt;/code&gt;로 호출하는 이유는 shutdown이 타임아웃 전에 완료되더라도 컨텍스트 내부의 타이머 리소스를 해제해야 하기 때문이다. 호출하지 않으면 메모리 누수가 발생할 수 있다.&lt;/p&gt;
&lt;h3&gt;여러 번의 defer&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;defer&lt;/code&gt;는 한 함수 내에서 여러 번 호출 가능하며, &lt;strong&gt;역순(LIFO)&lt;/strong&gt;으로 실행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func process() error {
    file, _ := os.Open(&amp;quot;data.txt&amp;quot;)
    defer file.Close()           // 2번째로 실행

    conn, _ := db.Connect()
    defer conn.Close()           // 1번째로 실행

    // 작업 수행...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;나중에 열린 리소스가 먼저 닫히므로, 의존 관계가 있을 때 안전한 순서가 보장된다.&lt;/p&gt;
&lt;h2&gt;fmt.Sprintf&lt;/h2&gt;
&lt;p&gt;문자열을 포맷팅하여 반환하는 함수다. Java의 &lt;code&gt;String.format()&lt;/code&gt;과 동일하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;addr := fmt.Sprintf(&amp;quot;:%s&amp;quot;, s.Cfg.Port)  // &amp;quot;:3000&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;자주 쓰는 포맷 지정자:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지정자&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;예시 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;%s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;문자열&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fmt.Sprintf(&amp;quot;hello %s&amp;quot;, &amp;quot;world&amp;quot;)&lt;/code&gt; → &lt;code&gt;&amp;quot;hello world&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;%d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;정수&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fmt.Sprintf(&amp;quot;port: %d&amp;quot;, 3000)&lt;/code&gt; → &lt;code&gt;&amp;quot;port: 3000&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;%v&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;값의 기본 형식&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fmt.Sprintf(&amp;quot;%v&amp;quot;, true)&lt;/code&gt; → &lt;code&gt;&amp;quot;true&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;%w&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;에러 래핑 (&lt;code&gt;Errorf&lt;/code&gt;에서만)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fmt.Errorf(&amp;quot;failed: %w&amp;quot;, err)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;</description>
      <category>Development/golang</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/165</guid>
      <comments>https://icarus8050.tistory.com/165#entry165comment</comments>
      <pubDate>Sun, 22 Mar 2026 18:50:07 +0900</pubDate>
    </item>
    <item>
      <title>Endofunctor (엔도펑터), 모나드 (Monad)</title>
      <link>https://icarus8050.tistory.com/164</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;엔도펑터 (Endofunctor)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;출발하는 카테고리와 도착하는 카테고리가 같은 펑터로, 그리스어 접두사 'Endo-'(내부의)와 의미와 Functor가 결합한 것이다. 프로그래밍에서 다루는 대부분의 펑터가 엔도펑터다. 가장 흔한 예시로 Maybe(or 자바의 Optional)가 있다. (F : C -&amp;gt; C)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;엔도펑터 역시 펑터와 마찬가지로 대상 객체를 매핑하는 것 뿐만 아니라 사상 또한 매핑해야 한다. 이를 리프팅(lifting) 한다고 하며, 들어올린다고도 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex) a -&amp;gt; b를 Maybe a -&amp;gt; Maybe b 로 리프팅&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;모나드 (Monad)&lt;/h4&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;함수를 적용한다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;map : F&amp;lt;A&amp;gt; -&amp;gt; (A -&amp;gt; B) -&amp;gt; F&amp;lt;B&amp;gt;&lt;/li&gt;
&lt;li&gt;함수가 컨텍스트를 생성하지는 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;모나드는 컨텍스트 안의 값에 컨텍스트를 생성하는 함수를 적용한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;flatMap : M&amp;lt;A&amp;gt; -&amp;gt; (A -&amp;gt; M&amp;lt;B&amp;gt;) -&amp;gt; M&amp;lt;B&amp;gt;&lt;/li&gt;
&lt;li&gt;함수 자체가 새로운 컨텍스트를 반환한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1772443708664&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sealed class Maybe&amp;lt;out A&amp;gt; {
    data class Just&amp;lt;A&amp;gt;(val value: A) : Maybe&amp;lt;A&amp;gt;()
    data object Nothing : Maybe&amp;lt;kotlin.Nothing&amp;gt;()

    fun &amp;lt;B&amp;gt; map(f: (A) -&amp;gt; B): Maybe&amp;lt;B&amp;gt; = when (this) {
        is Just -&amp;gt; Just(f(value))
        is Nothing -&amp;gt; Nothing
    }

    fun &amp;lt;B&amp;gt; flatMap(f: (A) -&amp;gt; Maybe&amp;lt;B&amp;gt;): Maybe&amp;lt;B&amp;gt; = when (this) {
        is Just -&amp;gt; f(value)
        is Nothing -&amp;gt; Nothing
    }
}

// ── 순수한 변환: 펑터로 충분
val number: Maybe&amp;lt;Int&amp;gt; = Maybe.Just(42)
val doubled: Maybe&amp;lt;Int&amp;gt; = number.map { it * 2 }         // Just(84)
val asString: Maybe&amp;lt;String&amp;gt; = number.map { it.toString() } // Just(&quot;42&quot;)

// ── 실패 가능한 연산: 펑터만으로는 한계
fun safeDivide(a: Int, b: Int): Maybe&amp;lt;Int&amp;gt; =
    if (b != 0) Maybe.Just(a / b) else Maybe.Nothing

val result: Maybe&amp;lt;Maybe&amp;lt;Int&amp;gt;&amp;gt; = number.map { safeDivide(it, 0) }
// 결과: Just(Nothing) &amp;mdash; Maybe가 이중으로 중첩됨.
// 타입이 Maybe&amp;lt;Maybe&amp;lt;Int&amp;gt;&amp;gt;가 되어버림.

// ── 모나드의 flatMap이 해결
val clean: Maybe&amp;lt;Int&amp;gt; = number.flatMap { safeDivide(it, 0) }
// 결과: Nothing &amp;mdash; 평탄화됨.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/164</guid>
      <comments>https://icarus8050.tistory.com/164#entry164comment</comments>
      <pubDate>Mon, 2 Mar 2026 18:36:30 +0900</pubDate>
    </item>
    <item>
      <title>함수형 프로그래밍, 커링, 모노이드, 펑터..</title>
      <link>https://icarus8050.tistory.com/163</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;함수형 프로그래밍&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;순수 함수(Pure function)와 불변 데이터(Immutable data)를 중심으로 프로그래밍을 구성하는 패러다임.&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;일급 함수(First-Class Function) : 함수를 일반 변수처럼 취급하여 변수 할당, 인자 전달 및 반환값으로 사용 가능하도록 하는 특성이다.&lt;/li&gt;
&lt;li&gt;고차 함수(Higher-Order Function) : 함수를 인자로 받거나 함수를 반환하는 함수다.&lt;/li&gt;
&lt;li&gt;함수 합성(Function Composition) : 함수들을 조합하여 다양한 함수를 만들 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1771386059669&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 작은 단위 함수들
val removeSpaces: (String) -&amp;gt; String = { it.replace(&quot; &quot;, &quot;&quot;) }
val toUpperCase: (String) -&amp;gt; String = { it.uppercase() }
val addPrefix: (String) -&amp;gt; String = { &quot;PREFIX_$it&quot; }

// 합성 유틸리티
infix fun &amp;lt;A, B, C&amp;gt; ((A) -&amp;gt; B).then(next: (B) -&amp;gt; C): (A) -&amp;gt; C =
    { a -&amp;gt; next(this(a)) }

// 함수 합성
val processKey = removeSpaces then toUpperCase then addPrefix

println(processKey(&quot;hello world&quot;))  // PREFIX_HELLOWORLD&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커링(Currying) : 다중 인자를 갖는 함수를 단일 인자를 갖는 함수 체인으로 변환하는 방법이다.&lt;/p&gt;
&lt;pre id=&quot;code_1771386524998&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 커링: 여러 인자를 받는 함수를 한 인자씩 받는 함수 체인으로 변환
fun &amp;lt;A, B, C&amp;gt; ((A, B) -&amp;gt; C).curried(): (A) -&amp;gt; (B) -&amp;gt; C =
    { a -&amp;gt; { b -&amp;gt; this(a, b) } }

val multiply = { a: Int, b: Int -&amp;gt; a * b }
val curriedMultiply = multiply.curried()

val double = curriedMultiply(2)   // b만 받으면 되는 함수
val triple = curriedMultiply(3)

println(double(5))   // 10
println(triple(5))   // 15&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;모노이드(Monoid)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아래 세 가지 조건을 만족하면 모노이드다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;결합 법칙(associativity) : (a * b) * c == a * (b * c)&lt;/li&gt;
&lt;li&gt;항등원(identity element) : a * e == a, e * a == a&lt;/li&gt;
&lt;li&gt;닫힌 연산(closure) : 같은 타입끼리 연산하면 같은 타입이 나옴.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;결합 법칙이 성립하기 때문에 데이터를 여러 덩어리로 나누어 각각 계산한 뒤에 마지막에 합쳐도 결과가 동일하다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;안전한 병렬 처리의 추상화가 가능하여 대규모 병렬 처리(Map Reduce), 분산 집계 등의 핵심 원리가 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;background-color: #f0f2f5; color: #0a0a0a; text-align: start;&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;/ 모노이드 인터페이스
interface Monoid&amp;lt;T&amp;gt; {
    val empty: T                       // 항등원
    fun combine(a: T, b: T): T        // 결합 연산
}

// 정수 덧셈 모노이드: 항등원 = 0, 연산 = +
object IntAddMonoid : Monoid&amp;lt;Int&amp;gt; {
    override val empty: Int = 0
    override fun combine(a: Int, b: Int): Int = a + b
}

// 문자열 모노이드: 항등원 = &quot;&quot;, 연산 = 연결
object StringMonoid : Monoid&amp;lt;String&amp;gt; {
    override val empty: String = &quot;&quot;
    override fun combine(a: String, b: String): String = a + b
}

// 리스트 모노이드: 항등원 = emptyList(), 연산 = +
class ListMonoid&amp;lt;T&amp;gt; : Monoid&amp;lt;List&amp;lt;T&amp;gt;&amp;gt; {
    override val empty: List&amp;lt;T&amp;gt; = emptyList()
    override fun combine(a: List&amp;lt;T&amp;gt;, b: List&amp;lt;T&amp;gt;): List&amp;lt;T&amp;gt; = a + b
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;교환 법칙은 성립하지 않는다. 위 문자열 모노이드가 그 예시다. (&quot;Hello&quot; 와 &quot;World&quot;는 연산의 순사가 달라지면 결과가 달라진다.)
&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;원소 그 자체를 바라보는 것이 아니라, 객체와 객체를 변환하는 사상(morphism)으로 이해해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;펑터(Functor)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;펑터는 두 카테고리 사이의 구조를 보존하는 매핑을 의미한다.&lt;/li&gt;
&lt;li&gt;두 카테고리 C와 D가 있을 때, 펑터 F : C -&amp;gt; D는 두 가지 매핑으로 구성된다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;대상 매핑 (Object Mapping) : 카테고리 C의 객체 A를 카테고리 D의 객체 F(A)로 보낸다.&lt;/li&gt;
&lt;li&gt;사상 매핑 (Morphism Mapping) : 카테고리 C의 사상 f : A -&amp;gt; B 를 카테고리 D의 사상 F(f) : F(A) -&amp;gt; F(B)로 보낸다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;펑터는 다음의 성질을 만족해야 한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;항등 사상 보존 : F(id_A) = id_F(A)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;원래의 카테고리에서 아무것도 안 하는 화살표는 옮겨진 카테고리에서도 아무것도 하지 않아야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;사상 합성 보존 : F(g * f) = F(g) * F(f)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;f를 가고나서 g를 가는 경로를 한꺼번에 옮긴 것은, 각각을 옮겨서 연결한 것과 같아야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1771404946090&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 펑터 인터페이스
interface Functor&amp;lt;out A&amp;gt; {
    fun &amp;lt;B&amp;gt; map(f: (A) -&amp;gt; B): Functor&amp;lt;B&amp;gt;
}

// Maybe &amp;mdash; null-safe한 값을 담는 컨텍스트
sealed class Maybe&amp;lt;out A&amp;gt; : Functor&amp;lt;A&amp;gt; {
    data class Just&amp;lt;A&amp;gt;(val value: A) : Maybe&amp;lt;A&amp;gt;()
    data object None : Maybe&amp;lt;Nothing&amp;gt;()

    override fun &amp;lt;B&amp;gt; map(f: (A) -&amp;gt; B): Maybe&amp;lt;B&amp;gt; = when (this) {
        is Just -&amp;gt; Just(f(value))    // 값이 있으면 변환
        is None -&amp;gt; None              // 없으면 그대로 None
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Development/Architecture</category>
      <category>Functional Programming</category>
      <category>모나드</category>
      <category>모노이드</category>
      <category>커링</category>
      <category>펑터</category>
      <category>함수형 프로그래밍</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/163</guid>
      <comments>https://icarus8050.tistory.com/163#entry163comment</comments>
      <pubDate>Wed, 18 Feb 2026 18:56:51 +0900</pubDate>
    </item>
    <item>
      <title>아키텍처 퀀텀 (Architecture Quantum)</title>
      <link>https://icarus8050.tistory.com/162</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;아키텍처 퀀텀 (Architecture Quantum)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아키텍처 퀀텀은 높은 기능 응집도, 높은 정적 커플링, 동기적 동적 커플링의 특성을 띤, 독립적으로 배포 가능한 아티팩트다.&lt;/li&gt;
&lt;li&gt;모놀리식 아키텍처는 정의에 따라 단일 아키텍처 퀀텀이다.&lt;/li&gt;
&lt;li&gt;마이크로 서비스와 같은 분산 아키텍처는 서비스를 독립적으로 배포가 가능하기 때문에 각각이 아키텍처 퀀텀이 될 수 있다. (서비스가 서로 격리되어 있다고 해서 퀀텀이 생성되는 것은 아니다.)&lt;/li&gt;
&lt;li&gt;독립적으로 배포가 가능해도 공유 데이터베이스와 같은 공통 결합점이 생긴다면 아키텍처 퀀텀에 포함된다. 따라서 단순히 배포의 경계만 봐서는 아키텍처 퀀텀을 가늠하기 어렵다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터베이스는 정적 커플링을 판단하는 기준으로, 단일 데이터베이스에 의존하는 시스템은 1 이상의 퀀텀을 가질 수 없다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;책에서는 데이터베이스가 정적 커플링을 판단하는 기준으로 나와있지만 무조건적이지는 않을 것 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;아키텍처 퀀텀을 정적 분석할 때는 아키텍처에 종속된 이것이 서비스 작동에 필요한지를 확인해야 한다.&lt;/li&gt;
&lt;li&gt;마이크로 서비스들이 유저 인터페이스와 단단하게 결합돼 있으면 아키텍처 퀀텀이 합쳐질 수 있다. 이는 서비스마다 운영을 위한 특성을 세밀하게 적용하기가 어려워지게 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;동적 커플링&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;런타임에 퀀텀들이 서로 어떻게 통신하는지를 나타낸다. 이러한 특성들은 피트니스 함수를 지속적으로 실행시켜 모니터링해야 한다.&lt;/li&gt;
&lt;li&gt;서비스가 서로를 호출할 때는 아래 3가지를 고려해야 한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;통신(Communication) : 동기 통신인지, 비동기 통신인지?&lt;/li&gt;
&lt;li&gt;일관성(Consistency) : 워크플로 통신에 원자성이 필수로 적용되어야 하는지, 최종적 일관성만 맞춰도 충분한지?&lt;/li&gt;
&lt;li&gt;조정(Coordination) : 워크플로가 오케스트레이터를 활용하는지, 코레오그래피 방식으로 서비스가 서로 통신하는지?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;동적 커플링은 서비스들 간의 특성으로 인해서 서로 영향을 주면 아키텍처 퀀텀이 일시적으로 뒤얽힐 수도 있다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ex) 100,000TPS를 처리하는 서비스가 50,000TPS를 처리하는 서비스를 동기적으로 호출하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고 서적&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[&lt;span style=&quot;color: #000000; text-align: center;&quot;&gt;소프트웨어 아키텍처 The Hard Parts]&lt;/span&gt;&lt;/p&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/162</guid>
      <comments>https://icarus8050.tistory.com/162#entry162comment</comments>
      <pubDate>Tue, 17 Feb 2026 18:24:19 +0900</pubDate>
    </item>
  </channel>
</rss>