<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://wlswo.me/feed.xml" rel="self" type="application/atom+xml" /><link href="https://wlswo.me/" rel="alternate" type="text/html" /><updated>2026-09-13T14:25:45+00:00</updated><id>https://wlswo.me/feed.xml</id><title type="html">Ephemeris</title><subtitle>JaeJin의 기술 블로그 - 개발, 컴퓨터 과학, 소프트웨어 엔지니어링에 대한 글을 씁니다.</subtitle><author><name>JaeJin</name></author><entry><title type="html">백엔드 기술 복습</title><link href="https://wlswo.me/2026/07/24/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B8%B0%EC%88%A0-%EB%B3%B5%EC%8A%B5/" rel="alternate" type="text/html" title="백엔드 기술 복습" /><published>2026-07-24T17:00:00+00:00</published><updated>2026-07-24T17:00:00+00:00</updated><id>https://wlswo.me/2026/07/24/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B8%B0%EC%88%A0-%EB%B3%B5%EC%8A%B5</id><content type="html" xml:base="https://wlswo.me/2026/07/24/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B8%B0%EC%88%A0-%EB%B3%B5%EC%8A%B5/"><![CDATA[<blockquote>
  <p>헷갈렸던 기술들을 복습한다.</p>
</blockquote>

<h2 id="1-배열-vs-연결리스트">1. 배열 vs 연결리스트</h2>

<h3 id="연산별-비교">연산별 비교</h3>

<table>
  <thead>
    <tr>
      <th>연산</th>
      <th>배열</th>
      <th>연결리스트</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>인덱스 접근</td>
      <td>O(1)</td>
      <td>O(n)</td>
    </tr>
    <tr>
      <td>값 탐색</td>
      <td>O(n)</td>
      <td>O(n)</td>
    </tr>
    <tr>
      <td>맨 앞 삽입/삭제</td>
      <td>O(n)</td>
      <td>O(1)</td>
    </tr>
    <tr>
      <td>중간 삽입/삭제 (위치를 이미 참조 중)</td>
      <td>O(n)</td>
      <td>O(1)</td>
    </tr>
    <tr>
      <td>중간 삽입/삭제 (인덱스만 아는 경우)</td>
      <td>O(n)</td>
      <td>O(n)</td>
    </tr>
  </tbody>
</table>

<p>마지막 줄이 자주 잘못 이야기되는 부분이다. 연결리스트의 중간 삽입이 O(1)인 것은 <strong>해당 노드의 참조를 이미 손에 들고 있을 때</strong>뿐이다. <code class="language-plaintext highlighter-rouge">list.add(5000, x)</code>처럼 인덱스만 아는 상태라면 head부터 5000번 따라가야 하므로 결국 O(n)이다. 순회 중 이터레이터로 삭제하는 상황이 아니라면 이 이점은 잘 발생하지 않는다.</p>

<h3 id="진짜-차이는-메모리-배치">진짜 차이는 메모리 배치</h3>

<p>시간복잡도 표만으로는 왜 실무에서 배열 기반 자료구조를 기본으로 쓰는지 설명되지 않는다. 이유는 캐시다.</p>

<p>CPU는 메모리를 바이트 단위가 아니라 <strong>캐시 라인 단위(보통 64바이트)</strong> 로 읽어온다. 4바이트 <code class="language-plaintext highlighter-rouge">int</code> 배열을 순회하면 한 번의 메모리 접근으로 16개 원소가 캐시에 올라오고, 나머지 15번은 캐시 히트다. 게다가 하드웨어 프리페처가 순차 접근 패턴을 감지해 다음 라인을 미리 당겨온다.</p>

<p>연결리스트는 노드가 힙 여기저기에 흩어져 있다. 노드를 하나 따라갈 때마다 새로운 캐시 라인을 읽어야 하고, 주소가 예측 불가능하니 프리페처도 도움이 되지 않는다. <strong>포인터 추적(pointer chasing)</strong> 은 매 단계가 메모리 지연(수십~수백 사이클)에 그대로 노출된다.</p>

<p>공간 오버헤드도 있다. Java <code class="language-plaintext highlighter-rouge">LinkedList</code>의 노드는 값 참조 외에 prev/next 참조와 객체 헤더를 함께 들고 있어, 원소 하나당 수십 바이트가 추가로 든다. 담는 데이터가 작을수록 이 비율은 커진다.</p>

<h3 id="그래서">그래서</h3>

<ul>
  <li>기본은 <code class="language-plaintext highlighter-rouge">ArrayList</code>. 크기 증가는 1.5배씩 늘리는 방식이라 <strong>분할 상환하면 추가 비용은 O(1)</strong> 이다.</li>
  <li>큐/덱이 필요하면 <code class="language-plaintext highlighter-rouge">LinkedList</code>보다 <code class="language-plaintext highlighter-rouge">ArrayDeque</code>가 낫다. 원형 배열 기반이라 양끝 연산이 O(1)이면서 캐시 지역성도 유지된다.</li>
  <li>연결리스트가 실제로 유리한 경우는 “순회하면서 조건에 맞는 노드를 제거”처럼 <strong>참조를 이미 들고 있는 상태의 삽입/삭제가 반복될 때</strong> 정도다.</li>
</ul>

<hr />

<h2 id="2-이진-탐색-트리와-b-tree">2. 이진 탐색 트리와 B-tree</h2>

<h3 id="최악의-경우가-on인-이유">최악의 경우가 O(n)인 이유</h3>

<p>BST 탐색 비용은 정확히는 O(n)이나 O(log n)이 아니라 <strong>O(h)</strong>, 즉 트리 높이에 비례한다. 문제는 h가 삽입 순서에 따라 달라진다는 점이다.</p>

<p>1, 2, 3, 4, 5를 순서대로 삽입하면 새 값은 항상 현재 노드보다 커서 오른쪽 자식으로만 붙는다. 결과는 한쪽으로 완전히 치우친(skewed) 트리이고, 이는 사실상 연결리스트다. h = n이 되어 탐색이 O(n)이 된다.</p>

<p>O(log n)이 나오는 전제는 “매 비교마다 후보가 절반씩 줄어든다”는 것이고, 그 전제는 좌우 서브트리 크기가 비슷할 때만 성립한다. 정렬된 데이터를 그대로 넣는 것은 실무에서 드물지 않기 때문에(예: 자동 증가 ID, 타임스탬프) 이건 이론적인 걱정이 아니다.</p>

<h3 id="균형-트리">균형 트리</h3>

<ul>
  <li><strong>AVL 트리</strong>: 모든 노드에서 좌우 높이 차를 1 이하로 유지한다. 균형이 엄격해서 탐색이 빠른 대신, 삽입/삭제 때 회전이 더 자주 일어난다. 읽기가 많은 워크로드에 맞는다.</li>
  <li><strong>Red-Black 트리</strong>: “루트에서 리프까지 검은 노드 수가 같다” 같은 느슨한 규칙으로 최대 높이를 2log(n+1) 이하로 묶는다. 균형이 덜 엄격한 대신 재조정 비용이 적어 쓰기가 섞인 워크로드에 유리하다. Java의 <code class="language-plaintext highlighter-rouge">TreeMap</code>, <code class="language-plaintext highlighter-rouge">HashMap</code>의 트리화된 버킷이 이 구조다.</li>
</ul>

<h3 id="db-인덱스가-b-tree인-이유">DB 인덱스가 B-tree인 이유</h3>

<p>메모리 자료구조와 디스크 자료구조는 최적화 대상이 다르다. 디스크(혹은 SSD)에서는 <strong>비교 횟수가 아니라 페이지 I/O 횟수</strong>가 비용을 지배한다.</p>

<p>이진 트리는 노드마다 자식이 둘뿐이라 원소가 100만 개면 높이가 20이고, 최악의 경우 20번의 랜덤 I/O가 필요하다. B-tree는 노드 하나를 <strong>디스크 페이지 크기(InnoDB 기본 16KB)</strong> 에 맞춰 키를 수백 개씩 담는다. 팬아웃이 수백이 되면 같은 100만 건도 높이가 3 정도로 떨어진다. 게다가 상위 레벨 노드는 버퍼 풀에 캐시되어 있어 실제 디스크 접근은 리프 한 번 수준이다.</p>

<p><strong>B+tree</strong>는 여기서 한 걸음 더 나간다.</p>

<ul>
  <li>실제 데이터(혹은 PK)는 <strong>리프 노드에만</strong> 두고 내부 노드는 키만 담는다 → 팬아웃이 더 커진다</li>
  <li>리프 노드끼리 <strong>양방향 링크드 리스트</strong>로 연결된다 → <code class="language-plaintext highlighter-rouge">WHERE created_at BETWEEN ...</code> 같은 범위 스캔에서 트리를 다시 타지 않고 리프를 따라 훑으면 된다</li>
</ul>

<p>인덱스 설계에서 정렬과 범위 조건이 왜 그렇게 잘 맞아떨어지는지가 여기서 나온다.</p>

<hr />

<h2 id="3-유니크-인덱스의-실제-비용">3. 유니크 인덱스의 실제 비용</h2>

<h3 id="풀스캔이-아니다">풀스캔이 아니다</h3>

<p>유니크 제약은 내부적으로 <strong>유니크 인덱스(B-tree)</strong> 로 구현된다. 삽입 시 중복 확인은 그 인덱스를 타는 탐색이므로 <strong>O(log n)</strong> 이지, 테이블 전체를 훑는 것이 아니다.</p>

<h3 id="그럼-실제-비용은-어디서-나오나">그럼 실제 비용은 어디서 나오나</h3>

<ol>
  <li><strong>인덱스 유지 비용</strong>: 쓰기마다 B-tree에 키를 넣고, 페이지가 꽉 차면 분할(split)이 일어난다. 랜덤한 값(UUID 등)을 인덱스로 잡으면 분할과 페이지 파편화가 잦아진다.</li>
  <li><strong>체인지 버퍼를 못 쓴다</strong>: InnoDB는 세컨더리 인덱스 갱신을 메모리에 모아뒀다 나중에 반영하는 change buffer 최적화를 갖고 있는데, <strong>유니크 인덱스에는 적용되지 않는다.</strong> 중복 여부를 지금 당장 확인해야 하므로 해당 페이지를 반드시 디스크에서 읽어와야 하기 때문이다. 쓰기가 많은 테이블에서 유니크 인덱스가 비싼 진짜 이유가 이것이다.</li>
  <li><strong>잠금</strong>: 중복 체크 과정에서 인덱스 레코드에 락이 잡힌다. 동시에 같은 키로 들어온 트랜잭션은 대기하고, 격리 수준에 따라 갭 락이 얽히면 데드락이 생길 수도 있다.</li>
</ol>

<h3 id="그래도-거는-게-맞는-경우">그래도 거는 게 맞는 경우</h3>

<p>선착순 쿠폰 발급처럼 중복·초과 발급이 곧 사고인 도메인에서는 <code class="language-plaintext highlighter-rouge">(coupon_id, member_id)</code>에 UNIQUE를 걸어 <strong>최종 방어선</strong>으로 둔다.</p>

<p>애플리케이션에서 “조회해보고 없으면 INSERT”하는 방식은 조회와 삽입 사이에 다른 트랜잭션이 끼어들 수 있어 동시성 상황에서 언제든 뚫린다. Redis 카운터나 분산 락으로 앞단을 막더라도 그것들은 장애·재시도·타임아웃 상황에서 완벽하지 않다. DB 유니크 제약은 그 모든 게 실패해도 뚫리지 않는 마지막 계층이다.</p>

<p>중복 시 예외를 던지게 두고 애플리케이션에서 “이미 발급됨”으로 변환하거나, <code class="language-plaintext highlighter-rouge">INSERT ... ON DUPLICATE KEY UPDATE</code>로 흡수하는 식으로 설계한다. 성능 주장이 갈릴 때는 결국 <strong>실측한 숫자</strong>가 근거다.</p>

<hr />

<h2 id="4-뮤텍스-vs-세마포어">4. 뮤텍스 vs 세마포어</h2>

<h3 id="개념">개념</h3>

<ul>
  <li><strong>뮤텍스</strong>: 상호배제를 위한 락. <strong>소유권</strong>이 있어서 잠근 스레드만 풀 수 있다. 동시 진입은 1개.</li>
  <li><strong>세마포어</strong>: 사용 가능한 자원 개수를 세는 카운터. <code class="language-plaintext highlighter-rouge">acquire()</code>로 감소, <code class="language-plaintext highlighter-rouge">release()</code>로 증가하며 0이면 대기한다. N개까지 동시 진입.</li>
</ul>

<h3 id="이진-세마포어와-뮤텍스는-왜-다른가">이진 세마포어와 뮤텍스는 왜 다른가</h3>

<p>N=1인 세마포어는 겉보기 동작이 뮤텍스와 같아 보이지만 <strong>소유권 개념이 없다</strong>. 이 차이가 실제로 만드는 결과는 다음과 같다.</p>

<ul>
  <li>A가 획득한 이진 세마포어를 B가 반환할 수 있다. 뮤텍스에서는 오류다.</li>
  <li>뮤텍스는 재진입(reentrant) 지원이 가능하다. 같은 스레드가 이미 들고 있는 락을 다시 잡을 수 있다. 세마포어는 소유자를 모르니 그대로 자기 자신을 블록시킨다.</li>
  <li><strong>우선순위 역전</strong> 대응이 가능하다. 낮은 우선순위 스레드가 락을 쥔 채 밀려나 높은 우선순위 스레드가 무한정 대기하는 상황에서, 뮤텍스는 소유자를 알기 때문에 소유자의 우선순위를 임시로 올려주는 <strong>우선순위 상속</strong>을 구현할 수 있다.</li>
</ul>

<h3 id="용도-구분">용도 구분</h3>

<ul>
  <li><strong>상호배제</strong>: 뮤텍스. Java에서는 <code class="language-plaintext highlighter-rouge">synchronized</code>, <code class="language-plaintext highlighter-rouge">ReentrantLock</code>.</li>
  <li><strong>자원 개수 제한</strong>: 세마포어. 커넥션 풀, 동시 요청 수 제한, API 호출 스로틀링.</li>
  <li><strong>시그널링</strong>: 세마포어. 생산자가 <code class="language-plaintext highlighter-rouge">release()</code>, 소비자가 <code class="language-plaintext highlighter-rouge">acquire()</code>하는 식으로 스레드 간 신호를 주고받는 데 쓸 수 있다. 반환 주체가 획득 주체와 달라도 되기 때문에 가능한 사용법이고, 뮤텍스로는 할 수 없다.</li>
</ul>

<hr />

<h2 id="5-대칭키-vs-비대칭키">5. 대칭키 vs 비대칭키</h2>

<h3 id="두-방식">두 방식</h3>

<ul>
  <li><strong>대칭키</strong>: 암호화와 복호화에 같은 키를 쓴다(AES 등). 빠르다. AES는 CPU 전용 명령어(AES-NI) 지원까지 있어 처리량이 크다. 문제는 “그 키를 상대에게 어떻게 안전하게 전달하느냐”다.</li>
  <li><strong>비대칭키</strong>: 공개키와 개인키 한 쌍을 쓴다(RSA, ECC 등). 공개키로 암호화한 것은 개인키로만 풀리고, 개인키로 서명한 것은 공개키로 검증된다. 대칭키보다 수백~수천 배 느리다.</li>
</ul>

<p>또 하나 실무적인 차이로, 비대칭키는 한 번에 다룰 수 있는 데이터 크기가 키 길이에 묶여 있다. 대용량 데이터를 통째로 비대칭키로 암호화하는 방식 자체가 성립하지 않는다.</p>

<h3 id="tls는-둘을-어떻게-조합하나">TLS는 둘을 어떻게 조합하나</h3>

<p>핵심은 <strong>비대칭키를 데이터 암호화가 아니라 인증과 키 합의에만 쓰고, 실제 데이터는 대칭키로 처리한다</strong>는 것이다.</p>

<p>TLS 1.3 기준 흐름은 이렇다.</p>

<ol>
  <li>클라이언트와 서버가 <strong>ECDHE</strong>(타원곡선 Diffie-Hellman) 로 각자의 임시 공개값을 교환하고, 각자 자기 개인값과 상대 공개값을 조합해 <strong>같은 비밀값을 도출</strong>한다. 이 비밀값 자체는 네트워크에 흐르지 않는다.</li>
  <li>서버는 인증서와, 핸드셰이크 내용에 대한 <strong>개인키 서명</strong>을 보낸다. 클라이언트는 CA 체인으로 인증서를 검증하고 서명을 확인해 “지금 이 서버가 그 인증서의 개인키를 실제로 갖고 있다”는 사실을 확인한다.</li>
  <li>도출한 비밀값에서 대칭키를 파생시키고, 이후 모든 데이터는 <strong>AES-GCM 같은 대칭 암호</strong>로 주고받는다.</li>
</ol>

<p>여기서 두 가지를 짚어둘 만하다.</p>

<ul>
  <li>비대칭키의 역할은 <strong>신원 인증(서명)</strong> 이고, 키 합의는 ECDHE가 맡는다. RSA 공개키로 대칭키를 직접 암호화해 보내는 방식(RSA key transport)은 TLS 1.3에서 제거됐다.</li>
  <li>임시(ephemeral) 키를 쓰는 이유는 <strong>전방 비밀성(forward secrecy)</strong> 때문이다. 나중에 서버 개인키가 유출되더라도, 과거에 녹화해둔 트래픽은 복호화할 수 없다. 세션마다 키가 새로 만들어지고 버려지기 때문이다.</li>
</ul>

<hr />

<h2 id="6-kafka-컨슈머-장애-복구">6. Kafka 컨슈머 장애 복구</h2>

<h3 id="리밸런싱">리밸런싱</h3>

<p>컨슈머 그룹은 브로커 쪽 <strong>그룹 코디네이터</strong>가 관리한다. 각 컨슈머는 백그라운드 스레드로 주기적인 하트비트를 보내고, <code class="language-plaintext highlighter-rouge">session.timeout.ms</code> 안에 하트비트가 끊기면 코디네이터가 그 컨슈머를 죽은 것으로 판단해 <strong>리밸런싱</strong>을 트리거한다. 담당하던 파티션은 살아 있는 다른 컨슈머에게 재할당된다.</p>

<p>프로세스가 살아 있어도 쫓겨날 수 있다. 한 번의 <code class="language-plaintext highlighter-rouge">poll()</code> 이후 다음 <code class="language-plaintext highlighter-rouge">poll()</code>까지 <code class="language-plaintext highlighter-rouge">max.poll.interval.ms</code>를 넘기면(=메시지 처리가 너무 오래 걸리면) 코디네이터는 그 컨슈머가 진행 불가 상태라고 보고 그룹에서 제외한다. 처리 시간이 긴 컨슈머에서 “멀쩡한데 계속 리밸런싱이 돈다”면 대개 이쪽이다. <code class="language-plaintext highlighter-rouge">max.poll.records</code>를 줄이거나 인터벌을 늘려 맞춘다.</p>

