이 글은 BuildingHub 시리즈의 2번째 기록입니다. (총 6개)

오늘 한 일

어느 빌딩에서 회의실을 운영하는 실무자에게, 그 사이트를 만든 업체의 피드백이 느리다는 이야기를 들었다. 무엇이 불편한지 물어 목록을 받았고, 그게 그대로 이 프로젝트의 기능 목록이 됐다.

  • 시간대별 예약이 안 돼서 회의실 하나에 하루 1건만 잡힌다. 나머지 신청은 엑셀로 따로 관리하는데 두 장부가 어긋나면 사고가 난다
  • 관리자가 예약을 고칠 수 없어서 취소하고 다시 잡아야 한다
  • 예약 현황이 한눈에 안 보인다
  • 안내 문구를 고치려면 업체에 요청해야 한다
  • 입점업체 이미지와 로고도 업체를 거쳐야 한다

원본을 그대로 베끼면 저작권 문제가 되고, 너무 다르게 만들면 개선안으로 읽히지 않는다. 정보구조만 남기고 코드와 문구는 새로 쓰기로 했다. 브랜드는 가상의 로그스톤 빌딩으로 바꿨다. 로그스톤은 이쪽 상표라 저작권·상표 리스크가 없고 포트폴리오 브랜딩도 겸한다.

구성은 Vite + React + Hono on Cloudflare Workers + D1로 잡았다. 원본은 Next.js지만 정작 예약 화면을 클라이언트 렌더링으로 처리하고 있어서, 같은 스택을 고집할 이유가 없었다. 대신 검색과 공유 카드가 죽지 않도록 Worker가 경로별 메타 태그를 HTML에 넣어 준다.

시간대별 예약

09:00부터 18:00까지를 30분 슬롯으로 쪼갰다. 핵심은 중복을 애플리케이션 검사로 막지 않은 것이다. 점유 슬롯을 별도 테이블에 한 행씩 넣고 (회의실, 날짜, 슬롯)에 복합 기본키를 걸었다. 예약 행과 슬롯을 한 트랜잭션으로 넣기 때문에, 두 사람이 같은 순간에 같은 시간대를 신청하면 뒤에 도착한 요청이 통째로 롤백된다. 동시성 처리를 코드가 아니라 스키마가 책임진다.

시각은 "09:30" 같은 문자열이 아니라 자정 기준 분 정수로 저장했다. 09:00이 540이다. 겹침 판정과 정렬이 전부 정수 비교라 파싱하다 어긋날 자리가 없다.

같은 회의실에 하루 네 건이 들어가는 것과 부분 겹침이 막히는 것을 확인했다. 엑셀을 따로 둘 이유가 사라진다.

취소 없이 수정

관리자가 시간·회의실·신청 내용을 그 자리에서 고친다. 기존 슬롯 해제와 새 슬롯 점유가 한 트랜잭션이라, 옮기려는 자리가 이미 차 있으면 이동만 실패하고 원래 예약이 그대로 남는다. 예약번호도 유지된다.

저장 → 미리보기 → 배포

문구와 사진을 관리자가 직접 고치되, 실수가 바로 라이브로 나가지 않게 세 단계를 뒀다. 저장은 초안만 바꾸고, 미리보기는 초안을 입힌 실제 화면을 관리자만 보고, 배포를 눌러야 공개 사이트에 나간다. 외부 빌드를 부르지 않아 1초 안에 반영된다.

편집 화면은 페이지마다 따로 만들지 않았다. 필드 정의 하나로 폼이 생성되게 해서 페이지를 늘려도 관리자 화면이 따라온다.

이미지

캐러셀·갤러리·회의실 사진·입주사 로고를 붙였다. 가상 건물이라 실사진이 없어서 저작권 걱정 없는 CC0 사진만 골랐고, 실존 랜드마크가 식별되는 것은 가상 건물과 맞지 않아 뺐다. 입주사 로고는 세상에 없으므로 이름에서 뽑은 이니셜을 그린다.

처음 보는 눈으로 다시 훑기

