[토이 프로젝트] Business Card Maker 만들기

기운찬곰·2023년 8월 6일
0

토이 프로젝트

목록 보기
1/3
post-thumbnail

Overview

무엇보다 일주일 안에 만들 수 있을만큼 간단하면서도 재미있고 눈에 띄는 프로젝트를 하고 싶었습니다. 처음부터 복잡한 걸 하기 보다는 내가 만들고 싶은 걸 만들어야 된다는 목표도 있었고요.

참고 : https://youtu.be/Rh1MOyfQo14 (제일 처음 영향을 받은 곳)

그 중에서 저는 Josh tried coding 라는 유튜버 영상 중에 Business Card App을 만드는 게 있길래 명함 만들기가 재미있어보였습니다.


Business Card Maker 요구 사항 및 사전 조사

요구 사항 (최소 기능)

제가 생각하는 Business Card Maker는 메인 페이지에 Business Card 템플릿 몇 가지가 있어서 사용자가 맘에 드는 템플릿을 선택하도록 했으면 좋겠습니다. 그리고 나서 상세 페이지로 넘어가서 사용자가 텍스트를 수정하고 색상이라던가 위치를 바꿀 수 있도록 했으면 좋겠습니다. 마지막으로 명함 수정이 완료되면 이를 이미지로 만들어서 제공하고 명함 이미지를 공유할 수 있게 하는 것이 최종 목표입니다.

이를 정리해서 MVP(Minimum Viable Product)를 정의해보면 다음과 같습니다.

  1. 메인 페이지에서 Business Card 템플릿 제공
  2. 상세 페이지에서 제공된 템플릿을 기반으로 Business Card 수정 (텍스트, 위치, 색상 등)
  3. 최종 Business Card 이미지 공유 기능

사전 조사 - 이미 많이 있지 않을까?

일단 구글 검색 시에는 많이 보이지 않았습니다. 그래서 velog 내에서 검색을 해보니 2~3개 정도 보였습니다.

참고 : https://velog.io/@sohyeonbak_oly/비즈니스-카드-메이커-명함-만들기

해당 프로젝트는 뭔가 살짝 아쉬웠습니다. 제가 평가하기는 그렇지만 개인적으로 봤을때 너무 정적인 명함 만들기 형태인거 같았습니다.

깃허브 검색 : https://github.com/search?q=business+card&type=repositories
참고 : https://github.com/scastiel/github-business-card

깃허브 검색 시에는 꽤나 많이 존재했습니다. 하지만 마음에 드는건 별로 없었고 Sebastien Castiel 라는 분이 만든 저 프로젝트는 괜찮아보였습니다. 이건 특이하게 깃허브 유저명만 입력하면 자동으로 깃허브 명함을 만들어줍니다. 이건 개인적으로 탐나는 아이템이긴 하지만 이미 이 분이 만들었으므로 아쉽긴 했습니다.

참고 : https://github.com/syneo-tools-gmbh/Javascript-BCR-Library

이건 좀 다른 얘기인데 명함으로 OCR 판독해서 글자 추출하는게 있습니다. Tesseract OCR 를 사용하나봅니다. 이것도 재밌어보이긴 합니다.

참고 : https://www.bizhows.com/

여기는 아예 서비스가 별도로 있었습니다. 근데 자세히 보니 미리캠퍼스랑 연동이 되어있더군요. 즉, 명함 수정은 미리캠퍼스 사이트 내에서 이뤄지는 형태입니다. 그리고 그 결과를 바탕으로 실제 명함을 만들어서 배송해주는 서비스 입니다.

명함 종류 살펴보기

정말 명함 종류는 다양하고 제각각입니다. 저는 다양한 명함을 살펴본 결과 배치 형태는 크게 몇 개로 나눌 수 있었습니다. 이를 기준으로 저는 템플릿 몇 개를 만들기로 했습니다.


Vercel Open Graph (OG) Image Generation

참고 : https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation

그러면 명함 이미지를 어떻게 만들 수 있을지 먼저 생각해봤습니다. 그 중에서 @vercel/og 라이브러리를 통해 소셜 미디어 이미지 생성을 최적화하는 방법이 가장 눈에 띄었습니다. 위에서 사전 조사한 프로젝트에서도 @vercel/og 라이브러리가 사용 되기도 했고요.

