설치

Next.js 에서 Hackle 을 사용하시려면 @hackler/react-sdk를 설치해야 합니다.

npm install @hackler/react-sdk
yarn add @hackler/react-sdk
pnpm add @hackler/react-sdk

.env.development, .env.production 파일에 Hackle SDK 키를 개발환경, 운영환경에 맞게 각각 추가하세요.

NEXT_PUBLIC_HACKLE_SDK_KEY=your-hackle-sdk-key

SDK 키는 https://dashboard.hackle.io/config/sdk-setting 에서 App/Browser 키를 찾을 수 있습니다.


연동 방법

Hackle은 Next.js 의 Page RouterApp Router를 모두 지원합니다. 각각 Hackle을 연동하는 방법에는 몇 가지 차이점이 있습니다.


인스턴스 생성

// app/hackleClient.client.ts

"use client";

import { createInstance } from "@hackler/react-sdk";

export const hackleClient = createInstance(
  process.env.NEXT_PUBLIC_HACKLE_SDK_KEY!
);

// app/hackleClient.client.ts

import { createInstance } from "@hackler/react-sdk";

export const hackleClient = createInstance(
  process.env.NEXT_PUBLIC_HACKLE_SDK_KEY!
);


App Router - 프로바이더 생성

// app/HackleClientProvider.tsx

"use client";

import { HackleProvider } from "@hackler/react-sdk";
import { hackleClient } from "@/app/hackleClient.client";

export function HackleClientProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <HackleProvider hackleClient={hackleClient} user={{userId: "a-user-id"}} supportSSR>
      {children}
    </HackleProvider>
  );
}


App Router - 루트 레이아웃 설정

import { HackleClientProvider } from "@/app/HackleClientProvider";
import "./globals.css";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body>
        <HackleClientProvider>
          {children}
        </HackleClientProvider>
      </body>
    </html>
  );
}

Page Router - 프로바이더 사용

// pages/_app.tsx

import type { AppProps } from "next/app"
import { HackleProvider } from "@hackler/react-sdk"
import { hackleClient } from "@/app/hackleClient.client"

export default function App({ Component, pageProps }: AppProps) {
  return (
    <HackleProvider hackleClient={hackleClient} user={{ userId: "a-user-id" }} supportSSR>
      <Component {...pageProps} />
    </HackleProvider>
  )
}


주요 기능

A/B 테스트 그룹 분배

"use client"

import { useLoadableVariationDetail } from "@hackler/react-sdk"
import DecisionComponent from "./DecisionComponent"

interface ClientComponentProps {
  experimentKey: number
}

export default function ClientComponent({ experimentKey }: ClientComponentProps) {
  const { decision, isLoading } = useLoadableVariationDetail(experimentKey)
  if (isLoading) return <div>Loading...</div>
  const { variation, reason, experiment } = decision

  return (
    <div>
      <h3>Client Component</h3>
      <DecisionComponent
        variation={variation}
        reason={String(reason)}
        experimentKey={experiment?.key.toString() ?? "-"}
        experimentVersion={experiment?.version.toString() ?? "-"}
      />
    </div>
  )
}

import { useLoadableVariationDetail } from "@hackler/react-sdk"
import DecisionComponent from "./DecisionComponent"

interface ClientComponentProps {
  experimentKey: number
}

export default function ClientComponent({ experimentKey }: ClientComponentProps) {
  const { decision, isLoading } = useLoadableVariationDetail(experimentKey)
  if (isLoading) return <div>Loading...</div>
  const { variation, reason, experiment } = decision

  return (
    <div>
      <h3>Client Component</h3>
      <DecisionComponent
        variation={variation}
        reason={String(reason)}
        experimentKey={experiment?.key.toString() ?? "-"}
        experimentVersion={experiment?.version.toString() ?? "-"}
      />
    </div>
  )
}


기능플래그 결정

"use client"

import { useLoadableFeatureDetail } from "@hackler/react-sdk"
import { FEATURE_FLAG_KEY } from "@/app/constants"
import DecisionComponent from "./DecisionComponent"

