한국 천문 연구원 일몰 일출 공공데이터 API Android 연동 (XML)

오늘의 프로젝트 개발

히나의 엔딩 요정



안녕하세요! 오늘은 안드로이드 개발 중 많은 개발자들을 당황하게 만드는 공공데이터 포털의 XML API를 Jetpack Compose 환경에서 깔끔하게 연동하는 방법을 정리해 보려고 합니다.

대부분의 현대 API는 JSON을 지원하지만, 한국천문연구원의 출몰시각 정보(getAreaRiseSetInfo) 같은 일부 구형 공공데이터 API는 _type=json 파라미터를 던져도 무조건 XML로만 응답을 줍니다.

안녕하세요! 오늘은 안드로이드 개발 중 많은 개발자들을 당황하게 만드는 공공데이터 포털의 XML API를 Jetpack Compose 환경에서 깔끔하게 연동하는 방법을 정리해 보려고 합니다

📄 일출 일몰 데이터 공식 문서 확인

가이드 문서를 열어보면 제공기관 스펙상 응답 데이터가 JSON이 아닌 XML 포맷 고정인 것을 확인할 수 있습니다. 데이터 구조는 크게 <response> <body> <items> <item> 형태로 계층이 나뉩니다.

IROS5_OA_DV_0401_OpenAPI활용가이드_09.한국천문연구원_천문우주정보_출몰시각정보제공서비스_v1.2.docx
0.16MB

이를 최신 Jetpack Compose 환경에서 파싱하려다 보면 JsonReader.setLenient 같은 파싱 에러부터 버전 충돌까지 온갖 빌드 오류를 만나게 되는데요. 소스 코드를 짜기 전에 가장 먼저 세팅해야 할 '뿌리 설정'부터 짚고 넘어가겠습니다.

🚀 제0단계: 모든 빌드의 뿌리, 자바(Java) 버전부터 확인하기

안드로이드 스튜디오에서 최신 Jetpack Compose와 레드로핏 환경을 구축할 때, 라이브러리 의존성(Dependencies)보다 더 먼저 맞춰야 하는 최상위 설정이 바로 자바 컴파일러 버전(Java Version)과 Gradle 빌드 엔진 버전입니다.

구형 안드로이드 프로젝트에서는 Java 8을 표준으로 많이 사용했지만, 최신 Jetpack Compose 버전들과 최신 라이브러리(Retrofit 2.11+ 등)를 에러 없이 구동하기 위해서는 최소 Java 17 환경이 필수적으로 요구됩니다. 버전이 맞지 않으면 빌드를 시작하자마자 클래스 파일 버전 미스매치로 컴파일 자체가 거부됩니다. 이번 프로젝트에서는 Java 17을 기반으로 안정적인 환경을 구축했습니다.

1) Settings ➡️ Gradle 환경을 Java 17로 우선 설정

안드로이드 스튜디오가 프로젝트를 빌드할 때 사용하는 실제 내장 JDK 버전을 일치시켜야 런타임 크래시가 나지 않습니다.

  • 설정 경로: File ➡️ Settings (Mac은 Settings...) ➡️ Build, Execution, Deployment ➡️ Build Tools ➡️ Gradle
  • Gradle JDK 항목을 내장된 Embedded JDK 17 버전으로 가장 먼저 변경해 줍니다.

2) gradle-wrapper.properties 버전 안정화 (Gradle 8.4)

처음 프로젝트를 생성하면 개발 도구 버전에 따라 Gradle 9.x 이상 아주 높은 버전으로 잡히기도 합니다. 하지만 최신 버전은 간혹 기존 플러그인(TikXml 등)과 호환성 충돌을 일으키기 때문에, 현재 가장 생태계가 안정적이고 신뢰할 수 있는 Gradle 8.4 버전으로 롤백하여 기본 세팅을 잡아줍니다.

프로젝트 루트 폴더의 gradle/wrapper/gradle-wrapper.properties 파일을 열고 아래 주소로 수정해 줍니다.

distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip

 

3) build.gradle.kts (Module :app) 기본 뼈대 구성

자바 17 사양과 호환성을 맞춘 앱 수준의 빌드 스크립트 핵심 설정입니다.

android {
    namespace = "com.example.myapplication"
    // 💡 최신 안드로이드 API 사양을 지원하기 위해 compileSdk는 35로 타겟팅합니다.
    compileSdk = 35

    defaultConfig {
        applicationId = "com.example.myapplication"
        minSdk = 24  // 디바이스 최소 지원 사양 (Nougat 이상)
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables { useSupportLibrary = true }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }

    // 💡 안드로이드 컴파일러가 사용할 자바 버전을 17로 강제 지정합니다.
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    buildFeatures {
        compose = true // Jetpack Compose 활성화
    }

    // 💡 Kotlin 컴파일러 역시 Java 17 바이너리를 타겟으로 하도록 툴체인을 명시합니다.
    kotlin {
        jvmToolchain(17)
    }

    composeOptions {
        // 💡 프로젝트 코틀린 버전(1.9.24 기준)과 1:1 매칭되는 
        // 💡 매우 중요한 Compose 컴파일러 확장 버전 빌드 핀입니다.
        kotlinCompilerExtensionVersion = "1.5.14"
    }
}

