0%


자바 8에서 추가된 람다식에는 다음과 같은 규칙이 존재한다.

  1. 람다식은 외부 block 에 있는 변수에 접근할 수 있다.
  2. 외부에 있는 변수가 지역 변수 일 경우 final 혹은 effectively final 인 경우에만 접근이 가능하다.

이번 포스트에서는 effectively final 의 정의와 람다식에서 final or effectively final 인 외부 지역 변수만 사용할 할 수 밖에 없는 이유에 대해서 알아보려 한다.

Effectively final 이란 무엇인가?

A non-final local variable or method parameter whose value is never changed after initialization is known as effectively final.

Java 8 에 추가된 syntactic sugar 일종으로, 초기화 된 이후 값이 한번도 변경되지 않았다면 effectively final 이라고 할 수 있다. effectively final 변수는 final 키워드가 붙어있지 않았지만 final 키워드를 붙힌 것과 동일하게 컴파일러에서 처리한다. ‘의미상 final 하다.’ 고 이해해도 괜찮을 것 같다.

적용 사례

Effectively final 은 anonymous class 나 람다식에서 코드를 좀 더 간결하게 만들어준다.

java 7 에서는 anonymous class 가 외부지역변수 가 final 인 경우에만 접근이 가능했기에 항상 final 키워드를 추가해줘야 했다. java 8 에서는 effectively final 인 경우에도 접근이 가능하게 바뀌어 조건을 만족한다면 final 키워드를 생략할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Java 7
public void testPlus() {
final int number = 1;

Addable addableImple = new Addable() {
@Override
public int plusOne() {
return number + 1;
}
};
}

// Java 8
public void testPlus() {
int number = 1; // Effectively final

Addable addableImple = new Addable() {
@Override
public int plusOne() {
return number + 1;
}
};
}

이는 lambda 식에서도 동일하다. (규칙 2를 상기해보자.)

1
2
3
4
5
6
// Java 8
public void testPlus() {
int number = 1; // Effectively final

Addable addableImple = () -> number + 1;
}

lambda 에서 사용되는 Local variable 은 왜 final or effectively final 이여야 할까?

각 단계별로 설명하기 위해 내용이 꽤 긴데, 결론 부분만 보아도 괜찮다.

먼저 확실히 정리할 것

헷갈릴 수 있는 표현을 먼저 정리하고자 한다. 해당 내용을 다루는 몇몇 글에서 ‘람다식에서 참조하는 외부 변수는 final 혹은 effectively final 이어야한다.’ 라고 표현되는 것을 보았는데 이는 엄밀히 얘기하면 틀린 내용이다.

정확한 내용으로 수정하면 다음과 같다.

‘람다식에서 참조하는 외부 지역 변수는 final 혹은 effectively final 이어야한다.’

외부 변수라는 단어에는 지역변수, 인스턴스 변수, 클래스 변수가 모두 포함될 수 있는데, 인스턴스 변수나 클래스 변수는 final 혹은 effective final 하지 않아도 람다식에서 사용할 수 있기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private int instanceNumber = 1;
private static int staticNumber = 1;

// Error, 외부 지역변수는 final 혹은 effectively final 이어야 람다식에서 사용할 수 있다.
public void testPlusByLocalVariable() {
int localNumber = 1;

localNumber = 2;
Addable addableImple = () -> localNumber + 1;
}

// OK, 값을 변경하더라도 문제 없다.
public void testPlusByInstanceVariable() {
instanceNumber = 2;
Addable addableImple = () -> instanceNumber + 1;
}

// OK, 값을 변경하더라도 문제 없다.
public void testPlusByStaticVariable() {
staticNumber = 2;
Addable addableImple = () -> staticNumber + 1;
}

너무 깐깐하다고 생각할 수 있지만 이 차이는 뒤에 설명될 내용과 밀접한 연관이 있기 때문에 미리 정확히 구분하는 것이 중요하다.

다시 해당 섹션의 주제로 돌아가서, 람다식에서 사용되는 지역변수가 final or effective final 이어야하는 이유를 알기 위해서는 Capturing lambda 라는 키워드를 살펴볼 필요가 있다.

Capturing lambda 와 Non-Capturing lambda

람다식에는 2가지 타입이 존재한다.

  • Capturing lambda

    외부 변수를 이용하는 람다식을 의미한다. 외부 변수는 지역변수, 인스턴스 변수, 클래스 변수를 모두 포함한다.

    1
    2
    String message = "CapturingLambda";
    Runnable runnable = () -> System.out.println(message);
  • Non-Capturing lambda

    외부 변수를 이용하지 않는 람다식을 의미한다.

    1
    2
    3
    4
    5
    6
    Runnable runnable = () -> System.out.println("NonCapturingLambda");

    Runnable runnable = () -> {
    String message = "NonCapturingLambda";
    System.out.println(message);
    }

Capturing lambda 는 다시 local capturing lambda 와 non - local capturing lambda 로 구분할 수 있다. local 과 non - local 로 구분하는 이유는 지역 변수가 가지는 특징으로 인해 내부 동작 방식이 다르기 때문이다.

Local Capturing lambda

1
2
3
4
public void testPlusByLocalVariable() {
int localNumber = 1;
Addable addableImple = () -> localNumber + 1;
}

외부 변수로 지역 변수를 이용하는 람다식을 의미한다. 다음과 같은 특징이 있다.

  1. 람다식에서 사용되는 외부 지역 변수는 복사본이다.
  2. final 혹은 effectively final 인 지역 변수만 람다식에서 사용할 수 있다.
  3. 복사된 지역 변수 값은 람다식 내부에서도 변경할 수 없다. 즉 final 변수로 다뤄야 한다.

각 특징이 생기는 이유를 하나씩 살펴보자.

1. 람다식에서 사용되는 외부 지역변수는 복사본이다.

람다식에서는 외부 지역변수를 그대로 사용하지 못하고 복사본을 사용하는 이유는 다음과 같다.

  • 지역 변수는 스택 영역에 생성된다. 따라서 지역 변수가 선언된 block 이 끝나면 스택에서 제거된다.

    → 메소드 내 지역 변수를 참조하는 람다식을 리턴하는 메소드가 있을 경우, 메소드 block 이 끝나면 지역 변수가 스택에서 제거 되므로 추후에 람다식이 수행될 때 참조할 수 없다.

  • 지역 변수를 관리하는 쓰레드와 람다식이 실행되는 쓰레드가 다를 수 있다.

    → 스택은 각 쓰레드의 고유의 공간이고, 쓰레드끼리 공유되지 않기 때문에 마찬가지로 람다식이 수행될 때 값을 참조할 수 없다.

위와 같은 이유로 인해 람다식에서는 외부 지역 변수를 직접 참조하지 않고 복사본을 전달받아 사용하게 된다.

2. final 혹은 effectively final 인 지역 변수만 람다식에서 사용할 수 있다.

오늘의 핵심 주제에 드디어 도달했다.

만약 참조하고자 하는 지역 변수가 final 혹은 effectively final 이 아닐 경우 즉, 변경이 가능할 경우 어떤 문제가 발생할 수 있는지 살펴보자.

1
2
3
4
5
6
7
8
9
10
public void executelocalVariableInMultiThread() {
boolean shouldRun = true;
executor.execute(() -> {
while (shouldRun) {
// do operation
}
});

shouldRun = false;
}

람다식이 어떤 쓰레드에서 수행될지는 미리 알 수 없다. 이 얘기는 곧 외부 지역 변수를 다루는 쓰레드와 람다식이 수행되는 쓰레드가 다를 수 있다는 의미이다. 지역 변수 값(shouldRun) 을 제어하는 쓰레드 A, 람다식을 수행되는 쓰레드 B 가 있다고 가정하자. 문제는 다음과 같다.

쓰레드 B의 shouldRun 값이 가장 최신 값으로 복사되어 전달 됐는지 확신할 수 없다는 것이다. 왜냐하면 shouldRun 은 변경이 가능한 지역 변수이고, 지역 변수를 쓰레드 간에 sync 해주는 것은 불가능 하기 때문이다. (지역 변수는 쓰레드 A 의 스택 영역에 존재하기 때문에 다른 쓰레드에서 접근이 불가능하다. volatile 과 같은 키워드가 로컬 변수에서 사용될 수 없는 이유도 이와 같다.)

값이 보장되지 않는다면 매번 다른 결과가 도출 될 수 있다. 예측할 수 없는 코드가 의미가 있을까?
이러한 이유로 인해 외부 지역 변수는 전달되는 복사본이 변경되지 않은 최신 값 임을 보장하기 위해 fianl 혹은 effectively final 이어야 한다.

3. 복사된 지역 변수 값은 람다식 내부에서도 변경할 수 없다. 즉 final 변수로 다뤄야 한다.

처음에는 이미 복사가 된 값이므로 변경해도 문제가 없는 것 아닌가? nope.
복사될 값의 변조를 막아 최신 값임을 보장하기 위해 final 제약을 걸었는데 람다식 내부에서 변경이 가능할 경우 다시 제자리로 돌아오게 된 격이다. 또한 컴파일 된 람다식은 static 메소드 형태로 변경이 되는데, 이때 복사된 값이 파라미터로 전달되므로 마찬가지로 스택영역에 존재하기 때문에 sync 를 해주는 것도 불가능하다. 따라서 람다식 내부에서도 값이 변경 되어서는 안된다.

컴파일러 레벨에서 앞, 뒤로 final 제약을 걸어줌으로써 멀티 쓰레드 환경에서 대응하기 어려운 이슈를 미연에 방지했다.

Non - local capturing lambda

1
2
3
4
5
6
7
8
9
10
11
12
private int instanceNumber = 1;
private static int staticNumber = 1;

public void testPlusByInstanceVariable() {
instanceNumber = 2;
Addable addableImple = () -> instanceNumber + 1;
}

public void testPlusByStaticVariable() {
staticNumber = 2;
Addable addableImple = () -> staticNumber + 1;
}

외부 변수로 인스턴스 변수 혹은 클래스 변수를 이용하는 람다식을 의미한다. local capturing lambda 와 다르게 final 제약 조건이 없고, 외부 변수 값도 복사하지 않는다.

이유는 인스턴스 변수나 클래스 변수를 저장하고 있는 메모리 영역은 공통 영역이고 값이 메모리에서 바로 회수되지 않기 때문에 람다식에서 바로 참조가 가능하다. 따라서 복사 과정이 불필요하고 참조 시 최신 값 임을 보장할 수 있다. 다만 멀티 쓰레드 환경일 경우 volatile, synchronized 등을 이용하여 sync 를 맞춰주는 작업을 잊어서는 안된다.

결론

람다식에서 외부 지역 변수를 이용할 경우 final 혹은 effectively final 이어야 하는 이유는 지역 변수가 스택에 저장되기 때문에 람다식에서 값을 바로 참조하는 것에 제약이 있어 복사된 값을 이용하게 되는데, 이때 멀티 쓰레드 환경에서 복사 될/복사된 값이 변경 가능 할 경우 이로 인한 동시성 이슈를 대응할 수 없기 때문이다.

Ref

https://www.baeldung.com/java-effectively-final

https://www.baeldung.com/java-lambda-effectively-final-local-variables

https://dzone.com/articles/how-lambdas-and-anonymous-inner-classesaic-work


대부분의 디버깅 작업은 안드로이드 스튜디오 내부 디버거와 프로파일러를 이용하면 진행이 가능하기 때문에 adb 의 필요성을 크게 느끼지 못했다. 그런데 간혹 스튜디오 도구만으로는 해결이 어렵거나 애매한 케이스들을 맞이할 상황이 생겼고, 깊게 파보다 보니 adb 를 이용해 디버깅 할 수 있다는 사실을 알게 되었다.

사실 같은 작업을 수행한다면 커맨드 라인보다 GUI 를 선호해서 그런지 adb 와 그리 친숙하지 않았는데, 공부하다보니 제법 유용한 기능이 많아서 이번 기회에 간단히 정리를 했다.

시작하기 전에

adb 를 이용하기 전에 2가지 작업이 선행되어야 한다.

1. 환경변수 설정

