소개배경
현재 구상중인 하루하나 알고리즘은 Github API를 이용하여 하루하나 알고리즘 Repository에 Commit 유무를 판단한다.
이때 UserInfo(실제 API Response값과는 다르나 편의상 칭함)와 Commit 날짜를 Entity 형식으로 가져올 수 있다.
이를 RDB에 저장하여 Markdown형식의 파일로 만드는 작업을 하여 "알고리즘을 풀이한 날짜를 손쉽게 확인"하게 하는 것이 프로젝트의 목적이었다.
현재 해당프로젝트는 Oracle 서버에 CI를 구축하여 자동으로 매일밤 11시 55분에 실행시키는 작업을 구현하였는데 현재는 이슈가 발생하여 Server에서 내린상태이다.
여기서 말하는 이슈란 회원 저장 API의 응답속도가 상당히 느리다고 생각되어 이를 개선한 과정을 포스팅해보려고한다.
원인분석
우선 각 메서드의 실행시간을 초 단위로 환산하여 logging 작업을 수행했다. 이후 두 가지 지점에 문제가 생기는 것을 파악할 수 있었다.
- RestTemplate을 통한 Github API 응답(평균 1.66seconds)
- 이를 DTO화 시킨후 RDB까지의 저장(평균 0.851seconds)
해당 문제를 해결하기 위해 여러 방법들을 찾아본 결과 원인이 될만한 코드들이 있었고 이를 개선하기 위한 방법들을 이제부터 살펴보자
참고로, logging 작업의 경우 Junit 환경에서 Test 코드를 아래와 같이 작성하였다.
@Test
@DisplayName("RestTemplate 1회 Response 속도")
public void usedToRestTemplateConnectGit(){
long restStart = System.currentTimeMillis();
githubService.useToRestTemplate();
long restEnd = System.currentTimeMillis();
System.out.println("rest 1회 요청 평균 응답속도="+(double) ((restEnd-restStart)/1000.0));
}
아래는 test 코드에서 호출하는 githubService 코드의 일부이다.
public void useToRestTemplate(){
RestTemplate restTemplate=new RestTemplate();
String forObject = restTemplate.getForObject(url, String.class);
}
Github API 응답속도 개선하기 (최대 70% 개선)
우선 RestTemplate을 사용하게 된 이유는 프로젝트가 SpringBoot 환경이었기 때문에 단순히 간편성을 위함이었다.
SpringFramework에서 제공하는 HTTP 프로토콜 라이브러리였기에 안쓸이유가 없다고 생각했다.
하지만, 해당부분의 응답속도가 느렸기에 이를 개선하고자 이유를 찾게되었다.
RestTemplate을 버리게 된 이유
RestTemplate의 경우 SpringFramework에서 제공하여 편의성이 장점으로 존재하지만, 동기식으로 동작하며 요청/응답처리시에 호출자 스레드 자체를 차단하는 이슈가 있다.
이는 RestTemplate이 기본적으로 HttpURLConnection을 사용하여 HTTP 요청을 처리하는데 HttpURLConnection은 Synchronous(동기식) 방식으로 동작한다. 위의 코드에서는 "getForObject()" 메서드 호출 시 요청이 전송되고 응답이 올 때까지 호출자 스레드는 해당 메서드에서 블로킹(blocking)되어 대기하게 된다.
해당 대기시간으로 인해 성능저하가 발생할 수도 있다.
하지만 반드시 HttpURLConnection을 이용하는 것이 아니라 필요에 따라 내부 라이브러리를 바꿔서 사용하는데 응답속도가 느린 부분이 도무지 이해가 가지않았다.
그러던 중 GPT의 힘을 빌려 알아본 결과 RestTemplate 자체가 무거워서 그럴 수 있다는 의견이 있었다.
우리는 HttpURLConnection을 써야한다.
RestTemplate은 HttpURLConnection 방식을 이용하면서도 여타 라이브러리에 의존하고 오버헤드를 일으킬 가능성이 있어 조금 더 무거운 반면에, HttpURLConnection은 JDK에서 제공하기에 오버헤드가 적고 라이브러리를 쓰지않아 경량화 되어있어, 소규모의 데이터 처리는 HttpURLConnection의 속도가 더빠르게 보장될 수 있다는 점이다.
실제로 아래와 같이 TestCode를 작성해본 결과 더 빠른 응답을 보장하였다.
@Test
@DisplayName("HttpURLConnection 1회 Response 속도")
public void usedToHttpUrlConnectionGit(){
long hucStart = System.currentTimeMillis();
githubService.useToHttpURLConnection();
long hucEnd = System.currentTimeMillis();
System.out.println("HttpURLConnection 1회 요청 평균 응답속도="+(double) ((hucEnd-hucStart)/1000.0));
}
다만 만족스러운 결과가 아니었기에 이를 더 줄여보기위해 Http 통신 설정 값을 살펴본결과 Connection 생명주기 설정과 Compression을 사용을 하여 응답속도를 약 10% 단축시켰다.
참고로 Junit의 환경은 단순 호출이기에 응답속도가 비즈니스로직과는 차이가 크다.
conn.setRequestProperty("Connection", "Keep-Alive");
conn.setRequestProperty("Accept-Encoding", "gzip, deflate");
💡 Connection 생명주기와 Compression 개념
Connection 생명주기 : 클라이언트와 서버간 TCP 연결의 생명주기를 설정하는 것으로 Keep-Alive를 설정하게된다면 TCP 연결을 재사용하여 매요청마다 TCP 연결을 새로하지 않기에 연결시간을 절약할 수 있다.
Compression : 데이터를 압축하여 전송하는 것으로 이를 통해 데이터의 크기를 줄이고 전송시간을 절약할 수 있다.
혹시 비동기 처리하면 안되나요?
필자도 이 생각을 하여 비동기 통신을 지원하는 WebClient를 이용하여 코드를 작성하였다.
public void useToWebClient(){
WebClient webClient = WebClient.create();
String forObject = webClient.get()
.uri(url)
.retrieve()
.bodyToMono(String.class)
.block();
}
해당 방법의 경우 RestTemplate과 같이 편의성을 제공하면서도 비동기 통신이 가능하다는 장점이 있었다.
하지만 응답속도는 처참했다.
"왜 비동기 방식이 더 오래걸리는 거지?" 라는 생각을 하며 또 다시 이유를 찾아보았다.
찾아본결과 앞서 RestTemplate과 비슷한 이유로 라이브러리로 인한 무거움과 비동기 방식 구현을 위한 I/O처리를 이용할 때 더 많은 양의 리소스를 사용한다. 그렇기에 비동기 방식은 대량의 데이터를 처리할때는 이점을 얻을 수 있지만, 소규모의 데이터 처리 방식에서는 이점을 얻을 수 없었다.
여타 OkHttp나 HttpComponents와 같은 방식도 HttpURLConnection보다 단축시킬 수는 없었다.
HttpURLConnection으로 바꾸어 코드를 전체 실행시킨 결과 평균 1.6초의 시간을 최적화시켜 평균 0.6~0.65초(70% API개선)까지의 성능을 최적화 시켰다. (아마 편차가 심한 이유는 Github 서버의 상황이나 현재 네트워크의 영향이 있을 것이다.)
그렇다면 API 통신부분을 70%나 감소시켰으니 이번에는 두번째 DTO를 RDB에 저장하는 코드를 최적화시켜보는 작업을 가져보자.
Redis가 문제인가?(Redis 저장속도 개선 50%)
현재 RDB까지의 WorkFlow는 아래와같다.
- Response된 값을 JSON Entity화 시켜 JSON Array에 저장한다.
- JSON Array에서 forEach를 이용하여 DTO화시킨다.
- 이를 MemberService쪽으로 넘겨 Redis에 모두 저장시킨다.(문제되는 부분)
- 그후, 이미 있는 데이터인지 SpringDataJPA를 통해 Table에서 찾고 있다면 Update 없다면 Save하는 로직을 구현한다.
여기서 Redis를 사용한 이유는 이후 Commit의 유무를 판단하기위해 모든 커밋 데이터를 임시 저장할 저장소가 필요하여 Inmemory DB인 Redis를 채택하였다.
하지만 Redis를 최초 Connect 및 Save하는 작업이 평균적으로 0.8초정도 소요되는 것을 확인할 수있었다.
아래는 Redis에 저장을 하는 코드이며, CRUDRepository를 상속한 Repository를 이용하여 구현하였다.
@Transactional
public void saveTempMemberCommitList(MemberCommitDTO memberCommitDTO){
long saveStart = System.currentTimeMillis();
memberCommitRepository.save(memberCommitDTO.toEntity());
long saveEnd = System.currentTimeMillis();
System.out.println("redisSave="+(double)((saveEnd-saveStart)/1000.0)+"seconds");
}
위의 사진을 보면 Redis에서 최초 Connect 이후에는 상당히 빠른속도를 보여준다.
이말은 즉, 최초 Connect 부분을 해결하면 API의 응답속도를 줄일 수 있다는 말이었다.
정말 간단히 해결
제목 그대로 해당 이슈는 정말 간단히 해결할 수 있었다.
Redis를 사용하는 Service Layer에 @PostConstruct 어노테이션을 이용하여 어플리케이션 실행시에 이미 Redis Connection을 최초로 접근하여 Service Layer에서의 시간을 최소화 시켰다.
결론
정말 최적화를 하겠다는 생각으로 맨땅에 헤딩하듯 했던 작업이지만 최적화를 시킬 수 있는 요소가 생각보다 많다는 것에 놀랐다. 그리고 70%,50%로 총 API 응답 시간을 약 70%정도 감소시켰다는 점에서 꽤나 뜻깊은 수확을 걷었다고 생각한다.
평소 클린코드와 최적화에 관심을 두고 있었지만 이론적인 부분이 아닌, 직접 마주하니 더 흥미가 있는 작업이었던 것같다.