📦 제1단계: build.gradle.kts 의존성(Dependencies) 완벽 분석 & 다이어트

자바 버전과 그레들 세팅이 끝났다면, 이제 앱에서 사용할 외부 라이브러리들을 당겨올 차례입니다.
아래는 Jetpack Compose UI 환경에서 공공데이터 XML을 파싱하기 위해 최종 구성한 dependencies 블록입니다. 코드를 무작정 복사하기 전에 어떤 녀석들이 왜 들어갔는지, 그리고 우리가 무심코 저지른 '중복 코드 소동'은 무엇이었는지 하나씩 뜯어보겠습니다.

dependencies {
    // 1️⃣ 구형 공공데이터 XML 파싱을 위한 TikXml 핵심 세트
    implementation("com.tickaroo.tikxml:annotation:0.8.13")
    implementation("com.tickaroo.tikxml:core:0.8.13")
    implementation("com.tickaroo.tikxml:retrofit-converter:0.8.13")
    kapt("com.tickaroo.tikxml:processor:0.8.13") // 💡 빌드 시 파싱 어댑터 코드를 강제 생성

    // 2️⃣ Jetpack Compose UI 핵심 컴포넌트
    implementation("androidx.compose.ui:ui:1.7.0")
    implementation("androidx.compose.foundation:foundation:1.7.0")
    implementation("androidx.compose.material3:material3:1.3.0") // 💡 Card, CircularProgress 등 구글 최신 디자인
    implementation("androidx.compose.ui:ui-tooling-preview:1.7.0") // 💡 미리보기용 프리뷰 도구
    implementation("androidx.activity:activity-compose:1.9.3") // 💡 액티비티와 컴포즈 연결 접착제

    // 3️⃣ 아키텍처 및 네트워크 표준 라이브러리
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4") // 💡 컴포즈 화면에서 ViewModel 상태 관리
    
    // ⚠️ [주의] 중복 선언되어 있던 converter-gson은 최신 버전(2.11.0) 한 줄만 남기고 정리합니다!
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")

    // 4️⃣ 안드로이드 기본 베이스 시스템 (버전 카탈로그 libs 활용)
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    implementation(libs.androidx.activity)
    implementation(libs.androidx.constraintlayout)
    
    // 5️⃣ 테스트 주도 개발(TDD)을 위한 기본 테스트 프레임워크
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
}

🔍 핵심 라이브러리 역할 콕 찝어보기

① 공공데이터 XML을 파괴할 구원투수: TikXml

가장 중요한 대목입니다. annotation, core, retrofit-converter 3형제가 짝을 이뤄 레트로핏이 받아온 XML 데이터를 코틀린 데이터 클래스로 변환해 줍니다. 특히 kapt로 선언된 processor가 컴파일 시점에 백그라운드에서 열심히 매핑 파일($$TypeAdapter)을 찍어내기 때문에, 런타임에서 No TypeAdapter found 에러 없이 안전하게 데이터를 읽어올 수 있습니다.

② 화면을 하드캐리할 Jetpack Compose & Material 3

우리가 화면을 만들 때 썼던 예쁜 로딩 창(CircularProgressIndicator)이나 입체적인 카드 폼(Card), 폰트 스타일(MaterialTheme.typography)은 전부 material3 덕분에 쓸 수 있는 것입니다. 또한 lifecycle-viewmodel-compose가 들어갔기 때문에, 화면이 다크모드로 바뀌거나 회전해도 일출·일몰 데이터가 날아가지 않고 ViewModel에 안전하게 보존됩니다.

③ 서버와의 고속도로를 뚫는 Retrofit2 & 통역관 Gson

안드로이드 앱이 공공데이터 서버와 대화하기 위해 반드시 거쳐야 하는 네트워크 통신의 핵심 듀오입니다. 이 둘은 실무에서 언제나 세트로 움직이며, 다음과 같은 엄청난 시너지를 냅니다.

  • Retrofit2 (서버 접속 및 데이터 배달부): 안드로이드에서 API 통신을 할 때 쓰는 전 세계 표준 라이브러리입니다. 복잡한 HTTP 연결, 비동기 스레드 처리, 주소창 설정을 단 몇 줄의 코틀린 인터페이스 선언으로 축소시켜 줍니다. 서버로 날아가서 데이터를 땡겨오는 '운송 수단' 역할을 담당합니다.
  • Converter-Gson (데이터 번역기): 레트로핏이 서버에서 받아온 결과물은 그냥 날것의 거대한 문자열(텍스트) 덩어리입니다. 사람이 일일이 쪼개서 읽기 힘든 이 텍스트 데이터를, 우리가 코딩할 때 바로 쓸 수 있는 코틀린 객체(Data Class)로 자동 변환(역직렬화)해 주는 똑똑한 통역관입니다.

