안녕하세요. 이번 포스팅에서는 최근 레거시 스프링 프로젝트를 이용하여 진행했던 '함께 결제' 프로젝트에서 사용한 웹소켓과 STOMP에 대해 포스팅 해보려고 합니다.
❗❗❗❗❗ 스프링 부트가 아닌, 스프링 레거시 프레임워크만 사용한 프로젝트 입니다 ❗❗❗❗❗
스프링 버전 : 5.3.37
스프링 시큐리티 : 5.8.13
Tomcat : 9.0.912
Build : Gradle War
레거시 스프링이긴 하지만, 현재 포스팅하는 부분에 관해서는 스프링 부트와 크게 다르다고 느끼진 않았습니다. 지피티를 잘 활용하여 참고하신다면 부트에도 금방 적용 가능합니다.
함께 결제 프로젝트에 대해 간략하게 설명 드리면, 결제 방에 입장한 유저들은 자신이 결제할 메뉴를 선택하여 결제 후 정산이 아닌, 결제 시점에 따로 결제하는 프로젝트입니다. 이때, 같은 메뉴를 선택하거나 메뉴 선택시 수량을 초과, 최종 결제 금액이 맞지 않는 등의 문제를 해결하기 위해 실시간으로 유저들이 진행하는 상태를 업데이트 해주어야 했습니다.
이 요구사항을 맞추기 위해, 여태까지 진행했던 과정을 포스팅하려고 합니다. 프로젝트가 끝나고 회상 느낌으로 적는 글이라, 정확하지 않을 수 있는 점 양해 부탁드립니다.
상태를 실시간으로 업데이트 받기
현재 유저가 입장한 주문 방의 상태를 모두 업데이트 받기 위해, 처음에는 매우 짧은 시간 단위로 api를 호출하여 주문 방의 상태를 지속적으로 조회하도록 로직을 구현해보았습니다.
한 사람은 무리가 없었지만 저희 서버는 클라우드에서 그렇게 좋은 성능을 가진 서버가 아니기 때문에, 여러명이 참여할 경우 속도가 저하되는 현상이 발생하였습니다. 네트워크 상에서 호출되는 api양이 매우 많아서도 그렇겠지만, 주문 방 자체도 DB에 있어 쿼리가 날아가는 수가 매우 많아졌기 때문이라고 생각하였습니다. 추가로 JWT 토큰을 사용하기 때문에, 매우 짧은 시간 안에 매번 인가 를 진행하고 DB를 조회하는 로직 또한 성능 저하에 큰 영향을 끼쳤을 것으로 생각하였습니다.
그래서, 이 문제를 해결하고자 처음 생각했던 것이 단순 웹 소켓을 적용해 불필요한 API 호출 횟수를 감소시키는 것 이였습니다. 다만 저희 서비스는 단순히 일대일, 하나의 결제 방이 존재하는 것이 아닌, 여러 유저가 동시에 통신해야 하며 여러 결제 방이 주문에 따라 존재해야 하는 요구사항이 있었습니다.
WebSocket만으로도 소켓 서버를 완성할 수 있으나, 단순한 통신 구조로 인해 WebSocket만을 이용해 결제 방을 구현하면 해당 메세지가 어떤 요청인지, 어떻게 처리해야 하는지에 따라 결제방과 세션을 일일히 구현하고 메세지 발송 부분을 관리하는 추가 코드를 구현해주는 등 불편하고 복잡한 점이 많았습니다.
또한 저희 서비스는 금융 서비스로서 보안이 전제가 되어야 하는데, WebSocket만을 이용하면 통신 과정에서 인증과 인가를 적용하기 어려웠습니다.
위 문제들을 해결하기 위해, 저는 STOMP를 이용하여 실시간 통신을 구현하기로 결정하였습니다.
STOMP란?
STOMP (Simple Text Oriented Messaging Protocol)는 메세징 전송을 효율적으로 하기 위해 탄생한 프로토콜입니다. 기본적으로 pub / sub(발행 / 구독) 구조로 되어있어 메세지를 전송하고 메세지를 받아 처리하는 부분이 확실히 정해져 있기 때문에 개발자 입장에서 명확하게 인지하고 개발할 수 있는 이점이 있습니다. 즉, STOMP 프로토콜은 WebSocket 위에서 동작하는 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 매커니즘입니다.
또한 위에 잠깐 언급했듯이, STOMP를 이용하면 메세지의 헤더 및 바디에 값을 줄 수 있어 헤더 값을 기반으로 통신 시 인증 처리를 구현하는 것도 가능하며 STOMP 스펙에 정의한 규칙만 잘 지키면 여러 언어 및 플랫폼 간 메세지를 상호 운영할 수 있습니다.
STOMP는 TCP 또는 WebSocket 같은 양방향 네트워크 프로토콜 기반으로 동작합니다. 이름에서 알 수 있듯, STOMP는 Text 지향 프로토콜이나, Message Payload에는 Text or Binary 데이터를 포함 할 수 있습니다.
위에서 언급한 pub / sub란 메세지를 공급하는 주체와 소비하는 주체를 분리하여 제공하는 메시징 방법입니다. 우체통을 예를 들면, 특정 집의 우체통을 Topic 에 비교할 수 있습니다. 또한 우편을 배달하는 배달부는 Publisher, 우편이 도착하면 우편을 빼서 읽는 구독자를 Subscriber에 빗댈 수 있습니다. 이때 구독자는 한명이 아니라, 다수의 사람일 수 있습니다.
함께 결제 방 생성 : pub / sub 구현을 위한 Topic 생성
함께 결제 방 입장 : 특정 Topic을구독
함께 결제 방에서 상태를 송수신 : 해당 Topic으로 메세지를 송신(pub), 수신(sub)
클라이언트는 메세지를 전송하기 위해 SEND, SUBSCRIBE 명령어를 사용할 수 있습니다. 이런 명령어들은 destination 헤더를 요구하는데 이것이 어디에 전송할지, 혹은 어디에서 메세지를 구독할 것 인지를 나타냅니다.
위와 같은 과정을 통해 STOMP는 Publish-Subscribe 매커니즘을 제공합니다. 즉 Broker를 통해 타 사용자들에게 메세지를 보내거나 서버가 특정 작업을 수행하도록 메세지를 보냅니다. Spring에서 지원하는 STOMP를 사용하면 Spring WebSocket 어플리케이션은 STOMP Broker로 동작합니다.
STOMP의 장점
Spring framework 및 Spring Security는 STOMP 를 사용하여 WebSocket만 사용할 때보다 더 다채로운 모델링을 할 수 있습니다.
- Messaging Protocol을 만들고 메세지 형식을 커스터마이징 할 필요가 없다.
- RabbitMQ, ActiveMQ 같은 Message Broker를 이용해, Subscription(구독)을 관리하고 메세지를 브로드캐스팅할 수 있다.
- WebSocket 기반으로 각 Connection(연결)마다 WebSocketHandler를 구현하는 것 보다 @Controller 된 객체를 이용해 조직적으로 관리할 수 있다.
- 즉, 메세지는 STOMP의 "destination" 헤더를 기반으로 @Controller 객체의 @MethodMapping 메서드로 라우팅 된다.
- STOMP의 "destination" 및 Message Type을 기반으로 메세지를 보호하기 위해 Spring Security를 사용할 수 있다.
Spring에서 지원하는 STOMP는 많은 기능을 수행합니다. 예를 들면 Simple In-Memory Broker를 이용해 SUBSCRIBE 중인 다른 클라이언트들에게 메세지를 보낼 수 있습니다. Simple In Memory Broker는 클라이언트의 구독 정보를 자체적으로 메모리에 유지하여 개발자가 추가로 정보 유지를 위해 처리해야하는 작업이 없습니다.
또한 RabbitMQ, ActiveMQ같은 외부 메세징 시스템을 STOMP Broker로 사용할 수 있도록 지원합니다. 스프링은 메세지를 외부 Broker에게 전달하고, Broker는 WebSocket으로 연결된 클라이언트에게 메세지를 전달하는 구조입니다. 이와 같은 구조 덕분에 HTTP 기반의 보안 설정과 공통된 검증 등을 적용할 수도 있습니다.
STOMP는 HTTP 에서 모델링되는 Frame 기반 프로토콜입니다. Frame은 몇 개의 Text Line으로 지정된 구조인데 첫 번째 라인은 Text이고 이후에는 Key:Value 형태로 Header의 정보를 포함합니다. 그 다음 빈 라인을 추가하고 Payload(본문)가 존재합니다. 아래 구조를 보면 HTTP 요청과 유사한 것을 알 수 있습니다.
COMMAND
header1:value1
header2:value2
Body^@
- COMMAND : CONNECT, SEND, SUBSCRIBE등 작업을 지시하는 명령어 입니다.
- header : 기존 Websocket만으로는 전송이 불가능 했던 헤더를 작성할 수 있습니다.
- Body : 바디에 JSON 등의 형식으로 원하는 데이터를 전송할 수 있습니다.
아래는 주문번호가 1번인 함께 결제 방을 구독하는 예시 입니다.
SUBSCRIBE
id:sub-1
Authorization:{AccessToken}
destination:/sub/order/room/1
^@
단일 연결은 여러 개의 구독을 할 수 있으므로 구독 ID를 고유하게 식별하기 위해 "id"헤더가 프레임에 포함되어야 합니다. 또한 인증과 인가를 위해 Authorization 헤더도 포함시켜 주었습니다.
특정 함께 결제 방을 구독하기 위해, 제가 지정한 주소를 destination에 포함시켜 주었습니다. 이 주소는 개발자 각자가 지정할 수 있으며, 저는 /sub/order/room/{주문 번호} 로 지정하였습니다.
가장 중요한 것은, 모든 STOMP 메시지의 마지막에는 공백 문자가 포함되어야 한다는 점 입니다.
위 그림은 STOMP를 사용하여 클라이언트와 서버가 어떻게 통신하는지 나타내는 그림입니다. 클라이언트가 SEND를 통해 발행을 하게 되면, 서버에서는 MESSAGE 명령을 통해 구독중인 클라이언트들에게 응답을 보내는 구조입니다.
아래는 SEND나 SUBSCRIBE 외에 다른 명령어에 관한 설명입니다.
https://stomp.github.io/stomp-specification-1.2.html#Frames_and_Headers
DB에 쿼리 호출 수를 줄이고, 빠르게 방 정보를 받아오기
함께 결제 방에서 현재 참여한 유저의 정보와, 선택한 메뉴 등의 정보를 받아오려면 MySql과 Mybatis를 통해 쿼리를 호출해 불러오는 방식을 사용하였습니다. 하지만 이 방식을 통해 호출하다 보니 속도가 많이 느려지는 현상이 발생하였습니다. 꼭 DB에 저장되어야 하는 정보가 아님에도, 메뉴 선택과 취소 등 하나의 동작에도 계속 DB에 작업을 수행하다 보니 과부하가 생겼습니다.
이를 해결하기 위해, Redis에 함께 결제 방의 정보를 적재하여 빠르게 조회하고 수정이 가능하도록 하였습니다. 또한 redis는 key:value 형태의 데이터가 저장 가능해 훨씬 간편하게 정보를 저장할 수 있게 되었습니다.
이제, 본격적으로 구현을 시작해보도록 하겠습니다.
구현
Dependency
// Spring Data Redis
implementation 'org.springframework.data:spring-data-redis:2.7.2'
implementation 'io.lettuce:lettuce-core:6.4.0.RELEASE'
// websocket
implementation 'org.springframework:spring-websocket:5.3.20'
compileOnly 'javax.websocket:javax.websocket-api:1.1'
// Spring Messaging (STOMP 사용을 위한 의존성)
implementation 'org.springframework:spring-messaging:5.3.20'
// SockJS를 사용하기 위한 의존성
implementation 'org.webjars:sockjs-client:1.5.1'
// STOMP 클라이언트를 위한 의존성
implementation 'org.webjars:stomp-websocket:2.3.4'
// (Optional) WebSocket 서버 테스트를 위한 의존성
testImplementation 'org.springframework:spring-test:5.3.20'
implementation("org.redisson:redisson:3.16.3")
스프링 부트를 사용중이라면, 아래 두줄이면 충분할 것 같습니다. 꼭 확인해보고 맞는 의존성을 주입받아 사용하시길 권장합니다.
//redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
Redis Config
@Configuration
@EnableTransactionManagement
@RequiredArgsConstructor
public class RedisConfig {
@Value("${redis.host}")
private String redisHost;
@Value("${redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
// LettuceConnectionFactory를 사용하여 Redis 연결 설정
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory
) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
// RedisMessageListenerContainer 에 사용할 Redis 연결 팩토리(RedisConnectionFactory)를 설정
container.setConnectionFactory(connectionFactory);
return container;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(redisConnectionFactory);
stringRedisTemplate.setEnableTransactionSupport(true);
return stringRedisTemplate;
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setEnableTransactionSupport(true);
// 키와 해시 키 직렬화 설정
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
GenericJackson2JsonRedisSerializer serializer = serializer();
// 값과 해시 값 직렬화 설정
redisTemplate.setValueSerializer(serializer);
redisTemplate.setHashValueSerializer(serializer);
return redisTemplate;
}
@Bean
public GenericJackson2JsonRedisSerializer serializer() {
// 커스텀 ObjectMapper 생성
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 추가된 설정
// 타입 정보 활성화
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
return new GenericJackson2JsonRedisSerializer(objectMapper);
}
@Bean
public PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager();
}
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisHost + ":" + redisPort);
return Redisson.create(config);
}
/*
실제 메시지를 처리하는 subscriber 설정 추가
sendMessage 라는 메소드가 -> RedisSubscriber 클래스 안에 오버라이딩 된 onMessage 로 처리하도록 함
*/
@Bean
public MessageListenerAdapter listenerAdapter(RedisSubscriber subscriber) {
return new MessageListenerAdapter(subscriber, "onMessage");
}
}
(부트였다면 불필요한, 커넥션 팩토리나 직렬화와 역직렬화를 위한 Bean들이 추가되었습니다)
MessageListenerAdaper에서는 RedisMessageListenerContainer로부터 메시지를 dispatch 받고, 실제 메시지를 처리하는 비즈니스 로직이 담긴 Subscriber Bean을 추가하였습니다.
Redis서버와 상호작용하기 위한 RedisTemplate 관련 설정을 해주었습니다. Redis에는 bytes 코드만이 저장되므로 key와 value에 Serializer를 설정해주었습니다. Json 포맷 형식으로 메시지를 교환하기 위해 ValueSerializer에 Jackson2JsonRedisSerializer로 설정해주었습니다.
RedisSubscriber
@Slf4j
@RequiredArgsConstructor
@Service
public class RedisSubscriber implements MessageListener {
private final GenericJackson2JsonRedisSerializer serializer;
private final SimpMessageSendingOperations messagingTemplate;
/**
* Redis에서 메시지가 발행되면 이 메서드가 호출되어 WebSocket 클라이언트들에게 메시지를 전달합니다.
*/
@Override
public void onMessage(Message message, byte[] pattern) {
try {
// GenericJackson2JsonRedisSerializer를 사용하여 메시지를 역직렬화
Object obj = serializer.deserialize(message.getBody());
if (obj instanceof EnterOrderRoomResponseDto) {
EnterOrderRoomResponseDto dto = (EnterOrderRoomResponseDto) obj;
messagingTemplate.convertAndSend("/sub/order/room/" + dto.getOrderIdx(), dto);
log.info("Received ENTER message: {}", dto);
} else if (obj instanceof OrderRoomMenuInfoListDto) {
OrderRoomMenuInfoListDto dto = (OrderRoomMenuInfoListDto) obj;
messagingTemplate.convertAndSend("/sub/order/room/" + dto.getOrderIdx(), dto);
log.info("Received MENU_INFO message: {}", dto);
}
......
else {
log.error("RedisSubscriber: 알 수 없는 메시지 타입입니다. 클래스 = {}", obj.getClass());
}
} catch (Exception e) {
log.error("RedisSubscriber: Exception occurred while processing message - {}", e.getMessage(), e);
}
}
}
- onMessage 메소드는 리스너에 수신된 메시지를 각 비즈니스 로직을 거쳐 messagingTemplate을 이용해 WebSocket 구독자들에게 메시지를 전달하는 메소드입니다
- RedisPublisher로부터 온 메시지를 역직렬화하여 각 타입에 맞는 DTO로 전환한 뒤, 필요한 정보와 함께 메시지를 전달합니다.
RedisPublisher
@RequiredArgsConstructor
@Slf4j
@Service
public class RedisPublisher {
private final RedisTemplate<String, Object> redisTemplate;
public void publish(ChannelTopic topic, Object object) {
redisTemplate.convertAndSend(topic.getTopic(), object);
}
}
- RedisTemplate를 통해 특정 토픽으로 메시지를 전달하는 역할을 수행하는 객체입니다.
WebSockerConfig
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
private final StompErrorHandler stompErrorHandler;
// sockJS Fallback을 이용해 노출할 endpoint 설정
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 웹소켓이 handshake를 하기 위해 연결하는 endpoint
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*");
registry.addEndpoint("/socket.io")
.setAllowedOriginPatterns("*")
.withSockJS();
registry.setErrorHandler(stompErrorHandler);
}
//메세지 브로커에 관한 설정
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 서버 -> 클라이언트로 발행하는 메세지에 대한 endpoint 설정 : 구독
registry.enableSimpleBroker("/sub");
// 클라이언트->서버로 발행하는 메세지에 대한 endpoint 설정 : 구독에 대한 메세지
registry.setApplicationDestinationPrefixes("/pub");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
}
- WebSocketMessageBrokerConfigurer를 상속받아 STOMP로 메시지 처리 방법을 구성하였습니다.
- configureMessageBroker에서는 메시지를 중간에서 라우팅할 때 사용하는 메시지 브로커를 구성하였습니다.
- enableSimpleBroker에서는 해당 주소를 구독하는 클라이언트에게 메시지를 보냅니다. 즉, 인자에는 구독 요청의 prefix를 넣고, 클라이언트에서 1번 채널을 구독하고자 할 때는 /sub/1 형식과 같은 규칙을 따라야 합니다.
- setApplicationDestinationPrefixes에는 메시지 발행 요청의 prefix를 넣는다. 즉, /pub로 시작하는 메시지만 해당 Broker에서 받아서 처리한다.
- 클라이언트에서 WebSocket에 접속할 수 있는 endpoint를 지정합니다. socket.io도 같이 사용하기 위해 두가지의 endpoint를 지정해주었습니다.
- 사용자가 웹 소켓 연결에 연결 될 때와 끊길 때 추가 기능(인증, 세션 관리 등)을 위해 인터셉터를 걸어주었습니다. 커스텀 StompHandler를 빈으로 등록하였습니다.
Stomp Handler
@Component
@RequiredArgsConstructor
@Slf4j
public class StompHandler implements ChannelInterceptor {
private final RedisRepository redisRepository;
private final JwtService jwtService;
private final MemberRepository memberRepository;
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();//5
// WebSocket을 통해 들어온 요청이 처리 되기 전에 실행
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
log.info("StompHandler - preSend called. Command: {}", accessor.getCommand());
if (StompCommand.CONNECT.equals(accessor.getCommand()) || StompCommand.SUBSCRIBE.equals(accessor.getCommand()) || StompCommand.SEND.equals(accessor.getCommand())) {
// CONNECT 메시지 처리
List<String> authorizationHeaders = accessor.getNativeHeader("Authorization");
String authHeader = null;
String getMemberId = accessor.getFirstNativeHeader("MemberId");
if (authorizationHeaders != null && !authorizationHeaders.isEmpty()) {
authHeader = authorizationHeaders.get(0).trim();
}
Optional<String> accessToken = Optional.ofNullable(authHeader)
.filter(token -> token.startsWith("Bearer "))
.map(token -> token.replace("Bearer ", ""));
if (accessToken.isEmpty()) {
log.error("Stomp Handler : 유효하지 않은 토큰입니다.");
throw new MessageDeliveryException(ErrorStatus._UNAUTHORIZED.getMessage());
}
String memberId = jwtService.extractMemberId(accessToken.get()).orElse(null);
String sessionId = (String) message.getHeaders().get("simpSessionId");
if (sessionId == null) {
log.error("Stomp Handler : sessionId를 찾을 수 없습니다.");
throw new MessageDeliveryException(ErrorStatus._UNAUTHORIZED.getMessage());
}
if (!redisRepository.existMySessionInfo(sessionId)) {
redisRepository.saveMySessionInfo(sessionId, memberId);
}
if (!jwtService.isTokenValid(accessToken.get()) || !memberId.equals(getMemberId)) {
log.error("Stomp Handler : 유효하지 않은 토큰입니다. memberId : {}", getMemberId);
throw new MessageDeliveryException(ErrorStatus._UNAUTHORIZED.getMessage());
}
Member member = memberRepository.findByMemberId(memberId)
.orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));
saveAuthentication(member);
// 사용자 정보를 세션에 저장
accessor.setUser(new Principal() {
@Override
public String getName() {
return memberId;
}
});
log.info("Stomp Handler : SUCCESS. memberId : {}", memberId);
} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
// DISCONNECT 메시지 처리
String memberId = accessor.getFirstNativeHeader("MemberId");
String sessionId = (String) message.getHeaders().get("simpSessionId");
if (memberId == null) {
memberId = redisRepository.getMySessionInfo(sessionId);
}
log.info("Stomp Handler : DISCONNECT. memberId : {}", memberId);
if (memberId != null) {
Member member = memberRepository.findByMemberId(memberId)
.orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));
// 함께 주문에서 나가는 로직
if (redisRepository.existMyInfo(member.getIdx())) {
Long orderIdx = redisRepository.getMemberEnteredOrderRoomIdx(member.getIdx());
log.info("Exit orderRoom. memberIdx : {}, orderRoomIdx : {}", member.getIdx(), orderIdx);
if (orderIdx == null) {
log.error("Stomp Handler : 주문방을 찾는데 실패하였습니다. memberIdx : {}", member.getIdx());
throw new MessageDeliveryException(ErrorStatus.ORDER_ROOM_NOT_FOUND.getMessage());
}
// 채팅방 퇴장 정보 저장
if (redisRepository.existMemberInOrderRoom(orderIdx, member.getIdx())) {
redisRepository.exitMemberEnterOrderRoom(member.getIdx());
}
redisRepository.minusMemberCnt(orderIdx, member.getIdx());
redisRepository.deleteMySessionInfo(sessionId);
}
} else {
log.error("Stomp Handler : 사용자 정보를 찾을 수 없습니다.");
}
} else {
// 기타 명령에 대한 처리 (필요에 따라 추가)
}
return message;
}
private void saveAuthentication(Member member) {
CustomUserDetails userDetails = CustomUserDetails.create(member);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null,authoritiesMapper.mapAuthorities(userDetails.getAuthorities()));
SecurityContext context = SecurityContextHolder.createEmptyContext();//5
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
log.info("Authentication success: memberId = {}", member.getMemberId());
}
이 부분에서, 저는 JWT토큰을 Authorization 헤더에 실어서 STOMP 요청을 보냈기 때문에, 해당 토큰을 추출하여 유저에 대한 인증을 구현하였습니다. 유저 인증 부분은 어떻게 구성하냐에 따라 다 다르기 때문에 각자 코드에 맞게 수정하여 사용하면 좋습니다.
추가로, 이 Handler에서 시큐리티 컨텍스트에 인증정보를 저장해도 컨트롤러에 도달할때는 적용이 되지 않는 현상이 발생하였습니다. WebSocket 통신이 HTTP 요청과는 다르게 처리되기 때문에 발생하는 것 같습니다. 찾아보니 SecurityContext는 일반적으로 HTTP 요청 중에 스레드 로컬 저장소에 저장되기 때문에, WebSocket 통신에서는 동일한 방식으로 작동하지 않는다고 합니다. 이 부분에 대해서는 추후에 더 자세히 공부해볼 예정입니다. (Spring Boot로 프로젝트할 때는 잘 되었던 것 같은데, 레거시 스프링에서는 잘 안되는것 같기도 합니다.)
그래서, 매 소켓 요청시마다 memberId도 바디에 함께 넣어 컨트롤러에서 추출하여 유저를 식별하는 방식을 사용하였습니다.
또한, 웹소켓 세션에 따라 유저를 식별하여 사용하는 것이 훨씬 좋습니다. 대표적인 예로, Disconnect 시에는 추가 헤더가 잘 안들어가기 때문에 퇴장 정보를 기록하기 위해서는 세션을 이용해야 합니다.
그래서 레디스에 세션 아이디와 멤버 아이디를 매칭한 정보를 저장해두도록 하였습니다.
RedisRepository
@Slf4j
@RequiredArgsConstructor
@Service
public class RedisRepository {
private final RedisPublisher redisPublisher;
// Redis
private static final String ORDER_ROOMS = "ORDER_ROOM";
public static final String ENTER_INFO = "ENTER_INFO"; // 채팅룸에 입장한 클라이언트의 sessionId와 채팅룸 id를 맵핑한 정보 저장
public static final String MEMBER_INFO = "MEMBER_INFO"; // 채팅룸에 입장한 클라이언트의 sessionId와 채팅룸 id를 맵핑한 정보 저장
private final RedisTemplate<String, Object> redisTemplate;
private HashOperations<String, String, OrderRoom> opsHashOrderRoom;
// 채팅방 입장 정보 <ENTER_INFO, memberIdx, orderIdx>
private HashOperations<String, String, Long> opsHashEnterInfo;
// 채팅방 세션 멤버 정보 <MEMBER_INFO, SessionId, memberId>
private HashOperations<String, String, String> opsHashMemberSessionInfo;
// 채팅방의 대화 메시지를 발행하기 위한 redis topic 정보. 서버별로 채팅방에 매치되는 topic 정보를 Map에 넣어 roomId로 찾을수 있도록 한다.
private Map<String, ChannelTopic> topics;
@PostConstruct
private void init() {
opsHashOrderRoom = redisTemplate.opsForHash();
opsHashEnterInfo = redisTemplate.opsForHash();
opsHashMemberSessionInfo = redisTemplate.opsForHash();
topics = new HashMap<>();
}
/**
* 주문 입장 : redis에 topic을 만들고 pub/sub 통신을 하기 위해 리스너를 설정한다.
*/
@RedisLock(lockName = "lock:orderRoom:#{#orderIdx}")
public ChannelTopic enterOrderRoom(Long memberIdx, Long orderIdx) {
OrderRoom orderRoom = getOrderRoom(orderIdx);
String orderIdxStr = orderRoom.getOrderIdx() + "";
ChannelTopic topic = getTopic(orderIdxStr);
if (topic == null) {
log.info("기존에 등록된 topic이 없습니다. 새로운 topic 생성 : orderIdx = {}", orderIdxStr);
topic = new ChannelTopic(orderIdxStr);
}
log.info("topic 생성 : orderIdx = {}, topic = {}", orderIdxStr, topic);
topics.put(orderIdxStr, topic);
// if (existMyInfo(memberIdx)) {
// throw new ErrorHandler(ErrorStatus.ORDER_MEMBER_ALREADY_IN_OTHER_ROOM);
// }
if (getMemberEnteredOrderRoomIdx(memberIdx) != null) {
redisPublisher.publish(topics.get(orderIdx + ""), new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_MEMBER_ALREADY_IN_ROOM));
throw new RuntimeException(ErrorStatus.ORDER_MEMBER_ALREADY_IN_ROOM.getMessage());
}
if (orderRoom.getMemberIdxList().contains(memberIdx)) {
redisPublisher.publish(topics.get(orderIdx + ""), new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_MEMBER_ALREADY_IN_ROOM));
throw new RuntimeException(ErrorStatus.ORDER_MEMBER_ALREADY_IN_ROOM.getMessage());
}
if (!orderRoom.enterMember(memberIdx)) {
redisPublisher.publish(topic, new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_ROOM_MEMBER_CNT_CANNOT_EXCEED));
throw new ErrorHandler(ErrorStatus.ORDER_ROOM_MEMBER_CNT_CANNOT_EXCEED);
}
opsHashOrderRoom.put(ORDER_ROOMS, orderIdxStr, orderRoom);
opsHashEnterInfo.put(ENTER_INFO, memberIdx + "", orderIdx);
return topic;
}
public ChannelTopic saveOrderRoom(OrderRoom orderRoom) {
String orderIdxStr = orderRoom.getOrderIdx() + "";
if (topics.containsKey(orderIdxStr)) {
ChannelTopic topic = new ChannelTopic(orderIdxStr);
topics.put(orderIdxStr, topic);
}
opsHashOrderRoom.put(ORDER_ROOMS, orderIdxStr, orderRoom);
return topics.get(orderIdxStr);
}
public OrderRoom getOrderRoom(Long orderIdx) {
if (!existByOrderRoomIdx(orderIdx)) {
throw new ErrorHandler(ErrorStatus.ORDER_ROOM_NOT_FOUND);
}
return opsHashOrderRoom.get(ORDER_ROOMS, orderIdx + "");
}
@RedisLock(lockName = "lock:orderRoom:#{#orderIdx}")
public OrderRoom readyToPay(Long orderIdx, Long memberIdx, ChannelTopic topic) {
OrderRoom orderRoom = getOrderRoom(orderIdx);
String orderIdxStr = orderRoom.getOrderIdx() + "";
if (!orderRoom.readyToPay()) {
redisPublisher.publish(topic, new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_ROOM_MEMBER_CNT_NOT_MATCH));
throw new ErrorHandler(ErrorStatus.ORDER_ROOM_MEMBER_CNT_NOT_MATCH);
}
opsHashOrderRoom.put(ORDER_ROOMS, orderIdxStr, orderRoom);
return orderRoom;
}
@RedisLock(lockName = "lock:orderRoom:#{#orderIdx}")
public OrderRoom cancelReadyToPay(Long orderIdx, Long memberIdx, ChannelTopic topic) {
OrderRoom orderRoom = getOrderRoom(orderIdx);
if (orderRoom.getReadyCnt() == orderRoom.getMaxMemberCnt()) {
redisPublisher.publish(topic, new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_ROOM_PAY_ALREADY_STARTED));
throw new ErrorHandler(ErrorStatus.ORDER_ROOM_PAY_ALREADY_STARTED);
}
if (!orderRoom.cancelReadyToPay()) {
redisPublisher.publish(topic, new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_ROOM_MEMBER_CNT_NOT_MATCH));
throw new ErrorHandler(ErrorStatus.ORDER_ROOM_MEMBER_CNT_NOT_MATCH);
}
opsHashOrderRoom.put(ORDER_ROOMS, orderIdx + "", orderRoom);
return orderRoom;
}
public boolean existByOrderRoomIdx(Long orderIdx) {
return opsHashOrderRoom.hasKey(ORDER_ROOMS, orderIdx + "");
}
public ChannelTopic getTopic(String orderIdx) {
log.info("topic 을 불러옵니다 : orderIdx = {}, topic = {}", orderIdx, topics.get(orderIdx));
return topics.get(orderIdx);
}
@RedisLock(lockName = "lock:orderRoom:#{#orderIdx}")
public OrderRoom selectMenu(Long orderIdx, Long menuIdx, Long memberIdx, int price, ChannelTopic topic) {
OrderRoom orderRoom = getOrderRoom(orderIdx);
HashMap<Long, List<Long>> menuSelect = orderRoom.getMenuSelect();
HashMap<Long, Integer> menuAmount = orderRoom.getMenuAmount();
List<OrderRoomResponseDto.SelectedMenuInfoDto> selectedMenuInfoList = new ArrayList<>();
for (Long idx : orderRoom.getMenuSelect().keySet()) {
List<Long> memberIdxList = orderRoom.getMenuSelect().get(idx);
OrderRoomResponseDto.SelectedMenuInfoDto selectedMenuInfo = OrderRoomResponseDto.SelectedMenuInfoDto.builder()
.menuIdx(idx)
.currentAmount(memberIdxList.size())
.memberIdxList(memberIdxList)
.build();
selectedMenuInfoList.add(selectedMenuInfo);
}
if (!menuSelect.containsKey(menuIdx)) {
redisPublisher.publish(topic, new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_MENU_NOT_FOUND, selectedMenuInfoList));
throw new ErrorHandler(ErrorStatus.ORDER_MENU_NOT_FOUND);
}
if (menuSelect.get(menuIdx).size() >= menuAmount.get(menuIdx)) {
redisPublisher.publish(topic, new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_MENU_MEMBER_CNT_ERROR, selectedMenuInfoList));
throw new ErrorHandler(ErrorStatus.ORDER_MENU_MEMBER_CNT_ERROR);
}
menuSelect.get(menuIdx).add(memberIdx);
orderRoom.updateCurrentPrice(price);
opsHashOrderRoom.put(ORDER_ROOMS, orderIdx + "", orderRoom);
return orderRoom;
}
@RedisLock(lockName = "lock:orderRoom:#{#orderIdx}")
public OrderRoom cancelMenu(Long orderIdx, Long menuIdx, Long memberIdx, int price) {
OrderRoom orderRoom = getOrderRoom(orderIdx);
HashMap<Long, List<Long>> menuSelect = orderRoom.getMenuSelect();
List<OrderRoomResponseDto.SelectedMenuInfoDto> selectedMenuInfoList = new ArrayList<>();
for (Long idx : orderRoom.getMenuSelect().keySet()) {
List<Long> memberIdxList = orderRoom.getMenuSelect().get(idx);
OrderRoomResponseDto.SelectedMenuInfoDto selectedMenuInfo = OrderRoomResponseDto.SelectedMenuInfoDto.builder()
.menuIdx(idx)
.currentAmount(memberIdxList.size())
.memberIdxList(memberIdxList)
.build();
selectedMenuInfoList.add(selectedMenuInfo);
}
if (!menuSelect.containsKey(menuIdx)) {
redisPublisher.publish(topics.get(orderIdx + ""), new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_MENU_NOT_FOUND, selectedMenuInfoList));
throw new ErrorHandler(ErrorStatus.ORDER_MENU_NOT_FOUND);
}
if (menuSelect.get(menuIdx).size() <= 0) {
redisPublisher.publish(topics.get(orderIdx + ""), new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_MENU_MEMBER_CNT_ERROR, selectedMenuInfoList));
throw new ErrorHandler(ErrorStatus.ORDER_MENU_MEMBER_CNT_ERROR);
}
if (!menuSelect.get(menuIdx).contains(memberIdx)) {
redisPublisher.publish(topics.get(orderIdx + ""), new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_NOT_ORDERED_MENU_CANNOT_CANCELED, selectedMenuInfoList));
throw new ErrorHandler(ErrorStatus.ORDER_NOT_ORDERED_MENU_CANNOT_CANCELED);
}
menuSelect.get(menuIdx).remove(memberIdx);
orderRoom.updateCurrentPrice(price);
opsHashOrderRoom.put(ORDER_ROOMS, orderIdx + "", orderRoom);
return orderRoom;
}
// public void plusUserCnt(Long orderRoomIdx) {
// orderRoomRepository.plusMemberCnt(orderRoomIdx);
// }
//
// public void minusUserCnt(Long orderRoomIdx) {
// orderRoomRepository.minusMemberCnt(orderRoomIdx);
// }
@RedisLock(lockName = "lock:orderRoom:#{#orderIdx}")
public void minusMemberCnt(Long orderIdx, Long memberIdx) {
OrderRoom orderRoom = getOrderRoom(orderIdx);
if (orderRoom.exitMember(memberIdx)) {
throw new ErrorHandler(ErrorStatus.ORDER_ROOM_MEMBER_CNT_CANNOT_BE_MINUS);
}
opsHashOrderRoom.put(ORDER_ROOMS, orderIdx + "", orderRoom);
}
public void deleteOrderRoom(Long orderIdx) {
opsHashOrderRoom.delete(ORDER_ROOMS, orderIdx + "");
}
// 유저가 입장해 있는 주문방 ID 조회
public Long getMemberEnteredOrderRoomIdx(Long memberIdx) {
if (opsHashEnterInfo.get(ENTER_INFO, memberIdx + "") == null) {
return null;
}
return Long.parseLong(opsHashEnterInfo.get(ENTER_INFO, memberIdx + "") + "");
}
// 사용자가 특정 주문방에 입장해 있는지 확인
public boolean existMemberInOrderRoom(Long orderIdx, Long memberIdx) {
return getMemberEnteredOrderRoomIdx(memberIdx).equals(orderIdx);
}
// 사용자 퇴장
public void exitMemberEnterOrderRoom(Long memberIdx) {
opsHashEnterInfo.delete(ENTER_INFO, memberIdx + "");
}
// 이미 참여중인지
public boolean existMyInfo(Long memberIdx) {
return opsHashEnterInfo.hasKey(ENTER_INFO, memberIdx + "");
}
public void saveMySessionInfo(String sessionId, String memberId) {
opsHashMemberSessionInfo.put(MEMBER_INFO, sessionId, memberId);
}
public boolean existMySessionInfo(String sessionId) {
return opsHashMemberSessionInfo.hasKey(MEMBER_INFO, sessionId);
}
public String getMySessionInfo(String sessionId) {
return opsHashMemberSessionInfo.get(MEMBER_INFO, sessionId);
}
public void deleteMySessionInfo(String sessionId) {
opsHashMemberSessionInfo.delete(MEMBER_INFO, sessionId);
}
}
OrderRoom 함께 결제 방 객체, 사용자가 입장한 방 정보, 사용자 세션 정보를 HashOperation에 저장해두었습니다. 이를 통해, 사용자가 입장한 방의 정보를 받아와 원하는 작업을 수행할 수 있습니다.
또한 Topic을 매번 생성하는 것이 아닌, 방 idx별로 토픽을 생성할 때 레디스에 저장해두고, 불러와 사용할 수 있도록 하였습니다.
OrderRoom
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OrderRoom implements Serializable {
@Serial
private static final long serialVersionUID = 6494678977089006639L;
private Long idx;
private Long orderIdx;
private Long ownerMemberIdx;
private int maxMemberCnt;
private int memberCnt;
private List<Long> memberIdxList;
private int totalPrice;
private int currentPrice;
private OrderRoomType type;
private HashMap<Long, List<Long>> menuSelect;
private HashMap<Long, Integer> menuAmount;
private int readyCnt;
private OrderRoomStatus status;
private String createdAt;
private String imgUrl;
public void updateCurrentPrice(int price) {
currentPrice += price;
}
public boolean enterMember(Long memberIdx) {
if (memberCnt >= maxMemberCnt) {
return false;
}
memberCnt++;
memberIdxList.add(memberIdx);
return true;
}
public boolean exitMember(Long memberIdx) {
if (memberCnt <= 0) {
return false;
}
if (!memberIdxList.contains(memberIdx)) {
return false;
}
memberCnt--;
memberIdxList.remove(memberIdx);
return true;
}
public boolean readyToPay() {
if (readyCnt >= maxMemberCnt) {
return false;
}
readyCnt++;
return true;
}
public boolean cancelReadyToPay() {
if (readyCnt <= 0) {
return false;
}
readyCnt--;
return true;
}
}
Redis에 임시로 저장되었다가, 결제 승인이 되면 Mysql에 내역을 저장하는 OrderRoom 객체입니다. 이 객체의 모든 부분을 Mysql을 저장하는 것이 아닌, 필요한 부분만 저장되도록 하였습니다. 위 코드는 JPA를 사용하지 않고 MyBatis를 사용하였기 때문에 객체가 Dto처럼 구성되었습니다.
이 객체를 레디스에 저장해두었다가, 유저 입장정보, 선택한 메뉴 정보, 현재 선택된 금액 정보 등을 업데이트 할 수 있습니다.
남은 세세한 Service나 Repository는 굳이 이 포스팅에서 언급하지 않으려고 합니다. 일반적으로 구현하는 서비스가 아니다 보니 참고하여 개발하지는 않을것 같아서 더 궁금한 분들을 위해, 아래 깃허브 주소만 남겨두고 구현부는 마무리 하려고 합니다. 이제 저희가 만든 서비스를 테스트 해봐야하는데, 기존에 apic이라는 좋은 테스트 툴이 있었지만 현재는 접속도 되지 않습니다. 대체할 만한 툴을 제가 찾아봤는데 정상적으로 작동하는 것을 찾을 수 없었습니다.
그래서 저는 POSTMAN으로 테스트 해보기로 했는데, 웹소켓을 테스트하기 어렵다는 말과 달리 잘 사용하였습니다. 아래에 테스트하는 방법을 적어보겠습니다.
PostMan으로 Websocket 테스트하기
왼쪽 위 new버튼을 누르면 다음과 같은 창이 나옵니다. 이때 WebSocket을 선택합니다.
포스트맨으로 테스트하기 어렵다는 이유가 맨 마지막에 공백 문자를 넣기 어려워서 인데 ,StackOverFlow를 찾아보다 보니 좋은 해결책이 보였습니다.
포스트맨에는 한 API를 호출한 뒤의 동작을 지정할 수 있는 스크립트가 존재합니다. 저의 경우에는 로그인 API 스크립트에 아래와 같은 코드를 넣었습니다.
pm.globals.set("NULL_CHAR", '\0');
pm.environment.set('NULL_CHAR', '\0');
이러면 API 호출 후에, PostMan 환경 변수에 공백 문제가 자동으로 세팅됩니다.
이로써 저희는 WEBSOCKET 요청할때, {{NULL_CHAR}}을 입력하여 마지막에 공백 문자를 넣어서 정상적으로 테스트 할 수 있습니다.
다시 아까 만들어놓은 WebSocket 요청으로 돌아와서, 주소에는 아까 설정해둔 엔드포인트인 ws를 적어주었습니다.
만약 Https 환경에서 테스트 한다면 wss://{{주소}}/ws 이렇게 작성해주면 됩니다.
PostMan에서는 옆에 saved Message를 통해 여러 명령어를 저장해둘 수 있습니다.
연결 명령어는 위와 같이 작성하였습니다.
CONNECT
Authorization:Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsImF1dGgiOiJST0xFX0FETUlOIiwibWVtYmVySWQiOiJ0ZXN0QGdtYWlsLmNvbSIsImlhdCI6MTcyOTY1OTc0OCwiZXhwIjoxNzMwMDkxNzQ4fQ.9RH8NH-NzFvxPYHW1xyQFXfCw6dO5CQjWQ-LEYDbbU95e_LuiTWgv8ectqwsVc6PpX3hSkdMWh3ZSS_Fwn0erg
MemberId:test@gmail.com
accept-version:1.3,1.2,1.1,1.0
heart-beat:10000,10000
{{NULL_CHAR}}
구독 명령어
SUBSCRIBE
id:sub-1
Authorization:Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsImF1dGgiOiJST0xFX0FETUlOIiwibWVtYmVySWQiOiJ0ZXN0QGdtYWlsLmNvbSIsImlhdCI6MTcyODcwMDM2NSwiZXhwIjoxNzI5MTMyMzY1fQ.gDsVJ9lqQuZyWhElsBIcDkVnSQQIgKMD-8Q5YXE0dNZO72vMw6novflbKhXLWglDr-ZmR8B_e2TStbqGlDeeOg
MemberId:test@gmail.com
destination:/sub/order/room/1
{{NULL_CHAR}}
단일 연결은 여러 개의 구독을 할 수 있으므로 구독 ID를 고유하게 식별하기 위해 "id"헤더가 프레임에 포함되어야 합니다.
작업을 수행하는 명령어 SEND
SEND
Authorization:Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsImF1dGgiOiJST0xFX0FETUlOIiwibWVtYmVySWQiOiJ0ZXN0QGdtYWlsLmNvbSIsImlhdCI6MTcyODcwMDM2NSwiZXhwIjoxNzI5MTMyMzY1fQ.gDsVJ9lqQuZyWhElsBIcDkVnSQQIgKMD-8Q5YXE0dNZO72vMw6novflbKhXLWglDr-ZmR8B_e2TStbqGlDeeOg
destination:/pub/order/room/select
MemberId:test@gmail.com
content-type:application/json
{"orderIdx":1, "menuIdx":2, "memberId":"test@gmail.com", "menuName":"짜장면", "menuPrice":11000, "amount":1}{{NULL_CHAR}}
Body에 json형식의 데이터를 삽입해 Controller의 @MessageMapping에서 받아서 처리할 수 있도록 하였습니다. \
이제 위의 프로토콜 형식을 참고해서 아래와 같은 순서로 테스트 해보면 됩니다.
(`CONNECT` -> `SUBSCRIBE` -> `SEND` -> `UNSUBSCRIBE` -> `DISCONNECT`)
받아서 처리하는 예시 부분입니다.
@MessageMapping("/order/room/select")
public void selectOrderMenu(@Payload SelectMenuRequestDto requestDto) {
log.info("selectOrderMenu : orderIdx = {}, memberId = {}", requestDto.getOrderIdx(), requestDto.getMemberId());
orderRoomService.selectOrderMenu(requestDto, requestDto.getMemberId());
}
// DTO
@Data
public static class SelectMenuRequestDto {
private Long orderIdx;
private Long menuIdx;
private String memberId;
private String menuName;
private int menuPrice;
private int amount;
@JsonCreator
public SelectMenuRequestDto(
@JsonProperty("orderIdx") Long orderIdx,
@JsonProperty("menuIdx") Long menuIdx,
@JsonProperty("memberId") String memberId,
@JsonProperty("menuName") String menuName,
@JsonProperty("menuPrice") int menuPrice,
@JsonProperty("amount") int amount
) {
this.orderIdx = orderIdx;
this.menuIdx = menuIdx;
this.memberId = memberId;
this.menuName = menuName;
this.menuPrice = menuPrice;
this.amount = amount;
}
}
스프링 부트가 아니여서 Json을 Dto로 매칭하는게 자동으로 되지 않았습니다. @JsonCreator을 통해 필드를 일일히 매핑해주었습니다.
다음 포스팅에서는 AOP와 Redisson Rlock을 이용하여 분산락 구현에 대해 작성해보려고 합니다.
더 자세한 코드부분이 궁금하면 아래 깃허브 링크를 남겨둘테니 참고하여 봐주시면 감사하겠습니다.
깃 주소
https://github.com/Teamirum/server
출처 및 참고
- https://stomp.github.io/stomp-specification-1.2.html#Connecting
- https://velog.io/@hiy7030/chatting-2#redisconfig
- https://tychejin.tistory.com/423
- https://new-pow.tistory.com/96