MS-SQL 2012 이전 (11.X 미만)

예전의 MS-SQL (2012 이전)에서는 페이징 쿼리를 하기 위해서는
보통 ROW_NUMBER() 를 사용하여 정렬순으로 번호를 부여한 다음
중첩 쿼리을 이용하여 정렬번호를 기준으로 페이지에 해당하는 글을 불러오는 방법을 주로 썼습니다.

# ROW_NUMBER() + 중첩 쿼리를 이용한 기존 방식
SELECT T.MEMBER_ID
  FROM (
        SELECT ROW_NUMBER() OVER (ORDER BY MEMBER_ID) AS ROWNUM,
               MEMBER_ID
          FROM MEMBER_TABLE WITH (NOLOCK)
       ) T
 WHERE T.ROWNUM BETWEEN ({pageNo} - 1) * {pageSize} + 1 AND {pageNo} * {pageSize}

위 쿼리에서 MEM_ID로 우선 정렬한 후 ROW_NUMBER()를 이용해 ROWNUM을 부여하고,
BETWEEN 등을 사용하여 탐색하려는 페이지의 첫 번째 ROWNUM부터 페이지의 마지막 ROWNUM 사이를 가져오는 식으로
탐색했습니다.

# 페이지 당 10개 씩 가져올 때
# 1페이지
SELECT T.MEMBER_ID
  FROM (
        SELECT ROW_NUMBER() OVER (ORDER BY MEMBER_ID) AS ROWNUM,
               MEMBER_ID
          FROM MEMBER_TABLE WITH (NOLOCK)
       ) T
 WHERE T.ROWNUM BETWEEN 1 AND 10
 
# 5페이지
SELECT T.MEMBER_ID
  FROM (
        SELECT ROW_NUMBER() OVER (ORDER BY MEMBER_ID) AS ROWNUM,
               MEMBER_ID
          FROM MEMBER_TABLE WITH (NOLOCK)
       ) T
 WHERE T.ROWNUM BETWEEN 41 AND 50

 

MS-SQL 2012 이후 (11.X 이상)

MS-SQL 2012 이후에 추가된 ORDER BY 절의 OFFSET - FETCH 문을 통해
중첩 쿼리 없이 한 번에 가져올 수 있습니다.

# 양식
ORDER BY {정렬 대상 컬럼}
    OFFSET {건너 뛸 행의 수} ROWS
    FETCH NEXT {가져올 행의 수} ROWS ONLY

ORDER BY를 통해 정렬 기준을 정한 뒤
OFFSET을 통해 건너 뛸 행의 수를 먼저 설정하고
(1부터 가져올 경우 OFFSET 0 ROWS, 11부터 가져올 경우 OFFSET 10 ROWS)

FETCH NEXT 에서 몇 개의 행을 가져올 지 결정하게 됩니다.
(한 페이지 내에 10개를 가져올 경우 FETCH NEXT 10 ROWS ONLY)

# OFFSET - FETCH 를 사용한 예
SELECT MEMBER_ID
  FROM MEMBER_TABLE WITH (NOLOCK)
 ORDER BY MEMBER_ID
       OFFSET ({pageNo} - 1) * {pageSize} ROWS
       FETCH NEXT {pageSize} ROWS ONLY
       
# 1페이지
SELECT MEMBER_ID
  FROM MEMBER_TABLE WITH (NOLOCK)
 ORDER BY MEMBER_ID
       OFFSET 0 ROWS
       FETCH NEXT 10 ROWS ONLY
       
# 5페이지
SELECT MEMBER_ID
  FROM MEMBER_TABLE WITH (NOLOCK)
 ORDER BY MEMBER_ID
       OFFSET 40 ROWS
       FETCH NEXT 10 ROWS ONLY       

 

중첩 쿼리를 쓰지 않아서 실행계획도 훨씬 간단해집니다.

그림 1) ROW_NUMBER() + 중첩쿼리 사용한 경우 실행 계획

 

그림 2) OFFSET - FETCH 적용하여 단일 쿼리로 수행한 경우 실행 계획

 

쿼리도 간단해지고 성능 상의 이점도 가질 수 있습니다.

참고
Microsoft Docs: docs.microsoft.com/ko-kr/sql/t-sql/queries/select-order-by-clause-transact-sql?view=sql-server-ver15

※ 여기 있는 내용은 구글링을 통해 수집한 정보를 바탕으로 기존의 소스들을 재구성한 내용입니다.

