JsonTypeIdResolver 어노테이션과 Visitor 패턴으로 복잡한 다형성 처리하기

Choi Wontak·2025년 6월 7일

아이쿠MSA

목록 보기
12/12
post-thumbnail

난이도 ⭐️⭐️
작성 날짜 2025.06.07

고민 내용

아이쿠는 MSA 구조로 이루어져 있다.
FCM으로 알람을 보내는 책임을 별개의 서버로 분리하였고, 다른 서버에서 알람을 보내야 할 일이 생기면 kafka로 publish 하였다.

알람 서버는 kafka로 publish된 알람 정보를 받아와 FCM으로 전달하는 책임을 갖고 있다.

그런데 문제는...
알람 서버로 전달되는 메시지 객체가 모두 다르기 때문에,
해당 메시지를 캐스팅하는 것에 문제가 있다는 것이다!

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AlarmMessage {

    private List<String> alarmReceiverTokens;
    private AlarmMessageType alarmMessageType;

}

이러한 클래스를 상속받아 메시지 클래스를 만든다.

@Getter
@NoArgsConstructor(access = AccessLevel.PACKAGE)
public class ArrivalAlarmMessage extends AlarmMessage {

    private long memberId;
    private long scheduleId;
    private String scheduleName;
    private LocalDateTime arrivalTime;
    private AlarmMemberInfo arriveMemberInfo;

    public ArrivalAlarmMessage(List<String> alarmReceiverTokens, AlarmMessageType alarmMessageType, long memberId, long scheduleId, String scheduleName, LocalDateTime arrivalTime, AlarmMemberInfo arriveMemberInfo) {
        super(alarmReceiverTokens, alarmMessageType);
        this.memberId = memberId;
        this.scheduleId = scheduleId;
        this.scheduleName = scheduleName;
        this.arrivalTime = arrivalTime;
        this.arriveMemberInfo = arriveMemberInfo;
    }

}

이런 식으로...!
하지만 문제는 각기 다른 클래스를 kafka로 전달한다고 해서 alarm 서버에서 부모 클래스인 AlarmMessage로 캐스팅하면 안된다.

왜냐하면, kafka로 publish할 때 ObjectMapper를 이용해 JSON으로 변환이 되고, 해당 JSON을 변환할 때 AlarmMessage에 매핑해버리면 자식 클래스에 추가된 필드는 버려지기 때문이다.
이렇게 되면 이후에 자식 클래스로 다운 캐스팅이 불가능하다.

필자는 이 이유로 무지막지한 코드를 작성하고 만다.

@RequiredArgsConstructor
@Component
public class AlarmMessageMapper {

    private final ObjectMapper objectMapper;

    public AlarmMessage mapToAlarmMessage(ConsumerRecord<String, String> data) {
        Pattern pattern = Pattern.compile("\"alarmMessageType\":\"(.*?)\"");
        Matcher matcher = pattern.matcher(data.value());

        AlarmMessageType alarmMessageType;

        if (matcher.find()) {
            String extractedValue = matcher.group(1); // 첫 번째 그룹
            System.out.println("Extracted Value: " + extractedValue);

            alarmMessageType = AlarmMessageType.valueOf(extractedValue);
        } else {
            throw new JsonParseException();
        }

        Class<?> clazz = switch (alarmMessageType) {
            case SCHEDULE_ADD, SCHEDULE_ENTER, SCHEDULE_EXIT, SCHEDULE_UPDATE, SCHEDULE_OWNER, SCHEDULE_OPEN, SCHEDULE_AUTO_CLOSE ->
                data.value().contains("sourceMember") ? ScheduleMemberAlarmMessage.class : ScheduleAlarmMessage.class;
            case MEMBER_ARRIVAL -> ArrivalAlarmMessage.class;
            case SCHEDULE_MAP_CLOSE -> ScheduleClosedMessage.class;
            case EMOJI -> EmojiMessage.class;
            case ASK_RACING -> AskRacingMessage.class;
            case RACING_AUTO_DELETED -> RacingAutoDeletedMessage.class;
            case RACING_DENIED -> RacingDeniedMessage.class;
            case RACING_TERM -> RacingTermMessage.class;
            case RACING_START -> RacingStartMessage.class;
            case TITLE_GRANTED -> TitleGrantedMessage.class;
            case PAYMENT_SUCCESS -> PaymentSuccessMessage.class;
            case PAYMENT_FAILED -> PaymentFailedMessage.class;
            case POINT_ERROR -> PointErrorMessage.class;
            default -> null;
        };

        try {
            return (AlarmMessage) objectMapper.readValue(data.value(), clazz);
        } catch (JsonProcessingException e) {
            throw new MessagingException(FAIL_TO_SEND_MESSAGE);
        }

    }
}

