이전 포스팅: AST 노드로 RAG 토큰을 다이어트시켜보자

프로젝트 Repository: https://github.com/dong7812/dompruner-mcp

좋지만 토큰을 너무 먹는 애증의 관계..

왜 DOM AST에 'BM25'를 결합했는가?

OSSCA에서 ESLint 내부 작동 방식을 공부하며 ESTree(AST) 구조를 접했을 때, "웹 문서도 HTML이라는 트리 구조(AST)인데, 왜 LLM에 전달할 때는 플랫한 텍스트 덩어리로 잘라 넣을까?"라는 의문이 생겼다. -> 이전 포스팅에서 진행했던 고민을 기반으로 실제 구현을 시작하며 파이프라인 구축을 진행해보았다.

기존 Chunk 기반 RAG는 문맥 파괴와 무의미한 토큰 중복이라는 명확한 한계가 있었다. 반면 DOM AST 단위로 파싱하면 Heading, Section, Article 같은 문서의 계층 구조를 보존한 채 노드 단위로 정제할 수 있다.

문제는 "정제된 노드 중 질문과 연관된 섹션을 어떻게 고를 것인가"였다. 처음부터 Vector DB나 임베딩 모델을 붙이는 건 오버헤드가 너무 컸다. 또한 Vector DB와 같은 외부 연결 없이 최소한의 경량화를 진행해 보고 싶었다.

  1. 임베딩 모델을 호출하는 네트워크 / 계산 비용
  2. 웹 문서를 실시간으로 임베딩하고 벡터화하는 latency
  3. 가볍고 빠른 MCP(Model Context Protocol) 도구를 만들겠다는 목적과의 불일치

따라서 경량화와 즉각적인 스코어링을 위해 BM25 알고리즘을 선택했다. AST 노드별 키워드 빈도(TF)와 문서 내 희소성(IDF)을 기반으로, 연관 노드 섹션만 정확히 골라내어 LLM에 전달하는 가벼운 파이프라인을 구상했다.

빠른 구현을 진행하고 실제 실행을 통해서 벤치마크를 집계하고, 다른 사람들에게 보여주면서 문득 방향성에 대한 의구심이 들었다.

1. 비교 대상의 오류: Chunk RAG가 아니라 내장 웹 검색이었다

구현 후 성능 측정을 위해 처음엔 Chunk RAG를 기준점(Baseline)으로 삼으려 했다. 하지만 실무에서 개발자들이 웹 정보를 읽어올 때 Chunk RAG 파이프라인을 직접 구축해 쓰는 경우는 거의 없다. 다들 Claude Desktop, Cursor, Claude Code 등의 LLM 내장 웹 검색을 사용한다.

방식 실제 사용자 비교 대상으로서의 적절성
Chunk RAG 소수 (직접 인프라 구축 필요) ✗ 실무 사용 패턴과 거리 있음
LLM 내장 웹 검색 대다수 (클릭 한 번으로 사용) ✓ 실제 해결해야 할 페인 포인트

비교 대상을 Anthropic의 내장 툴(web_fetch_20260209)로 변경하고 API 응답의 input_tokens를 측정한 결과, 단일 페이지 평균 15,735 토큰, Wikipedia 기준 56,754 토큰이라는 엄청난 컨텍스트 낭비를 확인했다.

2. 의미 추론 질문과 Fallback: 책임을 명확히 분리하다

DOM AST + BM25 파이프라인을 검증하던 중 난관에 부딪혔다.

"이 기능이 빠른가요?", "어떤 특징이 있나요?"처럼 문서 본문에 키워드가 직접 등장하지 않는 의미 추론형 질문이 들어오면, BM25 검색 점수가 0으로 떨어진다.

초기 고민: 전처리기가 다 하려 했던 오버엔지니어링

처음엔 BM25 점수가 0일 때 소형 임베딩 모델을 Fallback으로 붙이거나, 임계값(Threshold)을 두고 복잡한 분기 로직을 태우려 했다. 하지만 이렇게 하면 파이프라인이 무거워지고 튜닝 포인트만 늘어난다. (이렇게 진행을 하면 소형 모델을 통한 web fetch가 사용자로 하여금 더 편리할 수 있기에 오버엔지니어링으로 판단하였다.)

전환점: BM25 '0점'의 의미와 책임 분리 Architecture

BM25 점수가 0이라는 것은 "쿼리 단어가 본문에 하나도 없다"는 가장 확실한 신호다.

여기서 아키텍처의 책임을 명확히 나누기로 결정했다.

  • BM25 0점 발생 시: 필터링을 멈추고, 노이즈(nav, footer, script 등)만 제거된 정제 전체 본문 노드(~2,000~3,000 토큰)를 그대로 LLM에 위임한다.
  • 이유: 문맥 이해와 의미 추론은 원래 LLM이 제일 잘하는 일이다. 전처리기가 억지로 의미 추론까지 다 하려고 욕심낼 필요가 없었다.
[사용자 쿼리]
   │
   ▼
[DOM AST 생성 및 노이즈 제거] (nav, script, footer 제거)
   │
   ▼
[BM25 스코어링]
   ├── 점수 있음 (키워드 매칭) ──► 연관 AST 노드 섹션만 추출 ──┐
   │                                                         │
   └── 점수 0 (의미 추론 질문) ──► 정제 본문 전체 유지 (~3k tok) ─┴─► [LLM 컨텍스트로 전달]
                                                                        (의미 추론은 LLM이 담당)

