포스트

눈대중으로 틀린 프론트엔드 세 가지

눈대중으로 틀린 프론트엔드 세 가지

IR 자료 생성 서비스의 프론트엔드를 Claude Code로 하루 종일 만들었다. 화면을 새로 짜고 디자인 토큰을 심고 결과 페이지를 분리했다. 코드는 세션이 썼고, 무엇을 만들지와 어디가 이상한지는 내가 봤다.

정작 오늘 제일 많이 배운 건 눈으로 봐서 괜찮아 보였는데 틀린 것들이었다. 세 번 다 계산이나 검사로 잡혔다.

항목결과
오늘 커밋9개
프론트엔드 변경18개 파일 (+1,364 / −194)
화면로그인 · 입력 · 만드는 중 · 결과 4단으로 분리
잡은 것색 대비 위반, 브라우저에 노출된 비밀 키, 반응형 분기점

1. “적당히 연하게”가 접근성 위반이었다

디자인 토큰을 만들 때 잉크색을 세 단계로 잡게 했다. 기본, 중간, 연한 것. 연한 색은 라벨이나 캡션용이라 oklch(0.68 0.01 265) 정도로 “적당히 연하게” 정해져 나왔다.

대비를 계산해보니 흰 배경에서 2.88 : 1이었다. WCAG AA 본문 기준은 4.5 : 1이다.

1
2
3
--color-ink        0.24  →  15.76 : 1   통과
--color-ink-muted  0.52  →   5.28 : 1   통과
--color-ink-faint  0.68  →   2.88 : 1   미달

장식이면 예외를 볼 수도 있는데, 이 색은 파일 이름·처리 단계·글자수처럼 읽어야 하는 정보에 쓰고 있었다. 예외가 아니다.

명도를 내려 다시 재게 했다. 0.55에서 4.65 : 1로 겨우 통과했고, 이 값이 하한이라는 걸 토큰 주석에 남겼다.

1
--color-ink-faint: oklch(0.55 0.011 265);   /* 4.65:1 — AA 하한. 더 밝히면 위반 */

중간 단계도 함께 0.44로 내려야 했다. 연한 색만 진하게 하면 두 단계가 붙어버려서 계층이 사라진다. 대비를 고치는 일이 팔레트 전체를 다시 보는 일이 된 셈이다.

색은 눈으로 승인하면 안 된다. oklch 명도는 사람이 느끼는 밝기와 꽤 맞게 설계된 색 공간인데도, “이 정도면 읽히겠지”라는 감각이 두 배 가까이 빗나갔다. 사람이든 AI든 감각으로 정한 값은 재봐야 한다.

2. NEXT_PUBLIC_ 하나가 RLS를 통째로 무력화했다

오늘 제일 아찔했던 부분이다.

익명 키로 API를 찔러봤는데 다른 사람 자료가 전부 조회됐다. 저장된 PPTX 파일도 그대로 다운로드됐다(HTTP 200, 247KB). 세션은 곧바로 “RLS 정책이 안 걸렸다”고 판단하고 정책을 다시 세우는 SQL을 만들었다.

오진이었다. 실제 정책 상태를 조회해보니 이랬다.

1
2
3
4
decks 테이블 RLS       활성
storage.objects RLS    활성
정책                   6개 전부 존재
버킷                   비공개

잠금은 전부 채워져 있었다. 진짜 원인은 환경변수였다.

1
NEXT_PUBLIC_SUPABASE_ANON_KEY = sb_secret_...

공개용 키 자리에 비밀 키가 들어가 있었다. Supabase의 secret 키는 service role급이라 설계상 RLS를 우회한다. 게다가 NEXT_PUBLIC_ 접두사가 붙은 값은 Next.js가 빌드할 때 문자열로 치환해 클라이언트 번들에 박는다. 브라우저에서 써야 하는 값이니 의도된 동작이고, 문제는 그 자리에 비밀 키를 넣은 것이다.

1
2
3
4
5
.env.local                 git 은 .gitignore 로 막았다 (커밋 이력 0건)
    ↓  NEXT_PUBLIC_ → 빌드 시 인라인
