[채팅3] STOMP + SpringBoot + React (2)

포테이토웅·2023년 2월 7일
0

채팅

목록 보기
6/6

STOMP의 기본 사용법을 학습하고, JWT인증을 추가한 PublicChat을 구현하였다. 현재는 WebSocket을 배우는게 큰 목적이므로 Security에 관한 내용은 생략하였다.


⚙️설정

⭐ build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.1'
    id 'io.spring.dependency-management' version '1.1.0'
    // querysl
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

group = 'com.example.woong99'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'mysql:mysql-connector-java'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    // ====================stomp======================
    implementation group: 'org.webjars', name: 'stomp-websocket', version: '2.3.3-1'
    // ====================stomp======================
    // ====================jwt========================
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    // ====================jwt========================
    // ==================querydsl=====================
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
    // ==================querydsl=====================
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
    useJUnitPlatform()
}

// ========================querydsl=======================
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

sourceSets {
    main.java.srcDir querydslDir
}

configurations {
    querydsl.extendsFrom compileClasspath
}

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileClasspath
}
// ========================querydsl=======================

⭐ StompWebSocketConfig

package com.example.woong99.stomp.config;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@EnableWebSocketMessageBroker // stomp를 사용하기 위해 선언하는 어노테이션
@Configuration
@RequiredArgsConstructor
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
    private final FilterChannelInterceptor filterChannelInterceptor;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOrigins("http://localhost:3000", "ws://localhost:3000");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/pub"); // client에서 SEND 요청을 처리
        registry.enableSimpleBroker("/sub"); // 해당 경로로 SimpleBroker를 등록. SimpleBroker는 해당하는 경로를 SUBSCRIBE하는 Client에게 메세지를 전달하는 간단한 작업을 수행
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(filterChannelInterceptor);
    }
}

🔥DTO

⭐ ChatMessageDto

package com.example.woong99.stomp.dto;

import com.example.woong99.stomp.entity.ChatMessage;
import lombok.*;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ChatMessageDto {
    private String roomId;
    private String writer;
    private String receiver;
    private String message;
    private String command;
    private String isRead;

    public static ChatMessageDto of(ChatMessage chatMessage) {
        return ChatMessageDto.builder()
                .roomId(chatMessage.getPrivateChatRoom().getId())
                .writer(chatMessage.getSender())
                .message(chatMessage.getMessage())
                .isRead(chatMessage.getIsRead())
                .build();
    }
}

⭐ PublicChatRoomDto

package com.example.woong99.stomp.dto;

import com.example.woong99.stomp.entity.PublicChatRoom;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.UUID;

@Data
@NoArgsConstructor
@Builder
public class PublicChatRoomDto {
    private String roomId;
    private String name;

    public PublicChatRoomDto(String roomId, String name) {
        this.roomId = roomId == null ? UUID.randomUUID().toString() : roomId;
        this.name = name;
    }

    public static PublicChatRoomDto from(PublicChatRoom entity) {
        return new PublicChatRoomDto(entity.getId(), entity.getName());
    }

    public PublicChatRoom toEntity(PublicChatRoomDto publicChatRoomDto) {
        return PublicChatRoom.builder()
                .id(publicChatRoomDto.getRoomId() == null ? UUID.randomUUID().toString() : publicChatRoomDto.getRoomId())
                .name(publicChatRoomDto.getName())
                .build();
    }
}

UUID.randomUUID().toString()을 이용하여 자동으로 랜덤한 방 번호를 생성되게 하였다.

🧩Entity

⭐ PublicChatRoom

package com.example.woong99.stomp.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity(name = "stomp_public_chat_room")
public class PublicChatRoom {
    @Id
    @Column(nullable = false, name = "stomp_public_chat_room_id")
    private String id;

    @Column(nullable = false, name = "stomp_public_chat_room_name")
    private String name;
}

🎺Repository & Service

⭐ PublicChatRoomRepository

package com.example.woong99.stomp.repository;

import com.example.woong99.stomp.entity.PublicChatRoom;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface PublicChatRoomRepository extends JpaRepository<PublicChatRoom, String> {
}

⭐ PublicChatRoomService

package com.example.woong99.stomp.service;

