INFLEARN

[스프링 DB 1편 - 데이터 접근 핵심 원리] 3. 트랜잭션 이해

ch010104 2026. 7. 8. 12:43

1. 트랜잭션 - 개념 이해

1.1 데이터베이스 저장과 트랜잭션의 필요성

데이터를 단순히 파일에 저장하지 않고 데이터베이스(DB)에 저장하는 가장 대표적인 이유는 바로 데이터베이스가 트랜잭션(Transaction) 개념을 지원하기 때문입니다.

  • 트랜잭션(Transaction)의 정의: 이름 그대로 '거래'를 뜻하며, 데이터베이스에서는 하나의 거래를 안전하게 처리하도록 보장하는 작업 단위를 의미합니다.
  • 계좌이체 예시를 통해 보는 트랜잭션의 필요성:
    • 상황: A가 B에게 5,000원을 계좌이체합니다.
    • 원하는 작업 흐름:
      1. A의 잔고를 5,000원 감소시킵니다.
      2. B의 잔고를 5,000원 증가시킵니다.
    • 문제 발생 시나리오: 만약 1번 작업은 성공했으나, 2번 작업 직전에 시스템 오류나 네트워크 단절로 인해 실패한다면 어떻게 될까요? A의 돈만 5,000원 사라지고 B는 돈을 받지 못하는 심각한 정합성 문제가 발생합니다.
    • 해결책: 데이터베이스의 트랜잭션 기능을 사용하면 1번과 2번이 모두 성공해야 저장(Commit)하고, 중간에 하나라도 실패하면 거래 전 상태로 완전히 되돌아갈 수 있습니다(Rollback). 결과적으로 A의 잔고는 감소하지 않고 안전하게 복구됩니다.

1.2 커밋(Commit)과 롤백(Rollback)

  • 커밋(Commit): 모든 작업이 성공적으로 완료되어 데이터베이스에 변경 사항을 영구적으로 반영하는 명령입니다.
  • 롤백(Rollback): 작업 중 하나라도 실패하여 트랜잭션 내에서 일어난 모든 변경 사항을 취소하고 거래 이전 상태로 되돌리는 명령입니다.

1.3 트랜잭션의 ACID 성질

트랜잭션은 안전한 처리를 위해 다음 4가지 핵심 원칙(ACID)을 보장해야 합니다.

  1. 원자성 (Atomicity):
    • 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패(All or Nothing)해야 합니다.
  2. 일관성 (Consistency):
    • 모든 트랜잭션은 데이터베이스의 일관성 있는 상태를 유지해야 합니다. 예를 들어, 데이터베이스가 지정한 제약 조건(기본키 무결성, 외래키 무결성 등)을 항상 만족해야 합니다.
  3. 격리성 (Isolation):
    • 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 완전히 격리되어야 합니다. 예를 들어, 동시에 같은 데이터를 수정하지 못하도록 차단해야 합니다. 격리성을 완벽히 보장하려면 순차 실행을 해야 하지만 이는 동시 처리 성능을 극도로 악화시키므로, ANSI 표준은 트랜잭션 격리 수준(Isolation level)을 4단계로 나누어 정의했습니다.
  4. 지속성 (Durability):
    • 트랜잭션을 성공적으로 완료하면 그 결과가 항상 영구적으로 기록되어야 합니다. 중간에 시스템에 물리적 결함이나 전원 차단 문제가 발생해도, 트랜잭션 로그 등을 사용하여 성공한 트랜잭션 내용을 반드시 복구해야 합니다.

1.4 트랜잭션 격리 수준 (Isolation Level)

동시성과 격리성 사이의 성능 타협을 위해 네 단계의 표준 격리 수준이 존재합니다.

  • READ UNCOMMITTED (커밋되지 않은 읽기)
  • READ COMMITTED (커밋된 읽기) - 실무 및 본 실습에서 가장 널리 기준이 되는 수준
  • REPEATABLE READ (반복 가능한 읽기)
  • SERIALIZABLE (직렬화 가능)

2. 데이터베이스 연결 구조와 DB 세션

트랜잭션의 물리적 동작 메커니즘을 이해하기 위해서는 데이터베이스 서버와 클라이언트 간의 연결 구조 및 세션(Session)의 개념을 파악해야 합니다.

