WebSocket
1. WebSocket이 필요한 이유
메모 기능 요구사항 중에서 메모는 실시간으로 다른 유저와 공유할 수 있다는 요구사항이 있다.
A 유저가 특정 메모의 상태를 변경하면 B 유저는 해당 메모가 변경되는 것을 실시간으로 확인 할 수 있어야 한다.
client가 server로 요청하면 응답을 받는 단방향 통신으로는 해당 기능을 구현할 수가 없다.
매우 짧은 간격을 두고 client가 지속적으로 server로 요청을 하는 polling 방식으로 구현을 할 수 있지만
메모의 상태가 변경되지 않았음에도 client는 지속적으로 server로 요청을 보내기 때문에 불필요한 통신이 발생하게 된다.
따라서 양방향 통신이 가능한 websocket을 사용하게 되었다.
2. WebSocket이란?
client와 server 간 지속적인 양방향 통신을 제공하는 protocol이다.
client와 server는 handshaking 과정에서 http protocol을 사용하고 이후에는 websocket protocol로 업그레이드 된다.
RFC 6455로 표준화된 규격이다.
3. STOMP란?
websocket 위에서 동작하는 text 기반의 messaging protocol이다.
websocket은 client와 server 간 메시지를 주고 받는 형식이 없지만
websocket 위에서 동작하는 stomp를 사용하게 되면 메시지 형식과 routing 규칙을 제공해준다.
4. STOMP 형식
메시지는 다음과 같은 형식으로 이루어진다.
COMMAND
header1 : value1
header2 : value2
Body^@
command는 메시지 유형을 나타낸다.
| CONNECT | 연결 |
| SEND | 메시지 전송 |
| SUBSCRIBE | 메시지 구독 |
| UNSUBSCRIBE | 메시지 구독 취소 |
| DISCONNECT | 연결 종료 |
header와 body가 있고 ^@는 null 문자로 body의 끝을 나타낸다.
header 중 destination 필드가 있는데 다음과 같은 routing 규칙이 있다.
| /topic | server가 여러 client로 broadcast 전송 |
| /queue | server가 특정 client로 unicast 전송 |
| /app | client가 server로 전송 |
WebSocket STOMP 적용하기
1. 인증하기
client에서 CONNECT 메시지를 보내게 되면 SessionConnectEvent가 발생하고
이때 header에 있는 token을 추출해서 검증을 한다.
@RequiredArgsConstructor
@Component
public class WebSocketAuthListener {
private final TokenProvider tokenProvider;
@EventListener
public void handleWebSocketConnectListener(SessionConnectEvent event) {
var accessor = StompHeaderAccessor.wrap(event.getMessage());
var authHeaders = accessor.getNativeHeader("Authorization");
if (authHeaders == null || authHeaders.isEmpty()) {
throw new AuthenticationException(BaseResponseStatus.AUTHENTICATION_ERROR);
}
String token = authHeaders.get(0).replace("Bearer ", "");
try {
tokenProvider.verifyToken(token);
} catch (Exception e) {
throw new AuthenticationException(AUTHENTICATION_ERROR);
}
}
}
2. Redis Pub/Sub 사용하기
메모를 수정하는 요청이 오면 메모를 수정하고 저장하고 broadcasting을 통해
websocket으로 접속해있는 다른 유저들에게도 응답을 해줘야 한다.
server 인스턴스가 1개라면 broadcasting 로직으로 충분하지만
여러 개라면 외부 broker를 통해 다른 server 인스턴스로 이벤트를 전파시켜야한다.
redis pub/sub은 channel 단위로 메시지를 전송한다.
메시지를 중간에 저장하지 않고 실시간으로 전송하는데 성능이 좋다.
이 프로젝트에서는 redis pub/sub을 사용하였다.

3. 구현

client의 요청을 받아서 적절한 형식으로 변환한다.
@MessageMapping("/move-memo")
void moveMemo(
@Valid MoveMemoRequestDto memoRequestDto) {
memoService.moveMemo(
memoRequestDto.createMemoDto(),
memoRequestDto.getRoomId(),
memoRequestDto.getUserId(),
memoRequestDto.getMemoId()
);
}
메모를 수정하고 저장한다. 이후 메모가 수정되었다는 이벤트를 MEMO_EDIT_CHANNEL로 publish한다.
@Transactional
public void updateMemo(UpdateMemoDto dto, Long roomId, Long userId, Long memoId) {
roomStore.getRoomEnteredByUser(userId, roomId);
var memo = memoStore.getMemo(memoId);
checkAuthorization(memo, userId);
memo.updateMemo(dto.getMemo(), dto.getMemoColor());
memoStore.saveMemo(memo);
publisher.publish(MEMO_EDIT_CHANNEL,
MemoEditEvent.builder()
.memoId(memo.getId())
.memoColor(dto.getMemoColor())
.xPosition(memo.getXPosition())
.yPosition(memo.getYPosition())
.memoItem(dto.getMemo())
.roomId(roomId)
.build()
);
}
모든 server 인스턴스는 MEMO_EDIT_CHANNEL을 구독하고 있는 아래 listener를 통해 메시지를 받는다.
메시지를 파싱하고 websocket 로직을 담당하고 있는 api server로 전달하기 위해
ApplicationEventPublisher를 사용해서 전달한다.
@Component
@RequiredArgsConstructor
public class MemoEditMessageListener implements MessageListener {
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
@Override
public void onMessage(Message message, byte[] pattern) {
try {
String json = new String(message.getBody(), StandardCharsets.UTF_8);
Object[] array = objectMapper.readValue(json, Object[].class);
var memoMessage = objectMapper.convertValue(array[1], MemoEditEvent.class);
eventPublisher.publishEvent(memoMessage);
} catch (Exception e) {
throw new ServerErrorException("failed to deserialize MemoEditEvent from Redis Pub/Sub");
}
}
}
broadcasting을 통해 응답한다.
@Component
@RequiredArgsConstructor
public class MemoEventHandler {
private final SimpMessagingTemplate messagingTemplate;
@EventListener
public void handleMemoEdited(MemoEditEvent event) {
messagingTemplate.convertAndSend("/topic/room/" + event.getRoomId() + "/memo/" + event.getMemoId() + "/edit", event);
}
}'프로젝트 > 집안일 관리 시스템' 카테고리의 다른 글
| [유지보수성 개선] 소셜 로그인 기능에 전략 패턴을 적용해보자 (0) | 2025.08.11 |
|---|---|
| [보안 강화] Refresh Token을 사용해보자 (2) | 2025.08.10 |
| [성능 개선] Batch 작업을 최적화해보자 (1) | 2024.12.04 |
| [성능 개선] 유니온 쿼리를 개선해보자 (0) | 2024.01.08 |
| [성능 개선] 데이터베이스로 날라가는 쿼리의 수를 줄여보자 (0) | 2024.01.07 |