.next/static/*.js          15개 파일에 박힘
    ↓  dev 서버가 전송
브라우저                    실제 노출 지점

git 쪽은 깨끗했다. 커밋 이력 전체를 훑어도 .env.local은 한 번도 올라간 적이 없었다. 하지만 그게 안전을 뜻하지 않았다. 노출 경로가 저장소가 아니라 빌드 산출물이었다.

두 키가 육안으로 구별되지 않는 것도 원인이었다.

키길이성격
sb_publishable_...46자공개용. 브라우저에 나가도 됨
sb_secret_...41자비밀. RLS 우회

옛 JWT 키(eyJ..., 200자+)는 길이만 봐도 구별됐는데, 새 형식은 접두사를 읽어야 안다.

키를 공개용으로 바꾸고 다시 쟀다.

1
2
3
decks 조회        2행 노출  →  []
파일 다운로드     HTTP 200  →  HTTP 400
Storage 목록      폴더 노출  →  []

그리고 눈으로 보지 말고 검사하도록 스크립트를 만들게 했다. 값은 출력하지 않고 종류만 판정한다.

1
npm run check:env

노출된 키는 내가 대시보드에서 직접 삭제했다. 그 권한은 세션에 주지 않았고, 실제로 API로 지우려다 403이 떴다. .next도 지우고 다시 빌드했다. 이미 브라우저로 나간 것은 되돌릴 수 없으니 키 폐기가 유일한 마무리였다.

증상이 같다고 원인이 같은 건 아니다. “데이터가 다 보인다”는 정책 누락과 키 오용이 완전히 같은 모습으로 나타난다. 정책을 고치기 전에 실제 상태를 조회해봐야 했다.

3. 카드가 2열일 거라고 생각했는데 1열이었다

입력 방식을 탭에서 카드 두 장으로 바꾸게 했다. 탭은 어느 쪽이 활성인지 흐릿하고, 무엇을 넣는 자리인지 설명할 공간이 없다. 카드로 바꾸면 용도와 상한을 카드 안에 적을 수 있다.

1
2
3
[ 텍스트 붙여넣기 ]        [ PDF 올리기 ]
  연혁, 실적, 사업 모델을     회사소개서나 IR 자료를
  그대로. 최대 30,000자.     그대로. 최대 10MB.

sm:grid-cols-2로 잡혀 나왔으니 데스크톱에서 나란히 놓일 줄 알았다. 그런데 화면에서는 위아래로 쌓였다.

원인은 폼을 감싼 컨테이너였다. 디자인 문서에 “읽는 문단은 62ch”라고 정해두고 그걸 폼에도 그대로 썼는데, 62ch는 약 600px이라 sm 분기점(640px)에 못 미쳤다.

1
2
max-w-(--container-read)   62ch ≈ 600px   →  sm 미달, 1열
max-w-[760px]                              →  2열

읽는 폭과 조작하는 폭은 다르다. 긴 문단은 좁아야 읽기 편하지만, 선택지를 나란히 비교하는 영역은 그 규칙을 따를 이유가 없다. 설명 문단만 62ch로 묶고 폼은 760px로 넓혔다.

브라우저에서 실제 폭을 재보고 나서야 알았다.

1
2
619px  →  1열 (패널이 좁아서)
1280px →  2열, 하단 안내 3열

4. 색으로 구분하지 않는 UI

이 서비스는 “자료에서 확인된 사실”과 “분석이 붙은 해석”과 “가정이 들어간 추정”을 구분해 보여주는 게 핵심이다. 처음 안은 등급마다 색을 다르게 주는 것이었는데, 그러면 색맹 사용자가 구분할 수 없다. 접근성 요건에도 “상태를 색상만으로 구분하지 않는다”가 있다.

그래서 본문 왼쪽에 방주 색단을 두는 쪽으로 갔다. 학술·법률 문서의 방주와 각주 전통을 그대로 가져온 것이다.

1
2
3
4
  사실 ⌐  2024년 매출 17억원, 전년 대비 62% 증가¹
  해석 ⌐  편의점 입점이 성장이 달라진 지점이다²³
  추정 ⌐  3~5년 시나리오를 3개 구간으로 ▨▨▨
  [  ] ⌐  해외 상시 유통을 확인할 자료가 없음

등급이 위치(왼쪽 색단)와 활자(굵기·등폭·라벨)로 먼저 구분되고, 색은 보조만 한다. 추정 항목에는 사선 배경을 덧붙였다. 색을 못 보는 사람도 라벨과 배경 패턴으로 알 수 있다.

접근성을 나중에 검사해서 고치는 게 아니라 구조로 미리 충족시킨 셈이다. 오늘 디자인 결정 중 제일 만족스러운 부분이었다.

5. 오류를 문구가 아니라 코드로 다루기

생성이 실패했을 때 화면에 “자료 생성 중 오류가 발생했습니다”만 떴다. 서버가 모든 실패를 하나로 뭉쳐 보내고 있었다.

로컬 CLI로 LLM을 부르는 구조라서 실패 종류가 원격 API보다 많다.

코드사용자가 할 조치
BACKEND_UNREACHABLE백엔드 서버를 켠다
CODEX_NOT_INSTALLEDCLI를 설치한다
CODEX_NOT_AUTHENTICATEDcodex login 한다
CODEX_RATE_LIMITED한도가 채워질 때까지 기다린다
CODEX_TIMEOUT입력을 줄인다
AI_SCHEMA_INVALID정보를 더 구체적으로 적는다

조치가 다 다른데 안내가 하나면 쓰는 사람은 아무것도 할 수 없다. 코드를 그대로 화면에 넘기고, 코드 → 문구 매핑을 한 파일에 모으게 했다.

1
2
// src/lib/messages/errors.ts
describeError(code)  // { message, action, retryable }

화면에서는 오류 문구를 문자열 리터럴로 쓰지 않는다. 무엇이 일어났는지 한 줄, 무엇을 하면 되는지 한 줄로 나눠 보여준다. 서버가 모르는 코드를 보내도 기본 안내로 떨어져서 화면이 멈추지 않는다.

한도 판정은 로그인 판정보다 먼저 보게 고쳤다. 한도 메시지에 api key나 login 같은 단어가 섞여 나오면 “로그인이 안 돼 있습니다”로 엉뚱하게 안내될 수 있었다.

6. 미리보기를 이미지로 만들지 않았다

결과 페이지를 별도 라우트(/decks/[id])로 뺐다. 미리보기를 붙이려면 서버에서 PPTX를 이미지로 변환해야 한다고 판단해서 일단 자리표시자만 두고 미뤘다. LibreOffice headless 같은 걸 띄우는 일이라 가볍지 않다.

그런데 백엔드를 맡은 다른 세션이 몇 시간 뒤 다른 방법으로 붙였다. 슬라이드를 만든 데이터(SlidePlan)가 이미 JSON으로 있으니, 그 데이터로 웹에서 다시 그리면 변환 단계가 아예 필요 없다는 것이었다.

1
2
SlidePlan (JSON) ┬→ pptxgenjs  → PPTX 파일
                 └→ React      → 웹 미리보기

픽셀 단위로 같지는 않다. 폰트 렌더링도 다르고 도형 위치도 미세하게 다르다. 대신 내용은 같은 원본에서 나온다. 이 차이를 화면에도 적었다. 미리보기가 결과물과 정확히 같다고 말하면 안 된다.

남은 숙제

  • 테스트가 없다. vitest·Playwright를 아직 안 붙였다. 오늘 잡은 세 가지 중 색 대비는 자동 검사로 만들 수 있는데(axe-core), 그게 있었으면 애초에 통과하지 못했을 것이다.
  • 모바일을 값으로만 확인했다. 실제 기기에서 카드 두 장이 1열로 쌓일 때 어떤지 봐야 한다.
  • 반응형 분기점을 폭 하나로 정하고 있는데, 컨테이너 쿼리를 쓰면 감싼 폭에 따라 스스로 결정하게 만들 수 있다. 오늘 겪은 62ch 문제가 애초에 안 생기는 방식이다.

정리하며

한 줄로 줄이면 이렇다. 프론트엔드에서 “괜찮아 보인다”는 판정 근거가 아니다.

색 대비는 계산해야 알고, 키 종류는 접두사를 읽어야 알고, 반응형은 실제 폭을 재야 안다. 세 번 다 눈으로는 문제없어 보였고 세 번 다 틀렸다. 다행히 셋 다 검사할 방법이 있었다 — 대비는 공식이 있고, 키는 스크립트로 만들었고, 폭은 브라우저가 알려준다.

AI에게 코드를 맡기면 속도는 올라가는데, 틀린 것도 그럴듯한 모양으로 빨리 나온다. oklch(0.68 ...)은 보기에 아무 문제가 없었고 sm:grid-cols-2도 맞는 코드였다. 결국 검사 장치를 붙이는 쪽이 남는 일이었다.

제일 위험했던 건 두 번째였다. 증상만 보고 원인을 단정해 엉뚱한 곳(RLS 정책)을 고치려 했다. 실제 상태를 조회하는 데 1분도 안 걸리는데 그걸 먼저 하지 않았다. 고칠 곳을 정하기 전에 지금 상태를 확인하는 것 — 이건 사람이 챙겨야 하는 순서였다.


코드는 GitHub에 있고, 배포는 ir-generator-five.vercel.app에서 볼 수 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.