한국형 검색 엔진 서비스, Dabom Search API
한달 전쯤 친구와 함께 최근 AI 관련 이야기를 하다가 Langgraph 라는 흥미로운 라이브러리가 나와서 이걸 이용한 프로덕트를 한 번 만들어보자는 계획을 세웠다.
Langgraph는 LLM 에이전트를 명시적으로 더 쉽게 이용할 수 있게 해주는 오픈소스 라이브러리인데, Langchain AI 에서 만들었다. 이걸 이용해서 Tavily처럼 검색 API를 한국형으로 만들어보자는 아이디어를 내었다. 해외 서비스는 주로 영어 데이터를 크롤링 하기 때문에 한국어로는 제대로된 결과를 내주지 않았고, 네이버, 다음, 한국경제 등 한국의 접근성 있는 정보를 이용한 agent를 이용하면 더 나은 검색 결과를 제공할 수 있을 거라 확신했다.
사이트 링크: Dabom Search API
Langgraph에 관심이 많던 친구가 Langgraph 및 Elastic Search를 이용한 유사도 기반 검색 python 코드를 작성하였고, 나는 node.js를 이용하여 end 유저에게 제공할 wrapper api 서버와 데이터베이스, 랜딩 페이지, 그리고 api key, 계정 등을 관리할 web client dashboard를 Next.js로 작성하였다.
이 개발 과정에서 기억에 남는 것을 기록해보고자 한다.
프론트에서 DI 패턴 이용하기
사실 useState나 useCallback과 같은 기본적인 것 외에 잘 만들어 사용하는 편은 아니었는데, 점점 그 편의성을 느끼기 시작한 것 같다. 지금까지는 주로 내 웹서버에서 cookie를 이용한 session management를 이용해서 큰 불편함이 없었는데, 소셜 로그인을 도입하면서 편의를 위해 Next Auth라는 라이브러리를 이용하였고, 해당 라이브러리에서 제공하는 SessionProvider의 session instance의 속성을 이용하여 api 호출 시 유저 auth 정보를 전달해야 했기 때문에, 서버에 api를 호출하는 부분을 작성 시 반복되는 코드가 너무 많아졌다.
사실 Next auth의 auth 관련 endpoint만 Next.js 서버 엔드포인트를 이용하고, 다른 웹 서버는 따로 Nest.js를 이용해서 만들어 두었기 때문에 이런 귀찮은 상황이 발생했다고 볼 수 있다. 모든 관련 api를 Next.js 서버로 만들면 편하겠지만, 개인적으로 풀스택 프레임워크의 퍼포먼스를 온전하게 이끌어낼 자신이 없어 따로 구현하는 것을 선호하기에, custom hook을 이용하여 프론트에서 api 호출부를 간소화시켜 보았다.
기존에는 아래와 같이 axios를 이용하여 api를 호출하고, 그 함수를 컴포넌트에서 직접 import 해서 이용하는 방법을 이용했는데, 개별적으로 import 하는 것이 복잡할 뿐더러 보기에도 난잡해보이는 단점이 있었다. (편의상 interface는 any로 대체하여 표기하였다.)
// service/axios-instance.ts
import axios from 'axios';
const instance = axios.create({
baseURL: 'https://api.dabomai.com',
timeout: 1000,
headers: {
'Content-Type': 'application/json',
}
});
// Request interceptor
instance.interceptors.request.use(
config => {
return config;
},
error => {
return Promise.reject(error);
}
);
// Response interceptor
instance.interceptors.response.use(
response => {
return response;
},
error => {
return Promise.reject(error);
}
);
export default instance
// services/apikey/api.ts
import instance from './axios-instance.ts';
export function getApiKeys(sessionToken: string) {
return instance.get('/api-keys', {
headers: {
'Authorization': `Bearer ${sessionToken}`
}
});
}
// app/page.tsx
'use client'
import { getApiKeys } from "@/services/apikey/api.ts";
import { useSession } from "next-auth/react";
import { useState, useEffect } from 'react';
export default page() {
const session = useSession();
const { apiKeys, setApiKeys } = useState<any[]>([]);
const handleGetApiKey = useCallBack(async (session: any) => {
const res = getApiKeys(session.data?.sessionToken);
setApiKeys(res);
}, [session]);
...
}
하지만 이런식으로 구현을 하게 되면 여러가지 문제가 있었는데,
- 모든 api 함수에서 sessionToken을 받아 헤더에 할당하는 과정이 반복됨
- Next Auth에서 제공하는 Hook인 useSession이 해당 컴포넌트에서 null을 반환할 가능성이 있음
사실 2번 문제로 인해 빌드 자체가 되지 않고, 페이지를 로딩할 때 랜덤하게 session 오브젝트가 일정 타이밍에 로딩되지 않으면 session.data.sessionToken이 undefined가 되며 api를 올바르게 호출하지 못하는 문제가 발생하였다. 완벽하게 이유를 찾아내지는 못했지만, useSession이 가장 먼저 쿠키를 이용해 {bashURL}/auth 에 GET 요청을 보내 auth 정보를 얻어내고 이를 최상위 SessionProvider를 통해 전파하는 방식인데, client 컴포넌트에서는 이 과정이 컴포넌트의 로딩 시점과 일치하지 않는 문제로 session이 잠시 동안 null인 상태가 발생하는 것으로 추측했다. 따라서 이 잠시동안 handleGetApiKey
함수를 이용하면 Bearer token에 ‘undefined’ 라는 스트링이 담겨 요청이 가는 기막힌 상황이 발생했던 것이고, 이를 서버 로그를 통해서야 알아낼 수 있었다. (유연한 타입 캐스팅을 제공하는 js에 경의를 표한다)
따라서 client component에서 session이 제대로 생성되는 시점까지 api 호출 함수 오브젝트 생성을 지연시킬 방법이 필요했고, session instance를 component 여기저기에서 가져다 쓰는게 아니라 부모 컴포넌트에서 session이 생성되는 시점까지 모달을 띄우는 식으로 렌더링을 지연시키고, 생성된 시점에 session instance를 prop으로 넘기는 방법을 이용하기로 했다.
또한 Spring이나 Nest.js에서 주로 잘 이용하는 DI(Dependency Injection) 형식의 코드를 구상하였는데, useService라는 Hook으로 만들어서 컴포넌트에서 이용하면 편할 것 같다는 생각을 했다. 따라서 이러한 방법론을 차용하여 다음과 같이 코드를 리팩토링하였다. (jsx 컴포넌트 코드는 상당부분 생략)
// services/apikey/api.ts
import { AxiosInstance } from "axios";
import { Apikey } from "./interface";
export class ApikeyService {
private instance: AxiosInstance;
constructor(instance: AxiosInstance) {
this.instance = instance;
}
async getApiKeys() {
const res = await this.instance.get(`/apikey`);
return res.data as Apikey[];
}
async createApiKey(name: string) {
const res = await this.instance.post(`/apikey`, {
name,
});
return res.data as {
success: boolean;
message: string;
key: string;
};
}
async deleteApiKey(apiKeyId: number) {
const res = await this.instance.delete(`/apikey`, {
data: { api_key_id: apiKeyId },
});
return res.data as {
success: boolean;
message: string;
};
}
}
// services/useService.js
import axios from "axios";
import { ApikeyService } from "./apikey/api";
import { MembershipService } from "./membership/api";
import { UsageService } from "./usage/api";
import { SearchService } from "./search/api";
import { AuthService } from "./auth/api";
export const useService = (session) => {
const sessionToken = session.data.sessionToken;
const instance = axios.create({
baseURL: "https://api.dabomai.com",
headers: {
"Access-Control-Allow-Origin": "https://app.dabomai.com",
"Content-Type": "application/json",
Authorization: `Bearer ${sessionToken}`,
},
});
instance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
if (error.response?.status == 401) {
window.location.href = "/signin";
return Promise.reject(error);
}
return Promise.reject(error);
}
);
return {
membershipService: new MembershipService(instance),
apiKeyService: new ApikeyService(instance),
usageService: new UsageService(instance),
searchService: new SearchService(instance),
authService: new AuthService(instance),
};
};
// app/dashboard/page.tsx
"use client";
import { useEffect, useState } from "react";
import useModal from "@/lib/useModal";
import KeyIcon from "@/assets/icons/KeyIcon";
import ApiKeyScreen from "./api-key-screen";
import { useSession } from "next-auth/react";
import LoadingDialog from "../components/dialog/LoadingDialog";
export default function Dashboard() {
const session = useSession();
const [activeMenu, setActiveMenu] = useState<string>("");
const {
openModal: openLoadingModal,
renderModal: renderLoadingModal,
closeModal: closeLoadingModal,
} = useModal();
const contents = [
{
id: "keys",
icon: (size: number, color: string) => (
<KeyIcon size={size} color={color} />
),
content: (session: any) => <ApiKeyScreen session={session} />,
},
];
const renderContent = (session: any) => {
const content = contents.find((c) => c.id === activeMenu);
return content ? content.content(session) : <div></div>;
};
useEffect(() => {
// @ts-ignore
if (session.data?.sessionToken) {
closeLoadingModal();
if (!activeMenu) {
setActiveMenu("keys");
}
} else {
openLoadingModal();
}
}, [session, openLoadingModal, closeLoadingModal, activeMenu]);
return (
<div>
{renderContent(session)}
{renderLoadingModal(<LoadingDialog text="Initializing..." />)}
</div>
);
}
// app/dashboard/api-key-screen.tsx
"use client";
import { Apikey } from "@/services/apikey/interface";
import { useService } from "@/services/useService";
import { useCallback, useEffect, useState } from "react";
export default function ApiKeyScreen({ session }: { session: any }) {
const { apiKeyService } = useService(session);
const [apiKeys, setApiKeys] = useState<Apikey[]>([]);
const handleGetApiKeys = useCallback(async () => {
const res = await apiKeyService.getApiKeys();
setApiKeys(res);
}, [apiKeyService]);
useEffect(() => {
handleGetApiKeys();
}, []);
return (
<div>
{apiKeys.map((key) => (
<div className={styles.keyItem} key={key.api_key_id}>
<p>{key.name}</p>
<p>{key.api_key}</p>
<p>{new Date(key.created_at).toISOString().split("T")[0]}</p>
</div>
))}
</div>
);
}
따라서 session이 확실히 생성된 시점, 즉 자식 컴포넌트가 렌더링된 시점에 useService hook이 호출되고, hook 내부에서는 관련 api 엔드포인트를 wrapping한 class를 Inject 받음으로써 올바른 api 함수를 컴포넌트에 전달할 수 있고, 한 컴포넌트에서는 서로 연관된 endpoint를 이용할 가능성이 높다는 점에서 코드의 작성의 효율성도 보장할 수 있게 되었다.
Modal 편하게 띄우기
웹 개발에서 모달은 떼려야 뗄 수 없는 기능인 것 같다. 유저 경험에 있어 페이지 이동 없이 필요한 추가적인 정보를 빠르고 심미적으로 전달할 수 있다는 점에서 훌륭한 디자인이라 생각하고 있고, 자주 이용하고 있다.
다양한 컴포넌트 라이브러리를 이용하거나 하면 편하게 모달을 띄울 수 있지만, 굳이 필요하지 않은 의존성을 만드는 것을 싫어하기에 모달도 구현하여 사용해왔다. 이전에는 주로 React에서 제공하는 createPortal을 이용하여 구현했는데, 매번 createPortal을 이용하는 것이 불필요하다 생각하였고, 좀 더 선언적으로 이용하고 싶은 마음에 이것 또한 Hook으로 만들어 사용하면 편하겠다는 생각을 했다. 찾아보니 useModal 훅을 만들어 이용하는 예시는 많았고, 이에 더해 unmount 될 때의 애니메이션도 적용하기 위해 framer-motion
을 이용하여 custom useModal Hook을 작성하였다.
// lib/useModal.js
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import ReactDOM from "react-dom";
export default function useModal() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContainer, setModalContainer] = useState(null);
useEffect(() => {
const modalRoot = document.getElementById("modal-root");
setModalContainer(modalRoot);
}, []);
const openModal = () => {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
};
const renderModal = (children) => {
if (!modalContainer) return null;
return ReactDOM.createPortal(
<AnimatePresence>
{isModalOpen && (
<motion.div
key={modalContainer.id}
style=
initial=
animate=
exit=
transition=
>
{children}
</motion.div>
)}
</AnimatePresence>,
modalContainer
);
};
return {
isModalOpen,
openModal,
closeModal,
renderModal,
};
}
확실히 프론트엔드 부분은 코드 작성의 convention이 서버 사이드에 비해 덜 고착화되어 있는 느낌이어서 코드 작성에 좀 더 자율성도 있고, 나름의 매력이 있는 것 같다. 다만 충분히 효율적인 코드를 작성하기 위해 노력해야 하기에, 공부를 꾸준히 이어가야겠다.
Enjoy Reading This Article?
Here are some more articles you might like to read next: