2번째 필수강의 ch3. 19강 요약 (서비스 계층 분리)
DAO를 공부할 때, DAO가 DB에 CRUD 역할을 담당하고, Controller에서 로직을 담당하도록 만들었었다. 그런데 유저 이력이라는 새로운 DAO가 생기면 Controller는 또 새로운 DAO를 주입받아야 한다. 수정사항이 생기면 Controller를 변경해야 하고, 이후에도 추가될 수 있는 기능들을 생각해보면, 서비스 계층의 분리가 필요해진다.
일반적으로 서비스를 만들 때 Controller, Service, DAO 3개의 계층으로 나누는 패턴을 사용한다. Controller는 Presentation Layer, Service는 Business Layer, DAO는 Persistence Layer가 된다.
DAO의 각 메서드는 개별 Connection을 사용한다. 그런데 트랜잭션은 한 개의 Connection에서 이루어지기 때문에, 메서드 하나당 개별 Connection을 사용하게 되면 롤백이 필요할 때 하지 못하게 될 수 있다. 하여 모든 메서드가 같은 하나의 커넥션을 사용하도록 수정해주어야 한다. 이 작업은 TransactionManager로 처리한다.
@Transactional 애너테이션은 AOP를 이용한 핵심기능과 부가기능의 분리를 가능하게 해준다. 해당 애너테이션은 클래스나 인터페이스에도 붙일 수 있고, 이 경우에는 클래스에 속한 모든 메서드나 구현체에 적용된다.
실습에 사용할 새로운 테이블을 생성한다. 예전에 만들어둔 테이블을 사용해도 되지만, 그러면 입력할 데이터가 많아지기 때문에 새로 만들었다.
CREATE TABLE `springbasic`.`a1` (
`key` INT NOT NULL,
`value` VARCHAR(45) NULL,
PRIMARY KEY (`key`));
다음으로 해당 테이블에 사용할 A1Dao를 만든다.
@Repository
public class A1Dao {
@Autowired
DataSource ds;
public int insert(int key, int value) throws Exception {
Connection conn = null;
PreparedStatement preparedStatement = null;
try {
conn = ds.getConnection();
preparedStatement = conn.prepareStatement("insert into springbasic.a1 values(?,?)");
preparedStatement.setInt(1, key);
preparedStatement.setInt(2, value);
return preparedStatement.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
close(conn, preparedStatement);
}
}
private void close(AutoCloseable... acs) {
for(AutoCloseable ac :acs)
try { if(ac!=null) ac.close(); } catch(Exception e) { e.printStackTrace(); }
}
}
UserDao를 만들었을 때 사용했던 close 메서드를 여기서도 사용한다.
Test를 만들고 2개의 데이터가 제대로 입력되는 것을 확인했다.
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/spring/**/root-context.xml"})
public class A1DaoTest extends TestCase {
@Autowired
A1Dao a1Dao;
@Test
public void testInsert() throws Exception{
a1Dao.insert(1, 100);
a1Dao.insert(2,200);
}
}
그런데 만약 두 개 다 key를 1로 주면, 첫 insert만 성공적으로 등록이 되고 두 번째 insert는 실패하여 등록되지 않는다. 하지만 Transaction에서는 둘 다 실패하거나 둘 다 성공하는 경우밖에 없기 때문에, TransactionManager를 생성해 트랜잭션을 걸어준다.
이 경우에는 Connection을 생성할 때 DataSourceUtils를 사용해 열고 닫아야 한다.
먼저 A1Dao의 insert 메서드를 수정해준다.
...
// conn = ds.getConnection();
conn = DataSourceUtils.getConnection(ds);
...
// close(conn, preparedStatement);
close(preparedStatement);
DataSourceUtils.releaseConnection(conn, ds);
Test에서는 @Autowired로 DataSource를 주입해주고, TransactionManager를 생성해 Default 속성으로 세팅해준다.
@Test
public void testInsert() throws Exception{
PlatformTransactionManager tm = new DataSourceTransactionManager(ds);
TransactionStatus status = tm.getTransaction(new DefaultTransactionDefinition());
try {
a1Dao.deleteAll();
a1Dao.insert(1, 100);
a1Dao.insert(2,200);
tm.commit(status);
} catch (Exception e) {
e.printStackTrace();
tm.rollback(status);
} finally {
}
}
테스트를 하는 과정에서 중복된 데이터가 계속 걸리면 번거롭기 때문에 deleteAll()을 만들어 사용한다. 이 때 deleteAll은 트랜잭션과 관계없이 사용하므로 ds.getConnection()을 그대로 사용한다.
public void deleteAll() throws Exception{
Connection conn = ds.getConnection();
String sql = "delete from springbasic.a1";
PreparedStatement preparedStatement = conn.prepareStatement(sql);
preparedStatement.executeUpdate();
close(preparedStatement);
}
중복된 키를 넣으면, 아무것도 저장되지 않는 것을 확인할 수 있다.
root-context.xml에 TransactionManager를 등록해준다.
// 1. namespace & schemaLocation
xmlns:tx="http://www.springframework.org/schema/tx"
...
http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-4.3.xsd
// 2. transactionManager
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
<tx:annotation-driven/>
이렇게 root-context.xml에 등록해두면 따로 객체를 생성해줄 필요가 없이 Autowired로 tm을 주입할 수 있다.
새로 테스트를 하기 위해 a1을 복사한 b1 테이블을 만든다. 다만 이렇게 복사하는 경우에는 Primary Key 설정이 되어있지 않기 때문에 따로 설정해주어야 한다.
create table springbasic.b1 select * from springbasic.a1;
A1Dao를 복사해 B1Dao를 만들고, 내부의 sql문에서 테이블 명만 a1에서 b1으로 바꿔준다. 다음으로 TxService 클래스를 만들어 Dao들을 주입해준다.
@Service
public class TxService {
@Autowired A1Dao a1Dao;
@Autowired B1Dao b1Dao;
public void insterA1WithoutTx() throws Exception{
a1Dao.insert(1, 100);
a1Dao.insert(1, 200);
}
}
먼저 A1를 사용해 중복된 key를 가진 데이터를 입력하는 메서드를 만들고 TxServiceTest를 생성해 실행한다.
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/spring/**/root-context.xml"})
public class TxServiceTest extends TestCase {
@Autowired
TxService txService;
@Test
public void testInsertA1WithoutTx() throws Exception{
txService.insterA1WithoutTx();
}
}
테스트를 실행해보면 실패가 뜨는데, TransactionManager를 사용하지 않아 커넥션을 출력해보면 전부 각각 다른 주소가 나오는 것을 알 수 있다. 하나의 커넥션을 사용하지 않았기 때문에 처음 입력한 데이터는 저장이 되었지만 두번째 데이터는 저장되지 않은 것을 확인할 수 있다.
이번에는 트랜잭션을 적용하여 테스트해본다. @Transactional 애너테이션을 붙이면 AOP가 connection과 close를 전부 처리해주기 때문에 코드가 간결해진다.
@Transactional(rollbackFor = Exception.class)
public void insertA1WithTxFail() throws Exception{
a1Dao.insert(1, 100);
a1Dao.insert(1, 200);
}
@Transactional
public void insertA1WithTxSuccess() throws Exception{
a1Dao.insert(1, 100);
a1Dao.insert(2, 200);
}
insertA1WithTxFail을 실행해보면, duplicate가 뜨면서 아무것도 저장되지 않는 것을 확인할 수 있다. 원래 @Transactional은 RuntimeException이나 Error에만 rollback을 하도록 되어있는데, Exception이 발생하면 rollback을 실행하도록 설정했기 때문이다.
a1 테이블을 깔끔하게 비우고 insertA1WithTxSuccess를 실행해보면 제대로 2개의 데이터가 저장되는 것을 확인할 수 있다.
속성 | 설명 |
---|---|
propagation | Tx의 경계를 설정하는 방법을 지정 |
isolation | Tx의 isolation level을 지정 (DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE) |
readOnly | Tx이 데이터를 읽기만 하는 경우, 이 옵션을 true로 지정하면 성능 향상 |
rollbackFor | 지정된 예외가 발생하면 Tx을 rollback (RuntimeException, Error는 자동 rollback) |
noRollbackFor | 지정된 예외가 발생해도 Tx을 rollback하지 않음 |
timeout | 지정된 시간(초) 내에 Tx이 종료되지 않으면 Tx 강제종료 |
값 | 설명 |
---|---|
REQUIRED | Tx이 진행 중이면 참여, 없으면 새로 시작(DEFAULT) |
REQUIRES_NEW | Tx이 진행 중이건 아니건 새로 Tx 시작 (=다른 Tx) |
NESTED | Tx이 진행 중이면 Tx이 내부 Tx로 실행(=같은 Tx) |
MANDATORY | 반드시 진행 중인 Tx 내에서만 실행 가능, 아니면 예외 발생 |
SUPPORTS | Tx이 진행 중이건 아니건 상관없이 실행 |
NOT_SUPPORTED | Tx 없이 처리하며, 진행 중이면 잠시 중단(suspend) |
NEVER | Tx 없이 처리, 진행 중이면 예외 발생 |