Network NotesChapter 02-1
CHAPTER 02-1 · C FOUNDATIONS FOR TCP

소켓 시작하기
C 기초

배열부터 버퍼의 수명까지.
TCP 소켓 코드를 읽고 쓰는 데 필요한 C 기초 10가지를 배웁니다.

각 주제는 쉬운 개념 → 단계별 도식 → 세부 설명과 코드 순서로 읽습니다.

원본 PDF 22쪽10개 핵심 주제C17 예제 6개오프라인 학습
표현하기배열 · 구조체 · 포인터주소와 길이
쌓고 찾기연결 리스트 · 스택 · 큐 · 원형 큐 · 해시순서와 탐색
안전하게 쓰기비트 연산 · 메모리 관리해석과 수명
보충 학습 지도 · 소켓은 바이트를 전달합니다. 그 바이트의 의미와 보관 방법은 프로그램이 정합니다.
01 / ARRAY원본 PDF 1–3쪽

버퍼의 용량과
유효 데이터 길이

01 쉬운 개념 설명

칸이 나란히 붙은 수납함

다섯 칸짜리 수납함에 물건을 세 개 넣어도 수납함은 여전히 다섯 칸입니다. 배열도 정해진 수의 원소를 나란히 보관하며, 각 칸을 0부터 시작하는 번호인 인덱스로 찾습니다.

네트워크 버퍼에서는 “마련한 칸 수”와 “이번에 받은 데이터의 양”을 따로 기억합니다. 그림의 빈칸은 이번에 받은 데이터가 들어 있지 않은 구간입니다. 그곳의 메모리 값이 자동으로 0이 되었다는 뜻은 아닙니다.

다섯 칸 수납함의 앞 세 칸에만 파란 구슬이 들어 있는 비유 그림
AI 생성 비유 그림 · 수납함 전체는 용량, 구슬이 들어 있는 구간은 유효 길이에 해당합니다.

02 원리를 시각적으로 보기

1 공간을 먼저 준비합니다

[0]·
[1]·
[2]·
[3]·
[4]·

5바이트를 담을 공간을 준비했습니다. 아직 데이터를 받지 않았으므로 읽을 수신 데이터는 없습니다.

2 3바이트를 받았습니다

[0]41시작
[1]00
[2]42유효 구간 끝
[3]·
[4]·

recv()가 3을 반환했다고 가정합니다. 0~2번 칸에 16진수 41, 00, 42가 있고, 나머지 두 칸은 이번 수신 내용이 아닙니다.

3 읽을 구간을 길이로 정합니다

공간의 크기5바이트
실제로 읽을 양3바이트
읽는 인덱스0 · 1 · 2

중간의 00도 정상 데이터입니다. 문자열의 끝으로 취급하지 않고, 세 바이트 모두 처리합니다.

보충 도식 · 버퍼 공간과 수신 길이. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

배열의 시작 주소는 “어디에서 읽을지”, 유효 길이는 “얼마나 읽을지”를 정합니다. 위 도식에서는 첫 칸부터 3바이트를 읽었습니다. 버퍼를 함수에 전달할 때도 주소와 길이를 함께 전달합니다.

배열은 같은 타입의 원소를 연속해서 보관합니다. 소켓에서 “버퍼를 넘긴다”는 말은 보통 이 공간의 시작 주소와 사용할 바이트 수를 함께 전달한다는 뜻입니다.

용량 capacity

unsigned char buf[1024]는 1,024바이트를 담을 공간입니다. 공간이 있다고 해서 1,024바이트의 유효 데이터가 들어 있다는 뜻은 아닙니다.

유효 길이 length

recv()가 37을 반환하면 이번에 채운 구간은 buf[0]부터 buf[36]까지입니다. 남은 공간은 이번 수신 내용에 포함되지 않습니다.

IPv4 배열의 값과 주소

IPv4 옥텟의 배열 표현 · 주소는 설명용 가상 주소
항목[0][1][2][3]
값192 / C0168 / A80 / 001 / 01
주소0x10000x10010x10020x1003
포인터ipip + 1ip + 2ip + 3

표의 주소가 연속된다는 것은 프로그램이 보는 주소 공간을 기준으로 한 설명입니다. 물리 RAM의 페이지 배치까지 연속이라는 뜻은 아닙니다.

배열 표현식은 많은 상황에서 첫 원소 포인터로 바뀌지만, 배열 자체와 포인터 변수는 다릅니다. 배열이 선언된 자리의 sizeof buf와 함수 인자로 받은 포인터의 sizeof를 구분하세요.

Ubuntu · C17 / 배열과 길이 · 개념 조각
/* Ubuntu · C17 / main 함수 안의 개념 조각 */
unsigned char bytes[] = {0x41, 0x00, 0x42};

/* 문자열 끝이 아니라 유효 바이트 수로 순회한다. */
for (size_t i = 0; i < sizeof bytes; i++) {
    printf("%02X ", (unsigned int)bytes[i]);
}

TCP는 바이트 스트림입니다. 배열이 연속되어 있어도 한 번의 send()가 한 번의 recv()로 전달된다는 보장은 없습니다. recv() 반환값 문서와 1장의 TCP 수신 단위 설명을 연결해 읽으세요.

02 / STRUCTURE원본 PDF 3–5쪽

구조체의 필드와
전송 형식

01 쉬운 개념 설명

서로 다른 물건을 한 세트로 묶기

여행 파우치에는 카드, 케이블, 작은 소품처럼 종류가 다른 물건을 함께 넣습니다. 구조체도 상태, 길이, 주소처럼 서로 다른 역할의 값을 하나의 대상으로 묶습니다.

구조체를 구성하는 각 항목을 필드라고 합니다. 필드에 이름을 붙이면 “세 번째 칸” 대신 “메시지 길이”처럼 값의 의미를 드러낼 수 있습니다.

이 세트의 내용을 다른 사람에게 전달하려면 무엇을 어떤 순서로 보낼지 약속해야 합니다. 구조체의 값을 네트워크로 보낼 때도 마찬가지입니다. 프로그램 안에서 값을 묶는 방식과 전송할 바이트의 순서를 따로 정합니다.

카드와 케이블과 구슬을 서로 다른 칸에 담은 여행 파우치
AI 생성 비유 그림 · 다른 종류의 값을 이름 있는 필드로 묶는다는 비유입니다. 실제 메모리 배치는 아래에서 따로 확인합니다.

02 원리를 시각적으로 보기

1 프로그램 안에서는 이름으로 접근합니다

flags0x12
length3

하나의 MessageHeader에 두 필드가 있습니다. flags는 상태 값, length는 본문 바이트 수를 나타냅니다.

2 보내는 순서를 약속합니다

[0]12flags
[1]00길이 상위
[2]03길이 하위

학습용 규격은 상태 1바이트 뒤에 길이 2바이트를 둡니다. 길이는 상위 바이트부터 저장하므로 3을 16진수 00 03으로 표현합니다. 이 그림에서는 본문을 생략했습니다.

3 받은 바이트를 값으로 복원합니다

길이 바이트00 · 03
큰 자릿수부터 조립0 × 256 + 3
복원한 length3

필드 이름은 네트워크로 전달되지 않습니다. 양쪽 프로그램이 같은 규격을 알아야 같은 의미로 해석합니다.

보충 도식 · 필드의 직렬화와 해석. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

도식의 3바이트는 전송 규격이 정한 길이이며, sizeof(MessageHeader)가 반드시 3이라는 뜻은 아닙니다. 컴파일러는 정렬을 맞추려고 필드 사이에 패딩이라는 빈 공간을 넣을 수 있습니다.

구조체는 서로 다른 타입의 값을 하나의 의미 있는 대상으로 묶습니다. header.length처럼 필드 이름을 쓰면 buffer[2]보다 의도가 분명합니다. 다만 구조체의 메모리가 곧 통신 규격인 것은 아닙니다.

패딩은 어디에서 생길까?

원본 PDF 5쪽에서는 2바이트 Port와 4바이트 SeqNum 사이에 빈 공간이 생깁니다. 다음 필드가 정해진 주소 경계에서 시작하도록 넣는 공간을 패딩(padding)이라고 합니다.

아래 표는 4바이트 정렬을 사용하는 흔한 환경의 예입니다. 실제 배치는 자료형의 크기·배치를 정하는 규약인 ABI와 컴파일러 설정에 따라 달라지므로 직접 확인해야 합니다.

표현배치크기 / 의미
일반 구조체 예Port 2 → 패딩 2 → SeqNum 48바이트가 되는 환경이 흔함
packed 예Port 2 → SeqNum 46바이트, 정렬 제약을 낮춤
명시적 직렬화규격에 정한 바이트를 순서대로 작성메모리 배치와 전송 형식을 분리

수신한 값을 구조체에 저장하기

보충 실습은 [flags 1바이트][length 2바이트][본문]이라는 작은 형식을 정의합니다. 길이는 상위 바이트부터 보내는 big-endian 방식으로 표현합니다. 00 03은 길이 3입니다. 수신 측은 두 바이트를 조립한 뒤 구조체 필드에 저장합니다.

