목차
1. 아키텍처 소개
2. MSA구조에서 일반 HTTP 요청의 인증 흐름
3. WebSocket 인증의 한계
4. WebSocket 인증 흐름 설계 with. api-gateway
1. 아키텍처 소개
3월 말부터 대기열 예매 시스템을 개발하게 되었다.
백엔드, 프론트 동시에 진행중이며 현재까지의 아키텍처는 다음과 같다.
배포는 1차 목표가 끝나면 진행할 예정이라 클라우드 아키텍처는 포함하지 않았다.
유레카 디스커버리에 게이트웨이, 유저, 콘서트, 예약 서비스 등을 등록하고 모든 요청은 게이트웨이를 거치도록 설계했다.
즉 클라이언트는 게이트웨이의 주소만 알고 있다는 의미이다.
예약 서비스는 트래픽이 몰릴 걸 대비해 2개를 띄워 부하를 나눈다.
이때 게이트웨이가 트래픽을 자동으로 분산시켜준다.
일반적인 콘서트 예매 사이트에 접속해 예매하기 버튼을 누르면 현재 자신의 대기 번호가 뜬다.
먼저 이 부분을 구현하기 위해 예약 서비스에서 웹소켓(STOMP)와 redis pub/sub을 사용하고 있다.
그럼 게이트웨이를 거치는 웹소켓의 인증을 보기 전, http 요청 흐름을 먼저 보자.
2. MSA에서 일반 HTTP 요청의 인증 흐름
▼ gateway-service의 application.yml
server:
port: 8888
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka
instance:
instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}
logging:
level:
org.springframework.cloud.gateway: DEBUG
cloud:
gateway:
routes:
- id: member-for-login/signup/reissue
predicates:
- Path=/api/v1/member/login, /api/v1/member/signup, /api/v1/member/reissue
- Method=POST,GET
uri: http://localhost:8081
filters:
- RewritePath=/api/v1/(?<segment>.*), /${segment}
# 위의 uri 제외한 나머지 member 서비스의 요청 처리
- id: member-all
predicates:
- Path=/api/v1/member/**
- Method=POST,GET,PUT,DELETE,PATCH
uri: http://localhost:8081
filters:
- AuthorizationHeaderFilter
- RewritePath=/api/v1/(?<segment>.*), /${segment}
- id: concert-all
predicates:
- Path=/api/v1/concert/**
- Method=POST,GET,PUT,DELETE,PATCH
uri: http://localhost:8082
filters:
- AuthorizationHeaderFilter
- RewritePath=/api/v1/(?<segment>.*), /${segment}
# 예약 서비스 WebSocket 요청 처리
- id: lookup-queue-websocket
predicates:
- Path=/api/v1/reservation/lookup-queue
uri: http://localhost:8083
filters:
- RewritePath=/api/v1/reservation/lookup-queue, /lookup-queue
- id: reservation-all
predicates:
- Path=/api/v1/reservation/**
- Method=POST,GET,PUT,DELETE,PATCH
uri: http://localhost:8083
filters:
- AuthorizationHeaderFilter
- RewritePath=/api/v1/(?<segment>.*), /${segment}
default-filters: # 중복 응답 헤더 제거 (CORS 설정을 위해 필요)
- DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials
jwt:
secret: ${JWT_SECRET_KEY}
클라이언트에서 HTTP 헤더에 액세스 토큰을 담아 게이트웨이로 요청을 보내면 게이트웨이는 서비스에 정의된 내용을 기준으로 알맞은 서비스에 라우팅한다.
예를 들어 현재 yaml 기준으로
[POST] http://localhost:8888/api/v1/member/signup 요청을 보내면
[POST] http://localhost:8081/member/signup 으로 라우팅 해준다는 의미이다.
회원가입, 로그인, 토큰 재발급 등 몇 가지 API를 제외하고 거의 모든 요청 헤더엔 액세스 토큰이 담긴다.
그런데 게이트웨이에서 타 서비스로 바로 라우팅하면 타서비스에서 유저 서비스로 인증 요청을 보내 사용자를 식별해야 한다. 이 경우 유저 서비스의 부담이 매우 커진다.
따라서 게이트웨이에서 간단한 토큰 인증 과정을 거친다.
yaml 파일을 자세히 보면 filters 항목이 있다.
여기에 라우팅 전 해당 요청이 거쳐야 하는 필터를 정의해주면 된다.
나는 AuthorizationHeaderFilter를 거치게 설정해주었다.
▼ gateway-service의 AuthorizationHeaderFilter.java
package org.example.gatewayservice.global.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.extern.slf4j.Slf4j;
import org.example.gatewayservice.global.security.JwtProvider;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
// Gateway로 요청 들어올 때 JWT 토큰 유효성 검사하는 필터
@Slf4j
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private final JwtProvider jwtProvider;
private final ObjectMapper objectMapper = new ObjectMapper();
private static final List<String> WHITE_LIST = List.of(
"/api/v1/reservation/lookup-queue", // WebSocket endpoint
"/api/v1/reservation/lookup-queue/info" // handshake 요청
);
public AuthorizationHeaderFilter(JwtProvider jwtProvider) {
super(Config.class);
this.jwtProvider = jwtProvider;
}
public static class Config {
}
// 사용자의 헤더에 Authorization 값이 없거나 유효한 토큰이 아니라면 사용자에게 권한이 없다는 401 Unauthorized 코드를 반환
@Override
public GatewayFilter apply(Config config) {
// Prefilter
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
log.info("AuthorizationHeaderFilter Start: request -> {}", exchange.getRequest());
// 웹소켓 관련 경로는 Authorization 헤더 사용 불가
String path = request.getURI().getPath();
if (WHITE_LIST.stream().anyMatch(path::startsWith)) {
log.info("AuthorizationHeaderFilter Skipped (white list): {}", path);
return chain.filter(exchange);
}
HttpHeaders headers = request.getHeaders();
if (!headers.containsKey(HttpHeaders.AUTHORIZATION)) {
return OnError(exchange, "로그인이 필요한 서비스입니다.", HttpStatus.UNAUTHORIZED);
}
String authorizationHeader = headers.get(HttpHeaders.AUTHORIZATION).get(0);
String token = authorizationHeader.replace("Bearer ", ""); // 토큰 추출
boolean isValid = jwtProvider.validateToken(token);
if(!isValid) {
return OnError(exchange, "유효하지 않은 토큰입니다.", HttpStatus.UNAUTHORIZED);
}
String memberId = jwtProvider.getMemberId(token);
ServerHttpRequest newRequest = request.mutate()
.header("memberId", memberId)
.build();
log.info("AuthorizationHeaderFilter End");
return chain.filter(exchange.mutate().request(newRequest).build());
};
}
// Mono, Flux => Spring WebFlux
private Mono<Void> OnError(ServerWebExchange exchange, String message, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
// 에러 응답 데이터를 Map에 추가
Map<String, Object> errorResponse = Map.of(
"timestamp", LocalDateTime.now(),
"message", message,
"path", exchange.getRequest().getURI().getPath(),
"status", httpStatus.value()
);
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return Mono.fromCallable(() -> objectMapper.writeValueAsBytes(errorResponse))
.map(response.bufferFactory()::wrap)
.doOnError(e -> log.error("JSON 변환 중 오류 발생: ", e))
.flatMap(dataBuffer -> response.writeWith(Mono.just(dataBuffer))); // Mono.just로 감싸기
}
}
apply 메소드를 보면 일반적인 HTTP 요청의 경우 아래의 과정을 거친다.
(1) Authorization 헤더에서 Bearer 토큰 추출
(2) 유효성 검증 (ex. 만료되지 않았는지, 올바른 서명키로 만들어진 토큰인지)
(3) 유효한 토큰일 때 파싱하여 memberId 추출
(4) 헤더에 memberId 넣어줌
* 회원가입, 로그인 등 헤더에 토큰이 없는 요청들은 아예 해당 필터를 타지 않도록 yaml filters에 AuthorizationHeaderFilter 정의하지 않는다.
토큰 검사가 끝나면 라우팅 되어 해당하는 마이크로 서비스에서 요청을 처리한다.
3. WebSocket 인증의 한계
앞에서 언급했듯이 모든 요청은 게이트웨이를 거치며, 클라이언트는 게이트웨이 주소만 알고 있다. 그래서 웹소켓도 똑같을 줄 알았다... (삽질 상큼하게 start)
아니다 아니다 아니다 아니다.
웹소켓은 HTTP 헤더를 사용할 수 없다 🥵🥵🥵💥💥💥
웹소켓은 연결 초기 요청(핸드셰이크)은 HTTP를 사용하기 때문에 이때만 제한된 헤더를 사용할 수 있다.
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
그러나 브라우저 보안 정책 상 Authorization, X-Custom-Header 등을 사용할 수 없다.
개.큰.멘.붕.
그럼 유저를 식별할 수 있는지, 웹소켓이 있는 예약 서비스에서 인증을 한다면 게이트웨이를 쓰는 의미가 있는지, MSA 구조에서 WebSocket 쓸 때 인증 구조를 어떻게 하는지...
결론부터 말하자면 >>>>
게이트웨이에서 단순 라우팅만 해주며
웹소켓이 있는 예약 서비스에서 토큰을 파싱해 memberId를 추출하고 Principal을 생성해 유저를 식별한다.
문제라고 생각했던 것들도 대부분 해결된다.
Q1. 유저를 식별할 수 있는지?
A. 일단 게이트웨이에서 WebSocket 인증을 처리하려고 해도 물리적으로 불가능하다. (HTTP 헤더 못 쓰니까)
그래도 STOMP 헤더를 통해 토큰 전달이 가능하기 때문에 [서버에서 헤더를 읽음 → JWT 유효성 검증 → memberId 추출 → Principal 주입] 과정을 거쳐 유저를 식별할 수 있다.
Q2. 게이트웨이를 쓰는 의미가 있는지?
A. 웹소켓 연결 라우팅도 게이트웨이를 통해 연결되며, 예약 서비스는 최소 2개 이상 띄우게 되는데 이때 게이트웨이가 로드 밸런싱을 수행해주기 때문에 인증은 예약 서비스에서 하게 되더라도 게이트웨이는 여전히 서비스 흐름을 조율하는 중심 역할을 수행한다.
즉, 웹소켓 구조 특성상 인증을 게이트웨이에서 처리할 수 없고 각 서비스가 여전히 명확한 책임을 갖고 있기 때문에 MSA 원칙에도 부합한다고 할 수 있다.
4. WebSocket 인증 흐름 설계 with. api-gateway
WebSocket 인증은 게이트웨이단에서 담당하지 않는다.
고로 AuthorizationHeaderFilter를 거칠 필요가 없다. (혹은 필터를 거치더라도, 더 이상 검사하지 않고 다음 필터로 넘어가게 한다.)
그럼 WebSocket이 있는 서비스에서는 어떤 로직이 필요할까?
▼ CustomJwtInterceptor.java
package org.example.reservationservice.global.config.processor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.reservationservice.global.security.JwtProvider;
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.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.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.List;
@Slf4j
@RequiredArgsConstructor
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 99) // 해당 인터셉터의 우선순위를 최상위로 설정
public class CustomJwtInterceptor implements ChannelInterceptor {
private final JwtProvider jwtProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
// StompHeaderAccessor를 사용하여 메시지의 헤더에 접근 (STOMP 메시지 프로토콜 헤더에 접근)
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (!StompCommand.CONNECT.equals(accessor.getCommand())) {
return message;
}
List<String> authHeaders = accessor.getNativeHeader("Authorization");
String token = authHeaders.get(0);
if(token != null && jwtProvider.validateToken(token)) {
String memberId = jwtProvider.getMemberId(token);
// Spring Security의 Authentication 객체 생성 및 Security Context 등록
Authentication authentication = new UsernamePasswordAuthenticationToken(memberId, token, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
// WebSocket 연결에서 사용자 정보 설정 (STOMP 세션에 사용자 정보 설정)
accessor.setUser(authentication);
} else {
throw new IllegalArgumentException("Invalid JWT token");
}
log.info("CustomJwtInterceptor 끝!");
return message;
}
}
해당 인터셉터는 클라이언트가 웹소켓 연결을 시도할 때 요청을 가로챈다.
Stomp 헤더 > Authorization에서 토큰을 뽑아 유효성을 검사하고 memberId를 추출한다.
1. Spring Security Context에 인증 정보를 등록
2. Stomp 세션에 인증 정보 등록
1-2번의 과정을 통해 Principal을 주입 받을 수 있게 된다!
▼ WebSocketConfig.java
package org.example.reservationservice.global.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.reservationservice.global.config.processor.CustomJwtInterceptor;
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;
@Configuration
@EnableWebSocketMessageBroker // 웹소켓 메시징을 활성화
@RequiredArgsConstructor
@Slf4j
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final CustomJwtInterceptor customJwtInterceptor;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue"); // 구독할 경로 정의
config.setApplicationDestinationPrefixes("/app"); // 클라이언트에서 서버로 메시지를 보낼 때 "/app"을 붙여야 함
config.setUserDestinationPrefix("/user"); // "/user"는 클라이언트가 개인 메세지를 받을 때 사용됨
}
// 클라이언트가 연결할 WebSocket 엔드포인트 설정
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/reservation/lookup-queue") // 클라이언트가 여기로 연결
.setAllowedOriginPatterns("*") // Cors 허용
.withSockJS(); // WebSocket을 지원하지 않는 브라우저용 fallback
}
// 클라이언트로 들어오는 메시지에 대한 설정
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(customJwtInterceptor);
}
}
해당 클래스는 웹소켓 설정 파일로 크게 3개의 메소드가 존재한다.
- configureMessageBroker : 메세지 브로커를 설정
- registerStompEndpoints : Stomp 엔드포인트를 등록
- withSockJs() : 웹소켓을 지원하지 않는 브라우저에서는 SockJS를 사용해 웹소켓을 대체함
- configureClientInboundChannel : 클라이언트로부터 들어오는 메세지를 처리 (★★★★★)
- JWT 인증을 처리하는 인터셉트를 매개변수로 설정
▼ SecurityConfig.java
package org.example.reservationservice.global.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import java.util.Arrays;
import java.util.Collections;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
public static final String[] PUBLIC_URLS = { "/**" };
private String[] allowedOrigins = { "*" };
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors((cors) -> cors
.configurationSource(request -> {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins));
configuration.setAllowedMethods(Collections.singletonList("*"));
configuration.setAllowCredentials(true);
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setMaxAge(3600L);
configuration.setExposedHeaders(Collections.singletonList("Authorization"));
return configuration;
}));
http.authorizeHttpRequests((auth) -> auth
.requestMatchers(PUBLIC_URLS).permitAll()
.requestMatchers("/ws/**", "/reservation/lookup-queue/**").authenticated() // 경로 허용 추가
.anyRequest().authenticated()
);
return http.build();
}
}
해당 클래스는 인증 서비스에 있는 SecurityConfig와는 다른 파일로, 예약 서비스(웹소켓을 사용하는 서비스)에 위치하고 있다. 그럼 인증 서비스도 아닌 주제에 웹소켓을 인증하기 위해 SecurityConfig가 필요한 이유가 뭘까?
1. 웹소켓도 최초 연결은 HTTP 요청을 통한 핸드셰이크
웹소켓 연결은 처음에 HTTP 요청을 통해 핸드셰이크가 이뤄진다.
이때 Client - Server 간 연결이 설정되기 때문에 HTTP에 대한 보안 설정이 필요하다.
2. 웹소켓 연결 전 인증
웹소켓 연결을 시작할 때 인증된 사용자만 접근하도록 하고 싶을 때, 해당 요청의 인증 및 권한 부여 단계를 설정한다.
http.authorizeHttpRequests((auth) -> auth
.requestMatchers("/ws/**", "/reservation/lookup-queue/**").authenticated() // 경로 허용 추가
);
또, 핸드셰이크 이후 메세지 처리에서는 WebSocketSecurityConfig에서 세부적인 메세지 보안을 관리할 수 있다.
먼저 build.gradle에 아래와 같은 의존성을 추가한다.
implementation 'org.springframework.security:spring-security-messaging'
▼ WebSocketSecurityConfig.java
package org.example.reservationservice.global.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.messaging.MessageSecurityMetadataSourceRegistry;
import org.springframework.security.config.annotation.web.socket.AbstractSecurityWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
messages
.nullDestMatcher().permitAll()
.simpDestMatchers("/app/auth").authenticated()
.simpSubscribeDestMatchers("/topic/**", "/**/queue/**").authenticated()
.simpDestMatchers("/reservation/lookup-queue/**").authenticated()
.anyMessage().denyAll();
}
@Override
protected boolean sameOriginDisabled() {
return true;
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web
.ignoring()
.requestMatchers("/reservation/*/lookup/join"); // /reservation/** 경로 허용
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry.setMessageSizeLimit(64 * 1024); // 메시지 크기 설정
registry.setSendBufferSizeLimit(512 * 1024); // 전송 버퍼 크기 설정
registry.setSendTimeLimit(20 * 1000); // 전송 타임아웃 설정
}
}
크게 4개의 메소드가 존재한다.
- configureInbound : 메세지 인증 규칙을 설정
- /app/auth = 해당 경로로 메세지 보내려면 인증 필요
- /topic = 특정 토픽 구독도 인증 필요
- .anyMessage().denyAll() = 그 외 모든 메세지는 차단
→ 웹소켓 연결됐더라도, 인증되지 않은 사용자는 특정 채널을 구독하거나 메세지를 보낼 수 없도록 설정해주는 메소드라고 할 수 있다.
- sameOriginDisabled : origin 검사를 비활성화
- 웹소켓은 기본적으로 same-origin policy에 민감함
- webSecurityCustomizer : HTTP 수준에서 특정 경로 무시
- SecurityFilter 아예 통과 X (필터 체인 안거친단 뜻)
- 웹소켓과 관련 없는 나머지 API는 이미 게이트웨이에서 토큰 검증 과정을 거쳤기 때문에 해당 서비스에서 필터를 거칠 필요가 없음
- configureWebSocketTransport : 웹소켓 성능 설정
- 메세지 크기 제한
- 버퍼 사이즈
- 전송 시간 제한
정리하자면 SecurityConfig는 연결 자체를 보호하고, WebSocketSecurityConfig는 메세지 흐름을 보호한다.
▼ ChatController.java
package org.example.reservationservice.domain.reservation.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.reservationservice.domain.reservation.dto.ChatDto;
import org.example.reservationservice.domain.reservation.service.SeatLookupQueueService;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import java.security.Principal;
@Controller
@RequiredArgsConstructor
public class ChatController {
private final SeatLookupQueueService seatLookupQueueService;
private final SimpMessagingTemplate messagingTemplate;
@MessageMapping("/auth")
public void authenticate(Principal principal, @Payload ChatDto.Request request) {
String memberId = principal.getName();
String concertId = request.getConcertId();
// 대기열에서 사용자의 순번 조회
int position = seatLookupQueueService.getQueuePosition(concertId, memberId);
log.info("대기 위치 조회. memberId={}, position={}", memberId, position);
// 개인 채널로 위치 전송
messagingTemplate.convertAndSendToUser(
memberId,
"/queue/lookup-position",
String.valueOf(position)
);
}
}
authenticate 메소드는 웹소켓 통신에서 인증된 사용자의 대기 순번을 조회하고, 그 정보를 사용자에게 전송한다.
물론 클라이언트에서는 /app/auth로 요청을 보낸다.
그럼 해당 메소드의 @MessageMapping("/auth") → /app/auth로 해석하여 해당 경로로 들어온 요청을 처리한다.
이때 매개변수 Principal로 웹소켓 핸드셰이크할때 인증된 사용자 정보(세션에 등록해둠)를 가져올 수 있다.
Spring Security에서 인증된 사용자가 있으면 자동으로 Authentication.getPrincipal()로 꺼내서 주입해주며
가장 기본적인 사용자 식별 정보 (getName() = memberId)만 들어있다.
나는 더도덜도 말고 딱 memberId만 필요했지만
만약 더 자세한 사용자 정보가 필요하다면 UserDetails 구현체를 만들고 @AuthenticationPrincipal로 해당 객체를 주입받으면 된다.
필요한 로직을 처리하고 최종적으로 사용자 개인 채널로 메세지를 보내고 싶을 때
messagingTemplate.convertAndSendToUser(
memberId,
"/queue/lookup-position",
String.valueOf(position)
);
다음과 같이 작성한다.
memberId로 사용자 개인 채널을 식별하고, 해당 메세지는 클라이언트에서 /user/queue/lookup-position으로 구독한다.
이때 사용자 개인 채널을 식별한다는 의미는 Spring에서 현재 인증된 사용자의 웹소켓 세션을 자동으로 구분할 수 있다는 뜻이다.
▼ webSocketClient.js
export class WebSocketClient {
constructor(onMessageCallback, concertId) {
this.onMessageCallback = onMessageCallback;
this.concertId = concertId;
this.stompClient = null;
}
connect() {
// SockJS를 이용해 WebSocket 서버에 연결
const token = localStorage.getItem("accessToken");
const socket = new SockJS(window.config.globalUrl + '/reservation/lookup-queue');
// Stomp 라이브러리를 WebSocket 위에 올림
this.stompClient = Stomp.over(socket);
this.stompClient.connect(
{ Authorization: token }, () => {
console.log("Connected to server!!");
const authPayload = JSON.stringify({
concertId: this.concertId
});
this.stompClient.send("/app/auth", { Authorization : token }, authPayload);
// 개인 메시지 수신용 채널 구독
// 예약 서비스에서 자동으로 /user 붙여서 처리하므로 아래와 같이 구독
this.stompClient.subscribe('/user/queue/lookup-position', (message) => {
const position = message.body;
console.log("내 대기 번호:", position);
this.onMessageCallback(position);
});
}, (error) => {
console.error("STOMP WebSocket Error:", error);
});
}
}
클라이언트에서는 위와 같이 작성하여 웹소켓 연결부터 메세지 구독까지 진행하며
콘솔창에 웹소켓 연결, 메세지 발송, 메세지 구독에 관련한 로그가 찍힌다.
특히 << CONNECTED에서 user-name으로 principal.getName 값인 memberId가 주입된 것을 확인할 수 있다.
이상으로 포스팅을 마친다.
관련 코드는 아래 주소에서 확인할 수 있다!
https://github.com/aeeazip/TickQueue-Backend
'Framework > Spring' 카테고리의 다른 글
[AWS] S3 활용한 SpringBoot 파일 업로드 프로젝트 (0) | 2023.12.15 |
---|---|
[SpringBoot] Swagger 3.0 + 전역적 Bearer 토큰 적용 (0) | 2023.10.31 |
[SpringBoot] Naver CLOVA Sentiment를 활용한 감정분석 (0) | 2023.08.30 |
[SpringBoot] ChatGPT를 활용한 API 작성 (0) | 2023.08.21 |
[SpringBoot] JPA ConverterNotFoundException 에러 (0) | 2023.05.16 |