Hyunseok
Dev

Hono에서 Island component를 구현해보자

2025-07-20

okonplan


발단

hono를 씹고 뜯고 맛 보던 어느날 .. 마음 한 켠에 항상 불만인 점이 있었다

아우 이놈의 hono jsx 렌더러, CSR도 같이 제공해주면 덧나나 ?

그렇다, Hono는 jsx렌더링 할 수 있지만 vdom을 생성해서 CSR기능을 제공하지 않는다 ..

물론 honox를 쓴다면 그런거 없이 한방에 가능하지만..

이런건 직접 해보지않으면 직성이 풀리지 않는 나이기 때문에 .. 직접 CSR을 구현해보려한다

컨셉 지정

일단 CSR도 여러가지가 있지않은가 ?
일단 최-애신 기술로 따지면 nextjs진영에서 html을 스트림으로 내다 꼽는 PPR이 있고
신생 프레임워크들 중에 qwik이나 astro를 보면 Island의 개념을 채용하여 CSR을 고도화하고있다

헷갈릴 수가 있는데 이걸 조금 더 풀어서 설명하고 넘어가자

PPR vs Island

다들 Nextjs를 사정없이 까지만
개인적으로 정말 좋아한다.
좋아하는 이유는 이런 최적화 기술을 계속 시도하고 있다는 점이다.

PPR ㅋㅋ 그거 Island랑 같은 거 아님 ?
이라고 할 수 있지만.. 성격이 약간다르다

내가 보고느낀 현대 JS웹 생태계에서는 웹 페이지의 렌더링 행위는
HTML + 청크 pull + hydration이다

여기서 이 html, 청크 부분을 streaming해서 제공하느냐 아니면 html이 먼저 load되고 chunk가 load가 되느냐의 차이이다

조금 더 자세하게 극단적으로 볼 때는, 렌더링의 주체를 Server에서 보느냐, Client에서 보느냐의 차이로 보면 된다.
FE만 한우물 파던 사람들은 이게 잘 이해가 안 갈 수도있는데 그래서 사진을 준비해왔다

pprisland

단순 명료하지않은가 ?

코드로 풀자면 아래와 같다.

PPR 단순 예제

typescriptapp.get('/', (req, res) => {
  res.write(`
    <html>
      <body>
        <h1>헤드라인</h1>
        <article>본문...</article>
        <section id="comments">댓글 로딩중...</section>
        <aside id="sidebar">사이드바 로딩중...</aside>
      </body>
    </html>
  `);
 
  fetchCommentsAsync().then(comments => {
    res.write(`
      <script>
        document.getElementById('comments').innerHTML = ${JSON.stringify(comments)};
      </script>
    `);
    res.end();
  });
});

간단하게, express로 설명하자면

  • streaming을 열고 html을 response에 정적 html을 서빙 (crawler대응)
  • Suspense된 chunking 부분을 script로 로드해서 완료 즉시 서빙
  • 페이지 response끝

이런식으로 설명된다 실제로는 React와 nextjs팀에서 작성하는 최적화 정책
그리고 Promise로 묶는 방법, Suspense에 관련된 컴포넌트의 청크 분리 전략에 따라 코드가 확 달라지겠지만
기본의 틀은 저런식의 Stream을 이용한 SSR의 점진적 hydration이 핵심이다

Island

island는 매우 단순한데,
지금 우리가 하고있는 hydration형식을 생각하면 된다
html을 서빙 받고 html에 명시되어있는 chunk를 서버에 요청해서 받는 형식이다

결론

그러면 우리는 어떻게 쉽게 이해 해야하는가 ?

주체 차이라고 생가각하면 된다 **PPR **

  • 서버에서 SSR페이지를 서빙하고 hydration될 부분까지 서버에서 제어 (서버가 주체)

Island

  • 서버에 html을 요청하고 받은 html에서 필요한 chunk를 다시 서버에 요청 (클라이언트가 주체)

Island구현

조금 먼 길을 돌아왔는데 Hono 에서 PPR 구현하려면 React19의 Suspense도 공부하고.. Streaming쪼개기와 fallback처리 등등
도저히 올해 불가능한 영역처럼 보이니 .. 개념만 이해해두고

간단하게 Island를 서빙하는 Hono를 작성하기로 해본다.

먼저 구현 할 것은 3개

  • Island renderer
  • Island layout
  • Island helper

요렇게 3개만 있으면 된다

Island helper

typescriptimport * as islands from 'island'
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
 
const islandMap: Record<string, React.ComponentType<unknown>> = islands
 
document.addEventListener('DOMContentLoaded', () => {
    document.querySelectorAll('[data-island]').forEach(mountIsland)
})
 
const mountIsland = (element: Element) => {
    const componentName = element.getAttribute('data-island')
    if (!componentName) return
 
    const Component = islandMap[componentName]
    if (!Component) {
        console.warn(`Island component "${componentName}" not found.`)
        return
    }
 
    const props = JSON.parse(element.getAttribute('data-props') || '{}')
    const root = createRoot(element)
    root.render(createElement(Component, props))
}