JSON에서 alarmMessageType을 꺼내서 해당하는 enum을 찾고, 거기에 맞는 클래스를 반환하는 switch-case 문을 완성했다.
여기서 끝나면 팩토리 클래스 역할을 할 수 있었지만...

@NoArgsConstructor
@Component
public class AlarmMessageConverter {

    // Firebase 알림 전달 용 메시지 생성
    public Map<String, String> getMessage(AlarmMessage alarmMessage) {
        return ReflectionJsonUtil.getAllFieldValuesRecursive(alarmMessage);
    }

    // DB 알림 저장 용
    public String getSimpleAlarmInfo(AlarmMessage alarmMessage) {
        switch (alarmMessage.getAlarmMessageType()) {
            case SCHEDULE_ADD -> {
                return getScheduleStatement(alarmMessage) + " 가 추가되었습니다.";
            }
            case SCHEDULE_ENTER -> {
                return getScheduleStatement(alarmMessage) + " 에 멤버가 입장하였습니다.";
            }
            case SCHEDULE_EXIT -> {
                return getScheduleStatement(alarmMessage) + " 에서 퇴장하였습니다.";
            }
            case SCHEDULE_UPDATE -> {
                return getScheduleStatement(alarmMessage) + " 이 업데이트 되었습니다.";
            }
            case SCHEDULE_OWNER -> {
                return getScheduleStatement(alarmMessage) + " 의 스케줄 장이 변경되었습니다.";
            }
            // ...이하 생략
      }

}

FCM에 들어갈 메시지와 알람 저장용 메시지 컨버터가 추가하면서 문제가 생겼다.

  • 새로운 알람 메시지 타입이 생성되는 경우 두 클래스 모두를 수정해야 하며 (OCP 위반)
  • 타입 캐스팅 남발로 코드가 더러워졌다.

각 메시지 클래스가 필드도 다 다르고 각 역할이 다 다르긴 하지만,
새로운 메시지를 만드는 경우 너무 신경써야 하는 부분이 많다...!

🤔 같은 부모지만 너무 다른 자식들... 다형성을 더 잘 활용할 방법은 없을까?


찾아보기

해결책 생각해보기

1. Factory

@Component
@RequiredArgsConstructor
public class AlarmMessageMapper {

    private final ObjectMapper objectMapper;

    private final Map<AlarmMessageType, Class<? extends AlarmMessage>> typeClassMap = Map.of(
        AlarmMessageType.MEMBER_ARRIVAL, ArrivalAlarmMessage.class,
        AlarmMessageType.SCHEDULE_MAP_CLOSE, ScheduleClosedMessage.class,
        AlarmMessageType.EMOJI, EmojiMessage.class,
        AlarmMessageType.ASK_RACING, AskRacingMessage.class,
        AlarmMessageType.RACING_AUTO_DELETED, RacingAutoDeletedMessage.class,
        AlarmMessageType.RACING_DENIED, RacingDeniedMessage.class,
        AlarmMessageType.RACING_TERM, RacingTermMessage.class,
        AlarmMessageType.RACING_START, RacingStartMessage.class,
        AlarmMessageType.TITLE_GRANTED, TitleGrantedMessage.class,
        AlarmMessageType.PAYMENT_SUCCESS, PaymentSuccessMessage.class,
        AlarmMessageType.PAYMENT_FAILED, PaymentFailedMessage.class,
        AlarmMessageType.POINT_ERROR, PointErrorMessage.class
        // Schedule 계열은 따로 처리
    );

    public AlarmMessage mapToAlarmMessage(ConsumerRecord<String, String> data) {
        String json = data.value();
        AlarmMessageType type = extractType(json);

        try {
            if (isScheduleType(type)) {
                Class<?> clazz = json.contains("sourceMember") ?
                        ScheduleMemberAlarmMessage.class : ScheduleAlarmMessage.class;
                return (AlarmMessage) objectMapper.readValue(json, clazz);
            }

            Class<?> clazz = typeClassMap.get(type);
            if (clazz == null) {
                throw new MessagingException(FAIL_TO_SEND_MESSAGE);
            }

            return (AlarmMessage) objectMapper.readValue(json, clazz);

        } catch (JsonProcessingException e) {
            throw new MessagingException(FAIL_TO_SEND_MESSAGE);
        }
    }

