웹 소켓을 선택하게 됐는가
위키백과나 블로그들을 보면 기존 통신 방식은 클라이언트가 데이터를 요청하면 서버가 데이터를 제공하는 형태의 단반향 통신이라고 볼 수 있습니다. 만일 이러한 통신 방식을 사용한다면 프런트 엔드 쪽에서 지속적으로 서버에 메시지가 왔는지 확인을 위한 작업이 필수적입니다. 그렇기에 많은 통신이 발생할 것이라고 판단하였습니다. 그렇기에 실시간으로 데이터를 주고받기 위해서는 양반향 통신이 필요하고 생각하였고 웹 검색을 통해 웹 소켓이라는 기술이 실시간 통신을 제공해주기 때문에 적용하자고 판단했습니다.
설계 방식
1번째 설계
가장 기초적인 설계로서 STOMP만을 사용한 설계입니다. 어떤 것도 생각하지 않고 웹 소켓을 연결 시키기 위한 작업을 진행하였습니다. 여기서 CORS가 또 한 번 발생하게 되었습니다. Access_Control_Allow_Origin이 *일 경우 CREDENTIALS가 TRUE일 수 없다는 오류이기에 Origin을 프런트 주소로 설정해 주었습니다.
이렇게 완성한 결과 정상적으로 프런트 쪽으로 실시간 메시지를 보낼 수 있게 되었습니다. 하지만 2가지 문제점이 발생하였습니다.
- 메시지를 보내는 유저의 채널 이름을 알고 있어야 한다.
- 유저가 접속을 했는지에 대한 정보를 알고 있어야 한다.
이 두 가지 문제를 해결하기 위해 2번째 설계를 진행하게 되었습니다.
/**
*
* @param todo id
* @return
*/
// Controller
@PostMapping(value = "/todo/{id}")
public ResponseEntity<?> requestSaveTodoHeart(@PathVariable Long id, @UserAuthToken UserTokenDTO userTokenDTO) {
String uuid = heartService.saveTodoHeart(id, userTokenDTO);
messageSender.sendTodoHeartEventMessage(id, userTokenDTO.getUsername() + "님이 좋아요를 눌르셨습니다.");
return new ResponseEntity<String>(uuid, HttpStatus.OK);
}
// sender
private void sendMessage(String userPrivateChannel, String message) {
if (userPrivateChannel == null) {
return;
}
if (message.equals(null)) {
return;
}
HashMap<String, String> repMessage = new HashMap<String, String>();
repMessage.put("message", message);
messagingTemplate.convertAndSend(userPrivateChannel, repMessage);
}
2번째 설계
첫 번째 문제 해결
2번째 설계에서는 먼저 유저가 접속한 세션 값을 알아야 한다고 생각했습니다. 연결시 세션값을 통해 데이터를 주고받기 때문에 이 세션을 알고 서버에서 유저 정보와 세션, 채널 주소를 알고 있다면 쉽게 해결할 수 있을 것이라고 생각합니다.
그렇기에 ChannelInterceptor를 상속 받아 기능을 구성하고 Socket Config에 configureClientInboundChannel에서 새로 만든 Interceptor을 설정해주면 세션을 가져올 수 있습니다.
@Component
public class PersonalChannelInterceptor implements ChannelInterceptor {
@Autowired
private MessageChannelService messageChannelService;
@Autowired
private AuthenticationJwtProvider authenticationJwtProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(headerAccessor.getCommand())) {
requestConnectedAction(headerAccessor);
}
if (StompCommand.SUBSCRIBE.equals(headerAccessor.getCommand())) {
requestSubscribeAction(headerAccessor);
}
return message;
}
private void requestConnectedAction(StompHeaderAccessor headerAccessor) {
String token = headerAccessor.getNativeHeader(HttpHeaders.AUTHORIZATION).get(0);
String session = headerAccessor.getSessionId();
Long userId = authenticationJwtProvider.resolveTokenToUserTokenDTO(token).getId();
messageChannelService.saveChannel(userId, token, session);
}
private void requestSubscribeAction(StompHeaderAccessor headerAccessor) {
String session = headerAccessor.getSessionId();
}
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new PersonalChannelInterceptor());
}
2번째 문제 해결
유저의 세션을 알았다고 해도 이 세션이 어떤 유저인지 또한 개인 채널 이름은 무엇인지 알 수 있어야 하기에 Redis를 사용하였습니다. Redis에 저장된 유저의 정보를 가지고 유저가 현재 로그인된 상태인지를 판단하여 유저의 채널에 메시지를 입력하고 현재 접속중이지 않다면 DB에 저장하여 메시지를 보여주는 것이 좋을 것이라고 판단하였습니다.
@ReidsHash(value = "AccessAuthority", timeToLive = 36000)
public class LoginUserRedisEntity {
@Id
private String id;
private String accessToken;
private String refreshToken;
private String accessAddressIP;
}
@RedisHash(value = "UserMessageChannel", timeToLive = 360000)
public class MessageChannelEntity {
@Id
private Long userid;
private String userMessageChannel;
private String userConnectSession;
}
/**
* 유저가 로그인을 하지 않았다면 Message DB에 저장
*
* @param userEntity
* @param message
*/
private void isExsistLoginUserOnServer(UserEntity userEntity, String message) {
if (messageChannelService.isExsistUserInRedis(userEntity.getId())) {
String userPersonalChannelName = userEntity.getPersonalMessageChannelName();
sendMessage(userPersonalChannelName, message);
} else {
eventMessageService.saveMessage(message, userEntity.getId());
}
}
사용 API
프런트 엔드는 SockJS를 사용하였으며 백엔드는 스프링이 제공하는 웹 소켓을 사용하는 것이 현재 프로젝트에서 가장 좋다고 판단하였습니다. 하지만 단순한 소켓을 사용한다면 복잡한 구현이 될 것이며, 정해진 규칙이 없기에 웹 소켓과 더불어 STOMP 기술을 적용하는 것이 좋을 것이라고 생각합니다.
최종 결과

부족한 부분
메시지를 전달하는 방식을 설계하면서 필요한 것은 아직 인증에대한 부족한 부분이라고 생각합니다. 웹 소켓은 최초 로그인시 1번 열리게 되지만 이후 토큰 값이 유효하지 않는 경우는 어떻게 되는지 확인해보지 못하였으며, Heart를 추가하는 과정에서 포스트의 유저 ID값을 DB로부터 다시 받아와야 하는 작업이 많기 때문에 쿼리를 여러번 호출하는 상황이 발생되기에 이 부분을 고치는 것이 쿼리를 최소한으로 부르는 필수라고 생각됩니다.
의존성
https://mvnrepository.com/artifact/org.springframework/spring-websocket
https://mvnrepository.com/artifact/org.springframework/spring-messaging




















