제육's 휘발성 코딩
article thumbnail
반응형

프로젝트 구조

image

  • 다음과 같이 상품 목록을 조회하는 프로젝트 구조를 알아보자.

 

build.gradle

plugins {
    id 'org.springframework.boot' version '2.6.5'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    //테스트에서 lombok 사용
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
    useJUnitPlatform()
}
  • 타임리프, 스타터 웹, 롬복 등을 주입받아서 사용했다.

 

도메인 분석

Item

package hello.itemservice.domain;

import lombok.Data;

@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

package hello.itemservice.repository;

import hello.itemservice.domain.Item;

import java.util.List;
import java.util.Optional;

public interface ItemRepository {

  Item save(Item item);

  void update(Long itemId, ItemUpdateDto updateParam);

  Optional<Item> findById(Long id);

  List<Item> findAll(ItemSearchCond cond);

}
  • 구현체의 변경을 자유롭게 하기 위해 인터페이스를 사용했다.

ItemSearchCond

package hello.itemservice.repository;

import lombok.Data;

@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 검색을 사용한다.

ItemUpdateDto

package hello.itemservice.repository;

import lombok.Data;

@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이다.

MemoryItemRepository

package hello.itemservice.repository.memory;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import org.springframework.stereotype.Repository;
import org.springframework.util.ObjectUtils;

import java.util.*;
import java.util.stream.Collectors;

@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를 구현한 메모리 저장소이다.

 

서비스 분석

ItemService

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;

import java.util.List;
import java.util.Optional;

public interface ItemService {

  Item save(Item item);

  void update(Long itemId, ItemUpdateDto updateParam);

  Optional<Item> findById(Long id);

  List<Item> findItems(ItemSearchCond itemSearch);
}
  • 서비스도 구현체를 쉽게 변경하기 위한 인터페이스를 사용했다. 서비스 로직에서 인터페이스를 잘 도입하지 않지만, 예제에서 변경하기 위해서 도입했다.

ItemServiceV1

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class ItemServiceV1 implements ItemService {

  private final ItemRepository itemRepository;

  @Override
  public Item save(Item item) {
    return itemRepository.save(item);
  }

  @Override
  public void update(Long itemId, ItemUpdateDto updateParam) {
    itemRepository.update(itemId, updateParam);
  }

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

  @Override
  public List<Item> findItems(ItemSearchCond cond) {
    return itemRepository.findAll(cond);
  }
}
  • 단순히 리포지토리를 위임하는 역할을 하는 구현체로 사용된다.

 

컨트롤러 분석

HomeController

package hello.itemservice.web;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequiredArgsConstructor
public class HomeController {

  @RequestMapping("/")
  public String home() {
    return "redirect:/items";
  }
}
  • 메인 페이지 요청이 오면 items 로 이동하는 컨트롤러

 

ItemController

package hello.itemservice.web;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.service.ItemService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Controller
@RequestMapping("/items")
@RequiredArgsConstructor
public class ItemController {

  private final ItemService itemService;

  @GetMapping
  public String items(@ModelAttribute("itemSearch") ItemSearchCond itemSearch, Model model) {
    List<Item> items = itemService.findItems(itemSearch);
    model.addAttribute("items", items);
    return "items";
  }

  @GetMapping("/{itemId}")
  public String item(@PathVariable long itemId, Model model) {
    Item item = itemService.findById(itemId).get();
    model.addAttribute("item", item);
    return "item";
  }

  @GetMapping("/add")
  public String addForm() {
    return "addForm";
  }

  @PostMapping("/add")
  public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
    Item savedItem = itemService.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/items/{itemId}";
  }

  @GetMapping("/{itemId}/edit")
  public String editForm(@PathVariable Long itemId, Model model) {
    Item item = itemService.findById(itemId).get();
    model.addAttribute("item", item);
    return "editForm";
  }

  @PostMapping("/{itemId}/edit")
  public String edit(@PathVariable Long itemId, @ModelAttribute ItemUpdateDto updateParam) {
    itemService.update(itemId, updateParam);
    return "redirect:/items/{itemId}";
  }
}
  • 상품을 CRUD하는 컨트롤러이다.

 

스프링부트 설정

MemoryConfig

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.memory.MemoryItemRepository;
import hello.itemservice.service.ItemService;
import hello.itemservice.service.ItemServiceV1;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MemoryConfig {

  @Bean
  public ItemService itemService() {
    return new ItemServiceV1(itemRepository());
  }

  @Bean
  public ItemRepository itemRepository() {
    return new MemoryItemRepository();
  }
}
  • OCP, DIP를 지키기 위한 DI설정으로 별도의 Configuration을 통해 수동 빈을 등록했다.

 