    private AlarmMessageType extractType(String json) {
        Pattern pattern = Pattern.compile("\"alarmMessageType\":\"(.*?)\"");
        Matcher matcher = pattern.matcher(json);

        if (matcher.find()) {
            return AlarmMessageType.valueOf(matcher.group(1));
        }
        throw new JsonParseException();
    }

    private boolean isScheduleType(AlarmMessageType type) {
        return switch (type) {
            case SCHEDULE_ADD, SCHEDULE_ENTER, SCHEDULE_EXIT, SCHEDULE_UPDATE,
                    SCHEDULE_OWNER, SCHEDULE_OPEN, SCHEDULE_AUTO_CLOSE -> true;
            default -> false;
        };
    }
}

조건에 맞는 클래스를 찾을 때 Map으로 등록해 switch-case를 탈피할 수 있다는 장점이 있다.
그러나 DB 저장용 메시지 변환 메서드를 위해서는 추가적인 캐스팅 메서드가 필요하기 때문에 근본적인 해결이 어렵다.

2. 전략 패턴 (Strategy Pattern)

Enum 처리를 위한 끝 없는 switch문에 관하여… (전략 패턴 사용기)

장점은 다음과 같다.

  • Enum에 대해 switch-case 대신 Map으로 처리한다.
  • 각 클래스 별로 다른 메서드 실행이 필요한 경우 각 클래스 별 하나의 전략으로 처리할 수 있다.

치명적인 단점은 15개 이상 존재하는 모든 전략에 대해 클래스 구현이 필요하다는 점이다.
부모 클래스로 받아야 하기 때문에 메시지 변환의 복잡함은 여전히 존재할 것이다.

3. @JsonTypeInfo + @JsonSubTypes

@JsonTypeInfo(
  use = JsonTypeInfo.Id.NAME,
  include = JsonTypeInfo.As.PROPERTY,
  property = "alarmMessageType"
)
@JsonSubTypes({
  @JsonSubTypes.Type(value = ArrivalAlarmMessage.class, name = "MEMBER_ARRIVAL"),
  @JsonSubTypes.Type(value = EmojiMessage.class, name = "EMOJI"),
  // ... 모든 메시지 타입 등록
})
public abstract class AlarmMessage {
    // 공통 필드
}

Jackson 어노테이션을 이용하는 방법이다.
이렇게 하면 따로 Mapper 클래스를 만들지 않고도 ObjectMapper 하나만으로 역직렬화가 가능하다.

활용하기

public enum AlarmMessageType {
    SCHEDULE_ADD(ScheduleAlarmMessage.class),
    SCHEDULE_ENTER(ScheduleMemberAlarmMessage.class),
    SCHEDULE_EXIT(ScheduleMemberAlarmMessage.class),
    SCHEDULE_UPDATE(ScheduleAlarmMessage.class),
    SCHEDULE_OWNER(ScheduleAlarmMessage.class),
    SCHEDULE_OPEN(ScheduleAlarmMessage.class),
    SCHEDULE_AUTO_CLOSE(ScheduleAlarmMessage.class),

    MEMBER_ARRIVAL(ArrivalAlarmMessage.class),
    SCHEDULE_MAP_CLOSE(ScheduleClosedMessage.class),
    EMOJI(EmojiMessage.class),
    ASK_RACING(AskRacingMessage.class),
    RACING_AUTO_DELETED(RacingAutoDeletedMessage.class),
    RACING_DENIED(RacingDeniedMessage.class),
    RACING_TERM(RacingTermMessage.class),
    RACING_START(RacingStartMessage.class),
    TITLE_GRANTED(TitleGrantedMessage.class),
    PAYMENT_SUCCESS(PaymentSuccessMessage.class),
    PAYMENT_FAILED(PaymentFailedMessage.class),
    POINT_ERROR(PointErrorMessage.class);

    private final Class<? extends AlarmMessage> messageClass;

    AlarmMessageType(Class<? extends AlarmMessage> messageClass) {
        this.messageClass = messageClass;
    }

    public Class<? extends AlarmMessage> getMessageClass() {
        return messageClass;
    }

