임도현의 성장
[Spring-Boot] 객체 지향적 중심 설계 공부 본문
📌프로젝트 개요
- 목표 : 객체지향적 설계와 SOLID 원칙을 기반으로 한 간단한 카페 키오스크 시스템 구현을 통해 OOP에 대한 이해를 깊이 있게 다지고, 유지보수까지 고려한 구조를 설계하는 것을 목표로 하였습니다.
- 기술 스택 : Java, Spring Boot, JPA, QueryDsl, JUnit, Mockito
- 아키텍쳐 : 각 계층은 Controller → Service Interface → Service Impl → Repository 순으로 책임을 분리하였습니다.
- 기능
- 메뉴 등록
- 메뉴 조회(전체, 카테고리 별, 판매 상태 별 필터링)
- 주문 생성(주문 총 금액, 수량 계산)
👀중요시 생각 한 부분
- 캡슐화 (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
}
]
}
}
🍸테스트 코드 전체 성공
'Spring Boot' 카테고리의 다른 글
OpenCV 얼굴인식(Face Detection) 사용해보기 (0) | 2025.04.03 |
---|---|
[Spring-Boot] JUnit & Mockito기반테스트 코드 작성 (0) | 2025.03.22 |
[Spring-Boot] Promtail + Loki + Logback 모니터링 (0) | 2025.03.09 |
[Spring-Boot] 객체 지향 설계 원칙 SOLID (0) | 2025.02.25 |
[Spring-Boot] QueryDsl 적용해보기 (0) | 2025.02.17 |