export default function ClientComponent() {
  const { decision, isLoading } = useLoadableFeatureDetail(FEATURE_FLAG_KEY)
  if (isLoading) return <div>Loading...</div>
  const { isOn, reason, experiment } = decision

  return (
    <div>
      <h3>Client Component</h3>
      <DecisionComponent
        isOn={isOn}
        reason={String(reason)}
        experimentKey={experiment?.key.toString() ?? "-"}
        experimentVersion={experiment?.version.toString() ?? "-"}
      />
    </div>
  )
}

import { useLoadableFeatureDetail } from "@hackler/react-sdk"
import { FEATURE_FLAG_KEY } from "@/app/constants"
import DecisionComponent from "./DecisionComponent"

export default function ClientComponent() {
  const { decision, isLoading } = useLoadableFeatureDetail(FEATURE_FLAG_KEY)
  if (isLoading) return <div>Loading...</div>
  const { isOn, reason, experiment } = decision

  return (
    <div>
      <h3>Client Component</h3>
      <DecisionComponent
        isOn={isOn}
        reason={String(reason)}
        experimentKey={experiment?.key.toString() ?? "-"}
        experimentVersion={experiment?.version.toString() ?? "-"}
      />
    </div>
  )
}


원격구성 적용

"use client";

import useRemoteConfigWithParsing from "@/app/hooks/useRemoteConfigWithParsing";
import { REMOTE_CONFIG_KEY } from "@/app/constants";

const defaultConfig = {
  isDemo: false,
};

export default function ClientComponent() {
  const config = useRemoteConfigWithParsing(REMOTE_CONFIG_KEY, defaultConfig);
  if (config.isLoading) return <div>Loading...</div>

  return (
    <div>
      <h3>Client Component</h3>
      <dl>
        <dt>defaultConfig</dt>
        <dd>{JSON.stringify(defaultConfig, null, 2)}</dd>
        <dt>config</dt>
        <dd>{JSON.stringify(config.value, null, 2)}</dd>
      </dl>
    </div>
  );
}

import useRemoteConfigWithParsing from "@/app/hooks/useRemoteConfigWithParsing";
import { REMOTE_CONFIG_KEY } from "@/app/constants";

const defaultConfig = {
  isDemo: false,
};

export default function ClientComponent() {
  const config = useRemoteConfigWithParsing(REMOTE_CONFIG_KEY, defaultConfig);
  if (config.isLoading) return <div>Loading...</div>

  return (
    <div>
      <h3>Client Component</h3>
      <dl>
        <dt>defaultConfig</dt>
        <dd>{JSON.stringify(defaultConfig, null, 2)}</dd>
        <dt>config</dt>
        <dd>{JSON.stringify(config.value, null, 2)}</dd>
      </dl>
    </div>
  );
}

// app/hooks/useRemoteConfigWithParsing.ts

import { useLoadableRemoteConfig } from "@hackler/react-sdk"

export default function useRemoteConfigWithParsing<T>(key: string, defaultValue: T): { value: T; isLoading: boolean } {
  const { remoteConfig, isLoading } = useLoadableRemoteConfig()
  if (isLoading) return { value: defaultValue, isLoading }

  try {
    const configValue = remoteConfig.get(key, JSON.stringify(defaultValue))
    return { value: JSON.parse(configValue), isLoading }
  } catch (error) {
    console.warn("Failed to parse remote config:", error)
    return { value: defaultValue, isLoading }
  }
}


이벤트 전송

// app/example.ts

"use client"

import { useTrack } from "@hackler/react-sdk";