💡 여기서 잠깐! 우리는 왜 TikXml과 Gson을 둘 다 넣었을까요? 이번 프로젝트의 핵심 의문점일 수 있습니다. 보통의 현대적인 API들은 JSON 포맷으로 데이터를 주기 때문에 converter-gson 하나만 있으면 끝납니다. 하지만 우리가 연동할 천문우주정보 API는 오직 구형 XML 포맷만 지원하죠. 그래서 XML 전용 파서인 TikXml 을 메인 변환기로 붙여 일출·일몰 데이터를 파싱한 것이고, 향후 추가될 다른 일반적인 JSON 기반의 API 통신(카카오 맵, 날씨 API 등)까지 유연하게 확장할 수 있도록 Gson 을 미리 베이스로 깔아두며 아키텍처의 균형을 맞춘 것입니다.

📂 제2단계: 서버 응답과 1:1 매칭되는 데이터 모델(Data Class) 생성하기

환경 구축을 마쳤으니, 이제 한국천문연구원 서버가 보내주는 XML 데이터를 우리 앱이 알아들을 수 있도록 코틀린 클래스로 변환해 줄 차례입니다. 공공데이터 가이드 문서에 적힌 XML 트리 구조는 다음과 같습니다.

<response>
    <body>
        <items>
            <item>
                <location>서울</location>
                <locdate>20230801</locdate>
                <sunrise>0532</sunrise>
                <sunset>1941</sunset>
            </item>
        </items>
    </body>
</response>
이 거대한 계층 구조를 쪼개어, TikXml 라이브러리가 인식할 수 있도록 매핑한 최종 데이터 모델 코드가 바로 아래의 코드입니다.
package com.example.myapplication.data.model

import com.tickaroo.tikxml.annotation.Element
import com.tickaroo.tikxml.annotation.PropertyElement
import com.tickaroo.tikxml.annotation.Xml

// 1️⃣ 최상위 루트 태그 매핑
@Xml(name = "response")
data class SunriseSunsetResponse(
    @Element(name = "body") val body: SunriseSunsetBody? = null
)

// 2️⃣ 중첩 태그 (Body) 매핑
@Xml(name = "body")
data class SunriseSunsetBody(
    @Element(name = "items") val items: SunriseSunsetItems? = null
)

// 3️⃣ 중첩 태그 (Items) 매핑
@Xml(name = "items")
data class SunriseSunsetItems(
    // 💡 공공데이터 특성상 특정 조건에서 item이 단수(하나)로 올 때를 대비한 매핑입니다.
    @Element(name = "item") var item: SunriseSunsetItem? = null
)

// 4️⃣ 실제 데이터가 담긴 최하위 태그 매핑
@Xml(name = "item")
class SunriseSunsetItem(
    @PropertyElement(name = "location") var location: String? = null,
    @PropertyElement(name = "locdate") var locdate: String? = null,
    @PropertyElement(name = "sunrise") var sunrise: String? = null,
    @PropertyElement(name = "sunset") var sunset: String? = null
)​

🧐 TikXml 핵심 어노테이션 콕 찝어보기

XML은 JSON과 달리 태그 속에 태그가 꼬리를 무는 복잡한 구조를 가집니다. TikXml은 이를 해석하기 위해 세 가지 핵심 무기를 사용합니다.

  • @Xml(name = "태그명") : 이 클래스가 XML의 특정 태그를 대표한다는 것을 알려줍니다. 최상위 <response>부터 최하위 <item>까지 각 클래스 머리 위에 반드시 얹어주어야 합니다.
  • @Element(name = "태그명") : 태그 내부에 또 다른 중첩 태그(자식 노드)가 존재할 때 사용합니다. response 안에 body가 있고, body 안에 items가 있는 계층 구조를 연결해 주는 고리 역할을 합니다.
  • @PropertyElement(name = "태그명") : 더 이상 자식 태그가 없고, <sunrise>0532</sunrise>처럼 태그 내부에 실제 텍스트 데이터(값)가 들어있을 때 콕 집어서 값을 추출하는 역할을 합니다.

🚨 [삽질 방지] TikXml 모델링 시 절대 잊지 말아야 할 2가지 규칙

만약 이 규칙을 어기면 앱을 켰을 때 화면이 나오지 않고 No TypeAdapter found나 InstantiationException을 뱉으며
앱이 무참히 터지게 됩니다.

① 모든 변수에 기본값(= null)을 반드시 채워주세요!

TikXml의 코드 생성기(kapt)는 컴파일 시점에 이 데이터 클래스들을 기반으로 파싱 어댑터 자바 파일들을 새로 만들어냅니다. 이때 클래스 내부에 초기 기본값이 선언되어 있지 않으면 생성기가 빈 생성자를 만들지 못해 파일 작성을 거부해 버립니다. 따라서 무조건 변수 뒤에 ? = null을 붙여서 Null 안정성과 빈 생성자를 동시에 확보해야 합니다.

