<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>🐍</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Sun, 24 Aug 2025 10:25:57 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>🐍</title>
            <url>https://velog.velcdn.com/images/frog_slayer/profile/0cf2279e-1069-4034-ba34-e8af8af5f740/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 🐍. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/frog_slayer" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[PJT] CodeSwamp (2) 서비스 분리 및 프로젝트 중단]]></title>
            <link>https://velog.io/@frog_slayer/Code-Swamp-2</link>
            <guid>https://velog.io/@frog_slayer/Code-Swamp-2</guid>
            <pubDate>Sun, 24 Aug 2025 10:25:57 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/frog_slayer/post/e942e16d-326c-4bfa-853d-386ca257c633/image.png" alt=""></p>
<h1 id="1-서비스-분리">1. 서비스 분리</h1>
<h2 id="1-공통-모듈-추출">(1) 공통 모듈 추출</h2>
<p>서비스 분리를 결정하고 난 후, 우선은 기존의 모놀리식 서버를 도메인 별로 복제하고 각각을 점진적으로 변경해 나가려고 했는데, 이 과정에서 <strong>인증</strong>, <strong>이벤트 처리</strong> 등 동일한 기능과 구조가 여러 서비스에서 반복되는 문제가 발생했다.</p>
<p>이러한 공통 기능들을 외부로 분리하여 모듈화했고, 나아가 아키텍처의 통일성을 유지하기 위해 <strong>기본 개발 구조와 인터페이스, 기본 구현체를 프레임워크 형태로 추출</strong>하여 관리하게 됐다.</p>
<blockquote>
<p>서비스 간 코드 중복이 줄기는 했지만 외부 모듈 의존성이 커졌고, 모듈 내부의 세부 로직을 파악하기 어려워졌다. 그 결과 학습 장벽이 높아졌고, 프로젝트 진행 도중 신규 팀원을 영입을 고려했다가 보류하게 되었다.</p>
</blockquote>
<h2 id="2-인증-방식의-선택-참고-문서">(2) 인증 방식의 선택 (<a href="https://github.com/Frog-Slayer/code-swamp-backend/wiki/%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%9D%B8%EC%A6%9D-%EB%B0%8F-%EC%8B%A0%EA%B7%9C-%ED%9A%8C%EC%9B%90-%EA%B0%80%EC%9E%85">참고 문서</a>)</h2>
<p>서비스를 분리하면서 가장 먼저 마주하게 된 고민은 <strong>사용자 인증 처리</strong>를 어떻게 할지에 대한 것이었다. 고려했던 방식에는 크게 두 가지가 있었다.</p>
<table>
<thead>
<tr>
<th></th>
<th><strong>중앙집중형</strong></th>
<th><strong>분산형</strong></th>
</tr>
</thead>
<tbody><tr>
<td>설명</td>
<td>게이트웨이에서 인증을 집중 처리하고, 이후 서비스에 요청과 함께 인증 정보를 전달</td>
<td>각 서비스에서 독립적으로 토큰을 검증</td>
</tr>
<tr>
<td>장점</td>
<td>인증 로직이 한 곳에 모여 있어 관리가 쉽고, 일관된 보안 정책 적용이 가능</td>
<td>인증 부하 분산. 한 서비스에 오류가 발생해도 다른 서비스로 전파되지 않음</td>
</tr>
<tr>
<td>단점</td>
<td>게이트웨이에 부하가 집중되고, SPOF로 작용</td>
<td>각 서비스 별 인증 로직 중복이 발생. 일관성 있는 보안 정책 적용이 어려움</td>
</tr>
</tbody></table>
<p>결제나 민감한 개인 정보를 다루지 않는 프로젝트의 특성상 높은 수준의 보안 정책이 필수적이지는 않을 것이라 판단했기에, 게이트웨이 단에 강력한 보안 체계를 두는 대신 각 서비스에서 독립적으로 인증을 처리하는 <strong>분산형</strong>을 선택했다.</p>
<p>다만 서비스별로 인증 로직이 중복되고, 일관성 있는 보안 정책 변경이 어렵다는 단점을 보완하기 위해, 인증 로직을 별도의 모듈로 분리, 각 서비스에 공통적으로 포함해 사용하는 방식으로 변경했다.</p>
<h3 id="jwt-신뢰">JWT 신뢰</h3>
<p>이전에 진행했던 프로젝트들에서는 JWT를 완전히 신뢰하기보다는, JWT에서 사용자 정보를 추출한 뒤 DB 조회를 통한 권한 확인 과정을 거쳤다.</p>
<p>이 방식을 유지하는 경우 다음과 같은 단점들이 있었다.</p>
<blockquote>
<ol>
<li>JWT의 장점인 무상태성을 살리지 못함.</li>
<li>. 각 서비스에서 인증 서버에 권한 확인 요청해야하므로<ul>
<li>인증 서버가 SPOF로 작용</li>
<li>불필요한 네트워크 I/O도 발생</li>
</ul>
</li>
</ol>
</blockquote>
<p>따라서 이번 프로젝트에서 인증 서버는 토큰 발급 및 갱신 역할만을 담당하게 하고, JWT를 신뢰하고 토큰 내에 사용자 ID와 권한 정보 등을 포함시킴으로써 개별 서비스에서 독립적으로 인증을 수행하도록 했다.</p>
<h3 id="게이트웨이-선택">게이트웨이 선택</h3>
<p>분리된 서비스들로 API 요청을 보내기 위해서는 Spring Cloud Gateway 등을 사용하기보다 간단하게 Nginx의 리버스 프록시 기능을 사용하는 편을 선택했다.</p>
<ul>
<li>각 서비스가 단일 인스턴스로 동작하며,</li>
<li>고급 라우팅 기능이 불필요하고,</li>
<li>각 서비스에서 개별 인증을 수행하므로 중앙 집중형 필터 체인 기능을 사용하지 않으며,</li>
<li>Spring Cloud Gateway 사용 경험 부재로 인한 학습 부담이 있음.</li>
</ul>
<h2 id="3-서비스-간-커뮤니케이션">(3) 서비스 간 커뮤니케이션</h2>
<p>서비스 간 커뮤니케이션을 위한 도구로는 다음의 두 가지를 사용했다..</p>
<ol>
<li>gRPC: 즉시 응답이 필요한 경우</li>
<li>카프카: 부가적인 작업 및 후속 처리. 즉시 응답이 필요하지는 않은 경우</li>
</ol>
<p>회원가입 시 인증 담당 객체를 만들고 유저 리소스를 만드는 작업이나, 각 포스트를 불러올 때 사용자 프로필을 불러오는 등, 다른 서비스로부터 즉시 데이터를 받아와야하는 경우에는 gRPC를 사용했고, 이외의 경우 카프카를 이용했다.</p>
<blockquote>
<p><strong>Q. 왜 REST API 대신 gRPC?</strong> </p>
<p>처음에는 서비스 간 커뮤니케이션을 위해서 REST API 사용을 검토했으나, 다음의 이유로 REST API 대신 gRPC를 사용했다.</p>
<ol>
<li>모놀리식 프로젝트를 분리하는 과정이므로, 이미 각 서비스가 공통 스펙을 공유함</li>
<li>REST API를 사용하는 경우 각 서비스마다 DTO를 정의하고 관리하는 데 일관성 문제가 발생할 수 있으나, gRPC를 사용하면 하나의 모듈로 중앙에서 정의할 수 있음</li>
</ol>
</blockquote>
<h3 id="보상-트랜잭션">보상 트랜잭션</h3>
<p>대부분의 업데이트는 하나의 서비스 내에서 이루어졌기에 분산 트랜잭션을 크게 신경 쓸 필요가 없었지만, 유일하게 회원가입의 경우 두 개의 서비스를 거쳐야만 했다.</p>
<p>프로젝트에서는 사용자 정보를 <strong>인증 주체로서의 사용자</strong>(<code>auth</code>)와 <strong>사용자 리소스</strong>(<code>user</code>)로 구분하고 있으며, 회원가입은 인증 주체가 아니라 사용자 리소스를 생성하는 과정으로 판단하여, 회원가입 API 엔드포인트를 유저 서비스에 두었다.</p>
<p>이 때 회원가입 처리 흐름은 다음과 같은데,</p>
<ol>
<li>유저 서비스로 회원가입 요청</li>
<li><code>auth</code> : 회원가입용 임시토큰 검증 및 사용자 ID 및 인증 객체 생성</li>
<li><code>user</code> : 인증 객체와 동일한 ID로 사용자 리소스 생성</li>
</ol>
<p>이때 3번에 실패하는 경우, <code>auth</code> 서비스에는 인증 객체가 남아있고, <code>user</code> 서비스에는 사용자 리소스가 생성되지 않아 부정합이 발생한다. 이를 처리하기 위해 사용자 리소스 생성에 실패하는 경우, 이벤트를 발행해 <code>auth</code> 서비스를 통해 인증 객체까지 삭제하도록 했다.</p>
<h3 id="아웃박스-패턴-적용">아웃박스 패턴 적용</h3>
<p>초기 프로젝트에서는 주 관심사와 부작용을 디커플링하거나, 이종 데이터소스를 저장하는 용도로만 이벤트를 사용했다. 이때는 동기적으로, <code>@TransactionalEventListener</code>를 사용해 트랜잭션 커밋 이후 이벤트를 발행하도록 했기에, 트랜잭션과 이벤트 발행 간의 일관성을 확보할 수 있었다.</p>
<p>하지만 서비스가 분리되고 카프카를 도입하면서 트랜잭션과 이벤트 발행 간의 일관성을 유지하기가 어려워졌다.  이를 해결하기 위해 고려했던 방식에는 다음의 세 가지가 있었는데,</p>
<ol>
<li><code>@Transactional</code> 메서드 내에서 이벤트 발행 사용<ul>
<li>트랜잭션 커밋에 실패하더라도 이벤트 발행이 될 수 있음</li>
</ul>
</li>
<li><code>@Transactional</code> 메서드를 따로 두고, 커밋 후 이벤트를 발행<ul>
<li>트랜잭션 커밋에 성공하더라도 이벤트 발행에는 성공할 수 있음</li>
</ul>
</li>
<li>2PC-like하게 이전 상태를 저장해두고, 커밋 후 이벤트 발행. 실패 시 이전 상태로 롤백<ul>
<li>어떤 종류의 작업인지에 따라 처리 방식이 달라지고 유지 보수성이 떨어짐</li>
</ul>
</li>
</ol>
<p>결론적으로 세 방식 모두 트랜잭션과 이벤트 발행을 완전히 원자적으로 묶는 것이 어렵다 판단되어, 아웃박스 패턴을 도입해 도메인 객체에서 발행한 이벤트를 DB에 JSON 형식으로 저장, 주기적인 폴링을 통해 미발행 이벤트를 발행하는 방식을 선택했다.</p>
<p>이 기능 또한 여러 서비스에서 반복적으로 나타나기 때문에, 별도의 모듈로 묶어 인터페이스와 기본 구현체를 제공하도록 했다. (<a href="https://github.com/Frog-Slayer/code-swamp-backend/wiki/%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A9%94%EC%8B%9C%EC%A7%95-%EC%8B%9C%EC%8A%A4%ED%85%9C">이벤트 메시징 시스템</a>)</p>
<blockquote>
<p><strong>남은 문제 (1)</strong> 5초 주기로 100개의 이벤트를 폴링하는 방식이기에, 이벤트 체인이 길어질 경우 눈에 띄는 지연이 발생할 수 있다. Debezium 같은 CDC를 써야하나 고민을 하기는 했지만, 전환 비용이 상당할 것이라 예상되어 보류.</p>
</blockquote>
<blockquote>
<p><strong>남은 문제 (2)</strong> 현재 방식에서는 DB 조회→발행→DB에 변경된 상태 반영의 순서로 작업이 이루어지고 있다. 이떄 발행 성공 후 상태 갱신이 제대로 되지 않는 경우, 중복 발행이 일어날 수 있다는 문제가 남아있고, 메시지 멱등성 보장을 위한 처리가 필요.</p>
</blockquote>
<h2 id="4-스펙-변경">(4) 스펙 변경</h2>
<h3 id="netty--coroutine--r2dbc로의-전환">Netty + Coroutine + R2DBC로의 전환</h3>
<p>단일 인스턴스에서 다수의 스프링 애플리케이션을 구동하는 구조에서 톰캣 기반의 애플리케이션은 그만큼 많은 스레드를 생성해 불필요한 메모리·컨텍스트 스위칭 오버헤드 발생 가능성이 있을 것이라 판단해, Netty와 Coroutine을 사용하는 방향으로 전환했다.</p>
<p>이때 JPA 또한 JDBC에 바탕하므로 블로킹으로 동작하고, 이 부분이 병목이 될 것을 우려해 R2DBC로 전환했고, 드라이버 안정성 문제에 따라 RDB 또한 MySQL에서 PostgreSQL로 변경했다.</p>
<h3 id="graphql-도입-참고-문서">GraphQL 도입 (<a href="https://github.com/Frog-Slayer/code-swamp-backend/wiki/infra-database-query-%EB%AA%A8%EB%93%88">참고 문서</a>)</h3>
<p>블로그 포스트의 경우 다양한 형태로 사용자에게 제공될 수 있다..</p>
<blockquote>
<p><strong>예시</strong></p>
<ul>
<li>리스트 조회에서는 간단한 메타데이터들만을 제공해도 됨</li>
<li>상세 조회에서는 본문, 폴더 Breadcrumb 등을 포함한 전체 데이터를 조합해 제공해야 함</li>
</ul>
</blockquote>
<p>이때 매번 포맷에 따라 응답용 DTO와 엔드포인트를 만드는 건 유연하지 않고, 항상 전체 데이터를 내보내고, 프론트엔드에서 필요한 것들을 선택적으로 사용하는 방법의 경우 글 본문이 그 외 모든 메타데이터들보다 크기가 훨씬 클 가능성이 높기에 비효율적이라 생각했다.</p>
<p>이에 따라 프론트엔드에서는 필요한 데이터만을 선택적으로 요청할 수 있게 하고, 백엔드에서도 요청된 필드만을 선택적으로 제공할 수 있게 하고자 GraphQL을 도입했다.</p>
<p><strong><code>infra-database-query</code> 모듈</strong>
GraphQL을 통해 단일 엔드 포인트로 필요한 필드를 요청받을 수 있게 되었지만, 마찬가지로,<strong>어떻게 DB에서 데이터를 가져올지</strong>를 다뤄야 했다. 고려한 방식에는 두 가지가 있었는데,</p>
<ol>
<li>모든 데이터를 가져오고 요청 받은대로 조합</li>
<li>요청받은 것만 선택적으로 조회</li>
</ol>
<p>1번의 경우 구현이 쉽고 안정적이지만, 불필요하게 큰 필드(글 본문)를 함께 조회해야 하거나, 여러 번의 DB I/O가 발생할 수 있어 비효율적이라 판단했다. 따라서 2번, 선택적으로 필요한 것들만을 조회/요청하는 방식을 선택했는데, R2DBC를 사용하게 되다 보니 다음과 같은 번거로움이 있었다.</p>
<ol>
<li>GraphQL로 요청된 필드와 실제 DB 컬럼 간의 매핑이 필요</li>
<li>필요한 컬럼만을 선택해 쿼리를 보낼 수 있어야 하지만, 매번 SQL을 직접 작성하기는 번거로움</li>
<li>쿼리 결과의 필드-컬럼을 매번 수동으로 매핑하는 방식은 유지보수에 취약하고 비효율적임</li>
</ol>
<p>위와 같은 문제를 해결하기 위해 부분 쿼리 지원을 위한 클래스들을 만들고 별도 모듈로 분리했다.</p>
<blockquote>
<p><strong>트레이드오프</strong>
반복되는 로직이 줄었고, 선택적 데이터 페칭이 가능해짐에 따라 유연성이 좋아졌다. 하지만 선택적으로 컬럼을 가져오기 위해 DTO의 모든 필드를 nullable하게 두면서, 코틀린의 null-safety를 살리지 못하게 됐고, 각 유즈케이스마다 어떤 컬럼이 내부 로직 처리에 필요한지 더 신경을 써줘야 하게 됨에 따라 개발 부담이 증가했다.</p>
</blockquote>
<h3 id="neo4j-삭제">Neo4j 삭제</h3>
<p>초기 프로젝트에서는 계층적 데이터 탐색을 위해 Neo4j를 도입하고, 이벤트를 통해 RDB와 Neo4j에 데이터를 중복 저장하도록 했다.</p>
<p>Neo4j 그래프 DB를 사용했을 때 얻었던 이점에는 다음과 같은 것들이 있었다.</p>
<ol>
<li><strong>디렉토리 구조 표현에 용이</strong><ul>
<li>폴더 생성, 삭제, 이동, 폴더명 변경 등의 CUD 작업을 단일 노드 이동만으로도 cascade 처리할 수 있어 구현이 단순함</li>
</ul>
</li>
<li><strong>글의 변경 이력 관리</strong><ul>
<li>초기 프로젝트의 경우 포스트의 쓰기 읽기 작업이 하나의 애플리케이션에서 이루어졌기에, 매번 diff를 조합해 본문을 재구성하는 경우 읽기 작업에서의 오버헤드가 발생할 수 있음.</li>
<li>주기적으로 글의 스냅샷 버전을 저장하고, 본문 재구성 시 가장 가까운 스냅샷 버전을 찾아 원하는 버전까지의 diff 체인을 만듦으로써 경로 길이를 줄이도록 했고, Neo4j를 사용해 RDB 재귀쿼리 없이도 빠른 탐색이 가능하도록 함.</li>
</ul>
</li>
</ol>
<p>하지만 프로젝트를 진행하면서 다음과 같은 문제들이 떠올랐다.</p>
<ol>
<li>RDB와 Neo4j 간의 일관성 유지가 어려움<ul>
<li>주기적 폴링 방식의 아웃박스 패턴을 사용하면서 이벤트 처리에 지연이 발생하게 되었고, 잦은 변경이 일어나는 경우 RDB와 Neo4j 간의 일관성을 유지하기 어려워졌음</li>
</ul>
</li>
<li>장애 전파<ul>
<li>RDB와 Neo4j가 동시에 실패 지점으로 작용해, 둘 중 하나에 문제가 생기면 전체 실패로 이어지게 됨</li>
</ul>
</li>
</ol>
<p>결국 Neo4j에는 정합성이 중요한, 도메인의 주요 엔티티 데이터를 담기보다는, 어느 정도 장애가 허용되는 관계 데이터(ex. 추천을 위한 관계 정보 등)를 담는 것이 적합할 것이라 판단해 Neo4j를 사용하지 않기로 했다.</p>
<p><strong>대안 1: 폴더 테이블의 비정규화</strong>
프론트엔드에서는 <code>@username/dir1/dir2/.../dirn/slug</code> 의 꼴로 발행된 글에 대한 요청을 보내도록 하고 있다. Neo4j를 사용했을 때에는 이러한 계층 구조 탐색이 용이했으나, Neo4j를 제거하게 되면서 폴더 삭제, 변경, 이동이 일어나더라도 경로를 빠르고 일관적이게 제공할 수 있는 방안이 필요해졌다.</p>
<p>R2DBC로 전환하게 되었기에 JPA 식 객체 그래프 탐색는 불가능했고. RDB 재귀 쿼리 역시 성능 및 복잡도의 측면에서 적절하지 않다고 판단했기에, 다음과 같은 비정규화 전략을 채택했다.</p>
<ol>
<li>폴더 경로 full path를 DB 컬럼에 저장</li>
<li>경로 변경 시, <code>LIKE &#39;path%&#39;</code> 를 통해 해당 경로 및 하위 경로를 전체 업데이트</li>
</ol>
<p><strong>대안 2: 애그리거트 루트의 확대</strong>
초기에는 ‘모든 블로그 포스트는 <strong>특정 버전의 글</strong>’이라는 가정 아래, 애그리거트 루트를 또한 <code>VersionedArticle</code> (특정 버전의 글)로 설정했고, 이에 따라 Neo4j에서 가장 가까운 스냅샷을 찾고 diff 체인을 순서대로 적용해 본문을 재구서하는 방식을 선택했다.</p>
<blockquote>
<p><strong>스냅샷</strong> 
매번 최초 버전에서 타겟 버전까지의 diff 체인을 적용하는 경우 성능 오버헤드가 있을 것이라 생각해, 스토리지를 조금 더 쓰는 대신, 읽기/롤백 성능을 확보하고자 특정 버전을 스냅샷으로 저장하도록 했다. </p>
<p><strong>가까운 스냅샷을 찾은 후 타겟 버전까지의 diff 체인을 차례로 적용해 본문을 재구성</strong>하는 것은 도메인 로직으로 두되, 어떤 버전을 스냅샷으로 저장할 것인지는 최적화의 영역이라 생각되어 애플리케이션 계층에 <strong>스냅샷 저장 정책 클래스</strong>를 만들고, 해당 정책에 따라 저장 여부를 결정하도록 했다.</p>
</blockquote>
<p>하지만 Neo4j를 제거하게 되면서 그래프 기반 롤백/탐색의 장점이 사라졌고, 재귀/조합 로직이 RDB/R2DBC 환경에서는 유지보수 난이도를 높였다. </p>
<p>이 문제를 해결하기 위해, 읽기 작업이 쓰기 작업보다 월등히 많을 것이라는 가정 아래 “발행본은 별도 서비스로 분리하고, diff 조합은 쓰기에서만 수행”하는 방향으로 전환해, 쓰기 성능을 일부 희생하고 도메인 완전성과 유지보수성을 우선하도록 했다.</p>
<blockquote>
<p>(기존) <code>VersionedArticle</code> : 특정 버전의 글.
→ (변경 후) <code>Article</code>: 게시글(버전 집합)을 하나의 애그리거트로 보고, 모든 버전을 로드해 메모리 상에서 버전 트리를 유지/조합</p>
</blockquote>
<p>이렇게 애그리거트 루트를 확장함으로써, <code>VersionedArticle</code>을 애그리거트 루트로 정의할 때 느꼈던, “하나의 게시글은 여러 버전의 집합”이라는 도메인 본질을 온전히 담아내지 못한다는 문제를 해소할 수 있게 됐다.</p>
<h1 id="2-전체-회고">2. 전체 회고</h1>
<p>이번 프로젝트를 돌아보면서 몇 가지 중요한 교훈들을 얻었다.</p>
<ul>
<li><p><strong>모놀리식에서 결합도를 낮추는 것과 서비스 분리는 다르다</strong>
  처음에는 단순히 결합도를 낮추면 서비스 분리가 쉬워질 것이라 생각했지만, 실제로는 데이터 정합성, 이벤트 흐름 등 고려해야 할 사항이 훨씬 많았다. 각 도메인을 독립 서비스로 나누는 것이 간단해 보였지만, 사실은 공통 모듈, 서비스 간 통신, 운영 편의성 등 다양한 문제가 얽혀 있었다.</p>
</li>
<li><p><strong>기능 구현과 분리의 트레이드오프</strong>
  분리를 시도해보기로 결정한 후 분리 작업에 많은 시간을 투자하다 보니, 모놀리식으로 진행했다면 구현했을 기능들을 끝내 구현하지 못하게 됐고, 기술 부채가 쌓여 프로젝트를 잠정 중단하게 됐다. 학습 효과는 컸지만 프로젝트 완성도 측면에서는 아쉬움이 남았다.</p>
</li>
<li><p><strong>성능 근거 없는 성급한 최적화</strong>
  실제 성능 지표를 바탕으로 병목 구간을 확인하기보다는 “아마 이 부분이 느릴 것이다”라는 추측에 따라 스펙을 바꾸는 선택을 했고, 결과적으로 성능 최적화보다는 유지보수 복잡성을 높이는 결과가 나온 부분도 있었다.
  초기 모놀리식 프로젝트에서 K6, Grafana, Prometheus, Spring Boot Actuator를 이용해 간단한 부하테스트를 진행해보기는 했지만, SEO를 위해 적용했던 Next.js SSR이 CPU 병목으로 작용해 스프링 애플리케이션에 대한 성능 검증이 되지는 못했다.</p>
</li>
<li><p><strong>어중간한 아키텍처</strong>
이번 프로젝트에서는 단일 인스턴스 내에서 여러 애플리케이션을 운영했다.<br>처음에는 ‘각 애플리케이션을 별도의 컨테이너로 띄우되, 각각 별도의 클라우드 인스턴스로 간주해보자’고 구상했지만, 실제로는 여러 시각이 혼재되어 있었다. </p>
<ul>
<li>일부는 여전히 단일 인스턴스에서 여러 애플리케이션이 돌아간다는 관점으로 접근</li>
<li>일부는 각 애플리케이션이 별도의 인스턴스에서 돌아간다면 괜찮을 것이라는 가정으로 접근 </li>
</ul>
<p>그 결과 설계 과정에서 일관성이 떨어지는 부분이 있었고, 초기에 어떤 방식으로 접근할 것인지를 명확히 하는 것이 중요함을 다시 한 번 깨닫게 됐다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[PJT] CodeSwamp (1) 초기 프로젝트]]></title>
            <link>https://velog.io/@frog_slayer/Code-Swamp-1</link>
            <guid>https://velog.io/@frog_slayer/Code-Swamp-1</guid>
            <pubDate>Sat, 23 Aug 2025 10:34:34 GMT</pubDate>
            <description><![CDATA[<h1 id="1-프로젝트-소개">1. 프로젝트 소개</h1>
<p><a href="https://velog.io/@frog_slayer/PJT-ZIGLOG-%ED%9A%8C%EA%B3%A0">2년 전 진행했던 프로젝트</a>에서 느꼈던 문제점들을 개선해보고자, 2025.05-07 개인 프로젝트를 진행했다.</p>
<h1 id="2-초기-프로젝트">2. 초기 프로젝트</h1>
<p>기획은 대부분 이전 프로젝트와 동일하게 가져갔고, 초기 구현에서는</p>
<blockquote>
<ol>
<li>처음에는 따로 서비스 분리를 하지 않고 모놀리식으로 구현하되, </li>
<li>추후 필요해진다면 일부 서비스를 분리해내더라도 문제가 없도록 도메인 간 결합도를 낮춘 코드</li>
</ol>
</blockquote>
<p>를 지향하면서, 코드 컴플리트 스터디를 진행하며 학습했던 내용들을 녹여내고자 했다.</p>
<h2 id="1-계층-분리">(1) 계층 분리</h2>
<p>이전에 프로젝트들을 진행했을 때 항상 느꼈단 <strong>미묘한 이질감</strong>이 있다.</p>
<blockquote>
<p>DB에 의존하지 않는 서비스 개발을 위해 JPA를 사용했지만,</p>
<ol>
<li>엔티티 코드에 JPA 관련 코드가 혼입되어, 도메인 모델이 JPA에 종속적이게 되고, </li>
<li>서비스 계층이 모든 로직을 수행해, 매핑된 객체들이 단순 데이터 구조체 이상의 역할을 하지 않게 됨(객체 지향적이지 않음)</li>
</ol>
</blockquote>
<p>이 뭔가 찝찝하게 느껴지는 것들을 풀어내보고자, 다음과 같은 원칙을 세우고 계층 구조를 명확히 분리해 프로젝트를 진행했다. </p>
<blockquote>
<ol>
<li><code>presentation</code>, <code>application</code>, <code>domain</code>, <code>infrastructure</code>의 네 계층으로 철저히 분리</li>
</ol>
</blockquote>
<ul>
<li>DB에서 불러온 JPA 엔티티를 바로 사용하지 않고, 조합 후 애그리거트 루트를 구성해 사용 <blockquote>
<ol start="2">
<li>도메인 객체에 가능한 한 많은 책임을 부여하고, 도메인 서비스는 단일 순수 객체만으로는 표현하기 어려운 로직에 한정</li>
<li>도메인 계층의 인프라 의존성을 최대한 배제하고, 인터페이스 및 순수 로직 위주로 설계</li>
<li>인프라 계층에는 되도록 구현체만 담기</li>
<li>서로 다른 도메인 간의 소통이 필요한 경우 직접 호출은 지양하고, 사용하는 쪽에 인터페이스를 정의하고 제공하는 쪽에서 구현체를 연결</li>
</ol>
</blockquote>
</li>
</ul>
<p>이로써 DB 저장 방식이나 ORM 기술에 대한 의존은 인프라 수준에 숨기고, 애플리케이션 및 도메인 계층에서는 하위 인프라에 의존하지 않게 만들 수 있었다.</p>
<p>특히 객체 ID가 도메인 수준에서 객체를 유일하게 식별할 수 있게 하기 위한 식별자라는 판단 하에, 객체 ID 생성 또한 JPA <code>@GeneratedValue</code>에 의존하던 방식에서 도메인의 책임으로 격상시켰다. 도메인 계층에 <code>IdGenerator</code> 인터페이스를 두고, 인프라에 구현체를 배치함으로써 ID 생성을 JPA 기술에 묶이지 않게 했고, 그 결과 JPA 환경 세팅이나 테스트 DB 없이 순수한 도메인 객체만으로도 비즈니스 로직을 검증할 수 있게 돼 훨씬 가볍고 빠르게 테스트 할 수 있었다.</p>
<blockquote>
<p>☹️ 다만 JPA를 곧바로 사용하지 않고 한 단계 더 추상화해 사용하게 되면서 JPA의 더티 체킹 기능을 사용하지 못하게 되었고, 객체를 조합하고 분리하기 위한 코드량이 증가하게 됐다.</p>
</blockquote>
<h2 id="2-검색-엔진-도입-meilisearch">(2) 검색 엔진 도입: Meilisearch</h2>
<p>이전 프로젝트에서는 포스트 검색을 전처리 후 <code>LIKE</code> 절을 이용한 DB 조회로 처리했는데, 그 결과 글이 수십 개밖에 없음에도 체감할 만한 지연이 발생했고, 이에 따라 텍스트 검색에 특화된 검색 엔진 도입이 필요해졌다.</p>
<h3 id="검색-엔진-후보-검토">검색 엔진 후보 검토</h3>
<p>가장 먼저 후보로 생각했던 것은 Elasticsearch였는데, 업계 표준이기도 하고 가장 널리 사용되는, 검증된 검색 엔진이기에, 현재 학습해두면 이후 프로젝트에도 큰 도움이 될 것이라 생각했기 때문이다.</p>
<p>하지만 이번 프로젝트에서는 다음과 같은 제약 사항이 있었다.</p>
<blockquote>
<p><strong>제약 사항</strong></p>
<ul>
<li>구글 클라우드 무료 크레딧 사용(30만원 한도, 90일 기한)</li>
<li>16GB 메모리, 2코어 CPU, 100GB 스토리지의 단일 인스턴스 기준, 2달 반 ~ 3달 사용 가능</li>
</ul>
</blockquote>
<p>Elasticsearch의 경우</p>
<ol>
<li>메모리 사용량이 높아 제한된 자원 환경에서의 사용 부담이 크고, </li>
<li>학습 곡선이 상대적으로 가파르며,</li>
<li>이 프로젝트에서는 단순 검색 엔진 용도 이상의 고급 기능이 필요하지 않을 것</li>
</ol>
<p>이기에, 이 프로젝트에서 사용하기에는 과한 투자라 판단되었고, 따라서 프로젝트에서 필요한, 가장 기본적인 검색 기능을 제공하면서도 경량화된 Meilisearch를 선택했다. </p>
<table>
<thead>
<tr>
<th><strong>구분</strong></th>
<th><strong>Elasticsearch</strong></th>
<th><strong>Meilisearch</strong></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>높은 메모리·CPU 요구</td>
<td>경량화되어 메모리·CPU 부담 적음</td>
</tr>
<tr>
<td><strong>확장성</strong></td>
<td>대규모 분산 시스템 지원</td>
<td>단일 인스턴스 중심, 대규모 분산은 제한적</td>
</tr>
<tr>
<td><strong>학습 곡선</strong></td>
<td>상대적으로 높음</td>
<td>상대적으로 낮음</td>
</tr>
</tbody></table>
<h3 id="키워드-기반-검색-플로우-관련-문서">키워드 기반 검색 플로우 <a href="https://github.com/Frog-Slayer/code-swamp-backend/wiki/%EA%B8%80-%EC%93%B0%EA%B8%B0%EC%99%80-%EC%9D%BD%EA%B8%B0">(관련 문서)</a></h3>
<p>프로젝트에서는 git과 비슷하게 unified format의 diff를 바탕으로 글의 변경 내역을 추적하는 기능을 추가했다.</p>
<blockquote>
<p>프론트엔드에서 unified format의 diff 데이터를 포함해 요청을 보내면, 이를 조합해 원본 마크다운 문서를 재구성할 수 있고, 각 요청을 하나의 버전으로 DB에 저장하는 방식으로 관리.</p>
</blockquote>
<p>이때 raw 마크다운을 그대로 저장하는 경우, 마크다운 문법이 검색 키워드 매칭에 영향을 미쳐 검색 정확도가 떨어질 수 있다고 판단해, 다음과 같은 저장 및 검색 플로우를 설계했다.</p>
<p><strong>저장 플로우</strong></p>
<ol>
<li>프론트엔드에서 unified format diff 데이터를 요청 본문에 포함해 전송</li>
<li>diff 데이터를 조합해 마크다운 문서로 복원</li>
<li>마크다운 문서에서 마크다운 문법을 제거한 텍스트를 생성</li>
<li>RDB에는 raw 마크다운을 저장하고, 검색 엔진에는 전처리된 텍스트를 저장</li>
</ol>
<p><strong>검색 플로우</strong></p>
<ol>
<li>사용자의 키워드 검색 요청</li>
<li>Meilisearch에서 전처리된 텍스트 인덱스를 검색</li>
<li>검색 결과의 문서 ID 목록을 바탕으로 RDB에서 원본 마크다운을 재조회 </li>
</ol>
<h2 id="3-그래프-db-도입--neo4j">(3) 그래프 DB 도입 : Neo4j</h2>
<p>프로젝트에서 다뤘던 여러 데이터들은 그래프의 형태로 모델링할 있었는데, 구체적으로는 다음과 같다.</p>
<blockquote>
<ol>
<li>지식 그래프 : 글 간 관계 표현</li>
<li>단일 문서에 대한 git-like 브랜치 구조</li>
<li>폴더와 파일의 계층 구조</li>
</ol>
</blockquote>
<p>이전 프로젝트에서는 객체 그래프 탐색을 가능케하는 JPA의 특성을 살리고자 <code>lazy fetch</code>를 이용했지만, 당연히 객체 그래프를 탐색할 때마다 DB I/O가 필요 이상으로 발생하는 문제가 발생하게 되고, 대안으로 애플리케이션 수준에서의 재귀적인 탐색 대신 RDB의 재귀 쿼리를 활용하는 방식도 가능하겠지만 성능 측면에서의 오버헤드가 크다.</p>
<p>제공하는 정보 형태와 저장되는 방식이 일관적이게 유지하는 것이 직관성과 유지보수성의 측면에서 이점이 있다고 판단해, 노드-관계형 데이터 저장 및 탐색에 특화된 Neo4j를 도입했다. </p>
<blockquote>
<p>다만 NoSQL인 만큼 데이터 무결성의 측면에서 RDB 대비 약점을 가지기에 RDB(MySQL)을 SSOT로 두되, Neo4j에 중복 저장해 그래프 탐색이 필요한 경우에 활용하도록 했다. </p>
</blockquote>
<h2 id="4-이벤트-처리">(4) 이벤트 처리</h2>
<p>도메인 간 호출이 필요한 경우에는 인터페이스를 통해 처리하도록 했다. 이벤트를 통해 조금 더 느슨한 결합을 가져갈 수도 있었지만, 모놀리식 프로젝트인만큼 호출 순서가 더 잘 드러나는 인터페이스가 흐름을 파악하기도 쉽고, 복잡도를 낮추는 데에도 도움을 주리라 생각했다.</p>
<p>이벤트는 특정 작업에 따르는 후속 작업이 필요한 경우(ex. 이종 데이터소스 중복 저장. 관심사가 아닌 부작용)에만 제한적으로 사용했고, 카프카 등의 메시지 브로커 대신 <code>Spring ApplicationEvent</code> 를 활용했다.</p>
<blockquote>
<ol>
<li><code>AggregateRoot</code>  클래스를 만들어 이벤트를 생성, 수집하게 만들고, 각 도메인의 애그리거트 루트가 해당 클래스를 상속</li>
<li>이벤트의 생성은 각 도메인 객체가, 이벤트의 발행은 애플리케이션 계층에서 도메인 객체의 이벤트 리스트를 참조, 발행</li>
</ol>
</blockquote>
<pre><code class="language-kotlin">// 도메인 계층에서의 이벤트 생성 및 수집
abstract class AggregateRoot {  
    private val domainEvents = mutableListOf&lt;DomainEvent&gt;()  

    protected fun addEvent(event: DomainEvent) {  
        domainEvents.add(event)  
    }  

    fun pullEvents(): List&lt;DomainEvent&gt; {  
        val events = domainEvents.toList()  
        domainEvents.clear()  
        return events  
    }  
}

//-------------------------------------------
// 애플리케이션 계층에서의 이벤트 발행
val events = domainObject.pullEvents()
events.forEach{ eventPublisher.publish(it) }</code></pre>
<hr>
<p>이렇게 모놀리식 프로젝트를 진행하면서 인증, 문서 CRUD 등의 기본적인 기능들의 구현이 완료되어 갈 때, 다음과 같은 의문이 들었다.</p>
<blockquote>
<p>🤔 ‘이 정도면 충분히 도메인 간 결합도를 낮춘 코드인 것 같은데, 정말 곧바로 분리할 수 있을까?’</p>
</blockquote>
<p>위와 같은 의문에서 실제로 서비스 분리를 해보았고, 결과적으로 프로젝트 완성에 있어서는 좋은 선택이 아니었다.</p>
<p><img src="https://media1.tenor.com/m/0Xt5AtzzMwYAAAAC/interstellar-no-no-no.gif" alt="stay..."></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 4-4. 이벤트 루프와 처리 (4)]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-4-4</link>
            <guid>https://velog.io/@frog_slayer/Nginx-4-4</guid>
            <pubDate>Tue, 29 Apr 2025 11:12:03 GMT</pubDate>
            <description><![CDATA[<p>실제로 어떻게 이벤트 처리가 이루어지는지 살펴보기 전에 간단하게 <code>epoll</code> 시스템 콜들을 리뷰하고 가자.</p>
<h1 id="1-epoll-시스템-콜-리뷰">1. <code>epoll</code> 시스템 콜 리뷰</h1>
<pre><code class="language-c">int epoll_create(int size);</code></pre>
<p><code>epoll_create()</code>는 새 epoll 인스턴스를 만들고 그 FD를 반환한다.  </p>
<ul>
<li><code>size</code>: 모니터링할 파일 디스크립터의 수에 해당한다.</li>
</ul>
<pre><code class="language-c">int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);</code></pre>
<p><code>epoll_ctl()</code>은 생성된 <code>epoll</code> 인스턴스 제어에 쓰이며, 성공 시 0, 실패 시 -1을 반환한다.</p>
<ul>
<li><code>epfd</code>: 위 <code>epoll_create()</code>로 반환된 FD</li>
<li><code>op</code>: 추가/수정/삭제 중 어떤 것을 할지</li>
<li><code>fd</code>: <code>op</code>를 적용할 FD</li>
<li><code>events</code>: <code>epoll</code> 이벤트 구조체</li>
</ul>
<p>여기서 <code>epoll_event</code>는 다음과 같은 구조체다.</p>
<pre><code class="language-c">struct epoll_event {
   uint32_t     events; // epoll 이벤트 플래그
   epoll_data_t data;   // 사용자가 사용할 데이터
};</code></pre>
<p>대표적인 <code>epoll</code> 이벤트에는 <code>EPOLLIN</code>, <code>EPOLLOUT</code>, <code>EPOLLERR</code>, <code>EPOLLET</code> 등이 있는데, 여기서 특히 <code>EPOLLET</code>은 <code>epoll</code>의 모드를 설정하기 위해 쓰인다. 기본은 LT모드지만, Nginx에서는 ET모드를 주로 사용한다.</p>
<ul>
<li>Edge Triggered(ET): 해당 파일 디스크립터에 상태 변화가 생기는 경우에만 전달</li>
<li>Level Triggered(LT): 해당 파일 디스크립터가 준비 상태면 계속 전달</li>
</ul>
<pre><code class="language-c">typedef union epoll_data {
   void        *ptr;    // 이벤트 관련 구조체를 정의/사용하는 경우
   int          fd;     // 단순 FD만 전달하면 되는 경우
   uint32_t     u32;    // 작은 정수 데이터 전달이 필요한 경우 (1)
   uint64_t     u64;    // 작은 정수 데이터 전달이 필요한 경우 (2)
} epoll_data_t;</code></pre>
<p><code>data</code>에 들어가는 <code>epoll_data_t</code> 구조체는 위와 같이 정의되어 있고, 사용자가 준비된 이벤트 처리를 위해 사용하게 될 정보들을 넣는 용도로 사용한다. </p>
<pre><code class="language-c">int epoll_wait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout);</code></pre>
<p>마지막으로 <code>epoll_wait()</code>는 해당 <code>epoll</code> 인스턴스에서의 이벤트 발생을 대기하기 위해 쓰인다. 성공 시 준비된 이벤트의 개수를 반환하고, 에러인 경우 -1, 타임아웃인 경우 0을 반환한다.</p>
<ul>
<li><code>epfd</code>: 대기할 <code>epoll</code> 인스턴스의 FD</li>
<li><code>events</code>: 준비된 이벤트들이 들어간다</li>
<li><code>maxevents</code>: 한 번에 반환받을 최대 이벤트 수</li>
<li><code>timeout</code>: 타임아웃 시간</li>
</ul>
<p>이때 <code>events</code> 배열에는 <code>epoll_ctl</code>로 넣었던 바로 그 구조체가 담겨 반환된다.</p>
<h1 id="2-nginx의-이벤트연결-처리-관련-구조체-소개">2. Nginx의 이벤트/연결 처리 관련 구조체 소개</h1>
<h3 id="1-ngx_listening_t-구조체">(1) <code>ngx_listening_t</code> 구조체</h3>
<p><code>ngx_listening_t</code> 구조체는 서버가 바인딩해서 클라이언트 연결을 기다리는 리스닝 소켓을 표현한다.  Nginx는 리스닝 소켓에 대한 정보를 이 구조체를 통해 관리하며, 새로운 연결 수락 시에도 이 구조체를 참조한다.``</p>
<pre><code class="language-c">// /src/core/ngx_connection.h
struct ngx_listening_s {
    ngx_socket_t        fd;         // 리스닝 소켓 FD

    struct sockaddr    *sockaddr;   // 바인딩된 주소
    socklen_t           socklen;    // 주소 길이
    size_t              addr_text_max_len;
    ngx_str_t           addr_text;

    int                 type;

    int                 backlog;    // 리스닝 소켓 백로그 
    int                 rcvbuf;     // 수신 버퍼 크기
    int                 sndbuf;     // 송신 버퍼 크기

    /* handler of accepted connection */
    ngx_connection_handler_pt   handler;  // 연결 수락 후 호출할 핸들러

    //.. 그 외 엄청나게 많은 필드들
};</code></pre>
<p>이 <code>ngx_listening_t</code> 구조체는 아래의 <code>ngx_connection_t</code> 구조체로 래핑되어, 새로운 연결 이벤트를 받을 수 있도록 <code>epoll</code> 인스턴스에 등록된다.</p>
<h3 id="2-ngx_connection_t-구조체">(2) <code>ngx_connection_t</code> 구조체</h3>
<p><code>ngx_connection_t</code>은 연결을 추상화한 구조체로, 연결과 관련한 모든 정보들이 담겨 있다.  Nginx에서는 주로 이 구조체의 포인터를 <code>epoll_data.ptr</code>에 넣어 이후 처리에 이용한다.</p>
<p>말 그대로 연결과 관련된 모든 정보들을 담은 구조체라 필드도 수십개가 있지만, 몇 가지만 추려보면 다음 정도가 있다. </p>
<pre><code class="language-c">// /src/core/ngx_connection.h
struct ngx_connection_s {
    void               *data;      // 연결 관련 사용자 정의 데이터 포인터
    ngx_event_t        *read;      // 읽기 이벤트 관련 구조체 (핸들러)
    ngx_event_t        *write;     // 쓰기 이벤트 관련 구조체 (핸들러)

    ngx_socket_t        fd;        // 소켓 FD 

    ngx_listening_t    *listening; // 연결 수락에 쓰일/쓰인 리스닝 소켓

    int                 type;      // 연결 유형 플래그

    // 아래는 연결 수립 이후 클라이언트의 정보들 
    struct sockaddr    *sockaddr;
    socklen_t           socklen;
    ngx_str_t           addr_text;

    //.. 그 외 겁내 많은 필드들
};</code></pre>
<p><code>data</code> 필드에는 소켓의 종류에 따라 여러 가지 구조체 포인터가 들어간다.</p>
<ul>
<li>리스닝 소켓인 경우 <code>ngx_listening_t</code>의 포인터</li>
<li>클라이언트 연결 소켓인 경우 <code>ngx_http_request_t</code> 등의 포인터</li>
</ul>
<p>각 워커마다 만들 수 있는 연결 소켓의 개수는 <code>nginx.conf</code>에서 다음과 같이 설정할 수 있고,</p>
<pre><code class="language-nginx">events {
    worker_connections 1234;
}</code></pre>
<p>각 워커 프로세스에서 <code>ngx_events_module</code>의 <code>init_process</code>를 호출할 때, 다음과 같이 그 수만큼의 <code>ngx_connection_t</code> 구조체를 생성/초기화 해놓고 사용한다. </p>
<pre><code class="language-c">// /src/event/ngx_event.c
static ngx_int_t
ngx_event_process_init(ngx_cycle_t *cycle)
{
    // ...

    // worker_connections 크기의 ngx_connection_t 구조체 배열을 만들고
    cycle-&gt;connections =
    ngx_alloc(sizeof(ngx_connection_t) * cycle-&gt;connection_n, cycle-&gt;log);
    if (cycle-&gt;connections == NULL) {
        return NGX_ERROR;
    }

    c = cycle-&gt;connections;

    // 각 커넥션 구조체에 넣을 read 이벤트 구조체를 만들고 초기화
    cycle-&gt;read_events = ngx_alloc(sizeof(ngx_event_t) * cycle-&gt;connection_n,
                                   cycle-&gt;log);
    if (cycle-&gt;read_events == NULL) {
        return NGX_ERROR;
    }

    rev = cycle-&gt;read_events;

    for (i = 0; i &lt; cycle-&gt;connection_n; i++) {
        rev[i].closed = 1;
        rev[i].instance = 1;
    }

    // 각 커넥션 구조체에 넣을 write 이벤트 구조체를 만들고 초기화
    cycle-&gt;write_events = ngx_alloc(sizeof(ngx_event_t) * cycle-&gt;connection_n,
                                    cycle-&gt;log);
    if (cycle-&gt;write_events == NULL) {
        return NGX_ERROR;
    }

    wev = cycle-&gt;write_events;
    for (i = 0; i &lt; cycle-&gt;connection_n; i++) {
        wev[i].closed = 1;
    }

    i = cycle-&gt;connection_n;
    next = NULL;

    // 커넥션 객체에 넣어줌
    do {
        i--;

        c[i].data = next;
        c[i].read = &amp;cycle-&gt;read_events[i];
        c[i].write = &amp;cycle-&gt;write_events[i];
        c[i].fd = (ngx_socket_t) -1;

        next = &amp;c[i];
    } while (i);

    cycle-&gt;free_connections = next;//사용 가능한 커넥션 구조체 연결리스트의 헤드
    cycle-&gt;free_connection_n = cycle-&gt;connection_n;//남아있는 사용 가능 커넥션

    // ...
}</code></pre>
<p>새 클라이언트 연결 등의 이유로 커넥션 구조체가 필요한 경우 <code>/src/core/ngx_connection.c:ngx_get_connection()</code>을 호출해, 위에서 만들어 놓은 <code>ngx_connection_t</code> 풀에서 하나를 가져와 사용한다.</p>
<h3 id="3-ngx_event_t-구조체">(3) <code>ngx_event_t</code> 구조체</h3>
<p><code>ngx_event_t</code>는 개별 I/O 이벤트를 추상화한 구조체로, 이벤트가 발생했을 때 어떤 처리를 할지를 정의하는 핸들러가 있다. 이벤트의 각 상태는 1비트를 이용해 나타내고, <code>ngx_connection_t</code>와 마찬가지로 많은 필드가 있지만, 몇 가지만 추려보면 다음 정도가 있다.</p>
<pre><code class="language-c">struct ngx_event_s {
    void            *data;       //이벤트 발생한 연결
    unsigned         write:1;    //쓰기 이벤트 여부(1: 쓰기, 0: 읽기)
    unsigned         accept:1;   //accept 이벤트 여부(리스닝 소켓용)

    unsigned         instance:1;
    unsigned         active:1;
    unsigned         disabled:1;
    unsigned         ready:1;
    unsigned         complete:1;
    unsigned         eof:1;
    unsigned         error:1;
    unsigned         timedout:1;
    unsigned         timer_set:1;
    unsigned         closed:1;

    ngx_queue_t      queue;
    ngx_event_handler_pt  handler; //이벤트 핸들러

    //.. 그 외 쥰내 많은 필드들
};</code></pre>
<h1 id="3-epoll-이벤트-처리">3. <code>epoll</code> 이벤트 처리</h1>
<p><code>epoll</code> 이벤트의 경우 <code>/src/event/ngx_event.c:ngx_process_events_and_timers()</code>에서 <code>ngx_epoll_process_events()</code>를 호출해 이벤트를 대기/처리 한다. 아래 코드를 몇 개의 블럭으로 나눠, Nginx에서 어떻게 <code>epoll</code> 이벤트를 처리하는지 살펴보자.</p>
<h3 id="1-이벤트-에러-처리">(1) 이벤트 에러 처리</h3>
<p>일단은 <code>epoll_wait()</code>를 호출해 이벤트 발생을 기다리고, 반환 시 에러/타임아웃 발생 여부를 확인한다.</p>
<pre><code class="language-c">// /src/event/modules/ngx_epoll_module.c

static ngx_int_t
ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)
{
    /**
     변수 선언
    */

    // epoll_wait()를 호출해 이벤트 발생 대기
    events = epoll_wait(ep, event_list, (int) nevents, timer);

    // 에러 여부 확인
    err = (events == -1) ? ngx_errno : 0;

    // 타이머 업데이트
    if (flags &amp; NGX_UPDATE_TIME || ngx_event_timer_alarm) {
        ngx_time_update();
    }

    // 에러처리
    if (err) {
        if (err == NGX_EINTR) {//인터럽트로 인한 대기 종료인 경우

            if (ngx_event_timer_alarm) {//타이머로 인한 종료인 경우
                ngx_event_timer_alarm = 0;
                return NGX_OK;
            }

            level = NGX_LOG_INFO;

        } else {
            level = NGX_LOG_ALERT;
        }

        ngx_log_error(level, cycle-&gt;log, err, &quot;epoll_wait() failed&quot;);
        return NGX_ERROR;
    }

    // events == 0이면 타임아웃인 경우여야 하지만,
    if (events == 0) {
        if (timer != NGX_TIMER_INFINITE) {
            return NGX_OK;
        }

        // 타임아웃이 아님에도 0이 반환되면 에러를 반환
        return NGX_ERROR;
    }

    //...</code></pre>
<p><code>epoll_wait()</code>에 대한 오류 처리가 끝나고 나면 각 이벤트를 처리한다.</p>
<h3 id="2-오래된-이벤트stale-event-처리">(2) 오래된 이벤트(stale event) 처리</h3>
<blockquote>
<p><strong>오래된 이벤트</strong>(stale event)
<code>epoll</code>로부터 전달된, 이미 닫힌/무효화된 연결에 대한 이벤트</p>
</blockquote>
<p>다음과 같은 시나리오를 생각해보자.</p>
<ol>
<li>클라이언트 A의 연결 소켓에 이벤트가 발생</li>
<li>이벤트가 전달되기 전 A가 연결을 종료<ol>
<li>해당 연결 소켓의 FD가 닫힌다</li>
<li>사용했던 커넥션 구조체(<code>conn_A</code>)를 반납한다.</li>
</ol>
</li>
<li>클라이언트 B가 서버에 연결한다.<ol>
<li>A와의 연결에서 사용됐던 FD와 커넥션 구조체가 재사용됐다고 가정</li>
</ol>
</li>
</ol>
<p><code>epoll</code>은 빠르기는 하지만, 완전히 실시간은 아니다. 이전 A와의 연결에서 발생한 이벤트가 이제서야 전달됐다고 해보자. 워커프로세스는 이 오래된 A의 이벤트를 B의 이벤트로 생각하고 잘못 처리하게 될 것이다. </p>
<p>Nginx에서는 이러한 오래된 이벤트 처리를 위해 <code>epoll_event.data.ptr</code>에 담긴 포인터의 최하위 비트를 사용한다.</p>
<blockquote>
<p><strong>메모리 정렬</strong>
현대 컴퓨터 시스템에서는 성능 향상/오류 방지 등을 위해, 데이터를 메모리에 저장할 때 특정 주소 단위로 배치하며, 그 주소 단위는 보통 그 자료형에 따라 정해진다.</p>
<ul>
<li>1바이트 데이터(char): 아무 데나 상관 없음</li>
<li>2바이트 데이터(short 등): 2의 배수 주소 (<code>0x00, 0x02, 0x04, ...</code> )</li>
<li>4바이트 데이터(int 등): 4의 배수 주소 (<code>0x00, 0x04, 0x08, ...</code> )</li>
<li>8바이트 데이터(long long, double 등): 8의 배수 주소 (<code>0x00, 0x08, 0x10, ...</code> )</li>
</ul>
</blockquote>
<p>포인터 변수는 32비트 주소 체계의 경우 4바이트 단위로 정렬될 것이고, 64비트 주소 체계라면 8바이트 단위로 정렬된다. 어떤 주소 체계든 최하위 비트는 항상 0이 되고, Nginx에서는 이 비트 자리를 오래된 이벤트를 걸러내는 데 사용한다.</p>
<ol>
<li><code>ngx_event_t</code> 구조체에는 1비트짜리 <code>instance</code> 필드가 있다. <code>ngx_get_connection()</code>로 커넥션 구조체 풀에서 하나를 가져올 때, 이 <code>instance</code> 필드는 토글된다.</li>
</ol>
<pre><code class="language-c">// /src/core/ngx_connections.c

ngx_connection_t *
ngx_get_connection(ngx_socket_t s, ngx_log_t *log)
{
    /**
     사용 가능한 커넥션 구조체를 풀에서 가져오는 부분
    */

    instance = rev-&gt;instance;

    ngx_memzero(rev, sizeof(ngx_event_t));
    ngx_memzero(wev, sizeof(ngx_event_t));

    rev-&gt;instance = !instance;
    wev-&gt;instance = !instance;

    /**
      기타 필드 설정 
    */

    return c;
}</code></pre>
<ol start="2">
<li><code>epoll</code> 인스턴스에 이벤트를 등록할 때에는 커넥션 구조체 포인터의 최하위 비트를 이벤트 <code>instance</code> 값으로 설정한 다음 <code>epoll_event.data.ptr</code>에 넣는다.</li>
</ol>
<pre><code class="language-c">// /src/event/modules/ngx_epoll_module.c

static ngx_int_t
ngx_epoll_add_event(ngx_event_t *ev, ngx_int_t event, ngx_uint_t flags)
{
    // ...

    ee.events = events | (uint32_t) flags;
    ee.data.ptr = (void *) ((uintptr_t) c | ev-&gt;instance); // &lt;&lt; 여기

    if (epoll_ctl(ep, op, c-&gt;fd, &amp;ee) == -1) {
        ngx_log_error(NGX_LOG_ALERT, ev-&gt;log, ngx_errno,
                      &quot;epoll_ctl(%d, %d) failed&quot;, op, c-&gt;fd);
        return NGX_ERROR;
    }

    // ...

    return NGX_OK;
}</code></pre>
<ol start="3">
<li>이벤트를 확인할 때에는 포인터의 최하위 비트와, 안에 담긴 이벤트 구조체의 <code>instance</code> 값을 비교한다. 만약 두 값이 일치하지 않으면 이젠 유효하지 않은 연결에서 발생했던 이벤트임을 알 수 있다.<ul>
<li>실제 커넥션 구조체의 주소는 최하위 비트를 제외한 부분을 사용해 얻을 수 있다.</li>
</ul>
</li>
</ol>
<pre><code class="language-c">// /src/event/modules/ngx_epoll_module.c

static ngx_int_t
ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)
{
    /**
      앞에서 본 부분
    */

    // 각 이벤트들에 대해
    for (i = 0; i &lt; events; i++) {

        // epoll_event 구조체 -&gt; data 필드 -&gt; ptr 필드에 담긴 포인터
        c = event_list[i].data.ptr;

        // 인스턴스 값
        instance = (uintptr_t) c &amp; 1;

        // 실제 커넥션 구조체의 주소
        c = (ngx_connection_t *) ((uintptr_t) c &amp; (uintptr_t) ~1);

        rev = c-&gt;read;

        // 이미 닫힌 연결이거나, 인스턴스 값이 일치하지 않으면 건너 뛴다.
        if (c-&gt;fd == -1 || rev-&gt;instance != instance) {
            ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle-&gt;log, 0,
                           &quot;epoll: stale event %p&quot;, c);
            continue;
        }

    // ...</code></pre>
<h3 id="3-이벤트-처리">(3) 이벤트 처리</h3>
<p>오래된 이벤트가 아님을 확인했다면 발생한 이벤트에 따른 처리를 해준다.</p>
<pre><code class="language-c">        /**
          앞에서 본 부분
        */

        revents = event_list[i].events;

        if (revents &amp; (EPOLLERR|EPOLLHUP)) {
            ngx_log_debug2(NGX_LOG_DEBUG_EVENT, cycle-&gt;log, 0,
                           &quot;epoll_wait() error on fd:%d ev:%04XD&quot;,
                           c-&gt;fd, revents);

            revents |= EPOLLIN|EPOLLOUT;
        }
</code></pre>
<p><code>EPOLLERR</code>나 <code>EPOLLHUP</code>이벤트가 발생하는 경우에는 <code>EPOLLIN</code>, <code>EPOLLOUT</code> 비트를 1로 만든 후 다음으로 이어간다. </p>
<blockquote>
<p><code>EPOLLHUP</code>
<strong>H</strong>ang <strong>up</strong>. 상대방이 더 이상 데이터를 송/수신할 수 없는 상태인 경우 발생 (ex. 연결 종료)</p>
<p><strong>Q. 에러 발생/연결 종료 이벤트가 발생했는데 왜 <code>EPOLLIN | EPOLLOUT</code>을 ?</strong>
A1. <code>EPOLLERR</code>, <code>EPOLLHUP</code> 플래그만으로는 충분하지 않고, <code>read()</code>, <code>write()</code>를 호출하고 반환값과 에러 코드를 확인해야만 정확히 알 수 있다. 
A2. 읽기/쓰기 이벤트 핸들러를 재활용하면 별도의 에러/연결 종료 클린 업 핸들러를 만들지 않아도 된다.</p>
</blockquote>
<p>이후는 읽기/쓰기 이벤트에 따라 핸들러를 호출하고 처리하는 부분들이다.</p>
<pre><code class="language-c">        /**
          앞에서 본 부분
        */
        if ((revents &amp; EPOLLIN) &amp;&amp; rev-&gt;active) {

#if (NGX_HAVE_EPOLLRDHUP)
            if (revents &amp; EPOLLRDHUP) {
                rev-&gt;pending_eof = 1;
            }
#endif

            rev-&gt;ready = 1;
            rev-&gt;available = -1;

            // 이벤트 지연 플래그가 설정된 경우
            if (flags &amp; NGX_POST_EVENTS) {
                queue = rev-&gt;accept ? &amp;ngx_posted_accept_events
                                    : &amp;ngx_posted_events;

                // 지연 이벤트 큐에 추가
                ngx_post_event(rev, queue);

            } else {//그렇지 않으면 즉시 핸들러를 호출해 처리
                rev-&gt;handler(rev);
            }
        }

        wev = c-&gt;write;

        if ((revents &amp; EPOLLOUT) &amp;&amp; wev-&gt;active) {

            // stale event 확인
            if (c-&gt;fd == -1 || wev-&gt;instance != instance) {
                continue;
            }

            wev-&gt;ready = 1;
#if (NGX_THREADS)
            wev-&gt;complete = 1;
#endif

            // 이벤트 지연 플래그가 설정된 경우
            if (flags &amp; NGX_POST_EVENTS) {
                // 지연 이벤트 큐에 추가
                ngx_post_event(wev, &amp;ngx_posted_events);

            } else {//그렇지 않으면 즉시 핸들러를 호출해 처리
                wev-&gt;handler(wev);
            }
        }
    }

    return NGX_OK;
}</code></pre>
<blockquote>
<p>다음 글에서는 이벤트 처리 최적화를 위한 큐 &amp; 이벤트 지연 처리 전략에 대해</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[PJT] 1년 반 지난 코드 리팩토링하기]]></title>
            <link>https://velog.io/@frog_slayer/ziglog-refactor</link>
            <guid>https://velog.io/@frog_slayer/ziglog-refactor</guid>
            <pubDate>Fri, 18 Apr 2025 17:48:12 GMT</pubDate>
            <description><![CDATA[<p>저번 달에 1차 <a href="https://dev-trail.github.io/code-complete/">코드 컴플리트 2 스터디</a>를 마친 기념으로, 1년 반 동안 미뤄뒀던 코드 리팩토링을 해보자.</p>
<ol>
<li>별도의 기능 추가는 없지만, 알고도 넘어갔던 버그는 짚고 넘어가자</li>
<li>프로젝트 규모 상 굳이 서비스 별로 서버를 나누지는 않는다.<ul>
<li>동일한 모놀리식 서버가 2대(이상) 있고, 그 앞단에는 로드밸런서가 하나 있다고 가정하자. </li>
</ul>
</li>
<li>코드 변경 후 일어날 수 있는 버그 가능성도 무시한다.</li>
</ol>
<h1 id="1-실시간-알림-서비스">1. 실시간 알림 서비스</h1>
<p>프로젝트가 끝난 직후에도 많이 신경 쓰이긴 했던 부분으로, 특정 글을 북마크하거나 인용하면  해당 글의 저자에게 실시간 알림을 보내는 서비스다. SSE와 카프카를 사용해 구현했다.</p>
<h3 id="q-왜-sse">Q. 왜 SSE?</h3>
<p>SSE는 HTTP 기반, 서버에서 클라이언트로의 단방향 이벤트 스트리밍 기술이다. 다음의 이유로 SSE를 선택했다.</p>
<blockquote>
<ol>
<li>폴링은 부담스럽다. </li>
<li>웹소켓으로 지속적으로 양방향으로 데이터를 주고받을 필요가 없다.</li>
<li>간단히 페이지 변경 시 요청/응답을 주고 받는 식으로 구현할 수도 있지만, 맛이가 없다.</li>
</ol>
</blockquote>
<p>SSE를 사용할 때의 가장 큰 장점은 클라이언트에서 서버와의 연결이 끊어지면 자동 재연결 시도를 한다는 점이다.</p>
<blockquote>
<p>SSE를 사용하는 경우, 브라우저는 일정 시간 이상 메시지가 오지 않으면 자동으로 재연결을 시도한다. 이때 서버가 살아있다고 답이 오면 연결을 유지하고, 서버가 죽었으면 지수적으로 간격을 늘리면서 재연결을 시도한다.</p>
</blockquote>
<p>반대로 가장 큰 단점은 서버에서는 클라이언트의 상태를 모니터링하기 힘들다는 것이다. 주기적으로 하트비트를 보내주는 방식으로 가능하기는 하겠지만, 그렇게 하지 않으려고 SSE를 쓰는 거라 비용이 좀 크다. </p>
<p>또 하나 큰 단점이 있다면 SSE를 사용하면서 OSIV를 켜면 DB 커넥션 풀 고갈 문제가 발생할 수도 있다는 점이다. </p>
<ol>
<li>요청이 들어오면 톰캣은 해당 연결 처리를 위한 스레드를 생성</li>
<li>OSIV가 켜져 있으므로, 뷰에 들어가면서 영속성 컨텍스트가 시작. DB 커넥션도 빌림.</li>
<li>SSE 객체를 만듦. 이후 서버에서 이벤트를 보낼 수 있도록 연결을 계속 유지.</li>
<li>OSIV로 인해 응답을 완료할 때까지 DB 커넥션은 끊기지 않게 됨.</li>
<li>하지만 SSE 연결로 응답 완료 상태가 되지 못함.</li>
</ol>
<p>SSE를 쓰면 OSIV는 꼭 끄도록 하자.</p>
<h3 id="q-왜-카프카를-선택했을까">Q. 왜 카프카를 선택했을까?</h3>
<p>서버에서 클라이언트에게 메시지를 보내려면 클라이언트의 (SSE)연결 요청 후 해당 클라이언트에게 메시지를 보내기 위한 <code>SseEmitter</code> 객체를 만들어야 한다. </p>
<p>여러 서버가 있고, 어떤 사용자가 북마크/인용을 했다고 치자. 서버가 여러 대 있으므로, 북마크/인용 요청이 들어간 서버에는 해당 글 저자의 <code>SseEmitter</code> 객체가 있을 수도 있고, 없을 수도 있다.</p>
<ol>
<li>있으면? (오류가 없으면) 글의 저자에게 실시간 알림이 간다.</li>
<li>없으면? 없네? 못 보낸다</li>
</ol>
<p>따라서 북마크/인용 이벤트가 일어나면, 해당 이벤트가 일어났음을 다른 서버에도 알릴 필요가 있다. 사실 이 기능만을 위해서라면 Redis Pub/Sub으로도 충분하다. 하지만 문제는 앞서의 이벤트가 발생하면 다음의 두 작업을 마쳐야 하면서 발생한다.</p>
<ol>
<li>알림 내역을 DB에 저장: 저자가 온라인이 아닐 때, 이후 로그인 시 알림을 받아올 수 있도록 저장.</li>
<li>SSE로 알림을 전송</li>
</ol>
<p>이때 DB저장의 우선 순위는 알림 전송보다 낮다. 물론 DB 저장도 빠트리면 안되겠지만, 알림이 가능하다면 최대한 빠르게 실시간으로 처리하고, DB는 조금 늦어도 상관없다.</p>
<p>DB 저장과 SSE 알림 전송을 한 메서드로, 예를 들어 다음과 같이 묶는다고 해보자.</p>
<pre><code class="language-java">@대충_어떤_이벤트_리스너
void func(메시지 m) throws 뭐시기예외{
    DB저장(m);//여기서 실패하면? DB에 넣을 수 있을 때까지 알림 발송 불가 
    SSE전송(m);//여기서 실패하면? DB에는 들어갔는데 알림은 불가능 
}</code></pre>
<p>DB에 문제가 발생하면, 문제가 해결할 때까지 알림 서비스도 멈춘다. 반대의 경우라면?</p>
<pre><code class="language-java">@대충_어떤_이벤트_리스너
void func2(메시지 m) throws 뭐시기예외{
    SSE전송(m);//여기서 실패하면? 그냥 망함
    DB저장(m);//여기서 실패하면? 알림은 가지만, DB에는 저장되지 않음 
}</code></pre>
<p>Redis Pub/Sub을 쓰면 메시지를 가져오는 순간 저장소에서 제거하므로 DB에는 저장할 수 없다. 물론 예외가 발생하면 같은 메시지를 다시 넣는 방법도 가능하겠지만, 그럼 중복 알림을 보내게 될 수도 있고, 무엇보다 브로드캐스트 방식이라 DB에 두 번 들어갈 수도 있다(중복 체크 로직이 추가돼야 함). </p>
<p>이때 이벤트 리스너를 다음과 같이 둘로 분리해주면 SSE 전송 실패와 DB 저장 실패가 서로 영향을 주지 않게 된다.</p>
<pre><code class="language-java">@대충_전송용_리스너
void func3(메시지 m) throws 전송실패예외{
    SSE전송(m);
}

@대충_저장용_리스너
void func4(메시지 m) throws 저장실패예외{
    DB저장(m);
}</code></pre>
<p>카프카의 경우 한 토픽에 대한 컨슈머 그룹을 두 개 만들면, 각 컨슈머 그룹에서, 딱 한 명의 컨슈머가 이벤트를 가져갈 수 있다. 물론 자동으로 <code>ack</code>를 보내면 각 컨슈머가 가져가는 순간 오프셋이 옮겨가므로 <code>ack</code>는 성공하는 경우 수동으로 보내야 한다.</p>
<pre><code class="language-java">@대충_전송용_리스너
void func3(메시지 m) throws 전송실패예외{
    SSE전송(m);
    ACK();
}

@대충_저장용_리스너
void func4(메시지 m) throws 저장실패예외{
    DB저장(m);
    ACK();
}</code></pre>
<p>그런데 서버1에는 원하는 <code>SseEmitter</code>가 있고 서버2에는 없다고 하자. 메시지를 서버2가 가져가면 처리에 실패하고 <code>ACK()</code>를 호출하지 않는다. 서버2에 에미터가 있기는 한데 연결이 끊어진 상태였다면? 처리해줘야 한다.</p>
<pre><code class="language-java">@대충_전송용_리스너
void func3(메시지 m) throws 전송실패예외{
    if (에미터있음) {
        if (타임아웃아님) {
            try {
                SSE전송(m);
                ACK();
            }
            catch (전송실패예외) {
                //아마도 일시적인 네트워크 오류일걸?
            }
        }
        else {//연결 끊어진 상태
            에미터삭제
            ACK();
        }
    }
}

@대충_저장용_리스너
void func4(메시지 m) throws 저장실패예외{
    DB저장(m);
    ACK();
}</code></pre>
<blockquote>
<p>카프카에서는 기본적으로 파티션-컨슈머를 1:1 매핑하므로, 2개의 파티션, 2개의 컨슈머(서버1, 2)인 경우 각 파티션은 각 서버의 컨슈머에 매핑되고, 메시지 이관이 자동으로 일어나지는 않는다. 따라서 전송에 실패하고 메시지를 소화하지 못하는 경우, 영원히 소화 못하게 될 수도 있다...</p>
</blockquote>
<blockquote>
<p><code>SseEmitter</code>의 경우 타임아웃 여부를 확인할 수 있는 플래그 같은 게 따로 없기 때문에 별도의 래퍼 클래스를 만들어줘야 하긴 할 것 같다.</p>
</blockquote>
<h3 id="q-괜찮은-선택이었을까">Q. 괜찮은 선택이었을까?</h3>
<p>빠르고 좋기는 한데, 사실 과한 선택이다.</p>
<ol>
<li>오랫동안 메시지 로그를 유지할 필요가 없음.<ul>
<li>네트워크 문제로 인한 실패를 대비하기 위해서 어느 정도의 내구성이 필요하기는 하지만, 영구적으로 저장할 내용은 DB에 알아서 저장하고, 알림은 전송에 성공하기만 하면 따로 저장할 필요가 없음</li>
</ul>
</li>
<li>아래 영상처럼 엄청나게 많이 쌓인 로그를 필터링하고 보내줘야 하는 경우라면 적절할 수도 있겠지만, 이 프로젝트에서는 100만 명의 사용자가 있다 하더라도 초당 수만번 이상의 이벤트가 일어나는 일은 없을 것 같다.  <iframe width="560" height="315" src="https://www.youtube.com/embed/bzJJt4YdhgI?si=Tl62hEl5hb-FW0Ef" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

</li>
</ol>
<p>비교는 해봐야 하겠지만, 어느 정도는 durable하면서도 가벼운 MQ가 있다면 그걸 사용할 수도 있을 것 같다.</p>
<h1 id="2-인터페이스-분리">2. 인터페이스 분리</h1>
<p>알림 서비스의 인터페이스는 다음과 같은데</p>
<pre><code class="language-java">public interface NotificationService {  

    NotificationListDto getNotificationList(Member member);

    List&lt;Notification&gt; getNotifications(Member member);  

    void readNotification(Member member, String notificationId) throws InconsistentNotificationOwnerException, AlreadyRemovedNotificationException;  

    Notification getVerified(Member member, String notificationId) throws InconsistentNotificationOwnerException, AlreadyRemovedNotificationException;

    void delete(Member member, String notificationId) throws AlreadyRemovedNotificationException, InconsistentNotificationOwnerException;  

    SseEmitter subscribe(Member member) throws Exception;  
    void sendMessage(Long id, Object event) throws Exception;

    void consumeKafkaEvent(NotificationKafkaDto notificationKafkaDto, Acknowledgment ack) throws Exception;  

    void saveKafkaEventIntoRDB(NotificationKafkaDto notification, Acknowledgment ack) throws Exception;  
}</code></pre>
<p>사실 다음과 같이 세 종류로 나눌 수 있다</p>
<ol>
<li>RDB의 알림 테이블과 상호작용하는 부분</li>
<li>SSE 관련</li>
<li>카프카 컨슈머 부분</li>
</ol>
<p>그럼 대충 <code>NotificationRdbService</code>, <code>SseManagerService</code>, <code>NotificationKafkaConsumer</code>로 별도의 인터페이스를 나누고, <code>NotificationService</code>를 이 세 인터페이스를 상속하는 인터페이스로 만드는 게 낫지 않을까?</p>
<pre><code class="language-java">public interface NotificationService {  

    /**
        RDB에 저장된 알림 정보 관련
    */
    NotificationListDto getNotificationList(Member member);

    List&lt;Notification&gt; getNotifications(Member member);  

    void readNotification(Member member, String notificationId) throws InconsistentNotificationOwnerException, AlreadyRemovedNotificationException;  

    void delete(Member member, String notificationId) throws AlreadyRemovedNotificationException, InconsistentNotificationOwnerException;  

    Notification getVerified(Member member, String notificationId) throws InconsistentNotificationOwnerException, AlreadyRemovedNotificationException;

    /**
        SSE 연결/메시지 발송
    */
    SseEmitter subscribe(Member member) throws Exception;  
    void sendMessage(Long id, Object event) throws Exception;

    /**
        카프카 이벤트 소비
    */
    void consumeKafkaEvent(NotificationKafkaDto notificationKafkaDto, Acknowledgment ack) throws Exception;  

    void saveKafkaEventIntoRDB(NotificationKafkaDto notification, Acknowledgment ack) throws Exception;  
}</code></pre>
<p>그런데 이렇게 나누면 전부 다 세부적인 구현에, 구체적으로 어떤 기술을 사용할 것인지에 의존하게 되는 느낌이다. 만약 SSE가 아니라 다른 걸로 바꾼다면? 카프카가 아닌 다른 MQ로 바꾼다면?</p>
<p>구현보다, 각 인터페이스 어떤 기능을 하는지를 생각해보자.</p>
<ol>
<li>RDB 알림 테이블과의 상호 작용</li>
<li>사용자에게 실제로 알림을 보내는 기능(SSE? 웹소켓? 구현체는 고를 수 있음)</li>
<li>메시지 소비(카프카? RabbitMQ? 모르겠지만 구현체 선택하도록)</li>
</ol>
<p>그럼 인터페이스를 나누면 대충 이렇게 되지 않을까</p>
<pre><code class="language-java">public interface NotificationRdbService {
    /**
        RDB에 저장된 알림 정보 관련
    */
}

public interface NotificationPushService {
    /**
         클라이언트와의 연결/메시지 발송
    */
}

public interface NotificationMessageConsumer {
    /**
        메시지 소비
    */
}

public interface NotificationService extends NotificationRdbService, NotificationPushService, NotificationMessageConsumer {
}</code></pre>
<p>구현도 물론 각각 해줘야 할 텐데,</p>
<pre><code class="language-java">@Service
public class NotificationRdbServiceImpl implements NotificationRdbService {
    //...
}

@Service
public class SseService implements NotificationPushService{
    //...
}

@Service
public class NotificationKafkaConsumer implements NotificationMessageConsumer {
    //...
}</code></pre>
<p>각각 사용할 기술과 구현에 따라 인터페이스를 구현하게 하고</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class NotificationFacade implements NotificationService {
    private final NotificationRdbService rdbService;
    private final NotificationPushService pushService;
    private final NotificationMessageConsumer messageConsumer;

    @Override
    public NotificationListDto getNotificationList(Member member) {
        return rdbService.getNotificationList(member);
    }

    //...
}</code></pre>
<p>세부 로직은 내부 서비스에 위임하게 하면 깔끔한 거 같다.</p>
<h1 id="3-알림-발송-부분은">3. 알림 발송 부분은?</h1>
<p>북마크 서비스 구현을 보면 아래와 같이 북마크 추가 시 알림을 보내게 했다. </p>
<pre><code class="language-java">public class BookmarkServiceImpl implements BookmarkService {  
    //...
    @Override  
    public void addBookmark(Member member, AddBookmarkRequestDto requestDto) throws UserNotFoundException, NoteNotFoundException,BookmarkAlreadyExistsException{  
        Long noteId = requestDto.getNoteId();  
        Note note = noteRepository.findNoteById(noteId).orElseThrow(NoteNotFoundException::new);  
        Member memberPersist = memberRepository.findById(member.getId()).orElseThrow(UserNotFoundException::new);  

        List&lt;Bookmark&gt; bookmarkList = bookmarkRepository.findAllByMember(memberPersist);  

        Bookmark checkExists = bookmarkList.stream().filter(bookmark -&gt; bookmark.getNote().getId().equals(noteId)).findAny().orElse(null);

        if (checkExists != null) throw new BookmarkAlreadyExistsException();  

        Bookmark bookmark = Bookmark.builder()  
                            .member(memberPersist)  
                            .note(note)  
                            .build();  
        bookmark = bookmarkRepository.save(bookmark);  
        memberPersist.getBookmarks().add(bookmark);  

        sendBookmarkNotification(memberPersist, note);  
    }

    //...

    private void sendBookmarkNotification(Member sender, Note note){  
        //대충 카프카 메시지 발송
    }

    //...
}</code></pre>
<p>고쳐야 할 부분들이 너무 많다. 일단은</p>
<ol>
<li><code>void addBookmark()</code>의 파라미터로 들어오는 <code>Member member</code>는 스프링 시큐리티의 인증 객체에 들어가 있는 객체다. 액세스 토큰에서 추출한 이메일로 DB를 조회해 얻은 사용자 엔티티이며, <code>detached</code> 상태에 있다.<ul>
<li>여기서는 ID로 DB를 조회해 영속 상태 객체를 또 찾아냈지만, <code>em.merge()</code>로 충분히 영속성 컨텍스트에 넣을 수 있다. 내부적으로는 ID 비교를 하겠지만.</li>
</ul>
</li>
<li>N+1 문제가 발생한다. 프로젝트를 할 땐, &#39;아~ 당연히 쿼리 더 날려야지~&#39; 했지만... 처리가 필요하다.</li>
<li>이미 북마크로 추가한 경우 예외를 던지도록 했지만, 꼭 그렇게 하지 않고 리턴을 하기만 해도 좋을 것 같다. 별로 큰 일이 아닌데 예외를 던지는 건 비용이 너무 크다.</li>
</ol>
<p>같은 문제들이 보이지만 제쳐두자. 한 가지 문제점이 분명하다. </p>
<blockquote>
<p>북마크 추가 메서드는 자기 책임 이상의 기능을 처리하고 있다.</p>
</blockquote>
<ol>
<li>북마크 추가는 인터페이스로 드러나는 기능이고,</li>
<li>북마크 알림은 드러나지 않는 기능인데,</li>
<li>명확히 별도의 서비스로 처리될, 알림 기능을</li>
<li><strong>북마크 추가에서 암묵적으로 진행하고 있다.</strong></li>
</ol>
<p>분리가 필요하다. 어떻게 해야할까?</p>
<ol>
<li>이벤트만 발행한다<ul>
<li>지금이 이 방식인데, 부수 효과를 숨겨놓은 방식이라 흐름이 명시적이지 않다.</li>
</ul>
</li>
<li>알림 서비스에 메시지 퍼블리셔 인터페이스를 추가하고, 북마크 서비스가 알림 서비스를 의존하게 만든다.<ul>
<li>간단하기는 하지만, 북마크와 알림 사이에 기능적으로 명확한 협력이 필요할까? 잘 모르겠다. 알림은 좀 더 부가적인 느낌이다.</li>
</ul>
</li>
<li>이전에 인터페이스를 분리한 것과 마찬가지의 방식을 사용한다.<ul>
<li>이벤트 발행을 별도의 인터페이스로 두고,</li>
<li>북마크 인터페이스와 파사드</li>
<li>깔끔하고 일관성 있어 보이기는 하지만, 한 메서드에서, 그것도 한 줄만 호출되는 메서드를 위해서? 더 복잡해지지는 않을까?</li>
</ul>
</li>
</ol>
<p>일단은 고민 거리로 남겨두고, 마지막으로 다음을 보자.</p>
<pre><code class="language-java">private void sendBookmarkNotification(Member sender, Note note){ 
    if (note.getAuthor().getId().equals(sender.getId())) return;  

        String id = sender.getId() + &quot;_&quot; + note.getAuthor().getId() + &quot;_&quot; + UUID.randomUUID();  

        Notification notification = Notification.builder()  
                    .id(id)  
                    .type(NotificationType.BOOKMARK)  
                    .receiver(note.getAuthor())  
                    .sender(sender)  
                    .note(note)  
                    .title(note.getTitle())  
                    .isRead(false)  
                    .build();  

        kafkaTemplate.send(&quot;sse&quot;, NotificationKafkaDto.toDto(notification)); 
}</code></pre>
<blockquote>
<p>흠...</p>
</blockquote>
<p>RDB에 저장할 알림 객체를 생성하고, 카프카로 보내기 위한 DTO로 변환해 프로듀싱하는 부분이다.</p>
<p>여기서 문제는 </p>
<ol>
<li><code>Notification</code> 엔티티의 필드를 빌더 패턴으로 다 집어 넣고 있는데, <ul>
<li>현재 코드 흐름에 적절한 추상화 수준이 아니다. 알림 타입만 별개로, <code>Member, Note</code>를 생성자 파라미터로 넣는 게 훨씬 깔끔해 보인다.<ul>
<li>지금 보니 <code>id</code> 필드를 굳이 만들어 줄 필요도 없었는데, 왜 저렇게 했을까? 알 수 없다.</li>
</ul>
</li>
</ul>
</li>
</ol>
<pre><code class="language-java">Notification notification = new Notification(NotificationType.BOOKMARK, sender, note);</code></pre>
<ol start="2">
<li>단순히 <code>kafkaTemplate.send()</code>로 이벤트를 보내는 방식 대신, 여기도 따로 인터페이스를 만들고 구현체를 따로 주입하는 편이 나아보인다. <ul>
<li>구현이 아닌 인터페이스에 의존하도록 하자.</li>
</ul>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Network] 4-1. Overview of Network Layer]]></title>
            <link>https://velog.io/@frog_slayer/Network-4-1</link>
            <guid>https://velog.io/@frog_slayer/Network-4-1</guid>
            <pubDate>Wed, 16 Apr 2025 09:17:00 GMT</pubDate>
            <description><![CDATA[<p>네트워크 계층은 프로토콜 스택 중 가장 복잡한 계층이다. 네트워크 계층은 데이터 평면(data plane)과 제어 평면(control plane)이라는, 서로 상호작용하는 두 부분으로 나뉠 수 있다.</p>
<ul>
<li>데이터 평면: 실제 데이터 패킷 전달을 수행</li>
<li>제어 평면: 네트워크 경로 결정(라우팅) 및 정책 설정</li>
</ul>
<p>호스트 H1에서 H2로 정보를 보낸다고 하자. </p>
<ol>
<li>H1의 네트워크 계층에서는 전송 계층에서 세그먼트를 받아 데이터그램으로 캡슐해, 가까운 라우터 R1으로 보낸다.</li>
<li>H2는 가까운 라우터 R2로부터 데이터그램을 받아 세그먼트를 추출하고 전송 계층으로 올려 보낸다.</li>
</ol>
<h2 id="1-forwarding-and-routing-the-data-and-control-planes">1. Forwarding and Routing: The Data and Control Planes</h2>
<p>네트워크 계층에서 하는 일은 간단히 말해, 패킷을 한 호스트에서 다른 호스트로 옮기는 일이다. 이를 위해 필요한 네트워크 계층 기능으로는 두 가지, 포워딩과 라우팅이 있다. 포워딩과 라우팅을 섞어서 말하는 경우도 많지만 여기서는 좀 더 명확하게 구분하도록 한다.</p>
<ol>
<li><strong>포워딩</strong>(Forwarding)<ul>
<li>한 라우터가 인풋 링크로 들어온 패킷을 적절한 아웃풋 링크로 내보내는 것.</li>
<li>수 나노초에 일어나기 때문에, 보통은 하드웨어로 구현된다.</li>
<li>예를 들어 R1은 H1으로부터 받은 패킷을 R2로의 경로 상에 있는 다음 라우터로 포워딩해야 한다.</li>
<li>악의적인 송신자로부터의 패킷이나, 금지된 수신자로의 패킷을 차단하기도 한다.</li>
<li>혹은 패킷을 복사하고 여러 아웃고잉 링크로 보내기도 한다.</li>
</ul>
</li>
<li><strong>라우팅</strong>(Routing)<ul>
<li>소스에서 목적지로의 패킷 경로를 결정하는 것.</li>
<li>수 초 정도의 시간이 걸리기 때문에, 보통은 소프트웨어로 구현된다.</li>
<li>이 경로 계산을 위해 쓰이는 알고리즘을 라우팅 알고리즘이라 한다.</li>
</ul>
</li>
</ol>
<p>네트워크 라우터의 핵심은 각 라우터 안에 있는 <strong>포워딩 테이블</strong>(forwarding table)이다. 라우터는 자신에게 도착한 패킷 헤더의 필드를 분석하고, 그 값을 포워딩 테이블의 인덱스로 사용한다. 이때 포워딩 테이블의 엔트리에는 패킷이 다음으로 보내져야 할 라우터로 향하는 아웃고잉 링크 인터페이스가 담겨 있다. 그렇다면 이 포워딩 테이블은 어떻게 채울까?</p>
<h3 id="control-plane-the-traditional-approach">Control Plane: The Traditional Approach</h3>
<p>전통적인 방식에서는 한 라우터 안에 제어 평면과 데이터 평면이 모두 포함되어 있다. 즉 각 라우터는 포워딩 기능은 물론, 라우팅을 위한 라우팅 알고리즘도 내장되어 있다. 각 라우터는 다른 라우터와 라우팅 정보를 담은 메시지를 교환하고, 그 정보를 바탕으로 라우팅 알고리즘을 통해 각 테이블을 채운다.</p>
<h2 id="control-plane-the-sdn-approach">Control Plane: The SDN Approach</h2>
<p>전통적 방식에서는 한 라우터에 데이터 평면과 제어 평면이 모두 포함되어 있었다면, SDN(Software-defined Networking) 방식에서 라우터는 데이터 평면만을 담당하고, 제어는 별도의 외부 컨트롤러가 관리한다.</p>
<p>제어 평면을 각 라우터에 분산시키는 전통적인 방식과 달리, 이 방식에서는 제어 평면을 별도의 중앙 집중형 컨트롤러에 두고 라우터는 단순히 이 컨트롤러의 명령에 따라 포워딩만 한다. 이 컨트롤러는 ISP나 그 외 서드 파티가 관리하는 데이터 센터에서 구현되어, 소프트웨어를 기반으로 포워딩 테이블을 구성해 각 라우터에게 뿌려주는 역할을 한다.</p>
<h2 id="2-network-service-model">2. Network Service Model</h2>
<p>네트워크 계층이 어떤 서비스를 제공할 수 있을지에 대해 간략히 알아보자.</p>
<ul>
<li>전달 보장: 소스에서 보낸 패킷이 반드시 목적지에 도착함을 보장</li>
<li>지연 한도를 가진 전달 보장: 시간 내에 목적지에 도착함을 보장</li>
<li>순서 보장 전달: 원래 전송된 순서대로 목적지에 도착하도록 보장</li>
<li>최소 대역폭 보장: 패킷을 손실 없이, 일정 비트레이트를 보장하며 전달</li>
<li>보안: 네트워크 계층에서 모든 데이터그램을 암/복호화함으로써 전송 계층 세그먼트의 기밀성 제공</li>
</ul>
<p>단 인터넷 외의 다른 네트워크 구조에서는 다양한 서비스 모델이 구현되어 있지만, 실제 인터넷에서는 하나의 서비스, 최선 노력(Best-effort) 서비스만을 제공한다. 이 서비스에서는</p>
<ul>
<li>패킷이 보낸 순서로 도착할 것이라는 보장도</li>
<li>지연 시간에 대한 보장도,</li>
<li>최소 대역폭에 대한 보장도,</li>
<li>심지어 도착할 것이라는 보장도</li>
</ul>
<p>없다. 인터넷에서는 패킷을 최선을 다해 전달하지만, 그 결과를 보장하지 않는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Network] 3-6. Congestion Control]]></title>
            <link>https://velog.io/@frog_slayer/Network-3-6</link>
            <guid>https://velog.io/@frog_slayer/Network-3-6</guid>
            <pubDate>Wed, 16 Apr 2025 09:15:03 GMT</pubDate>
            <description><![CDATA[<p>네트워크가 혼잡하면 송신자가 보낸 패킷이 수신자에게 너무 늦게 도착하거나, 혹은 전송 도중에 네트워크 상에서 사라져버릴 수도 있다. 이런 네트워크 혼잡 문제를 해결하기 위한 제어를 <strong>혼잡 제어</strong>(congestion control)이라 하는데, 우선은 보다 일반적인 맥락에서 혼잡 제어 문제를 다뤄보고, 그 다음에 TCP에서의 혼잡 제어에 대해 알아보자.</p>
<h1 id="1congestion-control-in-general">1.Congestion Control (in general)</h1>
<h3 id="1-the-causes-and-the-costs-of-congestion">(1) The Causes and the Costs of Congestion</h3>
<p>여러 개의 시나리오를 보면서 어떤 경우에 네트워크 혼잡이 발생하는지, 또 그 결과는 어떻게 되는지 알아보자. </p>
<ol>
<li>시나리오 1
두 명의 송신자 A, B가 각각 연결된 수신자 C, D에게 데이터를 보낸다고 하고, 다음과 같이 가정하자.</li>
</ol>
<ul>
<li>A와 B의 애플리케이션은 모두 $\lambda_{in}$의 속도로 데이터를 보낼 수 있다.</li>
<li>신뢰성 있는 데이터 전송을 위한 메커니즘 및, 전송 계층과 그 이하 계층의 헤더 오버헤드 등은 없다고 가정한다.  </li>
<li>A, B는 출발지와 목적지 사이에 있는 한 홉을 공유한다.</li>
<li>무한한 크기의 버퍼를 가지고 있는 라우터가 하나 있다.</li>
<li>라우터의 아웃고잉 링크의 대역폭은 R이다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/a9d2cfbd-40f0-42f2-a507-5a5ac6433ed5/image.png" alt=""></p>
<p>A, B가 동시에 데이터를 보낸다고 하자. 공유된 링크 하나를 서로 나눠 쓰게 될 것이므로 처리량은 R/2를 넘지 못한다. 이때 호스트의 송신 속도($\lambda_{in}$)를 생각해보면,</p>
<ul>
<li>송신 속도가 R/2보다 느리면, C, D는 송신하는 족족 수신할 수 있다.</li>
<li>송신 속도가 R/2보다 빠른 경우 공유되는 링크는 곧 가득차게 된다. 호스트 A, B가 라우터가 처리할 수 있는 속도 R보다 빠르게 패킷을 보내므로, 라우터 버퍼에는 패킷이 무한히 쌓이게 되고, 처리량도 R/2를 넘지 못하게 된다.</li>
</ul>
<p>두 호스트가 너무 빠르게 데이터를 보내는 경우, 라우터에도 패킷이 무한히 쌓이면서 지연 또한 무한히 증가한다. 라우터의 버퍼 크기가 무한하다고 가정했기 때문에 패킷 손실이 일어나지는 않지만, 지연은 매우 크다.</p>
<ol start="2">
<li>시나리오 2
이번에는 위와 같기는 하지만, 라우터의 버퍼가 유한하고 TCP처럼 라우터에서 드롭되어 손실된 패킷은 재전송한다고 가정하자. 애플리케이션이 소켓에 데이터를 집어 넣는 속도는 $\lambda_{in}$이지만, 전송 계층에서 네트워크로 세그먼트(재전송 포함)를 내보내는 속도는 $\lambda&#39;_{in}$이라 하자. 이를 네트워크에 대한 <strong>제공 부하</strong>(offered load)라 부르기도 한다. </li>
</ol>
<ul>
<li>라우터 버퍼에 여유 공간이 있는지를 알고, 손실이 일어나지 않도록 속도를 조절하는 경우<ul>
<li>애플리케이션의 처리량이 $\lambda&#39;<em>{in} = \lambda</em>{in}$이 되고, 지연도 낮다. 단 평균 처리량이 R/2를 넘지는 못한다.</li>
<li>하지만 실제로는 불가능하다.</li>
</ul>
</li>
<li>패킷 손실이 확실한 경우에만 재전송<ul>
<li>긴 타임아웃 간격을 두는 방식 등으로 손실을 감지한다.</li>
<li>라우터 버퍼의 크기가 작아 패킷이 손실되는 경우 재전송한다.</li>
<li>제공 부하 $\lambda&#39;_{in}$에는 새로 보내는 세그먼트 + 재전송 세그먼트가 모두 있기에, 수신자 애플리케이션의 처리량은 R/2에 미치지 못한다.</li>
</ul>
</li>
<li>너무 일찍 타임아웃해, 아직 라우터 버퍼에 대기 중인 패킷을 재전송하는 경우<ul>
<li>한 세그먼트의 원본 + 재전송본이 모두 수신자에게 도착할 수 있다. 수신자는 재전송 패킷은 무시하기 때문에 불필요한 낭비다.</li>
</ul>
</li>
</ul>
<ol start="3">
<li>시나리오 3
마지막으로는 네 명의 호스트가 서로 교차하는 두 홉의 경로를 따라 패킷을 전송한다고 해보자. 각 호스트는 타임아웃/재전송 방식으로 신뢰성 있는 데이터 전송 서비스를 구현하고 있고, $\lambda_{in}$ 값은 동일하다. 모든 라우터 링크의 용량은 R바이트/초다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/a0ab8f3f-63b4-43cc-a064-a3a35679f108/image.png" alt=""></p>
<p>A에서 C로 데이터를 보낸다고 하자. 이 연결은 R1, R2라우터를 거치는데, R1에서는 D-B 연결과, R2에서는 B-D 연결과의 트래픽 경쟁이 일어난다. </p>
<ul>
<li>$\lambda_{in}$ 가 충분히 작은 경우<ul>
<li>버퍼 오버플로가 드물게 일어나고, 처리량도 $\lambda_{in}$에 근접해진다.</li>
<li>작은 $\lambda_{in}$의 경우 $\lambda_{in}$가 증가함에 따라 $\lambda_{out}$도 증가하게 된다.</li>
</ul>
</li>
<li>$\lambda_{in}$이 너무 큰 경우<ul>
<li>R1에서 D-B 연결과의 경쟁이 일어나므로 패킷 드롭이 발생한다.</li>
<li>이후 R2에서도 B-D 연결과의 경쟁이 일어나는데, 이미 한 번 경쟁을 거친 A-C 트래픽의 비율은 B-D보다 작을 수 밖에 없다. </li>
<li>R2 버퍼에 B-D 트래픽이 더 많이 쌓이게 되고, A-C는 더 많이 드롭된다. </li>
<li>재전송과 패킷 드롭이 잦아지면서 처리량이 뚝 떨어지게 된다.</li>
</ul>
</li>
</ul>
<h3 id="2-approaches-to-congestion-control">(2) Approaches to Congestion Control</h3>
<p>그럼 인터넷은 어떻게 네트워크 혼잡을 제어할까? 일반적으로 혼잡 제어는 네트워크가 제공하는 정보를 바탕으로 종단 시스템, 즉 송수신자가 실행한다. 일반적으로는 다음의 네 방식을 사용한다.</p>
<ol>
<li><p>종단 시스템 기반 혼잡 제어. 네트워크 피드백 없음
IP에서는 네트워크 혼잡에 대한 피드백을 제공하지 않기 때문에, TCP에서는 이 방식을 사용해야 한다. 송신자는 패킷 손실, 긴 RTT, ACK 지연 등을 바탕으로 혼잡이 발생했다고 간주한다. 혼잡이 발생했다고 판단되면 송신 속도를 줄이고, 그렇지 않으면 송신 속도를 점진적으로 늘린다.</p>
</li>
<li><p>종단 시스템 기반 혼잡 제어. 명시적 피드백
라우터 등의 네트워크 장치가 혼잡 상태를 감지하고, 이에 대한 명시적인 신호를 송신자에게 전달한다. 이 신호를 받은 송신자는 혼잡을 줄이기 위해 송신 속도를 낮춘다.</p>
</li>
<li><p>네트워크 기반 혼잡 제어. 종단 시스템 개입 없음
라우터가 혼잡을 감지하고 직접 제어한다. 예를 들면 혼잡 상태에서 특정 패킷을 드롭하거나, 큐잉 지연을 줄이기 위해 패킷을 미리 드롭하는 식이다.</p>
</li>
</ol>
<p>일반적으로는 혼잡 회피(congestion avoidance)라 부르는 방식으로, 송신자는 네트워크 내부에서 어떤 일이 일어나는지 모르고, 송신자의 행위와 무관하게 네트워크 장비가 스스로 혼잡을 관리한다.</p>
<ol start="4">
<li>네트워크와 종단 시스템이 함께 혼잡 제어
송신자는 네트워크 장치가 주는 정보를 바탕으로 적절히 송신 속도를 조절한다. 네트워크 장치도 혼잡 제어에 참여한다. 복잡하기는 하지만 가장 효과적일 수도 있다고 여겨진다. </li>
</ol>
<h1 id="2-tcp-congestion-control">2. TCP Congestion Control</h1>
<p>앞서 봤듯 TCP는 종단 시스템 기반의 혼잡 제어 메커니즘을 사용한다. 기본 아이디어는 간단하다.</p>
<blockquote>
<p> 빡빡하면 줄이고, 널널하면 늘린다.</p>
</blockquote>
<p>그럼 다음의 세 질문이 자연스럽게 나온다.</p>
<ol>
<li>TCP 송신자는 어떻게 트래픽 속도를 제한할까? (메커니즘)</li>
<li>TCP 송신자는 어떻게 네트워크 혼잡을 감지할까?</li>
<li>TCP 송신자는 어떤 속도로 데이터를 보내야 할지 어떻게 결정할까? (정책)</li>
</ol>
<p>우선 첫 번째부터 보자. </p>
<h2 id="1-tcp-송신자는-어떻게-트래픽-속도를-제한할까">(1) TCP 송신자는 어떻게 트래픽 속도를 제한할까?</h2>
<p>송신자는 한 번에 보낼 수 있는 데이터량을 조절함으로써 간접적으로 데이터 전송 속도를 제한한다. </p>
<p>TCP의 송/수신자는 각각 수신 버퍼, 송신 버퍼, 그 외의 여러 변수들을 가지고 있다. 이전에 봤던 흐름 제어에서 수신자의 수신 버퍼 윈도우(<code>rwnd</code>)크기를 바탕으로 송신자의 송신 윈도우 크기를 조절했던 것과 같이, 혼잡 제어에서는 네트워크 혼잡도를 바탕으로 송신자의 송신 윈도우 크기를 조절한다. </p>
<p>TCP는 네트워크 혼잡도에 따라 송신 윈도우 크기를 조절하기 위해 추가적인 변수로 혼잡 윈도우(<code>cwnd</code>)를 추적하며, 혼잡 윈도우는 TCP 송신자가 네트워크로 보내는 트래픽의 속도를 제한하는 제약으로 작용한다.</p>
<p>구체적으로 송신 윈도우의 크기는 수신자의 수신 윈도우를 초과하면 안되고, 혼잡 윈도우도 초과해서는 안되기 때문에, 두 값의 최솟값으로 설정된다.</p>
<pre><code>LastByteSent - LastByteAcked &lt;= min(cwnd, rwnd)</code></pre><p>간단히 혼잡 윈도우만 신경 쓴다고 가정하자. 송신자는 <code>cwnd</code> 바이트의 데이터를 연결에 전송할 수 있고, RTT 후에 그에 대한 확인 응답을 받는다. 전송 속도의 측면에서 보면 $\frac{\text{cwnd}}{\text{RTT}} \text{byte/sec}$로 속도를 제한한다고 볼 수 있다.</p>
<blockquote>
<p><strong>Q. 엥? 데이터 전송 시간만 보면 되니까 RTT의 절반 쯤 아닌가?</strong>
A. 한 번 보내기만 하는 데 걸리는 시간은 RTT/2 쯤 되지만, TCP에서는 <code>ACK</code>을 받아야 다음 데이터를 보낼 수 있다. 다시 말해 한 번 <code>cwnd</code>만큼 데이터를 보냈으면, 그에 대한 <code>ACK</code>를 받아야만 다음 <code>cwnd</code>만큼의 데이터를 보낼 수 있다. 때문에 1RTT 동안 보낼 수 있는 데이터량이 <code>cwnd</code>로 제한된다고 본다. </p>
</blockquote>
<h2 id="2-tcp-송신자는-어떻게-네트워크-혼잡을-감지할까">(2) TCP 송신자는 어떻게 네트워크 혼잡을 감지할까?</h2>
<p>TCP 송신자는 패킷 손실을 네트워크 혼잡의 신호로 여긴다. 혼잡이 발생하면 소스-목적지 경로 상에 있는, 적어도 한 라우터의 버퍼가 오버플로우되고 데이터그램이 드롭되기 때문이다.</p>
<p>패킷 손실을 감지하는 방법은 이전에도 많이 봤다. 타임 아웃이 발생하거나, 중복 <code>ACK</code>을 세 번 이상 받는 경우다.</p>
<p>반대로 보낸 세그먼트들에 대한 <code>ACK</code>이 잘 도착한다면, 네트워크가 널널한 상태라고 판단할 수 있다. 이 경우 <code>ACK</code>을 받을 때마다 혼잡 윈도우를 늘린다. <code>ACK</code>를 빠르게 받으면 혼잡 윈도우도 빠르게 증가할 것이고, 느리게 받으면 혼잡 윈도우도 천천히 증가할 것이다.</p>
<blockquote>
<p>TCP는 이렇게 <code>ACK</code>을 바탕으로 혼잡 윈도우 크기를 늘리기에, self-clocking 방식이라고도 한다. </p>
</blockquote>
<h2 id="3-tcp-송신자는-어떤-속도로-데이터를-보내야-할지-어떻게-결정할까">(3) TCP 송신자는 어떤 속도로 데이터를 보내야 할지 어떻게 결정할까??</h2>
<p>TCP 송신자가 너무 빠르게 전송 속도를 올리면 네트워크는 금방 혼잡해질 것이고, 그렇다고 너무 느리게 보내면 네트워크 대역폭을 제대로 활용하지 못하게 된다. 따라서 최대한 빠르게, 그렇다고 혼잡을 일으키지 않을 정도의 속도로 데이터를 보내야 한다.</p>
<p>그렇다면 TCP 송신자는 어떻게 전송 속도를 결정할까? TCP는 다음과 같은 원칙을 따른다.</p>
<ul>
<li>손실된 세그먼트는 혼잡을 의미한다. 송신자는 손실 발생 시 전송 속도를 줄인다.</li>
<li><code>ACK</code>는 네트워크 상태가 좋음을 의미한다. 이 경우 전송 속도를 올려도 좋다. </li>
<li>대역폭 탐색(Bandwidth probing): 네트워크 혼잡 상태를 직접 알 수는 없고, 위와 같은 긍정적/부정적 피드백으로 추측만 할 수 있다.</li>
</ul>
<p>어떤 경우에 전송 속도를 증감시킬지는 알게 됐다. 그럼 얼마나 전송 속도를 증감시켜야 할까? 그 표준 알고리즘([RFC 5681])에 대해 알아보자. 이 알고리즘은 아래의 세 구성 요소를 가지고 있는데, 이 중 느린 시작과 혼잡 회피는 필수, 빠른 복구는 권장 사항이다.</p>
<ol>
<li>느린 시작: 일단 하나부터 시작해서 2배씩 늘려 보기</li>
<li>혼잡 회피: 어느 정도 늘렸으니 천천히 늘리기</li>
<li>빠른 복구: 너무 많이는 말고, 반 정도로만 줄이고 선형적으로 늘리기.</li>
</ol>
<p>이때 감지하는 이벤트 종류에는 세 가지가 있다. 대충 이렇게 반응한다 생각하자.</p>
<ol>
<li>타임아웃: 네트워크 터졌음. 망했음. </li>
<li><code>cwnd &gt;= ssthresh</code>: 이 정도면 조금만 늘려도 될 듯.</li>
<li>셋 이상의 중복 <code>ACK</code> 수신: 혼잡하긴 하지만 그렇게 심하진 않음.</li>
</ol>
<h3 id="3-1-느린-시작slow-start">(3-1) 느린 시작(Slow Start)</h3>
<p>TCP 연결이 시작될 때 <code>cwnd</code>는 1 MSS로 설정된다. 전송 속도의 측면에서 본다면 MSS/RTT다. 단 실제 사용할 수 있는 대역폭이 MSS/RTT보다는 훨씬 클 수 있기 때문에, TCP 송신자는 가능한 한 빨리 사용 가능한 대역폭을 알아내야 한다.</p>
<p>느린 시작에서는 <code>cwnd</code>가 1MSS에서 시작해,전송된 세그먼트에 대한 <code>ACK</code>을 받을 때마다 1MSS 씩 증가하고, 결과적으로는 아래와 같이 지수적으로 늘어난다.</p>
<ol>
<li>세그먼트 1개 보냄</li>
<li>세그먼트 1개 <code>ACK</code> -&gt; 혼잡 윈도우 + 1MSS = 2MSS</li>
<li>세그먼트 2개 보냄</li>
<li>세그먼트 2개에 대한 <code>ACK</code> -&gt; 혼잡 윈도우 + 2MSS = 4MSS</li>
<li>세그먼트 4개 보냄</li>
<li>...</li>
</ol>
<blockquote>
<p><strong>Q. 엥? TCP는 누적 <code>ACK</code>이라 1MSS씩만 늘어나야 하는 거 하는 거 아닌가요?</strong>
A. 실제로 받는 <code>ACK</code>는 누적 <code>ACK</code> 하나지만, 그 앞의 세그먼트들도 <code>ACK</code>을 받았다고 가정합니다.</p>
</blockquote>
<p>물론 무한히 늘릴 수는 없고 언젠가 끝나기는 해야 하는데, 다음과 같은 경우에 끝이 난다.</p>
<ol>
<li>타임아웃 발생 시: <code>ssthresh = cwnd/2, cwnd = 1</code>로 설정하고 느린 시작으로 전환한다.</li>
<li><code>cwnd</code>가 <code>ssthresh</code>값 이상이 되는 경우: 느린 시작을 종료하고 혼잡 회피 모드로 전환한다.</li>
<li>셋 이상의 중복 <code>ACK</code> 수신: <code>ssthresh = cwnd/2, cwnd = ssthresh + 3</code>으로 설정하고 빠른 회복 상태로 전환한다.</li>
</ol>
<h3 id="3-2-혼잡-회피congestion-avoidance">(3-2) 혼잡 회피(Congestion Avoidance)</h3>
<p>혼잡 회피 상태에서 <code>cwnd</code>는 마지막으로 혼잡이 일어났을 때의 절반이다. <code>cwnd</code>를 확 늘리면 곧 혼잡이 발생할 수 있으므로, 조금은 보수적으로, 이제는 RTT마다 1MSS만큼만 늘린다. 일반적으로는 <code>ACK</code>를 받을 때마다 <code>cwnd</code>를 <code>MSS/cwnd</code>만큼 증가시킨다.</p>
<p>혼잡 회피는 다음과 같은 시점에 끝난다.</p>
<ol>
<li>타임아웃 발생 시: <code>ssthresh = cwnd/2, cwnd = 1</code>로 설정하고 느린 시작으로 전환한다.</li>
<li>셋 이상의 중복 <code>ACK</code> 수신: <code>ssthresh = cwnd/2, cwnd = ssthresh + 3</code>으로 설정하고 빠른 회복 상태로 전환한다.</li>
</ol>
<blockquote>
<p><strong>Q. 3 MSS는 왜 더하냐고요. 예?</strong>
A. </p>
</blockquote>
<h3 id="3-3-빠른-회복fast-recovery">(3-3) 빠른 회복(Fast Recovery)</h3>
<p>빠른 회복에서는 네트워크가 어느 정도 안정기에 들어섰다고 가정하고 다시 좀 더 빠르게 높여 본다.</p>
<ol>
<li>손실 세그먼트 중복 <code>ACK</code> 수신: <code>cwnd</code>를 1 MSS씩 증가시킨다.</li>
<li>손실 세그먼트 정상 <code>ACK</code> 수신:  어느 정도 네트워크 혼잡이 해결되었다고 판단. <code>cwnd = ssthresh</code>로 설정하고 혼잡 회피 상태로 전환해 <code>cwnd</code>를 천천히 늘린다.</li>
<li>타임아웃: <code>cwnd</code>는 1 MSS, <code>ssthresh</code>는 손실 당시의 절반으로 설정한다.  </li>
</ol>
<h2 id="4-평가">(4) 평가</h2>
<p>연결 초기의 느린 시작과 드물게 일어나는 타임아웃을 제외하면, TCP는 대부분 선형적으로 윈도우 크기를 늘려나가다가 중복 <code>ACK</code>이 세 번 들어오면 윈도우 크기를 반으로 줄이는 방식으로 이루어진다. 때문에 종종 TCP 혼잡 제어는 <strong>AIMD</strong>(Additive increase/Multiplicative decrease) 방식이라고도 불린다.</p>
<blockquote>
<p><strong>AIMD</strong>
선형적으로 윈도우 크기를 1씩 늘리다가, 패킷 손실이 일어나면 반으로 줄이는 방식</p>
</blockquote>
<h3 id="4-1-공정성">(4-1) 공정성</h3>
<p>K개의 TCP 연결이 하나의 병목 링크를 공유한다고 하자. 각 연결은 큰 파일을 전송하고 있고, 이 K개 TCP 연결 트래픽 이외의 트래픽은 없다. 만약 혼잡 제어 메커니즘이 공정하다면, 각 연결의 평균 전송 속도는 R/K여야 한다. </p>
<p>K개 TCP 연결의 MSS와 RTT가 모두 동일하다고 하자. 초기의 느린 시작/타임아웃을 제외하고, TCP가 AIMD 방식으로만 동작한다고 하자. AIMD는 공정한 알고리즘일까? </p>
<ol>
<li>각 연결이 선형적으로 혼잡 윈도우 크기를 늘려나간다. (RTT마다 1MSS)</li>
<li>그러다가 각 연결이 사용하는 대역폭의 합이 R을 초과하면 패킷 손실이 발생한다.</li>
<li>각 연결은 윈도우 크기를 반으로 줄인다.</li>
<li>다시 윈도우 크기를 선형적으로 늘려나간다.</li>
</ol>
<p>위 과정을 반복할 때, K개 연결은 링크의 대역폭 R을 공정하게 나누어 갖게 된다.</p>
<p>물론 여기서는 병목 링크에 K개 TCP 연결 외 다른 연결이 없고, 모든 연결이 같은 RTT, MSS를 가지고 있다고 가정했기 때문에 현실적이지는 않다. RTT가 작은 연결들이 더 빠르게 대역폭을 차지하고, 더 높은 전송 속도를 얻는 등, 대역폭이 균등하게 분배되지는 않는다.</p>
<h3 id="4-2-공정성과-udp">(4-2) 공정성과 UDP</h3>
<p>UDP의 경우에는 혼잡 제어 기능이 없기 때문에, 네트워크가 혼잡한지 그렇지 않은지 신경 쓰지 않고 일정한 속도로 계속 전송하려 한다. 때문에 UDP는 네트워크 상태에 따라 패킷 손실이 일어날 가능성이 높고, 공정하지도 않으며, TCP 트래픽을 밀어낼 수도 있기 때문에 전체 네트워크에 혼잡을 가져올 수도 있다.</p>
<h3 id="4-3-공정성과-병렬-tcp-연결">(4-3) 공정성과 병렬 TCP 연결</h3>
<p>TCP 연결만 사용한다고 해도 공정성 문제는 남아있다. TCP 애플리케이션이 여러 개의 병렬 연결을 사용할 수 있기 때문이다.</p>
<p>예를 들어 대역폭 R의 링크에 9개의 TCP 연결이 있다고 하자. 이때 어떤 웹 브라우저가 병렬 TCP 연결을 통해 11개의 연결을 만들면, 이 애플리케이션 하나가 반 이상의 대역폭을 차지할 수 있게 된다. TCP 연결만 이용해도 공정성을 해치는 꼼수를 부릴 수는 있다. </p>
<h2 id="5-explicit-congestion-notification-ecn-network-assisted-congestion-control">(5) Explicit Congestion Notification (ECN): Network-assisted Congestion Control</h2>
<p>앞서 TCP는 종단 시스템 기반 혼잡 제어라 했다. 기본적으로는 그렇지만 사실 IP, TCP에 대한 확장 기능도 배포되어 있는데, 이 기능은 네트워크 장치가 TCP 송수신자에게 혼잡 상태를 명시적으로 알릴 수 있게 해준다. 이러한 방식을 <strong>명시적 혼잡 알림</strong>(ECN)이라 한다.</p>
<p>TCP와 IP가 모두 이 기능에 관여하는데 하나씩 살펴보자.</p>
<h3 id="5-1-ip-계층에서의-동작">(5-1) IP 계층에서의 동작</h3>
<p>네트워크 계층에서는 IP 데이터그램 헤더의 TOS 필드 내에 있는 두 비트를 ECN 용도로 사용한다.</p>
<table>
<thead>
<tr>
<th>비트</th>
<th>0</th>
<th>1</th>
<th>2</th>
<th>3</th>
<th>4</th>
<th>5</th>
<th>6</th>
<th>7</th>
</tr>
</thead>
<tbody><tr>
<td>의미</td>
<td>DS</td>
<td>DS</td>
<td>DS</td>
<td>DS</td>
<td>DS</td>
<td>DS</td>
<td>ECN</td>
<td>ECN</td>
</tr>
<tr>
<td>- <code>DS</code>: 패킷 우선 순위, 서비스 품질 제어 등</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- <code>ECN</code>: 송수신자가 모두 ECN을 사용할 수 있는 경우 <code>01</code> 또는 <code>10</code>으로 설정됨</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- <code>00</code>: ECN 미지원</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- <code>01</code>: ECN 사용 가능</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- <code>10</code>: ECN 사용 가능</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- <code>11</code>: 혼잡 발생</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<p>라우터에서 혼잡이 감지되는 경우, <code>ECN</code> 비트가 <code>11</code>로 설정된다. 수신자는 이 데이터그램을 받았을 때 네트워크가 혼잡하다는 것을 알고,  네트워크 혼잡 신호를 송신자에게 다시 전달할 수 있다. </p>
<p>다만 언제 라우터가 혼잡 상태라 파악해야하는지에 대한 공식적인 기준이 따로 정해져 있지는 않고, 라우터 벤더나 네트워크 운영자가 정한다.</p>
<h3 id="5-2-tcp-계층에서의-동작">(5-2) TCP 계층에서의 동작</h3>
<p>TCP 수신자는 <code>ECN = 11</code>로 설정된 데이터그램을 받았을 때, <code>ACK</code> 세그먼트의 <code>ECE</code> 비트를 1로 설정해서 송신 호스트에게 응답을 보낸다.</p>
<p>송신 호스트는 <code>ECE = 1</code>인 <code>ACK</code>을 받으면 혼잡 윈도우 크기를 반으로 줄이고, 다음으로 보내는 TCP 세그먼트의 헤더의 <code>CWR</code> 비트를 설정해 혼잡 윈도우를 줄였음을 알린다.</p>
<blockquote>
<p>TCP 이외에도 ECN 신호를 사용할 수 있는 전송 계층들이 있다.</p>
<ul>
<li>DCCP(Datagram Congestion Control Protocol): 혼잡 제어를 제공하는 UDP</li>
<li>DCTCP(Data Center TCP): 데이터센터 환경에서 사용되는 TCP의 변형 버전</li>
</ul>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Network] 3-5.  Connection-Oriented Transport - TCP (2)]]></title>
            <link>https://velog.io/@frog_slayer/Network-3-5-2</link>
            <guid>https://velog.io/@frog_slayer/Network-3-5-2</guid>
            <pubDate>Wed, 16 Apr 2025 09:11:25 GMT</pubDate>
            <description><![CDATA[<h1 id="4-reliable-data-transfer">4. Reliable Data Transfer</h1>
<p>인터넷의 네트워크 계층에는 패킷이 순서대로, 손실 없이 전달될 것이라는 보장이, 심지어는 패킷이 전달될 것이라는 보장도 없다. 그런데 TCP는 그 위에서 신뢰성 있는 데이터 전송을, 그러니까 패킷이 손상없이, 빠짐없이, 중복 없이, 순서대로 도착될 것임을 보장한다.</p>
<p>이전 글에서는 전송되었지만 아직 <code>ACK</code>을 받지 못한 각 세그먼트에 개별적인 타이머가 연결되어 있다고 가정했는데, 이 방식은 잘 동작하기는 하지만 타이머 관리를 위한 상당한 오버헤드가 발생할 수 있다. 따라서 TCP에서는 보통 여러 개의 세그먼트가 아닌, 하나의 세그먼트(전송/비확인)에 대해서만 재전송 타이머를 사용한다.</p>
<p>그렇다면 어떻게 TCP는 신뢰성 있는 데이터 전송을 제공할까?</p>
<h3 id="간단한-모델">간단한 모델</h3>
<p>일단은 타임아웃만을 사용하는 간단한 모델을 살펴보자. TCP 송신자는 다음의 세 주요 이벤트를 확인하고 처리해야 한다.</p>
<ol>
<li>상위 애플리케이션 계층으로부터의 데이터 수신<ul>
<li>데이터를 수신하고, 세그먼트로 캡슐화해 IP로 전달한다. </li>
<li>각 세그먼트의 <code>SEQ</code>에는  세그먼트 내 첫 번째 데이터 바이트의 바이트 스트림 번호가 들어간다.</li>
<li>타이머가 동작 중이 아닌 경우, 이 세그먼트를 IP로 전달할 때 타이머를 시작한다.</li>
</ul>
</li>
<li>타임 아웃<ul>
<li>타임아웃 이벤트가 발생하면 TCP는 해당 세그먼트를 재전송하고 타이머를 재시작한다.</li>
</ul>
</li>
<li><code>ACK</code> 수신<ul>
<li>수신 측으로부터 <code>ACK</code> 세그먼트를 받으면 <code>ACK</code> 번호를 윈도우의 <code>SendBase</code>와 비교한다. </li>
<li>TCP에서는 누적 <code>ACK</code> 방식을 사용하므로 송신 측에서 <code>ACK</code> 번호 <code>y</code>를 받으면 <code>y</code> 이전까지의 모든 바이트를 수신했음을 알 수 있다.  </li>
<li><code>y &gt; SendBase</code>를 받은 송신자는 윈도우를 옆으로 옮기고, 여전히 확인되지 않은 세그먼트가 있으면 타이머를 재시작한다.</li>
</ul>
</li>
</ol>
<h3 id="빠른-재전송fast-retransmit">빠른 재전송(Fast Retransmit)</h3>
<p>타임아웃 기반 재전송에는 <strong>타임아웃 시간 자체가 너무 길 수 있다</strong>는 단점이 있다. 세그먼트가 손실된 경우 송신자는 이 긴 시간동안 패킷 재전송을 위해 대기해야하고, 결론적으로는 지연이 증가하게 된다. 이를 해결하기 위한 방법 중 하나가 <strong>빠른 재전송</strong>으로, 중복 <code>ACK</code> 감지를 통해 타임아웃 이벤트 발생 전에 패킷 손실을 감지하는 방법이다.</p>
<p>TCP 수신자는 다음과 같은 경우 <code>ACK</code>를 보낸다.</p>
<ol>
<li>예상했던 번호의 세그먼트가 도착한 경우.<ul>
<li>또 그 다음으로 올 세그먼트가 도착할 수 있으므로 최대 500ms 기다리고, 더 오지 않으면 <code>ACK</code> 전송</li>
<li>만약 그 다음 세그먼트가 도착하면, 누적 <code>ACK</code>을 즉시 전송</li>
</ul>
</li>
<li>기대한 것보다 높은 번호의 세그먼트가 도착한 경우<ul>
<li>데이터 스트림에 갭이 발생했음을 알 수 있음</li>
<li>누락된 바이트의 시퀀스 번호를 표시한 <code>ACK</code>를 전송(즉 마지막으로 제대로 순서대로 수신한 바이트에 대한 <code>ACK</code>을 재전송!)</li>
</ul>
</li>
<li>수신된 데이터가 갭을 일부/전부 채움<ul>
<li>해당 세그먼트가 갭이 발생한 시작 부분인 경우일 때에 한해 <code>ACK</code> 즉시 전송</li>
</ul>
</li>
</ol>
<p>이때 송신자는 여러 개의 연속된 세그먼트를 전송할 수도 있다. 이렇게 연속적으로 보낸 세그먼트 중 하나가 손실되면, 그 이전 세그먼트에 대한 여러 개의 <code>ACK</code>이 중복될 수도 있다. 송신자는 이렇게 동일한 <code>ACK</code>이 세 번 중복되면 해당 세그먼트가 손실되었다는 신호로 간주하고 재전송한다.</p>
<blockquote>
<p>Q. 중복 <code>ACK</code>을 한 번 받았을 때 재전송하면 안될까? 왜 세 번일까?
A. 세그먼트가 정말로 손실됐을 가능성이 높다고 판단하기 위해 중복 <code>ACK</code> 한 번은 부족하다.</p>
<p>예를 들어 하나의 중복 <code>ACK</code>이 오면 세그먼트를 재전송한다고 하자. 송신자는 1, 2, 3, 4번 세그먼트를 보낸다.</p>
<p>수신자는 1번 세그먼트를 받고 <code>ACK = 2</code>를 보내고, 2번 세그먼트를 기다린다. 그런데 네트워크의 문제로 2번 세그먼트보다 3, 4번이 먼저 도착했다고 해보자. 순서가 잘못됐으니 수신자는 두 번 <code>ACK = 2</code>를 보낸다. </p>
<p>이때 만약 2번 세그먼트가 뒤늦게 수신자에게 도착했다면 송신자는 2번 세그먼트를 다시 보낼 필요가 없다. 하지만 중복 <code>ACK</code>를 하나만 받아도 수신자는 재전송하므로 수신자는 불필요한 2번 세그먼트를 재전송한다. </p>
<p>중복 <code>ACK</code> 세 번은 세그먼트가 손실됐을 가능성이 높다고 판단하기 위해 필요한, 경험적으로 최적으로 알려진 횟수(라고 한)다. </p>
</blockquote>
<h3 id="tcp는-gbn일까-sr일까">TCP는 GBN일까, SR일까?</h3>
<p>TCP에서 <code>ACyK</code>은 누적되고, 올바르게 수신되었지만 순서가 어긋난 세그먼트에 대해서는 개별적으로 <code>ACK</code>을 보내지 않는다. 이런 면에서 TCP는 GBN 스타일 프로토콜처럼 보인다.</p>
<p>TCP에서는 GBN과 달리 올바르게 수신되었지만 순서가 어긋난 세그먼트들을 버퍼링하고 이용할 수도 있다.</p>
<p>예를 들어 송신자가 1, 2, 3, 4, 5번 세그먼트를 보냈는데, 수신자는 1, 2, 4, 5만 받았다고 하자. 수신자는 3번이 비었음을 알고 <code>ACK 3</code>과 함께 <code>SACK</code>(selective <code>ACK</code>) 옵션을 이용해 4, 5번은 받았으니 3번만 다시 보내달라는 응답을 보낼 수 있다. </p>
<pre><code>// 2번까지는 제대로 받았고, 4, 5번도 제대로 받았음... 같은 형식으로
ACK 3 (SACK: 4, 5)</code></pre><p>결론은 TCP는 기본적으로 GBN처럼 동작하지만,  옵셔널하게는 SR처럼 동작하게 할 수도 있다.</p>
<h1 id="5-flow-control">5. Flow Control</h1>
<p>TCP에서는 송수신자가 모두 수신 버퍼를 가지고 있는데, 송신자가 수신자가 처리할 수 있는 양 이상으로 데이터를 보내는 경우 수신 버퍼에 오버플로가 발생할 수 있다. TCP는 이런 문제를 방지하기 위해 <strong>흐름 제어</strong> 서비스를 제공한다.</p>
<blockquote>
<p><strong>흐름 제어</strong>(flow control)
송신자가 데이터를 보내는 속도와 수신자가 데이터를 읽는 속도를 맞추는 서비스</p>
</blockquote>
<p>구체적으로는 <strong>수신 윈도우</strong>를 사용한다. 수신 윈도우는 수신자 버퍼에 남아 있는 여유 공간을 송신자에게 알려주는 것으로, 동적으로 시간에 따라 변한다.</p>
<p>호스트 A가 B에게 파일을 전송할 때, B는 수신 버퍼를 할당하고 B의 애플리케이션은 이 버퍼에서 데이터를 읽는다. 이때 사용되는 변수는</p>
<ul>
<li><code>LastByteRead</code>: 애플리케이션 프로세스가 읽은 데이터 스트림의 마지막 바이트 번호</li>
<li><code>LastByteRcvd</code>: 네트워크에서 수신된 데이터 스트림의 마지막 번호. 즉 수신 버퍼에 들어온 마지막 바이트 번호</li>
</ul>
<p>가 있다. 수신 버퍼가 넘지 않으려면 <code>LastByteRcvd - LastByteRead &lt;= RcvBuffer</code>가 되어야 하며, 따라서 수신 버퍼의 여유 공간인 수신 윈도우(<code>rwnd</code>)은 <code>rwnd = RcvBuffer - (LastByteRcvd - LastByteRead)</code>로 계산할 수 있다.</p>
<p>B는 매번 TCP 세그먼트의 수신 윈도우 필드에 <code>rwnd</code>의 값을 넣어 송신자에게 알려준다. 처음에는 <code>rwnd = RcvBuffer</code>로 설정되고, 이후 시간에 지남에 따라 변하는 값을 지속적으로 알려준다.</p>
<p>만약 수신 버퍼가 가득 차서 <code>rwnd = 0</code>이 된다고 하자. 이렇게 수신 버퍼가 가득 찬 상태에서 호스트 B는 데이터를 더 읽어야 하는데, A의 입장에서는 수신 버퍼가 언제 비게 될지 알지 못한다. 때문에 TCP에서는 A는 <code>rwnd = 0</code>을 받으면 세그먼트에 1바이트의 데이터를 포함시켜 보내고, 이에 대한 <code>ACK</code>에 새로운 <code>rwnd</code>가 들어오는 걸 보면 수신 버퍼에 여유가 생겼음을 알 수 있게 된다.</p>
<h1 id="6-tcp-connection-management">6. TCP Connection Management</h1>
<p>이제 TCP 연결이 어떻게 설정되고 해제되는지 더 자세히 알아보자. </p>
<h3 id="tcp-연결-수립3-way-핸드셰이크">TCP 연결 수립(3-way 핸드셰이크)</h3>
<p>늘 그렇듯 클라이언트는 연결 요청을 하는 쪽, 서버는 요청을 받는 쪽이다.</p>
<ol>
<li>클라이언트 TCP에서 서버 TCP에 <code>SYN</code> 세그먼트를 하나 보낸다.<ul>
<li>클라이언트의 상태가 <code>CLOSED</code>에서 <code>SYN_SENT</code>로 변한다.</li>
<li>헤더의 <code>SYN</code> 플래그를 1로 설정한다.</li>
<li>애플리케이션 계층 데이터는 포함되어 있지 않고, 무작위로 선택한 초기 시퀀스 번호(<code>client_isn</code>)를 시퀀스 번호 필드에 넣는다.</li>
</ul>
</li>
<li>서버는 <code>SYN</code> 세그먼트를 받으면 연결을 위한 버퍼와 변수를 할당하고, <code>SYNACK</code> 세그먼트로 응답한다.<ul>
<li>서버의 상태가 <code>LISTEN</code>에서 <code>SYN_RCVD</code>로 변한다.</li>
<li>헤더의 <code>SYN</code> 플래그와 <code>ACK</code> 플래그가 모두 1이다.</li>
<li><code>ACK</code> 번호 필드에는 <code>client_isn + 1</code>이 들어간다.</li>
<li><code>SYN</code> 번호 필드에는 서버 자신이 랜덤하게 정한 초기 시퀀스 번호(<code>server_isn</code>)가 들어간다.</li>
</ul>
</li>
<li><code>SYNACK</code>를 받은 클라이언트는 연결을 위한 버퍼/변수를 할당하고, 서버에 <code>ACK</code>를 보낸다.<ul>
<li>클라이언트의 상태가 <code>SYN_SENT</code>에서 <code>ESTABLISHED</code>로 변한다.</li>
<li><code>ACK</code> 플래그는 1, <code>SYN</code> 플래그는 0으로 설정된다.</li>
<li><code>SEQ</code>필드는 <code>client_isn + 1</code>, <code>ACK</code> 필드는 <code>server_isn + 1</code>로 설정된다.</li>
<li>이 세그먼트에는 클라이언트에서 서버 방향으로의 데이터가 포함될 수도 있다.</li>
<li>서버도 <code>ACK</code>를 받으면 상태가 <code>SYN_RCVD</code>에서 <code>ESTABLISHED</code>로 변한다.</li>
</ul>
</li>
</ol>
<p>위의 세 단계가 완료되고나면 클라이언트와 서버는 서로에게 데이터가 포함된 세그먼트를 보낼 수 있게 된다. </p>
<blockquote>
<p><strong>Q. 잘못된 포트로 연결 요청을 보내거나, 리슨 소켓의 백로그가 가득 찬 경우에는?</strong>
A. 잘못된 포트로 연결 요청을 보내는 경우 <code>RST</code> 플래그를 1로, 리슨 소켓 백로그가 가득 차서 더 이상 새 연결을 받을 수 없는 경우에는 <code>RST + ACK</code>을 1로 설정한다.   </p>
</blockquote>
<blockquote>
<p><strong>Q. 왜 초기 시퀀스 번호를 교환할까?</strong>
A. 신뢰성 있는 데이터 전송을 위해서는 시퀀스 번호가 필요하다. TCP는 전이중 통신으로, 양방향 통신이 가능하므로 서로의 ISN을 교환해야 한다.</p>
</blockquote>
<blockquote>
<p><strong>Q. 왜 랜덤한 초기 시퀀스 번호일까?</strong>
A1. ISN이 고정되어 있거나 예측 가능한 경우, 중간자의 세션 하이재킹이 쉬워진다. 공격자는 클라이언트가 보낸 패킷을 가로채고, 클라이언트인 척, 가짜 <code>SYN</code>, 가짜 <code>ACK</code>을 날릴 수 있따. 
A2. 이전에 비정상적으로 종료된 연결과 동일한 TCP 4-tuple(소스 IP/포트, 목적지 IP/포트)을 사용하는 경우, 새로 맺은 연결의 시퀀스 번호가 예전과 같거나 너무 가까우면 예전 연결의 지연된 패킷이 새로운 연결에 잘못 섞여 들어갈 위험이 있다(세그먼트 혼동 문제). </p>
</blockquote>
<blockquote>
<p><strong>Q. 왜 2-way가 아니라 3-way일까?</strong>
A1. 서버가 <code>SYNACK</code>를 보낸 후 클라이언트가 실제 응답 없이 사라질 수도 있다. 2-way에서는  불필요한 리소스 할당이 일어나게 된다.
A2. TCP는 양방향이다. 시퀀스 번호의 동기화가 완전히 이루어져야 한다.</p>
</blockquote>
<h3 id="tcp-연결-종료4-way-핸드셰이크">TCP 연결 종료(4-way 핸드셰이크)</h3>
<p>더 이상 연결이 필요하지 않으면 TCP 연결을 종료해야 한다. 종료는 두 프로세스 중 어느 쪽이든 먼저 시작할 수 있다. 우선 클라이언트가 연결 종료를 원한다고 해보자.</p>
<ol>
<li>클라이언트 애플리케이션 프로세스가 <code>close</code> 명령을 호출하면, 이 명령은 클라이언트 TCP가 서버에게 <code>FIN</code> 세그먼트를 보내게 한다.<ul>
<li>서버의 상태가 <code>ESTABLISHED</code>에서 <code>FIN_WAIT_1</code>로 변한다.</li>
<li>헤더의 <code>FIN</code> 플래그가 1이다.</li>
<li>애플리케이션 계층 데이터는 없다.</li>
</ul>
</li>
<li><code>FIN</code> 세그먼트를 받은 서버는 클라이언트에게 <code>ACK</code>를 보내고, 서버 애플리케이션에서의 종료 작업을 수행한다.<ul>
<li>서버의 상태가 <code>ESTABLISHED</code>에서 <code>CLOSE_WAIT</code>으로 변한다.</li>
<li><code>ACK</code>를 받은 클라이언트의 상태는 <code>FIN_WAIT_2</code>로 변한다.</li>
</ul>
</li>
<li>서버 애플리케이션이 정리되면 클라이언트에게 종료 세그먼트를 보낸다.<ul>
<li>서버 상태가 <code>CLOSE_WAIT</code>에서 <code>LAST_ACK</code>으로 변한다.</li>
<li>헤더 <code>FIN</code> 플래그가 1이다.</li>
</ul>
</li>
<li>클라이언트는 서버 종료 세그먼트에 대한 <code>ACK</code>를 보낸다.<ul>
<li>클라이언트의 상태가 <code>TIME_WAIT</code>으로 변한다. 이 <code>ACK</code>가 손실될 경우 재전송할 수 있도록 TCP 클라이언트에 시간을 주기 위함이다. 대기 시간(30초~2분 정도)이 지나면 클라이언트의 상태가 <code>CLOSED</code>로 변한다.</li>
<li><code>ACK</code>를 받은 서버는 <code>CLOSED</code> 상태로 변한다.</li>
</ul>
</li>
</ol>
<p>반대로 서버가 연결 종료를 원할 수도 있다. 서버가 <code>FIN</code> 세그먼트를 먼저 보낸다는 점만 제외하면 상태 전이나 전송되는 세그먼트나 동일하다.</p>
<h3 id="syn-flood-공격"><code>SYN</code> Flood 공격</h3>
<p>3-way 핸드셰이크에서 <code>SYN</code>을 받은 서버는 관련 리소스를 할당하고 <code>SYNACK</code>를 보낸다. 그런데 이때 클라이언트가 따로 <code>ACK</code>을 보내지 않으면 서버는 일정 시간이 지나고 나서야 해당 연결을 종료하고 자원을 회수한다.</p>
<p>그렇다면 공격자가 너무 많은 <code>SYN</code> 세그먼트를 보내기만 하면 어떻게 될까? 서버는 수많은 자원을 <strong>반쯤 열린 연결</strong>(half-open connection)에 할당하고, 정작 정상적인 클라이언트는 서비스를 이용할 수 없게 된다.</p>
<p>위와 같은 <code>SYN</code> Flood 공격을 막으려면 <code>SYN</code>을 받은 시점에는 리소스를 할당하지 않고, 완전히 연결이 수립됐을 때 리소스를 할당하면 된다. </p>
<p><code>SYN</code>을 받은 서버는 <code>SYN</code> 세그먼트의 소스/목적지 IP/포트, 서버만 알고 있는 비밀값 등을 이용한 해시 함수로 <code>server_isn</code>(<code>SYN</code>쿠키라고도 부른다)을 만들고, <code>SYNACK</code>의 <code>SEQ</code> 필드에 담아 보낸다. 이때 서버는 따로 자원 할당을 하거나 연결에 대한 상태 정보를 기억하지는 않는다.</p>
<p>정상적인 클라이언트는 <code>ACK</code>을 보내줄 것이고, <code>ACK</code>를 받은 서버는 이 <code>ACK</code>가 자신의 <code>SYNACK</code>에 대한 정보인지 확인한다.</p>
<ul>
<li><code>ACK</code> 세그먼트의 <code>ACK</code> 번호는 서버가 보낸 <code>server_isn</code>에 1을 더한 값이다.</li>
<li>서버는 동일한 해시 함수를 통해 쿠키를 재계산하고, 그 값에 1을 더한 값이 <code>ACK</code> 번호와 같다면 정상적인 연결임을 인식하고 실제로 연결을 생성한다.</li>
</ul>
<blockquote>
<p><strong>Q. 왜 굳이 해시 함수를 사용할까?</strong>
A. <code>SYN</code> Flood 공격 방지를 위해 어떠한 상태 정보도 저장하지 않기 때문이다. 만약 임의의 랜덤값을 배정한다면, 서버에서는 이 값을 또 어딘가에 저장해둬야 한다. 하지만 <code>SYN</code> 쿠키와 해시 함수를 사용하면 연결을 맺기 전에는 서버를 무상태적으로 유지할 수 있다.</p>
</blockquote>
<blockquote>
<p><strong>Q. 너무 늦게 <code>ACK</code>가 와버리면 어떨까?</strong>
A. 서버는 <code>SYNACK</code>를 보내고 상태를 유지하지 않는다. 그렇다면 클라이언트가 너무 늦게, 예를 들어 1시간 후에 <code>ACK</code>를 보내도 연결을 만들어줘야 할 수도 있다. 이런 상황을 방지하기 위해 서버는 <code>server_isn</code>을 만들 때, 해시값에 타임스탬프를 붙이는 식으로 만든다. 일종의 쿠키의 수명을 정하는 방식으로 생각할 수 있는데, 너무 늦게 도착한 클라이언트로 <code>ACK</code>으로 연결을 수립하는 것을 방지한다.</p>
</blockquote>
<blockquote>
<p><strong>Q. 따로 자원 할당을 하지 않는데 수신 윈도우의 크기는 어떻게 보낼까?</strong>
A. 따로 자원 할당은 하지 않지만, 수신 버퍼의 기본 크기는 알고 있다. 그냥 보내면 된다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Network] 3-5. Connection-Oriented Transport - TCP (1)]]></title>
            <link>https://velog.io/@frog_slayer/Network-3-5-1</link>
            <guid>https://velog.io/@frog_slayer/Network-3-5-1</guid>
            <pubDate>Wed, 16 Apr 2025 09:09:20 GMT</pubDate>
            <description><![CDATA[<p>TCP는 인터넷 전송 계층의, 연결 지향, 신뢰성 있는 데이터 전송 프로토콜이다. 이 섹션에서는 TCP의 에러 검출, 재전송, 누적 <code>ACK</code>, 타이머, <code>SEQ</code>/<code>ACK</code> 헤더 필드에 대해 알아본다.</p>
<h1 id="1-the-tcp-connection">1. The TCP Connection</h1>
<p>TCP 프로토콜은 <strong>연결 지향적 프로토콜</strong>로, 서로 통신할 두 프로세스는 실제 데이터를 주고 받기 전에 <strong>핸드셰이크</strong> 과정을 통해 연결을 수립해야 한다. 이 연결과정에서 두 프로세스는 이후 데이터 전송에서 필요할 매개 변수를 설정하는데, 연결을 마친 두 프로세스가 꼭 서로 물리적 회선으로 연결된 것만 같다는 의미에서 <strong>가상 회선</strong> 방식이라고도 부른다.</p>
<p>TCP의 연결에 대해 몇 가지를 짚고, 어떻게 연결이 이루어지는지 알아 보자.</p>
<ol>
<li>TCP 프로토콜은 종단 시스템에서만 실행된다.<ul>
<li>라우터, 스위치와 같은 중간 네트워크 요소는 TCP 연결을 전혀 알지 못하고, 그냥 데이터그램을 처리할 뿐이다. </li>
</ul>
</li>
<li>TCP는 전이중(full-duplex) 서비스를 제공한다.<ul>
<li>서로 TCP 연결을 맺은 두 서비스는 모두 서로에게 데이터를 주고 받을 수 있다.</li>
</ul>
</li>
<li>TCP 연결은 항상 점대점 연결이다.<ul>
<li>항상 한 송신자-한 수신자 사이에서만 연결이 이루어지며, 멀티캐스팅은 불가능하다.</li>
</ul>
</li>
</ol>
<p>이제 연결이 어떻게 이루어지는지 간단하게 보자. 연결을 시작하는 프로세스를 클라이언트, 나머지 한 쪽을 서버 프로세스라 하면,</p>
<ol>
<li>클라이언트 애플리케이션은 클라이언트 전송 계층에 서버 프로세스와의 연결 수립을 원한다고 알린다.</li>
<li>클라이언트 TCP는 서버 TCP와의 연결 수립을 위한 절차를 진행한다.<ol>
<li>클라이언트가 연결 요청 TCP 세그먼트를 보낸다.</li>
<li>서버는 요청에 대한 응답 TCP 세그먼트를 보낸다.</li>
<li>클라이언트가 응답에 대한 응답 TCP 세그먼트를 보낸다.</li>
</ol>
</li>
</ol>
<p>여기서 처음 두 TCP 세그먼트에는 페이로드가 담겨있지 않고, 세 번째 세그먼트에는 페이로드가 있을 수도 있다. 위와 같이 세 개의 세그먼트가 두 호스트 사이에서 오가기 때문에, 위 세그먼트 교환 과정을 흔히 <strong>3-way 핸드셰이크</strong>라 부른다.</p>
<p>이렇게 TCP 연결이 수립되고 나면 두 애플리케이션 프로세스는 서로에게 데이터를 보낼 수 있게 된다. 예를 들어 클라이언트가 서버에게 데이터를 보낸다고 해보자.</p>
<ol>
<li>클라이언트 애플리케이션은 소켓을 통해 데이터 스트림을 전달한다.</li>
<li>소켓을 통과한 데이터는 클라이언트 TCP의 송신 버퍼로 들어간다. 이 버퍼는 3-way 핸드셰이크 동안에 따로 마련된다.</li>
<li>TCP는 송신 버퍼에서 데이터를 조각내서(chunk) 캡슐화해 네트워크 계층에 전달한다.</li>
<li>수신자는 TCP 세그먼트를 받으면 데이터를 추출해 수신 버퍼에 넣는다.</li>
<li>수신자 애플리케이션은 수신 버퍼로부터 데이터 스트림을 읽는다.</li>
</ol>
<p>이때 버퍼링된 데이터를 언제 전송할지에 대해서는 프로토콜에 명시된 제한이 없고, 편의에 따라(in convenience) 전송하면 된다. </p>
<h1 id="2-tcp-segment-structure">2. TCP Segment Structure</h1>
<p>TCP에서는 데이터를 조각내고, 각 조각에 TCP 헤더를 붙여 TCP 세그먼트를 만든다. </p>
<p>이때 TCP 세그먼트에 담을 수 있는 최대 데이터량, 즉 세그먼트에 들어가는 데이터 조각의 최대 크기를 <strong>MSS</strong>(Maximum Segment Size)라 하는데, 다음과 같은 절차를 통해 정해진다.</p>
<ol>
<li>로컬 송신 호스트가 전송할 수 있는 가장 큰 링크-계층 프레임의 크기(MTU)를 찾음</li>
<li>데이터를 TCP 캡슐화, 이후 IP 캡슐화한 후에도 해당 크기 안에 들어갈 수 있도록 설정</li>
</ol>
<p>소스에서 목적지까지의 경로 상에 있는 모든 링크에서 전송 가능한 최대 크기 프레임(경로 MTU)를 찾아내고, 그 값을 기준으로 MSS를 설정할 수도 있다. </p>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/62228a02-d41d-40de-93d1-64479c2985a6/image.png" alt=""></p>
<p>UDP와 마찬가지로 상위 애플리케이션 계층을 식별하고 데이터를 멀티플렉싱/디멀티플렉싱하는 데 쓸 수 있는 포트 번호가 있고, 데이터 오류 확인을 위한 체크섬 필드도 있다. 이외에도 여러 필드들도 있는데 간단히 알아보자.</p>
<table>
<thead>
<tr>
<th>필드</th>
<th>길이(비트)</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>SEQ</code> 번호/ <code>ACK</code> 번호</td>
<td>32/32</td>
<td>송수신자 간 신뢰성 있는 데이터 전송 서비스 구현에 사용</td>
</tr>
<tr>
<td>수신 윈도우</td>
<td>16</td>
<td>흐름 제어에서 사용. 수신자가 수용 가능한 바이트 수를 표시</td>
</tr>
<tr>
<td>헤더 길이</td>
<td>4</td>
<td>실제 데이터가 어디서 시작하는지를 4바이트 단위로 표시. 보통은 옵션 헤더를 사용하지 않기 때문에 20바이트 = 5 * (4 바이트) =  <code>0101</code></td>
</tr>
<tr>
<td>플래그</td>
<td>6</td>
<td>- ACK: <code>ACK</code> 번호 필드가 유효함<br>- SYN/RST/FIN: 연결 설정 및 해제에 쓰임<br>- CWR, ECE: 명시적 혼잡 알림<br>- PSH: 수신자는 즉히 데이터를 상위 계층으로 전달<br>- URG: 긴급 데이터가 있음</td>
</tr>
<tr>
<td>옵션</td>
<td>가변</td>
<td>MSS 협상, 타임스탬프 옵션 등 추가 옵션이 필요한 경우</td>
</tr>
<tr>
<td>데이터</td>
<td>가변</td>
<td>실제 전달할 데이터</td>
</tr>
</tbody></table>
<h3 id="sequence-numbers-and-acknowledgment-numbers">Sequence Numbers and Acknowledgment Numbers</h3>
<p>TCP 세그먼트에서 가장 중요한 필드는 <code>SEQ</code> 번호 필드와 <code>ACK</code> 번호 필드로, 신뢰성 있는 데이터 전송 서비스 구현의 핵심이다. </p>
<p>TCP에서는 데이터를 순서가 있는 바이트 스트림으로 보는데, 세그먼트의 <code>SEQ</code>에는 해당 세그먼트에 들어가는 첫 번째 바이트의 바이트 스트림 상 번호가 들어간다.</p>
<blockquote>
<p>예를 들어 500,000 바이트 크기의 파일을 보낸다고 하자. MSS가 1,000바이트라 하면 파일은 500개의 세그먼트로 나뉠 수 있다. 첫 번째 바이트가 0번부터 시작한다고 가정하면, 첫 번째 세그먼트의 <code>SEQ</code>는 0, 두 번째는 1000, 세 번째는 2000, ...이 된다.</p>
</blockquote>
<p><code>ACK</code> 번호는 조금 더 까다로운데, TCP에서 <code>ACK</code>은 데이터 스트림 내에서 <strong>처음으로 누락된 바이트</strong>다. 다시 말해 호스트 A가 B에게 전송하는 세그먼트의 <code>ACK</code> 번호는 A가 B로부터 받기를 기대하는  바이트 번호를 의미한다. 예를 들어 A가 바이트 번호 0 ~ 535를 받고 536을 다음으로 받기를 원하는 경우, <code>ACK</code> 번호 필드에는 536이 들어간다. 데이터 스트림에서의 첫 누락 바이트의 번호이기 때문에 수신된 바이트 각각에 대해 <code>ACK</code>를 보내지는 않으며, 누적 <code>ACK</code> 방식으로 동작한다. </p>
<p>TCP에서 수신자가 순서에 어긋난 세그먼트를 받는 경우, 어떻게 세그먼트를 처리해야 하는지에 대한 규칙은 정해져있지 않다. 일반적으로는 다음의 두 개의 선택지가 있고,</p>
<ol>
<li>순서가 어긋난 세그먼트 즉시 버림(GBN).</li>
<li>수신자가 보관해뒀다가, 나중에 누락된 바이트가 오면 재구성(SR).</li>
</ol>
<p>네트워크 대역폭의 측면에서 두 번째 방법이 효율적이기 때문에, 실제 대부분의 구현에서는 이 방식을 사용한다.</p>
<p>위에서는 초기 시퀀스 번호(ISN)를 0으로 가정했지만, 실제로는 TCP 연결을 맺을 때 임의로 정한다. 이전에 종료된 연결에서 네트워크 어딘가에 세그먼트가 남아 있다가 나중에 같은 호스트끼리의 연결에서 유효한 세그먼트로 오인되는 상황을 방지하기 위함이다.</p>
<h1 id="3-round-trip-time-estimation-and-timeout">3. Round-Trip Time Estimation and Timeout</h1>
<p>TCP에서도 타임아웃/재전송 메커니즘을 사용한다. 그렇다면 타임아웃 간격은 얼마나 길어야 할까?타임아웃 간격이 1RTT보다 작으면 불필요한 재전송이 일어나므로 적어도 1RTT보다는 커야한다. 그렇다면 RTT는 어떻게 계산/추정할 수 있을까? 각 세그먼트마다 타이머를 따로 둬야 할까?</p>
<h3 id="estimating-the-round-trip-time">Estimating the Round-Trip Time</h3>
<ol>
<li>SampleRTT<ul>
<li>세그먼트를 네트워크 계층으로 내려보낸 시점에서 해당 세그먼트에 대한 <code>ACK</code>를 받은 시점 사이의 시간이다.</li>
<li>이때 전송된 모든 세그먼트에 대해 RTT를 측정하는 건 아니고, 대부분 한 번에 한 세그먼트에 대해서만 RTT를 측정한다. </li>
<li>재전송된 세그먼트에 대해서는 SampleRTT를 계산하지 않는다. </li>
<li>라우터가 혼잡하거나 종단 시스템 부하 변화가 일어나는 경우 세그먼트마다 변동이 발생할 수 있고, 대표성을 띄지 못할 수도 있다.</li>
</ul>
</li>
<li>EstimatedRTT<ul>
<li>전형적인 RTT를 추정하기 위해 SampleRTT의 평균을 구한 것.</li>
<li>정확한 평균치를 구하는 것은 아니고 최근 샘플에 더 많은 가중치를 두는, <strong>가중 평균값</strong>이다. 최근 샘플일수록 현재 네트워크 혼잡 상태를 더 잘 반영하기 때문이다.</li>
<li>아래와 같이 계산한다. <code>α</code>는 최신값에 부여하는 가중치에 해당하고, 보통은 0.125 정도를 추천한다고 한다.<pre><code>EstimatedRTT = (1 − α) ⋅ EstimatedRTT + α ⋅ SampleRTT</code></pre></li>
</ul>
</li>
<li>DevRTT<ul>
<li>RTT 평균뿐만 아니라, RTT의 변동성은 어떻게 되는지 측정하는 것도 중요한 일이다.</li>
<li>SampleRTT에 변동이 적으면 DevRTT는 작아지고, 변동이 커지면 DevRTT도 그만큼 커진다.</li>
<li>아래와 같이 계산하며, <code>β</code>는 최근 RTT 변동에 얼마나 민감하게 반응할지를 조절하기 위한 가중치로, 보통 0.25로 둔다.<pre><code>DevRTT = (1 − β) ⋅ DevRTT + β ⋅ |SampleRTT − EstimatedRTT|</code></pre></li>
</ul>
</li>
</ol>
<h3 id="타임아웃-간격-설정">타임아웃 간격 설정</h3>
<p>EstimatedRTT와 DevRTT 값이 주어졌다면, 타임아웃 간격은 어떻게 설정해야 할까?</p>
<p>일단 EstimatedRTT보다는 크거나 같아야 한다. 이보다 작으면 불필요한 재전송이 자주 발생할 수도 있기 때문이다. 반대로 너무 크면 손실 발생 시 세그먼트 재전송을 너무 늦게 시작하기 때문에 전송 지연이 발생한다. </p>
<p>때문에 타임아웃은 EstimatedRTT에 조금의 여유 마진을 더한 값으로 정한다. 이때 DevRTT 값이 활용되는데, SampleRTT의 변동성을 추적해, 값이 많이 흔들리면 마진을 크게, 조금 흔들릴 떄에는 마진을 작게 설정한다.</p>
<pre><code>TimeoutInterval = EstimatedRTT + 4 ⋅ DevRTT</code></pre><p>초기 타임아웃 간격으로는 보통 1초를 두고, EstimatedRTT, DevRTT 값을 가지고 조정을 해나가되, 타임아웃이 발생하면 재전송 후 타임아웃 간격을 두 배로 증가시키고, <code>ACK</code>를 받으면 기존대로 재계산한다.</p>
<blockquote>
<p><strong>Q. 왜 타임아웃이 발생했을 때 간격을 두 배로 증가시킬까?</strong> 
A. 타임아웃이 발생했다면 현재 네트워크가 느린 상태일 것이라 판단할 수 있다. 만약 이 상태에서 <code>TimeoutInterval</code> 조정 없이 그대로 재전송하면, 또 타임아웃이 발생하고, 또 재전송하고, ...., 계속해서 불필요한 재전송이 발생할 수 있다.</p>
<p>타임아웃이 발생했을 때 임시로 간격을 증가시키면 위와 같은 불필요한 재전송 문제를 해결할 수 있다. 이는 제한적인 형태의 <strong>혼잡 제어</strong>(congestion control)라 할 수 있다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Network] 3-4. Principles of Reliable Data Transfer (2)]]></title>
            <link>https://velog.io/@frog_slayer/Network-3-4-2</link>
            <guid>https://velog.io/@frog_slayer/Network-3-4-2</guid>
            <pubDate>Mon, 14 Apr 2025 14:22:22 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Computer Networking: A Top-Down Approach, 7th Edition의 번역 및 정리입니다.</p>
</blockquote>
<h1 id="1-pipelined-reliable-data-transfer-protocols">1. Pipelined Reliable Data Transfer Protocols</h1>
<p><code>rdt3.0</code>은 제대로 동작은 하지만, 성능이 그리 좋다고 할 수는 없다. 매번 패킷을 제대로 받았다는 응답이 왔을 때에야 패킷을 보내기 때문에, 한 패킷을 전부 보내고 다음 패킷을 보낼 때까지 1 RTT가 걸리기 때문이다. 그렇다면 어떻게 해야할까? 방법은 간단하다. <code>ACK</code>을 받기 전에 여러 개의 패킷을 보낼 수 있으면 된다. 이러한 테크닉을 <strong>파이프라이닝</strong>(pipelining)이라 한다.</p>
<ol>
<li>한 번에 여러 패킷을 보내니 시퀀스 번호의 범위를 더 넓혀야 한다. 이제 1비트 시퀀스 비트로는 충분하지 않다.</li>
<li>송수신자 모두 여러 개의 패킷을 버퍼링할 수 있어야 한다.</li>
<li>시퀀스 번호 범위와 버퍼링 크기는 데이터 전송 프로토콜의 손실/손상/지연 패킷 대응 전략에 따라 달라진다. 기본적인 전략에는 Go-Back-N과 selective repeat이 있다.</li>
</ol>
<h1 id="2-go-back-ngbn">2. Go-Back-N(GBN)</h1>
<p>GBN 프로토콜에서는 송신자가 보낼 수 있는 최대 패킷의 개수를 N개로 제한한다. 즉 송신자는 한 번에 최대 N개의 패킷을 보낼 수 있고, 송신자의 입장에서는 시퀀스 번호를 다음과 같이 네 영역으로 나눠 볼 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/fe12f25a-69e7-4cf8-815e-9f30ba49456a/image.png" alt=""></p>
<p>GBN은 아래와 슬라이딩 윈도우 방식으로 동작한다. 때문에 GBN을 그냥 슬라이드 윈도우 방식이라 하기도 하고, N을 윈도우 크기라고도 한다. </p>
<ol>
<li>송신자는 N개의 패킷을 순차적으로 보낸다</li>
<li><code>ACK</code>을 받으면 <code>base</code>를 <code>ACK + 1</code>로 보낸다. </li>
<li><code>base</code>를 옮겼을 때 윈도우 범위 내에 아직 보내지 않은 패킷이 있다면 추가로 전송한다.</li>
</ol>
<blockquote>
<p><strong>Q. 왜 굳이 패킷 수를 N개로 제한하는 걸까?</strong> 
A. 나중에 TCP 흐름 제어/혼잡 제어에서 다룹니다.</p>
</blockquote>
<p>실제 구현에서 패킷 시퀀스 번호는 패킷 헤더의 고정 길이 필드에 담겨 있다. 시퀀스 번호 필드의 비트 수가 k일때, 시퀀스 번호의 범위는 $[0, 2^k-1]$이 되고, 오버플로우 방지를 위해 $modulo\ 2^k$를 해서 사용한다.</p>
<p>이제 시퀀스 번호가 1비트만으로는 충분하지 않으니, 송수신자가 이벤트에 반응하는 방식도 변해야 한다.</p>
<h3 id="gbn-sender">GBN Sender</h3>
<p>GBN 송신자는 <code>rdt3.0</code>에 추가로 다음의 세 이벤트에 반응해야 한다.</p>
<ol>
<li>상위 계층에서의 호출
상위 계층에서 <code>rdt_send()</code>가 호출되면, 송신자는 우선 윈도우가 가득 찼는지, 즉 아직 <code>ACK</code>를 받지 못한 패킷이 N개 있는지를 확인한다.</li>
</ol>
<ul>
<li>만약 윈도우가 가득 차지 않았다면 패킷을 만들어 전송하고, 관련된 변수를 갱신한다.</li>
<li>만약 윈도우가 가득 찼다면 송신자는 윈도우가 가득 찼다는 신호로, 상위 계층에 데이터를 그냥 반환한다.</li>
</ul>
<p>실제 구현에서는 데이터를 버퍼에 저장만 해두고 바로 보내지는 않거나, 세마포어/플래그 등의 동기화 메커니즘을 통해 윈도우에 여유가 있을 때에만 상위 계층이<code>rdt_send()</code>를 호출하도록 제한하곤 한다.</p>
<ol start="2">
<li><p><code>ACK</code> 수신
GBN에서는 시퀀스 번호 <code>n</code>에 대한 <code>ACK</code>을 <strong>누적</strong> <code>ACK</code>으로 본다. 즉 <code>n</code>번까지의 모든 패킷이 수신자에게 올바르게 도착했다는 뜻이다.</p>
</li>
<li><p>타임아웃 이벤트
사실 &quot;Go-Back-N&quot;이라는 이름 자체가, 패킷 손실/지연 시 N개의 패킷을 재전송하는 데에서 유래했다. <code>Stop-and-Wait</code> 방식 때와 마찬가지로 타이머를 데이터 손실 복구에 사용하는데, 타임아웃이 일어나면 송신자는 이전에 보냈지만 <code>ACK</code>을 받지는 못한 <strong>모든 패킷을 재전송</strong>한다.</p>
</li>
</ol>
<p><code>ACK</code>을 받았을 때 아직 전송/미확인 패킷이 있다면 타이머는 재시작되고, 더 이상 전송 중인 패킷이 없는 경우 타이머는 중지된다.</p>
<h3 id="gbn-receiver">GBN Receiver</h3>
<p>GBN 수신자가 하는 일은 더 간단하다. 데이터가 정상적으로, 순서에 맞게 도착하면 송신자에게는 <code>ACK(n)</code>을, 상위 계층으로는 데이터를 전달하면 된다. 그 외 패킷에 오류가 있거나 순서가 안 맞는 경우, 해당 패킷은 버리고 마지막으로 올바르게 받은 패킷에 대한 <code>ACK</code>을 재전송한다. 이 <code>ACK</code>를 받은 송신자는 <code>ACK + 1</code>번 패킷부터 다시 보내게 될 것이다.</p>
<hr>
<p>GBN에서 한 가지 생각해 볼 것은, 왜 굳이 수신자가 순서에 맞지 않는 패킷을 모두 버리냐는 것이다. 그렇게 하는 가장 큰 이유는 일단 수신자가 복잡한 버퍼 처리를 할 필요가 없기 때문이다.</p>
<p>순서에 맞지 않는 패킷을 모두 버리는 방식에서 수신자는 그냥 <code>expectedsqenum</code>, 즉 다음으로 몇 번 패킷을 받으면 될지만 신경쓰면 된다. 기대하던 패킷 <code>n</code>이 아닌 <code>n+1</code>을 먼저 받았다면 <code>ACK(n-1)</code>을 보내 <code>n</code>번 이후의 모든 패킷을 재전송 받기만 하면 된다.</p>
<p>만약 위 방식이 아니라면, 순서에 맞지 않는 패킷이 오면 이를 가지고 있다가 나중에 상위 계층으로 올려보내기 위해 복잡한 버퍼 처리를 해줘야만 한다. </p>
<p>물론 모두 버리는 방식에 장점만 있는 건 아니다. 대표적으로는 정상적으로 수신됐지만 순서가 어긋나서 버린 패킷을 재전송 받는 과정에서 또 다시 패킷 유실이 발생할 수도 있다. 이 경우 재전송이 또 필요해지고, 이런 상황이 반복되면 네트워크 트래픽이 치솟게 될 수도 있다.</p>
<p>그럼에도 GBN 프로토콜에는 TCP에서 사용되는, 신뢰성 있는 데이터 전송을 위한 거의 모든 기술들이 포함되어 있다.</p>
<ul>
<li>시퀀스 번호</li>
<li>누적 ACK</li>
<li>체크섬</li>
<li>타임아웃/재전송</li>
</ul>
<p>이 기술들에 대해서는 나중에 TCP를 다룰 떄 다시 다루게 될 것이다.</p>
<h1 id="3-selective-repeat-sr">3. Selective Repeat (SR)</h1>
<p>앞서 봤듯 GBN에서도 성능 문제가 발생할 수 있는 상황이 있다.</p>
<blockquote>
<p>파이프라인에 매우 많은 패킷이 존재할 수 있고, 이 중 단 하나의 패킷 오류만으로도 수많은 패킷을 (불필요하게) 재전송해야 할 수도 있다.</p>
</blockquote>
<p>SR 프로토콜은 이러한 불필요한 재전송 방지를 위해 만들어진 프로토콜로, <strong>오류가 의심되는 패킷만 골라서 재전송</strong>하는 방식이다. 이때 수신자는 올바르게 받은 각 패킷마다 개별적으로 <code>ACK</code>을 보내고, 여전히 슬라이딩 윈도우 방식으로 동작하기는 하지만, GBN과 달리 윈도우 안의 일부 패킷은 이미 <code>ACK</code>을 받았을 수도 있다.</p>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/3ba66dd0-96c7-4fe7-9a01-2b6853112fd5/image.png" alt=""></p>
<p>SR 수신자의 동작 방식은 아래와 같다.</p>
<ol>
<li>패킷이 올바르게 수신되면 순서와 상관없이 <code>ACK</code>을 보낸다.</li>
<li>순서가 맞지 않아도 버리지 않고 버퍼링하고 있다가</li>
<li>이후 누락된 패킷이 도착하면, 버퍼에 저장해 둔 패킷들과 함께 순서대로 상위 계층에 전달한다.</li>
</ol>
<p>이제 SR 송수신자의 이벤트 반응 방식을 보도록 하자.</p>
<h3 id="sr-sender">SR Sender</h3>
<ol>
<li>상위 계층에서 데이터를 받는다.<ul>
<li>상위 계층에서 데이터를 받으면 다음으로 사용할 수 있는 패킷 시퀀스 번호를 확인한다.</li>
<li>윈도우 내에 있다면 패킷으로 만들고 전송하고, 그렇지 않으면 버퍼 처리하거나 곧바로 상위 계층으로 반환한다.</li>
</ul>
</li>
<li>타임아웃<ul>
<li>이번에도 유실된 패킷을 처리하기 위해 타이머를 사용한다.</li>
<li>다만 이번에는 각 패킷이 각자의 논리적 타이머를 가진다. 전송에 실패한 각 패킷을 재전송해줘야 할 수 있기 때문이다. </li>
</ul>
</li>
<li><code>ACK</code> 수신<ul>
<li>SR 송신자는 윈도우 내에 있는 패킷에 대한 <code>ACK</code>을 받으면 해당 패킷에 마크한다.</li>
<li><code>ACK</code>이 <code>send_base</code>와 동일한 경우 윈도우를 옆으로 한 칸 옮기고, 윈도우 내에 아직 전송되지 않은 시퀀스 번호의 패킷이 있다면 전송한다.</li>
</ul>
</li>
</ol>
<h3 id="sr-receiver">SR Receiver</h3>
<ol>
<li><code>[rcv_base, rcb_base+N-1]</code>의 시퀀스 번호를 가지는 패킷 정상 수신<ul>
<li>해당 패킷의 시퀀스 번호에 맞는 <code>ACK</code>을 보낸다.</li>
<li>이전에 받았던 패킷이 아닌 경우 버퍼 처리한다.</li>
<li>이 패킷이 <code>rcv_base</code>와 같은 시퀀스 번호를 가진다면 이 패킷을 포함해 이전에 버퍼처리된, 연속적인 시퀀스 번호의 패킷들을 모두 상위 계층으로 올려 보내고, 윈도우를 그만큼 옮긴다. </li>
</ul>
</li>
<li><code>[rcv_base, rcv_base-1]</code>의 시퀀스 번호를 가지는 패킷 정상 수신<ul>
<li>이미 수신자가 받은 <code>ACK</code>이기는 하지만, 다시 <code>ACK</code>을 보낸다.</li>
</ul>
</li>
<li>그 외의 패킷은 무시한다.</li>
</ol>
<p>여기서 눈여겨 볼 곳은 2번이다. 왜 윈도우를 벗어난, 이미 받았던 패킷에 대한 <code>ACK</code>를 또 보내줘야 할까? 수신자가 어떤 경우에 그런 패킷을 받게 될지를 생각해보면 간단하다.</p>
<ol>
<li>송신자가 <code>[0, 1, 2, 3]</code> 패킷을 보내고, 수신자도 이를 잘 받아 각 패킷에 대한 <code>ACK</code>를 보냈다고 하자. 수신자의 윈도우는 이제 <code>[4, 5, 6, 7]</code>에 있다.</li>
<li>그런데 <code>ACK(2)</code>가 도중에 유실됐다고 하자. 송신자는 <code>ACK(0), ACK(1), ACK(3)</code>을 받았으므로, 윈도우를 <code>[2, 3, 4, 5]</code>로 이동시킨다. 3번 패킷은 <code>ACK</code>를 받았음이 표시되어 있다.</li>
<li>송신자는 <code>ACK(2)</code>를 받지 못했으므로 2번 패킷을 재전송한다.</li>
<li>수신자의 입장에서 2번 패킷은 이미 예전에 받았던 패킷이다. 만약 수신자가 이 패킷을 그냥 무시한다면? 송신자는 <code>ACK(2)</code>를 영영 받지 못하고 윈도우를 옆으로 옮길 수 없게 된다.</li>
</ol>
<h3 id="sr에서의-윈도우-비동기-문제">SR에서의 윈도우 비동기 문제</h3>
<p>송신자와 수신자의 윈도우가 항상 일치하지는 않을 수도 있다. 특히 시퀀스 번호 공간이 제한적일 때 문제가 발생할 수 있는데, 다음과 같은 경우를 생각해보자.</p>
<p>사용할 수 있는 시퀀스 번호에는 <code>0, 1, 2, 3</code>이 있고, 윈도우의 크기는 송/수신자 모두 3이다.</p>
<ol>
<li>송신자가 <code>[0, 1, 2]</code> 패킷을 전송하고, 수신자는 이를 정상적으로 수신해 <code>ACK</code>를 보낸다. 수신자의 윈도우는 <code>[3, 0, 1]</code>로 이동된다. </li>
<li>이때 모든 <code>ACK</code>가 네트워크에서 사라졌다고 하자. 송신자는 다시 <code>[0, 1, 2]</code>를 보낸다.</li>
<li>수신자는 이번에 받은 0번, 1번 패킷이 이전에 받았던 0번, 1번인지, 실제로는 4번, 5번이라 생각해도 좋을 패킷인지 알 수 없다.</li>
</ol>
<p>이러한 혼동을 막으려면, 시퀀스 번호를 재사용하기 전에 수신자가 해당 번호를 기억하는 일이 발생하지 않도록 해야한다. 그러려면 윈도우의 크기를 아무리 커도 시퀀스 번호 공간 크기의 반을 넘지 않도록 설정해야 한다.</p>
<p>이번에는 윈도우의 크기가 2라 가정해보자. </p>
<ol>
<li>송신자가 <code>[0, 1]</code> 패킷을 전송하고, 수신자는 이를 정상적으로 수신해 <code>ACK</code>를 보낸다. 수신자의 윈도우는 <code>[2, 3]</code>으로 이동된다. </li>
<li>이때 모든 <code>ACK</code>가 네트워크에서 사라졌다고 하자. 송신자는 다시 <code>[0, 1]</code>을 보낸다.</li>
<li>수신자는 0번, 1번 패킷이 이전에 받았던 패킷들임을 알 수 있다. 앞서와 같은 혼동이 발생하지 않는다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 4-3. 이벤트 루프와 처리 (3)]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-4-3</link>
            <guid>https://velog.io/@frog_slayer/Nginx-4-3</guid>
            <pubDate>Sat, 05 Apr 2025 09:06:36 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>Thundering Herd 문제</strong>
여러 워커가 동일한 이벤트를 대기하고 하고 있을 때, 이벤트 발생 시 실제 이벤트를 처리하는 워커는 하나 뿐임에도 대기 중인 모든 워커가 깨어나게 되는 문제</p>
<p><strong>문제 발생 시나리오</strong>
여러 워커들이 마스터 프로세스로부터 동일한 리스닝 소켓을 상속받아 가지고 있고, 이 리스닝 소켓으로 들어오는 연결 요청 이벤트를 대기하고 있는 상태. </p>
</blockquote>
<p>(분량 채우려고 재탕하는 거? 맞습니다.)</p>
<h1 id="1-so_reuseport">1. <code>SO_REUSEPORT</code></h1>
<p>Thundering Herd 문제의 두 번째 해결책은 <code>SO_REUSEPORT</code> 소켓을 사용하는 방법이다. </p>
<p><code>nginx.conf</code>에서는 아래와 같이 각 리스닝 포트 번호에 <code>reuseport</code>를 추가해 사용할 수 있다. </p>
<pre><code class="language-nginx">http {
    server {
        listen 80 reuseport;
        listen 443 ssl;
    }
}</code></pre>
<p>앞서 문제가 발생했던 이유는 워커들이 마스터로부터 <strong>동일한 리스닝 소켓을 상속받아서 등록했기 때문</strong>이다. </p>
<pre><code>[클라이언트] ---- SYN ----→ [서버 (포트 80)]
                        │
                        │  ※ 모든 워커가 마스터로부터 상속받은 리스닝 소켓을 공유
                        │
                        ├───▶ [Worker A] → accept() 성공
                        ├───▶ [Worker B] → accept() 실패
                        └───▶ [Worker C] → accept() 실패</code></pre><p>그렇다면 워커 별로 서로 다른 리스닝 소켓을 가지면 되지 않을까?  <code>SO_REUSEPORT</code> 소켓 옵션은 여러 개의 <code>AF_INET</code>, <code>AF_INET6</code> 소켓들을 동일한 소켓 주소로, 그러니까 동일한 포트 번호로 바인드할 수 있도록 하는 옵션이다. </p>
<pre><code>[클라이언트] ---- SYN ----→ [서버 (포트 80)]
                        │
                        │  ※ OS가 알아서 소켓을 정함
                        |                        
                        ├───▶ [Worker A 소켓] → [Worker A] → accept()
                        ├─x─▶ [Worker B 소켓] → [Worker B] → 깨어나지 않음
                        └─x─▶ [Worker C 소켓] → [Worker B] → 깨어나지 않음</code></pre><p>워커들이 각각 <code>SO_REUSEPORT</code> 리스닝 소켓을 만들고 80번 포트에 바인드했다고 해보자. </p>
<ol>
<li>80번 포트로 클라이언트의 연결 요청이 들어옴   </li>
<li>커널이 <code>(src IP, src port, dst IP, dst port, protocol)</code>으로 해시 값을 계산</li>
<li>해시값을 바탕으로 포트 별 <code>SO_REUSEPORT</code> 소켓 그룹에서 하나의 소켓을 선택</li>
<li>선택된 소켓으로 연결 요청을 라우팅</li>
</ol>
<p>선택된 소켓에만 연결 요청 이벤트가 발생하고, 나머지 소켓에서는 이벤트가 발생하지 않으므로 Thundering Herd 문제가 원천적으로 해결된다.</p>
<blockquote>
<p><code>SO_REUSEPORT</code>가 해시 값을 바탕으로 소켓을 선택하기 때문에, 어떤 경우에는 한 소켓에 부하가 집중되는 문제가 발생할 수 있다. </p>
<p>이러한 부하 불균형 문제를 해결하기 위해 로드 밸런싱까지 처리하는 <code>SO_REUSEPORT_LB</code> 소켓 옵션을 제공하는 OS(FreeBSD 12+)도 있으며, Nginx에서는 <code>SO_REUSEPORT_LB</code>가 사용 가능한 경우 우선적으로 선택한다.</p>
</blockquote>
<h1 id="2-so_reuseport-리스닝-소켓의-생성">2. <code>SO_REUSEPORT</code> 리스닝 소켓의 생성</h1>
<p>기존의 리스닝 소켓 생성 과정을 리뷰해보자.</p>
<ol>
<li><code>nginx.conf</code> 파일을 파싱해서 <code>listen</code> 지시어를 찾는다.</li>
<li>포트 번호 및 기타 설정 정보들을 리스닝 소켓 리스트에 넣는다.</li>
<li><code>ngx_open_listening_sockets()</code>으로 소켓을 생성하고 논블록 리스닝.</li>
<li><code>ngx_configure_listening_sockets()</code>에서 필요한 추가 소켓 설정</li>
<li>워커 프로세스를 <code>fork()</code>로 생성해, 만들었던 리스닝 소켓을 공유해준다.</li>
</ol>
<p><code>SO_REUSEPPORT</code> 리스닝 소켓을 사용하는 경우에는 몇 가지 추가적인 작업들이 필요하다.</p>
<ol>
<li><code>nginx.conf</code> 파일을 파싱해서 <code>listen</code> 지시어를 찾는다.</li>
<li>포트 번호 및 기타 설정 정보들을 리스닝 소켓 리스트에 넣는다.<ul>
<li><code>reuseport</code>인 경우가 기록된다.</li>
</ul>
</li>
</ol>
<pre><code class="language-c">// /src/http/ngx_http_core_module.c

static char *
ngx_http_core_listen(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{// listen 지시어에 대한 핸들러
    // ...

        if (ngx_strcmp(value[n].data, &quot;reuseport&quot;) == 0) {
#if (NGX_HAVE_REUSEPORT)
            lsopt.reuseport = 1; //여기서 reuseport임을 기록 
            lsopt.set = 1;
            lsopt.bind = 1;
#else
            ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
                               &quot;reuseport is not supported &quot;
                               &quot;on this platform, ignored&quot;);
#endif
            continue;
        }

    // ...
</code></pre>
<ol start="3">
<li><code>ngx_events_module</code>의 <code>init_conf</code>를 호출할 때 리스닝 소켓 클로닝을 준비한다.<ul>
<li><code>init_conf</code>는 <code>ngx_init_cycle()</code>에서 전역으로 한 번, 설정 초기화를 위해서 호출된다는 사실을 잊지 말자.</li>
<li><code>reuseport</code> 리스닝 소켓 정보를 복사하고, 해당 소켓이 어떤 워커의 것인지를 표시한다.</li>
</ul>
</li>
</ol>
<pre><code class="language-c">static char *
ngx_event_init_conf(ngx_cycle_t *cycle, void *conf)
{
    // ...

#if (NGX_HAVE_REUSEPORT)

    ccf = (ngx_core_conf_t *) ngx_get_conf(cycle-&gt;conf_ctx, ngx_core_module);

    if (!ngx_test_config &amp;&amp; ccf-&gt;master) {

        ls = cycle-&gt;listening.elts;
        for (i = 0; i &lt; cycle-&gt;listening.nelts; i++) {

            //reuseport가 아니거나, 이미 워커가 정해졌으면
            if (!ls[i].reuseport || ls[i].worker != 0) {
                continue;
            }

            if (ngx_clone_listening(cycle, &amp;ls[i]) != NGX_OK) {
                return NGX_CONF_ERROR;
            }

            /* cloning may change cycle-&gt;listening.elts */

            ls = cycle-&gt;listening.elts;
        }
    }

#endif

    return NGX_CONF_OK;
}

// /src/core/ngx_connection.c
ngx_int_t
ngx_clone_listening(ngx_cycle_t *cycle, ngx_listening_t *ls)
{
#if (NGX_HAVE_REUSEPORT)

    ngx_int_t         n;
    ngx_core_conf_t  *ccf;
    ngx_listening_t   ols;

    if (!ls-&gt;reuseport || ls-&gt;worker != 0) {
        return NGX_OK;
    }

    ols = *ls;

    ccf = (ngx_core_conf_t *) ngx_get_conf(cycle-&gt;conf_ctx, ngx_core_module);

    // 각 워커 수만큼 
    for (n = 1; n &lt; ccf-&gt;worker_processes; n++) {

        /* create a socket for each worker process */

        // 해당 리스닝 소켓의 정보를 복사할 공간을 할당(마지막 자리의 포인터 받음)
        ls = ngx_array_push(&amp;cycle-&gt;listening);
        if (ls == NULL) {
            return NGX_ERROR;
        }

        // 마지막 자리에 복사하고
        *ls = ols;

        // 어떤 워커의 것인지를 표시
        ls-&gt;worker = n;
    }

#endif

    return NGX_OK;
}

</code></pre>
<ol start="4">
<li><code>ngx_open_listening_sockets()</code>으로 소켓을 생성하고 논블록 리스닝.<ul>
<li>생성 후 <code>setsockopt()</code>로 <code>SO_REUSEPORT</code> 옵션을 지정</li>
</ul>
</li>
</ol>
<pre><code class="language-c">ngx_int_t
ngx_open_listening_sockets(ngx_cycle_t *cycle)
{
    /**
        이 앞의 코드는 2-1. Nginx의 구조 (1)에서 다뤘습니다.
        아래는 해당 글에서 다루지 않았던 &#39;REUSEPORT인 경우&#39;
    */

#if (NGX_HAVE_REUSEPORT)

        if (ls[i].reuseport &amp;&amp; !ngx_test_config) {
            int  reuseport;

            reuseport = 1;

#ifdef SO_REUSEPORT_LB

            if (setsockopt(s, SOL_SOCKET, SO_REUSEPORT_LB,
                           (const void *) &amp;reuseport, sizeof(int))
                == -1)
            {
               // 안되면 닫고 에러 반환 
            }

#else

            if (setsockopt(s, SOL_SOCKET, SO_REUSEPORT,
                           (const void *) &amp;reuseport, sizeof(int))
                == -1)
            {
                // 안되면 닫고 에러 반환
            }
#endif
        }
#endif
    // ...
}</code></pre>
<ol start="5">
<li>. <code>ngx_configure_listening_sockets()</code>에서 필요한 추가 소켓 설정</li>
<li><code>fork()</code>로 워커를 생성해 만들었던 리스닝 소켓을 복사해준다.</li>
<li>각 워커는 <code>ngx_events_module</code>의 <code>process_init</code>을 호출할 때, 자신에게 배정된 리스닝 소켓을 이벤트 모델에 등록한다.</li>
</ol>
<pre><code class="language-c">// /src/event/ngx_event.c
static ngx_int_t
ngx_event_process_init(ngx_cycle_t *cycle)
{
    // ...

     /* for each listening socket */

    ls = cycle-&gt;listening.elts;
    for (i = 0; i &lt; cycle-&gt;listening.nelts; i++) {
        //...

#if (NGX_HAVE_REUSEPORT)
        // 내가 주인이 아닌 reuseport는 제외
        if (ls[i].reuseport &amp;&amp; ls[i].worker != ngx_worker) {
            continue;
        }
#endif

        /**
            여러 연결 관련 설정들을 진행
        */

#if (NGX_HAVE_REUSEPORT)
        if (ls[i].reuseport) {//reuseport 소켓을 이벤트 목록에 등록
            if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
                return NGX_ERROR;
            }

            continue;
        }
#endif
        // 아래에는 EPOLLEXCLUSIVE를 사용할 수 있을 때 

        // ...
    }

    return NGX_OK;
}</code></pre>
<h1 id="3-thundering-herd-문제-해결책-정리">3. Thundering Herd 문제 해결책 정리</h1>
<ol>
<li><code>epoll</code> + <code>EPOLLEXCLUSIVE</code><ul>
<li>워커가 동일한 리스닝 소켓을 공유</li>
<li>연결 요청 시 커널이 알아서 하나만 깨워줌</li>
</ul>
</li>
<li><code>accept_mutex</code><ul>
<li>애플리케이션 단의 뮤텍스를 사용.</li>
<li>구형 커널에서도 잘 동작하지만 성능이 낮음</li>
</ul>
</li>
<li><code>SO_REUSEPORT</code><ul>
<li>각 워커가 독립적인 리스닝 소켓을 가짐</li>
<li>커널이 알아서 연결을 분산시켜 하나만 깨워줌</li>
<li>성능이 가장 좋음</li>
</ul>
</li>
</ol>
<p>그런데 여기서 한 가지, <code>SO_REUSEPORT</code>나 <code>EPOLLEXCLUSIVE</code>를 쓰면서 <code>accept_mutex</code>도 함께 사용하면 뮤텍스로 인한 성능 저하가 일어나버릴 수도 있다. </p>
<p>예전 버전에는 <code>accept_mutex on;</code>이 디폴트라 <code>SO_REUSEPORT</code>나 <code>EPOLLEXCLUSIVE</code>를 사용하는 경우 자동으로 <code>accept_mutex</code>를 꺼버리도록 했다는 것 같은데, 현재 버전에서는 <code>accept_mutex off;</code>가 디폴트라 최신 <code>epoll</code>이나 <code>reuseport</code>를 사용하는 경우에는 굳이 <code>accept_mutex</code>관련 옵션은 명시하지 않기를 권장하는 듯하다.</p>
<blockquote>
<p>다음 글에서는 실제로 이벤트 처리가 어떻게 이루어지는지. 또 Nginx는 어떻게 이벤트 처리를 최적화하는지에 대해 알아봅니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 4-2. 이벤트 루프와 처리 (2)]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-4-2</link>
            <guid>https://velog.io/@frog_slayer/Nginx-4-2</guid>
            <pubDate>Fri, 04 Apr 2025 10:37:34 GMT</pubDate>
            <description><![CDATA[<p>Thundering Herd 문제와, 이 문제가 발생하게 되는 시나리오를 한 번만 짚고 가자.</p>
<blockquote>
<p><strong>Thundering Herd 문제</strong>
여러 워커가 동일한 이벤트를 대기하고 하고 있을 때, 이벤트 발생 시 실제 이벤트를 처리하는 워커는 하나 뿐임에도 대기 중인 모든 워커가 깨어나게 되는 문제</p>
<p><strong>문제 발생 시나리오</strong>
여러 워커들이 마스터 프로세스로부터 동일한 리스닝 소켓을 상속받아 가지고 있고, 이 리스닝 소켓으로 들어오는 연결 요청 이벤트를 대기하고 있는 상태. </p>
</blockquote>
<p>오늘은 Nginx가 어떻게 이 문제를 해결하는지, 그 해결 전략들에 대해 한 번 알아보자.</p>
<h1 id="1-뮤텍스를-이용한-레이스-컨디션-해소">1. 뮤텍스를 이용한 레이스 컨디션 해소</h1>
<p>첫 번째는 뮤텍스(<code>accept_mutex</code>)를 사용하는 방법인데, 이 뮤텍스의 사용 여부는 <code>nginx.conf</code>에서 설정할 수 있다.</p>
<pre><code class="language-nginx">events {
    accept_mutex off; # (default: 1.11.3 버전 전까지는 on, 이후 off)
}</code></pre>
<p>아이디어는 간단하다.</p>
<ol>
<li>공유 메모리에 뮤텍스를 하나 둔다.</li>
<li>락을 획득한 워커만이 이벤트를 대기하고 처리한다.</li>
<li>이벤트 처리가 끝난 워커는 락을 반납한다.</li>
</ol>
<pre><code class="language-c">// 실제 코드는 아닙니다.
void
ngx_process_events_not_real_just_for_practice(ngx_cycle_t *cycle)
{
    if (ngx_use_accept_mutex) {

        if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
            return;
        }

        if (!ngx_accept_mutex_held) {
            return;
        } 
    }


    (void) ngx_process_events(cycle, timer, flags);

    if (ngx_accept_mutex_held) {
        ngx_shmtx_unlock(&amp;ngx_accept_mutex);
    }

}</code></pre>
<p>이제 락을 획득한 하나의 워커만이 이벤트를 대기하고 처리하게 되니, 리스닝 소켓에 이벤트가 준비돼도 하나의 워커만이 해당 이벤트를 받게 된다.</p>
<h1 id="2--accept_mutex-세부">2.  <code>accept_mutex</code> 세부</h1>
<blockquote>
<p>락에 대한 자세한 내용들은 <a href="https://velog.io/@frog_slayer/series/OS">OS 시리즈</a>의 <a href="https://velog.io/@frog_slayer/OS-22-3">Locks(3)</a>과 <a href="https://velog.io/@frog_slayer/OS-22-4">Locks(4)</a>를 참고하시면 좋을 것 같습니다.</p>
</blockquote>
<p>일단은 뮤텍스 구현을 위해 필요한 구조체들을 보자. <code>/src/core/ngx_shmtx.h</code>에 정의되어 있다. <code>ngx_shmtx_sh_t</code>는 실제 락 상태를 공유 메모리에 저장하기 위한 구조체고 , <code>ngx_shmtx_t</code>는 각 워커가 락을 제어하기 위해 이용하는 구조체다.</p>
<pre><code class="language-c">// /src/core/ngx_shmtx.h

typedef struct {
    ngx_atomic_t   lock;//뮤텍스 상태. = 0: 해제, &gt; 0: 락을 가진 프로세스의 PID
#if (NGX_HAVE_POSIX_SEM)
    ngx_atomic_t   wait;//세마포어 사용시, 대기 중인 프로세스 수 추적에 사용
#endif
} ngx_shmtx_sh_t;


typedef struct {
#if (NGX_HAVE_ATOMIC_OPS)
    ngx_atomic_t  *lock;//위 ngx_shmtx_sh_t.lock의 포인터
#if (NGX_HAVE_POSIX_SEM)
    ngx_atomic_t  *wait;//위 ngx_shmtx_sh_t.wait의 포인터
    ngx_uint_t     semaphore;//세마포어 사용 여부 플래그
    sem_t          sem;//세마포어 객체
#endif
#else
    ngx_fd_t       fd;//파일락 사용 시 사용할 FD
    u_char        *name;//파일 락 경로명
#endif
    ngx_uint_t     spin;//스핀락 횟수. -1: 스핀 사용하지 않음
} ngx_shmtx_t;</code></pre>
<p>일반적으로는 Nginx의 내장 원자적 연산 라이브러리, 혹은 시스템이 제공하는 원자적 연산을 이용해 락을 사용하지만, 어떠한 원자적 연산도 전혀 지원되지 않는 환경이라면 파일 락을 기본적인 동기화 메커니즘으로 사용한다.</p>
<h2 id="2-1-accept_mutex의-생성">(2-1) <code>accept_mutex</code>의 생성</h2>
<p>뮤텍스 생성 자체는 <code>/src/core/ngx_cycle.c:ngx_init_cycle()</code>에서 각 모듈에 대한 전역 초기화를 진행할 때 이루어진다. <code>ngx_shmtx_sh_t</code> 구조체는 공유 메모리에 생기고, <code>ngx_shmtx_t</code> 구조체는 일단 만들어놨다가 나중에 워커들을 <code>fork()</code>로 생성할 때 각 워커가 복사해 가지게 한다.</p>
<pre><code class="language-c">// /src/core/ngx_cycle.c
ngx_cycle_t *
ngx_init_cycle(ngx_cycle_t *old_cycle)
{
    // ...
    if (ngx_init_modules(cycle) != NGX_OK) {
        /* fatal */
        exit(1);
    }
    // ...
}

// /src/ore/ngx_module.c
ngx_int_t
ngx_init_modules(ngx_cycle_t *cycle)
{
    ngx_uint_t  i;

    for (i = 0; cycle-&gt;modules[i]; i++) {
        if (cycle-&gt;modules[i]-&gt;init_module) {
            if (cycle-&gt;modules[i]-&gt;init_module(cycle) != NGX_OK) {
                return NGX_ERROR;
            }
        }
    }

    return NGX_OK;
}

// /src/event/ngx_event.c
static ngx_int_t
ngx_event_module_init(ngx_cycle_t *cycle)
{
    //...

    ngx_accept_mutex_ptr = (ngx_atomic_t *) shared;
    ngx_accept_mutex.spin = (ngx_uint_t) -1;

    if (ngx_shmtx_create(&amp;ngx_accept_mutex, (ngx_shmtx_sh_t *) shared,
                         cycle-&gt;lock_file.data)
        != NGX_OK)
    {
        return NGX_ERROR;
    }

    // ...
}

// /src/core/ngx_shmtx.c
ngx_int_t
ngx_shmtx_create(ngx_shmtx_t *mtx, ngx_shmtx_sh_t *addr, u_char *name)
{
    mtx-&gt;lock = &amp;addr-&gt;lock;//포인터 설정

    if (mtx-&gt;spin == (ngx_uint_t) -1) {//즉시 반환
        return NGX_OK;
    }

    // 아래에 추가적인 내용이 있기는 하지만 생략
}</code></pre>
<h2 id="2-2-락-획득">(2-2) 락 획득</h2>
<p><code>accept_mutex</code>의 락 획득은 간단하다. 일단 락 획득 시도를 해보고, 획득에 성공하면 리스닝 소켓들을 내 이벤트 목록에 등록하고, 내가 이 락을 가지고 있다는 표시를 해주는 게 끝이다(<code>ngx_accept_mutex_held = 1;</code>). </p>
<pre><code class="language-c">// /src/event/ngx_event_accept.c
ngx_int_t
ngx_trylock_accept_mutex(ngx_cycle_t *cycle)
{
    if (ngx_shmtx_trylock(&amp;ngx_accept_mutex)) {

        // 이렇게 되는 경우는 없을 것 같지만 방어적 프로그래밍을 위해서 있는 듯함
        if (ngx_accept_mutex_held &amp;&amp; ngx_accept_events == 0) {
            return NGX_OK;
        }

        if (ngx_enable_accept_events(cycle) == NGX_ERROR) {
            ngx_shmtx_unlock(&amp;ngx_accept_mutex);
            return NGX_ERROR;
        }

        ngx_accept_events = 0;
        ngx_accept_mutex_held = 1;

        return NGX_OK;
    }

    if (ngx_accept_mutex_held) {
        if (ngx_disable_accept_events(cycle, 0) == NGX_ERROR) {
            return NGX_ERROR;
        }

        ngx_accept_mutex_held = 0;
    }

    return NGX_OK;
}

// /src/core/ngx_shmtx.c
ngx_uint_t
ngx_shmtx_trylock(ngx_shmtx_t *mtx)
{
    return (*mtx-&gt;lock == 0 &amp;&amp; ngx_atomic_cmp_set(mtx-&gt;lock, 0, ngx_pid));
}</code></pre>
<p><code>ngx_atomic_cmp_set(lock, old, new)</code>는 compare-and-swap 연산이다. 세부 구현은 원자적 연산 라이브러리가 있거나, 시스템에서 원자적 연산을 지원하는 경우에는 해당 구현을 곧바로 사용하고, 그렇지 않으면 직접 구현해서 사용한다.</p>
<pre><code class="language-c">// /src/core/os/unix/ngx_atomic.h

/**
    원자적 연산이 지원되는 경우 이를 이용하고
*/
#if (NGX_HAVE_LIBATOMIC)

#define ngx_atomic_cmp_set(lock, old, new)  \
    AO_compare_and_swap(lock, old, new)

#elif (NGX_HAVE_GCC_ATOMIC)
#define ngx_atomic_cmp_set(lock, old, set)  \
    __sync_bool_compare_and_swap(lock, old, set)

// ... 기타 시스템에서 원자적 연산을 지원하는 경우

#endif

/** 
    그렇지 않으면 아래와 같이 직접 CAS를 구현해서 사용
*/
#if !(NGX_HAVE_ATOMIC_OPS)

#define NGX_HAVE_ATOMIC_OPS  0

static ngx_inline ngx_atomic_uint_t
ngx_atomic_cmp_set(ngx_atomic_t *lock, ngx_atomic_uint_t old,
    ngx_atomic_uint_t set)
{
    if (*lock == old) {
        *lock = set;
        return 1;
    }

    return 0;
}</code></pre>
<p>락 획득에 성공 시에는 <code>ngx_enable_accept_events()</code>를 호출해, 사용할 수 있는 리스닝 소켓들 중 활성화되지 않은 것들(누군가의 이벤트 목록에 등록된 상태가 아닌 것들)을 모두 자신의 이벤트 목록에 등록한다.</p>
<pre><code class="language-c">// /src/event/ngx_event_accept.c
ngx_int_t
ngx_enable_accept_events(ngx_cycle_t *cycle)
{
    ngx_uint_t         i;
    ngx_listening_t   *ls;
    ngx_connection_t  *c;

    ls = cycle-&gt;listening.elts;
    for (i = 0; i &lt; cycle-&gt;listening.nelts; i++) {

        c = ls[i].connection;

        if (c == NULL || c-&gt;read-&gt;active) {
            continue;
        }

        if (ngx_add_event(c-&gt;read, NGX_READ_EVENT, 0) == NGX_ERROR) {
            return NGX_ERROR;
        }
    }

    return NGX_OK;
}</code></pre>
<p>락 획득에 실패한 경우에는 <code>ngx_disable_accept_events()</code>를 호출해, 활성화된 리스닝 소켓들을 내 이벤트 목록에서 제거한다. 불필요한 리소스 경쟁이 일어나는 일을 막기 위함이다.</p>
<pre><code class="language-c">// /src/event/ngx_event_accept.c

static ngx_int_t
ngx_disable_accept_events(ngx_cycle_t *cycle, ngx_uint_t all)
{
    ngx_uint_t         i;
    ngx_listening_t   *ls;
    ngx_connection_t  *c;

    ls = cycle-&gt;listening.elts;
    for (i = 0; i &lt; cycle-&gt;listening.nelts; i++) {

        c = ls[i].connection;

        if (c == NULL || !c-&gt;read-&gt;active) {
            continue;
        }

#if (NGX_HAVE_REUSEPORT)

        /*
         * do not disable accept on worker&#39;s own sockets
         * when disabling accept events due to accept mutex
         */

        if (ls[i].reuseport &amp;&amp; !all) {
            continue;
        }

#endif

        if (ngx_del_event(c-&gt;read, NGX_READ_EVENT, NGX_DISABLE_EVENT)
            == NGX_ERROR)
        {
            return NGX_ERROR;
        }
    }

    return NGX_OK;
}</code></pre>
<h2 id="2-3-락-해제">(2-3) 락 해제</h2>
<p>락 해제는 더 간단하다. 지금 내가 락을 획득한 상태인 경우 해제시켜 주기만 하면 된다.</p>
<pre><code class="language-c">void
ngx_process_events_not_real_just_for_practice(ngx_cycle_t *cycle)
{
    //...

    if (ngx_accept_mutex_held) {
        ngx_shmtx_unlock(&amp;ngx_accept_mutex);
    }
}


// /src/core/ngx_shmtx.c
void
ngx_shmtx_unlock(ngx_shmtx_t *mtx)
{
    // spin == -1이므로 X 
    if (mtx-&gt;spin != (ngx_uint_t) -1) {
        ngx_log_debug0(NGX_LOG_DEBUG_CORE, ngx_cycle-&gt;log, 0, &quot;shmtx unlock&quot;);
    }

    // 이번에는 락의 값이 내 PID와 동일한지 확인하고 0(잠금 해제)으로 설정해준다
    if (ngx_atomic_cmp_set(mtx-&gt;lock, ngx_pid, 0)) {
        ngx_shmtx_wakeup(mtx);//세마포어 관련 함수인데 쓰지 않아서 즉시 리턴함
    }
}</code></pre>
<h1 id="3--이벤트-처리">3.  이벤트 처리</h1>
<p>락을 획득한 후에는 이벤트를 대기하다 준비가 완료되면 처리한다.</p>
<pre><code class="language-c">void
ngx_process_events_not_real_just_for_practice(ngx_cycle_t *cycle)
{
    //학 획득

    (void) ngx_process_events(cycle, timer, flags);

    //락 해제
}</code></pre>
<p>이 <code>ngx_process_events</code>는 <code>/src/event/ngx_event.h</code>에 다음과 같이 매크로 정의가 되어 있는데, 간단히 선택된 이벤트 모델의 모듈에 정의된 <code>process_events</code> 함수를 호출하기 위함이라 생각하면 좋을 것 같다.</p>
<pre><code class="language-c">// /src/event/ngx_event.h

#define ngx_process_events   ngx_event_actions.process_events</code></pre>
<p><code>epoll</code>을 쓴다고 가정하면, Nginx의 <code>epoll</code> 관련 모듈의 다음 함수를 실행한다.</p>
<pre><code class="language-c">static ngx_int_t
ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)
{
    // ...

    events = epoll_wait(ep, event_list, (int) nevents, timer);

    // 준비된 이벤트들에 대한 처리
}</code></pre>
<p>이렇게 <code>accept_mutex</code>를 사용해 항상 하나의 워커만이 대기/처리하게 함으로써  Thundering Herd 문제를 해결할 수 있게 됐다.</p>
<p>하지만 이렇게만 하면 <strong>경쟁하지 않아도 되는 이벤트까지도 동시에 처리할 수 없게 된다는 문제가 발생</strong>하고, 이렇게 할 바에는 차라리 Thundering Herd 문제가 발생하더라도 뮤텍스를 사용하지 않는 편이 낫다.</p>
<p>락 획득에 실패한 워커들이 기존 연결의 이벤트들을 처리할 수 있도록 코드를 수정해줄 필요가 있다.</p>
<h1 id="3-기존-연결-이벤트의-병렬-처리">3. 기존 연결 이벤트의 병렬 처리</h1>
<p>수정 방향 자체는 간단하다.</p>
<ol>
<li><code>accept_mutex</code>를 가진 프로세스는 리스닝 소켓을 자신의 이벤트 목록에 등록</li>
<li>나머지 프로세스는 자신의 이벤트 목록에서 리스닝 소켓을 제거하고, 기존의 연결 소켓 이벤트는 처리</li>
</ol>
<p>지난 코드에서 문제가 되었던 지점은 아래와 같다.</p>
<pre><code class="language-c">void
ngx_process_events_not_real_just_for_practice(ngx_cycle_t *cycle)
{
    if (ngx_use_accept_mutex) {

        if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
            return;
        }

        if (!ngx_accept_mutex_held) {
            return;// &lt;-----------바로 여기
        } 
    }

    (void) ngx_process_events(cycle, timer, flags);

    if (ngx_accept_mutex_held) {
        ngx_shmtx_unlock(&amp;ngx_accept_mutex);
    }
}</code></pre>
<p>단순히 표시된 부분의 <code>return;</code>을 삭제해주기만 해도 리스닝 소켓으로 들어오는 새 연결 요청 이벤트를 처리하면서 기존의 연결 소켓 이벤트까지도 모두 병렬 처리할 수 있게 된다.</p>
<blockquote>
<p>다음 글에서는 <code>REUSEPORT</code> 소켓을 이용해 Thundering Herd 문제를 해결합니다.  </p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 4-1. 이벤트 루프와 처리 (1)]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-4-1</link>
            <guid>https://velog.io/@frog_slayer/Nginx-4-1</guid>
            <pubDate>Thu, 03 Apr 2025 15:53:46 GMT</pubDate>
            <description><![CDATA[<p>이전에 썼던 <a href="https://velog.io/@frog_slayer/Nginx-1">1. 이벤트 드리븐 서버</a>에서 대표적인 I/O 멀티플렉싱 인터페이스와 동작 방식을 봤다. 그런데 Nginx는 리눅스만이 아니라, Windows, macOS, Solaris 등 다양한 OS와 플랫폼을 지원하고, OS에 따라 지원하는 이벤트 모델에는 차이가 있다. 계속해서 <code>epoll</code>만 사용할 것 같기는 하지만, 그래도 종류와 이름 정도는 간단히 슥 훑고 가자.</p>
<table>
<thead>
<tr>
<th>이벤트 모델</th>
<th>지원 OS</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><code>epoll</code></td>
<td>Linux 2.5.44+</td>
<td>고성능, Edge/Level Triggered 지원, 대규모 연결에 최적화</td>
</tr>
<tr>
<td><code>kqueue</code></td>
<td>BSD 계열, macOS</td>
<td>고성능, 다양한 이벤트 필터링</td>
</tr>
<tr>
<td><code>poll</code></td>
<td>POSIX 호환 OS</td>
<td><code>select</code>보다는 낫지만 성능이 썩 좋지는 않음</td>
</tr>
<tr>
<td><code>select</code></td>
<td>POSIX 호환 OS</td>
<td>구식, FD_SET 크기 제한, 성능 낮음</td>
</tr>
<tr>
<td><code>IOCP</code></td>
<td>Windows</td>
<td>Windows 전용 비동기 I/O 모델</td>
</tr>
<tr>
<td><code>eventport</code></td>
<td>Solaris 10+</td>
<td>최신 Solaris에서 사용</td>
</tr>
</tbody></table>
<h1 id="1-이벤트-루프">1. 이벤트 루프</h1>
<p>워커 프로세스는 초기화 후 계속해서 루프를 돌며 이벤트를 처리한다.</p>
<pre><code class="language-c">// /src/os/unix/ngx_process_cycle.c
static void
ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data)
{
    /**
        워커 프로세스 초기화
    */

    for ( ;; ) {//이벤트 루프
        /**
            종료 중 처리
        */

        //이벤트 처리
        ngx_process_events_and_timers(cycle);

        /**
            강제 종료 or 정상 종료 or 로그 파일 재오픈 처리
        */
    }
}</code></pre>
<p>실제로 이벤트를 대기하고 준비가 되면 처리하는 부분은 <code>ngx_process_events_and_timers()</code>다. </p>
<h1 id="2-thundering-herd">2. Thundering Herd</h1>
<p>지금까지는 모든 워커들이 마스터로부터 상속받은 리스닝 소켓을 자신의 이벤트 모델에 등록하고, 요청이 들어오면 OS가 알아서 해당 소켓을 등록한 프로세스들 중 하나만을 깨운다고 했지만, <strong>사실 꼭 그렇지만은 않다.</strong></p>
<h2 id="2-1-epoll_ctl의--epollexclusive-플래그">(2-1) <code>epoll_ctl()</code>의  <code>EPOLLEXCLUSIVE</code> 플래그</h2>
<p><code>man epoll_ctl</code>로 매뉴얼 페이지를 열어보면 <code>epoll_event</code> 구조체에 쓸 수 있는 플래그 목록이 주루룩 나온다. 그 중 <code>EPOLLEXCLUSIVE</code> 플래그는 이벤트 발생 시 동일한 타겟 이벤트를 대기 중인 것들 중 하나만을 깨우도록 하는 옵션이다. 지금까지 요청을 OS가 알아서 분배해준다고 했던 건 <code>EPOLLEXCLUSIVE</code> 플래그 사용을 전제로 했을 때나 그렇다. </p>
<table>
<thead>
<tr>
<th>케이스</th>
<th>이벤트 발생 시</th>
</tr>
</thead>
<tbody><tr>
<td>모두 <code>EPOLLEXCLUSIVE</code>로 타겟 등록</td>
<td>정확히 하나에 이벤트 발생 알림</td>
</tr>
<tr>
<td>일부만 <code>EPOLLEXCLUSIVE</code>로 타겟 등록</td>
<td>해당 플래그를 사용하지 않는 것들에는 모두 알림을 보내고, 플래그를 사용한 것들 중에는 정확히 하나만 깨움</td>
</tr>
<tr>
<td><code>EPOLLEXCLUSIVE</code> 미사용</td>
<td>모두에게 이벤트 발생 알림</td>
</tr>
</tbody></table>
<p>실제로도 OS에서 <code>epoll</code>을 사용할 수 있고(Linux 2.5.44+), <code>EPOLLEXCLUSIVE</code> 플래그도 사용할 수 있다면(Linux 4.5+), Nginx에서는 각 워커에서 <code>ngx_events_module</code>의 <code>init_process</code>을 호출할 때 상속받은 리스닝 소켓을 <code>EPOLLEXCLUSIVE</code>로 등록한다.</p>
<pre><code class="language-c">// /src/event/ngx_event.c
static ngx_int_t
ngx_event_process_init(ngx_cycle_t *cycle)
{
    // ...

     /* for each listening socket */
    ls = cycle-&gt;listening.elts;
    for (i = 0; i &lt; cycle-&gt;listening.nelts; i++) {
        //...

#if (NGX_HAVE_EPOLLEXCLUSIVE)

        if ((ngx_event_flags &amp; NGX_USE_EPOLL_EVENT)
            &amp;&amp; ccf-&gt;worker_processes &gt; 1)
        {
            ngx_use_exclusive_accept = 1;

            if (ngx_add_event(rev, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
                == NGX_ERROR)
            {
                return NGX_ERROR;
            }

            continue;
        }

#endif
        //...
    }
    return NGX_OK;
}</code></pre>
<h2 id="2-2-thundering-herd-문제">(2-2) Thundering Herd 문제</h2>
<p>그렇다면 <code>EPOLLEXCLUSIVE</code> 플래그를 사용할 수 없는 경우에는 어떨까?</p>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/4a3cdd68-183d-46b0-99fc-c319be3cc392/image.gif" alt=""></p>
<p>리스닝 소켓에 연결 요청이 들어오는 순간, 대기 중인 모든 워커 프로세스가 이벤트 준비 메시지를 받고 <code>accept()</code>하기 위해 경쟁하게 된다. </p>
<pre><code>[클라이언트] ---- SYN ----→ [서버]
                        ↳ Worker A: accept() 성공 (연결 처리)
                        ↳ Worker B: accept() → EAGAIN (실패)
                        ↳ Worker C: accept() → EAGAIN (실패)</code></pre><p>이때 한 워커만이 실제로 <code>accept()</code>에 성공하고, 나머지는 <code>accept()</code>를 호출하기는 하지만 곧 실패해버린다.</p>
<p>이렇게 실제로 작업을 처리하는 주체는 하나뿐인데 하나의 이벤트를 대기 중이던 다수의 프로세스(혹은 스레드)가 불필요하게 깨어나 자원을 낭비하게 되는 문제를 <strong>Thundering Herd</strong> 문제라 한다.</p>
<blockquote>
<p>그럼 늘 이런 비효율성을 감내해야만 할까? 그렇지는 않다. Nginx의 Thundering Herd 문제 해결 전략에 대해서는 다음 글에서.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 3-3. 워커 프로세스 초기화와 모듈 (3)]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-3-3</link>
            <guid>https://velog.io/@frog_slayer/Nginx-3-3</guid>
            <pubDate>Wed, 02 Apr 2025 11:41:46 GMT</pubDate>
            <description><![CDATA[<p>지난 글에서는 모듈의 설정을 생성하고 초기화하는 과정을 봤다. 간단히 정리하면 다음과 같다.</p>
<ul>
<li><code>create_conf</code>: 설정 구조체를 메모리에 생성하고 필드를 <code>NGX_CONF_UNSET</code> 등으로 초기화</li>
<li><code>ngx_conf_parse()</code> 등: 설정 파일을 불러와 파싱하고, 설정이 있는 필드를 설정값으로 적용</li>
<li><code>init_conf</code>: 설정이 없어 <code>NGX_CONF_UNSET</code>으로 남아있는 필드에 기본값을 적용</li>
</ul>
<p>이렇게 설정 구조체를 만들고 나면 다음으로는 이 구조체를 바탕으로 실제로 모듈을 초기화하는 과정이 이어지는데, 간단히 어떤 것이 언제 어떤 일을 하는지 정도만 보고 넘어가자.</p>
<ul>
<li><code>init_module</code>: <code>ngx_init_cycle()</code>에서 1회 호출되어, 공유 메모리 등 모든 워커 프로세스가 공유해야 하는 정보들에 대한 전역 초기화</li>
</ul>
<pre><code class="language-c">// /src/core/ngx_cycle.c
ngx_cycle t* 
ngx_init_cycle(ngx_cycle_t *old_cycle)
{
    // ...
    // 각 모듈들에 대한 초기화. 필요한 실제 리소스를 할당함.
    if (ngx_init_modules(cycle) != NGX_OK) {
        /* fatal */
        exit(1);
    }
}</code></pre>
<ul>
<li><code>init_process</code>: 각 워커를 생성하고 난 후, <code>ngx_worker_process_init()</code>에서 호출해 워커프로세스 별로 필요한 초기화를 진행 </li>
</ul>
<pre><code class="language-c">// /src/os/unix/ngx_process_cycle.c
static void
ngx_worker_process_init(ngx_cycle_t *cycle, ngx_int_t worker)
{
    //...

    //모듈들 중 초기화 함수 포인터가 주어진 경우 초기화를 진행
    for (i = 0; cycle-&gt;modules[i]; i++) {
        if (cycle-&gt;modules[i]-&gt;init_process) {
            if (cycle-&gt;modules[i]-&gt;init_process(cycle) == NGX_ERROR) {
                /* fatal */
                exit(2);
            }
        }
    }

    // 나머지 초기화 과정...
}</code></pre>
<p>남아있는 나머지 초기화 과정에서 워커 프로세스는 자신의 FD 테이블에서 사용하지 않는 것들을 정리한다.</p>
<pre><code class="language-c">static void
ngx_worker_process_init(ngx_cycle_t *cycle, ngx_int_t worker)
{
    //...

    /**
        자신의 FD 테이블을 정리. 쓰이지 않는 것들을 제거하는 과정
    */

    // 현재 FD 테이블에서, 자신의 것이 아닌 워커용 소켓을 닫음
    for (n = 0; n &lt; ngx_last_process; n++) {

        if (ngx_processes[n].pid == -1) {
            continue;
        }

        if (n == ngx_process_slot) {
            continue;
        }

        if (ngx_processes[n].channel[1] == -1) {
            continue;
        }

        if (close(ngx_processes[n].channel[1]) == -1) {
            ngx_log_error(NGX_LOG_ALERT, cycle-&gt;log, ngx_errno,
                          &quot;close() channel failed&quot;);
        }
    }

    //마스터의 소켓도 닫음
    if (close(ngx_processes[ngx_process_slot].channel[0]) == -1) {
        ngx_log_error(NGX_LOG_ALERT, cycle-&gt;log, ngx_errno,
                      &quot;close() channel failed&quot;);
    }

    //마스터 프로세스와 통신하기 위한 채널 소켓을 이벤트 모델에 등록
    if (ngx_add_channel_event(cycle, ngx_channel, NGX_READ_EVENT,
                              ngx_channel_handler)
        == NGX_ERROR)
    {
        /* fatal */
        exit(2);
    }</code></pre>
<p>현재 만들어진 워커 프로세스의 인덱스를 <code>i</code>라 하자. 위와 같은 정리 과정 후 이 워커 프로세스의 FD 테이블에는 마스터로부터 상속 받은 리스닝 소켓들, <code>channel[1][0]</code>, ..., <code>channel[i -1][0]</code>, <code>channel[i][1]</code>만이 남게 된다. 이후 마스터가 남은 다른 워커들을 생성하고 <code>ngx_pass_open_channel()</code>을 실행하면, 이후 생성되는 워커들의 <code>channel[j][0]</code>도 전달받게 되긴 한다.</p>
<p>이제 지금까지의 초기화 과정을 정리해보고, 이렇게 만들어진 프로세스들이 어떻게 서로 메시지를 교환하는지 살펴보자. 간단히 아래와 같이 가정하자.</p>
<ol>
<li>2개의 워크프로세스를 사용</li>
<li>80번(HTTP), 443번(HTTPS) 포트를 사용한다. 단 <code>REUSEPORT</code> 소켓을 사용하지는 않는다고 가정한다.</li>
</ol>
<pre><code class="language-nginx">worker_processes: 2;

http {
    server {
        listen 80;
        listen 443 ssl;

        # 실제로는 여러 기타 설정있어야 함
    }
}</code></pre>
<h1 id="1-nginx-아키텍처-구성-과정-정리">1. Nginx 아키텍처 구성 과정 정리</h1>
<p>Nginx의 아키텍처가 구성되는 과정을 간단히 정리하면 다음과 같이 정리할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/3a83d041-2aff-4ff4-a5b4-299d62f2bbc0/image.png" alt=""></p>
<ol>
<li>프로세스 시작을 위한 초기 작업 수행</li>
<li>설정 초기화. 리스닝 소켓 오픈. 모듈 전역 초기화 </li>
<li>마스터 프로세스 생명 주기 시작</li>
</ol>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/34061e9b-0a07-4707-8134-6db84427e387/image.png" alt=""></p>
<ol start="4">
<li>마스터-워커 간의 통신을 위한 UDS 페어(<code>ch[1][0]</code>, <code>ch[1][1]</code>) 생성 및 설정</li>
<li><code>fork()</code>로 <code>worker[1]</code>을 생성</li>
<li><code>worker[1]</code> 초기화. <ul>
<li>기타 필수 초기화 및 워커 별 모듈 초기화</li>
<li>마스터로부터 복사해온 FD 테이블에서 사용하지 않는 소켓을 닫음<ul>
<li>마스터가 자신에게 메시지를 보낼 때 사용하는 <code>ch[1][0]</code>은 닫음</li>
</ul>
</li>
<li><code>ch[1][1]</code> 소켓은 이벤트 모델에 등록(마스터와의 메시지 교환)</li>
</ul>
</li>
<li>초기화 이후 <code>worker[1]</code>은 이벤트 루프를 돌기 시작. </li>
</ol>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/5ce212a1-1e44-45ca-87c4-3a7ef14dc8d4/image.png" alt=""></p>
<ol start="8">
<li>마스터-워커 간의 통신을 위한 UDS 페어(<code>ch[2][0]</code>, <code>ch[2][1]</code>) 생성 및 설정</li>
<li><code>fork()</code>로 <code>worker[2]</code>을 생성</li>
<li><code>worker[2]</code> 초기화. <ul>
<li>기타 필수 초기화 및 워커 별 모듈 초기화</li>
<li>마스터로부터 복사해온 FD 테이블에서 사용하지 않는 소켓을 닫음<ul>
<li>마스터가 자신에게 메시지를 보낼 때 사용하는 <code>ch[2][0]</code>은 닫음</li>
<li><code>worker[1]</code>이 마스터와의 통신을 위해 사용하는 <code>ch[1][1]</code>도 닫음</li>
</ul>
</li>
<li><code>ch[2][1]</code>을 이벤트 모델에 등록(마스터와의 메시지 교환)</li>
</ul>
</li>
<li>마스터 프로세스는 <code>ch[2][0]</code>소켓을 <code>worker[1]</code>에게 전달한다. <code>worker[1]</code>은 이를 통해 마스터 프로세스를 거치지 않고 <code>worker[2]</code>로 메시지를 보낼 수 있게 된다.</li>
<li>초기화 이후 <code>worker[2]</code>은 이벤트 루프를 돌기 시작. </li>
</ol>
<h1 id="2-프로세스-간-메시지-교환">2. 프로세스 간 메시지 교환</h1>
<h3 id="1-마스터에서-워커로-메시지-보내기">(1) 마스터에서 워커로 메시지 보내기</h3>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/3260e6f2-f796-4a6e-9970-1e49b582b48b/image.png" alt=""></p>
<p>마스터에서 <code>worker[1]</code>로 메시지를 보내기 위해서는 <code>ch[1][0]</code>에 쓰기만 하면 된다. <code>ch[1][0]</code>에 쓰면 곧 <code>ch[1][1]</code>이 읽기 준비 상태가 되고, 커널이 <code>ch[1][1]</code>을 <code>epoll</code> 인스턴스에 등록한 프로세스를 찾아 메시지를 전달해주고, <code>worker[1]</code>이 이를 읽을 수 있게 된다.</p>
<h3 id="2-워커에서-마스터로-메시지-보내기">(2) 워커에서 마스터로 메시지 보내기</h3>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/d3776109-9192-47df-a0e5-c432851c2470/image.png" alt=""></p>
<p><code>worker[1]</code>에서 마스터로 메시지를 보낼 때에는 그냥 <code>ch[1][1]</code>에 쓰기만 하면 된다. 이벤트 모델을 통해서가 아니라, <code>ch[1][1]</code>과 연결된 <code>ch[1][0]</code>으로 곧바로 메시지를 보낼 수 있게 된다( 따지자면 커널을 거치기는 하지만).</p>
<h3 id="3-워커에서-워커로-메시지-보내기">(3) 워커에서 워커로 메시지 보내기</h3>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/fc8831a2-98b7-40d1-9a1a-9272396308f3/image.png" alt=""></p>
<p><code>worker[1]</code>은 <code>ngx_pass_open_channel()</code>을 통해 마스터 프로세스로부터 <code>ch[2][0]</code>을 전달받은 상태다. <code>worker[1]</code>에서 <code>worker[2]</code>로 메시지를 보내려면 단순히 <code>ch[2][0]</code>에 쓰기만 하면 된다. <code>ch[2][0]</code>에 쓰기가 완료되면 이와 연결된 <code>ch[2][1]</code>이 읽기 준비 상태가 되고,  <code>worker[2]</code>는 이벤트 모델을 통해 이 메시지를 받아올 수 있게 된다.</p>
<blockquote>
<p><strong>그런데!</strong>) 사실 이 UDS 채널은 마스터(<code>ch[i][0]</code>)에서 워커(<code>ch[i][1]</code>)로의 단방향 통신을 위해서만 사용되며, 워커에서 마스터로, 혹은 워커에서 워커로 메시지를 보내기 위해 사용되는 경우는 없다고 한다.</p>
</blockquote>
<blockquote>
<p><strong>그렇다면 전역 상태 변화는?</strong>) 워커 프로세스가 직접 마스터 프로세스로 메시지를 보내기 보다는, 공유 메모리를 이용한 간접적, 중앙 집중적 IPC만을 사용한다고 한다.</p>
</blockquote>
<blockquote>
<p>Q) 왜 마스터에서는 사용하지 않는 <code>ch[i][1]</code>을 닫지 않을까? 
Q) 왜 사용하는 일이 없는데도 굳이 다른 프로세스의 <code>ch[i][0]</code>을 공유하는 걸까?</p>
</blockquote>
<p>이제 남은 주제들과 순서는 다음과 같다.</p>
<ol>
<li>이벤트 루프 심화: Nginx에서의 이벤트 처리는 어떻게 이뤄질까?</li>
<li>요청 처리 파이프라인: HTTP 요청은 실제로 어떻게 처리될까? </li>
<li>업스트림 로드 밸런싱: 단일 서버를 넘어, 분산 처리는 어떻게 될까?</li>
<li>캐시 관리: 성능 최적화를 위한 캐시 관리 전략은?</li>
<li>HTTPS: 보안 레이어를 추가한다면?</li>
<li>HTTP3/QUIC: 최신 기술에 대한 처리는?</li>
</ol>
<p>열심히 써야지</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 3-2. 워커 프로세스 초기화와 모듈 (2)]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-3-2</link>
            <guid>https://velog.io/@frog_slayer/Nginx-3-2</guid>
            <pubDate>Sun, 30 Mar 2025 23:02:53 GMT</pubDate>
            <description><![CDATA[<p><code>nginx.conf</code>의 각 블록 안을 컨텍스트라 하고, 어떠한 블록에도 속하지 않는 부분을 <code>main</code> 컨텍스트라 한다. <code>nginx.conf</code>를 작성할 때에는 이 블록들의 순서도 중요한데, <code>main</code> 컨텍스트의 전역 설정과 <code>events</code> 블록은 항상 설정 파일의 최상단에 위치해야 한다. </p>
<pre><code class="language-nginx"># main context : nginx_core_module
events {
    # events context
}

http {
    # http context
}

#...</code></pre>
<p>이 설정들은 <strong>OS와 직접 상호작용하는</strong> 모듈(과 그 하위 모듈)들에 대한 것으로, OS와 어떤 연관이 있는지를 간단히 보자면,</p>
<ul>
<li><code>ngx_core_module</code> (<code>main</code> 컨텍스트)<ul>
<li>프로세스 관리 및 시스템 리소스 제어<ul>
<li>워커 프로세스 수, PID 파일 경로, 프로세스 실행 권한, 파일 디스크립터 수 제한 등</li>
</ul>
</li>
</ul>
</li>
<li><code>nginx_events_module</code> (<code>events</code> 컨텍스트)<ul>
<li>이벤트 모델 선택<ul>
<li>OS가 저마다 제공하는 I/O 멀티플렉싱 기술을 활용</li>
</ul>
</li>
<li>연결 관리<ul>
<li>동시 연결 수 제한, <code>accept</code> 방식 제어 등</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>정도가 있다.</p>
<p>이 모듈들은 다른 모듈들에 인프라를 제공하는 역할을 맡기에, 가장 먼저 설정을 끝내야 한다. 이때 설정 파일의 파싱이 앞에서부터 순서대로 이루어지기 때문에, 해당 모듈들에 대한 설정 또한 파일의 최상단에 위치해야 한다. 나머지 <code>http</code>, <code>mail</code>, <code>stream</code>의 경우에는 유연하게 설정해도 좋다.</p>
<p>오늘은 <code>ngx_events_module</code>의 초기화 코드를 보며, 모듈이 어떻게 초기화되는지 알아보자.</p>
<h1 id="1--nginx-모듈의-객체-지향성">1.  Nginx 모듈의 객체 지향성</h1>
<p>두 구조체 <code>ngx_module_t</code>와 <code>ngx_core_module_t</code>를 보자. 모든 Nginx 모듈은<code>ngx_module_t</code> 타입으로 정의되고, 아래와 같은 공통 인터페이스를 가진다. </p>
<pre><code class="language-c">// /src/core/ngx_module.h
struct ngx_module_s {
    ngx_uint_t            ctx_index;
    ngx_uint_t            index;

    char                 *name;

    ngx_uint_t            spare0;
    ngx_uint_t            spare1;

    ngx_uint_t            version;
    const char           *signature;

    void                 *ctx;
    ngx_command_t        *commands;
    ngx_uint_t            type;

    ngx_int_t           (*init_master)(ngx_log_t *log);

    ngx_int_t           (*init_module)(ngx_cycle_t *cycle);

    ngx_int_t           (*init_process)(ngx_cycle_t *cycle);
    ngx_int_t           (*init_thread)(ngx_cycle_t *cycle);
    void                (*exit_thread)(ngx_cycle_t *cycle);
    void                (*exit_process)(ngx_cycle_t *cycle);

    void                (*exit_master)(ngx_cycle_t *cycle);

    uintptr_t             spare_hook0;
    uintptr_t             spare_hook1;
    uintptr_t             spare_hook2;
    uintptr_t             spare_hook3;
    uintptr_t             spare_hook4;
    uintptr_t             spare_hook5;
    uintptr_t             spare_hook6;
    uintptr_t             spare_hook7;
};

typedef struct {
    ngx_str_t             name;
    void               *(*create_conf)(ngx_cycle_t *cycle);
    char               *(*init_conf)(ngx_cycle_t *cycle, void *conf);
} ngx_core_module_t;

// /src/core/ngx_core.h
typedef struct ngx_module_s ngx_module_t;
</code></pre>
<p>여기서 우리는 <code>class</code>가 없는 C의 언어적 한계를 뛰어 넘는, Nginx의 <strong>객체 지향적</strong> 설계 철학을 엿볼 수 있다.</p>
<ol>
<li>클래스 메서드는 함수 포인터로 표현</li>
<li>상속 및 확장은 ctx 필드에 하위 구조체를 포함시켜 구현</li>
<li>다형성은 런타임에 함수 포인터 교체로 달성</li>
</ol>
<p>만약 Java 느낌으로 클래스를 통해 나타낸다면 다음과 같은 모습이 될 것 같다.</p>
<pre><code class="language-java">//별도의 class 파일로 나뉘어져 있다고 가정 
public abstract class NgxModule {
    /**
        필드
    */

    public abstract int initMaster(NgxLog log);
    public abstract int initModule(NgxCycle cycle);
    public abstract int initProcess(NgxCycle cycle);
    public abstract int initThread(NgxCycle cycle);
    public abstract void exitThread(NgxCycle cycle);
    public abstract void exitProcess(NgxCycle cycle);
    public abstract void exitMaster(NgxCycle cycle);

    //...
};

public abstract class NgxCoreModule extends NgxModule {
    //필드

    public abstract void createConf(NgxCycle cycle);
    public abstract char initConf(NgxCycle cycle);
}

public class NgxEventsModule extends NgxCoreModule {
    //...

    public char initConf(NgxCycle cycle){
        //구체적인 구현
    }

    //...
}</code></pre>
<h1 id="2-모듈-초기화-과정">2. 모듈 초기화 과정</h1>
<p>상-하위 모듈 사이의 의존성을 관리하기 위해 Nginx의 모듈의 초기화는 다음과 같은 순서로 이루어진다.</p>
<ol>
<li>상위 모듈 설정 생성: 하위 모듈들이 사용할 공간을 준비</li>
<li>하위 모듈 설정 생성/초기화: 하위 모듈이 자신의 설정 구조체를 생성하고 상위 모듈의 배열에 등록</li>
<li>상위 모듈 설정 초기화: 모든 하위 모듈의 설정 이후, 상위 모듈이 설정 트리의 무결성 확인</li>
</ol>
<pre><code class="language-c">static ngx_core_module_t  ngx_events_module_ctx = {
    ngx_string(&quot;events&quot;),                  /* name */
    NULL,                                  /* create_conf */
    ngx_event_init_conf                    /* init_conf */
};

ngx_module_t  ngx_events_module = {
    NGX_MODULE_V1,
    &amp;ngx_events_module_ctx,                /* module context */
    ngx_events_commands,                   /* module directives */
    NGX_CORE_MODULE,                       /* module type */
    NULL,                                  /* init master */
    NULL,                                  /* init module */
    NULL,                                  /* init process */
    NULL,                                  /* init thread */
    NULL,                                  /* exit thread */
    NULL,                                  /* exit process */
    NULL,                                  /* exit master */
    NGX_MODULE_V1_PADDING
};
</code></pre>
<p><code>ngx_events_module</code>은 위와 같이 생겼다. 눈여겨 볼 점은 <code>create_conf</code>가 <code>NULL</code>이라는 점이다. 그 이유는 이 모듈이 이벤트 관련 설정들을 위한 <strong>컨테이너 역할</strong>만을 하는 모듈이기 때문이다. </p>
<pre><code class="language-c">// /src/core/ngx_cycle.c
ngx_cycle t* 
ngx_init_cycle(ngx_cycle_t *old_cycle)
{
    // ...

    /**
        NGX_CORE_MODULE 타입 모듈들에 대한 create_conf
    */

    /**
        nginx.conf의 파일을 파싱하고 지시어에 따른 설정을 적용
    */
    if (ngx_conf_parse(&amp;conf, &amp;cycle-&gt;conf_file) != NGX_CONF_OK) {
        environ = senv;
        ngx_destroy_cycle_pools(&amp;conf);
        return NULL;
    }

    /**
        NGX_CORE_MODULE 타입 모듈들에 대한 init_conf
    */
    // ...
}
</code></pre>
<p>이전의 <code>ngx_init_cycle()</code>에서 <code>ngx_conf_parse()</code>를 호출하는 부분이 있는데,  <code>/src/core/ngx_conf_file.c:ngx_conf_parse()</code>에서는 다음과 같은 작업들이 수행된다.</p>
<ol>
<li>(최상위인 경우) <code>nginx.conf</code> 설정 파일을 엶.</li>
<li>파싱 후 현재 컨텍스트(ex. <code>http</code>, <code>events</code>)에 따른 지시어의 유효성을 검사.</li>
<li>지시어에 해당하는 핸들러 함수를 호출, 설정 적용.<ul>
<li>실제 해당 지시어를 가지고 있는 모듈을 초기화하고 실행하는 게 아니라, 구조화하는 역할만 하며, 실제 리소스 할당은 각 모듈 별로 <code>init_module()</code>이 호출될 때 수행된다.</li>
<li><code>http { server { location {} } }</code>과 같이 계층을 이루는 경우, 핸들러 함수(ex. <code>ngx_http_block()</code>)에서 재귀적으로 <code>ngx_conf_parse()</code>를 호출해 하위 모듈 블럭을 계층적으로 처리한다.</li>
</ul>
</li>
</ol>
<p>각 모듈에는 아래와 같은, 설정 시 사용할 수 있는 커맨드(<code>ngx_command_t</code>)들의 목록이 있다.  <code>ngx_conf_parse()</code>에서는 파싱을 진행할 때, 지시어와 커맨드 명이 같으면 그에 맞는 <code>set()</code> 함수를 호출해 초기 설정을 위한 준비를 한다.</p>
<pre><code class="language-c">// ngx_conf_file.h
struct ngx_command_s {
    ngx_str_t             name;
    ngx_uint_t            type;
    char               *(*set)(ngx_conf_t *cf, ngx_command_t *cmd, void *conf);
    ngx_uint_t            conf;
    ngx_uint_t            offset;
    void                 *post;
};

#define ngx_null_command  { ngx_null_string, 0, NULL, 0, 0, NULL }

// /src/core/ngx_core.h
typedef struct ngx_command_s         ngx_command_t;</code></pre>
<p>예를 들어 <code>nginx.conf</code>에 아래와 같이 쓰여있다고 해보자.</p>
<pre><code class="language-nginx">events {
    use epoll;
    worker_connections 1024;
}</code></pre>
<p><code>ngx_events_module</code>의 경우 이벤트 블록임을 나타내기 위한 <code>events</code> 지시어가 있다.</p>
<pre><code class="language-c">// /src/event/ngx_event.c
static ngx_command_t  ngx_events_commands[] = {

    { ngx_string(&quot;events&quot;),
      NGX_MAIN_CONF|NGX_CONF_BLOCK|NGX_CONF_NOARGS,
      ngx_events_block,
      0,
      0,
      NULL },

      ngx_null_command
};
</code></pre>
<p>파싱 중 이 지시어를 찾게 되면 <code>ngx_events_block()</code>이라는 함수가 호출되고, 아래의 하위 모듈들을 위한 설정 초기화 작업이 수행된다.</p>
<pre><code class="language-c">static char *
ngx_events_block(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{
    /**
        events {} 블록이 있는지 확인하고 
        로드된 모듈들 중 NGX_EVENT_TYPE의 모듈들을 찾아 인덱스를 설정하고,
        해당 모듈들에 대한 설정 초기화 작업(create_conf 및 init_conf)도 수행한다.
    */
}</code></pre>
<p>위 <code>ngx_events_block()</code>에서 찾은 <code>NGX_EVENT_TYPE</code> 모듈 구조체는 이벤트와 관련된 실제 처리들을 하는 구현체다. 마찬가지의 방식으로 파싱을 통해 지시어에 맞는 핸들러를 실행해 적절하게 처리한다.</p>
<pre><code class="language-c">static ngx_str_t  event_core_name = ngx_string(&quot;event_core&quot;);

static ngx_command_t  ngx_event_core_commands[] = {

    { ngx_string(&quot;worker_connections&quot;),
      NGX_EVENT_CONF|NGX_CONF_TAKE1,
      ngx_event_connections,
      0,
      0,
      NULL },

    { ngx_string(&quot;use&quot;),
      NGX_EVENT_CONF|NGX_CONF_TAKE1,
      ngx_event_use,
      0,
      0,
      NULL },

    { ngx_string(&quot;multi_accept&quot;),
      NGX_EVENT_CONF|NGX_CONF_FLAG,
      ngx_conf_set_flag_slot,
      0,
      offsetof(ngx_event_conf_t, multi_accept),
      NULL },

    { ngx_string(&quot;accept_mutex&quot;),
      NGX_EVENT_CONF|NGX_CONF_FLAG,
      ngx_conf_set_flag_slot,
      0,
      offsetof(ngx_event_conf_t, accept_mutex),
      NULL },

    { ngx_string(&quot;accept_mutex_delay&quot;),
      NGX_EVENT_CONF|NGX_CONF_TAKE1,
      ngx_conf_set_msec_slot,
      0,
      offsetof(ngx_event_conf_t, accept_mutex_delay),
      NULL },

    { ngx_string(&quot;debug_connection&quot;),
      NGX_EVENT_CONF|NGX_CONF_TAKE1,
      ngx_event_debug_connection,
      0,
      0,
      NULL },

      ngx_null_command
};


static ngx_event_module_t  ngx_event_core_module_ctx = {
    &amp;event_core_name,
    ngx_event_core_create_conf,            /* create configuration */
    ngx_event_core_init_conf,              /* init configuration */

    { NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL }
};


ngx_module_t  ngx_event_core_module = {
    NGX_MODULE_V1,
    &amp;ngx_event_core_module_ctx,            /* module context */
    ngx_event_core_commands,               /* module directives */
    NGX_EVENT_MODULE,                      /* module type */
    NULL,                                  /* init master */
    ngx_event_module_init,                 /* init module */
    ngx_event_process_init,                /* init process */
    NULL,                                  /* init thread */
    NULL,                                  /* exit thread */
    NULL,                                  /* exit process */
    NULL,                                  /* exit master */
    NGX_MODULE_V1_PADDING
};</code></pre>
<p>하위 모듈(여기서는 모듈 구현체)에서 필요한 작업이 모두 끝난 후에는 <code>init_conf()</code>가 있는지 확인하고 실행해, 하위 모듈을 검증하고 기타 마지막 사전 설정들을 끝낸다.</p>
<pre><code class="language-c">static char *
ngx_event_init_conf(ngx_cycle_t *cycle, void *conf)
{
    /**
        ngx_events_module의 init_conf
        - 이벤트 관련 필수 설정 검증
        - REUSEPORT를 사용하는 경우 소켓 클로닝을 위한 준비
          - REUSEPORT 동일한 포트를 여러 소켓이 리스닝할 수 있게 됨
          - 워커에 같은 포트-다른 소켓을 배정함으로써 성능 최적화
          - 여기서 소켓이 생성되는 건 아니고, 나중에 소켓 생성을 하기 위한 사전 설정만. 
    */
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 3-1.  워커 프로세스 초기화와 모듈 (1)]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-3-1</link>
            <guid>https://velog.io/@frog_slayer/Nginx-3-1</guid>
            <pubDate>Sat, 29 Mar 2025 06:01:13 GMT</pubDate>
            <description><![CDATA[<h1 id="1-리뷰">1. 리뷰</h1>
<p>지금까지 확인한 동작은 다음과 같다.</p>
<ol>
<li>마스터 프로세스가 리스닝 소켓을 생성, 바인드하고 논블로킹 리슨함</li>
<li>마스터 프로세스가 워커 프로세스들을 생성(<code>fork()</code>)</li>
</ol>
<p>80번(HTTP), 443번(HTTPS) 포트를 논블로킹 모드로 오픈하고 있다고 하자. <code>fork()</code>의 경우 부의 FD 테이블을 복사하므로 워커들도 이 포트들과 바인딩된 소켓의 FD를 가지고 있다. 그런데 실제로 요청이 들어오면 어떻게 될까? 이제 <code>epoll</code>과 같은 이벤트 모델이 사용된다.</p>
<p>각 워커는 초기화 과정에서 자기 자신의 <code>epoll</code> 인스턴스를 생성한 후, 부모 프로세스, 그러니까 마스터 프로세스로부터 받은 리스닝 소켓을 자신의 <code>epoll</code> 인스턴스에 등록하고, <code>epoll_wait()</code>를 호출해 등록한 소켓에서의 I/O 이벤트가 준비되기를 대기한다.</p>
<p>클라이언트가 서버에 HTTP 요청을 보낸다고 해보자.</p>
<ol>
<li>80번(HTTP) 포트로 연결 요청(<code>SYN</code>)이 들어온다.</li>
<li>커널이 연결을 소켓의 백로그에 저장하고 <code>epoll_wait()</code>를 호출 중인 워커 중 하나를 깨운다.</li>
<li>선택된 워커는 연결을 <code>accept()</code>하고, 만들어진 연결 소켓을 다시 자신의 <code>epoll</code> 인스턴스에 등록해, 이후 전달될 클라이언트의 실제 요청 처리에 사용한다.</li>
</ol>
<h1 id="2-소스-코드-읽기-continue">2. 소스 코드 읽기 (continue)</h1>
<h4 id="srcosunixngx_process_cyclec"><code>/src/os/unix/ngx_process_cycle.c</code></h4>
<pre><code class="language-c">static void
ngx_worker_process_init(ngx_cycle_t *cycle, ngx_int_t worker)
{
    //...

    //환경 변수 설정
    if (ngx_set_environment(cycle, NULL) == NULL) {
        /* fatal */
        exit(2);
    }

    ccf = (ngx_core_conf_t *) ngx_get_conf(cycle-&gt;conf_ctx, ngx_core_module);

    /**
        워커 프로세스의 CPU 스케줄링 우선도(nice값) 지정 
    */
    if (worker &gt;= 0 &amp;&amp; ccf-&gt;priority != 0) {
        if (setpriority(PRIO_PROCESS, 0, ccf-&gt;priority) == -1) {
            ngx_log_error(NGX_LOG_ALERT, cycle-&gt;log, ngx_errno,
                          &quot;setpriority(%d) failed&quot;, ccf-&gt;priority);
        }
    }

    /**
        워커 프로세스의 최대 FD 수, 코어 덤프 크기를 nginx.conf에 적은 경우 설정
    */

    //root 권한으로 실행되어 있음
    if (geteuid() == 0) {
        /**
            워커 프로세스의 권한을 하강
        */
    }

    //CPU affinity를 설정한 경우
    if (worker &gt;= 0) {
        cpu_affinity = ngx_get_cpu_affinity(worker);

        if (cpu_affinity) {
            ngx_setaffinity(cpu_affinity, cycle-&gt;log);
        }
    }

    /**
        코어 덤프 생성 허용
        작업 디렉터리 설정
    */

    //차단된 시그널 해제
    sigemptyset(&amp;set);

    if (sigprocmask(SIG_SETMASK, &amp;set, NULL) == -1) {
        ngx_log_error(NGX_LOG_ALERT, cycle-&gt;log, ngx_errno,
                      &quot;sigprocmask() failed&quot;);
    }

    //난수생성기 설정
    tp = ngx_timeofday();
    srandom(((unsigned) ngx_pid &lt;&lt; 16) ^ tp-&gt;sec ^ tp-&gt;msec);

    //모듈들 중 초기화 함수 포인터가 주어진 경우 초기화를 진행
    for (i = 0; cycle-&gt;modules[i]; i++) {
        if (cycle-&gt;modules[i]-&gt;init_process) {
            if (cycle-&gt;modules[i]-&gt;init_process(cycle) == NGX_ERROR) {
                /* fatal */
                exit(2);
            }
        }
    }

    //현재 FD 테이블에서, 자신의 것이 아닌 UDS를 닫음
    for (n = 0; n &lt; ngx_last_process; n++) {

        if (ngx_processes[n].pid == -1) {
            continue;
        }

        if (n == ngx_process_slot) {
            continue;
        }

        if (ngx_processes[n].channel[1] == -1) {
            continue;
        }

        if (close(ngx_processes[n].channel[1]) == -1) {
            ngx_log_error(NGX_LOG_ALERT, cycle-&gt;log, ngx_errno,
                          &quot;close() channel failed&quot;);
        }
    }

    //자신의 FD 테이블에서 마스터의 소켓도 닫음
    if (close(ngx_processes[ngx_process_slot].channel[0]) == -1) {
        ngx_log_error(NGX_LOG_ALERT, cycle-&gt;log, ngx_errno,
                      &quot;close() channel failed&quot;);
    }

    //마스터 프로세스와 통신하기 위한 채널 소켓을 이벤트 모델에 등록
    if (ngx_add_channel_event(cycle, ngx_channel, NGX_READ_EVENT,
                              ngx_channel_handler)
        == NGX_ERROR)
    {
        /* fatal */
        exit(2);
    }
}
</code></pre>
<h3 id="워커-프로세스의-제한-관련-설정">워커 프로세스의 제한 관련 설정</h3>
<p>워커 프로세스의 최대 FD 수 제한은 <code>nginx.conf</code>에서 아래와 같이 명시적으로 설정할 수 있다. 한 워커 당 최대 연결 수도 마찬가지로 <code>nignx.conf</code>에서 정할 수 있는데, 클라이언트와의 연결을 처리하기 위해서는 해당 연결 소켓을 FD 테이블에 넣어줘야 하므로 최대 FD 수보다는 작거나 같아야 한다. 성능 튜닝이 필요한 경우 사용한다.</p>
<pre><code class="language-nginx">worker_processes  auto;
worker_rlimit_nofile 65535;  # 워커 최대 FD 수(default: 시스템 설정을 따름)

events {
    worker_connections  8192;  # 워커 당 최대 연결 수(default: 512)
}

http {
    server {
        listen 80;
        # ...
    }
}</code></pre>
<p>코어 덤프 제한도 <code>nginx.conf</code>에서 설정할 수 있다. <code>100M</code>, <code>1G</code>와 사이즈를 써도 되고, <code>0</code>을 써서 코어 덤프 자체를 비활성화할 수도 있다. 비정상 종료 시의 디버깅이 필요하다면 설정하자.</p>
<pre><code class="language-nginx">worker_processes  auto;
worker_rlimit_core 0;       # 코어 덤프 비활성화
worker_rlimit_nofile 65535;</code></pre>
<h3 id="모듈">모듈</h3>
<p>아까 코드 중간에 모듈의 초기화 프로세스를 실행하는 부분이 있었다.</p>
<pre><code class="language-c">// /src/os/unix/ngx_process_cycle.c:ngx_worker_process_init()    
    //모듈들 중 초기화 함수 포인터가 주어진 경우 초기화를 진행
    for (i = 0; cycle-&gt;modules[i]; i++) {
        if (cycle-&gt;modules[i]-&gt;init_process) {
            if (cycle-&gt;modules[i]-&gt;init_process(cycle) == NGX_ERROR) {
                /* fatal */
                exit(2);
            }
        }
    }</code></pre>
<p><a href="https://nginx.org/en/docs/">Nginx 공식 문서</a>에서 볼 수 있듯, Nginx에는 코어 이외에도 수많은 모듈들이 있다.</p>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/d69572c0-cab4-4b58-879f-3480930cfe1a/image.png" alt=""></p>
<p>모듈의 종류는 크게 빌드 방식에 따라 정적(static) 모듈과 동적(dynamic) 모듈로 나눌 수 있다. 정적 모듈은 Nginx 바이너리에 내장되고, 동적 모듈은 별도의 <code>.so</code> 파일로 빌드되어 <code>load_module</code>로 로드해야 한다. </p>
<p>모듈을 동적으로 빌드할지, 정적으로 빌드할지, 혹은 제거할지는 <code>/auto/configure</code>을 실행해 <code>Makefile</code>을 만들 때 아래와 같이 옵션을 줘서 처리하면 된다.</p>
<p>Nginx에서 공식적으로 제공하는 모듈들은 아래와 같이 컴파일에 포함시키거나 제외시키기 위한 커맨드가 따로 주어져 있다. </p>
<pre><code class="language-sh">./configure \
    --with-http_ssl_module \ # 기본적으로 포함되지 않는 모듈을 정적 모듈로 추가
    --with-http_image_filter_module=dynamic \ # 모듈을 동적으로 빌드
    --without-http_rewrite_module  # 모듈을 제외
make &amp;&amp; sudo make install</code></pre>
<p>위에서 볼 수 있듯 일부 공식 모듈의 경우 동적 빌드를 지원하기도 하는데,  바이너리로 포함시키지 않고 <code>.so</code> 파일로 만들어 필요할 때 로드할 수 있도록 해 유연하게 사용하기 위함이다. 단 동적으로 빌드되었으니 <code>nginx_conf</code>에서 <code>load_module</code>로 따로 명시해줘야 한다. </p>
<pre><code class="language-nginx">load_module modules/ngx_http_ssl_module.so;</code></pre>
<p>공식 모듈이 아닌 서드 파티 모듈은 다음과 같이 추가할 수 있다.</p>
<pre><code class="language-sh">./configure \
    --add-module=PATH \ # 정적 모듈로 추가
    --add-dynamic-module=PATH # 동적 모듈로 추가
make &amp;&amp; make install</code></pre>
<p>마찬가지로 동적 모듈인 경우 <code>nginx.conf</code>에 추가해야 런타임에 로드할 수 있다.</p>
<pre><code class="language-nginx">load_module /path/to/modules/ngx_some_dynamic_module.so;</code></pre>
<p>한편 Nginx에는 <code>--without-*</code> 옵션으로 제거할 수 없는, 필수적인 핵심 모듈들도 있다. 이 모듈들은 말 그대로 핵심적인 역할을 하는 모듈들로 가장 먼저 초기화되어 이외 다른 모듈들을 위한 기반을 마련한다.</p>
<table>
<thead>
<tr>
<th>핵심 모듈명(소스 코드)</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong><code>ngx_core_module</code></strong></td>
<td>전역 설정 (worker_processes, pid 파일 경로 등) 관리</td>
</tr>
<tr>
<td><strong><code>ngx_events_module</code></strong></td>
<td>이벤트 처리 모델 (epoll, kqueue 등) 초기화</td>
</tr>
<tr>
<td><strong><code>ngx_http_module</code></strong></td>
<td>HTTP 서비스의 기반 기능 제공</td>
</tr>
<tr>
<td><strong><code>ngx_mail_module</code></strong></td>
<td>메일 프록시 (SMTP, IMAP, POP3) 지원</td>
</tr>
<tr>
<td><strong><code>ngx_stream_module</code></strong></td>
<td>TCP/UDP 스트림 프록시 (Layer 4 로드 밸런싱) 지원</td>
</tr>
</tbody></table>
<blockquote>
<p><code>NGX_HTTP_MODULE</code>이나 <code>NGX_EVENT_MODULE</code> 타입도 있기는 하지만, 이 타입은 구체적인 구현체들이 가지는 타입이다.</p>
</blockquote>
<p>여기서 중요한 사실. <code>nginx.conf</code> 사실 파일은 이 코어 모듈들을 기반으로 하는 모듈들의 설정을 계층적으로 구성하는 파일이었다!</p>
<pre><code class="language-nginx"># [1] core 모듈에 대한 설정 (ngx_core_module)
user  nginx;
worker_processes  auto;

# [2] events 모듈 블록 (ngx_events_module)
events {
    use epoll;
    worker_connections  1024;
}

# [3] http 모듈 블록 (ngx_http_module)
http {
    # [4] http 모듈 하위 계층의 서버 모듈 블록 (ngx_http_core_module)
    server {
        listen       80;
        server_name  example.com;
    }
}</code></pre>
<p><a href="https://velog.io/@frog_slayer/Nginx-2-1">2-1. Nginx의 구조 (1)</a>에서는 마스터 &amp; 워커 프로세스의 생성만 보면서 넘어갔지만, 사실 <code>/src/core/nginx.c:main()</code>을 보면 다음과 같이 모듈 초기화를 위한 사전 작업을 진행한다. </p>
<pre><code class="language-c">// /src/core/nginx.c
int ngx_cdecl
main(int argc, char *const *argv)
{
{
    //...

    //모듈 초기화를 위한 사전 작업
    if (ngx_preinit_modules() != NGX_OK) {
        return 1;
    }

    //nginx.conf의 설정에 따른 초기화 작업. src/core/ngx_cycle.c에 있다.
    cycle = ngx_init_cycle(&amp;init_cycle);

    //...
}</code></pre>
<p>아래에 <code>ngx_modules</code> 배열에 인덱스와 이름을 매핑하는 부분이 있다. 현재<code>ngx_modules</code>과 <code>ngx_module_names</code>에는 각각 컴파일된 모든 정적 모듈(이 런타임에 올라간 메모리의 주소)과 그 이름이 들어가있다.</p>
<pre><code class="language-c">// /src/core/ngx_module.c
ngx_int_t
ngx_preinit_modules(void)
{
    ngx_uint_t  i;

    //ngx_modules 배열은 NULL로 끝을 표시한다
    for (i = 0; ngx_modules[i]; i++) {
        ngx_modules[i]-&gt;index = i;
        ngx_modules[i]-&gt;name = ngx_module_names[i];
    }

    ngx_modules_n = i;
    ngx_max_module = ngx_modules_n + NGX_MAX_DYNAMIC_MODULES;

    return NGX_OK;
}</code></pre>
<blockquote>
<p>Q. 왜 이렇게 인덱스와 모듈명을 따로 주입해주는 걸까? 각 정적 모듈에 이름을 하드 코딩하면 안 될까? </p>
</blockquote>
<p>이전에 봤던 생명 주기 초기화 함수 <code>/src/core/ngx_cycle.c:ngx_init_cycle()</code>에서도 모듈과 관련해서 빠진 부분이 있다.</p>
<pre><code class="language-c">// /src/core/ngx_cycle.c
ngx_cycle t* 
ngx_init_cycle(ngx_cycle_t *old_cycle)
{
    // ...

    //사이클 설정 컨텍스트에 모듈을 담기 위한 메모리 할당
    cycle-&gt;conf_ctx = ngx_pcalloc(pool, ngx_max_module * sizeof(void *));
    if (cycle-&gt;conf_ctx == NULL) {
        ngx_destroy_pool(pool);
        return NULL;
    }

    // ...

    //이 사이클에서 정적 모듈을 사용하기 위해 복사하는 함수
    if (ngx_cycle_modules(cycle) != NGX_OK) {
        ngx_destroy_pool(pool);
        return NULL;
    }

    //복사해온 정적 모듈 중 전역 코어, 이벤트 모듈에 대한 설정 생성
    for (i = 0; cycle-&gt;modules[i]; i++) {
        if (cycle-&gt;modules[i]-&gt;type != NGX_CORE_MODULE) {
            continue;
        }

          /**
            각 모듈 구조체의 ctx 필드에는 해당 모듈에 특화된
             설정 생성/초기화 함수 포인터 &amp; 모듈별 고유 데이터가 있다.
        */
        module = cycle-&gt;modules[i]-&gt;ctx;

        //설정 생성 함수 포인터 create_conf가 NULL아 아닌 경우
        if (module-&gt;create_conf) {

            //  설정 생성. 주로 메모리를 할당하고 초기값으로 초기화한다.
            rv = module-&gt;create_conf(cycle);
            if (rv == NULL) {
                ngx_destroy_pool(pool);
                return NULL;
            }
            //설정 컨텍스트에 이를 등록
            cycle-&gt;conf_ctx[cycle-&gt;modules[i]-&gt;index] = rv;
        }
    }

    //...

    //커맨드 라인의 옵션으로 전달된 파라미터를 파싱
    if (ngx_conf_param(&amp;conf) != NGX_CONF_OK) {
        environ = senv;
        ngx_destroy_cycle_pools(&amp;conf);
        return NULL;
    }

    /**
        nginx.conf의 파일을 파싱
        nginx.conf에 load_module이 있는 경우 
        /src/core/nginx.c:ngx_load_module()을 실행해 동적 모듈을 로드함
    */
    if (ngx_conf_parse(&amp;conf, &amp;cycle-&gt;conf_file) != NGX_CONF_OK) {
        environ = senv;
        ngx_destroy_cycle_pools(&amp;conf);
        return NULL;
    }

    if (ngx_test_config &amp;&amp; !ngx_quiet_mode) {
        ngx_log_stderr(0, &quot;the configuration file %s syntax is ok&quot;,
                       cycle-&gt;conf_file.data);
    }

    //NGX_CORE_MODULE 타입 모듈에 대한 최종 초기화
    for (i = 0; cycle-&gt;modules[i]; i++) {
        if (cycle-&gt;modules[i]-&gt;type != NGX_CORE_MODULE) {
            continue;
        }

        module = cycle-&gt;modules[i]-&gt;ctx;

        if (module-&gt;init_conf) {
            if (module-&gt;init_conf(cycle,
                                  cycle-&gt;conf_ctx[cycle-&gt;modules[i]-&gt;index])
                == NGX_CONF_ERROR)
            {
                environ = senv;
                ngx_destroy_cycle_pools(&amp;conf);
                return NULL;
            }
        }
    }

    // ...

    // 저번에 봤던 부분
    if (ngx_open_listening_sockets(cycle) != NGX_OK) {
        goto failed;
    }

    if (!ngx_test_config) {
        ngx_configure_listening_sockets(cycle);
    }


    // ...

    // 각 모듈들에 대한 초기화. 필요한 실제 리소스를 할당함.
    if (ngx_init_modules(cycle) != NGX_OK) {
        /* fatal */
        exit(1);
    }

    //...
}</code></pre>
<h4 id="srccorengx_cyclec"><code>/src/core/ngx_cycle.c</code></h4>
<pre><code class="language-c">ngx_int_t
ngx_cycle_modules(ngx_cycle_t *cycle)
{
    /*
     * create a list of modules to be used for this cycle,
     * copy static modules to it
     */

    cycle-&gt;modules = ngx_pcalloc(cycle-&gt;pool, (ngx_max_module + 1)
                                              * sizeof(ngx_module_t *));
    if (cycle-&gt;modules == NULL) {
        return NGX_ERROR;
    }

    ngx_memcpy(cycle-&gt;modules, ngx_modules,
               ngx_modules_n * sizeof(ngx_module_t *));

    cycle-&gt;modules_n = ngx_modules_n;

    return NGX_OK;
}

ngx_int_t
ngx_init_modules(ngx_cycle_t *cycle)
{
    ngx_uint_t  i;

    //각 모듈들에 대해 초기화 함수 포인터가 있으면 실행
    for (i = 0; cycle-&gt;modules[i]; i++) {
        if (cycle-&gt;modules[i]-&gt;init_module) {
            if (cycle-&gt;modules[i]-&gt;init_module(cycle) != NGX_OK) {
                return NGX_ERROR;
            }
        }
    }

    return NGX_OK;
}</code></pre>
<blockquote>
<p>조금 곁가지로 빠지긴 했지만, 왜 <code>nginx.conf</code>가 저런 모습을 띄게 된 건지 이해하고, Nginx의 핵심 철학인 <strong>모듈화</strong>에 대한 냄새를 맡아볼 수 있는 기회가 됐다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 2-2. Nginx의 구조 (2)]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-2-2</link>
            <guid>https://velog.io/@frog_slayer/Nginx-2-2</guid>
            <pubDate>Thu, 27 Mar 2025 04:11:35 GMT</pubDate>
            <description><![CDATA[<h1 id="1-유닉스-도메인-소켓uds-unix-domain-socket">1. 유닉스 도메인 소켓(UDS, Unix Domain Socket)</h1>
<pre><code class="language-c">unix_socket = socket(AF_UNIX, type, 0);
error = socketpair(AF_UNIX, 0, int sv[2]);</code></pre>
<p>유닉스 도메인 소켓은 <strong>로컬 프로세스 간의 효과적인 IPC에 쓰이는 소켓</strong>으로, <code>AF_UNIX</code> 소켓 패밀리에 속한다. Nginx에서의 IPC는 대부분 이 UDS를 통해 이루어진다. </p>
<table>
<thead>
<tr>
<th>타입</th>
<th><code>SOCK_STREAM</code></th>
<th><code>SOCK_DGRAM</code></th>
<th><code>SOCK_SEQPACKET</code></th>
</tr>
</thead>
<tbody><tr>
<td><strong>연결 방식</strong></td>
<td>연결 지향적</td>
<td>비연결 지향적</td>
<td>연결 지향적</td>
</tr>
<tr>
<td><strong>신뢰성</strong></td>
<td>데이터 순서, 무손실 보장.</td>
<td>메시지 손실 가능</td>
<td>데이터 순서, 무손실 보장.</td>
</tr>
<tr>
<td><strong>메시지 경계</strong></td>
<td>경계 없음</td>
<td>경계 보존</td>
<td>경계 보존</td>
</tr>
<tr>
<td>소켓인만큼 <code>bind()</code>, <code>connect()</code>, <code>sendto()</code>와 같은 시스템 콜들을 이용할 수 있는데, 이때 필요한 소켓 주소는 다음과 같은 구조로 이루어져있다.</td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code class="language-c">struct sockaddr_un {
    sa_family_t sun_family;             /* AF_UNIX */
    char        sun_path[108];          /* Pathname */
}</code></pre>
<p>소켓 주소의 유형도 다음과 같은 세 가지로 구분될 수 있는데, 여기서는 UDS를 따로 만들어 바인드하지는 않고, 양방향 파이프처럼 사용하기 위한 <code>socketpair()</code>를 자주 사용해서, 전부 Unnamed라고 봐도 될 것 같다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Pathname</th>
<th>Unnamed</th>
<th>Abstract</th>
</tr>
</thead>
<tbody><tr>
<td><strong>파일 시스템</strong></td>
<td>생성 (실제 I/O 발생은 X)</td>
<td>없음</td>
<td>없음</td>
</tr>
<tr>
<td><strong>보안</strong></td>
<td>파일 권한 적용</td>
<td>프로세스 간만 가능</td>
<td>권한 문제 없음</td>
</tr>
<tr>
<td><strong>정리</strong></td>
<td><code>unlink()</code></td>
<td>자동 해제</td>
<td>자동 해제</td>
</tr>
<tr>
<td><strong>사용 예</strong></td>
<td>일반적인 IPC</td>
<td>언바운드, <code>socketpair()</code></td>
<td>임시/보안 통신</td>
</tr>
</tbody></table>
<blockquote>
<p>자세한 내용은 <code>man unix</code>에서 확인할 수 있습니다.</p>
</blockquote>
<h1 id="2-소스-코드-계속-읽어-보기-continue">2. 소스 코드 계속 읽어 보기 (continue)</h1>
<h4 id="srcosunixngx_process_cyclec"><code>/src/os/unix/ngx_process_cycle.c</code></h4>
<p>프로세스 시그널들을 블록하면서 마스터 프로세스 사이클을 시작한다. 프로세스의 작업을 수행하다 예상치 못한 신호로 인해 비정상적으로 중단되는 경우를 방지하기 위함이다. </p>
<pre><code class="language-c">// 
void
ngx_master_process_cycle(ngx_cycle_t *cycle)
{
    char              *title;
    u_char            *p;
    size_t             size;
    ngx_int_t          i;
    ngx_uint_t         sigio;
    sigset_t           set;
    struct itimerval   itv;
    ngx_uint_t         live;
    ngx_msec_t         delay;
    ngx_core_conf_t   *ccf;

    /**
        프로세스 시그널 블록
    */

    /**
        마스터 프로세스 시작 메시지 + 타이틀 출력 
    */

    //ngx_init_cycle()에서 저장했던 컨텍스트
    ccf = (ngx_core_conf_t *) ngx_get_conf(cycle-&gt;conf_ctx, ngx_core_module);

    /**
        컨텍스트 안에 몇 개의 워커 프로세스를 만들지가 들어있다(default: 1)
        NGX_PROCESS_RESPAWN == -3
    */
    ngx_start_worker_processes(cycle, ccf-&gt;worker_processes,
                               NGX_PROCESS_RESPAWN);
    ngx_start_cache_manager_processes(cycle, 0);

    //...

    for ( ;; ) {
        /**
            강제 종료 시 워커 프로세스들을 종료하기 위한 딜레이 처리
        */

        ngx_log_debug0(NGX_LOG_DEBUG_EVENT, cycle-&gt;log, 0, &quot;sigsuspend&quot;);

        //블록된 시그널들을 제외한 시그널들에 대한 수신 대기
        sigsuspend(&amp;set);

        ngx_time_update();

        ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle-&gt;log, 0,
                       &quot;wake up, sigio %i&quot;, sigio);

        /**
            종료, 강제 종료, 재설정, 재시작 등을 처리
        */
    }
}</code></pre>
<p>다음으로는 설정된 n개의 워커 프로세스를 생성하고, 마스터-워커의 IPC를 위한 유닉스 도메인 소켓을 생성하고 설정한다.</p>
<pre><code class="language-c">static void
ngx_start_worker_processes(ngx_cycle_t *cycle, ngx_int_t n, ngx_int_t type)
{
    ngx_int_t  i;

    ngx_log_error(NGX_LOG_NOTICE, cycle-&gt;log, 0, &quot;start worker processes&quot;);

    for (i = 0; i &lt; n; i++) {

        ngx_spawn_process(cycle, ngx_worker_process_cycle,
                          (void *) (intptr_t) i, &quot;worker process&quot;, type);

        ngx_pass_open_channel(cycle);
    }</code></pre>
<h4 id="srcosunixngx_processc"><code>/src/os/unix/ngx_process.c</code></h4>
<p>아래는 프로세스 생성 및 생성한 프로세스와의 통신을 위한 유닉스 도메인 소켓을 만드는 과정이다.</p>
<pre><code class="language-c">ngx_pid_t
ngx_spawn_process(ngx_cycle_t *cycle, ngx_spawn_proc_pt proc, void *data,
    char *name, ngx_int_t respawn)
{
    u_long     on;
    ngx_pid_t  pid;
    ngx_int_t  s;

    if (respawn &gt;= 0) {
        s = respawn;
    } else {
        //사용할 수 있는 슬롯을 찾기
        for (s = 0; s &lt; ngx_last_process; s++) {
            if (ngx_processes[s].pid == -1) {
                break;
            }
        }

        //현재 너무 많은 프로세스를 만든 경우
        if (s == NGX_MAX_PROCESSES) {
            ngx_log_error(NGX_LOG_ALERT, cycle-&gt;log, 0,
                          &quot;no more than %d processes can be spawned&quot;,
                          NGX_MAX_PROCESSES);
            return NGX_INVALID_PID;
        }
    }

    //NGX_PROCESS_DETACHED: 마스터와는 독립적으로 실행되는 프로세스인 경우
    if (respawn != NGX_PROCESS_DETACHED) {

        /**
            독립 프로세스가 아닌 경우 IPC를 위한 유닉스 도메인 소켓 페어를 생성.
            두 프로세스는 여기서 만들어진 소켓을 사용해 서로 통신한다.
            channel[0]: 마스터가 사용, channel[1]: 워커가 사용.
        */
        if (socketpair(AF_UNIX, SOCK_STREAM, 0, ngx_processes[s].channel) == -1)
        {
            ngx_log_error(NGX_LOG_ALERT, cycle-&gt;log, ngx_errno,
                          &quot;socketpair() failed while spawning \&quot;%s\&quot;&quot;, name);
            return NGX_INVALID_PID;
        }

        ngx_log_debug2(NGX_LOG_DEBUG_CORE, cycle-&gt;log, 0,
                       &quot;channel %d:%d&quot;,
                       ngx_processes[s].channel[0],
                       ngx_processes[s].channel[1]);

        /**
            생성한 두 소켓을 논블로킹으로 만듦
        */

        /**
            마스터 프로세스의 소켓을 비동기 + SIGIO를 받도록 설정
            : 마스터는 다른 일을 하다 워커로부터 메시지가 오면 그 일을 처리한다.
            : 워커는 epoll 등으로 마스터로부터의 메시지를 가져와 처리
        */

        /**
            이외 소켓 설정
        */

        //워커가 마스터와 통신하기 위해 사용할 소켓 FD를 channel[1]로 지정
        ngx_channel = ngx_processes[s].channel[1];

    } else {//독립적인 프로세스인 경우에는 IPC 채널을 따로 만들지 않아도 됨
        ngx_processes[s].channel[0] = -1;
        ngx_processes[s].channel[1] = -1;
    }

    ngx_process_slot = s;

    //드디어 자식 프로세스를 만듦!!
    pid = fork();

    switch (pid) {

    case -1://실패한 경우
        ngx_log_error(NGX_LOG_ALERT, cycle-&gt;log, ngx_errno,
                      &quot;fork() failed while spawning \&quot;%s\&quot;&quot;, name);
        ngx_close_channel(ngx_processes[s].channel, cycle-&gt;log);
        return NGX_INVALID_PID;

    case 0://자식 프로세스인 경우
        ngx_parent = ngx_pid;
        ngx_pid = ngx_getpid();
        proc(cycle, data);//해당 자식 프로세스로서의 생명 주기를 시작
        break;

    default://부모 프로세스인 경우
        break;
    }

    ngx_log_error(NGX_LOG_NOTICE, cycle-&gt;log, 0, &quot;start %s %P&quot;, name, pid);

    /**
        새롭게 생성한 프로세스 관련 정보들을 유지하기 위해 저장
    */

    //마지막 프로세스 번호인 경우 늘려줌
    if (s == ngx_last_process) {
        ngx_last_process++;
    }

    return pid;
}</code></pre>
<p>아래의 <code>ngx_pass_open_channel()</code>은 이전에 만들었던 다른 프로세스들에게 새로운 프로세스가 생겼음을 알리고, <code>channel[0]</code>을 공유하도록 한다. 따로 공유하지 않았다면 워커 프로세스끼리의 통신은 꼭 마스터를 경유해서만 이루어질 수 있었겠지만, 이제는 곧바로 다른 프로세스에 메시지를 보낼 수 있게 된다.</p>
<pre><code class="language-c">static void
ngx_pass_open_channel(ngx_cycle_t *cycle)
{
    ngx_int_t      i;
    ngx_channel_t  ch;

    ngx_memzero(&amp;ch, sizeof(ngx_channel_t));

    ch.command = NGX_CMD_OPEN_CHANNEL;
    ch.pid = ngx_processes[ngx_process_slot].pid;
    ch.slot = ngx_process_slot;
    ch.fd = ngx_processes[ngx_process_slot].channel[0];

    for (i = 0; i &lt; ngx_last_process; i++) {

        if (i == ngx_process_slot
            || ngx_processes[i].pid == -1
            || ngx_processes[i].channel[0] == -1)
        {
            continue;
        }

        ngx_log_debug6(NGX_LOG_DEBUG_CORE, cycle-&gt;log, 0,
                      &quot;pass channel s:%i pid:%P fd:%d to s:%i pid:%P fd:%d&quot;,
                      ch.slot, ch.pid, ch.fd,
                      i, ngx_processes[i].pid,
                      ngx_processes[i].channel[0]);

        /* TODO: NGX_AGAIN */

        ngx_write_channel(ngx_processes[i].channel[0],
                          &amp;ch, sizeof(ngx_channel_t), cycle-&gt;log);
    }
}</code></pre>
<blockquote>
<p>Q. 프로세스는 독립적인 FD를 가지는데, 어떻게 FD 번호만 보내서 소켓을 공유할 수 있을까?
A. <code>ngx_wite_channel()</code>을 보면 컨트롤 메시지의 타입을 <code>SCM_RIGHTS</code>로 설정한다. <code>SCM_RIGHTS</code>를 설정하면 한 프로세스의 파일 디스크립터를 다른 프로세스로 전달할 수 있게 된다(꼭 같은 번호를 쓰게 되는 건 아님!). 세부 내용은 <code>man unix</code>로 볼 수 있다.</p>
</blockquote>
<p>아까 만든 자식 프로세스는 아래의 사이클을 돌면서 비로소 이벤트를 처리하는 워커 프로세스로 거듭나게 된다.</p>
<pre><code class="language-c">//src/os/unix/ngx_process_cycle.c
static void
ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data)
{
    ngx_int_t worker = (intptr_t) data;

    ngx_process = NGX_PROCESS_WORKER;
    ngx_worker = worker;

    ngx_worker_process_init(cycle, worker);

    ngx_setproctitle(&quot;worker process&quot;);

    for ( ;; ) {

        /**
            종료 중일 때의 처리
        */

        ngx_log_debug0(NGX_LOG_DEBUG_EVENT, cycle-&gt;log, 0, &quot;worker cycle&quot;);

        //이벤트가 있으면 처리
        ngx_process_events_and_timers(cycle);

        /**
            강제 종료 or 정상 종료 or 로그 파일 재오픈 처리
        */
    }
}</code></pre>
<blockquote>
<p>다음은 워커 프로세스의 내부 구조와 소스 코드를 보도록 합시다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 2-1. Nginx의 구조 (1)]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-2-1</link>
            <guid>https://velog.io/@frog_slayer/Nginx-2-1</guid>
            <pubDate>Wed, 26 Mar 2025 11:59:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Nginx의 구조를 알아보고, 어떻게 만들어지는지 확인해봅시다. </p>
</blockquote>
<h1 id="1-nginx의-구조">1. NGINX의 구조</h1>
<h2 id="1-마스터-프로세스와-워커-프로세스">(1) 마스터 프로세스와 워커 프로세스</h2>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/085e3638-3a96-4fcc-afec-99baf77bb07e/image.png" alt=""></p>
<p>Nginx는 하나의 마스터 프로세스와 여러 개의 자식 프로세스들로 이루어져 있다. 자식 프로세스에는 캐시 매니저, 캐시 로더, 워커 프로세스 등이 있다.  캐시 관련 프로세스들은 간단히 그 역할만 짚고 넘어 가자.</p>
<ul>
<li>캐시 로더: 캐시할 데이터를 공유 메모리로 올리는 역할을 함.</li>
<li>캐시 매니저: 주기적으로 캐시를 정리하는 등의 역할을 함.</li>
</ul>
<p><strong>마스터 프로세스</strong>는 사용자와의 요청을 직접 처리하지는 않지만, 설정에 따라 서버의 상태를 초기화하고 각 워커 프로세스들을 생성하고 그 생명 주기를 관리하는 등의 역할을 하고,  <strong>워커 프로세스</strong>는 이벤트들을 처리하는 역할, 즉 직접 사용자와 연결을 맺고, 사용자로부터의 요청을 받고, 요청을 처리해 응답을 보내고, 연결을 끊는 역할을 한다.</p>
<p>각 워커 프로세스는 하나의 스레드로 이 이벤트들을 처리하는데, 시간이 오래 걸리는 블로킹 이벤트가 발생해 이후 이벤트를 처리하지 못하는 문제를 해결하기 위해 스레드 풀을 두고 스레드를 사용하기도 한다.</p>
<p>워커 프로세스를 몇 개 만들지는 <code>nginx.conf</code>에서 지정할 수 있다. 미지정 시 1개로 설정되고, <code>auto</code>를 사용하는 경우 CPU 코어의 개수만큼 생성된다. </p>
<pre><code class="language-nginx">worker_processes 3;#워커 프로세스를 3개 생성
worker_processes auto;#CPU 코어 개수에 맞게 워커 프로세스를 생성</code></pre>
<p>워커 프로세스를 CPU 코어 개수만큼 만들어서 사용하면 컨텍스트 스위칭으로 인한 오버헤드를 줄일 수 있고, 캐시 일관성도 높일 수 있다. 각 워커 프로세스가 어느 코어에서 돌아가게 할지는 보통 OS 스케줄링에 맡기지만, Nginx Plus에서는 각 프로세스를 특정 CPU에 바인딩하는 옵션도 있다.</p>
<pre><code class="language-nginx">worker_processes  4;
worker_cpu_affinity  0001 0010 0100 1000;#Nginx Plus. 비트마스크로 코어 바인딩</code></pre>
<h2 id="2-소스-코드를-뜯어-보자">2. 소스 코드를 뜯어 보자.</h2>
<p>오픈 소스인만큼 코드가 공개되어 있다(<a href="https://github.com/nginx/nginx">https://github.com/nginx/nginx</a>). 소스코드를 보면서 어떻게 위와 같은 구조가 형성되는지 살펴보자.</p>
<p>아래 소스 코드에서 계속해서 &quot;이전&quot;이라는 말이 나오는데, Nginx의 무중단 재시작 때문이다. 무중단 재시작의 흐름은 다음과 같다.</p>
<ol>
<li>새 마스터 프로세스가 시작</li>
<li>새 워커 프로세스가 생기고 트래픽을 처리</li>
<li>기존 워커 프로세스는 기존 연결을 처리하고, 새 요청은 새 워커 프로세스에서 처리됨</li>
<li>기존 워커 프로세스는 모든 연결을 처리한 후 종료됨</li>
</ol>
<p>Nginx는 무중단 재시작을 통해, 설정 파일을 고친 후에도 굳이 끄고 켜지 않고 새 설정이 반영된 서비스를 지속할 수 있다. 단 이때에는 이전에 사용했던 포트가 남아 있거나 할 수 있기 때문에 처리해줘야 하게 된다.</p>
<h4 id="srccorenginxc"><code>/src/core/nginx.c</code></h4>
<p>Nginx의 실행이 시작되는 <code>main()</code>함수가 들어있다. 여러 곁가지 작업들을 제외하면 대충 다음과 같이 진행된다.</p>
<pre><code class="language-c">int ngx_cdecl
main(int argc, char *const *argv)
{
    //...
    ngx_cycle_t      *cycle, init_cycle;
    //...

    /**
        프로세스 시작을 위한 여러 초기 작업들을 수행
    */

    //init_cycle은 환경 초기화를 위한 구조체
    ngx_memzero(&amp;init_cycle, sizeof(ngx_cycle_t));
    init_cycle.log = log;
    //ngx_cycle은 실제 nginx의 실행 상태 관리를 위한 ngx_cycle_t 전역 변수
    ngx_cycle = &amp;init_cycle;

    /**
        초기 설정 및 리소스 초기화 작업
    */

    //nginx.conf의 설정에 따른 초기화 작업. src/core/ngx_cycle.c에 있다.
    cycle = ngx_init_cycle(&amp;init_cycle);

    /**
        설정이 제대로 됐는지 확인하기
    */

    //ngx_cycle 변경
    ngx_cycle = cycle;

    /**
        PID 파일 생성 및 로그가 파일로 저장될 수 있도록 stderr를 리디렉션
    */

    //NGX_PROCESS_SINGLE은 nginx 개발 테스트를 위해서만 쓰임. 프로덕션에서는 사용 X
    if (ngx_process == NGX_PROCESS_SINGLE) {
        ngx_single_process_cycle(cycle);
    } else {
        //마스터 프로세스로서의 생명 주기를 시작
        ngx_master_process_cycle(cycle);
    }

    return 0;
}</code></pre>
<h4 id="srccorengx_cyclec"><code>/src/core/ngx_cycle.c</code></h4>
<p>이 소스 파일의 <code>ngx_init_cycle()</code>에서는 실행 주기에서 사용할 메모리 풀을 생성하는 등의 여러 초기화 과정과 함께, <code>nginx.conf</code> 같은 설정 파일들 분석해 실행 컨텍스트에 등록한다. </p>
<p>Nginx에서는 프로세스 간 공통 데이터 관리를 위해 공유 메모리를 사용하는데, 공유 메모리 설정도 여기서 한다(<code>/* create shared memory */</code> 이하). 이 공유 메모리는 여러 가지 성능 최적화를 위해 쓰인다.</p>
<ul>
<li>모든 워커 프로세스가 동일한 캐시 데이터를 참조할 수 있도록 공유 메모리에 캐시 메타데이터를 저장</li>
<li>워커 프로세스들의 구성 공유 (실시간 구성 동기화도 가능)</li>
<li>SSL/TLS 세션의 재사용</li>
<li>사용자 세션 추적(특정 클라이언트에 특정 서버에 고정하기 위한 세션 정보 저장)</li>
<li>이외에도 더 많다.</li>
</ul>
<p>리스닝 소켓을 여는 작업도 여기서 한다(<code>/* handle the listening sockets */</code> 이하). </p>
<pre><code class="language-c">ngx_cycle_t *
ngx_init_cycle(ngx_cycle_t *old_cycle)
{
    /**
        구성 파일 읽기 등의 초기화
        공유 메모리 설정
    */

    /* handle the listening sockets */

    /*
        이전에 사용하던 리스닝 소켓이 있으면 재사용할지 결정
    */

    //아래 ngx_open_listening_sockets() &amp; ngx_configure_listening_sockets()는 /src/core/ngx_connection.c에 구현되어 있다.
    if (ngx_open_listening_sockets(cycle) != NGX_OK) {
        goto failed;
    }

    if (!ngx_test_config) {
        ngx_configure_listening_sockets(cycle);
    }

    //...
}</code></pre>
<h4 id="srccorengx_connectionc"><code>/src/core/ngx_connection.c</code></h4>
<p>소켓을 만들고, 열고, 구성을 변경하고, 닫는 등의 작업들이 있다.</p>
<pre><code class="language-c">ngx_int_t
ngx_open_listening_sockets(ngx_cycle_t *cycle)
{
    //...

    //제대로 소켓이 안 만들어지는 경우를 대비해 5번 재시도
    for (tries = 5; tries; tries--) {
        failed = 0;

        /* for each listening socket */

        ls = cycle-&gt;listening.elts;
        for (i = 0; i &lt; cycle-&gt;listening.nelts; i++) {

            if (ls[i].ignore) {//이미 열린 소켓인 경우를 제외
                continue;
            }

            /**
                SO_REUSEPORT가 아닌 소켓을 SO_REUSEPORT로 변경하는 부분
            */

            if (ls[i].fd != (ngx_socket_t) -1) {
                continue;//이미 제대로 만들어진 소켓인 경우 패스
            }

            if (ls[i].inherited) {
                continue;//직접 만든 소켓이 아니라, 부모 프로세스로부터 받은 경우
            }

            //새로운 소켓을 생성. 성공 시 fd, 실패 시 -1 반환
            s = ngx_socket(ls[i].sockaddr-&gt;sa_family, ls[i].type, 0);

            //소켓 생성에 실패하는 경우
            if (s == (ngx_socket_t) -1) {
                ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
                              ngx_socket_n &quot; %V failed&quot;, &amp;ls[i].addr_text);
                return NGX_ERROR;
            }

            //UDP 소켓이 아니면
            if (ls[i].type != SOCK_DGRAM || !ngx_test_config) {

                //소켓 주소 재사용이 불가능한 경우
                if (setsockopt(s, SOL_SOCKET, SO_REUSEADDR,
                               (const void *) &amp;reuseaddr, sizeof(int))
                    == -1)
                {
                    ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
                                  &quot;setsockopt(SO_REUSEADDR) %V failed&quot;,
                                  &amp;ls[i].addr_text);

                    if (ngx_close_socket(s) == -1) {
                        ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
                                      ngx_close_socket_n &quot; %V failed&quot;,
                                      &amp;ls[i].addr_text);
                    }

                    return NGX_ERROR;
                }
            }

            /**
                RESUSEPORT인 경우, IPv6만을 사용하는 경우 처리
            */

            /*
                ngx_event_flags: SELECT, EPOLL, IOCP 등 이벤트 모델 플래그   
                IOCP가 아닌 경우 소켓을 논블로킹으로 만든다.
                실패하면 소켓을 닫고 에러 반환
                ?왜 IOCP일 때는 논블로킹 설정을 안해도 될까?는 나중에 공부해보겠습니다.
            */
            if (!(ngx_event_flags &amp; NGX_USE_IOCP_EVENT)) {
                if (ngx_nonblocking(s) == -1) {
                    ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
                                  ngx_nonblocking_n &quot; %V failed&quot;,
                                  &amp;ls[i].addr_text);

                    if (ngx_close_socket(s) == -1) {
                        ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
                                      ngx_close_socket_n &quot; %V failed&quot;,
                                      &amp;ls[i].addr_text);
                    }

                    return NGX_ERROR;
                }
            }

            ngx_log_debug2(NGX_LOG_DEBUG_CORE, log, 0,
                           &quot;bind() %V #%d &quot;, &amp;ls[i].addr_text, s);

            //소켓 바인딩
            if (bind(s, ls[i].sockaddr, ls[i].socklen) == -1) {
                //실패한 경우
                err = ngx_socket_errno;

                //NGX_EADDRINUSE: 이미 사용중인 주소인 경우
                if (err != NGX_EADDRINUSE || !ngx_test_config) {
                    ngx_log_error(NGX_LOG_EMERG, log, err,
                                  &quot;bind() to %V failed&quot;, &amp;ls[i].addr_text);
                }

                if (ngx_close_socket(s) == -1) {
                    ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
                                  ngx_close_socket_n &quot; %V failed&quot;,
                                  &amp;ls[i].addr_text);
                }

                /**
                    이미 사용 중인 주소인 경우 나중에 다시 연결 시도를 하고, 
                    이외의 오류인 경우는 에러 반환
                */
                if (err != NGX_EADDRINUSE) {
                    return NGX_ERROR;
                }

                if (!ngx_test_config) {
                    failed = 1;
                }

                continue;
            }

            //TCP 소켓이 아니면 아까 만든 소켓의 파일 디스크립터로 설정하면 됨
            if (ls[i].type != SOCK_STREAM) {
                ls[i].fd = s;
                continue;
            }

            //논블로킹 소켓으로 리스닝 시작
            if (listen(s, ls[i].backlog) == -1) {
                //실패하는 경우
                err = ngx_socket_errno;

                /*
                 * on OpenVZ after suspend/resume EADDRINUSE
                 * may be returned by listen() instead of bind(), see
                 * https://bugs.openvz.org/browse/OVZ-5587
                 */

                if (err != NGX_EADDRINUSE || !ngx_test_config) {
                    ngx_log_error(NGX_LOG_EMERG, log, err,
                                  &quot;listen() to %V, backlog %d failed&quot;,
                                  &amp;ls[i].addr_text, ls[i].backlog);
                }

                if (ngx_close_socket(s) == -1) {
                    ngx_log_error(NGX_LOG_EMERG, log, ngx_socket_errno,
                                  ngx_close_socket_n &quot; %V failed&quot;,
                                  &amp;ls[i].addr_text);
                }

                if (err != NGX_EADDRINUSE) {
                    return NGX_ERROR;
                }

                if (!ngx_test_config) {
                    failed = 1;
                }

                continue;
            }

            ls[i].listen = 1;

            ls[i].fd = s;
        }

        //원하는 소켓들을 다 만들었음
        if (!failed) {
            break;
        }

        /* TODO: delay configurable */

        ngx_log_error(NGX_LOG_NOTICE, log, 0,
                      &quot;try again to bind() after 500ms&quot;);
        //만들지 못한 소켓이 있으면 잠시 대기 후 재시도
        ngx_msleep(500);
    }

    // 재시도를 해도 불가능한 경우 에러 반환
    if (failed) {
        ngx_log_error(NGX_LOG_EMERG, log, 0, &quot;still could not bind()&quot;);
        return NGX_ERROR;
    }

    return NGX_OK;
}
</code></pre>
<p><code>nginx.conf</code>에 다음과 같은 서버 블럭이 있으면, </p>
<pre><code class="language-nginx">server {
    listen 80;           # 포트 80에 대한 설정
    listen 443 ssl;      # 포트 443에 대한 SSL 설정
    server_name example.com;
    ...
}</code></pre>
<ol>
<li>소켓을 생성하고</li>
<li>논블로킹 소켓으로 만든 후 </li>
<li>바인딩하고</li>
<li>리슨한다<ul>
<li>논블로킹 소켓이므로 리슨을 하더라도 스레드가 멈추지 않고, 다른 작업을 하고 있더라도 리슨 소켓에 생기는 I/O 이벤트를 받을 수 있게 된다.</li>
</ul>
</li>
</ol>
<p>필요한 리스닝 소켓들을 만들고 나면, <code>ngx_configure_listening_sockets()</code>를 호출해, 고급 소켓 옵션들을 설정한다.</p>
<p>이후 <code>ngx_init_cycle()</code>에서 필요한 작업들을 모두 마치고, 나머지 작업들을 하고 나면 <code>ngx_master_process_cycle()</code>를 호출해, 본격적으로 마스터 프로세스로서의 생명 주기를 시작한다.</p>
<blockquote>
<p>분량 조절 실패(2)로, 마스터 프로세스 생명 주기 및 워커 프로세스 생성 + 관리는 다음 시간에.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Nginx] 1. 이벤트 드리븐 웹 서버]]></title>
            <link>https://velog.io/@frog_slayer/Nginx-1</link>
            <guid>https://velog.io/@frog_slayer/Nginx-1</guid>
            <pubDate>Tue, 25 Mar 2025 11:48:46 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://velog.io/@frog_slayer/OS-27.-Event-based-Concurrency">이 글</a>에서 이벤트 기반 동시성에 대해 다룬 적이 있습니다. 이번에는 좀 더 구체적으로, 이벤트 큐로 활용되는 시스템 콜들이 어떻게 동작하는지를 자세하게 다뤄봅시다.</p>
</blockquote>
<h3 id="1-다중처리-모듈의-문제와-이벤트-기반-동시성-프로그래밍">(1) 다중처리 모듈의 문제와 이벤트 기반 동시성 프로그래밍</h3>
<p>요청마다 스레드, 혹은 프로세스를 만드는 <strong>다중처리 모듈</strong>(MPM, Multi Processing Module) 방식에서 일어날 수 있는 문제에는 크게 세 가지가 있다.</p>
<ol>
<li>동시성 관리가 어렵다.<ul>
<li>웹 서버의 경우, HTTP가 무상태적이기는 하지만, 레이스 컨디션이 있는 경우에는 직접 동기화를 해줄 필요가 있다. </li>
</ul>
</li>
<li>개발자가 스케줄링을 직접 제어하기는 어렵다. <ul>
<li>개발자는 스레드를 만들고 OS가 알아서 잘 스케줄링을 해주기를 바랄 뿐이다.</li>
</ul>
</li>
<li>스레드가 너무 많이 생기게 되면 스레드 컨텍스트 스위칭에 많은 시간을 쏟게 되어 전체적인 성능이 떨어지게 된다.<ul>
<li>그래서 보통은 만들 수 있는 스레드의 수를 제한하거나, 일정한 수의 스레드를 미리 만들어 놓는 방법(스레드 풀)을 사용한다.</li>
</ul>
</li>
</ol>
<p>이벤트 기반 동시성 프로그래밍은 &quot;이벤트 큐&quot;를 통해 단일 스레드에서의 비동기 처리를 가능하게 하는 방법으로, 자바스크립트의 node.js, 자바의 Netty, 그리고 오늘 다룰 Nginx가 대표적인 이벤트-드리븐 웹 서버들이다. </p>
<h3 id="2-이벤트란-이벤트는-어떻게-처리할까">(2) 이벤트란? 이벤트는 어떻게 처리할까?</h3>
<p>이벤트라 부르는 것에는 여러 가지가 있는데, 대충 다음과 같은 것들이 있다.</p>
<ul>
<li>연결 맺기</li>
<li>요청 받기</li>
<li>응답 보내기</li>
<li>연결 끊기 </li>
<li>타이머 이벤트<ul>
<li>클라이언트가 연결을 맺고 요청을 오랫동안 보내지 않는 경우 등</li>
</ul>
</li>
<li>백엔드 이벤트(업스트림 이벤트)<ul>
<li>백엔드 서버 헬스 체크 등 </li>
</ul>
</li>
</ul>
<p>이벤트 드리븐 웹 서버에서는 일종의 큐 역할을 하는 자료 구조에 이러한 이벤트들을 담고, 이벤트가 준비가 되는대로 하나씩 꺼내 필요한 처리를 함으로써 <strong>동시성</strong>(concurrency)을 달성한다. </p>
<blockquote>
<p><strong>동시성</strong>(병행성, concurrency)
여러 작업을 잘게 나눠 번갈아 실행함으로써, 작업들이 꼭 동시에 실행되는 것처럼 보이게 함.
ex) 시분할 시스템, 멀티 스레드, 이벤트 기반 동시성</p>
</blockquote>
<p>다시 멀티 스레드 웹 서버와 비교해보자. 멀티 스레드 웹 서버에서는 연결-요청-응답-연결 끊기와 같은 일련의 작업을 하나의 스레드에 배정한다. 여러 요청이 있으면 여러 스레드가 짧은 시간동안 서로 돌아가면서 CPU를 점유해, 마치 여러 스레드가 동시에 돌아가는 듯한, 여러 요청이 동시에 처리되는 듯한 환상을 만들어낸다.</p>
<p>이벤트 드리븐 웹 서버의 경우, 연결-요청-응답-연결 끊기의 일련의 작업을 각각의 이벤트로 분할하고 하나의 스레드에서 처리한다. 여러 요청이 들어오는 경우에는, (물론 이벤트가 준비되는 순서에 따라 이벤트 처리 순서가 달라질 수 있겠지만)  연결(1)-연결(2)-요청(1)-요청(2)-응답(1)-연결 끊기(1)-응답(2)-연결 끊기(2)와 같은 순서로 각 이벤트를 처리함으로써 여러 요청이 동시에 처리되는 듯한 환상을 만들어낸다. </p>
<p>이제 대표적인 이벤트 드리븐 웹서버인 Nginx와, Nginx가 어떻게 이벤트 드리븐 웹 서버를 구현하고 있는지 확인해보자.</p>
<h1 id="1-nginx">1. Nginx</h1>
<p>Nginx는 2004년 러시아에서 만들어진 오픈 소스 웹 서버다. </p>
<h2 id="1-nginx가-제공하는-기능에는-어떤-게-있을까">(1) Nginx가 제공하는 기능에는 어떤 게 있을까?</h2>
<p>Nginx가 제공하는 기능은 정말 많은데, 몇 가지를 추리자면 다음 정도가 있다.</p>
<ol>
<li>웹 서버</li>
<li>리버스 프록시 기능</li>
<li>로드 밸런서</li>
<li>HTTP 캐시</li>
<li>SSL/TLS 지원</li>
</ol>
<blockquote>
<p><strong>리버스 프록시</strong>(Reverse Proxy)
일반적으로 말하는 프록시가 클라이언트가 자신을 숨기는 것이라면, 리버스 프록시는 서버가 자신을 숨기는 것을 말한다. 리버스 프록시 서버는 클라이언트의 요청을 대신 받아 백엔드로 전달함으로써 실제 요청을 처리하는 백엔드 서버를 숨기는데, 이렇게 함으로써 얻을 수 있는 장점에는 다음과 같은 것들이 있다.</p>
<ul>
<li>보안 강화: 클라이언트가 직접 백엔드 서버에 접근하지 못하게 막을 수 있고, DDoS 같이 악의적인 트래픽을 걸러낼 수도 있다.</li>
<li>클라이언트와의 연결은 HTTPS, 백엔드 서버와의 연결은 HTTP를 사용해 백엔드 서버의 부담을 줄일 수 있다.</li>
<li>리버스 프록시 서버를 하나의 게이트웨이로 두고, 뒤에 여러 개의 백엔드 서버를 둬 트래픽을 분산해 부하를 줄일 수 있다(로드 밸런싱).</li>
</ul>
<p><strong>Nginx에서 제공하는 부하 분산 옵션</strong></p>
<ul>
<li>라운드 로빈</li>
<li>가중 라운드 로빈<ul>
<li>각 백엔드 서버에 가중치를 두는 라운드 로빈</li>
</ul>
</li>
<li>IP Hash<ul>
<li>클라이언트 IP + 포트를 해싱해, 한 클라이언트가 항상 한 백엔드 서버와 연결을 맺도록 할 수 있음</li>
</ul>
</li>
<li>Least Connection<ul>
<li>현재 연결된 클라이언트의 수가 가장 적은 서버를 선택 </li>
</ul>
</li>
<li>Least time, 스티키 쿠키, 스티키 런, 스티키 라우팅 (Nginx Plus 유료 결제 필요)</li>
</ul>
</blockquote>
<h2 id="2-nginx에서는-어떻게-이벤트를-관리할까">(2) Nginx에서는 어떻게 이벤트를 관리할까?</h2>
<p>Nginx에서는 독자적인 이벤트 큐를 구현해 사용하지는 않고, <code>select</code>, <code>poll</code>,  <code>epoll</code>과 같은, OS가 제공하는 I/O 멀티플렉싱 시스템 콜(사실 <code>epoll</code> 자체는 시스템 콜이 아니라 자료 구조긴 함)을 활용해 비동기 이벤트 처리를 한다. 이 시스템 콜들은 간단히 말해, 등록된 파일 디스크립터(들)을 감시하다가 읽기/쓰기가 가능해지면 알림을 주는 역할을 한다.</p>
<p>리눅스/UNIX에서 &quot;모든 것이 파일&quot;이기 때문에, 소켓도 일종의 파일로 취급되고 파일 디스크립터로 관리할 수 있으며, <code>epoll</code> 등에 등록해 OS가 해당 소켓의 네트워크 I/O 상태를 감시하다, 읽거나 쓸 준비가 되면 이를 알려주게 할 수 있다.</p>
<pre><code class="language-c">//socket()은 소켓을 만들고, 쓰이지 않은 가장 낮은 파일 디스크립터 번호를 반환한다. 
int socket(int domain, int type, int protocol);</code></pre>
<p><code>select</code>, <code>poll</code>, <code>epoll</code> 중 어떤 걸 사용할지는 설정을 할 때 정할 수 있다.</p>
<h3 id="select"><code>select()</code></h3>
<p>읽을/쓸/예외 발생을 확인할 파일 스크립터들을 각 파일 디스크립터 집합(<code>fd_set</code>)에 넣어 <code>select()</code>에 인자로 전달한다.</p>
<pre><code class="language-c">//return &gt; 0: 준비된 파일 디스크립터 수, 0: 타임아웃, -1: 오류 발생
int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);
</code></pre>
<p><code>fd_set</code>은 아래와 같이 정수형 배열을 가지는 구조체로, 각 파일 디스크립터를 비트마스크로 관리한다.</p>
<pre><code class="language-c">struct fd_set{
            unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(long))];
            };</code></pre>
<p>내부적으로 <code>select()</code>는 타임아웃이 나거나, 전달한 <code>fd_set</code>에 준비된 파일 디스크립터가 생길 때까지  0~<code>nfds-1</code>의 파일 디스크립터를 반복해서 순차적으로 확인하는 방식을 사용한다.</p>
<p><code>select()</code>가 0보다 큰 값은 반환하는 경우, 아래의 매크로를 통해 원하는 <code>fd_set</code>의 어떤 파일 디스크립터가 준비된 상태인지를 확인할 수 있다. </p>
<pre><code class="language-c">int  FD_ISSET(int fd, fd_set *set);</code></pre>
<blockquote>
<p><code>select()</code>는 이해하기 쉽기는 하지만, 단점이 너무 너무 많다.</p>
<ul>
<li>모니터링할 수 있는 최대 파일 디스크립터 수인 <code>FD_SETSIZE</code>는 보통 1024로 고정되어 있다. </li>
<li><code>select()</code>는 모니터링을 하기 위해 주어진 범위의 FD를 순차적으로 확인한다. 비효율적이다.</li>
<li><code>select()</code>후, 어떤 FD가 준비된 상태인지 확인하기 위해서도 직접 FD를 순차적으로 확인해야 한다.</li>
</ul>
</blockquote>
<h3 id="poll"><code>poll()</code></h3>
<p><code>poll()</code>은 <code>select()</code>와 비슷하지만, <code>FD_SETSIZE</code> 같은 제한이 없어서 더 많은 파일 디스크립터를 확인할 수 있다.</p>
<pre><code class="language-c">//return &gt; 0: 준비된 파일 디스크립터 수, 0: 타임아웃, -1: 오류 발생
int poll(struct pollfd *fds, nfds_t nfds, int timeout);</code></pre>
<p><code>pollfd</code> 구조체에는 확인할 FD를 기입하고, 어떤 이벤트를 확인할 것인지를 <code>events</code>에 비트 필드로 넣을 수 있다. 예를 들어 읽을 준비가 됐는지를 확인하려면 <code>POLLIN</code>, 쓸 준비가 됐는지를 확인하려면 <code>POLLOUT</code>을 쓰면 되고, 둘 중 하나라도 준비됐는지 확인하고 싶으면 <code>POLLIN | POLLOUT</code>으로 쓰면 된다.</p>
<pre><code class="language-c">struct pollfd {
               int   fd;         /* file descriptor */
               short events;     /* requested events */
               short revents;    /* returned events */
           };</code></pre>
<p><code>revents</code>는 실제로 발생한 이벤트들이 채워지는 필드다. <code>events</code>에 설정해놓은 이벤트를 확인하고, 준비가 됐다면 해당 필드의 비트를 1로 만든다. 혹은 어떤 에러가 발생하는 경우에도 그 에러에 해당하는 비트를 1로 만든다.  </p>
<p>파일 디스크립터들이 준비됐는지 확인하는 것도 <code>select()</code>와 비슷하다. 구조체 배열<code>fds</code>에 들어있는 <code>nfds_t</code>개의 파일 디스크립터들을 순차적으로 확인하면서, <code>revents</code>가 0이 아니게 된 것이 있는지를 확인하는 방법이다.</p>
<blockquote>
<p><code>poll()</code>은 <code>select()</code>와 달리 모니터링할 수 있는 FD 수에 제한이 없기는 하지만, 마찬가지로 순차적으로 확인해야하기 때문에 비효율적이다.</p>
</blockquote>
<h2 id="3-epoll">(3) <code>epoll</code></h2>
<p>요즘 리눅스에서의 고성능 서버의 경우에는 <code>select()</code>나 <code>poll()</code> 대신 <code>epoll</code>을 사용한다고 한다. <code>epoll</code>은 커널에서 관리되는 자료 구조로, 그 핵심 컨셉은 커널에 두 리스트를 담은 <code>epoll</code> 인스턴스를 만들어 사용하는 것이다.</p>
<ul>
<li>interest list(<code>epoll</code> set): 사용자 프로세스에서 모니터링하고 싶은 파일 디스크립터 집합</li>
<li>ready list: interest list의 파일 디스크립터들 중 I/O가 준비된 파일 디스크립터들의 집합</li>
</ul>
<p><code>epoll</code> 인스턴스를 만들고 제어하기 위해서는 아래의 시스템 콜들을 사용한다.</p>
<pre><code class="language-c">int epoll_create(int size);</code></pre>
<p><code>epoll_create()</code>는 새 <code>epoll</code> 인스턴스를 만들고 해당 인스턴스의 파일 디스크립터를 반환한다. 이때 <code>size</code>는 해당 <code>epoll</code> 인스턴스에 추가할 파일 디스크립터의 수를 알려주기 위해 쓰인다. 반환된 FD는 이후 <code>epoll</code> 관련 시스템 콜들을 호출할 때 쓰이고, 더 이상 필요가 없어지면 <code>close()</code>로 닫으면 되고, 해당 <code>epoll</code> 인스턴스를 가리키는 모든 파일 디스크립터가 닫히면 커널이 알아서 인스턴스를 삭제하고 자원을 반납한다.</p>
<pre><code class="language-c">int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);</code></pre>
<p><code>epoll_ctl()</code>은 <code>epoll</code> 인스턴스를 제어하는 데 쓰인다. 어떤 <code>epoll</code> 인스턴스(<code>epfd</code>)에 어떤 작업을 할지(<code>op</code>), 어떤 FD(<code>fd</code>)에 대한 것인지, 또 어떤 이벤트인지를 명시해야 한다. 성공 시 0을, 에러 발생 시 -1을 반환한다.</p>
<p><code>op</code>로 가능한 작업으로는 다음의 세 가지가 있다.</p>
<ul>
<li><code>EPOLL_CTL_ADD</code>: 인스턴스에 모니터링할 새 파일 디스크립터와 이벤트를 추가. </li>
<li><code>EPOLL_CTL_MOD</code>: 인스턴스의 파일 디스크립터의 interest list를 변경.</li>
<li><code>EPOLL_CTL_DEL</code>: 인스턴스에서 해당 파일 디스크립터를 삭제. <code>event</code>는 무시됨.</li>
</ul>
<p><code>epoll_event</code>는 다음과 같은 구조체다.</p>
<pre><code class="language-c">typedef union epoll_data {
   void        *ptr;
   int          fd;
   uint32_t     u32;
   uint64_t     u64;
} epoll_data_t;

struct epoll_event {
   uint32_t     events;      /* Epoll events */
   epoll_data_t data;        /* User data variable */
};</code></pre>
<p> <code>data</code> 멤버는 커널이 저장해놓고, 나중에 <code>epoll_wait()</code>로 해당 파일 디스크립터가 준비됐을 때 반환하게 되고, <code>events</code> 멤버는 모니터링할 이벤트를 비트 필드로 나타낸 것이다. 여러 가지 이벤트들이 있지만 <code>poll()</code>과 비슷하게, 읽기 작업이 준비됐는지를 확인하려면 <code>EPOLLIN</code>, 쓰기 작업이 준비됐는지 확인하려면 <code>EPOLLOUT</code>, 둘 중 하나라도 준비됐는지 확인하려면 <code>EPOLLIN | EPOLLOUT</code>과 같이 쓸 수 있다.</p>
<p>여기서 한 가지 중요한 이벤트 플래그 <code>EPOLLET</code>을 짚고 넘어 가자. <code>epoll</code>에는 다음과 같은 두 가지 모드가 있다.</p>
<ul>
<li>Edge Triggered(ET): 모니터링 중인 파일 디스크립터에 상태 변화가 생기는 경우에만 이벤트를 전달</li>
<li>Level Triggered(LT): 모니터링 중인 파일 디스크립터가 준비 상태면 계속 이벤트를 전달</li>
</ul>
<p>man-page에 나오는 예로, 다음과 같은 상황을 생각해보자.</p>
<ol>
<li>파이프의 읽기 쪽을 나타내는 파일 디스크립터 <code>rfd</code>를 <code>epoll</code>에 등록</li>
<li>해당 파이프에 2kB의 데이터를 씀</li>
<li><code>epoll_wait()</code>을 호출하고 <code>rfd</code>가 읽기 준비 됐음을 확인</li>
<li><code>rfd</code>로부터 1kB의 데이터를 읽음</li>
<li><code>epoll_wait()</code> 호출</li>
</ol>
<p>ET 모드인 경우, 준비가 되지 않은 상태에서 준비가 된 상태로 변한 3번에서만 이벤트가 전달되어 1kB만을 읽을 수 있게 되지만, LT 모드인 경우 <code>rfd</code>가 준비된 상태를 유지하기 때문에 5번 이후 남은 데이터도 읽을 수 있게 된다.</p>
<p>LT는 직관적이고 간편하지만, 단순히 말하면 그냥 좀 더 빠른 <code>poll()</code>이고, 불필요한 이벤트가 계속 발생할 수도 있다.</p>
<p>이와 달리 ET는 상태가 변경되는 순간에만 이벤트를 발생시키기 때문에 이벤트가 발생하는 빈도가 적고, 성능을 최적화하는 데에도 쓸 수 있다. 다만 얼마나 데이터를 읽었는지 계속해서 상태를 추적해야 하기 때문에 복잡하고, 논 블로킹 I/O를 사용하지 않으면 미처리된 데이터를 다시 처리하지 못하게 될 수도 있다.</p>
<p> 기본적으로는 LT를 사용하는데, ET를 쓰고 싶은 경우 이벤트에 <code>EPOLLET</code> 플래그를 추가하면 된다.</p>
<pre><code class="language-c">int epoll_wait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout);</code></pre>
<p>마지막 <code>epoll_wait()</code>는 <code>epoll</code> 인스턴스의 FD 모니터링을 시작하기 위한 시스템 콜로, 최대 <code>maxevents</code>개의 이벤트를 반환한다. 여기의 <code>events</code>는 ready list로, <code>epoll_ctl()</code>로 interest list에 넣은 FD 중 준비가 된 것들이 들어가게 된다. 이때 <code>epoll_event</code>의 <code>data</code> 멤버는 <code>epoll_ctl()</code>로 넣은 것과 동일하고, <code>events</code> 멤버는 실제로 모니터링된 결과가 비트 필드로 주어진다.</p>
<h3 id="q-epoll의-상태-추적은-어떻게-이루어질까">Q. <code>epoll</code>의 상태 추적은 어떻게 이루어질까?</h3>
<p><code>select()</code>나 <code>poll()</code>은 파일 디스크립터 중 어느 것이 준비됐는지를 확인하기 위해 주기적으로 모든 FD에게 준비 여부를 물어보는 방식을 택했기 때문에 비효율적이었다.  </p>
<p><code>epoll</code>의 경우는 다음과 같이 동작한다.</p>
<ol>
<li>등록 단계<ul>
<li><code>epoll_create()</code>로 <code>epoll</code> 인스턴스 생성<ul>
<li>커널 내부에는 <code>eventpoll</code> 구조체가 생성되고, 파일 추적 및 관리를 위한 레드-블랙 트리의 루트 노드와 준비 리스트가 생긴다.</li>
</ul>
</li>
<li><code>epoll_ctl()</code>로 FD를 <code>epoll</code> 인스턴스에 등록<ul>
<li><code>epitem</code> 구조체를 생성하고, (파일 구조, FD 번호)를 키로 레드-블랙 트리에 삽입</li>
<li>동일한 키가 있는 경우는 등록에 실패</li>
</ul>
</li>
<li>파일 객체와 <code>epitem</code>을 연결<ul>
<li>파일 객체는 자신을 모니터링하는 모든 <code>epitem</code>를 연결리스트로 유지함.</li>
</ul>
</li>
</ul>
</li>
<li>I/O 이벤트 발생 시<ul>
<li>네트워크 패킷이 도착하거나 디스크 I/O가 완료됨</li>
<li>I/O 완료 인터럽트가 발생</li>
<li>커널은 해당 파일의 상태 확인 메서드(ex. <code>sock_poll()</code>)를 호출. 플래그를 반환 받음</li>
<li>해당 파일 객체와 연결된 모든 <code>epitem</code>의 콜백 함수(<code>ep_poll_callback()</code>)를 실행</li>
<li>각 <code>epitem</code>은 자신이 소속된 인스턴스의 ready list에 추가됨.</li>
</ul>
</li>
<li><code>epoll_wait()</code> 호출 시<ul>
<li>ready list에서 이벤트가 조회한 FD만을 조회해 사용자에게 반환</li>
</ul>
</li>
</ol>
<p>결국 <code>epoll</code>의 경우, 커널이 각 FD에 직접 준비 여부를 물어보는 대신, 콜백 함수를 이용해 준비된 FD가 스스로 각 인스턴스에 준비 여부를 알리도록 하기 때문에 훨씬 빠르다.</p>
<blockquote>
<p>분량 조절 실패로 Nginx의 구조와 어떻게 Nginx가 구성되는지는 다음 시간에...</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Algo] 할당 문제(Assignment problem)와 헝가리안 알고리즘 (Hungarian Algorithm)]]></title>
            <link>https://velog.io/@frog_slayer/Hungarian-Algorithm</link>
            <guid>https://velog.io/@frog_slayer/Hungarian-Algorithm</guid>
            <pubDate>Tue, 04 Mar 2025 15:42:18 GMT</pubDate>
            <description><![CDATA[<p>다음과 같은 <a href="https://www.acmicpc.net/problem/14216">문제</a>를 생각해보자.</p>
<blockquote>
<p>$N$명의 사람과 $N$개의 작업이 있다. 모든 사람은 모든 작업을 할 수 있고, 각 사람이 각 작업을 마치는 데 드는 비용이 주어진다고 하자.</p>
<p>전체 비용을 최소(혹은 최대)로 하면서 $N$명의 사람과 $N$개의 작업을 1:1 매칭하려면 어떻게 해야할까? </p>
</blockquote>
<p>위 문제를 좀 더 정식화해보자. </p>
<blockquote>
<p>그래프 $G = (V, E)$가 정점 집합 $V$를 $L \cup R\ (\text{단 } |L| = |R| = N)$로 분할하는 <strong>완전 이분 그래프</strong>(complete bipartite graph)라 하자. 간선 $(l, r) \in E\ (l \in L, r \in R)$이 $w(l, r)$의 가중치를 가질 때, $G$의 <strong>완전 매칭</strong>(perfect matching) 중 간선 가중치의 총합이 최소(혹은 최대)가 되도록 하는 완전 매칭 $M^*$를 찾기.</p>
<ul>
<li><strong>완전 이분 그래프</strong>(complete bipartite graph): 정점 집합 $V$가 두 개의 독립적인 정점 집합 $L, R$로 분할될 때, $L$의 모든 정점이 $R$의 모든 정점과 간선을 가지는 그래프.</li>
<li><strong>완전 매칭</strong>(perfect matching): 그래프의 모든 정점이 매칭되는 매칭.</li>
</ul>
</blockquote>
<p>가장 간단한 풀이는 모든 가능한 완전 매칭을 돌아보면서 가중치의 총합이 최소(혹은 최대)가 되는 것을 찾는 것이다. 이때, 가능한 완전 매칭의 수는 $N!$개이므로 그 시간복잡도도 $O(N!)$가 될 것이다.</p>
<p>오늘 다룰 <strong>헝가리안 알고리즘</strong>(Hungarian algorithm)은 훨씬 빠르게 $O(N^4)$, 최적화를 곁들이면 $O(N^3)$의 시간에 $M^*$를 찾는 알고리즘이다. </p>
<blockquote>
<p>헝가리안 알고리즘을 이용하면 가능한 완전 매칭들 중 간선 가중치의 총합이 최소가 되는 것도, 최대가 되는 것도 찾을 수 있다. </p>
<p>다만 아래에서는 간선 가중치의 총합이 <strong>최대</strong>가 되는 것을 찾는 것을 목표로 한다.</p>
</blockquote>
<h1 id="1-헝가리안-알고리즘">1. 헝가리안 알고리즘</h1>
<h2 id="11-이퀄리티-서브그래프equality-subgraph">1.1. 이퀄리티 서브그래프(equality subgraph)</h2>
<p>헝가리안 알고리즘에서는 주어진 완전 이분 그래프 $G$의 <strong>이퀄리티 서브그래프</strong>(equality subgraph)를 이용하며, 그 성질은 다음과 같다.</p>
<ul>
<li>이퀄리티 서브그래프는 시간에 따라 변한다.</li>
<li>이퀄리티 서브그래프에서 찾은 완전 매칭은 주어진 할당 문제의 최적해임이 보장된다.</li>
</ul>
<p>이퀄리티 서브그래프가 무엇인지 정의하기에 앞서, 각 정점에 새로운 속성값 $h$를 할당한다. 이 속성 $h$를 정점의 <strong>레이블</strong>(label)이라 부르며, 다음을 만족하는 $h$를 <strong>그래프 $G$의 허용 가능한 정점 레이블링</strong>(feasible vertex labeling)이라 한다.</p>
<p>$$
l.h + r.h \geq w(l,r) \text{ for all } l \in L \text{ and } r \in R.
$$</p>
<p>허용 가능한 정점 레이블링은 항상 존재하며, 한 가지 기본적인 방법은</p>
<p>$$
\begin{aligned}
&amp;l.h = max{w(l,r): r \in R} &amp;\text{ for all } l \in L\
&amp;r.h = 0 &amp;\text{ for all } r \in R
\end{aligned}
$$</p>
<p>과 같은 초기값을 사용하는 것이다.</p>
<h3 id="def-이퀄리티-서브그래프-equality-subgraph">$def.$ 이퀄리티 서브그래프 (equality subgraph)</h3>
<blockquote>
<p>허용 가능한 정점 레이블링 $h$가 주어졌을 때, 그래프 $G = (V, E)$의 이퀄리티 서브그래프 $G_h = (V, E_h)$의 간선 집합 $E_h$는 다음과 같이 정의된다.
$$E_h = {(l, r) \in E: l.h + r.h = w(l, r)}$$</p>
</blockquote>
<h3 id="theorem-1">Theorem 1.</h3>
<blockquote>
<p>이퀄리티 서브 그래프 $G_h$에 완전 매칭 $M^<em>$가 있다면, $M^</em>$는 $G$의 할당 문제의 최적해이다.</p>
</blockquote>
<p>$pf)$
  $G_h$와 $G$가 같은 정점 집합을 가지고 있으므로, $G_h$에서 찾은 완전 매칭 $M^*$은 $G$의 완전 매칭이기도 하다. </p>
<p>  (1) $M^*$의 모든 간선은 $G_h$의 간선이고, (2) 완전 매칭에서 각 정점은 모두 단 하나의 간선에만 나타나므로 다음이 성립한다. </p>
<p>$$
\begin{aligned}
w(M^<em>) &amp;= \sum_{(l,r) \in M^</em>}w(l, r)\
&amp;= \sum_{(l,r) \in M^*}(l.h + r.h) &amp;(1)\
&amp;= \sum_{l\in L}{l.h} + \sum_{r \in R}{r.h} &amp;(2)
\end{aligned}
$$</p>
<p>  $M$을 $G$의 임의의 완전 매칭이라 하자. (1) $h$가 허용 가능한 정점 레이블링이고, (2) $M$이 완전 매칭이므로 다음이 성립한다.</p>
<p>$$
\begin{aligned}
w(M) &amp;= \sum_{(l,r) \in M}w(l, r)\
&amp;\leq \sum_{(l,r) \in M}(l.h + r.h) &amp;(1)\
&amp;= \sum_{l\in L}{l.h} + \sum_{r \in R}{r.h} &amp;(2)
\end{aligned}
$$</p>
<p>  따라서 다음과 같이 쓸 수 있다.
$$
w(M) \leq \sum_{l \in L}{l.h} + \sum_{r \in R}{r.h} = w(M^<em>)
$$
  즉 $G_h$의 완전 매칭의 $M^</em>$는 $G$의 최대-가중치 완전 매칭이다. $\square$</p>
<h2 id="12-이퀄리티-서브그래프의-완전-매칭-찾기">1.2. 이퀄리티 서브그래프의 완전 매칭 찾기</h2>
<p>어떤 이퀄리티 서브그래프에서든 완전 매칭을 찾을 수 있다면, 그것이 바로 할당 문제의 최적해임이 증명됐다. 문제는 모든 이퀄리티 서브그래프에서 완전 매칭이 가능한 것은 아니라는 점이다. 따라서 우리는 우선 (1) 완전 매칭이 가능한 이퀄리티 서브그래프를 찾고, (2) 해당 그래프에서 완전 매칭을 찾아야 한다. </p>
<p>임의의 한 이퀄리티 서브그래프를 생각해보자. 이 서브그래프에서 찾을 수 있는 최대 매칭의 가중치 총합은 많아야 각 정점 레이블 값의 총합이다. 만약 정답에 해당하는 정점 레이블링이라면 그 정점 레이블의 총합은 $w(M^*)$의 값과 같아지고, 최대 매칭은 최대 가중치 완전 매칭이 된다.</p>
<p>헝가리안 알고리즘은 반복적으로 매칭과 정점 레이블을 수정함으로써, 완전 매칭을 찾을 수 있는 이퀄리티 서브그래프와 그 완전 매칭을 찾아낸다.</p>
<ol>
<li>이퀄리티 서브그래프 $G_h$에서, 임의의 허용 가능한 정점 레이블링 $h$와 매칭 $M$으로 시작한다.</li>
<li>$G_h$의 $M$-증가 경로 $P$를 찾고, 매칭을 $M \oplus P$로 업데이트해, 매칭 수를 증가시킨다.</li>
<li>정점 레이블링을 수정해 이퀄리티 서브그래프를 업데이트한다.</li>
<li>더 이상 $M$-증가 경로를 갖는 이퀄리티 서브그래프를 찾을 수 없을 때까지 2, 3을 반복한다.</li>
</ol>
<p>정점 레이블링 초기값($l.h = max{w(l,r): r \in R} \text{ for all } r \in L, r.h = 0 \text{ for all } l \in R$)과, 가능한 임의의 매칭으로 시작한다(빈 매칭도 상관없다.).</p>
<h3 id="1-g_h에서-m-증가-경로-찾기">(1) $G_h$에서 $M$-증가 경로 찾기</h3>
<p>이퀄리티 서브그래프 $G_h$와 매칭 $M$이 주어졌다면, $G_h$로부터 다음을 만족하는 <strong>유향 이퀄리티 서브그래프</strong>(directed equality subgraph) $G_{M,h} = (V, E_{M, h})$를 만든다. </p>
<p>$$
\begin{aligned}
E_{M,h} = &amp;{(l, r): l \in L, r \in R, \text{ and } (l, r) \in E_h - M }\
&amp;\cup {(r, l): r \in R, l \in L, \text { and } (l, r) \in M}
\end{aligned}
$$</p>
<p>$M$-증가 경로는 아직 매칭되지 않은 $L$의 정점에서 시작해, 아직 매칭되지 않은 $R$의 정점으로 끝나는 경로다. $G_{M,h}$에서 찾은 $M$-증가 경로는 $G_h$의 $M$-증가 경로이기도 하므로 $G_{M,h}$에서 증가 경로를 찾을 수만 있으면 된다.</p>
<p>위와 같이 간선 집합을 만들면, 아직 매칭되지 않은 $L$의 정점들에서 시작해 아직 매칭되지 않은 $R$의 정점에 도달할 때까지 BFS를 계속한다. BFS를 진행하면 아직 매칭되지 않은 $L$의 정점을 각 트리의 루트로 갖는 <strong>너비-우선 포레스트</strong>(breadth-first forest) $F = (V_F, E_F)$가 만들어진다. </p>
<p>$M$-증가 경로가 $L$의 정점에서 시작해 $R$의 정점으로 끝나므로, 해당 경로에는 아직 매칭에 포함되지 않은 간선$(L \rightarrow R)$이 매칭에 포함된 간선($R \rightarrow L$)보다 반드시 1개 더 많아지게 된다. </p>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/b1a61b17-3e33-4e61-80c2-ee2b568aa79d/image.png" alt=""></p>
<p>파란색은 $L$의 정점, 빨간색은 $R$의 정점을 나타낸다고 하자. 탐색을 통해 이와 같은 증가 경로를 찾았을 때, 굵은 화살표는 $R \rightarrow L$ 간선으로, 현재 매칭에 포함되어 있다. </p>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/6a56e8e1-5770-42de-b0e9-6f150233fb35/image.png" alt=""></p>
<p>이를 거꾸로 뒤집어 $R \rightarrow L$ 간선(현재 매칭에 포함된 간선)은 매칭에서 제외시키고 $L \rightarrow R$ 간선(현재 매칭에 포함되지 않은 간선)은 매칭에 포함시키면, 위와 같이 기존보다 하나 더 많은 간선을 매칭에 포함시킬 수 있게 된다.</p>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/866ad045-377f-456f-85aa-8a1d0d3de5f3/image.png" alt=""></p>
<p>위 그림은 왼쪽에서부터 차례로 </p>
<p>  (1) 기존 매칭(파랑)으로부터 만들어 낸 유향 이퀄리티 서브그래프 $G_{M,h}$
  (2) $G_{M,h}$에서 BFS를 통해 만들어 낸 BFS 포레스트와 $M$-증가 경로(주황)
  (3) 찾아낸 $M$-증가 경로를 바탕으로 업데이트된 매칭(파랑)으로 새로 만든 $G_{M, h}$ </p>
<p>에 해당한다.</p>
<h3 id="2-m-증가-경로-탐색이-실패하는-경우">(2) $M$-증가 경로 탐색이 실패하는 경우</h3>
<p>$M$-증가 경로를 통해 매칭을 업데이트해 나가다 보면, 언젠가 해당 이퀄리티 서브그래프에서 더 이상 증가 경로를 찾을 수 없을 때를 만나게 된다. 이때의 매칭은 현재 이퀄리티 서브그래프에서 가능한 최대 매칭이기는 하지만, 완전 매칭은 아닐 수도 있다. </p>
<p>만약 이 매칭이 완전 매칭이 아니라면 증가 경로를 가진 <strong>다른 이퀄리티 서브그래프</strong>가 존재한다. 헝가리안 알고리즘에서는 증가 경로를 가진 다른 이퀄리티 서브그래프를 찾기 위해, 정점들의 레이블을 적절히 수정해 새로운 간선이 이퀄리티 서브그래프에 추가될 수 있도록 한다.</p>
<p>이때 염두에 둘 점은 증가 경로 탐색이 실패하는 경우의 탐색은 항상 $L$에 속하는 정점으로 끝난다는 점이다.</p>
<ol>
<li>가장 마지막에 탐색된 정점이 아직 매칭되지 않은 $R$의 정점이라면 $M$-증가 경로를 찾는 데 성공했다는 말이다.</li>
<li>가장 마지막에 탐색된 정점이 이미 매칭된 $R$의 정점이라면 해당 정점과 매칭된 $L$ 정점으로 향하는 간선이 남아있을 것이므로 추가적인 탐색이 가능하다.</li>
<li>따라서 증가 경로 탐색이 실패하는 경우는 $R$의 정점이 아닌 $L$의 정점으로 끝난다.</li>
</ol>
<p>현재 BFS 탐색에서 도달하지 못한 $R$의 정점으로의 탐색이 가능하도록 간선을 추가해야 한다. 단 정점 레이블링 갱신은 다음의 세 조건을 만족해야 한다.</p>
<ol>
<li>어떤 $F$의 간선도 유향 이퀄리티 서브그래프에서 제외되지 않도록 해야 한다<ul>
<li>기존 탐색 결과를 유지하면서 추가적인 탐색이 가능해지도록 해야한다.</li>
</ul>
</li>
<li>어떤 $M$의 간선도 유향 이퀄리티 서브그래프에서 제외되지 않도록 해야 한다.<ul>
<li>기존 매칭 결과를 유지하면서 매칭을 더 증가시킬 수 있는 경로를 찾아야 한다.</li>
</ul>
</li>
<li>$l \in L \cap V_F,\ r \in R - V_F$인 간선 $(l, r)$이 적어도 하나 $E_h$, $E_{M,h}$에 추가되도록 해야 한다.<ul>
<li>현재 상태로는 추가적인 탐색이 불가능하므로, 적어도 하나의 새로운 간선을 이퀄리티 서브그래프에 포함시켜야 한다.</li>
</ul>
</li>
</ol>
<p>이제 어떻게 해야 현재의 허용 가능한 정점 레이블링에서 아직 이퀄리티 서브그래프에 들어가지 못한 간선을 추가할 수 있을지를 생각해보자.</p>
<ol>
<li>지금 사용하고 있는 레이블링도 허용 가능한 정점 레이블링이므로 다음을 만족한다.
$$
l.h + r.h \geq w(l,r) \text{ for all } l \in L \text{ and } r \in R.
$$</li>
<li>이퀄리티 서브그래프의 정의에 따라 $l.h + r.h = w(l, r)$를 만족하는 간선 $(l, r)$은 이퀄리티 서브그래프에 포함된다. </li>
<li>따라서 현재 이퀄리티 서브그래프에 포함되지 않은 간선의 양 끝 정점 $l, r$은 $l.h + r.h &gt; w(l, r)$인 정점들이다.</li>
</ol>
<p>만약 어떤 간선 $(l, r)$의 양 끝 정점의 레이블 값을 $l.h + r.h = w(l, r)$이 될 수 있도록 적절히 수정해줄 수 있다면, 이 간선을 이퀄리티 서브그래프에 포함시킬 수 있을 것이다. </p>
<p>물론 레이블 값을 임의로 수정하면 기존 $F$에 포함되어있던 간선들 중 일부가 그래프로부터 빠져나올 수도 있으므로 주의가 필요하다.</p>
<p>새 간선을 추가하기 위해 다음의 값 $\delta$를 계산하도록 하자. $F_L = L \cap V_F$고, $F_R = R \cap V_F$다.</p>
<p>$$
\delta = min{l.h + r.h - w(l, r): l \in F_L,\ r \in R - F_R}
$$</p>
<p>$\delta$값을 찾았다면 모든 정점 $l \in F_L$과 $r \in F_R$의 레이블을 다음과 같이 수정한다.</p>
<p>$$
\begin{aligned}
v.h = \begin{cases}
v.h - \delta &amp;\text{if } v \in F_L,\
v.h + \delta &amp;\text{if } v \in F_R,\
v.h &amp;\text{otherwise } (v \in V - V_F)
\end{cases}
\end{aligned}
$$</p>
<h3 id="lemma">Lemma</h3>
<blockquote>
<p>위와 같은 방식으로 레이블링 $h$를 $h&#39;$로 수정할 때, $h&#39;$는 다음의 성질을 만족하는 $G$의 허용 가능한 정점 레이블링이다.</p>
<ol>
<li>$(u, v)$가 $G_{M,h}$의 포레스트 $F$에 속한 간선이면 $(u, v) \in E_{M, h&#39;}$다. (탐색 유지)</li>
<li>$(l, r)$이 $G_h$의 매칭 $M$에 속한 간선이면 $(r, l) \in E_{M, h&#39;}$다. (매칭 유지)</li>
<li>$(l, r) \not\in E_{M, h}$지만 $(l, r) \in E_{M, h&#39;}$인 정점 $l \in F_L,\ r \in R - F_R$이 존재한다. (신규 간선)</li>
</ol>
</blockquote>
<p>$pf\ 0)$ $h&#39;$가 허용 가능한 정점 레이블링임을 증명</p>
<p>  현재의 $h$는 허용 가능한 정점 레이블링이므로, 모든 정점 $l \in L, r \in R$에 대해 $w(l, r) \leq l.h + r.h$다. 레이블링을 수정한 후의 $h&#39;$가 허용 가능한 정점 레이블링이 아니라고 가정하자. 어떤 $l \in L, r \in R$에 대해, $l.h&#39; +r.h&#39; &lt; w(l,r) \leq l.h +r.h$다. </p>
<p>  이렇게 레이블링 수정 후 두 정점의 레이블 값의 합이 줄어들 수 었는 경우는 $l \in F_L, r \in R - F_R$일 때 밖에 없으며, 이때 수정된 레이블의 값은 $l.h&#39; + r.h&#39; = l.h + r.h - \delta$를 만족한다. 하지만 $\delta$ 값의 선정 방식에 따라, 모든 $l \in F_L, r \in R - F_R$에 대해 $l.h + r.h- \delta \geq w(l, r)$고, 따라서 $l.h&#39; + r.h \geq w(l,r)$이다. 이는 $h&#39;$가 허용 가능한 정점 레이블링이 아니라는 가정과 모순이므로 $h&#39;$는 허용 가능한 정점 레이블링이다. $\square$</p>
<p>$pf\ 1)$ 성질 1 증명
  $l \in F_L, r \in F_R$이라 하자. $l.h&#39; + r.h&#39; = (l.h - \delta) + (r.h + \delta) = l.h + r.h$이므로, 정점 $l, r$을 양 끝점으로 가지는 간선 $(l, r)$ 또는 $(r, l)$은 $G_{M, h&#39;}$에도 포함된다. $\square$</p>
<p>$pf\ 2)$ 성질 2 증명
  우선은 매칭 $M$에 포함된 임의의 간선 $(l ,r)$에 대해, $l \in F_L \Leftrightarrow r \in F_R$임을 증명한다. </p>
<p>  우선은 $r \in F_R$이라 가정해보자. 앞서 증가 경로 발견에 실패하는 경우, BFS가 항상 $L$에 포함된 정점으로 끝남을 봤다. 따라서 만약 $r \in F_R$이면 해당 정점과 매칭되는 $l \in L$로의 탐색이 가능하고, 즉 $l \in F_L$이다. 이번에는 반대로 $r \not\in F_R$이라 가정해보자. $(l, r) \in M$이므로 $G_{M,h}$에서 $l$로 향하는 간선은 $(r, l)$밖에 없다. $r$로의 탐색에 실패했으므로 해당 간선을 따라 $l$로의 탐색을 하는 것은 불가능하다.</p>
<p>  따라서 $(l, r) \in M$일 때 가능한 것은 $l \in F_L,\ r \in F_R$인 경우와 $l \in L - F_L,\ R \in R - F_R$인 경우, 두 가지 밖에 없다. </p>
<p>  $l \in F_L,\ r \in F_R$인 경우, $l.h&#39; + r.h&#39; = l.h + r.h$다. 반대로 $l \in L - F_L,\ R \in R - F_R$인 경우도 마찬가지로, $l.h&#39; = l.h,\ r.h&#39; = r.h$이므로 $l.h&#39; + r.h&#39; = l.h + r.h$다. 따라서 $(l, r) \in M$이면 $(r, l) \in E_{M, h&#39;}$다. $\square$</p>
<p>$pf\ 3)$ 성질 3 증명
  $\delta$의 정의에 따라, $\delta = l.h + r.h - w(l,r)$이면서 $l \in F_L,\ r \in R - F_R$이 되는 간선 $(l, r) \not\in E_h$이 적어도 하나 존재한다. $l.h&#39; + r.h&#39; = l.h + r.h - \delta = w(l, r)$이므로 $(l, r) \in E_{h&#39;}$다. $(l, r)$은 $E_h$에 포함되지 않으므로, 매칭 $M$에도 포함되지 않는다. 따라서 해당 간선이 유향 이퀄리티 서브그래프 $E_{M, h&#39;}$에 들어갈 때 $L \rightarrow R$의 방향으로 들어가게 된다. 즉 $(l, r) \in E_{M, h&#39;}$다. $\square$</p>
<h1 id="2-pseudo-code">2. Pseudo-code</h1>
<p>헝가리안 알고리즘의 플로우를 다시 한 번 살펴보고 넘어가자.</p>
<ol>
<li>이퀄리티 서브그래프 $G_h$에서, 임의의 허용 가능한 정점 레이블링 $h$와 매칭 $M$으로 시작한다.</li>
<li>$G_h$의 $M$-증가 경로 $P$를 찾고, 매칭을 $M \oplus P$로 업데이트해, 매칭 수를 증가시킨다.</li>
<li>정점 레이블링을 수정해 이퀄리티 서브그래프를 업데이트한다.</li>
<li>더 이상 $M$-증가 경로를 갖는 이퀄리티 서브그래프를 찾을 수 없을 때까지 2, 3을 반복한다.</li>
</ol>
<pre><code>HUNGARIAN(G)

// 초기 정점 레이블링 설정
for each vertex l ∈ L
    l.h = max {w(l, r) | r ∈ R}
for each vertex r ∈ R
    r.h = 0

let M be any matching in G_h 

from G, M, and h, form the equality subgraph G_h 
    and the directed equality subgraph G_Mh

while M is not a perfect matching in G_h
    P = FIND-AUGMENTING-PATH(G_Mh)
    M = M ⊕ P
    update the equality subgraph G_h 
        and the directed equality subgraph G_Mh

return M</code></pre><p>아래의 <code>FIND-AUGMENTING-PATH</code> 프로시저는 <code>HUNGARIAN</code> 프로시저의 서브루틴으로, 증가 경로를 찾는 데 쓰인다. 여기서 $\pi$는 BFS 포레스트의 한 정점에 선행하는 정점을 가리킨다. </p>
<pre><code>FIND-AUGMENTING-PATH(G_Mh)

Q = ∅
F_L = ∅
F_R = ∅

for each unmatched vertex l ∈ L
    l.π = NIL
    ENQUEUE(Q, l)
    F_L = F_L ∪ {l}

repeat
    if Q is empty
        δ = min { l.h + r.h - w(l, r) | l ∈ F_L and r ∈ R - F_R }

        for each vertex l ∈ F_L
            l.h = l.h - δ
        for each vertex r ∈ F_R
            r.h = r.h + δ

        from G, M, and h, form a new directed equality graph G_Mh

        for each new edge (l, r) in G_Mh
            if r ∉ F_R
                r.π = l

                if r is unmatched
                    an M-augmenting path has been found
                    (exit the repeat loop)
                else ENQUEUE(Q, r)
                     F_R = F_R ∪ {r}

    u = DEQUEUE(Q)

    for each neighbor v of u in G_Mh
        if v ∈ L
            v.π = u
            F_L = F_L ∪ {v}
            ENQUEUE(Q, v)
        elseif v ∉ F_R
            v.π = u
            if v is unmatched
                an M-augmenting path has been found
                    (exit the repeat loop)
            else ENQUEUE(Q, v)
                 F_R = F_R ∪ {v}

until an M-augmenting path has been found

using the predecessor attributes π, construct an M-augmenting path P
    by tracing back from the unmatched vertex in R

return P</code></pre><p>더 이상 해당 이퀄리티 서브그래프에서 $M$-증가 경로를 찾을 수 없는 경우 <code>if Q is empty</code>가 실행된다. Lemma의 (3)에 의해 <code>if</code>문을 거치면 적어도 하나 이상의 간선이 이퀄리티 서브그래프에 추가되므로, 큐가 비어 있어 <code>if</code>문 아래의 <code>DEQUEUE(Q)</code>가 실패하는 경우는 발생하지 않는다.</p>
<h1 id="3-시간복잡도-계산-및-개선">3. 시간복잡도 계산 및 개선</h1>
<h3 id="시간복잡도-계산-on4">시간복잡도 계산 ($O(n^4)$)</h3>
<p>주어진 이분 그래프 $G = (V = L \cup R, E)$에 대해, $|L| = |R| =  n,\ |E| = n^2$라 하자. </p>
<p>우선 <code>FIND-AUGMENTING-PATH</code> 프로시저부터 보자.</p>
<ol>
<li>첫 번째 <code>for</code> 문은 $O(n)$의 시간에 완료될 수 있다.</li>
<li><code>repeat</code>의 첫 번째 <code>if</code>문은 한 번 실행될 때마다 적어도 하나의 새로운 $R$의 정점을 찾으므로 최대 $n$번 반복될 수 있다.<ul>
<li>(병목) <code>δ</code> 계산은 $O(n^2)$의 시간에 완료될 수 있다.</li>
<li>정점 레이블 수정은 각각 $O(n)$의 시간에 완료될 수 있다.</li>
<li>(병목) 새로운 $G_{M,h}$ 구성은 $O(n^2)$의 시간에 완료될 수 있다. </li>
<li>$G_{M,h}$에 대한 <code>for</code>문의 경우 새롭게 추가될 수 있는 간선의 개수가 많아야 $n^2$개이므로, <strong>한 번의 <code>FIND-AUGMENTING-PATH</code> 프로시저 호출에서</strong> 최대 $O(n^2)$번 실행된다.</li>
</ul>
</li>
<li>2.에서 다룬 <code>if</code>문을 제외한다면 <code>repeat</code>은 단순 BFS이므로 $O(V + E) = O(n^2)$의 시간에 완료될 수 있다.</li>
</ol>
<p>따라서 <code>FIND-AUGMENTING-PATH</code> 프로시저의 시간복잡도는 $O(n^3)$다.</p>
<p><code>HUNGARIAN</code> 프로시저의 경우,</p>
<ol>
<li>초기 정점 레이블링 설정은 $O(n ^2)$의 시간에 완료될 수 있다. </li>
<li><code>while</code> 루프의 경우 매 루프마다 최소 하나의 매칭이 추가되므로, 최대 $n$번 수행될 수 있다.<ul>
<li><code>while</code> 조건문의 경우 상수 시간에 계산할 수 있다.</li>
<li><code>FIND-AUGMENTING-PATH</code>는 $O(n^3)$의 시간에 완료될 수 있다.</li>
<li>매칭 $M$의 업데이트는 $O(n)$의 시간에 완료될 수 있다.</li>
<li>$G_h$와 $G_{M,h}$의 업데이트는 $O(n^2)$의 시간에 완료될 수 있다.</li>
</ul>
</li>
</ol>
<p>따라서 <code>HUNGARIAN</code> 프로시저의 시간복잡도는 $O(n^4)$다. </p>
<h3 id="시간복잡도-개선-on3">시간복잡도 개선 ($O(n^3)$)</h3>
<p><code>HUNGARIAN</code> 프로시저의 시간복잡도가 $O(n^4)$가 되는 것은 <code>FIND-AUGMENTING-PATH</code> 프로시저에서 병목이 되는 $\delta$ 계산 및 $G_{M,h}$ 구성 때문이다. 이 둘의 시간복잡도를 각각 $O(n)$으로 개선할 수 있다면, <code>FIND-AUGMENTING-PATH</code>의 시간복잡도는 $O(n^2)$으로, <code>HUNGARIAN</code>의 시간복잡도는 $O(n^3)$으로 개선할 수 있다.</p>
<ol>
<li><p>$\delta$ 값 계산의 $O(n)$의 시간 최적화
원래는 $\delta$값 계산을 위해 모든 $(l, r)$ 정점 쌍의 레이블을 확인했기에 $O(n^2)$의 시간이 걸렸다. 하지만 이 과정은 다음과 같은 방식으로 $O(n)$으로 최적화할 수 있다. </p>
<p>(1) 각 정점 $r \in R - F_R$에 대해, 다음의 새 속성값 $\sigma$를 정의한다. 이 $\sigma$는 보통 $r$의 <strong>슬랙</strong>(slack)이라고 부른다.
$$r.\sigma = min {l.h + r.h - w(l,r) : l \in F_L}.$$</p>
<p>이때 $\delta = min {r.\sigma : r \in R - F_R}$가 되므로, 만약 각 $r \in R - F_R$의 $\sigma$값이 계산되어 있다면 $\delta$ 또한 $O(n)$의 시간에 구할 수 있게 된다.</p>
<p>(2) 계산된 $\delta$값을 가지고 각 정점들의 레이블 값을 수정한다. 이때 모든 $r \in R - F_R$에 대해 $r.\sigma$ 또한 $\delta$만큼 감소시켜줘야 한다. 모든 $r.\sigma$를 $r.\sigma - \delta$로 갱신해주는 데에는 $O(n)$의 시간으로 충분하다.</p>
<p>(3) $F_L$에 새로운 정점 $l$이 추가되는 경우에도 모든 $r \in R - F_R$의 $\sigma$값을 수정해줘야 한다. $|L| = |R| = n$이므로 새로운 정점 $l$이 추가될 때마다 $O(n)$번의 $\sigma$값 수정이 일어날 수 있고, $F_L$에 새로 들어갈 수 있는 정점의 개수도 $O(n)$개다. 따라서 $F_L$ 계산에 따른 $\sigma$값 수정은 한 번의 <code>FIND-AUGMENTING-PATH</code> 프로시저에서 최대 $O(n^2)$번 수행될 수 있다.</p>
</li>
<li><p>매번 $G_{M,h}$를 새로 만들지 않아도 된다.
원래는 매 레이블링 갱신이 일어난 후, 어떤 간선이 $E_{M,h}$에 포함되는지 확인하기 위해 $O(n^2)$의 시간(=모든 간선을 확인하는 시간)을 들여 $G_{M,h}$를 새로 구성했지만, 사실 그럴 필요는 없다.</p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/frog_slayer/post/6bdcc3a0-4b73-478b-801d-4a92397ce04c/image.png" alt=""></p>
<p>레이블링을 왜 수정했는지를 다시 생각해보자. 우리가 관심을 두는 것은 어떤 간선들이 실제로 $E_{M,h}$​에 포함될 것인지가 아니라, 어떻게 해야 포레스트를 확장하여 $M$-증가 경로를 찾을 수 있을지에 대한 것이다.</p>
<p>대충 위 그림을 통해 보자면, 레이블링 수정은 더 이상 탐색을 계속할 수 없는 포레스트 $F$에서, 아직 탐색되지 않은 $r \in R - F_R$로의 탐색을 가능하게 할 간선 후보들(주황 화살표)을 추가하기 위함이라 생각해도 좋다.</p>
<p>앞서 $\delta$ 값 계산 개선을 위해 사용했던 슬랙($\sigma$)을 이용하면 포레스트 $F$에 새롭게 추가될 수 있는 정점 및 간선도 보다 빠르게 찾을 수 있다. </p>
<p>  (1) 레이블링과 슬랙을 수정한 시점에 $r.\sigma = 0$이 되는 정점 $r \in R - F_R$을 향하는 어떤 간선 $(l \in F_L,\ r)$이 $E_{M, h}$에 새롭게 추가된다. 이 시점에 $l.h + r.h = w(l, r)$이 되는 $(l, r)$쌍이 존재하게 되기 때문이다. 모든 $r$을 확인하는 데에는 $O(n)$의 시간이 걸린다.</p>
<p>  (2) 만약 각 $r \in R - F_R$에 대해, $l.h + r.h - w(l,r) = r.\sigma$가 되는 $l$이 어떤 것인지도 추가로 관리해준다면, $E_{M,h}$에 추가되는 간선 $(l, r)$이 어떤 것인지도 $O(1)$에 알 수 있다. $F_L$에 새로운 원소가 들어갈 때, 각 $r$에 대해 $l.h + r.h - w(l,r) = r.\sigma$가 되도록 하는 $l$의 갱신도 $O(1)$에 해줄 수 있다.</p>
<p>원래는 매번 $F$에 추가할 수 있을 만한 간선을 찾기 위해 $O(n^2)$의 시간을 들였지만, 이제는 각 $r \in R - F_R$의 $\sigma$와 $l.h + r.h - w(l,r) = r.\sigma$가 되는 정점 $l$이 무엇인지만을 확인하면 되기 때문에 $O(n)$의 시간에 $F$에 간선을 추가할 수 있게 됐다.</p>
<h1 id="4-조금-다른-경우들">4. 조금 다른 경우들</h1>
<p>지금까지는 $|L| = |R| = n,\ |E| = n^2$인 경우의 최대 가중치 완전 매칭의 가중치를 찾으려 했지만, 조금만 수정한다면 $|L| \neq |R|$인 경우나, $|E| \neq n^2$인 경우의 매칭 최대 가중치, 혹은 최소 가중치 매칭을 찾는 경우도 해결할 수 있다.</p>
<ol>
<li>$|L| \neq |R|,\ E = L \times R$ 인 경우
 더 작은 쪽에 더미 노드를 추가한 후, 다른 쪽의 모든 노드들과 $0$의 가중치를 갖는 간선으로 연결한다. $|L| \neq |R|$이므로 완전 매칭은 아니지만, 매칭의 최대 가중치는 올바르게 구할 수 있다.</li>
<li>$|L| = |R| = n,\ |E| &lt; n^2$인 경우
 서로 연결되지 않은 노드들을 가중치 $0$의 간선으로 서로 연결한다. 마찬가지로 완전 매칭이 아닐 수 있고, 최대 매칭의 수를 구하지 못할 수도 있지만, 매칭 최대 가중치는 올바르게 구할 수 있다.</li>
<li>최소 가중치 완전 매칭을 찾는 경우 ($|L| = |R| = n,\ |E| = n^2$를 가정)
 임의의 큰 값 $MAX$를 정하고 가중치를 $MAX - w(l, r)$로 수정한다. 이후 헝가리안 알고리즘을 수행해서 찾은 최대 가중치 완전 매칭의 가중치 $w(M^*)$을 $n * MAX$에서 빼주면 최소 가중치 완전 매칭이 된다. 더 간단하게는 그냥 모든 가중치 $w(l,r)$을 $-w(l,r)$로 수정하기만 해도 된다.</li>
</ol>
<h1 id="5-c-코드-14216-할-일-정하기-2">5. C++ 코드 (<a href="https://www.acmicpc.net/problem/14216">14216 할 일 정하기 2</a>)</h1>
<pre><code class="language-cpp">#include &lt;iostream&gt;
#include &lt;vector&gt;
#include &lt;algorithm&gt;
#include &lt;climits&gt;
#include &lt;queue&gt;
using namespace std;

#define fastio cin.tie(NULL); cout.tie(NULL); ios_base::sync_with_stdio(false)
#define all(v) v.begin(), v.end()

const int NIL = -1;
const bool LEFT = false;
const bool RIGHT = true;
const int MAX = 10000;

int N;
vector&lt;int&gt; rmatch, lmatch;
vector&lt;int&gt; l_pred, r_pred;
vector&lt;vector&lt;int&gt;&gt; w;
vector&lt;int&gt; lh, rh;

int find_augmenting_path() {//find augmenting path and return the index of an unmatched r, if any

    queue&lt;pair&lt;int, bool&gt;&gt; q;//{vertex idx, (0: left, 1: right)}
    vector&lt;bool&gt; FL(N), FR(N);
    vector&lt;int&gt; slack(N, INT_MAX), slack_l(N, NIL);

    for (int l = 0; l &lt; N; l++) {
        if (lmatch[l] != NIL) continue;//unmatched l in L
        l_pred[l] = NIL;
        q.emplace(l, LEFT);
        FL[l] = true;
    }

    for (int l = 0; l &lt; N; l++) {
        if (!FL[l]) continue;

        for (int r = 0; r &lt; N; r++) {//none of R is in FR now
            if (lh[l] + rh[r] - w[l][r] &lt; slack[r]) {
                slack[r] = lh[l] + rh[r] - w[l][r]; 
                if (slack[r] &lt; 0) cout &lt;&lt; lh[l] &lt;&lt; &quot;+&quot; &lt;&lt; rh[r] &lt;&lt; &quot;-&quot; &lt;&lt; w[l][r] &lt;&lt; &quot;=&quot; &lt;&lt; slack[r] &lt;&lt; &#39;\n&#39;;
                slack_l[r] = l;
            }
        }
    }


    while (true) {
        if (q.empty()) {
            int delta = INT_MAX;

            for (int r = 0; r &lt; N; r++) {//optimize delta calculation with slack variable
                if (!FR[r]) delta = min(delta, slack[r]);
            }

            if (delta == INT_MAX) return -1;

            for (int i = 0; i &lt; N; i++){
                if (FL[i]) lh[i] -= delta;

                if (FR[i]) rh[i] += delta;
            }

            for (int i = 0; i &lt; N; i++) {
                if (FR[i]) continue;
                    slack[i] -= delta;

                    if (!slack[i]) {
                        r_pred[i] = slack_l[i];

                        if (rmatch[i] == NIL) return i;//unmatched
                        q.emplace(i, RIGHT);
                        FR[i] = true;
                }
            }
        }

        auto [u, V] = q.front();
        q.pop();

        if (V == RIGHT) {//matched right
            int l = rmatch[u];
            l_pred[l] = u;
            FL[l] = true;
            q.emplace(l, LEFT);

            for (int r = 0; r &lt; N; r++) {
                if (!FR[r] &amp;&amp; (slack[r] &gt; lh[l] + rh[r] - w[l][r])) {
                    slack[r] = lh[l] + rh[r] - w[l][r];
                    slack_l[r] = l;
                }
            }
        }
        else {//left
            for (int r = 0; r &lt; N; r++) {
                if ((rmatch[u] != r &amp;&amp; lh[u] + rh[r] == w[u][r]) &amp;&amp; !FR[r]) {
                    r_pred[r] = u;

                    if (rmatch[r] == NIL) return r;//unmatched
                    q.emplace(r, RIGHT);
                    FR[r] = true;
                }
            }
        }
    }
}

void update_match(int cur) {//backtrace augmenting path
    bool toggle = RIGHT;

    while (cur != NIL) {
        if (toggle == RIGHT) {
            rmatch[cur] = r_pred[cur];
            lmatch[r_pred[cur]] = cur;
            cur = r_pred[cur];
        }
        else cur = l_pred[cur];

        toggle = !toggle;
    }
}

int hungarian() {

    // default feasible vertex labeling
    for (int l = 0; l &lt; N; l++) lh[l] = *max_element(all(w[l]));

    int max_match_weight = 0;

    while (true) {
        int r = find_augmenting_path();
        if (r &lt; 0) break;
        update_match(r);
    }

    for (int l = 0; l &lt; N; l++) {
        max_match_weight += w[l][lmatch[l]];
    }

    return max_match_weight;
};

void init() {
    w.assign(N, vector&lt;int&gt;(N));
    lh.resize(N);
    rh.resize(N);
    lmatch.resize(N, NIL);
    rmatch.resize(N, NIL);
    l_pred.resize(N, NIL);
    r_pred.resize(N, NIL);
}

int main() {
    fastio;

    cin &gt;&gt; N;

    init();

    for (int i = 0; i &lt; N; i++) {
        for (int j = 0; j &lt; N; j++) {
            cin &gt;&gt; w[i][j];
            w[i][j] = MAX - w[i][j];
        }
    }

    cout &lt;&lt; N * MAX - hungarian();
}</code></pre>
<blockquote>
<p>Introdutction to Algorithms, 4th Edition</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코드 컴플리트 2] Ch2. Metaphors for a Richer Understanding of Software Development]]></title>
            <link>https://velog.io/@frog_slayer/Code-Complete-2-Ch2</link>
            <guid>https://velog.io/@frog_slayer/Code-Complete-2-Ch2</guid>
            <pubDate>Mon, 06 Jan 2025 06:53:03 GMT</pubDate>
            <description><![CDATA[<p>컴퓨터 사이언스에서는 &#39;바이러스&#39;, &#39;트로이의 목마&#39;, &#39;버그&#39; 등의 다양한 비유를 사용한다. 이 비유들은 구체적인 소프트웨어적 현상을 설명하기 위해 쓰이며, 이러한 비유들을 잘 사용하면 소프트웨어 개발 프로세스를 더욱 잘 이해할 수 있게 된다.</p>
<h1 id="21-the-importance-of-metaphors">2.1. The Importance of Metaphors</h1>
<p>과학에서는 잘 모르는 주제를 설명하기 위해 더 잘 이해하는 유사한 주제의 비교를 이용하곤 한다. 이러한 비유를 <strong>모델링</strong>(modeling)이라고 한다.</p>
<ul>
<li>모델링 사례<ul>
<li>벤젠의 구조 : 꼬리를 물고 있는 뱀</li>
<li>기체 분자 : 당구공</li>
<li>빛 : 소리</li>
</ul>
</li>
</ul>
<p>모델링을 이용하면, 비유 대상이 가지고 있는 속성과 관계, 혹은 필요한 추가 정보들을 이용해, 설명 대상을 보다 생생히 그려내고, 그 전체 개념의 윤곽을 잡을 수 있다. </p>
<p>단, 비유에 불과하다는 사실을 잊고 둘을 과도하게 동일시할 때에도 문제는 발생할 수 있다.</p>
<ul>
<li>예) 빛과 소리의 비유: 소리가 매질을 필요로 하는 것처럼 빛의 매질(에테르)도 필요할 것이다. </li>
</ul>
<p>어떤 비유는 다른 비유들보다 낫다. 좋은 비유는 간단하고, 다른 비유들과도 잘 연관되어 있고, 실험적 증거나, 관찰된 다른 현상들도 대부분 잘 설명한다. 좋은 비유는 그렇지 않은 비유를 사용했을 때에는 찾아낼 수 없었던 문제들을 해결할 수 있도록 하는, 좀 더 나은 관점을 제공해주기도 한다.</p>
<p>소프트웨어 개발에서도 마찬가지로, 비유를 이용하면 많은 이슈들을 더 잘 이해할 수 있다. 다만 소프트웨어 개발 분야는 다른 과학들에 비해서는 아직 그렇게 성숙하지 못해, 상충하는 비유들이 여럿 섞여 있기도 하다. 어떤 비유는 더 낫고, 다른 것들은 그렇지 않다. 비유를 얼마나 잘 이해하는지가 소프트웨어 개발을 얼마나 잘 이해하는지를 결정한다. </p>
<h1 id="22-how-to-use-software-metaphors">2.2. How to Use Software Metaphors</h1>
<p>소프트웨어에서의 비유는 로드맵보다는 서치라이트에 가깝다. 이는 어디에서 답을 찾을 수 있는지 알려주는 것이라기 보다는, 어떻게 이를 찾을 수 있을지를 알려주는 것에 가깝고, 알고리즘이라기보다는 휴리스틱에 가깝다.</p>
<ul>
<li>알고리즘: 특정 작업을 수행하기 위해 필요한 잘-정의된 명령들의 집합. 예측 가능하고, 결정론적이며, 우연에 따라 변하지 않음.</li>
<li>휴리스틱: 답을 찾는 데 도움을 주는 테크닉. &quot;무엇&quot;을 찾아야 하는지보다, &quot;어떻게&quot; 찾아야하는지를 알려줌. 예측 가능성은 조금 떨어지고, 확률적으로 결과가 바뀔 수도 있음.</li>
</ul>
<p>물론 프로그래밍에서 발생할 수 있는 문제들을 어떻게 풀 수 있을지 정확히 알려주는 것이 가능하다면 좋겠지만, 그렇게 하기는 어렵다. 모든 프로그램에는 고유의 개념들이 있고, 그런 모든 프로그램들에 일괄적으로 적용될 수 있는 솔루션을 만들 수는 없다. 때문에 그런 문제들에 &#39;일반적으로&#39; 어떻게 접근할 수 있는지를 아는 것이 중요하다.</p>
<p>소프트웨어 비유를 통해, 프로그래밍 문제와 프로세스에 대한 통찰을 얻고, 자신의 프로그래밍 활동을 다시 돌아보고 더 나은 방법은 없을지를 찾아보자. 소프트웨어 비유를 통해 개발 프로세스를 이해하는 사람은 그렇지 않은 사람들보다 소프트웨어 개발을 더 잘 이해하고, 더 나은 코드를 더 잘 이해하는 사람이 될 것이다. </p>
<h1 id="23-common-software-metaphors">2.3. Common Software Metaphors</h1>
<h3 id="software-penmanship-writing-code">Software Penmanship: Writing Code</h3>
<p>코드 작성을 손으로 편지를 쓰는 작업에 비유해보자. 한 사람이 형식적인 계획 없이 쓰고 싶은 것들을 쓴다.</p>
<ul>
<li>개인 작업이나 소규모 프로젝트들에서는 적절할 수 있다.</li>
<li>하지만 그렇지 않은 경우, 이는 소프트웨어 개발을 제대로, 적절히 설명하지 못한다.</li>
<li>편지를 쓰는 것은 개인의 활동인 반면, 소프트웨어 프로젝트에는 서로 다른 역할을 가지고 있는 여러 사람들이 참여한다.</li>
<li>편지를 다 쓰고 보내면 더 이상 수정할 수 없지만, 소프트웨어의 경우에는 수정을 하기 쉽고, 완벽하게 완성을 하는 일도 거의 없다. </li>
</ul>
<h3 id="software-farming-growing-a-system">Software Farming: Growing a System</h3>
<p>어떤 개발자들은 소프트웨어를 만드는 일을 씨를 뿌리고 작물을 키우는 일에 비유하기도 한다. 일부를 설계하고 코딩하고 테스트한 후, 전체 시스템에 조금씩 추가해 나가는 방식이다. </p>
<ul>
<li>조금씩 점진적으로 개선해나간다는 컨셉은 좋지만, 좋은 비유는 아니다.</li>
<li>소프트웨어 개발에 대한 직접적인 제어 개념이 없다. 씨앗을 뿌리고 작물이 제대로 잘 자라기를 기도할 뿐이다.</li>
</ul>
<h3 id="software-oyster-farming-system-accretion">Software Oyster Farming: System Accretion</h3>
<p>소프트웨어 개발을 굴 양식에 비유해보자. 위 농사의 비유와 마찬가지로 개발을 진행하면서 점진적으로 사이즈를 키우는 일이다. 굴에다 탄산 칼슘을 조금씩 주입하면서 진주를 만드는 것을 생각해보자.</p>
<ul>
<li>소프트웨어 시스템에 조금씩 추가해나가는 방법.</li>
<li>점진적 설계, 구축 및 테스트는 가장 강력한 소프트웨어 개발 개념 중 하나.</li>
<li>실행될 시스템의 간단한 초기 버전을 만든다. 이 버전은 실제 입력도, 데이터에 대한 실질적인 조작을 수행할 필요도, 실제 출력을 만들 필요도 없다. 개발할 실제 시스템을 지탱할 수 있을 만한 뼈대, 각 기능의 더미 클래스를 만든다.</li>
<li>뼈대를 만들고 나면, 각 더미 클래스를 실제 클래스로 만든다. 실제로 입력을 받아 출력을 하게 만든다.</li>
</ul>
<h3 id="software-construction-building-software">Software Construction: Building Software</h3>
<p>소프트웨어를 &#39;건설&#39;의 대상으로 생각하는 것은, 소프트웨어를 &#39;쓰거나&#39;, &#39;키우는&#39; 대상으로 보는 것보다 더 유용하며, 점진적 개발 개념과 함께 좀 더 세부적인 가이드도 제공한다.소프트웨어를 건설한다는 것은 소프트웨어의 종류와 규모에 따라 계획, 준비, 수행에 여러 단계를 둔다는 것을 의미한다.</p>
<ul>
<li>같은 종류라도 그 규모에 따라 다른 플랜과 테크닉이 필요하다.<ul>
<li>예를 들어 작은 개집을 만든다고 생각해보자. 나무 판자와 못들을 사면 금방 만들 수 있을 것이다. 하지만 같은 개집의 크기를 100배로 키운다면 어떨까? 단순히 더 많은 나무 판자와 못들을 산다고 해서 같은 방법으로 100배 큰 개집을 만들 수는 없다.</li>
<li>작은 프로젝트에서는 설계 미스가 있어도 금방 고칠 수 있다. 하지만 프로젝트의 규모가 커지면 잘못된 설계로 인한 비용은 훨씬 더 커진다.</li>
</ul>
</li>
<li>건설은 여러 단계를 거친다. 소프트웨어 구축도 마찬가지다.<ul>
<li>어떤 집을 만들지 정하기 : 문제 정의</li>
<li>건축가와 함께 전체적인 설계 : 소프트웨어 아키텍처 설계</li>
<li>구체적인 청사진 그리고 토건업자 고용하기 : 세부 소프트웨어 설계</li>
<li>부지 구하기, 기반 다지기, 골조, 벽, 지붕, 배관 배선 작업 : 소프트웨어 구현</li>
<li>정원사, 페인트공, 인테리어 업자의 작업 : 소프트웨어  최적화</li>
<li>각 단계에서 작업이 제대로 진행되고 있는지 관리 : 소프트웨어 리뷰와 인스펙션</li>
</ul>
</li>
<li>인건비가 많이 든다.<ul>
<li>실제로 집을 지을 때, 원자재도 물론 비싸기는 하지만, 가장 돈이 많이 드는 것은 인건비다.</li>
<li>소프트웨어의 경우, 원자재 비용은 별로 들지 않고, 임금이 많이 든다.</li>
</ul>
</li>
<li>이미 살 수 있는 것들을 굳이 다 만들 필요는 없다.<ul>
<li>살 수 있는 가전 제품들은 굳이 만들기보다는 사서 쓰면 된다.</li>
<li>소프트웨어 시스템에서도 마찬가지로, 굳이 ready-made한 자원들이 있는데 직접 만들어 쓰지는 않는다.</li>
<li>물론 커스텀이 필요한 경우에는 그렇게 할 수도 있다. </li>
</ul>
</li>
<li>계획에는 적당한 수준이 필요하다.<ul>
<li>잘못된 순서로 만들면, 만들기도, 테스트하기도, 문제점을 찾기도 어렵다.</li>
<li>그렇다고 해서 너무 완벽한 계획을 만드는 것은 시간도 많이 들고 복잡해, 혼란스러워지기 쉽다.</li>
<li>&quot;제대로 설계&quot;된 프로젝트는 나중에 세부 사항을 수정하기 쉬운 프로젝트다.</li>
</ul>
</li>
<li>대상에 따라 다른 개발 방법이 필요하다.</li>
<li>구조를 변경하는 일은 단순히 주변적인 것들을 수정하는 것보다 어렵다. </li>
<li>규모가 큰 프로젝트들에 대한 인사이트<ul>
<li>안전성이 가장 중요하다. 건물이 무너지는 것보다는 10% 정도 비싸더라도 더 좋은 자재를 쓰는 것이 낫다.</li>
<li>소프트웨어의 경우도 마찬가지로, 복잡하고 큰 프로젝트의 경우 최대한 철저한 사전 준비가 필요하다.</li>
</ul>
</li>
</ul>
<h3 id="applying-software-technique-the-intellectual-toolbox">Applying Software Technique: The Intellectual Toolbox</h3>
<ul>
<li>기술은 규칙이 아니라 도구다.</li>
<li>장인은 작업에 필요한 도구가 무엇인지 알고, 이를 적절히 사용한다. 프로그래머도 마찬가지다.</li>
<li>한 가지에만 집착하지 말고, 여러 도구들을 갖춰 놓고 현재 문제에 따라 적절한 방법, 기술, 관점을 사용하는 자세를 가지자.</li>
</ul>
<h3 id="combining-metaphors">Combining Metaphors</h3>
<p>비유는 알고리즘이 아니라 휴리스틱이므로 서로 양립 가능하고, 여러 가지를 섞어 쓸 수도 있다. 커뮤니케이션에 도움이 된다면 어떤 비유, 혹은 비유 조합을 사용하든 좋다.</p>
]]></description>
        </item>
    </channel>
</rss>