Ubuntu · C17 / buffer_basics.c 발췌 · MessageHeader 정의는 전체 코드 참고
static bool decode_header(const unsigned char *data, size_t size,
                          MessageHeader *out)
{
    if (data == NULL || out == NULL || size < 3) {
        return false;
    }

    out->flags = data[0];
    out->length = (uint16_t)(((unsigned int)data[1] << 8) | data[2]);
    return true;
}

일반 TCP 소켓으로 보내는 것은 응용 데이터입니다. TCP/IP 헤더를 매번 직접 조립하는 것은 아닙니다. 원본의 계층별 구조체 그림은 헤더 구조를 이해하기 위한 모델로 읽고, 여기의 학습용 메시지 헤더와 실제 TCP 헤더를 구분하세요.

03 / POINTER원본 PDF 5–7쪽

포인터의 주소·범위와
객체 수명

01 쉬운 개념 설명

물건 대신 위치를 적어 둔 안내 카드

물건이 든 상자와 “그 상자가 어디 있는지” 알려 주는 카드는 서로 다릅니다. 포인터는 이 카드처럼 다른 객체의 위치를 가리킵니다. 포인터를 복사하는 일은 상자 속 물건 전체를 복사하는 일과 다릅니다.

위치를 안다고 언제나 열어 볼 수 있는 것은 아닙니다. 상자가 아직 존재하는지, 그 안에서 어느 범위까지 읽어도 되는지도 알아야 합니다. 코드에서도 같은 이유로 객체의 주소뿐 아니라 읽을 수 있는 길이와 객체가 유효하게 존재하는 기간인 수명을 함께 확인합니다.

안내 카드와 별도의 파란 상자가 파란 선으로 연결된 모습
AI 생성 비유 그림 · 카드는 포인터, 상자는 가리키는 객체의 비유입니다. 선은 관계를 나타내며 실제 메모리에 선이 생기는 것은 아닙니다.

02 원리를 시각적으로 보기

1 시작 위치를 가리킵니다

[0]41p = buf
[1]00
[2]42
[3]43

unsigned char 배열을 가리키는 p는 시작 위치를 담습니다. *p를 읽으면 첫 바이트 0x41을 얻습니다.

2 필요한 범위를 먼저 확인합니다

유효 길이 len4
offset + 필요량2 + 2
읽을 범위[2] · [3]

2번 위치에서 2바이트를 읽으려 합니다. offset이 len 이하인지, 필요한 양이 len - offset 이하인지 검사합니다.

3 그 뒤에 포인터를 이동합니다

[0]41
[1]00
[2]42p = buf + 2
[3]43p[1]

p[0]은 0x42, p[1]은 0x43입니다. 포인터가 이동했어도 원래 배열의 데이터가 이동한 것은 아닙니다.

보충 도식 · 범위 검사와 포인터 이동. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

포인터 산술의 “한 칸”은 가리키는 타입의 원소 하나입니다. 도식의 unsigned char는 한 원소가 1바이트이므로 2칸 이동하면 2바이트를 건너뜁니다. 이동하거나 접근하기 전에 타입과 유효 길이를 확인합니다.

포인터는 객체를 가리키는 값을 저장합니다. 네트워크 버퍼를 처리할 때는 시작 주소에 더해 남은 길이, 타입, 수명까지 알아야 합니다.

&value

변수의 주소를 얻습니다. 함수가 결과를 기록할 공간을 넘길 때 사용합니다.

*ptr

가리키는 객체에 접근합니다. 유효한 객체를 가리키는지 먼저 확인해야 합니다.

node->next

(*node).next와 같은 의미입니다. 구조체 포인터를 따라 필드에 접근합니다.

이동 단위는 포인터 타입이 정한다

unsigned char *p에서 p + 1은 한 바이트 다음입니다. uint32_t *p에서도 p + 1은 원소 하나만큼 이동하지만, Ubuntu에서는 그 크기가 4바이트입니다.

계산은 같은 배열 내부 또는 바로 다음 위치까지만 허용되며, 배열 바로 다음 위치는 역참조하면 안 됩니다.

Ubuntu · C17 / 경계 검사 · 개념 조각
/* Ubuntu · C17 / len은 유효한 수신 길이, buf는 그 배열의 주소 */
size_t offset = 3;
size_t needed = 2;

/* 포인터를 이동하기 전에 길이를 검사한다. */
if (offset <= len && needed <= len - offset) {
    const unsigned char *p = buf + offset;
    unsigned int value = ((unsigned int)p[0] << 8) | p[1];
    printf("%u\n", value);
}

함수에 포인터를 전달하면 주소 값의 복사본이 전달됩니다. 복사본도 같은 대상을 가리키므로 함수 안에서 원래 데이터를 수정할 수 있습니다.

배열 전체 대신 주소를 넘기면 응용 코드에서 큰 배열을 복사하는 일을 줄일 수 있습니다. 다만 일반적인 send()·recv() 내부의 복사까지 없어지는 것은 아닙니다. 데이터 복사를 없애는 zero-copy는 별도의 문제입니다.

원본의 “Ethernet 헤더 14바이트 건너뛰기”는 버퍼 앞에 그 헤더가 실제로 있는 경우의 예입니다. 일반 TCP 수신 버퍼에는 Ethernet 헤더가 들어 있지 않습니다.

04 / LINKED LIST원본 PDF 7–9쪽

연결 리스트의
노드 연결

01 쉬운 개념 설명

다음 수레가 어디 있는지 서로 알려 주기

떨어져 있는 수레들을 연결 끈으로 이어 놓으면 첫 수레에서 다음 수레로 차례차례 따라갈 수 있습니다. 연결 리스트의 각 노드도 데이터와 다음 노드를 가리키는 포인터인 next를 함께 저장합니다. 데이터 자리에는 정수, 문자열, 접속자 정보 등 필요한 내용을 담을 수 있습니다.

head는 첫 노드의 주소를 저장하고, NULL은 다음 노드가 없다는 표시입니다. 서버의 접속자 목록이라면 노드 하나에 소켓의 식별 번호인 fd와 수신 버퍼, 받은 길이를 함께 둘 수 있습니다. 연결 리스트가 fd만 저장하도록 정해진 것은 아닙니다.

파란 짐을 실은 세 수레가 순서대로 끈으로 연결된 모습
AI 생성 비유 그림 · 수레는 노드, 연결은 next의 비유입니다. 실제 노드는 주소 공간에서 서로 떨어져 있을 수 있습니다.

02 원리를 시각적으로 보기

데이터 종류와 노드 연결

1 기존 목록을 보존합니다

head노드 A데이터 · next
다음NULL

현재 head는 노드 A를 가리키고, A의 next는 NULL입니다. A 앞에 새 노드 B를 넣을 때도 노드에 담긴 데이터 종류와 관계없이 같은 방법으로 연결합니다.

2 새 노드가 기존 노드를 가리키게 합니다

새 노드노드 B데이터 · next
기존 head의 노드노드 A데이터 · next
끝NULL

먼저 B의 next에 기존 head 값을 넣어 A를 가리키게 합니다. 아직 head 자체는 A를 가리킵니다. 화살표는 노드 사이의 주소 연결이며, 데이터가 이동하는 경로는 아닙니다.

3 출발점을 새 노드로 바꿉니다

head가 가리키는 노드노드 B데이터 · next
next노드 A데이터 · next
끝NULL

이제 head에 B의 주소를 저장합니다. head → B → A → NULL 순서로 노드에 접근할 수 있습니다. 각 노드 안의 데이터는 그대로 유지됩니다.

보충 도식 · 목록 앞에 노드 삽입. 인쇄 시 모든 단계를 표시합니다.

접속자별 수신 버퍼

클라이언트 A가 TCP로 HELLO 5바이트를 보냈다고 가정합니다. 서버는 접속자별로 노드를 만들고 fd, buffer, used, next를 보관합니다.

아래에서 7과 4는 실제 연결에 배정됐다고 가정한 번호입니다. 실제 프로그램은 숫자를 임의로 정하지 않고 accept()가 성공했을 때 반환한 값을 저장합니다.

1 접속자마다 수신 상태를 보관합니다

head가 가리키는 접속자 A의 노드fd = 7buffer: 비어 있음 · used: 0
next: B 노드의 주소
접속자 B의 노드fd = 4buffer: BYE · used: 3
next: NULL

화살표는 next에 저장된 주소로 다음 노드를 가리키는 관계를 나타냅니다. fd는 운영체제에 소켓을 지정할 때 쓰는 정수이고, next는 프로그램 메모리의 노드 주소입니다. B가 이미 받은 BYE는 B의 버퍼에 따로 보관되어 있습니다.

2 recv가 HE를 A의 버퍼에 기록합니다

커널 · fd 7이 식별하는 소켓HE이번에 읽을 수신 데이터
recv(fd, 저장 주소, 여유 크기, 0)n = 2운영체제가 2바이트를 복사
프로그램 · A의 bufferHEused: 0 → 2