② 대소문자와 태그명을 공식 문서와 100% 일치시키세요!

공공데이터 API는 대소문자를 아주 까다롭게 가립니다. 문서에는 sunrise라고 소문자로 적혀있는데 코드에 @PropertyElement(name = "Sunrise")라고 대문자로 적는 순간, 파서가 태그를 찾지 못하고 null을 뱉어버리니 주의해야 합니다.

🌐 제3단계: 레트로핏(Retrofit) 인터페이스 및 클라이언트 싱글톤 구성하기

이제 실제로 서버에 데이터를 요청하고 응답을 받아올 통신 엔진을 조립할 차례입니다.
안드로이드 네트워크의 표준인 Retrofit2를 활용해, 서버의 엔드포인트를 정의하는 인터페이스(Interface)와 프로젝트 전역에서 단 하나만 생성되어 자원을 효율적으로 관리할 싱글톤 객체(RetrofitClient)를 구성했습니다.

package com.example.myapplication.data.remote

import com.example.myapplication.data.model.SunriseSunsetResponse
import com.tickaroo.tikxml.TikXml
import com.tickaroo.tikxml.retrofit.TikXmlConverterFactory
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.http.GET
import retrofit2.http.Query

// 1️⃣ 서버의 세부 경로(엔드포인트)와 파라미터 정의
interface SunriseSunSeService {
    @GET("B090041/openapi/service/RiseSetInfoService/getAreaRiseSetInfo")
    suspend fun getSunriseSunset(
        @Query("serviceKey", encoded = true) serviceKey: String, // ⚠️ 인코딩 주의
        @Query("locdate") locdate: String,
        @Query("location") location: String
    ): SunriseSunsetResponse
}

// 2️⃣ 네트워크 통신 엔진을 일괄 관리하는 싱글톤 객체
object RetrofitClient {
    private const val BASE_URL = "https://apis.data.go.kr/"
    
    // HTTP 통신성능 및 인터셉터 등을 제어할 베이스 클라이언트
    private val okHttpClient = OkHttpClient.Builder()
        .build()

    // 💡 TikXml 파서의 디테일한 옵션 설정
    private val tikXml = TikXml.Builder()
        .exceptionOnUnreadXml(false) // 매핑하지 않은 불필요한 태그가 XML에 섞여 있어도 크래시 없이 패스!
        .build()

    // 💡 실제 서비스 객체를 필요할 때 처음 생성(Lazy)하여 주입
    val service: SunriseSunSeService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient) 
            .addConverterFactory(TikXmlConverterFactory.create(tikXml)) // 💡 XML 변환 팩토리 주입!
            .build()
            .create(SunriseSunSeService::class.java)
    }
}

🧐 네트워크 구성 코드 콕 찝어보기

① @GET과 @Query가 만드는 마법

레트로핏은 복잡한 주소창 생성을 어노테이션 기반으로 단순화합니다.

  • @GET 내부의 긴 주소는 공공데이터 포털에서 제공하는 고유 엔드포인트입니다. BASE_URL 뒤에 이 경로가 자동으로 결합됩니다.
  • @Query들은 주소창 뒤에 ?serviceKey=XXX&locdate=20230801&location=서울 형태로 파라미터를 안전하게 엮어주는 역할을 합니다.
  • 비동기 처리를 위해 suspend fun으로 선언하여, 코루틴 스코프 내에서 메인 스레드를 멈추지 않고 안전하게 네트워크 백그라운드 통신이 수행되도록 설계했습니다.

② exceptionOnUnreadXml(false)의 중요성

공공데이터 API가 내려주는 XML 데이터를 뜯어보면, 우리가 모델 클래스에 정의한 location, sunrise 같은 데이터 외에도 결과 코드(<resultCode>), 메시지(<resultMsg>) 등 수많은 부가 태그들이 포함되어 있습니다. 만약 이 옵션을 주지 않으면 TikXml은 "내가 모르는 태그가 응답에 섞여 있어!"라며 앱을 강제로 터트립니다. 내가 필요한 데이터 클래스 필드만 깔끔하게 파싱하고 나머지는 쿨하게 무시하려면 이 빌더 옵션이 필수입니다.

📊 제4단계: 안전한 상태 관리와 비동기 처리를 위한 ViewModel 구현하기

데이터 모델과 네트워크 인터페이스가 준비되었으니, 이제 이 둘을 연결해 줄 핵심 브레인인 ViewModel(뷰모델)을 구현할 차례입니다.

Jetpack Compose 환경에서는 화면의 '상태(State)'가 변화함에 따라 UI가 알아서 재구성(Recomposition)됩니다. 따라서 화면이 [로딩 중 / 성공 / 실패] 중 어떤 상태에 있는지 명확히 정의하고, 화면 회전이나 구성 변경이 일어나도 데이터를 안전하게 보존할 수 있도록 뷰모델을 설계해야 합니다.

