olrlobt
[Java] IntelliJ Profiler로 병목지점 찾아, Java ImageIO 성능 개선하기 본문
SSAFY를 진행하면서 개인 프로젝트로, Github README에 블로그 포스팅을 SVG 이미지 박스 형태로 띄워주는 위젯을 개발했었다. 적극적으로 홍보해서 사용자를 늘리고 싶은 마음은 있었지만, 아직 완성도가 낮다고 판단하여 그런 행동을 취하지는 않았었다. 그리고 SSAFY가 끝나고, 취업 준비를 하면서 바쁜 와중에 시간을 조금씩 투자하여 개선을 해보려고 한다.
1. 서비스 안정성 문제 원인 파악
현재 서비스 사용자를 적극적으로 늘리지 않은 가장 큰 이유는 안정성 문제였다.
Github README 자체에서 API로 내 프로젝트에서 생성된 이미지를 가져오는 과정에서, 어떠한 이유에서인지 위와 같이 엑스박스 형태로 제대로 서비스되지 않는 경우가 발생한다.
하지만 개발자 모드로 Github에서 이미지를 로드하는 과정을 살펴보면 어째서인지 파일 URL로 접근이 가능하고, 사진도 제대로 나오는 것을 알 수 있었다. 따라서 서버에서 이미지를 잘못 호스팅 해주고 있는 것이 아니라, 다른 문제라는 것을 알 수 있었다.
여기서 한 가지 추측해 볼 수 있는 것은, 이미지 서버 응답시간에 따른 타임아웃이다.
Github README는 빠르고 안전하게 이미지를 보여주어야 하고, 이를 위해 이미지 프록시 서버인 Camo를 사용한다. 사용자가 이미지를 등록하거나 나처럼 API에서 호스팅 해주는 경우에도 모두 Camo서버에 저장하고 가져오는 과정으로 이루어진다.
하지만 여기서 API 서버의 응답 시간이 길면 Camo가 요청을 타임아웃 처리를 할 수 있기 때문에 엑스박스가 표시된다는 가정이다. Github docs나 Camo에 관련된 docs를 찾아 응답시간에 관한 정보를 찾고 싶은데, 아무리 찾아도 못 찾겠어서 추측으로 진행하게 되었다. 😓
2. IntelliJ Profiler로 병목지점 찾기
이 전에는 성능을 개선을 본격적으로 하지 않았기 때문에 가장 단순한 방법으로 Log를 활용했었다.
API 실행 전과 후에 System.currentTimeMills()를 활용하여 시간 차이를 이용하여 단순하게 성능을 측정했다. 다른 Tool들을 학습하지 않고 바로 적용할 수 있는 방법이어서 많이 애용했었다.
그러다가 SpringAOP를 도입하여 조금 더 구조화시켰다.
SpringAOP를 이용하면 Aspect 관심사 설정으로 간편하게 원하는 메서드를 바꿀 수 있었다.
또한, 매번 성능 개선 코드를 썼다 지우는 것이 아니라, Git Ignore에 작성만 해주면 성능 개선코드를 포함시키지 않고 프로젝트를 Push 할 수 있는 것도 장점이었다.
@Aspect
@Component
@Slf4j
public class ExecutionTimeAspect {
@Pointcut("execution(* olrlobt.githubtistoryposting.api..*(..))")
public void applicationPackagePointcut() {
// Pointcut expression
}
@Pointcut("execution(* olrlobt.githubtistoryposting.service.ImageService..*(..))")
public void imageServiceMethods() {
// Pointcut expression
}
@Pointcut("execution(* olrlobt.githubtistoryposting.utils.ScrapingUtils..*(..))")
public void scrapingMethods() {
// Pointcut expression
}
// @Around("scrapingMethods()")
public Object measureExecutionTime2(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object proceed = joinPoint.proceed(); // 실제 메서드 실행
long executionTime = System.currentTimeMillis() - start;
String methodName = joinPoint.getSignature().toShortString();
log.info("{} scraping in {} ms", methodName, executionTime);
return proceed;
}
@Around("imageServiceMethods()")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object proceed = joinPoint.proceed(); // 실제 메서드 실행
long executionTime = System.currentTimeMillis() - start;
String methodName = joinPoint.getSignature().toShortString();
log.info("{} executed in {} ms", methodName, executionTime);
return proceed;
}
}
하지만, 이 방법에도 한계가 존재했다.
AOP로 성능 로그를 찍는 자체가 오버헤드를 유발할 수 있다는 점과, 보다 세밀한 성능 분석이 어렵다는 점이었다.
이런 한계점을 돌파하고 더 면밀한 성능 개선을 위해 다양한 툴을 찾아본 결과, 프로파일러를 도입하기로 하였다.
특히 내 프로젝트의 경우에는 DB를 사용하지 않고, API의 속도만 개선하면 되기 때문에 프로파일러는 아주 적합한 도구였다.
프로파일러(Profiler)
프로파일러(Profiler)는 소프트웨어 성능 분석 도구로, 애플리케이션이 실행되는 동안 발생하는 다양한 성능 관련 데이터를 수집하고 분석하는 데 사용된다. 프로파일러는 주로 CPU 사용량, 메모리 사용량, 메서드 호출 시간, 스레드 상태 등을 추적하여 성능 병목 구간을 찾아내고, 이를 기반으로 성능을 최적화할 수 있도록 도와준다.
대표적인 Java 프로파일러 도구:
- IntelliJ Profiler: IntelliJ IDEA에 내장된 프로파일러로, Java 애플리케이션의 성능을 실시간으로 분석하고 플레임그래프 등을 통해 성능 문제를 시각적으로 보여준다.
- JProfiler: 상용 도구로, Java 애플리케이션의 CPU, 메모리, 스레드 등 다양한 성능 지표를 심층적으로 분석할 수 있다.
- VisualVM: 무료로 제공되는 JVM 기반의 성능 분석 도구로, 간단한 CPU 및 메모리 프로파일링을 제공한다.
JProfiler와 VisualVM 모두 높은 사용자 점유율을 보인다. 특히 2015년 자료에서 VisualVM이 조금 앞서는 것으로 확인했는데, 이는 JProfiler가 유료이기 때문이라 생각한다. 하지만 StackOverFlow의 대부분의 글에서 JProfiler를 사용하는 것을 봐서는 확실히 유료 값을 하나보다.
마음 같아선 JProfiler를 이용하고 싶지만, 그럴 순 없으니 IntelliJ Ultimate 사용자에게만 제공되는 IntelliJ Profiler를 사용하기로 했다. Ultimate를 사용한 지는 꽤 됐는데, 프로파일링 기능을 제공하는지 사실 몰랐다. 😓
인텔리제이 프로파일러(IntelliJ Profiler) 사용하기
IntelliJ의 Profiler를 실행하는 것은 아주 간단하다.
Run > Profile 'Project' with 'IntelliJ Profiler'를 선택하면 된다.
그러면 SpringBoot가 실행되면서, 위와 같은 화면으로 바뀌면서 프로파일링을 수행한다.
Spring 로고 위에서는 "Profiling started"라는 메시지와 함께, 우측으로 CPU 사용량과 Heap Memory 사용량을 실시간으로 표기해 준다.
만약 관측을 끝냈다면, "Stop Recording and Show Results" 버튼을 통해 데이터 수집을 종료할 수 있다.
이 과정에서 사용하고자 하는 API를 한번 호출해 주고, 프로파일링을 종료하면,
위와 같은 화면으로 결과를 보여준다.
총 5개의 탭으로 분류되는데, 각 탭이 나타내는 것은 아래와 같다.
- 플레임 그래프: 성능 병목을 시각적으로 보여주는 그래프, 시간이 많이 걸린 함수가 넓게 표시됨.
- 콜 트리: 메서드 호출 경로를 트리 구조로 보여주는 도구, 호출 관계와 순서를 분석.
- 메서드 리스트: 메서드별 실행 시간과 호출 횟수를 리스트 형태로 제공, 문제 메서드를 세밀하게 분석.
- 타임라인: 시간 경과에 따른 CPU, 메모리 사용량 변화 등을 시각적으로 표현, 성능 변화를 시간대별로 추적.
- 이벤트 탭: Garbage Collection, 스레드 전환 등의 시스템 이벤트를 추적하여 성능 문제의 원인을 파악.
특히 IDE 내부에서 Hint 표기로 메서드 실행시간을 표기해 주는데, 이 기능이 내 프로젝트에서 특히 유용하게 작용하는 것 같다. 덕분에 병목지점을 아주 빠르게 찾을 수 있었고, 점차 개선해 나가고 있다.
3. Java ImageIO 성능 개선하기
IntellJ Profiler의 Method List 탭에서 검색 기능을 이용하여 CPU Time을 분석했다.
특히 createSvgImageBox()의 시간이 오래 걸렸고, 이 메서드는 크롤링한 정보들을 바탕으로 Svg 태그를 만드는 역할을 수행한다. 이 과정은 특히 썸네일 이미지를 만들거나, 작성자 이미지를 가져오거나, 제목이나 본문 텍스트를 작성하는 등 세부적인 작업들로 나누어져 있다.
특히 이미지를 그리는 과정에서 CDN서버에서 가져온 이미지를 SVG에 작성하기 위해서 Java의 ImageIO를 사용하는데, Java는 이미지 처리에 특화된 언어는 아니기 때문에 성능적인 문제가 자주 발생하곤 한다.
ImageIO는 StackOverFlow에 검색한 결과가 많이 나올 정도로 그 성능 문제가 특히 심각한데,
나는 이 ImageIO에서 Write는 사용하지 않고 Read만 사용하기 때문에 비교적 안정적이긴 하지만, 세 이미지를 로드하는 과정이 API 호출에서 상당히 많은 시간을 차지했다.
따라서 이를 개선하기 위해 TwelveMonkey 라이브러리를 도입하기로 한다.
TwelveMonkey 도입
TwelveMonkeys는 Java용 이미지 I/O 확장 라이브러리로, 기본 Java 이미지 I/O API에서 지원하지 않는 다양한 이미지 포맷을 읽고 쓸 수 있게 해 준다. 그뿐만 아니라, javax.imageio 패키지를 확장하여 더 많은 이미지 포맷에 대한 지원을 추가하고, 기존 이미지 I/O 기능의 성능을 향상하는 효과가 있다.
TwelveMonkeys는 특정 포맷, 특히 TIFF나 JPEG 2000과 같은 고해상도 이미지 포맷에 대해 더 나은 압축 및 해제 알고리즘을 사용하며, 이를 통해 속도 및 메모리 효율성이 개선된다고 한다.
기존 ImageIO와 완벽하게 호환되기 때문에, 별도의 코드를 추가하지 않고 성능 개선 효과를 맛볼 수 있다는 것이 가장 큰 장점이다.
https://mvnrepository.com/search?q=twelvemonkeys
나는 내가 선택한 이미지 포맷을 사용하는 것이 아니라, 사용자가 크롤링한 데이터의 이미지 포맷을 사용하기 때문에 대부분의 라이브러리를 포함시켜 주었다.
implementation 'com.twelvemonkeys.imageio:imageio-webp:3.11.0'
implementation 'com.twelvemonkeys.imageio:imageio-jpeg:3.11.0'
implementation 'com.twelvemonkeys.imageio:imageio-tiff:3.11.0'
implementation 'com.twelvemonkeys.imageio:imageio-core:3.11.0'
그리고 Gradle 코끼리를 한 번 돌려주고 테스트한 결과..
Thmbnail 476ms -> 180ms
AuthorImg 357ms -> 200ms
놀랍게도 약 50% 정도의 성능 향상을 보였다.
앞서 말했듯이 코드 수정은 거의 진행하지 않고 라이브러리만 추가한 결과이다.
Watermark와 같이 ImageIO를 사용하지 않는 부분에서는 변화가 없었지만, 라이브러리 하나만 적용해서 전체적으로 약 800ms의 처리 시간을 400ms로 절반 가까이 단축한 점은 매우 인상적이다.
4. OkHttp 개선
이미지 단축 시간을 줄이고 나니 전체적인 Svg 생성 시간은 약 800ms, 그리고 Url 스크래핑 시간은 약 500ms 정도의 시간이 소요됐다.
아무래도 README의 특성상 빠른 시간 안에 정보를 보여주어야 하니까 1,000ms 이내로 들어오게 하기 위해, 스크래핑 작업도 최적화를 검토했다.
현재 posting 메서드는 Jsoup을 이용한 웹 스크래핑을 바탕으로 Posting 객체를 만들어 주고 있다.
이 과정에서 특히 Jsoup을 이용한 웹 스크래핑 과정이 오래 걸렸는데, 이 부분의 원래 코드는 아래와 같다.
@Component
public class ScrapingUtils {
@Cacheable(cacheNames = "url", key = "#url", sync = true)
public Document byUrl(String url) throws IOException {
return Jsoup.connect(url).get();
}
}
Jsoup 라이브러리는 HTML 파싱에 매우 유용하지만, 네트워크 연결이나 응답 시간에 대한 추가적인 최적화가 부족했다.
특히, 네트워크 지연이나 일시적인 연결 문제를 처리하는 데 있어 한계를 보였다.
코드가 간결하고 직관적이었지만, 더 빠른 API 호출을 위해 OkHttp 라이브러리를 도입하였다.
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
@Component
public class ScrapingUtils {
private final OkHttpClient client;
public ScrapingUtils() {
this.client = new OkHttpClient.Builder()
.retryOnConnectionFailure(true) // 연결 실패 시 재시도
.build();
}
@Cacheable(cacheNames = "url", key = "#url", sync = true)
public Document byUrl(String url) throws IOException {
Request request = new Request.Builder().url(url).build();
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful() && response.body() != null) {
return Jsoup.parse(response.body().string());
}
}
return null;
}
}
OkHttp는 비동기 처리, 연결 실패 시 재시도, 그리고 커넥션 풀링과 같은 기능을 제공한다. 이로 인해 네트워크 성능이 향상되며, 안정적으로 데이터를 수집할 수 있게 한다.
특히 OkHttp에서 TCP 연결을 재사용할 수 있는 커넥션 풀링이 내 프로젝트에서 성능을 최적화시켜 주었다. 즉, 매번 새로운 네트워크 연결을 설정하지 않고, 기존 연결을 재사용함으로써 연결 시간을 줄여준 것이다.
이 외에도, OkHttpClient를 재사용할 수 있는 구조로 설계하여 다음번 요청에도 같은 객체를 사용할 수 있게 개선했다.
그 결과 기존 486ms -> 299ms로 성능이 약 38% 개선되었다.
이 외에도 Spring에서 아주 기본적인 Bean등록을 제대로 사용하지 못해 발생한 성능적인 문제를 IntellJ Profiler를 통해 쉽게 파악할 수 있었다.
그리고 Object Pooling을 도입하거나 CompletableFuture를 이용하여 병렬 작업을 수행하게 리팩토링하여 성능을 크게 개선했지만, 이 부분은 조금 더 자세히 다음 포스팅에서 다루어볼 예정이다.
지금까지 많은 개선을 했지만, Github README의 특정 페이지에서는 아직 이미지를 호스팅 하기까지 많은 시간이 소요되는 것으로 관측된다. 특히 내 프로젝트 소개 README에서 이런 현상이 발생해서 엑스박스가 뜨는데,,
사진의 용량이 크다는 것이 가장 가능성 있는 추측이고, 정확한 원인은 조금 더 살펴보는 과정을 거쳐야 할 것 같다.
앞으로도 IntelliJ의 Profiler 기능을 적극 활용할 계획이고, 앞선 문제를 해결하기 위해서는 서비스 자체에 다른 Profiler를 도입하는 것까지 고려해 봐야겠다.
'Spring > Project' 카테고리의 다른 글
[Java] CompletableFuture로 비동기 프로그래밍 구현하기 (10) | 2024.10.09 |
---|---|
[Java] Object pooling(오브젝트 풀링) - Apache Commons Pool2로 구현하기 (2) | 2024.10.05 |
[디자인 패턴] 전략 패턴(Strategy Pattern), 팩토리 패턴(Factory Pattern), 레지스트리 패턴(Registry Pattern) (0) | 2024.07.30 |
[추천 알고리즘] Spotify ANNOY (Approximate Nearest Neighbors Oh Yeah) 알고리즘이란? (3) | 2024.04.20 |
[토이 프로젝트] 블로그를 깃허브에 효과적으로 노출시키는 방법 (2) | 2024.03.28 |