Image Setakgi - 이미지 메타데이터 제거 프로그램
이미지 메타데이터 제거부터 회전·변형까지. 회전 후 빈 공간 자동 제거, 멀티스레드 최적화, WebP vs JPEG 선택 과정 등 실전 문제 해결기.
Image Setakgi - 이미지 세탁기
프로젝트 배경
웹에서 이미지 메타데이터 제거 서비스를 사용하고 있었는데, 파일을 업로드하는 게 귀찮고 느려서 로컬에서 동작하는 프로그램을 만들기로 했습니다. 단순히 메타데이터만 지우는 게 아니라, 회전, 노이즈 추가, 원근 변형 등 이미지 자체를 변형해서 역추적을 어렵게 만드는 게 목표였습니다.
주요 기능 (간략)
- 일괄 이미지 변형: 드래그앤드롭으로 여러 파일 처리
- 실시간 미리보기: 옵션 변경 시 즉시 반영 (별도 스레드)
- 이미지 변환: 회전, 크롭, 원근 변형, 노이즈 추가, 색조 조정
- 메타데이터 조작: EXIF 삭제 또는 랜덤 생성
- 멀티스레드 처리: 100개 파일도 빠르게 처리
기술 스택: Python 3.10, PySide6, Pillow, NumPy, PyInstaller
기술적 도전과제
1. 회전 후 빈 공간 자동 제거
문제 상황
이미지를 회전시키면 모서리에 빈 공간(삼각형 영역)이 생깁니다. 투명 배경으로 남겨두면 JPEG로 저장할 때 흰색이 되고, 사용자가 일일이 크롭해야 하는 불편함이 있었습니다.
해결 방법
회전 후 빈 공간이 전혀 없는 최대 크기의 내접 직사각형을 수학적으로 계산하는 알고리즘을 구현했습니다.
알고리즘
참고: image_ops.py:8-38
def get_inscribed_rect_size(orig_w: int, orig_h: int, angle_deg: float):
"""회전 후 빈 공간 없이 추출 가능한 최대 직사각형 크기 계산"""
angle = math.radians(abs(angle_deg))
cos_a = abs(math.cos(angle))
sin_a = abs(math.sin(angle))
# cos(2θ) = cos²θ - sin²θ (제곱근 연산 회피)
cos_2a = cos_a * cos_a - sin_a * sin_a
# 45도 특수 케이스 처리
if abs(cos_2a) < 1e-10:
scale = 1 / math.sqrt(2)
return int(orig_w * scale), int(orig_h * scale)
# 원본 비율 유지하는 최대 내접 직사각형
if orig_w * sin_a >= orig_h * cos_a:
new_w = (orig_w * cos_a - orig_h * sin_a) / cos_2a
new_h = new_w * orig_h / orig_w
else:
new_h = (orig_h * cos_a - orig_w * sin_a) / cos_2a
new_w = new_h * orig_w / orig_h
return max(10, int(new_w)), max(10, int(new_h))
핵심 아이디어
- 삼각함수 항등식(
cos(2θ) = cos²θ - sin²θ)을 활용해 제곱근 연산 최소화 - 시간 복잡도: O(1) (순수 수학 연산만 사용)
- 45도 같은 특수 각도는 분기 처리해서 정밀도 향상
적용 결과
- 이미지 회전 (Pillow의
expand=True로 전체 보존) - 내접 직사각형 크기 계산
- 중앙에서 자동 크롭
- 결과: 빈 공간 완전히 제거된 자연스러운 이미지
2. 원근 변형 후 빈 여백 처리
문제 상황
원근 변형(Perspective Transform)을 적용하면 이미지 가장자리에 흰색 여백이 생깁니다. 4개 점의 좌표만으로 변환 행렬을 계산하는데, 어느 정도 크롭해야 여백이 완전히 사라지는지가 명확하지 않았습니다.
해결 과정
처음에는 고정값(10px, 20px)으로 크롭했는데, 변형 강도에 따라 부족하거나 과하게 잘리는 문제가 있었습니다. 핸들 4개의 이동 거리 중 최댓값을 기준으로 크롭 마진을 동적으로 계산하도록 수정했습니다.
구현
참고: image_ops.py:297-303
# 원근 변형 후 크롭 마진 계산
max_offset = max(
abs(tl[0]), abs(tl[1]), abs(tr[0]), abs(tr[1]),
abs(bl[0]), abs(bl[1]), abs(br[0]), abs(br[1])
)
margin = int(max_offset * 2) + 2 # 최대 오프셋의 2배 + 2px
crop_w = max(10, output_w - margin * 2)
crop_h = max(10, output_h - margin * 2)
결과
- 약한 변형: 최소한만 크롭 (이미지 손실 적음)
- 강한 변형: 충분히 크롭 (흰색 여백 완전 제거)
- 변형 강도와 무관하게 깔끔한 결과 보장
3. 이미지 포맷 선택: 기술 vs 사용자 경험
문제 상황
처음에는 PNG 형식으로 이미지를 저장했는데, 용량이 너무 컸습니다. 같은 이미지가 PNG는 2.4MB인데 다른 포맷은 훨씬 작았습니다.
기술적으로 최적의 선택: WebP
여러 포맷을 비교 분석한 결과:
| 포맷 | 파일 크기 | 압축률 | 품질 | 호환성 |
|---|---|---|---|---|
| PNG | 2.4MB | - | Perfect (무손실) | 완벽 |
| WebP | 720KB | 70% 감소 | Excellent | 현대 브라우저 |
| JPEG Q=75 | 640KB | 73% 감소 | Good | 완벽 |
WebP가 압도적으로 유리했습니다:
- PNG 대비 70% 이상 용량 감소
- 무손실/손실 압축 모두 지원
- 현대적 포맷으로 품질도 우수
- 메타데이터 지원
당연히 WebP로 가는 게 맞다고 생각했고, 실제로 구현도 해봤습니다:
# WebP 저장 시도
img.save(output_path, "WEBP", quality=80, exif=exif_bytes)
# 결과: 용량도 작고, 품질도 좋음
실제 문제: Windows + Chrome 환경
그런데 실제 사용자들(직원들)로부터 불편하다는 피드백이 들어왔습니다.
문제 상황:
- Windows에서 WebP 파일을 더블클릭하면 → 웹브라우저(Chrome)로 열림
- 이미지 뷰어가 뜰 거라고 기대했는데 갑자기 브라우저가 뜨니까 혼란스러움
- 이미지를 빠르게 확인하려고 클릭했는데 브라우저 로딩 기다려야 함
- 여러 이미지를 연속으로 보려면 매번 브라우저 탭이 열림
- 방향키로 다음 이미지 넘기기도 안 됨 (이미지 뷰어처럼)
왜 이런 문제가?
- Windows 10/11에서 WebP는 기본적으로 브라우저와 연결됨
- Windows 기본 "사진" 앱은 WebP를 지원하지만, 파일 연결이 브라우저 우선
- 일반 사용자들은 파일 연결 설정을 바꾸는 게 번거로움
- "왜 이미지인데 브라우저가 떠요?" 라는 질문이 계속 들어옴
고민 과정: 기술 vs 사용성
기술적으로는 WebP가 명백히 우월한데, 실제 사용 환경에서는 불편함을 줍니다.
고민했던 점:
- 이 프로그램을 쓰는 건 개발자가 아니라 일반 직원들
- "포맷이 뭔지" 신경 쓰지 않고 그냥 더블클릭해서 이미지를 보고 싶어 함
- 파일 용량보다 익숙한 사용 경험이 더 중요한가?
- 기술적 우월성과 실용성 중 무엇을 선택해야 하는가?
최종 결정: JPEG
선택 이유:
- PNG보다는 작고 (약 73% 감소)
- 모든 OS에서 기본 이미지 뷰어로 열림
- 품질도 충분함 (quality=75)
- 사용자가 전혀 신경 쓸 필요 없음
트레이드오프 분석:
| 항목 | WebP | JPEG | 결정 근거 |
|---|---|---|---|
| 파일 크기 | 720KB | 640KB | 큰 차이 없음 (80KB) |
| 압축률 | 70% | 73% | JPEG가 오히려 더 좋음 |
| 사용자 경험 | ⚠️ 브라우저 열림 | ✅ 뷰어로 열림 | 결정적 차이 |
| 설정 필요 | 파일 연결 변경 필요 | 없음 | JPEG 승 |
| 호환성 | 신규 포맷 | 오래된 표준 | JPEG 승 |
배운 점
처음엔 "WebP가 기술적으로 우월하니까 당연히 WebP"라고 생각했습니다.
하지만 실제 사용자들은 파일 포맷 같은 거에 관심 없습니다. 그냥 이미지를 빠르게 보고 싶을 뿐입니다. 더블클릭했는데 브라우저가 뜨면 "뭐지?" 하고 당황합니다.
기술적 최적화보다 사용자가 익숙한 경험이 더 중요할 때가 있습니다.
이 프로젝트의 목표는 "최신 이미지 포맷 사용하기"가 아니라 "이미지 일괄 처리를 편하게 하기"였습니다. JPEG를 선택한 건 타협이 아니라, 목표에 맞는 올바른 선택이었습니다.
성능 최적화
1. 썸네일 기반 미리보기
문제 상황
4K 이미지(3840x2160)에 변형을 적용하면 미리보기 생성에 800ms 이상 걸렸습니다. 슬라이더를 드래그할 때마다 UI가 멈추는 것처럼 느껴졌습니다.
해결 방법
미리보기는 512x512 썸네일로 축소해서 처리하고, 실제 저장할 때만 원본 해상도로 변환합니다.
성능 개선 효과 (4K 이미지 기준)
| 항목 | Before (원본) | After (썸네일) | 개선율 |
|---|---|---|---|
| 처리 픽셀 수 | 8,294,400 | 262,144 | 96.8% 감소 |
| 미리보기 생성 시간 | 800ms | 95ms | 8.4배 빠름 |
| 메모리 사용량 | 24MB | 768KB | 30배 감소 |
2. NumPy 벡터화로 노이즈 추가 고속화
성능 비교 (2000x2000 이미지)
| 방식 | 소요 시간 | 개선율 |
|---|---|---|
| 이중 for 문 | 1200ms | - |
| NumPy 벡터화 | 45ms | 26.7배 빠름 |
3. QThreadPool로 일괄 처리 병렬화
성능 개선 (100개 파일, 4코어 CPU)
- 싱글스레드: 82초
- 멀티스레드: 25.6초 (3.2배 개선)
성과 지표
- 파일 크기 최적화: PNG 대비 73% 감소 (JPEG Q=75)
- 미리보기 속도: 8.4배 개선 (썸네일 기반)
- 노이즈 추가: 26.7배 고속화 (NumPy 벡터화)
- 일괄 처리: 3.2배 빠름 (멀티스레딩, 4코어 기준)
- 메모리 사용: 96.8% 감소 (썸네일 미리보기)
마무리
처음에는 단순히 "메타데이터 지우는 프로그램"을 만들려고 했는데, 회전 후 크롭, 원근 변형, 멀티스레딩 등 생각보다 복잡한 문제들을 많이 마주쳤습니다. 특히 수학적 알고리즘(내접 직사각형, 행렬 변환)을 직접 구현하면서, 단순히 라이브러리를 가져다 쓰는 것보다 훨씬 깊이 있게 이해할 수 있었습니다.
Windows/macOS 플랫폼 차이, 멀티스레드 동기화, 메모리 최적화 등 실무에서 자주 마주칠 문제들을 경험한 게 가장 큰 수확이라고 생각합니다.