Spring

[인프런/스프링 DB 2편] 1. 데이터 접근 기술

주니어주니 2023. 6. 28. 00:04

 

 

1. 데이터 접근 기술 소개

 

1. SQLMapper 

- 개발자는 SQL만 작성하면 해당 SQL의 결과를 객체로 편리하게 매핑해줌

- JDBC를 직접 사용할 때 발생하는 중복 제거, 기타 개발자에게 편리한 여러 기능 제공

  • JdbcTemplate 
  • Mybatis

 

2. ORM 관련 기술 

- 기본적인 SQL은 JPA가 대신 작성하고 처리. 개발자는 저장하고 싶은 객체를 자바 컬렉션에 저장하고 조회하듯이 사용하면 ORM 기술이 데이터베이스에 해당 객체를 저장, 조회

- JPA -> 자바 진영의 ORM 표준,
  Hibernate -> JPA에서 가장 많이 사용하는 구현체

(자바에서 ORM 사용할 때는 JPA 인터페이스 사용, 그 구현체로 하이버네이트 사용)

- 스프링 데이터 JPA, Querydsl은 JPA를 더 편리하게 사용할 수 있게 도와주는 프로젝트 

  • JPA, Hibernate
  • 스프링 데이터 JPA
  • Querydsl

 


 

2. 데이터 접근 기술 - 프로젝트 설정 분석 (상품 관리 프로젝트)

 

 

2-1. 프로젝트 기본 구조 분석

 

1) 프로젝트 설정 

 

* build.gradle 

  • spring-boot-starter-thymeleaf : 타임리프 사용 
  • spring-boot-starter-web : 스프링 웹, MVC 사용
  • spring-boot-starter-test : 스프링이 제공하는 테스트 기능
  • lombok : lombok을 테스트에서도 사용하는 설정
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()
}

 

 

2) 도메인 분석 

 

* 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;
    }
}

 

 

3) 리포지토리 분석 

 

(1) 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);

}

 

 

(2) 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;
    }
}

 

 

(3) ItemUpdateDto 상품 수정 객체

  • Data Transfer Object : 데이터 전송 객체
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를 어디에 포함할 것인지 

- DTO를 제공하는 마지막 단이 어딘지 확인
- 서비스에서 호출 -> 리포지토리에서 최종 사용하고 끝남 => 리포지토리 패키지 
- 애매하다 => 패키지 따로 생성

 

 

(4) 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();
    }

}

 

 

4) 서비스 분석 

 

(1) 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);
}

 

 

(2) 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);
    }
}

 

 

 

5) 컨트롤러 분석

 

* 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}";
    }

}

 

 

 

2-2. 프로젝트 설정 분석 

 

1) 스프링 부트 설정 분석 

 

(1) MemoryConfig

  • ItemServiceV1, MemoryItemRepository를 스프링 빈으로 등록하고 생성자를 통해 의존관계 주입
  • @Service, @Repository 처럼 자동으로 등록할 수 있지만, 여기서는 서비스와 리포지토리 구현체를 편리하게 변경하기 위해 수동으로 빈 등록 
  • 컨트롤러는 컴포넌트 스캔 사용
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();
    }

}

 

 

(2) 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)
    • 스프링 컨테이너가 초기화를 완전히 다 끝내고 실행준비가 되었을 때 발생하는 이벤트
    • 이 시점에 해당 어노테이션이 붙은 InitData() 메소드 호출
    • 이 기능 대신 @PostConstruct 사용할 경우 AOP같은 부분이 아직 다 처리되지 않은 시점에 호출될 수 있음
    • 해당 기능은 AOP를 포함한 스프링 컨테이너가 완전히 초기화 된 이후 호출

 

teset data init 로그 확인

 

 