2.1 데이터베이스 연결 구조 분석

  1. 커넥션 생성: 사용자는 웹 애플리케이션 서버(WAS)나 DB 접근 툴(Client)을 사용하여 데이터베이스 서버에 연결을 요청하고 물리적인 커넥션(Connection)을 맺습니다.
  2. 세션 생성: 데이터베이스 서버는 커넥션이 수립될 때 내부적으로 전용 세션(Session)을 만듭니다.
  3. SQL 실행: 앞으로 해당 커넥션을 통해 들어오는 모든 SQL 요청은 데이터베이스 내부에서 해당 커넥션과 1:1로 매핑된 세션을 통해 수행됩니다. 세션은 트랜잭션을 시작하고, SQL을 실행하며, 커밋 또는 롤백을 처리하여 트랜잭션을 관리합니다.
  4. 세션 종료: 사용자가 커넥션을 닫거나, DBA(데이터베이스 관리자)가 강제로 세션을 Kill하면 세션이 종료됩니다.

2.2 커넥션 풀과 세션의 관계

만약 커넥션 풀(Connection Pool)에 10개의 커넥션을 미리 생성해 둔다면, 데이터베이스 서버 내부에도 이에 매핑되는 10개의 세션이 생성되어 대기 상태가 됩니다.

3. 트랜잭션 - DB 예제

트랜잭션의 동작을 단계별 예제를 통해 이해해 보겠습니다.

3.1 트랜잭션 기본 규칙

  • 임시 저장 상태: 커밋을 호출하기 전까지 데이터는 오직 해당 트랜잭션을 시작한 세션(사용자)에게만 보입니다. 다른 세션에서는 변경 이전의 기존 데이터만 조회할 수 있습니다.
  • 이유(데이터 정합성): 만약 커밋되지 않은 데이터가 다른 세션에 보인다면, 세션 2는 잘못된 신규 데이터가 존재한다고 믿고 비즈니스 로직을 실행할 것입니다. 이때 세션 1이 작업을 취소하고 롤백해 버리면 세션 2는 가상의 데이터를 기반으로 비즈니스를 처리한 꼴이 되어 데이터 정합성이 깨집니다.

3.2 예제 1 & 2: 자동 커밋 vs 수동 커밋

실습용 Member 테이블 스키마

DROP TABLE member IF EXISTS;
CREATE TABLE member (
    member_id VARCHAR(10),
    money INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (member_id)
);

자동 커밋 (Auto Commit)

쿼리 실행 직후 데이터베이스가 자동으로 COMMIT을 호출하는 모드입니다.

  • 장점: 개발자가 직접 커밋/롤백 명령을 날리지 않아도 되므로 편리합니다.
  • 단점: 쿼리가 실행될 때마다 즉시 확정되므로, 여러 쿼리가 묶여 하나의 작업 단위로 작동해야 하는 트랜잭션 기능을 사용할 수 없습니다.
SET autocommit true; -- 자동 커밋 모드 설정 (기본값인 경우가 많음)
INSERT INTO member (member_id, money) VALUES ('data1', 10000); -- 즉시 커밋 완료
INSERT INTO member (member_id, money) VALUES ('data2', 10000); -- 즉시 커밋 완료

수동 커밋 (Manual Commit)

쿼리를 실행한 후 직접 commit 또는 rollback 명령어를 전달하기 전까지 데이터베이스에 영구 반영되지 않는 모드입니다. "수동 커밋 모드로 설정하는 것"을 대중적으로 "트랜잭션을 시작한다"고 표현합니다.

SET autocommit false; -- 수동 커밋 모드 설정 (트랜잭션 시작)
INSERT INTO member (member_id, money) VALUES ('data3', 10000); -- 임시 저장 상태
INSERT INTO member (member_id, money) VALUES ('data4', 10000); -- 임시 저장 상태
COMMIT; -- 영구 반영 (수동 커밋)

3.3 예제 3: 세션 분리를 통한 트랜잭션 실습

실제 동작 흐름을 파악하기 위해 H2 데이터베이스 웹 콘솔 창을 2개(각각 세션 1, 세션 2) 열어두고 교차 테스트를 진행합니다.

  • 주의: H2 콘솔 창을 열 때는 반드시 새 브라우저 창에 http://localhost:8082를 직접 입력하여 jsessionid가 서로 다른 완전히 개별적인 물리 세션으로 진입해야 합니다.

[Step 1] 기본 데이터 초기화

세션 1에서 자동 커밋 모드로 기본 데이터를 준비합니다.