adb 명령을 편하게 사용하기 위해서는 환경변수 설정이 필수다. MAC/window 별 설정 방법은 이미 자세히 설명되어있는 글들이 많으므로 생략한다.

2. USB 디버깅 허용

개발자 옵션 - USB 디버깅 항목에 대해 on 을 해줘야 adb - 기기간 통신이 가능해진다.

기본

가장 많이 사용했던 명령어 및 상황을 정리해봤다.

디바이스 조회

1
> adb devices [-l]

위 명령은 adb 서버에 연결된 디바이스의 시리얼 넘버와 상태를 차례로 출력한다.

상태는 아래와 같은 케이스가 있다.

  • device : 정상적으로 연결되어 있다. 하지만 시스템이 부팅되는동안 adb 에 연결이 되기 때문에 시스템 부팅이 완료되었다고 보장하는 상태는 아니다.
  • no device : 연결되어있는 디바이스가 없다.
  • offline : 기기가 adb 에 연결되어 있지 않다. usb 선에 문제가 있는지 살펴보자.
  • unauthorized : USB 디버깅이 허용되지 않은 기기이다.

특정 디바이스에 명령을 해야할 때

연결되어있는 디바이스가 1개라면 별도 옵션 추가 없이 adb 명령어를 사용할 수 있다. 그러나 연결된 디바이스가 2개이상일 경우, 이전에 사용한 명령어를 입력하면 어느 기기를 기준으로 adb 명령이 수행되어야 하는 알 수 없기 때문에 아래와 같은 에러가 발생한다.

“more than one device/emulator”

옵션과 시리얼 넘버를 이용하여 해결이 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 연결된 디바이스 정보 확인
> adb devices
List of devices attached
SM1234 device
emulator-1234 device

// -s 옵션, 특정 디바이스 를 지정한 후 명령어 수행
> adb -s SM1234 shell $command

// -d 옵션, 연결된 하드웨어 디바이스가 1개라면 자동 지정 후 명령어 수행
> adb -d shell $command

// -e 옵션, 연결된 에뮬레이터 디바이스가 1개라면 자동 지정 후 명령어 수행
> adb -e shell $command

adb 초기화

1
> adb kill-server

adb 서버 프로세를 kill 한다. adb 가 먹통일 때 주로 사용한다. 어떤 adb 명령어든 다시 입력하면 서버가 재시작한다.

기기에/기기에서 파일 복사

1
2
3
4
5
// 디바이스에서 개발 머신으로 파일 복사
> adb pull $devicePath $macihnePath

// 개발 머신에서 디바이스로 파일 복사
> adb push $machinePath $devicePath

디바이스의 host 파일을 변경할 때 유용하게 활용했다.

dumpsys

adb 는 shell 명령어로 직접 기기를 제어하거나 기기정보를 가져올 수 있다. dumpsys 명령은 연결된 기기의 시스템 서비스 정보를 가져올 수 있다. 가장 기본적인 명령어를 입력하면 너무 방대한 정보가 한꺼번에 출력되기 때문에 다양한 옵션과 grep 을 적절히 활용하여 필요한 정보만 꺼내보는 지혜가 필요하다. 직접 활용하고 있는 몇가지 케이스에 대해서만 다룰예정이다.

Acitivty stack 출력하기

Activity stack 이 깊고 복잡할수록 디버깅이 힘들어진다. adb 의 존재를 알기 전에는 스택의 구성을 알아내기 위해 하나하나 백버튼을 눌러봤던 기억이 있다.

activity stack 을 볼 수 있는 기본 명령은 다음과 같다.

1
> adb shell dumpsys activity activities

그런데 위 명령은 현재 디바이스에서 활성화되어있는 모든 앱에대한 Acitivty 정보를 가져오기 때문에 필요한 정보를 금방 파악하기 어렵다.

우리가 알고 싶은 것은 특정 앱에 대한 Activity stack 이므로 필터링이 필요하다.

1
> adb shell dumpsys activity activities | grep -i $packageName | grep -i Hist

명령어의 결과는 다음과 같다.

1
2
* Hist #1: ActivityRecord{692b031 u0 com.android.settings/.SubSettings t630}
* Hist #0: ActivityRecord{8e296f3 u0 com.android.settings/.homepage.SettingsHomepageActivity t630}

Hist (History) 의 숫자가 낮을수록 stack 에 먼저 추가된 Activity 이다. Hist 숫자가 가장 큰 Activity 가 현재 화면상에 나타나는 Activity 이므로, 현재 화면의 Activity 이름을 찾을때도 유용하게 활용할 수 있다.

Doze, 앱 대기 모드 테스트

간혹 Doze 모드, 앱 대기 모드로 인한 이슈를 확인해야할 상황이 생긴다. 문제는 각 조건을 충족하여 모드를 재현하는 것이 꽤나 수고스러운 일이라는 점이다. 이를 대비하여 adb 에서는 각 모드로 진입할 수 있는 명령어를 제공한다.

Doze 모드 테스트

1
2
3
4
5
6
> adb shell dumpsys deviceidle force-idle // Doze 모드 돌입

// Doze 모드 테스트...

> adb shell dumpsys deviceidle unforce // Doze 모드 해제
> adb shell dumpsys battery reset // 배터리 상태 리셋

앱 대기 모드 테스트

1
2
3
4
5
6
7
// 앱 대기모드 활성화
> adb shell dumpsys battery unplug
> adb shell am set-inactive $packageName true

// 앱 대기모드 해제
> adb shell am set-inactive $packageName false
> adb shell am get-inactive $packageName

am (activity manager)

am 은 각종 컴포넌트 수행, 화면 속성 수정 등 다양한 시스템 작업을 수행하는 명령어이다.

Activity 시작

am start 명령은 지정된 intent 정보를 기반으로 activity 를 실행한다. 옵션을 이용하여 인텐트 구성에 필요한 정보를 추가할 수 있다. IntentSpec

앱 내 Activity 로드

1
> adb shell am start -n $packageName/$activityName

-n 옵션은 패키지 정보를 기반으로 명시적 인텐트를 생성한다. 이때 activityName 은 클래스 이름이 아닌 manifest 에 등록된 android:name 값 이다. 또한 위 명령어로 실행할 수 있는 Activity 는 android:exported 옵션이 true 인 Activity 만 가능하다.
아마 보안상 대부분의 Activity 는 exported 가 false 일 것 이므로 유용한 상황은 많이 없다. Intent filter 가 걸려있는 Activity 는 exported 를 true 로 유지해야 하므로 실행이 가능하나 intent filter 특성상 추가로 데이터를 넘기지 않으면 원하는 동작을 확인하기는 어려울 것이다.

커스텀 스킴 테스트

1
> adb shell am start -a android.intent.action.VIEW $customScheme://$path

-a 옵션은 Intent 의 Action 을 지정한다. 커스텀 스킴을 지정한 인텐트 필터를 설정 후 테스트 할때 유용하다.

그외..

자주 활용하는 명령어를 위주로 정리 해봤다. 이 외에도 adb 로 할 수 있는 일은 무궁무진하다. 다만 모든 명령어를 다 외우는 것은 큰 의미가 없고, 어떠한 제어가 가능한지 인지하고 있다가 필요할 때 찾아서 쓰고 정리하면 충분하다고 생각된다.


제네릭을 활용할 일이 많은데 겉핥기 식으로만 알고 있다는 생각이 들어 이번 기회에 관련 내용들을 정리했다.

무엇인가?

제네릭은 클래스, 인터페이스 및 메서드를 정의할 때 내부에서 사용될 type 을 parameter 로 전달할 수 있는 개념이다.

간단히 아래와 같이 표현이 가능하다.

1
2
3
4
5
6
7
public class GenericsClass <T> { }
public interface GenericsInterface <T> { }
public class MultiGenericsClass <K, V> { } // 여러개도 가능하다!

public <T> void getResult(T parameter) {
// do something with parameter
}

위 예시에서 GenericsClass, GenericsInterface 처럼 type parameter 를 전달받는 클래스 / 인터페이스를 Parameterized Types 라고 칭하기도 한다.

왜 사용하는가?

사용 시 장점

  • 컴파일 타임에 타입 체크 가능
  • 캐스팅 불필요
  • 특정 타입에 종속되지 않은 유연한 로직

사용 시 단점

단점이 있다면 코드의 가독성이 떨어지게 된다. 사실 이건 제네릭만의 단점이라기보다 보통 유연하고 느슨한 코드일 수록 가독성이 떨어지게 되는데 일종의 트레이드 오프라고 볼 수 있다.

Subtyping

자바는 is - a 관계일 경우 아래 코드가 문제없이 작동한다.

1
2
3
Object newData = new Object();
Integer newNumber = Integer.valueOf(1);
newData = newNumber;

제네릭 콜렉션에 값을 추가할 때도 마찬가지이다.

1
2
3
List<Object> objects = new ArrayList<>();
object.add(1);
object.add("name");

그러나 아래 코드는 에러가 발생한다.

1
2
3
List<Object> objects = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();
objects = numbers; // compile time error

List<Integer> 는 List<Object> 의 서브타입이 아니기 때문이다. 이를 Invariant (무공변성, 불변성) 하다고 이야기 한다. 이에 대해서는 Type Bound 파트에서 좀 더 자세히 다뤄보자.

Parameterized Type 이 기본적으로 불변성을 가지는 이유는 타입 안정성을 컴파일 타임에 보장하기 위해서이다. 만약 Parameterized Type 에 subtyping 이 허용된다고 가정해보자. 그렇게 되면 어떤 타입의 파라미터가 넘어올지 컴파일 타임에 미리 아는 것이 불가능하고, 예외 발생을 막기위해 지속적으로 타입 체크가 필요하다. 즉, 제네릭의 장점을 전혀 취할 수 없게 된다. 따라서 타입 안정성을 보장하기 위해서 Parameterized Type 은 기본적으로 Invariant 하다.

Type Bound 와 Variance

Computer Science 에는 Variance 라는 개념이 있다.

Variance refers to how subtyping between more complex types relates to subtyping between their components.

쉽게 얘기하면 element 와 element 를 포함하고 있는 컴포넌트가 있을 때 element 간의 sub type 관계가 컴포넌트간의 sub type 관계에 영향을 주는 정도를 의미한다.

영향도에 따라 다음과 같이 나눌 수 있다.

  • Invariant (무공변성) : T → T’ 일 때 <T>, <T’> 는 서로 별개의 타입이면 <T> 는 Invariant 하다.
  • Covariant (공변성) : T→ T’ 일 때 <T> → <T’> 가 성립하면 <T> 는 covariant 하다.
  • Contravariant (반공변성) : T → T’ 일 때 <T> ← <T’> 가 성립하면 <T> 는 contravariant 하다.

T 는 element, <T> 는 element를 포함하는 컴포넌트를 의미하며 A → B 이면 A 는 B 의 서브 타입이다. 이때 서브 타입은 자바의 상속만이 아니라 더 넓은 개념인 Subtyping 을 의미한다.

제네릭은 Type bound 를 적용하여 parameterized type 의 Variance 를 결정할 수 있다. 화살표 방향에 유의하며 하나씩 살펴보자.

1
2
3
4
// 예시 클래스
class Animal {}
class Cat extends Animal {}
class KoreanShortHair extends Cat {}

1. Unbounded type

<T>, <?>

  • 제네릭 타입 선언 후 아무런 키워드도 붙히지 않으면 unbounded type 이다.
  • Unbounded type 타입 파라미터로 선언된 Parameterized type 은 모든 타입 파라미터에 대해 Invariant 하다.
  • Cat → Animal 일때 <Cat> 와 <? extends Animal> 는 서로 아무런 연관관계를 가지지 않는 각각 별개의 타입이다.
1
2
3
4
List<Animal> animals = new ArrayList<>(); // unbounded type parameter
List<Cat> cats = new ArrayList<>();

animals = cats // Fail, 서로 다른 타입으로 판단한다.

2. Upper bounded type

<T extends Animal>, <? extends Animal>

  • Upper bounded type 은 제네릭 타입 선언 후 extends 키워드를 이용하여 구현가능하다.
  • Upper bounded type 타입 파라미터로 선언된 Parameterized type 는 자기 자신 혹은 하위 클래스의 타입 파라미터에 대해 Covariant 하다.
  • 예를 들어 Cat → Animal 이면 <Cat> → <? extends Animal> 이다.