참고한 소스의 URL은 글 하단에 표기하였습니다.

 

기존의 운영되던 프로젝트를 확인하다 레거시 쪽에 API를 호출하는 부분을 살펴 보게 되었습니다.

한 화면에 여러 결과를 한꺼번에 모아 보여주다 보니 페이지 접속 시 API 호출이 5번 발생하게 되더군요.

Apache의 Http Component 4.5.X를 사용하고 있는데,

좀 더 자세히 살펴 보니 API를 호출할 때 마다 Apache HttpClient를 생성하고 자원을 반환하고 있었습니다.

 

표 1) 기존 소스

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
CloseableHttpClient httpClient = null;
CloseableHttpResponse response = null;
 
try {
    URI uri = new URIBuilder()
            .setScheme("http")
            .setHost(...)
            .setPath(...)
            .build();
    HttpPost httpPost = new HttpPost(uri);
    httpClient = HttpClients.createMinimal();
    response = httpClient.execute(httpPost);
    ...
catch (URISyntaxException | IOException exception) {
    throw new ApiCallException("API 조회에 실패하였습니다.", exception);
finally {
    if (response != null) {
        try {
            response.close();
        } catch (IOException ignore) {
            LOGGER.debug("HttpResponse Close 에러", ignore);
        }
    }
    if (httpClient != null) {
        try {
            httpClient.close();
        } catch (IOException ignore) {
            LOGGER.debug("HttpClient Close 에러", ignore);
        }
    }
}
 

여기서는 HttpClients.createMinimal()를 사용하고 있는데, createMinimal()의 소스를 보면

HttpClient를 객체를 새로 생성하고 PoolingHttpClientConnectionManager를 등록하고 있습니다.

 

표 2) HttpClients 소스 일부

1
2
3
public static CloseableHttpClient createMinimal() {
    return new MinimalHttpClient(new PoolingHttpClientConnectionManager());}
}

결국 API 호출 후 HttpClient 자원을 반환하면서 Connection Pool 도 같이 반환하게 되어

결과적으로 Connection Pool을 전혀 사용하지 못 하는 상황이 되었습니다.

 

우선 PoolingHttpClientConnectionManager를 등록해서 사용할 수 있도록 Customizing 된 HttpClient가 필요했습니다.

 

표 3) HttpClient 빈 등록

1
2
3
4
5
6
7
8
9
10
@Configuration
public class HttpClientConfig {
 
    @Bean
    public CloseableHttpClient myHttpClient() {
        return HttpClients.custom()
                .build();
    }
 
}

먼저 HttpClient 객체를 재사용할 수 있도록 Bean으로 등록하였습니다.

 

이어서 해당 HttpClient에서 사용할 PoolingHttpClientConnectionMananger 등록이 필요했습니다.

 

표 4) PoolingHttpClietConnectionManager 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class HttpClientConfig {
 
    @Bean
    public CloseableHttpClient myHttpClient() {
        return HttpClients.custom()
                .setConnectionManager(poolingHttpClientConnectionManager())
                .build();
    }
 
    private PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        return connectionManager;
    }
 
}
 

HttpClient에 Pooling Connection Manager까지 추가했습니다.

그런데 PoolingHttpClientConnectionManager는 기본값으로 Max Connection은 20, Route 당 Connection 수는 2였습니다.

실제 운영환경에서는 접속자가 많아질 경우 Connection 할당이 되지 않는 경우가 생길 거 같네요.

적당한 값으로 조정했습니다.

 

표 5) Max Connection 수 및 Route 당 Connection 수 할당

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
public class HttpClientConfig {
 
    private static final int MAX_CONNECTIONS_PER_ROUTE = 10;
    private static final int MAX_CONNECTIONS_TOTAL = 100;
 
    @Bean
    public CloseableHttpClient myHttpClient() {
        return HttpClients.custom()
                .setConnectionManager(poolingHttpClientConnectionManager())
                .build();
    }
 
    private PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        connectionManager.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE);
        connectionManager.setMaxTotal(MAX_CONNECTIONS_TOTAL);
        return connectionManager;
    }
 
}
 

이렇게 해서 Pooling Connection Mananger가 적용된 HttpClient Bean이 만들어졌습니다.

이제 호출해서 잘 쓰기만 하면 되겠네요.

 

저희 서비스는 Spring 환경에서 구축되었기 때문에 HttpClient Bean을 Injection 하여 사용하였습니다.

 