import com.example.woong99.stomp.dto.PublicChatRoomDto;
import com.example.woong99.stomp.repository.PublicChatRoomRepository;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PublicChatRoomService {
    private final PublicChatRoomRepository publicChatRoomRepository;

    public List<PublicChatRoomDto> getChatRooms() {
        return publicChatRoomRepository.findAll().stream().map(PublicChatRoomDto::from)
                .collect(Collectors.toList());
    }

    public void saveChatRoom(PublicChatRoomDto publicChatRoomDto) {
        publicChatRoomRepository.save(publicChatRoomDto.toEntity(publicChatRoomDto));
    }

}

❗Controller

⭐ PublicChatRoomController

package com.example.woong99.stomp.controller;

import com.example.woong99.stomp.dto.PublicChatRoomDto;
import com.example.woong99.stomp.service.PublicChatRoomService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/public-room")
public class PublicChatRoomController {
    private final PublicChatRoomService publicChatRoomService;

    @GetMapping()
    public ResponseEntity<List<PublicChatRoomDto>> getRooms() {
        return ResponseEntity.ok(publicChatRoomService.getChatRooms());
    }

    @PostMapping()
    public ResponseEntity<Void> saveRoom(@RequestBody PublicChatRoomDto publicChatRoomDto) {
        publicChatRoomService.saveChatRoom(publicChatRoomDto);
        return ResponseEntity.ok().build();
    }
}

⭐ StompChatController

package com.example.woong99.stomp.controller;

import com.example.woong99.stomp.dto.ChatMessageDto;
import com.example.woong99.stomp.entity.ChatMessage;
import com.example.woong99.stomp.entity.PrivateChatRoom;
import com.example.woong99.stomp.repository.ChatMessageRepository;
import com.example.woong99.stomp.repository.MemberRepository;
import com.example.woong99.stomp.repository.PrivateChatRoomRepository;
import com.example.woong99.stomp.service.ChatMessageService;
import com.example.woong99.stomp.service.PrivateChatRoomService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.MessageDeliveryException;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import org.springframework.web.socket.messaging.SessionSubscribeEvent;

import java.security.Principal;
import java.util.HashMap;

@Controller
@RequiredArgsConstructor
public class StompChatController {
    private final SimpMessagingTemplate template;
    private final HashMap<String, String> simpSessionIdMap = new HashMap<>();   // stomp에 CONNECT한 유저 정보
    private final String noticeDestination = "/sub/notice";
    private final PrivateChatRoomRepository privateChatRoomRepository;

    @MessageMapping(value = "/chat/enter")
    public void enter(ChatMessageDto message, Principal principal) {
        message.setWriter(principal.getName());
        message.setMessage(principal.getName() + "님이 채팅방에 참여하였습니다.");
        template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
    }

    @MessageMapping(value = "/chat/message")
    public void message(ChatMessageDto message, Principal principal) {
        message.setWriter(principal.getName());
        template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
    }

    @MessageMapping("/notice")
    public void noticeMessage(ChatMessageDto chatMessageDto) {
        if (chatMessageDto.getCommand().equals("SUBSCRIBE")) {
            template.convertAndSend(noticeDestination, simpSessionIdMap.values());
        }
    }

    @EventListener
    public void handleSessionConnected(SessionConnectedEvent event) {
        String simpSessionId = (String) event.getMessage().getHeaders().get("simpSessionId");

        if (event.getUser() != null) {
            Principal user = event.getUser();
            if (user != null) {
                try {
                    String username = user.getName();
                    simpSessionIdMap.put(simpSessionId, username);
                } catch (Exception e) {
                    throw new MessageDeliveryException("인증 정보가 올바르지 않습니다. 다시 로그인 후 이용해주세요.");
                }
            }
        }
    }

    @EventListener
    public void handleSessionSubscribe(SessionSubscribeEvent event) {
        String destination = (String) event.getMessage().getHeaders().get("simpDestination");
        assert destination != null;
        if (destination.equals(noticeDestination)) {
            template.convertAndSend(noticeDestination, simpSessionIdMap.values());
        }
    }

