WAP에서 진행하는 랜덤 개발 챌린지에 참가하게 되었다! 랜덤 개발 챌린지는 참가자가 주제를 선정하고, 랜덤으로 배정받아 개발하는 챌린지이다. 내가 배정된 주제는 "취업"이였고, 현재 상황에서 산업기능요원을 준비하고 있는 나에게 필요한 어플리케이션을 개발하고 싶었다. 그래서 IT산업기능요원 채용 플랫폼 WANTED를 이은 (kid)NAPPED를 기획하게 되었다.
사실 어느정도 생각은 하고 있던 프로젝트여서, 문제 없이 금방 개발할 수 있을 것 같았다. 그 API를 자세히 보기 전까지,,
병무청에서는 병역일터 채용공고 API를 제공하고 있다. 해당 API는 현재 산업기능요원 및 전문연구요원을 모집하고 있는 회사의 공고를 확일할 수 있는 공공 API이다. 이번 포스팅에서는 해당 공공 API를 사용하면서 발생했던 문제와 해결한 방법을 공유한다.
1. 이 API 반환값이 XML인데요?
해당 API의 문서를 확인해보면, JSON도 가능하다고 적혀있어서, 요청 변수를 통해 JSON을 받을 수 있다고 생각했다. 따라서 처음 기획할 당시에도, 자주 사용하는 직렬화 라이브러리 KotlinSerialization을 사용하려고 했다. 그런데 확인해보니 반환 값이 (only)XML이였고, Kotlin Serialization은 XML을 지원하지 않고 있었다..!
그래서 다른 XML Serialization을 지원하는 라이브러리를 확인하던 도중, Retorfit2을 만든 Squre사의 SimpleXml을 확인할 수 있었다. 그런데 공식 Github을 확인해보니 SimpleXml은 이미 Deprecated되고, 안드로이드 버전은 제공하지 않는 JAXB converter를 이어서 개발하고 있었다.
한참을 Android XML Serialization을 뒤져보던 도중, Ktor가 눈에 들어왔다! Ktor는 Retrofit2과 같이, 통신을 위해 사용하는 라이브러리이다. 해당 Ktor의 Content Negotiation에서 XML Serialization을 제공하고 있었다!
필자는 Ktor를 알고만 있었고, 사용해 본 경험은 없다. 이번에 겸사겸사 사용하면 좋을 것 같아, 바로 Retrofit2를 걷어내고, Ktor로 Migration을 진행했다. 아래는 간략하게 사용 방법을 소개한다.
1-1 Ktor Dependency 추가
dependencies {
// Ktor, Ktor Xml Serialization(xml_utils)
val ktor_version = "2.3.3"
implementation("io.ktor:ktor-client-core:$ktor_version")
implementation("io.ktor:ktor-client-cio:$ktor_version")
implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-xml:$ktor_version")
implementation("io.ktor:ktor-client-logging:$ktor_version")
}
추가적으로 CIO는 Coroutine Based I/O를 의미하며, 네트워크 통신을 위한 엔진을 의미한다. Logging은 말그대로, 로깅전략을 위해 사용하였다.
1-2 DI 설정 - Hilt 사용
@Module
@InstallIn(SingletonComponent::class)
object ClientModule {
@Provides
@Singleton
fun provideHttpClient(): HttpClient {
return HttpClient(CIO) {
engine {
endpoint {
connectTimeout = TimeoutLimit
connectAttempts = AttemptsLimit
}
}
defaultRequest {
url(BuildConfig.BASE_URL + "?ServiceKey=${BuildConfig.API_KEY}")
contentType(ContentType.Application.Xml)
}
install(ContentNegotiation){
xml(format = XML {
xmlDeclMode = XmlDeclMode.Charset
})
}
install(Logging){
logger = object : Logger {
override fun log(message: String) {
if (BuildConfig.DEBUG) {
Timber.d(message)
}
}
}
level = LogLevel.ALL
}
}
}
}
Ktor에 대한 게시글이 아직은 많이 안보여서, Ktor Docs를 보면서 구성했다. 파라미터들이 다 가독성이 좋아서, 구성하는데 어렵지 않았다. 짚어보자면, defaultRequest를 통해 기본 url과 api key를 연결하고, 반환 타입을 설정하였다. 또한 install 블럭안에, xml Serialization을 구성하였다.
1-3 Response 구현
@Serializable
@XmlSerialName("response", "", "")
data class EmploymentAllResponse(
val body: EmploymentBodyResponse,
val header: EmploymentHeaderResponse
)
@Serializable
@XmlSerialName("body", "", "")
data class EmploymentBodyResponse(
val items: EmploymentListResponse,
@XmlElement(true) val numOfRows: Int,
@XmlElement(true) val pageNo: Int,
@XmlElement(true) val totalCount: Int
)
...
정말 알아보는데 오래걸렸던 부분이다. 해당 부분 위에는 다 Docs가 잘되어 있는데, 이번 Response부분에 대한 소개는 거의 확인할 수 없었다. 다시 무한 구글링을 하였고, 하나의 사실을 발견할 수 있었다. 사실 이 ktor-xml-serialization은 xmlutils을 가져와서 사용하고 있다는 점이였다. 따라서 해당 라이브러리의 github example을 통해 사용방법을 확인할 수 있었다. 해당 라이브러리 관련된 포스팅도 거의 없었다..ㅠㅠ
따라서 해당 라이브러리를 소개하자면, Kotlin Serialization과 호환되는 라이브러리이다! 따라서 XML 직렬화를 원하는 객체는 Kotlin Serialization과 같이 @Serializable을 붙여야 한다. 그러나 이전과 @SerialName과 다르게 @XmlSerialName을 통해 Response 이름을 설정해야 한다. 분명, @SerialName도 된다고 했는데, 나는 잘 안되었다.
@XmlSerialName은 세 개의 파라미터를 받고 있는데, XML 특성상 주소와 접두사가 함께 붙어서 오는 경우에 사용한다. 마지막으로 필드의 타입이 원시타입인 경우 @XmlElement(true)를 붙여야 한다.
구현을 하고보니까 별거 없어보이는데, 여기까지 하루 넘게 걸렸다,, 🥹🥹
1-4 GET 메소드 호출하기
class EmploymentServiceImpl @Inject constructor(
private val client: HttpClient
): EmploymentService {
override suspend fun getEmploymentList(pageCount: Int, page: Int): List<EmploymentResponse> {
val response = client.get(""){
url{
parameters.append("numOfRows", pageCount.toString())
parameters.append("pageNo", page.toString())
}
}.body<EmploymentAllResponse>()
return response.body.items.item
}
}
모든 통신을 위한 설정을 마치고, 위와 같이 호출해주면 끝이다!
해당 XML Seralization을 사용하기 위해, Ktor를 사용해보았는데, 나름 코드가 직관적이고, 적은 코드량으로 통신할 수 있어 좋은 것 같았다! GET 하나만 해봐서, 사실 Retrofit2와 비교하기는 힘들 것 같다. 그래도 다른 라이브러리와 호환이 가능한 점, 작은 코드 작성만으로도 Retrofit2를 대체할 수 있다고 생각한다.
해당 문제 이후 또 문제가 발생하지 않을 것 같았다..
2. 아이템 Id가 목록과 아무런 관계가 없는데요?
위에서 소개한 API는 반환값으로 20개가 넘는 파라미터를 제공한다. 사용자 입장에서는 많은 정보를 확인하면 좋은데, 모든 회사의 정보를 확인하고 싶지는 않을 것이다. 그래서 나는 채용 제목, 회사, 급여와 같은 일부 정보만 리스트에 노출시키고, 리스트 아이템을 클릭하면 상세 페이지로 전환해서 확인할 수 있도록 구성하고자 했다.
그렇게 하기 위해서는 ID값 조회 메소드가 필요했다. 그런데 당연히 해당 API는 제공하지 않는다. 그래서 필자는 로컬 캐싱을 통해, 내부에 저장된 데이터에서 ID값 조회를 통해 구현하기로 결정했다. 로컬 캐싱을 구현하기 위해서 Paging3와 RemoteMediator를 사용하였다.
Paging3는 API의 요청 파라미터에 현재 페이지 번호와 아이템 갯수를 설정하여, API 반환값을 로컬 데이터베이스에 저장하는 형태이다. 그런데, 위의 API의 아이템 id가 목록과 아무런 관계가 없었다. 예를들면,
- 1번 데이터 id : 20001923
- 2번 데이터 id : 20001331
- 3번 데이터 id : 20001749
id가 목록 인덱스와 동일한 경우, id와 목록 사이즈를 통해 다음 페이지 번호를 유추할 수 있으나, 위처럼 아무런 관계가 없는 경우, Paging3에서 다음 페이지 번호를 알기 어렵다. 이런 경우, 공식 안드로이드 문서에서는 RemoteKey 테이블을 생성하여 대응하라고 작성되어 있다. 따라서 RemoteKey를 함께 캐싱한 방법을 간략하게 소개한다.
2-1. RemoteKey Entity, Dao 생성하기
@Entity(tableName = "remote_keys")
data class RemoteKeyEntity(
@PrimaryKey val repoId: Long,
val nextKey: Int?
)
@Dao
interface RemoteKeyDao {
@Upsert
suspend fun insertRemoteKeyList(remoteKey: List<RemoteKeyEntity>)
@Query("SELECT * FROM remote_keys WHERE repoId = :id")
suspend fun getRemoteKeyById(id: Long): RemoteKeyEntity?
@Query("DELETE FROM remote_keys")
suspend fun clearRemoteKey()
}
RemoteMediator의 load() 메소드에서, PREPEND를 구현하고 싶으면, previousKey도 엔티티에 추가해야 한다. 필자는 APPEND만 구현하기 때문에, id와 nextKey로 Entity를 구성했다.
Dao에서는 무관한 id값을 통해 다음 페이지 번호(next key)를 얻을 메소드와 삽입, 삭제 메소드를 구현했다.
2-2. RemoteMediator 구현하기
@OptIn(ExperimentalPagingApi::class)
class EmploymentRemoteMediator(
private val database: NappedDatabase,
private val service: EmploymentService
): RemoteMediator<Int, EmploymentEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, EmploymentEntity>
): MediatorResult {
return try {
val loadKey = when(loadType) {
LoadType.REFRESH -> DEFAULT_PAGE_INDEX
LoadType.PREPEND -> return MediatorResult.Success(
endOfPaginationReached = true
)
LoadType.APPEND -> {
val lastData = getPageLastData(state)
if (lastData == null) {
DEFAULT_PAGE_INDEX
}
else {
val nextKey = getRemoteKey(lastData.number)?.nextKey
?: return MediatorResult.Success(endOfPaginationReached = true)
nextKey
}
}
}
val employmentResponses = service.getEmploymentList(
page = loadKey,
pageCount = state.config.pageSize
)
database.withTransaction {
if(loadType == LoadType.REFRESH){ // Refresh시 모든 데이터 삭제
database.employmentDao.clearEmploymentList()
database.remoteKeyDao.clearRemoteKey()
}
val employmentEntities = employmentResponses.map{ it.toEntity() } // Response
val nextKey = if (employmentEntities.isEmpty()) null else loadKey + 1 // 다음 페이지 번호 설정
val keys = employmentEntities.map{
RemoteKeyEntity(it.number, nextKey) // Response와 다음 페이지 번호를 묶어 엔티티 구현
}
database.employmentDao.insertEmploymentList(employmentEntities) // Response 저장
database.remoteKeyDao.insertRemoteKeyList(keys) // 다음 페이지 번호 키 저장
}
MediatorResult.Success(
endOfPaginationReached = employmentResponses.isEmpty()
)
}
catch(e: IOException){
MediatorResult.Error(e)
}
catch(e: ClientRequestException){
MediatorResult.Error(e)
}
catch(e: ServerResponseException){
MediatorResult.Error(e)
}
}
// 마지막에 사용한 페이지 확인
private fun getPageLastData(
state: PagingState<Int, EmploymentEntity>
): EmploymentEntity?{
val lastPage = state.pages.lastOrNull()
if(lastPage == null){
return lastPage
}
val pageLastData = lastPage.data.lastOrNull()
return pageLastData
}
// response id를 통해, 다음번 페이지 번호 확인
private suspend fun getRemoteKey(id: Long): RemoteKeyEntity? {
return database.withTransaction {
database.remoteKeyDao.getRemoteKeyById(id)
}
}
companion object{
private const val DEFAULT_PAGE_INDEX = 1
}
}
필자가 구현한 RemoteMediator는 load() 메소드로 구성되어 있으며, load() 메소드의 구성은 다음과 같다.
- REFRESH - 처음 데이터를 불러오는 시점이며, 코드에서는 1를 페이지 번호로 넘겨, 첫번째 페이지를 받아 올 수 있도록 구성하였다.
- PREPEND - 이전 데이터를 불러오는 시점이며(목록을 위로 올림), 코드에서는 PREPEND를 구현하지 않았다.
- APPEND - 다음 데이터를 불러오는 시점이며(목록을 아래로 내림), 코드에서는 다음과 같은 단계로 구현하였다.
- 이전에 저장된 페이지의 마지막 데이터를 불러온다.
- 마지막 데이터의 id값을 통해, RemoteKey 테이블의 다음 페이지 번호를 불러온다.
- 다음 페이지 번호와 함께 API 호출한다.
- Response Item의 id와 현재 페이지 + 1 값을 RemoteKeyEntity로 묶어 저장한다.
- Response Item를 Entity로 변환하여 저장한다.
나름 로직이 복잡했는데, 알고리즘 문제 푸는 느낌나서 재밌었다. 진짜 구성이 재귀 알고리즘 같았다,,
이렇게 구현한 대로 동작하면, 정말 기분이 좋겠지만 그럴 확률은 정말 낮다. 당연히 또 다른 문제가 발생했다.
3. APPEND가 무한으로 호출되는데요?
이렇게 구현을 하고 실행을 확인하니, 문제가 두 가지 발생하였다. 첫번째 문제는 데이터가 두번째 페이지까지 밖에 저장이 안되었다는 점이고, 두번째 문제는 APPEND가 무한으로 호출되는 점이였다.
디버깅과 App Inspector로 돌려본 결과, 재밌는 사실을 하나 발견했다. Entity에서 PrimaryKey로 등록한 id를 기준으로 데이터가 자동으로 정렬이 되고있는 점이였다. 앞에서 설명했던 대로, Response Id는 목록과 아무런 관계가 없고, 당연히 정렬되지 않은 상태로 받는다.
이런 ResponseId가 다음 페이지 번호와 묶이면서 조회할 때 문제가 발생했던 것이다.
예를 들어, 데이터가 아래와 같이 들어왔다고 가정하자. (RemoteKey Table)
- id : 20001931, nextKey : 2 / 첫번째 페이지 호출 때 들어옴
- id : 20001432. nextKey : 3 / 두번째 페이지 호출 때 들어옴
필자는 당연히, 마지막 데이터를 조회하는 메소드(load() 1번)에서 두번째 아이템이 나오길 기대했지만, 내부 데이터베이스에서 id를 기준으로 자동으로 정렬하여, 첫번째 아이템이 마지막 아이템으로 조회된다.
따라서 예시처럼 순서가 아닌, id값이 큰 데이터가 계속 호출되어, 다음번째 페이지를 불러올 수 없었던 것이다.
그럼 왜? APPEND가 계속 호출되는 것일까?
이 문제에 대한 원인은 이전에 만든 페이징 샘플 프로젝트에서 확인할 수 있었다. Paging을 할 때 사용자가 페이지 크기를 지정할 수 있다. 그런데, 이것과는 별개로 내부의 페이지는 지정한 크기와 다른 크기의 페이지로 만들어 진다.
만약 사용자가 지정한 페이지의 크기가 내부의 페이지 크기보다 작다면, APPEND를 계속 호출해 내부 페이지를 채운다. 이를 통해, 문제 원인을 유추할 수 있었다. 이전 문제, id값 자동 정렬 때문에, 같은 페이지를 계속 호출하고 있어 페이지의 크기를 채울 수 없었던 것이다.
해당 문제는 Primary Key를 두개를 두어 해결할 수 있었다.
@Entity(tableName = "employment", primaryKeys = ["number", "company"])
data class EmploymentEntity(
val number: Long,
val title: String,
val company: String,
val deadline: String,
val workTime: String,
...
)
두 개의 PrimaryKey는 따로 자동정렬이 발생하지 않아, 들어온 순서대로 저장되었다. 그리고 문제 없이 실행되었다👍👍
발생한 건 두 개의 문제지만, 사실 하나 때문에 연쇄적으로 발생한 문제라 같이 해결할 수 있었다.
마치며
사실 이번 랜덤개발챌린지를 통해, 내가 얻을 수 있는게 있을까? 라는 생각을 했었다. 그러나 실제 또 프로젝트를 진행하고, 이슈를 직면하니 세상은 넓고 아직도 공부할게 많다는 것을 느낄 수 있었다. 해당 문제를 하루씩 잡아가면서 분석하고 해결하는 과정도 나름 재미있는 것 같다!
해당 프로젝트는 아래에서 확인할 수 있습니다!
https://github.com/pknu-wap/2023_RDC_NAPPED
GitHub - pknu-wap/2023_RDC_NAPPED: 2023_랜덤개발챌린지_NAPPED
2023_랜덤개발챌린지_NAPPED . Contribute to pknu-wap/2023_RDC_NAPPED development by creating an account on GitHub.
github.com