먼저 client파일을 작성한다 동작은 DOM 로드 -> data-island를 가진 element탐색 -> 탐색 후 chunk확인 후 vdom으로 렌더링

이런식이다

Island layout

import { reactRenderer } from '@hono/react-renderer'
 
const importMap = {
    imports: {
        'react': '/node_modules/react/index.js',
        'react/jsx-runtime': '/node_modules/react/jsx-runtime.js',
        'react/jsx-dev-runtime': '/node_modules/react/jsx-dev-runtime.js',
        'react-dom': '/node_modules/react-dom/index.js',
        'react-dom/client': '/node_modules/react-dom/client.js',
    },
}
 
export default reactRenderer(({ children, c }) => {
    return (
        <html lang='ko'>
            <head>
                <meta charSet='UTF-8' />
                <meta name='viewport' content='width=device-width, initial-scale=1.0' />
                <title>{c.var.title || 'Honobono'}</title>
                <script type='importmap' dangerouslySetInnerHTML={{ __html: JSON.stringify(importMap) }} />
                <link rel='stylesheet' href='/styles.css' />
                <script src='/theme.js' defer></script>
                <script src='/island/client.js' type='module' defer></script>
            </head>
            <body className='antialiased bg-background text-foreground'>{children}</body>
        </html>
    )
})
 

Hono는 자체적으로 jsx렌더러가 있는데 이걸 쓰면 제대로 인식을 못하고, react도 탑재해서 넣어줘야하니 react renderer + react탑재까지 해준다

client까지 탑재했으면 이제 렌더링을 도와줄 renderer를 작성해보자

Island Renderer

import type { ComponentProps } from 'react'
import * as islands from 'island'
 
type IslandProps = {
    [P in keyof typeof islands]: {
        name: P
        props?: ComponentProps<(typeof islands)[P]>
    }
}[keyof typeof islands]
 
export const IslandRenderer = ({ name, props }: IslandProps) => {
    return <div data-island={name} data-props={props ? JSON.stringify(props) : '{}'} />
}
 

이건 좀 간단하다, island로 모아둘 폴더에 컴포넌트를 자동으로 긁어서 name과 props를 자동으로 생성하고 렌더링한다.

이렇게 하면 준비 끝이.. 아니라 Bun으로 이제 서빙될 island chunk도 자동으로 생성해야한다

typescriptimport { existsSync, mkdirSync } from 'fs'
import * as islands from 'island'
import { join } from 'path'
 
const outDir = join(import.meta.dir, '..', 'assets', 'island')
 
const build = async () => {
    console.log(`Building client bundle with islands: ${Object.keys(islands).join(', ')}`)
    try {
        if (!existsSync(outDir)) {
            mkdirSync(outDir, { recursive: true })
        }
 
        const result = await Bun.build({
            entrypoints: [join(import.meta.dir, '..', 'island', 'client.ts')],
            outdir: outDir,
            target: 'browser',
            naming: '[name].js',
            sourcemap: 'inline',
        })
 
        if (!result.success) {
            console.error('Client build failed:')
            for (const log of result.logs) {
                console.error(log)
            }
        } else {
            console.log('Client bundle built successfully!')
        }
    } catch (e) {
        console.error('An error occurred during client build:', e)
    }
}
 
await build()

이전에 bun으로 크롬 익스텐션 할 때의 짬이 조금 도움이 되었다. island를 빌드하는 스크립트를 하나 짜고 package.json에

json"dev:island": "bun run --watch scripts/development.ts",

이런식으로 watch만 걸어주면 끝이다.

이러면 Island의 준비는 끝났다. 이제 page를 만들고 route에 탑재시키고 렌더링 한 결과를 보면 ..

islandresult

이런식으로 실제로 따로 csr이 로딩되는 것을 확인 할 수있다.

아니 그냥 JS로 만들면 되는 거 아니야 ?

이러면 밑도 끝도 없는게 그러면

아니 그냥 그러면 nextjs에서 dynamic import쓰면 되는거아니야?

이런식으로 말이 나올수도있다. 하지만 이건 순전히.. 나의 구현욕구에 기반된 프로젝트라
JS로 짜는것도 맞긴 하다 ㅋㅋ

마지막으로

한동안 좀 뜸 하다가 한달정도 살짝 일 권태기가 와서 아무것도 하기싫어서
주말에 게임만하다가 재미가 없어져서 다시 개발판으로 들어오니 매우재밌다.
가끔은 이런식으로 쉬어가면서 개발하는게 나의 커리어를 길게 잡아 갈 수 있는 비결아닐까 ?

요즘 AI가 판치는 세상, FE기술들이 생각보다 더뎌지고있는 이 순간에 (그래도 아직 빠름)
더뎌진 이 시점, 지금이 기회다 하고 근간이 되는 기술들을 하나씩 파헤쳐보는 것도 좋을 것 같다.

BunPPRSSRHonojsIslandStream
Comments()