[프로젝트 : SoloTech-StockFlow 준비하기]

yongcrane·2025년 4월 2일

GitHub : 메인에서 브랜치 - 칸반 보드 사용 해보기

프로젝트 진행 상황을 쉽게 관리 가능
To Do, In Progress, Done 단계 설정
브랜치, 이슈와 연계하여 협업 가능

추가로 작업하면서 느낀 거는 JIRA를 사용하는 것에 편하고

Impl 파일 생성 (implements) 구현체

인터페이스와 구현체 분리 이유

유지보수성: 인터페이스를 통해 코드 변경 시 영향 최소화
유연성: DI(의존성 주입)를 통해 쉽게 교체 가능
테스트 용이성: Mock 객체를 활용한 단위 테스트 가능

단위 테스트 : 외부에 IO 개념 없이 순수하게 자바 코드로 이뤄진 테스트 (Mock 키토 사용하면 스프링 컨테이너가 올라가고 -> 그래서 살짝 번잡하다)

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private ProductService productService;
    
    @InjectMocks
    private OrderService orderService;

    @Test
    void testCreateOrder() {
        Product mockProduct = new Product("P001", "상품1", 100);
        when(productService.getProduct("P001")).thenReturn(mockProduct);
        
        Order order = orderService.createOrder(new OrderDto("P001", 1, "CARD"));
        assertNotNull(order);
    }
}
단위 테스트 : 외부 I/O 제외한 (mock, stub -> )

-Test-Double 전략
- Fake -> 핵사고날 관점이랑 비슷하다.

통합 테스트 : 외부 I/O 를 포함한 테스트(ProductService.getProduct(id))

단점 TEST DB 를 만들어서 넣으면 더미 상품 1~ 더미 상품 1000
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void getProduct() throws Exception {
        mockMvc.perform(get("/api/product/P001"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value("P001"));
    }
}

인수 테스트 : end to end 테스트 (GET api/getProudct )

@SpringBootTest
@AutoConfigureMockMvc
class OrderAcceptanceTest {

    @Test
    void orderProcessTest() {
        // 1. 상품 조회
        // 2. 재고 확인
        // 3. 결제 요청
        // 4. 주문 생성
    }
}

.gitignore 설정

.gradle/
.idea/
build/
out/

컨트롤러 어드바이스 -> 에러 핸들링을 위해 컨트롤러단에서는 try-catch 문을 주는건 별루다! 이걸 기반으로 에러를 핸들링 할 수 있는걸 만들어라 !!!

package kr.co.youhost.sfep.fems.controller;

import kr.co.youhost.sfep.fems.response.ErrorResponse;
import kr.co.youhost.sfep.fems.response.SuccessCodeResponse;
import kr.co.youhost.sfep.fems.response.SuccessResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
public class BaseRestController {

    protected String getSignedAdminId() {
        // XXX: 나중에 로그인 중인 id로 수정해야 함
        return "admin";
    }

    protected ResponseEntity<?> getResponse(HttpStatus httpStatus, Object result, int size) {
        if (size >= 0)
            log.info("httpStatus={}, result={}", httpStatus, (result != null) ? result : "null");
        else
            log.info("httpStatus={}, result={}", httpStatus, size);

        return new ResponseEntity<>(result, httpStatus);
    }

    protected ResponseEntity<?> getSuccessResponse(HttpStatus httpStatus, Object result, int size) {
        SuccessResponse response = new SuccessResponse();
        response.setData(result);
        return getResponse(httpStatus, response, size);
    }

    protected ResponseEntity<?> getSuccessCodeResponse(HttpStatus httpStatus, String responseCode, Object result, int size) {
        if (result == null) {
            Map<String, String> simpleResponse = new HashMap<String, String>();
            simpleResponse.put("responseCode", responseCode);
            return getResponse(httpStatus, simpleResponse, size);
        }

        SuccessCodeResponse response = new SuccessCodeResponse();
        response.setResponseCode(responseCode);
        response.setData(result);
        return getResponse(httpStatus, response, size);
    }

    protected ResponseEntity<?> getOkResponse(Object result) {
        return getSuccessResponse(HttpStatus.OK, result, -1);
    }

    protected ResponseEntity<?> getOkResponse(String responseCode, Object result) {
        return getSuccessCodeResponse(HttpStatus.OK, responseCode, result, -1);
    }

    protected ResponseEntity<?> getOkResponse(Map resultMap) {
        return getSuccessResponse(HttpStatus.OK, resultMap, resultMap.size());
    }

    protected ResponseEntity<?> getOkListResponse(List resultList) {
        int size = 0;
        if (resultList != null)
            size = resultList.size();

        return getSuccessResponse(HttpStatus.OK, resultList, size);
    }

    protected ResponseEntity<?> getCreatedResponse(Object result) {
        return getSuccessResponse(HttpStatus.CREATED, result, -1);
    }

    protected ResponseEntity<?> getErrorResponse(String errorMessage) {
        log.error(errorMessage);
        ErrorResponse response = new ErrorResponse();
        response.setMessage(errorMessage);
        return getResponse(HttpStatus.BAD_REQUEST, response, -1);
    }

}

ErrorResponse, SuccessCodeResponse, SuccesResponse 는 클래스로 @Data로 따로 만들어줘야한다.

createOrder 메소드 : 주문 -> 상품에 재고가 있는지 확인 -> 재고가 있다면 결제 -> 결제가 성공한다면 -> 재고 감소 -> 주문 생성

{
getProduct(); -> 상품이 없다면?
getStock(); -> 재고가 없다면?
payment(); -> 결제가 실패한다면?
decreaseStock(); -> 재고 감소 실패한다면?
createOrder();
}

@Transactional
public Order createOrder(OrderDto dto) {
    Product product = productService.getProduct(dto.getProductId());
    if (product == null) throw new ProductNotFoundException(dto.getProductId());

    Stock stock = stockService.getStock(dto.getProductId());
    if (stock.getStock() < dto.getQuantity()) throw new OutOfStockException();

    Payment payment = paymentService.createPayment(new PaymentDto(dto.getOrderId(), dto.getAmount(), dto.getPaymentMethod()));
    if (!payment.isSuccess()) throw new PaymentFailedException();

    stockService.decreaseStock(dto.getProductId(), dto.getQuantity());
    return orderRepository.save(new Order(dto));
}

deleteStock은 소프트 딜리트로 처리하기! -> 완료

✅ cache local -> ehcache, caffeine , guava | remote -> redis, hazelcast, memcached -> 작업예정

✅ 계층형 캐시 어노테이션을 사용해서 캐시랑 래디스 사용하기 필수

profile
짧고 강력하게!

0개의 댓글