전처리기는 "구조적 노이즈 제거 및 관련 섹션 선별"에만 집중하고, "의미 추론과 최종 답변 생성"은 LLM에 완전 위임함으로써 구조가 눈에 띄게 깔끔해졌다.

3. 정보 손실 우려: 요약이 아닌 '노이즈 제거'

"토큰을 90% 줄였다"고 하면 "필요한 정보가 잘린 것 아니냐"는 의문이 따라온다. LLM의 내장 웹 검색 툴 역시 서버 단에서 소형 모델을 거치며 요약 및 전달을 수행하기 때문에 정보 손실이나 환각 위험이 있다.

 

하지만 DomPruner는 원문을 요약하거나 재창작하지 않는다. Script, Style, Navigation, Footer, 광고 같은 '구조적 노이즈' 노드만 AST 단에서 잘라내고, 본문 텍스트와 코드 블록 원문은 100% 그대로 유지한다.

결론 및 벤치마크

이 과정을 거치며 내가 만든 것은 RAG 시스템이 아닌 "LLM을 위한 가벼운 웹 Fetch 전처리기"였음이 명확해졌다.

MCP(Model Context Protocol) 서버 형태로 구현한 dompruner-mcp를 실시간 측정한 결과는 다음과 같다.

테스트 대상 Anthropic web_fetch DomPruner 토큰 절감율
Python asyncio 문서 21,783 tok 1,328 tok 93.9%
MDN Fetch API 15,965 tok 1,368 tok 91.4%
Wikipedia (영어) 56,754 tok 679 tok 98.8%
평균 (10개 주요 사이트) 15,735 tok 1,019 tok 93.5%

컨텍스트 윈도우 부담이 줄어들면서 LLM의 응답 속도도 평균 45% 향상되었다.

정리하며

AST 기반 RAG를 만들겠다는 거창한 생각에서 출발했지만, 구현 과정에서 BM25의 한계를 마주하고 Fallback 구조를 단순화하면서 "각 모듈의 책임을 명확히 분리하는 것"이 얼마나 중요한지 배울 수 있었다.

모든 것을 시스템 파이프라인 안에서 처리하려 하기보다, LLM이 잘하는 영역(의미 추론)과 전처리기가 잘하는 영역(AST 구조 정제 및 키워드 추출)을 구분할 때 가장 가볍고 강력한 솔루션이 나온다는 점을 깨달은 프로젝트였다.

 

 

요즘 OSSCA(오픈소스 아카데미)에서 ESLint 오픈소스 참여 과정을 진행하고 있다.

eww

ESLint 동작 원리를 하나씩 진행하다 보니 자연스럽게 정적 분석을 이해하게 됐는데, 여기서 쓰이는 ESTree(AST) 구조가 생각보다 너무 흥미로웠다. 파일 내 태그와 코드를 노드(Node) 단위로 쪼개서 정밀하게 분석하는 걸 보면서, 문득 머릿속에 질문 하나가 떠올랐다.

"ESLint가 코드를 노드 단위로 핀포인팅해서 분석하는 것처럼, RAG 전처리할 때도 웹 문서를 AST 노드로 정제해서 넘겨주면 토큰과 속도를 비약적으로 아낄 수 있지 않을까?"

문제 인식 : 기존 Chunk RAG의 지독한 토큰 낭비

보통 RAG라고 하면 웹 사이트나 문서 전체를 적당한 길이(500자~1000자)의 Chunk 단위로 뚝뚝 자른 뒤, 이걸 임베딩해서 Vector DB에 넣고 검색하는 방식을 떠올린다. 물론 이렇게 하면 구현도 쉽고 텍스트 검색도 빠르다.

 

하지만 쓸 때마다 늘 아쉬웠던 점이 있었다. 필요 없는 쓰레기 정보(노이즈)까지 너무 많이 읽는다는 것. 그리고 내 소중한 토큰을 낭비한다.

 

HTML을 그냥 글자 수대로 자르다 보니, 헤더 메인 메뉴, 푸터, 레이아웃 CSS, 관련 글 추천, 심지어 본문 옆의 잡다한 부연 설명까지 전부 Chunk에 섞여 들어간다. 결국 전처리 단계에서 노이즈를 전혀 걸러내지 못하는 셈이다.

 

안 그래도 귀한 LLM 입력 토큰(Input Token)을 이런 무의미한 노이즈로 채운다는 게 솔직히 너무 아까웠다.

토큰만 많이 먹는 게 아니라, LLM이 읽어야 할 분량이 늘어나니 첫 토큰이 나올 때까지 대기 시간(TTFT)도 길어지고, 문맥이 오염되어서 환각(Hallucination)이 생길 확률도 높아진다.

"ESLint처럼 파싱해 보면 어떨까?" (AST RAG 아이디어)

ESLint는 자바스크립트 코드를 읽을 때 문자열을 통으로 보지 않는다. ESTree라는 추상 구문 트리(AST)로 변환한 뒤, 우리가 찾고자 하는 특정 노드(FunctionDeclaration, Identifier 등)만 집중해서 규칙을 검사한다.

웹 문서나 라이브러리 API 문서를 RAG에 넣을 때도 똑같이 할 수 있지 않을까?

  1. HTML/문서를 AST / DOM 트리 구조로 파싱한다.
  2. 핵심 정보가 들어있는 Semantic Anchor 노드만 추적한다.
    • (예: <code>, <pre>, 메서드 시그니처, 핵심 파라미터 타입 등)
  3. 나머지 무의미한 노드(메뉴, 푸터, 광고, 쓸데없는 부연 설명)는 트리 단에서 통째로 가지치기(Pruning)한다.
  4. 남은 핵심 노드만 아주 짧고 명확한 Compact Markdown(150토큰 내외)으로 직렬화해서 LLM에 건넨다.