이번 화살표는 수신 데이터의 흐름입니다. node->socket_fd로 소켓을 지정하고 node->buffer의 주소를 저장 위치로 전달합니다. recv()가 2를 반환하면 프로그램이 used를 2로 늘립니다. fd를 포인터로 바꿔 커널 메모리를 직접 읽는 과정은 없습니다.

3 다음 수신 바이트를 뒤에 이어 담습니다

같은 소켓에서 이어 받은 바이트LLO다음 recv()의 데이터
A의 buffer + used에 기록n = 3기존 HE 다음 위치부터
프로그램 · A의 bufferHELLOused: 2 → 5

프로그램은 이제 A의 buffer에서 유효한 5바이트를 읽습니다. B의 buffer와 두 노드의 next는 바뀌지 않습니다. 이것이 접속자마다 수신 상태를 보관하는 이유입니다.

보충 도식 · TCP 수신 API를 설명하는 가상 시나리오이며 실제 통신은 하지 않습니다. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

next는 노드를 연결하고, fd는 소켓을 식별하며, buffer는 받은 데이터를 보관합니다. 노드 연결과 데이터 수신은 서로 다른 역할입니다.

연결 리스트의 핵심은 다음 노드의 주소를 저장하는 연결 방식입니다. 배열처럼 노드들이 인접할 필요는 없습니다. 노드에 어떤 데이터를 둘지는 프로그램이 정합니다.

노드에 소켓 번호와 수신 버퍼를 함께 둡니다

노드의 필드저장하는 것사용하는 방법
socket_fd운영체제가 배정한 소켓 식별 번호recv() 등에 전달해 소켓을 지정
buffer[1024]프로그램 메모리 안의 실제 수신 바이트used가 0보다 크면 buffer[0]부터 buffer[used - 1]까지 읽기. used가 0이면 읽을 데이터가 없음
used현재 버퍼에 쌓인 유효 바이트 수다음 저장 위치와 남은 용량 계산
next다음 노드의 메모리 주소 또는 NULL다음 접속자 노드로 이동

아래 구조체는 버퍼 배열을 노드 내부에 포함합니다. buffer를 별도로 할당한 메모리를 가리키는 포인터로 설계할 수도 있습니다.

어느 방식이든 fd를 저장하는 것만으로 수신 데이터가 노드에 들어오지는 않습니다. recv()에 저장할 버퍼의 주소를 전달해야 합니다. Linux recv() 문서 · accept() 문서

보충 코드 펼치기 · 접속자 노드와 수신 함수
Ubuntu · C17 / 실제 연결된 fd가 필요한 함수 예시
#include <errno.h>
#include <stddef.h>
#include <sys/socket.h>

typedef struct ConnectionNode {
    int socket_fd;                  /* 실제 연결된 소켓의 식별 번호 */
    unsigned char buffer[1024];      /* 이 연결에서 받은 바이트 */
    size_t used;                    /* buffer에 쌓인 유효 바이트 수 */
    struct ConnectionNode *next;    /* 다음 접속자 노드의 주소 */
} ConnectionNode;

/* node는 유효한 노드이며 used는 0에서 시작한다.
 * socket_fd에는 accept() 등으로 얻은 실제 연결된 fd를 넣는다. */
ssize_t receive_into_node(ConnectionNode *node)
{
    /* 공간이 없으면 수신하지 않는다. 호출한 쪽이 데이터를 처리해야 한다. */
    if (node->used >= sizeof node->buffer) {
        errno = ENOBUFS;
        return -1;
    }

    /* fd는 소켓 선택에, buffer의 주소는 데이터 저장 위치 지정에 쓴다.
     * 이미 받은 바이트 뒤부터 남은 공간만큼만 기록한다. */
    ssize_t n;
    do {
        n = recv(node->socket_fd,
                 node->buffer + node->used,
                 sizeof node->buffer - node->used, 0);
    } while (n < 0 && errno == EINTR);

    /* 양수일 때만 길이를 늘린다. NUL도 한 바이트의 데이터다. */
    if (n > 0) {
        node->used += (size_t)n;
    }

    /* 0은 상대의 송신 종료, -1은 errno 확인이 필요하다는 뜻이다. */
    return n;
}

먼저 실제로 연결된 fd를 노드에 저장하고 used = 0으로 시작합니다. 수신 함수를 호출한 뒤에는 반환값에 따라 처리합니다.

  • 양수: 그 수만큼 바이트가 추가됐습니다.
  • 0: 상대가 송신을 마쳤습니다. 이미 받은 데이터는 통신 규칙에 따라 처리합니다.
  • -1: errno로 원인을 확인합니다.

논블로킹 소켓의 EAGAIN·EWOULDBLOCK은 지금 읽을 데이터가 없다는 뜻입니다. 읽을 수 있다는 알림을 받은 뒤 다시 시도합니다. 이 예제 함수의 ENOBUFS는 노드의 버퍼가 가득 찼다는 뜻입니다.

가상 fd를 이용한 노드 삽입·삭제 실습

아래 client_list.c는 연결·삽입·삭제만 연습하는 작은 예제입니다. 실제 소켓을 열지 않고 가상의 fd 4와 7을 저장하므로, 이 번호로 recv()를 호출하면 안 됩니다. 위 수신 예시처럼 노드의 필드를 늘려도 next를 연결하는 원리는 같습니다.

처음head → NULL빈 목록
4 추가head → [fd 4] → NULL첫 노드
7 추가head → [fd 7] → [fd 4] → NULL맨 앞 삽입
원본 PDF 9쪽 기반 보충 도식 · fd는 가상 식별자입니다.
  1. malloc(sizeof *node)로 노드를 만들고 실패를 확인합니다.
  2. node->next = *head로 기존 목록을 먼저 연결합니다.
  3. *head = node로 시작점을 바꿉니다. 순서를 뒤집으면 기존 목록을 잃거나 자기 자신을 가리킬 수 있습니다.
Ubuntu · C17 / client_list.c 발췌
static bool add_client(ClientNode **head, int fd)
{
    ClientNode *node = malloc(sizeof *node);
    if (node == NULL) {
        return false;
    }

    node->socket_fd = fd;
    node->next = *head;
    *head = node;
    return true;
}

삽입과 탐색의 연산 수

맨 앞에 노드를 붙일 때는 목록 전체를 훑지 않습니다. 목록이 길어져도 필요한 연산 수가 일정하므로 O(1)이라고 씁니다.

특정 fd를 찾아 삭제하려면 노드를 차례로 확인해야 합니다. 노드가 n개일 때 최대 n개를 살펴볼 수 있으므로 탐색은 O(n)입니다. 단일 연결 리스트의 끝 노드를 삭제할 때도 앞 노드를 찾아야 할 수 있습니다.

노드마다 메모리와 next 포인터 공간도 필요합니다. 연결 리스트를 쓴다고 접속자를 무제한으로 받을 수 있는 것은 아닙니다.

05 / STACK원본 PDF 9–11쪽

스택의
LIFO 규칙

01 쉬운 개념 설명

맨 위 접시부터 꺼내는 접시 더미

접시를 차곡차곡 쌓으면 마지막에 올린 접시가 맨 위에 있습니다. 스택도 맨 위에서만 데이터를 넣고 꺼냅니다. 이 규칙을 LIFO라고 하며, “나중에 넣은 것을 먼저 꺼낸다”는 뜻입니다.

데이터를 올리는 동작은 push, 꺼내는 동작은 pop입니다. 작업을 취소할 때 가장 최근에 저장한 상태부터 복원하는 경우에 유용합니다.

흰 접시 두 장 위에 마지막으로 올린 파란 접시를 집어 올리는 모습
AI 생성 비유 그림 · 맨 위에서 넣고 꺼내는 순서를 나타내며 실제 메모리 배치와는 다릅니다.

02 원리를 시각적으로 보기

1 10을 먼저 넣습니다

출입구 · top ↓
10가장 위
바닥

원소가 하나이고 count는 1입니다. 다음 push는 이 위에 새 값을 올립니다.

2 20을 그 위에 넣습니다

출입구 · top ↓
20가장 위
10아래에 보관
바닥

count는 2입니다. 가장 최근 값인 20이 맨 위에 있습니다.

3 pop은 맨 위의 20을 꺼냅니다

꺼낸 값20
남은 스택10
남은 원소 수count = 1

10을 먼저 넣었지만 20을 먼저 꺼냈습니다. 실제 배열 안의 20을 지우지 않아도 count를 줄이면 유효 범위에서 제외됩니다.

보충 도식 · 두 값의 push와 pop 순서. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

이 예제는 원소 수 count로 스택의 맨 위 위치를 계산합니다. 연산의 성공 여부는 반환값으로 알리므로, 데이터 -1과 빈 스택 상태를 구분할 수 있습니다.

스택은 LIFO입니다. 중첩된 괄호를 검사하거나 이전 작업 상태로 돌아갈 때 유용합니다. 함수가 호출되었다가 복귀하는 흐름도 이 원리와 연결됩니다.

연산아래 → 위반환
push(10)10성공
push(20)10, 20성공
push(-1)10, 20, -1성공
pop()10, 20-1

