사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
소개▾
튜토리얼▾
핵심 개념▾
시스템 아키텍처▾

UI 아키텍처

Akan 앱의 컴포넌트는 두 곳 중 한 곳에서 실행됩니다. 서버 컴포넌트는 서버에서 한 번 실행되고, 완성된 HTML로 브라우저에 도착합니다. 클라이언트 컴포넌트, 즉 첫 줄이 "use client"인 파일도 HTML로 먼저 도착하지만, 그 뒤에 JavaScript가 따라가고 브라우저가 그것을 한 번 더 실행해야 버튼과 입력이 동작합니다.
이 페이지는 화면의 각 조각을 둘 중 어디에 둘지 정하는 법을 다룹니다. 막으려는 실수는 이런 모습입니다. 파일 안의 버튼 하나에 onClick이 필요해서 맨 위에 "use client"를 답니다. 그 파일은 상품 마크업 200줄에 핸들러 하나인데, 이제 200줄 전체가 두 번 전송됩니다.
클라이언트 파일은 두 번 전송됩니다
use client가 붙은 파일은 브라우저로 두 번 갑니다. 한 번은 사용자가 바로 읽을 수 있는 HTML로, 또 한 번은 그 안의 버튼 하나가 동작하기 전에 브라우저가 내려받아 다시 실행해야 하는 JavaScript로 갑니다.
그래서 Akan은 SSR(서버 렌더링)이 기본입니다. 기본값은 서버이고, "use client"는 습관처럼 다는 것이 아니라 컴포넌트마다 이유가 있어야 하는 비용입니다. 다행히 그 선은 대부분 기계적으로 정해집니다. 아래 섹션에서 어떤 기능이 브라우저를 필요로 하는지, 도메인 파일은 어떻게 알아서 정해지는지, 지금 내 앱이 어디쯤인지 재는 법을 차례로 봅니다.
이 페이지에서 쓰는 말
server component
서버에서 한 번 실행되어 HTML로 도착합니다. 이 컴포넌트의 JavaScript는 브라우저로 가지 않습니다.
client component
첫 줄이 "use client"인 파일입니다. HTML로 한 번, 번들 속 JavaScript로 한 번 더 도착합니다.
hydrate
브라우저가 클라이언트 컴포넌트의 JavaScript를 다시 실행해, 화면의 HTML이 클릭과 입력에 반응하게 하는 일입니다.
shell
서버가 가장 먼저 보내는 HTML입니다. page가 await한 것은 여기 들어가고, 스트리밍하는 섹션은 뒤따라옵니다.
island
서버가 그린 HTML 속에서 hydrate되는 클라이언트 컴포넌트 하나입니다.

페이지가 브라우저에 닿는 경로

