안드로이드에서의 딥링크(Deep links)와 앱링크(App links)

웹이 지금 처럼 널리 사용되고 강력한 힘을 갖게 된 이유는 링크 덕분이였다고 해도 과언이 아닐거라고 생각을 합니다. 웹에서 사용되는 HTML(Hypertext Markup Language) 문서는 링크를 통해 서로 연결이 되고 우리는 링크를 타고 정보의 세계를 여행합니다.

안드로이드에서도 앱 내의 컨텐츠에 유저가 쉽게 이동 할 수 있도록 링크를 지원합니다. 우리는 이 링크를 딥링크라고 부릅니다.

디지털 세상에서의 링크

URI(Uniform Resource Identifier)

디지털 세상에서는 웹상의 컨텐츠를 URI라는 포맷으로 표시합니다. 앞에서 설명한 링크 역시 URI 포맷으로 표현됩니다. 그럼 먼저 URI가 어떻게 생겼는지 살펴보도록 하겠습니다.

scheme://username:password@host:port/path?query_string#fragment_id

  • 스킴(scheme): 리소스에 접근하기 위한 프로토콜을 나타냅니다. 예를들어 웹 리소스의 경우 http(Hypertext Transfer Protocol), 또는 https(HTTP Secure)를 사용합니다.
  • 유저명 & 비밀번호(Username and Password): 선택 필드로, 인증이 필요한 경우 사용할 수 있습니다. 보안에 취약하기 때문에 사용이 권장되지는 않습니다.
  • 호스트(Host): 리소스가 존재하는 서버의 도메인 또는 IP 주소 입니다. 도메인은 IP 주소를 사람이 읽기 쉬운 형태로 사용할 수 있도록 해줍니다. www.example.com과 같은 형태의 도메인이 있다면 www는 서브도메인, example.com을 도메인이라고 부릅니다.
  • 포트(Port): 선택 필드로, 서버 내에서 어떤 컴포넌트가 요청을 처리할지를 표현합니다. 정의가 되어있지 않은 경우 브라우저는 스킴에 주어진 기본 포트를 사용합니다. 예를 들어 http의 경우 80, https의 경우 443이 기본 포트입니다.
  • 패스(Path): 서버에서의 구체적인 위치를 표현합니다. ex> /path/to/myfile.html
  • 쿼리 스트링(Query String): 서버에 데이터를 전달하기 위해 사용합니다. ?로 시작하며 키-값의 쌍을 표현합니다. 여러개의 쿼리 스트링은 &로 구분합니다. ex> ?key1=value1&key2=value2
  • 프래그먼트 아이디(Fragment ID): 리소스의 특정 파트를 나타내기 위해 사용되며 #로 시작합니다. 보통 웹페이지의 특정 섹션으로 바로 이동하기 위해 사용됩니다.

URI의 예

https://www.example.com:443/path/to/page?query=123#content

위의 URI의 경우 스킴은 https 프로토콜로 443 포트를 사용하며 서버의 위치는 www.example.com 도메인을 통해 접근합니다. /path/to/page는 접근하려는 자원이고, 키가 query 이고 123인 값을 쿼리 스트링으로 서버에 전달합니다. content는 해당 리소스에서 접근하고자 하는 부분입니다.

좀 더 이해를 돕기 위해서는 아래처럼 말할 수도 있겠네요. (조금 억지스런 예제일 수 있습니다. ^^;)

한국어://경기도용인시XX아파트:관리실/관리비?청구월=11월#전기료

프로토콜은 한국어입니다. 즉 한국어로 클라이언트와 서버가 소통합니다. 리소스를 받을 서버의 주소는 경기도 용인시 XX아파트이고 XX아파트의 관리실에서 요청을 처리합니다. 받고자 하는 리소스 즉 경로는 관리비입니다. 11월 관리비만 궁금하기 때문에 청구월 쿼리스트링으로 11월을 포함하여 관리비를 요청합니다. 그리고 관리비 고지서에서 전기료 파트에 바로 접근합니다.

안드로이드에서 사용할 수 있는 링크의 종류

안드로이드는 웹과 유사하게 링크를 통해 유저가 앱에서 제공하는 컨텐츠에 접근할 수 있도록 딥링크(Deep Link)라는 기능을 제공합니다. 접근하려는 컨텐츠가 웹이 아닌 안드로이드 앱 내부에 있지만 안드로이드에서 제공하는 인텐트(Intent)라는 도구를 이용해서 이를 처리 할 수 있습니다.

아래와 같이 AndroidManifest.xmlintent-filter를 선언하면 URI를 통해 앱의 컨텐츠를 노출 할 수 있습니다. 눈여겨 봐야할 부분은 data 속성인데요. geo라는 스킴을 정의하고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
<activity
android:name=".MyMapActivity"
android:exported="true"
...>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geo" />
</intent-filter>
</activity>

이렇게 하면 이제 geo라는 스킴을 갖는 URI가 안드로이드 단말의 브라우저에서 클릭이 되거나 다른 앱에서 트리거 되면 우리 앱의 MyMapActivity 가 이를 처리 할 수 있게 됩니다.

위의 인텐트 필터에서 카테고리는 아래와 같은 역할을 합니다.

  • <category android:name="android.intent.category.BROWSABLE" />
    웹 브라우저에서의 링크 클릭을 인텐트로 받을 수 있게 합니다.
  • <category android:name="android.intent.category.DEFAULT" />
    암시적 인텐트를 받을 수 있도록 합니다.