Chunk RAG처럼 글자 수대로 뭉텅이 잘라 넣는 게 아니라, ESLint가 AST 노드를 짚어내듯 정확히 필요한 시그니처 노드만 핀포인팅해서 정제해 주는 방식이다.

Chunk RAG vs AST RAG 간단 비교

구분 기존 Chunk RAG AST 기반 RAG (아이디어)
자르는 기준 단순 글자 수 / 단락 단위 의미 있는 구문 노드(Node) 단위
노이즈 제어 불필요한 메타데이터/태그 다 섞임 트리 단계에서 불필요 노드 가지치기
주입 토큰량 보통 4,000 ~ 5,000 토큰 ~150 토큰 (핵심 정보만 소량 주입)
정확도 확률적 유사도 (잘린 텍스트에 의한 문맥 손실) 결정론적 구문 노드 추출 (환각 0% 지향)

정리하며

OSSCA에서 ESLint의 정적 분석 원리를 배우지 않았다면 그냥 "RAG 쓰면 원래 토큰 많이 먹고 느린가 보다" 하고 넘어갔을지도 모른다.

하지만 코드의 의미 단위(AST Node)를 분석하는 정적 분석 방식을 RAG의 파싱 전처리 과정에 이식한다면, 토큰 비용 감축(90%+)+응답 속도 개선+환각 차단이라는 세 마리 토끼를 한 번에 잡을 수 있지 않을까 싶다.

 

다음 단계로는 실제 Java 21이나 TypeScript 공식 API 문서를 대상으로, 이 AST 노드 정제 파이프라인(PoC)을 직접 코드로 구현해 보고 기존 Chunk RAG 대비 토큰이 얼마나 줄어드는지 벤치마크를 측정해 볼 예정이다.

LLM을 어떻게 하면 잘 활용할 수 있을지 오랫동안 고민했다.

CLAUDE.md를 명확하게 작성하면 좋은 협업이 될까? 어느 정도는 맞지만, 세션에서 실제로 무슨 일이 있었는지는 기록되지 않는다.

plan.md로 harness engineering을 하는 방향은 어떨까? 전제 조건을 명확히 하는 데는 좋지만, 결정의 맥락과 이유가 남지 않는다.

Claude Code를 실제로 쓰면서 느낀 건, 내가 필요한 건 세션 중에 일어난 일을 명확히 기록하고 나중에 돌아볼 수 있는 환경이라는 것이었다. 결정이 왜 내려졌는지, 어떤 대안을 왜 버렸는지가 다음 세션의 출발점이 되어야 한다는 생각.

 

그래서 Claude Code skill을 만들어보았다.

1. 해결하고자 한 문제

세션이 끝나면 대화가 사라진다.

git log는 무엇이 바뀌었는지는 보여준다. 하지만 왜 Lua EVAL을 선택했는지, Claude가 내가 놓친 버그를 발견한 순간, 의식적으로 배제한 대안 — 이것들은 전부 증발한다.

토큰 트래커는 비용을 알려준다. 세션 로거는 무슨 대화를 했는지 알려준다. 하지만 누가 실제로 결과를 주도했는지는 아무것도 말해주지 않는다.

2. /collab-proof 가 하는 일

세션이 끝난 후 /collab-proof를 실행하면 git 히스토리와 대화 컨텍스트를 분석해서 이런 기록을 남긴다.

AI 기여도 예시 (HIGH 세션)

**AI contribution**:
  - Identified: 개발자가 놓친 ZCARD와 ZADD 사이의 TOCTOU 구간
  - Suggested: Redis 원자성 검토 후 Lua EVAL 방식 제안
  - Developer-driven: 최종 구현, Lua vs MULTI/EXEC 최종 결정

Claude가 지시를 실행만 한 세션은 솔직하게 기록한다:

**AI contribution**:
  - Developer-driven session. Claude executed instructions.

루브릭이 양쪽 모두 명시하도록 강제한다. 과장도, 축소도 없이.

3. 어떻게 동작하나

3단계 파이프라인이 /collab-proof 실행 시 동작한다.

  • Layer 01 — 신호 감지 git log와 git diff를 읽어 이 세션이 기록할 가치가 있는지 판단한다. 새 파일 생성, 4개 이상 파일 수정, 명시적 대안 비교가 있으면 HIGH. 단순 구현이면 침묵한다. 전체 세션의 30~40%만 기록된다.
  • Layer 02 — 4-frame 분석 4개의 인지 프레임을 동시에 점수화한다.
    • A: 코드 변경 깊이
    • B: 개발자의 불확실성 신호 (롤백, 방향 전환)
    • C: 명시적 대안 비교 (A vs B)
    • D: AI 기여도 — 여기가 핵심이다.
  • Layer 03 — 기록
    • DECISIONS.md: 결정 분기점마다 왜 그 결정을 했는지, 무엇을 버렸는지 기록.
    • WORKLOG.md: D score가 세션마다 누적됨 — 내가 나아지고 있는지 트렌드로 확인.
    • proof.html: 토큰 효율 포함한 자체 포함 HTML (로컬 JSONL 읽기, 추가 API 비용 없음).

💡 Note: 훅은 비동기(Background subshell)로 동작해서 Claude Code를 블로킹하지 않는다.

