@Annotaion
•
@EventListener(AppllicationReadyEvent.class)
◦
스프링 컨테이너가 완전히 초기화를 다 끝내고, 실행 준비가 되었을 때 해당 메서드를 호출한다.
◦
@PostConstruct를 사용해도 되나 AOP 같은 부분이 처리되지 않은 시점에서 호출될 수도 있다.
•
@Import(MemoryConfig.class)
◦
미리 설정한 MemoryConfig.java 파일을 설정 파일로 사용하며 해당 클래스엔 @Configuration을 붙여야 함
•
@SpringBootApplication(scanBasePackages = “hello.itemservice.web”)
◦
컴포넌트 스캔을 할 경로를 지정한다. (지정된 경로와 그 하위 패키지만 수행한다.)
•
@Profile(”local”)
◦
특정 프로필에 경우에만 해당 스프린 빈을 등록한다.
◦
application.properties에 spring.profiles.active 속성을 읽어서 프로필로 사용한다.
◦
이걸 이용해 main과 test를 따로 사용할 수 있고 프로필을 지정하지 않으면 “default”가 지정된다.
JdbcTemplate
•
장점
◦
spring-jdbc 라이브러리로 스프링으로 JDBC를 사용할 때 기본으로 사용되어 복잡한 설정이 없다.
◦
템플릿 콜백 패턴을 사용해 대부분의 반복 작업을 대신 처리해준다.
•
단점
◦
동적 SQL을 해결하기 어렵다.
private final JdbcTemplate template;
public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
Java
복사
•
JdbcTemplate는 데이터소스가 필요하다.
•
생성자를 통해 데이터소스 의존관계를 주입 받고 JdbcTemplate를 생성한다.
@Override
public Item save(Item item) {
String sql = "insert into item(item_name, price, quantity) values (?, ?, ?)";
KeyHolder keyHolder = new GeneratedKeyHolder();
template.update(connection -> {
PreparedStatement pstmt = connection.prepareStatement(sql, new String[]{"id"});
pstmt.setString(1, item.getItemName());
pstmt.setInt(2, item.getPrice());
pstmt.setInt(3, item.getQuantity());
return pstmt;
}, keyHolder);
long key = keyHolder.getKey().longValue();
item.setId(key);
return item;
}
Java
복사
•
template.update()
◦
데이터를 변경할 때 사용하며 INSERT, UPDATE, DELETE SQL에 사용한다.
◦
반환 값은 int로 영향 받은 로우 수를 반환한다.
•
ID 생성 전략은 IDENTITY를 사용한다.
◦
데이터베이스가 ID를 대신 생성해주고 INSERT가 완료되어야 ID 값을 확인할 수 있다.
◦
KeyHolder 와 connection → {} 람다식을 통해 INSERT를 미리 실행시키고 ID 값을 조회한다.
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
String sql = "update item set item_name=?, price=?, quantity=? where id=?";
template.update(sql, updateParam.getItemName(), updateParam.getPrice(), updateParam.getQuantity(), itemId);
}
Java
복사
•
update()를 사용할 때 ?에 바인딩 할 파라미터를 순서대로 전달하며 DTO에서 꺼내서 전달한다.
@Override
public Optional<Item> findById(Long id) {
String sql = "select id, item_name, price, quantity 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;
});
}
Java
복사
•
template.queryForObject()
◦
결과 로우가 하나일 때 사용하며 sql, RowMapper, 파라미터를 전달해야 한다.
◦
결과가 없으면 EmptyResultDataAccessException 예외를 터트린다.
◦
결과가 둘 이상이면 IncorrectResultSizeDataAccessException 예외를 터트린다.
•
RowMapper
◦
데이터베이스의 반환 결과인 ResultSet을 객체에 매핑해서 반환한다.
•
Optional
◦
결과가 없으면 Optional.empty() 를 반환하고 있으면 Optional.of()로 감싸서 반환한다.
@Override
public List<Item> findAll(ItemSearchCond cond) {
String itemName = cond.getItemName();
Integer maxPrice = cond.getMaxPrice();
String sql = "select id, item_name, price, quantity from item";
if(StringUtils.hasText(itemName) || maxPrice != null) {
sql += " where";
}
boolean andFlag = false;
List<Object> param = new ArrayList<>();
if(StringUtils.hasText(itemName)) {
sql += " item_name like concat('%', ?, '%')";
param.add(itemName);
andFlag = true;
}
if(maxPrice != null) {
if(andFlag) {
sql += " and";
}
sql += " price <= ?";
param.add(maxPrice);
}
log.info("sql={}", sql);
return template.query(sql, itemRowMapper(), param.toArray());
}
Java
복사
•
template.query()
◦
결과가 하나 이상일 때 사용한다. SELECT SQL에 사용한다.
◦
RowMapper는 ResultSet의 커서에 대한 while문을 돌려 객체에 매핑해 컬렉션으로 반환한다.
•
여기서 if문은 동적 쿼리를 처리하는 부분으로 조건이 많아지면 코드가 복잡해지는 문제점이 있다.
이름 지정 파라미터
•
SQL 에 ? 는 JdbcTemplate를 사용하면 데이터를 순서대로 바인딩한다.
•
파라미터가 많아지게되면 순서가 바뀌게 되고 DB에 데이터가 잘못 들어가게 되는 버그가 발생한다.
private final NamedParameterJdbcTemplate template;
public JdbcTemplateItemRepositoryV2(DataSource dataSource) {
this.template = new NamedParameterJdbcTemplate(dataSource);
}
Java
복사
•
NamedParameterJdbcTemplate와 SqlParameterSource 를 사용하면 해결할 수 있다.
String sql = "insert into item (item_name, price, quantity) values (?, ?, ?)";
String sql = "insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)";
Java
복사
•
SQL에서 “?” 를 “:프로퍼티이름” 으로 바꿔 직접 지정한다.
template.update(sql, param, keyHolder);
template.queryForObject(sql, param, itemRowMapper());
template.query(sql, param, itemRowMapper());
Java
복사
•
template를 이용해 query를 호출할 때 파라미터를 전달해야 한다.
파라미터의 종류
•
Map
String sql = "select id, item_name, price, quantity from item where id=:id";
Map<String, Object> param = Map.of("id", id);
Item item = template.queryForObject(sql, param, itemRowMapper());
Java
복사
◦
단순히 Map을 사용하는 방식 (key는 :프로퍼티이름, value는 값)
•
SqlParameterSource
◦
MapSqlParameterSource
String sql = "update item set item_name=:itemName, price=:price, quantity=:quantity where id=:id";
MapSqlParameterSource param = new MapSqlParameterSource()
.addValue("itemName", updateParam.getItemName())
.addValue("price", updateParam.getPrice())
.addValue("quantity", updateParam.getQuantity())
.addValue("id", itemId);
template.update(sql, param);
Java
복사
▪
Map과 유사하며 SQL 타입을 지정할 수 있는 등 SQL에 좀 더 특화된 기능을 제공한다.
▪
메서드 체인을 통해 편리한 사용을 제공한다.
◦
BeanPropertySqlParameterSource
String sql = "insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)";
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
KeyHolder keyHolder = new GeneratedKeyHolder();
template.update(sql, param, keyHolder);
Java
복사
▪
자바빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성한다.
getXXX() → XXX
BeanPropertyRowMapper
//전
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;
});
}
//후
private RowMapper<Item> itemRowMapper() {
return BeanPropertyRowMapper.newInstance(Item.class);
}
Java
복사
•
ResultSet의 결과를 받아서 자바빈 프로퍼티 규약에 맞춰 데이터를 변환해준다.
별칭
select member_name as username
Java
복사
•
데이터베이스에 컬럼이름은 member_name인데 객체에 프로퍼티 이름이 username인 경우 별칭을 사용
관례 불일치
select item_name -> setItemName()
Java
복사
•
자바는 camelCase를 사용하고 데이터베이스는 snake_case를 사용한다.
•
BeanPropertyRowMapper는 이러한 스네이크 표기법을 카멜 표기법으로 자동으로 변환해준다.
SimpleJdbcInsert
private final NamedParameterJdbcTemplate template;
private final SimpleJdbcInsert jdbcInsert;
public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
this.template = new NamedParameterJdbcTemplate(dataSource);
this.jdbcInsert = new SimpleJdbcInsert(dataSource)
.withTableName("item")
.usingGeneratedKeyColumns("id")
.usingColumns("item_name", "price", "quantity");
}
Java
복사
•
SimpleJdbcInsert 를 주입 받아 사용할 수 있다.
◦
withTableName : 데이터를 저장할 테이블 명을 지정한다.
◦
usingGeneratedKeyColumns : key를 생성하는 PK 컬럼 명을 지정한다.
◦
usingColumns : INSERT SQL에 사용할 컬럼을 지정하며 생략하면 테이블 내 전체 컬럼 지정
JdbcInsert for table [item] compiled
The following parameters are used for call INSERT INTO item (item_name, price, quantity) VALUES(?, ?, ?) with: [org.springframework.jdbc.core.SqlParameterValue@16132f21, org.springframework.jdbc.core.SqlParameterValue@2cd388f5, org.springframework.jdbc.core.SqlParameterValue@4640195a]
Java
복사
•
SimpleJdbcInsert는 생성 시점에 지정된 테이블의 메타 데이터를 조회해 INSERT SQL을 만든다.
//전
@Override
public Item save(Item item) {
String sql = "insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)";
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
KeyHolder keyHolder = new GeneratedKeyHolder();
template.update(sql, param, keyHolder);
long key = keyHolder.getKey().longValue();
item.setId(key);
return item;
}
//후
@Override
public Item save(Item item) {
BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(item);
Number key = jdbcInsert.executeAndReturnKey(param);
item.setId(key.longValue());
return item;
}
Java
복사
•
SimpleJdbcInsert가 제공하는 executeAndReturnKey를 사용하면 INSERT SQL를 실행하고 생성된 키 값을 편리하게 조회할 수 있다.
데이터베이스 연동 테스트
•
데이터베이스 연동 테스트의 중요한 원칙
◦
테스트는 다른 테스트와 격리해야 한다.
◦
테스트는 반복해서 실행할 수 있어야 한다.
@SpringBootTest
class ItemRepositoryTest {}
Java
복사
•
@SpringBootTest 가 붙은 테스트는 @SpringBootApplication 를 찾아서 설정으로 사용한다.
//main
spring.profiles.active=local
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
logging.level.org.springframework.jdbc=debug
//test
spring.profiles.active=test
spring.datasource.url=jdbc:h2:tcp://localhost/~/testcase
spring.datasource.username=sa
spring.datasource.password=
logging.level.org.springframework.jdbc=debug
Java
복사
•
application.properties 도 main 과 test 에서 각각 설정을 할 수 있다.
•
url을 각각 다르게 설정하면 데이터베이스를 분리할 수 있다.
@Autowired
PlatformTransactionManager transactionManager;
TransactionStatus status;
@BeforeEach
void beforeEach() {
status = transactionManager.getTransaction(new DefaultTransactionDefinition());
}
@AfterEach
void afterEach() {
//MemoryItemRepository 의 경우 제한적으로 사용
if (itemRepository instanceof MemoryItemRepository) {
((MemoryItemRepository) itemRepository).clearStore();
}
transactionManager.rollback(status);
}
Java
복사
•
트랜잭션의 롤백을 이용하면 데이터베이스의 오염없이 각 테스트를 격리할 수 있다.
•
PlatformTransactionManager와 TransactionStatus를 사용해 트랜잭션을 시작한다.
•
@BeforeEach와 @AfterEach를 이용해 각 테스트 시작 전 트랜잭션을 시작하고 종료 후 롤백한다
•
원래 @Transactional은 로직이 성공적으로 수행하면 커밋되도록 동작한다.
•
테스트에서는 특별하게 테스트를 트랜잭션 안에서 실행하고 테스트가 끝나면 롤백되도록 동작한다.
•
커밋을 하고 싶다면 @Commit 이나 @Rollback(value = false)를 사용하면 된다.
임베디드 모드 DB
•
H2 데이터베이스와 몇몇 데이터베이스는 JVM 안에서 메모리 모드로 동작하는 임베디드 모드를 제공한다.
//main의 ItemServiceApplication.java
@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_DLEAY=-1");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
//test의 application.properties
spring.profiles.active=test
spring.datasource.url=jdbc:h2:tcp://localhost/~/testcase
spring.datasource.username=sa
spring.datasource.password=
logging.level.org.springframework.jdbc=debug
Java
복사
•
@Profile(”test”)
◦
프로필이 test인 경우에만 빈으로 등록한다.
•
dataSource
◦
jdbc:h2:mem:db : 임베디드 모드로 사용하는 url이다.
◦
DB_CLOSE_DLEAY=-1 : 임베디드 모드의 커넥션 연결이 모두 끊어지면 종료되는걸 방지한다.
//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)
);
Java
복사
•
메모리를 사용하는 임베디드 모드를 사용하려면 사용할 때마다 테이블을 생성해주어야 한다.
•
src/test/resources/schema.sql 의 경로와 이름에 주의하여 추가해주어야 한다.
스프링 부트의 임베디드 모드 DB
•
자 이제 위에서 설정했던걸 다 지워보자.
◦
test/application.properties 의 datasource 설정
◦
main/ItemServiceApplication.java 의 dataSource 빈 설정
•
그럼에도 정상적으로 임베디드 모드가 작동하는걸 확인할 수 있다.
◦
스프링은 데이터베이스 설정 정보가 없으면 임베디드 모드로 접근하는 데이터소스를 만들어서 제공한다.
MyBatis
•
JdbcTemplate보다 더 많은 기능을 제공하는 SQL Mapper이다.
•
SQL을 XML에 편리하게 작성할 수 있고 동적 쿼리를 매우 편리하게 작성할 수 있다.
//build.gradle
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2'
//application.properties
mybatis.type-aliases-package=hello.itemservice.domain
mybatis.configuration.map-underscore-to-camel-case=true
logging.level.hello.itemservice.repository.mybatis=trace
mybatis.mapper-locations=classpath:mapper/**/*.xml
Java
복사
•
JdbcTemplate과 다르게 MyBatis는 약간의 설정이 필요하다.
◦
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2'
▪
build.gradle에 의존성을 추가해준다.
◦
mybatis.type-aliases-package
▪
xml에서 resultType의 객체 경로를 간편하게 적을 수 있게 해준다.
◦
mybatis.configuration.map-underscore-to-camel-case
▪
item_name → itemName 으로 변경해준다.
◦
logging.level.hello.itemservice.repository.mybatis
▪
로그 레벨 설정
◦
mybatis.mapper-locations=classpath:mapper/**/*.xml
▪
resources 내에서 매퍼 xml 파일의 경로를 지정해줄 수 있다. (현재 resources/mapper)
•
이러한 설정은 test에 있는 application.properties에도 동일하게 적용해주어야 한다.
@Mapper
public interface ItemMapper {
void save(Item item);
void update(@Param("id") Long id, @Param("updateParam")ItemUpdateDto updateParam);
List<Item> findAll(ItemSearchCond itemSearchCond);
Optional<Item> findById(Long id);
}
Java
복사
•
MyBatis의 매핑XML을 호출해주는 매퍼 인터페이스로 @Mapper 어노테이션을 꼭 붙어야 한다.
•
애플리케이션 로딩 시 @Mapper 인터페이스를 찾아 동적 프록시 기술로 구현체를 만들어 빈으로 등록한다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="hello.itemservice.repository.mybatis.ItemMapper">
...
</mapper>
Java
복사
•
매퍼 XML은 위와 같이 작성하고 namespace의 경로는 꼭 매퍼 인터페이스의 경로로 지정해야 한다.
<insert id="save" useGeneratedKeys="true" keyProperty="id">
insert into item (item_name, price, quantity)
values (#{itemName}, #{price}, #{quantity})
</insert>
<update id="update">
update item
set item_name=#{updateParam.itemName},
price=#{updateParam.price},
quantity=#{updateParam.quantity}
where id = #{id}
</update>
<select id="findById" resultType="Item">
select id, item_name, price, quantity
from item
where id = #{id}
</select>
<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>
Java
복사
•
매퍼 XML에 SQL은 각 태그를 이용해 작성한다.
◦
id를 통해 인터페이스의 메서드와 매핑하고 resultType을 통해 반환 타입을 지정한다.
◦
파라미터는 #{} 문법을 사용한다. (?를 치환하는 PreparedStatement랑 같다.)
•
useGeneratedKeys는 키 생성 전략이 IDENTITY일 때 사용하고 keyProperty를 통해 키 이름을 지정
•
@Param은 파라미터가 2개 이상일 때부터 붙인다.
•
resultType은 BeanPropertyRowMapper처럼 결과를 객체에 바로 매핑한다.
◦
반환 객체가 하나이면 Item, Optional<Item> 으로 사용하고 하나 이상이면 컬렉션(List)로 사용한다.
•
< , > , & 은 태그에 사용하므로 < , > , & 를 사용한다.
◦
CDATA 구문 문법을 사용해도 된다.
동적 쿼리
•
if
<select id="findActiveBlogWithTitleLike"
resultType="Blog">
SELECT * FROM BLOG
WHERE state = ‘ACTIVE’
<if test="title != null">
AND title like #{title}
</if>
</select>
Java
복사
◦
해당 조건을 추가할지 말지 if 절의 조건에 따라 판단한다.
•
choose, when, otherwise
<select id="findActiveBlogLike"
resultType="Blog">
SELECT * FROM BLOG WHERE state = ‘ACTIVE’
<choose>
<when test="title != null">
AND title like #{title}
</when>
<when test="author != null and author.name != null">
AND author_name like #{author.name}
</when>
<otherwise>
AND featured = 1
</otherwise>
</choose>
</select>
Java
복사
◦
자바의 switch 구문과 유사하다.
•
where, set
<select id="findActiveBlogLike"
resultType="Blog">
SELECT * FROM BLOG
<where>
<if test="state != null">
state = #{state}
</if>
<if test="title != null">
AND title like #{title}
</if>
<if test="author != null and author.name != null">
AND author_name like #{author.name}
</if>
</where>
</select>
Java
복사
◦
<where> 태그는 문장이 없으면 where을 추가하지 않는다. (and가 먼저 시작되면 and를 지운다.)
•
trim
<trim prefix="WHERE" prefixOverrides="AND |OR ">
...
</trim>
Java
복사
◦
where와 같은 기능을 수행한다.
•
foreach
<select id="selectPostIn" resultType="domain.blog.Post">
SELECT *
FROM POST P
<where>
<foreach item="item" index="index" collection="list"
open="ID in (" separator="," close=")" nullable="true">
#{item}
</foreach>
</where>
</select>
Java
복사
◦
컬렉션을 반복 처리할 때 사용한다. where in (1,2,3,4,5,6)과 같은 문장을 쉽게 완성할 수 있다.
◦
파라미터로 List를 전달해야 한다.
•
sql
<sql id="userColumns"> ${alias}.id,${alias}.username,${alias}.password </sql>
<select id="selectUsers" resultType="map">
select
<include refid="userColumns"><property name="alias" value="t1"/></include>,
<include refid="userColumns"><property name="alias" value="t2"/></include>
from some_table t1
cross join some_table t2
</select>
Java
복사
◦
<sql>을 미리 작성해두고 <include>를 통해 SQL조각을 가져다 쓸 수 있다.
◦
프로퍼티 값을 전달할 수 있고 해당 값은 내부에서 사용할 수 있다.
•
resultMap
<resultMap id="userResultMap" type="User">
<id property="id" column="user_id" />
<result property="username" column="username"/>
<result property="password" column="password"/>
</resultMap>
<select id="selectUsers" resultMap="userResultMap">
select user_id, user_name, hashed_password
from some_table
where id = #{id}
</select>
Java
복사
◦
resultMap을 만들어 놓으면 테이블과 객체 프로퍼티의 이름이 일치하지 않을 때 별칭을 사용하지 않아도 된다.
어노테이션
@Select("select id, item_name, price, quantity from item where id=#{id}")
Optional<Item> findById(Long id);
Java
복사
•
@Insert, @Update, @Delete, @Select 로 간단한 CRUD를 XML 없이 사용할 수 있다.
•
동적 SQL은 해결되지 않는다.
ORM
•
객체는 객체대로, 관계형 데이터베이스는 관계형 데이터베이스대로 설계할 수 있게 해주는 기술이다.
•
ORM 프레임워크가 중간에서 대신 매핑해준다.
JPA
•
자바 진영의 ORM 기술 표준이다.
•
JPA는 인터페이스의 모음으로 구현체로 하이버네이트 등이 있다.
•
CRUD
◦
저장 : jpa.persist(member)
◦
조회 : Member member = jpa.find(memberId)
◦
수정 : member.setName(”변경할 이름”)
◦
삭제 : jpa.remove(member)
•
장점
◦
SQL 중심적인 개발에서 객체 중심으로 개발
◦
생산성, 유지보수, 성능
◦
표준, 패러다임 불일치 해결
◦
데이터 접근 추상화와 벤더 독립성
•
특징
◦
1차 캐시와 동일성(동일한 트랜잭션에서 조회한 엔티티는 같음) 보장
◦
트랜잭션을 지원하는 쓰기 지연
◦
지연 로딩
설정
//build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
//application.properties
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=trace
Java
복사
•
'org.springframework.boot:spring-boot-starter-data-jpa' 의존성 추가
◦
spring-boot-starter-jdbc 라이브러리를 포함한다.
◦
hibernate-cord 라이브러리 추가 (JPA 구현체인 하이버네이트 라이브러리)
◦
jakarta.persistence-api 라이브러리 추가 (JPA 인터페이스)
◦
spring-data-jpa 라이브러리 추가 (스프링 데이터 JPA 라이브러리)
•
logging.level.org.hibernate.SQL=debug
◦
하이버네이트가 생성하고 실행하는 SQL을 확인할 수 있다.
•
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=trace
◦
SQL에 바인딩 되는 파라미터를 확인할 수 있다.
Entity 매핑
@Data
@Entity
@Table(name = "item")
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() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
Java
복사
•
@Entity를 붙여 JPA가 객체로 인식할 수 있게 한다.
•
@Table(name = “DB테이블 이름”) 으로 매핑할 테이블을 지정한다.
•
@Id 로 테이블의 PK로 매핑할 프로퍼티를 지정한다.
•
@GeneratedValue(strategy = GenerationType.IDENTITY) 로 키 생성 전략을 지정한다.
•
@Column(name = “컬럼이름", length = 10) 로 컬럼과 프로퍼티를 매핑한다.
◦
length는 DDL(create table)을 할 때 사용된다. → varchar 10
◦
스프링 부트와 통합하여 사용하면 snake_case to camelCase를 자동으로 해주므로 생략 가능하다.
•
JPA는 public 또는 protected 의 기본 생성자가 필수이다!
Repository
@Slf4j
@Repository
@Transactional
public class JpaItemRepository implements ItemRepository {
private final EntityManager em;
public JpaItemRepository(EntityManager em) {
this.em = em;
}
}
Java
복사
•
JPA를 사용하기 위해선 EntityManage 를 주입 받아야 한다.
◦
내부에 데이터소스를 가지고 있고 데이터베이스에 접근할 수 있다.
◦
EntityManager는 사실 EntityManagerFactory, JpaTransactionManager 를 만든 후 생성해야 하는데 스프링 부트가 해준다.
•
JPA에서 조회를 제외한 모든 데이터 변경은 트랜잭션 안에서 이루어져야하므로 @Transactional을 붙인다.
JPA의 CRUD
•
save
@Override
public Item save(Item item) {
em.persist(item);
return item;
}
Java
복사
◦
키 생성 전략이 IDENTITY인 경우 INSERT SQL 실행 후 생성된 키를 넣어준다.
•
update
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
Item item = em.find(Item.class, itemId);
item.setItemName(updateParam.getItemName());
item.setPrice(updateParam.getPrice());
item.setQuantity(updateParam.getQuantity());
}
Java
복사
◦
트랜잭션이 커밋되면 영속성 컨텍스트 내의 스냅샷 객체와 비교해서 변경된 객체를 찾는다.
◦
변경된 경우에만 UPDATE SQL을 실행한다.
•
findAll
@Override
public List<Item> findAll(ItemSearchCond cond) {
String jpql = "select i from Item i";
List<Item> result = em.createQuery(jpql, Item.class).getResultList();
return result;
}
Java
복사
◦
SQL이 테이블을 대상으로 한다면 JPQL은 엔티티 객체를 대상으로 SQL을 실행한다.
◦
엔티티 객체의 매핑 정보와 파라미터를 활용해 SQL을 만든다.
JPA의 예외처리
•
EntityManager는 순수한 JPA 기술로 스프링과 관련이 없어 JPA 관련 예외만을 발생시킨다.
•
@Repository를 통해 JPA 예외를 스프링 예외 추상화로 변환할 수 있다.
◦
@Repository 클래스는 컴포넌트 스캔 대상이 되고 예외 변환 AOP 대상이 된다.
◦
스프링과 JPA를 함께 사용하는 경우 스프링은 PersistenceExceptionTranslator를 등록한다.
•
스프링 부트가 자동으로 등록하는 PersistenceExceptionTranslatorPostProcessor에서 @Repository를 AOP 프록시로 만드는 어드바이저가 등록된다.
•
EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible()에서 JPA 예외를 변환한다.
스프링 데이터
•
여러 종류의 데이터베이스들의 공통적인 부분을 통합해 한 단계 더 추상화 시켜주는 기술이다.
◦
CRUD + 쿼리
◦
동일한 인터페이스
◦
페이징 처리
◦
메서드 이름으로 쿼리 생성
◦
스프링 MVC에서 id값만 넘겨도 도메인 클래스로 바인딩
스프링 데이터 JPA
•
스프링 데이터를 사용하며 JPA를 좀 더 편리하게 사용할 수 있도록 도와주는 라이브러리이다.
//build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
ext["hibernate.version"] = "5.6.5.Final" //버전 변경
Java
복사
•
build.gradle에 의존성을 추가해 사용할 수 있다.
•
스프링이 자동으로 잡아주는 버전에서 변경하고 싶다면 ext[”hibernate.version”] 을 사용한다.
public interface SpringDataJpaItemRepository extends JpaRepository<Item, Long> {
}
Java
복사
•
JpaRepository 인터페이스를 상속 받은 인터페이스를 만들어 제네릭으로 <엔티티, PK>를 주면 된다.
•
아무런 내용을 적지 않아도 상속 받았기 때문에 기본 CRUD 기능을 모두 사용할 수 있다.
•
JpaRepository를 상속 받으면 스프링 데이터 JPA가 동적 프록시 기술로 구현 클래스를 만들고 인스턴스를 만들어 스프링 빈으로 등록한다.
•
개발자는 구현 클래스 없이 인터페이스만 만들면 CRUD 기능을 사용할 수 있다.
쿼리 메서드
•
인터페이스에 메서드만 적어두면 메서드 이름을 분석해 쿼리를 자동으로 만들고 실행해주는 기능이다.
//순수 JPA Repository
public List<Member> findByUsernameAndAgeGreaterThan(String username, int age) {
return em.createQuery("select m from Member m where m.username = :username and m.age > :age")
.setParameter("username", username)
.setParameter("age", age)
.getResultList();
}
//스프링 데이터 JPA
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
Java
복사
•
순수 JPA Repository는 직접 JPQL을 작성하고 파라미터를 직접 바인딩 해야 한다.
•
스프링 데이터 JPA는 메서드 이름을 분석해 JPQL을 만들고 실행한다. (JPA가 SQL로 변역해서 실행)
◦
조회 : find..By , read..By , query..By , get..By
◦
삭제 : delete..By , remove..By (반환타입 long)
◦
COUNT : count..By (반환타입 long)
◦
EXISTS : exists..By (반환타입 boolean)
◦
DISTINCT : findDistinct , findMemberDistinctBy
◦
LIMIT : findFirst3 , findFirst , findTop , findTop3
//이름이 너무 길어 복잡한 경우
List<Item> findByItemNameLikeAndPriceLessThanEqual(String itemName, Integer price);
//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);
Java
복사
•
쿼리 메서드의 이름이 복잡해지는 경우 직접 JPQL을 사용할 수 있다.
•
@Query 로 JPQL을 작성한 후 파라미터를 매핑해준다. (파라미터 2개 이상이면 @Param 사용)
•
@Modifying 를 추가로 붙여 UPDATE SQL도 실행 할 수 있다.
•
기존에 ItemService는 ItemRepository를 의존하고 있기 때문에 SpringDataJpaItemRepository를 사용할 수 없다.
→ ItemRepository의 구현체를 만들어 SpringDataJpaItemRepository를 주입 받는다.
•
ItemRepository의 구현체는 SpringDataJpaItemRepository라는 인터페이스를 의존하게 된다.
하지만 실제로 구현체는 해당 인터페이스의 프록시 구현체이다.
→ 위는 클래스 일 때의 의존관계고 런타임 일 땐 프록시 구현체를 사용하게 된다.
QueryDSL
•
쿼리를 JAVA로 type-safe하게 개발할 수 있게 지원하는 프레임워크이다.
◦
기존의 쿼리를 사용하면 type-check가 불가능하고 실행하기 전까진 작동여부를 확인할 수 없었다.
◦
QueryDSL 사용 시 컴파일에서 에러 체크가 가능하고 code-assistant를 사용할 수 있다.
•
JPQL, Criteria API, MetaModel Criteria API 에서의 동적 쿼리의 어려움을 해결해준다.
◦
쿼리 + 도메인 + 특화 + 언어로 쿼리에 특화된 프로그래밍 언어 (단순, 간결, 유창)
•
@Entity 와 같은 APT(Annotation Processing Tool) 를 사용해 쿼리를 생성해준다.
◦
QueryDSL은 JPQL를 생성하고 JPA에 의해 SQL로 변환된다.
//build.gradle
implementation 'com.querydsl:querydsl-jpa'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
//build.gradle (자동 생성된 Q클래스 gradle clean으로 제거)
clean {
delete file('src/main/generated')
}
Java
복사
•
build.gradle에 다음과 같은 설정을 해주어야 한다.
•
컴파일 시 생성되는 Q타입은 깃 이그노어 하는 것이 좋다.
@Repository
@Transactional
public class JpaItemRepositoryV3 implements ItemRepository {
private final EntityManager em;
private final JPAQueryFactory query;
public JpaItemRepositoryV3(EntityManager em) {
this.em = em;
this.query = new JPAQueryFactory(em);
}
}
Java
복사
•
QueryDSL을 사용하기 위해선 JPAQueryFactory가 필요하고 JPQL도 생성해야 하기 때문에 EntityManager도 필요하다.
public List<Item> findAllOld(ItemSearchCond cond) {
String itemName = cond.getItemName();
Integer maxPrice = cond.getMaxPrice();
QItem item = QItem.item;
BooleanBuilder builder = new BooleanBuilder();
if(StringUtils.hasText(itemName)) {
builder.and(item.itemName.like("%" + itemName + "%"));
}
if(maxPrice != null) {
builder.and(item.price.loe(maxPrice));
}
List<Item> result = query
.select(item)
.from(item)
.where(builder)
.fetch();
return result;
}
@Override
public List<Item> findAll(ItemSearchCond cond) {
String itemName = cond.getItemName();
Integer maxPrice = cond.getMaxPrice();
return query
.select(item)
.from(item)
.where(likeItemName(itemName), maxPrice(maxPrice))
.fetch();
}
private BooleanExpression maxPrice(Integer maxPrice) {
if(maxPrice != null) {
return item.price.loe(maxPrice);
}
return null;
}
private BooleanExpression likeItemName(String itemName) {
if(StringUtils.hasText(itemName)) {
return item.itemName.like("%" + itemName + "%");
}
return null;
}
Java
복사
•
QueryDSL를 사용하면 쿼리와 비슷하게 자바 코드로 작성할 수 있다.
◦
Query (select, from, where, join, …)
◦
Path (QMember, QMember.name, …)
◦
Expression (name.eq, name,gt, …)
•
BooleanBuilder를 사용해 WHERE 조건을 채울 수 있다.
◦
BooleanExpression을 사용하면 where()에서 콤마로 AND를 사용할 수 있다.
•
fetch()를 사용하면 목록 조회, fetchOne()을 사용하면 단건 조회가 가능하다.
트레이드 오프
•
의존 관계를 잘 보면 JpaItemRepositoryV2가 어댑터 역할을 해주기 때문에 ItemService 는 코드 변경 없이 ItemRepository 인터페이스를 의존할 수 있다.
◦
DI, OCP 원칙을 지키기 위해 어댑터가 들어가면서 전체 구조가 너무 복잡해지고 클래스도 많아진다.
◦
개발자는 어댑터도 만들고 실제 코드까지 만들어야 하고 유지보수 해야 하는 불편함이 생긴다.
•
DI, OCP 원칙을 포기하고 ItemService 코드를 일부 고쳐서 SpringDataJpaItemRepository를 사용하는 방법이다.
◦
중간에 어댑터가 제거되며 구조가 단순해진다.
구조의 안정성 VS 단순한 구조와 개발의 편리성
•
위와 같은 경우 발생하는 트레이드 오프이다.
•
개발을 할 땐 항상 자원이 무한하지 않고 어설픈 추상화는 독이 된다.
•
결국 정답은 없다. 프로젝트의 현재 상황에 맞는 더 적절한 선택지를 선택하는 개발자가 좋은 개발자이다.
실용적인 구조
•
스프링 데이터 JPA를 담당하는 레포지토리와 QueryDSL을 담당하는 레포지토리를 함께 의존한다.
◦
CRUD와 단순 조회는 스프링 데이터 JPA가 담당
◦
복잡한 조회 쿼리는 QueryDSL이 담당
public interface ItemRepositoryV2 extends JpaRepository<Item, Long> {
}
Java
복사
@Repository
public class ItemQueryRepositoryV2 {
private final JPAQueryFactory query;
public ItemQueryRepositoryV2(EntityManager em) {
this.query = new JPAQueryFactory(em);
}
public List<Item> findAll(ItemSearchCond cond) {
return query.select(item)
.from(item)
.where(likeItemName(cond.getItemName()), maxPrice(cond.getMaxPrice()))
.fetch();
}
private BooleanExpression maxPrice(Integer maxPrice) {
if(maxPrice != null) {
return item.price.loe(maxPrice);
}
return null;
}
private BooleanExpression likeItemName(String itemName) {
if(StringUtils.hasText(itemName)) {
return item.itemName.like("%" + itemName + "%");
}
return null;
}
}
Java
복사
@Service
@RequiredArgsConstructor
@Transactional
public class ItemServiceV2 implements ItemService {
private final ItemRepositoryV2 itemRepositoryV2;
private final ItemQueryRepositoryV2 itemQueryRepositoryV2;
@Override
public Item save(Item item) {
return itemRepositoryV2.save(item);
}
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
Item findItem = itemRepositoryV2.findById(itemId).orElseThrow();
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
@Override
public Optional<Item> findById(Long id) {
return itemRepositoryV2.findById(id);
}
@Override
public List<Item> findItems(ItemSearchCond cond) {
return itemQueryRepositoryV2.findAll(cond);
}
}
Java
복사
•
둘 다 DI 받은 후 쿼리마다 필요한 레포지토리를 사용한다.
@Configuration
@RequiredArgsConstructor
public class V2Config {
private final EntityManager em;
private final ItemRepositoryV2 itemRepositoryV2;
@Bean
public ItemService itemService() {
return new ItemServiceV2(itemRepositoryV2, itemQueryRepositoryV2());
}
@Bean
public ItemQueryRepositoryV2 itemQueryRepositoryV2() {
return new ItemQueryRepositoryV2(em);
}
//테스트용
@Bean
public ItemRepository itemRepository() {
return new JpaItemRepositoryV3(em);
}
}
Java
복사
•
Config에서 수동으로 빈 등록할 때 주의해야 한다.
다양한 데이터베이스 접근 기술 조합
•
비즈니스 상황과 현재 프로젝트 구성원의 역량에 따라 결정하는 것이 적합하다.
◦
JPA, 스프링 데이터 JPA, QueryDSL을 기본으로 사용하고 해결이 안되는 문제는 MyBatis나 JdbcTemplate를 사용하는 것이 좋다.
•
트랜잭션 매니저를 고를 땐 JpaTransactionManager를 선택한다.
◦
SQL Mapper는 DataSourceTransactionManager를 사용한다.
◦
ORM은 JpaTransactionManager를 사용한다.
◦
JpaTransactionManager는 DataSourceTransactionManager 기능을 대부분 제공한다.
•
JPA와 JdbcTemplate를 함께 사용하는 경우 플러시 타이밍에 주의하라
◦
JPA는 영속성 컨텍스트에 의해 관리되며 커밋 시점에 SQL이 한꺼번에 실행된다.
◦
JdbcTemplate는 바로바로 SQL을 실행시킨다.
◦
JPA의 플러시 기능을 사용하면 커밋 전에도 SQL을 즉시 실행시킬 수 있다.
스프링 트랜잭션
•
@Transactional를 사용하면 트랜잭션 AOP 프록시를 통해 서비스와 트랜잭션을 분리할 수 있다.
◦
실제 객체 대신 프록시 객체가 스프링 빈으로 등록되고 주입된다. (~~$$CGLIB)
◦
AopUtils.isAopProxy() 를 통해 프록시인지 확인할 수 있다.
◦
프록시는 기존 서비스 객체를 상속해서 만들어지기 때문에 다형성을 활용한다.
•
TransactionSynchronizationManager.isActualTransactionActive()
◦
현재 스레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다.
•
TransactionSynchronizationManager.isCurrentTransactionReadOnly()
◦
현재 트랜잭션에 적용된 readOnly 옵션의 값을 반환한다.
•
@Transactional의 우선순위는 구체적이고 자세한 것이 높은 우선순위를 가진다.
1.
클래스의 메서드
2.
클래스
3.
인터페이스의 메서드
4.
인터페이스
(인터페이스에 사용하면 AOP가 적용하지 않는 경우가 있으므로 가급적 구체적으로 사용한다.)
•
트랜잭션 AOP는 public 메서드에서만 적용된다.
트랜잭션 AOP 주의사항
•
요청을 하면 프록시 객체가 먼저 받아 트랜잭션을 처리하고 실제 객체를 호출한다.
따라서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체를 호출해야 한다.
@Slf4j
@SpringBootTest
public class InternalCallV1Test {
@Autowired CallService callService;
@Test
void internalCall() {
callService.internal();
}
@Test
void externalCall() {
callService.external();
}
@Slf4j
@RequiredArgsConstructor
static class CallService {
public void external() {
log.info("call external");
printTxInfo();
internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
Java
복사
•
정상적으로 트랜잭션이 실행된다.
•
external()이 internal()이 호출했음에도 트랜잭션이 적용되지 않는다.
◦
이 때 internal()은 생략된 this.internal()이라 실제 external()에서 호출했으므로 트랜잭션이 없다.
프록시 방식 AOP의 한계
•
메서드 내부 호출에 트랜잭션 프록시가 적용되지 않는 문제는 프록시 방식의 AOP 한계이다.
이를 해결하기 위해선 내부 호출하는 메서드를 별도의 클래스로 분리해야 한다.
@Slf4j
@RequiredArgsConstructor
static class CallService {
private final InternalService internalService;
public void external() {
log.info("call external");
printTxInfo();
internalService.internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
static class InternalService {
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
Java
복사
•
InternalService 클래스로 분리하여 this.internal() 대신 internalService.internal()로 변경하였다.
→ 트랜잭션이 정상적으로 적용된다.
@Autowired Hello hello;
@Test
void go() {
}
@Slf4j
static class Hello {
@PostConstruct
@Transactional
public void initV1() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init @PostConstruct tx active={}", isActive);
}
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initV2() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init ApplicationReadyEvent tx active={}", isActive);
}
}
Java
복사
•
@PostConstruct와 @Transactional을 함께 사용하면 트랜잭션이 적용되지 않는다.
→ 초기화 코드가 먼저 호출되고 그 다음에 트랜잭션 AOP가 적용되기 때문이다.
•
@EventListner(value = ApplicationReadyEvent.class)
→ 트랜잭션 AOP를 포함한 스프링 컨테이너 생성 등 모든 초기 작업이 끝난 후 호출된다.
트랜잭션 옵션
•
@Transactional(value = “”)
public class TxService {
@Transactional("memberTxManager")
public void member() {...}
@Transactional("orderTxManager")
public void order() {...}
}
Java
복사
◦
트랜잭션 매니저를 구분하기 위해 사용하고 생략하면 기본으로 등록된 트랜잭션 매니저를 사용한다.
◦
속성이 하나인 경우 value를 생략하고 값을 바로 넣을 수 있다.
•
@Transactional(rollbackFor = ~~.class)
@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException {
log.info("call checkedException");
throw new MyException();
}
Java
복사
◦
트랜잭션 롤백 기본 정책
▪
언체크 예외 (RuntimeException, Error) 가 발생하면 롤백
▪
체크 예외 (Exception) 이 발생하면 커밋
◦
rollbackFor 을 지정할 경우 해당 예외가 발생하면 체크 예외여도 롤백한다.
◦
noRollbackFor 은 rollbackFor의 반대이다.
•
@Transactional(isolation = )
◦
DEFAULT : 데이터베이스에서 설정한 격리 수준을 따른다.
◦
READ_UNCOMMITED : 커밋되지 않은 읽기
◦
READ_COMMITED : 커밋된 읽기
◦
REPEATABLE_READ : 반복 가능한 읽기
◦
SERIALIZABLE : 직렬화 가능
•
@Transactional(timeout = )
◦
트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정한다.
•
@Transactional(label = “”)
◦
트랜잭션 어노테이션의 라벨 값을 읽어 사용하고 싶을 때 지정한다.
•
@Transactional(readOnly = false)
◦
프레임워크
▪
JdbcTemplate는 읽기 전용 트랜잭션 안에서 변경 기능을 실행하면 예외를 던진다.
▪
JPA는 읽기 전용 트랜잭션의 경우 커밋 시점에 플러시를 호출하지 않는다.
◦
JDBC 드라이버
▪
읽기 전용 트랜잭션에서 변경 쿼리가 발생하면 예외를 던진다.
▪
읽기(슬레이브), 쓰기(마스터) 데이터베이스를 구분해서 요청한다.
◦
데이터베이스
▪
읽기 전용 트랜잭션의 경우 읽기만 하면 되므로 내부에서 성능 최적화가 발생한다.
트랜잭션 전파
•
트랜잭션이 이미 수행 중인데 추가로 트랜잭션을 수행하면 스프링은 하나의 트랜잭션을 만들어준다.
•
처음 시작된 트랜잭션은 외부 트랜잭션 이후 시작된 트랜잭션은 내부 트랜잭션이다.
•
물리 트랜잭션은 실제 커넥션을 통해 트랜잭션을 시작하고 커밋, 롤백하는 단위이다.
•
논리 트랜잭션은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위이다.
@Test
void inner_commit() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction={}", outer.isNewTransaction()); //true
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("inner.isNewTransaction={}", inner.isNewTransaction()); //false
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
Java
복사
@Test
void outer_rollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 롤백");
txManager.rollback(outer);
}
Java
복사
@Test
void inner_rollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
Java
복사
•
트랜잭션의 대원칙
◦
모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
◦
하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.
•
TransactionStatus.isNewTransaction()
◦
트랜잭션이 신규 트랜잭션인지 확인한다.
◦
외부 트랜잭션에 참여한 내부 트랜잭션은 신규 트랜잭션이 아니므로 false이다.
1.
txManager.getTransaction() 호출
2.
txManager가 데이터소스를 통해 커넥션 생성 후 setAutoCommit(false) 설정 - 외부(물리) 트랜잭션 시작 (isNewTransaction() == true)
3.
txManager가 트랜잭션 동기화 매니저에 커넥션 보관
4.
로직1 사용
5.
txManager.getTransaction() 호출
6.
txManager가 트랜잭션 동기화 매니저를 통해 기존 트랜잭션이 있는지 확인하고 존재하면 참여 - 내부 트랜잭션 시작 (isNewTransaction() == false)
7.
로직2 사용
•
둘 다 커밋하는 경우
8.
로직2가 끝나고 txManager를 통해 내부 트랜잭션 커밋 (실제 커넥션을 커밋하진 않음)
9.
로직1이 끝나고 txManager를 통해 외부 트랜잭션 커밋 (실제 커넥션에 커밋 호출)
•
외부 트랜잭션이 롤백하는 경우
8.
로직2가 끝나고 txManager를 통해 내부 트랜잭션 커밋 (실제 커넥션을 커밋하진 않음)
9.
로직1이 끝나고 txManager를 통해 외부 트랜잭션 롤백 (실제 커넥션에 롤백 호출)
•
내부 트랜잭션이 롤백하는 경우
8.
로직2가 끝나고 txManager를 통해 내부 트랜잭션 롤백 (실제 커넥션을 롤백하지 않음)
9.
트랜잭션 동기화 매니저에 rollbackOnly = true로 표시
10.
로직1이 끝나고 txManager를 통해 외부 트랜잭션 커밋 (실제 커넥션에 커밋 호출)
11.
트랜잭션 동기화 매니저에 rollbackOnly = true 표시 확인 후 커밋 대신 롤백 호출
12.
UnexpectedRollbackException 런타임 예외를 던진다.
REQUIRES_NEW
log.info("내부 트랜잭션 시작");
DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus inner = txManager.getTransaction(definition);
log.info("inner.isNewTransaction={}", inner.isNewTransaction()); //true
Java
복사
•
물리 트랜잭션을 분리하려면 내부 트랜잭션을 시작할 때 REQUIRES_NEW 옵션을 사용하면 된다.
•
커밋과 롤백은 서로가 영향을 주지 않는다.
•
트랜잭션 동기화 매니저에서 서로 다른 커넥션을 가져와 독립된 물리 트랜잭션을 시작한다.
•
내부 트랜잭션이 실행되는 동안 외부 트랜잭션이 대기상태가 되기 때문에 데이터베이스 커넥션이 동시에 2개 사용되는 점을 주의해야 한다.
트랜잭션 전파 옵션
•
REQUIRED (기본 설정)
◦
기존 트랜잭션 없음 : 새로운 트랜잭션을 생성
◦
기존 트랜잭션 있음 : 기존 트랜잭션에 참여
•
REQUIRES_NEW
◦
기존 트랜잭션 없음 : 새로운 트랜잭션을 생성
◦
기존 트랜잭션 있음 : 새로운 트랜잭션을 생성
•
SUPPORT
◦
기존 트랜잭션 없음 : 트랜잭션 없이 진행
◦
기존 트랜잭션 있음 : 기존 트랜잭션에 참여
•
NOT_SUPPORT
◦
기존 트랜잭션 없음 : 트랜잭션 없이 진행
◦
기존 트랜잭션 있음 : 트랜잭션 없이 진행 (기존 트랜잭션은 보류)
•
MANDATORY
◦
기존 트랜잭션 없음 : IllegalTransactionStateException 예외 발생
◦
기존 트랜잭션 있음 : 기존 트랜잭션에 참여
•
NEVER
◦
기존 트랜잭션 없음 : 트랜잭션 없이 진행
◦
기존 트랜잭션 있음 : IllegalTransactionStateException 예외 발생
•
NESTED
◦
기존 트랜잭션 없음 : 새로운 트랜잭션을 생성
◦
기존 트랜잭션 있음 : 중첩 트랜잭션을 만든다
▪
외부 트랜잭션의 영향은 받지만 중첩 트랜잭션은 외부에 영향을 주지 않는다.
▪
중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋할 수 있다.
▪
외부 트랜잭션이 롤백 되면 중첩 트랜잭션도 함께 롤백된다.
▪
JPA에서 사용할 수 없다.
+ isolation, timeout, readOnly 등은 트랜잭션이 처음 시작될 때만 적용된다. (참여하는 경우 적용 X)