이제 안드로이드 폰에서 웹브라우저를 통해 geo:// 스킴으로 시작하는 링크를 클릭하면 내가 만든 앱의 MyMapActivity가 받아서 처리할 수 있게 되었습니다.

딥링크의 한계

지금까지의 설명만 보면 딥링크 자체는 별 문제가 없어보입니다. 하지만 잘 생각해보면 우리가 받아서 처리하겠다고 AndroidManifest.xml에 선언한 스킴은 우리가 정의한 스킴입니다. 즉 다른 앱에서도 얼마든지 동일한 스킴을 받아서 처리하겠다고 선언할 수 있습니다. 위에서 예를 든 geo라는 스킴을 받아서 처리하는 앱이 스마트폰에 여러 개가 설치되어 있을 수 있습니다. 그런 경우 안드로이드는 아래와 같은 다이얼로그를 띄우게 됩니다.

geo라는 스킴을 받아서 처리하겠다고 선언되어 있는 앱이 MapsChrome 두개가 있으니 둘 중 어디서 이 요청을 처리할지 사용자가 선택하라는 다이얼로그입니다. 하단에 보면 JUST ONCEALWAYS 버튼이 있어서 앞으로 동일한 스킴이 트리거 되었을 때 이번에 선택한 앱이 한번만 실행될지, 항상 실행될지를 결정할 수 있습니다.

유저 입장에선 선택권이 넓다고 할 수도 있지만 어떤 앱이 이걸 처리해야할지 헷갈릴수도 있고 맞는 앱을 선택 해야하는 것 자체가 어렵습니다. 그리고 앱 개발자 입장에서는 내가 만든 앱의 딥링크가 내 앱이 아닌 엉뚱한 앱에서 실행이 될 수도 있는 문제가 있습니다.

명확성 대화상자

딥링크 중복 문제 해결

안드로이드에서는 이러한 문제를 해결하기 위해 앱링크(App Links)라는 해결책을 제공합니다. 이에 대해 알아보기 전에 먼저 웹링크(Web Links)에 대해 알아보도록 하겠습니다.

웹링크는 스킴이 http 또는 https인 링크를 말합니다. 웹에서 웹브라우저를 통해 인터넷을 할 때 사용되는 프로토콜이 http 또는 https 이기 때문에 이를 웹링크라고 하는 것이죠.

그럼 안드로이드 앱에서 아까와 같이 처리할 딥링크를 정의할 때 이를 웹링크로, 즉, 스킴에 http 또는 https를 선언하면 어떻게 동작할까요? 이건 안드로이드 OS 버전에 따라 다른데 12 미만에서는 일반 딥링크와 동일하게 처리되고(웹링크를 처리하는 앱으로 연결, 하나 이상이면 다이얼로그로 처리할 앱 선택) 12 이상에서는 단말에 기본으로 설정된 웹브라우저 앱이 뜹니다. 참고로 웹브라우저 앱은 설정 -> 애플리케이션 웹링크를 스킴으로 설정한 앱들 중에 선택이 가능합니다.

아래는 웹링크의 인텐트 필터 샘플입니다. scheme가 http로 선언된걸 볼 수 있습니다.

1
2
3
4
5
6
7
8
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data android:scheme="http" />
<data android:host="myownpersonaldomain.com" />
</intent-filter>

앱링크

앱링크는 안드로이드 6.0(API Level 23)에서 부터 사용이 가능한 기능입니다. 앱링크는 autoVerify라는 속성을 제공하는데 이 속성은 웹링크를 사용하더라도, 해당 도메인의 소유주가 확인이 되면 웹링크를 사용하는 다른 앱들이 있거나, 기본 브라우저 앱으로 등록이 되어 있지 않더라도 즉시 내가 만든 앱이 링크를 받을 수 있도록 해줍니다.

참고로 iOS에서는 앱링크와 유사한 개념으로 유니버셜 링크(Universal Link)를 제공합니다.

아래는 앱링크를 사용하는 인텐트 필터 샘플입니다. 웹링크와 다른점은 autoVerify속성이 추가되었고 이 값이 true로 세팅되었다는 점입니다.

1
2
3
4
5
6
7
8
9
10
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data android:scheme="http" />
<data android:scheme="https" />

<data android:host="myownpersonaldomain.com" />
</intent-filter>

그럼 이렇게 인텐트 필터만 설정하면 앱링크가 동작할까요? 이것만으로는 부족합니다. 해당 도메인의 소유권이 나에게 있음을 확인할 수 있도록 ‘도메인 소유권 확인’ 처리를 해주어야합니다. 도메인 소유권 확인 절차가 없다면 아무나 동일한 링크를 선언할 수 있으니 딥링크와 다를게 없겠죠.

도메인 소유권 확인

도메인 소유권 확인은 디지털 애셋 링크 JSON 파일을 이용합니다. 특정한 포맷으로 구성된 JSON 파일을 아래의 위치에 업로드 하면 되는 방식입니다.

https://{{domain.name}}/.well-known/assetlinks.json

내가 소유한 도메인에는 나만 파일을 올릴 수 있으니 이를 통해 소유권 확인이 되는 것이죠. 그럼 위에서 언급한 디지털 애셋 링크 JSON(assetlinks.json)은 어떻게 생겼을까요?

디지털 애셋 링크