package com.example.myapplication.ui.feature.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.myapplication.data.model.SunriseSunsetItem
import com.example.myapplication.data.remote.RetrofitClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import android.util.Log

// 1️⃣ 화면의 상태를 단단하게 규격화하는 sealed interface
sealed interface SunriseSunSetUiState {
    object Loading : SunriseSunSetUiState
    data class Success(val data: SunriseSunsetItem) : SunriseSunSetUiState
    data class Error(val message: String) : SunriseSunSetUiState
}

class SunriseSunSetViewModel : ViewModel() {

    // 2️⃣ 캡슐화를 적용한 반응형 StateFlow 선언
    private val _uiState = MutableStateFlow<SunriseSunSetUiState>(SunriseSunSetUiState.Loading)
    val uiState: StateFlow<SunriseSunSetUiState> = _uiState

    // 3️⃣ 코루틴을 통한 안전한 비동기 데이터 요청 로직
    fun fetchSunriseSunset(serviceKey: String, locdate: String, location: String) {
        viewModelScope.launch {
            // 통신 시작과 동시에 화면을 로딩 상태로 전환
            _uiState.value = SunriseSunSetUiState.Loading
            
            try {
                // Retrofit 백그라운드 통신 개시
                val response = RetrofitClient.service.getSunriseSunset(
                    serviceKey = serviceKey,
                    locdate = locdate,
                    location = location
                )
                
                // 💡 앞서 설계한 XML 계층 구조(response -> body -> items -> item)에 따라 알맹이 추출
                val item = response.body?.items?.item

                if (item != null) {
                    _uiState.value = SunriseSunSetUiState.Success(item)
                } else {
                    _uiState.value = SunriseSunSetUiState.Error("일출·일몰 데이터를 찾을 수 없습니다.")
                }
            } catch (e: Exception) {
                // 💡 만약 XML이 아니라 꼬인 HTML 에러 페이지가 내려온다면 여기서 캐치됩니다.
                _uiState.value = SunriseSunSetUiState.Error("통신 에러: ${e.message}")
            }
        }
    }
}

🔍 뷰모델 핵심 아키텍처 콕 찝어보기

① UI의 정석 상태 지도, sealed interface

화면의 상태를 Success, Error, Loading 3가지 클래스로 묶어 정의했습니다. 이렇게 sealed interface를 쓰면 상태의 종류가 컴파일 시점에 완전히 제한되므로, 나중에 Jetpack Compose 화면단 코드에서 when (uiState) 절을 작성할 때 빠진 상태 없이 컴파일러의 체크를 받으며 안전하게 100% 매핑할 수 있습니다.

② 캡슐화 정석 패턴, _uiState와 uiState

외부 화면(Compose)에서는 데이터를 함부로 조작하지 못하고 오직 관찰(읽기)만 가능해야 데이터 무결성이 지켜집니다. 내부에서만 변경 가능한 MutableStateFlow(_ 접두사)를 꽁꽁 숨겨두고, 외부에는 읽기 전용인 StateFlow로 노출시키는 안드로이드 아키텍처의 교과서적인 캡슐화 기법을 적용했습니다.

③ 메모리 누수 없는 비동기 통신, viewModelScope.launch

네트워크 통신은 언제 끝날지 모르는 비동기 작업입니다. 만약 사용자가 데이터를 불러오는 도중에 전 화면으로 뒤로 가기를 눌러 앱 화면을 꺼버리면 통신 코드가 메모리에 둥둥 떠다니는 메모리 누수(Memory Leak)가 발생할 수 있습니다. 하지만 viewModelScope 블록 안에서 코루틴을 실행하면, 뷰모델이 소멸할 때 진행 중이던 네트워크 요청도 알아서 세트로 칼같이 취소해 주어 메모리를 아주 안전하게 방어합니다.

🎨 제5단계: Jetpack Compose UI로 화면에 일출·일몰 정보 우아하게 띄우기

이제 모든 데이터와 상태 흐름이 준비되었으니, 마지막으로 유저가 보게 될 아름다운 화면(View)을 Jetpack Compose로 그려낼 차례입니다. 선언형 UI 컴포즈의 강력함은 ViewModel의 상태(UiState)를 관찰하고 있다가, 상태가 바뀌면 화면이 알아서 쓱 체인지된다는 점입니다. 로딩 상태일 때는 인디케이터를, 성공했을 때는 카드 뷰를 보여주는 화면단 코드를 구현했습니다.

package com.example.myapplication.ui.feature.sunrise

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.myapplication.ui.feature.viewmodel.SunriseSunSetViewModel
import com.example.myapplication.ui.feature.viewmodel.SunriseSunSetUiState
import com.example.myapplication.data.model.SunriseSunsetItem
import android.util.Log
// 💡 local.properties에서 수확한 보안 키를 쓰기 위해 자동 생성된 BuildConfig를 임포트합니다.
import com.example.myapplication.BuildConfig

