본문 바로가기
spring

스프링 DB 1편 - 2

by 오우지 2022. 11. 30.

3. 트랜잭션 이해

 

트랜잭션은 DB의 핵심 개념으로 여러개의 작업을 하나의 작업단위로 묶어 성공하면 Commit, 실패하면 Rollback 하는 과정을 말한다.

 

트랜잭션은 ACID라는 것이 있는데

원자성: 모두 성공하거나 실패

일관성: 일관성 있는 데이터베이스 상태

격리성: 동시에 실행되는 트랜잭션이 서로에게 영향을 미치지 않도록. (트랜잭션 격리수준을 선택할 수 있다)

지속성: 트랜잭션을 성공적으로 끝나면 항상 기록되어야 한다.

 

위의 네 가지 중 격리성을 완벽히 보장하려면 동시처리 성능을 많은 부분 포기해야 하므로 ANSI 표준은 격리 수준을 4단계로 정의한다.

1. READ UNCOMMITED(커밋되지 않은 읽기)

2. READ COMMITTED(커밋된 읽기)

3. REPEATABLE READ(반복 가능한 읽기)

4. SERIALIZABLE(직렬화 가능)

 

일반적으로는 2번을 많이 선택한다.

 

사용자가 WAS나 DB 툴을 이용해 DB 서버에 연결하면 클라이언트는 DB 서버에 연결하고 커넥션을 맺는데 이 때 DB 서버는 세션이라는 것을 만들고 커넥션을 통한 요청은 세션을 통해서 진행된다. 커넥션 하나당 세션 하나가 만들어지게 된다.

 

 

커밋, 롤백에 관한 내용은 익숙해서 정리하지 않았다.

 

DB락 

세션 1이 트랜잭션을 시작하고 (데이터 수정을 하고 커밋을 하기 전), 세션2가 동시에 같은 데이터를 수정하면 원자성이 깨지게 된다. 여기에 세션1이 롤백을 하게 된다면 세션2는 잘못된 데이터를 수정하는 문제가 발생한다.

 

DB는 이런 문제를 방지하기 위해 락을 제공한다.

같은 부분에 대한 수정이 일어나게 되면 다른 세션이 커밋하기 전까지 다른 트랜잭션이 대기하게 되며 설정한 락 대기시간이 지나면 타임아웃 오류가 일어나게 된다.

 

h2 데이터베이스에서 세션을 두 개 띄워놓고 하나의 세션에서 데이터를 수정하고 커밋을 하지 않으면 

SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberA';

다른 세션에서는 60초의 타임아웃 시간 동안 다른 세션의 트랜젝션이 끝나기를 기다린다. 끝난 후 두번째 요청이 수행된다.

 

일반적인 락은 다음과 같은데 조회 상황에서의 DB락은 어떻게 될까

 

데이터베이스마다 다르지만 보통은 조회할 때는 락을 사용하지 않는다. 하지만 select for update 구문을 사용하면 조회시에도 락을 걸 수 있다. 예를 들어 트랜잭션 종료 시전까지 해당 데이터를 다른 곳에서 변경하지 못하게 해야 하는 로직이 있다면 조회시 락을 획득해야 한다.

 

auto commit false 모드에서 

select * from member where member_id='memberA' for update;

select for update를 하고 commit을 해주지 않으면 

다른 테이블에서 update를 하려고 할 때 락이 잡혀 있어서 commit 전까지 update를 할 수 없다. 물론 수정 전의 정보를 select 하는 것은 가능하다.

 

 

4. 트랜잭션 문제 해결

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);
        validation(toMember);
        memberRepository.update(toId, toMember.getMoney() + money);
    }

이렇게 작성하면 간단하게 끝날 수 있는 결제 거래 처리가 거래의 안전성을 유지하려면

오토커밋을 사용하지 말아야 한다.

