전체 글
- Reverie 2026.08.18
- 마이크로프로세서 1강 2026.08.18
- Bill Gates & Sam Altman 팟캐스트 요약 (Q&A + 배경지식) 2026.01.22
- 안드로이드 빌드 2부: DEX와 Dalvik, 그리고 ART – .class는 어디로 사라졌을까? (2/7) 2025.11.15
- 안드로이드 빌드 1부: 소스코드에서 바이트코드까지 - 컴파일의 세계 (1/7) 2025.11.15
- AI 활용에 대한 생각 2025.10.11
- [Kotlin] Continuation Interceptor 2025.05.11
- [책] 코틀린 코루틴 - Mutex vs Single thread dispatcher 2024.05.31
- 6. Kotlin Coroutines - Exceptions 2024.05.23 2
- 5. Kotlin Coroutine - Cancellation 2024.05.22
Reverie
마이크로프로세서 1강
http://www.kocw.net/home/enrolment/enrolmentView.do?cid=fe50d62222e7089e&lid=004aa100c29d4bdc
KOCW
※ 소셜 로그인 10월 중 오픈 예정 --> 인터넷 옵션에 쿠키 허용이 안될경우 로그인이 안될 수 있습니다. 인터넷브라우저 > 도구 > 인터넷옵션 > 개인정보 > 고급 > 쿠키허용
www.kocw.net


