트랜잭션에 대해 알아보고, 적용해보자

Hanjmo·2023년 8월 1일
0

트랜잭션이란?

데이터를 단순히 파일이 아니라 데이터베이스에 저장하는 이유는 데이터베이스가 트랜잭션을 지원하기 때문이다.

트랜잭션은 이름 그대로 번역하면 ‘거래’라는 뜻으로, 데이터베이스에서 하나의 거래를 안전하게 보장해주기 때문에 이러한 이름이 붙여진듯하다.

💰 트랜잭션 == 계좌 이체

트랜잭션을 이해하기 위해서 대부분의 사람들은 계좌이체로 예를 든다.

A, B라는 사람이 있고 각자 3000원을 가지고 있다고 가정하자.

A가 B에게 가진 돈 3000원을 모두 이체하는 경우 아래와 같이 두 가지 작업으로 나뉘게 된다.

  1. A의 돈이 3000원 감소한다.
  2. B의 돈이 3000원 증가한다.

만약 두 개의 작업 중 하나라도 실패하게 된다면, A의 돈이 사라졌는데 B는 3000원 그대로 있는 등 상상도 하기 싫은 문제가 발생한다.

이러한 문제를 해결하기 위해서 트랜잭션을 사용한다.

트랜잭션을 사용하면 두 작업이 모두 성공해야 저장하고, 하나의 작업이 실패해도 그 전의 상태로 되돌릴 수 있다.

이때 전자는 Commit, 후자는 Rollback을 수행한다고 말한다.

⚛️ ACID

트랜잭션은 ACID를 보장해야 하며, ACID는 아래 네 가지 속성을 말한다.

  • Atomicity(원자성)
    트랜잭션 내에서 실행한 모든 작업은 마치 하나의 작업인 것처럼 모두 성공하거나, 모두 실패해야 한다.

  • Consistency(일관성)
    트랜잭션은 일관된 데이터베이스 상태를 유지해야 한다. 즉, 데이터베이스의 무결성 제약 조건을 만족해야 한다.

  • Isolation(격리성)
    트랜잭션은 동시에 실행되는 다른 트랜잭션에 영향을 미치지 않도록 격리되어야 한다. 동시성 이슈때문에 격리 수준은 4가지 단계로 나누었는데, 이에 대해서는 나중에 알아보자.

  • Durability(지속성)
    트랜잭션은 성공적으로 끝나면 그 결과가 항상 기록되어야 한다. 그래야 중간에 문제가 발생하면 성공한 트랜잭션 내용을 복구할 수 있기 때문이다.

데이터베이스 구조와 세션

클라이언트는 WAS나 DB 접근 툴을 사용해서 데이터베이스 서버에 연결을 요청하고, 커넥션을 연결한다.

이때 DB 서버는 세션을 내부에 만들어내고, 이 세션을 통해서 모든 요청을 실행한다.

세션은 트랜잭션을 시작하고나서 커밋 또는 롤백을 통해 종료하며, 이후에도 새로운 트랜잭션을 다시 시작할 수도 있다.

각각의 커넥션마다 세션이 새롭게 만들어지며, 만약 사용자가 커넥션을 닫으면 해당 세션도 종료된다.

트랜잭션 사용 방법 (feat. 계좌이체)

계좌이체 예시를 통해 트랜잭션을 어떻게 사용하는지 알아보자.

가운데 상단은 실제 테이블의 모습이고, 아래는 각 세션의 조회 결과를 나타낸다.

세션1에서 set autocommit false를 통해 트랜잭션을 시작하고 memberA의 돈 2000원을 memberB에게 이체하는 쿼리를 수행했을 때, commit을 하지 않았다면 다음과 같이 세션2는 어떤 변화도 없는 결과를 보게 될 것이다.

후에 세션1이 commit을 수행하면 세션1의 작업이 실제 테이블에 반영되고, 세션2도 세션1과 같은 조회 결과를 확인할 수 있게 된다.

만약 세션1이 memberA의 돈을 2000원 감소시키는 쿼리는 성공적으로 수행했지만, memberB의 돈을 2000원 증가시키는 쿼리를 실패하는 경우 commit을 해버리게 되면 다음과 같이 memberA의 돈만 사라지는 문제가 발생한다.

따라서 세션1은 데이터 수정 도중에 문제가 발생하는 경우 commit 대신 rollback을 수행하여 트랜잭션 시작 시점으로 복구해야 한다.

DB Lock

한 세션이 트랜잭션을 시작하고, 데이터를 수정했는데 아직 commit을 하지 않았다고 가정하자.