/**
 * [최상위 스크린 컴포저블]
 * ViewModel의 단방향 데이터 흐름(UDF) 상태를 관찰하여 로딩/성공/실패 UI를 스위칭합니다.
 */
@Composable
fun SunriseSunsetScreen(
    // 💡 뷰모델 컴포즈 확장 라이브러리를 통해 의존성을 안전하게 주입받습니다.
    viewModel: SunriseSunSetViewModel = viewModel()
) {
    // 💡 깃허브에 노출되지 않는 local.properties의 키를 안전하게 가져옵니다.
    val apiKey = BuildConfig.DATA_SERVICE_KEY

    // 1️⃣ [화면 진입 이벤트 리스너]
    // LaunchedEffect(Unit)은 화면이 처음 켜질 때 딱 한 번만 내부 블록을 실행합니다.
    // 리컴포지션(UI 재구성) 시마다 무한으로 API를 호출해 서버를 공격하는 크래시를 원천 방어합니다.
    LaunchedEffect(Unit) {
        viewModel.fetchSunriseSunset(
            serviceKey = apiKey,
            locdate = "20250801", // 조회 대상 날짜
            location = "서울"       // 조회 대상 지역
        )
    }

    // 2️⃣ [반응형 데이터 관찰] StateFlow를 컴포즈 상태(State)로 변환합니다.
    val uiState by viewModel.uiState.collectAsState()

    // 3️⃣ [MVI 스타일 상태 분기 처리] 정중앙 박스 레이아웃 안에서 뷰를 스위칭합니다.
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        when (val state = uiState) {
            // A. 로딩 상태: 서버 응답 전까지 머티리얼3 표준 프로그레스 휠을 돌립니다.
            is SunriseSunSetUiState.Loading -> { CircularProgressIndicator() }
            
            // B. 성공 상태: 파싱된 데이터(state.data)를 들고 하단 전용 카드 컴포저블로 이동합니다.
            is SunriseSunSetUiState.Success -> { SunriseSunsetContent(item = state.data) }
            
            // C. 실패 상태: 디버깅용 로그를 남기고 유저에게 안내 텍스트를 보여줍니다.
            is SunriseSunSetUiState.Error -> {
                Log.e("Error", state.message)
                Text("에러: ${state.message}")
            }
        }
    }
}

/**
 * [상세 컨텐츠 데이터 탑재 컴포저블]
 * 오직 성공한 일출·일몰 데이터 모델만 바라보고 이쁘게 렌더링하는 순수 UI 모듈입니다.
 */
@Composable
fun SunriseSunsetContent(item: SunriseSunsetItem) {
    // 💡 엘비스 연산자(?:)를 활용해 서버 데이터가 기습적으로 null로 올 때를 대비한 1차 안전장치입니다.
    val displayLocation = item.location ?: "알 수 없는 지역"
    val displayLocdate = item.locdate ?: "-"

    // 머티리얼 3 사양의 세련된 그림자(Elevation)가 들어간 카드 폼
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(
            modifier = Modifier
                .padding(24.dp)
                .fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = "$displayLocation 일출·일몰 정보",
                style = MaterialTheme.typography.headlineMedium
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(text = "날짜: $displayLocdate", style = MaterialTheme.typography.bodyMedium)

            // 머티리얼 3 표준 가로 구분선으로 상단 헤더와 하단 시간 수치 레이아웃 분리
            HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))

            // 좌우 분할 레이아웃으로 일출/일몰 정보를 정갈하게 배치
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                // 일출 영역
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(text = "☀️ 일출 시간", style = MaterialTheme.typography.titleMedium)
                    Spacer(modifier = Modifier.height(4.dp))
                    Text(
                        text = formatTime(item.sunrise), // 헬퍼 함수를 통한 포맷팅 적용
                        style = MaterialTheme.typography.headlineSmall
                    )
                }
                // 일몰 영역
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(text = "🌙 일몰 시간", style = MaterialTheme.typography.titleMedium)
                    Spacer(modifier = Modifier.height(4.dp))
                    Text(
                        text = formatTime(item.sunset),
                        style = MaterialTheme.typography.headlineSmall
                    )
                }
            }
        }
    }
}

/**
 * [텍스트 가공 유틸 함수]
 * 공공데이터 특유의 붙어있는 4자리 텍스트(예: "0532")를 가독성 좋은 시계 포맷("05:32")으로 파싱합니다.
 * @param time 서버에서 날아온 날것의 문자열 (Nullable 처리 필수)
 */