4. 트레이드오프와 한계

  • D score의 주관성: LLM이 루브릭에 따라 평가한 값이다. 같은 세션도 실행할 때마다 조금씩 다를 수 있다. 절댓값이 아닌 '트렌드 지표'로 보는 게 맞다.
  • git notes의 제약: 앵커링은 솔로 개발자 기능이다. 팀 환경에서 squash merge를 쓰면 노트가 유실된다.

5. 설치 및 사용법

 외부 의존성 없음. pip install 불필요.

git clone https://github.com/dong7812/collab-proof && cd collab-proof && ./install.sh

이후 Claude Code 세션에서 /collab-proof만 실행하면 된다. SessionEnd 훅을 활성화하면 세션 종료 시 자동으로 실행된다.

 

GitHub - dong7812/collab-proof: Claude Code skill that auto-generates AI collaboration evidence — Vela pipeline × ADHD tree-o

Claude Code skill that auto-generates AI collaboration evidence — Vela pipeline × ADHD tree-of-thought, zero dependencies - dong7812/collab-proof

github.com

간단한 데모 gif


이 스킬을 직접 빌드하고 내 로컬 환경에 적용해 보면서 두 가지 확신이 들었다.

첫째, "기록되지 않은 지식은 부채가 된다."
그동안 AI와 속도감 있게 개발하며 생산성이 높아졌다고 착각했지만, 세션이 끝나고 일주일만 지나면 "내가 왜 이 코드를 이렇게 짰지?"라며 과거의 나와 Claude가 내린 결정을 역공학(Reverse Engineering)하고 있는 나를 발견하곤 했다. `/collab-proof`를 띄워두고 나서야 비로소 AI를 '단순 코딩 대행사'가 아닌, 내 아키텍처 결정을 함께 고민한 '동료'로 대우하고 기록할 수 있게 되었다.

둘째, "좋은 도구는 개발자의 태도를 바꾼다.
`WORKLOG.md`에 쌓이는 D score 트렌드를 보면서 묘한 심리적 변화가 생겼다. AI 기여도가 1.0에 가까운 세션이 많아지면 '내가 너무 생각 없이 Claude에게 다 맡기고 지시만 따랐나?' 하고 반성하게 되고, 반대로 너무 낮으면 'Claude를 단순 타자수로만 쓰고 협업을 못 했구나' 하는 캘리브레이션이 스스로 일어난다. 점수 자체의 완벽함보다, 이 점수를 보며 **"내가 지금 AI와 제대로 협업하고 있는가?"를 주기적으로 자각하게 만드는 것** 자체가 이 스킬의 진짜 가치라는 생각이 든다.

앞으로는 이 누적된 기록들을 바탕으로, 장기적인 협업 트렌드를 시각화하는 `/collab-review` 기능이나, 팀원들과 이 결정을 더 부드럽게 공유할 수 있는 방법을 고민해 볼 예정이다. 결국 AI 시대의 개발자는 '코드를 많이 짜는 사람'이 아니라 '결정의 맥락을 완벽하게 통제하는 사람'이 진정 어울린다고 생각한다.

 

물론 내가 만든 skill에 대한 실효성은 아직 검증되지 않았지만, 여러 방면으로 노력하는 개발자가 되고 싶다...!

 

RAG를 단순하게 컨텍스트 기반 검색으로 원하는 검색 범위를 지정해서 검색할 수 있는 검색 기능으로 이해하고 있었다.

하지만 RAG를 깊게 공부해볼수록 여러 가지 방식으로 검색이 가능함을 알 수 있었다.

공부 파바바바바바ㅏㄱ

RAG의 진짜 목적

RAG는 단순 검색이 아니다. LLM이 학습 데이터 이후의 정보를 모르거나, 특정 도메인 데이터가 없을 때 할루시네이션이 발생하는 근본적인 한계를 보완하는 아키텍처다. 실제 데이터 기반으로 답변을 생성하기 때문에 정확도가 올라간다.

 

RAG-Sequence vs RAG-Token

크게 RAG는 2가지로 token, sequence 기반 검색 방식으로 나뉜다.

RAG-Sequence는 검색을 1번만 진행하고 전체 답변을 한 번에 생성한다. 일반적인 QA 서비스에서 쓰기 적합하고 비용도 저렴하다.

RAG-Token은 토큰을 생성할 때마다 다른 문서를 참조해서 정밀도와 완성도 높은 답변을 얻을 수 있다. 하지만 토큰 수만큼 검색과 LLM 호출이 발생해서 비용이 폭발적으로 늘어난다.


이를 보완하고 발전된 방식으로, 2026년에 가장 큰 변화는 RAG와 에이전트 루프의 결합이다. Agentic RAG는 시스템이 한 번만 검색하는 게 아니라, 무엇을 검색할지, 언제 멈출지를 스스로 추론한다. 이게 2026년 대부분의 프로덕션 RAG 시스템을 이끄는 아키텍처다.

 

Agentic RAG

RAG-Token의 비용 문제를 보완하고 발전된 방식이 Agentic RAG다. 검색 횟수를 고정하지 않고 Agent가 스스로 "지금 정보가 부족하다"고 판단할 때만 추가 검색을 진행한다. 무엇을 검색할지, 언제 멈출지를 스스로 추론하는 방식이다. 2026년 대부분의 프로덕션 RAG 시스템이 이 방향으로 가고 있다.

 

Chunking 전략

