Carefree Hub

Command Palette

Search for a command to run...

FLOWIKI - 사내 위키 및 실시간 협업 문서 시스템

FLOWIKI - 사내 위키 및 실시간 협업 문서 시스템

Next.js App Router와 TipTap 에디터를 활용한 Notion 스타일 사내 위키 시스템 개발기

Carefreelife98
10분 소요

FLOWIKI 프로젝트 수행 기록

FLOWIKI Logo

FLOWIKI - 사내 위키 및 실시간 협업 문서 시스템


프로젝트 개요

항목내용
프로젝트명FLOWIKI
개발 기간2025-04 ~ 2025-05 (약 2개월, 초기 개발만 수행)
서비스 상태운영 배포 완료

서비스 운영 현황

환경고객사/서비스상태
엔터프라이즈SK 쉴더스운영중
엔터프라이즈매그나칩 반도체운영중
SaaS플로우 SaaS운영중

1. 배경

조직/비즈니스 문제

  • 사내 지식 관리 시스템 부재
  • 분산된 문서와 정보 접근성 문제
  • 실시간 협업 문서 편집 필요성
  • Notion, Confluence 등 외부 도구 의존도 감소 필요

기존 시스템의 한계

  • 기존 문서 관리 도구의 검색 기능 미흡
  • 계층적 문서 구조 관리 어려움
  • 사용자별 문서 히스토리 추적 불가
  • 실시간 공동 편집 지원 부재

2. 나의 역할

항목내용
기획 참여 여부X
설계O (프론트엔드 초기 아키텍처)
구현O (프론트엔드 초기 전체 + 검색 백엔드)
운영운영 배포 완료 (현재 담당 X)

담당 영역

1. 프론트엔드 레이아웃

  • 계층형 사이드바: 재귀적 트리 구조 렌더링
  • 탭 시스템: 다중 문서 탭 관리 (브라우저 탭과 유사)
  • BreadCrumb 네비게이션: 문서 계층 경로 표시
  • PostgreSQL FTS 기반 검색: 한글 형태소 분석
  • 검색 결과 UI: Dialog 기반 검색 결과 표시
  • 보안: SQL Injection 방어 로직

3. 문서 라우팅

  • Next.js App Router: 파일 기반 동적 라우팅
  • 인증 레이아웃: (auth) 그룹 기반 라우팅

3. 기술 선택과 이유

Next.js (App Router) 선택 이유

이유상세
TipTap 에디터 사용React 기반 실시간 공동 편집 라이브러리 활용 필요
기존 플로우 시스템과 독립적퍼블리싱 팀 협업 없이 자체 개발 가능
서버 컴포넌트App Router의 RSC 활용으로 초기 로딩 최적화
파일 기반 라우팅문서 구조와 URL 매핑 용이

프론트엔드 기술 스택

기술역할
Zustand경량 상태 관리 (editorStore, userStore, flowikiTabStore)
TipTapRich Text Editor (Notion 스타일 WYSIWYG)
Radix UI + shadcn/ui접근성 고려한 UI 컴포넌트
Tailwind CSS유틸리티 기반 스타일링

PostgreSQL FTS 선택 이유 (RediSearch 대비)

선택대안이유
PostgreSQL FTSRediSearch별도 인프라 없이 기존 DB 활용
한글 형태소 분석 지원 용이
운영 복잡성 감소

아키텍처: Next.js App Router 기반 구조

text
1src/
2├── api/ # API 호출 계층
3│ ├── document/
4│ ├── search/
5│ └── dashboard/
6├── app/(auth)/ # 인증된 사용자 라우팅
7│ ├── document/
8│ ├── favorites/
9│ ├── history/
10│ └── recently-views/
11├── components/ # 기능별 컴포넌트
12│ ├── common/
13│ ├── dashboard/
14│ ├── editor/
15│ ├── layout/
16│ ├── search/
17│ └── ui/
18├── store/ # Zustand 상태 관리
19│ ├── editorStore.ts
20│ ├── userStore.ts
21│ └── flowikiTabStore.ts
22├── hooks/ # 커스텀 훅
23└── lib/ # 유틸리티

참고: FSD(Feature-Sliced Design)가 아닌 기술 계층 기반 구조 사용


4. 어려웠던 지점

4.1 기술적 병목

Full Text Search 한글 처리

문제: PostgreSQL FTS 한글 토크나이저 설정

  • 영어와 달리 한글은 형태소 분석 필요
  • 조사, 어미 등 처리 복잡
  • 접두사 검색 (prefix search) 지원 필요