<p>리밸런싱 자체도 비용이다. 기본(eager) 방식은 모든 컨슈머가 일단 할당을 전부 반납한 뒤 다시 나눠 갖는 stop-the-world 형태라 그동안 소비가 멈춘다. 이를 줄이려면 바뀐 파티션만 옮기는 <strong>cooperative sticky</strong> 할당 전략을 쓰거나, 재배포처럼 잠깐 나갔다 돌아오는 경우에 대비해 <code class="language-plaintext highlighter-rouge">group.instance.id</code>를 부여하는 <strong>정적 멤버십</strong>으로 불필요한 리밸런싱을 막는다.</p>

<h3 id="어디부터-다시-읽는가">어디부터 다시 읽는가</h3>

<p>이어받은 컨슈머는 <code class="language-plaintext highlighter-rouge">__consumer_offsets</code> 토픽에 <strong>커밋된 오프셋</strong>부터 다시 읽는다. 그래서 커밋 시점이 전달 보장을 결정한다.</p>

<ul>
  <li><strong>처리 후 커밋</strong>: 처리했지만 커밋 직전에 죽으면 그 메시지를 다시 받는다 → 중복 가능 (at-least-once)</li>
  <li><strong>처리 전 커밋</strong>: 커밋했지만 처리 중 죽으면 그 메시지는 사라진다 → 유실 가능 (at-most-once)</li>
</ul>

<p>기본값인 <code class="language-plaintext highlighter-rouge">enable.auto.commit=true</code>는 poll 시점에 이전 배치를 자동 커밋하기 때문에 경계가 모호하다. 정확한 제어가 필요하면 수동 커밋으로 바꾼다.</p>

<p>실무에서는 at-least-once를 택하고 <strong>컨슈머 로직을 멱등하게</strong> 만드는 쪽이 일반적이다. 메시지에 고유 키를 넣어 처리 이력을 확인하거나, DB 유니크 제약으로 중복 삽입을 흡수하거나, UPSERT로 몇 번 처리해도 같은 결과가 되게 한다.</p>

<h3 id="druid-인제스천">Druid 인제스천</h3>

<p>Druid는 컨슈머를 직접 관리하지 않고 <strong>슈퍼바이저</strong>가 인제스천 태스크의 수명주기를 관리한다. 태스크가 실패하면 슈퍼바이저가 감지해 재시작하고, 태스크가 어디까지 읽었는지는 메타데이터 저장소에 기록된 오프셋으로 판단해 그 지점부터 이어서 소비한다. 세그먼트 커밋과 오프셋 기록을 하나의 트랜잭션으로 묶기 때문에 재시작 후에도 중복 적재가 생기지 않는다.</p>

<hr />

<h2 id="7-정합성-사전-모니터링">7. 정합성 사전 모니터링</h2>

<p>장애를 “고객 문의로 알게 되는 것”과 “시스템이 먼저 알려주는 것”의 차이를 만드는 게 대사 배치다.</p>

<h3 id="대사reconciliation-배치">대사(reconciliation) 배치</h3>

<p>주기적으로 두 소스의 값을 비교한다. 선착순 쿠폰이라면 <strong>Redis 카운터의 발급 수 vs DB 발급 레코드 수</strong>다. 설계할 때 챙길 것들:</p>

<ul>
  <li><strong>기준 시점을 고정한다.</strong> 두 값을 시차를 두고 읽으면 그 사이 발급된 건 때문에 항상 차이가 난다. 특정 시각까지의 데이터만 비교하도록 컷오프를 두거나, 처리 중인 건을 감안한 허용 오차를 둔다.</li>
  <li><strong>어느 쪽이 진실인지 정한다.</strong> 불일치를 발견했을 때 무엇을 기준으로 맞출지가 없으면 복구를 자동화할 수 없다. 보통 영속화된 DB를 진실로 두고 캐시 카운터를 재계산한다.</li>
  <li><strong>복구는 멱등하게.</strong> 대사 배치가 여러 번 돌아도 결과가 같아야 한다.</li>
</ul>

<h3 id="알림과-지표">알림과 지표</h3>

<ul>
  <li>차이가 임계치를 넘으면 Slack이나 메일로 담당자에게 알린다.</li>
  <li>발급 성공/실패 수, 카운터 격차, 대사 배치 실행 시각 같은 지표를 Prometheus로 노출하고 Grafana에서 추적한다. 격차는 누적값(counter)이 아니라 현재 상태(gauge)로 두는 게 읽기 편하다.</li>
  <li>알림 임계치는 절대값보다 <strong>변화율</strong>로 잡는 편이 낫다. 상시로 소소한 오차가 있는 시스템에서 절대값 기준을 걸면 알림이 계속 울리고, 그러면 아무도 안 본다.</li>
  <li><strong>배치가 안 도는 상황</strong>도 감지해야 한다. 마지막 성공 시각을 지표로 내보내고, 그 값이 오래됐으면 알림을 띄운다. 대사 배치 자체가 죽으면 “이상 없음”과 구분되지 않기 때문이다.</li>
</ul>

<hr />

<h2 id="8-헥사고날-vs-레이어드">8. 헥사고날 vs 레이어드</h2>

<h3 id="차이는-의존성-방향">차이는 의존성 방향</h3>

<ul>
  <li><strong>레이어드</strong>: Controller → Service → Repository로 위에서 아래로 의존한다. 단순하고 익숙하다. 대신 Repository가 JPA를 쓰면 그 엔티티가 Service를 거쳐 Controller까지 새어 나가기 쉽고, 그 상태가 되면 영속화 기술을 바꿀 때 도메인 코드까지 함께 흔들린다.</li>
  <li><strong>헥사고날(포트&amp;어댑터)</strong>: 도메인을 중심에 두고, 도메인은 <strong>포트(인터페이스)</strong> 만 알게 한다. DB·메시지 브로커·외부 API 같은 기술은 그 포트를 구현하는 <strong>어댑터</strong>로 바깥에 둔다. 의존성이 항상 안쪽(도메인)을 향하므로 도메인이 기술을 모른다.</li>
</ul>

<p>포트는 방향에 따라 나뉜다. 바깥에서 도메인을 호출하는 <strong>인바운드 포트</strong>(유스케이스 인터페이스)와, 도메인이 바깥을 호출하는 <strong>아웃바운드 포트</strong>(저장소·알림 인터페이스)다. 후자에서 의존성 역전이 일어나 도메인이 인프라를 향하지 않게 된다.</p>

<h3 id="얻는-것과-치르는-것">얻는 것과 치르는 것</h3>

<p>얻는 것은 테스트 용이성과 교체 가능성이다. 아웃바운드 포트를 인메모리 페이크로 갈아끼우면 DB 없이 도메인 로직을 테스트할 수 있고, 저장소를 바꿔도 어댑터만 새로 쓰면 된다.</p>

<p>치르는 것은 간접 계층이다. 도메인 모델과 영속화 엔티티를 분리하면 둘 사이를 오가는 매핑 코드가 계속 생기고, 인터페이스와 구현이 매번 쌍으로 늘어난다.</p>

<p><strong>도메인 로직이 얇으면 이 비용이 이득보다 크다.</strong> CRUD에 가까운 기능에서는 격리할 도메인 자체가 별로 없어서, 포트와 어댑터가 사실상 Repository 인터페이스를 한 번 더 감싼 것에 그친다. 아키텍처 선택은 좋고 나쁨이 아니라 <strong>도메인 복잡도에 대한 트레이드오프</strong>다.</p>

<hr />

<h2 id="9-rag-리랭킹">9. RAG 리랭킹</h2>

<h3 id="bi-encoder의-한계">bi-encoder의 한계</h3>

<p>벡터 검색은 <strong>bi-encoder</strong> 구조다. 질의와 문서를 <strong>각각 독립적으로</strong> 벡터로 인코딩하고, 코사인 유사도 같은 거리로 비교한다. 문서 벡터는 미리 계산해 인덱싱해둘 수 있어서 수백만 건에서도 밀리초 단위로 후보를 뽑는다.</p>

<p>대신 정밀도에 한계가 있다. 질의와 문서가 서로를 보지 못한 채 각자 하나의 벡터로 압축되기 때문에, 어느 단어가 어느 문장과 대응하는지 같은 세밀한 관계가 표현되지 않는다. 결과적으로 “주제는 비슷한데 정작 질문에 답하지 않는 문서”가 상위에 올라오곤 한다.</p>

<h3 id="cross-encoder로-재정렬">cross-encoder로 재정렬</h3>

<p><strong>cross-encoder</strong>는 질의와 문서를 <strong>하나의 입력으로 이어 붙여</strong> 모델에 넣고 관련도 점수를 직접 출력한다. 어텐션이 질의 토큰과 문서 토큰 사이를 오가므로 훨씬 정확하다.</p>

<p>문제는 미리 계산해둘 수 없다는 것이다. 질의가 정해져야 점수가 나오므로 (질의, 문서) 쌍마다 모델을 한 번씩 돌려야 한다. 전체 코퍼스에 적용하는 건 불가능하다.</p>

<p>그래서 두 단계로 나눈다.</p>

<ol>
  <li><strong>검색</strong>: bi-encoder(+BM25 같은 키워드 검색)로 top-k 후보를 넉넉히 뽑는다. k는 보통 50~100.</li>
  <li><strong>리랭킹</strong>: cross-encoder로 그 k개만 재채점해 상위 5~10개를 남긴다.</li>
  <li><strong>생성</strong>: 남은 문서만 LLM 컨텍스트에 넣는다.</li>
</ol>

<p>느린 모델을 후보 k개에만 적용하는 <strong>비용과 정확도의 절충</strong>이다. k를 키우면 재현율은 오르지만 리랭킹 지연이 선형으로 늘어나므로, 지연 예산을 보고 정한다.</p>

<p>리랭킹은 컨텍스트 길이 측면에서도 의미가 있다. LLM에 문서를 많이 넣을수록 비용이 늘고, 관련 없는 문서가 섞이면 오히려 답변 품질이 떨어진다. 상위 몇 개만 정확하게 골라 넣는 편이 낫다.</p>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[배열과 연결리스트, BST와 B-tree, 유니크 인덱스 비용, 뮤텍스와 세마포어, TLS 키 교환, Kafka 컨슈머 장애 복구, 정합성 대사 배치, 헥사고날 아키텍처, RAG 리랭킹을 정리한다.]]></summary></entry><entry><title type="html">백엔드 기술의 탄생 목적과 실사용</title><link href="https://wlswo.me/2026/05/14/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B8%B0%EC%88%A0%EC%9D%98-%ED%83%84%EC%83%9D%EB%AA%A9%EC%A0%81%EA%B3%BC-%EC%8B%A4%EC%82%AC%EC%9A%A9/" rel="alternate" type="text/html" title="백엔드 기술의 탄생 목적과 실사용" /><published>2026-05-14T11:25:00+00:00</published><updated>2026-05-14T11:25:00+00:00</updated><id>https://wlswo.me/2026/05/14/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B8%B0%EC%88%A0%EC%9D%98-%ED%83%84%EC%83%9D%EB%AA%A9%EC%A0%81%EA%B3%BC-%EC%8B%A4%EC%82%AC%EC%9A%A9</id><content type="html" xml:base="https://wlswo.me/2026/05/14/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B8%B0%EC%88%A0%EC%9D%98-%ED%83%84%EC%83%9D%EB%AA%A9%EC%A0%81%EA%B3%BC-%EC%8B%A4%EC%82%AC%EC%9A%A9/"><![CDATA[<p>같은 리스트를 <strong>탄생 목적 / 실사용</strong> 두 줄로 분리해서 정리한다. (★ 표시는 둘이 크게 갈라진 케이스 — 즉 “원래 그러려고 만든 게 아닌데 더 유명해진” 기술)</p>

<h2 id="1-프로그래밍-언어">1. 프로그래밍 언어</h2>

<ul>
  <li><strong>C</strong> (1972, Dennis Ritchie @ Bell Labs)
    <ul>
      <li>탄생: Unix를 다른 컴퓨터로 이식하기 위한 OS 기술용 언어</li>
      <li>실사용: OS, 임베디드, 다른 언어의 런타임/확장</li>
    </ul>
  </li>
  <li><strong>Java</strong> (1995, James Gosling @ Sun) ★
    <ul>
      <li>탄생: 셋톱박스/가전제품용 임베디드 언어 (Project Oak). 인터랙티브 TV 시장 노림</li>
      <li>실사용: 엔터프라이즈 백엔드, Android</li>
    </ul>
  </li>
  <li><strong>Python</strong> (1991, Guido van Rossum)
    <ul>
      <li>탄생: ABC 언어 후속. CWI의 Amoeba OS 자동화용 스크립트 + 가독성 실험</li>
      <li>실사용: 웹 백엔드, 데이터/ML, DevOps</li>
    </ul>
  </li>
  <li><strong>Ruby</strong> (1995, 마츠모토 유키히로)
    <ul>
      <li>탄생: Perl보다 OO스럽고 Smalltalk보다 실용적인 스크립트 언어. “프로그래머 행복”</li>
      <li>실사용: Rails 백엔드, DevOps (Chef/Puppet)</li>
    </ul>
  </li>
  <li><strong>PHP</strong> (1995, Rasmus Lerdorf) ★
    <ul>
      <li>탄생: 자기 개인 홈페이지에 방문자 카운터 같은 거 넣을 Perl/CGI 스크립트 모음</li>
      <li>실사용: WordPress, 전 세계 웹 70%</li>
    </ul>
  </li>
  <li><strong>Go</strong> (2009, Google)
    <ul>
      <li>탄생: Google 내부 C++ 빌드 너무 느림 + 동시성 어려움. 시스템 SW를 단순하게</li>
      <li>실사용: 클라우드 인프라(Docker, k8s, Terraform), 마이크로서비스</li>
    </ul>
  </li>
  <li><strong>Rust</strong> (2010, Graydon Hoare @ Mozilla) ★
    <ul>
      <li>탄생: Mozilla 차세대 브라우저 엔진(Servo)의 메모리 안전성 위해</li>
      <li>실사용: 시스템 SW, 블록체인, CLI 도구, 일부 백엔드</li>
    </ul>
  </li>
  <li><strong>Node.js</strong> (2009, Ryan Dahl)
    <ul>
      <li>탄생: Apache가 “오래 열린 연결”(파일 업로드 진행률 같은) 다루기 어려움. V8 위에 이벤트 기반 IO 서버</li>
      <li>실사용: 웹 백엔드, 빌드 도구, Electron</li>
    </ul>
  </li>
  <li><strong>Kotlin</strong> (2011, JetBrains) ★
    <ul>
      <li>탄생: JetBrains가 자기 IDE를 Java로 짜다 boilerplate에 지쳐 만든 사내 언어</li>
      <li>실사용: Android 공식 언어</li>
    </ul>
  </li>
  <li><strong>TypeScript</strong> (2012, Anders Hejlsberg @ MS)
    <ul>
      <li>탄생: MS 내부 대규모 JS 프로젝트(Bing/Office) 유지보수 위해 점진적 타입 추가</li>
      <li>실사용: 모던 프론트엔드 + Node 백엔드 표준</li>
    </ul>
  </li>
</ul>

<h2 id="2-웹-프레임워크">2. 웹 프레임워크</h2>

<ul>
  <li><strong>Ruby on Rails</strong> (2004, DHH)
    <ul>
      <li>탄생: 자기 회사 Basecamp 만들면서 추출</li>
      <li>실사용: 스타트업 MVP, GitHub/Shopify 백엔드</li>
    </ul>
  </li>
  <li><strong>Django</strong> (2005, Lawrence Journal-World)
    <ul>
      <li>탄생: 캔자스 지역 신문사 내부 도구. 기사 사이트 빠르게 찍어내기</li>
      <li>실사용: 콘텐츠 사이트(Instagram, Pinterest), 어드민</li>
    </ul>
  </li>
  <li><strong>Spring</strong> (2003, Rod Johnson)
    <ul>
      <li>탄생: 책 부록 코드. EJB 무거움을 IoC + DI로 대체</li>
      <li>실사용: 엔터프라이즈 자바 백엔드 표준</li>
    </ul>
  </li>
  <li><strong>Express</strong> (2010, TJ Holowaychuk)
    <ul>
      <li>탄생: Ruby Sinatra에 영감받아 Node용 minimal 프레임워크</li>
      <li>실사용: Node 라우팅 사실상 표준</li>
    </ul>
  </li>
  <li><strong>FastAPI</strong> (2018, Sebastián Ramírez)
    <ul>
      <li>탄생: Flask는 type hint 활용 못 하고 자동 문서 없음. Pydantic + Starlette 위에</li>
      <li>실사용: ML 모델 서빙, 모던 Python API</li>
    </ul>
  </li>
</ul>

<h2 id="3-rdb">3. RDB</h2>

<ul>
  <li><strong>SQL</strong> (1974, IBM)
    <ul>
      <li>탄생: System R 프로젝트에서 Codd의 관계형 모델(1970)을 실제 쿼리 언어로 구현</li>
      <li>실사용: 거의 모든 RDB의 표준</li>
    </ul>
  </li>
  <li><strong>PostgreSQL</strong> (1986, Stonebraker @ UC Berkeley)
    <ul>
      <li>탄생: 학술 프로젝트. <strong>확장 가능한 DBMS</strong> 연구 (custom type, 객체-관계형 등)</li>
      <li>실사용: 범용 OLTP, JSON/지리정보/벡터까지 다 처리</li>
    </ul>
  </li>
  <li><strong>MySQL</strong> (1995, MySQL AB)
    <ul>
      <li>탄생: 스웨덴 회사 TcX가 자기 데이터 도구에 mSQL 쓰려다 너무 느려서 직접 만듦</li>
      <li>실사용: LAMP 스택, WordPress</li>
    </ul>
  </li>
  <li><strong>SQLite</strong> (2000, D. Richard Hipp) ★
    <ul>
      <li>탄생: <strong>미 해군 구축함 통신 SW에 임베드할</strong> 의존성 없는 작은 DB</li>
      <li>실사용: 모바일 앱, 브라우저, 데스크톱 SW의 로컬 저장소</li>
    </ul>
  </li>
</ul>

<h2 id="4-nosql--특수-db">4. NoSQL / 특수 DB</h2>