청크를 어떻게 나누냐가 RAG 품질을 직접적으로 결정한다. Fixed Size는 단순하지만 문장 중간에서 잘리는 문제가 있다. Sliding Window는 청크 경계에서 컨텍스트가 끊기지 않도록 overlap을 준다. 회의록처럼 발화자 정보가 있으면 발화자 단위가 가장 자연스럽다.

 

Hybrid Retrieval

Dense(벡터 유사도 기반)와 Sparse(키워드 기반)를 결합한 Hybrid Retrieval이 실무에서 가장 효과적이다. 의미 기반 검색과 정확한 키워드 검색을 동시에 잡을 수 있기 때문이다.

Hybrid Retrieval의 간단한 예시 상황

추가 공부 : Long Context와 RAG의 관계

2024~2025년부터 LLM의 컨텍스트 윈도우가 급격히 커졌다. Claude 4.6 기준 100만 토큰까지 처리 가능해지면서 "RAG 없이 전체 문서를 통째로 넣으면 되지 않냐"는 얘기가 나오기 시작했다.

 

실제로 고트래픽 서비스에서는 Prompt Caching과 결합하면 Long Context가 RAG보다 저렴한 경우도 생겼다. 하지만 회의록처럼 대용량 파일을 다루는 서비스에서는 여전히 관련 청크만 검색해서 LLM에 전달하는 RAG 방식이 비용 효율적이다.

 

결국 Long Context와 RAG는 경쟁 관계가 아니라 상황에 따라 선택하거나 결합하는 방식으로 가고 있다.

모바일 환경에서 안드로이드 앱은 격리된 프로세스, 즉 샌드박스(Sandbox) 환경에서 동작한다.

이런 개별 프로세스에서 중요한 점은 단말의 한정된 메모리를 적절히 분배하는 것도 있지만, 앱 프로세스에 할당된 스레드(Thread)를 얼마나 적절히 나누느냐에 따라 UI/UX 측면에서 큰 차이가 발생한다.

코코 코코코코코루틴

할당된 스레드를 더 작은 단위인 태스크(Task)로 나눠 동작하게 만드는 기능이 바로 async와 코루틴(Coroutine)이다.

기존 RxKotlin 환경에서도 비동기 처리를 위해 async를 사용했지만, 최근 트렌드인 Compose에서는 코루틴을 더 적극적으로 활용하며 최적화되어 있다.

 

특히 Dispatcher를 통해 코루틴이 동작할 스코프를 지정할 수 있는데, 주요 설정은 다음과 같다.

  • Main: UI 스레드 (1개)
  • IO: 네트워킹 및 DB 작업 (최대 64개)
  • Default : JSON 파싱, 이미지 처리 (CPU 코어 수와 동일)

코루틴으로 실행된 함수는 실행 후 결과가 나오기까지 다른 함수에게 스레드 점유를 넘겨주고(Suspend), 결과가 나오면(await) 다시 태스크를 할당받아 결과를 전달해 주는 방식으로 동작한다.

 

CPS와 State Machine

 

이러한 코루틴의 '일시 중단(Suspend)'과 '재개(Resume)'가 가능한 이유는 컴파일 단계에서 CPS(Continuation Passing Style) 구조로 변환되기 때문이다. (컴파일 단계에서 factory 구조로 변환됨은 C를 실행했을 때, 컴파일러가 assembly 언어로 변경하는 것과 비슷하다. 이렇게 하면 보일러플레이트 코드 없이 컴파일러가 자동으로 실행까지 진행해준다.)

 

코루틴의 컴파일 단계에서 cps 변환 과정에 대한 flow

 

1. Continuation (코루틴의 Context)

 

OS가 스레드를 전환할 때 CPU 레지스터 상태를 저장하는 PCB(Process Control Block)나 Context를 사용하는 것처럼, 코루틴은 Continuation이라는 객체에 현재 작업 상태를 저장한다.

  • C <-> Assembly : 컨텍스트 스위칭 시 레지스터(EAX, ESP 등) 값을 커널 스택에 저장(Push)하고 복구(Pop)하는 하드웨어 의존적 비용이 발생한다.
  • Kotlin Coroutine: 컴파일러가 함수의 마지막 인자로 Continuation을 추가하며, 여기에 로컬 변수와 실행 지점(label)을 저장한다. 이는 커널의 개입 없이 Heap 영역에서 객체로 관리되므로 훨씬 가볍다.

2. State Machine: 컴파일러가 생성하는 Factory 코드

 

suspend 함수는 컴파일 타임에 일종의 State Machine 구조로 변경된다. 각 중단 지점(delay, await 등)을 기준으로 코드가 분절되며, switch-case 문과 유사한 형태로 변환된다.

  • 함수가 재개될 때마다 Continuation에 저장된 label 값을 확인하여 이전에 멈췄던 지점으로 즉시 점프(Jump)한다.
  • 이는 C언어에서 함수 호출 시 스택 프레임을 새로 생성하는 것과 달리, 하나의 객체 상태만 업데이트하며 로직을 이어가는 방식이다.

3. Summary: OS 스레드 vs 코루틴

결과적으로 코루틴은 OS가 인터럽트를 통해 강제로 CPU 점유를 뺏어오는 선점형(Preemptive) 방식이 아니라, 함수 스스로가 다음 타자에게 실행권을 양보하는 협력형(Cooperative) 모델이다.

 

android 코드의 실행 파일 변환 과정(jvm 위에서 동작하지만 .dex로 런타임 파일 생성 flow 추가)