(3) 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(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" 
    • 나머지는 스프링 빈에 수동 등록, web 패키지 하위에 있는 컨트롤러들만 컴포넌트 스캔으로 스프링 빈에 자동 등록

  • @Profile("local") 
    • 특정 프로필의 경우에만 해당 스프링 빈 등록
    • local 이라는 이름의 프로필이 사용되는 경우에만 testDataInit이라는 스프링 빈 등록 -> 초기 데이터 추가

 

 

💡 프로필 

 

- 로컬, 운영 환경, 테스트 실행 등 다양한 환경에 따라서 다른 설정을 할 때 사용하는 정보

- 스프링은 로딩 시점에 application.properties 속성을 읽어서 프로필로 사용

 

 

① main 프로필 

 

- /src/main/resources 하위의 application.properties

spring.profiles.active=local
  • /src/main 하위의 자바 객체를 실행할 때 동작하는 스프링 설정
  • local 이라는 프로필로 동작
    -> @Profile("local") 동작 -> testDataInit가 스프링 빈으로 등록 -> 초기 데이터 추가

 

실행 시 로그

 

- 프로필을 설정하지 않을 때 - default

#spring.profiles.active=local

실행 시 로그

 

 

② test 프로필

 

- /src/test/resources 하위의 application.properties 

spring.profiles.active=test
  • /src/test 하위의 자바 객체를 실행할 때 동작하는 스프링 설정 
  • 테스트 케이스 실행할 때 동작
  • test라는 프로필로 동작 
    -> @Profile("local") 과 프로필 정보가 맞지 않아서 동작 X
    -> testDataInit 라는 스프링 빈 등록 X , 초기 데이터 추가 X 
  • 테스트 시 초기화 데이터 사용하면 오류 발생할 수 있음
    (데이터 하나 저장하고 전체 카운트 시 1이 아니라 초기화 데이터를 포함한 3 출력)

 

실행 시 로그

 

 

2-3. 테스트 

 

* 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()
    • 테스트는 서로 영향을 주면 안됨 -> 각 테스트 끝나면 모두 제거
    • 인터페이스에는 clear() 가 없기 때문에 MemoryItemRepository인 경우에만 다운캐스팅해서 데이터 초기화
      (실제 DB 사용할 때는 테스트 끝난 후 트랜잭션을 롤백해서 데이터 초기화함)

  • findItems()
    • 상품 검색 테스트 
    • 상품명, 최대가격이 빈 칸이면 모든 상품 출력
    • 상품명 (문자열) - null 일 때, 빈문자(" ")일 때
    • 최대 가격 - null 일 때
  • 💡 MemoryItemRepository 구현체를 테스트 하는 것이 아니라 ItemRepository 인터페이스를 테스트
    • 인터페이스 테스트 -> 다른 구현체로 변경되었을 때 같은 테스트로 검증할 수 있음

 


 

3. 데이터베이스 테이블 생성 

 

H2 데이터베이스 - 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)
);
  • generated by default as identity
    • identity 전략 - 기본 키 생성을 데이터베이스에 위임하는 방법 (MySQL의 Auto Increment와 같은 방법)
    • PK로 사용되는 id는 개발자가 직접 지정 X, 비워두고 저장 -> 데이터베이스가 순서대로 증가하는 값을 사용해서 넣어줌

 

 

💡 참고 - 식별자 선택 전략 

 

* 데이터베이스 기본 키의 3가지 조건 

  • null 값 X 
  • 유일
  • 변하면 X

 

* 테이블의 기본 키 선택하는 2가지 전략

  • 자연 키 (natural key)
    • 비즈니스에 의미가 있는 키
    • 예 : 주민등록번호, 이메일, 전화번호

  • 대리 키 (surrogate key)
    • 비즈니스와 관련 없는 임의로 만들어진 키 
    • 예 : 오라클 시퀀스, auto_increment, identity, 키 생성 테이블

 

 

* 자연 키 < 대리 키 권장 

  • 전화번호 -> 변할 수 있음 
  • 이메일 -> 변할 수 있음 
  • 주민등록번호 -> 변할 수 있음 
  • ---> 외부 상황에도 변하지 않는 대리 키 사용 권장