-- 세션 1에서 실행
SET autocommit true;
DELETE FROM member;
INSERT INTO member (member_id, money) VALUES ('oldId', 10000);
  • 결과: 세션 1과 세션 2에서 SELECT * FROM member;를 실행하면 모두 oldId (10,000원) 데이터를 정상 조회할 수 있습니다.

[Step 2] 신규 데이터 추가 - 커밋 전

세션 1에서 수동 커밋 모드로 전환하고 신규 데이터를 추가합니다.

-- 세션 1에서 실행
SET autocommit false; -- 수동 커밋 모드 전환 (트랜잭션 시작)
INSERT INTO member (member_id, money) VALUES ('newId1', 20000);
INSERT INTO member (member_id, money) VALUES ('newId2', 30000);

이 상태에서 조회(SELECT * FROM member;)를 시도해 봅니다:

  • 세션 1 조회 결과: 본인이 실행한 변경 건이므로 oldId, newId1, newId2 총 3건의 회원 데이터가 모두 임시 상태로 조회됩니다.
  • 세션 2 조회 결과: 세션 1이 아직 커밋하지 않은 상태이므로 기존 데이터인 oldId (1건)만 정상 조회됩니다.

[Step 3-A] 정상 커밋 처리 시나리오

세션 1에서 COMMIT을 선언합니다.

-- 세션 1에서 실행
COMMIT;
  • 결과: 세션 1과 세션 2 모두에서 테이블을 조회하면 신규 회원 newId1, newId2가 데이터베이스에 최종 영구 반영되어 정상 조회됩니다.

[Step 3-B] 롤백 처리 시나리오 (초기화 후 재실습)

데이터를 다시 oldId만 남기고 초기화한 상태에서, 세션 1이 데이터를 삽입하고 롤백을 선언합니다.

-- 세션 1에서 실행
SET autocommit false;
INSERT INTO member (member_id, money) VALUES ('newId1', 20000);
INSERT INTO member (member_id, money) VALUES ('newId2', 30000);

ROLLBACK; -- 트랜잭션 변경 사항을 모두 취소
  • 결과: 세션 1과 세션 2 모두 데이터 추가 직전 상태로 완벽히 원상복구되어 오직 oldId 데이터만 테이블에 남게 됩니다.

3.4 예제 4: 계좌이체 예제를 통한 문제 극복 흐름

이 실습은 계좌이체라는 비즈니스 시나리오에서 트랜잭션 실패 시 데이터 복구가 어떻게 일어나는지 구체적인 데이터를 활용해 검증합니다.

1) 정상 흐름 (정상 커밋)

  • 준비 데이터: memberA (10,000원), memberB (10,000원)
  • 비즈니스: memberA가 memberB에게 2,000원 계좌이체

-- [Step 1] 기본 데이터 세팅
SET autocommit true;
DELETE FROM member;
INSERT INTO member (member_id, money) VALUES ('memberA', 10000);
INSERT INTO member (member_id, money) VALUES ('memberB', 10000);

-- [Step 2] 계좌이체 트랜잭션 시작 (세션 1)
SET autocommit false;
UPDATE member SET money = 10000 - 2000 WHERE member_id = 'memberA'; -- A의 돈 2,000원 감소
UPDATE member SET money = 10000 + 2000 WHERE member_id = 'memberB'; -- B의 돈 2,000원 증가

-- [Step 3] 최종 커밋
COMMIT;
  • 최종 결과: memberA는 8,000원, memberB는 12,000원으로 정상 반영되며 다른 세션에서도 확인 가능합니다.

2) 오류 흐름 - 트랜잭션이 없는 경우 (또는 도중에 강제 커밋한 경우)

  • 상황: A의 돈을 깎는 데는 성공했으나, B의 돈을 늘릴 때 오타(member_iddd)가 있는 잘못된 SQL 실행으로 오류가 발생한 경우.

-- [Step 1] 기본 데이터 초기화
SET autocommit true;
DELETE FROM member;
INSERT INTO member (member_id, money) VALUES ('memberA', 10000);
INSERT INTO member (member_id, money) VALUES ('memberB', 10000);

-- [Step 2] 계좌이체 트랜잭션 시작 (세션 1)
SET autocommit false;
UPDATE member SET money = 10000 - 2000 WHERE member_id = 'memberA'; -- 성공 (A 잔고: 8,000)

-- 아래 SQL은 필드명 오타(member_iddd)로 인해 에러가 발생합니다.
UPDATE member SET money = 10000 + 2000 WHERE member_iddd = 'memberB';
-- 발생 오류: Column "MEMBER_IDDD" not found [42122-200]
  • 최종 조치 실패(강제 커밋): 이 상태에서 수동으로 COMMIT;을 수행해버리면 어떻게 될까요?
    • 결과: 데이터 정합성이 완전히 붕괴되어 memberA는 8,000원으로 줄어들었지만, memberB는 그대로 10,000원으로 유지되어 공중으로 2,000원이 증발해버립니다.

3) 오류 흐름 - 트랜잭션을 통한 완벽 원복 (롤백 적용)

동일한 에러 상황에서 COMMIT 대신 ROLLBACK을 올바르게 호출합니다.

-- 위와 같이 두 번째 UPDATE 쿼리 실패 직후
ROLLBACK; -- 트랜잭션을 시작하기 전 단계로 복구
  • 결과: 원자성(Atomicity)이 보장되어 memberA의 돈은 원상복구된 10,000원, memberB 또한 기존 그대로 10,000원으로 깔끔하게 원복 됩니다.

4. DB 락 (DB Lock) - 개념 이해 및 실습

동시에 여러 세션이 동일한 행(Row)의 데이터를 변경하려고 시도하면 데이터의 신뢰성이 무너집니다. 이를 방지하기 위해 데이터베이스는 락(Lock)이라는 상호 배제 기술을 제공합니다.

4.1 데이터 변경 시 락 메커니즘

  1. 세션 1 트랜잭션 시작: 세션 1이 먼저 특정 로우를 업데이트하기 위해 트랜잭션을 시작하고 UPDATE 쿼리를 실행합니다.

  1. 락 획득: 해당 로우의 변경 권한을 의미하는 '락(Lock)'을 세션 1이 선점(획득)합니다.
  2. 세션 2 대기: 세션 2가 동일한 로우에 대해 UPDATE 변경을 시도합니다. 이때 세션 2는 락이 존재하지 않으므로, 락을 보유한 세션 1이 트랜잭션을 종료하고 락을 반환할 때까지 대기(Wait)합니다.

  1. 타임아웃(Timeout): 세션 2가 너무 오래 대기하면 설정된 락 대기 시간 한도(LOCK_TIMEOUT)에 도달하여 락 타임아웃 오류(Lock Timeout Exception)가 발생합니다.

  1. 커밋 및 락 반납: 세션 1이 COMMIT 또는 ROLLBACK을 선언하여 트랜잭션을 끝내면 락을 반납하고, 대기 중이던 세션 2가 즉시 락을 획득하여 SQL 변경을 진행합니다.

4.2 변경과 락 실습

[Step 1] 기본 데이터 세팅

SET autocommit true;
DELETE FROM member;
INSERT INTO member (member_id, money) VALUES ('memberA', 10000);

[Step 2] 세션 1에서 먼저 데이터 수정 (락 선점)

-- 세션 1에서 실행
SET autocommit false;
UPDATE member SET money = 500 WHERE member_id = 'memberA';
-- 세션 1이 memberA 로우의 락을 보유하고 있는 상태 (커밋 전)

[Step 3] 세션 2에서 동일 로우 수정 시도 (대기 상태 돌입)

-- 세션 2에서 실행
SET LOCK_TIMEOUT 60000; -- 락 타임아웃을 60초로 설정(60초가 지나면, TIMEOUT 에러가 남)
SET autocommit false;
UPDATE member SET money = 1000 WHERE member_id = 'memberA';
-- 세션 1이 아직 트랜잭션을 끝내지 않았으므로 세션 2는 락을 획득하지 못해 블로킹(무한 로딩 대기)에 걸립니다.

[Step 4] 세션 1 커밋을 통한 락 해제 및 세션 2 반영 완료

-- 세션 1에서 실행
-- 세션 1에서 락을 반납하는 순간, 세션 2에서 락을 대기하던 UPDATE 쿼리가 바로 실행됨(TIMEOUT 전일 경우)
COMMIT; -- 커밋 완료와 동시에 락이 즉시 반납됨
  • 결과: 대기 중이던 세션 2는 즉시 락을 획득하고 자신의 UPDATE 문을 수행하여 잔고를 1000으로 바꿉니다. 세션 2도 최종적으로 COMMIT;을 선언하여 트랜잭션을 닫아주어야 안전하게 완전히 반영됩니다.

만약 타임아웃 시간이 지난다면?

세션 1이 커밋을 하지 않고 버티면, 세션 2는 아래와 같은 락 타임아웃 에러를 내며 멈추게 됩니다.

Timeout trying to lock table {0}; SQL statement:
update member set money=1000 where member_id = 'memberA' [50200-200]

4.3 DB 락 - 조회와 락 (SELECT FOR UPDATE)

  • 일반적인 조회: 일반적인 SELECT 쿼리는 데이터 변경이 목적이 아니기 때문에 데이터베이스 격리 수준 설정에 따라 락을 필요로 하지 않고 즉시 자유롭게 원본 데이터를 조회할 수 있습니다.
  • 조회 시점 락 획득의 필요성: 비즈니스 로직 상 데이터를 조회한 뒤 가공하고 저장하기까지의 모든 시간 동안, 다른 곳에서 절대로 이 데이터를 수정하지 못하게 막아야 하는 경우가 있습니다. (예: 민감한 정산 프로세스 등)
  • SELECT ... FOR UPDATE: 조회 시점에 강제로 해당 로우의 락을 선점하고 트랜잭션이 완벽히 끝날 때까지 락을 잡고 있게 하는 구문입니다.
-- 세션 1에서 트랜잭션 시작과 함께 FOR UPDATE 조회 실행
SET autocommit false;
SELECT * FROM member WHERE member_id = 'memberA' FOR UPDATE;
-- 조회와 동시에 세션 1이 memberA 로우의 락을 독점 획득합니다.

-- 세션 2에서 수정 시도 시 블로킹 발생
UPDATE member SET money = 500 WHERE member_id = 'memberA'; -- 세션 1이 커밋할 때까지 대기하게 됨

5. 트랜잭션 - 적용 1: 트랜잭션 없는 단순 애플리케이션 서비스

DB 트랜잭션 없이 단순하게 계좌이체 비즈니스 로직을 구현했을 때 어떤 데이터 정합성 파괴 문제가 발생할 수 있는지 코드로 검증합니다.

5.1 MemberServiceV1 구현체

  • 목적: fromId 회원에게서 money만큼 돈을 차감한 뒤, toId 회원에게 해당 액수만큼 입금합니다.
  • 오류 테스트용 코드: 받는 사람의 ID가 "ex"일 경우, 비즈니스 처리 도중 강제로 예외(IllegalStateException)를 발생시키도록 의도적인 검증 로직(validation)을 삽입했습니다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import lombok.RequiredArgsConstructor;

import java.sql.SQLException;

@RequiredArgsConstructor
public class MemberServiceV1 {

    private final MemberRepositoryV1 memberRepository;

    /**
     * 단순 계좌이체 비즈니스 로직 (트랜잭션 미적용)
     */
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);

        // 1. 보내는 사람의 돈을 감액 (UPDATE SQL 실행)
        memberRepository.update(fromId, fromMember.getMoney() - money);

        // 의도적인 비즈니스 오류 검증 유도
        validation(toMember);

        // 2. 받는 사람의 돈을 증액 (UPDATE SQL 실행)
        memberRepository.update(toId, toMember.getMoney() + money);
    }

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

5.2 MemberServiceV1Test 코드 및 분석

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;

/**
 * 기본 동작 분석 - 트랜잭션이 없어서 비즈니스 실패 시 치명적인 데이터 누수가 일어남을 검증합니다.
 */
class MemberServiceV1Test {

    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";

    private MemberRepositoryV1 memberRepository;
    private MemberServiceV1 memberService;

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

    @AfterEach
    void after() throws SQLException {
        // 테스트 완료 후 DB 데이터 정리
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        // given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);

        // when
        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);

        // then: A 잔고 10000 -> 8000, B 잔고 10000 -> 12000
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberB.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }

    @Test
    @DisplayName("이체중 예외 발생")
    void accountTransferEx() throws SQLException {
        // given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX, 10000); // 오류를 유발할 "ex" 회원 준비
        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        // when
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        // then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());

        // [결과 확인]
        // 트랜잭션이 전혀 적용되지 않았기 때문에, memberA의 돈만 2000원 삭감된 채 비즈니스가 도중 종료되었습니다.
        // A의 잔고는 8,000원이 되었으나 수신인(ex)의 잔고는 그대로 10,000원으로 유지되어 2,000원이 증발했습니다.
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}

