(Kotlin/코틀린) 프록시 패턴(Proxy Pattern) 완전 정리

개요


1. 왜 프록시 패턴이 필요한가

// 실제 객체 생성 비용이 큼
class HeavyImageProcessor {
    init {
        println("HeavyImageProcessor 초기화 — 머신러닝 모델 로딩 중... (5초)")
        Thread.sleep(5000)
    }
    fun processImage(bitmap: Bitmap): Bitmap = bitmap // 실제 처리
}

// ❌ 직접 사용 — 사용 여부와 관계없이 앱 시작 시 5초 대기
class MainActivity : AppCompatActivity() {
    private val processor = HeavyImageProcessor()  // 즉시 초기화
}
// ✅ 프록시로 해결 — 실제 사용 시점에 초기화
class MainActivity : AppCompatActivity() {
    private val processor = LazyImageProcessorProxy()  // 즉시 반환, 초기화 안 함

    fun onEditPhoto(bitmap: Bitmap) {
        processor.processImage(bitmap)  // 이 시점에 HeavyImageProcessor 초기화
    }
}

2. 프록시 종류

종류 목적 사용 시점
가상 프록시 생성 비용이 큰 객체의 초기화 지연 무거운 객체, 지연 로딩
보호 프록시 접근 권한 제어 로그인 여부, 역할 기반 접근
캐싱 프록시 결과를 캐싱해 중복 요청 방지 네트워크, DB 조회
원격 프록시 원격 객체에 대한 로컬 대리 네트워크 통신 추상화

3. 가상 프록시 (Virtual Proxy) — 지연 초기화

interface ImageProcessor {
    fun processImage(bitmap: Bitmap): Bitmap
    fun applyFilter(bitmap: Bitmap, filter: String): Bitmap
}

// 실제 구현 — 초기화 비용이 큼
class HeavyImageProcessor : ImageProcessor {
    init { println("ML 모델 로딩 중... (3초)"); Thread.sleep(3000) }
    override fun processImage(bitmap: Bitmap) = bitmap
    override fun applyFilter(bitmap: Bitmap, filter: String) = bitmap
}

// 가상 프록시 — 처음 사용 시 실제 객체 생성
class LazyImageProcessorProxy : ImageProcessor {
    private val real: ImageProcessor by lazy {
        println("처음 사용 — HeavyImageProcessor 초기화")
        HeavyImageProcessor()
    }

    override fun processImage(bitmap: Bitmap): Bitmap {
        println("processImage 호출 → 실제 객체에 위임")
        return real.processImage(bitmap)
    }

    override fun applyFilter(bitmap: Bitmap, filter: String): Bitmap {
        println("applyFilter 호출 → 실제 객체에 위임")
        return real.applyFilter(bitmap, filter)
    }
}

// 사용
val processor: ImageProcessor = LazyImageProcessorProxy()
println("프록시 생성 완료 — 아직 ML 모델 안 로딩")
// 실제 사용 시점에만 초기화
val result = processor.processImage(bitmap)  // 이 시점에 HeavyImageProcessor 초기화

4. 보호 프록시 (Protection Proxy) — 권한 제어

interface AdminService {
    fun deleteUser(userId: Long)
    fun getSystemLogs(): List<String>
    fun updateSystemConfig(config: Map<String, String>)
}

class RealAdminService : AdminService {
    override fun deleteUser(userId: Long) = println("유저 $userId 삭제")
    override fun getSystemLogs() = listOf("Log1", "Log2", "Log3")
    override fun updateSystemConfig(config: Map<String, String>) = println("설정 업데이트: $config")
}

// 보호 프록시 — 접근 권한 확인 후 실제 객체에 위임
class AdminServiceProxy(
    private val real: AdminService,
    private val currentUser: () -> User?
) : AdminService {

    private fun checkAdmin() {
        val user = currentUser() ?: throw SecurityException("로그인이 필요합니다")
        if (user.role != Role.ADMIN) throw SecurityException("관리자 권한이 필요합니다")
    }

    override fun deleteUser(userId: Long) {
        checkAdmin()
        println("[Proxy] deleteUser 권한 확인 통과")
        real.deleteUser(userId)
    }

    override fun getSystemLogs(): List<String> {
        checkAdmin()
        return real.getSystemLogs()
    }

    override fun updateSystemConfig(config: Map<String, String>) {
        checkAdmin()
        println("[Proxy] 설정 변경 감사 로그 기록")
        real.updateSystemConfig(config)
    }
}

// 사용
val adminService: AdminService = AdminServiceProxy(
    real        = RealAdminService(),
    currentUser = { sessionManager.getCurrentUser() }
)

try {
    adminService.deleteUser(42L)   // 관리자면 성공, 아니면 SecurityException
} catch (e: SecurityException) {
    showError(e.message)
}

5. 캐싱 프록시 (Caching Proxy)

interface WeatherRepository {
    suspend fun getWeather(cityCode: String): Weather
}

class RemoteWeatherRepository(private val api: WeatherApi) : WeatherRepository {
    override suspend fun getWeather(cityCode: String): Weather = api.fetchWeather(cityCode)
}

// 캐싱 프록시 — 동일 요청은 캐시에서 반환
class CachingWeatherProxy(
    private val real: WeatherRepository,
    private val ttlMs: Long = 10 * 60 * 1000L  // 10분 캐시
) : WeatherRepository {

    data class CacheEntry(val weather: Weather, val timestamp: Long)

    private val cache = mutableMapOf<String, CacheEntry>()

    override suspend fun getWeather(cityCode: String): Weather {
        val cached = cache[cityCode]
        val now    = System.currentTimeMillis()

        if (cached != null && now - cached.timestamp < ttlMs) {
            println("[Cache] 캐시 반환: $cityCode")
            return cached.weather
        }

        println("[Cache] 원격 조회: $cityCode")
        return real.getWeather(cityCode).also {
            cache[cityCode] = CacheEntry(it, now)
        }
    }

    fun invalidate(cityCode: String) { cache.remove(cityCode) }
    fun invalidateAll() { cache.clear() }
}

