Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

임도현의 성장

[Spring-Boot] 객체 지향적 중심 설계 공부 본문

Spring Boot

[Spring-Boot] 객체 지향적 중심 설계 공부

림도현 2025. 4. 6. 20:48

📌프로젝트 개요

  • 목표 : 객체지향적 설계와 SOLID 원칙을 기반으로 한 간단한 카페 키오스크 시스템 구현을 통해 OOP에 대한 이해를 깊이 있게 다지고, 유지보수까지 고려한 구조를 설계하는 것을 목표로 하였습니다.
  • 기술 스택 : Java, Spring Boot, JPA, QueryDsl, JUnit, Mockito
  • 아키텍쳐 : 각 계층은 Controller → Service Interface → Service Impl → Repository 순으로 책임을 분리하였습니다.
  • 기능 
    1. 메뉴 등록
    2. 메뉴 조회(전체, 카테고리 별, 판매 상태 별 필터링)
    3. 주문 생성(주문 총 금액, 수량 계산)

👀중요시 생각 한 부분

  • 캡슐화 (Setter 사용 안 하기)
  • 책임 분리
  • 도메인 중심 설계
  • 유효성 스스로 검증 
  • 테스트 가능한 구조

🏠GitHub-Repositories 주소 

https://github.com/Imdo714/Kiosk-OOP

📚 Class 다이어그램

👾ERD 설계

총 3개의 테이블을 사용했으며, 정규화를 통해 유연하고 확장성 있는 구조를 목표로 했습니다.

📰Entity

MenuEntity에 타입의 안정성을 주기 위해 MenuCategory와 MenuStatus은 Enum클래스로 정의하였습니다.

public class MenuEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "menu_id")
    private Long menuId;

    @Column(name = "menu_name")
    private String menuName;

    @Column(name = "menu_price")
    private int menuPrice;

    @Enumerated(EnumType.STRING)
    @Column(name = "menu_category")
    private MenuCategory menuCategory;

    @Enumerated(EnumType.STRING)
    @Column(name = "menu_status")
    private MenuStatus menuStatus;
 }
 
public enum MenuCategory {
    HANDMADE("제조 음료"),
    BOTTLE("병 음료"),
    BAKERY("베이커리");

    private final String text;
}

public enum MenuStatus {
    SELLING("판매중"),
    STOP_SELLING("판매중지");

    private final String text;
}

🙈ApiResponse 응답 값

Api응답을 일관된 형식으로 클라이언트에 전달하기 위해 ApiResponse 클래스를 생성하였습니다. data응답에 담기는 데이터의 타입을 유동적으로 설정하기 위해 제네릭을 사용해 어떤 타입이든 감쌀 수 있도록 만들었습니다. 메서드 오버로딩을 통해 ApiResponse.of()메서드를 통해 다양한 상황에도 맞게 반응 할 수 있도록 설계하였습니다.

@AllArgsConstructor
@Getter
public class ApiResponse<T> {

    private int code;
    private HttpStatus status;
    private String message;
    private T data;

    public ApiResponse(HttpStatus status, String message, T data) {
        this.code = status.value();
        this.status = status;
        this.message = message;
        this.data = data;
    }

    public static <T> ApiResponse<T> of(HttpStatus httpStatus, String message, T data) {
        return new ApiResponse<>(httpStatus, message, data);
    }

    public static <T> ApiResponse<T> of(HttpStatus httpStatus, T data) {
        return of(httpStatus, httpStatus.name(), data);
    }

    public static <T> ApiResponse<T> ok(T data) {
        return of(HttpStatus.OK, data);
    }

}

🥕메뉴 생성 API

  • 객체 지향 설계를 위해 MenuCreateRequest에게 Entity로 변환할 책임음 가지게 하였습니다. 이로써 컨트롤러 서비스 레이어에서 조금 더 역활과 책임을 분리 할 수 있었습니다. 
  • toEntity()메서드를 통해 변환 로직을 내부에 감춰 외부에 로직 구현을 노출하지 않게 정보 은닉을 통해 안전하게 관리하도록 캡슐화하였습니다.
