Kakao API


카카오 Developers 사이트 내에서 제공해주는
이미지 검색API를 연결하여 사용합니다.


간단히 요약

  • [내 애플리케이션 > 앱 설정 > 요약 정보] 내 REST API 키 확인

  • 이미지 검색하기 API 문서 확인

  • API Tester Talend 크롬 확장 프로그램 설치

  • API Tester 확장 프로그램 열어주고 API 문서와 아래 이미지 참고 해서 입력

    HEADERS에만
    Authorization: KakaoAK {아까 본인이 확인했던 내 REST_API_KEY}
    신경써서 적어주시면 끝입니다

200으로 통신이 성공되고
데이터도 잘 보이나요?

그럼 준비 끝 👏



Quick Start


1. new compose Project

  • new project -> Empty Compose Activity

2. add internet permission

AndroidManifest.xml

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

위치 참고:

<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
    ...

3. add dependencies

build.gradle

// retrofit2 (http)
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
// retrofit2 (converter)
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'

4. project 내 레이어 분리

  • com.example.프로젝트명 우클릭 - New - Package

  • 아래와 같이 data, domain, ui 레이어 이렇게 3개로 분리
    (ui 레이어는 원래 있어요 ui.theme 이렇게 등록되어 있을 겁니다.)

  • 그리고 레이어를 아래와 같이 좀 더 조금 더 세분화
    ui.theme 제외하고 나머지는 새로 생성해줍니다.

    data
     ㄴ data_source
     ㄴ model
    domain
    ui
     ㄴ main
     ㄴ theme (기존에 있었음)

5. data layer에 data source 연결 작업

이미지 검색 API 문서를 보고 연결 작업을 해줍니다.

만들어 둔 [data - data_source] 패키지에
KakaoAPI.kt 파일을 생성

code:

interface KakaoAPI {
    @GET("v2/search/image")
    fun getSearchImage(
        @Header("Authorization") token: String,
        @Query("query") query: String,
    ): Call<Map<String, Any>>
}
  • interface로 생성
  • @Get("") 했을 때 링크 주소는 맨 앞에 / 제외
  • parameter required로 되어있는 query
    Header에 올라갈 Authorization 값만 파라미터로 생성

그러고 나서 [data - data_source] 패키지에
RemoteDataSource.kt 파일을 생성

code:

private const val TAG: String = "REMOTE DATA SOURCE"
private val retrofit: Retrofit = Retrofit.Builder().baseUrl("https://dapi.kakao.com/")
    .addConverterFactory(GsonConverterFactory.create()).build()
private val retrofitAPI: KakaoAPI = retrofit.create(KakaoAPI::class.java)

fun searchImage(query: String = "hello"): List<String> {
    val results = mutableListOf<String>()
    val call: Call<Map<String, Any>> = retrofitAPI.getSearchImage(
        query = query, token = "KakaoAK 내 REST_API_KEY"
    )

    call.enqueue(object : Callback<Map<String, Any>?> {
        override fun onResponse(
            call: Call<Map<String, Any>?>,
            response: Response<Map<String, Any>?>,
        ) {
            Log.d(TAG, "onResponse 성공")
            if (response.isSuccessful) {
                val responseBody = response.body()!!
                val docs = responseBody["documents"] as List<*>
                for (doc in docs) {
                    Log.d(TAG, "doc: $doc")
                    results.add(doc.toString())
                }
            }
        }

        override fun onFailure(call: Call<Map<String, Any>?>, t: Throwable) {
            Log.d(TAG, "onFailure 실패")
        }
    })
    return results
}
  • 클래스나 object로 따로 감싸지 않고 함수를 바깥으로 빼서 작성

  • API당 함수가 계속 생성 될 예정이라 공통적으로 사용하는 TAG, retrofit, retrofitAPI 바깥 변수로 빼서 작성

  • 그리고 remote api에 요청할 searchImage() 함수 작성

    • query는 default text 주고, 파라미터로 전달 받을 수 있게 빼놓음

    • call.enqueue 내에서 응답 성공 시 onResponse(), 응답 실패 시 onFailure() 비동기로 호출 됨

    • results로 List<String> 객체 만들어놓고 성공하든 실패하든 list 객체 뱉을 수 있게 작업


