4 분 소요

Controller, Service, Repository으로 분리해서 개발할 때 Service 계층에는 비즈니스 로직만 남겨두고 특정 기술에 종속
된 코드를 작성하지 않는게 좋다. 그래야 추후에 다른 기술을 사용하게 됐을 때 Service 계층에 있는 코드는 변경없이 그대로 남겨
둘 수 있기 때문이다. 하지만 기술적인 이유로 처음부터 Service 계층에 비즈니스 로직만 남기도록 설계할 수는 없었고, 이후 여러
가지 방법을 적용하면서 비즈니스 로직만 남길 수 있도록 점차 발전하면서 지금의 스프링이 있게 됐다. 처음부터 그렇게 설계할 수 없
었던 이유는 트랜잭션 처리을 처리하는 코드가 포함됐기 때문이다.

트랜잭션(Transaction)

트랜잭션은 DB 상태를 변화시키는 하나의 단위이다. 일을 하나하나 처리하면 각각의 결과가 따로 DB에 처리되는데 때에 따라서는
문제가 될 수 있다. 은행에서 계좌이체를 하면 사람A는 사람B에게 송금함으로써 두 사람의 계좌에서 입출금이 동시에 이뤄져야 한다.
입금과 출금이 따로 처리(=다른 트랜잭션에서 처리)된다면 한 트랜잭션에서 장애가 났을 때 계좌이체라는 서비스를 신뢰할 수 없게 된
다. 그러므로 이러한 서비스는 한 트랜잭션 내에서 처리돼야 하며, 문제가 생겼을 경우 처리하기 이전의 상태로 돌아갈 수 있게하는
로직이 필요하다. 이걸 롤백(Rollback)이라 한다.

SQLException

accountTransfer()는 Service 계층에 있는 함수로, 내부만 봤을 땐 특정 기술에 종속된 코드가 없고 비즈니스 로직만 있어
깔끔한 상태다. 하지만 SQLException을 던지고 있기 때문에 JDBC 기술에 의존한다는 점을 확인할 수 있다. JDBC 기술에 의존
한다는 건 다른 기술로 바뀌었을 때 코드도 수정해야 한다는 점을 의미한다. 그리고 update() 를 2번 호출하는데 같은 트랜잭션
내에서 처리된다는걸 보장해주지 않고 있어 한쪽만 수행되거나 아예 수행이 안될 수 있다는 문제를 갖고 있다.

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);
  memberRepository.update(toId, toMember.getMoney() +  money);
}
  • SQLException을 처리하는 로직은 다음글에서 소개

JDBC 트랜잭션 코드

비즈니스 로직을 bizLogic()으로 따로 정의했다. 이전 코드에 비해서 트랜잭션 처리를 위한 코드가 많아지면서 지저분해졌고,
트랜잭션 처리를 위해 DataSource, Connection같은 JDBC 기술에 의존해야 한다. 드디어 한 트랜잭션 내에서 처리됨을
보장할 수 있지만 유지보수가 어렵다는 단점이 있다.

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

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

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


트랜잭션 추상화

특정 기술에 의존한다는 건 다른 기술로 교체할 때 코드도 수정해야 한다고 언급했었다. 그런데 이 기술들이 같은 인터페이스를 써서
사용하는 메소드를 강제한다면 코드를 수정하지 않고 구현체만 바꿔줘도 된다는 장점이 있다. 그래서 스프링은 여러 트랜잭션 클래스들
PlatformTransactionManager 인터페이스를 구현해야만 사용할 수 있도록 만들어 두었다.

public interface PlatformTransactionManager extends TransactionManager {
  TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
          throws TransactionException;
  void commit(TransactionStatus status) throws TransactionException;
  void rollback(TransactionStatus status) throws TransactionException;
}


리소스 동기화

같은 트랜잭션 내에서 처리되는 모든 로직은 같은 커넥션을 사용해야 한다. 그래서 JDBC 트랜잭션 코드에서는 con을 파라미터로
넘겨주고 있는걸 확인할 수 있다. 하지만 con이 필요하지 않은 로직은 파라미터를 추가하지 않아도 된다는 복잡성이 있고 일단 파라
미터로 추가한다는 것부터 코드가 지저분해진다.

