Skip to content

자바<?>와 코틀린<*>

자바 코드를 코틀린으로 변환하던 중 이슈가 발생했다.

자바 코드

private final ConcurrentMap<String, EventEntry<?>> eventListeners = ConcurrentHashMap();
...
public void onEvent(NamespaceClient client, String eventName, List<Object> args, AckRequest ackRequest) {
EventEntry entry = eventListeners.get(eventName);
Queue<DataListener> listeners = entry.getListeners();
for (DataListener dataListener : listeners) {
Object data = getEventData(args, dataListener);
dataListener.onData(client, data, ackRequest);
}
}
public interface DataListener<T> {
void onData(SocketIOClient client, T data, AckRequest ackSender) throws Exception;
}

중요한 부분만 보자면 이런 코드였다.

  • eventListenersstring(이벤트 이름)과 EventEntry(해당 이벤트의 listener List를 가지고 있는 객체)가 들어있다.
  • listeners를 하나씩 순회하면서 client, data, ackSender 파라미터를 넣어서 호출한다.

코틀린 코드

문제가 되는 코틀린 코드는 아래와 같았다.

private val eventListeners = ConcurrentHashMap<String, EventEntry<*>>()
...
fun onEvent(client: NamespaceClient, eventName: String, args: List<Any>, ackRequest: AckRequest) {
val entry = eventListeners[eventName] ?: return
val listeners = entry.getListeners()
for (dataListener: DataListener<out Any?> in listeners) {
val data: Any? = getEventData(args, dataListener)
dataListener.onData(
client = client,
data = data, // error -> Type mismatch. Required: Nothing? Found: Any?
ackSender = ackRequest
)
}
}
interface DataListener<T> {
fun onData(client: SocketIOClient, data: T?, ackSender: AckRequest)
}

자바에서 사용했던 <?><*>로 바꾸고, eventListeners에서 get할때 null check를 해준 걸 뺴면 완전히 동일한 코드이다.

dataListenr의 제네릭이 out Any? 타입이니까 Any? 타입인 data도 정상적으로 들어갈 것이라 생각했지만? 뜬금없이 Type mismatch 컴파일 에러가 뜬다.

일단 Nothing이라는 클래스 자체도 처음 봐서, 제네릭과 Nothing, star-projection에 대해서 알아보기로 했다.

Nothing

/**
* Nothing has no instances. You can use Nothing to represent "a value that never exists": for example,
* if a function has the return type of Nothing, it means that it never returns (always throws an exception).
*/
public class Nothing private constructor()

Nothing은 어떠한 값도 포함하지 않는 타입이다. 생성자가 private으로 정의되어 있어 인스턴스를 생성할 수 없다.

이름 그대로 없는 타입이라고 생각하면 된다.

fun throwException(): Nothing {
throw IllegalStateException()
}

kotlin에서 함수의 반환값을 정의하지 않으면 Unit 이라는 것을 반환한다. 하지만 반환값이 없는 수준을 넘어서, 아예 반환할 일이 절대로 없는 함수가 있다면 (return 조차 쓰지 않음) Nothing을 지정해주면 된다. 위와 같이 무조건 Exception을 던지는 함수라면 반환할 일이 없기 떄문에 Nothing을 반환하는 것과 동일하다고 볼 수 있는 것이다.

val value: String = nullableString ?: throw IllegalStateException()

이런 코드가 가능한 이유도, throw를 하면 Nothing이기 때문이다.

(사실 내부적으로는 Nothing 모든 타입의 서브 클래스이기 때문이다.)

<*><?>

그렇다면 코틀린의 <*>이 자바의 <?>와 다른 것인가??하는 의문이 들 수 있는데, 사실 다른게 맞다. 문서를 천천히 읽어보면 알 수 있다.

Java의 ?는 wildcard를 뜻하는 것으로, 제네릭에서 알 수 없는 유형을 나타낼떄 사용한다. wildcard이므로 어떤 클래스든 들어갈 수 있다. (문서)

그런데 kotlin의 *은 그 클래스에 대해 아는 정보가 없지만 그것을 안전하게 사용하고 싶은 경우 사용할 수 있는 문법이다. out Any?와 비슷하여, List로 사용했을때 write가 불가능하다. (Sometimes you want to say that you know nothing about the type argument, but you still want to use it in a safe way. Star-projections are very much like Java’s raw types, but safe.) (문서)

kotlin은 java와 다르게 더 안전한 공변성을 구현하기 위해서 Mixed-Site Variance라는 것을 사용하며, JAVA와 같은 Wildcard의 개념이 존재하지 않는다.

실제로 JAVA의 <?>로 구현되어있던 기존 코드에는, Listener의 제네릭 타입을 무시하고 인자를 넣을 수 있는 위험한 부분이 존재했다.

Screenshot 2023-02-11 at 12 17 45

entry.getListener()를 호출했을때, 큐에 담겨있는 각 DataListener의 제네릭 타입을 알 수 없음에도 불구하고 Object 타입의 data 값을 인자로 넣을 수 있었지만 코틀린에선 이러한 경우를 Nothing으로 표시해서 사전에 방지했던 것이다.

try catch문을 사용해서 Exception이 난 경우 catching 처리는 하고 있지만 이에 대한 이해 없이 <?>를 남용했다면 분명히 문제가 발생했을 것이다. 기존 자바 코드를 코틀린으로 변환하기 위해선, 필드에 eventClass 데이터를 직접 받아서 저장후 비교해서 타입 캐스팅이 안되는 경우에는 Exception을 throw하는 로직을 직접 명시하는 등의 다른 처리방식을 사용해야한다.


참고