블로그 개발기 8 (기능 편)

하루에 여섯 개를 붙였다 - 검색·시리즈·RSS·공유 카드·복사 버튼·이미지 뷰어, 그리고 같은 걸 두 번 만든 이야기
calendar icon

2026년 9월 16일

조회 6

tag icon

개발기 7을 올리고 나서 목록을 하나 적었다. 블로그에 없는 것들이었다.

검색이 없었다. 글이 서른 편인데 태그 여덟 개로 거르는 게 전부였다. 시리즈가 없었다. "블로그 개발기 1~7"은 슬러그 숫자로만 이어져 있었다. 코드블록에 복사 버튼이 없었다. RSS도, 글별 공유 카드도 없었다.

"기술 블로그라면서 검색이 없다고?"

하루에 다 붙이기로 했다. 커밋은 15개, 76개 파일에 2,233줄이 들어가고 329줄이 빠졌다.

이 글은 그 하루의 기록이다. 기능 여섯 개보다, 같은 걸 두 번 만들었다가 합친 얘기가 더 길다.


코드블록 복사 버튼

가장 작은 것부터. 코드블록 오른쪽 위에 언어 이름과 복사 버튼을 붙였다.

파이프라인을 건드리지 않았다

본문은 서버에서 마크다운을 HTML 문자열로 렌더해 저장하고, 화면에서는 그 문자열을 그대로 꽂는다. 버튼을 넣을 자리는 둘이다. 렌더할 때 HTML에 심거나, 브라우저에서 나중에 붙이거나.

브라우저를 골랐다. 이유는 둘이다. 버튼은 브라우저에서만 뜻이 있다. 그리고 렌더 파이프라인엔 12가지 회귀 검사가 물려 있어서, 출력이 바뀌면 그 기준을 전부 다시 맞춰야 한다.

body.querySelectorAll("pre").forEach((pre) => {
    if (pre.querySelector(":scope > .code-tools")) return; // StrictMode 는 두 번 돈다
    const language = /\blanguage-([\w-]+)/.exec(code.className)?.[1];
    // …createElement 로 라벨과 버튼을 만들어 append
});

클릭은 버튼마다 달지 않고 본문 하나에 위임했다. 본문 HTML이 바뀌면 React가 통째로 갈아끼우니 버튼도 같이 사라지고, effect가 다시 붙인다. 되돌릴 게 없다.

하나 걸리는 건 클립보드 API가 HTTPS에서만 된다는 것. 프로덕션과 localhost는 되는데, 같은 와이파이의 192.168.x.x로 열면 안 된다. 그땐 토스트로 실패를 알린다.


시리즈

접두사로 추론하지 않았다

blog-development-*, docker-basics-*, salesforce-ci-cd-*. 슬러그만 봐도 시리즈다. 그러니 접두사로 묶으면 스키마를 안 건드려도 된다.

안 했다. docker-gcp-01-vm-creation처럼 규칙이 하나만 달라도 추론은 조용히 틀린다. 그리고 슬러그는 URL이라 나중에 못 바꾸는데, 시리즈 순서는 바꿀 일이 생긴다. 개요 글을 뒤늦게 써서 1번에 끼우는 것 같은.

그래서 컬럼 둘을 추가했다.

series      String? @db.VarChar(60)
seriesOrder Int?    @map("series_order")

@@index([series, seriesOrder])

기존 18편은 마이그레이션 안에서 슬러그를 하나하나 명시해 채웠다. 추론이 아니라 목록이다.

순서는 자동으로 채워진다

처음엔 에디터에 시리즈 이름과 순서 숫자를 손으로 적게 했다. 그런데 순서를 비워도 발행일순으로 정렬되게 해 뒀으니, 사실 숫자를 적을 일이 거의 없다.

그래서 시리즈를 고르면 그 시리즈의 다음 번호가 따라 들어오게 했다. 가장 큰 순서 + 1. 개발기 8을 쓰면서 "블로그 개발기"를 고르면 8이 바로 채워진다. 손으로 고치는 건 그대로 된다.

시리즈 이름 입력은 <datalist>로 시작했다가 <select>로 바꿨다. datalist는 브라우저마다 생김새와 여는 조건이 달라서, 목록이 있는지조차 안 보였다. 기존 시리즈는 고르고, "+ 새 시리즈"를 고를 때만 이름 칸이 열린다.


같은 것이 둘이면 하나로

이 하루에서 제일 오래 걸린 건 새 기능이 아니라 이거였다.

드롭다운

포트폴리오 갤러리의 화면 종류 선택이 네이티브 <select>였다. 펼친 목록이 사이트와 따로 놀아서 고치기로 했다.

처음에 한 짓은 버튼을 알약 모양으로 바꾼 것이었다. 문제는 버튼이 아니라 펼쳐진 목록이었는데. 네이티브 select의 펼친 목록은 브라우저가 그려서 CSS가 닿지 않는다. 버튼을 아무리 꾸며도 목록은 그대로다.