// 조합
val weatherRepo: WeatherRepository = CachingWeatherProxy(
    RemoteWeatherRepository(weatherApi)
)

6. Kotlin by 위임 — 간결한 프록시

Kotlin의 by 키워드로 보일러플레이트 없이 프록시를 구현합니다.

interface Analytics {
    fun trackEvent(name: String, params: Map<String, Any> = emptyMap())
    fun trackScreen(screenName: String)
    fun setUserId(userId: String)
}

class FirebaseAnalytics : Analytics {
    override fun trackEvent(name: String, params: Map<String, Any>) =
        println("[Firebase] Event: $name, $params")
    override fun trackScreen(screenName: String) =
        println("[Firebase] Screen: $screenName")
    override fun setUserId(userId: String) =
        println("[Firebase] UserId: $userId")
}

// by로 실제 구현에 위임 — 오버라이드가 필요한 메서드만 재정의
class DebugAnalyticsProxy(
    private val real: Analytics
) : Analytics by real {

    // trackEvent만 오버라이드 (로그 추가), 나머지는 real에 자동 위임
    override fun trackEvent(name: String, params: Map<String, Any>) {
        println("[DEBUG] trackEvent 호출: $name")  // 디버그 로그 추가
        real.trackEvent(name, params)
    }
}

// 환경별 선택
val analytics: Analytics = if (BuildConfig.DEBUG)
    DebugAnalyticsProxy(FirebaseAnalytics())
else
    FirebaseAnalytics()

analytics.trackEvent("button_click", mapOf("screen" to "home"))
analytics.trackScreen("HomeScreen")  // real에 자동 위임

7. Android 실전 예제 ① — Repository 프록시

interface UserRepository {
    suspend fun getUser(id: Long): User?
    suspend fun updateUser(user: User): Boolean
    suspend fun deleteUser(id: Long): Boolean
}

// 로깅 + 에러 핸들링 프록시
class SafeUserRepositoryProxy(
    private val real: UserRepository
) : UserRepository {

    override suspend fun getUser(id: Long): User? =
        runCatching { real.getUser(id) }
            .onFailure { println("[Error] getUser($id) 실패: ${it.message}") }
            .getOrNull()

    override suspend fun updateUser(user: User): Boolean =
        runCatching { real.updateUser(user) }
            .onSuccess { println("[Log] 유저 업데이트: ${user.id}") }
            .onFailure { println("[Error] updateUser 실패: ${it.message}") }
            .getOrDefault(false)

    override suspend fun deleteUser(id: Long): Boolean =
        runCatching { real.deleteUser(id) }
            .onSuccess { println("[Log] 유저 삭제: $id") }
            .onFailure { println("[Error] deleteUser 실패: ${it.message}") }
            .getOrDefault(false)
}

8. Android 실전 예제 ② — SharedPreferences 프록시

interface AppSettings {
    var isDarkMode: Boolean
    var fontSize: Int
    var language: String
    fun reset()
}

class SharedPrefsSettings(context: Context) : AppSettings {
    private val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)

    override var isDarkMode: Boolean
        get() = prefs.getBoolean("dark_mode", false)
        set(v) = prefs.edit().putBoolean("dark_mode", v).apply()

    override var fontSize: Int
        get() = prefs.getInt("font_size", 14)
        set(v) = prefs.edit().putInt("font_size", v).apply()

    override var language: String
        get() = prefs.getString("language", "ko") ?: "ko"
        set(v) = prefs.edit().putString("language", v).apply()

    override fun reset() = prefs.edit().clear().apply()
}

// 변경 감지 프록시 — 값이 바뀔 때마다 콜백 호출
class ObservableSettingsProxy(
    private val real: AppSettings,
    private val onChange: (String, Any) -> Unit
) : AppSettings by real {

    override var isDarkMode: Boolean
        get() = real.isDarkMode
        set(v) { real.isDarkMode = v; onChange("isDarkMode", v) }

    override var fontSize: Int
        get() = real.fontSize
        set(v) { real.fontSize = v; onChange("fontSize", v) }

    override var language: String
        get() = real.language
        set(v) { real.language = v; onChange("language", v) }
}

// 사용
val settings: AppSettings = ObservableSettingsProxy(
    real     = SharedPrefsSettings(context),
    onChange = { key, value -> println("설정 변경: $key = $value") }
)

settings.isDarkMode = true  // 설정 변경: isDarkMode = true
settings.fontSize   = 18    // 설정 변경: fontSize = 18

9. 프록시 vs 데코레이터

두 패턴은 구조가 유사하지만 목적이 다릅니다.

항목 프록시 데코레이터
목적 접근 제어 (권한, 캐싱, 지연) 기능 추가
실제 객체 생성 프록시가 직접 관리 외부에서 주입
체이닝 일반적으로 단일 여러 개 중첩 가능
클라이언트 인지 프록시 존재를 모름 데코레이터 존재를 알 수 있음

10. 정리

항목 내용
목적 실제 객체 접근을 대리 객체가 제어
가상 프록시 by lazy로 지연 초기화
보호 프록시 권한 체크 후 실제 객체에 위임
캐싱 프록시 결과를 저장해 중복 요청 방지
Kotlin 도구 by 위임으로 필요한 메서드만 오버라이드
Android 사례 Repository 에러 처리, 설정 변경 감지, Analytics 래퍼

참고



Related Posts