Spring 서비스 계층 분리, @Transactional

강서진·2024년 1월 9일
0

Spring

목록 보기
11/18
post-custom-banner

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개의 데이터가 저장되는 것을 확인할 수 있다.


@Transactional의 속성
속성설명
propagationTx의 경계를 설정하는 방법을 지정
isolationTx의 isolation level을 지정 (DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE)
readOnlyTx이 데이터를 읽기만 하는 경우, 이 옵션을 true로 지정하면 성능 향상
rollbackFor지정된 예외가 발생하면 Tx을 rollback (RuntimeException, Error는 자동 rollback)
noRollbackFor지정된 예외가 발생해도 Tx을 rollback하지 않음
timeout지정된 시간(초) 내에 Tx이 종료되지 않으면 Tx 강제종료
Propagation 속성값
설명
REQUIREDTx이 진행 중이면 참여, 없으면 새로 시작(DEFAULT)
REQUIRES_NEWTx이 진행 중이건 아니건 새로 Tx 시작 (=다른 Tx)
NESTEDTx이 진행 중이면 Tx이 내부 Tx로 실행(=같은 Tx)
MANDATORY반드시 진행 중인 Tx 내에서만 실행 가능, 아니면 예외 발생
SUPPORTSTx이 진행 중이건 아니건 상관없이 실행
NOT_SUPPORTEDTx 없이 처리하며, 진행 중이면 잠시 중단(suspend)
NEVERTx 없이 처리, 진행 중이면 예외 발생
post-custom-banner

0개의 댓글