디지털 애셋 링크의 생성은 아래 링크에서 어떻게 만들 수 있는지, 어떤 속성들이 있고 어떤 역할을 하는지에 대해 자세히 설명이 되어 있습니다.

https://developer.android.com/studio/write/app-link-indexing

안드로이드 스튜디오의 Digital Asset Links File Generator를 이용하면 보다 편리하게 생성이 가능합니다.

Digital Asset Links File Generator

기본적인 형태는 아래와 같습니다.

앱의 기본 정보와 사이닝 키의 해시 값이 들어갑니다. 업로드한 디지털 애셋 링크 파일에 있는 정보와 앱의 정보가 맞지 않으면 앱링크가 동작하지 않으니 주의깊게 디지털 애셋 링크 파일을 작성하고 테스트를 해야합니다.

1
2
3
4
5
6
7
8
9
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example",
"sha256_cert_fingerprints":
["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"]
}
}]

딥링크 vs 웹링크 vs 앱링크

지금까지 살펴본 딥링크와 웹링크, 그리고 앱링크의 관계는 아래와 같습니다. 딥링크는 앱에서 자유롭게 스킴을 포함한 URI를 정의 할 수 있기 때문에 자유도가 가장 높습니다. 하지만 딥링크가 충돌할 수 있는 위험이 있습니다.

웹링크는 스킴이 http 이거나 https인 링크를 말합니다. 즉 웹에서 사용되는 링크입니다.

안드로이드에서도 딥링크를 정의할 때 스킴을 httphttps로 정의함으로써 웹링크의 형태로 정의가 가능합니다. 하지만 이렇게 하면 딥링크가 제대로 동작하지 않습니다. 해당 웹링크가 얼마든지 중복이 될 수 있으니 결국 이것만으로는 딥링크와 다를게 없기 때문입니다.

이를 해결하기 위해 웹링크에 도메인 소유권 확인 절차를 추가한게 앱링크 입니다. 앱링크는 사용하고자 하는 도메인에 디지털 애셋 링크 JSON 파일을 업로드 함으로써 해당 도메인이 나의 소유임을 안드로이드 OS에서 알 수 있게 합니다. 그리고 이를 통해 다른 앱에서 동일한 링크를 선언했더라도 내 앱이 해당 링크를 처리할 수 있도록 보장해줍니다.

링크의 종류

앱링크의 장단점

장점

  • 도메인 소유권이 확인된 웹링크를 사용하기 때문에 앱 선택 다이얼로그가 뜨지 않아 더 좋은 유저 경험을 제공하고, 다른 앱과 링크가 중복되더라도 내가 만든 앱에서 링크를 받는게 보장됩니다.
  • 안드로이드 인스턴트 앱 기능의 사용이 가능합니다. 인스턴트 앱은 앱의 설치 없이 앱의 일부 기능을 사용할 수 있도록 하는 기능입니다.
  • 유저가 모바일 브라우저나, 구글 검색으로 웹링크 클릭으로 바로 앱의 컨텐츠에 접근이 가능하기 때문에 더 많은 유저 유입이 가능해집니다.

앱링크의 단점

  • 도메인 소유권 확인 처리는 디지털 애셋 링크 생성 및 테스트, 파일 업로드의 과정을 걸치기 때문에 불편하고 추가로 시간과 노력이 필요합니다.
  • 사용하는 도메인의 관리가 중요합니다. 실수로 도메인 연장을 하지 못했다면 해당 앱링크는 사용이 불가능합니다.
  • 팀으로 작업 하는 경우 개발하는 머신마다 디버그 키가 다르기 때문에 디지털 애셋 링크 파일에 디버그 키의 해시 키 등록이 힘듭니다.

더 알아볼 것들

딥링크와 웹링크, 그리고 앱링크 까지 알았다면 안드로이드 앱에서의 링크에 대해 어느정도 알게된 것입니다. 하지만 실제 서비스를 개발할 때에는 마케팅이 필요하고 이를 위해 추가로 지연된 딥링크(Deferred Deep Links)와 파이어베이스(Firebase)에서 제공하는 지연된 딥링크 서비스인 다이나믹 링크(Dynamic Links)에 대해 알아두면 많은 도움이 됩니다.

지연된 딥링크

지연된 딥링크는 주로 마케팅에 많이 쓰이는데 마케팅을 통해 제공된 링크를 통해 앱의 설치를 유도하고 이에 대한 성과 측정을 위해 사용됩니다. UTM 코드라는 것과 함께 사용되며 여기에는 앱 설치를 위해 사용된 링크가 어디서 만들어졌는지, 어떤 캠페인에서 사용되었는지, 어떤 매체를 통해 공유되었는지 등의 정보가 들어갑니다. 즉 앱이 설치 되어 있지 않을때 앱의 설치를 유도 할 수 있고 설치 후 지연된 딥링크에 전달된 파라미터를 전달 받을 수 있습니다.

파이어베이스의 다이나믹 링크

파이어베이스의 다이나믹 링크는 파이어베이스에서 제공하는 지연된 딥링크 서비스입니다. 하나의 링크로 링크가 실행된 플랫폼에 따라 서로 다른 동작을 할 수 있도록 편리하게 구성할 수 있습니다. 또한 해당 링크를 받아서 처리할 수 있는 앱이 있는지 없는지에 따라 다른 동작을 정의할수도 있습니다.

