ZIP Streaming · @Async · S3 Multipart Upload 실전 패턴 완전 가이드
TL;DR
-
ZipOutputStream STORED mode, CallerRunsPolicy, AsyncUncaughtExceptionHandler, S3 Multipart Upload, @Async Self-Invocation Proxy Bypass에 대한 종합 연구 1.
- 원문 전체는 아래 상세 내용에 그대로 포함했다.
1. 개념
ZipOutputStream STORED mode, CallerRunsPolicy, AsyncUncaughtExceptionHandler, S3 Multipart Upload, @Async Self-Invocation Proxy Bypass에 대한 종합 연구 1.
2. 배경
3. 이유
4. 특징
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