최종 프로젝트 중 생겼던 이슈에 대한 내용이다.
STOMP에서 특정 topic에서 여러 종류의 메시지들을 처리해야 하는 상황이었다.
다시 말해, 클라이언트가 특정 게임대기방에 참가 메시지, 준비완료 메시지, 게임시작 메시지를 발행한다. 그리고 컨트롤러에서는 DTO의 messageType의 메시지 종류를 확인하고, 그에 따라 switch case문으로 처리한다.
이 때 문제는 하나의 컨트롤러 메서드에서 처리하므로, 메시지의 종류와 관계없이 같은 요청 DTO를 사용해야 하고 같은 응답 DTO를 사용해야 한다는 것이다. 구체적으로 말하면, 참가 메시지에서는 nickname과 role을 받아야 하고 게임시작 메시지에서는 gameId를 받아야 한다. 하지만 같은 DTO를 사용해야 했다. 그리고 참가 메시지의 응답으로는 GroupStatusResponse를 보내야 하고 게임시작 메시지의 응답으로는 GameStartResponse를 보내야 하지만, 마찬가지로 같은 DTO를 사용해야 했다.
다음과 같이 메시지의 종류에 따라서 다른 컨트롤러 메서드로 처리하는 방법을 찾았었다.
//JOIN
@MessageMapping("/group/join")
@SendTo("/subscribe/group/join")
public JoinResponse sendMessage(JoinRequest request) {
return new JoinResponse();
}
//READY
@MessageMapping("/group/ready")
@SendTo("/subscribe/group/ready")
public ReadyResponse sendMessage(ReadyRequest request) {
return new ReadyResponse();
}
//GAME START
@MessageMapping("/group/start")
@SendTo("/subscribe/group/start")
public StartResponse sendMessage(StartRequest request) {
return new StartResponse();
}
하지만 이 경우엔 클라이언트 쪽에서 그룹에 참가시, 모든 메시지 종류의 URL을 구독해 주어야 한다. 이는 개인적으로 안 될 것은 없긴 하지만, 뭔가 상당히 부자연스럽게 느껴졌다. 그리고 나중에 더 구체적인 단점을 깨달았다.
기존대로 하나의 컨트롤러 메서드에서 처리한다. 이 때, 요청 DTO에서는 메시지 종류를 구분하는 변수와 상세 데이터를 담은 Map 타입의 변수를 이용하는 것이다. 그리고 응답 DTO에서는 제네릭 타입을 이용하여 각 메시지 종류별 응답 DTO를, 공통 응답 DTO로 한 번 더 감싸서 사용하는 방법을 떠올렸다.
우선 요청 DTO 부터 확인해보겠다.
public record MessageRequest(
EventType eventType,
Map<String, Object> data
) {}
EventType이라는 Enum타입을 이용해 메시지의 종류를 구분한다. (자동으로 String으로 매핑하여 받을 수 있다.) 그리고 그에 해당하는 case문에서 필요한 데이터를 Map에서 key값으로 꺼내 사용한다.
public record MessageResponse<T>(
T data
) {}
각 case에서 응답할 DTO를 위 제네릭타입에 담아서, MessageResponse라는 DTO로 통일하여 응답할 수 있게 되었다.
이를 적용하여 다음과 같이 사용할 수 있었다.
@MessageMapping("/group")
@SendTo("/subscribe/group")
public MessageResponse<?> sendMessage(MessageRequest request) {
return switch (request.getMessageType()) {
//참가 메시지인 경우
case JOIN -> {
//Map에서 "nickname"과 "role"이라는 key 값으로 데이터를 꺼내 사용
String nickname = (String)(request.data().get("nickname")); //RTTI 생략
String role = (String)(request.data().get("role"));
groupService.addMember(nickname, role);
//공통 응답 DTO로 감싸서 응답
yield new MessageResponse<>(new JoinResponse());
}
//시작 메시지인 경우
case START -> {
//Map에서 "gameId"라는 key 값으로 데이터를 꺼내 사용
Long gameId = messageDataReader.readLong(request.data().get("gameId"));
groupService.gameStart(gameId);
//공통 응답 DTO로 감싸서 응답
yield new MessageResponse<>(new StartResponse());
}
//준비완료 메시지인 경우 _생략
}
};
이 경우에는 모든 메시지 종류를 하나의 컨트롤러에서 다루어야 하는 단점이 있다.
두 방법 중 어떤 방법이 나을지 고민해보다 객체지향 프로그래밍을 공부하며 항상 주요한 골자였던, 유지보수 및 확장성의 관점에서 생각해보았다. 즉, 새로운 종류의 메시지가 추가되는 상황을 생각해보았다.
첫 번째 방법의 경우에는 해당 종류의 메시지를 처리하는 새로운 컨트롤러를 추가해야 하고, 프론트 쪽에도 이 컨트롤러의 URL을 추가해주어야 한다.
두 번째 방법의 경우에는 case 문을 추가하고, 새로운 응답 DTO클래스를 생성해주면 된다.
나는 여기서 두 번째 방법을 채택했다.
객체지향적 설계에 따른 확장에서는 코드의 변경보다, 새로운 클래스를 만들기만 하면 되는 상황이 굉장히 많았다. 두 번째 방법의 경우 case 문을 추가하는 코드의 변경사항이 있긴 하지만 새로운 컨트롤러 메서드를 작성하는 것보다는 훨씬 비용이 적게 든다고 생각들었고, 사실상 응답 DTO만 추가하는 것 정도가 전부라고 할 수 있을 정도로 간단한 방법이라 생각되었다.
또한 동시에 앞서 말한 것처럼 프론트엔드 쪽에도, 같은 대기방에 대해서 메시지 종류 별로 URL을 추가하는 것이 부자연스럽다고 느껴졌다.
따라서 실제 프로젝트에 아래와 같이 적용하였다.
@MessageMapping("/group/{groupId}")
@SendTo("/group-service/subscribe/group/{groupId}")
public MessageResponse<?> sendMessage(MessageRequest request, @DestinationVariable Long groupId) {
return switch (request.eventType()) {
case PARTICIPANT -> {
String nickname = messageDataReader.readString(request.data().get("nickname"));
GroupMemberRole role = GroupMemberRole.valueOf(messageDataReader.readString(request.data().get("role")));
groupMemberService.addMember(groupId, nickname, role);
List<GroupMemberStatusResponse> groupMemberStatusResponseList = groupMemberService.getStatus(groupId);
yield new MessageResponse<>(GroupStatusResponse.of(groupMemberStatusResponseList));
}
case READY -> {
String nickname = messageDataReader.readString(request.data().get("nickname"));
groupMemberService.ready(groupId, nickname);
List<GroupMemberStatusResponse> groupMemberStatusResponseList = groupMemberService.getStatus(groupId);
yield new MessageResponse<>(GroupStatusResponse.of(groupMemberStatusResponseList));
}
case START -> {
Long gameId = messageDataReader.readLong(request.data().get("gameId"));
Integer rounds = groupService.getGroupInfo(groupId).totalRounds();
List<String> nicknameList =
groupMemberService.getMemberList(groupId).
members().stream().parallel().
map(GroupMemberRes::userId).
map(userServiceClient::getUserById).
map(ResponseUserInfo::nickname).toList();
yield new MessageResponse<>(GameStartResponse.of(gameId, nicknameList, rounds));
}
};
}
멘토님 답변