이때 다른 세션에서 동시에 데이터를 수정하게 되면, 트랜잭션의 원자성이 깨지면서 데이터가 이상하게 수정되는 문제가 발생할 것이다.

이 문제를 해결하기 위해서 트랜잭션을 시작하고 데이터를 수정하는 동안에는 다른 세션에서 접근하지 못하도록 막아주는 DB Lock이라는 개념을 고안했다.

🔎 DB Lock 동작 방식

  1. 세션1에서 트랜잭션을 시작하여 데이터를 수정하게 되면 락을 획득한다. 이때 단순 조회의 경우 락을 획득하지 않고도 조회할 수 있다. 하지만 특정 상황에서 조회할 때도 락을 획득하고 싶다면 SELECT FOR UPDATE 구문을 사용해서 획득할 수 있다.

  2. 도중에 세션2에서 트랜잭션을 시작하여 데이터를 수정하려 하지만 락이 없어서 대기하게 된다.

  3. 세션1에서 commit을 수행하여 트랜잭션을 종료하면 락을 반납한다.

  4. 대기하고 있던 세션2는 락을 획득하여 트랜잭션을 시작할 수 있게 된다.

여기서 락 타임아웃을 통해 대기 시간을 설정할 수 있으며, 다음의 SQL문을 통해서 설정하면 된다. -> SET LOCK_TIMEOUT <milliseconds>

스프링에 트랜잭션 적용하기

애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 할까?

비즈니스 로직이 잘못되면 해당 로직으로 인해 문제가 발생한 부분을 함께 롤백해야 하기 때문에 비즈니스 로직이 있는 서비스 계층에서 트랜잭션을 시작해야 한다.

이때 트랜잭션을 시작하려면 커넥션이 필요하므로 서비스 계층에서 커넥션을 만들고, 커밋 이후에 커넥션을 종료해야 한다.

애플리케이션에서 같은 커넥션을 유지하려면 어떻게 해야할까?

애플리케이션에서 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 같은 세션을 사용할 수 있다.

따라서 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지한다.

repository 계층에서 Id를 통해 Member를 조회하는 메서드를 보면, 커넥션을 생성하지 않고 파라미터로 받는다.

public Member findById(Connection con, String memberId) throws SQLException {
		String sql = "select * from member where member_id = ?";

		PreparedStatement pstmt = null;
		ResultSet rs = null;

		try {
				pstmt = con.prepareStatement(sql);
				pstmt.setString(1, memberId);

				rs = pstmt.executeQuery();
				if (rs.next()) {
						Member member = new Member();
						member.setMemberId(rs.getString("member_id"));
						member.setMoney(rs.getInt("money"));
						return member;
				} else {
						throw new NoSuchElementException("member not found memberId = " + memberId);
				}
		} catch (SQLException e) {
				log.error("error", e);
				throw e;
		} finally {
				JdbcUtils.closeResultSet(rs);
				JdbcUtils.closeStatement(pstmt);
				// JdbcUtils.closeConnection(con); // 커넥션 종료 X
		}
}

이때 주의할 점은 커넥션을 해당 메서드에서 닫으면 안된다는 것이다. 커넥션을 전달 받은 repository 계층 뿐만 아니라, 이후에도 커넥션을 이어서 사용하기 때문이다. 따라서 서비스 로직이 끝날 때 트랜잭션을 종료하고 커넥션을 닫아야 한다.

다음은 service 계층에 있는 계좌이체 메서드다.

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); // 커넥션 종료
		}
}

커넥션을 여기서 생성해주고, 비즈니스 로직에 커넥션을 파라미터로 전달하는 것을 볼 수 있다.

con.setAutoCommit(false)를 통해 트랜잭션을 시작해주고, 성공시 커밋하고 실패시 예외를 던지는 동시에 롤백을 수행한다.

마지막으로 트랜잭션이 종료되면 커넥션을 닫아준다. (커넥션 풀을 사용하는 경우 커넥션을 종료하는 것이 아니라 풀에 반환한다.)

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

여기서 주의할 점이 한 가지 더 있는데, 커넥션을 종료하기 전에 오토커밋을 true로 변경해야 한다는 것이다.

오토커밋은 기본값이 true이므로, 후에 오토커밋이 되지 않아 문제가 발생할 수도 있기 때문이다.

트랜잭션 덕분에 로직이 실패할 때 데이터를 정상적으로 초기화할 수 있게 되었다.

🥲 남은 문제

하지만 트랜잭션을 사용하는 코드가 꽤 복잡하고, 서비스 계층이 지저분해진다는 문제가 남아있다.

스프링을 사용하면 이 문제를 해결할 수 있으며, 조만간 알아보도록 하겠다.

0개의 댓글