일반적인 웹 애플리케이션 구조는 다음과 같다.

  • 프레젠테이션 계층 (@Controller)
  • 서비스 계층 (@Service)
  • 데이터 접근 계층 (@Repository)

 

여기서 가장 중요한 곳은 핵심 비즈니스 로직이 있는 서비스 계층으로 비즈니스 로직은 최대한 변경 없이 순수하게 유지되도록 특정 기술에 종속적이지 않게 개발하는 것이 좋다. 서비스 계층을 특정 기술에 종속적이지 않게 개발하기 위해 다른 계층에서 기술에 종속적인 부분을 가지고 가는데 프레젠테이션 계층UI와 관련된 기술인 웹, 서블릿, HTTP와 관련된 부분을 담당하고 데이터 접근 계층JDBC, JPA와 같은 데이터 접근 기술을 담당한다. 서비스 계층은 데이터 접근 계층에 직접 접근하지 않고 인터페이스를 통해 접근하면 비즈니스 로직을 유지보수, 테스트 하기 쉽게 관리할 수 있다.

 

JDBC로 서비스 계층에서 트랜젝션을 시작하면 DataSource, Connection 같은 JDBC 기술에 의존하게 되는데 JDBC 코드를 데이터 계층으로 옮기고 인터페이스로 제공한다 해도 데이터 계층에서 발생한 JDBC 예외가 서비스 계층으로 전파되고  try, catch 코드가 반복되는 등 여러 문제가 발생한다.

 


 

스프링 트랜잭션

 

스프링은 서비스 계층을 순수하게 유지하면서 위의 문제들을 해결할 수 있는 기술을 제공한다.

 

트랜잭션 매니저(TransactionManager)

 

스프링은 PlatformTransactionManager 라는 인터페이스를 제공하는데 이는 트랜잭션 추상화를 통해 데이터 접근 기술이 바뀌면 트랜젝션 코드를 수정해야 하는 등의 문제를 해결해주고 각각의 기술에 맞는 구현체(JpaTransactionManage..)도 직접 만들어 제공한다.

 

트랜잭션 매니저는 내부적으로 트랜잭션 동기화 매니저를 사용하는데 이는 쓰레드 로컬을 사용해서 트랜젝션의 시작부터 끝까지 같은 커넥션을 유지할 수 있도록 커넥션을 동기화 해주고 쓰레드 로컬을 사용하기 때문에 멀티 쓰레드 상황에서 안전하게 커넥션을 동기화 할 수 있다.

 

트랜젝션 매니저는 트랜젝션을 시작하면 트랜젝션 동기화 매니저를 통해 쓰레드 로컬에 커넥션을 보관하는데 리포지토리는 트랜젝션 동기화 매니저를 통해 동기화된 커넥션을 꺼내서 획득한 커넥션으로 SQL을 데이터베이스에 전달해서 실행한다.

트랜잭션은 커밋하거나 롤백하면 종료가 되는데 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득하고 데이터베이스에 트랜잭션을 커밋하거나 롤백한 뒤에 전체 리소스를 정리한다. 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 사용 후 반드시 리소스를 정리해야 한다.

 

추가로 스프링은 TransactionTemplate이라는 템플릿 클래스를 제공하는데 트랜잭션 템플릿을 사용하면 트랜잭션을 시작하고, 커밋하거나 롤백하는 코드를 전부 제거할 수 있다. (비즈니스 로직이 정상 수행되거나 체크 예외가 발생하면 커밋하고 언체크 예외는 발생 시 롤백)

 

하지만 트랜잭션 템플릿을 사용해도 비즈니스 로직과 트랜잭션 로직을 완전히 분리할 수는 없는데 스프링 AOP를 통해 프록시를 도입하면 해당 문제를 깔끔하게 해결할 수 있다.

 


 

스프링 트랜잭션 AOP

 

스프링 AOP를 통해 트랜잭션 프록시를 사용하면 서비스 계층에서 트랜잭션을 생성했던 것과 달리 트랜잭션 프록시 객체에서 트랜잭션을 생성해서 비즈니스 로직을 호출을 하는 방식으로 바뀌어 트랜잭션과 비즈니스 로직을 처리하는 객체를 명확하게 분리할 수 있다.

스프링이 제공하는 트랜잭션 AOP를 사용하면 프록시를 매우 편리하게 적용할 수 있는데 스프링 부트는 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들(어드바이저, 포인트컷, 어드바이스)을 자동으로 등록해준다. 

 

스프링이 제공하는 트랜잭션 AOP를 사용하려면 메서드나 클래스에 @Transactional 애노테이션을 추가하면 되는데 클래스에 붙이면 외부에서 호출 가능한 public 메서드에 AOP 적용이 된다.

 

 

간단한 흐름

 

1. 트랜잭션 AOP 프록시 트랜잭션 시작

2. 스프링 컨테이너에 등록된 트랜잭션 매니저 획득

3. transactionManager.getTransaction()

4. DataSource를 통해 커넥션 조회, 없으면 생성

5. setAutoCommit(false)

