Kotlin 데스크톱 앱을 Windows·Linux·macOS에 배포하며 만난 문제들

Compose Multiplatform으로 만든 데이터 마이그레이션 도구는 개발 환경에서 세 운영체제 모두 같은 UI와 기능을 제공했습니다. 공통 Kotlin 코드가 동작한다는 사실만 보면 배포도 하나의 Gradle 작업으로 끝날 것처럼 보였지만, 사용자가 실제로 설치할 파일을 만드는 순간부터 문제의 성격이 달라졌습니다.

처음에는 개발 머신에서 패키징 작업을 실행해 DMG, MSI, DEB 파일을 만들었습니다. 애플리케이션을 직접 실행할 때는 보이지 않던 serializer 오류가 최적화된 패키지에서 나타났고, 운영체제마다 로그와 설정을 저장해야 하는 위치도 달랐습니다.

가장 곤란한 점은 한 플랫폼에서 만든 산출물만 확인하고 릴리스를 완료할 수 없다는 것이었습니다. 설치 도구, 포함된 runtime, architecture와 서명 절차가 모두 대상 운영체제에 묶여 있었습니다. 그래서 배포 파일을 빌드 결과물이 아니라 별도로 설치하고 검증해야 하는 제품으로 다루기 시작했습니다.

개발 실행과 배포 실행은 같은 프로그램이 아니었다

IDE에서 실행할 때는 개발용 classpath에 모든 라이브러리와 metadata가 남아 있습니다. 반면 배포 빌드는 필요한 runtime module을 추리고 ProGuard 최적화를 적용합니다. 소스 코드가 같더라도 실제 사용자가 실행하는 구성은 달라질 수밖에 없습니다.

처음 확인한 실패는 reflection으로만 참조되는 serializer가 최적화 과정에서 제거되는 문제였습니다. 컴파일과 패키지 생성은 성공했지만 특정 데이터를 읽는 시점에 필요한 serializer를 찾지 못했습니다. 개발 실행만 확인했다면 코드 오류가 아니라 사용자 환경의 문제처럼 보일 수 있는 상황이었습니다.

파일 경로도 비슷했습니다. 설치 디렉터리 아래에 설정이나 로그를 쓰면 운영체제의 권한 정책에 막히거나 업데이트 과정에서 파일이 사라질 수 있습니다. 공백과 한글, 조합형 Unicode가 포함된 경로에서는 단순한 문자열 연결과 파일명 정규화 가정도 깨졌습니다.

공통 코드와 분리한 패키징 설정

애플리케이션 코드는 플랫폼에 상관없이 같은 기능을 제공하되, package format과 runtime 구성은 배포 설정에서 명시했습니다. DMG, MSI, DEB를 하나의 목록에 선언해 지원 범위를 코드로 남기고, 사용자가 별도 JRE를 설치하지 않아도 되도록 최소 runtime을 포함했습니다.

compose.desktop {
  application {
    mainClass = "com.example.migration.MainKt"
    nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) }
  }
}

runtime을 포함하면 설치 파일은 커지지만 실행 환경을 통제할 수 있습니다. 사용자의 JRE 버전과 module 구성에 의존하는 fat JAR보다 재현 가능한 출발점이 됩니다. 대신 네트워크, 파일, 직렬화처럼 애플리케이션이 실제 사용하는 module이 jlink 결과에 모두 포함됐는지 설치본으로 확인해야 합니다.

공통 코드에 if (windows) 같은 분기를 흩뿌리지 않은 것도 같은 이유입니다. 운영체제 차이는 packaging과 filesystem adapter 경계에 모았습니다. 애플리케이션 로직은 경로의 의미만 요청하고, 실제 저장 위치는 플랫폼 규칙에 맞는 구현이 반환하도록 했습니다.

ProGuard 오류는 전체 제외가 아니라 필요한 근거부터 찾았다

최적화 후 실행 오류가 발생하면 가장 쉬운 대응은 관련 패키지를 통째로 keep하는 것입니다. 이 방법은 당장 오류를 없앨 수 있지만 어떤 metadata가 필요했는지 알 수 없고, 패키지 크기와 난독화 효과도 함께 잃습니다.

