

예약 정보 업데이트 기능을 구현한 뒤 플로우차트를 만들면서 설계상 잘못된 점을 발견했다.
웹 소켓 프로토콜은 양뱡향 통신 프로토콜이다. HTTP 프로토콜과는 달리 클라이언트와 서버 사이 연결을 유지하면서 자유롭게 데이터를 주고 받을 수 있다는 이점이 있다.
그런데 지금 설계는 서버에서 클라이언트 단방향으로만 메시지가 전달되고 있다. 클라이언트가 웹 소켓을 이용해서 서버에 전달하는 데이터가 존재하지 않는 것이다.
서버에서 클라이언트로 단방향 메시지를 주기적으로 전송하는 경우 효율적인 방법을 찾아보니 Server-Sent Events (SSE)가 좋은 대안이 될 수 있을 것 같다. SSE는 주기적인 데이터 업데이트나 이벤트 스트림에 최적화되어 있고, 웹 소켓과는 다르게 연결이 끊어졌을 경우 자동 재연결 기능도 제공하며 구현도 간단하다.
그러나, 이번 프로젝트에선 웹 소켓 사용하면서 공부하기로 결정했으므로 클라이언트에서 서버로 메시지를 전달하는 상황을 설계해보기로 했다.

https://velog.io/@devtab/unispace-실시간-예약-시스템-구현1-redis를-이용한-예약-정보-선점-기능
기존 코드에서 Redis를 이용한 예약 시간 선점 기능을 구현할 때, 사용자가 예약 시간을 선택할 때 마다 서버로 선택된 예약 시간 선점을 요청하는 HTTP Request를 전송했다.
@PostMapping("/reservation/lock")
public ResponseEntity<?> lockTimeSlot(@RequestBody TimeSlotLockRequest request, Authentication authentication) {
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
User user = userDetails.getUser();
boolean locked = reservationService.lockTimeSlot(request.getRoomId(), request.getReserveDate(), request.getTimeSlotId(), user.getId());
return ResponseEntity.ok(new Result<>(200, locked ? "시간대 선점 성공" : "이미 선점된 시간대입니다", locked));
}
이 요청을 웹 소켓 프로토콜을 통해서 메시지로 전송하도록 설계를 수정했다.
https://velog.io/@devtab/unispace-실시간-예약-시스템-구현2-실시간-예약-정보-업데이트-기능-구현
실시간 예약 정보 업데이트를 구현할 때는 예약 정보가 갱신되었을 경우messagingTemplate을 이용해서 클라이언트에 메시지를 전달했었다.

