자바와 코틀린에서의 변성(Variance in Java and Kotlin)

들어가며

코틀린 코드에서 자주 보게되는 inout, 그리고 자바에서 자주 보게 되는 <? extends T>, <? super T> 는 제네릭의 변성과 깊은 관련이 있습니다. 이 글에서는 자바와 코틀린과 같은 객체지향 프로그래밍 언어에서 사용되는 변성의 개념을 살펴 보도록 하겠습니다.

서브타입과 다형성(Subtypes and Polymorphism)

객체지향 프로그래밍 언어에서는 클래스의 상속이나 인터페이스, 추상클래스를 이용하여 객체를 모델링 합니다. 추상화된 개념을 이용하여 슈퍼타입을 만들면 이로부터 슈퍼타입을 구체화 하는 서브타입을 만들어 낼 수 있습니다.

서브타입은 슈퍼타입에 정의된 기능과 속성을 상속받아 그대로 사용하거나 필요에 따라 재정의할 수 있습니다.

서브타입을 인스턴스화 할 때 해당 인스턴스는 그 슈퍼타입으로 받아 사용이 가능합니다. 이를 다형성이라고 합니다. 다형성은 객체지향 프로그래밍의 핵심 기능으로 아주 강력한 편의성과 확장성을 가져다 주는 도구입니다.

이를테면 동물이라는 추상화를 하고 이를 구체화하는 개, 고양이와 같은 서브타입을 만들 수 있는데 이를 서브타이핑이라 하고, 동물이란 타입을 이용하여 개나 고양이 객체를 받아서 동물에 정의된 공통 동작과 속성을 사용하는 것을 다형성 이라고 합니다.

객체 지향 프로그래밍 언어에서 갖는 설계상 이점의 아주 큰 부분이 다형성을 바탕으로 존재합니다.

Subtyping

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
// Super class
open class Animal {
open fun makeSound() {
println("Animal makes a sound")
}
}

// Sub class
class Dog : Animal() {
override fun makeSound() {
println("Bark")
}
}

// Sub class
class Cat : Animal() {
override fun makeSound() {
println("Meow")
}
}

fun main() {
val myDog: Animal = Dog()
val myCat: Animal = Cat()

myDog.makeSound() // Outputs: Bark
myCat.makeSound() // Outputs: Meow
}

제네릭(Generics)

제네릭은 객체지향 프로그래밍을 지원하는 언어에서 타입 안정성을 제공하면서도 다양한 타입을 다룰 수 있는 방법으로 제공됩니다. 클래스, 인터페이스, 함수를 정의 할 때 타입을 플레이스홀더로 지정하는 방식으로 사용합니다.

이해를 돕기 위한 예를 들자면 제네릭은 라벨이 붙은 상자라고 볼 수 있습니다. 상자에 붙은 라벨에 따라 이 박스는Animal을 넣거나, Dog, Cat 을 넣을 수 있습니다.

Generics

  • 제네릭 클래스(Generic Classes)

    1
    2
    3
    class Box<T>(t: T) {
    var value = t
    }
  • 제네릭 함수(Generic Functions)

    1
    2
    3
    fun <T> singletonList(item: T): List<T> {
    return listOf(item)
    }

변성(Variance)

앞서 서브타이핑, 다형성, 그리고 제네릭에 대해 설명했습니다. 그럼 Box<T>라는 제네릭 타입이 있을 때, Box<Animal>Box<Dog>, Box<Cat> 사이에도 Animal, Dog, Cat 사이에 있었던 서브타이핑 관계가 유지될까요?

변성이란 바로 서브타이핑 관계에 있는 타입들이 제네릭에서 갖는 성질입니다.

변성은 불변성(Invariance), 공변성(Covariance), 반공변성(Contravariance) 의 세 가지 성질을 가집니다.

불변성(Invariance)

자바나 코틀린에서는 다형성으로 인해 ST의 서브타입이면 T = S가 가능하지만, Box<T> = Box<S>는 기본적으로 허용되지 않습니다. 이것이 제네릭의 불변성입니다. 한마디로 제네릭 타입 간에는 다형성과 같은 규칙이 적용되지 않습니다.

아래와 같은 코드는 잘 동작하지만,

