[{"content":"배치 수집 + 변동 감지 전체 흐름 API에서 전체 인원 목록 가져오기 │ DB 현재 상태와 비교 │ ├─ DB에 없는 usId → JOIN (입사) ├─ API에 없는 usId → LEAVE (퇴사) └─ 값이 다른 필드 → DEPT_CHANGE / ROLE_CHANGE │ ▼ 변동 건 → user_history 적재 현재 상태 → users 테이블 upsert 배치 구현 async function runBatch() { console.log(`[배치] 시작: ${new Date().toLocaleString(\u0026#39;ko-KR\u0026#39;)}`); // 1. API에서 전체 인원 가져오기 const apiUsers = await fetchAllUsers(); console.log(`[배치] API 응답: ${apiUsers.length}명`); // 2. DB 현재 상태 가져오기 const dbUsers = await prisma.user.findMany(); const dbMap = new Map(dbUsers.map(u =\u0026gt; [u.usId, u])); const apiMap = new Map(apiUsers.map(u =\u0026gt; [u.usId, u])); const historyRecords = []; // 3. 입사 감지 (API에 있고 DB에 없음) for (const [usId, apiUser] of apiMap) { if (!dbMap.has(usId)) { historyRecords.push({ usId, changeType: \u0026#39;JOIN\u0026#39;, fieldName: null, oldValue: null, newValue: apiUser.usName, }); } } // 4. 퇴사 감지 (DB에 있고 API에 없음) for (const [usId, dbUser] of dbMap) { if (!apiMap.has(usId)) { historyRecords.push({ usId, changeType: \u0026#39;LEAVE\u0026#39;, fieldName: null, oldValue: dbUser.usName, newValue: null, }); } } // 5. 변동 감지 (둘 다 있는데 값이 다름) const TRACKED_FIELDS = [\u0026#39;deptName\u0026#39;, \u0026#39;usRollName\u0026#39;, \u0026#39;usPosName\u0026#39;]; for (const [usId, apiUser] of apiMap) { const dbUser = dbMap.get(usId); if (!dbUser) continue; for (const field of TRACKED_FIELDS) { const oldVal = dbUser[field] ?? null; const newVal = apiUser[field] ?? null; if (oldVal !== newVal) { historyRecords.push({ usId, changeType: field === \u0026#39;deptName\u0026#39; ? \u0026#39;DEPT_CHANGE\u0026#39; : \u0026#39;ROLE_CHANGE\u0026#39;, fieldName: field, oldValue: oldVal, newValue: newVal, }); } } } // 6. 이력 적재 if (historyRecords.length \u0026gt; 0) { await prisma.userHistory.createMany({ data: historyRecords }); } // 7. 현재 상태 upsert for (const user of apiUsers) { await prisma.user.upsert({ where: { usId: user.usId }, update: { ...user, updatedAt: new Date() }, create: user, }); } // 8. 퇴사자 DB에서 제거 (선택) const leaveIds = [...dbMap.keys()].filter(id =\u0026gt; !apiMap.has(id)); if (leaveIds.length \u0026gt; 0) { await prisma.user.deleteMany({ where: { usId: { in: leaveIds } } }); } console.log( `[배치] 완료 — 입사 ${historyRecords.filter(h =\u0026gt; h.changeType === \u0026#39;JOIN\u0026#39;).length}, ` + `퇴사 ${historyRecords.filter(h =\u0026gt; h.changeType === \u0026#39;LEAVE\u0026#39;).length}, ` + `변동 ${historyRecords.filter(h =\u0026gt; ![\u0026#39;JOIN\u0026#39;,\u0026#39;LEAVE\u0026#39;].includes(h.changeType)).length}` ); } 배치 스케줄 // 매일 오전 1시 실행 (TZ=Asia/Seoul 필수) cron.schedule(\u0026#39;0 1 * * *\u0026#39;, () =\u0026gt; { runBatch().catch(err =\u0026gt; console.error(\u0026#39;[배치] 오류:\u0026#39;, err)); }); 최초 배포 시에는 배치가 돌 때까지 DB가 비어있다. 수동으로 한 번 실행해줘야 한다.\ndocker exec insa-server node -e \u0026#34;require(\u0026#39;./src/batch\u0026#39;).runBatch().catch(console.error)\u0026#34; React + AG Grid UI AG Grid 기본 세팅 AG Grid Community를 설치한다.\nnpm install ag-grid-community ag-grid-react 기본 사용법:\nimport { AgGridReact } from \u0026#39;ag-grid-react\u0026#39;; import \u0026#39;ag-grid-community/styles/ag-grid.css\u0026#39;; import \u0026#39;ag-grid-community/styles/ag-theme-alpine.css\u0026#39;; function UserGrid() { const columnDefs = [ { field: \u0026#39;usName\u0026#39;, headerName: \u0026#39;이름\u0026#39;, width: 100 }, { field: \u0026#39;deptName\u0026#39;, headerName: \u0026#39;부서\u0026#39;, flex: 1 }, { field: \u0026#39;usRollName\u0026#39;, headerName: \u0026#39;직책\u0026#39;, width: 100 }, { field: \u0026#39;usPosName\u0026#39;, headerName: \u0026#39;직위\u0026#39;, width: 100 }, { field: \u0026#39;usMail1\u0026#39;, headerName: \u0026#39;이메일\u0026#39;, flex: 1 }, ]; return ( \u0026lt;div className=\u0026#34;ag-theme-alpine\u0026#34; style={{ height: 600 }}\u0026gt; \u0026lt;AgGridReact rowData={users} columnDefs={columnDefs} pagination={true} paginationPageSize={50} /\u0026gt; \u0026lt;/div\u0026gt; ); } AG Grid v33 테마 충돌 AG Grid를 설치하고 실행하면 아래 경고가 뜨면서 스타일이 깨진다.\nAG Grid: As of v33, the grid uses a new Theming API by default. CSS file imports (ag-theme-alpine.css etc.) are not compatible... v33부터 Theming API 방식이 기본으로 바뀌었는데, 기존 CSS 파일 import 방식과 충돌한다.\n해결: theme=\u0026quot;legacy\u0026quot; prop을 추가하면 기존 방식으로 동작한다.\n\u0026lt;AgGridReact theme=\u0026#34;legacy\u0026#34; // ← 이거 추가 rowData={users} columnDefs={columnDefs} pagination={true} paginationPageSize={50} /\u0026gt; 필터링 상단에 부서/이름/직책 필터를 두고, 조합 필터링이 되도록 구현했다.\nconst [filters, setFilters] = useState({ dept: \u0026#39;\u0026#39;, name: \u0026#39;\u0026#39;, role: \u0026#39;\u0026#39; }); const filtered = useMemo(() =\u0026gt; { return users.filter(u =\u0026gt; (!filters.dept || u.deptName?.includes(filters.dept)) \u0026amp;\u0026amp; (!filters.name || u.usName?.includes(filters.name)) \u0026amp;\u0026amp; (!filters.role || u.usRollName?.includes(filters.role)) ); }, [users, filters]); TanStack Query로 서버 상태 관리 TanStack Query를 쓰면 로딩/에러/캐싱/리페치를 직접 관리하지 않아도 된다.\nnpm install @tanstack/react-query 인사 목록 조회 // api.js export const fetchUsers = (params) =\u0026gt; fetch(`/api/users?${new URLSearchParams(params)}`).then(r =\u0026gt; r.json()); // UserGrid.jsx const { data: users = [], isLoading, isError } = useQuery({ queryKey: [\u0026#39;users\u0026#39;, filters], queryFn: () =\u0026gt; fetchUsers(filters), staleTime: 1000 * 60 * 5, // 5분간 캐시 유지 }); if (isLoading) return \u0026lt;div\u0026gt;로딩 중...\u0026lt;/div\u0026gt;; if (isError) return \u0026lt;div\u0026gt;데이터를 불러올 수 없습니다.\u0026lt;/div\u0026gt;; 수정 뮤테이션 const queryClient = useQueryClient(); const updateMutation = useMutation({ mutationFn: ({ usId, data }) =\u0026gt; fetch(`/api/users/${usId}`, { method: \u0026#39;PUT\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, body: JSON.stringify(data), }).then(r =\u0026gt; r.json()), onSuccess: () =\u0026gt; { // 수정 후 목록 자동 갱신 queryClient.invalidateQueries({ queryKey: [\u0026#39;users\u0026#39;] }); }, }); invalidateQueries로 수정 완료 후 목록을 자동으로 다시 가져온다. 상태를 직접 업데이트할 필요가 없다.\nCSV 다운로드 function downloadCsv(rows, filename) { const headers = [\u0026#39;이름\u0026#39;, \u0026#39;부서\u0026#39;, \u0026#39;직책\u0026#39;, \u0026#39;직위\u0026#39;, \u0026#39;이메일\u0026#39;, \u0026#39;휴대폰\u0026#39;, \u0026#39;내선번호\u0026#39;]; const fields = [\u0026#39;usName\u0026#39;, \u0026#39;deptName\u0026#39;, \u0026#39;usRollName\u0026#39;, \u0026#39;usPosName\u0026#39;, \u0026#39;usMail1\u0026#39;, \u0026#39;usCellno\u0026#39;, \u0026#39;usTelno\u0026#39;]; const csv = [ headers.join(\u0026#39;,\u0026#39;), ...rows.map(row =\u0026gt; fields.map(f =\u0026gt; `\u0026#34;${(row[f] ?? \u0026#39;\u0026#39;).replace(/\u0026#34;/g, \u0026#39;\u0026#34;\u0026#34;\u0026#39;)}\u0026#34;`).join(\u0026#39;,\u0026#39;) ), ].join(\u0026#39;\\n\u0026#39;); const blob = new Blob([\u0026#39;\\uFEFF\u0026#39; + csv], { type: \u0026#39;text/csv;charset=utf-8\u0026#39; }); const url = URL.createObjectURL(blob); const a = document.createElement(\u0026#39;a\u0026#39;); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } '\\uFEFF' (BOM)을 앞에 붙여야 Excel에서 한글이 깨지지 않는다.\nINSERT SQL 생성 현재 필터 조건에 해당하는 인원의 INSERT SQL을 생성해서 클립보드로 복사하는 기능이다. 다른 DB에 동일한 데이터를 넣어야 할 때 편하다.\n// 서버: INSERT SQL 생성 API app.post(\u0026#39;/api/users/sql/insert\u0026#39;, async (req, res) =\u0026gt; { const { dept, name, role } = req.body; const users = await prisma.user.findMany({ where: { deptName: dept ? { contains: dept } : undefined, usName: name ? { contains: name } : undefined, usRollName: role ? { contains: role } : undefined, }, }); const values = users.map(u =\u0026gt; ` (\u0026#39;${u.usId}\u0026#39;, \u0026#39;${u.usName}\u0026#39;, ${nullable(u.deptId)}, ${nullable(u.deptName)}, ` + `${nullable(u.usRollName)}, ${nullable(u.usPosName)}, ${nullable(u.usMail1)}, ` + `${nullable(u.usCellno)}, ${nullable(u.usTelno)})` ).join(\u0026#39;,\\n\u0026#39;); const sql = `INSERT INTO users (us_id, us_name, dept_id, dept_name, us_roll_name, us_pos_name, us_mail1, us_cellno, us_telno)\\nVALUES\\n${values}\\nON CONFLICT (us_id) DO UPDATE SET\\n us_name = EXCLUDED.us_name,\\n dept_name = EXCLUDED.dept_name,\\n us_roll_name = EXCLUDED.us_roll_name;`; res.json({ sql }); }); const nullable = v =\u0026gt; v ? `\u0026#39;${v.replace(/\u0026#39;/g, \u0026#34;\u0026#39;\u0026#39;\u0026#34;)}\u0026#39;` : \u0026#39;NULL\u0026#39;; // 클라이언트: 클립보드 복사 const { data } = useMutation({ mutationFn: () =\u0026gt; fetch(\u0026#39;/api/users/sql/insert\u0026#39;, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, body: JSON.stringify(filters), }).then(r =\u0026gt; r.json()), onSuccess: ({ sql }) =\u0026gt; { navigator.clipboard.writeText(sql); alert(\u0026#39;SQL이 클립보드에 복사됐습니다.\u0026#39;); }, }); 다크/라이트 테마 CSS 변수 기반으로 구현했다. localStorage에 저장해서 새로고침 후에도 유지된다.\n/* App.css */ :root { --bg: #ffffff; --surface: #f8f9fa; --text: #1a1a1a; --border: #e0e0e0; } [data-theme=\u0026#34;dark\u0026#34;] { --bg: #1a1a1a; --surface: #242424; --text: #e0e0e0; --border: #3a3a3a; } body { background: var(--bg); color: var(--text); } // App.jsx const [theme, setTheme] = useState( () =\u0026gt; localStorage.getItem(\u0026#39;theme\u0026#39;) || \u0026#39;light\u0026#39; ); useEffect(() =\u0026gt; { document.documentElement.setAttribute(\u0026#39;data-theme\u0026#39;, theme); localStorage.setItem(\u0026#39;theme\u0026#39;, theme); }, [theme]); const toggleTheme = () =\u0026gt; setTheme(t =\u0026gt; t === \u0026#39;light\u0026#39; ? \u0026#39;dark\u0026#39; : \u0026#39;light\u0026#39;); 다음 편에서는 Docker Compose로 db → server → client 시작 순서를 보장하는 방법과 운영하면서 겪은 트러블슈팅을 다룬다.\n","permalink":"https://chanyeols.com/posts/insa-02-batch-agrid/","summary":"\u003ch2 id=\"배치-수집--변동-감지\"\u003e배치 수집 + 변동 감지\u003c/h2\u003e\n\u003ch3 id=\"전체-흐름\"\u003e전체 흐름\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eAPI에서 전체 인원 목록 가져오기\n        │\nDB 현재 상태와 비교\n        │\n        ├─ DB에 없는 usId → JOIN (입사)\n        ├─ API에 없는 usId → LEAVE (퇴사)\n        └─ 값이 다른 필드 → DEPT_CHANGE / ROLE_CHANGE\n        │\n        ▼\n변동 건 → user_history 적재\n현재 상태 → users 테이블 upsert\n\u003c/code\u003e\u003c/pre\u003e\u003ch3 id=\"배치-구현\"\u003e배치 구현\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ff7b72\"\u003easync\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003efunction\u003c/span\u003e runBatch() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  console.log(\u003cspan style=\"color:#a5d6ff\"\u003e`[배치] 시작: \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e${\u003c/span\u003e\u003cspan style=\"color:#ff7b72\"\u003enew\u003c/span\u003e Date().toLocaleString(\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;ko-KR\u0026#39;\u003c/span\u003e)\u003cspan style=\"color:#a5d6ff\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e`\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 1. API에서 전체 인원 가져오기\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e apiUsers \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003eawait\u003c/span\u003e fetchAllUsers();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  console.log(\u003cspan style=\"color:#a5d6ff\"\u003e`[배치] API 응답: \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e${\u003c/span\u003eapiUsers.length\u003cspan style=\"color:#a5d6ff\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e명`\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 2. DB 현재 상태 가져오기\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e dbUsers \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003eawait\u003c/span\u003e prisma.user.findMany();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e dbMap \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003enew\u003c/span\u003e Map(dbUsers.map(u =\u0026gt; [u.usId, u]));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e apiMap \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003enew\u003c/span\u003e Map(apiUsers.map(u =\u0026gt; [u.usId, u]));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e historyRecords \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e [];\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 3. 입사 감지 (API에 있고 DB에 없음)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003efor\u003c/span\u003e (\u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e [usId, apiUser] \u003cspan style=\"color:#ff7b72\"\u003eof\u003c/span\u003e apiMap) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e!\u003c/span\u003edbMap.has(usId)) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      historyRecords.push({\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        usId,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        changeType\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;JOIN\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        fieldName\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003enull\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        oldValue\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003enull\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        newValue\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e apiUser.usName,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 4. 퇴사 감지 (DB에 있고 API에 없음)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003efor\u003c/span\u003e (\u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e [usId, dbUser] \u003cspan style=\"color:#ff7b72\"\u003eof\u003c/span\u003e dbMap) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e!\u003c/span\u003eapiMap.has(usId)) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      historyRecords.push({\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        usId,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        changeType\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;LEAVE\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        fieldName\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003enull\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        oldValue\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e dbUser.usName,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        newValue\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003enull\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 5. 변동 감지 (둘 다 있는데 값이 다름)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e TRACKED_FIELDS \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e [\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;deptName\u0026#39;\u003c/span\u003e, \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;usRollName\u0026#39;\u003c/span\u003e, \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;usPosName\u0026#39;\u003c/span\u003e];\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003efor\u003c/span\u003e (\u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e [usId, apiUser] \u003cspan style=\"color:#ff7b72\"\u003eof\u003c/span\u003e apiMap) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e dbUser \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e dbMap.get(usId);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e!\u003c/span\u003edbUser) \u003cspan style=\"color:#ff7b72\"\u003econtinue\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003efor\u003c/span\u003e (\u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e field \u003cspan style=\"color:#ff7b72\"\u003eof\u003c/span\u003e TRACKED_FIELDS) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e oldVal \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e dbUser[field] \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e??\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003enull\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e newVal \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e apiUser[field] \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e??\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003enull\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#ff7b72\"\u003eif\u003c/span\u003e (oldVal \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e!==\u003c/span\u003e newVal) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        historyRecords.push({\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          usId,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          changeType\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e field \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;deptName\u0026#39;\u003c/span\u003e \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e?\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;DEPT_CHANGE\u0026#39;\u003c/span\u003e \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;ROLE_CHANGE\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          fieldName\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e field,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          oldValue\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e oldVal,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          newValue\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e newVal,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 6. 이력 적재\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003eif\u003c/span\u003e (historyRecords.length \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e0\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003eawait\u003c/span\u003e prisma.userHistory.createMany({ data\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e historyRecords });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 7. 현재 상태 upsert\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003efor\u003c/span\u003e (\u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e user \u003cspan style=\"color:#ff7b72\"\u003eof\u003c/span\u003e apiUsers) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003eawait\u003c/span\u003e prisma.user.upsert({\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      where\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e { usId\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e user.usId },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      update\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e { ...user, updatedAt\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003enew\u003c/span\u003e Date() },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      create\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e user,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 8. 퇴사자 DB에서 제거 (선택)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e leaveIds \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e [...dbMap.keys()].filter(id =\u0026gt; \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e!\u003c/span\u003eapiMap.has(id));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003eif\u003c/span\u003e (leaveIds.length \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e0\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003eawait\u003c/span\u003e prisma.user.deleteMany({ where\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e { usId\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e { \u003cspan style=\"color:#ff7b72\"\u003ein\u003c/span\u003e\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e:\u003c/span\u003e leaveIds } } });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  console.log(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a5d6ff\"\u003e`[배치] 완료 — 입사 \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e${\u003c/span\u003ehistoryRecords.filter(h =\u0026gt; h.changeType \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;JOIN\u0026#39;\u003c/span\u003e).length\u003cspan style=\"color:#a5d6ff\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e, `\u003c/span\u003e \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e+\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a5d6ff\"\u003e`퇴사 \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e${\u003c/span\u003ehistoryRecords.filter(h =\u0026gt; h.changeType \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;LEAVE\u0026#39;\u003c/span\u003e).length\u003cspan style=\"color:#a5d6ff\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e, `\u003c/span\u003e \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e+\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a5d6ff\"\u003e`변동 \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e${\u003c/span\u003ehistoryRecords.filter(h =\u0026gt; \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e!\u003c/span\u003e[\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;JOIN\u0026#39;\u003c/span\u003e,\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;LEAVE\u0026#39;\u003c/span\u003e].includes(h.changeType)).length\u003cspan style=\"color:#a5d6ff\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003e`\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  );\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"배치-스케줄\"\u003e배치 스케줄\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 매일 오전 1시 실행 (TZ=Asia/Seoul 필수)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecron.schedule(\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;0 1 * * *\u0026#39;\u003c/span\u003e, () =\u0026gt; {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  runBatch().\u003cspan style=\"color:#ff7b72\"\u003ecatch\u003c/span\u003e(err =\u0026gt; console.error(\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;[배치] 오류:\u0026#39;\u003c/span\u003e, err));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e});\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e최초 배포 시에는 배치가 돌 때까지 DB가 비어있다. 수동으로 한 번 실행해줘야 한다.\u003c/p\u003e","title":"사내 인사정보 관리 시스템 만들기 - 배치 변동 감지 + React AG Grid UI (2편)"},{"content":"왜 만들게 됐나 사내 그룹웨어에는 인사 정보가 있지만 조회 UI가 불편하고, 부서 이동이나 입퇴사 같은 변동 이력을 추적하는 기능이 없었다. 직접 관리하고 싶은 데이터가 생겼을 때 그때그때 SQL을 뽑아 쓸 수 있는 환경도 필요했다.\n그래서 만들었다.\n그룹웨어 API에서 인사 데이터를 매일 자동 수집 이전 상태와 비교해서 입사/퇴사/부서이동/직책변경 자동 감지 React 웹 UI에서 조회·수정·삭제, CSV 다운로드, INSERT SQL 생성 기술 스택 선택 영역 기술 선택 이유 백엔드 Node.js 20 + Express 기존 Slack 봇과 동일 스택 ORM Prisma 타입 안전한 쿼리, 마이그레이션 자동화 DB PostgreSQL 16 JSON 파일로는 한계, 이력 테이블 필요 스케줄러 node-cron 매일 1시 배치 실행 프론트엔드 React 18 + Vite 빠른 개발 환경 그리드 AG Grid Community 대용량 데이터 필터/정렬/페이지네이션 서버 상태 TanStack Query API 캐싱, 로딩/에러 상태 관리 인프라 Docker Compose 3개 서비스(DB, 서버, 클라이언트) 일괄 관리 기존 Slack 봇은 DB 없이 JSON 파일로 상태를 관리했는데, 인사 데이터는 수백 명 규모에 이력까지 쌓아야 하니 PostgreSQL이 필요했다.\nAG Grid를 선택한 이유는 단순하다. 수백 건 데이터를 부서/이름/직책 기준으로 빠르게 필터링하고 정렬하는 기능을 직접 구현하기엔 공수가 크다. Community 버전이 무료라 부담 없이 쓸 수 있었다.\n전체 아키텍처 [그룹웨어 API] │ │ 매일 01:00 배치 실행 ▼ [Node.js 서버] │ ├─ 인사 데이터 수집 ├─ DB 비교 → 변동 감지 (입사/퇴사/부서이동/직책변경) ├─ REST API (/api/users, /api/history, ...) └─ 크론 스케줄러 │ ▼ [PostgreSQL] │ ▼ [React 클라이언트] ← nginx (API 프록시 + SPA 라우팅) Docker Compose로 세 서비스를 한 번에 관리한다.\n서비스 포트 역할 db 5432 PostgreSQL 16 server 4000 Express API + 배치 크론 client 3002 React (nginx 서빙) 클라이언트의 nginx가 /api/ 요청을 server로 프록시하기 때문에 프론트에서 API URL을 하드코딩할 필요가 없다.\n프로젝트 구조 unipost_insa/ ├── docker-compose.yml ├── server/ │ ├── Dockerfile │ ├── entrypoint.sh # DB 대기 → prisma db push → 서버 시작 │ ├── .env │ ├── prisma/ │ │ └── schema.prisma │ └── src/ │ ├── index.js # 서버 진입점 + 크론 │ ├── api.js # REST API 라우터 │ ├── batch.js # 인사 수집 + 변동 감지 │ └── login.js # SSO 로그인 └── client/ ├── Dockerfile ├── nginx.conf ├── .env └── src/ ├── App.jsx ├── api.js └── components/ ├── UserGrid.jsx # AG Grid 테이블 ├── UserModal.jsx # 추가/수정 모달 ├── SqlModal.jsx # INSERT SQL 모달 └── HistoryPanel.jsx # 변동 이력 패널 Prisma 스키마 설계 테이블 구조 인사 데이터는 두 테이블로 관리한다.\nusers — 현재 인사 정보 (최신 상태만 유지) user_history — 변동 이력 (변동이 생길 때마다 누적) generator client { provider = \u0026#34;prisma-client-js\u0026#34; binaryTargets = [\u0026#34;native\u0026#34;, \u0026#34;debian-openssl-3.0.x\u0026#34;] } datasource db { provider = \u0026#34;postgresql\u0026#34; url = env(\u0026#34;DATABASE_URL\u0026#34;) } model User { id Int @id @default(autoincrement()) usId String @unique @map(\u0026#34;us_id\u0026#34;) usName String @map(\u0026#34;us_name\u0026#34;) deptId String? @map(\u0026#34;dept_id\u0026#34;) deptName String? @map(\u0026#34;dept_name\u0026#34;) usRollName String? @map(\u0026#34;us_roll_name\u0026#34;) usPosName String? @map(\u0026#34;us_pos_name\u0026#34;) usMail1 String? @map(\u0026#34;us_mail1\u0026#34;) usCellno String? @map(\u0026#34;us_cellno\u0026#34;) usTelno String? @map(\u0026#34;us_telno\u0026#34;) chiefYn String? @map(\u0026#34;chief_yn\u0026#34;) chiefUsId String? @map(\u0026#34;chief_us_id\u0026#34;) createdAt DateTime @default(now()) @map(\u0026#34;created_at\u0026#34;) updatedAt DateTime @updatedAt @map(\u0026#34;updated_at\u0026#34;) history UserHistory[] @@map(\u0026#34;users\u0026#34;) } model UserHistory { id Int @id @default(autoincrement()) usId String @map(\u0026#34;us_id\u0026#34;) changeType String @map(\u0026#34;change_type\u0026#34;) // JOIN | LEAVE | DEPT_CHANGE | ROLE_CHANGE fieldName String? @map(\u0026#34;field_name\u0026#34;) oldValue String? @map(\u0026#34;old_value\u0026#34;) newValue String? @map(\u0026#34;new_value\u0026#34;) detectedAt DateTime @default(now()) @map(\u0026#34;detected_at\u0026#34;) user User @relation(fields: [usId], references: [usId]) @@map(\u0026#34;user_history\u0026#34;) } 설계 결정: 왜 현재 상태와 이력을 분리했나 한 테이블에 모든 이력을 쌓는 방식도 있지만, 두 테이블로 분리한 이유가 있다.\n현재 상태 조회 성능: 인사 목록 조회는 항상 \u0026ldquo;현재\u0026rdquo; 데이터만 보면 된다. 이력이 수천 건 쌓여도 users 테이블은 항상 현재 인원 수만큼만 유지된다.\n이력 추적 명확성: user_history에는 무슨 필드가 어떤 값에서 어떤 값으로 바뀌었는지가 명시적으로 기록된다. 쿼리 없이 바로 읽을 수 있다.\n관계 명확성: usId로 현재 정보와 이력을 연결한다. 특정 사람의 전체 변동 이력을 한 번에 조회할 수 있다.\nchangeType 설계 const CHANGE_TYPES = { JOIN: \u0026#39;JOIN\u0026#39;, // 신규 입사 (DB에 없던 usId 등장) LEAVE: \u0026#39;LEAVE\u0026#39;, // 퇴사 (API 응답에서 usId 사라짐) DEPT_CHANGE: \u0026#39;DEPT_CHANGE\u0026#39;, // 부서 이동 ROLE_CHANGE: \u0026#39;ROLE_CHANGE\u0026#39;, // 직책/직위 변경 }; DEPT_CHANGE와 ROLE_CHANGE는 fieldName, oldValue, newValue에 구체적으로 뭐가 바뀌었는지 기록한다.\nfieldName: \u0026#34;deptName\u0026#34; oldValue: \u0026#34;개발1팀\u0026#34; newValue: \u0026#34;개발2팀\u0026#34; binaryTargets 설정 generator client { provider = \u0026#34;prisma-client-js\u0026#34; binaryTargets = [\u0026#34;native\u0026#34;, \u0026#34;debian-openssl-3.0.x\u0026#34;] } 이 설정이 없으면 Docker 배포 시 문제가 생긴다. 로컬(macOS/Windows)에서 생성된 Prisma Client는 Linux 환경에서 동작하지 않는다. debian-openssl-3.0.x를 추가하면 Linux 컨테이너에서도 정상 동작하는 바이너리가 함께 생성된다.\nREST API 목록 Method Path 설명 GET /api/users 인사 목록 (dept, name, role 쿼리 필터) GET /api/users/:usId 단건 조회 POST /api/users 수동 추가 PUT /api/users/:usId 수정 DELETE /api/users/:usId 삭제 GET /api/history 변동 이력 (usId, type 쿼리 필터) GET /api/depts 부서 목록 POST /api/users/sql/insert 필터 기반 INSERT SQL 생성 GET /health 헬스체크 /health 엔드포인트는 Docker Compose healthcheck에서 server가 준비됐는지 확인하는 데 쓴다. 3편에서 자세히 다룬다.\n다음 편에서는 그룹웨어 API에서 인사 데이터를 수집해서 DB와 비교하고, 변동을 감지하는 배치 로직과 React + AG Grid로 관리 UI를 만드는 과정을 다룬다.\n","permalink":"https://chanyeols.com/posts/insa-01-architecture-schema/","summary":"\u003ch2 id=\"왜-만들게-됐나\"\u003e왜 만들게 됐나\u003c/h2\u003e\n\u003cp\u003e사내 그룹웨어에는 인사 정보가 있지만 조회 UI가 불편하고, 부서 이동이나 입퇴사 같은 변동 이력을 추적하는 기능이 없었다. 직접 관리하고 싶은 데이터가 생겼을 때 그때그때 SQL을 뽑아 쓸 수 있는 환경도 필요했다.\u003c/p\u003e\n\u003cp\u003e그래서 만들었다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e그룹웨어 API에서 인사 데이터를 매일 자동 수집\u003c/li\u003e\n\u003cli\u003e이전 상태와 비교해서 입사/퇴사/부서이동/직책변경 자동 감지\u003c/li\u003e\n\u003cli\u003eReact 웹 UI에서 조회·수정·삭제, CSV 다운로드, INSERT SQL 생성\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"기술-스택-선택\"\u003e기술 스택 선택\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e영역\u003c/th\u003e\n          \u003cth\u003e기술\u003c/th\u003e\n          \u003cth\u003e선택 이유\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e백엔드\u003c/td\u003e\n          \u003ctd\u003eNode.js 20 + Express\u003c/td\u003e\n          \u003ctd\u003e기존 Slack 봇과 동일 스택\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eORM\u003c/td\u003e\n          \u003ctd\u003ePrisma\u003c/td\u003e\n          \u003ctd\u003e타입 안전한 쿼리, 마이그레이션 자동화\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eDB\u003c/td\u003e\n          \u003ctd\u003ePostgreSQL 16\u003c/td\u003e\n          \u003ctd\u003eJSON 파일로는 한계, 이력 테이블 필요\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e스케줄러\u003c/td\u003e\n          \u003ctd\u003enode-cron\u003c/td\u003e\n          \u003ctd\u003e매일 1시 배치 실행\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e프론트엔드\u003c/td\u003e\n          \u003ctd\u003eReact 18 + Vite\u003c/td\u003e\n          \u003ctd\u003e빠른 개발 환경\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e그리드\u003c/td\u003e\n          \u003ctd\u003eAG Grid Community\u003c/td\u003e\n          \u003ctd\u003e대용량 데이터 필터/정렬/페이지네이션\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e서버 상태\u003c/td\u003e\n          \u003ctd\u003eTanStack Query\u003c/td\u003e\n          \u003ctd\u003eAPI 캐싱, 로딩/에러 상태 관리\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e인프라\u003c/td\u003e\n          \u003ctd\u003eDocker Compose\u003c/td\u003e\n          \u003ctd\u003e3개 서비스(DB, 서버, 클라이언트) 일괄 관리\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e기존 Slack 봇은 DB 없이 JSON 파일로 상태를 관리했는데, 인사 데이터는 수백 명 규모에 이력까지 쌓아야 하니 PostgreSQL이 필요했다.\u003c/p\u003e","title":"사내 인사정보 관리 시스템 만들기 - 아키텍처 + Prisma 스키마 설계 (1편)"},{"content":"왜 관리자 페이지가 필요한가 처음엔 팀 Webhook이 하나라 teams.json을 직접 수정해도 됐다. 그런데 팀이 늘어나고, 알림 타입을 팀별로 다르게 설정하고 싶어지고, 출근 알림 대상 멤버도 자주 바뀌면서 파일을 직접 건드리는 게 너무 번거로워졌다.\n그래서 관리자 페이지를 만들었다. React 같은 프레임워크 없이 Express + 바닐라 JS로 만들었다. 관리자 혼자 쓰는 내부 툴이라 빌드 파이프라인 없이 심플하게 가는 게 낫다고 판단했다.\n관리자 페이지 구성 패널 4개로 구성된다.\n패널 기능 팀·Webhook 팀 추가/수정/삭제, 활성 토글, 알림 타입 배지 토글, 테스트 발송 출근알림 멤버 멤버 추가/수정/삭제, Slack 이름 자동완성 출근현황 카드뷰 출근/미출근 현황, 수동 알림 발송 크론 관리 크론 활성화 토글, 스케줄 표현식 수정 설정 Slack Bot Token 관리 팀·Webhook 패널 팀별로 6가지 알림 타입을 배지 형태로 토글할 수 있다.\n// 알림 타입 배지 토글 document.querySelectorAll(\u0026#39;.alert-badge\u0026#39;).forEach(badge =\u0026gt; { badge.addEventListener(\u0026#39;click\u0026#39;, async () =\u0026gt; { const type = badge.dataset.type; const teamId = badge.closest(\u0026#39;[data-team-id]\u0026#39;).dataset.teamId; badge.classList.toggle(\u0026#39;active\u0026#39;); await fetch(`/api/teams/${teamId}/alert-types`, { method: \u0026#39;PATCH\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, body: JSON.stringify({ type, enabled: badge.classList.contains(\u0026#39;active\u0026#39;) }), }); }); }); 테스트 버튼을 누르면 해당 Webhook으로 즉시 테스트 메시지를 발송해서 연결 여부를 확인할 수 있다.\n출근 알림 멤버 관리 출근 미등록 DM을 보내려면 멤버의 사번과 Slack 유저 ID가 필요하다.\nSlack 유저 이름 자동완성 기능을 넣었다. 입력하면 Bot Token으로 Slack 멤버 목록을 가져와서 필터링한다.\nasync function fetchSlackMembers() { const res = await fetch(\u0026#39;https://slack.com/api/users.list\u0026#39;, { headers: { Authorization: `Bearer ${BOT_TOKEN}` }, }); const data = await res.json(); return data.members .filter(m =\u0026gt; !m.is_bot \u0026amp;\u0026amp; !m.deleted) .map(m =\u0026gt; ({ id: m.id, name: m.profile.display_name || m.real_name, })); } selectedIndex 버그 Slack 이름 자동완성에서 한 가지 버그가 있었다. 같은 팀에 동명이인이 있을 때 select.value = \u0026quot;targetValue\u0026quot;로 설정하면 항상 첫 번째 옵션으로 스냅되는 문제였다.\n// 버그 있는 코드 select.value = member.slackId; // 항상 첫 번째로 스냅됨 // 해결: selectedIndex 직접 지정 const idx = [...select.options].findIndex(o =\u0026gt; o.value === member.slackId); if (idx !== -1) select.selectedIndex = idx; value 방식은 같은 value를 가진 옵션이 여러 개일 때 첫 번째로 스냅된다. selectedIndex를 직접 지정하면 원하는 옵션을 정확하게 선택할 수 있다.\n크론 관리 패널 크론을 활성화/비활성화하고 스케줄 표현식을 수정하면 서버에서 실시간으로 크론을 재등록한다.\n// 크론 재등록 API app.patch(\u0026#39;/api/crons/:id\u0026#39;, async (req, res) =\u0026gt; { const { id } = req.params; const { enabled, schedule } = req.body; // 기존 크론 중지 if (cronJobs[id]) { cronJobs[id].stop(); delete cronJobs[id]; } // 설정 저장 settings.crons[id] = { enabled, schedule }; await saveSettings(); // 활성화 상태면 재등록 if (enabled) { cronJobs[id] = cron.schedule(schedule, cronHandlers[id]); } res.json({ ok: true }); }); 재배포 없이 브라우저에서 바로 크론을 조정할 수 있어서 편하다.\nDocker Compose 배포 docker-compose.yml services: slack-bot: build: . container_name: slack-bot ports: - \u0026#34;3001:3000\u0026#34; env_file: - .env volumes: - ./data:/app/data restart: unless-stopped data/ 폴더를 볼륨으로 마운트해서 컨테이너를 재시작해도 teams.json, snapshot.json 등 데이터가 유지된다.\n.env LOGIN_ID=포털_아이디 LOGIN_PW=포털_비밀번호 PORT=3000 DATA_DIR=/app/data TZ=Asia/Seoul SLACK_BOT_TOKEN은 .env에 넣지 않고 관리자 페이지 설정 패널에서 입력하면 settings.json에 저장된다. 재배포 없이 토큰을 바꿀 수 있다.\nDockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . EXPOSE 3000 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] 배포 명령어 # 최초 실행 또는 코드 변경 시 docker compose up -d --build # .env만 변경 시 docker compose restart # 로그 확인 docker logs slack-bot -f Nginx 리버스 프록시 슬래시 커맨드 엔드포인트만 외부에 노출하고, 관리자 페이지는 Tailscale VPN에서만 접근한다.\n# 외부 노출: 슬래시 커맨드, Slack 이벤트 location /slack/ { proxy_pass http://localhost:3001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } # 관리자 페이지는 Nginx에 열지 않음 # → Tailscale VPN으로만 접근: http://100.109.108.36:3001/admin 트러블슈팅 모음 Docker 타임존 이슈 컨테이너 기본 타임존이 UTC라서 알림이 오전 9시가 아닌 오후 6시(UTC 09:00)에 발송됐다.\n# .env에 추가 TZ=Asia/Seoul 한 줄 추가로 해결됐다. Docker 컨테이너 운영 시 타임존은 항상 명시적으로 설정하는 게 좋다.\n같은 Webhook 중복 발송 여러 팀 엔트리가 같은 Webhook URL을 쓰는 경우, 팀 수만큼 동일한 알림이 발송됐다.\n// 해결: webhook 기준으로 dedup function dedupeByWebhook(teams) { const seen = new Set(); return teams.filter(t =\u0026gt; { if (seen.has(t.webhook)) return false; seen.add(t.webhook); return true; }); } 서버 재시작 시 스냅샷 유실 초기에 스냅샷을 메모리에만 들고 있었는데, 재시작하면 스냅샷이 초기화돼서 모든 항목이 \u0026ldquo;새로 추가됨\u0026quot;으로 감지되는 문제가 있었다.\n// 서버 시작 시 snapshot.json 로드 async function loadSnapshot() { try { const raw = await fs.readFile(SNAPSHOT_PATH, \u0026#39;utf8\u0026#39;); return JSON.parse(raw); } catch { return { vacations: [], rooms: [], updatedAt: null }; } } 파일에 저장하고 시작 시 로드하는 것만으로 해결됐다.\nPM2 → Docker 전환 초기에는 PM2로 프로세스를 관리했다. 그런데 Node.js 버전 관리, 환경변수 주입, 재시작 정책 등을 일관되게 관리하기가 번거로웠다.\nDocker로 전환하면서 이 문제가 모두 해결됐다. restart: unless-stopped로 서버가 죽어도 자동 재시작되고, 환경변수는 .env로 관리하고, Node.js 버전은 FROM node:20-alpine으로 고정된다.\n마무리 4편에 걸쳐 사내 Slack 알림 봇을 만든 과정을 정리했다.\n편 내용 1편 기획 배경 + 전체 아키텍처 2편 SSO 세션 처리 + 내부 API 연동 3편 스냅샷 비교 변동 감지 + 스케줄러 + Slack 알림 4편 관리자 페이지 + Docker 배포 + 트러블슈팅 ← 지금 여기 DB 없이 JSON 파일만으로 상태를 관리한 게 핵심이다. 사내 툴은 오버엔지니어링할 필요가 없다. 실제로 필요한 기능만 빠르게 만들어서 쓰는 게 낫다.\n현재 Slack 봇에서 관리하는 인사 데이터를 DB로 체계적으로 관리하는 시스템도 별도로 만들었다. 추후 두 시스템을 연동해서 인사 변동이 생기면 Slack으로 알림이 오는 흐름을 구성할 예정이다.\n","permalink":"https://chanyeols.com/posts/slack-04-admin-deploy/","summary":"\u003ch2 id=\"왜-관리자-페이지가-필요한가\"\u003e왜 관리자 페이지가 필요한가\u003c/h2\u003e\n\u003cp\u003e처음엔 팀 Webhook이 하나라 \u003ccode\u003eteams.json\u003c/code\u003e을 직접 수정해도 됐다. 그런데 팀이 늘어나고, 알림 타입을 팀별로 다르게 설정하고 싶어지고, 출근 알림 대상 멤버도 자주 바뀌면서 파일을 직접 건드리는 게 너무 번거로워졌다.\u003c/p\u003e\n\u003cp\u003e그래서 관리자 페이지를 만들었다. React 같은 프레임워크 없이 Express + 바닐라 JS로 만들었다. 관리자 혼자 쓰는 내부 툴이라 빌드 파이프라인 없이 심플하게 가는 게 낫다고 판단했다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"관리자-페이지-구성\"\u003e관리자 페이지 구성\u003c/h2\u003e\n\u003cp\u003e\u003cimg alt=\"관리자 페이지 전체 화면\" loading=\"lazy\" src=\"/images/slack-04-admin.png\"\u003e\u003c/p\u003e\n\u003cp\u003e패널 4개로 구성된다.\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e패널\u003c/th\u003e\n          \u003cth\u003e기능\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e팀·Webhook\u003c/td\u003e\n          \u003ctd\u003e팀 추가/수정/삭제, 활성 토글, 알림 타입 배지 토글, 테스트 발송\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e출근알림 멤버\u003c/td\u003e\n          \u003ctd\u003e멤버 추가/수정/삭제, Slack 이름 자동완성\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e출근현황\u003c/td\u003e\n          \u003ctd\u003e카드뷰 출근/미출근 현황, 수동 알림 발송\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e크론 관리\u003c/td\u003e\n          \u003ctd\u003e크론 활성화 토글, 스케줄 표현식 수정\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e설정\u003c/td\u003e\n          \u003ctd\u003eSlack Bot Token 관리\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"팀webhook-패널\"\u003e팀·Webhook 패널\u003c/h2\u003e\n\u003cp\u003e팀별로 6가지 알림 타입을 배지 형태로 토글할 수 있다.\u003c/p\u003e","title":"사내 Slack 봇 만들기 - 관리자 페이지 + Docker 배포 + 트러블슈팅 (4편)"},{"content":"핵심 아이디어: 스냅샷 비교 변동 감지의 핵심은 단순하다. \u0026ldquo;방금 가져온 데이터\u0026quot;와 \u0026ldquo;지난번에 저장해둔 데이터\u0026quot;를 비교한다.\n새로 생긴 항목 → 추가 알림 사라진 항목 → 취소 알림 이 상태를 snapshot.json에 저장해두면 서버가 재시작돼도 이전 상태를 그대로 복구할 수 있다.\n스냅샷 구조 { \u0026#34;vacations\u0026#34;: [ { \u0026#34;id\u0026#34;: \u0026#34;unique-key\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;홍길동\u0026#34;, \u0026#34;date\u0026#34;: \u0026#34;2026-04-03\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;연차\u0026#34; } ], \u0026#34;rooms\u0026#34;: [ { \u0026#34;id\u0026#34;: \u0026#34;unique-key\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;주간 회의\u0026#34;, \u0026#34;room\u0026#34;: \u0026#34;중회의실\u0026#34;, \u0026#34;start\u0026#34;: \u0026#34;2026-04-03T10:00:00\u0026#34;, \u0026#34;end\u0026#34;: \u0026#34;2026-04-03T11:00:00\u0026#34; } ], \u0026#34;updatedAt\u0026#34;: \u0026#34;2026-04-03T08:50:00.000Z\u0026#34; } 변동 감지 로직 function detectChanges(prev, curr, keyFn) { const prevMap = new Map(prev.map(item =\u0026gt; [keyFn(item), item])); const currMap = new Map(curr.map(item =\u0026gt; [keyFn(item), item])); const added = curr.filter(item =\u0026gt; !prevMap.has(keyFn(item))); const removed = prev.filter(item =\u0026gt; !currMap.has(keyFn(item))); return { added, removed }; } keyFn으로 각 항목의 고유 키를 만든다. 휴가는 사번+날짜, 회의실은 예약ID 같은 식이다.\n// 휴가 변동 감지 const vacationChanges = detectChanges( snapshot.vacations, freshVacations, v =\u0026gt; `${v.id}_${v.date}` ); // 회의실 변동 감지 const roomChanges = detectChanges( snapshot.rooms, freshRooms, r =\u0026gt; r.id ); 회의실 변동 감지 주의점 회의실은 한 가지 함정이 있다. API가 요청한 날짜 범위보다 더 넓은 데이터를 반환하는 경우가 있어서, 날짜가 바뀌는 시점에 오탐이 발생했다.\n예를 들어 오늘 오후 11시에 내일 예약이 새로 잡혔는데, 내일 예약은 아직 어제 스냅샷에 없으니 \u0026ldquo;추가됨\u0026quot;으로 오탐하는 것이다.\n해결: 날짜가 바뀐 경우 회의실 비교를 건너뛴다.\nconst snapshotDate = snapshot.updatedAt?.slice(0, 10); const today = new Date().toISOString().slice(0, 10); // 날짜가 같을 때만 변동 비교 (날짜 변경 시 오탐 방지) if (snapshotDate === today) { const roomChanges = detectChanges(snapshot.rooms, freshRooms, r =\u0026gt; r.id); if (roomChanges.added.length || roomChanges.removed.length) { await notifyRoomChanges(roomChanges); } } 스냅샷 저장 변동 감지 후 현재 상태를 스냅샷으로 저장한다.\nasync function saveSnapshot(vacations, rooms) { const snapshot = { vacations, rooms, updatedAt: new Date().toISOString(), }; await fs.writeFile(SNAPSHOT_PATH, JSON.stringify(snapshot, null, 2)); } node-cron 스케줄러 구성 const cron = require(\u0026#39;node-cron\u0026#39;); // 10분마다: 변동 감지 cron.schedule(\u0026#39;*/10 * * * *\u0026#39;, () =\u0026gt; syncAndCheckChanges()); // 월요일 09시: 이번 주 전체 휴가자 cron.schedule(\u0026#39;0 9 * * 1\u0026#39;, () =\u0026gt; sendVacationWeekly()); // 화~금 09시: 당일 휴가자 cron.schedule(\u0026#39;0 9 * * 2-5\u0026#39;, () =\u0026gt; sendVacationDaily()); // 월~금 09시: 당일 회의실 예약 cron.schedule(\u0026#39;0 9 * * 1-5\u0026#39;, () =\u0026gt; sendRoomDaily()); // 월~금 09시: 출근 미등록자 DM cron.schedule(\u0026#39;0 9 * * 1-5\u0026#39;, () =\u0026gt; sendCommuteAlert()); // 30분마다: 세션 keepalive cron.schedule(\u0026#39;*/30 * * * *\u0026#39;, () =\u0026gt; keepSession()); // 월요일 00시: 로그 초기화 cron.schedule(\u0026#39;0 0 * * 1\u0026#39;, () =\u0026gt; resetLog()); 타임존 주의: node-cron의 기본 타임존은 시스템 타임존을 따른다. Docker 컨테이너 기본값은 UTC라서 .env에 TZ=Asia/Seoul을 설정하지 않으면 알림이 9시간 늦게 발송된다.\n크론 설정을 파일로 관리 크론 스케줄을 코드에 하드코딩하면 수정할 때마다 재배포해야 한다. settings.json에 저장해서 관리자 페이지에서 수정 가능하게 했다.\n{ \u0026#34;crons\u0026#34;: { \u0026#34;sync\u0026#34;: { \u0026#34;enabled\u0026#34;: true, \u0026#34;schedule\u0026#34;: \u0026#34;*/10 * * * *\u0026#34; }, \u0026#34;vacationWeekly\u0026#34;: { \u0026#34;enabled\u0026#34;: true, \u0026#34;schedule\u0026#34;: \u0026#34;0 9 * * 1\u0026#34; }, \u0026#34;vacationDaily\u0026#34;: { \u0026#34;enabled\u0026#34;: true, \u0026#34;schedule\u0026#34;: \u0026#34;0 9 * * 2-5\u0026#34; }, \u0026#34;roomDaily\u0026#34;: { \u0026#34;enabled\u0026#34;: true, \u0026#34;schedule\u0026#34;: \u0026#34;0 9 * * 1-5\u0026#34; }, \u0026#34;commute\u0026#34;: { \u0026#34;enabled\u0026#34;: true, \u0026#34;schedule\u0026#34;: \u0026#34;0 9 * * 1-5\u0026#34; }, \u0026#34;sessionKeep\u0026#34;: { \u0026#34;enabled\u0026#34;: true, \u0026#34;schedule\u0026#34;: \u0026#34;*/30 * * * *\u0026#34; }, \u0026#34;logReset\u0026#34;: { \u0026#34;enabled\u0026#34;: true, \u0026#34;schedule\u0026#34;: \u0026#34;0 0 * * 1\u0026#34; } } } 서버 시작 시 이 파일을 읽어서 크론을 동적으로 등록한다.\nconst settings = JSON.parse(await fs.readFile(SETTINGS_PATH)); const cronJobs = {}; for (const [id, config] of Object.entries(settings.crons)) { if (!config.enabled) continue; cronJobs[id] = cron.schedule(config.schedule, cronHandlers[id]); } Slack Webhook 알림 발송 기본 발송 async function sendWebhook(webhookUrl, message) { await axios.post(webhookUrl, { text: message }); } 팀별 알림 타입 필터링 팀마다 받고 싶은 알림 타입이 다를 수 있다. teams.json에서 각 팀의 활성화된 타입만 발송한다.\n{ \u0026#34;teams\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;개발팀\u0026#34;, \u0026#34;webhook\u0026#34;: \u0026#34;https://hooks.slack.com/...\u0026#34;, \u0026#34;enabled\u0026#34;: true, \u0026#34;alertTypes\u0026#34;: { \u0026#34;vacationChange\u0026#34;: true, \u0026#34;vacationDaily\u0026#34;: true, \u0026#34;roomChange\u0026#34;: false, \u0026#34;roomDaily\u0026#34;: true, \u0026#34;commute\u0026#34;: false }, \u0026#34;deptIds\u0026#34;: [\u0026#34;0001\u0026#34;, \u0026#34;0002\u0026#34;] } ] } async function notifyByType(type, message) { const teams = settings.teams.filter( t =\u0026gt; t.enabled \u0026amp;\u0026amp; t.alertTypes[type] ); for (const team of teams) { await sendWebhook(team.webhook, message); } } 같은 Webhook 중복 발송 방지 여러 팀이 같은 Webhook URL을 쓰는 경우가 있다. 그냥 발송하면 같은 채널에 동일한 알림이 여러 번 온다.\nfunction dedupeByWebhook(teams) { const seen = new Set(); return teams.filter(team =\u0026gt; { if (seen.has(team.webhook)) return false; seen.add(team.webhook); return true; }); } async function notifyByType(type, message) { const teams = dedupeByWebhook( settings.teams.filter(t =\u0026gt; t.enabled \u0026amp;\u0026amp; t.alertTypes[type]) ); for (const team of teams) { await sendWebhook(team.webhook, message); } } Slack 슬래시 커맨드 슬래시 커맨드는 Slack이 POST 요청을 서버로 보내는 방식이다. 3초 안에 응답하지 않으면 타임아웃이 난다.\napp.post(\u0026#39;/slack/command\u0026#39;, async (req, res) =\u0026gt; { const { command, text, channel_id } = req.body; // Slack에 일단 빈 응답 먼저 → 3초 타임아웃 방지 res.json({ response_type: \u0026#39;ephemeral\u0026#39;, text: \u0026#39;조회 중...\u0026#39; }); // 실제 처리는 비동기로 const result = await handleCommand(command, text, channel_id); await axios.post(req.body.response_url, { response_type: \u0026#39;in_channel\u0026#39;, text: result, }); }); 날짜 파싱 function parseDate(text) { if (!text || text === \u0026#39;오늘\u0026#39;) return [today(), today()]; if (text === \u0026#39;내일\u0026#39;) return [tomorrow(), tomorrow()]; if (text === \u0026#39;이번주\u0026#39;) return [monday(), friday()]; if (text.includes(\u0026#39;~\u0026#39;)) { const [start, end] = text.split(\u0026#39;~\u0026#39;).map(s =\u0026gt; s.trim()); return [start, end]; } // 날짜 직접 입력: \u0026#39;2026-04-03\u0026#39; return [text.trim(), text.trim()]; } 채널 기반 팀 매칭 채널 ID로 어느 팀 채널인지 파악해서 해당 팀 휴가자만 보여준다.\nfunction getTeamByChannel(channelId) { // 1. 채널 ID로 팀 매칭 const team = teams.find(t =\u0026gt; t.channelId === channelId); if (team) return team; // 2. 실패 시 → 사용자 Slack 이름에서 팀 추출 // 3. 그것도 실패 시 → 전체 팀 그룹핑해서 표시 return null; } 메시지 포맷 예시 당일 휴가자 회의실 예약 현황 휴가 변동 알림 정리 스냅샷 비교로 변동 감지 → DB 없이 JSON 파일만으로 충분 회의실 변동은 날짜가 바뀌는 시점에 오탐 주의, 날짜 동일할 때만 비교 node-cron 타임존은 반드시 확인, Docker면 TZ=Asia/Seoul 필수 슬래시 커맨드는 3초 타임아웃 때문에 즉시 응답 후 비동기 처리 같은 Webhook URL 중복 발송은 dedup 처리 다음 편에서는 관리자 페이지 구현과 Docker 배포, 실제 겪은 트러블슈팅을 다룬다.\n","permalink":"https://chanyeols.com/posts/slack-03-snapshot-scheduler/","summary":"\u003ch2 id=\"핵심-아이디어-스냅샷-비교\"\u003e핵심 아이디어: 스냅샷 비교\u003c/h2\u003e\n\u003cp\u003e변동 감지의 핵심은 단순하다. \u003cstrong\u003e\u0026ldquo;방금 가져온 데이터\u0026quot;와 \u0026ldquo;지난번에 저장해둔 데이터\u0026quot;를 비교한다.\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e새로 생긴 항목 → 추가 알림\u003c/li\u003e\n\u003cli\u003e사라진 항목 → 취소 알림\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e이 상태를 \u003ccode\u003esnapshot.json\u003c/code\u003e에 저장해두면 서버가 재시작돼도 이전 상태를 그대로 복구할 수 있다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"스냅샷-구조\"\u003e스냅샷 구조\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-json\" data-lang=\"json\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;vacations\u0026#34;\u003c/span\u003e: [\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;id\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;unique-key\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;name\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;홍길동\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;date\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;2026-04-03\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;type\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;연차\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  ],\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;rooms\u0026#34;\u003c/span\u003e: [\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;id\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;unique-key\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;title\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;주간 회의\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;room\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;중회의실\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;start\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;2026-04-03T10:00:00\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;end\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;2026-04-03T11:00:00\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  ],\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#7ee787\"\u003e\u0026#34;updatedAt\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;2026-04-03T08:50:00.000Z\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"변동-감지-로직\"\u003e변동 감지 로직\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ff7b72\"\u003efunction\u003c/span\u003e detectChanges(prev, curr, keyFn) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e prevMap \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003enew\u003c/span\u003e Map(prev.map(item =\u0026gt; [keyFn(item), item]));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e currMap \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#ff7b72\"\u003enew\u003c/span\u003e Map(curr.map(item =\u0026gt; [keyFn(item), item]));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e added   \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e curr.filter(item =\u0026gt; \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e!\u003c/span\u003eprevMap.has(keyFn(item)));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003econst\u003c/span\u003e removed \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e prev.filter(item =\u0026gt; \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e!\u003c/span\u003ecurrMap.has(keyFn(item)));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#ff7b72\"\u003ereturn\u003c/span\u003e { added, removed };\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003ekeyFn\u003c/code\u003e으로 각 항목의 고유 키를 만든다. 휴가는 \u003ccode\u003e사번+날짜\u003c/code\u003e, 회의실은 \u003ccode\u003e예약ID\u003c/code\u003e 같은 식이다.\u003c/p\u003e","title":"사내 Slack 봇 만들기 - 스냅샷 비교 변동 감지 + 스케줄러 + Slack 알림 (3편)"},{"content":"문제: 그룹웨어 API를 어떻게 호출하나 사내 그룹웨어는 브라우저에서 로그인한 세션 쿠키로 API를 호출하는 구조다. 공개 API키 같은 게 없고, 그냥 브라우저처럼 로그인해서 쿠키를 들고 API를 쳐야 한다.\nNode.js에서 이걸 하려면 axios로 로그인 과정을 그대로 흉내내야 한다.\nSSO 로그인 흐름 대부분의 사내 SSO는 아래 패턴을 따른다.\n1단계: 로그인 페이지 GET → 초기 세션 쿠키 획득 2단계: 로그인 form POST → 인증 처리 3단계: SSO 토큰 발급 페이지 GET → 리다이렉트로 토큰 획득 4단계: 대상 도메인에 토큰으로 접근 → 해당 도메인 세션 쿠키 획득 핵심은 각 단계에서 받은 쿠키를 다음 요청에 그대로 넘겨줘야 한다는 것이다.\naxios로 SSO 구현하기 쿠키 파싱 헬퍼 axios는 브라우저와 달리 쿠키를 자동으로 관리해주지 않는다. 응답 헤더에서 직접 파싱해야 한다.\nfunction parseCookies(setCookieHeader) { if (!setCookieHeader) return \u0026#39;\u0026#39;; const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; return cookies .map(c =\u0026gt; c.split(\u0026#39;;\u0026#39;)[0]) // \u0026#39;name=value; Path=/\u0026#39; → \u0026#39;name=value\u0026#39; .join(\u0026#39;; \u0026#39;); } 1단계: 초기 세션 쿠키 획득 const initRes = await axios.get(\u0026#39;https://portal.company.com/login\u0026#39;, { maxRedirects: 0, validateStatus: s =\u0026gt; s \u0026lt; 400, }); let cookie = parseCookies(initRes.headers[\u0026#39;set-cookie\u0026#39;]); 2단계: 로그인 const loginRes = await axios.post( \u0026#39;https://portal.company.com/login/check\u0026#39;, new URLSearchParams({ id: process.env.LOGIN_ID, password: process.env.LOGIN_PW, }), { headers: { Cookie: cookie, \u0026#39;Content-Type\u0026#39;: \u0026#39;application/x-www-form-urlencoded\u0026#39;, }, maxRedirects: 0, validateStatus: s =\u0026gt; s \u0026lt; 400, } ); cookie = parseCookies(loginRes.headers[\u0026#39;set-cookie\u0026#39;]) || cookie; 로그인 요청은 form-data(application/x-www-form-urlencoded) 방식으로 보내야 한다. axios.post에 객체를 그냥 넘기면 JSON으로 보내져서 로그인이 안 된다. URLSearchParams로 감싸줘야 한다.\n3단계: SSO 토큰 발급 const ssoRes = await axios.get(\u0026#39;https://portal.company.com/sso-redirect\u0026#39;, { headers: { Cookie: cookie }, maxRedirects: 0, // 리다이렉트 수동으로 따라가야 함 validateStatus: s =\u0026gt; s \u0026lt; 400, }); // 리다이렉트 URL에서 토큰 추출 const ssoUrl = ssoRes.headers[\u0026#39;location\u0026#39;]; maxRedirects: 0이 중요하다. axios가 자동으로 리다이렉트를 따라가면 쿠키가 유실된다. 리다이렉트를 수동으로 처리해야 각 단계의 쿠키를 직접 챙길 수 있다.\n4단계: 대상 도메인 세션 획득 const domainRes = await axios.get(ssoUrl, { maxRedirects: 5, // 여기서는 따라가도 됨 validateStatus: s =\u0026gt; s \u0026lt; 400, }); const domainCookie = parseCookies(domainRes.headers[\u0026#39;set-cookie\u0026#39;]); 이 domainCookie가 실제 API 호출에 쓰는 세션이다.\n도메인별 세션 분리 우리 그룹웨어는 기능별로 서브도메인이 달랐다. 예를 들면 휴가는 leave.company.com, 회의실은 gw.company.com 식이다.\n문제는 도메인이 다르면 쿠키가 공유되지 않는다는 거다. 각 도메인에 별도로 SSO 로그인을 해야 한다.\n// 서버 시작 시 두 도메인 모두 로그인 let avsCookie = await login(\u0026#39;leave\u0026#39;); // 휴가 도메인 let gwCookie = await login(\u0026#39;gw\u0026#39;); // 회의실 도메인 // API 호출 시 해당 도메인 쿠키 사용 async function getVacation(date) { return axios.post(VACATION_API_URL, payload, { headers: { Cookie: avsCookie, ...customHeaders }, }); } async function getMeetingRooms(date) { return axios.post(ROOM_API_URL, payload, { headers: { Cookie: gwCookie, ...customHeaders }, }); } 커스텀 헤더 문제 API를 호출했는데 404나 의미 없는 오류 코드가 계속 반환됐다. 로그인도 됐고 쿠키도 맞는데 왜 안 되나 한참 삽질했다.\n브라우저 Network 탭을 열어서 실제 요청을 비교해보니 커스텀 헤더들이 빠져있었다.\nconst customHeaders = { \u0026#39;__service_id__\u0026#39;: \u0026#39;SERVICE_NAME\u0026#39;, \u0026#39;__view_id__\u0026#39;: \u0026#39;view-identifier\u0026#39;, \u0026#39;__menu_id__\u0026#39;: \u0026#39;MENU_CODE\u0026#39;, \u0026#39;ajax\u0026#39;: \u0026#39;true\u0026#39;, \u0026#39;x-requested-with\u0026#39;: \u0026#39;XMLHttpRequest\u0026#39;, }; 그룹웨어 API는 이런 커스텀 헤더로 어떤 서비스/메뉴에서 요청이 왔는지 검증한다. 빠지면 요청이 거부된다.\n해결법: 브라우저 Network 탭에서 실제 API 요청을 찾아 Request Headers를 전부 복사해서 동일하게 맞춰줬다.\n세션 유지 (30분마다 keepalive) SSO 세션은 일정 시간 요청이 없으면 만료된다. 봇이 새벽에 아무것도 안 하다가 아침에 알림을 보내려 하면 세션이 끊겨있는 상황이 생긴다.\ncron.schedule(\u0026#39;*/30 * * * *\u0026#39;, async () =\u0026gt; { try { // 세션 페이지에 GET 요청으로 keepalive await axios.get(SESSION_KEEP_URL, { headers: { Cookie: avsCookie }, }); await axios.get(SESSION_KEEP_URL_GW, { headers: { Cookie: gwCookie }, }); } catch (err) { // 실패 시 재로그인 avsCookie = await login(\u0026#39;leave\u0026#39;); gwCookie = await login(\u0026#39;gw\u0026#39;); } }); keepalive 실패 시 재로그인하도록 해두면 세션이 끊겨도 자동 복구된다.\n메모리 캐시로 API 호출 줄이기 같은 날짜 데이터를 10분마다 계속 API에서 가져오면 서버에 부담이 된다. 메모리 캐시로 불필요한 호출을 줄였다.\nconst cache = new Map(); const CACHE_TTL = 15 * 60 * 1000; // 15분 async function fetchWithCache(key, fetchFn) { const cached = cache.get(key); if (cached \u0026amp;\u0026amp; Date.now() - cached.time \u0026lt; CACHE_TTL) { return cached.data; } const data = await fetchFn(); cache.set(key, { data, time: Date.now() }); return data; } 트러블슈팅 정리 증상 원인 해결 로그인 후 API 호출 시 인증 오류 axios가 리다이렉트 자동 처리 중 쿠키 유실 maxRedirects: 0으로 수동 처리 API 404 / 의미 없는 오류 코드 커스텀 헤더 누락 브라우저 Network 탭에서 헤더 전부 확인 후 동일하게 설정 아침 알림 시 세션 만료 SSO 세션 TTL 초과 30분마다 keepalive + 실패 시 재로그인 같은 도메인인데 API마다 세션 다름 서브도메인별 쿠키 분리 도메인별 별도 로그인 세션 관리 form 로그인 안 됨 axios POST에 JSON으로 전송됨 URLSearchParams로 감싸서 form-data 형식으로 전송 다음 편에서는 수집한 데이터를 스냅샷으로 관리하고, 이전 상태와 비교해서 변동을 감지하는 로직과 Slack 알림 발송 구현을 다룬다.\n","permalink":"https://chanyeols.com/posts/slack-02-sso/","summary":"\u003ch2 id=\"문제-그룹웨어-api를-어떻게-호출하나\"\u003e문제: 그룹웨어 API를 어떻게 호출하나\u003c/h2\u003e\n\u003cp\u003e사내 그룹웨어는 브라우저에서 로그인한 세션 쿠키로 API를 호출하는 구조다. 공개 API키 같은 게 없고, 그냥 브라우저처럼 로그인해서 쿠키를 들고 API를 쳐야 한다.\u003c/p\u003e\n\u003cp\u003eNode.js에서 이걸 하려면 axios로 로그인 과정을 그대로 흉내내야 한다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"sso-로그인-흐름\"\u003eSSO 로그인 흐름\u003c/h2\u003e\n\u003cp\u003e대부분의 사내 SSO는 아래 패턴을 따른다.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e1단계: 로그인 페이지 GET → 초기 세션 쿠키 획득\n2단계: 로그인 form POST → 인증 처리\n3단계: SSO 토큰 발급 페이지 GET → 리다이렉트로 토큰 획득\n4단계: 대상 도메인에 토큰으로 접근 → 해당 도메인 세션 쿠키 획득\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e핵심은 \u003cstrong\u003e각 단계에서 받은 쿠키를 다음 요청에 그대로 넘겨줘야\u003c/strong\u003e 한다는 것이다.\u003c/p\u003e","title":"사내 Slack 봇 만들기 - SSO 세션 처리 + 내부 API 연동 (2편)"},{"content":"왜 만들게 됐나 조직 개편 전에는 팀원들이 한 채널에 모여 있어서 누가 휴가인지, 어떤 회의실이 예약됐는지 슬쩍 보면 알 수 있었다.\n개편 이후 팀이 분리되면서 연계 채널이 새로 생겼다. 그런데 서로 다른 팀 채널에 있다 보니 상대 팀의 휴가·회의실 정보를 알기가 불편해졌다. 매번 그룹웨어에 들어가서 확인하는 게 번거로웠다.\n그래서 만들었다. 매일 아침 Slack으로 당일 휴가자와 회의실 예약 현황을 자동으로 보내주는 봇.\n만들다 보니 기능이 붙었다. 출근 미등록자 DM 알림, 슬래시 커맨드로 날짜별 조회, 관리자 웹 페이지까지.\n기술 스택 영역 기술 Runtime Node.js 20 Framework Express.js 스케줄러 node-cron HTTP 클라이언트 axios 배포 Docker Compose 인프라 OCI 서버 (홈서버에서 OCI로 이전) 리버스 프록시 Nginx 데이터 저장 JSON 파일 (DB 없음) DB 없이 JSON 파일로 상태를 관리한 이유는 단순하다. 저장해야 할 데이터가 \u0026ldquo;마지막으로 확인한 휴가/회의실 목록\u0026rdquo; 하나뿐이라 PostgreSQL까지 쓸 이유가 없었다.\n전체 아키텍처 [그룹웨어 API] │ │ SSO 인증 → 휴가 / 회의실 / 출근 데이터 수집 ▼ [Node.js 봇 서버] ←→ [JSON 파일] │ teams.json (팀·Webhook 설정) │ members.json (출근 알림 대상) │ settings.json (Slack 토큰, 크론 설정) │ snapshot.json (마지막 상태 저장) │ ├─ 크론 스케줄 → Slack Webhook 알림 ├─ 슬래시 커맨드 → Slack API 응답 └─ /admin 관리자 페이지 외부에서 /admin은 Tailscale VPN으로만 접근하고, 슬래시 커맨드 엔드포인트(/slack/command)만 Nginx를 통해 외부에 열어둔다.\n전체 워크플로우 크게 세 가지 흐름으로 동작한다.\n1. 서버 시작 시\nsnapshot.json 로드 (재시작 후에도 이전 상태 복구) 그룹웨어 SSO 로그인 → 세션 취득 마지막 스냅샷이 10분 초과됐으면 즉시 동기화 실행 2. 매 10분 정기 동기화 (sync 크론)\n휴가·회의실 데이터를 API에서 새로 가져옴 이전 스냅샷과 비교해서 추가/취소된 건 감지 변동이 있으면 Slack Webhook으로 즉시 알림 발송 스냅샷 갱신 후 snapshot.json 저장 3. 오전 9시 정기 발송\n월요일: 이번 주 전체 휴가자 + 당일 회의실 예약 화~금: 당일 휴가자 + 당일 회의실 예약 출근 미등록자 → 개인 Slack DM 발송 알림 종류 휴가 변동 알림 휴가가 새로 등록되거나 취소되면 즉시 알림이 온다.\n오전 정기 알림 매일 아침 9시에 당일 휴가자와 회의실 예약 현황을 한번에 보내준다.\n출근 미등록 DM 출근 미등록자에게는 채널 알림 대신 개인 DM으로 조용히 보낸다.\n슬래시 커맨드 Slack에서 직접 조회도 된다.\n파일 구조 ~/slackbot/ ├── Dockerfile ├── docker-compose.yml ├── .env ├── server.js ├── package.json └── data/ ← 볼륨 마운트 (재시작 시 유지) ├── teams.json (팀·Webhook 설정) ├── members.json (출근 알림 대상 멤버) ├── settings.json (Slack Bot Token, 크론 설정) ├── snapshot.json (마지막 상태 저장) └── server.log data/ 폴더를 볼륨으로 마운트해서 컨테이너를 재시작해도 설정과 상태가 유지된다.\n크론 스케줄 전체 목록 크론 ID 시간 내용 sync 10분마다 휴가/회의실 변동 감지 → 알림 vacationWeekly 월요일 09시 이번 주 전체 휴가자 vacationDaily 화~금 09시 당일 휴가자 roomDaily 월~금 09시 당일 회의실 예약 현황 commute 월~금 09시 출근 미등록자 DM sessionKeep 30분마다 세션 유지 ping logReset 월요일 00시 로그 파일 초기화 모든 크론은 관리자 페이지에서 활성화/비활성화하고 스케줄 표현식도 수정할 수 있다.\n다음 편에서는 그룹웨어 SSO 로그인 세션을 처리하는 방법을 다룬다. 쿠키 기반 SSO를 axios로 처리할 때 빠지기 쉬운 함정들을 정리한다.\n","permalink":"https://chanyeols.com/posts/slack-01-intro/","summary":"\u003ch2 id=\"왜-만들게-됐나\"\u003e왜 만들게 됐나\u003c/h2\u003e\n\u003cp\u003e조직 개편 전에는 팀원들이 한 채널에 모여 있어서 누가 휴가인지, 어떤 회의실이 예약됐는지 슬쩍 보면 알 수 있었다.\u003c/p\u003e\n\u003cp\u003e개편 이후 팀이 분리되면서 연계 채널이 새로 생겼다. 그런데 서로 다른 팀 채널에 있다 보니 상대 팀의 휴가·회의실 정보를 알기가 불편해졌다. 매번 그룹웨어에 들어가서 확인하는 게 번거로웠다.\u003c/p\u003e\n\u003cp\u003e그래서 만들었다. 매일 아침 Slack으로 당일 휴가자와 회의실 예약 현황을 자동으로 보내주는 봇.\u003c/p\u003e\n\u003cp\u003e만들다 보니 기능이 붙었다. 출근 미등록자 DM 알림, 슬래시 커맨드로 날짜별 조회, 관리자 웹 페이지까지.\u003c/p\u003e","title":"사내 Slack 봇 만들기 - 기획 배경 + 전체 아키텍처 (1편)"},{"content":"시작은 단순했다 클라우드 비용이 아깝고, NAS도 필요하고, 사이드 프로젝트 서버도 있으면 좋겠고. 마침 집에 안 쓰는 ThinkPad가 있었다.\n그렇게 시작된 홈서버 구축기가 어느새 12편이 됐다.\n최종 구성 기기: ThinkPad E15 Gen3 (Ryzen 5 5600U, RAM 16GB) OS: Ubuntu Server 24.04 LTS 네트워크: Tailscale VPN + OCI Nginx 리버스 프록시 도메인: yourdomain.com (Cloudflare) 스토리지: 256GB SSD (OS/Docker) + 1TB SSD (/mnt/data, NTFS) 운영 중인 서비스 전체 목록 포트 서비스 접근 방식 2283 Immich (사진 관리) 도메인 (OCI 프록시) 9090 Filebrowser (파일 관리) 도메인 (OCI 프록시) 11000 Vaultwarden (비밀번호) 도메인 (OCI 프록시) 13000 Grafana (모니터링) Tailscale VPN 19000 Portainer (Docker GUI) Tailscale VPN 19090 Prometheus Tailscale VPN 19100 Node Exporter 내부 수집용 23000 컨테이너 대시보드 (React) Tailscale VPN 28080 컨테이너 대시보드 (Spring) Tailscale VPN 12편 한눈에 보기 1편 — 왜 홈서버인가? + 전체 아키텍처 → 보러가기\n클라우드 대신 홈서버를 선택한 이유와 Tailscale + OCI + Cloudflare로 구성한 전체 아키텍처를 소개한다.\n2편 — Ubuntu Server 설치 + Tailscale VPN → 보러가기\nVentoy로 Ubuntu Server 24.04를 설치하고, Tailscale VPN으로 홈서버와 OCI 인스턴스를 하나의 사설 네트워크로 묶었다.\n3편 — 외장 SSD 마운트 + Filebrowser 원격 파일 관리 → 보러가기\nNTFS 외장 SSD를 마운트하고 ntfsfix로 읽기/쓰기 문제를 해결했다. Docker로 Filebrowser를 띄워서 브라우저에서 파일을 관리할 수 있게 됐다.\n4편 — Immich로 구글 포토 대체하기 → 보러가기\nDocker Compose 설치 과정에서 구버전 docker, NTFS 권한 문제 등 4가지 트러블슈팅을 겪었다. DB는 반드시 ext4에 둬야 한다.\n5편 — Vaultwarden으로 비밀번호 자체 호스팅 → 보러가기\n구글 비밀번호를 CSV로 내보내서 Vaultwarden으로 이전했다. Bitwarden 브라우저 확장과 연동하면 기존과 사용감이 동일하다.\n6편 — Portainer CE로 Docker GUI 관리 → 보러가기\n컨테이너가 8개를 넘어가면서 CLI 관리가 한계에 달했다. Portainer CE로 웹 UI에서 컨테이너를 관리한다. 보안상 VPN 안에서만 접근한다.\n7편 — Grafana + Prometheus로 홈서버 모니터링 → 보러가기\nNode Exporter를 홈서버 + OCI 두 곳에 설치하고, Prometheus가 Tailscale VPN을 통해 두 서버의 메트릭을 수집한다. ThinkPad 배터리 메트릭도 추가했다.\n8편 — Fail2ban으로 SSH 브루트포스 차단 → 보러가기\nauth.log를 열어보니 수천 줄의 SSH 로그인 시도가 쌓여 있었다. Fail2ban으로 자동 차단하고, recidive jail로 반복 공격자는 7일 장기 차단한다.\n9편 — certbot \u0026ndash;expand로 SSL 서브도메인 추가 → 보러가기\n서비스가 늘어날 때마다 SSL 인증서에 서브도메인을 추가해야 한다. --expand 옵션과 셸 스크립트로 관리하면 -d 한 줄만 추가하면 된다.\n10편 — PostgreSQL 자동 백업 (pg_dump + cron) → 보러가기\nImmich DB가 날아가면 앨범, 태그, 얼굴 인식 데이터가 전부 사라진다. docker exec으로 pg_dumpall을 실행하고 cron으로 매일 새벽 3시에 자동 백업한다.\n11편 — TLP + thinkfan + Swap 튜닝으로 운영 최적화 → 보러가기\nCPU 온도 85°C → 55°C, Swap 사용률 26% → 7%. TLP 터보 부스트 비활성화와 swappiness 튜닝으로 24시간 운영에 최적화했다.\n12편 — 직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE) → 보러가기\nPortainer 대신 Spring Boot + React로 컨테이너 모니터링 대시보드를 직접 만들었다. SSE로 실시간 로그 스트리밍을 구현한 게 핵심이다.\n돌아보며 파일 서버 하나 만들려고 시작했는데 어쩌다 보니 이렇게 됐다.\n홈서버의 가장 큰 장점은 공부할 게 계속 생긴다는 거다. 서비스 하나 올리면 모니터링이 필요하고, 모니터링 하다 보면 튜닝하고 싶어지고, 외부에 열면 보안이 신경 쓰이고. 그 과정에서 Linux, Docker, Nginx, Spring Boot, React를 실제로 써보게 된다.\n클라우드가 편하긴 하지만, 직접 서버를 굴리는 재미는 또 다르다.\n","permalink":"https://chanyeols.com/posts/part-00-summary/","summary":"\u003ch2 id=\"시작은-단순했다\"\u003e시작은 단순했다\u003c/h2\u003e\n\u003cp\u003e클라우드 비용이 아깝고, NAS도 필요하고, 사이드 프로젝트 서버도 있으면 좋겠고. 마침 집에 안 쓰는 ThinkPad가 있었다.\u003c/p\u003e\n\u003cp\u003e그렇게 시작된 홈서버 구축기가 어느새 12편이 됐다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"최종-구성\"\u003e최종 구성\u003c/h2\u003e\n\u003cp\u003e\u003cimg alt=\"전체 아키텍처 구성도\" loading=\"lazy\" src=\"/images/homeserver-01-architecture.png\"\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e기기\u003c/strong\u003e: ThinkPad E15 Gen3 (Ryzen 5 5600U, RAM 16GB)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eOS\u003c/strong\u003e: Ubuntu Server 24.04 LTS\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e네트워크\u003c/strong\u003e: Tailscale VPN + OCI Nginx 리버스 프록시\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e도메인\u003c/strong\u003e: yourdomain.com (Cloudflare)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e스토리지\u003c/strong\u003e: 256GB SSD (OS/Docker) + 1TB SSD (/mnt/data, NTFS)\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"운영-중인-서비스-전체-목록\"\u003e운영 중인 서비스 전체 목록\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e포트\u003c/th\u003e\n          \u003cth\u003e서비스\u003c/th\u003e\n          \u003cth\u003e접근 방식\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e2283\u003c/td\u003e\n          \u003ctd\u003eImmich (사진 관리)\u003c/td\u003e\n          \u003ctd\u003e도메인 (OCI 프록시)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e9090\u003c/td\u003e\n          \u003ctd\u003eFilebrowser (파일 관리)\u003c/td\u003e\n          \u003ctd\u003e도메인 (OCI 프록시)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e11000\u003c/td\u003e\n          \u003ctd\u003eVaultwarden (비밀번호)\u003c/td\u003e\n          \u003ctd\u003e도메인 (OCI 프록시)\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e13000\u003c/td\u003e\n          \u003ctd\u003eGrafana (모니터링)\u003c/td\u003e\n          \u003ctd\u003eTailscale VPN\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e19000\u003c/td\u003e\n          \u003ctd\u003ePortainer (Docker GUI)\u003c/td\u003e\n          \u003ctd\u003eTailscale VPN\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e19090\u003c/td\u003e\n          \u003ctd\u003ePrometheus\u003c/td\u003e\n          \u003ctd\u003eTailscale VPN\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e19100\u003c/td\u003e\n          \u003ctd\u003eNode Exporter\u003c/td\u003e\n          \u003ctd\u003e내부 수집용\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e23000\u003c/td\u003e\n          \u003ctd\u003e컨테이너 대시보드 (React)\u003c/td\u003e\n          \u003ctd\u003eTailscale VPN\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e28080\u003c/td\u003e\n          \u003ctd\u003e컨테이너 대시보드 (Spring)\u003c/td\u003e\n          \u003ctd\u003eTailscale VPN\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"12편-한눈에-보기\"\u003e12편 한눈에 보기\u003c/h2\u003e\n\u003ch3 id=\"1편--왜-홈서버인가--전체-아키텍처\"\u003e1편 — 왜 홈서버인가? + 전체 아키텍처\u003c/h3\u003e\n\u003cp\u003e\u003ca href=\"/posts/part-01-intro\"\u003e→ 보러가기\u003c/a\u003e\u003c/p\u003e","title":"노트북 한 대로 홈서버 구축하기 - 12편 완전 정복 총정리"},{"content":"왜 직접 만들었나 Portainer가 있는데 굳이 만든 이유는 단순하다. 그냥 만들어보고 싶었다.\nPortainer는 기능이 너무 많다. 나는 컨테이너 목록 확인, 시작/중지/재시작, 로그 보기 딱 세 가지만 필요했다. 이 정도면 직접 만들 수 있겠다 싶어서 Spring Boot 백엔드 + React 프론트엔드로 구성했다.\n완성 화면 컨테이너 목록을 카드 UI로 표시 (상태별 색상 배지) Start / Stop / Restart 원클릭 제어 Logs 버튼으로 실시간 로그 스트리밍 (SSE) 5초 주기 자동 갱신 기술 스택 역할 기술 백엔드 Spring Boot 3.5, Java 17, Maven Docker 연동 docker-java 3.3.6 (zerodep transport) 실시간 로그 SSE (Server-Sent Events) + WebFlux 프론트엔드 React 19, plain CSS 배포 Docker Compose + nginx 백엔드 (Spring Boot) 의존성 설정 \u0026lt;!-- Docker Java --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.github.docker-java\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;docker-java\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.3.6\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.github.docker-java\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;docker-java-transport-zerodep\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.3.6\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; transport 선택이 중요하다. httpclient5 트랜스포트는 Unix 소켓을 제대로 처리하지 못해서 아래 에러가 난다.\nConnect to unix://localhost:2375 failed zerodep 트랜스포트를 써야 /var/run/docker.sock 연결이 정상 동작한다.\nDockerClient 빈 설정 @Configuration public class DockerConfig { @Value(\u0026#34;${docker.host:unix:///var/run/docker.sock}\u0026#34;) private String dockerHost; @Bean public DockerClient dockerClient() { DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder() .withDockerHost(dockerHost) .build(); DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder() .dockerHost(config.getDockerHost()) .sslConfig(config.getSSLConfig()) .build(); return DockerClientImpl.getInstance(config, httpClient); } } application.properties:\ndocker.host=unix:///var/run/docker.sock 컨테이너 목록 조회 public List\u0026lt;ContainerSummaryDto\u0026gt; listContainers(boolean all) { return dockerClient.listContainersCmd() .withShowAll(all) .exec() .stream() .map(this::toDto) .toList(); } 실시간 로그 스트리밍 (SSE) 이 프로젝트의 핵심이다. docker-java의 콜백 기반 API를 WebFlux의 Flux로 브리징한다.\npublic Flux\u0026lt;String\u0026gt; streamLogs(String containerId, int tail) { return Flux.create(sink -\u0026gt; { dockerClient.logContainerCmd(containerId) .withStdOut(true) .withStdErr(true) .withFollowStream(true) .withTail(tail) .withTimestamps(true) .exec(new ResultCallback.Adapter\u0026lt;\u0026gt;() { @Override public void onNext(Frame frame) { sink.next(new String(frame.getPayload()).stripTrailing()); } @Override public void onError(Throwable throwable) { sink.error(throwable); } @Override public void onComplete() { sink.complete(); } }); }); } 컨트롤러에서 SSE로 내보낸다.\n@GetMapping(value = \u0026#34;/{id}/logs\u0026#34;, produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux\u0026lt;ServerSentEvent\u0026lt;String\u0026gt;\u0026gt; streamLogs(@PathVariable String id, @RequestParam(defaultValue = \u0026#34;100\u0026#34;) int tail) { return containerService.streamLogs(id, tail) .map(line -\u0026gt; ServerSentEvent.\u0026lt;String\u0026gt;builder().data(line).build()); } API 목록 Method Endpoint 설명 GET /api/containers?all=true 컨테이너 목록 GET /api/containers/{id}/logs?tail=100 SSE 로그 스트리밍 POST /api/containers/{id}/start 시작 POST /api/containers/{id}/stop 정지 POST /api/containers/{id}/restart 재시작 프론트엔드 (React) 프로젝트 구조 src/ api.js # API 호출 함수 App.js # 루트 컴포넌트 (목록 + 폴링) App.css # 전체 스타일 (다크 테마) components/ ContainerCard.js # 카드 UI + 액션 버튼 LogViewer.js # SSE 실시간 로그 모달 5초 폴링 const POLL_INTERVAL = 5000; export default function App() { const [containers, setContainers] = useState([]); const [logTarget, setLogTarget] = useState(null); const load = useCallback(async () =\u0026gt; { const data = await fetchContainers(); setContainers(data); }, []); useEffect(() =\u0026gt; { load(); const timer = setInterval(load, POLL_INTERVAL); return () =\u0026gt; clearInterval(timer); }, [load]); // ... } setInterval을 useEffect cleanup에서 clearInterval로 정리해야 컴포넌트 언마운트 시 폴링이 멈춘다.\n상태별 색상 배지 const STATE_META = { running: { label: \u0026#39;Running\u0026#39;, color: \u0026#39;#22c55e\u0026#39; }, exited: { label: \u0026#39;Exited\u0026#39;, color: \u0026#39;#ef4444\u0026#39; }, paused: { label: \u0026#39;Paused\u0026#39;, color: \u0026#39;#f59e0b\u0026#39; }, created: { label: \u0026#39;Created\u0026#39;, color: \u0026#39;#6b7280\u0026#39; }, dead: { label: \u0026#39;Dead\u0026#39;, color: \u0026#39;#991b1b\u0026#39; }, }; SSE 실시간 로그 브라우저 내장 EventSource API를 사용한다. WebSocket보다 단방향 스트리밍에 적합하고 서버 구현도 단순하다.\nuseEffect(() =\u0026gt; { const es = new EventSource(getLogUrl(containerId)); es.onopen = () =\u0026gt; setConnected(true); es.onmessage = (e) =\u0026gt; { setLines((prev) =\u0026gt; { const next = [...prev, e.data]; // 메모리 관리: 최대 2000줄 유지 return next.length \u0026gt; 2000 ? next.slice(-2000) : next; }); }; es.onerror = () =\u0026gt; { setConnected(false); setError(\u0026#39;Connection lost.\u0026#39;); es.close(); }; return () =\u0026gt; es.close(); // 모달 닫으면 SSE 연결 종료 }, [containerId]); cleanup에서 es.close()를 빠뜨리면 모달을 닫아도 서버와 연결이 계속 유지된다. 꼭 넣어야 한다.\n배포 백엔드 Dockerfile (멀티스테이지 빌드) FROM eclipse-temurin:17-jdk AS builder WORKDIR /app COPY .mvn/ .mvn/ COPY mvnw pom.xml ./ RUN ./mvnw dependency:go-offline -q COPY src/ src/ RUN ./mvnw package -DskipTests -q FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=builder /app/target/*.jar app.jar ENTRYPOINT [\u0026#34;java\u0026#34;, \u0026#34;-jar\u0026#34;, \u0026#34;app.jar\u0026#34;] 프론트엔드 Dockerfile (멀티스테이지 빌드) FROM node:20-alpine AS build WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci --silent COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/build /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD [\u0026#34;nginx\u0026#34;, \u0026#34;-g\u0026#34;, \u0026#34;daemon off;\u0026#34;] nginx.conf — SSE 버퍼링 비활성화가 핵심 server { listen 80; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://100.109.108.36:28080; proxy_http_version 1.1; proxy_set_header Connection \u0026#39;\u0026#39;; proxy_buffering off; # SSE 필수 설정 proxy_cache off; chunked_transfer_encoding on; } location / { try_files $uri $uri/ /index.html; # SPA 라우팅 } } proxy_buffering off가 없으면 nginx가 SSE 응답을 버퍼에 쌓았다가 한꺼번에 보내서 실시간성이 깨진다. 삽질 포인트다.\n배포 플로우 서버에서 직접 빌드하면 node_modules 설치에 시간이 오래 걸린다. 로컬에서 이미지를 만들고 tar로 전송하는 방식을 선택했다.\n# 로컬에서 이미지 빌드 docker build -t dashboard-front ./frontend docker build -t dashboard-back ./backend # tar로 저장 docker save dashboard-front | gzip \u0026gt; dashboard-front.tar.gz docker save dashboard-back | gzip \u0026gt; dashboard-back.tar.gz # 서버로 전송 scp dashboard-front.tar.gz dashboard-back.tar.gz user@홈서버IP:~/dashboard/ # 서버에서 로드 \u0026amp; 실행 ssh user@홈서버IP cd ~/dashboard docker load \u0026lt; dashboard-front.tar.gz docker load \u0026lt; dashboard-back.tar.gz docker compose up -d docker-compose.yml services: dashboard-back: image: dashboard-back container_name: dashboard-back ports: - \u0026#34;28080:8080\u0026#34; volumes: - /var/run/docker.sock:/var/run/docker.sock restart: unless-stopped dashboard-front: image: dashboard-front container_name: dashboard-front ports: - \u0026#34;23000:80\u0026#34; restart: unless-stopped 백엔드 컨테이너에도 /var/run/docker.sock을 마운트해서 호스트의 Docker 데몬에 접근한다.\n구현하면서 신경 쓴 것들 SSE cleanup: useEffect return에서 es.close() 호출 필수. 안 하면 모달 닫아도 연결 유지 메모리 관리: 로그 줄 수를 2000줄로 제한해 장시간 열어둬도 브라우저가 버벅이지 않게 처리 nginx SSE 설정: proxy_buffering off 없으면 로그가 실시간으로 안 온다 zerodep transport: docker-java에서 Unix 소켓 연결 시 반드시 zerodep 사용 Stop 버튼을 누르면 실제로 컨테이너가 정지되는 걸 Portainer에서 교차 확인했다.\n마무리 12편에 걸쳐 ThinkPad 노트북 한 대로 홈서버를 구축한 과정을 정리했다.\n처음엔 파일 서버 하나 만들려고 시작했는데 어쩌다 보니 사진 관리, 비밀번호 관리, 모니터링, 보안, 자동 백업에 커스텀 대시보드까지 만들어버렸다. 홈서버는 공부할 게 계속 생긴다는 게 가장 큰 장점이자 단점이다.\n시리즈 구성 이 구축기는 총 12편으로 구성된다.\n왜 홈서버인가? + 전체 아키텍처 Ubuntu Server 설치 + Tailscale VPN 외장 SSD 마운트 + Filebrowser 원격 파일 관리 Immich로 구글 포토 대체하기 Vaultwarden으로 비밀번호 자체 호스팅 Portainer CE로 Docker GUI 관리 Grafana + Prometheus로 홈서버 모니터링 Fail2ban으로 SSH 브루트포스 차단 certbot \u0026ndash;expand로 SSL 서브도메인 추가 PostgreSQL 자동 백업 (pg_dump + cron) TLP + thinkfan + Swap 튜닝으로 운영 최적화 직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE) ← 지금 여기 ","permalink":"https://chanyeols.com/posts/part-12-dashboard/","summary":"\u003ch2 id=\"왜-직접-만들었나\"\u003e왜 직접 만들었나\u003c/h2\u003e\n\u003cp\u003ePortainer가 있는데 굳이 만든 이유는 단순하다. 그냥 만들어보고 싶었다.\u003c/p\u003e\n\u003cp\u003ePortainer는 기능이 너무 많다. 나는 컨테이너 목록 확인, 시작/중지/재시작, 로그 보기 딱 세 가지만 필요했다. 이 정도면 직접 만들 수 있겠다 싶어서 Spring Boot 백엔드 + React 프론트엔드로 구성했다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"완성-화면\"\u003e완성 화면\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e컨테이너 목록을 카드 UI로 표시 (상태별 색상 배지)\u003c/li\u003e\n\u003cli\u003eStart / Stop / Restart 원클릭 제어\u003c/li\u003e\n\u003cli\u003eLogs 버튼으로 실시간 로그 스트리밍 (SSE)\u003c/li\u003e\n\u003cli\u003e5초 주기 자동 갱신\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cimg alt=\"대시보드 메인 UI - 컨테이너 카드 목록\" loading=\"lazy\" src=\"/images/homeserver-12-dashboard-ui.png\"\u003e\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - 직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE) (12편)"},{"content":"두 가지 문제 홈서버를 며칠 돌려보니 두 가지가 눈에 띄었다.\nImmich 썸네일 생성 같은 작업이 걸리면 CPU 온도가 85°C까지 치솟는다. 24시간 켜두는 서버라 장기적으로 하드웨어에 좋지 않다. Grafana 대시보드를 보니 RAM 사용률이 38%인데 Swap을 26%나 사용하고 있었다. RAM이 절반도 안 찼는데 Swap을 쓰는 건 비정상이다. 두 문제를 각각 TLP + thinkfan, swappiness 튜닝으로 해결했다.\n1부: TLP + thinkfan으로 온도 낮추기 TLP 설치 TLP는 Linux용 전력 관리 도구다. 설치만 해도 기본값으로 어느 정도 효과가 있고, ThinkPad에 맞게 튜닝하면 훨씬 효과적이다.\nsudo apt install tlp tlp-rdw -y sudo tlp start 설치 확인:\nsudo tlp-stat -s --- TLP 1.6.1 -------------------------------------------- +++ TLP Status State = enabled Mode = AC Power source = AC TLP 튜닝 sudo vi /etc/tlp.conf 아래 항목을 찾아서 주석 해제 후 값을 수정한다.\nCPU_SCALING_GOVERNOR_ON_AC=powersave CPU_ENERGY_PERF_POLICY_ON_AC=balance_power CPU_BOOST_ON_AC=0 PLATFORM_PROFILE_ON_AC=balanced 핵심은 CPU_BOOST_ON_AC=0이다. 터보 부스트를 끄는 것만으로도 온도가 10~20°C 내려간다. 홈서버 용도에서는 최대 성능이 필요한 순간이 거의 없기 때문에 체감 성능 저하도 없다.\nsudo tlp start thinkfan 설치 thinkfan은 온도 구간별로 팬 속도를 직접 제어하는 도구다. ThinkPad 기본 팬 제어보다 세밀하게 조절할 수 있다.\nsudo apt install thinkfan -y thinkpad_acpi 팬 제어 권한 활성화:\necho \u0026#34;options thinkpad_acpi fan_control=1\u0026#34; | sudo tee /etc/modprobe.d/thinkpad_acpi.conf sudo modprobe -r thinkpad_acpi sudo modprobe thinkpad_acpi fan_control=1 센서 경로 확인:\nfind /sys/devices -name \u0026#34;temp*_input\u0026#34; 2\u0026gt;/dev/null ThinkPad CPU 온도 센서는 보통 아래 경로에 있다.\n/sys/devices/platform/thinkpad_hwmon/hwmon/hwmon4/temp1_input hwmon 뒤의 숫자는 환경마다 다를 수 있으니 직접 확인하자.\nthinkfan 설정 sudo vi /etc/thinkfan.conf sensors: - hwmon: /sys/devices/platform/thinkpad_hwmon/hwmon/hwmon4/temp1_input fans: - tpacpi: /proc/acpi/ibm/fan levels: - [0, 0, 55] - [1, 50, 60] - [2, 55, 65] - [3, 60, 70] - [4, 65, 75] - [5, 70, 80] - [7, 75, 255] 레벨 온도 구간 동작 0 ~55°C 팬 정지 1 50~60°C 최저속 2 55~65°C 저속 3 60~70°C 중저속 4 65~75°C 중속 5 70~80°C 고속 7 75°C~ 최대속 서비스 시작:\nsudo systemctl enable thinkfan sudo systemctl start thinkfan sudo systemctl status thinkfan 온도 개선 결과 적용 전 적용 후 CPU 온도 (풀로드) 85°C 55~66°C 팬 RPM 3600 RPM 1800 RPM GPU 온도 79°C 48~59°C TLP 터보 부스트 비활성화만으로도 약 20~30°C 온도가 내려갔다. 24시간 상시 운영 환경에서는 성능보다 안정성이 중요하기 때문에 이 설정이 최적이다.\n부팅 시 자동 적용 확인 sudo systemctl is-enabled tlp sudo systemctl is-enabled thinkfan # 둘 다 \u0026#34;enabled\u0026#34; 출력되면 정상 2부: swappiness 튜닝 문제 발견 Grafana 대시보드를 보다가 이상한 걸 발견했다.\nRAM 사용률은 37.8%인데 Swap을 26%나 쓰고 있었다. Swap은 SSD를 메모리처럼 쓰는 거라 RAM보다 훨씬 느리다. 불필요하게 Swap을 쓰면 전체 성능이 저하된다.\nswappiness란? vm.swappiness는 커널이 얼마나 적극적으로 Swap을 사용할지 결정하는 값이다.\n값 설명 적합한 환경 0 Swap 거의 사용 안 함 RAM이 매우 넉넉한 서버 10 RAM 거의 다 찰 때만 Swap 사용 홈서버, 일반 서버 60 Ubuntu 기본값 데스크탑 100 적극적으로 Swap 사용 RAM이 매우 부족한 환경 Ubuntu 기본값이 60인데, 홈서버처럼 RAM이 충분한 환경에서는 너무 높다.\n튜닝 적용 즉시 적용:\nsudo sysctl vm.swappiness=10 재부팅 후에도 유지:\necho \u0026#34;vm.swappiness=10\u0026#34; | sudo tee -a /etc/sysctl.conf 적용 확인:\ncat /proc/sys/vm/swappiness # 10 기존 Swap에 올라간 데이터를 RAM으로 옮겨서 효과를 바로 확인한다.\nsudo swapoff -a \u0026amp;\u0026amp; sudo swapon -a 튜닝 결과 기존 26%가량 사용하던 Swap이 7%대로 떨어진 것을 확인할 수 있다.\n정리 온도 관리:\nTLP CPU_BOOST_ON_AC=0 하나로 온도 20~30°C 감소 thinkfan으로 팬 커브를 직접 제어해서 불필요한 소음 줄임 홈서버는 성능보다 안정성이 중요하므로 터보 부스트 끄는 게 최선 Swap 튜닝:\nUbuntu 기본값 swappiness=60은 데스크탑 기준, 서버는 10이 적합 swapoff -a \u0026amp;\u0026amp; swapon -a로 기존 Swap 데이터를 RAM으로 즉시 이동 가능 한 줄 설정으로 전반적인 시스템 응답성 개선 다음 편에서는 Portainer 대신 Spring Boot + React로 직접 만든 컨테이너 대시보드를 다룬다. SSE를 이용한 실시간 로그 스트리밍이 핵심이다.\n","permalink":"https://chanyeols.com/posts/part-11-tuning/","summary":"\u003ch2 id=\"두-가지-문제\"\u003e두 가지 문제\u003c/h2\u003e\n\u003cp\u003e홈서버를 며칠 돌려보니 두 가지가 눈에 띄었다.\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eImmich 썸네일 생성 같은 작업이 걸리면 \u003cstrong\u003eCPU 온도가 85°C까지 치솟는다\u003c/strong\u003e. 24시간 켜두는 서버라 장기적으로 하드웨어에 좋지 않다.\u003c/li\u003e\n\u003cli\u003eGrafana 대시보드를 보니 \u003cstrong\u003eRAM 사용률이 38%인데 Swap을 26%나 사용\u003c/strong\u003e하고 있었다. RAM이 절반도 안 찼는데 Swap을 쓰는 건 비정상이다.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e두 문제를 각각 TLP + thinkfan, swappiness 튜닝으로 해결했다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1부-tlp--thinkfan으로-온도-낮추기\"\u003e1부: TLP + thinkfan으로 온도 낮추기\u003c/h2\u003e\n\u003ch3 id=\"tlp-설치\"\u003eTLP 설치\u003c/h3\u003e\n\u003cp\u003eTLP는 Linux용 전력 관리 도구다. 설치만 해도 기본값으로 어느 정도 효과가 있고, ThinkPad에 맞게 튜닝하면 훨씬 효과적이다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - TLP + thinkfan + Swap 튜닝으로 운영 최적화 (11편)"},{"content":"왜 백업이 필요한가 Immich에 사진을 올리기 시작하면서 DB가 날아가면 복구할 방법이 없다는 게 갑자기 걱정됐다. 사진 원본은 /mnt/data에 있으니 파일은 살아있더라도 Immich DB가 날아가면 앨범, 태그, 얼굴 인식 데이터가 전부 사라진다.\n백업 전략은 단순하게 잡았다.\n대상 방법 위치 Immich DB pg_dumpall /backup/immich_db_YYYYMMDD.sql 사진 원본 추후 rsync 추가 예정 외장하드 구매 후 OS SSD에 204GB 여유가 있어서 DB 덤프는 우선 거기에 보관한다.\n백업 디렉토리 생성 sudo mkdir -p /backup sudo chown your-username:your-username /backup chown으로 소유권을 넘겨줘야 한다. 처음에 sudo mkdir만 하고 스크립트를 실행했더니 아래 에러가 났다.\ncannot create /backup/immich_db_20260329.sql: Permission denied 백업 스크립트 작성 mkdir ~/backup_script vi ~/backup_script/postgres_immich_backup.sh #!/bin/bash BACKUP_DIR=\u0026#34;/backup\u0026#34; DATE=$(date +%Y%m%d) mkdir -p $BACKUP_DIR # DB 덤프 백업 echo \u0026#34;pg_dump 시작...\u0026#34; docker exec immich_postgres pg_dumpall -U postgres \u0026gt; $BACKUP_DIR/immich_db_$DATE.sql # 7일치만 보관 (오래된 것 삭제) find $BACKUP_DIR -name \u0026#34;immich_db_*.sql\u0026#34; -mtime +7 -delete echo \u0026#34;백업 완료: $DATE\u0026#34; docker exec으로 immich_postgres 컨테이너 안에서 pg_dumpall을 실행해서 결과를 파일로 저장한다. 컨테이너가 실행 중이면 별도 설치 없이 바로 쓸 수 있다.\nfind ... -mtime +7 -delete 로 7일이 지난 백업 파일은 자동으로 삭제한다. 무한정 쌓이면 디스크가 금방 찬다.\n실행 권한 부여:\nchmod +x ~/backup_script/postgres_immich_backup.sh 테스트 실행 sh ~/backup_script/postgres_immich_backup.sh pg_dump 시작... 백업 완료: 20260329 백업 파일 확인:\nls -lh /backup/ cron 등록 매일 새벽 3시에 자동 실행되도록 cron에 등록한다.\ncrontab -e 아래 내용 추가:\n0 3 * * * /home/your-username/backup_script/postgres_immich_backup.sh \u0026gt;\u0026gt; /backup/backup.log 2\u0026gt;\u0026amp;1 등록 확인:\ncrontab -l 로그는 /backup/backup.log에 쌓이니 문제가 생기면 여기서 확인하면 된다.\n7일 보관 정책 find $BACKUP_DIR -name \u0026#34;immich_db_*.sql\u0026#34; -mtime +7 -delete 스크립트 안에 이 한 줄이 있어서 7일이 지난 백업 파일은 매일 자동으로 정리된다. 7일치 보관 시 필요한 용량은 DB 크기에 따라 다른데, Immich DB는 사진 수가 늘어도 원본 파일은 /mnt/data에 따로 있으니 DB 자체는 수백 MB 수준이다.\n추후 개선 계획 외장하드를 구매하면 rsync로 사진 원본도 자동 백업할 예정이다.\n# 라이브러리 rsync (외장하드 마운트 후 추가) rsync -av --delete /mnt/data/immich/photos/ /mnt/backup/immich/photos/ 정리 docker exec으로 컨테이너 안의 pg_dumpall을 실행하면 별도 PostgreSQL 설치 없이 백업 가능 /backup 디렉토리는 chown으로 소유권 설정 필수 find -mtime +7 -delete로 오래된 백업 자동 정리 cron 로그를 파일로 남겨두면 백업 실패 여부를 나중에 확인할 수 있다 다음 편에서는 TLP + thinkfan으로 CPU 온도를 85°C에서 55°C로 낮추고, swappiness 튜닝으로 불필요한 Swap 사용을 줄인 과정을 다룬다.\n","permalink":"https://chanyeols.com/posts/part-10-postgresql-backup/","summary":"\u003ch2 id=\"왜-백업이-필요한가\"\u003e왜 백업이 필요한가\u003c/h2\u003e\n\u003cp\u003eImmich에 사진을 올리기 시작하면서 DB가 날아가면 복구할 방법이 없다는 게 갑자기 걱정됐다. 사진 원본은 \u003ccode\u003e/mnt/data\u003c/code\u003e에 있으니 파일은 살아있더라도 Immich DB가 날아가면 앨범, 태그, 얼굴 인식 데이터가 전부 사라진다.\u003c/p\u003e\n\u003cp\u003e백업 전략은 단순하게 잡았다.\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e대상\u003c/th\u003e\n          \u003cth\u003e방법\u003c/th\u003e\n          \u003cth\u003e위치\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eImmich DB\u003c/td\u003e\n          \u003ctd\u003epg_dumpall\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003e/backup/immich_db_YYYYMMDD.sql\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e사진 원본\u003c/td\u003e\n          \u003ctd\u003e추후 rsync 추가 예정\u003c/td\u003e\n          \u003ctd\u003e외장하드 구매 후\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eOS SSD에 204GB 여유가 있어서 DB 덤프는 우선 거기에 보관한다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"백업-디렉토리-생성\"\u003e백업 디렉토리 생성\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo mkdir -p /backup\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo chown your-username:your-username /backup\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003echown\u003c/code\u003e으로 소유권을 넘겨줘야 한다. 처음에 \u003ccode\u003esudo mkdir\u003c/code\u003e만 하고 스크립트를 실행했더니 아래 에러가 났다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - PostgreSQL 자동 백업 (pg_dump + cron) (10편)"},{"content":"문제 상황 처음 SSL 인증서를 발급할 때 메인 도메인만 포함해서 발급했다.\ncertbot certonly --nginx -d yourdomain.com 이후 서비스가 하나씩 늘어나면서 서브도메인이 추가됐는데, 브라우저에서 photo.yourdomain.com에 접속하면 아래 에러가 발생했다.\nNET::ERR_CERT_COMMON_NAME_INVALID 연결이 비공개로 설정되어 있지 않습니다. 인증서에 photo.yourdomain.com이 포함돼 있지 않아서 생기는 문제였다.\n해결: \u0026ndash;expand 옵션 기존 인증서에 서브도메인을 추가할 때는 --expand 플래그를 써야 한다.\n--expand 없이 서브도메인을 추가하려고 하면 아래 에러가 난다.\nMissing command line flag or config entry for this setting: You have an existing certificate that contains a portion of the domains you requested. It contains these names: yourdomain.com You requested these names for the new certificate: yourdomain.com, photo.yourdomain.com Do you want to expand and replace this existing certificate with the new certificate? (You can set this with the --expand flag) certbot이 친절하게 --expand 쓰라고 안내해주긴 한다.\n셸 스크립트로 관리 서브도메인이 추가될 때마다 명령어를 직접 치는 건 번거롭다. 스크립트로 관리하면 나중에 도메인 하나 추가할 때 -d 줄만 늘리면 된다.\n#!/bin/bash DOMAIN=\u0026#34;yourdomain.com\u0026#34; EMAIL=\u0026#34;your@email.com\u0026#34; SSL_DIR=\u0026#34;/ssl\u0026#34; certbot certonly --nginx \\ --non-interactive \\ --agree-tos \\ --expand \\ --email $EMAIL \\ -d $DOMAIN \\ -d photo.$DOMAIN \\ -d files.$DOMAIN \\ -d vault.$DOMAIN \\ --config-dir $SSL_DIR \\ --work-dir $SSL_DIR/work \\ --logs-dir $SSL_DIR/logs if [ $? -ne 0 ]; then echo \u0026#34;인증서 발급 실패\u0026#34; exit 1 fi echo \u0026#34;인증서 발급 성공\u0026#34; nginx -t \u0026amp;\u0026amp; systemctl restart nginx 옵션 설명 --expand 기존 인증서에 새 도메인 추가 --non-interactive 사용자 입력 없이 자동 실행 --agree-tos 서비스 약관 자동 동의 -d 인증서에 포함할 도메인 (여러 개 가능) 새 서브도메인이 생길 때마다 -d newservice.$DOMAIN \\ 한 줄만 추가하고 스크립트를 실행하면 된다.\n인증서 확인 발급 후 포함된 도메인 목록 확인:\ncertbot certificates --config-dir /ssl 인증서에 서브도메인이 모두 포함된 것을 확인할 수 있다. 브라우저에서 접속하면 자물쇠 아이콘을 클릭해서 인증서에 포함된 도메인 목록을 직접 확인할 수 있다.\n자동 갱신 설정 Let\u0026rsquo;s Encrypt 인증서는 90일마다 갱신해야 한다. cron으로 자동화해뒀다.\ncrontab -e 0 3 1 */2 * certbot renew --config-dir /ssl --work-dir /ssl/work --logs-dir /ssl/logs \u0026amp;\u0026amp; nginx -s reload 60일마다 새벽 3시에 갱신 시도한다. 갱신 후 Nginx를 리로드해서 새 인증서를 바로 적용한다.\n정리 기존 인증서에 서브도메인 추가할 때는 반드시 --expand 플래그 사용 서브도메인 관리를 스크립트로 해두면 나중에 도메인 추가가 편하다 cron으로 자동 갱신 설정해두면 인증서 만료 걱정이 없다 다음 편에서는 Immich PostgreSQL DB를 매일 새벽 자동으로 백업하는 스크립트를 만든다.\n","permalink":"https://chanyeols.com/posts/part-09-ssl/","summary":"\u003ch2 id=\"문제-상황\"\u003e문제 상황\u003c/h2\u003e\n\u003cp\u003e처음 SSL 인증서를 발급할 때 메인 도메인만 포함해서 발급했다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecertbot certonly --nginx -d yourdomain.com\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e이후 서비스가 하나씩 늘어나면서 서브도메인이 추가됐는데, 브라우저에서 \u003ccode\u003ephoto.yourdomain.com\u003c/code\u003e에 접속하면 아래 에러가 발생했다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-plain\" data-lang=\"plain\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eNET::ERR_CERT_COMMON_NAME_INVALID\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e연결이 비공개로 설정되어 있지 않습니다.\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e인증서에 \u003ccode\u003ephoto.yourdomain.com\u003c/code\u003e이 포함돼 있지 않아서 생기는 문제였다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"해결-expand-옵션\"\u003e해결: \u0026ndash;expand 옵션\u003c/h2\u003e\n\u003cp\u003e기존 인증서에 서브도메인을 추가할 때는 \u003ccode\u003e--expand\u003c/code\u003e 플래그를 써야 한다.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e--expand\u003c/code\u003e 없이 서브도메인을 추가하려고 하면 아래 에러가 난다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-plain\" data-lang=\"plain\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMissing command line flag or config entry for this setting:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eYou have an existing certificate that contains a portion of the domains you requested.\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eIt contains these names: yourdomain.com\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eYou requested these names for the new certificate: yourdomain.com, photo.yourdomain.com\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eDo you want to expand and replace this existing certificate with the new certificate?\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e(You can set this with the --expand flag)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003ecertbot이 친절하게 \u003ccode\u003e--expand\u003c/code\u003e 쓰라고 안내해주긴 한다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - certbot --expand로 SSL 서브도메인 추가하기 (9편)"},{"content":"얼마나 많이 들어오나 OCI 서버는 공인 IP가 직접 노출돼 있어서 설치 직후부터 SSH 로그인 시도가 들어온다. auth.log를 열어봤다가 깜짝 놀랐다.\nsudo grep \u0026#34;Failed password\u0026#34; /var/log/auth.log | tail -20 Invalid user admin, Invalid user guest, Invalid user root 같은 로그가 수분 간격으로 끊임없이 들어오고 있었다. 전 세계 봇들이 24시간 SSH 로그인을 시도하는 것이다. 방치하면 언젠가 뚫릴 수 있고, 서버 리소스도 낭비된다.\nFail2ban이란? 로그 파일을 모니터링하다가 일정 횟수 이상 로그인에 실패한 IP를 자동으로 방화벽에서 차단하는 도구다.\n설치만 해도 SSH 기본 보호 즉시 적용 iptables와 연동해서 IP 차단 일정 시간 후 자동으로 차단 해제 SSH 외에도 Nginx, Apache 등 다양한 서비스 보호 가능 설치 sudo apt install fail2ban -y sudo systemctl enable fail2ban sudo systemctl start fail2ban sudo systemctl status fail2ban 설치 직후부터 기본 SSH 보호가 바로 적용된다.\n설정 Fail2ban 기본 설정은 /etc/fail2ban/jail.conf에 있지만 이 파일은 직접 수정하지 않는다. 업데이트 시 덮어씌워질 수 있기 때문이다. 대신 jail.local을 만들어서 오버라이드한다.\nsudo vi /etc/fail2ban/jail.local [DEFAULT] # 차단에서 제외할 IP (내 Tailscale IP 등) ignoreip = 127.0.0.1/8 ::1 # 차단 지속 시간: 기본 10분 → 1시간 bantime = 3600 # 몇 초 동안 실패 횟수를 카운트할지 findtime = 600 # 몇 번 실패하면 차단할지 maxretry = 5 [sshd] enabled = true port = ssh logpath = %(sshd_log)s backend = %(sshd_backend)s 항목 기본값 변경값 설명 bantime 600초 3600초 차단 지속 시간 findtime 600초 600초 실패 횟수 카운트 기간 maxretry 5회 5회 차단 기준 실패 횟수 설정 적용:\nsudo systemctl restart fail2ban ⚠️ 설정 전에 반드시 자신의 IP를 ignoreip에 추가해둘 것. 실수로 자신의 IP가 차단되면 SSH 접속이 불가능해진다.\n반복 공격자 장기 차단 (recidive) 같은 IP가 여러 번 차단됐다 풀리면 또 시도하는 경우가 있다. recidive jail을 활성화하면 반복 공격자를 장기간 차단할 수 있다.\njail.local에 추가:\n[recidive] enabled = true logpath = /var/log/fail2ban.log banaction = %(banaction_allports)s bantime = 604800 ; 7일 findtime = 86400 ; 1일 maxretry = 3 ; 하루에 3번 차단되면 7일 차단 Nginx 보호 OCI에서 Nginx 리버스 프록시를 운영하고 있으니 HTTP 레벨 공격도 막을 수 있다.\n[nginx-http-auth] enabled = true port = http,https logpath = /var/log/nginx/error.log [nginx-botsearch] enabled = true port = http,https logpath = /var/log/nginx/access.log maxretry = 2 차단 현황 확인 # 전체 jail 상태 sudo fail2ban-client status # SSH jail 상세 sudo fail2ban-client status sshd Status for the jail: sshd |- Filter | |- Currently failed: 3 | |- Total failed: 1284 | `- File list: /var/log/auth.log `- Actions |- Currently banned: 12 |- Total banned: 87 `- Banned IP list: 185.156.73.233 139.19.117.130 ... 실시간 로그 확인:\nsudo tail -f /var/log/fail2ban.log 2026-03-29 22:48:33 INFO [sshd] Ban 193.46.255.86 2026-03-29 22:52:33 INFO [sshd] Ban 185.156.73.233 2026-03-29 23:01:15 INFO [sshd] Ban 139.19.117.130 IP 수동 차단/해제 # 수동 차단 sudo fail2ban-client set sshd banip 1.2.3.4 # 차단 해제 (내 IP 실수로 차단됐을 때) sudo fail2ban-client set sshd unbanip 1.2.3.4 차단 목록 확인 이후 시간이 조금 지나고 확인해보니, 정상적으로 잘 차단하고 있는 걸 확인할 수 있다. 설치 전후 비교 설치 전 설치 후 auth.log Failed password 수천 줄 차단된 IP는 시도 자체가 안 됨 서버 부하 SSH 시도로 인한 불필요한 부하 최소화 보안 브루트포스 공격에 노출 자동 차단 정리 클라우드 서버를 운영한다면 Fail2ban은 선택이 아니라 필수다. 설치 자체는 5분이면 되고, 기본 설정만으로도 대부분의 브루트포스 공격을 막을 수 있다. recidive jail까지 설정해두면 반복 공격자를 장기간 차단해서 더욱 안전하게 운영할 수 있다.\n다음 편에서는 서비스가 늘어나면서 기존 SSL 인증서에 서브도메인을 추가하는 방법을 다룬다.\n","permalink":"https://chanyeols.com/posts/part-08-fail2ban/","summary":"\u003ch2 id=\"얼마나-많이-들어오나\"\u003e얼마나 많이 들어오나\u003c/h2\u003e\n\u003cp\u003eOCI 서버는 공인 IP가 직접 노출돼 있어서 설치 직후부터 SSH 로그인 시도가 들어온다. auth.log를 열어봤다가 깜짝 놀랐다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo grep \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;Failed password\u0026#34;\u003c/span\u003e /var/log/auth.log | tail -20\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cimg alt=\"auth.log에 Invalid user 로그가 수천 줄 쌓인 화면\" loading=\"lazy\" src=\"/images/homeserver-08-authlog.png\"\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eInvalid user admin\u003c/code\u003e, \u003ccode\u003eInvalid user guest\u003c/code\u003e, \u003ccode\u003eInvalid user root\u003c/code\u003e 같은 로그가 수분 간격으로 끊임없이 들어오고 있었다. 전 세계 봇들이 24시간 SSH 로그인을 시도하는 것이다. 방치하면 언젠가 뚫릴 수 있고, 서버 리소스도 낭비된다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"fail2ban이란\"\u003eFail2ban이란?\u003c/h2\u003e\n\u003cp\u003e로그 파일을 모니터링하다가 일정 횟수 이상 로그인에 실패한 IP를 자동으로 방화벽에서 차단하는 도구다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - Fail2ban으로 SSH 브루트포스 공격 차단하기 (8편)"},{"content":"구성 개요 모니터링 스택은 세 가지로 구성된다.\nPrometheus — 메트릭 수집 및 저장 Grafana — 대시보드 시각화 Node Exporter — 서버 시스템 메트릭 노출 (CPU, RAM, 디스크, 네트워크 등) Prometheus와 Grafana는 홈서버에서 Docker로 실행하고, Node Exporter는 홈서버와 OCI 서버 양쪽에 systemd로 설치했다. 두 서버가 Tailscale VPN으로 연결돼 있으니 Prometheus가 VPN을 통해 OCI 메트릭도 수집할 수 있다.\n서비스 포트 Prometheus 19090 Grafana 13000 Node Exporter 19100 1. 디렉토리 생성 mkdir ~/monitoring \u0026amp;\u0026amp; cd ~/monitoring 2. docker-compose.yml 작성 services: prometheus: image: prom/prometheus:latest container_name: prometheus restart: unless-stopped ports: - \u0026#34;19090:9090\u0026#34; volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus_data:/prometheus command: - \u0026#39;--config.file=/etc/prometheus/prometheus.yml\u0026#39; - \u0026#39;--storage.tsdb.path=/prometheus\u0026#39; - \u0026#39;--storage.tsdb.retention.time=30d\u0026#39; grafana: image: grafana/grafana:latest container_name: grafana restart: unless-stopped ports: - \u0026#34;13000:3000\u0026#34; volumes: - grafana_data:/var/lib/grafana environment: - GF_SECURITY_ADMIN_PASSWORD=your_password_here - GF_SERVER_ROOT_URL=http://100.109.108.36:13000 volumes: prometheus_data: grafana_data: version: \u0026quot;3.8\u0026quot; 은 Docker Compose v2부터 obsolete라 생략했다. 넣어도 동작하지만 경고가 뜬다.\n3. prometheus.yml 작성 global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: \u0026#39;thinkpad\u0026#39; static_configs: - targets: [\u0026#39;100.109.108.36:19100\u0026#39;] labels: instance: \u0026#39;thinkpad-homeserver\u0026#39; - job_name: \u0026#39;oci\u0026#39; static_configs: - targets: [\u0026#39;\u0026lt;OCI_Tailscale_IP\u0026gt;:19100\u0026#39;] labels: instance: \u0026#39;oci-server\u0026#39; OCI 서버의 Tailscale IP는 OCI에서 아래 명령으로 확인한다.\ntailscale ip -4 4. Node Exporter 설치 (홈서버 + OCI 공통) 두 서버 모두 동일하게 진행한다.\nwget https://github.com/prometheus/node_exporter/releases/download/v1.8.2/node_exporter-1.8.2.linux-amd64.tar.gz tar xf node_exporter-1.8.2.linux-amd64.tar.gz sudo cp node_exporter-1.8.2.linux-amd64/node_exporter /usr/local/bin/ sudo chmod +x /usr/local/bin/node_exporter systemd 서비스로 등록한다.\nsudo tee /etc/systemd/system/node_exporter.service \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; [Unit] Description=Node Exporter After=network.target [Service] Type=simple User=nobody ExecStart=/usr/local/bin/node_exporter --web.listen-address=\u0026#34;:19100\u0026#34; Restart=on-failure [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable node_exporter sudo systemctl start node_exporter 정상 동작 확인:\nsudo systemctl status node_exporter # active (running) 이 뜨면 성공 status=203/EXEC 에러가 나는 경우 바이너리 복사가 안 된 것이므로 설치 단계부터 다시 진행한다.\n5. OCI 방화벽 설정 OCI 서버에서 Node Exporter 포트를 Tailscale 인터페이스에서만 허용한다. 외부에 열면 안 된다.\nsudo ufw allow in on tailscale0 to any port 19100 sudo ufw deny 19100 6. 컨테이너 시작 cd ~/monitoring docker compose up -d docker compose ps 7. Grafana 초기 설정 브라우저에서 http://100.109.108.36:13000 접속.\n초기 계정은 admin이고 비밀번호는 docker-compose.yml에 설정한 GF_SECURITY_ADMIN_PASSWORD 값이다.\n볼륨이 이미 생성된 상태에서 환경변수를 바꿔도 적용이 안 된다. 볼륨을 삭제하고 재시작해야 한다.\ndocker compose down docker volume rm monitoring_grafana_data docker compose up -d Prometheus 데이터소스 연결 Connections → Data sources → Add data source → Prometheus\nURL에 http://prometheus:9090 입력 후 Save \u0026amp; test.\n컨테이너 이름으로 통신하므로 IP 대신 서비스 이름 사용 포트는 컨테이너 내부 포트인 9090 (외부 포트 19090 아님) Successfully queried the Prometheus API 가 뜨면 성공이다.\n대시보드 Import Dashboards → Import 에서 ID 1860 입력 후 Load.\nPrometheus 데이터소스를 선택하고 Import하면 Node Exporter Full 대시보드가 추가된다. 상단 Job 드롭다운에서 thinkpad / oci를 전환하면 두 서버를 각각 모니터링할 수 있다.\n8. 배터리 메트릭 수집 (ThinkPad 전용) ThinkPad는 배터리가 있으니 powersupplyclass 컬렉터를 추가로 활성화했다.\nsudo vi /etc/systemd/system/node_exporter.service ExecStart 줄 수정:\nExecStart=/usr/local/bin/node_exporter --web.listen-address=\u0026#34;:19100\u0026#34; --collector.powersupplyclass sudo systemctl daemon-reload sudo systemctl restart node_exporter 수집 확인:\ncurl http://localhost:19100/metrics | grep power_supply 수집되는 주요 메트릭:\n메트릭 내용 node_power_supply_capacity 배터리 잔량 (%) node_power_supply_online 어댑터 연결 여부 (1=연결, 0=미연결) node_power_supply_power_watt 현재 소비 전력 (W) node_power_supply_cyclecount 배터리 충방전 횟수 Grafana 배터리 패널 추가 Dashboards → Edit → Add → Visualization\nVisualization: Gauge 쿼리: node_power_supply_capacity Standard options → Unit: Percent (0-100) Min: 0, Max: 100 Title: Battery 접근 방식 Grafana와 Prometheus는 Portainer와 마찬가지로 외부에 열지 않고 Tailscale VPN 안에서만 접근한다.\nGrafana → http://100.109.108.36:13000 (Tailscale VPN) Prometheus → http://100.109.108.36:19090 (Tailscale VPN) 모니터링 툴을 외부에 노출하면 서버 내부 정보가 그대로 보이기 때문이다.\n정리 Node Exporter를 두 서버에 설치하고 Prometheus가 Tailscale VPN으로 수집하는 구조 OCI의 Node Exporter 포트는 Tailscale 인터페이스에서만 허용할 것 Grafana 데이터소스 URL은 컨테이너 서비스 이름(prometheus:9090)으로 설정 ThinkPad는 --collector.powersupplyclass 옵션으로 배터리 메트릭도 수집 가능 다음 편에서는 OCI 서버 auth.log에 쌓인 SSH 브루트포스 공격을 Fail2ban으로 자동 차단하는 방법을 다룬다.\n","permalink":"https://chanyeols.com/posts/part-07-monitoring/","summary":"\u003ch2 id=\"구성-개요\"\u003e구성 개요\u003c/h2\u003e\n\u003cp\u003e모니터링 스택은 세 가지로 구성된다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003ePrometheus\u003c/strong\u003e — 메트릭 수집 및 저장\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGrafana\u003c/strong\u003e — 대시보드 시각화\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNode Exporter\u003c/strong\u003e — 서버 시스템 메트릭 노출 (CPU, RAM, 디스크, 네트워크 등)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003ePrometheus와 Grafana는 홈서버에서 Docker로 실행하고, Node Exporter는 홈서버와 OCI 서버 양쪽에 systemd로 설치했다. 두 서버가 Tailscale VPN으로 연결돼 있으니 Prometheus가 VPN을 통해 OCI 메트릭도 수집할 수 있다.\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"모니터링 구성도\" loading=\"lazy\" src=\"/images/homeserver-07-architecture.png\"\u003e\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e서비스\u003c/th\u003e\n          \u003cth\u003e포트\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003ePrometheus\u003c/td\u003e\n          \u003ctd\u003e19090\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eGrafana\u003c/td\u003e\n          \u003ctd\u003e13000\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eNode Exporter\u003c/td\u003e\n          \u003ctd\u003e19100\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-디렉토리-생성\"\u003e1. 디렉토리 생성\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir ~/monitoring \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e cd ~/monitoring\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"2-docker-composeyml-작성\"\u003e2. docker-compose.yml 작성\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003eservices\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eprometheus\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eimage\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003eprom/prometheus:latest\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003econtainer_name\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003eprometheus\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003erestart\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003eunless-stopped\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eports\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;19090:9090\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003evolumes\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e./prometheus.yml:/etc/prometheus/prometheus.yml:ro\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003eprometheus_data:/prometheus\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003ecommand\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;--config.file=/etc/prometheus/prometheus.yml\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;--storage.tsdb.path=/prometheus\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#39;--storage.tsdb.retention.time=30d\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003egrafana\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eimage\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003egrafana/grafana:latest\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003econtainer_name\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003egrafana\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003erestart\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003eunless-stopped\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eports\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;13000:3000\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003evolumes\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003egrafana_data:/var/lib/grafana\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eenvironment\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003eGF_SECURITY_ADMIN_PASSWORD=your_password_here\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003eGF_SERVER_ROOT_URL=http://100.109.108.36:13000\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003evolumes\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eprometheus_data\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003egrafana_data\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cblockquote\u003e\n\u003cp\u003e\u003ccode\u003eversion: \u0026quot;3.8\u0026quot;\u003c/code\u003e 은 Docker Compose v2부터 obsolete라 생략했다. 넣어도 동작하지만 경고가 뜬다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - Grafana + Prometheus로 서버 모니터링하기 (7편)"},{"content":"왜 Portainer인가 Filebrowser, Immich, Vaultwarden, Prometheus, Grafana\u0026hellip; 서비스가 하나씩 늘어나다 보니 컨테이너가 어느새 8개가 넘었다. 매번 SSH 접속해서 docker ps, docker logs, docker compose restart 치는 게 점점 번거로워졌다.\nPortainer CE는 Docker를 웹 UI로 관리할 수 있는 오픈소스 도구다. 컨테이너 시작/중지/재시작, 실시간 로그 확인, 볼륨/네트워크 관리까지 브라우저에서 다 된다. CE(Community Edition)는 무료다.\n설치 1. 디렉토리 생성 mkdir -p ~/portainer \u0026amp;\u0026amp; cd ~/portainer 2. docker-compose.yml 작성 services: portainer: image: portainer/portainer-ce:latest container_name: portainer restart: always ports: - \u0026#34;19000:9000\u0026#34; - \u0026#34;18000:8000\u0026#34; volumes: - /var/run/docker.sock:/var/run/docker.sock - portainer_data:/data volumes: portainer_data: 핵심은 /var/run/docker.sock을 마운트하는 것이다. 이를 통해 Portainer가 호스트의 Docker 데몬에 직접 접근할 수 있다.\n포트는 기존 서비스들과 충돌하지 않도록 10000번대로 설정했다.\n포트 용도 19000 Portainer 웹 UI 18000 Edge Agent 터널 (원격 환경 연결용) 3. 실행 docker compose up -d 4. 초기 설정 브라우저에서 http://100.109.108.36:19000 접속 후 admin 계정을 생성한다.\n⚠️ 컨테이너 실행 후 5분 이내에 초기 계정을 만들어야 한다. 시간이 지나면 보안상 접근이 차단되므로 docker compose restart portainer로 재시작해야 한다.\n결과 설치 후 현재 홈서버에서 실행 중인 모든 컨테이너를 한눈에 볼 수 있다.\n컨테이너 상태 filebrowser healthy ✅ grafana running ✅ immich_machine_learning healthy ✅ immich_postgres healthy ✅ immich_redis healthy ✅ immich_server healthy ✅ portainer running ✅ prometheus running ✅ 컨테이너별로 로그 확인, 시작/중지, 환경변수, 마운트 볼륨 정보까지 GUI에서 바로 볼 수 있다.\n접근 방식 Portainer는 Docker 소켓에 직접 접근하는 민감한 도구라 외부에 열지 않고 Tailscale VPN 안에서만 접근한다.\nPortainer UI → http://100.109.108.36:19000 (Tailscale VPN으로만 접근) Nginx 리버스 프록시로 외부에 노출하지 않는다. 누군가 Portainer에 접근하면 서버의 모든 컨테이너를 제어할 수 있기 때문이다.\n정리 Docker Compose 파일 하나로 설치가 끝난다 /var/run/docker.sock 마운트가 핵심 컨테이너 실행 후 5분 안에 초기 계정 생성할 것 보안상 VPN 안에서만 접근하고 외부에는 절대 열지 말 것 다음 편에서는 Prometheus + Grafana + Node Exporter로 홈서버와 OCI 서버를 동시에 모니터링하는 환경을 구성한다.\n","permalink":"https://chanyeols.com/posts/part-06-portainer/","summary":"\u003ch2 id=\"왜-portainer인가\"\u003e왜 Portainer인가\u003c/h2\u003e\n\u003cp\u003eFilebrowser, Immich, Vaultwarden, Prometheus, Grafana\u0026hellip; 서비스가 하나씩 늘어나다 보니 컨테이너가 어느새 8개가 넘었다. 매번 SSH 접속해서 \u003ccode\u003edocker ps\u003c/code\u003e, \u003ccode\u003edocker logs\u003c/code\u003e, \u003ccode\u003edocker compose restart\u003c/code\u003e 치는 게 점점 번거로워졌다.\u003c/p\u003e\n\u003cp\u003ePortainer CE는 Docker를 웹 UI로 관리할 수 있는 오픈소스 도구다. 컨테이너 시작/중지/재시작, 실시간 로그 확인, 볼륨/네트워크 관리까지 브라우저에서 다 된다. CE(Community Edition)는 무료다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"설치\"\u003e설치\u003c/h2\u003e\n\u003ch3 id=\"1-디렉토리-생성\"\u003e1. 디렉토리 생성\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir -p ~/portainer \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e cd ~/portainer\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"2-docker-composeyml-작성\"\u003e2. docker-compose.yml 작성\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003eservices\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eportainer\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eimage\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003eportainer/portainer-ce:latest\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003econtainer_name\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003eportainer\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003erestart\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003ealways\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eports\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;19000:9000\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;18000:8000\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003evolumes\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e/var/run/docker.sock:/var/run/docker.sock\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003eportainer_data:/data\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003evolumes\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eportainer_data\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e핵심은 \u003ccode\u003e/var/run/docker.sock\u003c/code\u003e을 마운트하는 것이다. 이를 통해 Portainer가 호스트의 Docker 데몬에 직접 접근할 수 있다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - Portainer CE로 Docker GUI 관리하기 (6편)"},{"content":"Vaultwarden이란? Bitwarden의 오픈소스 서버 구현체다. 공식 Bitwarden 앱, 브라우저 확장 프로그램과 100% 호환되면서, 내 서버에서 직접 운영할 수 있다.\n구글 비밀번호 관리자를 쭉 써왔는데, 비밀번호를 외부 서비스에 맡기는 게 항상 마음에 걸렸다. 홈서버가 생겼으니 직접 호스팅하기로 했다.\n설치 1. 디렉토리 생성 mkdir -p ~/vaultwarden \u0026amp;\u0026amp; cd ~/vaultwarden 2. docker-compose.yml 작성 services: vaultwarden: image: vaultwarden/server:latest container_name: vaultwarden restart: always ports: - \u0026#34;11000:80\u0026#34; volumes: - vaultwarden_data:/data environment: - DOMAIN=https://vault.yourdomain.com - SIGNUPS_ALLOWED=true volumes: vaultwarden_data: DOMAIN에 실제 접근할 도메인을 설정해야 한다. Vaultwarden이 HTTPS 환경에서 동작한다고 인식해야 브라우저 확장 연동이 정상적으로 된다.\n3. 실행 docker compose up -d OCI Nginx 리버스 프록시 설정 sudo vi /etc/nginx/sites-available/vault.yourdomain.com server { listen 80; server_name vault.yourdomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name vault.yourdomain.com; ssl_certificate /ssl/live/yourdomain.com/fullchain.pem; ssl_certificate_key /ssl/live/yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; location / { proxy_pass http://100.109.108.36:11000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } sudo ln -s /etc/nginx/sites-available/vault.yourdomain.com /etc/nginx/sites-enabled/ sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx 초기 계정 생성 https://vault.yourdomain.com 접속 후 계정을 생성한다.\n⚠️ 마스터 비밀번호는 절대 잊으면 안 된다. 서버 관리자도 복구가 불가능하고, 분실하면 저장된 비밀번호 전부 날아간다.\n계정 생성 완료 후 신규 가입을 막는다. 혼자 쓰는 서버라 추가 가입이 필요 없다.\ndocker-compose.yml 수정:\nenvironment: - SIGNUPS_ALLOWED=false docker compose up -d 구글 비밀번호 가져오기 1. 구글 비밀번호 내보내기 passwords.google.com 접속 → 우측 상단 설정(⚙️) → 비밀번호 내보내기 → CSV 다운로드\n2. Vaultwarden으로 가져오기 vault.yourdomain.com 로그인 → Tools → Import data\n형식: Google Chrome (csv) 선택 다운로드한 CSV 파일 업로드 Import 클릭 ⚠️ CSV 파일에 비밀번호가 평문으로 들어있다. 가져오기 완료 후 즉시 삭제할 것.\n브라우저 확장 연동 크롬 웹스토어에서 Bitwarden 확장 프로그램을 설치한다.\n확장 아이콘 클릭 → 로그인 화면 좌측 상단 지구본 아이콘 클릭 → Self-hosted 선택\nServer URL에 https://vault.yourdomain.com 입력 후 Save.\n이후 Vaultwarden 계정으로 로그인하면 기존 구글 자동완성과 동일하게 사이트마다 비밀번호를 자동완성해준다.\n정리 Vaultwarden은 Bitwarden과 완전 호환이라 앱, 확장 프로그램 그대로 쓸 수 있다 DOMAIN 환경변수를 HTTPS 도메인으로 설정해야 브라우저 확장이 정상 동작한다 계정 생성 후 SIGNUPS_ALLOWED=false로 꼭 닫아두자 마스터 비밀번호 분실 시 복구 방법이 없으므로 안전하게 보관할 것 다음 편에서는 Portainer CE를 설치해서 Docker 컨테이너를 GUI로 관리하는 환경을 구성한다.\n","permalink":"https://chanyeols.com/posts/part-05-vaultwarden/","summary":"\u003ch2 id=\"vaultwarden이란\"\u003eVaultwarden이란?\u003c/h2\u003e\n\u003cp\u003eBitwarden의 오픈소스 서버 구현체다. 공식 Bitwarden 앱, 브라우저 확장 프로그램과 100% 호환되면서, 내 서버에서 직접 운영할 수 있다.\u003c/p\u003e\n\u003cp\u003e구글 비밀번호 관리자를 쭉 써왔는데, 비밀번호를 외부 서비스에 맡기는 게 항상 마음에 걸렸다. 홈서버가 생겼으니 직접 호스팅하기로 했다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"설치\"\u003e설치\u003c/h2\u003e\n\u003ch3 id=\"1-디렉토리-생성\"\u003e1. 디렉토리 생성\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir -p ~/vaultwarden \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e cd ~/vaultwarden\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"2-docker-composeyml-작성\"\u003e2. docker-compose.yml 작성\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003eservices\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003evaultwarden\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eimage\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003evaultwarden/server:latest\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003econtainer_name\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003evaultwarden\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003erestart\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#a5d6ff\"\u003ealways\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eports\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;11000:80\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003evolumes\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003evaultwarden_data:/data\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003eenvironment\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003eDOMAIN=https://vault.yourdomain.com\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e      \u003c/span\u003e- \u003cspan style=\"color:#a5d6ff\"\u003eSIGNUPS_ALLOWED=true\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003evolumes\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003evaultwarden_data\u003c/span\u003e:\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003eDOMAIN\u003c/code\u003e에 실제 접근할 도메인을 설정해야 한다. Vaultwarden이 HTTPS 환경에서 동작한다고 인식해야 브라우저 확장 연동이 정상적으로 된다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - Vaultwarden으로 비밀번호 자체 호스팅하기 (5편)"},{"content":"Immich란? 구글 포토와 거의 동일한 UX를 제공하는 자체 호스팅 사진 관리 서비스다. 얼굴 인식, 지도 뷰, 앨범, 공유 기능까지 있고 모바일 앱도 있어서 자동 백업이 된다. 구글 포토 유료 요금제를 쓰고 있었는데 이걸로 완전히 대체했다.\n설치 과정 1. 디렉토리 생성 mkdir ~/immich \u0026amp;\u0026amp; cd ~/immich 2. docker-compose.yml 및 .env 다운로드 Immich 공식에서 제공하는 파일을 그대로 받아서 쓴다.\nwget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env 3. .env 파일 수정 vi .env 아래 두 경로를 수정한다.\nUPLOAD_LOCATION=/mnt/data/immich/photos DB_DATA_LOCATION=/home/your-username/immich-db DB_DATA_LOCATION을 /mnt/data(NTFS) 아래로 잡으면 안 된다. 이유는 아래 트러블슈팅에서 설명한다.\n4. 실행 docker compose up -d 트러블슈팅 설치 과정에서 꽤 여러 번 막혔다. 겪은 순서대로 정리한다.\n1. docker compose up -d — unknown shorthand flag 오류 unknown shorthand flag: \u0026#39;d\u0026#39; in -d apt install docker.io로 설치한 패키지가 구버전이라 docker compose 플러그인을 지원하지 않아서 생기는 문제다.\n해결: Docker 공식 레포에서 설치한다.\nsudo apt install ca-certificates curl gnupg -y sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo \u0026#34;deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu noble stable\u0026#34; | sudo tee /etc/apt/sources.list.d/docker.list sudo apt update sudo apt install docker-compose-plugin -y 2. Compose 파일 버전 오류 ERROR: The Compose file \u0026#39;./docker-compose.yml\u0026#39; is invalid because: \u0026#39;name\u0026#39; does not match any of the regexes: \u0026#39;^x-\u0026#39; apt install docker-compose로 설치한 구버전(1.29.x)은 최신 Compose 파일 형식을 지원하지 않는다.\n해결: 구버전 제거 후 플러그인 버전으로 교체한다.\nsudo apt remove docker-compose -y sudo apt install docker-compose-plugin -y 3. docker-compose-plugin 패키지를 찾을 수 없음 E: Unable to locate package docker-compose-plugin Ubuntu 기본 레포에는 docker-compose-plugin이 없다.\n해결: 1번 해결 방법에서 Docker 공식 레포를 먼저 추가하면 된다.\n4. immich_postgres 컨테이너가 계속 재시작됨 컨테이너 상태를 확인해보면 Restarting 상태로 계속 재시작만 반복한다.\ndocker logs immich_postgres FATAL: data directory \u0026#34;/var/lib/postgresql/data\u0026#34; has wrong ownership HINT: The server must be started by the user that owns the data directory. 원인: DB_DATA_LOCATION을 /mnt/data(NTFS) 경로로 설정했기 때문이다. NTFS는 Linux 파일 권한 시스템을 지원하지 않아서 PostgreSQL이 요구하는 소유권을 설정할 수 없다.\n해결: DB 경로를 ext4 파일시스템(Ubuntu 기본 디스크)으로 바꾼다.\ndocker compose down mkdir -p ~/immich-db vi .env .env 수정:\nDB_DATA_LOCATION=/home/your-username/immich-db docker compose up -d 사진 원본(UPLOAD_LOCATION)은 용량이 크니까 NTFS 드라이브에 둬도 되지만, DB는 반드시 ext4에 둬야 한다.\n정상 실행 확인 docker compose ps 모든 컨테이너가 running 또는 healthy 상태면 성공이다.\n브라우저에서 http://100.109.108.36:2283 접속 후 계정을 생성하면 된다.\nOCI Nginx 리버스 프록시 연결 3편에서 Filebrowser에 했던 것과 동일하게 Nginx 설정을 추가한다.\nserver { listen 80; server_name photo.yourdomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name photo.yourdomain.com; ssl_certificate /ssl/live/yourdomain.com/fullchain.pem; ssl_certificate_key /ssl/live/yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; # Immich는 대용량 파일 업로드가 있으므로 크기 제한 해제 client_max_body_size 0; location / { proxy_pass http://100.109.108.36:2283; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 웹소켓 지원 (Immich 실시간 업로드에 필요) proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; } } Cloudflare에서 photo.yourdomain.com DNS 추가하고 Nginx 적용하면 외부에서 접근 가능하다.\n정리 docker.io는 구버전이라 최신 Compose 파일을 못 읽는다. Docker 공식 레포에서 설치하자 PostgreSQL 데이터 디렉토리는 NTFS가 아닌 ext4에 둬야 한다 사진 원본은 NTFS 외장 드라이브, DB는 OS 디스크로 분리하면 된다 Immich Nginx 설정 시 client_max_body_size 0과 웹소켓 설정을 빠뜨리지 말 것 다음 편에서는 Vaultwarden으로 비밀번호를 자체 호스팅하는 과정을 다룬다.\n","permalink":"https://chanyeols.com/posts/part-04-immich/","summary":"\u003ch2 id=\"immich란\"\u003eImmich란?\u003c/h2\u003e\n\u003cp\u003e구글 포토와 거의 동일한 UX를 제공하는 자체 호스팅 사진 관리 서비스다. 얼굴 인식, 지도 뷰, 앨범, 공유 기능까지 있고 모바일 앱도 있어서 자동 백업이 된다. 구글 포토 유료 요금제를 쓰고 있었는데 이걸로 완전히 대체했다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"설치-과정\"\u003e설치 과정\u003c/h2\u003e\n\u003ch3 id=\"1-디렉토리-생성\"\u003e1. 디렉토리 생성\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir ~/immich \u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e cd ~/immich\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"2-docker-composeyml-및-env-다운로드\"\u003e2. docker-compose.yml 및 .env 다운로드\u003c/h3\u003e\n\u003cp\u003eImmich 공식에서 제공하는 파일을 그대로 받아서 쓴다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ewget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ewget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"3-env-파일-수정\"\u003e3. .env 파일 수정\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003evi .env\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e아래 두 경로를 수정한다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - Immich로 구글 포토 대체하기 (4편)"},{"content":"외장 SSD 마운트 집에 1TB SSD가 남아있어서 홈서버 스토리지로 활용하기로 했다. 기존에 Windows에서 쓰던 드라이브라 NTFS 포맷이다.\n디스크 확인 lsblk 어떤 디바이스명으로 잡혔는지 확인한다.\n파티션 포맷 확인:\nsudo blkid /dev/nvme1n1p2 NTFS로 확인됐으니 마운트를 진행한다.\n마운트 sudo apt install ntfs-3g -y sudo mkdir /mnt/data sudo mount /dev/nvme1n1p2 /mnt/data 마운트 후 확인해보니 Could not mount read-write, trying read-only 메시지가 떴다. 읽기 전용으로 마운트된 것이다. Windows에서 쓰던 드라이브라 더티 플래그가 남아있어서 발생하는 문제다.\n읽기/쓰기 가능하도록 재마운트 sudo umount /mnt/data sudo ntfsfix /dev/nvme1n1p2 sudo mount /dev/nvme1n1p2 /mnt/data ntfsfix로 더티 플래그를 제거하고 다시 마운트하면 읽기/쓰기가 모두 가능해진다.\n심볼릭 링크 생성 홈 디렉토리에서 편하게 접근하기 위해 심볼릭 링크를 만들어둔다.\nln -s /mnt/data ~/data Samba 설정 파일 탐색기에서 네트워크 드라이브처럼 접근하고 싶다면 Samba를 설치하면 된다.\nsudo apt install samba -y Samba 사용자 등록:\nsudo smbpasswd -a your-username 설정 파일에 공유 디렉토리 추가:\nsudo vi /etc/samba/smb.conf 파일 맨 아래에 아래 내용을 추가한다.\n[data] path = /mnt/data browseable = yes read only = no valid users = your-username Samba 서비스 재시작:\nsudo systemctl restart smbd 이제 Windows 파일 탐색기 주소창에 \\\\100.109.108.36\\data를 입력하면 마운트된 SSD에 접근할 수 있다.\n다만 이건 파일 탐색기 전용이라 브라우저에서 접근하려면 Filebrowser를 써야 한다.\nDocker + Filebrowser 설치 Docker 설치 # Docker 공식 레포 추가 sudo apt install ca-certificates curl gnupg -y sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo \u0026#34;deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu noble stable\u0026#34; | sudo tee /etc/apt/sources.list.d/docker.list sudo apt update sudo apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin -y root가 아닌 일반 계정으로 Docker를 사용하려면:\nsudo usermod -aG docker $USER # 재로그인 후 적용 Filebrowser 실행 docker run -d \\ --name filebrowser \\ --restart always \\ -v /mnt/data:/srv \\ -p 9090:80 \\ filebrowser/filebrowser /mnt/data를 컨테이너 안의 /srv로 마운트해서 Filebrowser가 SSD 전체를 서빙하도록 했다.\n초기 로그인 브라우저에서 http://100.109.108.36:9090 접속. 초기 계정은 admin / admin이라고 알려져 있는데 접속이 안 됐다.\ndocker logs filebrowser 로그를 확인해보니 랜덤으로 생성된 비밀번호가 출력돼 있었다. 그 비밀번호로 접속하면 된다.\nOCI Nginx 리버스 프록시 + SSL VPN 없이도 외부에서 브라우저로 접근하려면 OCI Nginx에 리버스 프록시를 설정해야 한다.\nCloudflare DNS 추가 Cloudflare에서 files.yourdomain.com 서브도메인을 OCI 공인 IP로 추가한다.\nNginx 설정 OCI 서버에서 /etc/nginx/sites-available/files.yourdomain.com 파일을 생성한다.\n처음엔 HTTP만 설정했는데, files.yourdomain.com으로 접속하면 계속 메인 도메인으로 리다이렉트되는 문제가 있었다. SSL 인증서를 안 걸어줘서 생기는 문제였다.\n최종 설정:\nserver { listen 80; server_name files.yourdomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name files.yourdomain.com; ssl_certificate /ssl/live/yourdomain.com/fullchain.pem; ssl_certificate_key /ssl/live/yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; location / { proxy_pass http://100.109.108.36:9090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } 심볼릭 링크 생성 후 적용:\nsudo ln -s /etc/nginx/sites-available/files.yourdomain.com /etc/nginx/sites-enabled/ sudo nginx -t \u0026amp;\u0026amp; sudo systemctl reload nginx proxy_pass에 Tailscale VPN IP를 쓰는 게 핵심이다. OCI와 홈서버가 같은 Tailscale 네트워크 안에 있기 때문에 가능한 구성이다.\n이제 VPN 없이 files.yourdomain.com으로 접속해서 홈서버의 파일을 관리할 수 있다.\n정리 NTFS 드라이브는 ntfsfix로 더티 플래그 제거 후 마운트해야 읽기/쓰기가 된다 Samba는 파일 탐색기 접근용, Filebrowser는 브라우저 접근용 OCI Nginx 리버스 프록시 + Tailscale VPN 조합으로 홈서버를 외부에 안전하게 노출할 수 있다 다음 편에서는 Immich를 설치해서 구글 포토를 대체하는 과정을 다룬다.\n","permalink":"https://chanyeols.com/posts/part-03-ssd-filebrowser/","summary":"\u003ch2 id=\"외장-ssd-마운트\"\u003e외장 SSD 마운트\u003c/h2\u003e\n\u003cp\u003e집에 1TB SSD가 남아있어서 홈서버 스토리지로 활용하기로 했다. 기존에 Windows에서 쓰던 드라이브라 NTFS 포맷이다.\u003c/p\u003e\n\u003ch3 id=\"디스크-확인\"\u003e디스크 확인\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003elsblk\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e어떤 디바이스명으로 잡혔는지 확인한다.\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"lsblk 결과\" loading=\"lazy\" src=\"/images/homeserver-03-lsblk.png\"\u003e\u003c/p\u003e\n\u003cp\u003e파티션 포맷 확인:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo blkid /dev/nvme1n1p2\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eNTFS로 확인됐으니 마운트를 진행한다.\u003c/p\u003e\n\u003ch3 id=\"마운트\"\u003e마운트\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt install ntfs-3g -y\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo mkdir /mnt/data\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo mount /dev/nvme1n1p2 /mnt/data\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cimg alt=\"마운트 결과\" loading=\"lazy\" src=\"/images/homeserver-03-mount.png\"\u003e\u003c/p\u003e\n\u003cp\u003e마운트 후 확인해보니 \u003ccode\u003eCould not mount read-write, trying read-only\u003c/code\u003e 메시지가 떴다. 읽기 전용으로 마운트된 것이다. Windows에서 쓰던 드라이브라 더티 플래그가 남아있어서 발생하는 문제다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - 외장 SSD 마운트 + Filebrowser 원격 파일 관리 (3편)"},{"content":"OS 선택 Windows를 그대로 쓸까, Linux native로 갈까 고민했다. Docker 운영이 메인이고 서버답게 쓰려면 Linux가 맞다. Ubuntu Server 24.04 LTS로 결정했다.\nUbuntu Server 설치 준비물 Ventoy — USB를 부팅 드라이브로 만들어주는 도구다. 일반적인 방식은 ISO를 USB에 굽는 방식인데, Ventoy는 USB 하나에 여러 ISO를 넣고 부팅 시 선택할 수 있어서 훨씬 편하다.\nVentoy 다운로드: https://github.com/ventoy/Ventoy/releases Ubuntu Server 24.04 LTS ISO 다운로드: https://ubuntu.com/download/server 설치 순서 1. Ventoy2Disk.exe 실행\nUSB를 꽂고 Ventoy2Disk.exe를 실행한다. 사용할 USB를 선택하고 Install을 누른다.\n⚠️ USB에 있는 데이터는 전부 지워지므로 먼저 백업할 것\n2. ISO 파일 복사\nInstall 완료 후 다운로드한 Ubuntu ISO 파일을 USB에 그냥 복사하면 된다.\n3. 서버에 USB 꽂고 부팅\nUbuntu를 설치할 노트북에 USB를 꽂고 전원을 켠다. F12를 연타해서 바이오스 부팅 메뉴 진입 후 Ventoy가 설치된 USB를 선택한다.\n이후 Ubuntu Server 설치 과정은 안내에 따라 진행하면 된다. 특별히 복잡한 부분은 없다. 파티션은 기본값으로 잡고, SSH 서버 설치 옵션은 체크해두는 게 좋다.\nTailscale VPN 연결 홈서버는 공인 IP가 없어서 외부에서 직접 접근이 안 된다. Tailscale을 사용하면 홈서버와 OCI 인스턴스를 같은 사설 네트워크로 묶을 수 있다.\n설치 Ubuntu 설치 후 터미널에서 아래 명령어를 실행한다.\ncurl -fsSL https://tailscale.com/install.sh | sh 설치 후 활성화:\nsudo tailscale up 실행하면 아래처럼 인증 URL이 출력된다.\nTo authenticate, visit: https://login.tailscale.com/a/xxxxxxxxxxxxxxx URL을 브라우저에서 열어서 Tailscale 계정으로 로그인하면 홈서버가 네트워크에 등록된다.\n홈서버 IP 확인 tailscale ip -4 이 IP(예: 100.109.108.36)로 이제 어디서든 홈서버에 SSH 접속이 가능하다.\nssh username@100.109.108.36 OCI 서버에도 동일하게 설치 OCI 인스턴스에도 위 과정을 그대로 반복해서 Tailscale을 설치하면 두 서버가 같은 사설 네트워크로 묶인다.\n설정 완료 후 Tailscale 관리 콘솔에서 홈서버와 OCI 서버가 모두 연결된 것을 확인할 수 있다. 이제 OCI에서 홈서버로, 홈서버에서 OCI로 자유롭게 통신이 가능하다.\n여기까지의 구성 [내 PC / 스마트폰] │ Tailscale VPN ▼ [홈서버 ThinkPad] ─── Tailscale VPN ─── [OCI 인스턴스] 100.109.108.36 100.x.x.x Tailscale이 연결된 것만으로도 이미 꽤 쓸만한 환경이 됐다. 이제 어디서든 홈서버에 SSH로 접속해서 작업할 수 있다.\n다음 편에서는 외장 SSD를 마운트하고 Filebrowser로 브라우저에서 파일을 관리하는 환경을 구성한다.\n","permalink":"https://chanyeols.com/posts/part-02-ubuntu-tailscale/","summary":"\u003ch2 id=\"os-선택\"\u003eOS 선택\u003c/h2\u003e\n\u003cp\u003eWindows를 그대로 쓸까, Linux native로 갈까 고민했다. Docker 운영이 메인이고 서버답게 쓰려면 Linux가 맞다. Ubuntu Server 24.04 LTS로 결정했다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"ubuntu-server-설치\"\u003eUbuntu Server 설치\u003c/h2\u003e\n\u003ch3 id=\"준비물\"\u003e준비물\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eVentoy\u003c/strong\u003e — USB를 부팅 드라이브로 만들어주는 도구다. 일반적인 방식은 ISO를 USB에 굽는 방식인데, Ventoy는 USB 하나에 여러 ISO를 넣고 부팅 시 선택할 수 있어서 훨씬 편하다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eVentoy 다운로드: \u003ca href=\"https://github.com/ventoy/Ventoy/releases\"\u003ehttps://github.com/ventoy/Ventoy/releases\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003eUbuntu Server 24.04 LTS ISO 다운로드: \u003ca href=\"https://ubuntu.com/download/server\"\u003ehttps://ubuntu.com/download/server\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"설치-순서\"\u003e설치 순서\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e1. Ventoy2Disk.exe 실행\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eUSB를 꽂고 Ventoy2Disk.exe를 실행한다. 사용할 USB를 선택하고 Install을 누른다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - Ubuntu Server 설치 + Tailscale VPN (2편)"},{"content":"왜 홈서버를 만들게 됐나 개인 블로그를 운영하면서 OCI(Oracle Cloud) 무료 인스턴스 하나를 쭉 써왔다. 1 vCPU, 1GB RAM짜리라 Hugo 정적 블로그 서빙에는 충분했는데, 문제는 점점 하고 싶은 게 늘어난다는 것이다.\n사진 파일이 쌓이면서 개인 NAS가 필요해졌다 사이드 프로젝트 돌릴 서버가 필요했다 비밀번호 관리도 외부 서비스 말고 직접 하고 싶었다 클라우드로 커버하면 되지 않냐 싶지만, 스토리지가 좀 붙으면 요금이 눈에 띄게 올라간다. 마침 집에 안 쓰는 노트북이 하나 있었고, 거기서부터 홈서버 구축기가 시작됐다.\n하드웨어 선택: 데스크탑 대신 노트북 처음엔 집에 있는 데스크탑을 홈서버로 쓸까 생각했다. 그런데 데스크탑을 24시간 켜두면 전기세가 만만치 않다.\n마침 Ryzen 5 5600U, RAM 16GB짜리 ThinkPad E15 Gen3가 있었다. 노트북이라 전력 소비가 훨씬 낮고, 배터리가 있어서 정전 시 UPS 역할도 된다. 성능도 홈서버 용도로는 충분하다.\n항목 사양 CPU Ryzen 5 5600U RAM 16GB 저장장치 256GB SSD (OS/Docker) + 1TB SSD (/mnt/data) OS Ubuntu Server 24.04 LTS 유선 랜 포트를 따로 뚫기가 애매해서 일단 와이파이로 연결해서 운영 중이다.\n전체 아키텍처 핵심은 세 가지다.\nTailscale VPN — 홈서버는 공인 IP가 없어서 외부에서 직접 접근이 안 된다. Tailscale로 홈서버와 OCI 인스턴스를 같은 사설 네트워크로 묶었다. 이렇게 하면 어디서든 VPN으로 홈서버에 접근할 수 있다.\nOCI Nginx 리버스 프록시 — 기존에 Hugo 블로그를 서빙하던 OCI 인스턴스의 Nginx를 리버스 프록시로 활용한다. 외부에서 photo.yourdomain.com으로 접근하면 Nginx가 Tailscale VPN을 통해 홈서버의 Immich로 연결해주는 구조다.\nCloudflare DNS — 도메인 관리는 Cloudflare에서 한다. 서브도메인 추가할 때마다 Cloudflare에서 레코드 하나 추가하고 Nginx 설정 추가하면 끝이다.\n운영 중인 서비스 현재 홈서버에서 돌아가는 서비스 목록이다.\n포트 서비스 접근 방식 2283 Immich (사진 관리) 도메인 (OCI 프록시) 9090 Filebrowser (파일 관리) 도메인 (OCI 프록시) 11000 Vaultwarden (비밀번호) 도메인 (OCI 프록시) 13000 Grafana (모니터링) Tailscale VPN 19000 Portainer (Docker GUI) Tailscale VPN 19090 Prometheus Tailscale VPN 19100 Node Exporter 내부 수집용 23000 컨테이너 대시보드 (React) Tailscale VPN 28080 컨테이너 대시보드 (Spring) Tailscale VPN Grafana, Portainer처럼 민감한 도구는 VPN 안에서만 접근하고 외부에는 열지 않았다. Docker 소켓에 직접 접근하는 툴을 외부에 노출하면 보안상 위험하기 때문이다.\n시리즈 구성 이 구축기는 총 12편으로 구성된다.\n왜 홈서버인가? + 전체 아키텍처 ← 지금 여기 Ubuntu Server 설치 + Tailscale VPN 외장 SSD 마운트 + Filebrowser 원격 파일 관리 Immich로 구글 포토 대체하기 Vaultwarden으로 비밀번호 자체 호스팅 Portainer CE로 Docker GUI 관리 Grafana + Prometheus로 홈서버 모니터링 Fail2ban으로 SSH 브루트포스 차단 certbot \u0026ndash;expand로 SSL 서브도메인 추가 PostgreSQL 자동 백업 (pg_dump + cron) TLP + thinkfan + Swap 튜닝으로 운영 최적화 직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE) 다음 편에서는 Ubuntu Server 설치부터 Tailscale VPN 연결까지 다룬다.\n","permalink":"https://chanyeols.com/posts/part-01-intro/","summary":"\u003ch2 id=\"왜-홈서버를-만들게-됐나\"\u003e왜 홈서버를 만들게 됐나\u003c/h2\u003e\n\u003cp\u003e개인 블로그를 운영하면서 OCI(Oracle Cloud) 무료 인스턴스 하나를 쭉 써왔다. 1 vCPU, 1GB RAM짜리라 Hugo 정적 블로그 서빙에는 충분했는데, 문제는 점점 하고 싶은 게 늘어난다는 것이다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e사진 파일이 쌓이면서 개인 NAS가 필요해졌다\u003c/li\u003e\n\u003cli\u003e사이드 프로젝트 돌릴 서버가 필요했다\u003c/li\u003e\n\u003cli\u003e비밀번호 관리도 외부 서비스 말고 직접 하고 싶었다\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e클라우드로 커버하면 되지 않냐 싶지만, 스토리지가 좀 붙으면 요금이 눈에 띄게 올라간다. 마침 집에 안 쓰는 노트북이 하나 있었고, 거기서부터 홈서버 구축기가 시작됐다.\u003c/p\u003e","title":"노트북으로 홈서버 구축하기 - 왜 홈서버인가? (1편)"},{"content":" [Dev-Fortune] 시리즈 다시보기\n1편: 기획부터 스택 선정까지 2편: 로컬 LLM Ollama 연동 3편: RAG와 Vector Store 구축 4편: 프롬프트 엔지니어링 실전 5편: 스트리밍 API 구현 6편: 전체 워크플로우 분석 1. 서론: 프로젝트를 마무리하며 시니컬한 개발자 챗봇 \u0026lsquo;Dev-Fortune\u0026rsquo;을 통해 Spring AI와 RAG의 가능성을 엿보았습니다. 마지막으로 이 프로젝트의 한계를 짚어보고 고도화 로드맵을 그려봅니다.\n2. 미래 고도화 로드맵 (AS-IS vs TO-BE) 현재의 메모리 기반 구조에서 영구 저장소와 맥락 인지 능력을 갖춘 시스템으로의 진화 방향입니다.\ngraph LR subgraph \"AS-IS (Current)\" A[SimpleVectorStore] --- B[In-Memory] C[Stateless] --- D[No History] end subgraph \u0026quot;TO-BE (Future)\u0026quot; E[PostgreSQL + pgvector] --- F[Persistent Storage] G[ChatMemory / Redis] --- H[Context Awareness] I[Multi-Persona] --- J[Style Selection] end A -.-\u0026gt; E C -.-\u0026gt; G 3. 핵심 고도화 포인트 PostgreSQL + pgvector: 수만 건의 데이터를 영구 저장하고 고속 검색. Chat Memory: 이전 대화 맥락을 기억하는 지능형 대화. 멀티 페르소나: 에인절 시니어, 까칠한 CTO 등 페르소나 확장. 4. 결론 Java 진영에서도 AI 개발이 너무나 쉽고 우아해졌습니다. 여러분의 앞날에 컴파일 에러 없는 평안만 가득하시길 바라며 시리즈를 마칩니다.\n당신의 인생을 계속 디버깅하세요!\n","permalink":"https://chanyeols.com/posts/spring-ai-chatbot-retrospective-roadmap/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e[Dev-Fortune] 시리즈 다시보기\u003c/strong\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-ollama-chatbot-planning/\"\u003e1편: 기획부터 스택 선정까지\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/ollama-spring-boot-local-llm-setup/\"\u003e2편: 로컬 LLM Ollama 연동\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-rag-simplevectorstore-ingestion/\"\u003e3편: RAG와 Vector Store 구축\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/prompt-engineering-ai-persona-tuning/\"\u003e4편: 프롬프트 엔지니어링 실전\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-webflux-sse-ai-streaming-api/\"\u003e5편: 스트리밍 API 구현\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-rag-workflow-analysis/\"\u003e6편: 전체 워크플로우 분석\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"1-서론-프로젝트를-마무리하며\"\u003e1. 서론: 프로젝트를 마무리하며\u003c/h2\u003e\n\u003cp\u003e시니컬한 개발자 챗봇 \u0026lsquo;Dev-Fortune\u0026rsquo;을 통해 Spring AI와 RAG의 가능성을 엿보았습니다. 마지막으로 이 프로젝트의 한계를 짚어보고 고도화 로드맵을 그려봅니다.\u003c/p\u003e\n\u003ch2 id=\"2-미래-고도화-로드맵-as-is-vs-to-be\"\u003e2. 미래 고도화 로드맵 (AS-IS vs TO-BE)\u003c/h2\u003e\n\u003cp\u003e현재의 메모리 기반 구조에서 영구 저장소와 맥락 인지 능력을 갖춘 시스템으로의 진화 방향입니다.\u003c/p\u003e","title":"Spring AI 프로젝트 마무리: 로컬 LLM 챗봇의 한계와 향후 발전 로드맵 (7편)"},{"content":" [Dev-Fortune] 시리즈 다시보기\n1편: 기획부터 스택 선정까지 2편: 로컬 LLM Ollama 연동 3편: RAG와 Vector Store 구축 4편: 프롬프트 엔지니어링 실전 5편: 스트리밍 API 구현 1. 서론: 조각난 퍼즐을 하나로 합치기 데이터의 이동이 일어나는 찰나의 순간, 서버 내부에서 일어나는 유기적인 상호작용을 파헤쳐 보겠습니다.\n2. 전체 워크플로우 시퀀스 (Deep-Dive) 사용자의 엔터 키 한 번이 답변으로 돌아오기까지의 7단계 여정입니다.\nsequenceDiagram autonumber User-\u003e\u003eController: 고민 입력 (JSON) Controller-\u003e\u003eService: 사주 분석 요청 Service-\u003e\u003eVectorStore: 고민 기반 유사도 검색 VectorStore--\u003e\u003eService: 관련 사주 데이터 반환 Service-\u003e\u003eAI: 프롬프트 조합 후 전달 (System+User) AI--\u003e\u003eController: 스트리밍 답변 생성 (Flux) Controller--\u003e\u003eUser: SSE 응답 (실시간 텍스트) 3. 데이터 흐름의 5단계 요청 수신: JSON 고민 데이터 접수. 의미 검색: 사주 데이터 조각 탐색. 프롬프트 조합: 페르소나 + 지식 + 질문 결합. 추론 및 생성: AI의 인격이 투영된 답변 생성. 스트리밍 응답: 차가운 조언의 실시간 전달. 결국 데이터가 지능을 만들고, 프롬프트가 성격을 만듭니다.\n마지막 7편에서는 프로젝트를 마무리하며 미래 고도화 로드맵을 그려보겠습니다.\n","permalink":"https://chanyeols.com/posts/spring-ai-rag-workflow-analysis/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e[Dev-Fortune] 시리즈 다시보기\u003c/strong\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-ollama-chatbot-planning/\"\u003e1편: 기획부터 스택 선정까지\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/ollama-spring-boot-local-llm-setup/\"\u003e2편: 로컬 LLM Ollama 연동\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-rag-simplevectorstore-ingestion/\"\u003e3편: RAG와 Vector Store 구축\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/prompt-engineering-ai-persona-tuning/\"\u003e4편: 프롬프트 엔지니어링 실전\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-webflux-sse-ai-streaming-api/\"\u003e5편: 스트리밍 API 구현\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"1-서론-조각난-퍼즐을-하나로-합치기\"\u003e1. 서론: 조각난 퍼즐을 하나로 합치기\u003c/h2\u003e\n\u003cp\u003e데이터의 이동이 일어나는 찰나의 순간, 서버 내부에서 일어나는 유기적인 상호작용을 파헤쳐 보겠습니다.\u003c/p\u003e\n\u003ch2 id=\"2-전체-워크플로우-시퀀스-deep-dive\"\u003e2. 전체 워크플로우 시퀀스 (Deep-Dive)\u003c/h2\u003e\n\u003cp\u003e사용자의 엔터 키 한 번이 답변으로 돌아오기까지의 7단계 여정입니다.\u003c/p\u003e\n\u003cdiv class=\"mermaid\"\u003e\nsequenceDiagram\n    autonumber\n    User-\u003e\u003eController: 고민 입력 (JSON)\n    Controller-\u003e\u003eService: 사주 분석 요청\n    Service-\u003e\u003eVectorStore: 고민 기반 유사도 검색\n    VectorStore--\u003e\u003eService: 관련 사주 데이터 반환\n    Service-\u003e\u003eAI: 프롬프트 조합 후 전달 (System+User)\n    AI--\u003e\u003eController: 스트리밍 답변 생성 (Flux)\n    Controller--\u003e\u003eUser: SSE 응답 (실시간 텍스트)\n\u003c/div\u003e\n\u003ch2 id=\"3-데이터-흐름의-5단계\"\u003e3. 데이터 흐름의 5단계\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e요청 수신\u003c/strong\u003e: JSON 고민 데이터 접수.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e의미 검색\u003c/strong\u003e: 사주 데이터 조각 탐색.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e프롬프트 조합\u003c/strong\u003e: 페르소나 + 지식 + 질문 결합.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e추론 및 생성\u003c/strong\u003e: AI의 인격이 투영된 답변 생성.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e스트리밍 응답\u003c/strong\u003e: 차가운 조언의 실시간 전달.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e결국 \u003cstrong\u003e데이터가 지능을 만들고, 프롬프트가 성격을 만듭니다.\u003c/strong\u003e\u003c/p\u003e","title":"Spring AI RAG 워크플로우 분석: 사용자 질문부터 AI 답변까지의 여정 (6편)"},{"content":" [Dev-Fortune] 시리즈 다시보기\n1편: 기획부터 스택 선정까지 2편: 로컬 LLM Ollama 연동 3편: RAG와 Vector Store 구축 4편: 프롬프트 엔지니어링 실전 1. 서론: AI 답변, 왜 기다리게 하나요? 사용자 경험(UX)을 위해 한 글자씩 타이핑하듯 보여주는 스트리밍 방식은 필수적입니다. 우리 프로젝트는 WebFlux와 **SSE(Server-Sent Events)**를 활용했습니다.\n2. 스트리밍 시퀀스 다이어그램 서버와 클라이언트 간의 끊임없는 데이터 흐름을 살펴보세요.\nsequenceDiagram participant U as User participant S as Spring Server (Flux) participant A as AI Model (Ollama) U-\u0026gt;\u0026gt;S: POST /chat (Request) Note over S,A: Connection Stay Open A--\u0026gt;\u0026gt;S: \u0026quot;오늘의\u0026quot; (Token 1) S--\u0026gt;\u0026gt;U: data: \u0026quot;오늘의\u0026quot; A--\u0026gt;\u0026gt;S: \u0026quot; 사주는\u0026quot; (Token 2) S--\u0026gt;\u0026gt;U: data: \u0026quot; 사주는\u0026quot; Note right of U: 사용자는 실시간으로 글자가 보임 3. Flux와 SSE MediaType.TEXT_EVENT_STREAM_VALUE를 사용하여 AI가 단어(Token)를 생성할 때마다 즉시 클라이언트로 전송합니다. 비차단(Non-blocking) 방식인 WebFlux는 답변을 기다리는 동안 쓰레드를 점유하지 않아 성능적으로도 우수합니다.\n4. 클라이언트에서의 처리 프론트엔드에서는 fetch API의 getReader()를 사용하여 한 글자씩 화면에 덧붙이는 작업을 수행합니다.\n다음 6편에서는 지금까지의 기술들을 하나로 묶어 전체 워크플로우를 심층 분석해 보겠습니다.\n","permalink":"https://chanyeols.com/posts/spring-webflux-sse-ai-streaming-api/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e[Dev-Fortune] 시리즈 다시보기\u003c/strong\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-ollama-chatbot-planning/\"\u003e1편: 기획부터 스택 선정까지\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/ollama-spring-boot-local-llm-setup/\"\u003e2편: 로컬 LLM Ollama 연동\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-rag-simplevectorstore-ingestion/\"\u003e3편: RAG와 Vector Store 구축\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/prompt-engineering-ai-persona-tuning/\"\u003e4편: 프롬프트 엔지니어링 실전\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"1-서론-ai-답변-왜-기다리게-하나요\"\u003e1. 서론: AI 답변, 왜 기다리게 하나요?\u003c/h2\u003e\n\u003cp\u003e사용자 경험(UX)을 위해 한 글자씩 타이핑하듯 보여주는 스트리밍 방식은 필수적입니다. 우리 프로젝트는 \u003cstrong\u003eWebFlux\u003c/strong\u003e와 **SSE(Server-Sent Events)**를 활용했습니다.\u003c/p\u003e\n\u003ch2 id=\"2-스트리밍-시퀀스-다이어그램\"\u003e2. 스트리밍 시퀀스 다이어그램\u003c/h2\u003e\n\u003cp\u003e서버와 클라이언트 간의 끊임없는 데이터 흐름을 살펴보세요.\u003c/p\u003e\n\u003cdiv class=\"mermaid\"\u003e\nsequenceDiagram\n    participant U as User\n    participant S as Spring Server (Flux)\n    participant A as AI Model (Ollama)\n\u003cpre\u003e\u003ccode\u003eU-\u0026gt;\u0026gt;S: POST /chat (Request)\nNote over S,A: Connection Stay Open\nA--\u0026gt;\u0026gt;S: \u0026quot;오늘의\u0026quot; (Token 1)\nS--\u0026gt;\u0026gt;U: data: \u0026quot;오늘의\u0026quot;\nA--\u0026gt;\u0026gt;S: \u0026quot; 사주는\u0026quot; (Token 2)\nS--\u0026gt;\u0026gt;U: data: \u0026quot; 사주는\u0026quot;\nNote right of U: 사용자는 실시간으로 글자가 보임\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/div\u003e\n\u003ch2 id=\"3-flux와-sse\"\u003e3. Flux와 SSE\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003eMediaType.TEXT_EVENT_STREAM_VALUE\u003c/code\u003e를 사용하여 AI가 단어(Token)를 생성할 때마다 즉시 클라이언트로 전송합니다. 비차단(Non-blocking) 방식인 WebFlux는 답변을 기다리는 동안 쓰레드를 점유하지 않아 성능적으로도 우수합니다.\u003c/p\u003e","title":"Spring WebFlux와 SSE로 구현하는 AI 스트리밍 API: 실시간 대화 경험 (5편)"},{"content":" [Dev-Fortune] 시리즈 다시보기\n1편: 기획부터 스택 선정까지 2편: 로컬 LLM Ollama 연동 3편: RAG와 Vector Store 구축 1. 서론: AI의 \u0026lsquo;인격\u0026rsquo;은 어디서 오는가? 지식만 있는 AI는 백과사전일 뿐입니다. 우리가 원하는 \u0026ldquo;시니컬한 시니어 개발자\u0026rdquo; 인격을 형성하고 답변 형식을 강제하는 프롬프트 엔지니어링을 살펴보겠습니다.\n2. 프롬프트 조합 구조 시스템 지침(Persona)과 검색된 데이터, 사용자의 질문이 하나로 섞이는 과정입니다.\ngraph TD A[Persona: 실리콘밸리 개발자] + B[Rules: 반말/두문장] --\u003e E[System Message] C[Retrieved Saju Data] + D[User Message] --\u003e F[User Message with Context] E \u0026 F --\u003e G((AI Model)) G --\u003e H[Final Response] 3. 시스템 프롬프트(System Prompt) 설계 apiContext를 통해 페르소나를 정의하고, 건조하고 시니컬한 반말 말투를 강제합니다. 특히 출력 형식을 엄격하게 통제하여 UI 일관성을 유지합니다.\nprivate final String apiContext = \u0026#34;\u0026#34;\u0026#34; # 페르소나: 실리콘밸리 천재 개발자... # 답변 형식: 무조건 아래 두 문장 구조로만 답변해라. - \u0026#34;오늘의 개발자 사주는 \u0026#39;...\u0026#39;다.\u0026#34; - \u0026#34;주의사항은 \u0026#39;...\u0026#39;이니 \u0026#39;...\u0026#39; 하지 않도록 조심해라.\u0026#34; \u0026#34;\u0026#34;\u0026#34;; 4. Few-Shot 기법 AI가 형식을 자꾸 어긴다면 예시를 몇 개 보여주는 Few-Shot 기법을 사용하여 일관성을 비약적으로 높일 수 있습니다.\n다음 5편에서는 답변을 실시간으로 전달하는 WebFlux 기반 스트리밍 API 구현을 알아보겠습니다.\n","permalink":"https://chanyeols.com/posts/prompt-engineering-ai-persona-tuning/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e[Dev-Fortune] 시리즈 다시보기\u003c/strong\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-ollama-chatbot-planning/\"\u003e1편: 기획부터 스택 선정까지\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/ollama-spring-boot-local-llm-setup/\"\u003e2편: 로컬 LLM Ollama 연동\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-rag-simplevectorstore-ingestion/\"\u003e3편: RAG와 Vector Store 구축\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"1-서론-ai의-인격은-어디서-오는가\"\u003e1. 서론: AI의 \u0026lsquo;인격\u0026rsquo;은 어디서 오는가?\u003c/h2\u003e\n\u003cp\u003e지식만 있는 AI는 백과사전일 뿐입니다. 우리가 원하는 \u0026ldquo;시니컬한 시니어 개발자\u0026rdquo; 인격을 형성하고 답변 형식을 강제하는 \u003cstrong\u003e프롬프트 엔지니어링\u003c/strong\u003e을 살펴보겠습니다.\u003c/p\u003e\n\u003ch2 id=\"2-프롬프트-조합-구조\"\u003e2. 프롬프트 조합 구조\u003c/h2\u003e\n\u003cp\u003e시스템 지침(Persona)과 검색된 데이터, 사용자의 질문이 하나로 섞이는 과정입니다.\u003c/p\u003e\n\u003cdiv class=\"mermaid\"\u003e\ngraph TD\n    A[Persona: 실리콘밸리 개발자] + B[Rules: 반말/두문장] --\u003e E[System Message]\n    C[Retrieved Saju Data] + D[User Message] --\u003e F[User Message with Context]\n    E \u0026 F --\u003e G((AI Model))\n    G --\u003e H[Final Response]\n\u003c/div\u003e\n\u003ch2 id=\"3-시스템-프롬프트system-prompt-설계\"\u003e3. 시스템 프롬프트(System Prompt) 설계\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003eapiContext\u003c/code\u003e를 통해 페르소나를 정의하고, 건조하고 시니컬한 반말 말투를 강제합니다. 특히 출력 형식을 엄격하게 통제하여 UI 일관성을 유지합니다.\u003c/p\u003e","title":"프롬프트 엔지니어링 실전: AI에게 시니컬한 개발자 페르소나 주입하기 (4편)"},{"content":" [Dev-Fortune] 시리즈 다시보기\n1편: 기획부터 스택 선정까지 2편: 로컬 LLM Ollama 연동 1. 서론: AI는 어떻게 사주를 \u0026lsquo;공부\u0026rsquo;하는가? AI에게 새로운 지식을 가르치는 방법 중 가장 경제적이고 정확한 RAG(Retrieval-Augmented Generation) 방식을 살펴봅니다. 질문이 들어올 때마다 관련 내용을 찾아서 읽어주며 답변하게 하는 원리입니다.\n2. 데이터 주입 프로세스 (Data Ingestion) JSON 파일이 어떻게 벡터화되어 메모리에 저장되는지 그 흐름을 도식화했습니다.\nflowchart LR A[(sajuAPI.json)] --\u003e B[DataLoader] B --\u003e C[Text 정제: Key-Value형식] C --\u003e D[Embedding Model] D --\u003e E{SimpleVectorStore} E --\u003e F[RAM Memory] style F fill:#f96,stroke:#333,stroke-width:2px 3. SimpleVectorStore와 DataLoader 우리 프로젝트는 별도의 DB 없이 메모리 기반의 SimpleVectorStore를 사용합니다. UnidocuDataLoader는 서버 기동 시점에 JSON 데이터를 읽어 벡터로 변환하여 주입합니다.\n// 텍스트 정제 예시 String content = String.format( \u0026#34;it_interpretation: %s\\nanti_pattern: %s\\nbad_habit: %s\u0026#34;, item.get(\u0026#34;it_interpretation\u0026#34;), ... ); 4. 유사도 검색(Similarity Search) 사용자가 \u0026ldquo;프로젝트 마감\u0026quot;에 대해 물으면, 벡터 저장소는 의미적으로 유사한 사주 데이터 상위 3개를 찾아 AI에게 전달합니다.\n다음 4편에서는 이 데이터를 기반으로 시니컬한 말투를 생성하는 \u0026lsquo;프롬프트 엔지니어링\u0026rsquo;에 대해 다뤄보겠습니다.\n","permalink":"https://chanyeols.com/posts/spring-ai-rag-simplevectorstore-ingestion/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e[Dev-Fortune] 시리즈 다시보기\u003c/strong\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-ollama-chatbot-planning/\"\u003e1편: 기획부터 스택 선정까지\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/ollama-spring-boot-local-llm-setup/\"\u003e2편: 로컬 LLM Ollama 연동\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"1-서론-ai는-어떻게-사주를-공부하는가\"\u003e1. 서론: AI는 어떻게 사주를 \u0026lsquo;공부\u0026rsquo;하는가?\u003c/h2\u003e\n\u003cp\u003eAI에게 새로운 지식을 가르치는 방법 중 가장 경제적이고 정확한 \u003cstrong\u003eRAG(Retrieval-Augmented Generation)\u003c/strong\u003e 방식을 살펴봅니다. 질문이 들어올 때마다 관련 내용을 찾아서 읽어주며 답변하게 하는 원리입니다.\u003c/p\u003e\n\u003ch2 id=\"2-데이터-주입-프로세스-data-ingestion\"\u003e2. 데이터 주입 프로세스 (Data Ingestion)\u003c/h2\u003e\n\u003cp\u003eJSON 파일이 어떻게 벡터화되어 메모리에 저장되는지 그 흐름을 도식화했습니다.\u003c/p\u003e\n\u003cdiv class=\"mermaid\"\u003e\nflowchart LR\n    A[(sajuAPI.json)] --\u003e B[DataLoader]\n    B --\u003e C[Text 정제: Key-Value형식]\n    C --\u003e D[Embedding Model]\n    D --\u003e E{SimpleVectorStore}\n    E --\u003e F[RAM Memory]\n\u003cpre\u003e\u003ccode\u003estyle F fill:#f96,stroke:#333,stroke-width:2px\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/div\u003e\n\u003ch2 id=\"3-simplevectorstore와-dataloader\"\u003e3. SimpleVectorStore와 DataLoader\u003c/h2\u003e\n\u003cp\u003e우리 프로젝트는 별도의 DB 없이 메모리 기반의 \u003ccode\u003eSimpleVectorStore\u003c/code\u003e를 사용합니다. \u003ccode\u003eUnidocuDataLoader\u003c/code\u003e는 서버 기동 시점에 JSON 데이터를 읽어 벡터로 변환하여 주입합니다.\u003c/p\u003e","title":"Spring AI RAG 구현하기: SimpleVectorStore로 전문 지식 데이터 주입 (3편)"},{"content":" [Dev-Fortune] 시리즈 다시보기\n1편: 기획부터 스택 선정까지 1. 서론: 왜 유료 API 대신 로컬 LLM인가? 이번 2편에서는 본격적인 구현의 첫 단추인 AI 모델 환경 구축을 다룹니다. 우리는 비용 제로, 데이터 보안, 완전한 통제를 위해 Ollama를 활용한 로컬 LLM 방식을 선택했습니다.\n2. 로컬 AI 서버 연동 구조 Spring Boot가 로컬에서 실행 중인 Ollama와 통신하는 물리적 구조입니다.\ngraph LR subgraph \"Local PC\" A[Spring Boot App] -- \"HTTP Post (11434)\" --\u003e B[Ollama Server] B -- \"Model Load\" --\u003e C[qwen2.5:3b] subgraph \u0026quot;Application.properties\u0026quot; D[Base-URL] E[Temperature: 0.4] F[Model Name] end D -.-\u0026gt; A E -.-\u0026gt; A end 3. Spring Boot 프로젝트 설정: application.properties application.properties 파일에 담긴 설정값들을 하나씩 뜯어보며 그 의미를 파헤쳐 보겠습니다.\nspring.ai.ollama.base-url=http://localhost:11434 spring.ai.ollama.chat.options.model=qwen2.5:3b spring.ai.ollama.chat.options.num-ctx=4096 spring.ai.ollama.chat.options.temperature=0.4 핵심 파라미터 분석 temperature=0.4: 사주라는 데이터에 기반하되, 개발자 특유의 시니컬한 변주를 주기 위한 최적의 밸런스입니다. num-ctx=4096: AI가 한 번에 기억할 수 있는 대화의 맥락 크기입니다. 4. Spring AI의 ChatClient 설정을 마쳤다면 코드에서는 ChatClient를 통해 아주 간편하게 AI와 대화할 수 있습니다.\n다음 3편에서는 메모리 기반 벡터 저장소(SimpleVectorStore)를 활용하여 사주 데이터를 AI에게 학습시키는 RAG 시스템 구축 과정을 다루겠습니다.\n","permalink":"https://chanyeols.com/posts/ollama-spring-boot-local-llm-setup/","summary":"\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e[Dev-Fortune] 시리즈 다시보기\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/spring-ai-ollama-chatbot-planning/\"\u003e1편: 기획부터 스택 선정까지\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/blockquote\u003e\n\u003ch2 id=\"1-서론-왜-유료-api-대신-로컬-llm인가\"\u003e1. 서론: 왜 유료 API 대신 로컬 LLM인가?\u003c/h2\u003e\n\u003cp\u003e이번 2편에서는 본격적인 구현의 첫 단추인 \u003cstrong\u003eAI 모델 환경 구축\u003c/strong\u003e을 다룹니다. 우리는 비용 제로, 데이터 보안, 완전한 통제를 위해 \u003cstrong\u003eOllama\u003c/strong\u003e를 활용한 로컬 LLM 방식을 선택했습니다.\u003c/p\u003e\n\u003ch2 id=\"2-로컬-ai-서버-연동-구조\"\u003e2. 로컬 AI 서버 연동 구조\u003c/h2\u003e\n\u003cp\u003eSpring Boot가 로컬에서 실행 중인 Ollama와 통신하는 물리적 구조입니다.\u003c/p\u003e\n\u003cdiv class=\"mermaid\"\u003e\ngraph LR\n    subgraph \"Local PC\"\n        A[Spring Boot App] -- \"HTTP Post (11434)\" --\u003e B[Ollama Server]\n        B -- \"Model Load\" --\u003e C[qwen2.5:3b]\n\u003cpre\u003e\u003ccode\u003e    subgraph \u0026quot;Application.properties\u0026quot;\n        D[Base-URL]\n        E[Temperature: 0.4]\n        F[Model Name]\n    end\n    D -.-\u0026gt; A\n    E -.-\u0026gt; A\nend\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/div\u003e\n\u003ch2 id=\"3-spring-boot-프로젝트-설정-applicationproperties\"\u003e3. Spring Boot 프로젝트 설정: application.properties\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003eapplication.properties\u003c/code\u003e 파일에 담긴 설정값들을 하나씩 뜯어보며 그 의미를 파헤쳐 보겠습니다.\u003c/p\u003e","title":"로컬 LLM Ollama와 Spring Boot 연동하기: 서버 설정과 성능 최적화 (2편)"},{"content":"1. 서론: 왜 예외 처리를 통일해야 하는가? API를 개발하다 보면 다양한 예외 상황이 발생합니다. 데이터가 없는 경우, 입력값이 잘못된 경우, 서버 내부 로직 오류 등이 대표적입니다. 이때 각 컨트롤러에서 try-catch로 예외를 개별 처리하면 다음과 같은 문제가 발생합니다.\n코드 중복: 비슷한 예외 처리 로직이 여러 컨트롤러에 반복됩니다. 응답 일관성 부족: 어떤 API는 JSON으로 에러를 주는데, 어떤 API는 HTML 에러 페이지를 주는 등 응답 형식이 제각각이 됩니다. 비즈니스 로직 집중도 저하: 예외 처리 코드가 섞여 있어 핵심 로직을 파악하기 힘듭니다. Spring은 이를 우아하게 해결할 수 있도록 @ControllerAdvice와 @ExceptionHandler를 제공합니다.\n2. 핵심 어노테이션 이해하기 2.1. @ExceptionHandler 특정 컨트롤러 내부에서 발생하는 특정 예외를 잡아 처리하는 메서드를 정의합니다. 해당 컨트롤러 안에서만 유효하다는 특징이 있습니다.\n2.2. @RestControllerAdvice (@ControllerAdvice) 여러 컨트롤러에서 발생하는 예외를 한곳에서 전역적으로 처리할 수 있게 해주는 \u0026ldquo;공통 관심사\u0026rdquo; 클래스입니다. @RestControllerAdvice는 @ResponseBody가 포함되어 있어 JSON 형태로 에러를 응답하기에 적합합니다.\n3. 실무적인 공통 예외 처리 구현 3.1. 에러 응답 규격 정의 (ErrorResponse) 모든 에러 응답은 동일한 구조를 가져야 클라이언트(프론트엔드)에서 처리하기 쉽습니다.\n@Getter @Builder public class ErrorResponse { private final LocalDateTime timestamp = LocalDateTime.now(); private final int status; private final String error; private final String code; private final String message; } 3.2. 전역 예외 처리기 구현 (GlobalExceptionHandler) @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { // 모든 비즈니스 예외 처리 @ExceptionHandler(BusinessException.class) protected ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleBusinessException(BusinessException e) { log.error(\u0026#34;handleBusinessException\u0026#34;, e); ErrorCode errorCode = e.getErrorCode(); ErrorResponse response = ErrorResponse.builder() .status(errorCode.getStatus()) .error(errorCode.name()) .code(errorCode.getCode()) .message(e.getMessage()) .build(); return new ResponseEntity\u0026lt;\u0026gt;(response, HttpStatus.valueOf(errorCode.getStatus())); } // 그 외 예상치 못한 모든 예외 처리 @ExceptionHandler(Exception.class) protected ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleException(Exception e) { log.error(\u0026#34;handleException\u0026#34;, e); ErrorResponse response = ErrorResponse.builder() .status(HttpStatus.INTERNAL_SERVER_ERROR.value()) .error(\u0026#34;INTERNAL_SERVER_ERROR\u0026#34;) .code(\u0026#34;COMMON-001\u0026#34;) .message(e.getMessage()) .build(); return new ResponseEntity\u0026lt;\u0026gt;(response, HttpStatus.INTERNAL_SERVER_ERROR); } } 4. 사용자 정의 예외(Custom Exception) 활용 단순히 RuntimeException을 던지기보다, 도메인의 의미를 담은 사용자 정의 예외를 만드는 것이 좋습니다.\n@Getter public class BusinessException extends RuntimeException { private final ErrorCode errorCode; public BusinessException(String message, ErrorCode errorCode) { super(message); this.errorCode = errorCode; } } // 예외 상황에 따른 코드 정의 @Getter public enum ErrorCode { USER_NOT_FOUND(404, \u0026#34;U001\u0026#34;, \u0026#34;사용자를 찾을 수 없습니다.\u0026#34;), INVALID_INPUT_VALUE(400, \u0026#34;C001\u0026#34;, \u0026#34;잘못된 입력값입니다.\u0026#34;); private final int status; private final String code; private final String message; ErrorCode(int status, String code, String message) { this.status = status; this.code = code; this.message = message; } } 5. 결론: 일관된 에러 응답의 가치 공통 예외 처리를 구축하면 개발자는 비즈니스 로직에서 throw new UserNotFoundException()처럼 예외를 던지기만 하면 됩니다. 나머지는 @RestControllerAdvice가 알아서 처리하여 일관된 JSON 응답을 클라이언트에 전달합니다.\n이러한 구조는 코드의 가독성을 높일 뿐만 아니라, 프론트엔드와의 협업 효율성을 극대화하는 핵심적인 설계 패턴입니다. 지금 바로 프로젝트의 예외 처리 로직을 리팩토링해 보세요!\n","permalink":"https://chanyeols.com/posts/spring-boot-exception-handling/","summary":"\u003ch2 id=\"1-서론-왜-예외-처리를-통일해야-하는가\"\u003e1. 서론: 왜 예외 처리를 통일해야 하는가?\u003c/h2\u003e\n\u003cp\u003eAPI를 개발하다 보면 다양한 예외 상황이 발생합니다. 데이터가 없는 경우, 입력값이 잘못된 경우, 서버 내부 로직 오류 등이 대표적입니다. 이때 각 컨트롤러에서 \u003ccode\u003etry-catch\u003c/code\u003e로 예외를 개별 처리하면 다음과 같은 문제가 발생합니다.\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e코드 중복\u003c/strong\u003e: 비슷한 예외 처리 로직이 여러 컨트롤러에 반복됩니다.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e응답 일관성 부족\u003c/strong\u003e: 어떤 API는 JSON으로 에러를 주는데, 어떤 API는 HTML 에러 페이지를 주는 등 응답 형식이 제각각이 됩니다.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e비즈니스 로직 집중도 저하\u003c/strong\u003e: 예외 처리 코드가 섞여 있어 핵심 로직을 파악하기 힘듭니다.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eSpring은 이를 우아하게 해결할 수 있도록 \u003cstrong\u003e@ControllerAdvice\u003c/strong\u003e와 \u003cstrong\u003e@ExceptionHandler\u003c/strong\u003e를 제공합니다.\u003c/p\u003e","title":"Spring Boot 예외 처리 통일하기 — @ControllerAdvice와 @ExceptionHandler"},{"content":"1. 프롤로그: 왜 \u0026lsquo;AI 개발자\u0026rsquo; 사주인가? 전통적인 사주 풀이는 어렵고 따분합니다. \u0026ldquo;올해는 물의 기운이 강하니\u0026hellip;\u0026rdquo; 같은 말은 현대의 개발자들에게는 다소 와닿지 않죠. 하지만 만약 **\u0026ldquo;실리콘밸리 출신의 천재 개발자\u0026rdquo;**가 당신의 인생을 코드로 보고 \u0026lsquo;디버깅\u0026rsquo;해준다면 어떨까요?\n이 프로젝트는 바로 그 엉뚱한 상상에서 시작되었습니다. 감정 섞인 위로 대신, 건조하고 시니컬한 말투로 당신의 사주를 \u0026ldquo;안티 패턴\u0026quot;과 \u0026ldquo;배드 해빗(Bad Habit)\u0026ldquo;으로 분석해 주는 챗봇, **\u0026lsquo;Dev-Fortune\u0026rsquo;**입니다.\n2. 프로젝트 시스템 구조도 전체적인 데이터 흐름과 기술 스택을 한눈에 살펴보겠습니다.\ngraph TD A[사용자 고민 입력] --\u003e B[Spring Boot Application] subgraph \"Backend Stack\" B --\u003e C{Spring AI} C --\u003e D[WebFlux/Streaming] C --\u003e E[SimpleVectorStore] end subgraph \"AI Engine\" C -- HTTP:11434 --- F[Ollama: qwen2.5] end subgraph \"Data Source\" G[(sajuAPI.json)] --\u003e E end B --\u003e H[시니컬한 개발자 사주 답변] 3. 기술 스택 (The Stack) Framework: Spring Boot 3.x AI Library: Spring AI LLM: Ollama (qwen2.5:3b) Vector DB: SimpleVectorStore Data Source: JSON 기반의 사주 풀이 데이터 4. 왜 Spring AI와 RAG인가? 일반적인 모델은 사주에 대한 지식이 파편화되어 있거나, 우리가 원하는 특유의 \u0026ldquo;개발자 스타일\u0026quot;로 대답하도록 통제하기 어렵습니다. 이를 해결하기 위해 우리는 RAG(Retrieval-Augmented Generation) 기법을 사용하여 전문 사주 데이터를 미리 벡터화해서 저장해 둡니다.\n다음 편에서는 Ollama를 이용해 내 컴퓨터에 로컬 AI 환경을 구축하고 Spring Boot와 연결하는 방법을 알아보겠습니다.\n","permalink":"https://chanyeols.com/posts/spring-ai-ollama-chatbot-planning/","summary":"\u003ch2 id=\"1-프롤로그-왜-ai-개발자-사주인가\"\u003e1. 프롤로그: 왜 \u0026lsquo;AI 개발자\u0026rsquo; 사주인가?\u003c/h2\u003e\n\u003cp\u003e전통적인 사주 풀이는 어렵고 따분합니다. \u0026ldquo;올해는 물의 기운이 강하니\u0026hellip;\u0026rdquo; 같은 말은 현대의 개발자들에게는 다소 와닿지 않죠. 하지만 만약 **\u0026ldquo;실리콘밸리 출신의 천재 개발자\u0026rdquo;**가 당신의 인생을 코드로 보고 \u0026lsquo;디버깅\u0026rsquo;해준다면 어떨까요?\u003c/p\u003e\n\u003cp\u003e이 프로젝트는 바로 그 엉뚱한 상상에서 시작되었습니다. 감정 섞인 위로 대신, 건조하고 시니컬한 말투로 당신의 사주를 \u0026ldquo;안티 패턴\u0026quot;과 \u0026ldquo;배드 해빗(Bad Habit)\u0026ldquo;으로 분석해 주는 챗봇, **\u0026lsquo;Dev-Fortune\u0026rsquo;**입니다.\u003c/p\u003e\n\u003ch2 id=\"2-프로젝트-시스템-구조도\"\u003e2. 프로젝트 시스템 구조도\u003c/h2\u003e\n\u003cp\u003e전체적인 데이터 흐름과 기술 스택을 한눈에 살펴보겠습니다.\u003c/p\u003e","title":"Spring AI와 Ollama로 만드는 AI 개발자 사주 챗봇: 기획부터 스택 선정까지 (1편)"},{"content":"1. 서론: 명령형에서 선언형으로의 전환 Java 8에서 도입된 Stream API는 자바 프로그래밍 패러다임을 획기적으로 바꾸어 놓았습니다. 기존의 for, while 루프를 사용한 명령형(Imperative) 방식은 \u0026ldquo;어떻게(How)\u0026rdquo; 동작하는지에 집중했다면, 스트림은 선언형(Declarative) 방식으로 \u0026ldquo;무엇을(What)\u0026rdquo; 할 것인지에 집중합니다.\n스트림을 사용하면 코드의 가독성이 높아지고, 병렬 처리를 쉽게 적용할 수 있으며, 복잡한 데이터 처리를 간결한 체이닝으로 해결할 수 있습니다. 이번 가이드에서는 가장 핵심적인 연산인 filter, map, reduce를 중심으로 실무 활용법을 알아봅니다.\n2. 스트림의 핵심 연산 3종 세트 2.1. filter: 조건에 맞는 데이터 선별 filter는 스트림의 요소 중 특정 조건(Predicate)을 만족하는 요소만 남기는 중간 연산입니다.\nList\u0026lt;String\u0026gt; names = Arrays.asList(\u0026#34;Kim\u0026#34;, \u0026#34;Lee\u0026#34;, \u0026#34;Park\u0026#34;, \u0026#34;Choi\u0026#34;, \u0026#34;Kang\u0026#34;); // 이름이 \u0026#39;K\u0026#39;로 시작하는 요소만 필터링 List\u0026lt;String\u0026gt; filteredNames = names.stream() .filter(name -\u0026gt; name.startsWith(\u0026#34;K\u0026#34;)) .collect(Collectors.toList()); // 결과: [Kim, Kang] 2.2. map: 데이터 변환 및 추출 map은 스트림의 각 요소를 다른 형태의 데이터로 변환하는 중간 연산입니다. 객체에서 특정 필드를 추출하거나, 데이터를 가공할 때 주로 사용합니다.\nList\u0026lt;User\u0026gt; users = Arrays.asList( new User(\u0026#34;Alice\u0026#34;, 25), new User(\u0026#34;Bob\u0026#34;, 30), new User(\u0026#34;Charlie\u0026#34;, 22) ); // 사용자 객체 리스트에서 이름(String)만 추출하여 대문자로 변환 List\u0026lt;String\u0026gt; upperNames = users.stream() .map(user -\u0026gt; user.getName().toUpperCase()) .collect(Collectors.toList()); // 결과: [ALICE, BOB, CHARLIE] 2.3. reduce: 데이터를 하나로 통합 reduce는 스트림의 모든 요소를 소모하며 하나의 결과값(Optional 또는 단일 값)을 산출하는 최종 연산입니다. 합계, 최댓값, 문자열 결합 등에 사용됩니다.\nList\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 3, 4, 5); // 모든 숫자의 합계 계산 (초기값 0 제공) int sum = numbers.stream() .reduce(0, (a, b) -\u0026gt; a + b); // 결과: 15 3. 실무 시나리오: 복합 연산 체이닝 실제 업무에서는 이 연산들을 조합하여 강력한 데이터 처리 파이프라인을 구축합니다.\n요구사항: \u0026ldquo;20대 사용자들의 점수 평균을 구하라.\u0026rdquo;\ndouble averageScore = users.stream() .filter(u -\u0026gt; u.getAge() \u0026gt;= 20 \u0026amp;\u0026amp; u.getAge() \u0026lt; 30) // 20대 필터링 .mapToInt(User::getScore) // 점수 추출 (IntStream 변환) .average() // 평균 계산 .orElse(0.0); // 데이터 없을 시 기본값 4. 스트림 사용 시 주의사항 및 팁 4.1. 지연 연산 (Lazy Evaluation) 스트림의 중간 연산(filter, map 등)은 최종 연산(collect, forEach, reduce 등)이 호출되기 전까지는 실제로 실행되지 않습니다. 이는 불필요한 연산을 방지하고 성능을 최적화하는 데 도움을 줍니다.\n4.2. 가독성 vs 성능 스트림은 가독성이 뛰어나지만, 매우 단순한 루프나 기본형(primitive) 배열 작업에서는 전통적인 for 루프보다 약간의 오버헤드가 발생할 수 있습니다. 성능이 극도로 중요한 크리티컬한 구간이 아니라면 유지보수성을 위해 스트림을 권장합니다.\n4.3. 상태를 가지는 람다 지양 스트림 내부에서 외부 변수의 상태를 변경하는 작업은 병렬 스트림 실행 시 예측 불가능한 결과를 초래할 수 있습니다. 스트림 연산은 순수 함수(Pure Function)의 성격을 유지하는 것이 안전합니다.\n5. 결론: 더 깔끔한 코드를 위하여 Java Stream API는 이제 자바 개발자에게 선택이 아닌 필수입니다. filter, map, reduce의 개념만 명확히 이해해도 대부분의 컬렉션 처리를 우아하게 해결할 수 있습니다. 오늘부터 복잡한 루프를 스트림 파이프라인으로 리팩토링해 보시는 건 어떨까요?\n","permalink":"https://chanyeols.com/posts/java-stream-api-guide/","summary":"\u003ch2 id=\"1-서론-명령형에서-선언형으로의-전환\"\u003e1. 서론: 명령형에서 선언형으로의 전환\u003c/h2\u003e\n\u003cp\u003eJava 8에서 도입된 \u003cstrong\u003eStream API\u003c/strong\u003e는 자바 프로그래밍 패러다임을 획기적으로 바꾸어 놓았습니다. 기존의 \u003ccode\u003efor\u003c/code\u003e, \u003ccode\u003ewhile\u003c/code\u003e 루프를 사용한 \u003cstrong\u003e명령형(Imperative)\u003c/strong\u003e 방식은 \u0026ldquo;어떻게(How)\u0026rdquo; 동작하는지에 집중했다면, 스트림은 \u003cstrong\u003e선언형(Declarative)\u003c/strong\u003e 방식으로 \u0026ldquo;무엇을(What)\u0026rdquo; 할 것인지에 집중합니다.\u003c/p\u003e\n\u003cp\u003e스트림을 사용하면 코드의 가독성이 높아지고, 병렬 처리를 쉽게 적용할 수 있으며, 복잡한 데이터 처리를 간결한 체이닝으로 해결할 수 있습니다. 이번 가이드에서는 가장 핵심적인 연산인 \u003ccode\u003efilter\u003c/code\u003e, \u003ccode\u003emap\u003c/code\u003e, \u003ccode\u003ereduce\u003c/code\u003e를 중심으로 실무 활용법을 알아봅니다.\u003c/p\u003e\n\u003ch2 id=\"2-스트림의-핵심-연산-3종-세트\"\u003e2. 스트림의 핵심 연산 3종 세트\u003c/h2\u003e\n\u003ch3 id=\"21-filter-조건에-맞는-데이터-선별\"\u003e2.1. filter: 조건에 맞는 데이터 선별\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003efilter\u003c/code\u003e는 스트림의 요소 중 특정 조건(Predicate)을 만족하는 요소만 남기는 중간 연산입니다.\u003c/p\u003e","title":"Java Stream API 실전 가이드 — filter, map, reduce 완벽 정리"},{"content":"서론: 왜 마이크로서비스 아키텍처(MSA)인가? 거대한 하나의 애플리케이션으로 모든 기능을 처리하던 모놀리식 구조는 서비스 규모가 커질수록 유지보수와 확장에 한계를 드러냅니다. 이러한 문제를 해결하기 위해 등장한 **마이크로서비스 아키텍처(MSA)**는 서비스를 독립적인 작은 단위로 쪼개어 개발하고 배포하는 방식입니다. 각 서비스가 독립적으로 운영되므로 특정 기능의 장애가 전체 시스템으로 확산되는 것을 방지할 수 있습니다. 오늘날 수많은 기업들이 비즈니스의 유연성을 확보하기 위해 **마이크로서비스 아키텍처(MSA)**를 도입하고 있습니다.\n1. 마이크로서비스 아키텍처(MSA)의 설계 원칙 **마이크로서비스 아키텍처(MSA)**를 성공적으로 구축하기 위해서는 \u0026lsquo;느슨한 결합(Loose Coupling)\u0026lsquo;과 \u0026lsquo;높은 응집도(High Cohesion)\u0026lsquo;가 필수적입니다. 각 서비스는 자신만의 데이터베이스를 가지며, 다른 서비스의 데이터에 직접 접근하지 않고 API를 통해서만 소통해야 합니다. 이러한 원칙을 통해 **마이크로서비스 아키텍처(MSA)**는 각 팀이 서로 간섭받지 않고 독립적으로 기술 스택을 선택하고 배포 주기를 관리할 수 있는 환경을 제공합니다. 비즈니스 영역을 기준으로 서비스를 나누는 \u0026lsquo;도메인 주도 설계(DDD)\u0026lsquo;는 마이크로서비스 아키텍처(MSA) 설계의 핵심적인 기반이 됩니다.\n2. API 게이트웨이(API Gateway)의 역할 수많은 서비스로 분산된 환경에서 클라이언트가 각 서비스의 주소를 직접 아는 것은 비효율적입니다. 이때 **마이크로서비스 아키텍처(MSA)**의 입구 역할을 하는 것이 바로 API 게이트웨이입니다. API 게이트웨이는 클라이언트의 요청을 받아 적절한 서비스로 라우팅하며, 인증, 인가, 속도 제한 등의 공통 기능을 통합 관리합니다. 이를 통해 마이크로서비스 아키텍처(MSA) 내부의 복잡성을 외부로부터 숨기고 보안성을 높일 수 있습니다. 또한, 여러 서비스의 데이터를 조합하여 응답하는 \u0026lsquo;API 조합\u0026rsquo; 기능 역시 API 게이트웨이가 수행하는 중요한 업무 중 하나입니다.\n3. 서비스 간 통신 방식: REST vs gRPC 마이크로서비스 아키텍처(MSA) 내부에서 서비스끼리 데이터를 주고받는 방식은 크게 동기 방식과 비동기 방식으로 나뉩니다. 가장 보편적인 REST 방식은 HTTP/JSON 기반으로 가독성이 좋고 표준화되어 있어 마이크로서비스 아키텍처(MSA) 초기 도입 시 많이 사용됩니다. 하지만 성능 최적화가 필요한 환경에서는 HTTP/2 기반의 gRPC가 선호됩니다. gRPC는 프로토콜 버퍼를 사용하여 데이터 크기를 줄이고 통신 속도를 비약적으로 향상시켜 **마이크로서비스 아키텍처(MSA)**의 네트워크 오버헤드를 줄여줍니다.\n4. 비동기 통신과 메시지 브로커 동기 방식의 호출은 서비스 간 의존성을 높여 장애 전파의 위험을 초래할 수 있습니다. 이를 방지하기 위해 **마이크로서비스 아키텍처(MSA)**에서는 카프카(Kafka)나 래빗MQ(RabbitMQ)와 같은 메시지 브로커를 활용한 비동기 통신을 적극적으로 권장합니다. 이벤트 기반 아키텍처를 도입하면 한 서비스의 상태 변화를 이벤트로 발행하고, 이를 필요로 하는 다른 서비스들이 소비하는 방식으로 동작합니다. 이러한 비동기 구조는 **마이크로서비스 아키텍처(MSA)**의 확장성을 극대화하며 시스템의 탄력성을 높여주는 핵심 요소입니다.\n5. 데이터 일관성 관리: 사가(Saga) 패턴 데이터베이스가 분산되어 있는 마이크로서비스 아키텍처(MSA) 환경에서는 전통적인 트랜잭션 방식(ACID)을 적용하기 어렵습니다. 이 문제를 해결하기 위해 여러 서비스에 걸친 비즈니스 로직을 하나로 묶어 관리하는 \u0026lsquo;사가(Saga) 패턴\u0026rsquo;이 사용됩니다. 사가 패턴은 각 단계별로 로컬 트랜잭션을 수행하고, 실패 시 이전에 성공한 작업을 취소하는 \u0026lsquo;보상 트랜잭션\u0026rsquo;을 실행하여 데이터의 최종적인 일관성을 보장합니다. **마이크로서비스 아키텍처(MSA)**에서 데이터 무결성을 유지하기 위한 가장 정교하고도 강력한 설계 기법이라 할 수 있습니다.\n6. 서비스 디스커버리(Service Discovery) 클라우드 환경에서 서비스의 IP 주소는 수시로 변하기 때문에 이를 자동으로 감지하는 메커니즘이 필요합니다. 서비스 디스커버리는 마이크로서비스 아키텍처(MSA) 내의 각 서비스 인스턴스가 자신의 위치를 등록하고, 다른 서비스의 위치를 조회할 수 있게 돕습니다. 넷플릭스 에우레카(Eureka)나 쿠버네티스의 서비스 오브젝트가 대표적인 예시입니다. 동적으로 변화하는 인프라 환경에서 **마이크로서비스 아키텍처(MSA)**가 안정적으로 운영될 수 있도록 돕는 핵심 인프라 서비스입니다.\n7. 관찰 가능성(Observability)과 모니터링 서비스가 잘게 쪼개질수록 문제 발생 시 원인을 파악하기가 매우 힘들어집니다. 따라서 **마이크로서비스 아키텍처(MSA)**에서는 분산 추적(Distributed Tracing), 로그 수집, 메트릭 모니터링이 필수적입니다. 자가(Jaeger)나 집킨(Zipkin)을 사용하여 요청이 여러 서비스를 거쳐가는 과정을 시각화함으로써 **마이크로서비스 아키텍처(MSA)**의 병목 구간을 찾아낼 수 있습니다. 이러한 관찰 가능성을 확보해야만 복잡한 시스템 내부를 투명하게 들여다보고 빠르게 장애에 대응할 수 있습니다.\n결론: 마이크로서비스 아키텍처(MSA)의 미래 **마이크로서비스 아키텍처(MSA)**는 분명 강력한 도구이지만, 운영 복잡도가 높고 분산 시스템 특유의 어려운 문제들을 동반합니다. 무조건적인 도입보다는 비즈니스의 성장 단계와 조직의 역량을 고려하여 적절한 시점에 도입하는 지혜가 필요합니다. 하지만 대규모 트래픽을 처리하고 빠른 배포 주기를 유지해야 하는 환경에서 **마이크로서비스 아키텍처(MSA)**는 거부할 수 없는 대세임이 분명합니다.\n이 글을 통해 **마이크로서비스 아키텍처(MSA)**의 기본 원리부터 핵심 패턴들까지 살펴보았습니다. 앞으로도 **마이크로서비스 아키텍처(MSA)**를 실제 서비스에 적용하며 얻은 경험과 노하우를 공유하여, 여러분의 아키텍처 설계에 실질적인 도움이 되도록 노력하겠습니다. 성공적인 시스템 구축을 위해 **마이크로서비스 아키텍처(MSA)**의 깊은 바다로 도전해 보시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/msa-patterns-communication/","summary":"\u003ch3 id=\"서론-왜-마이크로서비스-아키텍처msa인가\"\u003e서론: 왜 마이크로서비스 아키텍처(MSA)인가?\u003c/h3\u003e\n\u003cp\u003e거대한 하나의 애플리케이션으로 모든 기능을 처리하던 모놀리식 구조는 서비스 규모가 커질수록 유지보수와 확장에 한계를 드러냅니다. 이러한 문제를 해결하기 위해 등장한 **마이크로서비스 아키텍처(MSA)**는 서비스를 독립적인 작은 단위로 쪼개어 개발하고 배포하는 방식입니다. 각 서비스가 독립적으로 운영되므로 특정 기능의 장애가 전체 시스템으로 확산되는 것을 방지할 수 있습니다. 오늘날 수많은 기업들이 비즈니스의 유연성을 확보하기 위해 **마이크로서비스 아키텍처(MSA)**를 도입하고 있습니다.\u003c/p\u003e\n\u003ch3 id=\"1-마이크로서비스-아키텍처msa의-설계-원칙\"\u003e1. 마이크로서비스 아키텍처(MSA)의 설계 원칙\u003c/h3\u003e\n\u003cp\u003e**마이크로서비스 아키텍처(MSA)**를 성공적으로 구축하기 위해서는 \u0026lsquo;느슨한 결합(Loose Coupling)\u0026lsquo;과 \u0026lsquo;높은 응집도(High Cohesion)\u0026lsquo;가 필수적입니다. 각 서비스는 자신만의 데이터베이스를 가지며, 다른 서비스의 데이터에 직접 접근하지 않고 API를 통해서만 소통해야 합니다. 이러한 원칙을 통해 **마이크로서비스 아키텍처(MSA)**는 각 팀이 서로 간섭받지 않고 독립적으로 기술 스택을 선택하고 배포 주기를 관리할 수 있는 환경을 제공합니다. 비즈니스 영역을 기준으로 서비스를 나누는 \u0026lsquo;도메인 주도 설계(DDD)\u0026lsquo;는 \u003cstrong\u003e마이크로서비스 아키텍처(MSA)\u003c/strong\u003e 설계의 핵심적인 기반이 됩니다.\u003c/p\u003e","title":"마이크로서비스 아키텍처(MSA)의 핵심 패턴과 통신 방식"},{"content":"서론: 왜 쿠버네티스(Kubernetes)인가? 현대적인 클라우드 네이티브 환경에서 컨테이너 기술은 필수적인 요소가 되었습니다. 하지만 수많은 컨테이너를 수동으로 관리하는 것은 불가능에 가깝습니다. 이때 등장한 **쿠버네티스(Kubernetes)**는 컨테이너화된 애플리케이션의 배포, 확장 및 관리를 자동화해주는 오픈소스 플랫폼입니다. 구글의 운영 노하우로 탄생한 **쿠버네티스(Kubernetes)**는 현재 전 세계 기업들의 표준 인프라로 자리 잡았으며, 효율적인 서버 관리를 위한 필수 도구입니다.\n1. 쿠버네티스(Kubernetes)의 전체적인 구조 쿠버네티스(Kubernetes) 클러스터는 크게 두 부분으로 나뉩니다: 전체 클러스터를 관리하는 \u0026lsquo;컨트롤 플레인(Control Plane)\u0026lsquo;과 실제 애플리케이션이 구동되는 \u0026lsquo;노드(Node)\u0026lsquo;입니다. **쿠버네티스(Kubernetes)**는 이 구조를 통해 높은 가용성과 확장성을 보장하며, 복잡한 분산 시스템을 하나의 거대한 컴퓨터처럼 사용할 수 있게 해줍니다. 사용자가 원하는 상태를 선언적으로 정의하면, **쿠버네티스(Kubernetes)**는 실제 상태를 그에 맞추기 위해 지능적으로 동작합니다.\n2. 컨트롤 플레인(Control Plane): 클러스터의 두뇌 컨트롤 플레인은 쿠버네티스(Kubernetes) 클러스터의 의사결정을 담당하는 핵심 구성 요소들의 집합입니다. 가장 먼저 \u0026lsquo;kube-apiserver\u0026rsquo;는 모든 요청의 통로 역할을 하며, 클러스터의 상태 변화를 관리합니다. 또한, \u0026rsquo;etcd\u0026rsquo;라는 분산 저장소는 쿠버네티스(Kubernetes) 클러스터의 모든 설정 데이터를 안전하게 보관하는 중추적인 역할을 수행하며, 시스템의 일관성을 유지합니다.\n\u0026lsquo;kube-scheduler\u0026rsquo;는 새로 생성된 포드가 어떤 노드에서 실행될지 결정하며 리소스 최적화를 돕는 **쿠버네티스(Kubernetes)**의 지능적인 엔진입니다. 그리고 \u0026lsquo;kube-controller-manager\u0026rsquo;는 클러스터의 상태를 모니터링하며 원하는 상태와 현재 상태를 일치시킵니다. 이러한 요소들이 유기적으로 결합되어 **쿠버네티스(Kubernetes)**의 자동화된 관리가 가능해집니다.\n3. 노드(Node) 컴포넌트: 실행의 주체 실제 워크로드가 실행되는 서버인 노드에는 \u0026lsquo;kubelet\u0026rsquo;이라는 에이전트가 상주합니다. kubelet은 컨트롤 플레인과 통신하며 컨테이너가 정상적으로 동작하는지 확인하는 **쿠버네티스(Kubernetes)**의 현장 감독관입니다. 또한 \u0026lsquo;kube-proxy\u0026rsquo;는 각 노드에서 네트워크 규칙을 관리하여 트래픽이 올바른 목적지로 전달되도록 보장하는 **쿠버네티스(Kubernetes)**의 네트워크 관리자 역할을 수행합니다.\n이러한 노드 컴포넌트들은 쿠버네티스(Kubernetes) 클러스터에 참여하여 실제 애플리케이션이 안정적으로 서비스될 수 있도록 지원합니다. 특히 다양한 컨테이너 런타임을 지원하는 유연성을 통해 **쿠버네티스(Kubernetes)**는 특정 기술에 종속되지 않는 범용적인 환경을 구축할 수 있게 해줍니다.\n4. 쿠버네티스(Kubernetes)의 핵심 오브젝트 이해하기 **쿠버네티스(Kubernetes)**를 다루기 위해 반드시 알아야 할 최소 단위는 \u0026lsquo;포드(Pod)\u0026lsquo;입니다. 포드는 하나 이상의 컨테이너를 포함하며 공유 네트워크와 저장소를 가집니다. **쿠버네티스(Kubernetes)**는 이 포드를 직접 관리하기보다는 \u0026lsquo;디플로이먼트(Deployment)\u0026lsquo;를 통해 선언적으로 배포하고 업데이트 전략을 관리하여 운영의 편의성을 극대화합니다.\n서비스(Service)는 여러 포드에 걸쳐 부하를 분산하고 고정된 접근 지점을 제공하는 **쿠버네티스(Kubernetes)**의 네트워킹 핵심 오브젝트입니다. 또한 컨피그맵(ConfigMap)과 시크릿(Secret)은 설정 데이터와 민감 정보를 애플리케이션 코드와 분리하여 관리할 수 있게 해주는 **쿠버네티스(Kubernetes)**의 유용한 기능입니다. 네임스페이스(Namespace)를 활용하면 하나의 클러스터 내에서 팀별로 자원을 효율적으로 분리할 수 있습니다.\n5. 쿠버네티스(Kubernetes)의 강력한 기능: 자가 치유와 확장 **쿠버네티스(Kubernetes)**의 가장 큰 장점 중 하나는 \u0026lsquo;자가 치유(Self-healing)\u0026rsquo; 능력입니다. 특정 컨테이너에 오류가 발생하면 자동으로 재시작하거나, 장애가 발생한 노드의 포드를 즉시 재생성합니다. 이러한 자동화 덕분에 운영자는 **쿠버네티스(Kubernetes)**를 통해 서비스 중단 시간을 최소화하고 안정적인 시스템 운영이 가능해집니다.\n또한 \u0026lsquo;수평적 확장(Horizontal Scaling)\u0026rsquo; 역시 **쿠버네티스(Kubernetes)**의 강력한 기능입니다. HPA를 통해 CPU나 메모리 사용량에 따라 포드 개수를 자동으로 조절하며 자원 효율성을 극대화합니다. 트래픽 변동에 탄력적으로 대응할 수 있는 **쿠버네티스(Kubernetes)**의 특징은 현대적인 마이크로서비스 아키텍처 구축에 최적의 환경을 제공합니다.\n결론: 쿠버네티스(Kubernetes)와 함께하는 미래 클라우드 컴퓨팅 시대에 **쿠버네티스(Kubernetes)**를 이해하고 활용하는 능력은 개발자와 엔지니어 모두에게 필수적인 역량이 되었습니다. 초기 학습 곡선은 다소 높을 수 있지만, 한 번 익혀두면 얻을 수 있는 운영 효율성과 안정성은 매우 큽니다. **쿠버네티스(Kubernetes)**의 핵심 원리를 파악함으로써 더욱 견고한 서버 인프라를 구축할 수 있습니다.\n이번 글을 통해 **쿠버네티스(Kubernetes)**의 구조와 핵심 오브젝트, 그리고 자동화 기능까지 살펴보았습니다. 기초를 탄탄히 다지는 것이 실무 역량을 키우는 가장 빠른 길입니다. 앞으로도 **쿠버네티스(Kubernetes)**를 활용한 다양한 실무 팁을 공유하며, 여러분의 인프라가 더욱 강력해질 수 있도록 돕겠습니다. **쿠버네티스(Kubernetes)**와 함께 클라우드 네이티브 환경으로의 여정을 지금 시작해 보세요.\n","permalink":"https://chanyeols.com/posts/kubernetes-architecture-concepts/","summary":"\u003ch3 id=\"서론-왜-쿠버네티스kubernetes인가\"\u003e서론: 왜 쿠버네티스(Kubernetes)인가?\u003c/h3\u003e\n\u003cp\u003e현대적인 클라우드 네이티브 환경에서 컨테이너 기술은 필수적인 요소가 되었습니다. 하지만 수많은 컨테이너를 수동으로 관리하는 것은 불가능에 가깝습니다. 이때 등장한 **쿠버네티스(Kubernetes)**는 컨테이너화된 애플리케이션의 배포, 확장 및 관리를 자동화해주는 오픈소스 플랫폼입니다. 구글의 운영 노하우로 탄생한 **쿠버네티스(Kubernetes)**는 현재 전 세계 기업들의 표준 인프라로 자리 잡았으며, 효율적인 서버 관리를 위한 필수 도구입니다.\u003c/p\u003e\n\u003ch3 id=\"1-쿠버네티스kubernetes의-전체적인-구조\"\u003e1. 쿠버네티스(Kubernetes)의 전체적인 구조\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e쿠버네티스(Kubernetes)\u003c/strong\u003e 클러스터는 크게 두 부분으로 나뉩니다: 전체 클러스터를 관리하는 \u0026lsquo;컨트롤 플레인(Control Plane)\u0026lsquo;과 실제 애플리케이션이 구동되는 \u0026lsquo;노드(Node)\u0026lsquo;입니다. **쿠버네티스(Kubernetes)**는 이 구조를 통해 높은 가용성과 확장성을 보장하며, 복잡한 분산 시스템을 하나의 거대한 컴퓨터처럼 사용할 수 있게 해줍니다. 사용자가 원하는 상태를 선언적으로 정의하면, **쿠버네티스(Kubernetes)**는 실제 상태를 그에 맞추기 위해 지능적으로 동작합니다.\u003c/p\u003e","title":"쿠버네티스(Kubernetes) 클러스터 구조와 핵심 개념 완벽 정리"},{"content":"OpenAI Sora, ChatGPT에 완전 통합: 누구나 영화 감독이 되는 시대 드디어 기다리던 순간이 왔습니다. OpenAI가 자사의 초고화질 비디오 생성 모델인 Sora를 ChatGPT 인터페이스 내에 완전 통합하여 정식 출시했습니다. 이제 유료 사용자들은 텍스트 대화만으로 복잡한 영상을 만들고 편집할 수 있게 되었습니다.\n1. 대화로 만드는 1분 분량의 시네마틱 영상 기존의 Sora가 소수 전문가들에게만 제한적으로 공개되었다면, 이번 통합으로 일반 사용자들도 다음과 같은 작업이 가능해졌습니다.\n프롬프트 기반 영상 생성: \u0026ldquo;비 오는 서울 거리의 야경을 사이버펑크 스타일로 보여줘\u0026quot;와 같은 명령어로 즉시 영상 생성. 실시간 편집: 생성된 영상에 대해 \u0026ldquo;카메라 앵글을 더 낮춰줘\u0026rdquo; 또는 \u0026ldquo;배경 음악을 재즈로 바꿔줘\u0026quot;라고 요청 가능. 2. 멀티모달 능력의 정점 Sora의 ChatGPT 통합은 텍스트, 이미지, 오디오를 넘어 비디오까지 아우르는 완벽한 멀티모달 시스템의 완성을 의미합니다. 특히 영상 내 물체의 물리적 움직임이 더욱 정교해졌다는 평가를 받고 있습니다.\n3. 크리에이티브 산업에 미칠 영향 유튜브 크리에이터, 마케터, 영화 제작자들에게는 새로운 기회의 장이 열렸습니다. 제작 비용과 시간의 획기적인 단축이 예상되지만, 딥페이크나 저작권 문제에 대한 우려도 여전합니다.\n🌐 참고 출처 (Sources) PCMag: \u0026ldquo;OpenAI integrates Sora video generation directly into ChatGPT\u0026rdquo; (2026.03.13) OpenAI Official Blog: \u0026ldquo;Sora is now available to ChatGPT Plus and Enterprise users\u0026rdquo; ","permalink":"https://chanyeols.com/posts/openai-sora-chatgpt/","summary":"\u003ch1 id=\"openai-sora-chatgpt에-완전-통합-누구나-영화-감독이-되는-시대\"\u003eOpenAI Sora, ChatGPT에 완전 통합: 누구나 영화 감독이 되는 시대\u003c/h1\u003e\n\u003cp\u003e드디어 기다리던 순간이 왔습니다. OpenAI가 자사의 초고화질 비디오 생성 모델인 \u003cstrong\u003eSora\u003c/strong\u003e를 ChatGPT 인터페이스 내에 완전 통합하여 정식 출시했습니다. 이제 유료 사용자들은 텍스트 대화만으로 복잡한 영상을 만들고 편집할 수 있게 되었습니다.\u003c/p\u003e\n\u003ch2 id=\"1-대화로-만드는-1분-분량의-시네마틱-영상\"\u003e1. 대화로 만드는 1분 분량의 시네마틱 영상\u003c/h2\u003e\n\u003cp\u003e기존의 Sora가 소수 전문가들에게만 제한적으로 공개되었다면, 이번 통합으로 일반 사용자들도 다음과 같은 작업이 가능해졌습니다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e프롬프트 기반 영상 생성:\u003c/strong\u003e \u0026ldquo;비 오는 서울 거리의 야경을 사이버펑크 스타일로 보여줘\u0026quot;와 같은 명령어로 즉시 영상 생성.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e실시간 편집:\u003c/strong\u003e 생성된 영상에 대해 \u0026ldquo;카메라 앵글을 더 낮춰줘\u0026rdquo; 또는 \u0026ldquo;배경 음악을 재즈로 바꿔줘\u0026quot;라고 요청 가능.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"2-멀티모달-능력의-정점\"\u003e2. 멀티모달 능력의 정점\u003c/h2\u003e\n\u003cp\u003eSora의 ChatGPT 통합은 텍스트, 이미지, 오디오를 넘어 비디오까지 아우르는 완벽한 멀티모달 시스템의 완성을 의미합니다. 특히 영상 내 물체의 물리적 움직임이 더욱 정교해졌다는 평가를 받고 있습니다.\u003c/p\u003e","title":"[2026.03.15] OpenAI Sora, ChatGPT에 완전 통합: 누구나 영화 감독이 되는 시대"},{"content":"로봇에 영혼을 불어넣다: 한화에어로스페이스 \u0026amp; 크래프톤 \u0026lsquo;피지컬 AI\u0026rsquo; 합작 대한민국의 방산 리더 한화에어로스페이스와 세계적인 게임 개발사 **크래프톤(Krafton)**이 의기투합했습니다. 두 기업은 로보틱스 및 국방 시스템을 위한 \u0026lsquo;피지컬 AI(Physical AI)\u0026rsquo; 개발 합작 법인(JV)을 설립한다고 발표하며 업계를 놀라게 했습니다.\n1. \u0026lsquo;피지컬 AI\u0026rsquo;란 무엇인가? 피지컬 AI는 텍스트나 이미지 같은 가상 세계의 데이터 처리를 넘어, 현실 세계의 물리적 법칙을 이해하고 로봇이 자율적으로 판단하여 움직이게 하는 기술입니다. 크래프톤의 고도화된 물리 엔진 기술과 한화의 하드웨어 제조 역량이 결합하는 지점입니다.\n2. 게임 엔진이 만드는 강력한 국방 로봇 크래프톤은 \u0026lsquo;배틀그라운드\u0026rsquo; 등 대규모 게임을 통해 쌓은 가상 환경 시뮬레이션 및 AI 강화학습 능력을 제공합니다. 이를 통해 국방 로봇은 수만 번의 가상 시뮬레이션을 거쳐 실전에서 완벽한 기동력을 갖추게 됩니다.\n3. K-방산의 디지털 전환 가속화 이번 협력은 전통적인 방위 산업이 소프트웨어와 AI 중심으로 재편되고 있음을 보여주는 상징적인 사례입니다. 자율주행 탱크, 수색용 로봇 등 다양한 분야에서의 활약이 기대됩니다.\n🌐 참고 출처 (Sources) Tech Crunch Korea: \u0026ldquo;Hanwha Aerospace and Krafton join forces for Physical AI venture\u0026rdquo; (2026.03.13) Financial News: \u0026ldquo;K-Defense meets K-Gaming: The future of robotics is Physical AI\u0026rdquo; ","permalink":"https://chanyeols.com/posts/physical-ai-hanwha-krafton/","summary":"\u003ch1 id=\"로봇에-영혼을-불어넣다-한화에어로스페이스--크래프톤-피지컬-ai-합작\"\u003e로봇에 영혼을 불어넣다: 한화에어로스페이스 \u0026amp; 크래프톤 \u0026lsquo;피지컬 AI\u0026rsquo; 합작\u003c/h1\u003e\n\u003cp\u003e대한민국의 방산 리더 \u003cstrong\u003e한화에어로스페이스\u003c/strong\u003e와 세계적인 게임 개발사 **크래프톤(Krafton)**이 의기투합했습니다. 두 기업은 로보틱스 및 국방 시스템을 위한 \u003cstrong\u003e\u0026lsquo;피지컬 AI(Physical AI)\u0026rsquo;\u003c/strong\u003e 개발 합작 법인(JV)을 설립한다고 발표하며 업계를 놀라게 했습니다.\u003c/p\u003e\n\u003ch2 id=\"1-피지컬-ai란-무엇인가\"\u003e1. \u0026lsquo;피지컬 AI\u0026rsquo;란 무엇인가?\u003c/h2\u003e\n\u003cp\u003e피지컬 AI는 텍스트나 이미지 같은 가상 세계의 데이터 처리를 넘어, 현실 세계의 물리적 법칙을 이해하고 로봇이 자율적으로 판단하여 움직이게 하는 기술입니다. 크래프톤의 고도화된 물리 엔진 기술과 한화의 하드웨어 제조 역량이 결합하는 지점입니다.\u003c/p\u003e","title":"[2026.03.14] 로봇에 영혼을 불어넣다: 한화에어로스페이스 \u0026 크래프톤 '피지컬 AI' 합작"},{"content":"영국, 100억 파운드 투자로 \u0026lsquo;유럽 AI 허브\u0026rsquo; 꿈꾼다: 엘샴 테크 파크 승인 영국 정부가 AI 인프라 경쟁에서 우위를 점하기 위해 승부수를 던졌습니다. 링컨셔(Lincolnshire) 지역에 무려 100억 파운드(한화 약 17조 원) 규모의 AI 전용 데이터센터인 \u0026lsquo;엘샴 테크 파크(Elsham Tech Park)\u0026rsquo; 건설 계획을 공식 승인했습니다.\n1. 유럽 최대 규모의 AI 전용 캠퍼스 엘샴 테크 파크는 단순히 데이터를 저장하는 곳을 넘어, 대규모 언어 모델(LLM) 학습과 추론에 최적화된 슈퍼컴퓨팅 자원을 제공할 예정입니다.\n투자 규모: 100억 파운드 (정부 및 민간 합작) 일자리 창출: 약 900개 이상의 고숙련 기술직 창출 기대 지속 가능성: 100% 재생 에너지 기반 전력 공급 계획 2. 왜 지금 영국인가? 브렉시트 이후 새로운 경제 성장 동력을 찾는 영국에게 AI는 핵심 산업입니다. 이번 프로젝트는 영국을 유럽 내 AI 연구 및 서비스의 중심지로 굳히려는 전략적 움직임으로 풀이됩니다.\n3. 지역 경제 및 글로벌 테크 기업의 반응 링컨셔 지역 사회는 이번 투자 결정을 적극 환영하고 있으며, 구글과 오라클 등 글로벌 클라우드 기업들이 이곳의 주요 파트너로 참여할 가능성이 높다는 분석이 나오고 있습니다.\n🌐 참고 출처 (Sources) Computing Weekly: \u0026ldquo;UK government gives green light to £10bn Elsham Tech Park AI data centre\u0026rdquo; (2026.03.13) BBC Business: \u0026ldquo;Lincolnshire to host Europe\u0026rsquo;s largest AI data center campus\u0026rdquo; 🖼️ 이미지 가이드 (Image Prompt) 이미지 설명: 영국 시골 풍경과 조화를 이루는 초현대적인 데이터 센터 건물의 조감도. 생성 프롬프트: \u0026ldquo;A futuristic, eco-friendly data center campus integrated into the green rolling hills of the English countryside. Massive modern glass buildings with solar panels, glowing fiber optic cables connecting the structures. Twilight lighting, cinematic wide-angle shot, photorealistic, 8k.\u0026rdquo;\n","permalink":"https://chanyeols.com/posts/uk-10b-ai-hub/","summary":"\u003ch1 id=\"영국-100억-파운드-투자로-유럽-ai-허브-꿈꾼다-엘샴-테크-파크-승인\"\u003e영국, 100억 파운드 투자로 \u0026lsquo;유럽 AI 허브\u0026rsquo; 꿈꾼다: 엘샴 테크 파크 승인\u003c/h1\u003e\n\u003cp\u003e영국 정부가 AI 인프라 경쟁에서 우위를 점하기 위해 승부수를 던졌습니다. 링컨셔(Lincolnshire) 지역에 무려 \u003cstrong\u003e100억 파운드(한화 약 17조 원)\u003c/strong\u003e 규모의 AI 전용 데이터센터인 \u0026lsquo;엘샴 테크 파크(Elsham Tech Park)\u0026rsquo; 건설 계획을 공식 승인했습니다.\u003c/p\u003e\n\u003ch2 id=\"1-유럽-최대-규모의-ai-전용-캠퍼스\"\u003e1. 유럽 최대 규모의 AI 전용 캠퍼스\u003c/h2\u003e\n\u003cp\u003e엘샴 테크 파크는 단순히 데이터를 저장하는 곳을 넘어, 대규모 언어 모델(LLM) 학습과 추론에 최적화된 슈퍼컴퓨팅 자원을 제공할 예정입니다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e투자 규모:\u003c/strong\u003e 100억 파운드 (정부 및 민간 합작)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e일자리 창출:\u003c/strong\u003e 약 900개 이상의 고숙련 기술직 창출 기대\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e지속 가능성:\u003c/strong\u003e 100% 재생 에너지 기반 전력 공급 계획\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"2-왜-지금-영국인가\"\u003e2. 왜 지금 영국인가?\u003c/h2\u003e\n\u003cp\u003e브렉시트 이후 새로운 경제 성장 동력을 찾는 영국에게 AI는 핵심 산업입니다. 이번 프로젝트는 영국을 유럽 내 AI 연구 및 서비스의 중심지로 굳히려는 전략적 움직임으로 풀이됩니다.\u003c/p\u003e","title":"[2026.03.13] 영국, 100억 파운드 투자로 '유럽 AI 허브' 꿈꾼다: 엘샴 테크 파크 승인"},{"content":"1. 서론: 왜 단순 로그만으로는 부족한가? 시스템을 운영하다 보면 에러 로그만으로는 원인을 파악하기 힘든 상황에 직면합니다. \u0026ldquo;갑자기 왜 느려졌지?\u0026rdquo;, \u0026ldquo;메모리가 부족한 건 아닐까?\u0026rdquo;, \u0026ldquo;현재 동시 접속자 수는 얼마인가?\u0026ldquo;와 같은 질문에 답하기 위해 필요한 것이 바로 메트릭(Metric) 모니터링입니다.\n로그가 **사건(Event)**에 대한 기록이라면, 메트릭은 **상태(State)**에 대한 수치적 기록입니다. 이번 포스트에서는 수치 데이터를 수집하는 Prometheus와 이를 대시보드로 시각화하는 Grafana의 조합을 통해 서비스의 생애 주기를 추적하는 방법을 상세히 알아보겠습니다.\n2. 모니터링 스택의 작동 원리 전형적인 모니터링 스택은 Pull 방식을 기반으로 합니다.\nTarget Application (Spring Boot): 애플리케이션의 현재 상태(JVM, CPU, Memory 등)를 HTTP 엔드포인트(.actuator/prometheus)로 노출합니다. Prometheus: 정해진 주기(예: 15초)마다 타겟 애플리케이션에 접속하여 메트릭 데이터를 긁어와(Scrape) 자신의 DB(TSDB)에 저장합니다. Grafana: Prometheus를 데이터 소스(Data Source)로 설정하고, 쿼리 언어인 PromQL을 사용하여 데이터를 조회하고 아름다운 그래프로 그려줍니다. 3. Spring Boot와 Micrometer 설정 Spring Boot는 Micrometer 라이브러리를 통해 Prometheus 포맷의 메트릭을 기본 제공합니다.\n3.1. 의존성 추가 (build.gradle) dependencies { implementation \u0026#39;org.springframework.boot:spring-boot-starter-actuator\u0026#39; implementation \u0026#39;io.micrometer:micrometer-registry-prometheus\u0026#39; } 3.2. 엔드포인트 개방 (application.yml) 보안상 기본적으로 닫혀 있는 /actuator/prometheus 엔드포인트를 열어줍니다. 실무에서는 이 경로에 대한 접근을 특정 IP(Prometheus 서버)로 제한하는 보안 설정이 동반되어야 합니다.\nmanagement: endpoints: web: exposure: include: prometheus, health, info endpoint: prometheus: enabled: true 4. Prometheus 설정 및 실행 Prometheus 설정 파일(prometheus.yml)은 메트릭을 어디서 가져올지를 정의합니다.\nglobal: scrape_interval: 15s # 데이터를 수집할 주기 scrape_configs: - job_name: \u0026#39;spring-boot-app\u0026#39; metrics_path: \u0026#39;/actuator/prometheus\u0026#39; static_configs: - targets: [\u0026#39;172.17.0.1:8080\u0026#39;] # 애플리케이션 서버 주소 5. Grafana를 활용한 대시보드 구축 Grafana의 강점은 커뮤니티 대시보드입니다. 처음부터 모든 그래프를 그릴 필요 없이, 이미 잘 만들어진 템플릿을 가져와(Import) 수정하는 것이 효율적입니다.\n5.1. 추천 대시보드 (ID: 11378) Spring Boot 애플리케이션을 위한 표준 대시보드 중 하나인 11378(JVM Micrometer)을 임포트하면 다음과 같은 정보를 즉시 모니터링할 수 있습니다.\nJVM Memory Usage: Heap 및 Non-Heap 메모리 사용량 추이. Garbage Collection: GC 발생 횟수와 소요 시간(Stop-the-world 감지). Thread Count: 활성 스레드 수 및 최대 스레드 도달 여부. HTTP Request Statistics: API별 응답 속도 및 에러율(4xx, 5xx). 6. 실무에서의 알림 설정 (Alerting) 시각화만으로는 충분하지 않습니다. 서버가 죽거나 메모리가 임계치를 넘었을 때 즉시 통보를 받아야 합니다.\nAlertmanager: Prometheus에서 발생한 알림을 필터링하고 슬랙(Slack), 메일, 카카오톡 등으로 전송합니다. Grafana Alerts: Grafana 대시보드 상에서 특정 수치가 넘었을 때 알림을 보내는 방식도 최근 많이 사용됩니다. 7. 결론: \u0026ldquo;측정되지 않는 것은 개선될 수 없다\u0026rdquo; 모니터링 시스템은 단순히 장애를 찾는 도구가 아닙니다. 리소스 사용량을 분석하여 서버 비용을 최적화하고, 성능 병목 지점을 찾아 사용자 경험을 개선하는 핵심적인 인프라입니다. Prometheus와 Grafana를 통해 여러분의 서비스를 데이터 기반으로 투명하게 운영해 보시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/prometheus-grafana-monitoring/","summary":"\u003ch2 id=\"1-서론-왜-단순-로그만으로는-부족한가\"\u003e1. 서론: 왜 단순 로그만으로는 부족한가?\u003c/h2\u003e\n\u003cp\u003e시스템을 운영하다 보면 에러 로그만으로는 원인을 파악하기 힘든 상황에 직면합니다. \u0026ldquo;갑자기 왜 느려졌지?\u0026rdquo;, \u0026ldquo;메모리가 부족한 건 아닐까?\u0026rdquo;, \u0026ldquo;현재 동시 접속자 수는 얼마인가?\u0026ldquo;와 같은 질문에 답하기 위해 필요한 것이 바로 \u003cstrong\u003e메트릭(Metric)\u003c/strong\u003e 모니터링입니다.\u003c/p\u003e\n\u003cp\u003e로그가 **사건(Event)**에 대한 기록이라면, 메트릭은 **상태(State)**에 대한 수치적 기록입니다. 이번 포스트에서는 수치 데이터를 수집하는 \u003cstrong\u003ePrometheus\u003c/strong\u003e와 이를 대시보드로 시각화하는 \u003cstrong\u003eGrafana\u003c/strong\u003e의 조합을 통해 서비스의 생애 주기를 추적하는 방법을 상세히 알아보겠습니다.\u003c/p\u003e\n\u003ch2 id=\"2-모니터링-스택의-작동-원리\"\u003e2. 모니터링 스택의 작동 원리\u003c/h2\u003e\n\u003cp\u003e전형적인 모니터링 스택은 \u003cstrong\u003ePull 방식\u003c/strong\u003e을 기반으로 합니다.\u003c/p\u003e","title":"Prometheus와 Grafana를 활용한 서버 메트릭 모니터링 구축하기: 안정적인 서비스를 위한 시각화 전략"},{"content":"1. 검색의 중요성: LIKE %keyword%의 한계 데이터가 많아질수록 데이터베이스의 LIKE 연산자는 성능 저하의 주범이 됩니다. 인덱스를 탈 수 없기 때문입니다. 하지만 별도의 검색 엔진(Elasticsearch 등)을 도입하기 부담스러운 규모라면, PostgreSQL이 제공하는 **Full Text Search(FTS)**는 매우 훌륭한 대안이 됩니다.\n2. PostgreSQL FTS의 핵심 개념 tsvector: 검색 대상이 되는 텍스트를 단어 단위로 쪼개어(Lexemes) 저장하는 전용 타입. tsquery: 검색어에 대한 논리 연산(AND, OR, NOT 등)을 수행하는 타입. GIN Index: tsvector 전용 인덱스로, 대규모 데이터에서도 빠른 검색 속도를 보장. 3. 기본 검색 쿼리 예제 문장에서 특정 단어를 찾는 가장 단순한 형태입니다.\nSELECT title, content FROM posts WHERE to_tsvector(\u0026#39;english\u0026#39;, content) @@ to_tsquery(\u0026#39;english\u0026#39;, \u0026#39;search \u0026amp; guide\u0026#39;); 4. GIN 인덱스 적용으로 성능 최적화 검색 성능을 비약적으로 향상시키기 위해 인덱스를 생성합니다.\n-- GIN 인덱스 생성 CREATE INDEX idx_posts_content_fts ON posts USING GIN (to_tsvector(\u0026#39;english\u0026#39;, content)); -- 조회 성능 확인 (EXPLAIN ANALYZE) EXPLAIN ANALYZE SELECT * FROM posts WHERE to_tsvector(\u0026#39;english\u0026#39;, content) @@ to_tsquery(\u0026#39;english\u0026#39;, \u0026#39;optimization\u0026#39;); 5. PostgreSQL FTS의 장점 관리 간소화: 별도의 검색 엔진 서버(Elasticsearch 등)를 유지보수할 필요가 없음. 데이터 정합성: 메인 DB에서 데이터가 수정되는 즉시 검색 결과에 반영됨. 풍부한 기능: 가중치 부여(Ranking), 하이라이트 표시 등을 기본 지원. 6. 결론 PostgreSQL의 FTS를 활용하면 데이터베이스만으로도 강력한 검색 기능을 구현할 수 있습니다. 시스템의 복잡도를 낮추면서도 사용자에게 높은 품질의 검색 경험을 제공해 보세요.\n","permalink":"https://chanyeols.com/posts/postgresql-full-text-search/","summary":"\u003ch2 id=\"1-검색의-중요성-like-keyword의-한계\"\u003e1. 검색의 중요성: \u003ccode\u003eLIKE %keyword%\u003c/code\u003e의 한계\u003c/h2\u003e\n\u003cp\u003e데이터가 많아질수록 데이터베이스의 \u003ccode\u003eLIKE\u003c/code\u003e 연산자는 성능 저하의 주범이 됩니다. 인덱스를 탈 수 없기 때문입니다. 하지만 별도의 검색 엔진(Elasticsearch 등)을 도입하기 부담스러운 규모라면, \u003cstrong\u003ePostgreSQL\u003c/strong\u003e이 제공하는 **Full Text Search(FTS)**는 매우 훌륭한 대안이 됩니다.\u003c/p\u003e\n\u003ch2 id=\"2-postgresql-fts의-핵심-개념\"\u003e2. PostgreSQL FTS의 핵심 개념\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003etsvector\u003c/strong\u003e: 검색 대상이 되는 텍스트를 단어 단위로 쪼개어(Lexemes) 저장하는 전용 타입.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003etsquery\u003c/strong\u003e: 검색어에 대한 논리 연산(AND, OR, NOT 등)을 수행하는 타입.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGIN Index\u003c/strong\u003e: \u003ccode\u003etsvector\u003c/code\u003e 전용 인덱스로, 대규모 데이터에서도 빠른 검색 속도를 보장.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"3-기본-검색-쿼리-예제\"\u003e3. 기본 검색 쿼리 예제\u003c/h2\u003e\n\u003cp\u003e문장에서 특정 단어를 찾는 가장 단순한 형태입니다.\u003c/p\u003e","title":"PostgreSQL Full Text Search를 활용한 강력한 검색 기능 구현하기"},{"content":"1. 인증 방식의 진화: Session에서 JWT로 웹 서비스가 발전하면서 세션 기반의 인증 방식은 서버의 확장성 면에서 한계를 보이게 되었습니다. 서버에 상태를 저장하지 않는 무상태(Stateless) 인증 방식인 **JWT(JSON Web Token)**가 대중적으로 사용되고 있습니다.\n2. JWT의 구조 이해 Header: 토큰 타입(JWT)과 서명 알고리즘(HS256 등) 정보. Payload: 토큰에 담길 정보(Claims, 사용자 ID, 만료 시간 등). Signature: Header와 Payload를 조합하여 생성된 서명값. 위변조 방지. 3. Spring Security 필터 체인 설정 JWT는 요청마다 헤더의 Authorization: Bearer \u0026lt;token\u0026gt; 값을 검사해야 하므로 전용 필터를 추가해 줍니다.\n@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(\u0026#34;/api/auth/**\u0026#34;).permitAll() .anyRequest().authenticated() .and() .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } } 4. JWT 생성 로직 (Java) public String generateToken(String username) { long now = System.currentTimeMillis(); return Jwts.builder() .setSubject(username) .setIssuedAt(new Date(now)) .setExpiration(new Date(now + 3600000)) // 1시간 만료 .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); } 5. 보안 고려사항: Refresh Token의 도입 Access Token은 탈취 위험 때문에 만료 시간을 짧게(예: 30분) 설정합니다. 대신 유효 기간이 긴 Refresh Token을 별도의 안전한 저장소(Redis 등)에 보관하여 Access Token 재발급을 처리하는 것이 일반적입니다.\n6. 결론 Spring Security와 JWT의 조합은 안전하고 확장성 있는 서비스를 구축하기 위한 필수적인 기술입니다. 프로젝트의 보안 요구사항에 맞춰 적절한 보안 전략을 수립해 보세요.\n","permalink":"https://chanyeols.com/posts/spring-security-jwt-authentication/","summary":"\u003ch2 id=\"1-인증-방식의-진화-session에서-jwt로\"\u003e1. 인증 방식의 진화: Session에서 JWT로\u003c/h2\u003e\n\u003cp\u003e웹 서비스가 발전하면서 세션 기반의 인증 방식은 서버의 확장성 면에서 한계를 보이게 되었습니다. 서버에 상태를 저장하지 않는 \u003cstrong\u003e무상태(Stateless)\u003c/strong\u003e 인증 방식인 **JWT(JSON Web Token)**가 대중적으로 사용되고 있습니다.\u003c/p\u003e\n\u003ch2 id=\"2-jwt의-구조-이해\"\u003e2. JWT의 구조 이해\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eHeader\u003c/strong\u003e: 토큰 타입(JWT)과 서명 알고리즘(HS256 등) 정보.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePayload\u003c/strong\u003e: 토큰에 담길 정보(Claims, 사용자 ID, 만료 시간 등).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSignature\u003c/strong\u003e: Header와 Payload를 조합하여 생성된 서명값. 위변조 방지.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"3-spring-security-필터-체인-설정\"\u003e3. Spring Security 필터 체인 설정\u003c/h2\u003e\n\u003cp\u003eJWT는 요청마다 헤더의 \u003ccode\u003eAuthorization: Bearer \u0026lt;token\u0026gt;\u003c/code\u003e 값을 검사해야 하므로 전용 필터를 추가해 줍니다.\u003c/p\u003e","title":"Spring Security와 JWT를 이용한 무상태(Stateless) 인증 시스템 구축"},{"content":"1. 서론: 왜 현대 개발자에게 CI/CD는 필수인가? 과거의 배포 방식은 개발자가 로컬에서 빌드한 jar나 war 파일을 FTP로 서버에 업로드하고, 직접 터미널에 접속하여 프로세스를 재시작하는 수동적인 과정이었습니다. 하지만 서비스의 규모가 커지고 배포 주기가 짧아짐에 따라 이러한 방식은 휴먼 에러의 온상이 되었습니다.\n**CI(Continuous Integration)**는 코드 변경 사항을 지속적으로 통합하고 검증하는 과정을 자동화하며, **CD(Continuous Deployment)**는 검증된 코드를 실제 운영 환경에 자동으로 반영하는 것을 의미합니다. 이번 포스트에서는 GitHub Actions를 활용해 이 과정을 어떻게 구축하고, 실무에서 고려해야 할 최적화 포인트는 무엇인지 심도 있게 다뤄보겠습니다.\n2. CI/CD 파이프라인의 전체 아키텍처 우리가 구축할 파이프라인은 다음과 같은 흐름을 가집니다.\nPush \u0026amp; Trigger: 개발자가 main 브랜치에 코드를 푸시하거나 Pull Request를 병합합니다. Build \u0026amp; Test: GitHub이 제공하는 가상 환경(Runner)에서 JDK를 설정하고 Gradle을 이용해 테스트 및 빌드를 수행합니다. Artifact Management: 빌드된 결과물(jar)을 안전하게 관리합니다. (필요 시 Docker Hub 등에 이미지로 업로드) Deploy: SSH 프로토콜을 이용해 운영 서버에 접속하고, 기존 프로세스를 종료한 뒤 새 버전을 실행합니다. 3. GitHub Actions 워크플로우 상세 설정 (.github/workflows/deploy.yml) 단순한 배포를 넘어, 빌드 속도를 높이기 위한 캐싱 전략이 포함된 설정입니다.\nname: Production CD Pipeline on: push: branches: [ \u0026#34;main\u0026#34; ] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: java-version: \u0026#39;21\u0026#39; distribution: \u0026#39;temurin\u0026#39; # Gradle 캐싱 활성화로 빌드 속도 향상 cache: \u0026#39;gradle\u0026#39; - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build with Gradle (Exclude Tests for speed if needed) run: ./gradlew clean build -x test - name: Deliver Artifact via SCP uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.SERVER_IP }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} source: \u0026#34;build/libs/*.jar\u0026#34; target: \u0026#34;/home/${{ secrets.SERVER_USER }}/deploy\u0026#34; strip_components: 2 - name: Execute Remote Deployment Script uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SERVER_IP }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} script: | echo \u0026#34;Stopping current application...\u0026#34; fuser -k 8080/tcp || true echo \u0026#34;Starting new application...\u0026#34; nohup java -jar ~/deploy/*.jar \u0026gt; ~/deploy/app.log 2\u0026gt;\u0026amp;1 \u0026amp; echo \u0026#34;Deployment successful!\u0026#34; 4. 실무에서의 핵심 고려 사항 4.1. 보안 관리 (GitHub Secrets) 절대로 application.yml에 담긴 DB 비밀번호나 API 키, 서버 접속 정보를 소스 코드에 포함해서는 안 됩니다. GitHub Actions는 Secrets 기능을 통해 암호화된 변수를 제공합니다. 특히 SSH Key를 등록할 때는 -----BEGIN RSA PRIVATE KEY-----부터 끝까지 정확히 복사해야 접속 오류를 방지할 수 있습니다.\n4.2. 빌드 속도 최적화 (Caching) 매번 배포할 때마다 의존성 라이브러리를 다시 다운로드하는 것은 비효율적입니다. 위 설정에서 cache: 'gradle'을 사용한 것처럼, 캐싱 기능을 적극 활용하면 배포 시간을 수십 초에서 수 분까지 단축할 수 있습니다.\n4.3. 무중단 배포 (Blue-Green / Rolling Update) 위의 스크립트는 서비스를 잠시 중단(Stop -\u0026gt; Start)하는 방식입니다. 실제 상용 서비스에서는 Nginx의 리버스 프록시 설정을 이용해 구버전과 신버전을 교체하는 Blue-Green 방식이나, 여러 대의 서버를 순차적으로 업데이트하는 Rolling Update를 고려해야 합니다.\n5. 결론 및 향후 과제 GitHub Actions는 별도의 서버 구축 없이도 강력한 CI/CD 환경을 무료로 제공합니다. 이제 여러분은 단순 반복 작업에서 벗어나 더 가치 있는 코드 작성에 집중할 수 있습니다. 다음 단계로는 Docker 컨테이너 기반의 배포나 Kubernetes를 이용한 오케스트레이션으로 확장해 보시길 권장합니다.\n","permalink":"https://chanyeols.com/posts/github-actions-cicd-spring-boot/","summary":"\u003ch2 id=\"1-서론-왜-현대-개발자에게-cicd는-필수인가\"\u003e1. 서론: 왜 현대 개발자에게 CI/CD는 필수인가?\u003c/h2\u003e\n\u003cp\u003e과거의 배포 방식은 개발자가 로컬에서 빌드한 \u003ccode\u003ejar\u003c/code\u003e나 \u003ccode\u003ewar\u003c/code\u003e 파일을 FTP로 서버에 업로드하고, 직접 터미널에 접속하여 프로세스를 재시작하는 수동적인 과정이었습니다. 하지만 서비스의 규모가 커지고 배포 주기가 짧아짐에 따라 이러한 방식은 휴먼 에러의 온상이 되었습니다.\u003c/p\u003e\n\u003cp\u003e**CI(Continuous Integration)**는 코드 변경 사항을 지속적으로 통합하고 검증하는 과정을 자동화하며, **CD(Continuous Deployment)**는 검증된 코드를 실제 운영 환경에 자동으로 반영하는 것을 의미합니다. 이번 포스트에서는 GitHub Actions를 활용해 이 과정을 어떻게 구축하고, 실무에서 고려해야 할 최적화 포인트는 무엇인지 심도 있게 다뤄보겠습니다.\u003c/p\u003e","title":"GitHub Actions를 이용한 Spring Boot 자동 배포(CI/CD) 완벽 가이드: 이론부터 실무 최적화까지"},{"content":"Spring Data JPA는 간단한 CRUD 작업을 처리할 때 매우 강력하지만, 복잡한 검색 조건이나 동적 쿼리를 작성해야 할 때는 한계에 부딪히기 쉽습니다. @Query 어노테이션을 사용하여 직접 JPQL을 작성할 수는 있지만, 문자열 기반의 쿼리는 오타 발생 시 런타임 에러를 유발하며 가독성이 떨어지는 단점이 있습니다.\n이러한 문제를 해결해 주는 도구가 바로 Querydsl입니다. Querydsl은 자바 코드로 쿼리를 작성할 수 있게 해주어 컴파일 시점에 오류를 잡아낼 수 있고, 메서드 체이닝 방식을 통해 직관적인 동적 쿼리 작성을 지원합니다. 이번 포스팅에서는 최신 Spring Boot 3.x 환경에서의 설정 방법과 실무 최적화 팁을 알아보겠습니다.\n1. Spring Boot 3.x Querydsl 설정 Spring Boot 3.x(Jakarta EE) 환경에서는 이전 버전(Java EE)과 라이브러리 의존성 및 설정 방식이 약간 변경되었습니다. build.gradle 설정을 정확히 구성하는 것이 첫 번째 단계입니다.\nbuild.gradle 설정 예제 dependencies { // Querydsl 관련 라이브러리 implementation \u0026#39;com.querydsl:querydsl-jpa:5.0.0:jakarta\u0026#39; annotationProcessor \u0026#34;com.querydsl:querydsl-apt:5.0.0:jakarta\u0026#34; annotationProcessor \u0026#34;jakarta.annotation:jakarta.annotation-api\u0026#34; annotationProcessor \u0026#34;jakarta.persistence:jakarta.persistence-api\u0026#34; } // Querydsl QClass 생성 위치 설정 def querydslDir = \u0026#34;$buildDir/generated/querydsl\u0026#34; sourceSets { main.java.srcDirs += [ querydslDir ] } tasks.withType(JavaCompile) { options.getGeneratedSourceOutputDirectory().set(file(querydslDir)) } clean { delete file(querydslDir) } 핵심은 :jakarta 분류자를 사용하여 Jakarta 패키지를 지원하도록 명시하는 것입니다. 설정 후 gradle build를 실행하면 generated 폴더 하위에 엔티티 기반의 QClass들이 생성됩니다.\n2. JPAQueryFactory 빈(Bean) 등록 Querydsl을 편리하게 사용하기 위해 JPAQueryFactory를 스프링 빈으로 등록하여 주입받아 사용하는 방식을 권장합니다.\n@Configuration public class QuerydslConfig { @PersistenceContext private EntityManager entityManager; @Bean public JPAQueryFactory jpaQueryFactory() { return new JPAQueryFactory(entityManager); } } 3. 실무 지향적 동적 쿼리 작성 기법 Querydsl의 가장 큰 강점은 BooleanExpression을 활용한 동적 쿼리 작성입니다. 이를 통해 쿼리 조건을 작은 단위의 메서드로 분리하여 재사용성과 가독성을 높일 수 있습니다.\nBooleanExpression 활용 예제 public List\u0026lt;Member\u0026gt; searchMember(MemberSearchCondition condition) { return queryFactory .selectFrom(member) .where( usernameEq(condition.getUsername()), ageGoe(condition.getAgeGoe()) ) .fetch(); } private BooleanExpression usernameEq(String username) { return StringUtils.hasText(username) ? member.username.eq(username) : null; } private BooleanExpression ageGoe(Integer ageGoe) { return ageGoe != null ? member.age.goe(ageGoe) : null; } where 절에 null이 전달되면 Querydsl이 이를 무시하므로, 자연스럽게 동적 쿼리가 완성됩니다. 또한 각 메서드는 다른 쿼리에서도 재사용될 수 있습니다.\n4. Querydsl 성능 최적화 팁 1) Projection 활용 (DTO 조회) 전체 엔티티가 아닌 특정 필드만 필요한 경우, 엔티티 대신 DTO로 바로 조회하여 성능을 최적화할 수 있습니다. Projections.constructor나 Projections.fields를 사용합니다.\nList\u0026lt;MemberDto\u0026gt; result = queryFactory .select(Projections.constructor(MemberDto.class, member.username, member.age)) .from(member) .fetch(); 2) Fetch Join 사용 N+1 문제를 방지하기 위해 연관된 엔티티를 한 번의 쿼리로 가져오도록 fetchJoin()을 명시적으로 사용해야 합니다.\nMember findMember = queryFactory .selectFrom(member) .join(member.team, team).fetchJoin() .where(member.username.eq(\u0026#34;user1\u0026#34;)) .fetchOne(); 3) Exist 쿼리 최적화 JPA의 exists는 내부적으로 count 쿼리를 실행하므로 대용량 데이터에서 성능이 저하될 수 있습니다. Querydsl에서는 fetchFirst()를 사용하여 직접 구현하는 것이 성능상 유리합니다.\npublic Boolean exists(Long id) { Integer fetchOne = queryFactory .selectOne() .from(member) .where(member.id.eq(id)) .fetchFirst(); // limit 1과 동일 return fetchOne != null; } 결론 Querydsl은 Spring Data JPA와 함께 사용했을 때 시너지가 가장 큰 도구입니다. 타입 안정성 확보, 동적 쿼리의 유연성, 코드 재사용성 등 개발자가 얻을 수 있는 이점이 매우 많습니다.\nSpring Boot 3.x 환경으로 전환하면서 설정 방식이 다소 까다로워졌지만, 위에서 설명한 jakarta 설정과 BooleanExpression 패턴을 적용한다면 유지보수가 용이하고 고성능의 데이터 접근 계층을 구축할 수 있을 것입니다. 복잡한 쿼리 로직으로 고민 중이라면 지금 바로 Querydsl 도입을 검토해 보시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/spring-boot-3-querydsl-optimization/","summary":"\u003cp\u003eSpring Data JPA는 간단한 CRUD 작업을 처리할 때 매우 강력하지만, 복잡한 검색 조건이나 동적 쿼리를 작성해야 할 때는 한계에 부딪히기 쉽습니다. \u003ccode\u003e@Query\u003c/code\u003e 어노테이션을 사용하여 직접 JPQL을 작성할 수는 있지만, 문자열 기반의 쿼리는 오타 발생 시 런타임 에러를 유발하며 가독성이 떨어지는 단점이 있습니다.\u003c/p\u003e\n\u003cp\u003e이러한 문제를 해결해 주는 도구가 바로 \u003cstrong\u003eQuerydsl\u003c/strong\u003e입니다. Querydsl은 자바 코드로 쿼리를 작성할 수 있게 해주어 컴파일 시점에 오류를 잡아낼 수 있고, 메서드 체이닝 방식을 통해 직관적인 동적 쿼리 작성을 지원합니다. 이번 포스팅에서는 최신 Spring Boot 3.x 환경에서의 설정 방법과 실무 최적화 팁을 알아보겠습니다.\u003c/p\u003e","title":"Spring Boot 3.x 환경에서 Querydsl 설정 및 동적 쿼리 최적화 가이드"},{"content":"Node.js는 싱글 스레드(Single-threaded) 기반이면서도 고성능 비동기 I/O를 지원하여 현대적인 백엔드 아키텍처에서 널리 활용되고 있습니다. 하지만 실무에서 대용량 데이터를 처리하거나 복잡한 연산을 수행하다 보면 \u0026ldquo;왜 내 서버가 멈추지?\u0026rdquo; 또는 \u0026ldquo;왜 비동기 작업이 예상보다 늦게 처리되지?\u0026ldquo;와 같은 의문을 갖게 됩니다.\n이러한 현상의 근본 원인은 Node.js의 심장부인 **이벤트 루프(Event Loop)**와 Libuv의 동작 방식을 정확히 이해하지 못한 데서 비롯됩니다. 이번 포스팅에서는 이벤트 루프의 6가지 단계를 깊이 있게 파헤치고, 실제 애플리케이션의 성능을 최적화하는 전략을 살펴보겠습니다.\n1. Node.js 이벤트 루프의 6가지 단계 이벤트 루프는 매 루프(Tick)마다 특정 순서에 따라 큐에 쌓인 콜백들을 처리합니다. 각 단계는 자신만의 큐를 가지고 있으며, 해당 큐가 비워지거나 실행 한도에 도달할 때까지 콜백을 실행합니다.\n단계 (Phase) 설명 Timer setTimeout(), setInterval()의 콜백이 실행됩니다. Pending Callbacks 이전 루프에서 지연된 일부 I/O 콜백을 처리합니다. Idle, Prepare 내부적인 시스템 처리에 사용됩니다. Poll 새로운 I/O 이벤트를 가져오고, I/O 관련 콜백을 실행합니다. Check setImmediate()의 콜백이 실행됩니다. Close Callbacks 'close' 이벤트 관련 콜백(예: socket.on('close', ...))을 처리합니다. 특히 중요한 것은 Poll 단계입니다. 대부분의 I/O 작업(네트워크, 파일 읽기 등)의 결과가 여기서 처리되며, 루프가 이 단계에서 새로운 이벤트를 기다리며 대기할 수도 있습니다.\n2. Microtask Queue: nextTick과 Promise 이벤트 루프의 각 단계 사이사이에 실행되는 특별한 큐가 있습니다. 바로 Microtask Queue입니다. 여기에는 process.nextTick()과 Promise의 then 콜백이 포함됩니다.\nconsole.log(\u0026#39;Start\u0026#39;); setTimeout(() =\u0026gt; console.log(\u0026#39;Timeout\u0026#39;), 0); setImmediate(() =\u0026gt; console.log(\u0026#39;Immediate\u0026#39;)); process.nextTick(() =\u0026gt; console.log(\u0026#39;NextTick\u0026#39;)); Promise.resolve().then(() =\u0026gt; console.log(\u0026#39;Promise\u0026#39;)); console.log(\u0026#39;End\u0026#39;); 실행 결과 순서:\nStart -\u0026gt; End (동기 코드 실행) NextTick -\u0026gt; Promise (Microtask Queue가 우선순위가 가장 높음) Timeout (Timer 단계) Immediate (Check 단계) Microtask Queue는 이벤트 루프의 어느 단계에서든 현재 작업이 끝나면 즉시 실행되므로, 너무 많은 nextTick 작업을 쌓으면 이벤트 루프가 다음 단계로 넘어가지 못하는 \u0026lsquo;굶주림(Starvation)\u0026rsquo; 현상이 발생할 수 있습니다.\n3. CPU 집약적 작업의 문제와 최적화 Node.js의 메인 스레드는 하나뿐이므로, 복잡한 암호화나 대량의 JSON 파싱 같은 CPU 집약적 작업이 메인 스레드를 점유하면 이벤트 루프가 멈추게(Blocking) 됩니다. 이로 인해 모든 후속 요청의 응답 시간이 급격히 증가합니다.\n최적화 방안 1: Worker Threads 활용 Node.js 10.5.0부터 지원되는 worker_threads 모듈을 사용하면 별도의 스레드에서 무거운 연산을 수행할 수 있습니다.\nconst { Worker, isMainThread, parentPort } = require(\u0026#39;worker_threads\u0026#39;); if (isMainThread) { // 메인 스레드: 워커 스레드 생성 const worker = new Worker(__filename); worker.on(\u0026#39;message\u0026#39;, (result) =\u0026gt; console.log(\u0026#39;결과:\u0026#39;, result)); } else { // 워커 스레드: 무거운 연산 수행 const heavyTask = () =\u0026gt; { /* ... 복잡한 계산 ... */ }; parentPort.postMessage(heavyTask()); } 최적화 방안 2: 작업 분할 (Partitioning) 큰 작업을 작은 단위로 쪼개어 이벤트 루프가 중간에 다른 I/O를 처리할 틈을 주는 방식입니다. setImmediate()를 활용하여 작업을 예약할 수 있습니다.\nfunction chunkedTask(data, index = 0) { if (index \u0026gt;= data.length) return; // 100개씩 처리 후 다음 작업을 큐에 예약 for (let i = 0; i \u0026lt; 100 \u0026amp;\u0026amp; index \u0026lt; data.length; i++, index++) { process(data[index]); } setImmediate(() =\u0026gt; chunkedTask(data, index)); } 4. Libuv의 스레드 풀(Thread Pool) 이해하기 Node.js는 모든 I/O를 직접 싱글 스레드로 처리하지 않습니다. 파일 I/O나 암호화 작업 등은 Libuv의 스레드 풀에서 비동기적으로 처리됩니다. 기본값은 4개이며, 작업량이 많을 경우 이를 조정하여 성능을 높일 수 있습니다.\n스레드 풀 크기 조정 (Environment Variable) # 운영 환경의 코어 수에 맞춰 조정 (예: 8개) export UV_THREADPOOL_SIZE=8 node app.js 결론 Node.js 이벤트 루프의 동작 원리를 이해하는 것은 단순한 지식 습득을 넘어, 고성능 애플리케이션을 설계하고 트러블슈팅하는 데 필수적인 역량입니다.\nMicrotask Queue의 남용을 피하고, CPU 집약적 작업은 워커 스레드나 작업 분할을 통해 격리하며, Libuv 스레드 풀 설정을 통해 인프라 자원을 최적화해야 합니다. 싱글 스레드의 제약을 극복하고 Node.js의 비동기적 강점을 극대화하여, 더 빠르고 안정적인 서비스를 구축해 보시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/nodejs-event-loop-performance-optimization/","summary":"\u003cp\u003eNode.js는 싱글 스레드(Single-threaded) 기반이면서도 고성능 비동기 I/O를 지원하여 현대적인 백엔드 아키텍처에서 널리 활용되고 있습니다. 하지만 실무에서 대용량 데이터를 처리하거나 복잡한 연산을 수행하다 보면 \u0026ldquo;왜 내 서버가 멈추지?\u0026rdquo; 또는 \u0026ldquo;왜 비동기 작업이 예상보다 늦게 처리되지?\u0026ldquo;와 같은 의문을 갖게 됩니다.\u003c/p\u003e\n\u003cp\u003e이러한 현상의 근본 원인은 Node.js의 심장부인 **이벤트 루프(Event Loop)**와 \u003cstrong\u003eLibuv\u003c/strong\u003e의 동작 방식을 정확히 이해하지 못한 데서 비롯됩니다. 이번 포스팅에서는 이벤트 루프의 6가지 단계를 깊이 있게 파헤치고, 실제 애플리케이션의 성능을 최적화하는 전략을 살펴보겠습니다.\u003c/p\u003e","title":"Node.js 이벤트 루프의 동작 원리와 비동기 성능 최적화 전략"},{"content":"최근 백엔드 개발 환경에서 동시성 처리는 시스템의 전체 성능과 직결되는 매우 중요한 요소입니다. 기존의 Java는 운영체제(OS)의 스레드와 1:1로 매핑되는 플랫폼 스레드 모델을 사용해 왔으나, 이는 메모리 점유율과 컨텍스트 스위칭 비용 측면에서 한계가 있었습니다. 특히 수만 개의 동시 연결을 처리해야 하는 현대적인 웹 애플리케이션에서는 이러한 한계가 병목 현상으로 작용하곤 합니다.\n이번 포스팅에서는 Java 21에서 정식 도입된 가상 스레드(Virtual Threads)가 무엇인지, 그리고 이를 통해 어떻게 애플리케이션의 처리량을 획기적으로 개선할 수 있는지 상세히 살펴보겠습니다.\n1. 가상 스레드(Virtual Threads)란 무엇인가? 가상 스레드는 JDK 21(Project Loom)에서 도입된 경량 스레드 모델입니다. 기존 플랫폼 스레드와 달리 가상 스레드는 OS 스레드와 직접 매핑되지 않고, JVM 내부의 스케줄러를 통해 소수의 플랫폼 스레드 위에서 수백만 개의 가상 스레드가 동작할 수 있도록 설계되었습니다.\n가상 스레드의 가장 큰 장점은 \u0026lsquo;Thread-per-Request\u0026rsquo; 모델을 유지하면서도 리소스를 매우 적게 사용한다는 점입니다. 이는 기존의 비동기 리액티브 프로그래밍(Reactive Programming)이 주는 복잡성을 피하면서도 그에 상응하는 높은 처리량을 제공합니다.\n플랫폼 스레드와 가상 스레드 비교 구분 플랫폼 스레드 (Platform Thread) 가상 스레드 (Virtual Thread) 메모리 점유 약 1MB (Stack 영역) 수 KB 내외 생성 비용 비쌈 (OS 시스템 콜 필요) 매우 저렴 (객체 생성 수준) 컨텍스트 스위칭 OS 커널 개입 (무거움) JVM 내부 스케줄링 (가벼움) 동시성 한계 수천 개 수준 수백만 개 가능 2. 가상 스레드 생성 및 사용 방법 Java 21부터는 매우 간단한 API를 통해 가상 스레드를 생성할 수 있습니다. 기존의 Thread 클래스에 가상 스레드 생성을 위한 정적 메서드가 추가되었습니다.\n기본 생성 예제 // 단일 가상 스레드 생성 및 시작 Thread.ofVirtual().start(() -\u0026gt; { System.out.println(\u0026#34;가상 스레드에서 동작 중: \u0026#34; + Thread.currentThread()); }); // ExecutorService를 이용한 관리 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -\u0026gt; { // 비즈니스 로직 수행 return \u0026#34;Success\u0026#34;; }); } 위의 newVirtualThreadPerTaskExecutor()는 각 작업마다 새로운 가상 스레드를 할당합니다. 가상 스레드는 생성 비용이 매우 낮기 때문에 기존처럼 스레드 풀(Thread Pool)을 만들어 관리할 필요가 없다는 것이 핵심입니다.\n3. Spring Boot에서 가상 스레드 활성화하기 Spring Boot 3.2 버전부터는 설정 한 줄로 내장 톰캣(Tomcat)과 작업 스케줄러에서 가상 스레드를 사용하도록 지정할 수 있습니다.\napplication.yml 설정 spring: threads: virtual: enabled: true 이 설정을 활성화하면 Spring MVC의 요청 처리 스레드 모델이 가상 스레드로 전환됩니다. 블로킹 I/O(예: DB 조회, 외부 API 호출)가 발생하는 구간에서 가상 스레드가 자동으로 양보(yield)되어 다른 작업을 처리할 수 있게 하므로, 전체적인 시스템 처리량이 대폭 향상됩니다.\n4. 가상 스레드 도입 시 주의사항 (Pinning 현상) 가상 스레드가 만능은 아닙니다. 가장 주의해야 할 점은 \u0026lsquo;Pinning\u0026rsquo; 현상입니다. 가상 스레드 내에서 synchronized 블록을 사용하거나 네이티브 메서드를 호출할 경우, 해당 가상 스레드가 실행 중인 플랫폼 스레드(Carrier Thread)에 고정되어 다른 가상 스레드로 전환되지 못하는 문제가 발생할 수 있습니다.\n이를 방지하기 위해서는 synchronized 대신 java.util.concurrent.locks.ReentrantLock을 사용하는 것이 권장됩니다.\n// 개선 전: Pinning 발생 가능 public synchronized void updateData() { // I/O 작업 포함 시 위험 } // 개선 후: 가상 스레드 친화적 설계 private final ReentrantLock lock = new ReentrantLock(); public void updateData() { lock.lock(); try { // 비즈니스 로직 } finally { lock.unlock(); } } 결론 Java 21의 가상 스레드는 높은 동시성이 요구되는 서버 환경에서 게임 체인저가 될 수 있는 기술입니다. 코드의 복잡성을 낮추면서도 리소스를 효율적으로 사용할 수 있게 해주어, 인프라 비용 절감과 성능 향상을 동시에 꾀할 수 있습니다.\n가상 스레드를 도입할 때는 무조건적인 적용보다는 synchronized 사용 여부를 점검하고, 실제 I/O 집약적인 작업에서 성능 테스트를 거친 후 점진적으로 전환하는 것을 추천합니다. 차세대 Java 생태계의 중심이 될 가상 스레드를 지금 바로 프로젝트에 적용해 보시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/java-21-virtual-threads-optimization/","summary":"\u003cp\u003e최근 백엔드 개발 환경에서 동시성 처리는 시스템의 전체 성능과 직결되는 매우 중요한 요소입니다. 기존의 Java는 운영체제(OS)의 스레드와 1:1로 매핑되는 플랫폼 스레드 모델을 사용해 왔으나, 이는 메모리 점유율과 컨텍스트 스위칭 비용 측면에서 한계가 있었습니다. 특히 수만 개의 동시 연결을 처리해야 하는 현대적인 웹 애플리케이션에서는 이러한 한계가 병목 현상으로 작용하곤 합니다.\u003c/p\u003e\n\u003cp\u003e이번 포스팅에서는 Java 21에서 정식 도입된 가상 스레드(Virtual Threads)가 무엇인지, 그리고 이를 통해 어떻게 애플리케이션의 처리량을 획기적으로 개선할 수 있는지 상세히 살펴보겠습니다.\u003c/p\u003e","title":"Java 21 가상 스레드(Virtual Threads) 도입과 성능 최적화 가이드"},{"content":"1. 서론: \u0026ldquo;내 로컬에서는 잘 되는데요?\u0026ldquo;의 종말 개발 환경에서는 잘 돌아가던 코드가 운영 서버에만 올라가면 OS 환경 차이, 라이브러리 버전 문제로 죽어버리는 경우가 많습니다. Docker는 애플리케이션과 그에 필요한 모든 라이브러리, 설정을 하나의 \u0026lsquo;컨테이너\u0026rsquo;로 묶어 어디서든 동일하게 실행되도록 보장합니다.\n현대적인 개발자라면 반드시 마스터해야 할 Spring Boot의 Dockerizing 기법을 상세히 알아보겠습니다.\n2. 효율적인 Dockerfile 작성을 위한 멀티 스테이지 빌드 단순히 JAR 파일을 통째로 복사하는 방식은 이미지 용량이 너무 커집니다. 빌드 단계와 실행 단계를 나누는 **멀티 스테이지 빌드(Multi-stage Build)**를 사용하면 보안과 성능을 모두 챙길 수 있습니다.\n최적화된 Dockerfile 예시 # 1단계: 빌드 스테이지 FROM eclipse-temurin:21-jdk-jammy AS build WORKDIR /app COPY . . # 권한 부여 및 빌드 (테스트 제외) RUN chmod +x ./gradlew RUN ./gradlew clean bootJar -x test # 2단계: 실행 스테이지 (최소 용량 이미지 사용) FROM eclipse-temurin:21-jre-jammy WORKDIR /app # 빌드 스테이지에서 생성된 JAR만 복사 COPY --from=build /app/build/libs/*.jar app.jar # 보안을 위해 비루트(Non-root) 사용자 생성 및 실행 RUN useradd -m myuser USER myuser ENTRYPOINT [\u0026#34;java\u0026#34;, \u0026#34;-jar\u0026#34;, \u0026#34;app.jar\u0026#34;] EXPOSE 8080 3. 실무 사례: 컨테이너 기반의 무중단 배포 환경 구축 상황: 새로운 기능을 배포할 때마다 서버를 잠시 꺼야 하는 상황입니다.\n해결책: Docker를 활용하면 신규 버전의 컨테이너를 먼저 띄우고, Nginx의 리버스 프록시 설정을 변경하여 트래픽을 넘기는 방식으로 무중단 배포(Blue-Green Deployment)를 손쉽게 구현할 수 있습니다.\n# 1. 신규 버전 이미지 빌드 docker build -t chanyeols-app:v2.0 . # 2. 신규 컨테이너 실행 (8081 포트) docker run -d --name app-v2 -p 8081:8080 chanyeols-app:v2.0 # 3. Nginx 설정 변경 (8080 -\u0026gt; 8081) 및 Reload # 4. 기존 v1.0 컨테이너 종료 docker stop app-v1 \u0026amp;\u0026amp; docker rm app-v1 4. Dockerize 시 주의할 점 (Best Practices) 레이어 캐싱 활용: 자주 바뀌지 않는 의존성(gradle, pom.xml)을 먼저 복사하고 빌드하면, 다음 빌드 속도가 비약적으로 빨라집니다. 최소 권한 원칙: 컨테이너 내부에서 root 권한으로 프로세스를 실행하는 것은 보안에 취약합니다. 위 예시처럼 전용 사용자를 생성하세요. 환경 변수 활용: DB 접속 정보와 같은 민감 정보는 Dockerfile에 적지 말고, docker run -e 옵션이나 env 파일을 통해 주입해야 합니다. 5. 마치며: 컨테이너 환경으로의 초대 Docker를 도입하는 것은 단순히 배포 방식을 바꾸는 것을 넘어, 전체 개발 워크플로우를 표준화하는 일입니다. 오늘 만든 Docker 이미지는 나중에 쿠버네티스(Kubernetes) 환경으로 확장할 때도 그대로 사용할 수 있습니다.\n애드센스 승인을 위한 블로그 운영도 중요하지만, 이렇게 자동화된 배포 환경을 구축해보는 경험은 여러분을 더 높은 수준의 엔지니어로 만들어줄 것입니다.\n","permalink":"https://chanyeols.com/posts/docker-spring-boot-guide/","summary":"\u003ch2 id=\"1-서론-내-로컬에서는-잘-되는데요의-종말\"\u003e1. 서론: \u0026ldquo;내 로컬에서는 잘 되는데요?\u0026ldquo;의 종말\u003c/h2\u003e\n\u003cp\u003e개발 환경에서는 잘 돌아가던 코드가 운영 서버에만 올라가면 OS 환경 차이, 라이브러리 버전 문제로 죽어버리는 경우가 많습니다. \u003cstrong\u003eDocker\u003c/strong\u003e는 애플리케이션과 그에 필요한 모든 라이브러리, 설정을 하나의 \u0026lsquo;컨테이너\u0026rsquo;로 묶어 어디서든 동일하게 실행되도록 보장합니다.\u003c/p\u003e\n\u003cp\u003e현대적인 개발자라면 반드시 마스터해야 할 Spring Boot의 Dockerizing 기법을 상세히 알아보겠습니다.\u003c/p\u003e\n\u003ch2 id=\"2-효율적인-dockerfile-작성을-위한-멀티-스테이지-빌드\"\u003e2. 효율적인 Dockerfile 작성을 위한 멀티 스테이지 빌드\u003c/h2\u003e\n\u003cp\u003e단순히 JAR 파일을 통째로 복사하는 방식은 이미지 용량이 너무 커집니다. 빌드 단계와 실행 단계를 나누는 **멀티 스테이지 빌드(Multi-stage Build)**를 사용하면 보안과 성능을 모두 챙길 수 있습니다.\u003c/p\u003e","title":"Spring Boot 애플리케이션 Docker 라이징: 개발부터 배포까지 한 번에 끝내기"},{"content":"1. 서론: 왜 Redis 캐시를 사용해야 할까? 사용자가 늘어남에 따라 데이터베이스(DB) 서버는 병목 지점이 됩니다. 매번 동일한 데이터를 DB에서 가져오는 대신, 메모리 기반의 고성능 저장소인 Redis에 보관해두면 어떨까요?\nRedis는 초당 수만 건의 읽기/쓰기를 처리할 수 있어, 잦은 조회 작업이 발생하는 API 성능 개선에 탁월합니다. 이번 포스팅에서는 실무 사례를 바탕으로 Redis 캐시 적용법을 살펴보겠습니다.\n2. Spring Boot Redis 설정과 구현 먼저, Spring Boot에서 Redis 라이브러리를 추가하고 캐시 매니저를 설정해야 합니다.\nMaven 의존성 추가 및 설정 예시 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-data-redis\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-cache\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; @Configuration @EnableCaching public class RedisCacheConfig { @Bean public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) // 캐시 유효 시간: 10분 .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(config) .build(); } } 3. 실무 사례: 인기 상품 상세 페이지 캐싱 상황: 하루 수십만 명이 접속하는 커머스 사이트에서, 베스트셀러 상품 상세 정보를 조회하는 쿼리가 DB에 과부하를 주고 있습니다.\n캐시 적용 로직 (Look-aside 패턴) @Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; @Cacheable(value = \u0026#34;products\u0026#34;, key = \u0026#34;#id\u0026#34;, unless = \u0026#34;#result == null\u0026#34;) public ProductDto getProductDetail(Long id) { log.info(\u0026#34;DB에서 상품 정보를 조회합니다. (id: \u0026#34; + id + \u0026#34;)\u0026#34;); return productRepository.findById(id) .map(ProductDto::from) .orElse(null); } @CacheEvict(value = \u0026#34;products\u0026#34;, key = \u0026#34;#id\u0026#34;) public void updateProduct(Long id, ProductUpdateDto updateDto) { // 상품 정보 수정 시 캐시 삭제 (Cache-aside 패턴의 핵심) productRepository.update(id, updateDto); } } 이 코드를 적용하면 첫 번째 조회 시에만 DB에 접근하고, 이후 10분간은 Redis에서 데이터를 가져오므로 DB 부하가 90% 이상 줄어듭니다.\n4. Redis 캐시 운영 시 주의사항 (Critical Tips) 데이터 일관성(Inconsistency): DB의 데이터가 바뀌었을 때 캐시도 즉시 삭제하거나 업데이트해야 합니다. @CacheEvict를 적절히 활용하세요. 캐시 유효 시간(TTL) 설정: 무한정 캐시를 쌓아두면 Redis 메모리가 부족해집니다. 데이터의 중요도에 따라 적절한 TTL(예: 1분, 10분, 1시간)을 설정해야 합니다. 직렬화 문제: Redis에 저장할 객체는 반드시 Serializable을 구현하거나, JSON 형식으로 직렬화 설정을 해줘야 나중에 다른 서버에서도 읽을 수 있습니다. 5. 결론: \u0026ldquo;캐시\u0026quot;는 선택이 아닌 필수입니다. 사용자의 트래픽이 몰리는 지점(Hotspot)을 찾아 적절히 Redis 캐시를 적용하는 것만으로도, 고가의 DB 장비를 증설하는 것보다 수십 배 높은 비용 효율을 낼 수 있습니다.\n다음 포스팅에서는 이 서비스를 안정적으로 배포하기 위한 Docker 컨테이너화 전략에 대해 다뤄보겠습니다.\n","permalink":"https://chanyeols.com/posts/redis-cache-spring-boot/","summary":"\u003ch2 id=\"1-서론-왜-redis-캐시를-사용해야-할까\"\u003e1. 서론: 왜 Redis 캐시를 사용해야 할까?\u003c/h2\u003e\n\u003cp\u003e사용자가 늘어남에 따라 데이터베이스(DB) 서버는 병목 지점이 됩니다. 매번 동일한 데이터를 DB에서 가져오는 대신, 메모리 기반의 고성능 저장소인 \u003cstrong\u003eRedis\u003c/strong\u003e에 보관해두면 어떨까요?\u003c/p\u003e\n\u003cp\u003eRedis는 초당 수만 건의 읽기/쓰기를 처리할 수 있어, 잦은 조회 작업이 발생하는 API 성능 개선에 탁월합니다. 이번 포스팅에서는 실무 사례를 바탕으로 Redis 캐시 적용법을 살펴보겠습니다.\u003c/p\u003e\n\u003ch2 id=\"2-spring-boot-redis-설정과-구현\"\u003e2. Spring Boot Redis 설정과 구현\u003c/h2\u003e\n\u003cp\u003e먼저, Spring Boot에서 Redis 라이브러리를 추가하고 캐시 매니저를 설정해야 합니다.\u003c/p\u003e\n\u003ch3 id=\"maven-의존성-추가-및-설정-예시\"\u003eMaven 의존성 추가 및 설정 예시\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-xml\" data-lang=\"xml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;dependency\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;groupId\u0026gt;\u003c/span\u003eorg.springframework.boot\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/groupId\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;artifactId\u0026gt;\u003c/span\u003espring-boot-starter-data-redis\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/artifactId\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/dependency\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;dependency\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;groupId\u0026gt;\u003c/span\u003eorg.springframework.boot\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/groupId\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;artifactId\u0026gt;\u003c/span\u003espring-boot-starter-cache\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/artifactId\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/dependency\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-java\" data-lang=\"java\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#d2a8ff;font-weight:bold\"\u003e@Configuration\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#d2a8ff;font-weight:bold\"\u003e@EnableCaching\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ff7b72\"\u003epublic\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#ff7b72\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#f0883e;font-weight:bold\"\u003eRedisCacheConfig\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e{\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#d2a8ff;font-weight:bold\"\u003e@Bean\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#ff7b72\"\u003epublic\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003eCacheManager\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#d2a8ff;font-weight:bold\"\u003ecacheManager\u003c/span\u003e(RedisConnectionFactory\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003econnectionFactory)\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e{\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e        \u003c/span\u003eRedisCacheConfiguration\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003econfig\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003e\u003cspan style=\"color:#ff7b72;font-weight:bold\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003eRedisCacheConfiguration.defaultCacheConfig()\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e                \u003c/span\u003e.entryTtl(Duration.ofMinutes(10))\u003cspan style=\"color:#6e7681\"\u003e  \u003c/span\u003e\u003cspan style=\"color:#8b949e;font-style:italic\"\u003e// 캐시 유효 시간: 10분\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e                \u003c/span\u003e.serializeKeysWith(SerializationPair.fromSerializer(\u003cspan style=\"color:#ff7b72\"\u003enew\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003eStringRedisSerializer()))\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e                \u003c/span\u003e.serializeValuesWith(SerializationPair.fromSerializer(\u003cspan style=\"color:#ff7b72\"\u003enew\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003eGenericJackson2JsonRedisSerializer()));\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e        \u003c/span\u003e\u003cspan style=\"color:#ff7b72\"\u003ereturn\u003c/span\u003e\u003cspan style=\"color:#6e7681\"\u003e \u003c/span\u003eRedisCacheManager.builder(connectionFactory)\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e                \u003c/span\u003e.cacheDefaults(config)\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e                \u003c/span\u003e.build();\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6e7681\"\u003e    \u003c/span\u003e}\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\u003cspan style=\"color:#6e7681\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"3-실무-사례-인기-상품-상세-페이지-캐싱\"\u003e3. 실무 사례: 인기 상품 상세 페이지 캐싱\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e상황:\u003c/strong\u003e 하루 수십만 명이 접속하는 커머스 사이트에서, 베스트셀러 상품 상세 정보를 조회하는 쿼리가 DB에 과부하를 주고 있습니다.\u003c/p\u003e","title":"Redis를 활용한 Spring Boot 캐싱 처리: 성능과 고가용성을 동시에 잡는 법"},{"content":"1. 서론: 인덱스는 왜 중요한가? 데이터베이스의 성능은 곧 서비스의 응답 속도와 직결됩니다. 데이터가 수만 건일 때는 문제가 없다가, 수백만 건을 넘어서는 순간 웹 사이트가 느려지는 경험을 해보셨을 겁니다. 이때 가장 먼저 확인해야 할 것이 바로 **인덱스(Index)**입니다.\n인덱스는 책의 맨 뒤에 있는 \u0026lsquo;색인\u0026rsquo;과 같습니다. 방대한 데이터 속에서 내가 원하는 정보를 찾기 위해 처음부터 끝까지 다 뒤지는 \u0026lsquo;Full Table Scan\u0026rsquo;을 방지하고, B-Tree 구조를 통해 원하는 위치로 즉시 점프하게 해줍니다.\n2. 실무 사례: 500만 건의 주문 테이블 쿼리 최적화 상황: 쇼핑몰 프로젝트에서 특정 사용자의 최근 주문 내역을 조회하는 쿼리가 5초 이상 소요되고 있습니다.\n기존 쿼리:\nSELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 10; 문제점: user_id에 인덱스가 없다면, MySQL은 500만 건의 데이터를 모두 확인해야 합니다.\n해결책: 복합 인덱스(Composite Index) 생성 단순히 user_id에만 인덱스를 거는 것이 아니라, 정렬 조건까지 포함한 복합 인덱스를 생성하여 성능을 극대화합니다.\n# 최적의 인덱스 생성 CREATE INDEX idx_user_id_created_at ON orders (user_id, created_at DESC); 이 설정을 통해 MySQL은 특정 사용자의 데이터를 찾자마자 이미 정렬된 상태로 데이터를 읽어오기 때문에, 5초 걸리던 쿼리가 0.01초 내외로 단축됩니다.\n3. 인덱스 설계 시 반드시 지켜야 할 원칙 카디널리티(Cardinality)가 높은 컬럼 선택: 성별(남/여)처럼 중복도가 높은 컬럼보다는 주민번호나 이메일처럼 중복도가 낮은 컬럼에 인덱스를 걸어야 효과적입니다. WHERE 절뿐만 아니라 ORDER BY, GROUP BY도 고려: 쿼리 실행 계획(Explain)을 확인하여 인덱스가 정렬 과정에서도 사용되는지 확인해야 합니다. 인덱스 과유불급: 인덱스는 조회 속도를 높여주지만, INSERT, UPDATE, DELETE 시에는 인덱스도 함께 갱신해야 하므로 쓰기 성능을 저하시킵니다. 꼭 필요한 곳에만 생성하세요. 4. 인덱스가 작동하지 않는 나쁜 쿼리 예시 인덱스를 만들어 놓고도 제대로 활용하지 못하는 경우입니다:\n컬럼을 가공하는 경우: WHERE ABS(score) \u0026gt; 100 (인덱스 사용 불가) -\u0026gt; WHERE score \u0026gt; 100 OR score \u0026lt; -100으로 변경. 부정형 조건을 사용하는 경우: WHERE status != 'DELETED' (인덱스 효율이 매우 떨어짐). LIKE 연산자 앞에 %를 붙이는 경우: WHERE name LIKE '%찬열' (Full Scan 유발). 5. 마치며: EXPLAIN을 습관화하자 내가 만든 인덱스가 실제로 잘 작동하는지 확인하는 가장 좋은 방법은 쿼리 앞에 EXPLAIN 키워드를 붙여 실행 계획을 분석하는 것입니다. type 항목이 ref나 range가 아닌 ALL로 나온다면 즉시 개선이 필요합니다.\n데이터가 쌓이기 전에 미리 인덱스 전략을 수립하여, 사용자에게 쾌적한 환경을 제공하는 개발자가 됩시다.\n","permalink":"https://chanyeols.com/posts/mysql-index-optimization/","summary":"\u003ch2 id=\"1-서론-인덱스는-왜-중요한가\"\u003e1. 서론: 인덱스는 왜 중요한가?\u003c/h2\u003e\n\u003cp\u003e데이터베이스의 성능은 곧 서비스의 응답 속도와 직결됩니다. 데이터가 수만 건일 때는 문제가 없다가, 수백만 건을 넘어서는 순간 웹 사이트가 느려지는 경험을 해보셨을 겁니다. 이때 가장 먼저 확인해야 할 것이 바로 **인덱스(Index)**입니다.\u003c/p\u003e\n\u003cp\u003e인덱스는 책의 맨 뒤에 있는 \u0026lsquo;색인\u0026rsquo;과 같습니다. 방대한 데이터 속에서 내가 원하는 정보를 찾기 위해 처음부터 끝까지 다 뒤지는 \u0026lsquo;Full Table Scan\u0026rsquo;을 방지하고, B-Tree 구조를 통해 원하는 위치로 즉시 점프하게 해줍니다.\u003c/p\u003e\n\u003ch2 id=\"2-실무-사례-500만-건의-주문-테이블-쿼리-최적화\"\u003e2. 실무 사례: 500만 건의 주문 테이블 쿼리 최적화\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e상황:\u003c/strong\u003e 쇼핑몰 프로젝트에서 특정 사용자의 최근 주문 내역을 조회하는 쿼리가 5초 이상 소요되고 있습니다.\u003c/p\u003e","title":"MySQL 인덱스 최적화 전략: 느린 쿼리를 100배 빠르게 만드는 마법"},{"content":"서론 서비스 규모가 커지면 L4나 L7 로드 밸런서를 통해 여러 대의 WAS(Tomcat)를 운영하게 됩니다. 이때 사용자의 세션 정보가 특정 서버에만 남아있다면, 로드 밸런싱 과정에서 세션 끊김 현상이 발생합니다. 이를 해결하기 위해 서버 간 세션을 동기화하는 세션 클러스터링(Session Clustering) 설정이 필수적입니다.\n1. Tomcat 기본 설정 (server.xml) Tomcat은 멀티캐스트(Multicast) 방식을 이용한 자체 세션 복제 기능을 제공합니다. server.xml의 \u0026lt;Host\u0026gt; 태그 내부에 \u0026lt;Cluster\u0026gt; 설정을 추가합니다.\n\u0026lt;Cluster className=\u0026#34;org.apache.catalina.ha.tcp.SimpleTcpCluster\u0026#34; channelSendOptions=\u0026#34;8\u0026#34;\u0026gt; \u0026lt;Manager className=\u0026#34;org.apache.catalina.ha.session.DeltaManager\u0026#34; expireSessionsOnShutdown=\u0026#34;false\u0026#34; notifyListenersOnReplication=\u0026#34;true\u0026#34;/\u0026gt; \u0026lt;Channel className=\u0026#34;org.apache.catalina.tribes.group.GroupChannel\u0026#34;\u0026gt; \u0026lt;Membership className=\u0026#34;org.apache.catalina.tribes.membership.McastService\u0026#34; address=\u0026#34;228.0.0.4\u0026#34; port=\u0026#34;45564\u0026#34; frequency=\u0026#34;500\u0026#34; dropTime=\u0026#34;3000\u0026#34;/\u0026gt; \u0026lt;Receiver className=\u0026#34;org.apache.catalina.tribes.transport.nio.NioReceiver\u0026#34; address=\u0026#34;auto\u0026#34; port=\u0026#34;4000\u0026#34; autoBind=\u0026#34;100\u0026#34; selectorTimeout=\u0026#34;5000\u0026#34; maxThreads=\u0026#34;6\u0026#34;/\u0026gt; \u0026lt;/Channel\u0026gt; \u0026lt;/Cluster\u0026gt; 2. 애플리케이션 활성화 (web.xml) 클러스터링 대상이 되는 웹 애플리케이션의 WEB-INF/web.xml 파일에 반드시 다음 태그를 추가해야 합니다. 이 태그가 없으면 세션 복제가 작동하지 않습니다.\n\u0026lt;web-app\u0026gt; ... \u0026lt;distributable /\u0026gt; \u0026lt;/web-app\u0026gt; 3. 실무에서의 한계와 대안 (Redis/Spring Session) Tomcat 자체 세션 복제는 소규모 환경에서는 간편하지만, 서버 수가 늘어날수록 멀티캐스트 통신 오버헤드가 급격히 증가하는 단점이 있습니다.\nSticky Session: 클라이언트 IP를 해싱하여 동일한 서버로만 보내는 방식 (가장 간편하나 서버 장애 시 세션 유실). Redis Session Storage: 별도의 Redis 서버에 세션을 저장하고 모든 WAS가 이를 공유하는 방식 (가장 권장되는 실무 패턴). 핵심 SEO 포인트: 고가용성과 세션 유실 distributable: web.xml에 이 설정이 누락되어 세션 복제가 안 되는 경우가 실무 장애의 90%입니다. Multicast vs Unicast: 클라우드 환경(AWS, GCP 등)에서는 멀티캐스트를 지원하지 않는 경우가 많으므로 설정 전 확인이 필요합니다. 결론 Tomcat 세션 클러스터링은 중단 없는 서비스를 위한 핵심 인프라 기술입니다. 운영 환경의 규모와 클라우드 지원 여부에 따라 Tomcat 자체 클러스터링이나 Redis 기반 세션 공유 방식을 적절히 선택하여 적용해 보시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/tomcat-session-clustering-replication-guide/","summary":"\u003ch2 id=\"서론\"\u003e서론\u003c/h2\u003e\n\u003cp\u003e서비스 규모가 커지면 L4나 L7 로드 밸런서를 통해 여러 대의 WAS(Tomcat)를 운영하게 됩니다. 이때 사용자의 세션 정보가 특정 서버에만 남아있다면, 로드 밸런싱 과정에서 세션 끊김 현상이 발생합니다. 이를 해결하기 위해 서버 간 세션을 동기화하는 \u003cstrong\u003e세션 클러스터링(Session Clustering)\u003c/strong\u003e 설정이 필수적입니다.\u003c/p\u003e\n\u003ch2 id=\"1-tomcat-기본-설정-serverxml\"\u003e1. Tomcat 기본 설정 (server.xml)\u003c/h2\u003e\n\u003cp\u003eTomcat은 멀티캐스트(Multicast) 방식을 이용한 자체 세션 복제 기능을 제공합니다. \u003ccode\u003eserver.xml\u003c/code\u003e의 \u003ccode\u003e\u0026lt;Host\u0026gt;\u003c/code\u003e 태그 내부에 \u003ccode\u003e\u0026lt;Cluster\u0026gt;\u003c/code\u003e 설정을 추가합니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-xml\" data-lang=\"xml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;Cluster\u003c/span\u003e className=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;org.apache.catalina.ha.tcp.SimpleTcpCluster\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e         channelSendOptions=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;8\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;Manager\u003c/span\u003e className=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;org.apache.catalina.ha.session.DeltaManager\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e             expireSessionsOnShutdown=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;false\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e             notifyListenersOnReplication=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;true\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;Channel\u003c/span\u003e className=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;org.apache.catalina.tribes.group.GroupChannel\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;Membership\u003c/span\u003e className=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;org.apache.catalina.tribes.membership.McastService\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    address=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;228.0.0.4\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    port=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;45564\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    frequency=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;500\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    dropTime=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;3000\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;Receiver\u003c/span\u003e className=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;org.apache.catalina.tribes.transport.nio.NioReceiver\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                  address=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;auto\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                  port=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;4000\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                  autoBind=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;100\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                  selectorTimeout=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;5000\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                  maxThreads=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;6\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/Channel\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/Cluster\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"2-애플리케이션-활성화-webxml\"\u003e2. 애플리케이션 활성화 (web.xml)\u003c/h2\u003e\n\u003cp\u003e클러스터링 대상이 되는 웹 애플리케이션의 \u003ccode\u003eWEB-INF/web.xml\u003c/code\u003e 파일에 반드시 다음 태그를 추가해야 합니다. 이 태그가 없으면 세션 복제가 작동하지 않습니다.\u003c/p\u003e","title":"Tomcat 세션 클러스터링 가이드: 고가용성(HA) 환경의 세션 복제 설정"},{"content":"서론 Tomcat 기반의 Java 애플리케이션을 운영하다 보면 가장 빈번하게 발생하는 장애 중 하나가 바로 OutOfMemoryError입니다. 서버의 물리적 메모리가 충분하더라도 JVM에 할당된 메모리가 적절하지 않으면 서비스는 쉽게 멈출 수 있습니다. 이번 포스팅에서는 실무에서 필수적인 JVM 메모리 설정 파라미터를 정리합니다.\n1. 힙 메모리(Heap Memory) 핵심 설정 힙 메모리는 객체가 생성되고 상주하는 공간입니다. 가장 중요한 두 가지 설정은 -Xms와 -Xmx입니다.\n-Xms: JVM이 시작될 때 할당하는 초기 힙 크기입니다. -Xmx: JVM이 가질 수 있는 최대 힙 크기입니다. 실무 권장 설정 (setenv.sh) Tomcat의 bin/setenv.sh(윈도우는 setenv.bat) 파일을 생성하거나 수정하여 설정합니다.\n# 초기값과 최대값을 동일하게 설정하여 힙 확장에 따른 성능 저하를 방지합니다. export CATALINA_OPTS=\u0026#34;$CATALINA_OPTS -Xms2g -Xmx2g\u0026#34; 2. Metaspace 및 기타 설정 Java 8 이후부터는 기존의 PermGen 대신 Metaspace를 사용합니다. 클래스 메타데이터가 저장되는 공간입니다.\n-XX:MetaspaceSize: 초기 메타스페이스 크기. -XX:MaxMetaspaceSize: 최대 메타스페이스 크기. (제한을 두지 않으면 시스템 메모리를 모두 사용할 수 있으므로 주의가 필요합니다.) export CATALINA_OPTS=\u0026#34;$CATALINA_OPTS -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m\u0026#34; 3. GC(Garbage Collection) 로그 설정 메모리 문제를 진단하기 위해 GC 로그를 남기는 것은 필수입니다.\nexport CATALINA_OPTS=\u0026#34;$CATALINA_OPTS -Xlog:gc*:file=logs/gc.log:time,uptime,level,tags:filecount=10,filesize=10M\u0026#34; 핵심 SEO 포인트: Xms와 Xmx의 동일 설정 왜 동일하게 설정하나?: 힙 크기가 동적으로 변하면 GC 부하가 증가하고 일시적인 성능 저하(STW, Stop-The-World)가 발생할 수 있습니다. 상용 서버에서는 초기값과 최대값을 동일하게 가져가는 것이 표준입니다. 물리 메모리의 50~80%: 전체 물리 메모리의 50~80% 내외에서 힙 크기를 결정하고, 나머지는 OS와 비힙(Non-heap) 영역을 위해 남겨두는 것이 좋습니다. 결론 JVM 메모리 설정은 서버의 안정성을 담보하는 가장 기본적인 작업입니다. 서비스의 트래픽 규모와 객체 생성 패턴에 맞춰 점진적으로 최적의 수치를 찾아보시기 바랍니다. 메모리 사용량은 jstat이나 VisualVM 같은 도구로 주기적으로 모니터링하는 것을 잊지 마세요.\n","permalink":"https://chanyeols.com/posts/tomcat-jvm-heap-memory-tuning-guide/","summary":"\u003ch2 id=\"서론\"\u003e서론\u003c/h2\u003e\n\u003cp\u003eTomcat 기반의 Java 애플리케이션을 운영하다 보면 가장 빈번하게 발생하는 장애 중 하나가 바로 \u003ccode\u003eOutOfMemoryError\u003c/code\u003e입니다. 서버의 물리적 메모리가 충분하더라도 JVM에 할당된 메모리가 적절하지 않으면 서비스는 쉽게 멈출 수 있습니다. 이번 포스팅에서는 실무에서 필수적인 JVM 메모리 설정 파라미터를 정리합니다.\u003c/p\u003e\n\u003ch2 id=\"1-힙-메모리heap-memory-핵심-설정\"\u003e1. 힙 메모리(Heap Memory) 핵심 설정\u003c/h2\u003e\n\u003cp\u003e힙 메모리는 객체가 생성되고 상주하는 공간입니다. 가장 중요한 두 가지 설정은 \u003ccode\u003e-Xms\u003c/code\u003e와 \u003ccode\u003e-Xmx\u003c/code\u003e입니다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e-Xms:\u003c/strong\u003e JVM이 시작될 때 할당하는 초기 힙 크기입니다.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e-Xmx:\u003c/strong\u003e JVM이 가질 수 있는 최대 힙 크기입니다.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"실무-권장-설정-setenvsh\"\u003e실무 권장 설정 (setenv.sh)\u003c/h3\u003e\n\u003cp\u003eTomcat의 \u003ccode\u003ebin/setenv.sh\u003c/code\u003e(윈도우는 \u003ccode\u003esetenv.bat\u003c/code\u003e) 파일을 생성하거나 수정하여 설정합니다.\u003c/p\u003e","title":"Tomcat JVM 힙 메모리(Heap Memory) 최적화 설정 가이드"},{"content":"서론 웹 서비스 운영 중 발생하는 문제의 80%는 로그 분석을 통해 해결됩니다. 특히 **접근 로그(Access Log)**는 누가, 언제, 어떤 페이지를 호출했고 얼마나 빨리 응답했는지를 기록하는 핵심 데이터입니다. Tomcat의 기본 설정을 넘어 실무에서 필요한 데이터들을 추출하는 커스텀 방법을 정리합니다.\n1. 접근 로그 패턴 커스텀 (server.xml) Tomcat의 server.xml 파일 내 \u0026lt;Valve\u0026gt; 설정을 통해 로그 패턴을 변경할 수 있습니다.\n실무 추천 패턴 기본 패턴에는 가장 중요한 \u0026lsquo;처리 시간(Processing Time)\u0026lsquo;이 빠져 있는 경우가 많습니다.\n\u0026lt;Valve className=\u0026#34;org.apache.catalina.valves.AccessLogValve\u0026#34; directory=\u0026#34;logs\u0026#34; prefix=\u0026#34;localhost_access_log\u0026#34; suffix=\u0026#34;.txt\u0026#34; pattern=\u0026#34;%h %l %u %t \u0026amp;quot;%r\u0026amp;quot; %s %b %D %F %{X-Forwarded-For}i\u0026#34; /\u0026gt; %D: 요청 처리에 소요된 시간 (밀리초, ms) %F: 응답을 커밋하는 데 소요된 시간 (밀리초, ms) %{X-Forwarded-For}i: 리버스 프록시(Nginx 등) 뒤에 있을 때 실제 클라이언트 IP를 기록 2. 로그 순환(Rotation) 및 보관 정책 로그 파일이 무한정 커지는 것을 방지하기 위해 날짜별 분리 설정을 확인해야 합니다.\n\u0026lt;Valve ... rotatable=\u0026#34;true\u0026#34; renameOnRotate=\u0026#34;true\u0026#34; fileDateFormat=\u0026#34;yyyy-MM-dd\u0026#34; /\u0026gt; rotatable: 날짜가 바뀌면 새 파일을 생성할지 여부. renameOnRotate: 활성 로그 파일 이름을 고정하고 이전 로그를 날짜별로 변경할지 여부. 3. catalina.out 무한 증식 방지 Tomcat의 모든 표준 출력(stdout)이 기록되는 catalina.out은 파일 크기가 계속 커져 디스크 풀(Full)의 주범이 됩니다.\n방법 1: logrotate 도구 사용 (가장 권장). 방법 2: 애플리케이션 레벨(Logback, Log4j2)에서 로그 파일을 직접 파일로 빼내고 Tomcat의 콘솔 로그 비중을 줄임. 핵심 SEO 포인트: 응답 속도 분석 (%D) 왜 %D를 남겨야 하나?: 단순한 서버 에러뿐만 아니라 특정 API가 왜 느려졌는지, DB 지연인지 네트워크 지연인지를 파악하기 위한 가장 기초적인 데이터가 바로 %D를 통한 응답 속도 추적입니다. X-Forwarded-For: 로드 밸런서 환경에서 실제 사용자 IP를 식별하는 것은 보안 및 마케팅 분석의 필수 요소입니다. 결론 로그는 서비스의 발자취입니다. 특히 Tomcat의 접근 로그는 단순히 기록하는 것을 넘어 \u0026lsquo;분석 가능한 형태\u0026rsquo;로 남기는 것이 중요합니다. 오늘 정리한 %D와 X-Forwarded-For 설정을 통해 장애 대응 능력을 한 단계 높여 보시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/tomcat-access-log-customization-analysis/","summary":"\u003ch2 id=\"서론\"\u003e서론\u003c/h2\u003e\n\u003cp\u003e웹 서비스 운영 중 발생하는 문제의 80%는 로그 분석을 통해 해결됩니다. 특히 **접근 로그(Access Log)**는 누가, 언제, 어떤 페이지를 호출했고 얼마나 빨리 응답했는지를 기록하는 핵심 데이터입니다. Tomcat의 기본 설정을 넘어 실무에서 필요한 데이터들을 추출하는 커스텀 방법을 정리합니다.\u003c/p\u003e\n\u003ch2 id=\"1-접근-로그-패턴-커스텀-serverxml\"\u003e1. 접근 로그 패턴 커스텀 (server.xml)\u003c/h2\u003e\n\u003cp\u003eTomcat의 \u003ccode\u003eserver.xml\u003c/code\u003e 파일 내 \u003ccode\u003e\u0026lt;Valve\u0026gt;\u003c/code\u003e 설정을 통해 로그 패턴을 변경할 수 있습니다.\u003c/p\u003e\n\u003ch3 id=\"실무-추천-패턴\"\u003e실무 추천 패턴\u003c/h3\u003e\n\u003cp\u003e기본 패턴에는 가장 중요한 \u0026lsquo;처리 시간(Processing Time)\u0026lsquo;이 빠져 있는 경우가 많습니다.\u003c/p\u003e","title":"Tomcat 접근 로그(Access Log) 커스텀 설정과 분석 효율 극대화"},{"content":"안녕하세요, chanyeol입니다.\n이 블로그는 프론트엔드와 백엔드를 아우르는 IT 전반에 대한 지식을 다루는 공간입니다. 주니어 개발자로서 마주하는 기술적 고민과 해결 과정을 기록하며 동료 개발자들과 함께 성장하는 것을 목표로 합니다.\n","permalink":"https://chanyeols.com/about/","summary":"\u003cp\u003e안녕하세요, \u003cstrong\u003echanyeol\u003c/strong\u003e입니다.\u003c/p\u003e\n\u003cp\u003e이 블로그는 프론트엔드와 백엔드를 아우르는 \u003cstrong\u003eIT 전반\u003c/strong\u003e에 대한 지식을 다루는 공간입니다. 주니어 개발자로서 마주하는 기술적 고민과 해결 과정을 기록하며 동료 개발자들과 함께 성장하는 것을 목표로 합니다.\u003c/p\u003e","title":"About Me"},{"content":"블로그 내용에 대한 피드백이나 비즈니스 문의는 아래 이메일로 연락 부탁드립니다.\nEmail: ocy7231@gmail.com Response Time: 보통 1~2일 내에 회신해 드립니다. 감사합니다.\n","permalink":"https://chanyeols.com/contact/","summary":"\u003cp\u003e블로그 내용에 대한 피드백이나 비즈니스 문의는 아래 이메일로 연락 부탁드립니다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eEmail:\u003c/strong\u003e \u003ca href=\"mailto:ocy7231@gmail.com\"\u003eocy7231@gmail.com\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eResponse Time:\u003c/strong\u003e 보통 1~2일 내에 회신해 드립니다.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e감사합니다.\u003c/p\u003e","title":"Contact"},{"content":"본 블로그(chanyeols.com)는 방문자의 개인정보를 소중히 여기며, 관련 법령을 준수합니다.\n쿠키(Cookies): 구글 애드센스 등 제3자 제공업체는 쿠키를 사용하여 사용자의 이전 방문 기록을 바탕으로 광고를 게재합니다. 로그 파일: 웹 서버는 표준 절차에 따라 로그 파일을 수집하며, 이는 IP 주소, 브라우저 유형, 접속 시간 등을 포함할 수 있습니다. 문의처: 개인정보 관련 문의는 ocy7231@gmail.com으로 연락 바랍니다. ","permalink":"https://chanyeols.com/privacy-policy/","summary":"\u003cp\u003e본 블로그(chanyeols.com)는 방문자의 개인정보를 소중히 여기며, 관련 법령을 준수합니다.\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e쿠키(Cookies):\u003c/strong\u003e 구글 애드센스 등 제3자 제공업체는 쿠키를 사용하여 사용자의 이전 방문 기록을 바탕으로 광고를 게재합니다.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e로그 파일:\u003c/strong\u003e 웹 서버는 표준 절차에 따라 로그 파일을 수집하며, 이는 IP 주소, 브라우저 유형, 접속 시간 등을 포함할 수 있습니다.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e문의처:\u003c/strong\u003e 개인정보 관련 문의는 \u003ca href=\"mailto:ocy7231@gmail.com\"\u003eocy7231@gmail.com\u003c/a\u003e으로 연락 바랍니다.\u003c/li\u003e\n\u003c/ol\u003e","title":"개인정보처리방침 (Privacy Policy)"},{"content":"서론 웹 서비스의 보안(HTTPS)은 이제 선택이 아닌 필수입니다. Tomcat에서 SSL 인증서를 적용하는 방법은 인증서 형식(JKS, PKCS12 등)에 따라 설정 방식이 조금씩 다릅니다. 이번 포스팅에서는 인증서 적용부터 HTTP 요청을 HTTPS로 강제 전환하는 방법까지 정리합니다.\n1. SSL 인증서 적용 (server.xml) 최신 Tomcat 버전에서는 .p12(PKCS12) 형식을 권장합니다. server.xml의 \u0026lt;Connector\u0026gt; 부분을 다음과 같이 설정합니다.\n\u0026lt;Connector port=\u0026#34;443\u0026#34; protocol=\u0026#34;org.apache.coyote.http11.Http11NioProtocol\u0026#34; maxThreads=\u0026#34;150\u0026#34; SSLEnabled=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;SSLHostConfig\u0026gt; \u0026lt;Certificate certificateKeystoreFile=\u0026#34;conf/certificate.p12\u0026#34; certificateKeystorePassword=\u0026#34;your_password\u0026#34; type=\u0026#34;RSA\u0026#34; /\u0026gt; \u0026lt;/SSLHostConfig\u0026gt; \u0026lt;/Connector\u0026gt; 2. HTTP to HTTPS 자동 리다이렉트 사용자가 http://로 접속해도 자동으로 https://로 전환되도록 설정해야 합니다.\nweb.xml 설정 추가 모든 요청에 대해 보안 연결을 강제하도록 WEB-INF/web.xml 하단에 다음 내용을 추가합니다.\n\u0026lt;security-constraint\u0026gt; \u0026lt;web-resource-collection\u0026gt; \u0026lt;web-resource-name\u0026gt;HTTPS Only\u0026lt;/web-resource-name\u0026gt; \u0026lt;url-pattern\u0026gt;/*\u0026lt;/url-pattern\u0026gt; \u0026lt;/web-resource-collection\u0026gt; \u0026lt;user-data-constraint\u0026gt; \u0026lt;transport-guarantee\u0026gt;CONFIDENTIAL\u0026lt;/transport-guarantee\u0026gt; \u0026lt;/user-data-constraint\u0026gt; \u0026lt;/security-constraint\u0026gt; server.xml 포트 매핑 확인 HTTP 커넥터 설정에 redirectPort=\u0026quot;443\u0026quot;이 올바르게 지정되어 있는지 확인합니다.\n\u0026lt;Connector port=\u0026#34;80\u0026#34; protocol=\u0026#34;HTTP/1.1\u0026#34; connectionTimeout=\u0026#34;20000\u0026#34; redirectPort=\u0026#34;443\u0026#34; /\u0026gt; 핵심 SEO 포인트: 인증서 형식과 리다이렉트 PKCS12 vs JKS: Java 9 이후부터는 산업 표준인 PKCS12(.p12) 형식을 기본으로 사용하므로 호환성이 더 높습니다. Port 443: 표준 HTTPS 포트인 443을 사용하면 URL 뒤에 포트 번호를 붙이지 않아도 됩니다. Strict-Transport-Security (HSTS): 보안 강화를 위해 브라우저 수준에서 HTTPS를 강제하는 헤더 설정을 함께 고려하는 것이 좋습니다. 결론 Tomcat에 HTTPS를 직접 적용하는 것은 생각보다 간단합니다. 하지만 실무에서는 Nginx와 같은 리버스 프록시에서 SSL을 처리(SSL Termination)하고 WAS와는 HTTP로 통신하는 구조를 더 많이 사용하므로, 자신의 인프라 환경에 맞는 방식을 선택하시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/tomcat-https-ssl-setup-redirect/","summary":"\u003ch2 id=\"서론\"\u003e서론\u003c/h2\u003e\n\u003cp\u003e웹 서비스의 보안(HTTPS)은 이제 선택이 아닌 필수입니다. Tomcat에서 SSL 인증서를 적용하는 방법은 인증서 형식(JKS, PKCS12 등)에 따라 설정 방식이 조금씩 다릅니다. 이번 포스팅에서는 인증서 적용부터 HTTP 요청을 HTTPS로 강제 전환하는 방법까지 정리합니다.\u003c/p\u003e\n\u003ch2 id=\"1-ssl-인증서-적용-serverxml\"\u003e1. SSL 인증서 적용 (server.xml)\u003c/h2\u003e\n\u003cp\u003e최신 Tomcat 버전에서는 \u003ccode\u003e.p12(PKCS12)\u003c/code\u003e 형식을 권장합니다. \u003ccode\u003eserver.xml\u003c/code\u003e의 \u003ccode\u003e\u0026lt;Connector\u0026gt;\u003c/code\u003e 부분을 다음과 같이 설정합니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-xml\" data-lang=\"xml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;Connector\u003c/span\u003e port=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;443\u0026#34;\u003c/span\u003e protocol=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;org.apache.coyote.http11.Http11NioProtocol\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e           maxThreads=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;150\u0026#34;\u003c/span\u003e SSLEnabled=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;true\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;SSLHostConfig\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;Certificate\u003c/span\u003e certificateKeystoreFile=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;conf/certificate.p12\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                     certificateKeystorePassword=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;your_password\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                     type=\u003cspan style=\"color:#a5d6ff\"\u003e\u0026#34;RSA\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#7ee787\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/SSLHostConfig\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#7ee787\"\u003e\u0026lt;/Connector\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"2-http-to-https-자동-리다이렉트\"\u003e2. HTTP to HTTPS 자동 리다이렉트\u003c/h2\u003e\n\u003cp\u003e사용자가 \u003ccode\u003ehttp://\u003c/code\u003e로 접속해도 자동으로 \u003ccode\u003ehttps://\u003c/code\u003e로 전환되도록 설정해야 합니다.\u003c/p\u003e","title":"Tomcat HTTPS(SSL/TLS) 적용 가이드: JKS와 P12 설정 및 자동 리다이렉트"},{"content":"서론 대규모 트래픽이 발생하는 환경에서 WAS(Tomcat)가 응답하지 않거나 속도가 급격히 느려진다면, 가장 먼저 의심해야 할 곳은 쓰레드 풀(Thread Pool) 설정입니다. Tomcat의 server.xml에서 설정하는 세 가지 핵심 파라미터의 관계를 정확히 이해해야 서비스 장애를 예방할 수 있습니다.\nTomcat 요청 처리의 3단계 레이어 1. acceptCount (대기열 크기) 모든 쓰레드가 사용 중일 때, 운영체제(OS) 수준에서 요청을 대기시킬 큐의 크기입니다. 이 큐마저 가득 차면 클라이언트는 Connection Refused 에러를 받게 됩니다.\n실무 팁: 너무 크게 설정하면 클라이언트는 타임아웃에 걸릴 때까지 하염없이 기다리게 되므로 적절한 제한이 필요합니다. 2. maxConnections (최대 연결 수) Tomcat이 동시에 유지할 수 있는 최대 네트워크 연결 수입니다. 쓰레드 수보다 훨씬 크게 설정하여(NIO 방식), 실제 처리 중이지 않은 연결도 잠시 붙들어 둘 수 있습니다.\n기본값: NIO 기준 10,000개 3. maxThreads (최대 처리 쓰레드 수) 실제로 요청을 처리하기 위해 생성되는 작업 쓰레드의 최대 개수입니다. CPU와 메모리 자원을 직접적으로 사용하는 가장 중요한 수치입니다.\n설정 주의: 무조건 크게 설정한다고 좋은 것이 아닙니다. 쓰레드가 너무 많으면 컨텍스트 스위칭(Context Switching) 비용이 발생하여 오히려 성능이 저하됩니다. 최적의 설정 조합 찾기 \u0026lt;Connector port=\u0026#34;8080\u0026#34; protocol=\u0026#34;HTTP/1.1\u0026#34; connectionTimeout=\u0026#34;20000\u0026#34; redirectPort=\u0026#34;8443\u0026#34; maxThreads=\u0026#34;200\u0026#34; maxConnections=\u0026#34;10000\u0026#34; acceptCount=\u0026#34;100\u0026#34; /\u0026gt; 로그 분석: jstack이나 모니터링 도구(Scouter, Jennifer 등)를 통해 현재 활성 쓰레드 수를 확인합니다. 부하 테스트: nGrinder나 JMeter를 사용해 응답 시간이 급격히 늘어나는 시점의 쓰레드 수를 파악합니다. 자원 모니터링: 쓰레드 증가 시 CPU와 Memory 사용률이 임계치를 넘지 않는지 체크합니다. 핵심 SEO 포인트: 성능 병목과 에러 메시지 Connection Refused: acceptCount 초과 시 발생. Timeout: maxThreads 부족으로 요청 처리가 지연될 때 발생. NIO Connector: 적은 쓰레드로 많은 연결을 관리하기 위해 반드시 NIO 방식을 사용해야 합니다. 결론 Tomcat 튜닝은 하드웨어 자원과 서비스의 특성에 맞춘 \u0026lsquo;최적의 지점\u0026rsquo;을 찾는 과정입니다. maxThreads를 무작정 늘리기보다, 애플리케이션의 로직(I/O 집중형인지 CPU 집중형인지)을 파악하여 점진적으로 수치를 조정해 보시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/tomcat-performance-thread-pool-tuning/","summary":"\u003ch2 id=\"서론\"\u003e서론\u003c/h2\u003e\n\u003cp\u003e대규모 트래픽이 발생하는 환경에서 WAS(Tomcat)가 응답하지 않거나 속도가 급격히 느려진다면, 가장 먼저 의심해야 할 곳은 \u003cstrong\u003e쓰레드 풀(Thread Pool)\u003c/strong\u003e 설정입니다. Tomcat의 \u003ccode\u003eserver.xml\u003c/code\u003e에서 설정하는 세 가지 핵심 파라미터의 관계를 정확히 이해해야 서비스 장애를 예방할 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"tomcat-요청-처리의-3단계-레이어\"\u003eTomcat 요청 처리의 3단계 레이어\u003c/h2\u003e\n\u003ch3 id=\"1-acceptcount-대기열-크기\"\u003e1. acceptCount (대기열 크기)\u003c/h3\u003e\n\u003cp\u003e모든 쓰레드가 사용 중일 때, 운영체제(OS) 수준에서 요청을 대기시킬 큐의 크기입니다. 이 큐마저 가득 차면 클라이언트는 \u003ccode\u003eConnection Refused\u003c/code\u003e 에러를 받게 됩니다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e실무 팁:\u003c/strong\u003e 너무 크게 설정하면 클라이언트는 타임아웃에 걸릴 때까지 하염없이 기다리게 되므로 적절한 제한이 필요합니다.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"2-maxconnections-최대-연결-수\"\u003e2. maxConnections (최대 연결 수)\u003c/h3\u003e\n\u003cp\u003eTomcat이 동시에 유지할 수 있는 최대 네트워크 연결 수입니다. 쓰레드 수보다 훨씬 크게 설정하여(NIO 방식), 실제 처리 중이지 않은 연결도 잠시 붙들어 둘 수 있습니다.\u003c/p\u003e","title":"Tomcat 성능 튜닝의 핵심: maxThreads, maxConnections, acceptCount 완벽 이해"},{"content":"서론 사용자가 요청을 보냈을 때 이메일 발송, 리포트 생성, 외부 API 호출 등 시간이 오래 걸리는 작업이 포함되어 있다면 응답 속도가 느려질 수밖에 없습니다. 이럴 때 **비동기 처리(Asynchronous Processing)**를 도입하면, 핵심 로직만 즉시 응답하고 무거운 작업은 백그라운드에서 실행하여 사용자 경험을 크게 향상시킬 수 있습니다.\n@Async 활성화 및 설정 Spring Boot에서 비동기 기능을 사용하려면 먼저 @EnableAsync 설정을 추가해야 합니다.\n1. 비동기 설정 클래스 기본 ThreadPool 대신 커스텀 설정을 통해 안정성을 확보하는 것이 좋습니다.\n@Configuration @EnableAsync public class AsyncConfig { @Bean(name = \u0026#34;taskExecutor\u0026#34;) public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); // 기본 스레드 수 executor.setMaxPoolSize(10); // 최대 스레드 수 executor.setQueueCapacity(500); // 큐 용량 executor.setThreadNamePrefix(\u0026#34;AsyncThread-\u0026#34;); executor.initialize(); return executor; } } 비동기 메서드 구현 비동기로 동작할 메서드 위에 @Async 어노테이션을 붙여주기만 하면 됩니다.\n@Service public class EmailService { @Async(\u0026#34;taskExecutor\u0026#34;) public void sendEmail(String recipient, String content) { // 시간이 오래 걸리는 이메일 전송 로직 구현 System.out.println(\u0026#34;Sending email in: \u0026#34; + Thread.currentThread().getName()); // 실제 전송 작업 수행... } } 비동기 작업 결과 처리: CompletableFuture 비동기 작업의 결과값이 필요한 경우 Java의 CompletableFuture를 활용할 수 있습니다.\n@Async(\u0026#34;taskExecutor\u0026#34;) public CompletableFuture\u0026lt;String\u0026gt; processAsyncTask() { // 시간이 걸리는 비즈니스 로직 수행 return CompletableFuture.completedFuture(\u0026#34;Task Result\u0026#34;); } 핵심 SEO 포인트: 비동기 처리와 성능 향상 비동기 도입의 장점: 사용자에게 즉각적인 응답을 제공하여 서비스 체감 속도를 높입니다. ThreadPool 최적화: 시스템 리소스를 적절히 배분하여 무분별한 스레드 생성을 방지하고 메모리 부족(OOM) 오류를 예방합니다. 결론 Spring Boot의 @Async는 복잡한 비동기 프로그래밍을 어노테이션 하나로 해결해 주는 강력한 도구입니다. 시간이 걸리는 작업을 비동기로 분리하여 고성능 애플리케이션을 구축해 보시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/spring-boot-async-annotation-guide/","summary":"\u003ch2 id=\"서론\"\u003e서론\u003c/h2\u003e\n\u003cp\u003e사용자가 요청을 보냈을 때 이메일 발송, 리포트 생성, 외부 API 호출 등 시간이 오래 걸리는 작업이 포함되어 있다면 응답 속도가 느려질 수밖에 없습니다. 이럴 때 **비동기 처리(Asynchronous Processing)**를 도입하면, 핵심 로직만 즉시 응답하고 무거운 작업은 백그라운드에서 실행하여 사용자 경험을 크게 향상시킬 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"async-활성화-및-설정\"\u003e@Async 활성화 및 설정\u003c/h2\u003e\n\u003cp\u003eSpring Boot에서 비동기 기능을 사용하려면 먼저 \u003ccode\u003e@EnableAsync\u003c/code\u003e 설정을 추가해야 합니다.\u003c/p\u003e\n\u003ch3 id=\"1-비동기-설정-클래스\"\u003e1. 비동기 설정 클래스\u003c/h3\u003e\n\u003cp\u003e기본 ThreadPool 대신 커스텀 설정을 통해 안정성을 확보하는 것이 좋습니다.\u003c/p\u003e","title":"Spring Boot @Async로 비동기 처리 구현하기: 서비스 응답 속도 개선"},{"content":"서론 운영 중인 서버가 갑자기 멈췄을 때, 원인을 찾아보면 로그 파일이 디스크 용량을 모두 차지해버린 경우가 의외로 많습니다. 특히 트래픽이 몰리는 서비스라면 로그 파일 크기는 순식간에 수십 GB를 넘어섭니다. 이번 포스팅에서는 리눅스 표준 도구인 logrotate를 사용하여 주요 서버들(Nginx, Apache, Tomcat)의 로그를 서비스 중단 없이 관리하는 방법을 정리합니다.\nlogrotate란? logrotate는 리눅스 시스템에서 로그 파일을 주기적으로 순환(Rotation), 압축(Compression), 삭제(Removal)해주는 시스템 유틸리티입니다. 이를 통해 오래된 로그를 보관하면서도 현재 디스크 사용량을 일정하게 유지할 수 있습니다.\n주요 서버별 설정 방법 보통 /etc/logrotate.d/ 디렉토리에 서비스별 설정 파일을 생성하여 관리합니다.\n1. Nginx 설정 Nginx는 로그 파일 핸들을 갱신하기 위해 USR1 시그널을 보내야 합니다.\n/var/log/nginx/*.log { daily rotate 14 compress delaycompress missingok notifempty create 0640 www-data adm sharedscripts postrotate [ -f /var/run/nginx.pid ] \u0026amp;\u0026amp; kill -USR1 `cat /var/run/nginx.pid` endscript } 2. Apache HTTP Server 설정 Apache는 apachectl graceful 명령어를 통해 기존 연결을 유지하면서 로그 파일을 교체합니다.\n/var/log/apache2/*.log { weekly rotate 52 compress delaycompress missingok notifempty sharedscripts postrotate /usr/sbin/apachectl graceful \u0026gt; /dev/null endscript } 3. Tomcat (Catalina.out) 설정 Tomcat의 catalina.out은 파일이 항상 열려 있어 copytruncate 옵션을 사용하는 것이 안전합니다.\n/opt/tomcat/logs/catalina.out { copytruncate # 원본 파일을 비우고 복사본 생성 (중단 방지) daily rotate 7 compress missingok notifempty size 100M # 파일 크기가 100M 초과 시 순환 } 핵심 SEO 포인트: copytruncate와 시그널 copytruncate: Tomcat처럼 프로세스가 로그 파일을 계속 물고 있는 경우 필수적입니다. postrotate 시그널: Nginx와 Apache처럼 로그 파일 경로가 바뀌어도 프로세스가 이를 인지하게 하려면 시그널(USR1)이나 서비스 재로딩이 반드시 필요합니다. 설정 적용 및 테스트 수정한 설정이 정상인지 확인하려면 다음 명령어를 사용합니다.\n# 문법 체크 및 강제 실행 테스트 (-d는 디버그 모드로 실제 변경 안 함) sudo logrotate -d /etc/logrotate.d/nginx 결론 logrotate 설정은 서버 인프라 관리의 기초 중의 기초입니다. 각 서버별 특성에 맞는 옵션을 적용하여 \u0026lsquo;디스크 풀\u0026rsquo;로 인한 장애를 미리 예방하시기 바랍니다.\n","permalink":"https://chanyeols.com/posts/server-logrotate-nginx-apache-tomcat/","summary":"\u003ch2 id=\"서론\"\u003e서론\u003c/h2\u003e\n\u003cp\u003e운영 중인 서버가 갑자기 멈췄을 때, 원인을 찾아보면 로그 파일이 디스크 용량을 모두 차지해버린 경우가 의외로 많습니다. 특히 트래픽이 몰리는 서비스라면 로그 파일 크기는 순식간에 수십 GB를 넘어섭니다. 이번 포스팅에서는 리눅스 표준 도구인 \u003ccode\u003elogrotate\u003c/code\u003e를 사용하여 주요 서버들(Nginx, Apache, Tomcat)의 로그를 서비스 중단 없이 관리하는 방법을 정리합니다.\u003c/p\u003e\n\u003ch2 id=\"logrotate란\"\u003elogrotate란?\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003elogrotate\u003c/code\u003e는 리눅스 시스템에서 로그 파일을 주기적으로 순환(Rotation), 압축(Compression), 삭제(Removal)해주는 시스템 유틸리티입니다. 이를 통해 오래된 로그를 보관하면서도 현재 디스크 사용량을 일정하게 유지할 수 있습니다.\u003c/p\u003e","title":"서버 3대장(Nginx, Apache, Tomcat) logrotate 완벽 가이드: 디스크 풀 방지"},{"content":"문제 상황 서버 작업을 하면서 PuTTY와 WinSCP를 같이 사용하는 경우가 많습니다.\n저도 평소에 PuTTY로 서버에 접속한 뒤, 파일 전송이 필요할 때 WinSCP를 사용하고 있습니다.\n그런데 어느 날부터 PuTTY에서는 정상적으로 접속이 되는데,\nWinSCP에서는 동일한 .ppk 키 파일을 사용해도 접속이 되지 않는 문제가 발생했습니다.\n발생한 오류 메시지 WinSCP에서 접속을 시도하면 다음과 같은 메시지가 출력되었습니다.\n서버가 키를 거부하였습니다 인증에 실패했습니다 No supported authentication methods available 사용자명, 포트, 서버 주소 모두 동일했고,\nPuTTY 세션을 그대로 가져왔기 때문에 설정 문제라고 보기도 애매한 상황이었습니다.\n원인 확인 과정 처음에는 서버 설정이나 authorized_keys 문제를 의심했습니다.\n하지만 PuTTY에서는 동일한 키로 정상 접속이 되었기 때문에 서버 쪽 문제는 아닌 것으로 판단했습니다.\n확인해 보니, PuTTY는 최신 버전을 사용 중이었고\nWinSCP는 꽤 오래된 버전을 그대로 사용하고 있었습니다.\n최근 PuTTY에서 생성되거나 업데이트된 .ppk 키는\n구버전 WinSCP에서 제대로 인식하지 못하는 경우가 있다는 것을 알게 되었습니다.\n즉, 키 파일 자체가 문제라기보다는\n클라이언트 도구 간 버전 차이로 인한 호환성 문제였습니다.\n해결 방법 해결 방법은 생각보다 간단했습니다.\nWinSCP 최신 버전으로 업데이트 WinSCP를 최신 버전으로 업데이트한 뒤,\n기존에 사용하던 .ppk 키 파일을 그대로 다시 지정해 접속을 시도했습니다.\n별도의 키 재생성이나 서버 설정 변경 없이\n정상적으로 접속이 되는 것을 확인할 수 있었습니다.\n확인해 볼 필요 없는 부분 이번 문제는 다음 항목들과는 관련이 없었습니다.\n서버 SSH 설정 방화벽 또는 네트워크 설정 키 파일 권한 문제 사용자 계정 문제 설정 변경 전에 클라이언트 버전부터 확인하는 것이 우선이었습니다.\n정리 PuTTY에서는 접속이 되는데 WinSCP에서만 SSH 키 인증이 실패한다면,\n서버나 키 설정을 바로 의심하기보다는 WinSCP 버전 문제를 먼저 확인하는 것이 좋습니다.\n보안 도구들은 업데이트에 따라 키 포맷도 함께 변경되기 때문에,\n오래된 클라이언트를 그대로 사용하는 경우 이런 문제가 발생할 수 있습니다.\n비슷한 상황을 겪고 있다면,\n설정 수정 전에 먼저 WinSCP를 최신 버전으로 업데이트해 보시길 권장합니다.\n","permalink":"https://chanyeols.com/posts/winscp-ppk-authentication-failed-fix/","summary":"PuTTY로는 접속이 되는데 WinSCP에서만 SSH 키 인증이 실패하는 문제가 발생했습니다. 원인을 확인해 보니 WinSCP 버전 문제였고, 업데이트로 해결할 수 있었습니다.","title":"WinSCP에서 .ppk 키 인증 실패 오류 해결 방법 (PuTTY는 되는데 WinSCP는 안 될 때)"},{"content":"서론 현대 웹 아키텍처에서 백엔드 WAS(Tomcat, Spring Boot 등)를 외부에 직접 노출하는 것은 보안과 성능 면에서 권장되지 않습니다. Nginx를 앞단에 두어 **리버스 프록시(Reverse Proxy)**로 활용하면 보안 강화는 물론, **로드 밸런싱(Load Balancing)**을 통해 시스템의 가용성을 획기적으로 높일 수 있습니다.\n리버스 프록시 설정 (Reverse Proxy) 리버스 프록시는 클라이언트의 요청을 대신 받아 백엔드 서버로 전달하는 역할을 합니다. 이를 통해 백엔드 서버의 IP를 숨기고 SSL 종단점(SSL Termination) 역할을 수행할 수 있습니다.\nserver { listen 80; server_name example.com; location / { proxy_pass http://backend_servers; # 로드 밸런서 그룹 지정 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } 로드 밸런싱 설정 (Load Balancing) upstream 블록을 사용하여 여러 대의 백엔드 서버로 부하를 분산할 수 있습니다.\nupstream backend_servers { least_conn; # 연결수가 가장 적은 서버로 전달 (추천 전략) server 10.0.0.1:8080 max_fails=3 fail_timeout=30s; server 10.0.0.2:8080 max_fails=3 fail_timeout=30s; server 10.0.0.3:8080 backup; # 모든 서버 장애 시에만 작동 } 주요 분산 전략 Round Robin (기본값): 서버에 순차적으로 요청을 배분합니다. Least Connections (least_conn): 활성 연결이 가장 적은 서버로 요청을 보냅니다. IP Hash (ip_hash): 클라이언트 IP를 해싱하여 동일 사용자가 항상 같은 서버에 접속하도록 보장(Session Sticky)합니다. 핵심 SEO 포인트: 프록시 헤더와 성능 X-Forwarded-For: 백엔드 서버가 실제 클라이언트의 IP를 식별하기 위해 반드시 설정해야 하는 헤더입니다. Health Check: max_fails와 fail_timeout 설정을 통해 장애가 발생한 서버를 자동으로 제외하여 서비스 가용성을 유지합니다. 결론 Nginx 리버스 프록시와 로드 밸런싱은 고가용성 웹 서비스를 위한 필수 관문입니다. 간단한 설정만으로도 서비스의 안정성을 크게 개선할 수 있으므로, 단일 서버 운영 환경이라도 리버스 프록시 도입을 적극 권장합니다.\n","permalink":"https://chanyeols.com/posts/nginx-reverse-proxy-load-balancing-guide/","summary":"\u003ch2 id=\"서론\"\u003e서론\u003c/h2\u003e\n\u003cp\u003e현대 웹 아키텍처에서 백엔드 WAS(Tomcat, Spring Boot 등)를 외부에 직접 노출하는 것은 보안과 성능 면에서 권장되지 않습니다. Nginx를 앞단에 두어 **리버스 프록시(Reverse Proxy)**로 활용하면 보안 강화는 물론, **로드 밸런싱(Load Balancing)**을 통해 시스템의 가용성을 획기적으로 높일 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"리버스-프록시-설정-reverse-proxy\"\u003e리버스 프록시 설정 (Reverse Proxy)\u003c/h2\u003e\n\u003cp\u003e리버스 프록시는 클라이언트의 요청을 대신 받아 백엔드 서버로 전달하는 역할을 합니다. 이를 통해 백엔드 서버의 IP를 숨기고 SSL 종단점(SSL Termination) 역할을 수행할 수 있습니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-nginx\" data-lang=\"nginx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ff7b72\"\u003eserver\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003elisten\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e80\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003eserver_name\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003eexample.com\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#ff7b72\"\u003elocation\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003e/\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eproxy_pass\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003ehttp://backend_servers\u003c/span\u003e; \u003cspan style=\"color:#8b949e;font-style:italic\"\u003e# 로드 밸런서 그룹 지정\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eproxy_set_header\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003eHost\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003e$host\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eproxy_set_header\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003eX-Real-IP\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003e$remote_addr\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eproxy_set_header\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003eX-Forwarded-For\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003e$proxy_add_x_forwarded_for\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#ff7b72\"\u003eproxy_set_header\u003c/span\u003e \u003cspan style=\"color:#a5d6ff\"\u003eX-Forwarded-Proto\u003c/span\u003e \u003cspan style=\"color:#79c0ff\"\u003e$scheme\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"로드-밸런싱-설정-load-balancing\"\u003e로드 밸런싱 설정 (Load Balancing)\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003eupstream\u003c/code\u003e 블록을 사용하여 여러 대의 백엔드 서버로 부하를 분산할 수 있습니다.\u003c/p\u003e","title":"Nginx 리버스 프록시와 로드 밸런싱 설정 가이드: WAS 성능 최적화"},{"content":" title: \u0026ldquo;Nginx에서 404 발생 시 index.html로 리다이렉트하는 방법 (SPA 배포 오류 해결)\u0026rdquo; date: 2026-02-22 draft: false tags: [\u0026ldquo;nginx\u0026rdquo;, \u0026ldquo;spa\u0026rdquo;, \u0026ldquo;troubleshooting\u0026rdquo;] categories: [\u0026ldquo;Nginx\u0026rdquo;] description: \u0026ldquo;SPA 프로젝트를 nginx에 배포했을 때 직접 경로 접근 시 404가 발생하는 문제 해결 방법\u0026rdquo; 문제 상황 React 또는 Vue 같은 SPA를 nginx에 배포했을 때\n직접 URL 접근 시 404 오류가 발생하는 문제가 있다.\n예:\n새로고침하면 404 발생.\n원인 nginx는 실제 파일이 존재하는 경우만 서빙한다.\nSPA는 라우팅을 클라이언트에서 처리하기 때문에 문제가 발생한다.\n해결 방법 nginx.conf에서 다음과 같이 설정한다.\nlocation / { try_files $uri $uri.html $uri/ /index.html; } ","permalink":"https://chanyeols.com/posts/nginx-404-index-redirect/","summary":"\u003chr\u003e\n\u003ch2 id=\"description-spa-프로젝트를-nginx에-배포했을-때-직접-경로-접근-시-404가-발생하는-문제-해결-방법\"\u003etitle: \u0026ldquo;Nginx에서 404 발생 시 index.html로 리다이렉트하는 방법 (SPA 배포 오류 해결)\u0026rdquo;\ndate: 2026-02-22\ndraft: false\ntags: [\u0026ldquo;nginx\u0026rdquo;, \u0026ldquo;spa\u0026rdquo;, \u0026ldquo;troubleshooting\u0026rdquo;]\ncategories: [\u0026ldquo;Nginx\u0026rdquo;]\ndescription: \u0026ldquo;SPA 프로젝트를 nginx에 배포했을 때 직접 경로 접근 시 404가 발생하는 문제 해결 방법\u0026rdquo;\u003c/h2\u003e\n\u003ch2 id=\"문제-상황\"\u003e문제 상황\u003c/h2\u003e\n\u003cp\u003eReact 또는 Vue 같은 SPA를 nginx에 배포했을 때\u003cbr\u003e\n직접 URL 접근 시 404 오류가 발생하는 문제가 있다.\u003c/p\u003e\n\u003cp\u003e예:\u003c/p\u003e\n\u003cp\u003e새로고침하면 404 발생.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"원인\"\u003e원인\u003c/h2\u003e\n\u003cp\u003enginx는 실제 파일이 존재하는 경우만 서빙한다.\u003cbr\u003e\nSPA는 라우팅을 클라이언트에서 처리하기 때문에 문제가 발생한다.\u003c/p\u003e","title":"Nginx 404 Index Redirect"}]