예를 들어, 하나의 링크로 안드로이드에서 링크가 클릭되었다면 해당 링크를 실행할 수 있는 앱을 찾고 없다면 앱을 설치할 수 있도록 안드로이드 마켓으로, 있다면 앱으로 링크를 전달할 수 있습니다. 또한 대시보드를 통해 링크의 성과를 바로 볼 수도 있습니다.

단축 URL 기능도 제공을 하는데 이는 긴 URL을 짧은 URL로 바꿔주는 기능입니다.

즉 파이어베이스의 다이나믹 링크는 지연된 딥링크에 여러가지 추가 기능을 제공하는 솔루션 이라고 보면 됩니다. 그리고 이 솔루션의 핵심은 사실상 다이나믹 링크의 성과를 볼 수 있는 대시보드 일 것입니다. 즉 마케팅의 성과를 볼 수 있는 기능입니다.

Firebase Consoe

Dynamic Links with Analytics

결국 지연된 딥링크와 파이어베이스의 다이나믹 링크의 핵심은 마케팅을 위한 추가 기능이라고 보면 됩니다. 당연히 이런 서비스는 파이어베이스에서만 제공하는 것은 아니고, AppsFlyerBranch 같은 다른 마케팅 솔루션 서비스에서도 제공합니다.

맺음말

안드로이드 앱 개발을 하다보면 딥링크는 반드시 사용해야 하는 기능입니다. 앱의 초기 세팅이나 기초에 해당하는 부분에 딥링크 처리가 반드시 들어갑니다. 개발자는 웹과의 연동이나, 마케팅을 위해 적절한 링크 기능을 제공해야합니다. 그리고 요구사항에 맞는 선택을 하기 위해서는 각각의 개념과 장단점을 잘 알아야합니다. 이런 개념을 전달하는 것이 이번 글의 목적입니다.

딥립크가 한계를 가지고 있지만 앱링크를 사용하지 않더라도 스킴을 잘 겹치지 않을만한 걸로 사용한다거나 하면 실제로는 앱링크를 사용하지 않는게 더 좋은 선택일 수도 있습니다. 앱링크는 도메인 소유권 확이이나, 관리, 디버그 모드에서의 불편함 등을 감안하면 공짜가 아닙니다. 물론 이것은 서비스와, 팀의 상황에 따라 달라지며 팀의 상황에 맞게 맞는 선택을 하기 위해서는 각각의 차이가 무엇인지, 어떤 문제점을 해결하기 위해 등장했는지, 장단점이 무엇인지를 잘 알아야합니다.

마지막으로 서비스를 잘 만드는 것 만큼이나 중요한 것이 마케팅입니다. 모바일 앱의 생태계가 이미 굉장히 성숙해져 있기 때문에 이제는 유저가 스스로 원하는 앱을 찾기보다는 이미 알고 있는 서비스만 사용하는 것이 트렌드입니다. 즉 신규 서비스의 경우 이제는 아무리 잘 만들어도 유저에게 노출 시키기가 굉장히 힘이듭니다.

따라서 운영중인 앱에 대한 마케팅을 준비하고 있다면 적절한 마케팅 서비스를 선택하고 이를 활용할 수 있도록 지연된 딥링크와 이를 제공하는 서비스에 대해 잘 알고 있는것이 중요합니다.

참고 자료

코틀린에서의 위임(Delegation in Kotlin)

이번 포스트에서는 위임에 대해 구체적인 개념부터 사용법을 알아보려 합니다. 이를 위해 안드로이드의 젯팩 컴포즈에서 사용되는 실제 코드(mutableStateOf)를 분석함으로써 위임에 대한 이해도를 높이고 동시에 이를 어떻게 커스텀하고 이용할 수 있을지도 함께 살펴 보도록 하겠습니다.

위임(Delegation)의 개념

위임은 다른 사람에게 나의 일을 맡기는 행위라고 할 수 있습니다.

제가 까페를 운영한다고 합시다. 까페를 운영하는 일은 매장관리, 재고관리, 홍보, 음료 준비 등 다양한 일을 포함합니다. 이를 혼자 다하는건 아주 힘든 일입니다. 그래서 저는 숙련된 바리스타인 “동동이”를 고용했습니다. 동동이는 손님이 오면 음료 준비와 관련된 모든 업무를 수행합니다. 즉 저는 동동이에게 음료 준비를 위임 함으로써 까페 운영을 위한 다른 업무에 집중하고, 좀 더 효율적으로 까페를 운영할 수 있게 되었습니다.

구성요소

위의 예제에서 까페 사장인 저는 델리게이터(Delegator), 바리스타로 고용된 동동이는 델리게이트(Delegate)라고 합니다. 델리게이터는 일을 떠넘기는 사람이고, 델리게이트는 일을 대신 받아서 해주는 사람입니다.

프로그래밍에서의 위임

직관적으로 위임을 잘 사용하면 내가 하려고 하는 일을 좀 더 효율적으로 할 수 있다는 것은 명확합니다. 그럼 프로그래밍의 세계에서는 위임이 구체적으로 어떤 방식으로 우리 일을 효율적으로 할 수 있도록 도와 줄까요?

위임의 장점

