JAVA2) project : 대기열 통합 시스템

지우·2026년 1월 9일

java2

목록 보기
12/15
post-thumbnail

[01.05] 프로젝트 제안

문제 탐색

평소 매장의 키오스크 또는 어플을 통해 주문을 자주 하는데 그때마다 나의 순서를 명확하게 알 수 없어 답답함을 겪어왔다. 이런 불편함을 개선하기 위한 주문 시스템과 관련된 프로젝트를 진행하고자 함

문제 분석 및 주제 선정

대기열 통합 시스템: 앱(배달)/매장(키오스크)에서 들어오는 주문을 합쳐서 하나의 대기열에 넣음, 다양한 플랫폼에서 들어오는 주문들을 모두 감안해서 나의 대기 시간을 예측해볼 수 있도록 함

[01.06] 프로젝트 계획

일정 수립 및 역할 분담

프로젝트 제안서

DB 담당과 View 담당으로 분담, 메인로직은 함께 구현하기로 함

[01.07-01.09] 기획 및 DB 설계

프로세스 정리

중간보고서1

DB 테이블 설계
주문 정보 테이블 : 주문 번호, 매장/배달 타입, 주문 시간, 조리 상태 등을 필드로 설정해 주문 정보에 따른 주문 번호를 할당하고 이를 대기열에 표시할 수 있도록 함
채팅 화면 테이블 : 채팅 번호, 주문 번호, 채팅 대상, 메세지 내용을 필드로 설정해 주문 번호에 따른 채팅이 섞이지 않고 잘 진행될 수 있도록 함

[01.10-01.12] 1차 개발

중간보고서2

DB table 필드 확정
1) 단순화, 조리 시간 관련 DB 삭제함
2) 채팅 화면 테이블 삭제, 채팅은 DB 통해 저장하지 않기로 함

Model/Controller 구조 정리 및 개발 진행

[01.13-01.14] 2차 개발

DB

Spring Boot의 MVC 패턴을 따르도록 패키지와 클래스 구성
1) Controller가 사용자의 요청을 받으면
2) Service가 계층에서 '최신 주문 조회' 등의 핵심 비즈니스 로직을 수행하고
3) 데이터가 필요하면 Repository에서 DB에 접근
4) 주문 상태 변경이 일어나면 ChatServiceWebSocket을 통해 사용자에게 상태 변경을 알림

OrderApplication

package com.jpl.smtm;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OrderApplication {

	public static void main(String[] args) {
		SpringApplication.run(OrderApplication.class, args);
		System.out.println("Project : Order Process start!");
	}

}

WebSocketConfig

웹소켓 연결 요청을 허용하고, 어떤 핸들러가 처리할지 연결해주는 설정 담당

package com.jpl.smtm.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

import com.jpl.smtm.handler.ChatHandler;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	private final ChatHandler chatHandler;
	
	public WebSocketConfig(ChatHandler chatHandler) {
		this.chatHandler = chatHandler;
	}
	
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
		registry.addHandler(chatHandler, "/ws/chat").setAllowedOrigins("*");
	}
}

ChatMessage

DB에는 저장되지 않고 통신용으로만 사용 (DTO)

package com.jpl.smtm.dto;

import java.util.List;

import com.jpl.smtm.constant.OrderStatus;
import com.jpl.smtm.constant.OrderType;

public class ChatMessage {
	
	private String type;
	private Integer orderNumber;
	private OrderType orderType;
	private OrderStatus orderStatus;
	private List<Integer> waitingList;
	
	public ChatMessage() {}

	public ChatMessage(String type, Integer orderNumber, OrderType orderType, OrderStatus orderStatus, List<Integer> waitingList) {
		this.type = type;
		this.orderNumber = orderNumber;
		this.orderType = orderType;
		this.orderStatus = orderStatus;
		this.waitingList = waitingList;
	}

	public String getType() {return type;}
	public Integer getOrderNumber() {return orderNumber;}
	public OrderType getOrderType() {return orderType;}
	public OrderStatus getOrderStatus() {return orderStatus;}
	public List<Integer> getWaitingList() {return waitingList;}
    