동적 OG(Open Graph) 이미지 생성을 지원하기 위해, Vercel @vercel/og 라이브러리를 사용하여 Vercel Edge Functions를 사용하여 소셜 카드 이미지를 계산하고 생성할 수 있습니다. - 공식 문서

다음과 같은 이점이 있습니다.

  • 성능: 이미지를 생성하는 데 필요한 소량의 코드만 있으면, Edge Functions를 거의 즉시 시작할 수 있습니다. 이를 통해 이미지 생성 프로세스가 빠르고 Open Graph Debugger와 같은 도구로 인식될 수 있습니다.
  • 사용 편의성: HTML과 CSS를 사용하여 이미지를 정의할 수 있으며 라이브러리는 마크업에서 이미지를 동적으로 생성합니다.
  • 비용 효율성: @vercel/og에서 계산된 이미지를 캐시하기 위해 올바른 헤더를 자동으로 추가하여 비용 및 재계산 절감.

이런 것들을 지원합니다.

  • Flexbox 및 절대 위치 지정을 포함한 기본 CSS 레이아웃
  • 사용자 정의 글꼴, 텍스트 래핑, 가운데 맞춤 및 중첩 이미지
  • Google 글꼴에서 글꼴의 하위 집합 문자를 다운로드하는 기능
  • Vercel에 구축된 모든 프레임워크 및 애플리케이션과 호환 가능

저는 처음부터 Next.js를 통해 프로젝트를 진행하고 Vercel을 통해 배포할 생각이었으므로 @vercel/og를 통한 이미지 생성 방법을 사용하기로 했습니다. 이게 결국 원리가 마크업을 통해 이미지를 생성한다는 의미입니다.


피그마 목업 만들기

2일차에는 피그마를 통해 간단한 목업을 만들기로 했습니다. 메인 페이지와 상세 페이지, 그리고 공유 페이지를 만들었습니다.


Vercel og 기능 사용해보기

대략적인 로직 정의

  1. 메인 페이지에서 사용자가 명함 템플릿 선택
  2. 명함 템플릿 상세 페이지(수정할 수 있는 페이지)로 이동. /templates/1 이런식으로.
  3. 이때 DB에 저장된 템플릿 형식 데이터를 불러옴. 그걸 HTML로 잘 뿌려주도록 만들어야 함. (이게 핵심)
  4. 그리고 글자 수정부터 좀 처리하자. 나머지는 둘째 치고.
  5. 다 되었으면 Publish 버튼 클릭. 그러면 DB에 새 명함이 저장됨. 무작위 id를 반환해줌.
  6. /api/og?image=무작위 id 이런식으로 요청하면 다시 여기서 fetch를 해서 명함 id에 해당하는 정보를 받아온 다음에 vercel og를 이용해 HTML을 Image로 바꿔서 보여주면 됨.

Vercel og 기능

참고 : https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation

일단 지금 샘플 명함으로 이미지 생성이 가능한지 한번 테스트 해보도록 했습니다. 그게 제일 중요한 기능이기 때문에 그렇습니다. 먼저 라이브러리를 설치해줍니다.

pnpm i @vercel/og

참고 : https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes

특이한 점은 runtime: 'edge'을 활성화하여 Edge 런타임 사용해야 합니다. 기본 Node.js 런타임이 지원되지 않으므로 그렇습니다. Next.js 에서 "runtime"은 실행 중 코드에 사용할 수 있는 라이브러리, API 및 일반 기능 집합을 의미합니다. Next.js에는 응용 프로그램 코드의 일부를 렌더링할 수 있는 두 개의 서버 런타임이 있습니다:

각 런타임에는 고유한 API 세트가 있습니다. 사용 가능한 API의 전체 목록은 Node.js Docs 및 Edge Docs를 참조하십시오. 런타임을 선택할 때 고려해야 할 사항이 많습니다. 이 표는 주요 차이점을 한 눈에 보여줍니다

Next.js에서 경량 에지 런타임은 사용 가능한 Node.js API의 하위 집합입니다.

Edge Runtime은 작고 간단한 기능으로 짧은 대기 시간에 동적인 개인화된 콘텐츠를 제공해야 하는 경우 이상적입니다. 엣지 런타임의 속도는 최소한의 리소스 사용에서 비롯되지만 많은 시나리오에서 제한적일 수 있습니다.