그래서 패키징된 앱의 stack trace와 mapping을 기준으로 제거된 serializer와 metadata를 확인했습니다. reflection과 serialization에 실제로 필요한 클래스만 keep하고 다시 패키징했습니다. 수정의 성공 여부도 개발 실행이 아니라 동일한 최적화 옵션을 적용한 설치본에서 판단했습니다.

이 과정에서는 “패키지 생성 성공”과 “애플리케이션 실행 가능”을 분리했습니다. Gradle task가 끝났다는 사실은 installer 파일이 생겼다는 뜻일 뿐입니다. 앱이 시작되고 설정을 읽으며 실제 직렬화 경로를 통과해야 비로소 해당 플랫폼의 산출물이 유효합니다.

각 운영체제가 자기 설치 파일을 만들게 했다

한 운영체제에서 세 플랫폼용 설치 파일을 모두 교차 빌드하면 파이프라인이 단순해 보입니다. 그러나 설치 도구와 signing 환경, runtime 이미지가 대상 OS에 종속돼 있어 결과를 신뢰하기 어렵습니다. architecture까지 달라지면 파일 확장자만 맞는 산출물이 만들어질 위험도 있습니다.

CI는 운영체제별 matrix로 나누고 각 runner가 현재 플랫폼의 설치 파일을 만들도록 했습니다. 같은 commit과 Gradle 설정을 사용하되 패키징 도구는 대상 환경의 것을 사용합니다.

strategy:
  matrix:
    os: [macos-latest, windows-latest, ubuntu-latest]
steps:
  - uses: actions/checkout@v4
  - run: ./gradlew packageReleaseDistributionForCurrentOS

matrix가 빌드 재현성을 높여도 architecture 문제를 자동으로 해결하지는 않습니다. artifact 이름과 진단 화면에 architecture와 bundled runtime 버전을 표시해 무엇을 만들었는지 확인할 수 있게 했습니다. Intel Mac에서 만든 artifact를 Apple Silicon 사용자에게 그대로 전달하는 식의 혼동을 릴리스 전에 잡기 위해서입니다.

서명과 notarization도 선택적인 경고로 두지 않았습니다. 설치본의 내용이 정상이어도 운영체제의 신뢰 절차를 통과하지 못하면 사용자는 실행 단계에 도달하지 못합니다. 따라서 해당 검증 실패는 artifact 업로드 이후의 참고 사항이 아니라 릴리스를 중단시키는 조건으로 취급했습니다.

설치 경로 밖의 설정과 로그

개발 환경에서는 프로젝트 디렉터리 아래에 로그를 남겨도 문제가 없습니다. 설치된 애플리케이션에서는 실행 위치가 쓰기 가능한 사용자 데이터 위치라는 보장이 없습니다. 업데이트가 설치 디렉터리를 교체하면 그 안에 둔 파일의 생명주기도 애플리케이션과 함께 끝날 수 있습니다.

플랫폼별 사용자 데이터 규칙을 따라 설정과 로그 경로를 정하고, 문자열로 경로를 이어 붙이지 않고 filesystem API를 사용했습니다. 공개 예시에서는 다음과 같은 위치를 사용합니다.

Windows: %LOCALAPPDATA%\MigrationAssistant\logs
macOS:   ~/Library/Logs/MigrationAssistant
Linux:   ~/.local/state/migration-assistant/logs

경로 테스트에는 평범한 영문 사용자명만 넣지 않았습니다. 공백, 한글, 조합형 Unicode를 포함한 디렉터리에서 파일을 쓰고 다시 읽어 같은 항목을 찾을 수 있는지 확인했습니다. 파일명을 정규화하는 주체가 OS인지 애플리케이션인지 섞이면 저장에는 성공했지만 다음 실행에서 파일을 찾지 못할 수 있기 때문입니다.

설치, 업데이트, 제거가 사용자 데이터에 어떤 영향을 주는지도 구분했습니다. 실행 파일은 installer가 관리하지만 설정과 로그는 사용자 영역의 생명주기를 따릅니다. 이 경계를 정해야 업데이트를 배포하면서 진단에 필요한 로그까지 지우는 일을 피할 수 있습니다.

