Bomi Kwon 권보미

나만의 간판 생성 프로젝트 개발기 (feat. 풀스택 도전기)

ganpan

도입

image0

간판이라는 특별한 타이포그래피를 조합해서 나만의 간판을 만들어보자

  • 간판 프로젝트는 간판이라는 아날로그 매체를 웹으로 재구성하는 프로젝트입니다. 간판 사진을 데이터로, 사용자가 원하는 메세지에 따라 여러 간판을 조합해서 콜라주한 이미지를 제공합니다. 이 글은 개발기에 대한 내용을 다루는만큼, 자세한 기획 의도는 링크(https://ganpan.vercel.app/about)로 대체하겠습니다. 전반적인 목차는 아래와 같습니다.

목차

  1. 기획 과정

    1. 기능 소개
    2. 기술스택 설정
  2. 개발 과정

    1. DB 구축
      1. supabase 선택한 이유
      2. 사진 indexing
      3. supabase storage & table 사용해 DB 구축하는 법
    2. API 구현
    3. 클라이언트 구현
      1. Image 렌더링 컴포넌트
  3. 결과


1. 기획 과정

1. 기능 소개

DB 기능

image1

  • DB를 구축한 과정은 다음과 같습니다. 간판을 사진을 통해 수집하고, 각 음절의 사진으로 크롭해 DB에 table로 저장합니다.

클라이언트 기능

gif01

image2

  • 클라이언트의 기능은 다음과 같습니다. 사용자가 간판으로 생성하고자 하는 메세지를 입력하면, 음절단위의 간판 이미지를 DB에서 가져와 콜라주한 이미지를 제공합니다. 또한 생성된 콜라주 이미지를 건물 형태의 3d 환경에서 아카이빙합니다.

2. 기술스택 설정

image3

  • 클라이언트에는 Next.js, Three.js, Tailwind css를 사용했고, 배포에는 Vercel을 이용했습니다. 또한 서버는 supabase를 사용했습니다.

  • 그 외에 한글을 로마자로 변환해주는 library인 aromanize, HEIC 파일을 jpg로 변환하는 heic2any, 이미지 저장을 위한 html2canvas 라이브러리를 사용했습니다.

2. 개발 과정

1. DB 구축

1. supabase 선택한 이유

  1. 직관적인 UI
  • storage 메뉴를 통해 데이터 관리하고, 해당 데이터베이스를 table로 관리해 PostgreSQL 형식의 CRUD 기능을 지원합니다. 해당 기능을 supabase의 웹사이트에서 사용할 수 있고, 직관적인 UI를 통해 백앤드를 noSQL로 구축할 수 있습니다.
  1. 다양한 클라이언트 단에서 API 코드 관리 가능
  • supabase의 데이터를 받아오는 client 객체를 생성한 뒤, DB에서 동기적으로 데이터를 받아오는 과정을 클라이언트 단에서 모두 관리할 수 있습니다. frontend 단에서 DB에 간편하게 접근할 수 있다는 게 큰 장점으로 느껴졌어요.

image 14

  • 또한 supabase는 다양한 framework를 지원합니다. 또한 docs도 간결하게 서술되어 있어 빠르게 시작할 수 있습니다.

2. 사진 indexing

  • 사용자는 input에 원하는 text를 넣고, 웹은 해당 text와 매핑되는 간판 이미지를 조합해서 제공합니다. 이 기능을 구현하기 위해서 간판 사진을 음절 단위로 크롭하고, 크롭된 이미지를 한 글자와 매핑해야합니다.

image4

  • 크롭된 이미지의 이름을 [원래 간판 이미지의 인덱스]-[인덱스]-[음절 단위 글자]로 구성했습니다. 이는 업로드할 때 효율적으로 table을 구성하기 위한 목적입니다. 아쉽게도 크롭 및 텍스트 맵핑, 이름 설정까지는 수작업으로 진행했습니다. (추후에 더 큰 DB를 구축하게 된다면 이 작업도 AI를 이용해 자동화해보고 싶네요.)

  • storage 데이터를 table에 업로드하는 것은 PostgreSQL 방식으로 구현합니다. 이때 파일명을 통해 table의 column을 채우도록 자동화 코드를 추가했습니다.

const { data: files, error } = await supabase
    .storage
    .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET as string)
    .list('', {
        limit: 10000,
        offset: 0,
        sortBy: { column: 'name', order: 'asc' },
    })

DB가 올라와있는 storage, NEXT_PUBLIC_STORAGE_BUCKET에서 모든 data 리스트를 가져옵니다.

if (file.name.includes("-")) {
  const parent = file.name.split("-")[0];
  const tmp = file.name.split("-")[2];
  const last = tmp.split(".")[0];

  const { data: insertData, error: insertError } = await supabase
    .from("image_gallery")
    .insert([
      {
        file_name: file.name,
        public_url: publicUrl,
        fk_parent_id: parent,
        character: last,
      },
    ]);
}