원본의 top = -1은 맨 위 원소의 인덱스를 기록하는 방식입니다. 보충 예제는 원소 수 count를 사용합니다. 비어 있으면 0, 꽉 차면 용량과 같습니다.

top은 맨 위 원소의 인덱스이고 count는 원소 수입니다. 따라서 같은 숫자라도 의미가 다릅니다.

Ubuntu · C17 / stack.c 발췌
static bool pop(Stack *stack, int *out)
{
    if (stack->count == 0) {
        return false;
    }

    stack->count--;
    *out = stack->values[stack->count];
    return true;
}

이 절의 자료구조 스택, 함수 호출을 관리하는 호출 스택, 통신의 여러 계층을 뜻하는 프로토콜 스택은 구분해서 읽으세요.

C 함수가 오류를 반환해도 힙 메모리나 소켓이 자동으로 정리되지는 않습니다. 프로그램이 직접 정리해야 합니다.

원본 PDF 11쪽의 0x1은 일반적인 IP 계층 번호가 아닙니다. IPv4의 Protocol 필드에서 1은 ICMP, 6은 TCP를 뜻합니다.

06 / QUEUE원본 PDF 11–13쪽

큐의
FIFO 규칙

01 쉬운 개념 설명

앞에 도착한 상자부터 처리하는 줄

컨베이어에 먼저 올라간 상자가 앞에서 먼저 나옵니다. 큐는 들어오는 쪽과 나가는 쪽을 구분하여 이 순서를 지키는 대기열입니다. FIFO는 “먼저 넣은 것을 먼저 꺼낸다”는 뜻입니다.

새 데이터를 뒤에 붙이는 동작은 enqueue, 가장 오래 기다린 데이터를 앞에서 꺼내는 동작은 dequeue입니다. 데이터가 처리되는 속도보다 잠시 빠르게 들어오면, 큐에 보관해 두었다가 순서대로 처리할 수 있습니다.

한 줄로 놓인 상자 중 맨 앞 파란 상자가 출구에 가까운 컨베이어
AI 생성 비유 그림 · 출구에 먼저 도착하는 상자가 먼저 처리됩니다. 원소를 앞으로 당기지 않는 구현도 가능합니다.

02 원리를 시각적으로 보기

1 A, B, C가 순서대로 들어옵니다

[0]Afront
[1]B
[2]C
[3]·rear
[4]·

front는 꺼낼 A, rear는 다음에 넣을 빈칸을 가리킵니다. 기다리는 순서는 A → B → C입니다.

2 먼저 들어온 A를 꺼냅니다

[0]·
[1]Bfront
[2]C
[3]·rear
[4]·

front만 1로 이동했습니다. B와 C를 실제 메모리에서 앞으로 복사할 필요는 없습니다.

3 뒤에 D를 붙입니다

[0]·
[1]Bfront
[2]C
[3]D
[4]·rear

D를 index 3에 저장한 뒤 rear를 4로 옮깁니다. 이제 B → C → D 순서로 꺼냅니다.

보충 도식 · 먼저 들어온 값의 제거와 새 값의 삽입. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

이 도식에서 front는 다음에 꺼낼 위치, rear는 다음에 넣을 위치이므로 rear - front가 대기 중인 원소 수입니다. rear가 배열 끝에 도달하면 앞에 빈칸이 있어도 더 넣을 수 없습니다.

큐는 데이터를 넣는 쪽과 꺼내는 쪽 사이에 대기 공간을 둡니다. 먼저 들어온 순서대로 처리하되, 들어오는 양이 계속 더 많으면 언젠가는 용량 한계에 도달합니다.

front는 다음에 꺼낼 위치, rear는 다음에 넣을 위치로 정의합니다. 선형 큐에서 삽입할 때 rear++, 제거할 때 front++하면 유효 데이터는 [front, rear) 구간, 즉 front부터 rear 바로 앞까지에 있습니다.

Ubuntu · C17 / linear_queue.c 발췌
static bool enqueue(Queue *queue, int value)
{
    if (queue->rear == CAPACITY) {
        return false;
    }

    queue->values[queue->rear] = value;
    queue->rear++;
    return true;
}

static bool dequeue(Queue *queue, int *out)
{
    if (queue->front == queue->rear) {
        return false;
    }

    *out = queue->values[queue->front];
    queue->front++;
    return true;
}
상태배열 index 0 → 4front / rear
5개 삽입A · B · C · D · E0 / 5
3개 제거빈칸 · 빈칸 · 빈칸 · D · E3 / 5
F 삽입 시도앞에 빈칸이 있어도 실패rear가 배열 끝에 도달

앞에 빈칸이 있어도 삽입할 수 없는 상태를 “가짜 포화”라고 합니다. 메모리 누수가 아니라 앞부분을 재사용하지 못하는 구현의 한계입니다. 데이터를 앞으로 옮기거나 연결 리스트를 쓰는 대안도 있으며, 다음 절에서는 원형 인덱스로 해결합니다.

소켓 송수신 버퍼와 큐

송수신 버퍼는 속도 차이를 완충합니다. send() 성공이 상대 응용프로그램의 처리 완료를 뜻하지는 않습니다. 실제 전송은 별도로 진행됩니다. 또한 반환된 바이트 수가 보내려던 양보다 작으면 나머지를 이어 보내야 합니다. send() 문서

07 / CIRCULAR QUEUE원본 PDF 14–16쪽

원형 큐의
인덱스 순환

01 쉬운 개념 설명

같은 자리가 돌아오는 수하물 벨트

공항 수하물 벨트는 한 바퀴를 돌면 같은 자리가 다시 돌아옵니다. 원형 큐도 배열의 끝 다음에 0번 칸을 다시 사용합니다. 배열을 새로 늘리거나 남은 데이터를 한꺼번에 앞으로 옮기지 않아도 됩니다.

실제 데이터가 벨트처럼 움직이는 것은 아닙니다. “다음에 어디에 넣고 어디에서 꺼낼지”를 나타내는 인덱스가 순환합니다. 아직 꺼내지 않은 데이터를 덮어쓰지 않도록 가득 찼는지 먼저 검사합니다.

파란 여행 가방들이 놓인 순환형 수하물 벨트
AI 생성 비유 그림 · 다시 돌아온 빈 공간을 쓴다는 비유입니다. 실제 구현에서는 데이터 대신 인덱스가 순환합니다.

02 원리를 시각적으로 보기

1 앞의 두 값을 꺼낸 상태입니다

[0]·
[1]·
[2]30front
[3]40
[4]·rear

10과 20을 꺼냈습니다. front=2, rear=4이므로 50을 넣을 위치는 index 4입니다.

2 50을 끝 칸에 넣고 rear를 돌립니다

[0]·rear
[1]·
[2]30front
[3]40
[4]50

50을 index 4에 저장한 다음 rear를 (4+1)%5=0으로 갱신합니다. 이제 다음 삽입 위치는 배열의 첫 칸입니다.

3 60은 앞의 빈칸을 재사용합니다

[0]60
[1]·rear
[2]30front
[3]40
[4]50

대기 순서는 30 → 40 → 50 → 60입니다. 현재 rear=1이고, 한 칸 더 이동하면 front=2와 같아집니다. 한 칸을 비워 두는 규칙에 따라 지금은 FULL이며, 추가 삽입을 거부합니다.

보충 도식 · 배열 끝에서 첫 칸으로 인덱스 순환. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

큐에서 꺼내는 순서는 배열의 0번 칸부터 읽는 순서와 다를 수 있습니다. 위 도식에서는 front부터 배열 끝까지 읽은 뒤 첫 칸으로 돌아옵니다.

원형 큐의 배열도 메모리에서는 일렬로 놓여 있습니다. 다음 인덱스를 (index + 1) % SIZE로 계산해 앞의 빈 공간을 다시 사용합니다.

EMPTY

front == rear
꺼낼 값이 없습니다.

FULL

(rear + 1) % SIZE == front
rear를 한 칸 더 이동하면 front와 같아지므로 삽입을 거부합니다.

원본의 한 칸 비우기 방식 · 배열이 5칸이면 실제 저장 용량은 4개입니다. 꽉 찬 경우와 빈 경우를 구분하려고 한 칸을 남깁니다. 원소 수를 따로 저장하면 5칸 모두 사용할 수도 있습니다.

front·rear 이동 체험

보충 체험 · 실제 네트워크 통신 아님

넣기 4회 → 한 번 더 넣어 FULL 확인 → 꺼내기 2회 → 넣기 2회를 눌러 보세요. 값은 10씩 증가합니다.

JavaScript를 켜면 직접 조작할 수 있습니다. 아래 표에서도 같은 순서를 확인할 수 있습니다.

원본 PDF 15쪽 기반 보충 표 · 코드의 인덱스 규칙 적용
작업index 0 · 1 · 2 · 3 · 4front / rear논리 순서
A, B, C, D 넣기A · B · C · D · 빈칸0 / 4A → B → C → D
A, B 꺼내기빈칸 · 빈칸 · C · D · 빈칸2 / 4C → D
E 넣기빈칸 · 빈칸 · C · D · E2 / 0C → D → E
F 넣기F · 빈칸 · C · D · E2 / 1C → D → E → F