서버사이드 렌더링은 브라우저가 앱을 다 불러오기 전에 서버가 첫 HTML을 먼저 만들어 보내는 방식입니다. 필터와 제출 버튼이 아직 오는 중이어도 고객은 주문 목록과 가격과 정책 문구를 이미 읽을 수 있습니다.
요청 하나의 전 구간
사용자
브라우저
page().render()
fetch.init과 fetch.view
Akan 서버
opens /en/icecreamOrder
요청
fetch.initIcecreamOrderInPublic()
slice 쿼리
init payload
shell HTML, server component는 이미 렌더됨
promise가 도착하는 대로 섹션이 스트리밍됩니다
client island만 hydrate
여기서 사용자가 페이지를 읽을 수 있습니다
이제 입력과 클릭이 가능합니다
읽기와 조작이 같은 순간에 시작될 필요는 없습니다. 그래서 둘을 서로 다른 시계로 나눠 생각하면 이해가 쉽습니다:
Time to View
읽을 수 있게 되는 시점
사용자가 제목, 사이즈, 가격, 첫 행처럼 의미 있는 내용을 얼마나 빨리 읽는가입니다. 이 값을 당기는 것이 서버 렌더링입니다.
Time to Interaction
조작할 수 있게 되는 시점
사용자가 얼마나 빨리 입력하고, 클릭하고, 필터링하는가입니다. 이 값은 hydrate되는 섬만 움직이고, 서버에 남기는 엘리먼트마다 섬이 작아집니다.
셸을 먼저 보내고, 나머지는 스트리밍
page는 모든 query를 다 기다린 뒤에야 무언가를 보낼 필요가 없습니다. fetch.init<Model><Suffix>, fetch.view<Model>, fetch.edit<Model>은 두 가지 방법으로 쓸 수 있고, 어느 쪽을 고르느냐에 따라 데이터가 들어가는 자리가 달라집니다:
await — 셸에 넣기
await fetch.initXInY(id)
셸이 데이터를 기다리므로 첫 HTML에 들어갑니다. SEO 스냅샷, prerendering, hydration 이전 E2E가 읽는 것이 바로 이 셸입니다. 페이지에 당장 필요한 것에 씁니다.
구조 분해 — 스트리밍
const { xInitInY } = fetch.initXInY(id)
query는 이미 출발한 채로 field마다 promise 하나씩을 받습니다. 셸은 바로 나가고, 각 섹션은 자기 promise가 도착하는 대로 채워집니다. 나머지 전부에 씁니다.
아래 page에서 제목은 바로 나가고, 주문 목록은 그 뒤를 따라 스트리밍됩니다:
apps/koyo/page/(public)/icecreamOrder/_index.tsx
  • 제목(h1)은 서버 마크업입니다. slice query가 아직 도는 동안 이미 전송됩니다.
  • Zone은 트리에서 유일하게 hydrate되는 부분입니다. 데이터가 아니라 await하지 않은 promise를 받으므로, 그 위의 어떤 것도 기다리지 않습니다.
  • Zone이 받지 않는 promise는 대신 Load.Stream으로 보냅니다. UI 구성 문서에서 다룹니다.

클라이언트 컴포넌트는 꼭 필요할때만

브라우저가 꼭 있어야 하는 기능은 다섯 가지뿐입니다. 컴포넌트가 그중 아무것도 쓰지 않으면, 바로 옆에 그런 컴포넌트가 붙어 있더라도 서버에 둡니다. akan quality ssr도 "use client"가 필요했는지 따질 때 이 목록을 그대로 씁니다.
코드가 쓰는 것
서버
클라이언트
브라우저가 꼭 필요한 다섯 가지
useState · useEffect
✓
React는 hook을 브라우저에서 실행합니다. 예외로 usePage(), getSelf(), useServer()는 서버에서도 씁니다.
onClick · onChange
✓
이벤트 핸들러는 브라우저에서 클릭을 받아야 하므로, 그 컴포넌트도 브라우저로 갑니다.
st.use · st.do
✓
store는 클라이언트 번들에만 있습니다. st를 import하면 그 파일에는 "use client"가 필요합니다.
window · document · localStorage
✓
브라우저 전역 객체와 matchMedia, WebSocket 같은 API는 서버에 없습니다.
클라이언트 전용 패키지
✓
import하는 순간 DOM을 건드리는 지도, 에디터, 차트입니다. lib의 re-export를 거쳐 씁니다.
나머지는 전부 서버의 일
마크업과 목록
✓
배열로 그린 카드 목록은 hydrate할 것이 없는 평범한 HTML입니다.
usePage · l · l.trans
✓
번역은 서버에서도 되므로, 다국어 문구 때문에 "use client"를 달 일은 없습니다.
.param · .search
✓
route 값은 첫 바이트가 나가기 전에 타입이 맞춰져 render callback에 들어옵니다.
fetch.*
✓
route에서 부르면 첫 바이트 전에 끝납니다. 마운트된 클라이언트에서 부르면 왕복이 두 번 더 듭니다.
getSelf({ unauthorize })
✓
로그인 확인은 HTML이 나가기 전에 _layout.tsx에서 합니다. 렌더링한 뒤가 아닙니다.
패널 열고 닫기
✓
대개 서버입니다. data-* 속성이나 <details>를 쓰면 열린 상태와 닫힌 상태 모두 서버에서 그려집니다.
✓여기에 둡니다여기가 아닙니다

