틀린 부분이 많아요! 참고하고 보셨으면 좋겠습니다.
지난 STOMP의 후속작이다. 이번 내용은 이전 프로젝트와는 다르게, 서버를 Rest 서버로 생성할 예정이다.
여기서부터 많이 복잡해진다.
요구사항은 다음과 같다.
위의 요구사항을 구현하면서 많은 고민이 생겼다.
이전에 구현한 내용은 다음의 순서로 로직이 수행된다.
채팅방 입장부터 고민이 많았다. 채팅방이라는 Pool을 어떻게 정의를 할까..
맞다. 이렇게 구현하기로 했다.
그에 해당하는 로직을 팀원과 상의해서 다음과 같이 정의했다.
입장 성공 응답을 받았을 때, 소켓 연결을 신청하는 것이다.
우리 서비스는 JWT를 이용한다. 소켓을 이용할 때 또한 JWT를 이용하기로 했다.
JWT는 다음 3가지 상황에서 이용한다. (Header에 “Authorization” 추가)
이제 하나씩 살펴보자!
우선 다음 그림을 보자.
위 그림을 보면
로 세 가지의 상호 작용을 볼 수 있다.
소켓은 아주아주 편하다. 연결할 때는 HTTP로 수행되기 때문에 응답하기가 너무너무 편하다.
이미 구현해놓은 스프링 시큐리티 JWT 필터만 태우면 된다!
ok 소켓 연결 및 검증 부분 구상 완료.
사용자가 메세지를 보낼 때도 그 메세지가 인증된 사용자가 보냈는지 확인을 해야 한다. 그러기 위해서는 구독 및 발행 마다 해당 사용자를 검증해야한다.
그러기 위해서는 구독 및 발행 마다 Header에 “Authorization”을 추가하여 서버에 보내주어야 할 것이다. 그러기 위해서는 스프링에서는 어떻게 해야할까?
여기부터 복잡해진다. 소켓 연결과는 다르게 HTTP가 아닌 WS상에서 통신을 한다. 그래서 구현을 이미 해놓은 스프링 시큐리티 JWT 필터를 태우기가 애매하다.(jwt 필터는 HttpServletResponse 객체를 다루기 때문) 그래서 다른 인터셉터를 구현해야 한다.
어쨌든 !
STOMP도 헤더를 포함하기 때문에 Header에 Authorization을 추가하여 검증을 하기로 했다.
여기에 엄청나게 많은 고민이 있었다.
임의로 구현하여 개발자 도구를 엄청나게 만져보니까 힌트를 얻었다. 다음 그림을 보자.
SEND 커맨드를 통해 메세지를 발행했다. 올바르지 않은 토큰에 대한 ERROR 커맨드 메세지를 응답하는 것을 볼 수 있다.
저 ERROR를 커스텀하여 프론트엔드에게 알려주자!!
먼저 나는 백엔드 직무를 하기 때문에 백엔드 로직만 다루겠다!
앞서 올린 글에 이어서 구현할 예정이다.
https://velog.io/@jkijki12/STOMP-Spring-Boot
구현 순서는 다음과 같다.
SecurityConfig 수정
.antMatchers("/api/websocket").authenticated()
.antMatchers("/api/websocket").authenticated()
: 해당 URL로 요청이 올 경우 JWT 검사를 수행한다는 내용이다.소켓은 완료되었다.
PreHandler란?
메세지를 발행하기 전, 메세지에 대한 전처리를 수행하는 Handler이다.
Spring 에서는 STOMP 메세지에 대한 전처리의 커스텀을 제공한다.
그것을 알기 위해서는 3가지의 용어를 알아야한다.
우리는 인터셉터를 구현하여 채널을 가로채서 필요한 검증을 할 수 있다.
커스텀을 시작하자.
InBoundChannel을 가로채서 JWT 검증을 하자.
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class SocketConfig implements WebSocketMessageBrokerConfigurer {
private final ChatPreHandler chatPreHandler;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/api/websocket").setAllowedOriginPatterns("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue", "/topic");
registry.setApplicationDestinationPrefixes("/api");
}
// 여기 아래 부분 코드 추가
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(chatPreHandler);
}
}
인터셉터를 추가해주었다.
@RequiredArgsConstructor
@Component
public class ChatPreHandler implements ChannelInterceptor {
private final JwtService<User> jwtService;
private final JwtProperties jwtProperties;
private static final String BEARER_PREFIX = "Bearer ";
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
// 헤더 토큰 얻기
String authorizationHeader = String.valueOf(headerAccessor.getNativeHeader("Authorization"));
// 토큰 자르기 fixme 토큰 자르는 로직 validate 로 리팩토링
if(authorizationHeader == null || authorizationHeader.equals("null")){
throw new MessageDeliveryException("메세지 예외");
}
String token = authorizationHeader.substring(BEARER_PREFIX.length());
// 토큰 인증
Claims claims;
try{
claims = jwtService.verifyToken(token, jwtProperties.getAccessTokenSigningKey());
}catch (MessageDeliveryException e){
throw new MessageDeliveryException("메세지 에러");
}catch (MalformedJwtException e){
throw new MessageDeliveryException("예외3");
}
return message;
}
}
해당 인터셉터는 메세지가 서버에 도착해서 발행이 되기 전에 동작하는 인터셉터이다.
그럼 이제 예외를 터트린 상황을 구현해야한다. 어떻게 구현할까?
이 또한, Spring에서 제공해준다.
소켓에서 예외가 터졌을 경우를 위하여 Handler를 등록할 수 있게 제공한다.
SocketConfig 수정
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/api/websocket").setAllowedOriginPatterns("*").withSockJS();
//아래 코드 추가
registry.setErrorHandler(chatErrorHandler);
}
소켓 통신 중, 예외가 발생했을 때 “chatErrorHandler”로 제어권이 넘어간다.
@Component
public class ChatErrorHandler extends StompSubProtocolErrorHandler {
public ChatErrorHandler() {
super();
}
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]>clientMessage, Throwable ex)
{
Throwable exception = new MessageDeliveryException("abc");
if (exception instanceof MessageDeliveryException)
{
return handleUnauthorizedException(clientMessage, exception);
}
return super.handleClientMessageProcessingError(clientMessage, ex);
}
private Message<byte[]> handleUnauthorizedException(Message<byte[]> clientMessage, Throwable ex)
{
ApiError apiError = new ApiError(
ex.getMessage());
return prepareErrorMessage(clientMessage, apiError, String.valueOf(ErrorCodeConstants.UNAUTHORIZED_STRING));
}
private Message<byte[]> prepareErrorMessage(Message<byte[]> clientMessage, ApiError apiError, String errorCode)
{
String message = apiError.getErrorMessage();
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setMessage(errorCode);
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.getBytes(StandardCharsets.UTF_8), accessor.getMessageHeaders());
}
}
예외가 발생했을 시,
코드가 완벽하지 않지만, 임의로 동작하도록 구현했다. (추 후에 예외 종류에 따른 로직을 분기하자.)
이렇게 구현했을 경우 JWT에러가 터지면 어떻게 동작하는지 살펴보자.
에러에 따른 메세지가 소켓으로 응답 되는 것을 볼 수 있다.
해당 로직을 구현하면서 알아낸 것을 살펴보자.
ErrorHandler에서 Message Command 내용을 바꾸면 다르게 동작한다.
// 에러 커맨드
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
// 메세지 커맨드
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.MESSAGE);
// 발행 커맨드
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SEND);
커맨드들이 많이 존재한다.
STOMP는 커맨드에 따라 다르게 로직이 수행된다.
몇개는 잘 모르겠다.. 레퍼런스를 찾아보았으나 포기.
어쨌든 커맨드에 따라 STOMP의 일이 달라진다.
(ex . SEND일 경우 PreHandler를 새롭게 타게 된다.)
상황에 따라 다른 예외가 던져진다. 그리고 예외에 따른 Error 응답이 달라진다.
응답을 다르게 하기 위해서는 각 종류의 예외를 체크할 수 있어야 한다.
먼저 어떻게 예외가 던져지는지 살펴보자.
ErrorHandler 수정
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]>clientMessage, Throwable ex)
{
Throwable exception = ex;
if(exception instanceof MalformedInputException){
log.info("멀폼 익셉션");
return handleUnauthorizedException(clientMessage, exception);
}
if(exception instanceof JwtExpiredTokenException){
log.info("만료 익셉션");
return handleUnauthorizedException(clientMessage, exception);
}
if (exception instanceof MessageDeliveryException)
{
log.info("예외 내용 = {}", exception.getMessage());
log.info("예외 내용의 내용 = {}", exception.getCause().getMessage());
return handleUnauthorizedException(clientMessage, exception);
}
return super.handleClientMessageProcessingError(clientMessage, ex);
}
log를 찍어보면 다음과 같다.
중요한 사실을 알 수 있다.
MessageDeliveryException 속에 MalformedJwtException이 포함되어 있다.
무조건 MessageDeliveryException이 터진다.
우리는 속에 포함되어있는 실제 Exception을 캐치해야한다.
근데 Exception이 Object로 구현되어 있어서인지.. instance로 캐치하기는 힘들다. 그래서 지정해준 메세지로 등록을 해주어야 할 듯 하다.
ChatErrorHandler 수정
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]>clientMessage, Throwable ex)
{
if(ex.getCause().getMessage().equals("JWT")){
return handleJwtException(clientMessage, ex);
}
if(ex.getCause().getMessage().equals("Auth")){
return handleUnauthorizedException(clientMessage, ex);
}
return super.handleClientMessageProcessingError(clientMessage, ex);
}
Prehandler 수정
@RequiredArgsConstructor
@Component
public class ChatPreHandler implements ChannelInterceptor {
private final JwtService<User> jwtService;
private final JwtProperties jwtProperties;
private static final String BEARER_PREFIX = "Bearer ";
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
// 헤더 토큰 얻기
String authorizationHeader = String.valueOf(headerAccessor.getNativeHeader("Authorization"));
String command = String.valueOf(headerAccessor.getHeader("stompCommand"));
// 토큰 자르기 fixme 토큰 자르는 로직 validate 로 리팩토링
if(!command.equals("SEND")){
return message;
}
if(authorizationHeader == null || authorizationHeader.equals("null")){
throw new MalformedJwtException("JWT");
}
String token = authorizationHeader.substring(BEARER_PREFIX.length());
// 토큰 인증
Claims claims;
try{
claims = jwtService.verifyToken(token, jwtProperties.getAccessTokenSigningKey());
}catch (JwtExpiredTokenException e){
throw new MessageDeliveryException("JWT");
}catch (MalformedJwtException e){
throw new MalformedJwtException("JWT");
}catch (JwtModulatedTokenException e){
throw new JwtModulatedTokenException("JWT");
}
// Principal로 담을 예정
User verifiedUser = jwtService.convertUserModel(claims);
return message;
}
}
이제 예외 상황을 캐치할 수 있다. 이제 캐치한 예외에 따른 Message를 생성해주자.
그럼 이제, Client에게 줄 Error를 커스텀해보자.
메세지 인터페이스는 다음과 같다.
public interface Message<T> {
/**
* Return the message payload.
*/
T getPayload();
/**
* Return message headers for the message (never {@code null} but may be empty).
*/
MessageHeaders getHeaders();
}
메세지 객체 구현 객체는 2가지가 있다.
ErrorMessage를 살펴보자.
public class ErrorMessage extends GenericMessage<Throwable> {
private static final long serialVersionUID = -5470210965279837728L;
@Nullable
private final Message<?> originalMessage;
/**
* Create a new message with the given payload.
* @param payload the message payload (never {@code null})
*/
public ErrorMessage(Throwable payload) {
super(payload);
this.originalMessage = null;
}
/**
* Create a new message with the given payload and headers.
* The content of the given header map is copied.
* @param payload the message payload (never {@code null})
* @param headers message headers to use for initialization
*/
public ErrorMessage(Throwable payload, Map<String, Object> headers) {
super(payload, headers);
this.originalMessage = null;
}
/**
* A constructor with the {@link MessageHeaders} instance to use.
* <p><strong>Note:</strong> the given {@code MessageHeaders} instance
* is used directly in the new message, i.e. it is not copied.
* @param payload the message payload (never {@code null})
* @param headers message headers
*/
public ErrorMessage(Throwable payload, MessageHeaders headers) {
super(payload, headers);
this.originalMessage = null;
}
/**
* Create a new message with the given payload and original message.
* @param payload the message payload (never {@code null})
* @param originalMessage the original message (if present) at the point
* in the stack where the ErrorMessage was created
* @since 5.0
*/
public ErrorMessage(Throwable payload, Message<?> originalMessage) {
super(payload);
this.originalMessage = originalMessage;
}
/**
* Create a new message with the given payload, headers and original message.
* The content of the given header map is copied.
* @param payload the message payload (never {@code null})
* @param headers message headers to use for initialization
* @param originalMessage the original message (if present) at the point
* in the stack where the ErrorMessage was created
* @since 5.0
*/
public ErrorMessage(Throwable payload, Map<String, Object> headers, Message<?> originalMessage) {
super(payload, headers);
this.originalMessage = originalMessage;
}
/**
* Create a new message with the payload, {@link MessageHeaders} and original message.
* <p><strong>Note:</strong> the given {@code MessageHeaders} instance
* is used directly in the new message, i.e. it is not copied.
* @param payload the message payload (never {@code null})
* @param headers message headers
* @param originalMessage the original message (if present) at the point
* in the stack where the ErrorMessage was created
* @since 5.0
*/
public ErrorMessage(Throwable payload, MessageHeaders headers, Message<?> originalMessage) {
super(payload, headers);
this.originalMessage = originalMessage;
}
/**
* Return the original message (if available) at the point in the stack
* where the ErrorMessage was created.
* @since 5.0
*/
@Nullable
public Message<?> getOriginalMessage() {
return this.originalMessage;
}
@Override
public String toString() {
if (this.originalMessage == null) {
return super.toString();
}
return super.toString() + " for original " + this.originalMessage;
}
}
많을 것을 볼 필요는 없고.. “GenericMessage”를 상속받은 것을 알 수 있다.
그럼 GenericMessage를 살펴보자.
public class GenericMessage<T> implements Message<T>, Serializable {
private static final long serialVersionUID = 4268801052358035098L;
private final T payload;
private final MessageHeaders headers;
/**
* Create a new message with the given payload.
* @param payload the message payload (never {@code null})
*/
public GenericMessage(T payload) {
this(payload, new MessageHeaders(null));
}
/**
* Create a new message with the given payload and headers.
* The content of the given header map is copied.
* @param payload the message payload (never {@code null})
* @param headers message headers to use for initialization
*/
public GenericMessage(T payload, Map<String, Object> headers) {
this(payload, new MessageHeaders(headers));
}
/**
* A constructor with the {@link MessageHeaders} instance to use.
* <p><strong>Note:</strong> the given {@code MessageHeaders} instance is used
* directly in the new message, i.e. it is not copied.
* @param payload the message payload (never {@code null})
* @param headers message headers
*/
public GenericMessage(T payload, MessageHeaders headers) {
Assert.notNull(payload, "Payload must not be null");
Assert.notNull(headers, "MessageHeaders must not be null");
this.payload = payload;
this.headers = headers;
}
@Override
public T getPayload() {
return this.payload;
}
@Override
public MessageHeaders getHeaders() {
return this.headers;
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
}
if (!(other instanceof GenericMessage)) {
return false;
}
GenericMessage<?> otherMsg = (GenericMessage<?>) other;
// Using nullSafeEquals for proper array equals comparisons
return (ObjectUtils.nullSafeEquals(this.payload, otherMsg.payload) && this.headers.equals(otherMsg.headers));
}
@Override
public int hashCode() {
// Using nullSafeHashCode for proper array hashCode handling
return (ObjectUtils.nullSafeHashCode(this.payload) * 23 + this.headers.hashCode());
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder(getClass().getSimpleName());
sb.append(" [payload=");
if (this.payload instanceof byte[]) {
sb.append("byte[").append(((byte[]) this.payload).length).append(']');
}
else {
sb.append(this.payload);
}
sb.append(", headers=").append(this.headers).append(']');
return sb.toString();
}
}
2가지의 변수가 존재한다.
이것을 보면 우리는 payload와 header를 커스텀하면 되는 것을 알 수 있다.
그럼 커스텀 해보자!
먼저 어떻게 예외 상황을 응답할지 부터 보자.
AccessToken 만료 및 변조 예외
ERROR
message:{
status: {
code : 1302
message : "인증 토큰이 만료되었습니다."
}
}
content-length:~
(body 없음)
0000
그 외 예외(헤더 부적합, 헤더 요소 부족, 기타 예외)
ERROR
message:{
status: {
code : 1303
message : "기타 오류입니다."
}
}
content-length:~
(body 없음)
0000
메세지를 생성하기 위해서는 “MessageBuilder”를 사용하는 것이 편리하다.
해당 클래스에서 “createMessage” 메소드가 static으로 선언되어 있다. 이 메소드를 이용하자.
MessageBuilder.createMessage(code.getBytes(StandardCharsets.UTF_8), accessor.getMessageHeaders())
위의 메소드를 이용한 전체 코드를 보자!
@Component
public class ChatErrorHandler extends StompSubProtocolErrorHandler {
public ChatErrorHandler() {
super();
}
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]>clientMessage, Throwable ex)
{
if(ex.getCause().getMessage().equals("JWT")){
return handleJwtException(clientMessage, ex);
}
if(ex.getCause().getMessage().equals("Auth")){
return handleUnauthorizedException(clientMessage, ex);
}
return super.handleClientMessageProcessingError(clientMessage, ex);
}
// 권한 예외(해당 방에 접속하지 않았을 시)
private Message<byte[]> handleUnauthorizedException(Message<byte[]> clientMessage, Throwable ex)
{
ApiError apiError = new ApiError(
ex.getMessage());
return prepareErrorMessage(RoomCode.NOT_PARTICIPATE_ROOM);
}
// JWT 예외
private Message<byte[]> handleJwtException(Message<byte[]> clientMessage, Throwable ex){
return prepareErrorMessage(JwtErrorCode.ACCESS_TOKEN_EXPIRATION);
}
// 메세지 생성
private Message<byte[]> prepareErrorMessage(ResponseCode responseCode)
{
String code = String.valueOf(responseCode.getMessage());
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setMessage(String.valueOf(responseCode.getCode()));
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(code.getBytes(StandardCharsets.UTF_8), accessor.getMessageHeaders());
}
}
이렇게 구현하면 예외 응답으로 다음과 같이 온다!
완료.
너무너무 고마워요!!!