6. data layer에 model 객체 생성 (data class)

[data - model] 패키지에 Document.kt 파일을 생성

data class Document(
    val collection: String,
    val datetime: String,
    val displaySitename: String?,
    val docURL: String,
    val height: Double,
    val imageURL: String,
    val thumbnailURL: String?,
    val width: Double,
) {
    companion object {
        fun fromJson(json: Map<String, Any>): Document = Document(
            collection = json["collection"] as String,
            datetime = json["datetime"] as String,
            displaySitename = json["display_sitename"] as String,
            docURL = json["doc_url"] as String,
            height = json["height"] as Double,
            imageURL = json["image_url"] as String,
            thumbnailURL = json["thumbnailURL"] as String?,
            width = json["width"] as Double,
        )
    }
}
  • data class로 생성
  • companion object로 fromJson() 함수 생성
    : json을 전달 받으면 Document 객체를 반환하는 함수

7. data source에 반환 타입 변경

기존 함수 반환 타입인 List<String>List<Document>로 변경

fun searchImage(query: String = "hello"): List<Document> {
    val results = mutableListOf<Document>()
    val call: Call<Map<String, Any>> = retrofitAPI.getSearchImage(
        query = query, token = "KakaoAK 내 REST_API_KEY"
    )

    call.enqueue(object : Callback<Map<String, Any>?> {
        override fun onResponse(
            call: Call<Map<String, Any>?>,
            response: Response<Map<String, Any>?>,
        ) {
            Log.d(TAG, "onResponse 성공")
            if (response.isSuccessful) {
                val responseBody = response.body()!!
                val docs = responseBody["documents"] as List<*>
                for (doc in docs) {
                    val castedDoc = Document.fromJson(doc as Map<String, Any>)
                    Log.d(TAG, "doc: $castedDoc")
                    results.add(castedDoc)
                }
            }
        }

        override fun onFailure(call: Call<Map<String, Any>?>, t: Throwable) {
            Log.d(TAG, "onFailure 실패")
        }
    })
    return results
}
  • 초반 빈 리스트도 mutableListOf<Document>() 로 생성
  • API 응답 성공 시 Document.fromJson 이용해서 데이터 객체로 변환

8. UI 레이어에 데이터 연결

주저리:

repository 생성 후 data 레이어와 domain 레이어로 연결 짓고,

UI 내에서도 MVI또는 MVVM 패턴으로 생성 후 이벤트를 호출할 함수를 따로 만들고 이벤트 호출 함수에서 domain 레이어와 연결을 하는게 원칙이긴 하나..! (주절주절)

그냥 바로.. UI 출력하는 MainActivity.kt에 .. 바로..바로!!! 연결해보겠습니다!!...

위에 얘기했던 저 작업들은 계속 차차 같이 고도화 시켜나가보아요

MainActivity.kt
code:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val (query, setQuery) = rememberSaveable { mutableStateOf("") }
            val list = mutableListOf<Document>()

            ComposeAPIExampleTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background
                ) {
                    SearchPage(
                        query = query, setQuery = setQuery,
                        onSearch = {
                            val results = searchImage(query = query)
                            list.addAll(results)
                            Log.d("TAG", "onCreate: list $list")
                        },
                        results = list.toString(),
                    )
                }
            }
        }
    }
}

@Composable
fun SearchPage(
    query: String,
    setQuery: (String) -> Unit,
    onSearch: () -> Unit,
    results: String,
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(20.dp)
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            OutlinedTextField(value = query, onValueChange = setQuery)
            IconButton(onClick = onSearch) {
                Icon(imageVector = Icons.Default.Search, contentDescription = "")
            }
        }

        Text(
            text = results, modifier = Modifier
                .fillMaxSize()
                .background(Color.Yellow)
        )
    }
}
  • 우선 화면을 보여줄 SearchPage 컴포저블 함수 생성
  • 버튼 클릭 시 data source에 있는 searchImage()가 호출되어 검색

