본문 바로가기
안드로이드

안드로이드 컴파일 과정 완전정리 ② - 코틀린 컴파일러란?

by kkomaeng 2026. 2. 19.

이전 글

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 데몬과의 차이

많이 헷갈리는 부분이다.

둘 다 “데몬”이지만 역할이 완전히 다르다.

구분Gradle 데몬Kotlin 데몬
목적 전체 빌드 관리 Kotlin 코드 컴파일
실행 주체 Gradle Kotlin 컴파일러
담당 범위 태스크 실행, 의존성 해석, 빌드 스케줄링 .kt → .class 변환
항상 존재? 보통 항상 켜짐 Kotlin 컴파일 시 필요할 때 실행

정리하면:

  • Gradle 데몬은 빌드 관리자
  • Kotlin 데몬은 Kotlin 전용 컴파일 서버

빌드 흐름은 대략 이렇게 된다:

 
Gradle 데몬 ↓ (compileKotlin 태스크 실행) Kotlin 데몬 ↓ .class 생성

즉, 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개 덩어리로 나누면 이렇다.

  1. Parsing(구문 분석): 텍스트를 문법 트리로 만든다
  2. Frontend(의미 분석): 타입/호출/오버로드/제네릭 추론/데이터플로우/널 안정성 등 “무엇이 무엇인지”를 확정한다
  3. IR(중간표현) 생성 + Lowering: 고수준 Kotlin 개념을 점점 “코드 생성 가능한 형태”로 낮춘다
  4. Backend(코드 생성): JVM .class를 만든다

여기서 “타입체크/널 검사”는 2) frontend의 하위 단계로 촘촘하게 분해된다. 그리고 이게 컴파일러의 대부분이다.

더보기

컴파일러에서의 프론트엔드 / 백엔드는 무슨 의미인가?

웹 개발에서의 프론트엔드/백엔드와는 완전히 다른 의미다.

여기서 말하는 건 컴파일 단계의 분리 구조다.


✔ 프론트엔드(Frontend)

역할:

소스코드를 “이해”한다.

하는 일:

  • 파싱
  • 타입 검사
  • 오버로드 해석
  • 제네릭 추론
  • null 안정성 검사
  • 데이터플로우 분석
  • 에러/경고 생성

즉, 언어 의미(semantic)를 확정하는 단계다.


✔ 백엔드(Backend)

역할:

확정된 의미를 실제 타깃 코드로 만든다.

하는 일:

  • IR lowering
  • 바이트코드 생성
  • .class 파일 생성

▶ 왜 굳이 나누는가?

이 구조 덕분에:

  • 같은 프론트엔드를 써서
  • 다른 타깃(JVM, JS, Native)으로 출력할 수 있다.

즉:

 
Frontend (공통) ↓ IR ↓ JVM Backend JS Backend Native Backend

웹 프론트엔드/백엔드 개념과는 무관하다.


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) = 코드 생성용 중간 구조

즉,

 
소스코드 ↓ FIR (프론트엔드 분석용) ↓ 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를 끝까지 추적 요약

이 한 줄은 내부에서 대략 이렇게 처리된다.

  1. 파싱: “함수 선언 + 바이너리 연산” 형태 트리 생성
  2. 프론트엔드 의미 분석(FIR 중심):
    • a, b를 파라미터 심볼로 바인딩
    • Int를 타입으로 해석
    • a + b를 연산자 호출 후보군으로 확장하고 최종 호출을 resolve
    • 결과 타입을 Int로 확정
    • 문제 없으면 진단 없음
  3. FIR2IR: 의미가 확정된 트리를 IR로 변환
  4. IR lowering: 연산/호출/반환을 코드 생성 친화적으로 낮춤
  5. JVM 백엔드: .class 생성 + Kotlin 메타데이터 삽입

9. 안드로이드 개발자에게 이게 왜 중요하냐 (현실 포인트)

9-1) 빌드가 느릴 때, “어디가 느린지”가 달라진다

  • 파싱이 느린 경우는 상대적으로 드물다
  • 대부분은 frontend의 의미 분석/타입 추론/오버로드 해석/플러그인 처리/증분 컴파일 캐시 무효화에서 시간을 쓴다

9-2) KAPT/KSP/컴파일러 플러그인은 “컴파일러 단계”를 흔든다

  • 어노테이션 처리(KAPT/KSP)는 소스/심볼/스텁 생성 등으로 컴파일 파이프라인과 결합되어 빌드 시간과 실패 양상이 달라진다
  • Compose 같은 경우도 컴파일러 플러그인 개입이 있다(이 또한 “항상 어떤 내부 패스에서 어떻게”까지는 버전 종속이 강해서 단정하지 않는다)

9-3) 런타임 크래시가 아니라 “컴파일 타임 의미 규칙” 때문에 막힌다

  • null 안정성, 스마트캐스트, 가시성, 오버로드 해석 실패 같은 건 전부 frontend가 잡는다
  • 컴파일 에러는 “문법이 틀려서”보다 “의미가 모순돼서(타입/호출/스코프 규칙 위반)”가 훨씬 많다

댓글