# 데이터 접근 기술 - 시작
# 데이터 접근 기술 분류
실무에서 쓰는 데이터 접근 기술은 크게 두 갈래로 나뉜다.
- SQL Mapper:
JdbcTemplate,MyBatis- 개발자가 SQL을 직접 작성하면, 그 결과를 객체로 매핑해준다. JDBC 직접 사용 시의 반복을 제거해준다.
- ORM:
JPA / Hibernate,스프링 데이터 JPA,Querydsl- 기본적인 SQL은 JPA가 대신 작성·처리한다. 객체를 자바 컬렉션에 저장·조회하듯 다루면 ORM이 DB에 반영한다.
- JPA는 자바 ORM 표준(인터페이스), Hibernate는 가장 많이 쓰는 구현체다.
- 스프링 데이터 JPA, Querydsl은 JPA를 더 편리하게 쓰게 돕는 프로젝트로, 실무에서 JPA를 쓴다면 사실상 필수다.
JDBC와 ORM(JPA)의 관계
둘 다 자바 표준 인터페이스지만, 경쟁 관계가 아니라 추상화 수준이 다른 위아래 레이어다.
- JDBC: DB와 통신하는 저수준 표준. SQL을 직접 작성하고 결과를 객체에 수동 매핑한다. 구현체는 각 DB 드라이버(H2, MySQL 등).
- JPA: ORM을 위한 고수준 표준. 기본 SQL을 자동 생성해주고, 객체를 컬렉션 다루듯 저장·조회한다. 구현체는 Hibernate 등.
핵심은 JPA도 내부적으로는 JDBC를 사용한다는 점이다. JPA가 SQL을 만들어 JDBC 드라이버를 통해 DB로 보낸다. JdbcTemplate·MyBatis는 JDBC 바로 위에 얹은 편의 도구(SQL은 직접 작성)이고, JPA는 한 층 더 위에서 SQL 작성 자체를 대신한다.
내 코드 → JPA/Hibernate → JDBC → DB 드라이버 → DB
이번 장의 목표는 세세한 기능이 아니라 각 기술이 왜 필요한지, 장단점이 무엇인지 파악하는 것이다. 메모리 기반으로 완성된 프로젝트에 데이터 접근 기술을 하나씩 점진적으로 도입하며 비교한다.
# 인터페이스 기반 구조
구현 기술을 손쉽게 교체하기 위해 리포지토리를 인터페이스로 추상화한다.
public interface ItemRepository {
Item save(Item item);
void update(Long itemId, ItemUpdateDto updateParam);
Optional<Item> findById(Long id);
List<Item> findAll(ItemSearchCond cond);
}
findById는 값이 없을 수 있으므로Optional을 반환한다.- 검색 조건 객체는
Cond(condition) 접미사로 통일했다(ItemSearchCond).like검색을 위해 상품명 일부만 포함해도 검색되어야 한다. - 단순 데이터 전달용 객체는
Dto접미사를 붙인다(ItemUpdateDto).
네이밍 규칙
DTO·Cond 같은 접미사 규칙은 정해진 표준이 없다. 프로젝트 안에서 일관성 있게 정하면 된다. ItemSearchCond처럼 이미 용도가 드러나면 굳이 Dto를 중복으로 붙이지 않는다.
서비스도 같은 이유(예제에서 구현체 교체)로 인터페이스를 도입했지만, 실무에서 서비스는 구현체를 바꿀 일이 많지 않아 인터페이스를 잘 도입하지 않는다.
# 설정과 빈 등록 전략
서비스·리포지토리는 구현체를 편리하게 교체하기 위해 @Configuration에서 수동으로 빈 등록하고, 컨트롤러만 컴포넌트 스캔을 사용한다.
@Configuration
public class MemoryConfig {
@Bean
public ItemService itemService() {
return new ItemServiceV1(itemRepository());
}
@Bean
public ItemRepository itemRepository() {
return new MemoryItemRepository();
}
}
@Import(MemoryConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication { ... }
@Import로 설정 파일을 지정하고,scanBasePackages로 컴포넌트 스캔 범위를web하위(컨트롤러)로 제한한다.
# 초기 데이터 등록과 @EventListener
확인용 초기 데이터는 @EventListener(ApplicationReadyEvent.class)로 등록한다.
@Slf4j
@RequiredArgsConstructor
public class TestDataInit {
private final ItemRepository itemRepository;
@EventListener(ApplicationReadyEvent.class)
public void initData() {
itemRepository.save(new Item("itemA", 10000, 10));
itemRepository.save(new Item("itemB", 20000, 20));
}
}
@PostConstruct 대신 ApplicationReadyEvent
@PostConstruct는 AOP가 아직 다 적용되지 않은 시점에 호출될 수 있어, @Transactional 같은 AOP가 적용되지 않은 상태로 실행되는 문제가 생길 수 있다.
@EventListener(ApplicationReadyEvent.class)는 AOP를 포함해 스프링 컨테이너가 완전히 초기화된 뒤 호출되므로 안전하다.
# 프로필로 환경 분리
환경(로컬/운영/테스트)별로 다른 설정·빈을 적용할 때 프로필을 쓴다. 스프링은 application.properties의 spring.profiles.active를 읽는다.
src/main/resources→spring.profiles.active=local(앱 직접 실행 시)src/test/resources→spring.profiles.active=test(테스트 실행 시)@Profile("local")이 붙은 빈은 해당 프로필일 때만 등록된다.
@Bean
@Profile("local")
public TestDataInit testDataInit(ItemRepository itemRepository) {
return new TestDataInit(itemRepository);
}
프로필로 테스트 오염 방지
초기 데이터(TestDataInit)는 local에서만 등록된다. 테스트는 test 프로필로 실행되므로 초기 데이터가 들어가지 않는다.
이게 없으면 "1건 저장 후 카운트 검증"이 초기 데이터 2건 때문에 3이 되어 테스트가 깨진다.
# 테스트 작성 원칙
@SpringBootTest
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@AfterEach
void afterEach() {
if (itemRepository instanceof MemoryItemRepository) {
((MemoryItemRepository) itemRepository).clearStore();
}
}
}
- 테스트는 서로 영향을 주면 안 되므로
@AfterEach로 데이터를 초기화한다. 메모리 저장소는 인터페이스에clearStore()가 없어 다운캐스팅으로 처리하지만, 실제 DB는 테스트 후 트랜잭션 롤백으로 초기화한다. - 구현체(
MemoryItemRepository)가 아니라 인터페이스(ItemRepository)를 대상으로 테스트한다. 그러면 구현체가 바뀌어도 같은 테스트로 검증할 수 있다. findAll검색은null뿐 아니라 빈 문자열("") 조건도 잘 동작하는지 검증한다.
# DB 테이블 생성과 식별자 전략
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: 기본 키 생성을 DB에 위임하는 identity 전략. MySQL의 Auto Increment와 같다. PK는 비워두고 저장하면 DB가 증가값을 채운다.
기본 키는 (1) null 불가, (2) 유일, (3) 불변 세 조건을 모두 만족해야 한다. 키 선택 전략은 두 가지다.
- 자연 키(natural key): 비즈니스 의미가 있는 키 (주민번호, 이메일, 전화번호)
- 대리 키(surrogate key): 비즈니스와 무관한 임의 키 (identity, auto_increment, 시퀀스)
대리 키를 권장
자연 키는 그럴듯해 보여도 비즈니스 규칙 변화에 취약하다. 주민번호를 PK로 잡으면, 정책 변경으로 저장이 금지될 경우 이를 FK로 참조하던 수많은 테이블과 애플리케이션 로직을 전부 고쳐야 한다.
대리 키는 비즈니스와 무관해 요구사항이 변해도 PK가 바뀔 일이 드물다. 대리 키를 PK로 쓰되, 자연 키 후보(이메일·주민번호 등)는 유니크 인덱스로 관리하는 것을 권장한다. JPA도 모든 엔티티에 일관된 대리 키 사용을 권장한다.
# 스프링 JdbcTemplate
# JdbcTemplate 개요
SQL을 직접 작성하는 경우 가장 간단하고 실용적인 선택지다. 템플릿 콜백 패턴으로 JDBC의 반복 작업(커넥션 획득/종료, statement 준비·실행, ResultSet 루프, 트랜잭션 커넥션 동기화, 예외 변환)을 대신 처리해준다. 개발자는 SQL 작성, 파라미터 정의, 결과 매핑만 하면 된다.
- 장점:
spring-jdbc에 포함되어 별도 설정 없이 바로 사용. 반복 코드 제거. - 단점: 동적 쿼리 작성이 어렵다. SQL을 자바 문자열로 작성해 줄이 넘어갈 때마다 문자열 더하기를 해야 한다.
설정은 spring-boot-starter-jdbc와 DB 드라이버(com.h2database:h2)만 추가하면 된다. application.properties에 datasource.url만 설정하면 스프링 부트가 커넥션 풀·DataSource·트랜잭션 매니저를 자동 등록한다.
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
# V1 — 기본 JdbcTemplate (순서 기반 바인딩)
DataSource를 주입받아 생성자 내부에서 JdbcTemplate을 생성하는 것이 관례다.
public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
주요 메서드는 다음과 같다.
template.update(sql, 파라미터...): INSERT/UPDATE/DELETE. 반환값은 영향받은 로우 수(int).?에 순서대로 바인딩.template.queryForObject(sql, rowMapper, args): 단건 조회. 결과 없으면EmptyResultDataAccessException, 둘 이상이면IncorrectResultSizeDataAccessException.template.query(sql, rowMapper, args): 목록 조회. 결과 없으면 빈 컬렉션.
findById는 인터페이스가 Optional 반환을 요구하므로, 예외를 잡아 Optional.empty()로 변환한다.
public Optional<Item> findById(Long id) {
String sql = "select id, item_name, price, quantity from item where id = ?";
try {
Item item = template.queryForObject(sql, itemRowMapper(), id);
return Optional.of(item);
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
identity(auto increment) PK는 INSERT가 완료돼야 값이 생기므로, KeyHolder로 생성된 키를 조회한다. KeyHolder는 DB가 자동 생성한 PK 값을 받아오기 위한 객체이다.
KeyHolder keyHolder = new GeneratedKeyHolder();
template.update(connection -> {
PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"});
ps.setString(1, item.getItemName());
// ...
return ps;
}, keyHolder);
long key = keyHolder.getKey().longValue();
RowMapper는 ResultSet을 객체로 변환한다. V1은 수동으로 작성한다.
private RowMapper<Item> itemRowMapper() {
return (rs, rowNum) -> {
Item item = new Item();
item.setId(rs.getLong("id"));
item.setItemName(rs.getString("item_name"));
// ...
return item;
};
}
RowMapper 동작
JdbcTemplate이 while(ResultSet 끝까지) 루프를 돌려주고, 개발자는 한 행을 객체로 바꾸는 RowMapper 내부만 채운다.
# V2 — NamedParameterJdbcTemplate (이름 기반 바인딩, 권장)
V1 대비 핵심 변화는 ? 순서 바인딩 → :이름 이름 바인딩이다.
순서 바인딩의 위험
순서 바인딩은 SQL 컬럼 순서와 파라미터 전달 순서가 어긋나면 엉뚱한 값이 들어간다. 예를 들어 SQL에서 price와 quantity 순서만 바꿨는데 파라미터는 그대로면, 두 값이 뒤바뀌어 저장된다.
파라미터가 10~20개를 넘거나 나중에 필드를 추가·수정할 때 충분히 터질 수 있다. DB에 데이터가 잘못 들어가는 버그는 코드뿐 아니라 데이터까지 복구해야 해 가장 비싸다. 그래서 이름 기반 바인딩(NamedParameterJdbcTemplate)이 권장된다.
public JdbcTemplateItemRepositoryV2(DataSource dataSource) {
this.template = new NamedParameterJdbcTemplate(dataSource);
}
// SQL이 ? 대신 :이름
String sql = "insert into item (item_name, price, quantity) " +
"values (:itemName, :price, :quantity)";
이름 파라미터에 값을 넘기는 방식은 3가지다.
Map: 가장 단순.Map.of("id", id).MapSqlParameterSource: Map과 유사하나 SQL 타입 지정 등 특화 기능 제공. 메서드 체인 사용.BeanPropertySqlParameterSource: 자바빈 프로퍼티 규약으로 객체에서 파라미터를 자동 생성(getItemName()→itemName).
// BeanProperty — 객체 프로퍼티를 자동 매핑
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
// MapSqlParameterSource — 체인으로 직접 지정
SqlParameterSource param = new MapSqlParameterSource()
.addValue("itemName", updateParam.getItemName())
.addValue("price", updateParam.getPrice())
.addValue("id", itemId); // DTO에 없는 값을 별도로 추가
BeanPropertySqlParameterSource를 항상 쓸 수는 없다
update()는 SQL에 :id가 필요한데 ItemUpdateDto에는 itemId만 있고 id가 없다. 객체에 대응 프로퍼티가 없으면 자동 매핑이 불가하므로, 이런 경우 MapSqlParameterSource로 직접 값을 넣는다.
- SQL 쿼리(파라미터명 :id 포함)가 먼저 존재한다
- 런타임 객체로 바인딩할 때 객체 속성명 ↔
:파라미터명을 비교한다 - 조회 쿼리 시 AS를 사용하여 별칭을 지정해두면 최종 비교 대상은 별칭이 된다. 컬럼명은 비교 대상이 아니다.
RowMapper도 V1의 수동 작성에서 BeanPropertyRowMapper로 바뀐다. 조회 결과 컬럼명 기반으로 setter를 자동 호출한다(리플렉션 사용).
private RowMapper<Item> itemRowMapper() {
return BeanPropertyRowMapper.newInstance(Item.class);
}
snake_case ↔ camelCase 자동 변환과 별칭
BeanPropertyRowMapper는 DB의 snake_case를 객체의 camelCase로 자동 변환한다(item_name → setItemName()). 따라서 snake_case는 그냥 두면 된다.
다만 컬럼명과 객체 이름이 완전히 다르면(member_name ↔ username) 조회 SQL에서 별칭을 쓴다: select member_name as username. 실무에서 자주 쓰는 해결법이다.
# V3 — SimpleJdbcInsert (INSERT SQL 자동 생성)
V2 대비 INSERT만 SimpleJdbcInsert로 대체된다(update/findById/findAll은 V2와 동일). INSERT SQL을 직접 작성하지 않아도 된다.
public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
this.template = new NamedParameterJdbcTemplate(dataSource);
this.jdbcInsert = new SimpleJdbcInsert(dataSource)
.withTableName("item")
.usingGeneratedKeyColumns("id");
}
public Item save(Item item) {
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
Number key = jdbcInsert.executeAndReturnKey(param); // 생성 키도 바로 반환
item.setId(key.longValue());
return item;
}
withTableName: 저장할 테이블명.usingGeneratedKeyColumns: PK 컬럼명.usingColumns: INSERT에 쓸 컬럼 지정(생략 가능).SimpleJdbcInsert는 생성 시점에 테이블 메타데이터를 조회해 컬럼을 파악하므로 보통 생략한다.executeAndReturnKey로 INSERT 실행과 생성 키 조회를 한 번에 처리해, V1의KeyHolder보일러플레이트가 사라진다.
# 동적 쿼리 문제
findAll은 검색 조건(itemName, maxPrice)의 유무에 따라 4가지 SQL을 만들어야 한다. 조건 조합에 따라 where/and를 직접 계산하고 파라미터도 맞춰 생성해야 한다.
String sql = "select id, item_name, price, quantity from item";
if (StringUtils.hasText(itemName) || maxPrice != null) {
sql += " where";
}
boolean andFlag = false;
if (StringUtils.hasText(itemName)) {
sql += " item_name like concat('%',:itemName,'%')";
andFlag = true;
}
if (maxPrice != null) {
if (andFlag) sql += " and";
sql += " price <= :maxPrice";
}
조건이 늘수록 분기가 급격히 복잡해진다. 이 동적 쿼리 문제를 깔끔하게 푸는 것이 다음에 배울 MyBatis다.
# 기능 정리
JdbcTemplate: 순서 기반 바인딩NamedParameterJdbcTemplate: 이름 기반 바인딩 (권장)SimpleJdbcInsert: INSERT 편의 기능SimpleJdbcCall: 스토어드 프로시저 호출
조회는 단건이 queryForObject()(숫자·문자는 Integer.class 등 타입 지정, 객체는 RowMapper), 목록이 query()다. 변경은 모두 update(), 임의 DDL은 execute()를 쓴다. RowMapper를 필드로 분리하면 여러 곳에서 재사용할 수 있다.
JdbcTemplate의 위치
JPA 같은 ORM을 쓰면서도 SQL을 직접 써야 할 때 JdbcTemplate을 함께 쓰면 좋다. 다만 동적 쿼리에 약하고 SQL이 자바 문자열이라는 한계가 있어, 그 부분은 MyBatis가 보완한다.
# 데이터 접근 기술 - 테스트
# 테스트 환경 설정 (DB 연동)
테스트 코드는 src/test 하위에 있으므로 실행 시 src/test/resources/application.properties가 우선순위를 가진다. 따라서 테스트에서도 DB에 접속하려면 해당 파일에 datasource 설정을 넣어야 한다.
# src/test/resources/application.properties
spring.profiles.active=test
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
logging.level.org.springframework.jdbc=debug
@SpringBootTest는 @SpringBootApplication을 찾아 설정으로 사용한다. 따라서 메인 설정(Config)이 바뀌면 테스트도 같은 구현체를 사용하게 된다.
TestDataInit은 테스트에서 동작하지 않음
TestDataInit은 프로필이 local일 때만 동작한다. 테스트는 spring.profiles.active=test이므로 초기화 데이터가 추가되지 않는다.
# 테스트 DB 분리
로컬 애플리케이션 서버와 테스트가 같은 DB를 쓰면 기존에 저장된 데이터가 테스트에 영향을 준다. 핵심 해결 원칙은 테스트 전용 DB를 분리하는 것이다.
# local 전용
jdbc:h2:tcp://localhost/~/test
# test 케이스 전용
jdbc:h2:tcp://localhost/~/testcase
main 설정은 건드리지 않는다
main의 application.properties는 그대로 두고, test의 application.properties만 testcase로 변경해야 한다. testcase DB에도 동일한 item 테이블을 미리 생성해두어야 한다.
# 테스트 격리의 두 가지 원칙
DB를 분리해도 테스트를 반복 실행하면 이전 테스트가 저장한 데이터가 남아 다음 테스트를 오염시킨다.
테스트 핵심 원칙
- 테스트는 다른 테스트와 격리해야 한다
- 테스트는 반복 실행 가능해야 한다
DELETE SQL로 직접 지우는 방법은 근본 해결책이 아니다. 테스트 도중 예외가 발생하거나 애플리케이션이 종료되면 삭제 로직이 호출되지 않아 데이터가 남는다.
# 트랜잭션 롤백 전략
테스트가 끝난 뒤 트랜잭션을 강제 롤백하면 데이터가 깔끔하게 제거된다. 중간에 테스트가 실패해도 커밋하지 않았으므로 DB에 반영되지 않아 안전하다.
1. 트랜잭션 시작
2. 테스트 A 실행
3. 트랜잭션 롤백
@BeforeEach/@AfterEach로 트랜잭션을 직접 제어할 수 있다.
@SpringBootTest
class ItemRepositoryTest {
@Autowired ItemRepository itemRepository;
@Autowired PlatformTransactionManager transactionManager;
TransactionStatus status;
@BeforeEach
void beforeEach() {
status = transactionManager.getTransaction(new DefaultTransactionDefinition());
}
@AfterEach
void afterEach() {
transactionManager.rollback(status);
}
}
트랜잭션 매니저 자동 등록
스프링 부트가 적절한 PlatformTransactionManager를 빈으로 자동 등록해주므로 주입만 받으면 된다.
# @Transactional (실무 표준 방식)
위의 트랜잭션 제어 코드를 @Transactional 애노테이션 하나로 대체할 수 있다. 이것이 실무에서 사용하는 방식이다.
import org.springframework.transaction.annotation.Transactional;
@Transactional
@SpringBootTest
class ItemRepositoryTest {}
테스트에서의 특별 동작
@Transactional은 일반 로직에서는 성공 시 커밋하지만, 테스트에 붙으면 테스트를 트랜잭션 안에서 실행한 뒤 끝나면 자동 롤백한다.
동작 순서:
- 테스트 시작 시 트랜잭션 시작
- 모든 로직이 같은 트랜잭션 안에서 수행 (리포지토리의 JdbcTemplate도 트랜잭션 전파로 동일 트랜잭션 사용)
- INSERT로 데이터 저장
- SELECT로 조회·검증 (같은 트랜잭션이므로 조회 가능, 다른 트랜잭션에서는 안 보임)
- 테스트 종료 시 강제 롤백
- 저장했던 데이터 제거
서비스·리포지토리에 있는 @Transactional도 테스트에서 시작한 트랜잭션에 참여한다. 즉 같은 트랜잭션 = 같은 커넥션을 사용한다.
클래스 레벨에 @Transactional 애노테이션을 추가해도 메서드 단위로 롤백이 수행된다.
# 강제 커밋 - @Commit
DB에 데이터가 잘 들어갔는지 직접 확인하고 싶을 때는 @Commit(또는 @Rollback(value = false))을 붙이면 롤백 대신 커밋된다.
import org.springframework.test.annotation.Commit;
@Commit
@Transactional
@SpringBootTest
class ItemRepositoryTest {}
# 임베디드 모드 DB
H2는 JVM 메모리 안에서 동작하는 임베디드 모드를 지원한다. 애플리케이션과 함께 실행되고, 종료되면 DB와 데이터도 함께 사라진다. 테스트 검증용으로 별도 DB를 설치·운영할 필요가 없다.
직접 설정하는 경우 핵심 URL은 다음과 같다.
@Bean
@Profile("test")
public DataSource dataSource() {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
URL 핵심
jdbc:h2:mem:db→ 메모리(임베디드) 모드로 동작DB_CLOSE_DELAY=-1→ 커넥션이 모두 끊겨도 DB가 종료되지 않도록 방지
# 임베디드 DB 테이블 생성 (schema.sql)
메모리 DB는 매번 새로 만들어지므로 테이블이 없어 Table "ITEM" not found 오류가 난다. 스프링 부트의 SQL 스크립트 초기화 기능으로 해결한다.
파일 위치와 이름 주의
src/test/resources/schema.sql 위치와 파일명이 정확해야 동작한다.
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)
);
# 스프링 부트 임베디드 모드 (설정 생략)
스프링 부트는 DB 설정이 전혀 없으면 자동으로 임베디드 DB를 사용한다. 직접 만든 dataSource 빈과 test의 datasource 설정을 모두 주석 처리하면 된다.
# src/test/resources/application.properties
spring.profiles.active=test
#spring.datasource.url=jdbc:h2:tcp://localhost/~/testcase
#spring.datasource.username=sa
logging.level.org.springframework.jdbc=debug
임베디드 DB 이름 고정
기본적으로 jdbc:h2:mem: 뒤에 충돌 방지용 임의 이름이 붙는다. 고정하려면 아래 설정을 추가한다.
spring.datasource.generate-unique-name=false
스프링 부트 3.x 트랜잭션 로깅
트랜잭션 시작/롤백 로그가 안 보이면 로깅 레벨이 INFO에서 TRACE로 변경된 것이다. 다음 설정을 추가한다.
logging.level.org.springframework.test.context.transaction=trace
# 데이터 접근 기술 - MyBatis
# MyBatis란
MyBatis는 JdbcTemplate보다 더 많은 기능을 제공하는 SQL Mapper다. JdbcTemplate의 기능 대부분을 포함하면서, 두 가지가 핵심 강점이다.
- SQL을 XML에 작성 → 여러 줄 SQL에서 자바 문자열 더하기(
+)의 불편함이 사라짐 - 동적 쿼리를
<if>,<where>같은 태그로 매우 편리하게 작성
선택 기준
동적 쿼리·복잡한 쿼리가 많으면 MyBatis, 단순한 쿼리 위주면 JdbcTemplate. 둘을 함께 써도 되지만, MyBatis를 택했다면 그것으로 충분하다.
# 설정
mybatis-spring-boot-starter 의존성을 추가한다. 스프링 부트가 버전 관리하는 공식 라이브러리가 아니라서 버전을 직접 명시해야 한다.
// 스프링 부트 3.0 이상
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
// 스프링 부트 4.0 이상
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:4.0.1'
application.properties 설정의 핵심은 두 가지다.
mybatis.type-aliases-package=hello.itemservice.domain
mybatis.configuration.map-underscore-to-camel-case=true
logging.level.hello.itemservice.repository.mybatis=trace
type-aliases-package: XMLresultType등에서 패키지명을 생략할 수 있게 함 (하위 패키지 자동 인식)map-underscore-to-camel-case:item_name(snake) →itemName(camel) 자동 변환.BeanPropertyRowMapper와 같은 역할
main / test 양쪽 모두 수정
main과 test 각 위치의 application.properties를 둘 다 수정해야 한다. 설정 반영이 안 되면 거의 이 문제다.
이름 매핑 정리
snake_case ↔ camelCase는 옵션으로 자동 해결되니 그냥 두면 되고, 컬럼명과 객체명이 완전히 다를 때만 조회 SQL에서 별칭(select item_name as name)을 쓴다. 앞서 JdbcTemplate에서 본 별칭 원리가 그대로 적용된다.
# 매퍼 인터페이스와 XML
매퍼 인터페이스에 @Mapper를 붙이고, 같은 이름의 XML에 SQL을 작성한다. 인터페이스 메서드를 호출하면 XML의 해당 SQL이 실행된다.
@Mapper
public interface ItemMapper {
void save(Item item);
void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateParam);
Optional<Item> findById(Long id);
List<Item> findAll(ItemSearchCond itemSearch);
}
<mapper namespace="hello.itemservice.repository.mybatis.ItemMapper">
<insert id="save"> ... </insert> <!-- save() 메서드와 매칭 -->
<update id="update"> ... </update> <!-- update() 메서드와 매칭 -->
<select id="findById"> ... </select> <!-- findById() 메서드와 매칭 -->
<select id="findAll"> ... </select> <!-- findAll() 메서드와 매칭 -->
</mapper>
XML은 자바 코드가 아니므로 src/main/resources 하위에 두되 패키지 경로를 인터페이스와 동일하게 맞춘다. namespace에는 매퍼 인터페이스의 전체 경로를 지정한다.
XML 위치 자유롭게 두기
경로 맞추는 게 번거로우면 mybatis.mapper-locations=classpath:mapper/**/*.xml 설정으로 위치와 파일명을 자유롭게 둘 수 있다. (이때도 test의 properties까지 함께 수정)
# CRUD XML 작성 핵심
파라미터는 #{} 문법을 쓴다. 이건 PreparedStatement의 ?로 치환된다고 보면 된다. 매퍼에서 넘긴 객체의 프로퍼티명을 적는다.
<insert id="save" useGeneratedKeys="true" keyProperty="id">
insert into item (item_name, price, quantity)
values (#{itemName}, #{price}, #{quantity})
</insert>
- 위 코드에서는
save(Item item)타입의 메서드에 대해 Item 객체의 속성들을 속성명 비교를 통해 쿼리 파라미터에 값을 넣어준다.update(Item item, int id)와 같이 파라미터가 둘 이상일 때는@Param을 통해 명시해야 하고, xml 내에서는#{item.itemName},#{id}로 접근해야 한다.
id: 매퍼 인터페이스의 메서드명과 일치시킴useGeneratedKeys+keyProperty="id": DB가 키를 생성하는 IDENTITY 전략일 때 사용. INSERT 후 생성된 키가 객체의id에 채워짐 (JdbcTemplate의 KeyHolder 역할)
파라미터가 2개 이상이면 @Param으로 이름을 지정해 구분해야 한다(1개면 생략 가능). XML에서는 #{updateParam.itemName}처럼 점 표기로 접근한다.
<select id="findById" resultType="Item">
select id, item_name, price, quantity from item where id = #{id}
</select>
resultType으로 반환 타입을 지정하면 결과를 객체로 자동 매핑한다. 반환이 하나면 Item/Optional<Item>, 여럿이면 List를 쓴다.
함수 반환 타입이 리스트이면 MyBatis가 자동으로 반환 결과를 리스트 내에 담아준다는 의미이다.
# 동적 쿼리 (MyBatis의 핵심)
JdbcTemplate은 자바 코드로 if문을 붙여가며 SQL을 조립해야 하지만, MyBatis는 태그로 처리한다.
<select id="findAll" resultType="Item">
select id, item_name, price, quantity from item
<where>
<if test="itemName != null and itemName != ''">
and item_name like concat('%',#{itemName},'%')
</if>
<if test="maxPrice != null">
and price <= #{maxPrice}
</if>
</where>
</select>
<if>: 조건이 참이면 구문 추가 (내부 문법은 OGNL)<where>: 내부에 조건이 하나라도 있으면where를 붙이고, 맨 앞의and를 자동으로 제거. 조건이 모두 실패하면where자체를 안 만듦
OGNL은 Object-Graph Navigation Language의 약자이다. 객체의 속성에 접근하고 간단한 조건식을 평가하는 표현식 언어(expression language) 이다. MyBatis의 <if test="..."> 안에 들어가는 그 문법이 OGNL이다. (itemName != null and itemName != ''))
XML 특수문자
XML에서는 <, >, &를 직접 쓸 수 없어 <, >, &로 이스케이프한다. 또는 <![CDATA[ ... ]]> 안에 넣으면 특수문자를 그대로 쓸 수 있지만, CDATA 안에서는 <if> 같은 동적 태그가 동작하지 않는다.
비교연산을 수행할 때 이스케이프 문자를 직접 사용하거나 CDATA 구문을 활용해야 한다.
이 외 동적 SQL 태그로 choose(when/otherwise, 자바 switch 유사), trim(<where>/<set>을 직접 커스텀), foreach(컬렉션 반복 → where id in (1,2,3) 생성)가 있다.
<foreach item="item" collection="list" open="ID in (" separator="," close=")">
#{item}
</foreach>
# 구현체 없이 동작하는 원리
ItemMapper는 인터페이스만 있고 구현체를 작성하지 않는데도 동작한다. MyBatis 스프링 연동 모듈이 자동 처리하기 때문이다.
- 로딩 시점에
@Mapper가 붙은 인터페이스를 조사 - 동적 프록시 기술로 구현체를 생성 (실제 로그를 찍으면
com.sun.proxy.$Proxy...로 JDK 동적 프록시 확인 가능) - 생성된 구현체를 스프링 빈으로 등록
이 매퍼 구현체는 예외 변환도 처리한다. MyBatis 예외를 스프링 예외 추상화(DataAccessException)로 변환해준다(JdbcTemplate과 동일한 이점). 커넥션·트랜잭션 동기화도 함께 연동된다.
# 기타 기능
애노테이션으로 SQL을 작성할 수도 있다. 단 동적 SQL이 안 되므로 간단한 경우에만 쓴다.
@Select("select id, item_name, price, quantity from item where id=#{id}")
Optional<Item> findById(Long id);
${} 문자열 대체 — SQL 인젝션 위험
#{}는 PreparedStatement 바인딩(안전)이지만, ${}는 문자 그대로 치환한다. 컬럼명을 동적으로 넣는 등 바인딩이 불가능한 경우에만 쓰되, SQL 인젝션 공격에 노출되므로 가급적 사용하지 않는다.
컬럼명과 프로퍼티명이 다를 때는 별칭(as) 외에 resultMap으로 매핑을 선언할 수 있다.
<resultMap id="userResultMap" type="User">
<id property="id" column="user_id" />
<result property="username" column="user_name"/>
</resultMap>
복잡한 연관관계 매핑은 신중히
MyBatis도 <association>, <collection>으로 객체 연관관계 매핑이 가능하지만, 공수가 많고 성능 최적화가 어렵다. 이런 객체-관계 매핑은 ORM인 JPA가 훨씬 자연스럽다. MyBatis에서는 신중하게 사용한다.
# 객체 연관관계 매핑
객체끼리 참조로 연결된 관계를 DB 테이블의 외래키 관계와 맞춰 데이터를 채워주는 것. DB는 테이블끼리 외래키 숫자값으로 연결(post.user_id → user.id)하지만, 객체 세계에서는 숫자가 아니라 객체 참조로 표현하고 싶다(Post.author에 User 객체 자체를 담음).
class Post {
Long id;
String title;
User author; // user_id 숫자가 아니라, User 객체 자체를 들고 있고 싶음
}
class User {
Long id;
String name;
}
이 "숫자 외래키 ↔ 객체 참조" 변환이 연관관계 매핑이다. DB로 post를 조회하면 user_id = 5 같은 숫자만 오므로, user 테이블에서 5번을 다시 조회해 User 객체를 만들고 Post.author에 꽂아주는 작업이 필요하다.
MyBatis에서는 <association>(다대일·1:1), <collection>(1:다) 태그로 가능하지만 관계가 복잡해지면 XML 설정이 장황하고 성능 최적화가 어려워 신중히 써야 한다. 반면 JPA는 ORM의 본질이라 @ManyToOne + @JoinColumn 선언만으로 처리되어 자연스럽다.
@ManyToOne
@JoinColumn(name = "user_id")
private User author; // post.getAuthor() 호출 시 JPA가 알아서 user를 조회해 채워줌
깊이는 JPA에서
연관관계 매핑은 JPA 학습의 핵심 산이다. @ManyToOne/@OneToMany, 단방향·양방향, 지연로딩, N+1 문제가 전부 여기서 파생된다. 지금은 "객체 참조 ↔ 테이블 외래키를 연결하는 것"이라는 개념만 잡고, MyBatis에선 잘 안 쓴다 정도로 넘어가면 된다. 본격 학습은 JPA 챕터에서.
# 데이터 접근 기술 - JPA
# JPA 개요
JPA는 ORM 데이터 접근 기술이며, 자바 표준 인터페이스이다. JdbcTemplate·MyBatis 같은 SQL 매퍼는 SQL을 개발자가 직접 작성하지만, JPA는 SQL을 대신 작성·실행해준다. 글로벌에서는 스프링+JPA 조합을 80% 이상, 국내도 50%가량 쓰며 추세가 늘고 있다.
ORM(Object-Relational Mapping)은 자바 객체와 관계형 데이터베이스(테이블)를 매핑해주는 기술이다.
실무에서는 JPA를 더 편하게 쓰려고 스프링 데이터 JPA와 Querydsl을 함께 쓴다. 둘은 JPA를 편리하게 쓰게 돕는 도구이고, 본질은 JPA다.
# 설정
spring-boot-starter-data-jpa 의존성을 추가한다. 이 스타터가 spring-boot-starter-jdbc를 포함하므로 기존 jdbc 의존성은 제거해도 된다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
포함되는 핵심 라이브러리는 hibernate-core(JPA 구현체), jakarta.persistence-api(JPA 인터페이스), spring-data-jpa다. JPA는 인터페이스(표준)고, 하이버네이트가 실제 구현체라는 점이 중요하다.
SQL 로그 설정(부트 3.0 / 하이버네이트 6 기준):
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
show-sql은 비권장
spring.jpa.show-sql=true는 System.out으로 출력돼서 권장하지 않는다. logger 방식(위 설정)을 쓰고, 둘 다 켜면 로그가 중복된다.
# 엔티티 매핑 (가장 중요)
JPA의 핵심은 객체와 테이블을 매핑하는 것. 애노테이션으로 선언한다.
@Data
@Entity
public class Item {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "item_name", length = 10)
private String itemName;
private Integer price;
private Integer quantity;
public Item() {} // 기본 생성자 필수
}
@Entity: JPA가 관리하는 객체(엔티티)임을 표시. 이게 있어야 JPA가 인식@Id: 테이블 PK와 필드를 매핑@GeneratedValue(strategy = IDENTITY): PK를 DB가 생성(MySQL auto increment 등)@Column(name="item_name", length=10): 필드↔컬럼 매핑.length는 DDL 자동 생성 시 컬럼 길이로 활용- 스프링 부트 통합 시 카멜→스네이크 자동 변환(
itemName→item_name)이라@Column(name=...)은 생략 가능
기본 생성자 필수
JPA는 public 또는 protected 기본 생성자가 반드시 필요하다. 빼먹으면 동작하지 않는다.
# EntityManager와 기본 CRUD
JPA의 모든 동작은 EntityManager를 통해 이뤄진다. 스프링이 주입해주며, 내부에 데이터소스를 갖고 DB에 접근한다.
@Repository
@Transactional
public class JpaItemRepositoryV1 implements ItemRepository {
private final EntityManager em; // 생성자 주입
}
변경에는 트랜잭션 필수
JPA의 모든 데이터 변경(등록·수정·삭제)은 트랜잭션 안에서 이뤄져야 한다(조회는 트랜잭션 없이도 가능). 일반적으로는 서비스 계층에 트랜잭션을 거는 것이 맞다. 이 예제는 비즈니스 로직이 없어 편의상 리포지토리에 걸었을 뿐이다.
저장 — em.persist(item). PK가 IDENTITY라 INSERT SQL에는 id가 빠지고, 실행 후 DB가 생성한 PK를 JPA가 객체 id에 채워준다.
public Item save(Item item) {
em.persist(item);
return item;
}
조회 — em.find(타입, PK). JPA가 SELECT를 만들어 실행하고 결과를 객체로 변환.
Item item = em.find(Item.class, id);
# 변경 감지 (dirty checking) — JPA의 핵심 동작
수정 코드를 보면 em.update() 같은 호출이 전혀 없는데 UPDATE SQL이 나간다.
public void update(Long itemId, ItemUpdateDto updateParam) {
Item findItem = em.find(Item.class, itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
// update 호출 없음! 그래도 UPDATE SQL 실행됨
}
JPA는 트랜잭션 커밋 시점에 변경된 엔티티가 있는지 확인하고, 변경됐으면 자동으로 UPDATE SQL을 실행한다. 이 원리를 정확히 이해하려면 영속성 컨텍스트를 알아야 한다.
테스트에서 UPDATE가 안 보이는 이유
테스트는 끝에 트랜잭션이 롤백되므로 커밋 시점이 없어 UPDATE SQL이 실행되지 않는다. 확인하려면 @Commit을 붙인다.
# JPQL과 동적 쿼리 문제
PK 단건 조회가 아니라 복잡한 조건 조회는 JPQL(Java Persistence Query Language)을 쓴다.
String jpql = "select i from Item i where i.itemName like concat('%',:itemName,'%')";
TypedQuery<Item> query = em.createQuery(jpql, Item.class);
query.setParameter("itemName", itemName);
return query.getResultList();
- SQL은 테이블 대상, JPQL은 엔티티 객체 대상.
from Item의 Item은 테이블이 아니라 엔티티 이름이고, 대소문자를 구분한다. - 문법은 SQL과 거의 같아 적응이 쉽다
- 파라미터는
:이름으로 쓰고setParameter로 바인딩 - 실행되면 엔티티 매핑 정보를 활용해 실제 SQL로 변환된다
동적 쿼리는 JPA의 약점
JPQL도 결국 문자열 더하기로 동적 쿼리를 짜야 해서 JdbcTemplate과 같은 불편함이 남는다. 실무에서는 이 문제 때문에 Querydsl을 함께 쓴다.
# 예외 변환 — @Repository의 역할
EntityManager는 순수 JPA 기술이라 예외도 JPA 예외(PersistenceException 및 하위, IllegalStateException, IllegalArgumentException)를 던진다. 이를 스프링 예외 추상화(DataAccessException)로 바꿔주는 비밀이 **@Repository**다.
@Repository가 붙으면 컴포넌트 스캔 대상이 되는 동시에, 예외 변환 AOP의 대상이 된다. 스프링+JPA를 함께 쓰면 스프링이 JPA 예외 변환기(PersistenceExceptionTranslator)를 등록하고, AOP 프록시가 JPA 예외를 가로채 스프링 데이터 접근 예외로 변환한다.
TIP
리포지토리에 @Repository만 붙이면 스프링이 예외 변환 AOP를 자동으로 만들어준다. 부트가 PersistenceExceptionTranslationPostProcessor를 자동 등록하면서 처리되는 것이다.
# 데이터 접근 기술 - 스프링 데이터 JPA
# 개요
스프링 데이터 JPA는 JPA를 편리하게 쓰도록 도와주는 라이브러리다. JPA를 직접 쓸 때 반복되는 코드를 줄여준다. 대표 기능은 두 가지다.
- 공통 인터페이스 기능: 기본 CRUD를 제공
- 쿼리 메서드 기능: 메서드 이름만으로 쿼리 자동 생성
본질은 여전히 JPA
스프링 데이터 JPA는 어디까지나 JPA를 편하게 쓰는 도구다. 메서드 이름으로 만들어진 것도 결국 JPQL → SQL로 번역돼 실행된다. 따라서 JPA 자체를 잘 이해하는 것이 가장 중요하다.
# 공통 인터페이스 — JpaRepository
JpaRepository를 상속하고 제네릭에 <엔티티, ID타입>만 주면 기본 CRUD가 전부 제공된다.
public interface ItemRepository extends JpaRepository<Item, Long> {
}
JpaRepository는 Repository ← CrudRepository ← PagingAndSortingRepository ← JpaRepository 계층으로, save, findById, findAll, delete, count, 페이징·정렬까지 공통화 가능한 기능이 거의 다 들어있다.
구현체 없이 동작하는 원리
인터페이스만 상속하면 스프링 데이터 JPA가 프록시 기술로 구현 클래스를 자동 생성하고 스프링 빈으로 등록한다. 개발자는 구현체를 안 짜도 된다. 이건 앞서 MyBatis의 @Mapper 프록시 자동 생성과 정확히 같은 메커니즘이다.
# 쿼리 메서드 — 메서드 이름으로 쿼리 생성
공통 CRUD로 안 되는 검색(이름으로, 가격으로 등)은 메서드 이름을 규칙대로 적으면 스프링 데이터 JPA가 분석해서 JPQL을 만들어 실행해준다.
List<Item> findByItemNameLike(String itemName);
List<Item> findByPriceLessThanEqual(Integer price);
List<Item> findByItemNameLikeAndPriceLessThanEqual(String itemName, Integer price);
주요 명명 규칙:
- 조회:
find…By,read…By,query…By,get…By - COUNT:
count…By(반환 long), EXISTS:exists…By(반환 boolean) - 삭제:
delete…By,remove…By - DISTINCT:
findDistinct…, LIMIT:findFirst3,findTop,findTop3 - 조건 결합:
And, 비교:LessThanEqual,GreaterThan등
# @Query — JPQL 직접 작성
메서드 이름 방식의 한계가 있다. (1) 조건이 많으면 메서드 이름이 너무 길어지고, (2) 조인 같은 복잡한 조건을 못 쓴다. 이럴 땐 @Query로 JPQL을 직접 작성한다.
@Query("select i from Item i where i.itemName like :itemName and i.price <= :price")
List<Item> findItems(@Param("itemName") String itemName, @Param("price") Integer price);
@Query를 쓰면 메서드 이름 규칙은 무시된다- 메서드 이름 방식은 파라미터를 순서대로 받지만,
@Query는@Param으로 명시적 바인딩이 필요하다 - 네이티브 쿼리(SQL 직접 작성)도 지원한다
동적 쿼리에 약하다
스프링 데이터 JPA는 동적 쿼리 지원이 매우 약하다. 예제의 findAll도 조건 조합(이름만/가격만/둘다/없음)을 if-else로 분기해 각각 다른 메서드를 호출하는 비효율적 코드다. Example 기능이 있지만 실무에선 빈약하다. 동적 쿼리는 이후 Querydsl로 해결한다.
# 어댑터 구조 (실무 설계 포인트)
ItemService는 ItemRepository 인터페이스에 의존한다. 그런데 스프링 데이터 JPA의 SpringDataJpaItemRepository는 JpaRepository를 상속한 별도 인터페이스라 그대로 끼울 수 없다. 서비스 코드를 안 고치고 구현 기술만 바꾸기 위해, 중간에 어댑터 역할의 리포지토리를 둔다.
@Repository
@Transactional
@RequiredArgsConstructor
public class JpaItemRepositoryV2 implements ItemRepository {
private final SpringDataJpaItemRepository repository; // 위임
public Item save(Item item) {
return repository.save(item);
}
public void update(Long itemId, ItemUpdateDto updateParam) {
Item findItem = repository.findById(itemId).orElseThrow();
findItem.setItemName(updateParam.getItemName()); // 변경 감지로 UPDATE
// ...
}
}
런타임 의존 구조: itemService → jpaItemRepositoryV2 → springDataJpaItemRepository(프록시). 가운데 어댑터 덕분에 ItemService가 의존하는 ItemRepository 인터페이스를 그대로 유지하고, 클라이언트 코드를 안 바꿔도 된다.
update에 update 호출이 없는 이유
update()에서 findById로 엔티티를 찾아 setter만 호출한다. 저장 메서드 호출이 없는데도 트랜잭션 커밋 시점에 변경 감지(dirty checking)로 UPDATE SQL이 나간다. 앞 JPA 챕터의 그 동작 그대로다.
# 예외 변환
스프링 데이터 JPA가 만들어주는 프록시에서 이미 예외 변환을 처리한다. 그래서 JPA 챕터와 달리 @Repository가 없어도 스프링 예외 추상화(DataAccessException)로 변환된다.