표 6) Customizing된 HttpClient Bean을 이용한 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
private CloseableHttpClient myHttpClient;
 
@Autowired
public void setMyHttpClient(CloseableHttpClient myHttpClient) {
    this.myHttpClient = myHttpClient;
}
 
...
 
// 실제 비즈니스 코드 부분
CloseableHttpResponse response = null;
 
try {
    URI uri = new URIBuilder()
            .setScheme("http")
            .setHost(...)
            .setPath(...)
            .build();
    HttpPost httpPost = new HttpPost(uri);
    response = myHttpClient.execute(httpPost);
    ...
catch (URISyntaxException | IOException exception) {
    throw new ApiCallException("API 조회에 실패하였습니다.", exception);
finally {
    if (response != null) {
        try {
            response.close();
        } catch (IOException ignore) {
            LOGGER.debug("HttpResponse Close 에러", ignore);
        }
    }
}
 
 

이제 Connection Pool이 적용된 HttpClient를 사용하여 API를 호출하게 되었습니다.

이제 매번 Socket Open을 하지 않아도 되게 되었습니다.

 

여기까지만 해도 코드는 잘 작동합니다.

그런데 관련 내용을 좀 더 찾다 보니 Idle Connection에 대한 자원 반환 이슈가 있더군요.

Socket 통신을 할 경우 정상적으로 종료되지 않을 경우 TIME_WAIT에 걸리며 자원이 제대로 반환되지 않는 현상이 발생하게 되고

TIME_WAIT이 점점 늘어나다 보면 나중에는 할당할 수 있는 자원이 남지 않게 되지요.

 

만료되거나 비정상적으로 종료된 Connection에 대한 반환 작업이 필요했습니다.

 

주기적으로 사용하지 않는 Connection을 반환하기 위해서는

스프링의 스케줄링 기능을 이용하여 자원 반환을 지속 수행하도록 해야 했습니다.

 

물론 앞서 만들었던 Connection Manager도 Bean으로 등록하여

자원 반환 Thread에서 Injection 하여 사용하는 것이 필요했습니다.

 

표 7) 사용하지 않는 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
@Configuration
@EnableScheduling
public class HttpClientConfig {
 
    private static final int MAX_CONNECTIONS_PER_ROUTE = 10;
    private static final int MAX_CONNECTIONS_TOTAL = 100;
 
    private static final int IDLE_TIMEOUT = 30 * 1000;
 
    @Bean
    public CloseableHttpClient myHttpClient() {
        return HttpClients.custom()
                .setConnectionManager(poolingHttpClientConnectionManager())
                .build();
    }
 
    @Bean
    public PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        connectionManager.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE);
        connectionManager.setMaxTotal(MAX_CONNECTIONS_TOTAL);
        return connectionManager;
    }
 
    @Bean
    public Runnable idleConnectionMonitor(final PoolingHttpClientConnectionManager connectionManager) {
        return new Runnable() {
            @Override
            @Scheduled(fixedDelay = 30 * 1000)
            public void run() {
                try {
                    if (connectionManager != null) {
                        LOGGER.info("{} : 만료 또는 Idle 커넥션 종료.", Thread.currentThread().getName());
                        connectionManager.closeExpiredConnections();
                        connectionManager.closeIdleConnections(IDLE_TIMEOUT, TimeUnit.MILLISECONDS);
                    } else {
                        LOGGER.info("{} : ConnectionManager가 없습니다.", Thread.currentThread().getName());
                    }
                } catch (Exception e) {
                    LOGGER.error(Thread.currentThread().getName() + " : 만료 또는 Idle 커넥션 종료 중 예외 발생.", e);
                }
            }
        };
    }
}
 
 

ConnectionManager를 검사하여 이미 만료되었거나 Idle 상태인 Connection들을 종료해 주는 Bean을 만들어 30초(fixedDelay) 주기로 수행되도록 하였습니다.

IDLE_TIMEOUT은 30초로 설정하여 30초 동안 Connection을 재사용하지 않으면 Idle Connection으로 판단하여 종료되도록 하였습니다.

 

이제 Spring의 Scheduling을 이용할 Bean도 만들었으니 추가로 Scheduling을 관리하는 Configuration도 필요하겠죠.

 

