<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>WHITEVISION</title><link>https://whitevision.dev/</link><description>소프트웨어 원칙, 디자인 패턴, 트레이드오프, 설계</description><language>ko</language><lastBuildDate>Thu, 20 Aug 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://whitevision.dev/" rel="self" type="application/rss+xml"/><item><title>오프라인 환경에서 인증과 인가</title><link>https://whitevision.dev/posts/offline-auth/</link><pubDate>Thu, 20 Aug 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/offline-auth/</guid><description>&lt;p&gt;&lt;strong&gt;오프라인 환경에서는 인증과 인가를 검증 시점에 서버로 물어볼 수 없다. 서버가 미리 서명해 둔 정보를 디바이스에 심어 두고, 디바이스가 그 서명을 현장에서 직접 검증한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;지하주차장에 세워둔 차량의 문을 휴대폰으로 여는 상황을 생각해 보자. 차량도 휴대폰도 셀룰러 네트워크에 연결되지 않는다.
하지만 둘은 BLE(Bluetooth Low Energy)를 통해 직접 통신할 수 있다. 눈앞의 휴대폰이 등록된 사용자의 것인지, 그리고 그 사용자에게 문을 열 권한이 있는지를 차량 스스로 판정해야 한다.&lt;/p&gt;
&lt;p&gt;온라인 서비스에서 인증과 인가는 서버에 물어보면 된다. 토큰이 유효한지는 발급자가 답하고, 어떤 권한이 있는지는 정책 서버가 답한다.
검증에 필요한 답이 서버에 있기 때문이다.&lt;/p&gt;
&lt;p&gt;오프라인 환경에는 그 서버가 없다. 그래서 답을 미리 만들어 서명해 두고, 현장에서는 서명만 확인한다.&lt;/p&gt;
&lt;p&gt;이 글은 두 가지를 다룬다. 하나는 &lt;strong&gt;모바일과 디바이스가 서버 없이 서로를 인증하는 방법&lt;/strong&gt;이고, 다른 하나는 &lt;strong&gt;인가를 어떻게 오프라인으로 옮기는가&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h2 id="왜-오프라인에서-인증과-인가가-어려운가"&gt;왜 오프라인에서 인증과 인가가 어려운가&lt;/h2&gt;
&lt;p&gt;오프라인 인증이 어려운 것은 암호 기술 때문이 아니다. 눈앞의 상대가 누구인지 물어볼 서버가 없기 때문이다.&lt;/p&gt;
&lt;p&gt;온라인 인증 방식은 대부분 인가 서버를 전제로 한다. OAuth 2.0의 &lt;strong&gt;&lt;a href="https://klarciel.net/wiki/auth/auth-token-introspection/"&gt;Token Introspection&lt;/a&gt;&lt;/strong&gt; 에서는 리소스 서버가 인가 서버에 토큰의 유효성을 질의한다.
검증 시점에 네트워크 왕복이 한 번 이상 필요하고, 서버에 연결할 수 없으면 인증과 인가는 실패한다.&lt;/p&gt;
&lt;p&gt;오프라인 환경에서는 이 전제가 무너진다. 차량이 터널이나 지하주차장에 들어가면 LTE나 5G 신호가 약해지거나 완전히 사라진다.
이때 차량은 서버에 요청을 보낼 수도, 응답을 받을 수도 없다.&lt;/p&gt;
&lt;p&gt;따라서 &lt;strong&gt;검증 시점에 서버에 의존하는 인증·인가 메커니즘은 오프라인 환경에서 사용하기 어렵다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="모바일과-디바이스는-어떻게-인증하는가"&gt;모바일과 디바이스는 어떻게 인증하는가&lt;/h2&gt;
&lt;p&gt;모바일과 디바이스의 오프라인 인증 문제는 &lt;strong&gt;&lt;a href="https://klarciel.net/wiki/security/security-signed-certificates/"&gt;디지털 서명(Digital Signature)&lt;/a&gt;&lt;/strong&gt; 으로 푼다. 모바일은 자신의 비밀키(Private Key)로 서명하고, 디바이스는 미리 가지고 있던 공개키(Public Key)로 그 서명을 검증한다.
검증에 필요한 것은 공개키뿐이므로 이 과정에는 네트워크가 필요 없다.&lt;/p&gt;
&lt;p&gt;이 글에서는 모바일을 휴대폰으로, 디바이스를 차량으로 놓고 설명한다.&lt;/p&gt;
&lt;h3 id="오프라인-인증은-두-가지-조건-위에-선다"&gt;오프라인 인증은 두 가지 조건 위에 선다&lt;/h3&gt;
&lt;p&gt;첫째, &lt;strong&gt;디바이스 밖으로 나가지 않는 비밀키&lt;/strong&gt;다. 비밀키가 외부로 유출되면 누구나 디바이스를 대신해 인증할 수 있다. 따라서 비밀키는 HSM(Hardware Security Module)이나 TEE(Trusted Execution Environment)처럼 외부에서 직접 접근할 수 없는 영역에 보관해야 한다.&lt;/p&gt;
&lt;p&gt;둘째, &lt;strong&gt;미리 등록해 둔 공개키&lt;/strong&gt;다. 차량은 네트워크에 연결되지 않은 상태에서도 이 공개키를 기준으로 상대방의 서명을 검증해야 한다.&lt;/p&gt;
&lt;p&gt;이처럼 검증자가 사전에 신뢰하도록 등록해 둔 키나 인증서를 &lt;strong&gt;신뢰 앵커(Trust Anchor)&lt;/strong&gt; 라고 한다. 공개키 자체가 신뢰 앵커가 될 수도 있다.&lt;/p&gt;
&lt;p&gt;그렇다면 차량은 무엇을 신뢰 앵커로 삼아야 할까?&lt;/p&gt;
&lt;p&gt;휴대폰과 차량이 한 번도 만난 적이 없다면, 차량은 눈앞의 휴대폰을 어떻게 믿을 수 있을까? 차량과 휴대폰이 공통으로 신뢰하는 상위 기관, 즉 &lt;strong&gt;Root CA(루트 인증기관)&lt;/strong&gt; 가 있어야 한다. Root CA는 휴대폰의 공개키와 신원을 묶어 서명하고, 차량은 미리 가지고 있는 Root CA 공개키로 이 서명을 검증한다.&lt;/p&gt;
&lt;p&gt;이때 사용하는 것이 인증서(Certificate)다. 인증서는 &amp;ldquo;이 공개키가 이 신원에 속한다&amp;quot;는 내용을 Root CA가 서명한 문서다. X.509는 이러한 인증서를 표현하기 위한 표준 형식이다.&lt;/p&gt;
&lt;p&gt;실제 오프라인 환경에서는 &lt;strong&gt;Root CA의 공개키 또는 Root CA 인증서가 신뢰 앵커로 사용된다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Root CA 인증서는 자기 자신의 비밀키로 서명한 자기 서명 인증서(Self-Signed Certificate)다. 따라서 이 인증서의 서명만 검증해서는 &lt;strong&gt;그것이 신뢰할 수 있는 Root CA에서 나온 것인지 확인할 수 없다.&lt;/strong&gt; 자신을 검증해 줄 상위 인증서가 없기 때문이다.&lt;/p&gt;
&lt;p&gt;그래서 Root CA 인증서는 다른 인증서처럼 검증하는 대상이 아니라, &lt;strong&gt;인증서 검증을 시작하는 기준점&lt;/strong&gt;으로 사용된다. 검증자는 이 값을 미리 안전하게 저장해 두고, 이후 전달받은 인증서가 이 Root CA를 시작점으로 하는 유효한 인증서 체인을 갖는지 확인한다.&lt;/p&gt;
&lt;p&gt;따라서 차량은 모든 휴대폰의 정보를 미리 가지고 있을 필요가 없다. Root CA 공개키 하나만 미리 가지고 있으면, Root CA가 서명한 휴대폰 인증서를 현장에서 검증할 수 있다. 서버에 접속해 휴대폰의 정보를 다시 확인할 필요도 없다.&lt;/p&gt;
&lt;p&gt;대칭키와 비대칭키는 여기서 확장성이 갈린다. 대칭키 방식에서는 차량과 휴대폰이 공유할 비밀키를 미리 나눠 가져야 한다. 상대가 많아질수록 관리해야 할 키도 많아진다. 반면 비대칭키 방식에서는 차량이 휴대폰마다 비밀키를 가지고 있을 필요가 없다. 차량은 Root CA 공개키만 가지고 있고, 각 휴대폰은 자신의 비밀키로 서명한다.&lt;/p&gt;
&lt;p&gt;신뢰 앵커는 런타임에 만들어지는 정보가 아니다. 차량이 출고되기 전에 제조·프로비저닝 단계에서 안전하게 저장해야 한다. 펌웨어에 포함하거나 HSM, TEE와 같은 보호된 영역에 저장하는 이유가 여기에 있다.&lt;/p&gt;
&lt;p&gt;공격자가 차량의 Root CA 공개키를 임의로 바꿀 수 있다면, 공격자는 자신의 키를 새로운 루트로 등록할 수 있다. 그러면 차량은 공격자가 만든 인증서를 정상적인 인증서로 받아들인다. 신뢰 앵커가 뚫리면 그 위에 쌓은 모든 검증이 무의미해진다.&lt;/p&gt;
&lt;h3 id="비밀이-아니라-소유를-증명한다"&gt;비밀이 아니라 소유를 증명한다&lt;/h3&gt;
&lt;p&gt;인증에는 두 가지 방식이 있다. 비밀을 제시하는 방식과, 비밀을 사용할 수 있음을 증명하는 방식이다.&lt;/p&gt;
&lt;p&gt;비밀번호나 사전 공유키를 그대로 보내는 방식은 전자다. 채널을 도청당하면 그 비밀은 즉시 공격자의 것이 된다.&lt;/p&gt;
&lt;p&gt;소유 증명(Proof of Possession)은 후자다. 비밀 자체를 보내는 대신, 검증자가 매번 새로 만들어 보낸 값에 자신의 비밀키로 서명해 돌려준다. 이렇게 비밀키를 가지고 있음을 보이는 방식을 challenge-response라고 한다.&lt;/p&gt;
&lt;p&gt;지하주차장에서 차량과 휴대폰이 처음 만났다고 생각해 보자.&lt;/p&gt;
&lt;p&gt;먼저 휴대폰이 자신의 인증서를 차량에 보낸다. 인증서에는 휴대폰의 공개키와 이 공개키가 어떤 신원에 속하는지를 나타내는 정보가 들어 있다.&lt;/p&gt;
&lt;p&gt;차량은 미리 가지고 있던 Root CA 공개키를 사용해 인증서의 서명을 검증한다. 이를 통해 이 인증서가 차량이 신뢰하는 CA에서 발급된 것인지 확인한다.
검증이 끝나면 차량은 인증서에 들어 있는 휴대폰의 공개키를 얻을 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 여기서 인증이 끝난 것은 아니다.&lt;/p&gt;
&lt;p&gt;공개키와 인증서는 누구나 복사할 수 있다. 공격자가 다른 사람의 인증서를 복사해 차량에 보내는 것만으로는, 그 인증서가 가리키는 비밀키를 가지고 있다는 증거가 되지 않는다.&lt;/p&gt;
&lt;p&gt;그래서 차량은 새로운 난수(nonce)를 만들어 휴대폰에 보낸다. 휴대폰은 자신의 비밀키로 이 난수에 서명하고, 그 서명을 차량에 돌려준다. 차량은 앞에서 인증서에서 얻은 공개키로 이 서명을 검증한다.&lt;/p&gt;
&lt;p&gt;검증에 성공하면 차량은 인증서에 들어 있는 공개키에 대응하는 비밀키를 휴대폰이 실제로 가지고 있다는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;이 두 단계는 서로 다른 것을 확인한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;인증서 검증: 이 공개키를 신뢰해도 되는가?&lt;/li&gt;
&lt;li&gt;소유 증명: 지금 인증서를 제시한 휴대폰이 그 공개키에 대응하는 비밀키를 실제로 가지고 있는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;둘 중 하나만으로는 충분하지 않다. 인증서만 확인하면 공격자가 다른 사람의 인증서를 복사해서 제시할 수 있다. 반대로 비밀키로 서명했다는 사실만 확인해서는 그 공개키가 누구의 것인지 알 수 없다. 인증서 검증과 소유 증명을 함께 해야, 신뢰할 수 있는 공개키를 가진 주체가 실제로 그에 대응하는 비밀키를 쥐고 있는지 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;차량이 매번 새로운 난수를 사용하는 이유도 여기에 있다. 공격자가 이전에 오간 서명을 캡처하더라도 그 서명은 이전 난수에 대한 서명이므로, 다음번에 차량이 보낸 새로운 난수에 대한 응답으로 그대로 사용할 수 없다. 비밀키 자체도 네트워크를 통해 전송되지 않는다.&lt;/p&gt;
&lt;p&gt;이런 구조는 차량만의 특별한 기술이 아니다. 공개키를 미리 등록하고, 상대방이 그 공개키에 대응하는 비밀키를 가지고 있는지 서명으로 확인하는 방식은 여러 시스템에서 사용된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FIDO2/WebAuthn: 인증기가 서버가 제공한 challenge를 포함한 인증 데이터를 자신의 비밀키로 서명하고, 서버는 등록해 둔 공개키로 이를 검증한다.&lt;/li&gt;
&lt;li&gt;TLS 클라이언트 인증(mTLS): 클라이언트가 자신의 인증서와 함께 비밀키를 사용한 서명을 보내고, 서버는 인증서의 공개키를 사용해 클라이언트가 해당 비밀키를 가지고 있는지 확인한다.&lt;/li&gt;
&lt;li&gt;SSH 공개키 인증: 서버에 등록된 공개키에 대응하는 비밀키를 클라이언트가 가지고 있음을 서명으로 증명한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;세 시스템의 세부 프로토콜은 서로 다르지만 핵심 구조는 같다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;공개키는 미리 등록하고, 비밀키는 디바이스 밖으로 내보내지 않으며, 실제 인증 시에는 새로운 데이터를 비밀키로 서명하게 해서 소유를 증명한다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="비대칭-서명은-부인방지를-제공한다"&gt;비대칭 서명은 부인방지를 제공한다&lt;/h3&gt;
&lt;p&gt;전자서명은 송신자의 비밀키를 가진 쪽만 만들 수 있고, 검증은 누구나 공개키로 할 수 있다. 이 비대칭성이 부인방지(Non-Repudiation)를 만든다.
비밀키는 오직 본인만 가지고 있으므로, 나중에 자신이 서명하지 않았다고 주장할 수 없다.&lt;/p&gt;
&lt;p&gt;반면 대칭키에서는 메시지를 검증할 수 있는 주체가 같은 메시지를 만들 수도 있다. 따라서 두 당사자 사이에 분쟁이 생기면, 그 기록만으로는 어느 쪽이 메시지를 만들었는지 제3자에게 증명하기 어렵다.&lt;/p&gt;
&lt;p&gt;오프라인에서는 이 차이가 더 크게 벌어진다.&lt;/p&gt;
&lt;p&gt;온라인 시스템에서는 사건이 발생했을 때 서버의 기록을 확인할 수 있다. 예를 들어 서버는 &amp;ldquo;특정 시각에 특정 계정이 특정 차량에 문 열기 명령을 보냈다&amp;quot;와 같이 기록을 남긴다.
이 기록과 서버의 인증·접근 로그를 함께 확인하면 당시 어떤 요청이 있었는지 사후에 추적할 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 오프라인 환경에서는 이런 서버 기록이 남지 않는다.&lt;/p&gt;
&lt;p&gt;지하주차장에서 휴대폰과 차량이 BLE로 직접 통신하고, 두 장치 모두 서버에 연결되어 있지 않다고 생각해 보자. 이때 인증과 명령 처리가 모두 두 장치 사이에서 끝난다면, 나중에 분쟁이 발생했을 때 당시 어떤 명령이 오갔는지 확인할 기록을 두 장치가 직접 남겨야 한다.&lt;/p&gt;
&lt;p&gt;이 기록을 남기는 수단이 비대칭 서명이다.&lt;/p&gt;
&lt;p&gt;휴대폰이 문 열기 명령에 서명하고 차량이 그 서명을 보관하면, 나중에 차량은 해당 공개키를 사용해 그 기록을 다시 검증할 수 있다.
다만 서명만으로 &amp;ldquo;사람이 직접 문 열기 버튼을 눌렀다&amp;quot;는 사실까지 증명되지는 않는다.&lt;/p&gt;
&lt;p&gt;서명이 증명하는 범위는 &amp;ldquo;해당 비밀키를 가진 주체가 이 데이터에 서명했다&amp;quot;까지다.
그 비밀키를 실제 사용자가 직접 사용했는지, 악성 프로그램이 사용했는지, 사용자의 휴대폰을 다른 사람이 조작했는지는 별도의 문제다.&lt;/p&gt;
&lt;p&gt;그래서 비밀키를 어떻게 보호하는지가 서명의 신뢰도를 좌우한다. 비밀키를 일반 애플리케이션 메모리에 두는 대신 Secure Element, HSM, TEE와 같은 보호된 영역에서 외부로 꺼낼 수 없도록 관리하면 비밀키가 탈취될 가능성을 낮출 수 있다. 그만큼 그 서명이 실제 사용자의 디바이스에서 만들어졌다는 판단도 더 믿을 수 있게 된다.&lt;/p&gt;
&lt;p&gt;비대칭 서명 기반의 오프라인 인증은 세 가지를 함께 얻는다.
&lt;strong&gt;서버 없이 하는 검증, Root CA 공개키 하나로 여러 휴대폰의 인증서를 검증하는 확장성, 그리고 부인방지다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="비대칭키-기반-오프라인-인증은-무엇을-설계해야-하는가"&gt;비대칭키 기반 오프라인 인증은 무엇을 설계해야 하는가&lt;/h2&gt;
&lt;p&gt;&lt;img src="offline-auth.png" alt="휴대폰과 차량의 비대칭키 기반 오프라인 인증 흐름"&gt;&lt;/p&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;장기 신원키와 세션 키 분리&lt;/td&gt;
&lt;td&gt;누구인지 증명하는 키와 통신을 보호하는 키를 왜 나누는가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;세션마다 새 임시 키&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;/tr&gt;
&lt;tr&gt;
&lt;td&gt;장기 신원키 서명&lt;/td&gt;
&lt;td&gt;나중에 누가 이 명령을 만들었는지 검증할 수 있는가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재전송(Replay) 방어&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;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="장기-신원키와-세션-키를-분리한다"&gt;장기 신원키와 세션 키를 분리한다&lt;/h3&gt;
&lt;p&gt;장기 신원키와 세션 키는 서로 다른 목적으로 쓴다.&lt;/p&gt;
&lt;p&gt;장기 신원키는 &amp;ldquo;나는 누구인가&amp;quot;를 증명하는 데 쓴다. 반면 세션 키는 &amp;ldquo;이번 통신을 어떻게 보호할 것인가&amp;quot;를 담당한다.&lt;/p&gt;
&lt;p&gt;두 역할을 키 하나로 처리하면 그 키가 유출되었을 때 신원 인증과 과거 통신의 보안이 함께 영향을 받는다. 따라서 장기 신원키는 신원 확인과 서명에 쓰고, 실제 데이터를 암호화하는 세션 키는 별도로 만든다.&lt;/p&gt;
&lt;h3 id="세션-키는-매-세션마다-새로-만든다"&gt;세션 키는 매 세션마다 새로 만든다&lt;/h3&gt;
&lt;p&gt;세션 키는 장기간 재사용하지 않고, 매번 새로운 임시 키쌍을 이용해 만든다.&lt;/p&gt;
&lt;p&gt;예를 들어 휴대폰과 차량이 각각 이번 통신에서만 쓸 임시 키쌍을 만든다. 두 임시 키로 ECDH(Elliptic Curve Diffie-Hellman) 공유 비밀을 얻고, 여기에 KDF(Key Derivation Function)를 적용해 세션 키를 유도한다. 통신이 끝나면 임시 키를 폐기한다.&lt;/p&gt;
&lt;p&gt;이렇게 하면 장기 신원키가 나중에 유출되더라도, 이미 끝난 과거 세션의 트래픽을 복호화하기 어렵다. 이를 &lt;strong&gt;순방향 비밀성(Forward Secrecy)&lt;/strong&gt; 이라고 한다.&lt;/p&gt;
&lt;p&gt;TLS 1.3이 임시 키 교환을 기본으로 쓰는 것도 같은 이유다.&lt;/p&gt;
&lt;h3 id="인증할-때는-상대방의-신원과-이번-세션을-함께-묶는다"&gt;인증할 때는 상대방의 신원과 이번 세션을 함께 묶는다&lt;/h3&gt;
&lt;p&gt;상호 인증에서는 상대방의 서명이 유효한지만 확인하는 것으로는 부족하다. 누구의 서명인지, 그리고 어떤 세션을 위한 서명인지 함께 확인해야 한다.&lt;/p&gt;
&lt;p&gt;따라서 서명에는 다음 정보가 함께 들어가야 한다.&lt;/p&gt;
&lt;ul&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;li&gt;이번 인증을 위한 nonce&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;왜 이것이 필요한지는 공격 상황을 보면 알 수 있다.&lt;/p&gt;
&lt;p&gt;공격자가 임시 공개키를 자신의 것으로 바꿔치기할 수 있다고 해보자. 인증서 자체도, CA 서명도 유효하다. 하지만 공격자의 임시 공개키가 실제 상대방의 신원과 묶여 있지 않다면, 공격자는 양쪽과 각각 별도의 세션을 만들 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 인증서가 유효한지 확인하는 것만으로는 부족하다. 인증서에 들어 있는 신원과 이번 세션의 임시 공개키가 서로 묶여 있는지 확인해야 한다.&lt;/p&gt;
&lt;p&gt;이처럼 하나의 세션이나 메시지가 다른 주체의 신원과 잘못 연결되는 문제를 &lt;strong&gt;신원 오결합(Identity Misbinding)&lt;/strong&gt; 이라고 한다.
TLS 1.3의 CertificateVerify도 임시 공개키 하나만 서명하지 않는다. 핸드셰이크 과정에서 교환된 내용을 포함하는 transcript에 서명한다. 이를 통해 인증서와 키 교환 과정이 서로 다른 세션에서 가져온 값으로 뒤섞이는 것을 막는다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;세션 키를 만들었다는 사실만으로는 상대방의 신원을 확인할 수 없다. 상대방의 신원과 이번 세션의 임시 키를 함께 검증해야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="암호화와-서명은-서로-다른-목적을-가진다"&gt;암호화와 서명은 서로 다른 목적을 가진다&lt;/h3&gt;
&lt;p&gt;세션 키로 메시지를 암호화하면 통신 내용은 보호할 수 있다. 하지만 세션 키를 가진 양쪽 모두 메시지를 만들 수 있다. 암호화된 메시지만으로는 어느 쪽이 그것을 만들었는지 제3자가 판단하기 어렵다.&lt;/p&gt;
&lt;p&gt;세션 키에서 만든 MAC(Message Authentication Code)도 마찬가지다. MAC을 검증할 수 있는 쪽은 같은 키를 사용해 새로운 MAC을 만들 수도 있기 때문이다.&lt;/p&gt;
&lt;p&gt;따라서 어느 주체가 그 명령을 만들었는지 제3자가 확인할 증거가 필요하다면, 장기 신원키로 메시지에 따로 서명해야 한다.&lt;/p&gt;
&lt;h3 id="재전송을-막기-위해-메시지에-신선도를-넣는다"&gt;재전송을 막기 위해 메시지에 신선도를 넣는다&lt;/h3&gt;
&lt;p&gt;서명이 유효하다고 해서 같은 메시지를 계속 실행해도 되는 것은 아니다.&lt;/p&gt;
&lt;p&gt;공격자가 과거에 캡처한 &amp;ldquo;문 열기&amp;rdquo; 메시지를 다시 차량에 보내는 상황을 생각해 보자. 서명이 유효하면 차량은 그 명령을 그대로 실행한다.&lt;/p&gt;
&lt;p&gt;따라서 명령에는 이번 요청임을 나타내는 정보가 필요하다.&lt;/p&gt;
&lt;p&gt;예를 들어 다음과 같은 값을 사용할 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nonce&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;seq&lt;/code&gt;(sequence number)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;commandId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expiresAt&lt;/code&gt;(만료 시각)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;차량은 이미 처리한 &lt;code&gt;commandId&lt;/code&gt;나 &lt;code&gt;seq&lt;/code&gt;를 기록해 같은 요청이 다시 실행되지 않도록 해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;서명은 메시지가 변조되지 않았다는 것과 누가 서명했는지만 확인해 준다. 그 메시지가 지금 처음 온 것인지는 알려주지 않는다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="수신자도-서명-대상에-포함한다"&gt;수신자도 서명 대상에 포함한다&lt;/h3&gt;
&lt;p&gt;마지막으로 이 명령이 누구를 대상으로 하는지도 서명에 포함해야 한다.&lt;/p&gt;
&lt;p&gt;예를 들어 휴대폰이 다음 메시지에 서명했다고 하자.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Sign(SK_P, {
command: &amp;#34;unlock&amp;#34;
})
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 서명에는 &amp;ldquo;누구의 차량을 열 것인가&amp;quot;가 들어 있지 않다.&lt;/p&gt;
&lt;p&gt;같은 사용자의 공개키가 여러 차량이나 디바이스에 등록되어 있다면, 공격자가 한 차량에서 얻은 이 서명된 명령을 다른 차량으로 가져갈 수 있다. 서명 자체는 여전히 유효하기 때문에, 수신자는 이 명령이 원래 자신을 대상으로 만들어진 것인지 판단하기 어렵다.&lt;/p&gt;
&lt;p&gt;따라서 다음처럼 수신자를 명시하는 것이 좋다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Sign(SK_P, {
receiverId,
command,
commandId,
seq,
expiresAt
})
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이렇게 하면 서명은 &amp;ldquo;휴대폰이 문 열기 명령에 서명했다&amp;quot;는 것뿐만 아니라 &amp;ldquo;휴대폰이 특정 차량을 대상으로 문 열기 명령에 서명했다&amp;quot;는 것까지 묶는다.&lt;/p&gt;
&lt;p&gt;오프라인 차량 제어에서 서명해야 할 것은 명령 하나가 아니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;누가 보내는지, 누구에게 보내는지, 어떤 세션에서 보내는지, 언제 보내는지를 함께 묶어 서명해야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="실제-시스템은-어떻게-구현했는가"&gt;실제 시스템은 어떻게 구현했는가&lt;/h2&gt;
&lt;h3 id="테슬라는-사전-등록된-공개키로-명령을-검증한다"&gt;테슬라는 사전 등록된 공개키로 명령을 검증한다&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://whitevision.dev/posts/vehicle-control-system/"&gt;테슬라의 차량 명령 프로토콜&lt;/a&gt;&lt;/strong&gt; 은 지금까지 설명한 구조와 세부는 다르지만, 같은 문제를 같은 방식으로 푼다.
차량은 사전에 등록된 공개키를 신뢰하고, 명령에는 서명과 함께 nonce, counter, epoch 같은 값이 포함된다. 차량은 이 정보를 이용해 서버에 다시 물어보지 않고도 명령의 출처와 유효성을 검증할 수 있다. 서버에 연결할 수 없는 환경에서도 차량 제어가 가능해야 한다는 요구가, 이런 오프라인 검증 구조로 이어진다.&lt;/p&gt;
&lt;h3 id="ccc-디지털-키는-이-구조를-표준으로-확장했다"&gt;CCC 디지털 키는 이 구조를 표준으로 확장했다&lt;/h3&gt;
&lt;p&gt;CCC(Car Connectivity Consortium)의 &lt;strong&gt;&lt;a href="https://klarciel.net/wiki/sdv/digital-key/"&gt;디지털 키(Digital Key)&lt;/a&gt;&lt;/strong&gt; 는 앞에서 설명한 구조를 차량용 디지털 키 표준으로 확장한 대표적인 사례다. 휴대폰은 Secure Element에 디지털 키를 안전하게 보관하고, 차량과는 NFC(Near Field Communication)·BLE·UWB(Ultra-Wideband)를 이용해 통신한다. 차량과 휴대폰은 상호 인증을 하고, 인증이 끝난 뒤 차량 접근이나 시동과 같은 동작을 허용한다. CCC는 차량과 휴대폰 사이의 신뢰를 공개키 기반 구조로 세우도록 정의한다.&lt;/p&gt;
&lt;p&gt;특히 디지털 키는 인터넷에 연결할 수 없는 상황에서도 오너 페어링(Owner Pairing)과 차량 사용이 가능하도록 설계되어 있다. 지하주차장처럼 서버와 통신할 수 없는 환경에서도 이미 프로비저닝된 디지털 키를 이용해 차량을 사용할 수 있어야 하기 때문이다. 다만 키 공유나 재프로비저닝처럼 새 자격 증명을 발급해야 하는 작업에는 서버 연결이 필요하다.&lt;/p&gt;
&lt;p&gt;디지털 키는 여기서 한 단계 더 나아간다. &amp;ldquo;유효한 디지털 키를 가진 기기인가?&amp;ldquo;만 확인하는 것으로는 충분하지 않다. 그 키를 가진 휴대폰이 실제로 차량 가까이에 있는지도 확인해야 한다. Digital Key Release 3에서는 BLE를 이용해 인증 및 세션을 수립한 뒤, UWB를 이용한 Secure Ranging을 통해 휴대폰과 차량 사이의 거리를 측정한다. 이를 통해 공격자가 두 장치 사이의 통신을 중계해 멀리 떨어진 곳에서 차량을 제어하려는 Relay Attack을 막을 수 있다.&lt;/p&gt;
&lt;h3 id="비대칭-서명만으로는-오너-페어링을-풀-수-없다"&gt;비대칭 서명만으로는 오너 페어링을 풀 수 없다&lt;/h3&gt;
&lt;p&gt;디지털 키 시스템에는 오너 페어링이라는 과정이 있다. 차량과 소유자의 디바이스 사이에 최초로 신뢰 관계를 설정하는 단계다. 오너 페어링은 휴대폰이 정품 디지털 키 기능을 갖춘 기기인지 확인하는 과정이 아니다. 특정 휴대폰을 해당 차량의 Owner Device로 등록하고, 이후 디지털 키를 관리하거나 다른 사용자에게 키를 공유할 수 있는 권한의 출발점을 설정하는 과정이다.&lt;/p&gt;
&lt;p&gt;여기서 문제가 하나 생긴다.&lt;/p&gt;
&lt;p&gt;비대칭키 기반의 오프라인 인증만으로는 오너 페어링이 풀어야 할 문제를 모두 해결할 수 없다. 예를 들어 차량이 Device OEM CA 인증서를 이용해 휴대폰의 인증서 체인을 검증한다고 해보자. 이를 통해 차량은 다음과 같은 사실을 확인할 수 있다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;눈앞의 휴대폰이 신뢰할 수 있는 제조사가 발급한 자격 증명을 가지고 있으며, 디지털 키 기능을 수행할 수 있는 정당한 기기인가?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;하지만 이것만으로는 다음 질문에 답할 수 없다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;이 휴대폰이 지금 이 차량의 Owner로 등록될 권한이 있는가?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;즉, 기기의 정당성과 특정 차량에 대한 소유자 등록 권한은 서로 다른 문제다.&lt;/p&gt;
&lt;p&gt;CCC 디지털 키는 이러한 초기 신뢰 설정 문제를 해결하기 위해 SPAKE2+와 같은 Password-Authenticated Key Exchange(PAKE) 방식을 사용한다. 차량과 휴대폰은 사전에 공유한 비밀이나 페어링 과정에서 받은 인증 정보로 서로를 인증하고, 비밀번호 자체를 네트워크에 노출하지 않은 상태에서 안전하게 공유 비밀을 만든다.&lt;/p&gt;
&lt;h3 id="emv의-dda는-같은-소유-증명을-쓴다"&gt;EMV의 DDA는 같은 소유 증명을 쓴다&lt;/h3&gt;
&lt;p&gt;EMV 칩카드 결제도 비슷한 원리를 오래전부터 사용해 온 대표적인 사례다. 카드와 단말은 공개키 기반 인증 구조를 사용하며, 단말은 카드가 제공한 인증서와 서명을 검증한다. DDA(Dynamic Data Authentication)에서는 단말이 제공하는 예측 불가능한 값을 포함해 카드가 동적 데이터를 만들고 자신의 비밀키로 서명한다. 단말은 카드의 공개키를 이용해 이를 검증함으로써 카드가 해당 비밀키를 실제로 가지고 있는지를 확인한다.&lt;/p&gt;
&lt;p&gt;따라서 DDA는 앞에서 설명한 challenge-response 방식의 소유 증명과 같은 핵심 원리를 보여주는 사례다. 다만 EMV의 실제 인증 절차에는 인증서 체인과 동적 인증 데이터 등 여러 요소가 함께 들어간다. &amp;ldquo;난수를 받고 서명한다&amp;quot;만으로는 EMV 전체를 설명하지 못한다.&lt;/p&gt;
&lt;h3 id="v2x는-메시지마다-서명을-로컬에서-검증한다"&gt;V2X는 메시지마다 서명을 로컬에서 검증한다&lt;/h3&gt;
&lt;p&gt;V2X(Vehicle-to-Everything)에서는 또 다른 형태로 같은 원리를 볼 수 있다. 차량과 도로 인프라 등의 장치는 PKI(Public Key Infrastructure)를 통해 발급받은 인증서를 이용해 V2X 메시지에 서명하고, 다른 장치는 그 서명을 로컬에서 검증한다. 미국의 SCMS(Security Credential Management System)는 이러한 V2X 환경에서 인증서를 발급하고 관리하기 위한 공개키 기반 구조다. 실제 SCMS 요구사항에서도 OBU(On-Board Unit)와 RSU(Roadside Unit) 등의 종단 장치가 IEEE 1609.2 인증서를 이용해 V2X 메시지에 서명하도록 정의하고 있다.&lt;/p&gt;
&lt;p&gt;V2X에서는 특히 메시지가 여러 차량과 인프라에 브로드캐스트되기 때문에, 매 메시지를 중앙 서버에 보내 확인하는 방식은 적합하지 않다. 수신 차량이 메시지를 받은 자리에서 서명을 검증하고 신뢰 여부를 판단할 수 있어야 한다. SCMS는 이 검증에 필요한 인증서와 신뢰 체계를 제공한다.&lt;/p&gt;
&lt;h2 id="인가는-어떻게-오프라인으로-옮기는가"&gt;인가는 어떻게 오프라인으로 옮기는가&lt;/h2&gt;
&lt;p&gt;인증이 &amp;ldquo;너는 누구인가&amp;quot;라면 인가는 &amp;ldquo;너는 무엇을 할 수 있는가&amp;quot;다. 인증에 성공한 휴대폰이라도 소유자의 키인지 임시로 공유받은 키인지에 따라 허용되는 명령이 달라야 한다. 디바이스 안에서 동작하는 애플리케이션마다 접근할 수 있는 데이터도 달라야 한다.&lt;/p&gt;
&lt;p&gt;문제는 오프라인에서 인가가 겪는 제약이 인증과 정확히 같다는 점이다. 판정 시점에 정책 서버에 물어볼 수 없다. 따라서 해법의 형태도 같다.&lt;/p&gt;
&lt;h3 id="정책-결정을-런타임에서-빌드-시점으로-앞당긴다"&gt;정책 결정을 런타임에서 빌드 시점으로 앞당긴다&lt;/h3&gt;
&lt;p&gt;접근 제어는 두 부분으로 나뉜다. 허용할지 말지를 결정하는 정책 결정 지점(PDP, Policy Decision Point)과, 그 결정을 실제로 집행하는 정책 시행 지점(PEP, Policy Enforcement Point)이다. 온라인 시스템에서 PDP는 원격 서버에 있고 요청이 올 때마다 호출된다. PEP는 서버가 내려준 답을 실행할 뿐이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;오프라인 인가는 이 결정을 요청 시점이 아니라 빌드·프로비저닝 시점에 미리 내려두고, 그 결과에 서명해서 배포하는 것이다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;인증에서 상대의 공개키를 미리 배포해 두었던 것과 똑같이, 인가에서는 권한 판정 결과를 미리 만들어 배포한다. PDP는 사전에 한 번 실행되고, 런타임의 PEP는 서버를 호출하는 대신 서명만 검증한다.&lt;/p&gt;
&lt;h3 id="권한-토큰은-사전에-서명해-배포한다"&gt;권한 토큰은 사전에 서명해 배포한다&lt;/h3&gt;
&lt;p&gt;차량 밖의 예를 하나 보자. 하나의 디바이스 안에서 로컬 저장소를 여러 클라이언트가 공유하고, 클라이언트마다 읽고 쓸 수 있는 스키마가 다른 상황을 가정한다. 중개 역할을 하는 로컬 서비스는 권한 판정을 위해 원격 정책 서버를 호출할 수 없다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;발급 서버는 &amp;ldquo;어떤 클라이언트가 어떤 리소스에 어떤 동작을 할 수 있는가&amp;quot;를 정책으로 정의한다.&lt;/li&gt;
&lt;li&gt;런타임이 아닌 사전 단계에서 각 클라이언트별 권한 목록을 만들고 비밀키로 서명해 권한 토큰을 발급한다.&lt;/li&gt;
&lt;li&gt;토큰은 클라이언트 배포물에 함께 포함되어 나간다.&lt;/li&gt;
&lt;li&gt;로컬 서비스는 발급 서버의 공개키를 HSM이나 TEE 같은 안전 저장 영역에 보관한다.&lt;/li&gt;
&lt;li&gt;런타임에 클라이언트가 요청과 함께 토큰을 제시하면, 로컬 서비스는 서명을 검증하고 토큰에 적힌 권한 범위 안에서만 요청을 허용한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;토큰에는 최소한 주체 식별자, 허용된 리소스와 동작의 목록, 유효 기간, 발급자, 정책 버전이 들어간다. 서명 덕분에 토큰은 위조할 수 없고, 클라이언트가 자기 권한을 스스로 주장할 여지가 사라진다.&lt;/p&gt;
&lt;h3 id="오프라인-판정은-잠정적-결정이다"&gt;오프라인 판정은 잠정적 결정이다&lt;/h3&gt;
&lt;p&gt;오프라인에서 내린 인가 판정은 최종 판정이 아니다.&lt;/p&gt;
&lt;p&gt;디바이스가 네트워크에 복귀하면 서버가 다시 판정할 수 있게 된다. 로컬에서 허용된 요청이라도 서버에서 한 번 더 정책을 검증하는 이중 검증이 심층 방어(Defense in Depth)의 기본이다. 오프라인 인가는 &amp;ldquo;서버를 대신하는 결정&amp;quot;이 아니라 &amp;ldquo;서버가 없는 동안의 잠정적 결정&amp;quot;으로 설계해야 한다.&lt;/p&gt;
&lt;p&gt;로컬에서 먼저 동작하고 연결이 복구되면 서버와 맞춰가는 이 사고방식은 &lt;strong&gt;&lt;a href="https://whitevision.dev/posts/mqtt-zeropayload/"&gt;IoT 메시지 전달을 위한 MQTT와 ZERO PAYLOAD&lt;/a&gt;&lt;/strong&gt; 에서 다룬 Offline-First와 같은 구조다. 차이가 있다면 동기화되는 대상이 데이터가 아니라 권한 판정이라는 점이다.&lt;/p&gt;
&lt;h2 id="맺음말"&gt;맺음말&lt;/h2&gt;
&lt;p&gt;오프라인 인증과 인가는 서버를 없애는 기술이 아니다. 서버가 내려야 할 판정을 미리 끝내 서명해 두고, 현장에서는 그 서명만 검증하는 설계다.
인가에서는 서명된 권한 토큰이 미리 내려둔 판정이고, 인증에서는 신뢰 앵커가 미리 심어둔 판정 기준이다.&lt;/p&gt;
&lt;p&gt;그래서 오프라인 설계의 무게중심은 검증 시점이 아니라 그 앞에 있다. 어떤 키를 언제 어디에 심을 것인지, 어떤 권한을 어느 시점에 서명해 둘 것인지가 런타임의 안전성을 결정한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;오프라인 설계의 핵심은 검증을 없애는 것이 아니라, 검증에 필요한 답을 검증 시점보다 앞에 배치하는 것이다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="references"&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc8705"&gt;RFC 8705, OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc9383"&gt;SPAKE2+, an Augmented Password-Authenticated Key Exchange (PAKE) Protocol&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc2986"&gt;RFC 2986, PKCS #10: Certification Request Syntax Specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://grouper.ieee.org/groups/802/1/files/public/docs2021/60802-pfaff-et-al-security-slice-0521-v03.pdf"&gt;IEC/IEEE 60802 Security Slice&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.scmsmanager.org/wp-content/uploads/2024/10/SCMS-Manager-Provider-Requirements-v1_1.pdf"&gt;SCMS Manager provider Requirements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/teslamotors/vehicle-command/blob/main/pkg/protocol/protocol.md"&gt;Tesla, Vehicle Command SDK Protocol&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://carconnectivity.org/digital-key/"&gt;Car Connectivity Consortium, Digital Key&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://1.ieee802.org/security/802-1ar/"&gt;IEEE 802.1AR, Secure Device Identity&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>IoT 메시지 전달을 위한 MQTT와 ZERO PAYLOAD</title><link>https://whitevision.dev/posts/mqtt-zeropayload/</link><pubDate>Wed, 12 Aug 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/mqtt-zeropayload/</guid><description>&lt;p&gt;차량, IVI(In-Vehicle Infotainment)처럼 IoT 디바이스를 다루는 시스템에서는 클라우드와 디바이스 간 통신이 시스템 설계의 큰 부분을 차지한다.&lt;/p&gt;
&lt;p&gt;클라우드 간 이벤트 전달에는 보통 Kafka를 쓴다.
서비스 사이의 결합도를 낮춰 각각의 독립성을 지키고, 유지보수성과 확장성을 얻기 위한
&lt;strong&gt;&lt;a href="https://whitevision.dev/posts/decoupling/"&gt;디커플링(DECOUPLING)&lt;/a&gt;&lt;/strong&gt; 이다.&lt;/p&gt;
&lt;p&gt;반면 클라우드에서 디바이스로 이벤트를 전달할 때는 MQTT(Message Queuing Telemetry Transport)를 쓴다.&lt;/p&gt;
&lt;p&gt;이 글은 클라우드가 디바이스로 이벤트를 전달하고, 이벤트를 받은 디바이스가 서버에 최신 데이터를 조회하는 상황을 가정한다.
이때 MQTT를 쓰는 이유와, MQTT가 ZERO PAYLOAD 전략과 잘 맞는 이유를 살펴본다.&lt;/p&gt;
&lt;h2 id="mqtt"&gt;MQTT&lt;/h2&gt;
&lt;p&gt;MQTT는 Publisher와 Subscriber 중 하나 이상이 저사양·저대역폭·간헐적 연결 디바이스라는 전제 위에서 설계됐다.&lt;/p&gt;
&lt;p&gt;MQTT는 고정 헤더가 2바이트에 불과해 저대역폭 환경에 유리하다.&lt;/p&gt;
&lt;p&gt;차량은 터널, 지하주차장, 음영 지역, 셀룰러 핸드오프 때문에 연결이 수시로 끊겼다 다시 붙는다.
연결이 끊긴 사이 서버에서 발생한 이벤트도 재연결 후에는 디바이스에 도착해야 한다.&lt;/p&gt;
&lt;p&gt;MQTT는 이러한 제약 조건에서 메시지를 안정적으로 전달하기 위해 만들어진 메시징 프로토콜이다.&lt;/p&gt;
&lt;p&gt;Broker가 있어서 Publisher는 Subscriber의 주소나 연결 상태를 알 필요가 없다.
하나의 메시지를 여러 구독자에게 복제해 보내는 Fan-Out, 연결 관리,
마지막 메시지를 보관했다가 새로 구독한 Subscriber에게 바로 전달하는 Retained Message도 Broker가 처리한다.&lt;/p&gt;
&lt;p&gt;즉, Broker 덕분에 Publisher는 &amp;ldquo;이 Topic에 이 메시지를 발행한다&amp;quot;만 신경 쓰면 된다.
Broker는 Publisher와 Subscriber 사이의 &lt;strong&gt;&amp;ldquo;전달 책임&amp;quot;을 분리&lt;/strong&gt;하는 역할을 한다.&lt;/p&gt;
&lt;h3 id="끊긴-사이의-메시지를-받는-방법"&gt;끊긴 사이의 메시지를 받는 방법&lt;/h3&gt;
&lt;p&gt;연결이 끊긴 동안 발행된 메시지를 재연결 후에 받으려면 두 가지 설정이 필요하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;QoS(Quality of Service)&lt;/strong&gt; — 0은 한 번 보내고 잊는다. 1은 최소 한 번 전달을 보장하는 대신 중복이 생길 수 있다.
2는 정확히 한 번 전달하지만 왕복이 늘어난다. 재연결이 잦은 차량 환경에서는 보통 QoS 1을 쓴다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Persistent Session&lt;/strong&gt; — 세션을 유지하도록 접속하면(MQTT 5는 &lt;code&gt;Clean Start=0&lt;/code&gt; 과 &lt;code&gt;Session Expiry Interval&lt;/code&gt;,
MQTT 3.1.1은 &lt;code&gt;Clean Session=false&lt;/code&gt;) Broker가 구독 정보와 미전달 메시지를 세션에 보관한다.
디바이스가 다시 접속하면 보관해둔 메시지를 이어서 받는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;다만 세션 보관 기간과 큐 용량에는 한계가 있다.
차량이 장기간 오프라인 상태라면 메시지는 결국 버려진다.
MQTT만으로는 &amp;ldquo;모든 이벤트를 반드시 받는다&amp;quot;를 보장할 수 없다는 뜻이고,
이 한계를 어떻게 다룰 것인지가 ZERO PAYLOAD 전략의 출발점이다.&lt;/p&gt;
&lt;h2 id="zero-payload"&gt;ZERO PAYLOAD&lt;/h2&gt;
&lt;p&gt;ZERO PAYLOAD는 메시지의 페이로드에 실제 데이터(e.g &lt;code&gt;{&amp;quot;battery&amp;quot;: 82, &amp;quot;door&amp;quot;: &amp;quot;locked&amp;quot;}&lt;/code&gt;)를 담지 않고
어떤 리소스가 변경되었는지를 알려주는 스키마, 식별자 정보(e.g &lt;code&gt;{&amp;quot;schema&amp;quot;:&amp;quot;vehicle_state&amp;quot;, &amp;quot;vehicleId&amp;quot;: ...&lt;/code&gt;)만 담아서
Subscriber가 해당 이벤트를 받아 필요한 데이터를 별도로 조회하는 설계 전략을 의미한다.&lt;/p&gt;
&lt;p&gt;메시지는 &amp;ldquo;무엇이 바뀌었다&amp;quot;는 신호일 뿐이고, 실제 값은 언제나 서버에서 가져온다.&lt;/p&gt;
&lt;h3 id="zero-payload를-쓰는-이유"&gt;ZERO PAYLOAD를 쓰는 이유&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;오래된 값으로 덮어쓰지 않는다.&lt;/strong&gt; 재전송이나 순서 역전으로 과거 이벤트가 뒤늦게 도착해도,
디바이스가 조회하는 값은 조회 시점의 최신 상태다. 같은 이벤트를 중복해서 받아도 결과는 같다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;밀린 이벤트가 한 번의 조회로 수렴한다.&lt;/strong&gt; 같은 리소스에 대한 이벤트가 여러 건 쌓였다면 마지막 한 건만 처리해도 된다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;민감한 데이터가 Broker에 남지 않는다.&lt;/strong&gt; 특히 Retained Message는 Broker에 계속 보관되므로,
페이로드에 실제 값을 담으면 그 값이 보관 대상이 된다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;스키마를 바꾸기 쉬워진다.&lt;/strong&gt; 페이로드 구조가 식별자로 고정되므로, 조회 응답의 스키마를 바꿔도 발행 측을 함께 배포할 필요가 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;대가도 있다. 이벤트 하나마다 조회가 한 번씩 더 발생하므로 서버 부하와 전달 지연이 늘어난다.
다수의 디바이스가 동시에 재연결하면 조회가 한꺼번에 몰린다(Thundering Herd).
디바이스마다 지터(Jitter)를 준 재시도, 조회 결과 캐싱 같은 완충 장치가 필요하다.&lt;/p&gt;
&lt;h2 id="offline-first"&gt;Offline-First&lt;/h2&gt;
&lt;p&gt;Offline-First의 핵심은 네트워크 연결 여부와 관계없이 클라이언트가 동작할 수 있도록 만드는 것이다.&lt;/p&gt;
&lt;p&gt;서버 상태를 항상 실시간으로 전달받는다고 전제하지 않는다.
클라이언트는 로컬에 캐시한 상태로 우선 동작하고, 네트워크가 복구되면 서버와 동기화한다.&lt;/p&gt;
&lt;p&gt;클라이언트가 지는 책임은 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;오프라인 동안 로컬 캐시로 UI 표시&lt;/li&gt;
&lt;li&gt;재연결 후 동기화 스케줄링&lt;/li&gt;
&lt;li&gt;데이터를 언제 Pull할지 결정&lt;/li&gt;
&lt;li&gt;서버와 로컬 상태가 충돌했을 때의 해결 정책&lt;/li&gt;
&lt;li&gt;필요에 따른 주기적인 Full Sync&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MQTT와 ZERO PAYLOAD를 함께 쓰는 구성은 이 책임 분담과 맞물린다.
클라우드에 저장된 DB가 Source of Truth이고, MQTT는 &amp;ldquo;지금 확인하라&amp;quot;고 알리는 이벤트 채널일 뿐이다.
이벤트를 전달 받은 디바이스는 내부적으로 정의한 동기화 정책에 따라 서버에서 최신 상태를 조회한다.&lt;/p&gt;
&lt;p&gt;다음 그림은 클라이언트 앱이 데이터를 변경한 뒤 차량까지 반영되는 흐름이다.&lt;/p&gt;
&lt;p&gt;&lt;img src="mqtt-zeropayload-sequence.png" alt="시퀀스 다이어그램"&gt;&lt;/p&gt;
&lt;p&gt;Backend Server는 DB commit과 발행 큐 저장을 한 트랜잭션에서 처리한다(Transactional Outbox).
DB에는 반영되었는데 이벤트 발행에 실패해 둘이 어긋나는 상황을 막기 위해서다.
차량이 오프라인이면 이벤트 수신은 재연결 시점까지 밀리고,
재연결 후 이벤트를 받은 차량은 서버에서 최신 스냅샷을 조회해 로컬 DB를 갱신한다.&lt;/p&gt;
&lt;p&gt;이 구성은 메시지 전달과 상태 동기화의 책임을 분리한다.
일시적인 메시지 유실은 시스템의 치명적인 실패가 아니라 &amp;ldquo;다음 동기화에서 해결할 수 있는 상태 불일치&amp;quot;가 된다.
이벤트를 놓치더라도 다음 이벤트나 주기적인 Full Sync에서 상태가 다시 맞춰지기 때문이다.&lt;/p&gt;
&lt;h2 id="references"&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://microservices.io/patterns/data/transactional-outbox.html"&gt;https://microservices.io/patterns/data/transactional-outbox.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html"&gt;https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://offlinefirst.org/"&gt;https://offlinefirst.org/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>기술부채보다 무서운 것은 엔지니어링 태도 부채다</title><link>https://whitevision.dev/posts/high-quality/</link><pubDate>Mon, 10 Aug 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/high-quality/</guid><description>&lt;p&gt;제품을 만드는 대부분의 회사는 인력과 시간이 충분하지 않다.
이때 가장 먼저 챙기는 것은 &lt;strong&gt;속도&lt;/strong&gt;이고, 가장 먼저 타협하는 것은 &lt;strong&gt;품질&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;제품 초기에는 품질을 조금 희생하는 것이 합리적으로 보일 때가 많다. 아직 사용자가 많지 않고, 요구사항도 계속 바뀐다. 지금 만든 코드가 몇 달 뒤 사라질 수도 있다. 그런 상황에서 모든 코드를 정교하게 만드는 것은 오히려 낭비다.&lt;/p&gt;
&lt;p&gt;그래서 어느 정도의 기술부채는 필요하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문제는 어떤 기술부채를 언제 갚을지 판단하는 기준이 사라지는 순간&lt;/strong&gt;이다. &lt;strong&gt;코드 리뷰&lt;/strong&gt;를 제대로 하지 않는 문화도 마찬가지다.&lt;/p&gt;
&lt;h2 id="기술부채는-선형적으로-증가하지-않는다"&gt;기술부채는 선형적으로 증가하지 않는다&lt;/h2&gt;
&lt;p&gt;처음에는 품질을 조금 포기하는 것이 생산성을 높이는 선택처럼 보인다. 조금씩 쌓이는 기술부채는 복잡성과 사이드 이펙트 가능성을 아주 조금 올릴 뿐인 것처럼 보인다.&lt;/p&gt;
&lt;p&gt;하지만 회사는 늘 바쁘다. 채용 기준이 높은 회사라면 길게는 1~2년 동안 인력을 충원하지 못하기도 한다.&lt;/p&gt;
&lt;p&gt;기술부채를 갚을 시간을 확보하지 못한 채 새 기능만 빠르게 붙이다 보면, 복잡성은 어느 순간 임계점을 넘는다. 그때부터 기술부채는 J 커브를 그리며 증가한다.&lt;/p&gt;
&lt;p&gt;결국 대규모 리팩토링을 해야 하는 상황이 오고, “새로 만드는 게 낫겠는데?”라는 말이 나온다.&lt;/p&gt;
&lt;h2 id="태도의-부채는-문화가-된다"&gt;태도의 부채는 문화가 된다&lt;/h2&gt;
&lt;p&gt;가장 큰 문제는 &lt;strong&gt;품질을 포기하는 과정과 코드 리뷰 부채, 쌓이는 기술부채가 팀의 엔지니어링 태도까지 바꾼다&lt;/strong&gt;는 점이다.
나는 이것을 &lt;strong&gt;엔지니어링 태도의 부채&lt;/strong&gt;라고 부른다.&lt;/p&gt;
&lt;p&gt;이 부채는 아무도 인식하지 못한 사이에 팀의 문화로 굳는다.&lt;/p&gt;
&lt;p&gt;기술부채는 코드에만 쌓이는 것이 아니라 팀의 기대 수준과 엔지니어링 태도에도 쌓인다.&lt;/p&gt;
&lt;p&gt;기술부채는 나중에 갚을 수 있다. 구조를 다시 만들 수도 있고, 테스트를 추가할 수도 있다.&lt;/p&gt;
&lt;p&gt;하지만 품질을 포기해도 괜찮다는 태도가 팀에 자리 잡으면, 그 태도를 되돌리는 데 훨씬 큰 비용이 든다.&lt;/p&gt;
&lt;p&gt;기술부채를 만드는 것과 낮은 품질에 익숙해지는 것은 전혀 다른 문제다.
전자는 전략적인 선택일 수 있다. 후자는 문화가 된다.&lt;/p&gt;
&lt;h2 id="품질은-조직의-해자다"&gt;품질은 조직의 해자다&lt;/h2&gt;
&lt;p&gt;속도만큼 중요한 것이 &lt;strong&gt;품질&lt;/strong&gt;이다.
속도를 지키면서도 품질을 놓지 않으려는 개인의 태도가 팀의 문화를 만든다. 나는 그 문화가 조직의 해자(moat)라고 생각한다.&lt;/p&gt;
&lt;p&gt;눈에 보이는 것은 일정 수준의 테스트 커버리지와 팀원이 읽기 좋은 코드다.
눈에 보이지 않는 것은 엔지니어링 문화다.&lt;/p&gt;
&lt;p&gt;품질은 우리가 어떤 엔지니어링 문화를 가졌는지 보여주는 가장 직접적인 결과다.&lt;/p&gt;</description></item><item><title>차량 제어 시스템 디자인</title><link>https://whitevision.dev/posts/vehicle-control-system/</link><pubDate>Sun, 09 Aug 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/vehicle-control-system/</guid><description>&lt;p&gt;차량을 제어하는 시스템은 단순한 서버 애플리케이션보다 훨씬 복잡하다.&lt;/p&gt;
&lt;p&gt;사용자가 스마트폰으로 차량 문을 잠그는 동작 하나에도 모바일 앱, 백엔드 서버, 차량과 통신하는 연결 계층, 차량 내부의 제어기와 게이트웨이, 실제로 문을 잠그는 액추에이터가 관여한다. 여기에 인증과 암호화, 상태 관리, 커넥션과 세션, 메시지 유실과 순서 뒤바뀜, 중복 명령과 재시도, 차량의 물리적 안전 조건까지 고려해야 한다.&lt;/p&gt;
&lt;p&gt;차량 제어 시스템에는 클라이언트, 서버, 네트워크, 차량 통신, 임베디드 소프트웨어, 보안 등 서로 다른 영역의 전문성이 필요하다. 실제로 여러 팀의 엔지니어가 각자의 경계를 나눠 책임진다.&lt;/p&gt;
&lt;p&gt;나는 42dot의 Connected Service Group 초기 멤버로 합류해 Connect App 제품을 만들었다. 당시 팀원 분들과 함께 차량 원격 제어 시스템을 초기부터 설계하고 구축했으며, 테슬라의 원격 제어 시스템을 직접 벤치마킹하기도 했다.&lt;/p&gt;
&lt;p&gt;차량 제어 시스템은 일반적인 웹 서비스와 달리 &lt;strong&gt;소프트웨어 명령이 실제 물리 세계의 상태를 바꾼다는 점에서 독특한 설계 문제&lt;/strong&gt;를 갖는다.&lt;/p&gt;
&lt;p&gt;이 글은 차량과 통신하고 원격 명령을 전달하는 서버를 만드는 엔지니어의 관점에서 차량 제어 시스템을 다룬다. 하드웨어 추상화나 차량 내부 시스템은 다루지 않으므로, 본문에서 &amp;ldquo;차량&amp;quot;이라고 쓴 것은 실제로는 차량 내부의 여러 컴포넌트를 묶어 부르는 말이다. 공개된 테슬라의 차량 원격 제어 시스템과 차량 명령 프로토콜을 하나의 사례로 참고한다.&lt;/p&gt;
&lt;p&gt;출발점은 하나의 질문이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;불확실한 네트워크를 사이에 두고 물리적인 대상을 제어해야 할 때, 소프트웨어 엔지니어는 무엇을 신뢰해야 하고 무엇을 신뢰해서는 안 되는가?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;차량 원격 제어에서는 클라우드에서 명령을 보냈다는 사실과 차량이 실제로 그 명령을 실행했다는 사실이 항상 일치하지 않는다. 명령이 네트워크를 통과했는지, 차량이 받았는지, 처리했는지, 상태가 실제로 바뀌었는지, 그 결과를 클라우드가 관찰했는지는 서로 다른 문제다.&lt;/p&gt;
&lt;p&gt;신뢰성, 확장성, 탄력성, 장애 내성, 성능, 관측 가능성 같은 일반적인 설계 원칙은 이미 많은 시스템 디자인 자료가 다룬다. 여기서 다루는 것은 차량 원격 제어에서만 발생하는 &lt;strong&gt;제어의 불확실성&lt;/strong&gt;이다. 불확실한 네트워크를 통해 실제 차량이라는 물리적 대상을 제어하는 문제는 단순한 서버 간 통신의 문제로 환원하기 어렵다.&lt;/p&gt;
&lt;h2 id="차량의-불안정한-통신-환경"&gt;차량의 불안정한 통신 환경&lt;/h2&gt;
&lt;p&gt;웹 서버는 데이터센터나 클라우드에 있다. 전원이 안정적으로 공급되고, 여러 네트워크 장비를 거쳐 인터넷에 연결되며, 연중무휴 가동(24/7)을 전제한다.
클라이언트와 서버 사이의 연결은 끊어져도 서버 자체가 이동하거나 네트워크 환경이 바뀌지는 않는다.&lt;/p&gt;
&lt;p&gt;반면 차량은 이동하는 &lt;strong&gt;엣지 디바이스&lt;/strong&gt;(edge device)다. 차량과 서버 사이의 통신은 차량이 어디에 있는지, 어떤 상태인지, 어떤 네트워크를 사용하고 있는지에 따라 계속 달라진다.&lt;/p&gt;
&lt;p&gt;예를 들어 차량이 도로를 주행하고 있다면 하나의 기지국에 계속 연결되어 있는 것이 아니다. 차량이 이동하면서 더 가까운 기지국으로 연결을 변경하는 &lt;strong&gt;핸드오버&lt;/strong&gt;가 발생한다.
핸드오버 과정에서 연결이 순간적으로 끊기고, 패킷이 지연되거나 유실된다.&lt;/p&gt;
&lt;p&gt;차량이 지하주차장으로 들어가는 경우에는 LTE나 5G 신호가 약해지거나 완전히 사라질 수 있다. 이 경우 차량은 인터넷에 연결할 수 없기 때문에
서버가 보낸 명령을 즉시 받을 수 없다.&lt;/p&gt;
&lt;p&gt;지상에서도 항상 통신이 가능한 것은 아니다. 산이나 터널, 외곽 지역 등에서는 LTE/5G 음영 지역이 있고 이러한 지역을 지나가는 동안에는 서버와 차량 사이의 통신이 일시적으로 끊길 수 있다.&lt;/p&gt;
&lt;p&gt;차량은 항상 모든 통신 모듈과 컴퓨터를 켜놓고 있는 것이 아니다. 차량이 장시간 주차되어 있으면 배터리 소모를 줄이기 위해 딥 슬립(Deep Sleep) 상태로 들어갈 수 있다. 이때는 통신 모듈이나 차량의 일부 컴퓨터까지 절전 상태가 될 수 있다. 따라서 서버에서 명령을 보내더라도 차량이 즉시 이를 받을 수 있다고 가정할 수 없다. 차량을 깨우는 과정 자체에도 시간이 필요할 수 있다.&lt;/p&gt;
&lt;p&gt;차량의 전원 상태 역시 변수다. 특히 차량의 12V 배터리 상태가 좋지 않거나 전원 관리 정책에 따라 일부 시스템이 비활성화되면 통신이나 명령 처리에 영향을 줄 수 있다. 즉, 차량은 서버처럼 항상 동일한 전원 상태에서 실행되는 컴퓨터가 아니다.&lt;/p&gt;
&lt;p&gt;네트워크 연결 방식 자체가 변경될 수도 있다. 차량에 LTE/5G와 Wi-Fi가 모두 제공되는 경우 상황에 따라 연결이 변경될 수 있다. LTE에서 Wi-Fi로, 또는 Wi-Fi에서 다시 셀룰러 네트워크로 전환되는 과정에서 기존 연결이 끊어지고 새로운 연결이 만들어질 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 &lt;strong&gt;차량은 언제든 연결되어 있지 않을 수 있고, 연결되어 있더라도 통신이 지연되거나 끊길 수 있으며, 차량 자체가 명령을 처리할 수 없는 상태일 수도 있음&lt;/strong&gt;을 항상 기억해야 한다.&lt;/p&gt;
&lt;p&gt;그리고 이 불안정함은 클라우드와 차량 사이의 구간에서 끝나지 않는다. 차량 내부의 구성은 제조사와 차종마다 다르고 외부에 공개되지도 않으므로 아래는 일반적인 차량 전자 아키텍처를 전제한 서술이지만,
대체로 하나의 명령은 액추에이터에 닿기까지 여러 번 서로 다른 구간을 통과한다. 클라우드에서 차량의 통신 유닛까지는 셀룰러 망 위에 얹힌 HTTPS일 것이고, 통신 유닛에서 게이트웨이까지는 차량 내부 이더넷일 수 있으며,
게이트웨이에서 도어 컨트롤러까지는 CAN(Controller Area Network)일 수 있고, 마지막 구간은 전기 신호다. 각 구간은 서로 다른 타임아웃과 서로 다른 실패 모드를 갖는다.&lt;/p&gt;
&lt;p&gt;그리고 결정적으로, 하위 구간에서 발생한 결과가 상위 계층까지 온전히 전파된다고 보장할 수 없다. CAN은 애초에 종단 간 명령의 실행 결과를 전달하기 위한 프로토콜이 아니며, 각 계층과 구간은 서로 다른 방식으로 동작한다.
상위에서 &amp;ldquo;명령을 보냈다&amp;quot;는 사실과 하위에서 &amp;ldquo;액추에이터가 실제로 움직였다&amp;quot;는 사실 사이에는, 결과를 보장할 수 없는 단절이 있다.
즉, 상위 계층이 확인할 수 있는 것은 명령을 전달했다는 사실이지, 그 명령이 물리 세계의 상태를 실제로 변경했다는 사실이 아니다.&lt;/p&gt;
&lt;h2 id="ack가-보장하지-못하는-것"&gt;ACK가 보장하지 못하는 것&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;차량 원격 제어 시스템에서 클라이언트가 차량으로 명령을 보내고 전달받은 HTTP 200 응답은 차량이 명령을 성공적으로 실행했다는 뜻이 아니다.
명령을 실행했을 수도, 하지 못했을 수도 있다. 그저 응답을 받았다는 의미일 뿐이다. 클라이언트가 명령을 전송했지만 차량으로부터 응답을 받지 못했더라도, 차량은 명령을 수신하고 실행했을 수 있다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;예를 들어 차량 제어 서버에서 &lt;code&gt;Door Lock&lt;/code&gt; 명령을 차량으로 보낸다. 차량은 명령을 검증하고, ECU(Electronic Control Unit)로 전달하고, 액추에이터를 구동한 뒤, 실제 잠금 상태를 확인한다.
이때 클라우드 명령에 대한 ACK(응답)를 차량이 동기적으로 준다고 가정해보자.&lt;/p&gt;
&lt;p&gt;제어 명령에 대한 응답이 현재 차량의 상태가 &lt;code&gt;LOCKED&lt;/code&gt;임을 보장할 수 있을까?&lt;/p&gt;
&lt;p&gt;차량 제어 서버에서 응답을 모바일 앱으로 전달하는 과정에서 차 안에서 누군가 차 문 잠금을 해제할 수도 있다. 이 경우 앱에서는 &lt;code&gt;LOCKED&lt;/code&gt;로 표시되는데
실제 차량의 상태는 &lt;code&gt;UNLOCKED&lt;/code&gt;일 것이다.&lt;/p&gt;
&lt;p&gt;따라서 차량에서 물리적 변경이 발생한 뒤에 명령에 대한 응답을 동기적으로 준다고 하더라도 차량이 주는 &lt;strong&gt;ACK는 차량의 최종적인 상태를 보장하지 못한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이 동기 방식은 실무에서 쓸 수 없다. 물리적 작업은 하위 ECU와 액추에이터의 처리 시간에 의존하므로, 모든 명령의 완료를 기다렸다 응답하면
차량 내부의 처리 자원과 연결을 오래 점유한다. 그래서 차량은 물리적 변경을 &lt;strong&gt;비동기&lt;/strong&gt;로 처리한다.&lt;/p&gt;
&lt;p&gt;동기든 비동기든, 메시지가 유실될 수 있는 채널에서는 ACK도 유실된다.&lt;/p&gt;
&lt;p&gt;잠금 명령이 차량에 도착한다. 차량은 문을 잠그고, 자기 상태를 잠김으로 바꾼 뒤 응답을 보낸다. 만약 응답이 네트워크 어딘가에서 유실되면 서버는 시간이 지나도 아무것도 받지 못한다.
이번에는 반대의 상황이다. 공조 켜기 명령이 차량에 닿기도 전에 사라진다. 차량은 아무것도 받지 못했고, 아무것도 실행할 수 없기 때문에 공조는 여전히 꺼져 있는 상태일 것이다.&lt;/p&gt;
&lt;p&gt;두 경우에 차량의 실제 상태는 정반대다. 그런데 서버가 관측한 것은 두 경우 모두 &amp;ldquo;응답이 오지 않았다&amp;rdquo; 하나뿐이다. 즉, &lt;strong&gt;서버는 통신 결과만으로 차량에서 실제로 어떤 일이 발생했는지를 확정할 수 없다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&amp;ldquo;차량에서 ACK를 한 번 더 보내면 되지 않나?&amp;ldquo;라고 생각할 수 있다.&lt;/p&gt;
&lt;p&gt;차량에서 응답을 한 번 더 보낸다고 하자. 그런데 차량은 자기 응답이 서버에 도착했는지 모른다. 차량 입장에서는 &amp;ldquo;서버가 못 받았다면 서버는 명령을 다시 보낼 텐데, 그러면 문을 두 번 잠그게 되는 것은 아닐까?&amp;rdquo;
라고 생각할 수밖에 없다. 그러면 서버가 &amp;ldquo;네 응답을 받았다&amp;quot;를 알려주면 되지 않을까? 이 경우도 응답은 유실될 수 있고 이러한 과정을 &lt;strong&gt;몇 번을 주고받든 마지막으로 보낸 메시지의 도달 여부는 언제나 알 수 없다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이것이 1975년에 정식화된 &lt;strong&gt;&lt;a href="https://en.wikipedia.org/wiki/Two_Generals%27_Problem"&gt;Two Generals&amp;rsquo; Problem&lt;/a&gt;&lt;/strong&gt; 이다.
Two Generals&amp;rsquo; Problem은 &lt;strong&gt;&amp;ldquo;메시지가 유실될 수 있는 통신 채널에서 완벽한 합의를 달성할 수 있는가?&amp;rdquo;&lt;/strong&gt; 라는 이론적인 문제이며,
그 답은 &lt;strong&gt;신뢰할 수 없는 채널 위에서는 합의(consensus)가 불가능&lt;/strong&gt;하다는 것이다.&lt;/p&gt;
&lt;p&gt;이것이 차량 원격 제어와 같은 분산 시스템에서 발생하는 불확실성이며, &lt;strong&gt;차량 원격 제어 시스템 설계의 출발점은 차량의 물리적인 특성을 이해하는 것과, 이 불확실성을 인정하는 것&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;TCP를 쓰면 되지 않나?&amp;ldquo;라고 생각이 든다면 TCP가 보장하지 않는 것이 무엇인지 인지할 필요가 있다.
TCP는 바이트 스트림의 순서는 보장하고, 유실 시 재전송을 한다. 하지만 애플리케이션이 그 바이트를 읽었는지, 처리했는지, 처리 결과가 무엇인지는 보장하지 않으며, 애플리케이션 수준의 요청 순서도 보장하지 않는다.&lt;/p&gt;
&lt;p&gt;잠금 명령이 차량의 TCP 스택에 도착하는 순간 커널이 자동으로 TCP ACK를 보낸다. 서버는 &amp;ldquo;전달 완료&amp;quot;를 본다. 그런데 그 시점에 애플리케이션은 아직 그 바이트를 읽지도 않았다. 애플리케이션이 read를 호출하고 명령을 파싱하는 사이에 프로세스가 종료되면, 명령은 도어 컨트롤러까지 가지 못한다. 서버는 TCP ACK를 받았으므로 성공이라고 판단하고, 문은 잠기지 않은 채로 남는다.&lt;/p&gt;
&lt;p&gt;여기서 결정적인 것은 ACK를 누가 보내는가이다.&lt;/p&gt;
&lt;p&gt;TCP ACK를 받았다는 것은 상대방의 TCP 커널이 데이터를 정상적으로 수신했다는 것을 의미할 뿐이다. 애플리케이션이 해당 데이터를 실제로 처리했는지, 더 나아가 그 처리로 인해 원하는 비즈니스 상태가 만들어졌는지는 보장하지 않는다.&lt;/p&gt;
&lt;p&gt;그렇다면 애플리케이션 레벨에서 ACK를 보내면 해결될까? 그렇지 않다. 애플리케이션 ACK를 사용하더라도 같은 문제가 한 계층 위에서 반복된다.&lt;/p&gt;
&lt;p&gt;예를 들어 차량이 명령을 정상적으로 수신하고 애플리케이션 ACK를 전송한 직후 장애가 발생했다고 하자. 서버는 ACK를 받았기 때문에 명령이 성공했다고 판단할 수 있지만, 실제로는 차량이 명령을 끝까지 처리하지 못했거나 물리적인 상태가 변경되지 않았을 수 있다.
결국 TCP가 보장하는 것은 transport reliability, 즉 데이터가 전송 계층에서 상대방에게 전달되었다는 사실이다. 하지만 우리가 차량 제어 시스템에서 원하는 것은 business-operation reliability, 즉 &amp;ldquo;요청한 작업이 실제로 완료되어 원하는 비즈니스 상태가 만들어졌는가&amp;quot;에 대한 보장이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ACK는 차량에서 명령이 처리되어 차량의 실제 상태(Actual State)가 변경되었음을 보장하지 못한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;그렇다면 명령의 성공 여부는 무엇으로 판단해야 할까.&lt;/p&gt;
&lt;h2 id="명령과-상태-분리"&gt;명령과 상태 분리&lt;/h2&gt;
&lt;p&gt;차량 제어 서버에서 명령을 전달하고 차량 상태가 변경되었을 때 내 명령 때문에 발생한 것이라고 확신할 수 있을까?
차에서 사람이 물리적으로 Door Lock/Unlock 등의 행위를 할 수 있기 때문에 차량의 상태가 변경되었을 때 그 상태 변경이 내 명령 때문에 발생했는지를 보장할 수 없다.
특히 위에서 살펴본 것처럼 &lt;strong&gt;ACK 메커니즘으로는 불가능&lt;/strong&gt;하다.&lt;/p&gt;
&lt;p&gt;이 문제를 해결하기 위한 핵심은 &lt;strong&gt;명령과 상태를 분리&lt;/strong&gt;하는 것이다. &lt;strong&gt;명령은 차량의 상태를 변경해 달라는 요청일 뿐이고, 최종 상태는 차량의 상태로 확인해야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;최종 상태를 확인하는 방법은 두 가지다.&lt;/p&gt;
&lt;p&gt;첫째는 &lt;strong&gt;델타(Delta)&lt;/strong&gt; 다. 차 문, 공조, 트렁크처럼 차량에서 물리적 변경이 일어나 실제 상태(Actual State)가 바뀌면, &lt;strong&gt;차량이&lt;/strong&gt; 바뀐 부분만 클라우드로 보낸다. 클라우드는 이 델타로 앱에 보여줄 상태를 갱신한다.&lt;/p&gt;
&lt;p&gt;둘째는 &lt;strong&gt;스냅샷 조회&lt;/strong&gt;다. 델타를 처리하는 도중 예외가 발생하면 클라우드가 가진 상태와 차량의 실제 상태가 어긋난다. 이때 &lt;strong&gt;클라우드가&lt;/strong&gt; 차량의 현재 상태 전체를 조회해 맞춘다. 델타의 &lt;strong&gt;폴백(Fallback)&lt;/strong&gt; 이다.&lt;/p&gt;
&lt;p&gt;즉 &lt;strong&gt;클라우드는 명령을 전달할 뿐이고, 최종 상태는 차량이 보내는 델타로 알며, 어긋나면 스냅샷으로 복구한다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="상태-수렴-패턴"&gt;상태 수렴 패턴&lt;/h2&gt;
&lt;p&gt;차량은 클라우드에서 보낸 차 문 열기, 충전 퍼센트 설정, 온도 설정과 같은 명령을 어떻게 안전하게 처리할 수 있을까?&lt;/p&gt;
&lt;p&gt;차량은 하드웨어 오류, 통신 오류 등 다양한 원인으로 인해 클라우드 요청이 정상적으로 반영되지 않을 수 있다.&lt;/p&gt;
&lt;p&gt;예를 들어 Door Open 명령을 차량에 전달했다고 가정하자. 차량은 명령을 처리하는 과정에서 하드웨어 오류로 인해서 처리하지 못했다.
다시 차량이 복구되고 나서 해당 명령을 처리해야 할까? 차량이 복구된 시점에 운전자는 차량과 다른 곳에 있을 수 있다. 이때 차 문이 열린다면 큰 이슈가 될 수 있다.&lt;/p&gt;
&lt;p&gt;두 번째 예를 살펴보자. 이번에는 충전 목표를 80%로 설정하는 명령을 차량에 전달했다고 가정하자. 이번에는 차량의 네트워크 연결이 끊어졌다.
네트워크가 복구되고 나서 해당 명령은 처리되어야 할까?&lt;/p&gt;
&lt;p&gt;첫 번째 예시와 두 번째 예시를 든 이유는 &lt;strong&gt;명령에는 수명이 있다&lt;/strong&gt;는 것을 말하기 위해서이다.
Door Open의 경우에는 일회성 명령이다. 충전 목표를 80%로 설정은 지속되어야 하는 상태를 요구하는 명령이다.
따라서 두 번째 명령의 경우에는 네트워크가 복구되고 나면 차량이 사용자의 의도를 명확히 반영해야 한다.&lt;/p&gt;
&lt;p&gt;클라우드가 차량으로 보낸 명령은 &lt;strong&gt;사용자의 의도&lt;/strong&gt;이며 의도는 &lt;strong&gt;원하는 상태&lt;/strong&gt;를 의미하기도 한다. 이것을 &lt;strong&gt;Desired State&lt;/strong&gt;라고 한다.
차량은 하드웨어 오류, 통신 오류 등의 상황에서 차량이 복구되고 나서 사용자의 의도를 처리하기 위해서 의도를 &lt;strong&gt;Desired State&lt;/strong&gt;로 저장한다.
차량의 실제 상태는 &lt;strong&gt;Actual State&lt;/strong&gt;라고 한다.&lt;/p&gt;
&lt;p&gt;Desired State를 반영하려면 실제 상태, 전제조건, 정책을 함께 고려해야 한다. Desired와 Actual을 비교해 차이를 좁히는 주체를 &lt;strong&gt;Reconciler&lt;/strong&gt;라고 한다.
차량의 물리적 상태가 바뀌면 차량 내부의 상태 보고 컴포넌트가 Actual State의 변화를 구독해 클라우드로 델타를 보낸다.&lt;/p&gt;
&lt;p&gt;지금까지 설명한 이 디자인 패턴을 &lt;strong&gt;Desired/Actual State Pattern&lt;/strong&gt;이라고 하며 IoT에서 자주 사용되는 패턴이다.&lt;/p&gt;
&lt;p&gt;추가로 &lt;strong&gt;&lt;a href="https://docs.aws.amazon.com/iot/latest/developerguide/iot-device-shadows.html"&gt;AWS IoT Device Shadow&lt;/a&gt;&lt;/strong&gt; 를 살펴보면 디바이스의 상태를 저장하고 관리하기 위한 방법을 이해하는 데 도움이 된다.
Device Shadow는 디바이스의 현재 상태를 저장하고 동기화하는 용도다. desired, reported, delta가 모두 상태를 표현한다.
즉, Shadow는 &lt;strong&gt;명령이 아닌 상태를 저장&lt;/strong&gt;한다. 명령 큐에서는 빨강, 파랑, 초록으로 바꾸라는 세 명령을 모두 전달해야 하지만, 상태 모델에서는 &lt;strong&gt;최종 상태&lt;/strong&gt;인 초록만 전달하면 된다.
중간 상태가 유실되어도 문제가 없다.&lt;/p&gt;
&lt;p&gt;눈여겨볼 것은 delta의 의미다. Shadow의 delta는 &amp;ldquo;이번에 바뀐 것&amp;quot;이 아니라 &amp;ldquo;현재 desired와 reported 사이에 남아있는 차이&amp;quot;다. 그래서 Shadow는 명령 큐가 아니라
&lt;strong&gt;수렴 메커니즘&lt;/strong&gt;으로 동작한다. 일부 delta가 유실되더라도 다음 delta에 여전히 현재의 차이가 담기므로, 전달 실패가 곧바로 영구적인 상태 손실로 이어지지 않는다.&lt;/p&gt;
&lt;p&gt;하지만 이 추상화에도 명확한 경계가 있다. Shadow는 &lt;strong&gt;상태 동기화를 위한 추상화&lt;/strong&gt;다.
&amp;ldquo;경적을 울려라&amp;quot;를 desired 상태로 표현할 수는 있지만, 경적은 지속되는 상태가 아니라 &lt;strong&gt;한 번 발생하고 사라지는 이벤트&lt;/strong&gt;다.
이런 이벤트는 Shadow의 상태 모델과 잘 맞지 않으며, 일반적인 &lt;strong&gt;MQTT&lt;/strong&gt;(Message Queuing Telemetry Transport) &lt;strong&gt;메시지 경로&lt;/strong&gt;로 전달하는 것이 자연스럽다.&lt;/p&gt;
&lt;h2 id="도메인-모델링"&gt;도메인 모델링&lt;/h2&gt;
&lt;p&gt;모바일 앱에서 제어 명령을 받는 차량 제어 서버는 그 명령을 어떻게 모델링해야 할까.&lt;/p&gt;
&lt;p&gt;핵심은 &lt;strong&gt;Type&lt;/strong&gt; 중심의 설계다. 명령을 하나의 거대한 객체로 만들고 차이를 필드와 조건문으로 표현하지 않는다. 명령의 종류 자체를 Type으로 정의하고, Type마다 필요한 데이터와 처리 규칙을 분리한다.&lt;/p&gt;
&lt;p&gt;타입을 얼마나 세세하게 나눌 것인지는 설계에 따라 다르지만, 하나의 예를 들자면 DOOR_OPEN, DOOR_LOCK, HORN과 같이 명령을 정의할 수 있다.
이러한 명령을 표현하는 Command라는 상위 타입을 둘 수 있다. Kotlin의 경우에는 sealed interface를 활용할 수 있다.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;상태 수렴 패턴&amp;rdquo; 섹션에서 다룬 내용처럼 차량도 의도를 반영하기 위해 자체적으로 전제조건, 정책 등을 판단할 것이다.&lt;/p&gt;
&lt;p&gt;Type 중심으로 모델링하면 정책을 타입별로 관리할 수 있다. 모바일 앱에 특화된 비즈니스 요건에서 온 정책도, 차량 도메인 자체에서 온 정책도 각 Type에 붙는다.
즉, 명령의 의미와 수명(Lifetime), 재실행 가능성, 실패 후의 행동을 Type 하나로 함께 관리한다.&lt;/p&gt;
&lt;h2 id="프로토콜"&gt;프로토콜&lt;/h2&gt;
&lt;p&gt;서버는 차량으로 명령을 보내고, 차량으로부터 물리적 상태나 capability 같은 데이터를 받는다.
따라서 서버와 차량이 데이터를 주고받기 위한 프로토콜이 필요하다.&lt;/p&gt;
&lt;p&gt;프로토콜은 세 가지를 만족해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;언어와 플랫폼에 독립적인 직렬화 형식일 것&lt;/li&gt;
&lt;li&gt;필드를 추가해도 기존 데이터가 깨지지 않을 것&lt;/li&gt;
&lt;li&gt;전송 크기와 메모리 사용이 효율적일 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이런 점들을 고려했을 때 &lt;strong&gt;Protocol Buffers&lt;/strong&gt;(Protobuf)가 적절하다. Protobuf는 명시적인 스키마와 타입을 기반으로 메시지를 정의하고, 다양한 언어의 코드를 생성할 수 있으며, 바이너리 직렬화를 통해 JSON보다 작은 메시지를 만들 수 있다.
특히 제한된 네트워크 환경을 거치는 차량 통신에서는 메시지 크기를 줄이는 것 역시 중요한 장점이다.&lt;/p&gt;
&lt;p&gt;여기서 테슬라의 &lt;strong&gt;&lt;a href="https://github.com/teslamotors/vehicle-command/blob/main/pkg/protocol/protobuf/universal_message.proto"&gt;프로토콜&lt;/a&gt;&lt;/strong&gt; 사례를 살펴보자.&lt;/p&gt;
&lt;p&gt;그 전에 한 가지 짚어둘 것이 있다. Tesla Vehicle Command SDK는 서드파티를 위해 제공되는 것이고 해당 SDK를 사용하는 경우 대략적인 요청 흐름은 &lt;code&gt;Mobile &amp;gt; Vehicle Command SDK &amp;gt; Tesla Server &amp;gt; Vehicle&lt;/code&gt;와 같다.
따라서 테슬라 서버에서 차량으로 보내는 프로토콜의 형식을 정확히 알긴 어렵지만 Vehicle Command SDK에서 제공하는 프로토콜을 이해하는 것은 차량 제어 시스템을 설계할 때 좋은 참고 자료가 된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; RoutableMessage
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ├── to_destination / from_destination ← 라우팅
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ├── payload (불투명 바이트열) ← 애플리케이션 명령
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ├── signature_data ← 인증 계층
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; │ ├── signer_identity (공개키)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; │ └── AES_GCM_Personalized_Signature_Data
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; │ ├── epoch
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; │ ├── counter
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; │ ├── expires_at
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; │ └── tag
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ├── signedMessageStatus
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ├── uuid / request_uuid
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; └── flags
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;테슬라 프로토콜 사례를 통해 한 가지 원칙을 도출할 수 있다. &lt;strong&gt;&amp;ldquo;메시지 설계는 페이로드(payload)와 봉투(envelope)를 설계하는 일이며 이 둘은 서로 다른 기술을 사용할 수도 있다&amp;rdquo;&lt;/strong&gt; 이다.&lt;/p&gt;
&lt;p&gt;페이로드는 애플리케이션 명령을 의미하며 Protobuf를 직렬화해서 바이트로 담는다. 그 외 나머지 필드들이 봉투라고 할 수 있다.
봉투에는 누구에게 보내는지, 누가 보냈는지, 어떻게 검증할 것인지를 담는다.&lt;/p&gt;
&lt;p&gt;테슬라 프로토콜의 경우에는 봉투 또한 Protobuf로 설계했다. 하지만 테슬라 서버가 차량으로 전달할 때에는 다른 IDL을 사용할 수도 있다.
예를 들면 &lt;strong&gt;&lt;a href="https://en.wikipedia.org/wiki/ASN.1"&gt;ASN.1&lt;/a&gt;&lt;/strong&gt; 이다.
ASN.1은 차량과 클라우드처럼 서로 다른 구현체가 오랜 기간 동일한 메시지 구조를 안정적으로 이해하고, 이를 효율적이고 명확한 바이너리 표현으로 교환할 수 있도록 &lt;strong&gt;메시지 구조와 인코딩 규칙을 표준화&lt;/strong&gt;하기 위해 사용될 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 페이로드는 차량으로 제어 명령, 데이터 요청과 같은 애플리케이션 요청을 처리하기 위해서 Protobuf를 직렬화해서 바이트로 담고, 메시지가 위변조되지 않았는지, 누구에게 보낼 것인지와 같은 내용을 봉투(Protobuf or ASN.1)로
설계할 수 있다.&lt;/p&gt;
&lt;p&gt;이어지는 세 섹션은 &lt;a href="https://github.com/teslamotors/vehicle-command/tree/main"&gt;Tesla Vehicle Command SDK&lt;/a&gt; README의 &amp;ldquo;command&amp;rsquo;s flow diagram&amp;quot;과 함께 보면 이해하기 쉽다.&lt;/p&gt;
&lt;h2 id="봉투가-담는-세-가지-값"&gt;봉투가 담는 세 가지 값&lt;/h2&gt;
&lt;p&gt;테슬라 프로토콜의 봉투에 있는 필드 중 아래 세 가지는 따로 살펴볼 만하다.&lt;/p&gt;
&lt;p&gt;첫째, &lt;strong&gt;도메인은 라우팅 정보처럼 보이지만 실은 보안 경계&lt;/strong&gt;다. 차량에는 서로 다른 공개키를 갖는 서브시스템이 있고, 각각이 자기 키와 자기 세션을 따로 유지한다. 잠금을 담당하는 컨트롤러와 인포테인먼트가 그렇게 갈린다. 그래서 목적지가 정해지지 않은 명령은 만들어지지도 않는다. 도메인이 없으면 인증 데이터를 구성하는 단계에서 실패하고, 메시지는 네트워크에 나가기도 전에 멈춘다.&lt;/p&gt;
&lt;p&gt;둘째, &lt;strong&gt;만료 시간&lt;/strong&gt;이다. 명령마다 &amp;ldquo;이 시각이 지나면 실행하지 말라&amp;quot;는 값이 함께 실려 가고, 차량은 명령을 받자마자 그 값부터 확인한다. 늦게 도착한 명령은 인증이 아무리 멀쩡해도 그 자리에서 버려진다.&lt;/p&gt;
&lt;p&gt;여기서 눈여겨볼 것은 이 값을 정하는 방식이다. 프로토콜이 &amp;ldquo;무조건 5초&amp;quot;처럼 상수로 못박아 두지 않는다. 대신 &lt;strong&gt;명령을 보내는 쪽이 얼마나 기다릴 생각인지를 그대로 가져다 쓴다.&lt;/strong&gt; 앱이 10초까지 기다렸다가 포기하기로 했다면 명령의 만료 시각도 10초 뒤가 되고, 별도로 정하지 않았을 때만 기본값이 적용된다.
사소해 보이지만 이 결정이 막는 사고가 있다. 두 시각을 따로 정한다고 해보자. 앱은 5초 뒤에 포기하고 화면에 실패를 띄우는데, 차량은 30초까지 그 명령을 유효하다고 본다. 그러면 사용자가 &amp;ldquo;안 됐구나&amp;rdquo; 하고 돌아선 뒤에 차 문이 잠기는 25초짜리 구간이 생긴다. 두 값을 하나에서 뽑아내면 이 구간이 아예 존재할 수 없다. 사용자가 포기하는 순간이 곧 차량이 거부하기 시작하는 순간이기 때문이다.&lt;/p&gt;
&lt;p&gt;셋째, &lt;strong&gt;같은 명령이 두 번 쓰이는 것을 막는 값이 하나가 아니라 둘&lt;/strong&gt;이다. 왜 둘이나 필요한지는 하나만 있을 때 무엇이 뚫리는지 보면 알 수 있다.&lt;/p&gt;
&lt;p&gt;먼저 명령마다 &lt;strong&gt;순번&lt;/strong&gt;(counter)을 붙인다고 하자. 차량은 마지막으로 처리한 순번을 기억해 두었다가, 이미 지나간 순번이 다시 오면 거부한다. 누군가 내 잠금 해제 명령을 그대로 복사해 두었다가 나중에 다시 보내도 통하지 않는다. 여기까지는 잘 동작한다.
문제는 차량이 재부팅할 때 생긴다. 그 순번을 어디에 기억해 둘 것이냐가 관건이다. 매번 저장 장치에 쓰면 명령 하나 처리할 때마다 쓰기가 발생하고, 그렇다고 메모리에만 두면 재부팅과 함께 사라진다. 사라지면 순번이 1번부터 다시 시작하고, 공격자가 아침에 복사해 둔 명령이 저녁에 되살아난다.&lt;/p&gt;
&lt;p&gt;그래서 값이 하나 더 붙는다. 차량이 켜질 때마다 새로 만드는 &lt;strong&gt;세대 식별자&lt;/strong&gt;(epoch)다. 명령에는 &amp;ldquo;몇 번째 명령인가&amp;quot;와 &amp;ldquo;어느 세대에 속하는가&amp;quot;가 함께 실리고, 세대가 다르면 순번이 맞아도 거부된다. 재부팅하면 세대가 통째로 바뀌므로 이전 세대의 명령은 전부 무효가 된다. 저장 장치에 무언가를 쓰지 않고도 재부팅 문제를 해결한 셈이다.&lt;/p&gt;
&lt;p&gt;차량은 벽시계를 신뢰할 수 없는 환경이다. 12V 배터리가 방전되었다가 살아나거나, 장기간 오프라인이었거나, NTP 동기화가 안 된 상태일 수 있다.
&lt;strong&gt;절대 시각을 썼다면 시계가 틀린 차량은 모든 명령을 거부하거나 모든 명령을 수락하게 된다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;따라서 클라이언트는 시계 동기화 없이 만료를 구현해야 하며 핸드셰이크에서 받은 &lt;code&gt;clock_time&lt;/code&gt;으로 기준점을 역산한다. 차량이 알려준 &lt;code&gt;clock_time&lt;/code&gt;과 클라이언트의 현재 시간을 가지고 역산하여
&amp;ldquo;이 도메인의 세대는 내 시계로 14:45:50에 시작됐다&amp;quot;와 같이 &lt;code&gt;timeZero&lt;/code&gt; 값을 판단한다. 그런 다음 만료 시각을 &lt;code&gt;now + TTL&lt;/code&gt;에서 &lt;code&gt;timeZero&lt;/code&gt;를 뺀 값, 즉 세대가 시작된 뒤 몇 초가 지났는지로 환산해 &lt;code&gt;expires_at&lt;/code&gt;에 담는다.
&lt;strong&gt;도메인마다 시계가 다르므로 오프셋도 도메인마다 따로 추적해야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이러한 만료 설정 덕분에 &lt;strong&gt;오래된 의도의 지연 실행&lt;/strong&gt;을 방지할 수 있다. 또한 만료 시각은 인증 태그 계산에 포함되므로 중간자가 이 값을 연장할 수 없다.&lt;/p&gt;
&lt;h2 id="인증"&gt;인증&lt;/h2&gt;
&lt;p&gt;인증이 답해야 하는 질문은 하나다. &lt;strong&gt;이 명령을 정말 권한 있는 사람이 보냈는지를 가리는 것&lt;/strong&gt;이다. 그리고 차량 원격 제어에서는 여기에 조건이 하나 붙는다. 그 판단을 차량이 스스로 할 수 있어야 한다.
왜 스스로여야 하는지는 TLS만 썼을 때 무슨 일이 생기는지 보면 알 수 있다. TLS는 구간마다 새로 맺어지는 보호다. 앱에서 서버까지 한 구간이 안전하게 보호되고, 서버에 도착하는 순간 그 보호는 끝난다. 그 뒤로 그 메시지는 그냥 &amp;ldquo;우리 서버가 만든 메시지&amp;quot;가 된다. 백엔드가 침해당하면 공격자가 만들어 낸 잠금 해제 명령도 차량 입장에서는 똑같이 &amp;ldquo;우리 서버가 만든 메시지&amp;quot;로 보인다.
그래서 &lt;strong&gt;명령의 출처를 구간이 아니라 명령 자체에 붙여야 한다. 이것이 종단 간 인증&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;방식은 이렇다. 명령을 만드는 쪽과 차량이 &lt;strong&gt;각자 자기만 아는 비밀 숫자&lt;/strong&gt;를 하나씩 갖는다. 그리고 상대의 공개된 숫자(Public Key)와 자기 비밀 숫자를 조합하면, 양쪽이 똑같은 값을
각자 계산해 낼 수 있다. 이 값을 서로 주고받지 않았는데도 둘이 같다는 것이 이 방식의 핵심이다.
&lt;strong&gt;오가는 것은 공개해도 되는 값뿐이고, 실제로 쓰는 비밀은 각자 손에서 나가지 않는다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;중요한 것은 &lt;strong&gt;이 값을 계산할 수 있는 주체가 딱 둘뿐&lt;/strong&gt;이라는 점이다. 테슬라 클라우드도 계산할 수 없고, 다른 차량도 계산할 수 없다. 심지어 같은 차량의 다른 서브시스템도 계산할 수 없다. 잠금을 담당하는 컨트롤러와 인포테인먼트가 각자 다른 비밀 숫자를 갖고 있기 때문이다.&lt;/p&gt;
&lt;p&gt;그래서 클라우드가 통째로 털려도 공격자가 얻는 것은 명령을 지연시키거나 버릴 수 있는 능력뿐이다. 새 명령을 만들어 내는 능력은 얻지 못한다.&lt;/p&gt;
&lt;p&gt;Tesla Vehicle Command SDK는 사전 등록된 공개 키(pre-enrolled public key)와 ECDH(Elliptic Curve Diffie-Hellman) 기반 Shared Secret으로 명령의 인증과 무결성을 보장한다.&lt;/p&gt;
&lt;p&gt;키를 갖는다고 아무 명령이나 보낼 수 있는 것은 아니다. 차량은 등록된 각 키에 역할을 붙여 두고, 역할마다 인가할 수 있는 명령을 다르게 정한다. Owner는 다른 사람의 키를 관리하는 것까지 포함해 사실상 전권을 갖고, Driver는 대부분의 명령을 보낼 수 있지만 타인의 키를 관리하거나 PIN을 바꿀 수는 없다. 렌탈처럼 기간이 정해진 접근을 위한 역할도 있고, 데이터만 읽을 수 있는 역할이나 충전 관련 명령만 가능한 역할도 따로 있다.&lt;/p&gt;
&lt;p&gt;여기서 클라우드 기반 서비스의 위치가 분명해진다. 서드파티 서버를 운영한다면 &lt;strong&gt;그 서버가 자기 비밀 숫자를 갖고 차량에 하나의 키로 등록&lt;/strong&gt;된다. 즉 그 서버가 프로토콜 입장에서는 &amp;ldquo;클라이언트&amp;quot;다. 테슬라 클라우드는 그 사이를 지나가는 통로일 뿐 명령을 만드는 키를 갖지 않는다.&lt;/p&gt;
&lt;p&gt;실제로 테슬라 차량과 Vehicle Command SDK를 활용하여 차량에 공개키를 등록하기 위해서는 &lt;strong&gt;가상키 프로비저닝(VirtualKey Provisioning)&lt;/strong&gt; 이라는 과정을 거쳐야 한다.
서드파티 서버에서 비대칭키를 생성한 다음, 공개키를 S3, CloudFront로 호스팅하여 Tesla Developers에 엔드포인트를 등록해야 한다.
그리고 실제 테슬라 차량에 가서 카드키를 올려놓고 QR을 활용해 공개키를 차량에 설정할 수 있다.&lt;/p&gt;
&lt;p&gt;즉, 신뢰의 시작점은 암호학이 아니라 물리적 존재 증명이다. 원격만으로는 새 키를 등록할 수 없다. 원격 공격자에게 이것이 궁극적인 차단선이 된다.&lt;/p&gt;
&lt;h2 id="인증-태그의-계산-재료"&gt;인증 태그의 계산 재료&lt;/h2&gt;
&lt;p&gt;인증 태그는 비밀 값과 메시지를 함께 넣어 계산한 짧은 값이다. 같은 비밀 값과 같은 메시지를 넣으면 항상 같은 태그가 나오고, 메시지가 한 글자만 달라져도 태그는 완전히 달라진다. 비밀 값을 모르면 올바른 태그를 만들 수 없다.&lt;/p&gt;
&lt;p&gt;그런데 여기서 실질적인 보안 강도를 결정하는 것은 알고리즘이 아니라 &lt;strong&gt;&amp;ldquo;메시지&amp;quot;에 무엇을 넣느냐&lt;/strong&gt;다.
더 강한 해시 함수를 쓴다고 뚫리던 공격이 막히지는 않는다.&lt;/p&gt;
&lt;h3 id="명령-내용만-서명할-때의-구멍"&gt;명령 내용만 서명할 때의 구멍&lt;/h3&gt;
&lt;p&gt;가장 간단한 구현을 살펴보자. 비밀 값과 &amp;ldquo;LOCK&amp;quot;이라는 문자열만으로 태그를 만든다고 하자. 알고리즘은 최신이고 키도 안전하다. 그런데 이 태그가 붙은 메시지는 다음 다섯 가지 방식으로 악용될 수 있다.&lt;/p&gt;
&lt;p&gt;옆집 차로 그대로 주입할 수 있고, 엉뚱한 서브시스템으로 목적지를 바꿔 보낼 수 있고, 그대로 복사해 즉시 다시 보낼 수 있고, 세 시간 뒤에 다시 보낼 수도 있으며, 응답을 암호화하라는 요구를 슬쩍 꺼버릴 수도 있다. 다섯 가지 모두 명령 내용 자체는 진짜다. 공격자가 바꾼 것은 그 명령이 누구에게 가는 것이었는지, 언제까지 유효한 것이었는지, 몇 번째 명령이었는지 같은 주변 정보뿐이다. 편지의 본문은 그대로 두고 수신인과 날짜만 고쳐 쓴 셈이다.&lt;/p&gt;
&lt;p&gt;그래서 테슬라는 태그를 계산할 때 명령 내용만 넣지 않는다. &lt;strong&gt;인증 방식, 목적지 서브시스템, 차량 식별자, 세대 식별자, 만료 시각, 순번, 옵션 플래그&lt;/strong&gt;를 모두 함께 넣고 마지막에 명령 내용을 이어 붙인다. 이 중 하나라도 바뀌면 태그가 깨진다.&lt;/p&gt;
&lt;p&gt;항목이 일곱이나 되는 데에는 이유가 있다. 각 항목이 서로 다른 구멍을 막고, 어느 하나도 다른 것으로 대체되지 않는다.&lt;/p&gt;
&lt;h3 id="세대-식별자--재부팅이라는-구멍"&gt;세대 식별자 — 재부팅이라는 구멍&lt;/h3&gt;
&lt;p&gt;재생 공격을 막으려면 차량이 &amp;ldquo;이 메시지를 전에 봤는가&amp;quot;를 기억해야 한다. 그런데 차량은 재부팅한다. 재부팅하면 메모리에 있던 기억이 사라지고, 공격자가 재부팅 직전에 복사해 둔 메시지를 재부팅 직후에 흘려 넣으면 그대로 통과한다.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;그럼 저장 장치에 기록하면 되지 않나&amp;quot;라는 생각이 자연스럽게 든다. 프로토콜 문서는 이 선택지를 검토한 흔적을 남기지 않았으므로 아래는 임베디드 환경의 일반적인 제약에서 끌어낸 추정이지만, 명령마다 저장 장치에 쓰는 설계는 여러 방향에서 대가를 치를 수 있다. 저장 장치는 쓰기 횟수에 수명이 있어 마모가 누적될 수 있고, 쓰기 지연이 명령 처리 경로에 얹힐 수 있으며, 쓰는 도중에 전원이 끊기면 저장된 값과 실제 상태가 어긋날 수 있다.&lt;/p&gt;
&lt;p&gt;테슬라가 택한 것은 저장하지 않는 쪽이다. &lt;strong&gt;부팅할 때마다 16바이트 난수를 새로 만들고, 그것을 이번 세대의 이름으로 삼는다.&lt;/strong&gt; 명령에는 &amp;ldquo;어느 세대에 속하는가&amp;quot;가 함께 실리고, 세대가 다르면 다른 값이 다 맞아도 거부된다. 재부팅하면 세대가 통째로 바뀌므로 이전 세대의 메시지는 전부 무효가 된다. 결과만 놓고 보면 저장 비용을 재동기화 비용으로 바꾼 구조로 읽을 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;세대 식별자로 난수를 쓴 이유&lt;/strong&gt;도 짚어볼 만하다. 문서에 설명은 없지만 반대 경우를 상상하면 짐작할 수 있다. 세대 식별자가 단순히 증가하는 부팅 카운터였다면 다음 세대를 예측할 수 있다. 그러면 미래 세대에서 유효할 명령을 미리 만들어 보관하는 것이 가능해진다. 렌터카를 반납한 뒤에도 유효한 잠금 해제 명령 같은 것이 그렇다. 여기서 위험해지는 것이 공격자가 아니라 한때 정당했던 주체라는 점이 흥미롭다. 난수라면 이 가능성 자체가 사라진다.&lt;/p&gt;
&lt;h3 id="순번--중복은-막지만-순서는-아니다"&gt;순번 — 중복은 막지만 순서는 아니다&lt;/h3&gt;
&lt;p&gt;여기서 가장 많이 오해가 생긴다. &lt;strong&gt;순번은 순서를 보장하지 않는다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;먼저 순번이 무엇을 막는지 보자. 유효한 잠금 명령을 그대로 복사해 백 번 다시 보낸다고 하자. 메시지를 전혀 수정하지 않았으므로 태그는 매번 유효하다. 암호학만으로는 이것을 막을 수 없다. 막는 것은 &amp;ldquo;이 순번을 전에 썼는가&amp;quot;를 기억하는 상태다.&lt;/p&gt;
&lt;p&gt;그런데 순서가 조금이라도 어긋나면 무조건 버릴 수는 없다. 네트워크에서 순서가 뒤바뀌는 일은 흔하기 때문이다.
그래서 차량은 마지막 순번 하나만 기억하는 대신 최근 몇십 개의 순번을 놓고 각각을 이미 썼는지 여부만 기록해 둔다. 이 구간이 통째로 앞으로 밀려나면서 오래된 기록은 잊힌다.&lt;/p&gt;
&lt;p&gt;핵심은 이 장치가 답하는 질문이 &lt;strong&gt;&amp;ldquo;이 순번을 전에 썼는가&amp;rdquo;&lt;/strong&gt; 하나뿐이라는 점이다. 순서를 바로잡아 주지 않고, 빠진 순번을 기다려 주지도 않는다. 그래서 102번, 100번, 101번 순서로 도착하면 셋 다 도착한 순서 그대로 실행되고, 차량의 최종 상태가 사용자가 마지막에 누른 것과 달라질 수 있다.&lt;/p&gt;
&lt;p&gt;순서까지 보장하려면 앞선 순번이 도착할 때까지 뒤에 온 명령을 붙들고 기다려야 하는데, 그렇게 하면 앞 명령이 영영 오지 않을 때 뒤 명령까지 함께 만료된다. 5초 안에 만료되는 명령을 다루면서 그런 대기를 걸 수는 없다. &lt;strong&gt;순서 정합성이 정말 필요하다면 그것은 순번이 아니라 앞에서 본 상태 수렴으로 풀어야 할 문제&lt;/strong&gt;다.&lt;/p&gt;
&lt;p&gt;그리고 도메인마다 정책이 다르다. 프로토콜 문서는 인포테인먼트가 순서가 어긋난 도착을 허용하는 반면, 잠금을 담당하는 컨트롤러는 순번 순서대로 도착할 것을 요구한다고 적고 있다. 하나의 프로토콜 안에서 위험도에 비례해 서로 다른 정책이 적용되는 것이다.&lt;/p&gt;
&lt;p&gt;순번에 구멍이 나는 것은 정상이다. 클라이언트는 순번을 먼저 소비하고 그다음에 전송하므로, 전송이 실패한 순번은 영영 쓰이지 않는다. 차량이 요구하는 것은 값이 계속 커지는 것뿐이지 빠짐없이 이어지는 것이 아니다. TCP 시퀀스 번호에 익숙한 사람이 가장 자주 넘어지는 지점이 정확히 여기다. 참고로 클라이언트는 순번이 최댓값에 도달하면 0으로 되돌리지 않고 명령 생성 자체를 거부한다. 되돌리는 순간 재생 방어가 통째로 무너지기 때문이다.&lt;/p&gt;
&lt;h3 id="만료-시각--공격자가-없어도-필요한-유일한-항목"&gt;만료 시각 — 공격자가 없어도 필요한 유일한 항목&lt;/h3&gt;
&lt;p&gt;만료 시각이 없을 때 벌어지는 일은 이렇다. 사용자가 지하 3층에서 잠금 해제를 누른다. 명령이 어딘가에 큐잉된다. 30분 뒤 차량이 지상으로 올라와 온라인이 되고, 그 순간 명령이 전달되어 문이 혼자 열린다.&lt;/p&gt;
&lt;p&gt;악의를 가진 사람이 아무도 없는데 사고가 난다. 다른 항목들은 전부 공격자를 전제로 하지만, 이 항목만은 공격자가 없어도 필요하다.&lt;/p&gt;
&lt;p&gt;물론 공격 버전도 있다. 명령을 가로채되 차량에 전달하지 않고 붙잡고 있다가 원하는 순간에 발사하는 것이다. 사용자가 차에서 멀어질 때까지 기다렸다가 잠금 해제를 흘려 넣으면 된다. 순번도 세대 식별자도 이것을 막지 못한다. 메시지는 한 번도 재사용되지 않았고 세대도 그대로이기 때문이다. 오직 만료 시각만이 이 공격을 무력화한다.&lt;/p&gt;
&lt;p&gt;이 항목의 가장 우아한 부분은 &lt;strong&gt;절대 시각을 전혀 쓰지 않는다는 점&lt;/strong&gt;이다. 차량은 &amp;ldquo;내 현재 세대가 시작된 뒤 몇 초가 지났다&amp;quot;만 말하고, 클라이언트는 자기 시계로 그 세대의 시작 시점을 역산한 뒤 만료를 상대 시간으로 표현한다. 그래서 차량의 벽시계가 틀려도 만료가 정상 동작한다. 시계 동기화 없이 유통기한을 구현한 셈이다.&lt;/p&gt;
&lt;h3 id="옵션-플래그--한-줄의-조건문에-두-요구가-들어-있다"&gt;옵션 플래그 — 한 줄의 조건문에 두 요구가 들어 있다&lt;/h3&gt;
&lt;p&gt;플래그에는 &amp;ldquo;응답을 암호화해서 보내라&amp;quot;는 비트가 있다. 이 비트를 인증 대상에서 빼면 곧바로 구멍이 생긴다. 중간자가 비트를 꺼버리면 차량의 응답이 평문으로 돌아오고, 차량 위치나 도어 잠금 상태가 그대로 노출된다.&lt;/p&gt;
&lt;p&gt;그런데 더 위험한 것은 노출이 아니라 응답 위조다. 응답이 인증되지 않으면 &amp;ldquo;권한 없음&amp;quot;을 &amp;ldquo;성공&amp;quot;으로 바꿔치기할 수 있다. 사용자는 화면에서 잠금 성공을 확인하고 안심하고 자리를 뜬다. 실제로 문은 열려 있다.&lt;/p&gt;
&lt;p&gt;테슬라는 플래그 값이 0보다 클 때만 이 항목을 태그 계산에 포함한다. 언뜻 어중간한 타협처럼 보이지만, 이 한 줄의 조건에 두 개의 요구가 동시에 들어 있다. 플래그가 0이면 넣지 않으므로 &lt;strong&gt;플래그 개념이 없던 구형 차량과 같은 바이트열&lt;/strong&gt;이 되어 하위호환이 지켜진다. 플래그가 켜져 있으면 넣으므로 &lt;strong&gt;중간자가 그것을 끄는 순간 해시가 달라져&lt;/strong&gt; 거부된다.&lt;/p&gt;
&lt;h3 id="나머지-셋과-이어-붙이는-방식"&gt;나머지 셋과 이어 붙이는 방식&lt;/h3&gt;
&lt;p&gt;나머지 셋 중 인증 방식은 조금 설명이 필요하다. 이 시스템에서 클라이언트와 차량은 하나의 비밀 값을 공유하는데, 그 값으로 만드는 태그가 한 종류가 아니다. 명령에 붙이는 태그, 세션을 여는 응답에 붙이는 태그, 차량이 결과를 돌려줄 때 붙이는 태그, 이렇게 셋이다.
문제는 같은 비밀 값으로 만들다 보니 한 종류의 태그가 다른 종류로도 유효해질 여지가 생긴다는 점이다. 예를 들어 세션을 여는 응답에 붙었던 태그를 공격자가 떼어다 명령에 붙였는데 차량이 그것을 유효하다고 받아들이면 곤란하다.&lt;/p&gt;
&lt;p&gt;그래서 태그를 계산할 때 &amp;ldquo;&lt;strong&gt;이건 몇 번 용도의 태그인가&lt;/strong&gt;&amp;ldquo;를 맨 앞에 함께 넣는다. 명령용은 명령용 번호를, 응답용은 응답용 번호를 넣는다. 그러면 같은 비밀 값에서 나왔어도 용도가 다르면 계산 결과가 달라지므로, 태그를 다른 자리에 옮겨 붙일 수 없게 된다. 열쇠는 같아도 문마다 다른 홈을 파 두는 것과 비슷하다.&lt;/p&gt;
&lt;p&gt;차량 식별자를 넣는 것은 이 명령이 이 차량만을 위한 것임을 못박기 위함이다. 물론 차량마다 비밀 값이 다르므로 옆집 차는 애초에 태그를 검증하지 못하지만, 키 관리가 어딘가에서 실패했을 때의 두 번째 방어선이 된다. 목적지 서브시스템을 넣는 것은 잠금 명령이 인포테인먼트로 리라우팅되는 것을 막기 위함이고, 이 값이 없으면 메시지가 만들어지지도 않는다.&lt;/p&gt;
&lt;p&gt;그런데 이 일곱을 한자리에 놓고 보면 진짜 결론은 따로 있다. &lt;strong&gt;이 모든 항목을 하나의 바이트열로 이어 붙이는 방식 자체가 모호하지 않아야 한다.&lt;/strong&gt; 서로 다른 두 개의 항목 조합이 절대로 같은 바이트열이 되어서는 안 된다는 뜻이며, 테슬라도 이 요구를 명시적으로 적어 두었다.&lt;/p&gt;
&lt;p&gt;길이 정보가 없다고 하자. 그러면 어떤 값의 끝과 다음 항목의 시작이 뒤섞여서, 서로 다른 두 의미가 같은 바이트열로 인코딩될 수 있다. 그 순간 하나의 유효한 태그가 두 가지 뜻을 갖게 된다. 항목 번호와 길이와 값을 한 묶음으로 만들어 번호 오름차순으로 이어 붙이고 마지막에 종료 표시를 두는 구조는 이 모호성을 없애기 위한 것이다. JSON을 그냥 직렬화해서 서명하는 흔한 패턴이 위험한 이유가 정확히 이것이다. 키 순서와 공백과 인코딩이 조금만 달라져도 같은 의미가 다른 바이트가 되고, 반대로 다른 의미가 같은 바이트가 될 여지가 생긴다.&lt;/p&gt;
&lt;p&gt;응답 방향에는 항목이 둘 더 붙는다. 각각이 응답 위조의 서로 다른 수법을 막는다.&lt;/p&gt;
&lt;p&gt;하나는 &lt;strong&gt;요청의 태그를 응답의 계산 재료로 넣는 것&lt;/strong&gt;이다. 명령마다 태그 값이 다르므로, 이렇게 하면 그 응답은 오직 그 명령에 대해서만 유효해진다. 공격자가 어제 받아 둔 잠금 성공 응답을 오늘 보낸 명령의 답으로 재활용할 수 없다.&lt;/p&gt;
&lt;p&gt;다른 하나는 오류 코드까지 계산 재료에 넣는 것이다. 오류 코드는 암호화되지 않고 그대로 보이는 자리에 있어서, 이것이 없으면 공격자가 &amp;ldquo;권한 없음&amp;quot;을 &amp;ldquo;성공&amp;quot;으로 고쳐 쓰기만 하면 된다. 계산 재료에 넣어 두면 그 값을 건드리는 순간 응답 전체가 검증에 실패한다.&lt;/p&gt;
&lt;h2 id="커넥션과-세션"&gt;커넥션과 세션&lt;/h2&gt;
&lt;p&gt;차량 제어 시스템을 설계할 때 가장 중요한 부분 중 하나는 커넥션과 세션 관리다.&lt;/p&gt;
&lt;p&gt;먼저 두 단어를 구분해 두자. 커넥션은 지금 이 순간 소켓이 붙어 있다는 물리적 사실이고, 세션은 그 소켓이 몇 번 끊겼다 붙어도 이어지는 논리적 연속성이다. 통화에 비유하면 커넥션은 전화가 이어져 있다는 사실이고, 세션은 우리가 어디까지 이야기했는지에 해당한다. 전화가 끊겨도 대화의 맥락은 남지만, 시스템에서는 이 둘이 자주 한 덩어리로 묶인다. 묶이면 재연결이 &amp;ldquo;처음부터 다시&amp;quot;가 되고, 분리해 두면 &amp;ldquo;하던 것을 이어서&amp;quot;가 된다.
이 구분은 설계 요구사항 수준에서 못박아 두는 편이 좋다. 커넥션의 수명주기를 접속, 인증, 활성, 종료 진행, 종료처럼 단계로 명시해 두면 어느 단계에서 끊겼는지에 따라 세션을 살릴지 버릴지를 정할 수 있기 때문이다.&lt;/p&gt;
&lt;p&gt;테슬라처럼 서드파티에게 SDK를 제공하는 경우와 그렇지 않은 경우를 살펴보자.&lt;/p&gt;
&lt;h2 id="서드파티에게-sdk를-제공하는-경우"&gt;서드파티에게 SDK를 제공하는 경우&lt;/h2&gt;
&lt;p&gt;Tesla Vehicle Command SDK의 경우 세션 상태에 들어 있는 것은 상대 차량의 식별자, 대상 도메인, 공유 키, 세대 식별자, 순번, 시계 오프셋이 전부다. 소켓도 IP 주소도 BLE 핸들도 들어 있지 않다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;여기서 결정적인 결과가 나온다. 세션을 파일로 저장할 수 있다.&lt;/strong&gt; 소켓을 저장할 방법은 없지만 숫자와 키는 저장할 수 있기 때문이다. 프로토콜 문서는 계속 실행되지 않는 클라이언트에게 세션 상태를 디스크에 캐시하라고 권고하면서, 로컬 시계와 차량 시계의 차이도 함께 저장하라고 못박는다. 앞 절에서 본 만료 시각이 차량의 자체 시계로 표현되기 때문이다. 그 차이를 잃어버리면 저장해 둔 숫자를 해석할 기준점이 사라진다. SDK의 캐시 항목이 세션 정보와 함께 생성 시각을 갖는 이유가 이것이다.&lt;/p&gt;
&lt;p&gt;저장한 세션이 아직 유효한지는 저장한 쪽에서 알 수 없다. 그 사이 차량이 재부팅해서 세대가 바뀌었을 수도 있기 때문이다. 그런데 SDK는 캐시를 불러올 때 유효성을 확인하지 않고 곧바로 사용 가능한 상태로 표시한다. 무모해 보이지만 비용을 따져 보면 그렇지 않다. 캐시가 맞았다면 명령 한 번 왕복으로 끝나고, 틀렸다면 거절 응답에 최신 세션 정보가 실려 오므로 그것으로 갱신해 한 번 더 보내면 된다. 왕복 두 번이다. 그런데 캐시를 아예 쓰지 않으면 핸드셰이크 한 번에 명령 한 번, 이것도 왕복 두 번이다. 틀렸을 때의 비용이 애초에 시도하지 않았을 때의 비용과 같다.&lt;/p&gt;
&lt;p&gt;여기에는 일반화할 수 있는 원칙이 있다. &lt;strong&gt;일단 먼저 시도해 보고, 실패하면 처음부터 다시 하는 것과 같은 비용으로 복구할 수 있다면 먼저 시도하는 편이 항상 유리&lt;/strong&gt;하다.
이를 가능하게 하는 조건은 단순하다. 거절할 때 무엇이 잘못됐는지뿐 아니라, &lt;strong&gt;다시 시도하는 데 필요한 최신 상태까지 함께 알려줘야 한다.&lt;/strong&gt; 단순히 &amp;ldquo;안 된다&amp;quot;고만 응답하면 클라이언트는 처음부터 상태를 다시 확인해야 한다. 그러면 먼저 시도한 이점은 사라지고, 오히려 요청 한 번만 더 늘어난다.&lt;/p&gt;
&lt;p&gt;세션이 연결에 묶여 있지 않다는 사실은 이동 중인 차량에서 곧바로 제값을 한다. LTE에서 Wi-Fi로 전환되면 IP 주소가 바뀌고 기존 연결이 끊어지고 새 연결이 만들어지지만, 세대 식별자도 순번도 키도 시계 오프셋도 그대로다. 재핸드셰이크가 한 번도 필요하지 않다. 차량은 하루에도 수십 번 네트워크가 바뀌므로, 전환마다 재협상하는 설계는 SDK 입장에서는 애초에 성립하지 않는다.
신원을 연결 경계에 묶는 방식은 그 연결의 양 끝을 직접 소유할 때에나 통하는데, 여기서는 그 양 끝을 소유하지 않는다.&lt;/p&gt;
&lt;h2 id="커넥션과-세션을-다루는-서버"&gt;커넥션과 세션을 다루는 서버&lt;/h2&gt;
&lt;p&gt;여기서는 서드파티에 SDK를 제공하지 않는 환경을 전제로 한다. 즉, SDK 뒤에 감춰진 서버가 차량과의 상시 연결을 유지하고, 클라우드의 차량 제어 요청을 차량까지 전달하는 플랫폼을 기준으로 설명한다. 차량과 테슬라 서버 사이에 상시 연결이 어떻게 유지되는지는 공개된 자료만으로는 알 수 없고, 공개된 것은 인터넷 경로와 BLE(Bluetooth Low Energy) 두 통로뿐이다. 아래는 &lt;strong&gt;차량과 상시 연결을 유지하는 플랫폼을 직접 만들 때 반드시 마주치게 되는 문제&lt;/strong&gt;다.&lt;/p&gt;
&lt;p&gt;상시 연결을 직접 소유하는 경우 차량은 이동통신망의 사설 주소 뒤에 있어서 서버가 먼저 접속할 수 없고, 텔레메트리는 차량에서 서버 방향으로 상시 흐르며, 명령의 왕복 지연은 초 단위로 관리해야 한다. &lt;strong&gt;차량이 먼저 접속해서 연결을 유지하는 구조 말고는 사실상 답이 없다.&lt;/strong&gt;&lt;/p&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;WebSocket&lt;/th&gt;
&lt;th&gt;gRPC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;성격&lt;/td&gt;
&lt;td&gt;양방향 프레임 기반 연결&lt;/td&gt;
&lt;td&gt;HTTP/2 위에서 동작하는 RPC 프레임워크&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메시지 계약&lt;/td&gt;
&lt;td&gt;의미와 계약을 직접 정의해야 함&lt;/td&gt;
&lt;td&gt;Protobuf 기반의 강한 계약 + 코드 생성 기본 제공&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;HTTP 기반 인프라와 호환성이 높음&lt;/td&gt;
&lt;td&gt;장시간 HTTP/2 스트리밍 연결이 프록시나 로드밸런서의 timeout이나 정책으로 종료될 수 있음&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;p&gt;그래서 실무에서는 &lt;strong&gt;둘을 동시에 지원하는 선택&lt;/strong&gt;이 흔하다. 차량 세대마다 통신 모듈과 런타임이 다르고, 나라마다 망의 성향이 다르기 때문이다.&lt;/p&gt;
&lt;p&gt;추가로 MQTT를 사용하는 것을 고려할 수 있다. MQTT는 클라이언트와 서버가 직접 메시지를 주고받기보다 브로커를 중심으로 Pub/Sub 방식으로 통신하며, 연결 상태와 QoS(Quality of Service), 세션 관리 등을 프로토콜 차원에서 제공한다. 특히 네트워크가 불안정하거나 대역폭이 제한적인 차량 환경에서 지속적인 연결을 유지하면서 작은 메시지를 주고받는 데 적합하다. 대신 브로커가 통신 경로에 포함되기 때문에 WebSocket이나 gRPC와는 다른 인프라와 장애 모델을 고려해야 한다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 설계 원칙은 &lt;strong&gt;전송 방식이 바뀌어도 메시지 프로토콜은 변하지 않아야 한다는 것&lt;/strong&gt;이다. WebSocket을 사용하든 gRPC를 사용하든, 차량이 이해하는 명령과 메시지 구조는 동일해야 한다.
전송 계층은 메시지를 운반할 뿐, 메시지가 무엇을 의미하는지 결정해서는 안 된다. 그래야 네트워크 환경이나 인프라가 바뀌어도 차량 제어 프로토콜을 그대로 유지할 수 있다. 결국 무엇을 보낼 것인가와 어떻게 운반할 것인가는 서로 다른 설계 결정이어야 한다.&lt;/p&gt;
&lt;h3 id="heartbeat"&gt;Heartbeat&lt;/h3&gt;
&lt;p&gt;연결이 끊어졌다는 것을 어떻게 알아챌 수 있을까? 연결의 단절에는 두 가지 형태가 있고 둘은 정반대로 동작한다.&lt;/p&gt;
&lt;p&gt;먼저 프로세스를 강제 종료하는 경우다. SIGKILL은 프로세스가 직접 처리할 수 없으므로 커널이 소켓을 정리하고 연결 종료를 상대방에 알린다. 상대는 명시적인 연결 종료를 관찰할 수 있기 때문에 비교적 빠르게 연결이 끊어졌다는 사실을 알아차리고 정리할 수 있다. 이 상황에서는 하트비트가 아무 역할도 하지 않는다.&lt;/p&gt;
&lt;p&gt;반면 NAT(Network Address Translation) 리바인드, 전원 차단, 끊어진 링크는 상대방에게 아무런 종료 신호를 보내지 않을 수 있다. 상대는 사라졌지만 소켓은 여전히 연결된 것처럼 남아 있고, 결국 일정 시간 동안 아무런 응답이 없다는 사실을 통해서만 연결의 단절을 추론할 수 있다. 이때 필요한 것이 데드라인과 하트비트다.&lt;/p&gt;
&lt;p&gt;차량이 터널이나 지하주차장으로 들어가는 상황도 정확히 이쪽에 해당한다. 차량의 연결이 끊어졌다는 사실을 클라우드가 즉시 전달받는 것이 아니라, 일정 시간 동안 아무런 메시지가 도착하지 않는다는 사실을 통해 연결이 끊겼음을 추론해야 한다.&lt;/p&gt;
&lt;p&gt;그래서 &lt;strong&gt;하트비트(Heartbeat)&lt;/strong&gt; 가 필요하다. 주기적으로 Ping을 보내고, 읽기 데드라인을 Ping 주기와 Pong 대기 시간의 합으로 잡는다. Pong이 도착하면 데드라인을 미래로 밀고, 오지 않으면 데드라인이 만료되면서 읽기가 실패한다. 몇 번 놓쳤는지 따로 세는 카운터가 필요 없다는 것이 이 방식의 장점이다. 다만 규율이 하나 붙는다. 데이터 수신과 Pong 수신 양쪽 모두가 데드라인을 리셋해야 한다. 그러지 않으면 데이터는 활발히 주고받으면서 Pong만 늦는 멀쩡한 연결이 끊긴다.
읽기 데드라인은 &amp;ldquo;상대의 프로세스가 살아 있다&amp;quot;와 &amp;ldquo;상대가 내 메시지를 받을 수 있다&amp;quot;를 구분하지 못한다.&lt;/p&gt;
&lt;h3 id="backpressure"&gt;Backpressure&lt;/h3&gt;
&lt;p&gt;차량처럼 장시간 연결을 유지하면서 대부분의 시간 동안 유휴 상태인 연결이 많다면, 연결마다 유지해야 하는 소켓 버퍼와 TLS 상태, 애플리케이션 상태 때문에 메모리가 중요한 병목이 된다. 반면 연결이 활발하게 통신한다면 TLS 처리와 메시지 처리에 필요한 CPU, 네트워크 대역폭도 함께 병목이 될 수 있다.&lt;/p&gt;
&lt;p&gt;그리고 그 서버 안에서는 레지스트리에 압력이 걸린다. 동시 연결이 수만 개에 이르고 등록과 해제가 빈발하면 레지스트리를 지키는 단일 락 자체가 병목이 되는데, 흔한 해법은 이 자료구조를 여러 조각으로 쪼개서 락을 나누는 것이다. 다만 여기서 반드시 구분해야 할 것이 있다. 락을 쪼개는 일은 &lt;strong&gt;한 대의 서버 안에서 스레드끼리 덜 부딪히게 만드는 작업&lt;/strong&gt;이지, 서버를 늘려서 부하를 나누는 작업이 아니다. 이 둘을 섞으면 &amp;ldquo;쪼갰으니 확장된다&amp;quot;고 착각하게 되는데, 락 경합이 사라져도 방금 말한 연결 수의 상한은 그대로다. 그래서 연결 수가 크지 않은 동안에는 단일 락으로 버티는 편이 낫다. 아직 병목이 되지도 않은 곳을 미리 쪼개면 검증되지 않은 복잡도만 남는다.&lt;/p&gt;
&lt;p&gt;연결 수가 정말 커졌을 때의 답은 자료구조를 더 잘게 쪼개는 쪽이 아니라 한 서버가 책임지는 차량의 범위를 좁히는 쪽이고, 그러면 곧바로 다음 문제가 따라온다. &lt;strong&gt;특정 차량이 지금 어느 서버에 붙어 있는지를 찾아낼 방법이 필요&lt;/strong&gt;해진다. 공유 저장소에 매핑을 두거나, 서버끼리 메시지를 전달하거나, 차량 식별자로 담당 서버를 결정론적으로 정하는 세 가지 방법이 있는데 어느 것을 골라도 대가가 있다. 앞의 것에는 조회 비용과 정합성이, 가운데 것에는 홉의 증가와 장애 전파 경로가, 마지막 것에는 서버 수가 바뀔 때의 대량 재접속이 따라온다.&lt;/p&gt;
&lt;p&gt;어느 쪽을 고르든 한 서버는 여전히 수많은 연결을 동시에 붙들고 있어야 한다. 그래서 한 층 아래, 연결마다의 큐를 어떻게 다룰 것인가가 다음 문제가 된다.&lt;/p&gt;
&lt;p&gt;큐를 이야기하려면 배압에서 시작해야 한다. &lt;strong&gt;배압(Backpressure)&lt;/strong&gt; 은 단순히 처리량을 끌어올리기 위한 최적화가 아니다. 생산 속도가 소비 능력을 넘어서는 순간 시스템이 어디까지 받아들이고, 언제 거부하거나 연결을 종료할 것인지를 결정하는 흐름 제어의 규칙에 가깝다. 그리고 그 기준으로 쓸 수 있는 것은 &lt;strong&gt;시간과 공간&lt;/strong&gt; 둘뿐이다. 정해진 시간 안에 보내지 못하면 끊고, 쌓인 양이 정해진 한계를 넘으면 끊는다. 배칭이나 레이트 리미팅은 부하를 고르게 펴 주기는 하지만 느린 소비자를 끊어내지는 못하므로 배압이 아니다. 이 구분을 흐리면 최적화를 잔뜩 갖춰 놓고도 느린 연결 하나에 전체가 무너진다.&lt;/p&gt;
&lt;p&gt;큐는 반드시 연결마다 독립이어야 한다. 전역 큐를 공유하면 느린 연결 하나가 전체 처리량을 끌어내린다. 그런데 여기서 더 단순해 보이는 선택지가 하나 있다. 큐를 아예 두지 않고 메시지를 받은 자리에서 곧바로 소켓에 쓰는 것이다. 버퍼가 없으니 쌓일 것도 없다는 논리인데, 이 논리는 소켓에 쓴다는 행위를 오해한 데서 나온다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;소켓 쓰기가 성공했다는 것은 상대가 받았다는 뜻이 아니라 커널의 송신 버퍼에 복사되었다는 뜻&lt;/strong&gt;이다. 그리고 그 버퍼는 유한하다. 상대가 읽기를 멈추면 상대의 수신 윈도우가 0이 되고, 그 여파로 이쪽 송신 버퍼가 차고, 그다음의 쓰기는 그 자리에서 멈춰 선다. 여기까지는 고장이 아니라 TCP의 정상 동작이다.&lt;/p&gt;
&lt;p&gt;문제는 멈춘 다음이다. 쓰기를 기다리는 호출은 저마다 보내려던 메시지를 붙들고 있고, 그 호출을 수행하던 실행 흐름도 함께 블로킹된다. 보낼 메시지가 계속 생기면 붙들린 것의 수도 계속 늘어난다. &lt;strong&gt;버퍼를 두지 않았는데도 메시지는 쌓이고, 다만 큐가 아니라 멈춰 선 호출들 안에 쌓인다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;그리고 이쪽이 더 나쁘다. 큐에 쌓이면 길이를 셀 수 있고 한계를 정할 수 있지만, 멈춘 호출 안에 쌓이면 셀 곳이 없다. 연결은 여전히 연결된 상태로 보이고, 하트비트도 통과한다. 앞에서 본 것처럼 하트비트는 읽기 쪽을 보기 때문이다. 아무 경보도 울리지 않는 채로 사용 메모리만 늘어난다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;버퍼가 없다는 것은 쌓이지 않는다는 뜻이 아니라 셀 수 없다는 뜻이고, 셀 수 없으면 끊을 기준도 없다.&lt;/strong&gt; 그리고 기준이 없으면 지연은 조용히 메모리로 바뀐다. 연결마다 큐를 두는 진짜 이유는 처리량이 아니라, 쌓인 양을 눈에 보이는 숫자로 만들어서 끊을 기준을 갖기 위해서다.&lt;/p&gt;
&lt;p&gt;제한의 단위를 무엇으로 잡을지는 그보다 덜 분명하다. 개수로 잡으면 구현이 단순하고 대부분의 상황에서 잘 동작하지만, 메시지 크기 편차가 크면 &amp;ldquo;천 개&amp;quot;라는 제한이 실제로는 10킬로바이트일 수도 1기가바이트일 수도 있다. 메모리 사용량을 예측 가능하게 만들려면 바이트로 옮기는 편이 맞다. 다만 그렇게 하면 큐에 넣을 때마다 크기를 재야 하고 정책도 복잡해지므로, 이것은 규칙이라기보다 어느 쪽 불확실성을 감수할지 고르는 문제다.&lt;/p&gt;
&lt;p&gt;수신과 송신을 분리하지 않는 것도 같은 종류의 실수다. TCP 송신 버퍼가 차면 쓰기 호출이 블로킹되고, 같은 흐름 안에 있는 읽기가 실행되지 못해서, 한 느린 상대가 빠른 상대의 수신까지 막는다.&lt;/p&gt;
&lt;p&gt;큐가 넘쳤을 때의 선택은 버리기 아니면 끊기다. 차량 명령을 다룬다면 끊기 쪽이 낫다. &lt;strong&gt;한번 밀리기 시작한 연결은 회복을 기다리는 것보다 새로 맺는 편이 빠르기 때문&lt;/strong&gt;이다. 다만 이 정책이 옳으면서 동시에 위험하다는 것을 잊으면 안 된다. 광역 혼잡으로 수천 대의 큐가 동시에 넘치면 수천 개의 절단이 발생하고 재접속 폭풍(Reconnection Storm) 문제가 발생할 수 있다. 배압의 자기 치유는 개별 연결 단위에서만 자기 치유다. 그리고 넘쳤을 때 무엇을 할지도 메시지 종류에 따라 달라질 수 있어야 한다. 텔레메트리는 버려도 되지만 명령은 버리면 안 된다. 이 차이는 정책 계층에서 결정되어야 하고 큐라는 메커니즘에 하드코딩되면 안 된다.&lt;/p&gt;
&lt;h3 id="재접속-폭풍-방어"&gt;재접속 폭풍 방어&lt;/h3&gt;
&lt;p&gt;1만 대의 차량이 여러 파드에 나뉘어 연결되어 있다고 하자. 롤링 재시작으로 파드 하나가 종료되면 거기 붙어 있던 차량이 한꺼번에 연결을 잃고 재접속한다. 나머지 파드도 차례로 재시작되므로, 짧은 시간에 재접속 요청이 몰린다.&lt;/p&gt;
&lt;p&gt;남은 파드는 기존 연결을 유지하면서 대량의 TLS 핸드셰이크를 함께 처리한다. 재접속 요청이 처리 용량을 넘으면 CPU와 네트워크가 고갈되고, 그 영향은 기존 연결로 번진다. CPU가 부족해지면 기존 연결의 Ping/Pong과 메시지 처리가 밀린다. 데드라인이 만료되면서 멀쩡하던 연결까지 끊기고, 끊긴 연결은 다시 재접속에 합류해 부하를 키운다.&lt;/p&gt;
&lt;p&gt;재접속 폭풍이 위험한 이유는 많은 연결이 동시에 재접속해서가 아니다. 재접속을 처리하는 부하가 기존 연결의 안정성을 떨어뜨리고, 그래서 더 많은 연결이 재접속에 합류하는 &lt;strong&gt;악순환&lt;/strong&gt; 때문이다.&lt;/p&gt;
&lt;p&gt;그래서 방어는 한 층으로 되지 않는다. 클라이언트 쪽에는 지수 백오프와 Full Jitter를, 서버 쪽에는 초당 받아들일 연결 수의 상한과 동시 연결 수의 상한을, 그리고 연결을 끊을 때는 클라이언트가 재접속 전략을 구분할 수 있도록 사유를 실어 보내야 한다. 가장 흔한 실수는 지터를 빠뜨리는 것이다. 지터 없는 지수 백오프는 모든 클라이언트를 정확히 같은 시각에 동시에 도착시킨다. 1초 뒤에 다 같이 한 번, 2초 뒤에 다 같이 또 한 번, 그다음은 4초 뒤다. 지수 백오프는 폭풍을 없애는 것이 아니라 폭풍의 간격을 벌릴 뿐이다.&lt;/p&gt;
&lt;p&gt;지터를 넣더라도 범위가 좁으면 소용이 없다. 1만 대에 0에서 100밀리초 사이의 지터를 주면 여전히 100대 안팎이 같은 밀리초에 도착한다. 필요한 것은 0과 현재 백오프 상한 사이의 균등 난수, 즉 Full Jitter다.&lt;/p&gt;
&lt;p&gt;그리고 가장 위험한 실수는 이 모든 것을 클라이언트에게만 맡기는 것이다. &lt;strong&gt;서버는 클라이언트의 선의를 가정하면 안 된다.&lt;/strong&gt; 구형 펌웨어의 클라이언트가 백오프를 구현하지 않았을 수도 있고, 아예 다른 팀이나 서드파티가 만든 클라이언트일 수도 있다. 서버 측 속도 제한이 없으면 클라이언트 한 종류의 버그가 전체 플랫폼을 무너뜨린다.&lt;/p&gt;
&lt;p&gt;사유를 알려주는 일도 생각보다 중요하다. 느린 소비자로 끊긴 것과 토큰이 만료되어 끊긴 것은 클라이언트가 해야 할 행동이 정반대다. 앞의 것은 잠시 기다렸다 다시 붙어야 하고, 뒤의 것은 다시 붙어 봐야 똑같이 거절당한다. &lt;strong&gt;왜 끊겼는지 알려주지 않으면 클라이언트는 어느 경우에도 일단 다시 붙으려 하고, 그것이 폭풍을 키운다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;운영 측면에서는 롤링 재시작 자체에 &lt;strong&gt;드레인(Drain)&lt;/strong&gt; 단계를 넣는 방법이 있다. 새 연결 수락을 먼저 멈추고, 접속 중인 클라이언트에 이전하라는 신호를 보내고, 일정 시간 기다린 뒤에 종료하는 순서다. 동시에 끊기는 대신 시간에 걸쳐 나뉘어 이동한다.&lt;/p&gt;
&lt;p&gt;마지막으로 재접속이 성립하려면 지켜야 하는 불변식이 하나 있다. &lt;strong&gt;같은 차량이 두 개의 연결로 동시에 존재해서는 안 된다.&lt;/strong&gt; 같은 식별자로 다시 붙으면 기존 연결을 끊고 새 연결로 교체해야 한다. 그러지 않으면 메시지가 어느 연결로 나갈지 비결정적이 되고 상태가 어긋난다. 그런데 이 교체에는 함정이 하나 있다. 끊긴 기존 연결도 나름대로 뒷정리를 하면서 자기를 목록에서 지우려 하는데, 그 정리 작업이 교체 직후에 실행되면 방금 등록한 새 연결이 지워진다. 지우기 전에 목록에 있는 것이 정말 자기 자신인지 확인하도록 만들어야 한다.&lt;/p&gt;
&lt;h2 id="테슬라-차량-제어-시스템의-철학"&gt;테슬라 차량 제어 시스템의 철학&lt;/h2&gt;
&lt;p&gt;Tesla Vehicle Command SDK를 이해할 때 가장 중요한 것은 차량 제어의 신뢰 경계를 어디에 두었는가이다.&lt;/p&gt;
&lt;p&gt;일반적인 원격 제어 시스템에서는 클라우드가 사용자를 인증하고, 서버가 권한을 확인한 뒤 차량으로 명령을 전달하는 구조(User &amp;gt; Cloud &amp;gt; Gateway &amp;gt; Vehicle)를 쉽게 생각할 수 있다.
이 구조에서는 차량이 Cloud와 Gateway를 신뢰한다는 전제가 숨어 있다. Cloud가 정상적으로 인증했고 Gateway가 정상적으로 명령을 전달했다면 차량은 그 명령을 받아들이는 식이다.&lt;/p&gt;
&lt;p&gt;Tesla Vehicle Command Protocol은 여기서 한 단계 더 나아간다. 테슬라 서버가 명령을 전달할 수 있다는 사실과 차량이 그 명령을 실행할 권한이 있다는 사실을 분리한다.&lt;/p&gt;
&lt;p&gt;테슬라의 공식 문서에 따르면 서버 측에서는 OAuth 토큰을 통해 요청 주체를 인증하고, 차량은 별도로 자신의 키체인에 등록된 공개키를 사용해 명령을 인증한다. 즉, 유효한 OAuth 토큰을 가지고 Tesla 서버에 요청하는 것만으로는 충분하지 않으며, 해당 차량에 등록된 공개키에 대응하는 개인키를 가진 주체여야 차량이 명령을 받아들일 수 있다.&lt;/p&gt;
&lt;p&gt;이 구조의 핵심에는 &lt;strong&gt;Virtual Key&lt;/strong&gt;가 있다. 애플리케이션은 공개키와 개인키로 구성된 키 쌍을 만들고, 공개키를 사용자의 승인 과정을 거쳐 차량에 등록한다. 개인키는 애플리케이션 측에서 안전하게 보관한다. 차량은 이후 명령에 포함된 공개키가 자신의 키체인에 등록되어 있는지 확인하고, 그 키에 대응하는 개인키를 가진 주체가 생성한 명령인지를 검증한다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 철학이 드러난다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;차량 제어 권한을 단순히 클라우드의 인증 결과에 의존하지 않고, 차량이 이해할 수 있는 암호학적 자격 증명으로 표현한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이 때문에 테슬라의 서버가 명령을 차량으로 전달하는 통신 경로에 있더라도, 서버가 단순히 &amp;ldquo;이 명령은 인증된 사용자에게서 왔다&amp;quot;고 주장하는 것만으로 차량을 제어할 수 있는 것은 아니다. 차량은 자신이 신뢰하는 공개키와 명령의 암호학적 인증 정보를 바탕으로 명령을 받아들일지를 독립적으로 판단할 수 있다.&lt;/p&gt;
&lt;p&gt;실제 Vehicle Command Protocol에서는 클라이언트와 차량이 ECDH를 사용해 공유 비밀키를 도출한다. 클라이언트의 공개키가 이미 차량에 등록되어 있는 상태에서 세션을 설정하고, 차량이 제공한 세션 정보에는 epoch, 차량 시각, anti-replay counter 등의 정보가 포함된다. 이후 명령은 이 세션 정보와 공유 키를 이용해 인증된다.&lt;/p&gt;
&lt;p&gt;명령 자체는 Protobuf로 직렬화된 뒤 RoutableMessage의 payload에 들어가고, 별도의 signature_data에는 명령을 인증하기 위한 정보가 포함된다. 인증 방식에 따라 HMAC-SHA256 또는 AES-GCM이 사용되며, epoch, expiration time, counter, domain, VIN(Vehicle Identification Number) 등의 메타데이터도 인증 대상에 포함된다. 이를 통해 단순히 &amp;ldquo;누가 보냈는가&amp;quot;뿐만 아니라 어떤 차량을 대상으로, 어떤 세션에서, 어느 시점에, 어떤 순서로 생성된 명령인가까지 검증할 수 있다.&lt;/p&gt;
&lt;p&gt;즉, &lt;strong&gt;명령 자체가 권한 있는 주체에 의해 생성되었다는 사실을 검증&lt;/strong&gt;하는 것이 핵심이다.&lt;/p&gt;
&lt;h2 id="차량-제어-시스템이-신뢰해야-할-것"&gt;차량 제어 시스템이 신뢰해야 할 것&lt;/h2&gt;
&lt;p&gt;차량 제어 시스템은 성격이 다른 세 요구를 동시에 만족시켜야 한다. 그리고 각 요구는 무엇을 신뢰할 것인지에 대한 답을 하나씩 갖는다.&lt;/p&gt;
&lt;p&gt;첫째는 &lt;strong&gt;보안&lt;/strong&gt;이다. 명령이 정말 권한 있는 주체에게서 생성되었는지를 신뢰 경계 안에서 검증할 수 있어야 한다. 여기서 신뢰해야 하는 것은 &lt;strong&gt;암호학적 인증 정보&lt;/strong&gt;다. 네트워크 경로나 중간 서버의 동작을 믿는 것만으로는 부족하고, 서명이나 인증 태그와 같은 암호학적 증거로 명령이 신뢰된 주체에서 생성되었다는 사실을 검증해야 한다.&lt;/p&gt;
&lt;p&gt;모든 검증을 반드시 차량 자체에서 해야 하는 것은 아니다. Gateway와 같이 차량 앞단에 신뢰할 수 있는 컴포넌트가 있다면 그곳에서 인증과 인가를 처리할 수 있다. 중요한 것은 검증이 어디에서 이루어지는지가 아니라, 검증되지 않은 명령이 신뢰 경계를 넘어 차량의 제어 경로로 들어가지 않는다는 것이다.&lt;/p&gt;
&lt;p&gt;둘째는 &lt;strong&gt;명령 전달&lt;/strong&gt;이다. 유실되고 뒤바뀌고 중복될 수 있는 네트워크를 거쳐, 명령을 어떻게 차량까지 전달하고 실패했을 때 어떻게 처리할 것인가를 다뤄야 한다. 여기서 신뢰해야 하는 것은 명시적으로 정의된 &lt;strong&gt;프로토콜 규칙&lt;/strong&gt;이다. 메시지의 순서가 올바른지, 이전에 사용한 메시지가 다시 사용된 것은 아닌지, 세션이 여전히 유효한지, 명령이 아직 실행 가능한 시간 범위에 있는지는 임의의 추측이 아니라 프로토콜에 정의된 규칙으로 판단한다. Sequence, Epoch, Counter, TTL과 같은 값이 이러한 판단의 명시적인 근거가 된다.&lt;/p&gt;
&lt;p&gt;셋째는 &lt;strong&gt;상태 관리&lt;/strong&gt;다. 명령을 보냈다는 사실만으로는 차량의 실제 상태가 바뀌었다고 확신할 수 없고, 응답이 오지 않았다고 해서 명령이 실행되지 않았다고 단정할 수도 없다. 여기서 신뢰해야 하는 것은 &lt;strong&gt;차량이 관찰한 실제 상태&lt;/strong&gt;다. 명령의 성공 여부를 네트워크 응답으로 판단하지 말고, 차량에서 관찰된 상태를 실제 결과에 대한 가장 직접적인 근거로 삼는다. Desired와 Actual이 다르다면 다시 수렴시킨다.&lt;/p&gt;
&lt;p&gt;결국 차량 제어 시스템에서 신뢰해야 하는 것은 네트워크가 정상적으로 동작했다는 추측이 아니라, 검증할 수 있는 증거와 명시적으로 정의된 규칙, 그리고 차량이 실제로 관찰한 상태다.&lt;/p&gt;
&lt;p&gt;차량 제어 시스템을 설계한다는 것은 결국 &lt;strong&gt;무엇을 신뢰할 것인지를 정하는 일이고, 신뢰하지 않기로 한 것의 목록이 곧 아키텍처&lt;/strong&gt;가 된다.&lt;/p&gt;
&lt;h2 id="references"&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://fbswiki.org/wiki/index.php/Feedback_Systems:_An_Introduction_for_Scientists_and_Engineers"&gt;Feedback Systems: An Introduction for Scientists and Engineers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.golioth.io/better-iot-design-patterns-desired-state-vs-actual-state/"&gt;Better IoT design patterns: Desired state vs. actual state&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://downey.io/blog/desired-state-vs-actual-state-in-kubernetes/"&gt;Desired State Versus Actual State in Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://branislavjenco.github.io/desired-state-systems/"&gt;Desired State Systems&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>테스트를 넘어 제품 개발 관점에서 Mock 바라보기</title><link>https://whitevision.dev/posts/mock-product/</link><pubDate>Sat, 08 Aug 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/mock-product/</guid><description>&lt;p&gt;테스트 목적으로 실제 객체나 시스템을 대신하여 사용하는 모든 종류의 가짜 객체를 &amp;lsquo;&lt;strong&gt;&lt;a href="https://en.wikipedia.org/wiki/Test_double"&gt;테스트 더블(Test Double)&lt;/a&gt;&lt;/strong&gt;&amp;rsquo; 이라고 한다.&lt;/p&gt;
&lt;p&gt;테스트 더블에는 Stub, Fake, Spy, Mock 등 여러 종류가 있으며, 이 중 Mock은 사전에 정의한 호출이나 상호작용에 대한 명세를 기반으로 동작을 검증하는 데 사용된다.
예를 들어 특정 메서드가 호출되었는지, 어떤 인자가 전달되었는지, 몇 번 호출되었는지 등을 검증할 수 있다.&lt;/p&gt;
&lt;p&gt;일반적으로 Mock은 이메일 발송, 결제와 같이 서버에 사이드 이펙트를 발생시키거나 외부 환경에 의존하는 부분을 테스트할 때 자주 사용된다.&lt;/p&gt;
&lt;p&gt;이러한 Mock을 테스트를 넘어 &lt;strong&gt;제품 개발 관점&lt;/strong&gt;에서 바라보면 매우 유용하게 활용할 수 있는 지점이 있다.&lt;/p&gt;
&lt;h2 id="팀-간-개발-병목을-제거하는-mock"&gt;팀 간 개발 병목을 제거하는 Mock&lt;/h2&gt;
&lt;p&gt;E-Commerce, Connected Service 등 하나의 제품을 여러 팀들이 협업하여 개발하는 과정을 상상해보자.&lt;/p&gt;
&lt;p&gt;하나의 제품을 만들기 위해 여러 팀이 각자의 서버, 애플리케이션, 디바이스 등을 개발하고, 이들은 API와 같은 명확한 계약(Contract)을 통해 서로 통신한다.&lt;/p&gt;
&lt;p&gt;예를 들어 A팀이 B팀에서 개발하는 API를 사용해야 한다고 하자. 그런데 B팀의 개발 일정이 늦어지고 있거나, B팀이 담당하는 서버 또는 디바이스의 특성상 API를 추가하거나 변경하는 작업이 쉽지 않을 수 있다.
이 경우 B팀의 개발 일정이 A팀의 개발 일정까지 지연시키는 병목(Bottleneck)이 된다.
A팀 입장에서는 실제 B팀의 API가 완성될 때까지 아무것도 할 수 없는 상황이 발생하는 것이다.&lt;/p&gt;
&lt;p&gt;이때 &lt;strong&gt;Mock Server&lt;/strong&gt;를 활용할 수 있다.&lt;/p&gt;
&lt;p&gt;A팀은 B팀의 API 계약을 기반으로 실제 B팀의 서버가 아직 구현되지 않았더라도 동일한 인터페이스를 제공하는 Mock Server를 먼저 구성할 수 있다.
그리고 A팀에서 개발하는 기능은 실제 B팀의 서버 대신 Mock Server를 바라보도록 구성한다.
이를 통해 A팀은 B팀의 구현이 완료되기를 기다리지 않고 UI/UX, 비즈니스 로직, 예외 상황 등을 먼저 개발하고 테스트할 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 &lt;strong&gt;Mock은 팀 간의 개발 의존성을 분리하고, 계약을 기준으로 병렬 개발을 가능하게 만드는 도구로 활용될 수 있다.&lt;/strong&gt;&lt;/p&gt;</description></item><item><title>TDD IS ALIVE</title><link>https://whitevision.dev/posts/tdd-is-alive/</link><pubDate>Fri, 07 Aug 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/tdd-is-alive/</guid><description>&lt;p&gt;TDD(Test-Driven Development)는 개발자가 프로덕션 코드를 작성하기 전에 요구사항을 먼저 생각하고 정리하여 테스트 시나리오를 먼저 작성하게 한다.
그리고 테스트를 통과하기 위한 가장 간단한 코드를 작성하고 다시 테스트 코드를 실행하여 테스트가 통과됨을 확인한다. 테스트가 통과된 이후에는 필요에 따라 리팩토링을 할 수 있다. 이러한 주기(Red-Green-Refactor)를 반복하면서 개발하는 것을 TDD라고 한다.&lt;/p&gt;
&lt;p&gt;AI가 없던 시절에 일반적으로 TDD는 실제 업무에 사용하기 어려운 도구였다고 생각한다. TDD가 어려운 이유는 테스트의 우선순위와 관심이 현재 AI를 활용하여 코드를 작성하고 있는 시대에 비해서 적었다고 생각한다.
내가 주니어 개발자 시절 겪었던 대부분의 회사는 아쉽게도 테스트에 대한 관심과 우선순위가 낮았다. 따라서 과거에는 TDD를 개발 문화로 정착시키긴 어려웠으며 개인이 필요에 의해서 각자 사용하는 경우가 많았다.&lt;/p&gt;
&lt;p&gt;과거에 내가 경험했던 TDD의 장점은 &lt;strong&gt;테스트하기 쉬운 구조를 갖게 해준다는 것과 좋은 인터페이스 설계를 갖게 해준다는 것 그리고 추상화를 창조하는 게 아니라 발견하게 해준다는 것&lt;/strong&gt;이라고 생각한다.&lt;/p&gt;
&lt;p&gt;테스트 코드는 AI 시대에 더욱 중요해졌다. AI 시대에 TDD는 SKILL만 있으면 바로 적용 가능하기 때문에 과거와 달리 선택하지 않을 이유가 없다고 생각한다.&lt;/p&gt;
&lt;p&gt;인간은 요구사항 명세를 작성하여 AI에게 던져주면 AI가 플래닝과 구현을 진행하게 된다. 이때 AI는 TDD SKILL을 통해서
명세를 테스트로 문서화한다. 즉, 테스트 코드는 AI에게 &lt;strong&gt;정책 문서&lt;/strong&gt;로서 존재하게 된다.
따라서 이후에 다른 작업을 진행하더라도 AI가 플래닝 과정에서는 인간이 전달한 요구사항에 빈틈이 없는지 피드백을 해주며, 구현 과정에서는 테스트 코드가 안전망 역할을 해준다.
이렇게 쌓인 테스트 코드는 &lt;strong&gt;대규모 리팩토링을 진행할 때 매우 강력한 안전망 역할을 하고 회귀 문제를 방지&lt;/strong&gt;한다.&lt;/p&gt;
&lt;p&gt;실제로 42dot에서 &amp;ldquo;모바일과 IVI 간 실시간 프로필 데이터 동기화 시스템&amp;quot;을 대규모 리팩토링(200+ files) 하는 과정에서
AI가 단 한 번도 회귀 문제를 일으키지 않았다. 이때 Test Coverage는 약 70%였다.&lt;/p&gt;
&lt;p&gt;장기적으로 변경 가능한 시스템을 설계하고 있는 모든 소프트웨어 엔지니어들은 TDD를 사용해야 한다.&lt;/p&gt;</description></item><item><title>AI 와 IntelliJ Http Client 를 활용한 회귀 테스트 자동화</title><link>https://whitevision.dev/posts/intellij-regression-test/</link><pubDate>Wed, 05 Aug 2026 21:09:40 +0900</pubDate><guid>https://whitevision.dev/posts/intellij-regression-test/</guid><description>&lt;p&gt;코드 변경으로 인해 기존에 정상적으로 동작하던 기능이 여전히 정상 동작하는지 확인하는 테스트 활동을 &lt;strong&gt;회귀 테스트(Regression Testing)&lt;/strong&gt; 라고 한다.&lt;/p&gt;
&lt;p&gt;회귀 테스트는 유닛 테스트, 통합 테스트 등 다양한 테스트 전략을 동시에 활용하는게 일반적이다. 유닛 테스트의 작성 비용은 AI로 인해 많이 줄어들었다.
하지만 여전히 실제 운영 환경과 동일한 의존성을 갖는 환경에서 동작을 검증하기 위한 통합 테스트는 유닛 테스트에 비해 작성, 관리 비용이 높다.
비용이 더 큰 이유는 DB, Broker, Redis 등 외부 인프라 자원을 활용하기 때문이다.&lt;/p&gt;
&lt;p&gt;통합 테스트 환경을 구축하고 CI/CD에 통합하거나 혹은 로컬에서 수동으로 테스트를 진행하기 위해 Testcontainer를 많이 사용하지만
컨테이너를 생성하고 초기화하는 오버헤드로 인해 속도가 느리다는 점이 있고, 자원 관리도 신경써야 한다.&lt;/p&gt;
&lt;p&gt;개발자들은 AI가 작성한 코드를 직접 검증해야하는 의무가 있다. 예를 들어 로컬 서버를 dev 환경에 붙도록 띄운 다음에 직접 API를 호출하면서 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;이제는 AI 덕분에 AI가 작성한 코드가 회귀 문제를 일으키지 않는지 쉽게 검증하고 자동화할 수 있다.
아이디어는 다음과 같다.&lt;/p&gt;
&lt;p&gt;먼저 통합 테스트 케이스(e.g 자주 검증을 위한 테스트 케이스)를 &lt;code&gt;testcase.http&lt;/code&gt; 파일에 정의하고 &lt;code&gt;ijhttp&lt;/code&gt;를 설치한 다음 &lt;code&gt;testcase.http&lt;/code&gt;를 편하게 수행하기 위한 shell을 만들어야 한다.
그리고 rule을 정의해서 AI가 구현이 완료되면 자동으로 회귀 통합 테스트를 수행하도록 할 수 있다.&lt;/p&gt;
&lt;p&gt;즉, &lt;code&gt;testcase.http&lt;/code&gt;는 단순한 API 테스트 파일이 아니라 &lt;strong&gt;AI Agent가 코드 변경 후 기존 시스템의 동작을 스스로 검증하기 위한 통합 회귀 테스트의 최소 실행 단위로 활용&lt;/strong&gt;할 수 있다.&lt;/p&gt;</description></item><item><title>Go Proverbs Deep Dive #4: Concurrency is not parallelism</title><link>https://whitevision.dev/posts/go-concurrency-not-parallelism/</link><pubDate>Mon, 03 Aug 2026 21:09:40 +0900</pubDate><guid>https://whitevision.dev/posts/go-concurrency-not-parallelism/</guid><description>&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;Concurrency is not parallelism&amp;rdquo; — Rob Pike&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Go Proverbs의 &amp;ldquo;Concurrency is not parallelism&amp;quot;은 동시성과 병렬성이 같은 개념이 아니라는 사실을 이야기한다.&lt;/p&gt;
&lt;p&gt;동시성은 작업을 독립적으로 진행할 수 있도록 구조를 나누는 것이고, 병렬성은 나누어진 작업을 여러 코어에서 실제로 동시에 실행하는 것이다.&lt;/p&gt;
&lt;p&gt;두 개념은 서로 연결되어 있지만 동일하지 않다.&lt;/p&gt;
&lt;h2 id="동시성은-구조이고-병렬성은-실행이다"&gt;동시성은 구조이고, 병렬성은 실행이다&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;Concurrency is about structure, parallelism is about execution.&amp;rdquo;
(동시성은 구조에 대한 이야기이고, 병렬성은 실행에 대한 이야기다.)&lt;/p&gt;
&lt;p&gt;— Rob Pike, &lt;em&gt;Concurrency is not Parallelism&lt;/em&gt; (Waza, 2012)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;동시성은 여러 작업을 독립적으로 수행할 수 있도록 프로그램을 구성하는 방법이다. 병렬성은 그렇게 나누어진 작업들이 여러 CPU 코어에서 실제로 동시에 실행되는 상태를 의미한다.
중요한 점은 동시성은 코드만 봐도 알 수 있지만, 병렬성은 실행 환경을 알아야 판단할 수 있다는 것이다.&lt;/p&gt;
&lt;p&gt;고루틴을 100개 만드는 코드는 어디에서 실행하든 동시적인 코드다. 하지만 그 100개의 고루틴이 실제로 몇 개씩 동시에 실행되는지는 CPU 코어 수와 GOMAXPROCS 설정에 따라 달라진다.&lt;/p&gt;
&lt;h2 id="기다리는-작업은-동시성만으로도-빨라진다"&gt;기다리는 작업은 동시성만으로도 빨라진다&lt;/h2&gt;
&lt;p&gt;대부분의 시간이 I/O 대기인 작업이라면 병렬성이 없어도 큰 성능 향상을 얻을 수 있다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// 순차 실행&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;100&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;time&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Sleep&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;10&lt;/span&gt; &lt;span style="color:#f92672"&gt;*&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;time&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Millisecond&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// 동시 실행&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;100&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Add&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;time&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Sleep&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;10&lt;/span&gt; &lt;span style="color:#f92672"&gt;*&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;time&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Millisecond&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Wait&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;순차 버전은 기다림이 끝나야 다음 작업을 시작하지만, 동시 실행 버전은 모든 작업이 동시에 기다리기 시작한다.
&lt;code&gt;GOMAXPROCS=1&lt;/code&gt;처럼 코어를 하나만 사용해도 실행 시간은 크게 줄어든다.&lt;/p&gt;
&lt;p&gt;동시성이 성능을 높이는 이유는 CPU를 더 많이 쓰는 것이 아니라 &lt;strong&gt;놀고 있는 시간을 줄이기 때문&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h2 id="계산-작업은-병렬성이-필요하다"&gt;계산 작업은 병렬성이 필요하다&lt;/h2&gt;
&lt;p&gt;CPU 계산이 대부분인 작업은 이야기가 다르다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;_&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;job&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;jobs&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;process&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;job&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;위 코드를 아래 처럼 워커 풀로 바꿔도 &lt;code&gt;GOMAXPROCS=1&lt;/code&gt;이라면 실행 시간은 거의 변하지 않는다.
해야 하는 계산량 자체는 그대로이기 때문이다.&lt;/p&gt;
&lt;p&gt;하지만 &lt;code&gt;GOMAXPROCS&lt;/code&gt;를 늘려 여러 코어에서 동시에 실행하면 처리 시간이 점차 감소한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#a6e22e"&gt;workers&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;worker&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;jobCh&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;즉, &lt;strong&gt;작업을 나누는 것은 동시성이고, 나누어진 작업을 여러 코어에서 실행하는 것은 병렬성&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h2 id="go는-동시성과-병렬성을-어떻게-나눠-놓았는가"&gt;Go는 동시성과 병렬성을 어떻게 나눠 놓았는가&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Go에서 고루틴과 채널은 동시성을 표현하는 문법이고, &lt;code&gt;GOMAXPROCS&lt;/code&gt;는 병렬성을 정하는 설정이다. 언어 문법에는 병렬성을 지정하는 수단이 아예 없다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Go 스케줄러의 G-M-P 모델에 이 분리가 그대로 들어 있다.&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;strong&gt;G&lt;/strong&gt; (goroutine)&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;strong&gt;P&lt;/strong&gt; (processor)&lt;/td&gt;
&lt;td&gt;Go 코드를 실행할 수 있는 자리&lt;/td&gt;
&lt;td&gt;병렬성 — 자리 수가 곧 &lt;code&gt;GOMAXPROCS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;M&lt;/strong&gt; (machine)&lt;/td&gt;
&lt;td&gt;실제 OS 스레드&lt;/td&gt;
&lt;td&gt;자리에 앉아 고루틴을 실행하는 주체&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;고루틴은 얼마든지 만들 수 있지만, 어느 순간에 실제로 Go 코드를 실행 중인 고루틴 수는 &lt;strong&gt;자리 수를 넘을 수 없다.&lt;/strong&gt; 고루틴을 10만 개 띄워도 &lt;code&gt;GOMAXPROCS&lt;/code&gt;가 2면 같은 순간에 달리는 것은 2개다.&lt;/p&gt;
&lt;p&gt;한 가지 자주 오해되는 지점이 있다. &lt;code&gt;GOMAXPROCS&lt;/code&gt;는 &lt;strong&gt;Go 코드를 동시에 실행하는 자리 수의 상한&lt;/strong&gt;이지, 프로그램이 만드는 OS 스레드 수의 상한이 아니다. 파일 읽기 같은 시스템 콜에서 멈춘 고루틴은 자기 자리를 다른 스레드에 넘겨주고 물러나므로, &lt;code&gt;GOMAXPROCS=1&lt;/code&gt;인 프로그램도 스레드는 여러 개를 가질 수 있다.&lt;/p&gt;
&lt;h2 id="맺음말"&gt;맺음말&lt;/h2&gt;
&lt;p&gt;&lt;mark&gt;&lt;em&gt;&lt;strong&gt;동시성은 작업을 어떻게 나눌 것인가에 대한 답이고, 병렬성은 그 작업을 몇 명이 처리할 것인가에 대한 답이다.&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;&lt;/p&gt;
&lt;h2 id="references"&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://go.dev/talks/2012/waza.slide"&gt;Rob Pike, &amp;ldquo;Concurrency is not Parallelism&amp;rdquo; (Heroku Waza, 2012)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://go.dev/blog/waza-talk"&gt;Andrew Gerrand, &amp;ldquo;Concurrency is not parallelism&amp;rdquo; (The Go Blog, 2013)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Rob Pike, &amp;ldquo;Go Proverbs&amp;rdquo; (Gopherfest, 2015)&lt;/li&gt;
&lt;li&gt;Edsger W. Dijkstra, &amp;ldquo;Cooperating Sequential Processes&amp;rdquo; (EWD123, 1965)&lt;/li&gt;
&lt;li&gt;Go 표준 라이브러리, &lt;code&gt;runtime.GOMAXPROCS&lt;/code&gt; 문서 (Go 1.26.5)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://go.dev/ref/mem"&gt;The Go Memory Model&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/uber-go/automaxprocs"&gt;automaxprocs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>Go Proverbs Deep Dive #3: Channels orchestrate; mutexes serialize</title><link>https://whitevision.dev/posts/go-channel-mutex/</link><pubDate>Mon, 03 Aug 2026 12:23:18 +0900</pubDate><guid>https://whitevision.dev/posts/go-channel-mutex/</guid><description>&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;Channels orchestrate; mutexes serialize.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;— Rob Pike, Go Proverbs (Gopherfest 2015)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Go Proverbs의 **&amp;ldquo;Channels orchestrate; mutexes serialize&amp;rdquo;**는 채널과 뮤텍스 중 무엇이 더 좋은지를 말하는 문장이 아니라, 두 도구가 애초에 &lt;strong&gt;서로 다른 축의 문제를 푼다&lt;/strong&gt;는 사실을 말하는 문장이다.&lt;/p&gt;
&lt;p&gt;뮤텍스(Mutex)는 하나의 메모리 위치에 여러 실행 흐름이 동시에 닿지 못하도록 접근을 한 줄로 세우는 도구이고, 채널(Channel)은 독립적으로 살아 움직이는 실행 흐름들이 언제 만나고 언제 헤어지며 언제 멈추는지를 설계하는 도구다.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://whitevision.dev/posts/go-shared-memory-by-communicating/"&gt;지난 글&lt;/a&gt;에서는 공유 메모리의 문제가 결국 읽기-수정-쓰기의 비원자성 하나로 수렴한다는 점과,
접근을 직렬화하는 방법과 소유권을 설계하는 방법이 있다는 점을 살펴봤다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 위 두 가지 방법을 대표하는 도구인 뮤텍스와 채널을 더 깊게 파볼 것이다.&lt;/p&gt;
&lt;h2 id="orchestrate와-serialize는-무엇이-다른가"&gt;orchestrate와 serialize는 무엇이 다른가&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;직렬화(serialize)는 여러 실행 흐름이 하나의 자원에 접근하는 순서를 한 줄로 세워 동시 접근 자체를 없애는 것이고, 조율(orchestrate)은 독립적으로 진행하는 실행 흐름들이 서로 만나고 신호를 주고받는 시점을 설계하는 것이다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;에츠허르 다익스트라(Edsger Dijkstra)는 1965년 &amp;ldquo;Solution of a Problem in Concurrent Programming Control&amp;quot;에서 상호 배제(mutual exclusion) 문제를 정식화했다.
임계 영역에 한 번에 하나의 프로세스만 들어가게 하는 문제다. 반면 토니 호어(C. A. R. Hoare)가 1974년 &amp;ldquo;Monitors: An Operating System Structuring Concept&amp;quot;에서 모니터와 조건 변수(condition variable)를 제안한 이유는 상호 배제만으로는 버퍼가 비어있는지, 버퍼가 찼는지, 일이 끝났는지와 같은 조건을 기다리는 문제를 해결하기 어려웠다.&lt;/p&gt;
&lt;p&gt;이를 각각 **상호 배제(mutual exclusion)와 조건 동기화(condition synchronization)**라고 부른다.&lt;/p&gt;
&lt;p&gt;&lt;mark&gt;&lt;em&gt;&lt;strong&gt;뮤텍스는 상호 배제만 제공하고, 채널은 데이터 전달과 실행 흐름을 하나로 묶는다. 그 결과 개발자는 공유 상태를 보호하기 위한 Mutex와 조건을 기다리기 위한 Condition Variable를 직접 작성하는 대신 데이터의 흐름 자체를 표현하는데 집중할 수 있다.&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;&lt;/p&gt;
&lt;p&gt;아래 예시를 통해 살펴보자.&lt;/p&gt;
&lt;h3 id="mutex--condition-variable"&gt;Mutex + Condition Variable&lt;/h3&gt;
&lt;p&gt;이 방식은 공유 상태를 직접 관리하는 방식이다.
Mutex는 공유 데이터에 대한 접근을 보호하고, Condition Variable은 데이터가 준비될 때까지 기다렸다가 깨우는 역할을 한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;package&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;sync&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Mutex&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;cond&lt;/span&gt; = &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;NewCond&lt;/span&gt;(&lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;ready&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;value&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;producer&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Lock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;value&lt;/span&gt; = &lt;span style="color:#ae81ff"&gt;42&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;ready&lt;/span&gt; = &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 기다리는 소비자를 깨운다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;cond&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Signal&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Unlock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;consumer&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Lock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 데이터가 준비될 때까지 기다린다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; !&lt;span style="color:#a6e22e"&gt;ready&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;cond&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Wait&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;v&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;value&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Unlock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;v&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;WaitGroup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Add&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;consumer&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;producer&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Wait&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;여기서 개발자는 공유데이터(&lt;code&gt;value&lt;/code&gt;), 준비 여부(&lt;code&gt;ready&lt;/code&gt;), Mutex와 같이 직접 관리해야하는 포인트가 많다.
뮤텍스는 Lock이 반환됐다는 사실만 알려줄 뿐, 임계 영역(critical section)안에서 무엇을 읽어야 하는지, 왜 깨어났는지 등을 알려주지 않는다.&lt;/p&gt;
&lt;h3 id="channel"&gt;Channel&lt;/h3&gt;
&lt;p&gt;Mutex + Condition Variable로 구현한 코드를 채널로 구현하면 공유 상태가 사라진다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;package&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;sync&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;producer&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt;&lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;42&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;consumer&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 데이터가 올 때까지 기다린다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;v&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;v&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;WaitGroup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Add&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;consumer&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;producer&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Wait&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;v := &amp;lt;-ch &lt;/code&gt; 코드는 값의 전달과 순서의 보장과 소유권의 이전을 동시에 수행한다.&lt;/p&gt;
&lt;p&gt;채널을 사용한 방식은 Producer &amp;gt; Channel &amp;gt; Consumer라는 데이터 흐름이 명확히 표현된다.&lt;/p&gt;
&lt;p&gt;이것이 Go Proverbs에서 말하는 **Channels orchestrate; mutexes serialize.**이다.&lt;/p&gt;
&lt;h2 id="syncmutex는-어떻게-접근을-직렬화하는가"&gt;sync.Mutex는 어떻게 접근을 직렬화하는가&lt;/h2&gt;
&lt;p&gt;Go의 &lt;code&gt;sync.Mutex&lt;/code&gt;는 하나의 32비트 정수(state)안에 락의 상태와 대기 정보를 비트 단위로 저장한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.go101.org/std/src/internal/sync/mutex.go.html"&gt;src/internal/sync/mutex.go&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="mutex.png" alt="Mutex"&gt;&lt;/p&gt;
&lt;p&gt;state의 가장 낮은 비트는 현재 락이 잡혀있는지(&lt;code&gt;Locked&lt;/code&gt;), 그 다음 비트는 이미 대기 중인 goroutine을 깨웠는지(&lt;code&gt;Woken&lt;/code&gt;), 그 다음 비트는 starvation mode 인지(&lt;code&gt;Starving&lt;/code&gt;)
를 나타낸다. 나머지는 현재 락을 기다리는 goroutine의 개수가 저장된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;31 3 2 1 0
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;+------------------------------------------------+-+-+-+
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;| waiter count |S|W|L|
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;+------------------------------------------------+-+-+-+
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;L : Locked (mutexLocked: 현재 락이 잡혀 있는가?)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;W : Woken (mutexWoken: 대기 중인 goroutine 하나가 이미 깨어났는가?, 중복 wake-up 방지)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;S : Starving (mutexStarving: Starvation Mode인가?)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;나머지 비트 : waiter count (mutexWaiterShift: 대기 중인 goroutine 수)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;락을 잡으면 &lt;code&gt;mutexLocked&lt;/code&gt; 비트를 세우고, 기다리는 goroutine이 생기면 waiter count를 증가시키고,
starvation mode 면 &lt;code&gt;mutexStarving&lt;/code&gt; 비트를 세우는 방식이다.&lt;/p&gt;
&lt;p&gt;아래 &lt;code&gt;Lock()&lt;/code&gt; 메서드를 살펴보자.&lt;/p&gt;
&lt;p&gt;&lt;img src="lock.png" alt="Lock"&gt;&lt;/p&gt;
&lt;p&gt;Fast Path를 보면 알 수 있듯이 아무것도 잡고 있지 않다면 CAS 한번으로 끝낸다. 즉, 경합이 없을 때 뮤텍스의 비용은 &lt;strong&gt;CAS 명령 하나&lt;/strong&gt;다.
경합이 생기면 &lt;code&gt;lockSlow()&lt;/code&gt;로 내려가는데, 여기서는 곧바로 고루틴을 재우지 않고 먼저 **스핀(spin)**을 시도한다. 락 보유 시간이 짧다면
잠깐 CPU를 태우며(스핀 하며) 기다리는 편이 스케줄링 비용(parking/unparking)보다 싸기 때문이다. 스핀 횟수는 런타임 상수로 정해져 있다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// src/runtime/lock_spinbit.go&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;active_spin&lt;/span&gt; = &lt;span style="color:#ae81ff"&gt;4&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 최대 4회 스핀&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;active_spin_cnt&lt;/span&gt; = &lt;span style="color:#ae81ff"&gt;30&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 1회당 30번의 PAUSE 계열 명령&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;4회를 넘겨도 락을 얻지 못하면 그때 세마포어를 통해 고루틴을 큐에 넣고 재운다.
&lt;strong&gt;스핀할 것인가 재울 것인가는 동시성 도구가 반복해서 마주치는 고전적인 &lt;a href="https://whitevision.dev/posts/tradeoff/"&gt;트레이드오프&lt;/a&gt;이고, Go는 &amp;ldquo;아주 잠깐만 스핀하고 포기한다&amp;quot;는 지점을 선택했다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;여기서 중요한 점은 OS 스레드를 재우는 것이 아니로 고루틴을 park/unpark 하는 비용이다. Go 런타임은 고루틴만 대기 상태로 전환하고, 그 OS 스레드에는 다른 고루틴을
실행 시키므로 운영 체제 스레드를 블로킹 하는 것보다 저렴하다. 그럼에도 park/unpark 자체에도 대기열 관리, 스케줄러 작업, 컨텍스트 전환 등의 오버헤드가 있기 때문에,
아주 짧은 경합에서는 스핀하는 편이 낫다.&lt;/p&gt;
&lt;p&gt;정리하면 Go 런타임은 CPU를 조금 더 사용하는 비용과 고루틴을 재웠다가 다시 깨우는 스케줄링 비용을 비교한다. 락이 곧 풀릴 것 같다면 잠시 스핀하는 것이 더 저렴하고, 경합이 길어질 것 같다면 고루틴을 재워 CPU를 다른 작업에 사용하도록 한다.&lt;/p&gt;
&lt;h2 id="mutex의-두-가지-모드---normal-starvation"&gt;Mutex의 두 가지 모드 - normal, starvation&lt;/h2&gt;
&lt;p&gt;Go &lt;code&gt;sync.Mutex&lt;/code&gt;는 항상 공정한 락(Fair Lock)을 사용하지 않고 상황에 따라 두 가지 모드를 사용한다.&lt;/p&gt;
&lt;p&gt;핵심은 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;mark&gt;&lt;em&gt;&lt;strong&gt;Mutex는 평소에는 처리량(throughput)을 우선하고, 특정 고루틴이 너무 오래 기다리면 공정성을 우선한다.&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;&lt;/p&gt;
&lt;p&gt;위 Mutex 이미지에서 &lt;code&gt;starvationThresholdNs&lt;/code&gt; 관련 주석에 두 가지 모드에 대해 자세히 명시되어있다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;Normal mode has considerably better performance &amp;hellip; Starvation mode is important to prevent pathological cases of tail latency.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;정상 모드(normal mode)에서는 깨어난 대기자가 락의 소유권을 자동으로 받지 못하고, 방금 도착한 고루틴들과 다시 경쟁해야 한다.
이때 새로 도착한 고루틴은 이미 CPU 위에서 돌고 있으므로 압도적으로 유리하고, 깨어난 대기자는 번번이 진다. 이런 새치지를 바징(barging)이라고 부른다.
바징은 기다리고 있던 스레드보다 새로 들어온 스레드가 먼저 락을 획득하는 현상을 말한다.
바징은 처리량 관점에서는 이득이다. 이미 실행 중인 고루틴이 락을 빠르게 다시 획득하면 스케줄링 비용이 줄고, 전체 처리량이 올라간다.
문제는 대기하고 있는 특정 고루틴이 무한정 밀릴 수 있다는 것이다. 이것이 기아(starvation)이다.&lt;/p&gt;
&lt;p&gt;그래서 Go는 어떤 대기자가 1밀리초(starvationThresholdNs = 1e6) 넘게 락을 얻지 못하면 뮤텍스를 기아 모드로 전환한다.
기아 모드에서는 &lt;code&gt;Unlock()&lt;/code&gt;이 락 소유권을 대기열 맨 앞의 고루틴에게 직접 넘겨주고, 새 고루틴은 normal mode처럼 빠른 CAS 획득을 시도하지 않고
waiter queue 뒤에 추가된다. 대기 시간이 1ms 미만으로 떨어지거나 대기열이 비면 다시 정상 모드로 돌아온다.&lt;/p&gt;
&lt;h2 id="채널은-어떻게-실행-흐름을-조율하는가"&gt;채널은 어떻게 실행 흐름을 조율하는가&lt;/h2&gt;
&lt;p&gt;Go의 채널은 링 버퍼와 두 개의 고루틴 대기열, 그리고 그것들을 보호하는 뮤텍스로 이루어진 런타임 자료구조이며, 값의 전달과 실행 순서의 보장을 하나의 연산으로 묶어 제공한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// src/runtime/chan.go&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;type&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;hchan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;qcount&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 버퍼에 들어있는 원소 수&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;dataqsiz&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 버퍼 용량 — make(chan T, n)의 n&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;buf&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;unsafe&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Pointer&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 원형 큐&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;sendx&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 다음에 쓸 위치&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;recvx&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 다음에 읽을 위치&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;recvq&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;waitq&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 수신을 기다리며 잠든 고루틴들&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;sendq&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;waitq&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 송신을 기다리며 잠든 고루틴들&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;lock&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;mutex&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 위 필드 전부를 보호한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;주목할 필드는 마지막 줄이다. 채널의 내부에는 뮤텍스가 있다. 채널은 뮤텍스를 대체하는 도구가 아니라, 뮤텍스 위에 대기열과 스케줄러 연동을 얹어 만든 상위 계층이다. 그래서 채널이 뮤텍스보다 쌀 수가 없다. 채널 연산 한 번은 최소한 뮤텍스 연산 한 번을 포함한다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;recvq&lt;/code&gt;와 &lt;code&gt;sendq&lt;/code&gt;에 들어가는 원소는 &lt;code&gt;sudog&lt;/code&gt;라는 구조체인데, 이것이 &amp;ldquo;어떤 고루틴이 기다리고 있고, 송수신할 데이터 위치는 어디인지, 어떤 채널에서 기다리는지&amp;quot;를 표현한다.&lt;/p&gt;
&lt;p&gt;여기서 unbuffered channel의 중요한 최적화가 나온다.&lt;/p&gt;
&lt;p&gt;송신자가 값을 보내려고 할 때 이미 &lt;code&gt;recvq&lt;/code&gt;에 수신 대기 중인 고루틴이 있다면, 런타임은 값을 채널 내부에 저장했다가 다시 꺼내지 않는다.
대신 송신자가 가진 데이터를 수신자가 제공한 목적지 주소로 직접 복사하고, 기다리던 고루틴을 바로 실행 가능한 상태로 만든다.&lt;/p&gt;
&lt;p&gt;그리고 이 모든 과정에서 Go &lt;a href="https://whitevision.dev/posts/memory-model/"&gt;메모리 모델&lt;/a&gt;이 보장하는 순서 관계가 함께 성립한다. 채널 송신은 대응하는 수신의 완료보다 먼저 일어나고(happens-before), 채널 닫기는 닫힘을 관찰하는 모든 수신보다 먼저 일어난다.&lt;/p&gt;
&lt;h2 id="go-동시성-프리미티브-벤치마크-리포트"&gt;Go 동시성 프리미티브 벤치마크 리포트&lt;/h2&gt;
&lt;p&gt;이 글의 모든 수치를 한 화면에 모은 대시보드다. 무경합과 14코어 경합의 지연을 같은 로그 스케일 위에 나란히 놓고,
채널 버퍼 크기가 백프레셔에 어떤 영향을 주는지, 거짓 공유가 CPU 캐시 라인 수준에서 왜 발생하는지,
&lt;code&gt;select&lt;/code&gt;가 정말 무작위로 케이스를 고르는지까지 측정값과 함께 보여준다. 각 점과 막대에 마우스를 올리면 정확한 값이 나온다.&lt;/p&gt;
&lt;section class="wv-bench"&gt;
&lt;style&gt;
.wv-bench {
--s1: #fcfcfb; --page: #f9f9f7;
--tp: #0b0b0b; --ts: #52514e; --tm: #898781;
--grid: #e1e0d9; --axis: #c3c2b7; --bd: rgba(11,11,11,.10);
--mutex: #2a78d6; --channel: #eb6834; --atomic: #1baf7a; --runtime: #898781;
--o1: #86b6ef; --o2: #5598e7; --o3: #2a78d6; --o4: #184f95;
--good: #0ca30c; --critical: #d03b3b;
margin: 2.6em -220px 2.2em;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px; line-height: 1.55; color: var(--tp);
text-align: left;
}
@media (max-width: 1180px) { .wv-bench { margin-left: 0; margin-right: 0; } }
.wv-bench * { box-sizing: border-box; }
.wv-bench svg { display: block; width: 100%; height: auto; }
.wv-bench svg text { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
.wv-bench code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .94em; background: none; padding: 0; border-radius: 0;
}
.wv-bench pre {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: none; border: 0; border-radius: 0; padding: 0; margin: 0; line-height: 1.55;
}
.wv-bench p { margin: 0; }
.wv-bench h3, .wv-bench h4 { margin: 0; border: 0; padding: 0; }
.wv-bench .meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.wv-bench .chip {
display: inline-flex; align-items: center; gap: 6px;
border: 1px solid var(--bd); border-radius: 4px; background: var(--s1);
padding: 3px 9px; font-size: 12px; color: var(--ts); font-variant-numeric: tabular-nums;
}
.wv-bench .chip b { font-weight: 600; color: var(--tp); }
.wv-bench .chip.ok b { color: var(--good); }
.wv-bench .kpis { display: grid; gap: 10px; margin-bottom: 14px; grid-template-columns: 1.35fr 1fr 1fr 1fr 1fr; }
@media (max-width: 900px) { .wv-bench .kpis { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .wv-bench .kpis { grid-template-columns: 1fr; } }
.wv-bench .tile { background: var(--s1); border: 1px solid var(--bd); border-radius: 6px; padding: 14px 15px; }
.wv-bench .tile .k { font-size: 12px; color: var(--ts); margin-bottom: 7px; }
.wv-bench .tile .v { font-size: 27px; font-weight: 650; letter-spacing: -.02em; line-height: 1.1; }
.wv-bench .tile .v .u { font-size: 14px; font-weight: 500; color: var(--ts); margin-left: 2px; }
.wv-bench .tile .d { font-size: 12px; color: var(--tm); margin-top: 6px; }
.wv-bench .tile.hero .v { font-size: 50px; }
.wv-bench .panel { background: var(--s1); border: 1px solid var(--bd); border-radius: 6px; padding: 16px 18px 14px; margin-bottom: 14px; }
.wv-bench .panel &gt; h3 { font-size: 14px; font-weight: 620; margin: 0 0 3px; color: var(--tp); }
.wv-bench .panel &gt; .sub { font-size: 12px; color: var(--ts); margin: 0 0 14px; max-width: 78ch; }
.wv-bench .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 820px) { .wv-bench .row2 { grid-template-columns: 1fr; } }
.wv-bench .scroll { overflow-x: auto; }
.wv-bench .legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 12px 0 2px; }
.wv-bench .legend .li { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ts); }
.wv-bench .legend .sw { width: 10px; height: 10px; border-radius: 50%; }
.wv-bench .legend .sw.rect { border-radius: 2px; width: 11px; height: 11px; }
.wv-bench .note { margin-top: 12px; padding: 9px 12px; border-left: 2px solid var(--axis); font-size: 12px; color: var(--ts); }
.wv-bench .note b { color: var(--tp); font-weight: 600; }
.wv-bench .panel h4 { font-size: 12.5px; font-weight: 640; margin: 22px 0 9px; color: var(--tp); }
.wv-bench .panel h4:first-of-type { margin-top: 6px; }
.wv-bench .body { font-size: 12.5px; color: var(--ts); margin: 0 0 10px; max-width: 78ch; }
.wv-bench .body b { color: var(--tp); font-weight: 600; }
.wv-bench .persp { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 6px; }
@media (max-width: 700px) { .wv-bench .persp { grid-template-columns: 1fr; } }
.wv-bench .persp &gt; div { border: 1px solid var(--bd); border-radius: 5px; padding: 11px 13px; }
.wv-bench .persp .who { font-size: 11px; color: var(--tm); margin-bottom: 5px; letter-spacing: .03em; }
.wv-bench .persp .say { font-size: 13px; color: var(--tp); font-weight: 600; }
.wv-bench .persp .why { font-size: 11.5px; color: var(--ts); margin-top: 5px; }
.wv-bench .cl-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 760px) { .wv-bench .cl-grid { grid-template-columns: 1fr; } }
.wv-bench .cl-case { margin: 0; }
.wv-bench .cl-cap { font-size: 12px; margin-bottom: 8px; display: flex; align-items: center; gap: 7px; color: var(--ts); }
.wv-bench .tag { font-size: 10.5px; font-weight: 650; padding: 1px 7px; border-radius: 3px; border: 1px solid currentColor; }
.wv-bench .tag.bad { color: var(--critical); }
.wv-bench .tag.good { color: var(--good); }
.wv-bench .cl-line { display: flex; gap: 2px; margin-bottom: 6px; }
.wv-bench .cl-line + .cl-line { margin-top: 8px; }
.wv-bench .cl-seg { padding: 9px 6px; font-size: 11px; text-align: center; border-radius: 3px; font-weight: 600; line-height: 1.25; overflow: hidden; }
.wv-bench .cl-seg.a, .wv-bench .cl-seg.b { color: #0b0b0b; }
.wv-bench .cl-seg small { display: block; font-weight: 500; font-size: 9.5px; }
.wv-bench .cl-seg.a { background: var(--mutex); flex: 8; }
.wv-bench .cl-seg.b { background: var(--atomic); flex: 8; }
.wv-bench .cl-seg.rest { background: var(--grid); color: var(--tm); flex: 48; font-weight: 500; }
.wv-bench .cl-seg.pad { background: var(--grid); color: var(--tm); flex: 56; font-weight: 500; }
.wv-bench .cl-axis { font-size: 10.5px; color: var(--tm); text-align: center; margin-top: 2px; }
.wv-bench .pp { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 0; margin: 0; list-style: none; }
@media (max-width: 860px) { .wv-bench .pp { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 460px) { .wv-bench .pp { grid-template-columns: 1fr; } }
.wv-bench .pp &gt; li { border: 1px solid var(--bd); border-radius: 5px; padding: 10px 11px; margin: 0; }
.wv-bench .pp .n { font-size: 10.5px; font-weight: 700; color: var(--tm); letter-spacing: .06em; margin-bottom: 8px; }
.wv-bench .core { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 11px; color: var(--ts); padding: 5px 7px; border-radius: 3px; background: rgba(11,11,11,.04); }
.wv-bench .core + .core { margin-top: 4px; }
.wv-bench .core .st { font-size: 10px; font-weight: 650; display: inline-flex; align-items: center; gap: 4px; }
.wv-bench .core .st.ok { color: var(--good); }
.wv-bench .core .st.dead { color: var(--critical); }
.wv-bench .core .st i { font-style: normal; }
.wv-bench .core.writing { outline: 1px solid var(--mutex); }
.wv-bench .pp .cap { font-size: 11px; color: var(--ts); margin-top: 8px; line-height: 1.45; }
.wv-bench .pp .cap b { color: var(--tp); font-weight: 600; }
.wv-bench .calc {
font-size: 12px; border: 1px solid var(--bd); border-radius: 5px; padding: 10px 13px;
color: var(--ts); background: rgba(11,11,11,.03); margin: 0; overflow-x: auto;
}
.wv-bench .calc b { color: var(--tp); }
.wv-bench table { border-collapse: collapse; width: 100%; font-size: 12.5px; margin: 0; }
.wv-bench th, .wv-bench td { padding: 7px 10px; text-align: right; border-bottom: 1px solid var(--bd); white-space: nowrap; }
.wv-bench th { color: var(--tm); font-weight: 600; font-size: 11.5px; letter-spacing: .04em; }
.wv-bench th:first-child, .wv-bench td:first-child { text-align: left; white-space: normal; }
.wv-bench td.num { font-variant-numeric: tabular-nums; color: var(--ts); }
.wv-bench td.med { font-variant-numeric: tabular-nums; font-weight: 650; color: var(--tp); }
.wv-bench .fam { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 7px; vertical-align: 1px; }
#wvb-tip {
position: fixed; z-index: 60; pointer-events: none; opacity: 0; transition: opacity .09s ease;
background: #fcfcfb; border: 1px solid rgba(11,11,11,.10); border-radius: 5px; padding: 7px 10px;
box-shadow: 0 3px 14px rgba(0,0,0,.14); font-size: 12px; max-width: 260px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #0b0b0b; line-height: 1.45;
}
#wvb-tip .tv { font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums; }
#wvb-tip .tl { color: #52514e; display: flex; align-items: center; gap: 6px; margin-top: 2px; }
#wvb-tip .tk { width: 10px; height: 2px; border-radius: 1px; flex: none; }
.wv-bench .hit { fill: transparent; cursor: default; }
.wv-bench .foot { font-size: 12px; color: var(--tm); margin-top: 4px; }
&lt;/style&gt;
&lt;div class="meta"&gt;
&lt;span class="chip"&gt;HOST &lt;b&gt;Apple M4 Pro · 14 cores&lt;/b&gt;&lt;/span&gt;
&lt;span class="chip"&gt;GO &lt;b&gt;1.26.5 darwin/arm64&lt;/b&gt;&lt;/span&gt;
&lt;span class="chip"&gt;CMD &lt;b&gt;go test -bench=. -benchtime=2s -count=3&lt;/b&gt;&lt;/span&gt;
&lt;span class="chip"&gt;SAMPLES &lt;b&gt;3 × 2s&lt;/b&gt;&lt;/span&gt;
&lt;span class="chip"&gt;집계 &lt;b&gt;중앙값&lt;/b&gt;&lt;/span&gt;
&lt;span class="chip ok"&gt;STATUS &lt;b&gt;ok · 129.4s&lt;/b&gt;&lt;/span&gt;
&lt;/div&gt;
&lt;div class="kpis"&gt;
&lt;div class="tile hero"&gt;
&lt;div class="k"&gt;고루틴 간 채널 전달 ÷ 무경합 뮤텍스&lt;/div&gt;
&lt;div class="v"&gt;66&lt;span class="u"&gt;×&lt;/span&gt;&lt;/div&gt;
&lt;div class="d"&gt;103.6 ns vs 1.566 ns&lt;/div&gt;
&lt;/div&gt;
&lt;div class="tile"&gt;
&lt;div class="k"&gt;무경합 sync.Mutex&lt;/div&gt;
&lt;div class="v"&gt;1.57&lt;span class="u"&gt;ns&lt;/span&gt;&lt;/div&gt;
&lt;div class="d"&gt;CAS 1회 ≈ 7 사이클&lt;/div&gt;
&lt;/div&gt;
&lt;div class="tile"&gt;
&lt;div class="k"&gt;샤딩으로 경합 제거&lt;/div&gt;
&lt;div class="v"&gt;190&lt;span class="u"&gt;×&lt;/span&gt;&lt;/div&gt;
&lt;div class="d"&gt;53.28 ns → 0.280 ns&lt;/div&gt;
&lt;/div&gt;
&lt;div class="tile"&gt;
&lt;div class="k"&gt;거짓 공유 페널티&lt;/div&gt;
&lt;div class="v"&gt;4.8&lt;span class="u"&gt;×&lt;/span&gt;&lt;/div&gt;
&lt;div class="d"&gt;패딩 하나로 8.07 → 1.67 ns&lt;/div&gt;
&lt;/div&gt;
&lt;div class="tile"&gt;
&lt;div class="k"&gt;RWMutex 개선폭 (읽기 전용)&lt;/div&gt;
&lt;div class="v"&gt;8.6&lt;span class="u"&gt;%&lt;/span&gt;&lt;/div&gt;
&lt;div class="d"&gt;104.7 → 95.72 ns · 기대 이하&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="panel"&gt;
&lt;h3&gt;무경합 지연 — 단일 고루틴&lt;/h3&gt;
&lt;p class="sub"&gt;경합이 없을 때 각 프리미티브 1회 왕복 비용. 값의 범위가 3자릿수를 넘어 &lt;b&gt;x축은 로그 스케일&lt;/b&gt;이며, 막대 길이 대신 점의 위치로 값을 나타낸다.&lt;/p&gt;
&lt;div class="scroll"&gt;&lt;svg id="wvb-unc" role="img" aria-label="무경합 지연 도트 플롯"&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;div class="legend" id="wvb-lg1"&gt;&lt;/div&gt;
&lt;div class="note"&gt;&lt;b&gt;읽는 법 —&lt;/b&gt; 무경합 뮤텍스(1.57 ns)는 원자적 덧셈(1.64 ns)과 구별되지 않는다. 채널 비용의 대부분은 채널이 아니라 &lt;b&gt;고루틴 전환&lt;/b&gt;에서 온다: 같은 고루틴 안에서는 13.88 ns, 고루틴 경계를 넘으면 103.6 ns로 7.5배 뛴다. select 로 감싸는 것만으로 19.7 ns가 더 붙는다.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="panel"&gt;
&lt;h3&gt;경합 지연 — 14코어가 하나의 상태를 두고 경쟁&lt;/h3&gt;
&lt;p class="sub"&gt;&lt;code&gt;b.RunParallel&lt;/code&gt; 로 14개 코어가 동시에 같은 상태를 갱신할 때의 1회 비용. 같은 로그 스케일. 여기서의 ns/op 은 지연이 아니라 &lt;b&gt;처리량을 연산당으로 환산한 값&lt;/b&gt;이다.&lt;/p&gt;
&lt;div class="scroll"&gt;&lt;svg id="wvb-con" role="img" aria-label="경합 지연 도트 플롯"&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;div class="legend" id="wvb-lg2"&gt;&lt;/div&gt;
&lt;div class="note"&gt;&lt;b&gt;통념이 뒤집히는 지점 —&lt;/b&gt; 버퍼가 충분한 채널 소유자 패턴(48~79 ns)이 경합하는 뮤텍스(109.3 ns)보다 &lt;b&gt;빠르다&lt;/b&gt;. 다만 같은 축의 아래쪽이 그 이득의 출처를 알려준다: 버퍼를 0으로 줄이면 272.3 ns로 뮤텍스보다 2.5배 느려진다. 이득을 만든 것은 채널이 아니라 &lt;b&gt;버퍼&lt;/b&gt;다.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="row2"&gt;
&lt;div class="panel"&gt;
&lt;h3&gt;채널 버퍼 크기 = 백프레셔 정책&lt;/h3&gt;
&lt;p class="sub"&gt;같은 소유자 고루틴 패턴에서 버퍼 크기만 바꿨을 때의 지연.&lt;/p&gt;
&lt;div class="scroll"&gt;&lt;svg id="wvb-buf" role="img" aria-label="버퍼 크기별 지연 막대 그래프"&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;div class="note"&gt;버퍼를 키우면 처리량은 &lt;b&gt;5.6배&lt;/b&gt; 좋아지지만, 그 이득은 소비자가 밀렸을 때 쌓아둘 작업 4,096건과 맞바꾼 것이다. 버퍼 없는 채널은 생산자를 소비자 속도에 강제로 묶는 가장 강한 백프레셔 장치다.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="panel"&gt;
&lt;h3&gt;거짓 공유 — 논리적으로 무관한 두 변수&lt;/h3&gt;
&lt;p class="sub"&gt;두 고루틴이 각각 다른 &lt;code&gt;atomic.Int64&lt;/code&gt; 를 증가시킨다. 공유하는 상태는 없다.&lt;/p&gt;
&lt;div class="scroll"&gt;&lt;svg id="wvb-fs" role="img" aria-label="거짓 공유 비교 막대 그래프"&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;div class="note"&gt;아무것도 공유하지 않는 두 변수가 &lt;b&gt;같은 64바이트 캐시 라인에 있다는 이유만으로&lt;/b&gt; 4.8배 느려졌다. 소스 코드만 읽어서는 보이지 않는 비용이다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="panel"&gt;
&lt;h3&gt;왜 거짓 공유가 생기는가 — CPU 캐시 라인과 무효화&lt;/h3&gt;
&lt;p class="sub"&gt;위 4.8배가 어디서 왔는지를 CPU 캐시 관점에서 단계별로 따라간다. 이 문제는 &lt;code&gt;mutex&lt;/code&gt;·&lt;code&gt;atomic&lt;/code&gt;·&lt;code&gt;channel&lt;/code&gt; 보다 한 층 아래, &lt;b&gt;메모리가 물리적으로 어떻게 배치되는가&lt;/b&gt;의 문제다.&lt;/p&gt;
&lt;div class="persp"&gt;
&lt;div&gt;
&lt;div class="who"&gt;개발자 관점&lt;/div&gt;
&lt;div class="say"&gt;“두 변수는 독립이다”&lt;/div&gt;
&lt;div class="why"&gt;&lt;code&gt;counterA&lt;/code&gt; 와 &lt;code&gt;counterB&lt;/code&gt; 는 서로 다른 변수다. 아무도 같은 값을 건드리지 않으니 락도 필요 없고, 병렬로 돌리면 그만큼 빨라져야 한다.&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div class="who"&gt;CPU 관점&lt;/div&gt;
&lt;div class="say"&gt;“둘은 같은 데이터다”&lt;/div&gt;
&lt;div class="why"&gt;CPU 는 변수를 하나씩 다루지 않는다. &lt;b&gt;64바이트 캐시 라인&lt;/b&gt; 통째로 읽고 쓰며, 그 안에 두 변수가 같이 있으면 하나의 덩어리로 취급한다.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="note"&gt;이 &lt;b&gt;관점의 어긋남&lt;/b&gt;이 이름의 유래다. 실제로 공유하는 것은 없는데(false) CPU 에게는 공유로 보인다(sharing).&lt;/div&gt;
&lt;h4&gt;1. CPU 는 변수 단위가 아니라 64바이트 캐시 라인 단위로 읽는다&lt;/h4&gt;
&lt;p class="body"&gt;메모리에서 8바이트짜리 카운터 하나만 가져오는 일은 없다. CPU 는 언제나 캐시 라인 하나를 통째로 캐시에 올린다. 그래서 &lt;b&gt;변수를 나란히 선언하면 같은 라인에 담긴다.&lt;/b&gt;&lt;/p&gt;
&lt;div class="cl-grid"&gt;
&lt;figure class="cl-case"&gt;
&lt;figcaption class="cl-cap"&gt;&lt;span class="tag bad"&gt;문제&lt;/span&gt; 두 변수가 한 라인 안에 — &lt;code&gt;8.071 ns&lt;/code&gt;&lt;/figcaption&gt;
&lt;div class="cl-line"&gt;
&lt;div class="cl-seg a"&gt;counterA&lt;small&gt;8B&lt;/small&gt;&lt;/div&gt;
&lt;div class="cl-seg b"&gt;counterB&lt;small&gt;8B&lt;/small&gt;&lt;/div&gt;
&lt;div class="cl-seg rest"&gt;나머지 48B&lt;/div&gt;
&lt;/div&gt;
&lt;div class="cl-axis"&gt;← 캐시 라인 1개 · 64 bytes →&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class="cl-case"&gt;
&lt;figcaption class="cl-cap"&gt;&lt;span class="tag good"&gt;해결&lt;/span&gt; 패딩으로 라인 분리 — &lt;code&gt;1.668 ns&lt;/code&gt;&lt;/figcaption&gt;
&lt;div class="cl-line"&gt;
&lt;div class="cl-seg a"&gt;counterA&lt;small&gt;8B&lt;/small&gt;&lt;/div&gt;
&lt;div class="cl-seg pad"&gt;padding 56B&lt;/div&gt;
&lt;/div&gt;
&lt;div class="cl-line"&gt;
&lt;div class="cl-seg b"&gt;counterB&lt;small&gt;8B&lt;/small&gt;&lt;/div&gt;
&lt;div class="cl-seg pad"&gt;padding 56B&lt;/div&gt;
&lt;/div&gt;
&lt;div class="cl-axis"&gt;← 캐시 라인 2개 · 각 64 bytes →&lt;/div&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;h4&gt;2. 한쪽이 쓰면 다른 쪽 캐시가 통째로 무효화된다&lt;/h4&gt;
&lt;p class="body"&gt;멀티코어 CPU 는 코어마다 캐시 사본을 갖고, MESI 계열 프로토콜로 일관성을 유지한다. 어떤 코어가 라인에 &lt;b&gt;쓰기&lt;/b&gt;를 하려면 그 라인을 독점해야 하고, 다른 코어가 갖고 있던 사본은 &lt;b&gt;무효화(invalidate)&lt;/b&gt;된다. 문제는 무효화의 단위가 변수가 아니라 &lt;b&gt;라인 전체&lt;/b&gt;라는 점이다.&lt;/p&gt;
&lt;ol class="pp"&gt;
&lt;li&gt;
&lt;div class="n"&gt;STEP 1 · 시작&lt;/div&gt;
&lt;div class="core"&gt;Core 1 &lt;span class="st ok"&gt;&lt;i&gt;●&lt;/i&gt; 유효&lt;/span&gt;&lt;/div&gt;
&lt;div class="core"&gt;Core 2 &lt;span class="st ok"&gt;&lt;i&gt;●&lt;/i&gt; 유효&lt;/span&gt;&lt;/div&gt;
&lt;div class="cap"&gt;두 코어가 같은 라인의 사본을 각자 들고 있다. 여기까지는 아무 비용도 없다.&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;div class="n"&gt;STEP 2 · Core 1 쓰기&lt;/div&gt;
&lt;div class="core writing"&gt;Core 1 &lt;span class="st ok"&gt;&lt;i&gt;✎&lt;/i&gt; 수정&lt;/span&gt;&lt;/div&gt;
&lt;div class="core"&gt;Core 2 &lt;span class="st dead"&gt;&lt;i&gt;✕&lt;/i&gt; 무효&lt;/span&gt;&lt;/div&gt;
&lt;div class="cap"&gt;&lt;code&gt;counterA.Add(1)&lt;/code&gt;. Core 2 는 &lt;b&gt;counterB 만 쓰는데도&lt;/b&gt; 사본 전체를 버려야 한다.&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;div class="n"&gt;STEP 3 · Core 2 쓰기&lt;/div&gt;
&lt;div class="core"&gt;Core 1 &lt;span class="st dead"&gt;&lt;i&gt;✕&lt;/i&gt; 무효&lt;/span&gt;&lt;/div&gt;
&lt;div class="core writing"&gt;Core 2 &lt;span class="st ok"&gt;&lt;i&gt;✎&lt;/i&gt; 수정&lt;/span&gt;&lt;/div&gt;
&lt;div class="cap"&gt;&lt;code&gt;counterB.Add(1)&lt;/code&gt;. Core 2 는 라인을 다시 가져와야 하고, 이번엔 Core 1 사본이 죽는다.&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;div class="n"&gt;STEP 4 · 반복&lt;/div&gt;
&lt;div class="core"&gt;Core 1 &lt;span class="st dead"&gt;&lt;i&gt;✕&lt;/i&gt; 무효&lt;/span&gt;&lt;/div&gt;
&lt;div class="core"&gt;Core 2 &lt;span class="st dead"&gt;&lt;i&gt;✕&lt;/i&gt; 무효&lt;/span&gt;&lt;/div&gt;
&lt;div class="cap"&gt;라인 하나가 코어 사이를 &lt;b&gt;끝없이 왕복&lt;/b&gt;한다. 병렬로 돌린 두 루프가 사실상 직렬화된다.&lt;/div&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="note"&gt;&lt;b&gt;핵심 —&lt;/b&gt; 캐시 무효화는 &lt;b&gt;변수 단위가 아니라 캐시 라인 단위&lt;/b&gt;로 일어난다. 그래서 서로 다른 변수를 만져도, 같은 라인에 있으면 남의 쓰기가 내 캐시를 죽인다.&lt;/div&gt;
&lt;h4&gt;3. 그래서 패딩을 넣는다 — 왜 하필 56바이트인가&lt;/h4&gt;
&lt;p class="body"&gt;해결책은 두 변수를 &lt;b&gt;서로 다른 캐시 라인으로 밀어내는 것&lt;/b&gt;이다. 변수 뒤에 빈 공간을 채워 라인 하나를 통째로 점유하게 만든다.&lt;/p&gt;
&lt;pre class="calc"&gt;캐시 라인 64 bytes
atomic.Int64 &lt;b&gt;-&lt;/b&gt; 8 bytes
─────────────
패딩 &lt;b&gt;= 56 bytes&lt;/b&gt;&lt;/pre&gt;
&lt;p class="body" style="margin-top:10px"&gt;Go 에서는 익명 필드로 표현한다. 이름이 &lt;code&gt;_&lt;/code&gt; 라서 아무도 읽지 않지만, &lt;b&gt;메모리를 차지하는 것 자체가 목적&lt;/b&gt;이다.&lt;/p&gt;
&lt;pre class="calc"&gt;&lt;b&gt;type&lt;/b&gt; paddedCounter &lt;b&gt;struct&lt;/b&gt; {
n atomic.Int64
_ [56]&lt;b&gt;byte&lt;/b&gt; &lt;span style="opacity:.7"&gt;// 8 + 56 = 64 — 라인 하나를 혼자 쓴다&lt;/span&gt;
}&lt;/pre&gt;
&lt;div class="note"&gt;&lt;b&gt;설계 관점의 함의 —&lt;/b&gt; “락을 없애고 atomic 으로 바꿨으니 병렬성이 높아졌다”가 항상 참은 아니다. 메모리 배치 때문에 병렬 실행이 조용히 직렬화될 수 있다. 동시성 설계는 &lt;b&gt;누가 데이터를 소유하는가&lt;/b&gt;에 더해 &lt;b&gt;어떤 데이터가 함께 배치되는가&lt;/b&gt;까지 봐야 한다. 이 리포트의 &lt;b&gt;샤딩 카운터(0.280 ns)&lt;/b&gt;가 압도적으로 빠른 이유도 값을 쪼갠 것에 더해 &lt;b&gt;각 샤드에 패딩을 줘 라인을 분리했기&lt;/b&gt; 때문이다.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="panel"&gt;
&lt;h3&gt;select 는 준비된 케이스를 무작위로 고른다&lt;/h3&gt;
&lt;p class="sub"&gt;두 채널을 각각 1,000개의 값으로 가득 채운 뒤 &lt;code&gt;select&lt;/code&gt; 를 1,000회 실행하고, 어느 케이스가 선택됐는지 셌다. 3회 반복.&lt;/p&gt;
&lt;div class="scroll"&gt;&lt;svg id="wvb-sel" role="img" aria-label="select 케이스 선택 분포"&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;div class="legend" id="wvb-lg3"&gt;&lt;/div&gt;
&lt;div class="note"&gt;&lt;b&gt;실무 함의 —&lt;/b&gt; 세 번 모두 50 : 50 에 수렴한다. &lt;code&gt;case &amp;lt;-ctx.Done():&lt;/code&gt; 를 맨 위에 쓴다고 먼저 검사되지 않는다. 우선순위가 필요하면 중첩 select 와 &lt;code&gt;default&lt;/code&gt; 로 명시해야 한다.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="panel"&gt;
&lt;h3&gt;전체 측정값 (table view)&lt;/h3&gt;
&lt;p class="sub"&gt;모든 차트의 원본 데이터. 3회 샘플과 중앙값, 단위 ns/op.&lt;/p&gt;
&lt;div class="scroll"&gt;&lt;table id="wvb-tbl"&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;벤치마크&lt;/th&gt;&lt;th&gt;조건&lt;/th&gt;&lt;th&gt;#1&lt;/th&gt;&lt;th&gt;#2&lt;/th&gt;&lt;th&gt;#3&lt;/th&gt;&lt;th&gt;중앙값&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p class="foot"&gt;측정 환경 Apple M4 Pro (Mac16,11) · macOS · Go 1.26.5 darwin/arm64 — &lt;code&gt;GOMAXPROCS=14&lt;/code&gt;.
ns/op 은 반복 1회당 평균 소요 시간이며, 절대값은 하드웨어에 따라 달라진다. 이 리포트가 말하는 것은 절대값이 아니라 &lt;b&gt;프리미티브 사이의 상대 비율&lt;/b&gt;이다.&lt;/p&gt;
&lt;div id="wvb-tip" role="status" aria-live="polite"&gt;&lt;/div&gt;
&lt;script&gt;
(function () {
"use strict";
var ROOT = document.querySelector(".wv-bench");
if (!ROOT || ROOT.dataset.wvbInit) return;
ROOT.dataset.wvbInit = "1";
function cv(n) { return getComputedStyle(ROOT).getPropertyValue(n).trim(); }
var FAM = {
mutex: { name: "sync.Mutex / RWMutex", varn: "--mutex" },
channel: { name: "channel", varn: "--channel" },
atomic: { name: "sync/atomic", varn: "--atomic" },
runtime: { name: "런타임 참고값", varn: "--runtime" }
};
var UNC = [
{ label: "sync.Mutex Lock + Unlock", v: 1.566, fam: "mutex" },
{ label: "atomic.Int64.Add", v: 1.635, fam: "atomic" },
{ label: "버퍼 채널 송신 + 수신 (동일 고루틴)", v: 13.88, fam: "channel" },
{ label: "select 로 감싼 송신 + 수신", v: 33.57, fam: "channel" },
{ label: "버퍼 없는 채널 — 고루틴 간 전달", v: 103.6, fam: "channel" },
{ label: "고루틴 생성 + 종료", v: 332.8, fam: "runtime" }
];
var CON = [
{ label: "샤딩 atomic (코어별 카운터 + 패딩)", v: 0.280, fam: "atomic" },
{ label: "채널 소유자 고루틴 (버퍼 4096)", v: 48.35, fam: "channel" },
{ label: "atomic.Int64.Add (단일 카운터)", v: 53.28, fam: "atomic" },
{ label: "채널 소유자 고루틴 (버퍼 128)", v: 79.48, fam: "channel" },
{ label: "sync.RWMutex RLock + RUnlock", v: 95.72, fam: "mutex" },
{ label: "sync.Mutex Lock + Unlock (읽기)", v: 104.7, fam: "mutex" },
{ label: "sync.Mutex Lock + Unlock (카운터)", v: 109.3, fam: "mutex" },
{ label: "채널 소유자 고루틴 (버퍼 1)", v: 230.1, fam: "channel" },
{ label: "채널 소유자 고루틴 (버퍼 0)", v: 272.3, fam: "channel" }
];
var BUF = [
{ k: "0", v: 272.3, ord: "--o1" }, { k: "1", v: 230.1, ord: "--o2" },
{ k: "128", v: 79.48, ord: "--o3" }, { k: "4096", v: 48.35, ord: "--o4" }
];
var FS = [{ k: "같은 캐시 라인", v: 8.071 }, { k: "56B 패딩으로 분리", v: 1.668 }];
var FAIR = [{ run: "run 1", a: 523, b: 477 }, { run: "run 2", a: 494, b: 506 }, { run: "run 3", a: 505, b: 495 }];
var TABLE = [
["MutexUncontended","무경합",1.566,1.551,1.570,1.566,"mutex"],
["AtomicUncontended","무경합",1.644,1.630,1.635,1.635,"atomic"],
["ChanBufferedSelfLoop","무경합",13.83,13.88,13.95,13.88,"channel"],
["SelectSelfLoop","무경합",33.80,33.57,33.17,33.57,"channel"],
["ChanUnbufferedHandoff","무경합",103.6,103.1,105.2,103.6,"channel"],
["GoroutineSpawn","무경합",324.7,332.8,337.3,332.8,"runtime"],
["CounterShardedAtomic","14코어",0.2685,0.2800,0.3002,0.280,"atomic"],
["OwnerBuf4096","14코어",48.35,48.23,49.90,48.35,"channel"],
["CounterAtomicContended","14코어",53.31,53.28,51.93,53.28,"atomic"],
["OwnerBuf128","14코어",79.06,79.60,79.48,79.48,"channel"],
["CounterChannelOwner","14코어",80.99,80.96,80.72,80.96,"channel"],
["RWMutexReadContended","14코어",97.60,95.72,93.11,95.72,"mutex"],
["MutexReadContended","14코어",104.7,103.4,106.1,104.7,"mutex"],
["CounterMutexContended","14코어",109.3,111.1,107.1,109.3,"mutex"],
["OwnerBuf1","14코어",230.1,227.2,236.4,230.1,"channel"],
["OwnerBuf0","14코어",272.3,277.5,272.0,272.3,"channel"],
["FalseSharing","2고루틴",8.071,7.997,8.080,8.071,"atomic"],
["NoFalseSharing","2고루틴",1.668,1.680,1.664,1.668,"atomic"]
];
var NS = "http://www.w3.org/2000/svg";
function el(n, a) { var e = document.createElementNS(NS, n); for (var k in a) if (a.hasOwnProperty(k)) e.setAttribute(k, a[k]); return e; }
function txt(s) { return document.createTextNode(s); }
function fmt(v) { return v &lt; 1 ? v.toFixed(3) : (v &lt; 100 ? v.toFixed(2) : v.toFixed(1)); }
var tip = document.getElementById("wvb-tip");
function showTip(e, value, label, color) {
while (tip.firstChild) tip.removeChild(tip.firstChild);
var v = document.createElement("div"); v.className = "tv"; v.appendChild(txt(value));
var l = document.createElement("div"); l.className = "tl";
var k = document.createElement("span"); k.className = "tk"; k.style.background = color;
l.appendChild(k); l.appendChild(txt(label));
tip.appendChild(v); tip.appendChild(l);
tip.style.opacity = "1"; moveTip(e);
}
function moveTip(e) {
var r = tip.getBoundingClientRect(), nx = e.clientX + 14, ny = e.clientY + 14;
if (nx + r.width &gt; window.innerWidth - 8) nx = e.clientX - r.width - 14;
if (ny + r.height &gt; window.innerHeight - 8) ny = e.clientY - r.height - 14;
tip.style.left = Math.max(8, nx) + "px"; tip.style.top = Math.max(8, ny) + "px";
}
function hideTip() { tip.style.opacity = "0"; }
function bindTip(node, value, label, color) {
node.addEventListener("pointerenter", function (e) { showTip(e, value, label, color); });
node.addEventListener("pointermove", moveTip);
node.addEventListener("pointerleave", hideTip);
node.setAttribute("tabindex", "0");
node.addEventListener("focus", function () {
var b = node.getBoundingClientRect();
showTip({ clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }, value, label, color);
});
node.addEventListener("blur", hideTip);
}
function dotPlot(id, data) {
var svg = document.getElementById(id);
var W = 760, GUT = 236, VALW = 76, x0 = GUT, x1 = W - VALW;
var ROW = 27, TOP = 26, AXIS = 30, H = TOP + data.length * ROW + AXIS;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.setAttribute("style", "max-width:" + W + "px");
var lo = 0.1, hi = 1000;
function sx(v) { return x0 + (Math.log10(v) - Math.log10(lo)) / (Math.log10(hi) - Math.log10(lo)) * (x1 - x0); }
var grid = cv("--grid"), muted = cv("--tm"), sec = cv("--ts"), pri = cv("--tp"), surf = cv("--s1");
[0.1, 1, 10, 100, 1000].forEach(function (t) {
svg.appendChild(el("line", { x1: sx(t), x2: sx(t), y1: TOP - 10, y2: TOP + data.length * ROW, stroke: grid, "stroke-width": 1 }));
var lb = el("text", { x: sx(t), y: TOP + data.length * ROW + 16, fill: muted, "font-size": 10.5, "text-anchor": "middle", style: "font-variant-numeric:tabular-nums" });
lb.appendChild(txt(t &lt; 1 ? "0.1" : String(t))); svg.appendChild(lb);
});
var unit = el("text", { x: x1, y: TOP + data.length * ROW + 30, fill: muted, "font-size": 10.5, "text-anchor": "end" });
unit.appendChild(txt("ns/op — 로그 스케일")); svg.appendChild(unit);
data.forEach(function (d, i) {
var cy = TOP + i * ROW + ROW / 2, color = cv(FAM[d.fam].varn);
svg.appendChild(el("line", { x1: x0, x2: x1, y1: cy, y2: cy, stroke: grid, "stroke-width": 1 }));
var lb = el("text", { x: x0 - 12, y: cy + 3.6, fill: sec, "font-size": 11.5, "text-anchor": "end" });
lb.appendChild(txt(d.label)); svg.appendChild(lb);
svg.appendChild(el("circle", { cx: sx(d.v), cy: cy, r: 5.5, fill: color, stroke: surf, "stroke-width": 2 }));
var vl = el("text", { x: W - 6, y: cy + 3.6, fill: pri, "font-size": 11.5, "text-anchor": "end", style: "font-variant-numeric:tabular-nums" });
vl.appendChild(txt(fmt(d.v))); svg.appendChild(vl);
var hit = el("rect", { x: 0, y: cy - ROW / 2, width: W, height: ROW, "class": "hit" });
bindTip(hit, fmt(d.v) + " ns/op", d.label + " · " + FAM[d.fam].name, color);
svg.appendChild(hit);
});
}
function columns(id, data) {
var svg = document.getElementById(id);
var W = 420, H = 214, L = 40, R = 12, T = 18, B = 44;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
var pw = W - L - R, ph = H - T - B, max = 300;
var grid = cv("--grid"), muted = cv("--tm"), pri = cv("--tp"), sec = cv("--ts");
[0, 100, 200, 300].forEach(function (t) {
var y = T + ph - (t / max) * ph;
svg.appendChild(el("line", { x1: L, x2: W - R, y1: y, y2: y, stroke: grid, "stroke-width": 1 }));
var lb = el("text", { x: L - 7, y: y + 3.4, fill: muted, "font-size": 10, "text-anchor": "end", style: "font-variant-numeric:tabular-nums" });
lb.appendChild(txt(String(t))); svg.appendChild(lb);
});
var band = pw / data.length, bw = Math.min(24, band - 18);
data.forEach(function (d, i) {
var cx = L + band * i + band / 2, h = (d.v / max) * ph, y = T + ph - h, x = cx - bw / 2, r = 4;
var color = cv(d.ord);
svg.appendChild(el("path", { d: "M" + x + "," + (y + h) + " V" + (y + r) + " a" + r + "," + r + " 0 0 1 " + r + ",-" + r + " h" + (bw - 2 * r) + " a" + r + "," + r + " 0 0 1 " + r + "," + r + " V" + (y + h) + " Z", fill: color }));
var vl = el("text", { x: cx, y: y - 6, fill: pri, "font-size": 11, "text-anchor": "middle", style: "font-variant-numeric:tabular-nums" });
vl.appendChild(txt(fmt(d.v))); svg.appendChild(vl);
var kl = el("text", { x: cx, y: T + ph + 15, fill: sec, "font-size": 11, "text-anchor": "middle", style: "font-variant-numeric:tabular-nums" });
kl.appendChild(txt(d.k)); svg.appendChild(kl);
var hit = el("rect", { x: L + band * i, y: T, width: band, height: ph, "class": "hit" });
bindTip(hit, fmt(d.v) + " ns/op", "버퍼 크기 " + d.k, color); svg.appendChild(hit);
});
var ax = el("text", { x: L + pw / 2, y: H - 6, fill: muted, "font-size": 10.5, "text-anchor": "middle" });
ax.appendChild(txt("채널 버퍼 크기 → (세로축 ns/op)")); svg.appendChild(ax);
}
function hbars(id, data) {
var svg = document.getElementById(id);
var W = 420, ROW = 46, T = 10, B = 30, L = 132, R = 54, H = T + data.length * ROW + B;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
var pw = W - L - R, max = 9;
var grid = cv("--grid"), muted = cv("--tm"), pri = cv("--tp"), sec = cv("--ts"), color = cv("--atomic");
[0, 3, 6, 9].forEach(function (t) {
var x = L + (t / max) * pw;
svg.appendChild(el("line", { x1: x, x2: x, y1: T, y2: T + data.length * ROW, stroke: grid, "stroke-width": 1 }));
var lb = el("text", { x: x, y: T + data.length * ROW + 15, fill: muted, "font-size": 10, "text-anchor": "middle", style: "font-variant-numeric:tabular-nums" });
lb.appendChild(txt(String(t))); svg.appendChild(lb);
});
data.forEach(function (d, i) {
var cy = T + i * ROW + ROW / 2, bh = 22, y = cy - bh / 2, w = (d.v / max) * pw, r = 4;
svg.appendChild(el("path", { d: "M" + L + "," + y + " H" + (L + w - r) + " a" + r + "," + r + " 0 0 1 " + r + "," + r + " v" + (bh - 2 * r) + " a" + r + "," + r + " 0 0 1 -" + r + "," + r + " H" + L + " Z", fill: color, opacity: i === 0 ? 1 : 0.55 }));
var lb = el("text", { x: L - 10, y: cy + 3.6, fill: sec, "font-size": 11.5, "text-anchor": "end" });
lb.appendChild(txt(d.k)); svg.appendChild(lb);
var vl = el("text", { x: L + w + 8, y: cy + 3.6, fill: pri, "font-size": 11.5, style: "font-variant-numeric:tabular-nums" });
vl.appendChild(txt(fmt(d.v))); svg.appendChild(vl);
var hit = el("rect", { x: 0, y: cy - ROW / 2, width: W, height: ROW, "class": "hit" });
bindTip(hit, fmt(d.v) + " ns/op", d.k, color); svg.appendChild(hit);
});
var ax = el("text", { x: L + pw / 2, y: H - 4, fill: muted, "font-size": 10.5, "text-anchor": "middle" });
ax.appendChild(txt("ns/op")); svg.appendChild(ax);
}
function fairness(id, data) {
var svg = document.getElementById(id);
var W = 760, ROW = 40, T = 22, B = 26, L = 62, R = 12, H = T + data.length * ROW + B;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
var pw = W - L - R, total = 1000, GAP = 2;
var muted = cv("--tm"), sec = cv("--ts"), axisC = cv("--axis"), cA = cv("--mutex"), cB = cv("--channel");
var mid = L + pw / 2;
svg.appendChild(el("line", { x1: mid, x2: mid, y1: T - 8, y2: T + data.length * ROW + 2, stroke: axisC, "stroke-width": 1 }));
var ml = el("text", { x: mid, y: T - 12, fill: muted, "font-size": 10, "text-anchor": "middle" });
ml.appendChild(txt("50%")); svg.appendChild(ml);
data.forEach(function (d, i) {
var cy = T + i * ROW + ROW / 2, bh = 22, y = cy - bh / 2;
var wA = (d.a / total) * pw, wB = (d.b / total) * pw;
svg.appendChild(el("rect", { x: L, y: y, width: Math.max(0, wA - GAP), height: bh, fill: cA, rx: 2 }));
svg.appendChild(el("rect", { x: L + wA, y: y, width: Math.max(0, wB), height: bh, fill: cB, rx: 2 }));
var lb = el("text", { x: L - 10, y: cy + 3.6, fill: sec, "font-size": 11.5, "text-anchor": "end" });
lb.appendChild(txt(d.run)); svg.appendChild(lb);
var la = el("text", { x: L + 10, y: cy + 4, fill: "#fff", "font-size": 11.5, "font-weight": 600, style: "font-variant-numeric:tabular-nums" });
la.appendChild(txt(String(d.a))); svg.appendChild(la);
var lv = el("text", { x: W - R - 10, y: cy + 4, fill: "#fff", "font-size": 11.5, "font-weight": 600, "text-anchor": "end", style: "font-variant-numeric:tabular-nums" });
lv.appendChild(txt(String(d.b))); svg.appendChild(lv);
var hA = el("rect", { x: L, y: cy - ROW / 2, width: wA, height: ROW, "class": "hit" });
bindTip(hA, d.a + " / 1,000 회", d.run + " · 첫 번째 case 선택", cA); svg.appendChild(hA);
var hB = el("rect", { x: L + wA, y: cy - ROW / 2, width: wB, height: ROW, "class": "hit" });
bindTip(hB, d.b + " / 1,000 회", d.run + " · 두 번째 case 선택", cB); svg.appendChild(hB);
});
var ax = el("text", { x: L, y: H - 6, fill: muted, "font-size": 10.5 });
ax.appendChild(txt("select 1,000회 실행당 케이스 선택 횟수")); svg.appendChild(ax);
}
function legend(id, items, shape) {
var box = document.getElementById(id);
items.forEach(function (it) {
var li = document.createElement("span"); li.className = "li";
var sw = document.createElement("span"); sw.className = "sw" + (shape === "rect" ? " rect" : "");
sw.style.background = cv(it.varn);
li.appendChild(sw); li.appendChild(txt(it.name)); box.appendChild(li);
});
}
function table() {
var tb = ROOT.querySelector("#wvb-tbl tbody");
TABLE.forEach(function (r) {
var tr = document.createElement("tr");
var td0 = document.createElement("td");
var dot = document.createElement("span"); dot.className = "fam"; dot.style.background = cv(FAM[r[6]].varn);
td0.appendChild(dot); td0.appendChild(txt(r[0])); tr.appendChild(td0);
var td1 = document.createElement("td"); td1.style.textAlign = "right"; td1.appendChild(txt(r[1])); tr.appendChild(td1);
[2, 3, 4].forEach(function (i) {
var td = document.createElement("td"); td.className = "num"; td.appendChild(txt(fmt(r[i]))); tr.appendChild(td);
});
var tdm = document.createElement("td"); tdm.className = "med"; tdm.appendChild(txt(fmt(r[5]))); tr.appendChild(tdm);
tb.appendChild(tr);
});
}
dotPlot("wvb-unc", UNC);
dotPlot("wvb-con", CON);
columns("wvb-buf", BUF);
hbars("wvb-fs", FS);
fairness("wvb-sel", FAIR);
legend("wvb-lg1", [FAM.mutex, FAM.atomic, FAM.channel, FAM.runtime]);
legend("wvb-lg2", [FAM.mutex, FAM.atomic, FAM.channel]);
legend("wvb-lg3", [{ name: "첫 번째 case", varn: "--mutex" }, { name: "두 번째 case", varn: "--channel" }], "rect");
table();
})();
&lt;/script&gt;
&lt;/section&gt;
&lt;p&gt;버퍼를 0으로 줄이면 272.3 ns로, 뮤텍스보다 2.5배 느려진다.
채널이 경합 상황에서 뮤텍스보다 빨랐다면 그 이득은 채널이 아니라 버퍼가 만들어낸 것이며, 버퍼는 처리량을 사는 대신 지연을 숨긴다.&lt;/p&gt;
&lt;p&gt;경합을 구조적으로 없애면 어떻게 되는지도 위 표에 있다. 코어별로 카운터를 쪼개고 캐시 라인 패딩을 준 샤딩 방식은 0.280 ns/op로, 단일 원자 카운터(53.28 ns) 대비 약 190배 빨랐다. (이 항목은 값 자체가 작아 실행별 편차가 큰 편이라, 반복 측정에서는 160~190배 범위로 나온다. 정확한 상수가 아니라 자릿수로 읽는 편이 맞다.) Go 런타임과 표준 라이브러리가 sync.Pool의 per-P 캐시, sync.Map의 read/dirty 분리, 메모리 할당자의 mcache처럼 곳곳에서 이 전략을 쓰는 이유가 여기에 있다.
&lt;mark&gt;&lt;em&gt;&lt;strong&gt;경합하는 락을 최적화하는 가장 좋은 방법은 대개 락을 더 빠르게 만드는 것이 아니라, 경합할 이유를 없애도록 상태를 쪼개는 것이다.&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;&lt;/p&gt;
&lt;h2 id="select---뮤텍스에는-존재하지-않는-연산"&gt;select - 뮤텍스에는 존재하지 않는 연산&lt;/h2&gt;
&lt;p&gt;select는 여러 채널 연산 중 준비된 것 하나를 무작위로 골라 실행하고, 아무것도 준비되지 않았으면 모든 채널의 대기열에 동시에 등록한 뒤 잠드는 연산이며, 뮤텍스에는 이에 대응하는 연산이 아예 존재하지 않는다.&lt;/p&gt;
&lt;p&gt;이 문장이 이번 글의 proverb를 잘 설명한다. 상호 배제라는 추상화에는 애초에 &amp;ldquo;여러 사건 중 하나를 기다린다&amp;quot;는 개념이 들어 있지 않다. 바로 이 지점에서 뮤텍스는 serialize에 머물고 채널은 orchestrate로 넘어간다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;runtime.selectgo&lt;/code&gt;의 구현을 보면 select가 두 개의 순서 배열을 만드는 것을 볼 수 있다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// src/runtime/select.go&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;pollorder&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;order1&lt;/span&gt;[:&lt;span style="color:#a6e22e"&gt;ncases&lt;/span&gt;:&lt;span style="color:#a6e22e"&gt;ncases&lt;/span&gt;] &lt;span style="color:#75715e"&gt;// 어떤 순서로 케이스를 검사할 것인가&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;lockorder&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;order1&lt;/span&gt;[&lt;span style="color:#a6e22e"&gt;ncases&lt;/span&gt;:][:&lt;span style="color:#a6e22e"&gt;ncases&lt;/span&gt;:&lt;span style="color:#a6e22e"&gt;ncases&lt;/span&gt;] &lt;span style="color:#75715e"&gt;// 어떤 순서로 채널을 잠글 것인가&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;두 배열은 정반대의 목적을 갖는다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;lockorder&lt;/code&gt;는 &lt;strong&gt;채널의 메모리 주소를 기준으로 정렬&lt;/strong&gt;된다(소스에서는 힙 정렬로 &lt;code&gt;sortkey()&lt;/code&gt; 순서를 만든다). select는 여러 채널의 락을 동시에 쥐어야 하는데, 두 고루틴이 서로 다른 순서로 같은 채널 두 개를 잠그면 그 자체로 데드락이 된다.
이것은 운영체제 교과서가 데드락 예방 기법으로 가르치는 **자원에 전역 순서를 부여하기(ordered resource allocation)**를 언어 런타임이 그대로 구현한 사례다. 개발자가 select 케이스를 어떤 순서로 쓰든, 런타임은 항상 주소 오름차순으로 잠근다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pollorder&lt;/code&gt;는 반대로 &lt;strong&gt;Fisher-Yates 셔플로 무작위화&lt;/strong&gt;된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// src/runtime/select.go&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;j&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;cheaprandn&lt;/span&gt;(uint32(&lt;span style="color:#a6e22e"&gt;norder&lt;/span&gt; &lt;span style="color:#f92672"&gt;+&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;))
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;pollorder&lt;/span&gt;[&lt;span style="color:#a6e22e"&gt;norder&lt;/span&gt;] = &lt;span style="color:#a6e22e"&gt;pollorder&lt;/span&gt;[&lt;span style="color:#a6e22e"&gt;j&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;pollorder&lt;/span&gt;[&lt;span style="color:#a6e22e"&gt;j&lt;/span&gt;] = uint16(&lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;이유는 공정성이다. 만약 select가 항상 첫 번째 케이스부터 검사한다면, 두 채널이 모두 계속 준비된 상황에서 아래쪽 케이스는 영원히 선택되지 않는다. 기아가 언어 문법 수준에서 발생하는 셈이다.
실제로 그런지 확인해봤다. 두 채널을 각각 1000개의 값으로 가득 채운 뒤 select를 1000번 실행하고 어느 케이스가 선택됐는지 세었다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;SELECT-FAIRNESS: first-case=523 second-case=477
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;SELECT-FAIRNESS: first-case=494 second-case=506
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;SELECT-FAIRNESS: first-case=505 second-case=495
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;세 번 모두 50 대 50에 수렴한다. 균등 분포다. &lt;mark&gt;&lt;em&gt;&lt;strong&gt;select 문에서 케이스를 쓰는 순서는 우선순위가 아니며, 준비된 케이스가 여럿이면 Go 런타임은 그중 하나를 균등한 확률로 무작위 선택한다.&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;
이는 실무에서 자주 오해되는 지점이다. &amp;ldquo;종료 신호를 먼저 확인하도록 &lt;code&gt;case &amp;lt;-ctx.Done():&lt;/code&gt;을 맨 위에 쓴다&amp;quot;는 코드는 의도대로 동작하지 않는다. 작업 채널에도 값이 있다면 절반의 확률로 작업이 먼저 처리된다. 우선순위가 필요하면 중첩 select와 &lt;code&gt;default&lt;/code&gt;로 명시적으로 표현해야 한다.&lt;/p&gt;
&lt;p&gt;아무 케이스도 준비되지 않았을 때의 동작은 더 인상적이다. select는 &lt;strong&gt;모든 채널의 대기열에 자기 자신을 &lt;code&gt;sudog&lt;/code&gt;로 등록한 뒤 한 번 잠들고&lt;/strong&gt;, 어느 채널에서든 깨어나면 나머지 모든 대기열에서 자신을 제거한다.
하나의 고루틴이 N개의 사건을 동시에 기다리는 이 구조가, 뮤텍스로는 흉내 낼 수 없는 조율 능력의 실체다.&lt;/p&gt;
&lt;h2 id="채널만이-답할-수-있는-질문들---취소-타임아웃-백프레셔"&gt;채널만이 답할 수 있는 질문들 - 취소, 타임아웃, 백프레셔&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;취소(cancellation), 타임아웃(timeout), 백프레셔(backpressure)는 모두 &amp;ldquo;언제 진행하고 언제 멈출 것인가&amp;quot;라는 시간 축의 문제이며, 상호 배제만 제공하는 뮤텍스로는 표현 자체가 불가능하다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="취소는-브로드캐스트다"&gt;취소는 브로드캐스트다&lt;/h3&gt;
&lt;p&gt;닫힌 채널에서의 수신은 즉시, 그리고 &lt;strong&gt;몇 번이든&lt;/strong&gt; 성공한다. 이 성질 하나로 채널은 1:N 브로드캐스트 신호가 된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;done&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt;{})
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;100&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;worker&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;done&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 100개의 고루틴이 같은 채널을 바라본다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;close(&lt;span style="color:#a6e22e"&gt;done&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 단 한 번의 close로 100개 전부에게 동시에 신호가 간다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;context.Context&lt;/code&gt;가 하는 일의 본질이 정확히 이것이다. &lt;code&gt;ctx.Done()&lt;/code&gt;은 채널을 반환하고, 취소는 그 채널을 닫는 동작이며, 부모 컨텍스트의 취소가 자식들에게 전파되는 것도 닫힌 채널의 관찰 가능성이 전파되는 것이다.
같은 일을 뮤텍스로 하려면 취소 플래그를 락으로 보호하고 모든 고루틴이 주기적으로 그것을 폴링해야 한다. 폴링 주기만큼 반응이 늦고, 블로킹 중인 고루틴은 폴링조차 못 한다.&lt;/p&gt;
&lt;h3 id="타임아웃은-케이스-하나다"&gt;타임아웃은 케이스 하나다&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;select&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;case&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;res&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;resultCh&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;res&lt;/span&gt;, &lt;span style="color:#66d9ef"&gt;nil&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;case&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;time&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;After&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;500&lt;/span&gt; &lt;span style="color:#f92672"&gt;*&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;time&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Millisecond&lt;/span&gt;):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;nil&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;errors&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;New&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;timeout&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;sync.Mutex&lt;/code&gt;에는 &lt;code&gt;TryLock()&lt;/code&gt;은 있어도(Go 1.18+) &amp;ldquo;500ms 동안만 기다린다&amp;quot;는 연산이 없다. 표준 라이브러리 문서가 &lt;code&gt;TryLock&lt;/code&gt;에 대해 남긴 경고도 인상적이다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;correct uses of TryLock do exist, they are rare, and use of TryLock is often a sign of a deeper problem&amp;rdquo;&lt;/p&gt;
&lt;p&gt;— &lt;a href="https://go.dev/src/sync/mutex.go"&gt;Go 표준 라이브러리, sync.Mutex.TryLock 문서&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;시간을 다루려는 시도가 뮤텍스 위에서 나타나면, 그것은 대개 도구를 잘못 골랐다는 신호다.&lt;/p&gt;
&lt;h3 id="버퍼-크기는-백프레셔-정책이다"&gt;버퍼 크기는 백프레셔 정책이다&lt;/h3&gt;
&lt;p&gt;Go 채널을 사용할 때 버퍼 크기를 단순히 &amp;ldquo;성능을 높이기 위한 숫자&amp;quot;로 생각하기 쉽다. 하지만 채널의 버퍼 크기는 더 근본적인 설계 결정이다.&lt;/p&gt;
&lt;p&gt;채널 버퍼 크기는 소비자가 처리하지 못하는 상황에서 시스템이 얼마나 많은 작업을 메모리에 쌓아둘 것인지 결정하는 값이다.&lt;/p&gt;
&lt;p&gt;즉, 버퍼의 크기는 처리량만 결정하는 것이 아니라 시스템이 허용하는 대기 작업량과 백프레셔(backpressure)가 시작되는 지점을 결정한다.&lt;/p&gt;
&lt;p&gt;큐잉 이론의 리틀의 법칙(Little&amp;rsquo;s Law)은 이 관계를 설명하는 데 도움을 준다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;L = λW
&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;L&lt;/code&gt; : 시스템 안에 평균적으로 존재하는 작업 수&lt;/li&gt;
&lt;li&gt;&lt;code&gt;λ&lt;/code&gt; : 작업이 들어오는 속도&lt;/li&gt;
&lt;li&gt;&lt;code&gt;W&lt;/code&gt; : 작업이 머무는 평균 시간&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;채널 버퍼는 &lt;code&gt;L&lt;/code&gt;의 평균값을 직접 결정하는 것은 아니지만, 시스템이 허용할 수 있는 **대기 작업량의 상한(capacity)**을 정한다.&lt;/p&gt;
&lt;p&gt;예를 들어 아래 코드는 단순히 &amp;ldquo;성능을 위해 100개를 저장한다&amp;quot;는 의미가 아니다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;jobs&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Job&lt;/span&gt;, &lt;span style="color:#ae81ff"&gt;100&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;정확히는 &amp;ldquo;소비자가 잠시 느려지더라도 최대 100개의 작업까지는 생산자가 계속 진행할 수 있도록 허용한다&amp;quot;라는 시스템 정책이다.&lt;/p&gt;
&lt;p&gt;생산자가 빠르고 소비자가 느린 상황에서도 버퍼가 있으면 생산자는 일정 시간 동안 계속 진행할 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 중요한 점은 &lt;strong&gt;버퍼가 문제를 제거하는 것이 아니라 뒤로 미룬다는 것&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;버퍼가 크면 처리량은 좋아질 수 있고, 생산자는 더 오래 멈추지 않으며 순간적인 부하를 흡수할 수 있다.
대신 단점으로는 메모리 사용량 증가, 작업 처리 지연 증가, 장애 발생 시 더 많은 미처리 작업 손실 가능성이 있다.&lt;/p&gt;
&lt;h2 id="채널로-뮤텍스를-대체하면-무엇이-나빠지는가"&gt;채널로 뮤텍스를 대체하면 무엇이 나빠지는가&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;단순한 공유 상태를 보호하려고 채널과 전용 고루틴을 도입하면, 상호 배제 하나를 해결하는 대가로 고루틴 생명주기 관리라는 더 어려운 문제를 새로 떠안게 된다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://whitevision.dev/posts/go-shared-memory-by-communicating/"&gt;지난 글&lt;/a&gt;에 나온 &lt;code&gt;stockKeeper&lt;/code&gt; 같은 소유자 고루틴 패턴은 강력하지만, 단순히 맵 하나를 보호하는 용도로 쓰면 다음 문제들이 새로 생긴다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// 안티패턴 — 카운터 하나를 위해 고루틴과 채널 세 개를 도입한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;type&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Counter&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;incCh&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;getCh&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;closeCh&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt;{}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;고루틴 누수(goroutine leak)&lt;/strong&gt; — 소유자 고루틴을 누가 언제 종료시키는가. 종료를 잊으면 프로세스가 살아있는 내내 남는다. 뮤텍스로 보호한 필드에는 애초에 종료할 대상이 없다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;응답 채널 관리&lt;/strong&gt; — 값을 읽어오려면 요청마다 reply 채널을 만들어 실어 보내야 한다. 위 실측에서 채널 왕복은 100 ns 대이고, 뮤텍스로 읽으면 1.6 ns 대다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;에러와 패닉 전파&lt;/strong&gt; — 소유자 고루틴 안에서 패닉이 나면 프로세스 전체가 죽거나, &lt;code&gt;recover&lt;/code&gt;로 살려도 그 뒤로 모든 요청이 영원히 블로킹된다. 뮤텍스는 &lt;code&gt;defer mu.Unlock()&lt;/code&gt; 한 줄로 패닉 안전성을 얻는다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;디버깅 난이도&lt;/strong&gt; — 뮤텍스로 보호된 임계 영역은 코드가 물리적으로 인접해 있어 눈으로 읽을 수 있다. 채널로 흩어놓으면 스택 트레이스가 &amp;ldquo;채널에서 대기 중&amp;quot;까지만 알려준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;반대 방향의 실수도 똑같이 흔하다. 조율 문제를 뮤텍스와 &lt;code&gt;sync.Cond&lt;/code&gt;로 풀려는 시도는 조건 변수의 고전적 함정을 전부 다시 만난다. 대기 전에 조건을 검사하고 잠드는 사이의 틈에서 신호를 놓치는 &lt;strong&gt;잃어버린 깨움(lost wakeup)&lt;/strong&gt;, 그래서 &lt;code&gt;Wait()&lt;/code&gt;를 반드시 &lt;code&gt;for&lt;/code&gt; 루프로 감싸야 한다는 규칙, &lt;code&gt;Signal&lt;/code&gt;과 &lt;code&gt;Broadcast&lt;/code&gt; 중 무엇을 써야 하는지의 판단 같은 것들이다.
Go 팀이 &lt;code&gt;sync.Cond&lt;/code&gt;를 거의 홍보하지 않고 대부분의 문서에서 채널을 권하는 이유가 여기에 있다.&lt;/p&gt;
&lt;h2 id="그래서-언제-무엇을-쓰는가"&gt;그래서 언제 무엇을 쓰는가&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;보호하려는 대상이 특정 자료구조의 상태라면 뮤텍스를, 설계하려는 대상이 실행 흐름들 사이의 시점과 데이터의 이동이라면 채널을 쓴다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;실전에서 &lt;strong&gt;잘 설계된 Go 프로그램은 채널과 뮤텍스 중 하나를 고르지 않고, 바깥 계층의 흐름 제어는 채널로, 안쪽 계층의 상태 보호는 뮤텍스로 나누어 쓴다.&lt;/strong&gt;
표준 라이브러리 자체가 이 구조를 따른다. &lt;code&gt;net/http&lt;/code&gt;의 &lt;code&gt;Server&lt;/code&gt;는 연결마다 고루틴을 띄우고 종료·취소는 채널과 컨텍스트로 조율하지만, 서버 내부의 활성 연결 집합이나 상태 맵은 평범한 &lt;code&gt;sync.Mutex&lt;/code&gt;로 보호한다.&lt;/p&gt;
&lt;p&gt;아래 계층 분리가된 예제를 살펴보자.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// Metrics는 여러 워커가 갱신하는 집계 상태다 — 뮤텍스로 보호한다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;type&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Metrics&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Mutex&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;success&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;failure&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; (&lt;span style="color:#a6e22e"&gt;m&lt;/span&gt; &lt;span style="color:#f92672"&gt;*&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;Metrics&lt;/span&gt;) &lt;span style="color:#a6e22e"&gt;Record&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;ok&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;m&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Lock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;m&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Unlock&lt;/span&gt;() &lt;span style="color:#75715e"&gt;// 패닉이 나도 반드시 풀린다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;ok&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;m&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;success&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;m&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;failure&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// Run은 작업의 흐름을 다룬다 — 채널로 조율한다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Run&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;ctx&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;context&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Context&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;jobs&lt;/span&gt; []&lt;span style="color:#a6e22e"&gt;Job&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;workers&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;) &lt;span style="color:#f92672"&gt;*&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;Metrics&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;jobCh&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Job&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 버퍼 없음 = 워커가 소화하는 만큼만 생산한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;metrics&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;Metrics&lt;/span&gt;{}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;WaitGroup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#a6e22e"&gt;workers&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Add&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;job&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;jobCh&lt;/span&gt; { &lt;span style="color:#75715e"&gt;// 채널이 닫히면 자연스럽게 종료된다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;metrics&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Record&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;process&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;job&lt;/span&gt;)) &lt;span style="color:#75715e"&gt;// 안쪽은 뮤텍스 — 1.6 ns로 끝난다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 생산자: 취소 신호와 작업 전달 중 먼저 오는 것을 기다린다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; close(&lt;span style="color:#a6e22e"&gt;jobCh&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 종료 책임의 소재를 한 곳으로 모은다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;_&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;job&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;jobs&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;select&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;case&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;jobCh&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;job&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;case&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;ctx&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;(): &lt;span style="color:#75715e"&gt;// 취소되면 즉시 생산을 멈춘다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Wait&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;metrics&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;이 코드에서 채널과 뮤텍스는 경쟁하지 않는다. 각자 자기 층위의 문제만 푼다.
작업이 어떻게 흐르고 언제 멈추는지는 &lt;code&gt;jobCh&lt;/code&gt;와 &lt;code&gt;select&lt;/code&gt;와 &lt;code&gt;ctx.Done()&lt;/code&gt;이 표현하고, 카운터 두 개가 안전하게 증가하는지는 &lt;code&gt;Metrics.mu&lt;/code&gt;가 책임진다.&lt;/p&gt;
&lt;h2 id="맺음말"&gt;맺음말&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Go에서 좋은 동시성 설계는 채널과 뮤텍스 중 하나를 선택하는 일이 아니라, 조율해야 할 것과 직렬화해야 할 것을 정확히 구분해 각각을 올바른 층위에 배치하는 일이다.&lt;/strong&gt;&lt;/p&gt;</description></item><item><title>Go Proverbs Deep Dive #2: Don't communicate by sharing memory, share memory by communicating</title><link>https://whitevision.dev/posts/go-shared-memory-by-communicating/</link><pubDate>Sun, 02 Aug 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/go-shared-memory-by-communicating/</guid><description>&lt;p&gt;Go는 &amp;ldquo;메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.&amp;ldquo;라는 철학을 가지고 있다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;Don&amp;rsquo;t communicate by sharing memory, share memory by communicating&amp;rdquo;&lt;/p&gt;
&lt;p&gt;— Rob Pike, Go Proverbs&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이번 글에서는 메모리를 공유했을때 어떤 문제가 있는지를 CPU와 메모리 구조 수준에서 내려가 살펴보고, Java/Kotlin/Spring에서는 이 문제를 어떻게 풀어왔는지, 그리고 Go는 어떻게 해결하는지를 살펴볼 것이다.&lt;/p&gt;
&lt;h2 id="모든-층위에서-반복되는-하나의-문제"&gt;모든 층위에서 반복되는 하나의 문제&lt;/h2&gt;
&lt;p&gt;미리 결론을 하나 말해두면, 이 글 전체를 관통하는 문제는 단 하나다.&lt;/p&gt;
&lt;p&gt;&lt;mark&gt;&lt;em&gt;&lt;strong&gt;공유된 상태에 대한 읽기-수정-쓰기(read-modify-write)는 원자적이지 않다.&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;&lt;/p&gt;
&lt;p&gt;CPU 명령어 수준에서는 count++ 가 세 개의 명령으로 쪼개지며 발생하고, 애플리케이션 수준에서는 싱글톤 빈의 필드를 여러 스레드가 동시에 덮어쓰며 발생하고,
데이터베이스 수준에서는 두 트랜잭션이 같은 재고 행을 읽고, 차감하고, 저장하며 발생하고, 분산 시스템 수준에서는 두 서버의 인스턴스가 같은 Redis 키를 갱신하며 발생한다.&lt;/p&gt;
&lt;p&gt;이러한 문제를 해결하기 위한 방법은 크게 2가지가 존재한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;접근을 직렬화&lt;/strong&gt;: 한 번에 하나의 실행 흐름만 공유 자원에 접근하도록 하는 것이다. Mutex, synchronization, DB Lock, Distributed Lock 등을 사용하여 접근을 직렬화 할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;소유권 설계&lt;/strong&gt;: 공유 자원을 단 하나의 실행 흐름만 소유하게 만드는 것이다. Go 채널, Redis의 Single Thread EventLoop 등이 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Go의 &amp;ldquo;메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.&amp;rdquo; 철학은 &lt;strong&gt;소유권 설계&lt;/strong&gt;를 기반으로 한다.&lt;/p&gt;
&lt;h2 id="프로세스-메모리-구조"&gt;프로세스 메모리 구조&lt;/h2&gt;
&lt;p&gt;OS는 프로세스에게 크게 네 개의 메모리 영역을 준다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;┌──────────────┐
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ Stack │ 지역 변수, 매개변수 — 스레드마다 독립
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├──────────────┤
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ Heap │ 동적 할당 객체 — 모든 스레드가 공유
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├──────────────┤
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ Data │ 전역 변수, static 변수 — 모든 스레드가 공유
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├──────────────┤
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ Text │ 프로그램 코드
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;└──────────────┘
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Thread는 Stack만 각자 갖고, 나머지는 전부 공유한다.&lt;/strong&gt; 따라서 싱글톤 빈을 설계할때 지역 변수는 동시성 이슈로 부터 안전하지만 전역 변수는 모든 스레드가 공유하기 때문에 동시성 이슈가 발생할 수 있다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;@Service&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;OrderService&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 싱글톤 빈의 필드 = 힙에 단 하나 = 모든 요청 스레드가 공유하는 상태
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; userId: Long = &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;order&lt;/span&gt;(id: Long) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; userId = id &lt;span style="color:#75715e"&gt;// 스레드 A가 100을 저장하고
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Thread&lt;/span&gt;.sleep(&lt;span style="color:#ae81ff"&gt;100&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 잠시 다른 일을 하는 사이
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; pay(userId) &lt;span style="color:#75715e"&gt;// 스레드 B가 200으로 덮어쓰면, A는 200 번 유저의 결제를 하게 된다
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;동시성 이슈를 해결하기 위한 Spring 진영에서 지켜야할 중요한 원칙은 &lt;strong&gt;싱글톤 빈은 상태를 갖지 않도록 설계하는 것&lt;/strong&gt;이다.
만약 요청 마다 독립적인 전역 변수가 필요하다면 스레드별 독립 저장소인 &lt;code&gt;ThreadLocal&lt;/code&gt;을 사용해야한다.
참고로 Thread Pool은 Thread를 재사용하므로, 다 쓴 뒤 &lt;code&gt;remove()&lt;/code&gt;를 호출하지 않으면 다음 요청이 이전 요청의 값을 바라보게 될 수 있다.&lt;/p&gt;
&lt;h2 id="data-race"&gt;Data Race&lt;/h2&gt;
&lt;p&gt;경쟁 상태(Race Condition)는 &lt;strong&gt;결과가 실행 순서(interleaving)에 따라 달라지는 상황&lt;/strong&gt;을 의미한다. 예를 들어 Thread가 여러개 동시에 접근할 때 누가 먼저 접근하느냐에 따라 결과가 달라질 수 있다.&lt;/p&gt;
&lt;p&gt;Data Race란 Go Memory Model에서는 다음과 같이 정의한다. Data Race는 Race Condition의 한 종류이다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Data Race - 둘 이상의 goroutine이 같은 메모리(공유 메모리)에 접근하고, 그 중 하나 이상이 쓰기(write)이며, 적절한 동기화가 없는 경우&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Go도 고루틴을 사용하는 경우, 클로저가 캡처한 변수나 포인터로 건네받은 구조체는 힙 위에서 똑같이 공유된다. 이러한 상황에서 적절한 동기화가 없다면 Data Race가 발생한다.&lt;/p&gt;
&lt;p&gt;아래 예시를 살펴보자.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;package&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;sync&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 모든 고루틴이 공유하는 변수&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// WaitGroup은 &amp;#34;고루틴들이 전부 끝날 때까지 기다리는&amp;#34; 동기화 도구다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;WaitGroup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;1000&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Add&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 기다릴 고루틴이 하나 늘었다고 알린다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() { &lt;span style="color:#75715e"&gt;// go 키워드: 이 함수를 새 고루틴(경량 실행 흐름)에서 실행한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;() &lt;span style="color:#75715e"&gt;// 고루틴이 끝나면 카운트를 하나 줄인다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 위험 지점: 이 한 줄은 원자적이지 않다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Wait&lt;/span&gt;() &lt;span style="color:#75715e"&gt;// 1000 개가 모두 끝날 때까지 대기&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 1000이 아닐 수 있다. 실행할 때마다 다르다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;고루틴이 같은 힙 객체를 참조하면 스레드와 동일한 문제가 발생한다. 위 결과는 항상 1000이 아닐 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;counter++&lt;/code&gt;는 소스 코드에서는 한 줄이지만, CPU에게는 논리적으로 값을 읽고(read), 증가 시키고(modify), 다시 저장하는(write) 세 단계로 이뤄질 수 있다.&lt;/p&gt;
&lt;p&gt;x86 계열에서는 개념적으로 다음과 같은 명령으로 생각할 수 있다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;MOVQ counter, AX ; (1) 메모리에서 값을 읽어 레지스터에 담는다
INCQ AX ; (2) 레지스터에서 1을 더한다
MOVQ AX, counter ; (3) 결과를 메모리에 되쓴다
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Go는 Data Race 문제를 컴파일러 옵션 하나로 잡아낼 수 있게 해두었다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;$ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c00001c090 by goroutine 8:
main.main.func1()
Previous write at 0x00c00001c090 by goroutine 7:
main.main.func1()
==================
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="가시성과-재배치-문제"&gt;가시성과 재배치 문제&lt;/h2&gt;
&lt;p&gt;공유 메모리에는 가시성(visibility)과 재배치(reordering)라는 두 가지 문제가 존재한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;가시성(visibility)&lt;/strong&gt;: CPU는 메인 메모리가 너무 느려서 코어마다 캐시를 두고, 쓰기는 일단 스토어 버퍼(store buffer)에 모아뒀다가 나중에 반영한다.
그래서 코어 1이 쓴 값을 코어 2가 &amp;ldquo;언제 보게 될지, 심지어 보게 될지&amp;rdquo; 조차 보장이 없다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;재배치(reordering)&lt;/strong&gt;: 컴파일러와 CPU는 성능을 위해, 단일 스레드 관점에서 결과가 같다면 명령의 실행 순서를 바꾼다.
내 스레드에서는 티가 나지 않지만, 그 메모리를 훔쳐보는 다른 스레드에게는 순서가 뒤집혀 보인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;package&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;number&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 전달하려는 데이터&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;ready&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; &lt;span style="color:#75715e"&gt;// &amp;#34;데이터 준비 완료&amp;#34;를 알리는 깃발&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;number&lt;/span&gt; = &lt;span style="color:#ae81ff"&gt;42&lt;/span&gt; &lt;span style="color:#75715e"&gt;// (1) 데이터를 쓰고&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;ready&lt;/span&gt; = &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt; &lt;span style="color:#75715e"&gt;// (2) 깃발을 올린다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; !&lt;span style="color:#a6e22e"&gt;ready&lt;/span&gt; { &lt;span style="color:#75715e"&gt;// (3) 깃발이 올라올 때까지 반복문으로 기다린다 (동기화 없음!)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;number&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 42가 보장되지 않는다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;직관적으로는 &amp;ldquo;깃발이 올라왔으면 데이터도 준비됐겠지&amp;quot;라고 기대하지만, 동기화가 없는 이상 아무것도 보장되지 않는다.
(1)과 (2)는 재배치될 수 있으므로 &lt;code&gt;ready&lt;/code&gt;가 먼저 &lt;code&gt;true&lt;/code&gt;가 되어 &lt;code&gt;0&lt;/code&gt;이 출력될 수 있고,
&lt;code&gt;ready = true&lt;/code&gt;라는 쓰기가 main 고루틴에게 영영 보이지 않아 반복문이 끝나지 않을 수도 있다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;volatile&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;boolean&lt;/span&gt; ready &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;자바는 &lt;code&gt;volatile&lt;/code&gt;이라는 키워드를 두었다. &lt;code&gt;volatile&lt;/code&gt; 필드에 대한 쓰기는 이후의 읽기보다
먼저 일어난다는(happens-before) 순서를 메모리 모델이 보장하고, 그 구현으로 컴파일러와 CPU의 재배치를 막는
**메모리 배리어(memory barrier)**가 삽입된다.
즉 &lt;code&gt;volatile&lt;/code&gt;을 선언하면 CPU 캐시나 명령어 재배치(reordering)와 관련된 메모리 가시성(visibility)을 JVM이 보장해 준다.
다만 &lt;code&gt;volatile&lt;/code&gt;은 가시성만 해결한다. &lt;code&gt;counter++&lt;/code&gt;의 세 단계가 쪼개지는 원자성 문제는 그대로 남는다.&lt;/p&gt;
&lt;p&gt;흥미로운 것은 Go의 선택이다. &lt;em&gt;&lt;strong&gt;Go에는 volatile이 없다.&lt;/strong&gt;&lt;/em&gt; 실수로 빠뜨린 게 아니라 일부러 뺐다.
Go 메모리 모델 문서는 동기화가 필요하면 채널이나 &lt;code&gt;sync&lt;/code&gt;, &lt;code&gt;sync/atomic&lt;/code&gt;을 쓰라고 말한다.
저수준 규칙에 기대려는 사람에게 이렇게 조언한다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Don&amp;rsquo;t be clever.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://whitevision.dev/posts/go-clarity/"&gt;지난 글&lt;/a&gt;에서 다룬 &lt;strong&gt;Clear is better than clever&lt;/strong&gt;가 메모리 모델 문서에도 그대로 이어진다.&lt;/p&gt;
&lt;p&gt;위 예제는 CPU의 명령어 재배치, 컴파일러 최적화, Happens-Before 관계를 머릿속에서 추론해야 한다.
즉, 코드를 이해하기 위해 &lt;strong&gt;메모리 모델을 해석(decode)&lt;/strong&gt; 해야 한다. Go는 바로 이런 코드를 clever 하다고 본다.&lt;/p&gt;
&lt;p&gt;반대로 Go가 권장하는 방식은 다음과 같다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ready&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt;{})
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;number&lt;/span&gt; = &lt;span style="color:#ae81ff"&gt;42&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; close(&lt;span style="color:#a6e22e"&gt;ready&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;ready&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;number&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;이 코드는 메모리 모델을 몰라도 읽을 수 있다. 메모리 가시성이 어떻게 보장되는지는 channel이 책임진다.
즉, 코드를 이해하기 위해 &amp;ldquo;이 코드는 happens-before 관계가 성립하니까 안전하다&amp;quot;와 같은 추론을 머릿속에서 할 필요가 없다.&lt;/p&gt;
&lt;p&gt;따라서 Go가 &lt;code&gt;volatile&lt;/code&gt;을 제공하지 않는 이유는
개발자가 메모리 모델의 세부 규칙을 이용해 영리한(clever) 코드를 작성하는 대신, 채널과 sync, sync/atomic 같은 명시적인 동기화 도구를 사용해 누구나 이해할 수 있는(clear) 코드를 작성하도록 유도하기 위해서라는
&lt;strong&gt;Clear is better than clever&lt;/strong&gt; 철학을 반영하기 때문이다.&lt;/p&gt;
&lt;p&gt;메모리 모델과 순차적 일관성에 대한 더 깊은 이야기는 &lt;a href="https://whitevision.dev/posts/memory-model/"&gt;MEMORY MODEL&lt;/a&gt; 글에서 다뤘다.&lt;/p&gt;
&lt;h2 id="첫-번째-패러다임---접근을-직렬화-한다"&gt;첫 번째 패러다임 - 접근을 직렬화 한다&lt;/h2&gt;
&lt;p&gt;공유 메모리 패러다임의 해법은 &lt;strong&gt;임계 영역(critical section)에 한 번에 하나만 들여보내는 것&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;가장 원시적인 형태는 깃발 변수를 검사하며 도는 것이다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;acquire() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;/* busy waiting — 락이 풀릴 때까지 CPU를 태우며 돈다 */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;while&lt;/span&gt; (!available);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; available = &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;이러한 스핀락(spinlock)은 락 보유 시간이 아주 짧을 때만 효율적이다.
스레드를 재우는(blocking)방식을 쓰면 CPU 낭비는 없지만, 운영체제가 대기 중인 스레드를 잠재우고(lock), 이후 락이 해제되면 다시 깨워야(unpark) 한다. 이 과정에서는 커널 개입에 따른 User Mode ↔ Kernel Mode 전환, 스케줄링, 문맥 전환(Context Switch) 등의 비용이 발생한다.&lt;/p&gt;
&lt;p&gt;동시성 도구들의 성능 문제는 대부분 &amp;ldquo;락이 풀릴 때까지 CPU를 계속 사용하며 기다릴 것인지(spinning), 아니면 운영체제에게 스레드를 잠시 재워 달라고 맡길 것인지(blocking)&amp;ldquo;의 &lt;a href="https://whitevision.dev/posts/tradeoff/"&gt;트레이드오프&lt;/a&gt;다.&lt;/p&gt;
&lt;p&gt;여러 실행 흐름이 동시에 **공유 자원(shared resources)**에 접근하지 못하도록 보호하기 위해서 다양한 프로그래밍 언어나 라이브러리에서 다양한 동기화 도구를 지원한다.&lt;/p&gt;
&lt;p&gt;Java의 &lt;code&gt;synchronized&lt;/code&gt;는 공유 상태를 안전하게 다루기 위해 가장 많이 사용되는 동기화 도구다. 모든 Java 객체에는 내부적으로 monitor가 존재하며, &lt;code&gt;synchronized&lt;/code&gt;는 이 monitor를 획득한 스레드만 임계 영역에 접근할 수 있도록 만든다.&lt;/p&gt;
&lt;p&gt;JVM에서는 이를 &lt;code&gt;monitorenter&lt;/code&gt;와 &lt;code&gt;monitorexit&lt;/code&gt;라는 바이트코드로 처리한다. 하나의 스레드는 이미 보유한 monitor에 다시 진입할 수 있기 때문에 재진입성(reentrancy)을 제공한다. 또한 monitor 획득과 해제 과정은 스레드 간 메모리 가시성을 보장하여, 한 스레드에서 변경한 값이 다른 스레드에서도 안전하게 관찰될 수 있도록 한다.&lt;/p&gt;
&lt;p&gt;Go에서는 &lt;code&gt;sync.Mutex&lt;/code&gt; 라이브러리를 통해 임계 영역을 보호한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;package&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;sync&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Mutex&lt;/span&gt; &lt;span style="color:#75715e"&gt;// counter를 보호하는 뮤텍스&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;WaitGroup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;1000&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Add&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Lock&lt;/span&gt;() &lt;span style="color:#75715e"&gt;// 임계 영역 진입 — 이미 누가 잠갔다면 여기서 대기한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 이 순간 counter를 만질 수 있는 고루틴은 하나뿐이다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Unlock&lt;/span&gt;() &lt;span style="color:#75715e"&gt;// 잠금 해제 — 대기 중인 고루틴 하나가 진입한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Wait&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 항상 1000&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Mutex는 Lock 획득 &amp;gt; 임계 영역 실행 &amp;gt; Unlock 흐름을 보장하는 매커니즘이다.&lt;/p&gt;
&lt;p&gt;Go의 &lt;code&gt;sync.Mutex&lt;/code&gt;는 재진입(reentrant)을 지원하지 않는다. 따라서 같은 고루틴이 이미 획득한 Mutex를 다시 &lt;code&gt;Lock()&lt;/code&gt; 하려고 하면 deadlock이 발생한다.&lt;/p&gt;
&lt;p&gt;아래 Deadlock 예제를 살펴보자.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;package&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;sync&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;type&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Counter&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Mutex&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;value&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; (&lt;span style="color:#a6e22e"&gt;c&lt;/span&gt; &lt;span style="color:#f92672"&gt;*&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;Counter&lt;/span&gt;) &lt;span style="color:#a6e22e"&gt;Increment&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;c&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Lock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;c&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Unlock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;Increment: Lock 획득&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;c&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;update&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;Increment: 종료&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// 내부 함수도 같은 Mutex를 사용한다고 가정&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; (&lt;span style="color:#a6e22e"&gt;c&lt;/span&gt; &lt;span style="color:#f92672"&gt;*&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;Counter&lt;/span&gt;) &lt;span style="color:#a6e22e"&gt;update&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;update: Lock 획득 시도&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;c&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Lock&lt;/span&gt;() &lt;span style="color:#75715e"&gt;// ❌ 여기서 deadlock 발생&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;c&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Unlock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;c&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;value&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;Counter&lt;/span&gt;{}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Increment&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;value&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;위 코드를 실행하면 아래와 같은 결과를 얻는다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Increment: Lock 획득
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;update: Lock 획득 시도
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;fatal error: all goroutines are asleep - deadlock!
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;문제의 핵심은 Mutex를 해제할 수 있는 유일한 주체가 현재 Mutex를 보유한 고루틴인데, 그 고루틴 스스로가 다시 같은 Mutex를 기다리면서 진행이 멈춘다는 점이다.&lt;/p&gt;
&lt;p&gt;Go가 재진입 Mutex를 지원하지 않는 이유도 여기에 있다. 재진입을 허용하면 이러한 상태를 방지하기 위해 &amp;ldquo;현재 누가 몇 번 Lock을 획득했는지&amp;quot;를 추적해야 하고, 코드의 호출 관계가 복잡해질수록 락의 소유 관계를 이해하기 어려워진다.&lt;/p&gt;
&lt;p&gt;이러한 점은 &lt;strong&gt;&amp;ldquo;Clear is better than clever&amp;rdquo;&lt;/strong&gt; 와도 자연스럽게 연결된다. 핵심은 &amp;ldquo;재진입성이 없어서 불편하다&amp;quot;가 아니라, &lt;strong&gt;숨겨진 락 상태를 줄여 사람이 코드를 해석하는 비용을 낮춘다&lt;/strong&gt;는 점이다.&lt;/p&gt;
&lt;h3 id="락-없이-직렬화-하기---cas와-원자-연산"&gt;락 없이 직렬화 하기 - CAS와 원자 연산&lt;/h3&gt;
&lt;p&gt;락은 확실하지만, 대기와 문맥 교환이라는 비용이 있다. 카운터 하나 올리자고 고루틴을 재우는 것은 과하다. 그래서 CPU는 읽기-수정-쓰기를 한 번에 처리하는 원자 명령을 제공한다. 대표가 CAS(Compare-And-Swap)다.
CAS는 **&amp;ldquo;내가 읽었던 값과 현재 값이 같다면 변경하라&amp;rdquo;**라는 의미를 갖는다.&lt;/p&gt;
&lt;p&gt;CAS는 세 가지를 받는다. 메모리 위치 M, 기대값 A, 새값 B. 그리고 &amp;ldquo;M의 현재 값이 A와 같을 때만 B로 바꾼다&amp;quot;를 하드웨어가 하나의 명령으로 수행한다. 중간에 다른 코어가 끼어들 틈이 없다.&lt;/p&gt;
&lt;p&gt;대표적으로 자바에서는 &lt;code&gt;AtomicInteger&lt;/code&gt;가 있다.
AtomicInteger는 CAS(Compare-And-Swap)를 이용해 Lock 없이 값을 변경한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// AtomicInteger 내부 (단순화)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;volatile&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt; value; &lt;span style="color:#75715e"&gt;// 가시성 보장을 위해 volatile&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;public&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;final&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;incrementAndGet&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt; current;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;do&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; current &lt;span style="color:#f92672"&gt;=&lt;/span&gt; value; &lt;span style="color:#75715e"&gt;// (1) 현재 값을 읽고&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#66d9ef"&gt;while&lt;/span&gt; (&lt;span style="color:#f92672"&gt;!&lt;/span&gt;compareAndSet(current, current &lt;span style="color:#f92672"&gt;+&lt;/span&gt; 1)); &lt;span style="color:#75715e"&gt;// (2) &amp;#34;아직 그 값이면 +1&amp;#34;을 시도&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; current &lt;span style="color:#f92672"&gt;+&lt;/span&gt; 1; &lt;span style="color:#75715e"&gt;// 다른 스레드가 먼저 바꿨으면 (1)부터 재시도&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;위 코드는 실패하면 성공할 때까지 다시 도는 구조라서 락은 없지만(lock-free) 경합이 심할 수록 재시도가 늘어난다.&lt;/p&gt;
&lt;p&gt;CAS에는 대표적으로 &lt;strong&gt;ABA&lt;/strong&gt; 문제가 있다.
예를 들어 ThreadA가 현재 값(a)를 읽는다. 그 사이 ThreadB가 값을 변경(b)한다. 그리고 다시 원래 값으로 되돌린다(b-&amp;gt;a).
이제 ThreadA가 다시 실행된다. 그러면 ThreadA는 내가 읽었던 값은 A이고 현재 값도 A라고 판단한다. CAS는 성공하지만 실제로는 값이 변하지 않은 것이 아니다.&lt;/p&gt;
&lt;p&gt;즉, CAS는 &amp;ldquo;현재 값이 같은가&amp;quot;는 확인하지만, &amp;ldquo;그 사이에 변경이 있었는가&amp;quot;까지는 알지 못한다.&lt;/p&gt;
&lt;p&gt;Go에서는 &lt;code&gt;sync/atomic&lt;/code&gt;이 존재한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;atomic&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Int64&lt;/span&gt; &lt;span style="color:#75715e"&gt;// Go 1.19+ 타입 안전 원자 정수&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Add&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// x86에서는 LOCK XADD 원자 명령 하나로 수행된다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;v&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Load&lt;/span&gt;() &lt;span style="color:#75715e"&gt;// 원자적 읽기 — 가시성도 함께 보장된다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="synchronized에서-분산락까지"&gt;synchronized에서 분산락까지&lt;/h3&gt;
&lt;p&gt;Spring 환경에서는 이 문제가 애플리케이션 내부를 넘어 데이터베이스 수준으로 확장된다. 하지만 본질은 변하지 않는다.
하나의 변수에 여러 스레드가 접근하는 문제와, 하나의 재고 데이터를 여러 트랜잭션이 동시에 변경하는 문제는 같은 종류의 문제다.&lt;/p&gt;
&lt;p&gt;재고 차감이라는 고전적인 예제를 통해 살펴보자.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;@Service&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;StockService&lt;/span&gt;(&lt;span style="color:#66d9ef"&gt;private&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; stockRepository: StockRepository) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;@Transactional&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;decrease&lt;/span&gt;(id: Long, quantity: Long) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;val&lt;/span&gt; stock = stockRepository.findById(id).orElseThrow() &lt;span style="color:#75715e"&gt;// (1) 읽고
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; stock.decrease(quantity) &lt;span style="color:#75715e"&gt;// (2) 고치고
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 트랜잭션 커밋 시점에 UPDATE (3) 쓴다
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;재고 10개에 100개의 스레드가 동시에 1개씩 차감을 요청하면 0이 남아야 하지만, 실제로는 9개 이상이 남는다. &lt;code&gt;counter++&lt;/code&gt;와 완전히 같은 모양의 read-modify-write가, 이번에는 DB 행 위에서 벌어진 것이다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt;를 사용하면 문제를 해결할 수 있을까?&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@Transactional&lt;/code&gt;은 프록시 객체를 만들어 시작 → 대상 메서드 → 커밋 순서로 감싸는데, &lt;code&gt;synchronized&lt;/code&gt;는 대상 메서드에만 걸린다. 스레드 A가 메서드를 끝내고(락 해제) 아직 커밋하기 전에, 스레드 B가 락을 잡고 커밋 전의 옛 값을 읽어버린다. 락과 트랜잭션의 경계가 어긋나면서 생기는 경쟁 상태다. 그리고 어렵게 고쳐도 &lt;code&gt;synchronized&lt;/code&gt;는 JVM 하나 안에서만 유효하다. 서버가 두 대면 무용지물이다.&lt;/p&gt;
&lt;p&gt;이러한 문제를 해결하기 위해서 락을 거는 위치가 점점 바깥으로 밀려난다.
DB 수준에서는 비관락, 낙관락 등이 있고 Redis를 활용한 분산락도 존재한다.&lt;/p&gt;
&lt;h2 id="두-번째-패러다임---소유권을-설계한다"&gt;두 번째 패러다임 - 소유권을 설계한다&lt;/h2&gt;
&lt;p&gt;Go의 &amp;ldquo;메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.&amp;ldquo;라는 철학의 뿌리는
1978년 Tony Hoare의 논문 CSP(Communicating Sequential Processes)다.
공유 변수 대신, 독립적으로 실행되는 프로세스들이 메시지를 주고받으며 협력하는 모델이다. Go는 이것을 고루틴(값싼 실행 흐름)과 채널(타입이 있는 통신 통로)로 언어에 내장했다.&lt;/p&gt;
&lt;p&gt;채널의 기본 동작부터 보자.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// int를 실어 나르는 채널을 만든다 (버퍼 없음)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;42&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 송신: 받는 쪽이 나타날 때까지 여기서 기다린다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;v&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 수신: 보내는 쪽이 나타날 때까지 여기서 기다린다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;buf&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;, &lt;span style="color:#ae81ff"&gt;3&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 버퍼 3짜리 채널: 3개까지는 기다리지 않고 보낼 수 있다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;버퍼 없는 채널의 송신과 수신은 서로를 기다린다. 즉 채널은 데이터 전달과 동기화가 한 동작에 합쳐진 도구다. 이 성질로 아까의 카운터 문제를 다시 풀어보자.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;package&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 각 고루틴이 결과를 보내는 통신 통로&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;1000&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 공유 변수를 직접 고치는 대신, &amp;#34;1만큼 세어달라&amp;#34;는 값을 보낸다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt; &lt;span style="color:#75715e"&gt;// counter는 main 고루틴의 지역 변수 — 소유자가 하나뿐이다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;1000&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt; &lt;span style="color:#f92672"&gt;+=&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;results&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 채널에서 하나씩 받아 더한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;counter&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 항상 1000. 락도, atomic도 없다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Data Race가 해결된 이유는 &lt;strong&gt;counter에 접근하는 고루틴이 처음부터 하나뿐이기 때문이다.&lt;/strong&gt; 1,000 개의 고루틴은 counter를 만지지 않는다. 값을 보낼 뿐이다. 경쟁할 대상 자체가 없다.&lt;/p&gt;
&lt;p&gt;이것이 proverb의 뒷문장, &amp;ldquo;share memory by communicating&amp;quot;의 실체다.&lt;/p&gt;
&lt;p&gt;즉 소유권(ownership)이 통신과 함께 이전되며 &lt;mark&gt;&lt;em&gt;&lt;strong&gt;&amp;ldquo;이 데이터는 이제 네 것이다. 나는 더 이상 만지지 않겠다.&amp;quot;&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt; 라는 의미를 갖는다.
한 시점에 데이터를 만질 수 있는 고루틴이 항상 하나라면,
그 사이의 모든 코드는 단일 스레드 코드처럼 읽고 쓸 수 있다. 락의 획득 순서를 고민할 일도, 해제를 잊을 일도 없다.&lt;/p&gt;
&lt;p&gt;그리고 이 소유권 이전은 관례가 아니라 언어 명세가 보증하는 규칙이다. Go 메모리 모델은 &amp;ldquo;채널 송신은 대응하는 수신의 완료보다 먼저 일어난다(happens-before)&amp;ldquo;고 정의한다. 아까 volatile 없이는 깨졌던 깃발 예제가, 채널로는 정확해진다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;package&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;number&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;done&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt;{}) &lt;span style="color:#75715e"&gt;// 신호 전용 채널 (struct{} 는 크기 0 — 값이 아니라 신호가 목적)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;number&lt;/span&gt; = &lt;span style="color:#ae81ff"&gt;42&lt;/span&gt; &lt;span style="color:#75715e"&gt;// (1) 송신 이전의 모든 쓰기는&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;done&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt;{}{} &lt;span style="color:#75715e"&gt;// (2) 송신&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;done&lt;/span&gt; &lt;span style="color:#75715e"&gt;// (3) 수신 — 메모리 모델이 (1) → (3) 이후 읽기의 순서를 보증한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;number&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 반드시 42&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;채널을 사용하면 개발자는 CPU의 재배치나 스토어 버퍼 같은 하드웨어 수준의 세부 동작을 직접 관리하지 않아도 된다. Go 런타임이 채널 송수신 과정에서 필요한 동기화와 메모리 가시성을 책임진다.
덕분에 개발자가 고민해야 하는 것은 &amp;ldquo;이 메모리 접근이 happens-before 관계를 만족하는가?&amp;rdquo; 같은 저수준의 추론이 아니다. 대신 &amp;ldquo;어떤 고루틴이 데이터를 만들고, 어떤 고루틴이 그것을 소비하는가&amp;quot;라는 더 높은 수준의 프로그램 흐름에 집중할 수 있다.&lt;/p&gt;
&lt;h3 id="재고-예제를-go로---상태를-소유한-고루틴"&gt;재고 예제를 Go로 - 상태를 소유한 고루틴&lt;/h3&gt;
&lt;p&gt;Spring의 재고 차감 문제를 Go 방식으로 다시 풀어보자. 재고를 소유한 고루틴 하나를 세운다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;package&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// order는 &amp;#34;재고를 qty만큼 차감해달라&amp;#34;는 요청 메시지다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// 처리 결과를 돌려받을 reply 채널을 메시지에 함께 실어 보낸다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;type&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;order&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;qty&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;reply&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// stockKeeper는 재고를 소유한 유일한 고루틴이다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// stock은 이 함수의 지역 변수(매개변수)라서, 다른 고루틴은 접근할 방법 자체가 없다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;stockKeeper&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;stock&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;int&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;orders&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;order&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;o&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;orders&lt;/span&gt; { &lt;span style="color:#75715e"&gt;// 주문이 올 때마다 하나씩, 순서대로 처리한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;stock&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;gt;=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;o&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;qty&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;stock&lt;/span&gt; &lt;span style="color:#f92672"&gt;-=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;o&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;qty&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 이 순간 stock을 만지는 실행 흐름은 나 하나뿐이다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;o&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;reply&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 성공을 알린다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;o&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;reply&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 재고 부족 — 차감 없이 실패를 알린다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;main&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;orders&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;order&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;stockKeeper&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;10&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;orders&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 재고 10개를 들고 시작하는 관리자 고루틴&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 각 손님의 주문 결과가 모이는 채널&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 100 명의 손님이 동시에 1개씩 주문한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;100&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;reply&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 내 주문의 결과만 받을 1회용 채널&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;orders&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;order&lt;/span&gt;{&lt;span style="color:#a6e22e"&gt;qty&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;reply&lt;/span&gt;: &lt;span style="color:#a6e22e"&gt;reply&lt;/span&gt;} &lt;span style="color:#75715e"&gt;// 주문을 보내고&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;reply&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 결과를 받아 집계 채널로 넘긴다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;success&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;fail&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;, &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;100&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; { &lt;span style="color:#75715e"&gt;// main 고루틴이 결과 100개를 회수한다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;results&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;success&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fail&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Printf&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;성공 %d건, 실패 %d건\n&amp;#34;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;success&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;fail&lt;/span&gt;) &lt;span style="color:#75715e"&gt;// 항상: 성공 10건, 실패 90건&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="채널도-결국-공유-메모리-위에-있다"&gt;채널도 결국 공유 메모리 위에 있다.&lt;/h2&gt;
&lt;p&gt;Go 런타임 내부를 들여다보면 채널 역시 결국 공유 메모리와 동기화 메커니즘 위에 구현되어 있다.&lt;/p&gt;
&lt;p&gt;Go 런타임의 채널 구현(runtime/chan.go)을 단순화하면 다음과 같은 구조다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// hchan — 채널의 실제 구현체 (단순화)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;type&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;hchan&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;qcount&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 버퍼에 현재 들어있는 원소 수&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;dataqsiz&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 버퍼 크기 — make(chan T, n)의 n&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;buf&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;unsafe&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Pointer&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 원형 큐(ring buffer) — 힙 위의 공유 메모리다&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;sendx&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 다음에 쓸 버퍼 위치&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;recvx&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 다음에 읽을 버퍼 위치&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;recvq&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;waitq&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 수신하려고 잠들어 있는 고루틴들의 대기열&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;sendq&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;waitq&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 송신하려고 잠들어 있는 고루틴들의 대기열&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;lock&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;mutex&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 이 모든 필드를 보호하는 락&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;채널 역시 내부적으로는 공유 상태를 가지고 있다. 핵심은 개발자가 어떤 수준의 복잡성을 다뤄야 하는가이다.
직접 Mutex를 사용하면 개발자는 &amp;ldquo;누가 Lock을 획득하고&amp;rdquo;, &amp;ldquo;언제 Unlock&amp;quot;되고, &amp;ldquo;데이터 경쟁은 발생하지 않는지?&amp;rdquo; 등을 고민해야 한다.
반면 채널을 사용하면 고민의 중심이 &amp;ldquo;어떤 고루틴이 데이터를 만들고 어떤 고루틴이 데이터를 소비하는지&amp;quot;로 변경된다.&lt;/p&gt;
&lt;h2 id="채널이-효율적인-이유"&gt;채널이 효율적인 이유&lt;/h2&gt;
&lt;p&gt;Go 런타임은 채널 사용 패턴에 맞춰 다양한 최적화를 수행한다.&lt;/p&gt;
&lt;p&gt;대표적인 예가 버퍼 없는 채널(unbuffered channel)이다.
버퍼 없는 채널에서는 &lt;strong&gt;송신자의 스택에서 수신자의 스택으로 값을 직접 복사&lt;/strong&gt;한다.&lt;/p&gt;
&lt;p&gt;채널을 사용할 때 중요한 차이점은 대기 방식이다.&lt;/p&gt;
&lt;p&gt;일반적인 OS 스레드 기반 모델에서는 스레드가 락이나 조건을 기다리게 되면 운영체제 수준에서 해당 스레드를 잠재운다. 이후 작업을 재개하려면 운영체제 스케줄러가 다시 스레드를 깨워야 하며, 이 과정에서 사용자 모드와 커널 모드 사이의 전환 비용이 발생한다.&lt;/p&gt;
&lt;p&gt;하지만 Go의 고루틴은 다르다. 채널 송수신이나 동기화 지점에서 대기하는 고루틴은 OS 스레드를 그대로 점유한 채 멈춰 있지 않는다. Go 런타임 스케줄러가 해당 고루틴을 잠시 중단시키고, 같은 OS 스레드에서 실행 가능한 다른 고루틴을 실행한다.&lt;/p&gt;
&lt;p&gt;즉, 하나의 고루틴이 대기한다고 해서 하나의 OS 스레드가 함께 멈추는 것이 아니다.&lt;/p&gt;
&lt;p&gt;덕분에 Go는 수많은 고루틴이 동시에 대기하는 상황에서도 효율적으로 자원을 활용할 수 있다.&lt;/p&gt;
&lt;p&gt;이 차이는 Go가 동시성을 다루는 방식의 핵심이다. 개발자는 &amp;ldquo;어떤 고루틴이 기다리고 있는가&amp;quot;에 집중하고, &amp;ldquo;어떤 스레드를 잠재우고 깨울 것인가&amp;quot;와 같은 저수준 스케줄링 문제는 Go 런타임이 담당한다.&lt;/p&gt;
&lt;p&gt;결국 Go의 채널과 고루틴은 동시성의 복잡성을 없앤 것이 아니라, 개발자가 직접 추론해야 하는 영역을 줄이고 더 높은 수준의 데이터 흐름에 집중할 수 있도록 만든 설계다.&lt;/p&gt;
&lt;p&gt;JDK 21의 가상 스레드(virtual threads)또한 뒤늦게 같은 구조를 채택하였다.&lt;/p&gt;
&lt;h2 id="kotlin의-coroutine"&gt;Kotlin의 Coroutine&lt;/h2&gt;
&lt;p&gt;Kotlin 코루틴을 써봤다면 Go의 Goroutine 낯설진 않을 것이다.
Kotlin Coroutine은 Go의 CSP와 Erlang의 Actor 모델에서 많은 영향을 받았고, 이를 Kotlin 방식으로 재해석했다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;kotlinx.coroutines.Channel&lt;/code&gt;은 Go의 channel과 마찬가지로 CSP(Communicating Sequential Processes) 스타일의 메시지 전달 모델을 제공한다. 또한 &lt;code&gt;actor&lt;/code&gt; 패턴은 상태를 하나의 coroutine이 소유하고, 다른 coroutine은 메시지를 통해서만 접근하는 구조로 Go에서 자주 사용하는 패턴과 같은 방향을 가진다.&lt;/p&gt;
&lt;h2 id="분산-시스템에서도-반복되는-두-가지-선택"&gt;분산 시스템에서도 반복되는 두 가지 선택&lt;/h2&gt;
&lt;p&gt;시야를 서버 한 대 안에서 여러 서버로 넓혀도 동시성 문제의 본질은 크게 달라지지 않는다.
여전히 다음과 같은 질문을 마주한다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;여러 실행 주체가 하나의 상태를 변경하려 할 때, 어떻게 안전하게 처리할 것인가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;선택지는 크게 두 가지가 있다. 하나는 공유 상태를 유지하고 락으로 보호하는 방식이고, 다른 하나는 상태의 소유자를 하나로 정하고 메시지로 요청을 전달하는 방식이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Redis가 원자적인 이유는 락이 아니라 소유권이다.&lt;/strong&gt; Redis는 명령 처리를 싱글 스레드 이벤트 루프 하나가 전담한다. 수만 개의 클라이언트가 동시에 명령을 보내도, 결국 큐에 줄을 서고 루프가 한 번에 하나씩 실행한다. INCR이 원자적인 이유는 정교한 락이 아니라 데이터의 소유자가 하나뿐이기 때문이다. stockKeeper 고루틴과 정확히 같은 구조가 인프라 규모로 확장된 것이다. Lua 스크립트는 여기서 한 걸음 더 나가서, &amp;ldquo;읽고-판단하고-쓰는&amp;rdquo; 로직 전체를 메시지로 만들어 소유자에게 보내 통째로 실행시킨다. 통신으로 로직을 전달하는 셈이다.&lt;/p&gt;
&lt;p&gt;반면 &lt;strong&gt;Redisson 분산락은 공유 메모리 패러다임의 분산 확장이다.&lt;/strong&gt; 여러 서버가 하나의 락 키를 두고 경쟁하고, 락을 쥔 채 죽는 서버를 위해 leaseTime을 두고, 만료가 커밋보다 빨랐을 때를 위해 낙관적 락을 겹친다.
흥미로운 점은 Redisson 분산락 자체도 내부적으로는 Lua Script를 사용한다는 것이다.
락 획득과 해제 과정은 여러 Redis 명령을 조합해야 하는 작업이기 때문에, 이를 Redis 내부에서 원자적으로 실행하기 위해 Lua Script를 활용한다.&lt;/p&gt;
&lt;p&gt;즉, 공유 자원을 보호하기 위한 도구조차 내부적으로는 &lt;strong&gt;단일 소유자에게 원자적인 작업을 전달하는 방식&lt;/strong&gt;에 의존하고 있다.&lt;/p&gt;
&lt;p&gt;메시징 시스템으로 시야를 넓혀도 같은 패턴이 반복된다.
재고 차감 문제를 Kafka로 해결한다고 생각해보자. 상품 ID를 파티션 키로 사용하면 같은 상품에 대한 주문 이벤트는 항상 같은 파티션으로 전달된다.
상품 ID를 파티션 키로 삼아 한 상품의 주문이 모두 같은 파티션에 쌓이게 하고, 그 파티션을 컨슈머 하나가 순서대로 처리하게 만든다. 채널 앞의 stockKeeper와 같은 그림이다.&lt;/p&gt;
&lt;h2 id="언제-채널을-사용하고-언제-뮤텍스를-사용해야-할까"&gt;언제 채널을 사용하고, 언제 뮤텍스를 사용해야 할까?&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&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;/td&gt;
&lt;td&gt;channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;작업 단위를 여러 고루틴에 분배할 때&lt;/td&gt;
&lt;td&gt;channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비동기 결과를 돌려받아야 할 때&lt;/td&gt;
&lt;td&gt;channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;캐시, 상태, 카운터를 보호할 때&lt;/td&gt;
&lt;td&gt;mutex / atomic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;결국 판단 기준은 하나다.&lt;/p&gt;
&lt;p&gt;보호하려는 대상이 &lt;strong&gt;하나의 데이터 자체&lt;/strong&gt;라면 Mutex가 더 적합하다. 예를 들어 특정 맵이나 카운터 값을 여러 실행 흐름이 동시에 변경하지 못하도록 막는 것이 목적이라면, 단순히 접근을 직렬화하는 락이 가장 명확한 해결책이다.&lt;/p&gt;
&lt;p&gt;반면 고민하는 대상이 &lt;strong&gt;데이터가 어떻게 생성되고 이동하는가&lt;/strong&gt;라면 채널과 메시지 전달 방식이 더 적합하다. 상태를 여러 곳에서 직접 변경하는 대신, 하나의 소유자가 데이터를 관리하고 다른 실행 흐름은 메시지를 통해 요청하는 구조다.&lt;/p&gt;
&lt;p&gt;하지만 모든 문제를 채널로 풀려고 하는 것은 좋은 설계가 아니다.&lt;/p&gt;
&lt;p&gt;단순히 맵 하나를 보호하기 위해 여러 고루틴과 채널을 만들면, 오히려 문제의 본질보다 통신 구조를 이해하는 비용이 커질 수 있다. 이는 Go가 말하는 &lt;strong&gt;Clear is better than clever&lt;/strong&gt;와 반대되는 방향이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don&amp;rsquo;t communicate by sharing memory; share memory by communicating&lt;/strong&gt;이라는 proverb는 Mutex 사용을 금지하는 규칙이 아니다. 모든 상황에서 채널을 사용하라는 의미도 아니다.&lt;/p&gt;
&lt;p&gt;핵심은 동시성 설계를 시작할 때 &amp;ldquo;어디에 락을 걸 것인가?&amp;ldquo;보다 먼저 다음 질문을 던지는 것이다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;이 데이터의 소유자는 누구인가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;소유자가 명확하다면 메시지 전달을 통해 구조를 단순하게 만들 수 있고, 단순한 공유 데이터라면 Mutex가 더 명확한 선택일 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;좋은 동시성 설계는 특정 도구를 고집하는 것이 아니라, 데이터의 소유권과 변경 흐름을 가장 이해하기 쉬운 형태로 표현하는 것이다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="맺음말--접근-제어에서-소유권-설계로"&gt;맺음말 — 접근 제어에서 소유권 설계로&lt;/h2&gt;
&lt;p&gt;다시 처음의 문장으로 돌아가 보자.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Don&amp;rsquo;t communicate by sharing memory; share memory by communicating.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이 문장의 핵심은 &lt;strong&gt;명확한 소유권 설계를 통해 경쟁이 발생하기 어려운 구조를 만들고 동시성의 복잡성을 사람이 이해할 수 있는 구조로 만드는 것&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h2 id="references"&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Rob Pike, &amp;ldquo;Go Proverbs&amp;rdquo; (Gopherfest 2015)&lt;/li&gt;
&lt;li&gt;C. A. R. Hoare, &amp;ldquo;Communicating Sequential Processes&amp;rdquo; (CACM, 1978)&lt;/li&gt;
&lt;li&gt;Effective Go — Share by communicating&lt;/li&gt;
&lt;li&gt;The Go Memory Model&lt;/li&gt;
&lt;li&gt;Andrew Gerrand, &amp;ldquo;Share Memory By Communicating&amp;rdquo; (The Go Blog, 2010)&lt;/li&gt;
&lt;li&gt;Brian Goetz, 『Java Concurrency in Practice』&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>Go Proverbs Deep Dive #1: Clear is better than clever</title><link>https://whitevision.dev/posts/go-clarity/</link><pubDate>Sat, 01 Aug 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/go-clarity/</guid><description>&lt;p&gt;&lt;img src="martinfowler.png" alt="Martinfowler Quotes"&gt;&lt;/p&gt;
&lt;p&gt;마틴 파울러는 &amp;ldquo;컴퓨터가 이해하는 코드는 누구나 작성할 수 있다. 좋은 프로그래머는 사람이 이해하는 코드를 작성한다.&amp;ldquo;라고 『Refactoring: Improving the Design of Existing Code』에서 말했다.&lt;/p&gt;
&lt;p&gt;&lt;img src="sicp.png" alt="SICP Quotes"&gt;&lt;/p&gt;
&lt;p&gt;&amp;ldquo;프로그램은 사람이 읽기 위해 작성되어야 하며, 기계가 실행하는 것은 부차적인 일이다.&amp;ldquo;라는 명언은 『Structure and Interpretation of Computer Programs』에서 1984년에 소개되었다.&lt;/p&gt;
&lt;p&gt;약 40년 전의 문장이 지금도 유효한 이유는, 코드를 실행하는 주체는 계속 빨라졌지만 코드를 읽는 주체는 여전히 사람이기 때문이다.
현재 Claude Code, Codex, Gemini CLI, Cursor 같은 도구를 활용하여 코드를 작성하는 시대에서도 여전히 사람이 이해하는 코드를 작성하는 것은 중요하며
AI를 활용하여 본인과 동료가 코드를 잘 이해하도록 작성 해야 한다.&lt;/p&gt;
&lt;p&gt;Go는 &lt;strong&gt;Clear is better than clever&lt;/strong&gt;라는 철학을 가지고 있다. 마틴 파울러가 말한 내용과 크게 다르지 않다.&lt;/p&gt;
&lt;p&gt;본질은 &lt;mark&gt;&lt;em&gt;&lt;strong&gt;동료가 코드를 이해하는데 드는 비용을 최소화하라는 원칙&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;이다.&lt;/p&gt;
&lt;h2 id="명확성과-가독성"&gt;명확성과 가독성&lt;/h2&gt;
&lt;p&gt;명확성(Clarity)와 가독성(Readability)은 다르다. 명확성은 코드가 무엇을 하는지를 나타내는 성질이고 감추는게 적을 수록 명확성이 높다고 할 수 있다.
가독성은 코드를 얼마나 쉽고 편하게 읽고 이해할 수 있는 지를 의미한다.&lt;/p&gt;
&lt;p&gt;Go는 명확성을 중시하는 언어이다. 아래 Go로 작성된 코드와 Spring 기반의 Kotlin 코드와 비교해보자.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;select&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;case&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;ctx&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;():
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;case&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;msg&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;consumer&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Messages&lt;/span&gt;():
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;handle&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;msg&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;먼저 Go로 작성된 코드의 경우 언제 종료되고 누가 종료 시키고, 어떤 이벤트를 기다리는지 명확하다.
명확성을 중시하기 때문에 함수를 작성하더라도 무엇을 하는지가 다 드러난다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;@Transactional&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;fun&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;createOrder&lt;/span&gt;(&lt;span style="color:#f92672"&gt;..&lt;/span&gt;.) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Busines logic
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;반면 Spring 기반의 Kotlin으로 작성된 코드의 경우 가독성은 높지만 명확성은 낮다.
위 함수는 Transaction의 시작과 종료가 어떻게 이뤄지고, 언제 Commit/Rollback 되고, Proxy 생성 여부 등을 모두 알아야 함수를 올바르게 이해했다고 할 수 있다.&lt;/p&gt;
&lt;p&gt;즉 Spring은 비지니스 로직에 대한 가독성을 중시하며, 비지니스 로직을 이해하는데 방해가 되는 영역들을
Proxy, AOP 등을 통해 동작 방식을 감추는 특징을 가지고 있다.&lt;/p&gt;
&lt;p&gt;그렇다고 Spring이 항상 Go보다 가독성이 좋다는 말은 아니며, Go가 가독성을 중시하지 않는다는 말도 아니다. 위에서 설명하는 가독성은 &lt;strong&gt;비지니스 로직에 대한 가독성&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;가독성의 표면적 성질은 들여쓰기, 네이밍 컨벤션, 포매팅, 줄 길이와 같이 코드가 어떻게 쓰였는가 에 대한 것이다. Go에서는 이 영역의 상당 부분을 gofmt가 기계적으로 해결한다.&lt;/p&gt;
&lt;h2 id="go가-명확한-코드-작성을-위해-포기한-것들"&gt;Go가 명확한 코드 작성을 위해 포기한 것들&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;삼항 연산자가 없다&lt;/strong&gt; — &lt;code&gt;x = cond ? a : b&lt;/code&gt;는 중첩되는 순간 해석 비용이 폭발한다. Go는 if 문을 쓰게 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;예외(exception)가 없다&lt;/strong&gt; — 에러는 값이고, 제어 흐름은 눈에 보여야 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;while / do-while이 없다&lt;/strong&gt; — 반복은 &lt;code&gt;for&lt;/code&gt; 하나로 표현한다. 반복문을 읽는 방법도 하나면 충분하다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;암묵적 형변환이 없다&lt;/strong&gt; — &lt;code&gt;int&lt;/code&gt;와 &lt;code&gt;int64&lt;/code&gt; 조차 명시적으로 변환해야 한다. 타입이 조용히 바뀌는 일은 없다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;구현 상속이 없다&lt;/strong&gt; — 깊은 상속 계층을 따라 올라가며 동작을 재구성할 필요가 없다. 조합(composition)으로 표현한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;연산자 오버로딩이 없다&lt;/strong&gt; — &lt;code&gt;+&lt;/code&gt;는 언제나 &lt;code&gt;+&lt;/code&gt; 다. 연산자의 의미가 타입마다 달라지지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;각 항목은 표현력의 손실이다. 같은 로직을 더 길게 써야 한다. Go 팀이 이 트레이드오프에서 일관되게 선택한 것은
**&amp;ldquo;작성자의 편의&amp;quot;가 아니라 &amp;ldquo;독자의 확신&amp;rdquo;**이다. 기능이 하나 빠질 때마다, 코드를 읽는 사람이 고려해야 할 경우의 수가 하나 줄어든다.&lt;/p&gt;
&lt;h2 id="장황함과-명확함의-트레이드오프"&gt;장황함과 명확함의 트레이드오프&lt;/h2&gt;
&lt;p&gt;Java/Kotlin의 경우에는 exception과 try-catch를 통해서 Happy Path를 깔끔하게 작성할 수 있다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;try&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; User user &lt;span style="color:#f92672"&gt;=&lt;/span&gt; repository.&lt;span style="color:#a6e22e"&gt;find&lt;/span&gt;(id);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Profile profile &lt;span style="color:#f92672"&gt;=&lt;/span&gt; profileService.&lt;span style="color:#a6e22e"&gt;load&lt;/span&gt;(user);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; notifier.&lt;span style="color:#a6e22e"&gt;send&lt;/span&gt;(profile);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;} &lt;span style="color:#66d9ef"&gt;catch&lt;/span&gt; (IOException e) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ...
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;하지만 실제 실행 경로(제어 흐름) 이 코드에 명확하게 드러나지 않는다.
특히 throw 된 예외가 어느 스택 프레임에서 잡히는지는 그 코드만 읽어서는 알 수 없다. 호출자, 호출자의 호출자, 어쩌면 프레임워크의 최상단까지 올라가야 한다.&lt;/p&gt;
&lt;p&gt;반면 Go에서는 아래와 같이 err를 확인하는 패턴이 코드베이스 전체에 반복된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;user&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;err&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;repository&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Find&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;id&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;err&lt;/span&gt; &lt;span style="color:#f92672"&gt;!=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;nil&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;err&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;이러한 장황함을 통해서 얻는 것은 &lt;strong&gt;명확성&lt;/strong&gt;이다. Go는 에러를 값으로 다루는 특징이 있다.
이 덕분에 모든 실패 경로가 함수에 다 드러난다.&lt;/p&gt;
&lt;h2 id="좋은-코드는-해석decode이-필요-없다"&gt;좋은 코드는 해석(decode)이 필요 없다&lt;/h2&gt;
&lt;p&gt;Clever 한 코드는 어려운 문제를 해결하는 것처럼 보인다. 하지만 대부분의 경우 복잡성을 제거한 것이 아니라 코드 내부에 압축해 놓은 것이다.
반면 Clear 한 코드는 어려운 문제를 쉬워 보이게 만든다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Clever Code&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;processJobs&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;jobs&lt;/span&gt; []&lt;span style="color:#a6e22e"&gt;Job&lt;/span&gt;) []&lt;span style="color:#a6e22e"&gt;Result&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make([]&lt;span style="color:#a6e22e"&gt;Result&lt;/span&gt;, len(&lt;span style="color:#a6e22e"&gt;jobs&lt;/span&gt;))
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;WaitGroup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;job&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;jobs&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Add&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt;[&lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;] = &lt;span style="color:#a6e22e"&gt;process&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;job&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Wait&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;위 코드는 짧고 빠르게 보이지만 여러 goroutine이 동시에 write 하는 복잡성이 숨겨져 있고 data race는 없는지, 실패하면 worker는 어떻게 처리하는지, worker 종료 시점은 언제인지 등
사고해야하는 내용이 증가했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Clear Code&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;func&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;processJobs&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;jobs&lt;/span&gt; []&lt;span style="color:#a6e22e"&gt;Job&lt;/span&gt;) []&lt;span style="color:#a6e22e"&gt;Result&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;jobCh&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Job&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;resultCh&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; make(&lt;span style="color:#66d9ef"&gt;chan&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Result&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;sync&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;WaitGroup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Worker Pool&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 제한된 개수의 worker가 job을 받아 병렬 처리한다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &amp;lt; &lt;span style="color:#ae81ff"&gt;4&lt;/span&gt;; &lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;&lt;span style="color:#f92672"&gt;++&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Add&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Done&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Worker&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Producer가 전달한 Job을 처리하고 Result를 Consumer에게 전달한다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;job&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;jobCh&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;resultCh&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;process&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;job&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Producer&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 처리해야 할 Job을 생성하고 Worker에게 전달한다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;_&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;job&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;jobs&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;jobCh&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;job&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 더 이상 전달할 Job이 없음을 알림&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; close(&lt;span style="color:#a6e22e"&gt;jobCh&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Worker 종료 감시자&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// 모든 Worker가 작업을 완료하면 Result Channel을 닫는다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;go&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;func&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;wg&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Wait&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; close(&lt;span style="color:#a6e22e"&gt;resultCh&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt; []&lt;span style="color:#a6e22e"&gt;Result&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Consumer&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Worker가 처리한 Result를 소비한다.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;result&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;resultCh&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt; = append(&lt;span style="color:#a6e22e"&gt;results&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;result&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;results&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;코드는 길어졌지만 공유 메모리가 없으며 누가 데이터를 소유하는지 명확하다.&lt;/p&gt;
&lt;p&gt;위 두 코드의 차이는 문제의 복잡성을 없애는 것이 아니라, 복잡성을 사람이 이해할 수 있는 위치에 배치하는 데 있다.&lt;/p&gt;
&lt;p&gt;첫 번째는 코드 내부에 복잡성을 압축하여 독자가 코드를 &lt;strong&gt;해석(decode)&lt;/strong&gt; 해야 하는 양이 증가했다.&lt;/p&gt;
&lt;p&gt;후자는 &amp;ldquo;여러 goroutine이 결과 배열을 직접 수정한다&amp;quot;라는 저수준 복잡성을 &amp;ldquo;Producer → Worker → Consumer&amp;quot;라는 사람이 이해하기 쉬운 모델로 변환하였다.
각 컴포넌트는 책임 경계가 명확하고 자신의 책임에만 집중한다.
코드는 길어졌지만, 동료는 구현 세부사항을 해석하는 대신 데이터의 흐름과 책임의 경계만 이해하면 된다.&lt;/p&gt;
&lt;h2 id="마무리"&gt;마무리&lt;/h2&gt;
&lt;p&gt;AI가 코드를 대신 작성해주는 시대에 엔지니어의 실력은 무엇으로 증명될까?
타이핑 속도, 문법 지식, 언어의 숨겨진 기능을 아는 것, clever 한 트릭도 아니다. 이런 것들은 AI가 가장 잘 대체하는 영역이다.&lt;/p&gt;
&lt;p&gt;AI 시대에 인간은 &lt;mark&gt;&lt;em&gt;&lt;strong&gt;문제를 정의하는 능력과 복잡한 문제를 단순하게 표현하는 능력&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;을 갖춰야 한다.
무엇을 만들어야 하는지 정의하고, 복잡함을 구조로 정리하고, 그 구조를 사람과 AI 모두가
오해 없이 이해할 수 있는 형태로 코드에 새기는 능력이 중요하다.&lt;/p&gt;
&lt;p&gt;복잡성을 잘 다루기 위해서는 어디까지 책임을 나눌 것인지, 어떤 도메인이 필요한지, 무엇을 숨기고 노출할 것인지 등을 고려해야 한다.&lt;/p&gt;
&lt;p&gt;이러한 복잡성을 어디에서 다룰지 결정하는 것을 **추상화(Abstraction)**라고 한다.
&lt;strong&gt;Hides unnecessary details to Reduce complexity&lt;/strong&gt;는 추상화를 설명하는 가장 좋은 문장 중 하나라고 생각한다.&lt;/p&gt;
&lt;p&gt;AI 시대에서 추상화를 소프트웨어 엔지니어링에 잘 적용하는 능력을 갖추는 것이 중요하다.&lt;/p&gt;
&lt;h2 id="references"&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dave.cheney.net/2019/07/09/clear-is-better-than-clever"&gt;Dave Cheney - Clear is better than clever&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://go-proverbs.github.io/"&gt;Go Proverbs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>DECOUPLING</title><link>https://whitevision.dev/posts/decoupling/</link><pubDate>Thu, 30 Jul 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/decoupling/</guid><description>&lt;p&gt;디커플링(DECOUPLING)은 &lt;mark&gt;&lt;em&gt;&lt;strong&gt;생산자와 소비자간 결합도를 줄여서 각각의 독립성을 보장하고 유지보수성과 확장성을 얻는 설계 원칙&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;이며 시스템 아키텍처나 애플리케이션 아키텍처를 설계하고 개선함에 있어서 꼭 필요하다.
이 글에서는 디커플링이 왜 필요한지 시스템·애플리케이션·조직이라는 세 가지 관점에서 실제 사례를 통해 설명한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;결합도&lt;/strong&gt;란 모듈간의 상호 의존하는 정도를 의미한다. 따라서 높은 결합도는 상호 의존성이 높기 때문에 한쪽의 변경이 다른 한쪽에 영향을 줄 가능성이 높아진다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;독립성&lt;/strong&gt;은 한쪽 코드를 고쳐도 다른 쪽에 영향을 주지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;생산자와 소비자&lt;/strong&gt;란 시스템 아키텍처 수준에서 서비스 단위일 수도 있고 혹은 애플리케이션 아키텍처 수준에서 모듈, 클래스, 함수 단위일 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;시스템 단위에서 높은 결합도로 인한 독립성이 보장되지 않는 케이스를 살펴보자.&lt;/p&gt;
&lt;p&gt;첫 번째 예를 살펴보자. 주문 서비스(생산자)와 결제 서비스(소비자)가 있다. 만약 결제 서비스가 주문 서비스의 DB를 직접 조회하는 경우, 주문 서비스가 DB의 스키마, 제약조건, 컬럼명 등을
수정하게 되면 결제 서비스도 그에 맞게 시스템을 고쳐야 한다. 즉 서비스간 계약을 DB 스키마에 의존하면 강결합이 발생할 수 있다.
다른 단점으로는 주문 서비스에서 DB 변경이 있을 때마다 사전에 결제 팀과 협의해야 하며, 만약 주문 서비스 개발자의 실수로 DB 변경을 하게 되면 결제 팀은
이슈를 파악하기 위해서 분석하는 시간과 노력이 필요하다. 즉 디버깅 포인트가 증가하게 된다.&lt;/p&gt;
&lt;p&gt;두 번째 예를 살펴보자. 주문 서비스와 쿠폰 서비스가 있다. 주문 서비스가 쿠폰 서비스에 너무 강결합이 되어있어서 쿠폰 서비스가 죽으면 주문도 할 수 없게 된다.
하지만 제품 관점에서 쿠폰이 없어도 주문은 가능해야 한다.&lt;/p&gt;
&lt;p&gt;세 번째 예를 살펴보자. 주문 생성 &amp;gt; 결제 생성 &amp;gt; 재고 차감 &amp;gt; 배송 생성과 같은 일련의 흐름이 Two-Phase Commit과 같은 분산 트랜잭션(Distributed Transaction)으로
엮이게 되면 어느 하나만 실패하도 전체가 롤백 되어야 한다.&lt;/p&gt;
&lt;p&gt;이외에도 다양한 케이스가 많다. 다음으로는 애플리케이션 단위에서 높은 결합도로 인한 독립성이 보장되지 않는 케이스를 살펴보자.&lt;/p&gt;
&lt;p&gt;대표적인 예가 공통 DTO를 공유하는 경우다. 예를 들어 UserDto가 OrderService, PaymentService, CouponService, DeliveryService 등에서 사용되고 있다면
UserDto의 필드를 제거하거나 추가하기가 정말 어렵다. UserDto에 있던 address 필드를 삭제했는데 DeliveryService에서 사이드 이펙트가 발생할 수 있다.&lt;/p&gt;
&lt;p&gt;마지막으로 조직 수준의 아키텍처 설계에서도 디커플링을 필수로 고려해야 한다.&lt;/p&gt;
&lt;p&gt;예를 들어 초기에는 주문, 결제, 배송 모두 하나의 팀에서 개발하는 상황이 많다. 하지만 제품의 트래픽이 많아지고 제품이 확장 될 수록
각 컴포넌트의 확장성, 유지 보수성, 트러블 슈팅, 관측성 등 많은 요소들을 고려해야 한다. 따라서 기존의 적은 인원으로는 각 컴포넌트를 모두 담당하기 힘들다.
그렇기 때문에 채용을 하게 되며 각 컴포넌트를 담당할 전문 인력들을 배치하게된다. 이 경우 주문팀, 결제팀 같이 팀이 분리될 가능성이 있다.
팀이 분리된 이후에 강결합을 갖는 컴포넌트들에 대해 기능을 하나 수정하더라도 여러 팀과 일정 조율과 협의가 필요할 수 있다.
즉 조직의 의사소통 구조가 점점 복잡해지고 기능 추가에 많은 커뮤니케이션 비용이 들게 되며 실제 배포까지 하루 걸리던 게 1주일이 될 수도 있다.&lt;/p&gt;
&lt;p&gt;따라서 각 팀이 독립적으로 확장 가능 하도록 만들기 위해서는 컴포넌트들이 &lt;strong&gt;&amp;ldquo;구현&amp;quot;이 아니라 &amp;ldquo;계약(Contract)&amp;rdquo; 중심&lt;/strong&gt;으로 바뀌어야 유연성이 극대화 된다.
SOLID 원칙 중 하나인 DIP(Dependency Inversion Principle)의 개념을 빌려 설명하면 &lt;strong&gt;&amp;ldquo;의존성이 추상(abstraction)에 의존하며 구체(concretion)에는 의존하지 않는 시스템&amp;rdquo;&lt;/strong&gt;
을 갖춰야 한다.&lt;/p&gt;
&lt;p&gt;&lt;mark&gt;&lt;em&gt;&lt;strong&gt;디커플링은 단순히 컴포넌트를 분리하는 기술이 아니라, 변경의 영향을 격리하는 설계 원칙이다.&lt;/strong&gt;&lt;/em&gt;&lt;/mark&gt;&lt;/p&gt;</description></item><item><title>MEMORY MODEL</title><link>https://whitevision.dev/posts/memory-model/</link><pubDate>Thu, 30 Jul 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/memory-model/</guid><description>&lt;p&gt;메모리 모델은 프로그램에서 발생하는 메모리 읽기(Read)와 쓰기(Write)가 여러 스레드에서 어떤 순서로 관찰될 수 있는지, 그리고 어떤 동기화 연산이 그 순서를 보장하는지를 정의하는 언어의 명세이다.&lt;/p&gt;
&lt;p&gt;이 글에서는 OS가 하드웨어를 어떻게 추상화했는지 부터 순차적 일관성 개념과 Go에서의 Memory Model을 살펴본다.&lt;/p&gt;
&lt;h2 id="process"&gt;Process&lt;/h2&gt;
&lt;p&gt;운영 체제(OS, Operating System)는 하드웨어와 애플리케이션 사이에 위치한 시스템 소프트웨어다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────────────┐
│ Application │
├──────────────────────────────────────────────────────────────┤
│ System Call Interface │
├──────────────────────────────────────────────────────────────┤
│ Core OS Functions │
├──────────────────────────────────────────────────────────────┤
│ Hardware │
│ (CPU, RAM, Disk, NIC, Timer, GPU, ...) │
└──────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;OS는 프로그램을 효율적으로 실행하도록 CPU, 메모리, 디스크와 같은 하드웨어를 추상화하고 프로그램이 이를 효율적으로 사용할 수 있도록 관리하는 역할을 담당한다.&lt;/strong&gt;
OS가 시스템 리소스를 안정적이고 효율적으로 사용하기 위해서 실행중인 프로그램을 추상화한 **프로세스(Process)**를 관리한다.&lt;/p&gt;
&lt;p&gt;초기의 컴퓨터는 하나의 프로그램이 끝나야 다음 프로그램을 실행할 수 있었다. 이후 시간이 지날 수록 컴퓨터가 빨라지고 사용자가 많아짐으로써 여러 프로그램을 동시에 실행하고 싶어졌다.&lt;/p&gt;
&lt;p&gt;하지만 &lt;strong&gt;CPU는 동시에 하나의 명령어(한 가지 일)만 처리&lt;/strong&gt;할 수 있으며 많은 &lt;strong&gt;프로그램들은 동시에 컴퓨터의 물리적 메모리(RAM)을 공유&lt;/strong&gt;해야 한다.&lt;/p&gt;
&lt;p&gt;프로그램A와 프로그램B가 완전히 동일한 메모리를 공유하고 있다면, 프로그램A가 같은 메모리 위치를 건드려서 프로그램B가 기대하지 않은 값을 읽어갈 수 있다.
이렇게 같은 메모리 위치를 건드리고, 그 중 최소 하나가 쓰기인 경우를 **데이터 경합(Data Race)**이라고 한다.&lt;/p&gt;
&lt;p&gt;이러한 제약조건을 고려했을때 컴퓨터에서 여러 프로그램을 &lt;strong&gt;안정적이고 효율적으로 동시에 실행&lt;/strong&gt;하기 위해서는 아래 2가지를 고려해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;프로그램별 독립적인 실행 환경&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CPU의 전환빈도&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;프로그램별 독립적인 실행환경을 제공하기 위해서 실행중인 프로그램을 추상화한 것을 **프로세스(Process)**라고 한다.
그리고 이 독립성을 보장하는 가장 중요한 매커니즘이 **가상 메모리(Virtual Memory)**이다. 가상 메모리는 RAM을 추상화 한 것으로 볼 수 있다.
운영체제는 각 프로세스에 독립적인 가상 주소 공간(Virtual Address Space)을 제공하여 서로의 메모리에 직접 접근하지 못하도록 격리(Isolation)한다.&lt;/p&gt;
&lt;p&gt;CPU는 한 번에 한 가지 일만 할 수 있다. 여러 프로그램이 존재하는 경우 동시에 실행되는 것처럼 보이게 하기 위해서는 CPU의 전환 빈도(conversion frequency)가 빨라야 한다. 프로그램A에서 프로그램B로 전환이 일어날 때 프로그램A는 일시 중단(suspend) 되어야 한다. 일시 중단된 이후 다시 재개(resume)될 때, 이전에 유지했던 상태(context)를 이용해야 한다. 즉, 일시 중단되었을때의 상태(context)를 저장할 자료 구조가 필요하다.&lt;/p&gt;
&lt;p&gt;이 자료 구조가 **PCB(Process Control Block)**이며, PCB는 &lt;strong&gt;운영체제가 프로세스의 실행 상태(context)와 관리 정보를 저장&lt;/strong&gt;한다.&lt;/p&gt;
&lt;p&gt;OS는 **컨텍스트 스위칭(Context Switching)**을 통해서 실행 중인 프로세스를 다른 프로세스로 교체할 수 있다. 컨텍스트 스위칭은 다음과 같이 실행된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;OS는 현재 CPU에서 실행되는 프로세스의 컨텍스트를 저장한다. 이 컨텍스트에는 PC, Stack Pointer, 범용 레지스터 등이 포함된다.&lt;/li&gt;
&lt;li&gt;OS는 CPU의 또 다른 프로세스에서 저장된 컨텍스트를 복원하고, CPU에서 이 프로세스의 실행을 시작하도록 한다. 이때 이전에 명령이 중지됐던 곳에서 계속 이어 실행한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="thread"&gt;Thread&lt;/h2&gt;
&lt;p&gt;프로세스는 독립적인 가상 메모리, PID, CPU 실행 상태 등 독립적인 실행 환경을 갖는다. 하지만 기존 프로세스 방식의 멀티 프로그래밍은 새로운 프로세스를 생성할 때 전체 주소 공간을 복제해야하기 때문에 비용이 많이 들고 속도가 느리다는 단점이 있었다.&lt;/p&gt;
&lt;p&gt;이러한 문제를 해결하기 위해서 단일 프로세스내에서 동시에 실행될 수 있으며 프로세스의 메모리와 리소스를 공유하면서 별도의 실행 스택(Stack)을 보유하는 **스레드(Thread)**라는 추상화가 탄생하였다.
현대 애플리케이션에서 스레드란 커널 수준 스레드와, 애플리케이션이 관리하는 사용자 수준 스레드로 나뉜다.&lt;/p&gt;
&lt;h2 id="sequential-consistency"&gt;Sequential Consistency&lt;/h2&gt;
&lt;p&gt;우리는 아래와 같은 코드를 작성하면 순서대로 실행될 것이라고 생각한다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;x = 1
y = 2
z = x + y
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;하지만 놀랍게도 작성된 코드가 항상 순서대로 실행되진 않는다. 왜냐하면 현대의 &lt;strong&gt;CPU와 Compiler는 성능을 극대화하기 위해 명령어의 실행 순서를 변경(Reordering)&lt;/strong&gt; 할 수 있기 때문이다.
즉, 우리가 작성한 코드의 순서와 CPU가 실행하는 순서는 다를 수 있다는 것이다. 이러한 최적화에 따른 문제는 멀티 스레드 환경에서 발생한다.&lt;/p&gt;
&lt;p&gt;예를 들어 CPU가 항상 &amp;ldquo;쓰기 &amp;gt; 메모리 반영 &amp;gt; 다음 명령&amp;rdquo; 순서로 처리한다면 처리량이 엄청 떨어질 것이다. 따라서 CPU는 메모리 반영(e.g 캐시 응답)응답을 기다리지 않고 다음 명령어를 빠르게 실행하기 위해서 &lt;strong&gt;Store Buffer&lt;/strong&gt;라는 하드웨어 메모리 공간에 임시 저장을 하게 된다. 그리고 버퍼에 담긴 데이터는 비동기적으로 나중에 캐시나 메모리에 최종 반영된다.
즉, CPU는 지연 시간을 감소시키기 위해서 &lt;strong&gt;버퍼와 비동기 매커니즘&lt;/strong&gt;을 활용한다.&lt;/p&gt;
&lt;p&gt;순차적 일관성(Sequential Consistency)은 모든 메모리 연산이 하나의 전역 순서대로 실행되고, 각 스레드의 프로그램 순서도 그대로 유지되는 것처럼 보이는 이상적인 실행 모델이다. 하지만 위에서 설명한 것 처럼 CPU는 순차적 일관성을 항상 보장하지 않는다. 또한 CPU(x86, ARM, Apple Silicon, &amp;hellip;)마다 메모리 동작 방식이 다르고 Compiler(Go, JVM, GCC, &amp;hellip;)마다 최적화 방식이 다르기 때문에 프로그래머가 모든 하드웨어를 이해하면서 프로그램을 작성하는 것은 불가능하다.&lt;/p&gt;
&lt;p&gt;따라서 멀티 스레드 환경에서 안전하게 프로그래밍을 하기 위해서는 &lt;strong&gt;CPU와 Compiler에게 &amp;ldquo;여기에서는 반드시 이 순서를 지켜라&amp;quot;라고 알려주는 계약&lt;/strong&gt;이 필요하다. 이 계약이 ***메모리 모델(Memory Model)***이다. 메모리 모델은 프로그램에서 발생하는 메모리 읽기(Read)와 쓰기(Write)가 여러 스레드에서 어떤 순서로 관찰될 수 있는지, 그리고 어떤 동기화 연산이 그 순서를 보장하는지를 정의하는 언어의 명세이다.&lt;/p&gt;
&lt;h2 id="go"&gt;Go&lt;/h2&gt;
&lt;p&gt;**&lt;a href="https://go.dev/ref/mem#model"&gt;Go Memory Model&lt;/a&gt;**에서 &amp;ldquo;Data-Race-Free 프로그램은 Sequentially Consistent하게 동작한다&amp;quot;라고 설명한다.
즉, 프로그램에 Data Race가 없고, Channel, Mutex, Atomic 등 올바른 Synchronization을 사용했다면, CPU가 어떤 최적화를 하든, Compiler가 어떤 재배치를 하든,
프로그램이 순차적 일관성을 달성한다고 생각해도 된다는 의미이다.&lt;/p&gt;
&lt;p&gt;메모리 모델에서 가장 중요한 개념이 &lt;strong&gt;Happens-Before&lt;/strong&gt;이며, &amp;ldquo;A에서 수행한 메모리 쓰기(Write)가 B에서 반드시 관찰(Visible)된다&amp;quot;라는 의미를 가지고 있다.&lt;/p&gt;
&lt;p&gt;Go Memory Model에서는 아래와 같이 설명하고 있다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The happens before relation is defined as the transitive closure of the union of the sequenced before and synchronized before relations.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Happens-Before는 &lt;strong&gt;Sequenced-Before + Synchronized-Before + Transitive Closure&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h3 id="sequenced-before"&gt;Sequenced-Before&lt;/h3&gt;
&lt;p&gt;같은 고루틴 내부에서는 프로그램 순서가 Happens-Before의 일부가 된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;x&lt;/span&gt; = &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;y&lt;/span&gt; = &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;y&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="synchronized-before"&gt;Synchronized-Before&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;If a package p imports package q, the completion of q’s init functions happens before the start of any of p’s.&lt;/li&gt;
&lt;li&gt;The start of the function main.main happens after all init functions have finished.&lt;/li&gt;
&lt;li&gt;The go statement that starts a new goroutine happens before the goroutine’s execution begins.&lt;/li&gt;
&lt;li&gt;A send on a channel happens before the corresponding receive from that channel completes.&lt;/li&gt;
&lt;li&gt;The closing of a channel happens before a receive that returns a zero value because the channel is closed.&lt;/li&gt;
&lt;li&gt;A receive from an unbuffered channel happens before the send on that channel completes.&lt;/li&gt;
&lt;li&gt;The k’th receive on a channel with capacity C happens before the k+C’th send from that channel completes.&lt;/li&gt;
&lt;li&gt;For any sync.Mutex or sync.RWMutex variable l and n &amp;lt; m, call n of l.Unlock() happens before call m of l.Lock() returns.&lt;/li&gt;
&lt;li&gt;A single call of f() from once.Do(f) happens (returns) before any call of once.Do(f) returns.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="transitive-closure"&gt;Transitive Closure&lt;/h3&gt;
&lt;p&gt;Transitive Closure는 전이성을 의미한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// goroutine A&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;x&lt;/span&gt; = &lt;span style="color:#ae81ff"&gt;10&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// goroutine B&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;&amp;lt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;ch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;y&lt;/span&gt; = &lt;span style="color:#a6e22e"&gt;x&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Unlock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// goroutine C&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;mu&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Lock&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;fmt&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Println&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;y&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;위 코드는 아래와 같은 체인이 형성된다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;x = 10
→ Send(ch)
───HB──▶ Receive(ch)
→ y = x
→ Unlock(mu)
───HB──▶ Lock(mu)
→ Print(y)
&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;→&lt;/code&gt; : 같은 고루틴 내부의 Sequenced-Before&lt;/li&gt;
&lt;li&gt;&lt;code&gt;────HB────▶&lt;/code&gt; : 고루틴 간 Synchronizes-Before (채널, Mutex 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, Print(y) 시점에서는 x = 10의 결과가 반드시 관찰 가능(Visible) 하다. 이것이 Happens-Before의 핵심이다.&lt;/p&gt;
&lt;h2 id="references"&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://people.cs.rutgers.edu/~pxk/articles/big-ideas-os.html"&gt;Big Ideas in the History of Operating Systems&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Dive Into Systems: A Gentle Introduction to Computer Systems / Suzanne J. Matthews, Tia Newhall, Kevin C. Webb&lt;/li&gt;
&lt;li&gt;&lt;a href="https://research.swtch.com/gomm"&gt;Go Memory Model&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>SOLID</title><link>https://whitevision.dev/posts/solid/</link><pubDate>Thu, 30 Jul 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/solid/</guid><description>&lt;h2 id="single-responsibility-principle"&gt;Single Responsibility Principle&lt;/h2&gt;
&lt;h3 id="from-unclebob"&gt;From: UncleBob&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Gather together the things that change for the same reasons. Separate things that change for different reasons.&lt;/p&gt;
&lt;p&gt;Microservices do not solve this problem. You can create a tangled microservice, or a tangled set of microservices if you mix code that changes for different reasons.&lt;/p&gt;
&lt;p&gt;Dan North’s answer to the SRP is to “Write Simple Code”. I agree. The SRP is one of the ways we keep the code simple.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://speakerdeck.com/tastapod/why-every-element-of-solid-is-wrong"&gt;Dan North’s position on SOLID&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;Just write simple code&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-clean-architecture"&gt;From: Clean Architecture&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;SOLID 원칙 중에서 그 의미가 가장 잘 전달되지 못한 원칙은 바로 SRP이다. 프로그래머가 이 원칙의 이름을 듣는다면 모든 모듈이 단 하나의 일만 해야 한다는 의미로 받아들이기 쉽다.&lt;/p&gt;
&lt;p&gt;단 하나의 일만 해야 한다는 원칙은 따로 있다. 바로 함수는 반드시 하나의, 단 하나의 일만 해야 한다는 원칙이다.
이 원칙은 커다란 함수를 작은 함수들로 리팩터링 하는 더 저수준에서 사용된다.&lt;/p&gt;
&lt;p&gt;역사적으로 SRP는 다음과 같이 기술되어 왔다.&lt;/p&gt;
&lt;p&gt;단일 모듈은 변경의 이유가 하나, 오직 하나 뿐이어야 한다.&lt;/p&gt;
&lt;p&gt;변경의 이유란 바로 사용자와 이해관계자를 가리키며, 다음과 같이 바꿔 말할 수도 있다.&lt;/p&gt;
&lt;p&gt;하나의 모듈은 하나의, 오직 하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다.&lt;/p&gt;
&lt;p&gt;사용자와 이해관계자란 단어를 여기에 쓰는 것은 올바르지 않다. 이러한 집단을 액터라고 하는데 SRP의 최종 버전은 아래와 같다.&lt;/p&gt;
&lt;p&gt;하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.&lt;/p&gt;
&lt;p&gt;모듈은 함수와 데이터 구조로 구성된 응집된 집합이다.&lt;/p&gt;
&lt;p&gt;응집된(cohesive)이라는 단어가 SRP를 암시하며, 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성(cohesion)이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-head-first-ooad"&gt;From: Head First OOAD&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;필요 없는 복잡한 연관 관계를 피해서, 쉽게 재사용 가능하게 만들 수 있게 도와 주는 원칙이 SRP와 OCP이다.&lt;/p&gt;
&lt;p&gt;DRY는 하나의 기능을 한 곳에 두자는 내용이다.&lt;/p&gt;
&lt;p&gt;SRP는 클래스가 한 가지 일만 잘하게 하자는 내용이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-오브젝트"&gt;From: 오브젝트&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;SRP(단일 책임 원칙) 맥락에서 &amp;lsquo;책임&amp;rsquo;이라는 말이 &amp;lsquo;변경의 이유&amp;rsquo;라는 의미로 사용된다는 점이다. SRP는 &lt;a href="https://baekjungho.github.io/wiki/driven/oop-oo/#%EC%97%AD%ED%95%A0-%EC%B1%85%EC%9E%84-%ED%98%91%EB%A0%A5"&gt;역할, 책임, 협력&lt;/a&gt;에서 이야기하는 책임과는 다르며 변경과 관련된 더 큰 개념을 가리킨다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-한-번-읽으면-두-번-깨닫는-객체지향-프로그래밍"&gt;From: 한 번 읽으면 두 번 깨닫는 객체지향 프로그래밍&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;시스템의 모든 객체는 하나의 책임만을 가져야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-real-world-software-development"&gt;From: Real-World Software Development&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;SRP는 쉽게 관리하고 유지보수하는 코드를 구현하는 데 도움을 주는 포괄적인 소프트웨어 개발 지침이다.&lt;/p&gt;
&lt;p&gt;다음 두 가지를 보완하기 위해 SRP를 적용한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;한 클래스는 한 기능만 책임진다.&lt;/li&gt;
&lt;li&gt;클래스가 바뀌어야 하는 이유는 오직 하나여야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SRP를 적용하면 코드가 바뀌어야 하는 이유가 한 가지로 제한되므로 더 튼튼한 코드를 만들 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="open-closed-principle"&gt;Open-Closed Principle&lt;/h2&gt;
&lt;h3 id="from-unclebob-1"&gt;From: UncleBob&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;A Module should be open for extension but closed for modification.&lt;/p&gt;
&lt;p&gt;Dan’s answer is “write simple code”. Again, I agree. And, ironically, he is right. Simple code is both open and closed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-clean-architecture-1"&gt;From: Clean Architecture&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;OCP(개방-폐쇄 원칙)는 &amp;ldquo;소프트웨어 개체(artifact)는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다&amp;quot;는 원칙이다.&lt;/p&gt;
&lt;p&gt;소프트웨어 아키텍처를 공부하는 가장 근본적인 이유가 바로 이 때문이다. 만약 요구사항을 살짝 확장하는 데 소프트웨어를 엄청나게 수정해야 한다면, 그 소프트웨어 시스템을 설계한 아키텍트는 엄청난 실패에 맞닥뜨린것이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-head-first-ooad-1"&gt;From: Head First OOAD&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;필요 없는 복잡한 연관 관계를 피해서, 쉽게 재사용 가능하게 만들 수 있게 도와 주는 원칙이 SRP와 OCP이다.&lt;/p&gt;
&lt;p&gt;OCP를 사용하면, 기존 코드를 변경하기 보다는 확장을 통해 변경을 가능하게 한다. 예를 들어 클래스에 private 메서드가 여러개 있다면 이들은 수정에 닫혀있는 것이다. 하지만 그 private 메서드를 여러 방법으로 호출할 수 있도록 public 메서드를 추가할 수 있는데 이때 private 메서드의 행동은 변경하진 않지만 확장하고 있는 것이니 이것도 OCP가 사용되는 또 다른 예이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-오브젝트-1"&gt;From: 오브젝트&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;시스템에 새로운 로직을 추가하기 위해 클라이언트 코드를 수정할 필요가 없다는 것(기존 코드에 아무런 영향을 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 것)을 OCP라고 한다. 이것이 객체지향 설계가 전통적인 방식에 비해 변경하고 확장하기 쉬운 구조를 설계할 수 있는 이유다.&lt;/p&gt;
&lt;p&gt;소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;확장에 대해 열려있다: 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 동작을 추가해서 애플리케이션의 기능을 확장할 수 있다.&lt;/li&gt;
&lt;li&gt;수정에 대해 닫혀있다: 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;OCP는 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계라고 이야기한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-한-번-읽으면-두-번-깨닫는-객체지향-프로그래밍-1"&gt;From: 한 번 읽으면 두 번 깨닫는 객체지향 프로그래밍&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;이미 사용 중인 클래스 내부의 코드를 수정하게 되면, 사이드 이펙트가 우려된다.
또한, 수정한 코드의 정상작도 유무에 더해서, 사이드 이펙트 발생 유무도 번거롭게 테스트해야 한다.&lt;/p&gt;
&lt;p&gt;클래스는 기능 확장에 대해서는 열려있지만, 코드 수정에 대해서는 닫혀있어야 한다.&lt;/p&gt;
&lt;p&gt;객체지향의 근본 조건인 상속과 오버라이드, 폴리모피즘이 OCP를 지원한다. Strategy Pattern 이나 Decorator Pattern을 공부하는 것도 OCP 원리를 좀 더 명확하게 이해할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-real-world-software-development-1"&gt;From: Real-World Software Development&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;OCP는 코드베이스에 유연성을 추가하고 유지보수성을 개선하는 데 도움을 주는 원칙이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="liskov-substitution-principle"&gt;Liskov Substitution Principle&lt;/h2&gt;
&lt;h3 id="from-unclebob-2"&gt;From: UncleBob&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;A program that uses an interface must not be confused by an implementation of that interface.&lt;/p&gt;
&lt;p&gt;This principle is about keeping abstractions crisp and well-defined.&lt;/p&gt;
&lt;p&gt;Dan’s slides are entirely correct on this topic; he simply missed the point of the principle. Simple code is code that maintains crisp subtype relationships.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-clean-architecture-2"&gt;From: Clean Architecture&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;1998년 Barbara Liskov는 subtype을 아래와 같이 정의했다.&lt;/p&gt;
&lt;p&gt;여기에서 필요한 것은 다음과 같은 치환(substitution) 원칙이다. S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면 S는 T의 하위타입이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-오브젝트-2"&gt;From: 오브젝트&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;LSP를 한 마디로 정리하면 &amp;ldquo;서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다&amp;quot;는 것으로 클라이언트가 &amp;ldquo;차이점을 인식하지 못한 채 파생 클래스의 인터페이스를 통해 서브 클래스를 사용할 수 있어야 한다&amp;quot;는 것이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-한-번-읽으면-두-번-깨닫는-객체지향-프로그래밍-2"&gt;From: 한 번 읽으면 두 번 깨닫는 객체지향 프로그래밍&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;LSP란 &amp;ldquo;자식 클래스는 부모 클래스가 사용되는 곳(이 클래스 그룹을 사용하는 다른 클래스)에 대체될 수 있어야 한다&amp;quot;는 원칙이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-real-world-software-development-2"&gt;From: Real-World Software Development&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;LSP는 클래스 상속과 인터페이스 구현을 올바르게 사용하도록 도와준다.&lt;/p&gt;
&lt;p&gt;형식(type)이라는 용어가 등장하면 클래스나 인터페이스를 떠올리자. 하위형식(subtype)이라는 용어는 두 형식이 부모와 자식 관계를 이루었음을 의미한다. 즉, 클래스 상속이나 인터페이스 구현이 이에 해당한다.&lt;/p&gt;
&lt;p&gt;LSP를 자세히 들여다보면 LSP를 네 개의 부분으로 쪼갤 수 있다.&lt;/p&gt;
&lt;p&gt;q(x)는 T 형식의 x 객체를 증명할 수 있는 공식이다. 그러면 S 형식의 객체 y가 있고 S가 T의 하위형식이라면 q(y)는 참이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;하위형식에서 선행조건을 더 할 수 없음&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;선행조건은 어떤 코드가 동작하는 조건을 결정한다.&lt;/li&gt;
&lt;li&gt;LSP란 부모가 지정한 것보다 더 많은 선행조건을 요구할 수 없음을 의미한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;하위형식에서 후행조건을 악화시킬 수 없음&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;후행조건은 어떤 코드를 실행한 다음에 만족해야 하는 규칙이다.&lt;/li&gt;
&lt;li&gt;부모가 부작용을 포함하거나 어떤 값을 반환한다면 자식도 그래야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;슈퍼형식의 불변자는 하위형식에서 보존됨&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;불변자란 밀물과 썰물처럼 항상 변하지 않는 어떤 것을 가리킨다.&lt;/li&gt;
&lt;li&gt;상속 관계의 부모와 자식 클래스가 있을 때, 부모 클래스에서 유지되는 모든 불변자는 자식 클래스에서도 유지되어야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;히스토리 규칙&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;기본적으로 자식 클래스는 부모가 허용하지 않은 상태 변화를 허용하지 않아야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 id="interface-segregation-principle"&gt;Interface Segregation Principle&lt;/h2&gt;
&lt;h3 id="from-unclebob-3"&gt;From: UncleBob&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Keep interfaces small so that users don’t end up depending on things they don’t need.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-clean-architecture-3"&gt;From: Clean Architecture&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;img src="isp.png" alt="ISP 구조 다이어그램"&gt;&lt;/p&gt;
&lt;p&gt;User1의 소스 코드는 U1Ops와 op1에는 의존하지만 OPS에는 의존하지 않게 된다. 따라서 OPS에서 발생한 변경이 User1과는 전혀 관계없는 변경이 라면, User 1을 다시 컴파일하고 새로 배포하는 상황은 초래되지 않는다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-오브젝트-3"&gt;From: 오브젝트&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계원칙을 ISP라고 한다.&lt;/p&gt;
&lt;p&gt;이 원칙은 비대한 인터페이스의 단점을 해결한다. 비대한 인터페이스를 가지는 클래스는 응집성이 없는 인터페이스를 가지는 클래스다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-한-번-읽으면-두-번-깨닫는-객체지향-프로그래밍-3"&gt;From: 한 번 읽으면 두 번 깨닫는 객체지향 프로그래밍&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;ISP는 SRP와 비슷하다. SRP는 클래스 관점에서 클래스가 하나의 일만 해야 한다고 가이드라인을 제시한다. ISP는 &amp;ldquo;인터페이스 관점에서 클래스는 자신이 사용하지 않는 메서드에 의존하면 안된다&amp;quot;라는 인터페이스 사용 가이드라인을 제시한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-real-world-software-development-3"&gt;From: Real-World Software Development&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;ISP는 다음 사상을 추구한다. 어떤 클래스도 사용하지 않는 메서드에 의존성을 갖지 않아야 한다. 이는 불필요한 결합을 만들기 때문이다. 이 원칙을 따르면 응집도도 높아진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="dependency-inversion-principle"&gt;Dependency Inversion Principle&lt;/h2&gt;
&lt;h3 id="from-unclebob-4"&gt;From: UncleBob&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Depend in the direction of abstraction. High level modules should not depend upon low level details.&lt;/p&gt;
&lt;p&gt;In every case Dan’s slides end with: Just write simple code. This is good advice. However, if the years have taught us anything it is that simplicity requires disciplines guided by principles. It is those principles that define simplicity. It is those disciplines that constrain the programmers to produce code that leans towards simplicity.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-architecture-patterns-with-python"&gt;From: Architecture Patterns with Python&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;고수준 모듈(high-level module)&lt;/strong&gt; 은 여러분의 조직에서 정말 중요하게 여기는 코드다. 제약 회사에 근무한다면 고수준 모듈은 환자와 임상시험을 관리한다. 은행에서 근무한다면 고수준 모듈은 거래나 외환을 관리한다. 고수준 모듈은 실세게의 개념을 처리하는 함수, 클래스, 패키지를 말한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;저수준 모듈(low-level module)&lt;/strong&gt; 은 여러분의 조직에서 신경 쓰지 않는 코드다. HR 부서가 파일 시스템이나 네트워크 소켓에 관심을 갖을 가능성이 낮다. 여러분이 SMTP, HTTP, AMQP 등을 재무팀과 의논하는 경우도 드물 것이다. 기술적이지 않은 관련자들에게 이런 저수준 개념은 흥미로운 대상이 아니거나 중요하지 않다. 이런 관련자들은 고수준의 개념이 정상으로 작동되는지만 신경 쓴다. 급여 시스템이 정시에 정상적 실행되면 사업 부서는 급여 시스템이 크론 잡(cron job)인지, 쿠버네티스(Kubernetes)에서 실행되는 일시적인 함수인지에 대해 신경 쓰지 않는다.&lt;/p&gt;
&lt;p&gt;의존성은 꼭 임포트나 호출만을 뜻하지 않는다. 대신 한 모듈이 다른 모듈을 필요로 하거나, 안다는 좀더 일반적인 생각이 의존성이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-clean-architecture-4"&gt;From: Clean Architecture&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;DIP에서 말하는 유연성이 극대화된 시스템이란 소스 코드 의존성이 추상(abstraction)에 의존하며 구체(concretion)에는 의존하지 않는 시스템을 말한다.&lt;/p&gt;
&lt;p&gt;우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰(volatile) 구체적이 요소다. 그리고 이 구체적인 요소는 우리가 열심히 개발하는 중이라 자주 변경될 수밖에 없는 모듈들이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;변동성이 큰 구체 클래스를 참조하지 말라.&lt;/strong&gt; 대신 추상 인터페이스를 참조하라. 이 규칙은 언어가 정적 타입이든 동적 타입이든 관계없이 모두 적용된다. 또한 이 규칙은 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리(Abstract Factory)를 사용하도록 강제한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;변동성이 큰 구체 클래스로부터 파생하지 말라.&lt;/strong&gt; 이 규칙은 이전 규칙의 따름 정리이지만, 별도로 언급할 만한 가치가 있다. 정적 타입 언어에서 상속은 소스 코드에 존재하는 모든 관계 중에서 가장 강력한 동시에 뻣뻣해서 변경하기 어렵다. 따라서 상속은 아주 신중하게 사용해야 한다. 동적 타입 언어라면 문제가 덜 되지만, 의존성을 가진다는 사실에는 변함이 없다. 따라서 신중에 신중을 거듭하는 게 가장 현명한 선택이다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;구체 함수를 오버라이드 하지 말라.&lt;/strong&gt; 대체로 구체 함수는 소스 코드 의존성을 필요로 한다. 따라서 구체 함수를 오버라이드 하면 이러한 의존성을 제거할 수 없게 되며, 실제로는 그 의존성을 상속하게 된다. 이러한 의존성을 제거하려면, 차라리 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라.&lt;/strong&gt; 사실 이 실천법은 DIP 원칙을 다른 방식으로 풀어쓴 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-오브젝트-4"&gt;From: 오브젝트&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;DIP는 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안된다. 둘 모두 추상화에 의존해야 한다.&lt;/li&gt;
&lt;li&gt;추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-한-번-읽으면-두-번-깨닫는-객체지향-프로그래밍-4"&gt;From: 한 번 읽으면 두 번 깨닫는 객체지향 프로그래밍&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;DIP는 구체적인 클래스 대신 추상적인 클래스에 의존하라는 뜻이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AS-IS&lt;/strong&gt;: ArrayList list = new ArrayList()&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TO-BE&lt;/strong&gt;: List list = new ArrayList()&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3 id="from-real-world-software-development-4"&gt;From: Real-World Software Development&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;의존관계 역전의 정의는 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;높은 수준의 모듈은 낮은 수준의 모듈에 의존하지 않아야 한다. 두 모듈 모두 추상화에 의존해야 한다.&lt;/li&gt;
&lt;li&gt;추상화는 세부 사항에 의존하지 않아야 한다. 세부 사항은 추상화에 의존해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 id="references"&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Clean Architecture / Robert C. Martin 저 / 인사이트&lt;/li&gt;
&lt;li&gt;Head First Object-Oriented Analysis &amp;amp; Design / 브렛 맥래프린, 게리 폴리스, 데이빗 웨스트 저 / O&amp;rsquo;REILLY&lt;/li&gt;
&lt;li&gt;오브젝트 / 조영호 저 / 위키북스&lt;/li&gt;
&lt;li&gt;한 번 읽으면 두 번 깨닫는 객체지향 프로그래밍 / 김동헌 저 / e 비즈북스&lt;/li&gt;
&lt;li&gt;Real-World Software Development 실전 자바 소프트웨어 개발 / 라울-게이브리얼 우르마, 리처드 워버턴 저 / O&amp;rsquo;REILLY&lt;/li&gt;
&lt;li&gt;Architecture Patterns with Python / 해리 퍼시벌, 밥 그레고리 저 / O&amp;rsquo;REILLY&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.cleancoder.com/uncle-bob/2020/10/18/Solid-Relevance.html"&gt;SOLID Relevance - UncleBob&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mangsby.com/blog/programming/%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5-5%EC%9B%90%EC%B9%99-solid%EC%9D%80-%EA%B5%AC%EC%8B%9C%EB%8C%80%EC%9D%98-%EC%9C%A0%EB%AC%BC%EC%9D%B8%EA%B0%80/"&gt;번역본 - 객체지향 5원칙 (SOLID)은 구시대의 유물 ?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>DOMAIN DRIVEN DESIGN</title><link>https://whitevision.dev/posts/ddd/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/ddd/</guid><description>&lt;p&gt;DDD is about discussion, listening, understanding, discovery and business value,
all in an effort to centralize knowledge.&lt;/p&gt;</description></item><item><title>SPEC DRIVEN DEVELOPMENT</title><link>https://whitevision.dev/posts/spec-driven-development/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/spec-driven-development/</guid><description>&lt;p&gt;**스펙 주도 개발(SPEC DRIVEN DEVELOPMENT)**은 구현 코드보다 명세(Specification)를 먼저
정의하고, 그 명세를 기준으로 설계, 구현, 테스트를 진행하는 개발방식이다. 이렇게 작성된
&amp;ldquo;명세는 시스템이 만족해야하는 계약과 규칙의 SSOT&amp;quot;이다.&lt;/p&gt;
&lt;p&gt;AI 시대에 인간의 역할은 코드를 직접 작성하는 것이 아닌, 모호한 문제를 잘 정의하고, 목표와
맥락을 AI에게 전달하여 AI가 구현한 결과물을 검증하는 역할이 되었다고 생각한다. 인간이
이러한 역할을 잘 수행해내기 위한 방법론이 SPEC DRIVEN DEVELOPMENT라고 생각한다.&lt;/p&gt;
&lt;p&gt;나는 다음과 같은 Workflow를 사용한다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Context Document (Human)&lt;/li&gt;
&lt;li&gt;Write Spec (AI)&lt;/li&gt;
&lt;li&gt;Spec Validation (Human)&lt;/li&gt;
&lt;li&gt;Task Document (Human)&lt;/li&gt;
&lt;li&gt;Task Planning (AI)&lt;/li&gt;
&lt;li&gt;Task Planning Review (Human)&lt;/li&gt;
&lt;li&gt;Implementation by TDD (AI)&lt;/li&gt;
&lt;li&gt;Implementation Review (Human)&lt;/li&gt;
&lt;li&gt;(Before Push) Code Review (AI)&lt;/li&gt;
&lt;li&gt;Apply Code Review Feedback (Human)&lt;/li&gt;
&lt;li&gt;Verification (Human)&lt;/li&gt;
&lt;li&gt;SSOT Update (AI)&lt;/li&gt;
&lt;li&gt;Commit Message Generation (AI)&lt;/li&gt;
&lt;li&gt;Commit &amp;amp; Push (Human)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;새로운 기능 구현을 해야하는 경우를 생각해보자. 기획자가 작성한 기획 문서를 통해서 개발자는
문제, 제약조건, 스펙에 대해서 작성을 해야한다. 이것을 Context라고 한다. Context Document를
작성하고 나서 AI에게 이를 잘 구조화하여 Spec으로 만들도록 시킨다. 스펙 문서의 산출물은
다음과 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Testcase&lt;/li&gt;
&lt;li&gt;Sequence Diagram&lt;/li&gt;
&lt;li&gt;Functional Requirements&lt;/li&gt;
&lt;li&gt;Non-Functional Requirements&lt;/li&gt;
&lt;li&gt;API contracts&lt;/li&gt;
&lt;li&gt;Edge Cases&lt;/li&gt;
&lt;li&gt;Domain Rules&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;개발자는 작성된 Spec 문서를 검토하고 개선하는 루프를 진행하게 된다. 완성도 있는 Spec 문서를
작성하기 위해서는 Context Document가 최대한 디테일해야 한다. 요구사항에 대한 디테일, 모호한
경계 정의, AI가 하지 말아야할 것을 결정, 제약 조건 등을 디테일하게 정의해야한다.&lt;/p&gt;
&lt;p&gt;만약, 인간이 검토가 끝나면 위 스펙 문서들을 프로젝트 루트 하위에 &lt;code&gt;docs(SSOT)&lt;/code&gt; 폴더를 만들어서
이동 시키고, &lt;code&gt;AGENTS.md&lt;/code&gt;가 docs를 항상 참고하도록 한다. 이렇게 하면 실제 Agents가 구현을
시작할때 SSOT를 항상 참고하게 된다.&lt;/p&gt;
&lt;p&gt;이렇게 STEP3(Spec Validation)까지 끝나면 인간은 기능을 완성 시키기 위해서 작업을 Ticket
단위로 쪼개야 한다. 그리고 Ticket을 처리하기 위해 다시 작업에 대한 문제 정의, 목표, 제약조건
등을 AI에게 제시하기 위한 문서를 작성해야 하며 이 단계가 STEP4인 Task Document 단계이다.
AI에게 Task Document를 주고 Task Planning을 시키며, AI가 작성한 Planning Document를
인간이 검토한다. 검토가 완료되면 구현을 AI에게 시킨다. 이때 중요한 점은
TEST DRIVEN DEVELOPMENT를 사용하는 것이다.&lt;/p&gt;
&lt;p&gt;내 생각에 테스트 코드는 이제 더 이상 인간을 위한 코드가 아니다. 테스트 코드는 AI가
프로젝트를 빠르게 이해하기 위한 정책 문서라고 생각한다. 그리고 TDD는 AI가 구현시 회귀
문제를 일으키지 않도록 하기 위함이며, 프로덕션 코드가 테스트 없이 작성되지 않도록(즉,
검증되지 않은 코드가 작성되지 않도록) 방지하기 위한 도구라고 생각한다. 이렇게 쌓인 테스트
코드들은 추후에 대규모 리팩토링을 진행함에 있어서 회귀 문제를 단 한번도 일으키지 않도록
막아주는데 도움이 된다.&lt;/p&gt;
&lt;p&gt;AI의 구현이 완료되면 인간은 구현 리뷰를 진행한다. 이때 AI가 구현이 완료되면 자신이
진행하면서 얻은 인사이트와 여정을 별도 문서로 남기도록 하면 좋다. STEP9, 10은 생략할 수
있고(원격 저장소에 AI 기반 자동 리뷰 시스템이 구축되어있다면 생략해도 된다), 구현 리뷰
단계에서 Verification을 같이 진행할 수 있다.&lt;/p&gt;</description></item><item><title>TRADEOFF</title><link>https://whitevision.dev/posts/tradeoff/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0900</pubDate><guid>https://whitevision.dev/posts/tradeoff/</guid><description>&lt;p&gt;소프트웨어 아키텍처 선정, 구현, 시스템 설계 등 정답이 없는 과정에서 올바른 결정을 내리기 위해
근거와 논리를 가지고 균형을 고민하고 선택하는 과정은 엔지니어가 얻을 수 있는 낭만이자 즐거움이다.&lt;/p&gt;
&lt;p&gt;엔지니어링에서 중요한 결정의 대부분은 정답을 고르는 문제가 아니라 트레이드오프를 선택하는 문제이다.
트레이드오프를 잘 하기 위해서는 정보를 객관적으로 분석하고 평가하여 합리적인 판단을 내리는
사고 과정인 &amp;lsquo;비판적 사고&amp;rsquo;를 잘해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;비판적 사고를 잘 하기 위한 핵심 프레임은 &lt;strong&gt;문제 정의 → 가설 수립 → 검증&lt;/strong&gt;이다.&lt;/li&gt;
&lt;li&gt;트레이드오프를 잘 하기 위한 핵심 프레임은 &lt;strong&gt;문제 정의 → 제약 → 선택지 → 결정 사항&lt;/strong&gt;이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;가장 중요한 것은 &lt;strong&gt;문제 정의&lt;/strong&gt;이다. 문제를 제대로 정의해야 헛걸음을 하지 않는다. 다음으로는
&lt;strong&gt;불변 조건&lt;/strong&gt; 등을 고려해야 한다. 즉, &amp;ldquo;어떤 상황에서도 절대 깨지면 안되는 것&amp;quot;을 고려하는 것이다.
예를 들면 &amp;ldquo;한 차량은 동시에 두 배차를 갖지 않는다&amp;rdquo;, &amp;ldquo;돈은 중복 생성되지 않는다&amp;rdquo;,
&amp;ldquo;하나의 계정은 하나의 활성 세션만 가진다&amp;quot;와 같은 것이다.&lt;/p&gt;
&lt;p&gt;다음으로는 &lt;strong&gt;제약 조건(constraints)&lt;/strong&gt; 을 명시하는 것이다. 트래픽 규모, 지연 허용 범위, 팀 역량,
운영 복잡도, 확장성, 유지보수성, SLA/SLO, 컨슈머 특징 등을 명시해야 한다. 더 나아가
&lt;strong&gt;시간의 흐름에 따른 변화&lt;/strong&gt;도 고려해보면 좋다. 예를 들어 단기간 내에 우리 서비스가 글로벌
서비스로 나가야 한다던지, 트래픽이 얼마나 예상되는지 등이 있다. 이러한 제약 조건은 설계를
결정함에 있어서 중요한 역할을 한다.&lt;/p&gt;
&lt;p&gt;예를 들어 플랫폼을 구축할 때는 &lt;strong&gt;아키텍처 원칙(Architecture Principle)&lt;/strong&gt; 이 엄청 중요한데,
아키텍처 원칙을 제대로 세우려면 제약 조건이 필요하다. 아키텍처 원칙을 잘 수립하기 위해서는
만들고자 하는 서비스(서버)의 &lt;strong&gt;역할 및 책임&lt;/strong&gt;을 명확히 해야 한다. 즉, 어떠한 기능적 요구사항을
수행해야하는지를 알아야 한다. 그 다음으로는 비기능적 요구사항으로 어떤 것이 중요한지 정하는
것이다. 위에서 설명한 원칙은 비기능적 요구사항에 부합한다. 예를 들면 Latency보다 데이터
정합성이 중요하다는 등이 있다. 이러한 비기능적 요구사항은 역할 및 책임을 명확히 하지 않으면
도출할 수 없다.&lt;/p&gt;
&lt;p&gt;다음으로는 &lt;strong&gt;선택지를 열거&lt;/strong&gt;하는 것이다. 예를 들어 메시징을 위한 무언가를 도입해야하는데
MQ와 Pub/Sub 중 어떤 것을 사용할지, 이는 제약 조건을 기반으로 결정되며 더 나아가 구체적인
제품을 열거 할 수 있다. 예를 들어 Pub/Sub이라면 Redis를 쓸지, NATS를 쓸지 등을 나열하는
것이다.&lt;/p&gt;
&lt;p&gt;그 다음 &lt;strong&gt;평가 기준&lt;/strong&gt;을 세워본다. 일관성, 처리량, 레이턴시, 비용, 복잡성, 운영 난이도 등이 있다.
그리고 지금까지 정리한 내용을 바탕으로 &lt;strong&gt;가장 합리적인 선택&lt;/strong&gt;을 내리는 것이다.&lt;/p&gt;</description></item></channel></rss>