1
2
3
4
5
6
List<? extends Animal> animals = new ArrayList<>(); // upper bounded parameterized type
List<Cat> cats = new ArrayList<>();
List<KoreanShortHair> koreanShortHairs = new ArrayList<>();

animals = cats; // Ok, cats 는 animals 의 서브타입이다.
animals = koreanShortHairs; // Ok, koreanShortHairs 는 animals 의 서브타입이다.

3. Lower bounded type

<T super Cat>, <? super Cat>

  • Lower bounded type 은 제네릭 타입 선언 후 super 키워드를 붙히고 원하는 타입을 적으면된다.
  • Lower bounded type 타입 파라미터로 선언된 Parameterized type 는 자기 자신 혹은 상위 클래스의 타입 파라미터에 대해 Contravariant 하다.
  • 예를 들어 Cat → Animal 이면 <Animal> → <? super Cat> 이다.
1
2
3
4
5
6
List<Animal> animals = new ArrayList<>();
List<? super Cat> superCats = new ArrayList<>(); // lower bounded parameterized type
List<KoreanShortHair> koreanShortHairs = new ArrayList<>();

cats = animals; // Ok, animals 는 superCats 의 서브 타입이다.
cats = koreanShortHairs; // Fail, KoreanShortHair 는 Cat 의 상위클래스가 아니다.

Multi bound

Upper bounded type 에 한하여 N 개의 bound 적용이 가능하다. 이때 사용되는 연산자는 & 이다. 특징은 다음과 같다.

  • 자바는 다중 상속이 허용되지 않는다. 따라서 부모 클래스가 2개 이상일 수 없으므로 Multi bound 에서 bound 할 수 있는 클래스 타입은 0 ~ 1개 이다.
  • 위와 같은 맥락으로 인터페이스 타입은 bound 개수 제한이 없다.
  • 클래스 타입과 인터페이스 타입을 같이 사용할 경우 클래스 타입이 제일 앞에 선언되어야 한다.
1
2
3
4
5
6
7
8
9
class A {}
interface B {}
interface C {}

class Test <T extends A & B & C>

public <T extends Number & Comparable<? super T>> int compareNumbers(T t1, T t2){
return t1.compareTo(t2);
}

Test 클래스의 타입 파라미터 T 는 A 의 서브타입이고, B 와 C 를 구현해야한다.

compareNumbers 메소드의 타입 파라미터 T 는 Number 의 서브타입이고, Comparable 를 구현해야한다.

어디에 활용할 수 있을까? (PECS)

Type bound 를 활용하면 기본 제네릭에서 한번 더 확장이 가능하므로 좀 더 유연한 코드가 될 수 있다. 하지만 어떤 케이스에서 활용해야할지 감이 잘 오지 않는다. 이를 위하여 PECS 라는 규칙이 나오게 되었다.

PECS 란 Producer - extends, Consumer - super 의 약자이다. 생산자는 extends 를 소비자는 super 키워드를 활용하라는 의미이다.

생산자, 소비자라는 표현이 직관적으로 와닿지 않으므로 다음과 같이 이해해도 좋다.

Read - extends, Write - super

왜 그런지 한번 살펴보자.

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
// extends

public void read(List<? extends Animal> animals) {
for (Animal animal : animals) {
print(animal.toString()); // OK
}
}

public void write(List<? extends Animal> animals) {
animals.add(new Animal()); // compile time error
animals.add(new Cat()); // compile time error
}

// super

public void read(List<? super Cat> cats) {
for (Cat cat : cats) {
print(cat.toString()); // compile time error
}
}

public void write(List<? super Cat> cats) {
cat.add(new Animal()); // compile time error
cat.add(new KoreanShortHair()); // OK
}

Read - extends

  • read 메소드를 살펴보면 animals 의 각 element 를 Animal 타입으로 읽는 것은 문제 되지 않는다. 왜냐하면 어떠한 List 가 들어오더라도 내부 element 는 최소한 Animal 을 상속받았을 것이 보장되기 때문이다.
  • 한편 write 메소드를 살펴보면 add 를 시도할 경우 자신을 포함한 Animal 의 하위 타입을 전달했을 때 오류가 발생한다. 이유는 전달된 animals 의 element 는 animal 을 상속받은 것만 보장할뿐 구체적으로 어떤 타입인지는 알 수 없기 때문이다. 이를 허용할 경우 런타임에 타입 불일치로 오류가 발생할 수 있는 가능성이 생길 수 있다. (ex / List<Cat> 을 전달받았는데 Animal 객체를 추가하는 경우)

따라서 상방으로 닫혀있는 extends 는 read 에 적합하다.

Write - super

  • write 메소드를 먼저 살펴보면 cats 는 cat 의 하위타입에 한하여 어떠한 객체든 추가가 가능하다. cats 의 element 는 Cat 의 상위 타입인 것이 보장되기 때문이다. ( ? → Cat → ? super Cat )
  • read 메소드를 살펴보면 이제 슬슬 감이 온다. cats 의 element 는 Cat 의 상위 타입이므로 항상 Cat 자신이라고 보장할 수 없기 때문에 Cat 이라는 타입으로 확정지어 사용할 수 없다.

만약, read & write 를 모두 하는 케이스의 경우 type bound 를 사용할 수 없다!

Wildcard

Type bound 파트에서 먼저 언급이 되었는데, <?> 와 같이 물음표를 이용하여 제네릭 타입을 표현하는 것을 와일드 카드라고 부른다. 와일드 카드는 다음과 같은 특징이 있다.

  • 어떤 타입이든 가리지 않고 전달이 가능하다.

  • 제네릭 클래스의 파라미터, 제네릭 메소드의 인자 자체로는 사용이 불가능하다.

    1
    2
    3
    class TestClass <?> { } // 불가능

    public void invokeTest(<?> parameter) { } // 불가능

Type bound 에 따라 불리는 명칭이 다르다.

  • <?> : unbounded wildcard
  • <? extends Animal> : upper bounded wildcard
  • <? super Animal> : lower bounded wildcard

upper bounded wildcard, lower bounded wildcard 에 대한 얘기는 Type bound 파트에서 이미 대부분 다뤘으므로 unbounded wildcard 에 대해서만 잠깐 정리해보자.

<?> : unbounded wildcard

unbounded wildcards 는 제네릭 계의 Object 클래스 이다. 따라서 실제 특징도 Object 와 비슷하다.

  • ? 를 Object 로 바꿔서 사용하는 것이 가능하다.

    → unbounded wildcard type 은 컴파일 과정에서 Object 타입으로 판단하기 때문에 코드상에서 Object 로 취급하여도 오류가 발생하지 않는다. (후술할 reifiable type 이기도 하다.)

  • <?> 는 Covariant 하다.

    → 모든 T에 대하여 T → ?, <T> → <?> 가 성립한다.

    <사진>

이러한 특징을 고려하여 활용할 수 있는 케이스는 크게 2가지가 있다.

1. Object 클래스가 제공하는 기능을 사용하여 구현 해야할 때

Object 타입 기반의 메소드가 있다고 가정하자.

1
2
3
4
5
public void parseString(List<Object> objects) {
for (Object object : objects) {
print(object.toString());
}
}

해당 메소드는 Object 타입 List 를 전달받으므로 범용성이 클 것 같지만 위에서 다룬 Variance 룰에 의하여 List<Integer>, List<String> 과 같은 Object 하위 타입 리스트를 전달할 수 없다. 이때 unbounded wildcard 를 이용하면 해결할 수 있다.

1
2
3
4
5
public void parseString(List<?> objects) {
for (Object object : objects) {
print(object.toString());
}
}

2. 타입에 의존하지 않는 로직을 수행할 때

List 의 size(), clear() 와 같이 타입이 무엇이든 상관없이 수행이 가능한 메소드들이 있다. 이럴 때 unbounded wildcard 가 사용된 parameterized type 을 사용하면 유연한 코드 작성이 가능하다.

Type erasure

자바 컴파일러는 컴파일 과정에서 제네릭에 대해 타입 소거(Type erasure)를 진행한다. 타입 소거란 타입정보를 컴파일 타임에만 유지하고, 런타임에는 삭제시켜 버리는 것인데 과거 제네릭이 없던 버전과의 하위 호환성을 위해서이다.

타입소거가 이루어진 클래스는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 타입소거 전
class GenericClass <T> {

public void consume(T paramenter) {
paramenter.toString();
}
}

// 타입소거 후
class GenericClass {

public void consume(Object paramenter) {
paramenter.toString();
}
}

컴파일러는 Unbound type 에 대하여 Object 로 바꿔버렸다. 따라서 런타임에는 해당 타입이 본래 어떤 타입이었는지 알 수 없다. 타입 소거에 대한 동작은 다음과 같다.

  • Unbound type 의 경우 Object, bound type 의 경우 bound 값을 기준으로 타입을 바꾼다.
  • 필요에 따라 캐스팅 및 bridge 메소드가 추가될 수 도 있다.

Non-Reifiable Type

런타임에 타입 정보의 유무에 따라 Reifialbe type, Non - Reifialbe type 으로 구분한다.

  • Refiable type : 런타임에 타입에 대한 정보를 가지고 있다. 대표적으로 primitive, non - generic, unbounded wildcards (<?>) 등 이 있다.
  • Non - Reifiable type : 런타임에 타입에 대한 정보가 없다. 대표적으로 type erasure 가 진행되는 대부분의 generic parameterized type 이 있다.

제네릭이 가지는 한계점은 대부분 이 타입 소거라는 특징 때문에 발생할 만큼 기억해둬야할 원리다. 타입 소거로 인한 이슈를 피하기 위한 기법 super type token 이 나오기도 했다.

제네릭의 한계점

원시값을 제네릭 타입으로 사용할 수 없다

1
2
List<int> numbers = new ArrayList<>(); // compile time error
List<Integer> numbers = new ArrayList<>(); // Ok

원시값을 사용하기 위해서는 Wrapper class 를 사용해야한다. 허용되지 않는 이유가 타입소거 때문인줄 알았는데 컴파일러 구현 이슈 때문이라는 의견이 있었다. 이 부분은 좀 더 정확한 정보를 찾게 되면 다시 업데이트가 필요하다.

제네릭 타입의 인스턴스를 생성할 수 없다

1
2
3
4
public <E> E createInstance() {
E instnace = new E(); // compile time error
return E;
}

이유는 타입소거로 인하여 생성해야할 타입이 무엇인지 알 수 없기 때문이다. 꼭 생성이 필요할 경우 리플렉션을 이용하면 가능하다.

1
2
3
4
public <E> E createInstance(Class<E> clazz) {
E instnace = clazz.newInstance(); // OK
return E;
}

Parameterized type 을 instanceOf 로 비교할 수 없다

1
2
3
4
5
List<Integer> numberes = new ArrayList<>();

if (numberes instanceof ArrayList<Integer>) { // compile time error
// ...
}

타입소거의 특징으로 인해 위 코드는 컴파일 되지 않는다. 본래의 List 가 어떤 타입을 담고 있었는지 알 수 없으므로 위와 같은 코드가 무의미해지는 것이다. 예를들어 JVM 은 런타임에 ArrayList<String>, ArrayList<Integer> 를 구분하지 못한다. 하지만 방법이 아주 없는 것은 아니다. 제네릭 타입 중에 유일하게 타입소거가 되지않는 unbounded wildcard <?> 를 이용하면 된다.

1
2
3
4
5
List<Integer> numberes = new ArrayList<>();

if (numberes instanceof ArrayList<?>) { // Ok
//...
}

Parameterized type array 를 만들 수 없다

1
List<String>[] arrayOfTexts = new List<String>[10]; // compile time error

위 코드는 컴파일되지 않는다. 만약 Array 를 만드는 것이 허용된다고 가정했을 때 어떤 문제가 있을지 살펴보자.

1
2
3
4
5
List<Integer>[] numbers = new List<Integer>[10]; 
Object[] objects = numbers;

objects[0] = new ArrayList<Integer>(); // OK
objects[1] = new ArrayList<String>(); // Exception 을 던져야하지만, 런타임에 인지할 수 없음.

