본문 바로가기
스프링

[Spring] Hikari pool - 1 [오류에 대해서 어떤 생각을 가지고 해결을 해볼 수 있었을까?]

by itstime0809 2023. 9. 2.
728x90

Hikari pool - 1 time out

 게시판 프로젝트를 진행하면서 발생된 문제점이다. 해당 문제점에 대해서 깊게 고민을 하지 않고 단순 개발에 우선순위를 두다 보니 거슬리는 데도 잠깐 동안은 무시했었다. 하지만 코드를 정상적으로 작성했음에도 불구하고 이 에러는 사라지지 않았다. 따라서 나는 앞서 가정을 했었다.

 

 

1. 코드가 비정상적으로 동작하기 때문에 해당 오류가 뜰 수 있을 것 같다.

2. 타임 아웃이 발생되니 SQL 쿼리문에서 문제가 발생할 수 있을 것 같다.

 

 

하지만 1번을 해결하고 난 뒤에도 여전히 오류는 지속적으로 찍히고 있었으며, 2번을 면밀히 바라보려고 노력했다. 2번을 해결하기 위해서 비즈니스 로직을 두 번 실행하게 되는 것을 redirect로 변경하여 해당 포스트를 다시 가지고 오지 않고 새로고침을 하는 방법으로 해결하려고 했었다. 이렇게 되면 쿼리를 로직상에서 두 번 실행하지 않기 때문에 DB에 요청되는 영향이 줄어들 것으로 예상했었다. 하지만 여전히 오류는 찍히고 있었으며, 따라서 가정이 잘못되었다는 것을 알게 되었다. 이는 코드가 비정상적으로 동작하는 문제도 아니며 그리고 타임아웃이 발생되는 문제가 SQL 쿼리문에서 발생되는 문제도 아니다. 따라서 나는 해당 오류에 대해서 근본부터 해결해 보고 싶어 여러 자료들을 찾고 학습해 본 결과 아래와 같은 내용들의 학습이 부족했기 때문에 나타난 결과이며 그로 인해 가정을 할 수 있는 경우의 수가 하나 더 생기게 되었다. 그래서 아래와 같은 내용들을 한 번 더 정리해 보면서 해당 오류가 왜 나타나게 되었고, 어떻게 해결을 할 수 있었는지를 작성해 보려고 한다.

 

Connection

 Connection에 대한 이해가 전무한 상태였기 때문에 Connection에 대한 이해부터 하는게 우선이었다. Connection이라고 하는 것은 DataBase와 연결이 되어 있는 객체 그 자체를 뜻한다. 쉽게 말해 클라이언트로부터 DataBase의 접근이 필요로 되거나 혹은 그와 유사한 특징적인 행위가 필요로 될 때 Connection 객체를 통해서 DataBase에 접근하게 된다. 그래서 DataBase와 연결되어 있는 객체라는 표현을 사용할 수 있다. 아래 위키피디아에서 정의하고 있는 내용을 잠깐 보게 된다면.

 

 

In software engineering, a connection pool is a cache of database connections maintained so that the connections can be reused when future requests to the database are required. Connection pools are used to enhance the performance of executing commands on a database. Opening and maintaining a database connection for each user, especially requests made to a dynamic database-driven website application, is costly and wastes resources. In connection pooling, after a connection is created, it is placed in the pool and it is used again so that a new connection does not have to be established. If all the connections are being used, a new connection is made and is added to the pool. Connection pooling also cuts down on the amount of time a user must wait to establish a connection to the database.

 

https://en.wikipedia.org/wiki/Connection_pool

 

Connection pool - Wikipedia

From Wikipedia, the free encyclopedia In software engineering, a connection pool is a cache of database connections maintained so that the connections can be reused when future requests to the database are required.[1] Connection pools are used to enhance