위임의 장점을 코드의 관점에서 바라보면 크게 아래 세 가지로 나눌 수 있습니다.

  • 코드의 재사용성
    동동이는 바리스타로서 음료 준비의 전문가입니다. 손님으로 부터 주문을 받으면 계속 동동이를 이용해서 음료를 제공할 수 있습니다. 제가 새로운 까페 체인점을 만든다면 다른 체인점에서 동동이를 쓸 수도 있겠죠. 잠시 일을 도우러 온 알바 생이 궁금한게 있으면 동동이에게 도움을 받을 수도 있습니다. 이렇게 바리스타로서 음료의 전문가인 동동이로부터 음료와 완련된 모든 일들에 걸쳐 도움 받을 수 있으니 재사용성이 높습니다.
  • 관심사의 분리(SoC - Separation of Concerns)
    까페의 사장으로서 저는 까페의 운영이라는 작업에 집중하고 음료 준비와 관련된 일들은 전문가인 동동이에게 맡길 수 있습니다. 즉 까페 운영이라는 관심사와 음료 준비라는 관심사가 분리되었습니다.
  • 확장성 & 동적 행위 변경
    음료의 퀄리티나 스타일을 바꾸고 싶으면 동동이를 변경하거나, 동동이를 해고하고 다른 바리스타를 고용하기만 하면 됩니다. 까페의 다른 구성요소의 변경 없이 최소한의 수정으로 런타임에 행위를 변경할 수 있습니다.

기본 사용법 - 클래스 위임

클래스 위임을 이용하면 인터페이스의 구현을 다른 델리게이트에게 넘길 수 있습니다. 즉 인터페이스의 기능(함수)와 데이터(속성)를 직접 구현할 필요 없이 다른 객체를 이용해서 처리할 수 있습니다.

인터페이스의 구현을 특정 델리게이트로 위임하기

아래의 예제를 보면 Barista를 구현하는 클래스를 생성하면서 by 키워드를 이용하여 Barista의 구현을 특정 델리게이트로 넘길 수 있습니다.

여기서는 Barista를 구현하는 StarbucksBarista 또는 EdiyaBarista 둘 중 하나가 되겠네요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Interface
interface Barista {
fun makeCoffee()
}

// Delegate
class StarbucksBarista : Barista {
override fun makeCoffee() {
println("Make Starbucks-style coffee")
}
}
// Delegate
class EdiyaBarista : Barista {
override fun makeCoffee() {
println("Make Ediya-style coffee")
}
}

// Delegator: Cafe, Delegate: StarbucksBarista
class StarbucksCafe(): Barista by StarbucksBarista
// Delegator: Cafe, Delegate: EdiyaBarista
class EdiyaCafe(): Barista by EdiyaBarista

// Usage
fun main() {
val starbucks = StarbucksCafe()
starbucks.makeCoffee()

val ediya = EdiyaCafe()
ediya.makeCoffee()
}

인터페이스의 구현을 생성자를 이용하여 특정 델리게이트로 위임 하기

좀 더 유연하게 쓰고 싶다면 아래 처럼 쓸 수 있습니다. 생성자를 통해 Barista 인터페이스를 구현하는 구현체를 넘기는 방식입니다. Cafe 클래스에 Barista만 바꿔서 생성하면 되니 좀 더 유연하다고 할 수 있겠네요. 이 방법은 처음 방식보다 조금 더 유연하지만 여전히 객체의 생성 시 바리스타가 고정되며, 이후 바리스타를 바꾸는 것은 불가능합니다. 까페를 개업할 때 바리스타를 고용해서 평생 함께 가는거네요. 바리스타 입장에선 고용 안정이 되어서 좋지만 사장 입장에선 자유롭게 바리스타를 바꿀 수 있었으면 좋겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Interface
interface Barista {
fun makeCoffee()
}

// Delegate
class StarbucksBarista : Barista {
override fun makeCoffee() {
println("Make Starbucks-style coffee")
}
}
// Delegate
class EdiyaBarista : Barista {
override fun makeCoffee() {
println("Make Ediya-style coffee")
}
}

// Delegator: Cafe, Delegate: barista
class Cafe(barista: Barista): Barista by barista

// Usage of Cafe
fun main {
val starbucks = Cafe(StarbucksBarista())
starbucks.makeCoffee()

val ediya = Cafe(EdiyaBarista())
ediya.makeCoffee()
}

동적으로 델리게이트 변경하기

동적으로 런타임에 새로운 객체 생성 없이 바리스타를 바꾸고 싶다면 이렇게 하면 됩니다. 델리게이터인 Cafe 객체를 생성할 때 델리게이트를 넣어줄 수도 있지만 changeBarista를 이용하여 객체의 재 생성 없이 델리게이트를 변경 할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Interface
interface Barista {
fun makeCoffee()
}

// Delegate
class StarbucksBarista : Barista {
override fun makeCoffee() {
println("Make Starbucks-style coffee")
}
}
// Delegate
class EdiyaBarista : Barista {
override fun makeCoffee() {
println("Make Ediya-style coffee")
}
}

// Delegator: Cafe, Delegate: changable in runtime
class Cafe(b: Barista): Barista by b {
private var barista: Barista = b

override fun makeCoffee() {
barista.makeCoffee()
}

fun changeBarista(newBarista: Barista) {
this.barista = newBarista
}
}

// Usage of Cafe
fun main {
val cafe = Cafe(StarbucksBarista())
cafe.makeCoffee()

cafe.changeBarista(EdiyaBarista())
cafe.makeCoffee()
}

기본 사용법 - 2. 프로퍼티 위임

말 그대로 속성의 접근을 다른 객체에 위임하는 방법입니다. 즉 속성의 값을 읽거나 쓰는 두 가지 동작을 다른 객체에 위임 할 수 있다는 말이 됩니다.

