이전 글
2026.02.19 - [안드로이드] - 안드로이드 컴파일 과정 완전정리 ① - Kotlin, D8/R8, DEX, ART 등
안드로이드 컴파일 과정 완전정리 ① - Kotlin, D8/R8, DEX, ART 등
핵심 한 줄코틀린 코드는 Android에서 Kotlin 컴파일(.class) -> Android 변환(.dex) -> 설치/최적화 -> ART 해석/JIT/AOT -> CPU 기계어 실행으로 동작한다.코틀린 소스가 컴파일러에 들어간다fun sum(a:Int,b:Int)=a+b
develop-with-kkomaeng.tistory.com
안드로이드 개발자 관점에서 “Kotlin 컴파일러가 정확히 뭘 하는지”를 소스가 .class로 바뀌는 내부 파이프라인 단위로 쪼개서 정리한다. 목표는 fun sum(a: Int, b: Int) = a + b 같은 한 줄이 컴파일러 내부에서 어떤 중간표현을 거치며 어떤 검증을 받고 어떤 형태로 JVM 바이트코드가 되는지 끝까지 따라가는 것이다.
0. Kotlin 컴파일러가 “하는 일” 한 문장
Kotlin 컴파일러는 Kotlin 소스(.kt)를 읽고, 의미 분석(타입/호출/널/가시성/오버로드/제네릭 추론 등)을 수행한 뒤, 중간표현(IR)을 만들고, 최종적으로 JVM .class(바이트코드 + Kotlin 메타데이터)를 생성한다.
여기서 핵심은 “파싱해서 .class 찍는 도구”가 아니라, Kotlin 언어 의미(semantic)를 완전히 확정하는 엔진이라는 점이다. 안드로이드 빌드가 빨라지거나 느려지는 지점도 대부분 이 의미 분석/증분 빌드/캐시에서 갈린다.
1. 안드로이드 빌드에서 Kotlin 컴파일러는 어디에 끼는가
1-1) Gradle/AGP에서의 호출 형태
안드로이드(Gradle)에서는 보통 compileDebugKotlin 같은 태스크가 Kotlin 컴파일러를 호출한다. 호출 방식은 크게 두 가지로 생각하면 된다.
- **kotlinc(커맨드라인 컴파일러)**를 직접 실행하는 형태
- **Kotlin daemon(데몬)**을 띄워서 컴파일 요청을 RPC처럼 보내는 형태(프로세스 재사용)
실제로 어떤 방식이 선택되는지는 빌드 환경/옵션에 따라 달라질 수 있다. “항상 데몬이다” 같은 단정은 할 수 없다(환경마다 다르기 때문).
Kotlin daemon(데몬)?
안드로이드 빌드에서 Kotlin 컴파일러는 매번 새로 실행될 수도 있고,
이미 떠 있는 전용 컴파일 프로세스에 작업을 요청할 수도 있다.
그 “항상 켜져 있으면서 컴파일 요청을 받아 처리하는 프로세스”를 코틀린 데몬이라고 한다.
Gradle이 컴파일 태스크를 실행하면:
- 매번 kotlinc를 새로 띄우는 방식도 가능하지만
- 보통은 Kotlin 데몬을 재사용해서 빌드 속도를 높인다
왜 빠르냐면:
- JVM을 새로 띄우지 않아도 되고
- 이전 컴파일 정보(캐시 등)를 재사용할 수 있기 때문이다
Gradle 데몬과의 차이
많이 헷갈리는 부분이다.
둘 다 “데몬”이지만 역할이 완전히 다르다.
| 목적 | 전체 빌드 관리 | Kotlin 코드 컴파일 |
| 실행 주체 | Gradle | Kotlin 컴파일러 |
| 담당 범위 | 태스크 실행, 의존성 해석, 빌드 스케줄링 | .kt → .class 변환 |
| 항상 존재? | 보통 항상 켜짐 | Kotlin 컴파일 시 필요할 때 실행 |
정리하면:
- Gradle 데몬은 빌드 관리자
- Kotlin 데몬은 Kotlin 전용 컴파일 서버
빌드 흐름은 대략 이렇게 된다:
즉, Kotlin 데몬은 Gradle 데몬의 “하위 실행 주체”에 가깝다.
데몬(daemon)이란?
운영체제에서 백그라운드에서 계속 떠 있는 서비스 프로세스를 말한다.
사용자가 직접 실행하지 않아도, 요청이 오면 일을 처리한다.
예:
- DB 서버 프로세스
- Gradle 데몬
- Kotlin 데몬
즉, Kotlin 데몬은 “컴파일 전용 서버 프로세스”라고 생각하면 된다.
1-2) 입력과 출력
컴파일러도 함수처럼 생각할 수 있다. (소스코드 + 의존성 + 옵션) → (바이트코드)
컴파일러는 본질적으로 **순수 변환기(transformer)**에 가깝다.
입력 값이 같으면 출력도 같아야 한다.
그래서 빌드 캐시, 증분 빌드, 재현 가능한 빌드 같은 개념이 가능하다.
즉, 컴파일러는 “소스 → 결과물”로 변환하는 수학적 함수처럼 동작한다.
즉, 입력(Input)이 있고, 출력(Output)이 있다.
- 입력
- .kt 소스
- 의존성(다른 모듈/라이브러리의 .class, .jar, .klib 등 타깃에 따라)
- 컴파일 옵션(언어 버전, jvmTarget, freeCompilerArgs, 플러그인 등)
- (중요) 클래스패스: “이 타입/함수/어노테이션이 무엇인지”를 해석하는 기반
- 출력(JVM 타깃 기준)
- .class 파일들
- (옵션) 디버그 메타(라인 넘버 등)
- (중요) .class 안의 @kotlin.Metadata (Kotlin 전용 메타데이터)“입력과 출력”을 왜 굳이 구분하나?
클래스패스(Classpath)는 누가 어떻게 만드는가?
클래스패스는 Gradle이 만든다.
안드로이드 프로젝트에서:
- implementation
- api
- kapt
- runtimeOnly
같은 의존성 선언을 Gradle이 해석해서
컴파일 시점에 필요한 모든 jar/class 경로 목록을 만든다.
그 목록을 Kotlin 컴파일러에게 넘긴다.
컴파일러는 클래스패스를 보고:
- 이 타입이 실제 존재하는지
- 어떤 메서드가 있는지
- 상속 구조가 어떻게 되는지
를 확인한다.
@kotlin.Metadata는 “Kotlin이 만든 class file이면 들어간다” 수준으로 이해하면 된다. 이 메타데이터 덕분에 Java 바이트코드만 보고는 복원하기 어려운 Kotlin 정보(예: nullability, suspend 시그니처, 프로퍼티 등)를 도구들이 해석할 수 있다.
2. 전체 파이프라인 개요: “텍스트 → 의미 확정 → IR → 바이트코드”
Kotlin 컴파일러를 4개 덩어리로 나누면 이렇다.
- Parsing(구문 분석): 텍스트를 문법 트리로 만든다
- Frontend(의미 분석): 타입/호출/오버로드/제네릭 추론/데이터플로우/널 안정성 등 “무엇이 무엇인지”를 확정한다
- IR(중간표현) 생성 + Lowering: 고수준 Kotlin 개념을 점점 “코드 생성 가능한 형태”로 낮춘다
- Backend(코드 생성): JVM .class를 만든다
여기서 “타입체크/널 검사”는 2) frontend의 하위 단계로 촘촘하게 분해된다. 그리고 이게 컴파일러의 대부분이다.
컴파일러에서의 프론트엔드 / 백엔드는 무슨 의미인가?
웹 개발에서의 프론트엔드/백엔드와는 완전히 다른 의미다.
여기서 말하는 건 컴파일 단계의 분리 구조다.
✔ 프론트엔드(Frontend)
역할:
소스코드를 “이해”한다.
하는 일:
- 파싱
- 타입 검사
- 오버로드 해석
- 제네릭 추론
- null 안정성 검사
- 데이터플로우 분석
- 에러/경고 생성
즉, 언어 의미(semantic)를 확정하는 단계다.
✔ 백엔드(Backend)
역할:
확정된 의미를 실제 타깃 코드로 만든다.
하는 일:
- IR lowering
- 바이트코드 생성
- .class 파일 생성
▶ 왜 굳이 나누는가?
이 구조 덕분에:
- 같은 프론트엔드를 써서
- 다른 타깃(JVM, JS, Native)으로 출력할 수 있다.
즉:
웹 프론트엔드/백엔드 개념과는 무관하다.
3. 1단계: Parsing — “문법”만 이해한다
fun sum(a: Int, b: Int) = a + b를 파싱하면 대략 이런 구조가 잡힌다.
- 함수 선언
- 이름: sum
- 파라미터 목록: (a: Int, b: Int)
- 본문: “=` 표현식 본문”
- 바이너리 연산: a + b
중요: 이 단계에서는 +가 어떤 함수 호출로 해석되는지, a가 정말 Int인지, Int가 무엇인지(클래스패스에 실제 존재하는지) 같은 건 확정되지 않는다. 파싱은 “형태(syntax)”를 잡는 단계다.
4. 2단계: Frontend — 의미 분석의 “내부의 내부”
안드로이드 개발자가 진짜 알아야 하는 건 여기다. Kotlin의 “언어 기능” 대부분이 이 단계에서 정확한 규칙으로 실행된다.
Kotlin은 프론트엔드가 단계(phase)로 진행되며, 최근에는 K2 프론트엔드에서 FIR(Frontend IR) 라는 내부 표현을 중심으로 분석이 진행된다. 여기서 FIR은 “백엔드 IR”이 아니라, 프론트엔드가 의미 분석을 위해 쓰는 중간표현이다.
4-1) 왜 FIR 같은 걸 만들까
파서 트리만으로는 의미 분석을 하기 불편하다. 언어 기능이 많아질수록 “문법 트리 + 임시 테이블”로는 관리가 깨진다. 그래서 컴파일러는 보통 다음을 한다.
- 파서 트리 → “의미 분석하기 좋은 트리/그래프”로 변환
- 그 위에 resolve/type/dataflow 결과를 노드에 붙여서 누적
FIR이 바로 그 “의미 분석용 구조체/노드들의 집합”이라고 보면 된다.
4-2) Frontend의 내부 작업을 더 잘게 쪼개면
(A) 심볼 테이블/스코프 구성: “이 이름이 뭐냐”를 결정할 준비
- 패키지/임포트 처리
- 파일 레벨 선언 수집
- 클래스/함수/프로퍼티의 스코프 구축
- 같은 이름이 여러 개면(오버로드/확장함수/멤버/로컬) 후보군을 모으는 기반 마련
이게 없으면 sum 본문에서 a를 봤을 때 “파라미터 a”인지 “상위 스코프의 a”인지조차 못 고른다.
(B) 타입 이름 해석: Int가 무엇인지
- Int라는 식별자를 Kotlin 내장 타입으로 해석(실제로는 표준 라이브러리/플랫폼 타입 매핑과 결합된다)
- 클래스패스에 있는 심볼과 연결
- 타입 별칭(typealias)이 있으면 펼침(expand) 가능성
(C) 선언 헤더 타입 확정: 파라미터/리시버/제네릭
sum(a: Int, b: Int)의 파라미터는 이미 명시 타입이 있으니 쉬운 편이다. 하지만 Kotlin은 다음이 섞이면 급격히 어려워진다.
- 제네릭(fun <T> ...)
- 확장 함수 리시버(fun String.sum(...))
- 디폴트 파라미터/네임드 아규먼트
- 오버로드 + SAM 변환 + vararg
이들은 전부 “호출 해석”에서 폭발한다.
(D) 본문 해석(body resolve): a + b가 의미를 갖는다
여기서 a + b는 단순 연산이 아니라, Kotlin 규칙상 보통 함수 호출로 desugar된다.
- a + b
- 후보: a.plus(b) 형태의 연산자 함수(멤버 or 확장)
- 오버로드 후보군 중 최적 선택(가장 구체적인 타입, 가시성, 우선순위 등)
- 선택된 함수의 시그니처로부터 결과 타입 결정
즉, +는 “바이트코드 레벨에서 바로 iadd 같은 명령”이 아닐 수 있다. Int 같은 primitive-like 타입일 때는 최종 생성 단계에서 최적화로 iadd가 나올 가능성이 크지만, 의미 분석 단계에서는 일단 “연산자 호출”이라는 의미로 확정하는 게 먼저다.
(E) 타입 추론(type inference)
sum처럼 파라미터 타입이 다 박혀 있으면 추론이 거의 없다. 하지만 Kotlin에서 흔히 쓰는 형태는 추론이 핵심이다.
- val x = foo()에서 x 타입은?
- fun f() = expr에서 반환 타입은?
- 제네릭 함수 호출에서 T는?
- 람다의 파라미터 타입은?
이때 컴파일러는 “제약(constraint)”을 모아 시스템을 풀어 타입을 결정한다. 타입 추론이 무거워지면 빌드가 체감상 느려지는 경우가 많다.
(F) 데이터플로우 분석(data-flow analysis) + 스마트캐스트
Kotlin의 null 안정성은 단순히 T? 체크만이 아니다.
- if (x != null) x.length가 되는 이유는
- 조건 분기를 따라가며 “여기서는 x가 null이 아니다”라는 사실을 데이터플로우로 기록
- 그 사실로 스마트캐스트 수행
또한 val/var, 캡처 여부, 오픈 프로퍼티 여부 같은 조건이 스마트캐스트 가능성을 좌우한다. 이 규칙들은 전부 frontend가 “프로그램 실행 흐름”을 부분적으로 모델링해서 판단한다.
(G) 진단(diagnostic) 생성: 에러/경고를 최종 산출
중간중간 “이건 오류다” 표식을 해두고, 특정 시점에 한꺼번에 에러/경고로 확정해서 사용자에게 보여주는 구조를 가진다. 이 분리는 IDE 분석(부분적/lazy)과도 관련이 있다.
K2란 무엇인가?
K2는 Kotlin 2.0을 의미하는 버전명이 아니라,
새로운 Kotlin 컴파일러 프론트엔드 아키텍처의 이름이다.
Kotlin 2.0부터 기본 프론트엔드가 K2 기반으로 전환되었다.
✔ 기존(K1)과의 차이
기존 프론트엔드는 오래된 구조였다.
K2는 완전히 재작성된 프론트엔드다.
핵심 특징:
- FIR(Frontend IR) 기반
- 더 빠른 분석
- 더 정확한 타입 추론
- 스마트캐스트 개선
- IDE와 컴파일러 분석 구조 통합
▶ FIR이란?
FIR은 Frontend IR의 약자다.
여기서 IR은 “중간표현”이다.
하지만 백엔드 IR과 다르다.
- FIR = 의미 분석용 내부 구조
- IR(backend IR) = 코드 생성용 중간 구조
즉,
5. 3단계: FIR → IR (FIR2IR) — 프론트엔드 결과를 백엔드 파이프라인으로 넘긴다
frontend가 “의미를 확정”하면, 그 결과를 백엔드가 먹을 수 있는 Kotlin IR로 변환한다. 여기서의 IR은 타깃(JVM/JS/Native) 간에 공유되는 설계 철학을 가진 중간표현이다.
이 순간부터는 Kotlin 언어의 많은 고수준 문법이 “표준화된 노드”로 바뀌어 있다.
- 함수 호출
- 분기
- 로컬 변수
- 클래스/필드
- try/catch, when, for 등(대부분은 이미 desugar 되었거나 곧 될 준비 상태)
6. 4단계: IR Lowering — “Kotlin 스러운 것”을 “기계가 뽑기 쉬운 것”으로 계속 낮춘다
여기가 “내부의 내부의 내부” 느낌이 가장 강한 구간이다. IR lowering은 한 번에 끝나는 변환이 아니라, **여러 패스(pass)**로 IR을 점점 단순화한다.
대표적으로 이런 류의 변환이 일어난다(항목 자체는 일반적 사실이지만, 각 변환의 정확한 패스 이름/순서는 버전별로 달라질 수 있어 여기서는 단정하지 않는다).
- 프로퍼티 접근을 get/set 호출로 정규화
- when/for 같은 고수준 제어문을 더 단순한 분기/루프로 변환
- suspend 함수(코루틴)를 상태머신(state machine) 형태로 변환
- “suspend = 마법”이 아니라, 결국 continuation을 인자로 받는 형태와 상태 분기로 풀리는 식으로 내려간다
- inline 함수 호출을 호출 지점에 복사(inline expansion)
- 이 과정이 디버깅/스택트레이스/바이트코드 크기에 직접 영향을 준다
- reified 타입 파라미터 처리
- default parameter(디폴트 파라미터) 처리
- $default 같은 보조 함수/마스크 인자 방식으로 떨어지는 형태가 흔하다(정확한 형태는 버전/옵션에 따라 달라질 수 있다)
이 lowering 단계에서 IR이 “거의 JVM 바이트코드로 직번역 가능한 형태”가 된다.
7. 5단계: JVM Backend — .class 생성과 @Metadata 삽입
마지막으로 JVM 백엔드는 .class 파일을 만든다.
- 클래스/메서드/필드 시그니처 생성
- 바이트코드 명령 생성
- 라인 넘버, 로컬 변수 테이블 등 디버그 정보 생성(옵션/빌드 타입 영향)
- Kotlin 메타데이터(@kotlin.Metadata) 생성 및 삽입
여기서 “JVM 백엔드가 어떤 라이브러리로 바이트코드를 찍냐” 같은 구현 디테일은 일반적으로 ASM 계열을 사용한다고 알려져 있지만, 이 글에서는 “특정 라이브러리 호출 구조”까지 단정하지 않는다(버전/내부 구조 변경 가능성이 있고, 글 목적상 핵심이 아니다).
8. fun sum(a: Int, b: Int) = a + b를 끝까지 추적 요약
이 한 줄은 내부에서 대략 이렇게 처리된다.
- 파싱: “함수 선언 + 바이너리 연산” 형태 트리 생성
- 프론트엔드 의미 분석(FIR 중심):
- a, b를 파라미터 심볼로 바인딩
- Int를 타입으로 해석
- a + b를 연산자 호출 후보군으로 확장하고 최종 호출을 resolve
- 결과 타입을 Int로 확정
- 문제 없으면 진단 없음
- FIR2IR: 의미가 확정된 트리를 IR로 변환
- IR lowering: 연산/호출/반환을 코드 생성 친화적으로 낮춤
- JVM 백엔드: .class 생성 + Kotlin 메타데이터 삽입
9. 안드로이드 개발자에게 이게 왜 중요하냐 (현실 포인트)
9-1) 빌드가 느릴 때, “어디가 느린지”가 달라진다
- 파싱이 느린 경우는 상대적으로 드물다
- 대부분은 frontend의 의미 분석/타입 추론/오버로드 해석/플러그인 처리/증분 컴파일 캐시 무효화에서 시간을 쓴다
9-2) KAPT/KSP/컴파일러 플러그인은 “컴파일러 단계”를 흔든다
- 어노테이션 처리(KAPT/KSP)는 소스/심볼/스텁 생성 등으로 컴파일 파이프라인과 결합되어 빌드 시간과 실패 양상이 달라진다
- Compose 같은 경우도 컴파일러 플러그인 개입이 있다(이 또한 “항상 어떤 내부 패스에서 어떻게”까지는 버전 종속이 강해서 단정하지 않는다)
9-3) 런타임 크래시가 아니라 “컴파일 타임 의미 규칙” 때문에 막힌다
- null 안정성, 스마트캐스트, 가시성, 오버로드 해석 실패 같은 건 전부 frontend가 잡는다
- 컴파일 에러는 “문법이 틀려서”보다 “의미가 모순돼서(타입/호출/스코프 규칙 위반)”가 훨씬 많다
'안드로이드' 카테고리의 다른 글
| 안드로이드 컴파일 과정 완전정리 ④ - D8/R8 란? (0) | 2026.02.21 |
|---|---|
| 안드로이드 컴파일 과정 완전정리 ③ - JVM 이란? (0) | 2026.02.20 |
| 안드로이드 컴파일 과정 완전정리 ① - Kotlin, D8/R8, DEX, ART 등 (0) | 2026.02.19 |
| [코틀린 Kotlin 스터디] 코틀린 기초 시작하기 (0) | 2022.01.08 |
| [코틀린 Kotlin 스터디] 안드로이드 개발 환경 세팅하기 (안드로이드 스튜디오와 자바 환경 변수) (0) | 2022.01.05 |
댓글