<ul>
  <li><strong>MongoDB</strong> (2009, 10gen) ★
    <ul>
      <li>탄생: 원래 만들려던 PaaS의 백엔드 DB. PaaS는 망하고 DB만 살아남음</li>
      <li>실사용: 스키마 자주 바뀌는 웹앱, 프로토타입</li>
    </ul>
  </li>
  <li><strong>Redis</strong> (2009, Salvatore Sanfilippo)
    <ul>
      <li>탄생: 자기 스타트업 실시간 분석이 MySQL로 너무 느려서. 메모리 자료구조 서버</li>
      <li>실사용: 캐시, 세션, rate limiting, pub/sub, 큐</li>
    </ul>
  </li>
  <li><strong>Cassandra</strong> (2008, Facebook)
    <ul>
      <li>탄생: Facebook 받은편지함 검색의 폭주하는 쓰기를 받기 위해. Dynamo + Bigtable</li>
      <li>실사용: 시계열, 로그, 대규모 쓰기 (Apple, Netflix, Discord)</li>
    </ul>
  </li>
  <li><strong>DynamoDB</strong> (2012, AWS)
    <ul>
      <li>탄생: Amazon 쇼핑카트가 RDB로 안 버틴 경험(2007 Dynamo 논문)을 매니지드화</li>
      <li>실사용: AWS 환경 키-값, 서버리스</li>
    </ul>
  </li>
  <li><strong>Elasticsearch</strong> (2010, Shay Banon)
    <ul>
      <li>탄생: <strong>자기 아내 요리 레시피 검색 앱</strong> 만들다 Lucene 분산화 필요</li>
      <li>실사용: 로그 검색(ELK), 전문 검색</li>
    </ul>
  </li>
  <li><strong>Neo4j</strong> (2007)
    <ul>
      <li>탄생: 자산 관리 시스템에서 RDB JOIN으로 그래프 쿼리가 지옥</li>
      <li>실사용: 추천, 사기 탐지, 지식 그래프</li>
    </ul>
  </li>
  <li><strong>pgvector / Pinecone / Weaviate</strong>
    <ul>
      <li>탄생: ML 임베딩 벡터 유사도 검색 (Pinecone은 원래 추천 시스템용)</li>
      <li>실사용: LLM RAG 백엔드 ★ (LLM 붐 이후)</li>
    </ul>
  </li>
</ul>

<h2 id="5-메시지-큐--스트리밍">5. 메시지 큐 / 스트리밍</h2>

<ul>
  <li><strong>RabbitMQ</strong> (2007)
    <ul>
      <li>탄생: AMQP(JPMorgan 주도 금융권 메시징 표준)의 레퍼런스 구현</li>
      <li>실사용: 범용 작업 큐, 마이크로서비스 통신</li>
    </ul>
  </li>
  <li><strong>Kafka</strong> (2011, LinkedIn)
    <ul>
      <li>탄생: LinkedIn 사이트 활동 데이터를 여러 소비자에게 흘려보낼 통합 파이프라인</li>
      <li>실사용: 이벤트 스트리밍 백본 (탄생 목적과 거의 동일)</li>
    </ul>
  </li>
  <li><strong>Celery</strong> (2009)
    <ul>
      <li>탄생: Django 앱의 백그라운드 작업(이메일 등) 비동기 처리</li>
      <li>실사용: Python 비동기 작업 표준</li>
    </ul>
  </li>
</ul>

<h2 id="6-api-통신">6. API 통신</h2>

<ul>
  <li><strong>REST</strong> (2000, Roy Fielding 박사논문)
    <ul>
      <li>탄생: <strong>새 기술이 아님</strong>. 박사논문에서 HTTP/웹이 왜 잘 작동하는지 분석한 아키텍처 스타일</li>
      <li>실사용: 웹 API 사실상 기본값</li>
    </ul>
  </li>
  <li><strong>GraphQL</strong> (2012 FB 내부, 2015 공개)
    <ul>
      <li>탄생: Facebook 모바일 앱 리뉴얼 시 REST의 over-fetching/under-fetching, endpoint 폭증</li>
      <li>실사용: SPA·모바일 백엔드, BFF</li>
    </ul>
  </li>
  <li><strong>gRPC</strong> (2016, Google)
    <ul>
      <li>탄생: Google 내부 RPC(Stubby) 오픈소스화. 서비스 간 빠른 통신</li>
      <li>실사용: 마이크로서비스 internal 통신, k8s 컴포넌트 간</li>
    </ul>
  </li>
  <li><strong>WebSocket</strong> (2011 표준)
    <ul>
      <li>탄생: HTTP polling/long-polling으로 양방향 흉내내는 게 비효율적</li>
      <li>실사용: 채팅, 게임, 실시간 협업</li>
    </ul>
  </li>
  <li><strong>SSE</strong> (HTML5의 일부) ★
    <ul>
      <li>탄생: 서버→클라 단방향 push를 polling 없이 (2008년경 알림용으로 도입)</li>
      <li>실사용: 지금은 거의 <strong>LLM 응답 스트리밍</strong> 때문에 부활</li>
    </ul>
  </li>
</ul>

<h2 id="7-인증--보안">7. 인증 / 보안</h2>

<ul>
  <li><strong>OAuth</strong> (1.0: 2007, 2.0: 2012)
    <ul>
      <li>탄생: Twitter API 등에서 3rd party 앱이 사용자 비밀번호 받는 게 위험. 권한 위임</li>
      <li>실사용: “구글로 로그인”, API 권한 위임</li>
    </ul>
  </li>
  <li><strong>JWT</strong> (2015) ★
    <ul>
      <li>탄생: <strong>OpenID Connect용 ID 토큰 포맷</strong>. SAML(XML)을 JSON/URL-safe로 대체</li>
      <li>실사용: stateless 백엔드 인증, API 토큰 (탄생 의도와 다름)</li>
    </ul>
  </li>
  <li><strong>SAML</strong> (2002)
    <ul>
      <li>탄생: 엔터프라이즈 환경 시스템 간 신원 정보 교환 표준 (XML)</li>
      <li>실사용: 기업 SSO (Okta, Azure AD)</li>
    </ul>
  </li>
  <li><strong>TLS/SSL</strong> (SSL 1.0: 1995 Netscape)
    <ul>
      <li>탄생: Netscape가 e-commerce(신용카드) 위해 HTTP 트래픽 암호화</li>
      <li>실사용: 모든 HTTPS, gRPC, 이메일, VPN</li>
    </ul>
  </li>
  <li><strong>bcrypt</strong> (1999, OpenBSD)
    <ul>
      <li>탄생: MD5/crypt가 너무 빨라 brute-force 취약 → <strong>의도적으로 느린</strong> 비밀번호 해시</li>
      <li>실사용: 비밀번호 저장 표준</li>
    </ul>
  </li>
</ul>

<h2 id="8-컨테이너--오케스트레이션">8. 컨테이너 / 오케스트레이션</h2>

<ul>
  <li><strong>Docker</strong> (2013) ★
    <ul>
      <li>탄생: dotCloud라는 PaaS 회사가 <strong>자기 플랫폼 내부에서 쓰던</strong> LXC 래퍼를 오픈소스화</li>
      <li>실사용: 개발 환경 일치, 배포 단위, k8s 실행 단위</li>
    </ul>
  </li>
  <li><strong>Kubernetes</strong> (2014, Google)
    <ul>
      <li>탄생: Google Borg(15년 운영) + Omega 경험 오픈소스화. AWS 대항마 의도도</li>
      <li>실사용: 컨테이너 오케스트레이션 표준 (탄생 목적 그대로)</li>
    </ul>
  </li>
  <li><strong>Helm</strong> (2015)
    <ul>
      <li>탄생: k8s YAML이 환경별로 반복적이라 패키지 매니저 필요</li>
      <li>실사용: k8s 앱 배포 패키지</li>
    </ul>
  </li>
  <li><strong>Istio</strong> (2017)
    <ul>
      <li>탄생: Lyft Envoy + Google service mesh. 통신 정책/관측을 코드 밖으로</li>
      <li>실사용: 마이크로서비스 mTLS, 트래픽 관리</li>
    </ul>
  </li>
</ul>

<h2 id="9-웹-서버--프록시">9. 웹 서버 / 프록시</h2>

<ul>
  <li><strong>Apache</strong> (1995)
    <ul>
      <li>탄생: 망한 NCSA HTTPd의 사용자들이 <strong>패치 모음</strong>(“a patchy server”) 만든 게 기원</li>
      <li>실사용: 전통적 웹 서버, PHP 호스팅</li>
    </ul>
  </li>
  <li><strong>Nginx</strong> (2004, Igor Sysoev)
    <ul>
      <li>탄생: 러시아 검색 사이트 Rambler의 C10K 문제 해결용 이벤트 기반 서버</li>
      <li>실사용: 리버스 프록시, 정적 파일, 로드밸런서 (탄생 그대로 + 더 확장)</li>
    </ul>
  </li>
  <li><strong>HAProxy</strong> (2001)
    <ul>
      <li>탄생: 자기 회사 트래픽 분산할 무료 L4/L7 LB가 없어서 직접 작성</li>
      <li>실사용: 고성능 로드밸런싱</li>
    </ul>
  </li>
  <li><strong>Caddy</strong> (2015)
    <ul>
      <li>탄생: HTTPS 설정이 너무 복잡 (Let’s Encrypt 자동 통합)</li>
      <li>실사용: 작은~중간 웹 서버</li>
    </ul>
  </li>
</ul>

<h2 id="10-cicd--iac">10. CI/CD / IaC</h2>

<ul>
  <li><strong>Jenkins</strong> (2011)
    <ul>
      <li>탄생: Sun이 만든 <strong>Hudson의 fork</strong>. Oracle 인수 후 라이선스 분쟁</li>
      <li>실사용: 자체 호스팅 CI/CD</li>
    </ul>
  </li>
  <li><strong>GitHub Actions</strong> (2019)
    <ul>
      <li>탄생: GitHub repo와 통합된 CI 부재 (CircleCI/Travis 의존 내재화)</li>
      <li>실사용: 오픈소스 + 중소 프로젝트 표준</li>
    </ul>
  </li>
  <li><strong>Terraform</strong> (2014, HashiCorp)
    <ul>
      <li>탄생: 클라우드 GUI 클릭은 재현 불가. 코드 + multi-provider 통합</li>
      <li>실사용: IaC 표준</li>
    </ul>
  </li>
  <li><strong>Ansible</strong> (2012)
    <ul>
      <li>탄생: Puppet/Chef는 agent 필요 + Ruby DSL. SSH + YAML로 더 단순하게</li>
      <li>실사용: 서버 설정 관리</li>
    </ul>
  </li>
  <li><strong>ArgoCD</strong> (2018, Intuit)
    <ul>
      <li>탄생: Intuit가 자기 k8s 배포 관리하려고. GitOps 개념 정립</li>
      <li>실사용: k8s GitOps 배포</li>
    </ul>
  </li>
</ul>

<h2 id="11-관측">11. 관측</h2>

<ul>
  <li><strong>Prometheus</strong> (2012, SoundCloud)
    <ul>
      <li>탄생: SoundCloud가 마이크로서비스 옮기는데 기존 도구가 동적 환경에 약함. Borgmon 영향</li>
      <li>실사용: k8s/마이크로서비스 메트릭 표준 (탄생 목적 그대로)</li>
    </ul>
  </li>
  <li><strong>Grafana</strong> (2014) ★
    <ul>
      <li>탄생: <strong>Kibana를 fork</strong>해 Elasticsearch 외 시계열 DB(InfluxDB/Graphite)도 시각화</li>
      <li>실사용: 모든 데이터 소스 대시보드 표준</li>
    </ul>
  </li>
  <li><strong>Jaeger</strong> (2015, Uber)
    <ul>
      <li>탄생: Uber 마이크로서비스 추적. Google Dapper 논문 기반</li>
      <li>실사용: 분산 트레이싱 표준</li>
    </ul>
  </li>
  <li><strong>OpenTelemetry</strong> (2019)
    <ul>
      <li>탄생: OpenTracing + OpenCensus 두 표준이 경쟁하다 합쳐짐</li>
      <li>실사용: vendor-중립 관측 데이터 수집</li>
    </ul>
  </li>
</ul>

<h2 id="12-버전-관리">12. 버전 관리</h2>

<ul>
  <li><strong>Git</strong> (2005, Linus)
    <ul>
      <li>탄생: Linux 커널팀이 BitKeeper 무료 사용권 박탈당해 Linus가 2주 만에</li>
      <li>실사용: 소스 관리 사실상 표준</li>
    </ul>
  </li>
  <li><strong>GitHub</strong> (2008)
    <ul>
      <li>탄생: Git CLI가 너무 어려움. 웹 UI + 협업(PR, 이슈)</li>
      <li>실사용: 오픈소스/기업 코드 호스팅</li>
    </ul>
  </li>
</ul>

<h2 id="13-클라우드--서버리스">13. 클라우드 / 서버리스</h2>

<ul>
  <li><strong>AWS</strong> (2006) ★
    <ul>
      <li>탄생: Amazon이 자기 e-commerce 인프라를 외부 판매. Bezos의 “API everything” 정책 부산물</li>
      <li>실사용: 범용 클라우드 인프라</li>
    </ul>
  </li>
  <li><strong>S3</strong> (2006)
    <ul>
      <li>탄생: Amazon 내부 다양한 데이터 저장 표준화</li>
      <li>실사용: 파일 저장, 백업, 데이터 레이크</li>
    </ul>
  </li>
  <li><strong>Lambda</strong> (2014)
    <ul>
      <li>탄생: 서버 없이 코드만 올려 실행하는 이벤트 기반 컴퓨팅 모델</li>
      <li>실사용: 짧은 작업, 이벤트 처리, 서버리스 백엔드</li>
    </ul>
  </li>
  <li><strong>Cloudflare Workers</strong> (2017)
    <ul>
      <li>탄생: Cloudflare CDN edge에서 코드 실행 (V8 isolate로 Lambda보다 가볍게)</li>
      <li>실사용: edge computing, API 게이트웨이</li>
    </ul>
  </li>
</ul>

<h2 id="14-orm--db-도구">14. ORM / DB 도구</h2>

<ul>
  <li><strong>Hibernate</strong> (2001)
    <ul>
      <li>탄생: Java 개발자가 EJB CMP에 지쳐 만든 ORM</li>
      <li>실사용: Java ORM 표준</li>
    </ul>
  </li>
  <li><strong>SQLAlchemy</strong> (2006)
    <ul>
      <li>탄생: Python에 본격적 ORM 부재. Core(SQL 추상화) + ORM 두 레이어</li>
      <li>실사용: Python ORM 표준</li>
    </ul>
  </li>
  <li><strong>Prisma</strong> (2019)
    <ul>
      <li>탄생: TypeScript에서 type-safe DB 접근. Sequelize 등은 타입 약함</li>
      <li>실사용: TS 풀스택</li>
    </ul>
  </li>
  <li><strong>Alembic</strong> (2009)
    <ul>
      <li>탄생: SQLAlchemy 작성자가 마이그레이션 도구로 같이 만듦</li>
      <li>실사용: Python 마이그레이션</li>
    </ul>
  </li>
  <li><strong>dbt</strong> (2016)
    <ul>
      <li>탄생: 컨설팅 회사 Fishtown이 분석 SQL을 SW 엔지니어링 방식으로 관리</li>
      <li>실사용: 데이터 웨어하우스 변환 표준</li>
    </ul>
  </li>
</ul>

<h2 id="15-테스팅">15. 테스팅</h2>

<ul>
  <li><strong>JUnit</strong> (1997, Kent Beck + Erich Gamma)
    <ul>
      <li>탄생: <strong>비행기에서</strong> SUnit(Smalltalk)을 Java로 포팅</li>
      <li>실사용: Java 단위 테스트 표준</li>
    </ul>
  </li>
  <li><strong>pytest</strong> (2003)
    <ul>
      <li>탄생: Python의 unittest(JUnit 이식)가 boilerplate 많음</li>
      <li>실사용: Python 테스트 표준</li>
    </ul>
  </li>
  <li><strong>Selenium</strong> (2004, ThoughtWorks)
    <ul>
      <li>탄생: ThoughtWorks가 자기 웹앱 테스트 자동화</li>
      <li>실사용: E2E 테스트, 웹 자동화</li>
    </ul>
  </li>
  <li><strong>Playwright</strong> (2020, MS)
    <ul>
      <li>탄생: Puppeteer 만든 팀이 MS로 옮겨 멀티 브라우저 후속작</li>
      <li>실사용: 모던 E2E</li>
    </ul>
  </li>
  <li><strong>Postman</strong> (2012) ★
    <ul>
      <li>탄생: <strong>자기가 API 개발 중 curl 반복이 귀찮아</strong> 만든 Chrome 확장</li>
      <li>실사용: API 수동 테스트, 팀 컬렉션 공유 (회사로 성장)</li>
    </ul>
  </li>
  <li><strong>k6</strong> (2017)
    <ul>
      <li>탄생: JMeter 등 부하 테스트가 GUI 위주라 CI 친화적이지 않음</li>
      <li>실사용: 부하 테스트, 성능 회귀</li>
    </ul>
  </li>
</ul>

<h2 id="16-빌드--패키지">16. 빌드 / 패키지</h2>

<ul>
  <li><strong>Maven</strong> (2004)
    <ul>
      <li>탄생: Apache가 Jakarta 프로젝트 빌드 표준화. Ant는 자유로워서 일관성 없음</li>
      <li>실사용: Java 빌드 표준</li>
    </ul>
  </li>
  <li><strong>npm</strong> (2010)
    <ul>
      <li>탄생: Node 모듈 공유/설치 표준 부재</li>
      <li>실사용: 거의 모든 JS 프로젝트 의존성 관리</li>
    </ul>
  </li>
  <li><strong>pip</strong> (2008) → <strong>poetry</strong> (2018) → <strong>uv</strong> (2024)
    <ul>
      <li>탄생: 단계별로 “이전 도구의 한계”를 풀려고 만들어짐 (의존성 해결, lock, 속도)</li>
      <li>실사용: Python 패키지 관리 (현재는 uv로 빠르게 이동 중)</li>
    </ul>
  </li>
</ul>

<hr />

<h2 id="-표시들에서-보이는-패턴">★ 표시들에서 보이는 패턴</h2>

<p>탄생 목적과 실사용이 갈라진 케이스를 보면 공통점이 있다:</p>

<table>
  <thead>
    <tr>
      <th>패턴</th>
      <th>예시</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>회사 내부 도구가 오픈소스화 후 다른 곳에서 더 유명</strong></td>
      <td>Docker, Kafka, k8s, Cassandra</td>
    </tr>
    <tr>
      <td><strong>다른 분야(임베디드/PaaS/브라우저)용으로 만들었는데 백엔드가 가져감</strong></td>
      <td>Java, SQLite, JWT, Rust</td>
    </tr>
    <tr>
      <td><strong>스크래치 가려운 데에서 출발해 회사가 됨</strong></td>
      <td>PHP, Postman, Elasticsearch</td>
    </tr>
    <tr>
      <td><strong>fork/패치 모음으로 시작</strong></td>
      <td>Apache, Jenkins, Grafana, MariaDB</td>
    </tr>
    <tr>
      <td><strong>이전 기술의 라이선스 분쟁이 만든 결과</strong></td>
      <td>Git, MariaDB, Jenkins</td>
    </tr>
  </tbody>
</table>