fun formatTime(time: String?): String {
    // 💡 [NullPointerException 예방의 핵심]
    // 만약 공공데이터 기상 서버가 터지거나 값이 유실되어 빈 값(null)이 와도 절대 앱이 죽지 않고 대시(--:--)를 표현합니다.
    if (time.isNullOrBlank()) return "--:--"

    // 4자리 규격이 정상 확인되면 스트링 슬라이싱 후 가운데 콜론(:)을 강제 가공합니다.
    return if (time.length == 4) {
        "${time.substring(0, 2)}:${time.substring(2, 4)}"
    } else {
        // 혹시나 다른 규격으로 오더라도 원본 텍스트를 살려서 노출하는 유연한 예외 처리입니다.
        time
    }
}

🔍 컴포즈 코드 속 깨깨오톡 같은 핵심 디테일

  • LaunchedEffect(Unit) : 컴포즈는 화면이 바뀔 때마다 코드를 처음부터 다시 읽는 '리컴포지션'이 일어납니다. 만약 서버 요청 코드를 그냥 던져두면 화면이 갱신될 때마다 무한으로 서버에 API를 요청하는 무한 루프 디도스 공격이 발생합니다. LaunchedEffect(Unit)으로 감싸두면 화면이 최초로 로딩될 때 딱 1번만 실행되도록 안전하게 격리됩니다.
  • formatTime(String?) 크래시 방어 : 서버에서 간혹 데이터 누락으로 인해 일출 시간이 null이나 빈 값으로 올 수 있습니다. 파라미터 타입을 String?로 유연하게 열어두고 내부에서 isNullOrBlank() 처리를 거침으로써, 코틀린 특유의 NullPointerException(NPE)으로부터 앱을 완벽하게 수호했습니다.

🔐 [핵심 보안] 깃허브 탈탈 털리기 전에 서비스 키 숨기는 정석 방법

포스팅 소스 코드 예시에는 서비스 키(apiKey) 문자열을 코드 안에 직접 적어두었지만, 이 상태 그대로 GitHub 같은 공용 저장소에 push를 하는 순간 내 소중한 인증키가 전 세계에 공짜로 털리게 됩니다. 안드로이드에서 서비스 키를 가장 안전하게 격리하여 사용하는 정석 메커니즘을 보너스로 공유합니다.

1. local.properties 파일에 키 기록하기

프로젝트 루트 폴더에 있는 local.properties 파일은 애초에 .gitignore에 등록되어 있어 깃허브에 절대 올라가지 않는 보안 구역입니다. 이 파일 맨 밑줄에 내 서비스 키를 선언합니다.

🧐 그레들(Gradle) 보안 스크립트 한 줄씩 뽀개기

앞서 작성한 build.gradle.kts 내부의 코드는 안드로이드 빌드 엔진이 컴파일 전에 local.properties라는 비밀 금고를 열어 키를 복사해 오는 일련의 자동화 과정입니다. 코드가 어떻게 작동하는지 원리를 알면 나중에 다른 결제 API나 카카오 키를 숨길 때도 100% 응용할 수 있습니다.

// 💡 파일 최상단 맨 첫 줄에 이 import 문을 추가해 줍니다!
import java.util.Properties

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.kapt")
}

android {
    // ... 생략 ...
    
    defaultConfig {
        // properties 엔진을 돌려 local.properties 파일 안의 키 값을 불러옵니다.
        val properties = Properties().apply {
            val propertiesFile = rootProject.file("local.properties")
            if (propertiesFile.exists()) {
                load(propertiesFile.inputStream())
            }
        }
        val serviceKey = properties.getProperty("PUBLIC_DATA_SERVICE_KEY") ?: "\"\""

        buildConfigField("String", "DATA_SERVICE_KEY", serviceKey)
    }
}

📱 제6단계: 최종 출력 화면 및 레이아웃 구조 확인

모든 빌드와 코딩을 마치고 에뮬레이터나 실제 디바이스에서 앱을 구동하면, 다음과 같이 깔끔하고 세련된 머티리얼 3 디자인의 일출·일몰 정보 카드가 화면 정중앙에 나타납니다.

 

  • 최상위 컨테이너 (Box): 화면 전체(fillMaxSize)를 하얗게 감싸고 있으며, 데이터가 로딩되는 동안에는 가운데에 프로그레스 휠을, 로딩이 끝나면 카드 뷰를 정중앙(Alignment.Center)에 딱 잡아줍니다.
  • 입체적인 카드 배경 (Card): CardDefaults.cardElevation(4.dp) 옵션 덕분에 화면 바닥으로부터 둥실 떠 있는 듯한 부드러운 그림자(Shadow) 효과가 연출되어 시각적 몰입감을 줍니다.
  • 메인 타이틀 및 날짜 (Column): headlineMedium 서체가 적용된 "서울 일출·일몰 정보" 텍스트와 그 아래 조회된 날짜가 수직으로 정갈하게 정렬됩니다.
  • 중간 구분선 (HorizontalDivider): 머티리얼 3 스펙의 얇고 깔끔한 디바이더가 상단 영역과 하단 시간 수치 영역을 시각적으로 완전히 분리해 주어 뷰의 완성도를 높입니다.
  • 일출/일몰 시간 뷰 (Row): Arrangement.SpaceEvenly 균등 배분 옵션 덕분에 화면 좌측에는 태양 아이콘과 함께 오전 시간(예: 05:32)이, 우측에는 달 아이콘과 함께 오후 시간(예: 19:41)이 황금 비율의 여백을 두고 나란히 배치됩니다.