6. 트랜잭션 동기화 매니저에 커넥션 보관

7. 트랜잭션 프록시에서 실제 객체의 서비스 로직 호출

8. 리포지토리에서 트랜잭션 동기화 매니저로부터 커넥션 획득

 

스프링 부트는 커넥션 풀을 제공하는 HikariDataSource를 생성해서 스프링 빈으로 등록하고 현재 등록된 라이브러리를 확인하여 데이터 접근 기술에 맞는 PlatformTransactionManager 구현체를 스프링 빈으로 등록(빈 이름 : transactionManager)한다.

트랜젝션

 

데이터를 저장할 때 단순 파일이 아닌 데이터베이스에 저장하는 가장 큰 이유는 데이터베이스가 트랜젝션이라는 개념을 지원하기 때문이다.

트랜젝션은 데이터베이스에서 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻하는데 트랜젝션 기능을 사용하면 중간에 문제가 생길 경우 시작 이전으로 되돌릴 수 있다. 작업이 완료되고 데이터베이스에 정상 반영되는 것을 커밋(Commit)이라 하고, 문제가 생겨서 작업 이전으로 되돌리는 것을 롤백(Rollback)이라 한다.

 

 

트랜젝션 ACID 

 

트랜젝션은 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)를 보장해야 한다.

  • 원자성: 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업처럼 모두 성공 하거나 모두 실패해야 한다.
  • 일관성: 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
  • 격리성: 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준(JPA  16.1 트랜잭션과 락 참고)을 선택할 수 있다.
  • 지속성: 성공적으로 트랜젝션이 끝나면 그 결과가 항상 기록되어야 한다중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.

 


 

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

 

데이터베이스는 커넥션을 연결할 때 내부에 DB 세션을 생성(커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 생성)하는데 트랜젝션을 시작하고, SQL을 실행하고, 커밋, 롤백, 트랜젝션 종료 등의 모든 요청은 DB 세션을 통해 실행이 된다. 

 

트랜젝션 내부 동작

 

데이터베이스는 커밋을 호출하기 전까지 데이터를 임시로 저장하는데 해당 트랜젝션을 시작한 세션만 변경 데이터(등록, 수정, 삭제)가 보이고 다른 세션은 변경중인 데이터가 보이지 않는데 이는 문제가 생겨서 트랜젝션 롤백이 되는 경우 데이터 정합성에 큰 문제가 생기기 때문이다.

 

트랜젝션에는 자동 커밋과 수동 커밋이 있는데 자동 커밋의 경우 말 그대로 쿼리를 하나 하나 실행할 때마다 자동으로 커밋을 해줘서 편리하지만 트랜젝션 개념이랑 맞지 않기 때문에 수동 커밋 모드(set autocommit false)로 바꾸어 사용하는 것이 트랜젝션의 시작이라고 볼 수 있다.

 

 

DB 락

 

한 세션에서 트랜젝션을 시작하고 데이터를 수정하는 동안 다른 세션이 같은 데이터를 수정하게 되면 트랜젝션의 원자성이 깨지는데 이런 문제를 방지하려면 트랜젝션이 커밋이나 롤백을 하기 전까지 다른 세션에서 해당 데이터를 수정할 수 없도록 막아야 한다.

 

데이터베이스는 이런 문제를 해결하기 위해 락(Lock)이라는 개념을 제공하는데 세션은 트랜젝션을 시작하고 값을 변경하려는 데이터의 row에 대해 먼저 lock을 얻는데 락을 갖고 있는 동안 다른 세션은 해당 row의 데이터를 변경할 수 없다. 락을 획득한 세션이 트랜젝션을 종료하면 락이 반납되고 다른 세션은 대기하다가 락을 획득한 뒤에 데이터를 변경할 수 있으며 락 대기 시간이 넘어가면 타임아웃 오류가 발생한다.

 

데이터베이스마다 다르지만, 락이 걸린 동안에는 다른 세션에서 데이터를 변경하지 못하고 지연이 걸리기 때문에 일반적인 데이터베이스는 조회를 할 때는 락을 사용하지 않고 바로 데이터를 조회할 수 있도록 한다.  조회를 할 때도 락이 필요한 경우  'select for update' 구문을 사용하면 되는데 조회를 통해 중요한 계산을 수행하는 경우 조회 시점에 락을 획득하면 된다.

 


 

트랜젝션 적용

 

트랜젝션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 문제가 되는 부분을 함께 롤백해야 하기 때문이다. 서비스 로직에서 트랜젝션을 시작하려면 먼저 커넥션을 획득하고 유지해야 하는데 애플리케이션에서 같은 커넥션을 유지하려면 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지해야 한다.

 

하지만 이러한 방법은 서비스 계층이 매우 복잡해지고 커넥션을 유지하기도 어려워지는데 스프링을 사용해서 트랜젝션을 편리하게 사용할 수 있다.

 

 

[참고]  인프런 김영한님 강의를 공부한 내용입니다.

 

커넥션 풀

 