<p>→ <strong>“탄생 목적과 실사용이 다르다”는 건 실패가 아니라 오히려 정상</strong>이다. 한 분야에서 잘 만들어진 도구는 인접 분야에서 자주 재발견되고, 진짜 좋은 도구는 원래 의도를 넘어 살아남는다. 새 기술 평가할 때 “지금 사람들이 어떻게 쓰나”와 “원래 어떤 문제를 풀려고 만들어졌나”를 모두 보면, 그 도구의 <strong>강점이 어디서 오는지</strong> 더 정확히 보인다 (예: SQLite는 “임베디드 신뢰성”이 뿌리라서 모바일 앱이 신뢰하고 쓰는 것).</p>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[백엔드에서 자주 쓰는 기술들을 '탄생 목적'과 '실사용' 두 축으로 정리. ★는 둘이 크게 갈라진 케이스.]]></summary></entry><entry><title type="html">동시성 풀어나가기 (경합, CAS, Lock)</title><link href="https://wlswo.me/2026/04/04/%EB%8F%99%EC%8B%9C%EC%84%B1%EC%A0%91%EA%B7%BC%ED%95%98%EA%B8%B0/" rel="alternate" type="text/html" title="동시성 풀어나가기 (경합, CAS, Lock)" /><published>2026-04-04T16:00:00+00:00</published><updated>2026-04-04T16:00:00+00:00</updated><id>https://wlswo.me/2026/04/04/%EB%8F%99%EC%8B%9C%EC%84%B1%EC%A0%91%EA%B7%BC%ED%95%98%EA%B8%B0</id><content type="html" xml:base="https://wlswo.me/2026/04/04/%EB%8F%99%EC%8B%9C%EC%84%B1%EC%A0%91%EA%B7%BC%ED%95%98%EA%B8%B0/"><![CDATA[<h3 id="0-들어가며">0. 들어가며</h3>

<h3 id="읽는-방법">읽는 방법</h3>

<p>이 글은 책을 읽듯 자연스럽게 읽히도록 쓰려 노력했다. 기술 책을 읽다가 모르는 개념이 나오면 읽던 흐름을 끊고 찾아본 뒤 돌아와야 하는데, 그러면 앞뒤 맥락이 이어지지 않는 경험을 누구나 한 번쯤 해봤을 것이다. 그 불편함을 최소화하기 위해, 이 글에서 등장하는 개념들은 가능한 한 그 자리에서 바로 설명하고 넘어간다.</p>

<p>이 글은 다음과 같은 것들을 필요로 할때 도움이 된다.</p>

<ul>
  <li>동시성이 추상적으로 다가오는 경우</li>
  <li><code class="language-plaintext highlighter-rouge">synchronized</code>나 <code class="language-plaintext highlighter-rouge">AtomicInteger</code>를 왜 쓰는지 모를때</li>
  <li>경합, CAS, Lock에 대해서 모르거나, 근본부터 이해하고 싶을 때</li>
</ul>

<p>이 글은 <strong>왜(Why)를 먼저, 어떻게(How)를 나중에</strong> 설명하는 방식으로 작성했다. 각 제목이 다음 제목의 기반이 되기 때문에, 순서대로 읽는 것을 권장한다.</p>

<h4 id="글을-읽기-위해-필요한-지식들">글을 읽기 위해 필요한 지식들</h4>
<ul>
  <li>기본적인 프로그래밍 경험 / Java 코드 예시가 등장하지만, 개념 자체는 언어에 독립적이다.</li>
  <li>대략적인 프로세스/스레드 개념 - “스레드가 병렬로 돌아간다” 정도</li>
</ul>

<hr />

<h2 id="1-컴퓨터는-어떻게-프로그램을-실행하는가">1. 컴퓨터는 어떻게 프로그램을 실행하는가</h2>

<blockquote>
  <p>경합을 이해하려면 메모리 구조부터</p>
</blockquote>

<h3 id="프로세스란-무엇인가">프로세스란 무엇인가</h3>

<p>프로그램은 디스크에 저장된 <strong>정적인 코드 덩어리</strong>다. 이걸 실행하면 OS가 메모리에 올리고, CPU가 명령을 하나씩 처리하기 시작한다. 이 실행 중인 프로그램의 인스턴스를 <strong>프로세스(Process)</strong> 라고 한다.</p>

<p>프로세스는 단순히 코드만 올라간 게 아니다. OS는 프로세스에게 독립적인 메모리 공간을 할당해준다. 이 공간은 크게 네 구역으로 나뉜다.</p>

<h3 id="프로세스-메모리-구조-코드-데이터-힙-스택">프로세스 메모리 구조 (코드, 데이터, 힙, 스택)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>높은 주소
┌─────────────────────────────┐
│           스택 (Stack)       │  ← 함수 호출 시 자동 생성/소멸
│         ↓ grows down        │
├─────────────────────────────┤
│              ...            │
├─────────────────────────────┤
│         ↑ grows up          │
│            힙 (Heap)         │  ← 동적 할당 (new, malloc)
├─────────────────────────────┤
│         데이터 (Data)         │  ← 전역변수, static 변수
├─────────────────────────────┤
│         코드 (Text)           │  ← 실행할 명령어들
└─────────────────────────────┘
낮은 주소
</code></pre></div></div>

<p>각 구역의 역할은 다음과 같다.</p>

<ul>
  <li><strong>코드(Text)</strong>: 실제 실행할 명령어가 담긴 영역. 읽기 전용이다.</li>
  <li><strong>데이터(Data)</strong>: 전역 변수나 <code class="language-plaintext highlighter-rouge">static</code> 변수가 저장되는 곳. 프로그램 시작 시 할당되고 종료 시 해제된다.</li>
  <li><strong>힙(Heap)</strong>: 런타임에 동적으로 할당하는 메모리 영역. Java에서 <code class="language-plaintext highlighter-rouge">new</code>로 객체를 만들면 여기에 올라간다.</li>
  <li><strong>스택(Stack)</strong>: 함수 호출 시 지역 변수, 매개변수, 리턴 주소 등이 쌓이는 영역. 함수가 끝나면 자동으로 해제된다.</li>
</ul>

<h3 id="스택은-왜-스레드마다-따로-있는가">스택은 왜 스레드마다 따로 있는가</h3>

<p>스택이 하는 일을 생각해보면 자연스럽게 이해된다.</p>

<p>스택에는 지금 실행 중인 함수의 지역 변수와 리턴 주소가 들어있다. 스레드가 독립적으로 함수를 호출하고 실행하려면, <strong>자신만의 실행 흐름을 추적할 공간</strong>이 필요하다.</p>

<p>스레드 A가 <code class="language-plaintext highlighter-rouge">calculateA()</code>를 호출하는 동안, 스레드 B는 <code class="language-plaintext highlighter-rouge">calculateB()</code>를 호출하고 있을 수 있다. 이 두 함수의 지역 변수가 같은 스택을 공유한다면 서로 덮어쓰는 재앙이 발생한다. 따라서 스택은 스레드마다 독립적으로 존재해야 한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thread A의 스택          Thread B의 스택
┌──────────────┐        ┌──────────────┐
│ calculateA() │        │ calculateB() │
│   x = 10     │        │   y = 20     │
│    리턴주소    │        │    리턴주소    │
└──────────────┘        └──────────────┘
      완전 독립                완전 독립
</code></pre></div></div>

<h3 id="힙은-왜-공유되는가">힙은 왜 공유되는가</h3>

<p>힙에 올라가는 것들을 생각해보자. Java 기준으로 <code class="language-plaintext highlighter-rouge">new</code>로 만든 모든 객체가 힙에 올라간다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 이 객체는 힙에 올라간다</span>
<span class="nc">Counter</span> <span class="n">counter</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Counter</span><span class="o">();</span>
</code></pre></div></div>

<p>이 <code class="language-plaintext highlighter-rouge">counter</code> 객체에 여러 스레드가 접근하는 시나리오는 매우 자연스럽다. 웹 서버에서 동시 요청을 처리하는 스레드들이 같은 DB 커넥션 풀 객체에 접근하는 것처럼. 힙은 프로세스 안의 모든 스레드가 <strong>공유하는 공간</strong>이기 때문에, 스레드 간 데이터 공유가 가능해진다.</p>

<p>그리고 바로 이 공유 때문에 문제가 생긴다.</p>

<hr />

<h2 id="2-스레드-경합은-어디서-오는가">2. 스레드 경합은 어디서 오는가</h2>

<blockquote>
  <p>근본 원인 파헤치기</p>
</blockquote>

<h3 id="스레드란-무엇인가--프로세스-안의-실행-흐름">스레드란 무엇인가 — 프로세스 안의 실행 흐름</h3>

<p>프로세스는 메모리 공간을 포함한 하나의 독립된 실행 환경이다. 스레드는 그 프로세스 안에서 실제로 코드를 실행하는 <strong>실행 단위</strong>다.</p>

<p>프로세스 하나에 스레드가 여럿 존재할 수 있고, 이들은 힙과 코드 영역을 공유하면서 스택만 따로 가진다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>프로세스
├── 힙 (공유)
├── 코드 (공유)
├── Thread A → 스택 A (독립)
├── Thread B → 스택 B (독립)
└── Thread C → 스택 C (독립)
</code></pre></div></div>

<h3 id="counter-한-줄이-사실은-세-줄이다"><code class="language-plaintext highlighter-rouge">counter++</code> 한 줄이 사실은 세 줄이다</h3>

<p>이제 핵심이다. Java에서 이렇게 쓴다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">counter</span><span class="o">++;</span>
</code></pre></div></div>

<p>한 줄처럼 보이지만, CPU가 실제로 수행하는 작업은 세 단계다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. READ  : 메모리에서 counter 값을 읽어 레지스터에 올린다
2. MODIFY: 레지스터 값을 1 증가시킨다
3. WRITE : 레지스터 값을 다시 메모리에 쓴다
</code></pre></div></div>

<p>이 세 단계는 <strong>원자적(atomic)이지 않다.</strong> 즉, 1번 이후 3번 이전에 다른 스레드가 끼어들 수 있다.</p>

<h3 id="race-condition-발생-시나리오-직접-그려보기">Race Condition 발생 시나리오 직접 그려보기</h3>

<p><code class="language-plaintext highlighter-rouge">counter = 5</code>인 상황에서 스레드 A와 B가 동시에 <code class="language-plaintext highlighter-rouge">counter++</code>를 시도한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>시간 →
Thread A: [READ: 5]              [MODIFY: 6]   [WRITE: 6]
Thread B:           [READ: 5]  [MODIFY: 6]                [WRITE: 6]
메모리:      5                                               6
</code></pre></div></div>

<p>두 스레드가 각각 증가시켰으니 결과가 7이어야 하는데, <strong>6이 나온다.</strong> B가 A의 쓰기 결과를 덮어써버렸기 때문이다. 이게 <strong>Race Condition</strong>이다.</p>

<h3 id="왜-싱글코어에서도-경합이-발생하는가-컨텍스트-스위치">왜 싱글코어에서도 경합이 발생하는가 (컨텍스트 스위치)</h3>

<p>“코어가 하나면 동시에 실행이 안 되니까 안전한 거 아닌가?” 라고 생각할 수 있다. 아니다.</p>

<p>OS는 여러 스레드를 번갈아가며 실행한다. 스레드 A를 잠깐 실행하다 멈추고, B를 실행하다 멈추고, 다시 A를 실행하는 식이다. 이걸 <strong>컨텍스트 스위치(Context Switch)</strong> 라고 한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>싱글코어에서도:
시간 →
CPU: [Thread A: READ=5] [Context Switch!] [Thread B: READ=5, WRITE=6] [Thread A: WRITE=6]
</code></pre></div></div>

<p>A가 READ한 뒤 컨텍스트 스위치가 일어나면, B가 먼저 쓰고 나서야 A가 쓰게 된다. 싱글코어도 안전하지 않다.</p>

<h3 id="왜-멀티코어에서-더-심각한가-진짜-동시-실행">왜 멀티코어에서 더 심각한가 (진짜 동시 실행)</h3>

<p>멀티코어에서는 말 그대로 두 스레드가 <strong>물리적으로 동시에</strong> 실행된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>멀티코어에서:
Core 1 (Thread A): [READ=5] ──────────────── [WRITE=6]
Core 2 (Thread B):           [READ=5] [WRITE=6]
</code></pre></div></div>

<p>컨텍스트 스위치를 기다릴 필요도 없이 충돌이 발생한다. 발생 빈도가 훨씬 높아진다.</p>

<h3 id="cpu-캐시가-만드는-또-다른-경합--가시성-문제-visibility">CPU 캐시가 만드는 또 다른 경합 — 가시성 문제 (Visibility)</h3>

<p>Race Condition과는 다른 문제가 하나 더 있다. 쓰기 자체는 성공했는데, <strong>다른 스레드가 그 값을 못 보는</strong> 상황이다.</p>

<p>이건 CPU 캐시 때문인데, 다음 챕터에서 자세히 다룬다.</p>

<hr />

<h2 id="3-가시성-문제--경합의-숨은-주범">3. 가시성 문제 — 경합의 숨은 주범</h2>

<blockquote>
  <p>Race Condition과는 다른 차원의 문제</p>
</blockquote>

<h3 id="cpu-캐시-구조-l1l2l3">CPU 캐시 구조 (L1/L2/L3)</h3>

<p>CPU가 메모리에서 값을 읽을 때마다 매번 RAM에 접근하면 너무 느리다. 그래서 CPU는 자체 캐시를 가지고 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CPU Core 1          CPU Core 2
┌──────────┐        ┌──────────┐
│  L1 캐시  │        │  L1 캐시  │  ← 코어마다 독립
│  L2 캐시  │        │  L2 캐시  │  ← 코어마다 독립
└────┬─────┘        └─────┬────┘
     │                    │
     └──────┬─────────────┘
          L3 캐시            ← 공유 (있는 경우)
             │
           RAM
</code></pre></div></div>

<p>접근 속도 차이는 극적이다. L1 캐시는 약 1~4 사이클, RAM은 200~300 사이클이 걸린다.</p>

<h3 id="스레드-a가-쓴-값을-스레드-b는-왜-못-보는가">스레드 A가 쓴 값을 스레드 B는 왜 못 보는가</h3>

<p>스레드 A가 Core 1에서 실행되며 <code class="language-plaintext highlighter-rouge">counter = 1</code>을 썼다. 이 값은 우선 Core 1의 L1 캐시에 올라간다. RAM에 즉시 반영되지 않을 수 있다.</p>

<p>스레드 B는 Core 2에서 실행된다. <code class="language-plaintext highlighter-rouge">counter</code>를 읽으면 Core 2의 캐시나 RAM에서 읽는데, A가 쓴 값이 아직 전파되지 않았다면 <strong>이전 값을 읽게 된다.</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Core 1이 counter=1 씀
  → Core 1의 L1 캐시에만 반영
  → Core 2는 아직 counter=0을 보고 있음
</code></pre></div></div>

<p>이게 <strong>가시성(Visibility) 문제</strong>다. 값이 잘못 덮어써지는 Race Condition과는 다른 버그다.</p>

<h3 id="volatile-키워드가-하는-일"><code class="language-plaintext highlighter-rouge">volatile</code> 키워드가 하는 일</h3>

<p>Java에서 <code class="language-plaintext highlighter-rouge">volatile</code>을 변수에 선언하면 두 가지를 보장한다.</p>

<ol>
  <li><strong>모든 읽기는 메인 메모리(RAM)에서 직접 읽는다.</strong></li>
  <li><strong>모든 쓰기는 즉시 메인 메모리에 반영된다.</strong></li>
</ol>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">volatile</span> <span class="kt">boolean</span> <span class="n">running</span> <span class="o">=</span> <span class="kc">true</span><span class="o">;</span>

<span class="c1">// Thread A</span>
<span class="n">running</span> <span class="o">=</span> <span class="kc">false</span><span class="o">;</span> <span class="c1">// 즉시 RAM에 반영됨</span>

