Chapter 8. DB 연동

Posted by yunki kim on May 24, 2022

  JDBC를 사용하면 코드에는 디비 연동과 관련된 보일러플레이트가 존재한다. 이 단점을 없애기 위해 스프링은 템플릿 메서드 패턴과 전략 패턴을 엮은 JdbcTemplate을 제공한다. 또 한, 트랜잭션 관리를 쉽게 제공한다. 순수 JDBC API를 사용해 트랜잭션을 처리하려면 다음과 같은 과정이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public void insert(Member member) {
    Connection connection = null;
    PreparedStatement preparedStatement = null;
    try {
        connection = DriverManager.getConnection(
                "jdbc:mysql://localhost/spring4fs?characterEncoding=utf8",
                "spring4""spring4");
 
        connection.setAUtoCommit(false);
            
        // ... 자동 쿼리 비활성화
        connection.commti();
    } catche(SQLException ex) {
    if (connection != null) {
        try {
            // 트랜잭션 롤백
            connection.rollback();
        } catch (SQLException e) {
            if (PreparedStatement != null) {
                try {
                    preparedStatement.close();
                } catch(SQLException e) {
                }
            }
        } finally {
            if (preparedStatement != null ){
                try {
                    preparedStatement.close();
                } catch (SQLException e) {}
            }
            if (connection != connection.close, ii)
        }
    }
}
cs

 만약 스프링을 사용한다면 @Transactiona 어노테이션을 사용하면 된다.

커넥션 풀

  실제 서비스에서는 서로 다른 장비를 이용해 자바 프로그램과 DBMS를 실행한다. 이때, 자바 프로그램에서 DBMS로 커넥션을 생성하는 시간은 전체 성능에 영향을 줄 수 있따. 또한 동시에 접속하는 상요자수가 많다면, DB 커넥션을 생성해 DBMS에 부하를 준다.

  위와 같은 문제를 없애기 위해 커넥션 풀을 사용한다. 커넥션 풀은 일정 개수의 DB 커넥션을 미리 만들어 두고, 필요할 떄 가져와 사용한 뒤 커넥션을 다시 풀에 반납한다. 커넥션 풀을 사용하면 커넥션 생성 시간을 아낄 수 있고 많은 커넥션 생성으로 인한 부하를 방지할 수 있다. Tomcat JDBC, HikariCP 등이 커넥션 풀 기능을 제공한다.

 스프링 부트 2.0 이전에는 TomCat JDBC를 사용했지만, 그 이후 부터는 Hikari CP를 사용한다. Hikari CP 벤치마킹 페이지를 보면, 다른 커넥션풀 관리 방식에 비해 월등히 빠른 것을 볼 수 있다.

  Hikari CP가 유독 빠른 이유는 Connection 객체를 감싼 PoolEntry로 Connection을 관리하고, ConcurrentBag을 사용해 PoolEntry를 관리하고 있기 떄문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final class PoolEntry implements IConcurrentBagEntry {
    ...
 
    Connection connection;
 
    ...
    
    PoolEntry(final Connection connection, final PoolBase pool, final boolean isReadOnly, final boolean isAutoCommit) {
      this.connection = connection;
      this.hikariPool = (HikariPool) pool;
      this.isReadOnly = isReadOnly;
      this.isAutoCommit = isAutoCommit;
      this.lastAccessed = currentTime();
      this.openStatements = new FastList<>(Statement.class16);
   }
 
    ...
}
cs

  아래 코드는 HikairPool에서 PoolEntry를 가져오는 코드다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public final class HikariPool extends PoolBase implements HikariPoolMXBean, IBagStateListener {
    ...
    
    // Get a connection from the pool, or timeout after the specified number of milliseconds.
    public Connection getConnection(final long hardTimeout) throws SQLException {
      suspendResumeLock.acquire();
      final long startTime = currentTime();
 
      try {
         long timeout = hardTimeout;
         do {
            PoolEntry poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
            if (poolEntry == null) {
               break// We timed out... break and throw exception
            }
 
            final long now = currentTime();
            if (poolEntry.isMarkedEvicted() || (elapsedMillis(poolEntry.lastAccessed, now) > aliveBypassWindowMs && !isConnectionAlive(poolEntry.connection))) {
               closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? EVICTED_CONNECTION_MESSAGE : DEAD_CONNECTION_MESSAGE);
               timeout = hardTimeout - elapsedMillis(startTime);
            }
            else {
               metricsTracker.recordBorrowStats(poolEntry, startTime);
               return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry), now);
            }
         } while (timeout > 0L);
 
         metricsTracker.recordBorrowTimeoutStats(startTime);
         throw createTimeoutException(startTime);
      }
      catch (InterruptedException e) {
         Thread.currentThread().interrupt();
         throw new SQLException(poolName + " - Interrupted during connection acquisition", e);
      }
      finally {
         suspendResumeLock.release();
      }
   }
    
    ...
}
cs

  HikariPool.getConnnection()은 ConcurrentBag.borrow()를 호출해 사용 가능한 Connection을 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class ConcurrentBag<extends IConcurrentBagEntry> implements AutoCloseable {
    ...
    
    // The method will borrow a BagEntry from the bag, blocking for the specified timeout 
    // if none are available.    
    public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException {
      // Try the thread-local list first
      final List<Object> list = threadList.get();
      for (int i = list.size() - 1; i >= 0; i--) {
         final Object entry = list.remove(i);
         @SuppressWarnings("unchecked")
         final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
         if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
            return bagEntry;
         }
      }
 
      // Otherwise, scan the shared list ... then poll the handoff queue
      final int waiting = waiters.incrementAndGet();
      try {
         for (T bagEntry : sharedList) {
            if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
               // If we may have stolen another waiter's connection, request another bag add.
               if (waiting > 1) {
                  listener.addBagItem(waiting - 1);
               }
               return bagEntry;
            }
         }
 
         listener.addBagItem(waiting);
 
         timeout = timeUnit.toNanos(timeout);
         do {
            final long start = currentTime();
            final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
            if (bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
               return bagEntry;
            }
 
            timeout -= elapsedNanos(start);
         } while (timeout > 10_000);
 
         return null;
      }
      finally {
         waiters.decrementAndGet();
      }
   }
 
    ...
}
cs

 