6. 트랜잭션 - 적용 2: DB 트랜잭션 적용을 통한 원자성 보장

수동 커밋 제어를 통해 앞서 무너진 원자성(Atomicity) 문제를 근본적으로 해결해 보겠습니다.

6.1 비즈니스 로직과 트랜잭션 계층 구조 설계

  • 트랜잭션 시작의 핵심 계층: 트랜잭션은 반드시 비즈니스 로직이 가동되는 서비스 계층(Service Layer)에서 시작해야 합니다. 비즈니스 로직 중간에 오류가 발생하면, 해당 비즈니스로 인해 실행된 모든 DB 변경 SQL을 함께 일괄 롤백해야 하기 때문입니다.
  • 커넥션 공유 규칙: 서비스 레이어에서 트랜잭션을 시작하려면 Connection 객체가 필요합니다. 트랜잭션 동안 리포지토리의 여러 변경 쿼리가 동일한 세션(Session)에서 묶여 실행되려면, 동일한 커넥션 인스턴스를 유지 및 공유해야 합니다.
  • 해결 방법: 가장 직관적이고 고전적인 해결 기법으로, 서비스단에서 획득한 커넥션 객체(con)를 리포지토리 메서드의 파라미터로 명시 전달하는 기법을 사용합니다.

6.2 MemberRepositoryV2 구현체

파라미터로 이미 시작된 커넥션(Connection con)을 주입받아 동작하고, 커넥션을 임의로 닫지(close) 않는 전용 메서드 2종을 추가 오버로딩 설계합니다.

  • findById(Connection con, String memberId)
  • update(Connection con, String memberId, int money)
package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.support.JdbcUtils;

import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;

/**
 * JDBC ConnectionParam 연동 리포지토리
 */
@Slf4j
public class MemberRepositoryV2 {

    private final DataSource dataSource;

    public MemberRepositoryV2(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Member save(Member member) throws SQLException {
        String sql = "insert into member (member_id, money) values(?, ?)";
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    public Member findById(String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";
        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            con = getConnection();
            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("db error", e);
            throw e;
        } finally {
            close(con, pstmt, rs);
        }
    }

    /**
     * [트랜잭션 동기화용] 외부에서 수립된 커넥션을 파라미터로 받아 작동하는 조회 메서드
     */
    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("db error", e);
            throw e;
        } finally {
            // 중요: 외부에서 건너온 Connection은 여기서 close하지 않습니다.
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
        }
    }

    public void update(String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id=?";
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    /**
     * [트랜잭션 동기화용] 외부에서 수립된 커넥션을 파라미터로 받아 작동하는 수정 메서드
     */
    public void update(Connection con, String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id=?";
        PreparedStatement pstmt = null;

        try {
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            // Connection을 끊지 않고 Statement만 정리합니다.
            JdbcUtils.closeStatement(pstmt);
        }
    }

    public void delete(String memberId) throws SQLException {
        String sql = "delete from member where member_id=?";
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con);
    }

    private Connection getConnection() throws SQLException {
        Connection con = dataSource.getConnection();
        log.info("get connection = {}, class = {}", con, con.getClass());
        return con;
    }
}

6.3 MemberServiceV2 구현체

  • con.setAutoCommit(false);: 자동 커밋을 끄고 수동 커밋 모드로 전환하며 데이터베이스에 트랜잭션이 선언됩니다.
  • bizLogic(): 트랜잭션이 수립된 동일 con 객체를 인자로 넘기며 실질적인 계좌이체 비즈니스 영역을 태웁니다.
  • con.commit() / con.rollback(): 성공과 예외 여부에 맞춰 각각 최종 물리 반영과 전체 변경 사항 원복을 명시적으로 내립니다.
  • con.setAutoCommit(true);: 커넥션 풀을 사용하는 인프라를 고려하여, 커넥션을 재사용하기 전에 반드시 본래의 자동 커밋(기본값인 true) 모드로 돌려놓은 뒤 반납합니다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV2;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

