Spring

[Spring DB 1편 듣고 복습, 토이 프로젝트 수정] 4. 스프링과 문제 해결 - 트랜잭션 - 1

gogi masidda 2024. 2. 29. 16:35

문제점

애플리케이션 구조는  UI를 처리하는 @Controller의 '프레젠테이션 계층', 비즈니스 로직을 처리하는 @Service의 '서비스 계층', DB 접근을 처리하는 @Repository의 '데이터 접근 계층'의 3가지 계층으로 나뉜다.

 

  • 프레젠테이션 계층
    • UI와 관련된 처리 담당
    • 웹 요청과 응답
    • 사용자 요청 검증
  • 서비스 계층
    • 비즈니스 로직을 담당
    • 가급적 다른 특정 기술에 의존하지 않고, 순수 자바 코드로 작성
  • 데이터 접근 계층
    • 실제 데이터베이스에 접근하는 코드
    • JDBC, JPA, ...

이 3가지 계층 중에서 서비스 계층이 가장 중요하다. 시간이 흘려서 웹, 데이터 저장 기술이 변해도, 비즈니스 로직은 최대한 변경없이 유지되어야 한다. 이렇게 하려면 서비스 계층은 다른 기술에 종속적이지 않고 최대한 순수하게 유지해야 한다. 특정 기술에 의존하는 것은 프레젠테이션 계층과 데이터 접근 계층에서 이루어진다. 웹과 관련된 기술을 변경하면 프레젠테이션 계층만 수정하면 되고, 데이터 접근 기술을 바꾸면 데이터 접근 계층만 수정하면 된다. 

이렇게 해야 향후에 구현 기술이 변경될 때 변경의 영향 범위를 최소화할 수 있다.

 


프로젝트 코드에서 문제점

트랜잭션을 위해서 MemberService라는 비즈니스 로직을 담고 있는 파일에서 트랜잭션을 시작하고, 비즈니스 로직을 처리하고, 트랜잭션을 종료하도록 저번 포스팅에서 마지막으로 수정했다.

 

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
    private final MemberRepositoryWithDBV2 memberRepository;
    private final DataSource dataSource;

    //커뮤니티 포인트 전송
    public void pointTransfer(String fromNickname, String toNickname, int point) throws SQLException {

        Connection con = dataSource.getConnection();
        try {
            con.setAutoCommit(false);
            bizLogic(con, fromNickname, toNickname, point);
            con.commit();
        } catch (Exception e) {
            con.rollback();
            throw new IllegalStateException(e);
        } finally {
            release(con);
        }
    }

    private void bizLogic(Connection con, String fromNickname, String toNickname, int point) throws SQLException {
        Member fromMember = memberRepository.findByNickname(con, fromNickname);
        Member toMember = memberRepository.findByNickname(con, toNickname);
        validation(toMember);
        memberRepository.updatePoint(con, fromNickname, fromMember.getPoint() - point);
        memberRepository.updatePoint(con, toNickname, toMember.getPoint() + point);
    }

    private void validation(Member toMember) {
        if(toMember.getNickname().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }

    private void release(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true); //커넥션 풀 고려
                con.close();
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }
}

 

하지만 이 코드에서 java.sql.DataSource, java.sql.Connection, java.sql.SQLException이라는 JDBC 기술에 의존되어 있다. 그래서 나중에 JDBC가 아닌 다른 기술을 사용하면 비즈니스 로직을 수정해야 한다. 그리고 비즈니스 로직 코드보다 반복 때문에 트랜잭션을 위한 코드가 더 길다. 


