🧩 데이터베이스 세션(Session)과 락(Lock)
클라이언트가 데이터베이스 서버와 커넥션을 맺으면, 데이터베이스 서버는 내부에 해당 커넥션만을 위한 세션(Session)을 만든다.
앞으로 해당 커넥션을 통한 모든 요청(트랜잭션 시작, SQL 실행, 커밋, 롤백 등)은 이 세션을 통해 이루어진다.
사용자 → 클라이언트 (WAS, MySQL 워크벤치 등) → { 커넥션 } → 데이터베이스 서버 (세션)
- 사용자가 클라이언트를 통해 SQL을 전달하면, 해당 커넥션에 연결된 세션이 실행한다.
- 커넥션은 오로지 하나의 세션과 연결된다. (커넥션이 10개면 세션도 10개)
- 세션 안에서 트랜잭션을 시작하고, SQL을 실행하고, 커밋 또는 롤백을 하고, 트랜잭션을 종료한다.
- 클라이언트가 커넥션을 닫으면, 데이터베이스 서버의 세션도 종료된다.
🚨 각 커넥션마다 서로 다른 세션이 생성되므로, 여러 세션이 동시에 같은 데이터를 수정하려고 하는 동시성 문제가 발생할 수 있다.
이러한 문제를 방지하려면 한 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안, 다른 세션은 해당 세션이 커밋이나 롤백하기 전까지는 같은 데이터를 수정하지 못하도록 막아야 한다. 이를 위한 데이터베이스의 메커니즘이 바로 락(Lock)이다.
🔒 한 세션이 특정 데이터에 대한 락을 획득하면, 다른 세션은 해당 락이 해제될 때까지 대기해야 한다.
이 메커니즘을 통해 데이터베이스는 데이터 변경 작업의 원자성을 보장한다.
일반적으로 데이터를 조회(SELECT)할 때는 락을 획득하지 않고도 바로 조회할 수 있다.
조회할 때도 락을 획득하려면 SELECT ... FOR UPDATE 구문을 사용하면 된다.
✔️ 락 획득 / 대기 예시: 세션A와 세션B가 거의 동시에 데이터를 수정하려 한다고 가정하자.
| 세션A | 데이터 (값, 락) | 세션B |
| - | 1000 🔒 | - |
| 트랜잭션 시작 | 1000 🔒 | - |
| 락 획득 🔒 | 1000 | 트랜잭션 시작 |
| SQL 실행 (500으로 수정) 🔒 | 1000 | 락 대기 |
| 트랜잭션 커밋 / 종료 🔒 | 500 | 락 대기 |
| 락 반납 | 500 🔒 | 락 대기 |
| - | 500 | 락 획득 🔒 |
락 대기 시 타임아웃 시간을 초과하면 락 타임아웃 오류가 발생한다.
🧩 자동 커밋과 수동 커밋
한 세션(커넥션)에서 트랜잭션을 사용하려면 우선 자동 커밋과 수동 커밋을 이해해야 한다.
자동 커밋은 말그대로 하나의 쿼리 실행 직후에 데이터베이스가 자동으로 커밋한다.
개발자가 직접 트랜잭션을 시작하고 커밋이나 롤백을 호출하는 번거로운 과정을 데이터베이스가 자동으로 해 준다.
하지만 여러 쿼리를 실행하는 비즈니스 로직에서 트랜잭션을 제대로 사용하려면 자동 커밋을 비활성화하여 수동 커밋으로 전환해야 한다.
보통은 자동 커밋 모드가 기본으로 설정되어 있으므로, 수동 커밋 모드로 전환하는 것을 '트랜잭션을 시작한다'라고 표현할 수 있다.
🔧 자동 커밋 비활성화 (수동 커밋) = 트랜잭션 시작
⚠️ 주의점
- 커넥션을 수동 커밋 모드로 전환할 경우, 개발자가 반드시 커밋 또는 롤백을 직접 호출해야 한다.
- 커넥션의 커밋 모드는 해당 세션에서 계속 유지된다.
- 커넥션 풀을 사용할 경우, 수동 커밋 모드로 전환한 커넥션은 다시 원래의 값인 자동 커밋 모드로 원복하는 것이 안전하다.
🧩 애플리케이션에 트랜잭션 적용 시 문제점: 커넥션 동기화
트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다.
왜냐하면 비즈니스 로직에 문제가 있을 때, 해당 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.
그런데 트랜잭션을 시작하려면 커넥션이 필요하다.
결국 서비스 계층에서 커넥션을 만들고, 트랜잭션을 시작하고, 비즈니스 로직 수행하고, 커밋 또는 롤백하고, 커넥션 종료까지 해야 한다.
1. 커넥션 생성
2. 트랜잭션 시작
3. 비즈니스 로직 수행
4. 성공 시 커밋, 실패 시 롤백
5. 커넥션 종료
👉 이 말은 즉, 하나의 트랜잭션(비즈니스 로직)이 실행되는 동안에는 처음부터 끝까지 동일한 커넥션을 사용해야 한다는 뜻이다!
애플리케이션에서 같은 커넥션을 유지하기 위한 방법 중 하나는 바로 파라미터를 이용하는 것이다.
서비스 계층에서 커넥션을 생성하고, 트랜잭션을 시작한 뒤, 이 커넥션을 파라미터로 모든 리포지토리 메서드에 전달하면 된다.
서비스 로직 흐름:
- 서비스 계층에서 DataSource를 통해 커넥션(Connection 객체)을 획득한다.
- 커넥션의 자동 커밋 모드를 비활성화한다. (con.setAutoCommit(false)) → 이것이 트랜잭션의 시작이다.
- 비즈니스 로직을 수행하는 동안, 동일한 커넥션 객체를 리포지토리로 계속 전달한다.
- 모든 로직이 성공하면 con.commit()을 호출한다.
- 도중에 예외가 발생하면 con.rollback()을 호출한다.
- finally 블록에서 반드시 커넥션을 원래 상태(setAutoCommit(true))로 되돌리고 반납(con.close())한다.
@RequiredArgsConstructor
public class MemberServiceV2 {
private final DataSource dataSource;
private final MemberRepositoryV2 memberRepository;
// 계좌 이체 (fromMember → toMember)
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
// 1. 커넥션 획득
Connection connection = dataSource.getConnection();
try {
// 2. 트랜잭션 시작 (자동 커밋 비활성화)
connection.setAutoCommit(false);
// 3. 비즈니스 로직 수행
doLogic(connection, fromId, toId, money);
// 4.1. 성공 시 커밋
connection.commit();
} catch (Exception e) {
// 4.2. 실패 시 롤백
connection.rollback();
throw new IllegalStateException(e);
} finally {
// 5. 커넥션 반납
releaseConnection(connection);
}
}
// 비즈니스 로직
private void doLogic(Connection connection, String fromId, String toId, int money) throws SQLException {
// 조회 (커넥션 유지)
Member fromMember = memberRepository.findById(connection, fromId);
Member toMember = memberRepository.findById(connection, toId);
// 수정 (커넥션 유지)
memberRepository.update(connection, fromId, fromMember.getMoney() - money);
if (toMember.getMemberId().equals("ex")) { // 롤백 테스트용
throw new IllegalStateException("이체 중 예외 발생");
}
memberRepository.update(connection, toId, toMember.getMoney() + money);
}
// 커넥션 종료
private void releaseConnection(Connection connection) {
if (connection != null) {
try {
// 커넥션 풀에 반납해야 하므로, 자동 커밋 다시 활성화 (원복)
connection.setAutoCommit(true);
// 커넥션 풀에 반납
connection.close();
} catch (Exception e) {
log.error("error", e);
}
}
}
}
- connection.setAutoCommit(false / true) → 자동 커밋 모드 비활성화(트랜잭션 시작) / 활성화
- connection.commit() → 트랜잭션 커밋
- connection.rollback() → 트랜잭션 롤백
- connection.close() → 커넥션 반납
@RequiredArgsConstructor
public class MemberRepositoryV2 {
private final DataSource dataSource;
// 등록
public Member save(Member member) throws SQLException { ... }
// 조회
public Member findById(String memberId) throws SQLException { ... }
// 조회 (커넥션 유지)
public Member findById(Connection connection, String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
// Connection connection = null; // 제거: 파라미터로 넘어온 커넥션 사용
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
// connection = getConnection(); // 제거: 파라미터로 넘어온 커넥션 사용
statement = connection.prepareStatement(sql);
statement.setString(1, memberId);
resultSet = statement.executeQuery();
if (resultSet.next()) {
return new Member(
resultSet.getString("member_id"),
resultSet.getInt("money")
);
} else {
throw new NoSuchElementException("member not found memberId = " + memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
JdbcUtils.closeResultSet(resultSet);
JdbcUtils.closeStatement(statement);
// JdbcUtils.closeConnection(connection); // 커넥션을 유지해야 하므로, 닫으면 안 됨
}
}
// 수정
public void update(String memberId, int money) throws SQLException { ... }
// 수정 (커넥션 유지)
public void update(Connection connection, String memberId, int money) throws SQLException {
String sql = "update member set money = ? where member_id = ?";
PreparedStatement statement = null;
try {
statement = connection.prepareStatement(sql);
statement.setInt(1, money);
statement.setString(2, memberId);
// 쿼리 실행 후 영향 받은 row 수 반환
int result = statement.executeUpdate();
log.info("ressult = {}", result);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
JdbcUtils.closeStatement(statement);
}
}
// 삭제
public void delete(String memberId) throws SQLException { ... }
...
}
- 기존의 리포지토리 코드에서 커넥션 유지를 위한 두 메서드를 추가했다.
- findById(Connection connection, String memberId)
- update(Connection connection, String memberId, int money)
- ⚠️ 주의: 해당 메서드는 커넥션 유지를 위해 파라미터로 넘어온 커넥션만 사용해야 하며, 해당 커넥션을 종료해서는 안 된다.
- 해당 커넥션은 리포지토리에서만 사용하는 것이 아니라, 서비스 계층에서도 뒤이어서 사용해야 한다.
- 커넥션은 서비스 계층에서 트랜잭션을 종료하고 나서 닫아야 한다.
테스트 코드 예시:
class MemberServiceV2Test {
private MemberServiceV2 memberService;
private MemberRepositoryV2 memberRepository;
@BeforeEach
void setUp() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV2(dataSource);
memberService = new MemberServiceV2(dataSource, memberRepository);
}
@AfterEach
void tearDown() throws SQLException {
memberRepository.delete("memberA");
memberRepository.delete("memberB");
memberRepository.delete("ex");
}
@Test
void 정상_이체() throws SQLException {
Member memberA = new Member("memberA", 10000);
Member memberB = new Member("memberB", 10000);
memberRepository.save(memberA);
memberRepository.save(memberB);
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 5000);
Member foundMemberA = memberRepository.findById(memberA.getMemberId());
Member foundMemberB = memberRepository.findById(memberB.getMemberId());
assertThat(foundMemberA.getMoney()).isEqualTo(5000);
assertThat(foundMemberB.getMoney()).isEqualTo(15000);
}
@Test
void 이체_중_예외_발생() throws SQLException {
Member memberA = new Member("memberA", 10000);
Member memberEx = new Member("ex", 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
assertThatThrownBy(() ->
memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 5000)
).isInstanceOf(IllegalStateException.class);
Member foundMemberA = memberRepository.findById(memberA.getMemberId());
Member foundMemberB = memberRepository.findById(memberEx.getMemberId());
assertThat(foundMemberA.getMoney()).isEqualTo(10000); // 롤백되어야 함
assertThat(foundMemberB.getMoney()).isEqualTo(10000);
}
}
🚨 파라미터를 이용한 커넥션 동기화의 문제점
커넥션 객체를 서비스 계층에서 리포지토리 계층으로 파라미터를 통해 전달함으로써 커넥션 동기화를 해결했다.
하지만 이 방식은 다음과 같은 문제점이 존재한다.
- 서비스 계층 코드가 매우 복잡해진다. (비즈니스 로직보다 커넥션 및 트랜잭션 코드가 더 길다.)
- 커넥션 객체가 여러 계층을 떠돌게 한다. (커넥션 객체를 파라미터로 받는 리포지토리 메서드를 추가해야 한다.)
스프링은 이러한 문제들을 해결하기 위해 더 정교한 트랜잭션 관리 방법을 제공한다.
'Spring DB' 카테고리의 다른 글
| [Spring DB] JdbcTemplate (0) | 2025.10.02 |
|---|---|
| [Spring DB] 스프링의 예외 추상화 (DataAccessException), 예외 변환기 (SQLExceptionTranslator) (0) | 2025.09.23 |
| [Spring DB] 스프링의 트랜잭션 추상화 – 트랜잭션 매니저, 동기화 매니저, 트랜잭션 템플릿, @Transactional (0) | 2025.09.23 |
| [Spring DB] 커넥션 풀, DataSource (1) | 2025.09.22 |
| [Spring DB] JDBC (Java Database Connectivity) (0) | 2025.09.22 |