가장 괴랄한 Java 문법 - 실전

여러 타입의 이벤트를 처리하는 eventHandler를 만들면서 겪었던 제네릭과 타입 캐스팅의 딜레마

가장 괴랄한 Java 문법 - 실전

Intro

이전글에서 이어지는 내용이에요.

이 글에서는, 여러 이벤트를 타입에 따라 처리하는 eventHandler를 만들면서 느꼈던 제네릭에 대한 고민과 해결 방법을 정리할 거에요.


기본구조

EventPayload

public interface EventPayload {}
public class FooEventPayload implements EventPayload {private String foo = "FOO";}
public class BarEventPayload implements  EventPayload{private String bar  = "BAR";}

이벤트마다 다른 데이터를 담기 위해 최상위 EventPayload 마커 인터페이스를 만들고, 각 타입별로 구현 클래스를 나눴어요.

EventType

public enum EventType {
    FOO(FooEventPayload.class),
    BAR(BarEventPayload.class);

    private final Class<? extends EventPayload> payloadClass;

이벤트의 타입정보를 가지고있는 enum이에요. 각 타입마다 어떤 페이로드 클래스를 써야 하는지 같이 들고 있어요.

Event<T extends EventPayload>

@Getter
public class Event<T extends EventPayload> {
    private EventType type;
    private T payload;

    public static Event<EventPayload> of(EventType type, EventPayload payload) {
        Event<EventPayload> event = new Event<>();
        event.type = type;
        event.payload = payload;
        return event;
    }

    public static Event<EventPayload> fromJson(String json) {
        EventRaw eventRaw = DataSerializer.deserialize(json, EventRaw.class);
        if (eventRaw == null) {
            return null;
        }
        Event<EventPayload> event = new Event<>();
        event.type = EventType.from(eventRaw.getType());
        event.payload = DataSerializer.deserialize(eventRaw.getPayload(), event.type.getPayloadClass());
        return event;
    }

    @Getter
    private static class EventRaw {
        private String type;
        private Object payload;
    }
}

EventPayload를 담는 이벤트 객체에요.

역직렬화 시 처음에 type 값을 보고, 그에 맞는 페이로드 클래스로 다시 파싱해요.

EventType은 Class<? extends EventPayload> 타입의 클래스를 들고 있기 때문에,

역직렬화할 때 페이로드를 EventPayload로 자연스럽게 캐스팅할 수 있어요.

EventHandler<T extends EventPayload>

public interface EventHandler<T extends EventPayload> {
    boolean supports(Event<? extends EventPayload> event);  
    void handle(Event<T> event);
}

class FooEventHandler implements EventHandler<FooEventPayload> {
    public boolean supports(Event<? extends EventPayload> e) {
        return e.getType() == EventType.FOO;
    }
    public void handle(Event<FooEventPayload> e){}
}
...

다양한 형식의 이벤트를 처리하는 핸들러 인터페이스에요.

type에 따라 각기 다른 핸들러가 실행되요.

EventConsumer (main)

public class EventConsumer{
	 //autowire
	 private final List<EventHandler> handlers; //raw
	 
	 public void dispatch(String message){
			 Event<EventPayload> event = Event.fromJson(message);
			 
			 for(EventHandler h: handlers){
				 if(h.supports(event)){
					 h.handle(event);
					 return;
				 }
			 }
			 throw new Error("cannot find handler");
	 }
}

문자열로 받은 이벤트 메시지를 파싱하고, 맞는 핸들러를 찾아 실행하는 메인 로직이에요.

설명

handlers

가장 먼저 EventConsumer.handlers부터 살펴볼게요. 모든 이벤트는 결국 Event<EventPayload> 타입이니까, 직관적으로 List<EventHandler<EventPayload>> 를 받으면 되겠죠?

하지만 이건 불가능해요. 자바의 제네릭은 불변이라 EventHandler<FooPayload>EventHandler<EventPayload>의 하위 타입이 아니고, handler는 영원히 빈 상태로 남을거에요.

여기서 우리는 두 가지 선택지를 가질 수 있어요.