@Getter
@Builder
@AllArgsConstructor
public class MenuCreateRequest {
    @NotBlank(message = "메뉴 이름은 필수입니다.")
    private String menuName;
    
    @Positive(message = "메뉴 가격은 양수여야 합니다.")
    private int menuPrice;

    @NotNull(message = "메뉴 카테고리는 필수입니다.")
    private MenuCategory menuCategory;

    @NotNull(message = "메뉴 상태는 필수입니다.")
    private MenuStatus menuStatus;

    public MenuEntity toEntity() {
        return MenuEntity.builder()
                .menuName(menuName)
                .menuPrice(menuPrice)
                .menuCategory(menuCategory)
                .menuStatus(menuStatus)
                .build();
    }
}

public class MenuController {
    @PostMapping("/menu/new")
    public ApiResponse<MenuResponse> createMenu(@Valid @RequestBody MenuCreateRequest request){
        return ApiResponse.ok(menuService.createMenu(request));
    }
}

@Transactional(readOnly = true)
@Service
public class MenuServiceImpl implements MenuService{
    @Override
    public MenuResponse createMenu(MenuCreateRequest request) {
        MenuEntity menu = request.toEntity();
        MenuEntity savedMenu = menuRepository.save(menu);

        return MenuResponse.of(savedMenu);
    }
}

// 응답 값
{
    "code": 200,
    "status": "OK",
    "message": "OK",
    "data": {
        "menuId": 1,
        "menuName": "아메리카노",
        "menuPrice": 1000,
        "menuCategory": "HANDMADE",
        "menuStatus": "SELLING"
    }
}

📈메뉴 조회 API

@RestController
public class MenuController {
    @GetMapping("/menu")
    public ApiResponse<MenuListResponse> selectMenu(@RequestParam(required = false) String category,
                                                    @RequestParam(required = false) String name,
                                                    @RequestParam(required = false) String status){
        return ApiResponse.ok(menuService.selectMenu(category, name, status));
    }
}

@Transactional(readOnly = true)
@Service
public class MenuServiceImpl implements MenuService{
    @Override
    public MenuListResponse selectMenu(String category, String name, String status) {
        List<MenuEntity> dslAll = menuRepository.selectMenu(category, name, status);

        return MenuListResponse.arr(dslAll);
    }
}
  • 책임 분리를 통해 MenuService는 비즈니스 흐름만 책임지고 복잡한 쿼리는 QueryDsl클래스에서 책임을 지게 만들었습니다.
  • 또한 리팩토링 공부를 할 때 " 하나의 메서드는 하나의 책임만 가져야 한다 "를 기준으로 조건별 쿼리를 메서드로 분리하였습니다.
  • 새로운 조건이 추가될 경우에도 기존 로직을 수정하지않고 해당 조건 메서드만 새로 추가하면 되므로 개방폐쇄 원칙을 따랐습니다.
@Repository
public class MenuQueryDslImpl implements MenuQueryDsl {

    private final JPAQueryFactory query;

    public MenuQueryDslImpl(EntityManager em) {
        this.query = new JPAQueryFactory(em);
    }

    @Override
    public List<MenuEntity> selectMenu(String category, String name, String status) {
        QMenuEntity menu = QMenuEntity.menuEntity;

        return query
                .selectFrom(menu)
                .where(
                        eqCategory(category),
                        containsTitle(name),
                        eqStatus(status)
                )
                .fetch();
    }

    // BooleanExpression은 SQL 쿼리로 번역 가능한 추상적 조건 표현식, Boolean은 자바 메모리에서의 즉시 판단된 논리값
    private BooleanExpression eqCategory(String category) {
        if (category == null){
            return null;
        }

        try { // Enum 타입이기에 예외 처리 해줌
            return QMenuEntity.menuEntity.menuCategory.eq(MenuCategory.valueOf(category.toUpperCase()));
        } catch (IllegalArgumentException e) {
            throw new validEnumTypeException("존재하지 않는 카테고리입니다.");
        }
    }

