포스트

[업무일지] 파일 크기 제한을 통과한 이미지 파일이 발생시킨 힙 OOM

계약 서류 이미지를 업로드하는 API에서 힙 OutOfMemoryError가 몇 분 사이에 버스트로 터졌습니다. 그런데 컨테이너는 재시작하지 않았습니다. 파일 크기는 검증하고 있었는데, 정작 힙을 먹는 건 파일 바이트가 아니라 디코딩된 뒤의 픽셀 수였습니다. 요청 하나가 쓰는 메모리를 축소 디코딩으로 캡하고, 동시에 도는 변환 수를 세마포어로 묶어서 막은 기록입니다.

Java 21, Spring Boot 3.3.x 환경입니다. 셀러가 계약할 때 올리는 서류 이미지를 서버가 PDF로 변환해 저장하는 흐름이었습니다. 이 글의 코드는 최소 재현 형태로 다시 썼고, 에러 코드 이름은 예시입니다.

컨테이너는 안 죽었는데 힙만 잠깐 포화됐다

어느 날 밤 이 업로드 API에서 java.lang.OutOfMemoryError: Java heap space가 몇 분 사이에 열몇 건 몰려서 떴습니다.

처음엔 파드가 죽었나 싶었습니다. 그런데 컨테이너 재시작은 0건이었습니다. OOMKilled도 없었고요.

JVM 힙(Xmx 1.2GB)이 순간 가득 찼다가 스스로 회복한 겁니다. 컨테이너 메모리 한도를 넘겨 죽은 게 아니라, 앱 레벨에서 힙이 잠깐 터진 거죠.

이게 더 골치 아팠습니다. 차라리 컨테이너가 죽으면 재시작 알림이라도 오는데, 앱 레벨 OOM은 요청 몇 개가 조용히 500으로 실패하고 지나간 뒤 아무 일 없었다는 듯 힙이 원래대로 돌아오기 때문에 한참 뒤에 로그를 뒤져야 흔적이 겨우 보입니다. 알림은 안 옵니다.

파일 크기 제한은 이미 통과한 파일이었다

업로드 경로에는 검증이 있었습니다. 확장자를 보고, 파일 크기가 50MB를 넘는지 봤죠.

문제가 된 요청들의 파일은 그 검증을 다 통과한 정상 파일이었습니다. 크기도 몇 MB 수준이었고요.

그래서 처음엔 요청이 몰린 건가 싶었는데, 트래픽은 평소와 다르지 않았습니다. 로그를 따라가니 한 셀러가 같은 업로드를 계속 재시도하고 있었습니다. 실패하면 다시 올리고, 또 실패하면 또 올리는 식으로요.

파일은 작습니다. 요청량도 평범합니다. 그런데 힙이 터집니다. 확장자도 맞고 크기도 50MB 아래라 업로드 검증을 문제없이 통과한 정상 파일이, 정작 서버 안에서는 힙을 태우고 있다는 게 이 문제에서 가장 먼저 이상했던 지점이었습니다.

문제는 바이트가 아니라 디코딩된 픽셀 수였다

변환 코드는 이렇게 시작했습니다.

1
2
// 업로드된 이미지를 PDF로 변환
BufferedImage image = ImageIO.read(imageFile.getInputStream());

ImageIO.read는 이미지를 원본 해상도 그대로 디코딩합니다. 결과인 BufferedImage는 압축이 풀린 픽셀 배열이고요.

여기가 함정이었습니다. 파일은 JPEG로 압축돼 있어서 몇 MB지만, 디코딩하면 픽셀 하나당 4바이트(ARGB)를 씁니다.

요즘 휴대폰 사진은 1억 화소를 넘기기도 합니다. 사업자등록증을 고화질로 찍어 올리면 그런 이미지가 들어옵니다. 1억 화소를 디코딩하면 raster 하나가 대략 400MB입니다.

게다가 변환 한 번에 이 복사본이 하나가 아니었습니다. 디코딩한 원본, PDF에 넣으려고 만드는 lossless 변환본, PDF 저장 버퍼가 겹쳐서 힙에 같이 올라옵니다.

파일 크기 50MB 제한은 압축된 바이트를 잽니다. 힙을 먹는 건 압축이 풀린 픽셀이고요. 이 둘은 100배 넘게 벌어질 수 있습니다. 크기 검증이 정상이라고 말한 파일이 디코딩 순간 수백 MB짜리가 되는 거죠.

여기에 두 가지가 더 겹쳤습니다. 하나는 방금 본 반복 재시도입니다. 실패한 요청이 다시 큰 이미지를 디코딩합니다.

다른 하나는 동시성이었습니다. 이 서비스는 톰캣 스레드풀 대신 Virtual Threads를 쓰고 있었거든요(Spring Boot 3.2+ spring.threads.virtual.enabled).