  1. List<EventHandler<? extends EventPayload>>
  2. List<EventHandler> (raw type)

일단 첫 번째 <? extends EventPayload>를 기준으로 계속할게요.

handle()

EventHandler 구현체들은 인터페이스가 아닌, 실제 EventPayload 구현 클래스들을 핸들링해요(그래야 제네릭을 쓰는 의미가 있겠죠).

그런데 앞에서 핸들러를 <? extends EventPayload>로 받았죠? 이벤트 객체는 Event<EventPayload> 타입인데, 핸들러의 제네릭 타입은Event<? extends EventPayload>라서 이걸 인자로 받을 수 없어요.

물론 우리는 supports() 를 통과하면 지원된다는 걸 알지만, 컴파일러는 그런거 몰라요.

결국 강제 캐스팅이 필요한데, 당연히 컴파일러는 기겁하죠.

컴파일러에게 "나를 믿고 못 믿지만 그냥 눈 감아달라"고 애원해야 해요.

for (EventHandler<? extends EventPayload> h : handlers) {
    if (h.supports(event)) {
        @SuppressWarnings("unchecked")
        EventHandler<EventPayload> castedHandler = (EventHandler<EventPayload>) h;
        castedHandler.handle(event);
        return;
    }
}

raw 타입 사용

앞 글에서 봤던 예처럼, List[1, "a", "b"] 같은 것도 가능했죠? 자바의 제네릭은 컴파일 시에만 영향을 미치고, 실제로는 전부 Object로 동작해요.

다시 말해 타입만 맞으면 OK!

우리는 이미 supports() 메서드로 올바른 핸들러를 고른 다음 호출하기 때문에, 컴파일만 통과하면 OK예요.

실제로 EventConsumer에서만 raw 타입으로 다루고, 핸들러는 다형성을 제대로 활용하면서 타입이 보장된 시점에만 handle()을 호출해서 실제로는 꽤 안전할거에요.

물론 개발자가 supports() 구현을 잘못하면 런타임에서 ClassCastException이 터지겠죠.

컴파일러도 엄청 불평할 거고요.

근데 그건... 타입을 열심히 맞춰도 마찬가지잖아요?

주의!!

절충안일 뿐이지 raw type을 쓰는게 정답이라는 뜻은 아니에요!!!!!!!

클래스 타입에 따른 분기

사실, 이런 경우에는 실제 payload의 타입을 사용해서, 경고를 아예 없앨 수도 있어요.

아예 핸들러에서 제너릭을 없애버리고, EventType 의 실제 타입에 따라 처리하는거죠.


class  EventHandler {
   void handle(Event<EventPayload> event) {
        EventPayload payload = event.getPayload();

        switch (payload) { //java 21이상
            case FooEventPayload foo -> log.info(foo.getFoo())
                    ...
        }
    }
}

하지만 딱 봐도 OCP를 위반하죠? 간단한 경우면 상관없겠지만, 이벤트 종류가 늘어날수록 어지러워질거에요.


Final

사실 문법을 정확히 숙지하지 못한 상태로 작업하느라, 난관이 꽤 많았어요. LLM도 이 문제는 많이 헷갈렸는지, 이상한 예제가 많이 나오더라고요.

결국 저는 raw타입을 사용하기로 했어요. 경고는 많이 뜨지만, 코드는 훨씬 직관적이라고 생각했죠. 어짜피 미리 필터링을 한다면, 그냥 쓰는 게 더 낫다고 판단했어요.

물론 실제 서비스 코드라면? 팀 컨벤션을 따르는게 맞겠죠

3-Point

  1. 제너릭은 타입 안정성을 보장해 주지만, 강제 캐스팅을 해야하는 상황이 있다.
  2. 대신, 실수하면 런타임에서 터진다.
  3. 타입만 맞으면 그만이야~ 🤡