export default function Example() {
  const track = useTrack();

  return <button onClick={() => track({ key: "test" })}>test</button>;


고급설정

Next.js 에서 서버사이드 분배를 위해서는 추가 설정이 필요합니다. 서버사이드에서 분배를 하는 경우 분배 식별자와 서버 사이드와 클라이언트 사이드에서 실행되는 코드를 주의 깊게 관리해야 합니다.


설치

서버사이드(Node.js 환경)에서 사용될 sdk 를 별도로 설치해야 합니다.

npm install @hackler/javascript-sdk
yarn add @hackler/javascript-sdk
pnpm add @hackler/javascript-sdk


SDK 키 추가

.env.development, .env.production 파일에 Hackle SDK 키를 개발환경, 운영환경에 맞게 각각 추가하세요.

HACKLE_SDK_KEY_SERVER=your-hackle-sdk-key

SDK 키는 https://dashboard.hackle.io/config/sdk-setting 에서 Server 키를 찾을 수 있습니다.


연동 방법


// app/hackleClient.server.ts

import { createInstance } from "@hackler/javascript-sdk";

export const hackleClient = createInstance(
  process.env.NEXT_PUBLIC_HACKLE_SDK_KEY!
);



서버사이드 사용 방법


A/B 테스트 그룹 분배

import { hackleClient } from "@/app/HackleClient.server";
import DecisionComponent from "./DecisionComponent";

interface ServerComponentProps {
  experimentKey: number;
}

export default async function ServerComponent({
  experimentKey,
}: ServerComponentProps) {
  const userId = "a-user-id";
  await hackleClient.onInitialized();
  const { variation, reason, experiment } = hackleClient.variationDetail(
    experimentKey,
    { userId }
  );

  return (
    <div>
      <h3>Server Component</h3>
      <DecisionComponent
        variation={variation}
        reason={String(reason)}
        experimentKey={experiment?.key.toString() ?? "-"}
        experimentVersion={experiment?.version.toString() ?? "-"}
      />
    </div>
  );
}

import { Decision } from "@hackler/javascript-sdk";
import { hackleClient } from "@/app/HackleClient.server";
import DecisionComponent from "./DecisionComponent";

interface ServerComponentProps {
  decison: Decision;
}

export default async function ServerComponent({
  decison
}: ServerComponentProps) {
  return (
    <div>
      <h3>Server Component</h3>
      <DecisionComponent
        variation={decison.variation}
        reason={String(decison.reason)}
        experimentKey={decison.experiment?.key.toString() ?? "-"}
        experimentVersion={decison.experiment?.version.toString() ?? "-"}
      />
    </div>
  );
}

export const getServerSideProps = async () => {
  const userId = "a-user-id";
  await hackleClient.onInitialized();
  const decision = hackleClient.variationDetail(experimentKey, { userId });
  return {
    props: {
      decision,
    },
  };
};


기능플래그 결정

import { hackleClient } from "@/app/HackleClient.server";
import DecisionComponent from "./DecisionComponent";

interface ServerComponentProps {
  featureFlagKey: number;
}

export default async function ServerComponent({
  featureFlagKey,
}: ServerComponentProps) {
  const userId = "a-user-id";
  await hackleClient.onInitialized()
  const featureFlagDetail = hackleClient.featureFlagDetail(featureFlagKey, {
    userId
  });

  return (
    <div>
      <h3>Server Component</h3>
      <DecisionComponent
        isOn={featureFlagDetail.isOn}
        reason={String(featureFlagDetail.reason)}
        experimentKey={featureFlagDetail.experiment?.key.toString() ?? "-"}
        experimentVersion={
          featureFlagDetail.experiment?.version.toString() ?? "-"
        }
      />
    </div>
  );
}

import { FeatureFlagDecision } from "@hackler/javascript-sdk";
import { hackleClient } from "@/app/HackleClient.server";
import DecisionComponent from "./DecisionComponent";

interface ServerComponentProps {
  featureFlagDecision: FeatureFlagDecision;
}

export default async function ServerComponent({
  featureFlagDecision,
}: ServerComponentProps) {
  return (
    <div>
      <h3>Server Component</h3>
      <DecisionComponent
        isOn={featureFlagDecision.isOn}
        reason={String(featureFlagDecision.reason)}
        experimentKey={featureFlagDecision.experiment?.key.toString() ?? "-"}
        experimentVersion={
          featureFlagDecision.experiment?.version.toString() ?? "-"
        }
      />
    </div>
  );
}

export const getServerSideProps = async () => {
  const userId = "a-user-id";

  await hackleClient.onInitialized();
  const featureFlagDecision = hackleClient.featureFlagDetail(featureFlagKey, {
    userId,
  });

  return {
    props: {
      featureFlagDecision,
    },
  };
};


원격구성 적용

import { hackleClient } from "@/app/HackleClient.server";

const defaultConfig = {
  isDemo: false,
};

interface ServerComponentProps {
  remoteConfigKey: string;
}

export default async function ServerComponent({
  remoteConfigKey,
}: ServerComponentProps) {
  const userId = "a-user-id";
  await hackleClient.onInitialized()
  const remoteConfig = hackleClient.remoteConfig({
    userId
  });

  const config = JSON.parse(
    remoteConfig.get(remoteConfigKey, JSON.stringify(defaultConfig))
  );

  return (
    <div>
      <h3>Server Component</h3>
      <dl>
        <dt>defaultConfig</dt>
        <dd>{JSON.stringify(defaultConfig, null, 2)}</dd>
        <dt>config</dt>
        <dd>{JSON.stringify(config, null, 2)}</dd>
      </dl>
    </div>
  );
}

import { hackleClient } from "@/app/HackleClient.server";

const defaultConfig = {
  isDemo: false,
};

interface ServerComponentProps {
  config: typeof defaultConfig;
}

export default async function ServerComponent({
  config,
}: ServerComponentProps) {
  return (
    <div>
      <h3>Server Component</h3>
      <dl>
        <dt>defaultConfig</dt>
        <dd>{JSON.stringify(defaultConfig, null, 2)}</dd>
        <dt>config</dt>
        <dd>{JSON.stringify(config, null, 2)}</dd>
      </dl>
    </div>
  );
}

export const getServerSideProps = async () => {
  const userId = "a-user-id";
  await hackleClient.onInitialized();
  const remoteConfig = hackleClient.remoteConfig({
    userId,
  });

  const config = JSON.parse(
    remoteConfig.get(remoteConfigKey, JSON.stringify(defaultConfig))
  );

  return {
    props: {
      config: config,
    },
  };
};


이벤트 전송

import { hackleClient } from "@/app/HackleClient.server";

export default async function Example() {
	hackleClient.track({key: "test"}, {userId: "a-user-id"});
}

import { hackleClient } from "@/app/HackleClient.server";

export default async function Example() {
	hackleClient.track({key: "test"}, {userId: "a-user-id"});
}



instrumentation

HackleClient 는 초기화시 Hackle Server 로 부터 설정 정보를 받아오고 이후 주기적으로 동기화를 합니다. 따라서 instrumentation.ts 를 활용하면 await hackleClient.onInitialized() 와 같은 코드를 매 페이지에서 사용하지 않고 코드를 더 간단하게 유지할 수 있습니다. 단 instrumentationHook 이 기본설정이 되는 Next.js v15 이상에서 권장 합니다.

// app/hackleClient.server.ts

import { createInstance } from "@hackler/javascript-sdk";

declare global {
  var __hackleClient: ReturnType<typeof createInstance> | undefined;
  var __hackleInitialized: boolean | undefined;
}

if (!global.__hackleClient) {
  global.__hackleClient = createInstance(
    process.env.NEXT_PUBLIC_HACKLE_SDK_KEY!
  );
}

export async function initializeHackle() {
  if (global.__hackleInitialized) {
    return;
  }

  try {
    await global.__hackleClient!.onInitialized({
      timeout: 10000,
    });

    global.__hackleInitialized = true;
  } catch (error) {
    console.error("❌ Hackle SDK initialization failed:", error);
    throw error;
  }
}

export const hackleClient = global.__hackleClient;



// instrumentation.ts

import { initializeHackle } from "@/app/hackleClient.server";

export async function register() {
  await initializeHackle();
}