E는 index 4에 쓴 뒤 rear가 0으로 돌아옵니다. 원본 도표의 E 위치인 index 0과 다릅니다. 꺼낸 칸은 실제 메모리 값이 남아 있어도 유효 범위 밖이며, 체험 화면에서는 빈칸으로 표시합니다.

Ubuntu · C17 / ring_queue.c 발췌
static bool enqueue(RingQueue *queue, int value)
{
    size_t next = (queue->rear + 1) % SIZE;
    if (next == queue->front) {
        return false;
    }

    queue->values[queue->rear] = value;
    queue->rear = next;
    return true;
}

static bool dequeue(RingQueue *queue, int *out)
{
    if (queue->front == queue->rear) {
        return false;
    }

    *out = queue->values[queue->front];
    queue->front = (queue->front + 1) % SIZE;
    return true;
}
08 / HASH TABLE원본 PDF 16–18쪽

해시로 버킷을 찾고
키로 항목 구분하기

01 쉬운 개념 설명

분류함을 먼저 찾고 이름표를 다시 확인하기

물건을 찾을 때 모든 서랍을 열기보다 정해진 규칙으로 해당 분류함부터 찾으면 빠릅니다. 해시 함수는 키를 입력받아 먼저 살펴볼 버킷, 즉 보관 칸의 번호를 계산합니다.

서로 다른 키가 같은 분류함으로 배정되는 경우를 “충돌”이라고 합니다. 충돌한 항목들을 함께 보관하고, 찾을 때 원래 키를 다시 비교하면 원하는 항목을 구별할 수 있습니다.

한 칸에 서로 다른 파란 이름표 두 개가 함께 들어 있는 분류함
AI 생성 비유 그림 · 한 버킷에 여러 키가 들어갈 수 있습니다. 같은 칸에 있다는 사실이 같은 키라는 뜻은 아닙니다.

02 원리를 시각적으로 보기

1 키로 버킷을 계산합니다

키80
hash80 % 100
버킷80

테이블의 크기가 100이면 인덱스는 0~99입니다. 포트 80은 버킷 80으로 갑니다.

2 다른 키도 같은 버킷에 도착합니다

키8080
hash8080 % 100
같은 버킷80

8080도 나머지가 80입니다. 기존 80 항목을 덮어쓰지 않고 같은 버킷의 연결 리스트에 함께 보관합니다.

3 버킷 안에서 원래 키를 비교합니다

찾을 키80
첫 노드8080 ≠ 80
다음 노드80 = 80

체인에서 키가 일치하는 노드의 값을 반환합니다. 해시 계산은 탐색 범위를 줄이고, 키 비교는 정확성을 보장합니다.

보충 도식 · 버킷 계산과 키 비교. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

해시 함수는 여러 키를 제한된 수의 버킷에 배정하므로 충돌을 처리해야 합니다. 버킷 인덱스를 계산한 뒤 port 값을 비교하며, 충돌이 많아질수록 비교할 항목도 늘어납니다.

해시 테이블은 키를 버킷 인덱스로 바꾸어 탐색 범위를 줄입니다. 서로 다른 키가 같은 칸에 들어가는 충돌은 정상적으로 발생할 수 있습니다.

80 % 100bucket[80] → [80 : fd 10]첫 삽입
8080 % 100bucket[80] → [8080 : fd 11] → [80 : fd 10]체이닝
443 % 100bucket[43] → [443 : fd 12]다른 버킷
원본의 포트 해시 예시 재구성 · 콜론 뒤 fd는 프로그램에서 쓰는 가상 값입니다.

8080을 찾을 때는 버킷 80으로 이동한 뒤 각 노드의 port == 8080을 검사합니다. 80과 8080의 해시 인덱스가 같다는 이유로 같은 항목으로 취급하면 안 됩니다. 동일 키를 다시 삽입할 때는 값을 갱신하고, 새 키라면 노드를 추가합니다.

Ubuntu · C17 / port_table.c 발췌
static bool search(uint16_t port, int *out)
{
    for (const Bucket *node = table[hash(port)]; node != NULL; node = node->next) {
        /* 해시 인덱스가 같아도 원래 키가 같은지 다시 비교한다. */
        if (node->port == port) {
            *out = node->socket_fd;
            return true;
        }
    }
    return false;
}

평균 성능

저장된 항목 수를 버킷 수로 나눈 값을 부하율이라고 합니다. 키가 고르게 분산되고 부하율이 적절히 유지되면 검색·삽입의 평균 시간은 O(1)에 가까워집니다. 모든 키가 한 버킷에 몰리면 탐색은 O(n)이 될 수 있습니다.

실제 연결의 키

같은 서버 포트에 여러 클라이언트가 연결됩니다. 포트 하나만으로는 TCP 연결을 구분할 수 없습니다. 양쪽 IP·포트와 전송 프로토콜 등 문맥을 함께 고려합니다.

09 / BITWISE OPERATIONS원본 PDF 19–20쪽

비트 마스크로
플래그 검사·변경하기

01 쉬운 개념 설명

여러 스위치를 한 패널에 모아 두기

스위치 하나는 켜짐과 꺼짐 두 상태를 표현합니다. 비트도 1과 0을 담습니다. 8비트를 묶은 한 바이트에는 이런 스위치 여덟 개가 있어, 여러 상태를 각 자리에 나누어 기록할 수 있습니다.

마스크는 “어느 스위치를 확인할 것인가”를 지정하는 값입니다. 확인할 비트 자리는 1, 나머지 자리는 0으로 둡니다. 원본 값과 마스크를 AND 연산하면 선택한 자리의 상태만 남습니다.

여덟 스위치 중 두 개에만 파란 불이 켜진 흰 제어 패널
AI 생성 비유 그림 · 스위치는 독립적인 비트 상태의 비유입니다. SYN·ACK의 정확한 위치는 아래 비트 도식에서 확인합니다.

02 원리를 시각적으로 보기

1 0x12를 여덟 비트로 펼칩니다

bit 70
bit 60
bit 50
bit 41
bit 30
bit 20
bit 11
bit 00

왼쪽부터 bit 7~0입니다. bit 4와 bit 1이 1이고 나머지는 0입니다. TCP 플래그 예시에서는 각각 ACK와 SYN의 자리입니다.

2 SYN 자리만 선택할 마스크입니다

bit 70
bit 60
bit 50
bit 40
bit 30
bit 20
bit 11
bit 00

0x02는 bit 1만 1입니다. 다른 자리는 0이므로 AND 결과에서 다른 비트들은 지워집니다.

3 AND 결과가 0인지 확인합니다

원래 값0x12
AND 마스크0x02
검사 결과0x02 ≠ 0

결과가 0이 아니므로 SYN 자리가 켜져 있습니다. 검사 결과를 flags에 다시 대입하지 않으면 원래 값은 그대로 유지됩니다.

보충 도식 · 마스크를 이용한 SYN 비트 검사. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

비트 검사와 비트 변경은 다른 연산입니다. AND·OR·NOT·XOR의 용도를 비교하고, 시프트와 마스크로 IPv4 첫 바이트의 상위·하위 네 비트를 분리합니다.

SYN의 마스크 0x02와 ACK의 마스크 0x10을 OR 연산으로 합치면 0x12입니다. 이 값에는 두 비트가 모두 켜져 있습니다. 이 예시는 값을 읽는 연습이며, 일반 TCP 소켓의 플래그를 직접 조절하는 코드는 아닙니다.

목적연산예시 결과
SYN 검사flags & 0x02U0x12 → 0x02, 켜져 있음
FIN 켜기flags |= 0x01U0x12 → 0x13
SYN 끄기flags &= ~0x02U0x13 → 0x11
SYN 반전flags ^= 0x02U0x11 → 0x13
상위 4비트0x45U >> 44, IPv4 버전
하위 4비트0x45U & 0x0FU5, IHL → 5 × 4 = 20바이트
Ubuntu · C17 / 마스크와 시프트 · 개념 조각
unsigned int flags = 0x12U;

/* 마스크의 모든 비트가 켜졌는지 검사한다. */
unsigned int mask = 0x02U | 0x10U;
if ((flags & mask) == mask) {
    puts("SYN and ACK are set");
}

/* IPv4 첫 바이트는 TCP 플래그와 별개의 값이다. */
unsigned int first_byte = 0x45U;
unsigned int version = first_byte >> 4;
unsigned int header_bytes = (first_byte & 0x0FU) * 4U;

&는 각 비트를 계산하고, &&는 두 조건이 모두 참인지 확인합니다. 플래그를 검사할 때는 다음 두 식을 구분하세요.

  • (flags & mask) != 0: 선택한 비트 중 하나라도 켜져 있는지 확인합니다.
  • (flags & mask) == mask: 선택한 비트가 모두 켜져 있는지 확인합니다.

시프트에는 부호 없는 타입을 사용하고, 이동 횟수가 그 타입에서 허용되는 범위인지 확인하세요.

10 / MEMORY MANAGEMENT원본 PDF 21–22쪽

메모리 수명과
해제 책임

01 쉬운 개념 설명