배열 초기화 과정부터 이상함을 느꼈을 수 있다. Array 는 Collection 과 다르게 기본적으로 Convariant 하다. 즉, A → B 이면 A[ ] → B[ ] 가 성립하기에 objects 를 numbers 로 초기화 하는 것이 가능하다!

그 후에 순차적으로 List<Integer>, List<String> 를 각 배열원소에 초기화하는데, Array 는 런타임에 일치하지 않는 타입이 들어오면 ArrayStoreException 이 발생하므로 List<Integer> 로 초기화 할 때 예외가 발생해야한다. 하지만 타입소거에 의해 각 List 는 런타임에 구별되지 않으므로 예외가 발생하지 않고 초기화가 진행될 것이다. 즉, 타입이 구별되지 않아 배열의 기본동작 규칙이 깨지게 되는 것이다.
이러한 이유로 인해 Parameterized type array 생성은 허용되지 않는다.

type parameter 의 구분으로 메소드를 overload 할 수 없다

1
2
3
4
class Test {
public void execute(List<Integer> numbers) { }
public void execute(List<String> texts) { }
}

자바로 처음 개발을 하면 제법 많이 겪어봤을 케이스이다. 위 코드에서 execute 메소드는 오버로딩 될 수 없다. 타입 소거 과정이 끝나면 모두 List 타입으로 변경되어 똑같은 메소드 시그니쳐를 가지게 되기 때문이다.

Naming

제네릭이 선언된 대부분의 클래스들이 특정 알파벳만 사용되기 때문에 이것이 문법적으로 강제되는 부분이라고 착각할 수 있지만 실제로는 어떠한 이름을 지어도 상관이 없다.

1
public class MyClass <SUPERGENERIC> { } // 이렇게 지어도 잘돌아간다.

다만 표준으로 사용을 권장하는 알파벳들이 있을 뿐이다. 협업의 관점에서 중요한 포인트 이므로 특별한 이유가 없다면 표준을 따르는 것이 좋다. 가이드

  • 안드로이드의 Adapater 클래스의 시그니쳐를 보면 제네릭의 의미를 강조하기 위해 표준 표기가 아닌 이름을 사용하기도 했다.
1
public abstract static class Adapter<VH extends ViewHolder>

Reference

https://docs.oracle.com/javase/tutorial/java/generics/
https://dzone.com/articles/5-things-you-should-know-about-java-generics

https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)

https://en.wikipedia.org/wiki/Subtyping

https://jojoldu.tistory.com/25

IoC, DIP, DI 는 항상 혼동되는 개념이다. 각 개념을 서로 같다고 표현하는 글들도 제법 있고, 각 개념의 정의를 살펴보니 직관적으로 이해가 되지 않았다. 요즘 특히 DI 에 대한 내용이 많이 언급되고 있다고 느끼는데 정작 주장하는 문맥이 조금씩 다르다보니 스스로 답답함을 느꼈다. 제대로된 논의를 하기 위해서는 올바른 개념 정리가 필요하다고 생각되어 각 개념에 대한 정보를 모으고 정리했다.

무엇이 다를까?

To be sure, using DI or IoC with DIP tends to be more expressive, powerful and domain-aligned, but they are about different dimensions, or forces, in an overall problem. DI is about wiring, IoC is about direction, and DIP is about shape.

결론부터 얘기하면 IoC, DIP, DI 는 모두 다른 개념이다. 각자 목적과 요구하는 바가 다르다. 하지만 서로를 배척하는 개념은 아니다. 오히려 함께 했을 때 강한 시너지가 생긴다.

Inversion of Control (IoC, 제어의 역전)

don’t call me, I’ll call you. - Hollywood principle

개인적으로 IoC 를 가장 직관적으로 잘 설명한 문장이라고 생각한다.

좀 더 개발 친화적인 용어로 풀어서 설명하면 다음과 같이 표현할 수 있다.

IoC 란 코드의 흐름을 제어하는 주체가 바뀌는 것이다.

코드의 흐름을 제어한다는 것은 여러 행위를 포함한다. 오브젝트를 생성하는 것, 오브젝트의 생명주기를 관리하는 것, 메소드를 수행하는 것 등. 그리고 일반적인 프로그램은 이러한 행위를 하나부터 열까지 모두 스스로 수행한다. (우리가 처음 만들었던 프로그램을 잘 생각해보자.) IoC 를 적용한다는 것은 이러한 흐름 제어를 또다른 제 3자가 수행한다는 것을 의미한다.

안드로이드에서도 IoC 가 적용된 케이스를 볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyActivity extends AppCompatActivity {

@Override
protected void onResume() {
super.onResume();
// do something
}

@Override
protected void onPause() {
// do something
super.onPause();
}
}

우리가 Activity 코드를 작성할 때 생명주기 메소드가 호출되었을 때의 동작만 정의하고, 언제 생명주기 메소드를 호출 할지는 신경쓰지 않는다. 즉, Activity 의 메인 흐름 제어권은 나의 코드가 아니라 안드로이드 플랫폼에서 쥐고 있다.

누가 물어봤을 때 명확한 답변을 못했던 ‘프레임 워크와 라이브러리의 차이는 무엇인가?’ 에 대해 IoC 관점으로 설명이 가능하다. 라이브러리는 내 코드가 라이브러리를 이용한다. 즉, 제어권이 내 코드에 있다. 반면 프레임 워크는 프레임 워크가 나의 코드를 실행한다. 즉, 제어권은 프레임워크에게 있다.

Software frameworks, callbacks, schedulers, event loops, dependency injection, and the template method are examples of design patterns that follow the inversion of control principle

IoC 를 따르는 개념이 생각보다 많았다.

Dependency Inversion principle (DIP, 의존관계 역전 법칙)

a. High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g. interfaces).
b. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

SOLID 원칙 중 하나이다. 의존관계에 대해 다루고 있는데 한번에 바로 이해될 수 있는 설명이 아니었다. 관련된 내용을 찾아보다가 좀 더 직관적으로 표현된 문장을 발견했다.

DIP is about the level of the abstraction in the messages sent from your code to the thing it is calling

DIP 가 주장하는 바의 핵심은 추상화에 의존하라는 것이다.

추상화가 아닌 구체클래스에 의존한 경우를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class FileLoader {
private TextFileParser textFileParser;

public FileLoader() {
// TextFile 이 아닌 csv 파일을 파싱해야할 경우 필연적으로 코드의 변경이 발생
this.textFileParser = new TextFileParser();
}

public File parseFile(String serializedFile) {
return textFileParser.parse(serializedFile);
}
}

위와 같이 TextFileParser 에 변경이 발생했을 때 이를 의존하는 FileLoader 역시 변경이 발생하게 된다. 또한 FileLoader 는 TextFile 외에 다른 File 을 파싱하기 위해서는 아예 Parser 클래스를 변경해야한다. 변경에 유연하지 않은 구조이다.

추상화에 의존할 경우를 살펴보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface FileParser {
File parse(String serializedFile);
}

public class TextFileParser implements FileParser {

@override
public File parse(String serializedFile) {
// Do something
return File();
}
}

public class FileLoader {
private FileParser fileParser;

public FileLoader(FileParser fileParser) {
this.fileParser = fileParser;
}

public File parseFile(String serializedFile) {
return fileParser.parse(serializedFile);
}
}

FileLoader 는 FileParser 인터페이스에 의존하기에 FileParser 의 구현체인 TextFileParser 변경이 발생하더라도 영향을 받지 않는다. 또한 FileParser 인터페이스를 구현한 구현체라면 무엇이든 FileLoader 에서 이용이 가능하다. 즉, 다형성을 활용하여 변경에 유연한 구조가 된다.

Dependency Injection (DI, 의존성 주입)

DI is about how one object acquires a dependency

DI 는 필요로 하는 오브젝트를 스스로 생성하는 것이 아닌 외부로 부터 주입받는 기법을 의미한다. 마틴 파울러의 글에 따르면 3가지 타입으로 정의할 수 있다.

Constructor Injection

생성자를 통해 주입하는 방식이다. 인스턴스가 생성되었을 때 의존성이 존재하는 것이 보장되기 때문에 의존성의 존재여부가 보장되고 의존성을 immutable 하게 정의할 수 있다. 스프링에서도 해당 방식을 권장하는 것으로 알고 있다.

1
2
3
4
5
6
7
public class FileLoader {
private FileParser fileParser;

public FIilLoader(FileParser fileParser) {
this.fileParser = fileParser;
}
}

Setter Injection

Setter 메소드를 이용하여 주입하는 방식이다. 해당 방식은 Construcor Injection 보다 좀 더 주의를 요한다. 주입 받는 의존성의 기본값을 정의할 수 있지 않다면 null 값이 존재할 수 있는 이슈가 있기 때문이다. 의존성이 다시 주입되어야할 경우 유용하게 사용된다고 하나 나는 아직 그러한 상황을 겪지 못했고 모두 Construcor Injection 으로 해결할 수 있었다.

1
2
3
4
5
6
7
public class FileLoader {
private FileParser fileParser;

public void setFIilLoader(FileParser fileParser) {
this.fileParser = fileParser;
}
}

Interface Injection

Interface 로 주입받는 메소드를 정의한다. 이 방식은 이번에 조사하면서 처음 알게 되었고 실제로 사용해본 적이 없어 자세히 적기는 조심스럽다. 예시를 봐도 이점이 명확하게 보이지 않아 좀 더 공부를 하고 내용을 채워보려 한다.

각 개념 간의 관계

IoC 와 DI

종종 IoC 와 Dependency Injection 은 서로 interchangeably 한 것, 더 나아가 아예 같은 것처럼 표현하는 글이 보이곤 하는데 이는 잘못된 해석이라고 생각한다. Dependency Injection 은 IoC 개념이 적용된 결과물 중 하나일 뿐이다. 의존성을 주입한다는 것을 IoC 적인 행위로 바라볼 수 는 있지만 IoC 가 곧 의존성 주입이라고 보기 는 어렵기 때문이다.

DIP 와 DI

단어가 비슷해보이는 DIP 와 DI 역시 같은 개념으로 오해하기 쉽다. 하지만 마찬가지로 DI 는 DIP 를 구현하는 기법중 하나일 뿐 서로 같은 개념이 아니다. 위 DIP 예제 코드에서도 DI 가 이용되었다.

DIP 에 대한 이해가 부족했을 때, 아래와 같은 코드도 DIP 를 만족하는 것이라고 생각했었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface FileParser {
File parse(String serializedFile);
}

public class TextFileParser implements FileParser {

@override
public File parse(String serializedFile) {
// Do something
return File();
}
}

public class FileLoader {
private FileParser fileParser;

public FileLoader() {
this.fileParser = new TextFileParser();
}

public File parseFile(String serializedFile) {
return fileParser.parse(serializedFile);
}
}

하지만 해당 코드에서는 FileParser 를 다른 구현체로 바꿀 수 없다. 사실상 타입만 인터페이스로 했을 뿐 다형성의 이점을 전혀 살리지 못하는 코드이며 DIP 를 만족한다고 보기 어렵다.

또 다른 예시를 살펴보자. 이 코드도 조금 아쉬운 점이 있다.

1
2
3
4
5
6
7
8
9
10
11
public class FileLoader {
private TextFileParser textFileParser;

public FileLoader(TextFileParser textFileParser) {
this.textFileParser = textFileParser;
}

public File parseFile(String serializedFile) {
return textFileParser.parse(serializedFile);
}
}

해당 코드는 TextFileParser 를 주입 받으므로 DI 가 이루어졌다고 볼 수 있다. 따라서 TextFileParser 의 생성자에 변경이 생기더라도 FileLoader 에 전파되지 않는 것은 긍정적인 부분이다. 하지만 DIP 는 지켜지지 않았다. 구체 클래스에 의존하고 있으므로 다른 FileParser 로 교체하는 것은 불가능하며 TextFileParser 의 변경에 FileLoader 가 영향을 받게 된다.

위 예시들이 시사하는 바는 DIP 와 DI 는 서로 조합되었을 때 시너지를 발휘한다는 것이다. 그래서 보통 한쪽 개념의 예시를 들 때 다른 쪽 개념이 같이 적용되어 있기 때문에 두 개념을 같다고 이해 할 법도 하다.