	public void setType(String type) {this.type = type;}
	public void setOrderNumber(Integer orderNumber) {this.orderNumber = orderNumber;
	public void setOrderType(OrderType orderType) {this.orderType = orderType;}
	public void setOrderStatus(OrderStatus orderStatus) {this.orderStatus = orderStatus;}
	public void setWaitingList(List<Integer> waitingList) {this.waitingList = waitingList;}
}

[01.14-01.15] 개발 마무리

수정 사항 및 추가 기능 구현

OrderStatus, OrderType

기존에 String으로 정의했던 주문 상태와 주문 종류를 enum으로 수정하여 더 편한 관리가 가능하도록 함

package com.jpl.smtm.constant;

public enum OrderStatus {
	WAITING,	//조리중
	COMPLETED,	//조리완료
	DELIVERING	//배달중
}
package com.jpl.smtm.constant;

public enum OrderType {
	STORE,	//매장
	DELIVERY //배달
}

Order

OrderStatus, OrderType을 enum으로 수정함에 따라 Order 클래스도 이에 맞게 수정함

package com.jpl.smtm.entity;

import java.time.LocalDateTime;

import com.jpl.smtm.constant.OrderStatus;
import com.jpl.smtm.constant.OrderType;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "yourorder")
public class Order {
	
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id; //DB 내부 관리용
	
	@Column(name = "orderNumber")
	private Integer orderNumber;
	
	@Enumerated(EnumType.STRING)
	@Column(name = "orderType")
	private OrderType orderType;
	
	@Enumerated(EnumType.STRING)
	@Column(name = "orderStatus")
	private OrderStatus orderStatus;
	
	@Column(name = "regDate")
	private LocalDateTime regDate;

	public Order() {}
	
	public Order(Integer orderNumber, OrderType orderType, OrderStatus orderStatus) {
		this.orderNumber = orderNumber;
		this.orderType = orderType;
		this.orderStatus = orderStatus;
	}
	
	public void prePersist() {this.regDate = LocalDateTime.now();}
	public Integer getId() {return id;}
	public Integer getOrderNumber() {return orderNumber;}
	public OrderType getOrderType() {return orderType;}
	public OrderStatus getOrderStatus() {return orderStatus;}
	public LocalDateTime getRegDate() {return regDate;}
    
	public void setId(Integer id) {this.id = id;}
	public void setOrderNumber(Integer orderNumber) {this.orderNumber = orderNumber;}
	public void setOrderType(OrderType orderType) {this.orderType = orderType;}
	public void setOrderStatus(OrderStatus orderStatus) {this.orderStatus = orderStatus;}
	public void setRegDate(LocalDateTime regDate) {this.regDate = regDate;}
}

OrderController

외부와 소통하는 접수 창구로 HTTP 요청을 받음
@PostMapping, @GetMapping, @PutMapping 등의 어노테이션을 통해 구현

발생했던 에러들 )
@PathVariable 에러 :

URL 경로에 있는 변수를 받아오기 위해 코드를 작성했으나 아래와 같은 HTTP 500 에러가 발생함

스프링은 실행 시점에 해당 메소드의 파라미터 이름을 보고 URL의 orderNumber와 매칭하려고 시도하는데, 컴파일 후 변수명이 사라져버리면 스프링은 orderNumber을 어디에 넣어야할지 모르게 되어 에러가 발생한다는 것을 알게됨
-> 그래서 항상 안정적으로 동작하도록 @PathVariable 어노테이션에 매핑할 이름을 명시적으로 지정하여 에러 해결

@PatchMapping에서 @PutMapping으로 수정 :

주문 상태 값이 변경되지 않는 문제가 지속되어, status를 더 명확히 가르킬 수 있는 @PutMapping 이용하여 코드 수정

package com.jpl.smtm.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.jpl.smtm.entity.Order;
import com.jpl.smtm.service.OrderService;

@RestController
@RequestMapping("/api/orders")
public class OrderController {
	
	@Autowired
	private OrderService orderService;
	

	//새로운 주문 접수
	@PostMapping("/create")
	public Order createOrder(@RequestBody Order order) {
		return orderService.createOrder(order);
	}
	
	//전체 대기열 조회
	@GetMapping("/waiting")
	public List<Order> getWaitingQueue(){
		return orderService.getInitialQueue();
	}
	
