개요
AI 개발을 위한 플랫폼으로, RAG와 프롬프트 엔지니어링, llmops 기능 등을 제공하여 사용자가 AI 솔루션을 쉽게 구축하고 배포할 수 있도록 하는 앱
- 기간: 2024.5 ~ 2024.12 (진행 중)
- 인원: 프론트엔드 1명(나), 백엔드 2명, RAG 모듈 1명, PM 겸 기획자 1명, 디자이너 1명, 영업 1명
- 내 역할: 프론트엔드 전체
- 내가 사용한 기술:
- 모노레포 - turborepo, pnpm
- SSR, SSG, CSR - Next.js 14 앱 라우터
- 디자인 시스템, 스타일링 라이브러리 - shadcn/ui, tailwind
- 상태 관리 - react query, zustand
- 타입 안전성 - typescript
- 폼 관리 - react hook form, zod
- 테스트 - rtl, Jest
개발
모노레포 구축
처음에는 단일 Next.js 앱으로 시작했다. 프로젝트 구조는 다음과 같았다.
- app
- components
- hooks
- lib
- mocks
- remote
- types
// 여러 파일들
하지만 개발을 진행하면서 관리자 사이트를 별도로 개발하기로 기획이 변경되었다.
초기에는 기존 앱을 복사하여 테마만 수정하는 방식으로 진행할 계획이었지만, 이 경우 중복된 코드가 두 군데에 존재하게 되어 추후 유지보수 및 변경 작업이 매우 어려워질 것이라는 걱정이 있었다.
그래서 모노레포 구조로 전환하기로 결정했다.
// 모노레포
- apps
- user-panel: 관리자 웹
- admin-panel: 사용자 웹
- krc-chatbot: 농어촌공사 챗봇 웹
- packages
- components: ui 컴포넌트 모음
- hooks: react hook 모음
- lib: util 함수 모음
- remote: 서버 호출 함수 모음
- types: 타입 모음
# 각종 config
- eslint-config
- tailwind-config
- typescript-config
여러 앱에서 패키지들을 사용하도록 변경했다. 패키지를 한 곳에서 관리하니 편리하다. 코드 중복도 없어지고 가독성도 좋아졌다.
하지만 단점도 있다. 환경 설정하는 데 시간이 오래 걸렸다. 특히 VS Code 설정, 각종 구성 파일 설정, 빌드 설정 등이 번거로웠다.
또한 Next.js 앱에서 모노레포로 전환하다 보니, components의 의존성으로 Next.js가 포함되었다. Next.js의 redirect 등을 사용하는 컴포넌트가 몇 개 있는데, 해당 컴포넌트를 사용하려면 반드시 Next.js 앱이어야 한다. 이로 인해 확장에 제한이 생겼다.
디자인 시스템 활용
shadcn/ui를 사용했다. 기존의 다른 디자인 시스템(예: Material UI, Ant Design)과 가장 큰 차별점은 라이브러리를 import해서 사용하는 것이 아니라, 코드를 CLI로 가져와서 직접 관리한다는 점이다.
이 때문에 컴포넌트를 커스터마이징하기가 쉽고, 테마 적용도 간편하다.
단점으로는 Tailwind 기반이기 때문에 Tailwind를 배워야 한다. 하지만 Tailwind에 익숙해지니 사용하기 정말 좋다.
Tailwind는 내가 사용해본 모든 스타일링 라이브러리 중 최고의 사용성을 제공하며, SSR과의 호환성도 뛰어나고 성능도 좋다.
상태 관리
전역 상태 관리는 zustand를 사용하고, 서버 상태 관리는 react-query를 활용했다.
zustand store는 매우 가볍게 구성했다.
- 서버 enum 중 프론트에서 사용하는 항목들을 받아와 enum store에서 관리한다.
- 사용자의 메뉴와 페이지별 접근 권한은 로그인 시 받아와 MenuAccessStore에서 관리한다.
개인적으로 전역 상태 관리는 적은 상태만 관리해야 한다고 생각한다. 진정으로 전역에서 사용하는 상태는 많지 않기 때문이다. 특정 컴포넌트 트리에서 공유하는 상태는 context를 사용하여 전달했다.
context는 렌더링 최적화 등 신경 써야 할 부분이 많아 단점이 있다. 최적화를 위해 provider 컴포넌트를 따로 만들고, value와 action을 분리하며, useMemo로 감싸서 자식 컴포넌트에 전달하고 memo로 자식 컴포넌트를 감싸줘야 하는데, 이 과정이 다소 번거롭다.
//// context 최적화 예시 - gpt가 만들어 줌
const MyContext = createContext(null);
export const MyProvider = ({ children }) => {
const { state, actions } = useStore();
const value = useMemo(() => ({ state, actions }), [state, actions]);
return (
<MyContext.Provider value={value}>
{children}
</MyContext.Provider>
);
};
export const useMyContext = () => {
return useContext(MyContext);
};
// 자식 컴포넌트에서 사용 예시
const ChildComponent = React.memo(() => {
const { state, actions } = useMyContext();
return (
<div>
{/* 상태와 액션 사용 */}
</div>
);
}); context 최적화 예시
서버 상태 관리는 react-query를 사용한다. react-query는 캐싱과 fetch 상태 관리를 추상화하여 사용하기 쉽게 제공해 준다.
모든 앱은 서버 fetch를 사용하지 않고 클라이언트에서 백엔드로 직접 요청하도록 구성했다. Next.js 서버에서 DB를 직접 조회하거나 데이터를 가공하는 것이 아닌 이상, 굳이 Next.js 서버를 거칠 필요는 없다고 생각한다. 서버가 굳이 필요 없지만, 추후 서버 fetch 기능을 활용하거나 서버 쪽 확장을 위해 서버를 운영하고 있다.
query key와 fetch 함수들은 remotes 패키지에서 카테고리별로 관리하고 있다.
- project
- retriever
- RetrieverRemote.ts // fetch 함수
- retrieverQueries.ts // 쿼리 키, 옵션
- useRetrieverRemote.ts // useQuery, useMutation 모음
// 등등
react-query 개발자가 제시한 패턴(아닐 수도 있음)을 따라 쿼리 키를 따로 분리하여 관리하므로, mutate 동작 후 invalidateQueries를 사용할 때 쉽게 가져와서 쓸 수 있다. 이로 인해 중복이 줄어든다.
export const queryKeys = {
all: (params: GetRetrieversParams) => ["retrievers", params] as const,
detail: (id: number) => ["retrievers", id] as const,
}
export const queryOptions = {
all: (params: GetRetrieversParams) => ({
queryKey: queryKeys.all(params),
queryFn: () => RetrieverRemote.getRetrievers(params),
throwOnError: true,
}),
detail: (id: number) => {
return {
queryKey: queryKeys.detail(id),
queryFn: () => RetrieverRemote.getRetriever(id),
throwOnError: true,
}
},
}
카테고리별로 모아 놓으니 보기 편하고 관리하기도 용이하다.
폼 관리
react-hook-form과 zod를 활용한다.
zod는 폼 유효성 검사를 쉽게 해주며, react-hook-form의 zodResolver를 사용하여 연동이 간편하다. 유효성 검사 조건들을 따로 위에 두어 보기 쉽게 구성한다.
// zod schema 선언
const FormSchema = z
.object({
userName: z
.string({
required_error: "이름을 입력해주세요.",
})
.min(2, {
message: "최소 2글자 이상으로 입력해주세요.",
})
.max(64, {
message: "최대 64글자 이하로 입력해주세요.",
}),
password: z
.string({
required_error: "비밀번호를 입력해주세요.",
})
.regex(passwordRegex, {
message: passwordGuideMessage,
}),
passwordConfirm: z
.string({
required_error: "비밀번호를 재입력해주세요.",
})
.regex(passwordRegex, {
message: passwordGuideMessage,
}),
userEmail: z
.string({
required_error: "이메일을 입력해주세요.",
})
.email("이메일 형식으로 입력해주세요."),
})
.refine((data) => data.password === data.passwordConfirm, {
message: "비밀번호가 일치하지 않습니다.",
path: ["passwordConfirm"],
})
사용한 디자인 시스템인 shadcn/ui의 form 컴포넌트는 react-hook-form를 기초로 구성되어 있어 해당 컴포넌트를 사용했다.
입력 필드 아래에 오류 메시지를 표시하는 부분 등을 모두 추상화하여 제공하므로 매우 편리하다.
폼 관리를 할 때 zod와 react-hook-form은 필수라고 생각한다.
테스트
krc-chatbot 농어촌공사 챗봇 앱에만 테스트 코드를 추가했다. 가장 많은 사용자가 있어 우선적으로 넣어둔 것이다.
rtl과 jest를 활용하며, beforeEach를 통해 로그인 세션을 mocking하고, afterEach에서 정리해준다.
추후 Next.js 서버를 운영하는 앱들에도 테스트 코드를 추가할 예정인데, 이 경우 서버까지 고려해야 하므로 테스트 코드가 훨씬 복잡해질 것으로 예상된다. 조만간 작성해봐야겠다.
// 농어촌공사 챗봇 테스트 코드
describe("채팅화면 PC", () => {
// sessionManager는 로그인 세션 관리하는 모듈
beforeEach(() => {
sessionManager.set("accessToken", encryptForClient("TEST TOKEN"))
sessionManager.set("authUid", encryptForClient("TEST AUTH UID"))
sessionManager.set("isInitPwd", encryptForClient("false"))
sessionManager.set("lastLoginDt", encryptForClient("2024-10-29 13:02:02"))
sessionManager.set("userId", encryptForClient("12345678"))
sessionManager.set("userName", encryptForClient("사용자"))
sessionManager.set("userType", encryptForClient("SUPER"))
})
afterEach(() => {
sessionManager.clear()
})
it("상단 헤더의 로고 클릭시 중앙에 인사말, 추천 파이프라인이 표시된다.", async () => {
render(<ChatClient />)
const welcomeMessage = await screen.findByText("안녕하세요. 사용자 님!")
const pipelines = await screen.findAllByRole("button", {
name: /파이프라인/,
})
expect(welcomeMessage).toBeInTheDocument()
expect(pipelines).toBeInTheDocument()
})
// 생략...
}
CI/CD
gitlab-ci와 gitlab-runner, Docker를 활용하여 CI/CD 파이프라인을 구성했다. 처음에는 내가 프론트엔드만 임시로 구성하고, 이후 팀원이 전체 앱 파이프라인을 구축해 주셨다.
그 다음, 나는 단계를 나누고 Azure Container Registry(ACR)와 연동하며 롤백 기능을 추가하는 작업을 진행했다. 서버에서 제공한 Dockerfile과 내가 만든 프론트 Dockerfile을 사용하여 Dockerizing하고 배포했다.
총 5단계로 나누었으며, 단계는 다음과 같다:
- 환경 설정
- 빌드 및 푸시
- 배포 (롤백은 수동으로 버튼을 눌러서 가능)
- 검증
- 정리
gitlab-runner 환경에서 빌드하고 ACR에 이미지를 푸시한다. 배포는 해당 이미지를 서버에서 풀하고 컨테이너를 구동하는 방식으로 진행된다. 검증 단계에서는 배포 후 각 컨테이너가 정상적으로 실행되는지 확인하고 로그를 검토한다.
정리 단계에서는 배포 후 gitlab-runner의 로컬에 남아 있는 이미지를 삭제하고, pruning 작업을 수행한다.
각 단계를 hidden job으로 구성하여 여러 파일에서 재사용할 수 있는 템플릿 형태로 만들었다.
- environments
- dev.gitlab-ci.yml // tb
- krc.gitlab-ci.yml // 농어촌공사용 앱
- ktds.gitlab-ci.yml // 회사 내부 앱
- templates
- setup_environment.yml // 환경 설정
- build_and_push.yml // 빌드하고 푸시
- deploy.yml // 배포
- verify.yml // 검증
- common_functions.yml // 여러 단계에서 쓰는 함수들 모음
# dev.gitlab-ci.yml
include:
# Templates
- local: "ci/templates/setup_environment.yml"
- local: "ci/templates/build_and_push.yml"
- local: "ci/templates/deploy.yml"
- local: "ci/templates/verify.yml"
- local: "ci/templates/cleanup.yml"
- local: "ci/templates/common_functions.yml"
.dev_defaults:
only:
- main
tags:
- freesia-runner
variables:
RUNNER_TAG: freesia-runner
COMPOSE_FILE: docker-compose-dev.yml
ENV_NAME: dev
configure_git_dev:
extends:
- .configure_git
- .dev_defaults
variables:
ENV_NAME: none
build_and_push_dev:
extends:
- .build_and_push
- .dev_defaults
needs:
- configure_git_dev
deploy_dev:
extends:
- .deploy
- .dev_defaults
needs:
- build_and_push_dev
rollback_dev:
extends:
- .rollback
- .dev_defaults
needs:
- deploy_dev
verify_dev:
extends:
- .verify
- .dev_defaults
needs:
- deploy_dev
variables:
ENV_NAME: none
cleanup_dev:
extends:
- .cleanup
- .dev_defaults
needs:
- verify_dev
# build_and_push.yml
include:
- ci/templates/common_functions.yml
.build_and_push:
stage: build_and_push
variables:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
IMAGE_TAG: ${ENV_NAME}-${CI_COMMIT_SHORT_SHA}-${CI_PIPELINE_IID}
COMPOSE_PROJECT_NAME: ""
before_script:
- !reference [.common_functions, before_script]
script:
- echo "🔨 빌드 및 푸시 작업을 시작합니다..."
# Git 저장소 업데이트
- cd /home/gitlab-runner/freesia
- git pull
- cd /home/gitlab-runner/freesia/Docker
# Azure 컨테이너 레지스트리 로그인
- echo "🔐 Azure 컨테이너 레지스트리 로그인 중..."
- echo $AZURE_ACR_PASSWORD | docker login $AZURE_ACR_SERVER -u $AZURE_ACR_USERNAME --password-stdin
# 이미지 빌드
- echo "🏗️ Docker 이미지 빌드 중..."
- run_compose build --parallel
- echo "🔄 로컬 Docker 빌드 후 이미지들"
- docker images
# 이미지 태그 및 푸시
- |
tag_and_push() {
local service=$1
echo "⬆️ $service 이미지를 ACR로 푸시 중..."
docker push ${AZURE_ACR_SERVER}/$service:$IMAGE_TAG
}
for_each_service tag_and_push
- echo "✅ 모든 이미지가 성공적으로 빌드되어 ACR에 푸시되었습니다"
# common_functions.yml
.common_functions:
before_script:
# 공통 함수 정의
- |
# docker-compose 명령어 실행 함수
run_compose() {
AZURE_ACR_SERVER=$AZURE_ACR_SERVER \
IMAGE_TAG=$IMAGE_TAG \
docker-compose -f $COMPOSE_FILE $@
}
# 서비스 목록 조회 함수
get_services() {
run_compose config --services
}
# 배포 대상 서비스인지 확인
is_deployable_service() {
local service=$1
if [[ "$service" == "redis" || "$service" == "postgresql" ]]; then
return 1
fi
return 0
}
# 서비스 순회 함수
for_each_service() {
local callback=$1
for service in $(get_services); do
if is_deployable_service $service; then
$callback $service
else
echo "⏭️ $service 는 배포 대상에서 제외됩니다"
fi
done
}
커서의 도움을 받아 작업했다. 내가 gitlab-ci 문법을 거의 몰라서 커서가 대부분을 처리해 주었다.
단계를 나누어 놓으니 로그를 보기 편하다. 로그에 echo에 이모티콘을 넣으면 다른 로그들과 구분이 되어 훨씬 가독성이 좋아진다.
롤백 기능은 when: manual을 설정하여 GitLab Pipeline 탭에서 버튼을 눌러 롤백할 수 있도록 구성했다.
CI/CD 전용 툴에 비해 기능이나 가시성 면에서는 부족할 수 있지만, 간편하게 구성이 가능한 점은 큰 장점이다. DevOps 관련 개발자가 없다면 오히려 GitLab CI나 GitHub Actions를 사용하는 것이 좋을 것 같다.
잘한 점
- 모노레포
- 여러 앱을 하나의 레포지토리에서 관리하여 코드 중복을 줄이고, 중앙에서 공통 모듈을 관리하는 장점이 있다.
- 디자인 시스템 활용
- 디자인 시스템을 활용하여 빠르게 개발할 수 있다.
- shadcn/ui: 접근성을 고려하며 커스터마이징이 쉬워 매우 유용하다.
- 폼 관리
- zod와 react-hook-form을 사용하여 폼 유효성 검사, 사용자 액션, 오류 메시지 등을 추상화하여 가독성을 높이고 코드 중복을 많이 줄였다.
- 품질 관리 - 린트, 배포, 테스트 자동화
- VSCode에서 저장 시 린트를 설정하고, husky를 통해 pre-commit 단계에서 린트를 다시 적용하여 코드 품질을 유지했다.
- push 이전에 테스트를 수행하여, 테스트를 통과하지 못하는 코드는 remote 저장소에 올라가지 않도록 했다.
- 새로운 라이브러리 활용 능숙해짐
- Next.js: SSR 프레임워크로, 초기 로딩이 빠르고 개발 편의성이 매우 좋다. 라우팅, 성능 최적화 등 많은 기능을 제공한다.
- Tailwind: CSS 프레임워크로, 태그에 클래스 이름으로 스타일을 적용한다. 배우는 데 시간이 걸리지만, 사용성이 뛰어나고 성능도 좋다.
- Turborepo: 모노레포 관리 툴로, 매우 쉽게 모노레포를 구성할 수 있다.
아쉬운 점
- 모노레포
- UI 컴포넌트를 모아놓은 패키지에 일부 컴포넌트가 Next.js에 의존하고 있어 확장에 제한이 있다.
- UI 컴포넌트는 Next.js에 의존해서는 안 된다. React로만 구성한 앱을 만들 수도 있기 때문이다.
- 테스트 코드 부족
- 현재 챗봇 앱에만 테스트 코드가 존재한다.
- 매번 회고에서 아쉬운 점으로 쓴다.
- 이번 토이프로젝트에는 TDD를 꼭 적용해보자.
- 일부 컴포넌트 간 강한 결합
- 설계가 잘못되었다.
- 기본 공통 컴포넌트를 먼저 정의하고, 이후 개발하면서 추가로 컴포넌트를 추출하는 방식으로 작업했다. 한 곳에서 여러 개를 추출하면서 해당 컴포넌트들이 강하게 결합되어버렸다.
- 강하게 결합된 컴포넌트들은 사용이 매우 제한되므로 그렇게 만들면 안된다.
- 각 컴포넌트가 어떤 책임을 질지, 어떻게 사용할 지 더 깊이 고민하고 추출할 필요가 있다.