    private BooleanExpression eqStatus(String status) {
        if (status == null){
            return null;
        }

        try {
            return QMenuEntity.menuEntity.menuStatus.eq(MenuStatus.valueOf(status.toUpperCase()));
        } catch (IllegalArgumentException e) {
            throw new validEnumTypeException("존재하지 않는 상태 값입니다.");
        }
    }

    private BooleanExpression containsTitle(String name) {
        if (name == null || name.isBlank()){
            return null;
        }

        return QMenuEntity.menuEntity.menuName.containsIgnoreCase(name);
    }

}
  • 도메인 객체 Entity를 외부에 그대로 노출하지 않게 정적 팩토리를 통해 DTO 변환 책임도 내부에서 처리하며 캡슐화를 설계하였습니다. 
@Builder
public class MenuListResponse {

    private List<MenuEntity> menuEntityList;

    public static MenuListResponse arr(List<MenuEntity> dslAll){
        return MenuListResponse.builder()
                .menuEntityList(dslAll.stream()
                    .map(menu -> MenuEntity.builder()
                        .menuId(menu.getMenuId())
                        .menuName(menu.getMenuName())
                        .menuPrice(menu.getMenuPrice())
                        .menuCategory(menu.getMenuCategory())
                        .menuStatus(menu.getMenuStatus())
                        .build()
                    )
                    .collect(Collectors.toList())
                )
                .build();
    }
}

// 응답 값
{
    "code": 200,
    "status": "OK",
    "message": "OK",
    "data": {
        "menuEntityList": [
            {
                "menuId": 1,
                "menuName": "아메리카노",
                "menuPrice": 1000,
                "menuCategory": "HANDMADE",
                "menuStatus": "SELLING"
            },
            {
                "menuId": 2,
                "menuName": "헤이즐넛",
                "menuPrice": 1500,
                "menuCategory": "HANDMADE",
                "menuStatus": "SELLING"
            }
        ]
    }
}

💰주문 생성 API

public class OrderCreateRequest {
    private List<OrderDetailRequest> orderDetails; // 주문 상세 정보 목록
}

@RestController
public class OrderController {
    @PostMapping("/order/new")
    public ApiResponse<OrderResponse> createOrder(@RequestBody OrderCreateRequest request){
        return ApiResponse.ok(orderService.createOrder(request));
    }
}

@Transactional(readOnly = true)
@Service
public class OrderServiceImpl implements OrderService{
    @Override
    public OrderResponse createOrder(OrderCreateRequest request) {
        OrderEntity order = orderFactory.create(request);

        orderRepository.save(order);
        orderDetailRepository.saveAll(order.getOrderDetails());

        return OrderResponse.of(order, order.getOrderDetails());
    }
}
  • OrderServiceImpl에서는 비즈니스 흐름만 책임지고 주문 생성 책임을 OrderFactory에게 맡기도록 하였습니다.
  • OrderDetail을 추가하기 위해 createNow() 메서드를 통해 기본 값들만 넣어 빈 객체를 만들어 ID값을 만들도록 하고 addOrderDetail() 메서드에서 책임 분리를 위해 OrderEntity가 스스로 금액, 수량을 자동 계산하도록 설계하였습니다. 
  • 이 또한 외부에서는 내부 구현을 몰라도 되니 캡슐화를 사용하였습니다.
@Component
@RequiredArgsConstructor
public class OrderFactory {

    private final MenuService menuService;

    public OrderEntity create(OrderCreateRequest request) {
        OrderEntity order = OrderEntity.createNow(0, 0);

        for (OrderDetailRequest detail : request.getOrderDetails()) {
            MenuEntity menu = menuService.findById(detail.getMenuId());

            order.addOrderDetail(menu, detail.getOrderQuantity());
        }
        return order;
    }
}