    @EventListener
    public void handleSessionDisconnect(SessionDisconnectEvent event) {
        String simpSessionId = (String) event.getMessage().getHeaders().get("simpSessionId");
        simpSessionIdMap.remove(simpSessionId);
        template.convertAndSend(noticeDestination, simpSessionIdMap.values());
    }
}
  • @MessageMapping을 통해 WebSocket으로 들어오는 메세지 발행을 처리한다.
  • Client에서 Prefix를 붙여 "/pub/chat/enter"로 발행 요청을 하면 Controller가 해당 메세지를 받아 처리하고, "/sub/chat/room/roomID"를 구독하는 모든 구독자들에게 메세지가 전송된다. roomID를 통해 방을 구분한다.
  • @EventListener를 통해 이벤트를 캐치할 수 있다.
    • SessionConnectEvent : 세션에 연결하는 경우
    • SessionConnectedEvent : 세션에 연결한 경우
    • SessionSubscribeEvent : 구독을 하는 경우
    • SessionUnsubscribeEvent : 구독을 취소하는 경우
    • SessionDisconnectEvent : 종료하는 경우
    • 사용자가 접속중인지를 확인하기 위해 /sub/notice를 전부 구독하고 있고, 사용자가 접속하면 simpSessionIdMap에 저장하고, 접속을 종료하면 세션 값을 제거하고 각 경우마다 구독한 사용자들(접속자들)에게 simpSessionIdMap을 전송한다.
  • SimpMessagingTemplate을 이용하여 메세지를 보낼 수 있지만, @SendTo@SendToUser 등과 같은 어노테이션을 통해서도 메세지를 보낼 수 있다.

사용자 인증

⭐ SecurityFilterChain

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .and()
                .authorizeHttpRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .requestMatchers("/members/login").permitAll()
                .requestMatchers("/members/signup").permitAll()
                .requestMatchers("/members/me").authenticated()
                .requestMatchers("/public-room").authenticated()
                .requestMatchers("/ws/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .apply(new JwtSecurityConfig(jwtTokenProvider));
        return http.build();
    }
  • /ws를 permitAll() 해준다.
  • STOMP over WebSocket 이라면 기본적으로 스프링은 STOMP 프로토콜 레벨의 authorization 헤더를 무시한다고 한다. 그래서 이 부분에서는 허가를 해주고 다른 부분에서 인증을 진행하기로 했다.

⭐ FilterChannelInterceptor

package com.example.woong99.stomp.config;

import com.example.woong99.stomp.security.JwtTokenProvider;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.SecurityException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageDeliveryException;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.util.Objects;

@RequiredArgsConstructor
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class FilterChannelInterceptor implements ChannelInterceptor {
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        assert headerAccessor != null;

        if (!StompCommand.UNSUBSCRIBE.equals(headerAccessor.getCommand()) && !StompCommand.DISCONNECT.equals(headerAccessor.getCommand())) {
            String authorizationHeader = String.valueOf(headerAccessor.getNativeHeader("Authorization"));
            if (authorizationHeader == null || authorizationHeader.equals("null")) {
                throw new MessageDeliveryException("로그인 후 이용해주세요.");
            }

            String token = Objects.requireNonNull(headerAccessor.getNativeHeader("Authorization")).get(0)
                    .replace("Bearer ", "");
            try {
                jwtTokenProvider.validateToken(token);
                Authentication user = jwtTokenProvider.getAuthentication(token);
                headerAccessor.setUser(user);
            } catch (MessageDeliveryException e) {
                throw new MessageDeliveryException("메세지 에러");
            } catch (SecurityException | MalformedJwtException | ExpiredJwtException | UnsupportedJwtException |
                     IllegalArgumentException e) {
                throw new MessageDeliveryException("인증 정보가 올바르지 않습니다. 다시 로그인 후 이용해주세요.");
            }
        }

        return message;
    }
}
  • ChannelInterceptor를 implements 해주고, @Order(Ordered.HIGHEST_PRECEDENCE + 99)를 통해 Spring Security의 인증보다 앞에 오게 했다.
  • UNSUBSCRIBE와 DISCONNECT 시에는 인증이 필요 없으므로 그 외의 경우에만 Header로부터 토큰을 받아 인증을 진행했다.
  • 인증시에는 미리 구현한 JwtTokenProvider를 통해 인증했고, 인증 정보가 없거나 올바르지 않은 경우에는 MessageDeliveryException을 통해 클라이언트로 에러 메세지를 전송했다.

⭐ StompWebSocketConfig

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(filterChannelInterceptor);
    }

지금까지 구현한 인증 과정을 위 메소드를 통해 등록한다.

profile
주경야독

0개의 댓글