그럼 로직 실행 시 하나의 커넥션만 사용하며 실패시 롤백 로직도 완성해줘야 한다.

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Connection con = dataSource.getConnection();
        try {
            con.setAutoCommit(false); // 트랜잭션 시작
            //비즈니스 로직 수행
            bizLogic(con, fromId, toId, money);
            con.commit(); // 성공시 커밋
        } catch (Exception e) {
            con.rollback(); // 실패시 롤백
            throw new IllegalStateException(e);
        } finally {
            release(con);
        }
    }

    private void bizLogic(Connection con, String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(con, fromId);
        Member toMember = memberRepository.findById(con, toId);

        memberRepository.update(con, fromId, fromMember.getMoney() - money);
        validation(toMember);
        memberRepository.update(con, toId, toMember.getMoney() + money);
    }

    private void release(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true);
                con.close();
//                    autocommit이 false인 상태로 돌아가기 때문에 처리를 해줘야 한다.
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }

이런 과정을 거치게 된다.

 

위의 코드의 문제점은 비즈니스 로직에 데이터 접근 기술이 직접 영향을 미친다는 점이다. 레이어드 아키텍쳐는 비즈니스 로직을 최대한 독립적으로 가져가기 위한 레이어인데 첫번째 코드는 SQLException이라는 JDBC의존 기술이 존재하고 아래 코드는 상당히 많은 부분을 차지하고 있다.

 

만약 JDBC에서 JPA로 기술을 변경한다면 유지보수가 어려워진다.

 

 

결론적으로 지금까지 짠 코드의 문제는

 

1. 트랜잭션 문제

  - JDBC 구현 기술이 서비스 계층에 누수되는 문제

JDBC 구현 기술이 서비스 계층에 누수됐다.

 

  - 트랜잭션 동기화 문제

같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘겨야 된다.

 

  - 트랜잭션 적용 반복 문제

try, catch, finally의 반복이 많아진다.

 

2. 예외 누수 문제

SQLException은 JDBC 전용 기술인데 throw해서 서비스 계층으로 날아온다.

 

3. JDBC 반복 문제

 

세 가지로 정리할 수 있다. 하나씩 해결한다.

 

1. 트랜잭션 문제

트랜잭션 추상화

현재 서비스는 트랜잭션 로직을 비즈니스 로직에 포함하고 있는데 구현 기술마다 트랜잭션을 사용하는 방법이 다르다.

 

JDBC: con.setAutoCommit(false)

JPA: transaction.begin()

인터페이스를 만들고 사용처에 따른 구현체를 만들어도 되지만 스프링엔 이미 구현되어 있다.

 

스프링이 제공하는 트랜잭션 매니저는 두 가지 역할을 한다.

1. 트랜잭션 추상화

2. 리소스 동기화 : 같은 커넥션을 유지하기 위함

 

리소스 동기화를 유지하기 위해 스프링은 트랜잭션 동기화 매니저를 제공하는데 스레드 로컬을 사용해 커넥션을 동기화 해준다.

 

1. 트랜잭션 매니저가 커넥션을 만들고 트랜잭션이 시작되면 커넥션을 트랜잭션 동기화 매니저에 보관한다.

2. 리포지토리는 동기화 매니저에 보관된 커넥션을 꺼내서 사용하고 사용이 끝나면 트랜잭션 매니저가 트랜잭션을 종료한다.

 

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
//         트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야된다.
//        JdbcUtils.closeConnection(con);
        DataSourceUtils.releaseConnection(con, dataSource);
    }
    
    private Connection getConnection() throws SQLException {
//         트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야된다.
//        Connection con = dataSource.getConnection();
    Connection con = DataSourceUtils.getConnection(dataSource);
    log.info("get connection = {}, class={}", con, con.getClass());
    return con;
	}

주석 처리된 부분이 이전에 사용하던 코드인데

 

아래 connection 획득하는 부분을 DataSource가 아닌 DataSourceUtils에서 제공하는 getConnection()으로 바꿔주었는데 직접 사용하던 Connection을 저장해 넘겨줬던 이전 방식과 달리 트랜잭션 동기화 매니저는 관리하는 커넥션이 있으면 해당 커넥션을 반환해준다.

 

위의 close 부분은 con.close() 했을 시 로직이 끝나지 않았을 때 닫아버려져서 서비스단에서 직접 닫았던 부분을 대체해서 트랜잭션을 사용하기 위해 동기화된 커넥션은 닫지 않고 유지해준다.

 