빌린 책은 사용을 마친 뒤 반납하기

도서관에서 책을 빌리면 사용하는 동안 보관하고, 다 읽은 뒤 반납합니다. 동적 메모리도 필요한 공간을 할당받고 사용하는 동안 유지한 뒤 해제합니다. 이렇게 할당받은 공간은 함수를 빠져나가도 자동으로 해제되지 않습니다.

반납한 책을 계속 내 책처럼 사용하면 안 되듯, free()한 공간도 다시 읽거나 쓰면 안 됩니다. 프로그램에서는 이 공간을 누가 관리하고 마지막에 해제할지 정합니다. 이 책임을 가진 쪽이 메모리를 소유한다고 말합니다.

빌린 파란 책이 놓인 대출 쟁반과 비어 있는 반납 쟁반
AI 생성 비유 그림 · 할당·사용·해제의 책임을 책의 대출과 반납에 빗댄 그림입니다. 메모리 수명은 아래 순서로 확인합니다.

02 원리를 시각적으로 보기

1 길이를 확인한 뒤 공간을 받습니다

필요량 확인3바이트
malloc 결과 검사NULL인가?
성공했을 때사용 가능한 공간

할당에 실패하면 사용할 공간이 없습니다. 성공한 경우에만 데이터를 기록합니다.

2 사용하는 동안 공간을 유지합니다

소유자처리 함수
할당된 버퍼41 · 00 · 42
다음 할 일출력 / 처리

이 예제는 처리 함수가 버퍼를 소유합니다. 버퍼의 데이터를 출력하거나 처리하는 작업을 모두 마친 뒤 해제해야 합니다.

3 마지막 사용 뒤 정확히 한 번 해제합니다

처리 완료마지막 사용
free공간 반납
이후 규칙재접근 금지

같은 공간을 가리키는 포인터가 여러 개여도 해제는 한 번입니다. 한 변수에 NULL을 넣어도 다른 참조까지 자동으로 바뀌지는 않습니다.

보충 도식 · 메모리 할당·사용·해제. 인쇄 시 모든 단계를 표시합니다.

03 세부 설명과 코드

메모리 수명은 선언 위치와 할당 방식에 따라 달라집니다. 자동 저장 기간과 동적 할당을 구분하고, 정상·오류 경로 모두에서 메모리를 정리해야 합니다.

버퍼는 데이터를 사용하는 동안 살아 있어야 합니다. 고정 배열로 충분한지, 함수가 끝난 뒤에도 보관할지에 따라 저장 방법과 소유권을 정합니다.

자동 저장 기간

함수 안의 일반 지역 배열은 선언된 블록을 실행하는 동안에만 유효합니다. 그 주소를 반환하더라도 함수가 끝난 뒤에는 배열에 접근하면 안 됩니다. 동기적인 처리에 쓸 작은 버퍼에 적합합니다.

동적 할당

malloc()으로 얻은 저장 공간은 free()할 때까지 관리해야 합니다. 함수 경계를 넘어 전달할 때는 누가 해제할지 약속합니다.

  1. 입력 길이가 최소·최대 범위 안인지 확인합니다. 원소 수 × 원소 크기를 계산할 때도 정수 타입의 표현 범위를 넘지 않아야 합니다.
  2. 할당 결과가 NULL인지 확인합니다. 초기화되지 않은 영역을 읽지 않습니다.
  3. 유효한 길이 안에서 처리합니다. 바이너리와 NUL 종료 문자열을 구분합니다.
  4. 성공·오류 경로 모두에서 소유한 자원을 정확히 한 번 해제합니다.
Ubuntu · C17 / buffer_basics.c의 process_message() 전체 함수
static bool process_message(const unsigned char *data, size_t size)
{
    MessageHeader header;
    if (!decode_header(data, size, &header)) {
        puts("Rejected: incomplete header");
        return false;
    }

    /* 길이 검증을 먼저 하므로 size - 3이 음수처럼 언더플로하지 않는다.
     * 이 예제는 정확히 한 메시지가 들어 있는 배열을 처리한다. */
    if (header.length == 0 || header.length > MAX_PAYLOAD ||
        (size_t)header.length != size - 3) {
        puts("Rejected: invalid payload length");
        return false;
    }

    /* 할당 책임과 해제 책임을 이 함수가 함께 갖는다. */
    unsigned char *payload = malloc(header.length);
    if (payload == NULL) {
        fputs("Allocation failed\n", stderr);
        return false;
    }

    memcpy(payload, data + 3, header.length);
    printf("flags=0x%02X, length=%u\n",
           (unsigned int)header.flags, (unsigned int)header.length);
    printf("payload:");
    for (size_t i = 0; i < header.length; i++) {
        printf(" %02X", (unsigned int)payload[i]);
    }
    putchar('\n');

    /* 본문에 NUL이 있어도 길이로 처리한다. 문자열 함수는 쓰지 않는다. */
    free(payload);
    return true;
}

원본의 MAX_PACKET_SIZE 1500은 해당 학습 예제의 제한입니다. TCP 응용 메시지 전체의 최대 길이가 1,500바이트라는 뜻은 아닙니다.

이 장의 예제는 본문을 1,024바이트로 제한합니다. 실제 서비스는 개별 메시지뿐 아니라 연결 수와 대기 데이터의 총량도 제한해야 합니다.

힙·스택 개념도의 범위

스택이 위에서 내려오고 힙이 아래에서 올라오는 그림은 원리를 설명하는 개념도입니다. 실제 프로그램의 주소 공간에는 여러 영역이 있고, 스택의 방향과 메모리 할당 방식도 환경에 따라 다릅니다.

메모리가 작은 조각으로 나뉘는 단편화는 할당에 영향을 줄 수 있습니다. 다만 그림의 두 영역이 만난다고 PC 전체가 반드시 멈춘다는 뜻은 아닙니다.

메모리 풀은 준비한 공간을 재사용하는 방법입니다. 이를 사용해도 데이터의 사용 기간과 여러 스레드의 동시 접근은 관리해야 합니다.

11 / C17 LAB원본 기반 보충 실습 · Ubuntu

C17 예제
빌드·실행

오류 처리와 경계 조건을 포함한 보충 예제 6개입니다. 실제 소켓을 열지 않으므로 서버·클라이언트 터미널이나 포트 설정은 필요하지 않습니다.

1. 소스 파일을 Ubuntu 작업 폴더로 복사하기

프로젝트가 현재 Windows 경로에 있을 때 다음 명령을 Ubuntu에서 실행합니다. 위치를 옮겼다면 cp의 원본 경로를 바꾸세요. GCC 준비가 필요하면 기존 설치 가이드를 먼저 확인합니다. 강의 폴더를 단독으로 옮긴 경우 설치 가이드는 원래 프로젝트 위치에서 여세요.

Ubuntu · Bash / 소스 복사 및 작업 위치 이동
mkdir -p ~/projects/network-ch02
cp /mnt/c/Users/USER/Desktop/Network_Programing/Lecture_HTML/2장/examples/*.c ~/projects/network-ch02/
cd ~/projects/network-ch02

2. C17과 경고 옵션으로 각각 빌드하기

Ubuntu · Bash / ~/projects/network-ch02
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 buffer_basics.c -o buffer_basics
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 client_list.c -o client_list
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 stack.c -o stack
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 linear_queue.c -o linear_queue
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 ring_queue.c -o ring_queue
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 port_table.c -o port_table

성공하면 보통 출력 없이 실행 파일 6개가 생깁니다. 오류가 나오면 실행하기 전에 파일명과 현재 디렉토리를 확인하세요. 컴파일과 실행에 sudo는 필요하지 않습니다.

3. 경계 조건까지 실행하기

Ubuntu · Bash / ~/projects/network-ch02
./buffer_basics
./client_list
./stack
./linear_queue
./ring_queue
./port_table

각 프로그램은 결과를 출력한 뒤 자동 종료합니다. 필요하면 실행 중인 터미널에서 Ctrl+C로 중단할 수 있습니다.

파일관찰할 결과연결 주제
buffer_basics.c41 00 42의 NUL 보존, 짧은 입력·과도한 길이 거부배열·구조체·포인터·비트·메모리
client_list.c7 → 4, 4 삭제, 없는 99 검색, 전체 해제연결 리스트
stack.cFULL 이후 -1 → 20 → 10, EMPTYLIFO, 실패와 값 분리
linear_queue.c앞의 3칸을 비워도 1006 삽입은 FULLFIFO, 가짜 포화
ring_queue.cfront=2, rear=1에서 30 → 40 → 50 → 60순환 재사용
port_table.c80·8080 충돌, 8080 값 갱신, 삭제 후 80 유지체이닝과 자원 정리

대표 예상 출력

buffer_basics 예상 출력 펼치기
예상 출력 · buffer_basics
flags=0x12, length=3
payload: 41 00 42
Rejected: incomplete header
Rejected: invalid payload length
Rejected: invalid payload length
SYN=1, ACK=1
after set FIN / clear SYN: 0x11
IPv4 version=4, header bytes=20
ring_queue 예상 출력 펼치기
예상 출력 · ring_queue
enqueue 10: OK
enqueue 20: OK
enqueue 30: OK
enqueue 40: OK
enqueue 50: FULL
front=0, rear=4 | 10 20 30 40
dequeue: 10
dequeue: 20
enqueue 50: OK
enqueue 60: OK
front=2, rear=1 | 30 40 50 60
dequeue: 30
dequeue: 40
dequeue: 50
dequeue: 60
dequeue again: EMPTY
port_table 예상 출력 펼치기
예상 출력 · port_table
bucket[80] -> 8080:11 -> 80:10 -> NULL
search 8080: 11
updated 8080: 20
remove 8080: OK
bucket[80] -> 80:10 -> NULL
search 8080: missing
search 443: found

다운로드와 전체 코드

각 파일은 독립 실행형이며, 본문의 전체 코드와 다운로드 파일은 같습니다. 함수 이름이 중복되므로 여섯 소스는 각각 빌드합니다.

전체 코드 펼치기 · buffer_basics.c
Ubuntu · C17 / buffer_basics.c
/* C17 보충 실습: 바이트 해석, 구조체, 비트 연산, 메모리 수명. */
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_PAYLOAD 1024U

