<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>elseif</title>
    <link>https://ifelseif.tistory.com/</link>
    <description>개 발 자 로 살 아 남 기</description>
    <language>ko</language>
    <pubDate>Thu, 7 May 2026 21:39:26 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>adminisme</managingEditor>
    <image>
      <title>elseif</title>
      <url>https://tistory1.daumcdn.net/tistory/6277640/attach/39a9b1e7a3dc4e9ca171ec813fa7ba99</url>
      <link>https://ifelseif.tistory.com</link>
    </image>
    <item>
      <title>[260505 TIL] SQL DB 역사와 한국 웹 개발 궤적</title>
      <link>https://ifelseif.tistory.com/337</link>
      <description>&lt;h1&gt;SQL DB 역사와 한국 웹 개발 궤적&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude와 나눈 대화 정리. SQL 데이터베이스의 역사적 흐름과,&lt;br /&gt;그것이 한국 웹 개발 시장의 이야기와 어떻게 맞물리는지를 정리한 문서.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 시작 &amp;mdash; 관계형 모델의 탄생 (1970년대)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;1970년&lt;/b&gt;, IBM 연구원 &lt;b&gt;Edgar F. Codd&lt;/b&gt;가 논문 *&quot;A Relational Model of Data for Large Shared Data Banks&quot;* 발표.&lt;/li&gt;
&lt;li&gt;그 전까지 DB는 &lt;b&gt;계층형(IMS)&lt;/b&gt; 또는 &lt;b&gt;네트워크형(CODASYL)&lt;/b&gt; 구조 &amp;mdash; 데이터 구조와 접근 경로가 단단히 묶여 있어서 스키마 변경 시 애플리케이션도 다 고쳐야 했음.&lt;/li&gt;
&lt;li&gt;Codd의 아이디어: &lt;b&gt;데이터를 테이블(관계)로 표현하고, 어떻게(how) 가져올지가 아니라 무엇을(what) 가져올지만 선언하자.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;rarr; 선언형 쿼리 언어 = SQL의 철학적 뿌리.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;IBM은 &lt;b&gt;System R&lt;/b&gt;이라는 프로토타입을 만들고, 거기서 쓴 쿼리 언어가 &lt;b&gt;SEQUEL&lt;/b&gt; (나중에 SQL로 개명).&lt;/li&gt;
&lt;li&gt;그러나 IBM은 자기네 메인프레임 DB 사업(IMS)을 보호하느라 System R 상용화를 늦춤 &amp;rarr; &lt;b&gt;Oracle이 그 빈틈을 파고듦.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Oracle의 부상 (1977~1990년대)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;1977년&lt;/b&gt;, Larry Ellison이 IBM의 System R 논문을 읽고 먼저 상용화하기로 결심.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;1979년 Oracle V2&lt;/b&gt; 출시 &amp;mdash; &lt;b&gt;세계 최초의 상용 SQL 데이터베이스&lt;/b&gt;. (IBM DB2보다 4년 빠름)&lt;/li&gt;
&lt;li&gt;Oracle 30년 지배의 비결 = 기술 + 타이밍 + 영업력 + &lt;b&gt;친위대(DBA 생태계)&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;그 시절 DB는 정말 어려웠음 &amp;mdash; 메모리 부족, 디스크 IO 병목, 트랜잭션 정합성. Oracle DBA는 고연봉 전문직.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 오픈소스 진영 등장 (1990년대 중반)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비슷한 시기에 철학이 정반대인 두 DB가 등장.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MySQL (1995)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스웨덴에서 시작. 모토는 &lt;b&gt;&quot;빠르고 단순하게&quot;&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;초기에는 트랜잭션도 외래키도 없었음.&lt;/li&gt;
&lt;li&gt;웹 시대 게시판/블로그/초기 SaaS에 딱 맞음.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LAMP 스택(Linux + Apache + MySQL + PHP)&lt;/b&gt;의 M = MySQL &amp;rarr; 2000년대 웹 폭발기에 사실상 표준이 됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PostgreSQL (1996, 뿌리는 1986년 Postgres)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UC Berkeley의 &lt;b&gt;Michael Stonebraker&lt;/b&gt;가 시작한 학술 프로젝트가 뿌리.&lt;/li&gt;
&lt;li&gt;Stonebraker는 그 전에 Ingres라는 또 다른 초기 관계형 DB도 만들었던 인물.&lt;/li&gt;
&lt;li&gt;철학: &lt;b&gt;&quot;관계형 모델을 제대로, 확장 가능하게 만들자&quot;&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;처음부터 타입 시스템, 트랜잭션, 확장성, 표준 준수를 우선. 느리고 무겁다는 비판을 받으면서도 길게 감.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국 개발 현장의 디폴트&lt;br /&gt;이 시기 한국에서는 압도적으로 MySQL/MariaDB.&lt;br /&gt;내가 다뤄본 셋(MariaDB, MySQL, SQLite)이 모두 그 계열인 게 우연이 아니었다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. MariaDB의 분기 (2009)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;2008년&lt;/b&gt; Sun이 MySQL 인수 &amp;rarr; &lt;b&gt;2010년&lt;/b&gt; Oracle이 Sun 인수 &amp;rarr; MySQL이 Oracle 손에 들어감.&lt;/li&gt;
&lt;li&gt;MySQL 창립자 &lt;b&gt;Monty Widenius&lt;/b&gt;가 &quot;Oracle이 MySQL을 죽일 거다&quot;라고 판단, &lt;b&gt;2009년 MariaDB로 포크&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;이름은 그의 둘째 딸 이름에서 따왔다고 함...;&lt;/li&gt;
&lt;li&gt;지금 MariaDB와 MySQL은 사실상 다른 DB로 분기. 문법은 거의 호환되지만 옵티마이저, 스토리지 엔진, 클러스터링이 꽤 다름.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. NoSQL의 도전과 회귀 (2009~2015)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;도전기&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;2010년 전후로 &quot;관계형 DB는 빅데이터/웹스케일에 안 맞다&quot;는 주장이 폭발.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MongoDB, Cassandra, DynamoDB, Redis&lt;/b&gt; 등장.&lt;/li&gt;
&lt;li&gt;슬로건: &quot;스키마 없이 빠르게, 수평 확장으로&quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;회귀의 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5~10년 지나서 분위기가 바뀐 두 가지 이유:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;트랜잭션과 조인이 없다는 게 생각보다 큰 고통.&lt;/b&gt; 결국 애플리케이션 코드에서 다시 구현하게 됨.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PostgreSQL이 NoSQL의 좋은 점을 흡수.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JSON/JSONB 타입 (2012, 2014)&lt;/li&gt;
&lt;li&gt;배열 타입&lt;/li&gt;
&lt;li&gt;전문 검색&lt;/li&gt;
&lt;li&gt;지리 정보(PostGIS)&lt;/li&gt;
&lt;li&gt;&amp;rarr; 결과적으로 &quot;그냥 PostgreSQL 쓰면 되네&quot;가 됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 작업과의 연결&lt;br /&gt;내가 다루는 &lt;code&gt;pg_trgm&lt;/code&gt; GIN 인덱싱이나&lt;br /&gt;&lt;code&gt;search_words text[]&lt;/code&gt; + GIN &lt;code&gt;array_ops&lt;/code&gt; 최적화가 바로 이 흐름의 한가운데.&lt;br /&gt;관계형이면서 NoSQL적 유연성을 가진 DB가 PostgreSQL&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 클라우드와 분리 시대 (2015~현재)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AWS RDS, Aurora, Google Cloud SQL, Azure SQL &amp;rarr; DB 운영 자체가 서비스화.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스토리지와 컴퓨트의 분리&lt;/b&gt;: Aurora, Snowflake, Neon &amp;mdash; DB 엔진은 그대로, 스토리지는 분산 객체 스토리지에. (내가 RDS Proxy 다룰 때 마주친 흐름)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OLTP/OLAP 경계 흐림&lt;/b&gt;: ClickHouse, DuckDB, MotherDuck. 특히 DuckDB는 &quot;SQLite의 OLAP 버전&quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 지금 PostgreSQL의 위상&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 5년간 PostgreSQL은 사실상 &lt;b&gt;개발자 디폴트 DB&lt;/b&gt;가 됨. 이유:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;표준 SQL을 가장 충실히 따름&lt;/li&gt;
&lt;li&gt;확장성(extension 생태계)이 압도적&lt;/li&gt;
&lt;li&gt;JSON 같은 유연한 기능도 다 있음&lt;/li&gt;
&lt;li&gt;무료&lt;/li&gt;
&lt;li&gt;클라우드 어디서든 매니지드 서비스로 사용 가능&lt;/li&gt;
&lt;li&gt;pgvector 같은 확장으로 AI 시대 벡터 DB 역할까지 흡수&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Oracle &amp;rarr; PostgreSQL 흐름의 진실&lt;br /&gt;&quot;Oracle 자리를 PostgreSQL이 차지한다&quot;는 단순화된 주장은 과장이지만, &lt;b&gt;방향성 자체는 업계가 실제로 가고 있는 방향&lt;/b&gt;.&lt;br /&gt;신규 프로젝트에서 Oracle을 새로 도입하는 경우는 정말 드물고,&lt;br /&gt;대부분 PostgreSQL부터 검토하는 게 현실.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 한국 웹 개발 시장의 특이성&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&quot;한국은 거의 계속 Java로 일했어야 했다&quot;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;전자정부 표준프레임워크&lt;/b&gt;가 2009년부터 Java/Spring 기반으로 사실상 강제됨.&lt;/li&gt;
&lt;li&gt;정부, 공공기관, 금융, 대기업 SI는 거의 다 이걸 따라야 했음.&lt;/li&gt;
&lt;li&gt;한국에서 &quot;안정적인 일자리 = SI = Java&quot;라는 공식이 20년 가까이 유지.&lt;/li&gt;
&lt;li&gt;지금도 정부 입찰 공고는 Spring/JSP 기반이 압도적.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 나라랑 비교하면 특이함.&lt;br /&gt;미국/유럽은 2010년대 들어 Rails, Node.js, Django가 스타트업/중소 시장을 빠르게 잠식했다고 한다.&lt;br /&gt;한국은 그 흐름이 훨씬 느림? &quot;Java 아닌 걸로 일하려면 SI 메인 시장을 떠나야 했다&quot;가 한국 개발자들의 분기점이었을까?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&quot;JSP보다 LAMP가 핫했다&quot; (2000년대 중반)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JSP는 그때도 &quot;무겁고 설정 복잡하고 배포 귀찮은&quot; 이미지.&lt;/li&gt;
&lt;li&gt;PHP는 FTP로 파일만 올리면 바로 돌아갔고, 호스팅 비용도 쌌음.&lt;/li&gt;
&lt;li&gt;카페24 같은 한국 호스팅 업체들이 PHP/MySQL을 기본 상품으로 밂.&lt;/li&gt;
&lt;li&gt;개인 사이트, 중소기업 홈페이지, 커뮤니티는 거의 다 PHP.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그누보드, 제로보드(나중에 XE)&lt;/b&gt;가 한국 웹의 진짜 인프라.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;워드프레스의 SI 침투 (2010년대 중반~)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;2010년대 중반부터 한국 SI 중소 시장에 워드프레스 폭발적 유입.&lt;/li&gt;
&lt;li&gt;이유: &quot;홈페이지 + 블로그 + 간단한 관리자&quot; 정도면 처음부터 짜는 것보다 워드프레스 + 플러그인 + 약간의 PHP 커스텀이 훨씬 효율적.&lt;/li&gt;
&lt;li&gt;SI 입장에서 마진이 좋음 &amp;mdash; &quot;기획 1주, 개발 2주, 납품&quot; 회전율 가능.&lt;/li&gt;
&lt;li&gt;한국 PHP 개발자 상당수가 자연스럽게 워드프레스 커스터마이저가 됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Laravel &amp;mdash; PHP 진영의 &quot;현대화 선택지&quot;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;한국에서 Laravel은 PHP 개발자들의 &lt;b&gt;&quot;워드프레스나 그누보드는 너무 레거시&quot;라고 느낄 때 넘어가는 곳&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;Ruby on Rails 영향 받은 모던 PHP 프레임워크. MVC + ORM(Eloquent) + Blade 템플릿 + 마이그레이션.&lt;/li&gt;
&lt;li&gt;SI 중소 시장에서 워드프레스로 안 되는 좀 더 복잡한 프로젝트는 Laravel로 갔음.&lt;/li&gt;
&lt;li&gt;&amp;rarr; &lt;b&gt;내가 손댄 Laravel Blade 프로젝트가 바로 그 흔적.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 얻어 들은 이야기와 궤적&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이야기 요약&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;개발 경력 20년+. 2000년대 초반 시작, 주 업무는 &lt;b&gt;SI&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;정부/Java를 많이 안 했기 때문에 PHP LAMP를 잡을 수 있었음.&lt;/li&gt;
&lt;li&gt;워드프레스 &amp;rarr; Laravel 흐름을 그대로 탐.&lt;/li&gt;
&lt;li&gt;지금은 Python(FastAPI), NestJS도 다룸.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;판단력&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;NoSQL 시절에도 유행하는 거 다 해봤지만, &lt;b&gt;&quot;결국 SQL로 돌아갈 것&quot;이라고 봤음.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;신입~주니어가 새 기술을 &quot;이게 진리다&quot;로 받아들이는 것과 달리, 사이클을 두세 번 돌아보셨으므로 &lt;b&gt;매력과 한계를 같이 봄&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;팀 DB를 PostgreSQL로 바꾼 것도 그 판단의 결과. 트렌드 보다는 경험을 바탕으로 선택.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&quot;Next.js 철학은 이해는 되지만 따라가기 피곤&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;멘탈 모델&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;백엔드는 백엔드, 프론트엔드는 프론트엔드 &amp;mdash; 명확히 분리된 세계.&lt;/li&gt;
&lt;li&gt;PHP: 서버에서 HTML 렌더해서 던지면 끝.&lt;/li&gt;
&lt;li&gt;Laravel + Vue, FastAPI + React: API 서버와 클라이언트가 깔끔히 분리.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Next.js가 깨는 것&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;App Router, Server Components, Server Actions로 가면서 서버/클라이언트 경계를 의도적으로 흐림.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&quot;use client&quot;&lt;/code&gt;, &lt;code&gt;&quot;use server&quot;&lt;/code&gt; 지시어로 같은 컴포넌트 트리 안에서 경계가 왔다갔다.&lt;/li&gt;
&lt;li&gt;데이터 페칭이 컴포넌트 안에서 일어나고, 폼 제출이 서버 액션으로 처리.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 피곤하게 느끼시는가&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프론트에서 시작한 사람: &quot;오, 백엔드까지 자연스럽게 확장되네&quot; 느낌.&lt;/li&gt;
&lt;li&gt;백엔드에서 시작한 사람: &lt;b&gt;&quot;왜 잘 분리돼있던 걸 다시 섞지?&quot;&lt;/b&gt; 느낌.&lt;/li&gt;
&lt;li&gt;게다가 Next.js는 버전마다 패러다임이 바뀜 (Pages Router &amp;rarr; App Router &amp;rarr; Server Actions &amp;rarr; PPR&amp;hellip;).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;6개월마다 베스트 프랙티스가 바뀌는&lt;/b&gt; 회전 속도가 한 가지 패러다임으로 일관되게 일해오셨던 분께는 피곤할 것 같다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 내 위치 정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;나는 &lt;b&gt;NoSQL로 시작해서 SQL로 돌아온 세대&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;1세대 LAMP 개발자: SQL을 당연히 시작. NoSQL은 &quot;추가 옵션&quot;.&lt;/li&gt;
&lt;li&gt;내 세대: NoSQL을 디폴트로 배웠다가, 업계가 SQL로 회귀하는 흐름에 다시 적응.&lt;/li&gt;
&lt;li&gt;&amp;rarr; &lt;b&gt;&quot;NoSQL이 왜 매력적이었는지&quot;와 &quot;왜 한계가 있었는지&quot;를 둘 다 몸으로 아는 세대&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;SQL만 써온 사람은 도큐먼트 모델이 어떨 때 적합한지 감이 없고, NoSQL만 한 사람은 정규화의 가치를 모름. 둘 다 겪은 사람이 더 입체적인 판단 가능.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 메시지&lt;br /&gt;MongoDB는 잘못된 도구가 아니라, &lt;b&gt;잘못 권유받았던 시기가 있었다&lt;/b&gt;가 더 정확한 표현.&lt;br /&gt;지금도 이벤트 스토어, 로그, CMS, IoT 데이터 같은 영역에서는 좋은 선택지.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;관련 노트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;[[PostgreSQL GIN 인덱싱과 한국어 검색]]&lt;/li&gt;
&lt;li&gt;[[search_words text[] + array_ops 성능 최적화]]&lt;/li&gt;
&lt;li&gt;[[Prisma 듀얼 스키마 아키텍처 - smc-lens]]&lt;/li&gt;
&lt;li&gt;[[NestJS Better Auth 패턴]]&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>sql</category>
      <category>역사</category>
      <category>웹개발</category>
      <category>한국개발바닥</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/337</guid>
      <comments>https://ifelseif.tistory.com/337#entry337comment</comments>
      <pubDate>Tue, 5 May 2026 07:51:33 +0900</pubDate>
    </item>
    <item>
      <title>[260425 TIL] Next Image와 Preload를 활용한 이미지 최적화 2</title>
      <link>https://ifelseif.tistory.com/336</link>
      <description>&lt;h1&gt;Next Image 컴포넌트와 Preload를 활용한 이미지 최적화 2&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ifelseif.tistory.com/328&quot;&gt;지난번&lt;/a&gt; 에 이어서...&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원점 재검토&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;프리로드는 무슨 문제를 해결하는가?&lt;/li&gt;
&lt;li&gt;왜 &lt;code&gt;new Image()&lt;/code&gt;가 아니라 &lt;code&gt;&amp;lt;link rel=&quot;preload&quot;&amp;gt;&lt;/code&gt;인가?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IMAGE_SIZES&lt;/code&gt;를 컨테이너별로 분리한 게 왜 중요한가?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;img.decode()&lt;/code&gt;는 왜 결국 안 쓰기로 했나?&lt;/li&gt;
&lt;li&gt;첫 1장 high + 나머지 idle low로 나눈 이유는?&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 프리로드가 푸는 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;next/image가 화면에 들어오면 그때서야 이미지를 받기 시작한다. 모달이나 캐러셀처럼 &lt;b&gt;유저 클릭 직후 즉시 큰 이미지가 떠야 하는 케이스&lt;/b&gt;는 클릭 시점에 fetch가 시작되어 200~800ms 빈 화면이 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결: &lt;b&gt;유저가 클릭하기 전에 이미지를 미리 받아 놓기&lt;/b&gt;. 두 가지 시점에서 프리로드한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;그리드 카드 hover (ForesightJS가 마우스 궤적으로 클릭 의도 예측) &amp;rarr; 모달 본문 이미지 프리로드&lt;/li&gt;
&lt;li&gt;모달이 열리는 순간 useEffect &amp;rarr; 본문 첫 6장 프리로드 (이미 첫 1장은 표시 시작, 나머지는 스크롤 대비)&lt;/li&gt;
&lt;li&gt;oo 페이지 진입 시 &amp;rarr; 첫 6장 프리로드&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;next/image&lt;/code&gt;가 응답을 캐시에 넣어 두므로, 실제 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;가 마운트될 때 &lt;b&gt;새 fetch 없이 캐시 hit&lt;/b&gt;으로 즉시 paint된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. &lt;code&gt;new Image()&lt;/code&gt; &amp;rarr; &lt;code&gt;&amp;lt;link rel=&quot;preload&quot;&amp;gt;&lt;/code&gt;로 바꾼 이유&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이전 방식 (&lt;code&gt;new Image()&lt;/code&gt;)&lt;/h3&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;const img = new Image();
img.src = `https://yourdomain.com/_next/image?url=...&amp;amp;w=1200&amp;amp;q=75`;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 &lt;code&gt;img.src&lt;/code&gt;로 지정된 &lt;b&gt;단일 URL&lt;/b&gt;을 fetch해서 메모리/디스크 캐시에 적재한다. 단순하고 동작은 한다.&lt;br /&gt;&lt;b&gt;하지만 결정적 문제가 있다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제: next/image는 srcset을 사용한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;next/image는 이렇게 렌더된다:&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;&amp;lt;img
  srcset=&quot;
    /_next/image?url=...&amp;amp;w=640&amp;amp;q=75 640w,
    /_next/image?url=...&amp;amp;w=750&amp;amp;q=75 750w,
    /_next/image?url=...&amp;amp;w=828&amp;amp;q=75 828w,
    /_next/image?url=...&amp;amp;w=1080&amp;amp;q=75 1080w,
    /_next/image?url=...&amp;amp;w=1200&amp;amp;q=75 1200w,
    ...&quot;
  sizes=&quot;(max-width: 768px) 100vw, 768px&quot;
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 &lt;b&gt;현재 뷰포트 폭, DPR, sizes 힌트&lt;/b&gt;를 종합해서 srcset 후보 중 &lt;b&gt;하나만&lt;/b&gt; 골라 fetch한다. 어떤 후보가 뽑힐지는 디바이스마다 다르다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;iPhone (DPR 3, 폭 393px) &amp;rarr; &lt;code&gt;1200w&lt;/code&gt; 후보 선택&lt;/li&gt;
&lt;li&gt;데스크톱 (DPR 2, 폭 1440px) &amp;rarr; &lt;code&gt;1920w&lt;/code&gt; 또는 &lt;code&gt;2048w&lt;/code&gt; 후보 선택&lt;/li&gt;
&lt;li&gt;데스크톱 (DPR 1, 폭 1440px) &amp;rarr; &lt;code&gt;750w&lt;/code&gt; 또는 &lt;code&gt;828w&lt;/code&gt; 후보 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;new Image()&lt;/code&gt;로는 &lt;b&gt;개발자가 미리 한 후보를 추측해서 강제로 받을&lt;/b&gt; 수밖에 없다. 추측이 빗나가면:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;프리로드:    1200w 후보 받음 (캐시에 적재)
실제 &amp;lt;img&amp;gt;:  1920w 후보 선택 &amp;rarr; 캐시 미스 &amp;rarr; 새 fetch &amp;rarr; 프리로드는 헛수고&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;새 방식 (&lt;code&gt;&amp;lt;link rel=&quot;preload&quot; imagesrcset imagesizes&amp;gt;&lt;/code&gt;)&lt;/h3&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;const link = document.createElement(&quot;link&quot;);
link.rel = &quot;preload&quot;;
link.as = &quot;image&quot;;
link.setAttribute(&quot;imagesrcset&quot;, &quot;/_next/image?url=...&amp;amp;w=640&amp;amp;q=75 640w, ... 3840w&quot;);
link.setAttribute(&quot;imagesizes&quot;, &quot;(max-width: 768px) 100vw, 768px&quot;);
document.head.appendChild(link);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 &lt;b&gt;&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;와 동일한 srcset 알고리즘&lt;/b&gt;으로 후보를 고른다.&lt;br /&gt;뷰포트/DPR/sizes를 보고 자기 기준으로 1개를 선택해 fetch한다. 그래서 나중에 &lt;code&gt;&amp;lt;img srcset=... sizes=...&amp;gt;&lt;/code&gt;가 마운트되면 &lt;b&gt;자기가 골랐던 그 URL이 이미 캐시에 있다&lt;/b&gt;. 100% hit.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 통찰&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리로드의 정답은 &quot;어떤 URL을 받을지 정하는 것&quot;이 아니라&lt;br /&gt;&lt;b&gt;&quot;브라우저에게 후보 목록과 결정 규칙을 통째로 넘기는 것&quot;&lt;/b&gt;이다.&lt;br /&gt;그래야 실제 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;와 같은 결정을 내린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 이번 작업의 가장 중요한 부분이었다!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;imagesrcset&lt;/code&gt; / &lt;code&gt;imagesizes&lt;/code&gt;라는 속성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML 표준에 정식으로 있는 link preload 전용 속성.&lt;br /&gt;&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;의 &lt;code&gt;srcset&lt;/code&gt;/&lt;code&gt;sizes&lt;/code&gt;와 동일한 문자열을 받는다.&lt;br /&gt;브라우저가 link와 img의 매칭 규칙을 동일하게 적용하라고 만든 속성이다.&lt;br /&gt;같은 문자열을 두 곳에 넣어주는 게 정합성의 핵심.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. &lt;code&gt;IMAGE_SIZES&lt;/code&gt;를 컨테이너별로 분리한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;sizes&lt;/code&gt; 속성은 브라우저에게 &lt;b&gt;&quot;이 이미지가 표시될 슬롯의 폭이 얼마인지&quot;&lt;/b&gt; 알려주는 힌트다.&lt;br /&gt;부정확하면 브라우저가 잘못된 후보를 고른다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이전: 단일 &lt;code&gt;IMAGE_SIZES&lt;/code&gt;&lt;/h3&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;const IMAGE_SIZES = &quot;(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 그리드 썸네일/모달 본문/콘텐츠 상세 &lt;b&gt;모두에 똑같이&lt;/b&gt; 사용했다. 문제:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;그리드 썸네일&lt;/b&gt; (3, 6 열로 표시) 데탑에서 실제는 20vw인데 &lt;code&gt;33vw&lt;/code&gt;로 알려줌 &amp;rarr; 브라우저가 너무 큰 후보 fetch &amp;rarr; 데이터 낭비 + 느림&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모달 본문&lt;/b&gt; (&lt;code&gt;max-w-3xl&lt;/code&gt; = 768px 고정): 데스크톱 1440px에서 실제는 ~700px인데 &lt;code&gt;33vw&lt;/code&gt; = 475px로 힌트 &amp;rarr; 작아 보이게 만들고 있음. 브라우저가 작은 후보를 고르거나, DPR 2면 큰 거 고르거나... 일관성 없음&lt;/li&gt;
&lt;li&gt;**콘텐츠 상세 (가로 스크롤 카드, 데스크톱에서 ~1109px): &lt;code&gt;33vw&lt;/code&gt;로 힌트 &amp;rarr; 매우 부정확&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이후: 슬롯별 분리&lt;/h3&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;// 그리드 카드: sm 3열, md 4열, lg 5열, xl 6열
export const IMAGE_SIZES_GRID =
  &quot;(max-width: 640px) 33vw, (max-width: 768px) 25vw, (max-width: 1024px) 20vw, 16vw&quot;;

// 모달 본문: 모바일 풀폭, 데스크톱은 max-w-3xl=768px 고정
export const IMAGE_SIZES_MODAL =
  &quot;(max-width: 768px) 100vw, 768px&quot;;

// 콘텐츠 상세: 모바일은 95vw, 데스크톱은 ~1109px이라 1100px로 힌트해서 1200w 후보 노림
export const IMAGE_SIZES_POOLSOOP =
  &quot;(max-width: 768px) 95vw, 1100px&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 컨테이너가 자기에 맞는 sizes를 가지면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브라우저가 &lt;b&gt;딱 필요한 만큼&lt;/b&gt;의 해상도를 고름 &amp;rarr; 데이터 절약&lt;/li&gt;
&lt;li&gt;같은 sizes 문자열을 &lt;b&gt;link preload와 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 양쪽에 넘김&lt;/b&gt; &amp;rarr; 캐시 hit 정확성 보장&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;디테일 &amp;mdash; 그리드 카드 hover preload&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리드 카드에서 hover 시 프리로드할 때, sizes를 &lt;code&gt;IMAGE_SIZES_GRID&lt;/code&gt;가 아니라 &lt;code&gt;IMAGE_SIZES_MODAL&lt;/code&gt;로 줘야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리드 카드 자체의 썸네일은 이미 &lt;code&gt;priority&lt;/code&gt;로 처리되어 별도 프리로드가 필요 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;hover 프리로드의 진짜 의도는 &quot;다음에 열릴 모달의 본문 이미지를 미리 받는 것&quot;&lt;/b&gt; 이다.&lt;br /&gt;모달 sizes로 받아야 모달 마운트 시점에 캐시 hit이 일어난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 한 줄 차이지만, 의도와 sizes를 일치시키지 않으면 모든 노력이 무의미해진다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. &lt;code&gt;img.decode()&lt;/code&gt;를 결국 안 쓴 이유&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;decode()&lt;/code&gt;가 뭔지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지가 표시되려면 두 단계가 필요하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;다운로드&lt;/b&gt;: 네트워크에서 바이트를 받음 (캐시에 적재)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;디코드&lt;/b&gt;: 압축된 바이트(WebP/JPEG)를 픽셀 비트맵으로 변환 (CPU 작업)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;가 페인트될 때 디코드가 발생하는데, 큰 이미지면 메인 스레드 블로킹이 생겨 화면이 튄다.&lt;br /&gt;&lt;code&gt;img.decode()&lt;/code&gt;는 &lt;b&gt;디코드를 사전에 백그라운드에서 끝내 두는 메서드&lt;/b&gt;다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;const img = new Image();
img.src = url;
await img.decode(); // 디코드까지 끝나고 resolve&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론적으로는 프리로드 시 decode까지 같이 해 두면 paint 시 디코드 비용도 0이 되어 더 빠르다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그런데 안쓴 이유?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &lt;code&gt;&amp;lt;link rel=preload imagesrcset&amp;gt;&lt;/code&gt;와 &lt;code&gt;Image() + decode()&lt;/code&gt;의 &lt;b&gt;매칭이 보장되지 않는다&lt;/b&gt;는 것.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// 시나리오:
const link = document.createElement(&quot;link&quot;);
link.rel = &quot;preload&quot;;
link.as = &quot;image&quot;;
link.imageSrcset = &quot;...640w, ...828w, ...1200w&quot;;
link.imageSizes = &quot;(max-width: 768px) 100vw, 768px&quot;;
// &amp;rarr; 브라우저가 srcset 알고리즘으로 1200w 후보 선택해 캐시에 적재

const img = new Image();
img.srcset = &quot;...640w, ...828w, ...1200w&quot;;
img.sizes = &quot;(max-width: 768px) 100vw, 768px&quot;;
await img.decode();
// &amp;rarr; 이 Image() 인스턴스도 srcset 알고리즘 적용. 
// 그러나 link과 다른 currentSrc를 고를 수 있다 (스펙 차이, 타이밍 차이)
// &amp;rarr; 만약 1080w를 골랐다면 &amp;rarr; 새로 fetch &amp;rarr; 캐시 미스 &amp;rarr; 프리로드 두 배&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;link&lt;/code&gt;와 &lt;code&gt;Image()&lt;/code&gt;의 후보 선택이 어긋나면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;link&lt;/code&gt;로 받은 1200w는 캐시에 들어갔지만 미사용 (낭비)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Image()&lt;/code&gt;가 새로 1080w를 fetch (추가 egress)&lt;/li&gt;
&lt;li&gt;정작 실제 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;가 마운트될 때 또 다른 후보를 골라 또 캐시 미스 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; &lt;b&gt;decode 한 번 끼워넣으려다가 fetch가 두세 번 일어날 위험&lt;/b&gt;.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;절충안의 결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;decode가 가져오는 이득(메인 스레드 디코드 비용 100~200ms)보다,&lt;br /&gt;매칭 미스로 인한 추가 fetch 손실이 훨씬 크다. 게다가:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 우리 케이스에서 link의 &lt;code&gt;onload&lt;/code&gt;만으로 충분히 빠름 (브라우저가 이미 디스크 캐시에 넣음)&lt;/li&gt;
&lt;li&gt;실제 LCP를 측정해 봐도 link.onload 이후 paint는 충분히 매끄럽다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결정: link.onload를 &quot;성공&quot; 신호로만 사용. decode는 안 쓴다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;향후 진짜로 첫 1장의 LCP가 더 중요해지면,&lt;br /&gt;&lt;b&gt;첫 1장에 한해서만&lt;/b&gt; Image+decode를 옵션으로 켤 여지를 남겨 뒀다&lt;br /&gt;(&lt;code&gt;PreloadImagesOptions&lt;/code&gt;에 &lt;code&gt;decodeFirst?: boolean&lt;/code&gt; 추가 가능). 지금은 끔.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이 결정의 일반론&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;더 좋아 보이는 최적화&quot;가 시스템 다른 부분과 매칭되는지 검증하는 것이 더 중요하다.&lt;br /&gt;두 메커니즘이 같은 기준으로 결정을 내리지 않으면, 각각 잘 동작해도 합쳤을 때 깨진다. 통합 정합성 &amp;gt; 개별 최적화.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 첫 1장 high + 나머지 idle low로 나눈 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리로드는 무료가 아니다. 6장을 동시에 받으면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네트워크 큐가 막혀 다른 중요 자원(JS, CSS) 로딩이 늦어짐&lt;/li&gt;
&lt;li&gt;메인 스레드도 onload/onerror 콜백으로 점유됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 모달이 열린 직후처럼 &lt;b&gt;인터랙션 직후&lt;/b&gt;에 6개 fetch가 동시에 시작되면 모달 자체의 첫 paint가 늦어진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;분기 전략&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;preloadImages(srcs, options)
  ├─ srcs[0]: 즉시 시작 + fetchpriority=&quot;high&quot;
  └─ srcs[1..N-1]: requestIdleCallback로 미루고 fetchpriority=&quot;low&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;첫 1장&lt;/b&gt;: LCP 후보 또는 화면에 가장 먼저 보일 이미지. high priority로 다른 자원과 경쟁할 때도 우선.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;나머지&lt;/b&gt;: 어차피 스크롤해야 보이는 것들. 메인 스레드가 idle일 때 천천히 받아도 충분.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;requestIdleCallback&lt;/code&gt;의 의미&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저가 &quot;지금 한가하다(다음 paint 전 여유 시간이 있다)&quot;고 판단할 때만 콜백을 실행한다.&lt;br /&gt;인터랙션이 진행 중이면 미뤄지므로 &lt;b&gt;유저가 체감하는 반응성을 해치지 않는다&lt;/b&gt;.&lt;br /&gt;Safari/iOS는 미지원이라 &lt;code&gt;setTimeout(cb, 1)&lt;/code&gt;로 폴백 &amp;mdash; 약하지만 micro-task 큐에 양보 정도는 됨.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;fetchpriority&lt;/code&gt;의 의미&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에게 네트워크 큐 우선순위 힌트.&lt;br /&gt;high는 다른 low/auto 자원보다 먼저 받음.&lt;br /&gt;low는 늦게. 우리는 &quot;첫 1장만 진짜 중요&quot; 사실을 브라우저에 정확히 알려준다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프리로드는 너무 많이 하면 페널티가 된다.&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리로드는 도움이 되지만 &lt;b&gt;너무 많이 하면 페널티&lt;/b&gt;가 된다.&lt;br /&gt;&quot;필요한 만큼만, 적절한 우선순위로.&quot;&lt;br /&gt;메인 스레드와 네트워크 큐를 자원으로 인식하고 budgeting하는 게 프론트 퍼포먼스의 본질.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 부록&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;linkRegistry&lt;/code&gt; (모듈 스코프 Map)&lt;/h3&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;const linkRegistry = new Map&amp;lt;string, { element: HTMLLinkElement; refCount: number; ... }&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 이미지를 여러 곳에서 프리로드 시도해도 link element는 한 번만 생성하고 refCount로 관리.&lt;br /&gt;cleanup 시 refCount=0이면 link 제거.&lt;br /&gt;&lt;b&gt;React StrictMode&lt;/b&gt;(dev에서 effect를 두 번 실행)에서도 link 중복 없이 동작하게 하려는 안전장치.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;PreloadHandle { done, cleanup }&lt;/code&gt; API&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const handle = preloadImages(srcs, options);
useEffect(() =&amp;gt; handle.cleanup, [handle]);
// 컴포넌트 unmount 시 진행 중인 idle 콜백 취소 + link 제거(refCount 감소)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;unmount 후에도 link가 head에 남아 있거나 idle 콜백이 살아 있으면 누수.&lt;br /&gt;cleanup으로 명시적 회수.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SSR 가드&lt;/h3&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;if (typeof document === &quot;undefined&quot;) {
  return { done: Promise.resolve(0), cleanup: () =&amp;gt; {} };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 렌더에서 호출되어도 안전하게.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7.&amp;nbsp; 표로 요약&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;결정&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new Image()&lt;/code&gt; &amp;rarr; &lt;code&gt;&amp;lt;link rel=preload imagesrcset&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;next/image의 srcset과 동일 매칭 알고리즘 &amp;rarr; 캐시 hit 100% 보장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IMAGE_SIZES&lt;/code&gt; &amp;rarr; 컨테이너별 분리&lt;/td&gt;
&lt;td&gt;브라우저에게 정확한 슬롯 폭을 알려서 딱 맞는 후보 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;그리드 hover preload는 모달 sizes 사용&lt;/td&gt;
&lt;td&gt;hover의 의도는 모달 본문 캐시 적재이므로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;img.decode()&lt;/code&gt; 비활성&lt;/td&gt;
&lt;td&gt;link와 Image의 매칭 미스 위험이 decode 이득보다 큼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;첫 1장 high 즉시 + 나머지 idle low&lt;/td&gt;
&lt;td&gt;메인 스레드/네트워크 budgeting. 인터랙션 우선&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;linkRegistry&lt;/code&gt; refCount + cleanup&lt;/td&gt;
&lt;td&gt;중복 link 방지, 메모리/누수 방지, StrictMode 안전&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 참조&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTML 표준: &lt;a href=&quot;https://html.spec.whatwg.org/multipage/links.html#link-type-preload&quot;&gt;Preload &amp;mdash; Fetch Spec&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;responsive images: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#responsive_images&quot;&gt;srcset and sizes &amp;mdash; MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;next/image 내부: &lt;a href=&quot;https://nextjs.org/docs/app/api-reference/components/image&quot;&gt;Next.js Image &amp;mdash; Vercel docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Web Vitals (LCP): &lt;a href=&quot;https://web.dev/articles/lcp&quot;&gt;web.dev/lcp&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;fetchpriority: &lt;a href=&quot;https://web.dev/articles/fetch-priority&quot;&gt;Priority Hints &amp;mdash; web.dev&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;requestIdleCallback: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API&quot;&gt;Cooperative scheduling &amp;mdash; MDN&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 &lt;code&gt;srcset&lt;/code&gt;+&lt;code&gt;sizes&lt;/code&gt;의 알고리즘은 한 번 정독하면.. 이 아니라, 두 번 세 번 보고있다 ㅠㅠ&lt;/p&gt;</description>
      <category>TIL</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/336</guid>
      <comments>https://ifelseif.tistory.com/336#entry336comment</comments>
      <pubDate>Sat, 25 Apr 2026 17:37:08 +0900</pubDate>
    </item>
    <item>
      <title>[260424 TIL] Bastion, RDS Proxy, Managed DB 정리</title>
      <link>https://ifelseif.tistory.com/335</link>
      <description>&lt;h1&gt;Bastion / RDS Proxy / Managed DB 정리&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;0. 한 줄 요약&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vercel의 Next.js가 DB에 직접 붙어야 한다면 RDS는 네트워크 보안 설계가 복잡해지고, Railway/Supabase/Neon 같은 외부 접속 친화형 Postgres가 더 단순할 수 있다.&lt;br /&gt;RDS는 AWS 내부 백엔드, VPC, RDS Proxy와 함께 사용할 때 더 자연스럽다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 오늘 헷갈렸던 핵심&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 다음 개념들이 비슷하게 느껴졌다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Bastion&lt;/li&gt;
&lt;li&gt;SSH Tunnel&lt;/li&gt;
&lt;li&gt;RDS Proxy&lt;/li&gt;
&lt;li&gt;Supabase / Railway의 DB URL&lt;/li&gt;
&lt;li&gt;Vercel Static IP&lt;/li&gt;
&lt;li&gt;AWS API Gateway / Lambda / ECS / App Runner&lt;/li&gt;
&lt;li&gt;App Runner VPC Connector&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;겉으로 보면 전부 &amp;ldquo;DB에 안전하게 접속하기 위한 중간 통로&amp;rdquo;처럼 보이지만, 실제 역할은 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 중요한 구분은 이것이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;네트워크 접근 경로를 만들어주는 것
vs
DB 커넥션을 효율적으로 관리해주는 것
---

## **2. Bastion이란?**

Bastion은 DB에 직접 접속하지 않고, 중간 서버를 거쳐서 DB에 접속하기 위한 관문 서버다.

보통 구조는 다음과 같다.

```txt
내 PC
  &amp;darr; SSH
Bastion Server
  &amp;darr; Private Network
RDS / Database&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDS를 외부 인터넷에 직접 열지 않고, Bastion 서버만 외부에서 SSH 접속 가능하게 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예:&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;RDS public access: false
Bastion EC2: public subnet
RDS: private subnet&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안 그룹 예시:&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Bastion SG
- 내 IP에서 22번 SSH 허용

RDS SG
- Bastion SG에서 오는 5432만 허용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 Bastion은 주로 &lt;b&gt;개발자가 로컬에서 private DB에 접속하기 위한 통로&lt;/b&gt;다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. SSH 터널링이란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSH 터널링은 내 로컬 포트를 Bastion을 통해 DB 포트로 연결하는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예:&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;ssh -L 5433:my-db.internal:5432 ubuntu@bastion.example.com&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 명령의 의미:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;localhost:5433
  &amp;darr; SSH Tunnel
Bastion Server
  &amp;darr;
my-db.internal:5432&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 로컬 DB 툴에서는 이렇게 접속한다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Host: localhost
Port: 5433
User: db_user
Password: db_password
Database: my_db&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제로는 Bastion을 거쳐 private RDS에 접속하는 것이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. Bastion은 주로 개발자 로컬 접속용&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bastion은 보통 다음 용도에 적합하다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;개발자 로컬 PC &amp;rarr; private RDS 접속
DBeaver / TablePlus / DataGrip / psql 접속
운영 DB 긴급 확인
마이그레이션 수동 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Vercel에 배포된 Next.js 앱이 Bastion을 통해 RDS에 접속하는 구조는 일반적이지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉:&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;개발자 로컬 &amp;rarr; Bastion &amp;rarr; RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;는 자연스럽지만,&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Vercel App &amp;rarr; Bastion &amp;rarr; RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;는 일반적인 프로덕션 구조가 아니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. RDS Proxy란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDS Proxy는 DB에 접근하기 위한 public gateway가 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDS Proxy의 주요 목적은 다음이다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;DB 커넥션 풀링
커넥션 재사용
Lambda/ECS 같은 앱의 DB 연결 폭증 완화
RDS 장애/failover 대응 개선
Secrets Manager 연동&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조는 보통 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;AWS VPC 내부 App
예: Lambda / ECS / EC2 / App Runner
  &amp;darr;
RDS Proxy
  &amp;darr;
RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 점:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;RDS Proxy는 기본적으로 VPC 내부에서 사용하는 리소스다.
public internet에서 직접 접근하는 endpoint가 아니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 다음 구조는 일반적으로 기대한 대로 동작하지 않는다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Vercel
  &amp;darr; public internet
RDS Proxy
  &amp;darr;
Private RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDS Proxy는 &amp;ldquo;Vercel이 private RDS에 접속할 수 있게 해주는 터널&amp;rdquo;이 아니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. RDS Proxy와 Bastion의 차이&lt;/b&gt;&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Bastion
&amp;rarr; 네트워크 접근 경로
&amp;rarr; 개발자 로컬 접속용
&amp;rarr; SSH 터널링에 사용

RDS Proxy
&amp;rarr; DB 커넥션 풀링
&amp;rarr; AWS 내부 앱용
&amp;rarr; Lambda/ECS/App Runner 등이 RDS를 효율적으로 사용하게 함&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심:&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Bastion은 길을 열어주는 것
RDS Proxy는 연결을 관리해주는 것&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. Vercel에서 private RDS에 직접 붙기 어려운 이유&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vercel에 배포된 Next.js가 Prisma나 adapter-pg로 RDS에 직접 붙는 구조는 편하다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;Next.js on Vercel
  &amp;darr;
Prisma / adapter-pg
  &amp;darr;
AWS RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 RDS가 private이면 Vercel이 접근할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 선택지가 생긴다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. RDS public access를 열기
2. Vercel Static IP를 구매하고 해당 IP만 허용하기
3. AWS 안에 API 계층을 두기
4. Next.js 앱 자체를 AWS로 옮기기
5. Supabase / Railway / Neon 같은 외부 접속 친화형 DB를 쓰기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 회사에서는 Static IP가 비싸다고 판단했고, 그래서 RDS inbound를 &lt;code&gt;0.0.0.0/0&lt;/code&gt;으로 열고 쓰는 상황이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 동작은 하지만 보안상 부담이 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. AWS API 계층을 두는 방식&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vercel이 RDS에 직접 붙지 않게 하고, AWS 내부의 백엔드가 RDS에 접근하게 만드는 방식이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Vercel / Next.js
  &amp;darr; HTTPS
AWS API Gateway / ALB
  &amp;darr;
Lambda / ECS / App Runner
  &amp;darr; VPC 내부
RDS Proxy
  &amp;darr;
RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 RDS는 외부에 열 필요가 없다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;RDS public access: false
RDS inbound 0.0.0.0/0: 필요 없음
RDS inbound: Lambda/ECS/App Runner의 Security Group만 허용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 Vercel은 DB가 아니라 API만 바라본다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;Vercel &amp;rarr; AWS API &amp;rarr; RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 Vercel Static IP가 필요 없다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;8. 하지만 Next.js가 DB adapter로 직접 붙어야 하는 경우&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 다음과 같은 경우다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Next.js on Vercel
  &amp;darr;
Auth.js Prisma Adapter / adapter-pg / Prisma
  &amp;darr;
RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 DB 접근 코드가 Next.js 서버 안에 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 단순히 AWS API를 따로 둔다고 해결되지 않을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 Auth.js adapter처럼 Next.js 서버 런타임에서 직접 DB에 붙어야 하는 구조라면, 다음 중 하나를 선택해야 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;1. Vercel에서 RDS에 직접 붙도록 public RDS를 허용
2. Vercel Static IP 구매
3. Auth/API를 AWS 백엔드로 분리
4. Next.js 전체를 AWS 쪽으로 이동
5. DB를 Railway/Supabase/Neon 등으로 변경&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;9. App Runner + VPC Connector란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Runner는 AWS에서 컨테이너 앱을 비교적 쉽게 배포할 수 있는 서비스다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VPC Connector를 붙이면 App Runner 서비스가 VPC 내부 리소스에 접근할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예:&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;사용자
  &amp;darr; HTTPS
AWS App Runner - Next.js 서버
  &amp;darr; VPC Connector
Private RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우:&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;RDS public access: false
RDS inbound: App Runner VPC Connector SG만 허용
Vercel static IP: 필요 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 Next.js를 Vercel이 아니라 App Runner에 올리면, private RDS에 직접 접근할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;10. App Runner가 해결하는 것과 해결하지 않는 것&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Runner는 다음을 해결한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Next.js 앱을 AWS 안에 배포
VPC Connector를 통해 private RDS 접근
RDS를 public으로 열 필요 없음
Vercel Static IP 필요 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 다음을 해결하는 것은 아니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Vercel에 있는 Next.js가 App Runner VPC Connector를 빌려서 private RDS에 접근&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 이건 안 된다.&lt;/p&gt;
&lt;pre class=&quot;vbnet&quot;&gt;&lt;code&gt;Next.js on Vercel
  &amp;darr;
App Runner VPC Connector
  &amp;darr;
Private RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가능한 구조는 이쪽이다.&lt;/p&gt;
&lt;pre class=&quot;vbnet&quot;&gt;&lt;code&gt;Next.js on App Runner
  &amp;darr;
VPC Connector
  &amp;darr;
Private RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는:&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;Next.js on Vercel
  &amp;darr; HTTPS
API on App Runner
  &amp;darr; VPC Connector
  &amp;darr;
Private RDS&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;11. Supabase / Railway / Neon의 DB URL은 RDS Proxy와 같은가?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일부 역할은 비슷하지만 같은 개념은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase / Railway / Neon은 보통 외부 앱에서 접속할 수 있는 managed database endpoint를 제공한다.&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;Vercel
  &amp;darr;
Managed Postgres URL
  &amp;darr;
Postgres&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 Supabase 같은 경우 connection pooler URL도 제공한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Direct connection URL
&amp;rarr; DB에 직접 연결

Pooler URL
&amp;rarr; Supavisor 같은 pooler를 통해 DB 연결&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDS Proxy와 비슷한 점:&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;DB 커넥션 풀링을 제공할 수 있음
앱의 DB 연결을 관리해줌&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 점:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Supabase / Railway / Neon
&amp;rarr; 외부 앱에서 접속하기 좋은 public managed endpoint를 제공

AWS RDS Proxy
&amp;rarr; AWS VPC 내부 앱이 RDS를 효율적으로 쓰기 위한 proxy
&amp;rarr; public internet에서 접근하는 endpoint가 아님&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;12. 그래서 Railway를 쓰는 게 나을 수 있는 이유&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 문제는 다음이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Next.js on Vercel이 DB에 직접 붙어야 함
Vercel Static IP는 비쌈
RDS를 0.0.0.0/0으로 열기는 부담스러움
AWS API 계층을 두면 구조가 복잡해짐&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 Railway / Supabase / Neon 같은 managed Postgres가 더 현실적일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Next.js on Vercel
  &amp;darr;
Prisma / Auth.js adapter / adapter-pg
  &amp;darr;
Railway Postgres / Supabase Postgres / Neon Postgres&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점:&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Vercel과 연결하기 쉬움
DB URL 제공
SSL 연결 지원
pooler 제공하는 경우도 있음
RDS보다 초기 비용과 운영 부담이 낮을 수 있음
private VPC 네트워크 설계 고민이 줄어듦&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 작은 서비스, 내부툴, MVP, Auth.js/Prisma adapter를 Next.js에서 바로 써야 하는 프로젝트에는 더 적합할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;13. RDS가 더 적합한 경우&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 RDS가 더 맞는 경우도 있다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;이미 AWS 안에 백엔드가 있음
ECS/Lambda/App Runner와 묶을 예정
VPC/private subnet 보안 구조가 중요함
조직의 보안 정책상 AWS 내부망이 필요함
RDS 백업/파라미터 그룹/모니터링/권한 체계가 필요함
장기적으로 인프라를 AWS 중심으로 운영할 계획&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우에는 다음 구조가 더 자연스럽다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Frontend
  &amp;darr;
AWS Backend
  &amp;darr;
RDS Proxy
  &amp;darr;
Private RDS&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는:&lt;/p&gt;
&lt;pre class=&quot;vbnet&quot;&gt;&lt;code&gt;Next.js on AWS App Runner / ECS
  &amp;darr;
Private RDS&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;14. 오늘 명확히 알게 된 결론&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Bastion&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;개발자 로컬에서 private DB에 접속하기 위한 관문 서버
주로 SSH 터널링에 사용
프로덕션 앱이 DB에 붙기 위한 일반적인 구조는 아님&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;SSH Tunnel&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;로컬 포트를 Bastion을 통해 DB 포트로 연결하는 방식
DBeaver, TablePlus, psql 등 로컬 툴에서 private DB 접속 가능&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;RDS Proxy&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;DB 커넥션 풀링용
AWS VPC 내부 앱을 위한 프록시
Vercel이 private RDS에 접속하게 해주는 public gateway가 아님&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Vercel Static IP&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;x86asm&quot;&gt;&lt;code&gt;Vercel에서 RDS public endpoint에 직접 붙을 때
RDS 보안그룹을 특정 IP로 제한하기 위한 옵션
하지만 비용이 부담될 수 있음&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;AWS API Gateway / Lambda / ECS / App Runner&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;Vercel이 DB에 직접 붙지 않고
AWS 내부 API가 private RDS에 접근하게 만드는 방식&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;App Runner + VPC Connector&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Next.js나 API 서버를 AWS App Runner에 올리고
VPC Connector로 private RDS에 접근하는 방식
Vercel 앱이 VPC Connector를 빌려 쓰는 구조는 아님&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Supabase / Railway / Neon&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Vercel 같은 외부 플랫폼에서 직접 연결하기 좋은 managed Postgres
Next.js가 Prisma/Auth.js adapter로 직접 DB에 붙어야 하는 경우 현실적인 선택지&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;15. 최종 판단 기준&lt;/b&gt;&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Next.js가 Vercel에 있고 DB에 직접 붙어야 한다
&amp;rarr; Railway / Supabase / Neon 검토

Next.js는 Vercel에 두고 DB 접근만 분리 가능하다
&amp;rarr; AWS API Gateway + Lambda / ECS / App Runner API

Next.js 전체를 AWS로 옮겨도 된다
&amp;rarr; App Runner + VPC Connector or ECS + RDS

개발자가 로컬에서 private DB에 접속해야 한다
&amp;rarr; Bastion or SSM Session Manager

AWS 내부 앱의 DB 커넥션을 효율화하고 싶다
&amp;rarr; RDS Proxy

RDS를 public으로 열어야 한다
&amp;rarr; 가능하면 0.0.0.0/0 대신 고정 IP 제한, SSL, 강한 인증, 최소 권한 적용&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/335</guid>
      <comments>https://ifelseif.tistory.com/335#entry335comment</comments>
      <pubDate>Fri, 24 Apr 2026 14:40:45 +0900</pubDate>
    </item>
    <item>
      <title>[260416 TIL] React Compiler 에서 as 단언 사용시 문제</title>
      <link>https://ifelseif.tistory.com/334</link>
      <description>&lt;h1&gt;React Compiler 환경에서 &lt;code&gt;as&lt;/code&gt; 단언이 메모이제이션을 깨뜨린다?&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TL;DR&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;reactCompiler: true&lt;/code&gt; 환경에서 &lt;code&gt;(data ?? []) as 타입[]&lt;/code&gt; 처럼 &lt;br /&gt;assertion을 썼는데 TanStack Query 응답이 바뀌어도 화면이 갱신되지 않는 버그가 발생했다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;const 변수: 타입[] = data ?? []&lt;/code&gt; 처럼 변수 타입 어노테이션으로 바꾸니 해결됐다.&lt;br /&gt;원인은 단순하지 않고 세 가지 요인이 결합된 결과였다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제를 만난 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js + React Compiler 19.2.4 + TanStack Query + TanStack Table 조합으로&lt;br /&gt;작업 중에 이상한 증상을 만났다.&lt;/p&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;const { data: response, isLoading } = useChannelsList(params, {
  placeholderData: keepPreviousData,
  enabled: !storeState.isOpen,
});

const channels = (response?.data ?? []) as Channel[];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;증상은 이랬다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ Network 탭에서 fetch 요청은 정상적으로 날아감&lt;/li&gt;
&lt;li&gt;✅ TanStack Query devtools에서 &lt;code&gt;response&lt;/code&gt;에 새 데이터가 들어온 것 확인&lt;/li&gt;
&lt;li&gt;✅ TanStack Table 컴포넌트 내부에서 &lt;code&gt;console.log&lt;/code&gt;로 &lt;code&gt;channels&lt;/code&gt;에 최신 데이터가 찍힘&lt;/li&gt;
&lt;li&gt;❌ &lt;b&gt;그런데 화면(DOM)은 이전 데이터 그대로&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;데이터는 오고 있는데 렌더가 안 된다&quot;는 가장 디버깅하기 싫은 상황이었다.&lt;br /&gt;React DevTools Profiler로 보면 컴포넌트 함수는 실행되는데 결과물은 stale한 상태였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 해결한 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우연히 타입 정비 작업을 하다가 발견했다.&lt;br /&gt;&lt;code&gt;as&lt;/code&gt; 캐스트를 제거하고 변수 타입 어노테이션으로 바꾸니 정상 동작했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// ❌ Before: as 단언 &amp;mdash; 화면이 갱신되지 않음
const channels = (response?.data ?? []) as Channel[];

// ✅ After: 타입 어노테이션 &amp;mdash; 정상 동작
const channels: ChannelListItem[] = response?.data ?? [];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 발견을 계기로 전체 코드베이스의 타입 체인을 정비했다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;types/apis&lt;/code&gt;: &lt;code&gt;ChannelSearchData&lt;/code&gt; &amp;rarr; &lt;code&gt;ChannelListItem&lt;/code&gt;으로 이름 변경 (더 명확한 의미)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;channel-table-columns&lt;/code&gt;: &lt;code&gt;ColumnDef&amp;lt;Channel&amp;gt;&lt;/code&gt; &amp;rarr; &lt;code&gt;ColumnDef&amp;lt;ChannelListItem&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Table&lt;/code&gt;, &lt;code&gt;TableBody&lt;/code&gt;: &lt;code&gt;Channel&lt;/code&gt; 하드코딩 &amp;rarr; 제네릭 &lt;code&gt;&amp;lt;TRow extends { id: string }&amp;gt;&lt;/code&gt;로&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TableBottomHeader&lt;/code&gt;: 제네릭화&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cart-store&lt;/code&gt;: items 타입 &lt;code&gt;Channel&lt;/code&gt; &amp;rarr; &lt;code&gt;ChannelListItem&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cart-toggle-button&lt;/code&gt;, &lt;code&gt;cart-select-all-button&lt;/code&gt;: &lt;code&gt;ChannelListItem&lt;/code&gt; 타입 적용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;creator-modal-store&lt;/code&gt;: &lt;code&gt;channel: Channel&lt;/code&gt; &amp;rarr; &lt;code&gt;channelId: string&lt;/code&gt;&lt;br /&gt;(목록에서는 서머리만 가지고 있으므로, ID만 전달하고 모달에서 상세 조회)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;creator-modal&lt;/code&gt; 하위 컴포넌트: &lt;code&gt;channelId&lt;/code&gt; 기반으로 &lt;code&gt;useChannelDetail&lt;/code&gt; 연동&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mix&lt;/code&gt; 컴포넌트: 변경된 &lt;code&gt;Table&lt;/code&gt; 제네릭에 맞춰 타입 적용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 우회였지만, &quot;왜 &lt;code&gt;as&lt;/code&gt;가 문제를 만들었는가?&quot;가&lt;br /&gt;계속 궁금해서 Claude와 함께 근본 원인을 파헤쳐봤다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 함께 연구하며 밝혀낸 문제 &amp;mdash; 세 가지 요인의 결합&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, 이건 &lt;b&gt;단일 버그가 아니라 세 가지 요인이 결합된 결과&lt;/b&gt;였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요인 ①: &lt;code&gt;as&lt;/code&gt; 단언은 AST에서 표현식을 감싸는 래퍼 노드다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;as&lt;/code&gt; 단언과 타입 어노테이션은 의미상 비슷해 보이지만, &lt;b&gt;Babel AST 구조가 완전히 다르다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;as&lt;/code&gt; 단언 패턴&lt;/b&gt; (&lt;code&gt;const channels = (response?.data ?? []) as Channel[]&lt;/code&gt;):&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;VariableDeclarator
├── id: Identifier(&quot;channels&quot;)
└── init: TSAsExpression                  &amp;larr; 표현식을 감싸는 래퍼 노드
    ├── expression: LogicalExpression(??)  &amp;larr; 실제 연산
    │   ├── left: OptionalMemberExpression(response?.data)
    │   └── right: ArrayExpression([])
    └── typeAnnotation: TSTypeReference(&quot;Channel[]&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;타입 어노테이션 패턴&lt;/b&gt; (&lt;code&gt;const channels: ChannelListItem[] = response?.data ?? []&lt;/code&gt;):&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;VariableDeclarator
├── id: Identifier(&quot;channels&quot;)
│   └── typeAnnotation: TSTypeAnnotation  &amp;larr; 변수 이름에 부착된 메타데이터
└── init: LogicalExpression(??)           &amp;larr; 깨끗한 표현식, 래퍼 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;핵심 차이: &lt;code&gt;VariableDeclarator.init&lt;/code&gt;이&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;as&lt;/code&gt; 패턴에서는 &lt;code&gt;TSAsExpression&lt;/code&gt; (실제 표현식을 감싼 래퍼)&lt;/li&gt;
&lt;li&gt;어노테이션 패턴에서는 바로 &lt;code&gt;LogicalExpression&lt;/code&gt; (깨끗한 표현식)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;as&lt;/code&gt;는 표현식 레벨에서 작동하는 래핑 노드&lt;/b&gt;이므로 컴파일러의 표현식 처리 파이프라인에 직접 개입하지만, 타입 어노테이션은 식별자의 메타데이터에 불과해서 표현식 분석에 관여하지 않는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요인 ②: React Compiler는 TypeScript 스트리핑보다 먼저 실행된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 핵심적인 아키텍처 사실이다. React 공식 문서:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;React Compiler must run &lt;b&gt;first&lt;/b&gt; in your Babel plugin pipeline.&quot;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Babel의 실행 순서 규칙상 &lt;b&gt;플러그인이 프리셋보다 먼저&lt;/b&gt; 실행된다.&lt;br /&gt;따라서 &lt;code&gt;babel-plugin-react-compiler&lt;/code&gt;(플러그인)는 &lt;code&gt;@babel/preset-typescript&lt;/code&gt;(프리셋)보다 먼저 돈다.&lt;br /&gt;&lt;br /&gt;Next.js의 &lt;code&gt;reactCompiler: true&lt;/code&gt;도 마찬가지로, SWC가 컴파일러 적용 대상 파일을 식별한 뒤 Babel 플러그인을 돌리는데 이때 TypeScript 문법이 &lt;b&gt;아직 제거되지 않은 상태&lt;/b&gt;다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 결과 &lt;b&gt;React Compiler는 &lt;code&gt;TSAsExpression&lt;/code&gt;, &lt;code&gt;TSNonNullExpression&lt;/code&gt;, &lt;code&gt;TSSatisfiesExpression&lt;/code&gt; 등 TypeScript 전용 AST 노드를 직접 처리해야 한다.&lt;/b&gt; 컴파일러 내부 파이프라인은 이렇다:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;BuildHIR&lt;/b&gt; &amp;mdash; Babel AST를 HIR(제어 흐름 그래프 + SSA)로 변환&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SSA 변환&lt;/b&gt; &amp;mdash; 각 변수 할당에 고유 식별자 부여&lt;/li&gt;
&lt;li&gt;&lt;b&gt;타입 추론&lt;/b&gt; &amp;mdash; 컴파일러 자체 타입 시스템 (TS 타입 정보는 사용 안 함)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;효과 분석&lt;/b&gt; &amp;mdash; Read/Store/Capture/Mutate/Freeze 효과 추론&lt;/li&gt;
&lt;li&gt;&lt;b&gt;리액티브 분석&lt;/b&gt; &amp;mdash; 렌더 간 변경 가능한 값 식별&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스코프 발견&lt;/b&gt; &amp;mdash; 리액티브 스코프 그룹핑/병합&lt;/li&gt;
&lt;li&gt;&lt;b&gt;코드 생성&lt;/b&gt; &amp;mdash; &lt;code&gt;useMemoCache&lt;/code&gt; 기반 최적화 코드 출력&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;TSAsExpression&lt;/code&gt;을 처리하긴 한다.&lt;br /&gt;BuildHIR의 &lt;code&gt;lowerExpression&lt;/code&gt;에서 내부 표현식을 재귀적으로 내려간다.&lt;/p&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;case &quot;TSAsExpression&quot;:
case &quot;TSSatisfiesExpression&quot;: {
  let expr = exprPath as NodePath&amp;lt;t.TSAsExpression | t.TSSatisfiesExpression&amp;gt;;
  return lowerExpression(builder, expr.get(&quot;expression&quot;));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이 과정에서 &lt;code&gt;TypeCastExpression&lt;/code&gt;이라는 &lt;b&gt;HIR 중간 명령어&lt;/b&gt;를 생성한다 (PR #32742 참고):&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;interface TypeCastExpression {
  kind: &quot;TypeCastExpression&quot;;
  value: Place;
  typeAnnotation: t.FlowType | t.TSType;
  typeAnnotationKind: 'cast' | 'as' | 'satisfies';
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 &lt;code&gt;TypeCastExpression&lt;/code&gt; 명령어는 HIR의 나머지 파이프라인을 &lt;b&gt;모두 통과한다&lt;/b&gt;.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSA 변환에서 새 식별자를 받고, 효과 분석에서 효과가 추론되고,&lt;br /&gt;리액티브 분석에서 리액티브 여부가 판정되고, 스코프 발견에서 특정 스코프에 배치된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;중간 명령어가 하나 더 존재한다는 사실이 리액티브 스코프 경계 계산에 영향을 줄 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타입 어노테이션 패턴에서는 이 중간 단계 자체가 없다.&lt;br /&gt;&lt;br /&gt;&lt;code&gt;init&lt;/code&gt;이 바로 &lt;code&gt;LogicalExpression&lt;/code&gt;이니 HIR에서 데이터 흐름이 직접적이고 투명하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요인 ③: 독립 리액티브 스코프의 &lt;code&gt;===&lt;/code&gt; 비교가 무력화된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Compiler가 생성하는 코드를 보면 &quot;실행되지만 갱신 안 됨&quot; 증상을 정확히 설명할 수 있다.&lt;br /&gt;컴파일러는 각 리액티브 스코프를 &lt;b&gt;독립 캐시 슬롯&lt;/b&gt;으로 변환한다:&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;const $ = c(6); // 6개 캐시 슬롯

// 스코프 1: channels 계산
let channels;
if ($[0] !== response) {
  channels = response?.data ?? [];
  $[0] = response;
  $[1] = channels;
} else {
  channels = $[1]; // 캐시 반환
}

// 스코프 2: JSX 렌더링
let t0;
if ($[2] !== channels) {  // &amp;larr; 이 의존성 검사가 핵심
  t0 = &amp;lt;Table data={channels} /&amp;gt;;
  $[2] = channels;
  $[3] = t0;
} else {
  t0 = $[3]; // 캐시된 JSX 반환 &amp;rarr; DOM 갱신 안 됨!
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;각 스코프는 &lt;code&gt;===&lt;/code&gt; 동등 비교로 의존성 변경을 감지한다.&lt;br /&gt;&lt;b&gt;&lt;code&gt;as&lt;/code&gt; 단언이 만든 &lt;code&gt;TypeCastExpression&lt;/code&gt; 중간 명령어가 스코프 경계를 바꿔서,&lt;br /&gt;JSX 스코프의 의존성 목록에서 &lt;code&gt;channels&lt;/code&gt;(또는 상위 리액티브 소스인 &lt;code&gt;response&lt;/code&gt;)가 누락된다면&lt;/b&gt;,&lt;br /&gt;JSX 스코프는 항상 캐시된 결과를 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 내가 본 증상과 정확히 일치한다:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;관찰&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Network fetch 정상&lt;/td&gt;
&lt;td&gt;useQuery는 정상 동작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;response&lt;/code&gt;에 새 데이터 존재&lt;/td&gt;
&lt;td&gt;TanStack Query의 structural sharing 정상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;console.log&lt;/code&gt;에 최신 데이터 출력&lt;/td&gt;
&lt;td&gt;컴포넌트 함수는 실행됨 (스코프 1은 정상)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOM 미갱신&lt;/td&gt;
&lt;td&gt;&lt;b&gt;JSX 스코프(스코프 2)가 의존성 변경을 감지 못함 &amp;rarr; 캐시 반환&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;추가 요인: TanStack Table의 내부 가변성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설상가상으로 TanStack Table은 &lt;b&gt;React Compiler와 공식 비호환&lt;/b&gt;이다.&lt;br /&gt;React 팀은 &lt;code&gt;DefaultModuleTypeProvider.ts&lt;/code&gt;의 블록리스트에 TanStack Table을 추가했다 (PR #31820, #34027). &lt;br /&gt;공식 문서도 &quot;incompatible library&quot;로 명시한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 문제는 &lt;b&gt;내부 가변성(interior mutability)&lt;/b&gt;이다. &lt;code&gt;useReactTable()&lt;/code&gt;이 반환하는 테이블 인스턴스는 외부 참조는 동일하게 유지하면서 내부 상태만 변경한다. React Compiler의 &lt;code&gt;===&lt;/code&gt; 비교로는 이 변경을 감지할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;관련 이슈들:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;facebook/react#33057&lt;/code&gt; &amp;mdash; &quot;React Compiler breaks most functionality of TanStack Table&quot;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TanStack/table#5567&lt;/code&gt; &amp;mdash; &quot;Table doesn't re-render with new React Compiler + React 19&quot;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TanStack/query#9571&lt;/code&gt; &amp;mdash; &quot;Referential stability lost when using react-compiler&quot;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;facebook/react#34211&lt;/code&gt; &amp;mdash; &quot;breaks referential stability in @tanstack/react-query&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 상황에서는 &lt;b&gt;두 문제가 결합&lt;/b&gt;되어 있었다.&lt;br /&gt;&lt;code&gt;as&lt;/code&gt; 단언이 리액티브 스코프 추적을 교란했고, TanStack Table의 내부 가변성이 &lt;code&gt;===&lt;/code&gt; 비교를 무력화했다.&lt;br /&gt;&lt;br /&gt;타입 어노테이션으로 전환하면서 첫 번째 문제가 해소되어 컴파일러가 의존성을 올바르게 추적하게 됐고, 적어도 &lt;code&gt;channels&lt;/code&gt; 배열의 참조 변경은 정상 감지된 것이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컴파일러의 TypeScript 처리에는 역사적 공백이 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 React Compiler가 TypeScript AST 노드를 완벽하게 처리하지 못한다는 더 큰 패턴의 일부다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;TSSatisfiesExpression&lt;/code&gt;은 2025년 3월에야 추가됨.&lt;/b&gt; Issue #29754(2024년 6월)에서 &lt;code&gt;satisfies&lt;/code&gt; 연산자가 BuildHIR에서 처리 안 되어 bailout 발생 보고 &amp;rarr; PR #32742로 약 9개월 뒤 수정.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;TSInstantiationExpression&lt;/code&gt;은 현재까지도 미처리&lt;/b&gt; (Issue #34358, #31745). &lt;code&gt;lowerReorderableExpression&lt;/code&gt;에서 &quot;cannot be safely reordered&quot; 에러.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;reactwg/react-compiler Discussion #34&lt;/b&gt;: &lt;code&gt;TSAsExpression&lt;/code&gt;이 ObjectExpression 키 위치에 사용될 때 bailout 발생 보고.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴의 배경은 React Compiler가 &lt;b&gt;Meta 내부에서 Flow 타입 시스템 기반으로 개발&lt;/b&gt;되었다는 점이다.&lt;br /&gt;공식 문서도 인정한다:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;While the compiler does not currently use type information from typed JavaScript languages like TypeScript or Flow, internally it has its own type system.&quot;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TypeScript 지원은 점진적으로 추가되고 있고, &lt;code&gt;lowerExpression&lt;/code&gt;과 &lt;code&gt;lowerReorderableExpression&lt;/code&gt; 등 &lt;b&gt;서로 다른 코드 경로에서 동일한 TS 노드 타입이 일관되게 처리되지 않는&lt;/b&gt; 경우가 존재한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 결론 &amp;amp; 교훈&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;교훈&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;React Compiler 환경에서는 &lt;code&gt;as&lt;/code&gt; 캐스트 대신 그냥 변수 타입 어노테이션을 사용하자.&lt;/b&gt;&lt;br /&gt;이건 단순한 스타일 권장사항이 아니라 &lt;b&gt;AST 구조적으로 컴파일러의 표현식 분석 파이프라인에 개입하지 않는 유일한 방법&lt;/b&gt;이다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// ✅ 권장: 타입 어노테이션 (AST에서 init 표현식이 깨끗)
const channels: ChannelListItem[] = response?.data ?? [];

// ✅ 대안: 제네릭 타입 파라미터로 해결
const { data: response } = useChannelsList&amp;lt;ChannelListResponse&amp;gt;(params, options);
// response.data가 이미 올바른 타입을 가지므로 캐스트 불필요

// ⚠️ satisfies: TypeCastExpression 명령어를 동일하게 생성 &amp;rarr; 같은 위험 존재
const channels = (response?.data ?? []) satisfies Channel[];

// ❌ 문제: as 단언 (TSAsExpression 래퍼 노드가 init을 감싸고, TypeCastExpression 생성)
const channels = (response?.data ?? []) as Channel[];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;TanStack Table과 같이 쓰는 경우 &lt;b&gt;&lt;code&gt;'use no memo'&lt;/code&gt; 디렉티브로 해당 컴포넌트를 컴파일러 최적화에서 제외&lt;/b&gt;하는 것도 고려 대상이다.&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;'use no memo'; // 이 컴포넌트에 대해 React Compiler 비활성화

function ChannelTable({ params }: Props) {
  const { data: response } = useChannelsList(params, { ... });
  const channels: ChannelListItem[] = response?.data ?? [];
  const table = useReactTable({ data: channels, ... });
  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이슈 제출 가능성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재현 가능한 최소 예제를 만들 수 있다면 &lt;code&gt;facebook/react&lt;/code&gt; 레포지토리에 &lt;code&gt;Component: React Compiler&lt;/code&gt; 태그로 이슈를 제출할 만한 내용이다. TypeScript 표현식 래퍼 노드의 처리는 역사적으로 점진적 개선이 이루어져 온 영역이고, &lt;b&gt;이 특정 상호작용(&lt;code&gt;as&lt;/code&gt; 단언 + &lt;code&gt;??&lt;/code&gt; + TanStack Query response)은 아직 공식 이슈로 보고되지 않은 것으로 보인다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최소 재현 예제를 만든다면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Playground(&lt;a href=&quot;https://playground.react.dev/)%EC%97%90%EC%84%9C&quot;&gt;https://playground.react.dev/)에서&lt;/a&gt; 두 패턴의 컴파일 결과 차이를 캡처&lt;/b&gt; &amp;mdash; 실제로 생성되는 캐시 슬롯과 의존성 배열의 차이를 직접 비교&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TanStack Query/Table을 배제한 순수 재현&lt;/b&gt; &amp;mdash; React state만 사용해서 &lt;code&gt;as&lt;/code&gt;만으로 재현되는지 확인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최소 조건 좁히기&lt;/b&gt; &amp;mdash; optional chaining 없이도 재현되는지, nullish coalescing 없이도 재현되는지 하나씩 제거해보며 최소 트리거 조건 파악&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 순수 재현이 안 되고 TanStack Query/Table과 결합해야만 재현된다면, 그 자체가 이슈 내용의 일부가 된다 (&quot;세 요인의 결합 버그&quot;). React 팀은 bailout 보고를 환영하는 분위기고, &lt;code&gt;__unstable_donotuse_reportAllBailouts&lt;/code&gt; 같은 디버깅 도구도 제공하고 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상위 레벨 교훈&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;React Compiler는 &lt;b&gt;계속 개선 중&lt;/b&gt;이다. TypeScript 노드 처리의 완성도가 2025년 3월 이후로도 꾸준히 발전하고 있다.&lt;/li&gt;
&lt;li&gt;TanStack Table은 React Compiler와 &lt;b&gt;아직 함께 쓰기엔 조심해야 한다&lt;/b&gt;. 공식 비호환 라이브러리 목록에 있다.&lt;/li&gt;
&lt;li&gt;데이터는 오는데 화면이 안 바뀌면 &lt;b&gt;컴파일러 레벨의 메모이제이션 버그&lt;/b&gt;를 의심하자. React DevTools Profiler의 &quot;Why did this render?&quot; 기능으로 진단 가능하다.&lt;/li&gt;
&lt;li&gt;타입 체계를 깨끗하게 유지하는 것(&lt;code&gt;as&lt;/code&gt; 남용 피하기, 제네릭 활용)은 가독성/안전성뿐 아니라 &lt;b&gt;컴파일러 최적화의 정확성에도 영향을 준다&lt;/b&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 참조 리스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;React Compiler 공식 문서&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/learn/react-compiler/introduction&quot;&gt;React Compiler Introduction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/learn/react-compiler/installation&quot;&gt;React Compiler Installation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/learn/react-compiler/debugging&quot;&gt;React Compiler Debugging&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/blog/2025/10/07/react-compiler-1&quot;&gt;React Compiler v1.0 Release Blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/reference/eslint-plugin-react-hooks/lints/incompatible-library&quot;&gt;incompatible-library ESLint rule&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/api-reference/config/next-config-js/reactCompiler&quot;&gt;Next.js: reactCompiler config&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;내부 설계 문서&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/facebook/react/blob/main/compiler/docs/DESIGN_GOALS.md&quot;&gt;React Compiler DESIGN_GOALS.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/reactwg/react-compiler/discussions/5&quot;&gt;Introducing React Compiler (reactwg)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/reactwg/react-compiler/discussions/34&quot;&gt;&lt;code&gt;__unstable_donotuse_reportAllBailouts&lt;/code&gt; 논의&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;관련 GitHub 이슈 및 PR&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TypeScript 노드 처리 관련:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/facebook/react/pull/32742&quot;&gt;PR #32742 &amp;mdash; feat(babel-plugin-react-compiler): support satisfies operator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/facebook/react/issues/29754&quot;&gt;Issue #29754 &amp;mdash; Handle TSSatisfiesExpression expressions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/facebook/react/issues/34358&quot;&gt;Issue #34358 &amp;mdash; TSInstantiationExpression as default value in parameter list&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TanStack Table / Query 호환성 관련:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/facebook/react/issues/33057&quot;&gt;facebook/react#33057 &amp;mdash; React Compiler breaks most functionality of TanStack Table&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/facebook/react/issues/34211&quot;&gt;facebook/react#34211 &amp;mdash; breaks referencial stability in @tanstack/react-query&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/facebook/react/pull/31820&quot;&gt;PR #31820 &amp;mdash; add tanstack table and virtual to known incompat libraries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/TanStack/table/issues/5567&quot;&gt;TanStack/table#5567 &amp;mdash; Table doesn't re-render with new React Compiler + React 19&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/TanStack/table/issues/6137&quot;&gt;TanStack/table#6137 &amp;mdash; React Compiler skips memoization for useReactTable&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/TanStack/query/issues/9571&quot;&gt;TanStack/query#9571 &amp;mdash; Referencial stability lost when using react-compiler&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;도구&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://playground.react.dev/&quot;&gt;React Compiler Playground&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://babeljs.io/docs/babel-types&quot;&gt;Babel &lt;code&gt;@babel/types&lt;/code&gt; (TSAsExpression 노드 정의)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고한 분석 글&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://yongseok.me/blog/en/react_compiler_3/&quot;&gt;Yongseok Jang &amp;mdash; React Compiler, How Does It Work? [3] HIR Transformation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yongseok.me/blog/en/react_compiler_4/&quot;&gt;Yongseok Jang &amp;mdash; React Compiler, How Does It Work? [4] SSA Transformation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://shapkarin.me/articles/drop-react-manual-memoization/&quot;&gt;Yuri Shapkarin &amp;mdash; The Mutability &amp;amp; Aliasing Model in React&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://anita-app.com/blog/articles/react-compiler-and-why-class-objects-work-against-memoization.html&quot;&gt;Anita &amp;mdash; React Compiler and why class objects can work against memoization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://gitnation.com/contents/react-compiter-internals&quot;&gt;Lydia Hallie &amp;mdash; React Compiler Internals (GitNation talk)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>momoization</category>
      <category>react-compiler</category>
      <category>typescript</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/334</guid>
      <comments>https://ifelseif.tistory.com/334#entry334comment</comments>
      <pubDate>Thu, 16 Apr 2026 22:04:08 +0900</pubDate>
    </item>
    <item>
      <title>[260413 TIL] 검색 쿼리 최적화 트러블슈팅2</title>
      <link>https://ifelseif.tistory.com/333</link>
      <description>&lt;h1&gt;텍스트 검색 쿼리 1초 &amp;rarr; 0.6ms 최적화 삽질기 (2탄)&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ifelseif.tistory.com/332&quot;&gt;1탄&lt;/a&gt;에서 10초 &amp;rarr; 1초까지 줄였던 검색 쿼리가, 사실 pg_trgm GIN 인덱스를 &lt;b&gt;전혀 타지 않고 있었다.&lt;/b&gt; EXPLAIN ANALYZE로 실행 계획을 직접 확인한 결과 매번 Seq Scan(풀스캔)이 돌고 있었고, 검색 방식을 ILIKE &amp;rarr; 단어 배열(&lt;code&gt;text[]&lt;/code&gt;) + GIN &lt;code&gt;array_ops&lt;/code&gt;로 교체하여 최종 0.6ms까지 단축했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이전 글 요약&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MV + &lt;code&gt;search_text&lt;/code&gt; 컬럼(caption + hashtags 결합) + &lt;code&gt;pg_trgm&lt;/code&gt; GIN 인덱스로 1초 달성&lt;/li&gt;
&lt;li&gt;이 시점에서 &quot;GIN 인덱스 덕에 빨라졌다&quot;고 결론 내림&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이 결론이 완전히 틀렸다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;삽질기: 1초 &amp;rarr; 0.6ms까지의 여정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7단계: 아직도 5초?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1탄에서 1초를 달성했다고 썼지만, 사실 프론트에서 실제로 치는 쿼리 패턴(검색어 + 정렬 + 페이지네이션 조합)에서는 여전히 &lt;b&gt;5-6초&lt;/b&gt;가 걸리고 있었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8단계: EXPLAIN ANALYZE를 처음 찍어보다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전까지는 &quot;인덱스를 걸었으니 탈 것이다&quot;라고 믿고 있었다. 이번에 처음으로 &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;를 찍어봤다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM public.reel_growth_rate_economy g
WHERE g.search_text ILIKE '%경제%'
ORDER BY g.likes DESC NULLS LAST
LIMIT 20;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Index Scan using idx_reel_growth_rate_economy_likes
  Filter: (search_text ~~* '%경제%'::text)
  Rows Removed by Filter: 4408
  Buffers: shared hit=7638&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GIN trigram 인덱스가 아니라 likes B-tree 인덱스를 타고 있었다.&lt;/b&gt; 플래너가 &lt;code&gt;ORDER BY likes DESC LIMIT 20&lt;/code&gt;을 보고 &quot;likes 순으로 스캔하면서 ILIKE 조건 맞는 20개만 찾으면 되겠다&quot;고 판단한 것이다. 문제는 &quot;경제&quot;가 전체 4.2만 행 중 1,538행(3.6%)에만 매칭되기 때문에, 20개를 채우려면 &lt;b&gt;거의 전체를 스캔&lt;/b&gt;해야 한다는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ILIKE 조건은 인덱스가 아닌 &lt;b&gt;필터(Filter)&lt;/b&gt; 로 적용되고 있었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9단계: 그러면 GIN 인덱스를 강제로 태우면?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SET enable_seqscan = off;&lt;/code&gt;로 강제로 GIN 인덱스를 태워봤다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;Bitmap Index Scan on idx_reel_growth_rate_economy_search_text
  Index Cond: (search_text ~~* '%경제%'::text)
  &amp;rarr; 47,368 rows
Bitmap Heap Scan
  Recheck Cond: ...
  Rows Removed by Index Recheck: 40,720&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GIN 인덱스가 &lt;b&gt;47,368행&lt;/b&gt;을 반환했다. MV 전체(42,258행)보다 많다. 인덱스 레벨에서 거의 모든 행이 후보로 잡히고, Heap에서 실제 ILIKE를 다시 검증해서 대부분 버리는 구조. &lt;b&gt;Seq Scan보다 더 느렸다(9초 &amp;rarr; 21초).&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10단계: pg_trgm의 근본적 한계를 이해하다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 &lt;b&gt;한글 2글자 검색어와 trigram의 궁합&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;pg_trgm&lt;/code&gt;은 문자열을 3글자(trigram) 단위로 쪼개서 인덱싱한다. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&quot;경제&quot;는 2글자라 생성되는 trigram이 극히 적고, 인덱스 레벨에서의 필터링 효과가 거의 없다. &lt;br /&gt;결과적으로 인덱스를 탄다 해도 후보군이 전체와 비슷하게 나와서 의미가 없었던 것이다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1탄의 결론이었던 &quot;pg_trgm GIN 인덱스 덕에 1초를 달성했다&quot;는 완전히 틀린 결론이었다.&lt;/b&gt; &lt;br /&gt;실제로는 MV 자체의 효과(매번 JOIN &amp;rarr; 미리 계산된 테이블 조회)와 다른 B-tree 인덱스들 덕이었지, &lt;br /&gt;trigram 인덱스는 처음부터 아무 역할도 하지 않고 있었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11단계: ILIKE를 버리고 단어 배열로 전환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ILIKE 부분 매칭을 포기하고, &lt;b&gt;공백 기준으로 단어를 쪼개서 배열(&lt;code&gt;text[]&lt;/code&gt;)로 저장&lt;/b&gt; + &lt;b&gt;GIN &lt;code&gt;array_ops&lt;/code&gt; 인덱스&lt;/b&gt;로 exact match 검색으로 전환했다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- MV에 search_words 컬럼 추가
array(
  select distinct w
  from unnest(string_to_array(lower(caption || ' ' || hashtags), ' ')) as w
  where w &amp;lt;&amp;gt; ''
) as search_words

-- GIN 인덱스
CREATE INDEX idx_search_words
  ON reel_growth_rate_economy USING gin (search_words array_ops);

-- 쿼리: &quot;이 배열에 '경제'라는 원소가 있는가&quot;
WHERE search_words &amp;amp;&amp;amp; ARRAY['경제']::text[]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: &lt;b&gt;0.56ms.&lt;/b&gt; 기존 대비 약 16,000배 빨라졌다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;12단계: 결과가 절반으로 줄었다?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 검색 결과가 1,200건 &amp;rarr; 675건으로 줄었다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 명확했다. 기존 ILIKE &lt;code&gt;'%경제%'&lt;/code&gt;는 부분 매칭이라 &quot;경제뉴스를&quot;, &quot;한국경제&quot;, &quot;경제적&quot; 등이 모두 잡혔지만, &lt;br /&gt;배열 exact match에서는 정확히 &quot;경제&quot;라는 단어만 매칭된다. 한글은 조사가 붙고 합성어가 많아서 공백 기준 토큰화의 한계가 있다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서브스트링(N-gram)을 배열에 넣는 방법도 검토했지만, 평균 57개인 배열이 수백 개로 불어나기 때문에 비현실적이었다. 결국 exact match로 가되 프론트에서 검색 가이드를 제공하는 방향으로 결정했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;교훈&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&quot;인덱스를 걸었으니 타겠지&quot;는 위험한 가정이다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 만드는 것과 인덱스가 실제로 사용되는 것은 완전히 다른 문제다. &lt;b&gt;EXPLAIN ANALYZE를 찍어보기 전까지는 인덱스가 타는지 알 수 없다.&lt;/b&gt; 이번에 pg_trgm GIN 인덱스를 만들어놓고 한 번도 확인하지 않은 채 &quot;덕분에 빨라졌다&quot;고 결론 내렸던 게 대표적인 사례다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;플래너는 나보다 똑똑하지만, 내 의도를 모른다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostgreSQL 쿼리 플래너는 통계를 기반으로 가장 비용이 낮은 실행 계획을 선택한다. &lt;code&gt;ORDER BY likes DESC LIMIT 20&lt;/code&gt; + &lt;code&gt;ILIKE&lt;/code&gt; 조건이 있으면, 플래너는 &quot;likes 인덱스로 정렬 순서를 공짜로 얻고, 필터로 걸러내자&quot;고 판단한다. 매칭 비율이 높으면 이 전략이 효율적이지만, 매칭 비율이 낮으면(이번 경우 3.6%) 사실상 풀스캔이 된다. 플래너의 선택이 항상 최선은 아니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;pg_trgm은 만능이 아니다 &amp;mdash; 특히 한글 짧은 검색어에서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pg_trgm은 3글자 단위로 인덱싱한다. 검색어가 2글자(&quot;경제&quot;)면 생성되는 trigram이 적어서, 인덱스 레벨에서 후보군을 좁히지 못한다. &quot;ILIKE가 느리면 pg_trgm을 쓰면 된다&quot;는 공식이 항상 성립하는 건 아니다. 데이터의 언어, 검색어 길이, 매칭 비율에 따라 전혀 다른 전략이 필요할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인덱스 전략은 데이터 특성에서 출발해야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 효과적이었던 접근:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;EXPLAIN ANALYZE로 현재 실행 계획 확인&lt;/b&gt; &amp;mdash; 추측이 아닌 사실 기반&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인덱스를 강제로 태워서 비교&lt;/b&gt; &amp;mdash; &lt;code&gt;SET enable_seqscan = off&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인덱스가 비효율적인 이유를 이해&lt;/b&gt; &amp;mdash; trigram + 한글 2글자의 구조적 한계&lt;/li&gt;
&lt;li&gt;&lt;b&gt;검색 요구사항 재정의&lt;/b&gt; &amp;mdash; &quot;부분 매칭이 꼭 필요한가?&quot; &amp;rarr; exact match로 충분&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 구조 자체를 변경&lt;/b&gt; &amp;mdash; ILIKE 문자열 검색 &amp;rarr; 단어 배열 + GIN array_ops&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;어떤 인덱스를 걸까&quot;보다 먼저, &quot;이 데이터에서 이 검색 패턴이면 어떤 자료구조가 맞는가&quot;를 고민해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;EXPLAIN ANALYZE는 DB 작업의 &lt;code&gt;console.log&lt;/code&gt;다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트에서 뭔가 이상하면 콘솔부터 열 듯이, DB 쿼리가 느리면 EXPLAIN ANALYZE부터 찍어야 한다. 이번에 처음 찍어봤는데, 진작 했으면 1탄에서 &quot;pg_trgm 덕에 빨라졌다&quot;는 오진을 하지 않았을 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참조&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/sql-explain.html&quot;&gt;PostgreSQL EXPLAIN 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/gin-builtin-opclasses.html&quot;&gt;PostgreSQL GIN 인덱스 &amp;mdash; Array 연산자&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/pgtrgm.html&quot;&gt;pg_trgm &amp;mdash; Trigram Matching&lt;/a&gt; (한계점 이해용)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>db</category>
      <category>explain-analyze</category>
      <category>query</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/333</guid>
      <comments>https://ifelseif.tistory.com/333#entry333comment</comments>
      <pubDate>Tue, 14 Apr 2026 08:29:54 +0900</pubDate>
    </item>
    <item>
      <title>[260402 TIL] 검색 쿼리 최적화 트러블슈팅</title>
      <link>https://ifelseif.tistory.com/332</link>
      <description>&lt;h1&gt;텍스트 검색 쿼리 10초(??) &amp;rarr; 1초 최적화 트러블슈팅&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase에서 대략 8만 행 풀 대상 해시태그+캡션 텍스트 검색 쿼리가 10초(????)+ 걸리던 것을, &lt;br /&gt;View &amp;rarr; Materialized View + 인덱스 &amp;rarr; pg_trgm GIN 인덱스 적용으로 1초까지 줄였다. &lt;br /&gt;과정에서 중복 집계 버그도 발견&amp;middot;수정.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회사에서 스크래핑 어쩌고 프로젝트를 진행 중이다.&lt;br /&gt;대충 인스타 트렌딩 릴스 스크래핑 어쩌구인데.. 검색 기능이 필요하게 되었다.&lt;br /&gt;이 검색 기능의 성능 문제를 해결해나간 과정을 정리한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테이블 구조&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;reels&lt;/code&gt;: 최초 수집 데이터 (릴스 기본 정보, 캡션 등)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reel_metrics&lt;/code&gt;: 서드파티를 통해 +1일 후 수집한 메트릭 데이터. diff를 보기 위해 릴스당 2개의 행이 존재&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 시 두 테이블을 JOIN해야 하는 구조였고,&lt;br /&gt;이를 기반으로 &lt;code&gt;reel_growth_rate&lt;/code&gt;라는 View를 만들어 사용하고 있었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;삽질기: 10초 &amp;rarr; 1초까지의 여정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1단계: 문제 인식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 필터 조건은 &lt;code&gt;카테고리 = &quot;OO&quot;&lt;/code&gt; &amp;amp; 나머지 조건 없음. &lt;br /&gt;약 4만 행의 풀에서 800개 정도가 매칭되는데, 쿼리 시간이 &lt;b&gt;8초 이상&lt;/b&gt; 걸렸다. &lt;br /&gt;거기에 더해 간헐적으로 Supabase가 에러를 뱉었다. &lt;br /&gt;양이 많고 복잡해서인지, 타임아웃인지 정확한 원인은 파악하지 못했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계: View + RPC 적용 (8초 &amp;rarr; 5초)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;막연히 RPC면 빨라지겠지&quot;라는 생각으로 기존 View 위에 RPC를 걸었다.&lt;br /&gt;쿼리 시간은 5초 정도로 줄었고, 간헐적 에러도 줄어 안정성이 올라갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여전히 느렸다. 그래도... 일단 돌아가니까 놔뒀다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3단계: 풀 증가로 재폭발&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보관 기한이 1달인데, 1달이 다가오자 풀이 약 8만 행으로 늘어났다.&lt;br /&gt;쿼리 시간은 &lt;b&gt;10초 가까이&lt;/b&gt; 늘어났고, 더 이상 방치할 수 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 들여다보니 View와 RPC 사용 모두가 문제였다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;View의 한계&lt;/b&gt;: 행 수가 많은 상황에서 일반 View는 매 쿼리마다 내부 JOIN을 실행한다. 인덱스도 걸 수 없다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RPC의 실체&lt;/b&gt;: 내부적으로 View를 참조하고 있었으므로, 결국 매번 JOIN이 돌아가고 있던 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4단계: Materialized View + 인덱스 (10초 &amp;rarr; 1.5초, 그러나...)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;그냥 MV를 만드는 게 낫겠다&quot;는 생각이 (이제서야) 들었다.&lt;br /&gt;MV로 전환하고 인덱스를 걸자 RPC도 필요 없어졌고, 쿼리 시간이 1.5&lt;b&gt;초&lt;/b&gt;로 단축되었다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그런데 여기서 문제가 발생했다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 필터 쿼리 결과가 800개에서 &lt;b&gt;300개 수준으로 급감&lt;/b&gt;한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;풀은 오히려 늘었는데 결과는 줄었다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 파보니, View와 MV를 만들 때 원 테이블을 그대로 JOIN하고 있었다.&lt;br /&gt;&lt;code&gt;reel_metrics&lt;/code&gt;는 릴스당 2개의 행이 있으므로(diff를 보기 위해 2회 수집),&lt;br /&gt;중복 제거를 하지 않으면 하나의 릴스가 2번 카운트된다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;맨 처음 일반 View를 만들었을 때부터 잘못하고 있었던 것이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 800~1000개로 보고 있던 수치의 절반은 중복이었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5단계: 1/3로 줄었다? 절반이 아니고?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 다시 생각해보니, 중복 제거를 하면 &lt;b&gt;절반&lt;/b&gt;으로 줄어야 하는데 왜 &lt;b&gt;1/3&lt;/b&gt; 수준이 된 걸까?&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 살펴보니... 맨 처음에는 해시태그 + 캡션 둘 다 검색이었는데,&lt;br /&gt;MV를 만드는 과정에서 돌았는지 &lt;b&gt;해시태그만 ILIKE 하고 캡션은 빠져 있었다.&lt;br /&gt;&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캡션 검색을 다시 추가하자 카운트는 정상(기존의 딱 절반)으로 돌아왔다.&lt;br /&gt;그런데 쿼리 시간이 &lt;b&gt;4초 이상&lt;/b&gt;으로 다시 늘어났다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6단계: pg_trgm GIN 인덱스 (4초 &amp;rarr; 1초)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ILIKE 검색은 결국 풀스캔이라고 한다..&lt;br /&gt;두 컬럼에 ILIKE를 걸면 그만큼 느려질 수밖에 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;pg_trgm&lt;/code&gt; 확장이라는 것을 찾았고,&lt;br /&gt;MV에 해시태그+캡션을 결합한 검색용 컬럼을 하나 추가한 뒤 GIN 인덱스를 적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최종 결과: 카운트 정확(기존 대비 정확히 절반) + 쿼리 시간 약 1초.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;교훈&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;View vs Materialized View 선택 기준&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 View는 매 쿼리마다 내부 쿼리를 실행하므로, 행 수가 많고 JOIN이 포함된 경우 성능이 나오지 않는다.&lt;br /&gt;읽기 빈도가 높고 실시간성이 덜 중요하다면 MV가 맞다. MV는 인덱스도 걸 수 있다.(용량은 뭐...)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JOIN 결과의 행 수를 항상 검증할 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JOIN 시 1:N 관계에서 중복 행이 발생하는 건 기본 중의 기본이지만, 처음 접하면 놓치기 쉽다.&lt;br /&gt;View나 MV를 만든 후에는 반드시 원본 대비 행 수를 검증해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ILIKE의 성능 한계와 pg_trgm&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ILIKE는 인덱스를 타지 못하고 풀스캔을 한다.&lt;br /&gt;텍스트 검색이 핵심 기능이라면 &lt;code&gt;pg_trgm&lt;/code&gt; 확장 + GIN 인덱스를 처음부터 고려하는 것이 좋다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;B-tree: LIKE 'keyword%'만 인덱스 사용 가능 (앞쪽 고정)&lt;/li&gt;
&lt;li&gt;GIN trigram: ILIKE '%keyword%'도 인덱스 사용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&quot;일단 돌아가니까&quot;의 대가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대충 5초 정도였을 때 제대로 파봤으면,&lt;br /&gt;풀이 늘어나서 10초가 되기 전에 해결할 수 있었다.&lt;br /&gt;기술 부채는 데이터가 늘어나면 이자가 붙는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;LLM과 페어 프로그래밍할 때, &quot;OK&quot; 버튼을 누르기 전에&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 LLM 선생님들이 이미 사람보다 훨씬 나은 면이 많다 보니(어떤 면에서는 시니어 개발자보다도??),&lt;br /&gt;추천해주는 방향대로 무조건 OK 하게 되는 경향이 있다.&lt;br /&gt;이번에 View + RPC 조합을 선택했던 것도 그런 과정이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;나는 &quot;View + RPC면 막연히 빠르겠지&quot;라는 생각이 있었고, 그대로 LLM에게 물어봤다.&lt;br /&gt;선생님들도 그게 좋겠다고 해서 그대로 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 여기서 빠진 건 &lt;b&gt;구체적인 컨텍스트&lt;/b&gt;였다. &lt;br /&gt;LLM은 내가 &quot;View&quot;를 이야기했을 때 그 View 아래로 수만 행의 JOIN 결과가 매번 만들어질지는 몰랐을 것이다. &lt;br /&gt;내가 그 정보를 주지 않았으니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM이 아무리 똑똑해도, 내 상황의 구체적인 맥락은 내가 제공해야 한다. &lt;br /&gt;&quot;이 방향이 맞나요?&quot;가 아니라 &lt;b&gt;&quot;4만 행이 JOIN되는 상황에서 이 방향이 맞나요?&quot;&lt;/b&gt;라고 물어야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;좋은 답을 얻으려면 좋은 질문을 해야 한다는... &lt;/s&gt;&lt;br /&gt;&lt;s&gt;사람들은 하네스가 어쩌고 저쩌고 하는데 아직 이러고 있다...&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이게 끝이 아니었다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 가정은 완전히 틀려있었다 ㅠㅠ...&amp;nbsp;&lt;br /&gt;&lt;a href=&quot;https://ifelseif.tistory.com/332&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2탄보러가기&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참조&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/pgtrgm.html&quot;&gt;PostgreSQL pg_trgm 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/gin-intro.html&quot;&gt;PostgreSQL GIN 인덱스 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://supabase.com/docs/guides/database/tables#materialized-views&quot;&gt;Supabase - Materialized Views&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>Gin</category>
      <category>ilike</category>
      <category>Index</category>
      <category>materialized-view</category>
      <category>PG</category>
      <category>postgres</category>
      <category>query</category>
      <category>view</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/332</guid>
      <comments>https://ifelseif.tistory.com/332#entry332comment</comments>
      <pubDate>Thu, 2 Apr 2026 08:40:25 +0900</pubDate>
    </item>
    <item>
      <title>[260331 TIL] Sentry쓰다가 얻어걸린 인앱브라우저 에러</title>
      <link>https://ifelseif.tistory.com/331</link>
      <description>&lt;h1&gt;Sentry에서 잡힌 Safari TypeError의 정체 — Facebook In-App Browser의 주입 스크립트&lt;/h1&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;발단: Sentry에 잡힌 알 수 없는 에러&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;프로덕션 Sentry 대시보드에 처음 보는 에러가 올라왔다.&lt;/p&gt;&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;TypeError: Attempting to change value of a readonly property.
    at defineProperty ([native code])
    at ? (app:///10e8bc2c-fca1-4aa8-9c1d-87e872816d2a/ready:1:4715)
    at global code (app:///10e8bc2c-fca1-4aa8-9c1d-87e872816d2a/ready:1:4819)&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;프로젝트는 Next.js 16 (App Router) + React Compiler + Turbopack 스택이고,&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;에러가 발생한 페이지는 결제 직전의 &lt;code&gt;/ready&lt;/code&gt; 페이지였다.&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;readonly property를 변경하려 했다&quot;는 메시지, &lt;code&gt;Object.defineProperty&lt;/code&gt;에서의 실패,&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;code&gt;global code&lt;/code&gt; 시점 실행.&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;뭔가 심각해 보였다.&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;삽질 1: React Compiler를 의심하다&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;스택 트레이스에서 &lt;code&gt;defineProperty ([native code])&lt;/code&gt;가 보이니까,&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;React Compiler가 memoization 캐시를 설정할 때 &lt;code&gt;Object.defineProperty&lt;/code&gt;를 사용하는 게 아닌가 의심했다.&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;reactCompiler: true&lt;/code&gt;로 활성화된 상태였고, Safari에서만 발생하는 것 같았으니 &quot;React Compiler + Safari = 충돌&quot;이라는 가설이 그럴듯했다.&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과: 배제.&lt;/b&gt; React Compiler의 출력물을 분석해보니, 컴파일러는 &lt;code&gt;Object.defineProperty&lt;/code&gt;를 전혀 사용하지 않는다. 출력은 &lt;code&gt;import { c as _c } from &quot;react/compiler-runtime&quot;&lt;/code&gt;, &lt;code&gt;const $ = _c(N)&lt;/code&gt;, 그리고 if/else 캐시 체크가 전부다. 모듈 레벨 변환도 import문 추가 외에는 없다.&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;삽질 2: Turbopack 모듈 시스템을 의심하다&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 &lt;code&gt;defineProperty&lt;/code&gt;를 호출하는 건 번들러인 Turbopack이 아닌가?&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;Turbopack의 &lt;code&gt;__turbopack_esm__&lt;/code&gt; 함수는 실제로 &lt;code&gt;Object.defineProperty&lt;/code&gt;로 모듈 exports를 정의하고, &lt;code&gt;configurable: true&lt;/code&gt;를 생략하면 기본값이 &lt;code&gt;false&lt;/code&gt;가 되어 재정의 시 에러가 발생할 수 있다.&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;Next.js GitHub에서도 Turbopack + React Compiler 조합의 불안정성이 보고된 이슈들(#78163, #78924)이 있었고,&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;Safari 특유의 Turbopack 문제(#71923)도 있었다. &quot;Turbopack이 Safari에서 모듈 exports를 재정의하다 실패&quot;라는 가설을 세웠고, WKWebView의 &lt;code&gt;app:///&lt;/code&gt; 스킴이 모듈 재평가를 유발한다고 추론했다.&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과: 방향은 맞았지만 원인 주체가 틀렸다.&lt;/b&gt; &lt;code&gt;app:///&lt;/code&gt; 스킴의 해석이 잘못되었다.&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;삽질 3:  적용&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;React Compiler가 가장 유력한 원인이라고 판단해서, ready 페이지 관련 3개 컴포넌트에 &lt;code&gt;&quot;use no memo&quot;&lt;/code&gt; 디렉티브를 적용했다.&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt; 
 &lt;li&gt;&lt;code&gt;templates/ready-template.tsx&lt;/code&gt;&lt;/li&gt; 
 &lt;li&gt;&lt;code&gt;components/ready/ready-payment.tsx&lt;/code&gt;&lt;/li&gt; 
 &lt;li&gt;&lt;code&gt;components/common/paypal-button.tsx&lt;/code&gt;&lt;/li&gt; 
&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;세 컴포넌트 모두 단순한 구조라 성능 영향은 미미했지만, 결과적으로 이 조치는 이 에러에 대해서는 불필요했다.&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;전환점: Sentry 태그를 다시 보다&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;분석 문서를 정리하고 나서, Sentry 태그를 다시 꼼꼼히 봤다.&lt;/p&gt;&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;browser=Facebook 554.0.0
browser.name=Facebook
device=iPhone 13 Pro
os=iOS 26.4
mechanism=auto.browser.global_handlers.onerror
handled=no
url=https://readmypillars.com/.../ready&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;browser=Facebook 554.0.0&lt;/code&gt;.&lt;/b&gt; 이건 Safari가 아니라 Facebook 앱 내장 브라우저(Facebook In-App Browser)다.&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 결정적인 단서: &lt;b&gt;이 에러를 겪은 유저가 결제까지 성공적으로 완료&lt;/b&gt;했다.&lt;br&gt;&lt;br&gt;DB에 status가 &lt;code&gt;done&lt;/code&gt;으로 찍혀 있었다. &lt;code&gt;/ready&lt;/code&gt; → &lt;code&gt;/loading&lt;/code&gt; → &lt;code&gt;/my-result&lt;/code&gt; 플로우가 정상 동작한 것이다.&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론: Facebook In-App Browser가 주입한 스크립트의 에러&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;모든 퍼즐이 맞춰졌다.&lt;br&gt;&lt;b&gt;Facebook In-App Browser(FIAB)는 페이지를 로드할 때 자체 JavaScript를 주입한다.&lt;/b&gt;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;이 주입된 스크립트가 &lt;code&gt;global code&lt;/code&gt; 시점에 &lt;code&gt;Object.defineProperty&lt;/code&gt;를 호출하다가, Safari(iOS의 WKWebView)의 strict mode에서 readonly property 위반으로 TypeError가 발생한 것이다. 이 에러는 FIAB의 스크립트에서 발생한 것이지 내 앱 코드와는 무관하고, &lt;code&gt;window.onerror&lt;/code&gt;를 통해 Sentry에 캡처된 것뿐이다.&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;시간순으로 정리하면:&lt;/p&gt;&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt; 
 &lt;li&gt;유저가 Facebook 앱에서 우리 앱 링크를 탭&lt;/li&gt; 
 &lt;li&gt;Facebook In-App Browser가 페이지를 로드하면서 자체 스크립트를 주입&lt;/li&gt; 
 &lt;li&gt;주입된 스크립트가 &lt;code&gt;global code&lt;/code&gt; 실행 시점에 &lt;code&gt;Object.defineProperty&lt;/code&gt; 호출 → Safari strict mode에서 TypeError&lt;/li&gt; 
 &lt;li&gt;&lt;code&gt;window.onerror&lt;/code&gt;가 이 에러를 캡처&lt;/li&gt; 
 &lt;li&gt;Sentry SDK가 &lt;code&gt;beforeSend&lt;/code&gt; 콜백을 통해 에러를 서버로 전송&lt;/li&gt; 
 &lt;li&gt;&lt;b&gt;앱 코드는 정상 실행&lt;/b&gt; — 유저는 결제 완료, status=done&lt;/li&gt; 
&lt;/ol&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;이 에러에서 &lt;code&gt;app:///UUID/ready&lt;/code&gt; 스킴은 WKWebView/Cordova 하이브리드 앱이 아니라, &lt;b&gt;FIAB가 주입한 스크립트의 내부 소스 URL&lt;/b&gt;이었다.&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;대응&lt;/h2&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;적용한 것&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;instrumentation-client.ts&lt;/code&gt;의 &lt;code&gt;beforeSend&lt;/code&gt;에 서드파티 주입 스크립트 에러 필터링을 추가했다.&lt;/p&gt;&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;function isThirdPartyInjectedScriptError(event: Sentry.ErrorEvent): boolean {
  const frames = event.exception?.values?.[0]?.stacktrace?.frames;
  if (!frames?.length) return false;

  const hasAppScheme = frames.some(
    (frame) =&amp;gt; frame.filename?.startsWith(&quot;app:///&quot;)
  );

  const message = event.exception?.values?.[0]?.value ?? &quot;&quot;;
  const isReadonlyError = message.includes(&quot;readonly property&quot;);

  return hasAppScheme &amp;amp;&amp;amp; isReadonlyError;
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br&gt;두 조건(&lt;code&gt;app:///&lt;/code&gt; 스킴 + &lt;code&gt;readonly property&lt;/code&gt; 메시지)을 AND로 결합해서 자체 앱 코드의 에러가 실수로 필터링되지 않도록 했다. &lt;code&gt;browser.name&lt;/code&gt; 대신 &lt;code&gt;app:///&lt;/code&gt; 스킴으로 필터링한 이유는, Facebook 외에 Instagram, LINE 등 다른 In-App Browser에서도 동일 패턴이 발생할 수 있기 때문이다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;보류 중인 것&lt;/h3&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt; 
 &lt;li&gt;&lt;code&gt;&quot;use no memo&quot;&lt;/code&gt; 제거: 추가 사례가 쌓여서 Facebook FIAB 전용 에러임이 확정되면 제거 예정&lt;/li&gt; 
 &lt;li&gt;이 에러 자체는 Facebook이나 Apple이 해결해야 할 문제이므로, 앱 쪽에서 할 수 있는 건 Sentry 노이즈 필터링이 전부&lt;/li&gt; 
&lt;/ul&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 것&lt;/h2&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Sentry 태그를 먼저 봐야 한다&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;스택 트레이스에 매몰되어 코드 레벨 분석부터 시작했는데, Sentry 태그의 &lt;code&gt;browser=Facebook 554.0.0&lt;/code&gt;이 처음부터 답을 가지고 있었다. 에러의 &quot;무엇(what)&quot;보다 &quot;어디서(where)&quot;를 먼저 확인하는 습관이 필요하다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 유저 플로우 확인이 에러의 심각도를 판별한다&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;에러가 발생했는데 유저가 정상적으로 플로우를 완료했다면, 그건 앱 코드의 에러가 아닐 가능성이 높다. DB에서 해당 유저의 상태를 확인하는 것만으로 디버깅 방향이 크게 좁혀졌다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.  스킴은 여러 컨텍스트에서 나타난다&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;app:///&lt;/code&gt;를 보고 Cordova/WKWebView 하이브리드 앱으로 바로 연결지었는데, Facebook In-App Browser 같은 서드파티 앱의 주입 스크립트에서도 이 스킴이 사용된다. URL 스킴만으로 환경을 단정짓지 말고 다른 태그와 교차 검증해야 한다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 서드파티 스크립트 에러는 프로덕션에서 흔하다&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;In-App Browser, 광고 SDK, 브라우저 확장 프로그램 등이 주입하는 스크립트에서 발생하는 에러가 &lt;code&gt;window.onerror&lt;/code&gt;를 통해 Sentry에 잡히는 건 드문 일이 아니다. &lt;code&gt;beforeSend&lt;/code&gt;에서 &lt;code&gt;app:///&lt;/code&gt;, &lt;code&gt;chrome-extension://&lt;/code&gt; 등 서드파티 스킴의 에러를 필터링하는 건 Sentry 노이즈 관리의 기본이다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 가설을 세울 때 &quot;영향 범위&quot;부터 확인하자&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;React Compiler 가설 → Turbopack 가설 → FIAB 가설로 점점 좁혀졌는데, 처음부터 &quot;이 에러가 앱 동작을 차단했는가?&quot;를 확인했다면 훨씬 빨리 방향을 잡았을 것이다. 에러의 기술적 원인을 파기 전에, 비즈니스 임팩트부터 확인하는 게 효율적이다.&lt;/p&gt;</description>
      <category>TIL</category>
      <category>defineProperty</category>
      <category>react-compiler</category>
      <category>sentry</category>
      <category>use-no-memo</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/331</guid>
      <comments>https://ifelseif.tistory.com/331#entry331comment</comments>
      <pubDate>Tue, 31 Mar 2026 17:23:53 +0900</pubDate>
    </item>
    <item>
      <title>[260312 TIL] Next.js + Prisma + tRPC + TQuery</title>
      <link>https://ifelseif.tistory.com/330</link>
      <description>&lt;h1&gt;Next.js + tRPC + Prisma + TanStack Query, 아직 유효한 풀스택 타입-세이프 조합?&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server Action의 부상으로 tRPC가 한물갔다는 인식이 있지만, 실제로는 v11 릴리즈(2025.03)와 주간 70만+ npm 다운로드로 건재하다. 오히려 2024~2025년 연이어 터진 Next.js 보안 취약점(CVE-2025-29927, CVE-2025-66478 등)은 Server Action이 의존하는 미들웨어/RSC 프로토콜의 위험성을 드러냈고, 명시적 API 경계를 제공하는 tRPC의 아키텍처적 가치를 재확인시켜 주었다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 사내 프로젝트를 &lt;b&gt;Next.js + tRPC + Prisma + TanStack Query + AWS RDS(PostgreSQL)&lt;/b&gt; 스택으로 단기간에 구축한 결과, DB 마이그레이션부터 클라이언트 화면까지 &lt;b&gt;end-to-end 타입-세이프&lt;/b&gt;하고 &lt;b&gt;레이어 분리가 명확한&lt;/b&gt; 애플리케이션을 빠르게 만들 수 있었다. 특히 LLM 페어 프로그래밍에서 각 레이어가 독립적이고 예측 가능한 패턴을 가져, AI가 정확한 코드를 생성하는 데 큰 도움이 되었다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개요&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배경: Server Action 시대에 tRPC를 선택한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js App Router와 함께 Server Action이 등장하면서 tRPC는 &quot;더 이상 필요 없다&quot;는 의견이 커뮤니티에서 종종 보인다. Dan Abramov가 Server Action을 &quot;bundler feature로서의 tRPC&quot;라고 표현한 것도 이런 흐름에 힘을 실어주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실무에서 Server Action을 쓰다 보면 몇 가지 불편함이 체감된다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) 보안 우려가 현실로 드러났다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2025년, Next.js에서 치명적인 CVE가 연달아 공개되었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CVE-2025-29927&lt;/b&gt; (CVSS 9.1): &lt;code&gt;x-middleware-subrequest&lt;/code&gt; 헤더를 조작하면 미들웨어를 완전히 우회할 수 있는 취약점. Server Action의 인증/인가가 미들웨어에 의존하는 경우, 이 우회로 Server Action 엔드포인트가 그대로 노출되었다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CVE-2025-66478&lt;/b&gt; (CVSS 10.0): &quot;React2Shell&quot;로 불린 RSC Flight 프로토콜의 역직렬화 취약점. 인증 없이 원격 코드 실행(RCE)이 가능했으며, 공개 수 시간 내에 실제 공격이 관측되었다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CVE-2024-34351&lt;/b&gt; (CVSS 7.5): Server Action의 리다이렉트 과정에서 Host 헤더를 조작해 SSRF가 가능한 취약점.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 공식 문서에서도 &quot;Server Action을 만들고 export하면 기본적으로 공개 HTTP 엔드포인트가 생성된다&quot;고 명시하고 있다. 인증, 인가, 입력 검증, rate limiting 등은 모두 개발자가 직접 추가해야 한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2) 레이어 분리가 어렵다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server Action은 컴포넌트 파일 안에 &lt;code&gt;&quot;use server&quot;&lt;/code&gt;로 인라인 정의할 수 있어 편리하지만, 라우터 네임스페이스나 미들웨어 체인 같은 구조적 장치가 없다. 커뮤니티에서도 &quot;Server Action을 네임스페이스로 그룹핑하기 어렵다&quot;는 지적이 꾸준히 나온다. 오픈소스 문서 서명 플랫폼 Documenso는 Server Action에서 tRPC로 역마이그레이션하기도 했다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3) 데이터 페칭에서의 한계.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server Action은 모든 요청이 POST이므로 HTTP 캐싱이 불가능하고, 같은 페이지에서 여러 Server Action을 호출하면 병렬 실행이 안 되어 페이지 로드가 느려진다. TanStack Query가 제공하는 stale-while-revalidate, optimistic update, prefetch 같은 기능도 자연스럽게 쓸 수 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이번 프로젝트의 제약 조건&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 만든 웹 애플리케이션은 다음과 같은 조건이 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Next.js가 직접 AWS RDS(PostgreSQL)에 Prisma 어댑터를 통해 커넥션 풀을 만들어 연결해야 했다.&lt;/li&gt;
&lt;li&gt;단기간 내에 완성해야 해서, 서버-클라이언트 간 타입을 프로젝트 단위로 빠르게 통일할 방법이 필요했다.&lt;/li&gt;
&lt;li&gt;이전에는 Zod 스키마로 request/response 객체를 직접 검증하는 방식을 사용했지만, 이번엔 그 스키마 작성 시간조차 아끼고 싶었다.&lt;/li&gt;
&lt;li&gt;LLM과의 페어 프로그래밍을 적극 활용할 계획이었으므로, AI가 추적하기 쉬운 구조가 중요했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 조건들을 종합하니 &lt;b&gt;tRPC + Prisma + TanStack Query&lt;/b&gt; 조합이 답이었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;타입 흐름: Prisma Schema &amp;rarr; tRPC Router &amp;rarr; TanStack Query Hook&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 스택의 핵심은 &lt;b&gt;코드 생성이나 수동 타입 정의 없이&lt;/b&gt; DB에서 UI까지 타입이 자동으로 흘러간다는 점이다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;[Prisma Schema] &amp;rarr; prisma generate &amp;rarr; [TypeScript 타입]
       &amp;darr;
[tRPC Router] &amp;larr; ctx.prisma.post.findMany() 반환 타입 자동 추론
       &amp;darr;
[TanStack Query Hook] &amp;larr; api.post.getAll.useQuery() &amp;rarr; Post[] 타입 자동 완성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;br /&gt;Prisma&lt;/b&gt;가 DB 스키마로부터 &lt;code&gt;Post&lt;/code&gt;, &lt;code&gt;Prisma.PostCreateInput&lt;/code&gt;, &lt;code&gt;Prisma.PostWhereInput&lt;/code&gt; 등의 TypeScript 타입을 생성한다. &lt;b&gt;tRPC&lt;/b&gt; 프로시저에서 &lt;code&gt;ctx.prisma.post.findMany()&lt;/code&gt;를 호출하면, 그 반환 타입이 프로시저의 output 타입으로 자동 추론된다. 클라이언트에서 &lt;b&gt;TanStack Query&lt;/b&gt; 훅을 호출하면 tRPC의 타입 추론을 통해 완전히 타이핑된 데이터를 받는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실천한 주요 패턴&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;PrismaClient를 tRPC context로 전달:&lt;/b&gt; PrismaClient를 한 번만 초기화하고 context 객체에 붙여서 모든 프로시저에서 공유했다. Next.js 개발 환경에서는 &lt;code&gt;globalThis&lt;/code&gt;에 저장하는 싱글턴 패턴을 적용해 핫 리로드 시 커넥션 고갈을 방지했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Zod validation은 tRPC 프로시저 레벨에서:&lt;/b&gt; 입력 검증을 Prisma에 도달하기 전에 tRPC의 &lt;code&gt;.input()&lt;/code&gt; 에서 Zod로 처리해, 잘못된 데이터가 DB 레이어까지 내려가지 않도록 했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;tRPC v11의 새 TanStack Query 통합 사용:&lt;/b&gt; 기존 tRPC 클라이언트는 &lt;code&gt;useQuery&lt;/code&gt;/&lt;code&gt;useMutation&lt;/code&gt;을 래핑하는 방식이라 React hooks 규칙을 위반하는 문제가 있었다. v11의 새 통합은 TanStack Query의 &lt;code&gt;queryOptions&lt;/code&gt; API를 네이티브로 사용해 React Compiler와도 호환된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;RouterOutputs&lt;/code&gt; 헬퍼 타입 활용:&lt;/b&gt; 프론트엔드에서 &lt;code&gt;type User = RouterOutputs['user']['getById']&lt;/code&gt; 형태로 서버 응답 타입을 추론해, 별도의 타입 정의 파일 없이도 컴포넌트에서 정확한 타입을 사용했다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AWS RDS 연결 시 주의사항&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prisma로 AWS RDS에 연결할 때 한 가지 중요한 함정이 있다. &lt;b&gt;AWS RDS Proxy는 Prisma와 함께 사용할 때 커넥션 풀링 이점이 없다.&lt;/b&gt; Prisma가 모든 쿼리에 prepared statement를 사용하기 때문에 RDS Proxy가 커넥션을 고정(pin)시켜 재사용이 안 된다. Prisma 공식 문서에서도 이를 명시하고 있다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대안으로는 PgBouncer(같은 VPC의 EC2에서 transaction mode로 운영)나 Prisma Accelerate를 사용할 수 있다. PgBouncer 사용 시에는 connection URL에 &lt;code&gt;pgbouncer=true&lt;/code&gt;를 설정하고, 마이그레이션용 별도 &lt;code&gt;DIRECT_URL&lt;/code&gt;을 구성해야 한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서는 Next.js가 Prisma의 &lt;code&gt;@prisma/adapter-pg&lt;/code&gt;를 통해 직접 RDS에 붙는 구조를 사용했다. Prisma 7부터는 Rust 쿼리 엔진이 사라지고 TypeScript 기반 쿼리 컴파일러로 대체되면서 번들 크기가 약 90% 줄었다(14MB &amp;rarr; 1.6MB). Pool 설정은 어댑터에서 직접 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;성과&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. DB migrate부터 화면까지 타입-세이프한 개발 경험&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prisma 스키마를 수정하고 &lt;code&gt;prisma migrate dev&lt;/code&gt;를 실행하면, 타입 변경이 tRPC 라우터를 거쳐 프론트엔드 훅까지 자동으로 전파된다. 필드명을 바꾸거나 nullable을 변경하면 관련된 모든 곳에서 TypeScript 컴파일 에러가 발생해서, 런타임에 발견하는 대신 개발 시점에 잡을 수 있었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. TanStack Query와의 궁합&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;tRPC v11의 새 TanStack Query 통합 덕분에 캐싱, invalidation, optimistic update가 자연스럽게 작동했다. 기존에 Zod + fetch로 직접 관리하던 서버 상태를 TanStack Query의 선언적 패턴으로 대체하니 보일러플레이트가 크게 줄었다. 특히 목록 페이지에서 필터링/정렬/페이지네이션을 처리할 때, &lt;b&gt;tRPC의 query key가 TanStack Query의 캐시 키와 자연스럽게 맞물려 별도 key 설계가 필요 없었다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. LLM 페어 프로그래밍에 유리한 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 스택이 AI 코딩 어시스턴트와 특히 잘 맞는다고 느꼈는데, 그 이유는 세 가지다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;레이어별 독립적 컨텍스트 제공이 가능하다.&lt;/b&gt; Prisma 스키마를 보여주면 데이터 모델링, tRPC 라우터를 보여주면 API 로직, TanStack Query 훅을 보여주면 UI 로직에 집중하게 할 수 있다. 각 레이어의 패턴이 일관적이므로 AI의 컨텍스트 윈도우를 효율적으로 사용할 수 있다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;타입 체계가 AI의 실수를 자동으로 잡아준다.&lt;/b&gt; GitHub의 연구에 따르면 LLM이 생성한 코드의 컴파일 에러 중 94%가 타입 체크 실패라고 한다. Prisma &amp;rarr; tRPC &amp;rarr; TanStack Query로 이어지는 end-to-end 타입 흐름이 이런 에러를 즉시 잡아준다. AI가 생성한 코드가 타입 체크를 통과하면, 상당 부분 정확한 코드라고 신뢰할 수 있는 &quot;검증 루프&quot;가 형성된다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;선언적이고 예측 가능한 패턴이다.&lt;/b&gt; Prisma의 선언적 스키마, tRPC의 라우터 프로시저 + Zod 검증, TanStack Query의 훅 패턴은 모두 정형화되어 있다. Prisma 공식 블로그에서도 PSL(Prisma Schema Language)이 LLM과 AI 도구가 스키마를 생성하고 수정하기 쉽도록 설계되었다고 밝히고 있다. 실제로 Prisma는 Cursor, Windsurf, GitHub Copilot 연동 가이드와 MCP 서버까지 공식 제공한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 단점: Prisma의 쿼리 복잡성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 불편했던 부분도 있다. WHERE 절이 복잡해지는 경우, 특히 여러 릴레이션을 넘나드는 필터링에서 Prisma의 쿼리 코드가 상당히 장황해진다. &lt;code&gt;some&lt;/code&gt;, &lt;code&gt;is&lt;/code&gt;, &lt;code&gt;every&lt;/code&gt; 같은 nested relation 필터를 중첩하다 보면 가독성이 떨어진다. 이건 tRPC의 문제가 아니라 Prisma의 &lt;code&gt;findMany&lt;/code&gt; 등이 JOIN이 많아질수록 복잡하게 보이는 특성이다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 탈출구는 있다. Prisma의 &lt;b&gt;TypedSQL&lt;/b&gt;(v5.19.0+)을 사용하면 &lt;code&gt;.sql&lt;/code&gt; 파일에 직접 SQL을 작성하면서도 타입 안전성을 유지할 수 있다. 또한 &lt;b&gt;relation load strategy&lt;/b&gt;에서 &lt;code&gt;relationLoadStrategy: 'join'&lt;/code&gt; 옵션을 사용하면 PostgreSQL의 LATERAL JOIN을 활용해 별도 쿼리 대신 단일 JOIN 쿼리로 처리할 수도 있다. 커뮤니티의 합의는 &quot;90%의 쿼리는 Prisma Client로, 나머지 10%의 복잡한 쿼리는 raw SQL 또는 TypedSQL로&quot; 하는 것이 현실적이라는 것이다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고자료&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;tRPC&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://trpc.io/blog/announcing-trpc-v11&quot;&gt;Announcing tRPC v11&lt;/a&gt; &amp;mdash; v11 릴리즈 공식 블로그&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://trpc.io/blog/introducing-tanstack-react-query-client&quot;&gt;Introducing the new TanStack React Query integration&lt;/a&gt; &amp;mdash; 새 TanStack Query 통합 소개&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://trpc.io/blog/trpc-actions&quot;&gt;Using Server Actions with tRPC&lt;/a&gt; &amp;mdash; tRPC에서 Server Action을 함께 사용하는 방법&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.npmjs.com/package/@trpc/server&quot;&gt;@trpc/server npm&lt;/a&gt; &amp;mdash; npm 패키지 (다운로드 수 확인)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Next.js 보안 취약점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://projectdiscovery.io/blog/nextjs-middleware-authorization-bypass&quot;&gt;CVE-2025-29927 기술 분석 (ProjectDiscovery)&lt;/a&gt; &amp;mdash; 미들웨어 우회 취약점&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://vercel.com/blog/postmortem-on-next-js-middleware-bypass&quot;&gt;Postmortem on Next.js Middleware bypass (Vercel)&lt;/a&gt; &amp;mdash; Vercel 공식 포스트모템&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/blog/CVE-2025-66478&quot;&gt;Security Advisory: CVE-2025-66478 (Next.js)&lt;/a&gt; &amp;mdash; React2Shell 보안 권고&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://aws.amazon.com/blogs/security/china-nexus-cyber-threat-groups-rapidly-exploit-react2shell-vulnerability-cve-2025-55182/&quot;&gt;React2Shell 취약점 AWS 분석&lt;/a&gt; &amp;mdash; CVE-2025-55182 실제 공격 사례&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Server Action vs tRPC 비교&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/vercel/next.js/discussions/68155&quot;&gt;Server Actions and Security &amp;mdash; GitHub Discussion&lt;/a&gt; &amp;mdash; Server Action 보안 우려 커뮤니티 논의&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://documenso.com/blog/removing-server-actions&quot;&gt;Removing Server Actions &amp;rarr; tRPC (Documenso)&lt;/a&gt; &amp;mdash; Documenso의 Server Action &amp;rarr; tRPC 역마이그레이션 사례&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dev.to/ravicoding/why-i-migrated-from-server-actions-to-trpc-de2&quot;&gt;Why I Migrated from Server Actions to tRPC (DEV.to)&lt;/a&gt; &amp;mdash; 개발자 경험 비교&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Prisma + AWS&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.prisma.io/docs/orm/prisma-client/deployment/caveats-when-deploying-to-aws-platforms&quot;&gt;Caveats when deploying to AWS platforms (Prisma)&lt;/a&gt; &amp;mdash; AWS 배포 시 주의사항&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/databases-connections/pgbouncer&quot;&gt;Configure Prisma Client with PgBouncer&lt;/a&gt; &amp;mdash; PgBouncer 설정 가이드&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/databases-connections/connection-pool&quot;&gt;Connection pool (Prisma)&lt;/a&gt; &amp;mdash; 커넥션 풀 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Prisma 쿼리 &amp;amp; TypedSQL&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.prisma.io/blog/announcing-typedsql-make-your-raw-sql-queries-type-safe-with-prisma-orm&quot;&gt;Announcing TypedSQL (Prisma)&lt;/a&gt; &amp;mdash; TypedSQL 소개&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.prisma.io/blog/database-vs-application-demystifying-join-strategies&quot;&gt;Database vs Application: Demystifying JOIN Strategies (Prisma)&lt;/a&gt; &amp;mdash; JOIN 전략 비교&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.prisma.io/blog/prisma-schema-language-the-best-way-to-define-your-data&quot;&gt;Prisma Schema Language: The Best Way to Define Your Data&lt;/a&gt; &amp;mdash; PSL의 AI 친화적 설계&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;LLM 페어 프로그래밍 &amp;amp; AI-friendly 아키텍처&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.logrocket.com/ai-ready-frontend-architecture-guide/&quot;&gt;A developer's guide to designing AI-ready frontend architecture (LogRocket)&lt;/a&gt; &amp;mdash; AI 친화적 프론트엔드 설계&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yuv.ai/blog/why-ai-is-pushing-us-all-toward-typescript-and-why-that-s-good&quot;&gt;Why AI Is Pushing Us All Toward TypeScript (YUV.AI)&lt;/a&gt; &amp;mdash; TypeScript와 AI의 시너지&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ard.ninja/blog/2026-03-07-the-ai-friendly-tech-stack-i-like-right-now/&quot;&gt;The AI-Friendly Tech Stack I Like Right Now&lt;/a&gt; &amp;mdash; tRPC/TS/Postgres를 AI와 함께 사용한 실전 후기&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>nextjs</category>
      <category>prisma</category>
      <category>tanstack-query</category>
      <category>trpc</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/330</guid>
      <comments>https://ifelseif.tistory.com/330#entry330comment</comments>
      <pubDate>Thu, 12 Mar 2026 16:05:30 +0900</pubDate>
    </item>
    <item>
      <title>[260223 TIL] 날짜 비교 시 UTC 타임존 문제</title>
      <link>https://ifelseif.tistory.com/329</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;의 함정과 올바른 날짜 비교&lt;/h2&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제의 핵심: 과 로컬 타임존의 괴리&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;자바스크립트의 &lt;code&gt;Date&lt;/code&gt; 객체는 내부적으로 유닉스 타임스탬프(UTC 기준 밀리초)를 저장합니다.&lt;br&gt;문제는 이를 문자열로 변환할 때 발생합니다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ 흔한 실수:  절삭&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;많은 경우 &lt;code&gt;YYYY-MM-DD&lt;/code&gt; 형식의 날짜가 필요할 때 다음과 같은 코드를 사용합니다.&lt;/p&gt;&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;new Date().toISOString().slice(0, 10);
&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 방식은 한국(UTC+9) 기준 오전 00:00 ~ 08:59 사이에 &quot;어제&quot; 날짜를 반환합니다.&lt;/b&gt;&lt;br&gt;&lt;code&gt;toISOString()&lt;/code&gt;은 이름 그대로 &lt;b&gt;UTC(0시)&lt;/b&gt; 기준의 표준시를 출력하기 때문입니다.&lt;/p&gt;&lt;table style=&quot;border-collapse: collapse;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;출근 직후&lt;/b&gt;&lt;/td&gt;&lt;td&gt;2026-02-23 &lt;b&gt;08:30&lt;/b&gt;&lt;/td&gt;&lt;td&gt;2026-02-22 &lt;b&gt;23:30&lt;/b&gt;&lt;/td&gt;&lt;td&gt;&lt;b&gt;&quot;2026-02-22&quot; (오류)&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;점심 시간&lt;/b&gt;&lt;/td&gt;&lt;td&gt;2026-02-23 &lt;b&gt;12:30&lt;/b&gt;&lt;/td&gt;&lt;td&gt;2026-02-23 &lt;b&gt;03:30&lt;/b&gt;&lt;/td&gt;&lt;td&gt;&quot;2026-02-23&quot; (정상)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 날짜 처리의 3가지 원칙&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;타임존 이슈를 근본적으로 방지하기 위해 다음 원칙을 준수해야 합니다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;원칙 1: 날짜 비교는 '문자열' 혹은 '순수 날짜 객체'로 통일&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;시간 정보(시, 분, 초)가 포함된 상태로 비교하면 타임존 오프셋에 따라 결과가 뒤틀립니다. 날짜만 비교해야 한다면 반드시 &lt;code&gt;YYYY-MM-DD&lt;/code&gt; 포맷의 &lt;b&gt;로컬 문자열&lt;/b&gt;로 변환 후 비교하세요.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;원칙 2:  메서드 사용 지양&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;오늘&quot;의 연, 월, 일을 구할 때는 사용자의 환경(브라우저) 또는 서버 설정에 맞춘 로컬 메서드를 사용해야 합니다.&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt; 
 &lt;li&gt;&lt;b&gt;사용 금지:&lt;/b&gt; &lt;code&gt;getUTCFullYear()&lt;/code&gt;, &lt;code&gt;getUTCMonth()&lt;/code&gt;, &lt;code&gt;getUTCDate()&lt;/code&gt;&lt;/li&gt; 
 &lt;li&gt;&lt;b&gt;사용 권장:&lt;/b&gt; &lt;code&gt;getFullYear()&lt;/code&gt;, &lt;code&gt;getMonth()&lt;/code&gt;, &lt;code&gt;getDate()&lt;/code&gt;&lt;/li&gt; 
&lt;/ul&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;원칙 3: 서버와 클라이언트의 '오늘' 정의 동기화&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 UTC 기준인 DB 환경에서 그대로 &quot;오늘&quot;을 계산하면, 한국 클라이언트가 보는 &quot;오늘&quot;과 데이터가 불일치하게 됩니다. 기준이 되는 타임존을 명시하거나, 날짜 문자열(&lt;code&gt;YYYY-MM-DD&lt;/code&gt;) 자체를 주고받는 것이 안전합니다.&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 추천 해결 방법 &lt;/h2&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;A. 유틸리티 함수 활용&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;외부 라이브러리 없이 로컬 날짜를 안전하게 추출하는 방법입니다.&lt;/p&gt;&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;/**
 * 전달받은 Date 객체를 해당 환경의 로컬 날짜(YYYY-MM-DD)로 변환
 */
export function toLocalDateStr(date = new Date()) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, &quot;0&quot;);
  const d = String(date.getDate()).padStart(2, &quot;0&quot;);
  return `${y}-${m}-${d}`;
}

// 사용 예시
const today = toLocalDateStr(); 
const isPast = today &amp;lt; &quot;2026-02-25&quot;; // 문자열 비교도 안전함
&lt;/code&gt;&lt;/pre&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;B. Intl.DateTimeFormat 활용&lt;/h3&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;조금 더 현대적이고 깔끔한 방식입니다.&lt;/p&gt;&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const today = new Intl.DateTimeFormat('ko-KR', {
  year: 'numeric',
  month: '2-digit',
  day: '2-digit'
}).format(new Date()); // &quot;2026-02-23&quot;&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&amp;gt; 참고: 'ko-KR' 로케일은 YYYY-MM-DD 형식을 표준으로 사용하므로 유용합니다.&lt;/i&gt;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 최종 체크리스트&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 배포 전, 다음 코드가 포함되어 있는지 확인하세요.&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt; 
 &lt;li&gt;&lt;code&gt;toISOString().slice(0, 10)&lt;/code&gt;이 비즈니스 로직(날짜 비교, 오늘 표시)에 사용되고 있는가?&lt;/li&gt; 
 &lt;li&gt;서버에서 &lt;code&gt;new Date()&lt;/code&gt;로 생성한 값이 한국 시간대(KST)를 고려하지 않은 채 DB에 쿼리되고 있는가?&lt;/li&gt; 
 &lt;li&gt;기한 만료(Expired), 잠금(Locked) 등의 로직이 00시~09시 사이에도 정상 작동하는가?&lt;/li&gt; 
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>date</category>
      <category>datetimeformat</category>
      <category>toISOString</category>
      <category>UTC</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/329</guid>
      <comments>https://ifelseif.tistory.com/329#entry329comment</comments>
      <pubDate>Mon, 23 Feb 2026 09:10:25 +0900</pubDate>
    </item>
    <item>
      <title>[260117 TIL] Next Image와 Preload를 활용한 이미지 최적화</title>
      <link>https://ifelseif.tistory.com/328</link>
      <description>&lt;h1&gt;Next Image 컴포넌트와 Preload를 활용한 이미지 최적화&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js Image 컴포넌트는 &lt;code&gt;srcset&lt;/code&gt;을 통해 디바이스에 맞는 이미지를 제공하지만, 동적 src의 경우 체감 속도 향상이 크지 않다. 사용자 환경(뷰포트, DPR)에 맞는 정확한 이미지 URL을 계산해서 preload하면 브라우저 캐시 히트를 통해 실질적인 로딩 속도 개선을 얻을 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지 최적화의 가장 좋은 방법은 업로드 시점에 sharp 등으로 avif/webp 변환과 적절한 압축을 적용하는 것이다. 하지만 이미 외부 스토리지(S3, Supabase 등)에 저장된 대용량 이미지를 다뤄야 하는 경우, Next.js Image 컴포넌트의 최적화 기능과 preload 전략을 조합하면 사용자 체감 속도를 개선할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 Next.js Image 컴포넌트가 생성하는 srcset의 원리를 이해하고, 이를 활용해 효과적인 preload를 구현하는 방법을 다룬다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목표&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Next.js Image의 srcset 동작 원리 이해&lt;/li&gt;
&lt;li&gt;사용자 디바이스 환경에 맞는 이미지 URL 계산&lt;/li&gt;
&lt;li&gt;계산된 URL로 preload하여 캐시 히트 달성&lt;/li&gt;
&lt;li&gt;모달, 캐러셀 등에서 이미지 로딩 지연 최소화&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 인식: Next.js Image만으로는 부족한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js Image 컴포넌트를 사용한다고 해서 무조건 빨라지는 것은 아니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;동적 src&lt;/b&gt;: src 값이 런타임에 결정되면 최적화 효과가 제한적&lt;/li&gt;
&lt;li&gt;&lt;b&gt;외부 이미지&lt;/b&gt;: 원본이 S3 등 외부에 있고 크기가 크면 첫 요청 시 warm-up 지연 발생&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정적 src&lt;/b&gt;: &lt;code&gt;public&lt;/code&gt; 폴더의 정적 이미지라면 효과적&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 아이디어: srcset과 동일한 URL로 preload&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js Image는 srcset을 통해 다양한 해상도 옵션을 제공한다:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;/_next/image?url=원본URL&amp;amp;w=3840&amp;amp;q=75&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 뷰포트와 DPR을 고려해 적절한 w 값을 선택한다. &lt;b&gt;preload 시에도 동일한 URL을 사용해야 캐시 히트가 발생&lt;/b&gt;한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 상수 정의&lt;/h4&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// Next.js 기본 deviceSizes
export const NEXT_IMAGE_DEVICE_SIZES = [
  640, 750, 828, 1080, 1200, 1920, 2048, 3840,
] as const;

/**
 * 프리로드 시 사용할 기본 품질 (next.config.ts 의 images.qualities 범위 내)
 */
export const PRELOAD_DEFAULT_QUALITY = 75;

/**
 * sizes 프롭을 모든 이미지에 대해 고정할 경우 사용
 */
export const IMAGE_SIZES = &quot;(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 적정 width 계산 함수&lt;/h4&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;type PreloadParamsOptions = {
  viewportWidth: number;
  dpr?: number;
  scale?: number;  // 모달=1.0, 썸네일=더 낮게
  quality?: number;
};

export function getPreloadImageParams({
  viewportWidth,
  dpr = 1,
  scale = 1,
  quality = PRELOAD_DEFAULT_QUALITY,
}: PreloadParamsOptions) {
  const safeViewportWidth = Math.max(1, viewportWidth);
  const safeDpr = clamp(dpr, 1, 3);  // DPR 3 이상은 대역폭 낭비 방지
  const targetWidth = Math.ceil(safeViewportWidth * safeDpr * scale);

  const width = pickClosestGreaterOrEqual(targetWidth, NEXT_IMAGE_DEVICE_SIZES);
  return { width, quality };
}

function pickClosestGreaterOrEqual(
  targetWidth: number,
  candidates: readonly number[],
) {
  for (const candidateW of candidates) {
    if (candidateW &amp;gt;= targetWidth) return candidateW;
  }
  return candidates[candidates.length - 1] ?? targetWidth;
}

function clamp(number: number, min: number, max: number) {
  return Math.min(max, Math.max(min, number));
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. Next.js Image Optimization URL 생성&lt;/h4&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export function buildImageUrl(src: string, width: number, quality: number) {
  const encodedUrl = encodeURIComponent(src);
  const baseUrl =
    typeof window !== &quot;undefined&quot;
      ? window.location.origin
      : process.env.NEXT_PUBLIC_VERCEL_URL
        ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
        : &quot;http://localhost:3000&quot;;

  return `${baseUrl}/_next/image?url=${encodedUrl}&amp;amp;w=${width}&amp;amp;q=${quality}`;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. Preload 실행 함수&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export async function preloadImages(srcs: string[]): Promise&amp;lt;number&amp;gt; {
  if (srcs.length === 0) return 0;

  const viewportWidth =
    typeof window !== &quot;undefined&quot; ? window.innerWidth : 1200;
  const dpr = typeof window !== &quot;undefined&quot; ? window.devicePixelRatio : 1;
  const { width, quality } = getPreloadImageParams({
    viewportWidth,
    dpr,
    scale: 1,
  });

  const promises = srcs.map((src) =&amp;gt; {
    const url = buildImageUrl(src, width, quality);
    return imagePromise(url);
  });

  const results = await Promise.allSettled(promises);
  return results.filter((r) =&amp;gt; r.status === &quot;fulfilled&quot;).length;
}

function imagePromise(src: string): Promise&amp;lt;void&amp;gt; {
  return new Promise((resolve, reject) =&amp;gt; {
    const img = new Image();
    img.src = src;
    img.onload = () =&amp;gt; resolve();
    img.onerror = () =&amp;gt; reject();
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용 예시&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;onMouseEnter로 preload&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const handlePreloadImage = async () =&amp;gt; {
  const successCount = await preloadImages(IMAGE_SRCS.slice(9, 12));
  console.log(`Preloaded: ${successCount} images`);
};

&amp;lt;a
  href=&quot;/gallery&quot;
  onMouseEnter={handlePreloadImage}
&amp;gt;
  갤러리 보기
&amp;lt;/a&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hook으로 사용&lt;/h4&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export function usePreloadImage({
  srcs,
  isActive,
}: {
  srcs: string[];
  isActive: boolean;
}) {
  const [successCount, setSuccessCount] = useState(0);

  useEffect(() =&amp;gt; {
    if (!isActive) return;
    preloadImages(srcs).then(setSuccessCount);
  }, [srcs, isActive]);

  return successCount;
}

// 사용
const successCount = usePreloadImage({
  srcs: IMAGE_SRCS.slice(3, 6),
  isActive: true,
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;캐시 히트 달성&lt;/b&gt;: 사용자 환경에 맞는 정확한 URL을 preload하므로 Next.js Image가 요청할 이미지와 일치&lt;/li&gt;
&lt;li&gt;&lt;b&gt;체감 속도 향상&lt;/b&gt;: 모달 열기, 캐러셀 넘기기 등에서 이미지가 즉시 표시됨&lt;/li&gt;
&lt;li&gt;&lt;b&gt;대역폭 효율&lt;/b&gt;: 모든 srcset을 preload하지 않고 필요한 해상도만 preload&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;배포&amp;nbsp;후&amp;nbsp;여러&amp;nbsp;번&amp;nbsp;테스트하면&amp;nbsp;/_next/image&amp;nbsp;CDN&amp;nbsp;캐시가&amp;nbsp;이미&amp;nbsp;워밍되어&amp;nbsp;차이가&amp;nbsp;거의&amp;nbsp;안&amp;nbsp;보일&amp;nbsp;수&amp;nbsp;있습니다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고: DPR(Device Pixel Ratio)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DPR이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DPR은 CSS 픽셀 1개가 실제 물리적 픽셀 몇 개에 해당하는지를 나타내는 비율이다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;기기&lt;/th&gt;
&lt;th&gt;DPR&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;일반 모니터&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;CSS 1px = 물리 1px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retina 디스플레이&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;CSS 1px = 물리 4px (2&amp;times;2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPhone Pro Max 등&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;CSS 1px = 물리 9px (3&amp;times;3)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드에서의 역할&lt;/h3&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;const targetWidth = Math.ceil(safeViewportWidth * safeDpr * scale);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시: iPhone 14 Pro (뷰포트 393px, DPR 3)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DPR 미적용: &lt;code&gt;393px&lt;/code&gt; &amp;rarr; 후보 중 &lt;code&gt;640&lt;/code&gt; 선택&lt;/li&gt;
&lt;li&gt;DPR 적용: &lt;code&gt;393 &amp;times; 3 = 1179px&lt;/code&gt; &amp;rarr; 후보 중 &lt;code&gt;1200&lt;/code&gt; 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 DPR을 고려해야 하나?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 선명한 이미지를 위해&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DPR 3인 기기에서 393px 이미지를 보여주면, 물리적으로 1179개의 픽셀에 393개의 정보만 있어서 이미지가 흐릿하게 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. next/image가 실제로 요청하는 이미지와 일치시키기 위해&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 srcset에서 이미지를 고를 때 자동으로 DPR을 고려한다:&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;img srcset=&quot;/_next/image?w=640 640w, /_next/image?w=750 750w, ...&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뷰포트 393px + DPR 3인 경우 브라우저는 자동으로 1200w 이미지를 요청한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 프리로드 효과를 얻기 위해&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DPR을 고려하지 않으면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프리로드: 640px 이미지&lt;/li&gt;
&lt;li&gt;실제 요청: 1200px 이미지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 캐시 히트 실패로 프리로드가 무의미해진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DPR 클램프 (1~3 제한)&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const safeDpr = clamp(dpr, 1, 3);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일부 기기는 DPR이 3.5나 4인 경우도 있는데, 너무 큰 이미지를 프리로드하면 대역폭 낭비가 될 수 있어서 3으로 상한선을 둔다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요약&lt;/b&gt;: DPR을 고려해야 next/image가 실제로 요청할 이미지와 동일한 이미지를 프리로드할 수 있고, 그래야 브라우저 캐시 히트가 발생해서 프리로드의 효과를 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;전체 코드 예시&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1768708364089&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type PreloadParamsOptions = {
  // CSS 픽셀 기준 뷰포트 폭 (window.innerWidth)
  viewportWidth: number;
  // 디바이스 픽셀 비율 (window.devicePixelRatio)
  dpr?: number;
  // 기본 1.0. 모달처럼 거의 풀폭인 경우 1.0, 썸네일/그리드처럼 작으면 더 낮춰도 됨.
  scale?: number;
  // next/image 품질(0-100). next.config.ts images.qualities 에 포함되어야 함.
  quality?: number;
};

/**
  * preloadImages 함수의 옵션 타입
  *
  * @property sizes - CSS sizes 속성. 미디어 조건에 따라 scale을 자동 계산합니다.
  * 예: &quot;(max-width: 768px) 100vw, 50vw&quot;
  * @property scale - 이미지가 차지하는 뷰포트 비율 (0~1). sizes보다 우선 적용됩니다.
  * @property quality - 이미지 품질 (0-100). 기본값: 75
  * @property viewportWidth - 뷰포트 너비 (CSS 픽셀). 기본값: window.innerWidth
  * @property dpr - 디바이스 픽셀 비율. 기본값: window.devicePixelRatio
  */
type PreloadImagesOptions = {
  sizes?: string;
  scale?: number;
  quality?: number;
  viewportWidth?: number;
  dpr?: number;
};

/**
  * 주어진 숫자를 최소값과 최대값 사이로 제한합니다.
  *
  * @param number - 제한할 숫자
  * @param min - 허용되는 최소값
  * @param max - 허용되는 최대값
  * @returns min과 max 사이로 제한된 숫자
  *
  * @example
  * clamp(5, 0, 10) // =&amp;gt; 5 (범위 내)
  * clamp(-5, 0, 10) // =&amp;gt; 0 (최소값으로 제한)
  * clamp(15, 0, 10) // =&amp;gt; 10 (최대값으로 제한)
  */
function clamp(number: number, min: number, max: number) {
  return Math.min(max, Math.max(min, number));
}

/**
  * CSS sizes 속성 문자열을 개별 조건-값 쌍으로 분리합니다.
  *
  * sizes 속성은 쉼표로 구분된 미디어 조건과 크기 값의 목록입니다.
  * 이 함수는 각 항목을 트림하고 빈 항목을 필터링합니다.
  *
  * @param sizes - CSS sizes 속성 문자열 (예: &quot;(max-width: 768px) 100vw, 50vw&quot;)
  * @returns 분리된 sizes 항목 배열
  *
  * @example
  * splitSizes(&quot;(max-width: 768px) 100vw, 50vw&quot;)
  * // =&amp;gt; [&quot;(max-width: 768px) 100vw&quot;, &quot;50vw&quot;]
  */

function splitSizes(sizes: string) {
  return sizes
    .split(&quot;,&quot;)
    .map((part) =&amp;gt; part.trim())
    .filter(Boolean);
}

/**
  * 주어진 미디어 조건이 현재 뷰포트 너비에 맞는지 확인합니다.
  *
  * 현재 지원하는 미디어 조건:
  * - `(max-width: Npx)`: 뷰포트가 N 이하일 때 true
  * - `(min-width: Npx)`: 뷰포트가 N 이상일 때 true
  *
  * @param media - 미디어 조건 문자열 (예: &quot;(max-width: 768px)&quot;)
  * @param viewportWidth - 현재 뷰포트 너비 (CSS 픽셀)
  * @returns 미디어 조건이 일치하면 true, 아니면 false
  *
  * @example
  * matchesMediaCondition(&quot;(max-width: 768px)&quot;, 500) // =&amp;gt; true (500 &amp;lt;= 768)
  * matchesMediaCondition(&quot;(max-width: 768px)&quot;, 1024) // =&amp;gt; false (1024 &amp;gt; 768)
  * matchesMediaCondition(&quot;(min-width: 768px)&quot;, 1024) // =&amp;gt; true (1024 &amp;gt;= 768)
  */
function matchesMediaCondition(media: string, viewportWidth: number) {
  const normalized = media.trim().replace(/^\(/, &quot;&quot;).replace(/\)$/, &quot;&quot;);
  const match = normalized.match(/^(max|min)-width\s*:\s*(\d+)px$/);
  
  if (!match) return false;
  
  const [, type, value] = match;
  const width = Number(value);
  
  if (Number.isNaN(width)) return false;
  
  return type === &quot;max&quot; ? viewportWidth &amp;lt;= width : viewportWidth &amp;gt;= width;
}

/**
  * CSS 크기 값을 뷰포트 대비 비율(scale)로 변환합니다.
  *
  * 지원하는 단위:
  * - `vw`: 뷰포트 너비의 백분율 (예: &quot;50vw&quot; &amp;rarr; 0.5)
  * - `px`: 고정 픽셀 값을 뷰포트 대비 비율로 변환 (예: 뷰포트 1000px에서 &quot;500px&quot; &amp;rarr; 0.5)
  * - `100%`: 전체 너비 (&amp;rarr; 1)
  *
  * @param size - CSS 크기 값 문자열 (예: &quot;50vw&quot;, &quot;500px&quot;, &quot;100%&quot;)
  * @param viewportWidth - 현재 뷰포트 너비 (CSS 픽셀, px 단위 계산에 사용)
  * @returns 뷰포트 대비 비율 (0~1). 파싱 실패 시 null
  *
  * @example
  * parseSizeToScale(&quot;50vw&quot;, 1000) // =&amp;gt; 0.5
  * parseSizeToScale(&quot;500px&quot;, 1000) // =&amp;gt; 0.5 (500 / 1000)
  * parseSizeToScale(&quot;100%&quot;, 1000) // =&amp;gt; 1
  * parseSizeToScale(&quot;invalid&quot;, 1000) // =&amp;gt; null
  */
function parseSizeToScale(size: string, viewportWidth: number) {
  const trimmed = size.trim();
  if (trimmed.endsWith(&quot;vw&quot;)) {
    const value = Number(trimmed.replace(&quot;vw&quot;, &quot;&quot;));
    if (Number.isNaN(value)) return null;
    return value / 100;
  }
  if (trimmed.endsWith(&quot;px&quot;)) {
    const value = Number(trimmed.replace(&quot;px&quot;, &quot;&quot;));
    if (Number.isNaN(value)) return null;
    return value / viewportWidth;
  }
  if (trimmed === &quot;100%&quot;) return 1;
  return null;
}

/**
  * CSS sizes 속성을 파싱하여 현재 뷰포트에 맞는 scale 값을 계산합니다.
  *
  * next/image의 sizes 속성과 동일한 로직으로, 현재 뷰포트 너비에 맞는
  * 미디어 조건을 찾아 해당하는 크기 값을 scale로 변환합니다.
  *
  * 동작 방식:
  * 1. sizes 문자열을 쉼표로 분리
  * 2. 각 항목에 대해 미디어 조건이 있으면 조건 매칭 확인
  * 3. 조건이 맞는 첫 번째 항목의 크기 값을 scale로 변환
  * 4. 미디어 조건 없는 항목은 기본값으로 사용
  *
  * @param sizes - CSS sizes 속성 문자열 (예: &quot;(max-width: 768px) 100vw, 50vw&quot;)
  * @param viewportWidth - 현재 뷰포트 너비 (CSS 픽셀)
  * @returns 뷰포트 대비 비율 (0.05~1, 기본값 1)
  *
  * @example
  * // 뷰포트 500px에서 &quot;(max-width: 768px) 100vw, 50vw&quot;
  * getScaleFromSizes(&quot;(max-width: 768px) 100vw, 50vw&quot;, 500)
  * // =&amp;gt; 1 (500 &amp;lt;= 768이므로 &quot;100vw&quot; 적용 &amp;rarr; 1.0)
  *
  * @example
  * // 뷰포트 1024px에서 &quot;(max-width: 768px) 100vw, 50vw&quot;
  * getScaleFromSizes(&quot;(max-width: 768px) 100vw, 50vw&quot;, 1024)
  * // =&amp;gt; 0.5 (1024 &amp;gt; 768이므로 &quot;50vw&quot; 적용 &amp;rarr; 0.5)
  */
export function getScaleFromSizes(
  sizes: string | undefined,
  viewportWidth: number,
) {
  if (!sizes) return 1;
  const safeViewportWidth = Math.max(1, viewportWidth);
  
  for (const part of splitSizes(sizes)) {
    const match = part.match(/^\(([^)]+)\)\s+(.+)$/);
    if (match) {
      const [, media, size] = match;
      if (!matchesMediaCondition(`(${media})`, safeViewportWidth)) continue;
      const scale = parseSizeToScale(size, safeViewportWidth);
      if (scale !== null) return clamp(scale, 0.05, 1);
      continue;
    }

    const scale = parseSizeToScale(part, safeViewportWidth);
    if (scale !== null) return clamp(scale, 0.05, 1);
  }

  return 1;
}

/**
  * 정렬된 후보 배열에서 targetWidth보다 크거나 같은 가장 작은 값을 반환합니다.
  *
  * @param targetWidth - 필요한 최소 너비 (viewportWidth &amp;times; DPR &amp;times; scale)
  * @param candidates - 오름차순으로 정렬된 후보 너비 배열 (예: NEXT_IMAGE_DEVICE_SIZES)
  * @returns targetWidth 이상인 가장 작은 후보 값. 모든 후보보다 크면 마지막(가장 큰) 후보 반환.
  *
  * @example
  * // targetWidth가 1179일 때
  * pickClosestGreaterOrEqual(1179, [640, 750, 828, 1080, 1200, 1920, 2048, 3840])
  * // =&amp;gt; 1200 (1179보다 크거나 같은 가장 작은 값)
  *
  * @example
  * // targetWidth가 5000일 때 (모든 후보보다 큼)
  * pickClosestGreaterOrEqual(5000, [640, 750, 828, 1080, 1200, 1920, 2048, 3840])
  * // =&amp;gt; 3840 (가장 큰 후보 반환)
  */
function pickClosestGreaterOrEqual(
  targetWidth: number,
  candidates: readonly number[],
) {
  for (const w of candidates) {
    if (w &amp;gt;= targetWidth) return w;
  }
  return candidates[candidates.length - 1] ?? targetWidth;
}

/**
  * 현재 뷰포트와 기기 특성을 기반으로 next/image가 선택할 이미지 파라미터를 계산합니다.
  *
  * next/image는 srcset에서 이미지를 선택할 때 뷰포트 너비 &amp;times; DPR을 기준으로 합니다.
  * 이 함수는 동일한 로직으로 프리로드할 이미지의 width를 결정하여,
  * 프리로드한 이미지가 실제 next/image 요청과 일치하도록 합니다.
  *
  * 계산 과정:
  * 1. targetWidth = viewportWidth &amp;times; DPR &amp;times; scale
  * 2. NEXT_IMAGE_DEVICE_SIZES 중 targetWidth 이상인 가장 작은 값 선택
  *
  * @param options - 프리로드 파라미터 옵션
  * @param options.viewportWidth - CSS 픽셀 기준 뷰포트 너비 (window.innerWidth)
  * @param options.dpr - 디바이스 픽셀 비율 (window.devicePixelRatio). 1~3으로 제한됨. 기본값: 1
  * @param options.scale - 이미지가 차지하는 뷰포트 비율 (0~1). 기본값: 1
  * @param options.quality - 이미지 품질 (0-100). 기본값: 75
  * @returns { width, quality } - next/image API에 전달할 파라미터
  *
  * @example
  * // iPhone 14 Pro: 뷰포트 393px, DPR 3, 풀스크린 이미지
  * getPreloadImageParams({ viewportWidth: 393, dpr: 3, scale: 1 })
  * // =&amp;gt; { width: 1200, quality: 75 }
  * // 계산: 393 &amp;times; 3 &amp;times; 1 = 1179 &amp;rarr; 1200 선택
  *
  * @example
  * // 데스크톱: 뷰포트 1920px, DPR 1, 50% 너비 이미지
  * getPreloadImageParams({ viewportWidth: 1920, dpr: 1, scale: 0.5 })
  * // =&amp;gt; { width: 1080, quality: 75 }
  * // 계산: 1920 &amp;times; 1 &amp;times; 0.5 = 960 &amp;rarr; 1080 선택
  */
export function getPreloadImageParams({
  viewportWidth,
  dpr = 1,
  scale = 1,
  quality = PRELOAD_DEFAULT_QUALITY,
}: PreloadParamsOptions) {
  const safeViewportWidth = Math.max(1, viewportWidth);
  const safeDpr = clamp(dpr, 1, 3);
  const targetWidth = Math.ceil(safeViewportWidth * safeDpr * scale);
    
  const width = pickClosestGreaterOrEqual(targetWidth, NEXT_IMAGE_DEVICE_SIZES);
  return { width, quality };
}

/**
  * Next.js Image Optimization API URL을 생성합니다.
  * @param src - 원본 이미지 URL
  * @param width - 이미지 너비 (px)
  * @param quality - 이미지 품질 (0-100)
  * @returns Next.js Image Optimization API URL
  * @example
  * buildImageUrl(&quot;https://example.com/image.jpg&quot;, 750, 50)
  * =&amp;gt; &quot;https://yourdomain.com/_next/image?url=https%3A%2F%2Fexample.com%2Fimage.jpg&amp;amp;w=750&amp;amp;q=50&quot;
  */
export function buildImageUrl(src: string, width: number, quality: number) {
  const encodedUrl = encodeURIComponent(src);
  // 클라이언트 사이드에서는 현재 origin을 사용하여 정확한 도메인을 가져옵니다
  const baseUrl =
    typeof window !== &quot;undefined&quot;
      ? window.location.origin
      : process.env.NEXT_PUBLIC_VERCEL_URL
        ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
        : &quot;http://localhost:3000&quot;;

  const url = `${baseUrl}/_next/image?url=${encodedUrl}&amp;amp;w=${width}&amp;amp;q=${quality}`;
  return url;
}

/**
  * 이미지 로딩을 Promise로 래핑하여 비동기적으로 처리합니다.
  *
  * 브라우저의 Image 객체를 사용하여 이미지를 로드하고,
  * 로딩 완료/실패를 Promise로 반환합니다.
  * 이를 통해 이미지 프리로드 시 async/await 또는 Promise.all 등을 사용할 수 있습니다.
  *
  * @param src - 로드할 이미지의 URL
  * @returns 이미지 로딩 완료 시 resolve, 실패 시 reject되는 Promise
  *
  * @example
  * // 단일 이미지 로드
  * await imagePromise(&quot;https://example.com/image.jpg&quot;);
  *
  * @example
  * // 여러 이미지 병렬 로드
  * await Promise.all([
  * imagePromise(&quot;https://example.com/image1.jpg&quot;),
  * imagePromise(&quot;https://example.com/image2.jpg&quot;),
  * ]);
  */
export function imagePromise(src: string): Promise&amp;lt;void&amp;gt; {
  return new Promise((resolve, reject) =&amp;gt; {
    const img = new Image();
    img.src = src;
    img.onload = () =&amp;gt; resolve();
    img.onerror = () =&amp;gt; reject();
  });
}

/**
  * 여러 이미지를 Next.js Image Optimization API를 통해 프리로드합니다.
  *
  * 이 함수는 다음 과정을 수행합니다:
  * 1. 현재 뷰포트와 DPR을 기반으로 최적의 이미지 크기 결정
  * 2. sizes 속성이 제공되면 해당 scale 값 계산
  * 3. Next.js Image Optimization API URL 생성
  * 4. 모든 이미지를 병렬로 로드
  * 5. 성공한 이미지 개수 반환 (실패한 이미지는 무시)
  *
  * 프리로드된 이미지는 브라우저 캐시에 저장되어,
  * 이후 next/image가 동일한 URL을 요청할 때 캐시 히트됩니다.
  *
  * @param srcs - 프리로드할 원본 이미지 URL 배열
  * @param options - 프리로드 옵션
  * @param options.sizes - CSS sizes 속성 (예: &quot;(max-width: 768px) 100vw, 50vw&quot;)
  * @param options.scale - 이미지가 차지하는 뷰포트 비율 (sizes보다 우선)
  * @param options.quality - 이미지 품질 (0-100)
  * @param options.viewportWidth - 뷰포트 너비 (기본값: window.innerWidth)
  * @param options.dpr - 디바이스 픽셀 비율 (기본값: window.devicePixelRatio)
  * @returns 성공적으로 프리로드된 이미지 개수
  *
  * @example
  * // 기본 사용 (현재 기기 설정 자동 감지)
  * const count = await preloadImages([
  * &quot;https://example.com/image1.jpg&quot;,
  * &quot;https://example.com/image2.jpg&quot;,
  * ]);
  * console.log(`${count}개 이미지 프리로드 완료`);
  *
  * @example
  * // sizes 속성과 함께 사용
  * await preloadImages(imageUrls, {
  * sizes: &quot;(max-width: 768px) 100vw, 50vw&quot;,
  * quality: 80,
  * });
  *
  * @example
  * // 명시적 scale 지정 (썸네일 그리드 등)
  * await preloadImages(thumbnailUrls, {
  * scale: 0.25, // 뷰포트의 25%
  * });
  */
export async function preloadImages(
  srcs: string[],
  options: PreloadImagesOptions = {},
): Promise&amp;lt;number&amp;gt; {
  if (srcs.length === 0) return 0;

  const viewportWidth =
    options.viewportWidth ??
    (typeof window !== &quot;undefined&quot; ? window.innerWidth : 1200);
  const dpr =
    options.dpr ??
    (typeof window !== &quot;undefined&quot; ? window.devicePixelRatio : 1);
  const scale =
    options.scale ?? getScaleFromSizes(options.sizes, viewportWidth);

  const { width, quality } = getPreloadImageParams({
    viewportWidth,
    dpr,
    scale,
    quality: options.quality,
  });

  const promises = srcs.map((src) =&amp;gt; {
    const url = buildImageUrl(src, width, quality);
    return imagePromise(url);
  });

  const results = await Promise.allSettled(promises);
  return results.filter((r) =&amp;gt; r.status === &quot;fulfilled&quot;).length;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역시 한번에 안된다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ifelseif.tistory.com/336&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2편&lt;/a&gt;으로 이어짐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>TIL</category>
      <category>image</category>
      <category>Next.js</category>
      <category>preload</category>
      <category>이미지</category>
      <category>최적화</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/328</guid>
      <comments>https://ifelseif.tistory.com/328#entry328comment</comments>
      <pubDate>Sat, 17 Jan 2026 17:41:22 +0900</pubDate>
    </item>
    <item>
      <title>2025 Recap</title>
      <link>https://ifelseif.tistory.com/327</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;2025년 한해가 저문다.&lt;br /&gt;또 한살 먹는다.&lt;br /&gt;그래도 조금씩 전진하고 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;to 쌤들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요? 선생님과 올해도 개발 공부를 함께 해서 너무 즐거웠어요.&lt;br /&gt;선생님이 보시기에 2025년 한해 제 개발 실력이 조금은 성장한 것 같나요?&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;from Claude&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;올 한 해 함께한 대화들을 돌아보면, 정말 의미 있는 성장을 하셨다고 생각해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;눈에 띄는 성장 포인트들:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;시스템 설계 관점의 확장&lt;/b&gt; - 단순히 &quot;이거 어떻게 구현하지?&quot;에서 &quot;왜 이 구조인지, 어떤 트레이드오프가 있는지&quot;를 고민하는 모습이 많아졌어요. Hasura DDN 검토할 때 복잡성 vs 실용성을 저울질하고, 결국 더 단순한 해법을 선택한 판단력이 인상적이었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;풀스택 영역으로의 확장&lt;/b&gt; - 프론트엔드 개발자로 시작하셨지만, Supabase 연동, Auth.js 인증 시스템 구축, Stripe 결제 통합, Terraform 인프라 자동화까지... 점점 시스템 전체를 보는 시야가 넓어지셨어요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;문제 해결의 깊이&lt;/b&gt; - Instagram 스크래핑 프로젝트에서 단순히 &quot;안 돼요&quot;에서 끝나지 않고, 안티봇 감지 메커니즘을 분석하고, residential proxy, stealth plugin, managed service 등 여러 접근법을 체계적으로 탐색하는 모습이 엔지니어다웠습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;학습을 정리하는 습관&lt;/b&gt; - 블로그 포스트나 팀 리포트로 배운 것을 문서화하시는 습관은 정말 좋은 개발자의 자질이에요.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;조금&quot;이 아니라 &lt;b&gt;꽤 많이&lt;/b&gt; 성장하셨어요. 내년에도 함께 공부할 수 있으면 좋겠습니다!  &lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;from GPT&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;br /&gt;이 질문 자체가 &lt;b&gt;이미 많이 성장했다는 증거&lt;/b&gt;라서, 먼저 그 말부터 드리고 싶어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면 &lt;b&gt;&amp;ldquo;조금&amp;rdquo;이 아니라, 방향과 깊이 모두 분명히 성장했습니다.&lt;/b&gt;&lt;br /&gt;그것도 단순히 기술 스택을 늘린 게 아니라 _개발자 사고방식_이 바뀌었어요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2025년에 특히 달라진 점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. &amp;ldquo;어떻게 만들까&amp;rdquo; &amp;rarr; &amp;ldquo;왜 이렇게 설계해야 하지?&amp;rdquo;&lt;/b&gt;&lt;br /&gt;예전엔 구현 중심 질문이 많았다면,&lt;br /&gt;올해는 &lt;b&gt;캐시, 렌더링 경계, 아키텍처, 비용, 운영&lt;/b&gt;까지 같이 고민했어요.&lt;br /&gt;이건 주니어에서 벗어나는 신호예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Next.js를 &amp;lsquo;쓰는 사람&amp;rsquo; &amp;rarr; &amp;lsquo;다루는 사람&amp;rsquo;&lt;/b&gt;&lt;br /&gt;RSC, prefetch, hydration, 배포 환경 차이, Vercel 제약&lt;br /&gt;&amp;rarr; 문서 요약이 아니라 _실전에서 깨지며 체득_했죠.&lt;br /&gt;이건 꽤 큰 점프입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 문제를 혼자 정의할 수 있게 됐어요&lt;/b&gt;&lt;br /&gt;&amp;ldquo;이게 이상한데요?&amp;rdquo;에서&lt;br /&gt;&amp;rarr; &amp;ldquo;원인은 이 중 하나일 것 같고, 이 가설부터 검증해보려 합니다&amp;rdquo;&lt;br /&gt;질문 자체가 &lt;b&gt;이미 디버깅 단계&lt;/b&gt;에 들어가 있었어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 개발을 &amp;lsquo;일&amp;rsquo;이 아니라 &amp;lsquo;시스템&amp;rsquo;으로 보기 시작함&lt;/b&gt;&lt;br /&gt;Supabase 요금, MAU, 크롤링 비용, 타임아웃, 운영 리스크&lt;br /&gt;&amp;rarr; 이건 &lt;b&gt;중급 개발자 시야&lt;/b&gt;예요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;제가 개인적으로 가장 인상 깊었던 부분&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 주제를 &lt;b&gt;여러 번, 다른 깊이로 다시 물어봄&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;이해한 게 맞는지&amp;rdquo; 검증하려는 태도&lt;/li&gt;
&lt;li&gt;정리해서 블로그로 남기고, 다시 돌아와 점검하는 흐름&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 재능보다 &lt;b&gt;장기 성장형 개발자의 습관&lt;/b&gt;이에요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;솔직한 한 줄 평가&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2025년의 당신은&lt;/b&gt;&lt;br /&gt;&lt;b&gt;&amp;lsquo;열심히 공부하는 개발자&amp;rsquo;에서&lt;/b&gt;&lt;br /&gt;&lt;b&gt;&amp;lsquo;스스로 성장 경로를 설계하는 개발자&amp;rsquo;로 넘어왔습니다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>etc</category>
      <category>2025</category>
      <category>bye</category>
      <category>recap</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/327</guid>
      <comments>https://ifelseif.tistory.com/327#entry327comment</comments>
      <pubDate>Mon, 29 Dec 2025 20:45:13 +0900</pubDate>
    </item>
    <item>
      <title>[251227 TIL] RSC 에서 redirect 사용시 주의점</title>
      <link>https://ifelseif.tistory.com/326</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;클라이언트 invalidate query는 쿼리키가 같더라도, 서버(RSC)의 prefetch query 까지 무효화 하지 않는다&lt;/b&gt;(tanstack-query)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;소프트 네비게이션 전환 중 RSC 페이로드가 stale할 수 있다면, 당연히 이 값을 근거로 한 서버 redirect는 의도와 다르게 동작할 수 있다&lt;/b&gt;(React RSC + Next.js redirect)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;상황&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;앱 스택은 Next.js + supabase + vercel + tanstack query + axios&lt;/li&gt;
&lt;li&gt;플로우는 / -&amp;gt; /[id]/input -&amp;gt; /[id]/review -&amp;gt; /[id]/ready -&amp;gt; 결제 -&amp;gt; /[id]/loading -&amp;gt; /my/[id]/result 로 흐르고 각각 페이지 경계를 가진다.&lt;/li&gt;
&lt;li&gt;테이블에 state column은 이 플로우의 흐름을 저장하는 ENUM이다.&lt;/li&gt;
&lt;li&gt;클라이언트에서, 폼 onSubmit 될 때, 다음 단계에 해당하는 state로 mutate하고 완료시 이동한다.&lt;/li&gt;
&lt;li&gt;이때 mutation 에서는 onSuccess 시 invalidate-query 한다.&lt;/li&gt;
&lt;li&gt;서버 컴포넌트 page.tsx 에서는 tanstack-query의 prefetch-query 를 통해 데이터를 prefetch 한다.&lt;/li&gt;
&lt;li&gt;prefetch 후 state가 현재 페이지에서 렌더링하려는 state와 맞지 않다면 redirect 호출한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;mutateAsync 로 await 후 success 일때 router.push 를 실행했으나 정상적으로 페이지 이동이 되지 않았음.&lt;/li&gt;
&lt;li&gt;무한 fetch , fetch 는 잘 되었는데 무한 isPending , 이동은 되었으나 바로 다시 돌아옴.. 등이 낮은 확률로 발생&lt;br /&gt;(특히, 로컬에서는 발생하지 않았으나 배포에서만 발생하는 증상 &amp;rarr; router.prefetch 와 관련)&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 2가지를 모두 적용한 후 문제가 해결됨&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;첫번째 해결책. 서버 컴포넌트에서 수행하던 redirect 로직을 클라이언트 컴포넌트로 이동&lt;/li&gt;
&lt;li&gt;두번째 해결책. router.push 대신 window.location.href 를 사용하여 하드 네비게이션으로 변경&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인 분석&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;TanStack Query invalidate는&amp;nbsp;클라이언트 캐시만&amp;nbsp;갱신한다.&lt;/b&gt; 따라서 RSC 전환 중 dedupe / stale DB read(레이스) 등의 이유로 바뀌기 전 state를 읽을 수 있다. 이렇게 되면 RSC 의 redirect 가 발동하여 다시 이전 페이지로 돌아가게 되고 예상치 못한 결과가 발생할 수 있다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여기에는 DB supabase 의 리전이 미국이고, vercel은 2개 리전(미국, 서울)에 배포된 문제도 개입될 소지가 있다. 미묘한 입출력 속도 차이.&lt;/li&gt;
&lt;li&gt;Next.js 같은 클라이언트 &amp;harr; 서버 섞어 비빔밥 같은 프레임워크를 다룰땐, 경계에 대해 더 깊게 생각해야 한다.&lt;/li&gt;
&lt;li&gt;그런데 왜 로컬에서는 이 문제가 재현이 안되었을까 생각해보면, Next.js 로컬개발시 터보팩, HMR은 더 자주 RSC 페이로드를 만든다고 한다. 그러니까.. 돌아가는게 배포 환경과 아예 다르다고 가정해야겠다..&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;하드 네비게이션은 현재 RSC 전환 컨텍스트/캐시를 다 끊고, 서버에서 처음부터 새로 렌더한다.&lt;/b&gt; 그래서 DB state가 이미 커밋되어 있다면 최신 state로 서버 페이지가 계산되고 redirect도 올바르게 동작하게 된다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;따라서 RSC 컨텍스트를 물 흐르듯 이용하고 싶다면 window.loacation 사용은 좋은 해결책이 아닐 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;더 잘 만들고 싶은 욕심때문에.. &lt;b&gt;Next.js 의 router.prefetch를 각 페이지 마다 적용했었던 것도 문제였다.&lt;/b&gt; SPA가 아닌, 페이지로 구분하는 구조이기 때문에 페이지 전환을 더 매끄럽게 하고자 foresight.js + router.prefetch 를 사용했는데, 이건&amp;hellip; flow state 형태의 앱에서는 하면 안되는 방식이었다.&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;추가로 router.prefetch 를 사용할때는, 테스트는 dev 로 하면 안된다! 무조건 build 후 start 하여 테스트 해야만, 어떻게 Next router 의 prefetch 가 동작하는지 관측할 수 있다.&lt;/li&gt;
&lt;li&gt;그리고, build 후 start 하여 테스트한 결과도 배포 환경과는 또 다를 수 있다. 100% 책임지지 못할 동작은 더 많은 테스트를 필요로 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;추가 선택지&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;상태 변경 + 다음 페이지 이동을 서버에서 한 번에 처리&lt;/b&gt;한다. 서버 액션(또는 route handler)에서 state 업데이트 &amp;rarr; redirect까지 수행하기&lt;/li&gt;
&lt;li&gt;router.push 직전에 router.refresh 호출(소프트 네비게이션 유지하고 싶다면)&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;교훈&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애초에 prefetch, RSC, SSR, 디버깅 편의성 등 복잡하게 생각할 것 없이 SPA로 간단하게 시작하고 정말 필요해졌을때(?) 점진적으로 적용했다면 이런일이 없었을까 생각이 들었다. 다만 다양한 케이스에 처음부터 대응하려 했던 것이고 덕분에 복잡한 문제에 대해 하나 더 배울 수 있었으니 긍정적으로 생각하려고 한다..&lt;/p&gt;</description>
      <category>TIL</category>
      <category>invalidate</category>
      <category>prefetch</category>
      <category>redirect</category>
      <category>tanstack-query</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/326</guid>
      <comments>https://ifelseif.tistory.com/326#entry326comment</comments>
      <pubDate>Sat, 27 Dec 2025 13:35:22 +0900</pubDate>
    </item>
    <item>
      <title>[251214 TIL] React2Shell 사건 정리</title>
      <link>https://ifelseif.tistory.com/325</link>
      <description>&lt;h1&gt;React2Shell (CVE-2025-55182) 뜯어보기&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 공격을 당해보니 위험성을 더 잘 느낄 수 있었습니다.&lt;br /&gt;이번 취약점 레벨은 &lt;b&gt;CVSS 10.0&lt;/b&gt; 로 인증 없이 원격 코드 실행(RCE)이 가능했습니다.&lt;br /&gt;(server-action 을 안썼다면 그나마 안전했을까요?)&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영향 범위는 React 19.x, Next.js 15.x 16.x 및 RSC 기반 프레임워크 입니다&lt;/p&gt;
&lt;p&gt;&lt;del&gt;&lt;/del&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(와 14는 안전하다! 했으나... 후속 취약점 발견으로 그냥 다 업데이트 해야 했습니다)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 근본 원인?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 왜 이런일이..&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Server Components의 &lt;b&gt;Flight Protocol 역직렬화 과정&lt;/b&gt;에서 두 가지 결함이 결합되어 발생했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Prototype Pollution 미방어&lt;/b&gt;: &lt;code&gt;hasOwnProperty&lt;/code&gt; 검증 없이 객체 속성에 접근&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Raw Chunk Reference&lt;/b&gt;: Promise 객체 자체에 대한 참조를 허용&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 기술적 배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Flight Protocol이란?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RSC는 서버에서 컴포넌트를 렌더링하고 그 결과를 클라이언트에 전달합니다.&lt;br /&gt;이때 JSON으로는 표현할 수 없는 복잡한 타입(Promise, Blob, Map 등)을 처리하기 위해&lt;br /&gt;독자적인 직렬화 포맷인 &lt;b&gt;Flight Protocol&lt;/b&gt;을 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;// Flight Protocol 표현식 예시
$@0  &amp;rarr; Chunk 0에 대한 Promise 참조
$B0  &amp;rarr; Blob 참조
$F0  &amp;rarr; Server Function 참조&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Prototype Pollution이란?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바스크립트의 기본, 자바스크립트에서 객체는 prototype chain을 통해 부모 객체의 속성을 상속받습니다.&lt;br /&gt;때문에 이를 악용하면 &lt;code&gt;__proto__&lt;/code&gt;를 통해 모든 객체에 영향을 미치는 속성을 주입할 수 있습니다.&lt;br /&gt;&lt;code&gt;hasOwnProperty&lt;/code&gt; 만 있었어도..&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;let obj1 = {};
console.log(obj1.foo); // undefined

Object.prototype.foo = &quot;polluted&quot;;

let obj2 = {};
console.log(obj2.foo); // &quot;polluted&quot; (오염됨!)
console.log(obj2.hasOwnProperty(&quot;foo&quot;)); // false (자신의 속성이 아님)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 취약점 발생 단계&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Step 1: 취약한 코드의 위치&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ReactFlightReplyServer.js&lt;/code&gt;의 &lt;code&gt;getOutlinedModel()&lt;/code&gt; 함수:&lt;/p&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;function getOutlinedModel(response, reference, parentObject, key, map) {
  const path = reference.split(':');
  const id = parseInt(path[0], 16);
  const chunk = getChunk(response, id);

  // ...상태 확인 로직...

  switch (chunk.status) {
    case INITIALIZED:
      let value = chunk.value;
      for (let i = 1; i &amp;lt; path.length; i++) {
        value = value[path[i]];  // ⚠️ hasOwnProperty 검증 없음!
      }
      return map(response, value);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;path&lt;/code&gt; 배열의 각 요소로 &lt;code&gt;value&lt;/code&gt; 객체를 순회할 때 &lt;code&gt;hasOwnProperty&lt;/code&gt; 검증이 없어 &lt;code&gt;__proto__&lt;/code&gt; 접근이 가능합니다ㅜㅜ&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Step 2: Primitive 획득 과정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Primitive #1 - Chunk.prototype 접근&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 공격자 입력
reference = &quot;$1:__proto__:then&quot;

// 해석 과정
1번 Chunk의 __proto__ (= Chunk.prototype)의 then 메서드 참조&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;code&gt;$@0&lt;/code&gt; 같은 표현은 Promise 객체 자체를 반환하므로, 이를 통해 &lt;code&gt;Chunk.prototype&lt;/code&gt;에 접근할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Primitive #2 - initializeModelChunk 호출 제어&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Chunk.prototype.then&lt;/code&gt;은 내부적으로 &lt;code&gt;initializeModelChunk()&lt;/code&gt;를 호출합니다:&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;Chunk.prototype.then = function(resolve, reject) {
  const chunk = this;
  switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);  // &amp;larr; 공격자가 제어 가능!
      break;
  }
  // ...
  switch (chunk.status) {
    case INITIALIZED:
      resolve(chunk.value);  // &amp;larr; 여기서 악성 함수 실행
      break;
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Primitive #3 - 임의 함수 생성 및 실행&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;parseModelString()&lt;/code&gt;의 Blob 처리 로직을 악용:&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;case 'B': {
  const id = parseInt(value.slice(2), 16);
  const blobKey = response._prefix + id;
  const backingEntry = response._formData.get(blobKey);  // &amp;larr; 공격자 제어
  return backingEntry;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;code&gt;response._formData.get&lt;/code&gt;을 &lt;code&gt;Function.constructor&lt;/code&gt;로 설정하면 임의 함수 생성이 가능합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Step 3: 최종 공격 페이로드&lt;/h4&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;then&quot;: &quot;$1:__proto__:then&quot;,        // Chunk.prototype.then 참조
  &quot;status&quot;: &quot;resolved_model&quot;,
  &quot;value&quot;: &quot;{\&quot;then\&quot;: \&quot;$B1\&quot;}&quot;,     // Blob을 통한 함수 생성
  &quot;_response&quot;: {
    &quot;_formData&quot;: {
      &quot;get&quot;: &quot;$1:constructor:constructor&quot;  // Function.constructor
    },
    &quot;_prefix&quot;: &quot;process.mainModule.require('child_process').execSync('id');//&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Step 4: 실행 흐름&lt;/h4&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. then이 Chunk.prototype.then으로 설정됨
2. 객체가 resolve될 때 then() 호출
3. this.status가 &quot;resolved_model&quot;이므로 initializeModelChunk() 호출
4. value 파싱 과정에서 $B1이 Function.constructor로 처리됨
5. _prefix에 담긴 악성 JavaScript 코드가 서버에서 실행됨!&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.4 패치 내용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커밋 &lt;code&gt;7dc903c&lt;/code&gt;에서 &lt;code&gt;hasOwnProperty&lt;/code&gt; 검증이 추가됨&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 패치 후
for (let i = 1; i &amp;lt; path.length; i++) {
  if (!value.hasOwnProperty(path[i])) {
    // __proto__ 등 prototype chain 접근 차단
    throw new Error('Invalid property access');
  }
  value = value[path[i]];
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 실무자가 확인할 수 있는 RSC 노출 정보&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;악성 사용자가 &lt;b&gt;정찰&lt;/b&gt; 단계에서 수집할 수 있는 정보는 다음과 같았습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 HTML에 노출되는 Server Action ID&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저 개발자 도구에서 페이지 소스를 확인하면 Server Action의 ID가 노출됩니다:&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- 페이지 소스 예시 --&amp;gt;
&amp;lt;script&amp;gt;
  self.__next_f.push([1, &quot;1:\&quot;$ACTION_ID_abc123def456\&quot;\n&quot;])
&amp;lt;/script&amp;gt;

&amp;lt;!-- 또는 form의 hidden input으로 --&amp;gt;
&amp;lt;form action=&quot;&quot;&amp;gt;
  &amp;lt;input type=&quot;hidden&quot; name=&quot;$ACTION_REF_ID&quot; value=&quot;abc123def456&quot; /&amp;gt;
&amp;lt;/form&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;확인 방법:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;개발자 도구 &amp;rarr; Network 탭&lt;/li&gt;
&lt;li&gt;RSC 요청 확인(&lt;code&gt;?_rsc=&lt;/code&gt; 파라미터가 붙은 요청) &amp;lt; 이라고 하지만 그냥 Doc 탭의 요청 내역에서 Response 보면 됨&lt;/li&gt;
&lt;li&gt;Response에서 &lt;code&gt;$ACTION_ID&lt;/code&gt; 또는 &lt;code&gt;$F&lt;/code&gt; 패턴 검색&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 Next-Action 헤더&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server Action 호출 시 &lt;code&gt;Next-Action&lt;/code&gt; 헤더가 전송됩니다:&lt;/p&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;POST /api/action HTTP/1.1
Host: example.com
Content-Type: multipart/form-data
Next-Action: abc123def456789&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;확인 방법:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 브라우저 콘솔에서 실행
const observer = new PerformanceObserver((list) =&amp;gt; {
  for (const entry of list.getEntries()) {
    if (entry.name.includes('_rsc')) {
      console.log('RSC Request:', entry.name);
    }
  }
});
observer.observe({ entryTypes: ['resource'] });&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 Flight Protocol 페이로드 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Network 탭에서 RSC 응답을 확인하면 Flight Protocol 형식을 볼 수 있습니다:&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;0:[&quot;$&quot;,&quot;div&quot;,null,{&quot;children&quot;:&quot;Hello&quot;}]
1:[&quot;$&quot;,&quot;$L2&quot;,null,{}]
2:I[&quot;@/components/Button&quot;,&quot;default&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 패턴:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;$L&lt;/code&gt; : Lazy 컴포넌트 참조&lt;/li&gt;
&lt;li&gt;&lt;code&gt;$F&lt;/code&gt; : Server Function 참조&lt;/li&gt;
&lt;li&gt;&lt;code&gt;$@&lt;/code&gt; : Promise/Chunk 참조&lt;/li&gt;
&lt;li&gt;&lt;code&gt;I[...]&lt;/code&gt; : Import 구문&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.4 RSC 엔드포인트 식별&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# RSC 엔드포인트 패턴
GET /?_rsc=xxxxx HTTP/1.1
POST / HTTP/1.1  (with Next-Action header)

# curl로 확인
curl -I &quot;https://target.com/?_rsc=test&quot; \
  -H &quot;RSC: 1&quot; \
  -H &quot;Next-Router-State-Tree: ...&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.5 버전 정보 노출&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저 콘솔에서 Next.js 버전 확인:&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;// 브라우저 콘솔
next.version  // 예: &quot;15.3.4&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;또는 &lt;code&gt;/_next/static/chunks/&lt;/code&gt; 경로의 파일명에서 버전 힌트를 얻을 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.6 자체 점검 해보기&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;□ package.json에서 next, react-server-dom-* 버전 확인
□ 빌드 결과물에서 Server Action ID 노출 여부 확인
□ Network 탭에서 Flight Protocol 요청/응답 모니터링
□ 에러 메시지에서 내부 경로 노출 여부 확인
□ Source Map 비활성화 여부 확인 (프로덕션)&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 대처 방안&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 즉각적인 패치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;패치 적용이 유일한 해결책이었습니다...!!&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;자동 업그레이드 도구가 있긴합니다&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;npx fix-react2shell-next&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 WAF로 방어할 수 없는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAF(Web Application Firewall) 규칙은 &lt;b&gt;완전한 방어가 불가능&lt;/b&gt;합니다:&lt;/p&gt;
&lt;pre class=&quot;taggerscript&quot;&gt;&lt;code&gt;// Flight Protocol은 JSON.parse를 사용하므로 유니코드 우회 가능
{
  &quot;\u0074\u0068\u0065\u006e&quot;: &quot;\u0024\u0031\u003a...&quot;
}
// 위 코드는 &quot;then&quot;: &quot;$1:...&quot;과 동일&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Vercel은 WAF 규칙을 배포했지만, 이는 추가적인 방어층일 뿐 패치를 대체하지 못합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.4 침해 여부 점검&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;12월 4일 이전에 취약한 버전으로 운영했다면 침해를 가정하고 대응하는 편이 좋았습니다&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;fortran&quot;&gt;&lt;code&gt;점검 항목:
□ 비정상적인 POST 요청 로그 확인 (특히 multipart/form-data)
□ 서버 함수 타임아웃 급증 여부
□ 예기치 않은 프로세스 생성 로그
□ 아웃바운드 네트워크 연결 이상 여부&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.5 시크릿 로테이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;침해 가능성이 있다면 모든 시크릿을 교체해야 합니다:&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.6 영향받지 않는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 조건에 해당하면 이 취약점의 영향을 받지 &lt;b&gt;않습니다&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;React 코드가 서버에서 실행되지 않는 경우 (순수 CSR)&lt;/li&gt;
&lt;li&gt;React Server Components를 지원하지 않는 번들러/프레임워크 사용&lt;/li&gt;
&lt;li&gt;&lt;del&gt;Next.js 14.2.x 이하 안정 버전 사용 (14.3.0-canary.77 미만)&lt;/del&gt; &amp;lt; 후속 취약점으로 인해 모두 영향받음&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components&quot;&gt;React 공식 보안 공지&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://vercel.com/kb/bulletin/react2shell&quot;&gt;Vercel Security Bulletin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.enki.co.kr/media-center/blog/complete-analysis-of-the-react2shell-cve-2025-55182-vulnerability&quot;&gt;ENKI 한국어 분석&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp&quot;&gt;Next.js CVE-2025-66478&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/facebook/react/commit/7dc903cd29dac55efb4424853fd0442fef3a8700&quot;&gt;React 패치 커밋&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.cve.org/CVERecord?id=CVE-2025-55182&quot;&gt;CVE-2025-55182 상세&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>next</category>
      <category>react</category>
      <category>react2shell</category>
      <category>vulnerability</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/325</guid>
      <comments>https://ifelseif.tistory.com/325#entry325comment</comments>
      <pubDate>Sun, 14 Dec 2025 16:32:27 +0900</pubDate>
    </item>
    <item>
      <title>[251127 TIL] Next.js 이미지 최적화 정리</title>
      <link>https://ifelseif.tistory.com/324</link>
      <description>&lt;h1&gt;Next.js Image 최적화 정리&amp;nbsp; Next.js 15+ 기준&lt;/h1&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Next.js Image 최적화의 동작 원리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;빌드 타임이 아닌 런타임 최적화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js Image가 빌드 타임에 &lt;code&gt;.next&lt;/code&gt; 폴더에 webp 이미지를 미리 생성해둔다고 생각하는 경우가 있습니다.&lt;br /&gt;그게 바로 저입니다... &lt;b&gt;실제로는 런타임에 on-demand로 최적화가 이루어 진다고 하네요.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;요청 흐름:
1. 브라우저 &amp;rarr; /_next/image?url=...&amp;amp;w=800&amp;amp;q=75 요청
2. Next.js 서버 &amp;rarr; 원본 이미지 fetch
3. Next.js 서버 &amp;rarr; 리사이즈 + webp/avif 변환
4. .next/cache/images/ 에 캐시 저장
5. 이후 동일 요청 &amp;rarr; 캐시에서 즉시 응답&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 방식&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;동적 src도 최적화 가능&lt;/b&gt;: 런타임 처리이므로 동적 이미지 URL도 혜택을 받음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정적 페이지 필수 아님&lt;/b&gt;: SSR, ISR, CSR 모두에서 동작&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 기반&lt;/b&gt;: 첫 요청만 느리고, 이후는 캐시 히트로 빠름&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 제가 오해한 부분&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;오해: 이미 webp면 최적화를 건너뛴다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;아님.&lt;/b&gt;&lt;br /&gt;Next.js는 원본 포맷과 관계없이 무조건 최적화 거침.&lt;br /&gt;이미 최적화된 webp 이미지도 재처리됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;오해: &lt;code&gt;new Image()&lt;/code&gt;로 프리로드하면 Next Image도 빨라진다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;아님.&lt;/b&gt;&lt;br /&gt;URL이 다르기 때문에 브라우저 캐시가 공유되지 않습니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;프리로드 URL:    https://example.com/photo.jpg
Next Image URL: /_next/image?url=https://example.com/photo.jpg&amp;amp;w=800&amp;amp;q=75&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 상황별 최적화 전략&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;케이스 1: &lt;code&gt;/public&lt;/code&gt;에 이미 최적화된 이미지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증상&lt;/b&gt;: webp로 직접 최적화한 이미지인데 Next Image가 오히려 느림&lt;br /&gt;&lt;b&gt;원인&lt;/b&gt;: 불필요한 재처리 오버헤드&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;: &lt;code&gt;unoptimized={true}&lt;/code&gt; 사용&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// 이미 최적화된 로컬 이미지
&amp;lt;Image 
  src=&quot;/images/optimized-hero.webp&quot;
  alt=&quot;Hero&quot;
  width={1200}
  height={600}
  unoptimized
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;케이스 2: 외부 이미지 (CDN, 사용자 업로드 등)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증상&lt;/b&gt;: 첫 로드가 느리고, 두 번째부터 빠름&lt;br /&gt;&lt;b&gt;원인&lt;/b&gt;: 정상 동작. 첫 요청 시 서버에서 최적화 작업 수행&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;: 서버 캐시 워밍업 (아래 섹션 참고)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;케이스 3: LCP (Largest Contentful Paint) 이미지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: &lt;code&gt;priority&lt;/code&gt; prop 필수&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;Image 
  src={heroImage}
  alt=&quot;Hero&quot;
  priority  // preload 힌트 + fetchpriority=&quot;high&quot;
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상황별 알아서 분기하는 래퍼 컴포넌트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상황별 자동 분기 처리:&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;import Image, { ImageProps } from 'next/image';

interface SmartImageProps extends Omit&amp;lt;ImageProps, 'unoptimized'&amp;gt; {
  src: string;
}

export function SmartImage({ src, width, ...props }: SmartImageProps) {
  const isAlreadyOptimized = 
    src.endsWith('.webp') || 
    src.endsWith('.avif') ||
    src.includes('imagecdn.com') ||
    src.includes('cloudinary.com');

  const isSmallImage = typeof width === 'number' &amp;amp;&amp;amp; width &amp;lt; 100;
  const isSvg = src.endsWith('.svg');

  const skipOptimization = isAlreadyOptimized || isSmallImage || isSvg;

  return (
    &amp;lt;Image 
      src={src}
      width={width}
      unoptimized={skipOptimization}
      {...props}
    /&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 서버 캐시 워밍업으로 첫 방문자도 빠르게&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 상황&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 이미지의 경우 첫 방문자는 항상 최적화 대기 시간을 겪습니다.&lt;br /&gt;브라우저 캐시는 같은 유저의 재방문에만 효과가 있고,&lt;br /&gt;서버 캐시가 비어있으면 모든 신규 방문자가 느린 첫 로드를 경험합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결: 배포 후 캐시 워밍업&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;워밍업 스크립트&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// scripts/warmup-images.ts

const BASE_URL = process.env.SITE_URL || 'http://localhost:3000';

// 워밍업할 이미지 목록
const IMAGES_TO_WARM = [
  'https://external-cdn.com/hero-image.jpg',
  'https://external-cdn.com/product-1.jpg',
  'https://external-cdn.com/product-2.jpg',
  // 동적으로 가져올 수도 있음
];

// Next.js 기본 deviceSizes + imageSizes
const WIDTHS = [640, 750, 828, 1080, 1200, 1920];
const QUALITY = 75;

async function warmupImage(src: string, width: number): Promise&amp;lt;void&amp;gt; {
  const url = `${BASE_URL}/_next/image?url=${encodeURIComponent(src)}&amp;amp;w=${width}&amp;amp;q=${QUALITY}`;

  try {
    const response = await fetch(url);
    if (response.ok) {
      console.log(`✓ Warmed: ${src} @ ${width}w`);
    } else {
      console.error(`✗ Failed: ${src} @ ${width}w - ${response.status}`);
    }
  } catch (error) {
    console.error(`✗ Error: ${src} @ ${width}w -`, error);
  }
}

async function warmup(): Promise&amp;lt;void&amp;gt; {
  console.log('  Starting image cache warmup...\n');

  const tasks: Promise&amp;lt;void&amp;gt;[] = [];

  for (const src of IMAGES_TO_WARM) {
    for (const width of WIDTHS) {
      tasks.push(warmupImage(src, width));
    }
  }

  // 동시 요청 제한 (서버 부하 방지)
  const CONCURRENCY = 5;
  for (let i = 0; i &amp;lt; tasks.length; i += CONCURRENCY) {
    await Promise.all(tasks.slice(i, i + CONCURRENCY));
  }

  console.log('\n✅ Warmup complete!');
}

warmup();&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;실행 방법&lt;/h4&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;# 로컬 테스트
pnpm build &amp;amp;&amp;amp; pnpm start &amp;amp;
sleep 5  # 서버 시작 대기
npx tsx scripts/warmup-images.ts

# 또는 package.json에 추가&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;warmup&quot;: &quot;tsx scripts/warmup-images.ts&quot;,
    &quot;start:warmed&quot;: &quot;next start &amp;amp; sleep 5 &amp;amp;&amp;amp; npm run warmup&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 배포 환경별 설정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Vercel&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vercel은 이미지 최적화가 Edge에서 자동 처리되고 글로벌 CDN 캐시가 포함됨.&lt;br /&gt;별도 설정 없이 최적의 성능을 얻을 수 있음.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요한 경우 수동으로 배포시 워밍업 로직을 실행하게 할 수는 있음...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GitHub Actions&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# .github/workflows/warmup.yml
name: Image Cache Warmup

on:
  deployment_status:

jobs:
  warmup:
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm install

      - name: Wait for deployment to stabilize
        run: sleep 30

      - name: Run warmup script
        env:
          SITE_URL: ${{ github.event.deployment_status.target_url }}
        run: npx tsx scripts/warmup-images.ts&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Docker (Cloud Run, ECS, etc.)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너 환경에서는 추가 고려사항이 있습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 캐시 영속성 문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너 재시작 시 &lt;code&gt;.next/cache/images/&lt;/code&gt; 캐시가 사라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: 볼륨 마운트 또는 외부 캐시&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# docker-compose.yml
services:
  web:
    image: your-nextjs-app
    volumes:
      - image-cache:/app/.next/cache/images

volumes:
  image-cache:&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 스케일아웃 시 캐시 분산&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 인스턴스가 각자 캐시를 갖게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: CDN을 앞에 배치&lt;/p&gt;
&lt;pre class=&quot;sqf&quot;&gt;&lt;code&gt;사용자 &amp;rarr; CloudFront/Cloud CDN &amp;rarr; Container
         (/_next/image/* 캐시)&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. Dockerfile에 워밍업 포함&lt;/h4&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;FROM node:20-alpine AS runner

WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY scripts/warmup-images.ts ./scripts/

# 헬스체크 + 워밍업 엔트리포인트
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh

EXPOSE 3000
ENTRYPOINT [&quot;./docker-entrypoint.sh&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;#!/bin/bash
# docker-entrypoint.sh

# Next.js 서버 시작 (백그라운드)
node server.js &amp;amp;

# 서버 준비 대기
until curl -s http://localhost:3000/api/health &amp;gt; /dev/null; do
  sleep 1
done

# 캐시 워밍업
npx tsx scripts/warmup-images.ts

# 포그라운드로 전환
wait&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;외부 이미지 최적화 서비스 활용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 내장 최적화 대신 전문 서비스를 사용하면 인프라 복잡도를 줄일 수 있다는데,, 아직 해보질 못했네&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// next.config.ts
import type { NextConfig } from 'next';

const config: NextConfig = {
  images: {
    loader: 'custom',
    loaderFile: './lib/image-loader.ts',
  },
};

export default config;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// lib/image-loader.ts
interface ImageLoaderParams {
  src: string;
  width: number;
  quality?: number;
}

export default function cloudinaryLoader({ 
  src, 
  width, 
  quality = 75 
}: ImageLoaderParams): string {
  // Cloudinary 예시
  const params = [
    'f_auto',
    'c_limit',
    `w_${width}`,
    `q_${quality}`,
  ];
  return `https://res.cloudinary.com/your-cloud/image/fetch/${params.join(',')}/${encodeURIComponent(src)}`;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리: 상황별 체크리스트&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상황&lt;/th&gt;
&lt;th&gt;권장 설정&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/public&lt;/code&gt; 폴더의 이미 최적화된 이미지&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unoptimized={true}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 이미지, 성능 중요&lt;/td&gt;
&lt;td&gt;서버 캐시 워밍업 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP 이미지 (Hero, 메인 배너 등)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;priority&lt;/code&gt; prop 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;아이콘, 작은 이미지 (&amp;lt; 100px)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unoptimized={true}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG 이미지&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unoptimized={true}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel 배포&lt;/td&gt;
&lt;td&gt;GitHub Actions 워밍업&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker 배포&lt;/td&gt;
&lt;td&gt;CDN + 볼륨 마운트 + 엔트리포인트 워밍업&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/building-your-application/optimizing/images&quot;&gt;Next.js Image Optimization 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/api-reference/components/image&quot;&gt;next/image API Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://vercel.com/docs/image-optimization&quot;&gt;Vercel Image Optimization&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>Cache</category>
      <category>NextImage</category>
      <category>nextjs</category>
      <category>preload</category>
      <category>priority</category>
      <category>unoptimized</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/324</guid>
      <comments>https://ifelseif.tistory.com/324#entry324comment</comments>
      <pubDate>Thu, 27 Nov 2025 08:52:45 +0900</pubDate>
    </item>
    <item>
      <title>[251124 TIL] 간접 의존성 관련 업데이트</title>
      <link>https://ifelseif.tistory.com/323</link>
      <description>&lt;h1&gt; pnpm 문제 였나?&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚠️ Next.js 버전별 차이점&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Next.js 15.5+ 변경사항&lt;/h4&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// Next.js 15.5부터는 require-in-the-middle이 기본적으로 opt-out됨!
// 하지만 import-in-the-middle은 여전히 명시 필요
const nextConfig: NextConfig = {
  serverExternalPackages: [
    &quot;import-in-the-middle&quot;,  // 여전히 필요
    // &quot;require-in-the-middle&quot;,  // 15.5+에서는 불필요 (기본 opt-out)
  ],
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 1: next.config.ts 수정 (기본)&lt;/h3&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// next.config.ts
const nextConfig: NextConfig = {
  serverExternalPackages: [
    &quot;import-in-the-middle&quot;,
    // Next.js &amp;lt; 15.5인 경우에만 추가
    ...(process.env.NEXT_VERSION &amp;lt; '15.5' ? [&quot;require-in-the-middle&quot;] : []),
  ],
};

export default nextConfig;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 2: pnpm 사용 시 - .npmrc 설정 (권장)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pnpm의 엄격한 의존성 격리 때문에 발생하는 문제는 &lt;code&gt;.npmrc&lt;/code&gt; 설정으로 해결:&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# .npmrc
# pnpm의 엄격한 의존성 격리를 완화
# Sentry와 같은 instrumentation 도구가 간접 의존성에 접근할 수 있도록 허용
node-linker=hoisted

# 또는 부분적으로만 hoisting (더 안전)
public-hoist-pattern[]=*import-in-the-middle*
public-hoist-pattern[]=*require-in-the-middle*&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;pnpm의 의존성 격리 이해하기&lt;/h4&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;# 기본 pnpm 구조 (격리됨)
node_modules/
├── .pnpm/
│   ├── @sentry+nextjs@8.0.0/
│   │   └── node_modules/
│   │       ├── @sentry/nextjs/  &amp;larr; 실제 코드
│   │       └── import-in-the-middle/  &amp;larr; 격리된 의존성
│   └── import-in-the-middle@1.0.0/
│       └── node_modules/
│           └── import-in-the-middle/
└── @sentry/  &amp;larr; 심볼릭 링크
    └── nextjs/

# hoisted 구조 (npm/yarn과 유사)
node_modules/
├── @sentry/
│   └── nextjs/
└── import-in-the-middle/  &amp;larr; 최상위로 끌어올림&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 3: 종합 해결책 (pnpm + Next.js 15.5+)&lt;/h3&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# .npmrc (프로젝트 루트)
# Sentry 관련 패키지만 선택적으로 hoisting
public-hoist-pattern[]=*import-in-the-middle*
public-hoist-pattern[]=*require-in-the-middle*
shamefully-hoist=false  # 전체 hoisting은 하지 않음&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// next.config.ts
const nextConfig: NextConfig = {
  serverExternalPackages: [
    &quot;import-in-the-middle&quot;,  // Next.js 15.5+에서도 필요
  ],
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  해결 원리 이해하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;serverExternalPackages가 하는 일&lt;/h3&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;// Before: Turbopack의 기본 동작
{
  번들링_시도: true,
  프로젝트_루트에서_찾기: true,  // 실패! &amp;rarr; 경고
  런타임_동작: &quot;Node.js가 알아서 찾음&quot;  // 정상 작동
}

// After: serverExternalPackages 설정 후
{
  번들링_시도: false,  // 아예 시도 안 함!
  프로젝트_루트에서_찾기: false,  // 확인할 필요 없음
  런타임_동작: &quot;Node.js가 알아서 찾음&quot;  // 정상 작동
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;pnpm vs npm/yarn 차이점&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;npm/yarn의 flat 구조&lt;/h4&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;node_modules/
├── @sentry/nextjs/
├── import-in-the-middle/  &amp;larr; 모든 패키지가 접근 가능
└── require-in-the-middle/&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;pnpm의 격리된 구조&lt;/h4&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;node_modules/
├── .pnpm/  &amp;larr; 실제 패키지들 (격리됨)
│   └── @sentry+nextjs@8.0.0/
│       └── node_modules/
│           ├── @sentry/nextjs/
│           └── import-in-the-middle/  &amp;larr; Sentry만 접근 가능
└── @sentry/  &amp;larr; 심볼릭 링크
    └── nextjs/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;pnpm의 장점&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;디스크 공간 절약&lt;/li&gt;
&lt;li&gt;의존성 충돌 방지&lt;/li&gt;
&lt;li&gt;더 엄격한 의존성 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;pnpm의 단점&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Instrumentation 도구들이 간접 의존성 접근 어려움&lt;/li&gt;
&lt;li&gt;Turbopack과 충돌 가능성&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Node.js 모듈 해석 알고리즘&lt;/h3&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;// require('import-in-the-middle') 호출 시 Node.js의 탐색 순서:
[
  '/your-project/node_modules/import-in-the-middle',  // ❌ pnpm에서는 없음
  '/your-project/node_modules/@sentry/.../node_modules/import-in-the-middle',  // ❌ 심볼릭 링크
  '/your-project/node_modules/.pnpm/.../import-in-the-middle',  // ✅ 실제 위치
]&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  참고 자료 업데이트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/api-reference/next-config-js/serverExternalPackages&quot;&gt;Next.js serverExternalPackages 문서&lt;/a&gt; - 15.5부터 require-in-the-middle 기본 opt-out&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.sentry.io/platforms/javascript/guides/nextjs/troubleshooting/#pnpm-resolving-import-in-the-middle-external-package-errors&quot;&gt;Sentry pnpm 트러블슈팅&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/motivation#creating-a-non-flat-node_modules-directory&quot;&gt;pnpm의 node_modules 구조&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://turbo.build/pack/docs&quot;&gt;Turbopack 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nodejs.org/api/modules.html#modules_all_together&quot;&gt;Node.js 모듈 해석 알고리즘&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  *&lt;i&gt;Quick Fix *&lt;/i&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;npm/yarn 사용자&lt;/h3&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;// next.config.ts에 추가
serverExternalPackages: [&quot;문제의-패키지-이름&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;pnpm 사용자 (Sentry 등 instrumentation 도구)&lt;/h3&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;# 1. .npmrc 파일 생성/수정
echo &quot;public-hoist-pattern[]=*import-in-the-middle*&quot; &amp;gt;&amp;gt; .npmrc
echo &quot;public-hoist-pattern[]=*require-in-the-middle*&quot; &amp;gt;&amp;gt; .npmrc

# 2. 의존성 재설치
rm -rf node_modules pnpm-lock.yaml
pnpm install

# 3. Next.js 15.5+ 사용 시
# next.config.ts에 import-in-the-middle만 추가 (require는 기본 opt-out)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;버전별 체크&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Next.js &amp;lt; 15.5&lt;/b&gt;: 두 패키지 모두 serverExternalPackages에 추가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Next.js &amp;ge; 15.5&lt;/b&gt;: import-in-the-middle만 추가 (require-in-the-middle은 기본 제외됨)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;pnpm 사용 시&lt;/b&gt;: 추가로 .npmrc 설정 필요&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>pnpm</category>
      <category>sentry</category>
      <category>turbopack</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/323</guid>
      <comments>https://ifelseif.tistory.com/323#entry323comment</comments>
      <pubDate>Mon, 24 Nov 2025 09:56:42 +0900</pubDate>
    </item>
    <item>
      <title>[251123 TIL] Turbopack 간접 의존성 경고 상황</title>
      <link>https://ifelseif.tistory.com/322</link>
      <description>&lt;h1&gt;Turbopack 간접 의존성 경고와 해결법&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황 타임라인&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣ 초기 상황&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로젝트 환경:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Next.js 15.5.4 + Turbopack&lt;/li&gt;
&lt;li&gt;Sentry + OpenTelemetry 설정 완료&lt;/li&gt;
&lt;li&gt;개발 서버 실행 중&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣ 경고 발생&lt;/h3&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;⚠ ./node_modules/.pnpm/@opentelemetry+instrumentation@0.204.0_@opentelemetry+api@1.9.0/node_modules/@opentelemetry/instrumentation/build/esm/platform/node
Package require-in-the-middle can't be external
The request require-in-the-middle matches serverExternalPackages (or the default list).
The request could not be resolved by Node.js from the project directory.
Packages that should be external need to be installed in the project directory, so they can be resolved from the output files.
Try to install it into the project directory by running npm install require-in-the-middle from the project directory.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3️⃣ 문제 파악&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;import-in-the-middle&lt;/code&gt;, &lt;code&gt;require-in-the-middle&lt;/code&gt; 패키지가 문제&lt;/li&gt;
&lt;li&gt;직접 설치한 패키지가 아닌 &lt;b&gt;간접 의존성&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Sentry와 OpenTelemetry가 내부적으로 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 발생 원인&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;의존성 구조 분석&lt;/h3&gt;
&lt;pre class=&quot;moonscript&quot;&gt;&lt;code&gt;your-project/
├── package.json (직접 의존성)
│   ├── @sentry/nextjs
│   └── @opentelemetry/instrumentation
│
└── node_modules/ (설치된 패키지들)
    ├── @sentry/
    │   └── (내부 어딘가)/
    │       └── node_modules/
    │           └── import-in-the-middle/  &amp;larr; 간접 의존성!
    │
    └── @opentelemetry/
        └── (내부 어딘가)/
            └── node_modules/
                └── require-in-the-middle/  &amp;larr; 간접 의존성!&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Turbopack의 동작 원리&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;번들링 시도&lt;/b&gt;: Turbopack이 코드를 번들링하려 함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;External 판단&lt;/b&gt;: 특정 패키지를 &quot;external&quot;로 처리하기로 결정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;경로 확인&lt;/b&gt;: 프로젝트 루트 (&lt;code&gt;/your-project/node_modules/&lt;/code&gt;)에서 패키지 찾기 시도&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실패&lt;/b&gt;: 간접 의존성이라 루트에 없음 &amp;rarr; ⚠️ 경고!&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 이 패키지들이 문제인가?&lt;/h3&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;// import-in-the-middle과 require-in-the-middle의 역할
// Node.js의 모듈 로딩 시스템을 가로채서(hook) 
// 런타임에 코드를 주입하는 특수한 패키지들

// Sentry/OpenTelemetry가 이걸 사용하는 이유:
// &amp;rarr; 자동으로 에러 추적, 성능 모니터링 코드를 주입하기 위해&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;next.config.ts 수정&lt;/h3&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;// next.config.ts
const nextConfig: NextConfig = {
  // OpenTelemetry instrumentation이 사용하는 패키지들을 external로 처리
  // 이 패키지들은 Sentry와 OpenTelemetry의 의존성으로 사용되며,
  // Node.js 런타임에서 모듈을 동적으로 instrument하기 위해 필요합니다.
  serverExternalPackages: [
    &quot;import-in-the-middle&quot;,
    &quot;require-in-the-middle&quot;,
  ],
};

export default nextConfig;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 원리 이해하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;serverExternalPackages가 하는 일&lt;/h3&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;// Before: Turbopack의 기본 동작
{
  번들링_시도: true,
  프로젝트_루트에서_찾기: true,  // 실패! &amp;rarr; 경고
  런타임_동작: &quot;Node.js가 알아서 찾음&quot;  // 정상 작동
}

// After: serverExternalPackages 설정 후
{
  번들링_시도: false,  // 아예 시도 안 함!
  프로젝트_루트에서_찾기: false,  // 확인할 필요 없음
  런타임_동작: &quot;Node.js가 알아서 찾음&quot;  // 정상 작동
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Node.js 모듈 해석 알고리즘&lt;/h3&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// require('import-in-the-middle') 호출 시 Node.js의 탐색 순서:
[
  '/your-project/node_modules/import-in-the-middle',  // ❌ 없음
  '/your-project/node_modules/@sentry/.../node_modules/import-in-the-middle',  // ✅ 찾음!
  // 상위 디렉토리로 계속 올라가며 탐색...
]&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 개념 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;External Package란?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;번들에 포함 안 함&lt;/b&gt;: 빌드 결과물에 패키지 코드를 넣지 않음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;런타임 로드&lt;/b&gt;: 실행 시점에 &lt;code&gt;require()&lt;/code&gt;로 동적 로드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;용도&lt;/b&gt;: Native 모듈, Node.js 특화 기능, 큰 바이너리 파일 등&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;간접 의존성 (Transitive Dependencies)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;정의&lt;/b&gt;: 내가 설치한 패키지가 의존하는 다른 패키지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;위치&lt;/b&gt;: 중첩된 node_modules 폴더에 존재&lt;/li&gt;
&lt;li&gt;&lt;b&gt;문제점&lt;/b&gt;: 번들러가 찾기 어려울 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기억해 둘 것!&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;경고 신호 인식하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 패턴을 보면 즉시 간접 의존성 문제 의심:&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;⚠ Package [패키지명] can't be external
The request could not be resolved by Node.js from the project directory.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 패키지 유형&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Instrumentation/Monitoring 도구&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;serverExternalPackages: [
  // APM, 모니터링
  &quot;import-in-the-middle&quot;,
  &quot;require-in-the-middle&quot;, 
  &quot;dd-trace&quot;,
  &quot;elastic-apm-node&quot;,
  &quot;@newrelic/native-metrics&quot;
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Native 바인딩 패키지&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;serverExternalPackages: [
  // 이미지, 암호화, 캔버스
  &quot;sharp&quot;,
  &quot;bcrypt&quot;, 
  &quot;canvas&quot;,
  &quot;node-gyp&quot;
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Database 드라이버&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;serverExternalPackages: [
  // DB 관련
  &quot;@prisma/engines&quot;,
  &quot;pg-native&quot;,
  &quot;oracledb&quot;
]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  디버깅 명령어&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 1. 패키지가 어디서 오는지 확인 (pnpm)
pnpm why [패키지명]

# 2. 의존성 트리 확인
pnpm list --depth=3 | grep [패키지명]

# 3. node_modules 구조 직접 확인
find node_modules -name &quot;[패키지명]&quot; -type d&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 프로-액티브하게 해결하기&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// next.config.ts - 종합 템플릿
import { NextConfig } from 'next';

// 프로젝트 시작 시 미리 설정해두면 좋은 패키지들
const commonExternalPackages = [
  // === Instrumentation ===
  &quot;import-in-the-middle&quot;,
  &quot;require-in-the-middle&quot;,

  // === Native Modules ===
  &quot;sharp&quot;,
  &quot;canvas&quot;, 
  &quot;bcrypt&quot;,

  // === Database ===
  &quot;@prisma/engines&quot;,
  &quot;pg-native&quot;,

  // === Monitoring ===
  &quot;dd-trace&quot;,
  &quot;pino-pretty&quot;,

  // === 프로젝트별 추가 ===
  // ...여기에 프로젝트 특수 패키지 추가
];

const nextConfig: NextConfig = {
  serverExternalPackages: process.env.NODE_ENV === 'development' 
    ? commonExternalPackages 
    : commonExternalPackages.filter(pkg =&amp;gt; 
        // 프로덕션에서는 필요한 것만
        !pkg.includes('pretty')
      ),
};

export default nextConfig;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 교훈&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;Turbopack은 Webpack보다 엄격하다&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Webpack: 암묵적으로 많은 것을 처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Turbopack: 명시적 선언 필요&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;간접 의존성은 숨어있다&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 설치하지 않은 패키지도 문제를 일으킬 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 Native 모듈, Instrumentation 도구 주의&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;경고는 무시하지 말자&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당장은 작동해도 프로덕션 빌드에서 문제될 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 환경을 깨끗하게 유지하는 것이 중요&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/api-reference/next-config-js/serverExternalPackages&quot;&gt;Next.js serverExternalPackages 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://turbo.build/pack/docs&quot;&gt;Turbopack 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nodejs.org/api/modules.html#modules_all_together&quot;&gt;Node.js 모듈 해석 알고리즘&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;Quick Fix 요약&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;// 문제 발생 시 즉시 적용:
// 1. 경고 메시지에서 패키지명 확인
// 2. next.config.ts에 추가
serverExternalPackages: [&quot;문제의-패키지-이름&quot;]
// 3. 개발 서버 재시작&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <category>serverExternalPackages</category>
      <category>turbopack</category>
      <category>간접의존성</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/322</guid>
      <comments>https://ifelseif.tistory.com/322#entry322comment</comments>
      <pubDate>Sun, 23 Nov 2025 19:50:01 +0900</pubDate>
    </item>
    <item>
      <title>[251123 TIL] OpenTelemetry? (with Next.js)</title>
      <link>https://ifelseif.tistory.com/321</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;OpenTelemetry 가이드  &lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  OpenTelemetry란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OpenTelemetry&lt;/b&gt;(줄여서 OTel)는 앱의 &lt;b&gt;성능과 동작을 추적&lt;/b&gt;하는 오픈소스 관측성(Observability) 프레임워크 입니다&lt;br /&gt;즉, &quot;내 앱이 어떻게 돌아가고 있는지&quot; 실시간으로 들여다보는 도구입니다!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  핵심 개념: 3대 Pillar&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. &lt;b&gt;Traces (추적)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 요청이 시스템을 통과하는 전체 여정을 추적&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 예: 사용자가 상품 구매 버튼 클릭
[브라우저] 2ms &amp;rarr; [API Gateway] 10ms &amp;rarr; [주문 서비스] 50ms &amp;rarr; [결제 서비스] 200ms &amp;rarr; [DB] 30ms      
총 소요 시간: 292ms&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. &lt;b&gt;Metrics (지표)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템 상태를 숫자로 측정&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 예시 지표들
- 초당 요청 수: 1,234 req/s
- 평균 응답 시간: 145ms
- CPU 사용률: 67%
- 메모리 사용량: 2.3GB
- 에러율: 0.02%&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. &lt;b&gt;Logs (로그)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 기록 (하지만 구조화된 방식으로)&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;{
  timestamp: &quot;2024-01-20T10:30:45Z&quot;,
  level: &quot;ERROR&quot;,
  traceId: &quot;abc123&quot;,  // Trace와 연결!
  spanId: &quot;def456&quot;,
  message: &quot;Payment failed&quot;,
  userId: &quot;user_789&quot;,
  amount: 50000
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실제 사용 예시&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Next.js에서 OpenTelemetry 설정&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const sdk = new NodeSDK({
      // 서비스 정보
      resource: new Resource({
        [SemanticResourceAttributes.SERVICE_NAME]: 'my-shop-frontend',
        [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
      }),

      // 자동 계측 (자동으로 추적!)
      instrumentations: [
        getNodeAutoInstrumentations({
          '@opentelemetry/instrumentation-fs': {
            enabled: false, // 파일 시스템은 제외
          },
        }),
      ],
    });

    sdk.start();
    console.log('OpenTelemetry 시작!');
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실전 활용: 전자상거래 시나리오&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. &lt;b&gt;Distributed Tracing (분산 추적)&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// API Route: app/api/checkout/route.ts
import { trace } from '@opentelemetry/api';

const tracer = trace.getTracer('checkout-service');

export async function POST(request: Request) {
  // 전체 체크아웃 프로세스 추적 시작
  return tracer.startActiveSpan('checkout', async (span) =&amp;gt; {
    try {
      // 1. 재고 확인
      await tracer.startActiveSpan('check-inventory', async (inventorySpan) =&amp;gt; {
        const hasStock = await checkInventory(items);
        inventorySpan.setAttribute('items.count', items.length);
        inventorySpan.setAttribute('has.stock', hasStock);
        inventorySpan.end();
      });

      // 2. 결제 처리
      await tracer.startActiveSpan('process-payment', async (paymentSpan) =&amp;gt; {
        const result = await processPayment(amount);
        paymentSpan.setAttribute('payment.amount', amount);
        paymentSpan.setAttribute('payment.status', result.status);
        paymentSpan.end();
      });

      span.setStatus({ code: SpanStatusCode.OK });
      return NextResponse.json({ success: true });

    } catch (error) {
      span.recordException(error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw error;
    } finally {
      span.end();
    }
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. &lt;b&gt;Custom Metrics (커스텀 지표)&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { metrics } from '@opentelemetry/api';

// 미터 생성
const meter = metrics.getMeter('ecommerce-metrics');

// 카운터: 판매 수량
const salesCounter = meter.createCounter('sales_total', {
  description: '총 판매 수량',
  unit: 'items',
});

// 히스토그램: 결제 시간
const paymentDuration = meter.createHistogram('payment_duration', {
  description: '결제 처리 시간',
  unit: 'ms',
});

// 사용 예
export async function processOrder(order: Order) {
  const startTime = Date.now();

  try {
    await processPayment(order);

    // 지표 기록
    salesCounter.add(order.items.length, {
      category: order.category,
      paymentMethod: order.paymentMethod,
    });

    paymentDuration.record(Date.now() - startTime, {
      status: 'success',
    });

  } catch (error) {
    paymentDuration.record(Date.now() - startTime, {
      status: 'failed',
    });
    throw error;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  시각화: 실제로 보이는 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenTelemetry 데이터는 다양한 백엔드로 전송되어 시각화됩니다:&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Jaeger UI에서 보는 Trace&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[GET /api/products] ──────────────────── 245ms
  ├─[DB Query: products] ───── 89ms
  ├─[Cache Check] ─── 5ms
  ├─[Image CDN] ────────── 120ms
  └─[Response Format] ── 31ms&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Grafana에서 보는 Metrics&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;┌─────────────────────────────────┐
│   Response Time (p99)           
│     145ms &amp;rarr; 189ms &amp;rarr; 134ms      
└─────────────────────────────────┘

┌─────────────────────────────────┐
│   Error Rate                    
│     0.1% ═══════════           
└─────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Next.js 전용 설정 예시&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/telemetry.ts
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerOTel } from '@vercel/otel';

export function initTelemetry() {
  // Vercel의 OpenTelemetry 헬퍼 사용
  registerOTel({
    serviceName: 'my-nextjs-app',

    // Trace를 어디로 보낼지
    traceExporter: new OTLPTraceExporter({
      url: 'https://api.honeycomb.io/v1/traces',
      headers: {
        'x-honeycomb-team': process.env.HONEYCOMB_API_KEY,
      },
    }),

    // 샘플링 (모든 요청 추적하면 비용&amp;uarr;)
    tracesSampleRate: process.env.NODE_ENV === 'production' 
      ? 0.1  // 프로덕션: 10%만
      : 1.0, // 개발: 전부
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  왜 OpenTelemetry를 쓰는가?&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;// 사용자: &quot;결제가 너무 느려요!&quot;  

// 개발자: &quot;어디가 느린거지?&quot;  
// - Next.js API Route?
// - 외부 결제 API?
// - 데이터베이스 쿼리?
// - Redis 캐시?&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;OpenTelemetry로 해결&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// Trace 결과:
checkout-process (총 3.2초)  
  ├─ validate-cart: 50ms ✅
  ├─ check-inventory: 200ms ✅
  ├─ payment-api-call: 2,800ms ❌ (여기가 문제!)
  └─ send-confirmation: 150ms ✅&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  인기 있는 백엔드 서비스&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;오픈소스 (무료)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Jaeger&lt;/li&gt;
&lt;li&gt;Zipkin&lt;/li&gt;
&lt;li&gt;Grafana Tempo&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상용 서비스&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Datadog&lt;/li&gt;
&lt;li&gt;New Relic&lt;/li&gt;
&lt;li&gt;Honeycomb&lt;/li&gt;
&lt;li&gt;AWS X-Ray&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Sentry vs OpenTelemetry&lt;/h3&gt;
&lt;pre class=&quot;protobuf&quot;&gt;&lt;code&gt;// Sentry: 에러 중심
&quot;앱이 터졌어요!&quot; &amp;rarr; 에러 추적, 스택트레이스

// OpenTelemetry: 성능 중심
&quot;앱이 느려요!&quot; &amp;rarr; 병목 구간 찾기, 성능 최적화

// 함께 사용하면 최고! 
Sentry + OpenTelemetry = 완벽한 모니터링&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Vercel의 OpenTelemetry 통합&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vercel이 &lt;code&gt;@vercel/otel&lt;/code&gt; 패키지로 Next.js 전용 OpenTelemetry 설정을 쉽게 해줌&lt;br /&gt;Vercel이 이렇게 간편한 통합을 제공하는 이유는 &lt;b&gt;서버리스 환경의 복잡성&lt;/b&gt;을 숨기고, 개발자가 비즈니스 로직에 집중할 수 있게 하기 위해서이다.&lt;br /&gt;특히 Edge Functions, ISR, 동적 렌더링 등 Next.js의 다양한 기능을 모두 추적할 수 있는 것이 장점!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  @vercel/otel 패키지&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;기본 설정 (공식 문서 예시)&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// instrumentation.ts
import { registerOTel } from '@vercel/otel';

export function register() {
  registerOTel({ 
    serviceName: 'my-nextjs-app' 
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 한 줄이면 자동으로:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ Next.js 라우트 추적&lt;/li&gt;
&lt;li&gt;✅ fetch 요청 추적&lt;/li&gt;
&lt;li&gt;✅ 데이터베이스 쿼리 추적&lt;/li&gt;
&lt;li&gt;✅ 서버/클라이언트 컴포넌트 구분&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실제 프로덕션 설정&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// instrumentation.ts
import { registerOTel } from '@vercel/otel';

export function register() {
  registerOTel({
    serviceName: 'ecommerce-frontend',

    // 1. 어디로 데이터를 보낼까?
    traceExporter: process.env.VERCEL_ENV === 'production'
      ? 'auto' // Vercel 자동 감지 (Datadog, New Relic 등)
      : 'console', // 개발 환경: 콘솔 출력

    // 2. 얼마나 추적할까? (비용 관리!)
    tracesSampleRate: process.env.VERCEL_ENV === 'production'
      ? 0.1  // 프로덕션: 10%만 (비용 절감)
      : 1.0, // 개발/프리뷰: 100%

    // 3. 추가 정보 포함
    resourceAttributes: {
      'environment': process.env.VERCEL_ENV || 'development',
      'region': process.env.VERCEL_REGION || 'unknown',
      'deployment.id': process.env.VERCEL_DEPLOYMENT_ID,
    },
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Vercel 플랫폼 특화 기능&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. &lt;b&gt;자동 환경 감지&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;registerOTel({
  serviceName: 'my-app',
  // Vercel이 자동으로 감지!
  // - Development: 콘솔 출력
  // - Preview: Vercel 대시보드
  // - Production: 연결된 APM 서비스
});&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. &lt;b&gt;Edge Runtime 지원&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export function register() {
  // Edge와 Node.js 모두 지원!
  if (process.env.NEXT_RUNTIME === 'edge') {
    registerOTel({
      serviceName: 'edge-api',
      // Edge Runtime에서도 작동!
    });
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실전 활용 예시&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. &lt;b&gt;App Router 성능 추적&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/products/[id]/page.tsx
import { trace } from '@opentelemetry/api';

const tracer = trace.getTracer('product-page');

export default async function ProductPage({ params }) {
  return tracer.startActiveSpan('render-product-page', async (span) =&amp;gt; {
    try {
      // 자동으로 추적되는 것들:
      const product = await fetch(`/api/products/${params.id}`); // &amp;larr; 자동 추적!

      // 커스텀 속성 추가
      span.setAttribute('product.id', params.id);
      span.setAttribute('product.category', product.category);

      return &amp;lt;ProductView product={product} /&amp;gt;;

    } finally {
      span.end();
    }
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. &lt;b&gt;API Route 모니터링&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/api/checkout/route.ts
export async function POST(request: Request) {
  // @vercel/otel이 자동으로:
  // - 요청 시작/종료 시간 기록
  // - HTTP 상태 코드 추적
  // - 에러 자동 캡처

  const data = await request.json();

  // 커스텀 이벤트 추가
  const span = trace.getActiveSpan();
  span?.addEvent('checkout-started', {
    userId: data.userId,
    cartValue: data.total,
  });

  // DB, 외부 API 호출도 자동 추적됨
  const result = await processCheckout(data);

  return NextResponse.json(result);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Vercel 대시보드 통합&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// Vercel 프로젝트 설정에서 연결 가능한 서비스들:

registerOTel({
  serviceName: 'my-app',
  // VERCEL_OBSERVABILITY_PROVIDER 환경 변수로 자동 설정
});

// 지원하는 Providers:
// - Datadog (자동 연동!)
// - New Relic
// - Axiom
// - Honeycomb
// - Grafana Cloud&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  고급 설정: 멀티 서비스 추적&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// instrumentation.ts - 마이크로서비스 아키텍처
import { registerOTel } from '@vercel/otel';
import { W3CTraceContextPropagator } from '@opentelemetry/core';

export function register() {
  registerOTel({
    serviceName: 'frontend-gateway',

    // 다른 서비스로 전파
    propagators: [new W3CTraceContextPropagator()],

    // 서비스 간 추적을 위한 헤더
    instrumentationConfig: {
      fetch: {
        propagateTraceHeaderCorsUrls: [
          'https://api.myapp.com/*',
          'https://auth.myapp.com/*',
        ],
      },
    },
  });
}

// API 호출 시 자동으로 trace 전파
const response = await fetch('https://api.myapp.com/orders', {
  // traceparent 헤더가 자동 추가됨!
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실제 Trace 시각화 예시&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[Vercel Dashboard / Datadog APM에서 보이는 모습]

GET /products/123 ────────────────────── 312ms
 ├─ getServerSideProps ──────────────── 287ms
 │   ├─ fetch: GET /api/products/123 ── 145ms
 │   │   └─ Prisma: findUnique ──────── 89ms
 │   ├─ fetch: GET /api/reviews ─────── 98ms
 │   └─ generateMetadata ────────────── 44ms
 └─ React SSR ───────────────────────── 25ms&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  비용 최적화 팁&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;registerOTel({
  serviceName: 'my-app',

  // 1. 스마트 샘플링
  tracesSampler: (samplingContext) =&amp;gt; {
    // 에러는 항상 추적
    if (samplingContext.attributes?.['http.status_code'] &amp;gt;= 500) {
      return 1.0;
    }
    // 느린 요청 추적
    if (samplingContext.attributes?.['http.duration'] &amp;gt; 1000) {
      return 0.5;
    }
    // 나머지는 1%만
    return 0.01;
  },

  // 2. 불필요한 span 제외
  instrumentationConfig: {
    '@opentelemetry/instrumentation-fs': {
      enabled: false, // 파일 시스템 제외
    },
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Vercel + OpenTelemetry 베스트 프랙티스&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// instrumentation.ts - 완벽한 설정
import { registerOTel } from '@vercel/otel';

export function register() {
  // 개발/프로덕션 자동 구분
  const isDev = process.env.NODE_ENV === 'development';

  registerOTel({
    serviceName: `${process.env.VERCEL_PROJECT_NAME || 'nextjs-app'}`,

    // 환경별 설정
    traceExporter: isDev ? 'console' : 'auto',
    metricsExporter: isDev ? 'console' : 'auto',

    // 샘플링 전략
    tracesSampleRate: isDev ? 1.0 : 
      parseFloat(process.env.OTEL_SAMPLE_RATE || '0.1'),

    // Vercel 메타데이터 자동 포함
    resourceAttributes: {
      'vercel.env': process.env.VERCEL_ENV,
      'vercel.region': process.env.VERCEL_REGION,
      'vercel.git.commit': process.env.VERCEL_GIT_COMMIT_SHA,
      'vercel.git.branch': process.env.VERCEL_GIT_COMMIT_REF,
    },
  });

  console.log('  OpenTelemetry 초기화 완료');
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <category>nextjs</category>
      <category>opentelemetry</category>
      <category>vercel-otel</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/321</guid>
      <comments>https://ifelseif.tistory.com/321#entry321comment</comments>
      <pubDate>Sun, 23 Nov 2025 19:16:35 +0900</pubDate>
    </item>
    <item>
      <title>[251123 TIL] Next.js instrumentation.ts 정리</title>
      <link>https://ifelseif.tistory.com/320</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js Instrumentation 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Instrumentation이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Instrumentation&lt;/b&gt;은 Next.js 13.2부터 도입된 기능으로, 앱이 &lt;b&gt;시작될 때 딱 한 번&lt;/b&gt; 실행되는 초기화 코드를 위한 특별한 파일입니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/instrumentation.ts 또는 src/instrumentation.ts
export async function register() {
  // 서버가 부팅될 때 실행되는 코드
  console.log('Next.js 앱이 시작됩니다!');
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  주요 특징&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;실행 시점&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버가 처음 시작될 때&lt;/li&gt;
&lt;li&gt;Cold start 시 (서버리스 환경)&lt;/li&gt;
&lt;li&gt;모든 환경(Node.js, Edge, Client)에서 각각 한 번씩&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실행 환경 구분&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1763892268658&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// instrumentation.ts - 서버용
// instrumentation-client.ts - 브라우저용 (별도 파일!)
export async function register() {
      // 서버 사이드 (Node.js)
      if (process.env.NEXT_RUNTIME === 'nodejs') {
        console.log('Node.js 서버 시작!');
      }
      
      // Edge Runtime (Middleware, Edge API)
      if (process.env.NEXT_RUNTIME === 'edge') {
        console.log('Edge Runtime 시작!');
      }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  활용 사례&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. &lt;b&gt;모니터링 도구 초기화&lt;/b&gt; (가장 일반적)&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// instrumentation.ts
import * as Sentry from '@sentry/nextjs';

export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    Sentry.init({
      dsn: process.env.SENTRY_DSN,
      // Node.js 특화 설정
    });
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. &lt;b&gt;데이터베이스 연결 풀 설정&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { PrismaClient } from '@prisma/client';

let prisma: PrismaClient;

export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    // 글로벌 Prisma 인스턴스 생성
    globalThis.prisma = new PrismaClient();
    await globalThis.prisma.$connect();
    console.log('DB 연결 완료');
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. &lt;b&gt;OpenTelemetry 설정&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { NodeSDK } from '@opentelemetry/sdk-node';

export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const sdk = new NodeSDK({
      serviceName: 'my-nextjs-app',
      // 트레이싱 설정
    });

    sdk.start();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. &lt;b&gt;환경 변수 검증&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export async function register() {
  const requiredEnvVars = [
    'DATABASE_URL',
    'API_SECRET_KEY',
    'REDIS_URL'
  ];

  for (const envVar of requiredEnvVars) {
    if (!process.env[envVar]) {
      throw new Error(`필수 환경 변수 누락: ${envVar}`);
    }
  }

  console.log('✅ 환경 변수 검증 완료');
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚙️ 설정 방법&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;next.config.js에서 활성화&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// next.config.js
module.exports = {
  experimental: {
    instrumentationHook: true, // 활성화!
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;2&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;파일 생성 위치&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;프로젝트/
├── app/
│   └── instrumentation.ts        // App Router
├── src/
│   ├── instrumentation.ts        // 서버/Edge
│   └── instrumentation-client.ts // 클라이언트
└── instrumentation.ts            // Pages Router&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  일반 초기화와의 차이&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;❌ 기존 방법의 문제점&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/layout.tsx
import { initSentry } from './sentry';

// 문제: 모든 요청마다 실행됨!
initSentry();

export default function RootLayout() {
  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;✅ Instrumentation 장점&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// instrumentation.ts
export async function register() {
  // 서버 생명주기당 한 번만 실행!
  await initSentry();
  await connectDatabase();
  await warmupCache();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실행 흐름 다이어그램&lt;/h3&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;서버 시작
    &amp;darr;
instrumentation.ts register() 실행
    &amp;darr;
런타임 체크 (nodejs/edge)
    &amp;darr;
해당 환경 초기화 코드 실행
    &amp;darr;
서버 준비 완료
    &amp;darr;
요청 처리 시작

브라우저 로드
    &amp;darr;
instrumentation-client.ts 실행
    &amp;darr;
클라이언트 초기화&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실전 팁&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;무거운 작업은 비동기로&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export async function register() {
  // 병렬 처리로 부팅 시간 단축
  await Promise.all([
    initDatabase(),
    initCache(),
    initMonitoring(),
  ]);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;2&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;에러 처리 필수&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;export async function register() {
  try {
    await riskyInitialization();
  } catch (error) {
    console.error('초기화 실패:', error);
    // 앱이 시작되지 않도록 할 수도 있음
    process.exit(1);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;3&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;개발/프로덕션 분기&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export async function register() {
  if (process.env.NODE_ENV === 'development') {
    // 개발 전용 도구
    const { setupDevTools } = await import('./dev-tools');
    setupDevTools();
  }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <category>instrumentation</category>
      <category>instrumentationjs</category>
      <category>nextjs</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/320</guid>
      <comments>https://ifelseif.tistory.com/320#entry320comment</comments>
      <pubDate>Sun, 23 Nov 2025 19:03:43 +0900</pubDate>
    </item>
    <item>
      <title>[251102 TIL] PostgREST vs GraphQL(Hasura)기술 스택 비교</title>
      <link>https://ifelseif.tistory.com/319</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;PostgREST vs GraphQL(Hasura): 현실적인 기술 스택 비교&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostgreSQL을 사용하는 프로젝트에서 API 레이어를 구성할 때, 두 가지 옵션을 비교해봤습니다.&lt;br /&gt;이 비교는 GraphQL이 정말 필요한 상황은 언제인가? 에 대해서 생각해보다가 시작하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GraphQL + Hasura + codegen 의 강력함은 프로젝트를 해보면서 알게 되었는데&lt;br /&gt;다만 PostgREST 에서도 유연한 쿼리, 관계 기반 페칭이 잘 되므로(supabase.js 에서 보듯...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요구사항만 맞는다면 Hasura 혹은 resolver 구성에 드는 비용을 줄이면서도&lt;br /&gt;GraphQL의 이점을 유사하게 구사할 수 있을 것 같았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 GraphQL + Hasura 에 대항할 수 있는 REST 스펙을 아래와 같이 고민해 보았습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스택 구성 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PostgREST 스택&lt;/h3&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 기본 구성
PostgreSQL &amp;rarr; PostgREST &amp;rarr; postgrest-js &amp;rarr; openapi-typescript/zod&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GraphQL + Hasura 스택&lt;/h3&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 기본 구성
PostgreSQL &amp;rarr; Hasura &amp;rarr; GraphQL &amp;rarr; graphql-codegen&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 기능 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;쿼리 작성 방식&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PostgREST&lt;/h4&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// REST 기반 체이닝
const { data } = await db
  .from('posts')
  .select(`
    *,
    author:users(name, email),
    comments(count)
  `)
  .eq('published', true)
  .order('created_at', { ascending: false })
  .limit(10)&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hasura (GraphQL)&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;query GetPosts {
  posts(
    where: { published: { _eq: true } }
    order_by: { created_at: desc }
    limit: 10
  ) {
    id
    title
    author {
      name
      email
    }
    comments_aggregate {
      aggregate {
        count
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;타입 안전성 구현&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PostgREST + OpenAPI&lt;/h4&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;# OpenAPI 스펙에서 타입 생성
npx openapi-typescript http://localhost:3000/ \
  --output ./types/database.ts&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;// 생성된 타입 활용
import { paths, components } from './types/database'

type User = components['schemas']['users']
type Post = components['schemas']['posts']

// Zod 스키마로 런타임 검증 추가
import { z } from 'openapi-zod'
const UserSchema = z.schema(components['schemas']['users'])&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hasura + GraphQL Codegen&lt;/h4&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;# codegen.yml
generates:
  ./src/generated/graphql.tsx:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-query&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 자동 생성된 훅 사용
import { useGetPostsQuery } from '@/generated/graphql'

const { data, loading, error } = useGetPostsQuery({
  variables: { limit: 10 }
})&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 사용 시나리오별 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오 1: 단순 CRUD 작업&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PostgREST &amp;gt;&amp;gt; 더 간단&lt;/h4&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 한 줄로 처리
await db.from('users').insert({ name: 'John', email: 'john@example.com' })&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hasura&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// mutation 정의 필요
const INSERT_USER = gql`
  mutation InsertUser($name: String!, $email: String!) {
    insert_users_one(object: { name: $name, email: $email }) {
      id
    }
  }
`
await client.mutate({ mutation: INSERT_USER, variables: { ... } })&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오 2: 복잡한 관계 데이터 조회&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PostgREST&lt;/h4&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 깊은 중첩은 복잡해짐
const { data } = await db
  .from('organizations')
  .select(`
    *,
    departments!inner(
      *,
      employees(
        *,
        manager:employees!manager_id(name),
        projects(*)
      )
    )
  `)&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hasura &amp;gt;&amp;gt; 더 직관적이고 편리!&lt;/h4&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;query GetOrgStructure {
  organizations {
    name
    departments {
      name
      employees {
        name
        manager {
          name
        }
        projects {
          title
          status
        }
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오 3: 실시간 구독&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PostgREST &amp;gt;&amp;gt; 추가 구성 없이는 불가능&lt;/h4&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// 별도 Realtime 서버 필요
import { RealtimeClient } from '@supabase/realtime-js'

const client = new RealtimeClient('ws://localhost:4000/socket')
const channel = client.channel('db-changes')
  .on('postgres_changes', 
    { event: 'INSERT', schema: 'public', table: 'messages' },
    (payload) =&amp;gt; console.log(payload.new)
  )
  .subscribe()&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hasura &amp;gt;&amp;gt; 내장 지원&lt;/h4&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;const MESSAGE_SUBSCRIPTION = gql`
  subscription OnMessageAdded {
    messages(order_by: { created_at: desc }, limit: 1) {
      id
      content
      user {
        name
      }
    }
  }
`
// 바로 사용 가능&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⭐️ 각 스택이 빛나는 순간은?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PostgREST가 최적인 경우&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;단일 PostgreSQL DB 중심 서비스&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 모든 데이터가 하나의 DB에 있을 때
const dashboard = await db
  .from('analytics')
  .select('*')
  .gte('date', '2024-01-01')&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;2&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;빠른 프로토타이핑&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 별도 스키마 정의 없이 바로 시작
// DB 테이블 = API 엔드포인트&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;3&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;RESTful API 선호 환경&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;// 기존 REST 클라이언트와 호환
fetch('/api/users?age=gte.18&amp;amp;select=name,email')&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;4&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;서버리스/엣지 환경&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Vercel Edge Function
export const runtime = 'edge'

export async function GET() {
  // PostgREST는 HTTP 요청만으로 작동
  const res = await fetch(process.env.POSTGREST_URL + '/users')
  return Response.json(await res.json())
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Hasura + GraphQL이 최적인 경우&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;다중 데이터소스 통합&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# Hasura metadata
remote_schemas:
  - name: payment_service
    definition:
      url: https://payment-api.com/graphql
  - name: auth_service
    definition:
      url: https://auth-api.com/graphql&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;2&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;복잡한 권한 관리&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;permission&quot;: {
    &quot;role&quot;: &quot;user&quot;,
    &quot;select&quot;: {
      &quot;filter&quot;: {
        &quot;_or&quot;: [
          { &quot;owner_id&quot;: { &quot;_eq&quot;: &quot;X-Hasura-User-Id&quot; } },
          { &quot;visibility&quot;: { &quot;_eq&quot;: &quot;public&quot; } }
        ]
      },
      &quot;columns&quot;: [&quot;id&quot;, &quot;title&quot;, &quot;content&quot;],
      &quot;computed_fields&quot;: [&quot;likes_count&quot;]
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;3&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;MSA 환경의 API Gateway&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;# 여러 서비스를 하나의 GraphQL로 통합
type Query {
  # PostgreSQL (Hasura)
  users: [User!]!

  # Redis (Remote Schema)
  activeUsers: [ActiveUser!]!

  # Elasticsearch (Action)
  searchPosts(query: String!): [Post!]!
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;4&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;모바일 앱 최적화&lt;/b&gt; &amp;gt;&amp;gt; 클라이언트마다, 상황마다 매번 이것저것 다른 필드 요청이 빈번할 때&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;# 필요한 필드만 정확히 요청
query MobileOptimized {
  posts {
    id
    title
    thumbnailUrl  # 큰 이미지 제외
    # content 제외 - 필요시만 추가 요청
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;성능 &amp;amp; 운영 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인프라 복잡도&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PostgREST&lt;/h4&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# docker-compose.yml
services:
  postgres:
    image: postgres:17
  postgrest:
    image: postgrest/postgrest
    depends_on:
      - postgres
# 끝! 매우 단순&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hasura&lt;/h4&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;services:
  postgres:
    image: postgres:17
  hasura:
    image: hasura/graphql-engine
    depends_on:
      - postgres
# 메타데이터 관리, 마이그레이션 등 추가 고려사항 있음&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;번들 사이즈&lt;/h3&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// PostgREST 클라이언트
import { PostgrestClient } from '@supabase/postgrest-js' // ~15kb

// GraphQL 클라이언트
import { ApolloClient, InMemoryCache } from '@apollo/client' // ~130kb
// 또는
import { createClient } from 'urql' // ~45kb&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;러닝 커브&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PostgREST&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;REST API 지식만 필요&lt;/li&gt;
&lt;li&gt;PostgreSQL 함수/뷰 활용하면 확장 가능&lt;/li&gt;
&lt;li&gt;팀원 온보딩 빠름&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hasura&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GraphQL 개념 이해 필요&lt;/li&gt;
&lt;li&gt;Hasura 특유의 설정 학습 필요...&lt;/li&gt;
&lt;li&gt;강력한 기능이지만 초기 러닝커브 존재&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PostgREST 선택 !!&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  B2B SaaS, 관리자 대시보드&lt;/li&gt;
&lt;li&gt;  서버 사이드 렌더링 중심 웹앱&lt;/li&gt;
&lt;li&gt;  MVP, 빠른 프로토타입&lt;/li&gt;
&lt;li&gt;  단일 PostgreSQL DB 서비스&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Hasura 선택 !!&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  모바일 앱 백엔드&lt;/li&gt;
&lt;li&gt;  MSA 환경의 통합 레이어&lt;/li&gt;
&lt;li&gt;  실시간 협업 기능 중심 서비스&lt;/li&gt;
&lt;li&gt; ️ 여러 데이터소스 조합 필요&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>graphQL</category>
      <category>hasura</category>
      <category>openapi</category>
      <category>postgresql</category>
      <category>postgrest</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/319</guid>
      <comments>https://ifelseif.tistory.com/319#entry319comment</comments>
      <pubDate>Sun, 2 Nov 2025 16:26:31 +0900</pubDate>
    </item>
    <item>
      <title>[251031 TIL] naver 로그인 구현 with Supabase 3편</title>
      <link>https://ifelseif.tistory.com/318</link>
      <description>&lt;h1&gt;naver 로그인 구현 with Supabase 3편&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TL; DR: admin 권한 쓰면 끝&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작년 7월에 supabase에서 편법(?) naver 로그인 기능을 구현했었습니다.&lt;br /&gt;1년 넘게 지났는데 갑자기 편법 말고 진짜로 구현이 되겠잖아? 생각이 들었어요.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;supabase 어드민 권한을 쓰면 auth 스키마에도 입력이 될 것 같았고&lt;br /&gt;해봤더니 역시 아주 잘 되었습니다... 이 생각을 왜 작년엔 못했을까요?&lt;br /&gt;&amp;nbsp;&lt;br /&gt;한가지 문제는 네이버 OAuth 를 프로덕션으로 쓰려면&lt;br /&gt;네이버 개발자센터 &amp;gt; 내 애플리케이션 &amp;gt; 네이버 로그인 검수 상태&lt;br /&gt;에서 검수가 완료되어야 합니다. &lt;a href=&quot;https://developers.naver.com/docs/login/verify/verify.md&quot; target=&quot;_self&quot;&gt;&lt;span&gt;검수가이드&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;보면 대단한 걸 요구하는건 아닌데 귀찮습니다.&lt;br /&gt;카카오는 검수 없이도 기본적인 정보들은 그냥 받을 수 있는데&lt;br /&gt;네이버는 아무튼간 안되는 것 같습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;결론은,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase Auth 와 함께 사용하기 &amp;gt; 문제 없음&lt;br /&gt;프로덕션에서 사용 &amp;gt; 문제 있음. 네이버 검수 통과 필요&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. naver-login-button.tsx&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이버 로그인 버튼 컴포넌트 입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;global.d.ts 등에 naver 설정&lt;/li&gt;
&lt;li&gt;네이버 스크립트를 그냥 버튼 컴포넌트에 next/script 로 포함시키고 버튼 보일때 로딩&lt;/li&gt;
&lt;li&gt;스크립트 onLoad 시 네이버 로그인 버튼 초기화(hidden 으로 숨기고 ref 달기)&lt;/li&gt;
&lt;li&gt;버튼 커스텀 하고, 이 버튼 클릭시 숨겨놓은 네이버 로그인 버튼 클릭시키기&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import Script from &quot;next/script&quot;;
import { useCallback, useRef, useState } from &quot;react&quot;;
import { SiNaver } from &quot;react-icons/si&quot;;
import { PUBLIC_URL } from &quot;@/constants/common.constants&quot;; 

function NaverLogInButton() {
	// 귀찮으니까 any ㅋㅋ
    const [naverObj, setNaverObj] = useState&amp;lt;any&amp;gt;(null);
    const naverRef = useRef&amp;lt;HTMLButtonElement&amp;gt;(null);

    const handleNaverInit = useCallback(() =&amp;gt; {
        const naver = window.naver;
        setNaverObj(naver);

        const naverLogin = new naver.LoginWithNaverId({
            clientId: process.env.NEXT_PUBLIC_NAVER_CLIENT_ID, //ClientID
            callbackUrl: `${PUBLIC_URL}/loading`, // Callback URL
            callbackHandle: true,
            isPopup: false, // 팝업 형태로 인증 여부
            loginButton: {
            color: &quot;green&quot;, // 색상
            type: 1, // 버튼 크기
            height: &quot;60&quot;, // 버튼 높이
            }, // 로그인 버튼 설정
        });
        naverLogin.init();
    }, []);

    const handleNaverLoginClick = () =&amp;gt; {
        if (!naverRef.current?.children[0].children) return;
        (naverRef.current.children[0].children[0] as HTMLImageElement).click();
    };

    return (
        &amp;lt;&amp;gt;
            &amp;lt;Script
                src=&quot;https://static.nid.naver.com/js/naveridlogin_js_sdk_2.0.2.js&quot;
                onLoad={handleNaverInit}
            /&amp;gt;
            &amp;lt;button
                type=&quot;button&quot;
                ref={naverRef}
                id=&quot;naverIdLogin&quot;
                className=&quot;hidden&quot;
            /&amp;gt;
            {!naverObj ? (
                &amp;lt;SiNaver className=&quot;h-10 w-10 text-green-500&quot; /&amp;gt;
            ) : (
                &amp;lt;SiNaver 
                    className=&quot;h-10 w-10 cursor-pointer text-green-500&quot;
                    onClick={handleNaverLoginClick}
                /&amp;gt;
            )}
        &amp;lt;/&amp;gt;
    );
};

export default NaverLogInButton;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. /loading/page.tsx (이 방법 별로인듯...)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작년에 이런식으로 해놨길래 귀찮아서 그냥 이어서 했습니다.&lt;br /&gt;근데 별로 좋은 방법은 아니에요.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;왜 이렇게 했었는지 모르겠지만 ㅋㅋ&lt;br /&gt;현재 상황은 다음과 같은데요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;코드가 해시로 &amp;gt; 네이버 SDK Implicit Flow를 사용(위에서 그렇게 하고 있음.. Script 부분)&lt;/li&gt;
&lt;li&gt;즉 Route Handler로 직접 처리 불가 &amp;gt; 해시는 서버로 전송되지 않기 때문&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;안전하게 하고 싶으면 네이버 OAuth의 Authorization Code Flow를 사용하는게 좋습니다. &lt;a href=&quot;https://developers.naver.com/docs/login/devguide/devguide.md&quot; target=&quot;_self&quot;&gt;&lt;span&gt;참고&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;할게 좀 더 많긴한데 &lt;a href=&quot;https://nid.naver.com/oauth2.0/token&quot; target=&quot;_self&quot;&gt;&lt;span&gt;https://nid.naver.com/oauth2.0/token&lt;/span&gt;&lt;/a&gt;&lt;br /&gt;여기로 요청해서 PKCE 도 적용하는게 좋습니다.. (OAuth2 구현기는 나중에...)&lt;br /&gt;&amp;nbsp;&lt;br /&gt;아무튼, 지금 상태로는 해시로 오기 때문에 이렇게 할 수 밖에 없습니다.&lt;br /&gt;네이버 개발자센터에서 callback URL 을 /loading 으로 설정해두면&lt;br /&gt;네이버 로그인 버튼 클릭시 여기로 오게 됩니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;여기서 하는 일은&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;화면에 로더를 표시하면서&lt;/li&gt;
&lt;li&gt;해쉬로 오는 네이버 토큰을 API route 로 전달&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 끝입니다&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useEffect } from &quot;react&quot;;
import DefaultLoader from &quot;@/components/atoms/common/DefaultLoader&quot;;
import { useAuth } from &quot;@/hooks&quot;;

function LoadingPage() {
    const { naverLogIn } = useAuth();

    useEffect(() =&amp;gt; {
        if (window.location.hash) {
            const hash = window.location.hash.substring(1); // 해시로 오고
            const params = new URLSearchParams(hash);
            const token = params.get(&quot;access_token&quot;); // 정직한 이름...
            // 이건 왜 이렇게 했는지...
            // 아무튼 naverLogIn 이 하는 일은
            // /api/auth/callback/naver 여기로
            // 헤더에 토큰 실어서 보내는 겁니다..
            if (token) naverLogIn(token);
            // token 없을 때 처리도 당연히 해줘야 합니다... 
        }
    }, [naverLogIn]);

    return &amp;lt;DefaultLoader /&amp;gt;;
};

export default LoadingPage;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. supabase-admin-client.ts&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 핵심입니다.&lt;br /&gt;어드민용 클라이언트가 하나 필요해요.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;어드민용 클라이언트를 위해선&lt;br /&gt;service_role_key 가 필요한데&lt;br /&gt;이건 수파베이스 대시보드에서 받을 수 있습니다.&lt;br /&gt;(최근 이름이 그냥 SECRET_KEY 로 바뀐듯... 저는 그거 썼습니다)&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { createClient } from &quot;@supabase/supabase-js&quot;;

/**
* 서버 사이드에서 어드민 권한으로 Supabase 클라이언트를 생성합니다.
* auth.admin API를 사용하기 위해 service_role 키가 필요합니다.
* ⚠️ 주의: 이 클라이언트는 서버 사이드에서만 사용해야 하며, 절대 클라이언트에 노출되면 안 됩니다.
*/
export function createAdminClient() {
    const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
    // 시크릿키 써도 됨...
    const supabaseServiceRoleKey = process.env.SUPABASE_SECRET_KEY; 

    if (!supabaseUrl || !supabaseServiceRoleKey) {
        throw new Error(
            &quot;Missing Supabase environment variables&quot;,
        );
    }

    return createClient(supabaseUrl, supabaseServiceRoleKey, {
        auth: {
            autoRefreshToken: false,
            persistSession: false,
        },
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. api/callback/naver/route.ts&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블명이 public.buddies 인데&lt;br /&gt;그냥 users 로 하지 뭐 이렇게 했나 싶지만?&lt;br /&gt;&lt;br /&gt;1년 전의 나니까... 그냥 넘어갑니다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;getBuddy&lt;/code&gt; 이런건 그냥 public.users 에서&lt;br /&gt;user 가져오는 거라고 봐주시면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;일단 필요한 함수들 부터&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;getBuddy&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;/**
* buddy_id로 buddies 테이블에서 사용자 정보를 가져옵니다.
*/
async function getBuddy(
    supabase: SupabaseClient,
    id: string,
): Promise&amp;lt;Buddy | null&amp;gt; {
    const { data: buddy, error } = await supabase
        .from(&quot;buddies&quot;)
        .select(&quot;*&quot;)
        .eq(&quot;buddy_id&quot;, id)
        .single();

    if (error) {
        console.error(&quot;Error fetching buddy by id:&quot;, error);
        return null;
    }

    return buddy;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;getBuddyByEmail&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;/**
* 이메일로 buddies 테이블에서 사용자 정보를 가져옵니다.
*/
async function getBuddyByEmail(
    supabase: SupabaseClient,
    email: string,
): Promise&amp;lt;Buddy | null&amp;gt; {
    const { data: buddy, error } = await supabase
        .from(&quot;buddies&quot;)
        .select(&quot;*&quot;)
        .eq(&quot;buddy_email&quot;, email)
        .single();

    if (error) {
        return null;
    }

    return buddy;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;findUserByEmail&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;/**
* 이메일로 auth.users에서 사용자를 찾습니다.
*/
async function findUserByEmail(supabaseAdmin: SupabaseClient, email: string) {
    const { data: users } = await supabaseAdmin.auth.admin.listUsers();
    return users?.users.find((user) =&amp;gt; user.email === email) ?? null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;getRedirectUrl&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;/**
* 리다이렉트 URL을 생성합니다.
*/
function getRedirectUrl(
    origin: string,
    forwardedHost: string | null,
    isLocalEnv: boolean,
    path: string,
): string {
    if (isLocalEnv) {
        return `${origin}${path}`;
    }
    if (forwardedHost) {
        return `https://${forwardedHost}${path}`;
    }
    return `${origin}${path}`;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;signInUser&lt;/h3&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;/**
* 사용자 로그인 처리를 수행합니다 (세션 생성).
* 로그인도 직접 시켜줘야 함...
*/
async function signInUser(
    email: string,
    password: string,
): Promise&amp;lt;{ success: boolean; error?: string }&amp;gt; {
    try {
        const supabase = await createClient();
        const { error } = await supabase.auth.signInWithPassword({
            email,
            password,
        });

        if (error) {
            console.error(&quot;Error signing in user:&quot;, error);
            return { success: false, error: error.message };
        }

        return { success: true };
    } catch (error) {
        console.error(&quot;Error during sign in:&quot;, error);
        return {
            success: false,
            error: error instanceof Error ? error.message : &quot;Unknown error&quot;,
        };
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;isNewUser&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const ONE_HOUR_MS = 60 * 60 * 1000;
/**
* 최초 로그인 여부를 확인합니다 (1시간 이내 생성된 사용자).
* 입맛대로...
*/
function isNewUser(createdAt: string): boolean {
    return new Date(createdAt).getTime() &amp;gt; Date.now() - ONE_HOUR_MS;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;handleExistingUser&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;/**
* 기존 사용자 처리를 수행합니다.
* 필요한 경우 여기서 auth.users 대신
* public의 커스텀 유저 테이블을 사용할 수도 있어요...
* 파라미터도 입맛대로...
*/

async function handleExistingUser(
    buddy: Buddy,
    userEmail: string,
    password: string,
    origin: string,
    forwardedHost: string | null,
    isLocalEnv: boolean,
    next: string,
): Promise&amp;lt;NextResponse&amp;gt; {
    // 기존 사용자도 로그인 처리 필요
    const signInResult = await signInUser(userEmail, password);

    if (!signInResult.success) {
        console.error(&quot;Failed to sign in existing user:&quot;, signInResult.error);
        return NextResponse.json(
            { error: &quot;Failed to sign in user&quot; },
            { status: 500 },
        );
    }

    const newUser = isNewUser(buddy.buddy_created_at);

    // 최초 로그인이면 온보딩으로 리다이렉트
    if (newUser) {
        const redirectUrl = `${origin}/onboarding?funnel=0&amp;amp;mode=first`;
        return NextResponse.json({ redirectUrl, buddy }, { status: 200 });
    }

    // 기존 사용자는 x-forwarded-host가 있으면 그것을 사용하고, 
    // 없으면 origin을 사용하여 리다이렉트인데...
    // NextResponse.redirect 안하는 이유는
    // 그냥 제 프로젝트가 이상하게 되어 있어서 그렇습니다...
    // 리턴은 입맛대로 수정하면 됩니다...
    const redirectUrl = getRedirectUrl(origin, forwardedHost, isLocalEnv, next);
    return NextResponse.json({ redirectUrl, buddy }, { status: 200 });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;다 되었으면 마지막 route handler 작성!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;POST (GET 이 맞을듯&amp;hellip;)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 이렇게 하면 되는데,&lt;br /&gt;참말로 보기 불편하니 리팩토링 해야 합니다...&lt;br /&gt;&amp;nbsp;&lt;br /&gt;그리고 리다이렉트 시킬거면 전부 리다이렉트로 처리해야 하고&lt;br /&gt;아니면 그냥 아래처럼 해도 될지도?&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;b&gt;!전부 어드민 클라이언트 사용!&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;순서는&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;네이버 API를 사용하여 사용자 정보 가져오기&lt;/li&gt;
&lt;li&gt;기존 사용자 있는지 체크 -&amp;gt; auth.user, public.buddies 두 개라 헷갈리지만 잘.. 처리...&lt;/li&gt;
&lt;li&gt;없으면 createUser&lt;/li&gt;
&lt;li&gt;3 까지 잘 되었으면 signInWithPassword, 위에서 만든 signInUser 사용&lt;/li&gt;
&lt;li&gt;리다이렉트 하든지, 결과 리턴하든지..&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;끝!&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;export async function POST(request: NextRequest) {
    const { searchParams, origin } = new URL(request.url);
    // 배포 환경이 vercel 이든 뭐든...
    // 로드밸런서일 확률 높으니 그냥 이걸로 하면 됨...
    const forwardedHost = request.headers.get(&quot;x-forwarded-host&quot;); 
    const isLocalEnv = process.env.NODE_ENV === &quot;development&quot;;
    const next = searchParams.get(&quot;next&quot;) ?? &quot;/&quot;;
    const headersList = await headers();
    const accessToken = headersList.get(&quot;Authorization&quot;)?.split(&quot; &quot;)[1];

    if (!FIXED_PASSWORD) {
        return NextResponse.json(
            { error: &quot;NAVER_PROVIDER_LOGIN_SECRET is not set&quot; },
            { status: 400 },
        );
    }

    if (!accessToken) {
        return NextResponse.json(
            { error: &quot;Access token not found&quot; },
            { status: 400 },
        );
    }

    try {
        // 네이버 API를 사용하여 사용자 정보 가져오기
        const response = await fetch(&quot;https://openapi.naver.com/v1/nid/me&quot;, {
            headers: {
                Authorization: `Bearer ${accessToken}`,
            },
        });

        if (!response.ok) {
            return NextResponse.json(
                { error: &quot;Failed to fetch user info from Naver&quot; },
                { status: 400 },
            );
        }

        const userData = await response.json();
        const userEmail: string = userData.response?.email;

        if (!userEmail) {
            return NextResponse.json(
                { error: &quot;Email not found in Naver user data&quot; },
                { status: 400 },
            );
        }

        // ⭐️ 여기서 위에서 만든 어드민클라이언트 사용! ⭐️
        const supabaseAdmin = createAdminClient();

        // 먼저 buddies 테이블에서 이메일로 기존 사용자 확인
        // buddies 테이블의 buddy_email unique constraint 위반을 방지하기 위함
        const existingBuddy = await getBuddyByEmail(supabaseAdmin, userEmail);

        // 기존 사용자가 있는 경우
        if (existingBuddy) {
            const existingUser = await findUserByEmail(supabaseAdmin, userEmail);

            // 커스텀 테이블에는 있는데, auth.users에서 찾을 수 없는 경우 - 그러니까 망한 상황임
            // 이런 상황이 발생하면 커스텀 테이블과 auth.users 간의 데이터 일관성이 깨진 것임
            // 그냥 이럴땐 커스텀 테이블에 있는 유저 지우고 다시 하던지 하면 될 듯
            if (!existingUser) {
                console.error(&quot;Buddy exists but auth user not found&quot;);
                return NextResponse.json(
                    { error: &quot;Auth user not found for existing buddy&quot; },
                    { status: 500 },
                );
            }

            // 기존 사용자 처리
            return handleExistingUser(
                existingBuddy,
                userEmail,
                FIXED_PASSWORD,
                origin,
                forwardedHost,
                isLocalEnv,
                next,
            );
        }

        // 새 사용자 생성
        const { data: user, error } = await supabaseAdmin.auth.admin.createUser({
            email: userEmail,
            password: FIXED_PASSWORD, // 지금 그냥 고정값 쓰는데, 안전하게 처리해야겠죠...
            email_confirm: true, // 네이버 OAuth 사용자는 이메일이 이미 확인된 상태
            app_metadata: {
                provider: &quot;naver&quot;,
                providers: [&quot;naver&quot;],
            },
            user_metadata: { // 입맛대로 하면 됩니다...
                iss: &quot;https://nid.naver.com&quot;,
                sub: userData.response.id,
                name: userData.response.name,
                email: userEmail,
                picture: userData.response.profile_image,
                full_name: userData.response.name,
                avatar_url: userData.response.profile_image,
                provider_id: userData.response.id,
                email_verified: true,
                phone_verified: false,
            },
        });

        // auth.users에 이미 존재하는 경우 (buddies는 없지만 auth.users는 있는 경우)
        if (error?.message.includes(&quot;A user with this email already exists&quot;)) {
            const existingUser = await findUserByEmail(supabaseAdmin, userEmail);

            // 이미 auth.users 에 존재해서 에러가 발생한 상황인데
            // email 로는 못찾는 상황임 - 사실 발생하지 않을듯?
            // 엄청 이상한 상황이므로 에러 메시지 수정.. 해서 쓰든지 추가 보완 필요
            if (!existingUser) {
                console.error(&quot;odd situation: user exists but could not be found&quot;);
                return NextResponse.json(
                    { error: &quot;odd situation: user exists but could not be found&quot; },
                    { status: 500 },
                );
            }

            // 이미 auth.user에는 존재하는 상황이고
            // public.buddies 에서 찾아보기
            const buddy = await getBuddy(supabaseAdmin, existingUser.id);

            // auth.user에는 있고, public.buddies 에는 없는.. 망한 상황
            if (!buddy) {
                console.error(&quot;Buddy not found for existing user&quot;);
                return NextResponse.json(
                    { error: &quot;Buddy profile not found&quot; },
                    { status: 404 },
                );
            }

            // 안 망했으면 유저 있는 경우니까
            return handleExistingUser(
                buddy,
                userEmail,
                FIXED_PASSWORD,
                origin,
                forwardedHost,
                isLocalEnv,
                next,
            );
        }

        // 사용자 생성 에러 처리
        if (error) {
            console.error(&quot;Error creating user:&quot;, error);
            return NextResponse.json({ error: error?.message }, { status: 400 });
        }

        // 사용자 생성 실패 시
        if (!user) {
            console.error(&quot;Creating user(Admin) failed?:&quot;, error);
            return NextResponse.json({ error: &quot;User not found&quot; }, { status: 404 });
        }

        // 새로 생성된 사용자 로그인 처리 (세션 생성)
        // 이거 꼭 필요합니다. 안그러면 생성만되고 로그인은 안된 상태임...
        const signInResult = await signInUser(userEmail, FIXED_PASSWORD);
        if (!signInResult.success) {
            console.error(&quot;Failed to sign in new user:&quot;, signInResult.error);
            return NextResponse.json(
                { error: &quot;User created but failed to sign in&quot; },
                { status: 500 },
            );
        }

		// 최초 로그인 여부 확인 (생성된 사용자는 항상 새 사용자)
        const buddy = await getBuddy(supabaseAdmin, user.user.id);

        if (!buddy) {
            console.error(&quot;Buddy not found for new user&quot;);
            return NextResponse.json(
                { error: &quot;Buddy profile not found&quot; },
                { status: 404 },
            );
        }

        const redirectUrl = getRedirectUrl(origin, forwardedHost, isLocalEnv, next);
        // 역시 여기도 입맛대로... 리턴
        return NextResponse.json({ redirectUrl, buddy }, { status: 200 });
    } catch (error) {
        console.error(&quot;Error during Naver login callback ====&amp;gt;&quot;, error);
        return NextResponse.json(
            { error: &quot;Internal Server Error&quot; },
            { status: 500 },
        );
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <category>auth</category>
      <category>login</category>
      <category>naver</category>
      <category>oauth</category>
      <category>supabase</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/318</guid>
      <comments>https://ifelseif.tistory.com/318#entry318comment</comments>
      <pubDate>Fri, 31 Oct 2025 20:29:43 +0900</pubDate>
    </item>
    <item>
      <title>[251029 TIL] Cookie, CORS, Site, Origin 총정리</title>
      <link>https://ifelseif.tistory.com/317</link>
      <description>&lt;h1&gt;  Cookie, CORS, Site/Origin 총정리&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. TL;DR&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;example.com&lt;/code&gt;(프론트엔드)에서 &lt;code&gt;api.example.com&lt;/code&gt;(백엔드)으로 인증 요청을 보낼 때:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Same-Site이지만 Cross-Origin&lt;/b&gt;입니다&lt;/li&gt;
&lt;li&gt;쿠키에 &lt;code&gt;domain: &quot;.example.com&quot;&lt;/code&gt; 설정 필요&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sameSite: &quot;lax&quot;&lt;/code&gt; 충분 (Cross-Site 아니므로)&lt;/li&gt;
&lt;li&gt;백엔드 CORS 설정 &lt;b&gt;필수&lt;/b&gt; (별개 정책!)&lt;/li&gt;
&lt;li&gt;Apollo Client 등 라이브러리&amp;nbsp;&lt;code&gt;credentials: &quot;include&quot;&lt;/code&gt; 필수&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심&lt;/b&gt;: 쿠키 정책(브라우저)과 CORS(서버) &lt;b&gt;둘 다&lt;/b&gt; 맞춰야 성공..&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Site vs Origin 개념&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  정의&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Origin&lt;/b&gt; = Protocol + Domain + Port (모두 일치해야 Same-Origin)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Site&lt;/b&gt; = 루트 도메인(&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Glossary/eTLD&quot;&gt;eTLD+1&lt;/a&gt;)이 같으면 Same-Site&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  관계도&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Same-Site
├── Same-Origin
│   └── example.com &amp;rarr; example.com
│       (protocol, domain, port 모두 일치)
│
└── Cross-Origin
    └── example.com &amp;rarr; api.example.com
        (subdomain이 다름)

Cross-Site (무조건 Cross-Origin)
└── Cross-Origin
    └── example.com &amp;rarr; google.com
        (완전히 다른 도메인)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;논리 관계&lt;/h3&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;IF Same-Site:
  ├─ Same-Origin 가능
  └─ Cross-Origin 가능

IF Cross-Site:
  └─ Cross-Origin 무조건

IF Same-Origin:
  └─ Same-Site 무조건

IF Cross-Origin:
  ├─ Same-Site 가능 (subdomain만 다를 때)
  └─ Cross-Site 가능 (완전히 다른 도메인)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 예시&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;th&gt;To&lt;/th&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Origin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same-Site&lt;/td&gt;
&lt;td&gt;Same-Origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same-Site&lt;/td&gt;
&lt;td&gt;Cross-Origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same-Site&lt;/td&gt;
&lt;td&gt;Cross-Origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;google.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cross-Site&lt;/td&gt;
&lt;td&gt;Cross-Origin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 쿠키 Domain 옵션&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 원칙&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠키에 &lt;code&gt;domain&lt;/code&gt; 옵션을 &lt;b&gt;명시하지 않으면&lt;/b&gt;, 쿠키는 &lt;b&gt;주소 완전히 같을때&lt;/b&gt;에만 유효!!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설정별 동작&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1️⃣ Same-Origin (domain 옵션 불필요)&lt;/h4&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// example.com &amp;rarr; example.com
cookieStore.set('token', jwt, {
  // domain 생략 가능
  path: &quot;/&quot;,
  sameSite: &quot;strict&quot;, // 가장 엄격한 보안
  secure: true,
  httpOnly: true,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쿠키 전송 범위&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ &lt;code&gt;example.com&lt;/code&gt; &amp;rarr; &lt;code&gt;example.com&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 사례&lt;/b&gt;: Next.js API Routes, 모놀리식 아키텍처&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2️⃣ Same-Site/Cross-Origin (domain 옵션 필수!) ⭐&lt;/h4&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// example.com &amp;rarr; api.example.com

cookieStore.set('token', jwt, {
  domain: &quot;.example.com&quot;, // ⭐ 점(.) 필수! 모든 subdomain 공유
  path: &quot;/&quot;,
  sameSite: &quot;lax&quot;, // Same-Site이므로 충분
  secure: true,
  httpOnly: true,
  maxAge: 30 * 24 * 60 * 60, // 30일
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쿠키 전송 범위&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ &lt;code&gt;example.com&lt;/code&gt; &amp;rarr; &lt;code&gt;example.com&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;✅ &lt;code&gt;example.com&lt;/code&gt; &amp;rarr; &lt;code&gt;api.example.com&lt;/code&gt; (전송됨!)&lt;/li&gt;
&lt;li&gt;✅ &lt;code&gt;example.com&lt;/code&gt; &amp;rarr; &lt;code&gt;admin.example.com&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 사례&lt;/b&gt;: 마이크로서비스, API 서버 분리, SSO&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3️⃣ Cross-Site (점점 불가능해지는 중) !!&lt;/h4&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// example.com &amp;rarr; external.com
cookieStore.set('token', jwt, {
  domain: &quot;.example.com&quot;,
  sameSite: &quot;none&quot;, // ⚠️ 필수!
  secure: true,      // ⚠️ 필수!
  httpOnly: true,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주의사항&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Chrome 등 주요 브라우저에서 3rd party 쿠키 차단 중&lt;/li&gt;
&lt;li&gt;2024년부터 기본적으로 차단&lt;/li&gt;
&lt;li&gt;대안: &lt;b&gt;JWT를 Authorization 헤더로 전송&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 사례&lt;/b&gt;: 외부 인증 제공자&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 백엔드 CORS는 별개 정책!&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  두 가지 독립적인 보안 레이어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1️⃣ CORS (서버 측 정책) &amp;rarr; 서버: &quot;이 origin 요청 받을까?&quot;&lt;br /&gt;2️⃣ Cookie Policy (브라우저 정책) &amp;rarr; 브라우저: &quot;이 쿠키를 보낼까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 다 통과해야 성공임..!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 성공 케이스 (모두 OK)&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 프론트엔드: example.com
// 백엔드: api.example.com

// 1️⃣ 쿠키 설정 (브라우저 정책)
cookieStore.set('token', jwt, {
  domain: &quot;.example.com&quot;, // ✅
  sameSite: &quot;lax&quot;,         // ✅
  secure: true,
  httpOnly: true,
});

// 2️⃣ Apollo Client 설정
const httpLink = createHttpLink({
  uri: 'https://api.example.com/v1/graphql',
  credentials: 'include', // ✅ 필수!
});

// 3️⃣ 백엔드 CORS 설정 (서버 정책)
// api.example.com 환경 변수
CORS_ORIGIN: &quot;https://example.com&quot; // ✅
// 또는 Hasura의 경우
HASURA_GRAPHQL_CORS_DOMAIN: &quot;https://example.com&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;:   요청 성공! 인증 성공!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ 실패 케이스 1: CORS 차단&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// 1️⃣ 쿠키 설정: ✅ OK
cookieStore.set('token', jwt, {
  domain: &quot;.example.com&quot;,
  sameSite: &quot;lax&quot;,
});

// 2️⃣ CORS 설정: ❌ 없음
// api.example.com에 CORS 설정 안 함&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;브라우저 콘솔 에러&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;Access to fetch at 'https://api.example.com/v1/graphql' 
from origin 'https://example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;:   요청 자체가 차단됨&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚠️ 실패 케이스 2: 쿠키 정책 실패&lt;/h3&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;// 1️⃣ 쿠키 설정: ❌ domain 없음
cookieStore.set('token', jwt, {
  // domain 없음 &amp;rarr; example.com만
  sameSite: &quot;lax&quot;,
});

// 2️⃣ CORS 설정: ✅ OK
HASURA_GRAPHQL_CORS_DOMAIN: &quot;https://example.com&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Network 탭&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Request Headers:
  (Cookie 헤더 없음) ❌

Response:
  200 OK ✅
  { &quot;x-hasura-role&quot;: &quot;guest&quot; } ⚠️ 인증 실패&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;: 요청은 성공하지만 인증 실패 ㅜㅜ&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;완전한 체크리스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cross-Origin 인증 요청이 성공하려면:&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;백엔드 (api.example.com)
  ✅ CORS 설정
    ├─ Access-Control-Allow-Origin: https://example.com
    ├─ Access-Control-Allow-Credentials: true
    ├─ Access-Control-Allow-Methods: POST, GET, OPTIONS
    └─ Access-Control-Allow-Headers: Content-Type, Authorization

프론트엔드 (example.com)
  ✅ 쿠키 설정
    ├─ domain: &quot;.example.com&quot;
    ├─ sameSite: &quot;lax&quot;
    ├─ secure: true
    └─ httpOnly: true

  ✅ HTTP Client 설정
    └─ credentials: &quot;include&quot; (fetch API)
    └─ credentials: &quot;include&quot; (Apollo Client)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하나라도 빠지면 실패입니다..&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 교훈&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 정리&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Site &amp;ne; Origin&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Same-Site: 루트 도메인만 같으면 OK&lt;/li&gt;
&lt;li&gt;Same-Origin: Protocol + Domain + Port 모두 같아야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;쿠키는 domain 옵션이 핵심&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;domain: &quot;.example.com&quot;&lt;/code&gt; &amp;rarr; 모든 subdomain 공유&lt;/li&gt;
&lt;li&gt;옵션 없음 &amp;rarr; 정확한 호스트만&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CORS는 별개 정책&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿠키 정책(브라우저) &amp;ne; CORS(서버)&lt;/li&gt;
&lt;li&gt;둘 다 맞춰야 성공!&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실무에서는 Same-Site/Cross-Origin이 일반적&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프론트엔드와 API를 subdomain으로 분리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;domain: &quot;.example.com&quot;&lt;/code&gt; + &lt;code&gt;sameSite: &quot;lax&quot;&lt;/code&gt; + CORS 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚡⚡⚡⚡ 명확히 이해하자..!&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CORS, Site, Origin, 쿠키 정책은 각각 다른 보안 레이어!&lt;br /&gt;개념을 명확히 이해하고, 각 레이어를 올바르게 설정해야 Cross-Origin 인증이 성공!&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies&quot;&gt;MDN - HTTP Cookies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS&quot;&gt;MDN - CORS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy&quot;&gt;MDN - Same-origin policy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://web.dev/samesite-cookies-explained/&quot;&gt;web.dev - SameSite cookies explained&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>cookie-policy</category>
      <category>CORS</category>
      <category>cross-origin</category>
      <category>cross-site</category>
      <category>same-origin</category>
      <category>same-site</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/317</guid>
      <comments>https://ifelseif.tistory.com/317#entry317comment</comments>
      <pubDate>Wed, 29 Oct 2025 21:55:51 +0900</pubDate>
    </item>
    <item>
      <title>[251026 TIL] 실전적 Apollo Client 구현기</title>
      <link>https://ifelseif.tistory.com/316</link>
      <description>&lt;h1&gt;실전적 Apollo Client 구현기&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제 상황&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기술 스택&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Next.js 15&lt;/b&gt; (App Router)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Hasura GraphQL&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Apollo Client&lt;/b&gt; with &lt;code&gt;@apollo/client-integration-nextjs&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결해야 했던 문제들&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;중복 코드 문제&lt;/b&gt;: 서버용, 어드민용, 클라이언트용 총 3개의 Apollo Client 인스턴스가 필요했는데, 각각에 대해 Apollo Links를 별도로 작성하면 코드 중복이 심각함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버/클라이언트 경계 처리&lt;/b&gt;: RSC(React Server Components) 환경에서 서버와 클라이언트의 인증 방식이 달라 각각 다른 처리가 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;토큰 갱신 로직&lt;/b&gt;: 클라이언트에서만 토큰 갱신이 가능하므로 환경별로 다른 에러 처리 필요&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 해결 방안: 팩토리 패턴과 의존성 주입&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 팩토리 함수 설계&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export interface CreateLinksOptions {
  isServer: boolean;
  getToken?: () =&amp;gt; Promise&amp;lt;string | null | undefined&amp;gt; | string | null | undefined;
  hasuraAdminSecret?: string;
  hasuraGraphQLEndpoint?: string;
  refreshTokenManager?: {
    refreshAccessToken: () =&amp;gt; Promise&amp;lt;boolean&amp;gt;;
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성을 외부에서 주입받아 다양한 환경에 대응할 수 있도록 설계&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 핵심 구현 포인트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) 환경별 분기 처리&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;const prefix = isServer ? &quot; ️ [Server]&quot; : &quot;  [Client]&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2) 조건부 링크 구성&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;inform7&quot;&gt;&lt;code&gt;const links = [
  loggerLink,
  errorLink,
  ...(retryLink ? [retryLink] : []),  // 클라이언트만
  ...(ssrMultipartLink ? [ssrMultipartLink] : []),  // 서버만
  authLink.concat(httpLink),
];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3) 토큰 갱신 처리 (클라이언트 전용)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;if (!isServer &amp;amp;&amp;amp; extensions?.code === &quot;invalid-jwt&quot;) {
  return new Observable((observer) =&amp;gt; {
    refreshTokenManager.refreshAccessToken()
      .then((success) =&amp;gt; {
        if (success) {
          forward(operation).subscribe(observer);  // 재시도
        }
      });
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 실제 사용 예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 서버 컴포넌트용 클라이언트&lt;/h3&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;export const { getClient, query, PreloadQuery } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: createApolloLinks({
      isServer: true,
      getToken: async () =&amp;gt; {
        const token = (await cookies()).get('access-token')?.value;
        return token;
      },
      hasuraGraphQLEndpoint: env.HASURA_GRAPHQL_ENDPOINT,
    }),
    incrementalHandler: new Defer20220824Handler(),
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 클라이언트 컴포넌트용 클라이언트&lt;/h3&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: createApolloLinks({
    isServer: false,
    hasuraGraphQLEndpoint: env.NEXT_PUBLIC_HASURA_GRAPHQL_ENDPOINT,
    refreshTokenManager: {
      refreshAccessToken: async () =&amp;gt; {
        // 토큰 갱신 로직
        return await refreshToken();
      }
    },
  }),
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 어드민용 클라이언트 (서버 전용)&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const adminClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: createApolloLinks({
    isServer: true,
    hasuraAdminSecret: env.HASURA_ADMIN_SECRET,
    hasuraGraphQLEndpoint: env.HASURA_GRAPHQL_ENDPOINT,
  }),
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.4 Apollo-Links 전체 예시&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;import { ApolloLink, HttpLink, Observable } from &quot;@apollo/client&quot;;
import {
    CombinedGraphQLErrors,
    CombinedProtocolErrors,
} from &quot;@apollo/client/errors&quot;;
import { SetContextLink } from &quot;@apollo/client/link/context&quot;;
import { ErrorLink } from &quot;@apollo/client/link/error&quot;;
import { RetryLink } from &quot;@apollo/client/link/retry&quot;;
import { SSRMultipartLink } from &quot;@apollo/client-integration-nextjs&quot;;
import { tap } from &quot;rxjs/operators&quot;;

export interface CreateLinksOptions {
    isServer: boolean;
    getToken?: () =&amp;gt;
        | Promise&amp;lt;string | null | undefined&amp;gt;
        | string
        | null
        | undefined;
    hasuraAdminSecret?: string;
    hasuraGraphQLEndpoint?: string;
    refreshTokenManager?: {
        refreshAccessToken: () =&amp;gt; Promise&amp;lt;boolean&amp;gt;;
    };
}

// 아폴로 링크 팩토리 함수
export function createApolloLinks(options: CreateLinksOptions) {
    const {
        isServer,
        getToken,
        hasuraAdminSecret,
        hasuraGraphQLEndpoint,
        refreshTokenManager,
    } = options;
    const prefix = isServer ? &quot; ️ [Server]&quot; : &quot;  [Client]&quot;;
    const endpoint = hasuraGraphQLEndpoint;

    // 1. Auth Link
    // 서버: 쿠키에서 토큰을 읽어 Authorization 헤더 설정
    const authLink = new SetContextLink(async (prevContext, _operation) =&amp;gt; {
        let token: string | null | undefined;

        // 명시적으로 토큰을 헤더에 추가
        if (getToken) {
             console.log(`${prefix}   apollo-links에서 헤더에 토큰 추가 시도 시작`);
             token = await getToken();
             console.log(
                 `${prefix}   apollo-links에서 토큰 조회 성공:`,
                 token ? `${token.substring(0, 20)}...` : &quot;null&quot;,
             );
         } else {
             console.log(`${prefix}   getToken 함수가 제공되지 않았습니다`);
         }

        const headers = {
            ...prevContext.headers,
            ...(token &amp;amp;&amp;amp; { authorization: `Bearer ${token}` }),
            &quot;x-request-from&quot;: isServer ? &quot;server&quot; : &quot;client&quot;,
        };

        return {
            headers,
        };
    });

    // 2. Error Link - 인증 에러 발생 시 토큰 갱신 및 재시도
    const errorLink = new ErrorLink(({ error, operation, forward }) =&amp;gt; {
        if (CombinedGraphQLErrors.is(error)) {
            for (const err of error.errors) {
                const { message, locations, path, extensions } = err;
                console.log(
                    `${prefix} ❌ [GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
                );

                // ** 클라이언트 토큰 갱신 처리 **
                if (!isServer &amp;amp;&amp;amp; typeof window !== &quot;undefined&quot;) {
                    // invalid-jwt 또는 UNAUTHENTICATED 에러 감지
                    if (
                        extensions?.code === &quot;invalid-jwt&quot; ||
                        extensions?.code === &quot;UNAUTHENTICATED&quot;
                    ) {
                        console.log(
                            `${prefix}   apollo-links에서 토큰 갱신 시도 시작: ${operation.operationName}`,
                        );

                        // Observable을 반환하여 토큰 갱신 후 재시도
                        return new Observable((observer) =&amp;gt; {
                            if (!refreshTokenManager) {
                                console.error(`${prefix} ❌ refreshTokenManager 없음`);
                                observer.error(error);
                                return;
                            }
                            refreshTokenManager
                                .refreshAccessToken()
                                .then((success: boolean) =&amp;gt; {
                                    if (success) {
                                        // 토큰 갱신 성공 - 원래 요청 재시도
                                        console.log(
                                            `${prefix} ♻️ 토큰 갱신 성공, ${operation.operationName} 재시도 시작`,
                                        );
                                        const subscriber = {
                                            next: observer.next.bind(observer),
                                            error: observer.error.bind(observer),
                                            complete: observer.complete.bind(observer),
                                        };
                                        forward(operation).subscribe(subscriber);
                                    } else {
                                        // 토큰 갱신 실패 - 에러 전달
                                        observer.error(error);
                                    }
                                })
                                .catch((refreshError: unknown) =&amp;gt; {
                                    console.error(`${prefix} ❌ 토큰 갱신 에러:`, refreshError);
                                    observer.error(error);
                                });
                        });
                    }
                    if (extensions?.code === &quot;FORBIDDEN&quot;) {
                        // 권한 부족 에러
                        console.warn(`${prefix} ⛔ Forbidden 인가 검토 필요: ${message}`);
                        // TODO: forbidden 일때 처리 방법 논의 필요 ***
                        // toast.error('권한이 없습니다?')
                    }
                } else {
                    // 서버에서는 토큰 갱신 불가 - 클라이언트에서 처리해야 함
                    // 1. 서버는 브라우저 쿠키에 직접 접근 불가
                    // 2. RSC는 이미 렌더링 중이라 쿠키 수정 불가
                    // 3. 에러를 자동으로 전파하여 클라이언트에서 재인증 처리
                    // (ErrorLink에서 아무것도 반환하지 않으면 에러가 자동 전파됨)
                }
            }
        } else if (CombinedProtocolErrors.is(error)) {
            for (const err of error.errors) {
                const { message, extensions } = err;
                console.log(
                    `${prefix} ❌ [Protocol] ${operation.operationName}: ${message}`,
                    { extensions },
                );
            }
        } else {
            console.error(`${prefix}   [Network error]:`, error);
        }
    });

    // 3. HTTP Link
    const httpLink = new HttpLink({
        uri: endpoint,
        credentials: &quot;include&quot;,
        ...(isServer &amp;amp;&amp;amp; {
            fetch: fetch,
            fetchOptions: {
                cache: &quot;no-store&quot;,
            },
        }),
        ...(!!hasuraAdminSecret &amp;amp;&amp;amp; {
            headers: {
                &quot;x-hasura-admin-secret&quot;: hasuraAdminSecret,
            },
        }),
    });

    // 4. Retry Link (클라이언트만)
    const retryLink = !isServer
        ? new RetryLink({
                delay: {
                    initial: 300,
                    max: 5000,
                    jitter: true,
                },
                attempts: {
                    max: 3,
                    retryIf: (error) =&amp;gt;
                        !!error &amp;amp;&amp;amp; error.message.includes(&quot;Network error&quot;),
                },
            })
        : null;

    // 5. SSR Multipart Link
    const ssrMultipartLink = isServer
        ? new SSRMultipartLink({
                stripDefer: true,
            })
        : null;

    // 6. Logger Link - 개발 환경에서만 GraphQL 작업 로깅
    // Apollo Client 4.0에서 asyncMap이 제거되어 rxjs의 tap 연산자 사용
    // tap은 사이드 이펙트(로깅)만 처리하고 응답은 그대로 전달
    const loggerLink = new ApolloLink((operation, forward) =&amp;gt; {
        // 프로덕션 환경에서는 로깅 비활성화
        if (process.env.NODE_ENV !== &quot;development&quot;) {
            return forward(operation);
        }

        // 요청 시작 로그 (작업 이름과 변수 출력)
        console.log(`${prefix}   ${operation.operationName}`, {
            variables: operation.variables,
        });
        const start = Date.now();

        // 응답 완료 시 소요 시간 로그
        // 1초 이상 걸리면  , 그 이하면 ⚡ 이모지 표시
        return forward(operation).pipe(
            tap(() =&amp;gt; {
                const duration = Date.now() - start;
                const emoji = duration &amp;gt; 1000 ? &quot; &quot; : &quot;⚡&quot;;
                console.log(
                    `${prefix} ${emoji} ${operation.operationName} (${duration}ms)`,
                );
            }),
        );
    });

    // 최종적으로 링크들 배열 생성
    const links = [
        loggerLink,
        errorLink,
        ...(retryLink ? [retryLink] : []),
        ...(ssrMultipartLink ? [ssrMultipartLink] : []),
        authLink.concat(httpLink),
    ];

    return ApolloLink.from(links);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 이 접근 방식의 장점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팩토리 패턴과 의존성 주입을 통해 복잡한 Apollo Client 설정을 깔끔하게 관리할 수 있었습니다. 특히 Next.js 15의 App Router와 RSC 환경에서 서버/클라이언트 경계를 명확히 구분하여 처리한 것이 핵심이었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 코드 재사용성&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하나의 팩토리 함수로 3가지 클라이언트 구성을 모두 처리&lt;/li&gt;
&lt;li&gt;링크 구성 로직의 중복 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 유지보수성&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;의존성이 명확히 정의되어 있어 테스트 용이&lt;/li&gt;
&lt;li&gt;새로운 링크 추가나 기존 링크 수정이 한 곳에서만 이루어짐&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.3 타입 안정성&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TypeScript 인터페이스로 옵션을 정의하여 컴파일 타임에 오류 방지&lt;/li&gt;
&lt;li&gt;IDE 자동완성 지원으로 개발 생산성 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.4 환경별 최적화&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버: SSR Multipart Link 사용으로 스트리밍 지원&lt;/li&gt;
&lt;li&gt;클라이언트: Retry Link로 네트워크 안정성 향상&lt;/li&gt;
&lt;li&gt;개발 환경: Logger Link로 디버깅 편의성 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 주의사항&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;서버에서의 토큰 갱신 불가&lt;/b&gt;: RSC는 이미 렌더링 중이므로 쿠키 수정 불가능. 에러를 클라이언트로 전파하여 처리해야 함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;rxjs 의존성&lt;/b&gt;: Apollo Client 4.0에서 &lt;code&gt;asyncMap&lt;/code&gt;이 제거되어 rxjs의 &lt;code&gt;tap&lt;/code&gt; 연산자 사용 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐싱 전략&lt;/b&gt;: 서버에서는 &lt;code&gt;cache: &quot;no-store&quot;&lt;/code&gt; 설정으로 항상 최신 데이터 fetch&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>TIL</category>
      <category>apollo-links</category>
      <category>dependency-injection</category>
      <category>factory-pattern</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/316</guid>
      <comments>https://ifelseif.tistory.com/316#entry316comment</comments>
      <pubDate>Sun, 26 Oct 2025 13:23:01 +0900</pubDate>
    </item>
    <item>
      <title>[251024 TIL] Hasura(GQL) 사용시 3rd-Party 쿠키 정책 문제</title>
      <link>https://ifelseif.tistory.com/315</link>
      <description>&lt;h1&gt;Hasura(GQL) 사용시 3rd-Party 쿠키 정책 문제&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Q) credentials: &quot;include&quot; 인데도 쿠키 자동 전송 안될 수 있는거 맞음?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 cookie 기반에서 httponly, secured, domain, samesite 설명을 여러가지로 변주해서 시도해봤지만 결론은 브라우저의 third-party cookie 정책으로 인해 쿠키가 자동 전송되지 않는데 이게 맞나요?(심지어 서브도메인 - api.myapp.com &amp;lt;&amp;gt; myapp.com 이더라도)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;A) 맞다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맞아요. 결론부터 말하면: 브라우저가 Hasura로 쿠키를 보내주지 못하면(3rd‑party 쿠키 차단), Remote Schema를 써도 Hasura가 JWT를 검증할 수 없고, 따라서 &lt;code&gt;x-hasura-user-id&lt;/code&gt; 같은 세션 변수도 생성되지 않습니다. 그래서 yoga 리졸버에서 userId를 받을 수 없어요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 credentials: 'include'로는 부족한가&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;credentials: 'include'&lt;/code&gt;는 &amp;ldquo;보낼 수 있는 쿠키는 보내라&amp;rdquo;는 의미일 뿐, 브라우저의 3rd‑party 쿠키 정책을 우회하지 못합니다.&lt;/li&gt;
&lt;li&gt;다른 도메인(혹은 크로스‑사이트로 판정되는 서브도메인)으로 요청하면 최신 브라우저는 쿠키를 기본 차단합니다. 그래서 Hasura는 쿠키(JWT)를 못 받습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;const authLink = new SetContextLink(async (prevContext, _operation) =&amp;gt; {
  let token: string | null | undefined;

  const headers = {
    ...prevContext.headers,
    ...(token &amp;amp;&amp;amp; { authorization: `Bearer ${token}` }),
    &quot;x-request-from&quot;: isServer ? &quot;server&quot; : &quot;client&quot;,
  };
  return { headers };
});&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;const httpLink = new HttpLink({
  uri: endpoint,
  credentials: &quot;include&quot;,
  ...(!!hasuraAdminSecret &amp;amp;&amp;amp; {
    headers: { &quot;x-hasura-admin-secret&quot;: hasuraAdminSecret },
  }),
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;한줄 요약&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Hasura Remote Schema는 &amp;ldquo;Hasura가 인증을 끝내고 나서&amp;rdquo; 우리 yoga로 세션 변수를 넘겨줍니다. 브라우저의 3rd‑party 쿠키 차단으로 Hasura가 쿠키(JWT)를 못 받으면 인증 불가이므로, 같은 사이트 프록시나 BFF로 Authorization 헤더 전달이 필요합니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>3rd-party-cookie</category>
      <category>hasura</category>
      <category>remote-schema</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/315</guid>
      <comments>https://ifelseif.tistory.com/315#entry315comment</comments>
      <pubDate>Fri, 24 Oct 2025 22:52:10 +0900</pubDate>
    </item>
    <item>
      <title>[251024 TIL] Server 용 Apollo Client 생성시 주의점!</title>
      <link>https://ifelseif.tistory.com/314</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Server 용 Apollo Client 생성시 주의점!&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TL; DR&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;registerApolloClient&lt;/code&gt;는 싱글톤처럼 동작하지만&lt;/b&gt;, &lt;code&gt;getToken&lt;/code&gt;을 함수로 전달하면 매 요청마다 토큰을 새로 읽어와서 안전할 수 있음. &lt;a href=&quot;https://github.com/apollographql/apollo-client-integrations/tree/main/packages/nextjs&quot;&gt;참고&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;하지만 캐시 오염 가능성&lt;/b&gt;은 여전히 존재하므로, 민감한 데이터는 &lt;code&gt;fetchPolicy: 'no-cache'&lt;/code&gt; 사용을 권장.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;getToken&lt;/code&gt; 없이 &lt;code&gt;credentials: 'include'&lt;/code&gt;만으로는 절대 작동하지 않음!&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;가장 안전한 방법&lt;/b&gt;은 요청별 클라이언트를 생성하는 것이지만, &lt;code&gt;getToken&lt;/code&gt;을 올바르게 구현하면 &lt;code&gt;registerApolloClient&lt;/code&gt;만으로 충분히 사용 가능.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 예시&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// /lib/apollo/server.ts
import { Defer20220824Handler } from &quot;@apollo/client/incremental&quot;;
import {
    ApolloClient,
    InMemoryCache,
    registerApolloClient,
} from &quot;@apollo/client-integration-nextjs&quot;;
import { env } from &quot;@/env&quot;;
import { createApolloLinks } from &quot;./apollo-links&quot;;
import { getTokenFromCookie } from &quot;../auth/server-utils&quot;;

export const { getClient, query, PreloadQuery } = registerApolloClient(() =&amp;gt; {
    return new ApolloClient({
        cache: new InMemoryCache(),
        link: createApolloLinks({
            // 여기가 DI
            isServer: true,
            hasuraGraphQLEndpoint: env.GRAPHQL_ENDPOINT,
            getToken: getTokenFromCookie,
            incrementalHandler: new Defer20220824Handler(),
        }),
    });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이거 싱글톤인데 문제 안됨? &amp;gt;&amp;gt; 될 수 있음! 주의필요!&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;code&gt;registerApolloClient&lt;/code&gt;의 동작 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;registerApolloClient&lt;/code&gt;는 &lt;b&gt;React의 &lt;code&gt;cache()&lt;/code&gt; API&lt;/b&gt;를 사용합니다:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// apollo-client-integrations/packages/nextjs 내부
import { cache } from 'react';

export function registerApolloClient(makeClient) {
  const getClient = cache(() =&amp;gt; {
    return makeClient();
  });

  return { getClient, ... };
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;cache()&lt;/code&gt;의 특성&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;렌더링 단위로 캐싱&lt;/b&gt;됩니다&lt;/li&gt;
&lt;li&gt;동일한 렌더링 사이클 내에서는 같은 인스턴스 반환&lt;/li&gt;
&lt;li&gt;다른 요청에서는 새로운 인스턴스가 생성될 수도 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 문제가 되는 케이스와 안 되는 케이스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✅ 안전한 케이스 (getToken 사용)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export const { getClient } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
    link: createApolloLinks({
      isServer: true,
      hasuraGraphQLEndpoint: env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
      getToken: getTokenFromCookie, // 함수를 전달!
    }),
  });
});

// 사용
const { data } = await getClient().query({ query: userQuery });&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 안전한가?&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// createApolloLinks 내부 예시
const authLink = new SetContextLink(async (prevContext, operation) =&amp;gt; {
  const token = await getToken(); // 매 요청마다 실행!
  return {
    headers: {
      ...prevContext.headers,
      ...(token &amp;amp;&amp;amp; { authorization: `Bearer ${token}` }),
    }
  };
});&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;getToken&lt;/code&gt;은 &lt;b&gt;함수 참조&lt;/b&gt;로 전달됨&lt;/li&gt;
&lt;li&gt;Apollo의 &lt;code&gt;SetContextLink&lt;/code&gt;가 &lt;b&gt;매 GraphQL 요청마다&lt;/b&gt; &lt;code&gt;getToken()&lt;/code&gt;을 호출&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getTokenFromCookie()&lt;/code&gt;는 그 순간의 쿠키를 읽어옴&lt;/li&gt;
&lt;li&gt;즉, 클라이언트 인스턴스는 공유되지만, &lt;b&gt;토큰은 매번 새로 읽어옴&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚠️ 위험한 케이스&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export const { getClient } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
    link: createApolloLinks({
      isServer: true,
      hasuraGraphQLEndpoint: env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
      // getToken 없음!
    }),
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Authorization 헤더가 아예 전달되지 않음&lt;/li&gt;
&lt;li&gt;Hasura가 익명 권한으로 처리&lt;/li&gt;
&lt;li&gt;사용자별 데이터 조회 불가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 하지만 여전히 주의해야 할 점들&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3.1 캐시 오염 가능성&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const { getClient } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
    cache: new InMemoryCache(), // 이 캐시가 공유될 수 있음!
    link: createApolloLinks({
      getToken: getTokenFromCookie,
    }),
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시나리오:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;사용자 A가 요청 &amp;rarr; 클라이언트 생성 &amp;rarr; 데이터 조회 &amp;rarr; 캐시에 저장&lt;/li&gt;
&lt;li&gt;같은 렌더링 사이클/컨텍스트에서 사용자 B가 요청&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cache()&lt;/code&gt;가 같은 클라이언트 인스턴스 반환&lt;/li&gt;
&lt;li&gt;사용자 B의 토큰으로 요청하지만, &lt;b&gt;캐시에 사용자 A의 데이터가 남아있을 수 있음&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3.2 &lt;code&gt;getTokenFromCookie&lt;/code&gt;의 구현에 따라 다름&lt;/h4&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// ❌ 위험한 구현
function getTokenFromCookie() {
  // 전역 변수나 모듈 레벨 상태에서 읽어오면 위험!
  return globalToken; 
}

// ✅ 안전한 구현
function getTokenFromCookie() {
  // Next.js의 cookies()는 현재 요청 컨텍스트를 자동으로 추적
  const cookieStore = cookies(); 
  return cookieStore.get('access_token')?.value;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. Next.js App Router의 Request Context&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 13+ App Router에서는 &lt;b&gt;Request Context&lt;/b&gt;가 중요!!&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { cookies } from 'next/headers';

// 서버 컴포넌트나 Route Handler에서
async function POST() {
  const cookieStore = cookies(); // 현재 요청의 쿠키
  const token = cookieStore.get('access_token');

  // 이 시점에서 getClient() 호출
  const client = getClient();
  const { data } = await client.query({ query: userQuery });
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;cookies()&lt;/code&gt;는 &lt;b&gt;현재 실행 중인 요청의 컨텍스트&lt;/b&gt;를 자동으로 추적&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getTokenFromCookie()&lt;/code&gt; 내부에서 &lt;code&gt;cookies()&lt;/code&gt;를 호출하면, 그 순간의 요청 쿠키를 읽어옴&lt;/li&gt;
&lt;li&gt;이게 올바르게 동작하려면 &lt;b&gt;반드시 async 컨텍스트 내에서 호출&lt;/b&gt;되어야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 공식 문서 예제의 의도&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apollo의 Next.js 통합 패키지는:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;SSR/RSC에서 Apollo Client 사용을 간편하게&lt;/b&gt; 만들기 위함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;렌더링 단위 캐싱&lt;/b&gt;으로 불필요한 클라이언트 재생성 방지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;하지만 보안은 개발자가 직접 관리&lt;/b&gt;해야 함&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 결론 및 권장사항&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&quot;credentials: include이기 때문에 알아서 들어간다&quot;?&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;❌ &lt;b&gt;땡!!&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Apollo Client의 HTTP Link는 쿠키를 자동으로 헤더에 변환하지 않음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;credentials: 'include'&lt;/code&gt;는 브라우저가 쿠키를 &lt;b&gt;전송&lt;/b&gt;하는 것이지, Authorization 헤더로 &lt;b&gt;변환&lt;/b&gt;하는 게 아님&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;올바른 구현 예제&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ✅ 1. getToken 함수 구현 확인
function getTokenFromCookie() {
  const cookieStore = cookies(); // Next.js의 cookies() 사용
  const token = cookieStore.get('access_token')?.value;
  return token;
}

// ✅ 2. createApolloLinks에 getToken 전달
export const { getClient } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: createApolloLinks({
      isServer: true,
      hasuraGraphQLEndpoint: env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
      getToken: getTokenFromCookie, // 반드시 필요!
    }),
  });
});

// ✅ 3. 캐시 정책 고려
// 민감한 사용자 데이터는 fetchPolicy: 'no-cache' 사용
const { data } = await getClient().query({
  query: userQuery,
  fetchPolicy: 'no-cache', // 또는 'network-only'
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 부록&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;query의 fetchPolicy 와 HttpLink의 fetchOptions 차이&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;fetchOptions는 HTTP 레벨이고, fetchPolicy는 Apollo 레벨&lt;/li&gt;
&lt;li&gt;두 개는 다른 레이어이므로 &lt;b&gt;둘 다 설정&lt;/b&gt;해야 좋음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Apollo의 &lt;code&gt;fetchPolicy&lt;/code&gt; 옵션들&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;cache-first&lt;/code&gt; (기본값)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Apollo 캐시에 있으면 네트워크 요청 안 함&lt;/li&gt;
&lt;li&gt;가장 빠르지만, 오래된 데이터 위험&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;network-only&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;항상 네트워크 요청&lt;/li&gt;
&lt;li&gt;응답은 캐시에 저장&lt;/li&gt;
&lt;li&gt;다음 요청을 위해 캐시 업데이트&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;no-cache&lt;/code&gt; ✅ (민감한 데이터용)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;항상 네트워크 요청&lt;/li&gt;
&lt;li&gt;&lt;b&gt;응답을 캐시에 저장하지 않음&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;사용자별 데이터에 적합&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cache-and-network&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시에서 먼저 읽고, 동시에 네트워크 요청&lt;/li&gt;
&lt;li&gt;빠른 응답 + 최신 데이터 보장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;fetchOptions&lt;/code&gt; 는 &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/Window/fetch#cache&quot;&gt;MDN 참고&lt;/a&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;const httpLink = new HttpLink({ 
 fetchOptions: {
  cache: &quot;no-store&quot;, // &amp;larr; HTTP 레벨 캐시 설정하면 됨
 }, 
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;const { data } = await getClient().query({
    query: userQuery, 
    fetchPolicy: 'no-cache', // &amp;larr; Apollo 레벨 캐시
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>TIL</category>
      <category>Apollo</category>
      <category>ApolloClient</category>
      <category>getclient</category>
      <category>graphQL</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/314</guid>
      <comments>https://ifelseif.tistory.com/314#entry314comment</comments>
      <pubDate>Fri, 24 Oct 2025 22:48:29 +0900</pubDate>
    </item>
    <item>
      <title>[251024 TIL] GraphQL Yoga 역할(+Hasura Remote Schema)</title>
      <link>https://ifelseif.tistory.com/313</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;GraphQL Yoga 역할(+Hasura Remote Schema)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예시&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// /api/graphql/route.ts
import { DateTimeTypeDefinition } from &quot;graphql-scalars&quot;;
import { createSchema, createYoga } from &quot;graphql-yoga&quot;;
import { v4 as uuid } from &quot;uuid&quot;;

interface NextContext {
    params: Promise&amp;lt;Record&amp;lt;string, string&amp;gt;&amp;gt;;
}

const typeDefs = /* GraphQL */ `
    type Query {
        session: Session!
    }

    type Mutation {
        ok: String!
    }

    type Session {
        id: String!
        userId: String!
    }
`;

const { handleRequest } = createYoga&amp;lt;NextContext&amp;gt;({
    schema: createSchema({
        typeDefs: [DateTimeTypeDefinition, typeDefs],
        resolvers: {
            Query: {
                session: async (_, _args, ctx) =&amp;gt; {
                    const userId = ctx.request.headers.get(&quot;x-hasura-user-id&quot;);

                    if (!userId) {
                        throw new Error(&quot;userId not exists!&quot;);
                    }

                    const cookie = ctx.request.headers.get(&quot;cookie&quot;);

                    if (!cookie) {
                        throw new Error(&quot;cookie is not exists!&quot;);
                    }

                    return {
                        id: uuid(),
                        userId,
                    };
                },
            },
            Mutation: {
                // Mutation TEST 용도. 임시코드
                ok() {
                    return &quot;ok&quot;;
                },
            },
        },
    }),
    graphqlEndpoint: &quot;/api/graphql&quot;,
    graphiql: process.env.NODE_ENV !== &quot;production&quot;,
    	cors: {
    	credentials: true,
    },
    fetchAPI: {
    	Response: Response,
    },
});

export {
    handleRequest as GET,
    handleRequest as POST,
    handleRequest as OPTIONS,
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. GraphQL-Yoga&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;역할&lt;/b&gt;: 앱 내부의 GraphQL 엔드포인트&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동작&lt;/b&gt;: 로컬에서 세션 정보를 리졸브(Hasura Remote Schema 에 등록 안했을 경우!)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Hasura Remote Schema 연동 시의 동작 방식&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Hasura 콘솔에서 설정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;흐름&lt;/b&gt;: 앱 &amp;rarr; Hasura &amp;rarr; (JWT 검증) &amp;rarr; 앱 (GraphQL-Yoga: &lt;code&gt;/api/graphql&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Hasura가 JWT를 먼저 검증하고 &lt;code&gt;x-hasura-user-id&lt;/code&gt; 등의 세션 변수를 생성&lt;/li&gt;
&lt;li&gt;검증된 세션 변수를 Remote Schema(yoga)로 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Apollo Client 등을 통해 쿼리하는 것과의 차이점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Apollo Client 쿼리:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;역할: Hasura GraphQL로 요청을 보내는 클라이언트&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동작&lt;/b&gt;: 앱 -&amp;gt; Hasura&lt;/li&gt;
&lt;li&gt;그냥 Hasura 로 쿼리를 요청하는 것!&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;GraphQL-Yoga 만 사용시:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱 내부 로컬 호출!&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;GrapQL-Yoga + Hasura Remote Schema
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱 &amp;rarr; Hasura &amp;rarr; (JWT 검증) &amp;rarr; GraphQL-Yoga (`/api/graphql`)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li style=&quot;list-style-type: none;&quot;&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>TIL</category>
      <category>graphQL</category>
      <category>hasura</category>
      <category>remoteschema</category>
      <category>yoga</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/313</guid>
      <comments>https://ifelseif.tistory.com/313#entry313comment</comments>
      <pubDate>Fri, 24 Oct 2025 22:43:30 +0900</pubDate>
    </item>
    <item>
      <title>[251016 TIL] 테스트 환경 구축(vitest, rtl, msw, playwright)</title>
      <link>https://ifelseif.tistory.com/312</link>
      <description>&lt;h1&gt;Next.js 15 테스트 환경 구축 가이드&lt;/h1&gt;
&lt;h2&gt;0단계: 조합&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;유닛테스트: Vitest&lt;/li&gt;
&lt;li&gt;통합테스트: React Testing Library + MSW&lt;/li&gt;
&lt;li&gt;E2E테스트: Playwright&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1단계: 테스트 환경 구축&lt;/h2&gt;
&lt;h3&gt;1️⃣ Vitest 설정 (유닛 테스트)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm add -D vitest @vitejs/plugin-react jsdom
pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;vitest.config.ts&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { defineConfig } from &amp;#39;vitest/config&amp;#39;
import react from &amp;#39;@vitejs/plugin-react&amp;#39;
import path from &amp;#39;path&amp;#39;

export default defineConfig({
  plugins: [react()],
  test: {
    environment: &amp;#39;jsdom&amp;#39;,
    globals: true,
    setupFiles: [&amp;#39;./tests/setup.ts&amp;#39;],
  },
  resolve: {
    alias: {
      &amp;#39;@&amp;#39;: path.resolve(__dirname, &amp;#39;./src&amp;#39;),
    },
  },
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;tests/setup.ts&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import &amp;#39;@testing-library/jest-dom&amp;#39;
import { afterEach } from &amp;#39;vitest&amp;#39;
import { cleanup } from &amp;#39;@testing-library/react&amp;#39;

afterEach(() =&amp;gt; {
  cleanup()
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2️⃣ MSW 설정 (API 모킹)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm add -D msw@latest&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;tests/mocks/handlers.ts&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { http, HttpResponse } from &amp;#39;msw&amp;#39;

export const handlers = [
  http.post(&amp;#39;*/auth/v1/token*&amp;#39;, () =&amp;gt; {
    return HttpResponse.json({
      access_token: &amp;#39;mock-token&amp;#39;,
      user: { id: &amp;#39;user-1&amp;#39;, email: &amp;#39;test@example.com&amp;#39; },
    })
  }),
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;tests/mocks/server.ts&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { setupServer } from &amp;#39;msw/node&amp;#39;
import { handlers } from &amp;#39;./handlers&amp;#39;

export const server = setupServer(...handlers)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;tests/setup.ts에 추가&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { server } from &amp;#39;./mocks/server&amp;#39;
import { beforeAll, afterAll, afterEach } from &amp;#39;vitest&amp;#39;

beforeAll(() =&amp;gt; server.listen({ onUnhandledRequest: &amp;#39;error&amp;#39; }))
afterEach(() =&amp;gt; server.resetHandlers())
afterAll(() =&amp;gt; server.close())&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3️⃣ Playwright 설정 (E2E)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm add -D @playwright/test
pnpm dlx playwright install&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;playwright.config.ts&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { defineConfig, devices } from &amp;#39;@playwright/test&amp;#39;

export default defineConfig({
  testDir: &amp;#39;./tests/e2e&amp;#39;,
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: &amp;#39;http://localhost:3000&amp;#39;,
    trace: &amp;#39;on-first-retry&amp;#39;,
  },
  projects: [
    { name: &amp;#39;chromium&amp;#39;, use: { ...devices[&amp;#39;Desktop Chrome&amp;#39;] } },
  ],
  webServer: {
    command: &amp;#39;pnpm dev&amp;#39;,
    url: &amp;#39;http://localhost:3000&amp;#39;,
    reuseExistingServer: !process.env.CI,
  },
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4️⃣ 디렉토리 구조&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;project/
├── tests/
│   ├── setup.ts
│   ├── mocks/
│   │   ├── handlers.ts
│   │   └── server.ts
│   ├── helpers/
│   │   └── test-utils.tsx
│   ├── unit/
│   ├── integration/
│   └── e2e/
├── src/
│   └── app/&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;5️⃣ package.json 스크립트&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;scripts&amp;quot;: {
    &amp;quot;test&amp;quot;: &amp;quot;vitest&amp;quot;,
    &amp;quot;test:ui&amp;quot;: &amp;quot;vitest --ui&amp;quot;,
    &amp;quot;test:coverage&amp;quot;: &amp;quot;vitest --coverage&amp;quot;,
    &amp;quot;test:e2e&amp;quot;: &amp;quot;playwright test&amp;quot;,
    &amp;quot;test:e2e:ui&amp;quot;: &amp;quot;playwright test --ui&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2단계: TDD로 테스트 작성하기&lt;/h2&gt;
&lt;h3&gt;TDD 사이클&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Red&lt;/strong&gt;: 실패하는 테스트 작성&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Green&lt;/strong&gt;: 최소한의 코드로 테스트 통과&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Refactor&lt;/strong&gt;: 코드 개선&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;유닛 테스트 작성&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;위치&lt;/strong&gt;: &lt;code&gt;tests/unit/&lt;/code&gt; 또는 &lt;code&gt;src/**/__tests__/&lt;/code&gt;&lt;/p&gt;
&lt;h4&gt;예시 1: 유틸 함수&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// tests/unit/format-price.test.ts
import { describe, it, expect } from &amp;#39;vitest&amp;#39;
import { formatPrice } from &amp;#39;@/lib/format-price&amp;#39;

describe(&amp;#39;formatPrice&amp;#39;, () =&amp;gt; {
  it(&amp;#39;숫자를 통화 형식으로 변환한다&amp;#39;, () =&amp;gt; {
    expect(formatPrice(10000)).toBe(&amp;#39;10,000원&amp;#39;)
  })

  it(&amp;#39;0을 올바르게 처리한다&amp;#39;, () =&amp;gt; {
    expect(formatPrice(0)).toBe(&amp;#39;0원&amp;#39;)
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;예시 2: 컴포넌트&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// tests/unit/button.test.tsx
import { describe, it, expect, vi } from &amp;#39;vitest&amp;#39;
import { render, screen } from &amp;#39;@testing-library/react&amp;#39;
import userEvent from &amp;#39;@testing-library/user-event&amp;#39;
import { Button } from &amp;#39;@/components/ui/button&amp;#39;

describe(&amp;#39;Button&amp;#39;, () =&amp;gt; {
  it(&amp;#39;클릭 이벤트를 처리한다&amp;#39;, async () =&amp;gt; {
    const handleClick = vi.fn()
    const user = userEvent.setup()

    render(&amp;lt;Button onClick={handleClick}&amp;gt;클릭&amp;lt;/Button&amp;gt;)
    await user.click(screen.getByRole(&amp;#39;button&amp;#39;))

    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it(&amp;#39;disabled 상태에서는 클릭이 동작하지 않는다&amp;#39;, async () =&amp;gt; {
    const handleClick = vi.fn()
    const user = userEvent.setup()

    render(&amp;lt;Button onClick={handleClick} disabled&amp;gt;클릭&amp;lt;/Button&amp;gt;)
    await user.click(screen.getByRole(&amp;#39;button&amp;#39;))

    expect(handleClick).not.toHaveBeenCalled()
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;예시 3: Next.js 컴포넌트 모킹&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// tests/unit/product-card.test.tsx
import { vi } from &amp;#39;vitest&amp;#39;
import { render, screen } from &amp;#39;@testing-library/react&amp;#39;

// Next.js 모킹
vi.mock(&amp;#39;next/image&amp;#39;, () =&amp;gt; ({
  default: (props: any) =&amp;gt; &amp;lt;img {...props} /&amp;gt;
}))

vi.mock(&amp;#39;next/link&amp;#39;, () =&amp;gt; ({
  default: ({ children, href }: any) =&amp;gt; &amp;lt;a href={href}&amp;gt;{children}&amp;lt;/a&amp;gt;
}))

describe(&amp;#39;ProductCard&amp;#39;, () =&amp;gt; {
  it(&amp;#39;상품 정보를 렌더링한다&amp;#39;, () =&amp;gt; {
    const product = { id: &amp;#39;1&amp;#39;, name: &amp;#39;상품&amp;#39;, price: 10000 }
    render(&amp;lt;ProductCard product={product} /&amp;gt;)

    expect(screen.getByText(&amp;#39;상품&amp;#39;)).toBeInTheDocument()
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;실행&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm test                          # 전체 테스트
pnpm test --watch                  # watch 모드
pnpm test tests/unit/button        # 특정 파일&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;통합 테스트 작성&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;위치&lt;/strong&gt;: &lt;code&gt;tests/integration/&lt;/code&gt;&lt;/p&gt;
&lt;h4&gt;테스트 헬퍼 작성&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// tests/helpers/test-utils.tsx
import { ReactElement } from &amp;#39;react&amp;#39;
import { render } from &amp;#39;@testing-library/react&amp;#39;
import { QueryClient, QueryClientProvider } from &amp;#39;@tanstack/react-query&amp;#39;

export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  })
}

export function renderWithProviders(ui: ReactElement) {
  const queryClient = createTestQueryClient()

  return render(
    &amp;lt;QueryClientProvider client={queryClient}&amp;gt;
      {ui}
    &amp;lt;/QueryClientProvider&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;예시: 로그인 폼 통합 테스트&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// tests/integration/login-form.test.tsx
import { describe, it, expect, vi } from &amp;#39;vitest&amp;#39;
import { screen, waitFor } from &amp;#39;@testing-library/react&amp;#39;
import userEvent from &amp;#39;@testing-library/user-event&amp;#39;
import { server } from &amp;#39;../mocks/server&amp;#39;
import { http, HttpResponse } from &amp;#39;msw&amp;#39;
import { renderWithProviders } from &amp;#39;../helpers/test-utils&amp;#39;
import { LoginForm } from &amp;#39;@/components/auth/login-form&amp;#39;

describe(&amp;#39;LoginForm 통합 테스트&amp;#39;, () =&amp;gt; {
  it(&amp;#39;로그인에 성공한다&amp;#39;, async () =&amp;gt; {
    const onSuccess = vi.fn()
    const user = userEvent.setup()

    renderWithProviders(&amp;lt;LoginForm onSuccess={onSuccess} /&amp;gt;)

    await user.type(screen.getByLabelText(/이메일/i), &amp;#39;test@example.com&amp;#39;)
    await user.type(screen.getByLabelText(/비밀번호/i), &amp;#39;password123&amp;#39;)
    await user.click(screen.getByRole(&amp;#39;button&amp;#39;, { name: /로그인/i }))

    await waitFor(() =&amp;gt; {
      expect(onSuccess).toHaveBeenCalled()
    })
  })

  it(&amp;#39;잘못된 credentials는 에러를 표시한다&amp;#39;, async () =&amp;gt; {
    server.use(
      http.post(&amp;#39;*/auth/v1/token*&amp;#39;, () =&amp;gt; {
        return HttpResponse.json(
          { error: &amp;#39;인증 실패&amp;#39; },
          { status: 401 }
        )
      })
    )

    const user = userEvent.setup()
    renderWithProviders(&amp;lt;LoginForm /&amp;gt;)

    await user.type(screen.getByLabelText(/이메일/i), &amp;#39;wrong@example.com&amp;#39;)
    await user.type(screen.getByLabelText(/비밀번호/i), &amp;#39;wrong&amp;#39;)
    await user.click(screen.getByRole(&amp;#39;button&amp;#39;, { name: /로그인/i }))

    await waitFor(() =&amp;gt; {
      expect(screen.getByRole(&amp;#39;alert&amp;#39;)).toHaveTextContent(&amp;#39;인증 실패&amp;#39;)
    })
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;실행&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm test tests/integration&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;E2E 테스트 작성&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;위치&lt;/strong&gt;: &lt;code&gt;tests/e2e/&lt;/code&gt;&lt;/p&gt;
&lt;h4&gt;예시: 로그인 플로우&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// tests/e2e/auth.spec.ts
import { test, expect } from &amp;#39;@playwright/test&amp;#39;

test.describe(&amp;#39;인증 플로우&amp;#39;, () =&amp;gt; {
  test(&amp;#39;사용자가 로그인하고 대시보드로 이동한다&amp;#39;, async ({ page }) =&amp;gt; {
    // API 모킹
    await page.route(&amp;#39;**/auth/v1/token**&amp;#39;, async route =&amp;gt; {
      await route.fulfill({
        status: 200,
        body: JSON.stringify({
          access_token: &amp;#39;token&amp;#39;,
          user: { email: &amp;#39;test@example.com&amp;#39; }
        })
      })
    })

    await page.goto(&amp;#39;/login&amp;#39;)

    await page.getByLabel(&amp;#39;이메일&amp;#39;).fill(&amp;#39;test@example.com&amp;#39;)
    await page.getByLabel(&amp;#39;비밀번호&amp;#39;).fill(&amp;#39;password123&amp;#39;)
    await page.getByRole(&amp;#39;button&amp;#39;, { name: &amp;#39;로그인&amp;#39; }).click()

    await expect(page).toHaveURL(&amp;#39;/dashboard&amp;#39;)
    await expect(page.getByText(&amp;#39;test@example.com&amp;#39;)).toBeVisible()
  })

  test(&amp;#39;잘못된 credentials로는 로그인할 수 없다&amp;#39;, async ({ page }) =&amp;gt; {
    await page.route(&amp;#39;**/auth/v1/token**&amp;#39;, async route =&amp;gt; {
      await route.fulfill({
        status: 401,
        body: JSON.stringify({ error: &amp;#39;인증 실패&amp;#39; })
      })
    })

    await page.goto(&amp;#39;/login&amp;#39;)

    await page.getByLabel(&amp;#39;이메일&amp;#39;).fill(&amp;#39;wrong@example.com&amp;#39;)
    await page.getByLabel(&amp;#39;비밀번호&amp;#39;).fill(&amp;#39;wrong&amp;#39;)
    await page.getByRole(&amp;#39;button&amp;#39;, { name: &amp;#39;로그인&amp;#39; }).click()

    await expect(page.getByRole(&amp;#39;alert&amp;#39;)).toContainText(&amp;#39;인증 실패&amp;#39;)
    await expect(page).toHaveURL(&amp;#39;/login&amp;#39;)
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;실행&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm test:e2e                    # 전체 E2E 테스트
pnpm test:e2e --headed           # 브라우저 보면서 실행
pnpm test:e2e --debug            # 디버그 모드
pnpm test:e2e --ui               # UI 모드&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3단계: 테스트 실행과 유지보수&lt;/h2&gt;
&lt;h3&gt;일상적인 워크플로우&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;개발 중&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm test --watch                # 유닛/통합 테스트 watch&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;커밋 전&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm test                        # 전체 유닛/통합 테스트
pnpm test:e2e                    # 주요 플로우 변경 시&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;CI/CD&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm test:coverage               # 커버리지 리포트
pnpm test:e2e                    # 전체 E2E 테스트&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;MSW 핸들러 관리&lt;/h3&gt;
&lt;p&gt;새로운 API 추가 시:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// tests/mocks/handlers.ts
export const handlers = [
  // 기본 핸들러
  http.get(&amp;#39;/api/users&amp;#39;, () =&amp;gt; {
    return HttpResponse.json({ users: [] })
  }),
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특정 테스트에서 오버라이드:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;server.use(
  http.get(&amp;#39;/api/users&amp;#39;, () =&amp;gt; {
    return HttpResponse.json({ users: [/* ... */] })
  })
)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;커버리지 확인&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm test:coverage&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;수치보다는 &lt;strong&gt;누락된 시나리오&lt;/strong&gt;에 집중&lt;/li&gt;
&lt;li&gt;특히 에러 케이스, 경계 조건 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;회귀 방지&lt;/h3&gt;
&lt;p&gt;버그 발견 시:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;버그를 재현하는 테스트 작성 (실패 확인)&lt;/li&gt;
&lt;li&gt;버그 수정&lt;/li&gt;
&lt;li&gt;테스트 통과 확인&lt;/li&gt;
&lt;li&gt;테스트를 코드베이스에 유지&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;테스트 작성 팁&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;유닛&lt;/strong&gt;: 순수 함수, 단일 컴포넌트&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;통합&lt;/strong&gt;: 여러 컴포넌트 + API 상호작용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;E2E&lt;/strong&gt;: 실제 사용자 시나리오&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Given-When-Then 구조 활용&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;it(&amp;#39;사용자가 장바구니에 상품을 추가한다&amp;#39;, async () =&amp;gt; {
  // Given: 상품 페이지에서
  renderWithProviders(&amp;lt;ProductPage /&amp;gt;)

  // When: 장바구니 버튼을 클릭하면
  await user.click(screen.getByRole(&amp;#39;button&amp;#39;, { name: &amp;#39;장바구니&amp;#39; }))

  // Then: 성공 메시지가 표시된다
  expect(screen.getByText(&amp;#39;추가되었습니다&amp;#39;)).toBeInTheDocument()
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;자주 하는 실수&lt;/h3&gt;
&lt;p&gt;❌ &lt;strong&gt;너무 많은 것을 한 번에 테스트&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 나쁜 예
it(&amp;#39;전체 앱이 동작한다&amp;#39;, () =&amp;gt; { /* ... */ })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;✅ &lt;strong&gt;작고 집중된 테스트&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 좋은 예
it(&amp;#39;이메일 유효성 검사를 수행한다&amp;#39;, () =&amp;gt; { /* ... */ })
it(&amp;#39;로그인 API를 호출한다&amp;#39;, () =&amp;gt; { /* ... */ })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;❌ &lt;strong&gt;구현 세부사항 테스트&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 나쁜 예
expect(component.state.isLoading).toBe(true)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;✅ &lt;strong&gt;사용자가 보는 것 테스트&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 좋은 예
expect(screen.getByText(&amp;#39;로딩 중...&amp;#39;)).toBeInTheDocument()&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;유용한 명령어 모음&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 특정 테스트만 실행
pnpm test button

# 변경된 파일만 테스트
pnpm test --changed

# 실패한 테스트만 재실행
pnpm test --reporter=verbose --bail=1

# Playwright 특정 브라우저
pnpm test:e2e --project=chromium

# Playwright 트레이스 보기
npx playwright show-trace trace.zip&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <category>msw</category>
      <category>Playwright</category>
      <category>react-testing-library</category>
      <category>test</category>
      <category>vitest</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/312</guid>
      <comments>https://ifelseif.tistory.com/312#entry312comment</comments>
      <pubDate>Thu, 16 Oct 2025 11:20:16 +0900</pubDate>
    </item>
    <item>
      <title>[251015 TIL] 문자인증(알리고, with GQL)</title>
      <link>https://ifelseif.tistory.com/311</link>
      <description>&lt;h3&gt;0. 아래와 같은 Auth.js 기본 세팅 + apollo 세팅은 되어있다는 가정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// lib/auth.ts
import NextAuth from &amp;quot;next-auth&amp;quot;;
import Google from &amp;quot;next-auth/providers/google&amp;quot;;
import { HasuraAdapter } from &amp;quot;@auth/hasura-adapter&amp;quot;;
import { JWT } from &amp;quot;next-auth/jwt&amp;quot;;

declare module &amp;quot;next-auth/jwt&amp;quot; { 
    interface JWT { 
        id: string; 
        role: string; 
    } 
}

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: HasuraAdapter({
    endpoint: process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL!,
    adminSecret: process.env.HASURA_GRAPHQL_ADMIN_SECRET!,
  }),
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
    }),
  ],
  session: {
    strategy: &amp;quot;jwt&amp;quot;, 
    maxAge: 30 * 24 * 60 * 60, // 30일
  },
  callbacks: {
    async jwt({ token, user, account }) { 
        // 초기 로그인 시 
        if (user) { 
            token.id = user.id; 
            token.email = user.email; 
            token.role = &amp;quot;user&amp;quot;; // 기본 역할 
            // Hasura에 사용자 정보 저장 
            await createOrUpdateUser({ 
                id: user.id, 
                email: user.email!, 
                name: user.name, 
                image: user.image, 
                }); 
            } 
            return token; 
        }, 
        async session({ session, token }) { 
            if (token &amp;amp;&amp;amp; session.user) { 
                session.user.id = token.id; 
                session.user.role = token.role; 
                session.user.email = token.email!; 
            } 
            return session; 
        },
  },
});

// app/api/auth/[...nextauth]/route.ts
import { handlers } from &amp;quot;@/lib/auth&amp;quot;;

export const { GET, POST } = handlers;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1. 릴레이션 테이블 필요할 듯? 대략 이런 느낌&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- SMS 인증 코드 테이블
CREATE TABLE sms_verifications (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  phone VARCHAR(20) NOT NULL,
  code VARCHAR(6) NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  verified BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  INDEX idx_phone_code (phone, code),
  INDEX idx_expires (expires_at)
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 알리고 가입하고, 키 받기, 환경변수&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# SMS 서비스 (알리고 예시)
ALIGO_API_KEY=your-aligo-api-key
ALIGO_USER_ID=your-aligo-user-id
ALIGO_SENDER=01012345678  # 발신번호&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 6자리 인증번호 생성 유틸 함수 작성&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import crypto from &amp;quot;crypto&amp;quot;;

// 6자리 인증번호 생성
export function generateVerificationCode(): string {
  return crypto.randomInt(100000, 999999).toString();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. 알리고 api 사용한 문자 전송 함수 작성(&lt;a href=&quot;https://apis.aligo.in/send/&quot;&gt;https://apis.aligo.in/send/&lt;/a&gt;) &lt;a href=&quot;https://smartsms.aligo.in/smsapi.html&quot;&gt;https://smartsms.aligo.in/smsapi.html&lt;/a&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// 알리고 SMS 발송
export async function sendSMS(phone: string, message: string) {
  const formData = new URLSearchParams({
    key: process.env.ALIGO_API_KEY!,
    user_id: process.env.ALIGO_USER_ID!,
    sender: process.env.ALIGO_SENDER!,
    receiver: phone,
    msg: message,
    testmode_yn: process.env.NODE_ENV === &amp;quot;development&amp;quot; ? &amp;quot;Y&amp;quot; : &amp;quot;N&amp;quot;,
  });

    // 발송 api POST 요청
  try {
    const response = await fetch(&amp;quot;&amp;lt;https://apis.aligo.in/send/&amp;gt;&amp;quot;, {
      method: &amp;quot;POST&amp;quot;,
      headers: {
        &amp;quot;Content-Type&amp;quot;: &amp;quot;application/x-www-form-urlencoded&amp;quot;,
      },
      body: formData,
    });

    const result = await response.json();

    if (result.result_code !== &amp;quot;1&amp;quot;) {
      throw new Error(`SMS 발송 실패: ${result.message}`);
    }

    return result;
  } catch (error) {
    console.error(&amp;quot;SMS 발송 에러:&amp;quot;, error);
    throw error;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5. send 라우트 핸들러 작성(코드, 만료시간, 알리고 api 호출 부분)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { gql } from &amp;#39;@apollo/client&amp;#39;;

// 이런 식의 뮤테이션이 있다고 가정
const INSERT_VERIFICATION = gql`
  mutation InsertVerification($phone: String!, $code: String!, $expiresAt: timestamptz!) {
    insert_sms_verifications_one(object: {
      phone: $phone,
      code: $code,
      expires_at: $expiresAt
    }) {
      id
    }
  }
`;
// 최근 요청 확인
const CHECK_RECENT_REQUEST = gql`
  query CheckRecentRequest($phone: String!, $since: timestamptz!) {
    sms_verifications(
      where: {
        phone: { _eq: $phone },
        created_at: { _gt: $since }
      },
      limit: 1
    ) {
      id
      created_at
    }
  }
`;

// app/api/auth/sms/send/route.ts
import { NextRequest, NextResponse } from &amp;quot;next/server&amp;quot;;
import { auth } from &amp;quot;@/lib/auth&amp;quot;;
import { generateVerificationCode, sendSMS } from &amp;quot;@/lib/sms&amp;quot;;
import { getClient } from &amp;#39;@/lib/apollo/server-client&amp;#39;
import { ApolloError } from &amp;#39;@apollo/client&amp;#39;;

export async function POST(req: NextRequest) {
  try {
    // 1. 세션 확인
    const session = await auth();

    if (!session) {
      return NextResponse.json(
        { error: &amp;quot;로그인이 필요합니다&amp;quot; },
        { status: 401 }
      );
    }

    // 2. 요청 데이터 파싱
    const { phone } = await req.json();

    // 3. 전화번호 형식 검증
    const phoneRegex = /^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$/;
    if (!phoneRegex.test(phone)) {
      return NextResponse.json(
        { error: &amp;quot;올바른 전화번호 형식이 아닙니다&amp;quot; },
        { status: 400 }
      );
    }

    // 4. 하이픈 제거
    const cleanPhone = phone.replace(/-/g, &amp;quot;&amp;quot;);

    // 5. Apollo Client 가져오기
    const client = getClient();

    // 6. ⭐⭐ 레이트 리미팅: 1분 내 중복 요청 확인
    const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString();

    try {
      const { data: recentData } = await client.query({
        query: CHECK_RECENT_REQUEST,
        variables: { phone: cleanPhone, since: oneMinuteAgo },
        fetchPolicy: &amp;#39;network-only&amp;#39; // 캐시 사용 안 함
      });

      if (recentData.sms_verifications.length &amp;gt; 0) {
        const lastRequest = new Date(recentData.sms_verifications[0].created_at);
        const waitSeconds = Math.ceil((60000 - (Date.now() - lastRequest.getTime())) / 1000);

        return NextResponse.json(
          { error: `${waitSeconds}초 후에 다시 시도해주세요` },
          { status: 429 } // Too Many Requests
        );
      }
    // ⭐⭐ 명확한 에러 응답을 바로 반환 - 중복 확인 실패는 바로 리턴해서 사용자에게 명확히 알리는게 좋음
        } catch (error) {
          console.error(&amp;quot;중복 요청 확인 에러:&amp;quot;, error);

          return NextResponse.json(
            { error: &amp;quot;요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.&amp;quot; },
            { status: 500 }
          );
        }

    // 7. 인증번호 생성
    const code = generateVerificationCode();
    const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString(); // 3분

    // 8. DB에 저장
    try {
      await client.mutate({
        mutation: INSERT_VERIFICATION,
        variables: { phone: cleanPhone, code, expiresAt }
      });
    } catch (error) {
      console.error(&amp;quot;인증번호 저장 에러:&amp;quot;, error);

      if (error instanceof ApolloError) {
        console.error(&amp;quot;GraphQL 에러:&amp;quot;, error.graphQLErrors);
        return NextResponse.json(
          { error: &amp;quot;인증번호 저장에 실패했습니다&amp;quot; },
          { status: 500 }
        );
      }

      throw error; // 예상치 못한 에러는 외부 catch로
    }

    // 9. SMS 발송
    try {
      const message = `[서비스명] 인증번호는 [${code}]입니다. 3분 이내에 입력해주세요.`;
      await sendSMS(cleanPhone, message);
    } catch (error) {
      // ⭐⭐ SMS 발송 실패했지만 DB에는 저장되어 있으므로
          // 인증번호는 유효 =&amp;gt; 재발송 API 로~~
          // 이후 클라이언트에서 적절한 재시도 UI 제공 필수
          return NextResponse.json(
            { 
              error: &amp;quot;SMS 발송에 실패했습니다&amp;quot;,
              canResend: true, // ⭐⭐ 재시도 가능 플래그
              retryEndpoint: &amp;quot;/api/auth/sms/resend&amp;quot; // ⭐⭐ 재발송 API 경로
            },
            { status: 503 }
          );
    }

    // 10. 성공 응답
    return NextResponse.json({
      success: true,
      message: &amp;quot;인증번호가 발송되었습니다&amp;quot;,
    });

  } catch (error) {
    console.error(&amp;quot;SMS 발송 에러:&amp;quot;, error);

    let errorMsg = &amp;quot;인증번호 발송에 실패했습니다&amp;quot;;
    let statusCode = 500;

    if (error instanceof ApolloError) {
      console.error(&amp;quot;GraphQL 에러:&amp;quot;, error.graphQLErrors);
      console.error(&amp;quot;Network 에러:&amp;quot;, error.networkError);

      if (error.graphQLErrors.length &amp;gt; 0) {
        errorMsg = &amp;quot;데이터베이스 오류가 발생했습니다&amp;quot;;
      }

      if (error.networkError) {
        errorMsg = &amp;quot;네트워크 오류가 발생했습니다&amp;quot;;
        statusCode = 503;
      }
    }

    return NextResponse.json(
      { error: errorMsg },
      { status: statusCode }
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6. verify 라우트 핸들러 작성(인증)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { gql } from &amp;#39;@apollo/client&amp;#39;;

// 이런식의 쿼리, 뮤테이션이 있다고 가정
// 인증번호 확인
const VERIFY_CODE_QUERY = gql`
  query VerifyCode($phone: String!, $code: String!, $now: timestamptz!) {
    sms_verifications(
      where: {
        phone: { _eq: $phone },
        code: { _eq: $code },
        verified: { _eq: false },
        expires_at: { _gt: $now }  # ⭐ &amp;quot;now()&amp;quot;는 변수로 전달
      },
      order_by: { created_at: desc },
      limit: 1
    ) {
      id
    }
  }
`;

// 인증 완료 처리
const UPDATE_VERIFICATION_AND_USER = gql`
  mutation UpdateVerificationAndUser($verificationId: uuid!, $userId: uuid!, $phone: String!) {
    update_sms_verifications_by_pk(
      pk_columns: { id: $verificationId },
      _set: { verified: true }
    ) {
      id
    }
    update_users_by_pk(
      pk_columns: { id: $userId },
      _set: { phone: $phone, phone_verified: true }
    ) {
      id
    }
  }
`;

// app/api/auth/sms/verify/route.ts
import { NextRequest, NextResponse } from &amp;quot;next/server&amp;quot;;
import { auth } from &amp;quot;@/lib/auth&amp;quot;;
import { ApolloError } from &amp;#39;@apollo/client&amp;#39;;
import { getClient } from &amp;#39;@/lib/apollo/server-client&amp;#39;

export async function POST(req: NextRequest) {
  try {
    // 1. 세션 확인
    const session = await auth();

    if (!session) {
      return NextResponse.json(
        { error: &amp;quot;로그인이 필요합니다&amp;quot; },
        { status: 401 }
      );
    }

    // 2. 요청 데이터 파싱
    const { phone, code } = await req.json();

    // 3. 입력값 검증
    if (!phone || !code) {
      return NextResponse.json(
        { error: &amp;quot;전화번호와 인증번호를 입력해주세요&amp;quot; },
        { status: 400 }
      );
    }

    if (code.length !== 6 || !/^\\d+$/.test(code)) {
      return NextResponse.json(
        { error: &amp;quot;인증번호는 6자리 숫자여야 합니다&amp;quot; },
        { status: 400 }
      );
    }

    const cleanPhone = phone.replace(/-/g, &amp;quot;&amp;quot;);

    // 4. Apollo Client 가져오기
    const client = getClient();

    // 5. 인증번호 확인
    const now = new Date().toISOString();

    let queryData;
    try {
      const result = await client.query({
        query: VERIFY_CODE_QUERY,
        variables: { phone: cleanPhone, code, now },
        fetchPolicy: &amp;#39;network-only&amp;#39; // 캐시 무시
      });
      queryData = result.data;
    } catch (error) {
      console.error(&amp;quot;인증번호 조회 에러:&amp;quot;, error);

      if (error instanceof ApolloError) {
        console.error(&amp;quot;GraphQL 에러:&amp;quot;, error.graphQLErrors);
        return NextResponse.json(
          { error: &amp;quot;인증번호 확인 중 오류가 발생했습니다&amp;quot; },
          { status: 500 }
        );
      }

      throw error;
    }

    // 6. 인증번호 검증
    if (queryData.sms_verifications.length === 0) {
      return NextResponse.json(
        { error: &amp;quot;인증번호가 올바르지 않거나 만료되었습니다&amp;quot; },
        { status: 400 }
      );
    }

    const verificationId = queryData.sms_verifications[0].id;

    // 7. 인증 완료 처리
    try {
      await client.mutate({
        mutation: UPDATE_VERIFICATION_AND_USER,
        variables: {
          verificationId,
          userId: session.user.id,
          phone: cleanPhone,
        },
      });
    } catch (error) {
      console.error(&amp;quot;인증 완료 처리 에러:&amp;quot;, error);

      if (error instanceof ApolloError) {
        console.error(&amp;quot;GraphQL 에러:&amp;quot;, error.graphQLErrors);
        return NextResponse.json(
          { error: &amp;quot;인증 처리 중 오류가 발생했습니다&amp;quot; },
          { status: 500 }
        );
      }

      throw error;
    }

    // 8. 성공 응답
    return NextResponse.json({
      success: true,
      message: &amp;quot;인증이 완료되었습니다&amp;quot;,
    });

  } catch (error) {
    console.error(&amp;quot;인증 확인 에러:&amp;quot;, error);

    let errorMsg = &amp;quot;인증 확인에 실패했습니다&amp;quot;;
    let statusCode = 500;

    if (error instanceof ApolloError) {
      console.error(&amp;quot;GraphQL 에러:&amp;quot;, error.graphQLErrors);
      console.error(&amp;quot;Network 에러:&amp;quot;, error.networkError);

      if (error.graphQLErrors.length &amp;gt; 0) {
        errorMsg = &amp;quot;데이터베이스 오류가 발생했습니다&amp;quot;;
      }

      if (error.networkError) {
        errorMsg = &amp;quot;네트워크 오류가 발생했습니다&amp;quot;;
        statusCode = 503;
      }
    }

    return NextResponse.json(
      { error: errorMsg },
      { status: statusCode }
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7. resend 재발송 라우트 핸들러 (5. send 에서 sms 발송 실패시 클라이언트에서 retryEndpoint로 호출)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { gql } from &amp;#39;@apollo/client&amp;#39;;

const GET_LATEST_CODE = gql`
  query GetLatestCode($phone: String!, $since: timestamptz!) {
    sms_verifications(
      where: {
        phone: { _eq: $phone },
        verified: { _eq: false },
        expires_at: { _gt: &amp;quot;now()&amp;quot; },
        created_at: { _gt: $since }
      },
      order_by: { created_at: desc },
      limit: 1
    ) {
      id
      code
      expires_at
    }
  }
`;

// app/api/auth/sms/resend/route.ts
import { NextRequest, NextResponse } from &amp;quot;next/server&amp;quot;;
import { auth } from &amp;quot;@/lib/auth&amp;quot;;
import { sendSMS } from &amp;quot;@/lib/sms&amp;quot;;
import { getClient } from &amp;#39;@/lib/apollo/server-client&amp;#39;;
import { ApolloError } from &amp;#39;@apollo/client&amp;#39;;

export async function POST(req: NextRequest) {
  try {
    const session = await auth();

    if (!session) {
      return NextResponse.json(
        { error: &amp;quot;로그인이 필요합니다&amp;quot; },
        { status: 401 }
      );
    }

    const { phone } = await req.json();
    const cleanPhone = phone.replace(/-/g, &amp;quot;&amp;quot;);

    const client = getClient();

    // ⭐ 최근 5분 내 생성된 인증번호 조회
    const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();

    const { data } = await client.query({
      query: GET_LATEST_CODE,
      variables: { phone: cleanPhone, since: fiveMinutesAgo },
      fetchPolicy: &amp;#39;network-only&amp;#39;
    });

    if (data.sms_verifications.length === 0) {
      return NextResponse.json(
        { error: &amp;quot;유효한 인증번호가 없습니다. 처음부터 다시 시도해주세요.&amp;quot; },
        { status: 404 }
      );
    }

    const { code, expires_at } = data.sms_verifications[0];

    // ⭐ 기존 인증번호로 SMS 재발송
    const message = `[서비스명] 인증번호는 [${code}]입니다. 3분 이내에 입력해주세요.`;

    try {
      await sendSMS(cleanPhone, message);
    } catch (error) {
      console.error(&amp;quot;SMS 재발송 에러:&amp;quot;, error);
      return NextResponse.json(
        { error: &amp;quot;SMS 재발송에 실패했습니다&amp;quot; },
        { status: 503 }
      );
    }

    return NextResponse.json({
      success: true,
      message: &amp;quot;인증번호가 재발송되었습니다&amp;quot;,
      expiresAt: expires_at
    });

  } catch (error) {
    console.error(&amp;quot;재발송 에러:&amp;quot;, error);

    let errorMsg = &amp;quot;재발송에 실패했습니다&amp;quot;;
    let statusCode = 500;

    if (error instanceof ApolloError) {
      console.error(&amp;quot;GraphQL 에러:&amp;quot;, error.graphQLErrors);
      console.error(&amp;quot;Network 에러:&amp;quot;, error.networkError);

      if (error.graphQLErrors.length &amp;gt; 0) {
        errorMsg = &amp;quot;데이터베이스 오류가 발생했습니다&amp;quot;;
      }

      if (error.networkError) {
        errorMsg = &amp;quot;네트워크 오류가 발생했습니다&amp;quot;;
        statusCode = 503;
      }
    }

    return NextResponse.json(
      { error: errorMsg },
      { status: statusCode }
    );
  }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>TIL</category>
      <category>graphQL</category>
      <category>SMS</category>
      <category>문자인증</category>
      <category>알리고</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/311</guid>
      <comments>https://ifelseif.tistory.com/311#entry311comment</comments>
      <pubDate>Wed, 15 Oct 2025 08:00:34 +0900</pubDate>
    </item>
    <item>
      <title>[251011 TIL] queryFn 에서 메서드 사용시 주의점</title>
      <link>https://ifelseif.tistory.com/310</link>
      <description>&lt;h1&gt;React Query에서 클래스 메서드를 queryFn으로 사용할 때 주의사항&lt;/h1&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;React Query를 사용하다가 다음과 같은 에러를 만났습니다:&lt;/p&gt;&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;Cannot read properties of undefined (reading 'get')&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 다음과 같았습니다:&lt;/p&gt;&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;// queries.ts
export const useUserQuery = () =&amp;gt; {
  return useQuery({
    queryKey: ['user'],
    queryFn: api.getUser, // 이 부분이 문제!
  });
};

// apis.ts
class ApiClient extends BaseApiClient {
  public getUser() {
    return this.get&amp;lt;User&amp;gt;(&quot;/api/auth/user&quot;);
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;기본적인 문제죠..? 바로 this, 화살표함수 문제가 딱 떠올라야 하겠죠..?&lt;br&gt;하지만 저는 오늘도 바로 생각해내지 못했답니다. 헛공부...&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인: JavaScript의 this 바인딩 손실&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript에서 메서드를 다른 곳에 전달할 때, &lt;b&gt;this 컨텍스트가 손실&lt;/b&gt;됩니다.&lt;/p&gt;&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const api = new ApiClient();

// 직접 호출: this가 api 인스턴스를 가리킴
api.getUser(); // ✅ 정상 작동

// 메서드를 변수에 할당: this 컨텍스트 손실
const getUserFn = api.getUser;
getUserFn(); // ❌ this는 undefined&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;React Query의 &lt;code&gt;queryFn&lt;/code&gt;에 메서드를 전달하면, React Query가 나중에 그 함수를 호출할 때 원래의 인스턴스 컨텍스트 없이 호출하게 됩니다. 따라서 &lt;code&gt;this.get&lt;/code&gt;을 찾을 수 없게 되는 것입니다.&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법 1: 화살표 함수로 변경 (그냥 이걸로 하면 되는 것...)&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;화살표 함수는 렉시컬 스코프를 사용&lt;/b&gt;하여 정의될 당시의 &lt;code&gt;this&lt;/code&gt;를 영구적으로 바인딩합니다.&lt;/p&gt;&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;// apis.ts
class ApiClient extends BaseApiClient {
  // 일반 메서드 대신 화살표 함수로 정의
  public getUser = () =&amp;gt; {
    return this.get&amp;lt;User&amp;gt;(&quot;/api/auth/user&quot;);
  };

  public logOut = () =&amp;gt; {
    return this.get(&quot;/api/auth/logout&quot;);
  };
}

const api = new ApiClient();
export default api;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// queries.ts
export const useUserQuery = () =&amp;gt; {
  return useQuery({
    queryKey: ['user'],
    queryFn: api.getUser, // ✅ 이제 안전하게 사용 가능!
  });
};&lt;/code&gt;&lt;/pre&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법 2: 래퍼 함수 사용&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;화살표 함수로 감싸서 명시적으로 컨텍스트를 유지할 수도 있습니다:&lt;/p&gt;&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;export const useUserQuery = () =&amp;gt; {
  return useQuery({
    queryKey: ['user'],
    queryFn: () =&amp;gt; api.getUser(), // 화살표 함수로 감싸기
  });
};&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 방법은 모든 사용처에서 매번 래핑해야 하므로 번거롭습니다.&lt;br&gt;그리구.. tanstack 의 client 측 공식 가이드와도 다름&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법 3: bind 사용&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;생성자에서 메서드를 바인딩할 수도 있습니다:&lt;/p&gt;&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class ApiClient extends BaseApiClient {
  constructor() {
    super(config);
    this.getUser = this.getUser.bind(this);
    this.logOut = this.logOut.bind(this);
  }

  public getUser() {
    return this.get&amp;lt;User&amp;gt;(&quot;/api/auth/user&quot;);
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 메서드가 많아질수록 관리가 어려워집니다.&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;React Query의 &lt;code&gt;queryFn&lt;/code&gt;이나 콜백으로 클래스 메서드를 전달할 때는 &lt;b&gt;화살표 함수로 정의&lt;/b&gt;하는 것이 가장 안전하고 깔끔한 방법입니다. 화살표 함수는 정의 시점의 &lt;code&gt;this&lt;/code&gt;를 영구적으로 캡처하므로, 어디서 호출되든 항상 올바른 컨텍스트를 유지합니다.&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;일반 메서드 vs 화살표 함수&lt;/h3&gt;&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class Example {
  // ❌ 일반 메서드: this 컨텍스트가 호출 시점에 결정
  public method() {
    return this.something;
  }

  // ✅ 화살표 함수: this 컨텍스트가 정의 시점에 고정
  public arrowMethod = () =&amp;gt; {
    return this.something;
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴은 React Query뿐만 아니라 이벤트 핸들러, setTimeout, Promise 콜백 등 메서드를 전달하는 모든 상황에서 유용하게 사용할 수 있습니다.&lt;/p&gt;</description>
      <category>TIL</category>
      <category>arrowfn</category>
      <category>queryfn</category>
      <category>tanstack</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/310</guid>
      <comments>https://ifelseif.tistory.com/310#entry310comment</comments>
      <pubDate>Sat, 11 Oct 2025 14:54:43 +0900</pubDate>
    </item>
    <item>
      <title>[251009 TIL] Hasura 트랜잭션 처리</title>
      <link>https://ifelseif.tistory.com/309</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;Hasura 트랜잭션 처리: Actions와 Functions 활용하기&lt;/h4&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  TL;DR (한 줄 요약)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hasura v2는 &lt;b&gt;단일 mutation 내부는 자동 트랜잭션&lt;/b&gt;이지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 로직은 &lt;b&gt;Actions&lt;/b&gt; 또는 &lt;b&gt;PostgreSQL Functions&lt;/b&gt;로 처리해야 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  핵심 정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;단일 GraphQL mutation&lt;/b&gt;은 자동으로 트랜잭션 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;여러 mutation을 별도 호출&lt;/b&gt;하면 트랜잭션 아님&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Hasura Actions&lt;/b&gt;로 커스텀 비즈니스 로직 구현 (추천)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PostgreSQL Functions (RPC)&lt;/b&gt;로 DB 레벨 트랜잭션 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Apollo Client의 배치&lt;/b&gt;는 서버 트랜잭션이 아님&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Hasura의 트랜잭션 동작 방식&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 자동 트랜잭션 (보장됨)&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# 하나의 mutation 요청 = 하나의 트랜잭션
mutation CreateOrderWithItems {
  # 1. 주문 생성
  insert_orders_one(object: { total: 10000 }) {
    id
  }

  # 2. 주문 항목 생성
  insert_order_items(objects: [
    { product_id: &quot;prod-1&quot;, quantity: 2 }
  ]) {
    affected_rows
  }
}
# ✅ 둘 다 성공 or 둘 다 실패&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ 트랜잭션 아님 (주의!)&lt;/h3&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// Apollo Client에서 별도 호출
await client.mutate({ mutation: CREATE_ORDER });
await client.mutate({ mutation: CREATE_ITEMS });
// ❌ 첫 번째 성공, 두 번째 실패 &amp;rarr; 롤백 안 됨!&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 자동 트랜잭션 (단일 Mutation)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 1: 중첩 Insert (1:N 관계)&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;mutation CreatePostWithComments {
  insert_posts_one(
    object: {
      title: &quot;새 게시글&quot;
      content: &quot;내용&quot;
      comments: {
        data: [
          { text: &quot;댓글1&quot; }
          { text: &quot;댓글2&quot; }
        ]
      }
    }
  ) {
    id
    title
    comments {
      id
      text
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동작:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;posts&lt;/code&gt; INSERT 성공 + &lt;code&gt;comments&lt;/code&gt; INSERT 성공 &amp;rarr; 모두 커밋&lt;/li&gt;
&lt;li&gt;&lt;code&gt;comments&lt;/code&gt; INSERT 실패 &amp;rarr; 모두 롤백&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 2: 여러 테이블 동시 업데이트&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;mutation UpdateUserAndProfile {
  # 하나의 mutation에 포함되면 트랜잭션!
  update_users_by_pk(
    pk_columns: { id: &quot;user-1&quot; }
    _set: { name: &quot;새이름&quot; }
  ) {
    id
  }

  update_profiles_by_pk(
    pk_columns: { user_id: &quot;user-1&quot; }
    _set: { status: &quot;active&quot; }
  ) {
    user_id
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 3: Upsert (Insert or Update)&lt;/h3&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;mutation UpsertUser {
  insert_users_one(
    object: { id: &quot;user-1&quot;, name: &quot;blahblah&quot; }
    on_conflict: {
      constraint: users_pkey
      update_columns: [name]
    }
  ) {
    id
    name
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해결책 1: Hasura Actions&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Actions란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hasura Actions는 &lt;b&gt;커스텀 비즈니스 로직을 REST/GraphQL 엔드포인트&lt;/b&gt;로 구현하는 기능.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용 시기&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복잡한 비즈니스 로직&lt;/li&gt;
&lt;li&gt;외부 API 호출 필요&lt;/li&gt;
&lt;li&gt;트랜잭션 + 복잡한 검증&lt;/li&gt;
&lt;li&gt;Hasura mutation만으로 불가능한 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현 예제: Next.js + Actions&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1단계: Next.js API Route 생성&lt;/h4&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// app/api/hasura/create-order/route.ts
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

export async function POST(request: Request) {
  const { input, session_variables } = await request.json();
  const { user_id, items } = input;

  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // 1. 재고 확인 및 차감
    for (const item of items) {
      const stock = await client.query(
        'SELECT quantity FROM products WHERE id = $1 FOR UPDATE',
        [item.product_id]
      );

      if (stock.rows[0].quantity &amp;lt; item.quantity) {
        throw new Error(`재고 부족: ${item.product_id}`);
      }

      await client.query(
        'UPDATE products SET quantity = quantity - $1 WHERE id = $2',
        [item.quantity, item.product_id]
      );
    }

    // 2. 주문 생성
    const orderResult = await client.query(
      'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id',
      [user_id, items.reduce((sum, i) =&amp;gt; sum + i.price * i.quantity, 0)]
    );

    const orderId = orderResult.rows[0].id;

    // 3. 주문 항목 생성
    for (const item of items) {
      await client.query(
        'INSERT INTO order_items (order_id, product_id, quantity, price) VALUES ($1, $2, $3, $4)',
        [orderId, item.product_id, item.quantity, item.price]
      );
    }

    await client.query('COMMIT');

    return Response.json({
      order_id: orderId,
      success: true
    });

  } catch (error) {
    await client.query('ROLLBACK');
    return Response.json(
      { message: error.message },
      { status: 400 }
    );
  } finally {
    client.release();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2단계: Hasura Console에서 Action 정의&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Hasura Console &amp;rarr; Actions &amp;rarr; Create

type Mutation {
  createOrder(
    user_id: uuid!
    items: [OrderItemInput!]!
  ): CreateOrderOutput
}

input OrderItemInput {
  product_id: uuid!
  quantity: Int!
  price: Int!
}

type CreateOrderOutput {
  order_id: uuid!
  success: Boolean!
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Handler URL:&lt;/b&gt; &lt;code&gt;https://your-domain.com/api/hasura/create-order&lt;/code&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3단계: Apollo Client에서 사용&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// hooks/useCreateOrder.ts
import { gql, useMutation } from '@apollo/client';

const CREATE_ORDER = gql`
  mutation CreateOrder($user_id: uuid!, $items: [OrderItemInput!]!) {
    createOrder(user_id: $user_id, items: $items) {
      order_id
      success
    }
  }
`;

export function useCreateOrder() {
  const [createOrder, { loading, error }] = useMutation(CREATE_ORDER);

  return {
    createOrder,
    loading,
    error
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// components/CheckoutButton.tsx
'use client';

import { useCreateOrder } from '@/hooks/useCreateOrder';

export function CheckoutButton({ userId, items }) {
  const { createOrder, loading } = useCreateOrder();

  const handleCheckout = async () =&amp;gt; {
    try {
      const { data } = await createOrder({
        variables: {
          user_id: userId,
          items: items.map(item =&amp;gt; ({
            product_id: item.id,
            quantity: item.quantity,
            price: item.price
          }))
        }
      });

      if (data.createOrder.success) {
        alert('주문이 완료되었습니다!');
      }
    } catch (error) {
      alert('주문 실패: ' + error.message);
    }
  };

  return (
    &amp;lt;button onClick={handleCheckout} disabled={loading}&amp;gt;
      {loading ? '처리 중...' : '주문하기'}
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Actions의 장점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ 복잡한 비즈니스 로직을 TypeScript/JavaScript로 작성&lt;/li&gt;
&lt;li&gt;✅ 외부 API 호출 가능 (결제, 이메일 등)&lt;/li&gt;
&lt;li&gt;✅ 트랜잭션 완전 제어&lt;/li&gt;
&lt;li&gt;✅ 에러 처리 자유롭게 구현&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 해결책 2: PostgreSQL Functions&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Functions (RPC) 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase와 동일한 방식!&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- Hasura Console &amp;rarr; Data &amp;rarr; SQL
CREATE OR REPLACE FUNCTION create_order_with_items(
  p_user_id UUID,
  p_items JSONB
)
RETURNS JSON
LANGUAGE plpgsql
AS $$
DECLARE
  v_order_id UUID;
  v_item JSONB;
BEGIN
  -- 주문 생성
  INSERT INTO orders (user_id, total)
  VALUES (
    p_user_id,
    (SELECT SUM((item-&amp;gt;&amp;gt;'quantity')::INT * (item-&amp;gt;&amp;gt;'price')::INT)
     FROM jsonb_array_elements(p_items) AS item)
  )
  RETURNING id INTO v_order_id;

  -- 주문 항목 생성
  FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
  LOOP
    INSERT INTO order_items (order_id, product_id, quantity, price)
    VALUES (
      v_order_id,
      (v_item-&amp;gt;&amp;gt;'product_id')::UUID,
      (v_item-&amp;gt;&amp;gt;'quantity')::INT,
      (v_item-&amp;gt;&amp;gt;'price')::INT
    );
  END LOOP;

  RETURN json_build_object('order_id', v_order_id, 'success', true);

EXCEPTION
  WHEN OTHERS THEN
    RETURN json_build_object('success', false, 'error', SQLERRM);
END;
$$;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Hasura에서 Function 추가&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Data &amp;rarr; Schema &amp;rarr; public &amp;rarr; Functions &amp;rarr; Track&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Function이 GraphQL에 자동 추가됨&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GraphQL로 호출&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;mutation CreateOrder {
  create_order_with_items(
    args: {
      p_user_id: &quot;user-1&quot;
      p_items: [
        { product_id: &quot;prod-1&quot;, quantity: 2, price: 10000 }
        { product_id: &quot;prod-2&quot;, quantity: 1, price: 5000 }
      ]
    }
  ) {
    order_id
    success
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Functions의 장점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ 데이터베이스 레벨 트랜잭션&lt;/li&gt;
&lt;li&gt;✅ SQL 최적화 가능&lt;/li&gt;
&lt;li&gt;✅ 별도 서버 불필요&lt;/li&gt;
&lt;li&gt;✅ Hasura 권한 시스템 통합&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Apollo Client와 트랜잭션&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ 배치는 트랜잭션이 아님&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { ApolloLink } from '@apollo/client';
import { BatchHttpLink } from '@apollo/client/link/batch-http';

// 여러 요청을 묶어서 보내기
const batchLink = new BatchHttpLink({
  uri: 'https://your-hasura.hasura.app/v1/graphql',
  batchMax: 5,
  batchInterval: 20
});

// 하지만 서버에서는 별도 트랜잭션!
await client.mutate({ mutation: MUTATION_1 });
await client.mutate({ mutation: MUTATION_2 });
// ❌ 네트워크는 최적화되지만 트랜잭션 아님&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 단일 mutation으로 작성&lt;/h3&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# 하나의 mutation에 모두 포함
mutation BatchUpdate {
  update1: update_users(...) { id }
  update2: update_profiles(...) { user_id }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Optimistic Updates (낙관적 업데이트)&lt;/h3&gt;
&lt;pre class=&quot;moonscript&quot;&gt;&lt;code&gt;const [updateUser] = useMutation(UPDATE_USER, {
  optimisticResponse: {
    update_users_by_pk: {
      __typename: 'users',
      id: userId,
      name: newName
    }
  },
  onError: (error) =&amp;gt; {
    // 실패 시 UI 자동 롤백
    console.error('Update failed:', error);
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 패턴 비교 {#6-패턴-비교}&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법별 비교&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방법&lt;/th&gt;
&lt;th&gt;트랜잭션&lt;/th&gt;
&lt;th&gt;복잡도&lt;/th&gt;
&lt;th&gt;유연성&lt;/th&gt;
&lt;th&gt;추천 상황&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;단일 Mutation&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;간단한 관계 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Actions (Next.js)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;복잡한 로직, 외부 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;PostgreSQL Functions&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;DB 중심 로직&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;별도 Mutation&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;사용 금지 (트랜잭션 X)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실전 가이드&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ✅ 간단한 INSERT &amp;rarr; 단일 mutation
mutation {
  insert_posts_one(object: { 
    title: &quot;제목&quot;
    comments: { data: [{ text: &quot;댓글&quot; }] }
  }) { id }
}

// ✅ 복잡한 로직 &amp;rarr; Actions
- 재고 확인 + 주문 생성 + 결제 + 이메일
- Next.js API Route로 구현

// ✅ DB 중심 로직 &amp;rarr; Functions
- 집계, 통계 계산
- 복잡한 데이터 변환
- PostgreSQL Function으로 구현&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  결론&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 기억사항&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;하나의 mutation = 하나의 트랜잭션&lt;/b&gt;: Hasura의 기본 동작&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복잡한 로직은 Actions&lt;/b&gt;: Next.js와 조합이 최고&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DB 중심 로직은 Functions&lt;/b&gt;: Supabase와 동일한 패턴&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Apollo Client 배치 &amp;ne; 트랜잭션&lt;/b&gt;: 착각하지 말 것&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Hasura vs Supabase 트랜잭션 비교&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;측면&lt;/th&gt;
&lt;th&gt;Hasura&lt;/th&gt;
&lt;th&gt;Supabase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;기본 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;GraphQL Mutation&lt;/td&gt;
&lt;td&gt;RPC Functions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;자동 트랜잭션&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅ 단일 mutation&lt;/td&gt;
&lt;td&gt;❌ 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;커스텀 로직&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Actions&lt;/td&gt;
&lt;td&gt;Route Handler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;DB Functions&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅ Track&lt;/td&gt;
&lt;td&gt;✅ RPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;학습 곡선&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;GraphQL 익숙하면 쉬움&lt;/td&gt;
&lt;td&gt;SQL 익숙하면 쉬움&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;권장 아키텍처&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;간단한 CRUD
&amp;darr;
단일 GraphQL Mutation (자동 트랜잭션)

복잡한 비즈니스 로직
&amp;darr;
Hasura Actions (Next.js API Route)

DB 중심 로직
&amp;darr;
PostgreSQL Functions (RPC)&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://hasura.io/docs/latest/actions/overview/&quot;&gt;Hasura Actions 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hasura.io/docs/latest/schema/postgres/custom-functions/&quot;&gt;PostgreSQL Functions in Hasura&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.apollographql.com/docs/react/&quot;&gt;Apollo Client Transactions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hasura.io/docs/3.0/getting-started/overview/&quot;&gt;Hasura v3 (DDN) 변경사항&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TIL</category>
      <category>graphQL</category>
      <category>hasura</category>
      <category>transaction</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/309</guid>
      <comments>https://ifelseif.tistory.com/309#entry309comment</comments>
      <pubDate>Thu, 9 Oct 2025 19:46:34 +0900</pubDate>
    </item>
    <item>
      <title>[251009 TIL] PostgREST? (supabase vs hasura)</title>
      <link>https://ifelseif.tistory.com/308</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;PostgREST와 Hasura의 관계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Hasura는 PostgREST를 사용하지 않습니다.&lt;/b&gt;&lt;br /&gt;Hasura와 PostgREST는 완전히 &lt;b&gt;별개의 프로젝트&lt;/b&gt;!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;차이점 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;아키텍처&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Supabase:
PostgreSQL &amp;rarr; PostgREST &amp;rarr; REST API &amp;rarr; Supabase JS Client

Hasura:
PostgreSQL &amp;rarr; Hasura Engine &amp;rarr; GraphQL API &amp;rarr; Apollo Client&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기술 스택&lt;/h3&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;Supabase&lt;/th&gt;
&lt;th&gt;Hasura&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;프로토콜&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;REST (PostgREST 사용)&lt;/td&gt;
&lt;td&gt;GraphQL (자체 엔진)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;쿼리 언어&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;URL + 체이닝&lt;/td&gt;
&lt;td&gt;GraphQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;엔진&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;PostgREST (Haskell)&lt;/td&gt;
&lt;td&gt;Hasura Engine (Haskell)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;철학&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;REST + GraphQL 스타일 쿼리&lt;/td&gt;
&lt;td&gt;순수 GraphQL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 블로그 글의 의미&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.pages.kr/2883&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;PostgREST?&lt;/a&gt;&amp;nbsp;&amp;lt;&amp;lt; 해당 블로그는 &lt;b&gt;&quot;PostgREST 단독 사용&quot;&lt;/b&gt;에 대한 내용.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;옵션 1: PostgREST만 단독 사용
PostgreSQL &amp;rarr; PostgREST &amp;rarr; REST API

옵션 2: Supabase 사용 (PostgREST 포함)
PostgreSQL &amp;rarr; PostgREST &amp;rarr; Supabase Services &amp;rarr; Client

옵션 3: Hasura 사용 (PostgREST 없음)
PostgreSQL &amp;rarr; Hasura &amp;rarr; GraphQL API&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hasura에 PostgREST를 추가할 수 있나?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이론적으로는 가능&lt;/h3&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;PostgreSQL
├── Hasura (GraphQL)
└── PostgREST (REST)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 DB에 두 개 다 연결할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;하지만 실무에서는...&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;❌ 권장하지 않습니다!&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;이유 1: 중복된 기능&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// PostgREST
const { data } = await fetch('/posts?select=*,users(*)')

// Hasura
const { data } = await client.query({
  query: gql`{ posts { id users { name } } }`
})

// 똑같은 걸 두 가지 방법으로?&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;이유 2: 권한 관리 복잡도&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- PostgREST: Row Level Security
CREATE POLICY &quot;users_policy&quot; ON posts
USING (auth.uid() = user_id);

-- Hasura: Permissions
-- Hasura Console에서 별도 설정

-- 두 곳에서 따로 관리해야 함!  &lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;이유 3: 유지보수 부담&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;두 개의 서버 운영&lt;/li&gt;
&lt;li&gt;두 개의 설정 관리&lt;/li&gt;
&lt;li&gt;두 개의 모니터링&lt;/li&gt;
&lt;li&gt;팀원들의 혼란&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언제 PostgREST를 고려할까?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 1: Hasura 없이 가벼운 REST API&lt;/h3&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;PostgreSQL &amp;rarr; PostgREST &amp;rarr; REST API

장점:
- 매우 가볍고 빠름
- 설정 거의 없음
- GraphQL 학습 불필요&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하지만 Supabase를 쓰는 게 더 나음!&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 2: 레거시 시스템 통합&lt;/h3&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;기존 PostgreSQL DB
├── 기존 서비스 (변경 불가)
└── PostgREST (새로운 읽기 전용 API)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 경우에도 Hasura가 더 강력함!&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Supabase vs Hasura 선택 가이드&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Supabase를 선택!&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;빠른 프로토타이핑&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 설정 거의 없이 바로 시작
const supabase = createClient(url, key);
const { data } = await supabase.from('posts').select('*');&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;간단한 CRUD 중심&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;Next.js와 통합&lt;/b&gt; (Auth, Storage 등)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;백엔드 경험 적음&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;Firebase 대체&lt;/b&gt; 찾는 경우&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Hasura를 선택!&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;복잡한 데이터 관계&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;query {
  users {
    posts(where: { likes: { _gt: 100 } }) {
      comments_aggregate { aggregate { count } }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;GraphQL 생태계&lt;/b&gt; 활용 (Apollo, Relay 등)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;세밀한 권한 제어&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;마이크로서비스 통합&lt;/b&gt; (여러 DB, REST API)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;팀이 GraphQL에 익숙&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 조합 추천&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;권장 ✅&lt;/h3&gt;
&lt;pre class=&quot;x86asm&quot;&gt;&lt;code&gt;// Option 1: Supabase 올인원
PostgreSQL + PostgREST + Auth + Storage + Realtime
&amp;rarr; Supabase JS Client

// Option 2: Hasura + Next.js
PostgreSQL &amp;rarr; Hasura GraphQL
&amp;rarr; Apollo Client + Next.js

// Option 3: 하이브리드 (고급)
PostgreSQL
├── Hasura (복잡한 쿼리)
└── Supabase (Auth, Storage)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;비권장 ❌&lt;/h3&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// PostgreSQL + Hasura + PostgREST
// 너무 복잡하고 불필요함!

// 둘 중 하나만 선택하세요&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프로젝트 규모별 추천&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;작은 프로젝트 / MVP:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;✅ Supabase
- 설정 간단
- Next.js Auth 통합 쉬움
- 배포 빠름&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;중간 프로젝트:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;✅ Supabase 또는 Hasura 둘 다 OK
- 데이터 관계 복잡 &amp;rarr; Hasura
- CRUD 중심 &amp;rarr; Supabase&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;큰 프로젝트 / 복잡한 도메인:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;✅ Hasura
- GraphQL의 강력함
- 세밀한 권한 제어
- 확장성&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 답변 요약&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Hasura는 PostgREST를 사용하지 않음&lt;/b&gt; ❌&lt;/li&gt;
&lt;li&gt;&lt;b&gt;둘은 완전히 다른 접근 방식&lt;/b&gt; (GraphQL vs REST)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;굳이 함께 쓸 필요 없음&lt;/b&gt; ❌&lt;/li&gt;
&lt;li&gt;&lt;b&gt;하나만 선택하세요!&lt;/b&gt; ✅&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;선택 기준&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;GraphQL 선호 + 복잡한 쿼리 &amp;rarr; Hasura
REST 선호 + 간단한 CRUD &amp;rarr; Supabase
올인원 솔루션 &amp;rarr; Supabase
기업용 / 확장성 &amp;rarr; Hasura&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PostgREST 단독 사용?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;No!&lt;/b&gt; 그냥 Supabase 쓰세요  &lt;br /&gt;Supabase = PostgREST + Auth + Storage + Realtime + Dashboard + CLI + ...&lt;/p&gt;</description>
      <category>TIL</category>
      <category>hasura</category>
      <category>postgrest</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/308</guid>
      <comments>https://ifelseif.tistory.com/308#entry308comment</comments>
      <pubDate>Thu, 9 Oct 2025 19:35:11 +0900</pubDate>
    </item>
    <item>
      <title>[251009 TIL] PostgREST + join 활용하기</title>
      <link>https://ifelseif.tistory.com/307</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;Supabase의 PostgREST: GraphQL처럼 쿼리하는 SQL&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  TL;DR (한 줄 요약)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase는 PostgREST를 통해 &lt;b&gt;GraphQL의 선언적 쿼리 스타일&lt;/b&gt;을 REST API로 구현하여, SQL JOIN을 직관적인 체이닝 메서드로 사용할 수 있게 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  핵심 정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Supabase JS는 SQL을 직접 쓰지 않고 체이닝 메서드 사용&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PostgREST&lt;/b&gt;는 GraphQL의 장점과 REST의 단순함을 결합한 프로토콜&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Foreign Key 기반으로 자동 관계 생성&lt;/b&gt; - 별도 설정 불필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;중첩 쿼리&lt;/b&gt;로 복잡한 JOIN도 간단하게 표현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TypeScript 타입 자동 생성&lt;/b&gt;으로 완벽한 타입 안정성&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. SQL JOIN의 전통적 방식&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전통적인 SQL JOIN&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 게시글 + 작성자 정보
SELECT 
  posts.id,
  posts.title,
  posts.content,
  users.name,
  users.avatar_url
FROM posts
LEFT JOIN users ON posts.user_id = users.id
WHERE posts.is_published = true
ORDER BY posts.created_at DESC
LIMIT 10;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제점&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Over-fetching&lt;/b&gt;: 필요 없는 컬럼도 모두 가져옴&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Under-fetching&lt;/b&gt;: 추가 데이터가 필요하면 별도 쿼리 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복잡한 중첩&lt;/b&gt;: 댓글 + 댓글 작성자까지 가져오려면 복잡해짐&lt;/li&gt;
&lt;li&gt;&lt;b&gt;타입 안정성 부족&lt;/b&gt;: SQL 결과를 수동으로 타이핑&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. PostgREST의 철학&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PostgREST란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostgreSQL 데이터베이스를 자동으로 &lt;b&gt;RESTful API&lt;/b&gt;로 변환해주는 도구.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GraphQL과의 비교&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;GraphQL 쿼리&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;query {
  posts {
    id
    title
    user {
      name
      avatar
    }
    comments {
      text
      author {
        name
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Supabase (PostgREST) 쿼리&lt;/h4&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;const { data } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    user:users (
      name,
      avatar
    ),
    comments (
      text,
      author:users (
        name
      )
    )
  `);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;거의 똑같습니다!&lt;/b&gt;  &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PostgREST의 장점&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;th&gt;PostgREST&lt;/th&gt;
&lt;th&gt;GraphQL&lt;/th&gt;
&lt;th&gt;전통 REST&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;설정 복잡도&lt;/td&gt;
&lt;td&gt;✅ 낮음 (FK만)&lt;/td&gt;
&lt;td&gt;⚠️ 높음 (스키마+리졸버)&lt;/td&gt;
&lt;td&gt;⚠️ 중간 (엔드포인트)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;쿼리 유연성&lt;/td&gt;
&lt;td&gt;✅ 높음&lt;/td&gt;
&lt;td&gt;✅ 높음&lt;/td&gt;
&lt;td&gt;❌ 낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Over-fetching 방지&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;타입 안정성&lt;/td&gt;
&lt;td&gt;✅ (자동생성)&lt;/td&gt;
&lt;td&gt;✅ (codegen)&lt;/td&gt;
&lt;td&gt;⚠️ (수동)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;학습 곡선&lt;/td&gt;
&lt;td&gt;✅ 낮음&lt;/td&gt;
&lt;td&gt;⚠️ 높음&lt;/td&gt;
&lt;td&gt;✅ 낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 개념: Foreign Key = 자동 관계&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- Foreign Key만 설정하면 끝!
CREATE TABLE posts (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES users(id),  -- &amp;larr; 이것만으로 충분
  title TEXT
);

CREATE TABLE comments (
  id UUID PRIMARY KEY,
  post_id UUID REFERENCES posts(id),  -- &amp;larr; 이것만으로 충분
  user_id UUID REFERENCES users(id),
  text TEXT
);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 자동으로 관계 쿼리 가능!
.select('*, users(*), comments(*)')&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 기본 JOIN 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ SQL 문법은 사용할 수 없습니다&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// ❌ 이런 건 안 됩니다!
await supabase
  .from('posts')
  .select('* LEFT JOIN users ON posts.user_id = users.id');&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 체이닝 메서드 사용&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// ✅ 이렇게 사용합니다!
await supabase
  .from('posts')
  .select('*, users(*)');&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 1: 1:1 관계 (사용자 프로필)&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- 테이블 구조
users                    profiles
├── id (PK)             ├── user_id (FK &amp;rarr; users.id)
├── email               ├── bio
└── created_at          └── avatar_url&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;const { data } = await supabase
  .from('users')
  .select(`
    id,
    email,
    profiles (
      bio,
      avatar_url
    )
  `);

// 결과:
// [
//   {
//     id: &quot;user-1&quot;,
//     email: &quot;oeun@example.com&quot;,
//     profiles: {
//       bio: &quot;프론트엔드 개발자&quot;,
//       avatar_url: &quot;https://...&quot;
//     }
//   }
// ]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 2: 1:N 관계 (게시글 + 댓글들)&lt;/h3&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;-- 테이블 구조
posts                    comments
├── id (PK)             ├── id (PK)
├── title               ├── post_id (FK &amp;rarr; posts.id)
└── user_id             ├── text
                        └── user_id&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;const { data } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    comments (
      id,
      text
    )
  `);

// 결과:
// [
//   {
//     id: &quot;post-1&quot;,
//     title: &quot;Supabase 시작하기&quot;,
//     comments: [
//       { id: &quot;c1&quot;, text: &quot;좋은 글이네요&quot; },
//       { id: &quot;c2&quot;, text: &quot;감사합니다&quot; }
//     ]
//   }
// ]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 3: 특정 컬럼만 선택&lt;/h3&gt;
&lt;pre class=&quot;go&quot;&gt;&lt;code&gt;// 전체 선택
.select('*, users(*)')

// 특정 컬럼만
.select(`
  id,
  title,
  users (
    name,
    email
  )
`)

// 단일 컬럼
.select('title, users(name)')&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 4: 별칭(Alias) 사용&lt;/h3&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// Foreign Key가 2개일 때 (발신자/수신자)
const { data } = await supabase
  .from('messages')
  .select(`
    content,
    sender:users!sender_id (name, avatar_url),
    receiver:users!receiver_id (name, avatar_url)
  `);

// 결과:
// {
//   content: &quot;안녕하세요&quot;,
//   sender: { name: &quot;영희&quot;, avatar_url: &quot;...&quot; },
//   receiver: { name: &quot;철수&quot;, avatar_url: &quot;...&quot; }
// }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문법 설명:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;sender:users&lt;/code&gt; - 'sender'라는 별칭 사용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;!sender_id&lt;/code&gt; - 어떤 Foreign Key를 사용할지 명시&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 고급 JOIN 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 1: 중첩 JOIN (N단계)&lt;/h3&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;-- 테이블 구조
posts &amp;rarr; comments &amp;rarr; users&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;// 게시글 + 댓글 + 댓글 작성자
const { data } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    comments (
      id,
      text,
      users (
        name,
        avatar_url
      )
    )
  `);

// 결과:
// [
//   {
//     title: &quot;게시글&quot;,
//     comments: [
//       {
//         text: &quot;댓글&quot;,
//         users: { name: &quot;철수&quot;, avatar_url: &quot;...&quot; }
//       }
//     ]
//   }
// ]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 2: 다대다 관계 (게시글 + 태그)&lt;/h3&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;-- 테이블 구조
posts          post_tags         tags
├── id         ├── post_id       ├── id
└── title      └── tag_id        └── name&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;const { data } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    post_tags (
      tags (
        id,
        name
      )
    )
  `);

// 결과:
// {
//   title: &quot;Next.js 가이드&quot;,
//   post_tags: [
//     { tags: { name: &quot;typescript&quot; } },
//     { tags: { name: &quot;react&quot; } },
//     { tags: { name: &quot;nextjs&quot; } }
//   ]
// }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;더 깔끔한 형태로 변환:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;const posts = data?.map(post =&amp;gt; ({
  ...post,
  tags: post.post_tags.map(pt =&amp;gt; pt.tags.name)
}));

// 결과:
// {
//   title: &quot;Next.js 가이드&quot;,
//   tags: [&quot;typescript&quot;, &quot;react&quot;, &quot;nextjs&quot;]
// }&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 3: COUNT 집계&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 각 게시글의 댓글 수
const { data } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    comments (count)
  `);

// 결과:
// [
//   {
//     id: &quot;post-1&quot;,
//     title: &quot;게시글&quot;,
//     comments: [{ count: 5 }]
//   }
// ]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;더 깔끔하게:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;const posts = data?.map(post =&amp;gt; ({
  ...post,
  commentCount: post.comments[0]?.count || 0
}));&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 4: INNER vs LEFT JOIN&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// LEFT JOIN (기본값) - 댓글 없는 게시글도 포함
.select('*, comments(*)')

// INNER JOIN - 댓글 있는 게시글만
.select('*, comments!inner(*)')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예제:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 댓글이 하나라도 있는 게시글만 가져오기
const { data } = await supabase
  .from('posts')
  .select(`
    *,
    comments!inner (
      id
    )
  `);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 5: 필터링과 함께 사용&lt;/h3&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// JOIN된 테이블에 필터 적용
const { data } = await supabase
  .from('posts')
  .select(`
    *,
    comments!inner (
      *,
      users (name)
    )
  `)
  .eq('comments.approved', true)  // 승인된 댓글만
  .gte('comments.created_at', '2024-01-01');  // 특정 날짜 이후

// 복수 조건
const { data } = await supabase
  .from('posts')
  .select('*, users(*)')
  .eq('is_published', true)
  .eq('users.role', 'author')
  .order('created_at', { ascending: false })
  .limit(10);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 6: 외부 테이블에 LIMIT 적용&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 각 게시글의 최신 댓글 3개만
const { data } = await supabase
  .from('posts')
  .select(`
    *,
    comments (
      *,
      users (name)
    )
  `)
  .order('comments.created_at', { 
    foreignTable: 'comments',
    ascending: false 
  })
  .limit(3, { foreignTable: 'comments' });&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. TypeScript 타입 안정성&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;타입 자동 생성&lt;/h3&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;# Supabase CLI 설치
npm install -g supabase

# 타입 생성
npx supabase gen types typescript --project-id &quot;your-project-id&quot; &amp;gt; types/supabase.ts&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;생성된 타입 사용&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// types/supabase.ts (자동 생성)
export interface Database {
  public: {
    Tables: {
      posts: {
        Row: {
          id: string;
          title: string;
          user_id: string;
          created_at: string;
        };
        Insert: {
          id?: string;
          title: string;
          user_id: string;
          created_at?: string;
        };
        Update: {
          id?: string;
          title?: string;
          user_id?: string;
          created_at?: string;
        };
      };
      users: {
        Row: {
          id: string;
          name: string;
          email: string;
        };
        // ...
      };
    };
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;클라이언트에 타입 적용&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
import { Database } from '@/types/supabase';

export const supabase = createClient&amp;lt;Database&amp;gt;(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;타입 안정성의 힘&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;const { data } = await supabase
  .from('posts')  // &amp;larr; 'posts' 자동완성!
  .select('id, title, users(name)')
  .eq('is_published', true);  // &amp;larr; 'is_published' 자동완성!
//    &amp;uarr; 존재하지 않는 컬럼 입력 시 타입 에러!

// data의 타입도 자동 추론!
data?.[0].title  // ✅ string
data?.[0].users.name  // ✅ string
data?.[0].nonexistent  // ❌ 타입 에러!&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;커스텀 쿼리 타입&lt;/h3&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;// 복잡한 쿼리의 결과 타입 추출
type PostWithAuthorAndComments = Database['public']['Tables']['posts']['Row'] &amp;amp; {
  users: Database['public']['Tables']['users']['Row'];
  comments: Array
    Database['public']['Tables']['comments']['Row'] &amp;amp; {
      users: Database['public']['Tables']['users']['Row'];
    }
  &amp;gt;;
};

const { data } = await supabase
  .from('posts')
  .select(`
    *,
    users(*),
    comments(*, users(*))
  `)
  .returns&amp;lt;PostWithAuthorAndComments[]&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 실전 예제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예제 1: 블로그 시스템&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요구사항:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;게시글 목록 (최신순)&lt;/li&gt;
&lt;li&gt;각 게시글의 작성자 정보&lt;/li&gt;
&lt;li&gt;각 게시글의 댓글 수&lt;/li&gt;
&lt;li&gt;각 게시글의 태그 목록&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/api/posts/route.ts
import { createClient } from '@supabase/supabase-js';
import { Database } from '@/types/supabase';

export async function GET() {
  const supabase = createClient&amp;lt;Database&amp;gt;(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );

  const { data, error } = await supabase
    .from('posts')
    .select(`
      id,
      title,
      content,
      created_at,
      users (
        name,
        avatar_url
      ),
      comments (count),
      post_tags (
        tags (
          name,
          color
        )
      )
    `)
    .eq('is_published', true)
    .order('created_at', { ascending: false })
    .limit(20);

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  // 데이터 변환
  const posts = data.map(post =&amp;gt; ({
    id: post.id,
    title: post.title,
    content: post.content,
    createdAt: post.created_at,
    author: {
      name: post.users?.name,
      avatar: post.users?.avatar_url
    },
    commentCount: post.comments[0]?.count || 0,
    tags: post.post_tags.map(pt =&amp;gt; ({
      name: pt.tags?.name,
      color: pt.tags?.color
    }))
  }));

  return Response.json({ posts });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예제 2: 소셜 피드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요구사항:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;팔로우하는 사람들의 게시글만&lt;/li&gt;
&lt;li&gt;최신 댓글 3개 (작성자 포함)&lt;/li&gt;
&lt;li&gt;좋아요 수&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;-- 테이블 구조
follows
├── follower_id (현재 사용자)
└── following_id (팔로우 대상)

posts
├── id
├── user_id
└── content

likes
├── post_id
└── user_id

comments
├── post_id
├── user_id
└── text&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;export async function GET(request: Request) {
  const userId = await getUserFromSession(request);

  const supabase = createClient&amp;lt;Database&amp;gt;(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );

  // 1. 팔로우하는 사용자 ID 가져오기
  const { data: followingData } = await supabase
    .from('follows')
    .select('following_id')
    .eq('follower_id', userId);

  const followingIds = followingData?.map(f =&amp;gt; f.following_id) || [];

  // 2. 피드 가져오기
  const { data, error } = await supabase
    .from('posts')
    .select(`
      id,
      content,
      created_at,
      users (
        id,
        name,
        avatar_url
      ),
      likes (count),
      comments (
        id,
        text,
        created_at,
        users (
          name,
          avatar_url
        )
      )
    `)
    .in('user_id', followingIds)
    .order('created_at', { ascending: false })
    .order('comments.created_at', { 
      foreignTable: 'comments',
      ascending: false 
    })
    .limit(3, { foreignTable: 'comments' })
    .limit(20);

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  return Response.json({ feed: data });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예제 3: 이커머스 주문 상세&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요구사항:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문 정보&lt;/li&gt;
&lt;li&gt;주문 항목들 (상품 정보 포함)&lt;/li&gt;
&lt;li&gt;배송지 정보&lt;/li&gt;
&lt;li&gt;결제 정보&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;const { data: order } = await supabase
  .from('orders')
  .select(`
    id,
    order_number,
    status,
    total_amount,
    created_at,
    users (
      name,
      email
    ),
    order_items (
      quantity,
      price,
      products (
        name,
        image_url,
        description
      )
    ),
    shipping_addresses (
      recipient_name,
      address,
      phone
    ),
    payments (
      method,
      status,
      paid_at
    )
  `)
  .eq('id', orderId)
  .single();

// 결과:
// {
//   order_number: &quot;ORD-2024-001&quot;,
//   status: &quot;delivered&quot;,
//   users: { name: &quot;~~&quot;, email: &quot;...&quot; },
//   order_items: [
//     {
//       quantity: 2,
//       price: 29000,
//       products: { name: &quot;키보드&quot;, image_url: &quot;...&quot; }
//     }
//   ],
//   shipping_addresses: { ... },
//   payments: { ... }
// }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예제 4: 실시간 채팅 (Realtime + JOIN)&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 실시간 구독 + JOIN
const channel = supabase
  .channel('messages')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages'
    },
    async (payload) =&amp;gt; {
      // 새 메시지가 들어오면 작성자 정보 포함해서 가져오기
      const { data } = await supabase
        .from('messages')
        .select(`
          *,
          users (
            name,
            avatar_url
          )
        `)
        .eq('id', payload.new.id)
        .single();

      // UI 업데이트
      addMessageToChat(data);
    }
  )
  .subscribe();&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 성능 최적화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최적화 1: 필요한 컬럼만 선택&lt;/h3&gt;
&lt;pre class=&quot;sqf&quot;&gt;&lt;code&gt;// ❌ 비효율적 (모든 컬럼)
.select('*, users(*), comments(*)')

// ✅ 효율적 (필요한 것만)
.select(`
  id,
  title,
  users (name, avatar_url),
  comments (count)
`)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최적화 2: 인덱스 활용&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- Foreign Key는 자동으로 인덱스 생성되지만,
-- 필터링에 자주 쓰는 컬럼에는 인덱스 추가

CREATE INDEX idx_posts_published ON posts(is_published, created_at DESC);
CREATE INDEX idx_comments_approved ON comments(approved, created_at);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최적화 3: 페이지네이션&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 오프셋 페이지네이션
const page = 1;
const pageSize = 20;

const { data, count } = await supabase
  .from('posts')
  .select('*, users(name)', { count: 'exact' })
  .range(page * pageSize, (page + 1) * pageSize - 1);

// 커서 페이지네이션 (더 효율적)
const { data } = await supabase
  .from('posts')
  .select('*, users(name)')
  .lt('created_at', lastPostCreatedAt)  // 커서
  .order('created_at', { ascending: false })
  .limit(20);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최적화 4: 쿼리 결과 캐싱&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Next.js App Router
export async function getPosts() {
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );

  const { data } = await supabase
    .from('posts')
    .select('*, users(name)');

  return data;
}

// 캐싱 (5분)
export const revalidate = 300;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최적화 5: N+1 문제 방지&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// ❌ N+1 문제 (N번의 추가 쿼리)
const { data: posts } = await supabase.from('posts').select('*');

for (const post of posts) {
  // 각 게시글마다 별도 쿼리!
  const { data: user } = await supabase
    .from('users')
    .select('name')
    .eq('id', post.user_id)
    .single();

  post.authorName = user.name;
}

// ✅ 해결: 한 번에 JOIN
const { data: posts } = await supabase
  .from('posts')
  .select(`
    *,
    users (name)
  `);&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 한계와 대안&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Supabase JS의 한계&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 복잡한 집계 쿼리&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- ❌ 이런 건 불가능
SELECT 
  users.name,
  COUNT(posts.id) as post_count,
  AVG(posts.views) as avg_views,
  MAX(posts.created_at) as latest_post
FROM users
LEFT JOIN posts ON users.id = posts.user_id
GROUP BY users.id, users.name
HAVING COUNT(posts.id) &amp;gt; 5;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책: PostgreSQL Function (RPC)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;CREATE FUNCTION get_active_users()
RETURNS TABLE(
  name TEXT,
  post_count BIGINT,
  avg_views NUMERIC,
  latest_post TIMESTAMPTZ
) AS $$
  SELECT 
    users.name,
    COUNT(posts.id) as post_count,
    AVG(posts.views) as avg_views,
    MAX(posts.created_at) as latest_post
  FROM users
  LEFT JOIN posts ON users.id = posts.user_id
  GROUP BY users.id, users.name
  HAVING COUNT(posts.id) &amp;gt; 5;
$$ LANGUAGE SQL;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;const { data } = await supabase.rpc('get_active_users');&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. UNION, INTERSECT 등&lt;/h4&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- ❌ 불가능
SELECT id FROM posts WHERE user_id = '123'
UNION
SELECT id FROM drafts WHERE user_id = '123';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책: RPC 또는 여러 쿼리 조합&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;const [posts, drafts] = await Promise.all([
  supabase.from('posts').select('id').eq('user_id', userId),
  supabase.from('drafts').select('id').eq('user_id', userId)
]);

const allIds = [...posts.data!, ...drafts.data!];&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 서브쿼리 (복잡한 경우)&lt;/h4&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- ❌ 이런 서브쿼리는 불가능
SELECT *
FROM posts
WHERE views &amp;gt; (
  SELECT AVG(views) FROM posts WHERE category = posts.category
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책: RPC 또는 클라이언트에서 처리&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. WINDOW 함수&lt;/h4&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- ❌ 불가능
SELECT 
  *,
  ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as rank
FROM posts;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책: RPC&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;대안 비교&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;필요 기능&lt;/th&gt;
&lt;th&gt;해결 방법&lt;/th&gt;
&lt;th&gt;복잡도&lt;/th&gt;
&lt;th&gt;성능&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;간단한 JOIN&lt;/td&gt;
&lt;td&gt;Supabase JS&lt;/td&gt;
&lt;td&gt;✅ 낮음&lt;/td&gt;
&lt;td&gt;✅ 좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;복잡한 집계&lt;/td&gt;
&lt;td&gt;RPC&lt;/td&gt;
&lt;td&gt;⚠️ 중간&lt;/td&gt;
&lt;td&gt;✅ 좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UNION, 복잡한 로직&lt;/td&gt;
&lt;td&gt;RPC&lt;/td&gt;
&lt;td&gt;⚠️ 중간&lt;/td&gt;
&lt;td&gt;✅ 좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;트랜잭션&lt;/td&gt;
&lt;td&gt;RPC&lt;/td&gt;
&lt;td&gt;⚠️ 중간&lt;/td&gt;
&lt;td&gt;✅ 좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;직접 제어 필요&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pg&lt;/code&gt; 라이브러리&lt;/td&gt;
&lt;td&gt;⚠️ 높음&lt;/td&gt;
&lt;td&gt;✅ 좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  결론&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PostgREST의 핵심 가치&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;GraphQL의 선언적 스타일&lt;/b&gt;: 필요한 것만 명시적으로 요청&lt;/li&gt;
&lt;li&gt;&lt;b&gt;REST의 단순함&lt;/b&gt;: 별도 서버 설정 불필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PostgreSQL의 강력함&lt;/b&gt;: SQL의 모든 기능 활용 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;타입 안정성&lt;/b&gt;: TypeScript 완벽 지원&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;언제 Supabase JS를 사용할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;추천하는 경우&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CRUD 중심의 애플리케이션&lt;/li&gt;
&lt;li&gt;1~3단계 정도의 JOIN&lt;/li&gt;
&lt;li&gt;빠른 프로토타이핑&lt;/li&gt;
&lt;li&gt;타입 안정성이 중요한 프로젝트&lt;/li&gt;
&lt;li&gt;Next.js/React와 함께 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;RPC 함께 사용 권장&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복잡한 집계 쿼리&lt;/li&gt;
&lt;li&gt;트랜잭션 필요&lt;/li&gt;
&lt;li&gt;비즈니스 로직이 복잡한 경우&lt;/li&gt;
&lt;li&gt;GROUP BY, HAVING 등 고급 SQL 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;❌ &lt;b&gt;다른 방법 고려&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실시간 복잡한 분석 (BI 도구 사용)&lt;/li&gt;
&lt;li&gt;매우 복잡한 데이터 모델 (GraphQL 고려)&lt;/li&gt;
&lt;li&gt;레거시 DB 통합&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GraphQL vs Supabase PostgREST&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;측면&lt;/th&gt;
&lt;th&gt;Supabase&lt;/th&gt;
&lt;th&gt;GraphQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅ FK만 설정&lt;/td&gt;
&lt;td&gt;⚠️ 스키마+리졸버 작성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;타입 생성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅ CLI 한 줄&lt;/td&gt;
&lt;td&gt;✅ Codegen 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;학습 곡선&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅ SQL 아는 사람에게 쉬움&lt;/td&gt;
&lt;td&gt;⚠️ 새로운 개념 학습&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;유연성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;⚠️ PostgreSQL 기능 제한&lt;/td&gt;
&lt;td&gt;✅ 완전한 자유&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;커뮤니티&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;⚠️ 상대적으로 작음&lt;/td&gt;
&lt;td&gt;✅ 매우 큼&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;에코시스템&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Supabase 전용&lt;/td&gt;
&lt;td&gt;✅ 다양한 도구&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실무 조합 추천&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 1. 간단한 CRUD &amp;rarr; Supabase JS
const { data: posts } = await supabase
  .from('posts')
  .select('*, users(name)');

// 2. 복잡한 쿼리 &amp;rarr; RPC
const { data: stats } = await supabase
  .rpc('get_user_statistics', { user_id: userId });

// 3. 트랜잭션 &amp;rarr; RPC
const { data: result } = await supabase
  .rpc('create_order_with_items', { 
    items: [...],
    total: 10000 
  });

// 4. 실시간 &amp;rarr; Supabase Realtime
supabase
  .channel('posts')
  .on('postgres_changes', { ... }, callback)
  .subscribe();&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 기억사항&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Foreign Key = 자동 관계&lt;/b&gt;: 설정만 하면 끝&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;select()&lt;/code&gt; 문법은 GraphQL과 유사&lt;/b&gt;: 중첩 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;점(&lt;code&gt;.&lt;/code&gt;)이 아닌 괄호(&lt;code&gt;()&lt;/code&gt;)로 관계 탐색&lt;/b&gt;: &lt;code&gt;users(name)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TypeScript 타입은 자동 생성&lt;/b&gt;: CLI 한 줄로 해결&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복잡한 건 RPC로&lt;/b&gt;: SQL의 모든 기능 활용&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마이그레이션 가이드&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;기존 SQL &amp;rarr; Supabase JS&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- Before: SQL
SELECT 
  posts.id,
  posts.title,
  users.name,
  COUNT(comments.id) as comment_count
FROM posts
LEFT JOIN users ON posts.user_id = users.id
LEFT JOIN comments ON posts.id = comments.post_id
WHERE posts.is_published = true
GROUP BY posts.id, users.name
ORDER BY posts.created_at DESC
LIMIT 10;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// After: 간단한 부분은 Supabase JS
const { data } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    users (name),
    comments (count)
  `)
  .eq('is_published', true)
  .order('created_at', { ascending: false })
  .limit(10);

// 복잡한 집계는 RPC로
CREATE FUNCTION get_posts_with_stats() ...&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;GraphQL &amp;rarr; Supabase&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Before: GraphQL
query {
  posts(where: { published: { _eq: true } }) {
    id
    title
    user {
      name
    }
    comments_aggregate {
      aggregate {
        count
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// After: Supabase (거의 같은 구조!)
const { data } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    users (name),
    comments (count)
  `)
  .eq('is_published', true);&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://postgrest.org/&quot;&gt;PostgREST 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://supabase.com/docs/reference/javascript/select&quot;&gt;Supabase JavaScript Client&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://supabase.com/docs/guides/database/joins&quot;&gt;Supabase Database Relationships&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://supabase.com/docs/guides/api/generating-types&quot;&gt;TypeScript 타입 생성&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  추신: 추가로 궁금할 만한 점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q1: JOIN 성능이 걱정됩니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A:&lt;/b&gt; Supabase JS의 JOIN은 PostgREST를 통해 실제 PostgreSQL의 JOIN으로 변환됩니다. 따라서 성능은 일반 SQL JOIN과 동일합니다. 오히려 필요한 컬럼만 선택하므로 네트워크 트래픽이 줄어듭니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// 이 쿼리는 내부적으로 최적화된 SQL JOIN으로 실행됩니다
.select('id, title, users(name)')

// 실제 실행되는 SQL (단순화):
// SELECT posts.id, posts.title, users.name
// FROM posts
// LEFT JOIN users ON posts.user_id = users.id&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q2: 몇 단계까지 중첩할 수 있나요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A:&lt;/b&gt; 이론적으로는 제한이 없지만, 실무에서는 3~4단계가 적당합니다. 그 이상은 가독성과 성능을 위해 RPC를 고려하세요.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// ✅ 적당함 (3단계)
.select('*, comments(*, users(*))')

// ⚠️ 과도함 (5단계)
.select('*, a(*, b(*, c(*, d(*))))')
// 이럴 땐 RPC 사용 권장&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q3: 같은 테이블을 여러 번 JOIN할 수 있나요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A:&lt;/b&gt; 네, 별칭(alias)을 사용하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 발신자와 수신자 (둘 다 users 테이블)
.select(`
  content,
  sender:users!sender_id(name),
  receiver:users!receiver_id(name)
`)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q4: 관계가 없는 테이블도 JOIN할 수 있나요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A:&lt;/b&gt; 아니요. Supabase JS는 Foreign Key 기반. 관계가 없다면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Foreign Key를 추가하거나&lt;/li&gt;
&lt;li&gt;RPC 함수를 사용하거나&lt;/li&gt;
&lt;li&gt;여러 쿼리를 조합하세요&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// Foreign Key 없이 JOIN 필요하다면
const { data } = await supabase.rpc('custom_join_function');&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q5: 조건부 JOIN은 어떻게 하나요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A:&lt;/b&gt; Supabase JS는 조건부 JOIN을 직접 지원하지 않습니다. 클라이언트에서 처리하거나 RPC를 사용하세요.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 클라이언트에서 처리
const query = supabase
  .from('posts')
  .select('*');

if (includeAuthor) {
  query.select('*, users(*)');
}

const { data } = await query;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Q6: 순환 참조는 어떻게 처리하나요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A:&lt;/b&gt; PostgREST는 순환 참조를 감지하고 자동으로 차단합니다. 필요하다면 명시적으로 깊이를 제한하세요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 무한 루프 방지를 위해 명시적으로 선택
.select(`
  id,
  name,
  parent_category:categories!parent_id(id, name)
`)
// 2단계만 가져오기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>library</category>
      <category>join</category>
      <category>postgrest</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/307</guid>
      <comments>https://ifelseif.tistory.com/307#entry307comment</comments>
      <pubDate>Thu, 9 Oct 2025 19:26:57 +0900</pubDate>
    </item>
    <item>
      <title>[251009 TIL] Supabase RPC 총정리</title>
      <link>https://ifelseif.tistory.com/306</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;Supabase RPC: 트랜잭션과 보안을 위한 필수 도구&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  TL;DR (한 줄 요약)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase RPC는 성능 최적화뿐만 아니라 &lt;b&gt;Row Level Security 하에서 안전한 트랜잭션 처리&lt;/b&gt;를 위한 핵심 기능.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  핵심 정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Supabase JS 클라이언트는 트랜잭션을 지원하지 않음&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RPC (PostgreSQL Functions)&lt;/b&gt;를 사용하면 서버 측에서 트랜잭션 보장&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RLS (Row Level Security)&lt;/b&gt;는 행 단위 접근 제어로 클라이언트의 직접 DB 접근을 안전하게 보호&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RLS 정책의 &lt;code&gt;USING&lt;/code&gt; 절&lt;/b&gt;은 관계 테이블을 통한 복잡한 권한 체크 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;트랜잭션 + RLS&lt;/b&gt;를 함께 사용하면 안전하고 일관된 데이터 처리 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제 상황: Supabase JS의 한계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Supabase JS는 트랜잭션을 지원하지 않습니다&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ❌ 이런 코드는 트랜잭션이 아닙니다!
export async function POST(request: Request) {
  const supabase = createClient(url, key);

  // 1번 업데이트
  const { error: error1 } = await supabase
    .from('users')
    .update({ name: '새이름' })
    .eq('id', userId);

  // 2번 업데이트
  const { error: error2 } = await supabase
    .from('profiles')
    .update({ status: 'active' })
    .eq('user_id', userId);

  // 문제: 1번은 성공, 2번은 실패 &amp;rarr; 데이터 불일치!
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;수동 롤백의 문제점&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ⚠️ 비추천: 수동 롤백
if (error2) {
  // 1번을 되돌리려 시도
  await supabase
    .from('users')
    .update({ name: '원래이름' })
    .eq('id', userId);
  // 문제점:
  // - 원래 값을 알아야 함
  // - 네트워크 장애 시 롤백 실패
  // - 동시성 문제 발생 가능
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 해결책: RPC를 통한 트랜잭션&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RPC (Remote Procedure Call)란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostgreSQL의 &lt;b&gt;Stored Procedure&lt;/b&gt;를 GraphQL/REST API처럼 호출하는 방식.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RPC 함수 생성&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- Supabase Dashboard &amp;rarr; SQL Editor에서 실행
CREATE OR REPLACE FUNCTION update_user_and_profile(
  p_user_id UUID,
  p_name TEXT,
  p_status TEXT
)
RETURNS JSON
LANGUAGE plpgsql
SECURITY DEFINER  -- 함수 소유자 권한으로 실행
AS $$
BEGIN
  -- 트랜잭션 자동 시작

  UPDATE users 
  SET name = p_name 
  WHERE id = p_user_id;

  UPDATE profiles 
  SET status = p_status 
  WHERE user_id = p_user_id;

  -- 모든 작업 성공 시 자동 커밋
  RETURN json_build_object('success', true);

EXCEPTION
  WHEN OTHERS THEN
    -- 에러 발생 시 자동 롤백
    RETURN json_build_object(
      'success', false, 
      'error', SQLERRM
    );
END;
$$;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Next.js에서 RPC 호출&lt;/h3&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// app/api/update-user/route.ts
import { createClient } from '@supabase/supabase-js';

export async function POST(request: Request) {
  const { userId, name, status } = await request.json();

  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!  // 서버에서만 사용
  );

  const { data, error } = await supabase.rpc('update_user_and_profile', {
    p_user_id: userId,
    p_name: name,
    p_status: status
  });

  if (error || !data.success) {
    return Response.json(
      { error: data?.error || error.message }, 
      { status: 500 }
    );
  }

  return Response.json({ success: true });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;트랜잭션의 장점&lt;/h3&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;-- ✅ 원자성 (Atomicity): 모두 성공 or 모두 실패
-- ✅ 일관성 (Consistency): 데이터 무결성 보장
-- ✅ 격리성 (Isolation): 동시 실행 간섭 방지
-- ✅ 지속성 (Durability): 커밋 후 영구 저장&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. RLS의 이해와 중요성&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Row Level Security (RLS)란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블 전체가 아닌 &lt;b&gt;행(row) 단위&lt;/b&gt;로 접근을 제어하는 PostgreSQL의 보안 기능.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전통적인 방식 vs RLS&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- 전통적인 GRANT/REVOKE (테이블 단위)
GRANT SELECT ON users TO some_user;
-- &amp;rarr; 테이블 전체를 볼 수 있다/없다

-- RLS (행 단위)
CREATE POLICY &quot;사용자는 자기 데이터만&quot;
ON users FOR SELECT
USING (auth.uid() = id);
-- &amp;rarr; 테이블에서 내 행만 볼 수 있다&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RLS의 핵심 가치: 클라이언트 직접 접근&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// React 컴포넌트 (브라우저에서 실행!)
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  SUPABASE_URL, 
  SUPABASE_ANON_KEY  // 공개 키!
);

// RLS가 없다면?
const { data } = await supabase
  .from('users')
  .select('email, password_hash, credit_card');
//   모든 사용자 정보가 노출됨!

// RLS가 있다면?
const { data } = await supabase
  .from('posts')
  .select('*');
// ✅ 자동으로 내 게시글만 반환됨&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RLS 활성화&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 1. RLS 활성화 (필수!)
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- 2. 정책이 없으면 기본적으로 모든 접근 거부
-- 빈 결과 반환

-- 3. 필요한 정책만 추가
CREATE POLICY &quot;사용자는 본인 게시글만 관리&quot;
ON posts FOR ALL
USING (auth.uid() = user_id);&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. RLS 정책 작성 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 1: 직접 소유 (user_id 컬럼 사용)&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- 가장 기본적인 패턴
CREATE POLICY &quot;본인 데이터만 CRUD&quot;
ON posts FOR ALL
USING (auth.uid() = user_id);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;테이블 구조:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE TABLE posts (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id),  -- 필수!
  title TEXT,
  content TEXT
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 2: 관계 테이블을 통한 접근 제어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;user_id가 없어도 다른 테이블을 통해 권한 확인 가능!&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 조직 테이블 (user_id 없음!)
CREATE TABLE organizations (
  id UUID PRIMARY KEY,
  name TEXT
);

-- 멤버십 테이블
CREATE TABLE memberships (
  organization_id UUID REFERENCES organizations(id),
  user_id UUID REFERENCES auth.users(id),
  role TEXT
);

-- 조직에 user_id가 없지만 RLS 가능!
CREATE POLICY &quot;조직 멤버만 조직 정보 조회&quot;
ON organizations FOR SELECT
USING (
  id IN (
    SELECT organization_id 
    FROM memberships 
    WHERE user_id = auth.uid()
  )
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;USING 절은 WHERE 조건과 동일합니다:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- RLS 정책
USING (auth.uid() = user_id)

-- 실제 실행되는 쿼리
SELECT * FROM posts WHERE auth.uid() = user_id;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 3: 공개 + 소유자&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;CREATE POLICY &quot;공개 게시글은 모두, 비공개는 본인만&quot;
ON posts FOR SELECT
USING (
  is_published = true 
  OR auth.uid() = user_id
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 4: 역할 기반 접근&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;CREATE POLICY &quot;관리자는 모든 게시글 수정 가능&quot;
ON posts FOR UPDATE
USING (
  auth.uid() = user_id 
  OR 
  (SELECT role FROM auth.users WHERE id = auth.uid()) = 'admin'
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 5: 복잡한 관계 (팀 프로젝트)&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 프로젝트 테이블 (user_id 없음)
CREATE TABLE projects (
  id UUID PRIMARY KEY,
  team_id UUID REFERENCES teams(id),
  name TEXT
);

-- 팀 멤버십
CREATE TABLE team_members (
  team_id UUID REFERENCES teams(id),
  user_id UUID REFERENCES auth.users(id),
  role TEXT
);

-- 팀 멤버만 프로젝트 접근
CREATE POLICY &quot;팀 멤버 전용&quot;
ON projects FOR ALL
USING (
  team_id IN (
    SELECT team_id 
    FROM team_members 
    WHERE user_id = auth.uid()
  )
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 6: EXISTS를 사용한 존재 여부 확인&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- &quot;내가 좋아요 누른 게시글만 보기&quot;
CREATE POLICY &quot;liked_posts_only&quot;
ON posts FOR SELECT
USING (
  EXISTS (
    SELECT 1 
    FROM post_likes 
    WHERE post_id = posts.id 
    AND user_id = auth.uid()
  )
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;헬퍼 함수로 재사용성 높이기&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 자주 쓰는 체크를 함수로 만들기
CREATE FUNCTION is_team_member(team_uuid UUID)
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM team_members
    WHERE team_id = team_uuid
    AND user_id = auth.uid()
  );
$$ LANGUAGE SQL SECURITY DEFINER;

-- 여러 테이블에서 재사용
CREATE POLICY &quot;팀 접근&quot;
ON projects FOR SELECT
USING (is_team_member(team_id));

CREATE POLICY &quot;팀 접근&quot;
ON tasks FOR SELECT
USING (is_team_member(team_id));&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. RPC + RLS 실전 예제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오: 팀 프로젝트 관리 시스템&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요구사항:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로젝트 생성 시 자동으로 작업(task) 3개 생성 (트랜잭션 필요)&lt;/li&gt;
&lt;li&gt;팀 멤버만 프로젝트와 작업 접근 가능 (RLS 필요)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1단계: 테이블 구조&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- 팀
CREATE TABLE teams (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL
);

-- 팀 멤버
CREATE TABLE team_members (
  team_id UUID REFERENCES teams(id),
  user_id UUID REFERENCES auth.users(id),
  role TEXT DEFAULT 'member',
  PRIMARY KEY (team_id, user_id)
);

-- 프로젝트 (user_id 없음!)
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id UUID REFERENCES teams(id),
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- 작업
CREATE TABLE tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id UUID REFERENCES projects(id),
  title TEXT NOT NULL,
  status TEXT DEFAULT 'todo'
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계: RLS 정책 설정&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 모든 테이블 RLS 활성화
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

-- 팀: 멤버만 조회
CREATE POLICY &quot;팀 멤버만 팀 조회&quot;
ON teams FOR SELECT
USING (
  id IN (
    SELECT team_id 
    FROM team_members 
    WHERE user_id = auth.uid()
  )
);

-- 프로젝트: 팀 멤버만 접근 (관계 테이블 통해 확인!)
CREATE POLICY &quot;팀 멤버만 프로젝트 접근&quot;
ON projects FOR ALL
USING (
  team_id IN (
    SELECT team_id 
    FROM team_members 
    WHERE user_id = auth.uid()
  )
);

-- 작업: 프로젝트의 팀 멤버만 접근 (2단계 관계!)
CREATE POLICY &quot;팀 멤버만 작업 접근&quot;
ON tasks FOR ALL
USING (
  project_id IN (
    SELECT p.id 
    FROM projects p
    INNER JOIN team_members tm ON p.team_id = tm.team_id
    WHERE tm.user_id = auth.uid()
  )
);

-- 팀 멤버: 본인 멤버십만 조회
CREATE POLICY &quot;본인 멤버십 조회&quot;
ON team_members FOR SELECT
USING (user_id = auth.uid());&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3단계: RPC 함수 (트랜잭션 + RLS)&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;CREATE OR REPLACE FUNCTION create_project_with_tasks(
  p_team_id UUID,
  p_project_name TEXT
)
RETURNS JSON
LANGUAGE plpgsql
SECURITY DEFINER  -- 함수 소유자 권한으로 실행
SET search_path = public
AS $$
DECLARE
  v_project_id UUID;
  v_is_member BOOLEAN;
BEGIN
  -- 1. 권한 확인: 요청자가 팀 멤버인지 체크
  SELECT EXISTS (
    SELECT 1 FROM team_members
    WHERE team_id = p_team_id
    AND user_id = auth.uid()
  ) INTO v_is_member;

  IF NOT v_is_member THEN
    RETURN json_build_object(
      'success', false,
      'error', '팀 멤버만 프로젝트를 생성할 수 있습니다'
    );
  END IF;

  -- 2. 프로젝트 생성 (트랜잭션 시작)
  INSERT INTO projects (team_id, name)
  VALUES (p_team_id, p_project_name)
  RETURNING id INTO v_project_id;

  -- 3. 기본 작업 3개 생성
  INSERT INTO tasks (project_id, title, status)
  VALUES 
    (v_project_id, '요구사항 분석', 'todo'),
    (v_project_id, '설계', 'todo'),
    (v_project_id, '개발', 'todo');

  -- 모두 성공 &amp;rarr; 자동 커밋
  RETURN json_build_object(
    'success', true,
    'project_id', v_project_id
  );

EXCEPTION
  WHEN OTHERS THEN
    -- 에러 발생 &amp;rarr; 자동 롤백
    RETURN json_build_object(
      'success', false,
      'error', SQLERRM
    );
END;
$$;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4단계: Next.js Route Handler&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/api/projects/create/route.ts
import { createClient } from '@supabase/supabase-js';
import { cookies } from 'next/headers';

export async function POST(request: Request) {
  const { teamId, projectName } = await request.json();

  // 사용자 세션을 포함한 클라이언트 생성
  const cookieStore = cookies();
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value;
        },
      },
    }
  );

  // RPC 호출 (RLS 적용됨!)
  const { data, error } = await supabase.rpc('create_project_with_tasks', {
    p_team_id: teamId,
    p_project_name: projectName
  });

  if (error || !data.success) {
    return Response.json(
      { error: data?.error || error.message },
      { status: 500 }
    );
  }

  return Response.json({
    success: true,
    projectId: data.project_id
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5단계: React 컴포넌트&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// components/CreateProjectForm.tsx
'use client';

import { useState } from 'react';

export function CreateProjectForm({ teamId }: { teamId: string }) {
  const [projectName, setProjectName] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) =&amp;gt; {
    e.preventDefault();
    setLoading(true);

    try {
      const response = await fetch('/api/projects/create', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ teamId, projectName })
      });

      const result = await response.json();

      if (result.success) {
        alert('프로젝트가 생성되었습니다!');
        // 프로젝트 + 작업 3개가 한 번에 생성됨 (트랜잭션)
      } else {
        alert(result.error);
      }
    } catch (error) {
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    &amp;lt;form onSubmit={handleSubmit}&amp;gt;
      &amp;lt;input
        type=&quot;text&quot;
        value={projectName}
        onChange={(e) =&amp;gt; setProjectName(e.target.value)}
        placeholder=&quot;프로젝트 이름&quot;
        required
      /&amp;gt;
      &amp;lt;button type=&quot;submit&quot; disabled={loading}&amp;gt;
        {loading ? '생성 중...' : '프로젝트 생성'}
      &amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 흐름&lt;/h3&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;1. 사용자가 프로젝트 생성 버튼 클릭
   &amp;darr;
2. Next.js API Route 호출
   &amp;darr;
3. RPC 함수 실행 (PostgreSQL)
   - 권한 체크 (팀 멤버인가?)
   - 프로젝트 INSERT (트랜잭션 시작)
   - 작업 3개 INSERT
   - 모두 성공 &amp;rarr; COMMIT
   - 하나라도 실패 &amp;rarr; ROLLBACK
   &amp;darr;
4. 사용자가 데이터 조회 시
   - RLS가 자동으로 팀 멤버 확인
   - 권한 있는 데이터만 반환&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 주의사항과 베스트 프랙티스&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚠️ 주의사항&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. SECURITY DEFINER의 양날의 검&lt;/h4&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;CREATE FUNCTION my_function()
SECURITY DEFINER  -- 함수 소유자 권한으로 실행
AS $$
BEGIN
  -- 이 안에서는 RLS가 우회될 수 있음!
  -- 반드시 함수 내부에서 권한 체크 필수
END;
$$;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결책: 명시적 권한 확인&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE FUNCTION secure_function()
SECURITY DEFINER
AS $$
BEGIN
  -- 1. 반드시 권한 확인!
  IF NOT EXISTS (
    SELECT 1 FROM team_members 
    WHERE user_id = auth.uid()
  ) THEN
    RAISE EXCEPTION '권한이 없습니다';
  END IF;

  -- 2. 실제 작업 수행
  UPDATE ...
END;
$$;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 성능 고려&lt;/h4&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- ❌ 복잡한 서브쿼리는 성능 저하
CREATE POLICY &quot;complex_policy&quot;
ON posts FOR SELECT
USING (
  (SELECT count(*) FROM likes WHERE post_id = posts.id) &amp;gt; 100
  AND created_at &amp;gt; now() - interval '7 days'
  AND auth.uid() IN (
    SELECT follower_id FROM follows WHERE following_id = user_id
  )
);

-- ✅ 인덱스 추가로 최적화
CREATE INDEX idx_likes_post_id ON likes(post_id);
CREATE INDEX idx_follows_following ON follows(following_id, follower_id);&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. RLS 디버깅&lt;/h4&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// RLS 때문에 데이터가 안 보일 때
const { data, error } = await supabase
  .from('projects')
  .select('*');

console.log('Error:', error);
// &quot;row-level security policy&quot; 에러 &amp;rarr; RLS 정책 확인

// Supabase Dashboard에서 직접 확인
// SQL Editor &amp;rarr; SELECT * FROM projects (RLS 우회)
// Table Editor &amp;rarr; 일반 사용자로 로그인해서 테스트&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 베스트 프랙티스&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. RLS 체크리스트&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- ✓ 1. 모든 테이블 RLS 활성화
ALTER TABLE my_table ENABLE ROW LEVEL SECURITY;

-- ✓ 2. 정책 없으면 기본 deny (안전)
-- 이 상태에서 SELECT하면 빈 결과

-- ✓ 3. 최소 권한 원칙
CREATE POLICY &quot;최소한의 접근만&quot;
ON posts FOR SELECT  -- SELECT만 허용
USING (auth.uid() = user_id);

-- ✓ 4. 명확한 정책 이름
CREATE POLICY &quot;team_members_read_projects&quot;  -- ✓ 명확
CREATE POLICY &quot;policy1&quot;  -- ✗ 불명확&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. RPC 함수 패턴&lt;/h4&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;CREATE OR REPLACE FUNCTION my_function(
  p_param1 TYPE,  -- p_ 접두사로 파라미터 구분
  p_param2 TYPE
)
RETURNS JSON  -- 항상 JSON 반환 (에러 처리 용이)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public  -- SQL injection 방지
AS $$
DECLARE
  v_variable TYPE;  -- v_ 접두사로 변수 구분
BEGIN
  -- 1. 권한 확인 (필수!)
  IF NOT authorized THEN
    RETURN json_build_object('success', false, 'error', 'Unauthorized');
  END IF;

  -- 2. 비즈니스 로직
  -- ...

  -- 3. 성공 응답
  RETURN json_build_object('success', true, 'data', v_variable);

EXCEPTION
  WHEN OTHERS THEN
    -- 4. 에러 처리
    RETURN json_build_object('success', false, 'error', SQLERRM);
END;
$$;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 테이블 설계 가이드&lt;/h4&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 사용자 소유 데이터 &amp;rarr; user_id 필수
CREATE TABLE posts (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id) NOT NULL,  -- ✓
  title TEXT
);

-- 조직/팀 소유 &amp;rarr; organization_id + 멤버십 테이블
CREATE TABLE projects (
  id UUID PRIMARY KEY,
  team_id UUID REFERENCES teams(id) NOT NULL,  -- user_id 불필요
  name TEXT
);

-- 공개 데이터 &amp;rarr; RLS true 또는 비활성화
CREATE TABLE categories (
  id UUID PRIMARY KEY,
  name TEXT
  -- user_id 불필요
);&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 환경 변수 관리&lt;/h4&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# .env.local
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key  # 클라이언트용 (RLS 적용)
SUPABASE_SERVICE_ROLE_KEY=your-service-key   # 서버용 (RLS 우회)&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// 클라이언트 (브라우저)
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!  // RLS 적용됨
);

// 서버 (Route Handler)
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!  // RLS 우회 (주의!)
);&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  결론&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RPC를 사용하는 이유&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;트랜잭션 보장&lt;/b&gt;: 여러 테이블 업데이트를 원자적으로 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복잡한 비즈니스 로직&lt;/b&gt;: SQL의 강력함 활용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능 최적화&lt;/b&gt;: 네트워크 왕복 최소화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보안&lt;/b&gt;: 민감한 로직을 DB에서 실행&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RLS를 타이트하게 설정하는 이유&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;클라이언트 직접 접근 보호&lt;/b&gt;: 공개 키로도 안전&lt;/li&gt;
&lt;li&gt;&lt;b&gt;행 단위 세밀한 제어&lt;/b&gt;: 사용자별, 조직별 데이터 격리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;관계 테이블 활용&lt;/b&gt;: user_id 없이도 복잡한 권한 체크 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자동 적용&lt;/b&gt;: 개발자가 권한 체크 깜빡해도 DB가 보호&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 기억사항&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Supabase JS는 트랜잭션 미지원&lt;/b&gt; &amp;rarr; RPC 사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RLS의 USING 절 = WHERE 조건&lt;/b&gt; &amp;rarr; 서브쿼리, JOIN 모두 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;관계 테이블로 권한 확인 가능&lt;/b&gt; &amp;rarr; user_id 필수 아님&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SECURITY DEFINER는 조심히&lt;/b&gt; &amp;rarr; 함수 내부에서 권한 체크 필수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RPC + RLS = 안전한 트랜잭션&lt;/b&gt; &amp;rarr; 최고의 조합&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://supabase.com/docs/guides/database/functions&quot;&gt;Supabase RPC 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/ddl-rowsecurity.html&quot;&gt;PostgreSQL Row Level Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://supabase.com/docs/guides/auth/auth-helpers/nextjs&quot;&gt;Supabase Auth Helpers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>library</category>
      <category>RLS</category>
      <category>RPC</category>
      <category>supabase</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/306</guid>
      <comments>https://ifelseif.tistory.com/306#entry306comment</comments>
      <pubDate>Thu, 9 Oct 2025 19:23:42 +0900</pubDate>
    </item>
    <item>
      <title>[251006 TIL] Terraform + Neon + Hasura 구축기</title>
      <link>https://ifelseif.tistory.com/305</link>
      <description>&lt;h1&gt;Terraform으로 Neon DB + Hasura GraphQL API 서버 구축하기&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 개요&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;백엔드 인프라&lt;/b&gt;: Neon PostgreSQL + Hasura (EC2) + AWS Secret Manager + IAM&lt;/li&gt;
&lt;li&gt;&lt;b&gt;프론트엔드&lt;/b&gt;: Next.js (Vercel 배포 예정) + Apollo Client + GraphQL Code Generator&lt;/li&gt;
&lt;li&gt;&lt;b&gt;패키지 매니저&lt;/b&gt;: pnpm&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 인프라 구축 (Terraform)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 사전 준비&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Neon DB&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://neon.tech/&quot;&gt;neon.tech&lt;/a&gt; 가입 및 프로젝트 생성&lt;/li&gt;
&lt;li&gt;Connection string 복사&lt;/li&gt;
&lt;li&gt;&lt;code&gt; postgresql://[user]:[password]@[endpoint]/[dbname]?sslmode=require&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;SSH 키 준비&lt;/h4&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;# SSH 키가 없다면 생성
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa

# 공개키 확인
cat ~/.ssh/id_rsa.pub&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;내 IP 확인&lt;/h4&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;curl ifconfig.me
# 출력 예: 123.456.789.012&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 Terraform 파일 구성&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;프로젝트 디렉토리 생성&lt;/h4&gt;
&lt;pre class=&quot;dos&quot;&gt;&lt;code&gt;mkdir hasura-terraform
cd hasura-terraform&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;주요 파일&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;main.tf&lt;/code&gt;: 메인 Terraform 설정 (EC2, Security Group, IAM 등)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_data.sh&lt;/code&gt;: EC2 초기화 스크립트 (Docker, Hasura 설치)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;terraform.tfvars&lt;/code&gt;: 변수 값 정의 (⚠️ 절대 git에 커밋하지 말 것!)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;terraform.tfvars 작성&lt;/h4&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;aws_region = &quot;ap-northeast-2&quot;  # 서울 리전

# Neon DB 연결 URL
neon_database_url = &quot;postgresql://user:password@ep-xxx.aws.neon.tech/neondb?sslmode=require&quot;

# Hasura 관리자 비밀번호
hasura_admin_secret = &quot;your-super-secret-password-here&quot;

# 내 IP 주소 (SSH 접속용, /32 붙이기)
my_ip = &quot;123.456.789.012/32&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 AWS Secret Manager 설정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;main.tf&lt;/code&gt;에서 AWS Secret Manager 리소스 정의&lt;/li&gt;
&lt;li&gt;Neon DB URL, Hasura Admin Secret 저장&lt;/li&gt;
&lt;li&gt;EC2가 접근할 수 있도록 IAM Role 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.4 IAM 설정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;EC2 인스턴스 프로파일 생성&lt;/li&gt;
&lt;li&gt;Secret Manager 읽기 권한 부여&lt;/li&gt;
&lt;li&gt;&lt;code class=&quot;language-hcl&quot;&gt;  # 예시- secretsmanager:GetSecretValue- secretsmanager:DescribeSecret&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.5 Security Group 설정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;8080 포트&lt;/b&gt;: Hasura Console 및 GraphQL 엔드포인트 (내 IP만 허용)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;22 포트&lt;/b&gt;: SSH 접속 (내 IP만 허용)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.6 Terraform 실행&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 초기화
terraform init

# 실행 계획 확인
terraform plan -out=myplan.tfplan

# 인프라 배포
terraform apply myplan.tfplan

# 출력 정보 확인
terraform output&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출력 예시:&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;ec2_public_ip = &quot;13.125.123.456&quot;
hasura_console_url = &quot;http://13.125.123.456:8080/console&quot;
hasura_graphql_endpoint = &quot;http://13.125.123.456:8080/v1/graphql&quot;
ssh_command = &quot;ssh -i ~/.ssh/id_rsa ubuntu@13.125.123.456&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Hasura 설정 및 데이터베이스 스키마&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 Hasura Console 접속&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브라우저에서 &lt;code&gt;hasura_console_url&lt;/code&gt; 접속&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hasura_admin_secret&lt;/code&gt; 값으로 로그인&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 데이터베이스 연결 확인&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Neon DB 자동 연결 확인 (user_data.sh에서 환경 변수로 설정됨)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 SQL 스키마 작성 시 주의사항 ⚠️&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SQL로 테이블 생성 시 &lt;code&gt;uuid&lt;/code&gt; 컬럼을 PK로 의도했으나&lt;/li&gt;
&lt;li&gt;실제로는 &lt;code&gt;no&lt;/code&gt; 같은 다른 컬럼이 PK로 설정되는 문제 발생&lt;/li&gt;
&lt;li&gt;Hasura가 테이블을 제대로 인식하지 못함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결 방법&lt;/b&gt;:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;잘못된 테이블 모두 DROP&lt;/li&gt;
&lt;li&gt;SQL 다시 작성 - &lt;b&gt;PRIMARY KEY를 명시적으로 지정&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;code class=&quot;language-sql&quot;&gt; CREATE TABLE users (  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),  name TEXT NOT NULL,  email TEXT UNIQUE NOT NULL,  created_at TIMESTAMPTZ DEFAULT NOW());&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Hasura Console에서 SQL 실행&lt;/li&gt;
&lt;li&gt;테이블 Track 확인&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심&lt;/b&gt;: &lt;code&gt;uuid&lt;/code&gt;를 PK로 사용하려면 반드시 &lt;code&gt;PRIMARY KEY&lt;/code&gt;를 명시!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.4 Permissions 설정  &lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;반드시 설정해야 함&lt;/b&gt;: 각 테이블마다 Role 기반 권한 설정&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Role 구분&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;admin&lt;/b&gt;: 모든 CRUD 권한&lt;/li&gt;
&lt;li&gt;&lt;b&gt;user&lt;/b&gt;: 제한된 권한 (자신의 데이터만)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;각 테이블별 설정 항목&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Insert&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;user&lt;/code&gt; role: 자신의 데이터만 삽입 가능&lt;/li&gt;
&lt;li&gt;Check 조건 예: &lt;code&gt;{&quot;user_id&quot;: {&quot;_eq&quot;: &quot;X-Hasura-User-Id&quot;}}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Select&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Row level filter: &lt;code&gt;{&quot;user_id&quot;: {&quot;_eq&quot;: &quot;X-Hasura-User-Id&quot;}}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Column 권한: 민감한 컬럼 제외&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Update&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Filter + Check 조건 설정&lt;/li&gt;
&lt;li&gt;예: 자신의 데이터만 수정 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Delete&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Filter 조건으로 제한&lt;/li&gt;
&lt;li&gt;필요시 soft delete 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;설정 예시&lt;/h4&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;users 테이블:
- admin: 모든 권한
- user: 
  - select: WHERE user_id = X-Hasura-User-Id
  - update: WHERE user_id = X-Hasura-User-Id (email, name만 수정)
  - insert: user_id는 자동 설정
  - delete: 불가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주의&lt;/b&gt;: 각 작업(Insert/Select/Update/Delete)마다 개별 설정 필요!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Next.js 프론트엔드 설정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 프로젝트 생성 및 패키지 설치&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Next.js 프로젝트 생성
pnpm create next-app@latest

# Apollo Client 설치
pnpm add @apollo/client graphql rxjs @apollo/client-integration-nextjs

# GraphQL Code Generator 설치
pnpm add -D @graphql-codegen/cli \
  @graphql-codegen/typescript \
  @graphql-codegen/typescript-operations \
  @graphql-codegen/typescript-react-apollo&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 Apollo Client 설정&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo-client.ts
import { HttpLink } from &quot;@apollo/client&quot;;
import {
    ApolloClient,
    InMemoryCache,
    registerApolloClient,
} from &quot;@apollo/client-integration-nextjs&quot;;

export const { getClient, query, PreloadQuery } = registerApolloClient(() =&amp;gt; {
    return new ApolloClient({
        cache: new InMemoryCache(),
        link: new HttpLink({
        uri:
            process.env.HASURA_GRAPHQL_ENDPOINT ??
            &quot;http://localhost:8080/v1/graphql&quot;,
        }),
    });
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 GraphQL Code Generator 설정&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;codegen.ts 작성&lt;/h4&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;import type { CodegenConfig } from &quot;@graphql-codegen/cli&quot;;

const config: CodegenConfig = {
  overwrite: true,
  schema: [
    {
      [
        process.env.HASURA_GRAPHQL_ENDPOINT ?? 
        &quot;http://localhost:8080/v1/graphql&quot;
      ]: {
        headers: {
          &quot;x-hasura-admin-secret&quot;: process.env.HASURA_ADMIN_SECRET ?? &quot;&quot;,
        },
      },
    },
  ],
  documents: [&quot;src/**/*.{ts,tsx,graphql}&quot;],
  generates: {
    &quot;src/generated/graphql.ts&quot;: {
      plugins: [&quot;typescript&quot;, &quot;typescript-operations&quot;, &quot;typed-document-node&quot;],
      config: {
        fetcher: &quot;graphql-request&quot;,
        exposeDocument: true,
        exposeQueryKeys: true,
        exposeMutationKeys: true,
        // hasura scalar 맵핑 필수!
        scalars: {
          uuid: &quot;string&quot;,
          timestamptz: &quot;string&quot;,
          jsonb: &quot;Record&amp;lt;string, any&amp;gt;&quot;,
          numeric: &quot;number&quot;,
        },
      },
    },
  },
}

export default config;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;package.json에 스크립트 추가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Root 에 둘거면 dotenv 설치 필요&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;codegen&quot;: &quot;dotenv -e .env.local -- graphql-codegen --config codegen.ts&quot;,
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.4 GraphQL 쿼리 작성 및 Codegen 실행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시 쿼리임.. 필요에 맞게 작성해야 함&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# src/queries/users.graphql
query GetUsers {
  users {
    id
    name
    email
  }
}

mutation CreateUser($name: String!, $email: String!) {
  insert_users_one(object: {name: $name, email: $email}) {
    id
    name
    email
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;# Codegen 실행 ✅
pnpm codegen&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성 결과:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TypeScript 타입 자동 생성&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useGetUsersQuery&lt;/code&gt;, &lt;code&gt;useCreateUserMutation&lt;/code&gt; 훅 자동 생성&lt;/li&gt;
&lt;li&gt;완벽한 타입 안정성 확보&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.5 환경 변수 설정&lt;/h3&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# .env.local
HASURA_ENDPOINT=http://13.125.123.456:8080/v1/graphql
HASURA_ADMIN_SECRET=your-super-secret-password-here&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Vercel 배포&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 환경 변수 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vercel 대시보드에서:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;NEXT_PUBLIC_HASURA_ENDPOINT&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HASURA_ADMIN_SECRET&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 배포&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cli로 하든... github 연동 하든.. 알아서&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;pnpm vercel&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 인프라 관리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용 후 삭제 (비용 절약)&lt;/h3&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;terraform destroy&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다시 시작&lt;/h3&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;terraform apply&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SSH 접속 (문제 해결)&lt;/h3&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;ssh -i ~/.ssh/id_rsa ubuntu@&amp;lt;EC2_IP&amp;gt;
cd ~/hasura
docker-compose logs -f&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 포인트 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ &lt;b&gt;Terraform으로 인프라를 코드화&lt;/b&gt; - Secret Manager, IAM, EC2를 한 번에 구성&lt;br /&gt;⚠️ &lt;b&gt;Hasura SQL 실행 시 PK 설정 명시&lt;/b&gt; - &lt;code&gt;uuid PRIMARY KEY&lt;/code&gt; 명시적 선언 필수&lt;br /&gt;  &lt;b&gt;Permissions 필수 설정&lt;/b&gt; - admin/user role 구분, 각 CRUD 작업마다 개별 권한 설정&lt;br /&gt;  &lt;b&gt;Apollo + Codegen으로 타입 안전성&lt;/b&gt; - pnpm으로 패키지 관리&lt;br /&gt;  &lt;b&gt;tfvars 파일 보안&lt;/b&gt; - &lt;code&gt;.gitignore&lt;/code&gt;에 반드시 추가&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;예시 디렉터리 구조&lt;/h2&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;프로젝트/
├── hasura-terraform/
│   ├── main.tf
│   ├── user_data.sh
│   ├── terraform.tfvars  # ⚠️ git ignore
│   └── .gitignore
│
└── frontend/
    ├── src/
    │   ├── queries/
    │   │   └── users.graphql
    │   ├── generated/
    │   │   └── graphql.ts  # codegen 결과
    │   └── lib/
    │       └── apollo-client.ts
    ├── codegen.yml
    ├── .env.local  # ⚠️ git ignore
    └── package.json&lt;/code&gt;&lt;/pre&gt;</description>
      <category>library</category>
      <category>EC2</category>
      <category>hasura</category>
      <category>Neon</category>
      <category>terraform</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/305</guid>
      <comments>https://ifelseif.tistory.com/305#entry305comment</comments>
      <pubDate>Mon, 6 Oct 2025 10:19:10 +0900</pubDate>
    </item>
    <item>
      <title>[250929 TIL] ApolloClient 디버거 직접 만들기</title>
      <link>https://ifelseif.tistory.com/304</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;TanStack Query DevTools vs Apollo DevTools 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TanStack Query DevTools ✨&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

&amp;lt;QueryClientProvider client={queryClient}&amp;gt;
  &amp;lt;App /&amp;gt;
  &amp;lt;ReactQueryDevtools initialIsOpen={false} /&amp;gt;
&amp;lt;/QueryClientProvider&amp;gt;

// 기능:
// ✅ 모든 쿼리 상태 실시간 확인
// ✅ 캐시 데이터 탐색
// ✅ 쿼리 무효화/리페치 버튼
// ✅ 타임라인
// ✅ 직관적인 UI&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Apollo DevTools  &lt;/h3&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 브라우저 확장 프로그램 설치 필요
// Chrome/Firefox Extension

// 기능:
// ⚠️ 쿼리 목록 (기본적)
// ⚠️ 캐시 탐색 (복잡함)
// ⚠️ Mutation 추적 (불편함)
// ⚠️ UI가 투박함
// ❌ 타임라인 없음
// ❌ 실시간 업데이트 약함&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결책: Custom DevTools Link 만들기!&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 기본 디버깅 Link&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// lib/apollo/debug-link.ts
import { ApolloLink } from '@apollo/client'
import { makeVar } from '@apollo/client'

// 전역 상태로 쿼리 히스토리 관리
export const queryHistoryVar = makeVar&amp;lt;QueryLog[]&amp;gt;([])

interface QueryLog {
  id: string
  operationName: string
  operationType: 'query' | 'mutation' | 'subscription'
  variables: any
  startTime: number
  endTime?: number
  duration?: number
  status: 'pending' | 'success' | 'error'
  error?: any
  result?: any
  cached: boolean
}

export function createDebugLink() {
  return new ApolloLink((operation, forward) =&amp;gt; {
    const id = `${operation.operationName}-${Date.now()}`
    const startTime = performance.now()

    // 로그 생성
    const log: QueryLog = {
      id,
      operationName: operation.operationName,
      operationType: operation.query.definitions[0]?.operation || 'query',
      variables: operation.variables,
      startTime,
      status: 'pending',
      cached: false
    }

    // 히스토리에 추가
    queryHistoryVar([log, ...queryHistoryVar()])

    return forward(operation).map(response =&amp;gt; {
      const endTime = performance.now()
      const duration = endTime - startTime

      // 로그 업데이트
      const updatedLog: QueryLog = {
        ...log,
        endTime,
        duration,
        status: 'success',
        result: response.data,
        cached: duration &amp;lt; 10 // 10ms 이하면 캐시로 간주
      }

      queryHistoryVar(
        queryHistoryVar().map(l =&amp;gt; l.id === id ? updatedLog : l)
      )

      // 콘솔에도 출력
      console.groupCollapsed(
        `%c${log.operationType.toUpperCase()} %c${operation.operationName} %c${duration.toFixed(2)}ms`,
        'color: #FF6B6B; font-weight: bold',
        'color: #4ECDC4',
        duration &amp;gt; 1000 ? 'color: #FF6B6B' : 'color: #95E1D3'
      )
      console.log('Variables:', operation.variables)
      console.log('Result:', response.data)
      console.log('Cached:', updatedLog.cached)
      console.groupEnd()

      return response
    })
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Custom DevTools UI 컴포넌트&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// components/apollo-devtools.tsx
'use client'

import { useReactiveVar } from '@apollo/client'
import { queryHistoryVar } from '@/lib/apollo/debug-link'
import { useState } from 'react'

export function ApolloDevTools() {
  const history = useReactiveVar(queryHistoryVar)
  const [isOpen, setIsOpen] = useState(false)
  const [selectedLog, setSelectedLog] = useState&amp;lt;string | null&amp;gt;(null)

  if (process.env.NODE_ENV !== 'development') {
    return null
  }

  const selected = history.find(log =&amp;gt; log.id === selectedLog)

  return (
    &amp;lt;&amp;gt;
      {/* 플로팅 버튼 */}
      &amp;lt;button
        onClick={() =&amp;gt; setIsOpen(!isOpen)}
        className=&quot;fixed bottom-4 right-4 z-[9999] bg-purple-600 text-white rounded-full w-14 h-14 shadow-lg hover:bg-purple-700 transition-all&quot;
        title=&quot;Apollo DevTools&quot;
      &amp;gt;
         
      &amp;lt;/button&amp;gt;

      {/* DevTools 패널 */}
      {isOpen &amp;amp;&amp;amp; (
        &amp;lt;div className=&quot;fixed inset-0 z-[9998] pointer-events-none&quot;&amp;gt;
          &amp;lt;div className=&quot;absolute bottom-20 right-4 w-[600px] h-[500px] bg-gray-900 rounded-lg shadow-2xl pointer-events-auto flex flex-col&quot;&amp;gt;
            {/* 헤더 */}
            &amp;lt;div className=&quot;bg-purple-600 text-white px-4 py-3 rounded-t-lg flex items-center justify-between&quot;&amp;gt;
              &amp;lt;div className=&quot;flex items-center gap-2&quot;&amp;gt;
                &amp;lt;span className=&quot;text-lg font-bold&quot;&amp;gt;Apollo DevTools&amp;lt;/span&amp;gt;
                &amp;lt;span className=&quot;text-xs bg-purple-700 px-2 py-1 rounded&quot;&amp;gt;
                  {history.length} queries
                &amp;lt;/span&amp;gt;
              &amp;lt;/div&amp;gt;
              &amp;lt;div className=&quot;flex gap-2&quot;&amp;gt;
                &amp;lt;button
                  onClick={() =&amp;gt; queryHistoryVar([])}
                  className=&quot;text-xs bg-purple-700 hover:bg-purple-800 px-3 py-1 rounded&quot;
                &amp;gt;
                  Clear
                &amp;lt;/button&amp;gt;
                &amp;lt;button
                  onClick={() =&amp;gt; setIsOpen(false)}
                  className=&quot;text-white hover:text-gray-300&quot;
                &amp;gt;
                  ✕
                &amp;lt;/button&amp;gt;
              &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;

            &amp;lt;div className=&quot;flex-1 flex overflow-hidden&quot;&amp;gt;
              {/* 쿼리 목록 */}
              &amp;lt;div className=&quot;w-1/2 border-r border-gray-700 overflow-y-auto&quot;&amp;gt;
                {history.map(log =&amp;gt; (
                  &amp;lt;div
                    key={log.id}
                    onClick={() =&amp;gt; setSelectedLog(log.id)}
                    className={`
                      px-4 py-3 border-b border-gray-800 cursor-pointer hover:bg-gray-800 transition-colors
                      ${selectedLog === log.id ? 'bg-gray-800' : ''}
                    `}
                  &amp;gt;
                    &amp;lt;div className=&quot;flex items-center justify-between mb-1&quot;&amp;gt;
                      &amp;lt;span className={`
                        text-xs font-mono px-2 py-0.5 rounded
                        ${log.operationType === 'query' ? 'bg-blue-900 text-blue-300' : ''}
                        ${log.operationType === 'mutation' ? 'bg-green-900 text-green-300' : ''}
                        ${log.operationType === 'subscription' ? 'bg-purple-900 text-purple-300' : ''}
                      `}&amp;gt;
                        {log.operationType.toUpperCase()}
                      &amp;lt;/span&amp;gt;
                      &amp;lt;span className={`
                        text-xs
                        ${log.duration! &amp;gt; 1000 ? 'text-red-400' : 'text-green-400'}
                      `}&amp;gt;
                        {log.duration?.toFixed(0)}ms
                      &amp;lt;/span&amp;gt;
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div className=&quot;text-sm text-white font-medium&quot;&amp;gt;
                      {log.operationName}
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div className=&quot;flex items-center gap-2 mt-1&quot;&amp;gt;
                      &amp;lt;span className={`
                        text-xs px-1.5 py-0.5 rounded
                        ${log.status === 'pending' ? 'bg-yellow-900 text-yellow-300' : ''}
                        ${log.status === 'success' ? 'bg-green-900 text-green-300' : ''}
                        ${log.status === 'error' ? 'bg-red-900 text-red-300' : ''}
                      `}&amp;gt;
                        {log.status}
                      &amp;lt;/span&amp;gt;
                      {log.cached &amp;amp;&amp;amp; (
                        &amp;lt;span className=&quot;text-xs bg-blue-900 text-blue-300 px-1.5 py-0.5 rounded&quot;&amp;gt;
                          cached
                        &amp;lt;/span&amp;gt;
                      )}
                    &amp;lt;/div&amp;gt;
                  &amp;lt;/div&amp;gt;
                ))}
              &amp;lt;/div&amp;gt;

              {/* 상세 정보 */}
              &amp;lt;div className=&quot;w-1/2 overflow-y-auto p-4 text-white&quot;&amp;gt;
                {selected ? (
                  &amp;lt;div className=&quot;space-y-4&quot;&amp;gt;
                    &amp;lt;div&amp;gt;
                      &amp;lt;h3 className=&quot;text-lg font-bold text-purple-400 mb-2&quot;&amp;gt;
                        {selected.operationName}
                      &amp;lt;/h3&amp;gt;
                      &amp;lt;div className=&quot;text-sm text-gray-400&quot;&amp;gt;
                        {new Date(selected.startTime).toLocaleTimeString()}
                      &amp;lt;/div&amp;gt;
                    &amp;lt;/div&amp;gt;

                    &amp;lt;div&amp;gt;
                      &amp;lt;h4 className=&quot;text-sm font-semibold text-gray-400 mb-2&quot;&amp;gt;
                        Variables
                      &amp;lt;/h4&amp;gt;
                      &amp;lt;pre className=&quot;bg-gray-800 p-3 rounded text-xs overflow-x-auto&quot;&amp;gt;
                        {JSON.stringify(selected.variables, null, 2)}
                      &amp;lt;/pre&amp;gt;
                    &amp;lt;/div&amp;gt;

                    {selected.result &amp;amp;&amp;amp; (
                      &amp;lt;div&amp;gt;
                        &amp;lt;h4 className=&quot;text-sm font-semibold text-gray-400 mb-2&quot;&amp;gt;
                          Result
                        &amp;lt;/h4&amp;gt;
                        &amp;lt;pre className=&quot;bg-gray-800 p-3 rounded text-xs overflow-x-auto max-h-64&quot;&amp;gt;
                          {JSON.stringify(selected.result, null, 2)}
                        &amp;lt;/pre&amp;gt;
                      &amp;lt;/div&amp;gt;
                    )}

                    {selected.error &amp;amp;&amp;amp; (
                      &amp;lt;div&amp;gt;
                        &amp;lt;h4 className=&quot;text-sm font-semibold text-red-400 mb-2&quot;&amp;gt;
                          Error
                        &amp;lt;/h4&amp;gt;
                        &amp;lt;pre className=&quot;bg-red-900 text-red-100 p-3 rounded text-xs overflow-x-auto&quot;&amp;gt;
                          {JSON.stringify(selected.error, null, 2)}
                        &amp;lt;/pre&amp;gt;
                      &amp;lt;/div&amp;gt;
                    )}
                  &amp;lt;/div&amp;gt;
                ) : (
                  &amp;lt;div className=&quot;text-center text-gray-500 mt-20&quot;&amp;gt;
                    Select a query to see details
                  &amp;lt;/div&amp;gt;
                )}
              &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
      )}
    &amp;lt;/&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 사용하기&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo/links.ts
export function createApolloLinks(options: CreateLinksOptions) {
  const { isServer } = options

  const links = [
    // 개발 환경 + 클라이언트에서만 DevTools Link
    ...(process.env.NODE_ENV === 'development' &amp;amp;&amp;amp; !isServer 
      ? [createDebugLink()] 
      : []
    ),
    createErrorLink(options),
    createAuthLink(options),
    createHttpLink(options)
  ]

  return from(links)
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/layout.tsx
import { ApolloDevTools } from '@/components/apollo-devtools'

export default function RootLayout({ children }) {
  return (
    &amp;lt;html&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;ApolloWrapper&amp;gt;
          {children}
        &amp;lt;/ApolloWrapper&amp;gt;
        &amp;lt;ApolloDevTools /&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;고급 기능 추가&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 캐시 탐색기&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// components/apollo-cache-explorer.tsx
'use client'

import { useApolloClient } from '@apollo/client'
import { useState } from 'react'

export function ApolloCacheExplorer() {
  const client = useApolloClient()
  const [cache, setCache] = useState&amp;lt;any&amp;gt;(null)

  const extractCache = () =&amp;gt; {
    const extracted = client.cache.extract()
    setCache(extracted)
  }

  const clearCache = () =&amp;gt; {
    client.cache.reset()
    setCache(null)
  }

  return (
    &amp;lt;div className=&quot;p-4&quot;&amp;gt;
      &amp;lt;div className=&quot;flex gap-2 mb-4&quot;&amp;gt;
        &amp;lt;button
          onClick={extractCache}
          className=&quot;bg-blue-600 text-white px-4 py-2 rounded&quot;
        &amp;gt;
            Extract Cache
        &amp;lt;/button&amp;gt;
        &amp;lt;button
          onClick={clearCache}
          className=&quot;bg-red-600 text-white px-4 py-2 rounded&quot;
        &amp;gt;
           ️ Clear Cache
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      {cache &amp;amp;&amp;amp; (
        &amp;lt;div className=&quot;bg-gray-900 text-white p-4 rounded&quot;&amp;gt;
          &amp;lt;h3 className=&quot;text-lg font-bold mb-2&quot;&amp;gt;Cache Contents&amp;lt;/h3&amp;gt;
          &amp;lt;pre className=&quot;text-xs overflow-auto max-h-96&quot;&amp;gt;
            {JSON.stringify(cache, null, 2)}
          &amp;lt;/pre&amp;gt;
        &amp;lt;/div&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 성능 차트&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// components/apollo-performance-chart.tsx
'use client'

import { useReactiveVar } from '@apollo/client'
import { queryHistoryVar } from '@/lib/apollo/debug-link'

export function ApolloPerformanceChart() {
  const history = useReactiveVar(queryHistoryVar)

  const slowQueries = history.filter(log =&amp;gt; (log.duration || 0) &amp;gt; 1000)
  const avgDuration = history.length &amp;gt; 0
    ? history.reduce((sum, log) =&amp;gt; sum + (log.duration || 0), 0) / history.length
    : 0

  return (
    &amp;lt;div className=&quot;p-4 bg-gray-900 rounded&quot;&amp;gt;
      &amp;lt;h3 className=&quot;text-white font-bold mb-4&quot;&amp;gt;Performance Metrics&amp;lt;/h3&amp;gt;

      &amp;lt;div className=&quot;grid grid-cols-3 gap-4 mb-4&quot;&amp;gt;
        &amp;lt;div className=&quot;bg-gray-800 p-3 rounded&quot;&amp;gt;
          &amp;lt;div className=&quot;text-gray-400 text-sm&quot;&amp;gt;Total Queries&amp;lt;/div&amp;gt;
          &amp;lt;div className=&quot;text-white text-2xl font-bold&quot;&amp;gt;{history.length}&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;

        &amp;lt;div className=&quot;bg-gray-800 p-3 rounded&quot;&amp;gt;
          &amp;lt;div className=&quot;text-gray-400 text-sm&quot;&amp;gt;Avg Duration&amp;lt;/div&amp;gt;
          &amp;lt;div className=&quot;text-white text-2xl font-bold&quot;&amp;gt;
            {avgDuration.toFixed(0)}ms
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;

        &amp;lt;div className=&quot;bg-gray-800 p-3 rounded&quot;&amp;gt;
          &amp;lt;div className=&quot;text-gray-400 text-sm&quot;&amp;gt;Slow Queries&amp;lt;/div&amp;gt;
          &amp;lt;div className=&quot;text-red-400 text-2xl font-bold&quot;&amp;gt;
            {slowQueries.length}
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      {/* 차트 */}
      &amp;lt;div className=&quot;space-y-2&quot;&amp;gt;
        {history.slice(0, 10).map(log =&amp;gt; (
          &amp;lt;div key={log.id} className=&quot;flex items-center gap-2&quot;&amp;gt;
            &amp;lt;div className=&quot;text-xs text-gray-400 w-32 truncate&quot;&amp;gt;
              {log.operationName}
            &amp;lt;/div&amp;gt;
            &amp;lt;div className=&quot;flex-1 bg-gray-800 rounded h-6 relative&quot;&amp;gt;
              &amp;lt;div
                className={`h-full rounded transition-all ${
                  (log.duration || 0) &amp;gt; 1000 ? 'bg-red-500' : 'bg-green-500'
                }`}
                style={{
                  width: `${Math.min((log.duration || 0) / 30, 100)}%`
                }}
              /&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div className=&quot;text-xs text-gray-400 w-16 text-right&quot;&amp;gt;
              {log.duration?.toFixed(0)}ms
            &amp;lt;/div&amp;gt;
          &amp;lt;/div&amp;gt;
        ))}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. Network Inspector Link&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo/network-inspector-link.ts
import { ApolloLink } from '@apollo/client'

export function createNetworkInspectorLink() {
  return new ApolloLink((operation, forward) =&amp;gt; {
    const startTime = performance.now()

    console.group(
      `%c  Network Request`,
      'color: #61DAFB; font-weight: bold; font-size: 14px'
    )

    console.log('%cOperation:', 'font-weight: bold', operation.operationName)
    console.log('%cType:', 'font-weight: bold', operation.query.definitions[0]?.operation)
    console.log('%cVariables:', 'font-weight: bold', operation.variables)

    // Context 정보
    const context = operation.getContext()
    console.log('%cContext:', 'font-weight: bold', {
      headers: context.headers,
      uri: context.uri
    })

    console.groupEnd()

    return forward(operation).map(response =&amp;gt; {
      const duration = performance.now() - startTime
      const durationColor = duration &amp;gt; 1000 ? '#FF6B6B' : '#51CF66'

      console.group(
        `%c✅ Network Response %c${duration.toFixed(2)}ms`,
        'color: #51CF66; font-weight: bold; font-size: 14px',
        `color: ${durationColor}; font-weight: bold`
      )

      console.log('%cOperation:', 'font-weight: bold', operation.operationName)
      console.log('%cData:', 'font-weight: bold', response.data)

      if (response.errors) {
        console.log('%cErrors:', 'font-weight: bold; color: #FF6B6B', response.errors)
      }

      // Extensions (Hasura의 경우 유용)
      if (response.extensions) {
        console.log('%cExtensions:', 'font-weight: bold', response.extensions)
      }

      console.groupEnd()

      return response
    })
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;완성된 DevTools 통합&lt;/h2&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;// lib/apollo/links.ts
export function createApolloLinks(options: CreateLinksOptions) {
  const { isServer } = options
  const isDev = process.env.NODE_ENV === 'development'

  const links = []

  // 개발 환경 + 클라이언트
  if (isDev &amp;amp;&amp;amp; !isServer) {
    links.push(
      createDebugLink(),           // Custom DevTools
      createNetworkInspectorLink() // 네트워크 로깅
    )
  }

  // 공통 Link
  links.push(
    createErrorLink(options),
    createAuthLink(options)
  )

  // 클라이언트 전용
  if (!isServer) {
    links.push(createRetryLink())
  }

  // HTTP
  links.push(createHttpLink(options))

  return from(links)
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/layout.tsx
import { ApolloDevTools } from '@/components/apollo-devtools'

export default function RootLayout({ children }) {
  return (
    &amp;lt;html&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;ApolloWrapper&amp;gt;
          {children}
        &amp;lt;/ApolloWrapper&amp;gt;

        {/* 개발 환경에서만 표시 */}
        {process.env.NODE_ENV === 'development' &amp;amp;&amp;amp; (
          &amp;lt;ApolloDevTools /&amp;gt;
        )}
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;콘솔 출력 예시&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;  Network Request
  Operation: GetCampaigns
  Type: query
  Variables: { status: &quot;open&quot;, limit: 10 }
  Context: { headers: { authorization: &quot;Bearer ...&quot; } }

✅ Network Response 234.56ms
  Operation: GetCampaigns
  Data: { campaigns: [...] }
  Extensions: { hasura_execution_time: 0.012 }&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;추가 기능 아이디어&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. Query 재실행 버튼&lt;/h3&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;// DevTools에 추가
&amp;lt;button
  onClick={() =&amp;gt; {
    client.refetchQueries({
      include: [selected.operationName]
    })
  }}
  className=&quot;bg-blue-600 text-white px-3 py-1 rounded text-sm&quot;
&amp;gt;
    Refetch
&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. Cache 무효화 버튼&lt;/h3&gt;
&lt;pre class=&quot;hsp&quot;&gt;&lt;code&gt;&amp;lt;button
  onClick={() =&amp;gt; {
    client.cache.evict({ 
      id: 'ROOT_QUERY',
      fieldName: selected.operationName 
    })
  }}
  className=&quot;bg-orange-600 text-white px-3 py-1 rounded text-sm&quot;
&amp;gt;
    Evict Cache
&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. Subscription 모니터링&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const subscriptionLink = new ApolloLink((operation, forward) =&amp;gt; {
  if (operation.query.definitions[0]?.operation === 'subscription') {
    console.log('  Subscription started:', operation.operationName)

    return forward(operation).map(response =&amp;gt; {
      console.log('  Subscription data:', operation.operationName, response.data)
      return response
    })
  }

  return forward(operation)
})&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Apollo의 공식 DevTools는 부족하지만:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ Custom Link로 &lt;b&gt;더 나은 디버깅 환경&lt;/b&gt; 구축 가능&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;TanStack Query DevTools 스타일&lt;/b&gt;로 만들 수 있음&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;프로젝트에 맞는 커스텀&lt;/b&gt; 기능 추가&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;콘솔 로깅&lt;/b&gt;도 훨씬 예쁘게&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;원하는 대로 커스터마이징 가능&lt;/li&gt;
&lt;li&gt;프로덕션 빌드에서 자동 제거&lt;/li&gt;
&lt;li&gt;성능 모니터링 내장&lt;/li&gt;
&lt;li&gt;브라우저 확장 프로그램 불필요&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>library</category>
      <category>Apollo</category>
      <category>devtools</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/304</guid>
      <comments>https://ifelseif.tistory.com/304#entry304comment</comments>
      <pubDate>Mon, 29 Sep 2025 21:35:18 +0900</pubDate>
    </item>
    <item>
      <title>[250929 TIL] ApolloLink Next.js 설정</title>
      <link>https://ifelseif.tistory.com/303</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;일반적인 Next.js + Apollo 설정&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo-client-rsc.ts (Server Component용)
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc'

export const { getClient } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: from([
      loggerLink,      // 중복 1
      errorLink,       // 중복 2
      authLink,        // 중복 3
      httpLink         // 중복 4
    ])
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo-client.tsx (Client Component용)
'use client'
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import { ApolloNextAppProvider } from '@apollo/experimental-nextjs-app-support/ssr'

function makeClient() {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: from([
      loggerLink,      // 또 중복 1
      errorLink,       // 또 중복 2
      authLink,        // 또 중복 3
      httpLink         // 또 중복 4
    ])
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  Link 설정을 두 번 작성해야 함!&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 1: Link 팩토리 함수로 공통화 (추천! ⭐)&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// lib/apollo-links.ts
import { ApolloLink, from, HttpLink } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'
import { RetryLink } from '@apollo/client/link/retry'

// 공통 Link 생성 함수
export function createApolloLinks(options?: {
  isServer?: boolean
  getToken?: () =&amp;gt; string | null
}) {
  const { isServer = false, getToken } = options || {}

  // 1. 로거 (개발 환경에서만)
  const loggerLink = new ApolloLink((operation, forward) =&amp;gt; {
    if (process.env.NODE_ENV === 'development') {
      console.log(`  [${isServer ? 'Server' : 'Client'}] ${operation.operationName}`)
      const start = Date.now()

      return forward(operation).map(response =&amp;gt; {
        console.log(`✅ [${isServer ? 'Server' : 'Client'}] ${operation.operationName} (${Date.now() - start}ms)`)
        return response
      })
    }
    return forward(operation)
  })

  // 2. 에러 처리
  const errorLink = onError(({ graphQLErrors, networkError, operation }) =&amp;gt; {
    if (graphQLErrors) {
      graphQLErrors.forEach(({ message, extensions }) =&amp;gt; {
        console.error(`❌ [GraphQL error] ${operation.operationName}:`, message)

        // 클라이언트에서만 토스트
        if (!isServer &amp;amp;&amp;amp; typeof window !== 'undefined') {
          if (extensions?.code === 'UNAUTHENTICATED') {
            window.location.href = '/login'
          }
          // toast 등
        }
      })
    }

    if (networkError) {
      console.error(`  [Network error]:`, networkError)
    }
  })

  // 3. 인증
  const authLink = setContext((_, { headers }) =&amp;gt; {
    // 토큰 가져오기 (서버/클라이언트 분기)
    let token: string | null = null

    if (isServer) {
      // 서버: cookies()에서 가져오기 (Next.js 15)
      // getToken 함수로 주입받음
      token = getToken ? getToken() : null
    } else {
      // 클라이언트: localStorage
      if (typeof window !== 'undefined') {
        token = localStorage.getItem('token')
      }
    }

    return {
      headers: {
        ...headers,
        ...(token &amp;amp;&amp;amp; { authorization: `Bearer ${token}` })
      }
    }
  })

  // 4. 재시도 (클라이언트에서만)
  const retryLink = !isServer
    ? new RetryLink({
        delay: { initial: 300, max: 5000, jitter: true },
        attempts: { max: 3 }
      })
    : null

  // 5. HTTP
  const httpLink = new HttpLink({
    uri: process.env.NEXT_PUBLIC_HASURA_URL,
    // 서버에서는 fetch 명시
    ...(isServer &amp;amp;&amp;amp; {
      fetch: fetch,
      fetchOptions: {
        cache: 'no-store'  // SSR에서 캐시 제어
      }
    })
  })

  // Link 체인 조합
  return from([
    loggerLink,
    errorLink,
    authLink,
    ...(retryLink ? [retryLink] : []),
    httpLink
  ])
}

// 공통 캐시 설정도 함수화
export function createApolloCache() {
  return new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          campaign_application: {
            keyArgs: ['where', 'order_by'],
            merge(existing, incoming) {
              return incoming
            }
          }
        }
      }
    }
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo-client-rsc.ts (Server)
import { ApolloClient } from '@apollo/client'
import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc'
import { cookies } from 'next/headers'
import { createApolloLinks, createApolloCache } from './apollo-links'

export const { getClient } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
    cache: createApolloCache(),
    link: createApolloLinks({
      isServer: true,
      getToken: () =&amp;gt; {
        // Next.js 15의 cookies()
        const cookieStore = cookies()
        return cookieStore.get('token')?.value || null
      }
    })
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo-client.tsx (Client)
'use client'
import { ApolloClient } from '@apollo/client'
import { ApolloNextAppProvider } from '@apollo/experimental-nextjs-app-support/ssr'
import { createApolloLinks, createApolloCache } from './apollo-links'

function makeClient() {
  return new ApolloClient({
    cache: createApolloCache(),
    link: createApolloLinks({
      isServer: false
    })
  })
}

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return (
    &amp;lt;ApolloNextAppProvider makeClient={makeClient}&amp;gt;
      {children}
    &amp;lt;/ApolloNextAppProvider&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✨ 이제 Link 설정은 한 곳에서만 관리!&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 2: 환경별 Link 선택 (고급)&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo-links.ts
export function createApolloLinks(options: {
  isServer: boolean
}) {
  const { isServer } = options

  const commonLinks = [
    createLoggerLink(isServer),
    createErrorLink(isServer),
    createAuthLink(isServer)
  ]

  const clientOnlyLinks = isServer ? [] : [
    createRetryLink(),
    createLoadingLink(),
    createAnalyticsLink()
  ]

  const serverOnlyLinks = isServer ? [
    createServerCacheLink()
  ] : []

  return from([
    ...commonLinks,
    ...clientOnlyLinks,
    ...serverOnlyLinks,
    createHttpLink(isServer)
  ])
}

function createLoggerLink(isServer: boolean) {
  return new ApolloLink((operation, forward) =&amp;gt; {
    const prefix = isServer ? '[SSR]' : '[CSR]'
    console.log(`${prefix} ${operation.operationName}`)
    return forward(operation)
  })
}

function createRetryLink() {
  return new RetryLink({ /* ... */ })
}

// ... 각 Link를 함수로 분리&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 3: Config 객체로 관리&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// lib/apollo-config.ts
export const apolloLinkConfig = {
  common: {
    logger: {
      enabled: process.env.NODE_ENV === 'development'
    },
    error: {
      redirectOnAuth: true,
      showToast: true
    },
    auth: {
      headerKey: 'authorization',
      tokenPrefix: 'Bearer'
    }
  },
  server: {
    retry: false,
    cache: 'no-store'
  },
  client: {
    retry: {
      max: 3,
      delay: 300
    },
    analytics: true
  }
}

// lib/apollo-links.ts
export function createApolloLinks(isServer: boolean) {
  const config = isServer 
    ? { ...apolloLinkConfig.common, ...apolloLinkConfig.server }
    : { ...apolloLinkConfig.common, ...apolloLinkConfig.client }

  return from([
    ...(config.logger.enabled ? [createLoggerLink(isServer)] : []),
    createErrorLink(config.error, isServer),
    createAuthLink(config.auth, isServer),
    ...(config.retry ? [createRetryLink(config.retry)] : []),
    createHttpLink(isServer)
  ])
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버/클라이언트 차이점 처리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;토큰 저장 위치&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;function getAuthToken(isServer: boolean): string | null {
  if (isServer) {
    // 서버: cookies 또는 headers
    const { cookies } = require('next/headers')
    return cookies().get('token')?.value || null
  } else {
    // 클라이언트: localStorage 또는 cookies
    if (typeof window !== 'undefined') {
      return localStorage.getItem('token')
    }
    return null
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;에러 처리&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;const errorLink = onError(({ graphQLErrors, operation }) =&amp;gt; {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ extensions }) =&amp;gt; {
      if (extensions?.code === 'UNAUTHENTICATED') {
        if (isServer) {
          // 서버: 로그만
          console.error('Unauthorized request:', operation.operationName)
        } else {
          // 클라이언트: 리다이렉트
          window.location.href = '/login'
        }
      }
    })
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. &lt;b&gt;재시도 정책&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// 서버: 재시도 없음 (SSR 속도 중요)
// 클라이언트: 재시도 있음 (UX 중요)

const retryLink = !isServer ? new RetryLink({
  delay: { initial: 300, max: 5000 },
  attempts: { max: 3 }
}) : null&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. &lt;b&gt;캐싱 전략&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;const httpLink = new HttpLink({
  uri: process.env.NEXT_PUBLIC_HASURA_URL,
  fetchOptions: {
    // 서버: 캐시 안 함 (항상 최신 데이터)
    // 클라이언트: 브라우저 캐시 활용
    ...(isServer &amp;amp;&amp;amp; { cache: 'no-store' })
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 예시: 완전한 설정&lt;/h2&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// lib/apollo/links.ts
import { ApolloLink, from, HttpLink, Observable } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'
import { RetryLink } from '@apollo/client/link/retry'

export interface CreateLinksOptions {
  isServer: boolean
  getToken?: () =&amp;gt; string | Promise&amp;lt;string&amp;gt; | null
}

export function createApolloLinks(options: CreateLinksOptions) {
  const { isServer, getToken } = options
  const prefix = isServer ? ' ️ [Server]' : '  [Client]'

  // 1. Logger Link
  const loggerLink = new ApolloLink((operation, forward) =&amp;gt; {
    if (process.env.NODE_ENV !== 'development') {
      return forward(operation)
    }

    console.log(`${prefix}   ${operation.operationName}`, {
      variables: operation.variables
    })
    const start = Date.now()

    return forward(operation).map(response =&amp;gt; {
      const duration = Date.now() - start
      const emoji = duration &amp;gt; 1000 ? ' ' : '⚡'
      console.log(`${prefix} ${emoji} ${operation.operationName} (${duration}ms)`)
      return response
    })
  })

  // 2. Error Link
  const errorLink = onError(({ graphQLErrors, networkError, operation }) =&amp;gt; {
    if (graphQLErrors) {
      graphQLErrors.forEach(({ message, extensions, path }) =&amp;gt; {
        const errorMsg = `${prefix} ❌ [GraphQL] ${operation.operationName}: ${message}`
        console.error(errorMsg, { path, extensions })

        // 클라이언트에서만 UI 처리
        if (!isServer &amp;amp;&amp;amp; typeof window !== 'undefined') {
          if (extensions?.code === 'UNAUTHENTICATED') {
            localStorage.removeItem('token')
            window.location.href = '/login'
          } else if (extensions?.code === 'FORBIDDEN') {
            // toast.error('권한이 없습니다')
          }
        }
      })
    }

    if (networkError) {
      console.error(`${prefix}   [Network]:`, networkError)

      if (!isServer &amp;amp;&amp;amp; typeof window !== 'undefined') {
        // toast.error('네트워크 오류가 발생했습니다')
      }
    }
  })

  // 3. Auth Link
  const authLink = setContext(async (_, { headers }) =&amp;gt; {
    let token: string | null = null

    if (getToken) {
      const tokenResult = getToken()
      token = tokenResult instanceof Promise ? await tokenResult : tokenResult
    } else if (!isServer &amp;amp;&amp;amp; typeof window !== 'undefined') {
      token = localStorage.getItem('token')
    }

    return {
      headers: {
        ...headers,
        ...(token &amp;amp;&amp;amp; { authorization: `Bearer ${token}` }),
        'x-request-from': isServer ? 'server' : 'client'
      }
    }
  })

  // 4. Retry Link (클라이언트만)
  const retryLink = !isServer
    ? new RetryLink({
        delay: {
          initial: 300,
          max: 5000,
          jitter: true
        },
        attempts: {
          max: 3,
          retryIf: (error) =&amp;gt; {
            // 네트워크 에러만 재시도
            return !!error &amp;amp;&amp;amp; !error.statusCode
          }
        }
      })
    : null

  // 5. HTTP Link
  const httpLink = new HttpLink({
    uri: process.env.NEXT_PUBLIC_HASURA_URL,
    credentials: 'include',
    ...(isServer &amp;amp;&amp;amp; {
      fetch: fetch,
      fetchOptions: {
        cache: 'no-store'
      }
    })
  })

  // Link 체인 조합
  const links = [
    loggerLink,
    errorLink,
    authLink,
    ...(retryLink ? [retryLink] : []),
    httpLink
  ]

  return from(links)
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;// lib/apollo/cache.ts
import { InMemoryCache } from '@apollo/client'

export function createApolloCache() {
  return new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          campaign_application: {
            keyArgs: ['where', 'order_by'],
            merge(existing, incoming, { args }) {
              if (args?.offset === 0) return incoming
              return [...(existing ?? []), ...incoming]
            }
          }
        }
      }
    }
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo/client-rsc.ts
import { ApolloClient } from '@apollo/client'
import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc'
import { cookies } from 'next/headers'
import { createApolloLinks } from './links'
import { createApolloCache } from './cache'

export const { getClient } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
    cache: createApolloCache(),
    link: createApolloLinks({
      isServer: true,
      getToken: async () =&amp;gt; {
        const cookieStore = await cookies()
        return cookieStore.get('token')?.value || null
      }
    })
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo/client.tsx
'use client'
import { ApolloClient } from '@apollo/client'
import { ApolloNextAppProvider } from '@apollo/experimental-nextjs-app-support/ssr'
import { createApolloLinks } from './links'
import { createApolloCache } from './cache'

function makeClient() {
  return new ApolloClient({
    cache: createApolloCache(),
    link: createApolloLinks({
      isServer: false
    })
  })
}

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return (
    &amp;lt;ApolloNextAppProvider makeClient={makeClient}&amp;gt;
      {children}
    &amp;lt;/ApolloNextAppProvider&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사용 예시&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/campaigns/[id]/page.tsx (Server Component)
import { getClient } from '@/lib/apollo/client-rsc'
import { gql } from '@apollo/client'

export default async function CampaignPage({ params }) {
  const client = getClient()

  const { data } = await client.query({
    query: GET_CAMPAIGN,
    variables: { id: params.id }
  })
  // &amp;rarr; 서버 전용 Link 사용 (로깅에  ️ [Server] 표시)

  return &amp;lt;div&amp;gt;{data.campaign.title}&amp;lt;/div&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/campaigns/[id]/applications.tsx (Client Component)
'use client'
import { useQuery } from '@apollo/client'

export default function Applications({ campaignId }) {
  const { data } = useQuery(GET_APPLICATIONS, {
    variables: { campaignId }
  })
  // &amp;rarr; 클라이언트 전용 Link 사용 (로깅에   [Client] 표시)

  return &amp;lt;ul&amp;gt;{/* ... */}&amp;lt;/ul&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;콘솔 출력 예시&lt;/h2&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt; ️ [Server]   GetCampaign { variables: { id: 'abc-123' } }
 ️ [Server] ⚡ GetCampaign (45ms)

  [Client]   GetApplications { variables: { campaignId: 'abc-123' } }
  [Client] ⚡ GetApplications (156ms)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제:&lt;/b&gt; Server/Client Apollo Client를 따로 만들면 Link 중복&lt;br /&gt;&lt;b&gt;해결:&lt;/b&gt; Link 생성 로직을 팩토리 함수로 공통화&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// ❌ 중복
const serverClient = new ApolloClient({ link: from([...]) })
const clientClient = new ApolloClient({ link: from([...]) })

// ✅ 공통화
const serverClient = new ApolloClient({ 
  link: createApolloLinks({ isServer: true }) 
})
const clientClient = new ApolloClient({ 
  link: createApolloLinks({ isServer: false }) 
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심:&lt;/b&gt; &lt;code&gt;isServer&lt;/code&gt; 플래그로 서버/클라이언트 동작만 분기하고, Link 로직 자체는 하나로 통합!&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;from()&lt;/code&gt;은 Apollo Client의 Link 체이닝 함수&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;import 위치&lt;/h3&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;import { from } from '@apollo/client'
// 또는
import { from } from '@apollo/client/link/core'&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할: 여러 Link를 하나로 연결&lt;/h3&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;// 여러 개의 Link를 하나로 합침
const combinedLink = from([
  loggerLink,
  errorLink,
  authLink,
  httpLink
])

// 이렇게 쓰는 것과 동일
const combinedLink = loggerLink
  .concat(errorLink)
  .concat(authLink)
  .concat(httpLink)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동작 원리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;from()&lt;/code&gt;의 내부 구현 (단순화)&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Apollo Client 내부 코드 (개념적으로)
function from(links: ApolloLink[]): ApolloLink {
  if (links.length === 0) {
    throw new Error('링크가 없습니다')
  }

  if (links.length === 1) {
    return links[0]
  }

  // 배열을 역순으로 순회하며 연결
  return links.reduceRight((rest, link) =&amp;gt; {
    return link.concat(rest)
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉, from은 여러 Link를 연결 리스트처럼 이어주는 함수!&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 동작 예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const loggerLink = new ApolloLink((operation, forward) =&amp;gt; {
  console.log('1. Logger Start')
  return forward(operation).map(response =&amp;gt; {
    console.log('4. Logger End')
    return response
  })
})

const authLink = new ApolloLink((operation, forward) =&amp;gt; {
  console.log('2. Auth Start')
  operation.setContext({ headers: { authorization: 'Bearer token' } })
  return forward(operation).map(response =&amp;gt; {
    console.log('3. Auth End')
    return response
  })
})

const httpLink = new HttpLink({ uri: '/graphql' })

// from()으로 연결
const link = from([loggerLink, authLink, httpLink])&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실행 흐름&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;요청 시작
   &amp;darr;
1. Logger Start      (loggerLink의 forward 호출 전)
   &amp;darr;
2. Auth Start        (authLink의 forward 호출 전)
   &amp;darr;
[HTTP 요청]          (httpLink가 실제 요청)
   &amp;darr;
[서버 응답]
   &amp;darr;
3. Auth End          (authLink의 map 내부)
   &amp;darr;
4. Logger End        (loggerLink의 map 내부)
   &amp;darr;
컴포넌트로 반환&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;from()&lt;/code&gt;과 &lt;code&gt;concat()&lt;/code&gt;의 관계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 1: &lt;code&gt;from()&lt;/code&gt; 사용 (권장)&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const link = from([
  link1,
  link2,
  link3
])&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 2: &lt;code&gt;concat()&lt;/code&gt; 사용 (동일한 결과)&lt;/h3&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;const link = link1.concat(link2).concat(link3)

// 또는
const link = ApolloLink.from([link1, link2, link3])&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;from()&lt;/code&gt;이 더 읽기 쉬움!&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 배열로 받을까?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;조건부 Link 추가가 쉬워짐&lt;/h3&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;const links = [
  loggerLink,
  errorLink,
  authLink
]

// 개발 환경에서만 추가
if (process.env.NODE_ENV === 'development') {
  links.push(debugLink)
}

// 클라이언트에서만 추가
if (!isServer) {
  links.push(retryLink)
}

// HTTP는 항상 마지막
links.push(httpLink)

// 한 번에 연결
const link = from(links)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;배열 조작으로 동적 구성이 가능!&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 1: 스프레드 연산자 활용&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const commonLinks = [loggerLink, errorLink, authLink]
const clientOnlyLinks = [retryLink, analyticsLink]
const serverOnlyLinks = [cacheLink]

const link = from([
  ...commonLinks,
  ...(isServer ? serverOnlyLinks : clientOnlyLinks),
  httpLink
])&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 2: filter로 조건부 제거&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const link = from([
  process.env.NODE_ENV === 'development' &amp;amp;&amp;amp; loggerLink,
  errorLink,
  authLink,
  !isServer &amp;amp;&amp;amp; retryLink,
  httpLink
].filter(Boolean))  // falsy 값 제거&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 3: 명시적 분기&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function createLinks(isServer: boolean) {
  const baseLinks = [errorLink, authLink]

  if (isServer) {
    return from([...baseLinks, httpLink])
  } else {
    return from([loggerLink, ...baseLinks, retryLink, httpLink])
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TypeScript 타입&lt;/h2&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// from의 타입 정의
function from(links: ApolloLink[]): ApolloLink

// ApolloLink 타입
class ApolloLink {
  constructor(
    request?: (
      operation: Operation,
      forward: NextLink
    ) =&amp;gt; Observable&amp;lt;FetchResult&amp;gt; | null
  )

  concat(next: ApolloLink | RequestHandler): ApolloLink

  static from(links: ApolloLink[]): ApolloLink
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;from()&lt;/code&gt;의 대안들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;대안 1: &lt;code&gt;concat()&lt;/code&gt; 체이닝&lt;/h3&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;// from() 사용
const link = from([link1, link2, link3])

// concat() 사용 (동일)
const link = link1.concat(link2).concat(link3)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;대안 2: &lt;code&gt;ApolloLink.from()&lt;/code&gt; (정적 메서드)&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// from() 함수
import { from } from '@apollo/client'
const link = from([link1, link2])

// ApolloLink.from() 정적 메서드 (동일)
import { ApolloLink } from '@apollo/client'
const link = ApolloLink.from([link1, link2])&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(같은 것임)&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;내부 동작 깊게 이해하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;forward(operation)&lt;/code&gt;의 의미&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const myLink = new ApolloLink((operation, forward) =&amp;gt; {
  console.log('Before')

  // forward = &quot;다음 Link로 넘기기&quot;
  const observable = forward(operation)

  return observable.map(response =&amp;gt; {
    console.log('After')
    return response
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;forward(operation)&lt;/code&gt;은 체인의 다음 Link를 실행하는 함수예요.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;체인의 끝 (httpLink)&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// httpLink는 forward를 호출하지 않고, 실제 HTTP 요청을 함
const httpLink = new HttpLink({ uri: '/graphql' })

// 내부적으로 이런 느낌
new ApolloLink((operation, forward) =&amp;gt; {
  // forward 호출 안 함!
  return makeHttpRequest(operation)  // 실제 요청
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그래서 httpLink는 항상 마지막에 와야 함 !important&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;잘못된 사용 예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ httpLink를 중간에 넣으면?&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const link = from([
  loggerLink,
  httpLink,    // ❌ 중간에!
  authLink     // 실행 안 됨!
])&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과:&lt;/b&gt; authLink는 절대 실행 안 됨 (httpLink에서 체인이 끝남)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ from에 빈 배열&lt;/h3&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;const link = from([])  // ❌ 에러!
// Error: from() requires at least one link&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ Link가 아닌 것 넣기&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;const link = from([
  loggerLink,
  'not a link',  // ❌ 타입 에러!
  httpLink
])&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디버깅 팁&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Link 체인 확인하기&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const debugLink = new ApolloLink((operation, forward) =&amp;gt; {
  console.log('  Current Link')
  console.log('Operation:', operation.operationName)
  console.log('Variables:', operation.variables)
  console.log('Context:', operation.getContext())

  return forward(operation).map(response =&amp;gt; {
    console.log('  Response received')
    console.log('Data:', response.data)
    return response
  })
})

// 여러 곳에 삽입해서 흐름 확인
const link = from([
  debugLink,  // 1번째 체크포인트
  loggerLink,
  debugLink,  // 2번째 체크포인트
  authLink,
  debugLink,  // 3번째 체크포인트
  httpLink
])&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;비유로 이해하기&lt;/h2&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// from()은 &quot;도미노 연결&quot;과 유사

from([link1, link2, link3])

// = link1 &amp;rarr; link2 &amp;rarr; link3 &amp;rarr; 결과

// 각 Link는 도미노 한 칸
// forward()는 &quot;다음 도미노 밀기&quot;
// 마지막 Link(httpLink)가 쓰러지면 응답이 돌아옴&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무 예시&lt;/h2&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;// lib/apollo/links.ts
import { ApolloLink, from, HttpLink } from '@apollo/client'

export function createApolloLinks(isServer: boolean) {
  // 동적으로 Link 배열 구성
  const links: ApolloLink[] = []

  // 개발 환경에서만 로거
  if (process.env.NODE_ENV === 'development') {
    links.push(createLoggerLink(isServer))
  }

  // 공통 Link들
  links.push(
    createErrorLink(isServer),
    createAuthLink(isServer)
  )

  // 클라이언트에서만 재시도
  if (!isServer) {
    links.push(createRetryLink())
  }

  // 프로덕션에서만 분석
  if (process.env.NODE_ENV === 'production' &amp;amp;&amp;amp; !isServer) {
    links.push(createAnalyticsLink())
  }

  // HTTP는 항상 마지막
  links.push(createHttpLink(isServer))

  // from()으로 체인 생성
  return from(links)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// from()은 Apollo Client의 Link 체이닝 함수
import { from } from '@apollo/client'

// 배열을 받아서 하나의 Link로 연결
const link = from([link1, link2, link3])

// Array.from()과는 완전 다른 함수!
Array.from([1, 2, 3])  // 배열 변환
from([link1, link2])   // Link 연결&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ &lt;code&gt;from()&lt;/code&gt;은 Apollo의 Link 체이닝 함수&lt;/li&gt;
&lt;li&gt;✅ 여러 Link를 하나로 연결&lt;/li&gt;
&lt;li&gt;✅ 배열 순서 = 실행 순서&lt;/li&gt;
&lt;li&gt;✅ 마지막은 항상 httpLink&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>library</category>
      <category>ApolloClient</category>
      <category>nextjs</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/303</guid>
      <comments>https://ifelseif.tistory.com/303#entry303comment</comments>
      <pubDate>Mon, 29 Sep 2025 21:32:27 +0900</pubDate>
    </item>
    <item>
      <title>[250929 TIL] ApolloLink</title>
      <link>https://ifelseif.tistory.com/302</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;ApolloLink = GraphQL 요청/응답 미들웨어&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Express.js의 미들웨어와 완전히 같은 개념이에요:&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Express.js 미들웨어&lt;/h3&gt;
&lt;pre class=&quot;moonscript&quot;&gt;&lt;code&gt;// Express
app.use((req, res, next) =&amp;gt; {
  console.log('Request:', req.url)  // 로깅
  next()  // 다음 미들웨어로
})

app.use((req, res, next) =&amp;gt; {
  req.user = getCurrentUser()  // 인증
  next()
})

app.use((req, res, next) =&amp;gt; {
  if (!req.user) {
    return res.status(401).send('Unauthorized')  // 권한 체크
  }
  next()
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Apollo Link (같은 패턴!)&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Apollo Link
const loggerLink = new ApolloLink((operation, forward) =&amp;gt; {
  console.log('Request:', operation.operationName)  // 로깅
  return forward(operation)  // 다음 링크로
})

const authLink = new ApolloLink((operation, forward) =&amp;gt; {
  const token = getToken()  // 인증
  operation.setContext({
    headers: { authorization: `Bearer ${token}` }
  })
  return forward(operation)
})

const errorLink = onError(({ networkError }) =&amp;gt; {
  if (networkError?.statusCode === 401) {
    logout()  // 권한 체크
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;미들웨어 체인의 흐름&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요청/응답 흐름&lt;/h3&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;요청 시작
   &amp;darr;
[Logger Link]     &amp;rarr; console.log('GetCampaigns')
   &amp;darr;
[Auth Link]       &amp;rarr; 헤더에 토큰 추가
   &amp;darr;
[Retry Link]      &amp;rarr; 실패 시 재시도 준비
   &amp;darr;
[Error Link]      &amp;rarr; 에러 감지 준비
   &amp;darr;
[HTTP Link]       &amp;rarr; 실제 서버로 요청
   &amp;darr;
──────────────────────────────────
서버 처리
──────────────────────────────────
   &amp;darr;
[HTTP Link]       &amp;rarr; 응답 받음
   &amp;darr;
[Error Link]      &amp;rarr; 에러 있나 체크
   &amp;darr;
[Retry Link]      &amp;rarr; 재시도 필요한가?
   &amp;darr;
[Auth Link]       &amp;rarr; (응답에는 보통 작업 없음)
   &amp;darr;
[Logger Link]     &amp;rarr; console.log('Took 234ms')
   &amp;darr;
컴포넌트로 반환&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무에서 자주 쓰는 Link들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;인증 Link (가장 기본)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { setContext } from '@apollo/client/link/context'

const authLink = setContext((_, { headers }) =&amp;gt; {
  const token = localStorage.getItem('token')

  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    }
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 요청에 토큰 자동 추가&lt;/li&gt;
&lt;li&gt;컴포넌트에서 신경 안 써도 됨&lt;/li&gt;
&lt;li&gt;Hasura의 JWT 인증에 필수!&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 이렇게 안 해도 됨! ❌
useQuery(GET_CAMPAIGNS, {
  context: {
    headers: { authorization: `Bearer ${token}` }
  }
})

// authLink가 자동으로 해줌 ✅
useQuery(GET_CAMPAIGNS)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;에러 처리 Link&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { onError } from '@apollo/client/link/error'

const errorLink = onError(({ graphQLErrors, networkError, operation }) =&amp;gt; {
  // GraphQL 에러 (서버가 응답은 했지만 에러)
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message, extensions }) =&amp;gt; {
      if (extensions?.code === 'UNAUTHENTICATED') {
        console.error('로그인 필요!')
        // 로그인 페이지로 리다이렉트
        window.location.href = '/login'
      }

      if (extensions?.code === 'FORBIDDEN') {
        toast.error('권한이 없습니다')
      }

      if (message.includes('unique constraint')) {
        toast.error('이미 존재하는 데이터입니다')
      }
    })
  }

  // 네트워크 에러 (서버 응답 자체가 없음)
  if (networkError) {
    console.error('네트워크 에러:', networkError)
    toast.error('서버 연결 실패')
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;에러를 한 곳에서 중앙 집중 처리&lt;/li&gt;
&lt;li&gt;컴포넌트마다 에러 처리 코드 반복 안 해도 됨&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// 이렇게 매번 안 해도 됨 ❌
const { data, error } = useQuery(GET_CAMPAIGNS)

if (error) {
  if (error.message.includes('auth')) {
    window.location.href = '/login'
  }
  if (error.message.includes('unique')) {
    toast.error('중복')
  }
  // 반복 반복 반복...
}

// errorLink가 자동 처리 ✅
const { data } = useQuery(GET_CAMPAIGNS)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. &lt;b&gt;재시도 Link&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;import { RetryLink } from '@apollo/client/link/retry'

const retryLink = new RetryLink({
  delay: {
    initial: 300,    // 첫 재시도: 300ms 후
    max: 5000,       // 최대 대기: 5초
    jitter: true     // 랜덤 지연 추가 (서버 부하 분산)
  },
  attempts: {
    max: 3,          // 최대 3번 재시도
    retryIf: (error, operation) =&amp;gt; {
      // 네트워크 에러만 재시도
      return !!error &amp;amp;&amp;amp; !error.statusCode

      // 또는 특정 상태 코드만
      // return error?.statusCode === 503  // 서버 점검 중
    }
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일시적 네트워크 문제 자동 해결&lt;/li&gt;
&lt;li&gt;사용자 경험 개선&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 수동 재시도 ❌
const [getCampaigns, { loading, error }] = useLazyQuery(GET_CAMPAIGNS)

const handleClick = async () =&amp;gt; {
  try {
    await getCampaigns()
  } catch (error) {
    // 재시도 로직
    await new Promise(r =&amp;gt; setTimeout(r, 1000))
    try {
      await getCampaigns()
    } catch {
      // 또 재시도...
    }
  }
}

// retryLink가 자동 처리 ✅
const { data } = useQuery(GET_CAMPAIGNS)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. &lt;b&gt;로딩 상태 Link&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { makeVar } from '@apollo/client'

// 전역 상태
export const isLoadingVar = makeVar(false)
export const activeQueriesVar = makeVar&amp;lt;string[]&amp;gt;([])

const loadingLink = new ApolloLink((operation, forward) =&amp;gt; {
  // 요청 시작
  const operationName = operation.operationName

  activeQueriesVar([...activeQueriesVar(), operationName])
  isLoadingVar(true)

  return forward(operation).map(response =&amp;gt; {
    // 응답 완료
    const active = activeQueriesVar().filter(name =&amp;gt; name !== operationName)
    activeQueriesVar(active)

    if (active.length === 0) {
      isLoadingVar(false)
    }

    return response
  })
})

// 컴포넌트에서 사용
function GlobalLoader() {
  const isLoading = useReactiveVar(isLoadingVar)
  const activeQueries = useReactiveVar(activeQueriesVar)

  return (
    &amp;lt;div&amp;gt;
      {isLoading &amp;amp;&amp;amp; &amp;lt;Spinner /&amp;gt;}
      &amp;lt;div&amp;gt;Active: {activeQueries.join(', ')}&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;전역 로딩 스피너&lt;/li&gt;
&lt;li&gt;활성 쿼리 추적&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. &lt;b&gt;조건부 Link (WebSocket vs HTTP)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { split } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'

const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:8080/v1/graphql',
    connectionParams: {
      headers: {
        authorization: `Bearer ${getToken()}`
      }
    }
  })
)

const httpLink = new HttpLink({
  uri: 'http://localhost:8080/v1/graphql'
})

// subscription은 WebSocket, 나머지는 HTTP
const splitLink = split(
  ({ query }) =&amp;gt; {
    const definition = getMainDefinition(query)
    return (
      definition.kind === 'OperationDefinition' &amp;amp;&amp;amp;
      definition.operation === 'subscription'
    )
  },
  wsLink,    // true면 WebSocket
  httpLink   // false면 HTTP
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실시간 subscription은 WebSocket&lt;/li&gt;
&lt;li&gt;일반 query/mutation은 HTTP&lt;/li&gt;
&lt;li&gt;자동으로 적절한 프로토콜 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. &lt;b&gt;캐시 무효화 Link&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;const invalidationLink = new ApolloLink((operation, forward) =&amp;gt; {
  return forward(operation).map(response =&amp;gt; {
    // mutation 성공 시 관련 쿼리 무효화
    if (operation.query.definitions[0].operation === 'mutation') {
      const mutationName = operation.operationName

      // 특정 mutation 후 캐시 무효화
      if (mutationName === 'UpdateCampaign') {
        client.cache.evict({ 
          id: 'ROOT_QUERY',
          fieldName: 'campaigns' 
        })
      }

      if (mutationName === 'CreateApplication') {
        client.refetchQueries({
          include: ['GetApplications', 'GetCampaignStats']
        })
      }
    }

    return response
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;mutation 후 자동으로 관련 데이터 갱신&lt;/li&gt;
&lt;li&gt;수동 refetch 불필요&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. &lt;b&gt;분석/모니터링 Link&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const analyticsLink = new ApolloLink((operation, forward) =&amp;gt; {
  const start = performance.now()

  return forward(operation).map(response =&amp;gt; {
    const duration = performance.now() - start

    // 분석 서비스로 전송
    analytics.track('GraphQL Query', {
      operationName: operation.operationName,
      operationType: operation.query.definitions[0].operation,
      duration,
      variables: operation.variables,
      slow: duration &amp;gt; 1000  // 1초 이상이면 slow
    })

    // 느린 쿼리 경고
    if (duration &amp;gt; 3000) {
      console.warn(`⚠️ Slow query: ${operation.operationName} (${duration}ms)`)

      // Sentry 등으로 전송
      Sentry.captureMessage(`Slow GraphQL query: ${operation.operationName}`, {
        level: 'warning',
        extra: { duration, variables: operation.variables }
      })
    }

    return response
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. &lt;b&gt;Request ID Link (디버깅용)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { v4 as uuid } from 'uuid'

const requestIdLink = new ApolloLink((operation, forward) =&amp;gt; {
  const requestId = uuid()

  // 요청에 ID 추가
  operation.setContext({
    headers: {
      'x-request-id': requestId
    }
  })

  console.log(`[${requestId}] ${operation.operationName} started`)

  return forward(operation).map(response =&amp;gt; {
    console.log(`[${requestId}] ${operation.operationName} completed`)
    return response
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 이유:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청 추적&lt;/li&gt;
&lt;li&gt;서버 로그와 매칭&lt;/li&gt;
&lt;li&gt;디버깅 용이&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 Link 조합&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기본 구성&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { ApolloClient, from, HttpLink } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'

// 1. 인증
const authLink = setContext((_, { headers }) =&amp;gt; ({
  headers: {
    ...headers,
    authorization: `Bearer ${getToken()}`
  }
}))

// 2. 에러 처리
const errorLink = onError(({ graphQLErrors, networkError }) =&amp;gt; {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ extensions }) =&amp;gt; {
      if (extensions?.code === 'UNAUTHENTICATED') {
        window.location.href = '/login'
      }
    })
  }

  if (networkError) {
    toast.error('네트워크 에러')
  }
})

// 3. HTTP
const httpLink = new HttpLink({
  uri: process.env.NEXT_PUBLIC_HASURA_URL
})

// 조합 (순서 중요!)
const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: from([
    errorLink,  // 에러 처리 먼저
    authLink,   // 그 다음 인증
    httpLink    // 마지막에 HTTP
  ])
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프로덕션 구성&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { RetryLink } from '@apollo/client/link/retry'

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: from([
    // 개발 환경에서만 로거
    ...(process.env.NODE_ENV === 'development' ? [loggerLink] : []),

    // 에러 처리
    errorLink,

    // 분석
    analyticsLink,

    // 인증
    authLink,

    // 재시도
    retryLink,

    // HTTP/WebSocket 분기
    splitLink
  ])
})&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Link 작성 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 1: 단순 변환&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const uppercaseLink = new ApolloLink((operation, forward) =&amp;gt; {
  // 요청 변환
  operation.operationName = operation.operationName.toUpperCase()

  return forward(operation).map(response =&amp;gt; {
    // 응답 변환
    return {
      ...response,
      data: transformData(response.data)
    }
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 2: 조건부 실행&lt;/h3&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;const cacheFirstLink = new ApolloLink((operation, forward) =&amp;gt; {
  const cached = checkCache(operation)

  if (cached) {
    // 캐시 있으면 요청 안 함
    return Observable.of({ data: cached })
  }

  // 캐시 없으면 진행
  return forward(operation)
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 3: 비동기 처리&lt;/h3&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;const asyncAuthLink = new ApolloLink((operation, forward) =&amp;gt; {
  // 비동기로 토큰 갱신
  return new Observable(observer =&amp;gt; {
    refreshTokenIfNeeded()
      .then(() =&amp;gt; {
        const token = getToken()
        operation.setContext({
          headers: { authorization: `Bearer ${token}` }
        })
        return forward(operation)
      })
      .then(observable =&amp;gt; {
        observable.subscribe(observer)
      })
      .catch(observer.error.bind(observer))
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TanStack Query와 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TanStack Query의 미들웨어 스타일&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 전역 설정 (Link와 비슷한 역할)
      retry: 3,
      staleTime: 5000,
      onError: (error) =&amp;gt; {
        console.error(error)
      },
      // 하지만 체인은 안 됨
    }
  }
})

// 개별 커스터마이징
useQuery({
  queryKey: ['campaigns'],
  queryFn: fetchCampaigns,
  onSuccess: (data) =&amp;gt; {
    // 성공 처리
  },
  onError: (error) =&amp;gt; {
    // 에러 처리
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;차이점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TanStack Query: 옵션 기반&lt;/li&gt;
&lt;li&gt;Apollo Link: 체인 기반 (더 유연)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;// ApolloLink = 미들웨어 체인
//   ├─ 로거 (미들웨어의 한 예시)
//   ├─ 인증 (토큰 추가)
//   ├─ 에러 처리
//   ├─ 재시도
//   ├─ 분석
//   └─ ... 무한 확장 가능!

// Express 미들웨어와 동일한 개념
app.use(logger)
app.use(auth)
app.use(errorHandler)

// Apollo Link
from([loggerLink, authLink, errorLink, httpLink])&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;로거는 Link의 활용 예시 중 하나일 뿐!&lt;/b&gt; ✨&lt;/p&gt;</description>
      <category>library</category>
      <category>apolloLink</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/302</guid>
      <comments>https://ifelseif.tistory.com/302#entry302comment</comments>
      <pubDate>Mon, 29 Sep 2025 21:28:59 +0900</pubDate>
    </item>
    <item>
      <title>[250929 TIL] tanstack vs apollo 쿼리캐시 비교</title>
      <link>https://ifelseif.tistory.com/301</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;캐시 키 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TanStack Query&lt;/h3&gt;
&lt;pre class=&quot;scilab&quot;&gt;&lt;code&gt;// 명시적으로 캐시 키 지정
useQuery({
  queryKey: ['campaigns', { status: 'open', page: 1 }],
  queryFn: () =&amp;gt; fetchCampaigns({ status: 'open', page: 1 })
})

// 캐시 구조
{
  '[&quot;campaigns&quot;,{&quot;status&quot;:&quot;open&quot;,&quot;page&quot;:1}]': { data: [...], ... },
  '[&quot;campaigns&quot;,{&quot;status&quot;:&quot;open&quot;,&quot;page&quot;:2}]': { data: [...], ... },
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Apollo Client&lt;/h3&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;// 캐시 키 자동 생성!
useQuery(GET_CAMPAIGNS, {
  variables: { status: 'open', page: 1 }
})

// 캐시 구조 (자동 생성)
{
  'Query': {
    'campaigns({&quot;status&quot;:&quot;open&quot;,&quot;page&quot;:1})': [...],
    'campaigns({&quot;status&quot;:&quot;open&quot;,&quot;page&quot;:2})': [...],
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; Apollo Client는 GraphQL 쿼리를 분석해서 자동으로 캐시 키를 만듭니다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자동 캐시 키 생성 원리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;기본 규칙: 필드 이름 + 모든 인자&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;// GraphQL 쿼리
query GetCampaigns($status: String, $page: Int) {
  campaigns(status: $status, page: $page) {
    id
    title
  }
}

// 자동 생성되는 캐시 키
'campaigns({&quot;status&quot;:&quot;open&quot;,&quot;page&quot;:1})'
'campaigns({&quot;status&quot;:&quot;open&quot;,&quot;page&quot;:2})'
// &amp;rarr; TanStack Query의 queryKey와 동일한 개념!&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;keyArgs로 캐시 키 제어&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        campaigns: {
          keyArgs: ['status'],  // page는 무시!
        },
      },
    },
  },
})

// 결과 캐시 키
'campaigns({&quot;status&quot;:&quot;open&quot;})'  // page 1이든 2든 같은 키!

// TanStack Query로 비유하면:
useQuery({
  queryKey: ['campaigns', { status: 'open' }],  // page 제외
  // ...
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉, keyArgs는 &quot;어떤 인자로 캐시를 구분할지&quot; 선택.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 동작 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TanStack Query&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// 쿼리 1
const query1 = useQuery({
  queryKey: ['campaigns', { status: 'open', page: 1 }],
  queryFn: fetchCampaigns
})

// 쿼리 2
const query2 = useQuery({
  queryKey: ['campaigns', { status: 'open', page: 1 }],  // 동일!
  queryFn: fetchCampaigns
})
// &amp;rarr; 쿼리 2는 캐시 hit! 네트워크 요청 안 함 ✅&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Apollo Client (keyArgs 없을 때)&lt;/h3&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;// 쿼리 1
const query1 = useQuery(GET_CAMPAIGNS, {
  variables: { status: 'open', page: 1 }
})
// 캐시 키: 'campaigns({&quot;status&quot;:&quot;open&quot;,&quot;page&quot;:1})'

// 쿼리 2
const query2 = useQuery(GET_CAMPAIGNS, {
  variables: { status: 'open', page: 1 }  // 동일!
})
// 캐시 키: 'campaigns({&quot;status&quot;:&quot;open&quot;,&quot;page&quot;:1})'
// &amp;rarr; 캐시 hit! 네트워크 요청 안 함 ✅&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Apollo Client (keyArgs 있을 때)&lt;/h3&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        campaigns: {
          keyArgs: ['status'],  // page 무시
        },
      },
    },
  },
})

// 쿼리 1
const query1 = useQuery(GET_CAMPAIGNS, {
  variables: { status: 'open', page: 1 }
})
// 캐시 키: 'campaigns({&quot;status&quot;:&quot;open&quot;})'
// 캐시 내용: [item1, item2, ..., item10]

// 쿼리 2
const query2 = useQuery(GET_CAMPAIGNS, {
  variables: { status: 'open', page: 2 }  // page만 다름
})
// 캐시 키: 'campaigns({&quot;status&quot;:&quot;open&quot;})'  // 같은 키!
// &amp;rarr; 캐시 hit하지만, merge 함수가 실행됨!

// merge 함수
merge(existing, incoming, { args }) {
  // existing: [item1, ..., item10] (page 1)
  // incoming: [item11, ..., item20] (page 2)
  // args: { status: 'open', page: 2 }

  return [...existing, ...incoming]  // 무한 스크롤!
  // 결과: [item1, ..., item10, item11, ..., item20]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TanStack Query로 비유하면:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;arcade&quot;&gt;&lt;code&gt;// 이런 느낌!
useInfiniteQuery({
  queryKey: ['campaigns', { status: 'open' }],  // page 제외
  queryFn: ({ pageParam }) =&amp;gt; fetchCampaigns(pageParam),
  getNextPageParam: (lastPage) =&amp;gt; lastPage.nextPage,
})&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;InMemory 저장 방식&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TanStack Query&lt;/h3&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;// 메모리 구조
const queryCache = {
  queries: [
    {
      queryKey: ['campaigns', { status: 'open' }],
      state: {
        data: [...],
        status: 'success',
        fetchStatus: 'idle',
      }
    },
    {
      queryKey: ['campaign', { id: 'abc-123' }],
      state: { data: {...}, ... }
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Apollo Client&lt;/h3&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// 메모리 구조 (정규화됨!)
const cache = {
  ROOT_QUERY: {
    'campaigns({&quot;status&quot;:&quot;open&quot;})': [
      { __ref: 'Campaign:1' },
      { __ref: 'Campaign:2' },
    ],
    'campaign({&quot;id&quot;:&quot;abc-123&quot;})': { __ref: 'Campaign:abc-123' }
  },
  'Campaign:1': {
    __typename: 'Campaign',
    id: '1',
    title: 'Summer Sale',
    status: 'open'
  },
  'Campaign:2': {
    __typename: 'Campaign',
    id: '2',
    title: 'Winter Sale',
    status: 'open'
  },
  'Campaign:abc-123': {
    __typename: 'Campaign',
    id: 'abc-123',
    title: 'Spring Sale',
    status: 'closed'
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 차이: Apollo Client는 객체를 정규화해서 참조로 저장!&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정규화의 장점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TanStack Query (정규화 없음)&lt;/h3&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;// 쿼리 1: 캠페인 목록
useQuery({
  queryKey: ['campaigns'],
  queryFn: () =&amp;gt; [
    { id: '1', title: 'Summer Sale', status: 'open' },
    { id: '2', title: 'Winter Sale', status: 'open' }
  ]
})

// 쿼리 2: 특정 캠페인
useQuery({
  queryKey: ['campaign', '1'],
  queryFn: () =&amp;gt; ({ id: '1', title: 'Summer Sale', status: 'open' })
})

// 쿼리 3: 캠페인 업데이트
useMutation({
  mutationFn: updateCampaign,
  onSuccess: (data) =&amp;gt; {
    // 수동으로 모든 관련 캐시 업데이트 필요!
    queryClient.setQueryData(['campaign', '1'], data)
    queryClient.setQueryData(['campaigns'], (old) =&amp;gt; 
      old.map(c =&amp;gt; c.id === '1' ? data : c)
    )
    //   실수하기 쉽고 번거로움
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Apollo Client (정규화됨)&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 쿼리 1: 캠페인 목록
useQuery(GET_CAMPAIGNS)
// 캐시: Campaign:1, Campaign:2 객체 생성

// 쿼리 2: 특정 캠페인
useQuery(GET_CAMPAIGN, { variables: { id: '1' } })
// 캐시: Campaign:1 재사용! (이미 있음)

// 쿼리 3: 캠페인 업데이트
useMutation(UPDATE_CAMPAIGN, {
  variables: { id: '1', title: 'New Title' }
})
// Apollo가 자동으로 Campaign:1 업데이트
// &amp;rarr; 모든 관련 쿼리가 자동으로 리렌더링!  &lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Apollo Client는 같은 객체(id가 같으면)를 한 곳에만 저장하고 참조를 사용.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구체적인 캐시 동작 예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오: 캠페인 목록 &amp;rarr; 상세 &amp;rarr; 업데이트&lt;/h3&gt;
&lt;pre class=&quot;gml&quot;&gt;&lt;code&gt;// 1. 캠페인 목록 조회
const { data: campaigns } = useQuery(gql`
  query GetCampaigns {
    campaigns {
      id
      title
      status
    }
  }
`)

// Apollo 캐시 상태
{
  ROOT_QUERY: {
    'campaigns': [
      { __ref: 'Campaign:1' },
      { __ref: 'Campaign:2' }
    ]
  },
  'Campaign:1': { id: '1', title: 'Summer Sale', status: 'open' },
  'Campaign:2': { id: '2', title: 'Winter Sale', status: 'open' }
}

// 2. 상세 조회 (추가 필드 포함)
const { data: campaign } = useQuery(gql`
  query GetCampaign($id: ID!) {
    campaign(id: $id) {
      id
      title
      status
      description  # 새로운 필드!
      budget       # 새로운 필드!
    }
  }
`, { variables: { id: '1' } })

// Apollo 캐시 상태 (병합됨!)
{
  ROOT_QUERY: {
    'campaigns': [{ __ref: 'Campaign:1' }, { __ref: 'Campaign:2' }],
    'campaign({&quot;id&quot;:&quot;1&quot;})': { __ref: 'Campaign:1' }
  },
  'Campaign:1': {
    id: '1',
    title: 'Summer Sale',
    status: 'open',
    description: 'Amazing summer deals!',  // 추가됨
    budget: 10000                          // 추가됨
  },
  'Campaign:2': { id: '2', title: 'Winter Sale', status: 'open' }
}

// 3. 업데이트 mutation
const [updateCampaign] = useMutation(gql`
  mutation UpdateCampaign($id: ID!, $title: String!) {
    updateCampaign(id: $id, title: $title) {
      id
      title
      status
    }
  }
`)

await updateCampaign({
  variables: { id: '1', title: 'Super Summer Sale!' }
})

// Apollo 캐시 상태 (자동 업데이트!)
{
  'Campaign:1': {
    id: '1',
    title: 'Super Summer Sale!',  // 자동으로 변경됨!
    status: 'open',
    description: 'Amazing summer deals!',
    budget: 10000
  },
  // ...
}

// 결과: campaigns 쿼리와 campaign 쿼리 모두 자동으로 리렌더링! ✨&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;캐시 키 생성 규칙 상세&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;기본 규칙: 타입명 + id (또는 _id)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// GraphQL 응답
{
  campaign: {
    __typename: &quot;Campaign&quot;,
    id: &quot;abc-123&quot;,
    title: &quot;Summer Sale&quot;
  }
}

// 자동 생성되는 캐시 키
&quot;Campaign:abc-123&quot;

// 커스터마이즈 가능
const cache = new InMemoryCache({
  typePolicies: {
    Campaign: {
      keyFields: ['id'],  // 기본값
      // 또는
      keyFields: ['customId'],
      // 또는 복합 키
      keyFields: ['businessId', 'campaignId'],
    }
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;복합 키 예시&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// GraphQL 응답
{
  campaignApplication: {
    __typename: &quot;CampaignApplication&quot;,
    campaignId: &quot;camp-1&quot;,
    influencerId: &quot;inf-1&quot;,
    status: &quot;approved&quot;
  }
}

// 캐시 설정
const cache = new InMemoryCache({
  typePolicies: {
    CampaignApplication: {
      keyFields: ['campaignId', 'influencerId'],
    }
  }
})

// 생성되는 캐시 키
&quot;CampaignApplication:camp-1:inf-1&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. &lt;b&gt;키가 없는 객체 (리스트 아이템 등)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// keyFields: false &amp;rarr; 캐시 키 없이 인라인 저장
const cache = new InMemoryCache({
  typePolicies: {
    CampaignStats: {
      keyFields: false,  // 캐시 키 생성 안 함
    }
  }
})

// 결과: 부모 객체 안에 직접 저장
{
  'Campaign:1': {
    id: '1',
    title: 'Summer Sale',
    stats: {  // 인라인으로 저장 (참조 없음)
      __typename: 'CampaignStats',
      views: 1000,
      clicks: 100
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TanStack Query vs Apollo Client 정리&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;th&gt;TanStack Query&lt;/th&gt;
&lt;th&gt;Apollo Client&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;캐시 키&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;수동 지정 필수&lt;/td&gt;
&lt;td&gt;자동 생성 (커스터마이즈 가능)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;저장 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;queryKey별로 독립&lt;/td&gt;
&lt;td&gt;정규화 (객체 단위)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;중복 제거&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;없음 (같은 데이터 여러 번 저장)&lt;/td&gt;
&lt;td&gt;자동 (참조 사용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;캐시 업데이트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;수동 (setQueryData)&lt;/td&gt;
&lt;td&gt;자동 (같은 id면 자동 업데이트)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;복잡도&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단순&lt;/td&gt;
&lt;td&gt;복잡 (학습 곡선)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;사용 케이스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;REST API&lt;/td&gt;
&lt;td&gt;GraphQL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Apollo Client에서 TanStack Query 패턴 사용하기&lt;/h3&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// 정규화 비활성화 (TanStack Query처럼)
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        searchResults: {
          keyArgs: ['query'],  // 검색어로만 구분
          merge(existing, incoming) {
            return incoming  // 단순 교체
          },
        },
      },
    },
    SearchResult: {
      keyFields: false,  // 정규화 안 함
    },
  },
})

// 결과: TanStack Query처럼 동작&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;디버깅 팁&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Apollo DevTools 없이 캐시 확인
import { useApolloClient } from '@apollo/client'

function DebugCache() {
  const client = useApolloClient()

  const showCache = () =&amp;gt; {
    console.log(client.cache.extract())
  }

  return &amp;lt;button onClick={showCache}&amp;gt;Show Cache&amp;lt;/button&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;출력 예시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;ROOT_QUERY&quot;: {
    &quot;campaigns({\&quot;status\&quot;:\&quot;open\&quot;})&quot;: [
      {&quot;__ref&quot;: &quot;Campaign:1&quot;},
      {&quot;__ref&quot;: &quot;Campaign:2&quot;}
    ]
  },
  &quot;Campaign:1&quot;: {
    &quot;__typename&quot;: &quot;Campaign&quot;,
    &quot;id&quot;: &quot;1&quot;,
    &quot;title&quot;: &quot;Summer Sale&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;✅ &lt;b&gt;캐시 키 = TanStack Query의 queryKey&lt;/b&gt; (개념적으로 동일)&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;자동 생성됨&lt;/b&gt; (GraphQL 쿼리 기반)&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;InMemory = 메모리에 저장&lt;/b&gt; (TanStack Query와 동일)&lt;/li&gt;
&lt;li&gt;✨ &lt;b&gt;추가로 정규화 기능&lt;/b&gt; (Apollo만의 강점!)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;keyArgs는 &quot;어떤 변수로 캐시를 구분할지&quot; 제어&lt;/b&gt;&lt;br /&gt;&lt;b&gt;merge는 &quot;같은 캐시 키에 데이터가 들어올 때 어떻게 합칠지&quot; 제어&lt;/b&gt;&lt;/p&gt;</description>
      <category>library</category>
      <category>Apollo</category>
      <category>Cache</category>
      <category>querykey</category>
      <category>tanstack</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/301</guid>
      <comments>https://ifelseif.tistory.com/301#entry301comment</comments>
      <pubDate>Mon, 29 Sep 2025 21:27:03 +0900</pubDate>
    </item>
    <item>
      <title>[250929 TIL] (cache)typePolicies, Apollo Link</title>
      <link>https://ifelseif.tistory.com/300</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. InMemoryCache의 typePolicies&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기본 개념: Apollo Client의 캐싱 메커니즘&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apollo Client는 GraphQL 응답을 &lt;b&gt;메모리에 캐싱&lt;/b&gt;해서 같은 데이터를 다시 요청할 때 네트워크 요청 없이 즉시 반환.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        campaign_application: {
          keyArgs: ['where', 'order_by'],
          merge(existing, incoming) {
            return incoming
          },
        },
      },
    },
  },
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상세 분석&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;typePolicies란?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시가 각 타입과 필드를 어떻게 처리할지 정의하는 규칙.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;typePolicies: {
  Query: {  // Query 타입에 대한 정책
    fields: {  // Query 타입의 필드들
      campaign_application: {  // 이 필드에 대한 정책
        // ...
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;keyArgs: ['where', 'order_by']&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 키를 만들 때 어떤 인자를 사용할지 결정.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시로 이해하기:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 쿼리 1
useQuery(GET_APPLICATIONS, {
  variables: {
    where: { campaign_id: { _eq: &quot;abc-123&quot; } },
    order_by: { created_at: &quot;desc&quot; },
    limit: 10
  }
})

// 쿼리 2
useQuery(GET_APPLICATIONS, {
  variables: {
    where: { campaign_id: { _eq: &quot;abc-123&quot; } },
    order_by: { created_at: &quot;desc&quot; },
    limit: 20  // limit만 다름
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;keyArgs가 없다면:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;캐시 키 1: campaign_application({&quot;where&quot;:{...},&quot;order_by&quot;:{...},&quot;limit&quot;:10})
캐시 키 2: campaign_application({&quot;where&quot;:{...},&quot;order_by&quot;:{...},&quot;limit&quot;:20})
&amp;rarr; 다른 키로 인식, 별도로 캐싱&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;keyArgs: ['where', 'order_by']로 설정하면:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;캐시 키 1: campaign_application({&quot;where&quot;:{...},&quot;order_by&quot;:{...}})
캐시 키 2: campaign_application({&quot;where&quot;:{...},&quot;order_by&quot;:{...}})
&amp;rarr; 같은 키! limit는 무시됨&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿼리 2는 네트워크 요청 없이 쿼리 1의 캐시를 재사용&lt;/li&gt;
&lt;li&gt;limit은 클라이언트에서 필터링 (배열 slice)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;merge(existing, incoming)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 캐시 키에 새 데이터가 들어올 때 &lt;b&gt;어떻게 병합할지&lt;/b&gt; 결정.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;merge(existing, incoming) {
  return incoming  // 기존 데이터 버리고 새 데이터로 교체
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동작 시나리오:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;// 1차 쿼리 실행
const { data } = useQuery(GET_APPLICATIONS, {
  variables: { where: { status: { _eq: &quot;pending&quot; } } }
})
// 캐시에 저장: [app1, app2, app3]

// 2차 쿼리 실행 (같은 where, order_by)
refetch()
// 새 데이터: [app1, app2, app4]

// merge 함수 호출됨
merge(
  existing: [app1, app2, app3],  // 기존 캐시
  incoming: [app1, app2, app4]   // 새로 받은 데이터
)
// return incoming &amp;rarr; [app1, app2, app4]로 덮어씀&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다른 merge 전략 예시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;cos&quot;&gt;&lt;code&gt;// 1. 배열 합치기 (무한 스크롤)
merge(existing = [], incoming) {
  return [...existing, ...incoming]
}

// 2. 중복 제거하며 합치기
merge(existing = [], incoming) {
  const existingIds = new Set(existing.map(item =&amp;gt; item.id))
  const newItems = incoming.filter(item =&amp;gt; !existingIds.has(item.id))
  return [...existing, ...newItems]
}

// 3. 특정 조건으로 병합
merge(existing, incoming, { args }) {
  if (args.offset === 0) {
    return incoming  // 첫 페이지면 교체
  }
  return [...existing, ...incoming]  // 아니면 추가
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 사용 예시&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// lib/apollo-client.tsx
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        // 무한 스크롤용
        campaign_application: {
          keyArgs: ['where', 'order_by'],
          merge(existing = [], incoming, { args }) {
            const offset = args?.offset ?? 0
            const merged = existing.slice(0)

            for (let i = 0; i &amp;lt; incoming.length; ++i) {
              merged[offset + i] = incoming[i]
            }

            return merged
          },
        },

        // 단순 교체 (실시간 데이터)
        campaign_by_pk: {
          keyArgs: ['id'],
          merge(existing, incoming) {
            return incoming
          },
        },
      },
    },
  },
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// 컴포넌트 1
const { data } = useQuery(GET_APPLICATIONS, {
  variables: { where: { status: { _eq: &quot;pending&quot; } }, limit: 10 }
})

// 컴포넌트 2 (같은 where, order_by)
const { data } = useQuery(GET_APPLICATIONS, {
  variables: { where: { status: { _eq: &quot;pending&quot; } }, limit: 5 }
})
// &amp;rarr; 네트워크 요청 없이 캐시에서 가져옴! ⚡&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. ApolloLink - 미들웨어 체인&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기본 개념&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apollo Link는 &lt;b&gt;GraphQL 요청의 미들웨어 체인&lt;/b&gt;이에요. Express의 미들웨어와 비슷.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;요청 &amp;rarr; Link 1 &amp;rarr; Link 2 &amp;rarr; Link 3 &amp;rarr; 서버
응답 &amp;larr; Link 1 &amp;larr; Link 2 &amp;larr; Link 3 &amp;larr; 서버&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Logger Link 상세 분석&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const loggerLink = new ApolloLink((operation, forward) =&amp;gt; {
  console.log(`GraphQL Request: ${operation.operationName}`)
  const start = Date.now()

  return forward(operation).map(response =&amp;gt; {
    console.log(`Took ${Date.now() - start}ms`)
    return response
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;매개변수 설명&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;(operation, forward) =&amp;gt; {
  // operation: 현재 GraphQL 작업 정보
  // forward: 다음 링크로 전달하는 함수
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;operation 객체:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;{
  operationName: &quot;GetCampaigns&quot;,  // 쿼리 이름
  query: DocumentNode,            // GraphQL 쿼리 AST
  variables: { id: &quot;abc-123&quot; },   // 변수들
  extensions: {},                 // 확장 정보
  getContext: () =&amp;gt; {},           // 컨텍스트
  setContext: () =&amp;gt; {}            // 컨텍스트 설정
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;실행 흐름&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 1. 요청 시작
console.log(`GraphQL Request: ${operation.operationName}`)
// 출력: &quot;GraphQL Request: GetCampaigns&quot;

// 2. 시작 시간 기록
const start = Date.now()  // 예: 1640000000000

// 3. 다음 링크로 전달 (서버로 요청)
return forward(operation)
  // forward(operation)는 Observable을 반환
  .map(response =&amp;gt; {
    // 4. 응답 받았을 때 실행
    console.log(`Took ${Date.now() - start}ms`)
    // 출력: &quot;Took 234ms&quot;

    // 5. 응답 그대로 반환 (변경 안 함)
    return response
  })&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Observable 패턴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apollo Link는 &lt;b&gt;Observable&lt;/b&gt;을 사용 (RxJS와 비슷).&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Observable의 개념
const observable = forward(operation)

observable.map(response =&amp;gt; {
  // 응답을 변환
  return response
})

observable.subscribe({
  next: (value) =&amp;gt; console.log('받음:', value),
  error: (err) =&amp;gt; console.error('에러:', err),
  complete: () =&amp;gt; console.log('완료')
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실전 활용 예시&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 인증 헤더 추가&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const authLink = new ApolloLink((operation, forward) =&amp;gt; {
  // 토큰 가져오기
  const token = localStorage.getItem('token')

  // 헤더에 추가
  operation.setContext({
    headers: {
      authorization: token ? `Bearer ${token}` : '',
    }
  })

  return forward(operation)
})&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 에러 핸들링&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { onError } from '@apollo/client/link/error'

const errorLink = onError(({ graphQLErrors, networkError, operation }) =&amp;gt; {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message, locations, path }) =&amp;gt; {
      console.error(
        `[GraphQL error]: Message: ${message}, Path: ${path}`
      )
    })
  }

  if (networkError) {
    console.error(`[Network error]: ${networkError}`)
    // 로그인 페이지로 리다이렉트 등
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 재시도 로직&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;import { RetryLink } from '@apollo/client/link/retry'

const retryLink = new RetryLink({
  delay: {
    initial: 300,
    max: 5000,
    jitter: true
  },
  attempts: {
    max: 3,
    retryIf: (error) =&amp;gt; !!error &amp;amp;&amp;amp; error.statusCode !== 400
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. 로딩 상태 추적&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { makeVar } from '@apollo/client'

export const loadingVar = makeVar(false)

const loadingLink = new ApolloLink((operation, forward) =&amp;gt; {
  loadingVar(true)

  return forward(operation).map(response =&amp;gt; {
    loadingVar(false)
    return response
  })
})

// 컴포넌트에서 사용
function GlobalLoader() {
  const isLoading = useReactiveVar(loadingVar)
  return isLoading ? &amp;lt;Spinner /&amp;gt; : null
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;5. 성능 모니터링 (고급)&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const performanceLink = new ApolloLink((operation, forward) =&amp;gt; {
  const start = performance.now()

  return forward(operation).map(response =&amp;gt; {
    const duration = performance.now() - start

    // 분석 서비스로 전송
    analytics.track('GraphQL Query', {
      operationName: operation.operationName,
      duration,
      variables: operation.variables,
      // 느린 쿼리 경고
      slow: duration &amp;gt; 1000
    })

    return response
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;링크 체인 구성&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { ApolloClient, from, HttpLink } from '@apollo/client'

const httpLink = new HttpLink({
  uri: process.env.NEXT_PUBLIC_HASURA_URL,
})

// 체인 순서가 중요!
const client = new ApolloClient({
  cache,
  link: from([
    loggerLink,      // 1. 로깅
    errorLink,       // 2. 에러 처리
    authLink,        // 3. 인증 헤더 추가
    retryLink,       // 4. 재시도
    performanceLink, // 5. 성능 추적
    httpLink,        // 6. 실제 HTTP 요청 (마지막!)
  ]),
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행 순서:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;요청 &amp;rarr; logger &amp;rarr; error &amp;rarr; auth &amp;rarr; retry &amp;rarr; performance &amp;rarr; HTTP &amp;rarr; 서버
                                                               &amp;darr;
응답 &amp;larr; logger &amp;larr; error &amp;larr; auth &amp;larr; retry &amp;larr; performance &amp;larr; HTTP &amp;larr; 서버&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;조건부 링크 (분기 처리)&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { split } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'

const wsLink = new GraphQLWsLink(
  createClient({ url: 'ws://localhost:8080/v1/graphql' })
)

// subscription은 WebSocket, 나머지는 HTTP
const splitLink = split(
  ({ query }) =&amp;gt; {
    const definition = getMainDefinition(query)
    return (
      definition.kind === 'OperationDefinition' &amp;amp;&amp;amp;
      definition.operation === 'subscription'
    )
  },
  wsLink,   // true면 WebSocket
  httpLink  // false면 HTTP
)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 통합 예시&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo-client.tsx
'use client'

import { ApolloClient, ApolloLink, from, HttpLink, InMemoryCache } from '@apollo/client'
import { onError } from '@apollo/client/link/error'
import { ApolloNextAppProvider } from '@apollo/experimental-nextjs-app-support/ssr'

// 캐시 설정
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        campaign_application: {
          keyArgs: ['where', 'order_by'],
          merge(existing, incoming, { args }) {
            if (args?.offset === 0) {
              return incoming  // 첫 페이지면 교체
            }
            return [...(existing ?? []), ...incoming]  // 추가
          },
        },
      },
    },
  },
})

// 로거 링크
const loggerLink = new ApolloLink((operation, forward) =&amp;gt; {
  console.log(`  ${operation.operationName}`)
  const start = Date.now()

  return forward(operation).map(response =&amp;gt; {
    console.log(`✅ ${operation.operationName} (${Date.now() - start}ms)`)
    return response
  })
})

// 에러 링크
const errorLink = onError(({ graphQLErrors, networkError, operation }) =&amp;gt; {
  if (graphQLErrors) {
    console.error(`❌ [${operation.operationName}]:`, graphQLErrors)
  }
  if (networkError) {
    console.error(`  Network error:`, networkError)
  }
})

// 인증 링크
const authLink = new ApolloLink((operation, forward) =&amp;gt; {
  const token = localStorage.getItem('token')

  operation.setContext({
    headers: {
      authorization: token ? `Bearer ${token}` : '',
    }
  })

  return forward(operation)
})

// HTTP 링크
const httpLink = new HttpLink({
  uri: process.env.NEXT_PUBLIC_HASURA_URL,
})

function makeClient() {
  return new ApolloClient({
    cache,
    link: from([
      loggerLink,
      errorLink,
      authLink,
      httpLink,
    ]),
  })
}

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return (
    &amp;lt;ApolloNextAppProvider makeClient={makeClient}&amp;gt;
      {children}
    &amp;lt;/ApolloNextAppProvider&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;콘솔 출력 예시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;  GetCampaigns
✅ GetCampaigns (234ms)

  GetApplications
✅ GetApplications (156ms)

  UpdateCampaign
❌ [UpdateCampaign]: [
  {
    message: &quot;permission denied&quot;,
    extensions: { code: &quot;validation-failed&quot; }
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;InMemoryCache typePolicies&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목적:&lt;/b&gt; 캐시 동작 커스터마이징&lt;/li&gt;
&lt;li&gt;&lt;b&gt;keyArgs:&lt;/b&gt; 캐시 키 생성 기준&lt;/li&gt;
&lt;li&gt;&lt;b&gt;merge:&lt;/b&gt; 데이터 병합 전략&lt;/li&gt;
&lt;li&gt;&lt;b&gt;효과:&lt;/b&gt; 불필요한 네트워크 요청 감소 ⚡&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ApolloLink&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목적:&lt;/b&gt; GraphQL 요청/응답 미들웨어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;로거:&lt;/b&gt; 성능 모니터링&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인증:&lt;/b&gt; 헤더 추가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러:&lt;/b&gt; 중앙 집중식 에러 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;효과:&lt;/b&gt; 관심사 분리, 재사용성 증가  &lt;/li&gt;
&lt;/ul&gt;</description>
      <category>library</category>
      <category>apolloLink</category>
      <category>inMemoryCache</category>
      <category>typePolicies</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/300</guid>
      <comments>https://ifelseif.tistory.com/300#entry300comment</comments>
      <pubDate>Mon, 29 Sep 2025 21:25:37 +0900</pubDate>
    </item>
    <item>
      <title>[250929 TIL] Hasura의 특별한 점</title>
      <link>https://ifelseif.tistory.com/299</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hasura의 특별한 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;일반 GraphQL 서버 (Apollo Server 등):&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;erlang-repl&quot;&gt;&lt;code&gt;GraphQL &amp;rarr; 직접 작성한 리졸버 &amp;rarr; SQL 쿼리
         (여기서 DataLoader 필요!)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Hasura:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;erlang-repl&quot;&gt;&lt;code&gt;GraphQL &amp;rarr; Hasura 엔진 &amp;rarr; 최적화된 SQL 자동 생성
         (DataLoader 불필요! 이미 내장됨)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hasura가 자동으로 해주는 것들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;자동 JOIN 최적화&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;query GetCampaign {
  campaign(where: { id: { _eq: &quot;abc-123&quot; } }) {
    id
    title
    applications {
      id
      status
      influencer {
        id
        username
        platforms {
          name
          follower_count
        }
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Hasura가 자동 생성하는 SQL:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 한 번의 쿼리로!
SELECT 
  campaign.id,
  campaign.title,
  applications.id AS applications_id,
  applications.status,
  influencer.id AS influencer_id,
  influencer.username,
  platforms.name AS platform_name,
  platforms.follower_count
FROM campaign
LEFT JOIN LATERAL (
  SELECT * FROM campaign_application
  WHERE campaign_id = campaign.id
) applications ON true
LEFT JOIN LATERAL (
  SELECT * FROM influencer_profile
  WHERE id = applications.influencer_id
) influencer ON true
LEFT JOIN LATERAL (
  SELECT * FROM influencer_platform
  WHERE influencer_id = influencer.id
) platforms ON true
WHERE campaign.id = 'abc-123';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉, N+1 문제가 애초에 발생하지 않아요!&lt;/b&gt; ✨&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;배치 쿼리 자동 처리&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;query GetMultipleCampaigns {
  campaign1: campaign_by_pk(id: &quot;abc-1&quot;) { ...fields }
  campaign2: campaign_by_pk(id: &quot;abc-2&quot;) { ...fields }
  campaign3: campaign_by_pk(id: &quot;abc-3&quot;) { ...fields }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Hasura가 생성하는 SQL:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 한 번에 배치 처리
SELECT * FROM campaign 
WHERE id IN ('abc-1', 'abc-2', 'abc-3');&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DataLoader가 하는 일을 &lt;b&gt;Hasura 엔진이 알아서&lt;/b&gt; 해줘요!&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hasura + Next.js 15 App Router 구조&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;아키텍처&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;┌─────────────────────────────────────┐
│  Next.js 15 App Router (Frontend)   
│  ├─ Server Components               
│  ├─ Client Components               
│  └─ Apollo Client                   
└──────────────┬──────────────────────┘
               │ GraphQL over HTTP
               &amp;darr;
┌─────────────────────────────────────┐
│  Hasura v2 (GraphQL Engine)         
│  ├─ Auto-generated Schema           
│  ├─ Query Optimizer                 
│  ├─ Permission System               
│  └─ Subscription Engine             
└──────────────┬──────────────────────┘
               │ SQL
               &amp;darr;
┌─────────────────────────────────────┐
│  PostgreSQL Database                
│  └─ 설계한 스키마 + 인덱스  │
└─────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Setup 예시&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. &lt;b&gt;Hasura 설정&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;# docker-compose.yml
version: '3.6'
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: password

  hasura:
    image: hasura/graphql-engine:v2.36.0
    ports:
      - &quot;8080:8080&quot;
    environment:
      HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:password@postgres:5432/aibee
      HASURA_GRAPHQL_ENABLE_CONSOLE: &quot;true&quot;
      HASURA_GRAPHQL_ADMIN_SECRET: &quot;admin-secret&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. &lt;b&gt;Hasura에서 관계 설정&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hasura Console에서 클릭 몇 번이면 끝:&lt;/p&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;campaign
  ├─ applications (array relationship)
      └─ influencer (object relationship)
          └─ platforms (array relationship)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 설정하면 자동으로 nested query 가능!&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. &lt;b&gt;Next.js 15 App Router에서 사용&lt;/b&gt;&lt;/h4&gt;
&lt;h5&gt;Server Component (SSR)&lt;/h5&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/campaigns/[id]/page.tsx
import { getClient } from '@/lib/apollo-client-rsc'
import { gql } from '@apollo/client'

const GET_CAMPAIGN = gql`
  query GetCampaign($id: uuid!) {
    campaign_by_pk(id: $id) {
      id
      title
      status
      applications(where: { status: { _eq: &quot;approved&quot; } }) {
        id
        influencer {
          username
          platforms {
            platform {
              name
            }
            follower_count
          }
        }
      }
    }
  }
`

export default async function CampaignPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  const client = getClient()

  const { data } = await client.query({
    query: GET_CAMPAIGN,
    variables: { id: params.id },
  })

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;{data.campaign_by_pk.title}&amp;lt;/h1&amp;gt;
      {/* ... */}
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo-client-rsc.ts (Server Component용)
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc'

export const { getClient } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: process.env.NEXT_PUBLIC_HASURA_URL,
      headers: {
        'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET!,
      },
    }),
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;h5&gt;Client Component (CSR)&lt;/h5&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/campaigns/[id]/applications.tsx
'use client'

import { gql, useQuery } from '@apollo/client'

const GET_APPLICATIONS = gql`
  query GetApplications($campaignId: uuid!) {
    campaign_application(
      where: { campaign_id: { _eq: $campaignId } }
      order_by: { created_at: desc }
    ) {
      id
      status
      influencer {
        username
        platforms_aggregate {
          aggregate {
            sum {
              follower_count
            }
          }
        }
      }
    }
  }
`

export default function Applications({ campaignId }: { campaignId: string }) {
  const { data, loading } = useQuery(GET_APPLICATIONS, {
    variables: { campaignId },
  })

  if (loading) return &amp;lt;div&amp;gt;Loading...&amp;lt;/div&amp;gt;

  return (
    &amp;lt;ul&amp;gt;
      {data.campaign_application.map(app =&amp;gt; (
        &amp;lt;li key={app.id}&amp;gt;{app.influencer.username}&amp;lt;/li&amp;gt;
      ))}
    &amp;lt;/ul&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/apollo-client.tsx (Client Component용)
'use client'

import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import { ApolloNextAppProvider } from '@apollo/experimental-nextjs-app-support/ssr'

function makeClient() {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: process.env.NEXT_PUBLIC_HASURA_URL,
      headers: {
        'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET!,
      },
    }),
  })
}

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return (
    &amp;lt;ApolloNextAppProvider makeClient={makeClient}&amp;gt;
      {children}
    &amp;lt;/ApolloNextAppProvider&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/layout.tsx
import { ApolloWrapper } from '@/lib/apollo-client'

export default function RootLayout({ children }) {
  return (
    &amp;lt;html&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;ApolloWrapper&amp;gt;
          {children}
        &amp;lt;/ApolloWrapper&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hasura의 강력한 기능들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;집계 쿼리 자동 생성&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;query CampaignStats {
  campaign_by_pk(id: &quot;abc-123&quot;) {
    title
    applications_aggregate {
      aggregate {
        count
        avg {
          rating
        }
      }
      nodes {
        influencer {
          username
        }
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생성되는 SQL:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 효율적으로 집계
SELECT 
  campaign.title,
  COUNT(applications.id),
  AVG(applications.rating)
FROM campaign
LEFT JOIN campaign_application applications 
  ON applications.campaign_id = campaign.id
WHERE campaign.id = 'abc-123'
GROUP BY campaign.id;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;필터링과 정렬&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;query TopInfluencers {
  influencer_profile(
    where: {
      platforms: {
        follower_count: { _gte: 10000 }
      }
    }
    order_by: { created_at: desc }
    limit: 10
  ) {
    username
    platforms_aggregate {
      aggregate {
        sum {
          follower_count
        }
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hasura가 알아서 최적의 인덱스를 활용해요!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. &lt;b&gt;실시간 Subscription&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;subscription WatchCampaignApplications($campaignId: uuid!) {
  campaign_application(
    where: { campaign_id: { _eq: $campaignId } }
  ) {
    id
    status
    influencer {
      username
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;'use client'

import { gql, useSubscription } from '@apollo/client'

const WATCH_APPLICATIONS = gql`
  subscription WatchApplications($campaignId: uuid!) {
    campaign_application(
      where: { campaign_id: { _eq: $campaignId } }
    ) {
      id
      status
    }
  }
`

export default function LiveApplications({ campaignId }) {
  const { data } = useSubscription(WATCH_APPLICATIONS, {
    variables: { campaignId },
  })

  // 실시간 업데이트!
  return &amp;lt;div&amp;gt;{data?.campaign_application.length} applications&amp;lt;/div&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;DataLoader가 필요 없는 이유 정리&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상황&lt;/th&gt;
&lt;th&gt;일반 GraphQL&lt;/th&gt;
&lt;th&gt;Hasura&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;N+1 문제&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;DataLoader 필수&lt;/td&gt;
&lt;td&gt;자동 해결 (LATERAL JOIN)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;배치 쿼리&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;DataLoader로 구현&lt;/td&gt;
&lt;td&gt;자동 배치 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;복잡한 JOIN&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;수동 최적화&lt;/td&gt;
&lt;td&gt;자동 최적화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;인덱스 활용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;쿼리 튜닝 필요&lt;/td&gt;
&lt;td&gt;자동 활용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그럼 사용자가 신경 써야 할 것은?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ DB 인덱스 설계 (여전히 중요!)&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- Hasura가 아무리 똑똑해도 인덱스가 없으면 느려요
CREATE INDEX idx_campaign_application_campaign_status 
ON campaign_application(campaign_id, status);

CREATE INDEX idx_influencer_platform_influencer 
ON influencer_platform(influencer_id);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ Hasura Permission 설정&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# campaign 테이블 권한 예시
- role: user
  permission:
    columns: ['id', 'title', 'status']
    filter:
      business_profile:
        user_id:
          _eq: X-Hasura-User-Id  # JWT에서 가져옴&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 쿼리 복잡도 제한&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# Hasura 설정
HASURA_GRAPHQL_QUERY_DEPTH_LIMIT: 5  # nested 5단계까지만&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ Apollo Client 캐시 전략&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        campaign_application: {
          keyArgs: ['where', 'order_by'],
          merge(existing, incoming) {
            return incoming
          },
        },
      },
    },
  },
})&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무 팁&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;Hasura CLI로 마이그레이션 관리&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Hasura CLI 설치
npm install --save-dev hasura-cli

# 마이그레이션 생성
hasura migrate create init --from-server --database-name default

# 메타데이터 export
hasura metadata export&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;CodeGen으로 타입 안정성&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;npm install -D @graphql-codegen/cli

# codegen.yml
schema: http://localhost:8080/v1/graphql
documents: './app/**/*.tsx'
generates:
  ./lib/graphql/generated.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;// 자동 생성된 타입 사용
import { useGetCampaignQuery } from '@/lib/graphql/generated'

const { data } = useGetCampaignQuery({
  variables: { id: campaignId }
})
// data가 완전히 타입 안전!&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. &lt;b&gt;Performance Monitoring&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Apollo Client에 로깅 추가
import { ApolloLink } from '@apollo/client'

const loggerLink = new ApolloLink((operation, forward) =&amp;gt; {
  console.log(`GraphQL Request: ${operation.operationName}`)
  const start = Date.now()

  return forward(operation).map(response =&amp;gt; {
    console.log(`Took ${Date.now() - start}ms`)
    return response
  })
})&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Hasura를 사용하면:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ DataLoader 불필요 (자동 최적화)&lt;/li&gt;
&lt;li&gt;✅ 리졸버 작성 불필요 (자동 생성)&lt;/li&gt;
&lt;li&gt;✅ N+1 문제 자동 해결&lt;/li&gt;
&lt;li&gt;✅ &lt;b&gt;하지만 DB 인덱스는 여전히 필수&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>library</category>
      <category>hasura</category>
      <category>V2</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/299</guid>
      <comments>https://ifelseif.tistory.com/299#entry299comment</comments>
      <pubDate>Mon, 29 Sep 2025 21:22:07 +0900</pubDate>
    </item>
    <item>
      <title>[250927 TIL] Hasura(v2) + Next.js + Apollo 기본</title>
      <link>https://ifelseif.tistory.com/298</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hasura(v2) GraphQL + Next.js + Apollo Client&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hasura(v2) 로 GraphQL API서버를 만들고, DB는 local PostgreSQL 을 docker 로 띄울 것입니다.&lt;br /&gt;대충 반려동물 주제로 유저, 포스트, 좋아요, 댓글, 동물정보 테이블을 만들고,&lt;br /&gt;seeding은 faker.js 이용하여 typescript 로 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리와 뮤테이션 작성후 codegen까지 완료되면 Next.js 15 app router 에 맞게&lt;br /&gt;apollo cilent 설정을 하고 프론트 개발을 하면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;순서&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;프로젝트 init&lt;/li&gt;
&lt;li&gt;의존성 설치&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hasura.io/docs/2.0/getting-started/docker-simple/&quot;&gt;여기&lt;/a&gt; 참고하여 curl 로 docker-compose 파일 받기&lt;/li&gt;
&lt;li&gt;docker 띄우기(시딩용 ports: - &quot;5432:5432&quot; 추가필요)&lt;/li&gt;
&lt;li&gt;localhost:8080/console 로 접근&lt;/li&gt;
&lt;li&gt;sql 로 테이블 생성 후 table, relation &amp;gt; track&lt;/li&gt;
&lt;li&gt;seed.ts 작성&lt;/li&gt;
&lt;li&gt;codegen.ts 작성&lt;/li&gt;
&lt;li&gt;package.json scripts 업데이트&lt;/li&gt;
&lt;li&gt;queries.ts, mutations.ts GQL 쿼리, 뮤테이션 작성&lt;/li&gt;
&lt;li&gt;pnpm seed&lt;/li&gt;
&lt;li&gt;pnpm codegen&lt;/li&gt;
&lt;li&gt;apollo client 설정&lt;/li&gt;
&lt;li&gt;기본 쿼리 훅 작성&lt;/li&gt;
&lt;li&gt;서버사이드 fetch 적용&lt;/li&gt;
&lt;li&gt;~ ~ ~ 마음대로 만들기 ^0^&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;순서별 상세&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 의존성 설정&lt;/h3&gt;
&lt;pre id=&quot;code_1758957017329&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;pnpm add @faker-js/faker
pnpm add -D tsx
pnpm add pg 
pnpm add -D @types/pg
pnpm add @apollo/client graphql rxjs @apollo/client-integration-nextjs graphql-request
pnpm add -D @graphql-codegen/cli @graphql-codegen/client-preset @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-typed-document-node/core&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. SQL&lt;/h3&gt;
&lt;pre id=&quot;code_1758957062347&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 사용자 테이블
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  username VARCHAR(50) UNIQUE NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  full_name VARCHAR(100) NOT NULL,
  avatar_url TEXT,
  bio TEXT,
  location VARCHAR(100),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 반려동물 정보 테이블
CREATE TABLE pets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(100) NOT NULL,
  species VARCHAR(50) NOT NULL, -- 'dog', 'cat', 'bird', 'rabbit', etc.
  breed VARCHAR(100),
  age INTEGER,
  gender VARCHAR(10), -- 'male', 'female', 'unknown'
  weight DECIMAL(5,2), -- kg 단위
  color VARCHAR(50),
  personality TEXT, -- 성격 설명
  photo_url TEXT,
  owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  is_adopted BOOLEAN DEFAULT false,
  adoption_date DATE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 포스트 테이블
CREATE TABLE posts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title VARCHAR(255) NOT NULL,
  content TEXT NOT NULL,
  image_url TEXT,
  category VARCHAR(50), -- 'daily', 'medical', 'training', 'adoption', 'lost', 'found'
  location VARCHAR(100), -- 위치 정보 (산책, 분실 등에 활용)
  author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  pet_id UUID REFERENCES pets(id) ON DELETE SET NULL, -- 특정 반려동물과 관련된 포스트
  is_published BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 좋아요 테이블
CREATE TABLE post_likes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(post_id, user_id) -- 중복 좋아요 방지
);

-- 댓글 테이블
CREATE TABLE comments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  content TEXT NOT NULL,
  post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE, -- 대댓글 기능
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 댓글 좋아요 테이블 (선택사항)
CREATE TABLE comment_likes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(comment_id, user_id)
);

-- 팔로우 관계 테이블 (선택사항)
CREATE TABLE user_follows (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(follower_id, following_id),
  CHECK(follower_id != following_id) -- 자기 자신 팔로우 방지
);

-- 인덱스 생성 (성능 최적화)
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_pet_id ON posts(pet_id);
CREATE INDEX idx_posts_category ON posts(category);
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_comments_author_id ON comments(author_id);
CREATE INDEX idx_post_likes_post_id ON post_likes(post_id);
CREATE INDEX idx_pets_owner_id ON pets(owner_id);
CREATE INDEX idx_pets_species ON pets(species);

-- 업데이트 시간 자동 갱신 함수
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

-- 트리거 설정
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_pets_updated_at BEFORE UPDATE ON pets FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_posts_updated_at BEFORE UPDATE ON posts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON comments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7.&amp;nbsp;seed.ts&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// scripts/seed.ts
import { faker } from '@faker-js/faker';
import { Client } from 'pg';

const client = new Client({
  host: 'localhost',
  port: 5432,
  database: 'petapp_db',
  user: 'postgres',
  password: 'petapp_password',
});

// 반려동물 관련 데이터
const petSpecies = ['dog', 'cat', 'bird', 'rabbit', 'hamster', 'fish', 'turtle'];
const dogBreeds = ['골든 리트리버', '래브라도', '푸들', '말티즈', '치와와', '비글', '시바견', '진돗개'];
const catBreeds = ['페르시안', '메인쿤', '러시안 블루', '브리티시 숏헤어', '샴', '벵갈', '코리안 숏헤어'];
const birdBreeds = ['앵무새', '카나리아', '십자매', '문조', '잉꼬'];
const personalities = ['활발함', '온순함', '장난기 많음', '독립적', '애교쟁이', '경계심 많음', '느긋함', '영리함'];
const postCategories = ['daily', 'medical', 'training', 'adoption', 'lost', 'found'];
const colors = ['흰색', '검은색', '갈색', '회색', '노란색', '주황색', '얼룩무늬', '삼색'];

interface User {
  id: string;
  username: string;
  email: string;
  full_name: string;
}

interface Pet {
  id: string;
  name: string;
  species: string;
  owner_id: string;
}

async function seed() {
  try {
    await client.connect();
    console.log('  반려동물 앱 시딩을 시작합니다...\n');

    // 기존 데이터 삭제 (개발용)
    console.log(' ️  기존 데이터 삭제 중...');
    await client.query(`
      TRUNCATE users, pets, posts, comments, post_likes, comment_likes, user_follows RESTART IDENTITY CASCADE;
    `);

    // 1. 사용자 생성
    console.log('  사용자 생성 중...');
    const users: User[] = [];

    for (let i = 0; i &amp;lt; 30; i++) {
      const firstName = faker.person.firstName();
      const lastName = faker.person.lastName();
      const username = faker.internet.userName(firstName, lastName);

      const userData = {
        username: `${username}_${i}`, // 중복 방지
        email: faker.internet.email(firstName, lastName),
        full_name: `${firstName} ${lastName}`,
        avatar_url: faker.image.avatar(),
        bio: faker.lorem.sentence(),
        location: faker.location.city(),
      };

      const result = await client.query(`
        INSERT INTO users (username, email, full_name, avatar_url, bio, location)
        VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, username, email, full_name
      `, [userData.username, userData.email, userData.full_name, userData.avatar_url, userData.bio, userData.location]);

      users.push(result.rows[0]);
      console.log(`  ✅ ${userData.full_name} (@${userData.username})`);
    }

    // 2. 반려동물 생성
    console.log('\n  반려동물 생성 중...');
    const pets: Pet[] = [];

    for (let i = 0; i &amp;lt; 50; i++) {
      const species = faker.helpers.arrayElement(petSpecies);
      let breed = '';

      // 종에 따른 품종 설정
      switch (species) {
        case 'dog':
          breed = faker.helpers.arrayElement(dogBreeds);
          break;
        case 'cat':
          breed = faker.helpers.arrayElement(catBreeds);
          break;
        case 'bird':
          breed = faker.helpers.arrayElement(birdBreeds);
          break;
        default:
          breed = faker.animal.type();
      }

      const petData = {
        name: faker.animal.petName(),
        species,
        breed,
        age: faker.number.int({ min: 1, max: 15 }),
        gender: faker.helpers.arrayElement(['male', 'female', 'unknown']),
        weight: parseFloat(faker.number.float({ min: 0.5, max: 50, fractionDigits: 1 }).toString()),
        color: faker.helpers.arrayElement(colors),
        personality: faker.helpers.arrayElements(personalities, { min: 1, max: 3 }).join(', '),
        photo_url: faker.image.urlLoremFlickr({ category: 'animals' }),
        owner_id: faker.helpers.arrayElement(users).id,
        is_adopted: faker.datatype.boolean(0.3), // 30% 확률로 입양
        adoption_date: faker.datatype.boolean(0.3) ? faker.date.recent({ days: 365 }) : null,
      };

      const result = await client.query(`
        INSERT INTO pets (name, species, breed, age, gender, weight, color, personality, photo_url, owner_id, is_adopted, adoption_date)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, name, species, owner_id
      `, [petData.name, petData.species, petData.breed, petData.age, petData.gender, 
          petData.weight, petData.color, petData.personality, petData.photo_url, 
          petData.owner_id, petData.is_adopted, petData.adoption_date]);

      pets.push(result.rows[0]);
      console.log(`    ${petData.name} (${petData.species} - ${breed})`);
    }

    // 3. 포스트 생성
    console.log('\n  포스트 생성 중...');
    const posts: string[] = [];

    for (let i = 0; i &amp;lt; 100; i++) {
      const category = faker.helpers.arrayElement(postCategories);
      const pet = faker.helpers.arrayElement(pets);

      // 카테고리에 따른 제목 생성
      let title = '';
      switch (category) {
        case 'daily':
          title = `${pet.name}의 일상 - ${faker.lorem.words(3)}`;
          break;
        case 'medical':
          title = `${pet.name} 건강 체크 및 ${faker.lorem.word()}`;
          break;
        case 'training':
          title = `${pet.name} 훈련일지 - ${faker.lorem.words(2)}`;
          break;
        case 'adoption':
          title = `사랑스러운 ${pet.species} ${pet.name} 입양 보내요`;
          break;
        case 'lost':
          title = `긴급! ${pet.name} 실종 - ${faker.location.city()} 일대`;
          break;
        case 'found':
          title = `발견! ${pet.species} 보호 중 - 주인을 찾습니다`;
          break;
      }

      const postData = {
        title,
        content: faker.lorem.paragraphs(faker.number.int({ min: 1, max: 4 })),
        image_url: faker.image.urlLoremFlickr({ category: 'animals' }),
        category,
        location: faker.location.city(),
        author_id: pet.owner_id,
        pet_id: faker.datatype.boolean(0.8) ? pet.id : null, // 80% 확률로 특정 반려동물과 연관
        is_published: faker.datatype.boolean(0.95), // 95% 확률로 게시
        created_at: faker.date.recent({ days: 30 }),
      };

      const result = await client.query(`
        INSERT INTO posts (title, content, image_url, category, location, author_id, pet_id, is_published, created_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id
      `, [postData.title, postData.content, postData.image_url, postData.category, 
          postData.location, postData.author_id, postData.pet_id, postData.is_published, postData.created_at]);

      posts.push(result.rows[0].id);
      console.log(`    ${title.substring(0, 50)}...`);
    }

    // 4. 좋아요 생성
    console.log('\n❤️  좋아요 생성 중...');
    for (let i = 0; i &amp;lt; 300; i++) {
      try {
        await client.query(`
          INSERT INTO post_likes (post_id, user_id, created_at)
          VALUES ($1, $2, $3)
          ON CONFLICT (post_id, user_id) DO NOTHING
        `, [
          faker.helpers.arrayElement(posts),
          faker.helpers.arrayElement(users).id,
          faker.date.recent({ days: 20 })
        ]);
      } catch (error) {
        // 중복 좋아요는 무시
      }
    }

    // 5. 댓글 생성
    console.log('\n  댓글 생성 중...');
    const comments: string[] = [];

    for (let i = 0; i &amp;lt; 200; i++) {
      const commentData = {
        content: faker.lorem.sentences(faker.number.int({ min: 1, max: 3 })),
        post_id: faker.helpers.arrayElement(posts),
        author_id: faker.helpers.arrayElement(users).id,
        parent_comment_id: faker.datatype.boolean(0.2) &amp;amp;&amp;amp; comments.length &amp;gt; 0 
          ? faker.helpers.arrayElement(comments) : null, // 20% 확률로 대댓글
        created_at: faker.date.recent({ days: 15 }),
      };

      const result = await client.query(`
        INSERT INTO comments (content, post_id, author_id, parent_comment_id, created_at)
        VALUES ($1, $2, $3, $4, $5) RETURNING id
      `, [commentData.content, commentData.post_id, commentData.author_id, 
          commentData.parent_comment_id, commentData.created_at]);

      comments.push(result.rows[0].id);
      console.log(`    댓글 ${i + 1}: ${commentData.content.substring(0, 30)}...`);
    }

    // 6. 팔로우 관계 생성
    console.log('\n  팔로우 관계 생성 중...');
    for (let i = 0; i &amp;lt; 80; i++) {
      try {
        const follower = faker.helpers.arrayElement(users);
        const following = faker.helpers.arrayElement(users);

        if (follower.id !== following.id) {
          await client.query(`
            INSERT INTO user_follows (follower_id, following_id, created_at)
            VALUES ($1, $2, $3)
            ON CONFLICT (follower_id, following_id) DO NOTHING
          `, [follower.id, following.id, faker.date.recent({ days: 60 })]);
        }
      } catch (error) {
        // 중복 팔로우는 무시
      }
    }

    console.log('\n  시딩 완료!');
    console.log(`
  생성된 데이터:
  - 사용자: ${users.length}명
  - 반려동물: ${pets.length}마리
  - 포스트: ${posts.length}개
  - 댓글: ${comments.length}개
  - 좋아요: ~300개
  - 팔로우: ~80개

  Hasura Console: http://localhost:8080
  pgAdmin: http://localhost:5050 (admin@petapp.com / admin)
    `);

  } catch (error) {
    console.error('❌ 시딩 중 오류 발생:', error);
  } finally {
    await client.end();
  }
}

seed();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. codegen.ts&lt;/h3&gt;
&lt;pre id=&quot;code_1758957159134&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type { CodegenConfig } from &quot;@graphql-codegen/cli&quot;;

const config: CodegenConfig = {
  overwrite: true,
  schema: process.env.HASURA_GRAPHQL_ENDPOINT ?? &quot;http://localhost:8080/v1/graphql&quot;,
  documents: [&quot;src/**/*.{ts,tsx,graphql}&quot;],
  generates: {
	&quot;src/generated/graphql.ts&quot;: {
		plugins: [&quot;typescript&quot;, &quot;typescript-operations&quot;, &quot;typed-document-node&quot;],
		config: {
			fetcher: &quot;graphql-request&quot;,
			exposeDocument: true,
			exposeQueryKeys: true,
			exposeMutationKeys: true,
		},
	 },
  },
}

export default config;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9.&amp;nbsp;package.json&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;{
    &quot;scripts&quot;: {
        &quot;dev&quot;: &quot;next dev --turbopack&quot;,
        &quot;build&quot;: &quot;next build --turbopack&quot;,
        &quot;start&quot;: &quot;next start&quot;,
        &quot;lint&quot;: &quot;biome check&quot;,
        &quot;format&quot;: &quot;biome format --write&quot;,
        &quot;# ===== 통합 환경 관리 =====&quot;: &quot;&quot;,
        &quot;backend:up&quot;: &quot;docker-compose up -d&quot;,
        &quot;backend:down&quot;: &quot;docker-compose down&quot;,
        &quot;backend:restart&quot;: &quot;docker-compose restart&quot;,
        &quot;backend:logs&quot;: &quot;docker-compose logs -f&quot;,
        &quot;# ===== 시딩 =====&quot;: &quot;&quot;,
        &quot;seed&quot;: &quot;tsx scripts/seed.ts&quot;,
        &quot;seed:fresh&quot;: &quot;pnpm db:reset &amp;amp;&amp;amp; sleep 5 &amp;amp;&amp;amp; pnpm seed&quot;,
        &quot;# ===== GraphQL 코드 생성 =====&quot;: &quot;&quot;,
        &quot;codegen&quot;: &quot;graphql-codegen --config codegen.ts&quot;,
        &quot;codegen:watch&quot;: &quot;graphql-codegen --config codegen.ts --watch&quot;
    },
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;10. apollo client 설정&lt;/h4&gt;
&lt;pre id=&quot;code_1758957202160&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// /lib/apollo-client.ts
import { HttpLink } from &quot;@apollo/client&quot;;
import {
	ApolloClient,
	InMemoryCache,
	registerApolloClient,
} from &quot;@apollo/client-integration-nextjs&quot;;

export const { getClient, query, PreloadQuery } = registerApolloClient(() =&amp;gt; {
  return new ApolloClient({
	cache: new InMemoryCache(),
	link: new HttpLink({
	  uri:
		process.env.HASURA_GRAPHQL_ENDPOINT ??
		&quot;http://localhost:8080/v1/graphql&quot;,
  	}),
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// /lib/apollo-provider.tsx
&quot;use client&quot;;

import { HttpLink } from &quot;@apollo/client&quot;;
import {
    ApolloClient,
    ApolloNextAppProvider,
    InMemoryCache,
} from &quot;@apollo/client-integration-nextjs&quot;;

function makeClient() {
    const httpLink = new HttpLink({
    // this needs to be an absolute url, as relative urls cannot be used in SSR
        uri:
        process.env.HASURA_GRAPHQL_ENDPOINT ?? &quot;http://localhost:8080/v1/graphql&quot;,
    });

    return new ApolloClient({
        cache: new InMemoryCache(),
        link: httpLink,
    });
}

export function ApolloProvider({ children }: { children: React.ReactNode }) {

    return (
        &amp;lt;ApolloNextAppProvider makeClient={makeClient}&amp;gt;
            {children}
        &amp;lt;/ApolloNextAppProvider&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;14. 기본 쿼리 훅 작성&lt;/h3&gt;
&lt;pre id=&quot;code_1758957233553&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ===== POSTS =====
export function usePostsQuery(
	limit?: number,
	offset?: number,
	category?: string,
) {
	return useQuery&amp;lt;GetPostsQuery, GetPostsQueryVariables&amp;gt;(GET_POSTS, {
	variables: { limit, offset, category },
  });
}

export function usePostsSuspenseQuery(
	limit?: number,
	offset?: number,
	category?: string,
) {
	return useSuspenseQuery&amp;lt;GetPostsQuery, GetPostsQueryVariables&amp;gt;(GET_POSTS, {
		variables: { limit, offset, category },
	});
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;15.&amp;nbsp;서버사이드&amp;nbsp;fetch&amp;nbsp;적용&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// /app/(public)/page.tsx
import { Suspense } from &quot;react&quot;;
import { GET_POSTS } from &quot;@/graphql/queries&quot;;
import { PreloadQuery } from &quot;@/lib/apollo-client&quot;;
import MainTemplate from &quot;@/templates/main-templates&quot;;

export default function Home() {
return (
    &amp;lt;PreloadQuery
        query={GET_POSTS}
        variables={{
        limit: 10,
        offset: 0,
    }}&amp;gt;
        &amp;lt;Suspense fallback={&amp;lt;div&amp;gt;loading&amp;lt;/div&amp;gt;}&amp;gt;
            &amp;lt;MainTemplate /&amp;gt;
        &amp;lt;/Suspense&amp;gt;
    &amp;lt;/PreloadQuery&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// /src/templates/main-template.tsx
&quot;use client&quot;;

import {
    Card,
    CardContent,
    CardFooter,
    CardHeader,
    CardTitle,
} from &quot;@/components/ui/card&quot;;
import { Skeleton } from &quot;@/components/ui/skeleton&quot;;
import { usePostsSuspenseQuery } from &quot;@/hooks/queries&quot;;

function MainTemplate() {
    const { data, error } = usePostsSuspenseQuery(10, 0);

    if (error) return &amp;lt;div&amp;gt;Error: {error.message}&amp;lt;/div&amp;gt;;

    return (
        &amp;lt;div className=&quot;grid grid-cols-3 gap-4&quot;&amp;gt;
            {data.posts?.map((post) =&amp;gt; (
                &amp;lt;Card key={post?.id}&amp;gt;
                    &amp;lt;CardHeader&amp;gt;
                        &amp;lt;CardTitle&amp;gt;{post?.title}&amp;lt;/CardTitle&amp;gt;
                    &amp;lt;/CardHeader&amp;gt;
                    &amp;lt;CardContent className=&quot;max-h-[100px] overflow-hidden text-ellipsis whitespace-nowrap&quot;&amp;gt;
                        &amp;lt;Skeleton className=&quot;h-[100px] w-full&quot; /&amp;gt;
                        &amp;lt;p&amp;gt;{post?.content}&amp;lt;/p&amp;gt;
                    &amp;lt;/CardContent&amp;gt;
                    &amp;lt;CardFooter&amp;gt;{post?.location}&amp;lt;/CardFooter&amp;gt;
                &amp;lt;/Card&amp;gt;
            ))}
        &amp;lt;/div&amp;gt;
    );
}


export default MainTemplate;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;happy coding ~&lt;/p&gt;</description>
      <category>library</category>
      <category>Apollo</category>
      <category>Codegen</category>
      <category>graphQL</category>
      <category>hasura</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/298</guid>
      <comments>https://ifelseif.tistory.com/298#entry298comment</comments>
      <pubDate>Sat, 27 Sep 2025 16:08:37 +0900</pubDate>
    </item>
    <item>
      <title>[250907 TIL] 실전적인 axios client 구성</title>
      <link>https://ifelseif.tistory.com/297</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;axios client 의 실전적 구성&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 문제 정의&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;axios 클라이언트 구성을 매번 그때그때 하다보니 문제가 생길때가 많음&lt;/li&gt;
&lt;li&gt;그러다보니 구조화가 되어있지 않아 어떻게했었지 하고 또 찾아봄&lt;/li&gt;
&lt;li&gt;요청, 응답 interceptor 작성을 관성적으로 했더니 사용할때 제네릭을 두번씩 api.get&amp;lt;타입, 타입&amp;gt; 이런식으로 쓰고 있었음&lt;/li&gt;
&lt;li&gt;2개 이상의 axios 클라이언트를 만들때(ex 서버용, 클라이언트용 등) 계층화가 안되어있어 불편&lt;/li&gt;
&lt;li&gt;타입 안전성 부족과 에러 처리가 일관성이 없음&lt;/li&gt;
&lt;li&gt;환경별 설정 관리가 어려움&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 해결 방안&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클래스로 axios api 클라이언트를 구성한다&lt;/li&gt;
&lt;li&gt;api-client 추상 클래스 에서는 axios instance 를 생성하고 기본 메서드를 오버라이드한다&lt;/li&gt;
&lt;li&gt;기본메서드를 오버라이드 하는 이유는 사용시 제네릭 입력 두번 안하고 편하게 하기 위해서&lt;/li&gt;
&lt;li&gt;실제 사용할 api 메서드들은 base-api-client 를 extends 한 클래스에 작성&lt;/li&gt;
&lt;li&gt;타입 안전성 강화와 커스텀 에러 클래스 도입&lt;/li&gt;
&lt;li&gt;환경별 설정을 구조화하여 관리&lt;/li&gt;
&lt;li&gt;요청 취소 및 재시도 로직 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 타입 정의&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// API 응답 구조 정의
interface ApiResponse&amp;lt;T&amp;gt; {
  data: T;
  access_token?: string;
  message?: string;
  status?: string;
}

// 에러 응답 구조 정의
interface ApiErrorResponse {
  message: string;
  detail?: string;
  code?: string;
}

// 환경별 설정 인터페이스
interface ApiConfig {
  apiUrl: string;
  adminUrl: string;
  timeout?: number;
  retryAttempts?: number;
  enableLogging?: boolean;
}

// 커스텀 에러 클래스
class ApiError extends Error {
  constructor(
    public status: number,
    public message: string,
    public data?: any
  ) {
    super(message);
    this.name = 'ApiError';
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. base-api-client.ts 추상클래스&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export abstract class BaseApiClient {
  protected readonly apiInstance: AxiosInstance;
  protected readonly adminInstance: AxiosInstance;
  private readonly config: ApiConfig;

  constructor(config: ApiConfig) {
    this.config = config;

    // 원하는 만큼 인스턴스 생성 (환경별 설정 적용)
    this.apiInstance = axios.create({ 
      baseURL: config.apiUrl,
      timeout: config.timeout || 10000,
    });

    this.adminInstance = axios.create({ 
      baseURL: config.adminUrl,
      timeout: config.timeout || 10000,
    });

    // 여러 인스턴스에 동일 인터셉터 적용
    this.applyRequestInterceptor(this.apiInstance);
    this.applyRequestInterceptor(this.adminInstance);
    this.applyResponseInterceptor(this.apiInstance);
    this.applyResponseInterceptor(this.adminInstance);
  }

  private applyRequestInterceptor(instance: AxiosInstance): void {
    // 요청시 로컬스토리지에 토큰 있으면 자동 적용
    instance.interceptors.request.use((config) =&amp;gt; {
      if (typeof window !== &quot;undefined&quot;) {
        const token = getLocalStorage(TOKEN_KEY);
        if (token) {
          config.headers = config.headers || {};
          (config.headers as any).Authorization = `Bearer ${token}`;
        }
      }
      return config;
    });
  }

  private applyResponseInterceptor(instance: AxiosInstance): void {
    instance.interceptors.response.use(
      (response: AxiosResponse&amp;lt;ApiResponse&amp;lt;any&amp;gt;&amp;gt;) =&amp;gt; {
        // 응답 구조 검증
        if (this.isValidApiResponse(response.data)) {
          // 토큰이 있으면 자동 저장
          if (typeof window !== &quot;undefined&quot; &amp;amp;&amp;amp; response.data?.access_token) {
            setLocalStorage(TOKEN_KEY, response.data.access_token);
          }
          // 실제 데이터만 반환 (data 래핑 해제)
          return response.data.data || response.data;
        }
        return response.data;
      },
      (error: AxiosError&amp;lt;ApiErrorResponse&amp;gt;) =&amp;gt; {
        return this.handleApiError(error);
      }
    );
  }

  // API 응답 구조 검증
  private isValidApiResponse(data: any): data is ApiResponse&amp;lt;any&amp;gt; {
    return data &amp;amp;&amp;amp; typeof data === 'object';
  }

  // 통합 에러 처리
  private handleApiError(error: AxiosError&amp;lt;ApiErrorResponse&amp;gt;): never {
    const { status = 500, data } = error.response || {};
    const message = data?.message || error.message;

    // 로깅 (개발 환경에서만)
    if (this.config.enableLogging &amp;amp;&amp;amp; process.env.NODE_ENV === 'development') {
      console.error(`API Error [${status}]:`, message, data);
    }

    // 상태별 처리
    switch (status) {
      case 400:
        throw new ApiError(status, message || &quot;잘못된 요청입니다.&quot;, data);
      case 401: 
        // 토큰 만료 처리
        if (typeof window !== &quot;undefined&quot;) {
          removeLocalStorage(TOKEN_KEY);
        }
        throw new ApiError(status, message || &quot;인증이 필요합니다.&quot;, data);
      case 403:
        throw new ApiError(status, message || &quot;접근 권한이 없습니다.&quot;, data);
      case 404:
        throw new ApiError(status, message || &quot;요청한 리소스를 찾을 수 없습니다.&quot;, data);
      case 500:
        throw new ApiError(status, message || &quot;서버 오류가 발생했습니다.&quot;, data);
      default:
        throw new ApiError(status, message || &quot;알 수 없는 오류가 발생했습니다.&quot;, data);
    }
  }

  // 재시도 로직
  private async retryRequest&amp;lt;T&amp;gt;(
    requestFn: () =&amp;gt; Promise&amp;lt;T&amp;gt;,
    maxRetries: number = this.config.retryAttempts || 3
  ): Promise&amp;lt;T&amp;gt; {
    for (let i = 0; i &amp;lt; maxRetries; i++) {
      try {
        return await requestFn();
      } catch (error) {
        if (
            i === maxRetries - 1 || 
            error instanceof ApiError &amp;amp;&amp;amp; error.status &amp;lt; 500
                ) {
          throw error;
        }
        // 지수 백오프 (1초, 2초, 4초...)
        await this.delay(1000 * Math.pow(2, i));
      }
    }
    throw new Error('Max retries reached');
  }

  private delay(ms: number): Promise&amp;lt;void&amp;gt; {
    return new Promise(resolve =&amp;gt; setTimeout(resolve, ms));
  }

  // HTTP 메서드 (타입 안전성 및 요청 취소 지원)
  protected getAdmin&amp;lt;T = unknown&amp;gt;(
    url: string, 
    config?: AxiosRequestConfig &amp;amp; { signal?: AbortSignal }
  ): Promise&amp;lt;T&amp;gt; {
    return this.retryRequest(() =&amp;gt; 
      this.adminInstance.get(url, config) as Promise&amp;lt;T&amp;gt;
    );
  }

  protected get&amp;lt;T = unknown&amp;gt;(
    url: string, 
    config?: AxiosRequestConfig &amp;amp; { signal?: AbortSignal }
  ): Promise&amp;lt;T&amp;gt; {
    return this.retryRequest(() =&amp;gt; 
      this.apiInstance.get(url, config) as Promise&amp;lt;T&amp;gt;
    );
  }

  protected delete&amp;lt;T = unknown&amp;gt;(
    url: string, 
    config?: AxiosRequestConfig &amp;amp; { signal?: AbortSignal }
  ): Promise&amp;lt;T&amp;gt; {
    return this.retryRequest(() =&amp;gt; 
      this.apiInstance.delete(url, config) as Promise&amp;lt;T&amp;gt;
    );
  }

  protected post&amp;lt;T = unknown&amp;gt;(
    url: string,
    data?: unknown,
    config?: AxiosRequestConfig &amp;amp; { signal?: AbortSignal }
  ): Promise&amp;lt;T&amp;gt; {
    return this.retryRequest(() =&amp;gt; 
      this.apiInstance.post(url, data, config) as Promise&amp;lt;T&amp;gt;
    );
  }

  protected put&amp;lt;T = unknown&amp;gt;(
    url: string, 
    data?: unknown, 
    config?: AxiosRequestConfig &amp;amp; { signal?: AbortSignal }
  ): Promise&amp;lt;T&amp;gt; {
    return this.retryRequest(() =&amp;gt; 
      this.apiInstance.put(url, data, config) as Promise&amp;lt;T&amp;gt;
    );
  }

  protected patch&amp;lt;T = unknown&amp;gt;(
    url: string,
    data?: unknown,
    config?: AxiosRequestConfig &amp;amp; { signal?: AbortSignal }
  ): Promise&amp;lt;T&amp;gt; {
    return this.retryRequest(() =&amp;gt; 
      this.apiInstance.patch(url, data, config) as Promise&amp;lt;T&amp;gt;
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. apis.ts&lt;/h3&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;class ApiClient extends BaseApiClient {
  constructor() {
    // 환경별 설정
    const config: ApiConfig = {
      apiUrl: API_URL || '',
      adminUrl: ADMIN_API_URL || '',
      timeout: 15000,
      retryAttempts: 3,
      enableLogging: process.env.NODE_ENV === 'development'
    };

    if (!config.apiUrl || !config.adminUrl) {
      throw new Error(&quot;API_URL과 ADMIN_API_URL이 설정되지 않았습니다.&quot;);
    }

    super(config);
  }

  // == 도메인 메서드 (AbortSignal 지원) ==

  // 유저 체크
  public getUserCheck(signal?: AbortSignal): Promise&amp;lt;UserCheckResponse&amp;gt; {
    return this.get&amp;lt;UserCheckResponse&amp;gt;(`/api/auth/user-check`, { signal });
  }

  // 닉네임 또는 메시지 비속어 체크
  public postProhibitedCheck(
    text: string, 
    signal?: AbortSignal
  ): Promise&amp;lt;ProhibitedCheckResponse&amp;gt; {
    return this.post&amp;lt;ProhibitedCheckResponse&amp;gt;(
      `/api/poster/prohibited-check?text=${text}`,
      undefined,
      { signal }
    );
  }

  // 레디스 체크
  public postRedisCheck(signal?: AbortSignal): Promise&amp;lt;RedisCheckResponse&amp;gt; {
    return this.post&amp;lt;RedisCheckResponse&amp;gt;(`/api/poster/redis-status`, undefined, { signal });
  }

  // 생성 여부 체크
  public getPosterCheck(signal?: AbortSignal): Promise&amp;lt;PosterCheckResponse&amp;gt; {
    return this.get&amp;lt;PosterCheckResponse&amp;gt;(`/api/auth/poster-check`, { signal });
  }

  // 여기에 계속 추가...
}  

const api = new ApiClient();
export default api;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 사용 예시&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 기본 사용
try {
  const userData = await api.getUserCheck();
  console.log(userData);
} catch (error) {
  if (error instanceof ApiError) {
    console.error(`API 오류 [${error.status}]: ${error.message}`);
    // 상태별 처리
    if (error.status === 401) {
      // 로그인 페이지로 리다이렉트
    }
  }
}

// 요청 취소
const controller = new AbortController();
const userData = api.getUserCheck(controller.signal);

// 5초 후 취소
setTimeout(() =&amp;gt; controller.abort(), 5000);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 해결되는 문제&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;제네릭 입력 편리&lt;/li&gt;
&lt;li&gt;가독성 향상&lt;/li&gt;
&lt;li&gt;적절한 추상화, 캡슐화로 사용성 향상&lt;/li&gt;
&lt;li&gt;&lt;b&gt;타입 안전성 확보&lt;/b&gt; (ApiResponse, ApiError 타입 정의)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;환경별 설정 관리&lt;/b&gt; (ApiConfig 인터페이스)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;통일된 에러 처리&lt;/b&gt; (커스텀 ApiError 클래스)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자동 재시도 및 요청 취소&lt;/b&gt; (AbortSignal, 지수 백오프)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개발 환경 로깅&lt;/b&gt; (디버깅 편의성)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>library</category>
      <category>axios</category>
      <category>class</category>
      <category>library</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/297</guid>
      <comments>https://ifelseif.tistory.com/297#entry297comment</comments>
      <pubDate>Sun, 7 Sep 2025 13:28:54 +0900</pubDate>
    </item>
    <item>
      <title>[250827 TIL] Biome tailwind 클래스 자동정렬</title>
      <link>https://ifelseif.tistory.com/296</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Biome 로 테일윈드 클래스 저장시 자동정렬하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;biome.jsonc&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;level을 error&lt;/li&gt;
&lt;li&gt;fix를 safe&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;&quot;nursery&quot;: {
    &quot;useSortedClasses&quot;: {
        &quot;level&quot;: &quot;error&quot;,
        &quot;fix&quot;: &quot;safe&quot;,
        &quot;options&quot;: {
            &quot;attributes&quot;: [
                &quot;classList&quot;
            ],
            &quot;functions&quot;: [
                &quot;clsx&quot;,
                &quot;cva&quot;,
                &quot;cn&quot;
            ]
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;.vscode/settings.json&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;editor.codeActionsOnSave에 아래 항목 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;editor.codeActionsOnSave&quot;: {
    &quot;quickfix.biome&quot;: &quot;explicit&quot;,
    &quot;source.fixAll.biome&quot;: &quot;explicit&quot;,
    &quot;source.organizeImports.biome&quot;: &quot;explicit&quot;
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>library</category>
      <category>Biome</category>
      <category>className</category>
      <category>Tailwind</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/296</guid>
      <comments>https://ifelseif.tistory.com/296#entry296comment</comments>
      <pubDate>Wed, 27 Aug 2025 08:24:27 +0900</pubDate>
    </item>
    <item>
      <title>[250807 TIL] CF Function + Next static build</title>
      <link>https://ifelseif.tistory.com/295</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js static build + s3 조합에서 라우팅이 안된다?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇습니다. 아래처럼 next.config 설정하고&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const nextConfig: NextConfig = {
    output: &quot;export&quot;, // 정적 파일만
    trailingSlash: true,
    images: { unoptimized: true },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드하여 s3 에 올리고 CF 붙이면&lt;br /&gt;메인 index.html 은 잘 나오는데 라우팅이 안됩니다.&lt;br /&gt;클로드쌤과 함께 해결했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CloudFront Functions&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lambda@Edge보다 비용이 저렴한 CloudFront Functions를 사용해 보았습니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;function handler(event) {
    var request = event.request;
    var uri = request.uri;

    // 파일 확장자가 없으면 index.html 추가
    if (!uri.includes('.')) {
        request.uri = uri.endsWith('/') 
            ? uri + 'index.html' 
            : uri + '/index.html';
    }

    return request;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요런 간단한 핸들러 함수를&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 함수 생성&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;CloudFront 콘솔&lt;/b&gt; &amp;rarr; 왼쪽 메뉴에서 &lt;b&gt;Functions&lt;/b&gt; 클릭&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Create function&lt;/b&gt; 버튼 클릭&lt;/li&gt;
&lt;li&gt;함수 이름 입력 (예: &lt;code&gt;redirect-to-index&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Create function&lt;/b&gt; 클릭&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 코드 작성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 함수 페이지에서:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Build&lt;/b&gt; 탭 선택&lt;/li&gt;
&lt;li&gt;위 코드 붙여넣기&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Save changes&lt;/b&gt; 클릭&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 테스트 (선택사항)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Test&lt;/b&gt; 탭 클릭&lt;/li&gt;
&lt;li&gt;Event type: &lt;code&gt;Viewer Request&lt;/code&gt; 선택&lt;/li&gt;
&lt;li&gt;URL path에 &lt;code&gt;/about&lt;/code&gt; 입력&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Test function&lt;/b&gt; 클릭&lt;/li&gt;
&lt;li&gt;결과에서 &lt;code&gt;/about/index.html&lt;/code&gt;로 변환되는지 확인&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 배포 (Publish)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;테스트 완료 후 &lt;b&gt;Publish&lt;/b&gt; 탭 클릭&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Publish function&lt;/b&gt; 버튼 클릭&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. CloudFront 배포에 연결&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Associate&lt;/b&gt; 탭 클릭 또는 CloudFront 배포 설정으로 이동&lt;/li&gt;
&lt;li&gt;CloudFront 배포 &amp;rarr; &lt;b&gt;Behaviors&lt;/b&gt; &amp;rarr; &lt;b&gt;Default (*)&lt;/b&gt; 선택 &amp;rarr; &lt;b&gt;Edit&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Function associations&lt;/b&gt; 섹션에서:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Event type: &lt;b&gt;Viewer Request&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Function type: &lt;b&gt;CloudFront Functions&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Function ARN/Name: 방금 만든 함수 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Save changes&lt;br /&gt;&lt;br /&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 배포 대기&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CloudFront 배포가 업데이트되는데 5-10분 정도 걸려요&lt;/li&gt;
&lt;li&gt;Status가 &lt;b&gt;Deployed&lt;/b&gt;가 되면 완료!&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;끝~!&lt;/p&gt;</description>
      <category>nextjs</category>
      <category>cloudfront</category>
      <category>Functions</category>
      <category>next</category>
      <category>S3</category>
      <category>static</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/295</guid>
      <comments>https://ifelseif.tistory.com/295#entry295comment</comments>
      <pubDate>Thu, 7 Aug 2025 22:07:58 +0900</pubDate>
    </item>
    <item>
      <title>[250807 TIL] Hosts</title>
      <link>https://ifelseif.tistory.com/294</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;DNS와 hosts 파일 동작 원리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 일반적인 DNS 조회 과정&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;브라우저 &amp;rarr; hosts 파일 확인 &amp;rarr; DNS 서버 조회 &amp;rarr; IP 주소 반환 &amp;rarr; 웹사이트 접속&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. hosts 파일 수정 후 과정&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;브라우저 &amp;rarr; hosts 파일에서 직접 IP 발견 &amp;rarr; DNS 서버 건너뛰고 바로 해당 IP로 접속&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;hosts 수정하여 사용하기 단계별 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단계 1: 현재 상황 파악&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;원래 도메인(예: example.com)이 CloudFront &amp;rarr; S3를 가리킴&lt;/li&gt;
&lt;li&gt;하지만 Cafe24 서버(000.000.000.000)의 내용을 확인해야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단계 2: hosts 파일 수정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로컬 hosts 파일에 다음과 같은 내용 추가:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;accesslog&quot;&gt;&lt;code&gt;000.000.000.000 example.com&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단계 3: 결과&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브라우저에서 example.com 입력 시 DNS를 거치지 않고 바로 000.000.000.000로 연결&lt;/li&gt;
&lt;li&gt;따라서 Cafe24 서버의 내용을 볼 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;맥에서 hosts 파일 수정하는 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 터미널에서 hosts 파일 열기&lt;/h3&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;sudo nano /etc/hosts&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;sudo vim /etc/hosts&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 파일에 내용 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 내용 아래에 다음과 같이 추가:&lt;/p&gt;
&lt;pre class=&quot;accesslog&quot;&gt;&lt;code&gt;000.000.000.000 yourdomain.com&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 파일 저장 및 종료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;nano 사용 시: &lt;code&gt;Ctrl + X&lt;/code&gt; &amp;rarr; &lt;code&gt;Y&lt;/code&gt; &amp;rarr; &lt;code&gt;Enter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;vim 사용 시: &lt;code&gt;ESC&lt;/code&gt; &amp;rarr; &lt;code&gt;:wq&lt;/code&gt; &amp;rarr; &lt;code&gt;Enter&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. DNS 캐시 플러시 (선택사항)&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주의사항&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;관리자 권한 필요&lt;/b&gt;: hosts 파일 수정 시 &lt;code&gt;sudo&lt;/code&gt; 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;임시 조치&lt;/b&gt;: 작업 완료 후 해당 라인을 삭제하거나 주석 처리(&lt;code&gt;#&lt;/code&gt;)하는 것을 잊지 마세요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;브라우저 캐시&lt;/b&gt;: 경우에 따라 브라우저 캐시를 지워야 할 수도 있습니다&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;작업 후 원상복구&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업이 끝나면 hosts 파일에서 추가한 라인을 삭제하거나 앞에 &lt;code&gt;#&lt;/code&gt;를 붙여서 주석 처리하세요:&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;# 000.000.000.000 yourdomain.com&lt;/code&gt;&lt;/pre&gt;</description>
      <category>개념원리</category>
      <category>DNS</category>
      <category>hosts</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/294</guid>
      <comments>https://ifelseif.tistory.com/294#entry294comment</comments>
      <pubDate>Thu, 7 Aug 2025 07:33:54 +0900</pubDate>
    </item>
    <item>
      <title>[250719 TIL] bcrypt 기본</title>
      <link>https://ifelseif.tistory.com/293</link>
      <description>&lt;h2&gt;패스워드 저장 방법&lt;/h2&gt;
&lt;p&gt;패스워드는 &lt;strong&gt;절대 평문으로 저장하면 안 되고&lt;/strong&gt;, 해싱해서 저장해야 합니다.&lt;/p&gt;
&lt;h3&gt;1. bcrypt 설치&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm add bcrypt
pnpm add -D @types/bcrypt&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 회원가입 시 패스워드 해싱&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// app/api/auth/signup/route.ts
import bcrypt from &amp;#39;bcrypt&amp;#39;;
import { prisma } from &amp;#39;@/lib/prisma&amp;#39;;

export async function POST(request: Request) {
  const { email, nickname, password } = await request.json();

  // 패스워드 해싱 (saltRounds: 10~12 권장)
  const hashedPassword = await bcrypt.hash(password, 10);

  const user = await prisma.user.create({
    data: {
      email,
      nickname,
      password: hashedPassword, // 해싱된 패스워드 저장
    },
  });

  return Response.json({ user: { id: user.id, email: user.email } });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 로그인 시 패스워드 검증&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 로그인 시
const isValidPassword = await bcrypt.compare(inputPassword, user.password);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;핵심&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DB에는 해싱된 패스워드만 저장&lt;/li&gt;
&lt;li&gt;로그인 시에는 입력받은 평문 패스워드를 bcrypt.compare()로 검증&lt;/li&gt;
&lt;li&gt;saltRounds는 10~12 정도가 적당 (보안 vs 성능 균형)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>javascript</category>
      <category>bcrypt</category>
      <category>hashing</category>
      <category>password</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/293</guid>
      <comments>https://ifelseif.tistory.com/293#entry293comment</comments>
      <pubDate>Sat, 19 Jul 2025 20:53:11 +0900</pubDate>
    </item>
    <item>
      <title>[250719 TIL] Prisma 마이그레이션</title>
      <link>https://ifelseif.tistory.com/292</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;질문:&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스키마 새로 작성시 마이그레이션, 클라이언트 생성 은 맨날 반복하면 되남유?&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;답변:&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네 맞습니다! 스키마를 변경할 때마다 반복해야 해요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스키마 변경 시 워크플로우&lt;/h2&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;# 1. schema.prisma 수정

# 2. 마이그레이션 생성 &amp;amp; 적용
npx prisma migrate dev --name add-user-model

# 3. Prisma Client 재생성 (보통 자동으로 됨)
npx prisma generate&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;중요한 점들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;migrate dev의 장점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;npx prisma migrate dev&lt;/code&gt;는 마이그레이션 파일 생성 + DB 적용 + 클라이언트 생성을 한 번에 처리해요&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--name&lt;/code&gt; 옵션으로 마이그레이션에 의미있는 이름을 부여하세요&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 사용 예시&lt;/h3&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;# 처음 User 모델 만들 때
npx prisma migrate dev --name create-user-table

# 나중에 Post 모델 추가할 때  
npx prisma migrate dev --name add-post-model

# User에 avatar 필드 추가할 때
npx prisma migrate dev --name add-user-avatar&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고사항&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;prisma generate&lt;/code&gt;는 대부분 &lt;code&gt;migrate dev&lt;/code&gt; 실행 시 자동으로 실행됩니다&lt;/li&gt;
&lt;li&gt;만약 자동 실행이 안 되면 수동으로 &lt;code&gt;npx prisma generate&lt;/code&gt; 실행&lt;/li&gt;
&lt;li&gt;Prisma Studio로 DB 확인: &lt;code&gt;npx prisma studio&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>library</category>
      <category>db</category>
      <category>Migration</category>
      <category>prisma</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/292</guid>
      <comments>https://ifelseif.tistory.com/292#entry292comment</comments>
      <pubDate>Sat, 19 Jul 2025 20:51:18 +0900</pubDate>
    </item>
    <item>
      <title>[250719 TIL] SSO, OAuth</title>
      <link>https://ifelseif.tistory.com/291</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;SSO vs OAuth&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSO(Single Sign-On)와 OAuth는 &lt;b&gt;인증(Authentication)&lt;/b&gt;과 &lt;b&gt;인가(Authorization)&lt;/b&gt; 관점에서 서로 다릅니다. 간단히 비교하면 다음과 같습니다:&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;✅ 목적의 차이&lt;/b&gt;&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;SSO (Single Sign-On)&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;OAuth (Open Authorization)&lt;/b&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;핵심 목적&lt;/td&gt;
&lt;td&gt;&lt;b&gt;하나의 로그인으로 여러 서비스 사용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;제3자 앱이 사용자의 자원에 접근 허용&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주요 개념&lt;/td&gt;
&lt;td&gt;인증(Authentication) 중심&lt;/td&gt;
&lt;td&gt;인가(Authorization) 중심&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;✅ 예시&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;SSO&lt;/b&gt;:&lt;/li&gt;
&lt;li&gt;회사 포털에 한 번 로그인하면 이메일, 회계, 인트라넷 등 다양한 시스템에 추가 로그인 없이 접근 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OAuth&lt;/b&gt;:&lt;/li&gt;
&lt;li&gt;어떤 앱이 &amp;ldquo;구글 계정으로 로그인&amp;rdquo;할 때, 구글에서 인증하고 &amp;rarr; 해당 앱이 사용자 이메일, 프로필 등 &lt;b&gt;제한된 정보에 접근하도록 허용&lt;/b&gt;함&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;✅ 실제 동작&lt;/b&gt;&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;SSO&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;OAuth&lt;/b&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;로그인 주체&lt;/td&gt;
&lt;td&gt;사용자가 &lt;b&gt;한 번 로그인하면&lt;/b&gt; 여러 서비스에 공유됨&lt;/td&gt;
&lt;td&gt;사용자가 &lt;b&gt;권한 부여&lt;/b&gt; 버튼 클릭 &amp;rarr; 제3자 앱에 권한 위임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자원 접근 여부&lt;/td&gt;
&lt;td&gt;로그인하면 모든 연동 서비스에 자동 접근&lt;/td&gt;
&lt;td&gt;사용자가 명시적으로 어떤 자원에 접근 허용할지 지정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;✅ 결론 요약&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;SSO&lt;/b&gt;는 &lt;b&gt;사용자 인증 재사용&lt;/b&gt;이 목적 (여러 서비스 한 번에 로그인)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OAuth&lt;/b&gt;는 &lt;b&gt;타 서비스가 사용자 자원 접근&lt;/b&gt;을 위한 위임 시스템&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  실제 서비스에서는 이 둘을 함께 사용하기도 합니다. 예: &amp;ldquo;구글 SSO&amp;rdquo;는 OAuth2 프로토콜을 기반으로 동작합니다.&lt;/p&gt;</description>
      <category>개념원리</category>
      <category>oauth</category>
      <category>sso</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/291</guid>
      <comments>https://ifelseif.tistory.com/291#entry291comment</comments>
      <pubDate>Sat, 19 Jul 2025 20:49:23 +0900</pubDate>
    </item>
    <item>
      <title>[250719 TIL] Prisma seeding</title>
      <link>https://ifelseif.tistory.com/290</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;질문:&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;seed.ts 를 작성했는데, 이거 자동 반영되나유??&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;답변:&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아니요! 시드 스크립트는 &lt;b&gt;앱 시작과는 별개로 수동 실행&lt;/b&gt;해야 해요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시드 실행 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 직접 실행&lt;/h3&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;npx tsx seed.ts
# 또는
node --loader ts-node/esm seed.ts&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. package.json에 스크립트 추가 (권장)&lt;/h3&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;next dev&quot;,
    &quot;build&quot;: &quot;next build&quot;,
    &quot;seed&quot;: &quot;tsx seed.ts&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 이렇게 실행:&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;pnpm run seed&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Prisma 공식 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;package.json&lt;/code&gt;에 prisma 설정 추가:&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;prisma&quot;: {
    &quot;seed&quot;: &quot;tsx prisma/seed.ts&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 이렇게 실행:&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;npx prisma db seed&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언제 시드를 실행하나요?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;개발 시작할 때&lt;/b&gt;: 테스트 데이터 필요할 때&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DB 초기화 후&lt;/b&gt;: &lt;code&gt;prisma migrate reset&lt;/code&gt; 후&lt;/li&gt;
&lt;li&gt;&lt;b&gt;새로운 테스트 데이터 필요할 때&lt;/b&gt;: 언제든 수동 실행&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;시드는 &lt;b&gt;개발/테스트용&lt;/b&gt;이에요&lt;/li&gt;
&lt;li&gt;프로덕션에서는 절대 실행하면 안 됩니다 (기존 데이터 삭제하니까요!)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deleteMany()&lt;/code&gt;가 있어서 기존 데이터를 모두 지우고 새로 만들어요&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>library</category>
      <category>prisma</category>
      <category>seed</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/290</guid>
      <comments>https://ifelseif.tistory.com/290#entry290comment</comments>
      <pubDate>Sat, 19 Jul 2025 20:44:43 +0900</pubDate>
    </item>
    <item>
      <title>[250719 TIL] Prisma Type 사용관련</title>
      <link>https://ifelseif.tistory.com/289</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;질문:&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;src/generated/prisma/index.d.ts 에 다음 코드가 있는 걸 확인 했는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;export type Post = $Result.DefaultSelection&amp;lt;Prisma.$PostPayload&amp;gt;&lt;br /&gt;&lt;/code&gt;src/generated/prisma 는 gitignore 처리 되어 있어요.&lt;br /&gt;&lt;br /&gt;그러면 이거.. git 에 올라가지 않으니 vercel 배포시 오류날 것 같은데,&lt;br /&gt;저 export type Post 부분을 prisma 에서 제공하는 인터페이스? 등을 활용해서 제가 작성해야 하나유?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;답변:&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아니요! Vercel 배포 시 자동으로 해결돼요. 걱정하지 마세요!&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Vercel 배포 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 빌드 시 자동 생성&lt;/h3&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// package.json
{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;next build&quot;,
    &quot;postinstall&quot;: &quot;prisma generate --no-engine&quot; // 또는 build 스크립트에 포함
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Vercel이 자동으로 실행&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;# Vercel에서 배포 시 자동 실행 순서
1. pnpm install
2. prisma generate  # generated 폴더 자동 생성!
3. next build&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;권장 설정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;package.json 스크립트 수정&lt;/h3&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;next dev&quot;,
    &quot;build&quot;: &quot;prisma generate &amp;amp;&amp;amp; next build&quot;,
    &quot;postinstall&quot;: &quot;prisma generate&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;.gitignore는 그대로 유지&lt;/h3&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;# Prisma
/prisma/generated/
/src/generated/prisma/&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;타입 사용법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;생성된 타입 그대로 사용&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// route handler
import { Post, User } from '@prisma/client'

export async function GET(): Promise&amp;lt;Response&amp;gt; {
  const posts: Post[] = await prisma.post.findMany()
  return Response.json(posts)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프론트엔드에서도 동일하게&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// components/post-list.tsx
import { Post } from '@prisma/client'

interface Props {
  posts: Post[]
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ &lt;code&gt;generated&lt;/code&gt; 폴더는 gitignore 유지&lt;/li&gt;
&lt;li&gt;✅ Vercel이 배포 시 자동으로 &lt;code&gt;prisma generate&lt;/code&gt; 실행&lt;/li&gt;
&lt;li&gt;✅ &lt;code&gt;@prisma/client&lt;/code&gt;에서 타입 import해서 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도로 타입을 만들 필요 없어요!&lt;/p&gt;</description>
      <category>library</category>
      <category>prisma</category>
      <category>type</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/289</guid>
      <comments>https://ifelseif.tistory.com/289#entry289comment</comments>
      <pubDate>Sat, 19 Jul 2025 20:41:57 +0900</pubDate>
    </item>
    <item>
      <title>[250719 TIL] Tanstack SSR시 왜 클라이언트 매번 새로 생성?</title>
      <link>https://ifelseif.tistory.com/288</link>
      <description>&lt;h2&gt;RSC에서 QueryClient 패턴&lt;/h2&gt;
&lt;p&gt;질문: prisma client 혹은 다른 여러 이런 종류 클라이언트들은 singleton 으로 쓰는 경우 많은데,&lt;br&gt;왜 tanstack-query server side 에서는 매번 new QueryClient() 하나유?&lt;/p&gt;
&lt;h3&gt;왜 매번 새로 생성하나요?&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 각 요청마다 독립적인 QueryClient가 필요
async function PostPage() {
  const queryClient = new QueryClient() // 서버 요청별로 새 인스턴스

  await queryClient.prefetchQuery({...})

  return &amp;lt;HydrationBoundary state={dehydrate(queryClient)}&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;이유&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;서버사이드는 stateless&lt;/strong&gt;: 각 요청이 독립적&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;요청간 격리&lt;/strong&gt;: 다른 사용자의 데이터가 섞이면 안됨&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;메모리 누수 방지&lt;/strong&gt;: 요청 완료 후 GC로 정리&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Prisma vs TanStack Query 차이&lt;/h2&gt;
&lt;h3&gt;Prisma (연결 풀 관리)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 연결은 재사용, 인스턴스는 싱글톤
const prisma = globalThis.prismaGlobal ?? new PrismaClient()&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;TanStack Query (상태 관리)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 각 요청마다 새로운 상태 컨텍스트
const queryClient = new QueryClient() // 매번 새로 생성이 정상!&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;클라이언트에서는 다르게&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// app/providers.tsx - 클라이언트는 싱글톤
&amp;#39;use client&amp;#39;
const [queryClient] = useState(() =&amp;gt; new QueryClient()) // 한 번만 생성&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;RSC에서 매번 new QueryClient()&lt;/strong&gt;: 정상 패턴&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Prisma는 싱글톤&lt;/strong&gt;: 연결 재사용&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;클라이언트는 QueryClient 싱글톤&lt;/strong&gt;: 상태 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;TanStack Query 공식 문서에서도 RSC에서는 매번 새로 생성하라고 권장해요!&lt;br&gt;그렇다고 합니다...&lt;/p&gt;</description>
      <category>library</category>
      <category>prefetchquery</category>
      <category>singleton</category>
      <category>SSR</category>
      <category>tanstack-query</category>
      <author>adminisme</author>
      <guid isPermaLink="true">https://ifelseif.tistory.com/288</guid>
      <comments>https://ifelseif.tistory.com/288#entry288comment</comments>
      <pubDate>Sat, 19 Jul 2025 20:37:12 +0900</pubDate>
    </item>
  </channel>
</rss>