🧠 HBM 구조의 핵심 특징
- 1. 3D 적층 구조 (Vertical Stacking)
- 그림에서 보듯 여러 개의 **Core Die(여기서는 4개)**를 수직으로 포개어 쌓은 형태입니다. 평면(2D)으로 넓게 배치하던 기존 메모리의 한계를 극복하고 공간 효율을 극대화했습니다.
- 2. TSV (Through-Silicon Via, 실리콘 관통 전극)
- 적층된 수많은 다이들을 **수직으로 관통하는 미세한 구멍(전선)**들입니다. 그림 중앙에 1024 TSV I/O라고 적힌 부분이 바로 이것이며, 칩들을 수직으로 뚫어 연결함으로써 초고속 대역폭을 달성하는 핵심 기술입니다.
- 3. Base Logic Die (베이스 다이 / 로직 다이)
- 맨 아랫단에 위치하며, 메모리 셀(저장 공간)이 아니라 **컨트롤러 역할과 물리 계층(PHY)**을 담당하는 두뇌 역할을 합니다. 인터페이스 제어와 전원 관리 등을 수행합니다.
- 4. 초고대역폭과 광폭 인터페이스 (Wide I/O)
- 기존 메모리는 핀 개수가 제한적이었지만, HBM은 TSV를 통해 **1024개라는 엄청나게 많은 데이터 핀(1024-bit Bus Width)**을 동시에 사용하여 대량의 데이터를 한 번에(Parallel) 주고받습니다. 이 때문에 '고대역폭(High Bandwidth)'이라는 이름이 붙었습니다.
- 5. 마이크로 범프 (Microbump Array)
- 다이와 다이 사이, 그리고 베이스 다이와 연결할 때 사용하는 아주 미세한 금속 돌기(범프)입니다. 칩 간의 전기적 연결과 물리적 결합을 동시에 담당합니다.
- 6. 채널 구조 (Channel Architecture)
- 그림 우측과 상단에 CH 0부터 CH 7까지 표시된 것처럼, 내부가 여러 개의 독립된 채널(예: 8채널 등)로 쪼개져 있어 병렬 처리에 매우 유리한 구조를 가집니다.
Bill Gates & Sam Altman 팟캐스트 요약 (Q&A + 배경지식)
https://www.gatesnotes.com/meet-bill/my-podcasts/reader/unconfuse-me-podcast-with-guest-sam-altman
Unconfuse Me Ep. 6: Sam Altman | Bill Gates
In the sixth episode of his podcast, Bill sits down with Sam Altman to talk about where AI is headed next and what humanity will do once it gets there.
www.gatesnotes.com
AI 모델의 작동 원리 이해
Q: AI 모델이 어떻게 작동하는지 현재 이해하고 있나요?
A: 아직 완전히 이해하지 못하고 있지만, 5년 내에 이해할 수 있을 것으로 예상합니다. 인간 뇌에서 셰익스피어가 어디에 저장되어 있는지 모르는 것처럼, AI 모델도 마찬가지입니다. 다만 AI는 완벽히 분석할 수 있어서 해석 가능성(interpretability) 연구가 진행 중입니다. 이 이해가 깊어지면 훈련 효율성과 정확도 모두 크게 향상될 것입니다.
관련 배경지식
- Interpretability (해석 가능성): AI 모델이 왜 특정 결정을 내렸는지 인간이 이해할 수 있도록 하는 연구 분야입니다. 현재 딥러닝 모델은 수십억 개의 파라미터가 복잡하게 연결되어 있어서, 입력이 어떻게 출력으로 변환되는지 추적하기 어렵습니다. 예를 들어, GPT가 "서울의 수도는?"이라는 질문에 "서울은 수도입니다"라고 답할 때, 모델 내부의 어떤 뉴런들이 활성화되어 이 답을 만들어냈는지 역추적하는 연구가 해석 가능성 연구입니다.
- Scaling Laws (스케일링 법칙): 모델 크기, 데이터 양, 연산량을 늘리면 성능이 예측 가능한 방식으로 향상된다는 발견입니다. OpenAI가 GPT-1에서 GPT-4로 발전하면서 이 법칙이 경험적으로 확인되었고, 덕분에 아직 훈련하지 않은 모델의 성능을 미리 예측할 수 있게 되었습니다. 인터뷰에서 Altman이 "데모가 잘 될 거라고 확신했다. 모델을 훈련하지 않았는데도"라고 말한 것이 바로 이 스케일링 법칙 덕분입니다.
향후 2년간의 주요 발전
Q: AI의 다음 중요한 발전은 무엇인가요?
A: 멀티모달리티(음성, 이미지, 영상 입출력), 추론 능력, 신뢰성 향상이 핵심입니다. 현재 GPT-4는 매우 제한적인 추론만 가능하고, 같은 질문에 만 번 답하면 품질이 들쭉날쭉합니다. 개인화와 사용자 데이터 연동(이메일, 캘린더, 예약 선호도 등)도 중요한 발전 방향입니다. 이미지와 오디오 기능 출시 때 예상보다 훨씬 뜨거운 반응이 있었습니다.
관련 배경지식
- Multimodality (멀티모달리티): 하나의 AI 모델이 텍스트, 이미지, 음성, 영상 등 여러 형태의 데이터를 동시에 이해하고 생성할 수 있는 능력입니다. 초기 GPT는 텍스트만 다뤘지만, 인간은 보고, 듣고, 말하는 것을 동시에 처리하므로 AI도 이런 능력이 필요합니다. 예를 들어, 사용자가 냉장고 사진을 찍어서 "이걸로 뭘 만들 수 있어?"라고 음성으로 물으면, AI가 사진 속 재료를 인식하고 레시피를 음성으로 답해주는 것이 멀티모달 AI입니다.
연산량(Compute) 문제
Q: 현재 AI의 연산 방식에 어떤 문제가 있나요?
A: 현재는 "the"라는 단순한 단어나 리만 가설 같은 복잡한 수학 문제나 동일한 연산량을 사용합니다. 적응형 연산(adaptive compute)이 필요한데, 이는 어려운 문제에는 더 많은 연산을, 쉬운 문제에는 적은 연산을 할당하는 방식입니다. 복잡한 수학 방정식을 풀 때는 변환을 임의의 횟수만큼 적용해야 할 수 있어서, 제어 로직이 현재보다 훨씬 정교해져야 할 수도 있습니다.
관련 배경지식
- Adaptive Compute (적응형 연산): 문제의 난이도에 따라 연산량을 다르게 할당하는 방식입니다. 현재 트랜스포머 모델은 모든 토큰(단어)을 생성할 때 동일한 연산을 수행합니다. 인간이 "1+1=?"과 "페르마의 마지막 정리를 증명하라"에 쏟는 시간이 다르듯, AI도 쉬운 문제에는 적은 연산을, 어려운 문제에는 많은 연산을 할당해야 합니다.
- 리만 가설 (Riemann Hypothesis): 1859년 수학자 베른하르트 리만이 제시한 미해결 문제로, 소수의 분포에 관한 것입니다. 밀레니엄 7대 난제 중 하나이며, 증명하면 100만 달러의 상금이 있습니다. Gates가 현재 시스템의 비효율성을 설명할 때 극단적인 예시로 사용했습니다.
AI 규제
Q: AI는 어떻게 규제되어야 하나요?
A: IAEA(국제원자력기구)처럼 초강력 AI 시스템을 감독하는 글로벌 규제 기관이 필요합니다. 극도로 높은 연산 임계값을 초과하는 클러스터(전 세계에 약 5개 정도)만 국제 안전 감사를 받으면 됩니다. Altman은 올해 세계 여러 국가 정상들을 만났는데, 이 접근법에 거의 보편적인 지지가 있었다고 합니다. "속도를 늦추라"는 요구는 저항을 받겠지만, "원하는 대로 하되 특정 임계값 이상은 검사받으라"는 방식은 실현 가능합니다.
관련 배경지식
- IAEA (International Atomic Energy Agency, 국제원자력기구): 1957년 설립된 UN 산하 기구로, 원자력의 평화적 이용을 촉진하고 핵무기 확산을 방지합니다. 회원국의 핵시설을 정기적으로 사찰하고, 안전 기준을 수립합니다. IAEA 사찰관이 각국 원자력 발전소를 방문해 핵물질이 무기로 전용되지 않는지 확인하듯, AI 규제 기관이 초대형 AI 학습 클러스터를 감사하는 방식을 Altman이 제안한 것입니다.
- 소셜 미디어 규제 실패: Gates와 Altman 모두 정부가 소셜 미디어 규제에 실패한 것을 AI 규제의 교훈으로 삼아야 한다고 언급합니다. Facebook, Twitter 등이 사회적 극단화와 허위정보 확산에 기여했지만, 효과적인 규제가 이루어지지 못했습니다. 미국 상원에서 30명의 의원이 참석한 AI 교육 세션에서 의원들이 "소셜 미디어 때 실패했으니 AI는 다르게 해야 한다"고 말한 배경입니다.
AI의 생산성 향상
Q: AI가 가장 큰 생산성 향상을 가져오는 분야는?
A: 코딩이 현재 가장 큰 효과를 보이며, 프로그래머 생산성이 약 3배 향상됩니다. 단순히 3배 빨라지는 게 아니라, 더 높은 추상화 수준에서 질적으로 다른 작업이 가능해집니다. 이는 펀치카드에서 고급 언어로 전환했을 때와 비슷한 변화입니다. 헬스케어와 교육도 빠르게 발전 중이며, 앞으로는 "이 프로그램 전체를 작성해줘"라고 에이전트에게 지시하거나, 언젠가는 "이 회사를 운영해줘" 또는 "새로운 물리학을 발견해줘"라고 할 수 있을 것입니다.
관련 배경지식
- 프로그래머 생산성 3배 향상: 인터뷰 시점(2024년 초) 기준으로, AI 코딩 도구를 사용하면 프로그래머 생산성이 약 3배 향상된다는 것이 OpenAI의 관찰입니다. 핵심은 펀치카드에서 고급 언어로 전환했을 때 단순히 코딩이 빨라진 게 아니라, 이전에는 상상도 못 한 소프트웨어가 가능해졌다는 점입니다. AI가 반복 작업을 맡으면, 프로그래머는 더 높은 추상화 수준에서 "질적으로 다른" 문제를 생각할 수 있게 됩니다.
적응 속도에 대한 우려
Q: AI 발전에서 가장 우려되는 점은?
A: 기술 혁명마다 적응 속도가 빨라져 왔고, AI는 역대 가장 빠를 것입니다. 인류는 몇 세대에 걸쳐 대규모 일자리 변화를 흡수해왔지만, AI는 그 시간을 극적으로 단축시킵니다. 사람들이 적응해야 하는 것 자체가 무서운 게 아니라, 그 속도가 무서운 것입니다. 노동 시장이 얼마나 빠르게 변할지가 가장 큰 걱정거리입니다.
관련 배경지식
- 기술 혁명의 가속: 각 기술 혁명(농업→산업→정보)은 이전보다 빠르게 사회를 변화시켰습니다. 산업혁명은 수 세대에 걸쳐 일자리를 바꿨지만, AI 혁명은 그 변화를 훨씬 짧은 시간에 일으킬 수 있습니다. Altman의 핵심 우려는 인류가 적응력이 뛰어나고 대규모 일자리 변화를 흡수해왔지만, 그것은 "몇 세대"에 걸친 시간이 있었기 때문이라는 점입니다.
로보틱스
Q: OpenAI는 로봇 분야를 어떻게 보나요?
A: 초기에 로봇 프로젝트를 시작했지만 시뮬레이터 품질과 하드웨어 문제(힘줄이 끊어지는 등)로 중단했습니다. 먼저 지능과 인지 능력을 개발하고, 나중에 물리적 영역에 적용하는 게 더 효율적이었습니다. 현재 로봇 하드웨어가 발전하면서 다시 투자를 시작했고, 다리 기술이 발전한 회사들이 팔, 손, 손가락까지 완성하면 블루칼라 일자리에 상당한 변화가 올 수 있습니다.
관련 배경지식
- 기술 예측의 역전: 7~10년 전 AI 영향에 대한 컨센서스 예측은 AI가 블루칼라(육체노동) → 화이트칼라(사무직) → 창의적 작업 순서로 대체할 것이라는 것이었습니다. 창의성은 "인간만의 마법"이라 AI가 절대 못 할 것이라는 생각이 지배적이었습니다. 하지만 정반대로 진행되었고, ChatGPT는 글쓰기, 코딩, 예술 등 창의적 작업에서 먼저 성과를 냈으며, 로봇이 물리적 환경에서 일하는 것은 여전히 어렵습니다.
창의적 작업과 할루시네이션
Q: AI가 블루칼라보다 화이트칼라, 창의적 작업에서 먼저 성과를 낸 이유는?
A: 7~10년 전 컨센서스 예측은 블루칼라 → 화이트칼라 → 창의성(마지막, 어쩌면 불가능) 순서였지만, 정반대로 진행됐습니다. 창의적 작업에서는 GPT의 할루시네이션이 버그가 아니라 새로운 발견을 가능케 하는 기능입니다. 반면 로봇이 중장비를 옮길 때는 정밀성이 필수라 할루시네이션이 치명적입니다. 과학이 원하는 방향을 따라가야 한다는 교훈을 얻었습니다.
관련 배경지식
- Hallucination (할루시네이션): AI가 사실이 아닌 정보를 마치 사실인 것처럼 자신있게 생성하는 현상입니다. 모델이 학습 데이터에 없는 내용을 "창작"하거나, 있는 내용을 잘못 조합할 때 발생합니다. 예를 들어, "아인슈타인이 2015년에 발표한 논문"에 대해 물으면, AI가 아인슈타인은 1955년에 사망했음에도 그럴듯한 가짜 논문 내용을 생성합니다. 신뢰성이 중요한 작업에서는 치명적이지만, 창의적 작업에서는 오히려 새로운 아이디어를 발견하게 해주는 장점이 될 수 있다는 것이 Altman의 통찰입니다.
인간의 목적
Q: AI가 모든 일을 더 잘하게 되면 인간의 목적은 어떻게 되나요?
A: Altman은 이것이 AI 작업에서 심리적으로 가장 어려운 부분이라고 말합니다. Gates도 말라리아 퇴치 작업에서 큰 보람을 느끼는데, AI가 "빌, 피클볼이나 치세요. 말라리아는 제가 할게요. 당신은 생각이 느리잖아요"라고 하면 철학적으로 혼란스러울 것이라고 했습니다. 우리 마음은 희소성 중심으로 조직되어 있어서 희소성 없는 세상을 상상하기 어렵지만, 희소성 없이 자란 세대는 다른 답을 찾을 것입니다. Altman은 말라리아 해결 대신 "어떤 은하를 좋아하고 그걸로 뭘 할지" 결정하게 될 수도 있다고 말합니다.
관련 배경지식
- 희소성 기반 사회 vs 풍요 사회: 인류 역사 대부분은 자원(식량, 에너지, 전문가)의 희소성을 전제로 사회가 조직되었습니다. 현재는 의사가 부족해서 의료 접근성이 제한되지만, AI가 의사 수준의 진단을 무료로 제공하면 "의료 서비스 배분"이라는 문제 자체가 사라집니다. Gates는 자신의 사고방식이 희소성에 맞춰져 있어서, AI가 모든 것을 풍요롭게 만들면 사회를 어떻게 조직할지 상상하기 어렵다고 고백합니다.
- AGI (Artificial General Intelligence, 범용 인공지능): 특정 작업만 잘하는 현재 AI와 달리, 인간처럼 거의 모든 지적 작업을 수행할 수 있는 AI를 의미합니다. 현재 AI는 바둑, 번역, 코딩 등 개별 작업에서는 인간을 넘어섰지만, 새로운 상황에 유연하게 적응하는 능력은 부족합니다. AGI가 실현되면 Gates가 말한 "인간 목적의 문제"가 본격적으로 대두됩니다.
AI 비용
Q: AI 사용 비용은 어떻게 변하고 있나요?
A: GPT-3는 3년간 비용이 40배 감소했고, 이는 무어의 법칙보다 훨씬 빠른 속도입니다. 모델 효율화와 더 작은 모델에 더 많은 능력을 담는 연구 덕분입니다. Altman의 세계관은 "지능의 비용 + 에너지의 비용 = 삶의 질"로 요약되며, 특히 가난한 사람들에게 두 비용을 동시에 낮추면 엄청난 개선이 가능합니다. 현재 월 20달러로 GPT-4를 상당히 많이 사용할 수 있고, 그 가치는 20달러를 훨씬 넘습니다.
관련 배경지식
- Moore's Law (무어의 법칙): 반도체 집적회로의 트랜지스터 수가 약 2년마다 2배로 증가한다는 경험적 법칙입니다. 1965년 인텔 공동창업자 고든 무어가 제안했으며, 수십 년간 컴퓨터 성능 향상의 기준점이 되었습니다. 1970년대 방 하나를 채우던 컴퓨터의 성능을 지금은 스마트워치가 넘어서는 것이 무어의 법칙 덕분입니다. Altman은 AI 비용 감소 속도가 이보다 훨씬 빠르다고 강조합니다.
OpenAI의 독특한 접근
Q: OpenAI는 다른 스타트업과 어떻게 달랐나요?
A: 첫 제품 출시까지 4년 반이 걸렸고, 제품 아이디어 없이 회사를 시작했으며, 사용자와 대화하지 않았습니다. 이는 Y Combinator의 표준 조언과 완전히 반대였지만, Altman은 YC에서 규칙을 배웠기에 언제 어떻게 왜 깰 수 있는지 이해했다고 합니다. 벤처캐피털이 감당하기 어려운 수준의 연구 투자가 필요해서 Microsoft와의 파트너십이 결정적이었고, Gates는 Satya Nadella가 OpenAI를 대형 소프트웨어 회사와 결합하는 방법을 고안한 것에 큰 공을 돌렸습니다.
관련 배경지식
- Y Combinator (YC): 2005년 설립된 세계 최대 스타트업 액셀러레이터입니다. 초기 스타트업에 소액 투자와 3개월 집중 프로그램을 제공하며, Airbnb, Dropbox, Reddit, Stripe 등을 배출했습니다. Sam Altman은 2014-2019년 YC 대표를 역임했으며, YC의 핵심 조언은 "빨리 제품 출시하고, 사용자와 대화하라"입니다.
- 벤처캐피털의 한계: 전통적인 VC 투자 모델은 빠른 제품 출시와 시장 검증을 전제로 합니다. 보통 18-24개월 내에 다음 라운드를 위한 성과를 보여야 합니다. OpenAI처럼 4년 반 동안 제품 없이 순수 연구만 하는 것은 VC 모델과 맞지 않아서, 대기업(Microsoft)만이 "수익보다 훨씬 앞서는 투자"를 감당할 수 있었습니다.
- Satya Nadella: 2014년부터 Microsoft CEO를 맡고 있습니다. 클라우드 사업(Azure)에 집중하고 OpenAI에 수십억 달러를 투자하는 등 Microsoft를 AI 중심 회사로 전환시켰습니다.
OpenAI 조직
Q: OpenAI는 어떤 조직인가요?
A: 약 500명 규모로, Google, Microsoft, Apple 기준으로는 아주 작은 회사입니다. 하지만 연구소를 운영하면서 동시에 실제 사업과 두 개의 제품을 운영해야 합니다. 흥미롭게도 24살 프로그래머들의 회사가 아니라 30~50대가 많은, 평균보다 나이가 많은 조직입니다. Altman은 YC에서도 최고의 창업자들이 점점 나이가 많아지는 추세를 추적했는데, 이것이 사회에 좋은 신호인지는 확신하지 못한다고 했습니다.
관련 배경지식
- OpenAI: 2015년 설립된 AI 연구 기관으로, GPT 시리즈와 ChatGPT를 개발했습니다. 원래 비영리로 시작했으나 막대한 연산 비용 때문에 2019년 "capped-profit" 구조로 전환했습니다. Sam Altman이 CEO이며, Microsoft가 주요 투자자입니다. 인터뷰 직후인 2023년 11월, Altman이 이사회에 의해 해임되었다가 직원들의 강력한 반발로 5일 만에 복귀하는 사건이 있었습니다.
인재 유지
Q: 이렇게 경쟁이 치열한 시기에 어떻게 인재를 유지하나요?
A: 훌륭한 사람들은 훌륭한 동료와 일하고 싶어합니다. 거기에 강력한 구심력이 있습니다. 또한 모든 회사가 미션을 말하지만, OpenAI 사람들은 정말로 미션을 깊이 느낍니다. 모두가 AGI 탄생의 현장에 있고 싶어합니다.
조언
Q: Bill Gates와 Sam Altman이 자주 하는 조언은?
A: Gates는 다양한 재능의 조합이 중요하다고 강조합니다. 초기에는 순수 엔지니어링 IQ만 중요하다고 생각했지만 완전히 틀렸고, 올바른 기술 믹스를 가진 팀 구성이 핵심입니다. Altman은 사람들이 리스크를 잘못 계산한다고 말합니다. 안정적인 직장을 떠나 하고 싶은 일을 시도하지 않는 게 오히려 더 큰 리스크이며, 원하는 것을 명확히 하고 사람들에게 요청하는 것이 놀라울 정도로 효과가 있습니다.
Sam Altman의 음악 취향
Q: Sam Altman은 어떤 음악을 들으며 일하나요?
A: Max Richter가 재해석한 "The New Four Seasons - Vivaldi Recomposed"를 가져왔습니다. 가사 없는 음악을 선호하며, 익숙한 비발디의 편안함과 새로운 음이 주는 다른 경험을 좋아합니다. OpenAI를 시작할 때 많이 들었던 곡이라 강한 감정적 애착이 있고, 고양되고 낙관적인 느낌이 일할 때 완벽하다고 합니다. 헤드폰으로 조용히 듣고, 침묵 속에서 일하기는 어렵다고 했습니다.
관련 배경지식
- Max Richter의 "The New Four Seasons": 독일 태생 영국 작곡가 Max Richter가 비발디의 "사계"를 현대적으로 재해석한 2012년 앨범입니다. 원곡의 75%를 새로 작곡하면서도 비발디의 정수는 유지했습니다. Chineke! Orchestra가 연주했으며, Altman이 OpenAI 초기의 불확실하고 흥분되던 시기에 많이 들었다는 점에서 개인적 의미가 있습니다.
보너스: 인터뷰 말미
Q: Sam Altman의 첫 컴퓨터는 무엇이었나요?
A: Mac LC II였습니다. 아직도 가지고 있고 작동한다고 합니다.
관련 배경지식
- Mac LC II: 1992년 Apple이 출시한 교육/가정용 매킨토시 컴퓨터입니다. 당시 가격 대비 성능이 좋아 학교와 가정에서 인기가 있었습니다. Altman이 30년 넘은 컴퓨터를 아직 보관하고 있다는 점에서 기술에 대한 그의 애착을 엿볼 수 있습니다.
'Useful Links' 카테고리의 다른 글
| 수식 입력 (0) | 2021.10.27 |
|---|
안드로이드 빌드 2부: DEX와 Dalvik, 그리고 ART – .class는 어디로 사라졌을까? (2/7)
안드로이드 빌드 2부: DEX와 Dalvik, 그리고 ART – .class는 어디로 사라졌을까?
1부에서 우리는 Kotlin/Java 소스가 .class 바이트코드로 바뀌는 과정까지 따라가 봤습니다. 그런데 APK를 열어보면 .class는 하나도 없고, 정체불명의 classes.dex만 덩그러니 있습니다.
이 글에서는 이 질문에 답을 찾으면서, 안드로이드만의 독특한 실행 환경을 빌드 파이프라인 관점에서 정리합니다.
먼저 어떤 내용을 다룰지 흐름을 짚어보겠습니다. 이후 각 항목을 차례로 파고들 예정입니다.
- .class 파일이 어떻게 .dex로 합쳐지는지, 그리고 왜 굳이 그렇게 하는지
- 안드로이드가 왜 JVM 대신 Dalvik/ART라는 전용 런타임을 만들었는지
- JVM(스택 머신)과 Dalvik(레지스터 머신)의 차이를 바이트코드로 직접 비교
- 빌드 과정에서 D8/R8이 하는 일, 그리고 65K 메서드 제한과 MultiDex
- R8이 어떻게 APK 크기와 메서드 수를 줄여주는지
- DEX 파일 내부 구조를 간단히 들여다보기
1. APK를 열어보면 .class 대신 왜 classes.dex만 있을까?
Android Studio에서 아무 프로젝트나 열고, 평소처럼 빌드해 봅니다.
./gradlew assembleDebug
빌드가 끝났다면 app/build/ 아래에서 .class 파일을 찾을 수 있습니다.
find app/build -name "*.class" | head -5
intermediates/javac/... 경로에 MainActivity.class 같은 파일이 잔뜩 보일 것입니다. 여기까지가 JVM 세계의 익숙한 모습입니다.
이제 실제 APK를 열어보면 풍경이 확 바뀝니다.
cd app/build/outputs/apk/debug
unzip app-debug.apk -d extracted
ls extracted
대략 이런 파일들이 나옵니다.
AndroidManifest.xml
classes.dex
classes2.dex
res/
resources.arsc
META-INF/눈에 띄는 점은 두 가지입니다.
- .class 파일은 하나도 없고
- 대신 classes.dex, classes2.dex 같은 DEX 파일만 존재한다는 것
여기서 기본 질문이 생깁니다.
- 누가 .class를 .dex로 바꿨는가?
- 왜 JVM 바이트코드를 그대로 넣지 않았는가?
- .dex는 도대체 어떤 포맷인가?
나머지 섹션에서는 이 질문들을 하나씩 풀어가면서 안드로이드 빌드 파이프라인의 속을 들여다보겠습니다.
2. 2005년의 스마트폰으로 JVM을 돌리면 어떤 일이 벌어질까?
DEX 이야기를 이해하려면, 안드로이드 초창기 하드웨어 제약을 잠깐 떠올릴 필요가 있습니다.
오늘날 플래그십 폰은 8코어 CPU에 램이 8~12GB인 경우가 흔하지만, 첫 세대 안드로이드 폰이 설계되던 2005년 전후의 스펙은 훨씬 소박했습니다.
- RAM: 64MB 수준
- CPU: 400MHz 단일 코어 수준
- 저장공간: 수백 MB
이 정도 자원 위에서 일반적인 데스크톱 JVM을 그대로 올리면 어떤 일이 벌어질까요?
간단한 Java 프로그램으로 JVM의 메모리 사용량을 확인해 보면, 비교적 단순한 코드임에도 수십 MB를 금방 잡아먹습니다. 작은 모바일 기기 기준으로 보면, 운영체제와 다른 앱들까지 고려했을 때 여유 메모리가 거의 남지 않는 수준입니다.
이런 이유로 안드로이드는 다음과 같은 선택지 앞에 섰습니다.
- JVM을 그대로 쓰면서 모바일에서 최대한 버텨 보기
- 모바일에 맞춰 새 런타임과 바이트코드 포맷을 설계하기
구글의 선택은 후자였고, 그 결과물이 바로 Dalvik VM이었습니다. 이후에는 Dalvik과 호환되는 새로운 런타임 ART(Android Runtime)가 등장해 지금까지 이어지고 있습니다. 두 런타임 모두 DEX 바이트코드를 입력으로 받습니다.
정리하면, DEX는 다음 요구사항을 만족해야 했습니다.
- 메모리와 배터리가 적은 환경에서도 돌아갈 것
- 모바일에 맞게 명령어 수를 줄이고 효율적인 실행이 가능할 것
- 그래도 기존 Java/Kotlin 개발 경험을 최대한 재사용할 수 있을 것
이 설계 목표가 DEX 포맷과 Dalvik/ART의 형태를 결정합니다.
3. JVM vs Dalvik: 스택 머신과 레지스터 머신의 차이
DEX가 왜 필요한지 진짜 감이 오려면, JVM과 Dalvik이 코드를 어떻게 실행하는지 바이트코드 수준에서 직접 비교해 보는 것이 가장 빠릅니다.
간단한 예제로 시작해 보겠습니다.
// SimpleCalc.kt
fun add(a: Int, b: Int): Int {
return a + b
}
3-1. JVM 바이트코드: 스택에 push/pop하는 방식
먼저 Kotlin 코드를 .class로 컴파일한 뒤, javap로 바이트코드를 확인합니다.
kotlinc SimpleCalc.kt
javap -c SimpleCalcKt.class
핵심 부분은 대략 다음과 같습니다.
public static final int add(int, int);
Code:
0: iload_0 // a를 스택에 push
1: iload_1 // b를 스택에 push
2: iadd // 스택 top 두 값을 pop 후 더하기
3: ireturn // 결과를 반환JVM은 스택 머신입니다. 연산은 스택에 값을 쌓고(pop/push) 꺼내는 방식으로 이루어집니다.
- iload_0: 지역 변수 a를 스택에 올리기
- iload_1: 지역 변수 b를 스택에 올리기
- iadd: 스택에서 두 값을 꺼내 더하고, 결과를 다시 스택에 올리기
- ireturn: 스택 top의 값을 반환
각 단계에서 스택 상태는 이런 식으로 바뀝니다.
- 시작: []
- iload_0 후: [a]
- iload_1 후: [a, b]
- iadd 후: [a + b]
- ireturn 후: [] (반환값: a + b)
모든 계산이 "스택 위에서" 일어납니다.
3-2. DEX 바이트코드: 레지스터를 직접 다루는 방식
이제 같은 .class를 DEX로 변환한 뒤, dexdump로 코드를 확인해 봅니다.
d8 SimpleCalcKt.class
dexdump -d classes.dex | grep -A 10 "add"
필요한 부분만 추리면 다음과 같은 형식이 됩니다.
Virtual methods:
#0: add
registers: 3
ins: 2
code:
0000: add-int v0, v1, v2 // v0 = v1 + v2
0002: return v0여기서 중요한 포인트는 다음과 같습니다.
- Dalvik/DEX는 레지스터 기반이다.
- v0, v1, v2 같은 레지스터에 값이 저장된다.
- add-int v0, v1, v2는 "v1과 v2를 더해 v0에 저장하라"라는 뜻이다.
레지스터 상태를 정리하면 다음과 같습니다.
- 함수 진입 시:
- v1 = a, v2 = b (파라미터)
- add-int v0, v1, v2 실행 후:
- v0 = a + b
- return v0 실행 후:
- 반환값: a + b
다시 한 번 비교하면 이렇게 요약할 수 있습니다.
- JVM: 스택에 계속 push/pop 하면서 연산
- Dalvik/DEX: 고정된 레지스터를 직접 읽고 쓰면서 연산
3-3. 왜 이 차이가 중요한가?
이 차이는 단순한 구현 방식 차이가 아니라, 빌드 결과물과 실행 성능 모두에 영향을 줍니다. 여러 자료를 종합하면 다음과 같은 경향이 있습니다.
- 레지스터 기반 바이트코드는 같은 일을 더 적은 명령어로 표현할 수 있다.
- 스택 push/pop이 줄어들어 메모리 접근이 줄고, 곧 전력 소비에도 영향을 준다.
- 명령어 수가 줄고 중복이 제거되면 .dex 파일 크기도 줄어든다.
모바일에서 "조금이라도" 절약하는 것이 중요했기 때문에, 이런 설계 선택은 충분히 납득이 됩니다.
4. 빌드 파이프라인에서 D8과 R8이 하는 일
지금까지는 "왜 DEX인가?"에 집중했다면, 이제는 "누가 언제 DEX를 만드는가?"를 빌드 파이프라인 입장에서 보겠습니다.
안드로이드 앱의 큰 흐름은 다음과 같은 형태입니다.
Kotlin/Java 소스(.kt/.java)
↓ Kotlin/Java 컴파일러
.class (JVM 바이트코드)
↓ D8 (dexer)
.dex (Dalvik/ART 바이트코드)
↓ 패키징 / 서명
.apk 또는 .aab실제 Gradle 로그를 보면 이 흐름이 그대로 드러납니다.
> Task :app:compileDebugKotlin
→ Kotlin/Java 컴파일러가 .class 생성
> Task :app:dexBuilderDebug
→ D8이 .class 묶음을 .dex 조각으로 변환
> Task :app:mergeDexDebug
→ 여러 .dex 아카이브를 최종 .dex 세트로 병합최근 Android Gradle Plugin(AGP)에서는 다음과 같은 역할 분담을 하고 있습니다.
- D8: .class를 .dex로 변환하는 공식 dexer
- R8: shrink(불필요 코드 제거), optimize(최적화), obfuscate(난독화), 그리고 dexing까지 통합한 도구
즉, "빌드의 중후반부에서 .class 세계를 모두 털어서 DEX 세계로 옮겨주는 녀석이 D8/R8"이라고 정리할 수 있습니다.
5. 실무에서 마주치는 65K 메서드 제한과 MultiDex
DEX 포맷에는 중요한 제약이 하나 있습니다.
한 개의 .dex 파일이 참조할 수 있는 메서드 수가 65,536개(약 65K)를 넘을 수 없다는 점입니다. 이유는 내부적으로 메서드 참조를 16비트 인덱스로 표현하기 때문입니다.
현실적인 예를 들어 보면 다음과 같습니다.
- 앱 자체 코드: 수천 개 메서드
- AndroidX + Material: 수천~수만 개 메서드
- Firebase, Play Services, Retrofit, OkHttp, RxJava, Dagger 등을 이것저것 추가
이런 식으로 라이브러리를 계속 붙이다 보면, 어느 날 빌드 중에 다음과 같은 에러를 마주하게 됩니다.
Error: com.android.builder.dexing.DexArchiveMergerException:
The number of method references in a .dex file cannot exceed 64K.
See https://developer.android.com/tools/building/multidex.html이 상황에서 보통 두 가지 해결책이 등장합니다.
5-1. MultiDex 활성화
가장 직접적인 해결책은 한 APK 안에 여러 개의 DEX 파일을 두는 것입니다.
Gradle 설정은 대략 다음과 같습니다.
android {
defaultConfig {
multiDexEnabled true
}
}
dependencies {
implementation "androidx.multidex:multidex:2.0.1"
}
이렇게 하면 빌드 결과물에 classes.dex, classes2.dex처럼 여러 DEX가 포함됩니다. 런타임에서는 부트스트랩 과정에서 추가 DEX를 로드하게 되는데, 이 덕분에 65K를 넘는 메서드 참조도 처리할 수 있습니다.
다만 단점도 분명합니다.
- 앱 시작 단계에서 추가 DEX를 로드해야 해서 Start-up 시간이 늘어날 수 있다.
- 멀티덱스 로딩 관련 이슈에 민감해질 수 있다.
그래서 요즘은 가능한 MultiDex에 의존하기보다는, 애초에 메서드 수를 줄이는 방향이 선호됩니다.
5-2. R8으로 코드와 리소스를 줄이기
여기서 등장하는 것이 R8입니다. R8은 다음 작업을 한 번에 처리합니다.
- 사용되지 않는 클래스·메서드를 제거(shrinking)
- 간단한 최적화를 수행(인라이닝, dead code 제거 등)
- 심볼 이름을 짧게 바꾸는 난독화(obfuscation)
- 최종적으로 .dex를 생성
일반적으로 minifyEnabled true를 켜고 어느 정도 적절한 keep rule만 구성해도, 메서드 수가 확 줄어드는 것을 볼 수 있습니다.
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
실제 프로젝트에서 적용해 보면, 라이브러리를 많이 쓰는 앱 기준으로 메서드 수 30~60% 감소 정도는 흔하게 나옵니다. 이렇게 줄어든 메서드 수 덕분에 MultiDex 없이도 65K 제한 아래로 내려오는 경우가 많습니다.
6. R8이 APK에 남기는 흔적들: mapping.txt와 usage.txt
R8은 빌드 중 조용히 많은 일을 하지만, 결과물을 몇 가지 파일로 남겨줍니다.
특히 유용한 파일은 두 개입니다.
- mapping.txt
- usage.txt
mapping.txt는 난독화 이전과 이후의 이름 매핑을 기록합니다.
com.example.myapp.MainActivity -> com.example.myapp.a:
void onCreate(android.os.Bundle) -> a
com.example.myapp.data.UserRepository -> b:
com.example.myapp.data.User getUser(int) -> a이 정보는 다음 상황에서 중요합니다.
- 크래시 리포트를 디코딩할 때 (a.b.c 형태의 난독화된 스택 트레이스를 다시 원래 코드로 복원)
- 성능 분석, 로그 분석 등에서 클래스/메서드 이름을 추적할 때
usage.txt는 "어떤 클래스와 메서드가 제거되었는지"를 기록합니다.
com.example.unused.Helper
com.example.unused.Helper: void neverCalled()
androidx.appcompat.widget.TooltipPopup: int[] ATTRS
...이 파일을 보면 "실제로 아무도 안 쓰던 코드가 얼마나 많았는지"를 숫자로 체감할 수 있습니다.
요약하면 R8은 단순히 난독화를 위한 도구가 아니라, "DEX로 변환하기 전에 앱을 정리하고 다이어트시키는 빌드 단계"라고 이해하는 것이 좋습니다.
7. DEX 파일 내부 구조를 간단히 들여다보기
마지막으로 DEX 파일 안쪽을 아주 살짝만 들여다보겠습니다. 안드로이드 공식 문서를 보면 DEX 포맷은 대략 다음과 같은 섹션들로 구성됩니다.
- Header: 파일 전체 크기, 체크섬, 각 섹션 오프셋 등
- String IDs: 전체 문자열 풀(메서드 이름, 타입 이름, 시그니처 등)
- Type IDs: 클래스와 타입 목록
- Proto IDs: 메서드 시그니처(파라미터 타입, 반환 타입 등)
- Field IDs, Method IDs: 필드/메서드 참조 테이블
- Class Defs: 실제 클래스 정의와 코드, 애트리뷰트 등
간단한 헥스 덤프로 시작해볼 수 있습니다.
hexdump -C classes.dex | head -5
처음 몇 바이트는 다음과 같은 패턴을 보입니다.
00000000 64 65 78 0a 30 33 35 00 ... |dex.035.|앞부분의 "dex\n035\0"가 바로 DEX 파일의 매직 문자열과 버전입니다.
조금 더 구조적 정보를 보려면 dexdump -f를 사용합니다.
dexdump -f classes.dex | head -40
대략 이런 형태의 출력이 나옵니다.
Opened 'classes.dex', DEX version '035'
DEX file header:
magic : 'dex\n035\0'
file_size : 123456
header_size : 112
string_ids_size : 145
type_ids_size : 67
method_ids_size : 234
class_defs_size : 12
...이 정보만 봐도 다음을 알 수 있습니다.
- 이 DEX에 문자열이 몇 개 들어 있는지
- 타입과 메서드가 대략 몇 개인지
- 한 파일 안에 클래스가 얼마나 들어 있는지
중요한 점은, 문자열과 타입, 메서드들이 전역 풀(global pool)로 묶여 있다는 것입니다.
- 같은 문자열이 여러 클래스에서 반복해 쓰여도, 실제로는 문자열 풀에 한 번만 저장된다.
- 메서드 참조도 "풀에 있는 인덱스"로 표현되기 때문에, 코드에서의 표현이 더 compact해진다.
이 구조 덕분에 DEX는 공통된 정보를 하나의 테이블로 모아 중복을 줄이고, 이 테이블을 기준으로 바이트코드를 구성합니다. 이것이 빌드 결과물 크기에 꽤 큰 영향을 줍니다.
8. 정리: 안드로이드 빌드 파이프라인에서 DEX가 맡은 역할
이 글을 통해 우리가 따라가 본 내용을 간단히 정리해 보겠습니다. 먼저 문장으로 큰 줄기를 다시 잡고, 마지막에 한 번 더 요약 형태로 묶어보겠습니다.
- 안드로이드는 자원이 매우 제한된 초창기 스마트폰 환경을 고려해, JVM 대신 Dalvik이라는 자체 런타임과 DEX 포맷을 설계했다.
- JVM은 스택 기반, Dalvik/DEX는 레지스터 기반이기 때문에 같은 코드를 더 적은 명령어로 표현하고 효율적으로 실행할 수 있다.
- 빌드 파이프라인에서 Kotlin/Java 컴파일러는 .class를 만들고, D8/R8이 이를 .dex로 변환하면서 중복 제거와 최적화를 수행한다.
- DEX 포맷의 65K 메서드 제한 때문에 MultiDex 또는 R8 기반의 코드 다이어트 전략이 중요하다.
- DEX 파일 내부에는 전역 문자열/타입/메서드 풀과 헤더가 있고, 이 구조가 크기와 실행 효율을 함께 좌우한다.
이 내용을 기반으로 중요한 포인트만 다시 한 번 요약하면 다음과 같습니다.
- DEX는 "모바일에 맞게 설계된 레지스터 기반 바이트코드 포맷"이다.
- Dalvik과 ART는 둘 다 DEX를 입력으로 사용하는 안드로이드 전용 런타임이다.
- D8/R8은 .class → .dex 변환과 최적화·난독화까지 담당하는 빌드 파이프라인의 핵심 단계이다.
- 65K 메서드 제한은 라이브러리가 많아질수록 현실적인 문제로 드러나며, R8과 설계 선택으로 완화할 수 있다.
- DEX 내부 구조를 대략 이해해 두면, 빌드/최적화 문제를 디버깅할 때 훨씬 수월해진다.
다음 글(3부)에서는 "리소스의 여정"으로 넘어가, XML 레이아웃과 이미지, 문자열들이 AAPT2를 거치면서 어떤 바이너리 포맷으로 바뀌는지 살펴볼 예정입니다. 빌드 파이프라인의 나머지 절반이 궁금하다면, 리소스 변환의 세계가 꽤 재미있게 느껴질 것입니다.
더 공부해 보고 싶다면, 아래 문서들을 함께 읽어보면 좋습니다.
- 안드로이드 공식 빌드 개요: https://developer.android.com/studio/build
- DEX 포맷 공식 문서: https://source.android.com/docs/core/runtime/dex-format
- Android Runtime(ART) 개요: https://en.wikipedia.org/wiki/Android_Runtime
- Android CPU, D8, R8 설명 글: https://proandroiddev.com/android-cpu-compilers-d8-r8-a3aa2bfbc109
'Android' 카테고리의 다른 글
| 안드로이드 빌드 1부: 소스코드에서 바이트코드까지 - 컴파일의 세계 (1/7) (0) | 2025.11.15 |
|---|---|
| [Kotlin] Continuation Interceptor (0) | 2025.05.11 |
| [책] 코틀린 코루틴 - Mutex vs Single thread dispatcher (0) | 2024.05.31 |
| 6. Kotlin Coroutines - Exceptions (2) | 2024.05.23 |
| 5. Kotlin Coroutine - Cancellation (0) | 2024.05.22 |
안드로이드 빌드 1부: 소스코드에서 바이트코드까지 - 컴파일의 세계 (1/7)
1부: 소스코드에서 바이트코드까지 – 컴파일의 세계
7일에 마스터하는 안드로이드 빌드 과정 시리즈
1부는 Kotlin 소스코드가 JVM 바이트코드가 되기까지의 여정을 따라가며, 그 안에서 Gradle 빌드 시스템이 어디까지 개입하는지까지 함께 살펴봅니다.
도입부: Run 버튼을 누르면 실제로 무슨 일이 일어날까
Android Studio에서 초록색 삼각형 버튼을 하루에 몇 번이나 누르고 있을지 떠올려 보면, 사실 우리는 매일 "빌드"를 하고 있습니다.
그런데 정작 이런 질문은 잘 하지 않죠.
- Kotlin으로 작성한 코드가 폰에서 돌기까지, 정확히 어떤 단계들을 거칠까
- Gradle 이라는 빌드 시스템은 그 사이에서 어떤 역할을 할까
- 우리가 수정한 코드 한 줄이 빌드 시간과 APK 크기에 어떤 영향을 줄까
이 시리즈는 바로 이 질문들에 답하기 위한 여정입니다.
1부에서는 컴파일(Compilation) 단계에 집중합니다. 즉, 이 구간입니다.
Kotlin(.kt) / Java(.java)
↓
JVM 바이트코드(.class)
↓
DEX(.dex) → APK / AAB2부에서 .class → .dex(D8/R8)와 ART 런타임을 다룰 것이고, 그 이후에는 리소스, 패키징, 서명, AAB, Play Store까지 따라갈 예정입니다.
안드로이드 빌드 파이프라인 한 번에 조감도 보기
먼저 오늘 이야기의 좌표부터 찍고 갑니다. 전체 빌드 파이프라인을 아주 거칠게 펼치면 이런 그림이 됩니다.
- Gradle이 프로젝트와 모듈 구조를 읽고 빌드를 준비한다.
- Kotlin/Java 소스코드를 컴파일해 .class 파일을 만든다.
- .class 파일들을 D8/R8이 모아서 .dex로 변환한다.
- 리소스(AAPT2), 매니페스트 머지, 서명, 정렬(zipalign) 등을 거쳐 APK/AAB를 만든다.
- assembleDebug, assembleRelease 같은 Gradle Task들이 이 전체 파이프라인을 실행한다.
일반적인 "Android 빌드 과정 정리" 블로그 글에서 거의 항상 등장하는 내용은 다음 다섯 가지입니다.
- Gradle 빌드 라이프사이클(Initialization / Configuration / Execution)과 Task 그래프
- assembleDebug, assembleRelease, build, clean, check 같은 앵커 Task 구조와 역할
- Debug / Release 빌드 타입, Product Flavor, Variant 개념, 그리고 Gradle 명령행 빌드(./gradlew assembleDebug)
- Kotlin/Java 컴파일러가 생성한 .class 가 D8/R8 으로 .dex 로 묶이고, 그 과정에서 코드 축소·난독화·리소스 축소가 수행되는 흐름
- Gradle의 캐싱·증분 빌드·증분 어노테이션 프로세싱이 빌드 시간에 미치는 영향
이 1부는 그 중 "2번 – 소스코드에서 .class 파일까지"에 초점을 두고, 나머지 네 가지는 맥락 설명과 예고 정도로만 살짝 묻혀 있습니다.
1. 컴파일러, 결국 무엇을 하는 친구인가
컴파일러를 한 줄로 요약하면 다음과 같습니다.
"사람이 읽기 좋은 언어를, 기계가 실행 가능한 언어로 번역하는 프로그램"
프로그래밍 언어를 실행하는 방식에는 크게 두 가지가 있습니다.
먼저 개념을 문장으로 정리한 뒤, 핵심 차이만 요약하면 이렇습니다.
컴파일러 기반 언어
모든 코드를 한 번에 분석해 다른 형태(보통 바이트코드나 기계어)로 바꾼 뒤, 그 결과물을 실행합니다. Kotlin, Java, C/C++ 등이 여기에 속합니다.
인터프리터 기반 언어
코드를 한 줄씩 읽어서 즉시 실행합니다. Python, JavaScript 같은 언어가 대표적입니다.
요약하면:
컴파일 언어
- 미리 번역해 둔 결과물을 실행하므로 실행 속도가 빠른 편이다.
- "실행 전"에 타입 검사, 문법 검사 등을 강하게 걸 수 있다.
인터프리터 언어
- 실행이 자유롭고 상호작용적이지만, 실행 중에 에러가 늦게 드러나는 경우가 많다.
모바일 디바이스는 CPU·메모리가 상대적으로 제한적이기 때문에, 앱을 실행할 때마다 코드를 해석하는 방식보다는 미리 번역해 둔 결과를 빠르게 실행하는 방식이 훨씬 유리합니다. 그래서 안드로이드 빌드 파이프라인은 기본적으로 컴파일러 중심 구조를 선택합니다.
여기에 Kotlin이 제공하는 정적 타입 시스템이 결합되죠. 예를 들어:
val name: String = "가희"
val age: Int = 123 // 정상
val name2: String = 123 // 컴파일 오류
앱을 실행해 보기도 전에 IDE가 빨간 줄을 긋고 "이건 말이 안 된다"고 알려줍니다.
정적 타입 시스템 덕분에:
- IDE가 더 정확한 자동완성을 제공해 주고,
- 타입 관련 버그 상당수를 "런타임"이 아니라 "빌드 시점"에 막을 수 있으며,
- 코드 자체가 일종의 문서처럼 동작합니다.
2. Kotlin 컴파일러의 큰 그림
Kotlin 컴파일러가 하는 일은 대략 네 단계로 요약할 수 있습니다.
- 문자열로 되어 있는 소스코드를 토큰 단위로 쪼갠다. (어휘 분석, 파싱의 준비 단계)
- 토큰들을 트리 구조(AST)로 묶어, 코드의 문법 구조를 이해한다.
- Kotlin 특유의 편의 기능들을 "플랫폼 독립적 IR(Intermediate Representation)"로 펼친다.
- IR을 기반으로 JVM 바이트코드(.class)를 생성한다.
한 줄로 다시 정리하면:
소스 문자열
→ 토큰
→ 추상 구문 트리(AST)
→ 의미 분석·타입 체크
→ 중간 표현(IR)
→ 바이트코드(.class)이제부터 각 단계를 차례대로 파헤쳐 봅니다.
3. Step 1 – 파싱: 문자열을 의미 있는 단위로 쪼개기
.kt 파일은 컴파일러 입장에서 보면 그저 긴 문자열입니다.
사람이 자연어 문장을 "주어/목적어/동사" 같은 구조로 나누듯이, 컴파일러도 이 문자열을 토큰(Token) 단위로 나눕니다.
예를 들어:
class MainActivity : AppCompatActivity() {
}
이 코드를 토큰 단위로 해석하면 다음과 같은 식입니다.
- class → 키워드
- MainActivity → 식별자
- : → 콜론 기호
- AppCompatActivity → 식별자
- (, ) → 괄호
- {, } → 블록 구분
파싱 단계에서 주로 일어나는 일은 다음과 같습니다.
- 키워드, 식별자, 숫자, 문자열, 연산자, 괄호 등을 구분한다.
- 코드가 "문법적으로" 말이 되는지 확인한다.
- 괄호 짝이 맞는지, 문자열이 제대로 닫혔는지 등의 기초적인 오류를 잡아낸다.
예를 들어 아래 코드는 파싱 단계에서 바로 걸립니다.
fun broken() {
println("Hello"
}
여기서는 "닫히지 않은 괄호/문자열" 같은 문법 오류만을 다루고, "의미"는 아직 따지지 않습니다.
즉, val x = y + z 같은 코드는 파싱 단계에서는 통과하지만, y와 z가 실제로 선언되어 있는지는 이후 단계에서 검증됩니다.
Kotlin 컴파일러는 내부적으로 KotlinLexer를 사용해 소스 파일을 스캔하고 KtToken이라는 토큰으로 분해합니다. 예를 들어 괄호 토큰은 다음과 같이 정의되어 있습니다:
KtSingleValueToken LPAR = new KtSingleValueToken("LPAR", "(");
KtSingleValueToken RPAR = new KtSingleValueToken("RPAR", ")");
Step 2 – AST: 코드 구조를 트리로 표현하기
파싱이 끝나면, 컴파일러는 토큰들을 이용해 추상 구문 트리(Abstract Syntax Tree, AST) 를 만듭니다.
자연어로 치면 "문장 성분 분석"과 비슷합니다.
예를 들어:
val message = "Hello"
이 한 줄을 트리 형태로 보이면:
- 변수 선언(VariableDeclaration)
- 키워드: val
- 이름: message
- 타입: String (추론)
- 초기값: 문자열 리터럴 "Hello"
조금 더 복잡한 예제를 들면:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val message = "Hello, Android!"
println(message)
}
}
이 코드는 "클래스 선언 → 메서드 선언 → 문장들" 형태의 트리 구조로 표현될 수 있습니다.
중요한 점은, 연산자 우선순위, 블록 범위, 제어 흐름 같은 것들이 이 트리 구조 안에 자연스럽게 녹아 있다는 것입니다.
Kotlin 컴파일러는 JetBrains의 PSI(Program Structure Interface)라는 추상화를 사용합니다. PSI는 IDE와 컴파일러가 공유하는 형태로, 코드의 구조를 메모리 내 트리로 표현합니다. 이 덕분에 컴파일러 플러그인과 IDE 플러그인이 같은 구조를 재사용할 수 있습니다.
의미 분석 (Semantic Analysis)
AST가 만들어진 뒤에는 의미 분석 단계가 이어집니다. 여기서는:
- 사용된 변수들이 실제로 선언되어 있는지,
- 타입이 맞는지,
- override가 유효한지,
- 접근 제어자가 올바른지
등을 검사합니다.
예를 들어:
println(message)
val message = "Hello"
이 코드는 "message가 선언되기 전에 사용되었다"는 이유로 이 단계에서 에러가 됩니다.
또한 다음과 같은 코드도 마찬가지입니다.
val age: Int = "25" // 타입 불일치
val text = "Hello"
text.nonExistentMethod() // 존재하지 않는 메서드
이 단계가 끝나면, 컴파일러는 "이 코드는 문법적으로도 말이 되고, 타입도 맞고, 스코프도 합리적이다"라는 확신을 갖게 됩니다.
Kotlin 컴파일러는 이 과정에서 BindingContext라는 구조체를 사용합니다. 이는 추론된 타입, null 가능성, 참조 정보, 호출 정보 등 모든 의미론적 데이터를 담는 거대한 해시맵입니다.
Step 3 – IR: Kotlin의 편의 기능을 풀어서 기본 형태로 만들기
여기서부터가 Kotlin 컴파일러의 진짜 재미있는 부분입니다.
Kotlin은 개발자가 편하게 쓰라고 많은 특징을 제공합니다.
- data class
- val/var 프로퍼티와 자동 getter/setter
- by lazy
- sealed class
- 확장 함수(Extension Function)
- 스마트 캐스트(smart cast)
문제는 JVM은 이런 "고수준 기능"을 모른다는 것입니다. JVM이 아는 것은 결국 "클래스, 필드, 메서드, 바이트코드 명령어"뿐입니다.
그래서 Kotlin 컴파일러는 중간에 IR(Intermediate Representation) 이라는 단계를 둡니다.
이 단계에서 하는 핵심 작업은 다음과 같습니다.
- Kotlin 특유의 문법 설탕(syntactic sugar)을 "플랫폼 독립적인 중간 표현"으로 풀어낸다.
- 이 IR을 기반으로 JVM, JS, Native 각각에 맞는 코드로 변환한다.
Kotlin 1.4부터 도입된 새로운 IR(Intermediate Representation)은 모든 백엔드(JVM, JS, Native)에서 공통으로 사용되는 플랫폼 독립적 표현입니다. 이 덕분에 언어별 작업과 플랫폼별 작업을 분리할 수 있고, 공통 컴파일러 플러그인 API를 제공할 수 있게 되었습니다.
5-1. data class 가 실제로 되는 일
예를 들어 다음 코드를 보겠습니다.
data class User(val name: String, val age: Int)
우리 눈에는 한 줄로 보이지만, 컴파일러 입장에서는 수십 줄짜리 클래스로 확장됩니다.
- equals()
- hashCode()
- toString()
- copy()
- componentN()
이렇게 생성되는 메서드들의 역할을 잠깐만 설명하고 넘어가면:
- equals / hashCode
- 컬렉션에서 중복 여부, Map의 key 동작 등에 필수.
- toString
- 디버깅, 로그 찍기에 유용.
- copy
- 불변 객체를 일부만 바꾼 새 객체로 만들 때 필요.
- componentN
- 구조 분해(val (name, age) = user) 문법을 지원.
즉, data class 한 줄이 사실상 "잘 짜인 값 객체(Value Object)" 전체를 만들어 주는 셈입니다.
5-2. 프로퍼티와 getter/setter
Kotlin에서:
class Person {
var name: String = ""
val age: Int = 20
}
은 내부적으로 다음과 같은 구조로 풀립니다.
- 실제 값을 저장하는 private 필드
- 그 필드에 접근하는 getter / setter 메서드
Java 측에서 보면 getter/setter 형태로 호출 가능합니다.
덕분에:
- 캡슐화가 유지되고,
- 나중에 로직을 끼워 넣기 쉽고,
- Java와의 상호운용성이 좋습니다.
커스텀 getter/setter를 선언하면 IR에서 그 부분만 메서드 내용으로 들어갑니다.
5-3. by lazy 의 내부
by lazy 는 "첫 접근 시점까지 초기화를 미루고, 이후에는 캐싱된 값을 반환하는" 패턴을 캡슐화한 문법입니다.
val heavyObject: String by lazy {
// 무거운 초기화 코드
}
실제로는:
- backing field에 초기값 null을 두고,
- 동기화 락을 사용한 double-checked locking 구조로,
- 한 번만 람다를 실행한 뒤 값을 저장하고 재사용합니다.
IR 단계에서는 이 패턴이 명시적인 if, synchronized, 필드 접근 코드로 펼쳐집니다.
5-4. sealed class 와 when 의 exhaustiveness 체크
sealed class 는 "이 계층 구조 안에서만 하위 타입이 존재한다"는 것을 컴파일러에게 알려줍니다.
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}
JVM 관점에서는 abstract class Result + private 생성자 + 같은 파일 안에 존재하는 하위 클래스들로 풀립니다.
이 덕분에 when 식에서 다음과 같은 일이 가능합니다.
fun handle(result: Result) = when (result) {
is Result.Success -> ...
is Result.Error -> ...
Result.Loading -> ...
// else 없이도 컴파일 ok
}
컴파일러는 "Result를 상속하는 애는 이 세 가지뿐"이라는 사실을 알고 있으므로, 모든 경우를 처리했는지 검사할 수 있습니다.
5-5. 확장 함수의 진짜 정체
확장 함수는 문법상으로는 "기존 클래스에 메서드가 추가된 것처럼" 보이지만, 실제로는 static 함수입니다.
fun String.addExclamation(): String = this + "!"
IR 단계에서는 대략 다음과 같은 모양으로 나타납니다.
- 파일 수준의 static 함수 addExclamation(receiver: String): String
- "Hello".addExclamation() 은 addExclamation("Hello")로 바뀜
그래서 Java 코드에서는 StringExtensionsKt.addExclamation("Hello") 처럼 호출해야 합니다.
5-6. 스마트 캐스트
스마트 캐스트는 타입 체크 결과를 IR에 반영하는 최적화입니다.
fun getLength(obj: Any): Int {
if (obj is String) {
return obj.length // 별도의 캐스팅 없이 바로 사용
}
return 0
}
중간 표현에서는 "if 블록 안에서 obj의 타입을 String으로 바꿔서 취급해도 된다"고 마킹해 두고, 이후 바이트코드 생성 시에는 실제로 안전한 캐스팅 코드를 삽입합니다.
Step 4 – JVM 바이트코드: .class 파일 안에서 벌어지는 일
IR까지 왔다면, 플랫폼별로 코드를 생성할 준비가 된 것입니다.
우리가 Android에서 사용하는 경로는:
Kotlin/Java 코드 → JVM 바이트코드(.class) → DEX(.dex) → APK / AAB지금 보는 단계는 그 중 .class 까지입니다.
6-1. JVM 바이트코드는 스택 머신
JVM은 스택 기반 가상 머신입니다. 즉, 대부분의 연산을 "스택에 값을 올리고(pop/push) 연산하는" 방식으로 수행합니다.
예를 들어:
fun add(a: Int, b: Int): Int {
val sum = a + b
return sum
}
이 함수는 대략 다음과 같은 바이트코드로 바뀝니다.
iload_1 // a
iload_2 // b
iadd // a + b
istore_3 // sum
iload_3
ireturn명령어들의 역할을 문장으로 요약하면:
- iload_n / istore_n : n번 로컬 변수에서 값을 스택에 올리거나 저장한다.
- iadd : 스택 위의 두 int 값을 꺼내 덧셈 결과를 다시 스택에 올린다.
- ireturn : 스택 위의 값을 반환값으로 되돌린다.
JVM 바이트코드의 이런 구조 때문에, Kotlin의 연산자 우선순위나 제어 흐름 구조가 비교적 자연스럽게 맵핑됩니다.
6-2. 상수 풀(Constant Pool)
.class 파일 안에는 "상수 풀(Constant Pool)"이라는 테이블이 들어 있습니다.
- 문자열 리터럴
- 클래스 이름, 메서드 시그니처
- 정수/실수 상수
등이 여기에 저장되고, 바이트코드에서는 인덱스로 참조합니다.
예를 들면 ldc #2 는 "상수 풀의 2번 항목을 스택에 올려라"라는 뜻입니다.
상수 풀 덕분에:
- 같은 문자열을 여러 번 쓸 때도 파일 안에는 한 번만 저장할 수 있고,
- 클래스/메서드 참조를 효율적으로 관리할 수 있습니다.
6-3. 실제로 직접 들여다보기
직접 확인해보고 싶다면, 프로젝트 루트에서 다음을 실행해 볼 수 있습니다.
# Kotlin 파일만 따로 컴파일
kotlinc SimpleTest.kt -include-runtime -d SimpleTest.jar
# class 파일 디스어셈블
javap -c -v SomeClass.class
Android Studio에서도 Tools → Kotlin → Show Kotlin Bytecode → Decompile 메뉴를 통해 "Kotlin → (추정된) Java 코드" 변환 결과를 확인할 수 있습니다. 이를 통해:
- 스마트 캐스트가 어떻게 if/else와 캐스팅으로 풀리는지,
- data class 가 실제로 어떤 메서드를 갖고 있는지,
- Elvis 연산자가 어떤 구조로 변환되는지
등을 눈으로 확인할 수 있습니다.
- 어노테이션 프로세싱 – 컴파일 타임에 코드가 생기는 이유
안드로이드 개발을 하다 보면 거의 반드시 만나게 되는 어노테이션들이 있습니다.
- @Entity, @Dao (Room)
- @Parcelize (Kotlin Parcelize)
- @Inject, @Module, @Component (Dagger/Hilt)
표면적으로는 "메타데이터처럼 보이는 어노테이션"인데, 실제로는 컴파일 중에 새로운 소스코드 / 바이트코드를 생성하는 트리거 역할을 합니다.
7-1. 동작 원리 한 줄 요약
의미를 먼저 문장으로 설명하면:
- 컴파일러가 소스코드를 훑으면서 특정 어노테이션이 붙은 선언을 찾고,
- 해당 어노테이션 프로세서를 호출해 "추가 코드"를 생성하게 한 뒤,
- 그 새로 생성된 코드까지 함께 컴파일합니다.
요약하면:
- 우리가 작성한 인터페이스/데이터 클래스는 "스펙 정의" 역할만 하고,
- 진짜 구현체는 어노테이션 프로세서가 대신 만들어 줍니다.
이 구조 덕분에:
- Room DAO의 구현 클래스,
- Dagger의 의존성 그래프 구성 코드,
- Parcelize 된 클래스의 writeToParcel/CREATOR 구현,
같은 반복적인 보일러플레이트를 직접 작성할 필요가 없습니다.
7-2. kapt vs KSP
Kotlin 세계에는 두 가지 어노테이션 프로세싱 방식이 공존합니다.
kapt (Kotlin Annotation Processing Tool)
- Kotlin → Java Stub → Java Annotation Processor → 생성 코드
- 중간에 "Java Stub 생성" 단계가 필요해서 느리고 무겁습니다.
KSP (Kotlin Symbol Processing)
- Kotlin AST를 직접 다루며, Kotlin 전용 API를 통해 동작합니다.
- 중간 Stub 없이 바로 Kotlin 심볼을 분석하므로 더 가볍고 빠릅니다.
실제로 많은 글에서 "빌드 시간이 길다"라는 주제를 다룰 때:
- Room, Dagger, Moshi 등의 어노테이션 프로세서를 kapt에서 KSP로 옮기는 것이 가장 먼저 등장합니다.
실제 벤치마크 결과를 보면 KSP를 사용할 경우 빌드 시간이 약 2배 가까이 빨라지는 것을 확인할 수 있습니다:
# kapt 사용 시
./gradlew clean assembleDebug
# BUILD SUCCESSFUL in 45s
# KSP 사용 시
./gradlew clean assembleDebug
# BUILD SUCCESSFUL in 23s
KSP가 빠른 이유:
Kotlin → Java 변환 불필요
kapt: Kotlin → Java stub → 프로세서 → 코드 생성 KSP: Kotlin → 프로세서 → 코드 생성 (한 단계 제거!)증분 컴파일 지원
// User.kt만 수정 // kapt: 모든 @Entity 클래스 재처리 // KSP: User.kt 관련 코드만 재생성메모리 효율
kapt: Java stub을 메모리에 유지 KSP: Kotlin AST만 사용 (더 가벼움)
1부의 관점에서 보면, 어노테이션 프로세싱은 "컴파일러가 AST를 만들고 의미 분석을 하는 사이에 끼어들어, 새로운 코드 조각을 추가한 다음, 다시 컴파일러에게 넘겨주는" 역할을 수행한다고 볼 수 있습니다.
- 컴파일러 최적화 – 빌드 결과를 더 효율적으로 만드는 단계
컴파일러는 단순히 "번역기"에 머무르지 않습니다. 어느 정도의 최적화(Optimization) 기능도 가지고 있습니다.
이 부분은 많은 안드로이드 빌드 관련 블로그 글에서 다른 키워드와 함께 언급되는 경우가 많습니다.
- Dead Code Elimination
- Constant Folding
- 인라이닝(inlining)
- 그리고 Gradle 측의 Incremental Build / Caching 과의 상호작용
여기서는 핵심적인 몇 가지만 짚고 넘어갑니다.
8-1. Dead Code Elimination
다음과 같은 코드를 보겠습니다.
fun doSomething() {
if (BuildConfig.DEBUG) {
logDebug()
}
doRealWork()
}
Release 빌드에서 BuildConfig.DEBUG 값은 상수 false 입니다.
Kotlin 컴파일러와 이후 R8은 이 조건을 인식하고, 전체 if 블록을 통째로 제거할 수 있습니다.
결과적으로 Release 빌드의 바이트코드에는:
- logDebug() 호출도,
- 관련 문자열 리터럴도
아예 포함되지 않습니다.
8-2. Constant Folding
상수끼리의 계산은 컴파일 타임에 미리 수행할 수 있습니다.
val BUFFER_SIZE = 1024 * 1024 * 10
위 식을 매번 런타임에 계산하게 둘 필요는 없습니다.
컴파일러는 값을 미리 계산해서 상수 풀에 한 번만 저장해 두고, 런타임에는 그 값을 읽기만 하면 됩니다.
8-3. 컴파일 타임 최적화 vs 런타임 최적화
한 번 짚고 갈 만한 구분입니다.
컴파일 타임 최적화
- Kotlin 컴파일러 및 일부 Gradle 플러그인 수준에서 일어나는 최적화
- 소스코드 → .class 사이에서 수행
런타임 최적화 / 바이트코드 최적화
- D8/R8 단계에서 수행
- .class → .dex 변환 중에 코드 축소, 난독화, 인라이닝 등이 일어납니다.
우리가 지금 보고 있는 1부의 범위는 주로 "컴파일 타임 최적화" 쪽입니다.
2부에서는 R8이 하는 역할과, Release 빌드에서 APK 크기를 얼마나 줄일 수 있는지를 실험을 통해 살펴보게 됩니다.
- Gradle 빌드 시스템과 컴파일 단계의 위치
안드로이드 빌드를 설명하는 블로그 글의 상당수는 "Gradle과 Android Gradle Plugin이 어떻게 전체 빌드를 오케스트레이션하는지"를 강조합니다. 여기에서는 그 중 1부와 직접적으로 연결되는 부분만 가볍게 짚고, 5·6부에서 더 깊게 다루게 될 내용을 예고 수준으로만 정리해 둡니다.
9-1. Gradle 빌드 라이프사이클
Gradle 자체는 다음과 같은 세 단계로 빌드를 실행합니다.
- Initialization
- Configuration
- Execution
안드로이드 플러그인이 적용된 프로젝트에서, "컴파일 단계"는 Execution 단계에서 실행되는 여러 Task 중 일부입니다.
대표적으로:
- compileDebugKotlin / compileReleaseKotlin
- compileDebugJavaWithJavac
같은 Task가 여기 포함됩니다. 이 Task들이 바로 우리가 이 글에서 자세히 들여다본 "Kotlin/Java → .class" 단계를 담당합니다.
9-2. 앵커 Task와 build 변형들
Android Gradle Plugin은 다음과 같은 "앵커 Task"들을 제공합니다.
- assemble / assembleDebug / assembleRelease
- build
- check / connectedCheck / deviceCheck
- clean
이들 Task는 직접 컴파일을 수행하기보다는, 각각의 세부 Task들을 모아 실행하는 상위 노드입니다.
실무적으로 자주 쓰는 패턴은 다음과 같습니다.
- 로컬에서 디버그 빌드:
- ./gradlew assembleDebug
- CI에서 Release 빌드:
- ./gradlew assembleRelease
- 전체 빌드 + 테스트:
- ./gradlew build
이 Task들이 내부적으로는 "소스 컴파일 → 리소스 컴파일(AAPT2) → DEX 변환(D8/R8) → 패키징 → 서명" 순서로 의존성을 연결하고 있다는 점만 기억해 두면, 컴파일 단계를 전체 빌드 맥락 안에서 이해하기 훨씬 쉽습니다.
9-3. Gradle 캐싱·증분 빌드와 컴파일
안드로이드 빌드 캐싱에 대한 글들을 보면, 공통적으로 다음과 같은 권장 사항이 등장합니다.
- Gradle의 incremental build 기능을 활용하라.
- 가능한 한 "증분 어노테이션 프로세서"를 사용하라(Room, Hilt, Moshi의 KSP 지원 등).
- 불필요한 Task를 줄이고, 입력/출력을 잘 선언하여 캐시 히트를 높여라.
Gradle의 증분 빌드는 Task의 입력(inputs)과 출력(outputs)을 추적합니다. Task가 실행되기 전에 Gradle은 다음을 확인합니다:
- 입력 파일들이 변경되었는가?
- 출력 파일들이 변경되었는가?
변경되지 않았다면 Task를 건너뛰고 UP-TO-DATE로 표시합니다.
예를 들어:
$ ./gradlew assembleDebug --console=verbose
> Task :app:compileDebugKotlin
> Task :app:compileDebugJavaWithJavac
> Task :app:processDebugResources
> Task :app:mergeDebugResources
BUILD SUCCESSFUL in 12s
# 아무 변경 없이 다시 실행
$ ./gradlew assembleDebug --console=verbose
> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
BUILD SUCCESSFUL in 1s
12 actionable tasks: 12 up-to-date
이 글 1부에서 살펴본 "컴파일러 내부"는 그대로이지만, Gradle이 이를 "입력/출력 기반 Task"로 관리함으로써:
- 코드 몇 줄만 바꿨을 때 전체 프로젝트를 다시 빌드하지 않아도 되게 하고,
- 동일한 입력에 대해서는 이전 빌드 결과를 재사용하여 빌드 시간을 줄입니다.
즉, Kotlin 컴파일러의 세계와 Gradle의 세계는 "Task + 캐시 + 증분 빌드"라는 접점에서 만납니다.
빌드 캐시는 증분 빌드를 한 단계 더 발전시킨 것입니다. 동일한 입력에 대한 Task 출력을 로컬이나 원격 캐시에 저장해두고, 나중에 같은 입력이 들어오면 캐시에서 가져옵니다. 이는 FROM-CACHE로 표시됩니다:
$ ./gradlew assembleDebug --build-cache
> Task :app:compileDebugKotlin FROM-CACHE
> Task :app:processDebugResources FROM-CACHE
BUILD SUCCESSFUL in 3s
이를 통해:
- 브랜치를 전환했을 때 이전에 빌드한 결과를 재사용
- 팀원들 간 빌드 결과물 공유
- CI 서버에서 빌드 시간 단축
등의 이점을 얻을 수 있습니다.
- CS 쪽으로 한 걸음 더 – 전통적인 컴파일러 구조
조금 더 이론적으로 들어가면, 전통적인 컴파일러는 다음과 같은 단계로 설명됩니다.
먼저 흐름을 문장으로 정리하면:
- 소스코드를 어휘 분석기로 토큰 단위로 나누고,
- 구문 분석기로 토큰 시퀀스를 AST로 만든 다음,
- 의미 분석 단계에서 타입, 스코프, 이름 해석 등을 수행하고,
- IR을 생성해 최적화한 뒤,
- 타겟(기계어, 바이트코드 등)에 맞는 코드를 생성합니다.
이것을 그림처럼 나열하면 다음과 같습니다.
소스코드
↓
[Lexical Analysis] 어휘 분석
↓
Tokens
↓
[Syntax Analysis] 구문 분석
↓
Abstract Syntax Tree
↓
[Semantic Analysis] 의미 분석
↓
Annotated AST
↓
[Intermediate Code Generation]
↓
IR (Intermediate Representation)
↓
[Optimization]
↓
Optimized IR
↓
[Code Generation]
↓
Target Code (바이트코드 또는 기계어)Kotlin 컴파일러 역시 이와 크게 다르지 않은 구조를 따릅니다.
우리가 지금까지 살펴본 내용은 이 흐름을 "Android/Kotlin 실무"에 빗대어 설명한 버전에 가깝다고 볼 수 있습니다.
참고로 Kotlin 컴파일러는 최근 K2 컴파일러로의 전환을 진행하고 있습니다. K2는 새로운 프론트엔드 FIR(Frontend IR)을 도입하여 타입 추론과 해상도를 더 빠르고 정확하게 처리합니다. K2의 도입으로 컴파일 속도가 크게 개선되었으며, 컴파일러 플러그인 API도 더 강력해졌습니다.
11. 마무리 정리 – 1부에서 가져가면 좋은 포인트
요약을 먼저 문장으로 한 번 정리하면:
- Kotlin 컴파일러는 소스코드를 토큰과 AST, IR로 차근차근 바꿔가며, Kotlin 특유의 고수준 문법을 JVM이 이해하는 기본 구성 요소들로 풀어낸다.
- 이 과정에서 타입 시스템, 스마트 캐스트, data class, sealed class, 확장 함수 등이 모두 "코드 생성 규칙"으로 바뀐다.
- Gradle과 Android Gradle Plugin은 이 컴파일 과정을 Task 단위로 감싸 전체 빌드를 조율하며, 캐싱·증분 빌드를 통해 빌드 시간을 단축한다.
- 어노테이션 프로세싱(kapt/KSP)은 "컴파일 중간에 끼어들어 추가 코드를 만들어 넣는" 확장 지점이다.
- 컴파일러 최적화와 R8 단계의 최적화가 합쳐져 최종 APK 크기와 성능에 직접적인 영향을 준다.
간단히 다시 한 번 짚으면:
이 글에서 다룬 것
- Kotlin/Java 코드가 어떻게 .class 로 바뀌는지
- 파싱, AST, 의미 분석, IR, 바이트코드 생성의 전 과정을 실무 관점에서 이해하는 것
- data class, lazy, sealed class, 확장 함수, 스마트 캐스트가 실제로 어떤 코드로 펼쳐지는지 감을 잡는 것
- Gradle Task들 중 "컴파일 단계"가 어디에 위치하는지, 빌드 시스템 안에서의 역할을 파악하는 것
다음 글에서 이어질 것
- .class → .dex 로 넘어가는 D8/R8 단계
- 65K 메서드 제한, MultiDex, R8 최적화
- 리소스 빌드(AAPT2), APK/AAB 패키징, 서명, Play Store 배포까지의 전체 라인
이제 Run 버튼을 누를 때마다:
"지금쯤 Kotlin 컴파일러가 AST를 만들고, IR을 만들고, Gradle Task가 그 결과를 모아서 다음 단계로 넘기고 있겠구나."
라는 생각을 한 번쯤 떠올리게 될 것입니다. 그 정도 감각이 생겼다면, 이미 1부의 목표는 충분히 달성한 셈입니다.
참고 자료
이 글을 보면서 함께 열어보면 좋은 문서와 글들을 정리합니다.
Android 공식 문서 – Command line 빌드:
https://developer.android.com/build/building-cmdlineAndroid Gradle Plugin / Gradle Task 구조 설명:
https://blog.csdn.net/weixin_34199335/article/details/90662165Gradle 빌드 라이프사이클 정리:
https://www.mo4tech.com/gradle-2-build-process.htmlAndroid 빌드 캐싱과 증분 빌드 설명:
https://readmedium.com/caching-in-the-android-build-process-a52641a66b31Kotlin 컴파일러 오픈소스 저장소:
https://github.com/JetBrains/kotlinGradle Incremental Build 공식 문서:
https://docs.gradle.org/current/userguide/incremental_build.htmlCrash course on the Kotlin compiler (Frontend: Parsing phase):
https://medium.com/google-developer-experts/crash-course-on-the-kotlin-compiler-1-frontend-parsing-phase-9898490d922bUnderstanding Kotlin Compilation:
https://medium.com/@admahagaonkar/understanding-kotlin-compilation-how-kotlin-code-is-translated-to-bytecode-4315ef0fd363Types of Internal Representation of Kotlin code:
https://akuleshov7.com/2021-06-24-kotlin-representation.html
'Android' 카테고리의 다른 글
| 안드로이드 빌드 2부: DEX와 Dalvik, 그리고 ART – .class는 어디로 사라졌을까? (2/7) (0) | 2025.11.15 |
|---|---|
| [Kotlin] Continuation Interceptor (0) | 2025.05.11 |
| [책] 코틀린 코루틴 - Mutex vs Single thread dispatcher (0) | 2024.05.31 |
| 6. Kotlin Coroutines - Exceptions (2) | 2024.05.23 |
| 5. Kotlin Coroutine - Cancellation (0) | 2024.05.22 |
AI 활용에 대한 생각
손쉬운 CRUD앱은 이제, 굳이 Native 앱으로 만들 이유가 하나도 없어보인다.
native 안드로이드 개발자로서, 내가 왜 필요한지, 왜 native 기술을 사용해야만 하는 상황인지 설득할 수 있어야 한다.
그리고 또 꼭 Native 가 필요한 개발에 투입될 수 있도록 전문성을 강화해야 한다.
바로 생각나는건, 초당 수십만건의 데이터 처리가 필요한 금융 어플,
카메라를 활용한 어플, AR/VR 관련 어플, 온디바이스 ML이 필요한 경우가 있다.
Native 안드로이드 개발자가 어떻게 살아남을 수 있을지 고민이 필요하다.
[Kotlin] Continuation Interceptor
비동기 작업에서는 실행 위치나 스레드를 제어할 필요가 자주 생긴다. UI앱의 경우 UI업데이트는 메인 스레드에서만 가능하고, 서버 앱에서는 무거운 연산은 별도 스레드 풀로 분리하는 경우를 예로 들 수 있다. 이를 가능하게 해주는 도구가 바로 ContinuationInterceptor 이다. Coroutine 을 처음 배울때는 Interceptor 의 구현체인 Dispatcher 만 알게 되는데, Dispatcher 가 바로 Interceptor 의 구체클래스인 것을 알면 Continuation 을 "가로채서" 별개의 스레드에 실행하는 역할을 맡은 클래스인것이 좀더 와닿는다.
인터페이스 정의
interface ContinuationInterceptor : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<ContinuationInterceptor>
fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T>
fun releaseInterceptedContinuation(continuation: Continuation<*>)
}
ContinuationInterceptor 는 CoroutineContext.Element 를 구현하며, CoroutineContext 내에서 실행 위치나, 방식을 결정하는 역할을 한다. Dispatchers.Main, Dispatchers.IO, Dispatchers.Default 등은 모두 이 인터페이스를 구현한다.
CoroutineInterceptor 는 어떻게 동작하는가?
코루틴은 일시 중단되었다가 재개될 때 Continuation 객체를 통해 재개된다. (Coroutine State Machine 참고) 이 재개 시점(resume)에서 ContinuationInterceptor 는 다음과 같이 코루틴 실행을 가로채고 wrapping 한다.
val intercepted = continuation.context[ContinuationInterceptor]
?.interceptContinuation(continuation)
?: continuation
코루틴이 재개되는 시점에 코루틴은 자신의 CoroutineContext 에 ContinuationInterceptor 가 있는지 확인한다. 있으면, interceptContinuation() 을 호출하여 래핑된 Continuation 을 사용한다. (interceptContinuation 의 반환 객체) 그 래핑된 Continuation 은 어디서 실행할지를 결정한다. (메인 스레드, IO 스레드 ...)
재사용이 끝난 Continuation 은 다음 메서드를 통해 해제된다. 이 정리는 프레임워크 내부에서 자동으로 수행된다.
ContinuationInterceptor.releaseInterceptedContinuation(continuation)
Interceptor의 구현체인 Dispatcher에 대한 추가 정리:
CoroutineContext 의 Element 중 하나인 CoroutineDispatcher 의 명세
abstract class CoroutineDispatcher : AbstractCoroutineContextElement, ContinuationInterceptor
ContinuationInterceptor는 모든 CoroutineDispatcher 가 구현하는 추상 클래스이다.
공식 문서에 따르면 kotlinx.coroutines 를 사용할 경우, CoroutineDispatcher 가 아닌 다른 형태의 ContinuationInterceptor 구현체는 사용하지 않는 것이 좋다고 한다. 그 이유는 CoroutineDispatcher 는 newCoroutineContext() 함수 내에서 디버깅 기능이 올바르게 작동하도록 보장해주기 때문이라고 한다.
kotlinx.coroutines 는 코루틴의 컨텍스트 설정 시 newCoroutineContext() 라는 내부 함수에서 다음과 같은 작업을 수행한다 - 1) CoroutineName을 컨텍스트에 추가 (디버깅 이름 추적), 2) CoroutineId 부여 (Debug probes 용) 3) 디스패처가 있는 경우 현재 스레드나 컨텍스트에 맞는 위치에서 실행되도록 설정
즉, CoroutineDispatcher 는 공식적으로 인정된 인터셉터의 구현체라고 할 수 있다. 따라서 커스텀 Interceptor 구현은 권장되지 않는 것으로 보인다.
ContinuationInterceptor 를 알게된 후 보이는 것
Dispatchers.Unconfined 의 동작이 잘 이해되지 않았었는데, 위 내용을 알고나니 조금 더 이해가 쉬워졌다. Unconfined 디스패처는 실행 흐름이 처음 시작한 스레드에서 시작했다가, 중간에 suspend 가 일어나면 다시 resume 하는 시점에 resume 이 호출된 스레드에서 실행이 이어진다.
예시:
fun main() = runBlocking {
withContext(Dispatchers.Unconfined) {
println("1 - ${Thread.currentThread().name}") // main
doSomething()
println("2 - ${Thread.currentThread().name}") // DefaultExecutor
}
}
suspend fun doSomething() {
println("doSomething ${Thread.currentThread().name}") // main
delay(1000)
println("doSomething ${Thread.currentThread().name}") // DefaultExecutor
}
// 실행결과
1 - main
doSomething main
doSomething kotlinx.coroutines.DefaultExecutor
2 - kotlinx.coroutines.DefaultExecutor
delay 라는 중단 지점에서 중단되었다가 실행이 재개되는 시점에, resume 이 호출되는 스레드는 기본으로 지정되는 Default 스레드임을 알 수 있다. 이 때 doSomething() 이 반환된 이후 실행흐름또한 Default 스레드에서 이어진다.
delay 이후 재개되는 시점에 continuation.context[ContinuationInterceptor] 를 맵에서 꺼내서 확인을 하는데, ContinuationInterceptor 가 Unconfined 객체이며 이 객체의 내부 구현체의 isDispatchNeeded 변수가 false 가 되어있어서 dispatch() 가 호출되지 않기 때문이다.
출처:
'Android' 카테고리의 다른 글
| 안드로이드 빌드 2부: DEX와 Dalvik, 그리고 ART – .class는 어디로 사라졌을까? (2/7) (0) | 2025.11.15 |
|---|---|
| 안드로이드 빌드 1부: 소스코드에서 바이트코드까지 - 컴파일의 세계 (1/7) (0) | 2025.11.15 |
| [책] 코틀린 코루틴 - Mutex vs Single thread dispatcher (0) | 2024.05.31 |
| 6. Kotlin Coroutines - Exceptions (2) | 2024.05.23 |
| 5. Kotlin Coroutine - Cancellation (0) | 2024.05.22 |
[책] 코틀린 코루틴 - Mutex vs Single thread dispatcher
package coroutine_deepdive
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.system.measureTimeMillis
class MessagesRepository {
private val messages = mutableListOf<String>()
private val mutex = Mutex()
@OptIn(ExperimentalCoroutinesApi::class)
private val dispatcher = Dispatchers.IO.limitedParallelism(10)
suspend fun add(message: String) {
// 버전1. Single thread 로 제한된 디스패처 사용 - 각자 런치 되었다가 병렬 실행후 돌아오는 느낌 (독립적) - 그래서 총 1초 정도 걸림.
withContext(dispatcher) {
delay(1000)
println(message)
messages.add(message)
}
// 버전2. Mutex 사용
mutex.withLock { // queueing 된다. 코루틴들이 줄서서 줄줄이 소시지마냥 기다린다. 그래서 총 5초 이상 걸린다.
delay(1000)
println(message)
messages.add(message)
}
}
}
suspend fun main() = runBlocking {
val repo = MessagesRepository()
val timeMills = measureTimeMillis {
coroutineScope {
repeat(5) {
launch {
repo.add("Hello $it")
}
}
}
}
println("time $timeMills ms")
}'Android' 카테고리의 다른 글
| 안드로이드 빌드 1부: 소스코드에서 바이트코드까지 - 컴파일의 세계 (1/7) (0) | 2025.11.15 |
|---|---|
| [Kotlin] Continuation Interceptor (0) | 2025.05.11 |
| 6. Kotlin Coroutines - Exceptions (2) | 2024.05.23 |
| 5. Kotlin Coroutine - Cancellation (0) | 2024.05.22 |
| 4. Kotlin Coroutine - CoroutineScope/Context/Job (0) | 2024.05.21 |
6. Kotlin Coroutines - Exceptions
https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c
개발자들은 항상 happy path 만을 갈고 닦기를 좋아하지만,
항상 사용자들은 우리의 예상을 벗어난다.
만약 사용자들의 액션이 성공하지 않은 경우, 적절한 에러 메시지를 보여주는 것은 필수 불가결하다.
예외를 어떻게 적절하게 처리하느냐는 사용자가 애플리케이션을 바라보는 데 있어 아주 중요한 요소이다.
코루틴에서 어떤식으로 에러를 제대로 매니징 하는지 알아보자.
CoroutineScope 안에서 어떠한 코루틴이 실패로 끝나서 예외를 던진다면
0) 자식 코루틴 실패 -> 1) 해당 스코프 안의 다른 자식 코루틴 취소 -> 2) 부모 코루틴 취소 -> 3) 위로 전달
과 같은 과정을 거치게 된다.
위로 예외를 전달하는게 어떤 경우에는 말이 될 수 있지만, 이런 처리가 바람직하지 않은 경우들이 종종 있다.
UI 관련된 CoroutineScope 에 대해서 생각해보자.
만약 child coroutine 이 예외를 던져서 UI Scope 가 캔슬이 되었다면
모든 UI 컴포넌트들이 무응답 상태에 빠지게 된다.
만약 이런식의 일괄 취소 동작을 원하지 않는다면 어떻게 해야할까?
SupervisorJob 이라는 애를 사용하자.
자식 코루틴들을 생성하는 CoroutineScope 에 들어가는 CoroutineContext 에다가 SupervisorJob 을 박아주는 것이다.
Supervisor Job to the rescue