그래서 스프링은 트랜잭션 동기화 매니저를 사용한다. 트랜잭션을 관리하는 트랜잭션 매니저트랜잭션 동기화 매니저를 통해
같은 트랜잭션이 같은 커넥션을 사용하도록 보장한다. 정확히는 트랜잭션 매니저가 DataSource를 통해 만든 커넥션을 트랜잭션
동기화 매니저에 저장하고 repository는 커넥션이 필요할 때마다 트랜잭션 동기화 매니저에서 저장했던 커넥션을 꺼내서 사용한
다. 이렇게 처리함으로써 이전처럼 파라미터로 커넥션을 넘겨주지 않아도 된다. 트랜잭션을 종료하고 싶으면 트랜잭션 매니저
트랜잭션 동기화 매니저에 저장된 커넥션을 불러와 트랜잭션 종료 후 커넥션을 닫는다.

private Connection getConnection() throws SQLException {
    //트랜잭션 동기화
    Connection con = DataSourceUtils.getConnection(dataSource);
    return con;
}
  • DataSourceUtils.getConnection(DataSource) : 트랜잭션 동기화 매니저에서 커넥션을 가져와 반환
  • 파라미터로 커넥션을 전달받지 않고 getConnection()으로 커넥션을 얻음

커넥션 종료

DataSourceUtils.releaseConnection()를 호출해서 커넥션을 닫게 되는데, 호출했다고 바로 커넥션을 닫지 않는다. 트랜잭
션 안에서 해당 커넥션을 바로 닫아 종료했을 때 뒤에 커넥션이 유지돼야 하는 작업이 있다면 문제가 생기기 때문이다. 즉, 트랜잭션
이 종료될 때까지 커넥션은 유지돼야 하므로 트랜잭션을 사용하기 위해 동기화된 커넥션이면 바로 닫지않고 유지해주고, 트랜잭션 동
기화 매니저가 관리하는 커넥션이 없다면 해당 커넥션을 닫는다.

트랜잭션 템플릿(TransactionTemplate)

첫 번째 코드는 비즈니스 로직을 수행 후 성공적으로 완료했다면 commit을, 그렇지 않으면 rollback을 해주고 있다. 항상 DB에
반영하는 작업인 경우 트랜잭션을 거쳐야 하기 때문에 항상 commit과 rollback 함수를 반복적으로 작성해야 한다. 이런 수고로움
을 덜기위해 트랜잭션 템플릿을 사용한다. 두 번째 코드를 보면 commit, rollback에 대한 코드를 작성하지 않는다.

//트랜잭션 템플릿 미적용
try {
  bizLogic(toId, fromId, money);
  transactionManager.commit(status);
} catch (Exception e) {
  transactionManager.rollback(status);
  throw new IllegalStateException(e);
}

//트랜잭션 템플릿 적용
txTemplate.executeWithoutResult((status) -> {
    try {
      bizLogic(toId, fromId, money);
    } catch (SQLException e) {
      throw new IllegalStateException(e);
    }
});
  • 하지만 위 코드는 Service 로직에 해당되며 트랜잭션을 처리 로직이 포함돼 있음
  • 비즈니스 로직과 트랜잭션 처리 로직이 같이 있으면 유지보수가 어려움

트랜잭션 AOP

Service 로직은 순수한 비즈니스 로직만 있어야 하는데 아직 목표를 달성하지 못했다. 이러한 문제점을 스프링 AOP를 통해 프록시
를 도입하면 해결할 수 있다! 프록시는 비즈니스 로직을 수행하기 위해(=Service 로직을 수행하기 위해) 위에 있는 트랜잭션 처리
로직을 알아서 수행한다. 즉, 트랜잭션 처리 로직을 작성해 비즈니스 로직과 같이 적용할 필요가 없기 때문에 순수한 비즈니스 로직만
남겨둘 수 있고, 유지보수성이 증가한다는 장점이 있다!

//트랜잭션 AOP 미적용
public void logic() {
  txTemplate.executeWithoutResult((status) -> {
      try {
        bizLogic(toId, fromId, money);
      } catch (SQLException e) {
        throw new IllegalStateException(e);
      }
  });
}

//트랜잭션 AOP 적용
@Transactional
public void logic() {
  bizLogic(toId, fromId, money);
}
  • @Transactional : 트랜잭션 처리가 필요한 곳에 붙여주면 됨 (스프링 AOP)

스프링 부트의 자동 리소스 등록

스프링 부트는 application.properties에 적은 내용을 참고해 DataSource트랜잭션 매니저를 알아서 등록해준다.
다음은 H2 데이터베이스를 사용할 때 예시이다.

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=

카테고리:

업데이트:

댓글남기기