DataSource 설정

  스프링이 제공하는 DB 연동 기능은 DataSource를 사용해 DB Connection을 구한다. DB 연동에 사용할 DatatSource를 스프링 빈으로 등록하고 DB 연동 기능을 구현한 빈 객체는 DataSource를 주입받아 사용한다.

   org.springframework.boot.autoconfigure.jdbc의 DataSourceConfiguration을 보면 여러 DataSource의 구현 클래스를 빈으로 등록하고 있다. 그 중, hikariDataSource를 빈으로 등록하는 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
abstract class DataSourceConfiguration {
    ...
 
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(HikariDataSource.class)
    @ConditionalOnMissingBean(DataSource.class)
    @ConditionalOnProperty(name = "spring.datasource.type", havingValue = "com.zaxxer.hikari.HikariDataSource",
            matchIfMissing = true)
    static class Hikari {
 
        @Bean
        @ConfigurationProperties(prefix = "spring.datasource.hikari")
        HikariDataSource dataSource(DataSourceProperties properties) {
            HikariDataSource dataSource = createDataSource(properties, HikariDataSource.class);
            if (StringUtils.hasText(properties.getName())) {
                dataSource.setPoolName(properties.getName());
            }
            return dataSource;
        }
 
    }
 
    ...
}
 
cs

    HikairCP 사용 시 다음과 같은 application.yml에 설정값을 추가할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
spring:
 datasource:
   hikari:
     # 죄대 pool size (default 10)
     maximum-pool-size: 10
     # 커넥션 연결에 소비되는 최대 시간
     connection-timeout: 5000
     # 연결 확인을 위한 초기 쿼리
     connection-init-sql: SELECT 1
     validation-timeout: 2000
     # 연결 풀에서 HikariCP가 유지 관리하는 최수 유유 연결 수
     minimum-idle: 10
     # 연결을 위한 최대 유휴 시간
     idle-timeout: 600000
     # 닫힌 후 pool 에 있는 connection의 최대 수명(ms)
     max-lifetime: 1800000
     # auto commit 여부 (default 10)
      auto-commit: false