/**
 * 트랜잭션 - 파라미터 연동 및 커넥션 풀 원상태 회수 반환 구현체
 */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {

    private final DataSource dataSource;
    private final MemberRepositoryV2 memberRepository;

    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 validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }

    private void release(Connection con) {
        if (con != null) {
            try {
                // 커넥션 풀 환경을 위한 원 상태 복구 작업 후 close 반납 진행
                con.setAutoCommit(true);
                con.close();
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }
}

6.4 MemberServiceV2Test 코드 및 분석

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV2;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * 트랜잭션 - 커넥션 파라미터 연계를 이용한 예외 시나리오 롤백 검증 테스트
 */
class MemberServiceV2Test {

    private MemberRepositoryV2 memberRepository;
    private MemberServiceV2 memberService;

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

    @AfterEach
    void after() throws SQLException {
        memberRepository.delete("memberA");
        memberRepository.delete("memberB");
        memberRepository.delete("ex");
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        // given
        Member memberA = new Member("memberA", 10000);
        Member memberB = new Member("memberB", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);

        // when
        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);

        // then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberB.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }

    @Test
    @DisplayName("이체중 예외 발생")
    void accountTransferEx() throws SQLException {
        // given
        Member memberA = new Member("memberA", 10000);
        Member memberEx = new Member("ex", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        // when
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        // then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());

        // [검증 결과] 원자성(Atomicity) 충족 및 롤백 확인!
        // 이체 도중 예외가 터지자마자 서비스가 catch 블록으로 진입하여 con.rollback()을 수행했습니다.
        // 이로써 memberA의 감액 이력도 완전히 취소되어 거래 전 초기값인 10,000원으로 완벽하게 복구되었습니다.
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}

7. 요약 정리 및 남은 과제

7.1 최종 실습 핵심 정리

  1. 원자성 보장: 복잡하게 교차하는 계좌이체 예제에 트랜잭션의 개념을 도입함으로써 성공 시 일괄 반영, 실패 시 일괄 취소인 전부 혹은 무(All or Nothing) 속성을 안정적으로 확보했습니다.
  2. 트랜잭션 시작과 오토 커밋 해제: 애플리케이션 상에서 트랜잭션을 수동 구동하는 방법은 데이터베이스 커넥션의 setAutoCommit(false) 지시를 통해 이루어집니다.
  3. 동일 커넥션 생명주기 관리: 하나의 트랜잭션 묶음으로 연결되는 연속된 SQL 쿼리들은 반드시 서비스 계층에서 열려 같은 세션을 지속하는 단일 커넥션을 통해 리포지토리로 전달되어야만 동기화가 성립됩니다.

7.2 수동 커넥션 연동 방식의 심각한 한계와 남은 과제

목적한 정합성 확보에는 완벽하게 도달했으나, 비즈니스 서비스 로직 레이어에 물리적인 영속 기술이 과도하게 유입되어 아키텍처 관점에서 치명적인 결함들이 발생하게 되었습니다.

  1. 서비스 계층 코드의 기하급수적 오염:
    • 순수한 계좌이체 핵심 규칙만 기재되어 있어야 할 서비스 레이어가 try-catch-finally, commit(), rollback(), 풀 보정을 위한 release(con) 등 데이터 인프라스트럭처 제어를 위한 복잡한 지저분한 코드로 가득 찼습니다.
  2. 커넥션 파라미터 전달 폭탄:
    • 수동 연계 흐름을 타는 모든 리포지토리의 조회 및 수정 메서드 시그니처에 커넥션 인자(Connection con)를 억지로 받아내야만 합니다. 이는 전반적인 도메인 소스 코드의 재사용성을 심각하게 무너뜨립니다.
  3. 데이터베이스 인프라 영속 기술의 하드코딩 종속:
    • 비즈니스 최상위 규칙을 결정하는 서비스 클래스가 JDBC 종속적인 javax.sql.DataSource, java.sql.Connection, java.sql.SQLException 등을 직접 import하고 다루고 있습니다.
    • 향후 더 나은 영속 모델인 JPA, Hibernate, MyBatis 등의 기술로 프레임워크를 교체 및 전환할 때 서비스단의 소스코드를 전면적으로 뜯어고쳐야 하는 유지보수의 악순환이 발생합니다.

해결 방안 (Next Chapter): 스프링 프레임워크는 이러한 문제점들을 극복하기 위해 영속 기술 종속을 탈피하는 '트랜잭션 동기화 매니저(Transaction Synchronization Manager)' 및 '트랜잭션 템플릿(TransactionTemplate)'을 지원하며, 나아가 비즈니스 로직에 인프라 코드 오염을 단 한 줄도 주지 않는 선언적 트랜잭션인 AOP 기반의 @Transactional 어노테이션을 제공하여 서비스 계층의 무결한 순수성을 아름답게 되찾아 줍니다.