문제점 정리

  • 트랜잭션 문제
    • 트랜잭션 때문에 JDBC 구현 기술이 서비스 계층에 누수되는 문제
      • 데이터 접근 계층에 JDBC 구현 기술을 몰아두어야함
      • 데이터 접근 계층의 구현 기술이 변경될 수 있어 데이터 접근 계층은 인터페이스를 구현하는 식으로 인터페이스를 제공하는 것이 좋음.
    • 트랜잭션 때문에 커넥션 동기화 문제
      • 같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘기면서 같은 기능을 파라미터를 받는것과 안받는것 (트랜잭션이 필요한 것과 필요하지 않은 것)으로 두개씩 만들어두어야 한다.
    • 트랜잭션을 적용하는 try, catch, finally 반복 문제
  • 예외 누수 문제
    • 데이터 접근 계층의 JDBC 구현 기술 예외가 서비스 계층까지 올라간다.
    • SQLException은 체크 예외라서 데이터 접근 계층을 호출한 서비스 계층에서 잡아서 처리하거나 throws로 던져야 한다.
    • SQLException은 JDBC 전용 기술이라서 다른 기술로 변경하게 되면 서비스 계층의 비즈니스 로직도 수정해야 한다.
  • JDBC 반복 문제
    • MemberRepository의 try, catch, finally 코드의 반복이 너무 많음.
 public Member save(Member member) throws SQLException {
        String sql = "insert into member(id, register_date, name, loginid, password, nickname, point) values (?, ?, ?, ?, ?, ?, ?)";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setLong(1, ++sequence);
            pstmt.setDate(2, Date.valueOf(LocalDate.now()));
            pstmt.setString(3, member.getName());
            pstmt.setString(4, member.getLoginId());
            pstmt.setString(5, member.getPassword());
            pstmt.setString(6, member.getNickname());
            pstmt.setInt(7, member.getPoint());

            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
            log.info("save : member = {}", member);
        }
    }

이처럼 연결 가져오고, pstmt 가져오고, 파라미터 세팅, 결과 가져오는 코드가 함수마다 해주어야 한다.


스프링으로 문제 해결


트랜잭션 추상화

데이터 접근 기술마다 트랜잭션을 사용하는 방법도 달라서 데이터 접근 기술이 바뀌면 서비스 계층의 코드를 수정해야 한다. 그래서 이 문제를 해결하려면 인터페이스를 만들어 트랜잭션 기능을 추상화 해야 한다. 

 

public interface TxManager {
	begin();
	commit();
	rollback();
}

트랜잭션은 시작하고, 비즈니스 로직 수행이 끝나면 커밋하거나 롤백하는 기능만 있으면 된다.

 

이렇게 인터페이스를 만들고, 각 기술에 맞게 JDBCTxManager, JPATxManager와 같이 구현체를 만들면 된다. 그러면 서비스는 TxManager 인터페이스에만 의존하고, 원하는 구현체를 DI를 통해 주입하면 된다.

 

스프링에서는 트랜잭션 추상화 기술이 'PlatformTransactionManager'로 이미 있어서 이를 사용하기만 하면 된다.

 

  • 트랜잭션 추상화
    • getTransaction() : 트랜잭션 시작
      • 기존에 이미 진행 중인 트랜잭션이 있으면 해당 트랜잭션에 참여
    • commit()
    • rollback()
  • 리소스 동기화
    • 트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터 베이스 커넥션을 유지해야 한다. 그래서 이전에는 파라미터를 전달하는 방법을 사용했다.
    • 스프링에서는 트랜잭션 동기화 매니저를 제공한다.
      • 트랜잭션 매니저는 내부에서 트랜잭션 동기화 매니저를 사용한다.
      • 쓰레드 로컬을 사용하기 때문에 멀티 쓰레드 상황에 안전하게 커넥션을 동기화 할 수 있다.
      • 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득한다. 이전처럼 파라미터로 커넥션을 넘기지 않아도 된다.

동작 방식

  1. 트랜잭션을 시작하려면 커넥션이 필요한데, 트랜잭션 매니저는 데이터 소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.
  2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 그래서 파라미터로 커넥션을 전달하지 않아도 되는 것이다.
  4. 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고, 커넥션을 닫는다.

 

728x90