사용 환경에 맞는 설치 방식

개발자용 도구라면 만들기 쉬운 fat JAR로 충분할 수 있지만, 사용자 JRE와 실행 명령에 의존합니다. Runtime을 포함한 package는 파일이 커지는 대신 실행 환경과 설치 경험을 고정할 수 있어 데스크톱 제품에 더 적합했습니다. 단일 OS에서 교차 build하면 pipeline은 단순해 보여도 서명 도구와 architecture 제약 때문에 안정적인 결과를 보장하기 어려웠습니다.

내부 개발자만 사용하는 도구라면 fat JAR가 충분할 수 있습니다. 그러나 대상 사용자에게 JRE 설치와 명령 실행을 요구하지 않으려면 runtime이 포함된 native distribution이 더 적합했습니다. 선택 기준은 파일 크기 하나가 아니라 지원할 설치 경험이었습니다.

runtime 포함 방식은 운영 부담을 없애지 않습니다. 운영체제별 installer와 서명 설정을 유지해야 하고, 포함된 runtime의 업데이트도 제품 릴리스에 포함됩니다. 플랫폼 수가 늘어날수록 빌드 matrix와 smoke test 비용도 함께 증가합니다.

실제 설치본을 대상으로 한 Smoke Test

단위 테스트와 일반 Gradle 실행만으로는 packaging 단계에서 생기는 문제를 잡을 수 없습니다. CI가 만든 파일을 깨끗한 머신이나 VM에 설치하고, 실행과 설정 로딩, 파일 저장까지 통과시키는 최소 smoke test를 두었습니다.

검증 순서는 설치 파일 생성, 설치, 첫 실행, 설정 읽기, 테스트 파일 저장, 로그 생성, 종료와 제거로 이어집니다. 업그레이드 시나리오에서는 기존 사용자 데이터가 유지되는지도 확인합니다. 모든 기능을 UI 자동화로 반복하기보다 패키징 차이가 영향을 주는 경로를 우선했습니다.

실패 원인을 남기는 방식도 개발 실행과 달라야 했습니다. 사용자의 머신에서 오류가 나면 IDE console이나 빌드 로그를 볼 수 없으므로, 플랫폼 표준 위치에 로그가 있어야 합니다. 동시에 경로와 환경 정보가 진단에 필요하더라도 credential이나 사용자의 민감한 파일 내용은 기록하지 않습니다.

패키지별 JRE 크기와 포함 module도 정기적으로 확인했습니다. keep 규칙이나 dependency 변경으로 산출물이 갑자기 커지면 최적화 범위가 넓어졌거나 불필요한 module이 들어간 신호일 수 있습니다. 크기 자체보다 변화의 이유를 설명할 수 있는지가 중요했습니다.

배포 산출물까지 포함하는 완료 기준

Compose Multiplatform이 줄여 준 것은 UI와 애플리케이션 코드의 중복이었습니다. 설치 포맷, runtime, architecture, 서명, 파일 경로 같은 운영체제의 규칙까지 하나로 만들어 주지는 않았습니다. 개발 실행만 보고 배포 준비가 끝났다고 판단했을 때 그 차이가 가장 늦게 드러났습니다.

해결은 플랫폼 분기를 공통 코드에 계속 추가하는 것이 아니었습니다. 공통 애플리케이션과 OS별 packaging·filesystem 경계를 나누고, 각 운영체제가 자기 설치 파일을 만들게 했습니다. ProGuard 규칙은 실제 stack trace를 기준으로 좁혔고, 사용자 데이터는 설치 디렉터리 밖의 표준 위치에 뒀습니다.

무엇보다 CI의 최종 산출물을 다시 설치하고 실행했습니다. 사용자가 받는 파일을 테스트 대상에 포함해야 개발 환경과 배포 환경 사이의 차이를 릴리스 전에 발견할 수 있습니다. 멀티플랫폼 데스크톱 앱은 코드를 공유하는 순간이 아니라 세 운영체제의 설치본이 같은 계약을 지킬 때 하나의 제품이 됩니다.

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다