안드로이드 앱은 하나의 프로세스로 각각의 앱이 실행되는 것은 하나의 프로세스가 실행됨으로 표현할 수 있다. 즉, 하나의 앱이 실행되는 것은 OS 차원에서 하나의 프로세스가 생성되어 동작하는 것과 같다.

여기서 핵심적으로 보아야 할 점은 AOS(Android OS)가 이 프로세스들을 어떻게 관리하는지, 그리고 앱 내부의 메모리와 캐시가 어떤 메커니즘으로 동작하는지이다.

 

우선, OS는 우리가 보는 화면 단위가 아니라 PCB(Process Control Block)라는 구조체를 통해 프로세스를 식별하고 관리한다. 안드로이드 프로세스는 실행 시 시스템 자원을 효율적으로 쓰기 위해 공통 라이브러리 메모리를 공유하며 진행되는데, 이때 각 앱 프로세스에 할당된 메모리는 기기의 물리적 한계가 제약이 존재한다.

 

이렇게 제한된 메모리 환경에서 특정 앱이 과도하게 자원을 점유하는 것을 막기 위해, 안드로이드는 각 앱을 독립된 샌드박스(Sandbox)로 격리하여 운영한다. 또한, 프로세스 내부에서는 ART(Android Runtime)의 GC(Garbage Collector)가 동작하며 불필요한 객체를 정리하고, 메모리를 유동적으로 관리하며 시스템 전체의 안정성을 유지한다.

 

이러한 프로세스 단위의 격리와 관리 체계 위에서, 앱은 한정된 메모리 자원을 극대화하기 위해 운영체제의 설계 철학을 애플리케이션 레벨로 끌어올린다. 그 대표적인 사례이자, 실제 아키텍쳐를 구현하면서 느껴본 계층적 캐시 전략(Tiered Caching Strategy)이다.

 

흔히 운영체제는 물리 메모리(RAM)의 공간적 한계를 극복하기 위해 디스크의 일부를 가상 메모리 공간으로 활용하는 스왑(Swap) 메커니즘을 사용한다. 안드로이드 앱 역시 이와 본질적으로 동일한 논리 구조를 Memory 캐시와 Disk 캐시의 2-Tier 구조를 통해 재현하며, 이를 이미지 로더(Image Loader)의 동작 과정에서 명확히 확인할 수 있다.

1. Memory Cache: OS의 물리 RAM 역할

앱 프로세스 내부의 메모리 캐시(주로 LruCache 등)는 OS의 물리적 RAM과 대응된다. CPU(혹은 GPU)가 즉각적으로 참조할 수 있는 가장 빠른 영역이지만, 샌드박스에 할당된 힙 메모리 임계치 내에서만 생존할 수 있다는 제약이 있다. GC의 주요 관리 대상이기도 한 이 영역은 '현재 가장 자주 쓰이는 데이터'를 최우선으로 유지하며 빠른 사용자 경험을 보장한다.

2. Disk Cache: OS의 가상 메모리(Swap) 역할

메모리 캐시에서 밀려났거나, 앱이 종료되어도 유지되어야 하는 데이터는 디스크 캐시로 이동한다. 이는 OS 차원에서 RAM의 부족한 공간을 보완하기 위해 디스크를 가상 메모리(Swap) 영역으로 활용하는 것과 일맥상통한다. 속도는 메모리에 비해 현저히 느리지만, 프로세스의 생명주기와 무관하게 데이터를 영속적으로 보관할 수 있는 광활한 저장 공간을 제공한다.

3. 앱 레벨의 Swap-in: 이미지 로더의 데이터 로딩

이미지 로더가 특정 이미지를 요청받았을 때, 메모리 캐시에서 데이터를 찾지 못하는 'Cache Miss'가 발생하면 비로소 디스크 캐시를 탐색한다. 이때 디스크의 바이너리 데이터를 읽어와 비트맵으로 디코딩한 뒤 다시 메모리 캐시에 올리는 과정은, OS가 Page Fault 발생 시 디스크의 데이터를 RAM으로 로드하는 'Swap-in' 동작의 앱 레벨 구현체라고 볼 수 있다.

 

실제 구축해본 다계층 구조의 캐시 전략 아키텍쳐

 

[구현하면서 느낀 트러블 슈팅 사례: DB가 아닌 Disk Cache를 선택한 이유]

실제 다계층 캐시를 구축하며 직면했던 고민 중 하나는 이미지 데이터의 영속성을 어떻게 보장할 것인가였다. 초기에 단순히 데이터의 유지(Persistence)만을 생각했을 때는 Room 라이브러리를 통한 DB 관리를 고려했다. 하지만 이미지와 같은 대용량 바이너리 데이터를 DB에 직접 적재하는 것은 입출력 오버헤드와 성능 저하를 야기할 수 있다는 점이 걸림돌이었다.

 

여기서 깊이 파고든 지점은 DiskLruCache의 Journal 메커니즘이었다. 디스크 캐시는 단순히 파일을 저장하는 것에 그치지 않고, journal 파일을 통해 각 캐시 데이터의 상태(읽기, 쓰기, 삭제 등)를 기록하며 관리한다. 이 구조 덕분에 앱이 완전히 종료된 콜드 스타트(Cold Start) 환경에서도 캐시의 일관성을 유지하며 데이터를 즉각적으로 복구할 수 있음을 이해하게 되었다.

 

