4 분 소요


도메인 분석

Item

@Data
public class Item {

    private Long id;

    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {

    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}


리포지토리 분석

ItemRepository

public interface ItemRepository {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findAll(ItemSearchCond cond);
}

ItemSearchCond

@Data
public class ItemSearchCond {

    private String itemName;
    private Integer maxPrice;

    public ItemSearchCond() {

    }

    public ItemSearchCond(String itemName, Integer maxPrice) {
        this.itemName = itemName;
        this.maxPrice = maxPrice;
    }
}
  • 검색 조건으로 사용되며 상품명, 최대 가격이 있다.
    • 참고로 상품명의 일부만 포함되어도 검색이 가능해야 한다.(like검색)
  • cond -> condition을 줄여서 사용

ItemUpdateDto

@Data
public class ItemUpdateDto {

    private String itemName;
    private Integer price;
    private Integer quantity;

    public ItemUpdateDto() {

    }

    public ItemUpdateDto(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • 상품을 수정할 때 사용하는 객체
  • 단순히 데이터를 전달하는 용도로 사용되므로 DTO를 뒤에 붙였다.

DTO(data transfer object)

  • 데이터 전송 객체
  • DTO는 기능은 없고, 데이터를 전달만 하는 용도로 사용되는 객체를 뜻한다.

MemoryItemRepository

@Repository
public class MemoryItemRepository implements ItemRepository {

    private static final Map<Long, Item> store = new HashMap<>(); //static
    private static long sequence = 0L; //static

    @Override
    public Item save(Item item) {
        item.setId(++sequence);
        store.put(item.getId(), item);
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        Item findItem = findById(itemId).orElseThrow();
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }

    @Override
    public Optional<Item> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        return store.values().stream()
                .filter(item -> {
                    if (ObjectUtils.isEmpty(itemName)) {
                        return true;
                    }
                    return item.getItemName().contains(itemName);
                }).filter(item -> {
                    if (maxPrice == null) {
                        return true;
                    }
                    return item.getPrice() <= maxPrice;
                })
                .collect(Collectors.toList());
    }

    public void clearStore() {
        store.clear();
    }
}
  • ItemRepository 인터페이스를 구현한 메모리 저장소이다.
  • 메모리이기 때문에 자바를 다시 실행하면 기존에 저장된 데이터가 모두 사라진다.
  • findAllItemSearchCond라는 검색 조건을 받아서 내부에서 데이터를 검색하는 기능을 한다.
    • DB로 보면 where구문을 사용해서 필요한 데이터를 필터링 하는 과정을 거치는 것이다.
    • 자바 Stream을 사용한다.
  • clearStore() : 메모리에 저장된 Item을 모두 삭제해서 초기화한다.(테스트 용도로만 사용)


서비스 분석

ItemService

public interface ItemService {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findItems(ItemSearchCond itemSearch)
} 
  • 참고로 서비스는 구현체를 변경할 일이 많지는 않기 때문에 서비스에 인터페이스를 잘 도입하지는 않는다.

나머지 ItemService 구현체와 Controller는 생략..


설정

TestDataInit

@RequiredArgsConstructor
public class TestDataInit {

    private final ItemRepository itemRepository;

    // 확인용 초기 데이터 추가
    @EventListener(ApplicationReadyEvent.class)
    public void initData() {
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));
    }
}
  • 애플리케이션을 실행할 때 초기 데이터를 저장한다.
  • 메모리이기 때문에 서버를 내리면 데이터가 제거된다.
  • @EventListener(ApplicationReadyEvent.class): 스프링 컨테이너가 완전히 초기화를 다 끝내고, 실행 준비가 되었을 때 발생하는 이벤트이다. 스프링이 이 시점에 해당 애노테이션이 붙은 initData() 메서드를 호출해준다.
    • @PostConstruct를 사용할 경우 AOP 같은 부분이 아직 다 처리되지 않은 시점에 호출될 수 있기 때문에, 간혹 문제가 발생할 수 있다.
    • 예를 들면 @Transactional과 관련된 AOP가 적용되지 않은 상태로 호출될 수 있다.

ItemServiceApplication

// ItemService 구현체와 ItemRepository 구현체를 스프링 빈으로 등록하고 생성자를 통해 의존관계를 주입하는 클래스.
@Import(MemoryConfig.class) 
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(ItemServiceApplication.class, args);
    }

    @Bean
    @Profile("local")
    public TestDataInit testDataInit(ItemRepository itemRepository) {
        return new TestDataInit(itemRepository);
    }
}
  • @Import(MemoryConfig.class): 설정해둔 MemoryConfig를 설정 파일로 사용
  • scanBasePackages = "hello.itemservice.web": 여기서는 컨트롤러만 컴포넌트 스캔을 사용하고, 나머지는 직접 수동 등록하기 때문에 컴포넌트 스캔 경로를 설정해두었다.
  • @Profile("local"): 특정 프로필의 경우에만 해당 스프링 빈을 등록한다.

프로필

스프링은 로딩 시점에 application.propertiesspring.profiles.active 속성을 읽어서 프로필로 사용한다.
이 프로필은 로컬(개인 PC), 운영 환경, 테스트 실행 등등 다양한 환경에 따라서 다른 설정을 할 때 사용하는 정보이다.
예를 들면 로컬PC에서는 PC에 설치된 데이터베이스에 접근해야 하고, 운영 환경에서는 운영 데이터베이스에 접근해야 한다면 서로 설정 정보가 달라야 한다. 심지어 환경에 따라서 다른 스프링 빈을 등록해야 할 수도 있다.

main 프로필
/src/main/resources하위의 applicaiton.properties

spring.profiles.active=local

이 위치는 /src/main하위의 자바 객체를 실행할 때(주로 main()) 동작하는 스프링 설정이다. 이렇게 설정하면 스프링은 local이라는 프로필로 동작한다.


test프로필은 동일하기에 생략..


테스트

@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @AfterEach
    void afterEach() {
        //MemoryItemRepository 의 경우 제한적으로 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }
    }

    // save, updateItem 테스트 생략..
    
    @Test
    void findItems() {

        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);
        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items);
    }
}
  • afterEach: 테스트는 서로 영향을 주면 안된다. 따라서 각각의 테스트가 끝나고 나면 저장한 데이터를 제거해야 한다. @AfterEach는 각각의 테스트의 실행이 끝나는 시점에 호출된다. 여기서는 메모리 저장소를 완전히 삭제해서 다음 테스트에 영향을 주지 않도록 초기화 한다.
  • 인터페이스에는 clearStore()가 없기 때문에 MemoryItemRepository인 경우에만 다운 캐스팅 하여 데이터를 초기화한다.
  • findItems()
    • 상품을 찾는 테스트
      • 상품명과 상품 가격 조건을 다양하게 비교.
      • 문자의 경우 null조건 외에 빈 문자("")인 경우에도 잘 동작하는지 검증.


<출처 : 인프런 - 스프링 DB 2편 : 데이터 접근 활용 기술(김영한)>

댓글남기기