본문 바로가기

TIL

[TIL] 공공 데이터 API 받아오기 / Retrofit2, OkHttp3 를 사용한 간단한 앱

-1- 공공 데이터 API 받아와 미세먼지 확인 앱 만들기

 

1. API Key 생성

  • https://www.data.go.kr/index.do 웹 페이지 접속 후 원하는 공공 데이터 검색.
  • 초기 화면 중앙에 위치한 ' 어떤 공공 데이터를 찾으시나요? ' 에 원하는 데이터 입력 후 스크롤을 조금 내리면 오픈 API 탭을 확인할 수 있다 해당 탭에서 원하는 데이터 선택한다.
  • 글에서는 한국환경공단_에어코리아_대기오염정보를 받아와 지역별 미세먼지 지수를 확인하는 앱을 만든다.

  • 원하는 공공데이터 활용신청을 통해서 API Key를 얻으면 된다. 학습용 앱 생성 연습 등 임의대로 작성하면 바로 이용할 수 있다.

 

  • 활용 신청을 하고 나면 마이페이지 등에서 해당 Key값을 확인할 수 있다.

  • 서비스 정보에 End Point 의 값도 앱 만들 때 BASE_URL 로 사용 될 예정.
  • Decoding에 해당 하는 인증키를 통해서 API 연동을 할 것이다.
    • Encoding 인증키는 Text 등을 Byte 등의 형태로 데이터를 원래 형태와 다르게 변환해서 데이터를 받는 것이다.
    • Decoding 인증키는 별도로의 데이터 변환 없이 데이터를 받을 수 있다.

 

인증키 작동확인 - 

  • 서비스 정보에서 아래로 조금 내리면 활용 신청 상세기능정보에서 생성한 API키를 넣어서 잘 작동하는 지 확인할 수 있다. serviceKey에 생성된 Key를 입력 후 화면에는 짤렸지만 화면 바로 아래에 있는 미리보기 클릭 시 json 또는 xml 형태의 데이터를 얻을 수 있다. 이번 글에서는 Json 형태의 데이터로 값을 얻어올 것이다.
  • API 키를 입력하고, json의 형태로 numOfRows 를 1로 설정한 뒤 미리보기를 클릭하여 API Parameter를 확인할 수 있는 페이지를 띄워놓는다. 해당 API Request Parameter들은 Data Class로 변환하는데 사용할 예정.

+ 추후에 Activity에서 항목명 코드를 입력할 때 자동완성이 없으니 주의해야한다. 글쓴이는 sido를 자연스럽게 side로 입력해서 2시간 삽질했다

 

 

2. Manifest, build.gradle 적용

Manifest.xml

  • Retrofit 통신을 통해 데이터를 받아온다. 때문에 Internet 권한이 필요로 하므로 permission.INTERNET 을 허용해줌
  • 웹브라우저 작업 및 API 작업을 할 때 http가 허용되지 않아 오류가 발생할 수 있기 때문에 Manifest에 usesCleartextTraffic을 true로 설정한다. 
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET"/>
    <application
    
    	android:usesCleartextTraffic="true"
    	...
        
       
        ...
        </application>
</manifest>

 

 

 

build.gradle(프로젝트)

  • API KEY를 숨기기 위해 Plugin을 적용해준다
plugins {
    ...
    
    id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version  "2.0.1" apply false
}

buildscript {
    dependencies {
        classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1")
    }
}

 

 

 

build.gradle(module)

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
}

android {
	...
    

	buildFeatures {
    	viewBinding = true
        buildConfig = true
    }
}


dependencies {

    implementation("com.github.skydoves:powerspinner:1.2.7")
    implementation("com.google.code.gson:gson:2.10.1")
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation("com.squareup.okhttp3:okhttp:4.10.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.10.0")
    
    ...
    
}

 

 

 

3. API KEY 숨기기

  • Manifest 및 build.gradle 설정이 끝났으면 Gradle Scripts의 local.properties로 이동해서 API 키를 넣어준다

 

