본문 바로가기
항해 플러스 백엔드/서버 구축

[항해] 4주차, 서버 구축 기능 개발

by 코딩맛 2025. 1. 29.

 

항해 4주차에는 Swagger로 API 명세서를 작성하고, 3주차 설계를 토대로 주요 비즈니스 로직 개발 및 단위 & 통합 테스트를 작성하였다!

설계를 마치고 막상 개발에 들어가려고 하니 무척 떨리고 설레였다. (*´ ワ `*)

기능 개발 과정을 자세히 살펴보자~!

머기열 딱대!

목차
1. Swagger API 문서화
2. 주요 비즈니스 로직 완성 및 단위 테스트 작성
3. 비즈니스 Usecase 구현 및 통합 테스트 작성
4. 회고

 

1. Swagger API 문서화

개발에 들어가기 앞서, Mock API 를 생성한 후 Swagger 도구를 활용하여 편리하게 API를 호출하고 테스트 할 수 있게 하였다!

Swagger를 사용하기 위해서는 아래와 같이 설정을 해주면 되는데 생각보다 설정이 빨리 끝나는 것을 확인할 수 있다!

 

1. Swagger 라이브러리 추가

2. Config 파일 추가

3. yml 혹은 properties 파일에 스웨거 관련 환경변수 추가

 

처음에 라이브러리 버전을 v2.0.0으로 추가해주었는데 이때 500에러(서버 내부 오류)가 발생하여, v2.8.0으로 바꾸니 정상적으로 페이지가 로딩되었다. 😮‍💨

스프링부트 버전 3.4.x부터는 2.7.x - 2.8.x 버전을 사용해야 한다고 springdoc-open api 레퍼런스의 FAQ에 나와있다!

https://springdoc.org/faq.html#_what_is_the_compatibility_matrix_of_springdoc_openapi_with_spring_boot

 

 

이어서 API 명세서 초안을 보면 기능은 크게 5가지로 나뉘어져 있는 것을 확인할 수 있다.

 

- 잔액 충전/조회 API 

- 유저 토큰 발급 API

- 예약 가능 날짜/좌석 조회 API

- 좌석 예약 API

- 결제 API

Swagger로 작성한 API 명세서

 

각 API 오른쪽 끝에 아래로 향하는 화살표를 클릭해보면 Parameter나 Request Body에 값을 넣어서 요청을 실행할 수 있다.


2. 주요 비즈니스 로직 완성 및 단위 테스트 작성

 

위 내용은 과제 요구사항인데, 각 기능별 핵심 포인트 및 개발 과정에 대해 살펴보자! 👀 

 

유저 토큰 발급 API

- 유저 ID로 토큰을 발급하고 DB에는 대기열 토큰 상태를 '대기' 상태로 저장한다.

public Queue create(Long userId) {
    return Queue.builder()
            .userId(userId)
            .queueStatus(QueueStatus.WAIT)
            .expiredAt(LocalDateTime.now().plusMinutes(10))
            .build();
}

 

- API 호출 후 데이터를 응답하면서 Header에 토큰 & 유저ID를 key-value 형태로 저장한다.

@Operation(summary = "유저 대기열 토큰 발급")
@PostMapping("/queues")
public ApiResponse<QueueTokenResponse> createQueueToken(@RequestBody QueueTokenRequest queueTokenRequest, HttpServletResponse response){

    QueueTokenParam queueTokenParam = QueueTokenParam.from(queueTokenRequest);
    QueueTokenResponse queueTokenResponse = QueueTokenResponse.from(queueFacade.createQueueToken(queueTokenParam));

    response.setHeader("Queue-Token-Queue-Id", String.valueOf(queueTokenResponse.queueId()));
    response.setHeader("Queue-Token-User-Id", String.valueOf(queueTokenResponse.userId()));

    return ApiResponse.success(ResponseCode.TOKEN_CREATE_SUCCESS.getMessage(), queueTokenResponse);
}

 

- 이후 모든 API는 위 토큰을 이용해 대기열 검증을 통과해야 이용 가능하도록 한다.
  (대기열 토큰이 만료되지 않고, 상태가 '활성'인 것만 이용 가능하도록 제한한다.)

이 부분은 특정 API에서만 호출되기 전에 검증이 되어야 하므로

interceptor나 filter 내용을 다루는 5주차(리팩토링)에 자세히 다뤄보도록 하자.

 

 

예약 가능 날짜 / 좌석 API

- 예약 가능 날짜 조회 : 콘서트Id로 예약 가능 날짜를 조회한다.

Header에 QueueId를 넣고 GET 요청
예약 가능 날짜 조회

 

- 예약 가능 좌석 조회 : 예약 가능 날짜에서 조회된 스케줄Id로 예약 가능 좌석을 조회한다. 

Header에 QueueId를 넣고 GET 요청
예약 가능 좌석 조회

 

잔액 충전 / 조회 API

- 잔액 조회 비즈니스 로직 : 유저 Id로 잔액 정보를 조회한다.

public User findById(Long userId) {
    User user = userRepository.findById(userId)
            .orElseThrow(()-> new UserNotFoundException("사용자의 정보가 없습니다."));
    return user;
}

 

- 잔액 충전 비즈니스 로직 : 유저 Id로 정보를 조회하고 포인트를 충전한 뒤, 포인트 내역에 해당 이력을 저장한다.

@Transactional
public User charge(Long userId, BigDecimal amount) {
    User user = userRepository.findById(userId)
            .orElseThrow(()-> new UserNotFoundException("사용자의 정보가 없습니다."));

    BigDecimal balanceBefore = user.getPointBalance();

    User userUpdate = user.charge(amount);
    User userSave = userRepository.save(userUpdate);

    PointHistory pointHistory = PointHistory.builder()
            .userId(userId)
            .transMethod(TransMethod.CHARGE)
            .transAmount(amount)
            .balanceBefore(balanceBefore)
            .balanceAfter(userUpdate.getPointBalance())
            .build();

    PointHistory pointHistorySave = pointHistoryRepository.save(pointHistory);
    return userSave;
}

 

3. 비즈니스 Usecase 구현 및 통합 테스트 작성

좌석 예약 요청 API

- 좌석 예약 요청 :  좌석 상태를 '점유'로 변경하고, 스케줄의 잔여 티켓 수를 차감한 뒤 예약 정보를 DB에 저장한다.

@Transactional
public ReservationResult createSeatReservation(ReservationParam reservationParam) {
    //1. 좌석(seat) 상태 업데이트 (점유)
    Seat updatedSeat = concertService.updateSeatStatus(reservationParam.seatId(), SeatStatus.OCCUPIED);
    //2. 스케줄(schedule) 잔여 티켓 수 업데이트(-1)
    Schedule updatedSchedule = concertService.updateScheduleRemainingTicket(reservationParam.scheduleId(), -1);
    //3. 예약(reservation) 신청
    Reservation savedReservation = reservationService.creatSeatReservation(updatedSeat, reservationParam.userId());

    return new ReservationResult(savedReservation.getId(), updatedSchedule.getId(),
            savedReservation.getSeatId(), savedReservation.getUserId(), savedReservation.getReservationState(), savedReservation.getCreatedAt());
}

 

결제 API

- 결제 요청 : 예약 정보를 '결제 완료'로 변경한 뒤, 사용자 잔액에서 예약 좌석 가격을 차감, 좌석 상태를 점유로 변경, 스케줄 정보의 잔여 티켓 수 차감 후 결제 정보를 생성하고 결제 API에 진입하기 위해 사용한 대기열 토큰을 제거한다. 

@Transactional
public PaymentResult createPayment(PaymentParam paymentParam) {

    //예약 정보 업데이트
    Reservation reservation = reservationService.updateReservation(paymentParam.reservationId(), paymentParam.seatId());

    Payment savedPayment = null;

    if(reservation.getReservationState().equals(ReservationState.PAID)){
        //1. *사용자* 잔액 - *예약* 좌석가격
        User user = userService.use(paymentParam.userId(), reservation.getSeatPrice());

        //2. *좌석* 상태 'OCCUPIED'으로 변경
        Seat updatedSeat = concertService.updateSeatStatus(reservation.getSeatId(), SeatStatus.OCCUPIED);

        //3. *스케줄* 잔여 티켓 업데이트 -1
        Schedule updatedSchedule = concertService.updateScheduleRemainingTicket(updatedSeat.getSchedule().getId(), -1);

        //4. *결제* 생성
        savedPayment = paymentService.createPayment(updatedSchedule, updatedSeat, reservation);

        //5. *유저 대기열 토큰* 제거
        Queue updatedQueue = queueService.updateQueue(paymentParam.queueId());
    } else {
        throw new ReservationBadStatusException("유효하지 않은 예약 상태입니다.");
    }

    return new PaymentResult(savedPayment.getId(), savedPayment.getReservationId(), paymentParam.seatId(),
            savedPayment.getConcertDateTime(), savedPayment.getPaymentAmount(), savedPayment.getPaymentStatus(), savedPayment.getCreatedAt());
}

 

대기열 토큰 활성 / 만료 스케줄러

- 대기열 토큰 활성 : 스케줄러를 설정하여 대기열의 '대기' 상태의 토큰을 활성화한다.

 

QueueScheduler 컴포넌트에서 활성 스케줄러가 1분마다 실행되도록 하면,

QueueService에서 '대기' 상태 토큰을 10개씩 가져와서 '활성' 상태로 업데이트하는 로직이다.

@Scheduled(fixedRate = 10000) // 1분마다 실행
public void processQueue() {
    // 대기 토큰 활성화
    queueService.activeToken();
}
@Transactional
public void activeToken() {
    List<Queue> pendingQueues = queueRepository.findTopNByWaitStatusOrderByCreatedAt("WAIT", 10);

    List<Long> queueIds = pendingQueues.stream()
            .map(Queue::getId)
            .collect(Collectors.toList());

    // 활성 상태로 업데이트
    queueRepository.updateQueueStatus(QueueStatus.ACTIVE, queueIds);
}

 

- 대기열 토큰 만료 : 스케줄러를 설정하여 대기열에서 만료 시간이 지난 토큰을 제거한다.

 

QueueScheduler 컴포넌트에서 만료 스케줄러가 1분마다 실행되도록 하면,

QueueService에서 현재 시각을 repository에 전달하면 Jpa에서 현재 시각과 만료 시각을 비교하여 만료된 토큰을 제거하는 비즈니스 로직이다.

@Scheduled(fixedRate = 10000) // 1분마다 실행
public void expiredQueue() {
    // 만료시각 지난 토큰 제거
    queueService.deleteToken();
}
@Transactional
public int deleteToken() {
    LocalDateTime now = LocalDateTime.now();
    return queueRepository.deleteExpiredTokens(now);
}

 

 

💡 고민 포인트

모든 API 는 위 토큰을 이용해 대기열 검증을 통과해야 이용 가능하다.

 

API 수행 전에 대기열 토큰 정보를 입력 받아서 유효한 토큰인지 검증을 해야하는데 이 과정을 어디서 수행해야 할지 고민이 되었다.

controller에서 facade나 service 호출 전에 검증을 하면 동일한 로직이 반복되어 코드의 중복이 일어난다. 그리고 controller는 주로 요청을 처리하는 역할을 하는데 검증 로직이 포함되면 역할 분리가 되지 않아서 SRP(단일 책임 원칙)를 위반하게 된다.

이를 해결하기 위해서는 Interceptor나 AOP로 따로 분리하거나 Validator에서 처리하는 방법이 있는데 이 중 공통 검증(예: 인증, 권한 확인)은 Interceptor에서 검증하는 것이 적합하다고 한다. (차주 적용 예정)

https://gose-kose.tistory.com/10

 

[SpringBoot] 인터셉터(Interceptor)

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다. 이번 포스팅은 SpringBoot의 Interceptor에 대한 내용과 활용법에 대해 정리하고자 합니다. 먼저, 이번 포스팅에 많은 도움을 준 블로그 주소는 아

gose-kose.tistory.com

 

 

좌석 예약과 동시에 해당 좌석은 그 유저에게 약 5분간 임시 배정됩니다. 만약 배정 시간 내에 결제가 완료되지 않는다면 좌석에 대한 임시 배정은 해제되어야 하며 다른 사용자는 예약할 수 있어야 한다. 

 

5분간 임시 배정하고 그 이후에 해제가 되도록 하려면 해당 좌석이 만료되었는지 주기적으로 체크를 해야한다.
초반에는 결제 요청을 하면 예약 조회할 때 만료 시간을 체크하여 만료되었다면 좌석에 대한 임시 배정을 해제하고, 결제 취소하였다. 이렇게 했을 때 문제점은 유즈케이스에서 만료 여부에 따라 분기 처리 해야돼서 코드가 길어지고, 결제 요청이 일어날 때만 만료 체크를 하기 때문에 결제가 일어나지 않으면 임시 배정 해제가 되지 않아 해당 좌석에 다른 사용자가 예약할 수 없게 된다.
이를 해결하기 위해 임시 배정 만료 체크를 하는 스케줄러를 두어서 1분마다 만료되었는지 체크하도록 개선하였다.

@Scheduled(fixedRate = 1000) // 1초마다 실행
public void reservationExpiredCheck(){
    //임시 배정(5분) 만료 체크 후 다른 사용자가 이용할 수 있게 상태 변경
    reservationFacade.checkReservationExpiration();
}
@Transactional
public void checkReservationExpiration(){
    List<Reservation> reservations = reservationService.checkReservationExpiration();

    for (Reservation reservation : reservations) {
        // *좌석* 상태 'AVAILABLE'으로 변경
        Seat updatedSeat = concertService.updateSeatStatus(reservation.getSeatId(), SeatStatus.AVAILABLE);
        // *스케줄* 잔여 티켓 업데이트 +1
        Schedule updatedSchedule = concertService.updateScheduleRemainingTicket(updatedSeat.getSchedule().getId(), 1);
    }
}

 

4. 회고

이번 4주차는 정말 뜨겁게 불태운 한 주 였다! 발제가 비즈니스 로직이다 보니, 깊게 몰입하고 시간을 많이 쏟아야 했던 시간이었다.😄

설계에서 정의했던 내용을 바탕으로 요구사항에 충족하도록 API를 개발하는데 집중했다. 단순히 기본 기능만 구현한게 아니라 대기열 토큰 검증이나 스케줄러를 사용하여 토큰을 관리해주었다.

대기열의 필요성과 비동기 방식으로 어떻게 토큰을 관리할지 고민하면서 많이 배우고 생각할 수 있었다!

그리고 Controller에서 도메인 별로 Facade나 Service 중 어디를 거쳐 비즈니스 로직을 호출하는 것이 맞을지 고민하는 과정을 통해 단순히 코딩만 하는 것이 아니라 구조적인 측면에서 생각하게 되면서 개발자로서 성장하는 기분이 들었다.

한 주를 돌아보면, 여러 가지 고민과 결정하는 과정을 반복하면서 코드가 견고해지는 느낌을 받았다. 다음 주차에 진행하는 코드 리팩토링을 통해 품질을 더욱 높일 것이다.