cs

트랜잭션 처리

  스프링이 제공하는 @Transactional 어노테이션을 사용하면 트랜잭션 범위를 지정할 수 있다. 트랜잭션 범위에서 실행하고 싶은 메서드에 해당 어노테이션을 붙이면 된다. 

  정상적인 @Transactional 어노테이션 동작을 위해선 다음 두 가진 내용을 스프링 설정에 추가해야 한다.

    1. 플랫폼 트랜잭션 매니저(PlatformTransactionManager) 빈 설정

    2. @Transactional 어노테이션 활성화 설정

  PlatformTransactionManager는 스프링이 제공하는 트랜잭션 매니저 인터페이스다. 스프링은 구현 기술과 관련 없이 동일한 방식으로 트랜잭션을 처리하기 위해 이 인터페이스를 사용한다. @EnableTransactionManagement 어노테이션은 @Transactional 어노테이션이 붙은 메서드를 트랜잭션 범위에서 실행하는 기능을 활성화 한다. 등록된 PlatformTrasactionManager 빈을 사용해 트랜잭션을 적용한다.

  그런데, 스프링 부트를 사용하면 이런 별도의 설정 없이 @Transactional을 사용하는 것만으로도 트랜잭션 처리가 된다. 따라서 내부적으로 PlatformTransactionManager가 빈으로 등록되 있고, 어디에선가는 @EnableTransactionManagement 어노테이션이 사용되고 있음을 짐작할 수 있다. 그래서 뜯어보자.

  DataSourceTransactionManager는 DataSourceTransactionManagerAutoConfiguration 에서 빈으로 등록되며 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ JdbcTemplate.class, TransactionManager.class })
@AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE)
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceTransactionManagerAutoConfiguration {
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnSingleCandidate(DataSource.class)
    static class JdbcTransactionManagerConfiguration {
 
        @Bean
        @ConditionalOnMissingBean(TransactionManager.class)
        DataSourceTransactionManager transactionManager(Environment environment, DataSource dataSource,
                ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
            DataSourceTransactionManager transactionManager = createTransactionManager(environment, dataSource);
            transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager));
            return transactionManager;
        }
 
        private DataSourceTransactionManager createTransactionManager(Environment environment, DataSource dataSource) {
            return environment.getProperty("spring.dao.exceptiontranslation.enabled", Boolean.class, Boolean.TRUE)
                    ? new JdbcTransactionManager(dataSource) : new DataSourceTransactionManager(dataSource);
        }
 
    }
}
cs

  @EnableTransactionManagement 은 TransactionAutoConfiguration 내에 존재하는 EnableTransactionManagementConfiguration nested class 내에서 사용되고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(PlatformTransactionManager.class)
@AutoConfigureAfter({ JtaAutoConfiguration.class, HibernateJpaAutoConfiguration.class,
        DataSourceTransactionManagerAutoConfiguration.class, Neo4jDataAutoConfiguration.class })
@EnableConfigurationProperties(TransactionProperties.class)
public class TransactionAutoConfiguration {
    ...
 
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnSingleCandidate(PlatformTransactionManager.class)
    public static class TransactionTemplateConfiguration {
 