local.properties

  • 작성이 되었으면 이후에는 BuildConfig.dustApiKey로 호출 가능.

 

 

4. activity_main.xml

더보기
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.view.MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@+id/main_const_layout"
        app:layout_constraintTop_toTopOf="parent"
        android:orientation="horizontal"
        >
        
        <com.skydoves.powerspinner.PowerSpinnerView
            android:id="@+id/main_spinner_province"
            android:layout_width="100dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:hint="도시 선택"
            android:gravity="center"
            android:textColorHint="@color/black"
            android:textSize="20sp"
            android:paddingEnd="10dp"
            app:spinner_arrow_gravity="end"
            app:spinner_arrow_tint="@color/black"
            app:spinner_divider_color="@color/black"
            app:spinner_divider_size="1dp"
            app:spinner_divider_show="true"
            app:spinner_item_array="@array/province"
            app:spinner_selected_item_background="@color/grade_good"
            app:spinner_popup_animation="normal"
            app:spinner_popup_background="@color/grade_good"
            app:layout_constraintBottom_toTopOf="@+id/constraintLayout"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <com.skydoves.powerspinner.PowerSpinnerView
            android:id="@+id/main_spinner_city"
            android:layout_width="100dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:hint="지역 선택"
            android:gravity="center"
            android:textColorHint="@color/black"
            android:textSize="20sp"
            android:paddingEnd="10dp"
            app:spinner_arrow_gravity="end"
            app:spinner_arrow_tint="@color/black"
            app:spinner_divider_color="@color/black"
            app:spinner_divider_size="1dp"
            app:spinner_divider_show="true"
            app:spinner_selected_item_background="@color/grade_good"
            app:spinner_popup_animation="normal"
            app:spinner_popup_background="@color/grade_good"
            app:layout_constraintBottom_toTopOf="@+id/constraintLayout"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </LinearLayout>


    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/main_const_layout"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="80dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:background="@color/sky" >


        <TextView
            android:id="@+id/main_tv_selected_city"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="80dp"
            android:text="도시를 선택해 주세요."
            android:textSize="32sp"
            android:textStyle="bold"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/main_img_emoji"
            android:layout_width="200dp"
            android:layout_height="250dp"
            android:layout_marginTop="70dp"
            android:src="@drawable/dizzy_face"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/main_tv_selected_city" />

        <TextView
            android:id="@+id/main_tv_PM"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:text=" - ㎍ / ㎥"
            android:textStyle="bold"
            android:textSize="20sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/main_img_emoji" />

        <TextView
            android:id="@+id/main_tv_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text=""
            android:textSize="20sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/main_tv_selected_city" />

        <TextView
            android:id="@+id/main_tv_PM_grade"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="24dp"
            android:text=""
            android:textSize="28sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/main_tv_PM" />


    </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
  • PowerSpinnerView를 통해 Spinner를 생성하고 res / values / arrays 를 통해 Spinner Item을 등록해줌,

 

Arrays.xml

<resources>
    <string-array name="province">
        <item>전국</item>
        <item>서울</item>
        <item>경기</item>
        <item>대구</item>
        <item>인천</item>
        <item>광주</item>
        <item>대전</item>
        <item>울산</item>
        <item>부산</item>
        <item>강원</item>
        <item>충북</item>
        <item>충남</item>
        <item>전북</item>
        <item>전남</item>
        <item>경북</item>
        <item>경남</item>
        <item>제주</item>
        <item>세종</item>
    </string-array>
</resources>

 

 

 

5. Data Class ( DTO ) 생성

  • API를 호출할 때 Json 의 형태로 호출하기 때문에 해당 Json 형태의 데이터를 Data Class로 변환해주어야 한다.
  • Parameter를 일일이 입력하기는 번거롭기 때문에 Json To Kotlin class Plugin을 통해 Data class를 생성준다.

 

  •  java / 프로젝트이름 하부에 data.model등 data를 모을 package를 생성해준다.
  • [ 1. API키 생성 시 서비스 정보 ] 에서 미리보기를 통해 시도별 실시간 미세먼지 정보 Request Parameter를 확인했는데 해당 Json 코드 전체를 복사한 후 생성한 data.model package 에서 Kotlin data class File from JSON 클릭

 

  • Json 코드 붙여넣기 후 Class Name 작성 및 Advanced 로 추가 설정으로 이동

 

  • Gson 의 형태로 저장해줄 것이기 때문에 Gson 클릭 후 OK 및 Generate

 