    //내 주문상태 확인
	@GetMapping("/{orderNumber}")
    public Order getOrderInfo(@PathVariable("orderNumber") Integer orderNumber) {
        return orderService.getOrder(orderNumber);
    }
	
	//주문 상태 변경
	@PutMapping("/{orderNumber}/status")
	public Order updateStatus(@PathVariable ("orderNumber") Integer orderNumber, @RequestParam ("status") String status) throws Exception {
		return orderService.updateOrderStatus(orderNumber, status);
	}

}

OrderRepository

초반에는 Optional을 사용하여 주문번호로 데이터를 조회할 수 있도록 설계했지만, 테스트를 반복하면서 동일한 주문 번호가 DB에 여러 개 쌓였고, 이로 인해 서버 에러가 발생함
-> 중복 데이터가 있어도 서버가 멈추지 않도록 하기 위해 List 도입
findByOrderStatusInOrderByRegDataAsc 를 이용하여 최근 인덱스를 선택할 수 있도록 함

package com.jpl.smtm.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;

import com.jpl.smtm.constant.OrderStatus;
import com.jpl.smtm.entity.Order;


public interface OrderRepository extends JpaRepository<Order, Integer>{
	
	List<Order> findByOrderNumber(Integer orderNumber);
	List<Order> findByOrderStatusInOrderByRegDateAsc(List<OrderStatus> statuses);

}

ChatHandler

webSocket 연결이 끊기거나 새로운 메시지가 왔을 때 이를 감지하고 ChatService나 클라이언트에게 전달하는 역할

수정한 부분 )
List -> Set 변경 : 중복으로 인한 에러 방지를 위해 변경함
Set은 데이터의 중복을 허용하지 않기 때문에 동일한 세션이 여러 번 등록되는 것을 막아 통신 안정성을 높이고자 함

broadcastMessage와 handleTextMessage 분리 :
기존에는 handleTextMessage 안에서 메시지를 받고 전달하는 로직이 섞여있었는데, 이로 인해 주문 상태 변경과 채팅이 원활히 이루어지지 않음
SRP를 따라, handleTextMessage는 메시지 수신, broadcastMessage는 메시지 전송에 더 집중할 수 있도록 수정함

package com.jpl.smtm.handler;

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

@Component
public class ChatHandler extends TextWebSocketHandler{
	
	//List->Set으로 수정: 중복 저장 방지
	private final Set<WebSocketSession> sessions = Collections.synchronizedSet(new HashSet<>());
	
	//클라이언트와 웹소켓 연결 시
	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		sessions.add(session);
		System.out.println("새로운 연결: " + session.getId());
	}
	
	//클라이언트에게 메시지 도착 시
	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception{
		String payload = message.getPayload();
		System.out.println("받은 메세지: " + payload);
		broadcastMessage(payload);
	}
	
	//broadcastMessage를 handleTextMessage와 분리함
	public void broadcastMessage(String payload) throws Exception {
		for (WebSocketSession s : sessions) {
			if(s.isOpen()) {
				s.sendMessage(new TextMessage(payload));
			}
		}
	}
	
	//클라이언트와 연결이 끊어졌을 때
	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
		sessions.remove(session);
		System.out.println("연결 해제: " + session.getId());
	}

}

ChatService

package com.jpl.smtm.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.jpl.smtm.dto.ChatMessage;
import com.jpl.smtm.entity.Order;
import com.jpl.smtm.handler.ChatHandler;

@Service
public class ChatService {
	
    @Autowired 
    private ChatHandler chatHandler;
    
    private final ObjectMapper objectMapper = new ObjectMapper();

    // 주문 상태 변경 알림 전송 (가운데 UI + 왼쪽 UI 갱신용)
    //DTO 패킹
    public void notifyOrderUpdate(Order order, List<Integer> waitingList) throws Exception {
       ChatMessage response = new ChatMessage(
    		   "ORDER_UPDATE",
    		   order.getOrderNumber(),
    		   order.getOrderType(),
    		   order.getOrderStatus(),
    		   waitingList //갱신된 대기열 리스트
    		   );
        
       //Java Object -> JSON String 변환
        String json = objectMapper.writeValueAsString(response);
        //모든 세션에 전송 요청
        chatHandler.broadcastMessage(json);
    }
}

OrderService