        @Bean
        @ConditionalOnMissingBean(TransactionOperations.class)
        public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
            return new TransactionTemplate(transactionManager);
        }
 
    }
 
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnBean(TransactionManager.class)
    @ConditionalOnMissingBean(AbstractTransactionManagementConfiguration.class)
    public static class EnableTransactionManagementConfiguration {
 
        @Configuration(proxyBeanMethods = false)
        @EnableTransactionManagement(proxyTargetClass = false)
        @ConditionalOnProperty(prefix = "spring.aop", name = "proxy-target-class", havingValue = "false")
        public static class JdkDynamicAutoProxyConfiguration {
 
        }
 
        @Configuration(proxyBeanMethods = false)
        @EnableTransactionManagement(proxyTargetClass = true)
        @ConditionalOnProperty(prefix = "spring.aop", name = "proxy-target-class", havingValue = "true",
                matchIfMissing = true)
        public static class CglibAutoProxyConfiguration {
 
        }
 
    }
 
}
cs

  또 한, 트랜잭션 시작과 롤백을 별도로 명시하지 않아도 되는 이유는 DataSourceTransactinoManager가 이 기능을 지원하기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class DataSourceTransactionManager extends AbstractPlatformTransactionManager
        implements ResourceTransactionManager, InitializingBean {
    ...
 
    @Override
    protected void doCommit(DefaultTransactionStatus status) {
        DataSourceTransactionObject txObject = (DataSourceTransactionObject) status.getTransaction();
        Connection con = txObject.getConnectionHolder().getConnection();
        if (status.isDebug()) {
            logger.debug("Committing JDBC transaction on Connection [" + con + "]");
        }
        try {
            con.commit();
        }
        catch (SQLException ex) {
            throw translateException("JDBC commit", ex);
        }
    }
 
    @Override
    protected void doRollback(DefaultTransactionStatus status) {
        DataSourceTransactionObject txObject = (DataSourceTransactionObject) status.getTransaction();
        Connection con = txObject.getConnectionHolder().getConnection();
        if (status.isDebug()) {
            logger.debug("Rolling back JDBC transaction on Connection [" + con + "]");
        }
        try {
            con.rollback();
        }
        catch (SQLException ex) {
            throw translateException("JDBC rollback", ex);
        }
    }
 
    ...
}
cs

@Transactional과 프록시

  @Transactional 어노테이션을 이용해 트랜잭션을 처리하기 위해 내부적으로 AOP를 사용한다. @Transactional 어노테이션을 적용하기 위해 @EnableTransactionManagement 태그를 사용하면 @Transactional 어노테이션이 적용된 빈 객체를 찾아 알맞은 프록시 객체를 생성한다. 

  다음과 같은 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Configuration
@EnableTransactionManagement
public class ApplicationContext {
 
    ...
 
    @Bean
    public ChangePasswordService changePasswordService() {
        ChangePasswordService passwordService = new ChangePasswordService();
        passwordService.setMemberDao(memberDao());
        return passwordService;
    }
}
 
public class MainForChangePassword {
 
    public static void main(String[] args) {
        AnnotationConfigApplicationContext context = 
            new ApplicationContext(ApplicationContext.class);
 
        ChangePasswordService changePasswordService =
            context.getBeans("changePasswordService", ChangePasswordService.class);
        try {
            changePasswordService.cahngePassword("example@example.com""1234""1111");
        } catch (MemberNotFoundException e ) {
 
        } catch (WrongIdPasswordException e) {
 
        }
    }
}
 
cs

이 예제의 경우 다음과 같은 구조로 프록시를 사용하게 된다.

    프록시 객체는 @Transactional 어노테이션이 붙은 메서드를 호출하면 PlatformTransactionManager를 사용해 트랜잭션을 시작한다. 그 후 실제 객체의 메서드를 호출하고 성공적으로 실행되는 트랜잭션을 커밋한다.

  트랜잭션이 롤백이 된다면, 다음과 같은 과정을 거친다.

  별도의 설정이 없다면 RuntimeException만 롤백인 된다. 만약 특정 에러를 롤백시키고 싶다면 @Transactional 어노테이션에서 rollbackFor 속성을 사용해 지정할 수 없다. 이와 반대로 특정 에러는 롤백을 하고 싶지 않다면 noRollbackFor 속성을 사용하면 된다.

@Transactional의 주요 속성

속성 타입 설명
value String 트랜잭션을 관리할 떄 사용할 PlatformTransactionManager 빈의 이름을 지정한다. 디폴트값: ""
propagation Propagation 트랜잭션 전파 타입을 지정한다. 디폴트값은 Propagarion.REQUIRED 이다
isolation Isolation 트랜잭션 격리 레벨을 지정한다. 디폴트값은 Isolatino.DEFAULT 이다.
timeout int 트랜잭션 제한 시간을 지정한다. 디폴트값은 -1로 데이터베이스의 타임아웃 시간을 사용한다. 단위는 초다.

  @Transactional 어노테이션의 value 속성값이 없으면 등록된 빈 중에 타입이 PlatformTransactionManager인 빈을 사용한다.

  Propagation 열거 타입에 정의되있는 값 목록은 다음과 같다.

