Android Koin 적용하기: Compose와 ViewModel 의존성 주입

이 글은 2022년에 작성한 내용을 2026년 기준으로 전면 개정한 글입니다. 기존 URL은 유지하면서 스크린샷 위주의 Koin 3.2 설정 노트를 최신 Android·Compose 환경에서 실행할 수 있는 의존성 주입 가이드로 다시 작성했습니다.

Android Koin 적용하기: Compose와 ViewModel 의존성 주입

안드로이드 앱이 커지면 화면마다 필요한 객체를 직접 만들지 않고 외부에서 주입받는 구조가 편해집니다. Koin은 Kotlin 전용 의존성 주입 라이브러리로, 복잡한 애너테이션 처리 없이 DSL로 모듈을 선언하고 런타임에 객체를 제공합니다. 이 글에서는 최신 Android에서 Koin을 설정하고 ViewModel, Jetpack Compose, 테스트까지 한 흐름으로 적용합니다.

2026년 현재 Koin 4.2 계열은 BOM과 컴파일러 플러그인을 권장하고, 안드로이드 프로젝트는 version catalog를 기본 빌드 설정으로 사용합니다. 2022년 원문이 다루던 Gradle 파일 형식 변경 문제는 더 이상 별개의 이슈가 아니며, 최신 권장 구조에서 처음부터 설정하는 방법을 다룹니다.

먼저 보는 핵심
Koin BOM으로 라이브러리 버전을 맞추고, Repository는 singleOf, ViewModel은 viewModelOf로 선언합니다. Compose에서는 koinViewModel()로 ViewModel을 받고 collectAsStateWithLifecycle()로 상태를 수집하는 흐름이 기본입니다.

핵심 질문 이 글의 답
최신 Android에서 Koin은 어떻게 추가하나? version catalog에 Koin BOM과 아티팩트를 선언
ViewModel은 어떻게 주입하나? koinViewModel()로 Compose에서, by viewModel()로 Activity/Fragment에서
Compose 화면에 의존성을 어떻게 넣나? koinInject() 또는 ViewModel을 통해 노출
Koin Annotations는 언제 쓰나? 모듈 선언을 줄이고 컴파일 타임 검증이 필요할 때
테스트에서는 어떻게 다루나? KoinTest와 모듈 재정의로 가짜 의존성 주입

Koin이 해결하는 문제

의존성 주입이 없으면 화면이나 ViewModel이 필요한 Repository, API 클라이언트, 데이터베이스 객체를 직접 생성합니다. 이 구조는 다음 문제를 만듭니다.

  • 객체 생성 비용이 중복 발생한다.
  • 실제 구현을 테스트용 가짜로 바꾸기 어렵다.
  • 화면이 프레임워크 클래스에 강하게 결합된다.

Koin은 모듈이라는 선언 블록에서 객체를 만드는 방법을 정의하고, 런타임에 필요한 곳에 제공합니다. Dagger Hilt와 달리 애너테이션 프로세서 없이 Kotlin DSL로 동작하므로 빌드 설정이 단순하고 학습 곡선이 완만합니다.

비교 항목 Koin Dagger Hilt
동작 시점 런타임 컴파일 타임
설정 복잡도 낮음 상대적으로 높음
빌드 속도 영향 적음 KSP/KAPT 처리 추가
멀티플랫폼 지원 안드로이드 중심
학습 난이도 낮음 중간~높음

규모가 크고 엄격한 의존성 그래프 검증이 필요하면 Hilt도 좋은 선택입니다. 빠른 도입과 읽기 쉬운 설정이 중요하면 Koin이 적합합니다.


최소 요구 환경

이 글의 코드는 다음 환경을 기준으로 합니다.

항목 기준
AGP 8.x 이상
Kotlin 2.x
minSdk 21 이상(Compose 사용 시 권장 21 이상)
Koin 4.2.x
빌드 파일 Kotlin DSL(build.gradle.kts)과 version catalog