수정한 부분 )
Optional -> List 수정 :

상태 변경 제어 :

package com.jpl.smtm.service;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.jpl.smtm.constant.OrderStatus;
import com.jpl.smtm.constant.OrderType;
import com.jpl.smtm.entity.Order;
import com.jpl.smtm.repository.OrderRepository;

@Service
public class OrderService {
	
	@Autowired
	private OrderRepository orderRepository;
	
	
	@Autowired(required = false) //주문 처리는 멈추지 않도록
	private ChatService chatService;
	
	//주문 생성
	@Transactional //DB 저장 도중 에러 발생 시 자동 롤백
	public Order createOrder(Order order) {
		order.setOrderStatus(OrderStatus.WAITING); //초기 단계는 항상 WAITING
		return orderRepository.save(order);
	}
	
	//내 주문 확인
	public Order getOrder(Integer orderNumber) {
		List<Order> orders = orderRepository.findByOrderNumber(orderNumber); 
		//orderNumber을 List로 받아옴
		if (orders == null || orders.isEmpty()) {
			return null;
		}
		
		return orders.get(orders.size() - 1); //가장 최근에 저장된 데이터 반환
	 }
	
	//주문 상태 변경
	@Transactional
	public Order updateOrderStatus(Integer orderNumber, String statusStr) throws Exception {
        OrderStatus status;
		try{
			//유효성 검사
			status = OrderStatus.valueOf(statusStr);
		} catch(IllegalArgumentException e) {
			throw new IllegalArgumentException("유효하지 않음");
		}
		
		//리스트로 주문 조회
		List<Order> orders = orderRepository.findByOrderNumber(orderNumber);
		if (orders == null || orders.isEmpty()) {
			throw new RuntimeException("주문 번호를 찾을 수 없음: " + orderNumber);
		}
		
		Order order = orders.get(orders.size() - 1); //최신 주문 선택
		
		if (order.getOrderType() == OrderType.STORE && status == OrderStatus.DELIVERING) {
			throw new IllegalArgumentException("매장 주문은 배달 불가");
		}
		
		//상태 변경 및 저장 (DB 반영)
        order.setOrderStatus(status);
        Order savedOrder = orderRepository.save(order);

        // 대기열 정보 갱신
        //WAITING, COMPLETED, DELIVERING 상태의 주문 합쳐서 리스트 생성
        List<Integer> waitingNumbers = orderRepository.findByOrderStatusInOrderByRegDateAsc(
                	Arrays.asList(OrderStatus.WAITING, OrderStatus.COMPLETED, OrderStatus.DELIVERING))
        			.stream().map(Order::getOrderNumber).collect(Collectors.toList());

        if (chatService != null) {
			chatService.notifyOrderUpdate(savedOrder, waitingNumbers);
		}

        return order;
    }
	
	//초기 접속 시 현재 대기열 현황 가져오기
	public List<Order> getInitialQueue(){
		return orderRepository.findByOrderStatusInOrderByRegDateAsc(Arrays.asList(OrderStatus.WAITING, OrderStatus.COMPLETED, OrderStatus.DELIVERING));
	}
}

MVC 패턴 적용

수업 시간 및 실습 때 공부한 내용을 바탕으로
MVC 패턴에 맞추어 기능별로 패키지 나누고자 함

Model
Entity -> Order
DTO -> ChatMessage
Repository -> OrderRepository
Service -> OrderService

View

Controller
OrderController
ChatHandler

SOLID 원칙 적용

SRP

하나의 클래스가 하나의 역할만 할 수 있도록,
패키지와 클래스를 기능에 맞게 분리하여 구조를 설계함

OCP

enum 을 통해 상태가 늘어나도 큰 수정 없이 확장 가능하도록 설계

LSP, ISP

Repository를 통한 적용

DIP

@Autowired 적용!

AI 사용 방향성

  • 어노테이션 활용 : @Transactional, @PutMapping 등 실습 시 사용하지 않았던 어노테이션을 활용할 수 있었음

  • 에러 수정 : 특히 404에러, 500에러 등의 에러가 왜 발생하는지, 어떤 코드에 문제가 있는지, 어떻게 수정해야 하는지 등을 파악하게 해줌으로써 더 효과적인 리팩토링이 가능해짐

  • Enum 사용 : OrderType과 OrderStatus를 Enum을 활용하여 관리함으로써 타입 안전성을 보장할 수 있었음