@Entity
public class OrderEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "order_price")
    private int orderPrice;

    @Column(name = "order_quantity")
    private int orderQuantity;

    @Column(name = "order_date")
    private LocalDateTime orderDate;

    @OneToMany(mappedBy = "orderEntity")
    @Builder.Default
    private List<OrderDetailEntity> orderDetails = new ArrayList<>();

    public static OrderEntity createNow(int orderPrice, int orderQuantity) {
        return OrderEntity.builder()
                .orderPrice(orderPrice)
                .orderQuantity(orderQuantity)
                .orderDate(LocalDateTime.now())
//                .orderDetails(new ArrayList<>()) // new ArrayList<>()로 생성하지 안흥면 null이 들어가서 nullpointerexception 발생
//                그러나 @Builder.Default을 사용해 new ArrayList<>() 값으로 초기화 함 이러면 기본으로 ArrayList가 들어 감
                .build();
    }

//  Setter사용을 줄이기 위해 만든 메서드입니다. 현제는 테스트 코드에서 밖에 사용안해서 없다고 생각해도 됩니다.
    public void updateOrderPrice(int price){
        validPositiveValue(price, "가격은 0보다 커야 합니다.");
        this.orderPrice = price;
    }

    private void validPositiveValue(int value, String message) {
        if (value <= 0) {
            throw new InvalidEntityException(message);
        }
    }

    public void addOrderDetail(MenuEntity menu, int quantity) {
        OrderDetailEntity detail = OrderDetailEntity.builder()
                .orderEntity(this)
                .menuEntity(menu)
                .orderDetailQuantity(quantity)
                .build();

        this.orderDetails.add(detail);
        this.orderPrice += menu.getMenuPrice() * quantity;
        this.orderQuantity += quantity;
    }
}
  • 주문 성공시 응답 값 또한 정적 팩토리 메서드 사용하여 OrderResponse에게 책임을 분리하였고 
  • 또한 주문 상세 정보는 내부 클래스인 OrderDetailResult로 분리하고 List<>형태로 응답에 포함시켜 하나의 객체 안에서 처리할 수 있도록 설계하였습니다.
public class OrderResponse {
    private Long orderId;
    private int orderPrice;
    private int orderQuantity;
    private LocalDateTime orderDate;
    private List<OrderDetailResult> orderResultsList;

    @Getter
    @Builder
    public static class OrderDetailResult{
        private Long orderDetailId;
        private int orderDetailQuantity;
        private String orderDetailMenuName;
        private int orderDetailMenuPrice;
    }

    public static OrderResponse of(OrderEntity order, List<OrderDetailEntity> orderDetail){
        return OrderResponse.builder()
                .orderId(order.getOrderId())
                .orderPrice(order.getOrderPrice())
                .orderQuantity(order.getOrderQuantity())
                .orderDate(order.getOrderDate())
                .orderResultsList(orderDetail.stream()
                        .map(detail -> OrderDetailResult.builder()
                                .orderDetailId(detail.getOrderDetailId())
                                .orderDetailQuantity(detail.getOrderDetailQuantity())
                                .orderDetailMenuName(detail.getMenuEntity().getMenuName())
                                .orderDetailMenuPrice(detail.getMenuEntity().getMenuPrice())
                                .build())
                                .collect(Collectors.toList())
                        )
                .build();
    }
}

// 응답 값
{
    "code": 200,
    "status": "OK",
    "message": "OK",
    "data": {
        "orderId": 1,
        "orderPrice": 3500,
        "orderQuantity": 3,
        "orderDate": "2025-04-06T20:51:53.9305969",
        "orderResultsList": [
            {
                "orderDetailId": 1,
                "orderDetailQuantity": 2,
                "orderDetailMenuName": "아메리카노",
                "orderDetailMenuPrice": 1000
            },
            {
                "orderDetailId": 2,
                "orderDetailQuantity": 1,
                "orderDetailMenuName": "헤이즐넛",
                "orderDetailMenuPrice": 1500
            }
        ]
    }
}

🍸테스트 코드 전체 성공