eunsoolib
비동기·서버

async-context

AsyncLocalStorage 기반 타입 안전 컨텍스트와 Next.js route 래퍼

Props drilling 없이 비동기 실행 컨텍스트 내에서 데이터를 공유할 수 있습니다. React Context와 유사하지만 서버 사이드(Node.js)에서 동작합니다.

설치

pnpm add @cbcruk/async-context

핵심 컨셉

┌─────────────────────────────────────────────────────────────┐
│  userContext.run({ id: "123" }, async () => {              │
│    ┌─────────────────────────────────────────────────────┐ │
│    │  getProfile()                                       │ │
│    │    └─ userContext.get() → { id: "123" }            │ │
│    │       └─ getAppointments()                         │ │
│    │          └─ userContext.get() → { id: "123" }      │ │
│    │             └─ getNotifications()                  │ │
│    │                └─ userContext.get() → { id: "123" }│ │
│    └─────────────────────────────────────────────────────┘ │
│  })                                                         │
└─────────────────────────────────────────────────────────────┘

사용법

1. 컨텍스트 생성

import { createAsyncContext } from '@cbcruk/async-context'

interface User {
  id: string
  name: string
  role: 'admin' | 'user'
}

// 컨텍스트 생성
const userContext = createAsyncContext<User>({
  name: 'user',
  errorMessage: '인증된 사용자를 찾을 수 없습니다.',
})

2. 컨텍스트 사용

// 서비스 함수 - props drilling 없음!
async function getProfile() {
  const user = userContext.get() // 어디서든 접근 가능
  return {
    ...user,
    appointments: await getAppointments(),
  }
}

async function getAppointments() {
  const user = userContext.get() // 여기서도 접근 가능
  return db.query('SELECT * FROM appointments WHERE user_id = $1', [user.id])
}

// Route Handler에서 컨텍스트 설정
export async function GET(request: Request) {
  const user = await authenticate(request)

  const profile = await userContext.run(user, async () => {
    return getProfile() // 내부 함수들이 자동으로 user에 접근
  })

  return Response.json(profile)
}

Next.js Route Handler 통합

단일 컨텍스트 래퍼

import { createRouteWrapper } from '@cbcruk/async-context'

const withAuth = createRouteWrapper(userContext, async (request) => {
  return await authenticate(request)
})

// route.ts
export const GET = withAuth(async () => {
  const user = userContext.get()
  return Response.json({ user })
})

다중 컨텍스트 래퍼

import { createMultiRouteWrapper } from '@cbcruk/async-context'

const withContext = createMultiRouteWrapper({
  user: {
    context: userContext,
    setup: (req) => authenticate(req),
  },
  db: {
    context: dbContext,
    setup: () => getDbConnection(),
  },
  logger: {
    context: loggerContext,
    setup: () => createLogger(),
  },
})

export const GET = withContext(async () => {
  const user = userContext.get()
  const db = dbContext.get()
  const logger = loggerContext.get()

  logger.info('Fetching profile', { userId: user.id })
  return Response.json(await db.getProfile(user.id))
})

미들웨어 패턴

Express/Koa 스타일의 미들웨어 체인을 구성할 수 있습니다.

import {
  createContextMiddleware,
  runWithMiddlewares,
} from '@cbcruk/async-context'

// 미들웨어 정의
const requestMiddleware = createContextMiddleware(requestContext, (req) => ({
  id: crypto.randomUUID(),
  startTime: Date.now(),
}))

const authMiddleware = createContextMiddleware(userContext, async (req) => {
  const token = req.headers.get('authorization')
  return await validateToken(token)
})

// 미들웨어 체인 실행
const result = await runWithMiddlewares(
  request,
  [requestMiddleware, authMiddleware],
  async () => handleRequest(),
)

디버깅

import { debugContexts } from '@cbcruk/async-context'

const status = debugContexts({
  user: userContext,
  db: dbContext,
})

console.log(status)
// {
//   user: { active: true, value: { id: "123", ... } },
//   db: { active: false, value: undefined }
// }

실제 사용 예제: 의료 예약 시스템

// contexts.ts
export const userContext = createAsyncContext<User>({ name: 'user' })
export const dbContext = createAsyncContext<DbConnection>({ name: 'db' })

// services/appointment.ts
export async function createAppointment(data: AppointmentInput) {
  const user = userContext.get()
  const db = dbContext.get()

  return db.transaction(async (trx) => {
    const appointment = await trx.insert('appointments', {
      ...data,
      patientId: user.id,
    })

    // 중첩 함수에서도 컨텍스트 접근 가능
    await notifyDoctor(data.doctorId, appointment)

    return appointment
  })
}

async function notifyDoctor(doctorId: string, appointment: Appointment) {
  const user = userContext.get() // 여전히 접근 가능
  const db = dbContext.get()

  await db.insert('notifications', {
    userId: doctorId,
    message: `새 예약: ${user.name}님`,
  })
}

// route.ts
const withContext = createMultiRouteWrapper({
  user: { context: userContext, setup: authenticate },
  db: { context: dbContext, setup: getDbConnection },
})

export const POST = withContext(async (req) => {
  const body = await req.json()
  const appointment = await createAppointment(body)
  return Response.json(appointment)
})

API

createAsyncContext<T>(options?)

새로운 컨텍스트를 생성합니다.

const ctx = createAsyncContext<string>({
  name: 'contextName', // 에러 메시지에서 사용
  defaultValue: 'default', // 기본값 (선택)
  errorMessage: '커스텀 에러 메시지', // 선택
})

반환 객체:

메서드설명
run(value, callback)값을 설정하고 콜백 실행
get()현재 값 반환 (없으면 defaultValue, 그것도 없으면 에러)
getOptional()현재 값 반환 (없으면 defaultValue, 그것도 없으면 undefined)
isActive()컨텍스트 활성 여부 (defaultValue와 무관)

defaultValue를 준 컨텍스트에서는 컨텍스트 밖에서도 get()getOptional()이 같은 값을 반환합니다. 값이 실제로 run()으로 설정됐는지 구분하려면 isActive()를 쓰세요.

composeContexts(contexts, values, callback)

여러 컨텍스트를 동시에 설정하고 callback의 반환값을 그대로 돌려줍니다. 반환 타입도 callback에서 추론되므로(() => RR) 단언이 필요 없습니다.

const result = await composeContexts(
  { user: userContext, db: dbContext },
  { user: currentUser, db: dbConnection },
  async () => {
    // 두 컨텍스트 모두 접근 가능
    return handleRequest()
  },
)

getContextValues(contexts)

여러 컨텍스트의 값을 한 번에 가져옵니다.

const { user, db } = getContextValues({
  user: userContext,
  db: dbContext,
})

주의사항

  1. Node.js 전용: 브라우저에서는 동작하지 않습니다.
  2. 실행 컨텍스트 기반: run() 블록 외부에서는 값에 접근할 수 없습니다.
  3. 동시성 안전: 각 요청은 독립된 컨텍스트를 가집니다.

영감

On this page