여기서 생긴 문제


callback 비동기 함수 처리로 보장을 못 받고
list.addAll()에 빈 리스트가 담기고 나서 곧바로 Log로

D/TAG: onCreate: list []

출력 되고 바로 그 뒤에 데이터를 가져온 것을 볼 수 있다.

D/REMOTE DATA SOURCE: onResponse 성공
D/REMOTE DATA SOURCE: doc: Document(collection=cafe, datetime=2016-03-13T20:12:36.000+09:00, displaySitename=Daum카페, docURL=https://cafe.daum.net/yangdreamcommunity/BmPG/289, height=200.0, imageU

주저리:

비동기 처리 방식만 공부하면 간단하게 해결 될 문제인 줄 알고 과거에 뭣도 모르고 접근한 과거의 제가 궁금하다면..?

이전에 작성한 Flutter에서 async, await 비동기 함수 Kotlin 에선 어떻게? 글 보러가기 🤷‍♀️


두가지 문제점 발견 🔍

  1. API와 호출하는 call.enqueue 에서 비동기로 함수를 처리하기에 Callback을 받을 방법을 찾아야 한다.

  2. compose 내에서 list 내부 데이터가 바뀌었을 때 리컴포저블(화면 재빌드) 해주고 값을 출력할 방법을 찾아야 한다.


1) 비동기로 함수 call.enqueue 에서 Callback을 받을 방법


방법1: LiveData 사용해 옵저빙하기

요 스택오버플로우에 call enqeue에서 데이터를 가져온 후 반환 하는 방법은 무엇인지? 질문글 발견

답변은 "비동기 호출을 하고 있어서, 비동기 호출이 resolves 되기 전까지 데이터가 설정되지 않는다, 그런데 질문자가 MutableLiveData를 생성하고 있어서 비동기 값 호출 될 떼 업데이트를 제공해 옵저빙이 가능해야 한다" 라고 작성 되어있음


결과:
우선 지금까지 난 LiveData를 배워보고 사용한 적 없고
데이터 타입인 List를 유지하고 쓰고 싶다..
(나중에 viewmodel로 변환해서 쓰고 싶지 LiveDatas는 계획에 없었음)

그러면 LiveData를 안쓰고 하는 방법은 없나?



방법2: CallBack 처리

그 다음 바로 운 좋게 이 블로그 글 발견!

해당 글에선 onResponseSuccess와 onResponseFailure 함수를 매개변수로 받는 고차함수로 생성 하는 방법을 첫번째로 알려준다.

해당 방식은 코틀린 공시문서에 비동기 프로그래밍 기술 중 Callback 방식 이였다. CallBack 방식으로 했을 때 단점은 중첩된 콜백이 많아지면 복잡해지고, 오류처리 또한 복잡해 힘들어 진다고 서술 되어있음


적용:

RemoteDataSource.kt

fun searchImage(
    query: String = "hello",
    onResponseSuccess: (
        Map<String, Any>,
    ) -> Unit,
) {
    ...

    call.enqueue(object : Callback<Map<String, Any>?> {
        override fun onResponse(
            call: Call<Map<String, Any>?>,
            response: Response<Map<String, Any>?>,
        ) {
            Log.d(TAG, "onResponse 성공")
            if (response.isSuccessful) {
                val responseBody = response.body()!!
                onResponseSuccess.invoke(responseBody)
            }
        }

        override fun onFailure(call: Call<Map<String, Any>?>, t: Throwable) {
            Log.d(TAG, "onFailure 실패")
        }
    })
}
  • 우선 성공 케이스만 매개변수로 뺐다.
  • searchImage()의 반환 타입이 기존에 List 였는데, 아무것도 반환하지 않는 Unit로 처리
  • 매개변수로 받을 성공 후 처리 함수는 .invoke로 수행 시켜준다.
    : 물론 invoke를 생략하고 onResponseSuccess(responseBody)로 작성도 가능하다!!