결국, DB라는 무거운 레이어를 추가하는 대신, 파일 시스템 기반의 저널링 기법을 활용한 디스크 캐시를 채택함으로써 데이터 영속성과 입출력 성능이라는 두 마리 토끼를 잡을 수 있었다. 이 과정에서 "얕게 알고 있는 지식보다 도구들의 차이점을 명확히 알고 제대로 쓸 수 있는 것이 진짜 설계"라는 점을 다시 한번 깨달았다.

 

Android를 다룸에 있어서, 단순하게 UI/UX적인 측면에서 접근하려 하지 않고 실제 메모리 적재와 앱 성능의 향상에 대해 고민을 하면서 deepdive를 진행해보니 CS를 다시 공부했던게 많은 도움이 됨을 절실히 느끼고 있다. 솔직히 이제 더 재미있고 흥미가 느껴지는 거 같다. 

이번에 다계층 구조화 캐시를 직접 구현해봤으니, 다음에는 코루틴을 실행하는 것이 보일러플레이트 코드를 어떻게 줄이는지에 대해서 더 공부해보고 이해해보려고 한다.

 

공부 또 공부

hilt는 안드로이드에서 dependency injection을 통해 의존성 관리를 할 수 있도록 도와주는 프레임워크다.

이전 회사 프로젝트에서 한번 다뤄본 적이 있지만, 다시금 공부하면서 원리와 기존의 보일러플레이트 코드 대체 방식에 대해 정리해본다.

공부할게 참..많다..세상은 빠르다..

  Hilt는 **KSP(Kotlin Symbol Processing)**를 사용해서 빌드할 때 코드를 자동으로 만들어준다.

 

  hiltviewmodel을 예시로 한 단계별 동작 과정:

 

  1단계: 어노테이션 스캔 (컴파일 타임)

 

  @HiltViewModel
  class MovieListViewModel @Inject constructor(
      private val repository: MovieRepository
  ) : ViewModel()

  @Singleton
  class MovieRepository @Inject constructor()

 

  2단계: Hilt가 자동으로 생성하는 코드 (빌드 시)

 

  // app/build/generated/ksp/debug/kotlin/ 생성됨

 // 1. MovieListViewModel을 위한 Factory
  class MovieListViewModel_Factory @Inject constructor(
      private val repositoryProvider: Provider<MovieRepository>
  ) : ViewModelProvider.Factory {
      override fun <T : ViewModel> create(modelClass: Class<T>): T {
          val repository = repositoryProvider.get()  // Repository 가져오기
          return MovieListViewModel(repository) as T  // ViewModel 생성
      }
  }

  // 2. MovieRepository를 위한 Factory
  class MovieRepository_Factory @Inject constructor() {
      fun get(): MovieRepository {
          return MovieRepository()  // Repository 생성
      }
  }

  // 3. Hilt Component (의존성 그래프)
  @Component
  interface ApplicationComponent {
      fun movieRepositoryFactory(): MovieRepository_Factory
      fun movieListViewModelFactory(): MovieListViewModel_Factory
  }

 

 3단계: 런타임에 사용

  // Composable에서 hiltViewModel() 호출하면:
  @Composable
  fun MovieListScreen(
      viewModel: MovieListViewModel = hiltViewModel()  // ← 여기!
  ) {
      // hiltViewModel() 내부 동작:
      // 1. Hilt Component에서 MovieListViewModel_Factory 가져오기
      // 2. Factory가 MovieRepository_Factory에서 Repository 생성
      // 3. Repository를 MovieListViewModel 생성자에 주입
      // 4. ViewModel 반환
  }

 

구분 viewModel() hiltViewModel()
생성 방식 빈 생성자만 가능 파라미터 있어도 가능
의존성 주입 불가능 자동 주입
테스트 Mock 주입 어려움 Mock 주입 쉬움
사용 조건 없음 @HiltViewModel annotation 필요

 

Hilt의 핵심 어노테이션 역할

 

  @HiltAndroidApp

  class MovieApplication : Application()

  - 역할: "앱 전체의 DI 시작점이야!"

  - 생성: ApplicationComponent (의존성 그래프의 루트)

  - 생명주기: 앱이 살아있는 동안

 

  @AndroidEntryPoint

  class MainActivity : ComponentActivity()

  - 역할: "이 Activity에서 Hilt 사용할 거야!"

  - 생성: Activity용 하위 Component

  - 효과: 이 Activity/Fragment에서 @InjecthiltViewModel() 사용 가능

 

  @HiltViewModel

  class MovieListViewModel @Inject constructor(...)

  - 역할: "이건 Hilt가 관리하는 ViewModel이야!"

  - 생성: ViewModel용 Factory 자동 생성

  - 효과: hiltViewModel()로 인스턴스 생성 가능

 

  @Inject

  class MovieRepository @Inject constructor()

  - 역할: "이 생성자로 인스턴스 만들어줘!"

  - 효과: Hilt가 자동으로 Factory 생성

 

  @Singleton

  class MovieRepository @Inject constructor()

  - 역할: "앱에서 하나만 만들어!"

  - 효과: 같은 인스턴스를 재사용 (메모리 절약)

 

개념 설명
Boilerplate 반복적으로 작성해야 하는 지루한 코드
Hilt의 role Adapter/Factory 같은 연결 코드를 컴파일 타임에 자동 생성
KSP Kotlin Symbol Processing = 코드 생성 도구
결과 @inject만 붙이면, Hilt가 알아서 Factory 만들어줌

 

