Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Web Development/EN

Marketing Site for Tax Agency and Management SaaS

세무대행과 경영관리 SaaS를 소개하는 마케팅 사이트

일곱 기능과 세 서비스를 소개하는 세무대행 마케팅 사이트

설명할 창구가 없던 일곱 기능

이 사이트는 대행 서비스와 고객사가 쓰는 경영관리 SaaS를 한자리에서 소개해야 했습니다. 기능은 업무티켓, 근태관리, 조직관리, 전자계약, 자금관리, 채권채무, 인사이트까지 일곱 가지였고 서비스는 셋이었어요. 요구사항은 셋이었습니다. 도입사례와 고객 인터뷰 영상을 어드민에서 직접 올리고 내릴 수 있는 CMS, 회사 도메인 안에서 굴러가는 블로그, 그리고 상담과 무료체험 신청을 폼으로 받아 본 서비스 API로 넘기는 것이었습니다.

시스템 경계와 외부 의존
시스템 경계와 외부 의존

화면은 Next.js, 라우팅은 NestJS

NestJS 안에 Next.js를 렌더 엔진으로 넣은 단일 프로세스

어드민 CRUD와 파일 업로드, JWT 인증까지 필요한데 프론트와 백엔드를 나누면 저장소가 둘, 배포가 둘이 됩니다. nest-next의 RenderModule을 쓰면 컨트롤러가 @Render('blog')로 pages/views 아래 페이지를 직접 지정할 수 있어서, 라우팅과 데이터 조회를 한곳에 모을 수 있었어요. 두 달짜리 일정이었고 프론트 인력 위주로 붙는 프로젝트라 별도 API 서버를 세우고 관리할 여력이 없었습니다.

TypeORM + MySQL(RDS), synchronize는 APP_ENV=development일 때만

블로그 타입, 도입사례 필드가 기획과 함께 계속 늘어나던 시기여서 로컬에서는 엔티티만 고치면 테이블이 따라오게 두는 편이 빨랐습니다. 운영에서 켜 두면 컬럼이 날아갈 수 있어 환경으로 잘랐어요. 마이그레이션 도구를 붙이고 팀에 흐름을 익히게 할 시간이 없었습니다.

업로드는 리사이즈를 거쳐 S3에 public-read로

블로그 본문 이미지를 원본 그대로 두면 한 장에 수 MB가 됩니다. @dicolabs-kr/nest-module.media-converter로 폭 1500px로 줄인 버퍼를 wecake-homepage-files 버킷에 올리고 퍼블릭 URL만 DB에 저장했습니다. CloudFront를 붙일 예산과 시간이 없어 S3 URL을 그대로 노출했습니다.

Elastic Beanstalk + GitHub Actions, .ebextensions로 yarn 강제와 4GB swap

EB 기본 훅은 npm으로 설치를 시도하는데 이 저장소는 yarn.lock 기준이었습니다. 49_yarn.sh를 심어 yarn을 쓰게 하고 기본 50npm.sh는 빈 파일로 덮었어요. 인스턴스에서 의존성을 설치하다 메모리가 터져 swap 4GB도 함께 잡았습니다. 작은 인스턴스 한 대로 dev 환경을 굴려야 했고, 배포 zip에서 node_modules를 빼는 대신 설치를 서버가 떠안았습니다.

Chakra UI와 createBreakpoints

디자인 시안이 845px에서 갈리는 2단 구성이었습니다. md: '845px'로 브레이크포인트를 시안에 맞추고, 모든 여백을 { base, md } 객체로 쓰면 컴포넌트 하나로 두 레이아웃을 감당할 수 있었어요. 디자인 시스템이 따로 없었고, 시안이 넘어오는 대로 컴포넌트를 만들어야 했습니다.

배포와 인프라 구성
배포와 인프라 구성

컨트롤러가 지정하는 렌더 페이지

요청이 들어오면 Nest 컨트롤러가 먼저 받습니다. AppController.index()가 Blog에서 최신 6건, Customer에서 7건을 읽고 @Render('home')을 붙여 돌려주면, nest-next가 그 반환값을 Next의 ctx.query에 실어 넘겨요. 페이지는 getInitialProps나 getServerSideProps에서 ctx.query.blogs를 꺼내 props로 씁니다. 그래서 이 사이트에는 데이터 페칭용 API 라우트가 거의 없습니다.