뭐... 아무튼 @vercel/og를 이용하는 샘플 코드는 다음과 같습니다.

// app/api/og/route.tsx
import { ImageResponse } from "@vercel/og";

export const runtime = "edge";

export async function GET() {
  try {
    return new ImageResponse(<div>My First OG Image</div>, {
      width: 1200,
      height: 630,
    });
  } catch {
    return new Response(`Failed to generate the image`, {
      status: 500,
    });
  }
}

호출 : http://localhost:3000/api/og

<img style="display: block;-webkit-user-select: none;margin: auto;
background-color: hsl(0, 0%, 90%);transition: background-color 300ms;" 
src="http://localhost:3001/api/og">

성공입니다. 다만 아쉬운 점은 tailwind CSS 사용이 불가능합니다. 결국 style를 직접 사용해서 디자인을 해주었습니다.

명함 데이터 정의

DB 연동 전에 data 폴더 내 template.json을 만들어서 사용하기로 했습니다. 일단 간단하게 template id와 width, height, backgroundColor 만 정의해주었습니다.

{
  "id": "1",
  "width": 352,
  "height": 202,
  "backgroundColor": "#000000"
}

그리고 /api/business-card/[id] 폴더 내 API Route를 만들어 json 파일을 불러와서 넘겨주도록 했습니다.

import path from "path";
import { readFile } from "fs/promises";
import { NextResponse } from "next/server";

export async function GET(req: Request) {
  const { pathname } = new URL(req.url);
  const [, id] = pathname.match(/^\/api\/business-card\/([^\/]+)/) || [];

  if (!id) {
    return new Response("Not Found", { status: 404 });
  }

  //Find the absolute path of the json directory
  const jsonDirectory = path.join(process.cwd(), "src", "data");
  //Read the json data file data.json
  const fileContents = await readFile(jsonDirectory + "/template.json", "utf8");

  return NextResponse.json(fileContents);
}

이제 /api/og 에서 fetch를 통해 위 api 를 호출해서 가져와서 쓸 수 있게 해줬습니다. 잘 됩니다.

import { ImageResponse } from "@vercel/og";

export const runtime = "edge";

export async function GET() {
  try {
    const res = await fetch(`http://localhost:3001/api/business-card/1`);
    const data = await res.json();

    const businessCard = JSON.parse(data);

    return new ImageResponse(
      <div className="bg-[#014849]">My First OG Image</div>,
      {
        width: businessCard.width,
        height: businessCard.height,
      }
    );
  } catch (error) {
    console.error(error);
    return new Response(`Failed to generate the image`, {
      status: 500,
    });
  }
}

참고 : https://github.com/orgs/vercel/discussions/1343

다만 아쉬운 점은 fontWeight는 제대로 적용되지 않았습니다. 이는 “@vercel/og 패키지에는 400 두께의 내장 글꼴(Noto Sans)이 1개뿐입니다.” 이라고 합니다. 그래서 원하는 두께의 사용자 지정 글꼴을 포함해야 한다고 하네요.


명함 데이터 설계 및 렌더링

명함 데이터 설계

이제 본격적으로 명함 내 요소에 대한 데이터 설계를 진행했고 아래 처럼 만들었습니다.

{
  "id": 1,
  "width": 352,
  "height": 202,
  "backgroundColor": "#014849",
  "children": [
    {
      "id": 1,
      "type": "text",
      "text": "Logo",
      "x": 270,
      "y": 20,
      "color": "#ffffff",
      "fontSize": 20,
      "fontWeight": 700
    },
    {
      "id": 2,
      "type": "text",
      "text": "ceo",
      "x": 30,
      "y": 90,
      "color": "#ffffff",
      "fontSize": 12
    },
    {
      "id": 3,
      "type": "text",
      "text": "홍길동",
      "x": 60,
      "y": 85,
      "color": "#ffffff",
      "fontSize": 20
    },
    {
      "id": 4,
      "type": "text",
      "text": "Mobile. 010-1234-5678<br />Email. gildong-hong@naver.com<br />서울시 마포구 양화로 45, 세아타워 16층",
      "x": 30,
      "y": 120,
      "color": "#ffffff",
      "fontSize": 12
    }
  ]
}