en.wikipedia.org

 

 빨간색으로 칠해진 글자 부분들이 바로 위에서 설명했던 과정을 기술해놓은 글이다. 다시 정리해 보자면 결국 connection이라는 것은 Connection pool 이라 하여 connections 들을 관리하고 있는 하나의 리스트 개념으로 생각해 보면 이해가 빠르다. 여기에서는 cache라고 언급이 되어 있다. 캐시 또한 메인 메모리에 접근하지 않고 자주 사용하게 되는 것들을 빠르게 접근하게 만들기 위해서 생겨난 개념이기 때문에 앞서 클라이언트의 요청 같은 경우 매번 비즈니스 로직을 요청하는 일이 자주 생기게 된다면, 메인 메모리에 접근하게 되는 것보다 캐시에 저장해 놓고 사용하는 것이 더 효율적이라고 생각해 볼 수 있다. 

 

 Connection pool은 이러한 Connections들을 관리하고 있는데 Connection Pool의 목적 자체는 동적 웹 어플리케이션에서는 각 요청들을 처리하는 비용과 리소스가 상당히 많이 소모된다. 그래서 각 유저가 요청할 때마다 Connection 객체를 만들게 된다면 동시 접속자 수가 적게 된다면 문제가 안될지 모르겠지만, 동시 접속자 수가 수백 명, 수천 명이 넘어가게 된다고 가정해 보면 수천 명에 대한 Connection 객체들을 요청마다 생성해야 된다. 한 사람이 하나의 요청만 보내는 게 아니기 때문에(혹은 그럴 수도 있다.) 한 사람이 수십 번의 요청을 하게 된다면 결국 수천 명 * 1개의 Connection 객체를 생성하게 되는데 수천 명 * 횟수 * 1개의 Connection 값이 되게 된다. 따라서 이는 시간적인 측면과 메모리 측면에서 기하급수적으로 늘어날 수 있음을 짐작해 볼 수 있다. 따라서 Connection pool의 목적이 여기에서 빛을 발할 수 있다. 미리 Connection 들을 생성해 놓고 Connection pool에서 관리하게 된다면 Connection pool에서 가지고 있는 Connection 들만 가지고 즉 제한적인 Connection만을 가지고 database에 접근할 수 있다. 이게 Conneciton pool이 동작하는 가장 큰 그림이다. 그래서 빛을 보게 되는 부분은 메모리를 일정량 제한하여 사용한다는 측면이다. 미리 정해둔 크기만큼의 Connection 만을 사용하기 때문에 각 요청마다 새로운 Connection을 생성하지 않기에 메모리 측면에서 매우 효율적이라고 볼 수 있다. 그렇다면 시간적인 측면은 어떨까? 이번 오류에서의 핵심도 이 시간적인 측면에서 출발 한다고 볼 수 있다.

 

Hikari CP

 시간적인 측면을 설명하기 앞서 Hikari CP가 무엇인지 알아보자. 우선 해당 Hikari pool - 1 에러에서도 볼 수 있듯. Hikari가 명시되어 있다. 이 Hikari는 JDBC connection pooling machanism을 제공한다. 이는 효율적인 connection 알고리즘을 제공해준다고 볼 수 있다. 여러 문서들을 참조해 본 결과 Spring boot 2.x 버전 이후부터 default CP가 Hikari로 되어 있다고 한다. CP는 말 그대로 Connection Pool 이기 때문에 Connection 들을 관리하는 Pool의 한 종류다. 여러 종류가 있지만 Spring boot 2.x 에서는 default로 설정되어 있다는 뜻이다. 따라서 Hikari CP는 Connection Pool이며 JDBC의 효율적인 Connection 메커니즘을 제공한다.

 

 Connection pool 같은 경우 어찌보면 Connection들의 관리자 격으로 볼 수 있는데 결국 Thread가 Connection을 요청을 했을 때 조건에 맞게 된다면 Connection을 할당해 주고 그렇지 않다면 할당해주지 않을 수 있다. 그럼 Connection을 할당 해주기 위해서는 Connection이 Connection Pool에 있어야 줄 수 있다. 만약 Connection Pool에 Connection이 없는 경우가 있을 수가 있을까?

당연히 존재할 수 있다. 당연히 라는 말 자체가 너무나 확신적이지만 코드를 그렇게 짠다면 Pool에는 Connection이 없을 수 있다. 이게 내가 마주한 에러의 원인이다. 

 

 Connection을 Thread에 할당해주게 된다는 것은 결국 요청에 대한 처리를 위해 프로세스가 진행되는 것을 의미하는데 결국 쿼리문을 실행하는 역할이 최종적인 역할일 것이다. 그럼 쿼리문을 수행하고 나서는 어떻게 될까? 아래 코드를 잠깐 보자.

 

 