정확한 버전은 글을 읽는 시점의 Koin 공식 문서에서 다시 확인하세요. 이 글은 패턴과 API 형태를 기준으로 작성했습니다.


version catalog로 의존성 추가

최신 안드로이드 프로젝트는 gradle/libs.versions.toml에 버전과 라이브러리를 모아 관리합니다. Koin은 BOM으로 버전을 통일하고, 필요한 아티팩트만 선택해 추가합니다.

[versions]
koinBom = "4.2.0"

[libraries]
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koinBom" }
koin-core = { group = "io.insert-koin", name = "koin-core" }
koin-android = { group = "io.insert-koin", name = "koin-android" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose" }

앱 모듈 build.gradle.kts에서 BOM과 필요한 의존성을 추가합니다.

dependencies {
    implementation(platform(libs.koin.bom))
    implementation(libs.koin.core)
    implementation(libs.koin.android)
    implementation(libs.koin.androidx.compose)
}
흔한 실수 해결
BOM 없이 버전만 넣음 BOM으로 버전을 통일해 아티팩트 호환 유지
Compose 아티팩트 빠짐 koinViewModel() 등 Compose API 사용 불가

원문에서 다루던 $koin_version 변수를 직접 선언하던 방식은 더 이상 권장하지 않습니다. version catalog가 단일 버전 관리 지점입니다.


도메인과 데이터 계층 정의

예제는 간단한 메모 저장 앱입니다. 데이터 계층과 도메인 모델을 먼저 정의합니다.

data class Memo(
    val id: Long,
    val content: String,
    val createdAt: Long
)

interface MemoRepository {
    suspend fun getAll(): List<Memo>
    suspend fun save(content: String): Memo
}

class InMemoryMemoRepository : MemoRepository {
    private val items = mutableListOf<Memo>()
    private var nextId = 1L

    override suspend fun getAll(): List<Memo> = items.toList()

    override suspend fun save(content: String): Memo {
        val memo = Memo(id = nextId++, content = content, createdAt = System.currentTimeMillis())
        items.add(memo)
        return memo
    }
}

인터페이스와 구현을 분리하면 Koin 모듈에서 구현체만 바꿔 끼울 수 있고, 테스트에서 가짜 구현을 주입하기 쉽습니다.


Koin 모듈 선언

Koin 모듈은 객체를 만드는 방법을 선언하는 블록입니다. single은 앱 전체에서 하나, factory는 요청마다 새로 만듭니다.

import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module

val appModule = module {
    singleOf(::InMemoryMemoRepository) bind MemoRepository::class
}

singleOf(::InMemoryMemoRepository)는 생성자를 그대로 사용해 싱글톤을 만듭니다. bind는 구현체를 인터페이스 타입으로도 조회할 수 있게 합니다. 과거 원문처럼 { InMemoryMemoRepository() } 형태로 직접 작성할 수도 있지만, 생성자 참조가 최신 권장 형태입니다.

한정자 생명 주기 적합한 대상
singleOf 앱 전체 하나 Repository, API 클라이언트, 데이터베이스
factoryOf 주입마다 새 객체 매 요청마다 독립 상태가 필요한 객체
scopeOf 정의된 범위 내 Activity, 로그인 세션 등 한정된 생명 주기

ViewModel 모듈

ViewModel은 Koin 전용 DSL로 선언합니다. 안드로이드 프레임워크가 ViewModel 생명 주기를 관리하므로 viewModelOf를 사용합니다.

import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module

val viewModelModule = module {
    viewModelOf(::MemoViewModel)
}

ViewModel은 주입받은 Repository를 사용해 상태를 노출합니다.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class MemoViewModel(
    private val repository: MemoRepository
) : ViewModel() {

    private val _memos = MutableStateFlow<List<Memo>>(emptyList())
    val memos: StateFlow<List<Memo>> = _memos.asStateFlow()

    init {
        loadMemos()
    }

    private fun loadMemos() {
        viewModelScope.launch {
            _memos.value = repository.getAll()
        }
    }

    fun addMemo(content: String) {
        viewModelScope.launch {
            repository.save(content)
            _memos.value = repository.getAll()
        }
    }
}

ViewModel에 직접 안드로이드 프레임워크 객체(Context, View)를 넘기지 마세요. 데이터 계층 의존성만 주입하면 테스트와 화면 회전 모두 안전합니다.


Application에서 Koin 시작

앱 시작 시 Koin을 초기화합니다. 안드로이드 컨텍스트를 사용하는 androidContext()와 로거 설정을 포함합니다.

import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.core.logger.Level

class MemoApp : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger(Level.ERROR)
            androidContext(this@MemoApp)
            modules(appModule, viewModelModule)
        }
    }
}

