앞으로 실무에서 주로 사용하고 크게 2가지로 나뉘는 데이터 접근 기술들을 학습한다.
SQLMapper 주요 기능
1.개발자는 SQL만 작성하면 해당 SQL의 결과를 객체로 편리하게 매핑해준다.
2.JDBC를 직접 사용할 때 발생하는 여러가지 중복을 제거해주고, 기타 개발자에게 여러가지 편리한 기능을 제공한다.
ORM 주요 기능
1.SQL을 직접 작성하지 않아도 JPA와 같은 ORM기술이 객체를 마치 컬렉션 저장하듯이 데이터베이스에 저장,조회한다.
2.JPA는 자바 진영의 ORM 표준이고(인터페이스), Hibernate(하이버네이트)는 JPA에서 가장 많이 사용하는 구현체이다.
3.대부분 JPA를 사용하면 스프링 데이터 JPA, Querydsl도 함께 사용한다.
DTO(Data Transfer Object):오로지 데이터를 전송하기 위한 객체이며 주로 클라이언트,Form에서 전송된 정보가 @ModelAttribute를 통해 DTO 객체로 바인딩된다.
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 전략이라 부르며, 기본 키 생성을 데이터베이스에 위임한다. 여기서 PK라 불리는 id는 개발자가 객체 필드에 정의하지 않으며, DB가 순서대로 값을 증가시킨다.
식별자 선택 전략: 데이터베이스 기본키는 다음과 같은 조건을 모두 만족해야한다.
테이블의 기본키를 선택하는 전략은 크게 2가지가 있는데, 자연키(주민등록번호,이메일,전화번호)와 대리키(auto_increment,identity)이다. 실무에서 대리키를 사용하여 외부의 변화에 독립적이고 안전하다.
1.ItemRepository 인터페이스를 상속받아 메서드를 재정의한다.
@Slf4j
public class JdbcTemplateItemRepositoryV1 implements {
@Override
public Item save(Item item) {}
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {}
// 이후 생략
}
2.외부에서 datasource를 주입하면 생성자를 통해 JdbcTemplate를 생성한다.
private final JdbcTemplate template;
public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
3.단건 조회시(findById) template의 queryForObject 메서드와 두번째 파라미터에 private RowMapper<> 타입 함수를 사용한다.
@Override
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();
}
}
private RowMapper<Item> itemRowMapper(){
return ((rs, rowNum) -> {
Item item=new Item();
item.setId(rs.getLong("id"));
item.setItemName(rs.getString("item_name"));
item.setPrice(rs.getInt("price"));
item.setQuantity(rs.getInt("quantity"));
return item;
});
}
RowMapper는 DB의 반환 결과인 ResultSet을 반환할 객체에 바인딩한다.EmptyResultDataAccessException 예외를, 2개 이상이면 IncorrectResultSizeDataAccessException 예외를 반환하기 때문에 Optional.ofNullable()을 사용할수 없어 try-catch로 잡아야 한다.4.동적 쿼리(findAll)로써 데이터를 리스트로 조회한다. 그리고 검색 조건으로 적절한 데이터를 찾는다. template.query()는 결과가 여러개 일때 사용한다.
동적 쿼리: 사용자의 입력 값이나 조건에 따라 SQL 쿼리를 동적으로 생성하는 것을 의미한다. 실행 시점에 조건에 맞게 쿼리의 WHERE 절, ORDER BY 절, JOIN 등을 변경하거나 조합할 수 있다.
예를들어 다음과 같은 상황에서 각각 다른 sql 쿼리가 만들어져야 한다.
검색 조건 없음
select id, item_name, price, quantity from item
상품명(itemName)으로 검색
select id, item_name, price, quantity from item
where item_name like concat('%',?,'%')
최대 가격(maxPrice)으로 검색
select id, item_name, price, quantity from item
where price <= ?
상품명(itemName),최대 가격(maxPrice) 둘다 검색
select id, item_name, price, quantity from item
where item_name like concat('%',?,'%') and price <= ?
이렇게 사용자가 원하는 조건에 따라 생성하는것을 동적 쿼리라 하며, 개발할때 다양한 상황을 고려해야한다. 이렇게 복잡한 문제를 MyBatis에서 해결이 가능하다.
JdbcTemplate 내부에 PreparedStatement를 통한 sql 쿼리문에 데이터를 바인딩한다. 여기서 기본으로 사용하면 파라미터 순서대로 바인딩하는데, 실무에서 필드가 추가되거나 수정하면서 바인딩 문제가 발생한다.
String sql = "update item set item_name=?, quantity=?, price=? where id=?";
template.update(sql,
itemName,
price,
quantity,
itemId);
예를들어 price,quantity 순서가 변경되면 코드만 고치는것이 아니라 데이터베이스 복구도 필요한 심각한 문제가 발생한다. 개발을 할 때는 코드를 몇줄 줄이는 편리함도 중요하지만, 모호함을 제거해서 코드를 명확하게 만드는 것이 유지보수 관점에서 매우 중요하다. JdbcTemplate에서 이러한 문제를 보완하기 위해 이름 지정 바인딩 기술인 NamedParameterJdbcTemplate를 제공한다.
1.필드 생성
private final NamedParameterJdbcTemplate template;
2.sql 문 변경
String sql="insert into item(item_name,price,quantity) values(:itemName,:price,:quantity)";
기존 ? 자리에 :필드명이 들어간다.
3.바인딩 객체
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
------------------------------------------------------------------------------
SqlParameterSource param = new MapSqlParameterSource().addValue("itemName", updateParam.getItemName())
.addValue("price", updateParam.getPrice())
.addValue("quantity", updateParam.getQuantity())
.addValue("id", itemId);
------------------------------------------------------------------------------
Map<String, Object> param = Map.of("id", id);
BeanPropertySqlParameterSource,MapSqlParameterSource,Map은 파라미터로 전달된 객체를 sql 파라미터에 바인딩하는 역할을 한다. 해당 인스턴스는 쿼리문 파라미터 2번째 인자에 들어간다.
where절이 포함된 update와 같은 sql은 단순 필드를 파라미터로 변경해주는 BeanPropertySqlParameterSource가 사용 불가하고 MapSqlParameterSource,Map로 전달해야한다.
4.쿼리 결과 객체 바인딩
private RowMapper<Item> itemRowMapper(){
return BeanPropertyRowMapper.newInstance(Item.class);
}
기존 resultSet에서 직접 필드 매핑을 했던 작업을 BeanPropertyRowMapper의 객체 생성을 통해 자동으로 스프링 객체로 return 해준다.
만약 객체의 필드와 DB Column이 다르다면 BeanPropertyRowMapper 에서 camel_case와 snake_case와 호환이 가능하다. 만약 필드명이 완전다르다면 별칭(as)을 써서 구분하도록 하자.
JdbcTemplate은 INSERT SQL를 직접 작성하지 않고SimpleJdbcInsert를 사용하여 편리한 INSERT Query가 가능하다.
1.필드 생성
private final SimpleJdbcInsert jdbcInsert;
2.생성자 주입
public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
this.template = new NamedParameterJdbcTemplate(dataSource);
this.jdbcInsert=new SimpleJdbcInsert(dataSource)
.withTableName("item")
.usingGeneratedKeyColumns("id");
//.usingColumns("item_name","price","quantity") // 생략 가능
}
Bean으로 SimpleJdbcInsert객체를 주입 받을수 있지만 메서드 체인으로 Table명,PK 키를 지정해줘야 하기 때문에 구체 클래스에서 생성하도록 한다.
3.Save
@Override
public Item save(Item item) {
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
Number key = jdbcInsert.executeAndReturnKey(param);
item.setId(key.longValue());
return item;
}
executeAndReturnKey 메서드를 통해 INSERT SQL을 실행하고, 생성된 키 값도 매우 편리하게 조회할수 있다.
데이터 접근 기술은 실제 데이터베이스에 접근해서 데이터를 잘 저장하고 조회할 수 있는지 확인하는 것이 필요하다.
1.Application.properties 설정
테스트에서 데이터 소스를 주입받기 위해 src/test/resource에 있는 application.properties에 db설정이 마찬가지로 필요하다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test2
spring.datasource.username=sa
spring.datasource.password=
2.@SpringBootTest
@SpringBootTest는 main에 @SpringBootApplication 클래스를 찾아 해당 클래스의 설정을 테스트 환경에서 실행할수 있다.
@Import(JdbcTemplateV3Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {}
Component Scan은 hello.itemservice.web에 있는 컨트롤러를 스캔하고, 설정파일(Config)은 @Import 클래스인 JdbcTemplate를 사용한다.
애플리케이션 서버와 테스트에서 같은 데이터베이스를 사용하고 있으니 테스트에서 기존 데이터로 인한 문제가 발생한다. 이런 문제를 해결하려면 테스트를 다른 환경과 철저하게 분리해야 한다.
테스트에서 중요한 원칙은 다음과 같다.
기존 memory test에서 테스트용 db를 생성하고 테스트를 돌려보면 오류가 발생하는데 원인은 기존 데이터가 남아있어 대조하는 리스트가 일치하지 않기 때문이다.
@AfterEach의 ClearStore 메서드는 실제 데이터를 다날리기 때문에 실무에서 잘사용하지 않고 만들어둔 JdbcItemRepository에도 존재하지 않다. 해당 문제를 트랜잭션과 롤백 전략으로 해결할수 있다.
테스트를 하던 도중 중간에 테스트가 실패해서 롤백을 호출하지 못해도 commit을 하지 않았기 때문에 해당 데이터가 반영되지 않는다. 이렇게 트랜잭션을 활용하면 깔끔하게 원 상태로 복구할수 있다.
트랜잭션 매니저,컨텍스트 주입
@Autowired
PlatformTransactionManager transactionManager;
TransactionStatus status;
@BeforeEach
void beforeEach(){
//트랜잭션 시작
status = transactionManager.getTransaction(new DefaultTransactionDefinition());
}
@AfterEach
void afterEach() {
//트랜잭션 롤백
transactionManager.rollback(status);
}
이렇게 트랜잭션 매니저를 통해 Connection을 얻고 rollback을 하면 된다. JdbcTemplate와 JdbcInsert는 트랜잭션 동기화 매니저를 통해 커넥션을 획득하여 작업을 수행하기 때문에 동기화된 로직이 수행된다.
테스트 클래스에 @Transactional만 추가하면 트랜잭션 매니저,상태, @BeforeEach,@AfterEach 없이 데이터가 원상 복구되는것을 확인할수 있다.
@Transactional
@SpringBootTest
class ItemRepositoryTest {
기존 @Transactional은 로직이 성공적으로 수행하면 커밋하지만, 테스트에 적용한다면 롤백한다. 데이터 저장을 확인하고 싶다면 @Commit이나@Rollback(false)을 메서드에 붙여주면 된다.
테스트 케이스를 실행하기 위해서 별도의 데이터베이스를 설치하고, 운영하는 것은 상당히 번잡한 작업이다. 단순히 테스트를 검증할 용도로만 사용하기 때문에 테스트가 끝나면 데이터베이스의 데이터,DB 자체를 모두 삭제해도 된다.
임베디드 모드
H2 데이터베이스는 JVM안에서 메모리 모드로 동작하는 특별한 기능을 제공한다. 애플리케이션을 실행할 때 H2 데이터베이스도 해당 JVM 메모리에 함께 실행할수 있다. 해당 모드를 임베디드 모드라 한다.
test 모드 등록
@Slf4j
@Import(JdbcTemplateV3Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Bean
@Profile("test")
public DataSource dataSource(){
log.info("메모리 데이터베이스 초기화");
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;
}
이렇게 등록하면 test/resources/application.properties에 데이터소스 빈 등록이 무효화되며(spring.datasource.url=jdbc:h2:tcp://localhost/~/testcase) 임베디드 모드로 실행된다. 또한 초기에 테이블이 없기 때문에 test/resources/schema.sql파일을 생성하여 테이블을 생성해준다.
스프링 부트는 위의 설정을 하지 않아도 기본으로 임베디드 데이터베이스를 제공한다. 작성한 코드를 지우고 test의 application.properties에서 db관련 설정을 주석처리 하면 메모리DB로 동작하게 된다.
결론으로 @Transactional을 붙여주면 db가 없어도 테스트가 동작한다.