Data class 생성 끝

  • Header, Body 각각 다른 Class가 생성되었을텐데 한 곳으로 몰아넣어준다.
  • @SerialzedName() - () 변수 명을 API 에서 받아온 Request Parameter 과 변수명을 일치 시켜주는 것으로 받아온 그대로 사용하면 굳이 안넣어줘도 상관 없지만 임의대로 API에서 Request Parameter외 다른 변수명으로 바꾸기 위해서는 반드시 필요하다.
  • 기존 Request Parameter는 body 였지만, 코드 사용 편의를 위해서 DustBoy, 밑 DustHeader로 바꾼 상황에서 @serializedName("body"), ("header") 이 없다면 기존의 Request Parameter를 일치시킬 수 없어 오류가 생김
data class DustResponse(
    @SerializedName("body")
    val body: DustBody,
    @SerializedName("header")
    val header: DustHeader
)

 

6. Retrofit, OkHttp3 통해 API 네트워크 통신

Constants.kt

  • API정보와 로직을 분리 해놓았다
object Constants {
    const val DUST_BASE_URL = "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc/"
    const val API_KEY = BuildConfig.dustApiKey
}

NetworkClient.kt

object NetworkClient {

    private fun createOkHttpClient(): OkHttpClient{
        val interceptor = HttpLoggingInterceptor()

	// 디버깅 시 사용된다.
        if (BuildConfig.DEBUG)
            interceptor.level = HttpLoggingInterceptor.Level.BODY
        else
            interceptor.level = HttpLoggingInterceptor.Level.NONE

        return OkHttpClient.Builder()
            .connectTimeout(20, TimeUnit.SECONDS)
            .readTimeout(20,TimeUnit.SECONDS)
            .writeTimeout(20,TimeUnit.SECONDS)
            .addNetworkInterceptor(interceptor)
            .build()
    }

    private val dustRetrofit = Retrofit.Builder()
        .baseUrl(Constants.DUST_BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(createOkHttpClient())
        .build()

    val dustNetwork: NetWorkInterface = dustRetrofit.create(NetWorkInterface::class.java)
}

 

NetWorkInterface.kt

interface NetWorkInterface {
    @GET("getCtprvnRltmMesureDnsty")
    suspend fun getDust(@QueryMap param: HashMap<String, String>): Dust
}

 

  • Retrofit 이란?
    • Retrofit은 Type-safe한 REST 통신 라이브러리이다. RESTful API를 호출하고 JSON 또는  XML 과 같은 응답 데이터를 쉽게 파싱할 수 있도록 도와준다.
    • OkHttp 라이브러리의 상위 구현체로, OkHttp를 네트워크 계층으로 활용하고 그 위에 구축된다. API 30부터 deprecated 된 Async Task를 통해 작업하는 방식이 아닌 AsyncTask 없이 Background Thread를 실행하고 Callback을 통해 Main Thread에서 UI를 업데이트 할 수 있다.
  • Retrofit의 장점
    • 간단한 구현
      • 복잡한 HTTP API 요청을 쉽고 간결하게 만들 수 있다
      • 간단한 어노테이션을 통해 요청 메서드와 URL을 정의할 수 있어 HttpUrlConection의 Connection / Input & OutputStream / URL Encoding 생성 및 할당의 반복작업으로 가능.
    • 가독성
      • @Get, @Headers 등의 Annotation 사용으로 API 요청 메서드 등 인터페이스에 선언적으로 정의할 수 있다. 코드의 가독성이 뛰어남, 직관적인 설계가 가능하다.
    • 안정성과 확장성
      • 내부적으로 OkHttp 라이브러리를 사용하여 통신한다. 이를 통해 안정적인 통신이 가능하며 이로 인해 인터셉터를 사용하여 요청 / 응답 프롯스를 확장하거나 수정할 수 있다.
    • 동기 / 비동기 구현이 용이
      • 코루틴과 함께 사용할 수 있어 비동기 코드 작성이 더 쉽다.

 

 

