나만의 간판 생성 프로젝트 개발기 (feat. 풀스택 도전기)
도입
간판이라는 특별한 타이포그래피를 조합해서 나만의 간판을 만들어보자
간판 프로젝트
는 간판이라는 아날로그 매체를 웹으로 재구성하는 프로젝트입니다. 간판 사진을 데이터로, 사용자가 원하는 메세지에 따라 여러 간판을 조합해서 콜라주한 이미지를 제공합니다. 이 글은 개발기에 대한 내용을 다루는만큼, 자세한 기획 의도는 링크(https://ganpan.vercel.app/about)로 대체하겠습니다. 전반적인 목차는 아래와 같습니다.
목차
-
기획 과정
- 기능 소개
- 기술스택 설정
-
개발 과정
- DB 구축
- supabase 선택한 이유
- 사진 indexing
- supabase storage & table 사용해 DB 구축하는 법
- API 구현
- 클라이언트 구현
- Image 렌더링 컴포넌트
- DB 구축
-
결과
1. 기획 과정
1. 기능 소개
DB 기능
- DB를 구축한 과정은 다음과 같습니다. 간판을 사진을 통해 수집하고, 각 음절의 사진으로 크롭해 DB에 table로 저장합니다.
클라이언트 기능
- 클라이언트의 기능은 다음과 같습니다. 사용자가 간판으로 생성하고자 하는 메세지를 입력하면, 음절단위의 간판 이미지를 DB에서 가져와 콜라주한 이미지를 제공합니다. 또한 생성된 콜라주 이미지를 건물 형태의 3d 환경에서 아카이빙합니다.
2. 기술스택 설정
-
클라이언트에는 Next.js, Three.js, Tailwind css를 사용했고, 배포에는 Vercel을 이용했습니다. 또한 서버는 supabase를 사용했습니다.
-
그 외에 한글을 로마자로 변환해주는 library인
aromanize
, HEIC 파일을 jpg로 변환하는heic2any
, 이미지 저장을 위한html2canvas
라이브러리를 사용했습니다.
2. 개발 과정
1. DB 구축
1. supabase 선택한 이유
- 직관적인 UI
storage
메뉴를 통해 데이터 관리하고, 해당 데이터베이스를table
로 관리해 PostgreSQL 형식의 CRUD 기능을 지원합니다. 해당 기능을 supabase의 웹사이트에서 사용할 수 있고, 직관적인 UI를 통해 백앤드를 noSQL로 구축할 수 있습니다.
- 다양한 클라이언트 단에서 API 코드 관리 가능
- supabase의 데이터를 받아오는 client 객체를 생성한 뒤, DB에서 동기적으로 데이터를 받아오는 과정을 클라이언트 단에서 모두 관리할 수 있습니다. frontend 단에서 DB에 간편하게 접근할 수 있다는 게 큰 장점으로 느껴졌어요.
- 또한 supabase는 다양한 framework를 지원합니다. 또한 docs도 간결하게 서술되어 있어 빠르게 시작할 수 있습니다.
2. 사진 indexing
- 사용자는 input에 원하는 text를 넣고, 웹은 해당 text와 매핑되는 간판 이미지를 조합해서 제공합니다. 이 기능을 구현하기 위해서 간판 사진을 음절 단위로 크롭하고, 크롭된 이미지를 한 글자와 매핑해야합니다.
-
크롭된 이미지의 이름을
[원래 간판 이미지의 인덱스]-[인덱스]-[음절 단위 글자]
로 구성했습니다. 이는 업로드할 때 효율적으로 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
위와 같이 구성한 관계형 스키마는 supabase에서 table로 구성할 수 있습니다.
- DB 단에 저장해야할 table은 크게 3개입니다. 음절 단위로 크롭된 이미지 images, 크롭 전 간판 이미지 parent_images, 사용자 메시지로 콜라주된 collage_images입니다.
-
콜라주 이미지에서 크롭 이미지(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를 호출하는 부분
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였기 때문에 아래와 같은 부분을 고민했습니다.
-
사용자가 띄어쓰기를 입력할 때마다 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 변수 역할을 하는 currentRow
을 rows
에 반영한 뒤, 초기화해서 다음 글자를 처리하도록 했습니다.
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를 통해 렌더링할 수 있게 해 들여쓰기 기능을 구현했습니다.
결과
기획의도에 따라 DB를 제작하고, supabase api를 통해 직접 서버 통신까지 구현하는 점은 저에게 큰 도전이었다고 생각합니다. 또한 이미지를 css와 배열 관리를 통해 의도대로 렌더링해보는 것 또한 너무 재밌었습니다. 학과 전시를 통해 직접 유저의 input을 받아보고, 웹으로 저의 생각을 전달할 수 있음에 또 감사한 프로젝트였습니다.