SupervisorJob 을 사용하면, 자식의 실패가 다른 자식들에 영향을 주지 않는다.
또한 위로 예외를 전달하지도 않는다.
단지 자식 코루틴이 스스로 던진 예외를 처리하도록 내버려둔다.
val uiScope = CoroutineScope(SupervisorJob()) -- CoroutineContext 로 넣어준 것 염두에 두자
이렇게 uiScope 라는 애를 만들어서 사용해볼 수 있다.
만약 exception 이 처리가 안되었고, CoroutineContext 가 CoroutineExceptionHandler 가 없다면 default thread 의 ExceptionHandler 에 도달한다. JVM 에서는 이 오류가 콘솔에 로깅이 될 것이고, 안드로이드에서는 어떤 Dispatcher 에서 수행되든간에 앱 크래시가 날 것이다.
💥 Uncaught exceptions will always be thrown regardless of the kind of Job you use
위에서 설명한 것들은 scope builder 두가지
- coroutineScope
- supervisorScope
에도 동일하게 적용된다.
얘네들은 sub-scope 을 생성할 것이며 (각각, Job, SupervisorJob 을 부모로 가진 채로)
당신은 코루틴들을 이 sub-scope 내부에서 생성하게 된다.
그러면 언제 Job / SupervisorJob 써야 하는건가?
SupervisorJob 은 당신이 failure 가 났을 때 부모 그리고 이웃 코루틴들을 취소하고 싶지 않을 때 사용한다.
예시:
// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(SupervisorJob())
scope.launch {
// Child 1
}
scope.launch {
// Child 2
}
-> 위 예시에서 child 1 의 실패는, child 2 에 영향을 주지 않는다.
-> SupervisorJob 의 동작에 의하여.
또 다른 예시
// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(Job())
scope.launch {
supervisorScope {
launch {
// Child 1
}
launch {
// Child 2
}
}
}
-> supervisorScope 는 sub-scope 를 만들고, child 1 의 오류는 child 2 에 영향을 주지 않는다.
-> 만약 coroutineScope 빌더를 사용했다면, child 1 의 오류는 child 2 를 취소시키고, 부모 위로까지 전달될 것임.
그렇다면 이런 상황은 어떨까?
Job이랑 SupervisorJob 을 이렇게 섞어서 사용하면 어떨까?
val scope = CoroutineScope(Job())
scope.launch(SupervisorJob()) {
// new coroutine -> can suspend
launch {
// Child 1
}
launch {
// Child 2
}
}
child 1 의 부모는 어떤 Job 일지 맞춰보자.
정답은 그냥 Job 이다.
SupervisorJob 은 scope.launch 로 만들어진 코루틴의 부모인데,
사실상 위 코드에서는 아무것도 하지 않는다.