    public static AlarmMessageType fromName(String name) {
        try {
            return AlarmMessageType.valueOf(name);
        } catch (IllegalArgumentException e) {
            return null;
        }
    }
}

우선 Enum을 바꿔주었다. Enum의 필드로 각 Message 클래스를 매핑하였고, 이 과정을 통해 자동 등록되도록 할 것이다.

public class AlarmMessageTypeIdResolver extends TypeIdResolverBase {

    @Override
    public JavaType typeFromId(DatabindContext context, String id) {
        AlarmMessageType type = AlarmMessageType.fromName(id);
        if (type == null) {
            throw new IllegalArgumentException("Unknown alarmMessageType: " + id);
        }
        return context.constructType(type.getMessageClass());
    }

    @Override
    public String idFromValue(Object value) {
        return null;
    }

    @Override
    public String idFromValueAndType(Object value, Class<?> suggestedType) {
        return null;
    }

    @Override
    public JsonTypeInfo.Id getMechanism() {
        return JsonTypeInfo.Id.CUSTOM;
    }
}

@JsonTypeIdResolver을 통해 매핑 과정을 커스텀한다.
typeFromId를 통해 위에서 등록한 Enum을 확인하고, 자동으로 클래스를 매핑한다.

@JsonTypeInfo(
        use = JsonTypeInfo.Id.CUSTOM,
        include = JsonTypeInfo.As.PROPERTY,
        property = "alarmMessageType",
        visible = true
)
@JsonTypeIdResolver(AlarmMessageTypeIdResolver.class)
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AlarmMessage {

    private List<String> alarmReceiverTokens;
    private AlarmMessageType alarmMessageType;

}

이제 AlarmMessage의 어노테이션을 다음과 같이 설정하면 끝
이렇게 하면 ObjectMapper로 AlarmMessage를 받을 때 해당하는 자식 클래스로 자동으로 매핑시켜준다!

@Test
void convert() throws JsonProcessingException {
    ObjectMapper objectMapper = new ObjectMapper();

    String json = """
        {
          "alarmMessageType": "POINT_ERROR",
          "alarmReceiverTokens": ["token1", "token2"]
        }
       """;

    AlarmMessage message = objectMapper.readValue(json, AlarmMessage.class);
    System.out.println(message.getClass()); // PointErrorMessage
}

짜잔 잘 동작하는 것을 확인할 수 있다.

이 과정으로 다음의 조건을 달성했다.

  • 쉽게 자식 클래스로의 매핑이 가능할 것
    매핑을 Jackson이 대신 처리해주기 때문에 편리하다
  • 새로 클래스를 추가할 때 빠뜨리는 행위가 없을 것
    Enum 생성 과정에서 클래스 등록을 강제하기 때문에 가능하다.

하지만 한 가지 문제가 아직 존재했다.

  • 메시지 클래스의 DB 저장 & FCM 메시지 컨버팅을 위한 메서드 필요

🧐 그렇다면 Message 클래스 내에 메시지 컨버팅 내용을 직접 넣으면 되는 것 아닌가?
그건 옳은 방향이 아닌 것 같다!
왜냐하면 Message 클래스는 모든 모듈에서 사용하는 클래스인데, 다른 모듈에서 알람과 이해 관계가 없는 작업자가 클래스를 생성하더라도 메시지 컨버팅 내용에 의존하게 된다.
따라서 alarm과 관련된 책임 응집도가 떨어지게 될 것이다.

어떻게 해결할 수 있을까?

방문자 패턴 (Visitor Pattern)

Visitor 패턴은 알고리즘을 객체 구조에서 분리시켜주는 디자인 패턴이다.
각 자식 클래스가 처리해야 하는 복잡한 작업을 Visitor에 몰아 넣고,
실제 클래스는 Visitor를 받아 실행시킨다.

장점은 다음과 같다.

  • 알고리즘을 하나의 클래스에 몰아 넣어 응집도를 높인다.
  • 코드의 수정이 적다.

Visitor 패턴은 문제를 해결해 줄 수 있을 것 같다!

방문자 패턴 적용하기

public interface AlarmMessageVisitor {
    String visit(ArrivalAlarmMessage message);
    String visit(EmojiMessage message);
    String visit(ScheduleAlarmMessage message);
}

각 클래스에 맞는 행동을 실행하기 위한 방문자를 interface로 생성해준다.
각 메서드는 방문했을 때 방문자가 하는 행동이다.

public abstract String accept(AlarmMessageVisitor visitor);

부모 클래스의 타입을 추상 클래스로 변경하고, accept() 메서드를 작성한다.

@Override
public String accept(AlarmMessageVisitor visitor) {
    return visitor.visit(this);
}

이제 AlarmMessage의 자식 클래스에서 visitor의 행동을 실행시켜준다!

이렇게 하면 AlarmMessage와 그 상속 클래스들은 실제 구현이 어떻게 진행되는지 알 필요가 없기 때문에 관심사를 분리할 수 있다.

public class AlarmMessageConverter implements AlarmMessageVisitor {
    @Override
    public String visit(ArrivalAlarmMessage message) {
        return "약속 : " + message.getScheduleName() + "에서 멤버 " + message.getArriveMemberInfo().getNickname() + "가 약속 장소에 도착하였습니다!";
    }