예외는 둘뿐이에요. 블로그 더보기(GET /api/blog?page=2&slug=insight)와 신청 모달 옆에서 도는 배너(GET /api/advertisements)만 브라우저가 직접 부릅니다. 바깥으로 나가는 요청도 하나입니다. 상담·무료체험 폼이 POST https://api.wecake.io/consultings로 리드를 본 서비스에 넘기죠. 이미지와 영상은 /admin/image나 각 폼의 multipart 핸들러를 거쳐 리사이즈된 뒤 S3에 올라가고, DB에는 URL 문자열만 남습니다. 어드민은 같은 프로세스의 /admin/* 경로이고, JwtAuthGuard가 httpOnly 쿠키의 토큰을 읽어 막습니다.

핵심 데이터 모델
핵심 데이터 모델

한 글자씩 폰트를 갈아 끼우는 unicode-range

한글은 Noto Sans KR로, 영문과 숫자는 Poppins로 보이고 싶을 때 가장 먼저 떠오르는 방법은 font-family: Poppins, 'Noto Sans KR' 같은 폴백 목록입니다. 아니면 컴포넌트마다 영문 조각을 <span>으로 감싸고 폰트를 따로 지정하죠. 앞은 숫자와 괄호, 원화 기호가 어느 서체로 떨어질지 통제할 수 없고, 뒤는 어드민에서 들어오는 본문 HTML에는 아예 쓸 수 없습니다.

@font-face를 여덟 벌 선언하면서 font-family를 전부 'Noto Sans KR' 하나로 통일했습니다. 대신 한글 네 벌에는 unicode-range: U+AC00-D7AF를, 실제 파일이 Poppins인 라틴 네 벌에는 알파벳·숫자·기호 범위와 원화 기호 U+20A9를 지정했어요. Chakra 테마에는 heading과 body 모두 'Noto Sans KR' 하나만 넘깁니다.

브라우저는 글리프 단위로 unicode-range에 맞는 face를 고르고, 그 범위의 문자가 실제로 나타날 때만 파일을 내려받습니다. 그래서 마크업에 손대지 않아도 되고, 어드민에서 들어온 본문이든 한글과 영문이 섞인 버튼 문구든 알아서 갈립니다. 300/400/500/700 네 굵기를 양쪽에 다 준비해 뒀기 때문에 fontWeight만 바꿔도 두 서체가 함께 따라옵니다.

히어로 일러스트를 이미지가 아니라 링크 덩어리로 만들기

전달받은 아이소메트릭 일러스트를 PNG로 뽑아 깔고, 건물마다 position: absolute인 투명 <div>를 좌표로 얹는 방법입니다. 브레이크포인트마다 좌표를 다시 재야 하고, 호버 팝업은 이미지 밖 레이어에 따로 그려야 하며, 링크가 HTML에 남지 않아 크롤러가 따라가지 못합니다.

일러스트를 통째로 인라인 SVG(JSX)로 넣었습니다. source/home/hero/illustration/index.tsx 한 파일이 9,000줄을 넘어요. 대신 기능 일곱 개를 SVG 안의 <a href="/feature/bond-and-debt">로 감싸고, 각 링크 안에 <g className="popup">을 넣어 호버 시 opacity와 translateY로 떠오르게 했습니다. 채움색은 path마다 --fill / --hover-fill CSS 변수를 인라인으로 심어 두고 a:hover > path:first-child { color: var(--hover-fill) } 규칙 하나로 처리했고요. 가운데 TV는 g.play-button에 onClick을 걸어 브랜드 영상 모달을 엽니다.

좌표가 viewBox="0 0 1532 702" 안에서 정의되니, 컨테이너 폭이 얼마가 되든 히트 영역과 팝업이 그림과 정확히 같은 비율로 따라갑니다. 브레이크포인트를 늘려도 다시 잴 것이 없어요. 링크가 진짜 <a href>라서 서버가 그린 HTML에 그대로 남고, 새 탭으로 열기도 됩니다. 색을 변수로 빼 둔 덕에 수십 개 path마다 호버 규칙을 쓰지 않아도 됐고요. 대신 번들이 커지는 값은 치렀는데, 이 그림이 홈의 첫 화면 전부였기에 받아들일 만한 거래였습니다.