1
2
3
4
class Animal
class Dog: Animal

val animal: Animal = Dog()

아래와 같은 코드는 허용되지 않습니다.

1
2
3
4
5
class Animal
class Dog: Animal

class Box<T>(var t: T)
val animalBox: Box<Animal> = Box<Dog>(Dog())

제네릭 타입이 불변인 이유

그렇다면 제네릭이 불변성을 갖는 이유는 무엇일까요?

이는 런타임 타입 안정성 때문입니다. 예를들어 자바에서 Object는 모든 타입의 슈퍼타입이지만 다형성에서 허용하듯 제네릭에서도 이를 허용하면 아래와 같은 경우 런타임 타입 안정성이 깨지게 됩니다.

1
2
3
4
5
6
7
8
9
10
// 다형성에서는 슈퍼타입이 서브타입을 받을 수 있다.
Animal obj = new Dog();

// 제네릭 타입에서는 다형성에서의 타입 관계가 허용되지 않는다.
List<Dog> dogs = new ArrayList<Dog>();
List<Animal> animals = dogs; // 컴파일이 되지 않음!

// 컴파일이 됐다면 아래 코드에서 런타임 에러 발생
animals.add(Cat()); // 쓰기: animals는 사실 ArrayList<Dog>이므로 Cat을 넣을 수 없음
Dog s = dogs.get(0); // 읽기: dogs는 ArrayList<Dog>이지만 Cat을 반환하게 됨

공변성(Covariance)과 반공변성(Contravariance)

공변성과 반공변성은 불변의 위험성이 없는 경우, 즉 런타임 타입 안정성이 보장되는 경우 불변의 제약을 제한적으로 풀어주는 것 입니다.

공변성과 반공변성이 필요한 이유

제네릭 타입이 불변이어야 하는 이유는, 런타임 타입 안정성을 유지하기 위한 것입니다. 공변성과 반공변성은 이러한 제약을 완화하면서도 안전성을 보장할 수 있는 경우에 사용됩니다.

예제에서 처럼 런타임 타입 안정성이 깨지는 경우도 있지만 안전한 경우도 있습니다. 이런 경우에는 불변성이라는 제약이 필요없습니다. 그렇다면 이런 경우에는 제한적으로라도 불변성 이라는 제약을 풀어줄 수 있지 않을까요?

공변성(Covariance)

공변성에서는 한 타입이 다른 타입의 서브타입이면, 그 제네릭 타입도 서브타입 관계를 유지합니다. 예를 들어, DogAnimal의 서브타입인 경우, List<Dog>List<Animal>의 서브타입으로 간주할 수 있습니다.

공변성의 조건

공변성이 성립하기 위해서는 제네릭 타입이 데이터를 쓰지 않고, 오직 읽기만 가능해야합니다. 그리고 데이터를 읽을 때는 제네릭에서 사용하는 타입의 슈퍼타입으로만 데이터를 읽어와야합니다.

데이터를 쓰지 않고 제공(읽기)만 하기 때문에 우리는 이것을 생산자 라고도 부릅니다.

자바에서 제네릭 타입에 공변성 부여하기

자바에서는 상한 와일드 카드(Upper bound wild card)를 이용하여 공변성을 부여할 수 있습니다. 아래 코드에서 보면 <? extends E>가 상한 와일드 카드입니다.

1
2
3
interface Collection<E> ... {
boolean addAll(Collection<? extends E> items);
}

상한 와일드 카드는 제네릭 타입의 EE 또는 E 의 서브타입이 들어올 수 있도록 컴파일러에 알려줍니다. 상한 와일드 카드는 읽기만 허용하고 쓰기는 허용하지 않습니다. 쓰기를 허용하지 않는 것은 제약이지만 이 제약 덕분에 앞서 설명한 런타임 타입 안정성이 깨지는 상황을 막을 수 있습니다.

잘 생각해보면 제네릭에 공변성을 부여하는 것은 불변성의 제약을 제네릭 타입에 쓰기를 허용하지 않는 제약으로 바꾸는 걸로도 볼 수 있습니다.

앞서 공변성을 줄 수 있는 조건에 대한 설명과도 딱 맞아 떨어집니다.