<span class="c1">// Thread B</span>
<span class="k">while</span> <span class="o">(</span><span class="n">running</span><span class="o">)</span> <span class="o">{</span> <span class="c1">// 항상 RAM에서 읽어옴</span>
    <span class="n">doWork</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">volatile</code>만으로는 Race Condition을 막지 못한다는 점에 주의하자. <code class="language-plaintext highlighter-rouge">counter++</code>를 <code class="language-plaintext highlighter-rouge">volatile</code>로 선언해도 read-modify-write의 원자성은 보장되지 않는다. 가시성만 보장한다.</p>

<h3 id="memory-barrier--memory-fence란-무엇인가">Memory Barrier / Memory Fence란 무엇인가</h3>

<p>CPU는 성능 최적화를 위해 명령어 순서를 재배열(reordering)하기도 한다. 이게 가시성 문제를 더욱 복잡하게 만든다.</p>

<p><strong>Memory Barrier(메모리 장벽)</strong>는 CPU에게 “이 시점에서 모든 메모리 작업을 완료하고 캐시를 플러시하라”고 지시하는 명령이다.</p>

<p><code class="language-plaintext highlighter-rouge">volatile</code> 변수에 대한 쓰기는 내부적으로 Memory Barrier를 포함한다. 그래서 쓴 값이 즉시 다른 코어에 보이게 된다.</p>

<h3 id="happens-before-관계">Happens-before 관계</h3>

<p>Java 메모리 모델(JMM)은 <strong>happens-before</strong> 관계를 통해 가시성 보장을 형식화한다.</p>

<p>“A happens-before B”는 A의 결과가 B에게 반드시 보인다는 보장이다. 주요 규칙은 다음과 같다.</p>

<ul>
  <li>같은 스레드 안에서, 앞의 작업은 뒤의 작업보다 happens-before</li>
  <li><code class="language-plaintext highlighter-rouge">volatile</code> 쓰기는 이후의 <code class="language-plaintext highlighter-rouge">volatile</code> 읽기보다 happens-before</li>
  <li><code class="language-plaintext highlighter-rouge">synchronized</code> 블록의 해제는 이후 획득보다 happens-before</li>
  <li><code class="language-plaintext highlighter-rouge">Thread.start()</code>는 시작된 스레드의 모든 작업보다 happens-before</li>
</ul>

<hr />

<h2 id="4-경합을-해결하는-세-가지-접근">4. 경합을 해결하는 세 가지 접근</h2>

<blockquote>
  <p>Lock, Lock-free, Wait-free</p>
</blockquote>

<h3 id="mutual-exclusion-상호-배제--한-번에-하나만-들어가">Mutual Exclusion (상호 배제) — 한 번에 하나만 들어가</h3>

<p>가장 직관적인 방법이다. 임계 구역(Critical Section)에 한 번에 하나의 스레드만 들어가도록 <strong>잠금(Lock)</strong> 을 건다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thread A →  [Lock 획득] → [임계 구역 실행] → [Lock 해제]
Thread B →  [대기...  ] ──────────────────→ [Lock 획득] → [임계 구역 실행]
</code></pre></div></div>

<p>구현이 단순하고 정확성을 보장하기 쉽다. 하지만 대기하는 스레드가 블로킹되므로 성능 손실이 있다.</p>

<h3 id="lock-free--누군가는-반드시-진행됨을-보장">Lock-free — 누군가는 반드시 진행됨을 보장</h3>

<p>Lock을 사용하지 않으면서 다음을 보장한다: <strong>전체 스레드 중 적어도 하나는 항상 진행 중이다.</strong></p>

<p>CAS(Compare-And-Swap)가 대표적인 구현 방법이다. 개별 스레드가 실패(재시도)할 수 있지만, 시스템 전체가 멈추는 일은 없다.</p>

<p>Lock-free는 Deadlock이 발생하지 않는다는 큰 장점이 있다.</p>

<h3 id="wait-free--모두가-유한-시간-안에-진행됨을-보장">Wait-free — 모두가 유한 시간 안에 진행됨을 보장</h3>

<p>Lock-free보다 더 강한 조건이다. <strong>모든 스레드가 유한한 시간 안에 반드시 완료된다.</strong></p>

<p>구현이 매우 복잡하고 현실에서 사용되는 경우는 드물다. 이론적으로는 가장 이상적인 모델이다.</p>

<h3 id="세-가지의-트레이드오프-비교">세 가지의 트레이드오프 비교</h3>

<table>
  <thead>
    <tr>
      <th>특성</th>
      <th>Lock (Mutual Exclusion)</th>
      <th>Lock-free</th>
      <th>Wait-free</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>구현 복잡도</td>
      <td>낮음</td>
      <td>중간</td>
      <td>매우 높음</td>
    </tr>
    <tr>
      <td>Deadlock 가능</td>
      <td>있음</td>
      <td>없음</td>
      <td>없음</td>
    </tr>
    <tr>
      <td>개별 스레드 기아</td>
      <td>가능</td>
      <td>가능</td>
      <td>불가능</td>
    </tr>
    <tr>
      <td>성능 (경합 없을 때)</td>
      <td>중간</td>
      <td>높음</td>
      <td>높음</td>
    </tr>
    <tr>
      <td>성능 (경합 많을 때)</td>
      <td>낮음</td>
      <td>중간</td>
      <td>높음</td>
    </tr>
  </tbody>
</table>

<p>현실에서는 Lock과 Lock-free를 상황에 따라 선택하고, Wait-free는 특수 목적 시스템에서만 쓰인다.</p>

<hr />

<h2 id="5-cas--lock-없이-경합을-해결하다">5. CAS — Lock 없이 경합을 해결하다</h2>

<blockquote>
  <p>CPU 명령어 레벨의 마법</p>
</blockquote>

<h3 id="optimistic-lock-vs-pessimistic-lock-개념">Optimistic Lock vs Pessimistic Lock 개념</h3>

<p>경합에 대한 두 가지 철학이 있다.</p>

<p><strong>Pessimistic Lock (비관적 락)</strong>: “어차피 경합이 일어날 거야, 미리 잠그자.”
→ 임계 구역에 들어가기 전에 무조건 Lock을 건다. 전통적인 <code class="language-plaintext highlighter-rouge">synchronized</code>가 이 방식이다.</p>

<p><strong>Optimistic Lock (낙관적 락)</strong>: “아마 경합 안 일어나겠지, 일단 해보고 충돌나면 재시도하자.”
→ Lock 없이 작업하고, 완료 시점에 누가 먼저 썼는지 확인한다. CAS가 이 방식이다.</p>

<p>경합이 드물수록 Optimistic이 유리하고, 경합이 빈번할수록 Pessimistic이 안정적이다.</p>

<h3 id="cas-compare-and-swap-의-작동-원리">CAS (Compare-And-Swap) 의 작동 원리</h3>

<p>CAS는 세 개의 인자를 받는다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CAS(메모리주소, 기대값, 새값)
→ 메모리주소의 값이 기대값과 같으면, 새값으로 교체하고 true 반환
→ 다르면, 아무것도 하지 않고 false 반환
</code></pre></div></div>

<p>의사코드로 표현하면 이렇다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>function CAS(addr, expected, newVal):
    if *addr == expected:
        *addr = newVal
        return true
    else:
        return false
</code></pre></div></div>

<p><strong>중요한 점은 이 비교-교체 동작 전체가 원자적으로 실행된다는 것이다.</strong> 중간에 아무도 끼어들 수 없다.</p>

<h3 id="왜-cas는-atomic한가--cpu-명령어lock-cmpxchg-레벨로">왜 CAS는 atomic한가 — CPU 명령어(LOCK CMPXCHG) 레벨로</h3>

<p>CAS는 소프트웨어 레벨의 추상이 아니다. x86 CPU는 <code class="language-plaintext highlighter-rouge">LOCK CMPXCHG</code>라는 하드웨어 명령어를 제공한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>LOCK CMPXCHG [메모리주소], 새값
; AL/AX/EAX에 기대값이 들어있다고 가정
; LOCK 접두어가 버스를 잠가서 다른 코어의 접근을 차단
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">LOCK</code> 접두어는 해당 명령어 실행 동안 <strong>메모리 버스를 독점</strong>한다. 다른 코어는 이 시간 동안 해당 메모리에 접근할 수 없다. 이로써 비교와 교체가 완전한 원자성을 갖는다.</p>

<p>Java에서 <code class="language-plaintext highlighter-rouge">sun.misc.Unsafe</code>나 <code class="language-plaintext highlighter-rouge">VarHandle</code>이 내부적으로 이 명령어를 호출한다.</p>

<h3 id="cas-성공실패-시나리오">CAS 성공/실패 시나리오</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>초기값: counter = 5

Thread A: CAS(counter, 기대=5, 새값=6)
  → counter가 5이면 6으로 바꿈 → 성공 ✓

Thread B (동시 시도): CAS(counter, 기대=5, 새값=6)
  → counter가 이미 6 (A가 바꿨음) → 실패 ✗
  → counter 값 다시 읽음: 6
  → CAS(counter, 기대=6, 새값=7) → 성공 ✓
</code></pre></div></div>

<p>실패 시 현재 값을 다시 읽고 재시도한다. 이를 <strong>CAS loop</strong> 또는 <strong>spin</strong> 이라고 한다.</p>

<h3 id="cas-기반-직접-구현해보기">CAS 기반 직접 구현해보기</h3>

<p>Java에서 <code class="language-plaintext highlighter-rouge">AtomicInteger</code>가 내부적으로 CAS를 사용한다. 직접 흉내내보면 이렇다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AtomicInteger 내부와 유사한 구현</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">CasCounter</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="kd">volatile</span> <span class="kt">int</span> <span class="n">value</span><span class="o">;</span>

    <span class="kd">public</span> <span class="kt">int</span> <span class="nf">incrementAndGet</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">while</span> <span class="o">(</span><span class="kc">true</span><span class="o">)</span> <span class="o">{</span>
            <span class="kt">int</span> <span class="n">current</span> <span class="o">=</span> <span class="n">value</span><span class="o">;</span>        <span class="c1">// 1. 현재 값 읽기</span>
            <span class="kt">int</span> <span class="n">next</span> <span class="o">=</span> <span class="n">current</span> <span class="o">+</span> <span class="mi">1</span><span class="o">;</span>     <span class="c1">// 2. 새 값 계산</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">compareAndSet</span><span class="o">(</span><span class="n">current</span><span class="o">,</span> <span class="n">next</span><span class="o">))</span> <span class="o">{</span> <span class="c1">// 3. CAS 시도</span>
                <span class="k">return</span> <span class="n">next</span><span class="o">;</span>
            <span class="o">}</span>
            <span class="c1">// 실패하면 처음부터 다시</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="c1">// 실제로는 Unsafe.compareAndSwapInt()를 호출</span>
    <span class="kd">private</span> <span class="kt">boolean</span> <span class="nf">compareAndSet</span><span class="o">(</span><span class="kt">int</span> <span class="n">expected</span><span class="o">,</span> <span class="kt">int</span> <span class="n">update</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// CPU의 LOCK CMPXCHG 명령어가 실행됨</span>
        <span class="k">return</span> <span class="no">UNSAFE</span><span class="o">.</span><span class="na">compareAndSwapInt</span><span class="o">(</span><span class="k">this</span><span class="o">,</span> <span class="n">valueOffset</span><span class="o">,</span> <span class="n">expected</span><span class="o">,</span> <span class="n">update</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Lock이 전혀 없지만, 여러 스레드가 동시에 접근해도 값이 정확하게 증가한다.</p>

<h3 id="aba-문제--cas의-치명적-약점">ABA 문제 — CAS의 치명적 약점</h3>

<p>CAS는 값이 기대값과 <strong>같은지만</strong> 본다. 중간에 값이 변경되었다가 다시 원래 값으로 돌아온 경우를 구분하지 못한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>counter = A (값 A)

Thread A: READ → 5 (기대값으로 저장)
Thread B: 5 → 10 → 5 (값을 바꿨다가 다시 원래대로)
Thread A: CAS(기대=5, 새값=6) → 성공! (중간에 변화가 있었는데도)
</code></pre></div></div>

<p>단순 숫자 카운터에서는 문제없지만, <strong>포인터 기반 자료구조(연결 리스트 등)</strong> 에서는 심각한 버그를 유발할 수 있다.</p>

<h3 id="aba-해결법--stamped-reference-version-counter">ABA 해결법 — Stamped Reference, Version Counter</h3>

<p>핵심 아이디어: <strong>값과 함께 버전 번호(스탬프)를 같이 비교한다.</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(값=5, 버전=1) → (값=10, 버전=2) → (값=5, 버전=3)
</code></pre></div></div>

<p>버전이 달라졌기 때문에, Thread A의 CAS는 <code class="language-plaintext highlighter-rouge">(기대=5, 버전=1)</code>으로 체크하면 실패한다.</p>

<p>Java에서는 <code class="language-plaintext highlighter-rouge">AtomicStampedReference</code>가 이를 제공한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">AtomicStampedReference</span><span class="o">&lt;</span><span class="nc">Integer</span><span class="o">&gt;</span> <span class="n">ref</span> <span class="o">=</span>
    <span class="k">new</span> <span class="nc">AtomicStampedReference</span><span class="o">&lt;&gt;(</span><span class="mi">5</span><span class="o">,</span> <span class="mi">0</span><span class="o">);</span> <span class="c1">// (초기값, 초기스탬프)</span>

<span class="kt">int</span><span class="o">[]</span> <span class="n">stampHolder</span> <span class="o">=</span> <span class="k">new</span> <span class="kt">int</span><span class="o">[</span><span class="mi">1</span><span class="o">];</span>
<span class="kt">int</span> <span class="n">current</span> <span class="o">=</span> <span class="n">ref</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">stampHolder</span><span class="o">);</span> <span class="c1">// 값과 스탬프 함께 읽기</span>
<span class="kt">int</span> <span class="n">stamp</span> <span class="o">=</span> <span class="n">stampHolder</span><span class="o">[</span><span class="mi">0</span><span class="o">];</span>

<span class="n">ref</span><span class="o">.</span><span class="na">compareAndSet</span><span class="o">(</span><span class="n">current</span><span class="o">,</span> <span class="n">current</span> <span class="o">+</span> <span class="mi">1</span><span class="o">,</span> <span class="n">stamp</span><span class="o">,</span> <span class="n">stamp</span> <span class="o">+</span> <span class="mi">1</span><span class="o">);</span>
<span class="c1">// 값과 스탬프 모두 일치해야 교체 성공</span>
</code></pre></div></div>

<hr />

<h2 id="6-lock--os와-협력하는-동기화">6. Lock — OS와 협력하는 동기화</h2>

<blockquote>
  <p>Busy-wait와 Sleep, 무엇이 나은가</p>
</blockquote>

<h3 id="mutex-semaphore-monitor-개념-정리">Mutex, Semaphore, Monitor 개념 정리</h3>

<p><strong>Mutex (Mutual Exclusion Lock)</strong></p>

<p>오직 한 스레드만 잠글 수 있고, 잠근 스레드만 해제할 수 있다. 가장 기본적인 동기화 도구다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Mutex m;
m.lock();   // 다른 스레드는 여기서 대기
// 임계 구역
m.unlock(); // 대기 중인 스레드 깨움
</code></pre></div></div>

<p><strong>Semaphore</strong></p>

<p>내부에 정수 카운터를 가지고 있다. <code class="language-plaintext highlighter-rouge">N</code>개의 자원을 동시에 <code class="language-plaintext highlighter-rouge">N</code>개의 스레드가 사용할 수 있도록 제한한다. Mutex는 Semaphore(N=1)의 특수한 경우다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Semaphore</span> <span class="n">sem</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Semaphore</span><span class="o">(</span><span class="mi">3</span><span class="o">);</span> <span class="c1">// 최대 3개 동시 접근 허용</span>

<span class="n">sem</span><span class="o">.</span><span class="na">acquire</span><span class="o">();</span> <span class="c1">// 카운터 감소, 0이면 대기</span>
<span class="c1">// 자원 사용</span>
<span class="n">sem</span><span class="o">.</span><span class="na">release</span><span class="o">();</span> <span class="c1">// 카운터 증가, 대기 중인 스레드 깨움</span>
</code></pre></div></div>

<p><strong>Monitor</strong></p>

<p>Java의 <code class="language-plaintext highlighter-rouge">synchronized</code>가 Monitor 방식이다. Lock과 Condition Variable(대기/통지 메커니즘)을 함께 제공하는 고수준 추상화다. 모든 Java 객체는 내부에 Monitor를 가진다.</p>

<h3 id="busy-wait-스핀락--cpu를-태워서-빠르게">Busy-wait (스핀락) — CPU를 태워서 빠르게</h3>

<p>스핀락은 Lock이 해제되기를 기다리는 동안 <strong>루프를 돌면서 계속 확인</strong>한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 스핀락 의사코드</span>
<span class="k">while</span> <span class="o">(!</span><span class="n">lock</span><span class="o">.</span><span class="na">tryAcquire</span><span class="o">())</span> <span class="o">{</span>
    <span class="c1">// 아무것도 안 함, 그냥 계속 체크</span>
<span class="o">}</span>
<span class="c1">// Lock 획득 성공</span>
</code></pre></div></div>

<p><strong>언제 유리한가</strong></p>

<p>Lock이 아주 짧은 시간 안에 해제될 것이 확실할 때 유리하다. OS에 스레드를 재우고 깨우는 컨텍스트 스위치 비용(수천~수만 사이클)보다, 잠깐 스핀하는 게 오히려 빠를 수 있다.</p>

<p>커널 공간에서, 인터럽트 핸들러에서, 실시간 시스템에서 많이 사용된다.</p>

<p><strong>언제 독이 되는가</strong></p>

<p>Lock이 오래 유지될 때는 최악이다. CPU 코어 하나를 100% 점유하면서 아무 일도 못 하고 기다린다. 다른 스레드가 그 코어를 사용할 수 없으니, Lock을 가진 스레드가 더 느려지는 역효과도 날 수 있다. 배터리 소모와 발열도 증가한다.</p>

<h3 id="notify-방식-슬립락--os한테-맡기기">Notify 방식 (슬립락) — OS한테 맡기기</h3>

<p>슬립락은 Lock을 획득하지 못하면 <strong>OS에게 스레드를 재워달라고 요청</strong>한다. Lock이 해제되면 OS가 깨워준다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thread B가 Lock 획득 실패
  → OS에 "나 재워줘" 요청
  → Thread B는 WAITING 상태로 전환
  → 다른 스레드가 CPU 사용
  → Thread A가 Lock 해제 + OS에 "B 깨워줘" 요청
  → OS가 Thread B를 RUNNABLE 상태로 전환
  → Thread B가 Lock 획득 성공
</code></pre></div></div>

<p><strong>컨텍스트 스위치 비용이란</strong></p>

<p>스레드를 전환할 때 OS는 현재 스레드의 레지스터 상태, 프로그램 카운터 등 실행 컨텍스트를 저장하고, 새 스레드의 컨텍스트를 복원해야 한다. 이 과정이 수천~수만 사이클을 소모한다. 캐시도 오염된다(Cache Thrashing).</p>

<p><strong>깨어나는 데 얼마나 걸리는가</strong></p>

<p>슬립 → 깨어남의 지연 시간은 OS 스케줄러에 달려있다. 일반적인 Linux에서 약 10~100 마이크로초다. 이 지연이 허용 가능한지에 따라 슬립락의 적합성이 달라진다.</p>

<h3 id="adaptive-lock--둘을-섞은-현실의-선택">Adaptive Lock — 둘을 섞은 현실의 선택</h3>

<p>실제 VM과 OS는 두 방식을 섞는다. 이를 <strong>Adaptive Lock</strong> 또는 <strong>Hybrid Lock</strong>이라 한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Lock 획득 시도
  → 실패
  → 짧게 스핀 (예: 수십~수백 번)
    → 그 사이 Lock 해제됨 → 획득 성공 (스핀락 승리)
    → 여전히 잠겨있음 → OS에 슬립 요청 (슬립락으로 전환)
</code></pre></div></div>

<p><strong>Java <code class="language-plaintext highlighter-rouge">synchronized</code> 내부 동작</strong></p>

<p>Java의 <code class="language-plaintext highlighter-rouge">synchronized</code>는 JVM이 내부적으로 <strong>Biased Lock → Thin Lock → Fat Lock</strong> 순으로 전환한다.</p>

<ul>
  <li><strong>Biased Lock</strong>: 경합이 없는 경우, CAS조차 안 하고 스레드 ID만 마킹. 거의 비용 없음.</li>
  <li><strong>Thin Lock</strong>: 경합이 생기면 CAS 기반 스핀. 가볍게 처리.</li>
  <li><strong>Fat Lock</strong>: 스핀으로 안 되면 OS Mutex 사용. 슬립/깨움 발생.</li>
</ul>

<p>JVM이 런타임에 경합 패턴을 보고 자동으로 전환한다.</p>

<p><strong>Linux <code class="language-plaintext highlighter-rouge">futex</code> 동작 원리</strong></p>

<p><code class="language-plaintext highlighter-rouge">futex(fast userspace mutex)</code>는 Linux의 핵심 동기화 기본 요소다.</p>

<p>경합이 없을 때는 유저 공간에서만 처리(시스템 콜 없음)하고, 경합이 생길 때만 커널에 내려간다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Lock 획득:
  1. 유저 공간에서 원자적으로 Lock 상태 변경 시도
  2. 성공하면 끝 (커널 진입 없음)
  3. 실패하면 futex_wait() 시스템 콜로 커널에 내려가 대기

Lock 해제:
  1. 유저 공간에서 Lock 상태 변경
  2. 대기자가 있으면 futex_wake() 시스템 콜로 깨움
</code></pre></div></div>

<p>불필요한 시스템 콜을 최소화해서 성능을 극대화하는 설계다.</p>

<h3 id="lock의-문제들-deadlock-starvation-priority-inversion">Lock의 문제들: Deadlock, Starvation, Priority Inversion</h3>

<p><strong>Deadlock (교착 상태)</strong></p>

<p>두 스레드가 서로가 가진 Lock을 기다리며 영원히 멈추는 상황이다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thread A: Lock1 보유, Lock2 대기
Thread B: Lock2 보유, Lock1 대기
→ 둘 다 영원히 대기
</code></pre></div></div>

<p>해결책: 항상 같은 순서로 Lock을 획득하거나, 타임아웃을 설정한다.</p>

<p><strong>Starvation (기아 상태)</strong></p>

<p>특정 스레드가 Lock을 영원히 획득하지 못하는 상황이다. 다른 스레드들이 계속 먼저 획득하면 발생한다. Fair Lock(공정 락)으로 해결할 수 있다.</p>

<p><strong>Priority Inversion (우선순위 역전)</strong></p>

<p>높은 우선순위 스레드가 낮은 우선순위 스레드가 가진 Lock을 기다리는 상황이다. 결과적으로 낮은 우선순위 스레드가 먼저 실행된다. 실시간 시스템에서 치명적이며, Priority Inheritance(우선순위 상속)로 해결한다.</p>

<hr />

<h2 id="7-현실에서의-선택--언제-무엇을-쓰는가">7. 현실에서의 선택 — 언제 무엇을 쓰는가</h2>

<h3 id="상황별-선택-기준">상황별 선택 기준</h3>

<p><strong>짧은 임계구역 → 스핀락</strong></p>

<p>임계 구역 실행 시간이 컨텍스트 스위치 비용(수천 사이클)보다 짧다면 스핀락이 유리하다. 커널 드라이버, 인터럽트 핸들러, 고성능 서버의 핫패스에서 사용된다.</p>

<p><strong>긴 임계구역 → 슬립락</strong></p>

<p>임계 구역이 I/O를 포함하거나 긴 계산이 있다면 슬립락이 맞다. 기다리는 동안 CPU를 다른 스레드에게 양보해야 한다.</p>

<p><strong>단순 카운터 → Atomic (CAS)</strong></p>

<p>단일 변수의 원자적 증감이라면 Lock 자체가 오버킬이다. <code class="language-plaintext highlighter-rouge">AtomicInteger</code> 등의 Atomic 클래스를 사용한다. Lock-free이면서도 thread-safe하다.</p>

<p><strong>복잡한 자료구조 → Lock-free 자료구조 or Lock</strong></p>

<p>여러 변수를 함께 원자적으로 변경해야 한다면 CAS만으로는 한계가 있다. <code class="language-plaintext highlighter-rouge">ConcurrentLinkedQueue</code>같은 Lock-free 자료구조를 사용하거나, <code class="language-plaintext highlighter-rouge">ReentrantLock</code>으로 임계 구역을 직접 관리한다.</p>

<h3 id="java-기준-실전-선택-가이드">Java 기준 실전 선택 가이드</h3>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>선택</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>단순 숫자 증감</td>
      <td><code class="language-plaintext highlighter-rouge">AtomicInteger</code></td>
      <td>Lock-free, 빠름</td>
    </tr>
    <tr>
      <td>단순 플래그</td>
      <td><code class="language-plaintext highlighter-rouge">volatile boolean</code></td>
      <td>가시성만 필요</td>
    </tr>
    <tr>
      <td>간단한 임계 구역</td>
      <td><code class="language-plaintext highlighter-rouge">synchronized</code></td>
      <td>간단하고 JVM이 최적화</td>
    </tr>
    <tr>
      <td>타임아웃 필요</td>
      <td><code class="language-plaintext highlighter-rouge">ReentrantLock</code></td>
      <td><code class="language-plaintext highlighter-rouge">tryLock(timeout)</code> 제공</td>
    </tr>
    <tr>
      <td>읽기가 압도적으로 많음</td>
      <td><code class="language-plaintext highlighter-rouge">ReadWriteLock</code></td>
      <td>읽기 동시성 허용</td>
    </tr>
    <tr>
      <td>컬렉션 동시 접근</td>
      <td><code class="language-plaintext highlighter-rouge">ConcurrentHashMap</code> 등</td>
      <td>내부적으로 최적화된 동기화</td>
    </tr>
    <tr>
      <td>고성능 카운터</td>
      <td><code class="language-plaintext highlighter-rouge">LongAdder</code></td>
      <td>경합 시 <code class="language-plaintext highlighter-rouge">AtomicLong</code>보다 빠름</td>
    </tr>
  </tbody>
</table>

<p><strong><code class="language-plaintext highlighter-rouge">LongAdder</code> 참고</strong>: 경합이 많을 때 <code class="language-plaintext highlighter-rouge">AtomicLong</code>보다 <code class="language-plaintext highlighter-rouge">LongAdder</code>가 빠른 이유는, 스레드마다 독립적인 카운터를 두고 최종 합산하기 때문이다. CAS 충돌을 내부적으로 분산시킨다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 단순 카운터</span>
<span class="nc">AtomicInteger</span> <span class="n">count</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">AtomicInteger</span><span class="o">(</span><span class="mi">0</span><span class="o">);</span>
<span class="n">count</span><span class="o">.</span><span class="na">incrementAndGet</span><span class="o">();</span>

<span class="c1">// 고경합 카운터</span>
<span class="nc">LongAdder</span> <span class="n">adder</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">LongAdder</span><span class="o">();</span>
<span class="n">adder</span><span class="o">.</span><span class="na">increment</span><span class="o">();</span>
<span class="kt">long</span> <span class="n">total</span> <span class="o">=</span> <span class="n">adder</span><span class="o">.</span><span class="na">sum</span><span class="o">();</span>

<span class="c1">// 복잡한 임계 구역</span>
<span class="nc">ReentrantLock</span> <span class="n">lock</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ReentrantLock</span><span class="o">();</span>
<span class="k">if</span> <span class="o">(</span><span class="n">lock</span><span class="o">.</span><span class="na">tryLock</span><span class="o">(</span><span class="mi">1</span><span class="o">,</span> <span class="nc">TimeUnit</span><span class="o">.</span><span class="na">SECONDS</span><span class="o">))</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="c1">// 임계 구역</span>
    <span class="o">}</span> <span class="k">finally</span> <span class="o">{</span>
        <span class="n">lock</span><span class="o">.</span><span class="na">unlock</span><span class="o">();</span> <span class="c1">// finally에서 반드시 해제</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<hr />

<h2 id="8-마치며">8. 마치며</h2>

<h3 id="핵심-흐름-한-장으로-정리">핵심 흐름 한 장으로 정리</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>힙은 공유된다
    ↓
여러 스레드가 동시에 읽고 쓰면
    ↓
Race Condition (값 유실)
CPU 캐시 불일치 (Visibility 문제)
    ↓
해결 방법 세 갈래
    ├── volatile       → 가시성만 해결
    ├── CAS (Atomic)   → 단일 변수 Lock-free 원자 연산
    └── Lock           → 임계 구역 보호
            ├── Spin Lock  → 짧은 대기, CPU 소모
            ├── Sleep Lock → 긴 대기, OS 협력
            └── Adaptive   → 상황 따라 자동 전환 (현실의 선택)
</code></pre></div></div>

<p>동시성의 복잡함은 결국 하나의 질문으로 귀결된다.</p>

<blockquote>
  <p><strong>“공유 자원에 동시에 접근하는 상황에서, 어떻게 정확성과 성능을 함께 잡을 것인가?”</strong></p>
</blockquote>

<p>정답은 없다. 상황을 이해하고, 트레이드오프를 알고, 맥락에 맞는 도구를 선택하는 것이 전부다.</p>

<h3 id="더-공부할-것들">더 공부할 것들</h3>

<p>이 글을 이해했다면 다음 주제들이 자연스럽게 이어진다.</p>

<p><strong>LMAX Disruptor</strong>
Lock-free Ring Buffer 기반의 초고성능 이벤트 처리 라이브러리. CAS와 캐시 라인 최적화를 극단적으로 활용한다. “왜 이게 이렇게 빠른가”를 분석하면 지금까지 배운 개념이 실전에 어떻게 적용되는지 보인다.</p>

<p><strong>STM (Software Transactional Memory)</strong>
데이터베이스 트랜잭션처럼 메모리 접근을 트랜잭션으로 추상화하는 방식. Haskell의 STM이 유명하다. Lock 없이 복잡한 원자성을 표현하는 또 다른 접근법이다.</p>

<p><strong>Actor 모델</strong>
공유 메모리 자체를 포기하는 접근법. 각 Actor는 독립적인 상태를 가지고, 메시지로만 통신한다. Erlang, Akka가 대표적이다. 경합 자체가 생기지 않는 구조를 설계하는 사고방식을 배울 수 있다.</p>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[경합 조건이 발생하는 이유부터 CAS 연산, synchronized, Lock까지 동시성 문제를 왜(Why)부터 차근차근 이해합니다.]]></summary></entry><entry><title type="html">컴퓨터 과학(CS)지식의 중요성</title><link href="https://wlswo.me/2026/02/01/AI-CS/" rel="alternate" type="text/html" title="컴퓨터 과학(CS)지식의 중요성" /><published>2026-02-01T10:00:00+00:00</published><updated>2026-02-01T10:00:00+00:00</updated><id>https://wlswo.me/2026/02/01/AI-CS</id><content type="html" xml:base="https://wlswo.me/2026/02/01/AI-CS/"><![CDATA[<h1 id="cs-지식이-더-중요해지는-이유">CS 지식이 더 중요해지는 이유</h1>
<p>요즘 AI를 사용해 코드를 생성하고 새로운 기술이 쏟아지는 시대를 체감하고 있다.
대부분의 현대 기술은 완전히 새로운 개념이 아니라, 이미 수십 년 전에 정립된 컴퓨터 과학(CS)의 개념을 응용하거나 재해석한 것이다. AI가 루틴한 코딩업무를 대신할 수는 있겠지만 시스템의 복잡한 병목 현상을 해결하거나 설계의 트레이드오프를 판단하는 것은 여전히 엔지니어의 몫이다.</p>

<h1 id="cs-지식은-변하지-않는다">CS 지식은 변하지 않는다</h1>
<p>나뭇잎이 떨어지고 새로 생겨도 나뭇가지를 지탱하는 뿌리는 변하지 않는다. 프레임워크나 언어 기술은 나뭇잎이지만, CS는 뿌리와 같다.</p>

<p>기술이 빠르게 변할수록 <u>'무엇을 쓸 것인가'보다는 '어떻게 작동하는가'라는 본질</u>에 집중해야 한다. CS라는 단단한 기초를 가진 엔지니어는 새로운 기술이 나와도 그 핵심 개념을 빠르게 파악할 수 있다. 결국 본질에 투자하는 것이 기술적 불확실성이나 새로운 기술의 등장에 두려워하지 않고 이겨내는 방법일 것이다.</p>

<h1 id="최신-기술에-쓰인-cs-개념-사례">최신 기술에 쓰인 CS 개념 사례</h1>
<p>대부분의 기술의 하부 구조를 뜯어보면 결국 CS의 핵심 원리가 작동하고 있음을 알 수 있다. 구체적으로는 아래와 같은 기술들이 있다.</p>

<h2 id="커넥션-풀">커넥션 풀</h2>
<p>OS가 한정된 CPU 자원을 여러 프로세스에 분배하기 위해 상태를 관리하는 방식에 대해 알아보자.
운영체제는 프로세스를 생성할 때마다 <strong>PCB(Process Control Block)</strong>라는 데이터 구조를 생성하여 상태를 기록한다. 프로세스가 빈번하게 생성되고 소멸되면, 이 정보를 메모리에 쓰고 지우는 과정에서 막대한 <u>Context Switching 비용이 발생하며 시스템 성능이 저하된다.</u></p>

<p>데이터베이스와의 통신에서도 동일한 문제가 발생한다. TCP 연결마다 생성되는 <strong>TCB(Transmission Control Block)</strong>는 OS의 PCB와 마찬가지로 생성 비용이 높다.</p>

<p>따라서 OS가 자주 쓰는 프로세스를 완전히 종료하지 않고 대기 상태로 두어 효율을 높이듯, 커넥션 풀은 미리 연결된 소켓(TCB)을 메모리에 상주시켜 ‘풀(Pool)’이라는 자원 집합에 보관한다. 이는 자원의 할당과 해제를 반복하지 않고 <strong>재사용(Reuse)</strong>한다는 OS의 자원 관리 최적화 기법이 네트워크 계층으로 확장된 것이다.</p>

<h2 id="도커">도커</h2>
<p>운영체제가 개별 프로세스에게 독립적인 실행 환경을 제공하기 위해 사용하던 <strong>가상화</strong>라는 개념이 도커에도 사용됐다.
운영체제는 가상 메모리(Virtual Memory)를 통해 각 프로세스가 마치 시스템의 메모리를 독점하고 있는 것처럼 느끼게 한다. 이는 실제 물리 주소를 숨기고 논리 주소를 제공하는 추상화의 일종이다.</p>

<p>도커의 핵심인 Namespaces는 이 개념을 시스템 자원 전체로 확장했다. OS가 프로세스에게 가상 메모리 주소를 할당하듯, 도커는 컨테이너 내부 프로세스에게 독립적인 가상 네트워크 인터페이스, 가상 호스트명, 가상 PID 공간을 부여한다. 또한 Cgroups는 OS가 멀티태스킹 환경에서 CPU 스케줄링을 통해 자원을 배분하던 원리를 정교화한 것이다.</p>

<p>특정 프로세스 그룹이 사용할 수 있는 자원의 상한선(Quota)을 강제로 지정함으로써, 현대의 클라우드 인프라는 OS 커널 수준에서의 자원 격리 및 배분이라는 고전적 원리를 통해 수천 개의 컨테이너를 안정적으로 운용한다.</p>

<h2 id="vm">VM</h2>
<p>물리 서버 하나를 여러 대의 서버처럼 분할하여 사용하는 가상 머신(VM) 기술은 운영체제가 하드웨어를 관리하는 고전적인 방식에서 기인한다. 운영체제는 하드웨어와 소프트웨어 사이에서 <strong>중계자</strong> 역할을 수행한다.</p>

<p>프로그래머가 복잡한 기계 신호를 직접 제어하는 대신 규격화된 명령을 통해 자원을 사용하게 만드는데, 이것이 시스템 운영의 본질적인 추상화다. 컴퓨터 과학에서 추상화란 <u>복잡한 내부 구현이나 하부 구조를 감추고 사용자가 꼭 알아야 하는 핵심 기능만을 제공</u>하는 것을 의미하며, 마치 운전자가 엔진 원리를 몰라도 페달만으로 차를 움직이는 것과 같다.</p>

<p>클라우드 기술의 핵심인 하이퍼바이저는 이러한 운영체제의 역할을 시스템 전체 단위로 확장한 것으로, 하드웨어 자원을 중간에서 가로채 여러 개의 독립된 운영체제에게 논리적으로 쪼개어 배분한다. 결국 하이퍼바이저가 각 가상 머신에 자원을 할당하는 방식은 운영체제 커널이 여러 작업에 시간을 나누어 자원을 배분하던 <strong>스케줄링</strong> 원리를 하드웨어 단위로 재현한 것이다.</p>

<h1 id="변하지-않는-것에-집중">변하지 않는 것에 집중</h1>
<p>기술 트렌드는 빠르게 바뀌지만 우리가 사용하는 최신 기술들은 결국 운영체제가 자원을 관리하고, 프로세스를 격리하며, 하드웨어를 추상화하던 CS의 지식들 처럼 빌려온 것에 불과하다.</p>

<p>AI가 생산성을 높여주는 시대일수록 엔지니어의 진짜 실력은 도구 숙련도가 아니라 시스템의 내부를 꿰뚫어 보는 통찰력에서 결정된다. 새로운 기술을 배우는 속도는 그 기술이 뿌리내린 CS 지식의 깊이에 비례한다. 변화가 심할수록 본질에 집중하는 것이 중요하다.</p>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[AI와 새로운 기술이 쏟아지는 시대일수록 변하지 않는 CS 기초가 중요합니다. 커넥션 풀, 도커 등 최신 기술에 녹아있는 CS 원리를 살펴봅니다.]]></summary></entry><entry><title type="html">JVM 이해하기</title><link href="https://wlswo.me/2026/01/22/JVM/" rel="alternate" type="text/html" title="JVM 이해하기" /><published>2026-01-22T01:32:00+00:00</published><updated>2026-01-22T01:32:00+00:00</updated><id>https://wlswo.me/2026/01/22/JVM</id><content type="html" xml:base="https://wlswo.me/2026/01/22/JVM/"><![CDATA[<blockquote>
  <p>추상적으로 다가왔던 내용을 구체화하여 정리한다.</p>
