Skip to main content Link Menu Expand (external link) Document Search Copy Copied

created at 2023-03-15

HikariCP를 사용하던 중, 새로운 스레드에서 hikariDataSource.getConnection() 을 수행했지만 다른 스레드에서 사용하던 Connection 이 반환되었습니다. 3일간 해결이 안되서 stack-overflow 에 질문을 올렸습니다ㅜㅜ. 혹시 왜 이런 현상이 발생하는지 아신다면 아래의 링크에 답변을 남겨주시면 정말정말 감사하겠습니다!

Stack-overflow : Does HikariCP.getConnection() return the same Connection when run getConnection in different thread?

트랜젝션과 멀티스레드의 잘못된 매칭 수정 version.1

저는 서비스와 데이터베이스 별 따로 스레드 풀을 설정했습니다. 그 이유는, 단순히 1. DB의 connection 을 가져와서, 2. DB 쿼리 만을 관리하는 스레드 풀이 있었으면 좋겠다 라는 생각을 가졌었기 떄문이에요.

하지만, 저는 Transaction은 Connection에 종속되어 있으며, Connection은 ThreadLocal이다! 라는 것을 간과하고 있었어요.

  • DB-Connection 은 ThreadLocal 로 저장됩니다.
  • Transaction 은 단일 DB-Connection 에서 유효합니다.
  • 즉, Transaction 은 단일 Thread 내에서만 유효합니다!

그래서 하나의 스레드로 하나의 Connection 을 HikariCP 에서 가져와 Transaction 을 처리하기 위해 아래와 같이 변경했습니다.

img

  1. 일단 직접 트랜젝션을 컨트롤하기 위해서 @Trnasactional 어노테이션을 Service 메소드에서 뺐습니다.
  2. 이후, 리포지토리 private 메소드들을 시퀀셜하게 수행되도록 관리하는 login() 메소드를 만들었어요.
  3. 또한 서비스에서 리포지토리의 login()을 호출할 때, .get()으로 blocking 하였습니다(이유는, ).
  4. matchIdAndPw, updateLoginDate, addEvent 내부에서 HikariCP로부터 Connection 을 빌려오면, 같은 db-thread-1 스레드에서 실행되기때문에 동일한 Connection 을 반환합니다. 따라서 각각의 메소드에서 커넥션을 쓰고 prepareStatement와 ResultSet만 닫아주면 되요.
  5. 여러 메소드에서 Exception 발생 시, login()에서 try/catch 해서 마찬가지로 hikariDataSource.getConnection() 수행해서 동일 커넥션 얻어오고, conn.rollback() 을 수행하도록 합니다.
  6. 마지막으로 다 쓴 커넥션은 finally 문에 HikariCP에 다시 반환합니다.

점점 어노테이션을 안쓰는 것 같아서 마음이 찹찹하지만, 실제 Human Readable 하게 코드를 작성하다보니 어쩔 수 없는 부분인것 같습니다.