도메인 UI에서 규칙은 기계적입니다

도메인 모듈 안에서는 위의 판단을 직접 할 필요가 없습니다. 파일 이름이 정해 줍니다. Template, Zone, Util은 항상 첫 줄이 "use client"이고, Unit과 View에는 절대 없습니다. 파일의 역할과 첫 줄이 어긋나면 둘 중 하나가 잘못된 것입니다.
파일
서버
클라이언트
데이터를 그리는 파일
<Model>.Unit.tsx
✓
목록의 행, 카드, 타일 하나입니다. 모델을 prop으로 받아 그리기만 합니다.
<Model>.View.tsx
✓
레코드 하나의 상세 화면입니다. full 모델을 prop으로 받습니다.
상태나 동작을 가진 파일
<Model>.Zone.tsx
✓
init이나 view prop으로 store를 채우고 읽습니다. 자체 마크업은 거의 없습니다.
<Model>.Template.tsx
✓
폼입니다. 모든 필드가 store에 묶여 있어 useState가 없습니다.
<Model>.Util.tsx
✓
Serve, Refund, Remove 같은 도메인 동작 하나를 컨트롤로 만든 것입니다.
✓여기서 실행여기서는 실행하지 않음
Zone과 Unit이 함께 일하는 모습
규칙이 만들어 내는 한 쌍입니다. Zone이 클라이언트인 이유는 init으로 store를 채우기 때문, 하나뿐입니다. 자기 마크업은 그리지 않고 모든 행을 Unit에 맡깁니다:
apps/koyo/lib/icecreamOrder/IcecreamOrder.Zone.tsx
Unit은 모델을 prop으로 받아 그리기만 합니다. "use client"도, st도, hydrate할 것도 없습니다. 화면에 행이 100개여도 번들이 치르는 비용은 컴포넌트 하나, Zone뿐입니다.
apps/koyo/lib/icecreamOrder/IcecreamOrder.Unit.tsx

한 화면을 나누기

도메인 모듈 밖(앱 셸, 마케팅 섹션, 대시보드)에서는 경계를 직접 놓습니다. 실제로 브라우저가 필요한 가장 작은 조각까지 경계를 밀어 내리고, 그 위와 그 안은 서버 마크업으로 남겨 두세요.
클라이언트 잎 하나, 둘레는 모두 서버 마크업
영수증 카드에서 클라이언트 컴포넌트는 작은 복사 버튼 하나뿐입니다. 카드, 줄들, 합계, 심지어 버튼 안의 아이콘까지 서버 마크업으로 남습니다.
복사 버튼을 코드로 보면
클라이언트 부분은 이 정도로 작은 파일입니다. 클릭하면 복사하는 동작 하나만 더하고, children은 손대지 않고 그대로 그립니다:
apps/koyo/ui/CopyOrderId.tsx
그 둘레의 page는 서버 컴포넌트로 남습니다. 영수증과 각 줄, 심지어 버튼의 라벨까지 page에서 쓰고 children으로 넘기므로, 아무리 커져도 서버 마크업입니다:
apps/koyo/page/(public)/icecreamOrder/[icecreamOrderId]/_index.tsx
복사 버튼의 클라이언트 비용은 이것이 전부입니다. 핸들러 하나와 children 전달 하나입니다.
마크업을 서버에 남기는 방법 네 가지 더
복합 컴포넌트는 쪼개기
Tab은 작은 클라이언트 조각 네 개(Tab, Tab.Menus, Tab.Menu, Tab.Panel)입니다. 패널 본문은 children으로 들어오므로 번들에 들어가지 않습니다. mode용 useState 하나에 모든 패널을 인라인한 클라이언트 파일 하나는 정반대입니다.
<Tab.Panel menu="spec">…</Tab.Panel>
이름 있는 슬롯 쓰기
Layout.Navbar는 title, back, left, right, children을 받습니다. 클라이언트 셸이 서버 콘텐츠를 삼키지 않고 다섯 자리에 끼워 넣습니다.
<Layout.Navbar title={…} right={…}>
파생 계산은 서버에서
표시와 판별 로직은 양쪽이 모두 가진 Light<Model>에 둡니다. enum에서 클래스를 찾는 표는 모듈 스코프의 as const 맵에 둡니다.
order.isNew() · statusClass[order.status]
무거운 섬은 나중에 불러오기
지도, 에디터, 차트는 ui/<Folder>/index_.tsx와 lazy() 쌍 뒤에 두고, 옆에 서버에서 안전한 index.tsx를 둡니다. 이 쌍을 한 파일로 합치면 RSC가 깨집니다.
ui/Map/index_.tsx + lazy()

