데이터 접근 기술
SQL Mapper
- JdbcTemplate
- MyBatis
ORM 관련 기술
(ORM ==> Object Relational Mapping)
- JPA, Hibernate
- 스프링 데이터 JPA
- Querydsl
SQL Mapper 주요기능
개발자는 SQL만 작성하면 SQL Mapper 기술은 해당 SQL의 결과를 객체로 편리하게 매핑해준다.
JDBC를 직접 사용할 때 발생하는 여러가지 중복을 제거해주고, 기타 개발자에게 여러가지 편리한 기능을 제공한다
ORM 주요 기능
JdbcTemplate이나 MyBatis 같은 SQL 매퍼 기술은 SQL을 개발자가 직접 작성해야 하지만,
JPA를 사용하면 기본적인 SQL은 JPA가 대신 작성하고 처리해준다.
개발자는 저장하고 싶은 객체를 마치 자바 컬렉션에 저장하고 조회하듯이 사용하면
ORM 기술이 데이터베이스에 해당 객체를 저장하고 조회해준다.
JPA는 자바 진영의 ORM 표준이고, Hibernate(하이버네이트)는 JPA에서 가장 많이 사용하는 구현체이다.
자바에서 ORM을 사용할 때는 JPA 인터페이스를 사용하고, 그 구현체로 하이버네이트를 사용한다고 생각하면 된다.
스프링 데이터 JPA, Querydsl은 JPA를 더 편리하게 사용할 수 있게 도와주는 프로젝트이다.
실무에서는 JPA를 사용하면 이 프로젝트도 꼭! 함께 사용하는 것이 좋다.
개인적으로는 거의 필수라 생각한다.
프로젝트 둘러보기
DTO (data transfer object)
데이터 전송 객체
DTO는 기능은 없고 데이터를 전달만 하는 용도로 사용되는 객체를 뜻한다.
참고로 DTO에 기능이 있으면 안되는가? 그것은 아니다.
객체의 주 목적이 데이터를 전송하는 것이라면 DTO라 할 수 있다.
객체 이름에 DTO를 꼭 붙여야 하는 것은 아니다.
대신 붙여두면 용도를 알 수 있다는 장점은 있다.
이전에 설명한 ItemSearchCond 도 DTO 역할을 하지만,
이 프로젝트에서 Cond 는 검색 조건으로 사용한다는 규칙을 정했다. 따라서 DTO를 붙이지 않아도 된다.
ItemSearchCondDto 이렇게 하면 너무 복잡해진다.
그리고 Cond 라는 것만 봐도 용도를 알 수 있다.
참고로 이런 규칙은 정해진 것이 없기 때문에 해당 프로젝트 안에서 일관성 있게 규칙을 정하면 된다.
메모리 리포지토리
Map을 저장소로 삼는다. 서버가 꺼지면 당연히 데이터가 사라지는 메모리 리포지토리이다.
아이디를 받아 멤버를 찾기
@Override
public Optional<Item> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
findById 는 Optional 을 반환해야 하기 때문에 Optional.ofNullable 을 사용했다.
조건 검색
아이템 검색조건 객체를 받아서 조건이 있으면 해당 조건을 넣어 검색하고 결과를 매핑해준다.
@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());
}
(자바 스트림 사용)
itemName 이나, maxPrice 가 null 이거나 비었으면 해당 조건을 무시한다.
itemName 이나, maxPrice 에 값이 있을 때만 해당 조건으로 필터링 기능을 수행한다.
검색조건이 비어있으면 무조건 가져오는것이므로 true
검색조건이 있다면 해당 검색조건을 통과하면 true 아니면 false
즉 두가지 필터를 다 true로 통과한것들을 담아 리스트로 바꿔반환해주는 메소드이다.
스프링 부트 설정 - Memory Config
설정파일
@Configuration
public class MemoryConfig {
@Bean
public ItemService itemService() {
return new ItemServiceV1(itemRepository());
}
@Bean
public ItemRepository itemRepository() {
return new MemoryItemRepository();
}
}
ItemServiceV1 , MemoryItemRepository 를 스프링 빈으로 등록하고 생성자를 통해 의존관계를 주입한다.
(스프링빈의 이름은 메소드이름, 스프링 빈 객체는 반환값)
참고로 여기서는 서비스와 리포지토리는 구현체를 편리하게 변경하기 위해, 이렇게 수동으로 빈을 등록했다.
(자동 빈등록을 하려면 @Service, @Repository를 붙여주면된다. 쓰려는 리포지토리에는 @Repository를 붙이고, 안쓰는 리포지토리에는 @Repository를 지우거나, 빈 우선순위를 설정해줘야하는 귀찮음때문에 이렇게 진행하는것 같다.)
컨트롤러는 컴포넌트 스캔을 사용한다.
메모리 리포지토리를 위한 테스트 데이터 자동 생성
@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)
(@EventListner는 파라미터로 넘어온 이벤트가 종료됬을때 매핑된 메소드를 실행하는 어노테이션)
스프링 컨테이너가 완전히 초기화를 다 끝내고, 실행 준비가 되었을 때 발생하는 이벤트이다.
스프링이 이 시점에 해당 애노테이션이 붙은 initData() 메서드를 호출해준다.
참고로 이 기능 대신 @PostConstruct 를 사용할 경우
AOP 같은 부분이 아직 다 처리되지 않은 시점에 호출될 수 있기 때문에, 간혹 문제가 발생할 수 있다.
예를 들어서 @Transactional 과 관련된 AOP가 적용되지 않은 상태로 호출될 수 있다.
@EventListener(ApplicationReadyEvent.class) 는
AOP를 포함한 스프링 컨테이너가 완전히 초기화 된 이후에 호출되기 때문에 이런 문제가 발생하지 않는다.
ItemServiceApplication
@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);
}
}
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
컴포넌트 스캔의 범위를 해당 패키지와 하위패키지로 설정해두었다.
(여기서는 컨트롤러만 컴포넌트 스캔을 사용하고, 나머지는 직접 수동 등록한다.
그래서 컴포넌트 스캔 경로를 hello.itemservice.web 하위로 지정했다)
(설정하지않으면 현재 ItemServiceApplication이 존재하는 패키지와 그하위패키지로 설정된다.
(package hello.itemservice;))
설정된 경로 이외의 패키지에서의 클래스에서는 @Bean, @Configuration을 사용해도 컴포넌트스캔의 대상이 아니다.
@Import(MemoryConfig.class)
그러므로 package hello.itemservice.config 에 존재하는 MemoryConfig.class를 설정을 추가하기위해 @import를 이용해서 추가해준다. https://pangtrue.tistory.com/76
( MemoryConfig의 위치가 컴포넌트 스캔 대상에 포함되지않으므로)
@Bean
@Profile("local")
특정 프로필의 경우에만 해당 스프링 빈을 등록한다. (@Profile을 이용하여)
여기서는 local 이라는 이름의 프로필이 사용되는 경우에만 testDataInit 이라는 스프링 빈을 등록한다.
이 빈은 앞서 본 것인데, 편의상 초기 데이터를 만들어서 저장하는 빈이다.
프로필 (Profile)
스프링은 로딩 시점에 application.properties 의 spring.profiles.active 속성을 읽어서 프로필로 사용한다.
이 프로필은 로컬(나의 PC), 운영 환경, 테스트 실행 등등 다양한 환경에 따라서 다른 설정을 할 때 사용하는 정보이다.
예를 들어서 로컬PC에서는 로컬 PC에 설치된 데이터베이스에 접근해야 하고,
운영 환경에서는 운영 데이터베이스에 접근해야 한다면 서로 설정 정보가 달라야 한다.
심지어 환경에 따라서 다른 스프링 빈을 등록해야 할 수 도 있다.
프로필을 사용하면 이런 문제를 깔끔하게 해결할 수 있다.
https://keeeeeepgoing.tistory.com/171
main 프로필
/src/main/resources 하위의 application.properties
이 위치의 application.properties 는 /src/main 하위의 자바 객체를 실행할 때 (주로 main() ) 동작하는 스프링 설정이다.
spring.profiles.active = local
라는 코드로 프로필을 설정할 수 있다. ( 위의 코드는 프로필을 local로 설정한 모습)
이렇게 설정하면 스프링이 동작할때 해당 프로필로 동작하게 된다.
따라서 직전에 설명한 @Profile("local") 가 동작하고, testDataInit 가 스프링 빈으로 등록된다.
실행하면 다음과 같은 로그를 확인할 수 있다
참고로 프로필을 지정하지 않으면 디폴트( default ) 프로필이 실행된다
test 프로필
/src/test/resources 하위의 application.properties
spring.profiles.active=test
이 위치의 application.properties 는 /src/test 하위의 자바 객체를 실행할 때 동작하는 스프링 설정이다.
주로 테스트 케이스를 실행할 때 동작한다.
spring.profiles.active=test 로 설정하면 스프링은 test 라는 프로필로 동작한다.
이 경우 직전에 설명한 @Profile("local") 는 프로필 정보가 맞지 않아서 동작하지 않는다.
따라서 testDataInit 이라는 스프링 빈도 등록되지 않고, 초기 데이터도 추가하지 않는다.
기본값은?
프로필 기능을 사용해서 스프링으로 웹 애플리케이션을 로컬( local )에서 직접 실행할 때는
testDataInit 이 스프링 빈으로 등록된다.
따라서 등록한 초기화 데이터를 편리하게 확인할 수 있다.
초기화 데이터 덕분에 편리한 점도 있지만, 테스트 케이스를 실행할 때는 문제가 될 수 있다.
테스트에서 이런 데이터가 들어있다면 오류가 발생할 수 있다.
예를 들어서 데이터를 하나 저장하고 전체 카운트를 확인하는데
1이 아니라 testDataInit 때문에 데이터가 2건 추가되어서 3이 되는 것이다.
프로필 기능 덕분에 테스트 케이스에서는 test 프로필이 실행된다.
따라서 TestDataInit 는 스프링 빈으로 추가되지 않고, 따라서 초기 데이터도 추가되지 않는다.
테스트쪽에있는 ApplicationTests를 실행해도
main쪽에있는 ItemServiceApplication이 실행된다.
ItemServiceApplicationTests내부 코드에는 TestDataInit을 빈으로 추가하는 코드가 없으므로 테스트의 프로필을 local로 해도 상관없지않을까 했지만 ItemServiceApplicationTests실행시 main쪽에 있는 ItemServiceApplication가 실행된다.
그러므로 테스트 프로필이 local이라면 TestDataInit의 빈이 생성되면서 테스트 데이터가 추가도니다.
테스트코드
테스트 코드중 몇부분만 소개한다.
@AfterEach
void afterEach() {
//MemoryItemRepository 의 경우 제한적으로 사용
if (itemRepository instanceof MemoryItemRepository) {
((MemoryItemRepository) itemRepository).clearStore();
}
}
afterEach :
테스트는 서로 영향을 주면 안된다. 따라서 각각의 테스트가 끝나고 나면 저장한 데이터를 제거해야 한다.
@AfterEach 는 각각의 테스트의 실행이 끝나는 시점에 호출된다.
여기서는 메모리 저장소를 완전히 삭제해서 다음 테스트에 영향을 주지 않도록 초기화 한다.
인터페이스에는 clearStore() 가 없기 때문에 MemoryItemRepository 인 경우에만 다운 케스팅을 해서
데이터를 초기화한다.
뒤에서 학습하겠지만,
실제 DB를 사용하는 경우에는 테스트가 끝난 후에 트랜잭션을 롤백해서 데이터를 초기화 할 수 있다.
https://madplay.github.io/post/java-upcasting-and-downcasting
@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);
}
... 라는 가변인자를 통해 여러개의 파라미터를 받을수 있다.
https://jekal82.tistory.com/47
( 가변인자를 이용하여 파라미터를 넣을때 순서도 중요하다. )
인터페이스를 테스트하자
여기서는 MemoryItemRepository 구현체를 테스트 하는 것이 아니라
ItemRepository 인터페이스를 테스트하는 것을 확인할 수 있다.
인터페이스를 대상으로 테스트하면 향후 다른 구현체로 변경되었을 때
해당 구현체가 잘 동작하는지 같은 테스트로 편리하게 검증할 수 있다.
권장하는 식별자 선택 전략
데이터베이스 기본 키는 다음 3가지 조건을 모두 만족해야 한다.
1. null 값은 허용하지 않는다.
2. 유일해야 한다.
3. 변해선 안 된다
테이블의 기본 키를 선택하는 전략은 크게 2가지가 있다
자연 키(natural key)
비즈니스에 의미가 있는 키
예: 주민등록번호, 이메일, 전화번호
대리 키(surrogate key)
비즈니스와 관련 없는 임의로 만들어진 키, 대체 키로도 불린다.
예: 오라클 시퀀스, auto_increment, identity, 키생성 테이블 사용
자연 키보다는 대리 키를 권장한다
자연 키와 대리 키는 일장 일단이 있지만 될 수 있으면 대리 키의 사용을 권장한다.
예를 들어 자연 키인 전화번호를 기본 키로 선택한다면 그 번호가 유일할 수는 있지만,
전화번호가 없을 수도 있고 전화번호가 변경될 수도 있다.
따라서 기본 키로 적당하지 않다. 문제는 주민등록번 호처럼 그럴듯하게 보이는 값이다.
이 값은 null 이 아니고 유일하며 변하지 않는다는 3가지 조건을 모두 만족하는 것 같다.
하지만 현실과 비즈니스 규칙은 생각보다 쉽게 변한다. 주민등록번호 조차도 여러 가지 이유로 변경될 수 있다
비즈니스 환경은 언젠가 변한다
주민등록번호를 기본키로 사용하고 있었는데 정부 정책이 변경되면서 법적으로 주민등록번호를 저장할 수 없게 되면?
테이블은 물론이고 수많은 애플리케이션 로직을 수정 해야한다.
만약 데이터베이스를 처음 설계할 때부터 자연 키인 주민등록번호 대신에
비즈니스와 관련 없는 대리 키를 사용했다면 수정할 부분이 많지는 않았을 것이다.
기본 키의 조건을 현재는 물론이고 미래까지 충족하는 자연 키를 찾기는 쉽지 않다.
대리 키는 비즈니스와 무관한 임의의 값이므로 요구사항이 변경되어도 기본 키가 변경되는 일은 드물다.
대리 키를 기본 키로 사용하되 주민등록번호나 이메일처럼 자연 키의 후보가 되는 컬럼들은
필요에 따라 유니크 인덱스를 설정해서 사용하는 것을 권장한다.
참고로 JPA는 모든 엔티티에 일관된 방식으로 대리 키 사용을 권장한다
비즈니스 요구사항은 계속해서 변하는데 테이블은 한 번 정의하면 변경하기 어렵다.
그런면에서 외부 풍파에 쉽게 흔들리지 않는 대리 키가 일반적으로 좋은 선택이라 생각한다.
'인프런 > 스프링 DB 2편' 카테고리의 다른 글
6) 데이터 접근기술 - JPA (0) | 2024.02.08 |
---|---|
5)데이터 접근 기술 - MyBatis (0) | 2023.03.08 |
4) 데이터 접근 기술 - 테스트 (0) | 2023.03.07 |
3) 데이터 접근 기술 - 스프링 JdbcTemplate (2) (0) | 2023.03.07 |
2) 데이터 접근 기술 - 스프링 JdbcTemplate (1) (0) | 2023.03.06 |
댓글