표 8) Spring Scheduing Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class SchedulingConfig implements SchedulingConfigurer {
 
    private static final int POOL_SIZE = 10;
 
    @Override
    public void configureTasks(ScheduledTaskRegistrar scheduledTaskRegistrar) {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(POOL_SIZE);
        taskScheduler.setThreadNamePrefix("my-scheduled-task-pool-");
        taskScheduler.initialize();
 
        scheduledTaskRegistrar.setTaskScheduler(taskScheduler);
    }
}
 

이렇게  HttpClientConfig와 SchedulingConfig를 @Configuration으로 등록하고

비즈니스 로직에서 HttpClient Bean을 사용하여 Connection Pool을 사용하여 HttpClient를 사용하는 방법을 정리해 봤습니다.

 

구글링을 통해 수집한 정보를 제 나름대로 해석하여

필요에 맞게 가공하여 정리했기에 완벽하지 않습니다.

 

완전하지는 않지만 작업한 내용을 정리하기 위해 우선 기록해 둡니다.

수정되는 내용이 발생하거나 의견을 주시면 계속해서 수정, 반영해 나가도록 하겠습니다.

 

감사합니다.

 

참고

Tutorial - Connection management: http://hc.apache.org/httpcomponents-client-ga/tutorial/html/connmgmt.html

HttpClient Examples - Threaded request execution: https://hc.apache.org/httpcomponents-client-4.5.x/httpclient/examples/org/apache/http/examples/client/ClientMultiThreadedExecution.java

Apapche HttpComponent 제대로 사용하기: https://inyl.github.io/programming/2017/09/14/http_component.html

Spring RestTemplate + HttpClient configuration example: https://howtodoinjava.com/spring-boot2/resttemplate/resttemplate-httpclient-java-config/

How to Schedule Tasks with Spring Boot: https://www.callicoder.com/spring-boot-task-scheduling-with-scheduled-annotation/

커피 거리로 유명한 강릉 안목 해변입니다.

돌아와서 사진을 보니

정작 커피 거리 사진은 없네요.

 

커피 거리 한 카페에 들어가

커피 한 잔 마시고

잠시 해변을 내려다 보았습니다.

 

 

이날 굉장히 맑은 날씨였습니다.

서울도 미센먼지가 좋을 정도였으니...

필터 없이도 파란 하늘이 너무나도 잘 찍힌 날이었네요.

유난히도 바다가 파랐던 날이었습니다.

 

당닐 바람이 엄청 불었습니다.

너울성 파도 주의보가 내려져서

해수욕 하는 사람이 없더군요.

대신 파도 부서지는 장면은 장관이었습니다.

카메라에 제대로 담지를 못했네요.

 

아쉬운 대로 커피거리 상징 조형이라도 찍어봤습니다.

커피거리 인증

 

이날 굉장히 햇볕도 뜨거웠습니다.

한낮의 안목

 

해변가 중간 쯤에는 느린 우체통도 있었습니다.

1월과 7월... 이렇게 1년에 2번 수거를 해서 보내준다고 하네요.

안목커피거리의 카페나 상가에서 나눠주는 엽서를 이용하면 무료로 보내준다고 합니다.

저도 한 통 써볼까 했지만

편지를 써보기엔 너무 메마른 감정인 듯 하여...

 

 

조금 이른 동해 바다 바캉스였습니다.

(보도자료) 2018년도 공공기관 경영실적 평가결과 발표.hwp
0.19MB

공공기관 경영평가 기사가 났는데

보도자료의 텍스트 내용만 기사로 뜨고

기관평가 결과는 기사에 실리지 않았네요.

 

기획재정부 홈페이지에 가니 기관평가 결과가

보도자료에 첨부로 되어 있어 캡처해서 올립니다.

 

2019 공공기관 경영평가 기관평가 결과 - 보도자료 인용

 

기획재정부 홈페이지: http://www.moef.go.kr/

 

2019 공공기관 경영평가

 

www.moef.go.kr

보도자료 링크:

http://www.moef.go.kr/upload/_upload/temp/20190620/(%EB%B3%B4%EB%8F%84%EC%9E%90%EB%A3%8C)%202018%EB%85%84%EB%8F%84%20%EA%B3%B5%EA%B3%B5%EA%B8%B0%EA%B4%80%20%EA%B2%BD%EC%98%81%EC%8B%A4%EC%A0%81%20%ED%8F%89%EA%B0%80%EA%B2%B0%EA%B3%BC%20%EB%B0%9C%ED%91%9C.hwp

+ Recent posts