MainActivity.kt

...

onSearch = {
    searchImage(query = query, onResponseSuccess = {
        val docs = it["documents"] as List<*>
        for (doc in docs) {
            val castedDoc =
                Document.fromJson(json = doc as Map<String, Any>)
            list.add(castedDoc)
            Log.d("TAG", "onCreate: list $list")
            Log.d("TAG", "------")
        }
    })
}
  • 매개변수로 람다가 하나 있을 때 onResponseSuccess = {} 이렇게 작성 안하고 바로 {}로 처리 함수가 빠져도 된다
  • 그러고 기존에 searchImage()에 성공시 처리하던 로직을 빼서 내 리스트에 넣는다.

결과:

성공이다.

공식문서에 나와있던 대로 조금 코드가 중첩되어 여러개일 땐 지저분하겠지만 우선 UI 상 데이터를 받아 넣는건 성공했다..!

도움이 된 해당 블로그 에선 Callback 처리를 다루고 두번째 방법으로 MutableLiveData 를 이용해 더 간단하게 만든다.

LiveData는 나중에 학습하면서 해보는 걸로 하고
우선 callback 받아 처리하는 형태로
해당 문제는 마무리 지어야 겠다.



23.04.21 업데이트

LiveData가 옵저빙 해주는 객체인데
Compose엔 state 객체가 옵저빙을 해주고 있지 않는가..

그래서 Callback 처리 방식 대신 다른 글들에서 추천을 하던 옵저빙 가능한 객체(ex. LiveData, State)를 전달해 처리 할 수 있도록 방식을 바꿔보았다.

당연히 동작은 잘 되며,
Callback 처리 보다 코드가 가독성이 높아져 좋은 것 같다.

State값을 넘겨 변경을 감지하고 Compose로 그린 화면에서 어떻게 바뀌는지 코드는 어떻게 작성했는지 궁금하다면
변경한 commit을 git link를 확인해 적용해보시길 바란다!


full code link: https://github.com/adbr-brandi/compose_api_example/tree/feature/1_simple_connection



2) 리컴포지션 될 List 상태값 출력은 어떻게..?


아직 끝난게 아니다.

val (query, setQuery) = rememberSaveable { mutableStateOf("") }

우선 나는 compose에서 상태를 다룬게 여태 String, int 뿐이였다.

그래서 mutableStateOf() 이용해 상태를 변경하고
데이터 변경이 일어나 리컴포지션이 일어나면
remember{] 또는 rememberSaveable{} 를 이용해 객체를 저장했다.

그래서 MutableState 안에 내부 값을 접근하는 .value 방식 또는 by 방식 또는 구조분해 방식은 껌으로(?) 알고 있었다.

컴포즈 상태? 잘 모르겠다 싶으면
아래 공식문서를 확인해보자 🏃‍♀️
https://developer.android.com/jetpack/compose/state?hl=ko


근데?

리스트는 딱 봐도 mutableStateOf에 담지 못하고
따로 제공되는 mutableStateListOf에 담아 사용하라는 것 같다.

근데 문제는 리스트 상태 값 저장..?
모르겠다 찾아 공부해봐야한다..


그래서 상황을 말하자면

  1. remember{}로 감싸면? 앱이 실행이 안되고

  2. 바뀐건지 아닌지 확인해볼려고 .toString 했을 때 주소값으로 출력되고..
    내부 값을 출력하고 싶은데 어떻게 해야하는지도 모르겠고..


그러면 그 두가지.. 말고 써보지 뭐..

@SuppressLint("UnrememberedMutableState")

...

val list = mutableStateListOf<Document>()

그러면 우선 remember 감싸는거 없애고 toString 대신
list 내부값 쪼개서 출력할 LazyColumn이용해본다.


결과는?

..! 된다.