기능이 다 돌아간 뒤 전체를 다시 봤다. 지도가 없었고, 임대 페이지가 전화번호 두 개뿐이었고, 클리닉은 진료과가 일곱인데 한 번에 하나만 표 세 줄로 보였고, 열두 페이지 히어로가 전부 같은 남색 블록이었다. 폰트도 시스템 기본이었다.

지도는 실제 좌표가 없는 가상 건물이라 개념도를 직접 그렸다. 임대 페이지에는 공실 현황표와 건물 사양, 진행 절차, 문의 폼을 넣었다. 이 사이트에서 문의가 실제로 들어오는 통로라 제일 공들였다.

검색과 공유 카드

SPA라 초기 HTML이 비어 있어서, 크롤러와 메신저 미리보기 봇에는 어느 페이지를 공유해도 제목이 같게 나간다. Worker가 정적 파일을 흘려보내기 전에 HTMLRewriter로 경로에 맞는 제목·설명·이미지를 끼워 넣게 했다. 문서를 문자열로 만들지 않고 스트리밍으로 훑어 고치는 방식이라 비용이 거의 없다.

카드 이미지(1200×630)는 한글을 넣어야 해서 이미지 라이브러리 대신 HTML로 그려 브라우저로 캡처했다. 검사용으로 이미 크롬을 몰고 있으니 도구를 하나 더 들일 필요가 없었고, 줄바꿈과 자간을 CSS에 맡길 수 있다. PNG로 704KB가 나와 JPEG 71KB로 줄였다. 미리보기 봇은 파일을 늦게 받으면 카드를 비워 둔다.

폰트는 Pretendard 가변 폰트 원본이 2MB라 서브셋했다. 관리자가 문구를 직접 고치는 제품이라 쓰는 글자를 미리 셀 수 없어서, 글자 목록이 아니라 KS X 1001의 2350자 범위로 잘랐다. 2009KB가 436KB가 됐고, 가변 폰트라 이 한 벌이 굵기 전부를 담당한다.


막힌 부분

자동 검사는 다 통과인데 화면이 통째로 안 떴다

타입 검사도 API 점검 53개도 전부 통과했는데 브라우저를 열자 아무것도 렌더되지 않았다. 라우트가 바뀌면 맨 위로 올리는 컴포넌트가 원인이었다.

useEffect(() => window.scrollTo(0, 0), [pathname]);   // ❌

화살표 축약 본문은 window.scrollTo()의 반환값을 그대로 반환한다. React는 그걸 cleanup 함수로 받아 언마운트 때 호출하려다 터진다. 최상단 컴포넌트라 전 페이지가 같이 죽었다.

  • 원인: 축약 본문이 반환값을 흘려보냈고, 그 값이 함수가 아니었다
  • 해결: 블록 본문으로 바꿨다. 함수 참조를 그대로 넘기던 다른 곳도 같은 이유로 감쌌다
  • 남은 조치: 서버에서 확인할 수 없는 오류라, 설치된 크롬을 몰아 전 화면을 열고 콘솔을 읽는 검사를 만들었다

캐러셀 점을 누르면 엉뚱한 페이지로 갔다

홈 캐러셀의 점을 누르면 사진이 넘어가는 대신 식당가 페이지로 이동했다. 클릭 지점에 무엇이 있는지 재 보니 점이 아니라 홈 카드가 잡혔다.

.carousel { position: absolute; inset: 0; z-index: 1; }  /* ❌ */
  • 원인: 캐러셀에 z-index를 주면서 스태킹 컨텍스트가 생겼고, 안쪽 화살표·점의 z-index 4가 그 값에 갇혔다. 홈 카드(z-index 2)가 이겨 점 네 개를 전부 덮었다
  • 해결: 캐러셀에서 z-index를 걷어 스태킹 컨텍스트를 만들지 않게 했다. 슬라이드는 z-index 없이도 DOM 순서로 맨 아래에 깔린다
  • 남은 조치: 클릭이 실제로 대상에 닿는지 elementFromPoint로 검사한다

홈 카드가 세로로 한 줄씩 쌓였다

