๐ ๋ชฉ์ฐจ
- ์๋ก
- ClientOnly ์ปดํฌ๋ํธ๋ ๋ญ๊น?
- Next ๊ณต์ ๋ฌธ์์ ๋์ ์๋ ClientOnly ์ปดํฌ๋ํธ
- Next ๊ณต์ ๋ฌธ์์ ๋์ ์๋ ClientOnly ์ปดํฌ๋ํธ์ ๋ฌธ์ ์
- Nuxt ๊ฐ์ ClientOnly ๋ง๋ค๊ธฐ
- ์ฝ๋ ์์ธํ๊ฒ ๋ฏ์ด๋ณด๊ธฐ
- ๊ฒฐ๋ก
๐ ์๋ก
SSR์ ์ฌ์ฉํ๋ฉด ํ์ฐ์ ์ผ๋ก ClientOnly ์ปดํฌ๋ํธ๋ฅผ ์ฌ์ฉํด์ผ ํ๋ ์๊ฐ์ด ์์ด์. Browser์์๋ง ์๋ ์ ๋ณด๋ก rendering์ ํด์ผ ํ๊ฑฐ๋ ์๋์ ์ผ๋ก ์๋ฒ์์ ๊ทธ๋ ค์ง์ง ์๊ฒ ํ๋ ์๊ฐ ๋ง์ด์์. ํนํ ์ ๋ A, B ํ ์คํ ์ ํ ๋ ClientOnly์ ๋์์ ๋ง์ด ๋ฐ๊ณ ์์ด์. ๊ตฌ๊ธ ์๋๋ฆญํฑ์ค์ A, B ํ ์คํ ์์ ๋์ ์กฐ์ํ๊ฒ ๋๋๋ฐ ์ด๋ ClientOnly์ ๊ธฐ๋ฅ์ด ์๋ค๋ฉด ์๋ฒ์์ ๋ด๋ ค์ฃผ๋ Dom๊ณผ Hydration ์ดํ์ ๋์ด ๋ฌ๋ผ์ง๊ฒ ๋์ด hydration fail ์๋ฌ๋ฅผ ๋ง๋๊ฒ ๋๊ฑฐ๋ ์.
Nuxt์์๋ ClientOnly ์ปดํฌ๋ํธ๋ฅผ ์์ฒด ์ ๊ณต์ ํด์ฃผ๊ณ ์์ง๋ง Next์์๋ ๋๋ต ์ ์ธ ๊ตฌํ ๋ฐฉ๋ฒ์ ๊ณต์ ๋ฌธ์์๋ง ๋์์์ด์. ๊ทธ๋ฌ๋ ๋ฌธ์์ ๋์ ์๋ ClientOnly๋ ๊ธฐ๋ฅ์ด ์กฐ๊ธ ๋ถ์กฑํฉ๋๋ค. ๋ค์ ๊ธ์์๋ ์ด ๋ฌธ์ ๋ฅผ ์ด๋ป๊ฒ ๊ทน๋ณต์ ํ๋์ง๋ฅผ ๊ธฐ์ ํฉ๋๋ค.
๐ ClientOnly ์ปดํฌ๋ํธ๋ ๋ญ๊น?
์ง์ ์ ์ธ ๊ตฌํ์ ์์ ClientOnly ์ปดํฌ๋ํธ๊ฐ ํ๋ ์ผ์ ๋ํด ๊ฐ๋ตํ๊ฒ ์ค๋ช ํด๋ณผ๊ฒ์.
Next์์์ ๊ธฐ๋ณธ์ ์ธ ๋์ ๊ตฌ์กฐ๋ ๋ค์๊ณผ ๊ฐ์์.
Request -> Next Server -> Static HTML -> Client -> HTML์ script ์ฝ๋ ์ฝ์ (hydration)
๋จผ์ ์ฌ์ฉ์๊ฐ Next Server์ ํ์ด์ง ์์ฒญ์ ๋ณด๋ ๋๋ค. ๊ทธ๋ผ Next Server์์๋ ์์ฒญ์ ๋ง๋ ํ์ด์ง๋ฅผ ๋ง๋ค๊ฒ ๋ฉ๋๋ค.
๐ Next ๊ณต์ ๋ฌธ์์ ๋์ ์๋ ClientOnly ์ปดํฌ๋ํธ
Next ๊ณต์ ๋ฌธ์์ ๋์ ์๋ ClientOnly ์ปดํฌ๋ํธ๋ ์ด๋ ๊ฒ ๊ตฌํ์ด ๋์ด์์ด์.
import React, { useState, useEffect } from 'react'
export default function ClientOnly({ children }) {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
if (isMounted) return <>{children}</>
else return <></>
}
์ ๋ง ๊ฐ๋จํ๊ฒ ๊ตฌํ์ ํ ์ ์์ฃ ? ๊ทผ๋ฐ ์ด ์ปดํฌ๋ํธ์ ๋ฌธ์ ์ ์ ๋ํด ์ดํด๋ณผ๊ฒ์.
๐ Next ๊ณต์ ๋ฌธ์์ ๋์ ์๋ ClientOnly ์ปดํฌ๋ํธ์ ๋ฌธ์ ์
๋จผ์ ๊ณต์ ๋ฌธ์์ ๋์ ์๋ ClientOnly ์ปดํฌ๋ํธ์ ๋ฌธ์ ์ ์ ์ดํด๋ณด๊ธฐ ์ ์ ์ฐ๋ฆฌ๊ฐ ์ SSR์ ์ฌ์ฉํ๋์ง ์ง๊ณ ๋์ด๊ฐ ํ์๊ฐ ์์ด์.
์ฐ๋ฆฌ๋ ๋๋์ฒด ์ SSR์ ์ฌ์ฉํ ๊น์? ์๋ง ์ ๋ง ๋ง์ ์ด์ ๊ฐ ์์ํ ๋ฐ, ๊ทธ์ค ํ๋๋ ๋ฐ๋ก ๊ฒ์ ์์ง ์ต์ ํ๋ฅผ ํ๊ธฐ ์ํด์ ์ ๋๋ค. ๊ฒ์ ์์ง ์ต์ ํ๋ฅผ ์ํด ์ฐ๋ฆฌ๋ ํฌ๋กค๋ฌ ๋ด์๊ฒ ํด๋น ํ์ด์ง๊ฐ ์ด๋ค ๋ด์ฉ์ด ๋ค์ด๊ฐ ์๋์ง ์ต๋ํ ๋ง์ ์ ๋ณด๋ฅผ ์๋ ค์ค ํ์๊ฐ ์์ด์. ๊ทผ๋ฐ ClientOnly ๋ถ๋ถ์ ์๋ต์ด ๋ ๊ฑฐ์์. (๋ฌผ๋ก ์์ฆ ๊ตฌ๊ธ ํฌ๋กค๋ฌ ๋ด์ ์ผ๋ฐ์ ์ธ CSR์ ์ ๋ณด๋ ๊ธ์ด๊ฐ๋ค์ง๋ง ๋ค๋ฅธ ๊ฒ์ ์ฌ์ดํธ์์๋ ๊ทธ ์ ๋๊น์ง๋ ์ง์ํ์ง ์์์. ๐ญ๐ญ๐ญ)
๊ทธ๋ ๋ค๋ฉด ์ด ๋ฌธ์ ๋ฅผ ์ด๋ป๊ฒ ํด๊ฒฐํ ์ ์์๊น์? ๋ฐ๋ก ์ค๋ช ์ฝํ ์ธ ๋ฅผ ๋ฃ์ด์ฃผ๋ ๊ฑฐ์์.
๐ Nuxt ๊ฐ์ ClientOnly ๋ง๋ค๊ธฐ
์ฐ๋ฆฌ๋ ์์ Next ๊ณต์ ๋ฌธ์์ ๋์์๋ ClientOnly์ ๋ฌธ์ ์ ์ ์ดํด๋ณด์์ต๋๋ค. ๊ทธ๋ ๋ค๋ฉด ์ด ๋ฌธ์ ๋ ์ด๋ป๊ฒ ํด๊ฒฐ์ ํ ์ ์์๊น์? ๋ต์ Nuxt.js ์์ ์ ๊ณตํ๋ ClientOnly์ interface์ ์์ต๋๋ค.
interface ClientOnly {
fallback: string
fallbackTag?: 'div' | string
}
๋ค, Nuxt๋ ServerSide์์ ๊ทธ๋ ค์ง ์ปดํฌ๋ํธ์ ์ค๋ช ์ ์ฒจ๋ถํ ์ ์์ด์. ๊ทธ๋์ ์ด๋ ๊ฒ ์ฌ์ฉ์ด ๊ฐ๋ฅํฉ๋๋ค.
// ServerSide์์๋ <div>hello</div> ๋ง ๋๋๋ง ํจ.
// ClientSide์์ ์์๋ ๋น๋ก์ <div>hello world</div>๋ฅผ ๋๋๋ง.
<ClientOnly fallback='hello'>
<div>hello world</div>
</ClientOnly>
๊ทผ๋ฐ ์ ๋ ์ฌ๊ธฐ์ ์ข ๋ ํ์ฅ์ ํ๊ณ ์ถ์ด์.
- ๋ฆฌ์กํธ ์ปดํฌ๋ํธ๋ fallback์ผ๋ก ๋ฐ์ ์ ์์ -> ์กฐ๊ธ ๋ ๋ฆฌ์กํธ ์ค๋ฌ์ด ๋๋
- fallback์์ attribute๋ฅผ ์ง์ ํ ์ ์์(ex> aria-label="hello") -> SEO๋ฅผ ๊ฐํ๋ฅผ ์ํด
์ด์ ํด๊ฒฐ์ฑ ๋ ์์์ผ๋ ์ฐ๋ฆฌ๋ react ์ฝ๋๋ก ๋ง๋ค์ด ๋ณผ๊น์? ์ ์ฒด ์ฝ๋๋ ๋ค์๊ณผ ๊ฐ์์.
import React, { ReactElement, PropsWithChildren, useMemo, useEffect, useState } from 'react'
const useMount = (): boolean => {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
return isMounted
}
const isString = (target: unknown): target is string => typeof target === 'string'
interface FallBackProps {
fallbackTag?: keyof JSX.IntrinsicElements | 'div'
fallback?: string | ReactElement
}
function FallBack({ fallback, fallbackTag = 'div', ...delegates }: FallBackProps): ReactElement {
const FallBackTag = useMemo(() => `${fallbackTag}`, [fallbackTag]) as keyof JSX.IntrinsicElements
if (fallback) {
if (isString(fallback)) return <FallBackTag {...delegates}>{fallback}</FallBackTag>
else return <>{fallback}</>
}
return <></>
}
export default function ClientOnly({ children, fallbackTag, fallback, ...delegates }: PropsWithChildren<FallBackProps>): ReactElement {
const isMounted = useMount()
if (isMounted) return <>{children}</>
return <FallBack fallback={fallback} fallbackTag={fallbackTag} {...delegates} />
}
์ด๋ ๊ฒ ๊ตฌํ์ด ๊ฐ๋ฅํฉ๋๋ค. ๊ทธ๋ผ ์ด์ ClientOnly๋ฅผ ์ฌ์ฉํ๋์ชฝ์์๋ ๋ค์๊ณผ ๊ฐ์ด ํธํ๊ฒ ์ฌ์ฉ์ด ๊ฐ๋ฅํ ๊ฑฐ์์.
// ๐ก 1๋ฒ ์ผ์ด์ค) fallback์ string์ผ๋ก ์ฌ์ฉํ๋ ๊ฒฝ์ฐ:
// ServerSide: <div>hello world</div>
// ClientSide: <div>hello</div>
function Test() {
return (
<ClientOnly fallback={'hello world'}>
<div>hello</div>
</ClientOnly>
)
}
// ๐ก 2๋ฒ ์ผ์ด์ค) fallbackTag๋ฅผ ์ฌ์ฉํ๋ ๊ฒฝ์ฐ
// ServerSide: <h1>hello world</h1>
// ClientSide: <div>hello</div>
function Test() {
return (
<ClientOnly fallback={'hello world'} fallbackTag='h1'>
<div>hello</div>
</ClientOnly>
)
}
// ๐ก 3๋ฒ์ผ์ด์ค) Component๋ฅผ ์ฌ์ฉํ๋ ๊ฒฝ์ฐ
// ServerSide: <h1>hello world</h1>
// ClientSide: <div>hello</div>
function TestServer() {
return <h1>hello world</h1>
}
function Test() {
return (
<ClientOnly fallback={<TestServer />}>
<div>hello</div>
</ClientOnly>
)
}
๊ทธ๋ผ ์ด์ ์ ์ฝ๋๋ฅผ ์์ธํ๊ฒ ๋ฏ์ด๋ณผ๊ฒ์.
๐ ์ฝ๋ ์์ธํ๊ฒ ๋ฏ์ด๋ณด๊ธฐ
๋จผ์ Fallback ์ปดํฌ๋ํธ๋ฅผ ์ดํด๋ณผ๊ฒ์.
import React, { ReactElement, PropsWithChildren, useMemo } from 'react'
const isString = (target: unknown): target is string => typeof target === 'string'
interface FallBackProps {
fallbackTag?: keyof JSX.IntrinsicElements | 'div'
fallback?: string | ReactElement
}
// โญ๏ธ delegates๋ ์์ ๋งํ attributes๋ฅผ ์์ฑํ๊ธฐ ์ํจ์
๋๋ค.
// ex> <FallBack fallback='hello' aria-label='hello' />
// -> <div aria-label='hello'>hello</div>
function FallBack({ fallback, fallbackTag = 'div', ...delegates }: FallBackProps): ReactElement {
// โญ๏ธ Tag๋ฅผ ๋์ ์ผ๋ก ์์ฑ์ ํด์ค๋๋ค.
// props.fallbackTag ๊ฐ 'h1'์ผ๋ก ๋ค์ด์๋ค๋ฉด h1ํ๊ทธ๋ฅผ ๋ง๋ค์ด์ค์.
const FallBackTag = useMemo(() => `${fallbackTag}`, [fallbackTag]) as keyof JSX.IntrinsicElements
if (fallback) {
// โญ๏ธ fallback์ ๋๊ฐ์ง ํ์
(string, ReactElement)์ด ์์ด์ ๋ถ๊ธฐ์ฒ๋ฆฌ ๋ชจ์ต์
๋๋ค.
if (isString(fallback)) return <FallBackTag {...delegates}>{fallback}</FallBackTag>
else return <>{fallback}</>
}
return <></>
}
๊ทธ๋ผ ์ด์ ๋ง์ง๋ง์ผ๋ก ClientOnly ์ฝ๋๋ฅผ ์ดํด๋ณด๊ฒ ์ต๋๋ค.
// โญ๏ธ useMount๋ isMounted๋ฅผ ๋ฐํํ๋ ์ฝ๋๋ก ์ง์ฌ์์ด์.
// hydration์ด ์ผ์ด๋๋ฉด true๋ก ๋ณ๊ฒฝ์ด ๋ฉ๋๋ค.
// hydration์ด ์ผ์ด๋ฌ๋ค๋ ๋ง์ client-side๋ผ๋ ๊ฒ์ ์ ์ ์์ต๋๋ค.
const useMount = (): boolean => {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
return isMounted
}
// โญ๏ธ ClientOnly ์ปดํฌ๋ํธ๋ ๋จ์ํ๊ฒ isMounted์ ์ฌ๋ถ๋ฅผ ๋๊ณ ์ด๋ป๊ฒ ๋๋๋งํ ์ง ๊ฒฐ์ ํฉ๋๋ค.
// server side ๋ผ๋ฉด Fallback ์ปดํฌ๋ํธ๋ฅผ ๋๋๋ง
// client side ๋ผ๋ฉด children์ ๋๋๋ง
export default function ClientOnly({ children, fallbackTag, fallback, ...delegates }: PropsWithChildren<FallBackProps>): ReactElement {
const isMounted = useMount()
if (isMounted) return <>{children}</>
return <FallBack fallback={fallback} fallbackTag={fallbackTag} {...delegates} />
}
๐ ๊ฒฐ๋ก
์ด๋ ๊ฒ ClienyOnly๋ฅผ ์ฌ์ฉํด์ Hydration error๋ฅผ ํผํ ์ ์์์ต๋๋ค. ์ฌ์ค use-mount ํจํค์ง์ ๋ฐฐํฌ ๋ด์ฉ ์ค ํ๋์ ๋๋ค. ์กฐ๊ธ ๋ ๋ง์ ๊ธฐ๋ฅ์ ์ฌ์ฉํ๊ณ ์ถ์ผ์๋ค๋ฉด @react-useful-hooks/use-mount ๋ฅผ ์ ์ฉํด ์ฃผ์ธ์. (โญ๏ธ๋ ์ฌ๋์ ๋๋ค!)
๊ทธ๋ฆฌ๊ณ ํน์ react-hooks๊ด๋ จ ์คํ์์ค์ ๊ด์ฌ์ด ์์ผ์๋ค๋ฉด ์ ํฌ ReactUsefulHooks์ ๋๋ฌ ์ ์ฃผ์ธ์. ์์ผ๋ก ๋ ๋ง์ ์ ์ฉํ react-hooks๋ฅผ ์ ๊ณตํ ์์ ์ด๋ผ ํจ๊ปํ๊ณ ์ถ์ผ์๋ค๋ฉด ์ธ์ ๋ ํ์์ ๋๋ค! ๐๐ปโ๏ธ