오픈소스 라이선스 고지 의무
안드로이드 앱을 개발할 때 다양한 오픈소스 라이브러리를 당연하게 활용하여 개발합니다. 오픈소스 사용에는 라이선스 고지라는 중요한 의무가 따르지만, 평소에 개발할 때는 크게 생각하지 않고 사용했던 것 같습니다.
하지만 오픈소스 라이선스 고지 의무를 소홀이 하게 된다면 법적 분쟁에 휘말리어나 앱이 스토어에서 내려가는 등의 일이 발생할 수도 있다고 합니다.
고지 의무의 핵심 내용
앱을 사용자에게 배포할 때는 다음 정보를 사용자가 쉽게 확인할 수 있도록 제공해야 합니다.
- 오픈소스 명칭 및 버전: 사용된 오픈소스의 정확한 이름과 버전 정보
- 저작권자 정보: 원 개발자 또는 저작권 보유자에 대한 정보
- 라이선스 명칭: 적용된 라이선스의 이름 (예: Apache License 2.0)
- 라이선스 사본: 사용자가 라이선스 전문을 확인할 수 있도록 사본 또는 원문 링크 포함
중요한 점은 내가 직접 사용한 라이브러리뿐만 아니라, 그 라이브러리가 의존하는 다른 모든 하위 라이브러리까지 고지 대상에 포함된다는 것입니다. 최종 배포자로서 앱에 포함된 모든 오픈소스에 대한 고지 해야 합니다.
라이선스 종류
라이선스 | 특징 | 소스코드 공개 의무 | 동일 라이선스 적용 의무 | 상업적 이용 |
GPL | 소스 코드를 공개하고, 수정한 소스 코드도 공개해야 하는 '전염성'이 강한 라이선스. 개발자들 사이에서 법적 효력이 강하다고 알려져 있어 주의가 필요함. | 있음 | 있음 | 가능 |
Apache License 2.0 | 특허 관련 안전장치가 있으며, 소스 코드 공개 의무가 없음. 아파치 소프트웨어 재단에서 자체적으로 만든 라이선스. | 없음 | 없음 | 가능 |
MIT License | 가장 간단하고 자유로운 라이선스 중 하나. 소스 코드 공개 의무가 없으며, 라이선스 및 저작권 고지만 필요. | 없음 | 없음 | 가능 |
BSD License | MIT 라이선스와 유사하게 자유도가 높은 라이선스. '2-Clause BSD'는 MIT와 거의 동일하며, '3-Clause BSD'는 원 저작자의 승인 없이 이름 사용 금지 조항이 추가됨. | 없음 | 없음 | 가능 |
MPL | 카피레프트와 퍼미시브의 중간 형태. 수정된 파일만 공개 의무가 있어, 파생 저작물 전체 소스 코드 공개는 요구하지 않음. | 수정된 파일에 한해 있음 | 수정된 파일에 한해 있음 | 가능 |
LGPL | GPL보다 소스 코드 공개 의무가 완화된 형태. 라이브러리 형태로 사용할 경우 앱의 소스 코드 전체를 공개할 필요는 없음. | 라이브러리 수정 시 해당 부분만 공개 | 라이브러리 수정 시 해당 부분만 적용 | 가능 |
Copyleft는 저작권(Copyright)과는 반대되는 개념으로, 파생 저작물에 대해서도 원 저작물과 동일한 자유를 적용해야 한다는 조건이 있습니다. Copyleft 라이선스가 적용된 소프트웨어를 사용하거나 수정하여 새로운 소프트웨어를 만들고 배포할 경우, 그 새로운 소프트웨어의 소스 코드 또한 Copyleft 라이선스로 공개해야 할 의무가 발생합니다.
Permissive 라이선스는 '관대한 라이선스'라고도 불리며, 소프트웨어의 사용, 수정, 배포에 최소한의 제한만을 가하는 오픈소스 라이선스 유형입니다. Copyleft 라이선스와 달리, Permissive 라이선스는 수정된 소프트웨어의 소스 코드를 반드시 공개할 필요가 없습니다.
실제로 자주 사용하는 앱에서도 설정을 잘 살펴보면 오픈소스 라이선스를 고지한 것을 확인할 수 있습니다.
사진의 순서대로 카카오톡, 유튜브, 오늘의 집 앱의 설정에서도 오픈소스 라이선스를 확인할 수 있었습니다.
오픈소스 라이선스 고지는 어떻게?
보통 사용하는 라이브러리는 Permissive 라이선스로 Apache License 2.0 인 것 같습니다. 소스코드 공개의 의무는 없지만 저작권 표시 및 라이선스 고지 의무는 지켜야 하므로 이러한 오픈소스 라이선스를 쉽게 정리하여 사용자에게 보여줄 수 있는 방법을 정리하였습니다.
사용된 라이브러리를 수동으로 찾아 정리하는 방법도 있지만, 매우 귀찮은 일이고, 정확하게 파악하면서 개발하는 것은 매우 어려운 일인 것 같습니다. 특히 라이브러리가 의존하는 다른 모든 하위 라이브러리까지 고지의 대상이기 때문에 더 어려울 것입니다.
구글에서는 오픈소스 라이브러리를 정리하여 쉽게 보여주기 위해 사용하는 oss-licenses-plugin을 제공하고 있습니다.
https://github.com/google/play-services-plugins/blob/main/oss-licenses-plugin/README.md
play-services-plugins/oss-licenses-plugin/README.md at main · google/play-services-plugins
Plugins to help with using Google Play services SDK. - google/play-services-plugins
github.com
oss-licenses-plugin을 사용하는 방법은 해당 repository README 문서와 다른 글에서도 쉽게 확인할 수 있을 것 같습니다.
하지만 현재 최신 버전인 plugin 버전 0.10.6, library 버전 17.0.0에서 발생하는 문제가 있었습니다.
안드로이드 15 (API 수준 35)를 타겟팅하는 경우 기본적으로 더 넓은 화면을 표시하는 edge-to-edge가 적용되는데,
사진에서 확인할 수 있듯이 targetSdk 34(왼쪽)에서는 잘 동작하지만, targetSdk 35(오른쪽)에서는 TopAppBar와 분리되어 스크롤되며, 하단의 navigationBar의 padding도 적용되지 않는 것을 확인할 수 있었습니다.
해당 레포지토리의 이슈에서도 많은 사람들이 비슷한 이슈를 겪고 있는 것을 알 수 있었습니다.
2025년 6월 25일에 다음 릴리즈에서 해결된다는 것 코멘트가 추가되었고, 다음 릴리즈 버전에서 edge-to-edge 이슈가 해결될 것 같습니다.
하지만, 당장 적용해야 할 부분에 문제가 있기 때문에 이번에는 다른 개발자들이 추천한 AboutLibraries 라이브러리를 사용하여 오픈소스 라이선스를 고지해 보았습니다.
AboutLibraries 적용해 보기
https://github.com/mikepenz/AboutLibraries
GitHub - mikepenz/AboutLibraries: AboutLibraries automatically collects all dependencies and licenses of any gradle project (Kot
AboutLibraries automatically collects all dependencies and licenses of any gradle project (Kotlin MultiPlatform), and provides easy to integrate UI components for Android and Compose Multiplatform ...
github.com
이 라이브러리에서는 앱에서 사용된 다양한 라이선스들을 정리하고 간편하게 사용자에게 보여줄 수 있도록 기능을 제공합니다.
oss-licenses-plugin의 경우는 액티비티를 호출하여 AppCompat Theme을 적용해야 했기 때문에 오픈소스 라이선스 고지 화면을 커스텀하기는 어려웠습니다. 또한, oss-licenses-plugin의 경우 디버그 버전에서는 라이선스 목록이 확인되지 않고 릴리즈 버전에서 확인할 수 있었습니다.
하지만 AboutLibraries는 Compose UI를 반영하여 안드로이드뿐만 아니라 KMP 환경에서도 사용할 수 있다고 합니다.
[versions]
aboutLibraries = "{latest-version}"
[libraries]
# Core module (required for accessing library data)
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
# Compose UI modules (choose one or both)
aboutlibraries-compose-core = { module = "com.mikepenz:aboutlibraries-compose-core", version.ref = "aboutLibraries" } # Common compose core
aboutlibraries-compose-m2 = { module = "com.mikepenz:aboutlibraries-compose", version.ref = "aboutLibraries" } # Material 2 UI
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" } # Material 3 UI
# Deprecated View-based UI module
aboutlibraries-view = { module = "com.mikepenz:aboutlibraries", version.ref = "aboutLibraries" }
[plugins]
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" }
version catalog (libs.versions.toml)에 최신 버전과 libraries, plugins에 추가합니다. 라이브러리의 경우는 필요한 Compose UI 모듈을 추가합니다. 저는 이번에 compose-core와 compose-m3를 추가하여 사용하였습니다.
// root: build.gradle.kts
plugins {
alias(libs.plugins.aboutLibraries) apply false
}
// app: build.gradle.kts
plugins {
alias(libs.plugins.aboutLibraries)
}
// core: build.gradle.kts
dependencies {
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
}
root project의 build.gradle과 app 모듈의 build.gradle에 plugin을 추가하고, UI에 연결할 곳에 라이브러리를 추가합니다.
Compose에서 사용하는 방법은 매우 간단합니다.
@Composable
fun OpenSourceScreen() {
val libraries by rememberLibraries(R.raw.aboutlibraries)
LibrariesContainer(libraries, Modifier.fillMaxSize())
}
안드로이드 스튜디오의 IDE에서 raw의 aboutlibraries.json 파일을 찾을 수 없기 때문에 res폴더에 aboutlibraries.json 파일을 임시로 생성합니다.
aboutLibraries를 사용하면 빌드할 때 자동으로 라이선스를 파악하고, aboutLibraries.json 파일을 app모듈에 만들며 merge과정에서 덮어씌워져 앱에 포함됩니다.
내부 코드 살펴보기
@Serializable
data class Libs constructor(
@SerialName("libraries") val libraries: ImmutableList<Library>,
@SerialName("licenses") val licenses: ImmutableSet<License>,
)
@Serializable
data class Library(
@SerialName("uniqueId") val uniqueId: String,
@SerialName("artifactVersion") val artifactVersion: String?,
@SerialName("name") val name: String,
@SerialName("description") val description: String?,
@SerialName("website") val website: String?,
@SerialName("developers") val developers: ImmutableList<Developer>,
@SerialName("organization") val organization: Organization?,
@SerialName("scm") val scm: Scm?,
@SerialName("licenses") val licenses: ImmutableSet<License> = persistentSetOf(),
@SerialName("funding") val funding: ImmutableSet<Funding> = persistentSetOf(),
@SerialName("tag") val tag: String? = null,
)
@Serializable
data class License(
@SerialName("name") val name: String,
@SerialName("url") val url: String?,
@SerialName("year") val year: String? = null,
@SerialName("spdxId") val spdxId: String? = null,
@SerialName("licenseContent") val licenseContent: String? = null,
@SerialName("hash") val hash: String
)
생성된 Json 파일에 정의된 대로 라이브러리와 라이선스가 data class로 정의되어 있습니다.
Libs의 클래스로 LibrariesContainer에 전달되어 Composable을 그립니다.
@Composable
fun LibrariesContainer(
libraries: Libs?,
modifier: Modifier = Modifier,
libraryModifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
showAuthor: Boolean = true,
showDescription: Boolean = false,
showVersion: Boolean = true,
showLicenseBadges: Boolean = true,
showFundingBadges: Boolean = false,
typography: Typography = MaterialTheme.typography,
colors: LibraryColors = LibraryDefaults.libraryColors(),
padding: LibraryPadding = LibraryDefaults.libraryPadding(),
dimensions: LibraryDimensions = LibraryDefaults.libraryDimensions(),
textStyles: LibraryTextStyles = LibraryDefaults.libraryTextStyles(),
shapes: LibraryShapes = LibraryDefaults.libraryShapes(),
onLibraryClick: ((Library) -> Unit)? = null,
onFundingClick: ((Funding) -> Unit)? = null,
name: @Composable BoxScope.(name: String) -> Unit = { DefaultLibraryName(it, textStyles, colors, typography) },
version: (@Composable BoxScope.(version: String) -> Unit)? = { version ->
if (showVersion) DefaultLibraryVersion(version, textStyles, colors, typography, padding, dimensions, shapes)
},
author: (@Composable BoxScope.(authors: String) -> Unit)? = { author ->
if (showAuthor && author.isNotBlank()) DefaultLibraryAuthor(author, textStyles, colors, typography)
},
description: (@Composable BoxScope.(description: String) -> Unit)? = { description ->
if (showDescription) DefaultLibraryDescription(description, textStyles, colors, typography)
},
license: (@Composable FlowRowScope.(license: License) -> Unit)? = { license ->
if (showLicenseBadges) DefaultLibraryLicense(license, textStyles, colors, padding, dimensions, shapes)
},
funding: (@Composable FlowRowScope.(funding: Funding) -> Unit)? = { funding ->
if (showFundingBadges) DefaultLibraryFunding(funding, textStyles, colors, padding, dimensions, shapes, onFundingClick)
},
actions: (@Composable FlowRowScope.(library: Library) -> Unit)? = null,
header: (LazyListScope.() -> Unit)? = null,
divider: (@Composable LazyItemScope.() -> Unit)? = null,
footer: (LazyListScope.() -> Unit)? = null,
licenseDialogBody: (@Composable (Library, Modifier) -> Unit)? = { library, modifier -> LicenseDialogBody(library = library, colors = colors, modifier = modifier) },
licenseDialogConfirmText: String = "OK",
)
내부 코드를 살펴보면 다양하게 커스텀할 수 있도록 Composable이 설계되어 있는 것을 알 수 있습니다.
@Composable
fun LibraryDefaults.libraryColors(
backgroundColor: Color = MaterialTheme.colorScheme.background,
contentColor: Color = contentColorFor(backgroundColor),
versionChipColors: ChipColors = chipColors(containerColor = backgroundColor),
licenseChipColors: ChipColors = chipColors(),
fundingChipColors: ChipColors = chipColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = contentColorFor(MaterialTheme.colorScheme.secondary),
),
dialogConfirmButtonColor: Color = MaterialTheme.colorScheme.primary,
)
가장 흔하게 커스텀할 수 있는 부분은 컬러 부분입니다. Material Component인 Chip을 사용하면서 기본값으로 MaterialTheme에 설정된 값으로 구성되는 것을 확인할 수 있었습니다.
@Composable
fun LibrariesContainer(
...
) {
val libs = libraries?.libraries ?: persistentListOf()
val openDialog = remember { mutableStateOf<Library?>(null) }
LibrariesScaffold(
...
onLibraryClick = { library ->
if (onLibraryClick != null) {
onLibraryClick(library)
true
} else {
val license = library.licenses.firstOrNull()
if (!license?.htmlReadyLicenseContent.isNullOrBlank()) {
openDialog.value = library
true
} else false
}
},
)
val library = openDialog.value
if (library != null && licenseDialogBody != null) {
LicenseDialog(
library = library,
colors = colors,
padding = padding,
confirmText = licenseDialogConfirmText,
body = licenseDialogBody
) {
openDialog.value = null
}
}
}
LibrariesContainer 내부에서는 LibrariesScaffold와 클릭 시 다이얼로그로 구성되며, Click 이벤트 발생 시 다이얼로그를 여는 로직이 포함되어 있습니다.
@Composable
fun LibrariesScaffold(
...
onLibraryClick: ((Library) -> Boolean)? = { false },
) {
val uriHandler = LocalUriHandler.current
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(dimensions.itemSpacing),
state = lazyListState,
contentPadding = contentPadding
) {
header?.invoke(this)
itemsIndexed(libraries) { index, library ->
LibraryScaffoldLayout(
modifier = libraryModifier.clickable {
val license = library.licenses.firstOrNull()
val handled = onLibraryClick?.invoke(library) ?: false
if (!handled && !license?.url.isNullOrBlank()) {
license.url?.also {
try {
uriHandler.openUri(it)
} catch (t: Throwable) {
println("Failed to open url: $it // ${t.message}")
}
}
}
},
...
)
if (divider != null && index < libraries.lastIndex) {
divider.invoke(this)
}
}
footer?.invoke(this)
}
}
LibrariesScaffold에서는 LazyColumn을 통해 라이브러리의 아이템을 보여주고 있습니다. 클릭이벤트를 전달받아 Modifier에 연결한 것을 확인할 수 있습니다.
커스텀하여 사용하기
@Composable
fun OpenSourceScreen(
paddingValues: PaddingValues,
popBackStack: () -> Unit,
) {
val libraries by rememberLibraries(R.raw.aboutlibraries)
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.navigationBarsPadding()
) {
AppLinkAlarmTopAppBar(
modifier = Modifier.fillMaxWidth(),
title = stringResource(R.string.feature_setting_text_open_source_license),
navigationIcon = {
AppLinkAlarmIconButton(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.feature_setting_icon_description_close),
onClick = popBackStack
)
},
actions = {}
)
LibrariesContainer(libraries, Modifier.fillMaxSize())
}
}
저는 OpenSourceScreen을 추가하여 사용하던 compose navigation에 연결하였습니다.
LibrariesContainer는 LazyColumn을 포함한 목록들만 포함되므로 저는 TopAppBar를 추가하여 Title과 navigation 아이콘을 추가하였습니다.
Color는 사용하는 MaterialTheme에 지정된 색을 그대로 사용해도 괜찮을 것 같아서 특별히 값을 주지 않고 기본값을 사용하도록 하였습니다.
실제로 앱에 적용한 결과 라이트 모드, 다크 모드 모두 잘 적용되는 것을 확인할 수 있었습니다.
오픈소스 라이선스 목록을 직접 정리하여 사용하기
oss-licenses-plugin과 aboutLibraries 모두 의존성을 스캔하여 라이선스 정보를 수집하는데, 의존성을 병합하는 과정에서 중복되거나, 의존성 구조의 문제로 라이브러리가 중복되는 경우가 있는 것 같습니다.
특히 Compose에서 주로 중복되는 것이 발견되는데, oss-licenses-plugin에서도 이와 관련된 이슈를 확인할 수 있었습니다.
어떤 부분이 문제인지 정확하게 알아볼 수는 없었지만, 오픈소스 라이선스 목록을 수동으로 관리할 수 있는 방법도 aboutLibraries에 존재합니다.
// build.gradle.kts
aboutLibraries {
android {
// Disable automatic task registration for Android builds
registerAndroidTasks = false
}
export {
// Define the output path for manual generation
// Adjust the path based on your project structure (e.g., composeResources, Android res/raw)
outputFile = file("src/commonMain/composeResources/files/aboutlibraries.json")
// Optionally specify the variant for export
// variant = "release"
}
}
라이브러리의 설명에서는 app 모듈 build.gradle에 안드로이드 task를 등록하지 않고, 빌드시 자동으로 파일을 만들지 않도록 지정합니다.
# Generate using the configured location and variant
./gradlew :app:exportLibraryDefinitions
# Generate providing a custom path and variant override
./gradlew :app:exportLibraryDefinitions -PaboutLibraries.outputFile=src/main/res/raw/libraries.json -PaboutLibraries.exportVariant=release
그리고 터미널에서 gradle task를 직접 실행시키며, json 파일을 생성하고, 직접 수정하여 수동으로 관리할 수 있다고 합니다.
하지만 현재 최신 버전인 12.2.3 버전 기준으로 registerAndroidTasks = false를 설정해도 자동으로 json 파일을 생성하는 것을 확인할 수 있었습니다. 관련 이슈를 찾아본 결과, 플러그인 내부에서 설정을 읽을 때 사용자의 빌드 스크립트가 실행되지 않아 기본값인 true로 고정되는 문제인 것 같습니다. 안드로이드 task 로직을 분리하여 문제를 해결한 것 같습니다.
플러그인만 프리 릴리즈인 13.0.0-pre-a02를 사용하여 수동으로 처리할 경우 문제를 해결할 수 있을 것 같습니다.
주의 사항
This library uses all compile time dependencies (and their sub dependencies) as defined in the build.gradle file. This could lead to dependencies which are only used during compilation (and not actually distributed in the app) to be listed or missing in the attribution screen. It might also fail to identify licenses if the dependencies do not define it properly in their pom.xml file.
Careful optimisation and review of all licenses is recommended to really include all required dependencies. The use of the gradle commands like findLibraries can help doing this.
It is also important that native sub dependencies can not be resolved automatically as they are not included via gradle. Additional dependencies can be provided via the plugins API to extend and provide any additional details.
aboutLibraries의 README 문서에서는 다음과 같이 설명합니다. 덧붙여서 정리하면 아래와 같습니다.
- 프로젝트의 build.gradle에 명시된 모든 컴파일 시점 의존성과 그 하위 의존성을 대상으로 삼는다.
- 실제 앱에 들어가지 않은 컴파일 전용 의존성(테스트 라이브러리)이 포함될 수 있고, 반대로 배포 중에 포함되어야 하지만 build.gradle에 정의되어 있지 않아 누락될 수 있다.
- 각 의존성이 Maven 중앙 저장소용 POM에 라이선스 정보를 정확히 기재하지 않은 경우 인식하지 못할 수 있다.
- 의존성 트리를 최적화하고, 모든 라이선스를 하나하나 검토하는 과정을 반드시 거칠 것을 권장한다.
- ./gradlew findLibraries 커맨드로 실제 라이브러리가 포함되는지 파악하고, 누락된 의존성을 찾는 데 도움이 된다.
- 네이티브 코드(C/C++ 라이브러리)로 된 하위 의존성은 Gradle 메타데이터에 포함되지 않기 때문에 aboutLibraries가 추적할 수 없다.
- 이 문제를 보완하기 위해, 플러그인의 확장 API를 이용해 누락된 라이브러리 정보를 수동으로 추가하거나 메타데이터를 보강할 수 있다.
마무리
매번 개발할 때마다 느끼는 것이지만, 항상 이슈가 발생하는 것 같습니다. 변화에 따라 빠르게 대응하는 많은 개발자들을 확인하면서 개발은 매일매일 공부해야 한다는 것을 다시 한번 크게 느낄 수 있었던 것 같습니다.
'Project > AppLink Alarm' 카테고리의 다른 글
WorkManager Hilt 적용하기 (2) | 2025.07.13 |
---|---|
구글 플레이 정기 결제 V8 구현하기 (3) | 2025.07.08 |
Android AdMob Native Ads Compose와 함께 적용하기 (2) | 2025.06.26 |