TestDataInit

package hello.itemservice;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;

@Slf4j
@RequiredArgsConstructor
public class TestDataInit {

  private final ItemRepository itemRepository;

  /**
   * 확인용 초기 데이터 추가
   */
  @EventListener(ApplicationReadyEvent.class)
  public void initData() {
    log.info("test data init");
    itemRepository.save(new Item("itemA", 10000, 10));
    itemRepository.save(new Item("itemB", 20000, 20));
  }
}
  • 애플리케이션 실행 시점에 초기 데이터 생성을 위한 설정이다.
  • @EventListener(ApplicationReadyEvent.class)는 스프링 컨테이너가 완전히 초기화를 끝내고, 실행 준비가 되었을 때 발생하는 이벤트이다. 이 어노테이션이 실행되면서 연결된 메서드를 호출된다.
    • @PostConstruct는 AOP 같은 부분이 처리가 안된 시점에서 호출될 수 있으므로 이벤트리스너를 사용하는 것이 안전하다.

 

ItemServiceApplication

package hello.itemservice;

import hello.itemservice.config.*;
import hello.itemservice.repository.ItemRepository;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;

import java.util.ArrayList;


@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);
  }
}
  • 컨트롤러만 컴포넌트 스캔의 대상으로 적용하고 나머지는 수동으로 등록하기 위한 scanBasesPackages 위치를 지정하고, 설정파일 지정을 위한 @Import(MemoryConfig.class) 을 사용했다.
  • @Profile("local")의 경우 local이라는 이름의 프로필이 사용되는 경우에만 초기 데이터를 빈으로 등록하기 위한 설정이다.

 

프로필

application.properties

spring.profiles.active = local
  • 프로필이란 스프링에서 로컬, 운영, 테스트 등의 다양한 환경을 분리하기 위한 설정이다. 예를 들어 운영 DB와 로컬 DB를 분리 시키고 싶다면, 프로필을 사용해서 구별할 수 있다.
  • 스프링의 기본 프로필은 default 프로필이다.

테스트 코드

ItemRepositoryTest

package hello.itemservice.domain;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.repository.memory.MemoryItemRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class ItemRepositoryTest {

  @Autowired
  ItemRepository itemRepository;

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

  @Test
  void save() {
    //given
    Item item = new Item("itemA", 10000, 10);

    //when
    Item savedItem = itemRepository.save(item);

    //then
    Item findItem = itemRepository.findById(item.getId()).get();
    assertThat(findItem).isEqualTo(savedItem);
  }

  @Test
  void updateItem() {
    //given
    Item item = new Item("item1", 10000, 10);
    Item savedItem = itemRepository.save(item);
    Long itemId = savedItem.getId();

    //when
    ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
    itemRepository.update(itemId, updateParam);

    //then
    Item findItem = itemRepository.findById(itemId).get();
    assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
    assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
    assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
  }

  @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 어노테이션을 통해 각 테스트가 종료된 후 저장된 데이터를 제공시키도록 설정했다.

 

테이블 생성

H2 데이터베이스를 사용해서 테이블을 만들자.

H2 데이터베이스 설정은 https://sasca37.tistory.com/13?category=1218302 를 참고하자.

Item

drop table if exists item CASCADE;
create table item
  (
  id        bigint generated by default as identity,
  item_name varchar(10),
  price     integer,
  quantity  integer,
  primary key (id)
);
  • identity 전략은 기본 키 생성을 데이터베이스에 위임하는 전략이다. (MySQL의 AutoIncrement) PK값인 id를 빈 값으로 저장하면 데이터베이스가 자동으로 순서대로 증가하는 값을 넣어준다.
insert into item(item_name, price, quantity) values ('ItemTest', 10000, 10);
select * from item;
  • 다음과 같이 데이터 삽입 후 조회 테스트를 해보자.

식별자 선택 전략

자연키 : 비즈니스에 의미가 있는 키 (주민등록번호, 전화번호 등)

대리키 : 비즈니스와 관련 없이 임의로 만들어진 키 (auto_increment, identity 등)

 

자연 키 vs 대리키

되도록이면 대리 키를 권장한다. 비즈니스 환경은 언제가 변한다는 생각을 해보자. 예를 들어 주민등록번호는 기본 키 선택 전략인 유일성, 최소성, NotNull 등을 확실하게 만족한다. 하지만 정부 정책이 변경되며 주민등록번호를 저장할 수 없는 상황이 발생할 수 있다. 즉, 미래까지 충족하는 자연키는 찾기 어렵다. 그렇기 때문에 미래의 영향을 받지 않는 일관된 방식인 대리키를 사용하는 것이 바람직하다.


REFERENCES

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-2/

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^