본문 바로가기

국비 교육

2020.11.16 일자 수업 : 트랜잭션 관리

 실습 - 트랜잭션 관리 

 

ProjectDeleteCommand에서는 먼저 작업 테이블에서 다음과 같은 과정으로 세 가지 테이블에서 데이터를 삭제한다.

  • TaskDao.deleteByProjectNo()
    • 이 프로젝트에 대한 작업을 담당하는 팀원이 있는 경우, 이 팀원 정보들을 모두 삭제(TaskDao.deleteByProjectNo)
  • ProjectDao.delete()
    • 프로젝트와 멤버의 관계 테이블에서 해당 프로젝트의 팀원 데이터를 모두 삭제(ProjectDao.deleteMembers)
    • 해당 프로젝트에 대한 데이터를 삭제(ProjectDao.delete)

그런데 문제는 이 두 메서드가 SQL문을 실행하기 위한 SQLSession 객체가 각각 다르다는 점이다. 따라서 두 메서드의 삭제 작업이 모두 완료된 시점에 커밋할 수가 없으며 각각의 삭제 작업에 대해서만 커밋할 수 밖에 없다. 이는 두 메서드가 서로 다른 트랜잭션을 다루므로 발생하는 문제이다.

  @Override
  public int deleteByProjectNo(int projectNo) throws Exception {
    try (SqlSession sqlSession = sqlSessionFactory.openSession(true)) {
      return sqlSession.delete("TaskDao.deleteByProjectNo", projectNo);
    }
  }
  @Override
  public int delete(int no) throws Exception {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      sqlSession.delete("ProjectDao.deleteMembers", no);

      int count = sqlSession.delete("ProjectDao.delete", no);

      sqlSession.commit();
      return count;
    }
  }

이렇게 각각에 대해서 커밋을 하게 되면 delete 메서드를 수행하는 도중 오류가 발생하면 deleteByProjectNo 메서드가 변경한 사항만 유효하고, delete메서드가 수행한 작업은 무효하게 되어 무결성이 깨지게 된다.

 

따라서 DAO에서 트랜잭션을 다루면 하나의 DAO만을 사용해서 작업을 수행하는 경우 문제가 없지만, 이렇게 여러 DAO를 이용해서 작업을 하는 경우에는 각각의 DAO에서 commit을 해야하므로 한 단위로 작업을 할 수 없는 문제가 생긴다. 이 트랜잭션을 제어하고 있는 SqlSession을, DAO 객체 안의 각 메서드 별로 사용되고 있는 것이 원인이다. 

 

따라서 DAO의 각 메서드가 트랜잭션을 통제하지 않고, 각 DAO에게 작업을 수행시키는 Command 객체에서 통제하도록 해야 한다. 즉, Command 객체에서 하나의 SqlSession을 사용하여 각각의 DAO에게 넘겨주고, 모든 작업이 완료되면 이 객체에 대하여 commit을 호출하거나, 오류가 발생하면 rollback을 하도록 해야 한다.

 

그렇다면 Command는 SqlSession 객체를 어디서 받아서 어떻게 관리해야 할까? 우리는 하나의 SqlSessionFactory를 모든 Dao가 공유하여 사용하고 있다. 이것을 이제 DAO가 아닌 Command가 제어하고, 이 SqlSessionFactory 안에서 SqlSession 객체를 하나만 생성하여 관리할 수 있도록 할 것이다. 이 때 사용하게 될 디자인 패턴이 Proxy 디자인 패턴으로, 기존 클래스를 손대지 않고 기능을 변경할 수 있는 방법 중 하나이다. 

 


Proxy 디자인 패턴이란?

실제 기능을 수행하는 객체 대신 가상의 객체를 사용해 로직의 흐름을 제어하는 디자인 패턴이다. 기존의 객체가 원래 갖는 기능 외의 부가적인 작업을 수행하기에 좋다. 사용자의 입장에서는 기존의 객체와 사용법이 크게 다르지 않기 때문에 편리하게 사용 가능하다.

 

특정 인터페이스 구현체에 기능을 추가하기 위해 이 구현체를 사용하는 또 다른 구현체를 만들고, 이 새 구현체에는 부가적인 기능을 추가한다. 즉, 원래의 핵심 기능은 기존의 구현체가 계속 하되, 추가기능만 새로운 구현체에 두고, 이 새 구현체가 기존의 구현체를 사용함으로써 마치 새 구현체가 기존 구현체의 역할까지 모두 하는 것처럼 디자인한다.

 

이 디자인 패턴은  Spring Framework에 자주 사용된다.


 

이 Proxy 디자인 패턴에 따라 우선 SqlSessionFactory 객체에 SqlSession을 한 개만 생성하여 관리하는 기능을 추가하기 위해 새로운 SqlSessionFatory 구현체인 SqlSessionFactoryProxy라는 클래스를 만든다.

 