해결: 한글 + 영어 동시 검색 지원 및 파라미터 바인딩

typescript
1// apps/flowiki-back/src/modules/search/adapter/output/persistence/get-search-result.postgres.adapter.ts
2
3async getSearchResult(keyword: string, user: UserDto): Promise<SearchResult> {
4 if (keyword.trim() === "") {
5 return SearchResult.of([]);
6 }
7
8 const terms = keyword.trim().split(/\s+/).filter(term => term.length > 0);
9
10 const queryBuilder = this.documentRepository
11 .createQueryBuilder('document')
12 .where('document.user_id = :userId', { userId: user.userId });
13
14 if (terms.length > 0) {
15 const searchConditions = [];
16
17 for (let i = 0; i < terms.length; i++) {
18 const paramName = `term${i}`;
19 const searchTerm = `${terms[i]}:*`; // 접두사 매칭을 위해 :* 추가
20
21 // 제목 검색 (한글 + 영어)
22 searchConditions.push(`
23 (to_tsvector('korean', document.title) @@ to_tsquery('korean', :${paramName}) OR
24 to_tsvector('english', document.title) @@ to_tsquery('english', :${paramName}))
25 `);
26
27 // 내용 검색 (한글 + 영어)
28 searchConditions.push(`
29 (to_tsvector('korean', CAST(document.content_json AS TEXT)) @@ to_tsquery('korean', :${paramName}) OR
30 to_tsvector('english', CAST(document.content_json AS TEXT)) @@ to_tsquery('english', :${paramName}))
31 `);
32
33 // 파라미터 바인딩 (SQL Injection 방어)
34 queryBuilder.setParameter(paramName, searchTerm);
35 }
36
37 queryBuilder.andWhere(`document.stts = 'Y'`);
38 queryBuilder.andWhere(`(${searchConditions.join(' OR ')})`);
39 }
40
41 // 검색 결과 순위 기반 정렬
42 const rankingExpression = terms.map((_, i) => {
43 const paramName = `term${i}`;
44 return `
45 (ts_rank_cd(to_tsvector('korean', document.title), to_tsquery('korean', :${paramName})) +
46 ts_rank_cd(to_tsvector('english', document.title), to_tsquery('english', :${paramName})))
47 `;
48 }).join(' + ');
49
50 if (rankingExpression) {
51 queryBuilder.orderBy(rankingExpression, 'DESC');
52 }
53
54 const documents = await queryBuilder.getMany();
55 return SearchResult.of(documents.map(doc => SearchDocument.fromDocument(doc)));
56}

탭 상태 관리

문제: 새로고침 시 탭 상태 유지

  • 브라우저 새로고침 시 열린 탭 정보 손실
  • Zustand 상태가 메모리에서만 유지

해결: Zustand persist 미들웨어 적용

typescript
1// apps/flowiki-front/src/store/flowikiTabStore.ts
2
3import { create } from 'zustand';
4import { persist } from 'zustand/middleware';
5
6export type Tab = {
7 id: string;
8 docId: string;
9 title: string;
10 url: string;
11 isNew: boolean;
12};
13
14type TabStore = {
15 tabs: Tab[];
16 activeTabId: string | null;
17 getTabs: () => Tab[];
18 getTabByDocId: (docId: string) => Tab | null;
19 getCurrentTab: () => Tab | null;
20 addTab: (tab: Tab) => void;
21 closeTab: (id: string) => void;
22 setActiveTabId: (id: string) => void;
23 updateTab: (id: string, updates: Partial<Tab>) => void;
24 clearTabs: () => void;
25};
26
27export const useTabStore = create<TabStore>()(
28 persist(
29 (set, get) => ({
30 tabs: [],
31 activeTabId: null,
32 getTabByDocId: (docId: string) => {
33 return get().tabs.find((tab) => tab.docId === docId) || null;
34 },
35 getCurrentTab: () => {
36 const { activeTabId, tabs } = get();
37 if (!activeTabId) return null;
38 return tabs.find((tab) => tab.id === activeTabId) || null;
39 },
40 addTab: (tab) => {
41 set((state) => ({
42 tabs: [...state.tabs, tab],
43 activeTabId: tab.id
44 }));
45 },
46 closeTab: (id: string) => {
47 set((state) => {
48 // 마지막 탭은 닫지 않음
49 if (state.tabs.length <= 1) return state;
50
51 const newTabs = state.tabs.filter((t) => t.id !== id);
52 const activeTabId = state.activeTabId === id
53 ? newTabs[0].id
54 : state.activeTabId;
55
56 return { tabs: newTabs, activeTabId };
57 });
58 },
59 // ... 기타 메서드
60 }),
61 {
62 name: 'flowiki-tabs', // LocalStorage 키
63 partialize: (state) => ({
64 tabs: state.tabs,
65 activeTabId: state.activeTabId,
66 }),
67 },
68 ),
69);

