1. 데이터 접근 기술 진행 방식 소개
이번 강의에서는 메모리 저장소로 동작하던 상품 관리 프로젝트에 데이터 접근 기술을 하나씩 적용한다. 개별 기술의 모든 세부 기능보다 왜 필요한지, 어떤 장단점이 있는지, 어떻게 점진적으로 교체하는지를 이해하는 것이 목표다.
적용 데이터 접근 기술
| 분류 | 기술 |
| SQL Mapper | JdbcTemplate, MyBatis |
| ORM 관련 기술 | JPA, Hibernate, 스프링 데이터 JPA, Querydsl |
SQL Mapper와 ORM
- SQL Mapper: 개발자가 SQL을 작성하면 SQL 결과를 객체로 매핑한다. JDBC 직접 사용 시의 중복 코드를 제거하고 편의 기능을 제공한다.
- ORM: 기본 SQL을 ORM이 대신 작성·처리한다. 개발자는 자바 컬렉션처럼 객체를 저장·조회하고, ORM이 데이터베이스와 객체 사이의 변환을 처리한다.
- JPA는 자바 ORM 표준이고, Hibernate는 대표적인 JPA 구현체다.
- 스프링 데이터 JPA와 Querydsl은 JPA를 더 편리하게 사용하도록 돕는다.
🎯이번 강의의 목표는 데이터 접근 기술의 큰 그림을 만들고, 각 기술의 핵심 기능과 장단점을 점진적인 교체 과정에서 이해하는 것이다.
2. 프로젝트 설정과 메모리 저장소
기존 MVC 1편의 상품 관리 프로젝트를 기반으로 itemservice-db-start 프로젝트를 사용한다.
- 폴더 이름을 itemservice-db로 변경한다.
- IntelliJ에서 build.gradle을 선택해 프로젝트로 연다.
- ItemServiceApplication.main()을 실행하고 http://localhost:8080에서 기능을 확인한다.
이 프로젝트는 데이터 접근 기술에 초점을 맞추기 위해 검증을 포함한 일부 기능이 빠져 있다.
스프링 부트 3.0 주의 사항
- Java 17 이상을 사용해야 한다.
- javax 패키지는 jakarta로 변경해야 한다.
- H2는 2.1.214 이상을 사용해야 한다.
예시:
| 이전 | 변경 |
| javax.persistence.Entity | jakarta.persistence.Entity |
| javax.annotation.PostConstruct | jakarta.annotation.PostConstruct |
| javax.validation | jakarta.validation |
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'
ext["hibernate.version"] = "5.6.5.Final"
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()
}
- spring-boot-starter-thymeleaf: 타임리프 사용
- spring-boot-starter-web: 스프링 웹·MVC 기능 사용
- spring-boot-starter-test: 스프링 테스트 기능
- lombok: 테스트에서도 Lombok을 사용하도록 별도 설정한다.
3. 프로젝트 구조 설명 1 - 기본
도메인: 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;
}
}
Item은 상품을 나타내는 객체이며 이름, 가격, 수량을 속성으로 가진다.
리포지토리 인터페이스: 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 검색), 이 프로젝트에서는 검색 조건 객체에 Cond 접미사를 사용한다.
수정 DTO: 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(Data Transfer Object)는 데이터 전달이 주목적인 객체를 뜻한다. 객체에 기능이 있어도 주목적이 데이터 전달이라면 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();
}
}
- 메모리 기반 저장소이므로 애플리케이션을 재실행하면 데이터가 사라진다.
- findById()는 Optional을 반환해야 하므로 Optional.ofNullable()을 사용한다.
- findAll()은 ItemSearchCond로 데이터를 필터링한다. itemName이 비었거나 maxPrice가 null이면 해당 조건을 무시한다.
- clearStore()는 테스트 전용 초기화 기능이다.
4. 서비스와 컨트롤러
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);
}
}
ItemServiceV1은 대부분의 기능을 리포지토리에 위임한다.
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 컨트롤러이며 화면 리소스(css, html, templates)의 상세 내용은 MVC 1편을 참고한다.
5. 프로젝트 구조 설명 2 - 설정
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();
}
}
ItemServiceV1과 MemoryItemRepository를 수동으로 빈 등록하고 생성자로 의존관계를 주입한다. 구현체 교체가 필요한 서비스·리포지토리는 수동 등록하고, 컨트롤러는 컴포넌트 스캔을 사용한다.
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)는 스프링 컨테이너와 AOP를 포함한 초기화가 끝난 뒤 호출된다. 따라서 초기 데이터를 추가할 때 @PostConstruct보다 안전한 시점이다. @PostConstruct는 AOP 적용 전 호출될 수 있어 @Transactional 같은 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(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"): local 프로필에서만 초기 데이터를 등록한다.
프로필 설정
/src/main/resources/application.properties
spring.profiles.active=local
/src/main의 자바 객체를 실행할 때 적용된다. local 프로필이면 @Profile("local")이 동작하여 testDataInit 빈이 등록된다.
The following 1 profile is active: "local"
프로필을 지정하지 않으면 default 프로필이 실행된다.
No active profile set, falling back to 1 default profile: "default"
/src/test/resources/application.properties
spring.profiles.active=test
/src/test의 테스트 실행 시 적용된다. test 프로필에서는 @Profile("local")의 조건이 맞지 않아 초기 데이터 빈이 등록되지 않는다. 테스트마다 예상치 못한 초기 데이터가 들어가는 문제를 막는다.
The following 1 profile is active: "test"
6. 프로젝트 구조 설명 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는 각 테스트가 끝난 뒤 실행된다.
- 현재 메모리 구현체에는 clearStore()가 인터페이스에 없으므로, MemoryItemRepository일 때만 다운캐스팅하여 초기화한다.
- 실제 DB를 사용하면 테스트 이후 트랜잭션 롤백으로 데이터를 초기화할 수 있다.
- 구현체가 아닌 ItemRepository 인터페이스를 대상으로 테스트하면, 이후 다른 구현체에도 동일한 테스트를 적용할 수 있다.
7. 데이터베이스 테이블 생성
이후 데이터 접근 기술에서는 메모리가 아닌 H2 데이터베이스에 데이터를 보관한다.
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와 유사하다. 개발자가 id를 비워서 저장하면 데이터베이스가 증가하는 값을 생성한다.
등록·조회 SQL
insert into item(item_name, price, quantity) values ('ItemTest', 10000, 10)
select * from item;
8. 권장하는 식별자 선택 전략
데이터베이스 기본 키는 다음 조건을 만족해야 한다.
- null 값을 허용하지 않는다.
- 유일해야 한다.
- 변하면 안 된다.
| 구분 | 의미 | 예시 |
| 자연 키(natural key) | 비즈니스 의미가 있는 키 | 주민등록번호, 이메일, 전화번호 |
| 대리 키(surrogate key) | 비즈니스와 무관하게 임의 생성한 키 | 오라클 시퀀스, auto_increment, identity, 키 생성 테이블 |
자연 키보다 대리 키를 권장하는 이유
자연 키는 현재는 유일하고 변하지 않아 보여도 비즈니스 규칙·법·정책이 바뀌면 변경될 수 있다. 자연 키가 기본 키이고 많은 테이블이 이를 외래 키로 참조하는 상황에서 값이 바뀌면 데이터베이스 스키마와 애플리케이션 로직을 대규모로 수정해야 한다.
따라서 일반적으로는 비즈니스와 무관한 대리 키를 기본 키로 사용하고, 이메일·전화번호처럼 자연 키 후보인 컬럼에는 필요에 따라 유니크 인덱스를 설정하는 방식을 권장한다. JPA도 모든 엔티티에 일관된 대리 키 사용을 권장한다.
9. 최종 요약 정리
| 주제 | 핵심 내용 |
| 데이터 접근 기술 | SQL Mapper(JdbcTemplate, MyBatis)와 ORM(JPA/Hibernate, 스프링 데이터 JPA, Querydsl)으로 구분할 수 있다. |
| 프로젝트 구조 | 리포지토리 인터페이스를 통해 메모리 구현체를 이후 DB 구현체로 교체한다. |
| 수동 빈 등록 | 교체 대상인 서비스·리포지토리는 수동 등록하고, 컨트롤러는 컴포넌트 스캔한다. |
| 초기 데이터 | ApplicationReadyEvent 이후 @EventListener로 안전하게 넣는다. |
| 프로필 | local은 실행 확인용 초기 데이터를 넣고, test는 테스트 오염을 막는다. |
| 테스트 | 구현체 대신 인터페이스를 대상으로 테스트하면 구현체 교체 시에도 재사용할 수 있다. |
| 기본 키 | 변경 가능성이 있는 자연 키보다 대리 키를 권장한다. |
'INFLEARN' 카테고리의 다른 글
| [스프링 DB 2편 - 데이터 접근 활용 기술] 3. 데이터 접근 기술 - 테스트 (0) | 2026.07.17 |
|---|---|
| [스프링 DB 2편 - 데이터 접근 활용 기술] 2. 데이터 접근 기술 - 스프링 JdbcTemplate (0) | 2026.07.13 |
| [스프링 DB 1편 - 데이터 접근 핵심 원리] 6. 스프링과 문제 해결 - 예외 처리, 반복 (0) | 2026.07.10 |
| [스프링 DB 1편 - 데이터 접근 핵심 원리] 5. 자바 예외 이해 (0) | 2026.07.10 |
| [스프링 DB 1편 - 데이터 접근 핵심 원리] 4. 스프링과 문제 해결 - 트랜잭션 (0) | 2026.07.09 |