"고쳐달라는 건 목록인데 버튼을 고쳤다."

버튼을 원래대로 되돌리고, 목록을 직접 그렸다. 버튼 + role="listbox". 화살표·Home·End·Enter·Escape, 바깥 클릭.

그러고 나니 같은 게 세 곳에 있었다. 포트폴리오 화면 종류, 에디터의 시리즈 선택, 방문자 차트의 연도. components/ui/Dropdown 하나로 모으고, 자리마다 크기(sm/md)와 정렬만 준다.

이미지 뷰어

본문 이미지를 누르면 크게 보여주는 걸 붙였다. 검정 막 위에 이미지와 × 버튼. 40분짜리였다.

그런데 포트폴리오에 이미 전체 화면 뷰어가 있었다. 테마 배경, 툴바(설명·더 크게 보기·이미지 파일·닫기), 이전/다음. 같은 사이트에서 "이미지 확대"가 두 가지 모양으로 뜨게 된 것이다.

이유를 물으면 없다. 포트폴리오 뷰어가 갤러리(썸네일 줄·그룹)에 묶여 있어서 그대로 가져오기 번거로웠고, 본문용은 "커지고 닫히면 된다"고 봤다. 그게 전부다.

포트폴리오 쪽 디자인을 components/ui/ImageViewer로 뽑고 두 곳을 다 그걸로 바꿨다.

줄
지운 것 — 포트폴리오 dialog 마크업·CSS, 본문 오버레이·스타일−324
새로 쓴 것 — ImageViewer+312
두 호출부+53

합쳐서 +41이고, 그 41줄은 새 기능이다. 본문 안 이미지가 여럿이면 뷰어 안에서 이전·다음으로 넘긴다.

비슷한 게 두 개면 하나로. 이 하루에 두 번 배웠다.


검색

한국어에는 tsvector가 안 맞는다

Postgres 전문 검색은 tsvector다. 영어라면 "running"으로 "run"을 찾는다. 한국어엔 형태소 분석이 없어서 "조회수"로 "조회수가"를 못 찾는다. 사전이 simple이면 공백 단위 토큰이라 "조회수가"는 통째로 한 토큰이다.

pg_trgm으로 갔다. ILIKE '%조회수%'는 부분 문자열이라 "조회수가"도 잡고, GIN 인덱스가 그 ILIKE를 받는다.

CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions;

CREATE INDEX "posts_search_trgm_idx" ON "posts"
    USING gin ((title || ' ' || coalesce(description, '') || ' ' || content_md) extensions.gin_trgm_ops);

솔직히 글이 서른 편이라 인덱스 없이도 즉시 끝난다. 인덱스는 나중에 글이 늘었을 때 조용히 느려지는 걸 막는 보험이다.

Prisma.sql 조각이 JSON이 됐다

낱말이 여럿이면 ILIKE 조건을 낱말 수만큼 AND로 이어야 한다. Prisma엔 그걸 위한 Prisma.sql과 Prisma.join이 있다. 조각을 만들어 $queryRaw 템플릿에 끼워 넣는다.

죽었다.