  • OkHttp에 대해서도 간단하게 알아보자
    • OkHttp는 Http 클라이언트 라이브러리 이다. OkHttp는 애플리케이션에서 네트워크 통신을 처리하는 데 사용된다.
  • OkHttp의 장점 
    • HTTP/1,2,3 프로토콜을 지원하여 빠르고 효율적인 네트워크 통신을 가능하게 한다.
      • HTTP/2,3 프로토콜 ?
      • 웹 통신을 위해 최신 버전의 프로토콜이다. 빠르고 효율적인 네트워크 통신을 가능하게 한다
    • 인터셉터 지원
      • OkHttp는 인터셉터를 사용하여 요청 및 응답을 수정하고 로깅할 수 있다. 이를 통해 네트워크 요청과 Response를 받아 필요한 작업을 수행할 수 있다.
    •  Caching
      • Caching? 이전에 수신한 네트워크 응답을 로컬 또는 메모리에 저장하는 것.
        • 반복적인 요청에 대한 네트워크 비용과 시간이 절약.
        • 불안정한 네트워크 연결 상황에서도 이전에 수신되어 캐시된 응답을 사용하므로 안정성 향상.
        • 네트워크 대역폭을 절약하며 서버 부하를 줄여줌으로 시스템의 전반적인 성능 향상.
      • OkHttp는 HTTP응답을 캐싱하여 네트워크 사용량을 줄이고 응답 시간을 단축할 수 있도록 한다.
  • OkHttp가 Retrofit의 Base로 사용되는 이유.
    • OkHttp를 사용하지 않고 HTTP 통신을 하기 위해서는 다음과 같은 과정을 통해야한다
      • 1. HttpURLConnection 연결
      • 2. Buffer를 통한 입출력
      • 3. 예외 처리 등
    • 하지만 OkHttp를 사용하게 될 경우 이러한 부분을 해결할 수 있다. 이로 인해 Retrofit 라이브러리에서 내부 코드에서도 OkHttp 클라이언트를 디폴트로 선언한다.

 

 

7. MainActivity에서 UI와 데이터 연결 - 유저 , UI 상호작용 이벤트 구현

class MainActivity : AppCompatActivity() {
    private lateinit var binding : ActivityMainBinding

    var items = mutableListOf<DustItem>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.mainSpinnerProvince.setOnSpinnerItemSelectedListener<String> { _, _, _, text ->
            communicateNetwork(setUpDustParameter(text))
        }

        binding.mainSpinnerCity.setOnSpinnerItemSelectedListener<String>{ _, _, _, text ->

            var selectedItem = items.filter { f -> f.stationName == text }

            binding.mainTvSelectedCity.text = selectedItem[0].sidoName + " " + selectedItem[0].stationName
            binding.mainTvDate.text = selectedItem[0].dataTime
            binding.mainTvPM.text = selectedItem[0].pm10Value + " ㎍ / ㎥"

            when (getGrade(selectedItem[0].pm10Value)){
                1 -> {
                    binding.mainConstLayout.setBackgroundColor(Color.parseColor("#93B194"))
                    binding.mainImgEmoji.setImageResource(R.drawable.smiling_facewith_smiling_eyes)
                    binding.mainTvPMGrade.text = "좋음"
                }

                2 -> {
                    binding.mainConstLayout.setBackgroundColor(Color.parseColor("#9CC5D8"))
                    binding.mainImgEmoji.setImageResource(R.drawable.neutral_face)
                    binding.mainTvPMGrade.text = "보통"
                }

                3 -> {
                    binding.mainConstLayout.setBackgroundColor(Color.parseColor("#DCD7B3"))
                    binding.mainImgEmoji.setImageResource(R.drawable.downcast_face_with_sweat)
                    binding.mainTvPMGrade.text = "나쁨"
                }

                4 -> {
                    binding.mainConstLayout.setBackgroundColor(Color.parseColor("#F6C883"))
                    binding.mainImgEmoji.setImageResource(R.drawable.dizzy_face)
                    binding.mainTvPMGrade.text = "매우 나쁨"
                }
            }
        }
    }