그리고 이를 Zod를 통해 타입 체크 및 타입 추출을 해서 사용하도록 했습니다. 저는 children 타입을 text와 image 별도로 있길 원했고 z.union을 사용하면 된다는 사실을 알게 되었습니다.

import { z } from "zod";

export const BusinessCardText = z.object({
  type: z.literal("text"),
  x: z.number().int().positive(),
  y: z.number().int().positive(),
  width: z.number().int().positive(),
  height: z.number().int().positive(),
  text: z.string(),
  color: z.string().regex(/^#[0-9a-f]{6}$/i),

  // Optional
  fontSize: z.number().int().positive().optional(),
  fontWeight: z.number().int().positive().optional(),
  textAlign: z
    .union([z.literal("left"), z.literal("center"), z.literal("right")])
    .optional(),
  lineHeight: z.number().int().positive().optional(),
  letterSpacing: z.number().int().positive().optional(),
  textDecoration: z
    .union([
      z.literal("none"),
      z.literal("underline"),
      z.literal("line-through"),
    ])
    .optional(),
  fontStyle: z
    .union([z.literal("normal"), z.literal("italic"), z.literal("oblique")])
    .optional(),
  fontFamily: z.string().optional(),
});

export const BusinessCardImage = z.object({
  type: z.literal("image"),
  x: z.number().int().positive(),
  y: z.number().int().positive(),
  width: z.number().int().positive(),
  height: z.number().int().positive(),
  src: z.string(),
});

export const BusinessCardValidator = z.object({
  id: z.string(),
  width: z.number().int().positive(),
  height: z.number().int().positive(),
  backgroundColor: z.string().regex(/^#[0-9a-f]{6}$/i),
  children: z.array(z.union([BusinessCardText, BusinessCardImage])),
});

export type BusinessCard = z.infer<typeof BusinessCardValidator>;

명함 데이터 렌더링

그리고 최종적으로 명함 데이터를 불러와서 렌더링 해주었습니다.

return new ImageResponse(
  (
    <div
      style={{
        width: "100%",
        height: "100%",
        backgroundColor: "#014849",
        display: "flex",
      }}
    >
      {businessCard.children.map((child, i) => (
        <div
          key={i}
          style={{
            display: "flex",
            position: "absolute",
            top: 0,
            right: 0,
            bottom: 0,
            left: 0,
            transform: `translateX(${child.x}px) translateY(${child.y}px)`,
          }}
        >
          {child.type === "text" ? (
            <p
              style={{
                padding: 0,
                margin: 0,
                color: child.color,
                fontSize: child.fontSize,
                fontWeight: "bold",
                // lineHeight: child.lineHeight,
                // letterSpacing: child.letterSpacing,
                display: "flex",
                flexDirection: "column",
              }}
            >
              {child.text.split("<br />").map((line, i) => (
                <span key={i}>
                  {line}
                  <br />
                </span>
              ))}
            </p>
          ) : (
            <img src={""} alt={""} />
          )}
        </div>
      ))}
    </div>
  ),
  {
    width: businessCard.width,
    height: businessCard.height,
  })


상세 페이지 - 명함 수정 가능하게 만들기

Jotai로 상태 관리

데이터 설계 및 이미지 렌더링 까지 되었으므로 이제 상세 페이지에서 사용자가 명함을 수정할 수 있게 만들어야 했습니다. 그러기 위해서는 명함 데이터를 불러와서 전역 상태 라이브러리로 저장해놓고 사용하는 편이 좋을 거 같았습니다.

그 과정에서 많은 시행 착오가 있었고 그 내용은 전편 Jotai 공식 문서를 통해 사용법에 대해 알아보자 를 통해 다뤘습니다.

그렇게 해서 텍스트 수정, 좌표 수정, 색상 수정도 가능하도록 했습니다.

react-color 대신 react-colorful 사용

참고 : https://velog.io/@jay/react18.3defaultProps

근데 react-color를 사용 중에 브라우저 콘솔에 자꾸 경고가 표시되었습니다. 3년전 업데이트 되고 더 이상 유지 보수가 안되므로 자꾸 React에서 오래된 방식을 사용한다고 warning 이 나오는 것이었습니다. 찾아보니 이게 리액트 18.3이 되면서 경고 표시가 나는거라고 하네요.

그래서 도저히 거슬려서 못쓸거 같아서 찾아보니 react-colorful이 있어서 이걸 사용하도록 했습니다.


DB 연동하기 및 이미지 공유 기능

DB 연동하기

마지막으로 Publish를 누르면 지금까지 수정했던 businessCard 정보를 DB에 저장하도록 했습니다. 그리고 페이지 이동시키고, 거기서 businessCard 이미지를 불러와서 보여주면 됩니다.

// schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma"
}

model BusinessCard {
  id      String @id @default(cuid())
  content Json

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

더 이상 json 파일을 불러와서 사용할 필요가 없어졌고 대신 DB에 있는 데이터를 불러오도록 했습니다.

import path from "path";
import { readFile } from "fs/promises";
import { NextResponse } from "next/server";
import prisma from "@/lib/db";

export async function GET(req: Request) {
  const { pathname } = new URL(req.url);
  const [, id] = pathname.match(/^\/api\/business-card\/([^\/]+)/) || [];

  if (!id) {
    return new Response("Not Found", { status: 404 });
  }

  // //Find the absolute path of the json directory
  // const jsonDirectory = path.join(process.cwd(), "src", "data");
  // //Read the json data file data.json
  // const fileContents = await readFile(jsonDirectory + "/template.json", "utf8");

  const businessCard = await prisma.businessCard.findFirst({
    where: {
      id,
    },
  });

  return NextResponse.json(businessCard);
}

이제 /api/og?id=clky9prkc0000z27pwxp2gs9q 이렇게 요청하면 id를 찾아서 보여주도록 하는 부분도 구현해주었습니다.

export async function GET(req: Request) {
  try {
    const url = new URL(req.url);
    const id = url.searchParams.get("id");

    if (!id) {
      return new Response("Not Found", { status: 404 });
    }

    const res = await fetch(`http://localhost:3001/api/business-card/${id}`);
    const data = await res.json();

    const businessCard = BusinessCardValidator.parse(data);

공유 페이지 만들기

간단합니다. 이미지를 3가지 형태로 공유할 수 있게 만들고 Copy를 클릭하면 해당 값이 복사되도록 했습니다. 이 때는 navigator.clipboard.writeText를 사용하는게 좋다고 하네요.

export default function CopyInput({
  title,
  value,
}: {
  title: string;
  value: string;
}) {
  const [buttonText, setButtonText] = useState("Copy");
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, []);

  return (
    <div className="mx-20 space-y-4">
      <h3 className="text-xl font-semibold">{title}</h3>
      <div className="flex gap-6">
        <Input value={value} readOnly />

        <Button
          onClick={() => {
            if (timeoutRef.current) clearTimeout(timeoutRef.current);

            if (value && "clipboard" in navigator) {
              navigator.clipboard.writeText(String(value));
              setButtonText("Copied!");
              timeoutRef.current = setTimeout(() => {
                setButtonText("Copy");
              }, 5000);
            }
          }}
        >
          {buttonText}
        </Button>
      </div>
    </div>
  );
}


배포

Business card maker 사이트 : https://business-card-maker.vercel.app/
깃허브 소스코드 : https://github.com/ckstn0777/business-card-maker

비록 지금은 메인 페이지에 템플릿 하나 밖에 없지만... 이렇게 만든 명함 이미지를 velog에 공유할 수 있습니다. 하..하..

Business Card

미리 캠퍼스 처럼 마우스로 이동이랑 수정도 하게 만들고 싶고 할게 많지만 일주일 기간 상 그것까지는 다 못끝냈습니다. 그래도 최소 스펙은 맞춘거 같네요.


마치면서

사이드 프로젝트를 하면서 나름 배운 점도 있고 재밌었던거 같습니다. 특히 에어팟 끼고 노래 들으면서 하니까 더 잘되더라고요. 아래는 노션을 통해 정리한 내용입니다.

다음 프로젝트는 아무래도 웹 소캣 관련한 걸 해보고 싶기도 하고, 비디오 스트리밍도 해보고 싶기도 하고... 어떤걸 해볼지 기대됩니다.


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글