따라서, child 1 또는 child 2 가 실패하면, failure 는 sopce 에 도달할 것이고, 그 scope 에서 시작된 모든 작업들은 취소된다.
기억하기!
SupervisorJob 은 오직 scope 의 일부일떄만 제대로 작동한다.
- supervisorScope 라는 스코프 빌더를 사용하거나
- CoroutineScope(SupervisorJob()) 과 같이 CoroutineContext 로서 정의될때.
coroutine builder 의 인자로 SupervisorJob 을 넘겨주는 것은 아무런 소용이 없다고~
내부가 궁금한가?
SupervisorJob 구현을 보면 childCancelled 메서드는 단지 false 를 반환한다.
즉, cancellation 을 전달하지 않고, 예외 또한 처리하지 않는다.
Dealing with Exceptions
launch / async - 어떤 코루틴 빌더를 사용하느냐에 따라 에러 처리가 다르다.
launch 의 경우 실행 즉시 예외가 던져진다.
따라서 try-catch 로 예외를 던지는 코드를 감싸주면 된다.
scope.launch {
try {
codeThatCanThrowExceptions()
} catch(e: Exception) {
// Handle exception
}
}
With launch, exceptions will be thrown as soon as they happen
Async 가 root coroutine (coroutines that are a direct child of a CoroutineScope instance or supervisorScope) 일때는, 예외는 자동으로 던져지지 않는다. await() 를 호출할때서야 비로소 예외가 던져진다.
supervisorScope {
val deferred = async {
codeThatCanThrowExceptions()
}
try {
deferred.await()
} catch(e: Exception) {
// Handle exception thrown in async
}
}
이런 케이스에서는 async 는 절대로 예외를 던지지 않는다.
await() 가 async 내부에서 일어난 예외를 던지게 된다.
supervisorJob 을 사용하고 있는 것을 보자.
즉, child 가 자체적으로 터뜨린 예외를 처리하도록 내버려 둔다.
따라서 catch 블럭이 실행이 될 것이고, async 가 던지는 예외를 잡아서 삶아먹을 수 있다.
coroutineScope 를 사용하면,
우리의 예상대로 자식의 예외가 다른 자식, 그리고 부모에게 전파된다.
coroutineScope {
try {
val deferred = async {
codeThatCanThrowExceptions()
}
deferred.await()
} catch(e: Exception) {
// Exception thrown in async WILL NOT be caught here
// but propagated up to the scope
}
}
-> 위의 경우 coroutineScope 이기 때문에 async 가 던진 예외는 catch 에서 잡히지 않고 스코프 위로 전달된다.
덤으로, 다른 코루틴 내부에서 생성된 코루틴에서는
코루틴 빌더가 뭐냐에 따라 상관 없이 항상 예외가 위로 전달된다.
가령,
val scope = CoroutineScope(Job())
scope.launch {
async {
// If async throws, launch throws without calling .await()
}
}
이런게 있는 경우,
scope.launch 라는 빌더로 만들어진 코루틴 안에서
async 라는 애로 코루틴을 다시 만든 것인데
이 때 async 에서 예외가 발생한다면 await() 호출하지 않아도 예외가 던져진다.
다시 말해, async 가 root coroutine 이 아니기 때문에 await() 에서 예외가 터지지 않는다.
⚠️ Exceptions thrown in a coroutineScope builder or in coroutines created by other coroutines won’t be caught in a try/catch!
CoroutineExceptionHandler 라는 애가 예전부터 언급됐는데
왜 이제서야 나오느냐?
이유는 얘의 역할이 catch 되지 않는 예외를 다루도록 해주는 애기 때문이다.
아까부터 계속 부모 위로 예외가 전달 된다고 했는데,
그럴때 실행되는 예외 처리기가 CoroutineExceptionHandler 라고 할 수 있다.
Exceptions will be caught if these requirements are met:
- When ⏰: The exception is thrown by a coroutine that automatically throws exceptions (works with launch, not with async).
- Where 🌍: If it’s in the CoroutineContext of a CoroutineScope or a root coroutine (direct child of CoroutineScope or a supervisorScope).
val handler = CoroutineExceptionHandler {
context, exception -> println("Caught $exception")
}
val scope = CoroutineScope(Job())
scope.launch(handler) {
launch {
throw Exception("Failed coroutine")
}
}
// 틀린 예시
val scope = CoroutineScope(Job())
scope.launch {
launch(handler) { // 빌더에 박으면 어떡하나?
// CoroutineContext 에 인스톨 되어야 핸들러 제대로 작동한다.
// 요 런치는 예외 일어나자 마자 바로 부모한테 전달하는데, 부모는 핸들러를 모르기 때문이다.
throw Exception("Failed coroutine")
}
}
요약)
SupervisorScope, supervisorScope 는 자식 예외 처리 씹는다.
자식이 알아서 해야한다.
async 가 root coroutine 에서 생성된다면 await 호출시 예외 던져진다
그 외에는 async 실행시 곧바로 예외 던져진다
launch 는 실행시 곧바로 예외 던져진다
위로 위로 전달된 예외는 scope 에 내장된 CoroutineExceptionHandler 로 들어가서 처리될수도.
'Android' 카테고리의 다른 글
| [Kotlin] Continuation Interceptor (0) | 2025.05.11 |
|---|---|
| [책] 코틀린 코루틴 - Mutex vs Single thread dispatcher (0) | 2024.05.31 |
| 5. Kotlin Coroutine - Cancellation (0) | 2024.05.22 |
| 4. Kotlin Coroutine - CoroutineScope/Context/Job (0) | 2024.05.21 |
| Android - Coroutines best practices (0) | 2024.05.15 |
5. Kotlin Coroutine - Cancellation
https://medium.com/androiddevelopers/cancellation-in-coroutines-aa6b90163629
어떤 작업이 더이상 실행되지 않아도 될 때는, 메모리를 낭비하지 않기 위해 취소해주어야 한다.
코루틴은 CancellationException 이라는 예외를 던짊으로써 cancellation 을 처리한다.
fun cancel(cause: CancellationException? = null)
// 객체 안넘겨주면 기본값 사용함
public override fun cancel(cause: CancellationException?) {
cancelInternal(cause ?: defaultCancellationException())
}
cancel 함수에 인자값으로 사실은 CancellationException 이라는 녀석이 숨어있다.
이 예외에 대한 구체적인 이유를 적어주고 싶다면 cause 에 예외를 넘겨주면 된다.
만약 넘겨주지 않는다면 default를 만들어서 내부적으로 캔슬을 한다.
parent - child job 이 있다고 할때, child 는 CancellationException 을 부모에게 던지고,
부모는 이를 까본 후 자신이 처리해야 하는 예외인지 판단하게 된다.
부모는 CancellationException 으로 인해 끝나버린 자식의 경우 추가적인 액션을 취하지 않는다.
만약 ktx 라이브러리를 통해서 생성된 라이프사이클과 연관된 스코프라면 개발자가 직접 cancel 해주어야 하는 경우는 없다.
viewModelScope, lifecycleScope 모두 각자 알아서 끝나야 될 때를 알고있다.
그렇다면 다시, cancel 한다는 건 구체적으로 어떤 것을 의미하나 ?
coroutine 을 cancel 했다고 해서 그 작업이 끝난것은 아니다.
cancel() 을 호출하면 코루틴은 Cancelling 상태로의 전이가 일어날 뿐이다.
자신의 작업이 다 끝난 경우에 비로소 Cancelled 상태로의 전이가 일어난다.
즉, 코루틴은 어떠한 협력 없이는 작업을 중단할 수 없으며, 이로써 코루틴의 취소는 "협력적(Cooperative)" 하다는 표현이 등장한 것이다. 취소를 위한 어떠한 협력 매커니즘이 반드시 존재해야 한다.
kotlinx.coroutine 에서 제공하는 모든 suspend 함수들은 사실 Cancellable 하다.
즉, 내부적으로 active 상태인지를 주기적으로 확인하는 로직이 있어 cancel() 을 했을 때, 실제로 작업이 중단될 수 있는 것이다.
가령 withContext, delay 같은 애들이 있다.
만약 당신이 Cancellable 한 suspend 함수를 만들어야 한다면 다음의 두 가지 방법으로 Cancellable 하게 만들 수 있다.
1. Job.isActive / ensureActive()
2. yield() 를 통해 다른 작업이 일어나도록 함
fun Job.ensureActive(): Unit {
if (!isActive) {
throw getCancellationException()
}
}
이런식으로 내부적으로 isActive 를 체크하는 코드다.
ensureActive 를 사용하면 직접적으로 프라퍼티에 접근하는 코드에 대한 보일러플레이트를 줄일 수 있다는 장점이 있기는 한데,
뭔가 구체적으로 로깅을 하고 싶다거나 하는 경우 살짝 애매해질수도.
그러면 yield 는 뭐냐?
당신이 하려는 작업이
1. CPU 를 많이 사용한다거나
2. 스레드풀을 소진시킬 가능성이 있다거나
3. 스레드풀에 스레드를 추가하지 않은 채로 스레드가 다른 작업을 하기를 원한다거나
할때 사용해라. -> 무슨말이냐 ?
ensureActive 를 처음에 호출한 것 처럼 yield 도 함수 시작할때 호출하면 된다.
package kotlinx.coroutines
import kotlinx.coroutines.internal.*
import kotlin.coroutines.intrinsics.*
/**
* Yields the thread (or thread pool) of the current coroutine dispatcher
* to other coroutines on the same dispatcher to run if possible.
*
* This suspending function is cancellable: if the [Job] of the current coroutine is cancelled while
* [yield] is invoked or while waiting for dispatch, it immediately resumes with [CancellationException].
* There is a **prompt cancellation guarantee**: even if this function is ready to return the result, but was cancelled
* while suspended, [CancellationException] will be thrown. See [suspendCancellableCoroutine] for low-level details.
*
* **Note**: This function always [checks for cancellation][ensureActive] even when it does not suspend.
*
* ### Implementation details
*
* If the coroutine dispatcher is [Unconfined][Dispatchers.Unconfined], this
* functions suspends only when there are other unconfined coroutines working and forming an event-loop.
* For other dispatchers, this function calls [CoroutineDispatcher.dispatch] and
* always suspends to be resumed later regardless of the result of [CoroutineDispatcher.isDispatchNeeded].
* If there is no [CoroutineDispatcher] in the context, it does not suspend.
*/
public suspend fun yield(): Unit = suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
val context = uCont.context
context.ensureActive()
val cont = uCont.intercepted() as? DispatchedContinuation<Unit> ?: return@sc Unit
if (cont.dispatcher.isDispatchNeeded(context)) {
// this is a regular dispatcher -- do simple dispatchYield
cont.dispatchYield(context, Unit)
} else {
// This is either an "immediate" dispatcher or the Unconfined dispatcher
// This code detects the Unconfined dispatcher even if it was wrapped into another dispatcher
val yieldContext = YieldContext()
cont.dispatchYield(context + yieldContext, Unit)
// Special case for the unconfined dispatcher that can yield only in existing unconfined loop
if (yieldContext.dispatcherWasUnconfined) {
// Means that the Unconfined dispatcher got the call, but did not do anything.
// See also code of "Unconfined.dispatch" function.
return@sc if (cont.yieldUndispatched()) COROUTINE_SUSPENDED else Unit
}
// Otherwise, it was some other dispatcher that successfully dispatched the coroutine
}
COROUTINE_SUSPENDED
}
Job.join() 과 Deffered.await() 를 할때,
cancel() 을 전/후로 호출하면 결과적으로 어떤 차이가 있을까?
job.cancel()
job.join()
순서대로 호출을 하면 join() 은 작업이 끝날때까지 suspend 한다. -> cancellable 하다고 문서에는 나와있는데, 무슨 말이지?
job.join()
job.cancel()
순서대로 호출하면 cancel() 은 아무런 효과가 없다. 이미 작업이 끝난 상태이기 때문이다.
Deffered 는 Job 의 특정한 타입으로서, 마찬가지로 캔슬할 수 있다.
val deferred = async { … }
deferred.cancel()
val result = deferred.await() // throws JobCancellationException!
Deferred 에 cancel() 이 호출된 이후에는 await() 를 호출하면 JobCancellationException 을 던진다.
await() 의 역할이 결과가 계산되기까지 코루틴을 suspend 하는 것인데, 코루틴이 취소 되어서 결과가 계산될 수 없기 때문이다.
따라서 await 를 cancel 이후에 호출하면 예외가 발생하는 것.
Handling cancellation side effects
코루틴이 취소된 이후에 특정 동작을 수행하고 싶다고 해보자.
가령 어떤 자원을 닫는다거나, 취소를 로깅한다거나 ...
이를 달성할 수 있는 몇 가지 방법 있음:
- !isActive 확인해주기
- try - catch - finally
- suspendCancellableCoroutine / invokeOnCancellation
각 방법에 대한 예시:
1. isActive 하지 않을 때 체크
while (i < 5 && isActive) {
// print a message twice a second
if (…) {
println(“Hello ${i++}”)
nextPrintTime += 500L
}
}
// the coroutine work is completed so we can cleanup
println(“Clean up!”)
isActive 하지 않거나, 작업이 정상적으로 끝난경우 complete 상태에 오게 되고, clean up 코드를 넣어준다. (while 문 밖에)
2. try-catch-finally
val job = launch {
try {
work()
} catch (e: CancellationException){
println(“Work cancelled!”)
} finally {
println(“Clean up!”)
}
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)
finally 블럭에서 clean up 을 수행하는 경우다.
다만, 이 때 조심해야 할 것은 취소중인 (cancelling state) 코루틴은 더이상 suspend 연산이 불가능하다는 것이다.
다시 말해서, 더이상 suspend 상태에서 어떠한 코드블럭을 실행할 능력을 상실해 버린다.
따라서 finally 블럭에서 suspend 연산을 하고 싶다면, non-cancellable coroutine context 로 작업을 이관해야 한다.
그럼으로써 우리는 cancelling 상태에 대한 suspend 를 할 수 있다.
val job = launch {
try {
work()
} catch (e: CancellationException){
println(“Work cancelled!”)
} finally {
withContext(NonCancellable){
delay(1000L) // or some other suspend fun
println(“Cleanup done!”)
}
}
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)
3. suspendCancellableCoroutine / invokeOnCancellation
만약 suspendCoroutine 을 이용해서 콜백을 코루틴으로 변환했다면, suspendCancellableCoroutine 을 사용하는 것을 고려해보자.
그러면 캔슬 되는 상태에 대한 체크를 continuation.invokeOnCancellation 처럼 할 수 있음
suspend fun work() {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
// do cleanup
}
// rest of the implementation
}
결론)
구조화된 동시성 (Structured concurrency) 에 대한 이점을 가져가고 싶다면,
그리고 불필요한 작업을 하고 싶지 않다면 항상 job 이 취소가능하도록 관리하라.
어지간하면 당신이 직접 스코프 만들려 하지 말고, jetpack 에서 주는거 써라.
- viewModelScope
- lifecycleScope
만약 꼭 만들어야만 하겠으면 반드시 cancellable 하게 만들라.
The cancellation of coroutine code needs to be cooperative so make sure you
update your code to check for cancellation to be lazy and avoid doing more
work than necessary.
'Android' 카테고리의 다른 글
| [책] 코틀린 코루틴 - Mutex vs Single thread dispatcher (0) | 2024.05.31 |
|---|---|
| 6. Kotlin Coroutines - Exceptions (2) | 2024.05.23 |
| 4. Kotlin Coroutine - CoroutineScope/Context/Job (0) | 2024.05.21 |
| Android - Coroutines best practices (0) | 2024.05.15 |
| 3. Kotlin Coroutine - Concurrency issues (0) | 2024.05.15 |