개념의 본질을 이해, 착각하지 말기

이러한 개념들을 정리하고 나면 내가 좀 더 개발을 잘하게 되었다는 착각에 종종 빠지곤 한다. 개념은 개념일 뿐이다. 커뮤니케이션 과정에서 리소스를 줄여주고 코드 그것이 곧 개발력과는 연결되지 않는 다고 본다. 중요한 것은 각 개념 속에서 추구하는 본질이 무엇인지 깨닫는 것이다.

한편으로는 이러한 개념들에 대해 정확히 정리하고 이해하는 과정은 꼭 필요하다. 내가 당장 이러한 것들을 지키지 못하더라도 어떤것이 충족되지 않는지 인식하고, 고쳐나갈 수 있는 기준을 세울 수 있기 때문이다. 무엇을 모르는지 모르는 것과 무엇을 모르는지 아는 것의 차이는 매우 크다.

Reference

https://justhackem.wordpress.com/2016/05/14/inversion-of-control/
https://www.martinfowler.com/articles/injection.html

https://martinfowler.com/articles/dipInTheWild.html
https://dzone.com/articles/ioc-vs-di

https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#beans
https://www.codeproject.com/Articles/592372/Dependency-Injection-DI-vs-Inversion-of-Control-IO

https://medium.com/@ivorobioff/dependency-injection-vs-service-locator-2bb8484c2e20

MVVM 기반으로 프로젝트를 진행할 때 팀 내에서 가장 많은 논의가 오고 갔던 부분은 역시 ViewModel 이었다. 이번 글은 그 중 ViewModel 의 public 메소드에 대하여 적어보려 한다.

종류 및 구현 방식

ViewModel 은 보통 세 종류의 public 메소드를 제공한다.

  1. View 가 원하는 명령을 수행하기 위한 트리거형 메소드
  2. LiveData 등의 이벤트 옵저버 Getter
  3. 특정 값 Getter (되도록 지양!)

2개는 Getter 이므로 크게 논의할만한 부분이 없고, 첫 번째인 트리거형 메소드에 대해 다룰 것이다.

초기 구현 방식과 문제점

초기 MVVM 패턴을 적용하여 화면을 만들 때 다음과 같이 구현하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

...
mUserCategoryViewModel.loadUserCategory();
}

private void initializeClickListener() {
mDeleteAllButton.setOnClickListener(view -> mUserCategoryViewModel.deleteAllCategory());
}
}
1
2
3
4
5
6
7
8
9
class MyViewModel {

public void loadUserCategory() {
// Call API
}

public void deleteAllCategory() {
// Delete all
}

문제점

위 구현은 ViewModel 이 비즈니스 로직을 수행할 수 있는 메소드를 public 하게 제공하고 있고, 이를 View 가 직접 호출하고 있는 형태이다. 이는 곧 다음과 같은 문제점에 직면하게 된다.

  • View 가 비즈니스 로직 진행 과정을 알고 있어야 함을 의미한다.

    → 예를 들어 위 예제 코드에서 전체 삭제 버튼을 눌렀을 때, 전체 삭제 외에 추가적인 과정이 수행되야 하는 스펙이 추가 되는 것을 가정 해보자. 이는 곧 View 에서 신규 스펙과 관련된 viewModel 의 메소드를 추가로 호출해줘야 함을 의미한다. 즉, ViewModel 레벨의 변경사항이 View 에 영향을 끼친다. (기존의 deleteAllCategory 메소드에 신규 스펙의 내용을 구현하는 것은 SRP 규칙에 위배된다.)

  • 해당 화면의 컨텍스트를 모르는 개발자는 전체적인 흐름을 파악하기 위해 View, ViewModel 모두를 확인해야만 한다.

개선안

코드를 먼저 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

...
mUserCategoryViewModel.onEnterView();
}

private void initializeClickListener() {
mDeleteAllButton.setOnClickListener(view -> mUserCategoryViewModel.deleteAllCategory());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyViewModel {

public void onEnterView() {
loadUserCategory();
}

public void onClickDeleteAllButton() {
deleteAllCategory();
}

private void loadUserCategory() {
// Call API
}

private void deleteAllCategory() {
// Delete all
}
}

초기 구현 대비 바뀐 것은 2가지이다.

1. ViewModel 은 내부 비즈니스 로직을 직접 수행할 수 있는 메소드를 public 하게 제공하지 않는다.

기존 메소드의 접근제한자를 private 로 변경하였다. 이제 View 는 더이상 비즈니스 로직을 수행하는 메소드를 직접 호출할 수 없다.

2. ViewModel 의 public 메소드는 View 레벨의 특정한 이벤트를 지칭하는 네이밍을 가진다.

View 가 ViewModel 에게 명령할 트리거 메소드는 여전히 필요하기에 새로운 트리거 메소드를 생성하였다. 해당 메소드는 View 의 특정 이벤트가 발생 했음을 나타내는 네이밍을 가진다. 또한 기존의 비즈니스 로직 메소드를 내부에서 수행한다. 이러한 변경사항은 다음과 같은 이점을 가진다.

  • View 는 더이상 내부 비즈니스 로직의 수행 과정을 알 필요가 없다. 단지 View 이벤트가 발생했다고 ViewModel 에 전달 해주면 된다.
  • ViewModel 의 비즈니스 로직에 추가 스펙이 들어오더라도 View 에 전파되지 않는다.
  • 해당 화면의 컨텍스트를 모르는 개발자가 코드를 보더라도 비즈니스 로직이 한곳에 모여있으므로 파악이 용이하다.
    ex) 전체 삭제 버튼이 눌렸을 때 수행 되는 일이 궁금하다면 onClickDeleteAllButton 메소드의 구현 내용만 확인하면 된다!

위 변경사항을 통해 ViewModel 을 한단계 더 캡슐화하는 효과를 얻게 되었다.

효과

유저로 부터 리포트되는 이슈의 상당수는 ‘XXX 를 클릭했을 때 안돼요.’ 와 같이 특정 View 이벤트와 관련된 내용이 많다. 개선된 내용을 적용하고 난 후에는 해당 화면에 속한 ViewModel 의 트리거 메소드만 확인하면 바로 관련된 비즈니스 로직을 파악할 수 있기 때문에 개발 능률이 향상되는 효과를 얻었다.

더 변경하기 쉽고, 이해하기 쉬운 코드를 위한 여정은 계속된다. 🤟

공식 문서에서도 가이드 하는 것 처럼 이제 Android 아키텍처를 구성할 때 Repository 패턴은 기본적으로 사용 되고있다. 오늘은 Repository 패턴에 대한 간단한 정리와 사용하면서 내가 놓치고 있었던 점에 대하여 정리 해보려한다.

TL;DR

  • Repository 패턴의 궁극적인 목적은 결국 관심사의 분리다.
  • DataSource 외에 data 에 대한 분리도 필요하다.

Repository 패턴을 사용하는 이유

Repositories are classes or components that encapsulate the logic required to access data sources.

Repository 는 DataSource 를 캡슐화 한다. 이점은 다음과 같다.

  • 하나의 도메인을 표현하는데 필요한 DataSource 가 몇 개든 client 쪽에서는 이를 알 필요가 없다.
    • 따라서 DataSource 를 새롭게 추가하는 것도 부담이 없다.
  • DataSource 의 변경이 발생하더라도 repository 외부 layer 로 전파되지 않는다.
  • client 는 repository 인터페이스에 의존하기 때문에 테스트 하기 용이하다.

결국 repository 는 presentation layer 와 data layer 의 coupling 을 느슨하게 만들어준다.

구조

Andorid 진영에서는 아래 구조를 크게 벗어나지 않는다.

View → Presenter / ViewModel → Repository → DataSource (API, Local DB)

ViewModel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UserInformationViewModel {

...

public void onClickUserInfoButton(int userId) {
mUserRepository.getUser(userId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
user -> {
///
},
error -> {

}
);
}
}

Repository

1
2
3
4
5
6
7
8
public class UserRepository {

...

public Single<User> getUser(int userId) {
return userApi.getUser(userId);
}
}

놓치고 있었던 점

Repository 패턴 자체는 적용이 어렵지 않다. 하지만 많은 사람들이 놓치고 있는 부분이 있는데, 위 예시에서도 찾을 수 있다.

바로 Repository 가 DataSource 의 데이터를 그대로 전달해준다는 점이다.

위 예시를 다시한번 살펴보자. UserRepository 는 UserApi 로 부터 전달받은 User 를 별다른 처리없이 그대로 리턴하고 이를 presentation layer 에서 사용하고 있다.

이것은 다음과 같은 문제점을 가지고 있다.

  • back 단의 구현 이슈가 presentation layer 에 영향을 끼칠 수 있다.
  • User 는 서버 (혹은 로컬 DB) 의 데이터베이스 테이블을 표현하는 역할을 수행하는 객체일 뿐이다.

Data 스펙이 바뀌면 presentation layer 전반에 영향 끼치게 된다.

필드 삭제, 필드 이름 변경 등 서버가 데이터 구조를 변경하게 되면 이를 바로 참조하고 있는 다른 layer 에서도 필연적으로 변경이 발생하게 된다.

예를 들어 클라쪽에서는 User 정보를 표기해야 하는데 서버에서 각기 다른 데이터의 요청을 N 번 거쳐야 한다고 생각해보자. 그럼 N 개의 데이터에 대한 변경 사항은 모두 온전히 클라에 영향을 줄 것이다. 더 중요한 점은 이렇게 복잡한 데이터 모델을 혼재하여 사용하게 되면 다른 개발자가 컨텍스트를 이해하기 매우 어려워지기 시작한다.

서버 (혹은 로컬 DB) 의 데이터베이스 테이블을 표현하는 역할을 수행하는 객체일 뿐이다.

같은 도메인에 대하여 클라와 서버의 용어가 통일이 되는 것이 가장 이상적이겠지만 현실세계에서는 서로 다른 용어를 사용하는 경우가 허다하다. 그럴 경우 보통은 클라이언트 코드 베이스에서 라도 서로 통일이 이루어져야 한다. 그런데 서버 데이터 구조를 그대로 가져와 사용할 경우, 팀내 도메인 용어와 실제 코드 베이스의 용어가 달라지게 되고 커뮤니케이션 리소스가 급증하게 되는 상황이 발생한다.

또한 도메인의 비즈니스 로직 처리에 필요한 메소드를 생성할 시점이 왔을 때, (서버 테이블을 반영한) 객체에 추가하게 되면 해당 객체는 테이블도 표현하고, 도메인 로직도 처리하는 God object 가 될 확률이 높다.

한번쯤 만들어 봤을 법한 클래스..

1
2
3
4
5
6
7
8
9
10
11
12
13
public class User {

@SerializedName("id")
private String id;
@SerializedName("nickname")
private String nickname;
@SerializedName("grade")
private String grade;

public boolean isManager() {
return Grade.MANAGER == Grade.find(grade);
}
}

Repository 패턴을 사용하는 목적은 data layer 와의 coupling 을 느슨하게 하는 것인데, 다시 강한 결합이 되어버린 꼴이 되었다.

Mapper 를 활용하자

이러한 문제점을 해결하기 위하여 Mapper 를 사용할 수 있다. Mapper 란 말 그대로 테이블 객체 ↔ 도메인 모델 객체간의 mapping 을 시켜주는 유틸성 클래스를 의미한다. repository 내에서 mapper 를 활용하여 테이블 객체가 아닌 도메인 모델로 전달을 해주면 presentation layer 는 data layer 로 부터 진정한 자유를 찾을 수 있게 된다.
Repository

1
2
3
4
5
6
7
8
public class UserRepository {

...

public Single<UserDomain> getUser(int userId) {
return userApi.getUser(userId).map(UserMapper::fromTable);
}
}

Mapper

1
2
3
4
5
6
public class UserMapper {

public static UserDomain fromTable(User user) {
return new UserDomain(user.id, user.name, user.grade);
}
}

만약 모든 모델에 대하여 보일러 플레이트 코드 처럼 Mapper 클래스를 만드는 것이 싫다면 라이브러리 를 활용해보는 것도 좋은 대안이다.

