서버 유틸리티 개요

srvkit/에는 서버에서만 실행되어야 하는 코드를 둡니다. service, signal, 서버 job이 이 코드를 불러 쓰므로, module 파일은 비즈니스 동작에만 집중할 수 있습니다.
외부 라이브러리를 들여오는 안전한 통로이기도 합니다. vendor SDK와 저수준 서버 API는 먼저 srvkit을 거칩니다.
어느 폴더에 둘까
lib/ 바깥 코드는 다섯 폴더 중 하나에 둡니다. 무엇을 위한 코드인지가 아니라 무엇을 건드리는지로 고르며, common/과 webkit/ 문서도 같은 표로 시작합니다.
common/
순수하고 서버·클라이언트 양쪽에서 실행되며 의존성이 없습니다. 형제 common/*과 akanjs/base만 import하고 Err는 못 씁니다.
webkit/
window, navigator, 네이티브 브리지(akanjs/client/native)를 건드리거나 React hook입니다.
srvkit/
node:*, Bun, process.env, secret, 서버 SDK 중 하나라도 건드립니다.
ui/
JSX를 그리거나 레시피로 모양을 정의하되 model 하나에 묶이지 않습니다. model에 묶인 컴포넌트는 그 module에 둡니다.
plugin/
빌드 시점이나 CLI 시점에 실행되는 AkanPlugin입니다. akan.config.ts에 등록합니다.

srvkit/에 두는 것

srvkit/에는 일곱 종류의 코드를 둡니다. 넷은 요청 경로에 끼어들고, 셋은 service가 불러 쓰는 도구입니다:
Guard
요청이 endpoint를 실행해도 되는지 판단합니다. 로그인, role, 소유권 확인이 여기에 속합니다.
InternalArg
호출자의 account처럼 믿을 수 있는 값을 context에서 읽어 exec 인자로 넘깁니다.
Middleware
모든 signal 호출을 감싸고, endpoint의 guard가 실행되기 전에 서버 context를 붙입니다.
WebProxy
페이지 로드가 라우팅되기 전에 실행되어 redirect, rewrite, header 추가를 처리합니다.
서버 helper
hash, 암호화, 파일 처리, 이미지 분석, token 처리 같은 재사용 함수입니다.
Adaptor
storage, queue, email, 결제, vendor API를 감싸는 싱글턴 adapt() class입니다.
Class utility
legacy 형태입니다. SDK client 같은 서버 전용 class를 option.ts를 거쳐 주입합니다.
요청 경로의 네 가지는 어디서 실행되나
페이지 로드와 signal 호출은 서로 다른 길로 들어오고, 네 가지는 각각 그중 한 길에만 있습니다:
구성 요소
페이지 로드
Signal 호출
서버 레벨 — option.ts에 한 번 등록
WebProxy
✓
page가 정해지기 전에 redirect, rewrite, header 추가를 합니다.
Middleware
✓
guard가 실행되기 전에 account 같은 서버 context를 붙입니다.
Signal 레벨 — endpoint나 slice마다 지정
Guard
✓
호출을 허용하거나 거절합니다.
InternalArg
✓
서버가 만든 값을 exec 인자로 넘깁니다.
✓여기서 실행여기서는 실행하지 않음

서버 레벨: WebProxy와 Middleware

둘 다 option chain에 한 번 등록하면 해당 종류의 모든 요청에 적용됩니다. WebProxy는 라우팅 전에 페이지 로드를 다루고, Middleware는 모든 signal 호출을 감쌉니다.
WebProxy
페이지 로드
WebProxyredirect · rewrite · headers
page 렌더링
WebProxy는 use(request) 메서드 하나를 가진 class입니다. 아래 예시는 더 이상 쓰지 않는 URL을 새 page로 보냅니다:
apps/koyo/srvkit/legacyPageRedirect.ts
use()가 무엇을 돌려주느냐에 따라 다음 동작이 정해집니다:
반환값
↳ 결과
Response
그대로 응답합니다. 뒤의 proxy와 page는 실행되지 않습니다.
AkanResponse.redirect(url, status?)
redirect Response를 만듭니다. status 기본값은 307입니다.
AkanResponse.next({ request })
바꾼 request header를 들고 다음 단계로 넘어갑니다.
AkanResponse.rewrite(url)
주소창은 그대로 두고 다른 경로의 page를 보여 줍니다.
undefined
요청을 손대지 않고 넘깁니다.
  • 페이지 로드에만 적용됩니다. API 경로, websocket, /_akan/* 경로는 WebProxy를 거치지 않습니다. 정적 파일(확장자가 붙은 경로)도 matcher로 직접 지정하지 않으면 건너뜁니다.
  • 클라이언트 쪽 이동은 WebProxy를 건너뜁니다. /ko/old-docs로 가는 <Link>나 router.push는 redirect되지 않고 /ko/old-docs 자체를 그리며, 대개 404가 됩니다. 기본 locale·basePath 처리는 그대로 적용됩니다. 새 페이지로 바로 링크하고, 접근 제어는 proxy가 아니라 endpoint의 guard와 _layout.tsx의 getSelf({ unauthorize })로 합니다.
  • locale redirect가 먼저 실행됩니다. 기본 proxy가 내 proxy보다 먼저 실행되어 /old-docs를 /ko/old-docs로 바꾸므로, 경로는 locale 부분까지 포함해 비교합니다.
  • matcher로 범위를 좁힐 수 있습니다. applyWebProxy({ proxy, matcher })의 matcher에는 경로 prefix, RegExp, request를 받는 함수 중 하나를 넣습니다.
Middleware
Signal 호출
Middlewarecontext 부착
Signal endpointGuard → exec
아래 Middleware는 호출자를 확인해서 guard와 InternalArg가 읽는 자리에 넣어 둡니다:
apps/koyo/srvkit/requestUserMiddleware.ts
  • use(env)는 프로세스당 한 번 실행됩니다. 서버 option을 받으며, 매 호출마다 실행되는 것은 use가 돌려준 함수뿐입니다.
  • transport 분기는 여기서만 합니다. Middleware가 HTTP request나 socket data에 account를 써 두면, guard와 InternalArg는 모두 context.get("account")로 읽습니다.
  • libs/shared를 쓰면 이미 들어 있습니다. 그 안의 AccountMiddleware가 JWT를 account로 바꿔 주므로, 대부분의 app은 직접 만들 일이 없습니다.
  • refName이 등록 key입니다. refName이 같은 middleware 둘은 서로를 덮어씁니다. endpoint 하나에만 걸려면 middlewares signal option을 씁니다.
option.ts에 등록하기
srvkit에 선언했으면 app이나 library의 option chain에 등록합니다:
apps/koyo/lib/option.ts
  • 여러 개를 한 번에, 순서대로. applyMiddleware와 applyWebProxy는 class를 여러 개 받고, proxy는 적은 순서대로 실행됩니다.
  • library의 것도 함께 실행됩니다. app은 mount한 모든 library의 middleware와 proxy를 실행하고, app 자신의 option.ts는 마지막에 적용됩니다.

Signal 레벨: Guard와 InternalArg

Guard와 InternalArg는 endpoint나 slice마다 지정합니다. Guard는 호출을 실행해도 되는지 판단하고, InternalArg는 믿을 수 있는 서버 context를 exec 인자로 바꿉니다.
Middleware가 context를 준비한 뒤
Middleware
Guard허용 또는 거절
InternalArgexec 인자 생성
Signal exec
Service 로직
Guard
Guard는 canPass(context)를 가진 class입니다. SignedIn은 호출자만 읽고, CanCancelOrder는 호출 인자까지 봅니다:
apps/koyo/srvkit/guards.ts
그 차이를 선언하는 것이 static scope입니다. 기본값이 없으므로 모든 guard가 직접 적습니다:
scope읽는 것
↳ MCP 목록에서는
"account"호출자만
MCP 목록은 인자 없이 이 guard를 평가해, 호출자가 확실히 쓸 수 없는 항목을 숨깁니다.
"resource"호출 인자까지
MCP 목록은 이 guard를 평가하지 않습니다. 항목은 그대로 보이고, 호출할 때 막힙니다.
  • static name은 지우지 않습니다. fetch가 guard 이름을 직렬화하고, API explorer가 그 이름으로 필터링합니다.
  • 호출자는 context.get("account")로 읽습니다. guard는 websocket 호출에서도 실행되고, pubsub room은 socket의 credential이 바뀔 때마다 guard를 다시 실행하므로 getHttpContext()로 분기하지 않습니다.
  • side effect는 없게, 실패하면 거절로. guard는 다시 실행해도 안전해야 합니다. 가리키는 resource가 없으면 false, 조회가 throw하면 logger.warn 후 false입니다.
  • 모든 guard를 통과해야 합니다. guards 배열은 순서대로 검사하고, 처음 거절한 guard에서 403으로 응답합니다.
InternalArg
InternalArg는 request context를 읽어 서버가 만든 값을 exec에 넘깁니다. 클라이언트에게 보내 달라고 하지 않아도 비즈니스 로직이 그 값을 받습니다:
apps/koyo/srvkit/internalArgs.ts
guard와 InternalArg는 signal 파일에서 만납니다. guard는 option에 넣고, InternalArg는 .with()로 선언한 param 뒤에 붙입니다:
apps/koyo/lib/order/order.signal.ts
  • 인자는 순서대로 들어옵니다. exec는 .param() 값을 먼저, 그다음 .with() 값을 받습니다.
  • null이면 호출이 거절됩니다. InternalArg가 null을 돌려주면 401로 응답합니다. .with(CurrentUserId, { nullable: true })로 쓰면 exec가 null을 그대로 받습니다.
  • 클라이언트가 보낸 id는 믿지 않습니다. 행동하는 user는 .param()이 아니라 InternalArg에서 받습니다.
직접 만들기 전에 이미 있는 것부터 확인하세요:
ReqResIpWs
akanjs/signal에 있습니다. 원본 request, response, 호출자 IP, socketId가 붙은 socket을 넘깁니다.
AccountSelfMeAgentCall
@libs/shared/srvkit에 있습니다. account, 로그인한 user, admin, 모델이 호출 중인지를 넘깁니다.

서비스 로직과 외부 라이브러리

service에 crypto, AI SDK, HTTP client 같은 서버 전용 패키지가 필요하면 먼저 srvkit에서 감쌉니다. 그다음 service가 가져다 쓰는 방법은 감싼 것의 종류에 따라 다릅니다:
함수 helper
srvkit barrel에서 바로 import합니다.
싱글턴 adaptor
service에서 plug(Class)로 받고, option.ts에는 적지 않습니다.
class 인스턴스 (legacy)
option.ts의 .use()에서 만들고 use<T>()로 주입합니다.
함수 helper
함수 helper는 따로 연결할 것이 없습니다. 아래 helper는 service가 직접 import할 수 없는 node:crypto를 씁니다:
apps/koyo/srvkit/createOrderHash.ts
class 인스턴스: legacy 형태
파일 세 개가 필요합니다. 먼저 srvkit에 평범한 class를 둡니다:
apps/koyo/srvkit/emailClient.ts
다음으로 option.ts에서 key 하나에 인스턴스를 만들어 둡니다:
apps/koyo/lib/option.ts
마지막으로 service에 같은 이름의 use<T>() 필드를 둡니다:
apps/koyo/lib/order/order.service.ts
  • 필드 이름이 곧 key입니다. use<T>()는 service 필드 이름으로 값을 찾으므로, emailClient는 .use()의 key와 같아야 합니다.
  • 함수 helper는 이 과정이 필요 없습니다. 같은 service가 createOrderHash를 @apps/koyo/srvkit에서 바로 import합니다.

Adaptor와 plug

Adaptor는 외부 시스템을 service dependency로 만드는 싱글턴 adapt() class입니다. srvkit에 선언하고 필요한 service에서 plug()하면 됩니다. 스스로 등록되므로 option.ts에는 적을 것이 없습니다.
apps/koyo/srvkit/paymentApi.ts
service에서는 class를 그대로 plug합니다:
apps/koyo/lib/order/order.service.ts
adapt()의 builder는 injector 네 가지를 넘겨줍니다. 쓰는 것만 구조 분해합니다:
use<T>()
option.ts가 같은 key로 제공하는 값입니다. legacy 경로입니다.
env(fn)
서버가 시작될 때 서버 option에서 한 번 읽는 값입니다.
plug(Class)
다른 adaptor나 StorageAdaptorRole 같은 기본 제공 role입니다.
memory(Type, { of })
이 adaptor가 cache adaptor에 따로 두는 값입니다. local: true면 프로세스 안에 둡니다.
  • 프로세스당 하나입니다. adapt()는 싱글턴 전용입니다. 쓸 때마다 만드는 값 객체는 new로 만드는 평범한 class로 둡니다.
  • adaptor끼리도 plug할 수 있습니다. 단, plug가 순환을 이루면 안 됩니다.
  • logger와 lifecycle은 기본 제공입니다. Logger를 새로 만들지 말고 this.logger를 쓰며, 시작 작업은 override async onInit()에 둡니다.
  • 원격 호출은 #api() 하나로 모읍니다. signal: AbortSignal.timeout(20_000)을 달아 요청이 끝없이 매달리지 않게 합니다.

실전 규칙

코드를 어디에 둘까
  • 복잡해지는 서버 코드는 밖으로. service나 signal이 복잡해질 서버 전용 helper 코드는 srvkit에 둡니다.
  • 외부 라이브러리는 srvkit으로 들어옵니다. convention 파일이 쓰기 전에 여기서 먼저 감쌉니다.
  • 막는 건 Guard, 넘기는 건 InternalArg. 요청 방어에는 Guard를, context에서 만든 signal 인자에는 InternalArg를 씁니다.
  • 공용 외부 시스템은 adapt와 plug로. service가 재사용 가능한 외부 시스템 dependency를 필요로 할 때 씁니다.
  • app이냐 library냐. 앱 전용 연동은 app의 srvkit에, 재사용할 연동은 library의 srvkit에 둡니다.
srvkit 파일 안에서
  • 파일은 camelCase, class는 PascalCase. paymentApi.ts가 PaymentApi를 export합니다.
  • 클라이언트 코드와는 서로 import하지 않습니다. 클라이언트 파일(ui/, webkit/, *.store.ts, 모든 .tsx)은 srvkit을 import할 수 없고, srvkit도 store, ui/, webkit/, st barrel을 import할 수 없습니다.
  • Error가 아니라 Err를 throw합니다. Err는 ../lib/dict에서 import하고, 예외를 잡은 adaptor는 logger.error로 남긴 뒤 null을 돌려줍니다.
  • secret은 함수 안에서 읽습니다. process.env.X ?? options.x는 필요한 함수 안에서 쓰고, module scope에는 두지 않습니다.
  • 여기서는 #private이 기본입니다. #private 금지 lint는 constant, document, service, store 파일에만 걸립니다.

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

내 AI에 이 문서 연결하기

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