AndroidManifest.xml에서 Application 클래스를 지정합니다.

<application
    android:name=".MemoApp"
    ... >
    ...
</application>
설정 항목 목적
androidContext() 안드로이드 의존 Koin 기능에 컨텍스트 제공
androidLogger(Level.ERROR) 프로덕션에서 과도한 로그 방지
modules(...) 앱에서 사용할 전체 모듈 등록

Jetpack Compose에서 ViewModel 주입

Compose 화면에서는 koinViewModel()로 ViewModel을 가져옵니다. 이 함수는 내부적으로 ViewModelStoreOwner와 생명 주기를 처리합니다.

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.koin.androidx.compose.koinViewModel

@Composable
fun MemoScreen(
    viewModel: MemoViewModel = koinViewModel()
) {
    val memos by viewModel.memos.collectAsStateWithLifecycle()
    var input by remember { mutableStateOf("") }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        OutlinedTextField(
            value = input,
            onValueChange = { input = it },
            label = { Text("메모 입력") },
            modifier = Modifier.fillMaxSize().weight(1f)
        )
        Button(
            onClick = {
                if (input.isNotBlank()) {
                    viewModel.addMemo(input)
                    input = ""
                }
            },
            modifier = Modifier.align(Alignment.End)
        ) {
            Text("저장")
        }
        LazyColumn(
            modifier = Modifier.fillMaxSize().weight(2f),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(memos, key = { it.id }) { memo ->
                Text("[${memo.id}] ${memo.content}")
            }
        }
    }
}

ViewModel이 아닌 일반 객체를 Compose에서 직접 받을 때는 koinInject()를 씁니다. 다만 상태를 다루는 객체는 ViewModel로 감싸는 것이 화면 회전과 설정 변경에 안전합니다.

Compose 함수 용도
koinViewModel() ViewModel 조회, 생명 주기 연결
koinInject() 일반 싱글톤·팩토리 객체 직접 주입
rememberKoinInject() 기억된 컴포지션 범위 내 조회

Activity/Fragment에서 ViewModel 주입

Compose를 사용하지 않는 화면에서는 위임 프로퍼티로 ViewModel을 가져옵니다.

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.activity.viewModels
import org.koin.androidx.viewmodel.ext.android.viewModel

class MemoActivity : AppCompatActivity() {

    private val viewModel: MemoViewModel by viewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 관찰과 화면 갱신은 DataBinding이나 View 바인딩으로 연결
    }
}

by viewModels()가 아니라 Koin의 by viewModel()임에 주의하세요. Koin이 모듈에서 ViewModel을 생성하고 프레임워크가 생명 주기를 관리합니다.


컴파일 타임 검증은 선택적으로 추가

규모가 커지면 모듈 선언이 길어집니다. Koin 4.2 계열은 BOM과 함께 Koin Compiler Plugin을 권장합니다. 기존 koin-ksp-compiler는 더 이상 새 프로젝트의 기본 선택이 아니므로, 공식 마이그레이션 문서를 확인한 뒤 도입하세요. 작은 앱은 아래처럼 DSL 모듈만으로도 충분합니다.

컴파일러 플러그인은 Kotlin과 Koin 버전 호환 조건이 있으므로 버전 번호를 복사하기보다 Koin 공식 설정 문서의 호환성 표를 기준으로 적용해야 합니다.