설명
REQUIRED 메서드를 수행하는 데 트랜잭션이 필요하다는 것을 의미한다. 현재 진행 중인 트랜잭션이 존재하면 해당 트랜잭션을 사용한다. 존재하지 않으면 새로운 트랜잭션을 생성한다.
MANDATORY 메서드를 수행하는 데 트랜잭션이 필요하다는 것을 의미한다. 하지만 REQUIRED와 달리 진행중인 트랜잭션이 존재하지 않을 경우 익셉션이 발생한다.
REQUIRES_NEW 항상  새로운 트랜잭션을 시작한다. 진행중인 트랜잭션이 존재하면 기존 트랜잭션을 일시 중지하고 새로운 트랜잭션을 시작한다. 새로 시작도니 트랜잭션이 종료된 뒤에 기존 트랜잭션이 계속된다.
SUPPORTS 메서드가 트랜잭션을 필요로 하지는 않지만, 진행중인 트랜잭션이 존재하면 트랜잭션을 사용한다는 것을 의미한다. 진행중인 트랜잭션이 존재하지 않아도 메서드는 정상적으로 동작한다.
NOT_SUPPORTED 메서드가 트랜잭션을 필요로 하지 않음을 의미한다. SUPPORTS와 달리 진행 중인 트랜잭션이 존재할 경우 메서드가 실행되는 동안 트랜잭션은 일시 중지되고 메서드 실행이 종료된 후에 트랜잭션을 계속 진행한다.
NEVER  메서드가 트랜잭션을 필요로 하지 않는다. 만약 진행중인 트랜잭션이 존재하면 익셉션이 발생한다.
NESTED 진행중인 트랜잭션이 존재하면 기존 트랜잭션에 중첩된 트랜잭션에서 메서드를 실행한다. 진행 중인 트랜잭션이 존재하지 않으면 REQUIRED와 동일하게 동작한다. 이는 JDBC 3.0 드라이버를 사용할 때만 동작한다.

  Isolation 열거 타입에 정의된 값은 다음과 같다.

설명
DEFAULT 기본 설정을 사용한다.
READ_UNCOMMITTED 다른 트랜잭션이 커밋하지 않은 데이터를 읽을 수 있다.
READ_COMMITTED 다른 트랜잭션이 커밋한 데이터를 읽을 수 있다.
REPEATABLE_READ 처음에 읽어 온 데이터와 두 번쨰 읽어 온 데이터가 동일한 값을 갖는다.
SERIALIZABLE 동일한 데이터에 대해 동시에 두 개 이상의 트랜잭션을 수행할 수 있다.

@EnableTransactionManagement 어노테이션의 주요 속성

속성 설명
proxyTargetClass 클래스를 이용해 프록시를 생성할지 여부를 지정한다. 기본값은 false로서 인터페이스를 이용해 프록시를 생성한다.
order AOP 적용 순서를 지정한다. 기본값은 가장 낮은 우선순위에 해당하는 int의 최댓값이다.

트랜잭션 전파

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class SomeService {
 
    private AnyService anyService;
 
    @Transactional
    public void some() {
        anyService.any();
    }
 
    public void steAnyService(AnyService anyService) {
        this.anyService = anyService;
    }
}
 
public class AnyService {
    @Transactional
    public void any() {
        ...
    }
}
 
@Configuration
@EnableTransactionManagement
public class Config {
 
    @Bean
    public SomeService some() {
        SomeService some = new SomeService();
        some.setAnyService(any());
        return some;
    }
 
    @Bean
    public AnyService any() {
        return new AnyService();
    }
}
 
cs

  SomeService 클래스와 AnyService 클래스는 둘 다 @Transactional 어노테이션을 적용하고 있다. 따라서 SomeService의 some() 메서드 호출과 AnyService의 any() 메서드 호출 모두 트랜잭션이 시작된다고 생각할 수 있다. 새로운 트랜잭션 시작 여부는 @Transactional의 propagation 속성에 따라 달라진다.

 

 

출처 - 초보 웹 개발자를 위한 스프링5 프로그래밍 입문