🔑 번외: 통신의 시작과 끝, AndroidManifest.xml 권한 및 보안 세팅

레드로핏 엔진과 컴포즈 UI까지 완벽하게 빌드업을 끝냈다면, 마지막으로 앱의 대동여지도이자 통행증인 AndroidManifest.xml을 수정해 주어야 합니다. 이 설정을 빼먹으면 앱을 켰을 때 화면에 로딩 표시만 무한으로 돌다가 SecurityException이나 통신 실패 에러를 마주하게 됩니다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/xml"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/full_backup_content"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyApplication"
        tools:targetApi="34"
        
        android:usesCleartextTraffic="true">
        
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:theme="@style/Theme.MyApplication">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

🧐 매니페스트 설정 속 삽질 방지 포인트 콕 찝어보기

① 기본 중의 기본, android.permission.INTERNET

안드로이드 앱은 기본적으로 내부 샌드박스에 갇혀있기 때문에 운영체제(OS)단에 허락을 맡지 않으면 와이파이나 셀룰러 데이터를 일절 만질 수 없습니다. <application> 태그 바깥 최상단에 이 권한을 명시해 주어야 레트로핏이 정상적으로 서버 주소를 찾아 떠날 수 있습니다.

② 구형 공공데이터 서버를 위한 구원투수, usesCleartextTraffic="true"

안드로이드 9.0(API 28) 버전부터는 구글의 보안 정책이 강화되면서 암호화되지 않은 날것의 HTTP(Hypertext Transfer Protocol) 통신을 기본적으로 전면 차단합니다. 무조건 보안이 적용된 HTTPS 통신만 허용하겠다는 취지이죠.

하지만 우리가 연동하는 공공데이터 포털의 일부 서비스나 구형 정부 API 서버들은 여전히 http://apis.data.go.kr/... 처럼 일반 HTTP 주소를 엔드포인트로 사용하는 경우가 허다합니다. 이 옵션을 켜주지 않으면 안드로이드 OS 자체에서 통신을 가로막아 IOException: Cleartext HTTP traffic to ... not permitted라는 무시무시한 로그를 뱉게 됩니다.

따라서 <application> 태그 내부에 android:usesCleartextTraffic="true" 속성을 안전하게 수동 주입해 주어야, 변칙적인 구형 공공데이터 주소까지 깔끔하게 뚫어내어 화면에 일출·일몰 데이터를 띄울 수 있습니다!

✍️ 프로젝트를 마치며: 변칙적인 공공데이터를 모던 아키텍처로 크래킹한 후기

처음에는 그저 "화면에 일출·일몰 시간 하나 띄워보자"라는 가벼운 마음으로 시작한 토이 프로젝트였습니다. 하지만 대한민국 공공데이터 포털의 문을 두드리는 순간, 이 작업이 결코 만만치 않은 '삽질의 연속'이 될 것임을 직감했습니다.

모든 과정이 현대적인 안드로이드 개발 스펙(Kotlin + Jetpack Compose)과 구형 서버 시스템(구형 XML 표준 + 변칙적 HTTP 엔드포인트) 사이의 간극을 메우는 치열한 도전이었기 때문입니다. 이번 연동 과정을 완수하며 꾹꾹 눌러 담은 저만의 3가지 핵심 개발 회고를 공유합니다.

💡  구형 레거시와 모던 아키텍처의 아름다운 타협

요즘 대부분의 모던 API 서버들은 당연하다는 듯이 깔끔한 JSON 포맷을 제공합니다. 하지만 공공데이터 포털의 천문우주정보 API처럼 연식이 고포화된 레거시 시스템은 여전히 태그 속에 태그가 꼬리를 무는 구형 XML 구조를 고집하곤 합니다.

이번 프로젝트에서 가장 뿌듯했던 점은, 이 낡은 XML 데이터를 읽기 위해 과거의 구형 파싱 노가다 방식으로 회귀하지 않고, TikXml 라이브러리를 안드로이드의 최신 아키텍처 가이드라인인 MVVM 및 단방향 데이터 흐름(UDF)에 완벽하게 융합시켰다는 점입니다.

Sealed Interface로 화면의 상태(UiState)를 단단하게 규격화하고, 레트로핏이 땡겨온 데이터를 코루틴 비동기 스코프(viewModelScope) 안에서 안전하게 정제하여 컴포즈 UI로 토스하는 파이프라인을 구축하면서, "아무리 낡은 데이터라도 아키텍처를 어떻게 설계하느냐에 따라 충분히 모던하고 우아하게 다룰 수 있다"는 확신을 얻었습니다.

 

오늘의 개발 프로젝트 끝