플랫폼 스레드풀은 크기에 상한이 있어서 동시 요청이 많으면 자연스럽게 큐에서 대기합니다. 백프레셔가 공짜로 걸리는 셈이죠. Virtual Thread에는 그 상한이 없습니다.

그래서 재시도가 겹치면 큰 이미지 디코딩이 동시에 여러 개 돌면서 힙이 누적됐습니다. 요청당 메모리가 크고, 그게 무제한으로 병렬로 쌓였으니 임계를 넘은 겁니다.

압축된 파일 크기와 디코딩된 raster 크기가 어긋나는 지점을 그림으로 옮기면 이렇습니다.

flowchart LR
    A["업로드 이미지<br/>JPEG · 수 MB"] -->|"ImageIO.read<br/>원본 해상도 디코딩"| B["원본 raster<br/>수백 MB<br/>1억 화소 ≈ 400MB"]
    B --> C["디코딩본 +<br/>lossless 변환본 +<br/>PDF 저장 버퍼<br/>동시 적재"]
    C -->|"재시도 ×<br/>Virtual Threads<br/>무제한 병렬"| D(["힙 OOM"])
    classDef bad fill:#c62828,color:#fff,stroke:#b71c1c
    class B,D bad

헤더로 치수만 먼저 읽고 축소해서 디코딩했다

막는 방향을 둘로 나눴습니다. 요청 하나가 쓰는 메모리를 줄이는 것, 그리고 동시에 도는 변환 수를 줄이는 것.

첫 번째부터 봤습니다. ImageIO.read는 이미지를 전부 펼쳐서 읽는데, 사실 그럴 필요가 없습니다. PDF로 넣을 때 원본 1억 화소가 필요한 게 아니라, 문서를 읽을 만한 해상도면 충분하니까요.

ImageIO에는 실제로 디코딩하기 전에 헤더에서 치수만 읽는 경로가 있습니다.

1
2
3
4
5
6
7
8
// 디코딩하지 않고 헤더로 픽셀 치수만 읽는다
try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
    Iterator<ImageReader> readers = ImageIO.getImageReaders(iis);
    ImageReader reader = readers.next();
    reader.setInput(iis, false, true);
    int longEdge = Math.max(reader.getWidth(0), reader.getHeight(0));
    // longEdge 로 얼마나 줄일지 계산
}

치수를 먼저 알면, 긴 변이 목표 상한(예: 2500px)을 넘을 때 몇 배로 줄여야 하는지 계산할 수 있습니다. 그다음 ImageReadParam.setSourceSubsampling으로 축소 디코딩을 합니다. 다 펼친 뒤 리사이즈하는 게 아니라, 디코딩하면서 픽셀을 건너뛰며 읽는 거죠.

1
2
3
4
5
6
7
8
9
int subsampling = longEdge > maxLongEdge
        ? (int) Math.ceil((double) longEdge / maxLongEdge)
        : 1;

ImageReadParam param = reader.getDefaultReadParam();
if (subsampling > 1) {
    param.setSourceSubsampling(subsampling, subsampling, 0, 0);
}
BufferedImage image = reader.read(0, param);

이러면 원본 full raster를 아예 힙에 올리지 않습니다. 절감 배수는 대략 (원본 긴변 / 목표 긴변)의 제곱이고요. 긴 변을 5분의 1로 줄이면 픽셀 수는 25분의 1이 됩니다. 1억 화소짜리가 400만 화소 수준으로 내려오는 거죠.

상한을 하나 더 뒀습니다. 긴 변이 절대 상한(예: 20000px)을 넘으면 축소 디코딩도 시도하지 않고 바로 거부합니다. subsampling은 출력 래스터만 줄이는데, 리더에 따라 디코딩 도중 입력을 대량으로 버퍼링하는 경우가 있어서, 디컴프레션 폭탄까지 축소로 받아주지는 않기로 했습니다.

포맷별 편차도 하나 걸렸습니다. TIFF는 리더가 subsampling을 제대로 처리하지 못하는 경우가 있었습니다. 그럴 땐 목표 이하 크기만 full read로 폴백하고, 목표를 넘는데 축소가 안 되는 이미지는 거부했습니다. 축소할 수단이 없는데 큰 걸 다 읽어버리면 OOM을 막으려던 이유가 사라지니까요.

이 절대 상한은 처음엔 낮게 잡아서 큰 이미지를 아예 거부하려고 했습니다. 그런데 고화질 사업자등록증은 정상 서류인데 거부당하면 셀러가 계약을 못 합니다. 그래서 거부가 아니라 축소해서 받는 쪽으로, 상한을 넉넉히 올렸습니다.

동시에 도는 변환 수를 세마포어로 묶었다