서비스는 다음과 같이 만들어준다.

 

//    private final DataSource dataSource;
    private final PlatformTransactionManager transactionManager;
    private final MemberRepositoryV3 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
//        Connection con = dataSource.getConnection();
//        트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            //비즈니스 로직 수행
            bizLogic(fromId, toId, money);
            transactionManager.commit(status); // 성공시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status); // 실패시 롤백
            throw new IllegalStateException(e);
        }
    }

이렇게 사용해주면 트랜잭션 추상화가 가능해지고 서비스 코드는 JDBC 기술이 아닌 PlatformTransactionManager에 의존하게 된다. 덕분에 기술 변경에도 서비스 코드를 유지할 수 있다.

 

테스트 코드를 짤 때

@BeforeEach
void before() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    memberRepository = new MemberRepositoryV3(dataSource);

    PlatformTransactionManager transactionManager  = new DataSourceTransactionManager(dataSource);
    memberService = new MemberServiceV3_1(transactionManager, memberRepository);
}

V2에서는 

@BeforeEach
void before() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    memberRepository = new MemberRepositoryV2(dataSource);
    memberService = new MemberServiceV2(dataSource, memberRepository);
}

직접 dataSource를 서비스 단에 넣어줘서 그 안에서 직접 커넥션을 컨트롤했던 것과 달리 이제 PlatformTransactionManager를 통해 구현체만 DataSourceTransactionManager에서 JpaTransactionManager로 바꾸는 방법을 통해 서비스단을 그대로 가져갈 수 있다.

 

트랜잭션 템플릿

TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

try {
    //비즈니스 로직 수행
    bizLogic(fromId, toId, money);
    transactionManager.commit(status); // 성공시 커밋
} catch (Exception e) {
    transactionManager.rollback(status); // 실패시 롤백
    throw new IllegalStateException(e);
}

트랜잭션을 시작하고, 비즈니스 로직을 실행하고, 커밋, 예외 발생시 롤백은 DB 접근시 항상 반복될 패턴이다. 이런 형태는 서비스에서 반복되며 비즈니스 로직만 달라지게 된다.

 

이럴 땐 템플릿 콜백 패턴을 사용해 깔끔하게 해결할 수 있다.

public void accountTransfer(String fromId, String toId, int money) throws SQLException {
    txTemplate.executeWithoutResult((status) -> {
        try {
            bizLogic(fromId, toId, money);
        } catch (SQLException e) {
            e.printStackTrace();
        }
    });
}

하지만 여기에도 txTemplate이라는 트랜잭션을 처리하는 로직이 서비스단에 들어가게 된다. 이제 프록시(AOP)를 사용해야 한다.

 

@Transactional이 프록시를 이용해 구현된 코드며 AOP 적용을 위해선 @Aspect, @Advice, @Pointcut을 사용한다. 이렇게 만들어진 어노테이션을 사용하는 것을 선언적 트랜잭션, 위의 코드에서 해줬던 직접 트랜잭션 코드를 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라고 한다.

 

@TestConfiguration
static class TestConfig {
    @Bean
    DataSource dataSource() {
        return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    }
}

부트 없이 만든 테스트 코드에서는 DI를 위해 빈을 직접 등록해줘야 하는 과정을 거쳐야 했는데 부트는 application.*를 통해 이를 자동으로 해준다. 커넥션 풀과 관련된 설정도 여기서 가능하다. 만약 datasource 속성이 없다면 인메모리DB를 생성한다.

 

트랜잭션 매니저도 자동 등록해주는데 PlatformTransactionManager를 자동으로 transactionManager로 등록해준다. 만약 JDBC와 JPA를 동시에 사용하면 JpaTransactionManager를 빈으로 등록해준다.

 

'spring' 카테고리의 다른 글

스프링 DB 2편 - 1  (0) 2022.12.21
스프링 DB 1편 - 3  (0) 2022.12.12
스프링 DB 1편 - 1  (0) 2022.11.02
mvc2 - 타입컨버터  (0) 2022.03.02
API 예외 처리  (0) 2022.03.01