아쉬운 부분

  • orderType이 잘 사용되지 않음
    매장/배달 타입을 이용하는 부분이 UI에 특별히 없어 이 필드를 잘 사용 못한 것 같다.. 주문을 직접 입력할 수 있는 방식으로 설계를 하지 않아 활용하기 어려웠다.

  • 기획 및 설계의 추상성
    위의 내용에 이어서, 구체적인 기능 기획 및 설계가 부족했던 것 같다. 이번 기회로 기획의 중요성을 다시 한번 느꼈다. 우리의 프로젝트를 더 수정한다면, UI에서 직접 주문을 넣는 기능도 추가하여 내 DB의 모든 필드들을 잘 활용할 수 있도록 하면 좋을 것 같다.

  • DB와 UI 연결 미흡
    DB와 UI를 연결하는 과정에서 AI의 도움을 받았는데, 이 과정에서 불필요한 코드들이 많이 삽입되어 ManagerController와 UserController 클래스가 복잡해지고 거대해졌다. 이로 인해 오류가 발생해도 어디서부터 고쳐야할지 파악하기가 어려웠고, 그 결과 우리가 구현하고자 한 기능 중 일부를 완성하지 못했다. 특히 WebSocket 부분에서 문제가 발생했는데, 포트가 일치하지 않아 채팅 구현이 되지 않았다. 코드를 리팩토링 해본다면 DB의 코드와 UI의 코드 중 Socket 부분을 다시 한번 살펴보고, 포트와 url 등이 일치하는지, broadcasting은 잘 되는지 확인해보면 좋을 것 같다.

  • 협업 과정
    각자 맡은 부분을 따로 프로젝트를 생성하여 구현하고, 이를 합치는 방법으로 프로젝트를 진행했었다. 합치는 과정에서 오류도 발생했고, 서로의 코드가 각자의 이클립스에서 잘 열리지 않는 문제들도 발생했다. 깃을 효과적으로 사용하지 못한 것 같기도 하고, 중간 중간 같은 방향성과 구조를 가지고 구현을 하고 있는지, 설계와 어긋나는 부분은 없는지 체크하는 과정이 부족했던 것 같다.

최종

DB 구성

사용자 화면

DB에 들어가 있는 주문번호들이 좌측 대기열에 시간 순서대로 뜨고,
가운데에는 처음에 입력한 자신의 주문 번호와 주문 상태가 뜬다.
우측의 채팅 기능을 통해 관리자에게 추가적인 문의 등을 채팅으로 보낼 수 있다.

관리자 화면

좌측 대기열에 주문 번호가 시간 순서대로 뜬다. 대기열은 조리 중과 조리 완료 두 가지이고, 주문 번호 버튼을 누르면 해당 주문이 완료 처리되어 조리 완료 대기열로 넘어간다. 조리 완료로 넘어간 주문 번호는 특정 시간이 지난 후 사라지도록 구현하였다. 이때 DB에서는 주문 번호의 orderStatus가 WAITING에서 COMPLETED로 바뀌도록 하였다.
사용자에게 채팅이 오면, 채팅 알림 탭에서 몇 번 사용자에게 채팅이 왔다는 알림이 뜨며 고객별 채팅 칸에 해당 고객과의 채팅이 뜬다. 관리자는 고객의 메세지에 답장을 보낼 수 있고 상담 종료 버튼을 누르면 해당 고객과의 채팅이 사라지도록 설계하였으나, 앞서 말한 소켓 문제로 인해 관리자가 보낸 메세지가 사용자에게 전송되지 않는 문제가 발생하였다.

느낀 점

MVC 패턴과 SOLID 원칙에 따라 패키지를 나누고 클래스를 구분하는 것에 조금은 익숙해진 것 같다. 다만 아직 Socket과 Spring Boot를 다루는 것이 미숙했던 것 같아 개발의 기술적 측면에서 아쉬움이 남는다.
또한 기획 단계와 협업 단계의 아쉬움이 앞서 작성한 것과 같이 남아 이를 보충할 수 있는 학습이 필요할 것 같다.

0개의 댓글