가져온 data를 table에 넣는 코드입니다. 이때 위에서 처리한 인덱스별로 columns 값을 구성하도록 했습니다.

3. supabase storage & table 사용해 DB 구축하는 법

1. supabase의 storage와 table이란?
  • supabase는 storage와 table라는 개념을 통해 DB를 구축할 수 있게 하는데요.

  • storage는 컴퓨터 서버 상에 데이터를 영구적으로 보관하는 저장소 개념입니다.

  • table은 구성된 관계형 데이터베이스에 따라 column과 data를 가지고, API를 통해 CRUD 상호작용을 하는 데이터베이스의 단위라 할 수 있습니다.

  • 뭐랄까 storage는 올리브영의 창고에 쌓인 화장품들이고, table은 매대에 종류별로 정렬된 상품이라고 설명할 수 있겠네요..

2. 대용량 이미지 저장소 storage
  • 250여개의 간판 이미지와, 약 2000여 개(?)가 넘는 크롭된 이미지를 DB에 올려, 사용자의 input에 따라 적절한 이미지를 반환해야합니다. 이를 위해 대용량의 데이터를 관리할 수 있는 supabase의 storage를 활용했습니다.
3. 클라이언트의 CRUD 요청을 처리하는 스키마 table

image 16

위와 같이 구성한 관계형 스키마는 supabase에서 table로 구성할 수 있습니다.

image5

  • DB 단에 저장해야할 table은 크게 3개입니다. 음절 단위로 크롭된 이미지 images, 크롭 전 간판 이미지 parent_images, 사용자 메시지로 콜라주된 collage_images입니다.

gif02

  • 콜라주 이미지에서 크롭 이미지(images)를 누를 때마다 부모의 이미지(parent_images)를 불러오기 때문에, parent_images의 id를 foreign key로 images에서 참조했습니다.

  • table을 구축한 뒤에, storage에 올려진 데이터들을 table에 columns 별로 알맞게 업로드합니다. 이는 client 단에서 API를 만들어 처리합니다. 자세한 API 구현은 아래서 다루겠습니다.

try {
        const response = await fetch('/api/upload-image-urls', { method: 'POST' })
        const data = await response.json()
        setResult(data.message)
    }

자 이제 DB 설계와 구축이 끝났으니, 클라이언트를 구현할 차례입니다.

2. API 구현

  • supabase의 장점이라 할 수 있는 부분인데요. 바로 typescript로 클라이언트 단에서 API까지 생성할 수 있다는 점입니다.

  • api 폴더 안에 원하는 api 파일을 만들어줍니다. 저는 get-search-image 이라는 이름으로 사용자의 입력값과 맞는 table의 정보를 반환하는 api를 만들어보았습니다. api를 만드는 부분은 2가지로 나눠서 설명해보겠습니다.

1. api를 호출하는 부분

gif01

const fetchSearchedImages = async (userInput: string) => {
  try {
    const response = await fetch(
      `/api/get-search-image?input=${encodeURIComponent(userInput)}`,
      { method: "GET" }
    );
    const data = await response.json();
    return data.results;
  } catch (error) {
    console.log(error);
    return [];
  }
};

fetchSearchedImages 함수를 만들어 사용자의 입력값 userInput을 인자로 넘겨줍니다. 해당 입력값은 만들어둔 api 파일의 쿼리로 넘겨줍니다. encodeURIComponent을 통해 string 입력값을 UTF-8로 인코딩해줍니다.

또한 method을 나타내는 객체와 함께 api에 대한 정보를 넘겨줍니다.

2. api를 만들고, supabase DB에 접근하는 부분

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === "GET") {
    const { input } = req.query;
    if (typeof input !== "string") {
      return res
        .status(400)
        .json({ success: false, message: "Input must be a string" });
    }
    const result = await getSearchedImageUrls(input);
    if (result.success) {
      res.status(200).json(result);
    } else {
      res.status(500).json(result);
    }
  } else {
    res.setHeader("Allow", ["GET"]);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}

해당 api에서 쿼리를 받아, type을 검사한 뒤 에러 처리 및 DB 접근을 위한 getSearchedImageUrls 함수 호출을 하는 부분입니다.

async function getSearchedImageUrls(input: string) {
  const results: any = [];

  try {
    const { data: image_gallery, error } = await supabase
      .from("image_gallery")
      .select("*")
      .eq("character", input);

    if (error) throw error;
    for (const file of image_gallery) {
      if (error) throw error;
      results.push(file);
    }
    return { success: true, message: "All image URLS donw", results };
  } catch (error) {
    console.error("Error:", error);
    return {
      success: false,
      message: "An error occurred while getting image URLs.",
      error: String(error),
    };
  }
}

해당 함수에서 주목해야할 부분은 아래와 같습니다.

