현재 개발을 맡고있는 Android VPN 서비스인 Kraton VPN을 구현하던 중, 여러 디자인 패턴을 활용하며 겪은 시행착오에 대해 소개하는 글입니다.
VPN 앱을 개발하다 보면, 사용자에게는 하나의 단일 버튼(연결/해제)만 제공하지만, 실제로는 여러 가지 VPN 프로토콜(WireGuard, IKEv2 등) 중 하나를 선택해 실제 네트워크 연결을 수행해야 합니다.
이때 VPNController는 직접 각 프로토콜의 로직을 구현하기보다는, 그 작업을 Protocol이라는 인터페이스로 정의하고, 각 구현체(IKEv2, WireGuard 등)에 위임(delegate) 하도록 설계하면 다음과 같은 이점을 가질 수 있습니다.
interface Protocol {
fun connect()
fun disconnect()
fun status(): String
}
class IKEv2 : Protocol {
override fun connect() = println("IKEv2: Establishing secure tunnel")
override fun disconnect() = println("IKEv2: Tearing down connection")
override fun status() = "IKEv2 Connected"
}
class WireGuard : Protocol {
override fun connect() = println("WireGuard: Setting up fast, lightweight tunnel")
override fun disconnect() = println("WireGuard: Disconnecting tunnel")
override fun status() = "WireGuard Connected"
}
아래 예시는 VPNController 클래스 내부에 프로토콜별 분기 로직을 직접 작성한 형태입니다
class VPNController(private val type: String) {
fun connect() {
if (type == "IKEv2") {
println("IKEv2: Establishing secure tunnel")
} else if (type == "WireGuard") {
println("WireGuard: Setting up fast, lightweight tunnel")
}
}
fun disconnect() {
if (type == "IKEv2") {
println("IKEv2: Tearing down connection")
} else if (type == "WireGuard") {
println("WireGuard: Disconnecting tunnel")
}
}
}
사실 보자마자 가장 먼저 떠오른 형태의 코드입니다. 하지만 이 구현은 다음과 같은 이유로 OCP를 위배합니다.
if (type == "OpenVPN") {
println("OpenVPN: Starting session")
}
클래스는 기존 코드를 변경하지 않고도 기능을 확장할 수 있어야 하기 때문에, 이는 명백히 개방-폐쇄 원칙 위반입니다.
class VPNController : WireGuard()
이렇게 상속을 사용할 경우:
VPNController는 WireGuard에 갇혀버려 다른 프로토콜을 사용할 수 없습니다
타입이 고정되기 때문에 val protocol: WireGuard = VPNController() 와 같은 의도치 않은 타입 캐스팅이 가능해집니다 (리스코프 치환 원칙 위배)
이렇게 실수하기 쉬운 잘못된 설계 케이스 두 가지를 알아보았는데요, 이러한 문제들을 예방하기 위해 위임을 활용할 수 있습니다.
반면, 위임을 사용하는 구조에서는 다음과 같이 새로운 프로토콜을 추가해도 기존 코드에 영향을 주지 않습니다.
인자를 통해 넘어온 Protocol 인터페이스 기반 위임을 통해 동작을 분리하고, 기능 추가 시에도 기존 코드 변경 없이 확장 가능합니다.
class VPNController(private val protocol: Protocol) : Protocol by protocol {
fun logStatus() {
println("Controller: Delegating to ${protocol.javaClass.simpleName}")
}
}
이제 VPNController는 어떤 Protocol이든 자유롭게 사용할 수 있으며, 실제 연결 로직은 Protocol 구현체에 위임됩니다.
실행 예시
fun main() {
val ikeController = VPNController(IKEv2())
ikeController.connect() // IKEv2: Establishing secure tunnel
ikeController.logStatus() // Controller: Delegating to IKEv2
val wgController = VPNController(WireGuard())
wgController.connect() // WireGuard: Setting up fast, lightweight tunnel
println(wgController.status()) // WireGuard Connected
}
class VPNController(var protocol: Protocol) : Protocol by protocol
위임 대상을 동적으로 바꾸려고 이렇게 var로 선언해보아도, 위임된 대상은 최초 전달된 인스턴스로 고정됩니다. 이후 protocol = WireGuard() 로 바꿔도 실제 델리게이션은 여전히 초기 객체에 위임합니다.
따라서 GC 수집 대상이 되지 않고 메모리 누수가 발생할 수 있습니다.
by 키워드는 파라미터가 아닌 프로퍼티가 될 수 없습니다. 위임 대상이 동적으로 바뀌길 원하면 delegation pattern 대신 strategy pattern을 고려해야 합니다.
class VPNController(private val protocol: Protocol) : Protocol by protocol {
override fun disconnect() = println("Controller: Custom disconnect logic")
}
이처럼 델리게이션 대상과 동일한 이름의 메소드를 직접 구현하면, 코틀린은 내부 구현이 우선되며, 자동 위임 메소드는 생성되지 않습니다. 따라서 정확한 동작 흐름 제어가 가능합니다.
VPNController는 여러 프로토콜 중 하나에게 실행을 위임하는 역할만 담당합니다. 어떤 프로토콜인지에 따라 실제 동작은 달라지지만, VPNController 자체는 그 사실을 알 필요도 없고 알지도 못합니다. 이것이 바로 위임의 본질적인 이점입니다.
프로토콜을 바꾸고 싶을 땐 VPNController를 바꿀 필요 없이, 전달하는 Protocol 구현체만 바꾸면 됩니다.
객체 간의 공통점이 있다고 해서 반드시 상속을 사용해야 하는 것은 아닙니다. 구조를 설계할 때에는, 우선 연관 짓고자 하는 각 객체의 책임을 명확히 하고 객체 간의 관계를 정의해야 설계 실수를 줄일 수 있습니다.