</blockquote>

<h1 id="q-jvm-클래스-로더가-읽는다는-것">Q. JVM 클래스 로더가 “읽는다”는 것</h1>

<p>내 컴퓨터에 저장되어 있는 바이트코드로 된 .class 파일을 읽는다. 컴퓨터 입장에서 .class 파일은 하드디스크에 “010100…“과 같은 0과 1로 이루어진 바이너리 코드로 저장되어 있다.</p>

<p>클래스 로더는 이 바이너리 코드 데이터를 스트림으로 읽어 메서드 영역에 <u>C++ 구조체 형태로 저장한다.</u> 메서드 영역에 저장된 정보를 바탕으로, 힙 영역에 java.lang.Class 타입의 객체를 생성한다.</p>

<p>이 객체에는 메서드 영역의 실제 메타데이터를 가리키는 포인터가 주입되어 있어, getName(), getMethods() 같은 메서드를 호출하면 포인터를 타고 메서드 영역으로 넘어가서 정보를 읽어 반환한다.</p>

<p>최초 로딩 시에는 동기화(Synchronization) 블록을 통해 동시에 여러 스레드가 요청해도 딱 한 번만 로딩하고 객체를 생성하도록 제어한다. 따라서 User.class를 코드상에서 100번 호출해도, 힙 영역에 있는 동일한 주소의 java.lang.Class 객체 하나만을 계속해서 반환하게 된다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">User</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="nc">String</span> <span class="n">name</span><span class="o">;</span>
    <span class="kd">public</span> <span class="kt">int</span> <span class="n">age</span><span class="o">;</span>
<span class="o">}</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">Main</span> <span class="o">{</span>

  <span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">main</span><span class="o">(</span><span class="nc">String</span><span class="o">[]</span> <span class="n">args</span><span class="o">)</span> <span class="o">{</span>

    <span class="c1">// 1. Class 객체 획득 (힙 영역의 객체 참조)</span>
    <span class="nc">Class</span><span class="o">&lt;</span><span class="nc">User</span><span class="o">&gt;</span> <span class="n">user</span> <span class="o">=</span> <span class="nc">User</span><span class="o">.</span><span class="na">class</span><span class="o">;</span>

    <span class="c1">// 2. 클래스 이름 조회</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"Class Name: "</span> <span class="o">+</span> <span class="n">user</span><span class="o">.</span><span class="na">getName</span><span class="o">());</span>

    <span class="c1">// 3. 필드 정보 조회 (메서드 영역의 필드 참조)</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"\n[Fields]"</span><span class="o">);</span>
    <span class="nc">Field</span><span class="o">[]</span> <span class="n">fields</span> <span class="o">=</span> <span class="n">user</span><span class="o">.</span><span class="na">getDeclaredFields</span><span class="o">();</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="n">java</span><span class="o">.</span><span class="na">util</span><span class="o">.</span><span class="na">Arrays</span><span class="o">.</span><span class="na">toString</span><span class="o">(</span><span class="n">fields</span><span class="o">));</span>

    <span class="c1">// 4. 메서드 정보 조회 (메서드 영역의 메서드 참조)</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"\n[Methods]"</span><span class="o">);</span>
    <span class="nc">Method</span><span class="o">[]</span> <span class="n">methods</span> <span class="o">=</span> <span class="n">user</span><span class="o">.</span><span class="na">getDeclaredMethods</span><span class="o">();</span>
    <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="n">java</span><span class="o">.</span><span class="na">util</span><span class="o">.</span><span class="na">Arrays</span><span class="o">.</span><span class="na">toString</span><span class="o">(</span><span class="n">methods</span><span class="o">));</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Class Name: User