그러나 새로 설계된 상황에서 서버는 클라이언트로부터 전달된 메시지를 받아서 처리해야 한다. 이를 처리하는 웹 소켓 컨트롤러가 필요하다.
@RestController
@RequiredArgsConstructor
public class ReservationWebSocketController {
private final UserService userService;
private final ReservationService reservationService;
@MessageMapping("/reservation/{roomId}/lock")
@SendTo("/topic/reservations/{roomId}")
public LockUpdateMessage lockTimeSlot(@DestinationVariable Long roomId, TimeSlotLockRequest request) {
User user = userService.getUserById(request.getUserId());
boolean checkLocked = reservationService.isTimeSlotLocked(roomId, request.getReserveDate(), request.getTimeSlotId());
if(checkLocked) { // 선택한 시간대가 이미 선택될 시간대인 경우 true를 반환
return new LockUpdateMessage(
"LOCK_UPDATE",
roomId,
request.getUserId(),
request.getReserveDate(),
request.getTimeSlotId(),
request.getCurrentTimeSlotId(),
true,
"이미 선점된 시간대입니다"
);
}
else {
reservationService.lockTimeSlot(roomId, request.getReserveDate(), request.getTimeSlotId(), user.getId());
return new LockUpdateMessage(
"LOCK_UPDATE",
roomId,
request.getUserId(),
request.getReserveDate(),
request.getTimeSlotId(),
request.getCurrentTimeSlotId(),
false,
"시간대 선점 성공"
);
}
}
@MessageMapping("/reservation/{roomId}/unlock")
@SendTo("/topic/reservations/{roomId}")
public LockUpdateMessage unlockTimeSlot(@DestinationVariable Long roomId, TimeSlotLockRequest request) {
reservationService.unlockTimeSlot(roomId, request.getReserveDate(), request.getTimeSlotId());
return new LockUpdateMessage(
"UNLOCK_UPDATE",
roomId,
request.getUserId(),
request.getReserveDate(),
request.getTimeSlotId(),
request.getCurrentTimeSlotId(),
false,
"시간대 락 해제 성공"
);
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class TimeSlotLockRequest {
private Long userId;
private Long roomId;
private LocalDate reserveDate;
private Long timeSlotId;
private Long currentTimeSlotId;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class LockUpdateMessage {
private String type;
private Long roomId;
private Long userId;
private LocalDate reserveDate;
private Long timeSlotId;
private Long formerTimeSlotId;
private boolean isLocked;
private String message;
}
@MessageMapping: 클라이언트가 특정 URL로 메시지를 보낼 때 해당 메서드를 실행하도록 매핑한다./reservation/{roomId}/lock 및 /reservation/{roomId}/unlock URL로 온 메시지를 각각 lockTimeSlot 메서드와 unlockTimeSlot 메서드에서 처리한다.
@SendTo: 메서드의 반환 값을 특정 URL로 구독 중인 모든 클라이언트에게 전송한다. /topic/reservations/{roomId}를 구독하는 모든 클라이언트에게 LockUpdateMessage 객체를 전달한다.
lockTimeSlot 메서드와 unlockTimeSlot 메서드는 모두 LockUpdateMessage 객체를 반환한다. 이 객체는 @SendTo 어노테이션에 의해 /topic/reservations/{roomId} 를 구독하는 모든 클라이언트에게 전송된다.
스프링 시큐리티를 사용하면 @SendToUser 어노테이션을 통해 요청을 보낸 클라이언트 에게만 메시지를 전송할 수 있다고 한다. 일단 이번에는 클라이언트에서 메시지를 처리하기로 했다.
lockTimeSlot 내부 코드는 별다를 것 없다. 전송받은 시간대가 이미 잠금 상태인 경우 선점된 시간대라는 메시지를 전달하고, 잠금 상태가 아닌경우 reservationService.lockTimeSlot() 을 통해 해당 시간대를 잠근 뒤 선점 성공 메시지를 전송한다.
또한, 기존에 웹 소켓이 수행하던 실시간 예약 정보 업데이트 기능에서 서버 => 클라이언트로 전달되던 메시지에도 type 정보를 추가했다.
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ReservationUpdateMessage {
private String type = "RESERVATION_UPDATE";
private Long roomId;
private LocalDate reserveDate;
private Long timeSlotId;
기존에 클라이언트는 서버로부터 메시지를 받으면 예약 정보를 업데이트 하는 함수 handleReservationUpdate를 실행했다.
const connectWebSocket = () => {
const socket = new SockJS('http://localhost:8080/ws');
const client = Stomp.over(socket);
client.connect({}, () => {
setStompClient(client);
client.subscribe(`/topic/reservations/${roomId}`, (message) => {
const reservationUpdate = JSON.parse(message.body);
handleReservationUpdate(reservationUpdate);
});
});
};
handleReservationUpdate는 예약 정보를 출력하는 컴포넌트를 업데이트 하는 역할만을 수행했다.
const handleReservationUpdate = useCallback((update) => {
setScheduleData(prevScheduleData => {
return prevScheduleData.map(dateItem => {
if (dateItem.availableDate === update.reserveDate) {
const updatedAvailableTimes = dateItem.availableTimes.filter(
timeSlot => timeSlot.timeSlotId !== update.timeSlotId
);
const updatedUnavailableTimes = [
...dateItem.unavailableTimes,
dateItem.availableTimes.find(timeSlot => timeSlot.timeSlotId === update.timeSlotId)
];
return {
...dateItem,
availableTimes: updatedAvailableTimes,
unavailableTimes: updatedUnavailableTimes
};
}
return dateItem;
});
});
}, [selectedTimeSlot]);
바뀐 설계에 따라서 클라이언트가 전달받는 메시지에 따라 수행해야 하는 역할이 달라지기 때문에 코드의 대대적인 수정이 필요했다.
const handleReservationUpdate = (update) => {
const userID = sessionStorage.getItem('user_id');
if (update.type === 'RESERVATION_UPDATE') {
setScheduleData(prevData => prevData.map(day => {
if (day.availableDate === update.reserveDate) {
const updatedAvailableTimes = day.availableTimes.filter(
time => time.timeSlotId !== update.timeSlotId
);
const updatedUnavailableTimes = [
...day.unavailableTimes,
day.availableTimes.find(time => time.timeSlotId === update.timeSlotId) ||
{ timeSlotId: update.timeSlotId, startTime: '', endTime: '' }
];
return {
...day,
availableTimes: updatedAvailableTimes,
unavailableTimes: updatedUnavailableTimes
};
}
return day;
}));
} else if (userID.toString() === update.userId.toString()) {
if(update.type === 'LOCK_UPDATE') {
if(update.locked === true) { // 이미 선점된 시간대일 경우
setErrorMessage(update.locked ? update.message : '');
setSelectedTimeSlot(update.formerTimeSlotId);
}
else{ //선점된 시간대가 아닐 경우
setErrorMessage(update.locked ? update.message : '');
setSelectedTimeSlot(update.timeSlotId)
}
}
else if(update.type === 'UNLOCK_UPDATE') {
setSelectedTimeSlot(null);
}
}
};
handleReservationUpdate는 웹 소켓 통신을 통해 서버로부터 전달받은 메시지 속 type에 따라 수행하는 역할이 달라진다.
type = 'RESERVATION_UPDATE' : 예약 정보가 업데이트된 경우 전달되는 메시지.type = 'LOCK_UPDATE', 'UNLOCK_UPDATE' : 예약 정보 선점 기능과 관련된 서버로부터 전달된 메시지.const [selectedTimeSlot, setSelectedTimeSlot] = useState(0);
const handleTimeSlotSelect = async (timeSlot) => {
if (selectedTimeSlot !== 0) {
await sendUnlockMessage(selectedTimeSlot);
}
await sendLockMessage(timeSlot);
};
const sendLockMessage = async (timeSlot) => {
const userID = sessionStorage.getItem('user_id');
const lockMessage = {
userId: userID,
roomId: parseInt(roomId),
reserveDate: selectedDate,
timeSlotId: timeSlot.timeSlotId,
currentTimeSlotId : selectedTimeSlot
};
stompClient.current.publish({
destination: `/app/reservation/${roomId}/lock`,
body: JSON.stringify(lockMessage)
});
}
const sendUnlockMessage = async () => {
const userID = sessionStorage.getItem('user_id');
const unlockMessage = {
userId: userID,
roomId: parseInt(roomId),
reserveDate: selectedDate,
timeSlotId: selectedTimeSlot
};
stompClient.current.publish({
destination: `/app/reservation/${roomId}/unlock`,
body: JSON.stringify(unlockMessage)
});
};
사용자가 시간대를 선택할 경우 handleTimeSlotSelect가 호출된다.
selectedTimeSlot은 세가지 종류의 값을 가질 수 있다.
selectedTimeSlot이 0이 아닌 값을 가질 경우
sendUnlockMessage 를 호출하여 원래 선점하고 있었던 시간대의 선점을 해제시켜야 한다. 그 이후 sendLockMessage을 호출하여 새로 선택한 시간대의 선점을 시도한다.
새로 선택한 시간대가 선점 가능한 시간대인 경우
else {
setErrorMessage(update.locked ? update.message : '');
setSelectedTimeSlot(update.timeSlotId)
}
setSelectedTimeSlot를 통해 selectedTimeSlot의 값을 갱신한다.
새로 선택한 시간대가 이미 선점되어 선점 불가능한 시간대인 경우
if(update.locked === true) {
setErrorMessage(update.locked ? update.message : '');
setSelectedTimeSlot(update.formerTimeSlotId);
}
이런 경우를 대비하여 sendLockMessage을 통해 서버에 메시지를 보낼 때 currentTimeSlotId 항목에 원래 선점하고 있었던 시간대 정보를 같이 전송하고, 서버에서는 응답시 formerTimeSlotId에 해당 정보를 담아 전송한다.
sendLockMessage를 호출하기 전 sendUnlockMessage에 대한 응답으로
selectedTimeSlot의 값이 null로 초기화 되어 있기 때문에 formerTimeSlotId을 이용하여 selectedTimeSlot의 값을 원래 선점하고 있던 시간대로 다시 설정해 주어야 한다.


웹 소켓을 활용하여 구현한 두 가지 기능이 모두 제대로 동작했다.
지금 내가 작성한 코드는 기능의 구현을 최우선 목표로 두고, 실제 서비스 환경에서 발생할 수 있는 예외 상황에 대한 처리에 신경을 쓰지 못했다.
대규모 트래픽이 발생하는 서비스 환경에서 웹소켓 방식으로 기능을 구현할 경우 HTTP 프로토콜을 사용하는 것과 비교할 때 서버 리소스 사용량이 증가하고 보안 설정이 복잡해지며 네트워크 불안정시 메시지의 유실 가능성 또한 존재한다.
벡엔드에서 보완해야 할 점
프론트엔드에서 보완해야 할 점
또한 웹 소켓과 관련해서 리엑트 코드를 작성하는 것이 까다로웠다. 서버로부터 전달되는 메시지 처리와 상태 관리를 동시에 신경써야 하는 부분이 어려웠지만,웹 소켓을 사용했을 때 클라이언트의 전반적인 동작 흐름을 이해하는 데 큰 도움이 되었다.