계층형 사이드바

문제: 재귀적 트리 렌더링 성능

  • 깊은 문서 구조에서 렌더링 지연
  • 불필요한 리렌더링 발생

해결: useMemo를 활용한 재귀적 트리 평탄화

typescript
1// apps/flowiki-front/src/hooks/useFolderGroup.ts
2
3export function useFolderGroup({ nodes = [], openedFolders, setOpenedFolders }: UseFolderGroupProps) {
4 // 재귀적 트리 평탄화 - useMemo로 성능 최적화
5 const flattenedList = useMemo(() => {
6 const flatten = (
7 items: TreeNodeInterface[],
8 depth: number = 0,
9 parentFolderId: number | null = null
10 ): FlattenedNode[] => {
11 return items.reduce<FlattenedNode[]>((acc, node) => {
12 const flatNode: FlattenedNode = {
13 ...node,
14 depth,
15 parentFolderId
16 };
17 acc.push(flatNode);
18
19 // 열린 폴더만 자식 노드를 렌더링 (성능 최적화)
20 if (node.children && node.children.length > 0 && openedFolders.has(node.id.toString())) {
21 acc.push(...flatten(node.children, depth + 1, node.id));
22 }
23
24 return acc;
25 }, []);
26 };
27
28 return flatten(nodes);
29 }, [nodes, openedFolders]); // 의존성 배열로 불필요한 재계산 방지
30
31 const handleFolderOpenChange = (folderId: string, isOpen: boolean) => {
32 setOpenedFolders(prev => {
33 const newSet = new Set(prev);
34 if (isOpen) {
35 newSet.add(folderId);
36 } else {
37 newSet.delete(folderId);
38 }
39 return newSet;
40 });
41 };
42
43 return { flattenedList, handleFolderOpenChange, /* ... */ };
44}

4.2 도메인 이해의 어려움

  • 문서 계층 구조 설계: 폴더 vs 페이지 구분
  • 사용자별 권한 체계: 읽기/쓰기/관리자 역할

4.3 협업/운영 이슈

  • 실시간 협업 기능 (Socket) 연동: TipTap + Y.js 기반

5. 해결 방식

5.1 구조적 선택

컴포넌트 분리

text
1components/
2├── dashboard/ # 대시보드 관련 (9개 컴포넌트)
3├── editor/ # 에디터 관련
4├── search/ # 검색 관련
5├── layout/ # 레이아웃 (헤더, 사이드바)
6└── common/ # 공통 컴포넌트

상태 관리

typescript
1// Zustand store 분리
2const useEditorStore = create(...) // 에디터 상태
3const useUserStore = create(...) // 사용자 상태
4const useFlowikiTabStore = create(...) // 탭 상태

API 레이어

typescript
1// 별도 api 폴더에서 fetch 로직 캡슐화
2api/
3├── document/ # 문서 CRUD
4├── search/ # 검색 API
5└── dashboard/ # 대시보드 데이터

5.2 트레이드오프

결정선택대안이유
상태 관리ZustandRedux러닝커브, 번들 사이즈
검색PostgreSQL FTSRediSearch인프라 단순화
에디터TipTapSlate.js플러그인 생태계, 공동편집 지원
아키텍처기술 계층FSD팀 친숙도, 프로젝트 규모

6. 결과와 남은 것

6.1 서비스 상태

상태내용
개발완료
배포SK 쉴더스, 매그나칩, SaaS 운영중
담당초기 개발 완료 후 운영 담당자 이관

6.2 재사용 가능한 패턴

계층형 사이드바 컴포넌트

typescript
1// 재귀적 트리 렌더링
2const TreeNode = ({ node }) => (
3 <>
4 <NodeItem node={node} />
5 {node.children?.map(child => (
6 <TreeNode key={child.id} node={child} />
7 ))}
8 </>
9);

탭 관리 시스템