내용이 짧은 페이지에서 푸터가 화면 중간에 뜨는 걸 고치려고 최상위를 flex 컬럼으로 바꿨다. 그랬더니 홈에서 가로 네 칸으로 놓이던 카드가 한 줄에 하나씩 쌓였다.

  • 원인: flex 자식에 margin: 0 auto가 있으면 가로로 콘텐츠 폭까지 줄어든다. 폭 1180px이어야 할 래퍼가 496px이 됐고, 그리드가 1열로 접혔다
  • 해결: flex 직계 자식에 width: 100%를 고정했다
  • 남은 조치: 스크린샷만 보면 의도한 디자인으로 넘어갈 수 있어서, 래퍼 폭과 그리드 열 수를 검사 항목으로 넣었다

/admin 아래 화면이 하나도 안 떴다

path="/admin/*" 안에 다시 <Routes>를 두고 그 안쪽을 절대경로로 적었다.

  • 원인: 중첩된 <Routes> 안의 경로는 부모가 매칭한 지점 기준의 상대경로다. 앞에 슬래시를 붙이면 /admin이 중복돼 아무것도 안 맞는다
  • 해결: 하위 경로에서 앞 슬래시를 뺐다. 기본 화면은 index로 잡았다

sitemap.xml 자리에 SPA 껍데기가 나갔다

  • 원인: 어느 경로에서 Worker가 먼저 도는지는 run_worker_first 목록이 정한다. 여기 빠진 경로는 Worker를 안 거치고 정적 파일이 그대로 나간다
  • 해결: 목록에 넣었다. 메타 주입이 필요한 경로를 늘릴 때마다 이 목록도 같이 늘려야 한다

폰트 서브셋이 하나도 안 줄 뻔했다

KS X 1001 2350자를 뽑으려고 파이썬 코덱으로 걸렀는데, euc_kr·cp949로 시험하면 11172자가 전부 통과한다. 이 코덱들은 확장 완성형까지 받아 주기 때문이다.

  • 해결: iso2022_kr로 거르면 2350자와 정확히 맞는다. 이름이 비슷하다고 같은 범위가 아니다
  • 확인: 자른 뒤 프로젝트 전체 글자가 범위 안에 있는지 대조했다. 밖으로 나간 건 정규식 [가-힣]의 경계 문자 하나뿐이고 실제로 표시되는 글자는 아니었다

쓸 만한 사진을 구할 데가 없었다

가상 건물이라 사진이 존재하지 않는다. 무료 이미지 사이트는 봇 차단에 막히거나 API 키를 요구했다.

  • 해결: 공개 API로 열려 있고 출처 표기 의무도 없는 CC0 사진을 받아 후보 62장을 훑고 21장을 골랐다
  • 남은 한계: 클리닉 쪽은 후보 자체가 빈약해서 쓸 만한 게 한 장뿐이었다

검사가 죽어도 통과로 읽힌다

브라우저 검사 128건이 통과했다고 기록에 남았는데, 그 시점에 검사는 죽어 있었다.

  • 원인: 실패한 줄만 걸러 출력하는 구조라 “실패가 없음”과 “아무것도 안 돌았음”이 같은 화면이 된다. 스크립트가 중간에 죽으면 요약 줄 자체가 안 나온다
  • 해결: 통과 건수를 실제 출력에서 읽어 적는다. 요약 줄이 없으면 통과가 아니라 미실행으로 본다

다음에 할 일

  • 관리자 화면 다듬기. 오늘은 공개 사이트에 시간을 다 썼다
    • 대시보드에서 승인 대기 건을 그 자리에서 처리하는 동선
    • 승인·반려 사유를 브라우저 기본 팝업에서 떼기
    • 예약 검색·상태 필터를 화면에 붙이기. API는 이미 있다
    • 항목 많은 콘텐츠의 편집 화면 스크롤 줄이기
    • 모바일에서 관리자가 실제로 쓸 만한지 점검
  • 회의실 좌석 배치도
  • 업로드 이미지 자동 리사이즈·WebP 변환
  • 예약 승인·반려 안내 메일 발송
  • 사업자등록증 첨부
  • 관리자 조작 이력

Series: BuildingHub


Suggested Posts