diff --git a/package.json b/package.json index 25ab4e1e..a8c21670 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@react-three/postprocessing": "^3.0.4", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.57.4", + "@tanstack/react-query": "^5.101.2", "googleapis": "^171.4.0", "gsap": "^3.13.0", "katex": "^0.16.44", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec3566d2..7483487c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@supabase/supabase-js': specifier: ^2.57.4 version: 2.98.0 + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.2(react@19.2.4) googleapis: specifier: ^171.4.0 version: 171.4.0 @@ -1298,89 +1301,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1476,24 +1495,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.12': resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.12': resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.12': resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.12': resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==} @@ -1604,36 +1627,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -1753,66 +1782,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -2014,24 +2056,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.17': resolution: {integrity: sha512-D0/6Hj4CkgSTTahtlGxv9IDsLTuvQz30mkZEMDp8TqwYhCL8AomznkibwlQU8HtY4q/dqd1OGRPH+FmNb4BBEA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.17': resolution: {integrity: sha512-1s2OFsg6DeRkWU7c+PIfIHZsFCbiZ34akXFHrg7KjpF8zIvpHZNoUUZimoWEwcB6GquXSkAO+1b5KpG5nusTeQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.17': resolution: {integrity: sha512-gtxGMGYtRWWmCcgx6xM2Yos43uiE/j8kZwkeL/LNGG9zM0tatd23NsfL9PnQJ45hY7QZ+dx2rM68e4ArgG4kJg==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.17': resolution: {integrity: sha512-gxi+/Miytez/O9vJ/QiheIivA3oWZjPp9nJu3VmAfLMWUzcZORMwgaI1ygtDTLjz7CzcwlGMJz/Ab66Y5DfNpg==} @@ -2069,6 +2115,14 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2396,41 +2450,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4494,24 +4556,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -8405,6 +8471,13 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.4 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 diff --git a/src/app/[locale]/(home)/page.test.tsx b/src/app/[locale]/(home)/page.test.tsx index fa5a3659..2ee461f1 100644 --- a/src/app/[locale]/(home)/page.test.tsx +++ b/src/app/[locale]/(home)/page.test.tsx @@ -59,7 +59,7 @@ describe('HomeRoute', () => { alternates: { canonical: 'https://chaen.dev/ko', languages: { - 'x-default': 'https://chaen.dev/en', + 'x-default': 'https://chaen.dev/ko', en: 'https://chaen.dev/en', fr: 'https://chaen.dev/fr', ja: 'https://chaen.dev/ja', diff --git a/src/app/[locale]/project/page.test.tsx b/src/app/[locale]/project/page.test.tsx index 7c2bb05d..431d1cb2 100644 --- a/src/app/[locale]/project/page.test.tsx +++ b/src/app/[locale]/project/page.test.tsx @@ -64,15 +64,9 @@ describe('ProjectRoute', () => { }), }), ).resolves.toMatchObject({ + // project 아카이브는 canonical 신호만 노출한다(hreflang alternates 미제공). alternates: { canonical: 'https://chaen.dev/ko/project', - languages: { - 'x-default': 'https://chaen.dev/en/project', - en: 'https://chaen.dev/en/project', - fr: 'https://chaen.dev/fr/project', - ja: 'https://chaen.dev/ja/project', - ko: 'https://chaen.dev/ko/project', - }, }, description: '작업한 프로젝트와 그 안에 쌓인 기록들을 함께 정리합니다.', openGraph: { diff --git a/src/app/[locale]/resume/page.test.tsx b/src/app/[locale]/resume/page.test.tsx index 155eead6..4d35de3d 100644 --- a/src/app/[locale]/resume/page.test.tsx +++ b/src/app/[locale]/resume/page.test.tsx @@ -56,7 +56,7 @@ describe('ResumeRoute', () => { alternates: { canonical: 'https://chaen.dev/ko/resume', languages: { - 'x-default': 'https://chaen.dev/en/resume', + 'x-default': 'https://chaen.dev/ko/resume', en: 'https://chaen.dev/en/resume', fr: 'https://chaen.dev/fr/resume', ja: 'https://chaen.dev/ja/resume', diff --git a/src/app/api/(content)/article-popular-tags/route.test.ts b/src/app/api/(content)/article-popular-tags/route.test.ts index ebfb5e26..b882398f 100644 --- a/src/app/api/(content)/article-popular-tags/route.test.ts +++ b/src/app/api/(content)/article-popular-tags/route.test.ts @@ -1,15 +1,12 @@ +// @vitest-environment node + import { vi } from 'vitest'; import { GET } from '@/app/api/(content)/article-popular-tags/route'; -import { getPopularArticleTags } from '@/entities/article/api/list/get-popular-article-tags'; -import { getTagLabelMapBySlugs } from '@/entities/tag/api/query-tags'; +import { getLocalizedPopularArticleTags } from '@/entities/article/api/list/get-popular-article-tags'; vi.mock('@/entities/article/api/list/get-popular-article-tags', () => ({ - getPopularArticleTags: vi.fn(), -})); - -vi.mock('@/entities/tag/api/query-tags', () => ({ - getTagLabelMapBySlugs: vi.fn(), + getLocalizedPopularArticleTags: vi.fn(), })); describe('api/article-popular-tags route', () => { @@ -17,26 +14,19 @@ describe('api/article-popular-tags route', () => { vi.clearAllMocks(); }); - it('locale 기준 인기 태그 목록을 반환한다', async () => { - vi.mocked(getPopularArticleTags).mockResolvedValue([ + it('locale 기준 label이 결합된 인기 태그 목록을 반환한다', async () => { + vi.mocked(getLocalizedPopularArticleTags).mockResolvedValue([ { article_count: 3, + label: 'Next.js', tag: 'nextjs', }, ]); - vi.mocked(getTagLabelMapBySlugs).mockResolvedValue({ - data: new Map([['nextjs', 'Next.js']]), - schemaMissing: false, - }); const response = await GET(new Request('https://chaen.dev/api/article-popular-tags?locale=ko')); expect(response.status).toBe(200); - expect(getPopularArticleTags).toHaveBeenCalledWith({ locale: 'ko' }); - expect(getTagLabelMapBySlugs).toHaveBeenCalledWith({ - locale: 'ko', - slugs: ['nextjs'], - }); + expect(getLocalizedPopularArticleTags).toHaveBeenCalledWith({ locale: 'ko' }); expect(await response.json()).toEqual([ { article_count: 3, @@ -46,13 +36,12 @@ describe('api/article-popular-tags route', () => { ]); }); - it('인기 태그가 비어 있으면 label 조회를 건너뛴다', async () => { - vi.mocked(getPopularArticleTags).mockResolvedValue([]); + it('인기 태그가 비어 있으면 빈 배열을 반환한다', async () => { + vi.mocked(getLocalizedPopularArticleTags).mockResolvedValue([]); const response = await GET(new Request('https://chaen.dev/api/article-popular-tags?locale=ko')); expect(response.status).toBe(200); - expect(getTagLabelMapBySlugs).not.toHaveBeenCalled(); expect(await response.json()).toEqual([]); }); }); diff --git a/src/app/api/(content)/article-popular-tags/route.ts b/src/app/api/(content)/article-popular-tags/route.ts index 2c381490..a9d631f0 100644 --- a/src/app/api/(content)/article-popular-tags/route.ts +++ b/src/app/api/(content)/article-popular-tags/route.ts @@ -1,6 +1,5 @@ -import { getPopularArticleTags } from '@/entities/article/api/list/get-popular-article-tags'; +import { getLocalizedPopularArticleTags } from '@/entities/article/api/list/get-popular-article-tags'; import type { LocalizedArticleTagStat } from '@/entities/article/model/types'; -import { getTagLabelMapBySlugs } from '@/entities/tag/api/query-tags'; import { API_INTERNAL_ERROR_MESSAGE } from '@/shared/lib/http/api-error-catalog'; import { runJsonRoute } from '@/shared/lib/http/run-json-route'; @@ -12,25 +11,8 @@ export const GET = async (request: Request) => runJsonRoute({ action: async () => { const locale = new URL(request.url).searchParams.get('locale')?.trim() || 'ko'; - const popularTags = await getPopularArticleTags({ locale }); - if (popularTags.length === 0) { - return []; - } - - const localizedTagLabels = await getTagLabelMapBySlugs({ - locale, - slugs: popularTags.map(item => item.tag), - }); - - if (localizedTagLabels.schemaMissing) { - throw new Error('[articles] 태그 label schema가 없습니다.'); - } - - return popularTags.map(item => ({ - ...item, - label: localizedTagLabels.data.get(item.tag) ?? item.tag, - })); + return getLocalizedPopularArticleTags({ locale }); }, errorMessage: API_INTERNAL_ERROR_MESSAGE.tagsFetchFailed, }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 12736804..3421de10 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { d2Coding, pretendard, pretendardJp } from '@/app/fonts'; import { SceneAssetPreloader } from '@/entities/scene/ui/scene-asset-preloader'; +import { QueryProvider } from '@/shared/providers'; import './globals.css'; import 'katex/dist/katex.min.css'; @@ -27,7 +28,7 @@ const RootLayout = ({ children }: RootLayoutProps) => ( > - {children} + {children} ); diff --git a/src/entities/article/api/detail/get-article-detail-list.test.ts b/src/entities/article/api/detail/get-article-detail-list.test.ts index 70988307..365a10cb 100644 --- a/src/entities/article/api/detail/get-article-detail-list.test.ts +++ b/src/entities/article/api/detail/get-article-detail-list.test.ts @@ -12,6 +12,7 @@ import { hasSupabaseEnv } from '@/shared/lib/supabase/config'; import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); @@ -364,25 +365,16 @@ describe('getArticleDetailListWindow', () => { terminalCall: 2, terminalMethod: 'in', }); + // olderPage와 newer base row 조회가 Promise.all로 병렬 실행되므로 + // 호출 순서가 아닌 테이블 + older/newer 순서 기준으로 query mock을 반환합니다. + const articlesQueue = [olderArticlesQuery, newerArticlesQuery]; + const translationsQueue = [olderTranslationsQuery, newerTranslationsQuery]; const supabaseClient = { - from: vi - .fn() - .mockImplementationOnce((table: string) => { - if (table === 'articles') return olderArticlesQuery; - throw new Error(`unexpected table: ${table}`); - }) - .mockImplementationOnce((table: string) => { - if (table === 'article_translations') return olderTranslationsQuery; - throw new Error(`unexpected table: ${table}`); - }) - .mockImplementationOnce((table: string) => { - if (table === 'articles') return newerArticlesQuery; - throw new Error(`unexpected table: ${table}`); - }) - .mockImplementationOnce((table: string) => { - if (table === 'article_translations') return newerTranslationsQuery; - throw new Error(`unexpected table: ${table}`); - }), + from: vi.fn((table: string) => { + if (table === 'articles') return articlesQueue.shift(); + if (table === 'article_translations') return translationsQueue.shift(); + throw new Error(`unexpected table: ${table}`); + }), }; vi.mocked(hasSupabaseEnv).mockReturnValue(true); diff --git a/src/entities/article/api/detail/get-article-detail-list.ts b/src/entities/article/api/detail/get-article-detail-list.ts index b4350edb..e8c4897a 100644 --- a/src/entities/article/api/detail/get-article-detail-list.ts +++ b/src/entities/article/api/detail/get-article-detail-list.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { ARTICLES_CACHE_TAG } from '@/entities/article/model/cache-tags'; import type { ArticleArchivePage } from '@/entities/article/model/types'; @@ -278,6 +278,8 @@ const readCachedArticleDetailList = async (input: { 'use cache'; cacheTag(ARTICLES_CACHE_TAG); + // 예약 발행(publish_at) 글이 태그 무효화 없이도 TTL로 노출되도록 함. + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); const parsedCursor = parseLocaleAwarePublishedAtIdCursor(input.cursor); const requestedLocale = parsedCursor?.locale ?? input.normalizedLocale; @@ -349,12 +351,15 @@ export const getArticleDetailListWindow = async ({ publishedAt: currentItem.publish_at, }); - let olderPage = await getArticleDetailList({ - cursor: olderCursor, - limit: trailingSlots, - locale, - }); - let newerBaseRowsResult = await fetchNewerArticleArchiveBaseRows(currentItem, leadingSlots); + // olderPage 조회와 newer base row 조회는 서로 의존하지 않으므로 병렬로 실행합니다. + let [olderPage, newerBaseRowsResult] = await Promise.all([ + getArticleDetailList({ + cursor: olderCursor, + limit: trailingSlots, + locale, + }), + fetchNewerArticleArchiveBaseRows(currentItem, leadingSlots), + ]); if (newerBaseRowsResult.schemaMissing) { throw new Error('[articles] content schema가 없습니다.'); } diff --git a/src/entities/article/api/detail/get-article.test.ts b/src/entities/article/api/detail/get-article.test.ts index 432263b6..1a909a00 100644 --- a/src/entities/article/api/detail/get-article.test.ts +++ b/src/entities/article/api/detail/get-article.test.ts @@ -33,6 +33,18 @@ const createArticleSlugLookupQuery = (id = 'frontend-performance') => ({ select: vi.fn().mockReturnThis(), }); +/** + * `fetchAvailableArticleLocales`가 사용하는 `article_translations` locale 조회 mock을 만듭니다. + */ +const createArticleTranslationLocalesQuery = () => ({ + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockResolvedValue({ + data: [], + error: null, + }), + select: vi.fn().mockReturnThis(), +}); + describe('getArticle', () => { afterEach(() => { vi.clearAllMocks(); @@ -87,11 +99,14 @@ describe('getArticle', () => { }), select: vi.fn().mockReturnThis(), }; - supabaseClient.from = vi - .fn() - .mockReturnValueOnce(articleSlugQuery) - .mockReturnValueOnce(articleTagsV2Query) - .mockReturnValueOnce(tagsQuery); + const articleLocalesQuery = createArticleTranslationLocalesQuery(); + supabaseClient.from = vi.fn((table: string) => { + if (table === 'articles') return articleSlugQuery; + if (table === 'article_translations') return articleLocalesQuery; + if (table === 'article_tags') return articleTagsV2Query; + if (table === 'tags') return tagsQuery; + throw new Error(`unexpected table: ${table}`); + }); vi.mocked(hasSupabaseEnv).mockReturnValue(true); vi.mocked(getOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); @@ -209,10 +224,13 @@ describe('getArticle', () => { }), select: vi.fn().mockReturnThis(), }; - supabaseClient.from = vi - .fn() - .mockReturnValueOnce(articleSlugQuery) - .mockReturnValueOnce(articleTagsV2Query); + const articleLocalesQuery = createArticleTranslationLocalesQuery(); + supabaseClient.from = vi.fn((table: string) => { + if (table === 'articles') return articleSlugQuery; + if (table === 'article_translations') return articleLocalesQuery; + if (table === 'article_tags') return articleTagsV2Query; + throw new Error(`unexpected table: ${table}`); + }); vi.mocked(hasSupabaseEnv).mockReturnValue(true); vi.mocked(getOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); @@ -258,10 +276,13 @@ describe('getArticle', () => { }), select: vi.fn().mockReturnThis(), }; - supabaseClient.from = vi - .fn() - .mockReturnValueOnce(articleSlugQuery) - .mockReturnValueOnce(articleTagsV2Query); + const articleLocalesQuery = createArticleTranslationLocalesQuery(); + supabaseClient.from = vi.fn((table: string) => { + if (table === 'articles') return articleSlugQuery; + if (table === 'article_translations') return articleLocalesQuery; + if (table === 'article_tags') return articleTagsV2Query; + throw new Error(`unexpected table: ${table}`); + }); vi.mocked(hasSupabaseEnv).mockReturnValue(true); vi.mocked(getOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); @@ -311,8 +332,14 @@ describe('getArticle', () => { }), select: vi.fn().mockReturnThis(), }; + const articleLocalesQuery = createArticleTranslationLocalesQuery(); const supabaseClient = { - from: vi.fn().mockReturnValueOnce(articleSlugQuery).mockReturnValueOnce(articleTagsV2Query), + from: vi.fn((table: string) => { + if (table === 'articles') return articleSlugQuery; + if (table === 'article_translations') return articleLocalesQuery; + if (table === 'article_tags') return articleTagsV2Query; + throw new Error(`unexpected table: ${table}`); + }), rpc: vi.fn().mockResolvedValue({ data: [ { @@ -378,10 +405,13 @@ describe('getArticle', () => { }), select: vi.fn().mockReturnThis(), }; - supabaseClient.from = vi - .fn() - .mockReturnValueOnce(articleSlugQuery) - .mockReturnValueOnce(articleTagsV2Query); + const articleLocalesQuery = createArticleTranslationLocalesQuery(); + supabaseClient.from = vi.fn((table: string) => { + if (table === 'articles') return articleSlugQuery; + if (table === 'article_translations') return articleLocalesQuery; + if (table === 'article_tags') return articleTagsV2Query; + throw new Error(`unexpected table: ${table}`); + }); vi.mocked(hasSupabaseEnv).mockReturnValue(true); vi.mocked(getOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); @@ -429,10 +459,13 @@ describe('getArticle', () => { }), select: vi.fn().mockReturnThis(), }; - supabaseClient.from = vi - .fn() - .mockReturnValueOnce(articleSlugQuery) - .mockReturnValueOnce(articleTagsV2Query); + const articleLocalesQuery = createArticleTranslationLocalesQuery(); + supabaseClient.from = vi.fn((table: string) => { + if (table === 'articles') return articleSlugQuery; + if (table === 'article_translations') return articleLocalesQuery; + if (table === 'article_tags') return articleTagsV2Query; + throw new Error(`unexpected table: ${table}`); + }); vi.mocked(hasSupabaseEnv).mockReturnValue(true); vi.mocked(getOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); diff --git a/src/entities/article/api/detail/get-related-articles.test.ts b/src/entities/article/api/detail/get-related-articles.test.ts index 6eb7f1f8..320ea3e7 100644 --- a/src/entities/article/api/detail/get-related-articles.test.ts +++ b/src/entities/article/api/detail/get-related-articles.test.ts @@ -6,6 +6,7 @@ import { hasSupabaseEnv } from '@/shared/lib/supabase/config'; import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); diff --git a/src/entities/article/api/detail/get-related-articles.ts b/src/entities/article/api/detail/get-related-articles.ts index 2cc1b73c..3ea91682 100644 --- a/src/entities/article/api/detail/get-related-articles.ts +++ b/src/entities/article/api/detail/get-related-articles.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { type ArticleTranslationRow, @@ -283,6 +283,8 @@ const readCachedRelatedArticles = async ({ 'use cache'; cacheTag(ARTICLES_CACHE_TAG, createArticleCacheTag(articleId)); + // 예약 발행(publish_at) 글이 태그 무효화 없이도 TTL로 노출되도록 함. + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); const candidateArticleIds = await fetchCandidateArticleIdsBySharedTags({ articleId, diff --git a/src/entities/article/api/list/get-admin-google-article-traffic.test.ts b/src/entities/article/api/list/get-admin-google-article-traffic.test.ts index 51036112..f330cf5f 100644 --- a/src/entities/article/api/list/get-admin-google-article-traffic.test.ts +++ b/src/entities/article/api/list/get-admin-google-article-traffic.test.ts @@ -17,6 +17,13 @@ vi.mock('googleapis', () => ({ }, })); +vi.mock('next/cache', () => ({ + unstable_cache: + (fn: (...args: unknown[]) => unknown) => + (...args: unknown[]) => + fn(...args), +})); + describe('getAdminGoogleArticleTraffic', () => { const originalClientEmail = process.env.GOOGLE_SEARCH_CONSOLE_CLIENT_EMAIL; const originalPrivateKey = process.env.GOOGLE_SEARCH_CONSOLE_PRIVATE_KEY; @@ -170,7 +177,8 @@ describe('getAdminGoogleArticleTraffic', () => { const { getAdminGoogleArticleTraffic } = await import('@/entities/article/api/list/get-admin-google-article-traffic'); - await getAdminGoogleArticleTraffic({ today: new Date('2026-03-31T00:30:00+09:00') }); + // 실행 타임존(로컬/CI-UTC)과 무관하게 "로컬 날짜 범위" 계약을 검증하도록 로컬 컴포넌트로 생성한다. + await getAdminGoogleArticleTraffic({ today: new Date(2026, 2, 31, 0, 30) }); expect(mockQuery).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/entities/article/api/list/get-admin-google-article-traffic.ts b/src/entities/article/api/list/get-admin-google-article-traffic.ts index b2a49e4f..d226ef44 100644 --- a/src/entities/article/api/list/get-admin-google-article-traffic.ts +++ b/src/entities/article/api/list/get-admin-google-article-traffic.ts @@ -1,16 +1,86 @@ import { google } from 'googleapis'; +import { unstable_cache } from 'next/cache'; import type { AdminGoogleArticleTraffic, AdminGoogleArticleTrafficItem, } from '@/entities/article/model/types'; import { formatYearMonthDay } from '@/shared/lib/date/format-year-month-day'; -import { getGoogleSearchConsoleConfigOptional } from '@/shared/lib/google-search-console/config'; +import { + getGoogleSearchConsoleConfigOptional, + type GoogleSearchConsoleConfig, +} from '@/shared/lib/google-search-console/config'; import 'server-only'; const GOOGLE_SEARCH_CONSOLE_READONLY_SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly'; +type SearchConsoleClient = ReturnType; + +let cachedSearchConsoleClient: SearchConsoleClient | null = null; + +/** + * Search Console 클라이언트를 모듈 스코프에 1회만 생성해 재사용합니다. + * + * 매 요청마다 GoogleAuth를 새로 만들던 비용을 제거합니다. + */ +const getSearchConsoleClient = (config: GoogleSearchConsoleConfig): SearchConsoleClient => { + cachedSearchConsoleClient ??= google.searchconsole({ + auth: new google.auth.GoogleAuth({ + credentials: { + client_email: config.clientEmail, + private_key: config.privateKey, + }, + scopes: [GOOGLE_SEARCH_CONSOLE_READONLY_SCOPE], + }), + version: 'v1', + }); + + return cachedSearchConsoleClient; +}; + +/** + * Search Console 아티클 유입 상위 행을 15분 TTL 캐시로 조회합니다. + * + * 날짜(`startDate`/`endDate`)를 인자로 받아 캐시 키가 일 단위로만 바뀌게 합니다. + * 에러 상태 판별은 호출자에서 처리하므로, 실패 시 예외를 던져 캐시되지 않게 합니다. + */ +const fetchGoogleArticleTrafficRows = unstable_cache( + async (startDate: string, endDate: string, limit: number) => { + const config = getGoogleSearchConsoleConfigOptional(); + if (!config) return []; + + const searchConsole = getSearchConsoleClient(config); + const response = await searchConsole.searchanalytics.query({ + requestBody: { + dataState: 'final', + dimensionFilterGroups: [ + { + filters: [ + { + dimension: 'page', + expression: '/articles/', + operator: 'contains', + }, + ], + groupType: 'and', + }, + ], + dimensions: ['page'], + endDate, + rowLimit: limit, + searchType: 'web', + startDate, + }, + siteUrl: config.siteUrl, + }); + + return response.data.rows ?? []; + }, + ['admin-google-article-traffic'], + { revalidate: 900 }, +); + /** * Search Console row의 절대 URL을 관리자 패널용 path로 정규화합니다. */ @@ -81,41 +151,8 @@ export const getAdminGoogleArticleTraffic = async ({ } try { - const auth = new google.auth.GoogleAuth({ - credentials: { - client_email: config.clientEmail, - private_key: config.privateKey, - }, - scopes: [GOOGLE_SEARCH_CONSOLE_READONLY_SCOPE], - }); - const searchConsole = google.searchconsole({ - auth, - version: 'v1', - }); - const response = await searchConsole.searchanalytics.query({ - requestBody: { - dataState: 'final', - dimensionFilterGroups: [ - { - filters: [ - { - dimension: 'page', - expression: '/articles/', - operator: 'contains', - }, - ], - groupType: 'and', - }, - ], - dimensions: ['page'], - endDate, - rowLimit: limit, - searchType: 'web', - startDate: formattedStartDate, - }, - siteUrl: config.siteUrl, - }); - const items = (response.data.rows ?? []) + const rows = await fetchGoogleArticleTrafficRows(formattedStartDate, endDate, limit); + const items = rows .map(mapGoogleArticleTrafficItem) .filter((item): item is AdminGoogleArticleTrafficItem => item !== null); diff --git a/src/entities/article/api/list/get-articles.test.ts b/src/entities/article/api/list/get-articles.test.ts index 8976b5e6..6c189d80 100644 --- a/src/entities/article/api/list/get-articles.test.ts +++ b/src/entities/article/api/list/get-articles.test.ts @@ -10,6 +10,7 @@ import { hasSupabaseEnv } from '@/shared/lib/supabase/config'; import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); diff --git a/src/entities/article/api/list/get-articles.ts b/src/entities/article/api/list/get-articles.ts index 4a2bdd9c..492fcef4 100644 --- a/src/entities/article/api/list/get-articles.ts +++ b/src/entities/article/api/list/get-articles.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { ARTICLES_CACHE_TAG } from '@/entities/article/model/cache-tags'; import type { ArticleListItem, ArticleListPage } from '@/entities/article/model/types'; @@ -506,6 +506,8 @@ const readCachedArticles = async (input: { 'use cache'; cacheTag(ARTICLES_CACHE_TAG); + // 예약 발행(publish_at) 글이 태그 무효화 없이도 TTL로 노출되도록 함. + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); if (input.normalizedQuery) { return fetchSearchArticles( @@ -542,6 +544,7 @@ const readCachedResolvedArticlesFirstPage = async (input: { 'use cache'; cacheTag(ARTICLES_CACHE_TAG); + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); if (input.normalizedQuery) { return { diff --git a/src/entities/article/api/list/get-popular-article-tags.test.ts b/src/entities/article/api/list/get-popular-article-tags.test.ts index 5ea94473..bebe4767 100644 --- a/src/entities/article/api/list/get-popular-article-tags.test.ts +++ b/src/entities/article/api/list/get-popular-article-tags.test.ts @@ -3,11 +3,15 @@ import { unstable_cacheTag } from 'next/cache'; import { vi } from 'vitest'; -import { getPopularArticleTags } from '@/entities/article/api/list/get-popular-article-tags'; +import { + getLocalizedPopularArticleTags, + getPopularArticleTags, +} from '@/entities/article/api/list/get-popular-article-tags'; import { hasSupabaseEnv } from '@/shared/lib/supabase/config'; import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); @@ -112,3 +116,105 @@ describe('getPopularArticleTags', () => { ); }); }); + +describe('getLocalizedPopularArticleTags', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Supabase env가 없으면 빈 배열을 반환한다', async () => { + vi.mocked(hasSupabaseEnv).mockReturnValue(false); + + await expect(getLocalizedPopularArticleTags({ locale: 'ko' })).resolves.toEqual([]); + expect(unstable_cacheTag).not.toHaveBeenCalled(); + }); + + it('인기 태그 집계에 locale label을 결합해 반환한다', async () => { + const articleTagsQuery = { + select: vi.fn().mockResolvedValue({ + data: [{ tag_id: 'tag-1' }, { tag_id: 'tag-1' }, { tag_id: 'tag-2' }], + error: null, + }), + }; + const tagsSlugMapQuery = { + in: vi.fn().mockResolvedValue({ + data: [ + { id: 'tag-1', slug: 'nextjs' }, + { id: 'tag-2', slug: 'react' }, + ], + error: null, + }), + select: vi.fn().mockReturnThis(), + }; + const tagsBySlugQuery = { + in: vi.fn().mockResolvedValue({ + data: [ + { id: 'tag-1', slug: 'nextjs' }, + { id: 'tag-2', slug: 'react' }, + ], + error: null, + }), + select: vi.fn().mockReturnThis(), + }; + const tagTranslationsQuery = { + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockResolvedValue({ + data: [ + { label: 'Next.js', tag_id: 'tag-1' }, + { label: 'React', tag_id: 'tag-2' }, + ], + error: null, + }), + select: vi.fn().mockReturnThis(), + }; + const tagsQueue = [tagsSlugMapQuery, tagsBySlugQuery]; + const supabaseClient = { + from: vi.fn((table: string) => { + if (table === 'article_tags') return articleTagsQuery; + if (table === 'tags') return tagsQueue.shift(); + if (table === 'tag_translations') return tagTranslationsQuery; + throw new Error(`unexpected table: ${table}`); + }), + rpc: vi.fn(), + }; + + vi.mocked(hasSupabaseEnv).mockReturnValue(true); + vi.mocked(createOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); + + await expect(getLocalizedPopularArticleTags({ locale: 'ko' })).resolves.toEqual([ + { + article_count: 2, + label: 'Next.js', + tag: 'nextjs', + }, + { + article_count: 1, + label: 'React', + tag: 'react', + }, + ]); + expect(vi.mocked(unstable_cacheTag).mock.calls.flat()).toContain('articles'); + expect(vi.mocked(unstable_cacheTag).mock.calls.flat()).toContain('tags'); + }); + + it('인기 태그가 없으면 label 조회 없이 빈 배열을 반환한다', async () => { + const articleTagsQuery = { + select: vi.fn().mockResolvedValue({ + data: [], + error: null, + }), + }; + const supabaseClient = { + from: vi.fn((table: string) => { + if (table === 'article_tags') return articleTagsQuery; + throw new Error(`unexpected table: ${table}`); + }), + rpc: vi.fn(), + }; + + vi.mocked(hasSupabaseEnv).mockReturnValue(true); + vi.mocked(createOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); + + await expect(getLocalizedPopularArticleTags({ locale: 'ko' })).resolves.toEqual([]); + }); +}); diff --git a/src/entities/article/api/list/get-popular-article-tags.ts b/src/entities/article/api/list/get-popular-article-tags.ts index 934f0e09..e2ea2008 100644 --- a/src/entities/article/api/list/get-popular-article-tags.ts +++ b/src/entities/article/api/list/get-popular-article-tags.ts @@ -1,8 +1,13 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { ARTICLES_CACHE_TAG } from '@/entities/article/model/cache-tags'; -import type { ArticleTagStat } from '@/entities/article/model/types'; -import { getAllRelatedTagIds, getTagSlugMap } from '@/entities/tag/api/query-tags'; +import type { ArticleTagStat, LocalizedArticleTagStat } from '@/entities/article/model/types'; +import { + getAllRelatedTagIds, + getTagLabelMapBySlugs, + getTagSlugMap, +} from '@/entities/tag/api/query-tags'; +import { TAGS_CACHE_TAG } from '@/entities/tag/model/cache-tags'; import { hasSupabaseEnv } from '@/shared/lib/supabase/config'; import 'server-only'; @@ -33,6 +38,8 @@ const readCachedPopularArticleTags = async ( 'use cache'; cacheTag(ARTICLES_CACHE_TAG); + // 예약 발행(publish_at) 글이 태그 무효화 없이도 TTL로 노출되도록 함. + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); const relationTagIds = await getAllRelatedTagIds('article_tags'); if (relationTagIds.schemaMissing) { @@ -84,3 +91,51 @@ export const getPopularArticleTags = async ({ return readCachedPopularArticleTags(normalizedLimit, normalizedLocale); }; + +/** + * 인기 태그 집계와 locale label 조회를 결합한 결과를 `use cache`로 캐시합니다. + * + * 두 조회는 실제 의존 관계라 병렬화할 수 없으므로 조합 전체를 캐시해 + * warm 캐시에서는 DB 조회 없이 라벨이 결합된 결과를 반환합니다. + */ +const readCachedLocalizedPopularArticleTags = async ( + normalizedLocale: string, +): Promise => { + 'use cache'; + + cacheTag(ARTICLES_CACHE_TAG, TAGS_CACHE_TAG); + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); + + const popularTags = await getPopularArticleTags({ locale: normalizedLocale }); + if (popularTags.length === 0) { + return []; + } + + const localizedTagLabels = await getTagLabelMapBySlugs({ + locale: normalizedLocale, + slugs: popularTags.map(item => item.tag), + }); + if (localizedTagLabels.schemaMissing) { + throw new Error('[articles] 태그 label schema가 없습니다.'); + } + + return popularTags.map(item => ({ + ...item, + label: localizedTagLabels.data.get(item.tag) ?? item.tag, + })); +}; + +/** + * locale label이 결합된 인기 아티클 태그 목록을 `use cache` 기반으로 조회합니다. + */ +export const getLocalizedPopularArticleTags = async ({ + locale, +}: { + locale: string; +}): Promise => { + if (!hasSupabaseEnv()) return []; + + const normalizedLocale = locale.toLowerCase(); + + return readCachedLocalizedPopularArticleTags(normalizedLocale); +}; diff --git a/src/entities/article/comment/api/get-article-comments.test.ts b/src/entities/article/comment/api/get-article-comments.test.ts index c7eb0d24..be7f235f 100644 --- a/src/entities/article/comment/api/get-article-comments.test.ts +++ b/src/entities/article/comment/api/get-article-comments.test.ts @@ -7,6 +7,7 @@ import { hasSupabaseEnv } from '@/shared/lib/supabase/config'; import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); @@ -43,6 +44,7 @@ describe('getArticleComments', () => { const rootQuery = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue({ data: [ @@ -68,6 +70,7 @@ describe('getArticleComments', () => { const replyQuery = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue({ data: [ @@ -114,6 +117,7 @@ describe('getArticleComments', () => { const rootQuery = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue({ data: [ @@ -153,6 +157,7 @@ describe('getArticleComments', () => { const replyQuery = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), order: vi .fn() @@ -189,6 +194,7 @@ describe('getArticleComments', () => { const rootQuery = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue({ data: [ @@ -228,6 +234,7 @@ describe('getArticleComments', () => { const replyQuery = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), order: vi .fn() @@ -281,6 +288,7 @@ describe('getArticleComments', () => { const rootQuery = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue({ data: [ diff --git a/src/entities/article/comment/api/get-article-comments.ts b/src/entities/article/comment/api/get-article-comments.ts index 94db3b2d..367fef6d 100644 --- a/src/entities/article/comment/api/get-article-comments.ts +++ b/src/entities/article/comment/api/get-article-comments.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import type { ArticleComment, @@ -128,32 +128,40 @@ const fetchArticleCommentRoots = async ( }; /** - * 전달된 루트 댓글 id 목록의 대댓글을 조회합니다. + * 전달된 루트 댓글 id 목록의 대댓글을 단일 `.in()` 쿼리로 조회해 parent별로 그룹핑합니다. + * + * parent별 개별 조회(N+1) 대신 한 번의 쿼리로 읽고, 모든 parentId를 빈 배열로 초기화한 뒤 + * `created_at` 오름차순 정렬 결과를 순서대로 담아 parent 내부 순서를 보존합니다. */ const fetchRepliesByParentIds = async ( parentIds: string[], ): Promise> => { if (parentIds.length === 0) return {}; - const repliesByParentId = await Promise.all( - parentIds.map(async parentId => { - const supabase = createOptionalPublicServerSupabaseClient(); - if (!supabase) return [parentId, []] as const; + const repliesByParentId: Record = Object.fromEntries( + parentIds.map(parentId => [parentId, []]), + ); - const { data, error } = await supabase - .from('article_comments') - .select('*') - .eq('parent_id', parentId) - .is('deleted_at', null) - .order('created_at', { ascending: true }); + const supabase = createOptionalPublicServerSupabaseClient(); + if (!supabase) return repliesByParentId; - if (error) throw new Error(`[article-comments] 대댓글 조회 실패: ${error.message}`); + const { data, error } = await supabase + .from('article_comments') + .select('*') + .in('parent_id', parentIds) + .is('deleted_at', null) + .order('created_at', { ascending: true }); - return [parentId, (data ?? []) as ArticleCommentRow[]] as const; - }), - ); + if (error) throw new Error(`[article-comments] 대댓글 조회 실패: ${error.message}`); + + (data ?? []).forEach(reply => { + const typedReply = reply as ArticleCommentRow; + if (typedReply.parent_id && repliesByParentId[typedReply.parent_id]) { + repliesByParentId[typedReply.parent_id].push(typedReply); + } + }); - return Object.fromEntries(repliesByParentId); + return repliesByParentId; }; /** @@ -197,6 +205,8 @@ const readCachedArticleCommentThreads = async ( ]); cacheTag(ARTICLE_COMMENTS_CACHE_TAG, createArticleCommentsCacheTag(articleId), ...commentTags); + // UGC라 태그 무효화가 늦어도 짧은 TTL로 신선도를 유지함. + cacheLife({ expire: 3600, revalidate: 300, stale: 60 }); return threads; }; diff --git a/src/entities/guestbook/api/get-guestbook-threads.test.ts b/src/entities/guestbook/api/get-guestbook-threads.test.ts index bac8be26..310c58e3 100644 --- a/src/entities/guestbook/api/get-guestbook-threads.test.ts +++ b/src/entities/guestbook/api/get-guestbook-threads.test.ts @@ -7,6 +7,7 @@ import { hasSupabaseEnv } from '@/shared/lib/supabase/config'; import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); @@ -62,7 +63,7 @@ describe('getGuestbookThreads', () => { const replyQuery = { select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue({ data: [ @@ -107,7 +108,8 @@ describe('getGuestbookThreads', () => { expect(vi.mocked(unstable_cacheTag).mock.calls.flat()).toContain('guestbook:replies:parent-1'); expect(parentQuery.is).toHaveBeenCalledWith('parent_id', null); expect(parentQuery.is).not.toHaveBeenCalledWith('deleted_at', null); - expect(replyQuery.eq).toHaveBeenCalledWith('parent_id', 'parent-1'); + expect(replyQuery.in).toHaveBeenCalledWith('parent_id', ['parent-1']); + expect(replyQuery.is).toHaveBeenCalledWith('deleted_at', null); }); it('삭제된 원댓글은 답글이 없으면 목록에서 제외한다', async () => { @@ -136,7 +138,7 @@ describe('getGuestbookThreads', () => { const replyQuery = { select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue({ data: [], diff --git a/src/entities/guestbook/api/get-guestbook-threads.ts b/src/entities/guestbook/api/get-guestbook-threads.ts index 11e75810..a13b7380 100644 --- a/src/entities/guestbook/api/get-guestbook-threads.ts +++ b/src/entities/guestbook/api/get-guestbook-threads.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { createGuestbookEntryCacheTag, @@ -49,33 +49,40 @@ const fetchGuestbookParents = async ( }; /** - * 전달된 원댓글 id 목록에 대한 대댓글을 조회합니다. + * 전달된 원댓글 id 목록에 대한 대댓글을 단일 `.in()` 쿼리로 조회해 parent별로 그룹핑합니다. + * + * parent별 개별 조회(N+1) 대신 한 번의 쿼리로 읽고, 모든 parentId를 빈 배열로 초기화한 뒤 + * `created_at` 오름차순 정렬 결과를 순서대로 담아 parent 내부 순서를 보존합니다. */ const fetchRepliesByParentIds = async ( parentIds: string[], ): Promise> => { if (parentIds.length === 0) return {}; - const readRepliesByParentId = async (parentId: string): Promise => { - const supabase = createOptionalPublicServerSupabaseClient(); - if (!supabase) return []; + const repliesByParent: Record = Object.fromEntries( + parentIds.map(parentId => [parentId, []]), + ); - const { data, error } = await supabase - .from('guestbook_entries') - .select('*') - .eq('parent_id', parentId) - .is('deleted_at', null) - .order('created_at', { ascending: true }); + const supabase = createOptionalPublicServerSupabaseClient(); + if (!supabase) return repliesByParent; - if (error) throw new Error(`[guestbook] 대댓글 조회 실패: ${error.message}`); - return (data ?? []) as GuestbookEntryRow[]; - }; + const { data, error } = await supabase + .from('guestbook_entries') + .select('*') + .in('parent_id', parentIds) + .is('deleted_at', null) + .order('created_at', { ascending: true }); - const repliesByParent = await Promise.all( - parentIds.map(async parentId => [parentId, await readRepliesByParentId(parentId)] as const), - ); + if (error) throw new Error(`[guestbook] 대댓글 조회 실패: ${error.message}`); + + (data ?? []).forEach(reply => { + const typedReply = reply as GuestbookEntryRow; + if (typedReply.parent_id && repliesByParent[typedReply.parent_id]) { + repliesByParent[typedReply.parent_id].push(typedReply); + } + }); - return Object.fromEntries(repliesByParent); + return repliesByParent; }; /** @@ -140,6 +147,8 @@ const readCachedGuestbookThreads = async ( ]); cacheTag(GUESTBOOK_CACHE_TAG, ...guestbookTags); + // UGC라 태그 무효화가 늦어도 짧은 TTL로 신선도를 유지함. + cacheLife({ expire: 3600, revalidate: 300, stale: 60 }); return page; }; diff --git a/src/entities/pdf-file/api/get-pdf-file-availability.test.ts b/src/entities/pdf-file/api/get-pdf-file-availability.test.ts index 048e43f6..25171b73 100644 --- a/src/entities/pdf-file/api/get-pdf-file-availability.test.ts +++ b/src/entities/pdf-file/api/get-pdf-file-availability.test.ts @@ -7,6 +7,7 @@ import { getPdfFileAvailability } from '@/entities/pdf-file/api/get-pdf-file-ava import { resolveOptionalStorageReadSupabaseClient } from '@/shared/lib/supabase/storage-client'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); diff --git a/src/entities/pdf-file/api/get-pdf-file-availability.ts b/src/entities/pdf-file/api/get-pdf-file-availability.ts index 09be79b3..3d28e040 100644 --- a/src/entities/pdf-file/api/get-pdf-file-availability.ts +++ b/src/entities/pdf-file/api/get-pdf-file-availability.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { createPdfFileAvailabilityCacheTag, @@ -47,6 +47,8 @@ const readCachedPdfFileAvailability = async ({ const cacheKey = assetKey ?? kind ?? 'resume'; cacheTag(PDF_FILES_CACHE_TAG, createPdfFileAvailabilityCacheTag(cacheKey)); + // 스토리지 조회 부하를 줄이고 놓친 태그 무효화를 보완하기 위한 TTL 캐시입니다. + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); const storageConfig = assetKey ? getPdfFileAssetStorageConfig(assetKey) diff --git a/src/entities/pdf-file/api/get-pdf-file-content.test.ts b/src/entities/pdf-file/api/get-pdf-file-content.test.ts index 3fd9e7e7..104afc08 100644 --- a/src/entities/pdf-file/api/get-pdf-file-content.test.ts +++ b/src/entities/pdf-file/api/get-pdf-file-content.test.ts @@ -5,6 +5,7 @@ import { getPdfFileContent } from '@/entities/pdf-file/api/get-pdf-file-content' import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); diff --git a/src/entities/pdf-file/api/get-pdf-file-content.ts b/src/entities/pdf-file/api/get-pdf-file-content.ts index 8ac38c55..cd5718e7 100644 --- a/src/entities/pdf-file/api/get-pdf-file-content.ts +++ b/src/entities/pdf-file/api/get-pdf-file-content.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { createPdfFileContentCacheTag, @@ -31,6 +31,8 @@ const readCachedPdfFileContent = async ({ 'use cache'; cacheTag(PDF_FILES_CACHE_TAG, PDF_FILE_CONTENT_CACHE_TAG, createPdfFileContentCacheTag(kind)); + // 스토리지 조회 부하를 줄이고 놓친 태그 무효화를 보완하기 위한 TTL 캐시입니다. + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); const { tableName } = getPdfFileContentConfig(kind); const supabase = createOptionalPublicServerSupabaseClient(); diff --git a/src/entities/project/api/detail/get-project-detail-list.test.ts b/src/entities/project/api/detail/get-project-detail-list.test.ts index 3593d8b2..74df87cb 100644 --- a/src/entities/project/api/detail/get-project-detail-list.test.ts +++ b/src/entities/project/api/detail/get-project-detail-list.test.ts @@ -11,6 +11,7 @@ import { hasSupabaseEnv } from '@/shared/lib/supabase/config'; import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); diff --git a/src/entities/project/api/detail/get-project-detail-list.ts b/src/entities/project/api/detail/get-project-detail-list.ts index 876e89eb..6711a993 100644 --- a/src/entities/project/api/detail/get-project-detail-list.ts +++ b/src/entities/project/api/detail/get-project-detail-list.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { PROJECTS_CACHE_TAG } from '@/entities/project/model/cache-tags'; import type { ProjectArchivePage } from '@/entities/project/model/types'; @@ -237,6 +237,8 @@ const readCachedProjectDetailList = async (input: { 'use cache'; cacheTag(PROJECTS_CACHE_TAG); + // 예약 발행(publish_at) 글이 태그 무효화 없이도 TTL로 노출되도록 함. + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); return fetchProjectDetailListByLocaleFallback( input.normalizedLocale, diff --git a/src/entities/project/api/detail/get-project.test.ts b/src/entities/project/api/detail/get-project.test.ts index 54423c3a..d1aa52d7 100644 --- a/src/entities/project/api/detail/get-project.test.ts +++ b/src/entities/project/api/detail/get-project.test.ts @@ -33,6 +33,18 @@ const createProjectSlugLookupQuery = (id = 'funda-project') => ({ select: vi.fn().mockReturnThis(), }); +/** + * `fetchAvailableProjectLocales`가 사용하는 `project_translations` locale 조회 mock을 만듭니다. + */ +const createProjectTranslationLocalesQuery = () => ({ + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockResolvedValue({ + data: [], + error: null, + }), + select: vi.fn().mockReturnThis(), +}); + describe('getProject', () => { afterEach(() => { vi.clearAllMocks(); @@ -91,11 +103,14 @@ describe('getProject', () => { }), select: vi.fn().mockReturnThis(), }; - supabaseClient.from = vi - .fn() - .mockReturnValueOnce(projectSlugQuery) - .mockReturnValueOnce(projectTechStacksQuery) - .mockReturnValueOnce(techStacksQuery); + const projectLocalesQuery = createProjectTranslationLocalesQuery(); + supabaseClient.from = vi.fn((table: string) => { + if (table === 'projects') return projectSlugQuery; + if (table === 'project_translations') return projectLocalesQuery; + if (table === 'project_tech_stacks') return projectTechStacksQuery; + if (table === 'tech_stacks') return techStacksQuery; + throw new Error(`unexpected table: ${table}`); + }); vi.mocked(hasSupabaseEnv).mockReturnValue(true); vi.mocked(getOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); @@ -245,12 +260,15 @@ describe('getProject', () => { }, }), }; - supabaseClient.from = vi - .fn() - .mockReturnValueOnce(projectSlugQuery) - .mockReturnValueOnce(projectTranslationsQuery) - .mockReturnValueOnce(projectTechStacksQuery) - .mockReturnValueOnce(techStacksQuery); + // recovery(fetchProjectFromTranslationRows)와 availableLocales 조회가 모두 + // project_translations를 `.select().eq().in()`로 읽으므로 같은 query mock을 재사용합니다. + supabaseClient.from = vi.fn((table: string) => { + if (table === 'projects') return projectSlugQuery; + if (table === 'project_translations') return projectTranslationsQuery; + if (table === 'project_tech_stacks') return projectTechStacksQuery; + if (table === 'tech_stacks') return techStacksQuery; + throw new Error(`unexpected table: ${table}`); + }); vi.mocked(hasSupabaseEnv).mockReturnValue(true); vi.mocked(getOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); @@ -317,10 +335,13 @@ describe('getProject', () => { }), select: vi.fn().mockReturnThis(), }; - supabaseClient.from = vi - .fn() - .mockReturnValueOnce(projectSlugQuery) - .mockReturnValueOnce(projectTechStacksQuery); + const projectLocalesQuery = createProjectTranslationLocalesQuery(); + supabaseClient.from = vi.fn((table: string) => { + if (table === 'projects') return projectSlugQuery; + if (table === 'project_translations') return projectLocalesQuery; + if (table === 'project_tech_stacks') return projectTechStacksQuery; + throw new Error(`unexpected table: ${table}`); + }); vi.mocked(hasSupabaseEnv).mockReturnValue(true); vi.mocked(getOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); @@ -391,10 +412,13 @@ describe('getProject', () => { }), select: vi.fn().mockReturnThis(), }; - supabaseClient.from = vi - .fn() - .mockReturnValueOnce(projectSlugQuery) - .mockReturnValueOnce(projectTechStacksQuery); + const projectLocalesQuery = createProjectTranslationLocalesQuery(); + supabaseClient.from = vi.fn((table: string) => { + if (table === 'projects') return projectSlugQuery; + if (table === 'project_translations') return projectLocalesQuery; + if (table === 'project_tech_stacks') return projectTechStacksQuery; + throw new Error(`unexpected table: ${table}`); + }); vi.mocked(hasSupabaseEnv).mockReturnValue(true); vi.mocked(getOptionalPublicServerSupabaseClient).mockReturnValue(supabaseClient as never); diff --git a/src/entities/project/api/list/get-projects.test.ts b/src/entities/project/api/list/get-projects.test.ts index 4fca2941..b654ef55 100644 --- a/src/entities/project/api/list/get-projects.test.ts +++ b/src/entities/project/api/list/get-projects.test.ts @@ -7,6 +7,7 @@ import { hasSupabaseEnv } from '@/shared/lib/supabase/config'; import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); diff --git a/src/entities/project/api/list/get-projects.ts b/src/entities/project/api/list/get-projects.ts index 356d621d..c94ed384 100644 --- a/src/entities/project/api/list/get-projects.ts +++ b/src/entities/project/api/list/get-projects.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { PROJECTS_CACHE_TAG } from '@/entities/project/model/cache-tags'; import type { ProjectListItem, ProjectListPage } from '@/entities/project/model/types'; @@ -235,6 +235,8 @@ const readCachedProjects = async (input: { 'use cache'; cacheTag(PROJECTS_CACHE_TAG); + // 예약 발행(publish_at) 글이 태그 무효화 없이도 TTL로 노출되도록 함. + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); return fetchProjectsByLocaleFallback(input.normalizedLocale, input.cursor, input.pageSize); }; diff --git a/src/entities/tag/api/query-tags.test.ts b/src/entities/tag/api/query-tags.test.ts index fa56377d..a61874ff 100644 --- a/src/entities/tag/api/query-tags.test.ts +++ b/src/entities/tag/api/query-tags.test.ts @@ -5,6 +5,7 @@ import { getAllTags, getTagIdBySlug } from '@/entities/tag/api/query-tags'; import { createOptionalPublicServerSupabaseClient } from '@/shared/lib/supabase/public-server'; vi.mock('next/cache', () => ({ + unstable_cacheLife: vi.fn(), unstable_cacheTag: vi.fn(), })); diff --git a/src/entities/tag/api/query-tags.ts b/src/entities/tag/api/query-tags.ts index a65e4611..6f25b494 100644 --- a/src/entities/tag/api/query-tags.ts +++ b/src/entities/tag/api/query-tags.ts @@ -1,4 +1,4 @@ -import { unstable_cacheTag as cacheTag } from 'next/cache'; +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import type { TagOption } from '@/entities/tag/api/tag.types'; import { TAGS_CACHE_TAG } from '@/entities/tag/model/cache-tags'; @@ -203,6 +203,8 @@ const readCachedTagLabelMapBySlugs = async ({ 'use cache'; cacheTag(TAGS_CACHE_TAG); + // 예약 발행(publish_at) 글이 태그 무효화 없이도 TTL로 노출되도록 함. + cacheLife({ expire: 86400, revalidate: 3600, stale: 300 }); const supabase = createOptionalPublicServerSupabaseClient(); if (!supabase) return { data: new Map(), schemaMissing: false }; diff --git a/src/features/article-comment/model/article-comments-page-cache.test.ts b/src/features/article-comment/model/article-comments-page-cache.test.ts deleted file mode 100644 index d7fcd307..00000000 --- a/src/features/article-comment/model/article-comments-page-cache.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -// @vitest-environment node -import { - ARTICLE_COMMENTS_CACHE_TTL_MS, - cacheArticleCommentsPage, - DEFAULT_INITIAL_PAGE, - getCachedArticleCommentsPage, - invalidateArticleCommentsCache, - resetArticleCommentsPageCacheForTest, -} from '@/features/article-comment/model/article-comments-page-cache'; - -const pageData = { - ...DEFAULT_INITIAL_PAGE, - items: [ - { - article_id: 'article-1', - author_blog_url: null, - author_name: 'chaen', - content: 'cached comment', - created_at: '2026-03-17T00:00:00.000Z', - deleted_at: null, - id: 'comment-1', - parent_id: null, - reply_to_author_name: null, - reply_to_comment_id: null, - replies: [], - updated_at: '2026-03-17T00:00:00.000Z', - }, - ], -}; - -describe('article comments page cache', () => { - beforeEach(() => { - Object.assign(globalThis, { window: globalThis }); - resetArticleCommentsPageCacheForTest(); - vi.useRealTimers(); - }); - - it('articleId/page/sort 조건이 일치할 때, getCachedArticleCommentsPage는 저장된 데이터를 반환해야 한다', () => { - cacheArticleCommentsPage(pageData, 'article-1'); - - expect( - getCachedArticleCommentsPage({ - articleId: 'article-1', - page: 1, - sort: 'latest', - }), - ).toEqual(pageData); - }); - - it('다른 page나 sort 조건일 때, getCachedArticleCommentsPage는 null을 반환해야 한다', () => { - cacheArticleCommentsPage(pageData, 'article-1'); - - expect( - getCachedArticleCommentsPage({ - articleId: 'article-1', - page: 2, - sort: 'latest', - }), - ).toBeNull(); - expect( - getCachedArticleCommentsPage({ - articleId: 'article-1', - page: 1, - sort: 'oldest', - }), - ).toBeNull(); - }); - - it('TTL이 초과된 캐시 상태에서, getCachedArticleCommentsPage는 null을 반환하고 항목을 삭제해야 한다', () => { - vi.useFakeTimers(); - cacheArticleCommentsPage(pageData, 'article-1'); - - vi.advanceTimersByTime(ARTICLE_COMMENTS_CACHE_TTL_MS + 1); - - expect( - getCachedArticleCommentsPage({ - articleId: 'article-1', - page: 1, - sort: 'latest', - }), - ).toBeNull(); - }); - - it('TTL 이내의 캐시 상태에서, getCachedArticleCommentsPage는 저장된 데이터를 계속 반환해야 한다', () => { - vi.useFakeTimers(); - cacheArticleCommentsPage(pageData, 'article-1'); - - vi.advanceTimersByTime(ARTICLE_COMMENTS_CACHE_TTL_MS - 1); - - expect( - getCachedArticleCommentsPage({ - articleId: 'article-1', - page: 1, - sort: 'latest', - }), - ).toEqual(pageData); - }); - - it('특정 articleId로 invalidation을 호출할 때, 해당 article의 캐시는 모두 삭제되고 다른 article의 캐시는 유지되어야 한다', () => { - cacheArticleCommentsPage({ ...pageData, page: 1, sort: 'latest' }, 'article-1'); - cacheArticleCommentsPage({ ...pageData, page: 1, sort: 'oldest' }, 'article-1'); - cacheArticleCommentsPage({ ...pageData, page: 1, sort: 'latest' }, 'article-2'); - - invalidateArticleCommentsCache('article-1'); - - expect( - getCachedArticleCommentsPage({ articleId: 'article-1', page: 1, sort: 'latest' }), - ).toBeNull(); - expect( - getCachedArticleCommentsPage({ articleId: 'article-1', page: 1, sort: 'oldest' }), - ).toBeNull(); - expect( - getCachedArticleCommentsPage({ articleId: 'article-2', page: 1, sort: 'latest' }), - ).not.toBeNull(); - }); - - it('TTL 초과 항목 조회 후 새 데이터를 저장할 때, getCachedArticleCommentsPage는 새 데이터를 반환해야 한다', () => { - vi.useFakeTimers(); - cacheArticleCommentsPage(pageData, 'article-1'); - - vi.advanceTimersByTime(ARTICLE_COMMENTS_CACHE_TTL_MS + 1); - - // 만료 항목 조회 → null 반환 (내부 삭제) - expect( - getCachedArticleCommentsPage({ articleId: 'article-1', page: 1, sort: 'latest' }), - ).toBeNull(); - - // 새 데이터로 다시 저장하면 조회 가능해야 한다 - const updatedPageData = { ...pageData, totalCount: 5 }; - cacheArticleCommentsPage(updatedPageData, 'article-1'); - - expect( - getCachedArticleCommentsPage({ articleId: 'article-1', page: 1, sort: 'latest' }), - ).toEqual(updatedPageData); - }); -}); diff --git a/src/features/article-comment/model/article-comments-page-cache.ts b/src/features/article-comment/model/article-comments-page-cache.ts deleted file mode 100644 index b7d874c5..00000000 --- a/src/features/article-comment/model/article-comments-page-cache.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ArticleCommentPage, ArticleCommentsSort } from '@/entities/article/comment/model'; - -type CachedEntry = { - cachedAt: number; - data: ArticleCommentPage; -}; - -const articleCommentsPageCache = new Map(); - -export const DEFAULT_INITIAL_PAGE: ArticleCommentPage = { - items: [], - page: 1, - pageSize: 10, - sort: 'latest', - totalCount: 0, - totalPages: 0, -}; - -/** 브라우저 세션 메모리 캐시 유효 시간 (밀리초) */ -export const ARTICLE_COMMENTS_CACHE_TTL_MS = 60_000; - -/** - * 댓글 페이지 캐시 키를 생성합니다. - */ -const createArticleCommentsPageCacheKey = ({ - articleId, - page, - sort, -}: { - articleId: string; - page: number; - sort: ArticleCommentsSort; -}) => `${articleId}:${sort}:${page}`; - -/** - * 브라우저 세션 메모리에서 댓글 페이지 캐시를 조회합니다. - * - * TTL이 지난 항목은 null을 반환하고 캐시에서 제거합니다. - */ -export const getCachedArticleCommentsPage = ({ - articleId, - page, - sort, -}: { - articleId: string; - page: number; - sort: ArticleCommentsSort; -}) => { - if (typeof window === 'undefined') return null; - - const key = createArticleCommentsPageCacheKey({ articleId, page, sort }); - const entry = articleCommentsPageCache.get(key); - - if (!entry) return null; - - if (Date.now() - entry.cachedAt > ARTICLE_COMMENTS_CACHE_TTL_MS) { - articleCommentsPageCache.delete(key); - return null; - } - - return entry.data; -}; - -/** - * 브라우저 세션 메모리에 댓글 페이지를 저장합니다. - */ -export const cacheArticleCommentsPage = (pageData: ArticleCommentPage, articleId: string) => { - if (typeof window === 'undefined') return; - - articleCommentsPageCache.set( - createArticleCommentsPageCacheKey({ - articleId, - page: pageData.page, - sort: pageData.sort, - }), - { - cachedAt: Date.now(), - data: pageData, - }, - ); -}; - -/** - * 특정 article의 모든 댓글 페이지 캐시를 삭제합니다. - * - * 댓글 작성/수정/삭제 성공 직후 호출해 stale 데이터가 재사용되지 않도록 합니다. - */ -export const invalidateArticleCommentsCache = (articleId: string) => { - if (typeof window === 'undefined') return; - - const prefix = `${articleId}:`; - - for (const key of Array.from(articleCommentsPageCache.keys())) { - if (key.startsWith(prefix)) articleCommentsPageCache.delete(key); - } -}; - -/** - * 테스트에서 댓글 페이지 메모리 캐시를 초기화합니다. - */ -export const resetArticleCommentsPageCacheForTest = () => { - articleCommentsPageCache.clear(); -}; diff --git a/src/features/article-comment/model/use-article-comments-page.ts b/src/features/article-comment/model/use-article-comments-page.ts new file mode 100644 index 00000000..409bef61 --- /dev/null +++ b/src/features/article-comment/model/use-article-comments-page.ts @@ -0,0 +1,68 @@ +'use client'; + +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +import type { ArticleCommentPage, ArticleCommentsSort } from '@/entities/article/comment/model'; +import { getArticleCommentsPageAction } from '@/features/article-comment/api/get-article-comments-page'; +import { articleComments } from '@/shared/lib/query/query-keys'; + +/** + * 댓글 페이지 응답을 신선한 상태로 간주하는 시간(밀리초)입니다. + * 기존 브라우저 Map 캐시의 60초 TTL을 React Query `staleTime`으로 재현합니다. + */ +const ARTICLE_COMMENTS_PAGE_STALE_TIME_MS = 60_000; + +type UseArticleCommentsPageParams = { + articleId: string; + initialData?: ArticleCommentPage; + locale: string; + page: number; + sort: ArticleCommentsSort; +}; + +/** + * 특정 글·정렬·페이지의 댓글 목록을 React Query로 조회하는 훅입니다. + * + * 페이지/정렬 상태는 소비 위젯의 로컬 `useState`가 소유하고, 그 값이 이 훅의 + * 쿼리 키(`articleComments.page`)를 구동합니다. 조회 액션이 실패 + * (`ok === false` 또는 `data` 없음)하면 `errorMessage`를 담은 `Error`를 던져 + * React Query의 `error` 채널로 흘려보냅니다. + * + * `placeholderData: keepPreviousData`로 페이지/정렬 전환 중에도 직전 페이지를 + * 유지해 스켈레톤 깜빡임을 없앱니다. 서버가 넘겨준 첫 페이지가 있으면 + * 해당 키에 한해 `initialData`로 시드해 마운트 직후 추가 조회 없이 렌더합니다. + * + * @param params 대상 글 id, locale, 조회할 페이지/정렬, 선택적 초기 페이지 데이터입니다. + * @returns React Query의 `useQuery` 결과 객체입니다. + */ +export const useArticleCommentsPage = ({ + articleId, + initialData, + locale, + page, + sort, +}: UseArticleCommentsPageParams) => { + const hasMatchingInitialData = + initialData !== undefined && initialData.page === page && initialData.sort === sort; + + return useQuery({ + initialData: hasMatchingInitialData ? initialData : undefined, + placeholderData: keepPreviousData, + queryFn: async () => { + const result = await getArticleCommentsPageAction({ + articleId, + locale, + page, + sort, + }); + + if (!result.ok || !result.data) { + throw new Error(result.errorMessage ?? 'article-comments-load-failed'); + } + + return result.data; + }, + queryKey: articleComments.page(articleId, locale, sort, page), + staleTime: ARTICLE_COMMENTS_PAGE_STALE_TIME_MS, + }); +}; diff --git a/src/features/article-tag-filter/ui/deferred-article-tag-filter-list.test.tsx b/src/features/article-tag-filter/ui/deferred-article-tag-filter-list.test.tsx index 97f906ca..079c2a4a 100644 --- a/src/features/article-tag-filter/ui/deferred-article-tag-filter-list.test.tsx +++ b/src/features/article-tag-filter/ui/deferred-article-tag-filter-list.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { vi } from 'vitest'; import { DeferredArticleTagFilterList } from '@/features/article-tag-filter/ui/deferred-article-tag-filter-list'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; vi.mock('@/i18n/navigation', () => ({ Link: ({ children, href, ...props }: React.AnchorHTMLAttributes) => ( @@ -58,6 +59,7 @@ describe('DeferredArticleTagFilterList', () => { locale="ko" title="인기 태그" />, + { wrapper: createQueryClientWrapper() }, ); }); @@ -93,6 +95,7 @@ describe('DeferredArticleTagFilterList', () => { locale="ko" title="인기 태그" />, + { wrapper: createQueryClientWrapper() }, ); await waitFor(() => { @@ -119,6 +122,7 @@ describe('DeferredArticleTagFilterList', () => { source="all" title="전체 태그" />, + { wrapper: createQueryClientWrapper() }, ); await waitFor(() => { diff --git a/src/features/article-tag-filter/ui/deferred-article-tag-filter-list.tsx b/src/features/article-tag-filter/ui/deferred-article-tag-filter-list.tsx index a33b5996..587632cc 100644 --- a/src/features/article-tag-filter/ui/deferred-article-tag-filter-list.tsx +++ b/src/features/article-tag-filter/ui/deferred-article-tag-filter-list.tsx @@ -1,6 +1,7 @@ 'use client'; -import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import React, { useCallback } from 'react'; import type { LocalizedArticleTagStat } from '@/entities/article/model/types'; import type { TagOption } from '@/entities/tag/api/tag.types'; @@ -8,6 +9,12 @@ import { type ArticleTagFilterItem, ArticleTagFilterList, } from '@/features/article-tag-filter/ui/article-tag-filter-list'; +import type { AppLocale } from '@/i18n/routing'; +import { articles, tags } from '@/shared/lib/query/query-keys'; + +type TagSourceResponse = LocalizedArticleTagStat[] | TagOption[]; + +const EMPTY_TAG_FILTER_ITEMS: ArticleTagFilterItem[] = []; type TagFilterHrefMode = 'query' | 'tag-page'; type TagFilterSource = 'all' | 'popular'; @@ -29,7 +36,7 @@ type DeferredArticleTagFilterListProps = { */ const mapTagSourceItems = ( source: TagFilterSource, - data: LocalizedArticleTagStat[] | TagOption[], + data: TagSourceResponse, ): ArticleTagFilterItem[] => { if (source === 'all') { return (data as TagOption[]).map(item => ({ @@ -61,57 +68,33 @@ export const DeferredArticleTagFilterList = ({ title, }: DeferredArticleTagFilterListProps) => { const resolvedHrefMode = hrefMode ?? (source === 'all' ? 'tag-page' : 'query'); - const [items, setItems] = React.useState([]); - const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('idle'); - - React.useEffect(() => { - const abortController = new AbortController(); - - const loadItems = async () => { - setStatus('loading'); - - try { - const searchParams = new URLSearchParams({ - locale, - }); - const response = await fetch( - `${ - source === 'all' ? '/api/tags' : '/api/article-popular-tags' - }?${searchParams.toString()}`, - { - method: 'GET', - signal: abortController.signal, - }, - ); - - if (!response.ok) { - throw new Error(`Failed to load article ${source} tags: ${response.status}`); - } - - const data = (await response.json()) as LocalizedArticleTagStat[] | TagOption[]; - setItems(mapTagSourceItems(source, data)); - setStatus('ready'); - } catch (error) { - if (abortController.signal.aborted) { - return; - } - - console.error('[articles] deferred tags failed', { - error, - locale, - source, - }); - setItems([]); - setStatus('error'); + const selectTagFilterItems = useCallback( + (data: TagSourceResponse) => mapTagSourceItems(source, data), + [source], + ); + const { data, isPending } = useQuery({ + queryFn: async ({ signal }): Promise => { + const searchParams = new URLSearchParams({ + locale, + }); + const response = await fetch( + `${source === 'all' ? '/api/tags' : '/api/article-popular-tags'}?${searchParams.toString()}`, + { + method: 'GET', + signal, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to load article ${source} tags: ${response.status}`); } - }; - - void loadItems(); - return () => { - abortController.abort(); - }; - }, [locale, source]); + return (await response.json()) as TagSourceResponse; + }, + queryKey: + source === 'all' ? tags.all(locale as AppLocale) : articles.popularTags(locale as AppLocale), + select: selectTagFilterItems, + }); return ( ); diff --git a/src/features/browse-articles/model/use-browse-articles.test.ts b/src/features/browse-articles/model/use-browse-articles.test.ts new file mode 100644 index 00000000..350f0d79 --- /dev/null +++ b/src/features/browse-articles/model/use-browse-articles.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; + +import type { ArticleListItem } from '@/entities/article/model/types'; +import { useBrowseArticles } from '@/features/browse-articles/model/use-browse-articles'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; + +/** + * 테스트용 아티클 목록 항목을 생성합니다. + * + * @param id 항목 id입니다. + * @returns 최소 필드만 채운 아티클 목록 항목입니다. + */ +const createArticleListItem = (id: string): ArticleListItem => ({ + description: `${id} 설명`, + id, + publish_at: '2026-03-08T00:00:00.000Z', + slug: id, + thumbnail_url: null, + title: `${id} 제목`, +}); + +/** + * `fetch` 응답을 흉내 내는 최소 객체를 만듭니다. + * + * @param ok 응답 성공 여부입니다. + * @param body JSON 본문입니다. + * @returns `fetch`가 반환하는 Response 유사 객체입니다. + */ +const createFetchResponse = (ok: boolean, body: unknown) => + ({ + json: async () => body, + ok, + }) as Response; + +describe('useBrowseArticles', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('서버 시드가 주어지면, useBrowseArticles는 추가 fetch 없이 시드 목록을 렌더한다', () => { + const fetchSpy = vi.spyOn(window, 'fetch'); + + const { result } = renderHook( + () => + useBrowseArticles({ + activeTag: '', + initialCursor: null, + initialItems: [createArticleListItem('article-1')], + locale: 'ko', + query: '', + }), + { wrapper: createQueryClientWrapper() }, + ); + + expect(result.current.items).toHaveLength(1); + expect(result.current.items[0]?.id).toBe('article-1'); + expect(result.current.hasMore).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('다음 커서가 있을 때 loadMore를 호출하면, useBrowseArticles는 다음 페이지를 한 번만 조회해 이어붙인다', async () => { + const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue( + createFetchResponse(true, { + items: [createArticleListItem('article-2')], + nextCursor: null, + }), + ); + + const { result } = renderHook( + () => + useBrowseArticles({ + activeTag: '', + initialCursor: 'cursor-1', + initialItems: [createArticleListItem('article-1')], + locale: 'ko', + query: '', + }), + { wrapper: createQueryClientWrapper() }, + ); + + expect(result.current.hasMore).toBe(true); + + await act(async () => { + await result.current.loadMore(); + }); + + await waitFor(() => { + expect(result.current.items.map(item => item.id)).toEqual(['article-1', 'article-2']); + }); + expect(result.current.hasMore).toBe(false); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('다음 페이지 조회가 실패하면, useBrowseArticles는 errorMessage를 노출하고 기존 목록을 유지한다', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue( + createFetchResponse(false, { error: 'articleFeed.loadFailed' }), + ); + + const { result } = renderHook( + () => + useBrowseArticles({ + activeTag: '', + initialCursor: 'cursor-1', + initialItems: [createArticleListItem('article-1')], + locale: 'ko', + query: '', + }), + { wrapper: createQueryClientWrapper() }, + ); + + await act(async () => { + await result.current.loadMore(); + }); + + await waitFor(() => { + expect(result.current.errorMessage).toBe('articleFeed.loadFailed'); + }); + expect(result.current.items.map(item => item.id)).toEqual(['article-1']); + expect(result.current.hasMore).toBe(true); + }); + + it('쿼리 키가 바뀌면(locale 변경), useBrowseArticles는 새 서버 시드로 목록을 초기화한다', () => { + const fetchSpy = vi.spyOn(window, 'fetch'); + + const { result, rerender } = renderHook( + (props: { + activeTag: string; + initialCursor: string | null; + initialItems: ArticleListItem[]; + locale: string; + query: string; + }) => useBrowseArticles(props), + { + initialProps: { + activeTag: '', + initialCursor: null, + initialItems: [createArticleListItem('article-ko')], + locale: 'ko', + query: '', + }, + wrapper: createQueryClientWrapper(), + }, + ); + + expect(result.current.items.map(item => item.id)).toEqual(['article-ko']); + + rerender({ + activeTag: '', + initialCursor: null, + initialItems: [createArticleListItem('article-en')], + locale: 'en', + query: '', + }); + + expect(result.current.items.map(item => item.id)).toEqual(['article-en']); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/browse-articles/model/use-browse-articles.ts b/src/features/browse-articles/model/use-browse-articles.ts index fb4233e9..bf4214aa 100644 --- a/src/features/browse-articles/model/use-browse-articles.ts +++ b/src/features/browse-articles/model/use-browse-articles.ts @@ -1,10 +1,13 @@ 'use client'; -import { useCallback } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; import type { ArticleListItem } from '@/entities/article/model/types'; +import type { AppLocale } from '@/i18n/routing'; import { dedupeById } from '@/shared/lib/array/dedupe-by-id'; -import { useCursorPaginationFeed } from '@/shared/lib/react/use-cursor-pagination-feed'; +import { getErrorMessage } from '@/shared/lib/error/get-error-message'; +import { articles } from '@/shared/lib/query/query-keys'; type UseBrowseArticlesOptions = { activeTag: string; @@ -14,14 +17,29 @@ type UseBrowseArticlesOptions = { query: string; }; +type ArticlesFeedPage = { + items: ArticleListItem[]; + nextCursor: string | null; + totalCount?: number | null; +}; + const ARTICLE_FEED_LOAD_ERROR_CODE = 'articleFeed.loadFailed'; +const ARTICLE_FEED_PAGE_LIMIT = 10; /** * 아티클 페이지 응답을 JSON으로 읽고 실패 시 메시지를 정규화합니다. + * + * @param requestUrl 조회할 `/api/articles` 요청 URL입니다. + * @param signal React Query가 전달하는 요청 취소 시그널입니다. + * @returns 아티클 페이지 응답(항목·다음 커서)입니다. */ -const readArticlesFeedPage = async (requestUrl: string) => { +const readArticlesFeedPage = async ( + requestUrl: string, + signal?: AbortSignal, +): Promise => { const response = await fetch(requestUrl, { method: 'GET', + signal, }); if (!response.ok) { @@ -30,15 +48,16 @@ const readArticlesFeedPage = async (requestUrl: string) => { throw new Error(errorPayload?.error ?? ARTICLE_FEED_LOAD_ERROR_CODE); } - return response.json() as Promise<{ - items: ArticleListItem[]; - nextCursor: string | null; - totalCount?: number | null; - }>; + return response.json() as Promise; }; /** * 아티클 목록 무한 스크롤 상태를 관리합니다. + * + * 서버가 넘겨준 첫 페이지는 `initialData`로 시드하고 `staleTime: Infinity`로 + * 자동 refetch를 막아, 서버 렌더 결과를 그대로 재사용합니다. locale/검색어/태그가 + * 바뀌면 쿼리 키가 갈라지며 새 시드로 초기화됩니다. 반환 계약은 기존 + * `useCursorPaginationFeed`와 동일하므로 소비 컴포넌트는 변경되지 않습니다. */ export const useBrowseArticles = ({ activeTag, @@ -47,53 +66,47 @@ export const useBrowseArticles = ({ locale, query, }: UseBrowseArticlesOptions) => { - const loadPage = useCallback( - async ({ - cursor, - limit, - locale: nextLocale, - queryParams, - }: { - cursor: string; - limit: number; - locale: string; - queryParams?: Record; - }) => { + const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + getNextPageParam: lastPage => lastPage.nextCursor, + initialData: { + pageParams: [null], + pages: [{ items: initialItems, nextCursor: initialCursor }], + }, + initialPageParam: initialCursor, + queryFn: async ({ pageParam, signal }) => { const searchParams = new URLSearchParams({ - cursor, - limit: String(limit), - locale: nextLocale, + cursor: pageParam ?? '', + limit: String(ARTICLE_FEED_PAGE_LIMIT), + locale, }); - if (queryParams?.q) { - searchParams.set('q', queryParams.q); + if (query) { + searchParams.set('q', query); } - if (queryParams?.tag) { - searchParams.set('tag', queryParams.tag); + if (activeTag) { + searchParams.set('tag', activeTag); } - const result = await readArticlesFeedPage(`/api/articles?${searchParams.toString()}`); - - return { - items: result.items, - nextCursor: result.nextCursor, - totalCount: result.totalCount, - }; - }, - [], - ); - - return useCursorPaginationFeed({ - initialCursor, - initialItems, - locale, - loadPage, - mergeItems: (previousItems, incomingItems) => dedupeById([...previousItems, ...incomingItems]), - queryParams: { - q: query, - tag: activeTag, + return readArticlesFeedPage(`/api/articles?${searchParams.toString()}`, signal); }, - resetKey: `${locale}::${query}::${activeTag}`, + queryKey: articles.feed({ locale: locale as AppLocale, q: query, tag: activeTag }), + staleTime: Infinity, }); + + const items = useMemo(() => dedupeById((data?.pages ?? []).flatMap(page => page.items)), [data]); + + const loadMore = useCallback(async () => { + if (!hasNextPage || isFetchingNextPage) return; + + await fetchNextPage(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + return { + errorMessage: error ? getErrorMessage(error) : null, + hasMore: hasNextPage, + isLoadingMore: isFetchingNextPage, + items, + loadMore, + }; }; diff --git a/src/features/browse-projects/model/use-browse-projects.ts b/src/features/browse-projects/model/use-browse-projects.ts index de328ed4..acfe09f2 100644 --- a/src/features/browse-projects/model/use-browse-projects.ts +++ b/src/features/browse-projects/model/use-browse-projects.ts @@ -1,10 +1,13 @@ 'use client'; -import { useCallback } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; import type { ProjectListItem } from '@/entities/project/model/types'; +import type { AppLocale } from '@/i18n/routing'; import { dedupeById } from '@/shared/lib/array/dedupe-by-id'; -import { useCursorPaginationFeed } from '@/shared/lib/react/use-cursor-pagination-feed'; +import { getErrorMessage } from '@/shared/lib/error/get-error-message'; +import { projects } from '@/shared/lib/query/query-keys'; type UseBrowseProjectsOptions = { initialCursor: string | null; @@ -12,14 +15,28 @@ type UseBrowseProjectsOptions = { locale: string; }; +type ProjectsFeedPage = { + items: ProjectListItem[]; + nextCursor: string | null; +}; + const PROJECT_FEED_LOAD_ERROR_CODE = 'projectFeed.loadFailed'; +const PROJECT_FEED_PAGE_LIMIT = 10; /** * 프로젝트 페이지 응답을 JSON으로 읽고 실패 시 메시지를 정규화합니다. + * + * @param requestUrl 조회할 `/api/projects` 요청 URL입니다. + * @param signal React Query가 전달하는 요청 취소 시그널입니다. + * @returns 프로젝트 페이지 응답(항목·다음 커서)입니다. */ -const readProjectsFeedPage = async (requestUrl: string) => { +const readProjectsFeedPage = async ( + requestUrl: string, + signal?: AbortSignal, +): Promise => { const response = await fetch(requestUrl, { method: 'GET', + signal, }); if (!response.ok) { @@ -28,52 +45,55 @@ const readProjectsFeedPage = async (requestUrl: string) => { throw new Error(errorPayload?.error ?? PROJECT_FEED_LOAD_ERROR_CODE); } - return response.json() as Promise<{ - items: ProjectListItem[]; - nextCursor: string | null; - }>; + return response.json() as Promise; }; /** * 프로젝트 목록 무한 스크롤 상태를 관리합니다. + * + * 서버가 넘겨준 첫 페이지는 `initialData`로 시드하고 `staleTime: Infinity`로 + * 자동 refetch를 막아, 서버 렌더 결과를 그대로 재사용합니다. locale이 바뀌면 + * 쿼리 키가 갈라지며 새 시드로 초기화됩니다. 반환 계약은 기존 + * `useCursorPaginationFeed`와 동일하므로 소비 컴포넌트는 변경되지 않습니다. */ export const useBrowseProjects = ({ initialCursor, initialItems, locale, }: UseBrowseProjectsOptions) => { - const loadPage = useCallback( - async ({ - cursor, - limit, - locale: nextLocale, - }: { - cursor: string; - limit: number; - locale: string; - }) => { + const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + getNextPageParam: lastPage => lastPage.nextCursor, + initialData: { + pageParams: [null], + pages: [{ items: initialItems, nextCursor: initialCursor }], + }, + initialPageParam: initialCursor, + queryFn: async ({ pageParam, signal }) => { const searchParams = new URLSearchParams({ - cursor, - limit: String(limit), - locale: nextLocale, + cursor: pageParam ?? '', + limit: String(PROJECT_FEED_PAGE_LIMIT), + locale, }); - const result = await readProjectsFeedPage(`/api/projects?${searchParams.toString()}`); - - return { - items: result.items, - nextCursor: result.nextCursor, - }; + return readProjectsFeedPage(`/api/projects?${searchParams.toString()}`, signal); }, - [], - ); - - return useCursorPaginationFeed({ - initialCursor, - initialItems, - locale, - loadPage, - mergeItems: (previousItems, incomingItems) => dedupeById([...previousItems, ...incomingItems]), - resetKey: locale, + queryKey: projects.feed(locale as AppLocale), + staleTime: Infinity, }); + + const items = useMemo(() => dedupeById((data?.pages ?? []).flatMap(page => page.items)), [data]); + + const loadMore = useCallback(async () => { + if (!hasNextPage || isFetchingNextPage) return; + + await fetchNextPage(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + return { + errorMessage: error ? getErrorMessage(error) : null, + hasMore: hasNextPage, + isLoadingMore: isFetchingNextPage, + items, + loadMore, + }; }; diff --git a/src/features/edit-markdown-adapter/index.ts b/src/features/edit-markdown-adapter/index.ts index 668edc50..9b024d80 100644 --- a/src/features/edit-markdown-adapter/index.ts +++ b/src/features/edit-markdown-adapter/index.ts @@ -1,5 +1,8 @@ export { uploadEditorFileAdapter } from '@/features/edit-markdown-adapter/api/upload-editor-file-adapter'; export { uploadEditorImageAdapter } from '@/features/edit-markdown-adapter/api/upload-editor-image-adapter'; export { uploadEditorVideoAdapter } from '@/features/edit-markdown-adapter/api/upload-editor-video-adapter'; -export { fetchLinkPreviewMetaAdapter } from '@/features/edit-markdown-adapter/model/fetch-link-preview-meta'; +export { + createCachedFetchLinkPreviewMeta, + fetchLinkPreviewMetaAdapter, +} from '@/features/edit-markdown-adapter/model/fetch-link-preview-meta'; export { resolveMarkdownAttachmentHref } from '@/features/edit-markdown-adapter/model/resolve-markdown-attachment-href'; diff --git a/src/features/edit-markdown-adapter/model/fetch-link-preview-meta.ts b/src/features/edit-markdown-adapter/model/fetch-link-preview-meta.ts index 300240fb..26632bb1 100644 --- a/src/features/edit-markdown-adapter/model/fetch-link-preview-meta.ts +++ b/src/features/edit-markdown-adapter/model/fetch-link-preview-meta.ts @@ -1,5 +1,15 @@ +import type { QueryClient } from '@tanstack/react-query'; + import type { FetchLinkPreviewMeta } from '@/entities/editor-core'; import type { LinkEmbedData } from '@/shared/lib/markdown/link-embed'; +import { og } from '@/shared/lib/query/query-keys'; + +/** + * OG 미리보기 캐시 유효 시간(24시간)입니다. `/api/og` 라우트의 `max-age=86400`을 + * 클라이언트 캐시에서 그대로 반영해, 같은 URL을 반복 조회할 때 네트워크 요청을 + * 줄입니다. + */ +const OG_PREVIEW_STALE_TIME = 24 * 60 * 60_000; /** * 현재 앱의 `/api/og` endpoint를 이용해 링크 preview 메타데이터를 조회합니다. @@ -20,3 +30,25 @@ export const fetchLinkPreviewMetaAdapter: FetchLinkPreviewMeta = async (url, sig return (await response.json()) as LinkEmbedData; }; + +/** + * 기본 OG 어댑터를 React Query 캐시(`fetchQuery`)로 감싼 fetcher를 만듭니다. + * + * 같은 URL을 여러 링크 카드가 동시에 조회해도 `og.preview(url)` 키로 dedupe되고, + * `staleTime`(24시간) 동안 결과를 재사용해 중복 네트워크 요청을 없앱니다. 원시 + * 어댑터(`fetchLinkPreviewMetaAdapter`)는 테스트/주입용으로 그대로 export를 유지합니다. + * + * @param queryClient 캐시를 관리할 QueryClient입니다. + * @returns 캐시를 경유하는 link preview fetcher입니다. + */ +export const createCachedFetchLinkPreviewMeta = + (queryClient: QueryClient): FetchLinkPreviewMeta => + url => + queryClient.fetchQuery({ + // fetchQuery는 observer 없이 곧바로 inactive가 되므로, 기본 gcTime(5분)이면 + // staleTime(24시간)보다 먼저 캐시가 수거된다. gcTime을 함께 맞춰 유지한다. + gcTime: OG_PREVIEW_STALE_TIME, + queryFn: ({ signal }) => fetchLinkPreviewMetaAdapter(url, signal), + queryKey: og.preview(url), + staleTime: OG_PREVIEW_STALE_TIME, + }); diff --git a/src/shared/lib/query/create-query-client.ts b/src/shared/lib/query/create-query-client.ts new file mode 100644 index 00000000..354abf78 --- /dev/null +++ b/src/shared/lib/query/create-query-client.ts @@ -0,0 +1,48 @@ +import { isServer, QueryClient } from '@tanstack/react-query'; + +/** + * 기본 옵션이 적용된 새 QueryClient 인스턴스를 생성합니다. + * + * `staleTime`은 라우트 핸들러의 `s-maxage=60`과 기준을 맞춰 60초로 두고, + * `gcTime`은 5분, 실패 시 재시도는 1회, 창 포커스 복귀 시 자동 refetch는 + * 비활성화합니다. 서버/브라우저 어느 쪽에서도 동일한 기본값을 쓰도록 + * `getQueryClient()`가 이 함수만 호출해 인스턴스를 만듭니다. + * + * @returns 기본 옵션이 설정된 새 QueryClient 인스턴스입니다. + */ +export const makeQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + retry: 1, + staleTime: 60_000, + }, + }, + }); + +/** + * 브라우저에서 재사용할 QueryClient 싱글턴입니다. + * 모듈이 다시 로드되지 않는 한(예: 클라이언트 라우팅) 최초 생성된 인스턴스를 유지합니다. + */ +let browserQueryClient: QueryClient | undefined; + +/** + * 실행 환경에 맞는 QueryClient 인스턴스를 반환합니다. + * + * 서버에서는 요청마다 새 QueryClient를 생성해 서로 다른 요청/사용자 간에 + * 캐시가 새어나가지 않도록 하고, 브라우저에서는 모듈 스코프의 싱글턴을 + * 재사용해 리렌더·라우팅 사이에도 동일한 캐시를 유지합니다. + * + * @returns 현재 실행 환경(서버 또는 브라우저)에 맞는 QueryClient 인스턴스입니다. + */ +export const getQueryClient = () => { + if (isServer) { + return makeQueryClient(); + } + + browserQueryClient ??= makeQueryClient(); + + return browserQueryClient; +}; diff --git a/src/shared/lib/query/query-keys.test.ts b/src/shared/lib/query/query-keys.test.ts new file mode 100644 index 00000000..df407af9 --- /dev/null +++ b/src/shared/lib/query/query-keys.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment node + +import { + articleComments, + articles, + detailArchive, + editor, + og, + pdf, + projects, + tags, +} from '@/shared/lib/query/query-keys'; + +describe('articles 쿼리 키', () => { + it('동일한 locale/검색어/태그 조합이면 호출마다 동일한 키를 반환한다', () => { + const first = articles.feed({ locale: 'ko', q: 'react', tag: 'frontend' }); + const second = articles.feed({ locale: 'ko', q: 'react', tag: 'frontend' }); + + expect(first).toEqual(second); + }); + + it('검색어 앞뒤 공백만 다르면 정규화되어 동일한 키를 반환한다', () => { + const withoutSpaces = articles.feed({ locale: 'ko', q: 'react', tag: null }); + const withSpaces = articles.feed({ locale: 'ko', q: ' react ', tag: null }); + + expect(withSpaces).toEqual(withoutSpaces); + }); + + it('태그 대소문자·공백만 다르면 정규화되어 동일한 키를 반환한다', () => { + const lowerCaseTag = articles.feed({ locale: 'ko', q: null, tag: 'frontend' }); + const mixedCaseTag = articles.feed({ locale: 'ko', q: null, tag: ' FrontEnd ' }); + + expect(mixedCaseTag).toEqual(lowerCaseTag); + }); + + it('검색어가 있으면 get-articles.ts와 동일하게 태그를 무시해 태그 값과 무관하게 동일한 키를 반환한다', () => { + const withTagA = articles.feed({ locale: 'en', q: 'hello', tag: 'tag-a' }); + const withTagB = articles.feed({ locale: 'en', q: 'hello', tag: 'tag-b' }); + + expect(withTagA).toEqual(['articles', 'feed', { locale: 'en', q: 'hello', tag: '' }]); + expect(withTagA).toEqual(withTagB); + }); + + it('locale이 다르면 서로 다른 키를 반환한다', () => { + const koKey = articles.feed({ locale: 'ko', q: null, tag: null }); + const enKey = articles.feed({ locale: 'en', q: null, tag: null }); + + expect(koKey).not.toEqual(enKey); + }); + + it('popularTags 키는 articles 네임스페이스 접두사를 포함한다', () => { + expect(articles.popularTags('ko')).toEqual(['articles', 'popularTags', 'ko']); + }); +}); + +describe('articleComments 쿼리 키', () => { + it('page 키는 scope 키를 접두사로 포함해 scope로 무효화하면 모든 페이지가 함께 걸린다', () => { + const scopeKey = articleComments.scope('article-1'); + const pageKey = articleComments.page('article-1', 'ko', 'latest', 2); + + expect(pageKey.slice(0, scopeKey.length)).toEqual(scopeKey); + }); + + it('articleId가 다르면 scope 키가 달라 서로 다른 글의 댓글 캐시가 섞이지 않는다', () => { + const scopeA = articleComments.scope('article-a'); + const scopeB = articleComments.scope('article-b'); + + expect(scopeA).not.toEqual(scopeB); + }); + + it('locale, 정렬 또는 페이지가 다르면 서로 다른 키를 반환한다', () => { + const latestPage1 = articleComments.page('article-1', 'ko', 'latest', 1); + const oldestPage1 = articleComments.page('article-1', 'ko', 'oldest', 1); + const latestPage2 = articleComments.page('article-1', 'ko', 'latest', 2); + const latestPage1En = articleComments.page('article-1', 'en', 'latest', 1); + + expect(latestPage1).not.toEqual(oldestPage1); + expect(latestPage1).not.toEqual(latestPage2); + expect(latestPage1).not.toEqual(latestPage1En); + }); +}); + +describe('projects 쿼리 키', () => { + it('feed와 preview는 서로 다른 하위 키를 가진다', () => { + expect(projects.feed('ko')).toEqual(['projects', 'feed', 'ko']); + expect(projects.preview('ko', 3)).toEqual(['projects', 'preview', 'ko', 3]); + }); + + it('limit이 다르면 preview 키가 달라진다', () => { + expect(projects.preview('ko', 3)).not.toEqual(projects.preview('ko', 5)); + }); +}); + +describe('tags 쿼리 키', () => { + it('locale별로 서로 다른 키를 반환한다', () => { + expect(tags.all('ko')).not.toEqual(tags.all('en')); + }); +}); + +describe('pdf 쿼리 키', () => { + it('options 키는 all 접두사를 포함해 업로드 후 prefix invalidate가 가능하다', () => { + const optionsKey = pdf.options('resume', 'resume-page'); + + expect(optionsKey.slice(0, pdf.all.length)).toEqual(pdf.all); + }); + + it('kind 또는 source가 다르면 서로 다른 options 키를 반환한다', () => { + const resumeOnResumePage = pdf.options('resume', 'resume-page'); + const portfolioOnResumePage = pdf.options('portfolio', 'resume-page'); + const resumeOnProjectPage = pdf.options('resume', 'project-page'); + + expect(resumeOnResumePage).not.toEqual(portfolioOnResumePage); + expect(resumeOnResumePage).not.toEqual(resumeOnProjectPage); + }); + + it('adminAvailability는 파라미터 없이 고정된 키를 갖는다', () => { + expect(pdf.adminAvailability).toEqual(['pdf', 'adminAvailability']); + }); +}); + +describe('detailArchive 쿼리 키', () => { + it('domain이 다르면 서로 다른 키를 반환해 글/프로젝트 아카이브 캐시가 섞이지 않는다', () => { + const articleFeed = detailArchive.feed('article', 'ko', null); + const projectFeed = detailArchive.feed('project', 'ko', null); + + expect(articleFeed).not.toEqual(projectFeed); + }); + + it('seedKey가 null이든 커서 문자열이든 그대로 키에 반영된다', () => { + expect(detailArchive.feed('article', 'ko', null)).toEqual([ + 'detailArchive', + 'feed', + 'article', + 'ko', + null, + ]); + expect(detailArchive.feed('article', 'ko', 'cursor-1')).toEqual([ + 'detailArchive', + 'feed', + 'article', + 'ko', + 'cursor-1', + ]); + }); +}); + +describe('og 쿼리 키', () => { + it('url이 다르면 서로 다른 키를 반환한다', () => { + expect(og.preview('https://a.example.com')).not.toEqual(og.preview('https://b.example.com')); + }); +}); + +describe('editor 쿼리 키', () => { + it('excludeId를 생략하면 null을 넣은 것과 동일한 키를 반환한다', () => { + const withoutExcludeId = editor.slugCheck('article', 'my-slug'); + const withNullExcludeId = editor.slugCheck('article', 'my-slug', null); + + expect(withoutExcludeId).toEqual(withNullExcludeId); + }); + + it('type/slug/excludeId 중 하나라도 다르면 서로 다른 키를 반환한다', () => { + const articleSlug = editor.slugCheck('article', 'my-slug'); + const projectSlug = editor.slugCheck('project', 'my-slug'); + const editingExisting = editor.slugCheck('article', 'my-slug', 'content-id-1'); + + expect(articleSlug).not.toEqual(projectSlug); + expect(articleSlug).not.toEqual(editingExisting); + }); +}); diff --git a/src/shared/lib/query/query-keys.ts b/src/shared/lib/query/query-keys.ts new file mode 100644 index 00000000..76848605 --- /dev/null +++ b/src/shared/lib/query/query-keys.ts @@ -0,0 +1,238 @@ +import type { ArticleCommentsSort } from '@/entities/article/comment/model'; +import type { PdfFileDownloadSource, PdfFileKind } from '@/entities/pdf-file/model/types'; +import type { AppLocale } from '@/i18n/routing'; + +/** + * `articles.feed` 쿼리 키에 넣기 전 검색어를 정규화합니다. + * + * `src/entities/article/api/list/get-articles.ts`의 `normalizeSearchQuery`와 + * 동일한 기준(앞뒤 공백 제거, 빈 값은 `''`)을 따릅니다. 정규화 기준이 + * 어긋나면 동일한 조회 조건인데도 캐시 키가 갈라져 불필요한 재조회가 + * 발생하므로 두 곳의 기준을 반드시 맞춰야 합니다. + * + * @param query 원본 검색어입니다. + * @returns 정규화된 검색어입니다. + */ +const normalizeSearchQuery = (query?: string | null) => query?.trim() ?? ''; + +/** + * `articles.feed` 쿼리 키에 넣기 전 태그를 정규화합니다. + * + * `src/entities/article/api/list/get-articles.ts`의 `normalizeArticleTag`와 + * 동일한 기준(앞뒤 공백 제거 후 소문자화, 빈 값은 `''`)을 따릅니다. + * + * @param tag 원본 태그입니다. + * @returns 정규화된 태그입니다. + */ +const normalizeArticleTag = (tag?: string | null) => (tag?.trim() ? tag.trim().toLowerCase() : ''); + +/** + * `detailArchive.feed` 쿼리 키에서 아카이브 도메인을 구분하는 값입니다. + * 현재 `DetailArchiveFeed`가 사용되는 두 화면(글 상세/프로젝트 상세)에 대응합니다. + */ +export type DetailArchiveDomain = 'article' | 'project'; + +/** + * `editor.slugCheck` 쿼리 키에서 콘텐츠 종류를 구분하는 값입니다. + * 이력서(`resume`)는 slug 중복 검사 대상이 아니므로 포함하지 않습니다. + */ +export type EditorSlugCheckType = 'article' | 'project'; + +/** + * 글(article) 목록 관련 React Query 키 팩토리입니다. + */ +export const articles = { + /** + * 이 네임스페이스의 모든 쿼리 키가 공유하는 최상위 접두사입니다. + */ + all: ['articles'] as const, + /** + * locale/검색어/태그 조합에 대한 글 피드(무한 스크롤) 쿼리 키를 만듭니다. + * + * 검색어와 태그는 `get-articles.ts`와 동일한 기준으로 정규화한 뒤 키에 + * 넣어, 표기만 다른 동일 조건(예: 공백·대소문자 차이)이 서로 다른 캐시 + * 엔트리로 분리되지 않게 합니다. 또한 `get-articles.ts`가 검색어가 있으면 + * 태그 필터를 무시하는 것과 동일하게, 검색어가 있을 때는 태그를 빈 값으로 + * 취급해 실제로 동일한 데이터를 반환하는 조합이 같은 키를 갖도록 합니다. + * + * @param params locale, 검색어(`q`), 태그(`tag`)입니다. + * @returns 글 피드 쿼리 키입니다. + */ + feed: (params: { locale: AppLocale; q?: string | null; tag?: string | null }) => { + const normalizedQuery = normalizeSearchQuery(params.q); + const normalizedTag = normalizedQuery ? '' : normalizeArticleTag(params.tag); + + return [ + ...articles.all, + 'feed', + { + locale: params.locale, + q: normalizedQuery, + tag: normalizedTag, + }, + ] as const; + }, + /** + * locale별 인기 태그 목록 쿼리 키를 만듭니다. + * + * @param locale 대상 locale입니다. + * @returns 인기 태그 쿼리 키입니다. + */ + popularTags: (locale: AppLocale) => [...articles.all, 'popularTags', locale] as const, +}; + +/** + * 글 댓글(article comments) 관련 React Query 키 팩토리입니다. + */ +export const articleComments = { + /** + * 이 네임스페이스의 모든 쿼리 키가 공유하는 최상위 접두사입니다. + */ + all: ['articleComments'] as const, + /** + * 특정 글의 댓글 전체(모든 정렬·페이지)를 아우르는 범위 키를 만듭니다. + * + * 개별 페이지 키(`page`)가 모두 이 키를 접두사로 포함하므로, + * 댓글 작성/수정/삭제 후 `invalidateQueries({ queryKey: articleComments.scope(articleId) })`로 + * 해당 글의 모든 댓글 페이지를 한 번에 stale 처리할 수 있습니다. + * + * @param articleId 대상 글 id입니다. + * @returns 댓글 범위 쿼리 키입니다. + */ + scope: (articleId: string) => [...articleComments.all, articleId] as const, + /** + * 특정 글·locale·정렬·페이지의 댓글 목록 쿼리 키를 만듭니다. + * + * `queryFn`이 locale에 의존하므로, 클라이언트 locale 전환 시 이전 locale의 + * 캐시가 재사용되지 않도록 locale을 키에 포함합니다. + * + * @param articleId 대상 글 id입니다. + * @param locale 조회 locale입니다. + * @param sort 댓글 정렬 기준입니다. + * @param page 조회할 페이지 번호입니다. + * @returns 댓글 페이지 쿼리 키입니다. + */ + page: (articleId: string, locale: string, sort: ArticleCommentsSort, page: number) => + [...articleComments.scope(articleId), locale, sort, page] as const, +}; + +/** + * 프로젝트(project) 목록 관련 React Query 키 팩토리입니다. + */ +export const projects = { + /** + * 이 네임스페이스의 모든 쿼리 키가 공유하는 최상위 접두사입니다. + */ + all: ['projects'] as const, + /** + * locale별 프로젝트 피드(무한 스크롤) 쿼리 키를 만듭니다. + * + * @param locale 대상 locale입니다. + * @returns 프로젝트 피드 쿼리 키입니다. + */ + feed: (locale: AppLocale) => [...projects.all, 'feed', locale] as const, + /** + * locale별 프로젝트 미리보기(히어로 등) 쿼리 키를 만듭니다. + * + * @param locale 대상 locale입니다. + * @param limit 조회할 개수입니다. + * @returns 프로젝트 미리보기 쿼리 키입니다. + */ + preview: (locale: AppLocale, limit: number) => + [...projects.all, 'preview', locale, limit] as const, +}; + +/** + * 태그(tag) 관련 React Query 키 팩토리입니다. + */ +export const tags = { + /** + * locale별 전체 태그 목록 쿼리 키를 만듭니다. + * + * @param locale 대상 locale입니다. + * @returns 전체 태그 쿼리 키입니다. + */ + all: (locale: AppLocale) => ['tags', 'all', locale] as const, +}; + +/** + * PDF 다운로드 옵션/가용성 관련 React Query 키 팩토리입니다. + */ +export const pdf = { + /** + * 이 네임스페이스의 모든 쿼리 키가 공유하는 최상위 접두사입니다. + * PDF 업로드 성공 시 이 접두사로 `invalidateQueries`하면 옵션/가용성 쿼리를 + * 한 번에 무효화할 수 있습니다. + */ + all: ['pdf'] as const, + /** + * 어드민 PDF 가용성 조회 쿼리 키입니다. 파라미터가 없어 상수로 둡니다. + */ + adminAvailability: ['pdf', 'adminAvailability'] as const, + /** + * 콘텐츠 종류·화면 위치별 PDF 다운로드 옵션 쿼리 키를 만듭니다. + * + * @param kind PDF 콘텐츠 종류입니다. + * @param source 다운로드가 발생한 화면 위치입니다. + * @returns PDF 옵션 쿼리 키입니다. + */ + options: (kind: PdfFileKind, source: PdfFileDownloadSource) => + [...pdf.all, 'options', kind, source] as const, +}; + +/** + * 상세 페이지 아카이브(인접 글/프로젝트 목록) 관련 React Query 키 팩토리입니다. + */ +export const detailArchive = { + /** + * 이 네임스페이스의 모든 쿼리 키가 공유하는 최상위 접두사입니다. + */ + all: ['detailArchive'] as const, + /** + * 도메인·locale·시드 커서별 아카이브 피드(무한 스크롤) 쿼리 키를 만듭니다. + * + * @param domain 아카이브 대상 도메인(글/프로젝트)입니다. + * @param locale 대상 locale입니다. + * @param seedKey 서버 부트스트랩이 넘겨준 시드 커서입니다. 루트 페이지는 `null`입니다. + * @returns 아카이브 피드 쿼리 키입니다. + */ + feed: (domain: DetailArchiveDomain, locale: AppLocale, seedKey: string | null) => + [...detailArchive.all, 'feed', domain, locale, seedKey] as const, +}; + +/** + * OG(오픈그래프) 미리보기 관련 React Query 키 팩토리입니다. + */ +export const og = { + /** + * 이 네임스페이스의 모든 쿼리 키가 공유하는 최상위 접두사입니다. + */ + all: ['og'] as const, + /** + * URL별 OG 미리보기 메타데이터 쿼리 키를 만듭니다. + * + * @param url 미리보기를 조회할 URL입니다. + * @returns OG 미리보기 쿼리 키입니다. + */ + preview: (url: string) => [...og.all, 'preview', url] as const, +}; + +/** + * 에디터(글/프로젝트 작성) 관련 React Query 키 팩토리입니다. + */ +export const editor = { + /** + * 이 네임스페이스의 모든 쿼리 키가 공유하는 최상위 접두사입니다. + */ + all: ['editor'] as const, + /** + * 콘텐츠 종류·slug·제외 id별 slug 중복 확인 쿼리 키를 만듭니다. + * + * @param type 콘텐츠 종류(글/프로젝트)입니다. + * @param slug 확인할 slug입니다. + * @param excludeId 수정 중인 콘텐츠 자신의 id입니다. 신규 작성 시 `null`/`undefined`입니다. + * @returns slug 중복 확인 쿼리 키입니다. + */ + slugCheck: (type: EditorSlugCheckType, slug: string, excludeId?: string | null) => + [...editor.all, 'slugCheck', type, slug, excludeId ?? null] as const, +}; diff --git a/src/shared/lib/react/cursor-pagination-feed-state.test.ts b/src/shared/lib/react/cursor-pagination-feed-state.test.ts deleted file mode 100644 index 3f727a02..00000000 --- a/src/shared/lib/react/cursor-pagination-feed-state.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// @vitest-environment node - -import { resolveCursorPaginationLoadMore } from '@/shared/lib/react/cursor-pagination-feed-state'; - -describe('resolveCursorPaginationLoadMore', () => { - it('loadMore 성공 시 목록을 이어붙이고 nextCursor를 갱신한다', async () => { - const loadPage = vi.fn().mockResolvedValue({ - items: [{ id: 'b' }], - nextCursor: null, - }); - - const result = await resolveCursorPaginationLoadMore({ - currentCursor: '1', - currentItems: [{ id: 'a' }], - limit: 10, - loadPage, - locale: 'ko', - }); - - expect(result).toEqual({ - errorMessage: null, - items: [{ id: 'a' }, { id: 'b' }], - nextCursor: null, - }); - expect(loadPage).toHaveBeenCalledWith({ - cursor: '1', - limit: 10, - locale: 'ko', - queryParams: undefined, - }); - }); - - it('요청 실패 시 기존 목록과 cursor를 유지하고 errorMessage를 반환한다', async () => { - const loadPage = vi.fn().mockRejectedValue(new Error('load failed')); - - const result = await resolveCursorPaginationLoadMore({ - currentCursor: '1', - currentItems: [{ id: 'a' }], - limit: 10, - loadPage, - locale: 'ko', - }); - - expect(result).toEqual({ - errorMessage: 'load failed', - items: [{ id: 'a' }], - nextCursor: '1', - }); - }); - - it('queryParams를 함께 전달한다', async () => { - const loadPage = vi.fn().mockResolvedValue({ - items: [], - nextCursor: null, - }); - - await resolveCursorPaginationLoadMore({ - currentCursor: '3', - currentItems: [{ id: 'a' }], - limit: 10, - loadPage, - locale: 'ko', - queryParams: { - q: 'react', - tag: 'nextjs', - }, - }); - - expect(loadPage).toHaveBeenCalledWith({ - cursor: '3', - limit: 10, - locale: 'ko', - queryParams: { - q: 'react', - tag: 'nextjs', - }, - }); - }); -}); diff --git a/src/shared/lib/react/cursor-pagination-feed-state.ts b/src/shared/lib/react/cursor-pagination-feed-state.ts deleted file mode 100644 index 58350e3a..00000000 --- a/src/shared/lib/react/cursor-pagination-feed-state.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { getErrorMessage } from '@/shared/lib/error/get-error-message'; - -export type CursorPaginationFeedQueryParams = Record; - -export type CursorPaginationFeedPage = { - items: T[]; - nextCursor: string | null; - totalCount?: number | null; -}; - -type ResolveCursorPaginationLoadMoreOptions = { - currentCursor: string | null; - currentItems: T[]; - limit: number; - loadPage: (params: { - cursor: string; - limit: number; - locale: string; - queryParams?: CursorPaginationFeedQueryParams; - }) => Promise>; - locale: string; - mergeItems?: (previousItems: T[], incomingItems: T[]) => T[]; - queryParams?: CursorPaginationFeedQueryParams; -}; - -type ResolveCursorPaginationLoadMoreResult = { - errorMessage: string | null; - items: T[]; - nextCursor: string | null; -}; - -/** - * 다음 offset 페이지를 불러온 뒤 리스트/커서/에러 상태를 순수하게 계산합니다. - * - * 훅 바깥에서 검증할 수 있도록 비동기 로딩 결과를 데이터 형태로만 반환합니다. - * - * @param options 현재 cursor, 기존 items, loader, 병합 규칙 등 로딩에 필요한 옵션입니다. - * @returns append/merge가 반영된 items, 다음 cursor, 사용자용 errorMessage를 반환합니다. - */ -export const resolveCursorPaginationLoadMore = async ({ - currentCursor, - currentItems, - limit, - loadPage, - locale, - mergeItems, - queryParams, -}: ResolveCursorPaginationLoadMoreOptions): Promise< - ResolveCursorPaginationLoadMoreResult -> => { - if (!currentCursor) { - return { - errorMessage: null, - items: currentItems, - nextCursor: currentCursor, - }; - } - - try { - const payload = await loadPage({ - cursor: currentCursor, - limit, - locale, - queryParams, - }); - - return { - errorMessage: null, - items: mergeItems - ? mergeItems(currentItems, payload.items) - : [...currentItems, ...payload.items], - nextCursor: payload.nextCursor, - }; - } catch (error) { - const resolvedErrorMessage = getErrorMessage(error); - const fallbackErrorMessage = - typeof error === 'string' - ? error - : (() => { - try { - return JSON.stringify(error); - } catch { - return undefined; - } - })(); - - return { - errorMessage: - resolvedErrorMessage === 'unknown error' - ? (fallbackErrorMessage ?? 'unknown error') - : resolvedErrorMessage, - items: currentItems, - nextCursor: currentCursor, - }; - } -}; diff --git a/src/shared/lib/react/use-cursor-pagination-feed.test.tsx b/src/shared/lib/react/use-cursor-pagination-feed.test.tsx deleted file mode 100644 index f1583d87..00000000 --- a/src/shared/lib/react/use-cursor-pagination-feed.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; - -import { useCursorPaginationFeed } from '@/shared/lib/react/use-cursor-pagination-feed'; - -describe('useCursorPaginationFeed', () => { - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - /** - * 훅이 노출한 `loadMore`를 호출하고 상태 반영까지 한 번 감쌉니다. - * - * @param loadMore 다음 페이지를 불러오는 훅 함수입니다. - */ - const triggerLoadMore = async (loadMore: () => Promise) => { - await act(async () => { - await loadMore(); - }); - }; - - it('초기 props가 바뀌면 items와 nextCursor를 새 값으로 재동기화한다', async () => { - type HookProps = { - initialCursor: string | null; - initialItems: { id: string }[]; - }; - const loadPage = vi.fn(); - const initialProps: HookProps = { - initialCursor: '1', - initialItems: [{ id: 'a' }], - }; - - const { result, rerender, unmount } = renderHook( - ({ initialCursor, initialItems }: HookProps) => - useCursorPaginationFeed<{ id: string }>({ - initialCursor, - initialItems, - loadPage, - locale: 'ko', - }), - { - initialProps, - }, - ); - - try { - rerender({ - initialCursor: null, - initialItems: [{ id: 'b' }], - }); - - await waitFor(() => { - expect(result.current.items).toEqual([{ id: 'b' }]); - }); - expect(result.current.hasMore).toBe(false); - } finally { - unmount(); - } - }); - - it('같은 seed를 새 배열로 다시 받아도 목록을 불필요하게 초기화하지 않는다', async () => { - type HookProps = { - initialCursor: string | null; - initialItems: { id: string }[]; - }; - const seededItem = { id: 'a' }; - const loadPage = vi.fn().mockResolvedValue({ - items: [{ id: 'b' }], - nextCursor: null, - }); - - const { result, rerender, unmount } = renderHook( - ({ initialCursor, initialItems }: HookProps) => - useCursorPaginationFeed<{ id: string }>({ - initialCursor, - initialItems, - loadPage, - locale: 'ko', - }), - { - initialProps: { - initialCursor: '1', - initialItems: [seededItem], - }, - }, - ); - - try { - await triggerLoadMore(result.current.loadMore); - - rerender({ - initialCursor: '1', - initialItems: [seededItem], - }); - - await waitFor(() => { - expect(result.current.items).toEqual([{ id: 'a' }, { id: 'b' }]); - }); - expect(result.current.hasMore).toBe(false); - expect(loadPage).toHaveBeenCalledTimes(1); - } finally { - unmount(); - } - }); - - it('resetKey가 바뀌면 같은 seed여도 cursor와 items를 명시적으로 초기화한다', async () => { - type HookProps = { - initialCursor: string | null; - initialItems: { id: string }[]; - resetKey: string; - }; - const seededItem = { id: 'a' }; - const loadPage = vi.fn().mockResolvedValue({ - items: [{ id: 'b' }], - nextCursor: null, - }); - - const { result, rerender, unmount } = renderHook( - ({ initialCursor, initialItems, resetKey }: HookProps) => - useCursorPaginationFeed<{ id: string }>({ - initialCursor, - initialItems, - loadPage, - locale: 'ko', - resetKey, - }), - { - initialProps: { - initialCursor: '1', - initialItems: [seededItem], - resetKey: 'ko::react', - }, - }, - ); - - try { - await triggerLoadMore(result.current.loadMore); - - rerender({ - initialCursor: '1', - initialItems: [seededItem], - resetKey: 'ko::nextjs', - }); - - await waitFor(() => { - expect(result.current.items).toEqual([{ id: 'a' }]); - }); - expect(result.current.hasMore).toBe(true); - } finally { - unmount(); - } - }); -}); diff --git a/src/shared/lib/react/use-cursor-pagination-feed.ts b/src/shared/lib/react/use-cursor-pagination-feed.ts deleted file mode 100644 index 54e32940..00000000 --- a/src/shared/lib/react/use-cursor-pagination-feed.ts +++ /dev/null @@ -1,134 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { - type CursorPaginationFeedPage, - type CursorPaginationFeedQueryParams, - resolveCursorPaginationLoadMore, -} from '@/shared/lib/react/cursor-pagination-feed-state'; - -type UseCursorPaginationFeedOptions = { - initialCursor: string | null; - initialItems: T[]; - limit?: number; - loadPage: (params: { - cursor: string; - limit: number; - locale: string; - queryParams?: CursorPaginationFeedQueryParams; - }) => Promise>; - locale: string; - mergeItems?: (previousItems: T[], incomingItems: T[]) => T[]; - queryParams?: CursorPaginationFeedQueryParams; - resetKey?: string; -}; - -type UseCursorPaginationFeedResult = { - errorMessage: string | null; - hasMore: boolean; - isLoadingMore: boolean; - items: T[]; - loadMore: () => Promise; -}; - -const DEFAULT_LIMIT = 10; - -/** - * `areShallowEqualItems`는 무한 스크롤 seed 배열을 얕게 비교합니다. - * - * 먼저 배열 참조 동일성(`left === right`)을 확인하고, 다르면 길이를 비교한 뒤, - * 마지막으로 각 인덱스의 원소를 `Object.is` 기준으로 순서대로 검사합니다. - * - * 깊은 비교는 수행하지 않으므로, 중첩 객체나 배열 내부 값만 바뀌고 바깥 원소 참조가 - * 그대로인 경우에는 변경을 감지하지 못합니다. - */ -const areShallowEqualItems = (left: T[], right: T[]) => { - if (left === right) return true; - if (left.length !== right.length) return false; - - for (let index = 0; index < left.length; index += 1) { - if (!Object.is(left[index], right[index])) { - return false; - } - } - - return true; -}; - -/** - * cursor 기반 API를 사용하는 무한 스크롤 리스트 상태를 공통 관리합니다. - */ -export const useCursorPaginationFeed = ({ - initialCursor, - initialItems, - limit = DEFAULT_LIMIT, - loadPage, - locale, - mergeItems, - queryParams, - resetKey, -}: UseCursorPaginationFeedOptions): UseCursorPaginationFeedResult => { - const [items, setItems] = useState(initialItems); - const [nextCursor, setNextCursor] = useState(initialCursor); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const lastSeedCursorRef = useRef(initialCursor); - const lastSeedItemsRef = useRef(initialItems); - const lastResetKeyRef = useRef(resetKey); - const itemsRef = useRef(initialItems); - - useEffect(() => { - const didResetKeyChange = !Object.is(lastResetKeyRef.current, resetKey); - const isSameSeed = - Object.is(lastSeedCursorRef.current, initialCursor) && - areShallowEqualItems(lastSeedItemsRef.current, initialItems); - - if (!didResetKeyChange && isSameSeed) return; - - lastSeedCursorRef.current = initialCursor; - lastSeedItemsRef.current = initialItems; - lastResetKeyRef.current = resetKey; - itemsRef.current = initialItems; - setItems(initialItems); - setNextCursor(initialCursor); - setIsLoadingMore(false); - setErrorMessage(null); - }, [initialCursor, initialItems, resetKey]); - - const loadMore = useCallback(async () => { - if (!nextCursor || isLoadingMore) return; - - setIsLoadingMore(true); - setErrorMessage(null); - - try { - const resolved = await resolveCursorPaginationLoadMore({ - currentCursor: nextCursor, - currentItems: itemsRef.current, - limit, - locale, - loadPage, - mergeItems, - queryParams, - }); - - itemsRef.current = resolved.items; - setItems(resolved.items); - setNextCursor(resolved.nextCursor); - setErrorMessage(resolved.errorMessage); - } finally { - setIsLoadingMore(false); - } - }, [isLoadingMore, limit, loadPage, locale, mergeItems, nextCursor, queryParams]); - - const hasMore = useMemo(() => Boolean(nextCursor), [nextCursor]); - - return { - errorMessage, - hasMore, - isLoadingMore, - items, - loadMore, - }; -}; diff --git a/src/shared/lib/seo/canonical.test.ts b/src/shared/lib/seo/canonical.test.ts index 96c3161c..274fde3f 100644 --- a/src/shared/lib/seo/canonical.test.ts +++ b/src/shared/lib/seo/canonical.test.ts @@ -10,13 +10,22 @@ describe('seo canonical helpers', () => { ).toBe('ko'); }); - it('요청 locale 번역이 없으면 en을 canonical로 사용한다', () => { + it('요청 locale 번역이 없으면 실제 해석된 번역 locale을 canonical로 사용한다', () => { expect( resolveCanonicalLocale({ requestedLocale: 'fr', resolvedLocale: 'ko', }), - ).toBe('en'); + ).toBe('ko'); + }); + + it('해석된 locale이 유효하지 않으면 요청 locale을 canonical로 사용한다', () => { + expect( + resolveCanonicalLocale({ + requestedLocale: 'fr', + resolvedLocale: null, + }), + ).toBe('fr'); }); it('locale별 경로 맵을 생성한다', () => { diff --git a/src/shared/lib/seo/metadata.test.ts b/src/shared/lib/seo/metadata.test.ts index 796979f6..f7a94009 100644 --- a/src/shared/lib/seo/metadata.test.ts +++ b/src/shared/lib/seo/metadata.test.ts @@ -34,7 +34,8 @@ describe('seo metadata helpers', () => { en: 'https://chaen.dev/en/articles/hello', ja: 'https://chaen.dev/ja/articles/hello', fr: 'https://chaen.dev/fr/articles/hello', - 'x-default': 'https://chaen.dev/en/articles/hello', + // x-default는 앱 기본 locale(seoDefaultLocale = ko)의 경로를 따른다. + 'x-default': 'https://chaen.dev/ko/articles/hello', }, }); }); diff --git a/src/shared/lib/test/render-with-query-client.tsx b/src/shared/lib/test/render-with-query-client.tsx new file mode 100644 index 00000000..84728190 --- /dev/null +++ b/src/shared/lib/test/render-with-query-client.tsx @@ -0,0 +1,41 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React, { type ReactNode } from 'react'; + +/** + * 테스트 전용 QueryClient를 생성합니다. + * + * 재시도를 비활성화해 실패 케이스를 재시도 대기 없이 즉시 검증할 수 있게 하고, + * `gcTime`을 `Infinity`로 두어 테스트 도중 캐시 엔트리가 예기치 않게 + * 정리(gc)되지 않도록 합니다. + * + * @returns 테스트에서 사용할 새 QueryClient 인스턴스입니다. + */ +export const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + gcTime: Infinity, + retry: false, + }, + }, + }); + +/** + * 주어진(또는 새로 생성한) QueryClient로 하위 트리를 감싸는 래퍼 컴포넌트를 만듭니다. + * + * `@testing-library/react`의 `render`/`renderHook`에 `wrapper` 옵션으로 전달해 + * React Query 훅/컴포넌트를 테스트할 때 사용합니다. 테스트 간 캐시를 공유하지 + * 않으려면 매 테스트마다 새 QueryClient(기본값)를 사용하고, 특정 시나리오에서 + * 캐시 상태를 미리 세팅하려면 `queryClient.setQueryData(...)` 후 그 인스턴스를 + * 인자로 넘기면 됩니다. + * + * @param queryClient 사용할 QueryClient입니다. 생략하면 `createTestQueryClient()`로 새로 생성합니다. + * @returns children을 `QueryClientProvider`로 감싸는 래퍼 컴포넌트입니다. + */ +export const createQueryClientWrapper = (queryClient: QueryClient = createTestQueryClient()) => { + const QueryClientTestWrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return QueryClientTestWrapper; +}; diff --git a/src/shared/providers/index.ts b/src/shared/providers/index.ts index 390877fe..5432879f 100644 --- a/src/shared/providers/index.ts +++ b/src/shared/providers/index.ts @@ -1,2 +1,3 @@ export { AuthProvider, useAuth } from '@/shared/providers/auth-provider'; +export { QueryProvider } from '@/shared/providers/query-provider'; export { ThemeProvider } from '@/shared/providers/theme-provider'; diff --git a/src/shared/providers/query-provider.tsx b/src/shared/providers/query-provider.tsx new file mode 100644 index 00000000..71992174 --- /dev/null +++ b/src/shared/providers/query-provider.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +import { getQueryClient } from '@/shared/lib/query/create-query-client'; + +type QueryProviderProps = { + children: ReactNode; +}; + +/** + * React Query의 QueryClient를 앱 전역에 제공하는 프로바이더입니다. + * + * `getQueryClient()`로 환경에 맞는 QueryClient를 가져와 사용합니다. + * `useState(() => new QueryClient())` 패턴 대신 모듈 싱글턴/서버별 신규 생성 + * 전략을 그대로 따르므로, 리렌더마다 클라이언트가 재생성되거나 서버 요청 + * 간 캐시가 섞이는 문제가 없습니다. + */ +export const QueryProvider = ({ children }: QueryProviderProps) => { + const queryClient = getQueryClient(); + + return {children}; +}; diff --git a/src/shared/ui/markdown/link-embed-card.test.tsx b/src/shared/ui/markdown/link-embed-card.test.tsx index a365a4d5..677f1fb3 100644 --- a/src/shared/ui/markdown/link-embed-card.test.tsx +++ b/src/shared/ui/markdown/link-embed-card.test.tsx @@ -1,17 +1,27 @@ import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; import { LinkEmbedCard } from '@/shared/ui/markdown/link-embed-card'; import '@testing-library/jest-dom/vitest'; +/** + * `LinkEmbedCard`를 React Query 래퍼로 감싸 렌더링합니다. + * + * @param ui 렌더링할 엘리먼트입니다. + * @returns Testing Library 렌더 결과입니다. + */ +const renderWithClient = (ui: React.ReactElement) => + render(ui, { wrapper: createQueryClientWrapper() }); + describe('LinkEmbedCard', () => { afterEach(() => { vi.restoreAllMocks(); }); it('host fetcher가 대기 중이면, LinkEmbedCard는 로딩 skeleton을 렌더링해야 한다', () => { - render( + renderWithClient( new Promise(() => undefined)} url="https://github.com/openai/openai" @@ -23,7 +33,7 @@ describe('LinkEmbedCard', () => { }); it('유효한 preview metadata가 주어지면, LinkEmbedCard는 card variant를 렌더링해야 한다', async () => { - render( + renderWithClient( ({ description: 'Repository description', @@ -47,7 +57,7 @@ describe('LinkEmbedCard', () => { }); it('유효한 preview metadata가 주어지면, LinkEmbedCard는 카드 본문 텍스트 없이 preview variant를 렌더링해야 한다', async () => { - render( + renderWithClient( ({ description: 'Repository description', @@ -71,7 +81,7 @@ describe('LinkEmbedCard', () => { }); it('preview metadata가 부족하면, LinkEmbedCard는 일반 외부 링크로 fallback해야 한다', async () => { - render( + renderWithClient( ({ @@ -94,7 +104,7 @@ describe('LinkEmbedCard', () => { }); it('host fetcher가 실패하면, LinkEmbedCard는 일반 외부 링크로 fallback해야 한다', async () => { - render( + renderWithClient( { @@ -127,7 +137,9 @@ describe('LinkEmbedCard', () => { }), ); - render(); + renderWithClient( + , + ); const link = await screen.findByRole('link', { name: 'Fallback' }); diff --git a/src/shared/ui/markdown/link-embed-card.tsx b/src/shared/ui/markdown/link-embed-card.tsx index dcbe6ded..ea00ca9b 100644 --- a/src/shared/ui/markdown/link-embed-card.tsx +++ b/src/shared/ui/markdown/link-embed-card.tsx @@ -1,10 +1,11 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import React, { useEffect, useMemo, useState } from 'react'; import { css, cx } from 'styled-system/css'; import type { FetchLinkPreviewMeta } from '@/entities/editor-core'; -import { fetchLinkPreviewMetaAdapter } from '@/features/edit-markdown-adapter'; +import { createCachedFetchLinkPreviewMeta } from '@/features/edit-markdown-adapter'; import { type LinkEmbedData, shouldFallbackToPlainLink } from '@/shared/lib/markdown/link-embed'; type LinkEmbedCardProps = { @@ -65,14 +66,20 @@ export const LinkEmbedCard = ({ const [state, setState] = useState({ status: 'loading', }); - const resolvedFetchLinkPreviewMeta = fetchLinkPreviewMeta ?? fetchLinkPreviewMetaAdapter; + const queryClient = useQueryClient(); + const resolvedFetchLinkPreviewMeta = useMemo( + () => fetchLinkPreviewMeta ?? createCachedFetchLinkPreviewMeta(queryClient), + [fetchLinkPreviewMeta, queryClient], + ); useEffect(() => { - const controller = new AbortController(); + let isActive = true; const fetchEmbedData = async () => { try { - const data = await resolvedFetchLinkPreviewMeta(url, controller.signal); + const data = await resolvedFetchLinkPreviewMeta(url); + + if (!isActive) return; if (!data) { throw new Error('link preview data is empty'); @@ -83,7 +90,7 @@ export const LinkEmbedCard = ({ status: shouldFallbackToPlainLink(data) ? 'fallback' : 'success', }); } catch { - if (controller.signal.aborted) return; + if (!isActive) return; setState({ data: createFallbackLinkEmbedData(url, fallbackLabel), @@ -94,7 +101,9 @@ export const LinkEmbedCard = ({ void fetchEmbedData(); - return () => controller.abort(); + return () => { + isActive = false; + }; }, [fallbackLabel, resolvedFetchLinkPreviewMeta, url]); if (state.status === 'loading') { diff --git a/src/shared/ui/markdown/markdown-renderer.test.tsx b/src/shared/ui/markdown/markdown-renderer.test.tsx index 6d835787..80c02df3 100644 --- a/src/shared/ui/markdown/markdown-renderer.test.tsx +++ b/src/shared/ui/markdown/markdown-renderer.test.tsx @@ -1,7 +1,9 @@ import { render, screen } from '@testing-library/react'; +import React from 'react'; import { renderToReadableStream } from 'react-dom/server'; import { collectMarkdownImages } from '@/shared/lib/markdown/collect-markdown-images'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; import { MarkdownRenderer } from '@/shared/ui/markdown/markdown-renderer'; vi.mock('next-intl', () => ({ @@ -29,7 +31,10 @@ vi.mock('@/shared/lib/storage/attachment-download-path', () => ({ */ const renderServerHtml = async (markdown: string) => { const element = await MarkdownRenderer({ markdown }); - const stream = await renderToReadableStream(element); + const QueryClientTestWrapper = createQueryClientWrapper(); + const stream = await renderToReadableStream( + {element}, + ); return new Response(stream).text(); }; diff --git a/src/shared/ui/pdf-download-popover/deferred-pdf-download-popover.tsx b/src/shared/ui/pdf-download-popover/deferred-pdf-download-popover.tsx index c10c267b..bcaf217c 100644 --- a/src/shared/ui/pdf-download-popover/deferred-pdf-download-popover.tsx +++ b/src/shared/ui/pdf-download-popover/deferred-pdf-download-popover.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useQuery } from '@tanstack/react-query'; import React from 'react'; import type { @@ -7,8 +8,12 @@ import type { PdfFileDownloadSource, PdfFileKind, } from '@/entities/pdf-file/model/types'; +import { pdf } from '@/shared/lib/query/query-keys'; import { PdfDownloadPopover } from '@/shared/ui/pdf-download-popover/pdf-download-popover'; +const PDF_OPTIONS_STALE_TIME = 5 * 60_000; +const EMPTY_PDF_DOWNLOAD_OPTIONS: PdfFileDownloadOption[] = []; + type DeferredPdfDownloadPopoverProps = { className?: string; kind: PdfFileKind; @@ -28,59 +33,32 @@ export const DeferredPdfDownloadPopover = ({ source, unavailableLabel, }: DeferredPdfDownloadPopoverProps) => { - const [options, setOptions] = React.useState([]); - const [status, setStatus] = React.useState<'loading' | 'ready' | 'error'>('loading'); - - React.useEffect(() => { - const abortController = new AbortController(); - - const loadOptions = async () => { - setStatus('loading'); - - try { - const searchParams = new URLSearchParams({ - source, - }); - const response = await fetch(`/api/pdf/options/${kind}?${searchParams.toString()}`, { - method: 'GET', - signal: abortController.signal, - }); + const { data, isPending } = useQuery({ + queryFn: async ({ signal }) => { + const searchParams = new URLSearchParams({ + source, + }); + const response = await fetch(`/api/pdf/options/${kind}?${searchParams.toString()}`, { + method: 'GET', + signal, + }); - if (!response.ok) { - throw new Error(`Failed to load PDF options: ${response.status}`); - } - - const data = (await response.json()) as PdfFileDownloadOption[]; - setOptions(data); - setStatus('ready'); - } catch (error) { - if (abortController.signal.aborted) { - return; - } - - console.error('[pdf] deferred download options failed', { - error, - kind, - source, - }); - setOptions([]); - setStatus('error'); + if (!response.ok) { + throw new Error(`Failed to load PDF options: ${response.status}`); } - }; - - void loadOptions(); - return () => { - abortController.abort(); - }; - }, [kind, source]); + return (await response.json()) as PdfFileDownloadOption[]; + }, + queryKey: pdf.options(kind, source), + staleTime: PDF_OPTIONS_STALE_TIME, + }); return ( ); diff --git a/src/views/articles/model/get-article-detail-page-data.ts b/src/views/articles/model/get-article-detail-page-data.ts index 4eea52e6..dbd8966d 100644 --- a/src/views/articles/model/get-article-detail-page-data.ts +++ b/src/views/articles/model/get-article-detail-page-data.ts @@ -1,3 +1,5 @@ +import { cache } from 'react'; + import { getResolvedArticle } from '@/entities/article/api/detail/get-article'; import { getArticleDetailList, @@ -83,6 +85,17 @@ export const getArticleTagLabels = async ({ return tags.map(tag => tagLabelMap.data.get(tag) ?? tag); }; +/** + * 원시 파라미터로 아티클 상세 shell 데이터를 조회합니다. + * + * generateMetadata와 페이지 본문이 동일 요청에서 두 번 호출하므로 React `cache()`로 + * 요청 범위 dedupe합니다. 객체 인자는 참조 기준이라 캐시가 무효화되므로 원시값만 받습니다. + */ +const getCachedArticleDetailShellData = cache( + (articleSlug: string, locale: string): Promise => + getResolvedArticle(articleSlug, locale), +); + /** * 아티클 상세 본문 shell에 필요한 최소 데이터를 조회합니다. */ @@ -90,7 +103,7 @@ export const getArticleDetailShellData = ({ articleSlug, locale, }: GetArticleDetailPageDataInput): Promise => - getResolvedArticle(articleSlug, locale); + getCachedArticleDetailShellData(articleSlug, locale); /** * 아티클 상세 좌측 아카이브를 현재 글을 포함한 초기 slice로 조회합니다. diff --git a/src/views/articles/model/get-articles-page-data.ts b/src/views/articles/model/get-articles-page-data.ts index 9d05aa01..98f2f5b8 100644 --- a/src/views/articles/model/get-articles-page-data.ts +++ b/src/views/articles/model/get-articles-page-data.ts @@ -1,3 +1,5 @@ +import { cache } from 'react'; + import { getArticles, getResolvedArticlesFirstPage, @@ -152,6 +154,86 @@ export const buildArticlesPageHref = ({ return serializedSearchParams ? `${pathname}?${serializedSearchParams}` : pathname; }; +/** + * 정규화된 원시 파라미터로 아티클 목록 초기 데이터를 조회합니다. + * + * generateMetadata와 페이지 본문이 동일 요청에서 두 번 호출하므로 React `cache()`로 + * 요청 범위 dedupe합니다. 객체 인자는 참조 기준이라 캐시가 무효화되므로 원시값만 받고, + * `cursorHistory`는 join한 문자열로 전달받아 내부에서 배열로 복원합니다. + */ +const getCachedArticlesPageData = cache( + async ( + locale: string, + page: number, + normalizedCursor: string | null, + serializedCursorHistory: string, + normalizedQuery: string, + normalizedTag: string, + ): Promise => { + const normalizedCursorHistory = serializedCursorHistory + ? serializedCursorHistory.split(',') + : []; + const shouldUseDirectCursor = page > 1 && normalizedCursor; + let currentCursor: string | null = null; + const currentCursorHistory = normalizedCursorHistory; + let currentPage = 1; + let feedLocale = locale.toLowerCase(); + let articlesPage; + + if (shouldUseDirectCursor) { + articlesPage = await getArticles({ + cursor: normalizedCursor, + locale: feedLocale, + query: normalizedQuery, + tag: normalizedTag, + }); + currentCursor = normalizedCursor; + currentPage = articlesPage.items.length === 0 ? page - 1 : page; + } else { + const resolvedArticlesPage = await getResolvedArticlesFirstPage({ + locale, + query: normalizedQuery, + tag: normalizedTag, + }); + feedLocale = resolvedArticlesPage.resolvedLocale; + articlesPage = resolvedArticlesPage.page; + } + + return { + activeTag: normalizedTag, + feedLocale, + initialCursor: articlesPage.nextCursor, + initialItems: articlesPage.items, + locale, + pagination: { + currentPage, + nextHref: articlesPage.nextCursor + ? buildArticlesPageHref({ + cursor: articlesPage.nextCursor, + cursorHistory: currentCursor ? [...currentCursorHistory, currentCursor] : [], + locale, + page: currentPage + 1, + query: normalizedQuery, + tag: normalizedTag, + }) + : null, + previousHref: + currentPage > 1 + ? buildArticlesPageHref({ + cursor: currentCursorHistory.at(-1) ?? null, + cursorHistory: currentCursorHistory.slice(0, -1), + locale, + page: currentPage - 1, + query: normalizedQuery, + tag: normalizedTag, + }) + : null, + }, + searchQuery: normalizedQuery, + }; + }, +); + /** * 아티클 목록 페이지의 초기 무한스크롤 데이터를 조회합니다. * @@ -169,62 +251,13 @@ export const getArticlesPageData = async ({ const normalizedCursorHistory = normalizeCursorHistoryParams(cursorHistory); const normalizedQuery = normalizeSearchParams(query); const normalizedTag = normalizedQuery ? '' : normalizeTagParams(tag); - const shouldUseDirectCursor = page > 1 && normalizedCursor; - let currentCursor: string | null = null; - const currentCursorHistory = normalizedCursorHistory; - let currentPage = 1; - let feedLocale = locale.toLowerCase(); - let articlesPage; - - if (shouldUseDirectCursor) { - articlesPage = await getArticles({ - cursor: normalizedCursor, - locale: feedLocale, - query: normalizedQuery, - tag: normalizedTag, - }); - currentCursor = normalizedCursor; - currentPage = articlesPage.items.length === 0 ? page - 1 : page; - } else { - const resolvedArticlesPage = await getResolvedArticlesFirstPage({ - locale, - query: normalizedQuery, - tag: normalizedTag, - }); - feedLocale = resolvedArticlesPage.resolvedLocale; - articlesPage = resolvedArticlesPage.page; - } - return { - activeTag: normalizedTag, - feedLocale, - initialCursor: articlesPage.nextCursor, - initialItems: articlesPage.items, + return getCachedArticlesPageData( locale, - pagination: { - currentPage, - nextHref: articlesPage.nextCursor - ? buildArticlesPageHref({ - cursor: articlesPage.nextCursor, - cursorHistory: currentCursor ? [...currentCursorHistory, currentCursor] : [], - locale, - page: currentPage + 1, - query: normalizedQuery, - tag: normalizedTag, - }) - : null, - previousHref: - currentPage > 1 - ? buildArticlesPageHref({ - cursor: currentCursorHistory.at(-1) ?? null, - cursorHistory: currentCursorHistory.slice(0, -1), - locale, - page: currentPage - 1, - query: normalizedQuery, - tag: normalizedTag, - }) - : null, - }, - searchQuery: normalizedQuery, - }; + page, + normalizedCursor, + normalizedCursorHistory.join(','), + normalizedQuery, + normalizedTag, + ); }; diff --git a/src/views/project/model/get-project-detail-page-data.ts b/src/views/project/model/get-project-detail-page-data.ts index 1d5d0095..d1ac239e 100644 --- a/src/views/project/model/get-project-detail-page-data.ts +++ b/src/views/project/model/get-project-detail-page-data.ts @@ -1,3 +1,5 @@ +import { cache } from 'react'; + import { getResolvedProject } from '@/entities/project/api/detail/get-project'; import { getProjectDetailList, @@ -39,6 +41,17 @@ const toCurrentProjectArchiveItem = (item: Project | null): CurrentProjectArchiv }; }; +/** + * 원시 파라미터로 프로젝트 상세 shell 데이터를 조회합니다. + * + * generateMetadata와 페이지 본문이 동일 요청에서 두 번 호출하므로 React `cache()`로 + * 요청 범위 dedupe합니다. 객체 인자는 참조 기준이라 캐시가 무효화되므로 원시값만 받습니다. + */ +const getCachedProjectDetailShellData = cache( + (projectSlug: string, locale: string): Promise => + getResolvedProject(projectSlug, locale), +); + /** * 프로젝트 상세 본문 shell에 필요한 최소 데이터를 조회합니다. */ @@ -46,7 +59,7 @@ export const getProjectDetailShellData = ({ locale, projectSlug, }: GetProjectDetailPageDataInput): Promise => - getResolvedProject(projectSlug, locale); + getCachedProjectDetailShellData(projectSlug, locale); /** * 프로젝트 상세 좌측 아카이브를 현재 프로젝트를 포함한 초기 slice로 조회합니다. diff --git a/src/views/resume/ui/resume-page.test.tsx b/src/views/resume/ui/resume-page.test.tsx index 59b98414..2e9d9fc1 100644 --- a/src/views/resume/ui/resume-page.test.tsx +++ b/src/views/resume/ui/resume-page.test.tsx @@ -1,24 +1,28 @@ import React from 'react'; import { renderToReadableStream } from 'react-dom/server'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; import { ResumePage } from '@/views/resume/ui/resume-page'; /** * resume 페이지 서버 렌더링 결과를 HTML 문자열로 수집합니다. */ const renderResumePageHtml = async () => { + const QueryClientTestWrapper = createQueryClientWrapper(); const stream = await renderToReadableStream( - , + + + , ); return new Response(stream).text(); diff --git a/src/widgets/admin-pdf-upload/ui/admin-pdf-upload-panel.test.tsx b/src/widgets/admin-pdf-upload/ui/admin-pdf-upload-panel.test.tsx index 9979b2a0..aa0c9d4a 100644 --- a/src/widgets/admin-pdf-upload/ui/admin-pdf-upload-panel.test.tsx +++ b/src/widgets/admin-pdf-upload/ui/admin-pdf-upload-panel.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import React from 'react'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; import { AdminPdfUploadPanel } from '@/widgets/admin-pdf-upload/ui/admin-pdf-upload-panel'; vi.mock('@/entities/pdf-file/api/upload-pdf-file-by-asset-key', () => ({ @@ -71,7 +72,9 @@ describe('AdminPdfUploadPanel', () => { }), ); - render(); + render(, { + wrapper: createQueryClientWrapper(), + }); expect(screen.getByRole('heading', { level: 2, name: 'PDF 파일 관리' })).toBeTruthy(); expect(screen.getByText('ParkChaewon-Resume-kr.pdf')).toBeTruthy(); @@ -105,7 +108,9 @@ describe('AdminPdfUploadPanel', () => { }), ); - render(); + render(, { + wrapper: createQueryClientWrapper(), + }); const resumeInput = screen.getByLabelText('이력서 PDF · 국문 파일 선택', { selector: 'input', @@ -159,7 +164,9 @@ describe('AdminPdfUploadPanel', () => { }), ); - render(); + render(, { + wrapper: createQueryClientWrapper(), + }); const portfolioInput = screen.getByLabelText('포트폴리오 PDF · 국문 파일 선택', { selector: 'input', @@ -224,7 +231,9 @@ describe('AdminPdfUploadPanel', () => { }), ); - render(); + render(, { + wrapper: createQueryClientWrapper(), + }); fireEvent.change(screen.getByLabelText('이력서 PDF · 국문 파일 선택', { selector: 'input' }), { target: { diff --git a/src/widgets/admin-pdf-upload/ui/admin-pdf-upload-panel.tsx b/src/widgets/admin-pdf-upload/ui/admin-pdf-upload-panel.tsx index 2b1548da..d4754714 100644 --- a/src/widgets/admin-pdf-upload/ui/admin-pdf-upload-panel.tsx +++ b/src/widgets/admin-pdf-upload/ui/admin-pdf-upload-panel.tsx @@ -1,13 +1,20 @@ 'use client'; -import React, { useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import React, { useEffect, useRef, useState } from 'react'; import { css, cva } from 'styled-system/css'; import { uploadPdfFileByAssetKey } from '@/entities/pdf-file/api/upload-pdf-file-by-asset-key'; import type { PdfFileAssetKey } from '@/entities/pdf-file/model/types'; +import { pdf } from '@/shared/lib/query/query-keys'; import { Button } from '@/shared/ui/button/button'; import type { AdminPdfUploadItem } from '@/widgets/admin-pdf-upload/model/admin-pdf-upload.types'; +type AdminPdfAvailabilityItem = { + assetKey: PdfFileAssetKey; + isPdfReady: boolean; +}; + type AdminPdfUploadPanelProps = { initialItems: AdminPdfUploadItem[]; }; @@ -32,9 +39,9 @@ const createUploadErrorMessage = (title: string) => * 대시보드에서 PDF 자산 4종을 개별 업로드/다운로드 확인할 수 있는 관리자 패널입니다. */ export const AdminPdfUploadPanel = ({ initialItems }: AdminPdfUploadPanelProps) => { + const queryClient = useQueryClient(); const inputRefs = useRef>>({}); const [items, setItems] = useState(initialItems); - const [isCheckingAvailability, setIsCheckingAvailability] = useState(true); const [feedbackByAssetKey, setFeedbackByAssetKey] = useState< Partial> >({}); @@ -42,60 +49,39 @@ export const AdminPdfUploadPanel = ({ initialItems }: AdminPdfUploadPanelProps) Partial> >({}); - React.useEffect(() => { - const abortController = new AbortController(); + const { data: availabilityItems, isLoading: isCheckingAvailability } = useQuery({ + queryFn: async ({ signal }): Promise => { + const response = await fetch('/api/pdf/admin/availability', { + method: 'GET', + signal, + }); - const loadAvailability = async () => { - setIsCheckingAvailability(true); - - try { - const response = await fetch('/api/pdf/admin/availability', { - method: 'GET', - signal: abortController.signal, - }); + if (!response.ok) { + throw new Error(`Failed to load admin PDF availability: ${response.status}`); + } - if (!response.ok) { - throw new Error(`Failed to load admin PDF availability: ${response.status}`); - } - - const data = (await response.json()) as { - items: Array<{ - assetKey: PdfFileAssetKey; - isPdfReady: boolean; - }>; - }; - const readinessByAssetKey = new Map( - data.items.map(item => [item.assetKey, item.isPdfReady] as const), - ); + const data = (await response.json()) as { items: AdminPdfAvailabilityItem[] }; - setItems(previous => - previous.map(item => ({ - ...item, - // 업로드 직후 true로 바뀐 상태는 늦게 도착한 초기 조회가 false로 덮지 않도록 유지합니다. - isPdfReady: item.isPdfReady || (readinessByAssetKey.get(item.assetKey) ?? false), - })), - ); - } catch (error) { - if (abortController.signal.aborted) { - return; - } + return data.items; + }, + queryKey: pdf.adminAvailability, + }); - console.error('[admin-pdf-upload] availability fetch failed', { - error, - }); - } finally { - if (!abortController.signal.aborted) { - setIsCheckingAvailability(false); - } - } - }; + useEffect(() => { + if (!availabilityItems) return; - void loadAvailability(); + const readinessByAssetKey = new Map( + availabilityItems.map(item => [item.assetKey, item.isPdfReady] as const), + ); - return () => { - abortController.abort(); - }; - }, []); + setItems(previous => + previous.map(item => ({ + ...item, + // 업로드 직후 true로 바뀐 상태는 늦게 도착한 초기 조회가 false로 덮지 않도록 유지합니다. + isPdfReady: item.isPdfReady || (readinessByAssetKey.get(item.assetKey) ?? false), + })), + ); + }, [availabilityItems]); /** * 파일 선택 버튼에서 숨겨진 input 클릭을 위임합니다. @@ -149,6 +135,9 @@ export const AdminPdfUploadPanel = ({ initialItems }: AdminPdfUploadPanelProps) tone: 'success', }, })); + // 업로드 성공 후 어드민 가용성과 공개 다운로드 옵션 캐시를 무효화합니다. + void queryClient.invalidateQueries({ queryKey: pdf.adminAvailability }); + void queryClient.invalidateQueries({ queryKey: [...pdf.all, 'options'] }); } catch (error) { const message = error instanceof Error && error.message diff --git a/src/widgets/article-comments/ui/article-comments-section.interactions.test.tsx b/src/widgets/article-comments/ui/article-comments-section.interactions.test.tsx index 68ce2a72..e86eb86e 100644 --- a/src/widgets/article-comments/ui/article-comments-section.interactions.test.tsx +++ b/src/widgets/article-comments/ui/article-comments-section.interactions.test.tsx @@ -1,9 +1,12 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { ARTICLE_COMMENT_ERROR_CODE } from '@/entities/article/comment/error'; import { actionPopoverRenderCount, composeFormSpy, initialPage, + mockedDeleteArticleCommentAction, + mockedGetArticleCommentsPageAction, renderArticleCommentsSection, resetArticleCommentsSectionTestState, sortButtonRenderCount, @@ -117,6 +120,61 @@ describe('ArticleCommentsSection interactions', () => { expect(sortButtonRenderCount.value).toBe(2); }); + it('정렬을 오래된순으로 바꾸면 1페이지를 새로 조회한다', async () => { + mockedGetArticleCommentsPageAction.mockResolvedValue({ + data: { ...initialPage, page: 1, sort: 'oldest' }, + errorMessage: null, + ok: true, + }); + + renderArticleCommentsSection({ initialPage }); + + fireEvent.click(screen.getByRole('tab', { name: 'sortOldest' })); + + await waitFor(() => { + expect(mockedGetArticleCommentsPageAction).toHaveBeenCalledWith( + expect.objectContaining({ + articleId: 'article-1', + page: 1, + sort: 'oldest', + }), + ); + }); + }); + + it('삭제 모달에서 비밀번호가 틀리면 오류 메시지를 노출한다', async () => { + mockedDeleteArticleCommentAction.mockResolvedValue({ + data: null, + errorCode: ARTICLE_COMMENT_ERROR_CODE.invalidPassword, + errorMessage: 'invalid password', + ok: false, + }); + + renderArticleCommentsSection({ initialPage }); + + fireEvent.click(screen.getAllByRole('button', { name: 'actionMenuLabel' })[0]); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'delete' })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole('button', { name: 'delete' })); + + fireEvent.change(screen.getByLabelText('password'), { + target: { value: 'wrong-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'deleteConfirm' })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('secretVerifyFailed'); + }); + expect(mockedDeleteArticleCommentAction).toHaveBeenCalledWith( + expect.objectContaining({ + articleId: 'article-1', + commentId: 'comment-1', + password: 'wrong-password', + }), + ); + }); + it('댓글이 없으면 정렬 탭과 목록 패널을 렌더하지 않아야 한다', () => { renderArticleCommentsSection({ initialPage: { diff --git a/src/widgets/article-comments/ui/article-comments-section.test-support.tsx b/src/widgets/article-comments/ui/article-comments-section.test-support.tsx index 080f455c..f29d3181 100644 --- a/src/widgets/article-comments/ui/article-comments-section.test-support.tsx +++ b/src/widgets/article-comments/ui/article-comments-section.test-support.tsx @@ -2,10 +2,8 @@ import { render } from '@testing-library/react'; import React from 'react'; import type { ArticleCommentPage } from '@/entities/article/comment/model'; -import { - ArticleCommentsSection, - resetArticleCommentsPageCacheForTest, -} from '@/widgets/article-comments/ui/article-comments-section'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; +import { ArticleCommentsSection } from '@/widgets/article-comments/ui/article-comments-section'; import '@testing-library/jest-dom/vitest'; @@ -27,6 +25,8 @@ const mockedCommentActions = vi.hoisted(() => ({ export const composeFormSpy = vi.fn(); export const actionPopoverRenderCount = hoistedRenderState.actionPopoverRenderCount; export const mockedGetArticleCommentsPageAction = mockedCommentActions.getArticleCommentsPageAction; +export const mockedDeleteArticleCommentAction = mockedCommentActions.deleteArticleCommentAction; +export const mockedUpdateArticleCommentAction = mockedCommentActions.updateArticleCommentAction; export const sortButtonRenderCount = hoistedRenderState.sortButtonRenderCount; vi.mock('next-intl', () => ({ @@ -196,16 +196,21 @@ export const initialPage: ArticleCommentPage = { /** * 댓글 섹션 테스트 공통 상태를 초기화합니다. + * + * 기존 Map 캐시 리셋 대신, `renderArticleCommentsSection`이 렌더마다 새 테스트 + * QueryClient를 생성하므로 캐시 격리는 렌더 시점에 자동으로 보장됩니다. */ export const resetArticleCommentsSectionTestState = () => { vi.clearAllMocks(); actionPopoverRenderCount.value = 0; sortButtonRenderCount.value = 0; - resetArticleCommentsPageCacheForTest(); }; /** * 기본 props로 댓글 섹션을 렌더링합니다. + * + * 매 렌더마다 새 테스트 QueryClientProvider로 감싸 테스트 간 React Query 캐시가 + * 공유되지 않도록 격리합니다. */ export const renderArticleCommentsSection = (props?: { articleId?: string; @@ -218,6 +223,7 @@ export const renderArticleCommentsSection = (props?: { initialPage={props?.initialPage} locale={props?.locale ?? 'ko'} />, + { wrapper: createQueryClientWrapper() }, ); /** diff --git a/src/widgets/article-comments/ui/article-comments-section.tsx b/src/widgets/article-comments/ui/article-comments-section.tsx index 121266d5..7896b4cb 100644 --- a/src/widgets/article-comments/ui/article-comments-section.tsx +++ b/src/widgets/article-comments/ui/article-comments-section.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslations } from 'next-intl'; import React, { useActionState, @@ -23,14 +24,9 @@ import { deleteArticleCommentAction } from '@/features/article-comment/api/delet import { getArticleCommentsPageAction } from '@/features/article-comment/api/get-article-comments-page'; import { submitArticleComment } from '@/features/article-comment/api/submit-article-comment'; import { updateArticleCommentAction } from '@/features/article-comment/api/update-article-comment'; -import { - cacheArticleCommentsPage, - DEFAULT_INITIAL_PAGE, - getCachedArticleCommentsPage, - invalidateArticleCommentsCache, - resetArticleCommentsPageCacheForTest, -} from '@/features/article-comment/model/article-comments-page-cache'; import { initialSubmitArticleCommentState } from '@/features/article-comment/model/submit-article-comment.state'; +import { useArticleCommentsPage } from '@/features/article-comment/model/use-article-comments-page'; +import { articleComments } from '@/shared/lib/query/query-keys'; import { Button } from '@/shared/ui/button/button'; import { CommentComposeForm } from '@/shared/ui/comment-compose'; import { Input } from '@/shared/ui/input/input'; @@ -58,7 +54,19 @@ type ModalState = { const LOAD_LAST_PAGE = 9999; const TOAST_DURATION_MS = 2600; -export { resetArticleCommentsPageCacheForTest }; + +/** + * 초기 페이지 데이터가 없을 때 사용하는 빈 댓글 페이지 기본값입니다. + * (삭제된 브라우저 Map 캐시 모듈의 `DEFAULT_INITIAL_PAGE`를 대체합니다.) + */ +const DEFAULT_INITIAL_PAGE: ArticleCommentPage = { + items: [], + page: 1, + pageSize: 10, + sort: 'latest', + totalCount: 0, + totalPages: 0, +}; /** * 아티클 상세 하단 댓글 섹션 위젯입니다. @@ -69,6 +77,7 @@ export const ArticleCommentsSection = ({ locale, }: ArticleCommentsSectionProps) => { const t = useTranslations('ArticleComments'); + const queryClient = useQueryClient(); const titleId = useId(); const modalTitleId = useId(); const modalDescriptionId = useId(); @@ -84,28 +93,57 @@ export const ArticleCommentsSection = ({ ); const lastHandledRootSubmitStateRef = useRef(rootSubmitState); const lastHandledReplySubmitStateRef = useRef(replySubmitState); - const cachedInitialPage = initialPage - ? null - : getCachedArticleCommentsPage({ - articleId, - page: DEFAULT_INITIAL_PAGE.page, - sort: DEFAULT_INITIAL_PAGE.sort, - }); - const resolvedInitialPage = initialPage ?? cachedInitialPage ?? DEFAULT_INITIAL_PAGE; - const [pageData, setPageData] = useState(resolvedInitialPage); + const resolvedInitialPage = initialPage ?? DEFAULT_INITIAL_PAGE; const [queryState, setQueryState] = useState({ page: resolvedInitialPage.page, sort: resolvedInitialPage.sort, }); - const [isLoading, setIsLoading] = useState(!initialPage && !cachedInitialPage); - const [errorMessage, setErrorMessage] = useState(null); + + const { + data, + error, + isPending, + isPlaceholderData, + refetch: refetchCommentsPage, + } = useArticleCommentsPage({ + articleId, + initialData: initialPage, + locale, + page: queryState.page, + sort: queryState.sort, + }); + + const pageData = data ?? DEFAULT_INITIAL_PAGE; + const isLoading = isPending && !isPlaceholderData; + const errorMessage = error?.message ?? null; + const [replyTarget, setReplyTarget] = useState(null); const [modalState, setModalState] = useState(null); const [modalContent, setModalContent] = useState(''); const [modalPassword, setModalPassword] = useState(''); const [modalError, setModalError] = useState(null); - const [isModalSubmitting, setIsModalSubmitting] = useState(false); const [toasts, setToasts] = useState([]); + + const updateCommentMutation = useMutation({ + mutationFn: (variables: { commentId: string; content: string; password: string }) => + updateArticleCommentAction({ + articleId, + commentId: variables.commentId, + content: variables.content, + locale, + password: variables.password, + }), + }); + const deleteCommentMutation = useMutation({ + mutationFn: (variables: { commentId: string; password: string }) => + deleteArticleCommentAction({ + articleId, + commentId: variables.commentId, + locale, + password: variables.password, + }), + }); + const isModalSubmitting = updateCommentMutation.isPending || deleteCommentMutation.isPending; const text = useMemo(() => createArticleCommentsText(t), [t]); const activeReplyPlaceholder = useMemo( () => @@ -146,50 +184,53 @@ export const ArticleCommentsSection = ({ }, []); /** - * 지정된 페이지/정렬 기준으로 댓글 페이지를 다시 조회합니다. + * 뮤테이션(작성/수정/삭제) 성공 후 대상 페이지를 결정적으로 재조회합니다. + * + * 낙관적 업데이트 대신 `fresh: true`로 서버 최신본을 받아 캐시에 심고 + * (`setQueryData`), 표시 상태(`queryState`)를 서버가 clamp한 페이지/정렬로 + * 맞춘 뒤, 형제 페이지들은 `refetchType: 'none'`으로 stale만 표시해 다음 + * 접근 시 새로 조회되도록 합니다. + * + * 재조회 자체가 실패하더라도 뮤테이션은 이미 서버에 반영된 상태이므로, + * 활성 페이지를 포함해 즉시 refetch하는 무효화로 UI가 자동 복구되게 합니다. */ - const loadPage = useCallback( - async ( - nextPage: number, - nextSort: ArticleCommentsSort, - options?: { - fresh?: boolean; - }, - ) => { - setQueryState({ - page: nextPage, - sort: nextSort, - }); - setIsLoading(true); - setErrorMessage(null); + const refreshCommentsPage = useCallback( + async (nextPage: number, nextSort: ArticleCommentsSort) => { + let refreshedPage: Awaited>['data'] = null; try { const result = await getArticleCommentsPageAction({ articleId, - fresh: options?.fresh, + fresh: true, locale, page: nextPage, sort: nextSort, }); + if (result.ok) refreshedPage = result.data; + } catch { + // 아래 복구 무효화 경로로 넘어갑니다. + } - if (!result.ok || !result.data) { - setErrorMessage(result.errorMessage ?? 'article-comments-load-failed'); - return; - } - - setPageData(result.data); - cacheArticleCommentsPage(result.data, articleId); - setQueryState({ - page: result.data.page, - sort: result.data.sort, + if (!refreshedPage) { + await queryClient.invalidateQueries({ + queryKey: articleComments.scope(articleId), }); - } catch (_error) { - setErrorMessage(t('loadError')); - } finally { - setIsLoading(false); + return; } + queryClient.setQueryData( + articleComments.page(articleId, locale, refreshedPage.sort, refreshedPage.page), + refreshedPage, + ); + setQueryState({ + page: refreshedPage.page, + sort: refreshedPage.sort, + }); + queryClient.invalidateQueries({ + queryKey: articleComments.scope(articleId), + refetchType: 'none', + }); }, - [articleId, locale, t], + [articleId, locale, queryClient], ); const closeModal = useCallback(() => { @@ -200,22 +241,18 @@ export const ArticleCommentsSection = ({ }, []); useEffect(() => { - if (initialPage) { - cacheArticleCommentsPage(initialPage, articleId); - return; - } - if (cachedInitialPage) return; + if (!data || isPlaceholderData) return; + if (data.page === queryState.page && data.sort === queryState.sort) return; - void loadPage(DEFAULT_INITIAL_PAGE.page, DEFAULT_INITIAL_PAGE.sort); - }, [articleId, cachedInitialPage, initialPage, loadPage]); + setQueryState({ + page: data.page, + sort: data.sort, + }); + }, [data, isPlaceholderData, queryState.page, queryState.sort]); - const handleChangeSort = useCallback( - (sort: ArticleCommentsSort) => { - if (sort === queryState.sort) return; - void loadPage(1, sort); - }, - [loadPage, queryState.sort], - ); + const handleChangeSort = useCallback((sort: ArticleCommentsSort) => { + setQueryState(previous => (previous.sort === sort ? previous : { page: 1, sort })); + }, []); const handleReply = useCallback((thread: ArticleCommentThreadItem, entry: ArticleComment) => { setReplyTarget({ @@ -269,15 +306,12 @@ export const ArticleCommentsSection = ({ } setModalError(null); - setIsModalSubmitting(true); try { if (modalState.mode === 'edit') { - const result = await updateArticleCommentAction({ - articleId, + const result = await updateCommentMutation.mutateAsync({ commentId: modalState.entry.id, content: trimmedContent, - locale, password: trimmedPassword, }); if (!result.ok || !result.data) { @@ -289,50 +323,44 @@ export const ArticleCommentsSection = ({ pushToast(text.toastEditError, 'error'); return; } + + closeModal(); + await refreshCommentsPage(pageData.page, pageData.sort); pushToast(text.toastEditSuccess, 'success'); + return; } - if (modalState.mode === 'delete') { - const result = await deleteArticleCommentAction({ - articleId, - commentId: modalState.entry.id, - locale, - password: trimmedPassword, - }); - if (!result.ok || !result.data) { - if (result.errorCode === ARTICLE_COMMENT_ERROR_CODE.invalidPassword) { - setModalError(text.secretVerifyFailed); - return; - } - - pushToast(text.toastDeleteError, 'error'); + const result = await deleteCommentMutation.mutateAsync({ + commentId: modalState.entry.id, + password: trimmedPassword, + }); + if (!result.ok || !result.data) { + if (result.errorCode === ARTICLE_COMMENT_ERROR_CODE.invalidPassword) { + setModalError(text.secretVerifyFailed); return; } - pushToast(text.toastDeleteSuccess, 'success'); + + pushToast(text.toastDeleteError, 'error'); + return; } - invalidateArticleCommentsCache(articleId); closeModal(); - await loadPage(pageData.page, pageData.sort, { - fresh: true, - }); + await refreshCommentsPage(pageData.page, pageData.sort); + pushToast(text.toastDeleteSuccess, 'success'); } catch (_error) { pushToast(modalState.mode === 'edit' ? text.toastEditError : text.toastDeleteError, 'error'); - } finally { - setIsModalSubmitting(false); } }, [ - articleId, closeModal, + deleteCommentMutation, isModalSubmitting, - loadPage, - locale, modalContent, modalPassword, modalState, pageData.page, pageData.sort, pushToast, + refreshCommentsPage, text.editContentUnchanged, text.requiredField, text.secretVerifyFailed, @@ -340,6 +368,7 @@ export const ArticleCommentsSection = ({ text.toastDeleteSuccess, text.toastEditError, text.toastEditSuccess, + updateCommentMutation, ]); const modalTitle = useMemo(() => { @@ -359,16 +388,12 @@ export const ArticleCommentsSection = ({ return; } - invalidateArticleCommentsCache(articleId); - void loadPage(pageData.sort === 'latest' ? 1 : LOAD_LAST_PAGE, pageData.sort, { - fresh: true, - }); + void refreshCommentsPage(pageData.sort === 'latest' ? 1 : LOAD_LAST_PAGE, pageData.sort); pushToast(text.toastCreateSuccess, 'success'); }, [ - articleId, - loadPage, pageData.sort, pushToast, + refreshCommentsPage, rootSubmitState, text.toastCreateError, text.toastCreateSuccess, @@ -386,34 +411,27 @@ export const ArticleCommentsSection = ({ } setReplyTarget(null); - invalidateArticleCommentsCache(articleId); - void loadPage(pageData.page, pageData.sort, { - fresh: true, - }); + void refreshCommentsPage(pageData.page, pageData.sort); pushToast(text.toastReplySuccess, 'success'); }, [ - articleId, - loadPage, pageData.page, pageData.sort, pushToast, + refreshCommentsPage, replySubmitState, text.toastReplyError, text.toastReplySuccess, ]); const handleRetryLoad = useCallback(() => { - void loadPage(pageData.page, pageData.sort); - }, [loadPage, pageData.page, pageData.sort]); + void refetchCommentsPage(); + }, [refetchCommentsPage]); const handleReplyTargetReset = useCallback(() => { setReplyTarget(null); }, []); - const handlePaginationChange = useCallback( - (page: number) => { - void loadPage(page, queryState.sort); - }, - [loadPage, queryState.sort], - ); + const handlePaginationChange = useCallback((page: number) => { + setQueryState(previous => ({ page, sort: previous.sort })); + }, []); const handleModalContentChange = useCallback((value: string) => { setModalContent(value); setModalError(previous => (previous ? null : previous)); diff --git a/src/widgets/detail-page/archive/feed.test.tsx b/src/widgets/detail-page/archive/feed.test.tsx index 4c7e23d3..5ec4bbb2 100644 --- a/src/widgets/detail-page/archive/feed.test.tsx +++ b/src/widgets/detail-page/archive/feed.test.tsx @@ -1,15 +1,12 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { vi } from 'vitest'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; import { DetailArchiveFeed } from '@/widgets/detail-page/archive/feed'; import '@testing-library/jest-dom/vitest'; -vi.mock('@/shared/lib/react/use-cursor-pagination-feed', () => ({ - useCursorPaginationFeed: vi.fn(), -})); - vi.mock('@/i18n/navigation', () => ({ Link: ({ children, href, ...props }: React.AnchorHTMLAttributes) => ( @@ -32,15 +29,33 @@ type TestArchiveItem = { }; /** - * 상세 아카이브 피드 훅 목을 반환합니다. + * 테스트용 아카이브 항목을 생성합니다. + * + * @param id 항목 id입니다. + * @param overrides 덮어쓸 필드입니다. + * @returns 아카이브 항목입니다. */ -const getUseCursorPaginationFeedMock = async () => { - const feedModule = await import('@/shared/lib/react/use-cursor-pagination-feed'); - - return vi.mocked(feedModule.useCursorPaginationFeed); -}; +const createArchiveItem = ( + id: string, + overrides: Partial = {}, +): TestArchiveItem => ({ + created_at: '2026-03-08T00:00:00.000Z', + description: `${id} 요약`, + id, + publish_at: '2026-03-08T00:00:00.000Z', + slug: `${id}-slug`, + title: `${id} 제목`, + ...overrides, +}); -const loadPageActionMock = vi.fn(); +/** + * `DetailArchiveFeed`를 React Query 래퍼로 감싸 렌더링합니다. + * + * @param ui 렌더링할 엘리먼트입니다. + * @returns Testing Library 렌더 결과입니다. + */ +const renderWithQueryClient = (ui: React.ReactElement) => + render(ui, { wrapper: createQueryClientWrapper() }); describe('DetailArchiveFeed', () => { beforeEach(() => { @@ -73,33 +88,19 @@ describe('DetailArchiveFeed', () => { vi.clearAllMocks(); }); - it('추가 로드 에러가 나면 자동 재시도를 멈추고 retry 버튼으로만 다시 요청한다', async () => { - const useCursorPaginationFeed = await getUseCursorPaginationFeedMock(); - const loadMore = vi.fn(); - useCursorPaginationFeed.mockReturnValue({ - errorMessage: 'load failed', - hasMore: true, - isLoadingMore: false, - items: [ - { - created_at: '2026-03-08T00:00:00.000Z', - description: '요약', - id: 'article-1', - publish_at: '2026-03-08T00:00:00.000Z', - slug: 'article-1-slug', - title: '첫 글', - }, - ] satisfies TestArchiveItem[], - loadMore, + it('추가 로드가 실패하면 자동 재시도를 멈추고 retry 버튼으로만 다시 요청한다', async () => { + const loadPageAction = vi.fn().mockResolvedValue({ + errorCode: 'load failed', + ok: false, }); - const { container } = render( + renderWithQueryClient( emptyText="비어 있음" hrefBasePath="/articles" - initialPage={{ items: [] as TestArchiveItem[], nextCursor: 'cursor-1' }} + initialPage={{ items: [createArchiveItem('article-1')], nextCursor: 'cursor-1' }} loadErrorText="불러오기 실패" - loadPageAction={loadPageActionMock} + loadPageAction={loadPageAction} loadMoreEndText="끝" loadingText="불러오는 중" locale="ko" @@ -108,68 +109,72 @@ describe('DetailArchiveFeed', () => { />, ); - const viewport = container.querySelector('[data-scroll-region="true"]') as HTMLElement; + expect(screen.getByRole('link', { name: /article-1 제목/ })).toBeInTheDocument(); - fireEvent.scroll(viewport); + // 스크롤 의도를 보인 뒤 sentinel 교차로 첫 추가 로드를 트리거합니다. + fireEvent.scroll(window); + await act(async () => { + observerCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + }); - observerCallback?.( - [{ isIntersecting: true } as IntersectionObserverEntry], - {} as IntersectionObserver, - ); - expect(loadMore).not.toHaveBeenCalled(); + await waitFor(() => { + expect(loadPageAction).toHaveBeenCalledTimes(1); + }); + expect(await screen.findByText('불러오기 실패')).toBeInTheDocument(); + + // 에러 상태에서는 sentinel 교차가 다시 발생해도 자동 재요청하지 않습니다. + await act(async () => { + observerCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + }); + expect(loadPageAction).toHaveBeenCalledTimes(1); + // retry 버튼으로만 다시 요청합니다. fireEvent.click(screen.getByRole('button', { name: '다시 시도' })); - expect(screen.getByText('불러오기 실패')).toBeInTheDocument(); - expect(loadMore).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(loadPageAction).toHaveBeenCalledTimes(2); + }); }); it('활성 항목을 viewport 상단 쪽 기준으로 한 번만 스크롤 정렬한다', async () => { - const useCursorPaginationFeed = await getUseCursorPaginationFeedMock(); - useCursorPaginationFeed.mockImplementation(({ initialItems }) => ({ - errorMessage: null, - hasMore: false, - isLoadingMore: false, - items: initialItems, - loadMore: vi.fn(), - })); - - const { container } = render( + const loadPageAction = vi.fn(); + + const { container } = renderWithQueryClient( activeItemViewportOffsetRatio={0.25} emptyText="비어 있음" hrefBasePath="/articles" initialPage={{ items: [ - { - created_at: '2026-03-12T00:00:00.000Z', + createArchiveItem('article-2', { description: '최신 글', - id: 'article-2', publish_at: '2026-03-12T00:00:00.000Z', slug: 'article-2-slug', title: '최신 글', - }, - { - created_at: '2026-03-11T00:00:00.000Z', + }), + createArchiveItem('article-1', { description: '그다음 글', - id: 'article-1', publish_at: '2026-03-11T00:00:00.000Z', slug: 'article-1-slug', title: '그다음 글', - }, - { - created_at: '2026-03-10T00:00:00.000Z', + }), + createArchiveItem('current-article', { description: '현재 글', - id: 'current-article', publish_at: '2026-03-10T00:00:00.000Z', slug: 'current-article-slug', title: '현재 글', - }, + }), ], nextCursor: 'cursor-1', }} loadErrorText="불러오기 실패" - loadPageAction={loadPageActionMock} + loadPageAction={loadPageAction} loadMoreEndText="끝" loadingText="불러오는 중" locale="ko" diff --git a/src/widgets/detail-page/archive/feed.tsx b/src/widgets/detail-page/archive/feed.tsx index ba380902..05cf71d3 100644 --- a/src/widgets/detail-page/archive/feed.tsx +++ b/src/widgets/detail-page/archive/feed.tsx @@ -1,16 +1,19 @@ 'use client'; +import { useInfiniteQuery } from '@tanstack/react-query'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { css } from 'styled-system/css'; +import type { AppLocale } from '@/i18n/routing'; import type { ActionResult } from '@/shared/lib/action/action-result'; import { resolvePublicContentPathSegment, resolvePublicContentPublishedAt, } from '@/shared/lib/content/public-content'; import { formatYear } from '@/shared/lib/date/format-year'; +import { getErrorMessage } from '@/shared/lib/error/get-error-message'; +import { detailArchive, type DetailArchiveDomain } from '@/shared/lib/query/query-keys'; import { useAutoLoadAfterScroll } from '@/shared/lib/react/use-auto-load-after-scroll'; -import { useCursorPaginationFeed } from '@/shared/lib/react/use-cursor-pagination-feed'; import { Button } from '@/shared/ui/button/button'; import { srOnlyClass } from '@/shared/ui/styles/sr-only-style'; import { @@ -48,11 +51,21 @@ type DetailArchiveFeedProps = { }; const DETAIL_ARCHIVE_LOAD_ERROR_CODE = 'detailArchive.loadFailed'; +const DETAIL_ARCHIVE_PAGE_LIMIT = 10; const EMPTY_DETAIL_ARCHIVE_PAGE = { items: [], nextCursor: null, } satisfies DetailArchivePage; +/** + * 부트스트랩이 아직 끝나지 않았을 때 쿼리 키에 사용하는 시드 토큰입니다. + * + * 부트스트랩 중에는 시드가 빈 페이지이므로, 실제 시드가 준비되면 키가 반드시 + * 갈라지도록 별도 토큰을 사용합니다. 이렇게 하지 않으면 단일 페이지 아카이브에서 + * 빈 시드의 `initialData`가 그대로 캐시에 남아 목록이 비어 보일 수 있습니다. + */ +const DETAIL_ARCHIVE_BOOTSTRAPPING_SEED_KEY = '__bootstrapping__'; + /** * 상세 페이지 좌측 아카이브 목록에 cursor 기반 추가 로드를 붙입니다. */ @@ -82,20 +95,31 @@ export const DetailArchiveFeed = ({ }); const resolvedInitialPage = bootstrapPage ?? (EMPTY_DETAIL_ARCHIVE_PAGE as DetailArchivePage); - const loadPage = useCallback( - async ({ - cursor, - limit, - locale: nextLocale, - }: { - cursor: string | null; - limit: number; - locale: string; - }) => { + // 서로 다른 상세 페이지가 같은 커서 시드(예: 마지막 윈도우의 'root')를 공유해도 + // 이전 페이지의 캐시가 재사용되지 않도록 현재 아이템 식별자를 시드에 포함합니다. + const seedKey = `${selectedPathSegment}:${ + isBootstrapping + ? DETAIL_ARCHIVE_BOOTSTRAPPING_SEED_KEY + : (resolvedInitialPage.nextCursor ?? 'root') + }`; + const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + enabled: !isBootstrapping && !bootstrapError, + getNextPageParam: lastPage => lastPage.nextCursor, + initialData: { + pageParams: [null], + pages: [ + { + items: resolvedInitialPage.items, + nextCursor: resolvedInitialPage.nextCursor, + }, + ], + }, + initialPageParam: resolvedInitialPage.nextCursor, + queryFn: async ({ pageParam }) => { const result = await loadPageAction({ - cursor, - limit, - locale: nextLocale, + cursor: pageParam, + limit: DETAIL_ARCHIVE_PAGE_LIMIT, + locale, }); if (!result.ok || !result.data) { @@ -107,16 +131,30 @@ export const DetailArchiveFeed = ({ nextCursor: result.data.nextCursor, }; }, - [loadPageAction], + queryKey: detailArchive.feed( + resolveDetailArchiveDomain(hrefBasePath), + locale as AppLocale, + seedKey, + ), + staleTime: Infinity, + }); + + const items = useMemo( + () => + (data?.pages ?? []).reduce( + (mergedItems, page) => mergeDetailArchiveFeedItems(mergedItems, page.items), + [], + ), + [data], ); + const errorMessage = error ? getErrorMessage(error) : null; + const isLoadingMore = isFetchingNextPage; + const hasMore = hasNextPage; + const loadMore = useCallback(async () => { + if (!hasNextPage || isFetchingNextPage) return; - const { errorMessage, hasMore, isLoadingMore, items, loadMore } = useCursorPaginationFeed({ - initialCursor: resolvedInitialPage.nextCursor, - initialItems: resolvedInitialPage.items, - loadPage, - locale, - mergeItems: mergeDetailArchiveFeedItems, - }); + await fetchNextPage(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); const isAutoLoadEnabled = useAutoLoadAfterScroll(); const sentinelRef = useRef(null); const viewportRef = useRef(null); @@ -246,6 +284,19 @@ const alignActiveArchiveItemInViewport = ( }); }; +/** + * 아카이브가 렌더되는 화면(글/프로젝트 상세)을 쿼리 키 도메인 값으로 변환합니다. + * + * 두 상세 화면의 아카이브 캐시가 서로 섞이지 않도록 `hrefBasePath`를 기준으로 + * 도메인을 구분합니다. `/articles`로 시작하면 글 아카이브, 그 외에는 프로젝트 + * 아카이브로 취급합니다. + * + * @param hrefBasePath 아카이브 링크의 기준 경로입니다. + * @returns 쿼리 키에 사용할 아카이브 도메인 값입니다. + */ +const resolveDetailArchiveDomain = (hrefBasePath: string): DetailArchiveDomain => + hrefBasePath.startsWith('/articles') ? 'article' : 'project'; + type BuildDetailArchiveLinkItemsInput = { hrefBasePath: string; items: TItem[]; diff --git a/src/widgets/editor/ui/core/editor-core.test.tsx b/src/widgets/editor/ui/core/editor-core.test.tsx index 85782cdd..7a57e5f8 100644 --- a/src/widgets/editor/ui/core/editor-core.test.tsx +++ b/src/widgets/editor/ui/core/editor-core.test.tsx @@ -356,7 +356,8 @@ describe('EditorCore', () => { 'autosave는 마지막 입력 후 180초 뒤 한 번만 실행된다', async () => { vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-03-12T09:07:00+09:00')); + // 실행 타임존과 무관하게 로컬 09:07이 되도록 로컬 컴포넌트로 고정한다("저장됨 09:10" 라벨 검증). + vi.setSystemTime(new Date(2026, 2, 12, 9, 7)); const onDraftSave = vi.fn().mockResolvedValue(undefined); renderEditorCore({ onDraftSave }); diff --git a/src/widgets/editor/ui/core/editor-core.utils.test.ts b/src/widgets/editor/ui/core/editor-core.utils.test.ts index 482e9505..c4f591cc 100644 --- a/src/widgets/editor/ui/core/editor-core.utils.test.ts +++ b/src/widgets/editor/ui/core/editor-core.utils.test.ts @@ -98,7 +98,8 @@ describe('editor-core utils', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z')); - expect(formatSavedAtLabel('2026-03-12T09:07:00+09:00')).toBe('09:07'); + // 실행 타임존(로컬/CI-UTC)과 무관하게 로컬 09:07을 가리키도록 로컬 컴포넌트로 생성한다. + expect(formatSavedAtLabel(new Date(2026, 2, 12, 9, 7).toISOString())).toBe('09:07'); vi.useRealTimers(); }); }); diff --git a/src/widgets/editor/ui/publish/publish-panel.test.tsx b/src/widgets/editor/ui/publish/publish-panel.test.tsx index d0736ea1..a37c779e 100644 --- a/src/widgets/editor/ui/publish/publish-panel.test.tsx +++ b/src/widgets/editor/ui/publish/publish-panel.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { createEditorError, EDITOR_ERROR_MESSAGE } from '@/entities/editor/model/editor-error'; import { optimizeThumbnailImageFile } from '@/shared/lib/image/optimize-thumbnail-image-file'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; import type { PublishSettings } from '@/widgets/editor/ui/core/editor-core.types'; import { PublishPanel } from '@/widgets/editor/ui/publish/publish-panel'; import { createDefaultPublishSettings } from '@/widgets/editor/ui/publish/publish-panel.utils'; @@ -56,6 +57,7 @@ const renderPublishPanel = ( onClose={onClose} onSubmit={onSubmit} />, + { wrapper: createQueryClientWrapper() }, ); return { @@ -109,7 +111,9 @@ describe('PublishPanel', () => { target: { value: '10:00' }, }); - expect(await screen.findByText('UTC: 2026-03-20T01:00:00.000Z')).toBeTruthy(); + // 실행 타임존과 무관하게 "로컬 입력 → UTC 변환" 계약을 검증한다. + const expectedUtcLabel = `UTC: ${new Date(2026, 2, 20, 10, 0).toISOString()}`; + expect(await screen.findByText(expectedUtcLabel)).toBeTruthy(); }); it('이미 발행된 글을 수정할 때는 publishAt이 있어도 기본 모드를 지금 발행으로 둔다', async () => { @@ -367,7 +371,7 @@ describe('PublishPanel', () => { ); }; - render(); + render(, { wrapper: createQueryClientWrapper() }); await waitFor(() => { expect(screen.getByRole('textbox', { name: '슬러그' })).toHaveValue('draft-slug'); @@ -413,7 +417,7 @@ describe('PublishPanel', () => { ); }; - render(); + render(, { wrapper: createQueryClientWrapper() }); const thumbnailInput = await screen.findByLabelText('썸네일'); diff --git a/src/widgets/editor/ui/publish/publish-panel.tsx b/src/widgets/editor/ui/publish/publish-panel.tsx index 2bb2d83a..1fbecdff 100644 --- a/src/widgets/editor/ui/publish/publish-panel.tsx +++ b/src/widgets/editor/ui/publish/publish-panel.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { css } from 'styled-system/css'; @@ -10,6 +11,7 @@ import { parseEditorError, resolveEditorPublishInlineErrorField, } from '@/entities/editor/model/editor-error'; +import { editor, type EditorSlugCheckType } from '@/shared/lib/query/query-keys'; import { normalizeSlugInput } from '@/shared/lib/slug/slug'; import { Button } from '@/shared/ui/button/button'; import { SlideOver } from '@/shared/ui/slide-over/slide-over'; @@ -108,6 +110,7 @@ export const PublishPanel = ({ onSettingsChange, onSubmit, }: PublishPanelProps) => { + const queryClient = useQueryClient(); const showPublishCommentsSetting = shouldShowPublishCommentsSetting(contentType); const [slug, setSlug] = useState(''); const [visibility, setVisibility] = useState('public'); @@ -285,37 +288,48 @@ export const PublishPanel = ({ */ const handleCheckDuplicate = useCallback( async (nextSlug: string) => { - const searchParams = new URLSearchParams({ - slug: nextSlug, - type: contentType, + // 디바운스 체크와 제출 시 재체크가 같은 키로 dedupe되도록 React Query 캐시를 경유합니다. + const duplicate = await queryClient.fetchQuery({ + queryFn: async ({ signal }) => { + const searchParams = new URLSearchParams({ + slug: nextSlug, + type: contentType, + }); + + if (contentId) { + searchParams.set('excludeId', contentId); + } + + const response = await fetch(`/api/editor/slug-check?${searchParams.toString()}`, { + signal, + }); + const body = (await response.json()) as { + duplicate?: boolean; + error?: string; + message?: string; + }; + + if (!response.ok || typeof body.duplicate !== 'boolean') { + throw createEditorError( + 'slugCheckFailed', + body.error ?? body.message ?? EDITOR_ERROR_MESSAGE.slugCheckFailed, + ); + } + + return body.duplicate; + }, + queryKey: editor.slugCheck(contentType as EditorSlugCheckType, nextSlug, contentId), + staleTime: 30_000, }); - if (contentId) { - searchParams.set('excludeId', contentId); - } - - const response = await fetch(`/api/editor/slug-check?${searchParams.toString()}`); - const body = (await response.json()) as { - duplicate?: boolean; - error?: string; - message?: string; - }; - - if (!response.ok || typeof body.duplicate !== 'boolean') { - throw createEditorError( - 'slugCheckFailed', - body.error ?? body.message ?? EDITOR_ERROR_MESSAGE.slugCheckFailed, - ); - } - - if (!body.duplicate) { + if (!duplicate) { verifiedSlugRef.current = nextSlug; setErrors(previous => (previous.slug ? { ...previous, slug: undefined } : previous)); } - return body.duplicate; + return duplicate; }, - [contentId, contentType], + [contentId, contentType, queryClient], ); /** diff --git a/src/widgets/editor/ui/publish/publish-panel.utils.test.ts b/src/widgets/editor/ui/publish/publish-panel.utils.test.ts index d72f59ec..29ed628b 100644 --- a/src/widgets/editor/ui/publish/publish-panel.utils.test.ts +++ b/src/widgets/editor/ui/publish/publish-panel.utils.test.ts @@ -13,7 +13,9 @@ describe('publish-panel utils', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-03-13T00:00:00+09:00')); - expect(toScheduledPublishUtcIso('2026-03-20', '10:00')).toBe('2026-03-20T01:00:00.000Z'); + // 실행 타임존(로컬/CI-UTC)과 무관하게 검증하도록 기대값도 로컬 컴포넌트로 생성한다. + const expectedUtcIso = new Date(2026, 2, 20, 10, 0).toISOString(); + expect(toScheduledPublishUtcIso('2026-03-20', '10:00')).toBe(expectedUtcIso); vi.useRealTimers(); }); diff --git a/src/widgets/home-hero-scene/model/use-home-hero-project-preview.test.tsx b/src/widgets/home-hero-scene/model/use-home-hero-project-preview.test.tsx index 4d918dd9..a5accabf 100644 --- a/src/widgets/home-hero-scene/model/use-home-hero-project-preview.test.tsx +++ b/src/widgets/home-hero-scene/model/use-home-hero-project-preview.test.tsx @@ -3,6 +3,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { vi } from 'vitest'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; import { useHomeHeroProjectPreview } from '@/widgets/home-hero-scene/model/use-home-hero-project-preview'; const EMPTY_ITEMS: [] = []; @@ -15,22 +16,24 @@ describe('useHomeHeroProjectPreview', () => { it('초기 프로젝트가 주어질 때, useHomeHeroProjectPreview는 추가 fetch 없이 초기 목록을 유지해야 한다', () => { const fetchSpy = vi.spyOn(window, 'fetch'); - const { result } = renderHook(() => - useHomeHeroProjectPreview({ - initialItems: [ - { - description: 'description', - id: 'project-1', - period_end: null, - period_start: null, - publish_at: '2026-03-01T00:00:00.000Z', - slug: 'project-1', - thumbnail_url: null, - title: 'Project 1', - }, - ], - locale: 'ko', - }), + const { result } = renderHook( + () => + useHomeHeroProjectPreview({ + initialItems: [ + { + description: 'description', + id: 'project-1', + period_end: null, + period_start: null, + publish_at: '2026-03-01T00:00:00.000Z', + slug: 'project-1', + thumbnail_url: null, + title: 'Project 1', + }, + ], + locale: 'ko', + }), + { wrapper: createQueryClientWrapper() }, ); expect(result.current.isLoading).toBe(false); @@ -58,11 +61,13 @@ describe('useHomeHeroProjectPreview', () => { ok: true, } as Response); - const { result } = renderHook(() => - useHomeHeroProjectPreview({ - initialItems: EMPTY_ITEMS, - locale: 'ko', - }), + const { result } = renderHook( + () => + useHomeHeroProjectPreview({ + initialItems: EMPTY_ITEMS, + locale: 'ko', + }), + { wrapper: createQueryClientWrapper() }, ); expect(result.current.isLoading).toBe(true); diff --git a/src/widgets/home-hero-scene/model/use-home-hero-project-preview.ts b/src/widgets/home-hero-scene/model/use-home-hero-project-preview.ts index 68976f5f..11f9cec6 100644 --- a/src/widgets/home-hero-scene/model/use-home-hero-project-preview.ts +++ b/src/widgets/home-hero-scene/model/use-home-hero-project-preview.ts @@ -1,8 +1,10 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import type { ProjectListItem, ProjectListPage } from '@/entities/project/model/types'; +import type { AppLocale } from '@/i18n/routing'; +import { projects } from '@/shared/lib/query/query-keys'; type UseHomeHeroProjectPreviewParams = Readonly<{ initialItems?: ProjectListItem[]; @@ -15,6 +17,7 @@ type UseHomeHeroProjectPreviewResult = Readonly<{ }>; const EMPTY_PROJECT_ITEMS: ProjectListItem[] = []; +const HOME_HERO_PROJECT_PREVIEW_LIMIT = 3; /** * 프로젝트 목록 API 응답이 홈 히어로 프리뷰 계약과 호환되는지 확인합니다. @@ -29,9 +32,11 @@ const isProjectListPage = (value: unknown): value is ProjectListPage => * 홈 히어로에서 사용할 프로젝트 프리뷰 3개를 후속 조회합니다. * * 홈 첫 진입에서는 frame 이미지(photo)가 즉시 필요하지만, 프로젝트 카드는 - * 스크롤 전환 또는 바텀 시트 열기 이후에야 실제로 보입니다. - * 따라서 서버 첫 렌더를 프로젝트 조회에 묶지 않고, 클라이언트에서 후속 조회해 - * 초기 장면 진입 속도를 우선 확보합니다. + * 스크롤 전환 또는 바텀 시트 열기 이후에야 실제로 보입니다. 따라서 서버 첫 + * 렌더를 프로젝트 조회에 묶지 않고, 클라이언트 마운트 이후 React Query로 후속 + * 조회해 초기 장면 진입 속도를 우선 확보합니다. 서버가 시드를 넘겨준 경우에는 + * `enabled`를 꺼 추가 조회 없이 시드를 그대로 사용하고, 조회에 실패하면 빈 + * 목록을 노출합니다. * * @param params locale과 선택적 초기 프로젝트 목록 * @returns 현재 프로젝트 프리뷰 목록과 로딩 여부 @@ -41,69 +46,37 @@ export const useHomeHeroProjectPreview = ({ locale, }: UseHomeHeroProjectPreviewParams): UseHomeHeroProjectPreviewResult => { const resolvedInitialItems = initialItems ?? EMPTY_PROJECT_ITEMS; - const [items, setItems] = useState(resolvedInitialItems); - const [isLoading, setIsLoading] = useState(resolvedInitialItems.length === 0); - - useEffect(() => { - if (resolvedInitialItems.length > 0) { - setItems(resolvedInitialItems); - setIsLoading(false); - return; - } - - const abortController = new AbortController(); - - const loadProjectPreview = async () => { - setIsLoading(true); - - try { - const searchParams = new URLSearchParams({ - limit: '3', - locale, - }); - const response = await fetch(`/api/projects?${searchParams.toString()}`, { - signal: abortController.signal, - }); - - if (!response.ok) { - throw new Error(`Failed to load home project preview: ${response.status}`); - } - - const body: unknown = await response.json(); - - if (!isProjectListPage(body)) { - throw new Error('Invalid home project preview response'); - } + const hasInitialItems = resolvedInitialItems.length > 0; + + const { data, isLoading } = useQuery({ + enabled: !hasInitialItems, + queryFn: async ({ signal }) => { + const searchParams = new URLSearchParams({ + limit: String(HOME_HERO_PROJECT_PREVIEW_LIMIT), + locale, + }); + const response = await fetch(`/api/projects?${searchParams.toString()}`, { + signal, + }); + + if (!response.ok) { + throw new Error(`Failed to load home project preview: ${response.status}`); + } - if (!abortController.signal.aborted) { - setItems(body.items); - } - } catch (error) { - if (abortController.signal.aborted) { - return; - } + const body: unknown = await response.json(); - console.error('[home-hero] project preview fetch failed', { - error, - locale, - }); - setItems([]); - } finally { - if (!abortController.signal.aborted) { - setIsLoading(false); - } + if (!isProjectListPage(body)) { + throw new Error('Invalid home project preview response'); } - }; - - void loadProjectPreview(); - return () => { - abortController.abort(); - }; - }, [locale, resolvedInitialItems]); + return body.items; + }, + queryKey: projects.preview(locale as AppLocale, HOME_HERO_PROJECT_PREVIEW_LIMIT), + staleTime: Infinity, + }); return { - isLoading, - items, + isLoading: hasInitialItems ? false : isLoading, + items: hasInitialItems ? resolvedInitialItems : (data ?? EMPTY_PROJECT_ITEMS), }; }; diff --git a/src/widgets/home-hero-scene/ui/home-hero-scene.test.tsx b/src/widgets/home-hero-scene/ui/home-hero-scene.test.tsx index bdc5eeeb..11bac66e 100644 --- a/src/widgets/home-hero-scene/ui/home-hero-scene.test.tsx +++ b/src/widgets/home-hero-scene/ui/home-hero-scene.test.tsx @@ -1,12 +1,22 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render as rtlRender, screen } from '@testing-library/react'; import React from 'react'; +import { createQueryClientWrapper } from '@/shared/lib/test/render-with-query-client'; import { HomeHeroScene } from '@/widgets/home-hero-scene/ui/home-hero-scene'; import '@testing-library/jest-dom/vitest'; +/** + * `HomeHeroScene`는 프로젝트 프리뷰를 React Query로 후속 조회하므로, + * 모든 렌더를 테스트용 QueryClientProvider로 감싸 렌더링합니다. + * + * @param ui 렌더링할 엘리먼트입니다. + * @returns Testing Library 렌더 결과입니다. + */ +const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: createQueryClientWrapper() }); + const HOME_HERO_PHOTO_ITEMS = [ { alt: 'Hero photo 1',