경계를 측정하기

이 중 어느 것도 취향의 문제가 아니므로, 리뷰에서 다투지 않고 측정합니다. akan quality ssr은 양쪽의 JSX 엘리먼트를 세어 app과 lib마다 서버에 남긴 비율을 보고하고, 아래 여섯 가지 finding을 함께 알려 줍니다.
  • 각 app과 lib의 ui/, lib/ 아래 .tsx 파일을 읽습니다.
  • page/와 webkit/은 세지 않습니다. 그래서 마크업을 route로 옮겨도 수치는 오르지도 내리지도 않습니다.
Terminal
모든 finding의 이름은 akan.ssr.<규칙>입니다. 규칙마다 뜻과 고치는 법은 다음과 같습니다:
unnecessary-use-client
파일이 "use client"로 시작하지만 다섯 가지 기능을 하나도 쓰지 않습니다.
→ 그 첫 줄을 지웁니다.
client-static-component
클라이언트 파일 안의 컴포넌트가 클라이언트 기능 없이 엘리먼트를 4개 이상 그립니다.
→ "use client"가 없는 파일로 옮깁니다.
client-static-markup
엘리먼트 10개 이상이 클라이언트 기능 한두 개를 감싸고 있습니다.
→ 인터랙션 부분만 클라이언트로 남기고, 나머지는 children으로 넘깁니다.
client-mount-load
useEffect(…, [])가 화면이 마운트된 뒤에 서버 데이터를 불러옵니다.
→ route에서 fetch하고 init prop으로 넘깁니다.
module-missing-server-view
모듈이 Template, Zone, Util로만 그리고 Unit이나 View가 없습니다.
→ Unit이나 View를 추가하고, Zone이 행을 거기에 넘기게 합니다.
template-client-state
Template이 폼 상태를 store가 아니라 useState에 둡니다.
→ 각 필드를 store에 묶습니다:
일부러 잡지 않는 것
  • 클라이언트 전용 서드파티 패키지와 index_.tsx의 lazy() 경계. 둘 다 "use client"의 정당한 이유입니다.
  • 모듈 안의 Zone, Template, Util. 오늘의 본문이 클라이언트 기능을 쓰지 않더라도 역할 자체가 "use client"를 요구합니다.
  • 사용자가 시작한 fetch, 예를 들어 onClick 안의 조회. 서버가 미리 할 수 없었던 일입니다. finding은 마운트 시점의 로드뿐입니다.
.tsx를 건드리는 변경 전후로 실행하고, CI에 걸 때는 --format json을 씁니다.
경계가 정리되었으니, 다음 문서는 그 양쪽을 무엇이 채우는지 다룹니다. 로딩 상태를 직접 쓰지 않고도 목록, 상세, 폼을 그려 주는 akanjs/ui 셸들과, 그 아래의 생성된 helper들입니다.

MIT 라이선스 하에 배포되었습니다.

내 AI에 이 문서 연결하기

MCPhttps://akanjs.com/mcp
Copyright © 2026 Akan.js 모든 권리 보유.시스템 관리자bassman