브라우저에서 GeoTIFF 이미지 미리보기 구현하기 (Next.js API Route + geotiff + sharp)

2026. 6. 17. 08:52·React, Next.js

문제 상황

항공 이미지나 위성 이미지 데이터는 주로 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]);

포인트 정리

  1. sharp는 서버에서만 — 브라우저에서 sharp를 쓸 수 없기 때문에 Next.js API Route가 적합한 레이어입니다.
  2. 비트 깊이 정규화 필수 — GeoTIFF는 16bit, float 데이터를 자주 담고 있어서, 그대로 쓰면 이미지가 새하얗게 나오거나 비정상 렌더링이 됩니다.
  3. 캐싱 전략 — 위성/항공 이미지는 변경이 거의 없으므로 immutable 캐싱을 걸면 반복 요청을 줄일 수 있습니다.
  4. 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
'React, Next.js' 카테고리의 다른 글
  • 8개월간 프론트엔드 아키텍처가 바뀐 이유들 - Vite에서 Turborepo + Next.js까지
  • [Next.js] 배포 환경에서만 터지는 Next.js Server Actions 500 에러 — nginx $host vs $http_host
  • [Next.js] Hydration Mismatch 에러
  • Vite와 Next.js에서 ENV/MODE/NODE_ENV 정리
Lucy96
Lucy96
개발새발 프론트엔드 개발자
  • Lucy96
    Lucy dev ✨
    Lucy96
  • 전체
    오늘
    어제
    • 분류 전체보기 (31)
      • JavaScript (3)
      • React, Next.js (7)
      • HTTP (1)
      • GIS (2)
      • 회고 (3)
      • Dev (14)
      • CSS (1)
      • DB (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • github
  • 공지사항

  • 인기 글

  • 태그

    oauth
    토스모닥불
    OAuth 2.0
    토스
    블로킹
    HTTP
    소셜로그인
    cors
    CSS
    webapis
    Hoisting
    BEM
    sessionStorage
    JavaScript
    geojson
    scss
    논블로킹
    gcp
    cliend id
    Mapbox
    Cookie
    react
    자바스크립트엔진
    프론트엔드
    콜백큐
    이벤트루프
    localStorage
    scope
    회고
    Google Cloud Platform
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Lucy96
브라우저에서 GeoTIFF 이미지 미리보기 구현하기 (Next.js API Route + geotiff + sharp)
상단으로

티스토리툴바