[Fields]
[private int User.age, private java.lang.String User.name]

[Methods]
[public void User.printInfo()]

Process finished with exit code 0
</code></pre></div></div>

<h1 id="q-permgen이-사라지고-metaspace-전환된-이유">Q. PermGen이 사라지고 Metaspace 전환된 이유</h1>

<p>JVM 힙 메모리의 일부인 <code class="language-plaintext highlighter-rouge">PermGen(Permanent Generation)</code> 영역에는 <code class="language-plaintext highlighter-rouge">.class</code> 파일을 로딩한 메타데이터와 정적 변수 등이 저장된다. 로딩된 클래스 정보는 애플리케이션이 종료될 때까지 영구적으로 남아있을 것이라는 가정 때문에 Permanent Generation으로 불린다.</p>

<p>물리적으로 PermGen은 일반적인 힙 공간인 Young Generation, Old Generation과 연속된 메모리 공간에 위치한다.</p>

<p>PermGen은 JVM 구동 시 <code class="language-plaintext highlighter-rouge">-XX:MaxPermSize</code>로 설정한 고정된 크기를 할당받는다. 이곳에는 클래스 로더가 읽어들인 바이트코드를 기반으로 생성된 클래스 이름, 접근 제어자, 필드 정보, 메서드 바이트코드, 그리고 정적 변수 등이 저장된다.</p>

<p><u> 이름이 '영구적'이라고 해서 가비지 컬렉션(GC)이 전혀 발생하지 않는 것은 아니다. </u>  과거에는 HotDeploy 배포 방식을 사용했다. 거대한 톰캣 하나(JVM 프로세스 1개)가 24시간 떠있는 상태에서 내부의 <code class="language-plaintext highlighter-rouge">ROOT.war</code> 파일만 새 버전으로 갈아끼우는 방식이다. 이때 PermGen 영역에는 기존 버전의 클래스들이 남아있는 문제가 있다. 위 배포 방식과 연결되는 문제로, 개발자가 설정만 바꿔서 스프링 컨텍스트만 리프레시하는 경우에도 PermGen 영역에 프록시 객체들이 쌓이는 문제가 있다.</p>

<p>이곳의 메모리를 정리하려면 힙 전체를 멈추는 Full GC가 수행되어야 하며, 해당 클래스를 로딩한 클래스 로더(Class Loader) 자체가 참조를 잃어야만 내부의 메타데이터가 언로드(Unload)된다. 그렇지 않으면 GC의 대상이 되지 못하고 계속 남게 되어 OOM을 유발하는 주 원인이 되었다.</p>

<p>과거 톰캣(Tomcat) 환경에서 빈번했던 PermGen 누수 상황은 Hot Deploy(재기동 없는 재배포)이다.</p>

<p>WAS(Tomcat)는 스레드 풀을 관리하며, 이 스레드들은 애플리케이션(War)이 재배포되어도 사라지지 않고 계속 살아있다. 반면 애플리케이션은 재배포될 때마다 새로운 ClassLoader를 생성한다. 이때 ThreadLocal.remove()가 호출되지 않는 실수가 발생하면, 스레드들이 기존 버전의 객체를 계속 참조하게 된다. 이로 인해 GC의 대상이 되지 않아 PermGen이 가득 차고 결국 OOM이 발생한다.</p>

<p>결국 Java 8에서 이 영역을 제거하고 Native Memory를 사용하는 Metaspace로 구조를 변경하는 계기가 되었다.</p>

<p><img src="/assets/images/jvm/heap.png" alt="JVM Heap Structure" /></p>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[JVM 클래스 로더, 메서드 영역, 힙 영역, 가비지 컬렉션까지 추상적으로 느껴지는 JVM 내부 동작을 구체적으로 풀어냅니다.]]></summary></entry><entry><title type="html">데이터베이스 인덱스 공학적 관점</title><link href="https://wlswo.me/2026/01/20/%EC%9D%B8%EB%8D%B1%EC%8A%A4/" rel="alternate" type="text/html" title="데이터베이스 인덱스 공학적 관점" /><published>2026-01-20T09:32:00+00:00</published><updated>2026-01-20T09:32:00+00:00</updated><id>https://wlswo.me/2026/01/20/%EC%9D%B8%EB%8D%B1%EC%8A%A4</id><content type="html" xml:base="https://wlswo.me/2026/01/20/%EC%9D%B8%EB%8D%B1%EC%8A%A4/"><![CDATA[<h1 id="데이터베이스-인덱싱-기술적-원리">데이터베이스 인덱싱 기술적 원리</h1>

<h2 id="서론-인덱스의-기술적-배경과-io-병목-해결">서론: 인덱스의 기술적 배경과 I/O 병목 해결</h2>

<p>데이터베이스 시스템의 발전 과정은 CPU와 저장 장치(Disk) 간의 처리 속도 차이를 해결하기 위한 과정이다. 중앙 처리 장치(CPU)는 나노초(ns) 단위로 데이터를 처리하지만, 디스크 I/O는 물리적 한계로 인해 밀리초(ms)나 마이크로초(µs) 단위의 지연 시간이 발생한다. 이 ‘I/O Gap’은 시스템 전체 성능의 주요 병목 지점이며, 인덱스(Index)는 이를 보완하는 소프트웨어적 해결책이다.</p>

<p>백엔드 개발자에게 인덱스란 단순한 속도 향상 도구를 넘어, 자료 구조를 활용해 데이터 검색 비용을 선형 시간(<strong>O(N)</strong>)에서 로그 시간(<strong>O(log N)</strong>)으로 줄이는 장치다. 인덱스는 디스크의 최소 읽기 단위인 페이지(Page) 접근을 최소화하도록 설계된 포인터 체계다. 이는 읽기 성능을 높이는 대신, 쓰기 성능 저하와 저장 공간 추가 점유라는 비용을 지불하는 트레이드오프(Trade-off) 관계에 있다.</p>

<p>이에 데이터베이스는 디스크 I/O 효율을 극대화하기 위해 B-Tree 계열의 자료 구조를 핵심적으로 사용한다.</p>

<h2 id="인덱스-자료-구조-b-tree와-btree">인덱스 자료 구조: B-Tree와 B+Tree</h2>

<p>데이터베이스 인덱스는 디스크 I/O 횟수를 줄이기 위해 트리의 높이(Height)를 낮게 유지하는 구조를 사용한다.</p>

<h3 id="b-tree-균형-유지-구조">B-Tree: 균형 유지 구조</h3>

<p>B-Tree는 모든 리프 노드(Leaf Node)가 동일한 깊이를 유지하는 다차원 트리다.</p>

<ul>
  <li><strong>특징:</strong> 최악의 상황에서도 <strong>O(log N)</strong>의 탐색 성능을 보장한다. 각 노드는 키(Key)와 실제 데이터 위치(Value/Pointer), 자식 노드 포인터를 모두 가진다.</li>
  <li><strong>구조적 효율:</strong> 분기율(Fan-out)이 높아 대용량 데이터에서도 트리의 깊이가 보통 3~4 레벨로 유지된다. 이는 단 몇 번의 디스크 접근만으로 원하는 데이터를 찾을 수 있음을 의미한다.</li>
  <li><strong>제약:</strong> 내부 노드에 데이터까지 저장하므로, 고정된 페이지 크기 내에 저장할 수 있는 키의 개수가 적다. 이는 트리의 높이를 높이는 원인이 된다.</li>
</ul>

<h3 id="btree-디스크-최적화-구조">B+Tree: 디스크 최적화 구조</h3>

<p>대부분의 현대 DBMS는 B-Tree를 개선한 B+Tree를 사용한다.</p>

<ul>
  <li><strong>데이터 저장 위치 최적화:</strong> 루트와 내부 노드에는 키와 포인터만 저장하고, 실제 데이터(또는 데이터 포인터)는 <strong>리프 노드</strong>에만 저장한다. 이로 인해 한 페이지에 더 많은 키를 담을 수 있어 분기율이 높아지고 트리의 높이는 낮아진다.</li>
  <li><strong>리프 노드 연결(Linked List):</strong> 모든 리프 노드가 연결 리스트로 이어져 있다. 범위 검색 시 트리를 다시 올라갔다 내려올 필요 없이, 시작점에서 리프 노드를 따라 순차적으로 읽기(Sequential Scan)만 하면 되므로 범위 쿼리 성능이 우수하다.</li>
</ul>

<table>
  <thead>
    <tr>
      <th>비교 항목</th>
      <th>B-Tree</th>
      <th>B+Tree</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>데이터 위치</strong></td>
      <td>모든 노드</td>
      <td>리프 노드에만 저장</td>
    </tr>
    <tr>
      <td><strong>내부 노드 구성</strong></td>
      <td>키 + 데이터 + 포인터</td>
      <td>키 + 포인터 (더 많은 키 수용)</td>
    </tr>
    <tr>
      <td><strong>트리 높이</strong></td>
      <td>상대적으로 높음</td>
      <td>매우 낮음</td>
    </tr>
    <tr>
      <td><strong>범위 검색</strong></td>
      <td>모든 노드 순회 필요</td>
      <td>리프 노드 간 순차 읽기 가능</td>
    </tr>
  </tbody>
</table>

<p>B+Tree가 인덱스의 표준으로 자리 잡았으나, 실제 엔진 레벨에서의 구현은 상이하다. MySQL과 PostgreSQL은 이 자료 구조를 각기 다른 아키텍처 방식으로 해석하여 적용하고 있다.</p>

<h2 id="dbms-아키텍처별-인덱스-구현">DBMS 아키텍처별 인덱스 구현</h2>

<h3 id="mysql-innodb-클러스터드-인덱스-아키텍처">MySQL InnoDB: 클러스터드 인덱스 아키텍처</h3>

<p>InnoDB는 데이터와 인덱스가 결합된 <strong>Index-Organized Table(IOT)</strong> 구조다.</p>

<ul>
  <li><strong>클러스터드 인덱스:</strong> 테이블당 1개(주로 PK)만 존재하며, 리프 노드에 실제 행(Row) 데이터가 직접 저장된다. PK 기반 검색과 범위 검색에 매우 빠르다.</li>
  <li><strong>세컨더리 인덱스:</strong> PK 이외의 인덱스다. 리프 노드에 실제 데이터 주소 대신 <strong>PK 값</strong>을 저장한다.</li>
  <li><strong>이중 조회(Double Look-up):</strong> 세컨더리 인덱스로 검색 시, PK를 먼저 찾고 다시 PK 인덱스를 탐색하여 데이터를 가져온다.</li>
</ul>

<h3 id="postgresql-힙heap-테이블-아키텍처">PostgreSQL: 힙(Heap) 테이블 아키텍처</h3>

<p>데이터 파일과 인덱스 파일을 독립적으로 관리한다.</p>

<ul>
  <li><strong>TID(Tuple Identifier):</strong> 데이터는 정렬되지 않은 힙 영역에 저장되며, 각 행은 물리적 위치 정보인 TID를 가진다.</li>
  <li><strong>인덱스 구조:</strong> 모든 인덱스는 리프 노드에 TID를 저장한다.</li>
  <li><strong>데이터 접근:</strong> 인덱스에서 TID를 얻으면 힙 페이지에 직접 접근한다. 이중 조회가 없어 단건 조회에 효율적일 수 있으나, 데이터 업데이트 시 위치가 변하면 관련 인덱스를 모두 갱신해야 하는 부담이 있다.</li>
</ul>

<p>각 DBMS의 아키텍처 특성을 파악했다면, 다음으로 이를 활용해 쿼리 성능을 극대화할 수 있는 인덱스 설계 전략을 수립해야 한다.</p>

<h2 id="인덱스-설계-및-최적화-전략">인덱스 설계 및 최적화 전략</h2>

<h3 id="복합-인덱스와-최좌측-접두사leftmost-prefix">복합 인덱스와 최좌측 접두사(Leftmost Prefix)</h3>

<p>(A, B, C) 순서의 인덱스는 A를 기준으로 먼저 정렬된 후 B, C 순으로 정렬된다. 따라서 WHERE 절에 A 조건이 없으면 인덱스를 정상적으로 활용할 수 없다.</p>

<h3 id="커버링-인덱스covering-index">커버링 인덱스(Covering Index)</h3>

<p>쿼리에 필요한 모든 컬럼이 인덱스 자체에 포함된 경우다. 실제 테이블 데이터를 읽으러 갈 필요가 없으므로 I/O 비용이 대폭 감소한다.</p>

<h3 id="카디널리티와-선택도">카디널리티와 선택도</h3>

<ul>
  <li><strong>선택도:</strong> 전체 중 특정 조건이 걸러내는 비율(10~15% 이하 권장).</li>
  <li><strong>카디널리티:</strong> 고유한 값의 개수. 중복도가 낮은(카디널리티가 높은) 컬럼일수록 인덱스 효율이 극대화된다.</li>
</ul>

<p>인덱스는 읽기 성능을 보장하는 대신, 데이터 변경 시에는 필연적으로 쓰기 오버헤드가 발생한다. 이러한 트레이드오프를 관리하기 위해 내부적으로 다양한 쓰기 최적화 메커니즘이 동작한다.</p>

<h2 id="쓰기-부하와-내부-메커니즘">쓰기 부하와 내부 메커니즘</h2>

<h3 id="페이지-분할page-splitting">페이지 분할(Page Splitting)</h3>

<p>무작위 UUID(v4)처럼 정렬되지 않은 값을 PK로 삽입하면, B+Tree 중간에 데이터를 끼워 넣어야 한다. 이때 페이지 공간이 부족하면 페이지를 나누는 ‘페이지 분할’이 발생하며, 이는 디스크 쓰기 부하와 인덱스 파편화를 유발한다. (UUID v7 등 정렬 가능한 키 권장)</p>

<h3 id="최적화-기술-change-buffer--hot">최적화 기술 (Change Buffer &amp; HOT)</h3>

<ul>
  <li><strong>Change Buffer (MySQL):</strong> 쓰기 작업 시 인덱스 페이지를 즉시 업데이트하지 않고 메모리에 임시 기록하여 디스크 랜덤 쓰기를 줄인다.</li>
  <li><strong>HOT (PostgreSQL):</strong> 인덱스 컬럼이 수정되지 않는 업데이트의 경우, 인덱스 변경 없이 힙 페이지 내에서만 데이터를 관리하여 쓰기 증폭을 억제한다.</li>
</ul>

<p>설계한 인덱스와 최적화 기법이 의도대로 동작하는지는 데이터베이스의 실행 계획(Explain)을 분석하여 객관적으로 검증할 수 있다.</p>

<h2 id="실행-계획explain-분석-지표">실행 계획(Explain) 분석 지표</h2>

<h3 id="mysql-지표">MySQL 지표</h3>

<ul>
  <li><strong>type:</strong> <code class="language-plaintext highlighter-rouge">const</code>, <code class="language-plaintext highlighter-rouge">ref</code>, <code class="language-plaintext highlighter-rouge">range</code> 순으로 효율적이며, <code class="language-plaintext highlighter-rouge">ALL</code>(Full Scan)은 개선이 필요하다.</li>
  <li><strong>Extra:</strong> <code class="language-plaintext highlighter-rouge">Using filesort</code>나 <code class="language-plaintext highlighter-rouge">Using temporary</code>는 메모리/디스크 추가 부하를 의미하므로 주의해야 한다.</li>
</ul>

<h3 id="postgresql-지표">PostgreSQL 지표</h3>

<ul>
  <li><strong>Buffers:</strong> <code class="language-plaintext highlighter-rouge">shared hit</code>을 통해 메모리 캐시 활용도를, <code class="language-plaintext highlighter-rouge">read</code>를 통해 물리 디스크 접근량을 파악한다.</li>
  <li><strong>Scan Types:</strong> <code class="language-plaintext highlighter-rouge">Index Scan</code>, <code class="language-plaintext highlighter-rouge">Bitmap Index Scan</code> 등의 방식을 확인한다.</li>
</ul>

<h2 id="결론">결론</h2>

<p>인덱스는 데이터베이스의 I/O 비용을 관리하기 위한 핵심적인 공학적 도구다. B+Tree 구조를 바탕으로 각 DBMS가 채택한 아키텍처(Clustered vs Heap)를 이해하면 더욱 정교한 성능 튜닝이 가능하다. 백엔드 개발자는 데이터의 분포와 쿼리 패턴을 분석하여 효율적인 인덱스를 설계하고, 정기적으로 실행 계획을 점검하여 시스템 성능을 유지해야 한다.</p>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[B-Tree, B+Tree 자료구조를 기반으로 데이터베이스 인덱스가 디스크 I/O를 줄이는 원리를 공학적 관점에서 설명합니다.]]></summary></entry><entry><title type="html">마이그레이션은 롤백까지가 설계다</title><link href="https://wlswo.me/2025/12/02/migration-rollback/" rel="alternate" type="text/html" title="마이그레이션은 롤백까지가 설계다" /><published>2025-12-02T13:57:00+00:00</published><updated>2025-12-02T13:57:00+00:00</updated><id>https://wlswo.me/2025/12/02/migration-rollback</id><content type="html" xml:base="https://wlswo.me/2025/12/02/migration-rollback/"><![CDATA[<h3 id="순간에-바뀌지-않는다">순간에 바뀌지 않는다</h3>

<p>무중단 배포에서 코드가 한 번에 교체되는 일은 없다. 인스턴스가 하나씩 교체되는 동안 <strong>구 버전과 신 버전이 같은 DB를 함께 쓴다.</strong></p>

<p>롤백하면 신 버전이 만든 데이터 위에서 구 버전이 다시 돈다.</p>

<p>그래서 스키마 변경은 “바꾼 뒤에 잘 도는가”만으로 부족하다. <strong>바꾸는 도중과 되돌린 뒤에도 도는가</strong>를 함께 봐야 한다.</p>

<h3 id="한-번에-하면-안-되는-것">한 번에 하면 안 되는 것</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">users</span> <span class="k">RENAME</span> <span class="k">COLUMN</span> <span class="n">name</span> <span class="k">TO</span> <span class="n">full_name</span><span class="p">;</span>
</code></pre></div></div>