결론

다양한 아키텍처, 패턴을 공부하다 보면 결국 변경하기 쉬운 구조를 만들기 위한 여러 시도들이라고 생각된다. 단순히 패턴의 단면만을 보고 큰 고민없이 사용하는 것이 아니라 그속에 담겨있는 핵심을 잘 이해하고 사용하는 것이 중요하다는 것을 다시금 깨닫게 된다.

Ref :

작년 하반기에 Nextstep 의 클린코드 교육과정을 참여하며 배웠던 내용을 바탕으로 많은 성장을 할 수 있었다. 좋은 수업이라며 주위에 열심히 홍보를 하던 찰나, 과정을 직접 운영하고 계시는 박재성님으로부터 코드 리뷰어 활동을 제안 받게 되었다.

코드 리뷰어로 활동할 수 있는 실력이 안될 것 같아 고민이 되었지만, 재성님의 격려와 좋은 경험을 쌓을 수 있는 기회라고 생각이 되어 제안을 수락하였다.

리뷰어 활동은 약 한달간 진행되었고 그 과정속에서 깨달은 것이 많아 개인적인 정리 및 기록 차원에서 글을 남긴다.

코드 리뷰를 진행하며

처음 리뷰를 진행할 때는 생각 이상으로 많은 시간과 에너지가 소모되었다. 회사 업무를 마치고 또 코드를 봐야하는 압박감도 있었고, 각자 다른 스타일의 코드를 꼼꼼히 확인 하고 적절한 피드백이 무엇일지 고민하고 공부해야했기 때문이다. 또한 리뷰를 마쳐야 리뷰이가 다음 단계를 진행하기 수월하기 때문에 코드가 올라오는데로 최대한 빠르게 피드백을 줄 수 있도록 신경을 써야했다. 따라서 초반에는 시간적 여유가 별로 없다보니 특별한 체계없이 정해진 리뷰의 양을 채워나갔던 것 같다.
점점 시간이 흐를 수록 나름의 체계가 생기고 속도가 붙어 초반보단 여유가 생겼다. 이때 나 스스로의 리뷰 방식/관점에 대해 점검해보며 아래과 같은 기준을 세우고 리뷰를 진행했다.

1. 모호한 지식은 모호한 답변을 남긴다.

특정 내용에 대하여 피드백을 할 때마다 굉장히 모호하게 답변을 남기고 있는 것을 깨달았다. 해당 내용에 대한 정확한 지식이 없다는 신호라고 느꼈다. 이런 수준에서는 좋은 피드백을 줄 수 없을 것 같아 잠시 리뷰를 중단했다. 이후 책과 검색을 통해 좀 더 깊게 공부하고 부족해 보이는 부분을 채워나갔다. 내가 어떤 주제에 대하여 막연히만 알고 있었는지 깨닫는 좋은 순간이었다. 어느정도 정리 및 공부가 끝났다고 생각되면 다시 리뷰를 진행했고 이후에도 비슷한 주제가 나올 경우 위와 같은 과정을 반복하였다.

2. 모든 코드에는 근거가 있어야한다.

리뷰를 진행하다보면 특정 상황에서 관습적으로 굳어진듯한 구현을 발견할때가 있다. 이러한 코드를 발견하면 피드백을 남길 때 집요하게😈어떤 구현 의도를 가지고 작성했는지 물어보곤 했다.
관습적인 구현은 대게 큰 고민 없이 이루어지는 경우가 많다. 하지만 모든 코드는 명확한 근거가 있어야한다고 생각한다. 만약 스스로 세운 근거가 틀리더라도 큰 상관이 없다. 나중에 잘못된 방향임을 깨닫더라도 어떤 부분을 놓쳤는지 비교가 가능하기 때문이다. 특히 나같은 경우 스스로 세운 근거가 틀렸음을 깨달았을 때 더 많이 성장하는 것을 경험했기 때문에 이 부분을 특히 집중했다.

3. 나의 피드백보다 더 좋은 방향은 항상 있을 수 있다.

미션의 요구사항이 간단할 때는 리뷰이의 구현 방향이 대부분 비슷하게 수렴하고 이에 대한 베스트 케이스도 어느정도 정해져있다. 그래서 피드백으로 가이드를 남기는 것도 비교적 수월하다. 그러나 점점 미션의 요구사항이 복잡하고 어려워질 수록 다양한 접근 방식이 나타나기 시작한다. 이때 나 또한 결국 가지고 있는 지식과 경험 내에서 답변할 수 밖에 없는 한계점이 있기 때문에 항상 내가 남긴 피드백보다 더 좋은 방향이 있을 수 있다는 마음가짐으로 리뷰에 임했다.
실제로 다양한 방식으로 풀 수 있는 부분에 대하여 피드백을 남기면 리뷰이에게 종종 문의가 들어왔다. 이때 나는 피드백을 남긴 의도를 최대한 자세히 남겼다. 그리고 리뷰이의 의도 역시 물어본 후 각각 비교하여 장/단점을 생각해보고, 목표를 이루기 위해서 무엇을 취할 수 있을지/버려야할 지 논의했다. 이러한 과정 또한 나의 생각을 정리하고 공부하는 계기가 되어 오히려 내가 스스로 많이 배울 수 있는 좋은 거름이 되었다. 👏


(기억에 남았던 논의 과정, 지금 다시 보니 답변 내용 중 아쉬운 부분이 보인다. 😭)

코드리뷰도 결국은..

코드 리뷰도 결국은 사람과의 커뮤니케이션이다. 또한 사람은 각기 다른 커뮤니케이션 스타일을 가지고 있다. 그래서 소프트 스킬이 어렵다고 생각한다. 논의를 주고 받다보면 각 리뷰이분의 스타일을 대략적으로 파악하고, 이에 맞게 커뮤니케이션 방식을 조금씩 바꿔가며 진행해보려 노력했다.
소프트 스킬을 연습할 수 있는 기회는 흔치 않다고 생각되어 이부분에 대해 적지 않게 신경썼던 기억이 난다.

가장 많이 드렸던 피드백들

리뷰를 하다보면 공통으로 피드백을 많이 남기는 내용이 있었다. 정리를 해보니 다음과 같다.

이 객체가 알아야할 정보가 맞을까요?

→ 클래스가 존재해야할 이유는 하나이다. SRP를 잘 지켰는지 확인했다.

이 코드는 다른 객체에서 스스로 수행할 수 있지 않을까요?

→ 객체 상태를 꺼내와서 연산을 수행하지 말고 상태를 알고 있는 객체에 명령할 것, 객체의 응집도 측면에서 확인했다.

이 메소드의 네이밍을 좀 더 구체적으로 지어볼 수 있을까요? / 좀 더 구현 내용을 숨겨볼 수 있을까요?

→ 아마 리뷰이분들 입장에서는 가장 짜증나는(?) 피드백이지 않았을까.ㅎㅎ 어떤 메소드는 이름을 자세히 짓고, 어떤 메소드는 좀 더 추상적으로 지으라니..🤬🤬

나는 SRP캡슐화의 관점에서 피드백을 진행했다.

(1) 좀 더 구체적으로 지어볼 수 있을까요?

메소드는 하나의 일만을 수행해야 한다. 그리고 수행하는 일을 드러낼 수 있다.

위 규칙을 지킨다면 이름이 모호한 메소드는 수행하는 일이 명확히 드러나도록 표현할 수 있다.

1
2
user.get() // (X) 수행하는 일이 명확히 나타나지 않는다. 
user.getName() // (O) 무슨일을 수행하는지 알 수 있다.

(2) 좀 더 구현 내용을 숨겨볼 수 있을까요?

캡슐화란 변경 가능성이 높은 부분을 객체 내부로 숨기는 추상화 기법이다.

오브젝트에서는 변경할 수 있는 모든 것을 캡슐화하라고 이야기 한다.
(관련 내용에 대하여 과거에 정리했던 글이 있다.)

1
winnerNumber.getRankWithAnotherNumber(number) // (X) 전달되는 파라미터에 대한 정보가 드러난다.

위와 같은 메소드의 네이밍은 전달되는 인자에 대한 정보가 메소드에 그대로 드러나 있다. 파라미터가 드러나면 어느정도 가독성은 확보할 수 있을지도 모른다. 그러나 파라미터에 대한 변경은 꽤나 빈번하게 발생하는 일이며, 그때마다 해당 메소드의 이름은 변경되어야 할 것이다. 즉, 변경에 취약하다.

그렇다면 어디까지 추상화해야 할까? 메소드에서 변경되지 않는 것이 딱 하나 있다. 바로 메소드가 수행하는 일이다. 따라서 메소드가 수행하는 일만 드러내는 것이 변경에 좀 더 유연한 네이밍이라고 생각되어 피드백을 남겼었다. (1) 번 내용과도 같이 생각해볼만한 부분이다.

1
winnerNumber.getRank(number) // (O) 수행하는 일만을 명시한다.

요 내용은 개인적으로 고민이 있는 부분이라 다른 의견도 들어보고 싶다. 🤔

동시에 성장하는 경험

내가 알고 있는 지식을 누군가에게 설명해줄 때 가장 공부가 많이 되는 경험을 해본적이 있을 것이다. 코드 리뷰를 진행하면서 비슷한 경험을 했다. 내가 피드백을 제시하기 위해선 근거가 있어야하고 이를 위해서는 탄탄한 지식과 좋은 레퍼런스가 있어야 하기에 엄청나게 공부를 할 수 밖에 없다. 특히 날카로운 질문이 들어올 때, 질문의 주제가 내가 막연하게 알고 있는 내용일 경우 바로 바닥이 나타났기에 더욱 열심히 찾아보고 공부했다. 그래서 과정이 점점 진행될 수록 리뷰이와 내가 동시에 성장을 하는 것을 경험을 했다.

마무리하며

엄청난 열정으로 구현을 진행하고 논의를 나눈 리뷰이분들, 자신의 리뷰과정을 공유하여 다양한 관점으로 볼 수 있게 해준 리뷰어분들, 중간중간 어려움이 있을 때마다 아낌없는 조언을 주신 재성님에게 다시 한번 감사드린다. 🙏
NextStep 에서는 클린코드 과정외에도 다양한 주제의 강의가 진행되고 있으니 관심이 있다면 클릭!

TL;DR

  • TextView 에서 autoLink 혹은 movemnetMethod 옵션을 사용할 경우 ellipsize 가 제대로 동작하지 않는다.
  • autoLink & movementMethod 혹은 ellipsize 를 커스텀하게 구현해줄 필요가 있다.
  • API 27 기준의 TextView 로 조사를 진행하였다.

간단해 보이는 요구 사항, 삽질의 시작

텍스트 박스의 요구사항은 다음과 같았다.

  1. 내용은 최대 N 라인 까지만 노출 가능하다. 이후 내용은 말줄임 표시로 대체
  2. 내용 중 web url 이 있을 경우 클릭 및 스타일링이 되어야하고 클릭 시, 인앱 브라우저로 이동해야한다.

1번 사항은 maxLineellipsize 를 이용하면 된다.

2번 사항은 autoLink, textColorLink, 커스텀한 MovemetMethod 를 이용하면 된다.

커스텀한 MoveMentmethod 가 필요한 이유는 TextView 에서 기본으로 사용되는 LinkMovementMethod 의 경우 특정한 link 텍스트 클릭 시, 이를 감지할 수 있는 콜백을 제공하지 않고 내부에서 ACTION_VIEW intent 로 처리하기 때문이다.
직접 구현하기가 부담스럽다면 이런 라이브러리 를 이용해도 좋다.

어쨌든 안드로이드에서 기본적으로 제공하고 있는 속성을 활용하면 금방 완료가 가능할 것으로 보고 구현을 시작했다.
그런데… 아무리 빌드를 돌려봐도 ellipsis 가 표현되지 않는다.

구글이 그럴리 없다며 내가 잘못한 부분이 없나 몇 시간째 삽질을 하다가, 결국 하나하나 옵션을 제거하면서 결과를 비교해봤고 다음과 같은 결론을 얻었다.

  • autoLink 혹은 setAutoMask() 를 이용하여 autoMask 플래그를 set 해줬을 경우 ellipsize 가 제대로 동작하지 않는다.
  • movementMethod 를 set 해줬을 경우 ellipsize 가 제대로 동작하지 않는다.