요청당 메모리를 캡해도 동시성 문제는 따로 남습니다. Virtual Thread엔 상한이 없으니, 축소된 변환이라도 수십 개가 동시에 돌면 힙은 다시 쌓일 수 있거든요.

그래서 무거운 변환 구간을 세마포어로 감쌌습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private final Semaphore semaphore = new Semaphore(3);

public <T> T runGuarded(Supplier<T> task) {
    boolean acquired = false;
    try {
        acquired = semaphore.tryAcquire(3, TimeUnit.SECONDS);
        if (!acquired) {
            // 무한 대기 대신 fast-fail
            throw new BizException(Result.ERR_CONVERSION_BUSY);
        }
        return task.get();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BizException(Result.ERR_CONVERSION_BUSY);
    } finally {
        if (acquired) {
            semaphore.release();
        }
    }
}

permit이 없으면 무한정 기다리지 않고 3초 안에 fast-fail합니다. 대기가 길어지면 그냥 처리 지연 에러를 돌려주는 거죠. 힙을 지키자고 세운 방어가 스레드를 무한정 붙잡고 있으면 그것도 문제니까요.

이건 근본 해결이라기보다 최종 안전망입니다. 미래에 더 큰 포맷이 들어오거나 상한 계산이 빗나가도, 동시에 도는 변환이 3개를 넘지 않으면 힙 누적은 거기서 막힙니다. 요청당 메모리(축소 디코딩)와 동시성(세마포어)을 둘 다 캡한 셈이죠.

한 가지 조심한 건 permit 중첩입니다. 한 요청이 세마포어를 잡은 채로 또 다른 세마포어 구간을 호출하면 데드락이 날 수 있어서, 가장 안쪽 변환 호출 하나만 감쌌습니다.

축소하면서 회전이 두 번 먹지 않게 했다

곁가지로 하나 더 신경 쓴 게 있습니다. 이 서비스는 같은 이미지에서 신분증 마스킹도 합니다. 예전에 EXIF 회전 정보 때문에 마스킹 좌표가 빗나간 적이 있었거든요.

정규화하면서 EXIF 회전을 픽셀에 반영하는데, 여기서 문제가 하나 생깁니다. 회전을 픽셀에 반영해도 원본 파일의 EXIF orientation 태그가 그대로 남아 있으면, 다음에 그 이미지를 다시 디코딩하는 쪽에서 회전이 또 먹습니다.

그래서 정규화 결과를 항상 JPEG로 재인코딩했습니다. JPEG로 다시 쓰면 orientation 태그가 안 남거든요. 회전이 픽셀에 딱 한 번만 반영되고, 이후 재디코딩에서 중복으로 돌지 않습니다.

그리고 OCR과 마스킹에 똑같은 정규화 바이트를 먹였습니다. 원본 해상도로 OCR을 돌리고 축소본에 마스킹하면 좌표계가 어긋나서 가려야 할 자리가 빗나갈 수 있으니까요. 같은 바이트를 두 경로에 흘려서 좌표계를 하나로 맞췄습니다.

맺음말

결과는 실측으로 확인했습니다. 1억 3천만 화소짜리 이미지가 옛 코드에서는 힙 256MB로도 OOM이 났는데, 축소 디코딩을 태우니 힙 128MB에서도 OOM 없이 처리됐습니다.

에러 응답도 같이 손봤습니다. 예전엔 이런 실패가 전부 generic 500이었습니다. 클라이언트는 뭐가 잘못됐는지 모르니 재시도만 반복했고, 그 재시도가 힙을 또 태웠죠. 해상도 초과와 처리 지연을 각각 행동 가능한 에러로 돌려주면서 그 고리를 끊었습니다.

포기한 것도 있습니다. PDF 업로드 경로는 이번 축소 디코딩을 타지 않습니다. PDF를 이미지로 렌더하는 경로는 기존 DPI 캡과 세마포어로만 막고 있고요. 초고해상 페이지가 박힌 PDF는 렌더 한 건이 수백 MB를 쓸 수 있어서 여지가 남았습니다. 업로드 비중이 낮아 후순위로 미뤘습니다.

세마포어 대기가 트랜잭션 안에서 일어난다는 것도 걸립니다. 업로드 처리가 @Transactional이라, permit을 기다리는 동안 DB 커넥션을 쥔 채로 있습니다. 대기 타임아웃을 3초로 묶어두긴 했지만, 무거운 변환을 트랜잭션 밖으로 빼는 리팩토링은 다음으로 넘겼습니다.

알림도 아직 약합니다. 컨테이너가 안 죽는 앱 레벨 OOM이라 조기에 잡기가 어렵거든요. 힙 사용률을 따로 감시하는 건 별도 작업으로 잡아뒀습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.