Repository navigation
Releases: Lunas322/HanQ
Release list
v1.3.0 — 중국어 추가와 국가 카테고리 확장
중국어(zh)를 네 번째 언어로 추가하고, 한국·일본뿐이던 국가 카테고리에 미국·중국을 넣었다. 14 files changed, +482 −10
관련 PR: #39 (중국어·카테고리) · #40 (릴리즈)
🇨🇳 중국어 추가
이번 언어 추가는 대부분 데이터 작업이었다.
직전 릴리즈에서 "언어는 정확히 둘"이라는 전제를 걷어낸 결과가 여기서 나왔다.
LANGUAGES 배열에 "zh" 한 줄을 넣자, Record<Language, …>로 선언해둔 자리들 — 사전 레지스트리, 국기 이모지, OG 로케일, DeepL 코드 — 이 전부 컴파일 에러로 빠진 곳을 지목했다.
타입이 체크리스트 역할을 했고, 새로 짜야 할 로직은 언어 감지 한 군데뿐이었다.
lib/i18n/zh.ts신규- 국가 매핑: CN · TW · HK · MO · SG →
zh - OG 로케일
zh_CN, 국기 🇨🇳
한자는 누구 것인가
한글은 한국어에만, 가나는 일본어에만 있다. 신호가 깨끗하다.
그런데 한자는 일본어와 중국어가 나눠 쓴다. 한자를 그냥 중국어 점수로 세면, 한자 비중이 높은 일본어 문장이 통째로 중국어로 넘어간다.
2단으로 갈랐다.
1단 — 가나가 있으면 일본어다. 가나는 일본어에만 있으므로, 가나가 하나라도 있으면 그 글의 한자는 일본어 쪽으로 합산한다.
2단 — 가나가 없으면 글만으로는 못 가른다. 이때는 접속 지역을 따른다.
새로 만든 신호는 아니다. detectLanguage의 두 번째 인자가 이미 getCurrentLanguage()(경로 → 쿠키 → x-vercel-ip-country)로 채워져 들어오고 있었고, 질문 작성 시점엔 그 값이 그대로 sourceLanguage로 저장돼 나중 번역에서도 다시 쓰인다. 한자를 어느 쪽에 계산할지만 그 인자로 결정하면 되는 문제였다.
日本語質問 東京都行政手続처럼 가나 없는 한자 문장이 일본에서 올라오면 일본어로, 그 외 지역에서는 중국어로 잡힌다. 한글이 섞인 일본어(韓国語の「눈치」は…), 라틴이 섞인 한국어(서울에서 K-POP 콘서트…) 같은 기존 케이스도 회귀 없이 통과한다. 케이스 13종으로 검증했다.
대가: 일본 지역에서 중국어로 쓰면 일본어로 확신 판정된다. 확신 판정은 DeepL 자동 감지로 교정되는 경로를 타지 않는다(translations.ts는 confident: false일 때만 원문 언어를 DeepL에 맡긴다).
바꾸기 전에는 이 오류가 반대로 나 있었다 — 일본에서 쓴 한자 위주 일본어가 전부 중국어로 잡혔다. 재일 중국어 사용자보다 한자를 많이 쓰는 일본어 사용자가 훨씬 많으므로 지금 방향을 택했다.
🗂 카테고리
국가 카테고리가 한국·일본뿐이라 영어·중국어 사용자가 고를 자리가 없었다.
korea · japan 뒤에 usa(🇺🇸) · china(🇨🇳)를 추가했다.
Categories.tsx와 CategoryPicker.tsx가 CATEGORIES를 순회하므로 필터바와 질문 작성 폼에 자동으로 붙었고, 라벨은 Record<CategoryId, string> 제약이 네 사전 전부에 강제했다. 컴포넌트는 한 줄도 고치지 않았다.
🔤 DeepL
추측하지 않고 실제로 호출해 확인했다.
ZH · ZH-HANS 둘 다 target으로 200을 받고, 중국어 원문은 detected_source_language: "ZH"로 돌아와 기존 재감지 흐름에 그대로 물린다. 간체(ZH-HANS)를 target, ZH를 source로 지정했다.
번체는 지원하지 않는다. TW·HK도 현재는 간체로 표시된다. 필요해지면 zh-Hant를 별도 언어로 추가해야 한다.
🗃 데이터
기존 질문 14건 + 답변 21건에 중국어 번역을 채웠다.
백필 스크립트를 --lang= 인자를 받도록 일반화해 scripts/backfill-language.mjs로 옮기고, 이번에 리포에 포함시켰다 (v1.2.0 노트의 scripts/backfill-en.mjs가 이것이다). 다음 언어를 추가할 때 그대로 재사용한다.
남은 것
- 공지(notices) 문서에
en·zh필드가 없다 — 두 로케일에서 상단 배너가 한국어로 폴백된다. Firestore 데이터 작업 필요 - 언어 전환 UI 없음 — 이제 언어가 넷이라 체감이 커진다.
switcherLegend키는 네 사전에 다 있으나 쓰는 컴포넌트가 없다
v1.2.0 — 다국어 확장과 영어 지원
한일(ko/ja) 전용이던 서비스를 다국어 구조로 바꾸고 영어(en)를 추가했다. 21 files changed, +412 −85
관련 PR: #35 (크롤러 메타데이터) · #37 (다국어 확장) · #38 (릴리즈)
🌏 다국어 확장
언어를 2개로 전제한 코드가 흩어져 있었다. LANGUAGES 배열에 "en"만 추가하면 될 것처럼 보였지만, 실제로는 언어가 정확히 둘일 때만 성립하는 로직이 네 군데 박혀 있었다.
otherLanguage() — "그 반대 언어"라는 개념 자체
otherLanguage(language) => language === "ko" ? "ja" : "ko"
원문이 한국어면 일본어, 아니면 한국어. 언어가 셋이 되는 순간 "반대"가 정의되지 않는다.
translationTargets()로 교체해 LANGUAGES에서 원문 언어만 빼도록 바꿨다. 이제 언어를 몇 개 추가하든 번역 대상이 자동으로 따라온다.
영어 질문이 번역을 통째로 건너뛰고 있었다
hasTranslatableText()가 한글·가나·한자만 "번역할 내용"으로 인정했다.
2개 언어 시절엔 맞는 가정이었다 — CJK 문자가 하나도 없으면 숫자나 이모지뿐이라 번역할 게 없었으니까.
그런데 영어가 들어오면 순수 영어로 쓴 질문이 전부 이 조건에 걸려 번역 없이 저장되고, 한국어·일본어 사용자에게 영어 원문 그대로 노출됐을 상황이었다. 라틴 문자를 번역 대상에 포함시키고, 언어 감지도 3종 스크립트 비교로 일반화했다.
미국에서 접속해도 /ko로 갔을 자리
COUNTRY_TO_LANGUAGE가 lib/locale.ts와 middleware.ts 두 곳에 복제돼 있었다.
실제 리다이렉트를 결정하는 건 미들웨어 쪽인데, 국가 매핑을 추가할 때 눈에 띄는 건 locale.ts 쪽이다. 한 파일만 고치고 넘어갔으면 미국 방문자는 계속 한국어 페이지를 봤다. lib/country-language.ts로 합쳤다.
DeepL은 영어 코드를 방향에 따라 다르게 받는다
target_lang은 EN-US처럼 지역 변형이 필수고, source_lang은 변형을 거부하고 EN만 받는다.
기존 코드는 하나의 맵을 양쪽에 재사용하고 있어서, 영어로 쓴 글을 번역하는 순간 API가 에러를 뱉었을 것이다. 소스용 코드를 분리했다.
그 외
lib/i18n/en.ts신규 —ko.ts가 정의하는Dictionary타입을 그대로 만족시키므로, 키가 빠지면 컴파일이 깨진다- 랜딩·메타 문구를 "한국과 일본" → "다국어 소통"으로 (ko/ja 양쪽,
manifest.ts,llms.txt, OG 이미지 태그라인) - OG 로케일 하드코딩 삼항 3곳 제거 — 영어 페이지가
ja_JP로 표기되던 자리 toLanguage()가"ja"가 아니면 전부 ko로 뭉개던 것을 타입 가드로 교체
캐싱은 건드리지 않았다. unstable_cache가 캐싱하는 대상은 모든 언어를 한 문서에 담은 Firestore 원본이고, 언어 선택은 캐시 이후 readLocalizedText에서 일어난다. 페이지 HTML은 [lang] 세그먼트 덕에 URL별로 이미 분리된다. sitemap.ts · robots.ts · generateStaticParams도 처음부터 LANGUAGES를 순회하고 있어 무수정으로 통과했다.
🔎 크롤러
htmlLimitedBots에 Googlebot을 포함한 검색·SNS 크롤러 목록을 지정했다.
스트리밍 렌더에서는 메타데이터가 늦게 도착해 크롤러가 빈 메타를 읽을 수 있다. 이 봇들에게는 메타데이터를 블로킹으로 제공한다.
🗃 데이터
기존 문서는 translationStatus: "done"이라 번역 파이프라인이 다시 돌지 않는다.
scripts/backfill-en.mjs로 질문 14건 + 답변 21건에 영어 번역을 채웠다.
남은 것
- 공지(notices) 문서에
en필드가 없다 — 영어 페이지 상단 배너가 한국어로 폴백된다. Firestore 데이터 추가 필요 - 언어 전환 UI 없음 —
switcherLegend키는 세 사전에 다 있으나 쓰는 컴포넌트가 없다. 기존부터 있던 공백
v1.1.0 — 보안 수정과 접근성 개선
QA 두 건(기능·보안 / UX·UI)에서 나온 항목을 반영했다. 36 files changed, +1037 −83
관련 PR: #32 (시스템 층) · #33 (컴포넌트) · #34 (릴리즈)
🔒 보안
JSON-LD 저장형 XSS 차단
JSON.stringify는 JSON 문법만 이스케이프하고 HTML 문법은 통과시킨다.
질문 제목·본문·답변·작성자 이름 중 어디든 </script>가 들어가면 스크립트 태그가 조기 종료됐다.
로그인한 사용자 누구나 질문 하나로 그 상세 페이지를 보는 모든 사람의 브라우저에서 임의 스크립트를 실행할 수 있었다.
세션 쿠키는 httpOnly라 직접 탈취는 안 되지만, 그 스크립트는 이미 피해자 세션으로 서버 액션을 호출할 수 있다.
♿ 접근성
색 대비를 WCAG AA로 재산정 — 흰색·muted·page·brand-subtle 네 배경 전부에서 검증했다.
| 토큰 | 이전 | 이후 |
|---|---|---|
tertiary |
3.04:1 | 5.11:1 |
secondary |
4.62:1 | 5.84:1 |
like (오류 문구) |
3.71:1 | 5.14:1 |
| 플레이스홀더 | 1.40:1 | 4.89:1 |
| 컨트롤 테두리 | 1.23:1 | 3.32:1 |
| 아바타 3색 | 2.34~4.38:1 | 4.51:1+ |
tertiary는 11~14px 텍스트 19개 파일에 쓰이고 있었고, 빈 상태 문구와 에러 화면 설명이 전부 여기 걸려 있었다 —
사용자가 막혔을 때 읽어야 하는 문구가 가장 안 읽히는 색이었다.
그 외:
word-break: keep-all— 없어서 한국어·일본어가 어절 중간에서 끊겼다prefers-reduced-motion— 스켈레톤 pulse가 전 화면에 무조건 깔려 있었다- safe-area —
viewport-fit=cover를 켠 채env()를 쓰지 않아 탭바·랜딩 로그인 버튼·답변 입력창이 홈 인디케이터에 깔렸다 h1구조 — 홈·질문 작성·알림·프로필 수정 네 화면에h1이 없었다. 본문 건너뛰기 링크도 신규- 삭제 확인 모달 — 포커스 가두기, 닫을 때 원래 버튼으로 복귀, 배경 스크롤 잠금
✨ 동작 변경
Toast 추가 — 성공·실패를 알리는 층이 아예 없었다.
좋아요·투표 실패를 알린다. 그전에는 낙관적 UI로 즉시 반영한 뒤 실패하면
"잠깐 반영됐다가 슬쩍 되돌아가는" 화면만 보였다. 이유도 재시도 안내도 없었다.
서버 액션 3개가 성공 여부를 반환하도록 바꿨다.
좋아요 실패가 페이지 전체를 날리던 버그 수정.
상세 페이지를 열어둔 사이 작성자가 그 질문을 지우면, 하트를 누르는 순간 화면이 통째로 에러 페이지로 교체됐다.
제출 버튼 비활성 제거. 비활성 버튼은 무엇이 빠졌는지 알려주지 않고 스크린리더가 건너뛴다.
이제 항상 누를 수 있고, 부족한 필드로 스크롤 + 포커스가 이동한다.
일본어 문구 폭 — プロフィール編集이 메뉴 폭을 넘어 두 줄로 접히던 것 수정.
⚠️ 눈에 띄는 변화
색 토큰을 바꿨기 때문에 모든 화면의 외관이 조금씩 달라진다.
- 회색 텍스트가 전반적으로 진해짐
- 칩 · 좋아요 버튼 · 투표 선택지의 테두리가 눈에 띄게 진해짐 (
#e5e8eb→#808f9d) - 빨간색이 깊어짐 (
#f04452→#da1122) - 하단 고정 요소가 홈 인디케이터 위로 올라옴
Figma 토큰과 값이 어긋난 상태다. 코드 주석은 여전히 Figma 토큰명을 가리키므로, Figma 쪽도 맞춰야 한다.
검증
lint / typecheck / build 통과, 36/36 정적 페이지
빌드 CSS env() 7개 · keep-all · reduced-motion · color-scheme 생성 확인
런타임(비로그인) 전 공개 라우트 정상, JSON-LD 파싱 정상, skip link ko/ja 확인
로그인이 필요한 화면(h1 4개 · 토스트 위치 · 삭제 모달 키보드 순환)은 자동 검증하지 못했다.
다음
캐시 태그 정리(questionTag가 죽은 태그라 좋아요 하나에 전체 질문 목록 캐시가 날아간다),
revalidatePath("/home") 무효화, Accept-Language 미반영, CI에 next build 추가.
v1.0.3 — 인앱 브라우저 로그인 유도
X 인앱 브라우저에서 구글 로그인이 실패하던 문제를, 인앱 브라우저를 감지해 외부 브라우저로 유도하는 방식으로 처리했다.
배경
v1.0.1 에서 같은 missing initial state 에러를 고쳤으나, 이번 건은 원인이 다르다.
v1.0.1 |
이번 | |
|---|---|---|
| 원인 | 인증 핸들러가 firebaseapp.com 이라 서드파티 저장소가 차단됨 |
구글로 갔다 돌아오는 사이 인앱 브라우저가 sessionStorage 를 비움 |
| 성격 | 도메인 문제 | 저장소 수명 문제 |
| 해결 | 동일 오리진 프록시 | 코드로 우회 불가 → 사전 차단 |
동일 오리진으로 옮겨도 저장소 자체가 사라지면 복구할 방법이 없다.
왜 사전 차단인가
실패는 우리 화면이 아니라 인증 핸들러 페이지(/__/auth/handler)에서 발생한다. 사용자는 앱으로 돌아오지 못하고 그 에러 페이지에 갇힌다.
try/catch 로 잡아 안내하는 방식이 성립하지 않으므로, 리다이렉트를 시작하기 전에 분기해야 한다.
동작
- 인앱 브라우저 — 구글 로그인 버튼 대신 외부 브라우저 안내를 노출
- 안드로이드:
intent://로 크롬을 직접 연다 - iOS: 주소 복사 + 안내 (직접 여는 방법이 없다)
- 안드로이드:
- TikTok — 기존 로그인 버튼 그대로. 정상 동작이 확인된 유일한 인앱 브라우저다
- 일반 브라우저 / 서버 렌더 — 변화 없음
하이드레이션 불일치를 피하기 위해 useSyncExternalStore 로 UA를 읽는다. 서버에서는 UA가 비어 있어 인앱이 아닌 것으로 보고 기본 버튼을 렌더한다.
판정 정책
| 오판 | 결과 |
|---|---|
| 정상 동작하는 앱을 인앱으로 판정 | 사용자가 한 단계를 더 밟는다. 불편 |
| 막히는 앱을 정상으로 판정 | 빠져나올 수 없는 에러 페이지에 갇힌다. 이탈 |
위험이 비대칭이므로 확인된 TikTok만 통과시키고 나머지 인앱 브라우저는 모두 안내 대상으로 둔다. 다른 앱에서 정상 동작이 확인되면 WORKING 패턴에 추가한다.
검증
실제 UA 12종으로 판정 결과를 확인했다.
X iOS 인앱=true Chrome Android 인앱=false
X Android (wv) 인앱=true Safari iOS 인앱=false
TikTok Android 인앱=false Chrome iOS 인앱=false
TikTok iOS 인앱=false Chrome 데스크톱 인앱=false
Instagram iOS 인앱=true Firefox Android 인앱=false
KakaoTalk iOS 인앱=true UA 없음(서버) 인앱=false
TikTok 안드로이드 UA에는 안드로이드 WebView 표식인 wv 가 포함된다. 확인된 앱을 먼저 걸러낸 뒤 일반 WebView 판정을 하는 순서로 처리했다.
남은 한계
iOS는 외부 브라우저를 직접 열 수 있는 방법이 없어 주소 복사 + 안내가 최선이며, 이 단계에서 이탈이 발생한다.
근본적으로는 로그인 없이 볼 수 있는 범위를 넓히는 것이 인앱 브라우저 문제 자체를 우회하는 방법이다.
Full Changelog: v1.0.2...v1.0.3
v1.0.2 — 질문 투표 기능 + 공지 배너
질문에 투표를 붙여 토론이 성립하도록 하고, 그것을 알릴 공지 배너를 함께 추가했다.
질문 투표
질문 작성 질문 상세 답변 목록
┌─────────────┐ ┌────────────────┐ ┌──────────────────┐
│☑ 투표 붙이기 │ │ 당신의 선택은? │ │[안 봐준다] 민지 │
│ ● 봐준다 │ → │ 봐준다 23% │ → │ 한 번 하면 또… │
│ ● 안 봐준다 │ │ ▓▓▓░░░░░░░░░░░ │ │ │
│ + 선택지 추가│ │ 안 봐준다 77% │ │[봐준다] 유키 │
└─────────────┘ │ 52명 참여 │ │ 사정을 들어보는… │
└────────────────┘ └──────────────────┘
- 질문 작성 시 선택지를 2~4개까지 직접 입력한다. 선택 사항이며, 붙이지 않은 질문은 이전과 동일하게 동작한다
- 투표는 로그인이 필요하다. 비로그인 사용자에게는 좋아요와 같은 방식으로 로그인을 유도한다
- 결과는 투표해야 보인다. 투표 전에는 선택지와 참여 인원만 노출된다
- 선택은 바꿀 수 있다. 다만 이미 작성한 답변의 뱃지는 작성 시점으로 고정되어, 그때의 입장이 보존된다
- 선택지도 자동 번역된다. 제목·본문과 같은 요청에 실려 나가므로 DeepL 호출 횟수는 늘지 않는다
데이터 모델
poll.optionIds : ["o1", "o2"] 순서 보존용 배열
poll.labels : { o1: { ko, ja } } 번역 결과
poll.counts : { o1: 12 } FieldValue.increment 대상
questions/{id}/votes/{userId} = { optionId, createdAt }
counts 를 배열이 아니라 맵으로 둔 이유는 FieldValue.increment 가 필드 경로를 대상으로 하기 때문이다. 배열이면 원소만 증가시킬 수 없어 통째로 덮어써야 하고, 동시에 두 명이 투표하면 한 명의 표가 유실된다.
votes 는 기존 likes 서브컬렉션과 동일한 구조이며, toggleLike 의 트랜잭션 패턴을 따랐다.
번역 파이프라인 변경
기존에는 최상위 필드만 번역할 수 있었다. 선택지는 poll.labels.o1 같은 중첩 경로에 있고 개수도 질문마다 다르다.
- 번역 대상 목록을 상수 배열이 아니라 문서를 받아 계산하는 함수로 변경
- 점 표기 경로를 읽는
readPath를 추가. 쓰기(ref.update)는 점 경로를 원래 지원하므로 변경 없음
호환성
poll 필드가 없는 기존 질문은 투표 UI가 렌더되지 않는다. 마이그레이션이 필요 없다.
공지 배너
랜딩과 홈 상단에 공지 배너를 띄운다. 문구는 Firestore config/notice 문서 하나를 Firebase 콘솔에서 직접 수정해 배포 없이 갱신한다.
| 항목 | 선택 | 이유 |
|---|---|---|
| 캐싱 | 태그가 아니라 revalidate: 60 |
콘솔에서 사람이 고치는 데이터라 코드에서 updateTag 를 부를 수 없다. 공지에 1분 지연은 문제되지 않는다 |
| 닫기 상태 | localStorage가 아니라 쿠키 | 서버가 읽어야 닫은 사용자에게 배너를 아예 렌더하지 않는다. localStorage면 한 번 그려졌다 사라져 깜빡인다 |
| 쿠키 값 | true 가 아니라 공지 id |
id가 바뀌면 새 공지로 인식돼 다시 노출된다 |
| 렌더링 | Suspense 로 격리 |
쿠키를 읽으면 페이지가 동적이 된다. 배너만 감싸 랜딩의 정적 프리렌더(◐)를 유지했다 |
운용
- 문구만 수정 →
message만 변경 (이미 닫은 사용자에게는 계속 노출되지 않음) - 새 공지 →
id를 변경 (닫았던 사용자에게도 다시 노출됨) - 내리기 →
active=false
배포 후 필요한 작업
Firebase 콘솔 → Firestore 에 아래 문서를 만들어야 배너가 노출된다. 투표 기능은 이 작업과 무관하게 즉시 동작한다.
- 컬렉션
config/ 문서 IDnotice id(string),active(boolean),message(map:ko,ja)
Full Changelog: v1.0.1...v1.0.2
v1.0.1 — 인앱 브라우저 로그인 수정
틱톡 등 인앱 브라우저(WebView)에서 구글 로그인이 되지 않던 문제를 수정했다.
유입의 대부분이 틱톡(31/39명), 모바일 95%(안드로이드 74%)인 상황에서 광고로 들어온 사용자 대부분이 가입 자체를 하지 못하는 상태였다.
수정 내용
세 겹의 문제가 순서대로 겹쳐 있었고, 앞의 것을 고쳐야 뒤의 것이 드러났다.
| # | 증상 | 원인 | 수정 |
|---|---|---|---|
| 1 | missing initial state |
인증 핸들러가 firebaseapp.com(서드파티 오리진)에서 서빙되어 저장소 격리 정책에 sessionStorage 가 막힘 |
/__/auth/* 를 프록시해 인증 핸들러를 서비스 도메인과 동일 오리진으로 전환 |
| 2 | redirect_uri_mismatch |
변경된 authDomain 이 OAuth 클라이언트에 등록되지 않음 |
승인된 리디렉션 URI 및 JavaScript 원본 추가 (콘솔) |
| 3 | 빈 화면 | WebView가 팝업 창을 띄우지 못함 (auth/popup-blocked) |
signInWithPopup → signInWithRedirect 전환 |
변경 파일
next.config.ts—/__/auth/:path*프록시 추가src/middleware.ts—matcher에서/__/경로 제외. 미들웨어는rewrites보다 먼저 실행되므로 이 제외가 없으면 핸들러 요청이/{lang}/__/auth/handler로 리다이렉트되어 프록시에 도달하지 못한다src/hooks/login.ts— 리다이렉트 로그인으로 전환.browserSessionPersistence적용,getRedirectResult복귀 처리 추가, 세션 쿠키 발급 직후signOut호출로 브라우저에 인증 상태를 남기지 않는 기존 원칙 유지
함께 필요한 외부 설정
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= 서비스 도메인- Google Cloud OAuth 클라이언트 — 승인된 리디렉션 URI에
https://<도메인>/__/auth/handler
알려진 제약
- 리다이렉트 복귀 후
getRedirectResult응답까지 랜딩 화면이 약 0.3초 노출된다 - Preview 배포는 도메인이 매번 달라 로그인이 동작하지 않는다
Full Changelog: v1.0.0...v1.0.1
v1.0.0 — HanQ 첫 정식 릴리즈
한국과 일본을 잇는 실시간 번역 Q&A 서비스 HanQ의 첫 정식 릴리즈입니다.
서비스 주소: https://han-q.vercel.app
주요 기능
- 질문·답변 자동 번역 — 작성 즉시 DeepL로 번역해 언어별로 저장한다. 조회 시점에 번역하지 않아 읽기 속도에 영향이 없고, 번역 전에는 "번역 중" 배지를, 이후에는 원문/번역 전환 토글을 제공한다.
- ko / ja 다국어 라우팅 — 모든 화면이
/[lang]아래에 있고, 미들웨어가 쿠키 → 접속 국가 → 기본값 순으로 언어를 판별해 해당 경로로 보낸다. - 질문 상호작용 — 좋아요, 답변, 삭제, 알림. 좋아요와 탭 전환은 낙관적 업데이트로 즉시 반영된다.
- 카테고리 — 🇰🇷 한국 · 🇯🇵 일본 · 🎓 취업/유학 · ❤️ 연애 ·
✈️ 여행 · 🍜 음식 · 🎮 취미 · 💬 자유 - Google 로그인 — Firebase Auth + 세션 쿠키. 질문 상세와 프로필은 비로그인에도 공개된다.
- PWA — manifest와 아이콘을 제공해 홈 화면에 설치할 수 있다.
SEO / GEO
sitemap.xml,robots.txt,llms.txt,ads.txt- 질문 상세에
QAPage구조화 데이터, 동적 OG 이미지 생성 hreflang+x-default링크, 검색엔진 사이트 소유 확인- 로그인 전용 화면은 색인에서 제외
기술적으로 신경 쓴 것
- 태그 기반 캐싱 — 시간(
revalidate)이 아니라 캐시 태그(questions,question:{id},answers:{id},users)로 무효화한다. 변경이 없으면 캐시가 계속 유효하고, 변경 즉시 반영된다. - 서버 컴포넌트 우선 — 데이터 조회는 서버에서,
'use client'는 상호작용이 필요한 잎사귀로 밀어냈다. 폼 제출은 Server Actions. - 로딩·에러 상태 — 라우트별
loading.tsx스켈레톤,error.tsx/global-error.tsx에러 바운더리. 에러는 코드로 반환되어 사용자 언어 문구로 변환된다.
스택
Next.js 16 (App Router) · TypeScript · Tailwind CSS v4 · Firebase (Firestore / Auth / Storage) · DeepL API · Vercel