전혀 연관이 없을 것 같은 각 속성이 왜 충돌하는 것일까?

setText() 와 autoLink / movementMethod

TextView 에 내용을 전달하기 위해서는 반드시 setText() 를 거쳐야한다. setText() 메소드 내부에서 autoLink 값과 movementMethod 가 영향을 주고 있는 부분은 없을까하여 setText() 내부 구현을 조사해봤다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// TextView.class
else if (type == BufferType.SPANNABLE || mMovement != null) {
text = mSpannableFactory.newSpannable(text);
}

...

mText = text;

if (mAutoLinkMask != 0) {
Spannable s2;

if (type == BufferType.EDITABLE || text instanceof Spannable) {
s2 = (Spannable) text;
} else {
s2 = mSpannableFactory.newSpannable(text);
}
...

mText = s2;

각 코드는 모두 text 를 Spannable 로 포장 해주고 있다. 이 부분을 잘 기억해 두자.

autolink 는 Link 에 해당하는 내용을 스타일링 해주고, movementMethod 는 link 를 클릭 했을 때 특별한 상호작용이 이루어져야 하므로 plain text 가 아닌 spannable 로 포장 해주는 것은 어느정도 납득이 간다. 그럼 이러한 특징이 ellipsize 에 어떤 영향을 줄까?

ellipsize 적용 과정

TextView 에서 Ellipsize 과 관련된 몇가지 내용을 조사해봤다.

  1. TextView 의 설정이 같더라도 디스플레이 환경에 따라 실제 보이는 결과는 다를 수 있다. 그리고 실제 화면 상에 보이고 있는 TextView 에 대한 정보는 Layout 에서 가져올 수 있다. (LinearLayout 과 같은 ViewGroup Layout 이 아니다!)

따라서 ellisize 가 적용된 결과도 layout 을 통하여 가져와야 한다.

  1. TextVeiw 내부에서 Layout 을 생성하는 과정에서 ellipsize 가 적용되며, 이때 mMaximumVisibleLineCount 값을 기준값으로 사용한다.

여기까지만 봤을 때는 아직 특별한 연관성을 찾기 어렵다. Layout 을 생성하는 과정을 좀 더 살펴보자.

1
2
3
4
5
6
// TextView.class
if (mText instanceof Spannable) {
result = new DynamicLayout(mText, mTransformed, mTextPaint, wantWidth,
alignment, mTextDir, mSpacingMult, mSpacingAdd, mIncludePad,
mBreakStrategy, mHyphenationFrequency, mJustificationMode,
getKeyListener() == null ? effectiveEllipsize : null, ellipsisWidth);

이전 단락에서 autoLink 혹은 movementMethod 를 사용했을 경우 mText 는 spannable 로 포장된다고 한 것을 떠올려 보자. 그런데 mText 가 spannable 일 경우 Layout 은 DynamicLayout 으로 생성된다. DynamincLayout 은 내부적으로 StaticLayout 을 이용하여 Text 를 렌더링하는데, 이때 StaticLayout 에 mMaximumVisibleLineCount 값을 따로 설정 해주지 않는 특징이 있다. mMaximumVisibleLineCount 는 디폴트 값인 Integer.MAX_VALUE 으로 초기화 된다. 따라서 보여줄 수 있는 line 의 수가 의미상 무한대에 가까우므로 ellipsis 는 표현되지 않는다.

원인 정리

내용이 길었는데 원인을 정리하면 다음과 같다.

  1. ellipsize 는 mMaximumVisibleLineCount 값을 기준으로 노출여부가 결정된다. 이 값은 보통 maxLines 값으로 초기화 된다.
  2. autoLink or movementMethod 는 text 를 spannable 로 포장한다.
  3. text 가 spannable 하면 mMaximumVisibleLineCount 이 MAX_VALUE 인 Layout 을 생성한다. 따라서 ellipsize 가 노출되지 않는다.

해결책

결국 위 상황을 해결하기 위해서는 2가지 옵션이 있다.

  1. ellipsize 를 커스텀하게 구현한다.
  2. autoLink / movementMethod 를 커스텀하게 구현한다.

간단해보이는 View 라도 막상 구현하다보면 생각치도 못한 엣지 케이스, 예외들이 너무 많이 발생하는 것을 경험했고 나는 이것을 유지보수 할 자신이 없었다. 그래서 최대한 기존에 구현된 내용을 이용하고자 했다.

각각 ellipsize 는 실제로 영향을 받는 부분이 많았고 구현이 너무 깊숙히 숨겨져있어 외부에서 컨트롤 할 수 있는 타이밍이 없을 것 같아 보류하였고
autoLink / movementMethod 는 구현 내용을 살펴보니 TextView 외부에서도 처리가 가능할 것 같았다.
따라서 autoLink / movementMethod 를 기존 구현을 최대한 참고하여 구현해보기로 하였다.

TextView 내부에서 autoLink 가 적용되는 과정은 크게 2가지로 나눌 수 있다.

  1. Linkify 를 이용하여 text 에 link 스타일링
  2. 조건이 충족되면 디폴트 movementMethod 를 set

1 번 과정은 똑같이 Linkify 를 이용하여 적용하면된다.
2번 과정은 오히려 막아야 하므로 조건을 충족시키지 않도록 해야한다. setLinksClickable() 를 이용하여 조건 값을 설정할 수 있다.

1
2
3
4
mTextView.setLinksClickable(false); // 디폴트 movementMethod 를 사용하지 않겠다.

mTextView.setText("Hello https://google.com !");
Linkify.addLinks(mTextView, Linkify.WEB_URLS);

movementMethod 적용하기

MovementMethod 인터페이스의 역할은 link 텍스트 클릭 이벤트를 감지하여 미리 정의된 동작을 수행하는 것이다.

디폴트로 사용되는 LinkMovementMethod 의 내부 구현을 살펴보면, 텍스트 터치 시 텍스트에 Clickable 한 span 이 있는지 감지하여 onClick 콜백을 호출해준다. ClickableSpan 은 곧 link 텍스트라고 봐도 좋다.

나는 link 텍스트를 감지하는 부분에 대한 구현만 그대로 가져와, 감지 했을 시 내가 넣어둔 콜백이 수행되는 커스텀 TouchListener 를 생성하여 사용하였다.
(LinkMovementMethod - onTouchEvent 내부 구현을 보면 어렵지 않게 힌트를 얻을 수 있을 것이다.)

1
mTextView.setOnTouchListener(mCustomUrlTouchHandler);

결론

약간의 우회를 통하여 결과적으로는 ellipsize 도 적용되고, autoLink / movementMethod 도 이용할 수 있는 구현을 완성하였다. 하지만 말 그대로 우회일뿐 언제 이슈 케이스가 발견될지 모르기에 아직은 걱정되는 부분이 많다.
해당 이슈가 리포트된지 꽤 오랜 시간이 지났음에도 해결되지 않아 많은 사람들이 고통을 겪고 있는 듯 하다. 일해라 구글! 😡


이번 포스트에서는 MVVM 아키텍처에서 LiveData 를 사용하면서 겪었던 어려움과 여러 해결방법에 대해 적어보려한다. MVVM 에 대한 좋은 글은 이미 많이 있으므로 해당 포스트에서는 생략하고 넘어간다!

TL;DR

  • LiveData 는 이벤트 전달에 적합하지 않다. 약간의 변형이 필요하다.

데이터와 이벤트 흐름

MVVM 구조내에서 개발을 할 때, ViewModel → View 의 흐름을 다음과 같이 2가지로 나누어 다루고 있다. 일반적인 정의는 아닐 수 있으나, 해당 글에서 전개될 내용의 핵심이므로 잘 이해하고 가는 것이 중요하다.

데이터

데이터는 보통 모델로부터 가공되거나 유저의 액션에 의해 얻어진 특정한 값으로 이루어진다. ObservableField 나 LiveData 형태로 래핑되어 제공되며 데이터 바인딩을 이용하여 View (XML) 에게 변경 사항이 전달된다. 대표적으로 다음과 같은 케이스가 있다.

  • model 로 부터 가공된 list
  • visibility
  • 도메인과 관련된 flag 값
1
2
3
4
5
6
7
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="@{viewModel.temporaryArticles.size() > 0 ? View.VISIBLE : View.GONE}"
bind:bindData="@{viewModel.temporaryArticles}" />

이벤트

이벤트는 databinding 을 통해 수행할 수 없는 작업 즉, Activity/Fragment 레벨에서만 처리할 수 작업들로 구성된다. 이 부분은 자유도가 높지만 보통 RxJava/LiveData 를 이용하여 제공하며 Activity/Fragment 에서 이를 소비한다. 대표적으로 다음과 같은 케이스가 있다.

  • Activity action - startActivity(), finish()
  • Dialog
  • Permission
1
mViewModel.finishEvent.observe(this, aVoid -> finish())

LiveData 의 한계와 대안점

앞서 언급했듯이 이벤트를 전달하는 것은 구현 자유도가 높은 편이다.

RxJava 를 이용해도 되고, LiveData 혹은 Custom 하게 Obeserver 패턴 구현체를 써도 상관 없다. 하지만 RxJava 나 Custom Observer 를 구현하면 라이프사이클을 계속 신경써줄수 밖에 없기 때문에 보통은 LiveData 를 선택한다.

그래도 애매할 때는 여러 레퍼런스를 참고해보며 힌트를 얻어보자. 구글에서 운영하고 있는 공식 repo 인 Goolge IO appArchitecture Sample 를 살펴보자.

변형된 형태의 LiveData

각 repo 는 LiveData 를 이용하여 아래와 같이 이벤트 흐름을 처리하고 있다.

1
2
3
4
5
6
7
8
9
10
11
// Google IO repo
private val _navigateToSignInDialogAction = MutableLiveData<Event<Unit>>()
val navigateToSignInDialogAction: LiveData<Event<Unit>>
get() = _navigateToSignInDialogAction

private val _navigateToSignOutDialogAction = MutableLiveData<Event<Unit>>()
val navigateToSignOutDialogAction: LiveData<Event<Unit>>
get() = _navigateToSignOutDialogAction

// Artictecture sample repo
private final SingleLiveEvent<Void> mEditTaskCommand = new SingleLiveEvent<>();

그런데 자세히 보니 LiveData 를 그대로 쓰지 않고 변형된 형태로 쓰고 있다. IO 의 경우 데이터를 Event 로 한번 더 래핑하였고, sample 에서는 아예 새로운 구현체를 만들어 사용하고 있었다.

각 구현체의 내부를 좀 더 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SingleLiveEvent.java
super.observe(owner, new Observer<T>() {
@Override
public void onChanged(@Nullable T t) {
if (mPending.compareAndSet(true, false)) {
observer.onChanged(t);
}
}
});

// Event.kt
override fun onChanged(event: Event<T>?) {
event?.getContentIfNotHandled()?.let { value ->
onEventUnhandledContent(value)
}
}

각 구현체의 구조는 거의 유사한데 다음과 같은 특징을 가지고 있다.

  1. LiveData 에 등록되는 Observer 를 한번 더 래핑한다.
  2. 래핑된 Observer 는 onChanged 콜백이 여러번 호출되는 것을 막는다.

구글은 왜 이러한 처리를 거쳐서 LiveData 를 사용했을까? 이유는 LiveData 내부 구조에 있다.

LiveData 는 데이터를 위해 만들어 졌다.

docs 에서는 LiveData 를 다음과 같이 정의하고 있다.

LiveData is an observable data holder class

즉, LiveData 는 애초에 데이터의 전달을 위해 설계됐다는 얘기다. 다만, 리엑티브한 개념에서 데이터를 좀 더 추상적으로 생각한다면 이벤트 소스도 하나의 데이터로 생각할 수 있기에 이벤트 전달에 활용을 해도 어색하지는 않다. 다만 구조상 그대로 이벤트를 위해 사용하기에는 한계가 있어 위와 같은 변형 구조로 사용하게 되는 것이다.

여기서 LiveData 의 모든 코드를 다 확인하고 가기는 어려우므로 핵심적인 부분만 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
boolean shouldBeActive() {
return mOwner.getLifecycle().getCurrentState().isAtLeast(STARTED);
}

@Override
public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {
if (mOwner.getLifecycle().getCurrentState() == DESTROYED) {
removeObserver(mObserver);
return;
}
activeStateChanged(shouldBeActive());
}

LiveData 는 N 개의 옵저버가 등록될 수 있고, 각 옵저버는 메모리 관리를 위해 active 라는 상태를 가지고 있다. 해당 코드를 잘보면 생명주기가 바뀔 때마다 옵저버의 active 상태를 체크하며, 생명주기가 onStart 이후이면 옵저버가 active 될 수 있다고 판단한다.

그리고 옵저버는 inactive → active 로 상태가 바뀌면, LiveData 데이터를 소유하고 있을 경우 이를 콜백으로 전달받는다.

이러한 구조가 이벤트를 전달할 때 왜 문제가 될 수 있을까? 예시 상황을 가정해보자.

1
2
3
4
5
// MainViewModel.java
LiveData<String> mShowLoginEvent = new MutableLiveData<>();

// MainActivity.java
mViewModel.getShowLoginEvent.observe(this, this::showLoginDialog)

ViewModel 은 AAC ViewModel 을 상속 받은 것으로 가정한다.

  1. ViewModel 은 로그인 API 를 수행하고, 성공 시 다이얼로그를 띄우는 이벤트를 정의
  2. MainActivity 가 onCreate 에서 이벤트를 구독
  3. 유저 액션으로 로그인 요청 → 로그인 수행 → 성공 → 다이얼로그 노출까지 문제 없음
  4. 화면이 회전됨
  5. ViewModel 은 그대로 살아아있음.
  6. Activity 가 다시 onCreate 에서 이벤트를 구독
  7. onStart 가 됐을 때, 구독한 옵저버는 inactive → active 가 되었고 이전에 발행한 로그인 이벤트 데이터가 남아있으므로 콜백이 호출되어 로그인 다이얼로그가 다시 노출 됨

이런 케이스도 있을 수 있다. 동일한 AAC ViewModel 을 이용하여 Activity ↔ Fragment 통신을 수행하며 Activity 에 N 개의 Fragment 가 있다면 한번 발행된 이벤트는 이후에 명시적으로 post(=setValue) 를 하지 않아도, 각 Fragment 가 구독할 때 마다 전달받을 것이다.

즉, 이벤트를 구독하는 입장에서는 명시적으로 발행된 이벤트만 소비하고 싶은데 자꾸 이전에 발행됐던 이벤트가 전달되는 현상이 발생한다. 중요도가 높은 이벤트일수록 이러한 현상은 치명적으로 다가올 수 있다.

*구글에 LiveData twice 만 검색해도 고통을 겪은 많은 사람들을 볼 수 있다.

그럼 무엇을 선택해야할까?

위와 같은 이유로 인해 LiveData 를 이벤트 전달로 사용하기 위해서, onChanged 콜백이 여러번 호출되는 것을 막는 변형구조가 탄생하게 되었다.

그럼 이벤트 전달을 할때는 무엇을 선택하는 것이 좋을까? 진행하고 있는 프로젝트 상황이 각자 다르기에 꼭 하나를 추천하기는 어려울 것 같다. 다만, 현재 진행하고 있는 프로젝트에서 SingleLiveData 와 커스텀 LiveEvent 를 섞어서 사용하고 있으므로 사용해보면서 느낀점을 정리해보면서 마무리하고자 한다.

SingleLiveEvent

코드관련 포스트를 참고하면 금방 이해할 수 있을거라 생각한다.

구조는 단순하다. MutableLiveData 를 상속받아, 명시적으로 setValue 를 호출했을 때만 데이터가 전달되도록 flag 를 걸어주었다.

  • 장점은 MutableLiveData 를 상속받았기 때문에 기본적으로 메모리 관리나 LifeCycle 변경에 따른 처리를 따로 해줄필요가 없다. 사용방법도 기존의 LiveData 사용하듯이 쓰면 된다.
  • 단점은 옵저버를 여러개 등록할 수 없다. 제일 처음 구독한 옵저버가 데이터를 소비하면 그 뒤에 등록된 옵저버는 데이터를 전달받을 수 없다. 따라서 글로벌 이벤트나 Activity 나 Fragment 가 동시에 이벤트를 구독하는 케이스 등에서 활용할 수 없다.

커스텀 LiveEvent

이 부분은 커스텀을 어떻게 하느냐에 따라 달라지기 때문에 짧게만 적겠다. 기본적인 구현 아이디어는 LifeCycleOwner 를 전달받아 생명주기에 따른 처리를 해주고, 그외에는 Observer 패턴을 그대로 따른다.

  • 장점은 옵저버를 여러개 등록할 수 있으며, LiveData 구조를 따르지 않으므로 이전 이벤트가 다시 전달되지 않는다.
  • 단점은 구조를 바닥부터 새로 짜기 때문에, 초기에 LiveData 보다 안정성이 많이 떨어지는 리스크가 있다.

마무리하며

LiveData 가 나오면서 생명주기 관리에 대한 리소스가 줄어들고 좀 더 리엑티브한 구조로 가기 쉬워진 것은 너무나 환영할 일이다. 하지만 마구잡이로 사용했다가는 새로운 고통을 안겨줄 수 있으므로 잘 이해하고 사용하는 것이 중요하다. (리엑티브한 구조는 디버깅이 너무 괴롭다..)

LiveData 코드는 그리 양이 많지 않아 시간이 될 때 훑어보는 것도 큰 공부가 될 것이라고 생각한다. 👍


여유롭게 지나갈 줄 알았지만 폭풍같았던 2월이었다. 바빴던 만큼 깨달은 내용이 많아 짧게 정리했다.

있어야할 곳에 있는 편안함

최근 작업을 진행하면서 예상보다 훨씬 넓은 범위의 레거시 코드를 수정하게 되었다. 다양한 역사를 지닌 레거시를 보면 볼 수록 강력하게 떠오르는 생각이 있었다.

아, 코드가 있어야할 곳에 있는 것이 이렇게 중요한거구나

좀 더 풀어서 얘기하면 미리 합의된 약속을 지키는 것이라고 볼 수 있다. 약속은 MVP 와 같은 패턴일 수도 있고, 팀내 가이드 라인 혹은 Android 세계에서 암묵적으로 지켜지고 있는 표준일 수도 있다. 중요한 것은 합의된 약속을 지키는 것이다. Next Step 에서 배운 표현을 빌리자면 클-린한 코드를 작성하는 것이 되겠다.

사실 레거시가 왜 이렇게 구현되어있는지 따지는 것은 별 이득이 없다. 그때 당시에는 그게 최선이었을지도 모른다. 하지만 레거시 중에서도 약속이 지켜진 코드는 수정이 간단했고, 약속이 깨진 코드는 어김없이 버그를 유발하거나 결과 대비 많은 시간을 요구하는 차이를 보였다.

그런데 더 중요한 문제는 약속이 깨졌다는 이유로 그 위에 새롭게 약속이 깨진 코드를 짜고 있다는 것이었다. 깨진 약속 위에 새로운 약속을 세우는 것이 당장은 어렵고, 신경쓸게 더 많기 때문이다. 일정이 임박할 수록 ‘나중에 고쳐야지’ 라는 마법의 문장을 남발하며 깨진 코드 위에 깨진 코드를 얹는 유혹에 빠진다.

그 나중은 돌아오지 않는 걸 알고 있으니… 지금부터라도 점진적으로 약속을 지키다보면 언젠가는 눈에 띄는 개선이 이루어져 있지 않을까? 🤔

로우 레벨 지식의 기계적 반응

자주 반복해서 겪는 이슈, 자주 사용되는 속성 값, 기본적인 API 사용법 등 로우한 레벨의 지식을 기계적으로 체화시켜놓으면 더 높은 레벨에 대한 고민과 설계를 할 수 있는 여유를 확보할 수 있다. 어차피 검색하면 다 나온다고 하지만 이건 굉장히 중요한 포인트라고 생각한다.

내가 하루에 쏟을 수 있는 집중력과 에너지는 명확한 한계가 있기 때문이다. 그렇다고 docs 를 전부 외우려는 것은 아니고, 반복되는 내용들은 필요할 때 빠르게 참고할 수 있도록 짧게 정리하고 있다. 👉🏻 TIL 한번 내용을 정리한 후, 여러번 참고하다보면 내 머리속에 나름의 인덱스가 생겨 나중에는 관련 토픽만 보면 자동으로 떠오르는 효과가 있었다. 😮

좀 더 개발로…

상속은 신중히 사용하자

레거시 코드를 처리할 때도 가장 골치가 아픈 케이스는 상속과 엮인 것이다. 바뀐 스펙에 대한 도메인이 상위 클래스에 엮여있다? 그런데 그 클래스가 BaseActivity 이다? 상속받는 클래스를 찍어보니 100개를 우습게 넘어간다. 사이드 이펙트를 예측할 수 없으니 함부로 건드릴수가 없고, 간단한 스펙에도 많은 시간과 에너지가 소모된다.

이펙티브 자바에서도 지적했지만 상위 클래스는 상속받는 하위 클래스를 예측할 수 없다. 그리고 계속해서 변경사항이 생긴다. 하위 클래스는 점점 더 쌓여가는데 호환성때문에 변경은 점점더 악순환에 빠지게 된다.

상속이 필요한 순간이 분명히 있다. 사용할 때는 꼭 신중히 상위 클래스의 코드를 작성하고, 의도를 명확히 밝혀주는 것이 좋다.

LiveData 는 이벤트 전달로 적합하지 않다

MVVM 패턴을 적용할 경우, 대부분의 케이스는 databinding 을 이용하여 구현이 가능하다. 하지만 Dialog 를 띄운다던지, 다른 Activity 를 시작하는 등 반드시 Activity/Fragment 레벨에서 수행되어야 하는 작업들이 존재하며 이는 특정 Event 를 Activity/Fragment 가 구독하는 방식으로 풀 수 있다.

Event 를 전달하는 방식에는 RxJava, LiveData, 커스텀 Observer 구현 등 여러 옵션이 있다. LiveData 가 나온 뒤에는 주로 LiveData 를 활용하는 추세로 보인다.

문제는 LiveData 가 이름에서도 나타나듯이 데이터의 변경을 전달하는 것은 적합하나 Event 를 전달하기에는 내부 구현에 몇가지 문제점을 가지고 있다. 구글에서는 이를 보완하고자 io 앱 내에서 SingleLiveData 와 같은 구현체를 사용하고 있으나 이 역시 한계점을 가지고 있다. 자세한 내용은 다음 포스트에서 적어보려한다.

추후에는 생각이 바뀔수도 있으나 최근에 내린 결론은 LiveData 는 이벤트 전달용으로 적합하지 않다.

강제성과 습관의 형성

나는 강제성이 없으면 잘 움직이지 않는다. 그리고 무지하게 게으르다. 습관이 형성되기 위해서 최소 1년은 반복해야한다고 생각하기 때문에, 긍정적인 강제성을 부여해주는 글또를 망설임 없이 다시 신청하게 되었다.

이번 기수 목표는 3가지 이다.

  1. 문장을 짧고 간결하게 쓰는 연습

    → 평소에 글을 잘 적지 않다보니, 문장이 너무 장황해지는 경향이 있다. 짧으면서도 핵심있게 내용을 전달하는 문장을 쓰고 싶다.

  2. 각기 다른 성격의 글쓰기 연습

    → 개인 위키 성격의 TIL 페이지와 기술/이슈/삶에 대한 회고성글을 적는 블로그 2가지를 각각 운영 해보려한다. 각 목적에 맞는 문장과 구성을 연구해보려한다.

  3. Pass 사용 안하기

    → 글쓰기 습관을 확실히 다지겠다는 의지를 보이기 위해 이번에는 Pass 를 안써보려한다.

아! 그리고 글또 4기는 안드로이드 개발자분들이 이전 기수보다 많이 참여하신 것으로 보이는데, 배워갈 수 있는 기회가 더 늘게되어 기쁘다. 🥰