invalid input syntax for type boolean:
"{"values":["%on%","%conflict%"],"strings":["(title || ' ' || …"]}"

조각이 SQL로 풀리지 않고 JSON 문자열로 직렬화돼 파라미터로 들어갔다. Prisma 7의 드라이버 어댑터 경로에서 겹친 조각을 못 알아보는 것 같았다. 더 파지 않고 우회했다. 문장은 손으로 조립하고, 사용자 입력은 전부 $1, $2 자리로만 넣는다.

const where = params.map((_, i) => `${haystack} ILIKE $${i + 1}`).join(" AND ");
await prisma.$queryRawUnsafe(`SELECT … WHERE status = 'PUBLISHED' AND ${where} …`, ...params);

Unsafe라는 이름이 걸리지만, 문장 텍스트에 사용자 입력이 한 글자도 안 들어간다. 위험한 건 이름이 아니라 입력이 어디로 가느냐다.

스니펫이 "Notion"을 잡았다

결과엔 본문에서 맞은 자리 앞뒤를 잘라 보여준다. "on conflict"로 검색했더니 스니펫이 글 첫 문단을 보여줬다. ON CONFLICT는 한참 뒤에 있는데.

낱말 중 먼저 나오는 것을 기준으로 자르고 있었다. "on"이 "Notion"에서 먼저 나온다. 검색어 전체가 통째로 나오는 자리를 먼저 찾고, 없으면 가장 긴 낱말을 쓰게 바꿨다.

정렬은 최신순으로 고정했다

처음엔 제목에 맞으면 3점, 요약에 맞으면 2점을 줘서 앞으로 당겼다. 그럴듯했는데, 써 보니 같은 검색어인데 어떤 글은 위로 튀고 어떤 글은 아래로 가서 무작위처럼 읽혔다. 점수를 빼고 홈 목록과 같은 최신순으로 뒀다. 예측되는 게 낫다.

헤더 안에서 그리면 안 됐다

검색 대화상자를 헤더의 버튼 옆에 그렸더니 결과 링크가 전부 3rem 정사각형으로 눌렸다. 헤더에 a { width: 3rem; height: 3rem } 규칙이 있었다. 아이콘 링크용인데 대화상자 안 링크까지 덮은 것이다. createPortal로 body에 내보냈다.

그리고 마우스를 결과 목록 위아래 끝에 대면 내용이 조금씩 밀렸다. 항목에 닿을 때마다 scrollIntoView로 그 항목을 보이게 끌어당기고 있었다. 키보드로 옮길 때만 따라가게 바꿨다. 마우스 밑에서 내용이 미끄러지면 안 된다.


공유 카드

링크를 카카오톡이나 슬랙에 붙이면 나오는 그 카드. 지금까진 썸네일이 그대로 나갔다. 글마다 제목·요약·썸네일을 담은 1200×630 카드를 굽기로 했다. Next의 opengraph-image.tsx 규약이다.

satori는 woff2를 못 읽는다

카드에 한글이 들어간다. 사이트 글꼴(NanumSquareNeo)은 woff2뿐인데, 카드를 그리는 satori는 woff2를 못 읽는다. TTF나 OTF여야 한다.

woff2를 TTF로 풀었다. 브라우저에는 안 나가고 서버가 카드를 그릴 때만 읽는다. 2.2MB짜리 둘(Bold·Regular)이 저장소에 들어갔다. 서브셋을 뜨면 줄겠지만 그건 다음에.

metadata의 images가 파일 규약을 밀어냈다

파일을 만들었는데 og:image가 여전히 썸네일이었다. 페이지의 generateMetadata가 openGraph.images에 썸네일을 넣고 있었고, 그게 파일 규약보다 셌다. 문서엔 파일 규약이 우선이라고 돼 있는데, 페이지가 images를 명시하면 그쪽이 이긴다. 그 줄을 지우니 카드가 나갔다.

satori가 요구하는 것도 하나 있다. 자식이 둘 이상인 div는 display: flex여야 한다. #{tag}처럼 쓰면 #과 {tag}가 텍스트 노드 둘이라 걸린다. 템플릿 문자열 하나로 합쳤다.


RSS

/feed.xml. 캐시된 목록을 받아 최근 30편을 RSS 2.0 문자열로 조립한다. sitemap.ts와 같은 방식이라 30분이 안 걸렸다. <head>에 자동 발견 링크를 두고, feed.xml을 예약 슬러그에 넣었다.

본문은 안 넣는다. 요약과 링크만. 본문까지 주면 피드가 글 30편의 HTML을 통째로 들고 다니고, 조회수도 안 잡힌다.


결과

전후
검색태그 8개로 거르기제목·요약·본문, Ctrl+K
시리즈슬러그 숫자뿐컬럼, 본문 아래 목록과 이전/다음, 순서 자동
코드블록드래그로 복사언어 라벨 + 복사 버튼
공유 카드썸네일 그대로제목·요약·썸네일 카드
이미지 확대포트폴리오만본문도, 같은 뷰어
드롭다운세 곳이 제각각하나
RSS없음/feed.xml

README의 "개선 예정"에서 검색이 빠졌다. 남은 건 글별 조회 추이 하나다.

느낀점

  • 비슷한 게 두 개면 하나로. 드롭다운과 이미지 뷰어, 둘 다 새로 만든 뒤에야 이미 있던 걸 봤다. 합치는 데 새로 만드는 것만큼 걸렸다.
  • 문제가 어디인지 먼저 본다. 드롭다운에서 고쳐달라는 건 목록이었는데 버튼을 고쳤다. 화면에 보이는 것과 문제가 있는 곳은 다를 수 있다.
  • 한국어 검색은 부분 문자열이 답이다. 형태소 분석 없이 tsvector를 쓰면 조사 하나에 못 찾는다. pg_trgm은 단순하고 맞는다.
  • "우선한다"는 문서를 믿지 말고 찍어본다. 파일 규약이 우선이라는데, 페이지가 images를 명시하면 안 그랬다.
  • 정렬은 그럴듯한 것보다 예측되는 게 낫다. 제목 우선 점수는 논리적이었지만 무작위처럼 보였다.

기능 여섯 개를 붙인 날인데 기억에 남는 건 두 번의 "이미 있었네"다. 다음엔 만들기 전에 찾아보는 걸 먼저 하겠다. 읽어주셔서 감사하다.

좋아요 —
hamburger icon

무링의 개발 블로그

FDE를 향하는 개발자


댓글

GitHub 계정으로 로그인하면 댓글을 남길 수 있습니다.

로그인

연관된 글