일단 개발시간도 매우 오래 걸리긴 하지만, try/catch문이 진짜 너무너무 많았어요 :( 매 메소드마다 Try/Catch 가 들어가야하더라구요. 너무 지긋지긋해서 한군데서만 try/catch 하도록 바꾸는 것을 시도중입니다!

트랜젝션과 멀티스레드의 잘못된 매칭 수정 version.2

위와 같이 스레드, Connection, 등등을 직접 컨트롤 했었는데, 모든 메소드를 전부 이런식으로 고치려니 시간이 많이 소요될것 같았습니다. 효율적이지도 않구요.

사실 직접 컨트롤 했던 이유는, 세부 동작과정들을 상세히 이해하고 싶었기 때문이에요. 지금은 대부분 체감했기에, 정말 고마운 어노테이션들(@Transactional, @Async) + DeferredResult을 사용하기로 하였습니다! 그 결과 매우 코드가 간결해 짐과 동시에 개발속도또한 빨라졌습니다.

결론은 아래와 같이 기존 로직이 변경되었습니다.

  • 서비스 스레드 풀만 설정해주었습니다.(기존에는 DB풀,서비스풀 따로였어요. DB 쿼리 스레드는 트렌젝션 로컬을 지키기 위해서 서비스 스레드를 사용합니다.)
  • CompletableFuture은 @Async와 DeferredResult로 스레드 생성과 반환을 non-blocking으로 변경하였습니다.
  • @Transactional 을 통해 귀찮은 Conn,pstmt,resultSet 반환문을 제거할 수 있었어요.
  • 트랜젝션(~DB connection) 을 프록시 객체로 관리해주는 TransactionAspectSupport 를 통해 롤백위치를 직접 설정해주었습니다.

img

위의 그림과 같이 수정한 후, HTTP Benchmark Test로 HikariCP Status를 Spring-AOP를 통해 아래와 같이 체크해보았습니다. 이를 통해 몇 개의 Connection을 HikariCP에 미리 할당할 지 알 수 있기 때문이죠.

적정 HikariCP 체크를 위한 Spring AOP + Hikari Status Check

Code

@Aspect
@Component
@Slf4j
public class DataSourceAspectLogger {
    private HikariPool pool;
    @Autowired
    private HikariDataSource ds;
    
    // AOP
    @Before("execution(* chatting.chat.domain.user.repository.UserRepositoryJDBC.saveAll(..))")
    public void logBeforeConnection(JoinPoint jp) throws Throwable {
        logDataSourceInfos("Before ", jp);
    }

   // AOP
    @After("execution(* chatting.chat.domain.user.repository.UserRepositoryJDBC.saveAll(..))")
    public void logAfterConnection(JoinPoint jp) throws Throwable {
        logDataSourceInfos("After ", jp);
    }

    // Hikari Connection Pool 받아오기
    @Autowired
    public void getPool() {
        try {
            java.lang.reflect.Field field = ds.getClass().getDeclaredField("pool");
            field.setAccessible(true);
            this.pool = (HikariPool) field.get(ds);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    // HikariCP Status 체크
    public void logDataSourceInfos(final String time, final JoinPoint jp) {
        final String method = String.format("%s", jp.getSignature().getName());
        int totalConnections = pool.getTotalConnections(); // 사전에 미리 땡겨오는 DB Connection 개수
        int activeConnections = pool.getActiveConnections(); // 빌려준 Connection 개수
        int freeConnections = totalConnections - activeConnections; // Idle Conenction 개수, getIdle 로도 가져올 수 있어요.
        int connectionWaiting = pool.getThreadsAwaitingConnection(); // Wait Connection 개수
        log.info(String.format("%s %s: [Total: %d, Active: %d, Idle: %d, Wait: %d]", time, method, ds.getMaximumPoolSize(),activeConnections,freeConnections,connectionWaiting));
    }
}

1000개 request 동시성 테스트 시, 최대 HikariCP Status Result

[service-thread-14] c.c.web.logger.DataSourceAspectLogger    : After  saveAll: [Total: 10, Active: 10, Idle: 0, Wait: 101]

위의 결과로 미루어보아, 현재 서버는 1000개의 동시 request 가 들어왔을 때, Active+Wait=111 개의 DB connection 이 필요함을 알 수 있습니다. 현재 제 RDS는 max_connection이 83개로 설정되어 있는데요. 테스트용 psql 터미널 2개 + tableplus 터미널 1개 + rds proxy 2개 = 5개를 default로 사용하기 떄문에 최대 78개까지 아슬아슬하게 설정가능합니다. 이렇게 되면 최대한 wait를 줄일 수 있겠죠? 추가적으로 저는 jdbcTemplate.batchUpdate 로 일정 time 동안 Insert 쿼리들을 모아서 전송하는데요. 이렇게 되면, connection 을 덜 잡아먹는 다는 장점이 있어서 connection wait time 줄이기에 도움이 된 것같아요.

(Appendix) JDBC SQLException Code(Postgresql)

  • 23505 : unique_violation

https://www.postgresql.org/docs/8.3/errcodes-appendix.html

(Appendix) 서비스,리포지토리 노 어노테이션

UserController

    // 유저 저장
    @PostMapping("/user")
    public DeferredResult<ResponseEntity<?>> addUser(@RequestBody RequestAddUserVO req) throws InterruptedException {

        // DeferredResult는 Spring 3.2부터 지원하는 이벤트 driven 비동기 지원 Callback 메소드
        // DeferredResult를 리턴 타입으로 설정하면,
        // 이벤트가 다른 스레드로부터 들어오면 클라이언트 정보를 가지고 있는 현재 nio 톰켓 스레드에서 클라이언트에게 결과물 반환
        // 즉, Golang의 채널같은 존재임(조금 다르긴 하지만)
        DeferredResult<ResponseEntity<?>> dr = new DeferredResult<>();

        // 유저 서비스를 통해 유저 저장
        CompletableFuture.runAsync(()->{
        }).thenCompose(s->{
            return userService.save(req);
        }).thenAcceptAsync( s1->{
            sendToKafkaWithKey(TOPIC_USER_CHANGE, new RequestUserChange(req.getUserId(), req.getUserName(),"","INSERT"), req.getUserId());
            dr.setResult(ResponseEntity.ok("success"));
        }).exceptionally(e->{
            if (e.getCause() instanceof CustomException){
                dr.setResult(ErrorResponse.toResponseEntity(((CustomException) e.getCause()).getErrorCode()));
            }else{
                dr.setResult(ResponseEntity.badRequest().body("default bad request response"));
            }
            return null;
        });

        // deferredResult 는 default로 되었다가 이벤트가 들어오면 해당 이벤트를 비로소 수행
        return dr;
    }