From 2daaac5ae28044db5183bf43d98e6d5981f77e29 Mon Sep 17 00:00:00 2001 From: Jinwoo Sung Date: Sat, 8 Aug 2026 15:06:44 +0900 Subject: [PATCH] refactor: rename unilink to wirestead and fix content issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트명 변경(unilink → wirestead)과 함께 블로그 24편 검토에서 발견한 문제를 정리한다. ## 리네임 - 블로그 14편 + 프로젝트 1건 파일명 변경 (git mv로 이력 보존) - 본문의 네임스페이스, 헤더명, 로그 매크로, CMake 파일명, GitHub URL 반영 - taxonomy.json, admin/config.yml, README, docs의 project/series ID 갱신 - 파일명이 슬러그이므로 구 URL 14개와 /projects/unilink 리다이렉트 추가 ## 게시 사고 - memory/buffer: 본문 전체가 2회 중복 게시된 것 제거(1103행 → 559행), 중간에 삽입된 H1과 내부 중복 다이어그램 정리 - encoder/decoder: 제목과 무관하게 Decoding 본문이 실려 있던 문제 해결. 완성도가 높은 판본을 Decoding 글로 이관하고, 비게 된 자리에 Encoder-only/Decoder-only/Encoder-Decoder 글을 새로 작성 ## 기술적 정확성 - Transformer: softmax 이후 값을 원시 점수로 제시하던 행렬을 QK^T → 스케일링 → softmax → 가중합이 이어지는 값으로 재구성 - Transformer: Post-LN 도식을 Pre-LN으로 수정하고 두 방식 비교 추가 - Tokenizer: padding 예시의 토큰 수 불일치 수정, RoPE 설명 추가, byte-level BPE 조건 명시 - Logit/Softmax: 표기 확률을 실제 계산값으로 교정, token id 충돌 해소 - Pretraining: 각 위치에서 이전 문맥 전체를 본다는 점 명확화 - ChannelFactory: 누락된 config 분기 3개 추가, static_assert로 분기 누락 차단 - TcpClient가 wrapper/transport 양쪽에서 쓰이던 충돌을 TcpClientTransport로 분리하고 명명 규칙 문서화 ## 렌더링 - 볼드 뒤에 한글 조사가 붙어 ** 가 닫히지 않던 4곳 수정 - mermaid 라벨의 \n을
로 교체 - 라이트 테마에서 판독 불가하던 rect rgb 하드코딩 제거 - C++ 예제의 코드펜스 언어를 c에서 cpp로 수정 ## 구성/메타데이터 - llm-core, wirestead-design 시리즈 순서를 선행 지식에 맞게 재배열 - 본문 내부 링크 추가 (기존 0건) - ai-curator 설계 글의 배너를 본문 내용과 일치시키고 문체 통일 - description 어투 통일, 태그 3~5개로 정리, 무의미한 updatedAt 제거 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HdGDMdmkV56Pkv5G6UftJ6 --- README.md | 8 +- astro.config.mjs | 30 + docs/cms-writing-guide.md | 4 +- public/admin/config.yml | 4 +- src/content/about.md | 2 +- src/content/blog/2026-04-18-hello.md | 5 +- ...4\261-\354\230\210\354\213\234-sveltia.md" | 1 + .../blog/2026-04-21-stl-priority-queue.md | 31 +- ...4\354\266\225-\353\260\251\354\225\210.md" | 44 +- ...4\354\240\234-\352\265\254\355\230\204.md" | 6 +- ...d-builder-api-\354\204\244\352\263\204.md" | 76 +- ...d-unified-api-\354\204\244\352\263\204.md" | 122 +- ...4\352\263\204-\353\260\260\352\262\275.md" | 64 +- ...1\355\231\224-\354\204\244\352\263\204.md" | 22 +- ...hannelfactory-\354\204\244\352\263\204.md" | 41 +- ...4\354\270\265-\354\204\244\352\263\204.md" | 34 +- ...4\354\270\265-\354\204\244\352\263\204.md" | 31 +- ...memory-buffer-\354\204\244\352\263\204.md" | 1103 ----------------- ...-backpressure-\354\204\244\352\263\204.md" | 28 +- ...4\354\270\265-\354\204\244\352\263\204.md" | 37 +- ...memory-buffer-\354\204\244\352\263\204.md" | 545 ++++++++ ...0-diagnostics-\354\204\244\352\263\204.md" | 22 +- ...4\352\263\204-\355\232\214\352\263\240.md" | 62 +- ...64\355\225\264\355\225\230\352\270\260.md" | 453 +++---- ...64\355\225\264\355\225\230\352\270\260.md" | 123 +- ...54\353\270\224\354\212\210\355\214\205.md" | 42 +- ...54\353\270\224\354\212\210\355\214\205.md" | 35 +- .../blog/2026-07-03-llm-core-decoding.md | 644 +++------- ...-03-llm-core-logit\352\263\274-softmax.md" | 30 +- ...m-core-tokenizer\354\231\200-embedding.md" | 74 +- ...4-\353\220\230\353\212\224\352\260\200.md" | 29 +- ...ng-pretraining\352\263\274-fine-tuning.md" | 31 +- src/content/now.md | 4 +- src/content/projects/ai-curator.md | 2 +- .../projects/{unilink.md => wirestead.md} | 10 +- src/data/taxonomy.json | 8 +- 36 files changed, 1495 insertions(+), 2312 deletions(-) rename "src/content/blog/2026-05-31-unilink-builder-api-\354\204\244\352\263\204.md" => "src/content/blog/2026-05-31-wirestead-builder-api-\354\204\244\352\263\204.md" (88%) rename "src/content/blog/2026-05-31-unilink-unified-api-\354\204\244\352\263\204.md" => "src/content/blog/2026-05-31-wirestead-unified-api-\354\204\244\352\263\204.md" (74%) rename "src/content/blog/2026-05-31-unilink-\354\204\244\352\263\204-\353\260\260\352\262\275.md" => "src/content/blog/2026-05-31-wirestead-\354\204\244\352\263\204-\353\260\260\352\262\275.md" (75%) rename "src/content/blog/2026-06-02-unilink-channel-\354\266\224\354\203\201\355\231\224-\354\204\244\352\263\204.md" => "src/content/blog/2026-06-02-wirestead-channel-\354\266\224\354\203\201\355\231\224-\354\204\244\352\263\204.md" (95%) rename "src/content/blog/2026-06-02-unilink-channelfactory-\354\204\244\352\263\204.md" => "src/content/blog/2026-06-02-wirestead-channelfactory-\354\204\244\352\263\204.md" (87%) rename "src/content/blog/2026-06-02-unilink-transport-\352\263\204\354\270\265-\354\204\244\352\263\204.md" => "src/content/blog/2026-06-02-wirestead-transport-\352\263\204\354\270\265-\354\204\244\352\263\204.md" (95%) rename "src/content/blog/2026-06-02-unilink-wrapper-\352\263\204\354\270\265-\354\204\244\352\263\204.md" => "src/content/blog/2026-06-02-wirestead-wrapper-\352\263\204\354\270\265-\354\204\244\352\263\204.md" (93%) delete mode 100644 "src/content/blog/2026-06-03-unilink-memory-buffer-\354\204\244\352\263\204.md" rename "src/content/blog/2026-06-03-unilink-backpressure-\354\204\244\352\263\204.md" => "src/content/blog/2026-06-03-wirestead-backpressure-\354\204\244\352\263\204.md" (92%) rename "src/content/blog/2026-06-03-unilink-framer-\352\263\204\354\270\265-\354\204\244\352\263\204.md" => "src/content/blog/2026-06-03-wirestead-framer-\352\263\204\354\270\265-\354\204\244\352\263\204.md" (95%) create mode 100644 "src/content/blog/2026-06-03-wirestead-memory-buffer-\354\204\244\352\263\204.md" rename "src/content/blog/2026-06-03-unilink-runtimestats\354\231\200-diagnostics-\354\204\244\352\263\204.md" => "src/content/blog/2026-06-03-wirestead-runtimestats\354\231\200-diagnostics-\354\204\244\352\263\204.md" (96%) rename "src/content/blog/2026-06-03-unilink-\354\204\244\352\263\204-\355\232\214\352\263\240.md" => "src/content/blog/2026-06-03-wirestead-\354\204\244\352\263\204-\355\232\214\352\263\240.md" (87%) rename "src/content/blog/2026-07-02-unilink-tcp-nodelay-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" => "src/content/blog/2026-07-02-wirestead-tcp-nodelay-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" (80%) rename "src/content/blog/2026-07-02-unilink-udp-backpressure-\353\215\260\353\223\234\353\235\275-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" => "src/content/blog/2026-07-02-wirestead-udp-backpressure-\353\215\260\353\223\234\353\235\275-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" (85%) rename src/content/projects/{unilink.md => wirestead.md} (62%) diff --git a/README.md b/README.md index 9b3b3bf..a4be105 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,11 @@ src/content/ title: 제목 date: 2026-04-18 updatedAt: 2026-04-20 # 실질적으로 내용을 수정한 경우만 (선택) -project: unilink # unilink | ai-curator | site (선택) +project: wirestead # wirestead | ai-curator | site (선택) kind: design # design | implementation | retrospective | study | note | devlog tags: [ros2, c++] description: 한 줄 요약 -series: unilink-design # 연속 글일 때만 (선택) +series: wirestead-design # 연속 글일 때만 (선택) seriesOrder: 1 # series가 있으면 필수 draft: false --- @@ -62,7 +62,7 @@ draft: false `series`가 있는 글은 `project` 유무와 무관하게 `/blog` 인덱스의 "Series" 섹션에 카드로 묶여서 노출된다. -- `series`에 연결된 `project`가 있으면(`unilink-design` → `unilink` 등) 카드는 해당 프로젝트 허브(`/projects/[slug]`)로 링크되고, 그 페이지의 "Series" 섹션에서 순서대로 볼 수 있다. +- `series`에 연결된 `project`가 있으면(`wirestead-design` → `wirestead` 등) 카드는 해당 프로젝트 허브(`/projects/[slug]`)로 링크되고, 그 페이지의 "Series" 섹션에서 순서대로 볼 수 있다. - `project`가 없는 시리즈(예: `llm-core`, `llm-training`)는 전용 허브 페이지 `/blog/series/[series]`로 링크된다. 새 시리즈를 추가하려면 `src/data/taxonomy.json`의 `series` 배열에 항목을 추가하고 `config.yml`의 Series select 옵션도 함께 갱신한다 (다르면 `content:check`가 실패한다). @@ -114,4 +114,4 @@ Node.js 22 이상 필요. 이 레포가 블로그까지 통합 운영. 나머지 서브패스는 별도 레포: - `/ai-curator/` — AI 큐레이션 ([jwsung91/ai-curator](https://github.com/jwsung91/ai-curator), Astro) -- `/unilink/` — 라이브러리 문서 ([jwsung91/unilink](https://github.com/jwsung91/unilink), Doxygen) +- `/wirestead/` — 라이브러리 문서 ([jwsung91/wirestead](https://github.com/jwsung91/wirestead), Doxygen) diff --git a/astro.config.mjs b/astro.config.mjs index 1eeff0e..30bacf8 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -56,9 +56,39 @@ function remarkMermaid() { }; } +// unilink -> wirestead 리네임으로 바뀐 구 URL을 유지하기 위한 리다이렉트. +// 정적 빌드에서는 meta refresh 페이지로 생성된다. +const renamedSlugs = [ + '2026-05-31-unilink-builder-api-설계', + '2026-05-31-unilink-unified-api-설계', + '2026-05-31-unilink-설계-배경', + '2026-06-02-unilink-channel-추상화-설계', + '2026-06-02-unilink-channelfactory-설계', + '2026-06-02-unilink-transport-계층-설계', + '2026-06-02-unilink-wrapper-계층-설계', + '2026-06-03-unilink-backpressure-설계', + '2026-06-03-unilink-framer-계층-설계', + '2026-06-03-unilink-memory-buffer-설계', + '2026-06-03-unilink-runtimestats와-diagnostics-설계', + '2026-06-03-unilink-설계-회고', + '2026-07-02-unilink-tcp-nodelay-트러블슈팅', + '2026-07-02-unilink-udp-backpressure-데드락-트러블슈팅', +]; + +const legacyRedirects = { + '/projects/unilink': '/projects/wirestead', + ...Object.fromEntries( + renamedSlugs.map((slug) => [ + `/blog/${slug}`, + `/blog/${slug.replace('unilink', 'wirestead')}`, + ]), + ), +}; + export default defineConfig({ site: 'https://jwsung91.github.io', integrations: [sitemap()], + redirects: legacyRedirects, markdown: { processor: unified({ remarkPlugins: [remarkMath, remarkMermaid], diff --git a/docs/cms-writing-guide.md b/docs/cms-writing-guide.md index af23cfe..c78a6b2 100644 --- a/docs/cms-writing-guide.md +++ b/docs/cms-writing-guide.md @@ -17,7 +17,7 @@ 프로젝트와 직접 관련된 글이면 선택합니다. -- `unilink`: unilink 설계/구현/릴리즈 기록 +- `wirestead`: wirestead 설계/구현/릴리즈 기록 - `ai-curator`: AI Curator 파이프라인/운영 기록 - `site`: 이 블로그, Sveltia CMS, Astro 운영 기록 @@ -60,7 +60,7 @@ Reliable / BestEffort 채널에서 send(), queue pressure, drop 정책, backpres 연속 글이라면 아래 필드를 함께 입력한다. ```yaml -series: unilink-design +series: wirestead-design seriesOrder: 1 ``` diff --git a/public/admin/config.yml b/public/admin/config.yml index 80f54b2..e0f1b20 100644 --- a/public/admin/config.yml +++ b/public/admin/config.yml @@ -80,7 +80,7 @@ collections: required: false hint: "프로젝트와 직접 관련 없는 개인 공부 글이면 비워둡니다." options: - - { label: "unilink", value: "unilink" } + - { label: "wirestead", value: "wirestead" } - { label: "AI Curator", value: "ai-curator" } - { label: "Site", value: "site" } @@ -118,7 +118,7 @@ collections: required: false hint: "연속 글이 아니라면 비워둡니다." options: - - { label: "unilink 설계 노트", value: "unilink-design" } + - { label: "wirestead 설계 노트", value: "wirestead-design" } - { label: "AI Curator 파이프라인 구축기", value: "ai-curator-pipeline", diff --git a/src/content/about.md b/src/content/about.md index 9d9adb0..878ae3b 100644 --- a/src/content/about.md +++ b/src/content/about.md @@ -4,7 +4,7 @@ title: About # About -로보틱스 플랫폼에서 7년 이상 소프트웨어를 설계해 온 엔지니어입니다. 복잡도가 높은 시스템을 구조적으로 풀어내는 과정에서 소프트웨어 아키텍처의 중요성을 체감해 왔고, 도메인 전문가를 넘어 시스템 전체를 설계하는 아키텍트로 성장하는 것을 목표로 하고 있습니다. +2018년부터 로보틱스 플랫폼에서 소프트웨어를 설계해 온 엔지니어입니다. 복잡도가 높은 시스템을 구조적으로 풀어내는 과정에서 소프트웨어 아키텍처의 중요성을 체감해 왔고, 도메인 전문가를 넘어 시스템 전체를 설계하는 아키텍트로 성장하는 것을 목표로 하고 있습니다. 모듈 간 의존성을 분리하고 인터페이스를 일관되게 추상화하여 시스템 복잡도를 낮추는 것을 중요하게 생각합니다. 로우레벨 비동기 I/O부터 고수준 임무 관리까지, 확장 가능하고 일관된 소프트웨어 구조를 설계해 왔습니다. diff --git a/src/content/blog/2026-04-18-hello.md b/src/content/blog/2026-04-18-hello.md index f9ea3d5..ceef973 100644 --- a/src/content/blog/2026-04-18-hello.md +++ b/src/content/blog/2026-04-18-hello.md @@ -3,7 +3,10 @@ title: '사이트 오픈' date: 2026-04-18 project: site kind: note -tags: ['meta'] +tags: + - meta + - astro + - site description: 'Jekyll 기반 블로그에서 Astro 허브 사이트로 전환한 배경과 개인 플랫폼의 초기 방향을 정리했다.' --- diff --git "a/src/content/blog/2026-04-21-markdown-\354\236\221\354\204\261-\354\230\210\354\213\234-sveltia.md" "b/src/content/blog/2026-04-21-markdown-\354\236\221\354\204\261-\354\230\210\354\213\234-sveltia.md" index 624e912..bdecbce 100644 --- "a/src/content/blog/2026-04-21-markdown-\354\236\221\354\204\261-\354\230\210\354\213\234-sveltia.md" +++ "b/src/content/blog/2026-04-21-markdown-\354\236\221\354\204\261-\354\230\210\354\213\234-sveltia.md" @@ -6,6 +6,7 @@ kind: note tags: - markdown - sveltia + - site description: Sveltia CMS raw Markdown 모드에서 frontmatter, 코드 블록, 표, Mermaid 다이어그램 작성 예시를 정리했다. draft: false --- diff --git a/src/content/blog/2026-04-21-stl-priority-queue.md b/src/content/blog/2026-04-21-stl-priority-queue.md index cd85b88..c5dca85 100644 --- a/src/content/blog/2026-04-21-stl-priority-queue.md +++ b/src/content/blog/2026-04-21-stl-priority-queue.md @@ -3,25 +3,26 @@ title: 'Priority queue' date: 2026-04-21 kind: study tags: + - cpp - stl - - c++ - - 자료구조 -description: C++ std::priority_queue의 동작 방식, 최대 힙과 최소 힙 사용법, 사용자 정의 비교 함수 예제를 정리합니다. + - data-structure +description: C++ std::priority_queue의 동작 방식, 최대 힙과 최소 힙 사용법, 사용자 정의 비교 함수 예제를 정리했다. draft: true --- ## 정의 -우선순위가 가장 높은(혹은 낮은) 데이터를 가장 먼저 도출되는 형태의 자료구조 +우선순위가 가장 높은(혹은 낮은) 데이터가 가장 먼저 나오는 자료구조 - C++에서는 `std::priority_queue`가 기본적으로 제공 -- 내부적으로 \*\*최대 힙(Max Heap)\*\*을 사용해서 구현되어 있음 -- 정렬이 필요없는 **우선순위 기반의 데어터 처리**에 적합 +- 내부적으로 **최대 힙**(Max Heap)을 사용해서 구현되어 있음 +- 정렬이 필요 없는 **우선순위 기반의 데이터 처리**에 적합 ### 구조 - C++의 `std::priority_queue`는 기본적으로 힙(Heap) 자료구조를 기반으로 동작 -- 내부적으로 `std::vector`를 사용하며, 힙 연산을 활용하여 정렬 +- 내부적으로 `std::vector`를 사용하며, 힙 연산으로 힙 순서(heap order)를 유지한다 + - 컨테이너 전체가 정렬되어 있는 것은 아니다. `top()`이 항상 최우선 원소를 가리키도록 유지할 뿐이다 - `std::make_heap`, `std::push_heap`, `std::pop_heap` ### 시간복잡도 @@ -31,7 +32,7 @@ draft: true ### 템플릿 정의 -```c +```cpp template , class Compare = std::less> class priority_queue; ``` @@ -50,7 +51,7 @@ class priority_queue; - 기본적으로 최대 힙으로 적용되어 있음 -```c +```cpp #include #include @@ -79,7 +80,7 @@ int main() { `std::priority_queue`를 선언할 때 `std::greater<>` 추가 -```c +```cpp #include #include #include @@ -107,13 +108,14 @@ int main() { ## 사용자 정의 구조체 적용 -특정 기준으로 정렬하고 싶다면, `operator`를 오버로딩하거나 `compare` 함수를 지정 +특정 기준으로 정렬하고 싶다면, `operator<`를 오버로딩하거나 `compare` 함수를 지정 ### operator 오버로딩 -```c +```cpp #include #include +#include struct Task { int score; @@ -148,9 +150,10 @@ High Medium Low ### compare 함수 이용 -```c +```cpp #include #include +#include #include struct Task { @@ -180,7 +183,7 @@ int main() { } ``` -priority 기준 오름차순으로 정리됨 +priority 기준 오름차순으로 출력됨 ```text Low Medium High diff --git "a/src/content/blog/2026-04-23-ai-curator-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225-\353\260\251\354\225\210.md" "b/src/content/blog/2026-04-23-ai-curator-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225-\353\260\251\354\225\210.md" index b5f6dc7..a72c88e 100644 --- "a/src/content/blog/2026-04-23-ai-curator-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225-\353\260\251\354\225\210.md" +++ "b/src/content/blog/2026-04-23-ai-curator-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225-\353\260\251\354\225\210.md" @@ -6,58 +6,56 @@ kind: design tags: - ai-curator - astro -description: 'Astro와 GitHub Actions를 활용해 서버리스 정적 큐레이션 시스템의 초기 아키텍처와 운영 원칙을 정리합니다.' + - architecture +description: Astro와 GitHub Actions를 활용해 서버리스 정적 큐레이션 시스템의 초기 아키텍처와 운영 원칙을 정리했다. series: 'ai-curator-pipeline' seriesOrder: 1 draft: false --- -> 업데이트: 이후 Weekly 리포트와 별도 아카이브가 추가되었다. -> 현재 구조는 ai-curator README와 repository 구현을 기준으로 한다. +> 이 글은 구축 **이전**에 작성한 초기 설계 방안이다. +> 아래 클래스 구조, 소스 목록, 저장 경로는 실제 구현에서 상당 부분 달라졌다. +> 현재 동작하는 구조는 [파이프라인 구축기 — 실제 구현](/blog/2026-04-25-ai-curator-파이프라인-구축기-—-실제-구현)을 참고할 것. ## 1. 시스템 개요 -별도의 백엔드 서버나 동적 데이터베이스 없이 동작하는 완전 자동화 큐레이션 파이프라인입니다. 정보 수집부터 AI 요약, 웹사이트 배포까지의 전 과정이 단일 리포지토리 내에서 처리되는 서버리스(Serverless) 및 정적 사이트 생성(SSG) 기반 아키텍처를 채택했습니다. +별도의 백엔드 서버나 동적 데이터베이스 없이 동작하는 완전 자동화 큐레이션 파이프라인이다. 정보 수집부터 AI 요약, 웹사이트 배포까지의 전 과정이 단일 리포지토리 내에서 처리되는 서버리스(Serverless) 및 정적 사이트 생성(SSG) 기반 아키텍처를 채택했다. ### 주요 설계 원칙 -- **비용 통제:** 클라이언트(브라우저) 사이드에서의 API 호출을 원천 배제하여 악의적인 트래픽 공격에 따른 비용 과금을 방어합니다. -- **유지보수 효율화:** 프론트엔드 코드와 데이터 파이프라인(Python 스크립트)을 단일 저장소에서 관리하되, 모듈을 명확히 분리합니다. -- **정적 데이터 관리:** RDBMS 대신 마크다운(`.md`) 파일과 Git 커밋 히스토리를 시계열 데이터베이스처럼 활용합니다. +- **비용 통제:** 클라이언트(브라우저) 사이드에서의 API 호출을 원천 배제하여 악의적인 트래픽 공격에 따른 비용 과금을 방어한다. +- **유지보수 효율화:** 프론트엔드 코드와 데이터 파이프라인(Python 스크립트)을 단일 저장소에서 관리하되, 모듈을 명확히 분리한다. +- **정적 데이터 관리:** RDBMS 대신 마크다운(`.md`) 파일과 Git 커밋 히스토리를 시계열 데이터베이스처럼 활용한다. --- ## 2. 시스템 아키텍처 및 워크플로우 -전체 시스템은 크게 데이터 수집 및 가공을 담당하는 **파이프라인 모듈(Python)**과 정적 렌더링을 담당하는 **프론트엔드 모듈(Astro)**로 분리되어 동작합니다. +전체 시스템은 크게 데이터 수집 및 가공을 담당하는 **파이프라인 모듈**(Python)과 정적 렌더링을 담당하는 **프론트엔드 모듈**(Astro)로 분리되어 동작한다. ### 2.1. 컴포넌트 아키텍처 (Component Architecture) -시스템을 구성하는 주요 모듈 간의 논리적 의존성 및 데이터 흐름입니다. +시스템을 구성하는 주요 모듈 간의 논리적 의존성 및 데이터 흐름이다. ```mermaid graph TD subgraph CI["GitHub Actions (CI/CD Environment)"] Cron([Schedule Trigger]) Runner[Ubuntu Runner] - end subgraph Pipeline["Data Pipeline (Python)"] Fetcher["Fetcher Module
(RSS/API Parser)"] Processor["Processor Module
(Prompt & LLM)"] Formatter["Formatter Module
(Markdown Generator)"] - end subgraph External["External Services & APIs"] Sources[("Data Sources
(ArXiv, ROS2 Discourse, Hacker News)")] Gemini{"Gemini API"} - end subgraph Frontend["Astro Frontend (SSG)"] Collection[("Content Collections
(src/content/curation/)")] Builder["Astro Build Engine"] Pages(["Static HTML Pages"]) - end %% Flow Cron --> Runner @@ -75,7 +73,7 @@ graph TD ### 2.2. 아키텍처 워크플로우 (Sequence Diagram) -파이프라인이 실행되는 하루 주기의 데이터 흐름 명세입니다. +파이프라인이 실행되는 하루 주기의 데이터 흐름 명세다. ```mermaid sequenceDiagram @@ -89,37 +87,31 @@ sequenceDiagram Cron->>Fetcher: 지정된 시간에 파이프라인 실행 activate Fetcher - rect rgb(30, 30, 30) Note over Fetcher, LLM: 1. 데이터 수집 및 가공 Fetcher->>Fetcher: 타겟 소스(RSS, API) 스크래핑 Fetcher->>LLM: 텍스트 데이터 및 프롬프트 전송 activate LLM LLM-->>Fetcher: 요약 및 인사이트 데이터 반환 deactivate LLM - end - rect rgb(40, 40, 40) Note over Fetcher, Repo: 2. 데이터 저장 Fetcher->>Repo: Frontmatter 포함 YYYY-MM-DD.md 생성 Fetcher->>Repo: src/content/curation/ 경로에 Commit & Push - end deactivate Fetcher - rect rgb(30, 30, 30) Note over Repo, Astro: 3. 빌드 및 배포 Repo->>Astro: Commit 발생 시 배포 Action 트리거 activate Astro Astro->>Astro: SSG 빌드 (Markdown -> HTML) Astro-->>Repo: gh-pages 브랜치로 배포 완료 deactivate Astro - end ``` --- ## 3. 파이프라인 모듈 설계 (Class Diagram) -Python 기반의 데이터 수집기(`scripts/`) 내부의 객체 지향적 구조와 역할 명세입니다. 외부 소스 확장을 고려하여 인터페이스를 분리했습니다. +Python 기반의 데이터 수집기(`scripts/`) 내부의 객체 지향적 구조와 역할 명세다. 외부 소스 확장을 고려하여 인터페이스를 분리했다. ```mermaid classDiagram @@ -173,11 +165,11 @@ classDiagram ### 모듈별 책임 (Responsibility) -- **`PipelineController`**: 배치 작업의 전체 생명주기 관리. 설정된 소스 목록을 순회하며 프로세스를 순차 제어합니다. -- **`DataSource` (인터페이스/구현체)**: 외부 데이터를 읽어와 시스템 내부 규격인 `Article` 객체로 정규화합니다. +- **`PipelineController`**: 배치 작업의 전체 생명주기 관리. 설정된 소스 목록을 순회하며 프로세스를 순차 제어한다. +- **`DataSource` (인터페이스/구현체)**: 외부 데이터를 읽어와 시스템 내부 규격인 `Article` 객체로 정규화한다. - **`Article`**: 데이터 전송 객체(DTO). -- **`LLMClient`**: 정규화된 리스트를 바탕으로 프롬프트를 구성하고 Gemini API와 통신하여 텍스트를 반환합니다. -- **`MarkdownBuilder`**: LLM 응답과 메타데이터를 결합하여 Astro 프레임워크 규격에 맞는 마크다운 파일을 시스템에 기록합니다. +- **`LLMClient`**: 정규화된 리스트를 바탕으로 프롬프트를 구성하고 Gemini API와 통신하여 텍스트를 반환한다. +- **`MarkdownBuilder`**: LLM 응답과 메타데이터를 결합하여 Astro 프레임워크 규격에 맞는 마크다운 파일을 시스템에 기록한다. --- @@ -211,4 +203,4 @@ sources: ['ArXiv', 'ROS Discourse'] - **렌더링 방식:** 정적 사이트 생성(SSG). - **데이터 연동:** Astro의 Content Collections API를 사용하여 빌드 타임에 마크다운을 HTML로 변환. -- **호스팅:** 빌드된 정적 에셋은 GitHub Pages(`gh-pages` 브랜치)를 통해 무료 서빙. +- **호스팅:** 빌드된 정적 에셋은 GitHub Pages(`gh-pages` 브랜치)를 통해 무료 서빙한다. diff --git "a/src/content/blog/2026-04-25-ai-curator-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225\352\270\260-\342\200\224-\354\213\244\354\240\234-\352\265\254\355\230\204.md" "b/src/content/blog/2026-04-25-ai-curator-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225\352\270\260-\342\200\224-\354\213\244\354\240\234-\352\265\254\355\230\204.md" index 9af5257..26a8b8b 100644 --- "a/src/content/blog/2026-04-25-ai-curator-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225\352\270\260-\342\200\224-\354\213\244\354\240\234-\352\265\254\355\230\204.md" +++ "b/src/content/blog/2026-04-25-ai-curator-\355\214\214\354\235\264\355\224\204\353\235\274\354\235\270-\352\265\254\354\266\225\352\270\260-\342\200\224-\354\213\244\354\240\234-\352\265\254\355\230\204.md" @@ -5,13 +5,15 @@ project: ai-curator kind: implementation tags: - ai-curator -description: 'GitHub Actions와 Gemini API를 활용해 기술 뉴스를 자동 수집·요약하고 Astro 정적 사이트로 배포하는 과정을 정리합니다.' + - github-actions + - astro +description: GitHub Actions와 Gemini API를 활용해 기술 뉴스를 자동 수집·요약하고 Astro 정적 사이트로 배포하는 과정을 정리했다. series: 'ai-curator-pipeline' seriesOrder: 2 draft: false --- -> 이 글은 2026-04-23에 작성한 설계 방안 포스트의 실제 구현 버전입니다. +> 이 글은 [파이프라인 구축 방안](/blog/2026-04-23-ai-curator-파이프라인-구축-방안)의 실제 구현 버전이다. > 설계 단계와 달라진 부분을 포함해 현재 작동 중인 상태를 기록합니다. ## 결과물 diff --git "a/src/content/blog/2026-05-31-unilink-builder-api-\354\204\244\352\263\204.md" "b/src/content/blog/2026-05-31-wirestead-builder-api-\354\204\244\352\263\204.md" similarity index 88% rename from "src/content/blog/2026-05-31-unilink-builder-api-\354\204\244\352\263\204.md" rename to "src/content/blog/2026-05-31-wirestead-builder-api-\354\204\244\352\263\204.md" index 7dc4b97..869a560 100644 --- "a/src/content/blog/2026-05-31-unilink-builder-api-\354\204\244\352\263\204.md" +++ "b/src/content/blog/2026-05-31-wirestead-builder-api-\354\204\244\352\263\204.md" @@ -1,18 +1,16 @@ --- title: 'Builder API 설계' date: 2026-05-31 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - - async-io - builder-pattern - crtp - - concepts - api-design description: CRTP, Concepts, Fluent API를 활용해 복잡한 통신 객체 설정을 안전하고 읽기 쉬운 생성 흐름으로 구성한 과정을 정리했다. -series: 'unilink-design' +series: 'wirestead-design' seriesOrder: 3 draft: false --- @@ -41,11 +39,11 @@ TcpClient client( 이 코드는 짧지만 명확하지 않다. 각 숫자와 boolean이 무엇을 의미하는지 코드만 보고 이해하기 어렵다. 생성자 overload가 늘어나면 사용자는 어떤 생성자를 선택해야 하는지도 계속 확인해야 한다. -unilink에서는 이 문제를 Builder API로 풀었다. +wirestead에서는 이 문제를 Builder API로 풀었다. 필수 입력은 entry point에서 받고, 선택적 설정은 fluent API로 명시적으로 연결한다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .retry_interval(std::chrono::milliseconds(3000)) .max_retries(-1) .connection_timeout(std::chrono::milliseconds(5000)) @@ -88,15 +86,15 @@ mindmap ## Builder가 담당하는 역할 -unilink에서 Builder는 통신 객체가 생성되기 전에 필요한 설정을 모으는 계층이다. +wirestead에서 Builder는 통신 객체가 생성되기 전에 필요한 설정을 모으는 계층이다. 사용자는 builder를 통해 callback, framer, backpressure, reconnect, socket option 같은 설정을 구성한 뒤 `build()`를 호출한다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) - .on_data([](const unilink::MessageContext& ctx) { +auto client = wirestead::tcp_client("127.0.0.1", 9000) + .on_data([](const wirestead::MessageContext& ctx) { // handle received data }) - .on_error([](const unilink::ErrorContext& err) { + .on_error([](const wirestead::ErrorContext& err) { // handle error }) .build(); @@ -140,7 +138,7 @@ Builder API의 가장 중요한 목표는 설정 코드가 읽히는 것이다. 반면 Builder API는 설정의 의미를 메서드 이름으로 드러낸다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .retry_interval(std::chrono::milliseconds(3000)) .max_retries(5) .connection_timeout(std::chrono::milliseconds(5000)) @@ -174,11 +172,11 @@ mindmap runtime phase ``` -unilink의 Builder API는 이 목표를 기준으로 설계되었다. +wirestead의 Builder API는 이 목표를 기준으로 설계되었다. ## BuilderInterface: 공통 설정 계층 -unilink에는 transport별 builder가 존재한다. +wirestead에는 transport별 builder가 존재한다. 예를 들어 TCP client, TCP server, Serial, UDP, UDS는 각각 생성에 필요한 입력과 세부 옵션이 다르다. 하지만 모든 builder가 callback 등록, framer 설정, backpressure 설정을 각자 구현하면 중복이 발생한다. @@ -225,7 +223,7 @@ flowchart TD 문제는 callback signature가 잘못되었을 때다. 예를 들어 `on_data`는 수신 메시지 context를 받아야 하고, `on_error`는 error context를 받아야 한다. 사용자가 잘못된 인자를 받는 lambda를 넘기면 컴파일 단계에서 이를 잡아낼 수 있어야 한다. -unilink는 이를 C++20 Concepts로 제한한다. +wirestead는 이를 C++20 Concepts로 제한한다. ```cpp template @@ -275,7 +273,7 @@ flowchart TD ## Rebind와 BuilderState -unilink Builder에는 `BuilderState`와 `Rebind` 구조도 포함되어 있다. +wirestead Builder에는 `BuilderState`와 `Rebind` 구조도 포함되어 있다. `BuilderState`는 callback 등록 상태를 bitmask로 표현한다. 예를 들어 data callback이 등록되었는지, error callback이 등록되었는지를 상태로 추적할 수 있다. @@ -333,7 +331,7 @@ TCP client는 TCP client만의 설정이 필요하다. 예를 들어 retry interval, max retries, connection timeout, TCP_NODELAY, keep-alive, send/receive buffer size 같은 옵션은 TCP client의 성격에 더 가깝다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .retry_interval(std::chrono::milliseconds(3000)) .max_retries(5) .connection_timeout(std::chrono::milliseconds(5000)) @@ -410,16 +408,16 @@ flowchart TD Builder는 설정을 모으고, wrapper 생성 시점에 그 설정을 전달한다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) - .on_data([](const unilink::MessageContext& ctx) { +auto client = wirestead::tcp_client("127.0.0.1", 9000) + .on_data([](const wirestead::MessageContext& ctx) { // handle data }) .use_line_framer("\n") .backpressure_threshold(64 * 1024) .build(); -client->start(); -client->send("hello"); +client.start(); +client.send("hello"); ``` 이 구분은 중요하다. @@ -439,14 +437,14 @@ flowchart LR ## Framer와 Backpressure 설정 -unilink Builder의 특징 중 하나는 단순 transport option뿐 아니라, 비동기 통신에서 반복되는 런타임 문제도 설정할 수 있다는 점이다. +wirestead Builder의 특징 중 하나는 단순 transport option뿐 아니라, 비동기 통신에서 반복되는 런타임 문제도 설정할 수 있다는 점이다. 대표적인 예가 framer다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .use_line_framer("\n") - .on_message([](const unilink::MessageContext& msg) { + .on_message([](const wirestead::MessageContext& msg) { // handle complete line message }) .build(); @@ -458,9 +456,9 @@ Builder 단계에서 line framer를 설정하고, runtime에서는 message callb Packet 기반 framing도 같은 방향으로 확장할 수 있다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .use_packet_framer({0x02}, {0x03}, 4096) - .on_message([](const unilink::MessageContext& msg) { + .on_message([](const wirestead::MessageContext& msg) { // handle framed packet }) .build(); @@ -469,9 +467,9 @@ auto client = unilink::tcp_client("127.0.0.1", 9000) backpressure도 builder에서 설정할 수 있다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .backpressure_strategy( - unilink::base::constants::BackpressureStrategy::BestEffort) + wirestead::base::constants::BackpressureStrategy::BestEffort) .backpressure_threshold(64 * 1024) .on_backpressure([](size_t queued_bytes) { // observe queue pressure @@ -499,7 +497,7 @@ mindmap context-based reporting ``` -이것이 unilink Builder의 중요한 역할이다. +이것이 wirestead Builder의 중요한 역할이다. Builder는 단순 옵션 설정기가 아니라, 통신 객체의 runtime behavior를 구성하는 API다. ## Member Function Callback 지원 @@ -507,23 +505,23 @@ Builder는 단순 옵션 설정기가 아니라, 통신 객체의 runtime behavi 비동기 callback API는 lambda만 받으면 충분해 보일 수 있다. 하지만 실제 애플리케이션에서는 클래스의 멤버 함수로 이벤트를 처리하고 싶은 경우도 많다. -unilink Builder는 이런 사용을 위해 객체 포인터와 멤버 함수 포인터를 받는 overload도 제공한다. +wirestead Builder는 이런 사용을 위해 객체 포인터와 멤버 함수 포인터를 받는 overload도 제공한다. ```cpp class Handler { public: - void handle_data(const unilink::MessageContext& ctx) { + void handle_data(const wirestead::MessageContext& ctx) { // handle data } - void handle_error(const unilink::ErrorContext& err) { + void handle_error(const wirestead::ErrorContext& err) { // handle error } }; Handler handler; -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .on_data(&handler, &Handler::handle_data) .on_error(&handler, &Handler::handle_error) .build(); @@ -571,18 +569,18 @@ mindmap API consistency burden ``` -이 trade-off에도 불구하고 unilink에서는 Builder 구조를 선택했다. +이 trade-off에도 불구하고 wirestead에서는 Builder 구조를 선택했다. 이유는 통신 객체의 설정 복잡성이 생성자 기반 API보다 Builder 기반 API에 더 잘 맞기 때문이다. 특히 여러 transport를 지원하고, callback과 runtime policy를 함께 설정해야 하는 라이브러리에서는 Builder가 public API의 일관성을 유지하는 데 유리하다. ## 정리: Builder의 역할 -unilink에서 Builder는 다음 역할을 담당한다. +wirestead에서 Builder는 다음 역할을 담당한다. ```mermaid mindmap - root((unilink Builder Role)) + root((wirestead Builder Role)) Configuration required inputs optional settings @@ -607,7 +605,7 @@ mindmap ``` Builder는 단순히 객체 생성을 편하게 만드는 도구가 아니다. -unilink에서 Builder는 public API의 일관성을 유지하고, transport별 설정을 분리하며, 비동기 통신에서 필요한 callback과 runtime policy를 구성하는 핵심 계층이다. +wirestead에서 Builder는 public API의 일관성을 유지하고, transport별 설정을 분리하며, 비동기 통신에서 필요한 callback과 runtime policy를 구성하는 핵심 계층이다. 정리하면 다음과 같다. @@ -619,5 +617,5 @@ unilink에서 Builder는 public API의 일관성을 유지하고, transport별 - `build()` 단계에서 설정을 wrapper 객체로 전달한다. - framer와 backpressure 같은 runtime concern도 builder 설정으로 표현한다. -Builder API는 unilink의 사용 경험을 결정하는 중요한 계층이다. -통신 객체의 생성 과정을 명시적으로 만들고, 설정의 의미를 코드에 드러내며, transport별 차이를 확장 가능한 방식으로 흡수한다. 이 점에서 Builder는 unilink의 Unified API를 실제 사용 코드로 연결하는 핵심 설계 요소라고 볼 수 있다. +Builder API는 wirestead의 사용 경험을 결정하는 중요한 계층이다. +통신 객체의 생성 과정을 명시적으로 만들고, 설정의 의미를 코드에 드러내며, transport별 차이를 확장 가능한 방식으로 흡수한다. 이 점에서 Builder는 wirestead의 Unified API를 실제 사용 코드로 연결하는 핵심 설계 요소라고 볼 수 있다. diff --git "a/src/content/blog/2026-05-31-unilink-unified-api-\354\204\244\352\263\204.md" "b/src/content/blog/2026-05-31-wirestead-unified-api-\354\204\244\352\263\204.md" similarity index 74% rename from "src/content/blog/2026-05-31-unilink-unified-api-\354\204\244\352\263\204.md" rename to "src/content/blog/2026-05-31-wirestead-unified-api-\354\204\244\352\263\204.md" index f2b2027..51b66f6 100644 --- "a/src/content/blog/2026-05-31-unilink-unified-api-\354\204\244\352\263\204.md" +++ "b/src/content/blog/2026-05-31-wirestead-unified-api-\354\204\244\352\263\204.md" @@ -1,25 +1,23 @@ --- title: 'Unified API 설계' date: 2026-05-31 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - - async-io - api-design - - architecture - facade - - builder + - architecture description: Facade, Builder, Wrapper를 조합해 여러 transport를 일관된 public API로 다루는 설계 방향을 정리했다. -series: 'unilink-design' +series: 'wirestead-design' seriesOrder: 2 draft: false --- ## 도입: 설계 배경에서 Public API로 -이전 글에서는 unilink를 만든 배경과 설계 방향을 정리했다. +[설계 배경](/blog/2026-05-31-wirestead-설계-배경)에서는 wirestead를 만든 배경과 설계 방향을 정리했다. 핵심은 여러 통신 방식을 단순히 지원하는 것이 아니라, TCP, UDP, Serial, Unix Domain Socket을 하나의 일관된 개발 경험으로 다루는 것이었다. 이 목표를 실제 코드 구조로 옮기기 위해 가장 먼저 정리해야 하는 영역은 public API다. @@ -27,13 +25,13 @@ draft: false 라이브러리에서 public API는 단순한 함수 목록이 아니다. 사용자가 라이브러리를 이해하는 출발점이고, 애플리케이션 코드가 직접 의존하는 계약이다. 내부 구현이 아무리 잘 분리되어 있어도 public API가 복잡하거나 일관성이 없으면 사용자는 transport별 차이를 계속 의식해야 한다. -unilink에서는 이 문제를 줄이기 위해 `unilink.hpp`를 public entry point로 두고, 그 아래에 Facade, Builder, Wrapper 구조를 배치했다. +wirestead에서는 이 문제를 줄이기 위해 `wirestead.hpp`를 public entry point로 두고, 그 아래에 Facade, Builder, Wrapper 구조를 배치했다. ```mermaid mindmap root((Public API Goals)) Single Entry Point - unilink.hpp + wirestead.hpp common include surface Consistent Usage builder-based creation @@ -49,7 +47,7 @@ mindmap runtime stats ``` -이 글에서는 unilink의 public API가 어떤 구조로 정리되어 있는지, 그리고 왜 Facade, Builder, Wrapper 중심으로 구성되었는지 살펴본다. +이 글에서는 wirestead의 public API가 어떤 구조로 정리되어 있는지, 그리고 왜 Facade, Builder, Wrapper 중심으로 구성되었는지 살펴본다. ## Public API의 역할 @@ -58,7 +56,7 @@ TCP client, TCP server, UDP, Serial, UDS는 서로 다른 초기화 방식과 ru 하지만 사용자가 처음 만나는 API는 가능한 한 단순해야 한다. -unilink의 public API는 다음 역할을 담당한다. +wirestead의 public API는 다음 역할을 담당한다. ```mermaid flowchart TD @@ -82,16 +80,16 @@ flowchart TD 이 구분이 명확해야 라이브러리 사용자는 transport의 세부 구현보다 자신의 애플리케이션 로직에 집중할 수 있다. -## unilink.hpp: Public Facade +## wirestead.hpp: Public Facade -unilink의 public API 중심에는 `unilink.hpp`가 있다. +wirestead의 public API 중심에는 `wirestead.hpp`가 있다. -`unilink.hpp`는 단순히 여러 header를 모아둔 include 파일이 아니다. +`wirestead.hpp`는 단순히 여러 header를 모아둔 include 파일이 아니다. 사용자가 어떤 구성요소를 직접 바라봐야 하는지 정리하는 Facade 역할을 한다. ```mermaid mindmap - root((unilink.hpp)) + root((wirestead.hpp)) Public Context MessageContext ConnectionContext @@ -121,23 +119,23 @@ mindmap ConfigFactory ``` -사용자는 내부 namespace와 구현 파일을 세부적으로 찾아 들어가지 않고도, `unilink.hpp`를 통해 주요 public API에 접근할 수 있다. +사용자는 내부 namespace와 구현 파일을 세부적으로 찾아 들어가지 않고도, `wirestead.hpp`를 통해 주요 public API에 접근할 수 있다. ```cpp -#include +#include ``` 이 방식은 “single-header library”를 의미하지 않는다. -unilink는 내부적으로 여러 wrapper, builder, transport, diagnostics, memory, framer 계층을 가진다. 다만 사용자가 처음 의존해야 하는 public surface를 하나의 진입점으로 정리했다는 의미에 가깝다. +wirestead는 내부적으로 여러 wrapper, builder, transport, diagnostics, memory, framer 계층을 가진다. 다만 사용자가 처음 의존해야 하는 public surface를 하나의 진입점으로 정리했다는 의미에 가깝다. -즉, `unilink.hpp`는 내부 구조를 없애는 것이 아니라, 내부 구조를 사용자가 직접 탐색하지 않아도 되게 만드는 Facade다. +즉, `wirestead.hpp`는 내부 구조를 없애는 것이 아니라, 내부 구조를 사용자가 직접 탐색하지 않아도 되게 만드는 Facade다. ## Facade가 숨기는 것과 드러내는 것 Facade의 핵심은 모든 것을 숨기는 것이 아니다. 사용자에게 필요한 것은 드러내고, 불필요한 구현 세부사항은 숨기는 것이다. -unilink의 Facade는 다음과 같은 기준으로 public API를 정리한다. +wirestead의 Facade는 다음과 같은 기준으로 public API를 정리한다. ```mermaid mindmap @@ -156,7 +154,7 @@ mindmap buffer management details ``` -예를 들어 사용자는 `wrapper::TcpClient`라는 내부 namespace를 직접 사용할 수도 있지만, public API에서는 `unilink::TcpClient` alias로 접근할 수 있다. +예를 들어 사용자는 `wrapper::TcpClient`라는 내부 namespace를 직접 사용할 수도 있지만, public API에서는 `wirestead::TcpClient` alias로 접근할 수 있다. ```cpp using TcpClient = wrapper::TcpClient; @@ -164,31 +162,31 @@ using Serial = wrapper::Serial; using UdpClient = wrapper::UdpClient; ``` -사용자 입장에서는 “어디에 구현되어 있는가”보다 “무엇을 사용할 수 있는가”가 중요하다. unilink namespace에서 핵심 타입을 바로 노출하면 API 탐색 비용이 줄어든다. +사용자 입장에서는 “어디에 구현되어 있는가”보다 “무엇을 사용할 수 있는가”가 중요하다. wirestead namespace에서 핵심 타입을 바로 노출하면 API 탐색 비용이 줄어든다. ## Builder Entry Point -unilink의 객체 생성은 생성자를 직접 호출하는 방식보다 builder entry point를 통해 시작된다. +wirestead의 객체 생성은 생성자를 직접 호출하는 방식보다 builder entry point를 통해 시작된다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) - .on_data([](const unilink::MessageContext& ctx) { +auto client = wirestead::tcp_client("127.0.0.1", 9000) + .on_data([](const wirestead::MessageContext& ctx) { // handle received data }) - .on_error([](const unilink::ErrorContext& err) { + .on_error([](const wirestead::ErrorContext& err) { // handle error }) .build(); ``` -여기서 `unilink::tcp_client(...)`는 곧바로 TCP client 객체를 만드는 함수가 아니라, TCP client builder를 반환하는 진입점이다. +여기서 `wirestead::tcp_client(...)`는 곧바로 TCP client 객체를 만드는 함수가 아니라, TCP client builder를 반환하는 진입점이다. 사용자는 이 builder에 callback, framer, backpressure, transport option 등을 설정한 뒤 `build()`를 호출해 실제 wrapper 객체를 생성한다. 이 구조는 다음과 같은 흐름을 만든다. ```mermaid flowchart TD - A[unilink::tcp_client host, port] --> B[TcpClientBuilder] + A[wirestead::tcp_client host, port] --> B[TcpClientBuilder] B --> C[Configure Options] C --> D[Register Callbacks] D --> E[build] @@ -205,7 +203,7 @@ Builder를 사용하면 필수 정보는 entry point에서 받고, 선택적 설 ## Transport별 Entry Point -unilink는 transport별로 서로 다른 entry point를 제공한다. +wirestead는 transport별로 서로 다른 entry point를 제공한다. 하지만 entry point 이후의 사용 흐름은 최대한 동일하게 유지한다. ```mermaid @@ -243,22 +241,22 @@ UDS는 socket path가 필요하다. 하지만 그 이후에는 builder 기반 설정 흐름으로 합류한다. ```cpp -auto serial = unilink::serial("/dev/ttyUSB0", 115200) - .on_data([](const unilink::MessageContext& ctx) { +auto serial = wirestead::serial("/dev/ttyUSB0", 115200) + .on_data([](const wirestead::MessageContext& ctx) { // handle serial data }) - .on_error([](const unilink::ErrorContext& err) { + .on_error([](const wirestead::ErrorContext& err) { // handle error }) .build(); -auto server = unilink::tcp_server(9000) - .on_connect([](const unilink::ConnectionContext& ctx) { +auto server = wirestead::tcp_server(9000) + .on_connect([](const wirestead::ConnectionContext& ctx) { // client connected }) - .on_data([](const unilink::MessageContext& ctx) { + .on_data([](const wirestead::MessageContext& ctx) { // handle client data }) - .on_error([](const unilink::ErrorContext& err) { + .on_error([](const wirestead::ErrorContext& err) { // handle error }) .build(); @@ -270,7 +268,7 @@ transport별 시작점은 다르지만, 사용자는 동일한 패턴으로 객 Builder가 객체 생성 과정을 담당한다면, Wrapper는 생성 이후 사용자가 직접 다루는 실행 객체다. -unilink에서 wrapper는 transport별 구현을 감싸면서도 사용자에게 공통적인 조작 방식을 제공한다. +wirestead에서 wrapper는 transport별 구현을 감싸면서도 사용자에게 공통적인 조작 방식을 제공한다. ```mermaid flowchart TD @@ -310,7 +308,7 @@ flowchart TD 예를 들어 TCP client나 Serial은 기본적으로 하나의 channel로 볼 수 있다. 반면 TCP server는 여러 client를 accept하고, 특정 client에게 보내거나 전체 client에게 broadcast할 수 있어야 한다. -unilink는 이 차이를 `ChannelInterface`와 `ServerInterface`로 분리한다. +wirestead는 이 차이를 `ChannelInterface`와 `ServerInterface`로 분리한다. ```mermaid mindmap @@ -338,11 +336,11 @@ mindmap 모든 transport를 완전히 같은 interface에 넣으면 겉으로는 단순해 보일 수 있다. 하지만 server가 필요한 `broadcast`, `send_to`, `client_count` 같은 개념을 channel 객체에도 억지로 넣게 되면 API가 오히려 부자연스러워진다. -따라서 unilink는 공통화할 수 있는 영역은 공통화하되, 통신 모델 자체가 다른 부분은 interface를 분리한다. +따라서 wirestead는 공통화할 수 있는 영역은 공통화하되, 통신 모델 자체가 다른 부분은 interface를 분리한다. ## Framer와 Backpressure의 Public API 노출 -unilink의 public API는 단순히 연결과 송수신만 제공하지 않는다. +wirestead의 public API는 단순히 연결과 송수신만 제공하지 않는다. 비동기 통신에서 반복적으로 발생하는 문제도 사용자가 설정할 수 있는 API로 노출한다. 대표적인 예가 framer다. @@ -350,12 +348,12 @@ unilink의 public API는 단순히 연결과 송수신만 제공하지 않는다 TCP나 Serial은 stream 기반이므로 수신된 byte chunk가 곧 하나의 메시지라는 보장이 없다. 이 문제를 애플리케이션 코드가 매번 직접 처리하게 하면 수신 buffer 누적, delimiter 검색, packet parsing 코드가 반복된다. -unilink는 builder 단계에서 framer를 설정할 수 있게 한다. +wirestead는 builder 단계에서 framer를 설정할 수 있게 한다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .use_line_framer("\n") - .on_message([](const unilink::MessageContext& msg) { + .on_message([](const wirestead::MessageContext& msg) { // handle complete message }) .build(); @@ -366,11 +364,11 @@ auto client = unilink::tcp_client("127.0.0.1", 9000) backpressure도 마찬가지다. 송신 queue가 쌓이는 상황은 transport 내부의 문제처럼 보이지만, 실제로는 애플리케이션 정책과 연결된다. 어떤 데이터는 반드시 보내야 하고, 어떤 데이터는 최신성만 유지하면 된다. -따라서 unilink는 backpressure strategy와 threshold를 builder 설정으로 노출한다. +따라서 wirestead는 backpressure strategy와 threshold를 builder 설정으로 노출한다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) - .backpressure_strategy(unilink::base::constants::BackpressureStrategy::BestEffort) +auto client = wirestead::tcp_client("127.0.0.1", 9000) + .backpressure_strategy(wirestead::base::constants::BackpressureStrategy::BestEffort) .backpressure_threshold(64 * 1024) .on_backpressure([](size_t queued_bytes) { // observe queue pressure @@ -397,7 +395,7 @@ mindmap runtime state ``` -이 접근은 unilink의 API 설계에서 중요한 기준이다. +이 접근은 wirestead의 API 설계에서 중요한 기준이다. 구현 세부사항은 숨기되, 사용자가 의사결정해야 하는 정책은 public API로 드러낸다. ## Facade와 Builder의 경계 @@ -409,23 +407,23 @@ Builder는 객체를 어떻게 설정하고 생성할지를 정리한다. ```mermaid flowchart TD - A[unilink.hpp Facade] --> B[Builder Entry Point] + A[wirestead.hpp Facade] --> B[Builder Entry Point] B --> C[Transport-specific Builder] C --> D[Configuration] D --> E[Wrapper Object] E --> F[Runtime Operations] ``` -`unilink.hpp`가 없더라도 내부 header를 직접 include해 객체를 만들 수는 있다. +`wirestead.hpp`가 없더라도 내부 header를 직접 include해 객체를 만들 수는 있다. 하지만 그렇게 하면 사용자는 어떤 header를 포함해야 하는지, 어떤 namespace의 타입을 써야 하는지, 어떤 builder를 직접 생성해야 하는지 알아야 한다. 반대로 Facade를 제공하면 사용자는 다음처럼 시작할 수 있다. ```cpp -#include +#include -auto client = unilink::tcp_client("127.0.0.1", 9000) - .on_data([](const unilink::MessageContext& ctx) { +auto client = wirestead::tcp_client("127.0.0.1", 9000) + .on_data([](const wirestead::MessageContext& ctx) { // handle data }) .build(); @@ -433,7 +431,7 @@ auto client = unilink::tcp_client("127.0.0.1", 9000) 이 코드는 내부 구조를 모르는 상태에서도 읽을 수 있다. -- `unilink::tcp_client(...)`로 TCP client를 만들기 시작한다. +- `wirestead::tcp_client(...)`로 TCP client를 만들기 시작한다. - `on_data(...)`로 수신 callback을 등록한다. - `build()`로 실행 객체를 만든다. - 이후 `start()`와 `send()`로 사용한다. @@ -469,7 +467,7 @@ mindmap 무리한 추상화는 오히려 API를 불편하게 만든다. TCP server와 Serial port를 완전히 같은 객체처럼 다루려 하면, 어느 한쪽에는 맞지 않는 개념이 public API에 들어가게 된다. -unilink의 방향은 “모든 것을 하나의 타입으로 통합”하는 것이 아니다. +wirestead의 방향은 “모든 것을 하나의 타입으로 통합”하는 것이 아니다. 사용자가 반복적으로 마주치는 흐름을 통일하고, transport별 차이는 필요한 곳에서만 드러내는 것이다. ## Trade-off @@ -477,13 +475,13 @@ unilink의 방향은 “모든 것을 하나의 타입으로 통합”하는 것 Facade 기반 public API는 장점이 크지만, trade-off도 있다. 첫 번째는 include dependency가 커질 수 있다는 점이다. -`unilink.hpp`가 여러 public wrapper와 builder를 모으는 역할을 하기 때문에, 작은 기능만 사용하는 경우에도 비교적 넓은 public surface를 포함하게 된다. +`wirestead.hpp`가 여러 public wrapper와 builder를 모으는 역할을 하기 때문에, 작은 기능만 사용하는 경우에도 비교적 넓은 public surface를 포함하게 된다. 두 번째는 public API 설계의 책임이 커진다는 점이다. Facade에 무엇을 포함할지, 어떤 alias를 제공할지, 어떤 convenience function을 노출할지는 사용자의 의존 지점을 결정한다. 한 번 public API로 노출한 이름은 쉽게 바꾸기 어렵다. 세 번째는 편의성과 명시성 사이의 균형이다. -`unilink::tcp_client(...)` 같은 entry point는 사용하기 쉽지만, 내부적으로 어떤 builder와 wrapper가 연결되는지 처음에는 감춰진다. 따라서 문서와 예제에서 이 흐름을 명확히 설명해야 한다. +`wirestead::tcp_client(...)` 같은 entry point는 사용하기 쉽지만, 내부적으로 어떤 builder와 wrapper가 연결되는지 처음에는 감춰진다. 따라서 문서와 예제에서 이 흐름을 명확히 설명해야 한다. ```mermaid mindmap @@ -505,13 +503,13 @@ mindmap ## 정리: Public API의 역할 -unilink의 Unified API는 다음 구조를 중심으로 구성된다. +wirestead의 Unified API는 다음 구조를 중심으로 구성된다. ```mermaid mindmap - root((unilink Unified API)) + root((wirestead Unified API)) Facade - unilink.hpp + wirestead.hpp public aliases builder entry points Builder @@ -529,10 +527,10 @@ mindmap 이 구조를 통해 사용자는 transport별 세부 구현보다 공통 사용 흐름에 집중할 수 있다. -unilink에서 `unilink.hpp`는 public entry point이고, Builder는 설정과 생성 흐름을 담당하며, Wrapper는 실제 runtime operation을 담당한다. +wirestead에서 `wirestead.hpp`는 public entry point이고, Builder는 설정과 생성 흐름을 담당하며, Wrapper는 실제 runtime operation을 담당한다. 또한 Channel과 Server interface를 분리해 1:1 통신과 1:N 통신의 차이를 무리하게 숨기지 않는다. -정리하면 unilink의 Unified API는 다음 목표를 가진다. +정리하면 wirestead의 Unified API는 다음 목표를 가진다. - 사용자가 시작할 위치를 명확히 한다. - transport별 객체 생성 흐름을 builder로 통일한다. @@ -540,4 +538,4 @@ unilink에서 `unilink.hpp`는 public entry point이고, Builder는 설정과 - 공통 runtime concern은 API로 정리해 선택 가능하게 만든다. - 무리한 단일 interface 대신 Channel과 Server를 분리한다. -이러한 구조 때문에 unilink는 여러 통신 방식을 지원하면서도 사용자는 비교적 일관된 방식으로 객체를 만들고 사용할 수 있다. +이러한 구조 때문에 wirestead는 여러 통신 방식을 지원하면서도 사용자는 비교적 일관된 방식으로 객체를 만들고 사용할 수 있다. diff --git "a/src/content/blog/2026-05-31-unilink-\354\204\244\352\263\204-\353\260\260\352\262\275.md" "b/src/content/blog/2026-05-31-wirestead-\354\204\244\352\263\204-\353\260\260\352\262\275.md" similarity index 75% rename from "src/content/blog/2026-05-31-unilink-\354\204\244\352\263\204-\353\260\260\352\262\275.md" rename to "src/content/blog/2026-05-31-wirestead-\354\204\244\352\263\204-\353\260\260\352\262\275.md" index e0f8cad..4df203d 100644 --- "a/src/content/blog/2026-05-31-unilink-\354\204\244\352\263\204-\353\260\260\352\262\275.md" +++ "b/src/content/blog/2026-05-31-wirestead-\354\204\244\352\263\204-\353\260\260\352\262\275.md" @@ -1,18 +1,16 @@ --- title: '설계 배경' date: 2026-05-31 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - async-io - - communication - architecture - boost-asio - - design-patterns -description: TCP, UDP, Serial, UDS를 하나의 개발 경험으로 묶기 위해 unilink가 선택한 설계 배경과 목표를 정리했다. -series: 'unilink-design' +description: TCP, UDP, Serial, UDS를 하나의 개발 경험으로 묶기 위해 wirestead가 선택한 설계 배경과 목표를 정리했다. +series: 'wirestead-design' seriesOrder: 1 draft: false --- @@ -41,7 +39,7 @@ flowchart LR 실제 프로젝트에서는 통신 방식이 바뀌었을 뿐인데 비즈니스 로직까지 함께 수정해야 하는 경우가 있다. TCP 기준으로 작성한 구조를 Serial로 옮기거나, 테스트용 UDP 인터페이스를 실제 장비용 Serial 인터페이스로 바꾸는 과정에서 callback 구조, 에러 처리, queue 정책이 함께 흔들리기도 한다. -unilink는 이러한 반복과 구조적 흔들림을 줄이고, 여러 통신 방식을 일관된 API로 다루기 위해 설계한 C++ 비동기 통신 라이브러리다. +wirestead는 이러한 반복과 구조적 흔들림을 줄이고, 여러 통신 방식을 일관된 API로 다루기 위해 설계한 C++ 비동기 통신 라이브러리다. ## 문제 정의: 구현 반복과 불일치 @@ -87,11 +85,11 @@ mindmap ## 설계 목표: 개발 경험 통합 -unilink의 목표는 단순히 여러 통신 방식을 지원하는 것이 아니다. +wirestead의 목표는 단순히 여러 통신 방식을 지원하는 것이 아니다. TCP, UDP, Serial, Unix Domain Socket을 모두 지원하더라도 각 API가 서로 다르게 동작한다면 사용자는 결국 transport별 사용법을 따로 익혀야 한다. -따라서 unilink의 핵심 목표는 “프로토콜을 하나로 만드는 것”이 아니라, “여러 통신 방식을 하나의 일관된 개발 경험으로 다루는 것”이다. +따라서 wirestead의 핵심 목표는 “프로토콜을 하나로 만드는 것”이 아니라, “여러 통신 방식을 하나의 일관된 개발 경험으로 다루는 것”이다. ```mermaid flowchart TD @@ -127,11 +125,11 @@ flowchart TD 사용자는 가능한 한 다음과 같은 흐름으로 통신 객체를 다룰 수 있어야 한다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) - .on_data([](auto data) { +auto client = wirestead::tcp_client("127.0.0.1", 9000) + .on_data([](const wirestead::MessageContext& ctx) { // handle received data }) - .on_error([](auto error) { + .on_error([](const wirestead::ErrorContext& err) { // handle error }) .build(); @@ -155,7 +153,7 @@ Serial, TCP, UDP, UDS는 내부 구현이 다르지만, 애플리케이션이 물론 모든 transport를 완전히 같은 형태로 추상화할 수는 없다. TCP client와 TCP server는 구조가 다르고, UDP는 연결 상태보다 endpoint 관리가 중요하다. Serial은 baudrate, parity, stop bit 같은 포트 설정이 필요하다. -따라서 unilink는 공통화할 수 있는 영역과 transport별로 분리해야 하는 영역을 명확히 나누는 방향으로 설계했다. +따라서 wirestead는 공통화할 수 있는 영역과 transport별로 분리해야 하는 영역을 명확히 나누는 방향으로 설계했다. ## 설계 원칙 1: 단순한 Public API @@ -165,27 +163,27 @@ Boost.Asio 기반으로 구현할 경우 `io_context`, socket, strand, async ope 그러나 이러한 요소가 모두 public API에 노출되면 라이브러리 사용자는 통신 로직보다 비동기 구현 세부사항을 더 많이 다루게 된다. -unilink에서는 public API의 역할을 제한하고, 내부 복잡성은 구현 계층으로 숨기는 방향을 선택했다. +wirestead에서는 public API의 역할을 제한하고, 내부 복잡성은 구현 계층으로 숨기는 방향을 선택했다. ```mermaid flowchart TD subgraph API_Layer["1. Public API Layer"] direction LR - Facade["unilink.hpp\nFacade / Wrapper"] - Config["Builder\nConfiguration"] - Ops["Operations\nsend / callback / stats"] + Facade["wirestead.hpp
Facade / Wrapper"] + Config["Builder
Configuration"] + Ops["Operations
send / callback / stats"] end subgraph Core_Layer["2. Internal Implementation"] direction LR - Transports["Transports\nTCP / UDP / Serial / UDS"] - Pipeline["Data Pipeline\nFramer / Queue / Buffer"] - Manage["Management\nFactory / Error / Logging"] + Transports["Transports
TCP / UDP / Serial / UDS"] + Pipeline["Data Pipeline
Framer / Queue / Buffer"] + Manage["Management
Factory / Error / Logging"] end subgraph Runtime_Layer["3. Runtime Layer"] direction LR - Asio["Boost.Asio\nio_context / Sockets"] + Asio["Boost.Asio
io_context / Sockets"] end API_Layer ==> Core_Layer @@ -197,7 +195,7 @@ flowchart TD 이 구조를 통해 사용자는 단순한 API를 사용하고, 라이브러리는 내부에서 transport별 복잡성을 관리한다. -이 방향은 unilink의 계층 구조에도 직접적으로 반영된다. 사용자에게 노출되는 API는 Facade, Builder, Wrapper 중심으로 구성하고, 실제 transport 구현은 내부 계층으로 분리한다. +이 방향은 wirestead의 계층 구조에도 직접적으로 반영된다. 사용자에게 노출되는 API는 Facade, Builder, Wrapper 중심으로 구성하고, 실제 transport 구현은 내부 계층으로 분리한다. 중요한 것은 내부 구현을 숨기는 것 자체가 아니다. 사용자가 반드시 알아야 하는 개념과 라이브러리 내부에서 책임져야 하는 구현 세부사항을 구분하는 것이다. public API는 사용자가 의존하는 계약이고, 내부 구현은 성능과 안정성을 위해 계속 개선될 수 있는 영역이다. @@ -205,7 +203,7 @@ flowchart TD TCP client, TCP server, UDP, Serial, UDS는 구현 방식이 다르다. 그러나 애플리케이션에서 공통적으로 기대하는 동작은 상당 부분 겹친다. -unilink는 이 공통 동작을 중심으로 API를 구성하고, transport별 차이는 builder option과 내부 transport 계층으로 분리한다. +wirestead는 이 공통 동작을 중심으로 API를 구성하고, transport별 차이는 builder option과 내부 transport 계층으로 분리한다. 예를 들어 TCP client는 remote endpoint에 연결해야 하고, TCP server는 acceptor를 통해 여러 client를 관리해야 한다. UDP는 datagram 단위의 송수신과 endpoint 정보가 중요하며, Serial은 장치 경로와 포트 설정이 필요하다. @@ -219,7 +217,7 @@ flowchart LR B2 --> B3[Business Logic changes] end - subgraph After["After: With Abstraction (unilink)"] + subgraph After["After: With Abstraction (wirestead)"] direction TB A1[Transport code changes] --> A2[Internal adapter changes] A2 --> A3[Public API unchanged] @@ -231,7 +229,7 @@ flowchart LR 이 구조는 새로운 transport가 추가되더라도 사용자가 익힌 기본 사용 흐름은 유지하고, 내부 구현과 builder만 확장하는 방식으로 대응할 수 있게 한다. -즉, unilink의 abstraction은 통신 방식의 차이를 없애기 위한 것이 아니다. 차이는 내부에 남겨두되, 그 차이가 애플리케이션 전체로 전파되지 않도록 막는 것이다. +즉, wirestead의 abstraction은 통신 방식의 차이를 없애기 위한 것이 아니다. 차이는 내부에 남겨두되, 그 차이가 애플리케이션 전체로 전파되지 않도록 막는 것이다. ## 설계 원칙 3: 비동기 통신 문제의 API화 @@ -241,9 +239,9 @@ flowchart LR 이러한 문제를 매번 애플리케이션 코드에서 직접 처리하게 만들면, transport가 바뀔 때마다 비슷한 보조 로직이 반복된다. 또한 프로젝트마다 queue 정책, error handling, buffer 처리 방식이 달라지면서 사용 경험도 일관되지 않게 된다. -unilink는 이런 문제를 내부 구현에만 숨기지 않고, 필요한 부분은 API 설계의 일부로 다루는 방향을 선택했다. `send`, `try_send`, callback, framer, runtime statistics 같은 개념은 단순한 부가 기능이 아니라, 비동기 통신을 안정적으로 사용하기 위한 공통 접점이다. +wirestead는 이런 문제를 내부 구현에만 숨기지 않고, 필요한 부분은 API 설계의 일부로 다루는 방향을 선택했다. `send`, `try_send`, callback, framer, runtime statistics 같은 개념은 단순한 부가 기능이 아니라, 비동기 통신을 안정적으로 사용하기 위한 공통 접점이다. -즉, unilink의 목적은 통신 함수를 단순히 감싸는 것이 아니라, 비동기 통신에서 반복적으로 마주치는 문제를 일관된 방식으로 다룰 수 있는 기반을 제공하는 것이다. +즉, wirestead의 목적은 통신 함수를 단순히 감싸는 것이 아니라, 비동기 통신에서 반복적으로 마주치는 문제를 일관된 방식으로 다룰 수 있는 기반을 제공하는 것이다. ## 설계 원칙 4: 변경 영향 분리 @@ -251,7 +249,7 @@ unilink는 이런 문제를 내부 구현에만 숨기지 않고, 필요한 부 통신 라이브러리는 내부적으로 계속 바뀔 수밖에 없다. transport 구현이 개선될 수 있고, queue 정책이 보완될 수 있으며, reconnect 처리나 error propagation 방식도 더 안정적으로 다듬어질 수 있다. 하지만 이러한 내부 변경이 매번 사용자 코드 변경으로 이어진다면 라이브러리 사용 비용은 커진다. -unilink는 사용자 코드가 의존해야 할 영역과, 라이브러리 내부에서 자유롭게 개선할 수 있는 영역을 분리하는 방향으로 구조를 잡았다. +wirestead는 사용자 코드가 의존해야 할 영역과, 라이브러리 내부에서 자유롭게 개선할 수 있는 영역을 분리하는 방향으로 구조를 잡았다. 사용자 코드는 public API에 의존한다. 내부 구현은 transport별 구현, queue, framer, retry, socket option, logging처럼 계속 개선될 수 있는 영역으로 둔다. @@ -262,18 +260,18 @@ unilink는 사용자 코드가 의존해야 할 영역과, 라이브러리 내 ## 정리: 설계 방향 -unilink는 여러 통신 방식을 지원하는 C++ 비동기 통신 라이브러리다. +wirestead는 여러 통신 방식을 지원하는 C++ 비동기 통신 라이브러리다. 그러나 핵심 목적은 단순히 지원하는 프로토콜의 수를 늘리는 것이 아니라, 서로 다른 통신 방식을 “하나의 일관된 개발 경험”으로 묶어내는 데 있다. TCP, UDP, Serial, Unix Domain Socket은 서로 다른 특성을 가진다. 이 차이를 완전히 없앨 수는 없다. 그리고 없애는 것이 목표도 아니다. -unilink가 지향하는 것은 그 차이가 애플리케이션 코드 전체로 번지지 않도록 경계를 세우는 것이다. +wirestead가 지향하는 것은 그 차이가 애플리케이션 코드 전체로 번지지 않도록 경계를 세우는 것이다. -이를 위해 unilink는 public API를 단순하게 유지하고, transport별 차이는 내부 구현으로 격리하며, 비동기 통신에서 반복적으로 등장하는 문제를 공통 API 개념으로 다룬다. +이를 위해 wirestead는 public API를 단순하게 유지하고, transport별 차이는 내부 구현으로 격리하며, 비동기 통신에서 반복적으로 등장하는 문제를 공통 API 개념으로 다룬다. -결국 unilink의 설계 방향은 다음 문장으로 정리할 수 있다. +결국 wirestead의 설계 방향은 다음 문장으로 정리할 수 있다. > 통신 방식을 하나로 만드는 것이 아니라, 통신 방식의 차이가 애플리케이션 코드에 미치는 영향을 줄이는 것. -이것이 unilink를 설계한 가장 중요한 배경이다. +이것이 wirestead를 설계한 가장 중요한 배경이다. diff --git "a/src/content/blog/2026-06-02-unilink-channel-\354\266\224\354\203\201\355\231\224-\354\204\244\352\263\204.md" "b/src/content/blog/2026-06-02-wirestead-channel-\354\266\224\354\203\201\355\231\224-\354\204\244\352\263\204.md" similarity index 95% rename from "src/content/blog/2026-06-02-unilink-channel-\354\266\224\354\203\201\355\231\224-\354\204\244\352\263\204.md" rename to "src/content/blog/2026-06-02-wirestead-channel-\354\266\224\354\203\201\355\231\224-\354\204\244\352\263\204.md" index d882b77..bd7dd70 100644 --- "a/src/content/blog/2026-06-02-unilink-channel-\354\266\224\354\203\201\355\231\224-\354\204\244\352\263\204.md" +++ "b/src/content/blog/2026-06-02-wirestead-channel-\354\266\224\354\203\201\355\231\224-\354\204\244\352\263\204.md" @@ -1,19 +1,17 @@ --- title: 'Channel 추상화 설계' date: 2026-06-02 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - - async-io - channel - abstraction - - architecture - dependency-injection description: TCP, UDP, Serial, UDS의 차이를 숨기고 애플리케이션이 의존할 공통 Channel 계약을 설계한 기준을 정리했다. -series: 'unilink-design' -seriesOrder: 7 +series: 'wirestead-design' +seriesOrder: 5 draft: false --- @@ -37,7 +35,7 @@ flowchart TD 사용자는 일반적으로 통신 객체를 시작하고, 데이터를 보내고, 수신 이벤트를 받고, 에러를 처리하며, 현재 상태를 확인하고 싶어 한다. 즉, 사용자가 실제로 관심을 갖는 것은 TCP인지 Serial인지보다 “데이터를 안전하고 예측 가능한 방식으로 주고받을 수 있는가”에 가깝다. -unilink의 Channel 계층은 이 관점에서 출발한다. +wirestead의 Channel 계층은 이 관점에서 출발한다. Channel은 특정 transport를 그대로 노출하는 대신, 여러 통신 방식에서 반복되는 통신 행위를 공통 계약으로 정리하기 위한 추상화다. ## 용어 정리: Wrapper, Channel, Transport @@ -180,7 +178,7 @@ flowchart TD CH --> STATS[stats] ``` -이 차이가 unilink Channel 설계의 핵심이다. +이 차이가 wirestead Channel 설계의 핵심이다. 구현 중심으로 보면 TCP, UDP, Serial은 모두 다르지만, 행위 중심으로 보면 공통 계약을 만들 수 있다. ## Channel Interface @@ -337,7 +335,7 @@ flowchart TD ## Channel은 아키텍처 경계다 Channel 계층은 단순한 interface 하나가 아니다. -unilink 아키텍처에서 변경이 넘어가지 않도록 막는 경계다. +wirestead 아키텍처에서 변경이 넘어가지 않도록 막는 경계다. ```mermaid flowchart LR @@ -391,7 +389,7 @@ TCP의 connected 상태와 UDP의 connected-like 상태처럼, transport마다 ## 정리 -unilink에서 Channel은 TCP, UDP, Serial, UDS를 단순히 하나로 묶기 위한 계층이 아니다. +wirestead에서 Channel은 TCP, UDP, Serial, UDS를 단순히 하나로 묶기 위한 계층이 아니다. 더 정확히는 통신 방식의 차이를 뒤로 밀어내고, 애플리케이션과 wrapper가 실제로 필요로 하는 통신 행위를 앞으로 가져오기 위한 계약이다. ```mermaid @@ -426,5 +424,5 @@ mindmap - UDP 같은 비연결형 transport도 Channel 계약에 맞게 runtime state를 해석해야 한다. - 공통 계약은 테스트 가능성, API 안정성, 구현 교체 가능성을 높인다. -Channel 계층은 unilink에서 가장 눈에 잘 띄는 public API는 아닐 수 있다. -하지만 Builder, Wrapper, Transport 구현을 하나의 일관된 구조로 연결하는 핵심 축이며, unilink가 transport 구현이 아니라 통신 행위를 중심으로 설계되었다는 점을 가장 잘 보여주는 계층이다. +Channel 계층은 wirestead에서 가장 눈에 잘 띄는 public API는 아닐 수 있다. +하지만 Builder, Wrapper, Transport 구현을 하나의 일관된 구조로 연결하는 핵심 축이며, wirestead가 transport 구현이 아니라 통신 행위를 중심으로 설계되었다는 점을 가장 잘 보여주는 계층이다. diff --git "a/src/content/blog/2026-06-02-unilink-channelfactory-\354\204\244\352\263\204.md" "b/src/content/blog/2026-06-02-wirestead-channelfactory-\354\204\244\352\263\204.md" similarity index 87% rename from "src/content/blog/2026-06-02-unilink-channelfactory-\354\204\244\352\263\204.md" rename to "src/content/blog/2026-06-02-wirestead-channelfactory-\354\204\244\352\263\204.md" index c9a11bc..6193a8d 100644 --- "a/src/content/blog/2026-06-02-unilink-channelfactory-\354\204\244\352\263\204.md" +++ "b/src/content/blog/2026-06-02-wirestead-channelfactory-\354\204\244\352\263\204.md" @@ -1,19 +1,17 @@ --- title: 'ChannelFactory 설계' date: 2026-06-02 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - - async-io - factory-pattern - channel - - architecture - dependency-injection description: Config를 기반으로 concrete Channel 구현체를 생성하고 Wrapper가 transport 선택을 직접 알지 않도록 분리한 구조를 정리했다. -series: 'unilink-design' -seriesOrder: 6 +series: 'wirestead-design' +seriesOrder: 7 draft: false --- @@ -31,7 +29,7 @@ TCP client 설정이면 TCP client transport가 필요하고, Serial 설정이 이 선택 로직을 wrapper 내부에 모두 넣을 수도 있다. 하지만 그렇게 하면 wrapper는 다시 transport 종류를 알아야 하고, 생성 조건도 직접 관리해야 한다. 결국 wrapper가 transport 차이를 숨기는 계층이 아니라, transport 생성 로직까지 떠안는 계층이 된다. -unilink에서는 이 역할을 `ChannelFactory`로 분리한다. +wirestead에서는 이 역할을 `ChannelFactory`로 분리한다. ```mermaid flowchart TD @@ -214,6 +212,10 @@ Config 타입이 곧 생성 의도를 나타낸다. 개념적으로는 다음과 같은 구조다. ```cpp +// 분기 누락을 컴파일 타임에 잡기 위한 헬퍼 +template +inline constexpr bool always_false_v = false; + std::shared_ptr ChannelFactory::create( const ChannelOptions& options, std::shared_ptr external_ioc) { @@ -223,16 +225,29 @@ std::shared_ptr ChannelFactory::create( if constexpr (std::is_same_v) { return create_tcp_client(config, external_ioc); + } else if constexpr (std::is_same_v) { + return create_tcp_server(config, external_ioc); } else if constexpr (std::is_same_v) { return create_serial(config, external_ioc); } else if constexpr (std::is_same_v) { return create_udp(config, external_ioc); + } else if constexpr (std::is_same_v) { + return create_uds_client(config, external_ioc); + } else if constexpr (std::is_same_v) { + return create_uds_server(config, external_ioc); + } else { + static_assert(always_false_v, + "ChannelOptions에 처리되지 않은 config 타입이 있습니다."); } }, options); } ``` +마지막 `else` 분기의 `static_assert`가 중요하다. 이것이 없으면 `ChannelOptions`에 새 config 타입을 추가하고 분기를 빠뜨렸을 때, 해당 타입으로 인스턴스화된 lambda가 **아무것도 return하지 않고 함수 끝에 도달**한다. 이는 컴파일 에러가 아니라 정의되지 않은 동작(UB)이며, 경고조차 놓치기 쉽다. + +`always_false_v`를 쓰는 이유는 `static_assert(false, ...)`를 그대로 쓰면 template이 인스턴스화되기 전에 무조건 실패하기 때문이다. 타입에 의존하는 false 값을 만들어야 해당 분기가 실제로 선택될 때만 에러가 난다. + 이 구조에는 몇 가지 장점이 있다. 첫째, 지원 가능한 config 타입이 `ChannelOptions`에 명시된다. @@ -273,7 +288,9 @@ flowchart TD 전통적인 `enum` + `switch` 방식이나 문자열 기반 factory에서는 transport type과 config payload가 따로 움직일 수 있다. 예를 들어 type은 TCP인데 config는 Serial용 구조체인 잘못된 조합을 만들 여지가 생긴다. 이런 오류는 런타임에서야 드러날 가능성이 높다. 반면 `std::variant` 기반 구조에서는 factory가 받을 수 있는 config 타입의 목록이 타입 시스템 안에 들어간다. -새로운 transport를 추가하려면 `ChannelOptions`에 config 타입을 추가해야 하고, `std::visit` 분기에서 해당 타입을 처리해야 한다. 이 과정에서 누락된 분기는 컴파일 단계에서 드러날 수 있다. +새로운 transport를 추가하려면 `ChannelOptions`에 config 타입을 추가해야 하고, `std::visit` 분기에서 해당 타입을 처리해야 한다. 앞에서 본 `static_assert` 분기를 두었기 때문에, 이때 분기를 빠뜨리면 컴파일 단계에서 즉시 드러난다. + +주의할 점은 이것이 자동으로 보장되지는 않는다는 것이다. `else` 분기 없이 `if constexpr` 체인만 나열하면 누락된 타입은 조용히 UB가 된다. 타입 기반 dispatch의 안전성은 variant를 쓴다는 사실이 아니라, 처리되지 않은 타입을 명시적으로 막았는지에서 나온다. 또한 `dynamic_cast`나 런타임 타입 검사에 의존하지 않고, config의 실제 타입에 따라 생성 경로를 정할 수 있다. 즉, Factory는 런타임 문자열 비교나 불안정한 downcast보다, 타입으로 생성 경로를 정리하는 방식을 선택했다. @@ -287,7 +304,7 @@ flowchart TD 라이브러리가 내부 `io_context`를 직접 관리할 수도 있고, 사용자가 외부에서 관리하는 `io_context`를 주입할 수도 있다. -unilink의 `ChannelFactory`는 optional external `io_context`를 받는다. +wirestead의 `ChannelFactory`는 optional external `io_context`를 받는다. ```cpp static std::shared_ptr create( @@ -308,7 +325,7 @@ flowchart TD D --> F[Library manages runtime context] ``` -외부 `io_context`를 지원하면 사용자는 여러 channel을 하나의 event loop에 묶거나, 이미 존재하는 application-level runtime에 unilink channel을 통합할 수 있다. +외부 `io_context`를 지원하면 사용자는 여러 channel을 하나의 event loop에 묶거나, 이미 존재하는 application-level runtime에 wirestead channel을 통합할 수 있다. 반대로 external context를 제공하지 않으면 transport가 내부 context를 사용해 동작할 수 있다. @@ -399,7 +416,7 @@ mindmap OS resource handling ``` -이 책임 분리는 unilink 구조에서 중요하다. +이 책임 분리는 wirestead 구조에서 중요하다. Wrapper가 transport 선택까지 담당하면 wrapper는 점점 무거워지고, transport가 늘어날수록 변경 영향을 받게 된다. Factory를 분리하면 transport 추가나 생성 방식 변경이 wrapper에 직접 퍼지는 것을 줄일 수 있다. @@ -508,4 +525,4 @@ mindmap - 새로운 transport가 추가되면 factory의 variant와 생성 함수가 명확한 확장 지점이 된다. Channel 계층이 “통신 행위의 공통 계약”이라면, ChannelFactory는 그 계약을 만족하는 concrete 구현체를 선택하고 생성하는 경계다. -이 구조 덕분에 unilink는 wrapper의 public API를 안정적으로 유지하면서도, 내부 transport 구현과 생성 방식을 독립적으로 확장할 수 있다. +이 구조 덕분에 wirestead는 wrapper의 public API를 안정적으로 유지하면서도, 내부 transport 구현과 생성 방식을 독립적으로 확장할 수 있다. diff --git "a/src/content/blog/2026-06-02-unilink-transport-\352\263\204\354\270\265-\354\204\244\352\263\204.md" "b/src/content/blog/2026-06-02-wirestead-transport-\352\263\204\354\270\265-\354\204\244\352\263\204.md" similarity index 95% rename from "src/content/blog/2026-06-02-unilink-transport-\352\263\204\354\270\265-\354\204\244\352\263\204.md" rename to "src/content/blog/2026-06-02-wirestead-transport-\352\263\204\354\270\265-\354\204\244\352\263\204.md" index e5f8ef2..146c095 100644 --- "a/src/content/blog/2026-06-02-unilink-transport-\352\263\204\354\270\265-\354\204\244\352\263\204.md" +++ "b/src/content/blog/2026-06-02-wirestead-transport-\352\263\204\354\270\265-\354\204\244\352\263\204.md" @@ -1,19 +1,17 @@ --- title: 'Transport 계층 설계' date: 2026-06-02 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - - async-io - transport - boost-asio - - architecture - concurrency description: Channel 추상화를 실제 socket, serial port, event loop 기반 비동기 I/O 구현으로 연결하는 transport 계층을 정리했다. -series: 'unilink-design' -seriesOrder: 5 +series: 'wirestead-design' +seriesOrder: 6 draft: false --- @@ -25,7 +23,7 @@ Wrapper는 Channel 계약에 의존하고, ChannelFactory는 config를 기반으 하지만 결국 데이터는 실제 네트워크 socket이나 serial port를 통해 이동해야 한다. `start`, `stop`, `async_write`, `on_bytes`, `on_state` 같은 Channel 계약은 그 자체로 동작하지 않는다. 이 계약을 TCP, UDP, Serial, UDS 같은 실제 통신 방식에 맞게 구현하는 계층이 필요하다. -unilink에서 이 역할을 담당하는 것이 Transport 계층이다. +wirestead에서 이 역할을 담당하는 것이 Transport 계층이다. Transport는 public API가 아니다. 사용자가 직접 다루기보다는 Wrapper와 Channel 뒤에 숨어 있는 내부 구현 계층이다. 그러나 실제 비동기 I/O, socket 상태, reconnect, send queue, backpressure, error mapping, runtime statistics 같은 핵심 동작은 대부분 이 계층에서 발생한다. @@ -43,7 +41,7 @@ flowchart TD C --> C4[UDS Socket] ``` -Transport 계층은 unilink에서 가장 구현 세부사항이 많은 영역이다. +Transport 계층은 wirestead에서 가장 구현 세부사항이 많은 영역이다. 이 글에서는 Transport를 “프로토콜 구현체”로만 보지 않고, Channel 계약을 실제 비동기 I/O로 변환하는 실행 계층이라는 관점에서 정리한다. ## Transport의 역할 @@ -88,7 +86,7 @@ Transport는 다음을 함께 관리한다. - callback 이벤트 발생 - 통계와 에러 정보 기록 -따라서 Transport는 unilink에서 실제 runtime behavior가 모이는 계층이라고 볼 수 있다. +따라서 Transport는 wirestead에서 실제 runtime behavior가 모이는 계층이라고 볼 수 있다. ## Channel 계약 구현 @@ -97,7 +95,7 @@ Transport는 Channel 계약을 구현한다. 개념적으로 보면 다음과 같다. ```cpp -class TcpClient : public Channel { +class TcpClientTransport : public Channel { public: void start() override; void stop() override; @@ -119,7 +117,7 @@ TCP client는 내부적으로 resolver, socket, timer, strand를 사용하지만 ```mermaid flowchart TD - A[Channel Contract] --> B[TcpClient Transport] + A[Channel Contract] --> B[TcpClientTransport] B --> C[start] B --> D[stop] @@ -133,12 +131,12 @@ flowchart TD 이 구조의 핵심은 Transport가 내부 구현의 자유를 가지면서도, 외부에는 Channel 계약을 유지한다는 점이다. -Wrapper는 `TcpClient` transport인지, `Serial` transport인지 직접 알 필요가 없다. +Wrapper는 `TcpClientTransport`인지 `SerialTransport`인지 직접 알 필요가 없다. Transport는 Channel 계약을 지키는 한 내부 구현을 자유롭게 바꿀 수 있다. ## Boost.Asio 기반 실행 모델 -unilink Transport 계층은 Boost.Asio 기반으로 동작한다. +wirestead Transport 계층은 Boost.Asio 기반으로 동작한다. TCP client를 예로 들면, 내부 구현은 `io_context`, `strand`, `socket`, `resolver`, `steady_timer`, work guard, thread 등을 관리한다. 이 구성은 비동기 connect, read, write, timeout, retry를 처리하기 위한 실행 기반이다. @@ -264,7 +262,7 @@ TCP client의 경우 `start()`는 내부적으로 resolve/connect 흐름을 시 UDP나 Serial은 상태 전이의 의미가 다를 수 있다. UDP는 TCP처럼 연결이 성립되는 구조가 아니며, Serial은 장치 open 상태가 핵심이다. 그러나 Channel 계약은 이런 transport별 차이를 공통 상태 이벤트로 정리한다. -Transport 계층의 역할은 각 protocol-specific 상태를 unilink의 공통 link state로 매핑하는 것이다. +Transport 계층의 역할은 각 protocol-specific 상태를 wirestead의 공통 link state로 매핑하는 것이다. ## Read Path: raw bytes에서 Channel 이벤트로 @@ -319,7 +317,7 @@ flowchart TD G -- Yes --> I[wait in queue] ``` -unilink Transport는 여러 송신 API를 제공한다. +wirestead Transport는 여러 송신 API를 제공한다. - `async_write_copy`: 데이터를 내부 queue로 복사한다. - `async_write_move`: `std::vector`의 ownership을 transport로 이동한다. @@ -348,7 +346,7 @@ mindmap Boost.Asio의 async write는 호출 직후 완료되는 것이 아니라, 나중에 handler에서 완료된다. 따라서 write operation이 완료될 때까지 buffer가 살아 있어야 한다. 만약 caller가 넘긴 임시 buffer나 local buffer를 그대로 참조하면, 실제 write가 수행되기 전에 buffer가 사라져 dangling pointer 문제가 발생할 수 있다. -unilink는 이 문제를 API 차원에서 분리한다. +wirestead는 이 문제를 API 차원에서 분리한다. ```mermaid flowchart TD @@ -582,7 +580,7 @@ TCP 연결 실패와 UDP endpoint 변경, Serial 장치 제거는 서로 다른 ## 정리 -unilink에서 Transport 계층은 Channel 계약을 실제 비동기 I/O로 구현하는 계층이다. +wirestead에서 Transport 계층은 Channel 계약을 실제 비동기 I/O로 구현하는 계층이다. ```mermaid mindmap @@ -625,5 +623,5 @@ mindmap - error와 runtime stats는 transport에서 기록하고 wrapper를 통해 사용자에게 전달된다. - transport-specific concern은 Transport 계층 안에 격리한다. -Transport 계층은 unilink에서 가장 복잡한 내부 구현 계층이다. +Transport 계층은 wirestead에서 가장 복잡한 내부 구현 계층이다. 하지만 이 복잡성을 내부에 모아두기 때문에, 사용자는 Wrapper와 Channel 수준의 단순한 API로 여러 통신 방식을 다룰 수 있다. diff --git "a/src/content/blog/2026-06-02-unilink-wrapper-\352\263\204\354\270\265-\354\204\244\352\263\204.md" "b/src/content/blog/2026-06-02-wirestead-wrapper-\352\263\204\354\270\265-\354\204\244\352\263\204.md" similarity index 93% rename from "src/content/blog/2026-06-02-unilink-wrapper-\352\263\204\354\270\265-\354\204\244\352\263\204.md" rename to "src/content/blog/2026-06-02-wirestead-wrapper-\352\263\204\354\270\265-\354\204\244\352\263\204.md" index 4690825..e22faa9 100644 --- "a/src/content/blog/2026-06-02-unilink-wrapper-\352\263\204\354\270\265-\354\204\244\352\263\204.md" +++ "b/src/content/blog/2026-06-02-wirestead-wrapper-\352\263\204\354\270\265-\354\204\244\352\263\204.md" @@ -1,18 +1,16 @@ --- title: 'Wrapper 계층 설계' date: 2026-06-02 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - - async-io - wrapper - pimpl - api-design - - architecture description: Public API와 transport 구현 사이에서 수명, callback, 실행 상태를 조율하는 Wrapper 계층의 역할과 책임을 정리했다. -series: 'unilink-design' +series: 'wirestead-design' seriesOrder: 4 draft: false --- @@ -22,7 +20,7 @@ draft: false 통신 라이브러리에서 사용자가 실제로 다루는 객체는 내부 transport 구현체가 아니다. 사용자는 socket, acceptor, serial port, `io_context`, async read/write pipeline을 직접 다루기보다, `start`, `send`, `stop`, `on_data`, `on_error` 같은 의미 있는 동작을 호출하고 싶어 한다. -unilink에서 이 역할을 담당하는 계층이 Wrapper다. +wirestead에서 이 역할을 담당하는 계층이 Wrapper다. Wrapper는 public API와 transport implementation 사이에 위치한다. 사용자에게는 단순한 실행 객체처럼 보이지만, 내부적으로는 transport 객체 생성, lifecycle 관리, callback 연결, framer 적용, backpressure 처리, runtime statistics 조회 같은 작업을 중재한다. @@ -131,6 +129,15 @@ Transport는 Boost.Asio socket, acceptor, serial port 같은 OS 또는 라이브 정리하면 Wrapper는 사용자-facing 계층이고, Channel은 내부 통신 추상화이며, Transport는 실제 프로토콜 구현 계층이다. +이름이 헷갈리기 쉬운 지점이 하나 있다. TCP client는 Wrapper 계층에도 있고 Transport 계층에도 있는데, 이 둘은 완전히 다른 클래스다. 이 시리즈에서는 다음 규칙으로 구분한다. + +| 이름 | 계층 | 역할 | +| -------------------- | --------- | ----------------------------------------- | +| `TcpClient` | Wrapper | 사용자가 직접 생성하고 호출하는 실행 객체 | +| `TcpClientTransport` | Transport | `Channel` 계약을 구현하는 내부 I/O 구현체 | + +즉 접미사 `Transport`가 붙으면 내부 구현체이고, 붙지 않으면 사용자-facing wrapper다. + ```mermaid flowchart TD A[User Code] --> B[Wrapper] @@ -192,7 +199,7 @@ Wrapper는 사용자에게 필요한 API만 남기고, 구현 세부사항을 ## PImpl: 구현 세부사항 숨기기 -unilink의 wrapper는 PImpl 구조를 사용한다. +wirestead의 wrapper는 PImpl 구조를 사용한다. 개념적으로는 다음과 같은 형태다. @@ -291,7 +298,7 @@ flowchart TD ## ChannelInterface: 공통 실행 계약 Wrapper는 단순한 concrete class만으로 구성되지 않는다. -unilink는 1:1 통신 모델을 위한 공통 interface로 `ChannelInterface`를 둔다. +wirestead는 1:1 통신 모델을 위한 공통 interface로 `ChannelInterface`를 둔다. `ChannelInterface`는 TCP client, Serial, UDP client, UDS client처럼 point-to-point 성격의 통신 객체가 공통적으로 제공해야 하는 동작을 정의한다. @@ -424,7 +431,7 @@ Wrapper 내부에서 alive marker, mutex, pending promise, handler detach 같은 ## Send API: 정책을 담은 전송 함수 Wrapper의 전송 API는 단순히 `write`를 감싼 것이 아니다. -unilink는 전송 API를 몇 가지 성격으로 나눈다. +wirestead는 전송 API를 몇 가지 성격으로 나눈다. ```mermaid mindmap @@ -600,11 +607,11 @@ Wrapper는 단기적으로는 한 계층을 더 만드는 선택이지만, 장 ## 정리: Wrapper의 역할 -unilink에서 Wrapper는 사용자가 실제로 다루는 실행 객체이자, public API와 transport 구현 사이의 완충 계층이다. +wirestead에서 Wrapper는 사용자가 실제로 다루는 실행 객체이자, public API와 transport 구현 사이의 완충 계층이다. ```mermaid mindmap - root((unilink Wrapper Role)) + root((wirestead Wrapper Role)) Public Execution Object start stop @@ -643,4 +650,4 @@ Wrapper의 핵심은 단순한 위임이 아니다. - lifecycle, thread safety, callback safety를 내부에서 관리한다. - stats와 상태 조회를 통해 운영 관측성을 제공한다. -이 구조 덕분에 unilink는 내부 transport 구현을 확장하거나 개선하면서도, 사용자가 바라보는 API를 비교적 안정적으로 유지할 수 있다. +이 구조 덕분에 wirestead는 내부 transport 구현을 확장하거나 개선하면서도, 사용자가 바라보는 API를 비교적 안정적으로 유지할 수 있다. diff --git "a/src/content/blog/2026-06-03-unilink-memory-buffer-\354\204\244\352\263\204.md" "b/src/content/blog/2026-06-03-unilink-memory-buffer-\354\204\244\352\263\204.md" deleted file mode 100644 index af0acc0..0000000 --- "a/src/content/blog/2026-06-03-unilink-memory-buffer-\354\204\244\352\263\204.md" +++ /dev/null @@ -1,1103 +0,0 @@ ---- -title: 'Memory / buffer 설계' -date: 2026-06-03 -project: unilink -kind: design -tags: - - unilink - - cpp - - async-io - - memory - - buffer - - zero-copy - - ownership - - architecture -description: 비동기 통신에서 buffer lifetime, ownership, zero-copy 선택이 안전성과 성능에 미치는 영향을 기준으로 정리했다. -series: 'unilink-design' -seriesOrder: 9 -draft: false ---- - -## 도입: 통신 데이터는 단순한 byte 배열이 아니다 - -통신 라이브러리에서 데이터는 대부분 byte 배열로 표현된다. -문자열을 보내든, 바이너리 packet을 보내든, 센서 데이터를 보내든, 결국 transport 계층으로 내려가면 byte buffer가 된다. - -하지만 비동기 통신에서 buffer는 단순한 `uint8_t*`나 `std::vector` 이상의 의미를 가진다. -언제까지 살아 있어야 하는지, 누가 소유하는지, 복사해도 되는지, callback 이후에도 보관해도 되는지에 따라 안전성과 성능이 크게 달라진다. - -특히 Boost.Asio 같은 비동기 I/O에서는 write 요청이 호출 직후 완료되지 않는다. -데이터를 넘긴 시점과 실제 I/O가 완료되는 시점이 다르기 때문에, buffer lifetime을 명확히 설계하지 않으면 dangling pointer, 불필요한 copy, memory pressure 같은 문제가 발생할 수 있다. - -```mermaid -flowchart TD - A[Application Data] --> B[Transport API] - B --> C[Async I/O] - C --> D[Completion Handler] - - B --> E{Is buffer still alive until completion?} - E -- Yes --> F[Safe] - E -- No --> G[Dangling / Undefined Behavior] -``` - -unilink의 Memory / Buffer 설계는 이 문제를 다루기 위한 계층이다. -핵심은 view와 ownership을 구분하고, 비동기 경로에서는 buffer lifetime을 API 수준에서 명확히 표현하는 것이다. - -## Buffer 설계에서 중요한 기준 - -비동기 통신에서 buffer를 다룰 때는 최소한 세 가지를 구분해야 한다. - -```mermaid -mindmap - root((Buffer Design Concerns)) - View - no ownership - cheap to pass - callback scope - Ownership - copy - move - shared ownership - Safety - bounds check - max size - lifetime clarity - Performance - avoid unnecessary copy - memory pool - reuse allocation -``` - -첫 번째는 view다. -view는 데이터를 소유하지 않고 바라보기만 한다. 빠르고 가볍지만, 원본 buffer가 사라지면 더 이상 사용할 수 없다. - -두 번째는 ownership이다. -비동기 write처럼 데이터가 나중에 사용되는 경우에는 누가 buffer를 소유하는지 분명해야 한다. 복사해서 내부 소유로 만들 수도 있고, move로 ownership을 넘길 수도 있으며, `shared_ptr`로 공유 lifetime을 유지할 수도 있다. - -세 번째는 안전성이다. -외부 입력이나 큰 payload를 다루는 통신 라이브러리에서는 bounds check, max size, buffer validation이 필요하다. - -unilink는 이 세 가지를 각각 다른 타입과 API로 표현한다. - -## SafeSpan: 소유하지 않는 byte view - -`SafeSpan`은 `std::span` 기반의 memory view다. -데이터를 소유하지 않고, pointer와 size를 함께 들고 있는 가벼운 view 역할을 한다. - -개념적으로 보면 다음과 같다. - -```cpp -template -class SafeSpan : public std::span { -public: - T& at(std::size_t index) const; - SafeSpan subspan(std::size_t offset, std::size_t count) const; -}; - -using ByteSpan = SafeSpan; -using ConstByteSpan = SafeSpan; -``` - -`ConstByteSpan`은 Framer, Transport, Channel callback 등에서 자주 사용된다. -이 타입은 데이터를 복사하지 않고도 byte sequence를 전달할 수 있게 한다. - -```mermaid -flowchart TD - A[Raw buffer] --> B[ConstByteSpan] - B --> C[Transport callback] - B --> D[Framer push_bytes] - B --> E[Message extraction] - - B --> F[No ownership] - B --> G[No allocation] -``` - -View 기반 API의 장점은 명확하다. - -- 복사가 없다. -- 할당이 없다. -- pointer와 size가 함께 이동하므로 raw pointer보다 안전하다. -- callback 경로에서 가볍게 전달할 수 있다. - -하지만 단점도 분명하다. -view는 데이터를 소유하지 않기 때문에, view를 callback 밖으로 저장하면 위험할 수 있다. 원본 buffer의 lifetime이 끝나면 view는 무효가 된다. - -따라서 `ConstByteSpan`은 “읽기 전용 view”이지, “안전하게 오래 보관 가능한 buffer”가 아니다. - -## SafeDataBuffer: 소유권이 있는 안전한 buffer - -`SafeDataBuffer`는 데이터를 소유하는 buffer다. -string, string_view, vector, raw pointer, span 등 다양한 입력에서 생성할 수 있고, 내부적으로 `std::vector`를 보관한다. - -```cpp -class SafeDataBuffer { -public: - explicit SafeDataBuffer(std::string_view data); - explicit SafeDataBuffer(std::vector data); - explicit SafeDataBuffer(const uint8_t* data, size_t size); - explicit SafeDataBuffer(ConstByteSpan span); - - std::string as_string() const; - ConstByteSpan as_span() const noexcept; - const uint8_t* data() const noexcept; - size_t size() const noexcept; - - const uint8_t& at(size_t index) const; -}; -``` - -`SafeDataBuffer`의 역할은 view와 다르다. -데이터를 내부에 복사하거나 move해서 소유하기 때문에, callback 이후에도 안전하게 보관할 수 있다. - -```mermaid -flowchart TD - A[Incoming data view] --> B{Need to store after callback?} - - B -- No --> C[Use ConstByteSpan / string_view] - B -- Yes --> D[Copy into SafeDataBuffer] - - D --> E[Safe ownership] - E --> F[Can store / convert / inspect] -``` - -예를 들어 `MessageContext`는 수신 데이터를 `SafeDataBuffer`로 보관하고, 사용자에게는 view 또는 copy 형태로 접근할 수 있게 한다. - -```cpp -client.on_data([](const unilink::MessageContext& ctx) { - auto view = ctx.data(); // callback scope view - auto copy = ctx.data_as_vector(); // safe to store -}); -``` - -이 설계는 사용자가 lifetime을 선택할 수 있게 한다. - -callback 안에서만 읽을 것이라면 view를 사용하면 된다. -callback 이후에도 보관해야 한다면 copy를 얻어야 한다. - -즉, `SafeDataBuffer`는 안전한 소유권 경계다. - -## View와 Ownership 분리 - -unilink의 buffer 설계에서 가장 중요한 원칙은 view와 ownership을 분리하는 것이다. - -```mermaid -mindmap - root((View vs Ownership)) - ConstByteSpan - non-owning view - no copy - callback scoped - fast path - SafeDataBuffer - owning buffer - copy or move - safe to store - conversion helpers - PooledBuffer - owning transport buffer - reused allocation - RAII release -``` - -모든 곳에서 owning buffer를 사용하면 안전하지만, 불필요한 copy가 늘어난다. -반대로 모든 곳에서 view만 사용하면 빠르지만, lifetime 문제가 생기기 쉽다. - -따라서 unilink는 상황에 따라 다른 타입을 사용한다. - -Transport와 Framer의 내부 경로에서는 `ConstByteSpan`을 사용해 가능한 copy를 줄인다. -사용자 callback이나 저장 가능성이 있는 경계에서는 `SafeDataBuffer`를 사용해 lifetime을 명확히 한다. -Transport 송신 queue처럼 반복 allocation이 부담되는 경로에서는 `PooledBuffer`를 사용할 수 있다. - -이 구분은 성능과 안전성을 동시에 만족하기 위한 설계다. - -## 비동기 write와 buffer lifetime - -비동기 write에서 buffer lifetime은 특히 중요하다. - -다음과 같은 코드는 위험하다. - -```cpp -void send_message(Channel& ch) { - std::vector data = make_payload(); - ch.async_write(data.data(), data.size()); -} // data is destroyed here -``` - -`async_write`가 실제로 데이터를 사용하는 시점은 함수가 끝난 뒤일 수 있다. -이 경우 `data`가 이미 파괴되었다면 write operation은 무효한 memory를 참조할 수 있다. - -unilink는 이 문제를 송신 API에서 명시적으로 나눈다. - -```mermaid -mindmap - root((Async Write Ownership)) - Copy - async_write_copy - transport copies data - safest but copy cost - Move - async_write_move - caller transfers vector ownership - avoids extra copy - Shared - async_write_shared - shared_ptr keeps buffer alive - useful for reused immutable data -``` - -각 API는 buffer lifetime에 대한 다른 의도를 표현한다. - -`async_write_copy`는 caller의 buffer를 내부 queue로 복사한다. -가장 안전하지만 copy 비용이 있다. - -`async_write_move`는 `std::vector`의 ownership을 transport로 넘긴다. -caller는 더 이상 해당 buffer를 소유하지 않고, transport가 write 완료까지 lifetime을 보장한다. - -`async_write_shared`는 `std::shared_ptr>`를 통해 공유 ownership을 유지한다. -동일한 payload를 여러 곳에서 참조하거나, immutable buffer를 여러 비동기 작업에 공유하고 싶을 때 유용하다. - -```mermaid -flowchart TD - A[Send data] --> B{Ownership model} - - B -- copy --> C[Transport owns copied buffer] - C --> D[Safe but alloc/copy cost] - - B -- move --> E[Transport takes ownership] - E --> F[No extra copy when possible] - - B -- shared --> G[Shared immutable buffer] - G --> H[Lifetime until last owner releases] -``` - -중요한 것은 사용자가 의도적으로 선택할 수 있다는 점이다. -“무조건 빠르게”가 아니라, 데이터 성격과 lifetime 요구사항에 따라 안전성과 성능을 선택할 수 있어야 한다. - -## PooledBuffer: 반복 allocation 줄이기 - -통신 라이브러리에서는 작은 buffer allocation이 매우 자주 발생할 수 있다. -특히 high-frequency telemetry나 작은 packet을 많이 주고받는 경우, 매번 `new` / `delete` 또는 vector allocation이 발생하면 성능과 latency에 영향을 줄 수 있다. - -`MemoryPool`은 이런 반복 allocation을 줄이기 위한 계층이다. - -```mermaid -mindmap - root((MemoryPool)) - Buckets - SMALL 1KB - MEDIUM 4KB - LARGE 16KB - XLARGE 64KB - Goal - reuse buffers - reduce allocation overhead - improve latency consistency - Management - acquire - release - pool stats - cleanup -``` - -`PooledBuffer`는 memory pool에서 얻은 buffer를 RAII로 관리한다. - -```cpp -memory::PooledBuffer buffer(size); -if (buffer.valid()) { - std::memcpy(buffer.data(), data.data(), size); - // buffer is returned to pool when PooledBuffer is destroyed -} -``` - -이 구조의 장점은 buffer 반환을 명시적으로 호출하지 않아도 된다는 점이다. -`PooledBuffer`의 lifetime이 끝나면 내부 buffer가 pool로 돌아갈 수 있다. - -```mermaid -flowchart TD - A[Acquire buffer] --> B[PooledBuffer] - B --> C[Use in transport queue] - C --> D[PooledBuffer destroyed] - D --> E[Return to MemoryPool] -``` - -RAII는 memory pool과 잘 맞는다. -pool 사용의 핵심은 acquire와 release가 반드시 짝을 이루어야 한다는 것인데, RAII wrapper를 사용하면 예외나 조기 return 상황에서도 release 누락 가능성을 줄일 수 있다. - -## MemoryPool의 bucket 전략 - -MemoryPool은 모든 크기를 하나의 pool에서 다루지 않는다. -일반적인 통신 payload 크기를 기준으로 여러 bucket을 둔다. - -```mermaid -flowchart TD - A[Requested size] --> B{Bucket} - - B -- <= 1KB --> C[SMALL] - B -- <= 4KB --> D[MEDIUM] - B -- <= 16KB --> E[LARGE] - B -- <= 64KB --> F[XLARGE] - B -- larger --> G[Direct allocation or fallback] -``` - -Bucket을 나누면 자주 쓰이는 크기의 buffer를 재사용하기 쉽다. -작은 메시지와 큰 데이터 전송을 같은 pool에서 다루면 낭비가 생길 수 있는데, 크기별 bucket은 이런 문제를 줄인다. - -`PooledBuffer`는 이러한 pool buffer를 RAII 방식으로 감싸는 객체다. -buffer를 얻고, 사용하고, scope가 끝나면 반환하는 흐름을 객체 lifetime에 묶으면 release 누락 가능성을 줄일 수 있다. - -```mermaid -flowchart TD - A[Acquire buffer] --> B[PooledBuffer] - B --> C[Use in transport queue] - C --> D[PooledBuffer destroyed] - D --> E[Return to MemoryPool] -``` - -다만 memory pool이 항상 이득인 것은 아니다. - -- pool 관리 비용이 있다. -- 잘못된 bucket 크기는 memory 낭비를 만들 수 있다. -- 사용 패턴에 따라 hit rate가 낮을 수 있다. -- 멀티스레드 환경에서는 bucket lock 경합이 생길 수 있다. - -특히 high-throughput 환경에서는 memory pool 자체가 병목이 될 수도 있다. -여러 transport thread가 같은 bucket을 자주 acquire/release하면 mutex 경합이 발생할 수 있고, 이 경우 allocation 비용을 줄이려던 구조가 오히려 latency jitter를 만들 수 있다. - -이런 경합을 줄이기 위해서는 thread-local cache를 두거나, 특정 hot path에는 lock-free 구조를 적용하거나, transport별 pool을 분리하는 방식도 고려할 수 있다. -즉, MemoryPool은 단순한 최적화 장치가 아니라 workload와 동시성 수준에 맞춰 조정되어야 하는 성능 계층이다. - -따라서 MemoryPool의 목적은 모든 allocation을 무조건 pool로 대체하는 것이 아니다. -반복 allocation이 많은 hot path에서 선택적으로 사용할 수 있는 기반을 제공하고, 필요에 따라 pool 크기와 bucket 정책을 조정할 수 있게 하는 것이다. - -## 수신 경로의 lifetime - -수신 경로에서는 Transport가 raw bytes를 읽고, Wrapper 또는 Framer가 이를 처리한다. - -```mermaid -flowchart TD - A[Transport receive buffer] --> B[ConstByteSpan] - B --> C[Framer scan] - C --> D{Complete message?} - - D -- No --> E[Copy partial data to internal buffer] - D -- Yes --> F[on_message callback] -``` - -Framer가 현재 chunk 안에서 메시지를 바로 찾을 수 있다면, `ConstByteSpan` view를 통해 별도 allocation 없이 callback으로 전달할 수 있다. - -반면 메시지가 chunk 경계에 걸치면 partial data를 내부 buffer에 복사해야 한다. -이 복사는 피할 수 없는 비용이다. 메시지를 완성하기 위해 현재 chunk 이후의 데이터가 필요하기 때문이다. - -즉, 수신 경로의 원칙은 다음과 같다. - -- 즉시 처리 가능한 데이터는 view로 처리한다. -- 이후에도 필요한 partial data는 owning buffer로 복사한다. -- 사용자 callback 이후에도 보관해야 한다면 명시적으로 copy한다. - -이 원칙을 지키면 zero-copy 가능성과 lifetime safety를 동시에 확보할 수 있다. - -## callback scope와 저장 가능한 데이터 - -사용자 callback에서 가장 흔한 실수 중 하나는 view를 오래 보관하는 것이다. - -```cpp -std::string_view saved; - -client.on_data([&](const unilink::MessageContext& ctx) { - saved = ctx.data(); // dangerous if used after callback -}); -``` - -`ctx.data()`가 반환하는 view는 callback scope 안에서만 안전하다. -callback 이후에도 데이터를 보관해야 한다면 owning copy를 만들어야 한다. - -```cpp -std::vector saved; - -client.on_data([&](const unilink::MessageContext& ctx) { - saved = ctx.data_as_vector(); // safe to store -}); -``` - -이 차이를 API로 명확히 드러내는 것이 중요하다. - -```mermaid -flowchart TD - A[Callback receives MessageContext] --> B{Need to store data?} - - B -- No --> C[Use data view] - C --> D[No copy] - - B -- Yes --> E[Use data_as_vector / data_as_string] - E --> F[Owning copy] -``` - -unilink의 MessageContext는 view와 copy API를 함께 제공한다. -이는 사용자가 성능과 안전성 사이에서 의식적인 선택을 하도록 만드는 API 설계다. - -## Zero-copy의 의미와 한계 - -Zero-copy는 매력적인 표현이지만, 통신 라이브러리에서 항상 가능한 것은 아니다. - -```mermaid -mindmap - root((Zero-copy Reality)) - Possible - callback-scope view - direct scan by ConstByteSpan - move ownership send - shared immutable buffer - Not always possible - partial message buffering - user stores data - protocol transformation - OS/kernel copy -``` - -unilink에서 zero-copy는 “항상 복사가 없다”는 의미가 아니다. -그보다는 불필요한 복사를 피하고, 복사가 필요한 지점을 명확히 한다는 의미에 가깝다. - -예를 들어 Framer가 현재 chunk 안에서 complete message를 찾으면 view만으로 처리할 수 있다. -하지만 partial message가 남으면 내부 buffer에 복사해야 한다. - -비동기 write에서도 마찬가지다. -caller가 데이터를 넘긴 뒤 바로 소멸될 수 있다면 transport는 copy하거나 ownership을 가져야 한다. -move나 shared ownership을 사용하면 copy를 줄일 수 있지만, lifetime 관리는 여전히 필요하다. - -즉, zero-copy는 목표이지만 절대 원칙은 아니다. -안전하지 않은 zero-copy보다 명확한 ownership이 더 중요하다. - -## Memory / Buffer 설계의 Trade-off - -Memory / Buffer 계층은 성능과 안전성 사이의 trade-off를 다룬다. - -```mermaid -mindmap - root((Memory / Buffer Trade-offs)) - View - no copy - fast - lifetime risk - Owning Buffer - safe to store - clear lifetime - copy cost - Move Ownership - fewer copies - caller gives up ownership - API discipline required - Shared Ownership - lifetime safe - shared reuse - ref count overhead - Memory Pool - allocation reuse - lower jitter - pool management cost - contention risk -``` - -모든 데이터를 copy하면 안전하지만 느릴 수 있다. -모든 데이터를 view로 처리하면 빠르지만 lifetime 문제가 생긴다. -move는 효율적이지만 caller의 ownership을 명확히 포기해야 한다. -shared ownership은 안전하지만 reference count overhead가 있다. -memory pool은 allocation 비용을 줄이지만 pool 관리 비용과 thread contention 가능성이 있다. - -따라서 중요한 것은 하나의 방식을 강제하지 않는 것이다. -데이터 성격과 경로에 맞는 선택지를 제공해야 한다. - -## 정리 - -unilink에서 Memory / Buffer 설계는 비동기 통신의 lifetime과 ownership 문제를 다루기 위한 계층이다. - -```mermaid -mindmap - root((Memory / Buffer Role)) - View - ConstByteSpan - SafeSpan - callback scope - Ownership - SafeDataBuffer - copy - move - shared buffer - Async Safety - write lifetime - callback lifetime - partial message buffer - Performance - zero-copy fast path - memory pool - allocation reuse - Concurrency - bucket lock - contention risk - thread-local cache option - API Clarity - data - data_as_vector - async_write_copy - async_write_move - async_write_shared -``` - -정리하면 다음과 같다. - -- `ConstByteSpan`은 소유권 없는 view로, callback 경로에서 불필요한 copy를 줄인다. -- `SafeDataBuffer`는 데이터를 소유하는 안전한 buffer로, callback 이후에도 보관 가능한 데이터를 제공한다. -- 비동기 write에서는 buffer가 완료 시점까지 살아 있어야 하므로 ownership 모델이 명확해야 한다. -- `copy`, `move`, `shared` 송신 API는 서로 다른 lifetime / performance trade-off를 표현한다. -- `PooledBuffer`와 `MemoryPool`은 반복 allocation을 줄이기 위한 최적화 기반이다. -- MemoryPool은 allocation 비용을 줄일 수 있지만, 멀티스레드 환경에서는 bucket lock 경합도 함께 고려해야 한다. -- Framer는 즉시 처리 가능한 데이터는 view로 처리하고, partial message는 내부 buffer로 보관한다. -- Zero-copy는 절대 원칙이 아니라, 안전성이 확보되는 범위에서 불필요한 복사를 줄이는 전략이다. - -통신 라이브러리에서 buffer 설계는 단순한 성능 최적화가 아니다. -비동기 실행 모델에서 데이터가 언제까지 살아 있어야 하는지, 누가 소유하는지, 언제 복사해야 하는지를 명확히 하는 안정성 설계다. - -unilink는 view, owning buffer, move ownership, shared ownership, memory pool을 구분함으로써 성능과 안전성 사이의 선택지를 API와 내부 구조에 반영한다. - -# unilink Memory / Buffer 설계 - -> 비동기 통신에서 Lifetime과 Ownership을 명확히 하기 - -## 도입: 통신 데이터는 단순한 byte 배열이 아니다 - -통신 라이브러리에서 데이터는 대부분 byte 배열로 표현된다. -문자열을 보내든, 바이너리 packet을 보내든, 센서 데이터를 보내든, 결국 transport 계층으로 내려가면 byte buffer가 된다. - -하지만 비동기 통신에서 buffer는 단순한 `uint8_t*`나 `std::vector` 이상의 의미를 가진다. -언제까지 살아 있어야 하는지, 누가 소유하는지, 복사해도 되는지, callback 이후에도 보관해도 되는지에 따라 안전성과 성능이 크게 달라진다. - -특히 Boost.Asio 같은 비동기 I/O에서는 write 요청이 호출 직후 완료되지 않는다. -데이터를 넘긴 시점과 실제 I/O가 완료되는 시점이 다르기 때문에, buffer lifetime을 명확히 설계하지 않으면 dangling pointer, 불필요한 copy, memory pressure 같은 문제가 발생할 수 있다. - -```mermaid -flowchart TD - A[Application Data] --> B[Transport API] - B --> C[Async I/O] - C --> D[Completion Handler] - - B --> E{Is buffer still alive until completion?} - E -- Yes --> F[Safe] - E -- No --> G[Dangling / Undefined Behavior] -``` - -unilink의 Memory / Buffer 설계는 이 문제를 다루기 위한 계층이다. -핵심은 view와 ownership을 구분하고, 비동기 경로에서는 buffer lifetime을 API 수준에서 명확히 표현하는 것이다. - -## Buffer 설계에서 중요한 기준 - -비동기 통신에서 buffer를 다룰 때는 최소한 세 가지를 구분해야 한다. - -```mermaid -mindmap - root((Buffer Design Concerns)) - View - no ownership - cheap to pass - callback scope - Ownership - copy - move - shared ownership - Safety - bounds check - max size - lifetime clarity - Performance - avoid unnecessary copy - memory pool - reuse allocation -``` - -첫 번째는 view다. -view는 데이터를 소유하지 않고 바라보기만 한다. 빠르고 가볍지만, 원본 buffer가 사라지면 더 이상 사용할 수 없다. - -두 번째는 ownership이다. -비동기 write처럼 데이터가 나중에 사용되는 경우에는 누가 buffer를 소유하는지 분명해야 한다. 복사해서 내부 소유로 만들 수도 있고, move로 ownership을 넘길 수도 있으며, `shared_ptr`로 공유 lifetime을 유지할 수도 있다. - -세 번째는 안전성이다. -외부 입력이나 큰 payload를 다루는 통신 라이브러리에서는 bounds check, max size, buffer validation이 필요하다. - -unilink는 이 세 가지를 각각 다른 타입과 API로 표현한다. - -## SafeSpan: 소유하지 않는 byte view - -`SafeSpan`은 `std::span` 기반의 memory view다. -데이터를 소유하지 않고, pointer와 size를 함께 들고 있는 가벼운 view 역할을 한다. - -개념적으로 보면 다음과 같다. - -```cpp -template -class SafeSpan : public std::span { -public: - T& at(std::size_t index) const; - SafeSpan subspan(std::size_t offset, std::size_t count) const; -}; - -using ByteSpan = SafeSpan; -using ConstByteSpan = SafeSpan; -``` - -`ConstByteSpan`은 Framer, Transport, Channel callback 등에서 자주 사용된다. -이 타입은 데이터를 복사하지 않고도 byte sequence를 전달할 수 있게 한다. - -```mermaid -flowchart TD - A[Raw buffer] --> B[ConstByteSpan] - B --> C[Transport callback] - B --> D[Framer push_bytes] - B --> E[Message extraction] - - B --> F[No ownership] - B --> G[No allocation] -``` - -View 기반 API의 장점은 명확하다. - -- 복사가 없다. -- 할당이 없다. -- pointer와 size가 함께 이동하므로 raw pointer보다 안전하다. -- callback 경로에서 가볍게 전달할 수 있다. - -하지만 단점도 분명하다. -view는 데이터를 소유하지 않기 때문에, view를 callback 밖으로 저장하면 위험할 수 있다. 원본 buffer의 lifetime이 끝나면 view는 무효가 된다. - -따라서 `ConstByteSpan`은 “읽기 전용 view”이지, “안전하게 오래 보관 가능한 buffer”가 아니다. - -## SafeDataBuffer: 소유권이 있는 안전한 buffer - -`SafeDataBuffer`는 데이터를 소유하는 buffer다. -string, string_view, vector, raw pointer, span 등 다양한 입력에서 생성할 수 있고, 내부적으로 `std::vector`를 보관한다. - -```cpp -class SafeDataBuffer { -public: - explicit SafeDataBuffer(std::string_view data); - explicit SafeDataBuffer(std::vector data); - explicit SafeDataBuffer(const uint8_t* data, size_t size); - explicit SafeDataBuffer(ConstByteSpan span); - - std::string as_string() const; - ConstByteSpan as_span() const noexcept; - const uint8_t* data() const noexcept; - size_t size() const noexcept; - - const uint8_t& at(size_t index) const; -}; -``` - -`SafeDataBuffer`의 역할은 view와 다르다. -데이터를 내부에 복사하거나 move해서 소유하기 때문에, callback 이후에도 안전하게 보관할 수 있다. - -```mermaid -flowchart TD - A[Incoming data view] --> B{Need to store after callback?} - - B -- No --> C[Use ConstByteSpan / string_view] - B -- Yes --> D[Copy into SafeDataBuffer] - - D --> E[Safe ownership] - E --> F[Can store / convert / inspect] -``` - -예를 들어 `MessageContext`는 수신 데이터를 `SafeDataBuffer`로 보관하고, 사용자에게는 view 또는 copy 형태로 접근할 수 있게 한다. - -```cpp -client.on_data([](const unilink::MessageContext& ctx) { - auto view = ctx.data(); // callback scope view - auto copy = ctx.data_as_vector(); // safe to store -}); -``` - -이 설계는 사용자가 lifetime을 선택할 수 있게 한다. - -callback 안에서만 읽을 것이라면 view를 사용하면 된다. -callback 이후에도 보관해야 한다면 copy를 얻어야 한다. - -즉, `SafeDataBuffer`는 안전한 소유권 경계다. - -## View와 Ownership 분리 - -unilink의 buffer 설계에서 가장 중요한 원칙은 view와 ownership을 분리하는 것이다. - -```mermaid -mindmap - root((View vs Ownership)) - ConstByteSpan - non-owning view - no copy - callback scoped - fast path - SafeDataBuffer - owning buffer - copy or move - safe to store - conversion helpers - PooledBuffer - owning transport buffer - reused allocation - RAII release -``` - -모든 곳에서 owning buffer를 사용하면 안전하지만, 불필요한 copy가 늘어난다. -반대로 모든 곳에서 view만 사용하면 빠르지만, lifetime 문제가 생기기 쉽다. - -따라서 unilink는 상황에 따라 다른 타입을 사용한다. - -Transport와 Framer의 내부 경로에서는 `ConstByteSpan`을 사용해 가능한 copy를 줄인다. -사용자 callback이나 저장 가능성이 있는 경계에서는 `SafeDataBuffer`를 사용해 lifetime을 명확히 한다. -Transport 송신 queue처럼 반복 allocation이 부담되는 경로에서는 `PooledBuffer`를 사용할 수 있다. - -이 구분은 성능과 안전성을 동시에 만족하기 위한 설계다. - -## 비동기 write와 buffer lifetime - -비동기 write에서 buffer lifetime은 특히 중요하다. - -다음과 같은 코드는 위험하다. - -```cpp -void send_message(Channel& ch) { - std::vector data = make_payload(); - ch.async_write(data.data(), data.size()); -} // data is destroyed here -``` - -`async_write`가 실제로 데이터를 사용하는 시점은 함수가 끝난 뒤일 수 있다. -이 경우 `data`가 이미 파괴되었다면 write operation은 무효한 memory를 참조할 수 있다. - -unilink는 이 문제를 송신 API에서 명시적으로 나눈다. - -```mermaid -mindmap - root((Async Write Ownership)) - Copy - async_write_copy - transport copies data - safest but copy cost - Move - async_write_move - caller transfers vector ownership - avoids extra copy - Shared - async_write_shared - shared_ptr keeps buffer alive - useful for reused immutable data -``` - -각 API는 buffer lifetime에 대한 다른 의도를 표현한다. - -`async_write_copy`는 caller의 buffer를 내부 queue로 복사한다. -가장 안전하지만 copy 비용이 있다. - -`async_write_move`는 `std::vector`의 ownership을 transport로 넘긴다. -caller는 더 이상 해당 buffer를 소유하지 않고, transport가 write 완료까지 lifetime을 보장한다. - -`async_write_shared`는 `std::shared_ptr>`를 통해 공유 ownership을 유지한다. -동일한 payload를 여러 곳에서 참조하거나, immutable buffer를 여러 비동기 작업에 공유하고 싶을 때 유용하다. - -```mermaid -flowchart TD - A[Send data] --> B{Ownership model} - - B -- copy --> C[Transport owns copied buffer] - C --> D[Safe but alloc/copy cost] - - B -- move --> E[Transport takes ownership] - E --> F[No extra copy when possible] - - B -- shared --> G[Shared immutable buffer] - G --> H[Lifetime until last owner releases] -``` - -중요한 것은 사용자가 의도적으로 선택할 수 있다는 점이다. -“무조건 빠르게”가 아니라, 데이터 성격과 lifetime 요구사항에 따라 안전성과 성능을 선택할 수 있어야 한다. - -## PooledBuffer: 반복 allocation 줄이기 - -통신 라이브러리에서는 작은 buffer allocation이 매우 자주 발생할 수 있다. -특히 high-frequency telemetry나 작은 packet을 많이 주고받는 경우, 매번 `new` / `delete` 또는 vector allocation이 발생하면 성능과 latency에 영향을 줄 수 있다. - -`MemoryPool`은 이런 반복 allocation을 줄이기 위한 계층이다. - -```mermaid -mindmap - root((MemoryPool)) - Buckets - SMALL 1KB - MEDIUM 4KB - LARGE 16KB - XLARGE 64KB - Goal - reuse buffers - reduce allocation overhead - improve latency consistency - Management - acquire - release - pool stats - cleanup -``` - -`PooledBuffer`는 memory pool에서 얻은 buffer를 RAII로 관리한다. - -```cpp -memory::PooledBuffer buffer(size); -if (buffer.valid()) { - std::memcpy(buffer.data(), data.data(), size); - // buffer is returned to pool when PooledBuffer is destroyed -} -``` - -이 구조의 장점은 buffer 반환을 명시적으로 호출하지 않아도 된다는 점이다. -`PooledBuffer`의 lifetime이 끝나면 내부 buffer가 pool로 돌아갈 수 있다. - -```mermaid -flowchart TD - A[Acquire buffer] --> B[PooledBuffer] - B --> C[Use in transport queue] - C --> D[PooledBuffer destroyed] - D --> E[Return to MemoryPool] -``` - -RAII는 memory pool과 잘 맞는다. -pool 사용의 핵심은 acquire와 release가 반드시 짝을 이루어야 한다는 것인데, RAII wrapper를 사용하면 예외나 조기 return 상황에서도 release 누락 가능성을 줄일 수 있다. - -## MemoryPool의 bucket 전략 - -MemoryPool은 모든 크기를 하나의 pool에서 다루지 않는다. -일반적인 통신 payload 크기를 기준으로 여러 bucket을 둔다. - -```mermaid -flowchart TD - A[Requested size] --> B{Bucket} - - B -- <= 1KB --> C[SMALL] - B -- <= 4KB --> D[MEDIUM] - B -- <= 16KB --> E[LARGE] - B -- <= 64KB --> F[XLARGE] - B -- larger --> G[Direct allocation or fallback] -``` - -Bucket을 나누면 자주 쓰이는 크기의 buffer를 재사용하기 쉽다. -작은 메시지와 큰 데이터 전송을 같은 pool에서 다루면 낭비가 생길 수 있는데, 크기별 bucket은 이런 문제를 줄인다. - -`PooledBuffer`는 이러한 pool buffer를 RAII 방식으로 감싸는 객체다. -buffer를 얻고, 사용하고, scope가 끝나면 반환하는 흐름을 객체 lifetime에 묶으면 release 누락 가능성을 줄일 수 있다. - -```mermaid -flowchart TD - A[Acquire buffer] --> B[PooledBuffer] - B --> C[Use in transport queue] - C --> D[PooledBuffer destroyed] - D --> E[Return to MemoryPool] -``` - -다만 memory pool이 항상 이득인 것은 아니다. - -- pool 관리 비용이 있다. -- 잘못된 bucket 크기는 memory 낭비를 만들 수 있다. -- 사용 패턴에 따라 hit rate가 낮을 수 있다. -- 멀티스레드 환경에서는 bucket lock 경합이 생길 수 있다. - -특히 high-throughput 환경에서는 memory pool 자체가 병목이 될 수도 있다. -여러 transport thread가 같은 bucket을 자주 acquire/release하면 mutex 경합이 발생할 수 있고, 이 경우 allocation 비용을 줄이려던 구조가 오히려 latency jitter를 만들 수 있다. - -이런 경합을 줄이기 위해서는 thread-local cache를 두거나, 특정 hot path에는 lock-free 구조를 적용하거나, transport별 pool을 분리하는 방식도 고려할 수 있다. -즉, MemoryPool은 단순한 최적화 장치가 아니라 workload와 동시성 수준에 맞춰 조정되어야 하는 성능 계층이다. - -따라서 MemoryPool의 목적은 모든 allocation을 무조건 pool로 대체하는 것이 아니다. -반복 allocation이 많은 hot path에서 선택적으로 사용할 수 있는 기반을 제공하고, 필요에 따라 pool 크기와 bucket 정책을 조정할 수 있게 하는 것이다. - -## 수신 경로의 lifetime - -수신 경로에서는 Transport가 raw bytes를 읽고, Wrapper 또는 Framer가 이를 처리한다. - -```mermaid -flowchart TD - A[Transport receive buffer] --> B[ConstByteSpan] - B --> C[Framer scan] - C --> D{Complete message?} - - D -- No --> E[Copy partial data to internal buffer] - D -- Yes --> F[on_message callback] -``` - -Framer가 현재 chunk 안에서 메시지를 바로 찾을 수 있다면, `ConstByteSpan` view를 통해 별도 allocation 없이 callback으로 전달할 수 있다. - -반면 메시지가 chunk 경계에 걸치면 partial data를 내부 buffer에 복사해야 한다. -이 복사는 피할 수 없는 비용이다. 메시지를 완성하기 위해 현재 chunk 이후의 데이터가 필요하기 때문이다. - -즉, 수신 경로의 원칙은 다음과 같다. - -- 즉시 처리 가능한 데이터는 view로 처리한다. -- 이후에도 필요한 partial data는 owning buffer로 복사한다. -- 사용자 callback 이후에도 보관해야 한다면 명시적으로 copy한다. - -이 원칙을 지키면 zero-copy 가능성과 lifetime safety를 동시에 확보할 수 있다. - -## callback scope와 저장 가능한 데이터 - -사용자 callback에서 가장 흔한 실수 중 하나는 view를 오래 보관하는 것이다. - -```cpp -std::string_view saved; - -client.on_data([&](const unilink::MessageContext& ctx) { - saved = ctx.data(); // dangerous if used after callback -}); -``` - -`ctx.data()`가 반환하는 view는 callback scope 안에서만 안전하다. -callback 이후에도 데이터를 보관해야 한다면 owning copy를 만들어야 한다. - -```cpp -std::vector saved; - -client.on_data([&](const unilink::MessageContext& ctx) { - saved = ctx.data_as_vector(); // safe to store -}); -``` - -이 차이를 API로 명확히 드러내는 것이 중요하다. - -```mermaid -flowchart TD - A[Callback receives MessageContext] --> B{Need to store data?} - - B -- No --> C[Use data view] - C --> D[No copy] - - B -- Yes --> E[Use data_as_vector / data_as_string] - E --> F[Owning copy] -``` - -unilink의 MessageContext는 view와 copy API를 함께 제공한다. -이는 사용자가 성능과 안전성 사이에서 의식적인 선택을 하도록 만드는 API 설계다. - -## Zero-copy의 의미와 한계 - -Zero-copy는 매력적인 표현이지만, 통신 라이브러리에서 항상 가능한 것은 아니다. - -```mermaid -mindmap - root((Zero-copy Reality)) - Possible - callback-scope view - direct scan by ConstByteSpan - move ownership send - shared immutable buffer - Not always possible - partial message buffering - user stores data - protocol transformation - OS/kernel copy -``` - -unilink에서 zero-copy는 “항상 복사가 없다”는 의미가 아니다. -그보다는 불필요한 복사를 피하고, 복사가 필요한 지점을 명확히 한다는 의미에 가깝다. - -예를 들어 Framer가 현재 chunk 안에서 complete message를 찾으면 view만으로 처리할 수 있다. -하지만 partial message가 남으면 내부 buffer에 복사해야 한다. - -비동기 write에서도 마찬가지다. -caller가 데이터를 넘긴 뒤 바로 소멸될 수 있다면 transport는 copy하거나 ownership을 가져야 한다. -move나 shared ownership을 사용하면 copy를 줄일 수 있지만, lifetime 관리는 여전히 필요하다. - -즉, zero-copy는 목표이지만 절대 원칙은 아니다. -안전하지 않은 zero-copy보다 명확한 ownership이 더 중요하다. - -## Memory / Buffer 설계의 Trade-off - -Memory / Buffer 계층은 성능과 안전성 사이의 trade-off를 다룬다. - -```mermaid -mindmap - root((Memory / Buffer Trade-offs)) - View - no copy - fast - lifetime risk - Owning Buffer - safe to store - clear lifetime - copy cost - Move Ownership - fewer copies - caller gives up ownership - API discipline required - Shared Ownership - lifetime safe - shared reuse - ref count overhead - Memory Pool - allocation reuse - lower jitter - pool management cost - contention risk -``` - -모든 데이터를 copy하면 안전하지만 느릴 수 있다. -모든 데이터를 view로 처리하면 빠르지만 lifetime 문제가 생긴다. -move는 효율적이지만 caller의 ownership을 명확히 포기해야 한다. -shared ownership은 안전하지만 reference count overhead가 있다. -memory pool은 allocation 비용을 줄이지만 pool 관리 비용과 thread contention 가능성이 있다. - -따라서 중요한 것은 하나의 방식을 강제하지 않는 것이다. -데이터 성격과 경로에 맞는 선택지를 제공해야 한다. - -## 정리 - -unilink에서 Memory / Buffer 설계는 비동기 통신의 lifetime과 ownership 문제를 다루기 위한 계층이다. - -```mermaid -mindmap - root((Memory / Buffer Role)) - View - ConstByteSpan - SafeSpan - callback scope - Ownership - SafeDataBuffer - copy - move - shared buffer - Async Safety - write lifetime - callback lifetime - partial message buffer - Performance - zero-copy fast path - memory pool - allocation reuse - Concurrency - bucket lock - contention risk - thread-local cache option - API Clarity - data - data_as_vector - async_write_copy - async_write_move - async_write_shared -``` - -정리하면 다음과 같다. - -- `ConstByteSpan`은 소유권 없는 view로, callback 경로에서 불필요한 copy를 줄인다. -- `SafeDataBuffer`는 데이터를 소유하는 안전한 buffer로, callback 이후에도 보관 가능한 데이터를 제공한다. -- 비동기 write에서는 buffer가 완료 시점까지 살아 있어야 하므로 ownership 모델이 명확해야 한다. -- `copy`, `move`, `shared` 송신 API는 서로 다른 lifetime / performance trade-off를 표현한다. -- `PooledBuffer`와 `MemoryPool`은 반복 allocation을 줄이기 위한 최적화 기반이다. -- MemoryPool은 allocation 비용을 줄일 수 있지만, 멀티스레드 환경에서는 bucket lock 경합도 함께 고려해야 한다. -- Framer는 즉시 처리 가능한 데이터는 view로 처리하고, partial message는 내부 buffer로 보관한다. -- Zero-copy는 절대 원칙이 아니라, 안전성이 확보되는 범위에서 불필요한 복사를 줄이는 전략이다. - -통신 라이브러리에서 buffer 설계는 단순한 성능 최적화가 아니다. -비동기 실행 모델에서 데이터가 언제까지 살아 있어야 하는지, 누가 소유하는지, 언제 복사해야 하는지를 명확히 하는 안정성 설계다. - -unilink는 view, owning buffer, move ownership, shared ownership, memory pool을 구분함으로써 성능과 안전성 사이의 선택지를 API와 내부 구조에 반영한다. diff --git "a/src/content/blog/2026-06-03-unilink-backpressure-\354\204\244\352\263\204.md" "b/src/content/blog/2026-06-03-wirestead-backpressure-\354\204\244\352\263\204.md" similarity index 92% rename from "src/content/blog/2026-06-03-unilink-backpressure-\354\204\244\352\263\204.md" rename to "src/content/blog/2026-06-03-wirestead-backpressure-\354\204\244\352\263\204.md" index ec70af4..8fdce39 100644 --- "a/src/content/blog/2026-06-03-unilink-backpressure-\354\204\244\352\263\204.md" +++ "b/src/content/blog/2026-06-03-wirestead-backpressure-\354\204\244\352\263\204.md" @@ -1,19 +1,17 @@ --- title: 'Backpressure 설계' date: 2026-06-03 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - - async-io - backpressure - queue - realtime - - architecture -description: 'Reliable / BestEffort 채널에서 send(), queue pressure, drop 정책, backpressure 처리 기준을 정리합니다.' -series: 'unilink-design' -seriesOrder: 11 +description: Reliable / BestEffort 채널에서 send(), queue pressure, drop 정책, backpressure 처리 기준을 정리했다. +series: 'wirestead-design' +seriesOrder: 10 draft: false --- @@ -44,7 +42,7 @@ Backpressure 설계는 이 지점에서 필요하다. > 송신 queue가 압박을 받을 때, 데이터를 보존할 것인가, 아니면 최신성을 위해 일부 데이터를 버릴 것인가? -unilink는 이 선택을 `Reliable`과 `BestEffort`라는 두 가지 전략으로 나눈다. +wirestead는 이 선택을 `Reliable`과 `BestEffort`라는 두 가지 전략으로 나눈다. ## Backpressure가 필요한 이유 @@ -79,7 +77,7 @@ Backpressure가 없다면 일반적으로 두 가지 문제가 생긴다. ## 두 가지 전략: Reliable과 BestEffort -unilink의 Backpressure 전략은 크게 두 가지다. +wirestead의 Backpressure 전략은 크게 두 가지다. ```mermaid mindmap @@ -180,7 +178,7 @@ flowchart TD E -- Yes --> F[Enqueue latest data] ``` -unilink의 keep-latest 계열 동작은 새 데이터가 threshold보다 큰 경우 기존 queue를 비우는 방향으로 동작할 수 있다. 그렇지 않은 경우에는 새 데이터가 들어갈 수 있을 때까지 오래된 queue 항목을 제거한다. +wirestead의 keep-latest 계열 동작은 새 데이터가 threshold보다 큰 경우 기존 queue를 비우는 방향으로 동작할 수 있다. 그렇지 않은 경우에는 새 데이터가 들어갈 수 있을 때까지 오래된 queue 항목을 제거한다. 이 전략은 모든 메시지를 보장하지 않는다. 대신 queue가 오래된 데이터로 가득 차는 것을 막고, 가능한 최신 상태를 유지한다. @@ -207,7 +205,7 @@ BestEffort는 “덜 중요한 데이터”를 위한 전략이 아니다. Backpressure는 단순히 queue가 비어 있는지 아닌지를 보는 문제가 아니다. 어느 정도부터 압박 상태로 볼 것인지 기준이 필요하다. -unilink는 threshold를 기준으로 backpressure 상태를 판단한다. +wirestead는 threshold를 기준으로 backpressure 상태를 판단한다. ```mermaid flowchart TD @@ -289,7 +287,7 @@ flowchart TD 동일한 “보내기”라도 데이터의 중요도와 호출자의 의도에 따라 다른 의미를 가질 수 있기 때문이다. 다만 `send_blocking()`은 신중하게 사용해야 한다. -unilink는 비동기 I/O 기반으로 동작하므로, `io_context`를 실행하는 스레드나 latency-sensitive한 callback 내부에서 blocking send를 남용하면 전체 통신 루프의 응답성이 떨어질 수 있다. +wirestead는 비동기 I/O 기반으로 동작하므로, `io_context`를 실행하는 스레드나 latency-sensitive한 callback 내부에서 blocking send를 남용하면 전체 통신 루프의 응답성이 떨어질 수 있다. 따라서 `send_blocking()`은 초기화 단계, 별도 worker thread, 또는 호출자가 blocking 비용을 명확히 감수할 수 있는 제한적인 상황에서 사용하는 것이 적절하다. 실시간성이 중요한 경로에서는 `send()`와 `try_send()`를 통해 configured strategy를 따르거나, 데이터 성격에 맞게 Reliable / BestEffort 전략을 분리하는 편이 더 안전하다. @@ -314,7 +312,7 @@ flowchart TD Backpressure는 내부 queue 관리만의 문제가 아니다. 운영 중인 시스템에서는 queue pressure가 언제 발생했는지, 얼마나 자주 발생하는지, drop이 발생했는지 확인할 수 있어야 한다. -unilink는 backpressure 상태를 callback과 runtime stats로 관측할 수 있게 한다. +wirestead는 backpressure 상태를 callback과 runtime stats로 관측할 수 있게 한다. ```mermaid flowchart TD @@ -371,7 +369,7 @@ flowchart TD 즉, BackpressureStrategy는 transport 내부 queue 정책이지, 프로토콜의 전송 보장성을 바꾸는 기능은 아니다. -TCP의 Reliable 전략과 UDP의 Reliable 전략은 같은 API 이름을 사용하더라도, 네트워크 계층에서 보장하는 의미는 다르다. unilink의 Backpressure는 transport가 가진 물리적 특성을 바꾸지 않고, sender-side queue policy를 통일된 방식으로 다룬다. +TCP의 Reliable 전략과 UDP의 Reliable 전략은 같은 API 이름을 사용하더라도, 네트워크 계층에서 보장하는 의미는 다르다. wirestead의 Backpressure는 transport가 가진 물리적 특성을 바꾸지 않고, sender-side queue policy를 통일된 방식으로 다룬다. ## 데이터 성격에 따른 전략 선택 @@ -452,7 +450,7 @@ Backpressure는 장애를 완전히 없애는 기능이 아니다. ## 정리 -unilink에서 Backpressure는 송신 queue가 압박을 받을 때 어떤 정책으로 대응할지 정하는 계층이다. +wirestead에서 Backpressure는 송신 queue가 압박을 받을 때 어떤 정책으로 대응할지 정하는 계층이다. ```mermaid mindmap diff --git "a/src/content/blog/2026-06-03-unilink-framer-\352\263\204\354\270\265-\354\204\244\352\263\204.md" "b/src/content/blog/2026-06-03-wirestead-framer-\352\263\204\354\270\265-\354\204\244\352\263\204.md" similarity index 95% rename from "src/content/blog/2026-06-03-unilink-framer-\352\263\204\354\270\265-\354\204\244\352\263\204.md" rename to "src/content/blog/2026-06-03-wirestead-framer-\352\263\204\354\270\265-\354\204\244\352\263\204.md" index e6147b1..4832934 100644 --- "a/src/content/blog/2026-06-03-unilink-framer-\352\263\204\354\270\265-\354\204\244\352\263\204.md" +++ "b/src/content/blog/2026-06-03-wirestead-framer-\352\263\204\354\270\265-\354\204\244\352\263\204.md" @@ -1,20 +1,17 @@ --- title: 'Framer 계층 설계' date: 2026-06-03 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - - async-io - framer - stream - protocol - - zero-copy - - architecture description: TCP와 Serial의 raw byte stream을 애플리케이션이 처리할 message 단위로 나누는 Framer 계층의 경계를 정리했다. -series: 'unilink-design' -seriesOrder: 10 +series: 'wirestead-design' +seriesOrder: 8 draft: false --- @@ -148,7 +145,7 @@ Framer는 다음 문제를 해결한다. ## IFramer: 메시지 분리 전략의 공통 계약 -unilink에서 Framer는 `IFramer` 인터페이스로 추상화된다. +wirestead에서 Framer는 `IFramer` 인터페이스로 추상화된다. 개념적으로 보면 다음과 같다. @@ -413,12 +410,12 @@ flowchart TD 사용자 입장에서는 두 가지 callback을 구분할 수 있다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .use_line_framer("\n") - .on_data([](const unilink::MessageContext& ctx) { + .on_data([](const wirestead::MessageContext& ctx) { // raw chunk callback }) - .on_message([](const unilink::MessageContext& msg) { + .on_message([](const wirestead::MessageContext& msg) { // complete message callback }) .build(); @@ -428,16 +425,16 @@ auto client = unilink::tcp_client("127.0.0.1", 9000) `on_message`는 framer가 추출한 complete message에 가깝다. 이 분리는 중요하다. -어떤 애플리케이션은 raw chunk를 직접 처리하고 싶을 수 있고, 어떤 애플리케이션은 message 단위만 보고 싶을 수 있다. unilink는 이 둘을 분리해 제공한다. +어떤 애플리케이션은 raw chunk를 직접 처리하고 싶을 수 있고, 어떤 애플리케이션은 message 단위만 보고 싶을 수 있다. wirestead는 이 둘을 분리해 제공한다. ## Builder에서 Framer 설정하기 Framer는 Builder 단계에서 설정할 수 있다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .use_line_framer("\n") - .on_message([](const unilink::MessageContext& msg) { + .on_message([](const wirestead::MessageContext& msg) { // handle line message }) .build(); @@ -446,9 +443,9 @@ auto client = unilink::tcp_client("127.0.0.1", 9000) Packet 기반 protocol이라면 다음처럼 설정할 수 있다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .use_packet_framer({0x02}, {0x03}, 4096) - .on_message([](const unilink::MessageContext& msg) { + .on_message([](const wirestead::MessageContext& msg) { // handle packet }) .build(); @@ -457,11 +454,11 @@ auto client = unilink::tcp_client("127.0.0.1", 9000) 또는 custom framer factory를 넘기는 방식으로 사용자 정의 framing 전략을 사용할 수도 있다. ```cpp -auto client = unilink::tcp_client("127.0.0.1", 9000) +auto client = wirestead::tcp_client("127.0.0.1", 9000) .framer([] { return std::make_unique(); }) - .on_message([](const unilink::MessageContext& msg) { + .on_message([](const wirestead::MessageContext& msg) { // handle custom-framed message }) .build(); @@ -524,7 +521,7 @@ Framer는 이 복잡성을 하나의 전략 계층으로 격리한다. ## 정리 -unilink에서 Framer는 raw byte stream을 complete message로 변환하는 계층이다. +wirestead에서 Framer는 raw byte stream을 complete message로 변환하는 계층이다. ```mermaid mindmap @@ -566,5 +563,5 @@ mindmap - Builder는 Framer 전략을 설정하고, Wrapper는 이를 runtime callback과 연결한다. - max_length와 reset은 buffer 증가와 parser 상태 오류를 방지하는 안전장치다. -Framer 계층은 unilink에서 raw data와 application message 사이의 경계다. +Framer 계층은 wirestead에서 raw data와 application message 사이의 경계다. 이 계층을 분리했기 때문에 Transport는 I/O에 집중하고, 애플리케이션은 complete message 단위의 callback을 기준으로 로직을 작성할 수 있다. diff --git "a/src/content/blog/2026-06-03-wirestead-memory-buffer-\354\204\244\352\263\204.md" "b/src/content/blog/2026-06-03-wirestead-memory-buffer-\354\204\244\352\263\204.md" new file mode 100644 index 0000000..ae080f7 --- /dev/null +++ "b/src/content/blog/2026-06-03-wirestead-memory-buffer-\354\204\244\352\263\204.md" @@ -0,0 +1,545 @@ +--- +title: 'Memory / buffer 설계' +date: 2026-06-03 +project: wirestead +kind: design +tags: + - wirestead + - cpp + - memory + - buffer + - zero-copy +description: 비동기 통신에서 buffer lifetime, ownership, zero-copy 선택이 안전성과 성능에 미치는 영향을 기준으로 정리했다. +series: 'wirestead-design' +seriesOrder: 9 +draft: false +--- + +## 도입: 통신 데이터는 단순한 byte 배열이 아니다 + +통신 라이브러리에서 데이터는 대부분 byte 배열로 표현된다. +문자열을 보내든, 바이너리 packet을 보내든, 센서 데이터를 보내든, 결국 transport 계층으로 내려가면 byte buffer가 된다. + +하지만 비동기 통신에서 buffer는 단순한 `uint8_t*`나 `std::vector` 이상의 의미를 가진다. +언제까지 살아 있어야 하는지, 누가 소유하는지, 복사해도 되는지, callback 이후에도 보관해도 되는지에 따라 안전성과 성능이 크게 달라진다. + +특히 Boost.Asio 같은 비동기 I/O에서는 write 요청이 호출 직후 완료되지 않는다. +데이터를 넘긴 시점과 실제 I/O가 완료되는 시점이 다르기 때문에, buffer lifetime을 명확히 설계하지 않으면 dangling pointer, 불필요한 copy, memory pressure 같은 문제가 발생할 수 있다. + +```mermaid +flowchart TD + A[Application Data] --> B[Transport API] + B --> C[Async I/O] + C --> D[Completion Handler] + + B --> E{Is buffer still alive until completion?} + E -- Yes --> F[Safe] + E -- No --> G[Dangling / Undefined Behavior] +``` + +wirestead의 Memory / Buffer 설계는 이 문제를 다루기 위한 계층이다. +핵심은 view와 ownership을 구분하고, 비동기 경로에서는 buffer lifetime을 API 수준에서 명확히 표현하는 것이다. + +## Buffer 설계에서 중요한 기준 + +비동기 통신에서 buffer를 다룰 때는 최소한 세 가지를 구분해야 한다. + +```mermaid +mindmap + root((Buffer Design Concerns)) + View + no ownership + cheap to pass + callback scope + Ownership + copy + move + shared ownership + Safety + bounds check + max size + lifetime clarity + Performance + avoid unnecessary copy + memory pool + reuse allocation +``` + +첫 번째는 view다. +view는 데이터를 소유하지 않고 바라보기만 한다. 빠르고 가볍지만, 원본 buffer가 사라지면 더 이상 사용할 수 없다. + +두 번째는 ownership이다. +비동기 write처럼 데이터가 나중에 사용되는 경우에는 누가 buffer를 소유하는지 분명해야 한다. 복사해서 내부 소유로 만들 수도 있고, move로 ownership을 넘길 수도 있으며, `shared_ptr`로 공유 lifetime을 유지할 수도 있다. + +세 번째는 안전성이다. +외부 입력이나 큰 payload를 다루는 통신 라이브러리에서는 bounds check, max size, buffer validation이 필요하다. + +wirestead는 이 세 가지를 각각 다른 타입과 API로 표현한다. + +## SafeSpan: 소유하지 않는 byte view + +`SafeSpan`은 `std::span` 기반의 memory view다. +데이터를 소유하지 않고, pointer와 size를 함께 들고 있는 가벼운 view 역할을 한다. + +개념적으로 보면 다음과 같다. + +```cpp +template +class SafeSpan : public std::span { +public: + T& at(std::size_t index) const; + SafeSpan subspan(std::size_t offset, std::size_t count) const; +}; + +using ByteSpan = SafeSpan; +using ConstByteSpan = SafeSpan; +``` + +`ConstByteSpan`은 Framer, Transport, Channel callback 등에서 자주 사용된다. +이 타입은 데이터를 복사하지 않고도 byte sequence를 전달할 수 있게 한다. + +```mermaid +flowchart TD + A[Raw buffer] --> B[ConstByteSpan] + B --> C[Transport callback] + B --> D[Framer push_bytes] + B --> E[Message extraction] + + B --> F[No ownership] + B --> G[No allocation] +``` + +View 기반 API의 장점은 명확하다. + +- 복사가 없다. +- 할당이 없다. +- pointer와 size가 함께 이동하므로 raw pointer보다 안전하다. +- callback 경로에서 가볍게 전달할 수 있다. + +하지만 단점도 분명하다. +view는 데이터를 소유하지 않기 때문에, view를 callback 밖으로 저장하면 위험할 수 있다. 원본 buffer의 lifetime이 끝나면 view는 무효가 된다. + +따라서 `ConstByteSpan`은 “읽기 전용 view”이지, “안전하게 오래 보관 가능한 buffer”가 아니다. + +## SafeDataBuffer: 소유권이 있는 안전한 buffer + +`SafeDataBuffer`는 데이터를 소유하는 buffer다. +string, string_view, vector, raw pointer, span 등 다양한 입력에서 생성할 수 있고, 내부적으로 `std::vector`를 보관한다. + +```cpp +class SafeDataBuffer { +public: + explicit SafeDataBuffer(std::string_view data); + explicit SafeDataBuffer(std::vector data); + explicit SafeDataBuffer(const uint8_t* data, size_t size); + explicit SafeDataBuffer(ConstByteSpan span); + + std::string as_string() const; + ConstByteSpan as_span() const noexcept; + const uint8_t* data() const noexcept; + size_t size() const noexcept; + + const uint8_t& at(size_t index) const; +}; +``` + +`SafeDataBuffer`의 역할은 view와 다르다. +데이터를 내부에 복사하거나 move해서 소유하기 때문에, callback 이후에도 안전하게 보관할 수 있다. + +```mermaid +flowchart TD + A[Incoming data view] --> B{Need to store after callback?} + + B -- No --> C[Use ConstByteSpan / string_view] + B -- Yes --> D[Copy into SafeDataBuffer] + + D --> E[Safe ownership] + E --> F[Can store / convert / inspect] +``` + +예를 들어 `MessageContext`는 수신 데이터를 `SafeDataBuffer`로 보관하고, 사용자에게는 view 또는 copy 형태로 접근할 수 있게 한다. + +```cpp +client.on_data([](const wirestead::MessageContext& ctx) { + auto view = ctx.data(); // callback scope view + auto copy = ctx.data_as_vector(); // safe to store +}); +``` + +이 설계는 사용자가 lifetime을 선택할 수 있게 한다. + +callback 안에서만 읽을 것이라면 view를 사용하면 된다. +callback 이후에도 보관해야 한다면 copy를 얻어야 한다. + +즉, `SafeDataBuffer`는 안전한 소유권 경계다. + +## View와 Ownership 분리 + +wirestead의 buffer 설계에서 가장 중요한 원칙은 view와 ownership을 분리하는 것이다. + +```mermaid +mindmap + root((View vs Ownership)) + ConstByteSpan + non-owning view + no copy + callback scoped + fast path + SafeDataBuffer + owning buffer + copy or move + safe to store + conversion helpers + PooledBuffer + owning transport buffer + reused allocation + RAII release +``` + +모든 곳에서 owning buffer를 사용하면 안전하지만, 불필요한 copy가 늘어난다. +반대로 모든 곳에서 view만 사용하면 빠르지만, lifetime 문제가 생기기 쉽다. + +따라서 wirestead는 상황에 따라 다른 타입을 사용한다. + +Transport와 Framer의 내부 경로에서는 `ConstByteSpan`을 사용해 가능한 copy를 줄인다. +사용자 callback이나 저장 가능성이 있는 경계에서는 `SafeDataBuffer`를 사용해 lifetime을 명확히 한다. +Transport 송신 queue처럼 반복 allocation이 부담되는 경로에서는 `PooledBuffer`를 사용할 수 있다. + +이 구분은 성능과 안전성을 동시에 만족하기 위한 설계다. + +## 비동기 write와 buffer lifetime + +비동기 write에서 buffer lifetime은 특히 중요하다. + +다음과 같은 코드는 위험하다. + +```cpp +void send_message(Channel& ch) { + std::vector data = make_payload(); + ch.async_write(data.data(), data.size()); +} // data is destroyed here +``` + +`async_write`가 실제로 데이터를 사용하는 시점은 함수가 끝난 뒤일 수 있다. +이 경우 `data`가 이미 파괴되었다면 write operation은 무효한 memory를 참조할 수 있다. + +wirestead는 이 문제를 송신 API에서 명시적으로 나눈다. + +```mermaid +mindmap + root((Async Write Ownership)) + Copy + async_write_copy + transport copies data + safest but copy cost + Move + async_write_move + caller transfers vector ownership + avoids extra copy + Shared + async_write_shared + shared_ptr keeps buffer alive + useful for reused immutable data +``` + +각 API는 buffer lifetime에 대한 다른 의도를 표현한다. + +`async_write_copy`는 caller의 buffer를 내부 queue로 복사한다. +가장 안전하지만 copy 비용이 있다. + +`async_write_move`는 `std::vector`의 ownership을 transport로 넘긴다. +caller는 더 이상 해당 buffer를 소유하지 않고, transport가 write 완료까지 lifetime을 보장한다. + +`async_write_shared`는 `std::shared_ptr>`를 통해 공유 ownership을 유지한다. +동일한 payload를 여러 곳에서 참조하거나, immutable buffer를 여러 비동기 작업에 공유하고 싶을 때 유용하다. + +```mermaid +flowchart TD + A[Send data] --> B{Ownership model} + + B -- copy --> C[Transport owns copied buffer] + C --> D[Safe but alloc/copy cost] + + B -- move --> E[Transport takes ownership] + E --> F[No extra copy when possible] + + B -- shared --> G[Shared immutable buffer] + G --> H[Lifetime until last owner releases] +``` + +중요한 것은 사용자가 의도적으로 선택할 수 있다는 점이다. +“무조건 빠르게”가 아니라, 데이터 성격과 lifetime 요구사항에 따라 안전성과 성능을 선택할 수 있어야 한다. + +## PooledBuffer: 반복 allocation 줄이기 + +통신 라이브러리에서는 작은 buffer allocation이 매우 자주 발생할 수 있다. +특히 high-frequency telemetry나 작은 packet을 많이 주고받는 경우, 매번 `new` / `delete` 또는 vector allocation이 발생하면 성능과 latency에 영향을 줄 수 있다. + +`MemoryPool`은 이런 반복 allocation을 줄이기 위한 계층이다. + +```mermaid +mindmap + root((MemoryPool)) + Buckets + SMALL 1KB + MEDIUM 4KB + LARGE 16KB + XLARGE 64KB + Goal + reuse buffers + reduce allocation overhead + improve latency consistency + Management + acquire + release + pool stats + cleanup +``` + +`PooledBuffer`는 memory pool에서 얻은 buffer를 RAII로 관리한다. + +```cpp +memory::PooledBuffer buffer(size); +if (buffer.valid()) { + std::memcpy(buffer.data(), data.data(), size); + // buffer is returned to pool when PooledBuffer is destroyed +} +``` + +이 구조의 장점은 buffer 반환을 명시적으로 호출하지 않아도 된다는 점이다. +`PooledBuffer`의 lifetime이 끝나면 내부 buffer가 pool로 돌아갈 수 있다. + +```mermaid +flowchart TD + A[Acquire buffer] --> B[PooledBuffer] + B --> C[Use in transport queue] + C --> D[PooledBuffer destroyed] + D --> E[Return to MemoryPool] +``` + +RAII는 memory pool과 잘 맞는다. +pool 사용의 핵심은 acquire와 release가 반드시 짝을 이루어야 한다는 것인데, RAII wrapper를 사용하면 예외나 조기 return 상황에서도 release 누락 가능성을 줄일 수 있다. + +## MemoryPool의 bucket 전략 + +MemoryPool은 모든 크기를 하나의 pool에서 다루지 않는다. +일반적인 통신 payload 크기를 기준으로 여러 bucket을 둔다. + +```mermaid +flowchart TD + A[Requested size] --> B{Bucket} + + B -- <= 1KB --> C[SMALL] + B -- <= 4KB --> D[MEDIUM] + B -- <= 16KB --> E[LARGE] + B -- <= 64KB --> F[XLARGE] + B -- larger --> G[Direct allocation or fallback] +``` + +Bucket을 나누면 자주 쓰이는 크기의 buffer를 재사용하기 쉽다. +작은 메시지와 큰 데이터 전송을 같은 pool에서 다루면 낭비가 생길 수 있는데, 크기별 bucket은 이런 문제를 줄인다. + +다만 memory pool이 항상 이득인 것은 아니다. + +- pool 관리 비용이 있다. +- 잘못된 bucket 크기는 memory 낭비를 만들 수 있다. +- 사용 패턴에 따라 hit rate가 낮을 수 있다. +- 멀티스레드 환경에서는 bucket lock 경합이 생길 수 있다. + +특히 high-throughput 환경에서는 memory pool 자체가 병목이 될 수도 있다. +여러 transport thread가 같은 bucket을 자주 acquire/release하면 mutex 경합이 발생할 수 있고, 이 경우 allocation 비용을 줄이려던 구조가 오히려 latency jitter를 만들 수 있다. + +이런 경합을 줄이기 위해서는 thread-local cache를 두거나, 특정 hot path에는 lock-free 구조를 적용하거나, transport별 pool을 분리하는 방식도 고려할 수 있다. +즉, MemoryPool은 단순한 최적화 장치가 아니라 workload와 동시성 수준에 맞춰 조정되어야 하는 성능 계층이다. + +따라서 MemoryPool의 목적은 모든 allocation을 무조건 pool로 대체하는 것이 아니다. +반복 allocation이 많은 hot path에서 선택적으로 사용할 수 있는 기반을 제공하고, 필요에 따라 pool 크기와 bucket 정책을 조정할 수 있게 하는 것이다. + +## 수신 경로의 lifetime + +수신 경로에서는 Transport가 raw bytes를 읽고, Wrapper 또는 Framer가 이를 처리한다. + +```mermaid +flowchart TD + A[Transport receive buffer] --> B[ConstByteSpan] + B --> C[Framer scan] + C --> D{Complete message?} + + D -- No --> E[Copy partial data to internal buffer] + D -- Yes --> F[on_message callback] +``` + +Framer가 현재 chunk 안에서 메시지를 바로 찾을 수 있다면, `ConstByteSpan` view를 통해 별도 allocation 없이 callback으로 전달할 수 있다. + +반면 메시지가 chunk 경계에 걸치면 partial data를 내부 buffer에 복사해야 한다. +이 복사는 피할 수 없는 비용이다. 메시지를 완성하기 위해 현재 chunk 이후의 데이터가 필요하기 때문이다. + +즉, 수신 경로의 원칙은 다음과 같다. + +- 즉시 처리 가능한 데이터는 view로 처리한다. +- 이후에도 필요한 partial data는 owning buffer로 복사한다. +- 사용자 callback 이후에도 보관해야 한다면 명시적으로 copy한다. + +이 원칙을 지키면 zero-copy 가능성과 lifetime safety를 동시에 확보할 수 있다. + +## callback scope와 저장 가능한 데이터 + +사용자 callback에서 가장 흔한 실수 중 하나는 view를 오래 보관하는 것이다. + +```cpp +std::string_view saved; + +client.on_data([&](const wirestead::MessageContext& ctx) { + saved = ctx.data(); // dangerous if used after callback +}); +``` + +`ctx.data()`가 반환하는 view는 callback scope 안에서만 안전하다. +callback 이후에도 데이터를 보관해야 한다면 owning copy를 만들어야 한다. + +```cpp +std::vector saved; + +client.on_data([&](const wirestead::MessageContext& ctx) { + saved = ctx.data_as_vector(); // safe to store +}); +``` + +이 차이를 API로 명확히 드러내는 것이 중요하다. + +```mermaid +flowchart TD + A[Callback receives MessageContext] --> B{Need to store data?} + + B -- No --> C[Use data view] + C --> D[No copy] + + B -- Yes --> E[Use data_as_vector / data_as_string] + E --> F[Owning copy] +``` + +wirestead의 MessageContext는 view와 copy API를 함께 제공한다. +이는 사용자가 성능과 안전성 사이에서 의식적인 선택을 하도록 만드는 API 설계다. + +## Zero-copy의 의미와 한계 + +Zero-copy는 매력적인 표현이지만, 통신 라이브러리에서 항상 가능한 것은 아니다. + +```mermaid +mindmap + root((Zero-copy Reality)) + Possible + callback-scope view + direct scan by ConstByteSpan + move ownership send + shared immutable buffer + Not always possible + partial message buffering + user stores data + protocol transformation + OS/kernel copy +``` + +wirestead에서 zero-copy는 “항상 복사가 없다”는 의미가 아니다. +그보다는 불필요한 복사를 피하고, 복사가 필요한 지점을 명확히 한다는 의미에 가깝다. + +예를 들어 Framer가 현재 chunk 안에서 complete message를 찾으면 view만으로 처리할 수 있다. +하지만 partial message가 남으면 내부 buffer에 복사해야 한다. + +비동기 write에서도 마찬가지다. +caller가 데이터를 넘긴 뒤 바로 소멸될 수 있다면 transport는 copy하거나 ownership을 가져야 한다. +move나 shared ownership을 사용하면 copy를 줄일 수 있지만, lifetime 관리는 여전히 필요하다. + +즉, zero-copy는 목표이지만 절대 원칙은 아니다. +안전하지 않은 zero-copy보다 명확한 ownership이 더 중요하다. + +## Memory / Buffer 설계의 Trade-off + +Memory / Buffer 계층은 성능과 안전성 사이의 trade-off를 다룬다. + +```mermaid +mindmap + root((Memory / Buffer Trade-offs)) + View + no copy + fast + lifetime risk + Owning Buffer + safe to store + clear lifetime + copy cost + Move Ownership + fewer copies + caller gives up ownership + API discipline required + Shared Ownership + lifetime safe + shared reuse + ref count overhead + Memory Pool + allocation reuse + lower jitter + pool management cost + contention risk +``` + +모든 데이터를 copy하면 안전하지만 느릴 수 있다. +모든 데이터를 view로 처리하면 빠르지만 lifetime 문제가 생긴다. +move는 효율적이지만 caller의 ownership을 명확히 포기해야 한다. +shared ownership은 안전하지만 reference count overhead가 있다. +memory pool은 allocation 비용을 줄이지만 pool 관리 비용과 thread contention 가능성이 있다. + +따라서 중요한 것은 하나의 방식을 강제하지 않는 것이다. +데이터 성격과 경로에 맞는 선택지를 제공해야 한다. + +## 정리 + +wirestead에서 Memory / Buffer 설계는 비동기 통신의 lifetime과 ownership 문제를 다루기 위한 계층이다. + +```mermaid +mindmap + root((Memory / Buffer Role)) + View + ConstByteSpan + SafeSpan + callback scope + Ownership + SafeDataBuffer + copy + move + shared buffer + Async Safety + write lifetime + callback lifetime + partial message buffer + Performance + zero-copy fast path + memory pool + allocation reuse + Concurrency + bucket lock + contention risk + thread-local cache option + API Clarity + data + data_as_vector + async_write_copy + async_write_move + async_write_shared +``` + +정리하면 다음과 같다. + +- `ConstByteSpan`은 소유권 없는 view로, callback 경로에서 불필요한 copy를 줄인다. +- `SafeDataBuffer`는 데이터를 소유하는 안전한 buffer로, callback 이후에도 보관 가능한 데이터를 제공한다. +- 비동기 write에서는 buffer가 완료 시점까지 살아 있어야 하므로 ownership 모델이 명확해야 한다. +- `copy`, `move`, `shared` 송신 API는 서로 다른 lifetime / performance trade-off를 표현한다. +- `PooledBuffer`와 `MemoryPool`은 반복 allocation을 줄이기 위한 최적화 기반이다. +- MemoryPool은 allocation 비용을 줄일 수 있지만, 멀티스레드 환경에서는 bucket lock 경합도 함께 고려해야 한다. +- Framer는 즉시 처리 가능한 데이터는 view로 처리하고, partial message는 내부 buffer로 보관한다. +- Zero-copy는 절대 원칙이 아니라, 안전성이 확보되는 범위에서 불필요한 복사를 줄이는 전략이다. + +통신 라이브러리에서 buffer 설계는 단순한 성능 최적화가 아니다. +비동기 실행 모델에서 데이터가 언제까지 살아 있어야 하는지, 누가 소유하는지, 언제 복사해야 하는지를 명확히 하는 안정성 설계다. + +wirestead는 view, owning buffer, move ownership, shared ownership, memory pool을 구분함으로써 성능과 안전성 사이의 선택지를 API와 내부 구조에 반영한다. diff --git "a/src/content/blog/2026-06-03-unilink-runtimestats\354\231\200-diagnostics-\354\204\244\352\263\204.md" "b/src/content/blog/2026-06-03-wirestead-runtimestats\354\231\200-diagnostics-\354\204\244\352\263\204.md" similarity index 96% rename from "src/content/blog/2026-06-03-unilink-runtimestats\354\231\200-diagnostics-\354\204\244\352\263\204.md" rename to "src/content/blog/2026-06-03-wirestead-runtimestats\354\231\200-diagnostics-\354\204\244\352\263\204.md" index 6472d73..8571467 100644 --- "a/src/content/blog/2026-06-03-unilink-runtimestats\354\231\200-diagnostics-\354\204\244\352\263\204.md" +++ "b/src/content/blog/2026-06-03-wirestead-runtimestats\354\231\200-diagnostics-\354\204\244\352\263\204.md" @@ -1,19 +1,17 @@ --- title: 'RuntimeStats와 Diagnostics 설계' date: 2026-06-03 -project: unilink +project: wirestead kind: design tags: - - unilink + - wirestead - cpp - - async-io - diagnostics - - runtime-stats - observability - - architecture + - runtime-stats description: 연결 상태, 송수신량, drop, queue pressure를 RuntimeStats와 Diagnostics로 관측 가능하게 만드는 설계를 정리했다. -series: 'unilink-design' -seriesOrder: 8 +series: 'wirestead-design' +seriesOrder: 11 draft: false --- @@ -38,7 +36,7 @@ flowchart TD E --> F[Diagnose queue, drop, error, throughput] ``` -unilink에서 RuntimeStats와 Diagnostics는 이런 문제를 줄이기 위한 계층이다. +wirestead에서 RuntimeStats와 Diagnostics는 이런 문제를 줄이기 위한 계층이다. 목표는 단순히 카운터를 제공하는 것이 아니라, 통신 객체가 런타임에 어떤 상태인지 사용자가 판단할 수 있는 최소한의 관측 지점을 제공하는 것이다. ## 관측성이 필요한 이유 @@ -290,7 +288,7 @@ RuntimeStats가 수치 기반의 관측성이라면, `ErrorContext`는 이벤트 - 어떤 메시지인지 - 특정 client와 관련된 에러인지 -unilink의 error callback은 이런 정보를 context로 전달한다. +wirestead의 error callback은 이런 정보를 context로 전달한다. ```cpp class ErrorContext { @@ -344,7 +342,7 @@ mindmap 이 설계는 callback 사용성을 높이면서도 lifetime 문제를 명확히 한다. ```cpp -client.on_data([](const unilink::MessageContext& ctx) { +client.on_data([](const wirestead::MessageContext& ctx) { auto view = ctx.data(); // callback scope view auto copy = ctx.data_as_vector(); // safe to store }); @@ -458,7 +456,7 @@ Diagnostics는 “문제가 없도록 만드는 기능”이 아니라, 문제 ## 정리 -unilink에서 RuntimeStats와 Diagnostics는 통신 객체를 관측 가능한 runtime component로 만들기 위한 계층이다. +wirestead에서 RuntimeStats와 Diagnostics는 통신 객체를 관측 가능한 runtime component로 만들기 위한 계층이다. ```mermaid mindmap @@ -508,4 +506,4 @@ mindmap 실제 시스템에서는 느려짐, 끊김, drop, queue pressure, callback 지연 같은 문제가 반복적으로 발생한다. RuntimeStats와 Diagnostics는 이런 상황에서 라이브러리가 침묵하지 않도록 만드는 장치다. -즉, unilink의 Diagnostics 설계는 “문제를 없애는 것”이 아니라 “문제를 볼 수 있게 만드는 것”에 가깝다. +즉, wirestead의 Diagnostics 설계는 “문제를 없애는 것”이 아니라 “문제를 볼 수 있게 만드는 것”에 가깝다. diff --git "a/src/content/blog/2026-06-03-unilink-\354\204\244\352\263\204-\355\232\214\352\263\240.md" "b/src/content/blog/2026-06-03-wirestead-\354\204\244\352\263\204-\355\232\214\352\263\240.md" similarity index 87% rename from "src/content/blog/2026-06-03-unilink-\354\204\244\352\263\204-\355\232\214\352\263\240.md" rename to "src/content/blog/2026-06-03-wirestead-\354\204\244\352\263\204-\355\232\214\352\263\240.md" index 267fac9..d32f70d 100644 --- "a/src/content/blog/2026-06-03-unilink-\354\204\244\352\263\204-\355\232\214\352\263\240.md" +++ "b/src/content/blog/2026-06-03-wirestead-\354\204\244\352\263\204-\355\232\214\352\263\240.md" @@ -1,25 +1,23 @@ --- title: '설계 회고' date: 2026-06-03 -project: unilink +project: wirestead kind: retrospective tags: - - unilink + - wirestead - cpp - - open-source - cmake - - testing - packaging - - release -description: unilink를 기능 구현에서 테스트, 패키징, 문서화, 릴리즈 준비까지 사용할 수 있는 라이브러리로 다듬은 과정을 회고했다. -series: 'unilink-design' + - open-source +description: wirestead를 기능 구현에서 테스트, 패키징, 문서화, 릴리즈 준비까지 사용할 수 있는 라이브러리로 다듬은 과정을 회고했다. +series: 'wirestead-design' seriesOrder: 12 draft: false --- ## 도입: 구현만으로는 라이브러리가 되지 않는다 -unilink를 설계하면서 가장 크게 느낀 점은, 라이브러리는 기능 구현만으로 완성되지 않는다는 것이다. +wirestead를 설계하면서 가장 크게 느낀 점은, 라이브러리는 기능 구현만으로 완성되지 않는다는 것이다. TCP client가 동작하고, Serial 통신이 되고, UDP packet을 주고받을 수 있다고 해서 바로 사용할 수 있는 라이브러리가 되는 것은 아니다. 다른 개발자가 자신의 프로젝트에 포함할 수 있어야 하고, 여러 플랫폼에서 빌드되어야 하며, 테스트로 기본 동작을 확인할 수 있어야 한다. 또한 설치, 패키징, 문서화, 릴리즈 절차까지 어느 정도 정리되어 있어야 한다. @@ -50,23 +48,23 @@ mindmap documentation ``` -이 글은 unilink의 내부 구조 자체보다, 그 구조를 실제로 사용할 수 있는 라이브러리로 만들기 위해 어떤 운영 기반을 갖췄는지 정리하는 회고에 가깝다. +이 글은 wirestead의 내부 구조 자체보다, 그 구조를 실제로 사용할 수 있는 라이브러리로 만들기 위해 어떤 운영 기반을 갖췄는지 정리하는 회고에 가깝다. ## 빌드 구조: 기능보다 먼저 안정적인 진입점 만들기 C++ 라이브러리에서 빌드 구조는 public API만큼 중요하다. 사용자가 라이브러리를 사용하기 위해 가장 먼저 만나는 것은 코드가 아니라 `CMakeLists.txt`, build option, dependency 설정이다. -unilink의 최상위 CMake 구조는 여러 관심사를 별도 파일로 나눈다. +wirestead의 최상위 CMake 구조는 여러 관심사를 별도 파일로 나눈다. ```mermaid flowchart TD - A[CMakeLists.txt] --> B[UnilinkOptions.cmake] - A --> C[UnilinkCompiler.cmake] - A --> D[UnilinkDependencies.cmake] - A --> E[UnilinkSources.cmake] - A --> F[UnilinkTargets.cmake] - A --> G[UnilinkPackaging.cmake] + A[CMakeLists.txt] --> B[WiresteadOptions.cmake] + A --> C[WiresteadCompiler.cmake] + A --> D[WiresteadDependencies.cmake] + A --> E[WiresteadSources.cmake] + A --> F[WiresteadTargets.cmake] + A --> G[WiresteadPackaging.cmake] B --> H[Build options] C --> I[Compiler settings] @@ -79,7 +77,7 @@ flowchart TD 빌드 옵션, 컴파일러 설정, 의존성, target 구성, 패키징은 서로 다른 변경 이유를 가진다. 이들을 하나의 `CMakeLists.txt`에 모두 넣으면 작은 수정도 전체 구조를 읽어야 한다. 반대로 역할별로 분리하면 빌드 시스템 자체도 유지보수 가능한 구조가 된다. -unilink의 빌드 구조에서 중요한 기준은 다음이었다. +wirestead의 빌드 구조에서 중요한 기준은 다음이었다. - 사용자가 켜고 끌 수 있는 옵션을 명확히 둔다. - shared / static library를 모두 고려한다. @@ -96,7 +94,7 @@ unilink의 빌드 구조에서 중요한 기준은 다음이었다. 어떤 사용자는 static library를 원하고, 어떤 사용자는 shared library를 원한다. 테스트를 함께 빌드하고 싶은 경우도 있고, 패키지 소비자 입장에서는 테스트를 끄고 라이브러리만 빌드하고 싶을 수도 있다. -따라서 unilink는 주요 build option을 명시적으로 제공한다. +따라서 wirestead는 주요 build option을 명시적으로 제공한다. ```mermaid mindmap @@ -135,7 +133,7 @@ mindmap 순수 함수나 단일 클래스처럼 빠르게 확인할 수 있는 코드도 있지만, 실제 socket이나 serial port, event loop, thread, timeout이 얽히는 테스트도 있다. 이들을 모두 같은 테스트 계층에 두면 테스트 실행 시간이 길어지고, 실패 원인도 모호해진다. -unilink는 테스트를 크게 세 단계로 나눈다. +wirestead는 테스트를 크게 세 단계로 나눈다. ```mermaid mindmap @@ -189,7 +187,7 @@ E2E test가 깨지면 실제 사용 시나리오의 안정성을 봐야 한다. 테스트는 작성하는 것만큼 실행하기 쉬워야 한다. -unilink는 CTest와 GoogleTest discovery를 기반으로 test를 등록하고, unit / integration / e2e label을 기준으로 실행할 수 있게 구성한다. +wirestead는 CTest와 GoogleTest discovery를 기반으로 test를 등록하고, unit / integration / e2e label을 기준으로 실행할 수 있게 구성한다. ```text ctest -L unit @@ -239,7 +237,7 @@ cross-platform C++ 라이브러리에서는 플랫폼 차이가 생각보다 자 Linux에서 잘 빌드되는 코드가 Windows에서 깨질 수 있고, MSVC에서 warning이나 link option 문제가 발생할 수 있다. 반대로 Windows를 기준으로 작성한 설치 경로나 DLL 배치 방식이 Linux 패키징에는 맞지 않을 수 있다. -unilink는 Linux / Windows를 모두 고려해 target, output directory, runtime dependency, MSVC workaround 등을 빌드 시스템에 반영한다. +wirestead는 Linux / Windows를 모두 고려해 target, output directory, runtime dependency, MSVC workaround 등을 빌드 시스템에 반영한다. ```mermaid mindmap @@ -267,9 +265,9 @@ mindmap 라이브러리를 다른 프로젝트에서 쓰려면 install/export 구조가 필요하다. -단순히 `add_library`로 target을 만드는 것과, 외부 프로젝트에서 `find_package(unilink)`로 가져올 수 있게 만드는 것은 다르다. +단순히 `add_library`로 target을 만드는 것과, 외부 프로젝트에서 `find_package(wirestead)`로 가져올 수 있게 만드는 것은 다르다. -unilink는 install target, CMake package config, pkg-config, CPack 설정을 둔다. +wirestead는 install target, CMake package config, pkg-config, CPack 설정을 둔다. ```mermaid mindmap @@ -330,7 +328,7 @@ flowchart TD ## 문서화 전략: 핵심 문서와 상세 문서 분리 -unilink는 문서를 모두 한 repository에 넣는 방식 대신, 기본 문서는 core repository에 남기고 상세 문서는 별도 문서 repository로 분리하는 방향을 선택했다. +wirestead는 문서를 모두 한 repository에 넣는 방식 대신, 기본 문서는 core repository에 남기고 상세 문서는 별도 문서 repository로 분리하는 방향을 선택했다. 이 선택은 문서가 많아질수록 중요해진다. @@ -466,13 +464,13 @@ mindmap changelog ``` -이 관점에서 보면 unilink의 구조화 작업은 단순한 정리가 아니었다. +이 관점에서 보면 wirestead의 구조화 작업은 단순한 정리가 아니었다. Builder, Wrapper, Channel, Transport 같은 코드 아키텍처뿐 아니라, CMake, 테스트 구조, 패키징, 문서 분리까지 함께 정리해야 “다른 사람이 사용할 수 있는 라이브러리”에 가까워진다. ## 설계하면서 배운 점 -unilink를 설계하면서 가장 크게 느낀 점은, 좋은 라이브러리는 내부 구현이 잘 되어 있는 것만으로 충분하지 않다는 것이다. +wirestead를 설계하면서 가장 크게 느낀 점은, 좋은 라이브러리는 내부 구현이 잘 되어 있는 것만으로 충분하지 않다는 것이다. 내부 구현은 복잡할 수 있다. 비동기 I/O, buffer lifetime, reconnect, backpressure, framer, diagnostics 같은 요소는 피할 수 없다. @@ -493,7 +491,7 @@ flowchart TD 첫째, 내부 복잡성을 견딜 수 있는 구조를 만든다. 둘째, 외부 사용자에게는 단순하고 안정적인 경험을 제공한다. -unilink의 여러 설계 요소는 이 두 목표 사이의 균형을 맞추기 위한 시도였다. +wirestead의 여러 설계 요소는 이 두 목표 사이의 균형을 맞추기 위한 시도였다. ## Trade-off: 완성도를 높일수록 관리할 것도 늘어난다 @@ -524,11 +522,11 @@ mindmap ## 정리 -unilink의 설계는 단순히 통신 기능을 구현하는 것으로 끝나지 않았다. +wirestead의 설계는 단순히 통신 기능을 구현하는 것으로 끝나지 않았다. ```mermaid mindmap - root((unilink Library Readiness)) + root((wirestead Library Readiness)) Architecture Unified API Builder @@ -561,12 +559,12 @@ mindmap - 릴리즈 준비는 기능 완료가 아니라 사용 가능성의 점검이다. - 오픈소스 라이브러리는 빌드 가능성, 테스트 가능성, 문서화, 배포 가능성을 함께 갖춰야 한다. -unilink를 만들면서 반복적으로 확인한 것은 하나다. +wirestead를 만들면서 반복적으로 확인한 것은 하나다. > 좋은 라이브러리는 내부가 강해야 하지만, 외부에서는 단순해야 한다. 내부에는 복잡한 transport, queue, buffer, diagnostics가 있어도, 사용자는 명확한 API와 안정적인 빌드·테스트·배포 경험을 기대한다. -unilink의 설계와 릴리즈 준비 과정은 이 두 세계 사이의 간격을 줄이기 위한 작업이었다. +wirestead의 설계와 릴리즈 준비 과정은 이 두 세계 사이의 간격을 줄이기 위한 작업이었다. 구현은 라이브러리의 출발점이고, 테스트와 패키징과 문서화는 라이브러리를 실제로 사용할 수 있게 만드는 기반이다. -이 과정을 거치면서 unilink는 단순한 통신 코드 묶음이 아니라, 다른 프로젝트에서 가져다 쓸 수 있는 C++ 통신 라이브러리에 가까워질 수 있었다. +이 과정을 거치면서 wirestead는 단순한 통신 코드 묶음이 아니라, 다른 프로젝트에서 가져다 쓸 수 있는 C++ 통신 라이브러리에 가까워질 수 있었다. diff --git "a/src/content/blog/2026-07-02-llm-encoder-only\354\231\200-decoder-only-\352\265\254\354\241\260-\354\235\264\355\225\264\355\225\230\352\270\260.md" "b/src/content/blog/2026-07-02-llm-encoder-only\354\231\200-decoder-only-\352\265\254\354\241\260-\354\235\264\355\225\264\355\225\230\352\270\260.md" index ddd1bac..e7dffa7 100644 --- "a/src/content/blog/2026-07-02-llm-encoder-only\354\231\200-decoder-only-\352\265\254\354\241\260-\354\235\264\355\225\264\355\225\230\352\270\260.md" +++ "b/src/content/blog/2026-07-02-llm-encoder-only\354\231\200-decoder-only-\352\265\254\354\241\260-\354\235\264\355\225\264\355\225\230\352\270\260.md" @@ -1,409 +1,308 @@ --- title: 'Encoder-only와 Decoder-only' date: 2026-07-02 -updatedAt: 2026-07-02 kind: study series: llm-core -seriesOrder: 2 +seriesOrder: 3 tags: - llm - transformer - encoder - decoder -description: Transformer 기반 LLM이 Encoder-only, Decoder-only, Encoder-Decoder 구조로 나뉘는 이유 +description: 같은 Transformer block을 쓰면서도 attention mask와 학습 목표에 따라 Encoder-only, Decoder-only, Encoder-Decoder로 갈라지는 이유를 정리했다. draft: false --- -## 도입: 확률 분포만으로는 문장이 생성되지 않는다 +## 도입: 같은 Transformer인데 왜 구조가 갈라지는가 -LLM은 현재까지의 token sequence를 보고 다음 token 후보들의 확률 분포를 만든다. +[Transformer와 self-attention](/blog/2026-07-02-llm-transformer와-self-attention-이해하기)에서 본 것처럼, Transformer block의 내부 구성은 단순하다. Self-Attention, Feed Forward Network, Residual Connection, LayerNorm이 전부다. -예를 들어 입력이 다음과 같다고 하자. +그런데 실제 모델을 보면 계열이 뚜렷하게 나뉜다. -> "나는 커피를" - -모델은 Vocabulary 전체 token에 대해 다음과 같은 확률 분포를 만들 수 있다. +```text +BERT, RoBERTa, DeBERTa → Encoder-only +GPT, LLaMA, Qwen → Decoder-only +T5, BART, 번역 모델 → Encoder-Decoder +``` -| Token | Probability (확률) | -| :------- | :----------------- | -| 마셨다 | 0.60 | -| 좋아한다 | 0.20 | -| 샀다 | 0.15 | -| 내렸다 | 0.03 | -| . | 0.02 | +같은 block을 쌓는데 왜 이렇게 갈라질까. 흔한 오해는 encoder와 decoder가 서로 다른 연산을 한다는 것이다. 그렇지 않다. 세 구조 모두 $\text{softmax}(QK^T/\sqrt{d_k})V$라는 동일한 attention 연산을 쓴다. -하지만 확률 분포만으로는 실제 문장이 완성되지 않는다. 이 수많은 후보 중에서 출력으로 내보낼 최종 token 하나를 선택해야 한다. +갈라지는 지점은 두 가지다. -> **확률 분포:** 마셨다(0.60), 좋아한다(0.20), 샀다(0.15) ... -> **선택된 token:** 마셨다 +1. **각 토큰이 어떤 토큰을 볼 수 있는가** (attention mask) +2. **무엇을 맞히도록 학습하는가** (학습 목표) -이처럼 계산된 확률 분포에서 실제 출력할 token을 선택하는 과정을 **Decoding(디코딩)**이라고 한다. +이 글에서는 이 두 축이 어떻게 세 가지 구조를 만들어내는지, 그리고 왜 오늘날 LLM 대부분이 Decoder-only로 수렴했는지 정리한다. --- ## 이 글에서 다루는 범위 -이 글은 LLM이 계산해 낸 '다음 token 확률 분포'를 기준으로, 실제 token을 선택하는 추론 전략을 정리한다. - **[ 다루는 내용 ]** -- Greedy Decoding -- Sampling -- Temperature -- Top-k Sampling -- Top-p Sampling -- Penalty 기반 Logit 조정 -- Stop Sequence와 종료 조건 +- Attention mask가 구조를 가르는 방식 +- Encoder-only: 양방향 문맥 +- Decoder-only: 인과적 문맥 +- Encoder-Decoder: cross-attention +- 세 구조의 비교와 용도 +- LLM이 Decoder-only로 수렴한 이유 -명심해야 할 점은, **Decoding은 모델을 학습(Training)시키는 과정이 아니다.** 이미 학습이 완료된 모델이 내뱉은 확률 분포 안에서, 어떤 방식으로 다음 token을 고를지 결정하는 **추론 단계의 선택 전략**이다. +이 글은 구조의 차이에 집중한다. 토큰이 벡터가 되는 과정은 [Tokenizer와 Embedding](/blog/2026-07-03-llm-core-tokenizer와-embedding)에서, 출력 벡터가 확률이 되는 과정은 [Logit과 Softmax](/blog/2026-07-03-llm-core-logit과-softmax)에서 다룬다. --- -## 전체 흐름 - -Decoding은 Softmax 연산 직후에 일어난다. +## 구조를 가르는 것은 Attention Mask다 -```mermaid -flowchart TD - A[Token Probability Distribution] --> B[Decoding Strategy] - B --> C[Selected Token] - C --> D[Token Sequence에 추가] - D --> E[다음 위치 예측 반복] - -``` +Self-Attention은 기본적으로 **문장 안의 모든 토큰이 모든 토큰을 참조**할 수 있는 연산이다. 여기에 어떤 mask를 씌우느냐가 모델의 성격을 결정한다. -조금 더 세분화하여 이전 단계와 연결하면 다음과 같다. +`나는 커피를 마셨다`라는 입력에서, 각 토큰이 볼 수 있는 범위를 표로 그려 보자. -> **Logits $\rightarrow$ Softmax $\rightarrow$ Token Probability Distribution $\rightarrow$ Decoding Strategy $\rightarrow$ Selected Token** +양방향(Encoder) 구조에서는 모든 칸이 열려 있다. -이 글의 핵심은 다음 질문에 답하는 것이다. +```text + 나는 커피를 마셨다 +나는 O O O +커피를 O O O +마셨다 O O O +``` -> "확률이 계산된 여러 token 후보 중에서, 과연 어떤 기준으로 실제 token 하나를 선택할 것인가?" +인과적(Decoder) 구조에서는 오른쪽 위가 막힌다. ---- +```text + 나는 커피를 마셨다 +나는 O X X +커피를 O O X +마셨다 O O O +``` -## Decoding이란 무엇인가 +차이는 오른쪽 위 삼각형뿐이다. 이 삼각형을 가리면 Decoder가 되고, 열어 두면 Encoder가 된다. -Decoding은 token 확률 분포에서 실제 출력할 token을 선택하는 과정이다. 앞선 예시의 확률 분포에서 token을 선택하는 방법은 단 하나가 아니다. +```mermaid +flowchart TD + A[동일한 Transformer Block] --> B{Attention Mask} + B -->|mask 없음| C[Encoder-only
양방향 문맥] + B -->|미래 토큰 mask| D[Decoder-only
인과적 문맥] + B -->|둘 다 사용
+ cross-attention| E[Encoder-Decoder] +``` -- 항상 **가장 높은 확률**의 token을 고를 수 있다. -- 확률에 따라 무작위(Random)로 뽑을 수 있다. -- 낮은 확률의 token을 아예 **후보에서 제외**할 수 있다. -- 확률 분포를 더 **날카롭게** 혹은 **평평하게** 조작할 수 있다. +구현 상으로는 [Transformer 글에서 본 것](/blog/2026-07-02-llm-transformer와-self-attention-이해하기)처럼, Softmax 이전에 미래 토큰 위치의 점수에 $-\infty$를 더하는 것으로 처리한다. -이 선택 방식에 따라 LLM의 출력은 항상 똑같고 안정적일 수도 있고, 매번 새롭고 창의적일 수도 있다. +이 작은 차이가 모델이 할 수 있는 일을 결정한다. --- -## Greedy Decoding +## Encoder-only: 양방향 문맥 -가장 단순한 방식은 무조건 가장 확률이 높은 token을 선택하는 것이다. 이를 Greedy Decoding(탐욕적 탐색)이라고 한다. +Encoder-only 모델은 mask를 씌우지 않는다. 모든 토큰이 문장 전체를 좌우 양쪽으로 참조한다. -> **Greedy Decoding:** 가장 높은 확률을 가진 token을 1순위로 확정하여 선택한다. +```mermaid +flowchart TD + A[입력 문장 전체] --> B[Bidirectional Self-Attention] + B --> C[토큰별 Contextual Vector] + C --> D[분류 / 태깅 / 임베딩] +``` -| Token | Probability | 선택 여부 | -| ---------- | ----------- | -------------- | -| **마셨다** | **0.60** | **선택 (1위)** | -| 좋아한다 | 0.20 | 제외 | -| 샀다 | 0.15 | 제외 | +### 학습 목표: Masked Language Modeling -**[ Greedy 방식의 특징 ]** +문제는 학습 방법이다. 모든 토큰이 문장 전체를 볼 수 있으면 "다음 토큰 맞히기"는 성립하지 않는다. 정답이 입력에 이미 들어 있기 때문이다. -| 항목 | 특징 | -| ---------- | --------------------------------------------------------------------- | -| **안정성** | 높음 | -| **다양성** | 낮음 | -| **재현성** | 높음 | -| **단점** | 출력이 단조롭거나 국소적으로 최적인 선택(Local Optima)에 갇힐 수 있음 | +그래서 Encoder-only 모델은 입력의 일부를 가리고 그 자리를 복원하도록 학습한다. 이를 **MLM**(Masked Language Modeling)이라고 한다. -Greedy decoding은 기술문서 요약, 코드 설명, 구조화된 데이터 추출처럼 **일관성이 중요한 작업**에 적합하다. 다만 항상 1순위의 token만 고르기 때문에 다채로운 표현을 만들어내기는 어렵다. +```text +입력: 나는 [MASK] 마셨다 +정답: 커피를 +``` ---- +`[MASK]` 자리를 맞히려면 왼쪽의 `나는`과 오른쪽의 `마셨다`를 **동시에** 봐야 한다. 양방향 문맥이 필요한 과제이고, 양방향 구조라야 풀 수 있다. -## Sampling +### 무엇에 강한가 -Sampling(샘플링)은 확률 분포에 따라 주사위를 굴리듯 무작위로 token을 선택하는 방식이다. +양방향 문맥은 문장 전체의 의미를 한 벡터로 압축하는 데 유리하다. -> **Sampling:** 확률이 높은 token이 더 자주 선택되지만, 무조건 1등만 뽑히지는 않는다. 2위나 3위 token이 뽑힐 수도 있다. +| 용도 | 예시 | +| ----------- | ------------------------------- | +| 문장 분류 | 감성 분석, 스팸 판별, 의도 분류 | +| 토큰 분류 | 개체명 인식(NER), 품사 태깅 | +| 문장 임베딩 | 유사도 검색, RAG의 문서 임베딩 | +| 재순위화 | Cross-encoder reranker | -| Token | Probability | 선택 가능성 | -| -------- | ----------- | ---------------- | -| 마셨다 | 0.60 | 가장 자주 선택됨 | -| 좋아한다 | 0.20 | 종종 선택됨 | -| 샀다 | 0.15 | 가끔 선택됨 | +### 무엇을 못 하는가 -Sampling은 출력에 **다양성**을 부여한다. 똑같은 Prompt를 넣어도 매번 답변이 미묘하게 달라지는 이유가 바로 이 Sampling 덕분이다. +Encoder-only 모델은 **자연스러운 텍스트 생성을 하지 못한다.** -| 항목 | 특징 | -| --------------- | -------------------------------------------------------- | -| **안정성** | Greedy보다 낮음 | -| **다양성** | 높음 | -| **재현성** | 낮음 | -| **적합한 작업** | 창작, 아이디어 브레인스토밍, 표현의 다양성이 필요한 챗봇 | +생성은 "앞의 토큰들로 다음 토큰을 예측한다"의 반복인데, 이 모델은 애초에 그렇게 학습되지 않았다. 미래를 보는 것이 전제인 구조에서 미래를 가린 채 한 토큰씩 이어 쓰게 하면, 학습 시점과 추론 시점의 조건이 어긋난다. -단, 순수 Sampling만 사용하면 낮은 확률의 엉뚱한 token이 선택되어 문맥을 망칠 위험이 있다. 따라서 실무에서는 이를 보완하기 위해 **Temperature, Top-k, Top-p** 같은 제어 장치를 함께 엮어 사용한다. +즉 Encoder-only는 **이해(understanding)** 쪽에 특화된 구조다. --- -## Temperature +## Decoder-only: 인과적 문맥 -Temperature(온도)는 Softmax 함수에 개입하여 확률 분포의 '날카로움'을 조절하는 파라미터다. - -$$ \text{softmax}(\text{logits} / \text{temperature}) $$ - -Temperature가 낮으면 높은 logit을 가진 token에 확률이 더 뾰족하게 집중되고, 반대로 높으면 하위권 token들에게 확률이 넓게 분산된다. +Decoder-only 모델은 미래 토큰을 mask로 가린다. 각 토큰은 자기 자신과 그 이전 토큰만 참조한다. ```mermaid flowchart TD - A[Logits] --> B{Temperature} - B -->|낮음 < 1.0| C[상위 Token에 확률 집중] - B -->|높음 > 1.0| D[여러 Token으로 확률 분산] - C --> E[더 보수적이고 안정적인 출력] - D --> F[더 창의적이고 다양한 출력] - + A[현재까지의 Token Sequence] --> B[Masked Self-Attention] + B --> C[Last Hidden Vector] + C --> D[LM Head] + D --> E[다음 Token 확률 분포] + E --> F[Decoding] + F --> A ``` -### Temperature가 낮은 경우 - -Temperature가 1보다 작으면 확률 분포가 훨씬 날카로워진다. +### 학습 목표: Next Token Prediction -- 높은 확률 token이 더 강하게 선택됨 -- 출력이 결정적(Deterministic)으로 변함 -- 다양성은 줄어듦 +미래가 가려져 있으므로 "다음 토큰 맞히기"가 자연스럽게 성립한다. 별도의 `[MASK]` 토큰도, 사람이 붙인 라벨도 필요 없다. 텍스트 자체가 정답이 된다. -| Token | 원래 확률 | **낮은 Temp 적용 후** | -| ---------- | --------- | --------------------- | -| **마셨다** | 0.60 | **0.82** (집중) | -| 좋아한다 | 0.20 | 0.10 | -| 샀다 | 0.15 | 0.07 | - -기술 문서 QA, 코드 디버깅, 장애 분석처럼 **정확성과 안정성**이 생명일 때는 낮은 temperature가 적합하다. +```text +입력: 나는 → 정답: 커피를 +입력: 나는 커피를 → 정답: 마셨다 +입력: 나는 커피를 마셨다 → 정답: . +``` -### Temperature가 높은 경우 +이 학습 방식은 [Pretraining과 Fine-tuning](/blog/2026-07-03-llm-training-pretraining과-fine-tuning)에서 더 자세히 다룬다. -Temperature가 1보다 크면 확률 분포가 둥글고 평평해진다. +### 생성이 구조에 내장되어 있다 -- 낮은 확률 token도 선택될 가능성이 커짐 -- 출력 다양성이 크게 증가함 -- 산만하거나 부정확한 출력이 나올 위험도 증가함 +추론 시점의 동작이 학습 시점과 정확히 같다는 점이 중요하다. 마지막 위치의 hidden vector로 다음 토큰의 확률 분포를 만들고, [Decoding](/blog/2026-07-03-llm-core-decoding) 전략으로 토큰 하나를 골라 sequence 뒤에 붙인 뒤, 같은 과정을 반복한다. -| Token | 원래 확률 | **높은 Temp 적용 후** | -| ---------- | --------- | --------------------- | -| **마셨다** | 0.60 | **0.42** (감소) | -| 좋아한다 | 0.20 | 0.24 (증가) | -| 샀다 | 0.15 | 0.20 (증가) | +학습과 추론 사이에 구조적 간극이 없다. -아이디어 생성, 창작 문장 쓰기 등 **새로운 표현과 영감**이 필요할 때는 높은 temperature를 사용할 수 있다. +### 부수 효과: KV Cache -### Temperature 0에 대한 주의 +인과적 mask에는 실무적으로 큰 장점이 하나 더 있다. 각 토큰의 표현이 **자기 이전 토큰에만** 의존하므로, 이미 계산한 Key와 Value는 뒤에 토큰이 추가되어도 바뀌지 않는다. -일부 API나 도구에서는 `Temperature = 0`을 가장 결정적인 세팅으로 사용한다. 수식 그대로 보면 0으로 나누는 것은 정의되지 않으므로, 실제 구현부에서는 무작위성을 배제하고 **Greedy Decoding**에 가깝게 동작하도록 예외 처리되어 있다. +따라서 이전 단계의 K, V를 캐시해 두고 재사용할 수 있다. 토큰 하나를 생성할 때마다 전체 sequence를 다시 계산하지 않아도 된다. -- Temperature가 낮을수록 Greedy에 가까워진다. -- Temperature가 높을수록 Sampling의 무작위성이 커진다. +양방향 구조에서는 토큰이 하나 늘 때마다 모든 토큰의 표현이 바뀌므로 이런 캐싱이 성립하지 않는다. --- -## Top-k Sampling - -**Top-k**는 확률이 높은 상위 $k$개의 token만 후보로 남기고, 나머지는 단호하게 잘라내는 필터링 방식이다. - -> **Top-k:** 1등부터 $k$등까지만 본선에 진출시킨다. 나머지는 확률을 0으로 만들어 제외한다. - -예를 들어 $k = 3$이면 상위 3개 token만 남는다. - -| Token | Probability | 상태 | -| -------- | ----------- | --------- | -| 마셨다 | 0.60 | **유지** | -| 좋아한다 | 0.20 | **유지** | -| 샀다 | 0.15 | **유지** | -| 내렸다 | 0.03 | 제외 (0%) | -| . | 0.02 | 제외 (0%) | - -남은 3개의 후보 안에서만 다시 확률의 합이 100%가 되도록 조정한 뒤 Sampling을 진행한다. +## Encoder-Decoder: 두 구조를 잇기 -| $k$ 값 | 특징 | -| -------- | --------------------------------------------------------- | -| **작음** | 안정적이지만 다양성이 낮음 (1이면 Greedy와 동일) | -| **큼** | 다양성은 증가하지만, 품질 낮은 엉뚱한 후보가 섞일 수 있음 | +Encoder-Decoder는 이름 그대로 두 계층을 모두 쓴다. -Top-k는 문맥을 해치는 치명적인 오답을 차단하는 데 유용하다. 다만, 확률 분포의 형태와 관계없이 **무조건 고정된 개수**만 남기기 때문에 때로는 융통성이 떨어질 수 있다. +```mermaid +flowchart TD + A[Source: 나는 커피를 마셨다] --> B[Encoder
양방향 Self-Attention] + B --> C[Source Representation] ---- + D[Target: I drank] --> E[Decoder
Masked Self-Attention] + E --> F[Cross-Attention] + C --> F + F --> G[다음 Token 예측: coffee] +``` -## Top-p Sampling (Nucleus Sampling) +Encoder는 입력 문장 전체를 양방향으로 읽어 표현을 만든다. Decoder는 지금까지 생성한 출력을 인과적으로 보면서, **cross-attention**을 통해 encoder의 표현을 참조한다. -**Top-p**는 등수(개수)가 아니라 '누적 확률'이 $p$에 도달할 때까지 후보를 남기는 방식이다. +### Cross-Attention -> **Top-p:** 확률이 높은 순서대로 더해 나가다가, 누적 합이 $p$에 도달하는 순간 후보 풀(Pool)을 닫는다. +Self-Attention과 Cross-Attention의 차이는 Q, K, V의 출처다. -예를 들어 **$p = 0.90$** (누적 확률 90%)이라고 하자. +| 구분 | Query | Key / Value | +| ------------------- | ------------------- | ------------------ | +| **Self-Attention** | 자기 계층의 입력 | 자기 계층의 입력 | +| **Cross-Attention** | Decoder의 현재 상태 | **Encoder의 출력** | -| Token | Probability | **누적 확률** | 상태 | -| -------- | ----------- | ------------- | ------------------ | -| 마셨다 | 0.50 | 0.50 | **유지** | -| 좋아한다 | 0.25 | 0.75 | **유지** | -| 샀다 | 0.10 | 0.85 | **유지** | -| 내렸다 | 0.05 | **0.90** | **유지 (Cut-off)** | -| . | 0.03 | 0.93 | 제외 | +즉 cross-attention에서 decoder는 "지금 이 단어를 쓰려면 원문의 어느 부분을 봐야 하는가"를 묻는다. 기계 번역에서 정렬(alignment)에 해당하는 동작이다. -Top-p는 확률 분포의 모양에 따라 후보 개수가 유동적으로 변한다. +### 무엇에 적합한가 -- 1위 확률이 압도적이면 후보 수가 1~2개로 적어진다. -- 확률이 여러 token에 팽팽하게 분산되어 있다면 후보 수가 10개 이상으로 늘어난다. +입력과 출력이 명확히 구분되고, 출력이 입력의 변환인 과제에 잘 맞는다. -| 방식 | 컷오프 기준 | 후보 수 | -| --------- | ------------------------- | ------------------------ | -| **Top-k** | 상위 $k$개 token | **고정됨** | -| **Top-p** | 누적 확률 $p$까지의 token | **분포에 따라 유동적임** | +- 기계 번역 +- 요약 +- 문법 교정 +- 구조화된 형식 변환 -Top-p는 문맥 상황에 맞춰 유연하게 대처하므로 최신 자연어 생성 모델에서 널리 쓰인다. +입력을 완전히 양방향으로 읽은 뒤 출력을 생성하므로, 입력 전체에 대한 이해가 중요한 과제에서 유리하다. --- -## Temperature, Top-k, Top-p의 관계 +## 세 구조 비교 -이 세 가지 파라미터는 모두 Sampling의 다양성을 조절하지만 톱니바퀴처럼 서로 다른 역할을 맡고 있다. - -| 설정 | 역할 | -| --------------- | --------------------------------------- | -| **Temperature** | 확률 분포 자체의 날카로움/평평함 조절 | -| **Top-k** | 갯수를 기준으로 하위 후보를 잘라냄 | -| **Top-p** | 누적 확률을 기준으로 하위 후보를 잘라냄 | - -일반적인 파이프라인 흐름은 다음과 같다. +| 구분 | Encoder-only | Decoder-only | Encoder-Decoder | +| --------------- | ---------------------- | --------------------- | ------------------------- | +| **Attention** | 양방향 | 인과적(masked) | 양방향 + 인과적 + cross | +| **학습 목표** | MLM | Next Token Prediction | Seq2Seq (보통 denoising) | +| **입력 문맥** | 전체 | 이전 토큰만 | source 전체 / target 이전 | +| **텍스트 생성** | 어려움 | 자연스러움 | 자연스러움 | +| **KV Cache** | 해당 없음 | 가능 | decoder 쪽 가능 | +| **대표 모델** | BERT, RoBERTa, DeBERTa | GPT, LLaMA, Qwen | T5, BART | +| **주 용도** | 분류, 임베딩, 검색 | 대화, 생성, 범용 | 번역, 요약 | ```mermaid -flowchart TD - A[Logits] --> B[1. Temperature 적용] - B --> C[확률 분포 생성] - C --> D[2. Top-k 또는 Top-p로 후보 제한] - D --> E[3. 최종 Sampling] - E --> F[Selected Token] - +flowchart LR + A[Encoder-only] --> A1[이해에 특화] + B[Decoder-only] --> B1[생성에 특화] + C[Encoder-Decoder] --> C1[변환에 특화] ``` -**[ 목적에 따른 세팅 방향 ]** - -- **안정적인 답변이 필요할 때:** 낮은 Temperature, 작은 Top-p 또는 제한적인 Top-k. -- **다양하고 창의적인 답변이 필요할 때:** 높은 Temperature, 넓은 Top-p. - -특정 숫자 값 자체에 집착하기보다는 "이 파라미터가 전체 분포에 어떤 영향을 주는가"를 이해하는 편이 더 중요하다. - --- -## Repetition, Frequency, Presence Penalty +## 왜 LLM은 Decoder-only로 수렴했는가 -Decoding 단계에서는 모델이 똑같은 말을 앵무새처럼 반복하는 현상을 억제하기 위해 Logit 점수를 강제로 깎는 페널티 기법을 쓰기도 한다. +오늘날 우리가 LLM이라고 부르는 모델은 대부분 Decoder-only다. 성능이 절대적으로 우월해서라기보다, 확장에 유리한 성질이 여럿 겹친 결과에 가깝다. -- 이미 등장한 단어에 대한 선택 가능성을 낮춘다. -- 완전히 새로운 표현이 나올 가능성을 높인다. +**첫째, 학습 목표 하나로 모든 데이터를 쓸 수 있다.** -### Repetition Penalty +Next token prediction은 라벨이 필요 없다. 인터넷의 모든 텍스트가 그대로 학습 데이터가 된다. 데이터 규모를 키우는 데 병목이 없다. -연속된 텍스트 내에서 같은 token이 반복적으로 선택되는 것을 직접적으로 억제한다. +**둘째, 모든 과제를 하나의 형식으로 표현할 수 있다.** -> "나는 커피를 마셨다. 커피를 마셨다." 같은 기계적인 루프를 방지한다. +분류도, 요약도, 번역도 "프롬프트를 주고 이어 쓰게 한다"로 통일된다. Encoder-only 모델처럼 과제마다 별도의 head를 붙이고 fine-tuning할 필요가 없다. -### Frequency Penalty +```text +분류: "다음 문장의 감정은? 오늘 정말 좋았다 → " → "긍정" +번역: "다음을 영어로: 나는 커피를 마셨다 → " → "I drank coffee" +``` -특정 token이 **등장한 횟수**에 비례하여 점수를 깎는다. -특정 단어("매우", "진짜" 등)를 남용하여 문장이 지루해지는 것을 막아준다. +**셋째, in-context learning이 나타났다.** -### Presence Penalty +모델을 키우자 학습하지 않은 과제도 프롬프트에 예시 몇 개만 넣으면 수행하는 성질이 관찰됐다. 과제마다 모델을 다시 학습시키지 않아도 되므로 활용 비용이 크게 낮아졌다. -등장 횟수와 상관없이 특정 token이 한 번이라도 등장했는지(여부)를 따져 점수를 깎는다. -모델이 이전에 하던 이야기를 멈추고 새로운 화제나 단어를 꺼내도록 유도할 때 효과적이다. +**넷째, 추론 효율이 좋다.** -| Penalty | 기준 | 효과 | -| ---------------------- | ------------------- | --------------------------------- | -| **Repetition Penalty** | 연속된 문맥 내 반복 | 기계적인 동일 구문 반복 억제 | -| **Frequency Penalty** | 등장 횟수 비례 | 특정 단어의 과도한 쏠림/남용 방지 | -| **Presence Penalty** | 등장 여부 (0 or 1) | 새로운 토픽과 단어로의 전환 유도 | +앞서 본 KV cache 덕분에 긴 대화에서도 토큰당 연산량을 억제할 수 있다. --- -## Stop Sequence와 종료 조건 +## 그래도 Encoder는 사라지지 않았다 -Decoding은 token을 딱 하나 고르고 끝나는 단발성 과정이 아니다. 선택된 token을 기존 Sequence 뒤에 이어 붙이고, 다시 다음 token을 예측하는 무한 루프다. - -이 루프는 **종료 조건**을 만날 때 비로소 멈춘다. +Decoder-only가 주류가 됐다고 해서 Encoder 계열이 쓸모없어진 것은 아니다. 오히려 LLM 애플리케이션 안에서 함께 쓰인다. ```mermaid flowchart TD - A[현재 Token Sequence] --> B[확률 분포 계산] - B --> C[Decoding으로 Token 1개 선택] - C --> D[Token Sequence 맨 뒤에 추가] - D --> E{종료 조건 충족?} - E -->|No| A - E -->|Yes| F[최종 텍스트 반환 및 종료] - + A[사용자 질문] --> B[Encoder 기반 Embedding 모델] + B --> C[Vector DB 유사도 검색] + C --> D[관련 문서] + D --> E[Decoder-only LLM] + A --> E + E --> F[근거 기반 답변] ``` -**[ 대표적인 종료 조건 ]** - -- **EOS Token 선택:** 모델이 문맥상 문장이 끝났다고 판단해 마침표 격인 `[EOS]` (End Of Sequence) 토큰을 스스로 생성했을 때. -- **최대 출력 길이 도달:** 사전에 설정해 둔 Max Tokens 한계치에 다다랐을 때. -- **Stop Sequence 감지:** `\n\nUser:` 처럼 사용자가 지정한 특정 패턴이 출력될 조짐이 보이면 즉시 시스템이 컷오프(Cut-off) 할 때. - ---- - -## 같은 질문에 다른 답변이 나오는 이유 - -챗GPT에게 동일한 Prompt를 넣었는데 어제와 오늘의 답변이 달라지는 이유는 바로 이 Decoding 설정과 관련이 있다. - -Greedy 방식으로 세팅하면 1순위만 쫓아가므로 늘 비슷한 대답이 나온다. 하지만 기본적으로 LLM은 일정 수준의 Temperature와 Sampling이 활성화되어 있으므로, 매 턴마다 확률의 주사위를 굴리며 다른 갈래의 token을 뻗어 나갈 가능성을 품고 있다. - -> **같은 Prompt $\rightarrow$ 비슷한 확률 분포 $\rightarrow$ Decoding 주사위에 따라 다른 Token 선택** - -즉, LLM이 내뱉는 문장의 생동감과 다양성은 모델의 지식뿐만 아니라 Decoding 설정이 빚어낸 결과물이다. - ---- - -## 작업 유형별 Decoding 설정 방향 - -수행하려는 태스크에 따라 Decoding 파라미터의 밸런스를 적절히 맞춰주어야 최상의 결과를 얻을 수 있다. - -| 작업 | 설정 방향 | -| ------------------ | --------------------------------------- | -| **기술문서 요약** | 낮은 Temperature, 안정적 출력 유도 | -| **코드 생성/설명** | 낮은 Randomness, 형식 안정성 최우선 | -| **장애 로그 분석** | 팩트와 근거 중심, 과도한 Sampling 제한 | -| **JSON 출력** | 극히 낮은 Temperature, 출력 Schema 제약 | -| **브레인스토밍** | Sampling 허용, 다양한 영감 확보 | -| **창작 글쓰기** | 높은 Temperature 또는 넓은 Top-p 설정 | -| **후보 문장 생성** | 여러 번 Sampling하여 다채로운 후보 확보 | - ---- - -## Decoding은 학습이 아니다 +RAG 파이프라인이 대표적이다. 문서를 벡터로 압축해 검색하는 단계에서는 양방향 문맥이 유리하다. 문장 전체를 한 벡터로 요약하는 일은 원래 Encoder가 잘하는 일이기 때문이다. -마지막으로 짚고 넘어가야 할 점은, Decoding 과정에서 **모델의 파라미터(Weight)는 단 1%도 변하지 않는다**는 사실이다. +검색 결과를 다시 정렬하는 cross-encoder reranker도 같은 이유로 Encoder 계열을 쓴다. -| 구분 | Training (학습) | Decoding (추론) | -| ------------- | ------------------------------------ | ---------------------------------- | -| **시점** | 학습 단계 | 모델 배포 후 추론 단계 | -| **목적** | 정답 확률을 높이도록 가중치 업데이트 | 계산된 확률 안에서 다음 token 선택 | -| **입력** | 방대한 학습 데이터셋 | 사용자의 Prompt | -| **출력** | Loss 계산 및 Weight 수정 | 화면에 찍힐 실제 Token 문자 | -| **모델 변경** | **있음 (Weight 업데이트)** | **전혀 없음** | +정리하면 역할 분담에 가깝다. -Decoding은 모델을 똑똑하게 만드는 작업이 아니다. 이미 충분히 똑똑해진 모델이 머릿속에 띄운 선택지(확률 분포)를 우리가 **어떤 렌즈로 필터링하여 끄집어낼 것인가**에 대한 전략일 뿐이다. +| 단계 | 구조 | 이유 | +| ------------- | ------------ | ------------------------------- | +| 검색용 임베딩 | Encoder-only | 문장 전체를 한 벡터로 압축 | +| 재순위화 | Encoder-only | 질문-문서 쌍을 함께 읽고 점수화 | +| 답변 생성 | Decoder-only | 자연스러운 텍스트 생성 | --- ## 요약 및 정리 -Decoding은 LLM이 내놓은 Token 확률 분포표에서 실제 화면에 찍힐 Token을 최종 결정하는 과정이다. +세 구조는 다른 연산을 쓰는 것이 아니다. 같은 Transformer block에 **어떤 mask를 씌우고 무엇을 맞히도록 학습하는가**가 다를 뿐이다. -> **Token Probability Distribution $\rightarrow$ Decoding Strategy $\rightarrow$ Selected Token** +1. **Encoder-only:** mask 없이 양방향으로 읽고, 가려진 토큰을 복원하도록(MLM) 학습한다. 이해와 임베딩에 강하지만 생성은 어렵다. +2. **Decoder-only:** 미래 토큰을 가리고, 다음 토큰을 예측하도록 학습한다. 생성이 구조에 내장되어 있고 KV cache로 추론 효율도 좋다. +3. **Encoder-Decoder:** 입력은 양방향으로 읽고 출력은 인과적으로 생성하되, cross-attention으로 둘을 잇는다. 번역과 요약처럼 입력을 출력으로 변환하는 과제에 적합하다. -- **Greedy Decoding:** 무조건 1등만 고른다. 안정적이지만 단조롭다. -- **Sampling:** 확률에 기대어 주사위를 굴린다. 문맥이 다채로워진다. -- **Temperature:** 확률 분포의 날카로움을 조절한다. (낮으면 안정, 높으면 창의) -- **Top-k:** 순위를 기준으로 무조건 상위 $k$개의 후보만 남긴다. -- **Top-p:** 누적 확률 $p$에 도달할 때까지 유동적으로 후보를 남긴다. -- **Penalty:** 앵무새처럼 똑같은 표현을 반복하는 현상을 억제한다. -- **Stop Sequence / EOS:** 텍스트 생성의 브레이크(종료) 역할을 수행한다. +LLM이 Decoder-only로 수렴한 것은 라벨 없는 데이터로 무한히 확장할 수 있고, 모든 과제를 텍스트 이어 쓰기 하나로 통일할 수 있었기 때문이다. -목적에 맞는 완벽한 프롬프트를 짰더라도 Decoding 설정이 어긋나면 원하는 결과물을 얻기 힘들다. 이 설정값들의 원리를 이해하고 조작하는 것이 실무 LLM 활용의 핵심 키(Key)다. +다음 글에서는 이 구조가 만들어낸 출력 벡터가 어떻게 [Logit과 Softmax](/blog/2026-07-03-llm-core-logit과-softmax)를 거쳐 확률 분포가 되는지 살펴본다. diff --git "a/src/content/blog/2026-07-02-llm-transformer\354\231\200-self-attention-\354\235\264\355\225\264\355\225\230\352\270\260.md" "b/src/content/blog/2026-07-02-llm-transformer\354\231\200-self-attention-\354\235\264\355\225\264\355\225\230\352\270\260.md" index 76deb32..671c13b 100644 --- "a/src/content/blog/2026-07-02-llm-transformer\354\231\200-self-attention-\354\235\264\355\225\264\355\225\230\352\270\260.md" +++ "b/src/content/blog/2026-07-02-llm-transformer\354\231\200-self-attention-\354\235\264\355\225\264\355\225\230\352\270\260.md" @@ -1,17 +1,15 @@ --- title: 'Transformer와 self-attention' date: 2026-07-02 -updatedAt: 2026-07-02 kind: study series: llm-core -seriesOrder: 1 +seriesOrder: 2 tags: - llm - transformer - - attention - self-attention - - ai -description: Transformer가 sequence 데이터를 처리하는 방식과 Self-Attention이 Q, K, V 연산으로 토큰 간 관계를 계산하는 구조를 정리 + - attention +description: Transformer가 sequence 데이터를 처리하는 방식과 Self-Attention이 Q, K, V 연산으로 토큰 간 관계를 계산하는 구조를 정리했다. draft: false --- @@ -70,11 +68,15 @@ $$\text{입력 벡터} = \text{Token Embedding} + \text{Position Embedding}$$ ```mermaid flowchart TD - A[Input X] --> B[Masked Multi-Head Self-Attention] - B --> C[Add & LayerNorm] - C --> D[Feed Forward Network] - D --> E[Add & LayerNorm] - E --> F[Output X'] + A[Input X] --> B[LayerNorm] + B --> C[Masked Multi-Head Self-Attention] + C --> D[Add] + A --> D + D --> E[LayerNorm] + E --> F[Feed Forward Network] + F --> G[Add] + D --> G + G --> H[Output X'] ``` 각 구성 요소의 핵심 역할은 다음과 같다. @@ -97,26 +99,27 @@ Self-Attention은 각 토큰에 대해 다음 질문의 답을 수치로 계산 "현재 토큰을 명확히 표현하기 위해, 문장 안의 어떤 토큰을 얼마나 참고해야 하는가?" ``` -예를 들어 다음 문장을 보자. +예를 들어 다음 문장을 보자. 이 문장은 이 글 전체에서 같은 예시로 계속 사용한다. ```text -나는 어제 산 커피를 오늘 마셨다 +나는 오늘 커피를 마셨다 ``` `마셨다`라는 토큰의 문맥적 의미를 명확히 하려면 행동의 직접적인 대상인 `커피를`이 가장 중요하고, 시점 정보인 `오늘`도 밀접하게 관련된다. Self-Attention은 이 관계를 다음과 같이 수치화한다. ```mermaid flowchart LR - A[마셨다] --> B[나는: 0.05] - A --> C[어제: 0.05] - A --> D[산: 0.10] - A --> E[커피를: 0.55] - A --> F[오늘: 0.25] + A[마셨다] --> B[나는: 0.10] + A --> C[오늘: 0.30] + A --> D[커피를: 0.50] + A --> E[마셨다: 0.10] ``` -이 값은 고정된 것이 아니라, 학습 과정에서 가중치 행렬을 통해 모델이 스스로 최적화한다. 결과적으로 `마셨다`라는 토큰의 새로운 표현(Contextualized Vector)은 각 정보의 가중합으로 생성된다. +이 값은 고정된 것이 아니라, 학습 과정에서 가중치 행렬을 통해 모델이 스스로 최적화한다. 결과적으로 `마셨다`라는 토큰의 새로운 표현(Contextualized Vector)은 각 토큰의 Value 벡터를 이 비율로 섞어 만든다. + +$$\text{Output}_{\text{마셨다}} = 0.10\,V_{\text{나는}} + 0.30\,V_{\text{오늘}} + 0.50\,V_{\text{커피를}} + 0.10\,V_{\text{마셨다}}$$ -$$\text{마셨다의 새 표현} = 0.05 \times \text{나는} + 0.05 \times \text{어제} + 0.10 \times \text{산} + 0.55 \times \text{커피를} + 0.25 \times \text{오늘}$$ +여기서 섞이는 대상이 토큰의 원래 임베딩이 아니라 **Value 벡터**라는 점이 중요하다. 이 구분은 뒤의 Q, K, V 절에서 다시 다룬다. Attention은 특정 토큰 하나만 선택하는 하드 셀렉션(Hard Selection)이 아니다. 문맥에 따라 여러 토큰의 정보를 **비율대로 매끄럽게 섞어서** 현재 토큰의 의미를 새로이 빌딩하는 연산이다. @@ -173,47 +176,51 @@ flowchart TD $Q$ 행렬과 $K$ 행렬의 전치 행렬을 내적($QK^T$)하면, 문장 내 모든 토큰 쌍(Pair) 간의 원시 관련도 점수(Raw Attention Score)가 계산된다. 토큰이 4개라면 $4 \times 4$ 크기의 행렬이 나온다. +여기서 나오는 값은 **아직 확률이 아니다.** 단순한 내적 결과이므로 0~1 범위에 있지도 않고, 행의 합이 1이 되지도 않는다. 확률처럼 보이는 형태는 3단계 Softmax를 거친 뒤에야 나온다. + ```text [Key] 토큰들 - 나는 커피를 오늘 마셨다 -[Query] 나는 0.8 0.1 0.1 0.0 -[Query] 커피를 0.1 0.7 0.0 0.2 -[Query] 오늘 0.0 0.1 0.8 0.1 -[Query] 마셨다 0.1 0.5 0.3 0.1 + 나는 오늘 커피를 마셨다 +[Query] 나는 28.0 18.4 19.2 16.0 +[Query] 오늘 17.6 27.2 20.0 18.4 +[Query] 커피를 18.4 19.2 28.8 20.8 +[Query] 마셨다 16.8 25.6 29.6 16.8 ``` -`마셨다`(4번째 행)의 Query는 `커피를`(0.5)과 `오늘`(0.3)의 Key와 높은 내적값을 기록한다. 이 점수는 단순한 단어 유사도가 아니라 문법, 지시, 위치 등 학습된 복합적 관계가 반영된 결과다. +`마셨다`(4번째 행)의 Query는 `커피를`(29.6)과 `오늘`(25.6)의 Key와 높은 내적값을 기록한다. 이 점수는 단순한 단어 유사도가 아니라 문법, 지시, 위치 등 학습된 복합적 관계가 반영된 결과다. ### 2. Scaling: $\sqrt{d_k}$로 나누는 이유 -공식을 보면 내적값에 $\sqrt{d_k}$(Key 벡터의 차원수의 제곱근)를 나누는 스케일링 단계가 있다. - -벡터의 차원($d_k$)이 커질수록 내적값의 절대적인 크기도 커지기 쉽다. 내적값이 너무 커진 상태에서 바로 Softmax를 적용하면 극단적인 현상이 발생한다. +공식을 보면 내적값을 $\sqrt{d_k}$(Key 벡터 차원수의 제곱근)로 나누는 스케일링 단계가 있다. -$$\text{Score (Scaling 없음)} = [2, 4, 20, 3] \rightarrow \text{Softmax} \approx [0, 0, 1, 0]$$ +벡터의 차원($d_k$)이 커질수록 내적값의 절대적인 크기도 커지기 쉽다. 내적값이 너무 커진 상태에서 바로 Softmax를 적용하면, 가장 큰 값 하나에 확률이 거의 전부 쏠린다. 이렇게 분포가 뾰족해지면 Softmax의 그래디언트가 0에 가까워져 학습이 잘 진행되지 않는다. -스케일링이 없으면 특정 하나의 값만 1에 수렴하고 나머지는 0이 되어 버려, 다양한 토큰의 문맥을 부드럽게 반영하지 못하고 그래디언트 소실 문제를 야기한다. $\sqrt{d_k}$로 나누어 주면 점수의 분포가 완만해져 학습이 안정화된다. +$d_k = 64$인 head를 가정하면 $\sqrt{d_k} = 8$이므로, 위 `마셨다` 행은 다음과 같이 완만해진다. -$$\text{Score (Scaling 적용)} = [0.25, 0.5, 2.5, 0.375]$$ +$$[16.8,\ 25.6,\ 29.6,\ 16.8] \ \div\ 8 \ =\ [2.1,\ 3.2,\ 3.7,\ 2.1]$$ ### 3. Softmax: 점수를 확률 비율로 변환 스케일링된 점수 행렬에 Softmax를 취해 각 행의 합이 1이 되는 **Attention Weight(가중치)** 분포로 변환한다. +$$\text{softmax}([2.1,\ 3.2,\ 3.7,\ 2.1]) = [0.10,\ 0.30,\ 0.50,\ 0.10]$$ + ```mermaid flowchart LR - A["관련도 점수
(1.2, 0.3, 2.1, -0.4)"] --> B[Softmax] --> C["참고 비율
(0.24, 0.10, 0.58, 0.08)"] + A["스케일링된 점수
(2.1, 3.2, 3.7, 2.1)"] --> B[Softmax] --> C["참고 비율
(0.10, 0.30, 0.50, 0.10)"] ``` +이 단계를 지나야 비로소 "`마셨다`가 `커피를`을 50% 참고한다"고 말할 수 있다. + ### 4. Value 가중합 (Weighted Sum) 최종적으로 구한 확률 비율(Attention Weight)을 실제 정보인 Value($V$) 벡터에 곱해 가중합을 구한다. $$\text{Attention Output} = \text{Softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$ -`마셨다` 토큰의 최종 출력 벡터는 다음과 같이 구성되어, 단순한 단어 임베딩을 넘어 '주어와 목적어, 시간적 문맥 정보가 완전히 결합한 새로운 차원의 벡터'로 거듭난다. +`마셨다` 토큰의 최종 출력 벡터는 다음과 같이 구성되어, 단순한 단어 임베딩을 넘어 주어와 목적어, 시간 문맥이 결합된 표현이 된다. -$$\text{Output}_{\text{마셨다}} = 0.1 \times V_{\text{나는}} + 0.5 \times V_{\text{커피를}} + 0.3 \times V_{\text{오늘}} + 0.1 \times V_{\text{마셨다}}$$ +$$\text{Output}_{\text{마셨다}} = 0.10\,V_{\text{나는}} + 0.30\,V_{\text{오늘}} + 0.50\,V_{\text{커피를}} + 0.10\,V_{\text{마셨다}}$$ ```mermaid flowchart TD @@ -242,10 +249,11 @@ GPT 같은 디코더 전용(Decoder-only) 모델은 이전 토큰들을 바탕 이를 방지하기 위해 미래 토큰 행렬 위치를 가려버리는 **Masking** 작업을 수행한다. ```text - 나는 커피를 마셨다 -나는 O X X -커피를 O O X -마셨다 O O O + 나는 오늘 커피를 마셨다 +나는 O X X X +오늘 O O X X +커피를 O O O X +마셨다 O O O O ``` 구현 상으로는 소프트맥스를 통과하기 전 미래 토큰의 내적 점수 위치에 $-\infty$를 더해준다. @@ -274,7 +282,9 @@ Self-Attention을 한 번만 수행(Single-Head)하면 문장을 단 하나의 - `you` $\leftrightarrow$ `like` (주어-동사 호응 관계) - `coffee` $\leftrightarrow$ `tea` (대등한 선택 후보 관계) -**Multi-Head Attention**은 $Q, K, V$ 공간을 여러 개($h$개)의 Head로 쪼개어 병렬로 연산을 수행한다. 각 Head는 문장의 서로 다른 문법적, 의미적 관계를 나누어 포착한다. +**Multi-Head Attention**은 $Q, K, V$ 공간을 여러 개($h$개)의 Head로 쪼개어 병렬로 연산을 수행한다. 각 Head는 서로 다른 부분공간에서 attention을 계산하므로, 하나의 관점으로 볼 때보다 다양한 관계를 담을 수 있다. + +다만 아래 그림처럼 "1번 Head는 문장 구조, 2번 Head는 주어-동사 관계"처럼 역할이 깔끔하게 나뉘는 것은 아니다. 이는 이해를 돕기 위한 도식이고, 실제로는 여러 Head가 비슷한 패턴을 중복해서 학습하거나 해석하기 어려운 패턴을 잡는 경우가 더 많다. ```mermaid flowchart TD @@ -304,20 +314,33 @@ Self-Attention이 토큰 간의 정보를 '교환하고 섞는 역할'을 끝내 ```mermaid flowchart TD - A[Input X] --> B[Masked Multi-Head Self-Attention] - B --> C[Add
X + Attention] - A --> C - C --> D[LayerNorm] - D --> E[Feed Forward Network] - E --> F[Add
D + FFN] - D --> F - F --> G[LayerNorm] + A[Input X] --> B[LayerNorm] + B --> C[Masked Multi-Head Self-Attention] + C --> D[Add
X + Attention] + A --> D + D --> E[LayerNorm] + E --> F[Feed Forward Network] + F --> G[Add
D + FFN] + D --> G G --> H[Output X'] ``` 1. **Feed Forward Network (FFN):** Attention이 여러 토큰의 정보를 융합했다면, FFN은 다른 토큰을 보지 않고 각 토큰별(Position-wise)로 개별 작동하며 융합된 특징을 비선형 변환하여 심층 표현을 완성한다. 2. **Residual Connection:** 연산 결과에 원래의 입력값을 그대로 더해준다 ($\text{Output} = X + \text{SubLayer}(X)$). 레이어가 깊어져도 초기 정보가 왜곡 없이 끝까지 흘러갈 수 있도록 통로를 열어주어 그래디언트 흐름을 안정화한다. +### Pre-LN과 Post-LN + +위 그림에서 LayerNorm이 각 sub-layer **앞에** 놓인 점에 주의할 필요가 있다. 이 방식을 **Pre-LN**이라고 한다. + +2017년 원 논문의 Transformer는 sub-layer를 통과한 뒤에 정규화하는 **Post-LN**($\text{LayerNorm}(X + \text{SubLayer}(X))$) 구조였다. 하지만 Post-LN은 레이어가 깊어질수록 학습 초기에 발산하기 쉬워, learning rate warmup 같은 장치에 크게 의존했다. + +| 구분 | 순서 | 특징 | +| ----------- | -------------------------- | ---------------------------------------------------- | +| **Post-LN** | SubLayer → Add → LayerNorm | 원 논문 구조. 깊은 모델에서 학습이 불안정 | +| **Pre-LN** | LayerNorm → SubLayer → Add | residual 경로가 정규화를 거치지 않아 깊어져도 안정적 | + +Pre-LN에서는 입력 $X$가 정규화를 거치지 않고 그대로 residual 경로를 타고 흐르기 때문에, 레이어를 아무리 쌓아도 그래디언트가 안정적으로 전달된다. 이 때문에 GPT-2 이후의 Decoder-only 모델과 LLaMA 계열은 대부분 Pre-LN을 사용한다. + --- ## 요약 및 정리 @@ -328,6 +351,8 @@ Transformer는 과거 RNN처럼 시퀀스를 순차적으로 밟아 나가는 2. $QK^T$ 연산으로 모든 토큰 쌍 간의 연관도를 구한다. 3. $\sqrt{d_k}$로 스케일링하고 Softmax를 취해 '참고 비율'을 도출한다. 4. 확률 비율대로 $V$를 가중합하여 문맥이 온전히 녹아든 토큰 벡터를 얻는다. -5. 이 과정을 **Multi-Head**로 병렬화하고 **Transformer Block**으로 쌓아 올려 인간의 언어를 깊이 있게 이해한다. +5. 이 과정을 **Multi-Head**로 병렬화하고 **Transformer Block**으로 여러 층 쌓아, 토큰마다 문맥이 반영된 표현을 만든다. + +이 병렬화에 최적화된 아키텍처 위에 대규모 데이터와 연산량을 투입한 결과물이 오늘날의 대형 언어 모델(LLM)이다. -이 강건하고 병렬화에 최적화된 아키텍처 위에, 초거대 데이터와 연산량을 쏟아부어 탄생한 결과물이 바로 오늘날 우리가 사용하는 대형 언어 모델(LLM)이다. +다음 글에서는 같은 Transformer block이 attention mask에 따라 [Encoder-only와 Decoder-only](/blog/2026-07-02-llm-encoder-only와-decoder-only-구조-이해하기)로 갈라지는 과정을 살펴본다. diff --git "a/src/content/blog/2026-07-02-unilink-tcp-nodelay-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" "b/src/content/blog/2026-07-02-wirestead-tcp-nodelay-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" similarity index 80% rename from "src/content/blog/2026-07-02-unilink-tcp-nodelay-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" rename to "src/content/blog/2026-07-02-wirestead-tcp-nodelay-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" index b116494..5714ab7 100644 --- "a/src/content/blog/2026-07-02-unilink-tcp-nodelay-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" +++ "b/src/content/blog/2026-07-02-wirestead-tcp-nodelay-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" @@ -1,23 +1,21 @@ --- title: 'TCP_NODELAY 트러블슈팅' date: 2026-07-02 -updatedAt: 2026-07-02 -project: unilink +project: wirestead kind: devlog tags: - - unilink + - wirestead - cpp - tcp - - benchmarking - latency - - networking -description: TCP 44ms latency spike와 tcp_no_delay 기본값 개선 + - benchmarking +description: TCP payload 4096B부터 p50이 44ms로 고정되던 원인을 Nagle과 delayed ACK 조합으로 추적하고, 라이브러리 기본값을 tcp_no_delay=true로 바꾼 기록이다. draft: false --- ## 도입: 4096B부터 튄 44ms 지연의 원인은 TCP 기본값 -`unilink`는 TCP, UDP, Serial, UDS를 하나의 인터페이스로 감싸는 transport 계층을 제공한다. 이 글은 그중 TCP 벤치마크에서 발견한 44ms 지연 패턴과, 그 원인을 `TCP_NODELAY` 기본값까지 추적해 수정한 기록이다. +`wirestead`는 TCP, UDP, Serial, UDS를 하나의 인터페이스로 감싸는 transport 계층을 제공한다. 이 글은 그중 TCP 벤치마크에서 발견한 44ms 지연 패턴과, 그 원인을 `TCP_NODELAY` 기본값까지 추적해 수정한 기록이다. 문제는 TCP payload가 `4096B` 이상일 때부터 p50 latency가 약 `44ms`로 튀는 것이었다. 일시적인 outlier가 아니라, 10000번 반복 중 10000번 모두 같은 구간에서 재현됐다. @@ -56,7 +54,19 @@ mindmap ## 증상: 1024B까지는 정상, 4096B부터 44ms -v0.8.2 벤치마크 결과에서 이상한 패턴이 보였다. +측정 환경은 다음과 같다. latency 수치는 환경에 따라 크게 달라지므로 먼저 밝혀 둔다. + + + +| 항목 | 값 | +| --------- | ---------------------------------- | +| 버전 | wirestead v0.8.2 | +| 통신 경로 | (loopback / LAN / 실제 NIC 중 택1) | +| OS / 커널 | | +| 측정 방식 | payload 크기별 10000회 반복 | +| 지표 | p50 (중앙값) | + +이 조건에서 벤치마크 결과에 이상한 패턴이 보였다. | payload | p50 (us) | | -------- | --------- | @@ -120,22 +130,22 @@ flowchart TD 가설을 확인하기 위해 TCP 설정 코드를 봤다. ```cpp -// unilink/config/tcp_client_config.hpp +// wirestead/config/tcp_client_config.hpp bool tcp_no_delay = false; -// unilink/config/tcp_server_config.hpp +// wirestead/config/tcp_server_config.hpp bool tcp_no_delay = false; ``` config struct 기준으로 `tcp_no_delay` 기본값은 `false`였다. 즉, 기본적으로 Nagle 알고리즘이 켜져 있었다. -여기서 끝이 아니었다. unilink는 config struct 외에도 사용자가 실제로 많이 쓰는 wrapper builder API를 제공한다. 이 builder도 별도의 기본값을 가지고 있었다. +여기서 끝이 아니었다. wirestead는 config struct 외에도 사용자가 실제로 많이 쓰는 wrapper builder API를 제공한다. 이 builder도 별도의 기본값을 가지고 있었다. ```cpp -// unilink/wrapper/tcp_client/tcp_client.cc +// wirestead/wrapper/tcp_client/tcp_client.cc bool tcp_no_delay_ = false; -// unilink/wrapper/tcp_server/tcp_server.cc +// wirestead/wrapper/tcp_server/tcp_server.cc std::atomic tcp_no_delay_{false}; ``` @@ -164,7 +174,7 @@ flowchart TD | 벤치마크에서만 `.tcp_no_delay(true)` 설정 | 벤치마크 수치는 즉시 개선 | 실제 사용자는 같은 함정을 밟음 | | 라이브러리 기본값을 `true`로 변경 | 기본 동작이 저지연에 맞춰짐 | 작은 메시지를 자주 보내는 경우 패킷 수 증가 가능 | -벤치마크만 고치는 건 문제를 숨기는 것에 가깝다. unilink 사용자는 TCP, UDP, Serial, UDS를 같은 추상화로 사용할 것을 기대한다. 그런데 TCP만 기본값 때문에 40ms대 지연을 만들면 transport 간 동작 일관성이 깨진다. +벤치마크만 고치는 건 문제를 숨기는 것에 가깝다. wirestead 사용자는 TCP, UDP, Serial, UDS를 같은 추상화로 사용할 것을 기대한다. 그런데 TCP만 기본값 때문에 40ms대 지연을 만들면 transport 간 동작 일관성이 깨진다. 따라서 벤치마크가 아니라 라이브러리 기본값을 고치기로 했다. @@ -180,7 +190,7 @@ flowchart TD 물론 `tcp_no_delay = true`에도 trade-off는 있다. 작은 메시지를 매우 자주 보내는 workload에서는 Nagle이 해주던 packet coalescing이 줄어들어 패킷 수가 늘어날 수 있다. -하지만 unilink의 기본 사용처는 요청-응답, 제어 메시지, IPC성 통신에 가깝다. 이 경우 처리량보다 예측 가능한 latency가 더 중요하다. 대량 bulk 전송처럼 throughput이 중요한 사용자는 명시적으로 `.tcp_no_delay(false)`를 선택하면 된다. +하지만 wirestead의 기본 사용처는 요청-응답, 제어 메시지, IPC성 통신에 가깝다. 이 경우 처리량보다 예측 가능한 latency가 더 중요하다. 대량 bulk 전송처럼 throughput이 중요한 사용자는 명시적으로 `.tcp_no_delay(false)`를 선택하면 된다. 기본값은 저지연 쪽에 두는 것이 더 안전하다고 판단했다. @@ -235,7 +245,7 @@ flowchart TD ## 정리: 이상한 숫자는 구조적인 신호였다 -이번 문제는 TCP 성능이 갑자기 나빠진 문제가 아니었다. TCP 기본 동작인 Nagle 알고리즘이 unilink의 기본 사용 목적과 맞지 않았고, delayed ACK과 만나면서 특정 payload 크기부터 44ms 지연이 고정적으로 발생한 것이다. +이번 문제는 TCP 성능이 갑자기 나빠진 문제가 아니었다. TCP 기본 동작인 Nagle 알고리즘이 wirestead의 기본 사용 목적과 맞지 않았고, delayed ACK과 만나면서 특정 payload 크기부터 44ms 지연이 고정적으로 발생한 것이다. 정리하면 다음과 같다. diff --git "a/src/content/blog/2026-07-02-unilink-udp-backpressure-\353\215\260\353\223\234\353\235\275-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" "b/src/content/blog/2026-07-02-wirestead-udp-backpressure-\353\215\260\353\223\234\353\235\275-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" similarity index 85% rename from "src/content/blog/2026-07-02-unilink-udp-backpressure-\353\215\260\353\223\234\353\235\275-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" rename to "src/content/blog/2026-07-02-wirestead-udp-backpressure-\353\215\260\353\223\234\353\235\275-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" index 8d71def..c687076 100644 --- "a/src/content/blog/2026-07-02-unilink-udp-backpressure-\353\215\260\353\223\234\353\235\275-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" +++ "b/src/content/blog/2026-07-02-wirestead-udp-backpressure-\353\215\260\353\223\234\353\235\275-\355\212\270\353\237\254\353\270\224\354\212\210\355\214\205.md" @@ -1,24 +1,21 @@ --- title: 'UDP backpressure 데드락 트러블슈팅' date: 2026-07-02 -updatedAt: 2026-07-02 -project: unilink +project: wirestead kind: devlog tags: - - unilink + - wirestead - cpp - - async-io - - backpressure - - deadlock - concurrency - - debugging -description: UDP backpressure 데드락 재현과 lost wakeup 원인 분석 + - deadlock + - backpressure +description: UDP write 실패 후 프로세스가 멈추는 문제를 queue 미정리와 조건변수 lost wakeup 두 원인으로 좁혀 가며 분석한 기록이다. draft: false --- ## 도입: UDP write 실패가 아니라, backpressure 해제가 문제 -`unilink`의 backpressure 전략은 송신 queue가 압박을 받을 때 `Reliable`과 `BestEffort`로 동작을 나눈다. 이 글은 그 설계가 실제 환경에서 어떻게 깨졌고, 왜 단순한 에러 처리 버그가 아니라 동시성 문제까지 포함하고 있었는지를 정리한 기록이다. +`wirestead`의 backpressure 전략은 송신 queue가 압박을 받을 때 `Reliable`과 `BestEffort`로 동작을 나눈다. 이 글은 그 설계가 실제 환경에서 어떻게 깨졌고, 왜 단순한 에러 처리 버그가 아니라 동시성 문제까지 포함하고 있었는지를 정리한 기록이다. 문제는 UDP에서 payload `65536B`를 전송할 때 발생했다. 이 크기는 UDP datagram 한계인 약 `65507B`를 넘기 때문에 `Message too long` 에러가 나는 것 자체는 정상이다. 하지만 실제 문제는 그 다음이었다. write 실패 이후 벤치마크 프로세스가 종료되지 않고 멈췄고, GitHub Actions에서는 2시간 job timeout에 걸려 강제 종료됐다. @@ -84,7 +81,7 @@ x64에서 재현되지 않고 Jetson에서만 확률적으로 재현된다는 ```cpp if (ec) { - UNILINK_LOG_ERROR("udp", "write", ...); + WIRESTEAD_LOG_ERROR("udp", "write", ...); transition_to(LinkState::Error, ec); writing_ = false; // tx_, pending_, backpressure_active_ 정리 없이 return @@ -184,6 +181,24 @@ void wait_for_backpressure_clear(std::unique_lock& bp_lock) { 이 수정의 의미는 크다. 이제 notify를 놓치더라도 sender thread가 영원히 잠들지 않는다. 최악의 경우 50ms 뒤 스스로 상태를 다시 확인하고 빠져나올 수 있다. +### 왜 정석대로 고치지 않았나 + +여기서 짚고 넘어갈 점이 있다. lost wakeup의 교과서적인 해법은 bounded wait가 아니다. **predicate가 참조하는 상태를 변경할 때 조건변수와 같은 mutex를 잡고, 그 규율 안에서 notify하는 것**이다. 그렇게 하면 waiter가 predicate를 확인하고 wait에 들어가는 구간과 notify가 겹칠 수 없으므로 race 자체가 사라진다. + +이번 수정은 그 race를 없앤 것이 아니라, race가 발생해도 최대 50ms 뒤에 회복되도록 만든 것이다. 증상 완화에 가깝다. + +현재 상태를 정확히 적어 두면 다음과 같다. + +| 구분 | 내용 | +| --------------- | ------------------------------------------------- | +| **적용한 수정** | bounded wait으로 hang을 회복 가능한 지연으로 낮춤 | +| **남은 문제** | notify 유실 race 자체는 그대로 존재 | +| **정석 해법** | 상태 변경과 notify를 `bp_mutex_` 규율 안에서 수행 | + +정석 해법을 바로 적용하지 않은 것은, notify가 strand 위에서 실행되는 I/O handler 경로에서 발생하기 때문이다. 이 지점에서 사용자 thread가 잡을 수 있는 mutex를 기다리게 만들면 event loop 전체가 영향을 받을 수 있어, lock 순서를 다시 설계해야 한다. + +즉 이번 수정은 "고쳤다"가 아니라 "장애 등급을 낮추고 근본 수정을 후속 과제로 남겼다"에 가깝다. 상태 변경을 strand 안으로 모으는 구조 변경이 남아 있다. + ## 검증: 유닛 테스트는 일부만 증명했고, 실제 검증은 Jetson 반복 실행이었다 회귀 테스트는 두 개를 추가했다. diff --git a/src/content/blog/2026-07-03-llm-core-decoding.md b/src/content/blog/2026-07-03-llm-core-decoding.md index fb71251..0e7c861 100644 --- a/src/content/blog/2026-07-03-llm-core-decoding.md +++ b/src/content/blog/2026-07-03-llm-core-decoding.md @@ -1,18 +1,15 @@ --- title: Decoding date: 2026-07-03 -updatedAt: 2026-07-03 kind: study +series: llm-core +seriesOrder: 5 tags: - llm - decoding - sampling - temperature - - top-k - - top-p -description: 확률 분포에서 token을 선택하는 greedy, sampling, temperature, top-k, top-p 전략 정리 -series: llm-core -seriesOrder: 5 +description: 학습이 끝난 모델이 만든 확률 분포에서 실제 token을 고르는 단계를 greedy, sampling, temperature, top-k, top-p, penalty 기준으로 정리했다. draft: false --- @@ -22,61 +19,48 @@ LLM은 현재까지의 token sequence를 보고 다음 token 후보들의 확률 예를 들어 입력이 다음과 같다고 하자. -```text -나는 커피를 -``` +> "나는 커피를" -모델은 vocabulary 전체 token에 대해 다음과 같은 확률 분포를 만들 수 있다. +모델은 Vocabulary 전체 token에 대해 다음과 같은 확률 분포를 만들 수 있다. -| Token | Probability | -| -------- | ----------- | -| 마셨다 | 0.60 | -| 좋아한다 | 0.20 | -| 샀다 | 0.15 | -| 내렸다 | 0.03 | -| . | 0.02 | +| Token | Probability (확률) | +| :------- | :----------------- | +| 마셨다 | 0.60 | +| 좋아한다 | 0.20 | +| 샀다 | 0.15 | +| 내렸다 | 0.03 | +| . | 0.02 | -하지만 확률 분포만으로는 실제 문장이 생성되지 않는다. -출력으로 내보낼 token 하나를 선택해야 한다. +하지만 확률 분포만으로는 실제 문장이 완성되지 않는다. 이 수많은 후보 중에서 출력으로 내보낼 최종 token 하나를 선택해야 한다. -```text -확률 분포: -마셨다 0.60 -좋아한다 0.20 -샀다 0.15 -... +> **확률 분포:** 마셨다(0.60), 좋아한다(0.20), 샀다(0.15) ... +> **선택된 token:** 마셨다 -선택된 token: -마셨다 -``` - -이처럼 확률 분포에서 실제 token을 선택하는 과정을 **decoding**이라고 한다. +이처럼 계산된 확률 분포에서 실제 출력할 token을 선택하는 과정을 **Decoding**(디코딩)이라고 한다. --- -## 이 글에서 보는 범위 +## 이 글에서 다루는 범위 + +이 글은 LLM이 계산해 낸 '다음 token 확률 분포'를 기준으로, 실제 token을 선택하는 추론 전략을 정리한다. -이 글은 LLM이 만든 다음 token 확률 분포를 기준으로, 실제 token을 선택하는 전략을 정리한다. +**[ 다루는 내용 ]** -```text -다루는 내용: - Greedy Decoding - Sampling - Temperature - Top-k Sampling - Top-p Sampling -- Penalty 기반 logit 조정 +- Penalty 기반 Logit 조정 - Stop Sequence와 종료 조건 -``` -Decoding은 모델을 학습시키는 과정이 아니다. -이미 학습된 모델이 계산한 확률 분포에서, 어떤 방식으로 다음 token을 고를지 정하는 **추론 단계의 선택 전략**이다. +명심해야 할 점은, **Decoding은 모델을 학습(Training)시키는 과정이 아니다.** 이미 학습이 완료된 모델이 내뱉은 확률 분포 안에서, 어떤 방식으로 다음 token을 고를지 결정하는 **추론 단계의 선택 전략**이다. --- ## 전체 흐름 -Decoding은 softmax 이후에 일어난다. +Decoding은 Softmax 연산 직후에 일어난다. ```mermaid flowchart TD @@ -86,569 +70,337 @@ flowchart TD D --> E[다음 위치 예측 반복] ``` -조금 더 세분화하면 다음과 같다. +조금 더 세분화하여 이전 단계와 연결하면 다음과 같다. -```text -Logits -→ Softmax -→ Token Probability Distribution -→ Decoding Strategy -→ Selected Token -``` +> **Logits $\rightarrow$ Softmax $\rightarrow$ Token Probability Distribution $\rightarrow$ Decoding Strategy $\rightarrow$ Selected Token** -이 글의 핵심은 다음 질문이다. +이 글의 핵심은 다음 질문에 답하는 것이다. -```text -확률이 계산된 여러 token 후보 중에서 -어떤 기준으로 실제 token 하나를 선택할 것인가? -``` +> "확률이 계산된 여러 token 후보 중에서, 과연 어떤 기준으로 실제 token 하나를 선택할 것인가?" --- ## Decoding이란 무엇인가 -Decoding은 token 확률 분포에서 실제 출력 token을 선택하는 과정이다. +Decoding은 token 확률 분포에서 실제 출력할 token을 선택하는 과정이다. 앞선 예시의 확률 분포에서 token을 선택하는 방법은 단 하나가 아니다. -예를 들어 다음 확률 분포가 있다고 하자. +- 항상 **가장 높은 확률**의 token을 고를 수 있다. +- 확률에 따라 무작위(Random)로 뽑을 수 있다. +- 낮은 확률의 token을 아예 **후보에서 제외**할 수 있다. +- 확률 분포를 더 **날카롭게** 혹은 **평평하게** 조작할 수 있다. -| Token | Probability | -| -------- | ----------- | -| 마셨다 | 0.60 | -| 좋아한다 | 0.20 | -| 샀다 | 0.15 | -| 내렸다 | 0.03 | -| . | 0.02 | - -이 분포에서 token을 선택하는 방법은 하나가 아니다. - -```text -항상 가장 높은 확률의 token을 고를 수 있다. -확률에 따라 무작위로 뽑을 수 있다. -낮은 확률의 token을 후보에서 제외할 수 있다. -확률 분포를 더 날카롭게 만들 수 있다. -확률 분포를 더 평평하게 만들 수 있다. -``` - -이 선택 방식에 따라 LLM의 출력은 더 안정적일 수도 있고, 더 다양해질 수도 있다. +이 선택 방식에 따라 LLM의 출력은 항상 똑같고 안정적일 수도 있고, 매번 새롭고 창의적일 수도 있다. --- ## Greedy Decoding -가장 단순한 방식은 가장 확률이 높은 token을 선택하는 것이다. -이를 **Greedy Decoding**이라고 한다. +가장 단순한 방식은 무조건 가장 확률이 높은 token을 선택하는 것이다. 이를 Greedy Decoding(탐욕적 탐색)이라고 한다. -```text -Greedy Decoding: -가장 높은 확률을 가진 token을 선택한다. -``` +> **Greedy Decoding:** 가장 높은 확률을 가진 token을 1순위로 확정하여 선택한다. -예를 들어 다음 분포가 있다고 하자. +| Token | Probability | 선택 여부 | +| ---------- | ----------- | -------------- | +| **마셨다** | **0.60** | **선택 (1위)** | +| 좋아한다 | 0.20 | 제외 | +| 샀다 | 0.15 | 제외 | -| Token | Probability | -| -------- | ----------- | -| 마셨다 | 0.60 | -| 좋아한다 | 0.20 | -| 샀다 | 0.15 | -| 내렸다 | 0.03 | -| . | 0.02 | +**[ Greedy 방식의 특징 ]** -Greedy decoding은 `마셨다`를 선택한다. +| 항목 | 특징 | +| ---------- | --------------------------------------------------------------------- | +| **안정성** | 높음 | +| **다양성** | 낮음 | +| **재현성** | 높음 | +| **단점** | 출력이 단조롭거나 국소적으로 최적인 선택(Local Optima)에 갇힐 수 있음 | -```text -선택: -마셨다 -``` - -Greedy 방식의 특징은 다음과 같다. - -| 항목 | 특징 | -| ------ | ------------------------------------------------------- | -| 안정성 | 높음 | -| 다양성 | 낮음 | -| 재현성 | 높음 | -| 단점 | 출력이 단조롭거나 국소적으로 최적인 선택에 갇힐 수 있음 | - -Greedy decoding은 기술문서 요약, 코드 설명, 구조화된 답변처럼 일관성이 중요한 작업에 적합하다. -다만 항상 가장 높은 확률의 token만 고르기 때문에 다양한 표현을 만들기 어렵다. +Greedy decoding은 기술문서 요약, 코드 설명, 구조화된 데이터 추출처럼 **일관성이 중요한 작업**에 적합하다. 다만 항상 1순위의 token만 고르기 때문에 다채로운 표현을 만들어내기는 어렵다. --- ## Sampling -Sampling은 확률 분포에 따라 token을 무작위로 선택하는 방식이다. - -```text -Sampling: -확률이 높은 token이 더 자주 선택되지만, -항상 가장 높은 token만 선택하지는 않는다. -``` - -예를 들어 다음 분포가 있다고 하자. - -| Token | Probability | -| -------- | ----------- | -| 마셨다 | 0.60 | -| 좋아한다 | 0.20 | -| 샀다 | 0.15 | -| 내렸다 | 0.03 | -| . | 0.02 | +Sampling(샘플링)은 확률 분포에 따라 주사위를 굴리듯 무작위로 token을 선택하는 방식이다. -Sampling에서는 `마셨다`가 가장 자주 선택되지만, `좋아한다`나 `샀다`가 선택될 수도 있다. +> **Sampling:** 확률이 높은 token이 더 자주 선택되지만, 무조건 1등만 뽑히지는 않는다. 2위나 3위 token이 뽑힐 수도 있다. -```text -가능한 선택: -마셨다 -좋아한다 -샀다 -... -``` +| Token | Probability | 선택 가능성 | +| -------- | ----------- | ---------------- | +| 마셨다 | 0.60 | 가장 자주 선택됨 | +| 좋아한다 | 0.20 | 종종 선택됨 | +| 샀다 | 0.15 | 가끔 선택됨 | -Sampling은 출력 다양성을 만든다. -같은 prompt를 넣어도 답변이 달라질 수 있는 이유 중 하나가 sampling이다. +Sampling은 출력에 **다양성**을 부여한다. 똑같은 Prompt를 넣어도 매번 답변이 미묘하게 달라지는 이유가 바로 이 Sampling 덕분이다. -| 항목 | 특징 | -| ----------- | ---------------------------------------------- | -| 안정성 | Greedy보다 낮음 | -| 다양성 | 높음 | -| 재현성 | 낮음 | -| 적합한 작업 | 창작, 아이디어 생성, 표현 다양성이 필요한 작업 | +| 항목 | 특징 | +| --------------- | -------------------------------------------------------- | +| **안정성** | Greedy보다 낮음 | +| **다양성** | 높음 | +| **재현성** | 낮음 | +| **적합한 작업** | 창작, 아이디어 브레인스토밍, 표현의 다양성이 필요한 챗봇 | -Sampling은 다양성을 높이지만, 낮은 확률의 부적절한 token이 선택될 가능성도 만든다. -따라서 실제 사용에서는 temperature, top-k, top-p 같은 설정과 함께 사용되는 경우가 많다. +단, 순수 Sampling만 사용하면 낮은 확률의 엉뚱한 token이 선택되어 문맥을 망칠 위험이 있다. 따라서 실무에서는 이를 보완하기 위해 **Temperature, Top-k, Top-p** 같은 제어 장치를 함께 엮어 사용한다. --- ## Temperature -Temperature는 확률 분포의 날카로움을 조절하는 값이다. +Temperature(온도)는 Softmax 함수에 개입하여 확률 분포의 '날카로움'을 조절하는 파라미터다. -Softmax에 temperature를 적용하면 다음과 같은 형태가 된다. +$$ \text{softmax}(\text{logits} / \text{temperature}) $$ -```text -softmax(logits / temperature) -``` - -Temperature가 낮으면 높은 logit을 가진 token에 확률이 더 집중된다. -Temperature가 높으면 확률이 여러 token으로 더 넓게 분산된다. +Temperature가 낮으면 높은 logit을 가진 token에 확률이 더 뾰족하게 집중되고, 반대로 높으면 하위권 token들에게 확률이 넓게 분산된다. ```mermaid flowchart TD A[Logits] --> B{Temperature} - B -->|낮음| C[상위 Token에 확률 집중] - B -->|높음| D[여러 Token으로 확률 분산] - C --> E[더 안정적인 출력] - D --> F[더 다양한 출력] + B -->|낮음 < 1.0| C[상위 Token에 확률 집중] + B -->|높음 > 1.0| D[여러 Token으로 확률 분산] + C --> E[더 보수적이고 안정적인 출력] + D --> F[더 창의적이고 다양한 출력] ``` ### Temperature가 낮은 경우 -Temperature가 1보다 작으면 확률 분포가 더 날카로워진다. +Temperature가 1보다 작으면 확률 분포가 훨씬 날카로워진다. -```text -낮은 temperature: - 높은 확률 token이 더 강하게 선택됨 -- 출력이 더 결정적임 +- 출력이 결정적(Deterministic)으로 변함 - 다양성은 줄어듦 -``` -예를 들어 다음과 같은 경향이 생긴다. +| Token | 원래 확률 | **낮은 Temp 적용 후** | +| ---------- | --------- | --------------------- | +| **마셨다** | 0.60 | **0.82** (집중) | +| 좋아한다 | 0.20 | 0.10 | +| 샀다 | 0.15 | 0.07 | -| Token | 원래 확률 | 낮은 temperature 적용 후 | -| -------- | --------- | ------------------------ | -| 마셨다 | 0.60 | 0.82 | -| 좋아한다 | 0.20 | 0.10 | -| 샀다 | 0.15 | 0.07 | -| 내렸다 | 0.03 | 0.01 | -| . | 0.02 | 0.00 | - -기술문서 QA, 코드 설명, 장애 분석처럼 안정성이 필요한 작업에서는 낮은 temperature가 적합한 경우가 많다. +기술 문서 QA, 코드 디버깅, 장애 분석처럼 **정확성과 안정성**이 생명일 때는 낮은 temperature가 적합하다. ### Temperature가 높은 경우 -Temperature가 1보다 크면 확률 분포가 더 평평해진다. +Temperature가 1보다 크면 확률 분포가 둥글고 평평해진다. -```text -높은 temperature: - 낮은 확률 token도 선택될 가능성이 커짐 -- 출력 다양성이 증가함 -- 부정확하거나 산만한 출력 가능성도 증가함 -``` - -예시는 다음과 같다. +- 출력 다양성이 크게 증가함 +- 산만하거나 부정확한 출력이 나올 위험도 증가함 -| Token | 원래 확률 | 높은 temperature 적용 후 | -| -------- | --------- | ------------------------ | -| 마셨다 | 0.60 | 0.42 | -| 좋아한다 | 0.20 | 0.24 | -| 샀다 | 0.15 | 0.20 | -| 내렸다 | 0.03 | 0.08 | -| . | 0.02 | 0.06 | +| Token | 원래 확률 | **높은 Temp 적용 후** | +| ---------- | --------- | --------------------- | +| **마셨다** | 0.60 | **0.42** (감소) | +| 좋아한다 | 0.20 | 0.24 (증가) | +| 샀다 | 0.15 | 0.20 (증가) | -아이디어 생성, 창작 문장, 다양한 표현 후보가 필요한 작업에서는 상대적으로 높은 temperature를 사용할 수 있다. +아이디어 생성, 창작 문장 쓰기 등 **새로운 표현과 영감**이 필요할 때는 높은 temperature를 사용할 수 있다. ### Temperature 0에 대한 주의 -일부 API나 도구에서는 `temperature = 0`을 가장 결정적인 설정처럼 사용한다. -수식 그대로 보면 `logits / 0`은 정의되지 않으므로, 실제 구현에서는 보통 greedy에 가깝게 동작하도록 별도 처리한다. +일부 API나 도구에서는 `Temperature = 0`을 가장 결정적인 세팅으로 사용한다. 수식 그대로 보면 0으로 나누는 것은 정의되지 않으므로, 실제 구현부에서는 무작위성을 배제하고 **Greedy Decoding**에 가깝게 동작하도록 예외 처리되어 있다. -따라서 실무적으로는 다음처럼 이해하면 된다. - -```text -temperature가 낮을수록 greedy에 가까워진다. -temperature가 높을수록 sampling의 다양성이 커진다. -``` +- Temperature가 낮을수록 Greedy에 가까워진다. +- Temperature가 높을수록 Sampling의 무작위성이 커진다. --- ## Top-k Sampling -Top-k sampling은 확률이 높은 상위 `k`개의 token만 후보로 남기고, 그 안에서 sampling하는 방식이다. +**Top-k**는 확률이 높은 상위 $k$개의 token만 후보로 남기고, 나머지는 단호하게 잘라내는 필터링 방식이다. -```text -Top-k: -상위 k개 token만 후보로 유지한다. -나머지 token은 제외한다. -``` +> **Top-k:** 1등부터 $k$등까지만 본선에 진출시킨다. 나머지는 확률을 0으로 만들어 제외한다. -예를 들어 `k = 3`이면 상위 3개 token만 남긴다. +예를 들어 $k = 3$이면 상위 3개 token만 남는다. -| Token | Probability | 사용 여부 | +| Token | Probability | 상태 | | -------- | ----------- | --------- | -| 마셨다 | 0.60 | 사용 | -| 좋아한다 | 0.20 | 사용 | -| 샀다 | 0.15 | 사용 | -| 내렸다 | 0.03 | 제외 | -| . | 0.02 | 제외 | - -그 다음 남은 후보에서 확률을 다시 정규화하고 sampling한다. - -```text -Top-k 후보: -마셨다 -좋아한다 -샀다 +| 마셨다 | 0.60 | **유지** | +| 좋아한다 | 0.20 | **유지** | +| 샀다 | 0.15 | **유지** | +| 내렸다 | 0.03 | 제외 (0%) | +| . | 0.02 | 제외 (0%) | -이 후보 안에서 다시 확률을 맞춘 뒤 sampling -``` - -Top-k의 특징은 후보 개수를 고정한다는 점이다. +남은 3개의 후보 안에서만 다시 확률의 합이 100%가 되도록 조정한 뒤 Sampling을 진행한다. -| k 값 | 특징 | -| ---- | ------------------------------------- | -| 작음 | 안정적이지만 다양성 낮음 | -| 큼 | 다양성 증가, 낮은 품질 후보 포함 가능 | -| 1 | greedy와 유사 | +| $k$ 값 | 특징 | +| -------- | --------------------------------------------------------- | +| **작음** | 안정적이지만 다양성이 낮음 (1이면 Greedy와 동일) | +| **큼** | 다양성은 증가하지만, 품질 낮은 엉뚱한 후보가 섞일 수 있음 | -Top-k는 낮은 확률의 token이 선택되는 것을 막는 데 유용하다. -다만 분포 상황과 관계없이 후보 개수를 고정하기 때문에, 어떤 경우에는 불필요하게 많은 후보를 남기거나, 반대로 필요한 후보를 잘라낼 수 있다. +Top-k는 문맥을 해치는 치명적인 오답을 차단하는 데 유용하다. 다만, 확률 분포의 형태와 관계없이 **무조건 고정된 개수**만 남기기 때문에 때로는 융통성이 떨어질 수 있다. --- -## Top-p Sampling +## Top-p Sampling (Nucleus Sampling) -Top-p sampling은 누적 확률이 `p`에 도달할 때까지 상위 token을 후보로 남기는 방식이다. -이를 **nucleus sampling**이라고도 한다. +**Top-p**는 등수(개수)가 아니라 '누적 확률'이 $p$에 도달할 때까지 후보를 남기는 방식이다. -```text -Top-p: -확률이 높은 순서대로 token을 누적하고, -누적 확률이 p에 도달할 때까지 후보로 유지한다. -``` +> **Top-p:** 확률이 높은 순서대로 더해 나가다가, 누적 합이 $p$에 도달하는 순간 후보 풀(Pool)을 닫는다. -예를 들어 `p = 0.9`라고 하자. +예를 들어 **$p = 0.90$** (누적 확률 90%)이라고 하자. -| Token | Probability | 누적 확률 | 사용 여부 | -| -------- | ----------- | --------- | --------- | -| 마셨다 | 0.50 | 0.50 | 사용 | -| 좋아한다 | 0.25 | 0.75 | 사용 | -| 샀다 | 0.10 | 0.85 | 사용 | -| 내렸다 | 0.05 | 0.90 | 사용 | -| . | 0.03 | 0.93 | 제외 | +| Token | Probability | **누적 확률** | 상태 | +| -------- | ----------- | ------------- | ------------------ | +| 마셨다 | 0.50 | 0.50 | **유지** | +| 좋아한다 | 0.25 | 0.75 | **유지** | +| 샀다 | 0.10 | 0.85 | **유지** | +| 내렸다 | 0.05 | **0.90** | **유지 (Cut-off)** | +| . | 0.03 | 0.93 | 제외 | -Top-p는 확률 분포의 모양에 따라 후보 개수가 달라진다. +Top-p는 확률 분포의 모양에 따라 후보 개수가 유동적으로 변한다. -```text -확률이 상위 token에 집중되어 있으면 후보 수가 적어진다. -확률이 여러 token에 분산되어 있으면 후보 수가 많아진다. -``` - -Top-k와 Top-p의 차이는 다음과 같다. +- 1위 확률이 압도적이면 후보 수가 1~2개로 적어진다. +- 확률이 여러 token에 팽팽하게 분산되어 있다면 후보 수가 10개 이상으로 늘어난다. -| 방식 | 기준 | 후보 수 | -| ----- | ----------------------- | ---------------- | -| Top-k | 상위 k개 token | 고정 | -| Top-p | 누적 확률 p까지의 token | 분포에 따라 변함 | +| 방식 | 컷오프 기준 | 후보 수 | +| --------- | ------------------------- | ------------------------ | +| **Top-k** | 상위 $k$개 token | **고정됨** | +| **Top-p** | 누적 확률 $p$까지의 token | **분포에 따라 유동적임** | -Top-p는 문맥에 따라 후보 수가 유동적으로 바뀌므로, 자연어 생성에서 자주 사용된다. +Top-p는 문맥 상황에 맞춰 유연하게 대처하므로 최신 자연어 생성 모델에서 널리 쓰인다. --- ## Temperature, Top-k, Top-p의 관계 -Temperature, top-k, top-p는 모두 sampling의 품질과 다양성을 조절하는 설정이다. -하지만 역할은 다르다. +이 세 가지 파라미터는 모두 Sampling의 다양성을 조절하지만 톱니바퀴처럼 서로 다른 역할을 맡고 있다. -| 설정 | 역할 | -| ----------- | -------------------------------------- | -| Temperature | 확률 분포의 날카로움 조절 | -| Top-k | 상위 k개 token만 후보로 유지 | -| Top-p | 누적 확률 p 범위의 token만 후보로 유지 | +| 설정 | 역할 | +| --------------- | --------------------------------------- | +| **Temperature** | 확률 분포 자체의 날카로움/평평함 조절 | +| **Top-k** | 갯수를 기준으로 하위 후보를 잘라냄 | +| **Top-p** | 누적 확률을 기준으로 하위 후보를 잘라냄 | -일반적인 적용 흐름은 다음처럼 볼 수 있다. +일반적인 파이프라인 흐름은 다음과 같다. ```mermaid flowchart TD - A[Logits] --> B[Temperature 적용] + A[Logits] --> B[1. Temperature 적용] B --> C[확률 분포 생성] - C --> D[Top-k 또는 Top-p로 후보 제한] - D --> E[Sampling] + C --> D[2. Top-k 또는 Top-p로 후보 제한] + D --> E[3. 최종 Sampling] E --> F[Selected Token] ``` -예를 들어 안정적인 답변이 필요하다면 다음 방향을 사용할 수 있다. - -```text -낮은 temperature -작은 top-p 또는 제한적인 top-k -짧고 명확한 출력 형식 -``` - -다양한 답변이 필요하다면 다음 방향을 사용할 수 있다. +**[ 목적에 따른 세팅 방향 ]** -```text -높은 temperature -넓은 top-p -sampling 허용 -``` +- **안정적인 답변이 필요할 때:** 낮은 Temperature, 작은 Top-p 또는 제한적인 Top-k. +- **다양하고 창의적인 답변이 필요할 때:** 높은 Temperature, 넓은 Top-p. -단, 설정값은 모델, API, 작업 유형에 따라 다르게 동작할 수 있다. -따라서 특정 값 자체보다 “어떤 방향의 효과를 주는가”를 이해하는 편이 중요하다. +특정 숫자 값 자체에 집착하기보다는 "이 파라미터가 전체 분포에 어떤 영향을 주는가"를 이해하는 편이 더 중요하다. --- ## Repetition, Frequency, Presence Penalty -Decoding에서는 반복 출력을 줄이기 위해 logit을 조정하기도 한다. -대표적으로 repetition penalty, frequency penalty, presence penalty가 있다. - -이들은 softmax 이전 또는 sampling 이전에 token 점수를 조정하는 방식으로 볼 수 있다. +Decoding 단계에서는 모델이 똑같은 말을 앵무새처럼 반복하는 현상을 억제하기 위해 Logit 점수를 강제로 깎는 페널티 기법을 쓰기도 한다. -```text -목적: -같은 token이나 표현이 반복되는 것을 줄인다. -이미 등장한 단어에 대한 선택 가능성을 낮춘다. -새로운 표현이 나올 가능성을 높인다. -``` +- 이미 등장한 단어에 대한 선택 가능성을 낮춘다. +- 완전히 새로운 표현이 나올 가능성을 높인다. ### Repetition Penalty -Repetition penalty는 이미 나온 token이 다시 선택되는 것을 줄이기 위한 설정이다. +연속된 텍스트 내에서 같은 token이 반복적으로 선택되는 것을 직접적으로 억제한다. -```text -같은 token 반복을 억제한다. -``` - -예를 들어 모델이 다음처럼 반복하려는 경향을 보일 수 있다. +> "나는 커피를 마셨다. 커피를 마셨다." 같은 기계적인 루프를 방지한다. -```text -나는 커피를 마셨다. 커피를 마셨다. 커피를 마셨다. -``` - -Repetition penalty는 이미 나온 token의 점수를 낮추어 이런 반복을 줄인다. - -## Frequency Penalty - -Frequency penalty는 token이 등장한 횟수에 비례해 점수를 낮추는 방식이다. - -```text -많이 나온 token일수록 더 강하게 억제한다. -``` +### Frequency Penalty -이미 여러 번 나온 단어가 계속 반복되는 것을 줄이는 데 사용된다. +특정 token이 **등장한 횟수**에 비례하여 점수를 깎는다. +특정 단어("매우", "진짜" 등)를 남용하여 문장이 지루해지는 것을 막아준다. ### Presence Penalty -Presence penalty는 token이 한 번이라도 등장했는지 여부를 기준으로 점수를 조정한다. +등장 횟수와 상관없이 특정 token이 한 번이라도 등장했는지(여부)를 따져 점수를 깎는다. +모델이 이전에 하던 이야기를 멈추고 새로운 화제나 단어를 꺼내도록 유도할 때 효과적이다. -```text -이미 등장한 token이면 점수를 낮춘다. -등장 횟수보다는 등장 여부에 반응한다. -``` - -Frequency penalty는 “몇 번 나왔는가”에 더 민감하고, presence penalty는 “나온 적이 있는가”에 더 민감하다. - -| Penalty | 기준 | 효과 | -| ------------------ | ---------- | -------------------- | -| Repetition penalty | 반복 token | 같은 표현 반복 억제 | -| Frequency penalty | 등장 횟수 | 많이 나온 token 억제 | -| Presence penalty | 등장 여부 | 새로운 token 유도 | +| Penalty | 기준 | 효과 | +| ---------------------- | ------------------- | --------------------------------- | +| **Repetition Penalty** | 연속된 문맥 내 반복 | 기계적인 동일 구문 반복 억제 | +| **Frequency Penalty** | 등장 횟수 비례 | 특정 단어의 과도한 쏠림/남용 방지 | +| **Presence Penalty** | 등장 여부 (0 or 1) | 새로운 토픽과 단어로의 전환 유도 | --- ## Stop Sequence와 종료 조건 -Decoding은 token을 하나 선택하고 끝나는 과정이 아니다. -선택된 token을 sequence 뒤에 추가하고, 다시 다음 token을 예측한다. +Decoding은 token을 딱 하나 고르고 끝나는 단발성 과정이 아니다. 선택된 token을 기존 Sequence 뒤에 이어 붙이고, 다시 다음 token을 예측하는 무한 루프다. -이 과정은 종료 조건을 만날 때까지 반복된다. +이 루프는 **종료 조건**을 만날 때 비로소 멈춘다. ```mermaid flowchart TD A[현재 Token Sequence] --> B[확률 분포 계산] - B --> C[Decoding으로 Token 선택] - C --> D[Token Sequence에 추가] - D --> E{종료 조건?} + B --> C[Decoding으로 Token 1개 선택] + C --> D[Token Sequence 맨 뒤에 추가] + D --> E{종료 조건 충족?} E -->|No| A - E -->|Yes| F[Final Output] + E -->|Yes| F[최종 텍스트 반환 및 종료] ``` -대표적인 종료 조건은 다음과 같다. - -```text -EOS token이 선택됨 -최대 출력 token 수에 도달함 -stop sequence가 생성됨 -외부 시스템이 생성을 중단함 -``` - -`EOS`는 End Of Sequence를 의미하는 특수 token이다. -모델이 `[EOS]`를 선택하면 응답이 끝났다고 볼 수 있다. - -Stop sequence는 시스템이 지정한 문자열이나 token 패턴이다. - -예를 들어 stop sequence를 다음처럼 둘 수 있다. - -```text -stop sequence: -"\n\nUser:" -``` +**[ 대표적인 종료 조건 ]** -모델 출력 중 이 패턴이 나오면 생성을 중단할 수 있다. +- **EOS Token 선택:** 모델이 문맥상 문장이 끝났다고 판단해 마침표 격인 `[EOS]` (End Of Sequence) 토큰을 스스로 생성했을 때. +- **최대 출력 길이 도달:** 사전에 설정해 둔 Max Tokens 한계치에 다다랐을 때. +- **Stop Sequence 감지:** `\n\nUser:` 처럼 사용자가 지정한 특정 패턴이 출력될 조짐이 보이면 즉시 시스템이 컷오프(Cut-off) 할 때. --- ## 같은 질문에 다른 답변이 나오는 이유 -같은 prompt를 넣었는데 답변이 달라지는 이유는 decoding 설정과 관련이 있다. +챗GPT에게 동일한 Prompt를 넣었는데 어제와 오늘의 답변이 달라지는 이유는 바로 이 Decoding 설정과 관련이 있다. -Greedy 방식에 가깝게 설정하면 같은 입력에서 비슷한 답변이 나올 가능성이 높다. -Sampling, 높은 temperature, 넓은 top-p를 사용하면 같은 입력에서도 다른 token이 선택될 수 있다. +Greedy 방식으로 세팅하면 1순위만 쫓아가므로 늘 비슷한 대답이 나온다. 하지만 기본적으로 LLM은 일정 수준의 Temperature와 Sampling이 활성화되어 있으므로, 매 턴마다 확률의 주사위를 굴리며 다른 갈래의 token을 뻗어 나갈 가능성을 품고 있다. -```text -같은 prompt -→ 같은 확률 분포 또는 비슷한 확률 분포 -→ decoding 설정에 따라 다른 token 선택 가능 -``` +> **같은 Prompt $\rightarrow$ 비슷한 확률 분포 $\rightarrow$ Decoding 주사위에 따라 다른 Token 선택** -따라서 LLM 출력의 다양성은 모델 자체만이 아니라 decoding 설정의 영향을 받는다. +즉, LLM이 내뱉는 문장의 생동감과 다양성은 모델의 지식뿐만 아니라 Decoding 설정이 빚어낸 결과물이다. --- ## 작업 유형별 Decoding 설정 방향 -Decoding 설정은 작업 유형에 따라 다르게 잡아야 한다. - -| 작업 | 설정 방향 | -| -------------- | ----------------------------------- | -| 기술문서 요약 | 낮은 temperature, 안정적 출력 | -| 코드 설명 | 낮은 randomness, 형식 안정성 | -| 장애 분석 | 근거 중심, 과도한 sampling 제한 | -| JSON 출력 | 낮은 temperature, 출력 schema 제약 | -| 브레인스토밍 | sampling 허용, 다양성 확보 | -| 창작 글쓰기 | 높은 temperature 또는 넓은 top-p | -| 후보 문장 생성 | 여러 번 sampling해 다양한 후보 확보 | +수행하려는 태스크에 따라 Decoding 파라미터의 밸런스를 적절히 맞춰주어야 최상의 결과를 얻을 수 있다. -예를 들어 기술문서 기반 QA에서 높은 temperature를 사용하면 문서에 없는 표현이 섞일 가능성이 커질 수 있다. -반대로 아이디어 생성에서 너무 낮은 temperature를 사용하면 답변이 보수적이고 반복적으로 나올 수 있다. +| 작업 | 설정 방향 | +| ------------------ | --------------------------------------- | +| **기술문서 요약** | 낮은 Temperature, 안정적 출력 유도 | +| **코드 생성/설명** | 낮은 Randomness, 형식 안정성 최우선 | +| **장애 로그 분석** | 팩트와 근거 중심, 과도한 Sampling 제한 | +| **JSON 출력** | 극히 낮은 Temperature, 출력 Schema 제약 | +| **브레인스토밍** | Sampling 허용, 다양한 영감 확보 | +| **창작 글쓰기** | 높은 Temperature 또는 넓은 Top-p 설정 | +| **후보 문장 생성** | 여러 번 Sampling하여 다채로운 후보 확보 | --- ## Decoding은 학습이 아니다 -Decoding은 모델을 학습시키는 과정이 아니다. -모델의 parameter를 업데이트하지 않는다. - -```text -Training: -정답 token에 더 높은 확률을 주도록 weight를 업데이트한다. - -Decoding: -이미 계산된 확률 분포에서 token을 선택한다. -``` - -차이를 표로 정리하면 다음과 같다. - -| 구분 | Training | Decoding | -| -------------- | -------------------- | --------------- | -| 시점 | 학습 단계 | 추론 단계 | -| 목적 | 모델 weight 업데이트 | 다음 token 선택 | -| 입력 | 학습 데이터 | 사용자 prompt | -| 출력 | loss, updated weight | selected token | -| parameter 변경 | 있음 | 없음 | - -Decoding은 모델의 지식을 새로 학습시키는 작업이 아니다. -모델이 이미 계산한 분포를 어떻게 사용할지 정하는 추론 전략이다. - ---- - -## Decoding 전략 정리 +마지막으로 짚고 넘어가야 할 점은, Decoding 과정에서 **모델의 파라미터(Weight)는 단 1%도 변하지 않는다**는 사실이다. -대표적인 decoding 전략과 설정을 정리하면 다음과 같다. +| 구분 | Training (학습) | Decoding (추론) | +| ------------- | ------------------------------------ | ---------------------------------- | +| **시점** | 학습 단계 | 모델 배포 후 추론 단계 | +| **목적** | 정답 확률을 높이도록 가중치 업데이트 | 계산된 확률 안에서 다음 token 선택 | +| **입력** | 방대한 학습 데이터셋 | 사용자의 Prompt | +| **출력** | Loss 계산 및 Weight 수정 | 화면에 찍힐 실제 Token 문자 | +| **모델 변경** | **있음 (Weight 업데이트)** | **전혀 없음** | -| 항목 | 의미 | 효과 | -| ------------------ | --------------------------- | -------------------------- | -| Greedy | 가장 높은 확률 token 선택 | 안정적, 다양성 낮음 | -| Sampling | 확률 분포에 따라 token 선택 | 다양성 증가 | -| Temperature | 분포의 날카로움 조절 | 낮으면 안정적, 높으면 다양 | -| Top-k | 상위 k개 token만 후보 유지 | 후보 수 고정 | -| Top-p | 누적 확률 p 범위 후보 유지 | 후보 수 유동적 | -| Repetition penalty | 반복 token 점수 조정 | 반복 감소 | -| Stop sequence | 특정 패턴에서 생성 종료 | 출력 범위 제어 | - -Decoding 설정은 답변의 품질, 안정성, 다양성, 재현성에 영향을 준다. +Decoding은 모델을 똑똑하게 만드는 작업이 아니다. 이미 충분히 똑똑해진 모델이 머릿속에 띄운 선택지(확률 분포)를 우리가 **어떤 렌즈로 필터링하여 끄집어낼 것인가**에 대한 전략일 뿐이다. --- -## 정리 - -Decoding은 LLM이 만든 token 확률 분포에서 실제 출력 token을 선택하는 과정이다. - -```text -Token Probability Distribution -→ Decoding Strategy -→ Selected Token -``` - -Greedy decoding은 가장 높은 확률의 token을 선택한다. -안정적이고 재현성이 높지만 출력이 단조로울 수 있다. - -Sampling은 확률 분포에 따라 token을 선택한다. -출력 다양성을 만들지만, 낮은 확률의 부적절한 token이 선택될 가능성도 있다. +## 요약 및 정리 -Temperature는 확률 분포의 날카로움을 조절한다. +Decoding은 LLM이 내놓은 Token 확률 분포표에서 실제 화면에 찍힐 Token을 최종 결정하는 과정이다. -```text -낮은 temperature: -상위 token에 확률 집중, 안정적 출력 +> **Token Probability Distribution $\rightarrow$ Decoding Strategy $\rightarrow$ Selected Token** -높은 temperature: -여러 token으로 확률 분산, 다양한 출력 -``` - -Top-k는 상위 k개 token만 후보로 남긴다. -Top-p는 누적 확률 p에 도달할 때까지 후보를 남긴다. - -```text -Top-k: -후보 개수를 고정한다. - -Top-p: -확률 분포에 따라 후보 개수가 달라진다. -``` +- **Greedy Decoding:** 무조건 1등만 고른다. 안정적이지만 단조롭다. +- **Sampling:** 확률에 기대어 주사위를 굴린다. 문맥이 다채로워진다. +- **Temperature:** 확률 분포의 날카로움을 조절한다. (낮으면 안정, 높으면 창의) +- **Top-k:** 순위를 기준으로 무조건 상위 $k$개의 후보만 남긴다. +- **Top-p:** 누적 확률 $p$에 도달할 때까지 유동적으로 후보를 남긴다. +- **Penalty:** 앵무새처럼 똑같은 표현을 반복하는 현상을 억제한다. +- **Stop Sequence / EOS:** 텍스트 생성의 브레이크(종료) 역할을 수행한다. -Repetition, frequency, presence penalty는 반복 표현을 줄이기 위해 token 점수를 조정한다. -Stop sequence와 EOS token은 생성 종료를 제어한다. +목적에 맞는 완벽한 프롬프트를 짰더라도 Decoding 설정이 어긋나면 원하는 결과물을 얻기 힘들다. 이 설정값들의 원리를 이해하고 조작하는 것이 실무 LLM 활용의 핵심 키(Key)다. -Decoding은 학습이 아니다. -모델 weight를 바꾸는 과정이 아니라, 이미 학습된 모델이 계산한 확률 분포에서 token을 선택하는 추론 단계의 전략이다. +여기까지가 학습이 끝난 모델이 텍스트를 생성하는 과정이다. 그렇다면 모델은 애초에 어떻게 이런 확률 분포를 만들 수 있게 되었을까. 이어지는 [Pretraining과 Fine-tuning](/blog/2026-07-03-llm-training-pretraining과-fine-tuning)에서 학습 과정을 다룬다. diff --git "a/src/content/blog/2026-07-03-llm-core-logit\352\263\274-softmax.md" "b/src/content/blog/2026-07-03-llm-core-logit\352\263\274-softmax.md" index eea3a08..fff8fe0 100644 --- "a/src/content/blog/2026-07-03-llm-core-logit\352\263\274-softmax.md" +++ "b/src/content/blog/2026-07-03-llm-core-logit\352\263\274-softmax.md" @@ -1,7 +1,6 @@ --- title: 'Logit과 Softmax' date: 2026-07-03 -updatedAt: 2026-07-03 kind: study series: llm-core seriesOrder: 4 @@ -9,9 +8,8 @@ tags: - llm - logit - softmax - - vocabulary - probability -description: Transformer 출력 벡터가 Logit, Softmax를 거쳐 토큰 확률 분포로 변환되는 핵심 흐름 +description: Transformer 출력 벡터가 LM Head를 거쳐 Logit이 되고, Softmax로 Vocabulary 전체에 대한 토큰 확률 분포로 바뀌는 과정을 정리했다. draft: false --- @@ -35,7 +33,7 @@ LLM(대형 언어 모델)은 입력 문장을 보고 한 번에 전체 답변을 이 후보들은 모두 모델의 **Vocabulary(어휘 사전)** 안에 있는 token들이다. 즉, LLM은 Vocabulary 전체 token에 대해 다음 위치에 올 가능성을 수학적으로 계산한다. -이 글에서는 Transformer의 출력 벡터가 어떻게 **Logit(로짓)**이 되고, 이 Logit이 **Softmax(소프트맥스)**를 거쳐 최종적인 Token별 확률 분포로 바뀌는지 정리한다. +이 글에서는 Transformer의 출력 벡터가 어떻게 **Logit**(로짓)이 되고, 이 Logit이 **Softmax**(소프트맥스)를 거쳐 최종적인 Token별 확률 분포로 바뀌는지 정리한다. --- @@ -264,19 +262,21 @@ Softmax는 Vocabulary 전체 token을 대상으로 한 번에 계산된다. 즉, | Token | Probability (확률) | | ---------- | ------------------ | -| **마셨다** | **0.91 (91%)** | -| 좋아한다 | 0.04 (4%) | -| 샀다 | 0.03 (3%) | -| 내렸다 | 0.01 (1%) | -| . | 0.01 (1%) | +| **마셨다** | **0.9274 (92.7%)** | +| 좋아한다 | 0.0418 (4.2%) | +| 샀다 | 0.0280 (2.8%) | +| 내렸다 | 0.0019 (0.19%) | +| . | 0.0009 (0.09%) | -이 결과는 모델이 `"나는 커피를"` 다음에 `"마셨다"`가 올 확률을 압도적으로 높게(91%) 평가했음을 직관적으로 보여준다. +이 결과는 모델이 `"나는 커피를"` 다음에 `"마셨다"`가 올 확률을 압도적으로 높게(92.7%) 평가했음을 보여준다. + +logit 차이가 크지 않아 보여도 확률 차이는 훨씬 크게 벌어진다는 점에 주목할 만하다. `마셨다`와 `좋아한다`의 logit 차이는 3.1점에 불과하지만, 확률로 보면 22배가 넘는다. 지수 함수를 거치기 때문이다. ### Softmax는 점수 차이를 '확률 차이'로 극대화한다 Softmax 내부에는 지수 함수($\exp$)가 있기 때문에, Logit의 미세한 차이가 확률에서는 큰 격차로 벌어진다. -특정 token의 logit이 2\~3점만 높아도 Softmax 결과는 그 token에 확률의 대부분을 몰아주게 된다(위 예시처럼 91% 집중). 반대로 1, 2, 3위의 Logit 점수가 4.2, 4.1, 4.0처럼 매우 비슷하다면, 확률 역시 30%, 28%, 25% 식으로 고르게 분산된다. +특정 token의 logit이 2\~3점만 높아도 Softmax 결과는 그 token에 확률의 대부분을 몰아주게 된다(위 예시처럼 92.7% 집중). 반대로 후보 셋의 Logit 점수가 4.2, 4.1, 4.0처럼 매우 비슷하다면, 확률은 각각 36.7%, 33.2%, 30.1%로 고르게 분산된다. --- @@ -284,7 +284,7 @@ Softmax 내부에는 지수 함수($\exp$)가 있기 때문에, Logit의 미세 Softmax 결과에서 확률이 낮게 나왔다는 것은 선택될 가능성이 낮다는 뜻일 뿐, 후보에서 완전히 삭제되었다는 뜻은 아니다. -위 예시에서 `샀다`의 확률은 3%에 불과하지만, 디코딩 전략(예: Top-p Sampling)에 따라서는 이 3%의 확률을 뚫고 실제 답변으로 선택될 수도 있다. +위 예시에서 `샀다`의 확률은 2.8%에 불과하지만, 디코딩 전략(예: Top-p Sampling)에 따라서는 이 2.8%의 확률을 뚫고 실제 답변으로 선택될 수도 있다. - **Softmax의 역할:** Token별 확률 분포를 만든다. - **Token 선택의 역할:** 만들어진 확률 분포를 바탕으로 실제 출력할 token 하나를 고른다. @@ -303,7 +303,9 @@ Vocabulary Size가 50,000이라면 연산은 다음과 같이 이루어진다. token id 0 -> 0.00001% token id 1 -> 0.00000% ... -token id 1024 -> 91.0000% (마셨다) +token id 1024 -> 0.00003% (나는) +... +token id 9041 -> 92.74000% (마셨다) ... token id 49999 -> 0.00012% ``` @@ -361,3 +363,5 @@ $$\text{softmax}(z_i) = \frac{\exp(z_i - \max(z))}{\sum \exp(z_j - \max(z))}$$ 앞서 "Logit은 절대값이 아니라 상대적인 차이가 중요하다"고 설명했다. 모든 Logit에서 똑같이 최댓값을 빼주더라도 token 간의 상대적 점수 차이는 그대로 유지되므로, 최종적인 Softmax 확률 결과는 수학적으로 완벽히 동일하다. --- + +다음 글에서는 이렇게 만들어진 확률 분포에서 실제 token 하나를 고르는 [Decoding](/blog/2026-07-03-llm-core-decoding) 전략을 살펴본다. diff --git "a/src/content/blog/2026-07-03-llm-core-tokenizer\354\231\200-embedding.md" "b/src/content/blog/2026-07-03-llm-core-tokenizer\354\231\200-embedding.md" index c282018..ad5b11b 100644 --- "a/src/content/blog/2026-07-03-llm-core-tokenizer\354\231\200-embedding.md" +++ "b/src/content/blog/2026-07-03-llm-core-tokenizer\354\231\200-embedding.md" @@ -1,17 +1,15 @@ --- title: 'Tokenizer와 Embedding' date: 2026-07-03 -updatedAt: 2026-07-03 kind: study series: llm-core -seriesOrder: 3 +seriesOrder: 1 tags: - llm - tokenizer - embedding - token - - vector -description: Tokenizer의 토큰 분할, Token ID 매핑, Embedding Layer 및 Position Embedding의 구조와 역할 +description: Tokenizer의 토큰 분할과 Token ID 매핑, Embedding Layer의 역할, 위치 정보를 넣는 방식까지 LLM의 입력 변환 과정을 정리했다. draft: false --- @@ -34,7 +32,6 @@ flowchart LR E --> F[Embedding Vectors] F --> G[Position Embedding 추가] G --> H[Transformer 입력] - ``` 이 글에서는 위 흐름에서 **Tokenizer, Token ID, Embedding, Position Embedding**이 각각 어떤 역할을 하는지 정리한다. @@ -51,28 +48,24 @@ Token은 반드시 우리가 아는 '단어' 하나와 일치하지는 않는다 ```text 나는 커피를 마셨다. - ``` 사람이 보기에는 아래처럼 띄어쓰기(단어) 단위로 나눌 수 있다. ```text 나는 / 커피를 / 마셨다 - ``` 하지만 실제 Tokenizer는 모델에 따라 다음과 같이 다르게 쪼갤 수 있다. ```text 나 / 는 / 커피 / 를 / 마셨 / 다 - ``` 또는 이렇게 처리될 수도 있다. ```text 나는 / 커피 / 를 / 마 / 셨다 - ``` 이처럼 실제 분할 결과는 모델이 사용하는 Vocabulary(어휘 사전)와 **학습 방식**에 따라 달라진다. @@ -92,7 +85,6 @@ Token은 반드시 우리가 아는 '단어' 하나와 일치하지는 않는다 ```text 커피, 커피를, 커피가, 커피에도, 커피처럼... - ``` 단어 단위로만 처리하면 이 모든 변형을 각각 독립된 별도의 token으로 사전에 등록해야 한다. 이 경우 Vocabulary 크기가 기하급수적으로 커지고, 사전에 없는 단어가 나오면 처리가 불가능해진다. @@ -104,7 +96,6 @@ Token은 반드시 우리가 아는 '단어' 하나와 일치하지는 않는다 커피 + 가 커피 + 에도 커피 + 처럼 - ``` Subword 방식은 단어를 더 작은 의미 단위나 빈도 기반 단위로 쪼갠다. 이를 통해 처음 보는 단어라도 기존에 알고 있는 작은 token들의 조합으로 유연하게 표현할 수 있다. @@ -121,7 +112,6 @@ flowchart TD B --> C[Tokens] C --> D[Vocabulary Lookup] D --> E[Token IDs] - ``` 예를 들어 Tokenizer의 사전(Vocabulary)이 다음과 같이 매핑되어 있다고 하자. @@ -173,7 +163,6 @@ l + o -> lo lo + w -> low e + s -> es es + t -> est - ``` 그 결과, 자주 등장하는 익숙한 단어(예: `low`)는 통째로 하나의 token으로 처리되고, 드물게 등장하는 단어(예: `lowest`)는 더 작은 token들의 조합(`low` + `est`)으로 표현된다. @@ -184,10 +173,11 @@ flowchart TD B --> C[조합 병합] C --> D[Subword Token 생성] D --> E[Vocabulary 구성] - ``` -BPE 방식은 Vocabulary의 크기를 일정하게 제한하면서도 OOV 문제 없이 다양한 단어를 표현할 수 있게 해주는 강력한 무기다. +BPE는 Vocabulary 크기를 일정하게 제한하면서도 처음 보는 단어를 기존 token의 조합으로 표현할 수 있게 해준다. + +다만 "OOV가 완전히 사라진다"고 말하려면 조건이 하나 붙는다. 문자 단위에서 시작하는 BPE는 학습 때 본 적 없는 문자(예: 학습 코퍼스에 없던 언어의 글자, 희귀 이모지)를 만나면 여전히 처리하지 못한다. 최신 Tokenizer가 문자가 아니라 **byte 단위**에서 시작하는 byte-level BPE를 쓰는 이유가 이것이다. 모든 텍스트는 결국 256가지 byte로 환원되므로, byte를 기본 단위로 삼으면 표현하지 못하는 입력이 원리적으로 없어진다. --- @@ -197,9 +187,11 @@ Tokenizer가 뱉어낸 Token ID는 단순한 정수 번호다. > `[1024, 3812, 217, 9041, 13]` -이 숫자 자체에는 어떠한 수학적, 문맥적 의미도 담겨있지 않다. 예를 들어 `1024`라는 숫자가 `나는`이라는 단어의 성질을 표현하는 것이 아니라, 단순히 Vocabulary Table의 1,024번째 칸에 위치해 있다는 뜻(Index)일 뿐이다. +이 숫자 자체에는 어떠한 수학적, 문맥적 의미도 담겨있지 않다. 예를 들어 `1024`라는 숫자가 `나는`이라는 단어의 성질을 표현하는 것이 아니라, 단순히 Vocabulary Table에서 인덱스 `1024`번 자리에 등록되어 있다는 뜻일 뿐이다. -모델이 단어의 '의미'를 기반으로 실제로 연산을 수행하려면, 이 맹목적인 Token ID를 **의미를 담은 연속적인 벡터(Vector)** 공간으로 옮겨야 한다. 이 역할을 하는 것이 바로 **Embedding Layer**다. +그래서 `3812`(`커피`)가 `1024`(`나는`)보다 크다는 사실에는 아무 의미가 없다. ID 사이의 크기 비교나 사칙연산은 성립하지 않는다. + +모델이 단어의 '의미'를 기반으로 연산을 수행하려면, 이 순번에 불과한 Token ID를 **의미를 담은 연속적인 벡터(Vector)** 공간으로 옮겨야 한다. 이 역할을 하는 것이 바로 **Embedding Layer**다. --- @@ -211,7 +203,6 @@ Embedding(임베딩)은 단순한 정수인 Token ID를 일정한 길이를 가 flowchart LR A[Token ID] --> B[Embedding Layer] B --> C[Embedding Vector] - ``` Embedding Layer는 거대한 룩업 테이블(Lookup Table)처럼 동작하여, 각 Token ID에 대응하는 벡터를 찾아 꺼내온다. @@ -220,7 +211,6 @@ Embedding Layer는 거대한 룩업 테이블(Lookup Table)처럼 동작하여, 1024 -> [ 0.12, -0.03, 0.44, ... ] 3812 -> [-0.51, 0.27, 0.08, ... ] 217 -> [ 0.09, 0.11, -0.32, ... ] - ``` ```mermaid @@ -231,7 +221,6 @@ flowchart TD A1[1024] --> B A2[3812] --> B A3[217] --> B - ``` 이 벡터의 차원 수(Embedding Dimension)는 모델의 크기에 따라 다르다. 만약 차원이 4,096이라면, 토큰 하나가 4,096개의 실수로 이루어진 정밀한 배열로 표현되는 것이다. @@ -251,10 +240,9 @@ flowchart TD A[Vocabulary Size
token 개수] --> C[Embedding Table] B[Embedding Dimension
vector 차원] --> C C --> D[Token Embedding] - ``` -이 거대한 테이블 안의 실수 값들은 고정된 것이 아니다. 신경망이 수많은 텍스트를 학습하는 과정에서 역전파(Backpropagation)를 통해 미세하게 조정되며, 최종적으로는 **의미나 쓰임새가 비슷한 토큰들끼리 벡터 공간상에서 가까운 방향을 가리키도록** 똑똑하게 군집화된다. +이 거대한 테이블 안의 실수 값들은 고정된 것이 아니다. 신경망이 수많은 텍스트를 학습하는 과정에서 역전파(Backpropagation)를 통해 미세하게 조정되며, 최종적으로는 **의미나 쓰임새가 비슷한 토큰들끼리 벡터 공간상에서 가까운 방향을 가리키도록** 군집화된다. --- @@ -275,7 +263,6 @@ flowchart TD flowchart TD A[Token Embedding] --> B[Transformer Block] B --> C[Contextualized Token Representation] - ``` --- @@ -304,9 +291,23 @@ flowchart TD B --> E[Add] D --> E E --> F[Transformer Input] - ``` +### 최신 모델은 위치 정보를 다르게 넣는다 + +위에서 설명한 "입력 벡터에 위치 벡터를 더한다"는 방식은 **절대 위치 임베딩**이다. 초기 Transformer와 GPT-2, BERT가 이 방식을 썼다. + +하지만 최근의 주요 LLM(LLaMA, Qwen 계열 등)은 **RoPE**(Rotary Position Embedding)를 사용한다. RoPE는 입력 벡터에 무언가를 더하지 않는다. 대신 Attention 계산 직전에 Query와 Key 벡터를 **위치에 비례한 각도만큼 회전**시킨다. + +| 방식 | 적용 위치 | 성질 | +| -------------------- | --------------------------------- | ------------------------------------------- | +| **절대 위치 임베딩** | Embedding 단계에서 더함 | 학습한 최대 길이를 넘기기 어려움 | +| **RoPE** | Attention 내부의 Q, K에 회전 적용 | 두 토큰의 **상대 거리**가 자연스럽게 반영됨 | + +회전을 적용하면 Q와 K의 내적이 두 토큰의 **위치 차이**에 의존하게 되어, 상대적인 거리 정보가 attention 점수에 직접 들어간다. 학습할 때 본 것보다 긴 문맥으로 확장하기도 상대적으로 수월하다. + +따라서 `Input Embedding = Token Embedding + Position Embedding`은 위치 정보가 왜 필요한지 이해하기 위한 기본형으로 보는 편이 정확하다. 실제 모델이 위치를 주입하는 지점은 구현마다 다르다. + --- ## 전체 입력 변환 과정 요약 @@ -322,7 +323,6 @@ flowchart TD E --> F[Position Embedding 추가] F --> G[Transformer Blocks] G --> H[Contextualized Representations] - ``` | 단계 | 역할 | @@ -340,13 +340,19 @@ flowchart TD LLM은 연산 효율을 위해 여러 문장을 하나의 Batch(배치)로 묶어 동시에 처리한다. 문제는 문장마다 길이가 제각각이라는 점이다. -> **A:** 나는 커피를 마셨다. (4 tokens) -> **B:** 오늘 아침에 따뜻한 커피를 천천히 마셨다. (7 tokens) +앞에서 본 것처럼 `나는 커피를 마셨다.`는 5개 token으로 나뉜다. 조금 더 긴 문장과 함께 묶어 보자. + +```text +A: 나는 / 커피 / 를 / 마셨다 / . (5 tokens) +B: 오늘 / 아침 / 에 / 따뜻한 / 커피 / 를 / 마셨다 / . (8 tokens) +``` 길이가 다르면 직사각형 형태의 텐서(Tensor) 행렬로 묶을 수가 없다. 그래서 짧은 문장의 빈 공간에는 `[PAD]`라는 의미 없는 패딩 토큰을 채워 넣어 가장 긴 문장의 길이에 맞춘다. -> **A:** 나는 / 커피를 / 마셨다 / `[PAD]` / `[PAD]` / `[PAD]` / `[PAD]` -> **B:** 오늘 / 아침에 / 따뜻한 / 커피를 / 천천히 / 마셨다 / . +```text +A: 나는 / 커피 / 를 / 마셨다 / . / [PAD] / [PAD] / [PAD] +B: 오늘 / 아침 / 에 / 따뜻한 / 커피 / 를 / 마셨다 / . +``` 하지만 `[PAD]`는 실제 내용이 아니므로 연산(Attention)에 반영되어서는 안 된다. 이때 모델에게 "이 부분은 가짜니까 무시해!"라고 알려주는 가이드라인이 바로 **Attention Mask**다. @@ -356,7 +362,6 @@ flowchart TD B --> C[Same Length Batch 텐서화] C --> D[Attention Mask 적용] D --> E[Transformer 연산] - ``` --- @@ -366,11 +371,11 @@ flowchart TD LLM을 실무에서 다룰 때 '토큰 수(Token Count)'는 시스템의 성능과 비용을 결정짓는 핵심 지표다. - **입력 가능한 최대 길이 (Context Window)** 제한 -- API **호출 비용** (보통 1K Token 당 과금) +- API **호출 비용** (주요 API는 보통 100만 토큰 단위로 단가를 표기한다) - 생성 및 응답 **지연 시간 (Latency)** - RAG 구현 시 **검색 문맥(Context)의 크기** 제약 -특히 한국어나 프로그래밍 코드, JSON 데이터, 시스템 로그 등을 넣을 때는 사람이 느끼는 글자 수보다 토큰 수가 훨씬 뻥튀기될 수 있다. +특히 한국어나 프로그래밍 코드, JSON 데이터, 시스템 로그 등을 넣을 때는 사람이 느끼는 글자 수보다 토큰 수가 훨씬 많아질 수 있다. > `[2026-07-03 10:12:31.123] TCP_TRANSPORT_LATENCY_WARN` @@ -395,13 +400,14 @@ LLM을 실무에서 다룰 때 '토큰 수(Token Count)'는 시스템의 성능 | **사용 위치** | LLM 내부의 첫 번째 Layer | 텍스트 검색 파이프라인 (별도의 Embedding 모델 사용) | **LLM 내부 임베딩**은 텍스트를 연산하기 위해 쪼개진 ID 하나하나를 벡터로 바꾸는 '시작점'이다. -**RAG 임베딩**은 수백 자의 문맥 덩어리(Chunk)가 품고 있는 핵심 '의미'를 하나의 묵직한 벡터로 압축하여 검색 가능하게 만드는 기술이다. +**RAG 임베딩**은 수백 자의 문맥 덩어리(Chunk)가 품고 있는 핵심 의미를 하나의 벡터로 압축하여 검색 가능하게 만드는 기술이다. ```mermaid flowchart TD A[LLM Token Embedding] --> B[Token ID를 Transformer 입력 vector로 변환] C[RAG Embedding] --> D[문서 덩어리를 Vector DB 검색용 의미 벡터로 변환] - ``` 이 차이를 명확히 구분해야 전체적인 LLM 생태계와 RAG 시스템 아키텍처를 헷갈리지 않고 설계할 수 있다. + +다음 글에서는 이렇게 만들어진 입력 벡터가 [Transformer와 self-attention](/blog/2026-07-02-llm-transformer와-self-attention-이해하기)을 거쳐 문맥이 반영된 표현으로 바뀌는 과정을 살펴본다. diff --git "a/src/content/blog/2026-07-03-llm-training-base-model\354\235\200-\354\226\264\353\226\273\352\262\214-chat-model\354\235\264-\353\220\230\353\212\224\352\260\200.md" "b/src/content/blog/2026-07-03-llm-training-base-model\354\235\200-\354\226\264\353\226\273\352\262\214-chat-model\354\235\264-\353\220\230\353\212\224\352\260\200.md" index 3d7dc4e..8260a57 100644 --- "a/src/content/blog/2026-07-03-llm-training-base-model\354\235\200-\354\226\264\353\226\273\352\262\214-chat-model\354\235\264-\353\220\230\353\212\224\352\260\200.md" +++ "b/src/content/blog/2026-07-03-llm-training-base-model\354\235\200-\354\226\264\353\226\273\352\262\214-chat-model\354\235\264-\353\220\230\353\212\224\352\260\200.md" @@ -1,18 +1,16 @@ --- title: 'Base Model은 어떻게 Chat Model이 되는가' date: 2026-07-03 -updatedAt: 2026-07-03 kind: study series: llm-training seriesOrder: 2 tags: - llm - - base-model - chat-model - instruction-tuning - rlhf - alignment -description: 사전 학습을 마친 Base Model이 Instruction Tuning, Preference Tuning, Safety Alignment를 거쳐 대화형 Chat Model로 발전하는 세부 과정 정리 +description: 사전 학습을 마친 Base Model이 Instruction Tuning, Preference Tuning, Safety Alignment를 거쳐 대화형 Chat Model이 되는 과정을 정리했다. draft: false --- @@ -76,7 +74,6 @@ flowchart TD D --> E[Preference Tuning] E --> F[Aligned Chat Model] F --> G[Evaluation / Safety Check] - ``` 각 단계의 역할은 다르다. @@ -167,11 +164,17 @@ Instruction tuning은 모델이 사용자의 지시를 따르도록 학습시키 또는 대화 형태로 구성될 수 있다. -> **User:** TCP와 UDP의 차이를 표로 정리해줘. -> **Assistant:** > | 구분 | TCP | UDP | -> |---|---|---| -> | 연결 방식 | 연결 지향 | 비연결형 | -> ... +```text +User: +TCP와 UDP의 차이를 표로 정리해줘. + +Assistant: +| 구분 | TCP | UDP | +| --------- | --------- | -------- | +| 연결 방식 | 연결 지향 | 비연결형 | +| 신뢰성 | 높음 | 낮음 | +... +``` 모델은 이런 예시를 통해 “사용자의 요청이 들어오면 그에 맞는 답변을 생성하는 패턴”을 학습한다. @@ -181,7 +184,6 @@ flowchart TD B --> C[Reference Response] C --> D[Supervised Fine-tuning] D --> E[Instruction-following Model] - ``` Instruction tuning은 일반적인 next token prediction과 같은 학습 방식으로 수행될 수 있다. @@ -233,7 +235,8 @@ Chat model은 단순한 문자열 하나만 입력으로 받는 것이 아니라 모델 내부에서는 이런 대화가 하나의 token sequence로 변환된다. 이때 사용되는 형식을 **chat template**이라고 한다. -예시는 다음과 같다. +실제 구분자는 모델마다 다르다. ChatML 계열은 `<|im_start|>system` / `<|im_end|>` 같은 특수 토큰을 쓰고, Llama 계열은 `[INST]` 같은 형식을 쓴다. +아래는 이해를 돕기 위해 단순화한 형태이고, 실제 문법은 아니다. ```text @@ -245,7 +248,6 @@ TCP와 UDP의 차이를 표로 정리해줘. - ``` 모델은 이 뒤에 assistant 응답을 생성한다. @@ -302,7 +304,6 @@ flowchart TD C --> D D --> E[Chosen / Rejected Pair] E --> F[Preference Tuning] - ``` Preference tuning은 모델이 단순히 정답을 흉내 내는 것을 넘어서, 더 선호되는 응답 스타일과 판단 기준을 따르도록 만든다. @@ -323,7 +324,6 @@ flowchart TD C --> D[Reward Model 학습] D --> E[Reward가 높아지도록 모델 조정] E --> F[Aligned Model] - ``` RLHF에서는 먼저 사람이 여러 응답을 비교해 어떤 응답이 더 좋은지 평가한다. @@ -525,7 +525,6 @@ flowchart TD G --> H[Safety Alignment] H --> I[Chat Model] I --> J[Evaluation] - ``` 각 단계의 역할은 다음과 같다. diff --git "a/src/content/blog/2026-07-03-llm-training-pretraining\352\263\274-fine-tuning.md" "b/src/content/blog/2026-07-03-llm-training-pretraining\352\263\274-fine-tuning.md" index 809a3d3..5275a8c 100644 --- "a/src/content/blog/2026-07-03-llm-training-pretraining\352\263\274-fine-tuning.md" +++ "b/src/content/blog/2026-07-03-llm-training-pretraining\352\263\274-fine-tuning.md" @@ -1,7 +1,6 @@ --- title: 'Pretraining과 Fine-tuning' date: 2026-07-03 -updatedAt: 2026-07-03 kind: study series: llm-training seriesOrder: 1 @@ -9,15 +8,14 @@ tags: - llm - pretraining - fine-tuning - - next-token-prediction - loss -description: LLM이 대규모 텍스트로 기본 언어 패턴을 학습하고, 특정 작업에 맞게 조정되는 과정 정리 +description: LLM이 대규모 텍스트로 언어 패턴을 학습하는 pretraining과, 특정 작업에 맞게 조정하는 fine-tuning의 차이를 정리했다. draft: false --- ## 도입: LLM은 어떻게 다음 token을 잘 예측하게 되는가 -앞선 글에서는 LLM이 다음 token을 생성하는 과정을 봤다. +[LLM Core 시리즈](/blog/2026-07-03-llm-core-logit과-softmax)에서는 LLM이 다음 token을 생성하는 과정을 봤다. > Token Sequence → Transformer → Logits → Softmax → Token Probability → Decoding → Selected Token @@ -66,7 +64,6 @@ flowchart TD B --> C[Base Model] C --> D[Fine-tuning] D --> E[Task-adapted Model] - ``` Pretraining은 대규모 텍스트에서 일반적인 언어 패턴을 학습하는 단계다. @@ -100,7 +97,6 @@ flowchart TD E[Inference] --> F[확률 분포 계산] F --> G[Decoding] G --> H[출력 생성] - ``` Training에서는 모델 weight가 바뀐다. @@ -138,7 +134,6 @@ flowchart TD A[문장: 나는 커피를 마셨다.] --> B[입력: 나는 / 정답: 커피를] A --> C[입력: 나는 커피를 / 정답: 마셨다] A --> D[입력: 나는 커피를 마셨다 / 정답: .] - ``` 이 방식에서는 별도의 사람이 모든 정답을 라벨링하지 않아도 된다. @@ -158,11 +153,15 @@ flowchart TD 모델 입력과 정답은 다음처럼 구성된다. -| 위치 | 입력으로 보는 token | 예측해야 할 정답 token | -| ---- | ------------------- | ---------------------- | -| 1 | 나는 | 커피를 | -| 2 | 커피를 | 마셨다 | -| 3 | 마셨다 | . | +| 위치 | 그 위치까지 모델이 보는 sequence | 예측해야 할 정답 token | +| ---- | -------------------------------- | ---------------------- | +| 1 | 나는 | 커피를 | +| 2 | 나는 커피를 | 마셨다 | +| 3 | 나는 커피를 마셨다 | . | + +여기서 주의할 점이 있다. 2번 위치에서 모델이 보는 것은 `커피를` 하나가 아니라 `나는 커피를`이다. Decoder-only 모델은 각 위치에서 **그 위치까지의 모든 이전 토큰**을 참조한다. + +"한 칸씩 민다"는 표현은 입력과 정답을 만드는 방법을 가리키는 것이지, 모델이 토큰을 하나씩만 본다는 뜻이 아니다. 모델은 각 위치에서 다음 token의 확률 분포를 만든다. 그리고 실제 정답 token에 얼마나 높은 확률을 주었는지 평가한다. @@ -173,7 +172,6 @@ flowchart TD A --> C[정답 Tokens
한 칸 뒤의 Token] B --> D[LLM 예측] D --> E[정답과 비교] - ``` 이 구조 때문에 LLM은 많은 텍스트를 읽으면서 다음 token 예측 능력을 학습할 수 있다. @@ -233,7 +231,6 @@ flowchart TD D --> E[Gradient 계산] E --> F[Parameter 업데이트] F --> B - ``` 이 과정을 매우 많은 데이터에 대해 반복한다. @@ -272,7 +269,6 @@ flowchart TD B --> C[Loss 계산] C --> D[Parameter 업데이트] D --> E[Base Model] - ``` Pretraining을 마친 모델을 보통 **Base Model**이라고 부른다. @@ -308,7 +304,6 @@ flowchart TD A[Base Model] --> B[Task-specific Dataset] B --> C[추가 학습] C --> D[Fine-tuned Model] - ``` Pretraining이 넓은 범위의 일반 학습이라면, fine-tuning은 좁은 목적에 맞춘 추가 조정이다. @@ -510,7 +505,6 @@ flowchart TD C --> D{기준 통과?} D -->|Yes| E[배포 후보] D -->|No| F[데이터 / 학습 설정 수정] - ``` Fine-tuning은 학습 데이터, 평가 데이터, 배포 관리가 함께 설계되어야 한다. @@ -533,7 +527,6 @@ flowchart TD G --> H[Evaluation] H --> I[Deployment Candidate] - ``` 각 단계의 역할은 다음과 같다. @@ -579,3 +572,5 @@ LLM 학습을 이해할 때 핵심은 다음이다. > Pretraining은 모델의 기본 능력을 만든다. > Fine-tuning은 그 능력을 특정 목적에 맞게 조정한다. + +다음 글에서는 pretraining을 마친 base model이 [어떻게 chat model이 되는지](/blog/2026-07-03-llm-training-base-model은-어떻게-chat-model이-되는가) 살펴본다. diff --git a/src/content/now.md b/src/content/now.md index ba41ee8..7b090b5 100644 --- a/src/content/now.md +++ b/src/content/now.md @@ -5,7 +5,7 @@ updatedAt: 2026-07-17 ## 작업 중 -- unilink v0.8.9 - 릴리즈 전 안정화 +- wirestead v0.8.9 - 릴리즈 전 안정화 - Reliable 메시지 드랍 현상 해결 중 -- packet-probe : unilink 기반 패킷 분석기 +- packet-probe : wirestead 기반 패킷 분석기 - 현업 업무 diff --git a/src/content/projects/ai-curator.md b/src/content/projects/ai-curator.md index c356637..dce4a8a 100644 --- a/src/content/projects/ai-curator.md +++ b/src/content/projects/ai-curator.md @@ -5,7 +5,7 @@ status: active repo: https://github.com/jwsung91/ai-curator url: https://jwsung91.github.io/ai-curator/ tags: - - ai + - llm - astro - automation order: 2 diff --git a/src/content/projects/unilink.md b/src/content/projects/wirestead.md similarity index 62% rename from src/content/projects/unilink.md rename to src/content/projects/wirestead.md index ddc3dee..ee12ee8 100644 --- a/src/content/projects/unilink.md +++ b/src/content/projects/wirestead.md @@ -1,12 +1,12 @@ --- -title: unilink +title: wirestead description: TCP, UDP, Serial, UDS를 단일 C++ 비동기 인터페이스로 다루기 위한 통신 라이브러리와 설계 문서 모음입니다. tags: - - c++ - - async + - cpp + - async-io - networking status: active order: 1 -repo: https://github.com/jwsung91/unilink -url: https://jwsung91.github.io/unilink/ +repo: https://github.com/jwsung91/wirestead +url: https://jwsung91.github.io/wirestead/ --- diff --git a/src/data/taxonomy.json b/src/data/taxonomy.json index 3b4f0f7..6c4d8d2 100644 --- a/src/data/taxonomy.json +++ b/src/data/taxonomy.json @@ -1,6 +1,6 @@ { "projects": [ - { "id": "unilink", "label": "unilink" }, + { "id": "wirestead", "label": "wirestead" }, { "id": "ai-curator", "label": "AI Curator" }, { "id": "site", "label": "Site" } ], @@ -14,9 +14,9 @@ ], "series": [ { - "id": "unilink-design", - "title": "unilink 설계 노트", - "project": "unilink", + "id": "wirestead-design", + "title": "wirestead 설계 노트", + "project": "wirestead", "description": "C++ 비동기 통신 라이브러리를 설계하며 정리한 아키텍처 기록입니다." }, {