typedef struct {
    uint8_t flags;
    uint16_t length;
} MessageHeader;

/* 학습용 형식: [flags 1바이트][길이 2바이트, big-endian][본문].
 * 실제 TCP/IP 헤더가 아니다. 구조체로 캐스팅하지 않고 값을 조립한다. */
static bool decode_header(const unsigned char *data, size_t size,
                          MessageHeader *out)
{
    if (data == NULL || out == NULL || size < 3) {
        return false;
    }

    out->flags = data[0];
    out->length = (uint16_t)(((unsigned int)data[1] << 8) | data[2]);
    return true;
}

static bool process_message(const unsigned char *data, size_t size)
{
    MessageHeader header;
    if (!decode_header(data, size, &header)) {
        puts("Rejected: incomplete header");
        return false;
    }

    /* 길이 검증을 먼저 하므로 size - 3이 음수처럼 언더플로하지 않는다.
     * 이 예제는 정확히 한 메시지가 들어 있는 배열을 처리한다. */
    if (header.length == 0 || header.length > MAX_PAYLOAD ||
        (size_t)header.length != size - 3) {
        puts("Rejected: invalid payload length");
        return false;
    }

    /* 할당 책임과 해제 책임을 이 함수가 함께 갖는다. */
    unsigned char *payload = malloc(header.length);
    if (payload == NULL) {
        fputs("Allocation failed\n", stderr);
        return false;
    }

    memcpy(payload, data + 3, header.length);
    printf("flags=0x%02X, length=%u\n",
           (unsigned int)header.flags, (unsigned int)header.length);
    printf("payload:");
    for (size_t i = 0; i < header.length; i++) {
        printf(" %02X", (unsigned int)payload[i]);
    }
    putchar('\n');

    /* 본문에 NUL이 있어도 길이로 처리한다. 문자열 함수는 쓰지 않는다. */
    free(payload);
    return true;
}

int main(void)
{
    const unsigned char message[] = {0x12, 0x00, 0x03, 0x41, 0x00, 0x42};
    if (!process_message(message, sizeof message)) {
        return EXIT_FAILURE;
    }

    /* 버퍼가 짧거나 헤더가 과도한 길이를 요구하면 거부하는지 확인한다. */
    const unsigned char too_large[] = {0x12, 0x04, 0x01};
    if (process_message(message, 2) ||
        process_message(message, sizeof message - 1) ||
        process_message(too_large, sizeof too_large)) {
        return EXIT_FAILURE;
    }

    /* TCP의 SYN/ACK 마스크를 수치로 관찰하는 별도 예시다.
     * 앞의 학습용 메시지나 실제 소켓 상태를 변경하지 않는다. */
    unsigned int flags = 0x12U;
    printf("SYN=%d, ACK=%d\n", (flags & 0x02U) != 0U,
           (flags & 0x10U) != 0U);
    flags |= 0x01U;
    flags &= ~0x02U;
    printf("after set FIN / clear SYN: 0x%02X\n", flags);

    const unsigned int version_ihl = 0x45U;
    printf("IPv4 version=%u, header bytes=%u\n",
           version_ihl >> 4, (version_ihl & 0x0FU) * 4U);
    return EXIT_SUCCESS;
}
전체 코드 펼치기 · client_list.c
Ubuntu · C17 / client_list.c
/* C17 보충 실습: 실제 소켓 대신 가상의 fd 숫자를 저장한다. */
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

typedef struct ClientNode {
    int socket_fd;
    struct ClientNode *next;
} ClientNode;

static bool add_client(ClientNode **head, int fd)
{
    ClientNode *node = malloc(sizeof *node);
    if (node == NULL) {
        return false;
    }

    node->socket_fd = fd;
    node->next = *head;
    *head = node;
    return true;
}

static bool remove_client(ClientNode **head, int fd)
{
    /* link는 현재 노드를 가리키는 연결 자체의 주소다.
     * 첫 노드에서는 head, 이후에는 앞 노드의 next를 가리킨다. */
    ClientNode **link = head;
    while (*link != NULL) {
        ClientNode *node = *link;
        if (node->socket_fd == fd) {
            *link = node->next;
            free(node);
            return true;
        }
        link = &node->next;
    }
    return false;
}

static void clear_clients(ClientNode **head)
{
    while (*head != NULL) {
        ClientNode *node = *head;
        *head = node->next;
        free(node);
    }
}

static void print_clients(const ClientNode *head)
{
    printf("head");
    for (const ClientNode *node = head; node != NULL; node = node->next) {
        printf(" -> %d", node->socket_fd);
    }
    puts(" -> NULL");
}

int main(void)
{
    ClientNode *head = NULL;
    if (!add_client(&head, 4) || !add_client(&head, 7)) {
        fputs("Allocation failed\n", stderr);
        clear_clients(&head);
        return EXIT_FAILURE;
    }

    print_clients(head);
    printf("remove 4: %s\n", remove_client(&head, 4) ? "OK" : "missing");
    print_clients(head);
    printf("remove 99: %s\n", remove_client(&head, 99) ? "OK" : "missing");

    clear_clients(&head);
    print_clients(head);
    return EXIT_SUCCESS;
}
전체 코드 펼치기 · stack.c
Ubuntu · C17 / stack.c
/* C17 보충 실습: 성공 여부와 데이터 값을 분리한 LIFO 스택. */
#include <stdbool.h>
#include <stdio.h>

#define CAPACITY 3

typedef struct {
    int values[CAPACITY];
    size_t count;
} Stack;

static bool push(Stack *stack, int value)
{
    if (stack->count == CAPACITY) {
        return false;
    }

    stack->values[stack->count] = value;
    stack->count++;
    return true;
}

static bool pop(Stack *stack, int *out)
{
    if (stack->count == 0) {
        return false;
    }

    stack->count--;
    *out = stack->values[stack->count];
    return true;
}

int main(void)
{
    Stack stack = {0};
    const int values[] = {10, 20, -1, 40};
    for (size_t i = 0; i < sizeof values / sizeof values[0]; i++) {
        printf("push %d: %s\n", values[i],
               push(&stack, values[i]) ? "OK" : "FULL");
    }

    /* -1도 정상 데이터다. 빈 스택은 false 반환으로 구분한다. */
    int value;
    while (pop(&stack, &value)) {
        printf("pop: %d\n", value);
    }
    printf("pop again: %s\n", pop(&stack, &value) ? "OK" : "EMPTY");
    return 0;
}
전체 코드 펼치기 · linear_queue.c
Ubuntu · C17 / linear_queue.c
/* C17 보충 실습: 앞의 빈 공간을 재사용하지 않는 선형 큐의 한계. */
#include <stdbool.h>
#include <stdio.h>

#define CAPACITY 5

typedef struct {
    int values[CAPACITY];
    size_t front;
    size_t rear;
} Queue;

static bool enqueue(Queue *queue, int value)
{
    if (queue->rear == CAPACITY) {
        return false;
    }

    queue->values[queue->rear] = value;
    queue->rear++;
    return true;
}

static bool dequeue(Queue *queue, int *out)
{
    if (queue->front == queue->rear) {
        return false;
    }

    *out = queue->values[queue->front];
    queue->front++;
    return true;
}

int main(void)
{
    Queue queue = {0};
    for (int value = 1001; value <= 1005; value++) {
        printf("enqueue %d: %s\n", value,
               enqueue(&queue, value) ? "OK" : "FULL");
    }

    int value;
    for (int i = 0; i < 3; i++) {
        if (dequeue(&queue, &value)) {
            printf("dequeue: %d\n", value);
        }
    }

    /* 앞의 세 칸이 비었어도 rear == CAPACITY이므로 삽입이 실패한다. */
    printf("front=%zu, rear=%zu, waiting=%zu\n",
           queue.front, queue.rear, queue.rear - queue.front);
    printf("enqueue 1006: %s\n", enqueue(&queue, 1006) ? "OK" : "FULL");
    while (dequeue(&queue, &value)) {
        printf("dequeue: %d\n", value);
    }
    printf("dequeue again: %s\n", dequeue(&queue, &value) ? "OK" : "EMPTY");
    return 0;
}
전체 코드 펼치기 · ring_queue.c
Ubuntu · C17 / ring_queue.c
/* C17 보충 실습: 5칸 중 한 칸을 비워 최대 4개를 저장한다. */
#include <stdbool.h>
#include <stdio.h>