const { data: image_gallery, error } = await supabase
  .from("image_gallery")
  .select("*")
  .eq("character", input);

for (const file of image_gallery) {
  if (error) throw error;
  results.push(file);
}
  • import supabase from '@/utils/supabase/supabaseClient'로부터 가져온 supabase Promise 객체를 통해 특정 table로 접근합니다. 위 코드를 해석해보자면, image_gallery table의 모든 rows에서, character columns이 input과 같은 row를 image_gallery 변수에 넣는 기능을 하는 것이 되겠죠.

  • api 호출을 통해 supabase DB에 접근하고, 해당 결과값을 handler의 반환값으로 처리하게 되면 api 구현이 완료됩니다!

  • 클라이언트 단에서 이렇게 api를 직접 만들면서, DB table의 관계를 이해하는 게 보다 수월했습니다. 또한 typescript로 api를 만들 수 있다는 게 프론트엔드 개발자로서 매우 큰 장점으로 느껴졌습니다.

3. 클라이언트 구현

Image 렌더링 컴포넌트

  • DB를 구축하고, 사용자의 입력값에 따라 크롭된 이미지를 가져오는 api도 제작했습니다. 이제는 사용자에 입력에 따라 조합된 이미지를 렌더링하는 부분이 남았군요.

  • 해당 컴포넌트를 제작하는 데 가장 많은 수고를 들였던 것 같아요. 단순히 Image가 아닌 조합된 콜라주 형태의 Image였기 때문에 아래와 같은 부분을 고민했습니다.

image6

  • 사용자가 띄어쓰기를 입력할 때마다 Images가 들여쓰기 형태로 다음 줄로 넘어가게 한다.

  • 콜라주 형태는 직사각형 이미지여야한다. 이미지의 크기가 다르기 때문에, 높이는 고정하고 너비를 각 줄의 평균값으로 만들어 직사각형을 유지하게 한다.

1. 타입 지정

interface ImageData {
  fk_parent_id: number;
  public_url: string;
  file_name: string;
}
type RowItem = ImageData | string;

우선 Image를 렌더링하기 전에, Image의 각 정보를 객체로 받아오기 위해 타입 interface를 지정합니다. 사용자 입력값에 해당하는 이미지가 없는 경우엔, 일반 텍스트를 그대로 보여주기 위해 RowItem이라는 ImageData와 string을 혼용할 수 있는 type을 만들었습니다.

2. 2차원 배열로 이미지 정보를 관리

const groupImagesByRow = useMemo(() => {
  const rows: RowItem[][] = [];
  let currentRow: RowItem[] = [];

  images.forEach((image) => {
    if ("fk_parent_id" in image) {
      // 일반적인 image의 정보 객체일 경우
      currentRow.push(image);
    } else if ("file_name" in image) {
      if (currentRow.length > 0) {
        // 띄어쓰기를 입력한 경우
        rows.push(currentRow);
        currentRow = [];
      }
    }
  });

  if (currentRow.length > 0) {
    rows.push(currentRow);
  }
  return rows;
}, [images]);

Image 정보 객체를 담은 images변수를 2차원 배열로 분배하는 함수입니다. 이 함수의 목적은, 사용자가 띄어쓰기를 입력할 때마다 글자 이미지 또한 다음 줄로 넘겨 사용자의 의도를 최종 결과 콜라주에 반영하기 위함입니다.

띄어쓰기를 입력한 경우, tmp 변수 역할을 하는 currentRowrows에 반영한 뒤, 초기화해서 다음 글자를 처리하도록 했습니다.

3. 2차원 배열을 이미지로 렌더링

<div className="flex items-center justify-center">
  <div
    ref={ref}
    style={{
      width: `${averageWidth * 500}px`,
      maxWidth: "652px",
    }}
  >
    {groupImagesByRow.map((row, rowIndex) => (
      <div
        key={rowIndex}
        className="flex"
        style={{
          width: "100%",
          marginBottom: "1px",
        }}
      >
        {row.map(
          (image, imageIndex) => renderImage(image, imageIndex, row.length) // 각 배열에 해당하는 이미지를 렌더링하는 객체
        )}
      </div>
    ))}
  </div>
</div>

groupImagesByRow함수가 반환한 row에 속한 한 줄의 이미지들을, 또다시 renderImage를 통해 렌더링할 수 있게 해 들여쓰기 기능을 구현했습니다.

결과

gif03

gif04

기획의도에 따라 DB를 제작하고, supabase api를 통해 직접 서버 통신까지 구현하는 점은 저에게 큰 도전이었다고 생각합니다. 또한 이미지를 css와 배열 관리를 통해 의도대로 렌더링해보는 것 또한 너무 재밌었습니다. 학과 전시를 통해 직접 유저의 input을 받아보고, 웹으로 저의 생각을 전달할 수 있음에 또 감사한 프로젝트였습니다.