    @Override
    public String visit(EmojiMessage message) {
        return "약속 : " + message.getScheduleName() + "에서 멤버 " + message.getSenderInfo().getNickname() + "가 " + message.getEmojiType() + " 이모지를 전달했습니다.";
    }

    @Override
    public String visit(ScheduleAlarmMessage message) {
        AlarmMessageType alarmMessageType = message.getAlarmMessageType();
        
        if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_ADD))
            return "약속 : " + message.getScheduleName() + " 가 추가되었습니다.";
        else if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_UPDATE))
            return "약속 : " + message.getScheduleName() + " 이 업데이트 되었습니다.";
        else if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_OWNER))
            return "약속 : " + message.getScheduleName() + " 의 스케줄 장이 변경되었습니다.";
        else if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_OPEN))
            return "약속 : " + message.getScheduleName() + " 맵이 생성되었습니다!";
        else if (alarmMessageType.equals(AlarmMessageType.SCHEDULE_AUTO_CLOSE))
            return "약속 : " + message.getScheduleName() + " 이 자동 종료되었습니다.";
        else
            return "";
    }
}

실제 Visitor 역할을 해줄 구현체이다.
부모 클래스가 아닌 실제 상속된 클래스를 사용하기 때문에 자식 클래스에서 추가된 필드도 사용 가능하다.

타입 별로 다른 메시지가 반환되어야 하는 복잡한 로직도 Visitor 클래스 하나에서 처리 가능하다.

Visitor 인터페이스는 common 모듈에서 작성했지만, 해당 클래스는 직접적인 Alarm 내용을 하드코딩하는 부분이기 때문에 책임 분리를 위해 alarm 모듈에서 작성하였다!

만약 새로운 AlarmMessage 클래스를 생성하더라도 accept를 구현하지 않거나,
Visitor가 해당 메서드를 작성하지 않으면 컴파일 에러를 발생시키니 빼먹을 일도 없을 것 같다.

추가적인 의문

accept(), visit()와 같이 중립적인 네이밍을 써야할까?
이러한 네이밍은 실제 함수가 어떤 역할을 하는지 보이지 않는다..!
toLogMessage()와 같은 직접적인 네이밍을 쓰면 안되나?

그럼에도 중립적인 네이밍을 유지하는 이유는 다음과 같다.

방문자는 여러가지 역할을 가질 수 있다.
지금은 DB에 알림 리스트로 저장하는 방문자만 존재하지만, 이후에는 다른 역할을 하는 방문자가 추가될 수도 있다.

그럴 때마다 부모 클래스에 함수를 추가하고, 구현체를 수정하는 것은 비용이 크기 때문에 accept() 하나로 모든 방문자를 handle할 수 있어야 한다.

특히나 현재와 같이 다양한 AlarmMessage 자식 클래스가 존재할 때는 기능 추가에 특히나 열려있어야 할 것 같다.

어차피 accept() 함수의 매개변수에는 Visitor의 실제 구현체가 들어가게 된다.
따라서 해당 Visitor가 방문했을 때 어떤 역할을 하는지는 Caller가 이미 알고 있을 것이며,
함수 네이밍이 모호하더라도 Caller가 기대하는 함수 자체는 Visitor에서 실행되기 때문에 우려하는 일은 발생하지 않을 것 같다.


결론

과거의 코드를 볼 때 마다 왜 이렇게 짰지..? 싶은 무지막지한 코드가 많이 보인다.
이러한 코드를 짜게 되면 항상 의심부터 하자!!

결론

  • Jackson의 JsonTypeIdResolver를 사용하면 다형성을 이용한 매핑에서 효율을 챙길 수 있다.
  • 방문자 패턴을 통해 클래스의 알고리즘 구현 부담을 분리할 수 있다!

profile
백엔드 주니어 주니어 개발자

0개의 댓글