코틀린에서는 몇 가지 프로퍼티 위임을 처리하는 델리게이트를 제공합니다.

코틀린에서 제공하는 내장 Delegate의 종류

Lazy Properties

속성의 지연 초기화를 지원합니다. 아래의 예에서 lazyValue는 최초 접근 시 lazy 함수에 전달되는 객체의 초기화 로직을 수행하는 람다 함수를 호출함으로써 객체를 초기화 합니다. 이 로직은 최초 접근 시 한번만 호출되며 이후에는 초기화 된 값으로 데이터 읽기가 수행됩니다.

1
2
3
4
5
val lazyValue: String by lazy {
println("Computed!")
"Hello"
}

Observable

Delegates.observable는 첫번째 파라미터로 초기값을, 두번째 파라미터로는 람다 함수를 인자로 받습니다. 람다 함수는 old, new로 이전 값과, 새로 바뀐 값을 전달해줍니다.

1
2
3
4
5
6
7
8
9
10
var name: String by Delegates.observable("<no name>") { prop, old, new ->
println("$old -> $new")
}

name = "first"
name = "second"

// result
<no name> -> first
first -> second

Vetoable

Delegates.vetoableDelegates.observable과 다르게 값이 변경되기 전에 콜백이 호출됩니다. 두번째 파라미터에서 Delegates.observable과 다른 점은 Boolean을 반환하는 람다를 파라미터로 받는 다는 점인데 이 값이 true인 경우에만 값이 변경됩니다.

아래의 예제에서는 새 값이 기존 값보다 클때만 max의 값이 변경됩니다.

1
2
3
4
5
6
7
8
9
10
11
var max: Int by Delegates.vetoable(0) { prop, old, new ->
new > old
}

println(max) // 0

max = 10
println(max) // 10

max = 5
println(max) // 10

아래와 같이 사용하면 아예 예외를 던지도록 하는 것도 가능합니다.

1
2
3
4
5
6
7
8
9
10
var max: Int by Delegates.vetoable(0) { property, oldValue, newValue ->
if (newValue > oldValue) true else throw IllegalArgumentException("New value must be larger than old value.")
}

println(max) // 0

max = 10
println(max) // 10

// max = 5 // will fail with IllegalArgumentException

Map Delegation

위임을 이용하면 map에 있는 값을 속성으로 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val properties = mapOf(
"name" to "jude"
"age" to 10
)

data class User(val map: Map<String, Any?>) {
val name: String by map
val age: Int by map
}

val user = User(properties)
println(user) // User(map={name=jude, age=10})
println(user.name) // jude
println(user.age) // 10

아래와 같이 mutableMap을 이용하면 속성의 값을 수정 가능하도록 만들 수도 있습니다. 이를 이용하면 JSON을 파싱하는 작업을 아주 편하게 할 수 있습니다.

1
2
3
4
class MutableUser(val map: MutableMap<String, Any?>) {
var name: String by map
var age: Int by map
}

NotNull

var로 선언된 속성에 값이 설정되기 전에 접근하면 IllegalStateException을 발생 시킵니다. 이를 이용하면 런타임에 예외를 적절히 다뤄줘야하긴 하지만 선언과 값의 설정을 분리 할 수 있습니다. 참고로 코틀린에서는 notNull을 쓰지 않고 var로 변수를 선언하면서 값을 초기화 해주지 않으면 컴파일이 되지 않습니다.