커넥션을 획득할 때는 DB 드라이버에 커넥션 조회, TCP/IP 연결, 인증 정보 전달, 내부 인증, DB 세션 생성, 응답 결과, 커넥션 객체 생성 등 복잡한 과정을 거치는데 이는 클라이언트 응답 속도에 큰 영향을 준다. 이러한 문제를 해결하기 위해 커넥션을 미리 생성해두고 사용하는 커넥션 풀이라는 방법이 있는데 애플리케이션 시작 시점에 일정 커넥션을 미리 확보해서 풀에 보관하는 것이다.

 

커넥션 풀에 보관중인 커넥션은 TCP/IP로 DB와 커넥션 연결이 되어 있는 상태로 애플리케이션은 커넥션 풀에 있는 커넥션을 조회해서 객체 참조로 가져다 사용하고 로직 종료 시 반환하는데 이때 커넥션은 살아있는 상태로 커넥션 풀에 반환해야 한다.

 

대표적인 커넥션 풀 오픈 소스로는 스프링 부트에서 기본으로 제공하는 HikariCP가 있다.

 

 


 

DataSource

 

이전 JDBC로 커넥션을 직접 획득할 때는 DriverManager를 통해 커넥션을 생성했는데 커넥션 풀을 사용할 경우 커넥션이 풀이 직접 커넥션을 생성한다. 문제는 DriverManager를 사용해서 커넥션을 획득하다가 커넥션 풀로 바꾸는 경우 의존 관계가 DriverManager에서 HikariCP로 바뀌기 때문에 애플리케이션 코드도 전부 바꿔야 되는 점이다.

 

자바에서는 이러한 문제를 해결하기 위해 DataSource 인터페이스를 제공하는데 DataSource는 커넥션을 획득하는 방법을 추상화한 인터페이스로 핵심 기능은 커넥션 조회 기능이다. DataSource를 생성하는 시점에 필요한 설정을 해두면 DataSource를 사용할 때는 getConnection() 만 호출하면 되기 때문에 설정과 사용을 분리할 수 있다.

 

HikariCP 커넥션 풀은 DataSource 인터페이스를 구현한 HikariDataSource를 사용하는데 커넥션 풀에 커넥션을 채우는 것은 상대적으로 시간이 오래 걸리기 때문에 HikariDataSource는 애플리케이션 실행 속도에 영향을 주지 않기 위해 별도의 쓰레드에서 커넥션을 생성한다. 

 

클라이언트가 데이터를 저장하거나 조회할 때, 애플리케이션 서버는 데이터베이스와 커넥션을 연결하고, SQL을 전달해서 결과를 응답 받는데 데이터베이스마다 이러한 방법이 모두 다르기 때문에 JDBC 라는 자바 표준이 등장했다.

 

JDBC는 자바에서 DB에 접속할 수 있도록 하는 자바 API연결(Connection), SQL 전달(Statement), 응답(ResultSet)의 인터페이스를 제공하는데 이 JDBC 인터페이스를 각각의 DB에 맞도록 구현한 라이브러리JDBC 드라이버라고 한다.

 

애플리케이션 로직이 JDBC 표준 인터페이스에 의존하게 되면서 데이터베이스를 변경해도 JDBC 구현 라이브러리만 변경하면 되는 등의 편의점이 생겼지만 JDBC 인터페이스로 공통화하는데 한계가 있어서 SQL은 각각의 데이터베이스에 맞게 변경을 해야하는 단점이 있다. (페이징 처리  SQL 등)

 

JDBC를 직접 사용하기 보다는 JDBC를 편리하게 사용하는 SQL Mapper와 ORM 기술이 있는데 이런 기술들도 내부적으로 JDBC를 사용하기 때문에 JDBC의 기본 동작 원리는 알고 있는 것이 좋다.

 

Connection

  • DriverManager : 라이브러리에 등록된 DB 드라이버를 관리하고, 커넥션을 획득하는 역할
  • getConnection(url, username, password) : 라이브러리에 있는 DB 드라이버를 찾아서 DB에 맞는 커넥션을 반환

 

Statement

  • connection.prepareStatement(sql) : 커넥션을 통해 DB에 전달할 SQL과 파라미터를 바인딩 (SQL Injection 방지)
  • statement.executeUpdate() : Statement를 통해 준비된 SQL을 실제 데이터베이스에 전달

 

ResultSet

  • statement.executeQuery() : 데이터를 조회하여 결과를 ResultSet에 담아서 반환한다.
  • resultSet.next() : resultSet은 반환받은 데이터를 커서로 가르키는데 next()로 커서를 이동할 수 있다.
  • resultSet.getXxx() : 현재 커서가 가르키는 데이터를 원하는 타입으로 변환해서 가져온다.

 

Connection을 생성하고 Statement를 통해 SQL을 실행하고 나면 리소스를 정리해야 되는데 역순으로 종료를 하면 된다. 리소스 정리를 하지 않으면 커넥션이 끊어지지 않고 계속 유지되어 리소스 누수가 발생하고 커넥션 부족으로 장애가 발생할 수 있다.

 

 

 

+ Recent posts