이 클래스가 SqlSessionFacotry를 구현하려면 여기에 있는 모든 추상 메서드를 구현해야 하는데, 이클립스에서 generate delegate methods라는 기능이 있어, 이것을 사용하면 각 추상 메서드를 바로 구현하고 몸체에서 그 클래스 안에 있는 다른 구현체에 대하여 이 메서드를 호출하는 메서드를 모두 만들어준다.

public class SqlSessionFactoryProxy implements SqlSessionFactory {
  SqlSessionFactory original;
  
  public SqlSessionFactoryProxy (SqlSessionFactory original) {
    this.original = original;
  }  
  
  @Override
  public SqlSession openSession() {
    return original.openSession();
  }

  @Override
  public SqlSession openSession(boolean autoCommit) {
    return original.openSession(autoCommit);
  }

  @Override
  public SqlSession openSession(Connection connection) {
    return original.openSession(connection);
  }

  @Override
  public SqlSession openSession(TransactionIsolationLevel level) {
    return original.openSession(level);
  }

  @Override
  public SqlSession openSession(ExecutorType execType) {
    return original.openSession(execType);
  }

  @Override
  public SqlSession openSession(ExecutorType execType, boolean autoCommit) {
    return original.openSession(execType, autoCommit);
  }

  @Override
  public SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level) {
    return original.openSession(execType, level);
  }

  @Override
  public SqlSession openSession(ExecutorType execType, Connection connection) {
    return original.openSession(execType, connection);
  }

  @Override
  public Configuration getConfiguration() {
    return original.getConfiguration();
  }
}

이렇게 하면 기존의 구현체가 하던 역할을 이 새로운 구현체가 그대로 수행할 수 있게 된다. 

 

이제 이 클래스에서 기능을 더 추가해보자.

  • inTransaction : 트랜잭션의 시작 여부를 확인하기 위한 필드
  • currentSqlSession : SqlSession을 한번이라도 생성했다면 그것을 저장하여 두고두고 사용하기 위한 필드
  • startTransaction() : 트랜잭션을 시작했다는 신호를 주기 위해 inTransaction 값을 true로 바꾸는 메서드
  • endTransaction() : 트랜잭션이 끝났다는 신호를 주기 위해 inTransaction을 false로 지정하고, currentSqlSession을 null로 비워주는 메서드
  • openSession() : 트랜잭션의 시작 여부를 확인하고, 시작된 상태라면 새로 auto-commit이 false인 SqlSession을 생성하여currentSqlSession 안에 객체를 저장한 후, 리턴하고, 트랜잭션이 시작되지 않은 상태라면 auto-commit이 true인 SqlSession을 생성하여 그대로 리턴하는 메서드
  • commit() : currentSqlSession에 객체가 저장된 경우에 한해서, commit()을 호출하고, 트랜잭션을 끝내는 메서드
  • rollback() : currentSqlSession에 객체가 저장된 경우에 한해서, rollback()을 호출하고, 트랜잭션을 끝내는 메서드
public class SqlSessionFactoryProxy implements SqlSessionFactory {
  SqlSessionFactory original;
  boolean inTransaction = false;
  SqlSession currentSqlSession;
  
  public SqlSessionFactoryProxy (SqlSessionFactory original) {
    this.original = original;
  }

  public void startTransaction() {
    inTransaction = true;
  }
  
  @Override
  public SqlSession openSession() {
    if (inTransaction) {
      currentSqlSession =  original.openSession();
      return currentSqlSession;
    } else {
      return original.openSession(true);
    }
  }
  
  public void endTransaction() {
    inTransaction = false;
    currentSqlSession = null;
  }
  
  public void commit() {
    if (currentSqlSession != null) {
      currentSqlSession.commit();
    }
    endTransaction();
  }
  
  public void rollback() {
    if (currentSqlSession != null) {
      currentSqlSession.rollback();
    }
    endTransaction();
  }

  @Override
  public SqlSession openSession(boolean autoCommit) {
    return original.openSession(autoCommit);
  }

  @Override
  public SqlSession openSession(Connection connection) {
    return original.openSession(connection);
  }

  @Override
  public SqlSession openSession(TransactionIsolationLevel level) {
    return original.openSession(level);
  }

  @Override
  public SqlSession openSession(ExecutorType execType) {
    return original.openSession(execType);
  }

  @Override
  public SqlSession openSession(ExecutorType execType, boolean autoCommit) {
    return original.openSession(execType, autoCommit);
  }

  @Override
  public SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level) {
    return original.openSession(execType, level);
  }

  @Override
  public SqlSession openSession(ExecutorType execType, Connection connection) {
    return original.openSession(execType, connection);
  }

  @Override
  public Configuration getConfiguration() {
    return original.getConfiguration();
  }
  
}

 이제 이렇게 새로운 SqlSessionFactory 구현체를 만들었으니, 이것을 원래의 구현체 대신 사용해보자. AppIniContextListener에서 기존의 구현체 대신 새로운 구현체를 생성한다.