정확히말하면 리컴포지션이 일어나면?
빈 리스트로 되버려서 remeber가 안되는 것 빼고 된다.

그러면 list status값을 remeber 하는걸
나중에 다른 글로 맞이하는걸로 하고 오늘 글은 여기로 마무리 지어보려 한다..

23.04.21 업데이트

그렇다 해냈다.

[Jetpack Compose] remember List state 요 글을 읽어보면 해결하는 미래의 나를 만날 수 있다.


Solved Full code


MainActivity.kt

class MainActivity : ComponentActivity() {
    @SuppressLint("UnrememberedMutableState")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val (query, setQuery) = rememberSaveable { mutableStateOf("") }
            val list = mutableStateListOf<Document>()

            ComposeAPIExampleTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background
                ) {
                    SearchPage(
                        query = query, setQuery = setQuery,
                        onSearch = {
                            searchImage(query = query) {
                                val docs = it["documents"] as List<*>
                                for (doc in docs) {
                                    val castedDoc =
                                        Document.fromJson(json = doc as Map<String, Any>)
                                    list.add(castedDoc)
                                }
                            }
                        },
                        results = list,
                    )
                }
            }
        }
    }
}

@Composable
fun SearchPage(
    query: String,
    setQuery: (String) -> Unit,
    onSearch: () -> Unit,
    results: List<Document>,
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(20.dp)
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            OutlinedTextField(value = query, onValueChange = setQuery)
            IconButton(onClick = onSearch) {
                Icon(imageVector = Icons.Default.Search, contentDescription = "")
            }
        }
        Spacer(modifier = Modifier.size(20.dp))
        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(10.dp),
            contentPadding = PaddingValues(horizontal = 4.dp, vertical = 10.dp),
        ) {
            items(items = results) {
                Card(modifier = Modifier.fillMaxWidth(), elevation = 10.dp) {
                    Row() {
                        Text(it.imageURL)
                    }
                }
            }
        }
    }
}

RemoteDataSource.kt

private const val TAG: String = "REMOTE DATA SOURCE"
private val retrofit: Retrofit = Retrofit.Builder().baseUrl("https://dapi.kakao.com/")
    .addConverterFactory(GsonConverterFactory.create()).build()
private val retrofitAPI: KakaoAPI = retrofit.create(KakaoAPI::class.java)

fun searchImage(
    query: String = "hello",
    onResponseSuccess: (
        Map<String, Any>,
    ) -> Unit,
) {
    val call: Call<Map<String, Any>> = retrofitAPI.getSearchImage(
        query = query, token = "KakaoAK 내_REST_API_KEY"
    )

    call.enqueue(object : Callback<Map<String, Any>?> {
        override fun onResponse(
            call: Call<Map<String, Any>?>,
            response: Response<Map<String, Any>?>,
        ) {
            Log.d(TAG, "onResponse 성공")
            if (response.isSuccessful) {
                val responseBody = response.body()!!
                onResponseSuccess(responseBody)
            }
        }

        override fun onFailure(call: Call<Map<String, Any>?>, t: Throwable) {
            Log.d(TAG, "onFailure 실패")
        }
    })
}

full code (git link):
https://github.com/adbr-brandi/compose_api_example/tree/main

profile
𝙸 𝚊𝚖 𝚊 𝚌𝚞𝚛𝚒𝚘𝚞𝚜 𝚍𝚎𝚟𝚎𝚕𝚘𝚙𝚎𝚛 𝚠𝚑𝚘 𝚎𝚗𝚓𝚘𝚢𝚜 𝚍𝚎𝚏𝚒𝚗𝚒𝚗𝚐 𝚊 𝚙𝚛𝚘𝚋𝚕𝚎𝚖. 🇰🇷👩🏻‍💻

1개의 댓글

comment-user-thumbnail
2023년 4월 21일

해당 글을 읽고
https://velog.io/@adbr/Jetpack-Compose-remember-List-state
내용을 읽으셔야 비로소 완성이 됩니다 🙇🏻‍♀️

답글 달기