자바를 공부하다 보면 한 번쯤 마주하게 되는 키워드가 있습니다. 바로 자바 Enum 클래스입니다.
많은 초보 개발자들이 enum을 단순히 “상수를 나열하는 문법” 정도로만 알고 있지만, 사실 그 이상으로 강력한 기능을 제공합니다.
이번 글에서는 자바 Enum 클래스의 개념부터 실무 활용 예시까지 정리해보겠습니다.
(저도 아직 초보에요..)
Enum이란 무엇일까?
Enum(열거형)은 서로 관련된 상수들의 집합을 표현하기 위한 클래스입니다. 예를 들어, 요일을 나타내는 상수를 MONDAY, TUESDAY, WEDNESDAY로 정의한다고 할 때, 기존에는 다음처럼 작성했죠.
public static final int MONDAY = 1;
public static final int TUESDAY = 2;
public static final int WEDNESDAY = 3;
하지만 이런 방식은 숫자 값이 헷갈리기 쉽고, 타입 안전성(type safety)이 없습니다. 그래서 등장한 것이 바로 자바 Enum 클래스입니다.
(그리고 코딩하면서 static 변수가 많아지면 유지보수 하기도 힘들더라구요..)
기본 문법과 사용 예시
public enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
이제 위와 같이 정의해두면, Day.MONDAY처럼 직관적이고 안전한 코드로 사용할 수 있습니다.
Day today = Day.MONDAY;
if (today == Day.MONDAY) {
System.out.println("월요일입니다!");
}
Enum은 내부적으로 클래스이기 때문에, 생성자, 변수, 메서드도 정의할 수 있습니다.
Enum의 확장 기능
public enum Season {
SPRING("봄"), SUMMER("여름"), AUTUMN("가을"), WINTER("겨울");
private final String koreanName;
Season(String koreanName) {
this.koreanName = koreanName;
}
public String getKoreanName() {
return koreanName;
}
}
이제 아래처럼 사용할 수 있습니다 👇
System.out.println(Season.SPRING.getKoreanName()); // 봄
Enum은 단순한 값 나열이 아니라, 상수를 객체처럼 관리할 수 있는 구조라는 점이 핵심입니다.
Enum의 장점 정리
가독성 향상 — 의미 있는 이름으로 상수를 표현 (개인적으로 이게 제일 큰 장점인거 같아요)
타입 안전성 확보 — 잘못된 값 대입 방지
switch문과 궁합 — Enum은 switch문에서 바로 사용 가능
객체처럼 활용 가능 — 변수, 생성자, 메서드 포함 가능
switch (today) {
case MONDAY -> System.out.println("한 주의 시작!");
case FRIDAY -> System.out.println("불금!");
default -> System.out.println("평일 혹은 주말!");
}
실무에서의 활용 예시
Enum은 특히 API 응답 코드, 주문 상태, 결제 방식 등에서 자주 사용됩니다. 예를 들어, 주문 상태를 Enum으로 관리하면 코드 유지보수가 쉬워집니다.
public enum OrderStatus {
READY, PAID, SHIPPED, COMPLETED, CANCELLED
}
이처럼 Enum은 비즈니스 로직의 안정성과 가독성을 높이는 핵심 도구입니다.
자바 Enum 클래스는 단순한 상수 집합이 아닙니다. 객체 지향적인 지향하는 클래스 구조로, 유지보수성과 안정성을 높여줍니다. 실무에서는 상수 관리, 상태 정의, 코드 일관성 확보를 위해 필수적으로 사용됩니다.
Service 클래스는 Controller에서 받은 주문 정보를 Model 객체로 변환하고
DB에 저장하는 로직이 포함되어 있습니다.
package org.example.service;
import org.example.Side;
import org.example.dto.OrderRequestDto;
import org.example.dto.OrderResponseDto;
import org.example.model.Order;
import org.example.model.OrderBook;
import org.example.model.OrderMatchInfo;
import org.example.model.Trade;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class OrderService {
private final OrderSave orderSave;
private final OrderBook orderBook;
public OrderService(OrderSave orderSave, OrderBook orderBook) {
this.orderSave = orderSave;
this.orderBook = orderBook;
}
/**
* 주문을 추가하고 처리하는 함수
* @param orderRequestDto 주문 요청 정보를 포함한 DTO
* @return 주문과 관련된 응답 DTO
*/
public OrderResponseDto addOrder(OrderRequestDto orderRequestDto){
// 주문 생성
Order order = Order.of(orderRequestDto);
// 주문 저장
orderSave.orderSave(order);
// 주문 처리 및 체결 정보 얻어옴
List<Trade> trades = orderBook.process(order);
// 체결 정보를 사용하여 주문 상태 및 연관된 주문 업데이트
for (Trade trade : trades){
orderSave.orderUpdate(trade.getTakeOrderId());
orderSave.orderUpdate(trade.getMakerOrderId());
orderSave.orderMatchingSave(trade.getMakerOrderId(), trade.getTakeOrderId());
}
// 주문 및 체결 정보를 포함한 응답 DTO 변환
return OrderResponseDto.ofOrder(order, trades);
}
/**
* 매수 및 매도 주문 목록을 가져옴
* @return 매수 및 매도 주문 목록
*/
public Map<Side, List<Order>> getOrderBook(){
// 매수 및 매도 주문 목록을 가져옴
List<Order> buyOrderBook = orderBook.getOrderBook(5, Side.ask);
List<Order> sellOrderBook = orderBook.getOrderBook(5, Side.bid);
// 결과를 Map에 저장하여 변환
Map<Side, List<Order>> orderBooks = new HashMap<>();
orderBooks.put(Side.bid, buyOrderBook);
orderBooks.put(Side.ask, sellOrderBook);
return orderBooks;
}
/**
* 주문 ID를 사용하여 주문 정보를 찾음
* @param uuid 주문 ID
* @return 주문 정보를 포함한 응답 DTO
*/
public OrderResponseDto findOrder(UUID uuid) {
// 주문 ID를 사용하여 주문 저보를 조회
Order order = orderBook.findOrder(uuid);
// 주문 정보를 포함한 응답 DTO 반환
return OrderResponseDto.ofFindOrder(order);
}
/**
* 주문 ID를 사용하여 주문을 취소하고 취소된 주문 정보를 반환
*
* @param uuid 주문 ID
* @return 취소된 주문 정보를 포함한 응답 DTO
*/
public OrderResponseDto deleteOrder(UUID uuid) {
Order order = orderBook.cancelOrder(uuid);
return OrderResponseDto.ofDeleteOrder(order);
}
}
다음은 OrderBook.java 입니다.
지정가 매수 / 매도 주문을 처리하는 함수입니다.
/**
* 지정가 매수 주문을 처리
* @param order 처리할 주문
* @return 처리된 체결 목록
*/
private synchronized List<Trade> processLimitBuy(Order order) {
final ArrayList<Trade> trades = new ArrayList<>();
// 매도 리스트 체크 및 가장 저렴한 매도 주문의 가격이 현재 주문의 가격보다 작거나 같을 경우
if (!sellOrders.isEmpty() && sellOrders.peek().getPrice().compareTo(order.getPrice()) <= 0) {
// 현재 주문과 가장 저렴한 매도 주문을 비교하여 처리
while (!sellOrders.isEmpty()) {
Order sellOrder = sellOrders.peek();
// 현재 매도 주문의 가격이 주문의 가격보다 높으면 루프 종류
if (sellOrder.getPrice().compareTo(order.getPrice()) > 0) {
break;
}
BigDecimal maxQuantityToTrade = sellOrder.getQuantity().min(order.getQuantity());
sellOrder.executeTrade(maxQuantityToTrade);
order.executeTrade(maxQuantityToTrade);
trades.add(Trade.of(order,
sellOrder,
maxQuantityToTrade,
sellOrder.getPrice()));
// 매도 주문이 완전히 체결되면 주문서에서 제거
if (sellOrder.getQuantity().compareTo(BigDecimal.ZERO) == 0) {
sellOrders.poll();
}
this.lastPrice = sellOrder.getPrice();
if (order.getQuantity().compareTo(BigDecimal.ZERO) == 0) {
return trades;
}
}
}
// 남은 주문을 buyOrders에 추가
buyOrders.add(order);
return trades;
}
/**
* 지정가 매도 주문을 처리
* @param order 처리할 주문
* @return 처리된 체결 목록
*/
private synchronized List<Trade> processLimitSell(Order order) {
final ArrayList<Trade> trades = new ArrayList<>();
// 매수 주문 리스트 체크 및 가장 높은 주문의 가격이 현재 주문의 가격보다 크거나 같을 경우
if (!buyOrders.isEmpty() && buyOrders.peek().getPrice().compareTo(order.getPrice()) >= 0) {
// 현재 주문과 가장 높은 매수 주문을 비교하며 처리
while (!buyOrders.isEmpty()) {
final Order buyOrder = buyOrders.peek();
// 현재 매수 주문이 현재 주문의 가격보다 낮으면 종료
if (buyOrder.getPrice().compareTo(order.getPrice()) < 0) {
break;
}
BigDecimal maxQuantityToTrade = buyOrder.getQuantity().min(order.getQuantity());
// 주문 실행 및 체결 정보 생성
buyOrder.executeTrade(maxQuantityToTrade);
order.executeTrade(maxQuantityToTrade);
trades.add(Trade.of(order,
buyOrder,
maxQuantityToTrade,
buyOrder.getPrice()));
if (buyOrder.getQuantity().compareTo(BigDecimal.ZERO) == 0) {
buyOrders.poll();
}
this.lastPrice = buyOrder.getPrice();
if (order.getQuantity().compareTo(BigDecimal.ZERO) == 0) {
return trades;
}
}
}
// 남은 주문을 sellOrders 추가합니다.
sellOrders.add(order);
return trades;
}
다음은 Order.java 입니다.
주문의 수량이 0일 경우에 EXECUTED상태로 변하고 그렇지 않으면 PARTIALLY_FILLED 상태로 변경합니다.
지난 시간까지 주문 데이터는 List 자료구조를 통해 저장하였습니다. 매도 주문이 들어오면 매도 주문 리스트에 저장, 매수 주문이 들어오면 매수 주문 리스트에 저장 구조로 되어있습니다.
여기서 주문을 취소하거나, 조회하려면 리스트 전체를 조회하는 방법으로 사용을 해야되는데, 이는 매우 비 효율적이라고 생각이 듭니다.
그래서 고안한 방법이 HashMap의 자료구조를 활용하자 입니다.
주문이 들어오면 매수 혹은 매수 리스트에 저장을 하고 주문 HashMap에 저장을 합니다.
주문 매칭은 리스트를 이용하고 단순 조회나 삭제같은 경우 HashMap을 활용하여 처리를 좀더 빠르다고 생각이 들었습니다.
여기서 HashMap을 활용하면 왜 빠른가?? (자세한건 포스팅으로 따로 정리를 하겠습니다..)
Key-Value구조로 HashMap<UUID, Order> 객체를 사용하면 UUID로 빠르게 Order 정보를 조회할수 있습니다.
기존 리스트 조회
public synchronized Order findOrder(String id,
Side side) {
PriorityQueue<Order> toSearch;
if (side == Side.bid) {
toSearch = this.buyOrders;
} else {
toSearch = this.sellOrders;
}
return toSearch.stream().filter(order -> order.getOrderId().equals(id))
.findFirst().orElse(null);
}
HashMap 활용하여 조회
public synchronized Order findOrder(UUID id) {
return orderMap.get(id);
}
소스만 봐도 간단하게 처리가 되는거 같아 보이죠?? 맞습니다.
다만, 기존 리스트 2개만 사용하여 처리하던게 HashMap이라는 새로운 객체를 사용하기 때문에 메모리의 사용량이 늘어날수 있다는 단점이 있지만, 요즘 메모리 사양이 좋고, 가상 화폐거래는 빠르게 처리하는게 가장 중요하다고 생각을 하기 때문에,, 위에 방식을 사용하겠습니다.
기존 리스트 삭제
private synchronized boolean cancel(String orderId,
PriorityQueue<Order> orderBook) {
// 요소를 저장할 임시 큐를 생성
PriorityQueue<Order> tempQueue = new PriorityQueue<>(orderBook);
// 임시 큐에서 요소를 하나씩 꺼내면서 orderId와 비교하여 삭제 여부 결정
while (!tempQueue.isEmpty()) {
Order currentOrder = tempQueue.poll();
if (currentOrder.getOrderId().equals(orderId)) {
// 원본 큐에서도 해당 요소를 삭제
orderBook.remove(currentOrder);
return true;
}
}
return false;
}
주문 데이터는 order 테이블에 저장되고, 매칭된 주문 정보는 match_order 테이블에 저장됩니다.
match_order 테이블에서는 매수 주문과 매도 주문의 ID를 외래키로 가지고 있으므로 어떤 주문과 매칭되었는지 추적할 수 있습니다.
OrderMatchingEntity.java
buy_order_id, sell_order_id가 외래키로 선언되어 있어 @ManyToOne 어노테이션을 선언합니다.
public class OrderMatchingEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO) // UUID 생성 전략 수정
@Column(name = "match_id")
private UUID matchId;
@ManyToOne
@JoinColumn(name = "buy_order_id")
private OrderEntity buyOrder; // 매칭된 매수 주문
@ManyToOne
@JoinColumn(name = "sell_order_id")
private OrderEntity sellOrder; // 매칭된 매도 주문
@Column(name = "match_price")
private BigDecimal matchPrice; // 체결 가격
@Column(name = "match_quantity")
private BigDecimal matchQuantity; // 체결 수량
@Column(name = "match_time")
private LocalDateTime matchTime; // 체결 시간
public OrderMatchingEntity() {
}
public static OrderMatchingEntity toEntity(OrderEntity buyOrder, OrderEntity sellOrder){
return OrderMatchingEntity.builder()
.buyOrder(buyOrder)
.sellOrder(sellOrder)
.matchPrice(buyOrder.getPrice())
.matchQuantity(buyOrder.getExecutedQuantity())
.matchTime(LocalDateTime.now()) // 현재 시간 사용
.build();
}
}
OrderSave.java
레파지토리 클래스에 match 테이블 저장 메서드 생성합니다.
public void orderMatchingSave(Order buyOrder, Order sellOrder){
orderMatchingRepository.save(OrderMatchingEntity.toEntity(OrderEntity.toEntity(buyOrder), OrderEntity.toEntity(sellOrder)));
}
OrderService.java
주문이 체결이 되고 주문을 업데이트 후에 매칭 정보를 저장합니다.
public Order createOrder(OrderNewRequestDto orderNewRequestDto) {
Order order = Order.of(orderNewRequestDto);
orderSave.orderSave(order);
// 주문 체결 엔진
OrderBook orderBook = orderMatcher.submitOrder(order);
// 매칭 주문 리스트
List<OrderBook.OrderMatch> matchedOrders = orderBook.getMatchedOrders();
// 주문 정보 저장
for (OrderBook.OrderMatch orderMatch : matchedOrders) {
Order buyOrder = orderMatch.getBuyOrder();
Order sellOrder = orderMatch.getSellOrder();
// 체결된 주문 정보 저장
orderSave.orderUpdate(buyOrder);
orderSave.orderUpdate(sellOrder);
// 매칭 정보 저장
orderSave.orderMatchingSave(buyOrder, sellOrder);
}
return order;
}
이렇게 저장이 되면 주문에 대한 체결 정보들이 match_orders 테이블에 저장이 되고 다음 아래와 같은 쿼리로 체결된 정보를 조회할수 있습니다.
SELECT m.*
FROM orders o
LEFT JOIN match_orders m ON o.order_id = m.buy_order_id OR o.order_id = m.sell_order_id
WHERE o.order_id = :order_id
테스트
1. 주문 데이터 요청 (주문 UUID : c288442a-4bda-434e-ae5c-acf8fe8d4409)
SELECT m.*
FROM orders o
LEFT JOIN match_orders m ON o.order_id = m.buy_order_id OR o.order_id = m.sell_order_id
WHERE o.order_id = 'c288442a-4bda-434e-ae5c-acf8fe8d4409';
#|match_id|buy_order_id|sell_order_id|match_price|match_quantity|match_time
1|d0d3add5-5511-4b3a-8399-2d3fddebf12e|c288442a-4bda-434e-ae5c-acf8fe8d4409|8656a794-cff0-43e5-b25c-bf396bcfb37d|100.00000000|2.00000000|2023-08-18 23:13:58.012105
2|cbb96413-21e6-46dc-98f5-f7a27694cf96|c288442a-4bda-434e-ae5c-acf8fe8d4409|ad626f1f-127b-4fb8-986e-6f0c3a678747|100.00000000|5.00000000|2023-08-18 23:14:31.603982
레디스나 카프카를 활용해서 구축을 해볼수 있겠지만 우선, 정상적인 거래를 동작하기 위해 하나의 솔루션에서 개발을 진행할 예정입니다.
데이터 베이스 모델링
매매 체결 엔진 ERD 구조
주문 플로우
1. 주문 입력 단계:
- 사용자가 주문을 입력합니다. 주문은 주문 체결 엔진 프로젝트에서 처리됩니다.
- 주문 정보는 orders 테이블에 저장되며, 주문 체결 엔진은 주문을 오더북(주문 창구)에 매수 또는 매도 주문으로 저장합니다.
2. 주문 체결 단계:
- 주문 체결 엔진은 주문 큐를 조회하여 매수와 매도 주문을 매칭시킵니다.
- 가격이 매칭되는 경우, 체결된 주문 정보를 저장 및 업데이트를 합니다.
3. 사용자 자산 업데이트 단계:
- 주문 체결에 따라 사용자의 자산이 변경될 수 있습니다.
- 주문 체결 엔진은 사용자들의 자산 정보를 업데이트합니다.
Spring boot 프레임워크를 사용해서 Rest API 방식으로 주문 데이터를 저장하고 조회하는 기능을 만들었습니다.
JPA 방식을 이용해 데이터를 저장 및 조회를 하고 있습니다.
Post 방식으로 사용자로부터 데이터를 받으면 OrderMatcher 클래스에서 큐에 저장하고 체결 메서드를 호출합니다. 초기 개발이고 우선 전체적인 거래 시스템의 동작이 우선이기 때문에 싱글 스레드 방식으로 개발을 하고
추후 MSA 방식으로 분산 처리 시스템으로 업데이트 할 예정입니다. ^^
주문 컨트롤러 - OrderController.java
@RestController
@RequestMapping("/api/v1")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping("/orders")
public ResponseEntity<OrderNewResponseDto> orders(@RequestBody OrderNewRequestDto orderNewRequestDto){
try {
Order order = orderService.createOrder(orderNewRequestDto);
return ResponseEntity.ok(OrderNewResponseDto.of(order));
} catch (Exception e) {
// 예외 처리 로직
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
주문 관련 서비스 - OrderService.java
@Service
public class OrderService {
private final OrderMatcher orderMatcher;
private final OrderSave orderSave;
public OrderService(OrderMatcher orderMatcher, OrderSave orderSave) {
this.orderMatcher = orderMatcher;
this.orderSave = orderSave;
}
public Order createOrder(OrderNewRequestDto orderNewRequestDto) {
Order order = Order.of(orderNewRequestDto);
// 주문 체결 엔진
orderMatcher.submitOrder(order);
// 주문 정보 저장
orderSave.orderUpdate(order);
return order;
}
}
주문 체결 소스 - OrderMatcher.java
@Service
public class OrderMatcher {
private final PriorityQueue<Order> buyOrders;
private final PriorityQueue<Order> sellOrders;
public OrderMatcher() {
buyOrders = new PriorityQueue<>(Comparator.comparing(Order::getPrice).reversed());
sellOrders = new PriorityQueue<>(Comparator.comparing(Order::getPrice));
}
// 주문 체결 엔진에 주문을 제출하는 메서드
public void submitOrder(Order order) {
if (order.getSideType() == SideType.bid) {
buyOrders.add(order);
} else {
sellOrders.add(order);
}
matchOrders(); // 주문 체결 실행
}
private void updateOrdersAfterTrade(Order buyOrder, Order sellOrder, BigDecimal quantity) {
buyOrder.updateOrderAfterTrade(quantity);
sellOrder.updateOrderAfterTrade(quantity);
if (buyOrder.getQuantity().compareTo(BigDecimal.ZERO) == 0) {
buyOrders.remove(buyOrder);
}
if (sellOrder.getQuantity().compareTo(BigDecimal.ZERO) == 0) {
sellOrders.remove(sellOrder);
}
}
private void matchOrders() {
while (!buyOrders.isEmpty() && !sellOrders.isEmpty()) {
Order buyOrder = buyOrders.peek();
Order sellOrder = sellOrders.peek();
if (buyOrder == null || sellOrder == null) {
break;
}
if (buyOrder.getOrdType() == OrderType.market || sellOrder.getOrdType() == OrderType.market) {
// 시장가 주문 체결
BigDecimal quantity = buyOrder.getQuantity().min(sellOrder.getQuantity());
BigDecimal price = sellOrder.getPrice();
executeTrade(buyOrder, sellOrder, price, quantity);
updateOrdersAfterTrade(buyOrder, sellOrder, quantity);
} else if (buyOrder.getOrdType() == OrderType.limit && sellOrder.getOrdType() == OrderType.limit) {
// 지정가 주문 체결
if (buyOrder.getPrice().compareTo(sellOrder.getPrice()) >= 0) {
BigDecimal quantity = buyOrder.getQuantity().min(sellOrder.getQuantity());
BigDecimal price = sellOrder.getPrice();
executeTrade(buyOrder, sellOrder, price, quantity);
updateOrdersAfterTrade(buyOrder, sellOrder, quantity);
} else {
break; // 가격이 맞지 않으면 더 이상 체결하지 않음
}
}
}
}
private void executeTrade(Order buyOrder, Order sellOrder, BigDecimal price, BigDecimal quantity) {
BigDecimal totalAmount = price.multiply(quantity);
BigDecimal tradingFee = calculateTradingFee(totalAmount);
buyOrder.executeTrade(quantity, tradingFee);
sellOrder.executeTrade(quantity, tradingFee);
}
// 거래 수수료 계산 메서드
private BigDecimal calculateTradingFee(BigDecimal totalAmount) {
BigDecimal feePercentage = new BigDecimal("0.05");
BigDecimal fee = totalAmount.multiply(feePercentage).divide(new BigDecimal("100"));
return fee;
}
}
지정가/시장가 방식으로 처리를 되게 로직을 구현했습니다. 주문 데이터 전량을 메모리에 가지고 있기 때문에 데이터양이 많아지면 시스템 문제가 발생할 가능성이 많아 보입니다.