public class AppInitListener implements ApplicationContextListener {
  @Override
  public void contextInitialized(Map<String,Object> context) {
    System.out.println("프로젝트 관리 시스템(PMS)에 오신 걸 환영합니다!");

    try {
      SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryProxy(new SqlSessionFactoryBuilder().build(
          Resources.getResourceAsStream("com/eomcs/pms/conf/mybatis-config.xml")));

이제는 다중의 DAO 객체의 작업을 하나로 묶어 사용해야만하는 메서드들은 트랜잭션을 모두 수동 커밋으로 바꾼다.

  @Override
  public int deleteByProjectNo(int projectNo) throws Exception {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      return sqlSession.delete("TaskDao.deleteByProjectNo", projectNo);
    }
  }

또한 Dao 객체에서 트랜잭션을 제어하지 못하게 수정한다

  @Override
  public int delete(int no) throws Exception {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
      sqlSession.delete("ProjectDao.deleteMembers", no);

      if (100 == 100) {
        throw new Exception("일부러 예외 발생!");
      }

      int count = sqlSession.delete("ProjectDao.delete", no);
      
      //sqlSession.commit(); // 삭제!
      return count;
    }
  }

그리고 여러 DAO에게 작업을 지시하는 Command에서  트랜잭션을 제어하는 코드를 추가한다. 그러려면 Command에서 SqlSessionFactory 객체를 관리해야하므로 이 객체를 저장하는 필드를 만들고, 생성자로 이 객체를 받아 필드에 저장할 수 있게 한다.

public class ProjectDeleteCommand implements Command {
  ProjectDao projectDao;
  TaskDao taskDao;
  SqlSessionFactoryProxy factoryProxy;

  public ProjectDeleteCommand(ProjectDao projectDao, TaskDao taskDao, SqlSessionFactoryProxy sqlSessionFactory) {
    this.projectDao = projectDao;
    this.taskDao = taskDao;
    this.factoryProxy = factoryProxy;
  }

  @Override
  public void execute(Map<String,Object> context) {
    factoryProxy.startTransaction();
    
    System.out.println("[프로젝트 삭제]");
    int no = Prompt.inputInt("번호? ");

    String response = Prompt.inputString("정말 삭제하시겠습니까?(y/N) ");
    if (!response.equalsIgnoreCase("y")) {
      System.out.println("프로젝트 삭제를 취소하였습니다.");
      return;
    }

    try {
      taskDao.deleteByProjectNo(no);

      if (projectDao.delete(no) == 0) {
        System.out.println("해당 번호의 프로젝트가 존재하지 않습니다.");
        return;
      }
      System.out.println("프로젝트를 삭제하였습니다.");
      
      factoryProxy.commit();

    } catch (Exception e) {
      System.out.println("프로젝트 삭제 중 오류 발생!");
      e.printStackTrace();
      factoryProxy.rollback();
    } finally {
      factoryProxy.endTransaction();
    }
  }
}

그리고 우리가 새로 만들었던 SqlSessionFactory를 생성하는 AppInitContextListener에서 이 구현체를 Command 객체의 생성자 파라미터로 넘겨준다.

commandMap.put("/project/delete", new ProjectDeleteCommand(projectDao, taskDao, sqlSessionFactory));

 

DAO 안에서 절대 트랜젝션을 제어해서는 안된다는 사실을 꼭 기억해야 한다. 즉, DAO에서 스스로 commit하거나 rollback하면 안된다는 것이다. 트랜잭션의 제어는 DAO에게 작업을 지시하는 주체에서 해주는 것이다. 

 

이렇게 해주었는 데도 실행에서 오류가 뜨는 이유는 다음과 같다. 다음 코드에서 try / resource 문을 사용하였기 때문에 try 구문이 끝나면 자동으로 sqlSession을 닫는 것이다.

  @Override
  public int deleteByProjectNo(int projectNo) throws Exception {
    try (SqlSession sqlSession = sqlSessionFactory.openSession(true)) {
      return sqlSession.delete("TaskDao.deleteByProjectNo", projectNo);
    }
  }

그러나 그렇다고 해서 close를 호출하지 않을 수는 없다. 따라서 Proxy 디자인 패턴을 또 다시 이용하여 새로운 SqlSession 구현체를 정의하고 이 안에서 close 메서드가 호출되어도 SqlSession 객체가 close되지 않도록 수정하며, 진짜 close를 해야하는 상황에 호출할 realClose 메서드를 정의한다.

public class SqlSessionProxy implements SqlSession {
  SqlSession original;
  
  public SqlSessionProxy(SqlSession original) {
    this.original = original;
  }
  
  public void realClose() {
    this.original.close();
  }
  
  @Override
  public void close() {
  }
.
.
.
}

이렇게 해주었다면 우리가 새로 정의한 SqlSessionFactoryProxy에서 기존의 SqlSession을 생성하는 대신 이 프록시 객체를 생성하여 리턴하도록 수정해주면 된다. 

  public SqlSession openSession() {
    if (inTransaction) {

      if (currentSqlSession == null) {
        currentSqlSession = new SqlSessionProxy(original.openSession());
      }
      return currentSqlSession;
    } else {
      return original.openSession(true);
    }
  }