typescript
1// Zustand 기반 다중 탭 상태
2const useFlowikiTabStore = create(
3 persist(
4 (set) => ({
5 tabs: [],
6 activeTab: null,
7 addTab: (tab) => set(...),
8 removeTab: (id) => set(...),
9 }),
10 { name: 'flowiki-tabs' }
11 )
12);

PostgreSQL FTS 검색

sql
1-- 한글 검색 최적화 패턴
2SELECT * FROM documents
3WHERE to_tsvector('korean', content) @@ plainto_tsquery('korean', $1);

6.3 나의 사고 방식 변화

  • Next.js App Router 실무 적용: 서버 컴포넌트, 파일 기반 라우팅 경험
  • 검색 기능 설계 시 보안 고려: SQL Injection 방어의 중요성
  • 독립적 프로젝트 경험: 퍼블리싱 팀 없이 React 기반 자체 개발

취약점 분석

탭 기능

탭 버그 수정 - persist 미들웨어 적용

typescript
1// apps/flowiki-front/src/store/flowikiTabStore.ts
2
3// 버그: 새로고침 시 중복 탭 생성
4// 원인: Zustand 상태가 메모리에서만 유지되어 새로고침 시 초기화됨
5// 해결: persist 미들웨어로 localStorage에 탭 상태 저장
6
7export const useTabStore = create<TabStore>()(
8 persist(
9 (set, get) => ({
10 tabs: [],
11 activeTabId: null,
12 addTab: (tab) => {
13 set((state) => ({
14 tabs: [...state.tabs, tab],
15 activeTabId: tab.id
16 }));
17 },
18 closeTab: (id: string) => {
19 set((state) => {
20 if (state.tabs.length <= 1) return state; // 마지막 탭 보호
21 const newTabs = state.tabs.filter((t) => t.id !== id);
22 return {
23 tabs: newTabs,
24 activeTabId: state.activeTabId === id ? newTabs[0].id : state.activeTabId
25 };
26 });
27 },
28 }),
29 {
30 name: 'flowiki-tabs', // localStorage 키
31 partialize: (state) => ({ tabs: state.tabs, activeTabId: state.activeTabId }),
32 },
33 ),
34);

SQL Injection 방어 로직

typescript
1// apps/flowiki-back/src/modules/data/postgres.service.ts
2
3// 취약한 코드 (수정 전)
4// await this.pool.query(`SELECT * FROM documents WHERE doc_id = '${docId}'`);
5
6// 안전한 코드 (파라미터 바인딩 적용)
7async getDocumentData(docId: string): Promise<DocData | null> {
8 try {
9 // $1 파라미터 바인딩 - SQL Injection 방어
10 const { rows } = await this.pool.query(
11 "SELECT title, content_json, current_hash, user_id FROM documents WHERE doc_id = $1 and stts='Y'",
12 [docId], // 배열로 파라미터 전달
13 );
14 // ...
15 } catch (error) {
16 throw new InternalServerErrorException('Error fetching document content');
17 }
18}
19
20async saveDocument(docData: DocData, lastEdited: string, title: string): Promise<void> {
21 const client = await this.pool.connect();
22 try {
23 await client.query('BEGIN');
24
25 // 트랜잭션 내 모든 쿼리에 파라미터 바인딩 적용
26 await client.query(
27 `INSERT INTO documents_history (doc_id, title, content_json, created_at, user_id, current_hash)
28 VALUES ($1, $2, $3, NOW(), $4, $5)`,
29 [docData.docId, title, docData.contentStr, docData.userId, docData.currentHash],
30 );
31
32 await client.query('COMMIT');
33 } catch (error) {
34 await client.query('ROLLBACK');
35 throw error;
36 } finally {
37 client.release();
38 }
39}

핵심 러닝 포인트

기술적 성장

  • Next.js App Router 실무 적용: 서버 컴포넌트, 파일 기반 라우팅
  • TipTap 에디터 통합: React 기반 실시간 공동 편집 구현
  • PostgreSQL FTS: 한글 Full Text Search 최적화
  • 보안: SQL Injection 방어 패턴 (파라미터 바인딩)

도메인 이해

  • 사내 위키 시스템 설계: 계층적 문서 구조, 사용자별 권한 체계
  • 실시간 협업 요구사항: 문서 동시 편집, 변경 이력 추적

협업 및 운영

  • 독립적 프로젝트: 퍼블리싱 팀 없이 React 기반 자체 개발
  • 다중 환경 배포 경험: SaaS + 엔터프라이즈 (SK 쉴더스, 매그나칩)
  • 프로젝트 인수인계: 개발 완료 후 운영 담당자 이관