개발자로 후회없는 삶 살기
spring PART.데이터 접근 기술 본문
서론
※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.
https://www.inflearn.com/roadmaps/373
본론
- 데이터 접근 기술 소개
1. sql 맵퍼
sql만 작성하면 jdbc를 직접 사용할 때 발생하는 중복 제거등, 편리 기능을 제공합니다.
2. ORM 기술
맵퍼는 sql을 직접 개발자가 작성해야하지만 jpa을 사용하면 sql은 jpa가 대신 작성하고 처리해주고 개발자는 마치 객체를 자바 컬랙션에 저장하듯이 저장하면 orm 기술이 db에 해당 객체를 저장하고 조회해줍니다. jpa는 자바 orm 표준이고 인터입니다. jpa를 구현한 것이 하이버네이트로 가장 많이 사용하는 구현체입니다.
> 자바에서 orm을 사용할 떄는 jpa 인터를 사용하고 그 구현체로 하이버네이트를 사용한다고 보면 됩니다. 스프링 데이터 jpa는 jpa를 더 편리하게 사용할 수 있게 도와주는 것입니다.
- 프로젝트 설정
다양한 데이터 접근 기술을 레포를 DI로 바꿔가면서 진행할 것입니다. 이전 프로젝트는 메모리 레포를 사용했고 이걸 실제 데이터 접근 기술로 바꿔가며 장단점은 알아가 볼 것입니다.
조건 검색 기능이 추가되는데 상품명을 item으로 하면 item만 보이고 가격을 10000원까지만 보고 싶으면 범위가 생깁니다.
- 프로젝트 구조 설명
프로젝트를 살펴보면서 인사이트를 얻을 수 있을 것입니다.
- dir 구조
전에 저는 레포지토리와 서비스를 도메인 하위 dir로 두었는데 도메인과 같은 레이어에 있습니다.
- 도메인
@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;
}
}
상품 도메인이 있습니다. 상품 관리 프로젝트이니 그렇습니다.
- 레포지토리
인터가 있습니다.
public interface ItemRepository {
Item save(Item item);
void update(Long itemId, ItemUpdateDto updateParam);
Optional<Item> findById(Long id);
List<Item> findAll(ItemSearchCond cond);
}
findAll에 검색 조건이 넘어 갑니다.
public class ItemSearchCond {
private String itemName;
private Integer maxPrice;
public ItemSearchCond() {
}
public ItemSearchCond(String itemName, Integer maxPrice) {
this.itemName = itemName;
this.maxPrice = maxPrice;
}
}
검색 조건 클래스는 상품명과 가격이 있습니다. findALl 메서드에서 이 클래스를 다뤄서 검색합니다. 상품명의 일부만 포함되어도 검색이 가능하도록 like 검색을 사용합니다.
@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) 기능은 없습니다. 데이터를 전달만 하는 용도로 사용되는 객체입니다. UploadFile, ItemSearchCond도 어떻게 보면 DTO인데 이런 것은 규칙이 없고 팀 컨벤션으로 정하면 됩니다.
+ DTO의 위치
DTO는 어디에 만드는 것이 좋을까요? 계층상 컨트 > 서비스 > 레포로 최종 호출되는 소유자는 update 메서드를 가지고 있는 레포입니다. 따라서 현재는 DTO나 Cond나 레포지토리가 둘 다 소유권을 가지고 있고 "컨트나 서비스가 레포를 호출하려면 이것을 전달해야 해"하는 상황으로 레포를 쓸 때 이 둘이 무조건 필요해서 레포에 같이 두는 게 패키지 흐름상 맞습니다.
DTO를 form이라고 보면 이전에 컨트롤러에 뒀는데 서비스에서 사용이 끝나고 레포에 전달하지 않는 DTO나 컨트에서 사용을 끝내고 서비스나 레포에 전달하지 않는 DTO면 서비스나 컨트에 두는 게 맞습니다. 그런데 지금은 레포가 최종이므로 레포에 두는 것입니다.
-> 메모리 레포
레포 하위에 메모리 패키지를 만들고 구현체를 만듭니다. update에서 이전까지는 ItemUpdateForm을 컨트에서 MA로 받아서 컨트에서 set을 했는데 그렇기 때문에 이전에는 컨트롤러에서 레포의 update를 하는 코드를 edit 맵핑 메서드에 뒀으니 컨트롤러에 DTO를 뒀던 것입니다.
@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());
}
이젠 레포에 update 메서드를 만들고 레포에서 수정합니다. 레포가 데이터 계층이니 이렇게 데이터를 다루는 것은 레포에 두는 것이 맞습니다. 따라서 DTO를 레포에 둡니다.
@Override
public Optional<Item> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
findbyId는 optional로 반환하여 없으면 null, 있으면 객체를 반환하는 통입니다.
@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());
}
findAll은 cond이 레포로 전달되고 검색 조건을 받아서 내부에서 데이터를 검색하는 기능을 합니다. store에 저장된 것을 stream으로 다 찾아봐서 상품명 조건이 비어있으면 검색을 안 하는 것으로 다 가져오는 것이고 비어있지 않으면 상품명 조건을 포함하고 있는 것만 가져옵니다.
> 통과한 데이터를 가지고 그 다음 필터에서 가격을 적용합니다. 적용한 것을 List에 담아서 반환합니다. 데이터 베이스로 보면 where 구문을 사용해서 필터링 하는 과정을 거치는 것입니다. 그래서 SQL이 좋은 것입니다. 메모리로 코드로 작성하려고 하니 조금 복잡해집니다.
<form th:object="${itemSearch}" method="get" class="form-inline">
<div class="row">
<div class="col">
<input type="text" th:field="*{itemName}" class="form-control" placeholder="상품명"/>
</div>
<div class="col">
<input type="text" th:field="*{maxPrice}" class="form-control" placeholder="가격제한"/>
</div>
+ 컨트롤러까지 적용해서 이해해보자면 html에서 name 속성이 같은 cond 객체의 필드에 값을 form으로 넣고 이래서 cond도 DTO라고 보는 것이고
@GetMapping
public String items(@ModelAttribute("itemSearch") ItemSearchCond itemSearch, Model model) {
List<Item> items = itemService.findItems(itemSearch);
model.addAttribute("items", items);
return "items";
}
그것을 제출 버튼을 누르면 컨트롤러로 MA로 넘어오고 컨트에서 레포의 findAll에 넣어서 조건에 만족하는 것을 filtering해서 가져오는 것입니다. 원래 findAll에서는 그냥 return new ArrayList(store.values())인데 cond을 적용해서 조건이 있으면 필터링하고 없으면 그냥 원래처럼 작동하는 것입니다.
- 서비스
서비스도 인터를 가지고 있습니다. 로직을 보면 레포만 불러 쓰는 것인데 단순하게 비즈니스 로직을 위임하는 것입니다. 서비스에 인터를 도입하는 것은 실무에서 그렇게 많지는 않습니다. 여기서는 예제를 위해서 인터를 도입했습니다.
- 설정
config 하위 폴더에 메모리 config가 있고 빈을 수동으로 등록합니다.
@Configuration
public class MemoryConfig {
@Bean
public ItemService itemService() {
return new ItemServiceV1(itemRepository());
}
@Bean
public ItemRepository itemRepository() {
return new MemoryItemRepository();
}
}
예제에서 편리하게 DI하기 위해서 이렇게 한 것이고 WebCof를 이렇게 config 패키지를 만들고 하위에 작성합니다.
- 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));
}
}
스프링이 어떤 타이밍이 됐을 때 이것을 호출해주는 것으로 ApplicationReadyEvent.class가 어플이 준비가 됐다는 이벤트로 스프링 컨테가 뜰 준비를 끝냈을 때 발생하는 이벤트입니다.
PostConstruct를 써도 되지만 Post는 AOP 같은 부분이 아직 다 처리되지 않은 시점에 호출될 수 있기에 트랜잭션이 적용되지 않은 상태로 호출될 수 있습니다. 리스너는 완전 초기화 이후 호출됩니다. 이것도 스프링 빈으로 등록을 해야 적용됩니다.
-> 등록
@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);
}
}
Application에서 빈을 등록하는 설정을 해야합니다. @Import(MemoryConfig.class)로 MemoryConfig를 설정 파일로 씁니다. scanBasePackages = "hello.itemservice.web"이것으로 이 범위 하위만 컨포넌트 스캔을 합니다. 레포와 서비스는 예제에서 DI를 하기 위해 직접 빈 등록하는 것이고 컨트롤러는 스캔하는 것입니다.
> TestDataInit과 별개로 서비스와 레포를 등록하는 Config입니다. @Bean으로 TestDataInit을 등록해야 위에 만든 것을 사용할 수 있습니다. > profile은 스프링에는 local이라는 프로필이 있는데 현재 스프링이 특정 프로필인 경우에만 해당 스프링 빈을 등록합니다. 스프링의 실행에 프로필을 줄 수 있습니다.
- 프로필
프로퍼티스에 프로파일.active가 있습니다. 로컬이라는 프로필이 활성화 된다는 것으로
@Bean
@Profile("local")
@Bean @Profile("local") 얘가 스프링 빈으로 등록되는 것입니다. 스프링은 로딩 시점에 프로퍼티스를 읽어서 spring.profiles.active 속성을 읽어서 프로필로 사용합니다. 이 프로필은 로컬, 운영환경, 테스트 실행 등등 다양한 환경에 따라서 다른 설정을 할 때 사용하는 정보로
예를 들어서 로컬 pc에서는 내 로컬 환경에 저장된 db를 쓰고 싶고 운영 환경에서는 운영 서버에 저장된 db에 접근해야합니다. 그러면 db와 환경이 다르니 설정 정보가 달라야할 것이고 심지어 환경에 따라서 완전히 다른 빈을 등록해야할 수도 있습니다. 그때 프로필을 사용하면 이 문제를 해결할 수 있습니다. 지금은 프로퍼티스에 local을 활성화 시켜 지금 local 환경에서는 프로필이 local인 빈만 등록하게 하는 것입니다.
-> 메인 프로필
프로퍼티스에 만든 설정이 지금 /src/main/resource에 프로퍼티스에 있는데 이 위치에 두면 스프링은 /src/main 하위의 자바 객체를 실행할 때 스프링이 local이라는 프로필로 동작합니다. 스프링이 뜰 때 딱 이것을 읽고 "아 로컬이 active 되어있네 하고 난 지금부터 로컬이라는 이름의 프로필로 동작을 할 거야"하고 로컬 프로필로 동작합니다. 따라서 전에 설명한 @Profile("local")이 동작하고 testDataInit이 스프링 빈으로 등록됩니다.
이것을 nono로 바꾸고 실행하면 "난 지금부터 nono라는 이름의 프로필로 동작을 할 거야"하고
프로필이 nono인 빈만 등록해서 프로필이 local일 때만 등록되는 TestDataInit은 등록이 안 되어서 초기화 데이터가 없습니다. 프로필이 노노라서 프로필이 로컬인 빈은 등록이 안됩니다.
프로필을 지정하지 않으면 default 프로필이 실행됩니다.
-> 프로젝트 프로필 설정
현재 이 프로젝트는 프로필을 2개로 분류했습니다. 메인과 test입니다. 잘보면 /src/test/resource에도 프로퍼티스가 있는데
여기서 실행하면 이 프로퍼티스 속성을 읽고 프로필을 test로 해서 내가 테스트 케이스를 실행하면 이 프로필로 스프링을 동작하게 됩니다. 테스트라는 이름으로 스프링을 동작시킵니다. 주로 데케를 실행할 때 동작하고 이 경우 @Profile("local")이 빈으로 등록되지 않습니다.
+ 테스트에서는 등록하지 않는 이유
테스트에서는 등록하면 안 됩니다. 그 이유는 프로필 기능을 사용하면 메인 메서드로 웹 어플을 띄울 때는 프로필이 작동을 하는 것이 좋습니다. 근데 이게 스프링부트를 사용한(@SpringBootTest) 테케에서 문제가 될 수 있습니다. 스프링 부트를 사용하는 테스트에서는 이런 데이터가 들어있으면 오류가 발생할 수 있어서 테스트에서는 이런 초기화 데이터를 잘 쓰지 않고 프로필 기능 덕분에 데케에서는 test 프로필이 실행되고 따라서 TestDataInit이 빈으로 추가되지 않고 초기 데이터도 추가되지 않습니다.
> 이렇게 레포와 서비스는 스프링이 뜨면 무조건 등록돼야 해서 config로 무조건 등록하고 Init은 프로필에 따라서 다르게 등록되어야 해서 config에서 하지 않는 것입니다.
- 테스트
저장, 수정, find 테스트가 있습니다. 메모리 레포를 쓰는 경우는 afterEach를 써서 각 메서드가 영향을 받지 않게 합니다. 레포 인터는 clearStore가 없는데 메모리에만 있습니다. 메모리에서만 afterEach를 씁니다. DB를 쓰는 경우에는 트랜잭션을 롤백해서 데이터를 초기화할 것입니다.
@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);
}
findItems는 상품을 찾는 테스트로 아이템 3개를 저장하고 검증을 하는데
void test(String itemName, Integer maxPrice, Item... items) {
List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
assertThat(result).containsExactly(items);
}
test 메서드가 상품명, 가격 제한에 조건을 넣는데
test(null, null, item1, item2, item3);
상품명도 안 넣고 제한도 안넣으면 저장한 3개가 다 나와야합니다. test메서드에 assertThat이 있어서 items 파라미터가 3개가 왔나 평가합니다. findAll에 Cond받고 메서드 돌렸는데 3개가 있으면 참, 아니면 실패입니다. 지금 상품명, 가격 제한에 다 null 넣었으니 검색 조건이 없어서 다 나와야합니다.
> assertThat에서 containsExactly하면 순서도 맞아야합니다. 레포에 save한 순서가 상품 1, 2, 3이니 1, 2, 3순서로 나와야 성공입니다.
공백이 들어간 경우도 3개가 다 나와야합니다.
isEmpty가 문자인 경우(.isEmpty(itemName))에 길이가 0이어도 true(비어있는 것)를 반환합니다. findALL 메서드의 경우 isEmpty가 true면 상품명과 가격 제한이 Empty인 것으로 비어있는 것으로 조건이 없어서 3개가 다 나와야합니다. 테스트에서 이 정도는 다 검사해야합니다.
test("itemA", null, item1, item2);
> itemA로 검색하면 비어있지 않은 경우로
return item.getItemName().contains(itemName);
contains를 쓰기에 where를 쓴 것처럼 부분 검색도 되어야합니다. 따라서 상품 1, 2가 나와야합니다. B로 검색하면 상품 3만 나와야합니다. 이런 것이 조건 테스트에 반드시 있어야하는 부분입니다.
+ 인터페이스 테스트
지금 보면 메모리 구현체로 테스트하는 것이 아니라 인터로 테스트하는데 이러면 해야 다른 구현체로 변경해도 같은 테스트 로직으로 편리하게 검증할 수 있습니다. 테스트할 때는 기본적으로 인터로 하는게 좋고 하다보면 구현 내부에 로직을 테스트할 때가 필요하면 구현체로 테스트합니다.
※ ... 문법 : 여러개
- db 테이블
테이블을 생성합니다. id는 자동생성으로 했습니다. 기본 키를 생성을 db에 위임해서 개발자가 직접 id값을 지정하는게 아니라 비워두고 그러면 insert 할 때마다 개발자는 value에 item_name, price, quantity만 넣으면 db가 알아서 순서대로 증가하는 값을 넣습니다.
=> id를 증가하는 것으로 하는 이유
생각해보면 기본키를 왜 증가하는 수로 해야하는지 궁금합니다. 주민번호로 하면 되지 않을까 싶습니다.
-> db 권장 식별자 선택 전략
db 기본키는 3가지 조건을 만족해야 합니다. null 값을 허용하지 않아야하고 유일해야하고 변해선 안됩니다.
-> 테이블의 기본 키를 선택하는 전략 2가지
1) 자연 키
비즈니스에 의미가 있는 키로 주민번호, 전화번호, 이메일이 예입니다. 보는 순간 번호, 이멜은 변할 수 있습니다. 주민 번호도 변할 수 있습니다. 안 변할 것같지만 수백만이 사용하는 서비스를 만들다보면 주민 번호를 여러가지 이유로 바꾸는 사용자가 실제로 나타납니다. 그래서 비즈니스에 있는 데이터를 가지고 pk잡기가 어렵습니다.
2) 대리키
비즈니스와 관련없는 임의의 키로 시스템이 자동으로 값을 생성하는 키로 변하지 않습니다.
-> 권장
자연키 보다는 대리키를 권장합니다. 주민번호를 정부 정책으로 저장할 수 없게 되면 DB 수정 사항이 엄청납니다. 그래서 외부 풍파에 쉽게 흔들리지 않는 비즈니스와 무관한 임의의 값으로 기본키를 사용하여 수정을 적게 하는 것을 권장합니다.
'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글
spring PART.데이터 접근 기술 테스트 (0) | 2023.05.14 |
---|---|
spring PART.스프링 JdbcTemplate 적용 (0) | 2023.05.14 |
spring PART.중간점검 3 (0) | 2023.05.09 |
spring PART.스프링 데이터 접근 예외 처리 (0) | 2023.05.07 |
spring PART.자바 예외 이해 (0) | 2023.05.06 |