비교 DSL 모듈 Compiler Plugin
선언 위치 중앙 모듈 블록 각 클래스
검증 시점 주로 런타임 컴파일 타임 안전성 보강
적합한 경우 빠른 도입, 작은 프로젝트 대규모, 모듈 분산

테스트에서 Koin 다루기

테스트는 KoinTest를 상속받아 모듈을 재정의하거나 별도로 시작합니다. 실제 Repository 대신 가짜를 주입해 ViewModel 단위 테스트를 작성합니다.

import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.inject
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class MemoViewModelTest : KoinTest {

    private val viewModel: MemoViewModel by inject()

    @Before
    fun setUp() {
        startKoin {
            modules(
                module {
                    single<MemoRepository> { FakeMemoRepository() }
                    viewModel { MemoViewModel(get()) }
                }
            )
        }
    }

    @After
    fun tearDown() {
        stopKoin()
    }

    @Test
    fun 메모를_저장하면_목록에_추가된다() = runTest {
        viewModel.addMemo("테스트 메모")
        assertEquals(1, viewModel.memos.value.size)
        assertTrue(viewModel.memos.value.any { it.content == "테스트 메모" })
    }
}

private class FakeMemoRepository : MemoRepository {
    private val items = mutableListOf<Memo>()
    private var nextId = 1L

    override suspend fun getAll(): List<Memo> = items.toList()

    override suspend fun save(content: String): Memo {
        val memo = Memo(nextId++, content, 0L)
        items.add(memo)
        return memo
    }
}
테스트 패턴 목적
KoinTest + by inject() Koin 컨테이너에서 객체 직접 조회
모듈 재정의 실제 구현을 가짜로 교체
stopKoin() 테스트 간 컨테이너 격리

테스트에서 안드로이드 프레임워크 의존 ViewModel을 실행하려면 Robolectric이나 androidx.test 환경이 필요할 수 있습니다. 순수 로직은 프레임워크 없이 검증하는 편이 빠릅니다.


흔한 문제와 해결

증상 원인 해결
NoBeanDefFoundException 모듈에 정의가 없거나 등록 누락 modules(...)에 해당 모듈 포함 확인
ViewModel이 회전마다 재생성 viewModelOf가 아닌 factoryOf 사용 ViewModel은 viewModelOf로 선언
Compose에서 주입 실패 Compose 아티팩트 누락 koin-androidx-compose 의존성 확인
컴파일러 플러그인 적용 오류 Kotlin·Koin 호환 버전 불일치 공식 호환성 표와 BOM 버전 확인
테스트 컨테이너 누수 stopKoin() 누락 @After에서 컨테이너 종료

확인 항목

앱을 실행한 뒤 다음을 점검합니다.

확인 항목 기대 결과
앱 시작 Koin 초기화 로그 없이 정상 실행
첫 화면 ViewModel이 빈 메모 목록 표시
메모 저장 새 항목이 목록에 추가
화면 회전 메모 목록 유지, 중복 생성 없음
단위 테스트 가짜 Repository로 통과
메모리 프로파일 Repository가 단일 인스턴스로 유지

마무리

Koin은 최신 Android·Compose 환경에서 가장 빠르게 적용할 수 있는 의존성 주입 라이브러리 중 하나입니다. version catalog로 의존성을 통일하고, DSL 모듈로 객체를 선언한 뒤, ViewModel과 Compose API로 화면에 주입하는 흐름을 기본으로 잡으면 됩니다.

프로젝트가 커지면 Koin Annotations로 모듈 선언을 분산시키고 컴파일 타임 검증을 보탤 수 있습니다. 중요한 것은 인터페이스와 구현을 분리해 두면 실제 앱과 테스트 모두에서 구현체를 자유롭게 교체할 수 있다는 점입니다.


참고 자료

댓글

이 블로그의 인기 게시물

React에서 Socket.IO Client 연결하기: CORS와 useEffect 정리

Xamarin WebView 예제를 .NET MAUI로 다시 만들기