INFLEARN

[스프링 DB 2편 - 데이터 접근 활용 기술] 1. 데이터 접근 기술 - 시작

ch010104 2026. 7. 12. 23:53

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 프로젝트를 사용한다.

  1. 폴더 이름을 itemservice-db로 변경한다.
  2. IntelliJ에서 build.gradle을 선택해 프로젝트로 연다.
  3. 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. 권장하는 식별자 선택 전략

데이터베이스 기본 키는 다음 조건을 만족해야 한다.

  1. null 값을 허용하지 않는다.
  2. 유일해야 한다.
  3. 변하면 안 된다.
구분 의미 예시
자연 키(natural key) 비즈니스 의미가 있는 키 주민등록번호, 이메일, 전화번호
대리 키(surrogate key) 비즈니스와 무관하게 임의 생성한 키 오라클 시퀀스, auto_increment, identity, 키 생성 테이블

자연 키보다 대리 키를 권장하는 이유

자연 키는 현재는 유일하고 변하지 않아 보여도 비즈니스 규칙·법·정책이 바뀌면 변경될 수 있다. 자연 키가 기본 키이고 많은 테이블이 이를 외래 키로 참조하는 상황에서 값이 바뀌면 데이터베이스 스키마와 애플리케이션 로직을 대규모로 수정해야 한다.

따라서 일반적으로는 비즈니스와 무관한 대리 키를 기본 키로 사용하고, 이메일·전화번호처럼 자연 키 후보인 컬럼에는 필요에 따라 유니크 인덱스를 설정하는 방식을 권장한다. JPA도 모든 엔티티에 일관된 대리 키 사용을 권장한다.

9. 최종 요약 정리

주제 핵심 내용
데이터 접근 기술 SQL Mapper(JdbcTemplate, MyBatis)와 ORM(JPA/Hibernate, 스프링 데이터 JPA, Querydsl)으로 구분할 수 있다.
프로젝트 구조 리포지토리 인터페이스를 통해 메모리 구현체를 이후 DB 구현체로 교체한다.
수동 빈 등록 교체 대상인 서비스·리포지토리는 수동 등록하고, 컨트롤러는 컴포넌트 스캔한다.
초기 데이터 ApplicationReadyEvent 이후 @EventListener로 안전하게 넣는다.
프로필 local은 실행 확인용 초기 데이터를 넣고, test는 테스트 오염을 막는다.
테스트 구현체 대신 인터페이스를 대상으로 테스트하면 구현체 교체 시에도 재사용할 수 있다.
기본 키 변경 가능성이 있는 자연 키보다 대리 키를 권장한다.