유저가 할 수 있는 일

관리자가 로그인해 콘텐츠 화면으로 들어간다
관리자가 로그인해 콘텐츠 화면으로 들어간다

홈 일러스트에서 궁금한 기능으로 바로 들어간다
홈 일러스트에서 궁금한 기능으로 바로 들어간다

히어로 안의 TV를 눌러 브랜드 영상을 본다
히어로 안의 TV를 눌러 브랜드 영상을 본다

무료체험 신청서를 두 단계로 채워 보낸다
무료체험 신청서를 두 단계로 채워 보낸다

어느 페이지에서든 하단 배너로 상담을 신청한다
어느 페이지에서든 하단 배너로 상담을 신청한다

블로그를 굴려 읽고 카테고리로 좁힌다
블로그를 굴려 읽고 카테고리로 좁힌다

블로그 글 하나를 열어 끝까지 읽는다
블로그 글 하나를 열어 끝까지 읽는다

도입사례를 업종으로 걸러 하나를 읽는다
도입사례를 업종으로 걸러 하나를 읽는다

서비스 소개를 보고 요금표를 비교한다
서비스 소개를 보고 요금표를 비교한다

팀 소개를 보고 약관 페이지까지 확인한다
팀 소개를 보고 약관 페이지까지 확인한다

관리자가 블로그 글을 쓰고 대표 이미지를 올린다
관리자가 블로그 글을 쓰고 대표 이미지를 올린다

관리자가 도입사례를 등록하고 메인 노출을 정한다
관리자가 도입사례를 등록하고 메인 노출을 정한다

관리자가 홈에 걸 인터뷰 영상을 등록한다
관리자가 홈에 걸 인터뷰 영상을 등록한다

관리자가 신청 모달에 뜰 배너를 갈아 끼운다
관리자가 신청 모달에 뜰 배너를 갈아 끼운다

홈에서 고객 인터뷰 영상을 본다
홈에서 고객 인터뷰 영상을 본다

1 / 1

전역 !important 한 줄의 대가

모든 것을 아름답게 마무리하지는 못했습니다. 가장 마음에 걸리는 건 자간이에요. 전역에 * { letter-spacing: -1.25px !important; }를 걸어 두고, 마운트 후 setLatinLetterSpacing()이 라틴 구간만 <span style="letter-spacing: 0">으로 감싸 되돌리려 합니다. 그런데 CSS 캐스케이드에서 !important 선언은 인라인 일반 선언을 이깁니다. 이 보정은 사실상 먹히지 않았어요. 지금이라면 전역 !important를 걷어내고 라틴용 클래스에 평범한 규칙을 주겠습니다.

도입사례 카테고리도 그렇습니다. DB에는 정수로 넣고 그 뜻을 세 곳에 따로 적었습니다. 엔티티 주석, 컨트롤러의 categoryNumToEnum, 프론트의 CASE_CATEGORY. 값은 다행히 맞아떨어지지만 이름이 뜻과 어긋나 있어요. MEDICAL이 요식업·프랜차이즈고 IT가 물류·유통입니다. enum 컬럼이나 카테고리 테이블로 갔어야 했습니다.

그 밖에 관리자 비밀번호를 user.password === password로 평문 비교하고, 마이그레이션 없이 synchronize를 개발에서만 켠 채 운영 스키마는 손으로 맞췄습니다. pages/views/admin/advertisement/detail.tsx는 customer/detail.tsx를 복사해 둔 채 어느 라우트에도 연결되지 않고 남아 있고요. 관리자가 한 명이고 수명이 짧은 마케팅 사이트라는 판단으로 넘긴 것들이지만, 셋 다 한 줄에서 두 줄이면 될 일이었습니다.

Read next

영상 강의와 스크래치 실습을 모은 온라인 수업 사이트

Scratch Tutoring — 2021