1
2
3
4
#### NotNull
속성에 값이 설정되기 전 접근 시 IllegalStateException을 발생 시킴
```kotlin
var notNullVar: String by Delegates.notNull()

프로퍼티 위임의 커스텀

코틀린에서는 속성에 대한 위임을 지원합니다. 즉 클래스, 함수, 코드 블럭 안에서 사용하는 변수에 대한 접근을 델리게이트에 위임할 수 있습니다.

속성에 대한 접근이라고 하면 데이터를 가져오는 동작과, 데이터를 쓰는 동작이 있습니다. 따라서 속성에 대한 델리게이트는 결국 이 두 가지 동작을 커스텀 델리게이트에 구현하는 것 입니다.

먼저 사용법을 바로 보도록 하겠습니다.

1
2
3
class Example {
var p: String by Delegate()
}

위의 샘플 코드는 아래와 같이 문법적으로 나눠서 볼 수 있습니다. 일반적인 변수 선언 방법에서 뒤에 by <expression> 이 추가된 형태로, by 뒤에 있는 델리게이트에 속성에 대한 읽기, 쓰기를 위임한다는 의미입니다.

val/var <property name>: <Type> by <expression>

코틀린에서의 속성에 대한 델리게이트는 아래와 같은 형태로 구현할 수 있습니다. 아까 언급한 것처럼 읽기와 쓰기를 대신 처리해주기 위해 getValuesetValue를 구현하고 있는걸 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
import kotlin.reflect.KProperty

class Delegate {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "$thisRef, thank you for delegating '${property.name}' to me!"
}

operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
println("$value has been assigned to '${property.name}' in $thisRef.")
}
}

만약 델리게이트가 읽기 전용으로 val로만 쓰인다면 getValue만 추가하면 됩니다. 수정이 가능한 var로 사용하신다면 델리게이트에 setValue도 추가로 구현이 필요합니다.

ReadOnlyPropertyReadWriteProperty 인터페이스

코틀린 표준 라이브러리에서는 델리게이트를 만들기 위해 구현해야하는 getValuesetValue를 정의하는 인터페이스를 제공합니다. 이름을 보면 알 수 있듯이 ReadOnlyProperty에서는 getValue만을 정의하고, ReadWriteProperty에서는 getValue와 setValue 둘 다 정의하고 있습니다.

이를 구현하지 않아도 앞서 설명한 getValuesetValue를 구현해주면 델리게이트로 동작하지만 ReadOnlyPropertyReadWriteProperty를 이용하면 클래스 선언 없이 익명 클래스로 바로 델리게이트를 만들 수도 있습니다. 또한 구현하고 있는 인터페이스를 보면 바로 델리게이트로서 동작이 가능하다는 것을 알 수 있기 때문에 가독성도 올라갑니다. 복잡한 getValuesetValue의 시그니처를 외울 필요없이 IDE의 도움을 받아 편하게 정의 할 수 있는 점도 장점이겠죠?

1
2
3
4
5
6
7
8
9
10
11
fun resourceDelegate(resource: Resource = Resource()): ReadWriteProperty<Any?, Resource> =
object : ReadWriteProperty<Any?, Resource> {
var curValue = resource
override fun getValue(thisRef: Any?, property: KProperty<*>): Resource = curValue
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Resource) {
curValue = value
}
}

val readOnlyResource: Resource by resourceDelegate() // ReadWriteProperty as val
var readWriteResource: Resource by resourceDelegate()

실제 코드로 보는 위임의 사용 예(mutableStateOf)

안드로이드에서 Jetpack Compose로 UI를 개발하고 있다면 State의 개념은 아주 친숙할 것입니다.

Compose는 데이터를 UI로 그대로 노출하는 선언형 UI 프레임워크 입니다. Compose에서는 UI에 노출할 데이터를 관리하는 특별한 인터페이스를 하나 제공하는데요. 바로 State라는 인터페이스입니다.

1
2
3
4
@Stable
interface State<out T> {
val value: T
}

State안에는 val value: T 형태로 value 속성을 가지고 있습니다. State의 특별함은 Composable function에서 State의 상태 변화를 구독해서 State가 변경되면 자동으로 재구성(Recomposition) 트리거 된다는 점입니다.

이런 State는 어떤 식으로 사용될까요? 아래는 안드로이드에서 컴포즈로 UI를 개발하고 있다면 아주 친숙한 코드일거라 생각합니다.
(Composable 함수가 아닌 ViewModel에서는 mutableStateOf가 아닌 mutableStateFlow의 사용이 권장 되지만 이해를 돕기위한 예제로 이렇게 구성했으니 참고해주세요)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyViewModel: ViewModel() {
...
var showDialog: MutableState<Boolean> = mutableStateOf(false)
...
}

@Composable
fun MyScreen(viewModel: MyViewModel) {
...
if (viewModel.showDialog.value) {
MyDialog(onDismissRequest = {
viewModel.showDialog.value = false
})
...
}

showDialogvalue에 따라 MyDialog()를 UI에 노출 합니다. showDialog의 값은 MutableState기 때문에 변경이 가능하구요. 하지만 위의 예를 보고 뭔가 이상함을 느끼신 분들도 계실거 같습니다. 사실 우리가 실제로 MutableState를 쓰는 방식은 대부분 아래와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyViewModel: ViewModel() {
...
var showDialog: MutableState<Boolean> by mutableStateOf(false)
...
}

@Composable
fun MyScreen(viewModel: MyViewModel) {
if (viewModel.showDialog) {
MyDialog(onDismissRequest = {
viewModel.showDialog = false
})
}
}

귀찮거나, 지저분한 코드를 병적으로 싫어하는 개발자 답게 코틀린의 위임을 이용해서 조건문에서 showDialog.valueState에 접근하던걸 showDialog로 단순화 했네요. 위임을 사용했기 때문에 showDialog를 선언할 때 =by로 바뀐 것을 볼 수 있습니다.

그럼 MutableState 부터 차근차근 코드를 따라가 보도록 하겠습니다. 당연히 MutableState 역시 State를 구현하고 있는걸 볼 수 있습니다. 한 가지 눈에 띄는 부분은 Statevalue를 오버라이드 하면서 val 키워드로 선언되었던 value가 변경 가능한 변수임을 나타내는 var로 바뀐 점입니다. MutableState라는 이름에서 보이듯이 MutableState는 가지고 있는 value가 변경 가능해야하기 때문입니다.

1
2
3
4
5
6
@Stable
interface MutableState<T> : State<T> {
override var value: T
operator fun component1(): T
operator fun component2(): (T) -> Unit
}

MutableState의 델리게이트 구현

MutableStateState가 어떻게 생겼는지 보았는데 이것들이 델리게이트로는 어떻게 구현이 되어있는지를 살펴보도록 하겠습니다.

먼저 MutableState는 값의 변경이 가능해야합니다. 즉 쓰기 가능해야합니다. 따라서 setValue를 구현해주어야겠죠? 코드를 보면 바로 해당 함수를 찾을 수 있습니다. 아주 심플하게 this.value = value 이렇게 구현이 되어있네요.

1
2
3
inline operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
this.value = value
}

그럼 getValue 는 어떻게 구현되어 있을까요? 역시나 간단히 State 인터페이스의 value를 반환하는 단순한 코드로 구현이 되어있습니다.

1
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value

델리게이트를 알기 전에는 외계어 같이 보였던 코드가 지금은 너무 단순한 코드로 읽히네요. 😆

위임의 본질

상속 vs 구성(위임)

앞에 설명드린 많은 예제들을 보면 사실 위임이 아닌 다양한 방식으로 구현이 가능합니다. 전 프로그래밍이 결국 글쓰기와 동일하다고 생각합니다. 글쓰기에서는 전하는 핵심 메세지가 동일하더라도 이를 표현하는 문장은 수없이 다양할 수 있습니다.

다만 제목에 적은 세 가지의 개념을 간단히 한번 짚고 넘어간다면 도움이 될거 같습니다. 위의 세 가지 개념은 객체지향 프로그래밍의 토대가 되는 개념 들이며 대다수의 객체지향 프로그래밍 언어에서 이를 이용하여 코드를 작성할 수 있도록 지원합니다.

상속

먼저 상속은 객체의 관계를 부모-자식 관계로, 또는 is - a 관계로 많이 표현합니다. 당연히 구현하려고 하는 객체 사이의 관계를 이렇게 표현하는게 직관적이라면 상속을 사용할 수 있습니다. 상속을 사용하면 객체간의 관계를 수직적으로 표현하여 사람이 직관적으로 이해하기 좋고 부모의 속성과 기능이 자식에게 모두 전해지기 때문에 잘 모델링 하면 공유되는 기능과 속성을 부모에 두고 코드를 재활용 할 수 있는 장점이 있습니다.

상속보단 구성(Composition over Inheritance)

객체 지향 디자인 원칙에서 자주 언급되는 개념입니다. 앞서 상속의 장점들을 이야기 했지만 상속의 장점은 곧 단점이 되기도 합니다. 부모의 속성과 기능이 자식에게 전해지는 것은 얼핏 보면 코드의 재사용성을 높여주기도 하지만 크게 보면 캡슐화가 약해지는 단점과, 부모의 변경에 따라 자식이 영향을 받게 되는 강한 결합이 발생하며, 상속 관계가 깊어지면 자식 객체는 지나치게 비대해 져서 필요없는 기능이나 속성이 늘어나고, 어떤 기능과 속성이 있는지를 파악하는 것도 매우 힘들어집니다.

구성

이에 따라 최근에는 상속 보단 구성을 이용해서 코드를 작성하는걸 권장하고 있습니다. 구성은 has-a 관계로 많이 표현하며 해야하는 작업을 여러 객체로 나누고 합쳐서 원하는 기능을 구현하는 방식입니다. 각각의 객체가 할 일을 나눠서 가지고 있기 때문에 캡슐화가 잘 지켜지며 상속과 같은 복잡한 수직 구조를 갖지 않습니다. 물론 이에 따라 훨씬 더 많은 객체가 생겨나고 이들의 관계가 복잡해 지는 부분은 단점이 될 수도 있습니다.

위임

위임은 객체가 필요한 기능을 다른 객체로 전달하는 방식입니다. 결국 위임은 구성의 한 방법입니다. 구성이 좀 더 강력하고 편리하게 쓰일 수 있도록 도와주는 문법적인 장치라고도 볼 수 있습니다.

실제로 코틀린에서 by 키워드를 통해 위임을 사용하면 코틀린 컴파일러는 by가 지정하는 객체를 직접 생성해서 해당 객체의 레퍼런스를 갖고 이를 호출 하는 방식으로 위임을 처리합니다. 즉 다른 객체를 생성하고 이를 이용해서 특정 기능을 수행하는 구성과 완전 동일합니다. 다만 코틀린에서 문법적으로 매번 해당 객체의 레퍼런스를 호출하는걸 줄여주는 것일 뿐이죠.

아래 코드는 코틀린 공식 페이지에 있는 설명으로, 위임이 실제 어떤 식으로 구현이 되는지를 보여줍니다.

1
2
3
4
5
6
7
8
9
10
11
class C {
var prop: Type by MyDelegate()
}

// this code is generated by the compiler instead:
class C {
private val prop$delegate = MyDelegate()
var prop: Type
get() = prop$delegate.getValue(this, this::prop)
set(value: Type) = prop$delegate.setValue(this, this::prop, value)
}

위임의 대표적인 사용 예

위임이 구성의 한 방법이라고 보면 아래의 일들은 당연히 위임으로 처리하기 아주 좋은 사례라고 할 수 있습니다. 위임을 하면 특정 인터페이스나, 속성의 접근을 내가 통제할 수 있습니다. 즉 프록시나, 훅과 같은 역할을 델리게이트에 구현할 수 있는 것이죠. 결국 아래의 사용 사례들은 프록시나 훅으로 처리하기 좋은 것들이기도 합니다.

  • 로직의 재사용
  • 데이터 유효성 검사
  • 의존성 주입
  • 동적 행위 변경
  • 자원 관리
  • 모니터링 & 로깅
  • 원래의 기능에 새로운 기능 추가

이번 포스트에서는 위임에 대해 알아보았습니다. 내용이 좀 많긴 하지만 위임을 활용했을 때 가질 수 있는 많은 장점을 생각하면 여기서 다룬 내용들이 독자에게 많은 도움이 될거라 생각합니다.

또한 위임을 통해 구현된 코틀린, 안드로이드의 많은 기능들을 직접 코드를 따라가면서 분석해본다면 코드를 이해하는데에도 많은 도움이 될 것입니다.