ZIP Streaming · @Async · S3 Multipart Upload 실전 패턴 완전 가이드
TL;DR
- 이미 압축된 파일은 ZIP STORED 모드를 사용하고, 텍스트 계열은 DEFLATED로 선택해 CPU와 처리 시간을 최적화한다.
@Async는 프록시 기반이라 self-invocation을 피해야 하며,AsyncUncaughtExceptionHandler와 적절한 Executor 정책으로 운영 안정성을 확보해야 한다.- 대용량 업로드는 S3 Multipart Upload를 기본값으로 두고 part 크기·재시도·abort/lifecycle 정리를 함께 설계해야 한다.
1. 개념
이 문서는 ZIP 생성, Spring 비동기 처리(@Async), S3 Multipart Upload를 하나의 파이프라인으로 연결해 대용량 파일 처리 시스템의 성능·신뢰성·운영성을 높이는 아키텍처 패턴을 설명한다.
2. 배경
실무에서는 대용량 파일 번들링과 업로드 과정에서 CPU 과소비, 스레드풀 포화, 예외 누락, 업로드 실패 재시도 비용 같은 문제가 동시에 발생한다. 개별 기술 최적화만으로는 해결이 어렵고, 압축·비동기·스토리지 전송 전략을 함께 설계해야 한다.
3. 이유
압축 방식(STORED/DEFLATED), 실행 정책(CallerRunsPolicy), 예외 처리(AsyncUncaughtExceptionHandler), 업로드 방식(Multipart)을 목적에 맞게 조합하면 처리량과 안정성을 동시에 확보할 수 있다. 특히 실패 복구 비용과 운영 리스크를 크게 줄일 수 있다.
4. 특징
파일 유형별 ZIP 전략, 프록시 기반 @Async 동작 원리와 self-invocation 회피, 큐 포화 시 백프레셔 설계, S3 파트 단위 재전송/정리 전략까지 다뤄 실무 적용성을 높인 것이 핵심 특징이다.
5. 상세 내용
ZIP Streaming · @Async · S3 Multipart Upload 실전 패턴 완전 가이드
ZipOutputStream STORED mode, CallerRunsPolicy, AsyncUncaughtExceptionHandler, S3 Multipart Upload, @Async Self-Invocation Proxy Bypass에 대한 종합 연구
목차
- 용어 사전 (Terminology)
- 등장 배경과 이유 (Why It Emerged)
- 역사적 기원 (Historical Origins)
- 학술적/이론적 배경 (Academic Foundation)
- 진화 타임라인 (Evolution Timeline)
- 대안 비교 (Alternatives & Trade-offs)
- 상황별 최적 선택 (When Each is Effective)
- 실전 베스트 프랙티스 (Best Practices)
- 빅테크 실전 사례 (Big Tech Strategies)
- References
1. 용어 사전
ZIP 관련
| 용어 | 풀네임 / 의미 | 어원 |
|---|---|---|
| ZIP | - | Phil Katz가 ARC보다 “빠르다(zippy)”는 의미에서 명명. 1989년 |
| PKWARE | Phil Katz’s softWARE | 창시자 Phil Katz(1962~2000)의 이니셜 |
| STORED | Method 0 | “저장만 한다” - 압축 없이 원본 데이터를 그대로 아카이브에 보관 |
| DEFLATED | Method 8 | “공기를 빼다(deflate)” - LZ77 + Huffman 코딩 조합 압축 |
| APPNOTE.TXT | Application Note | PKWARE가 공개한 ZIP 포맷 공식 사양 문서명 |
| CRC-32 | Cyclic Redundancy Check, 32-bit | 순환(Cyclic) 다항식 코드 기반 잉여(Redundancy) 검사(Check). W. Wesley Peterson이 1961년 발명 |
| LZ77 | Lempel-Ziv 1977 | Abraham Lempel과 Jacob Ziv가 1977년 발표한 슬라이딩 윈도우 기반 사전 압축 |
| ZIP64 | ZIP 64-bit Extension | 4GiB/65,535개 항목 한계를 극복하는 64비트 확장. 2001년 도입 |
Java Concurrency 관련
| 용어 | 풀네임 / 의미 | 어원 |
|---|---|---|
| JSR-166 | Java Specification Request #166 | JCP 등록 순번 166. “Concurrency Utilities” 사양 |
| CallerRunsPolicy | Caller + Runs + Policy | 거부된 태스크를 제출자(caller) 스레드가 직접 실행하는 정책 |
| AbortPolicy | Abort + Policy | 거부 시 RejectedExecutionException 예외를 던지는 정책 |
| DiscardPolicy | Discard + Policy | 거부된 태스크를 조용히 삭제하는 정책 |
| DiscardOldestPolicy | Discard + Oldest + Policy | 큐의 가장 오래된 태스크를 제거 후 재시도 |
| Backpressure | Back + Pressure | 소비자 처리 용량 초과 시 생산자 흐름을 제어하는 메커니즘 |
Spring Async 관련
| 용어 | 풀네임 / 의미 | 어원 |
|---|---|---|
| @Async | Asynchronous | 메서드를 별도 스레드풀에서 비동기 실행하도록 지시 |
| AsyncUncaughtExceptionHandler | Async + Uncaught + Exception + Handler | void @Async 메서드의 잡히지 않은 예외를 처리하는 전략 인터페이스 |
| @EnableAsync | Enable + Async | Java Config로 비동기 지원을 활성화하는 애노테이션 |
| AsyncConfigurer | Async + Configurer | Executor와 예외 핸들러를 설정하는 인터페이스 |
| Self-Invocation | Self + Invocation | 같은 클래스 내에서 this.method()로 자기 메서드를 호출하는 것 |
Spring AOP 관련
| 용어 | 풀네임 / 의미 | 어원 |
|---|---|---|
| AOP | Aspect-Oriented Programming | 관점 지향 프로그래밍. Gregor Kiczales가 1997년 ECOOP에서 제안 |
| CGLIB | Code Generation Library | 런타임 서브클래스 동적 생성 바이트코드 조작 라이브러리 |
| JDK Dynamic Proxy | JDK Dynamic Proxy | Java 1.3(2000)에서 도입된 인터페이스 기반 프록시 메커니즘 |
| AspectJ | Aspect + Java | Xerox PARC에서 2001년 개발한 완전한 AOP 구현체 |
| CTW | Compile-Time Weaving | 컴파일 시점에 Aspect 코드를 바이트코드에 삽입 |
| LTW | Load-Time Weaving | 클래스 로딩 시점에 Aspect 코드를 삽입 |
AWS S3 관련
| 용어 | 풀네임 / 의미 | 어원 |
|---|---|---|
| S3 | Simple Storage Service | “단순한 저장 서비스”. Werner Vogels가 강조한 단순성 설계 원칙 반영 |
| Multipart Upload | Multi + Part + Upload | 하나의 객체를 여러 조각으로 나누어 업로드 |
| ETag | Entity Tag | HTTP 리소스의 특정 버전을 식별하는 태그. S3에서는 MD5 해시 |
| Pre-signed URL | Pre + Signed + URL | 서버가 미리 서명한 시한부 직접 접근 URL |
2. 등장 배경과 이유
2.1 ZipOutputStream STORED Mode
문제: 이미 내부 압축된 파일(PDF, XLSX, PNG, JPEG, MP4)에 DEFLATE를 적용하면:
- 파일 크기가 오히려 증가할 수 있음 (압축 헤더 오버헤드)
- CPU를 불필요하게 소비
- STORED 대비 처리 속도가 약 33% 느림
해결: ZIP 포맷은 설계상 각 파일마다 독립적으로 압축 방식을 선택할 수 있다. 하나의 아카이브 안에서 어떤 파일은 DEFLATED로, 어떤 파일은 STORED로 저장 가능. 이는 tar+gzip처럼 전체 아카이브를 단일 스트림으로 압축하는 방식과 근본적으로 다르다.
STORED 사용 시 CRC32/size/compressedSize 필수 설정 이유:
ZIP Local File Header 구조:
[4 bytes] Magic: 50 4B 03 04 ("PK\x03\x04")
[2 bytes] Version needed
[2 bytes] General purpose bit flag <- 비트3이 핵심
[2 bytes] Compression method <- STORED=0, DEFLATED=8
[4 bytes] CRC-32
[4 bytes] Compressed size
[4 bytes] Uncompressed size
DEFLATED는 Data Descriptor 메커니즘을 자동 사용하여 압축 후 CRC32/크기를 뒤에 기록. STORED는 “압축하지 않는다 = 변환하지 않는다”이므로 CRC32와 크기를 사전에 알고 있어야 한다는 설계 원칙. Java의 ZipOutputStream은 STORED 엔트리에 대해 헤더에 직접 값을 기록하므로 putNextEntry() 호출 전에 반드시 설정해야 한다.
2.2 CallerRunsPolicy
문제: ThreadPoolExecutor의 큐가 가득 차고 maxPoolSize에 도달하면 새 작업을 거부해야 함. 기본 AbortPolicy는 예외를 던져 태스크가 유실될 수 있음.
해결: CallerRunsPolicy는 거부된 태스크를 제출한 스레드가 직접 실행. 그 스레드가 작업하는 동안 새 태스크를 받지 못하므로 자연적 백프레셔(natural backpressure) 달성. 공식 Javadoc: “This provides a simple feedback control mechanism that will slow down the rate that new tasks are submitted.”
2.3 AsyncUncaughtExceptionHandler
문제: void 반환 @Async 메서드에서 발생한 예외는 기본적으로 조용히 소멸. Future를 반환하면 .get() 또는 .exceptionally()로 처리 가능하지만, fire-and-forget 패턴에서는 예외가 완전한 블랙홀(black hole)에 빠짐.
해결: Spring 4.1에서 SPR-8995 이슈(2012년 1월 Dwayne D’Souza 등록)를 해결하기 위해 AsyncUncaughtExceptionHandler 인터페이스 도입.
2.4 S3 Multipart Upload
문제: S3 초기(2006년) 단일 PUT 업로드의 한계:
- 최대 5GB 크기 제한
- 3GB 업로드 도중 실패하면 처음부터 재시작
- 단일 TCP 스트림으로는 가용 대역폭 100% 활용 불가
- 긴 업로드 시간 동안 연결 끊김 위험 증가
해결: 2010년 11월 10일 Multipart Upload 도입으로 최대 5TB, 병렬 업로드, 파트 단위 재전송 지원.
2.5 @Async Self-Invocation Proxy Bypass
문제: Spring AOP는 프록시 기반. 같은 클래스 내에서 this.asyncMethod() 호출 시 프록시를 거치지 않아 @Async, @Transactional, @Cacheable 등의 애노테이션이 동작하지 않음.
[외부 호출자] -> [Proxy] -> [Bean.methodA()] -> this.methodB() <- 프록시 우회!
^ AOP 적용됨 ^ AOP 미적용
this는 프록시 객체가 아닌 원본 빈 인스턴스를 가리키기 때문. Spring 공식 문서: “Self invocation is not going to result in the advice associated with a method invocation getting a chance to run.”
해결: 별도 클래스(BundleWorker)로 분리하여 외부 빈 주입을 통해 프록시 경유.
3. 역사적 기원
3.1 ZIP 포맷의 탄생 (1989)
1980년대 중반 Phil Katz(1962~2000)는 당시 지배적 압축 포맷 ARC와 호환되는 PKARC를 어셈블리로 제작. SEA가 상표권 침해 소송 제기 -> Katz는 합의 후 완전히 새로운 ZIP 포맷을 1989년 창제. APPNOTE.TXT를 public domain으로 공개한 것이 ZIP이 사실상 표준이 된 결정적 이유.
3.2 CRC-32의 발명 (1961)
W. Wesley Peterson(1924~2009)이 D.T. Brown과 공동으로 1961년 “Cyclic Codes for Error Detection” (Proceedings of the IRE)에서 CRC를 공식 발표. 1975년 Georgia Tech과 Mitre Corporation 연구자들이 CRC-32 다항식(0x04C11DB7)을 최적화. 이 다항식이 Ethernet, PNG, ZIP에 채택됨.
3.3 java.util.concurrent (2004)
Doug Lea(SUNY Oswego 교수)가 1998년경부터 util.concurrent 라이브러리를 독자 개발하여 public domain으로 배포. 이것이 JSR-166(2002년 제안)의 기반이 됨. Expert Group: Doug Lea, Josh Bloch, Joe Bowbeer, Brian Goetz, David Holmes, Tim Peierls. Java 5.0(2004년 9월)에서 정식 포함.
3.4 Spring @Async 도입 (2009)
- Spring 3.0 (2009.12):
@Async최초 도입 - Spring 3.1 (2011.12):
@EnableAsync,AsyncConfigurer인터페이스 - Spring 4.1 (2014):
AsyncUncaughtExceptionHandler추가 (SPR-8995) - Spring Boot 2.1 (2018):
TaskExecutionAutoConfiguration강화,ThreadPoolTaskExecutor기본 사용
3.5 AOP의 학술적 기원 (1997)
Gregor Kiczales 외 6인이 ECOOP 1997 (핀란드 Jyvaskyla)에서 “Aspect-Oriented Programming” 논문 발표 (LNCS 1241, Springer-Verlag). 2017년 ECOOP Test of Time Award 수상. 핵심 문제 제기: OOP는 횡단 관심사(crosscutting concerns)를 캡슐화할 수 없다 -> Aspect, Join Point, Pointcut, Advice, Weaving 개념 정의.
3.6 Amazon S3 출시 (2006)
2006년 3월 14일 정식 출시. API Version: 2006-03-01 (현재까지 유일한 버전 번호 – AWS의 하위 호환성 보장 철학). Multipart Upload는 2010년 11월 10일 도입.
4. 학술적/이론적 배경
4.1 DEFLATE의 계보
LZ77 (1977, Lempel & Ziv)
"A Universal Algorithm for Sequential Data Compression"
IEEE Transactions on Information Theory
-> 2004 IEEE Milestone, 2021 Jacob Ziv IEEE Medal of Honor
|
v
Huffman Coding (1952, David Huffman)
"A Method for the Construction of Minimum-Redundancy Codes"
MIT 정보이론 수업 중 개발, Proceedings of the IRE
|
v
DEFLATE = LZSS(LZ77 변형) + Huffman
Phil Katz가 PKZIP 2.04(1992)를 위해 설계
RFC 1951 (1996.05): DEFLATE Compressed Data Format Specification v1.3
RFC 1950 (1996.05): ZLIB Compressed Data Format Specification
4.2 Thread Pool의 이론적 기반
- Doug Lea, “Concurrent Programming in Java: Design Principles and Patterns” (1996, Addison-Wesley)
- Worker Thread Pattern, Thread-Per-Message Pattern 체계화
- ThreadPoolExecutor의 직접적 설계 근거
- JSR-166 (2002): https://www.jcp.org/en/jsr/detail?id=166
- Brian Goetz et al., “Java Concurrency in Practice” (2006, Addison-Wesley)
4.3 AOP 원본 논문
- Gregor Kiczales et al., “Aspect-Oriented Programming”, ECOOP 1997
- URL: https://www.cs.ubc.ca/~gregor/papers/kiczales-ECOOP1997-AOP.pdf
- AOP Alliance (2002): Spring이 최초로
org.aopalliance.intercept.MethodInterceptor구현
4.4 REST 이론
- Roy Fielding (2000), “Architectural Styles and the Design of Network-based Software Architectures” (UC Irvine 박사논문)
- AWS S3는 이 REST 원칙을 실용적으로 구현한 초기 사례
5. 진화 타임라인
5.1 ZIP 포맷
1989 -- PKZIP 0.9 + APPNOTE.TXT 공개 (STORED + Shrink 방식)
1992 -- PKZIP 2.04c: DEFLATE 정식 탑재 (Method 8)
1996 -- RFC 1951 (DEFLATE), RFC 1950 (ZLIB), RFC 1952 (GZIP) 표준화
2001 -- ZIP64 도입 (4GiB -> 16EiB 확장)
2006 -- APPNOTE 6.3.0: UTF-8 파일명 지원
java.util.zip: Java 7에서 ZIP64 공식 지원
5.2 Java Concurrency
1996 -- Doug Lea, "Concurrent Programming in Java" 1판
1998 -- util.concurrent 독립 라이브러리 공개
2002 -- JSR-166 공식 제안
2004 -- Java SE 5.0: java.util.concurrent 정식 포함, ThreadPoolExecutor + 4가지 거부 정책
2011 -- Java SE 7: ForkJoinPool 추가
2013 -- Java SE 8: CompletableFuture 추가
2023 -- Java SE 21: Virtual Threads 정식 출시
5.3 Spring @Async
2009 -- Spring 3.0: @Async 최초 도입 (기본: SimpleAsyncTaskExecutor)
2011 -- Spring 3.1: @EnableAsync, AsyncConfigurer 도입
2014 -- Spring 4.1: AsyncUncaughtExceptionHandler 추가
2018 -- Spring Boot 2.1: TaskExecutionAutoConfiguration, ThreadPoolTaskExecutor 기본 사용
2023 -- Spring Boot 3.2: Virtual Thread 지원 (spring.threads.virtual.enabled=true)
5.4 Spring AOP Proxy
2000 -- Java 1.3: JDK Dynamic Proxy 도입
2002 -- CGLIB 등장 (서브클래싱 방식)
2004 -- Spring 1.0: JDK Proxy 기본, 인터페이스 없으면 CGLIB
2006 -- Spring 2.0: AspectJ 어노테이션 방식 지원
2017 -- Spring Boot 2.0: proxyTargetClass=true 기본값 (CGLIB 기본)
현재 -- Spring 6.x: AOT(Ahead-Of-Time) 처리 지원 (GraalVM)
5.5 AWS S3
2006-03 -- S3 GA 출시 (API Version 2006-03-01)
2010-11 -- Multipart Upload 도입 (최대 5TB, 10,000 파트)
2016-04 -- Transfer Acceleration (CloudFront Edge 활용)
2018-04 -- S3 Select GA (SQL로 객체 내 데이터 추출)
2020-12 -- Strong Read-After-Write Consistency
2023 -- S3 Express One Zone, S3 Mountpoint
6. 대안 비교
6.1 ZIP 압축 메서드 비교
| Method | 압축률 | 압축 속도 | 해제 속도 | 메모리 | Java 지원 |
|---|---|---|---|---|---|
| STORED (0) | 없음 (1:1) | 최고속 | 최고속 | 최소 | java.util.zip 기본 |
| DEFLATED (8) | 60~70% 절감 | 중간 | 빠름 | 낮음 | java.util.zip 기본 |
| BZIP2 (12) | 70~75% 절감 | 느림 | 매우 느림 | 높음 | Commons Compress |
| LZMA (14) | 75~80% 절감 | 매우 느림 | 중간 | 매우 높음 | Commons Compress |
| Zstandard (93) | 65~75% 절감 | 최고속 | 최고속 | 낮음 | Commons Compress 1.16+ |
핵심: Zstandard는 DEFLATE 수준의 압축률 + 9배 이상 빠른 속도. BZIP2는 해제 속도가 특히 나빠 프로덕션 지양.
Java ZIP 라이브러리 비교
| 기준 | java.util.zip |
Apache Commons Compress | zip4j |
|---|---|---|---|
| 의존성 | 없음 (JDK 내장) | commons-compress JAR | zip4j JAR |
| 메서드 지원 | STORED, DEFLATED | 전체 | STORED, DEFLATED |
| AES 암호화 | 미지원 | 제한적 | 완전 지원 |
| ZIP64 | Java 7+ | 자동 처리 | 지원 |
| 멀티코어 병렬 | 미지원 | 지원 | 제한적 |
| 권장 | 단순 작업 | 엔터프라이즈 | 암호화 필요 시 |
6.2 ThreadPoolExecutor 거부 정책 비교
| 정책 | 동작 | 작업 손실 | 예외 | 권장 상황 |
|---|---|---|---|---|
| AbortPolicy (기본) | RejectedExecutionException |
있음 | 있음 | 명시적 오류 처리 필요 시 |
| CallerRunsPolicy | 호출 스레드가 직접 실행 | 없음 | 없음 | 작업 손실 불가 + 백프레셔 |
| DiscardPolicy | 조용히 삭제 | 있음 | 없음 | 로그/센서 등 손실 허용 |
| DiscardOldestPolicy | 큐의 가장 오래된 것 제거 | 있음 | 없음 | 최신 데이터 우선 시 |
대안 전략 비교
| 전략 | 장점 | 단점 | 위험도 |
|---|---|---|---|
| Unbounded Queue | 작업 손실 없음 | OOM 위험, maxPoolSize 무의미 | 높음 |
| Custom Handler | 메트릭/알림 가능 | 구현 필요 | 낮음 |
| ForkJoinPool | CPU 활용 극대화 | I/O 집약 비효율 | 낮음 |
| Virtual Threads (Java 21+) | I/O 블로킹 자동 처리 | CPU 집약 비효율, 백프레셔 없음 | 낮음 |
6.3 Spring 비동기 프레임워크 비교
| 기준 | Spring @Async |
CompletableFuture | Kotlin Coroutines | WebFlux/Reactor | Virtual Threads |
|---|---|---|---|---|---|
| 학습 곡선 | 낮음 | 중간 | 중간 | 높음 | 낮음 |
| 코드 가독성 | 높음 (선언적) | 중간 (체이닝) | 높음 (순차적) | 낮음 (함수형) | 높음 |
| 백프레셔 | 없음 | 없음 | 제한적 | 완전 지원 | 없음 |
| 예외 처리 | 낮음 (void 시) | 높음 | 높음 | 중간 | 높음 |
| Spring MVC 통합 | 완벽 | 완벽 | 부분적 | 별도 스택 | 완벽 |
| 디버깅 | 높음 | 중간 | 높음 | 낮음 | 높음 |
6.4 S3 업로드 방식 비교
| 방식 | 최대 크기 | 병렬 | 재시도 단위 |
|---|---|---|---|
| Single PUT | 5 GB | 불가 | 전체 파일 |
| Multipart Upload | 5 TB | 가능 | 실패 파트만 |
| TransferManager (SDK v1) | 5 TB | 자동 | 파트 단위 |
| S3TransferManager (SDK v2) | 5 TB | 자동 (CRT 기반) | 파트 단위 |
| Transfer Acceleration | 5 TB | Multipart 결합 | 파트 단위 |
SDK v1 vs v2 비교
| 기준 | SDK v1 TransferManager | SDK v2 S3TransferManager |
|---|---|---|
| 기본 Threshold | 16 MB | 8 MB |
| 기본 Part Size | 5 MB | 8 MB |
| 비동기 모델 | 동기 + Future | 완전 비동기 (CompletableFuture) |
| CRT 통합 | 없음 | 지원 (고성능 C 런타임) |
| 현재 상태 | 유지보수 모드 | 적극 개발 중 (권장) |
6.5 Spring AOP Self-Invocation 해결책 비교
| 해결책 | 구현 복잡도 | 침투성 | 스레드 안전 | Spring 팀 권장 |
|---|---|---|---|---|
| 클래스 분리 (BundleWorker) | 낮음 | 없음 | 안전 | 최우선 권장 |
@Lazy self-injection |
낮음 | 낮음 | 안전 | 권장 (대안) |
ApplicationContext.getBean() |
중간 | 높음 | 안전 | 비권장 |
AopContext.currentProxy() |
중간 | 높음 | 위험 (ThreadLocal) | 비권장 |
| AspectJ CTW/LTW | 높음 | 없음 | 안전 | 완전한 해결 (복잡) |
7. 상황별 최적 선택
7.1 ZIP 압축 방식 선택
이미 압축된 파일인가? (PDF, PNG, JPEG, XLSX, MP4, ZIP)
+-- YES -> STORED (Method 0)
| - CPU 절약, ~33% 빠름
| - CRC32/size/compressedSize 사전 설정 필수
+-- NO -> 압축 가능한 파일 (CSV, JSON, XML, TXT, SQL)
+-- Java 표준만 사용 -> DEFLATED (Method 8)
+-- Commons Compress 의존성 허용 -> Zstandard (최고 성능)
7.2 ThreadPoolExecutor 작업 유형별 선택
| 작업 유형 | 권장 방식 |
|---|---|
| I/O 집약 (DB, HTTP, 파일) | Virtual Threads (Java 21+) |
| CPU 집약 (압축, 암호화) | ThreadPoolExecutor + bounded queue |
| 재귀/분할정복 | ForkJoinPool |
| 혼합 작업 + 정밀 백프레셔 | ThreadPoolExecutor + CallerRunsPolicy |
7.3 CallerRunsPolicy 적합성 판단
| 시나리오 | 적합성 | 이유 |
|---|---|---|
| 백그라운드 배치 작업 | 적합 | 백프레셔 효과 |
| 짧은 CPU 작업 | 적합 | 큐 포화 방지 |
| HTTP 요청 처리 스레드 | 부적합 | Tomcat 스레드 고갈 위험 |
| 외부 API I/O 대기 | 매우 부적합 | 긴 블로킹 |
7.4 @Async 반환 타입 선택
비동기 메서드 결과가 필요한가?
+-- NO (fire-and-forget: 이메일, 로그)
| +-- void 반환 + AsyncUncaughtExceptionHandler 필수 등록
+-- YES
+-- 단순 결과 -> CompletableFuture @Async + .handle()
+-- 복잡한 체이닝 -> CompletableFuture 직접 사용 (Spring executor 주입)
7.5 S3 업로드 전략 선택
| 파일 크기 | 권장 전략 | 이유 |
|---|---|---|
| < 5 MB | Single PUT | 오버헤드 없음 |
| 5~100 MB | Single PUT 또는 Multipart | 네트워크 안정성에 따라 |
| 100 MB ~ 5 GB | Multipart Upload | AWS 공식 권장 (100 MB 이상) |
| > 5 GB | Multipart 필수 | Single PUT 불가 |
| 클라이언트 직접 업로드 | Pre-signed URL Multipart | 서버 대역폭 절약 |
7.6 Self-Invocation 해결책 선택
@Async/@Transactional이 같은 클래스 내에서 호출되는가?
+-- YES -> 해결 필요
| +-- 리팩토링 가능? -> 클래스 분리 (BundleWorker 패턴) <- 최우선
| +-- 클래스 분리 어려움? -> @Lazy self-injection
| +-- 완전한 AOP 필요? -> AspectJ CTW (빌드 설정 변경)
+-- NO -> 문제 없음
8. 실전 베스트 프랙티스
8.1 ZipOutputStream STORED Mode
올바른 STORED 엔트리 작성 패턴
// 베스트 프랙티스: Two-pass 스트림 방식
public void addStoredEntry(ZipOutputStream zos, Path file, String entryName)
throws IOException {
// Pass 1: CRC 계산
CRC32 crc = new CRC32();
long size;
try (InputStream is = Files.newInputStream(file)) {
byte[] buf = new byte[8192];
int read;
long count = 0;
while ((read = is.read(buf)) != -1) {
crc.update(buf, 0, read);
count += read;
}
size = count;
}
// Pass 2: ZIP에 기록
ZipEntry entry = new ZipEntry(entryName);
entry.setMethod(ZipEntry.STORED);
entry.setSize(size);
entry.setCompressedSize(size); // STORED는 동일
entry.setCrc(crc.getValue());
zos.putNextEntry(entry);
try (InputStream is = Files.newInputStream(file)) {
is.transferTo(zos);
}
zos.closeEntry();
}
// 안티패턴: CRC32 없이 STORED 시도
ZipEntry entry = new ZipEntry("file.bin");
entry.setMethod(ZipEntry.STORED);
entry.setSize(data.length);
// entry.setCrc(???) <- 누락!
zos.putNextEntry(entry); // -> ZipException: STORED entry missing size, compressed size, or crc-32
Hybrid 전략: 파일 타입별 자동 선택
private static final Set<String> PRE_COMPRESSED = Set.of(
"pdf", "xlsx", "docx", "png", "jpg", "jpeg", "gif",
"mp4", "mp3", "zip", "gz", "7z", "webp", "avif"
);
public int selectCompressionMethod(String fileName) {
String ext = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
return PRE_COMPRESSED.contains(ext) ? ZipEntry.STORED : ZipEntry.DEFLATED;
}
주요 함정
| 함정 | 증상 | 해결책 |
|---|---|---|
| CRC32 미설정 | ZipException: STORED entry missing CRC-32 |
setCrc() 필수 |
| size != compressedSize | ZipException: bad STORED entry size |
STORED는 두 값 동일 |
setMethod() 누락 |
기본 DEFLATED로 동작 | entry.setMethod(ZipEntry.STORED) 명시 |
| HTTP 스트림에서 pre-calculate | 응답 지연, OOM | DEFLATED NO_COMPRESSION 또는 temp file |
| Thread safety | 데이터 손상 | 인스턴스당 하나의 스레드만 |
| 4GB 이상 | ZIP64 필요 | Commons Compress ZipArchiveOutputStream 사용 |
HTTP 스트리밍 시 대안: STORED는 CRC32 사전 계산이 필요하므로, 크기 미상 동적 스트림에는 DEFLATED + Deflater.NO_COMPRESSION 조합이 사실상 STORED 수준 처리량 + 사전 계산 불필요.
8.2 ThreadPoolExecutor + CallerRunsPolicy
프로덕션 설정 예시
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int cpuCores = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(cpuCores * 2); // I/O bound 기준
executor.setMaxPoolSize(cpuCores * 4);
executor.setQueueCapacity(200); // Integer.MAX_VALUE 절대 금지
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("async-task-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
스레드 풀 크기 계산 공식
CPU bound: corePoolSize = CPU 코어 수 + 1
I/O bound: corePoolSize = CPU 코어 수 x (1 + 대기시간/처리시간)
예: 4코어, I/O 비율 10:1 -> 4 x 11 = 44
CallerRunsPolicy 핵심 함정: Tomcat 스레드 블로킹
정상 상태: [Tomcat Thread] -> 큐에 제출 -> [AsyncThread] 처리
CallerRuns 발동: [Tomcat Thread] -> 큐 꽉 참 -> [Tomcat Thread] 직접 처리
^ 이 동안 해당 Tomcat 스레드는 HTTP 요청을 받지 못함!
HTTP 요청 스레드 보호가 필요하면 CallerRunsPolicy 대신 커스텀 핸들러 + 메트릭/알람 사용
직관에 반하는 동작 방식 주의
작업 제출 흐름:
1. corePoolSize 미만 -> 새 스레드 생성
2. corePoolSize 이상, queue 여유 -> 큐에 추가 <- maxPoolSize 아직 아님!
3. 큐가 꽉 찼고, maxPoolSize 미만 -> 새 스레드 생성
4. 큐도 꽉 찼고, maxPoolSize 도달 -> RejectedExecutionHandler
queueCapacity를 Integer.MAX_VALUE(기본값)로 두면 큐가 절대 차지 않아 스레드 수가 corePoolSize 이상으로 절대 증가하지 않음.
Micrometer 모니터링
executor.active -> 현재 실행 중 스레드 수
executor.queued -> 큐 대기 태스크 수
executor.queue.remaining -> 큐 잔여 용량 (0이면 위험!)
executor.pool.size -> 현재 풀 크기
executor.completed -> 완료 태스크 수
알람 기준: queued > queueCapacity x 80% 경고, queue.remaining < 10 긴급
8.3 AsyncUncaughtExceptionHandler
올바른 등록 방식 (AsyncConfigurer 구현)
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
@Override
public Executor getAsyncExecutor() { /* ... */ }
}
// 가장 흔한 실수: @Bean으로만 등록 -> 실제로 호출되지 않음!
// Spring은 AsyncConfigurer의 메서드를 우선하며,
// @Bean으로 등록한 핸들러는 자동 연결되지 않음
프로덕션 구현 패턴
@Slf4j
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
private final MeterRegistry meterRegistry;
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
String methodName = method.getDeclaringClass().getSimpleName()
+ "." + method.getName();
// 1. 구조화된 로깅
log.error("Async method failed: method={}, params={}",
methodName, Arrays.toString(params), ex);
// 2. Micrometer 메트릭
meterRegistry.counter("async.exception",
"method", methodName,
"exception", ex.getClass().getSimpleName()
).increment();
}
}
@Async + @Transactional 함정
// 함정: 같은 메서드에 선언 -> 트랜잭션이 호출자와 완전히 별개
@Async
@Transactional // 새 스레드에서 새로 시작됨
public void processAsync(Long id) { ... }
// 베스트 프랙티스: 트랜잭션 경계를 명확히 분리
@Async
public void processAsync(Long id) {
transactionalService.doInTransaction(id);
}
8.4 S3 Multipart Upload
Part Size 선택 기준
| 파일 크기 | 권장 Part Size | 이유 |
|---|---|---|
| < 100MB | 단일 PutObject | Multipart 불필요 |
| 100MB ~ 1GB | 16MB | 균형잡힌 병렬성 |
| 1GB ~ 10GB | 32~64MB | API 호출 수 최소화 |
| 10GB+ | 64~100MB | 10,000 파트 한계 고려 |
파트 수 한계:
최소 파트 크기 = 파일 크기 / 10,000
InputStream (크기 미상) 업로드 - SDK v2
// SDK 2.20+ 투명한 multipart 처리
S3AsyncClient multipartClient = S3AsyncClient.builder()
.multipartEnabled(true)
.multipartConfiguration(c -> c
.thresholdInBytes(8 * 1024 * 1024L) // 8MB 이상이면 multipart
.minimumPartSizeInBytes(5 * 1024 * 1024L)
)
.build();
실패 처리 + AbortMultipartUpload (필수)
String uploadId = null;
try {
uploadId = s3Client.createMultipartUpload(req -> req.bucket(bucket).key(key))
.uploadId();
// ... 파트 업로드 ...
s3Client.completeMultipartUpload(/* ... */);
} catch (Exception e) {
if (uploadId != null) {
s3Client.abortMultipartUpload(req -> req
.bucket(bucket).key(key).uploadId(uploadId));
}
throw e;
}
Lifecycle Rule 자동 정리 (필수 설정)
불완전 파트에 대한 스토리지 비용이 계속 청구됨:
{
"Rules": [{
"ID": "abort-incomplete-multipart",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 3
}
}]
}
Pre-signed URL Multipart 흐름
1. 클라이언트 -> 서버: 업로드 초기화 요청
2. 서버 -> S3: CreateMultipartUpload -> uploadId 획득
3. 서버 -> 클라이언트: 파트별 Pre-signed URL 반환
4. 클라이언트 -> S3: 각 파트를 Pre-signed URL로 직접 업로드
5. 클라이언트 -> 서버: ETag 리스트 전송
6. 서버 -> S3: CompleteMultipartUpload
핵심: 대역폭이 서버를 통과하지 않음 -> 서버 비용 절감 및 병목 제거
8.5 @Async Proxy Bypass 해결
해결책 1: 클래스 분리 (가장 권장)
// 베스트 프랙티스: 별도 Worker 클래스로 분리
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderAsyncWorker asyncWorker;
public void processOrder(Order order) {
asyncWorker.sendConfirmationEmail(order); // 프록시 경유 -> 비동기 실행
}
}
@Component
public class OrderAsyncWorker {
@Async("orderExecutor")
public void sendConfirmationEmail(Order order) {
// 진짜 비동기 실행
}
}
해결책 2: @Lazy Self-Injection (현실적 대안)
@Service
public class NotificationService {
@Autowired @Lazy
private NotificationService self; // 프록시 주입
public void notifyAll(List<User> users) {
users.forEach(user -> self.notifyUser(user)); // 프록시 경유
}
@Async
public void notifyUser(User user) { /* ... */ }
}
주의:
@Lazy없이 self-injection ->BeanCurrentlyInCreationException. Spring Boot 2.6+ 기본 순환 참조 금지.
Thread Pool 격리 (용도별 분리)
@Bean("fileProcessingExecutor")
public Executor fileProcessingExecutor() {
ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
e.setCorePoolSize(4);
e.setMaxPoolSize(8);
e.setQueueCapacity(50);
e.setThreadNamePrefix("file-proc-");
e.initialize();
return e;
}
@Bean("emailExecutor")
public Executor emailExecutor() {
ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
e.setCorePoolSize(2);
e.setMaxPoolSize(4);
e.setQueueCapacity(100);
e.setThreadNamePrefix("email-");
e.initialize();
return e;
}
// 사용 시 풀 이름 명시
@Async("fileProcessingExecutor")
public void processFile(String path) { /* ... */ }
SecurityContext 전파
@Bean("secureAsyncExecutor")
public Executor secureAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
9. 빅테크 실전 사례
9.1 ZIP/Archive Streaming
Dropbox - 폴더 다운로드:
- 파일을 4MB 블록으로 분할, SHA-256 해시로 식별
- Magic Pocket(자체 exabyte급 blob 스토리지)에 저장
- ZIP 생성 시 각 블록을 순차적으로 읽어 스트리밍 방식으로 압축
- 스트리밍 프리페치로 100MB 파일 기준 28% 동기화 시간 단축 (89초 -> 64초)
- 출처: Streaming File Synchronization - Dropbox Tech Blog
Netflix - Content Drive (CDrive):
- 140억 개 이상의 파일/폴더 메타데이터 관리 (2024.10 기준)
- 평균 Netflix 타이틀 하나 = ~200TB 원본 카메라 파일
- Conform Pull 서비스: EDL 기반 미디어 조합 후 스트리밍 패키징
- 출처: Content Drive - Netflix TechBlog
GitHub - Repository Archive:
git archive로 tarball/zipball on-demand 생성 후 캐시- 동일 커밋 ID -> 항상 동일 아카이브 보장
9.2 Thread Pool & Backpressure
Netflix - Hystrix (현재 Maintenance Mode -> Resilience4j 전환):
- Bulkhead 패턴: 의존성별 독립 Thread Pool
- 하루 Thread-isolated 명령 100억 건+, Semaphore-isolated 2000억 건+
- 100+ HystrixCommand 타입, 40+ Thread Pool 운영
- 풀 포화 시 즉시 Reject (큐 대기 없음) -> Fast Fail + Fallback
- 출처: Introducing Hystrix - Netflix TechBlog
Uber - Cinnamon (2024):
- QALM을 대체한 차세대 RPC 미들웨어
- TCP-Vegas 알고리즘 변형(1994) + PID Controller(17세기 기술) 조합
- 300% 오버로드에서 P50 레이턴시 50% 증가에 그침, 요청당 추가 지연 ~1us
- Jaeger 기반 우선순위 전파 (tier 0~5, cohort 128 버킷)
- 출처: Cinnamon - Uber Engineering Blog
Netflix - Virtual Threads 경험 (2024.07):
- SpringBoot 3 + Tomcat에서
synchronized+ Virtual Thread 조합 -> 데드락 발생 synchronized내 블로킹 -> Virtual Thread가 OS Thread에 Pinned -> ForkJoinPool 고갈- 해결:
synchronized->ReentrantLock전환 - 출처: Java 21 Virtual Threads - Dude, Where’s My Lock? - Netflix TechBlog
9.3 Async Processing
Uber - Kafka Dead Letter Queue:
- 실패 메시지를 인라인 재시도 대신 별도 Retry Topic으로 이동 후 오프셋 커밋
Original Topic -> Retry Topic 1 (지연) -> Retry Topic 2 (더 긴 지연) -> Dead Letter Topic (수동 분석)- 핵심: 실패 메시지가 정상 처리를 블로킹하지 않음
- 출처: Building Reliable Reprocessing and DLQ with Kafka - Uber Blog
LinkedIn - 하루 4조(4 Trillion) 건 이벤트 실시간 스트리밍 처리 (Kafka 중심)
9.4 S3 Multipart Upload
AWS 공식 성능 벤치마크:
| 방식 | 소요 시간 | 개선율 |
|---|---|---|
| Single PUT | ~72초 | 기준 |
| Multipart (5MB 파트, 6병렬) | ~45초 | -38% |
| Multipart + Transfer Acceleration | ~28초 | -61% |
Dropbox - Magic Pocket:
- 파일을 4MB 블록으로 분할 (Content-addressed storage)
- 600,000+ 스토리지 드라이브, 연간 데이터 내구성 12 nines (99.9999999999%)
- 출처: Inside the Magic Pocket - Dropbox Tech Blog
9.5 Spring AOP in Production
Alibaba - Sentinel:
- Spring AOP 프록시 한계를 인식, Spring Cloud Circuit Breaker 표준 인터페이스로 통합
- 더블십일 페스티벌에서 10년+ 핵심 트래픽 제어 지원
- 런타임 Rule Dashboard로 코드 변경 없이 동적 설정
프로덕션 프록시 성능:
- AspectJ CTW는 Spring AOP 대비 8~35배 빠름 (런타임 오버헤드 없음)
- 그러나 빌드 설정 복잡도로 대부분 Spring AOP + 클래스 분리 선택
10. References
ZIP / DEFLATE
- PKWARE APPNOTE.TXT - ZIP File Format Specification
- RFC 1951: DEFLATE Compressed Data Format Specification v1.3
- RFC 1950: ZLIB Compressed Data Format Specification
- Zip Files: History, Explanation and Implementation - hanshq.net
- Apache Commons Compress - ZIP Package
Java Concurrency
- JSR 166 - JCP Official
- Doug Lea Concurrency Interest
- ThreadPoolExecutor JavaDoc
- Guide to RejectedExecutionHandler - Baeldung
Spring @Async
- How To Do @Async in Spring - Baeldung
- Task Execution and Scheduling - Spring Framework Docs
- SPR-8995: AsyncUncaughtExceptionHandler 도입 이슈
Spring AOP
- Gregor Kiczales et al., “Aspect-Oriented Programming”, ECOOP 1997
- Proxying Mechanisms - Spring Framework Docs
- Comparing Spring AOP and AspectJ - Baeldung
- Self-Injection With Spring - Baeldung
AWS S3
- S3 Multipart Upload Overview - AWS Docs
- S3 Transfer Manager - AWS SDK v2
- Uploading Large Objects with Multipart + Acceleration - AWS Blog
- Patterns for Building S3 Upload API - AWS Blog
빅테크 엔지니어링 블로그
- Streaming File Synchronization - Dropbox Tech Blog
- Content Drive - Netflix TechBlog
- Introducing Hystrix - Netflix TechBlog
- Java 21 Virtual Threads - Netflix TechBlog (2024.07)
- Cinnamon Load Shedder - Uber Engineering (2024)
- Reliable Reprocessing and DLQ with Kafka - Uber Blog
- 4 Trillion Events Daily - LinkedIn Engineering
- Inside the Magic Pocket - Dropbox Tech Blog
- Hystrix vs. Sentinel - Alibaba Cloud Blog