olrlobt
[Java] Object pooling(오브젝트 풀링) - Apache Commons Pool2로 구현하기 본문
[Java] Object pooling(오브젝트 풀링) - Apache Commons Pool2로 구현하기
olrlobt 2024. 10. 5. 01:23
Object pooling (오브젝트 풀링)
Object pooling은 자주 사용되는 객체를 미리 생성하고 재사용하여 메모리 할당 및 해제의 오버헤드를 줄이는 기술이다. Java와 같은 언어에서는 객체를 빈번하게 생성하고 폐기하는 작업이 성능에 큰 영향을 미칠 수 있다. 특히 복잡한 객체나 자주 호출되는 객체가 있는 경우, 이러한 작업은 GC(Garbage Collection)에 많은 부하를 줄 수 있으며, 이는 전체 시스템 성능에 악영향을 미치게 된다.
이때 Object pooling을 사용하면 자주 사용하는 객체들을 미리 만들어 두고 필요할 때 재활용할 수 있다. 객체를 새로 생성하는 대신, 풀(pool)에서 기존 객체를 가져와 사용한 후 다시 반납하는 방식으로 동작한다. 이를 통해 메모리 할당 비용을 줄이고, 성능을 크게 향상시킬 수 있다.
나의 경우에는 개인 프로젝트에서 SVGGraphics2D 객체를 매번 만들어 주는 작업이 있었다. 이 과정에서 적지 않은 오버헤드가 발생하는 것을 느꼈고, 이를 개선하기 위해 미리 만들어둔 설정으로 SVGGraphics2D객체를 만들 수 있으면 좋겠다는 고민을 했다. Clone()과 깊은 복사 등 많은 고민을 했지만, 기본적으로 SVGGraphics2D 객체는 깊은 복사를 지원하지 않았고, 얕은 복사를 하게 되면 API 작동 과정에서 다양한 설정들이 겹쳐 원하지 않는 결과물이 탄생하게 되었다.
이를 해결하기 위해 깊은 복사 대신, Object Pooling을 통해 SVGGraphics2D 객체를 재사용하는 방법을 도입했다.
특히 깊은 복사가 불가능한 객체도 미리 생성해 두고 재사용하고 반환할 수 있다는 점은 내 프로젝트에 너무 딱 맞는 기술이었다.
Java에서 Object Pooling 이해하기
public class ObjectPool<T> {
private Queue<T> pool = new LinkedList<>();
private int maxSize;
public ObjectPool(int maxSize) {
this.maxSize = maxSize;
}
public synchronized T borrowObject(Supplier<T> creator) {
if (pool.isEmpty()) {
return creator.get();
}
return pool.poll();
}
public synchronized void returnObject(T obj) {
if (pool.size() < maxSize) {
pool.offer(obj);
}
}
}
위 코드는 Supplier<T>를 통해 Java에서 Object Pool을 직접 구현하는 가장 간단한 방법으로, 객체 생성 방식을 유연하게 제공한다. 코드가 직관적이기 때문에 알아보기 쉬울 것이며, borrowObject() 메서드를 사용해 객체를 풀에서 가져오고, returnObject()로 객체를 반납하는 방식으로 작동한다.
borrowObject()와 returnObject()에서는 synchronized를 사용하여 여러 스레드가 동시에 객체 풀에 접근할 때의 안전성을 보장해야 한다.
이 방법은 간단한 경우에 유용한 방법이지만, 객체 생명주기 관리 및 동기화 등 여러 복잡한 부분은 추가로 고려해야 하기 때문에 권장되는 방식은 아니며 되도록이면 아래 설명하는 라이브러릴 사용하도록 하자.
Apache Commons Pool2 라이브러리 활용
Java에서 Object Pooling을 구현할 때, Apache Commons Pool2 라이브러리를 사용할 수 있다. commons-pool2는 객체 풀을 쉽게 관리할 수 있도록 다양한 기능을 제공하며, 성능 최적화와 자원 관리에 유리한 장점이 있다.
우선 Object Pooling을 적용하기 전, 나는 이 전 게시물에서 알아본 IntelliJ Profiler를 이용하여 기존에 얼마만큼의 시간과 메모리가 소모되었는지 측정하였다.
public static SVGGraphics2D init() {
DOMImplementation domImpl = GenericDOMImplementation.getDOMImplementation();
Document document = domImpl.createDocument(NAMESPACE_URL, SVG_ELEMENT, null);
SVGGraphics2D svgGenerator = new SVGGraphics2D(document);
svgGenerator.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
svgGenerator.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
svgGenerator.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED);
svgGenerator.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_SPEED);
svgGenerator.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED);
svgGenerator.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_OFF);
return svgGenerator;
}
기존 SvgUtils의 init() 메서드의 경우, 위와 같이 DOM객체를 생성하여 SVG를 그리기 위해 각종 렌더링 작업을 거친 후 객체를 반환하는 메서드로 구성해 놓았다.
SVGGraphics2D 객체를 한 번 생성하는 데 걸리는 시간을 측정해 보니 약 200ms가 소요되었고, API를 호출할 때마다 필수적으로 한 번 호출되는 구조이기 때문에 성능 향상을 위해선 개선이 필수적이었다.
또한, IntelliJ Profiler를 이용하면 해당 메서드가 소모한 메모리를 확인할 수 있는데, API 호출 한 번당 60.47MB 정도가 소모됐다.
이제 이 구조에 Object Pooling을 적용하고 개선량을 확인해 보자.
Apache Commons Pool2 적용
implementation group: 'org.apache.commons', name: 'commons-pool2', version: '2.12.0'
먼저 commons-pool2를 사용하기 위해 Gradle에 의존성을 추가해 주었다.
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.apache.commons.pool2.BasePooledObjectFactory;
@Component
public class SvgFactory extends BasePooledObjectFactory<SVGGraphics2D> {
@Override
public SVGGraphics2D create() {
DOMImplementation domImpl = GenericDOMImplementation.getDOMImplementation();
Document document = domImpl.createDocument("http://www.w3.org/2000/svg", "svg", null);
SVGGraphics2D svgGenerator = new SVGGraphics2D(document);
svgGenerator.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
svgGenerator.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
svgGenerator.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED);
svgGenerator.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_SPEED);
svgGenerator.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED);
svgGenerator.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_OFF);
return svgGenerator;
}
@Override
public PooledObject<SVGGraphics2D> wrap(SVGGraphics2D svgGraphics2D) {
return new DefaultPooledObject<>(svgGraphics2D);
}
@Override
public void passivateObject(PooledObject<SVGGraphics2D> pooledObject) {
// 객체 반환 시 초기화 작업 (예: 객체 상태 초기화)
SVGGraphics2D svgGenerator = pooledObject.getObject();
svgGenerator.setSVGCanvasSize(new java.awt.Dimension(0, 0));
}
}
다음으로, 객체 풀을 관리하기 위해 BasePooledObjectFactory 추상 클래스를 사용하여 풀에서 사용되는 메서드들을 구현했다. BasePooledObjectFactory의 구현 메서드들은 각각 다음과 같다.
create(): 객체 생성
- 풀에 넣을 객체를 생성하는 메서드로 객체가 풀에 처음으로 추가될 때 호출된다. 기존 init()에 있던 코드를 넣어주었다.
wrap(): 객체를 풀에 넣을 때 래핑
- 생성된 객체를 풀에서 관리할 수 있는 형태로 감싸서 반환한다. 풀 내에서 객체의 상태를 모니터링하거나 관리할 수 있도록 객체를 포장하는 역할을 한다. DefaultPooledObject는 commons-pool2에서 제공하는 기본 래핑 클래스이다. 이를 통해 풀에서 관리할 객체를 감싸고, 객체의 상태를 추적할 수 있다.
passivateObject(): 객체 반환 시 초기화
- 풀에서 객체가 사용된 후 반환될 때, 객체를 초기 상태로 되돌리는 작업을 한다. 즉, 객체가 다시 사용되기 전에 상태를 재설정하는 것이다. 간단하게 SVGGraphics2D를 0의 크기로 초기화하는 작업을 했다.
객체 풀을 관리하기 위한 작업을 마쳤으면, 객체를 사용하기 위한 Pool을 구성해야 한다.
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
@Component
//@Lazy(false)
public class SvgPool {
private final GenericObjectPool<SVGGraphics2D> pool;
public SvgPool() {
GenericObjectPoolConfig<SVGGraphics2D> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(20); // 최대 풀 크기 설정
config.setMinIdle(10); // 최소 유휴 객체 수
config.setMaxIdle(20); // 최대 유휴 객체 수
config.setBlockWhenExhausted(true); // 풀에 여유 객체가 없을 때 대기
this.pool = new GenericObjectPool<>(new SvgFactory(), config);
try {
for (int i = 0; i < pool.getMinIdle(); i++) {
pool.addObject();
}
} catch (Exception ignored) {
}
}
// 객체 대여
public SVGGraphics2D borrowObject() {
try {
return pool.borrowObject();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 객체 반환
public void returnObject(SVGGraphics2D svgGraphics2D) {
pool.returnObject(svgGraphics2D);
}
}
GenericObjectPool은 Apache Commons Pool2 라이브러리에서 제공하는 객체 풀 구현체이다. 해당 코드에서는 SVGGraphics2 D 객체들을 관리하는 Pool 역할을 한다.
시스템이 시작될 때 풀이 생성되도록 @Lazy(false)를 사용할 수 있지만, 나는 이미 애플리케이션 시작 시 Service 클래스에서 생성자 주입 방식을 통해 SvgPool Bean을 생성했다. 만약 서비스에서 바로 Bean을 생성하지 않는다면, Pool에서 객체를 만들어 두는 성능상 이점이 줄어들 수 있으니 조건을 잘 고려해서 설정하자.
SvgPool이 생성되면, GenericObjectPoolConfig를 이용하여 Pool에 대한 설정을 해준다. 이때, GenericObjectPool을 통해 객체 풀을 관리하는 클래스를 만들어주고, 여기서는 이전에 만든 SvgFactory를 인자로 넣어주어 객체 관리를 설정한다.
pool.addObject()의 경우 애플리케이션 시작 시 최소 유휴 객체를 생성하는 부분이다. 이 부분은 서비스에 따라 다르지만, 내 서비스에 경우 SVGGraphics2D을 생성에 드는 비용이 크기 때문에, 첫 요청에서 성능을 유지하고자 넣어 주었다. 만약 서비스에 따라 필요하지 않다면 제거해 주도록 하자.
borrowObject()는 객체를 대여하는 부분으로, Pool에서 객체를 하나 꺼내어 반환한다. 만약 현재 사용 가능한 객체가 없다면, 설정에 따라 대기하거나 예외를 발생시킬 수 있다.
이 메서드를 통해 애플리케이션이 필요한 시점에 객체를 가져와 사용할 수 있다.
returnObject()는 객체를 반환하는 부분으로, 객체를 사용한 후에는 반드시 풀에 반환해야 다른 작업에서 재사용할 수 있다. 반환된 객체는 풀에서 유휴 상태로 유지되며, 다음 요청 시 재사용된다.
여기까지 마쳤다면 Apache Commons Pool2를 사용해서 Object pooling을 할 준비가 됐다.
이제 기존 init()으로 객체를 생성하던 부분을 borrowObject() 메서드를 이용하여 객체를 빌려와 주는 작업으로 바꾸어주고, 빌려온 객체를 돌려주기 위하여 returnObject() 메서드를 사용하는 형식으로 바꿔주었다. 이 부분을 특히 IntelliJ Profiler로 성능 측정을 하였지만 Hint가 나오지 않을 정도로 성능상 개선이 된 것이 보인다.
또한, 최대한 성능상의 이점을 얻기 위해 반환 작업은 finally 구문을 사용했다. 나의 경우에는 객체 반환 작업에서 SVG의 크기를 조절하는 작업이 포함되어 있는데, 이 부분에서 오버헤드가 발생할 가능성을 고려했다. finally구문을 사용하면 return이 실행된 후 API에서 값을 반환한 이후에나 객체 반환작업이 수행되며 오버헤드가 줄어들 것이다.
Apache Commons Pool2 적용 결과
확실한 성능 차이를 측정하기 위해 Profiling 결과를 비교해 보았다.
테스트는 당연히 이전과 동일한 환경에서 API를 호출하는 것으로 진행했다.
CPU 시간의 경우 기존 743ms -> 664ms로 약 10%, 100ms 가량 개선되었다.
메모리 사용량의 경우 60.47MB -> 5.03MB로 약 91.68%가량 개선되었다.
이처럼 Object Pooling은 반복적인 객체 생성 및 소멸 작업에서 성능 저하를 방지하는 데 큰 효과를 발휘하며, 대규모 시스템이나 복잡한 객체를 다루는 프로젝트에서 매우 유용한 방법이다.
Profiler를 도입하면서부터 서비스의 병목 지점을 쉽게 찾아낼 수 있었고, 이를 통해 Object Pooling과 같은 성능 최적화 기법을 학습하고 적용할 수 있었다. 이를 통해 많은 개선을 이루어 냈지만, 배포 환경에서는 아직도 개선할 점이 한 투성이다. 특히 IntelliJ Profiler는 개발 환경에서 주로 사용되기 때문에 한계가 많이 느껴지는 부분이 있었다. 다음에는 VisualVM을 서비스에 도입해서 배포환경에서의 병목 지점도 해결해 볼 계획이다.
'Spring > Project' 카테고리의 다른 글
[Spring] Monitoring 도구 Prometheus란? (12) | 2024.10.16 |
---|---|
[Java] CompletableFuture로 비동기 프로그래밍 구현하기 (10) | 2024.10.09 |
[Java] IntelliJ Profiler로 병목지점 찾아, Java ImageIO 성능 개선하기 (5) | 2024.10.03 |
[디자인 패턴] 전략 패턴(Strategy Pattern), 팩토리 패턴(Factory Pattern), 레지스트리 패턴(Registry Pattern) (0) | 2024.07.30 |
[추천 알고리즘] Spotify ANNOY (Approximate Nearest Neighbors Oh Yeah) 알고리즘이란? (3) | 2024.04.20 |