
문제 상황
항공 이미지나 위성 이미지 데이터는 주로 GeoTIFF(.tif) 포맷으로 저장됩니다. 그런데 브라우저는 TIF 포맷을 네이티브로 지원하지 않아서 <img> 태그로 직접 표시할 수 없습니다.
[백엔드 서버] → TIF 바이너리 → ??? → [브라우저]
해결 방안
Next.js API Route에서 TIF → PNG로 변환 후 브라우저에 전달하는 방식을 선택했습니다.
[백엔드 서버] → TIF 바이너리 → [Next.js API Route] → PNG 변환 → [브라우저]
왜 Next.js API Route인가?
대안 문제점
| 백엔드에서 변환 | 백엔드 수정 필요, 추가 작업 요청 필요 |
| 클라이언트에서 변환 | geotiff는 가능하나 sharp는 Node.js 전용으로 브라우저에서 불가 |
| CDN 변환 서비스 | 추가 인프라 비용, 인증 처리 복잡 |
| Next.js API Route | FE 자체 해결, 인증 쿠키 접근 가능 |
기술 스택
라이브러리 역할 선택 이유
| geotiff | TIF 파일 파싱 | GeoTIFF 표준 지원, 래스터 데이터 추출 |
| sharp | PNG 변환 | libvips 기반, Node.js 최적화, raw 데이터 → PNG 인코딩 |
npm install geotiff sharp
구현
아키텍처 다이어그램
┌─────────────┐ ┌──────────────────────┐ ┌─────────────┐
│ Browser │────▶│ Next.js API Route │────▶│ Backend │
│ │ │ /api/tif-to-image │ │ Server │
└─────────────┘ └──────────────────────┘ └─────────────┘
│ │
│ ▼
│ ┌──────────────────┐
│ │ TIF → PNG 변환 │
│ │ - geotiff 파싱 │
│ │ - sharp 변환 │
│ └──────────────────┘
│ │
▼ ▼
┌─────────────┐ ┌──────────────────┐
│ <img> 표시 │◀────│ PNG Response │
│ Blob URL │ │ (캐싱 적용) │
└─────────────┘ └──────────────────┘
API Route 전체 흐름
// app/api/tif-to-image/route.ts
// 클라이언트 요청: GET /api/tif-to-image?imageId=xxx&type=xxx
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const imageId = searchParams.get('imageId');
const type = searchParams.get('type');
// 1. 백엔드에서 TIF 바이너리 fetch
const backendUrl = `${process.env.BACKEND_URL}/images/${imageId}?type=${type}`;
const tifResponse = await fetch(backendUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const arrayBuffer = await tifResponse.arrayBuffer();
// 2. TIF → PNG 변환
const pngBuffer = await tifBufferToPng(arrayBuffer);
// 3. PNG 응답 (캐싱 적용)
return new NextResponse(new Blob([pngBuffer]), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable', // 1년 캐싱
},
});
}
핵심: TIF → PNG 변환 로직
import GeoTIFF from 'geotiff';
import sharp from 'sharp';
async function tifBufferToPng(arrayBuffer: ArrayBuffer): Promise<Uint8Array> {
// 1. GeoTIFF 파싱
const tiff = await GeoTIFF.fromArrayBuffer(arrayBuffer);
const image = await tiff.getImage();
// 2. 이미지 메타데이터 추출
const width = image.getWidth();
const height = image.getHeight();
const rasters = await image.readRasters(); // 채널별 픽셀 데이터 배열
// 3. RGBA 버퍼 생성
const samplesPerPixel = rasters.length;
let rgbaBuffer: Uint8Array;
if (samplesPerPixel >= 3) {
// RGB 또는 RGBA 이미지
rgbaBuffer = new Uint8Array(width * height * 4);
for (let i = 0; i < width * height; i++) {
rgbaBuffer[i * 4] = normalizeValue(rasters[0][i]); // R
rgbaBuffer[i * 4 + 1] = normalizeValue(rasters[1][i]); // G
rgbaBuffer[i * 4 + 2] = normalizeValue(rasters[2][i]); // B
rgbaBuffer[i * 4 + 3] = samplesPerPixel >= 4
? normalizeValue(rasters[3][i]) // Alpha 채널 (있는 경우)
: 255; // 없으면 완전 불투명
}
} else {
// Grayscale 이미지 → RGB로 확장
rgbaBuffer = new Uint8Array(width * height * 4);
for (let i = 0; i < width * height; i++) {
const value = normalizeValue(rasters[0][i]);
rgbaBuffer[i * 4] = value; // R
rgbaBuffer[i * 4 + 1] = value; // G
rgbaBuffer[i * 4 + 2] = value; // B
rgbaBuffer[i * 4 + 3] = 255; // A
}
}
// 4. sharp로 PNG 인코딩
const pngBuffer = await sharp(rgbaBuffer, {
raw: { width, height, channels: 4 },
})
.png()
.toBuffer();
return new Uint8Array(pngBuffer);
}
비트 깊이 정규화
GeoTIFF는 8bit, 16bit, float 등 다양한 비트 깊이를 지원합니다. PNG는 8bit(0~255)만 지원하므로 정규화가 필요합니다.
function normalizeValue(value: number): number {
// 8비트 (0~255) → 그대로 사용
if (value >= 0 && value <= 255) {
return Math.round(value);
}
// 16비트 (0~65535) → 255 스케일로 변환
if (value > 255 && value <= 65535) {
return Math.round((value / 65535) * 255);
}
// Float (0~1) → 255 스케일로 변환
if (value >= 0 && value <= 1) {
return Math.round(value * 255);
}
// 범위 이탈 값 클램핑
return Math.min(255, Math.max(0, Math.round(value)));
}
클라이언트에서 사용하기
API를 호출하고 Blob URL을 생성해서 <img> 태그에 넘겨줍니다.
export const fetchTifImageUrl = async (
imageId: string,
type: string
): Promise<string | null> => {
try {
const res = await fetch(`/api/tif-to-image?imageId=${imageId}&type=${type}`);
if (!res.ok) return null;
const blob = await res.blob();
return URL.createObjectURL(blob); // 메모리 내 Blob URL 생성
} catch {
return null;
}
};
// 컴포넌트에서 사용
const [imageUrl, setImageUrl] = useState<string | null>(null);
useEffect(() => {
fetchTifImageUrl(imageId, 'preview').then(setImageUrl);
}, [imageId]);
return imageUrl ? <img src={imageUrl} alt="위성 이미지" /> : <Skeleton />;
주의: URL.createObjectURL()로 생성한 Blob URL은 컴포넌트 언마운트 시 URL.revokeObjectURL()로 해제해주는 것이 좋습니다. 메모리 누수를 방지할 수 있어요.
useEffect(() => {
let url: string | null = null;
fetchTifImageUrl(imageId, 'preview').then((result) => {
url = result;
setImageUrl(result);
});
return () => {
if (url) URL.revokeObjectURL(url);
};
}, [imageId]);
포인트 정리
- sharp는 서버에서만 — 브라우저에서 sharp를 쓸 수 없기 때문에 Next.js API Route가 적합한 레이어입니다.
- 비트 깊이 정규화 필수 — GeoTIFF는 16bit, float 데이터를 자주 담고 있어서, 그대로 쓰면 이미지가 새하얗게 나오거나 비정상 렌더링이 됩니다.
- 캐싱 전략 — 위성/항공 이미지는 변경이 거의 없으므로 immutable 캐싱을 걸면 반복 요청을 줄일 수 있습니다.
- Grayscale 처리 — 단채널 TIF(예: DSM, DEM 데이터)도 R=G=B로 복사해서 정상 처리되도록 처리해야 합니다.
'React, Next.js' 카테고리의 다른 글
| 8개월간 프론트엔드 아키텍처가 바뀐 이유들 - Vite에서 Turborepo + Next.js까지 (2) | 2026.06.25 |
|---|---|
| [Next.js] 배포 환경에서만 터지는 Next.js Server Actions 500 에러 — nginx $host vs $http_host (0) | 2026.06.17 |
| [Next.js] Hydration Mismatch 에러 (0) | 2026.02.19 |
| Vite와 Next.js에서 ENV/MODE/NODE_ENV 정리 (0) | 2025.11.19 |
| React Query (Tanstack Query) + Suspense + Error Boundary (0) | 2025.11.09 |