안드로이드 개발을 하면서 오랫동안 XML + Fragment 기반으로 개발을 해왔다.
이번 Compose를 공부하면서 느낀 건 단순히 UI 작성 방식이 바뀐 게 아니라 앱을 구성하는 방식 자체가 바뀌고 있다는 점이었다.

 

공부는 excited

1. 화면 구성: Fragment → Composable

예전 안드로이드에서 화면은 Fragment + XML이었다.

FragmentManager로 Transaction 날리고, backstack 관리하고, 생명주기 꼬이면 크래시 터지고 난리부르스였다.

조금만 복잡해져도 "이 Fragment 지금 attach 상태 맞나?" 같은 디버깅을 꽤 많이 했던 기억이 있다.

 

Compose에서는 화면이 @Composable 함수다.

상태(State)가 바뀌면, 그 상태를 사용하는 Composable이 다시 호출되면서 UI가 다시 그려진다.

그리고 Navigation도 Fragment가 아니라 Route 기반으로 동작한다.

home
detail/123
profile/42
 

이런 문자열 주소를 기준으로 화면 함수가 교체된다.

 

물론 현실에서는 Fragment가 완전히 사라지진 않는다.
BottomSheetDialogFragment 같은 라이브러리도 아직 Fragment 기반이고, 기존 프로젝트에서는 레거시 코드가 섞여 있기 때문이다.

그래서 정확히 말하면 Fragment가 사라졌다기보다, 의존성이 크게 줄어드는 방향으로 바뀌고 있다.

 

공부하면서 진짜 Fragment 영역이 줄어든 거는 너어어어어어무 편하고 좋다.

2. 로컬 저장소: SharedPreferences → DataStore

DataStore는 코루틴 + Flow 기반 저장소다.

데이터를 단순히 읽어오는 게 아니라, Flow로 데이터 흐름을 구독하는 구조다.

즉, 값이 바뀌면 UI가 자동으로 다시 그려진다.

 

SharedPreferences처럼

 
값 변경
→ 다시 읽기
→ UI 반영

이런 코드를 직접 작성할 필요가 없다.

 

앱 구동할 때, 저장해서 쓰는 value들도 훨씬 편하고 타이밍 관리가 편해진 거 같다.

3. 이벤트 전달: Broadcast → SharedFlow

예전에 앱 내부 이벤트 전달할 때
LocalBroadcastManager나 PendingIntent 같은 걸 쓰기도 했다.

근데 이 구조는

  • 인텐트 파싱 귀찮고
  • 등록/해제 관리 어렵고
  • 잘못하면 메모리 누수

문제가 꽤 많았다.

요즘은 대부분 Flow 기반 이벤트로 바뀌고 있다.

대표적으로

  • SharedFlow
  • Channel

차이는 단순하다.

예전은 "이 이벤트를 방송한다"

지금은 "이 데이터 흐름을 구독한다"

 

이 개념 차이다.

4. 리스트 구현: RecyclerView → LazyColumn

RecyclerView 만들 때 항상 느끼던 게 있다.

 

Adapter / ViewHolder / DiffUtil

 

파일이 기본 2~3개는 생긴다. 그 보일러플레이트 코드가 꽤 부담이었다. 그리고 나중가면 헷갈려서 끙끙 앓던 기억이 있다.

Compose에서는 LazyColumn 하나로 끝난다.

어댑터가 없다.

리스트도 그냥 함수 안에서 선언한다.

그리고 DiffUtil이 하던 역할은
아이템의 key가 대신한다.

데이터가 바뀌면 key를 기준으로 변경된 부분만 Recomposition된다.

 

RecyclerView Adapter 만들던 시절 생각하면
아직도 이게 함수 하나로 끝난다는 게 좀 신기하다.

5. 딥링크: 수동 매칭 → 자동 라우팅

예전에 Firebase 딥링크 처리할 때는
Activity에서 URL 받고

  • 파싱하고
  • 파라미터 꺼내고
  • 맞는 Fragment 찾아서
  • 트랜잭션 날리고

이걸 전부 수동으로 처리했다.

 

Navigation Compose에서는 NavHost에 딥링크만 등록하면 된다.

그러면 시스템이 알아서 해당 Composable 화면을 실행한다.

 

Notification이나 WorkManager 같은 시스템 도구는 그대로 쓰지만
화면 연결 구조는 딥링크 + Navigation으로 훨씬 단순해진다.

 

내가 쓰던 방식이 이제는 구시대 방식인거를 깨닫고 공부를 하는데, 선언형 UI 구성이라 react랑 비슷하다는 생각이 든다.

UI 선언 JSX @Composable 함수
로컬 상태 useState remember { mutableStateOf() }
상태 유지 (리렌더 생존) useRef remember
상태 유지 (새로고침 생존) 없음 (localStorage 직접) rememberSaveable
전역 상태 Zustand, Redux ViewModel + StateFlow
서버 상태 / 비동기 React Query ViewModel + Repository + Flow
리렌더 최소화 useMemo, React.memo derivedStateOf, remember 람다 안정화
사이드이펙트 useEffect LaunchedEffect, SideEffect
Context 전달 useContext CompositionLocalProvider
라우팅 React Router Navigation Compose
리스트 렌더링 Array.map() LazyColumn
DiffUtil (리스트 최적화) key prop key 파라미터 (동일함)
스타일링 CSS / Tailwind Modifier
컴포넌트 재렌더 조건 props / state 변경 State 변경 → Recomposition

 

react 공부했던게 여기서 이렇게 쓰일 줄은 몰랐는데, 좋다 히히

 

+ Recent posts