CollectionaddAll()이 공변성이 없이 아래와 같이 선언이 되었다면 우리는 animals.addAll(dogs) 같은 코드를 사용할 수 없었을 것 입니다.

1
2
3
interface Collection<E> ... {
boolean addAll(Collection<E> items);
}
코틀린에서 제네릭 타입에 공변성 부여하기

이번에는 코틀린에서의 공변성을 부여하는 방법에 대해 알아보도록 하겠습니다. 아래 Source 인터페이스를 보면 T를 반환하는 메소드만 존재합니다.

1
2
3
interface Source<T> {
T nextT();
}

이 인터페이스는 T 타입을 반환하는 코드만 있기 때문에 공변성을 부여할 수 있는 조건에 해당합니다. 즉 이 제네릭 인터페이스는 값을 생산(읽기) 하기만 할 뿐 쓰는 동작을 하지 않는 생산자입니다. 하지만 그럼에도 불구하고 아래와 같은 코드는 컴파일 에러를 발생 시키며 허용하지 않습니다.

1
2
3
4
void demo(Source<String> strs) {
Source<Object> objects = strs; // !!! Not allowed in Java
// ...
}

이는 앞서 설명한 대로 제네릭이 불변이기 때문입니다. 자바에 Source<T> 가 안전하다는걸 알려주기 위해 앞서 배웠던 상한 와일드 카드를 이용해서 아래와 같이 수정이 가능합니다.

1
2
3
4
5
void demo(Source<? extends Object> strs) {
Source<Object> objects = strs;
// ...
}

자바에서처럼 제네릭 타입을 사용할 때마다 사용하는 곳에서 상항 와일드 카드를 사용해서 공변성을 부여하는 것을 사용 지점 변성(use-site variance) 이라고 합니다. 하지만 이렇게 매번 반복하는 것은 번거롭습니다.

코틀린에서는 선언 지점 변성(Declation-site variance) 을 이용하여 이를 좀 더 편리하게 사용할 수 있습니다. 선언 지점 변성은 제네릭 타입이 이미 공변성이나 반공변성을 만족한다면 제네릭 타입의 선언 시점에 이를 함께 선언해줄 수 있습니다. (참고로 코틀린에서는 사용 지점 변성과 선언 지점 변성을 둘 다 지원합니다.)

즉 코틀린에서는 매번 공변성을 사용할때마다 상한 와일드 카드를 사용하는 것이 아니라 Source 인터페이스를 선언하면서 T 앞에 out 이라는 키워드를 추가하는 방식으로 한번에 공변성 부여가 가능합니다.

1
2
3
4
5
6
7
8
interface Source<out T> {
fun nextT(): T
}

fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // This is OK, since T is an out-parameter
// ...
}

다만 out 키워드를 이용해 선언 지점 반성을 사용하기 위해서는 제네릭 타입이 공변성의 조건에 부합해야합니다. 그러기 위해서는 해당 제네릭 타입은 쓰기 동작이 없고, 읽기 동작만 있어야합니다. 읽기 동작은 데이터를 생산 하는 것을 의미 합니다. 따라서 T는 이제 out-positon 에서만 사용이 가능합니다. 이는 T가 제네릭 클래스 내 함수의 리턴 타입 위치, 또는 퍼블릭 속성으로만 사용이 가능하다는 의미입니다.

이렇게 T가 제네릭 클래스 안에서 out-position 에서만 사용이 되는 경우 이 제네릭 클래스는 T의 생산자라고 합니다. 이런 경우 해당 클래스 또는 인터페이스는 T를 소비하지 않고 생산만 하기 때문에 out 키워드를 통해 제네릭 타입의 선언 시점에 공변성 부여가 가능합니다.

공변성을 말할 때는 아래의 네 가지를 떠올리면 이해가 빠릅니다.

  1. Upper Bounded Wildcards (<? extends Type>)
  2. out
  3. producer
  4. read

반공변(Contravariance)

반공변성에서는 한 타입이 다른 타입의 서브타입이면, 그 제네릭 타입은 슈퍼타입 관계를 유지합니다. 예를 들어, DogAnimal의 서브타입인 경우, List<Animal>List<Dog>의 슈퍼타입으로 간주할 수 있습니다.

반공변성의 조건