@Override
    public void viewCountUpdate(Integer id) {
        String sql = "update post set viewCount = viewCount + 1 where id = ?";
        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;


        try {
            con = dataSource.getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setQueryTimeout(10);
            pstmt.setInt(1, id);
            rs = pstmt.executeQuery();


        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

 

 

위 코드에서 문제가 무엇일까 쿼리문은 실행하겠지만 쿼리가 진행되고 나서 최종적으로 Connection이 더 이상 필요로 되지 않음에도 불구하고 다시 pool에 반환하지 않는다. 이 역시 내가 의도했던 것은 아니었으며 pool이 connection을 관리하기 때문에 자동으로 반환이 될 것이라는 착각에 빠져 있었다. 어떻게 반환을 하지 않는데 Connection이 pool에 돌아올 수 있겠는가. 자동으로 관리해 주면 몰라도 적어도 오류가 발생했다는 이유가 자동으로 반환을 해주지 않는다는 것을 증명해주고 있다. 결국 아래와 같이 수정해 주면 된다.

 

 

@Override
    public void viewCountUpdate(Integer id) {
        String sql = "update post set viewCount = viewCount + 1 where id = ?";
        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            con = dataSource.getConnection();
            pstmt = con.prepareStatement(sql);

            pstmt.setQueryTimeout(10);
            pstmt.setInt(1, id);
            rs = pstmt.executeQuery();


        } catch (Exception e) {
            throw new IllegalStateException(e);
        } finally {
            DataSourceUtils.releaseConnection(con, dataSource);
        }
    }

 

 

추가된 구문은 finally 부분인데 이는 try-catch 를 구분하지 않고 끝에 반드시 실행되게 되는 것이며 connection 객체를 끊는 역할을 한다. 이때 Connection객체는 pool에 다시 돌아가게 된다. 그럼 왜 나는 이런 오류를 마주 했는지를 설명할 수 있게 되었다.

 

Solve

 우선 Connection pool 에는 아무런 요청이 없다면 미리 생성되어 있는 Connection들이 존재할 것이다. 그럼 이제 클라이언트의 요청이 차례로 들어오게 되면 Pool은 Connection 객체들을 하나씩 넘겨주게 되고, 각 Thread는 Connection을 가지고 쿼리를 실행하게 된다. 이후 에러가 발생되는 시점을 알 수 있게 되는데 Connection객체가 반환되지 않으니 Connection Pool에는 다음 Connection 최대 개수에 + 1개를 사용하려고 했을 때 발생하게 된다고 볼 수 있다. 이는 예로 들면 이해가 바로 되는데 최초 Connection객체가 5개 생성되어 있다고 한다면 Thread 1~5는 아무도 반환하지 않은 상태에서 Pool은 Connection 객체만을 넘겨주고 돌려받지는 못하고 있는 상황이다. 그랬을 때 6번째 Thread가 Connection을 필요로 하는 시점에서 Pool에 남아 있는 Connection 객체가 존재하지 않기 때문에 대기하게 된다. 특정 제한 시간동안까지 기다리게 되는데 이때 기본 값으로 약 30000ms 초로 환산하면 30초 정도 된다. 그래서 오류에 30008ms와 같이 time out이 뜨게 된 것인데 결국 time out이 나타났다는 건 기다리는 시간이 모두 다 지났음에도 불구하고 connection 객체를 받지 못했기 때문에 발생된 에러라고 볼 수 있다. 만약 connection을 할당받았다면 해당 에러를 보지 않게 되기 때문이다. 따라서 반환 받지 못하는 상태에서 connection을 할당해 줄 수 없는 상황에서 이러한 에러가 발생된다고 볼 수 있다. 

 

 어떻게 보면 당연한 결과인데도 Conneciton에 대한 선행지식이 없었기 때문에 겪을 수 있었던 에러라고 생각이 든다. 결론적으로 위에 finally 구문처럼 매 sql 문마다 connection 객체들을 pool에 다시 반환하는 작업을 수행해주면 된다. 그렇게 되면 반환받은 connection은 다시 필요로 되는 thread에게로 할당되기 때문이다. 물론 위 경우만이 해당 에러를 겪는 것은 아니다. 하지만 대략적으로 큰 흐름은 비슷하다고 보인다. 우아한 기술 블로그에서도 이와 같은 문제를 다루고 있어 다중 사용자가 많은 요청을 처리하는 서비스 로직에서는 결국 pool size와 connection 대기 시간 또한 조절해 가는 세밀한 작업이 이루어져야 한다는 것도 볼 수 있었다.