Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ android {
val postHogHost: String = p.getProperty("POSTHOG_HOST")
buildConfigField("String", "POSTHOG_HOST", "\"$postHogHost\"")

val holidayApiKey: String = p.getProperty("HOLIDAY_API_KEY") ?: ""
buildConfigField(
"String",
"HOLIDAY_API_KEY",
"\"$holidayApiKey\""
)

isShrinkResources = true
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
Expand Down Expand Up @@ -111,6 +118,13 @@ android {
val postHogHost: String = p.getProperty("POSTHOG_HOST")
buildConfigField("String", "POSTHOG_HOST", "\"$postHogHost\"")

val holidayApiKey: String = p.getProperty("HOLIDAY_API_KEY") ?: ""
buildConfigField(
"String",
"HOLIDAY_API_KEY",
"\"$holidayApiKey\""
)

isMinifyEnabled = false
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.eatssu.android.data.remote.dto.response

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonTransformingSerializer

@Serializable
data class PublicHolidayApiResponse(
@SerialName("response") val response: Response? = null,
) {
@Serializable
data class Response(
@SerialName("header") val header: Header? = null,
@SerialName("body") val body: Body? = null,
)

@Serializable
data class Header(
@SerialName("resultCode") val resultCode: String? = null,
@SerialName("resultMsg") val resultMsg: String? = null,
)

@Serializable
data class Body(
@SerialName("items") val items: Items? = null,
@SerialName("numOfRows") val numOfRows: Int? = null,
@SerialName("pageNo") val pageNo: Int? = null,
@SerialName("totalCount") val totalCount: Int? = null,
)

@Serializable
data class Items(
@Serializable(with = PublicHolidayItemListSerializer::class)
@SerialName("item") val item: List<Item> = emptyList(),
)

@OptIn(ExperimentalSerializationApi::class)
object PublicHolidayItemListSerializer : JsonTransformingSerializer<List<Item>>(
ListSerializer(Item.serializer())
) {
override fun transformDeserialize(element: JsonElement): JsonElement {
return when (element) {
is JsonObject -> JsonArray(listOf(element))
else -> element
}
}
}

@Serializable
data class Item(
@SerialName("locdate") val locdate: Long? = null,
@SerialName("isHoliday") val isHoliday: String? = null,
@SerialName("dateName") val dateName: String? = null,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.eatssu.android.data.remote.repository

import com.eatssu.android.data.remote.service.PublicHolidayService
import com.eatssu.android.domain.model.PublicHoliday
import com.eatssu.android.domain.repository.PublicHolidayRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.net.URLEncoder
import java.time.LocalDate
import java.time.YearMonth
import javax.inject.Named
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class PublicHolidayRepositoryImpl @Inject constructor(
private val publicHolidayService: PublicHolidayService,
@Named(PUBLIC_HOLIDAY_SERVICE_KEY_NAME) private val serviceKey: String,
) : PublicHolidayRepository {

companion object {
const val PUBLIC_HOLIDAY_SERVICE_KEY_NAME: String = "PublicHolidayServiceKey"
}

override suspend fun getHolidays(yearMonth: YearMonth): List<PublicHoliday> {
if (serviceKey.isBlank()) {
Timber.w("HOLIDAY_API_KEY is blank; skipping public holiday fetch")
return emptyList()
}

val normalizedKey = normalizeServiceKey(serviceKey)

val responseResult = withContext(Dispatchers.IO) {
Comment thread
PeraSite marked this conversation as resolved.
Outdated
runCatching {
publicHolidayService.getRestDeInfo(
serviceKey = normalizedKey,
solYear = yearMonth.year.toString(),
solMonth = yearMonth.monthValue.toString().padStart(2, '0'),
)
}
}

val response = responseResult.getOrNull() ?: run {
Timber.w(responseResult.exceptionOrNull(), "Failed to fetch public holidays")
return emptyList()
}

val resultCode = response.response?.header?.resultCode
if (resultCode != null && resultCode != "00") {
Timber.w(
"PublicHoliday API returned non-normal resultCode=%s msg=%s",
resultCode,
response.response?.header?.resultMsg,
)
return emptyList()
}

val holidays = response.response
?.body
?.items
?.item
.orEmpty()
.asSequence()
.filter { it.isHoliday.equals("Y", ignoreCase = true) }
.mapNotNull { item ->
val date = item.locdate?.let(::parseLocdate)
val name = item.dateName?.trim().orEmpty()

if (date == null || name.isBlank()) return@mapNotNull null
PublicHoliday(date = date, name = name)
}
.distinctBy { it.date }
.sortedBy { it.date }
.toList()

return holidays
}

override suspend fun getHoliday(date: LocalDate): PublicHoliday? {
val yearMonth = YearMonth.from(date)
val holidays = getHolidays(yearMonth)
return holidays.firstOrNull { it.date == date }
}
Comment thread
HI-JIN2 marked this conversation as resolved.
Outdated

private fun parseLocdate(locdate: Long): LocalDate? {
val s = locdate.toString()
if (s.length != 8) return null

return runCatching {
val year = s.substring(0, 4).toInt()
val month = s.substring(4, 6).toInt()
val day = s.substring(6, 8).toInt()
LocalDate.of(year, month, day)
}.getOrNull()
}

private fun normalizeServiceKey(value: String): String {
val trimmed = value.trim()
if (trimmed.isEmpty()) return ""

return if ('%' in trimmed) trimmed else URLEncoder.encode(trimmed, Charsets.UTF_8.name())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.eatssu.android.data.remote.service

import com.eatssu.android.data.remote.dto.response.PublicHolidayApiResponse
import retrofit2.http.GET
import retrofit2.http.Query

interface PublicHolidayService {

@GET("B090041/openapi/service/SpcdeInfoService/getRestDeInfo")
suspend fun getRestDeInfo(
@Query(value = "ServiceKey", encoded = true) serviceKey: String,
@Query("solYear") solYear: String,
@Query("solMonth") solMonth: String,
@Query("numOfRows") numOfRows: Int = 50,
@Query("pageNo") pageNo: Int = 1,
@Query("_type") type: String = "json",
): PublicHolidayApiResponse
}
7 changes: 7 additions & 0 deletions app/src/main/java/com/eatssu/android/di/DataModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.eatssu.android.data.remote.repository.MealRepositoryImpl
import com.eatssu.android.data.remote.repository.MenuRepositoryImpl
import com.eatssu.android.data.remote.repository.OauthRepositoryImpl
import com.eatssu.android.data.remote.repository.PartnershipRepositoryImpl
import com.eatssu.android.data.remote.repository.PublicHolidayRepositoryImpl
import com.eatssu.android.data.remote.repository.ReportRepositoryImpl
import com.eatssu.android.data.remote.repository.ReviewRepositoryImpl
import com.eatssu.android.data.remote.repository.UserRepositoryImpl
Expand All @@ -16,6 +17,7 @@ import com.eatssu.android.domain.repository.MealRepository
import com.eatssu.android.domain.repository.MenuRepository
import com.eatssu.android.domain.repository.OauthRepository
import com.eatssu.android.domain.repository.PartnershipRepository
import com.eatssu.android.domain.repository.PublicHolidayRepository
import com.eatssu.android.domain.repository.ReportRepository
import com.eatssu.android.domain.repository.ReviewRepository
import com.eatssu.android.domain.repository.UserRepository
Expand Down Expand Up @@ -72,4 +74,9 @@ abstract class DataModule {
internal abstract fun bindsFirebaseRemoteConfigRepository(
firebaseRemoteConfigRepositoryImpl: FirebaseRemoteConfigRepositoryImpl,
): FirebaseRemoteConfigRepository

@Binds
internal abstract fun bindsPublicHolidayRepository(
publicHolidayRepositoryImpl: PublicHolidayRepositoryImpl,
): PublicHolidayRepository
}
57 changes: 57 additions & 0 deletions app/src/main/java/com/eatssu/android/di/PublicHolidayModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.eatssu.android.di

import com.eatssu.android.BuildConfig
import com.eatssu.android.data.remote.repository.PublicHolidayRepositoryImpl
import com.eatssu.android.data.remote.service.PublicHolidayService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import javax.inject.Named
import javax.inject.Qualifier
import javax.inject.Singleton

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class PublicHolidayApi

@Module
@InstallIn(SingletonComponent::class)
object PublicHolidayModule {

private const val PUBLIC_HOLIDAY_BASE_URL = "https://apis.data.go.kr/"

@Provides
@Singleton
@Named(PublicHolidayRepositoryImpl.PUBLIC_HOLIDAY_SERVICE_KEY_NAME)
fun providePublicHolidayServiceKey(): String {
return BuildConfig.HOLIDAY_API_KEY
}

@Provides
@Singleton
@PublicHolidayApi
fun providePublicHolidayRetrofit(
@NoToken okHttpClient: OkHttpClient,
json: Json,
): Retrofit {
return Retrofit.Builder()
.baseUrl(PUBLIC_HOLIDAY_BASE_URL)
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}

@Provides
@Singleton
fun providePublicHolidayService(
@PublicHolidayApi retrofit: Retrofit,
): PublicHolidayService {
return retrofit.create(PublicHolidayService::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.eatssu.android.domain.model

import com.eatssu.common.enums.Restaurant

data class MenuLoadResult(
val menuMap: Map<Restaurant, List<Menu>>,
val publicHolidayName: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.eatssu.android.domain.model

import java.time.LocalDate

data class PublicHoliday(
val date: LocalDate,
val name: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.eatssu.android.domain.repository

import com.eatssu.android.domain.model.PublicHoliday
import java.time.LocalDate
import java.time.YearMonth

interface PublicHolidayRepository {

suspend fun getHolidays(yearMonth: YearMonth): List<PublicHoliday>

suspend fun getHoliday(date: LocalDate): PublicHoliday?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.eatssu.android.domain.usecase.holiday

import com.eatssu.android.domain.model.PublicHoliday
import java.time.LocalDate
import java.time.YearMonth
import javax.inject.Inject

/**
* 특정 날짜가 공휴일이면 해당 공휴일 정보를 반환한다.
*/
class GetPublicHolidayOfDateUseCase @Inject constructor(
private val getPublicHolidaysOfMonthUseCase: GetPublicHolidaysOfMonthUseCase,
) {
suspend operator fun invoke(date: LocalDate): PublicHoliday? {
val holidays = getPublicHolidaysOfMonthUseCase(YearMonth.from(date))
return holidays.firstOrNull { it.date == date }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.eatssu.android.domain.usecase.holiday

import com.eatssu.android.domain.model.PublicHoliday
import com.eatssu.android.domain.repository.PublicHolidayRepository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.YearMonth
import javax.inject.Inject
import javax.inject.Singleton

/**
* 지정한 [YearMonth]의 공휴일 목록을 조회한다.
*
* 캐싱 정책(월 단위 메모리 캐시)은 usecase가 소유하고,
* repository는 데이터 접근(원격/로컬)에만 집중한다.
*/
@Singleton
class GetPublicHolidaysOfMonthUseCase @Inject constructor(
private val publicHolidayRepository: PublicHolidayRepository,
) {
private val mutex = Mutex()
private val cache: MutableMap<YearMonth, List<PublicHoliday>> = linkedMapOf()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 월 단위 캐시로 LinkedHashMap을 사용하고 있는데, 캐시의 크기에 제한이 없어 사용자가 여러 달을 탐색할 경우 메모리 사용량이 계속 증가할 수 있습니다. 메모리 효율성을 높이기 위해 LruCache와 같이 크기가 제한된 캐시를 사용하는 것을 권장합니다.

예시:

import androidx.collection.LruCache // or android.util.LruCache

// ...

private val cache = LruCache<YearMonth, List<PublicHoliday>>(12) // 최근 12개월 데이터 캐시

suspend operator fun invoke(yearMonth: YearMonth): List<PublicHoliday> {
    mutex.withLock {
        cache.get(yearMonth)?.let { return it }
    }

    val result = publicHolidayRepository.getHolidays(yearMonth)

    mutex.withLock {
        cache.put(yearMonth, result)
    }

    return result
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다. 월 캐시가 무제한으로 커지지 않도록 access-order LinkedHashMap 기반 LRU로 바꾸고 최대 12개월만 유지하도록 제한했습니다.


suspend operator fun invoke(yearMonth: YearMonth): List<PublicHoliday> {
mutex.withLock {
cache[yearMonth]?.let { return it }
}

val result = publicHolidayRepository.getHolidays(yearMonth)

mutex.withLock {
cache[yearMonth] = result
}

return result
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.eatssu.android.domain.usecase.holiday

import java.time.YearMonth
import javax.inject.Inject

class PrefetchPublicHolidaysOfMonthUseCase @Inject constructor(
private val getPublicHolidaysOfMonthUseCase: GetPublicHolidaysOfMonthUseCase,
) {
suspend operator fun invoke(yearMonth: YearMonth) {
getPublicHolidaysOfMonthUseCase(yearMonth)
}
}
Loading
Loading