웹 소켓 관련 코드를 새로 짜며 관련 서비스 종류에 따라, 메시지 타입에 따라 하나의 메서드로 관련 구독 및 메시지 발행정보를 관리하고 싶었다.
@MessageMapping("/room/event")
public void handleRoomEvent(WebSocketMessageRequest<?> request) {
RoomPlayersRes response;
switch (request.messageType()) {
case PLAYER_JOINED -> response = gameServiceClient.invitePlayer((PlayerReq) request.payload()).getBody();
case PLAYER_REMOVED -> response = gameServiceClient.removePlayer((PlayerReq) request.payload()).getBody();
case PLAYER_READY -> response = gameServiceClient.readyPlayer((PlayerReq) request.payload()).getBody();
}
sendMessage(response.roomId(), request.messageType(), response);
}
Java
복사
일단 돌아가게 만든 대기방 로직은 다음과 같았다. 하지만 딱봐도 알 수 있듯 response가 RoomPlayersRes일 경우에만 sendMessage가 돌아간다.
줄이려다 보니 조금 이상한 코드가 나왔고… 해결하기 위해 response DTO를 매핑해주는 private 메서드를 하나 만들었다.
private String getRoomIdFromResponse(Object response) {
if (response instanceof RoomPlayersRes res) {
return res.roomId();
} else if (response instanceof GameCreateRes res) {
return res.roomId();
} else if (response instanceof ScoreRes res) {
return res.roomId();
} else {
throw new IllegalArgumentException("roomId를 찾을 수 없는 응답 타입: " + response.getClass().getSimpleName());
}
}
Java
복사
공통으로 roomId를 활용하도록 설계했기에 만드는건 간단했는데, 이러면 관련 서비스가 늘어나고 dto가 추가될수록 코드가 끝도없이 길어진다.
더 깔끔하게 만들고 싶어서 친구에게 물어보니 리플렉션 API라는게 있다고 한다.
리플렉션(Reflection)은 런타임(실행 중)에 클래스, 메서드, 필드 등에 접근할 수 있도록 도와주는 기능이다.
즉, 컴파일 시점이 아니라 실행 시점에 객체 정보를 동적으로 조회하고 조작할 수 있다.
Reflection API 사용 방식
강조했듯 런타임 중에 실행한다는 부분이 중요하다. 만약 호출하려는 reponse dto 객체에 roomId라는 메서드가 있다면 해당 메서드를 자동으로 찾아서 런타임 시점에 호출해준다. 편리함이 있지만, 런타임 시점에 수행되기 때문에 당연히 오버헤드가 생길 것이라 생각했고 성능 개선에도 초점을 두기로 했다.
우선 위의 코드를 리플렉션 API로 변경하면 다음과 같이 호출이 가능하다.
private String getRoomIdFromResponse(Object response) {
try {
return (String) response.getClass().getMethod("roomId").invoke(response);
} catch (Exception e) {
throw new IllegalArgumentException("roomId를 찾을 수 없는 응답 타입: " + response.getClass().getSimpleName(), e);
}
}
Java
복사
이렇게 하면 새로운 response 타입이 추가되어도 코드 수정이 불필요해지는 장점이 생긴다.
여기서 성능 개선을 위해, 한 번 찾았던 메서드는 캐싱해서 처리하는 방법을 활용했다.
@Component
public class ReflectionUtil {
private static final Map<Class<?>, Method> methodCache = new ConcurrentHashMap<>();
public static String getRoomIdFromResponse(Object response) {
if (response == null) {
throw new IllegalArgumentException("응답 객체가 null입니다.");
}
try {
Method method = methodCache.computeIfAbsent(response.getClass(), clazz -> {
try {
return clazz.getMethod("roomId");
} catch (NoSuchMethodException e) {
throw new IllegalArgumentException("roomId를 찾을 수 없는 응답 타입: " + clazz.getSimpleName(), e);
}
});
return (String) method.invoke(response);
} catch (Exception e) {
throw new IllegalArgumentException("roomId 호출 실패: " + response.getClass().getSimpleName(), e);
}
}
}
Java
복사
우선, 캐싱된 리플렉션을 사용하기 위해 ConcurrentHashMap을 사용해서, 각 클래스별 roomId() 메서드를 캐싱한다. 이를 통해 동일한 객체가 반복적으로 호출될 때, 캐싱된 메서드를 활용하여 성능 개선이 가능하다.
이후 computeIfAbsent() 를 활용해서, response의 클래스에서 roomId()를 한 번만 찾는다.
computeIfAbsent 메서드는 아래의 식을 보면 알 수 있듯 key값이 있으면 아무런 작업 없이 value를 리턴하고, key값이 없으면 람다식을 적용한 값을 해당 key에 저장한 후, newValue를 리턴한다.
따라서 response.getClass() 값에 따라 뒤의 clazz -> 람다식을 사용하여 roomId() 메서드를 찾고, NoSuchMethod 예외처리를 해주었다. 예외처리시에는 리플렉션이 제공하는 getSimpleName() 메서드를 활용하여 해당 메서드에 대한 정보를 가져온다.
이후 method.invoke()로 가져온 메서드를 실행해준다.
이렇게 만들어서 빈으로 등록해둔 리플렉션은 아래와 같이 사용했다.
private <T> void sendMessage(T response, MessageType messageType) {
if (response == null) {
log.info("빈 응답이므로 기본 메시지를 전송합니다.");
return;
}
try {
String roomId = ReflectionUtil.getRoomIdFromResponse(response);
messagingTemplate.convertAndSend(
"/urdego/sub/" + roomId,
new WebSocketMessage<>(messageType, response)
);
} catch (IllegalArgumentException e) {
log.error("roomId 조회 실패: {}", e.getMessage());
}
}
Java
복사
위 코드는 소켓 메시지를 전송할 때, ReflectionUtil로 roomId값을 조회하고, 해당 roomId로 메시지를 전송하는 코드이다. 자동으로 매핑을 해주기에,
@MessageMapping("/room/event")
public void handleRoomEvent(WebSocketMessage<?> request) {
Object response = null;
switch (request.messageType()) {
case PLAYER_JOINED -> response = gameServiceClient.invitePlayer(objectMapper.convertValue(request.payload(), PlayerReq.class)).getBody();
case PLAYER_REMOVED -> response = gameServiceClient.removePlayer(objectMapper.convertValue(request.payload(), PlayerReq.class)).getBody();
case PLAYER_READY -> response = gameServiceClient.readyPlayer(objectMapper.convertValue(request.payload(), PlayerReq.class)).getBody();
case CONTENT_SELECTED -> response = gameServiceClient.selectContent(objectMapper.convertValue(request.payload(), ContentSelectReq.class)).getBody();
}
sendMessage(response, request.messageType());
}
Java
복사
위와 같은 다양한 MessageType을 지정하여 소켓 통신을 할 수 있었다. (옆의 objectMapper는 직렬화 과정에서 발생한 문제로 인해 감싸줌)
@PostMapping("/api/game-service/room/player/invite")
ResponseEntity<RoomPlayersRes> invitePlayer(@RequestBody PlayerReq request);
Java
복사
PLAYER_JOINED 메시지의 동작 과정을 보면,
1.
sendMessage(response, MessageType.PLAYER_JOINED) 호출
2.
ReflectionUtil.getRoomIdFromResponse(response) 호출
3.
methodCache에서 RoomPlayersRes 클래스의 roomId() 메서드 확인
4.
method.invoke(response) 실행하여 "roomId" 반환
5.
"roomId"를 구독 중인 WebSocket 채널로 메시지 전송
6.
WebSocketMessage<>(MessageType.PLAYER_JOINED, response) 객체가 JSON으로 직렬화되어 전송