#define SIZE 5

typedef struct {
    int values[SIZE];
    size_t front;
    size_t rear;
} RingQueue;

static bool enqueue(RingQueue *queue, int value)
{
    size_t next = (queue->rear + 1) % SIZE;
    if (next == queue->front) {
        return false;
    }

    queue->values[queue->rear] = value;
    queue->rear = next;
    return true;
}

static bool dequeue(RingQueue *queue, int *out)
{
    if (queue->front == queue->rear) {
        return false;
    }

    *out = queue->values[queue->front];
    queue->front = (queue->front + 1) % SIZE;
    return true;
}

static void print_queue(const RingQueue *queue)
{
    printf("front=%zu, rear=%zu |", queue->front, queue->rear);
    for (size_t i = queue->front; i != queue->rear; i = (i + 1) % SIZE) {
        printf(" %d", queue->values[i]);
    }
    putchar('\n');
}

int main(void)
{
    RingQueue queue = {0};
    for (int value = 10; value <= 50; value += 10) {
        printf("enqueue %d: %s\n", value,
               enqueue(&queue, value) ? "OK" : "FULL");
    }
    print_queue(&queue);

    int value;
    for (int i = 0; i < 2; i++) {
        if (dequeue(&queue, &value)) {
            printf("dequeue: %d\n", value);
        }
    }

    /* 50은 인덱스 4, 60은 인덱스 0에 저장된다. */
    printf("enqueue 50: %s\n", enqueue(&queue, 50) ? "OK" : "FULL");
    printf("enqueue 60: %s\n", enqueue(&queue, 60) ? "OK" : "FULL");
    print_queue(&queue);

    while (dequeue(&queue, &value)) {
        printf("dequeue: %d\n", value);
    }
    printf("dequeue again: %s\n", dequeue(&queue, &value) ? "OK" : "EMPTY");
    return 0;
}
전체 코드 펼치기 · port_table.c
Ubuntu · C17 / port_table.c
/* C17 보충 실습: 원본의 포트 기반 체이닝 해시 테이블.
 * fd는 가상 숫자다. 실제 연결 식별에는 주소 등 추가 정보가 필요하다. */
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

#define TABLE_SIZE 100

typedef struct Bucket {
    uint16_t port;
    int socket_fd;
    struct Bucket *next;
} Bucket;

static Bucket *table[TABLE_SIZE] = {0};

static size_t hash(uint16_t port)
{
    return port % TABLE_SIZE;
}

static bool insert(uint16_t port, int fd)
{
    size_t index = hash(port);
    for (Bucket *node = table[index]; node != NULL; node = node->next) {
        if (node->port == port) {
            node->socket_fd = fd;
            return true;
        }
    }

    Bucket *node = malloc(sizeof *node);
    if (node == NULL) {
        return false;
    }

    node->port = port;
    node->socket_fd = fd;
    node->next = table[index];
    table[index] = node;
    return true;
}

static bool search(uint16_t port, int *out)
{
    for (const Bucket *node = table[hash(port)]; node != NULL; node = node->next) {
        /* 해시 인덱스가 같아도 원래 키가 같은지 다시 비교한다. */
        if (node->port == port) {
            *out = node->socket_fd;
            return true;
        }
    }
    return false;
}

static bool remove_port(uint16_t port)
{
    Bucket **link = &table[hash(port)];
    while (*link != NULL) {
        Bucket *node = *link;
        if (node->port == port) {
            *link = node->next;
            free(node);
            return true;
        }
        link = &node->next;
    }
    return false;
}

static void free_table(void)
{
    for (size_t i = 0; i < TABLE_SIZE; i++) {
        while (table[i] != NULL) {
            Bucket *node = table[i];
            table[i] = node->next;
            free(node);
        }
    }
}

static void print_bucket(uint16_t port)
{
    printf("bucket[%zu]", hash(port));
    for (const Bucket *node = table[hash(port)]; node != NULL; node = node->next) {
        printf(" -> %u:%d", (unsigned int)node->port, node->socket_fd);
    }
    puts(" -> NULL");
}

int main(void)
{
    /* 80과 8080은 같은 버킷 80에 들어간다. */
    if (!insert(80, 10) || !insert(8080, 11) || !insert(443, 12)) {
        fputs("Allocation failed\n", stderr);
        free_table();
        return EXIT_FAILURE;
    }
    print_bucket(80);

    int fd;
    if (search(8080, &fd)) {
        printf("search 8080: %d\n", fd);
    }
    if (!insert(8080, 20)) {
        free_table();
        return EXIT_FAILURE;
    }
    if (search(8080, &fd)) {
        printf("updated 8080: %d\n", fd);
    }

    printf("remove 8080: %s\n", remove_port(8080) ? "OK" : "missing");
    print_bucket(80);
    printf("search 8080: %s\n", search(8080, &fd) ? "found" : "missing");
    printf("search 443: %s\n", search(443, &fd) ? "found" : "missing");

    free_table();
    return EXIT_SUCCESS;
}
12 / REVIEW

이해도 체크:
코드 결과 예측하기

1,024바이트 배열에 recv()가 37을 반환했다면 어디까지 읽을까요?

0번부터 36번까지, 총 37바이트입니다. 배열 자체에 적용한 sizeof는 전체 용량을 알려 줍니다. 이번에 받은 길이는 recv()의 반환값으로 확인합니다.

구조체를 packed로 만들면 바로 전송해도 될까요?

패딩 외에도 엔디언·필드 형식·정렬·객체 접근 규칙을 확인해야 합니다. 학습용 코드에서는 규격대로 바이트를 조립해 직렬화합니다.

포인터가 NULL이 아니면 안전한가요?

아닙니다. 객체 수명, 범위, 타입, 정렬을 확인해야 합니다. 유효하지 않은 포인터도 NULL이 아닐 수 있습니다.

새 노드를 head에 넣을 때 어떤 대입이 먼저일까요?

새 노드의 next가 기존 head를 가리키게 한 뒤 head를 새 노드로 바꿉니다. 기존 목록으로 가는 연결을 보존해야 합니다.

-1이 정상 데이터라면 빈 스택은 어떻게 알까요?

성공 여부는 bool 반환값으로, 데이터는 out 포인터로 전달합니다. 값과 상태를 분리하면 -1도 저장할 수 있습니다.

선형 큐에서 front=3, rear=5, 용량 5이면 왜 삽입이 실패할까요?

이 구현은 rear를 뒤로만 이동하므로 다음 삽입 위치가 배열 밖입니다. 앞의 빈 세 칸을 재사용하는 규칙이 없습니다.

5칸 원형 큐의 front=2, rear=4에서 E를 넣으면 어떻게 될까요?

E를 index 4에 저장하고 rear를 0으로 바꿉니다. 곧바로 index 0에 저장하는 것이 아닙니다.

80과 8080이 같은 버킷에 있으면 같은 키일까요?

아닙니다. 버킷 내부의 체인을 탐색하며 원래 port 값을 비교해야 합니다.

0x12에서 SYN을 끄고 ACK를 유지하면 어떤 값일까요?

0x12 & ~0x02 = 0x10입니다. SYN은 bit 1, ACK는 bit 4입니다.

free(p); p = NULL; 뒤에 같은 주소를 가진 q도 안전할까요?

아닙니다. q도 해제된 영역을 가리키므로 더 이상 그 메모리에 접근하는 데 사용할 수 없습니다. p에 NULL을 대입해도 q까지 바뀌지는 않습니다.

13 / ORIGINAL & REFERENCES원본 PDF 1–22쪽 전체

학습 범위와 참고 자료

배열부터 메모리 관리까지 원본의 10개 주제를 다룹니다. “보충·보완” 표시가 있는 설명과 실행 예제는 원본에 추가한 학습 자료입니다.

슬라이드 인쇄 번호는 01/60부터 58/60까지 불연속적이며 일부 실습 슬라이드의 35/60은 반복됩니다. PDF 13쪽의 중복 함수·빈칸과 16·18·20·22쪽의 빈칸은 보충 실행 예제에서 완성된 형태로 볼 수 있습니다.

주제별 PDF 쪽

주제PDF 쪽본문
1. 배열1–3배열 해설로 이동
2. 구조체3–5구조체 해설로 이동
3. 포인터5–7포인터 해설로 이동
4. 연결 리스트7–9연결 리스트 해설로 이동
5. 스택9–11스택 해설로 이동
6. 큐11–13큐 해설로 이동
7. 원형 큐14–16원형 큐 해설로 이동
8. 해시 테이블16–18해시 테이블 해설로 이동
9. 비트 연산19–20비트 연산 해설로 이동
10. 메모리 관리21–22메모리 관리 해설로 이동

보충 설명의 공식 참고 자료