    private fun communicateNetwork(param: HashMap<String, String>) = lifecycleScope.launch() {
        val responseData = NetworkClient.dustNetwork.getDust(param)
        
        items = responseData.response.body.items

        val city = ArrayList<String>()
        items.forEach {
            city.add(it.stationName)
            city.sort()
        }

        // items 로 부터 얻어온 city를 비동기적으로 mainSpinnerCity에 추가함.
        runOnUiThread {
            binding.mainSpinnerCity.setItems(city)
        }
    }

    private fun setUpDustParameter(province: String): HashMap<String,String>{
        val authKey = Constants.API_KEY

        return hashMapOf(
            "serviceKey" to authKey,
            "returnType" to "json",
            "numOfRows" to "100",
            "pageNo" to "1",
            "sidoName" to province,
            "ver" to  "1.0",
        )
    }

    fun getGrade(value: String): Int{
        val mValue = value.toInt()
        var grade = 1
        grade = if (mValue >= 0 && mValue <= 30){
            1
        } else if (mValue >= 31 && mValue <= 80){
            2
        } else if (mValue >= 81 && mValue <= 100){
            3
        } else 4
        return grade
    }
}

 

 

 

 

 


 

[오늘 복습한 내용]

1. API 키 생성 및 등록 방법, Key 관리하기

 


[오류,에러 등등]

1.BuildConfig가 안보이는 상황

Secrets Gradle Plugin의 홈페이지에 있는대로 적용법을 그대로 따라하는데 나한테는 BuildConfing가 나오지 않아서 한참 찾았다.

 

해결방법

이전에는 프로젝트 생성 시 BuildConfing에 별도로 설정을 하지 않으면 ( Default 값 )이 BuildConfig가 생성이 되는 것이었는데, Android gradle plugin 8.0 이후부터는 기본적으로 생성이 되지 않는다. 따라서 별도로 생성해줘야 한다.

 

생성방법

build gradle(module) 에서 ViewBinding를 적용하는 방법 처럼 생성해주면 된다. viewBinding은 예시로 넣어둔 것이다.

    buildFeatures{
        viewBinding = true
        buildConfig = true
    }

 

 

2. Http 는 접근이 불가능하다고 하는 오류 

해결방법 → Manifest <application> 내부에 코드 작성으로 임시로 해결 ( 권장되는 해결방법 X ) </application>

android:usesCleartextTraffic="true"

 

 

3. 오타 실수

Request Parameter sidoName 을 sideName으로 쳤다. sideName은 sidoName 처럼 초록색 밑줄이 나오는거랑 다르게 초록색 밑줄도 안나와서 눈에 더 안띄었다. 덕분에 이곳 저곳에 Log 찍어가면서 재밌게 학습했다

 


[느낀 점]

1. 오타로 인해서 오류가 생긴지도 모르고 Log를 생성해가면서 정상적인 코드를 망가트렸다가, 고쳤다가 하다보니까 금방 이해가 된 것 같다. 

 

2. 어렵긴 한데 재미있다.

 

3. 더 열심히 하자

 

 


[Reference]

 

// Retrofit2

https://hello-bryan.tistory.com/507

https://jsonobject.tistory.com/570

https://jaejong.tistory.com/33

// OkHttp

https://velog.io/@heetaeheo/okhttp-Retrofit

// BuildConfig

https://pluu.github.io/blog/android/androiddevsummit/2022/10/30/ads22-Whats-new-in-Android-Build/