반공변성은 데이터를 소비(쓰기)만 하는 경우 안전하게 사용이 가능합니다. 읽기도 가능은 하지만 이 경우 안전하게 사용하기 위해서는 자바에서는 Object, 코틀린에서는 Any와 같이 가장 범용적인 타입으로 가져와야 안전하게 사용이 가능합니다.

데이터를 쓰는데 사용되기 때문에 이를 소비자 라고 부릅니다.

자바에서 제네릭 타입에 반공변성 부여하기

자바에서는 하한 와일드 카드(Lower bound wild card)를 이용하여 반공변성을 부여할 수 있습니다. 아래 코드에서 보면 <? super E>가 하한 와일드 카드입니다.

1
public static <T> void sort(List<T> list, Comparator<? super T> c)

sort 메서드는 List<T>의 원소들을 정렬하는 데 사용되며, Comparator<? super T> 타입의 Comparator를 인자로 받습니다. Comparator는 리스트의 원소 타입 T 또는 T의 슈퍼타입 객체들을 비교할 수 있습니다.

Comparator<? super T>T 타입 또는 T의 어떤 슈퍼타입을 사용하여 두 객체를 비교할 수 있습니다. 이는 Comparator가 반공변적임을 의미하는데, T의 슈퍼타입에 대해 비교 기능을 제공할 수 있기 때문입니다.

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
class Animal {
int age;

public Animal(int age) {
this.age = age;
}
}

class Dog extends Animal {
public Dog(int age) {
super(age);
}
}

class AnimalAgeComparator implements Comparator<Animal> {
public int compare(Animal a1, Animal a2) {
return Integer.compare(a1.age, a2.age);
}
}

public static void main(String[] args) {
List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog(5));
dogs.add(new Dog(2));
dogs.add(new Dog(9));

// AnimalAgeComparator는 Comparator<Animal>이지만, 여기서는 Dog 리스트를 정렬하는 데 사용됩니다.
Collections.sort(dogs, new AnimalAgeComparator());
}

위 예제에서 AnimalAgeComparatorAnimal 객체들을 나이에 따라 비교합니다. Collections.sort() 메서드는 이 비교자를 사용하여 Dog 객체의 리스트를 정렬할 수 있습니다. 이는 Comparator<Animal>Dog의 슈퍼타입인 Animal을 처리할 수 있기 때문입니다. 이 예제에서 Comparator<? super T>의 반공변성을 통해 Dog 리스트를 정렬하는 데 Animal을 대상으로 하는 Comparator를 사용할 수 있습니다.

코틀린에서 제네릭 타입에 반공변성 부여하기

아래 예제는 코틀린의 Comparable 제네릭 인터페이스 입니다. Comparableoperator fun compareTo(other: T): Int 메소드만 가지고 있습니다. 이 메소드는 T 타입을 파라미터로 받아서 소비하기만 합니다. 따라서 in 키워드를 통해 Comparable에 반공변성을 부여할 수 있습니다.

1
2
3
4
5
6
7
8
interface Comparable<in T> {
operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Animal>) {
x.compareTo(Dog())
val y: Comparable<Dog> = x
}

코드를 보면 x: Comparable<Animal> 를 가지고 Animal의 서브타입인 Dog와 값 비교를 하고, val y: Comparable<Dog> = x 역시 가능함을 볼 수 있습니다.

반공변성을 말할 때는 아래의 네 가지를 떠올리면 이해가 빠릅니다.

  1. Lower Bounded Wildcards (<? super Type>)
  2. in
  3. consumer
  4. write

마치며

변성에 대한 이해가 부족하여 공부를 할겸 작성한 글입니다. 변성이란 개념은 처음에는 다소 이해하기가 어렵지만 잘 이해해둔다면 다른 코Ï드를 읽거나, 제네릭을 이용한 객체지향 설계 능력을 향상 시키는데 큰 도움이 될거라 생각합니다.

개인적으로는 이 개념을 이해하는게 참 힘들었는데 이 글이 다른 분들에게도 아주 조금이나마 도움이 되었으면 좋겠습니다.

참고

안드로이드에서의 딥링크(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 같은 다른 마케팅 솔루션 서비스에서도 제공합니다.

맺음말

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

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

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

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

참고 자료