[unispace] 실시간 예약 시스템 구현(4) - 예약 정보 선점 기능 웹소켓으로 리팩터링

Deeeep Breath·2024년 8월 3일

unispace

목록 보기
8/12
post-thumbnail

예약 정보 업데이트 기능을 구현한 뒤 플로우차트를 만들면서 설계상 잘못된 점을 발견했다.

웹 소켓 프로토콜은 양뱡향 통신 프로토콜이다. 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 : 예약 페이지에 처음 진입했을때. Default 값
  • selectedTimeSlot = null : 코드 진행 중 임시로 가지는 값
  • 그 외 : 사용자가 선택한 시간대를 나타내는 값

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 프로토콜을 사용하는 것과 비교할 때 서버 리소스 사용량이 증가하고 보안 설정이 복잡해지며 네트워크 불안정시 메시지의 유실 가능성 또한 존재한다.

벡엔드에서 보완해야 할 점

  • 웹 소켓 프로토콜 환경에서의 권한 인증 처리
  • 외부 메시지 브로커(RabbitMQ, Kafka) 설정
  • 동시성 제어를 위한 트렌젝션 메커니즘 점검
  • 서버 부하 분산을 위한 클러스터링 설정

프론트엔드에서 보완해야 할 점

  • 웹소켓 연결 상태를 관리하고 UI에 반영
  • 웹소켓 연결 장애시 재연결 로직을 구현
  • 메시지 전송 실패에 대한 처리를 구현
  • 백그라운드 연결 유지 구현

또한 웹 소켓과 관련해서 리엑트 코드를 작성하는 것이 까다로웠다. 서버로부터 전달되는 메시지 처리와 상태 관리를 동시에 신경써야 하는 부분이 어려웠지만,웹 소켓을 사용했을 때 클라이언트의 전반적인 동작 흐름을 이해하는 데 큰 도움이 되었다.

profile
안녕하세요!

0개의 댓글