D
Archive
커리어
  • SEO
  • 컴포넌트
  • 블로그
  • 성능
  • AI
  • 아키텍처
  • 회고
  • 새로운 프론트엔드 인프라 아키텍쳐를 고민하며
    아키텍처
    3일 전

    새로운 프론트엔드 인프라 아키텍쳐를 고민하며

    새 프로젝트를 맡게 되었습니다 이전 글 말미에서 지금의 SSR을 ISR + On Demand Revalidation으로 바꾸고 앞단에 CloudFront를 붙여보겠다고 적었었는데요. 그러던 차에 회사에서 새 프로젝트를 맡게 되었습니다. 기존 서비스를 고치는 게 아니라 인프라를 처음부터 설계해야 하는 일이었지요. 새로 만들 서비스는 커뮤니티 성격이 강한 서비스인데요. 콘텐츠 성격을 보면 이전 글에서 다뤘던 질문상세 페이지와 거의 비슷했습니다. 메인 콘텐츠는 유저가 작성한 텍스트 한 번 쓰이고 나면 수정 빈도가 낮음 검색 유입이 주된 유입 경로라 비회원 조회 비중이 높을 것으로 예상 마지막 항목은 막연한 기대는 아닌데요. 기존에 운영하던 커뮤니티 성격의 서비스에서 비회원 비율이 압도적으로 높았습니다. 검색엔진을 통한 자연유입 비중이 컸기 때문이지요. 성격이 비슷한 서비스를 새로 만드는 것이다보니 비슷한 곡선을 그릴 것으로 보고 설계를 시작했습니다. 그러니 목표도 똑같습니다. 렌더 비용을 줄이는 것이지요. SSR은 요청이 들어올 때마다 서버가 페이지를 새로 그리는 방식이다보니, 구조상 트래픽이 늘면 서버 비용이 그대로 따라 올라갑니다. 콘텐츠가 거의 바뀌지 않는데도 매번 같은 결과를 다시 그리고 있다면 그건 그냥 낭비인 것이고요. 그래서 앞단에 CDN을 두고 대부분의 요청을 엣지에서 끊어내자는 방향은 처음부터 정해져 있었습니다. 문제는 그 다음이었는데요. 막상 설계에 들어가니 결정해야 할 게 생각보다 많았습니다. CDN과 오리진을 어떤 조합으로 둘 것인가 페이지를 어떻게 그려서 어디에 둘 것인가 오리진은 어떻게 보호할 것인가 캐시는 어떻게 갱신할 것인가 개인화 데이터는 어떻게 할 것인가 그리고 결론부터 말씀드리면, 이전 글에서 예고했던 ISR 조합과는 다른 결론이 나왔습니다. 왜 그렇게 됐는지를 순서대로 적어보려고 합니다. 다만 미리 말씀드릴 부분이 있는데요. 이 글은 아직 운영 데이터가 없는 설계 단계의 기록입니다. 이전 글처럼 개선 전후 수치를 들고 오는 글이 아니라, 무엇을 근거로 이렇게 정했는지를 남겨두는 글에 가깝습니다. 숫자는 서비스가 뜨고 나서 다시 적어보려고 합니다. CDN과 오리진을 어떤 조합으로 둘 것인가 1) 출발점은 요구사항이었습니다 먼저 밝혀둘 게 있는데, CDN 벤더는 제가 고른 게 아닙니다. CTO님 쪽에서 Cloudflare를 요구하셨고, 이유는 CloudFront와 비교했을 때 비용 차원에 이점이 있다고 하셨습니다. 그래서 제가 판단해야 할 건 "Cloudflare를 쓸 것인가"가 아니라 "Cloudflare를 쓴다면 어디까지 옮길 것인가"였습니다. 애플리케이션은 EKS 위의 Next.js 파드로 띄운다는 것까지는 정해져 있었으니, 그 앞뒤를 어디서 끊느냐의 문제였지요. 그래서 익숙한 AWS 단일 구성을 기준선으로 놓고 비교를 시작했습니다. 2) 기준선 — 전부 AWS로 가는 구성 가장 단순한 구성입니다. 장점이 꽤 명확한데요. IAM·보안그룹으로 통합 접근제어 CloudFront–ALB처럼 네이티브 통합이 이미 갖춰져 있음 단일 벤더라 장애 대응·과금·설정 창구가 하나 다만 단점도 같은 자리에서 나옵니다. CloudFront 데이터 전송 비용이 트래픽 커질수록 부담 AWS 종속성 심화 WAF·봇 방어 등은 별도 서비스로 붙여야 함 3) CDN만 Cloudflare로 빼는 구성 이 구성의 포인트는 옮기는 게 CDN 하나뿐이라는 점입니다. 애플리케이션이 도는 곳도, 데이터가 사는 곳도 전부 VPC 안에 그대로 남고 맨 앞 레이어만 바뀌는 것이지요. 그러면서 기준선의 단점으로 적었던 두 가지 — 전송 비용과 별도로 붙여야 하는 보안 계층 — 는 CDN 레이어에서 해소됩니다. CTO님이 말씀하신 비용 이점도 결국 첫 번째 항목에 해당하는 이야기이고요. 대가는 세 가지였습니다. 벤더 두 곳 운영 (장애 대응·과금·설정 창구가 이원화) CDN → 오리진 인터넷 구간에 오리진 보호 설정이 필수 CloudFront–ALB 같은 네이티브 통합의 편의는 포기 4) 무엇을 택했나 Cloudflare로 CDN만 빼는 구성으로 갔고, 채택 기준은 사실상 하나였습니다. 비용이지요. 두 CDN은 과금 구조 자체가 다릅니다. CloudFront — 전송량(GB)과 요청 수에 따라 붙는 변동비. 게다가 단가가 엣지 리전별로 다름 Cloudflare — 플랜당 고정비. HTTP/S 대역폭은 전 플랜 무과금 구조가 이렇게 다르다보니 감으로는 비교가 안 돼서, 목표 트래픽을 놓고 직접 계산해봤습니다. 가정은 이렇게 잡았는데요. 월 1,000만 페이지뷰 페이지당 평균 전송량 500KB (텍스트 위주 + 정적 에셋, 재방문은 브라우저 캐시 감안) → 월 약 5TB 페이지당 평균 요청 10건 → 월 약 1억 건 여기에 2026년 기준 공개 요금을 대입해보면요. CloudFront는 아시아(한국·일본·싱가포르 등) 엣지 기준 GB당 $0.120이고, HTTPS 요청은 1만 건당 $0.0125입니다. 영구 무료 티어로 월 1TB 전송과 1,000만 요청이 빠지고요. 전송 — (5,120GB − 1,024GB) × $0.120 = 약 $492 요청 — (1억 − 1,000만) ÷ 10,000 × $0.0125 = 약 $113 합계 — 월 약 $600 (환율 1,400원 기준 84만원 남짓) 반면 Cloudflare는 Business 플랜이 월 $250(연간 결제 시 $200)이고, 대역폭은 얼마가 나가든 추가 과금이 없습니다. 5TB를 쓰든 50TB를 쓰든 청구서는 그대로인 것이지요. 월 $600 대 월 $250. 대략 2.4배 차이인데, CloudFront에 보안 번들 할인(최대 30%)을 붙여도 $420 선이라 순서는 바뀌지 않았습니다. 계산해보고 나서야 알았는데, 여기서 제일 크게 작용한 변수는 우리 사용자가 한국에 있다는 점이었습니다. CloudFront는 엣지 리전별로 단가가 다른데 아시아 엣지는 GB당 $0.120으로, 미국·유럽의 $0.085보다 41% 비쌉니다. Price Class를 낮춰 단가를 깎는 방법이 있긴 하지만 그건 사용자가 실제로 미국·유럽에 있을 때 얘기고요. 하필 우리 서비스가 가장 비싼 구간에 해당했던 셈입니다. 다만 정직하게 덧붙이면, 트래픽이 작을 때는 CloudFront가 더 쌉니다. 무료 티어 1TB 안에서 놀면 CloudFront는 $0인데 Cloudflare는 Pro만 해도 $25니까요. 손익분기를 계산해보면 Pro 기준으로는 월 1.2TB(약 240만 PV), Business 기준으로는 월 3.1TB(약 620만 PV) 근처였습니다. 목표로 잡은 1,000만 PV는 그 선을 이미 넘어서는 지점이었고요. 그리고 금액보다 더 크게 봤던 건 변동비냐 고정비냐였습니다. 커뮤니티 서비스는 글 하나가 터지면 트래픽이 몇 배로 뛰는 일이 흔한데, CloudFront에서는 그 스파이크가 그대로 다음 달 청구서에 실립니다. Cloudflare에서는 실리지 않고요. 예산을 잡는 입장에서는 이 차이가 꽤 컸습니다ㅎㅎ 한 가지 주의할 점도 적어둡니다. Cloudflare의 무제한 대역폭은 일반적인 웹 콘텐츠를 전제로 한 것이라, 약관상 비디오나 대용량 바이너리를 불균형하게 전송하는 경우는 범위 밖입니다. 텍스트 위주 커뮤니티라면 문제될 게 없지만, 나중에 영상 같은 대용량 콘텐츠를 붙이게 되면 그때는 별도로 논의해야 할 부분으로 보입니다. 참고로 위 숫자는 전부 공개된 리스트 가격 기준이고, 실제 계약 조건과는 다를 수 있다는 점은 감안해주시면 좋겠습니다. 페이지를 어떻게 그려서 어디에 둘 것인가 여기가 이번 설계에서 가장 오래 고민한 부분입니다. 그리고 이전 글의 계획에서 방향이 갈라진 지점이기도 하고요. 1) 두 갈래의 선택지 CDN을 앞에 두기로 한 이상, 엣지에 올릴 HTML을 누가 언제 그리느냐를 정해야 했습니다. 크게 두 갈래였는데요. 저장소를 끼우는 방식 — 정적 페이지를 S3 / R2에 보관해두고 CDN이 그걸 가져다 쓰는, ISR + Custom Cache Handler 조합 저장소 없이 가는 방식 — SSR로 실시간 렌더한 결과를 CDN에 곧바로 캐시 중요한 건 두 방식이 엣지 히트 시에는 완전히 동일하다는 것입니다. 캐시에 있으면 어느 쪽이든 CDN이 즉시 응답하고 끝이니까요. 차이는 오직 엣지 미스 이후에만 생깁니다. 저장소를 끼우는 쪽은 분명히 매력적입니다. 이미 그려둔 결과가 있으면 렌더 단계를 통째로 건너뛰다보니, 렌더 발생 횟수에 상한이 생기는 셈이니까요. 최초 생성과 데이터 변경 시에만 그리면 되고 그 외에는 읽기만 하면 됩니다. 2) 그럼에도 저장소를 끼우지 않은 이유 그런데 저장소를 끼우는 순간 갱신 경로가 두 갈래가 됩니다. 저장소에서 해당 페이지를 제거하고 CDN에서도 그 페이지 캐시를 초기화해야 함 호출 횟수만 놓고 보면 별것 아닙니다. 무효화 함수 안에 두 줄 적으면 되는 일이지요. 진짜 비용은 둘 중 하나만 성공했을 때 발생합니다. 두 계층이 어긋난 채로 서빙되기 시작하고, 장애가 나면 "지금 이 페이지가 어느 층에서 stale인지"를 사람이 추론해야 하는데요. 운영 중에 이걸 매번 따지는 그림이 잘 그려지지 않았습니다. 반면 저장소를 걷어내면 무효화 지점이 CDN 한 곳뿐입니다. 그런데 이게 단순히 손이 덜 간다는 얘기가 아닌데요. 틀릴 수 있는 상태의 개수가 줄어듭니다. 저장소가 있으면 갱신이 실패했을 때 시스템이 잘못된 상태로 굳어버립니다. 저장소에는 옛 페이지가 남아 있는데 CDN은 비어 있거나, 그 반대이거나요. 어느 쪽이든 사람이 들어가서 맞춰줘야 풀리는 상태입니다. 반면 저장소가 없으면 무효화가 실패했을 때 나올 수 있는 결과가 하나뿐인데요. 캐시가 안 지워져서 옛 페이지가 조금 더 서빙되거나, 지워져서 미스가 나거나 둘 중 하나입니다. 그리고 그 미스라는 게 이미 정상 경로입니다. 수정·삭제로 캐시가 지워졌다면 오리진이 페이지를 새로 그리는 건 예외 상황이 아니라 설계상 당연히 일어나야 할 일이니까요. 애초에 CDN이 있든 없든 SSR 서버는 요청이 오면 그리는 게 본업이고, 캐시는 그 본업을 덜어주는 장치일 뿐입니다. 캐시가 비면 본업으로 돌아가는 것뿐이지요. 그러니 이 구조에서는 무효화가 실패해도 다음 요청에서 알아서 정상으로 돌아옵니다. 두 계층을 맞춰주러 사람이 들어갈 일이 없습니다. 운영 중에 뭔가 이상할 때 던져야 할 질문도 "이 페이지 캐시가 살아 있나 없나" 하나로 끝나고, "저장소와 CDN 중 어디가 어긋났나"를 따질 일이 없고요. 사실 이게 이번 선택의 가장 큰 이유였습니다. 여기에 하나 더 있었는데요. 이번 구성은 원래 CloudFront + SSR로 구상했던 그림을 Cloudflare로 옮긴 것입니다. 저장소는 원래 없던 요소이다보니, 옮기는 김에 캐시 아키텍처까지 같이 바꾸면 문제가 생겼을 때 원인이 둘로 갈립니다. 한 번에 하나만 바꾸자는 판단도 작용했습니다. 3) 무엇을 포기했는지도 적어둡니다 다만 이 선택이 공짜는 아닙니다. 정리해보면 서버 자원 측면에서는 오히려 불리한 쪽을 고른 것으로 보였는데요. 저장소 없이 가면 이렇게 됩니다. 렌더는 엣지 미스가 날 때마다 발생 — 트래픽에 비례하고 상한이 없음 무효화 지점은 CDN 한 곳 롱테일 페이지는 축출되면 데이터가 같아도 다시 그림 저장소를 끼웠다면 이랬을 겁니다. 렌더는 최초 생성과 데이터 변경 시에만 — 페이지 수와 변경 횟수에 묶여 상한이 있음 무효화 지점은 저장소와 CDN 두 곳 롱테일 페이지도 저장소에서 읽기만 하면 됨 즉 이번 결정은 렌더 비용의 상한을 포기하는 대신 무효화를 한 뎁스로 끌어내린 교환인 셈입니다. 그리고 이 교환이 성립하려면 전제가 하나 필요한데요. 엣지 히트율이 충분히 높아야 한다는 것입니다. 히트율이 높으면 렌더는 어차피 드물게만 발생하니 상한이 없어도 무해하지만, 히트율이 떨어지면 포기한 상한이 곧바로 비용으로 돌아오기 때문이지요. 그럼에도 이쪽을 고른 건, 운영에서 사람이 개입해야 하는 상황을 없애는 쪽의 값어치를 더 크게 봤기 때문입니다. 렌더 비용은 결국 파드를 늘리면 해결되는 문제인데요. 두 계층이 어긋난 채로 서빙되는 상황은 돈으로 해결되지 않습니다. 장애 대응 중에 "지금 어느 층이 잘못됐지"를 따지고 앉아 있을 시간을 생각하면 더 그렇고요. 물론 이 판단이 틀렸다고 나올 수도 있습니다. 다행히 cacheHandler를 나중에 붙이는 건 CDN 레이어도 무효화 경로도 건드리지 않는 더하기 작업이다보니, 지표가 아니라고 말해주면 그때 붙이면 됩니다. 오리진은 어떻게 보호할 것인가 CDN을 Cloudflare로 빼는 순간 생긴 숙제입니다. CDN에서 오리진으로 가는 구간이 인터넷을 타게 되다보니 오리진을 그냥 열어둘 수는 없는데요. 후보는 두 가지였습니다. Cloudflare Tunnel — 오리진에 cloudflared를 띄워 아웃바운드 전용 연결만 맺는 방식 ALB + IP allowlist — 공개 엔드포인트는 두되 Cloudflare 대역만 통과시키는 방식 이 영역은 제 챕터 밖이라 인프라 개발자분께 여쭤보고 후자로 정해졌다는 답을 받았는데요. 보안 모델만 놓고 보면 Tunnel 쪽이 더 깔끔해 보였던 터라, 왜 그렇게 결정됐을지를 제 나름대로 정리해봤습니다. (실제 근거와 다를 수 있습니다) 1️⃣ 우회 경로가 아예 사라진다 Tunnel은 공개 IP가 없는 구조다보니 오리진에 도달하는 경로가 Cloudflare 하나뿐이 됩니다. CDN 벤더에 장애가 나거나 긴급히 우회해야 할 때 DNS를 돌릴 대상 자체가 없는 것이지요. 벤더를 두 곳 쓰기로 한 선택의 대비책이 오히려 사라지는 셈이라, 이게 가장 크지 않았을까 싶습니다. 2️⃣ Cloudflare를 안 거치는 트래픽은 여전히 남는다 Tunnel은 인그레스 한 경로만 덮습니다. VPC 내부 서비스 간 호출, 헬스체크, CI/CD 스모크 테스트, 모니터링 — 이것들은 굳이 Cloudflare를 통과할 이유가 없지요. 결국 ALB는 두게 되고, 그러면 인그레스 경로가 둘로 갈라져서 보안 검토와 트러블슈팅이 오히려 늘어납니다. 3️⃣ AWS 쪽 통합을 포기해야 한다 AWS WAF, 타깃 그룹 헬스체크, ALB 액세스 로그, AWS Load Balancer Controller 기반 Ingress 구성까지 전부 다시 짜야 합니다. 모니터링 지표도 ALB는 기존 도구에 그대로 들어오는데 cloudflared는 별도 계측을 붙여야 하고요. 4️⃣ 용량에 하드 리밋이 있다 터널 하나당 replica와 연결 수에 상한이 있고, 처리량은 cloudflared 호스트의 포트 수에 묶입니다. ALB는 알아서 스케일하지만 이쪽은 용량 계획 대상이 되는 것이지요. 다만 IP allowlist만으로는 부족하다는 점은 짚고 넘어가야 할 것 같습니다. Cloudflare의 IP 대역은 공개되어 있고 모든 Cloudflare 고객이 공유하다보니, allowlist만 걸어두면 누군가 우리 오리진 호스트명을 알고 자기 Cloudflare 계정에 오리진으로 등록해도 그대로 통과하기 때문인데요. mTLS나 최소한 공유 시크릿 헤더 검증을 함께 두는 방향으로 논의해보려고 합니다. 캐시는 어떻게 갱신할 것인가 무효화 지점을 CDN 한 곳으로 만들었으니, 이제 그 한 곳을 잘 쏘는 게 중요해집니다. 방식은 Cloudflare가 제공하는 Cache-Tag 기반으로 정했습니다. 응답 헤더에 태그를 실어두고 콘텐츠가 바뀌면 해당 태그를 퍼지하는 방식인데요. 예전에는 Enterprise 전용이었지만 지금은 전 플랜에서 태그 퍼지를 쓸 수 있습니다. 여기서 주의할 게 하나 있는데요. 퍼지된 태그가 붙은 콘텐츠는 강제로 MISS 처리됩니다. 저희는 오리진에 영속 캐시를 두지 않기로 했다보니, 이 MISS는 곧바로 EKS의 렌더로 이어지지요. 즉 태그 하나에 몇 개의 페이지가 묶여 있느냐가 퍼지 1회당 재렌더될 페이지 수가 됩니다. 말로만 하면 잘 와닿지 않으니 예시를 들어보면요. 칼럼 데이터 하나가 이렇게 세 페이지에 걸쳐 있다고 해보겠습니다. A 페이지 — 질문 상세. 질문·답변·댓글 데이터에 더해 관련 전문가 칼럼 요약이 곁들여져 있음 B 페이지 — 그 전문가 칼럼의 상세 페이지 C 페이지 — 그 칼럼이 포함된 전문가 칼럼 리스트 페이지 셋 모두 id=2인 칼럼 데이터를 참조하고 있으니, 응답 헤더에도 column:2 태그를 달게 됩니다. 그러면 이 칼럼의 제목 한 줄만 수정되어도 column:2 퍼지가 발동하고, A·B·C 세 페이지의 캐시가 한꺼번에 날아갑니다. 이후 들어오는 요청은 셋 다 미스가 나서 오리진으로 향하게 되고요. 여기서 짚어볼 게 두 가지 있는데요. 먼저 A 페이지는 다소 억울하게 다시 그려집니다. 질문도 답변도 댓글도 그대로인데, 곁들여둔 칼럼 요약의 제목 한 줄 때문에 페이지 전체를 새로 렌더하게 되는 것이지요. 퍼지는 페이지 단위로 걸리다보니 "칼럼 요약 부분만 갱신"이라는 선택지가 없습니다. 그리고 C 페이지가 더 문제입니다. 리스트 페이지는 한 화면에 칼럼을 여러 개 얹다보니 column:2뿐 아니라 column:7, column:11 … 을 전부 달게 됩니다. 즉 목록에 올라온 칼럼 중 아무거나 하나만 바뀌어도 매번 퍼지 대상이 되는 것인데요. 하필 리스트 페이지는 그려야 할 게 많아 렌더 단가도 높은 편이라, 가장 비싼 페이지가 가장 자주 지워지는 그림이 됩니다. 그래서 태그를 columns처럼 엔티티 종류 단위로 굵게 잡는 건 애초에 선택지가 될 수 없습니다. 칼럼 아무거나 하나만 바뀌어도 칼럼을 참조하는 모든 페이지가 날아갈 테니까요. column:2처럼 id 단위까지 내려가는 게 최소 조건인 셈인데, 거기까지 내려가도 팬아웃은 그대로 남습니다. 렌더 비용을 아끼려고 시작한 설계인데 여기서 다 까먹을 수도 있는 부분이라, 태그 스키마 설계가 생각보다 중요한 작업으로 보였습니다. 반대로 태그를 너무 잘게 쪼개면 퍼지 요청 수가 늘어나 rate limit에 먼저 걸립니다. 요청당 처리 가능한 태그 수에도 상한이 있다보니, 변경 이벤트마다 개별 퍼지를 쏘는 게 아니라 짧은 윈도우로 태그를 모아 배치로 보내는 큐가 사실상 필수일 것으로 보입니다. 개인화 데이터는 어떻게 할 것인가 사실 이 부분은 이전 글에서 이미 한 번 제안했던 내용인데요. 당시엔 타 챕터와의 협업이 필요한 작업이었고 비즈니스 업무에 밀려 공수가 나지 않아 다음을 기약했었습니다. 이번엔 처음부터 설계하는 자리이다보니 아예 이 구조로 시작하기로 했습니다. 원칙은 단순합니다. 캐시 대상은 비회원 기준 shell 페이지뿐입니다. 여기서 shell이라고 부르는 건 CDN에 캐시되는 페이지 그 자체입니다. 유저 데이터가 입혀지지 않아 누가 열어도 똑같이 보이는 페이지라고 생각하시면 되는데요. 구체적으로는 이렇게 나눴습니다. 글의 제목·본문, 좋아요 수·조회수처럼 회원·비회원 공통 데이터는 SSR로 미리 렌더 좋아요/싫어요를 내가 눌렀는지 여부처럼 유저마다 달라지는 데이터만 별도 API로 분리 클라이언트에서 쿠키 기반으로 토큰을 판단한 뒤 그 API를 호출해 UI에 덮어씌움 이 구조의 이점은 두 군데에서 나옵니다. 먼저 캐시 사본이 하나로 유지됩니다. A 유저 정보로 렌더된 페이지가 CDN에 캐시되었다가 B 유저에게 서빙되면 곤란한데요. 애초에 shell에 개인 정보가 들어가지 않으니 이 문제가 생길 여지가 없고, 캐시 키에 쿠키를 넣을 일도 없습니다. 그리고 비회원은 개인화 API를 아예 트리거하지 않습니다. 비회원 입장에서는 어차피 좋아요 여부라는 게 존재하지 않다보니, 불필요한 데이터를 받지 않게 되는 것이지요. 검색 유입 기반이라 비회원 비중이 높을 것으로 보는 서비스에서는 이 차이가 꽤 클 것으로 보입니다. 그리고 한 가지 더 짚어둘 게 있는데요. 이 개인화 API가 향하는 곳은 Next.js 파드가 아니라 백엔드 API 서버입니다. 앞에서 계속 "오리진"이라고 불렀던 렌더 서버와는 아예 다른 계층인 것이지요. 그러다보니 회원이 늘어 개인화 호출이 늘어도 그 부하가 렌더를 담당하는 EKS 파드에 쌓이지 않습니다. 문서 트래픽과 개인화 트래픽이 서로의 용량을 잠식하지 않는 셈인데요. 프론트 쪽 파드는 엣지 미스만 처리하면 되고, 개인화 호출 뒤에서 어떤 캐시 전략을 쓸지는 백엔드 챕터에서 준비해둔 Redis가 받아주는 영역이라 이 글의 범위 밖이기도 합니다. 여기에 부수적인 효과가 하나 더 있습니다. 개인화를 걷어내면 shell이 그려야 할 게 줄어드니 렌더 1회당 단가가 내려갑니다. 3번에서 렌더 횟수의 상한을 포기했다고 적었는데, 횟수의 상한은 못 두더라도 단가는 낮춘 셈이라 두 결정이 같은 방향으로 맞물리게 되었습니다. 앞으로 검증해야 할 것들 여기까지가 설계 단계에서 내린 결정들이고, 아직 운영 데이터로 검증되지 않은 부분이 남아 있습니다. 스스로 못 박아두는 의미에서 적어둡니다. 1) 엣지 히트율과 오리진 렌더 횟수 요청 중 몇 퍼센트가 엣지에서 끝났는지, 그리고 오리진이 페이지를 실제로 몇 번 그렸는지 이 두 값이 3번에서 적은 교환이 유효한지를 검증해줍니다. 특히 미스의 출처가 명시적 퍼지인지, TTL 만료인지, 롱테일 페이지의 축출인지를 구분해서 봐야 할 것 같은데요. 저는 축출 쪽이 가장 클 것으로 보고 있습니다. 검색엔진은 유저를 최신 글로만 보내지 않다보니 조회가 넓게 퍼질 수밖에 없고, 그러면 PoP별 캐시에서 밀려나는 페이지도 같이 늘어나니까요. 축출이 지배적이라면 데이터가 바뀌지 않았는데도 재렌더가 계속 발생하고 있다는 뜻이고, 그때가 cacheHandler를 붙일 시점이라고 보고 있습니다. 역설적이지만 이 설계를 택하게 만든 서비스 성격이 동시에 이 설계를 뒤집을 근거가 될 수도 있는 셈이라, 이 지표는 특히 눈여겨보려고 합니다. 덧붙여 프론트 쪽 파드는 개인화 호출을 받지 않다보니, 오토스케일링 기준도 전체 요청 수가 아니라 엣지에서 미스가 나서 오리진까지 내려온 요청 수가 되어야 맞을 것 같습니다. 2) 카운트는 얼마나 밀려도 되는가 좋아요 수나 조회수는 누가 봐도 같은 값이니 공통 데이터고, 그래서 shell에 포함됩니다. 개인 데이터로 빠지는 건 "내가 눌렀는지" 여부뿐이지요. 그러다보니 캐시가 살아 있는 동안에는 숫자가 멈춰 있게 되는데요. 이건 설계상 불가피한 결과이고, 감수하기로 했습니다. 카운트가 1 올라갈 때마다 태그를 퍼지해버리면 5번에서 본 팬아웃이 그대로 발동해서, 렌더 비용을 아끼려던 목적과 정면으로 충돌하니까요. 인기 글일수록 좋아요가 자주 눌릴 텐데 그때마다 페이지를 다시 그린다면 CDN을 붙인 의미가 없어집니다. 다만 사용자가 직접 누른 액션만큼은 즉시 반영되어야 합니다. 좋아요를 눌렀는데 숫자가 그대로면 눌린 게 맞나 싶어지니까요. 개인화 API에서 "내가 눌렀다"는 값이 돌아오니, 클라이언트에서 그만큼을 카운트에 더해 그려주는 정도로 보정할 생각입니다. 결국 남는 질문은 TTL을 얼마로 둘 것인가 하나인데요. 숫자가 몇 분쯤 밀려도 어색하지 않은지는 서비스 성격과 유저 감각에 달린 문제라, 이건 운영해보면서 조정할 수밖에 없을 것 같습니다. 3) 태그 스키마와 Cloudflare 플랜 변경 이벤트 하나가 어디까지 영향을 주는지 — 상세 페이지 외에 목록·피드·프로필까지 — 를 먼저 그려야 태그 입도를 정할 수 있습니다. 5번에서 적었듯 태그를 굵게 잡으면 재렌더 범위가 커지고, 잘게 쪼개면 퍼지 요청 수가 늘어나는 트레이드오프가 있어서요. 방향 자체는 자명합니다. 렌더 비용을 아끼려고 시작한 설계이다보니 요청 수보다 재렌더 범위를 줄이는 쪽에 무게를 두는 게 맞겠지요. 문제는 그러려면 퍼지 요청이 늘어나는 걸 받아줄 수 있어야 한다는 것인데, 퍼지 rate limit은 플랜에 따라 차이가 꽤 큽니다. 그래서 예상 퍼지 빈도가 나와야 필요한 플랜도 정해지는 순서라, 이 둘은 같이 결정할 예정입니다. 물론 태그를 잘게 쪼갠다고 해서 공짜는 아닙니다. 응답 헤더에 실을 수 있는 태그 총량에도 상한이 있다보니, 5번의 C 페이지처럼 목록에 걸린 항목을 전부 태그로 박는 설계는 또 다른 벽에 부딪힙니다. 리스트 페이지는 아예 캐시 전략을 달리 가져가야 할지도 고민해볼 지점이고요. 그리고 태그를 누가 붙일 것인가도 정해야 합니다. 페이지마다 손으로 태그를 나열하는 방식이면 A 페이지처럼 곁들여진 데이터의 태그를 빠뜨리기 쉬운데요. 태그가 누락되면 퍼지를 쏴도 그 페이지는 안 지워지고, 결국 TTL이 끝나거나 축출될 때까지 옛 제목을 보여주게 됩니다. 데이터를 조회하는 지점에서 태그가 자동으로 따라붙는 구조로 가야 할 것 같습니다. 정리 이전 글을 쓸 때만 해도 답은 정해져 있다고 생각했습니다. 정적 렌더링으로 바꾸고 CDN을 붙이면 되는 것 아닌가 싶었지요. 그런데 실제로 처음부터 설계를 해보니, 정답을 찾는 일이라기보다는 무엇을 포기할지를 고르는 일에 가까웠습니다. 저장소를 끼우면 렌더 비용에 상한이 생기지만 무효화가 두 뎁스가 되고, 걷어내면 무효화는 단순해지지만 렌더 비용에 상한이 없어집니다. 어느 쪽도 공짜가 아니었고요. 그래서 제가 결정권을 쥔 영역에서 기준으로 삼은 건 성능 수치가 아니라 운영에서 사람이 개입해야 하는 지점을 줄이는 것이었습니다. 저장소를 끼우지 않은 것도, 개인화를 클라이언트로 뺀 것도 결국 같은 얘기고요. 그리고 그 판단이 틀렸을 때 되돌릴 수 있게 남겨두는 것까지가 한 세트라고 봤습니다. 이 작업이 저에게 의미가 있었던 건, 그동안은 이미 깔려 있는 인프라 위에서 최적화를 해왔는데 이번엔 그 밑단부터 직접 정해봤기 때문인 것 같습니다. 이전 글에서 이렇게 바꾸겠다고 적었던 계획을 백지에서 다시 검토하니 다른 결론이 나왔다는 것도 재밌었고요. 물론 아직 검증되지 않은 가정 위에 서 있는 설계입니다. 운영 데이터가 쌓이면 이 판단들이 맞았는지 다시 회고해보려고 하는데, 특히 3번에서 적은 교환이 실제로 남는 장사였는지가 제일 궁금하네요ㅎㅎ

  • 내가 코드를 주기적으로 다시보는 이유
    회고
    2026.05.25

    내가 코드를 주기적으로 다시보는 이유

    첫 커리어, 특이한 도메인 경험 제 첫 커리어는 약 4년 전, 디지털트윈을 산업현장에 접목시켜 소프트웨어를 만들어 납품하는 기업에서 시작했습니다. 당시 KAI(한국항공우주), UIPA(울산정보산업진흥원)같은 공공기관과 주로 협업하였고 이들이 요청한 산업 환경을 3D로 구현하여 현장의 위험요소나 특정 설비의 결함이나 배터리 부족 등 특수 상황을 감지하여 관리자가 현장 전반 상황을 컨트롤 할 수 있게하는 서비스를 만들었습니다. 3년도 더 지난 일을 회고해볼까하는 이유는 첫 회사가 했던 일이 워낙 유니크한 도메인이기도 했고, 입사하기 이전부터 고착화되어 있던 보일러플레이트를 개선하여 주니어로서 커리어 처음으로 프론트엔드 팀 코드베이스에 기여한 것 이 아직 기억에 남기 때문입니다. 무엇이든 처음이 가장 기억에 남는 것 같네요 ㅎㅎ 그리고 이때의 경험으로 코드베이스를 주기적으로 다시 보게 되는 습관이 들었거든요. 제가 처음으로 담당하여 맡았던 일인 UIPA라는 기관이 주관하는 선박 모니터링 프로젝트 중 있었던 일입니다. 보일러 플레이트와 새로운 시선 1) UIPA 프로젝트의 내용 선박 연료탱크에 연료가 얼마나 남았는지, 부품의 배터리가 얼마 남았는지, 물탱크 수위, 증기기관의 열 온도가 안정적인지, 선체의 기울기, 경도 위도 등등 전반적인 선박의 운항 상태를 모니터링하기 위해 웹으로 대시보드를 띄워줘야 했습니다. 2) 보일러플레이트의 구조 기관의 수주를 받아 작업을 진행하게 되면 프론트엔드 팀에서는 메인 페이지에 iframe으로 별도 페이지를 하나 더 띄우고, 주기적으로 백엔드로부터 데이터를 받는다 SignalR 이라고 하는 C# 생태계의 소켓 통신방식으로 웹에 1분마다 주기적으로 데이터를 공급합니다. 그리고 3D 팀이 넘겨준 Unity 3D 빌드파일을 이 iframe에 WebGL 라이브러리로 렌더링한다. Unity가 웹에 렌더링되고 유저가 3D 요소를 클릭하면 그 요소의 id 정보를 iframe으로 보낸다. iframe은 이 id 정보를 받아 메인 페이지로 전송(postMessage)한다. 메인 페이지는 iframe이 보낸 id를 받아 공급받은 데이터셋에서 찾아서 위젯 UI로 띄운다. 구조의 굳어진 보일러 플레이트로 작업을 진행했었는데요. 보시듯 iframe이 플로우 중간중간마다 끼어 브릿지 역할을 하고 있습니다. 결론부터 얘기하자면 입사초기부터 iframe의 존재 이유에 의문이 있어서 제거를 건의하였고, 컨펌받아 평균적으로 1200줄 이상 쓰이던 iframe 레이어 없이 구현할 수 있었습니다. 다른 프로젝트였던 KAI에서 이 iframe 레이어를 위해 작성되었던 코드 라인이 1200~1300줄이었던 것을 기준으로 하였습니다. 3) 보일러플레이트의 개선 리더분 말씀으로는 예전에 모종의 이유가 있어 iframe로 감싸는 형태로 만들었는데 지금은 그냥 이 형태가 룰처럼 굳어져와서 계속 사용하고 있다고 하셨습니다. iframe을 유지해야 할 마땅한 근거도 없고, 코드 가독성과 유지보수의 확실한 개선이 예상되어 PoC 진행을 컨펌받아 동작이상 없음을 증명하였고, 이 UIPA 프로젝트에서 적용하여 iframe 없는 구조를 완성하였습니다. 즉, 메인 페이지에 Unity 3D 빌드파일을 WebGL 라이브러리를 통해 렌더링한다. Unity가 웹에 렌더링되고 유저가 3D 요소를 클릭하면 그 요소의 id 정보를 메인 페이지로 보낸다. 메인 페이지는 이 id 정보를 받아 공급받은 데이터셋에서 찾아서 위젯 UI로 띄운다. 로 전체 플로우에서 2개의 단계가 제거된 것을 볼 수 있습니다. 새로운 시선으로 보려고 하는 이유 이렇게 커리어의 첫 프로젝트에서 새로운 관점을 통한 개선이 만족스러웠다보니 그 이후부터, 제가 지금 다니는 회사의 프론트엔드 리더는 아니지만 새로운 인력의 온보딩에 참여할 기회가 생기면 주로 저희 코드베이스의 개선점을 묻곤 합니다. 아무래도 재직한지 3년이 다 되어가는 저같이 고인(?) 사람보다는 새로운 동료가 본인이 최근까지 겪어왔던 색다른 관점에서 평가나 제안을 할 수 있기 때문이지요. 실제로 저는 지금 다니고 있는 회사의 온보딩 시기에 질문상세 페이지를 SSR로 구성하면서 Promise.all로 5개 정도의 API를 페칭 중이었는데, 그 중 정말 코어 데이터는 2개 정도에 불과했기에 설령 다른 것들이 문제가 발생해도 에러를 내지 않게끔 Promise.allSettled로 바꾸자는 제안을 하여 런타임 안정성에 기여했습니다. 또 작년에 새로 조인하신 분은 온보딩 시 Smart App Banner라는 생각도 못하고 있었던 부분을 제안해주셔서 웹에서 앱으로 인입시키는 기점을 마련해주기도 하셨습니다. 위 작업들이 인상깊었던 것은 모두 코드 10줄 미만의 어렵지 않은 사소한 변경임에 비해 임팩트가 있었다는 점입니다. 조직이 고착화되지 않으려면 이러한 환기가 참 중요하다고 생각됩니다. 그렇다고 매번 새로운 동료를 모실 순 없으니 저는 주기적으로 코드베이스를 한번씩 훑어보곤 합니다. 몇달 몇년간 있었던 코드지만 간혹 새롭거나 의아하게 느껴지는 부분이 있거든요. 그렇게 발견했던 것이 대표적으로 meta keyword입니다. 꽤 오래전부터 SEO에 유의미하지 않은데에도 여기에 API까지 붙여서 성심껏 구성하고 있었었죠. 처음 코드를 짠 사람도, 그 뒤로 손댄 사람도 'meta 태그니까 SEO에 좋겠지'라는 전제하에 의심을 하지 않았던 겁니다. 저 역시도 몇 달간 그 코드를 지나치고 못 봤었구요. 코드가 잘 굴러가기 때문에 보지 않게 됨으로서 발생하는 허점을 예방하고자 저는 주기적으로 코드를 다시 들여다봅니다. 첫 직장에서 iframe을 의심했던 그때처럼요

  • SSR 최적화는 어떻게 해야할까
    성능
    2026.05.11

    SSR 최적화는 어떻게 해야할까

    코어 웹바이탈 웹 개발자가 서비스를 운영할 때 유념해야할 것으로 코어 웹바이탈이라는 지표가 있습니다. LCP INP CLS 이 3가지는 유저 입장에서 불편함을 느낄 수 있는 항목들을 지표화한 것인데요. 전부 사용자가 직접 인지하는 결과 지표입니다. 콘텐츠가 다 보였는가 내 클릭에 반응했는가 레이아웃이 흔들리지 않았는가 같은 것들이지요. UX적인 이유에서도 이 성능지표들은 중요하지만, SEO 순위와 직결되는 이유이기도 하기에 자연유입이 중요한 제 현업 도메인에서는 유독 더 중요합니다. 그럼 이 코어웹바이탈 항목을 개선하기 위해서는 어떻게 해야 될까요? LCP를 구성하는 요소 공식문서를 보면 각 코어 웹바이탈은 여러 요인을 합쳐놓은 것으로 볼 수 있는데, 그 중 LCP는 TTFB + 리소스 로드 딜레이/시간 + 요소 렌더 딜레이를 계산한 것으로 볼 수 있습니다. INP도 여러 요인의 집합체로 공식문서 참고 따라서 LCP를 개선하려면 하면 이에 속한 TTFB를 개선하거나 리소스 로드 시간을 줄이기 위해 JS 청크를 줄여야하는 것이지요. 즉, TTFB가 600ms면 LCP는 절대 600ms보다 빨라질 수 없습니다 그 중에서도 오늘 메인으로 다룰 주제는 TTFB인데요. 작년쯤 이맘때 쯤 현업에서 진행했던 작업에 대해 회고를 해보려고 합니다. TTFB 개선기 1) 주요 페이지 문제 확인 목적 조직에서 비즈니스 기능을 새로 시작하는 시기여서 백엔드 작업이 시작된 와중 프론트쪽 시간이 살짝 비어서 간단히 할 수 있는 기능 개선점을 찾고 있었습니다. 객관적으로 문제점을 찾기 좋은 영역이 네트워크 탭이든 성능검사 탭이든 브라우저 도구를 통한 것이라고 생각했고, 개발자 도구도 살펴보고 페이지 소스 보기도 살펴보던 도중,, 의아한 부분을 발견했는데요 바로 서버로부터 하이드레이션되는 데이터가 3653줄이나 된다는 점이었습니다. 질문상세 페이지가 주요 페이지이긴 하지만 서버로부터 받고 있는 데이터가 페이지 구성에 비해 너무 많다보니 원인분석을 하였고 아래와 같았습니다. 웹서버에서 SSR할 때 필요없는 유저 정보 기반의 데이터까지 API 서버에서 가져오고 있다. 주요 비즈니스 데이터의 요청 수를 줄이기 위해 비효율적으로 많은 데이터 더미를 한번에 가져오고 있다. 이 두 문제는 문서의 크기가 비대해지게 하며 불필요한 API RTT가 발생하는 원인이므로 개선 시 SSR시 문서 응답 시간 즉, TTFB를 상당히 낮출 수 있는 포인트로 보였습니다. 2) 서버측에서는 불필요한 유저 정보 기반의 데이터 이 블로그를 만들 때에도 언급했었던 내용으로, CDN 캐싱을 염두에 두고 있었기에 서버측에서 유저 정보 기반 UI는 렌더링하지 않도록 구성하였었습니다. ex) 답변 평가 여부, 좋아요/싫어요 여부 등 만약 A라는 유저의 정보를 토대로 렌더링된 페이지가 CDN에 캐싱이 되었다가 전혀 다른 B유저에게 서빙이 되면 곤란하기 때문이지요. 그러한 이유로 SSR을 할 때 사용하지 않고 있음에도 API는 유저정보 기반 데이터를 응답하고 있었습니다. ex) downVoted, upVoted 물론 웹서버측에서는 이 API를 요청할 때 헤더에 토큰을 싣지 않고 있기도 하고, CDN 캐싱 기법이 적용되어 있지 않기에 문제가 발생할 위험은 없지만 API 입장에서는 유저 토큰이 없어도 관련 테이블을 조인을 해보거나 예외처리는 하고 있다는 뜻이고 더욱이 가장 자연유입이 많기도 한 질문상세 페이지에 들어올 때 마다 요청되고 있는 API이다보니 이는 개선 포인트로 보였습니다. 즉, 프론트 입장에서는 불필요하게 하이드레이션 되는 데이터를 줄일 수 있는 작업이고, 백엔드도 MSA구조에서 테이블 조인으로 인해 발생하는 RTT를 개선 or 코드 가독성을 개선할 수 있는 작업이라 판단되어 기존 API를 웹서버측에서 유저 토큰 없이 요청할 정적 데이터 기반 API 클라이언트에서 유저 토큰이 있을 때 요청할 동적 데이터 기반 기반 API 로 분리하는 것이 어떠할지 제안을 드려봤습니다. 이 전략은 주로 저희 서비스에 들어오는 유저는 검색엔진을 통한 자연유입이기 때문에 비로그인 유저가 많아 웹서버측에서의 요청만으로 대부분의 트래픽을 처리가능하다는 기존 장점을 살림과 동시에 가장 다이어트된 형태로 데이터를 제공한다는 것입니다. 다만 해당 기술 작업은 당시 비즈니스 업무의 편중으로 작업 공수가 나질 않아 다음을 기약하게 되었습니다. 밀리고 밀렸지만 올해 하반기 있을 질문상세 페이지 리뉴얼이 비즈니스 업무로 예정되어 있어 같이 진행 가능할 것으로 보입니다 ㅎㅎ 3) 필요한 데이터를 비효율적으로 가져오고 있다 서비스에는 토픽이라는 주요 비즈니스 데이터가 존재하는데 이를 질문상세 페이지에서 렌더링하기 위해서 약 250개의 데이터 배열을 API 요청하여 가져온 후 질문에 맞는 토픽을 찾아서 사용하는 비효율이 있었습니다. 예를 들면 제가 질문상세 페이지에서 필요한 것은 형사 토픽 하나인데 [민사, 형사, 가족이혼, 인사 , ...약 240개]를 모두 벌크로 가져와서 find하는 방식인 것이죠. 실제로 하이드레이션 되는 데이터 중 거의 2/3가 이 토픽 배열 데이터였다보니 앞서 다루었던 문제보다 더 큰 비중을 차지하고 있었습니다. 이 벌크 조회 -> 단건 조회로 수정 작업은 2)에 비해서 아주 간단하면서도 타 챕터와의 협업 없이 가능하다는 장점이 있어 바로 작업하였습니다. 운영환경 기준 성과 1) 기존대비 문서크기 6~7% 감소 개선 작업을 통해 하이드레이션 페이로드 라인이 3653 -> 634줄(88% 감소)이 되었고 운영환경에서 실제 등록된 인기 질문을 기준으로 SSR된 문서의 크기가 7KB~10KB 정도 (기존대비 약 6~7%) 감소하였습니다. 또한 자연스럽게 TTFB 시간에 큰 개선이 있었는데요. 네트워크 탭을 통해 클라이언트가 HTML 문서를 받는데에 걸린 시간을 비교해보니 아래와 같았습니다. 2) 기존대비 TTFB 70% 감소 1️⃣ 개선 전 2️⃣ 개선 후 브라우저 캐시를 끄고 여러 번 반복 측정했을 때 (네트워크 노이즈 때문인지) ±20ms 수준의 변동은 있었으나 TTFB가 기존대비 약 70% 감소하는 경향은 일관되게 관찰되었습니다 3) 컨테이너 메모리 사용량 개선 서버측 메모리 점유율에도 10% ~ 15% 정도의 상당한 개선이 있었습니다. 약 240개 가량의 토픽 배열을 매 요청마다 메모리에 올렸다 버렸다하는 현상이 사라졌기 때문으로 보입니다. 앞으로는 어떻게 해야할까? 1) 렌더링 기법 변화 1️⃣ 콘텐츠의 성격에 맞는 렌더링 방식인가 이번 글에서 SSR 하에서 최적화하는 방법에 대해 고민/개선하였지만 사실 지금 SSR 자체가 서비스에 적합한 방식은 아니라고 생각합니다. 질문상세 페이지의 메인 콘텐츠는 내용의 수정 빈도가 높지 않은 텍스트이므로, 특징상 서버에서 동적으로 매 요청마다 새로 처리해야 될 필요가 없기 때문인데요. 이렇게 SSR로 돌아가는 상황에서 아무리 더 최적화한다한들 본래 적합한 정적 렌더링 방식에 비해 구조상 서버 비용 낭비 지금 언급한 TTFB처럼 서버가 요청을 처리하는데에서 필연적인 성능 저하 가 따라올 수 밖에 없습니다. 2️⃣ 어떻게 바꿔 볼 예정인가 지금까지는 서비스 인프라에 너무 큰 변혁을 가져다주고 비즈니스 업무에 밀려 함부로 작업하지 못했으나 올해 하반기 예정된 주요 페이지 리뉴얼을 통해 근간을 아래처럼 바꿔보려고 합니다. 주기적으로 정적 페이지를 생성하는 ISR 답변이 달렸거나 유저나 어드민에서 콘텐츠를 수정했을 때 이 정적 페이지를 갱신하기 위한 On Demand Revalidation 어드민같은 외부 서비스에서 곧바로 서비스에 갱신 요청을 할 수 있도록 Route Handler로 On Demand Revalidation를 트리거하도록 구성 을 조합하는 방식으로 인프라 변경을 구상하고 있습니다. 장점은 역시 정적 페이지가 생성되었다면 런타임에서 렌더링 단계가 제거되어 코어웹바이탈 지표 향상 - UX 개선 및 SEO 순위에 이점 서버 인프라 축소 가능 - 비용 감소 를 꼽을 수 있습니다. 2) CDN 캐싱 정적 렌더링 방식이 성공한다면 추가로 CDN 캐싱을 선택할 수 있습니다. 위 인프라 변혁이 이루어진다면 Cloudfront를 통해 요청 시 거쳐야할 인프라 RTT를 더 줄일 수 있는데요. 즉 Cloudfront는 인프라 설계 레이어의 제일 앞단에 위치하다보니 요청이 들어왔을 때 곧바로 응답할 수 있게 되므로 TTFB를 더 개선할 수 있고 캐시로 인해 오리진 서버(Next 서버)를 거치는 횟수가 꽤 줄어들테니 데이터 트랜스퍼 비용 절감 효과도 기대해볼 수 있습니다. 오리진 서버에서 정적 서빙을 한다면 CDN까지 붙이는 게 너무 투머치가 아닐까 싶지만 서버의 리전이 서울에 있는 상태이고, 저희 서비스가 글로벌 트래픽을 완전히 무시할 수는 없는 수치인 듯하여 고려해야 된다고 판단했습니다. 정리 이 작업이 저에게 의미가 깊었던 것은 프론트엔드 사이드에서 잘 언급되지 않는 부분을 개선하여 최적화를 성공했기 때문이었습니다. 개인적으로 느꼈던 프론트 성능 최적화 글은 대부분 번들 사이즈, 이미지 최적화, 코드 스플리팅에 집중하는 것을 볼 수 있습니다. 문서크기와 그 안의 하이드레이션 페이로드는 시야 밖에 있는 경우가 많은 것이지요. 그 부분을 찾아 LCP 하한선이라는 코어웹바이탈 개선의 작업이 될 수 있었다는게 뿌듯했네요 ㅎㅎ

  • AI에 대한 가치관 변화
    AI
    2026.05.02

    AI에 대한 가치관 변화

    대 AI의 시대, 저 역시 클로드 코드 유저로서 PR리뷰 자동화 SKILL과 PR생성 자동화 SKILL등을 만들어 챕터에 공유하는 등 최근에는 주로 번거로우면서도 반복되는 업무를 자동화하기 위해 활용하고 있습니다. 그러다보니 2년전까지만 해도 AI에 유독 박했던 제가 여기까지 온게 인상깊기도 하여,, 제 가치관의 변동을 회고해보려 합니다. 저에게 있어 AI의 인식에 대해 큰 분수령이 두차례 존재했는데요. 2024년 말과 2025년 말 즈음으로 되돌아가보면 ~ 2024년 말 당시에는 AI 코딩 에이전트는 존재하지 않았던건지 개발시장에서 주류는 아니었고 LLM 위주로 사용하였는데, 그 중에서도 ChatGPT가 가장 메이저였습니다. 공식문서 링크를 걸어주고 단순한 설명을 시키는 용도로 사용하는 것은 무난했습니다. 예를 들어 tanstack query에서 캐시를 초기화할 수 있는 방법은 뭐야? 라고 질문을 하면 setQueryData, invalidateQueries 등이 있다면서 용도별로 잘 답변해주었지만 막상 실제로 개발을 하면서 닥치는 시나리오 기반으로 설명한다고 했을 때에는 맥락을 잘 이해하지 못하는 듯한 뉘앙스에 답변이 대부분이었어요. 예를 들면 tanstack query를 사용하여 피드 리스트를 인피니티 스크롤로 구성하고 있어. 피드 중 한 아이템에 좋아요를 누르면 관련 데이터를 캐시를 업데이트해야 되는데 리스트 전체를 새로 다시 요청하는 것은 비효율적일 것 같아. 좋아요를 누른 아이템만 업데이트하는 방법이 없을까? 라는 시나리오를 던지면 제가 원했던 한 아이템만을 갱신할 수 있는 setQueryData가 아닌 리스트 전체를 새로 요청하는 invalidateQueries를 답변하는 경우가 있었습니다. 그 외에도 제가 어떤 상황 기반의 질문이나 가설을 제시했을 때 잘못된 답변을 교묘하게 그럴듯한 근거를 들어 정답인 척하며, 의아한 부분이 있어 반론은 제시하면 그제서야 너 핵심을 찔렀어같은 식의 피드백으로 화를 돋구는 사용감이 대부분이었습니다... 위같은 상황을 겪었던 당시를 회상해보면 AI가 아직 사용할 수준은 아니구나/신뢰할 수 없구나라고 느낀 점을 3가지로 추려볼 수 있을 것 같아요. AI가 같이 컨펌해주었다는 안도감에 저도 모르게 맞다는 전제하에 진행할 수 있는 위험 제 말을 객관적으로 평가하지 않고 그저 어떻게든 맞다고 증명해주려고 애쓰는 듯한 느낌 너 핵심을 찔렀다고 하면서 말하는 답변조차 잘 포장된 거짓일 수 있겠다. 즉 제 답을 객관적으로 볼 수 있는 능력도 없고, 저 또한 이에 속아 큰 스노우볼이 굴러가 시간낭비, 더욱 심하면 서비스에 잘못된 코드가 심어질 수도 있겠다 싶었습니다. 따라서 저는 2024년 말까지는 AI가 창작하는 것을 배제하기 위해 공식문서같은 객관적인 답안지를 링크로 줄 수 있을 때 위주로만 사용하였습니다. ~ 2025년 말 제 기억상으론 새로운 AI 모델이 속속이 등장하던 시기였습니다. 이 시기쯤 유명 기업 채용공고에서는 AI 활용을 잘하는 분을 우대한다라는 말이 보이기도 시작하는 등 업계가 한층 더 AI를 중요하게 바라보고 있구나라는 생각이 들더군요. 공식문서 기반의 잦은 문답으로 신뢰를 회복해서 그런건지, 모델들의 퀄리티가 산업 전반적으로 향상되어서 그런건지 예전보다 타율이 꽤 좋아진 듯한 느낌을 받고 있었던 때입니다. 이 때쯤에는 주요 로직을 짤 때 제가 하면 복잡하지만 AI가 하면 잘할 것 같은 업무들을 LLM에게 맡겨보곤 했습니다. 공식문서 기반인 것은 여전하지만 그것을 기반으로 창작해야 하는 등 조금의 생각이 더 필요한 작업으로요. 다만 컨벤션을 준수하는 등 기존 코드베이스를 이해해야 되거나 개발 도메인을 설명할 필요없는 작업들 위주의 작업을 주로 시켰습니다. 예를 들면 캘린더 로직이 있었습니다. MDN의 Date 객체의 링크를 주고 타겟이 된 달에 며칠이 있는지 세어서 배열에 날짜를 담아두는 로직을 짜달라고 요청했는데 꽤나 좋은 퀄리티로 짜줬던 경험이 있었습니다. ~ 지금까지 1) AI 에이전트 등장 2025년 중후반쯤 됐을 새로운 종류의 AI 도구가 등장했는데요. LLM과 달리 내 컴퓨터에서 직접 돌아가는 AI 에이전트라는 신문물이었습니다. LLM을 사용하여 개발을 할 때 위에서 말했듯 기존 코드베이스나 개발 도메인을 이해하지 않아도 되는 부분에 한정해서만 사용했던 이유는 이런 규칙들은 기본적으로 양이 매우 많아 컨텍스트가 알맹이보다 서론이 더 길어 답변이 멍청해지는 할루시네이션 현상 심지어 매 세션마다 일일이 설명해야 됨 같은 문제로 인해 효율이 안나와서였는데 AI 에이전트는 프로젝트의 코드베이스를 읽을 수 있으므로 컨벤션 및 개발 도메인을 이해하고 있다는 전제하에 소통이 이루어진다는 것이 엄청 큰 메리트로 다가오더군요. 물론 메모리 파일로 가이드를 잡아주기도 해야하고 설명해야 하는 내용이 길수록 토큰 소비량이 많아 발생하는 비용문제 등 새로운 관점의 문제가 발생했지만 ㅎㅎ 장족의 발전이지요. 프롬프트를 짤 때 알맹이에만 집중할 수 있게 되었고, 할루시네이션도 많이 개선되었습니다 2) 자동화 방안 AI 에이전트를 활용한 작업으로 가장 보람을 느꼈던 것은 제가 직접하면 시간을 많이 잡아먹고 수고가 많이 들며 반복되는 작업의 자동화였습니다. 예를 들면 PR 올리기, 팀원 PR 리뷰하기가 있지요. 제가 한 작업에 대해서 일일이 PR 내용을 작성하는 것이 꽤 번거로웠습니다. 어떠한 기획의 일환으로 작업한건지, 주요 파일은 무엇인지, 예외 처리는 어떠한 이유에서 이루어졌는지 등등 팀원을 이해시키기 위한 작문의 양 자체가 길기도 하고 맥락을 고려해서 작성해야 하는데에 시간을 너무 많이 잡아먹는 문제가 있었고, 반대로 제가 팀원의 작업을 리뷰할 때 내용이 잘 적혀있지 않으면 맥락을 이해해야 하는데에 수고가 많이 들었습니다 따라서 AI를 통해 자동화하면 효율이 많이 좋아지겠다 싶은 생각이 들었습니다 리뷰나 템플릿에 따른 내용 생성처럼 일정한 기준으로 이루어져야하는 작업이야말로 인간보다 AI가 더 잘 할 수 있는 영역이니까요. 팀 컨벤션으로 두기도 좋은 내용이다보니 SKILL(클로드 코드 기준)로서 규칙만 잘 정의하여 하네스만 잘채워두면 팀원 모두가 행복할 수 있겠구나싶었습니다. 마침 또 당시 코드래빗 툴을 도입할지 말지에 대한 논의가 있었는데 비싼 리뷰 툴을 사용하는 것보다 경제적이면서도 니즈에 충족되는 방향이었습니다. 팀원들이 각자 다른 AI 에이전트를 쓰다보니 관점이 다양하다는 장점도 있었습니다. 실제로 PR 스킬 사용 2달차인 지금, 누락된 예외처리 부분에 대해 언급을 남기거나 문제 발생이 우려되는 부분을 짚어주는 부분에서 PR 리뷰가 운영에 미치는 영향력이 상승한 것을 볼 수 있었습니다. 월간 웹 버그 리포트 리뷰 자동화 도입 전인 2026년 1월: 7건 리뷰 자동화 도입 후인 2026년 4월: 2건 배포 횟수 차이 등 다른 요인도 작용했겠지만 제가 인지하지 못했던 nullable 케이스나 코드만 봤을 때에는 예상할 수 없었던 race condition으로 인한 버그를 사전에 짚어주는 등 분명 영향이 있었어요. 예를 들면 리스트 필터기능을 팝업으로 구현했을 때, 팝업의 노출 여부도 히스토리로 관리되고 있다보니 필터 적용 시 query string 변경과 팝업 닫힘 사이에 race condition이 발생할 수 있다는 점을 지적해준 적이 있었습니다. 앞으로 개발자는 어떻게 될까요? 최근 비즈니스 업무에서도 AI 에이전트를 적극 활용하면서 느낀 점은 앞으로 개발자에게도 기획자로서의 역량이 요구될 것 같다는 점이었습니다. 어떤 기능에 대한 TF가 시작된다면 기획자분으로부터 전체적인 기획서를 받게 될텐데요. 받은 기획을 한번에 AI에게 그대로 맡긴다면 컨텍스트가 길어져서 할루시네이션 현상이 발생한다던가 기획서상 세부 정책이 누락된 것을 그대로 구현할 위험이 있습니다. 따라서 저는 큰 기획을 적절한 기능 단위로 나눠서 그 각각을 AI에게 컨텍스트로 제공하기 위해 한번 정제합니다. 예를 들어 유저간 서비스 내 재화를 주고받을 수 있는 응원박스라고 하는 기능에 대해 기획서를 받으면 응원박스를 유저간 주고 받을 수 있는 방법 응원박스 전송이 노출되는 페이지 응원박스를 보낼 수 있는 유저 조건 응원박스와 다른 기능 간 플로우 POST API 엔드포인트 전송 에러 시 UI 표출 방법 전송 간 애니메이션 노출 방법 및 이미지 에셋 경로 내가 받은 응원박스는 어디서 보는지 받은 응원박스가 노출되는 페이지 어떠한 형태로 노출할 것인지 - 리스트? 캘린더? GET API 엔드포인트 조회 시 UI 표출 방법 등등 에이전트가 작업을 수월하게 할 수 있도록 나누고 세부 정책을 작성합니다. 이 과정에서 기획서에 누락되어 있던 정책(ex 정지 유저는 응원박스를 보낼 수 있는가?)을 발견할 수도 있지요. 이렇게 시나리오나 플로우, 기능에 대한 요구사항을 잘 정리하는 것이 AI 에이전트 코딩 시대에서는 부각되는 능력인 것 같습니다. 코드를 짜던 사람에서 코드를 어떻게 짜야하는지 설명하는 사람으로 메타가 변하고 있기 때문이지요. 여러분들은 어떻게 생각하시나요?

  • 피드 성능 개선기
    성능
    2026.04.24

    피드 성능 개선기

    1년반전 즈음 더욱더 주니어였을 시기에 했던 서비스의 피드 성능 개선기를 회고해보려 합니다. 피드 리스트 성능 이슈 당시 기준 몇달 전 서비스 대규모 리뉴얼이 있었고 직후 피드에 심각한 성능 문제가 발생했습니다. 어느 페이지 구간부터는 로드에 짧게는 3초, 길게는 7초 등 적지 않은 시간이 걸렸고 슬랙에서 공론화되기까지 했습니다. 리뉴얼 중 피드를 담당했던 인력이 한달 전 퇴사를 하면서 대응에 공백이 생겼었는데 다행히도? 당시 브라우저 성능 최적화를 위해서 해보고 싶었던 시도가 있었던지라 업무 카드를 겟했습니다. 성능에 얼마나 하자가 있는지 실제 지표로 보려고 성능 모니터 탭을 켜보니 아니 웬걸.. 고작 7번째 페이지를 불러왔음에도 브라우저가 사용하는 CPU가 100%까지 치솟는 것으로 보아 렌더링을 하는데에 너무나 많은 연산이 이루어지는 것으로 예상되었습니다. 원인 찾기 인피니티 스크롤이 수차례 되었을 때 문제가 부각되는 것으로 보아 두 가지를 예상했습니다 1) 백엔드 API의 레이턴시🤔 고려해볼 법한 원인으로 오프셋 페이지네이션 체계에서 조회하려는 데이터의 앞 데이터를 SKIP하는 과정에서 느려지지 않을까 생각도 해보았는데요. 하지만 그렇다고 하기엔 고작 10번도 안되는 스크롤부터 바로 문제가 발생했었고, 순서상 앞에 있는 데이터가 최대 100개 밖에 안되는데 이렇게 느릴리는 없다고 판단하여 제외하였습니다. 네트워크창에서 봐도 실제로 문제가 될만큼 레이턴시가 발생하진 않았기도 하구요. 2) 과다 재연산 이슈✅ 새로운 리스트 데이터를 받아오면서 리스트 상태가 갱신되고, 이로 인해 부모인 리스트 컴포넌트가 리렌더링되면서 자식인 아이템 컴포넌트가 전부 리렌더링되는 것이 문제였습니다. 다만 흔한 프론트엔드 문제 패턴으로 이런 형태의 결점이 있다고 해서 이렇게까지 심각하게 버벅이는 경우는 잘없었는데 각 피드 아이템이 꽤나 많은 상태 관리 로직과 무거운 로직의 함수를 갖고 있던 것이 원흉이었습니다. 즉 아이템 컴포넌트에는 재연산 시 부담이 있는 좋아요, 싫어요 토글함수나 답변 보상 배율 계산같은 무거운 로직이 있어서 리렌더링 1회당 비용도 비싼 편이었고, 또한 아이템 컴포넌트에는 좋아요, 싫어요, 토픽, 유저정보 총 4개의 지역 상태가 존재했는데 부모에서 내려주는 이 값을 자식에서 상태로 관리하고 있었습니다. 따라서 데이터의 정합성을 맞추기 위해 자식에서 useEffect로 이 상태들을 업데이트하는 로직이 있었고 이로 인해 자식당 2번의 리렌더링이 발생하고 있었습니다. derived state 패턴(= 파생 상태)이라고 불리며 리액트는 이를 지양할 것을 강조합니다. 그리고 인피니티 스크롤이 트리거되어 부모의 리스트 상태가 갱신되면 리렌더링되면서 자식인 아이템 컴포넌트들이 모두 영향을 받는데 이 때 새로 불러올 아이템 외에 기존에 있었던 아이템들도 리렌더링이 발생하게 되는 것도 성능 악화에 큰 지분을 차지한다고 생각했습니다. 기존에 있었던 것들은 UI가 그대로 유지되어 리렌더링이 불필요한데도 부모로 인해 영향을 받게 되는 것이죠 즉, 인피니티 스크롤로 6번째 리스트를 불러온다고 예시를 들었을 때 기존 1~50번째 아이템 컴포넌트 새로 불러온 51~60번째 아아템 컴포넌트 자식에서 상태 정합성을 위해 사용한 useEffect 로 인해 총 (50 + 10) * 2 = 120번의 리렌더링이 발생할 수 있게 됩니다. 차차 문제를 해결했던 스토리를 풀어보면 리액트 derived 패턴 제거 부모에서 상태 갱신으로 자식이 리렌더링되는 것은 자연스러운 흐름이므로 가장 최후의 개선 요소라고 판단하여 자식 자체에서 발생하는 리렌더링 원인을 제거하기로 하였습니다. 좋아요, 싫어요, 유저정보, 토픽 총 4개의 값 모두 부모 리스트에서 뿌려준 데이터로 아이템에서 각각 useState 와 useEffect로 관리중이었으나 그렇게 관리할 필요가 없었습니다. 유저정보나 토픽은 런타임에서 AJAX 처리될 필요 없는 정보이므로 상태로 다룰 필요가 전혀 없었고 좋아요나 싫어요는 토글 기능이 필요하므로 상태로 컨트롤 되긴해야 하나 리스트 데이터가 이미 tanstack-query에 의해 캐시(상태)처리 중이므로 이 캐시를 조작하면 되어 useState와 useEffect를 사용할 필요가 없어져 제거했습니다. 그 결과 아이템당 리렌더링 횟수가 2회에서 1회로 줄어 6번째 리스트를 불러온다고 하면 총 리렌더링 횟수를 벌써 절반인 60회로 줄였습니다. 동시에 코드 가독성도 굉장히 개선됐구요. 리스트를 새로 받아오면서 발생하는 리렌더링 현상 제거 1) memo로 컴포넌트 메모이징 적용 6번째 리스트를 불러올 때의 리렌더링 횟수를 120번 -> 60번으로 줄였지만 여전히 아쉬운 점이 있죠. 새로 불러올 데이터는 51~60번째 컴포넌트에 해당하는 데이터이지만 기존의 1~50번째 컴포넌트는 전혀 달라진 사항이 없음에도 부모 컴포넌트의 리스트 상태값이 갱신되었다는 이유로 다시 리렌더링 되어야 합니다. 따라서 기존 컴포넌트의 데이터가 변하지 않았다면 리렌더링하지 않도록 memo를 적용합니다. memo는 부모가 리렌더링되었더라도 그에 속하는 자식 컴포넌트의 prop값이 변하지 않았다면 리렌더링 없이 이전 값을 그대로 사용할 수 있는 기법으로 특히나 이러한 리스트성 UI에서 큰 빛을 발휘합니다. 2) 리스트 데이터를 메모이징할 때 흔한 함정 memo를 적용해보면? 예상대로 동작하지 않습니다. 분명 부모가 리렌더링되기 이전과 똑같은 prop값을 받았을텐데 리액트는 다른 값을 prop으로 받았다고 인식한 것이죠. 왜일까요? 이 현상을 이해하려면 자바스크립트의 원시타입과 참조타입에 대해서 이해를 해야합니다. 라는 원시타입의 값을 할당한 변수가 있을 때 a === b 는 true입니다. foo === foo를 비교하는 것이므로 값이 같기 때문이죠. 그럼 아래 코드는 어떨까요? 라는 코드가 있을 때 a === b 는 false입니다. 겉으로는 값이 같아보이지만 내부적으로는 변수 a, b가 각자 가진 객체값은 별도의 메모리에 저장되고, 각 변수는 메모리 주소를 참조하게 되기 때문이죠 즉 a === b는 내부적으로 메모리주소A === 메모리주소B를 비교하는게 되어 false가 나오게 되는 것입니다. 이러한 것이 참조타입의 특징으로 자바스크립트에서는 객체, 배열, 함수가 참조타입으로서 취급됩니다. 따라서 부모의 리스트 상태값이 바뀌어 리렌더링되면 자식이 prop으로 내려받고 있던 함수도 재연산되어 메모리주소가 이전과 달라지므로 리액트는 이미 존재하던 리스트 아이템들이 이전과 다른 prop 값을 받았다고 판단하게 되어 리렌더링을 해버리게 되는 것입니다. 그럼 어떻게 해야되는 걸까요? 이런 참조타입의 데이터 자체를 메모이징해서 내려줘야 합니다. 즉, 부모의 상태가 갱신되어 리렌더링되어도 동일한 레벨에 존재하는 함수의 재연산을 막는 방법인 것이죠. useCallback을 이용해서 의존성 배열에 넣은 값에 변화가 생겼을 때에만 함수가 재연산되도록 하면, 이제 부모 리스트 상태값이 바뀌더라도 함수는 로직에 영향을 미치는 값이 변하지 않는 이상 재연산되지 않아 이전 메모리주소 값을 유지할 수 있게 되어 정상적으로 memo가 동작하게 됩니다. 이제 120번에 달했던 렌더링 횟수가 딱 새로 불러온 개수만큼인 10번으로 줄일 수 있게 되었습니다. 마무리 1) 브라우저 성능 최적화 확인 보이듯 약 15페이지까지 계속 스크롤을 내렸을 때 크롬 성능 모니터 탭 기준으로 As Is CPU 최고 100%, 메모리 최고 220MB To Be CPU 최고 20%, 메모리 최고 80MB 정도로 상당히 개선이 되었습니다. 브라우저 이외 웹뷰 환경에서도 유의미하게 줄어든 것을 볼 수 있었습니다. 웹뷰의 경우 자원을 과하게 사용하게 되면 앱이 크래시되거나 하얀 화면이 되어버리는 문제가 있었는데 운영환경 배포 후에 관련 CS가 줄어드는 결과도 얻을 수 있었습니다. 2) 메모이징이 능사는 아니다 메모이징은 참 좋아보입니다. 하지만 그렇다고 남용은 있어선 안돼요. 왜냐하면 연산한 값을 '메모리'에 저장해둔 후 리렌더링/재연산이 될 때 추가 연산없이 메모리에서 꺼내 재활용하는 방식이기에 결국 메모리 자원을 잡아먹으며 매 렌더마다 이전과 비교하기 위해 CPU도 사용하게 됩니다. 지금처럼 리스트 형태인 경우 대개 데이터가 몇 백개씩 렌더링에 관여될 때 관련 값들의 재연산으로 인해 CPU 부하가 심해질 수 있을 때에 도움이 되는 것이죠. 즉 대부분의 경우에서는 재연산을 하는 것이 성능적으로 우월할 때가 대다수입니다. 따라서 메모이징이 도움이 되는 경우는 예외적인 케이스라는 것입니다. 그러므로 정말 무거운 작업임이 확실한 상황에서만 도입을 고려해야 합니다.

  • 모바일에서의 모달 닫기 UX에 대해
    컴포넌트
    2026.04.03

    모바일에서의 모달 닫기 UX에 대해

    UI를 개발하면 항상 마주하는 녀석이 있습니다. 모달과 팝업 화면의 레이어를 무시하면서 나타나는 이 UI들은 디자인적 요소 외에도 UX적으로 고려해야 할 아주 중요한 요소가 있습니다. 바로 어떻게 닫을 것인가에 대한 처리입니다. 모달을 닫는 방법 모달 및 팝업(이하 모달로 통일하여 칭합니다)의 닫기 처리에 대해서 안드로이드/iOS 유저별로 대중적으로 학습되어 있는 상태가 다르기 때문인데요. 그 전에 우선 모바일에서 모달을 닫는 방법을 보면 흔히 아래 3개 정도가 있습니다. DIM(반투명 배경) 클릭 X버튼 클릭 OS에서 제공하는 뒤로가기 기능 사용 안드로이드: 하드백키 iOS: 스와이프백 1과 2는 애플리케이션에서 제공하는 기능이기에 OS환경이 다르더라도 웹뷰에서 동일한 로직으로 처리 가능하여 크게 신경쓸 필요는 없지만 문제가 되는 것이 3입니다. OS별 '뒤로가기' 기능에 대해 관점이 다르기 때문인데요. OS별 뒤로가기 기능에 대한 인식 차이 뒤로가기에 해당하는 기능인 안드로이드의 하드백키와 iOS의 스와이프백이 대중적으로 녹아있는 모습을 보면 안드로이드 유저는 하드백키를 누르면 모달은 닫고 페이지는 유지하는 것이 학습되어 있고, iOS 유저는 스와이프백을 하면 모달도 닫고 페이지까지 같이 뒤로가는 것이 학습되어 있습니다. 💡 물론 네이티브단에서 모달이 떠 있을 때에는 스와이프백 동작 자체를 허락하지 않게 하는 케이스도 있으나, 여기서는 순전히 웹뷰의 입장에서만 고려합니다. 즉, 우리는 웹뷰에서 모달을 구현할 때 유저의 경험을 존중하여 두 OS를 분기하여 로직을 작성해주어야 할 필요가 있습니다. 안드로이드는 하드백키 클릭 시 모달만 닫아야 하므로 모달을 상태로 관리하되, 브라우저 히스토리 스택도 추가적으로 쌓기 iOS는 스와이프백 시 모달을 닫으면서 페이지까지 같이 뒤로 가는 것으로 동작해야 하므로 모달을 상태로 관리 모달을 띄울 때 ‘상태’로 처리하는 것은 iOS/안드로이드 모두 똑같지만 안드로이드의 경우에는 히스토리를 하나 쌓아두어야 하는 셈이죠. 안드로이드에서의 추가 처리 따라서 이 로직을 공통적으로 처리하기 위해서 전역상태를 통해 모달 리스트를 관리하되, 안드로이드인 경우에만 pushState를 통해 보이지 않는 히스토리를 하나 추가해줍니다. 그리고 브라우저 뒤로가기 버튼이나 history api의 back으로 인해 pushState되었던 히스토리가 스택에서 제거되고 동시에 이로 인해 트리거될 popstate 이벤트를 두어 전역상태 모달 리스트에서 가장 마지막에 떠있는 모달을 제거하는 방식으로 로직을 구성합니다.
 마무리 이미 구현해본 사람들에게 크게 어려운 내용은 아닐 수 있으나, 웹뷰를 다뤄본 경험이 없는 프론트엔드 엔지니어라면 놓치고 지나갈 수 있는 부분인데요. 설계 초반에 반드시 공통 로직화하여 잡고가야 프로젝트가 진전되었을 때 피곤하지 않은 UX 기능이 되므로 꼭 참고하시길 바랍니다.
 저또한 현업에서 최근 모달UX 개선에 대한 필요성을 느껴 위 로직을 적용 시도하고 있는데 파일체인지가 상당하여 검증에 신중함을 기하고 있습니다. 최초에 설계를 잘해두었으면 어땠을까? 하는 생각이 들곤하네요 ㅎㅎ 이만 마무리하겠습니다.

  • SEO 순위에 대해 아시나요?
    SEO
    2026.02.19

    SEO 순위에 대해 아시나요?

    SEO는 왜 중요할까 내 사이트가 검색엔진에 잘 노출되기 위해서 라고 간단히 얘기할 수도 있습니다. 다만 현업에서 SEO 업무를 진행하면서 느낀 2가지 관점에서의 장점을 얘기해보자면 1) 기술적 관점 구글이나 네이버같은 SEO 프로바이더들은 막강한 영향력을 가지고 있습니다. 이 프로바이더들은 본인들의 검색엔진에서 내 사이트의 페이지가 SERP에 더 잘 노출될 수 있는 기술적인 가이드를 제공해주고 우리는 페이지를 구성할 때 이 내용을 표준으로서 참고하여 만드는 것이 권장됩니다. 기술적 영향력이 강한 프로바이더들이 제공하는 가이드이므로 신뢰도가 높기 때문에 시키는 것만 잘해도 내 웹사이트가 성능 좋게 잘 만들어질 수 있는 틀을 갖출 수 있게 됩니다. 예로 SEO를 위한 작업 중 흔히들 아는 서버측 렌더링(SSR)/정적 렌더링(SSG, ISR)을 이용한다던가, TTFB 개선을 위해 CDN을 이용한다던가 모두 가이드에 나와있는 내용인데요. 크롤러 봇은 유저입장에서 사이트를 평가하기에 SEO를 위한 것 = 유저를 위한 것이 된다고 볼 수 있습니다. 2) 마케팅적 관점 다른 마케팅 방법과 비교했을 때 돈들이지 않으면서 불특정 다수에게 서비스를 알릴 수 있는 가장 확실한 마케팅입니다. 구글, 네이버같은 사람들이 가장 자주 방문하는 서비스에서 제 사이트를 비용없이 노출할 수 있게 되는데 회사에서 보았지만.. 마케팅에 쓰이는 비용은 정말 장난이 아니었습니다... 지하철 광고 집행비, 강남 한복판 옥외 전광판 집행비, 광고 모델 섭외비 등 마케팅팀에 슬쩍 들은 비용만 해도 억소리나는 금액이었거든요. 돈을 그렇게 투자하더라도 성공이 보장되지 않는 시대이다보니 비용은 낮추고 접근성은 올릴 수 있는 SEO는 적어도 지금 제 현업 도메인에서는 최고의 마케팅이라고 느끼고 있습니다. SEO 순위를 높이는 방법 크롤링 색인 순위 로 나누어 볼 때 검색엔진에 노출 가능 조건인 색인까지는 어느 사이트든 대부분 문제없이 이루어집니다. 즉 '노출' 하는 것 자체만으로는 경쟁력이 크지 않으므로, 내 페이지가 그들보다 상단에 위치하도록 '순위'를 높이고 또 높은 순위를 바탕으로 내 사이트에 유입될 수 있게 CTR 지표를 개선하는 것이 SEO의 최종적인 목표라고 볼 수 있는데요. 구글링을 해봤을 때 흔히 나오는 대표적인 SEO 작업들 몇가지를 아래에 나열해보면 순위에 직접적으로 관여되는 것은 그리 많지 않습니다. 사이트맵 JSONLD 메타데이터 유저 친화적 URL 구성 몇 레퍼런스들은 이러한 작업들을 검색엔진 최적화 작업이라며 한번에 묶어서 얘기하고, 필수 작업인 것처럼 얘기하곤 합니다. 순위적인 관점에서 이걸 한다고 해서 구글에서 더 좋은 점수를 주거나 하지 않고, 노출적인 관점에서도 이 것들이 필수 요소는 아닙니다. 다만 대부분의 항목이 유저입장에서 SERP에 나온 결과물 중 내 페이지를 클릭할 확률을 높여주는 요소들이므로 (필수라기보다는) 반강제되는 옵션이라고 봐야합니다. sitemap을 예로 들어봅시다. 새로 만들어진 페이지를 크롤러에게 알려주는 행위가 sitemap인 것인데 만약 크롤러가 내 사이트의 새로운 페이지를 잘 찾아갈 환경이 링크 등으로 잘 구성되어 있다면? sitemap이 없더라도 크롤러는 찾아올 수 있습니다. 물론 내 사이트가 유명하지 않아서 크롤러가 접근할 진입점이 많지 않다던가 하는 이유로 페이지가 존재한다는 것을 선제적으로 알려주는 것이 좋은 옵션이라서 sitemap 작업을 할 뿐이죠. 즉 순위와는 연관이 없는 것을 언급하지 않고 SEO 작업이라고 묶어서 얘기한 것 + 노출과 관련해서도 필수인 작업은 아니므로 반은 잘못되었다고 생각하고, 그럼에도 해야하는 건 맞는 작업이라는 데에서 반은 맞다고 생각합니다. 그럼 이제 잘 알려진 작업들 중 실제로 순위와 연관이 있는 작업을 구분해보려고 합니다. 구글이 말하는 SERP 순위와 연관있는 최적화 기법 ✅ 1) 페이지 렌더링 방식 메이저 검색엔진들은 크롤러 봇이 페이지를 잘 인식하게 하기 위해서 웬만하면 서버측 렌더링, 정적 렌더링을 이용하라는 스탠스이지만 이러한 렌더링 방식 자체가 SEO 순위와 직접적으로 연결되어 있다는 언급은 없습니다. 다만 아래 3)에서 언급할 코어 웹바이탈 지표는 확실한 순위 반영 요소인데, 이 중 LCP나 INP가 이러한 렌더링 방식과 직결되는 지표이므로 '사실상' 직접적으로 기여된다고 판단하여 추가했습니다. 3)에서 묶어서 언급하지 않은 이유는 이 페이지 렌더링 방식 하나만으로도 하나의 주제가 나온다고 생각했기 때문인데요. 저는 보통 아래의 기준으로 페이지의 렌더링 방식을 결정합니다. 콘텐츠가 텍스트 위주라고 하더라도 질문/답변식으로 답변이 수시로 달리는 등 페이지 콘텐츠가 자주 변경되는지 일방적인 정보 제공인 블로그나 기사처럼 페이지 콘텐츠가 변경될 확률이 현저히 낮은지 등 콘텐츠의 성질에 따라 페이지 렌더링 방식을 선택합니다. 2) EEAT와 링크 링크가 과거보다는 엄청난 순위 결정 요소는 아니라는 언급이 보이긴 하지만 여전히 공식문서에서 언급되어 있는바 관련 논문에서 다른 많은 사이트에서 내 페이지가 링크되거나, 다른 유명한 사이트에서 내 페이지의 링크가 걸리게 되는 것은 SEO에서의 이점이라고 언급합니다. 콘텐츠의 질을 높이는 것도 순위 지정 요소이며, 내 페이지가 외부 사이트에 링크로 걸리는 것도 순위 지정 요소이기에 EEAT의 일환으로 내 페이지 콘텐츠 질을 높여 다른 거물급 사이트에서 내 페이지가 레퍼런스로서 언급되게 하는 것은 SEO상 큰 이점이 됩니다. 그리고 문서에서 내부링크냐 외부링크냐를 구분하지 않은 것으로 보아 제가 컨트롤할 수 없는 외부링크는 별론으로 하고 제 사이트의 페이지간 연결하는 내부링크도 적절히 활용하는 것이 좋아보이네요. 외부링크: 백링크와 동일어로 다른 사이트에서 내 페이지를 링크 내부링크: 내 사이트의 페이지에서 또 다른 내 페이지를 링크 💡 EEAT: 경험, 전문성, 권위, 신뢰성 3) 코어 웹바이탈 코어 웹바이탈로는 LCP, INP, CLS라는 3개의 주요 항목과 또 이 것들과 엮인 FCP, TTFB, TTI 같은 지표도 존재합니다. 위에서 SEO를 위한 것 = 유저를 위한 것라고 했는데요. 이 코어 웹바이탈을 위한 작업이 유저 경험을 개선할 수 있는 작업과 일치하기 때문입니다. 1)에서 언급했던 적절한 페이지 렌더링 방식 사용, CDN 캐싱, 레이지 로딩 등 웹 개발에 있어서의 많은 개념이 여기에 포함됩니다. 세부적인 작업에 대한 글은 아니니 개선 관련 공식문서만 남기고 넘어가겠습니다. 4) 시멘틱 태그와 title 모든 태그에 대해 시멘틱하게 작성해야 한다는 내용은 없지만 적어도 heading 태그에 대한 언급은 존재합니다. 문서의 아이덴티티를 나타낼 수 있는 정보인 title 태그와 heading 태그는 SERP 순위와 연관된 직접적인 요소로 작용하는 것으로 볼 수 있습니다. 구글이 말하는 SERP 순위와 연관없는 최적화 기법 🤔 1) sitemap 사이트맵은 위에서도 말했듯 검색엔진 크롤러에게 내 새로운 페이지의 링크를 알려주어 크롤링을 하라는 용도로 제공합니다. 이 작업을 했다고 해서 검색엔진에 색인까지 일사천리로 되어 SERP에 떡하니 노출되고 그런 것은 절대 아니고 '이런 페이지가 있으니 와서 얼른 크롤링해주세요' 느낌으로 내 페이지에 대해 진입점만 제공할 뿐입니다. 즉, 선제적으로 검색엔진에 내 페이지를 알려서 더 빨리 색인이 될 수 있게 돕는다는데에서 간접적인 이점이 있는 작업입니다. 페이지가 실제로 색인이 된다던가, 높은 순위를 받는다던가 하는 것은 별개의 이야기란 뜻입니다. 아직 규모가 작은 사이트에서는 검색엔진 콘솔에서 일일이 URL을 제공하여 요청을 하는 등으로 커버가 가능하지만 기업급의 규모이거나 UGC 사이트인 경우에는 어드민이 일일이 할 수 없으므로 사이트맵을 제공함으로서 보충하게 됩니다. 예시로 제 현업 도메인은 UGC 성격이라 하루에 몇천, 몇만개의 페이지가 생성이 됩니다. 사이트맵에 동적으로 이 새로운 콘텐츠의 링크를 추가하기 위해서 DB에 해당 데이터가 저장된 테이블의 로우를 전부 액세스하는 상당한 부하가 걸리는 작업이 진행됩니다. 실제 서비스되는 웹서버에서 이 작업을 하는 것은 서버가 터질 수 있으니 적절치 않고 그렇다고 별도의 서버를 띄우자니 비용적인 단점이 생기므로 AWS 람다를 통해 수행하고, 람다로 실행시킬 코드는 step function으로 event bridge에서 지정한 스케줄러로 새벽 3시반에 실행시키도록 하고 있습니다. 아직 여러 사정으로 테이블의 모든 로우를 조회하고 있지만 만약 비슷한 상황에 처한 분이 계시다면 콘텐츠가 오늘 날짜 이후로 update된 콘텐츠만 가져오는 식으로 DB 액세스를 하는 것을 권장드립니다.. 2) 리치스니펫(JSON-LD) 리치스니펫은 검색엔진에서 내 콘텐츠가 풍성(?)하게 보일 수 있게 합니다. 콘텐츠의 성격마다 적합한 리치스니펫이 있는데 질문/답변 위주의 글을 예시로 들자면 이렇게 답변을 부각한다던가 일반 검색결과와 다른 느낌을 제공합니다. 똑같은 주제의 글이 위 캡쳐처럼 SERP에 노출된다고 했을 때 유저입장에서 어느 글을 더 클릭하고 싶을까요? 당연히 윗 글일 겁니다. 대부분의 리치스니펫 UI가 구글에서 인증한 느낌이 들고 콘텐츠의 대한 정보를 미리 일부 제공하는 등 호기심을 유발할 수 있거든요. 리치스니펫 문법인 JSONLD를 적용한다고 해서 곧바로 내 페이지가 리치스니펫이 적용된 채로 보이게 되는 것도 아니고, 순위에 직접적으로 좋은 영향이 생기는 것도 아닙니다만 리치스니펫이 적용되었을 때 유저가 내 페이지를 선택할 확률을 높일 수 있으므로(= CTR 증가) 간접적인 이점이 됩니다. 💡 CTR(Click-Through Rate): 콘텐츠가 노출된 횟수 대비 사용자가 실제로 클릭하여 유입된 비율 3) title을 제외한 메타 데이터 메타 데이터는 해당 페이지에 대한 정보를 정의하는 데이터입니다. 대표적으로 검색엔진이나 SNS 공유 시 노출될 페이지의 정보를 담을 수 있습니다. 역시 이를 설정한다고 하여 직접적으로 순위에 영향이 생기는 것은 아니고 유저에게 내 페이지의 정보를 요약하여 제공함으로써 CTR을 올릴 수 있는 작업이므로 간접적으로 기여합니다. 4) 유저가 인지하기 쉬운 URI 사용 URI를 구성할 때 단순 id보다는 유저가 알아보기 좋은 slug형태로 구성하는 것을 권장합니다. ❌ /post/1 ✅ /post/how-to-make-pizza 구글의 순위 알고리즘 측면에서 도움이 되는건 아니지만 유저입장에서는 path가 slug형태로 보이는게 더 선택할 확률이 높으므로 간접적인 이점이 있다는 뜻으로 볼 수 있습니다. 개인적으로도 검색결과에 나타나는 브레드크럼이 페이지의 정보를 담고 있는 느낌이 들 때 더욱 클릭하고 싶은 마음이 들곤 했습니다. 정리 지금까지 SEO하면 떠오르는 작업들을 '순위'의 관점에서 공식문서를 통해 팩트체크해보면서 돌아보았는데요. 결론은 '순위'와 관련이 되었든 안되었든 공식문서에서 언급된 것들이면 다 하는 것이 좋습니다. 순위와 직접적인 연관은 없더라도 유저의 선택을 유발할 수 있는 요소들이라면 내 사이트에 유입되는데에 도움이 되니까요. 저는 SEO가 중요한 현업에서 해왔던 작업들이 실제로 얼마나 도움이 되는 걸까 싶어 호기심차 알아봤을 뿐이고 우리가 알던 작업들이 사실은 조금 도움이 되는 것이든, 많은 도움이 되는 것이든 해야하는 것은 변함없는 사실입니다. 다만 제가 공유하고 싶었던 것은 흔히 알려진 JSONLD, 메타데이터같은 작업들보단 비교적 덜 언급되는 코어 웹바이탈 개선을 위한 CDN같은 작업이 우리가 목표로 하는 최종적인 SEO에 더 도움이 된다는 것이죠. 시작에서도 말했듯 색인을 거쳐 '노출'시키는 것까지는 어느 사이트든 쉽게 할 수 있습니다. 다만 우리의 목표는 내 페이지가 유저의 시야에 들어 유입되게 하는 것이니까요. 그러기 위해선 SERP 상단에 노출되게 하는 '순위'라는 개념에 대해서 강조를 하며 글을 마무리하겠습니다 :)

  • 블로그 제작기 - 캐시 적용기
    블로그
    2026.01.31

    블로그 제작기 - 캐시 적용기

    서버컴포넌트의 등장이 불러온 새로운 캐싱 국면 1) pages router 에서의 데이터 기존 pages router 체제에서는 getServerSideProps나 getStaticProps같은 서버측에서만 실행되는 함수를 통해서 데이터를 불러오고 페이지에게 props로 넘겨주는 방식을 사용했었죠. 최상위에서 단 한 번의 데이터 요청만 발생하는 장점이 있었으나 페이지가 실제로 이 데이터가 필요한 자식 컴포넌트에게 넘겨주기 위해서는 tanstack-query같은 라이브러리로 클라이언트에 캐싱해두어 사용하지 않는 한 prop drilling 등 DX 차원의 문제가 있었습니다. 2) app router 에서의 데이터 이제 App Router에서는 리액트의 서버 컴포넌트를 이용하여 컴포넌트별로 데이터를 직접 페칭하는 방식을 사용합니다. 이 방식은 컴포넌트가 독립적으로 데이터를 처리하도록 만들어 prop drilling을 줄일 수 있는 등 개발 편의성을 높였지만, 그 반향으로 아래와 같은 문제가 발생할 수 있는데요. 여러 컴포넌트, 예약함수 등에서 같은 용도의 데이터를 중복 요청 발생 중복된 데이터 요청이 많아질수록 그만큼 네트워크 비용이 증가 따라서 Next는 이를 개선하고자 Request Memoization과 Data Cache 기법을 제공합니다. 편리하게도 Next는 fetch API에 기본적으로 이러한 기능들을 옵션으로 넣을 수 있게끔 해두어 간편히 사용할 수 있습니다. 하지만 저는 supabase client를 사용하고 있는데 공식문서에서는 이렇게 fetch가 아닌 DB client를 사용할 때 각 캐싱 기법을 사용하기 위해서 요구하는 함수로 감싸야했습니다. 뒤늦게 캐싱 확인 작업을 하면서 안 사실인데 supabase client도 리퀘스트 메모이제이션이 자동으로 적용이 되더라구요..? 하단 캐싱 적용 목차에서 자세히 보겠습니다 요구하는 함수는 위와 같고 아래부터는 캐싱 기법에 대해 알아보겠습니다. Request Memoization 알아보기 1) 현재 피드 페이지의 개선점 현재 피드 페이지에서는 모든 카테고리 데이터를 가져오는 fetchAllCategories API를 사용 중입니다. generateStaticParams에서 all-categories를 slug로 내려주기 위해 한 번 all-categories로 UI를 그리는 컴포넌트에서 한 번 으로 동일한 데이터임에도 중복 요청이 발생하게 됩니다. 더군다나 피드 페이지는 동적인 경로를 n개 정도 특정하여 정적으로 빌드하므로 페이지의 개수만큼 요청이 증가하는데요. 즉, /category/[slug]의 동적 경로 페이지에서 generateStaticParams가 반환할 값이 4개라고 할 때 generateStaticParams 자체에서 요청문 1번 정적으로 빌드될 페이지는 4개이므로, 각 페이지당 컴포넌트에서 요청문 4번 이렇게 총 5번이 호출되는 문제가 있습니다. 물론 정적으로 빌드할 피드 페이지 수가 4개 정도로 많지 않기도 하고, API가 런타임에서 수백 수천번 호출되는 것도 아니고, 당장은 slug가 몇 십개씩 추가될 일도 없어 빌드타임에 겨우 10번 미만 정도로 호출될 것이기에 크게 문제삼지 않을 수 있습니다. 그럼에도 slug가 100개까지도 증가할 수 있는 미래를 대비(?)하여 불필요한 요청은 최대한 줄여보자는 생각과 캐싱을 적극 도입하고자 하는 마음가짐으로 임해봅시다. 2) Request Memoization이란? 이러한 현상에 대한 개선책으로 Request Memoization이 있습니다. 컴포넌트, 페이지, 레이아웃, 일부 예약함수 등 어디서든 똑같은 API가 여러번 호출되었을 때 이를 묶어 단 한 번의 요청만 보내는 캐싱 방식입니다. ❗️ 다만 주의할 점! 리퀘스트 메모이제이션은 동일한 render pass에 한하여 발생하는 요청에 대해서만 적용됩니다. 동일한 render pass인 경우에만 리퀘스트 메모이제이션이 유효하다라는 말인데, 이 언급이 왜 중요하냐면 아래에서 볼 수 있듯 generateStaticParams는 페이지가 생성되기 전에 별도로 실행되기 때문입니다. 당연하긴 합니다. generateStaticParams를 통해 페이지를 몇 개 생성할 지 미리 알아둬야 페이지를 생성하는 행위가 가능해지는 것이니까요. 즉, 피드 페이지에서 n개의 정적 페이지 빌드로 인해 발생하는 n번의 fetchAllCategories API 요청을 리퀘스트 메모이제이션으로 하나로 묶는 데에는 유효하나, generateStaticParams에서 호출되는 fetchAllCategories API 요청까지는 하나로 묶지 못합니다. 페이지 생성과는 다른 render pass일 것이므로 별개로 보아 아무리 잘묶어도 적어도 2번의 요청은 발생한다는 것이죠 Data Cache 알아보기 리퀘스트 메모이제이션이 동일한 render pass에 대해 중복된 API요청을 방지하고자 하는 것이라면, 데이터 캐싱은 목적 자체가 백엔드로부터 가져온 데이터 보존에 있기에 서버에 항상 남습니다. 따라서 데이터 캐싱은 설령 다른 render pass라고 하더라도 같은 API 요청이라면 1회로 줄일 수 있게 됩니다. 즉, 이론을 지금 제 상황에 대입하자면 빌드 시에 fetchAllCategories API 요청은 아무 캐싱기법을 사용하지 않았다면 5번 리퀘스트 메모이제이션을 붙였다면 2번 데이터 캐싱을 붙였다면 1번 이 발생할 것입니다. 캐싱 기법 적용해보기 1) 아무런 캐시도 없이 빌드해보자 바로 빌드를 해보니 slug 개수만큼인 4개의 피드 페이지가 생성이 되었고, 그만큼의 API 요청횟수가 발생했는지 확인하기 위해 DB로그를 들어가보니..! 예상했던 결과인 DB 액세스가 5번이 아니라 2번만 로깅된 것을 볼 수 있습니다. 헛.. 당황스럽네요. 리퀘스트 메모이제이션같은 기능이 supabase client에도 자체적으로 있는 것인지, 아니면 내부적으로 fetch를 사용하여 Next가 이를 잡아 확장하고 있는 건지 리퀘스트 메모이제이션이 자동으로 적용된 모습입니다. 실제로 supabase client 라이브러리의 소스를 들어가보니 fetch를 사용하는 정황이 보이네요. 어떻게 이 메모이제이션을 제거할 수 있을지 고민해보았는데요. 공식문서에 AbortController를 쓰면 된다는 말도 있고해서 supabase client에 심어보는 등 이래저래 시도해보았으나 결국 해결책은 메모이제이션이라고 하는 매커니즘에 대한 돌파였습니다. 메모이제이션은 '같은 입력이면 같은 출력이 나온다'는 전제가 있어야 재사용할 수 있는 매커니즘입니다. 따라서 요청문 header에 요청마다 다른 값을 넣게 되면? 각 요청은 다른 입력이 되지 않을까? 라는 생각이 들더군요. 위처럼 코드를 수정한 후 다시 빌드를 한 후 DB로그를 들여다보니..! 의도했던 결과인 5건의 로그가 발견되었습니다! supabase client 자체적인 캐싱인건지, 아니면 Next에 의해 확장된건지 당황스러웠는데 메모이제이션의 특징을 생각해서 회피해본게 잘 들어먹혔군요 2) 리퀘스트 메모이제이션을 써보자 이제는 정말 리퀘스트 메모이제이션을 의도적으로 적용해보겠습니다. 헤더 세팅을 없애고 빌드를 해보면..! 예상했던 generateStaticParams에 의한 1건, 페이지 빌드단에서 리퀘스트 메모이제이션되어 1건의 로그까지 총 2건을 볼 수 있습니다. 참고로 제일 위에서 보았듯 명시적으로 react의 cache 함수로 요청문을 감싸서 리퀘스트 메모이제이션을 할 수도 있습니다. Next도 fetch 내부에서 이 cache 함수를 쓴 것이라고 하는데요. 저는 지금 supabase client가 의외로(?) 리퀘스트 메모이제이션을 지원해주므로 사용하지 않았으나 다른 DB 클라이언트를 사용하는 분들은 참고 부탁드립니다. 3) 데이터 캐싱까지 써보자 역시 fetch가 아닌 supabase client처럼 DB 클라이언트를 사용할 때 Next는 데이터 캐싱 사용을 돕기 위해 unstable_cache라는 기능을 제공합니다. 최근 use cache라는 지시자 방식도 있던데, 나온지 얼마 안 된 터라 더 안정적이어보이는 unstable_cache를 사용해보겠습니다. (이름은 unstable인데 그나마 stable해서 사용하는 상황이 아이러니하네요 ㅎㅎ) 빌드를 하고 DB 로그를 보니 의도대로 1번의 로그만 남은 것을 볼 수 있습니다. 원했던 결과를 얻었습니다. 리퀘스트 메모이제이션 더 자세히 보기 적용은 완료했으나 마지막으로 살펴볼 것이 있습니다. 위에서 저희는 의아한 것을 발견했었죠. 분명히 supabase client에 아무 것도 한 것이 없었음에도 자동으로 리퀘스트 메모이제이션이 적용된 현상이었습니다. 그 원인을 파악하고자 리퀘스트 메모이제이션쪽 소스코드를 분석해보았는데요. 중복 fetch 제거 관련 파일로 보이는 dedupe-fetch.ts를 보니 위에서도 말했듯 내부적으로 React.cache로 리퀘스트 메모이제이션을 구현한다는 것의 증거(빨간 박스)를 볼 수 있고, AbortController의 signal로 리퀘스트 메모이제이션을 제거할 수 있다는 등의 정황(파란 박스)이 여기서 보이네요 하단에 존재하는 이 부분이 중복 페칭을 하나로 묶는 곳으로 보입니다. cloneResponse가 리퀘스트 메모이제이션의 핵심 로직으로 보이는데요. 들어가서 보면 여기서 기존에 캐시된 페칭의 응답을 만들어 변수에 할당하여 사용하는 것을 볼 수 있습니다. 이러한 중복 요청 제거 함수를 patch-fetch.ts쪽 코드에서 globalThis.fetch에 재할당하여 fetch API를 확장합니다. 그리고 app-render.ts 부분에서 이 patchFetch 함수를 호출하여 로직이 적용되게 됩니다. 이쪽 소스코드 전반을 해석하기 위한 난이도가 좀 있지만 결국 중요하게 보면 되는 것은 NextJS가 리퀘스트 메모이제이션 기능을 붙인 fetch를 저희가 사용하게 될 네이티브 fetch에 global로 재할당하고 있다는 것입니다. 따라서 저희가 아무런 작업 없이도 fetch만 사용하면 리퀘스트 메모이제이션을 누릴 수 있는 것이었고, 내부적으로 fetch를 사용하는 supabase client가 자동으로 리퀘스트 메모이제이션을 갖게 된 이유가 여기 있었군요. 내용이 좀 길어졌네요. 다음 시간에는 역시 블로그를 위해 했던 작업으로 SEO를 직/간접적으로 개선할 수 있는 작업들에 대해 작성해보려고 합니다 :)

  • 블로그 제작기 - 정적 빌드와 SEO
    블로그
    2026.01.25

    블로그 제작기 - 정적 빌드와 SEO

    정적 빌드를 해보자 지난 글에서 제 페이지의 성격과 잘맞는 렌더링 방식을 탐구하였고, 정적 페이지 빌드와 풀라우트 캐싱(HTML + RSC Payload) 방식을 채택하였습니다. 이번 글에서는 정적 빌드를 하면서 겪은 점들을 SEO와 엮어 공유해보려 합니다 :) 빌드 명령을 내리면 Next는 명시적(라우트 세그먼트)으로 페이지 렌더링 방식을 지정하지 않는 한 몇몇 기제가 존재하는지에 따라 정적/동적 페이지인지 판단하여 빌드를 시작합니다. 저는 정적으로 빌드를 원하기에, 동적 빌드 기제만 주의하면 되었는데요. 피드 페이지를 빌드하는 과정에서 아래와 같이 의도치 않게 동적 빌드를 하게 되는 문제를 겪었습니다. Supabase SSR client의 cookies 사용 이 블로그는 모든 페이지가 SEO가 중요한 페이지로 서버측 렌더링을 활용하기로 했고, 따라서 데이터를 가져오기 위한 supabase client를 SSR용으로 미리 셋업을 해놓은 상태였습니다. 다만 이 client는 next/headers의 동적함수인 cookies의 사용을 요구하였는데요. cookies는 유저로부터 요청이 들어왔을 때 서버에서 쿠키로 받은 유저의 토큰 정보를 기반으로 페이지를 그리기 위한 용도이므로 런타임 서버에서만 사용할 수 있는 정보입니다. 그러기에 Next는 이 cookies라는 동적 함수를 호출한 페이지에 대해서는 항상 동적 빌드를 진행하게 됩니다. 제가 동적빌드를 하지 않는 이유는 아래와 같은데요. 피드 페이지는 cookies로 유저정보를 토대로 그려야 하는 페이지도 아니고 동적 렌더링 시 TTFB가 현저히 늦어지기 때문 정보 전달이 목적인 페이지이므로 여타 페이지에서도/가까운 미래에서도 유저정보를 기반으로 페이지를 구성할 일이 없어보인다는 점 나중에 좋아요나 싫어요같은 유저 정보가 필요한 기능이 추가되더라도 브라우저에서 유저측에서 직접 요청을 보내게하여 CSR을 할 것이기 때문 유저정보 기반 기능을 CSR로만 고려하는 이유? 추후 페이지의 CDN 캐싱을 고려하고 있기 때문입니다. 그게 무슨 상관이 있어요? SSR을 한다면 오리진이 될 내 Next 서버가 응답한 페이지가 CDN에 캐싱될 것인데요 이 때 유저정보가 입혀진 페이지가 캐싱이 되면 안되기 때문입니다. - ex) 좋아요 버튼이 눌러진 채로 페이지가 캐싱되는 것을 방지 따라서 유저정보가 입혀지지 않은 상태의 페이지를 유저가 응답받은 후 CSR을 통해 좋아요를 눌렀다는 것을 표시할 것 입니다. 따라서 SSR 클라이언트를 제거하고 일반 JS 클라이언트를 채택하였습니다. searchParams 사용 우선 앞서, 제가 왜 searchParams를 사용했는지 설명드리자면 1) 필터된 리스트 페이지의 인덱싱 피드에서는 글의 카테고리에 따라 리스트를 필터하는 기능이 있습니다. 저는 이 필터된 페이지들 각각이 모두 검색엔진에 인덱싱되길 바랬는데요. 그 이유는 현업에서 비슷하게 리스트 형태의 페이지가 있었고 카테고리에 따라 필터가 가능한 기능이 있었습니다. 기존에는 필터된 페이지들에 대해 캐노니컬로 하나로 묶어서 처리했었으나 💡 캐노니컬이란? 중복 콘텐츠 문제를 해결하기 위한 SEO 요소입니다. 예를 들어 https://example.com/products/shoes https://example.com/products/shoes?color=red https://example.com/products/shoes?utm_source=facebook https://www.example.com/products/shoes 같이 비슷한 주소가 여러개 있다고 할 때 검색엔진의 크롤링 리소스가 분산됩니다. 필터된 리스트 페이지 각각이 다른 의미로서 고유한 가치를 제공한다고 생각하여 캐노니컬을 제거하는 것을 원하였고 그 페이지에 대한 대규모 리뉴얼이 이루어질 때 필터별 페이지의 캐노니컬을 제거하여 모두 인덱싱이 가능하게 처리되면서 노출량이 급증하게 되어 지표에 좋은 영향을 끼쳤기 때문입니다. 2) 필터수단으로서 searchParams 사용하려 했으나.. 따라서 이 매커니즘을 블로그에 구현하기 위해 카테고리로 피드 리스트 페이지를 필터하는 수단으로 searchParams를 사용했는데요. ex) https://www.choiseongjun.com/?categoryId=1 이는 런타임에서 요청이 들어왔을 때 알 수 있는 동적인 값이라 일반적으로는 동적 빌드로 처리됩니다. 저는 path 역할을 하는 params가 미리 빌드타임에 특정 몇개의 값을 뽑아내어 정적빌드를 할 수 있도록 돕는 generateStaticParams를 갖고 있듯, 페이지의 변수값 역할을 하는 searchParams도 이에 상응하는 함수가 있을 것이라 생각했는데요... (얼추 generateStaticSearchParams라는 이름으로..) 아니더군요..🥲 params로 구분되는 path는 엄연히 페이지의 성격을 가지나, searchParams는 변수값정도로 여겨지기 때문이 아닐까 싶습니다. Next가 의도한 바는 SEO가 중요하면 개별 리소스로 표현해라. 즉, 핵심 콘텐츠는 경로로 표현해라라는 규칙을 강제한 것 같습니다. 그러니까 프레임워크인거죠. id대신 slug path를 사용해보자 정적 빌드를 위해서 generateStaticParams를 활용하기로 하였고, 처음에는 리스트 페이지를 필터할 식별자로 카테고리의 id를 고려했습니다. ex) https://www.choiseongjun.com/category/1 다만 공식문서 등 요새 SERP에 등장하는 정보 전달성 페이지를 보면 이렇게 유저 입장에서는 이해할 수 없는 식별자가 아닌 읽기 쉬운 유니크한 단어를 path로 쓰는 케이스가 많이 보이는데요. ex) https://f-lab.kr/blog/developer-blog-tips 이 것이 실제로 SEO 차원에서 의미가 있는지 문득 궁금해지더군요. 따라서 구글 검색엔진의 공식문서를 조사해보았습니다. 네. 설명 URL이라는 항목으로 명확하게 나와있네요. 네이버에서도 검색 친화적인 URL에 대해 언급한 바가 있구요. 즉, 구글과 네이버 모두 유저에게 도움이 될 만한 키워드를 URL에 포함시킬 것을 권장합니다. 다만 스크롤을 내리다보니 좀 모순되는 것 같은 의아한 워딩도 발견했는데요. 도움이 될 만한 키워드를 path로 구성하는 것은 유저 입장에서 더 많은 선택을 유발할 수 있으니 권장한다. 다만 검색엔진이 너의 페이지의 순위를 매기는 요소는 아니다. 라는 듯한 느낌으로 공식문서에서는 말합니다. 개인적으로도 /post/1보단 /post/how-to-make-pizza 라는 주소가 더 클릭하고 싶게 생겼긴 합니다. 결론은 기술적으로 장점은 없으나 유저 관점에서 클릭을 유발할 수 있으니 적용을 권장함이므로 적용하지 않을 이유가 없네요. 이렇게 id 대신 slug를 path 식별자로 적용을 하게 되었습니다. 그리고 비슷한 고민을 가진 포스트 상세 페이지에서도 마찬가지로 적용하게 되었습니다. ex) https://www.choiseongjun.com/post/caching-strategy-i-implemented 지금까지 정적 페이지로 빌드하기 위해 고민하며 적용한 것들을 정리해보았는데요. 내용이 좀 길어지는 것 같아 캐싱에 대한 내용은 다음 글에서 다루겠습니다 :)

  • 블로그 제작기 - 캐싱 고민
    블로그
    2026.01.22

    블로그 제작기 - 캐싱 고민

    블로그 만들기의 첫 걸음 일단 만들자 처음은 MVP 차원에서 성능은 고려하지 않고 UI/기능 구현에 집중하였습니다. 냅다 운영용으로 빌드를 해보았을 때 API 요청 용도로 세팅한 supabase server client에서 동적 함수인 cookies를 호출하고, 이 것이 전역적으로 사용되다보니 전부 동적 페이지로 빌드된 모습입니다.  이렇게 첫 빌드 후 페이지에 접근을 해보니 역시 느리네요..! 피드, 커리어 페이지를 각각 들어가보니 1.2초 정도로 데이터가 전무한 페이지임에도 TTFB에 꽤나 큰 시간을 잡아 먹고 있었습니다. TTFB : Time To First Byte의 약자로 네트워크 요청의 응답의 첫 번째 바이트가 도착하기까지 걸린 시간이다. 프론트에서는 HTML을 받고 그 문서 안에 담긴 리소스들을 요청하기에 보통 HTML 문서의 첫 바이트를 받기까지 걸린 시간을 뜻한다. 반드시 서버측 렌더링을 이용한다 문제의 원인을 짚기 전에, 내가 하고 있는 블로그 개발이라는 작업이 무엇을 위한 것인가를 고민해보면 블로그 콘텐츠는 사람들에게 공유되어야 하므로 무료 마케팅이라고 볼 수 있는 검색엔진에 노출되는 것이 가장 중요한 도메인 분야라고 생각합니다. 버전1 기준으로 현재 이 프로젝트에 존재하는 피드 페이지 커리어 페이지 포스트 상세 페이지 모두 다 SEO가 중요한 페이지이고, 따라서 검색엔진의 크롤러들이 내 콘텐츠를 더 잘 읽어갈 수 있도록 서버측에서 페이지의 정보를 담아둬야 하기 때문에 CSR은 배제하였습니다. 문제 규정과 해결책 고민 문제의 원인으로는 페이지가 동적 빌드되어 SSR로서 매 요청마다 서버에서 실시간으로 페이지를 그려야하기 때문이라고 판단했습니다. 따라서 정적 빌드와 Next가 제공하는 캐시 기법에 대해 고민했는데 이를 중심으로 해결 수단을 내보자면 1) 데이터 캐싱 🤔 데이터 캐싱은 한번 요청해서 받아온 응답 데이터에 대해 Next서버가 특정 시간동안 캐싱을 해두는 기법인데요. 즉 아래처럼 요청마다 DB에 액세스하게 되는 문제를 방지합니다. RTT를 줄일 수 있기에 너무나 확실한 개선 수단이라 후보 1로 두었습니다. RTT : Round Trip Time의 약자로 요청이 시작점에서 목적지로 갔다가 다시 시작점으로 돌아오는 데 걸리는 시간을 뜻합니다. 데이터 캐싱으로 API 요청을 스킵할 수 있어 RTT를 줄일 수 있습니다. 2) 풀라우트 캐싱 ✅ 1)에서 말한 매번 데이터를 요청하지 않고 캐싱해두는 것도 좋지만, 유저입장에서 더 가까운 단계에서 캐싱을 해두면 어떨까요? 일반적으로 유저가 SSR 페이지를 받기 위해서 유저가 프론트 서버에 요청 프론트는 요청을 받아 페이지를 그리기 시작함 페이지를 그리기 위해 데이터가 필요하다면 API 요청/응답까지 진행 하는 절차를 거칩니다. 위에서 TTFB가 1.2초가 걸렸던 이유는 이 모든 과정이 일어났기 때문입니다. 별 데이터가 없었음에도 1.2초는 꽤나 긴 시간이며 만약 제 블로그를 방문하는 유저도 많고, 이 페이지를 그리기 위해서 필요한 데이터가 많았다면 모든 유저의 각 요청마다 위 1~3의 플로우를 거친 후 응답하기에 프론트 서버도 바쁘고 DB도 많은 요청을 받게 되는 문제점이 있을텐데요. 데이터 캐싱으로 3은 생략하여 DB부하를 줄일 수 있겠지만 결국 서버까지 들어와서 캐싱해둔 데이터로 페이지를 그리는 과정이 필요하므로 서버에 대한 부하 위험은 여전히 존재합니다. 따라서 이보다 앞선 단계에서 페이지 자체를 캐싱해두면 서버가 페이지를 그리는 과정 또한 생략될 수 있겠죠. 즉, 위 1~3의 플로우를 거치지 않고 1에서 바로 응답을 할 수 있게 됩니다. 풀라우트 캐싱은 이러한 바램의 해결책으로서 미리 빌드단계에서 페이지를 정적빌드한 후 캐시해두었다가 들어온 요청에 대해 이 페이지를 유저에게 곧바로 서빙합니다. 현재는 이 풀라우트 캐싱이 적용되어 있지 않아 Cache MISS가 뜬 것을 볼 수 있습니다. 풀라우트 캐싱의 재생성 주기에 관하여 - ISR 제가 작성한 콘텐츠가 최초 공개 상태로 계속 유지되지 않을 수 있습니다. 수정으로 인해 내용이 바뀔 수 있고, 삭제로 인해 콘텐츠 자체가 삭제될 수도 있죠. 그런 변화에도 불구하고 풀라우트 캐싱된 페이지는 이러한 사항이 자동으로 반영되지 않고 변화 전 상태로 유저에게 서빙되는 문제가 있습니다. 그러기에 페이지를 주기적으로 재생성할 수 있도록 revalidation을 사용합니다. 다만 말그대로 주기적으로 재생성하는 것이기에 수정/삭제가 이루어졌더라도 그 주기가 지나지 않으면 기존에 캐싱된 페이지를 유저에게 서빙합니다. 만약 유저가 콘텐츠를 제작가능한 UGC 서비스라면 UX를 해치지 않기 위해서 유저의 수정/삭제 사항에 대해 페이지에 곧바로 반영해야 하므로 즉각 페이지를 재생성하기 위한 ODR까지 도입해야할 수 있으나 콘텐츠의 제작 권한이 오로지 어드민인 저에게만 있는 상황에서 즉각 재생성까지는 필요없다고 생각되네요. 유저 입장에서는 콘텐츠의 변화 사실을 당장은 알 필요가 없기 때문입니다. 즉, 수정/삭제된 페이지가 곧바로 반영될 필요는 없어서 ODR을 적용하지 않고 revalidation을 적용하는 선에서 마무리합니다. 💡 ODR: On-Demand Revalidation으로 정적 페이지를 유저가 콘텐츠를 수정/삭제 하는 등 원하는 시점에 재생성할 수 있는 기법 ODR이 정식 약어은 아니나 개인적으로는 ODR이라고 부르고 있습니다. 정리 1) 결론 결국에는 2가지 캐싱을 둘다 적용하기로 했습니다. 풀라우트 캐싱이 적용되었다면 데이터 캐싱이 크게 의미는 없을 수 있습니다만 해서 캐싱을 두 겹으로 두는 것이 나쁠건 없습니다. 이제는 풀라우트 캐시가 적용되어 Cache HIT가 뜬 것을 볼 수 있죠. 실제 개선된 시간을 같이 보자면 초기 HTML을 응답받는 시간이 20ms대까지 줄어든 것을 볼 수 있습니다. 최초 빌드 후 접근했을 때 걸렸던 1.2초(1200ms) -> 20ms 어마어마한 차이죠? 60배 정도의 차이가 납니다 2) 가치관과 캐시 저는 개발 가치관으로 늘 속도를 꼽는데요. 우리 프로젝트에게 느끼게 될 유저 경험도 큰 이유이지만 현업의 도메인 분야가 SEO를 중요시하였기 때문입니다. 2와 관련하여서 SEO와 속도와의 상관관계를 보자면 구글은 SERP에 노출하는 순위를 매기는 중요 지표로 코어 웹바이탈을 언급합니다. 그 코어웹바이탈 중 하나인 LCP 지표의 좋고나쁨은 페이지 응답 속도가 거의 대부분 좌우합니다. 페이지의 제공이 빨라야 그 다음 리소스들의 요청/응답도 따라오기 때문이죠. SEO가 곧 매출인 환경에서 일해왔던지라 속도를 위해 서버에서 네트워크 요청 수를 줄여보거나, 렌더링에 필요한 응답 데이터의 개수를 줄여보거나, HTML 문서 크기를 줄여보는 등 해봤지만 네트워크 홉 자체를 줄여버리는 캐시만큼 속도에 직빵인 것은 없어보입니다. 이 것이 캐시의 힘이고, 또 캐시를 어디서 하느냐가 중요한 이유입니다. 저 역시 현업에서 올해 하반기에 대규모 리뉴얼이 계획되어 있어 캐시 전략을 위한 인프라 설계 논의가 한창입니다 3) 추가적인 캐싱 방향성 위에서 언급한 캐싱들로도 충분해보이나 저는 AWS CF같은 CDN에 페이지를 캐싱해두는 것 까지 원하는데요. 풀라우트 캐싱을 걸어도 어쨌든 제 서버까지 요청을 보내야하는 것은 마찬가지인데, CDN을 붙이면 제 서버가 아닌 유저에게 가장 가까운 곳에서 리소스를 서빙받을 수 있습니다. 현업의 도메인에서 메인으로 다루는 질문/답변 페이지와 블로그 페이지는 ‘정보를 전달하는 글’이라는 특징상 노출이 중요하고, 따라서 SEO라는 공통점이 있기에 더 몰입하게 되었네요. 이렇게 풀라우트 캐시 + 페이지 revalidation (ISR)의 힘을 체감해보았고 실제 적용했던 과정은 다음 글에서 적어볼까 합니다. ...