<p>배포 순간 구 버전 인스턴스들이 전부 <code class="language-plaintext highlighter-rouge">name</code>을 찾다 실패한다. 롤백해도 이번엔 컬럼이 없어서 계속 실패한다. <strong>되돌릴 수 없는 상태</strong>에 들어간다.</p>

<p>컬럼 삭제, 이름 변경, <code class="language-plaintext highlighter-rouge">NOT NULL</code> 추가, 타입 축소가 여기 해당한다.</p>

<h3 id="expand-contract">expand-contract</h3>

<p>파괴적 변경을 여러 단계의 안전한 변경으로 쪼갠다.</p>

<p><strong>1. 확장 (expand)</strong> — 새 컬럼을 추가한다. 기존 컬럼은 그대로 둔다</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">users</span> <span class="k">ADD</span> <span class="k">COLUMN</span> <span class="n">full_name</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">255</span><span class="p">)</span> <span class="k">NULL</span><span class="p">;</span>
</code></pre></div></div>

<p>nullable로 추가하는 게 핵심이다. 구 버전은 이 컬럼을 모르지만 아무 영향이 없다.</p>

<p><strong>2. 양쪽 쓰기</strong> — 새 코드가 두 컬럼 모두에 쓴다. 읽기는 아직 옛 컬럼에서</p>

<p>이 상태로 배포한다. 롤백해도 구 버전은 옛 컬럼을 그대로 쓰므로 안전하다.</p>

<p><strong>3. 백필</strong> — 기존 데이터를 새 컬럼으로 옮긴다</p>

<p>한 번에 <code class="language-plaintext highlighter-rouge">UPDATE</code>하면 테이블 전체가 잠긴다. <strong>배치로 나눠</strong> 돌린다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">UPDATE</span> <span class="n">users</span> <span class="k">SET</span> <span class="n">full_name</span> <span class="o">=</span> <span class="n">name</span>
<span class="k">WHERE</span> <span class="n">full_name</span> <span class="k">IS</span> <span class="k">NULL</span> <span class="k">LIMIT</span> <span class="mi">1000</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>4. 읽기 전환</strong> — 새 컬럼에서 읽도록 배포한다. 쓰기는 아직 양쪽에</p>

<p><strong>5. 축소 (contract)</strong> — 옛 컬럼 쓰기를 멈추고, 충분히 지난 뒤 컬럼을 삭제한다</p>

<p>각 단계는 <strong>직전 상태로 되돌릴 수 있다.</strong> 이게 이 방식의 전부다.</p>

<h3 id="그-외-주의할-것">그 외 주의할 것</h3>

<p><strong>인덱스 추가.</strong> 큰 테이블에 인덱스를 걸면 그동안 쓰기가 막힐 수 있다. PostgreSQL은 <code class="language-plaintext highlighter-rouge">CREATE INDEX CONCURRENTLY</code>, MySQL 8은 온라인 DDL을 쓴다.</p>

<p><strong>기본값 있는 컬럼 추가.</strong> 과거 버전에서는 테이블 전체를 다시 썼다. 최신 버전은 메타데이터만 바꾸지만, 쓰는 DB의 동작을 확인해야 한다.</p>

<p><strong>마이그레이션은 배포와 분리한다.</strong> 스키마 변경을 애플리케이션 기동 시점에 실행하면, 인스턴스 여러 개가 동시에 시작할 때 충돌한다.</p>

<h3 id="정리">정리</h3>

<ul>
  <li>파괴적 변경은 <strong>여러 배포에 걸쳐</strong> 나눈다</li>
  <li>각 단계는 <strong>혼자서도 롤백 가능</strong>해야 한다</li>
  <li>“배포 후”가 아니라 <strong>“배포 중”</strong>을 기준으로 생각한다</li>
  <li>시간은 더 걸리지만, 되돌릴 수 없는 상태를 만드는 것보다 훨씬 싸다</li>
</ul>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[배포 중에는 구 버전과 신 버전이 함께 돈다. 그 사이를 견디는 스키마 변경법.]]></summary></entry><entry><title type="html">스레드 풀과 비동기는 대체재가 아니다</title><link href="https://wlswo.me/2025/11/12/threadpool-vs-async/" rel="alternate" type="text/html" title="스레드 풀과 비동기는 대체재가 아니다" /><published>2025-11-12T23:24:00+00:00</published><updated>2025-11-12T23:24:00+00:00</updated><id>https://wlswo.me/2025/11/12/threadpool-vs-async</id><content type="html" xml:base="https://wlswo.me/2025/11/12/threadpool-vs-async/"><![CDATA[<h3 id="비동기가-빠른-이유">비동기가 빠른 이유</h3>

<p>비동기가 “더 빠른 실행 방식”이라고 생각하면 오해가 시작된다. CPU 연산 속도는 달라지지 않는다.</p>

<p>이득은 한 곳에서 온다. <strong>기다리는 동안 스레드를 붙잡지 않는다는 것.</strong></p>

<p>동기 방식에서 DB 쿼리를 호출하면, 응답이 올 때까지 그 스레드는 아무것도 못 하고 묶여 있다. 쿼리가 50ms 걸리면 50ms 동안 스레드 하나가 놀면서 점유된다. 스레드는 스택 메모리(보통 1MB)를 쓰므로 수천 개를 띄울 수 없고, 늘릴수록 컨텍스트 스위칭 비용이 커진다.</p>

<p>비동기는 요청을 보내고 스레드를 반납한다. 응답이 도착하면 콜백이 실행된다. 스레드 몇 개로 수만 개의 대기를 감당할 수 있다.</p>

<h3 id="이득이-사라지는-조건">이득이 사라지는 조건</h3>

<p>여기에 함정이 있다. <strong>어딘가에서 블로킹이 일어나면 그 순간 이득이 사라진다.</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Mono</span><span class="o">.</span><span class="na">fromCallable</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="n">jdbcTemplate</span><span class="o">.</span><span class="na">query</span><span class="o">(...))</span>  <span class="c1">// JDBC는 블로킹이다</span>
</code></pre></div></div>

<p>리액티브 프레임워크를 쓰고 있어도 JDBC 드라이버는 응답을 기다리며 스레드를 붙잡는다. 이벤트 루프 스레드가 막히면 <strong>그 스레드가 처리하던 다른 모든 요청이 함께 멈춘다.</strong> 스레드 수가 적으니 피해는 오히려 더 크다.</p>

<p>별도 스케줄러로 넘겨 격리할 수는 있지만, 그러면 결국 그 뒤에 스레드 풀이 있는 것이다. 비동기의 형태만 남고 이득은 없다.</p>

<p><strong>리액티브의 이득은 스택 전체가 논블로킹일 때만 온전하다.</strong> 웹 서버, DB 드라이버(R2DBC 등), HTTP 클라이언트, 캐시 클라이언트가 모두 논블로킹이어야 한다.</p>

<h3 id="대가">대가</h3>

<ul>
  <li><strong>디버깅이 어렵다.</strong> 스택 트레이스가 끊기고 흐름을 눈으로 좇기 어렵다</li>
  <li><strong>생태계 제약.</strong> 쓰려는 라이브러리가 논블로킹을 지원하지 않으면 거기서 막힌다</li>
  <li><strong>학습 비용.</strong> 연산자 조합에 익숙해지는 데 시간이 든다</li>
</ul>

<h3 id="가상-스레드가-바꾸는-지점">가상 스레드가 바꾸는 지점</h3>

<p>Java 21의 가상 스레드는 이 선택 자체를 흔든다.</p>

<p>블로킹 호출을 만나면 <strong>JVM이 그 자리에서 캐리어 스레드를 반납한다.</strong> 코드는 동기식으로 쓰지만 스레드는 묶이지 않는다. 비동기의 이득을 동기 코드의 가독성으로 얻는 셈이다.</p>

<p>기존 코드를 거의 그대로 두고 스레드 팩토리만 바꾸면 되는 경우가 많다. 다만 <code class="language-plaintext highlighter-rouge">synchronized</code> 블록 안의 블로킹은 캐리어 스레드를 고정(pinning)시키므로 <code class="language-plaintext highlighter-rouge">ReentrantLock</code>으로 바꿔야 한다.</p>

<h3 id="어떻게-고를까">어떻게 고를까</h3>

<ul>
  <li><strong>CPU 위주 작업</strong> → 스레드 풀. 코어 수만큼 두면 된다. 비동기로 얻을 게 없다</li>
  <li><strong>I/O 대기가 많고 Java 21 이상</strong> → <strong>가상 스레드를 먼저 검토한다.</strong> 얻는 것에 비해 바꿀 게 가장 적다</li>
  <li><strong>이미 리액티브 스택이거나 스트리밍·백프레셔가 필요</strong> → 리액티브</li>
</ul>

<h3 id="정리">정리</h3>

<p>비동기는 <strong>대기 시간을 회수하는 기법</strong>이지 실행을 빠르게 하는 기법이 아니다. 어딘가에 블로킹이 남아 있다면 형태만 비동기일 뿐이다.</p>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[비동기의 이득이 어디서 오는지, 그리고 그 이득이 사라지는 조건.]]></summary></entry><entry><title type="html">캐시 무효화 전략 세 가지</title><link href="https://wlswo.me/2025/10/08/cache-invalidation/" rel="alternate" type="text/html" title="캐시 무효화 전략 세 가지" /><published>2025-10-08T08:36:00+00:00</published><updated>2025-10-08T08:36:00+00:00</updated><id>https://wlswo.me/2025/10/08/cache-invalidation</id><content type="html" xml:base="https://wlswo.me/2025/10/08/cache-invalidation/"><![CDATA[<h3 id="ttl--시간에-맡긴다">TTL — 시간에 맡긴다</h3>

<p>가장 단순하다. 저장할 때 만료를 걸고 지나면 사라진다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SET product:42 {...} EX 300
</code></pre></div></div>

<ul>
  <li><strong>보장</strong> — 낡은 데이터는 최대 TTL만큼만 유지된다. 무효화 로직이 필요 없고, 코드가 늘지 않는다</li>
  <li><strong>포기</strong> — 실시간성. 값이 바뀌어도 TTL이 남아 있으면 낡은 값이 나간다</li>
</ul>

<p>바뀌는 빈도가 낮거나 잠깐 낡아도 되는 데이터에 잘 맞는다. <strong>가장 먼저 시도할 방법</strong>이고, 이걸로 충분한 경우가 많다.</p>

<h3 id="명시적-삭제--바뀔-때-지운다">명시적 삭제 — 바뀔 때 지운다</h3>

<p>데이터를 갱신하면서 캐시도 함께 지운다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">productRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="n">product</span><span class="o">);</span>
<span class="n">cache</span><span class="o">.</span><span class="na">delete</span><span class="o">(</span><span class="s">"product:"</span> <span class="o">+</span> <span class="n">product</span><span class="o">.</span><span class="na">getId</span><span class="o">());</span>
</code></pre></div></div>

<ul>
  <li><strong>보장</strong> — 다음 조회부터 새 값이 나간다. 실시간성이 좋다</li>
  <li><strong>포기</strong> — 단순함. 지워야 할 곳을 <strong>빠짐없이 찾아야 한다</strong></li>
</ul>

<p>문제는 대개 파생 캐시에서 생긴다. 상품 하나를 고쳤을 때 <code class="language-plaintext highlighter-rouge">product:42</code>만이 아니라 목록 캐시, 카테고리 캐시, 검색 결과 캐시가 모두 낡는다. 새 화면을 추가하면서 캐시를 하나 더 만들었는데 무효화를 빠뜨리는 일이 흔하다.</p>

<p>DB 갱신과 캐시 삭제 사이에 장애가 나면 낡은 값이 남는 것도 감안해야 한다. 그래서 <strong>명시적 삭제를 쓰더라도 TTL을 함께 걸어두는 편</strong>이 안전하다.</p>

<h3 id="버전-키--지우지-않고-무효화한다">버전 키 — 지우지 않고 무효화한다</h3>

<p>키에 버전을 넣고, 무효화가 필요하면 버전을 올린다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>키 :  product:42:v7
갱신:  version:product:42 → 8
조회:  product:42:v8   (없음 → 새로 채운다)
</code></pre></div></div>

<ul>
  <li><strong>보장</strong> — 지울 대상을 찾을 필요가 없다. 버전 하나만 올리면 그 아래 파생 키가 전부 함께 무효화된다</li>
  <li><strong>포기</strong> — 메모리. 낡은 키가 즉시 사라지지 않고 TTL이나 LRU로 정리될 때까지 남는다</li>
</ul>

<p>카테고리 버전을 올려 그 카테고리의 모든 캐시를 한 번에 무효화하는 식으로, <strong>묶어서 무효화할 때</strong> 특히 강하다.</p>

<h3 id="스탬피드">스탬피드</h3>

<p>셋 중 무엇을 쓰든 만나는 문제다. 인기 있는 키가 만료되는 순간 요청 수천 개가 동시에 빈 캐시를 보고 <strong>한꺼번에 DB로 몰린다.</strong></p>

<ul>
  <li><strong>잠금</strong> — 하나만 갱신하게 하고 나머지는 기다리거나 낡은 값을 받는다</li>
  <li><strong>만료 분산</strong> — TTL에 약간의 무작위를 더해 동시에 만료되지 않게 한다</li>
  <li><strong>조기 갱신</strong> — 만료 직전에 미리 비동기로 채운다</li>
</ul>

<h3 id="정리">정리</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>실시간성</th>
      <th>구현 난이도</th>
      <th>메모리</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>TTL</td>
      <td>낮음</td>
      <td>낮음</td>
      <td>좋음</td>
    </tr>
    <tr>
      <td>명시적 삭제</td>
      <td>높음</td>
      <td>높음</td>
      <td>좋음</td>
    </tr>
    <tr>
      <td>버전 키</td>
      <td>높음</td>
      <td>중간</td>
      <td>나쁨</td>
    </tr>
  </tbody>
</table>

<p><strong>TTL로 시작해서 부족한 지점만 다른 전략으로 보강하는 순서</strong>를 권한다. 처음부터 정교하게 만들면 무효화 누락을 찾느라 시간을 더 쓰게 된다.</p>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[TTL, 명시적 삭제, 버전 키. 각각이 무엇을 보장하고 무엇을 포기하는지.]]></summary></entry><entry><title type="html">로그는 어디까지 남겨야 하나</title><link href="https://wlswo.me/2025/09/11/logging-policy/" rel="alternate" type="text/html" title="로그는 어디까지 남겨야 하나" /><published>2025-09-11T01:15:00+00:00</published><updated>2025-09-11T01:15:00+00:00</updated><id>https://wlswo.me/2025/09/11/logging-policy</id><content type="html" xml:base="https://wlswo.me/2025/09/11/logging-policy/"><![CDATA[<h3 id="기준은-하나다">기준은 하나다</h3>

<p>로그의 목적은 <strong>일이 벌어진 뒤에 무슨 일이었는지 재구성하는 것</strong>이다. 이 기준으로 보면 판단이 단순해진다.</p>

<p>“이 줄이 없으면 장애 상황을 설명할 수 있는가?” 설명할 수 있으면 빼도 된다.</p>

<h3 id="레벨을-기준으로-정한다">레벨을 기준으로 정한다</h3>

<p>레벨을 감으로 붙이면 나중에 아무 의미가 없어진다. 기준을 정해둔다.</p>

<ul>
  <li><strong>ERROR</strong> — <strong>사람이 개입해야 하는 것.</strong> 알림이 울려야 하는 수준</li>
  <li><strong>WARN</strong> — 지금은 넘어갔지만 반복되면 문제. 재시도 성공, 폴백 동작</li>
  <li><strong>INFO</strong> — 시스템의 상태 변화. 기동, 종료, 설정 로드, 주요 비즈니스 이벤트</li>
  <li><strong>DEBUG</strong> — 개발·조사용. 운영에서는 평소에 끈다</li>
</ul>

<p>가장 흔한 실수는 <strong>잡아서 처리한 예외를 ERROR로 남기는 것</strong>이다. 정상 흐름으로 복구했다면 ERROR가 아니다. ERROR가 하루 수천 건 쌓이면 아무도 보지 않게 되고, 진짜 오류가 묻힌다.</p>

<h3 id="구조화-로그와-상관관계-id">구조화 로그와 상관관계 ID</h3>

<p>문자열을 이어붙인 로그는 사람은 읽어도 검색이 어렵다. JSON으로 남기면 필드로 조회할 수 있다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"level"</span><span class="p">:</span><span class="s2">"ERROR"</span><span class="p">,</span><span class="nl">"trace_id"</span><span class="p">:</span><span class="s2">"a1b2c3"</span><span class="p">,</span><span class="nl">"user_id"</span><span class="p">:</span><span class="mi">1042</span><span class="p">,</span><span class="w">
 </span><span class="nl">"event"</span><span class="p">:</span><span class="s2">"payment_failed"</span><span class="p">,</span><span class="nl">"reason"</span><span class="p">:</span><span class="s2">"insufficient_balance"</span><span class="p">,</span><span class="nl">"amount"</span><span class="p">:</span><span class="mi">15000</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>특히 <strong>요청마다 고유 ID를 부여하고 모든 로그에 싣는 것</strong>이 중요하다. 서비스를 넘나드는 요청도 ID 하나로 전체 흐름을 이어 볼 수 있다. MDC나 트레이싱 컨텍스트로 자동 전파되게 해둔다.</p>

<h3 id="남기면-안-되는-것">남기면 안 되는 것</h3>

<p>로그는 여러 곳에 복제되고 오래 보관되며 접근 권한이 느슨한 편이다. <strong>한 번 찍히면 회수하기 어렵다.</strong></p>

<ul>
  <li>비밀번호, 토큰, API 키, 인증 헤더</li>
  <li>주민등록번호, 카드번호, 전화번호, 주소</li>
  <li>요청/응답 본문 전체 (위 항목이 섞여 들어온다)</li>
</ul>

<p>식별이 필요하면 원본 대신 <strong>ID나 마스킹된 값</strong>을 남긴다. 직렬화 시 특정 필드를 자동으로 가리도록 설정해두는 편이 사람이 매번 조심하는 것보다 안전하다.</p>

<h3 id="비용">비용</h3>

<p>로그는 공짜가 아니다. 수집·저장·검색 모두 비용이고, 디스크 I/O는 애플리케이션 성능에도 영향을 준다.</p>

<ul>
  <li>보존 기간을 레벨별로 다르게 둔다 (ERROR는 길게, INFO는 짧게)</li>
  <li>초당 수천 건씩 쏟아지는 로그는 샘플링한다</li>
  <li>반복되는 같은 오류는 묶어서 집계로 남긴다</li>
</ul>

<h3 id="정리">정리</h3>

<p>“많이 남기기”가 아니라 <strong>“재구성할 수 있게 남기기”</strong>가 목표다. 레벨 기준을 팀에서 합의하고, 개인정보는 애초에 들어갈 수 없게 막아두는 것부터 시작한다.</p>]]></content><author><name>JaeJin</name></author><summary type="html"><![CDATA[사후에 재구성할 수 있는 만큼. 레벨 기준과 남기지 말아야 할 것을 정리한다.]]></summary></entry></feed>