↔️

대구광역시 클라우드 네이티브 전환 사업

상태
진행 중
프로젝트 유형
팀 프로젝트
프로젝트 기간
Aug 1, 2025
사용 기술
Vue.js 3
Pinia
Vite
Turborepo
Java
Spring Boot
MSA
Redis
Kafka
프로젝트 소개
온라인 강좌 수강신청 및 학습 서비스를 제공하는 대구 시민 대상 온라인 학습 플랫폼의 클라우드 전환 및 현대화 프로젝트입니다.
링크

대구광역시 클라우드 네이티브 전환 사업

🔎 프로젝트 개요


📅 프로젝트 기간: 2025.08 ~ 현재 (통합 테스트 진행 중)

💡프로젝트 소개

대구 시민 대상 온라인 학습 플랫폼으로, 온라인 강좌 수강신청 및 학습 서비스를 제공하는 시스템의 온프레미스 환경을 클라우드 네이티브 환경으로 전환하는 프로젝트입니다.
JSP 기반 레거시 시스템을 관리자/사용자 두 개의 독립된 Vue.js 3 애플리케이션으로 분리하고, pnpm workspace + Turborepo 기반 모노레포 구조로 재설계하여 코드 중복을 최소화하고 개발 생산성을 향상시켰습니다.
또한, Multi-tenant 환경에서 OAuth2 2.1 + PKCE 기반의 통합 인증 서버를 구축하여 5개의 인증 제공자를 통합하고, Web Worker 기반의 정확한 토큰 갱신 메커니즘을 구현했습니다.

⚙️ Skills

Frontend: Vue.js 3, Pinia, Vite, pnpm workspace, Turborepo Backend: Java, Spring Boot, Spring Security, OAuth2 2.1, Redis, MyBatis, MySQL Infrastructure: Docker, NHN Cloud

🤔 왜 pnpm workspace + Turborepo를 선택했는가?

pnpm workspace 선택 이유
  • npm과 유사한 사용성: npm과 호환되는 명령어 체계로 러닝커브 최소화
  • 빠른 설치 속도: 심볼릭 링크 기반 node_modules 구조로 디스크 공간 절약 및 설치 속도 향상
  • Workspace 기능: 모노레포 구조를 네이티브로 지원하여 패키지 간 의존성 관리 용이
Turborepo 선택 이유
프론트엔드 빌드 도구로는 Turborepo와 Nx가 주요 선택지였지만, Turborepo를 선택한 이유는 다음과 같습니다.
  • 애플리케이션 기술스택의 유사성: 공통 의존성 공유 및 애플리케이션 간 빌드 환경의 유사성을 통한 캐시 재사용 이점 높음
  • 병렬 빌드 프로세스: 다수의 패키지를 병렬로 빌드하여 전체 빌드 시간 단축
  • 초기 설정의 단순성: Nx 대비 설정이 간단하고 빠르게 도입 가능
  • 빌드 최적화 강점: 캐싱 및 증분 빌드를 통한 효율적인 빌드 관리
  • 의존성 그래프 기반 빌드: 변경된 패키지와 이에 의존하는 패키지만 선택적으로 빌드
// turbo.json { "pipeline": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "dev": { "cache": false } } }
이러한 선택을 통해 대규모 모노레포 프로젝트에서도 빠른 개발 경험과 효율적인 빌드 프로세스를 확보할 수 있었습니다.

🧩 담당 역할


  • pnpm workspace + Turborepo 기반 모노레포 시스템 설계 및 구축 주도
  • 공통 패키지(UI 컴포넌트, 유틸리티, Composables 등) 40+ 개발
  • 기능 패키지(Auth, Feedback, Excel 등) 설계 및 구현
  • 패키지 활용 가이드 작성 및 팀 온보딩 세션 진행
  • Spring Security 기반 Multi-tenant 통합 인증 서버 구축
  • OAuth2 2.1 + PKCE 기반 인증 플로우 설계 및 구현
  • Web Worker 기반 토큰 갱신 메커니즘 개발
  • Visibility API 기반 Idle 감지 및 자동 로그아웃 구현

🧨 문제 인식


JSP 레거시 시스템의 한계

  • 관리자/사용자 시스템이 하나의 JSP 기반 애플리케이션으로 구성되어 유지보수 어려움
  • 컴포넌트 재사용이 불가능하여 기능 추가 시 중복 코드 발생

독립된 애플리케이션 간 코드 중복

  • JSP 레거시를 관리자/사용자 두 개의 독립된 Vue.js 애플리케이션으로 분리
  • 두 앱 간 중복되는 컴포넌트, 유틸리티, 상태 관리 로직 반복 구현 문제
  • 버그 수정 및 기능 개선 시 양쪽 앱 모두 수정해야 하는 비효율

Multi-tenant 인증 관리의 복잡성

  • 3개의 Tenant(관리자 1개, 사용자 2개)별 독립적인 인증 관리 필요
  • OAuth2(Naver, Kakao, 다대구, Facebook), Form Login 등 5개 인증 제공자 통합 필요
  • 2차 인증 연계 구조로 인한 복잡한 인증 플로우

보안 및 토큰 관리

  • XSS 공격 방지 및 안전한 토큰 저장 방식 필요
  • 브라우저 탭 비활성화 시 토큰 갱신 타이머의 정확도 문제
  • 사용자 Idle 상태 감지 및 자동 로그아웃 필요

🛠️ 해결 방안


🧩 pnpm workspace + Turborepo 기반 모노레포 시스템 구축

모노레포 구조 설계

JSP 레거시를 관리자/사용자 두 개의 독립된 Vue.js 애플리케이션으로 분리하면서, 코드 중복을 최소화하기 위해 모노레포 구조를 도입했습니다.
패키지 구조
패키지는 크게 공통 패키지기능 패키지로 분류됩니다.
공통 패키지 (Common Packages)
  • @packages/ui (40+ 컴포넌트): 재사용 가능한 UI 컴포넌트 제공
  • @packages/utils (10+ 유틸리티): 공통 유틸리티 함수 제공
  • @packages/composables (10+ Composables): Vue Composition API 기반 재사용 로직 제공
    • 예: useForm (Zod 기반 Form Validation + Submit)
    • 예: useAccessibility (접근성 관련 기능)
공통 패키지는 기본적으로 서로 의존성을 가지지 않도록 설계했습니다. 다만, @packages/ui@packages/composables에 의존합니다 (접근성 기능 활용).
기능 패키지 (Feature Packages)
  • @packages/auth: 인증 관련 로직 및 컴포저블 제공
  • @packages/feedback: Alert, Confirm 등 피드백 컴포넌트 제공 (Provider 패턴)
  • @packages/excel: Excel 관련 기능 제공
  • @packages/client: HTTP 클라이언트 설정 및 관리
의존성 구조
@apps/admin ─┐ ├─→ @packages/ui ──→ @packages/composables @apps/user ──┤ @packages/utils ├─→ @packages/auth ├─→ @packages/feedback └─→ @packages/client

pnpm workspace 설정

pnpm workspace를 통해 패키지 간 심볼릭 링크를 연결하여 로컬 개발 환경에서 실시간으로 변경사항을 반영할 수 있도록 구성했습니다.
# pnpm-workspace.yaml packages: - 'apps/*' - 'packages/*'
// packages/ui/package.json { "name": "@packages/ui", "version": "1.0.0", "main": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { "@packages/composables": "workspace:*" } }

Turborepo 기반 빌드 최적화

Turborepo를 통해 의존성 그래프에 따라 병렬 빌드를 실행하고, 캐싱을 통해 변경되지 않은 패키지는 재빌드하지 않도록 최적화했습니다.
// turbo.json { "$schema": "<https://turbo.build/schema.json>", "pipeline": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"], "cache": true }, "dev": { "cache": false, "persistent": true }, "test": { "dependsOn": ["^build"], "outputs": ["coverage/**"], "cache": true } } }
빌드 프로세스
  1. turbo build 실행 시 의존성 그래프 분석
  1. 변경된 패키지 및 이에 의존하는 패키지만 선택적 빌드
  1. 관리자/사용자 두 애플리케이션 병렬 빌드

Vite optimizeDeps 설정

패키지들이 라이브러리 방식으로 빌드되다 보니, Vite의 optimizeDeps 설정을 통해 빠른 개발 서버 구동 및 HMR(Hot Module Replacement) 반영을 최적화했습니다.
// vite.config.js export default defineConfig({ optimizeDeps: { include: [ '@packages/ui', '@packages/utils', '@packages/composables', '@packages/auth', '@packages/feedback', ], }, resolve: { alias: { '@packages/ui': path.resolve(__dirname, '../../packages/ui/src'), '@packages/utils': path.resolve(__dirname, '../../packages/utils/src'), '@packages/composables': path.resolve(__dirname, '../../packages/composables/src'), }, }, });
효과
  • 패키지 변경 시 즉각적인 HMR 반영
  • 개발 서버 구동 속도 향상
  • 번들링 최적화

공통 패키지 분리 전략

초기 설계 시 고려사항
어떤 코드를 공통 패키지로 분리할지 결정하는 것이 주요 쟁점이었습니다. 다음과 같은 기준을 수립했습니다:
  • 두 앱 모두에서 사용: 관리자/사용자 앱 모두에서 필요한 기능
  • 앱 특수성 배제: 특정 앱에만 필요한 로직은 제외
  • 재사용성: 3회 이상 중복 사용되는 코드
  • 독립성: 비즈니스 로직과 분리 가능한 순수 함수/컴포넌트
예시: useForm Composable
// packages/composables/src/useForm.js import { ref, reactive } from 'vue'; import { z } from 'zod'; export function useForm(schema, onSubmit) { const formData = reactive({}); const errors = reactive({}); const isSubmitting = ref(false); const validate = () => { try { schema.parse(formData); Object.keys(errors).forEach(key => delete errors[key]); return true; } catch (error) { if (error instanceof z.ZodError) { error.errors.forEach(err => { errors[err.path[0]] = err.message; }); } return false; } }; const handleSubmit = async () => { if (!validate()) return; isSubmitting.value = true; try { await onSubmit(formData); } finally { isSubmitting.value = false; } }; return { formData, errors, isSubmitting, validate, handleSubmit, }; }
예시: Feedback 패키지 (Provider 패턴)
// packages/feedback/src/FeedbackProvider.vue <template> <slot /> <AlertModal v-if="alertState.visible" v-bind="alertState" @close="closeAlert" /> <ConfirmModal v-if="confirmState.visible" v-bind="confirmState" @confirm="handleConfirm" @cancel="handleCancel" /> </template> <script setup> import { provide, reactive } from 'vue'; const alertState = reactive({ visible: false, message: '', type: 'info' }); const confirmState = reactive({ visible: false, message: '', onConfirm: null }); const showAlert = (message, type = 'info') => { alertState.message = message; alertState.type = type; alertState.visible = true; }; const showConfirm = (message, onConfirm) => { confirmState.message = message; confirmState.onConfirm = onConfirm; confirmState.visible = true; }; provide('feedback', { alert: { info: (msg) => showAlert(msg, 'info'), success: (msg) => showAlert(msg, 'success'), error: (msg) => showAlert(msg, 'error'), }, confirm: showConfirm, }); </script>
// 앱에서 사용 import { inject } from 'vue'; const feedback = inject('feedback'); feedback.alert.success('저장되었습니다.'); feedback.confirm('삭제하시겠습니까?', () => { // 삭제 로직 });
점진적 마이그레이션
요구사항 분석 및 기능 개발 분석 시 사전에 공통 패키지에 들어갈 대상을 설정하고, 앱 개발을 진행하면서 중복 코드를 발견할 때마다 점진적으로 패키지로 이관했습니다.
  1. 초기 개발 시 공통 패키지 대상 선정
  1. 앱 개발 중 중복 코드 발견
  1. 공통 패키지로 이관 및 리팩토링
  1. 문서 업데이트 및 팀 공유

Jest 기반 테스트 및 자동 문서화

공통 패키지의 품질을 보장하기 위해 Jest 기반 단위 테스트를 작성하고, 테스트 템플릿/컨벤션에 따라 작성함으로써 자동으로 Spec 문서를 생성하도록 구성했습니다.
테스트 템플릿
// packages/utils/src/__tests__/formatDate.spec.js /** * @function formatDate * @description 날짜를 지정된 형식으로 포맷팅합니다. * @param {Date} date - 포맷팅할 날짜 객체 * @param {string} format - 포맷 문자열 (예: 'YYYY-MM-DD') * @returns {string} 포맷팅된 날짜 문자열 */ describe('formatDate', () => { it('should format date to YYYY-MM-DD', () => { const date = new Date('2025-01-15'); expect(formatDate(date, 'YYYY-MM-DD')).toBe('2025-01-15'); }); it('should format date to YYYY년 MM월 DD일', () => { const date = new Date('2025-01-15'); expect(formatDate(date, 'YYYY년 MM월 DD일')).toBe('2025년 01월 15일'); }); it('should handle invalid date', () => { expect(formatDate(null, 'YYYY-MM-DD')).toBe(''); }); });
자동 문서 생성 스크립트
// scripts/generate-docs.js const fs = require('fs'); const path = require('path'); const jsdoc = require('jsdoc-api'); const testDir = path.join(__dirname, '../packages/utils/src/__tests__'); const docsDir = path.join(__dirname, '../docs'); if (!fs.existsSync(docsDir)) { fs.mkdirSync(docsDir, { recursive: true }); } fs.readdirSync(testDir).forEach(file => { if (!file.endsWith('.js')) return; const filePath = path.join(testDir, file); const docs = jsdoc.explainSync({ files: filePath }); let mdContent = `# ${file}\n\n`; docs.forEach(doc => { if (doc.kind === 'function') { mdContent += `## ${doc.name}\n`; if (doc.description) mdContent += `${doc.description}\n\n`; if (doc.params) { mdContent += `### Parameters\n`; doc.params.forEach(p => { mdContent += `- \`${p.name}\` (${p.type?.names.join('|')}): ${p.description}\n`; }); mdContent += `\n`; } if (doc.returns) { mdContent += `### Returns\n`; doc.returns.forEach(r => { mdContent += `- (${r.type?.names.join('|')}): ${r.description}\n`; }); mdContent += `\n`; } } }); fs.writeFileSync(path.join(docsDir, file.replace('.spec.js', '.md')), mdContent); console.log(`생성 파일: ${file}`); }); console.log("모든 문서 생성이 완료 되었습니다!"); // package.json에 스크립트 제공 { ... "scripts": { "generate-docs": "node scripts/generate-docs.js" }, "devDependencies": { "jsdoc-api": "^10.0.0" } }
결과물 예시
# formatDate.spec.js ## formatDate 날짜를 지정된 형식으로 포맷팅합니다. ### Parameters - `date` (Date): 포맷팅할 날짜 객체 - `format` (string): 포맷 문자열 (예: 'YYYY-MM-DD') ### Returns - (string): 포맷팅된 날짜 문자열
효과
  • 테스트 작성과 동시에 문서 자동 생성
  • 일관된 문서 품질 유지
  • 팀원들의 문서 작성 부담 감소

팀 온보딩 및 협업

온보딩 세션 내용
모노레포 구조에 대한 팀원들의 적응을 돕기 위해 다음 내용으로 온보딩 세션을 진행했습니다:
  1. 모노레포 도입 취지: 코드 중복 제거, 유지보수성 향상
  1. Alias 활용: @packages/ui 등 alias를 통한 패키지 import 방법
  1. 각 패키지 기능 소개: UI 컴포넌트, Composables, 기능 패키지 사용법
  1. 테스트 컨벤션: 테스트 템플릿 활용 및 자동 문서화 프로세스
  1. 패키지 추가 가이드: 새로운 공통 기능 추가 시 절차
사용 가이드 예시
# @packages/ui 사용 가이드 ## 설치 패키지는 이미 workspace로 연결되어 있으므로 별도 설치 불필요 ## Import ```javascript import { Button, Input, Modal } from '@packages/ui'; ``` ## 컴포넌트 사용 ### Button ```vue <template> <Button variant="primary" size="lg" @click="handleClick"> 클릭 </Button> </template> ``` **Props** - `variant`: 'primary' | 'secondary' | 'danger' - `size`: 'sm' | 'md' | 'lg' - `disabled`: boolean
효과
  • 팀원들의 빠른 적응
  • 일관된 코드 작성 스타일 확립
  • 패키지 활용도 향상

주요 성과

코드 중복 약 40% 감소
  • 버그 수정 시 한 번의 수정으로 두 앱 동시 반영
  • 유지보수 효율성 대폭 향상
개발 생산성 향상
  • 공통 패키지 활용으로 개발 시간 단축
  • 테스트 및 문서 자동화로 품질 보장
팀 내 개발 표준화
  • 일관된 코드 스타일 및 컨벤션 확립
  • 온보딩 시간 단축

🧩 Multi-tenant 통합 인증 서버 구축

Multi-tenant 아키텍처 설계

3개의 Tenant(관리자 1개, 사용자 2개)에 대해 독립적인 인증 관리가 필요했습니다.
Tenant 구분 방식
각 애플리케이션별로 client-id를 환경설정 파일에 설정하고, Auth 패키지에 Plugin 방식으로 주입하는 구조를 설계했습니다.
# application-tenant-admin.yml spring: security: oauth2: client: registration: admin-client: client-id: admin-client- authorization-grant-type: - authorization_code - refresh_token redirect-uri: "{adminFrontUrl}/callback" scope: openid,profile,email
# application-tenant-user-a.yml spring: security: oauth2: client: registration: user-a-client: client-id: user-a-client-id authorization-grant-type: - authorization_code - refresh_token redirect-uri: "{clientAFrontUrl}/callback" scope: openid,profile,email
프론트엔드에서 Tenant 설정
// apps/admin/main.js import { createApp } from 'vue'; import { createAuthPlugin } from '@packages/auth'; const app = createApp(App); app.use(createAuthPlugin({ clientId: 'admin-client-id', redirectUri: `${window.location.origin}/callback`, }));
// apps/user/user-a-main.js import { createApp } from 'vue'; import { createAuthPlugin } from '@packages/auth'; const app = createApp(App); // 사용자 앱은 2개의 Tenant로 나뉘므로 환경변수로 분기 app.use(createAuthPlugin({ clientId: import.meta.env.VITE_CLIENT_ID, // 'user-a-client-id' or 'user-b-client-id' redirectUri: `${window.location.origin}/callback`, }));
Tenant 독립성
  • 각 Tenant는 독립된 사용자 DB를 가짐
  • Tenant A의 사용자가 Tenant B에 로그인 시도 시 인증 실패

OAuth2 2.1 + PKCE 기반 인증 플로우

PKCE 선택 이유
클라이언트(프론트엔드)에서 Client Secret을 안전하게 보관할 수 없는 환경이기 때문에, Authorization Code Flow with PKCE를 선택했습니다.
  • 보안 강화: Code Verifier/Challenge를 통해 Authorization Code 탈취 방지
  • Public Client 지원: Client Secret 없이도 안전한 인증 가능
PKCE 플로우
1. 프론트엔드: Code Verifier 생성 (랜덤 문자열) 2. 프론트엔드: Code Challenge 생성 (SHA-256 해싱) 3. 프론트엔드: Authorization 요청 시 Code Challenge 전송 4. 백엔드: Authorization Code 발급 5. 프론트엔드: Token 요청 시 Code Verifier 전송 6. 백엔드: Code Verifier를 해싱하여 Code Challenge와 비교 검증 7. 백엔드: Access Token 발급
프론트엔드 구현
// packages/auth/src/composables/usePKCE.js import { ref } from 'vue'; export function usePKCE() { const codeVerifier = ref(''); const codeChallenge = ref(''); // Code Verifier 생성 (43-128자 랜덤 문자열) const generateCodeVerifier = () => { const array = new Uint8Array(32); crypto.getRandomValues(array); codeVerifier.value = base64UrlEncode(array); }; // Code Challenge 생성 (SHA-256 해싱) const generateCodeChallenge = async () => { const encoder = new TextEncoder(); const data = encoder.encode(codeVerifier.value); const hash = await crypto.subtle.digest('SHA-256', data); codeChallenge.value = base64UrlEncode(new Uint8Array(hash)); }; const base64UrlEncode = (array) => { return btoa(String.fromCharCode(...array)) .replace(/\\+/g, '-') .replace(/\\//g, '_') .replace(/=/g, ''); }; const initialize = async () => { generateCodeVerifier(); await generateCodeChallenge(); }; return { codeVerifier, codeChallenge, initialize, }; }
백엔드 구현 (Spring Security)
// OAuth2AuthorizationRequestCustomizer.java @Component public class OAuth2AuthorizationRequestCustomizer implements Consumer<OAuth2AuthorizationRequest.Builder> { @Override public void accept(OAuth2AuthorizationRequest.Builder builder) { builder.additionalParameters(params -> { // PKCE 파라미터 검증 String codeChallenge = params.get("code_challenge"); String codeChallengeMethod = params.get("code_challenge_method"); if (codeChallenge == null || !"S256".equals(codeChallengeMethod)) { throw new OAuth2AuthenticationException("PKCE required"); } }); } }
// TokenEndpointFilter.java @Component public class TokenEndpointFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { String code = request.getParameter("code"); String codeVerifier = request.getParameter("code_verifier"); // 저장된 Code Challenge 조회 String codeChallenge = redisTemplate.opsForValue().get("code_challenge:" + code); // Code Verifier를 SHA-256 해싱하여 검증 String computedChallenge = sha256(codeVerifier); if (!computedChallenge.equals(codeChallenge)) { throw new OAuth2AuthenticationException("Invalid code verifier"); } // Token 발급 // ... } }

5개 인증 제공자 통합

인증 제공자 목록
  1. Naver OAuth2
  1. Kakao OAuth2
  1. 다대구 OAuth2 (대구시 자체 인증 시스템)
  1. Facebook OAuth2
  1. Form Login (자체 ID/PW)
2차 인증 연계 구조
OAuth2 인증 제공자는 2차 인증 연계 구조로 되어 있습니다. 즉, 자체 회원 테이블에 kakao_cd, naver_cd 등의 코드 값을 저장하고, 2차 인증이 연계된 사용자만 로그인이 가능합니다.
-- 사용자 테이블 구조 CREATE TABLE users ( user_id VARCHAR(50) PRIMARY KEY, username VARCHAR(100), email VARCHAR(255), kakao_cd VARCHAR(100), -- Kakao 연계 코드 naver_cd VARCHAR(100), -- Naver 연계 코드 facebook_cd VARCHAR(100), -- Facebook 연계 코드 dadaegu_cd VARCHAR(100), -- 다대구 연계 코드 -- ... );
Spring Security 설정
// SecurityConfig.java @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(authorization -> authorization .authorizationRequestResolver(pkceAuthorizationRequestResolver()) ) .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService()) ) .successHandler(oauth2SuccessHandler()) ) .formLogin(form -> form .loginPage("/login") .successHandler(formLoginSuccessHandler()) ); return http.build(); } private ClientRegistration naverClientRegistration() { return ClientRegistration.withRegistrationId("naver") .clientId(naverClientId) .clientSecret(naverClientSecret) .authorizationUri("<https://nid.naver.com/oauth2.0/authorize>") .tokenUri("<https://nid.naver.com/oauth2.0/token>") .userInfoUri("<https://openapi.naver.com/v1/nid/me>") .redirectUri("{baseUrl}/oauth2/callback/naver") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .scope("name", "email") .userNameAttributeName("response") .build(); } // Kakao, 다대구, Facebook도 유사하게 설정 }
OAuth2UserService - 2차 인증 연계 처리
// CustomOAuth2UserService.java @Service public class CustomOAuth2UserService extends DefaultOAuth2UserService { @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) { OAuth2User oauth2User = super.loadUser(userRequest); String registrationId = userRequest.getClientRegistration().getRegistrationId(); String providerUserId = oauth2User.getAttribute("id").toString(); // 2차 인증 연계 코드 확인 User user = userRepository.findByProviderCode(registrationId, providerUserId) .orElseThrow(() -> new OAuth2AuthenticationException( "2차 인증이 연계되지 않은 사용자입니다." )); return new CustomOAuth2User(user, oauth2User.getAttributes()); } }
통합 과정의 어려움
각 OAuth2 제공자마다 API 스펙이 다르고, Callback URL, 응답 형식 등이 상이하여 이를 추상화하는 과정이 어려웠습니다.
  • Naver: response 객체 안에 사용자 정보
  • Kakao: kakao_account 객체 안에 사용자 정보
  • 다대구: 자체 API 스펙
이를 해결하기 위해 OAuth2UserService를 커스터마이징하여 각 제공자별로 사용자 정보를 추출하는 로직을 추상화했습니다.

HttpOnly Cookie + Redis 기반 토큰 관리

보안 강화를 위한 토큰 저장 방식 변경
초기에는 Access Token을 localStorage에 저장했으나, XSS 공격에 취약하다는 보안 문제로 인해 HttpOnly Cookie로 저장 방식을 변경했습니다.
HttpOnly Cookie 설정
// JwtAuthenticationSuccessHandler.java @Component public class JwtAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { ... // HttpOnly Cookie에 Access Token 저장 Cookie cookie = new Cookie("access_token", accessToken); cookie.setHttpOnly(true); // XSS 방지 cookie.setSecure(true); // HTTPS만 전송 cookie.setPath("/"); cookie.setMaxAge(accessTokenTtl); // Access Token TTL Cookie refreshCookie = new Cookie("refresh_token", refreshToken); refreshCookie.setHttpOnly(true); // XSS 방지 refreshCookie.setSecure(true); // HTTPS만 전송 refreshCookie.setPath("/"); refreshCookie.setMaxAge(refreshTokenTtl); // Access Token TTL response.addCookie(cookie); ... } }
개발 환경 HTTPS 설정
Secure Cookie는 HTTPS 환경에서만 전송되므로, 로컬 개발 환경에서도 HTTPS를 사용하기 위해 mkcert를 통해 로컬 인증서를 발급받아 SSL 레이어에서 테스트할 수 있도록 구성했습니다.
# mkcert 설치 및 로컬 인증서 생성 mkcert -install mkcert localhost 127.0.0.1
// vite.config.js import { defineConfig } from 'vite'; import fs from 'fs'; export default defineConfig({ server: { https: { key: fs.readFileSync('./localhost-key.pem'), cert: fs.readFileSync('./localhost.pem'), }, port: 3000, }, });
Redis 기반 토큰 관리
// TokenService.java @Service public class TokenService { public void saveToken(String username, String token) { redisTemplate.opsForValue().set( "token:" + username, token, 30, TimeUnit.MINUTES ); } public String getToken(String username) { return redisTemplate.opsForValue().get("token:" + username); } public void deleteToken(String username) { redisTemplate.delete("token:" + username); } }
토큰 갱신 전략
Refresh Token Rotation을 사용하지 않고, 토큰 유효기간을 최대한 짧게 설정(30분)하고 Silent Authentication을 통해 사용자가 활동 중일 때 백그라운드에서 지속적으로 토큰을 갱신하는 방식을 채택했습니다.

Web Worker 기반 정확한 토큰 갱신 메커니즘

Web Worker 선택 이유
JavaScript는 싱글 스레드 환경이므로, 메인 스택에서 setInterval 등의 타이머를 사용하면 메인 스레드가 점유될 때 정확한 시간을 셀 수 없습니다. 이는 토큰 갱신 타이밍의 불확실성으로 이어져, 사용자가 작업 중 세션 만료로 인해 로그인 페이지로 강제 이동되는 불편을 초래합니다.
이를 해결하기 위해 Web Worker를 도입하여 메인 스레드와 독립적으로 정확한 타이머를 운영하고, 토큰 만료 시점에 안정적으로 갱신 로직을 실행할 수 있도록 구현했습니다.
Web Worker 구현
// packages/auth/src/workers/token-refresh.worker.js let timerId = null; let expiresAt = null; self.addEventListener('message', (event) => { const { type, payload } = event.data; switch (type) { case 'START_TIMER': expiresAt = payload.expiresAt; startTimer(); break; case 'STOP_TIMER': stopTimer(); break; case 'RESET_TIMER': stopTimer(); expiresAt = payload.expiresAt; startTimer(); break; } }); function startTimer() { if (timerId) return; timerId = setInterval(() => { const now = Date.now(); const timeRemaining = expiresAt - now; // 토큰 만료 3분 전에 갱신 요청 if (timeRemaining <= 3 * 60 * 1000 && timeRemaining > 0) { self.postMessage({ type: 'REFRESH_TOKEN' }); stopTimer(); } // 토큰이 이미 만료된 경우 if (timeRemaining <= 0) { self.postMessage({ type: 'TOKEN_EXPIRED' }); stopTimer(); } }, 1000); // 1초마다 체크 } function stopTimer() { if (timerId) { clearInterval(timerId); timerId = null; } }
Vue에서 Web Worker 통합 (mitt 활용)
// packages/auth/src/composables/useTokenRefresh.js import { onMounted, onUnmounted } from 'vue'; import mitt from 'mitt'; const emitter = mitt(); let worker = null; export function useTokenRefresh() { const initWorker = () => { worker = new Worker( new URL('../workers/token-refresh.worker.js', import.meta.url), { type: 'module' } ); worker.addEventListener('message', (event) => { const { type } = event.data; switch (type) { case 'REFRESH_TOKEN': emitter.emit('refresh-token'); break; case 'TOKEN_EXPIRED': emitter.emit('token-expired'); break; } }); }; const startTimer = (expiresAt) => { worker?.postMessage({ type: 'START_TIMER', payload: { expiresAt }, }); }; const stopTimer = () => { worker?.postMessage({ type: 'STOP_TIMER' }); }; const resetTimer = (expiresAt) => { worker?.postMessage({ type: 'RESET_TIMER', payload: { expiresAt }, }); }; onMounted(() => { if (!worker) initWorker(); }); onUnmounted(() => { worker?.terminate(); worker = null; }); return { startTimer, stopTimer, resetTimer, on: emitter.on, off: emitter.off, }; }
Auth 패키지에서 토큰 갱신 처리
// packages/auth/src/composables/useAuth.js import { useTokenRefresh } from './useTokenRefresh'; import { silentRefreshToken } from '../api/auth'; export function useAuth() { const { startTimer, resetTimer, on } = useTokenRefresh(); // Worker에서 토큰 갱신 이벤트 수신 on('refresh-token', async () => { try { const { accessToken, expiresAt } = await silentRefreshToken(); // 새로운 토큰으로 타이머 재설정 resetTimer(expiresAt); } catch (error) { console.error('Token refresh failed:', error); // 재시도 로직 (최대 3회) await retryRefreshToken(); } }); // Worker에서 토큰 만료 이벤트 수신 on('token-expired', () => { // 로그아웃 처리 logout(); }); return { startTimer, // ... }; }
효과
  • 메인 스레드와 독립적으로 정확한 타이머 운영
  • 사용자 작업 중 세션 만료로 인한 불편 최소화
  • 안정적인 토큰 갱신 메커니즘 구축

Visibility API 기반 Idle 감지 및 자동 로그아웃

Visibility API를 통한 타이머 정확도 향상
브라우저 탭이 비활성화되면 setInterval 등의 타이머가 부정확해지는 문제가 있습니다. 이를 해결하기 위해 Visibility API를 활용하여 탭 활성화 여부를 감지하고, Idle 시간을 별도로 관리하여 타이머의 정확도를 향상시켰습니다.
Idle 감지 구현
// packages/auth/src/composables/useIdleDetection.js import { ref, onMounted, onUnmounted } from 'vue'; export function useIdleDetection(idleTimeout = 30 * 60 * 1000) { // 30분 const isIdle = ref(false); let idleTimer = null; let lastActivity = Date.now(); const resetIdleTimeout = () => { lastActivity = Date.now(); isIdle.value = false; if (idleTimer) { clearTimeout(idleTimer); } idleTimer = setTimeout(() => { isIdle.value = true; // 자동 로그아웃 처리 handleLogout(); }, idleTimeout); }; const handleActivity = (event) => { // 유의미한 활동만 감지 const meaningfulEvents = ['click', 'input', 'keydown', 'mousemove', 'scroll']; if (meaningfulEvents.includes(event.type)) { resetIdleTimeout(); } }; const handleVisibilityChange = () => { if (document.hidden) { // 탭 비활성화 시 마지막 활동 시간 저장 localStorage.setItem('lastActivity', lastActivity.toString()); } else { // 탭 활성화 시 경과 시간 계산 const savedLastActivity = parseInt(localStorage.getItem('lastActivity') || '0'); const elapsedTime = Date.now() - savedLastActivity; if (elapsedTime >= idleTimeout) { // Idle 시간 초과 시 자동 로그아웃 handleLogout(); } else { // 남은 시간만큼 타이머 재설정 resetIdleTimeout(); } } }; const handleLogout = () => { // 로그아웃 처리 console.log('Idle timeout - auto logout'); // logout API 호출 }; onMounted(() => { // 활동 감지 이벤트 리스너 등록 ['click', 'input', 'keydown', 'mousemove', 'scroll'].forEach((event) => { window.addEventListener(event, handleActivity, { passive: true }); }); // Visibility API 이벤트 리스너 등록 document.addEventListener('visibilitychange', handleVisibilityChange); // 초기 타이머 시작 resetIdleTimeout(); }); onUnmounted(() => { ['click', 'input', 'keydown', 'mousemove', 'scroll'].forEach((event) => { window.removeEventListener(event, handleActivity); }); document.removeEventListener('visibilitychange', handleVisibilityChange); if (idleTimer) { clearTimeout(idleTimer); } }); return { isIdle, resetIdleTimeout, }; }
여러 탭 관리
쿠키 기반 인증이므로, 한 탭에서만 활동해도 Silent Authentication이 계속 진행되는 동안에는 쿠키가 유지되어 다른 탭에서 로그아웃되지 않습니다.
효과
  • 정확한 Idle 시간 감지
  • 보안 강화 (장시간 미사용 시 자동 로그아웃)
  • 서버 리소스 최적화

Silent Authentication 구현

구현 방식
사용자 인터랙션 없이 백그라운드에서 토큰을 갱신하기 위해, display: none으로 숨겨진 <form>을 생성하여 POST 요청을 보내고, OAuth2 제공자에 prompt=none 설정을 추가하여 로그인 프롬프트가 생성되지 않도록 구현했습니다.
// packages/auth/src/api/silentAuth.js export async function silentRefreshToken() { return new Promise((resolve, reject) => { // 숨겨진 iframe 생성 const iframe = document.createElement('iframe'); iframe.style.display = 'none'; document.body.appendChild(iframe); // OAuth2 Authorization 요청 (prompt=none) const authUrl = new URL('/oauth2/authorize', window.location.origin); authUrl.searchParams.append('client_id', clientId); authUrl.searchParams.append('redirect_uri', redirectUri); authUrl.searchParams.append('response_type', 'code'); authUrl.searchParams.append('prompt', 'none'); // Silent Authentication authUrl.searchParams.append('code_challenge', codeChallenge); authUrl.searchParams.append('code_challenge_method', 'S256'); iframe.src = authUrl.toString(); // Callback 처리 window.addEventListener('message', (event) => { if (event.origin !== window.location.origin) return; const { code, error } = event.data; if (error) { reject(new Error(error)); } else { // Token 요청 fetchToken(code) .then((tokenResponse) => { resolve(tokenResponse); }) .catch(reject); } // iframe 제거 document.body.removeChild(iframe); }); // Timeout 처리 (10초) setTimeout(() => { document.body.removeChild(iframe); reject(new Error('Silent authentication timeout')); }, 10000); }); }

Refresh Token 기반 토큰 갱신

개요
사용자 인터랙션 없이 안전하게 Access Token을 갱신하기 위해, 기존 Silent Authentication 방식(iframe + prompt=none)을 대체하고 Refresh Token(RTR, Refresh Token Rotation) 기반 구조를 도입했습니다.
이를 통해 브라우저 상태 이상이나 보안 문제 발생 시에도 안정적으로 토큰을 갱신할 수 있습니다.

구현 방식
  1. Access / Refresh Token 분리
      • Access Token: API 호출 시 사용, 짧은 만료 시간
      • Refresh Token: HttpOnly Cookie에 저장, 브라우저에서 직접 접근 불가, 장기 만료
      • Refresh Token Rotation(RTR) 적용: 매 갱신 시 새로운 Refresh Token 발급 → 탈취 시 재사용 방지
  1. 토큰 갱신 로직
    1. // packages/auth/src/api/refreshToken.js export async function refreshAccessToken() { try { const res = await fetch('/auth/refresh', { method:'POST', credentials:'include',// HttpOnly Cookie 사용 }); if (!res.ok)throw new Error('Refresh failed'); const { accessToken } = await res.json(); return accessToken; } catch (err) { console.error('Access token refresh failed', err); throw err; } }
재발행 조건 및 Retry 로직
  • Access Token 만료 3분 전에 갱신 시도
  • 갱신 실패 시 최대 3회 재시도 (지수 백오프)
// packages/auth/src/api/auth.js async function retryRefreshToken(retries = 2) { for (let i = 0; i < retries; i++) { try { const tokenResponse = await refreshAccessToken(); return tokenResponse; } catch (error) { console.error(`Token refresh attempt ${i + 1} failed:`, error); if (i === retries - 1) { // 최종 실패 시 로그아웃 logout(); throw error; } // 지수 백오프 (1s, 2s, 4s) await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1000)); } } }

프론트엔드 인증 상태 관리

Pinia 스토어 구조
관리자/사용자 두 개의 Tenant는 독립된 스토어에서 관리하고, 사용자 앱의 경우 2개의 Tenant로 나뉘므로 Plugin 등록 시 Client ID를 설정하여 구분합니다.
import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { logout as apiLogout } from '@project/auth'; export const useAuthStore = defineStore('auth', () => { // state const user = ref(null); const roles = ref<string[]>([]); const isAuthenticated = ref(false); // getters const hasRole = (role: string) => roles.value.includes(role); // actions const fetchUser = async () => { try { const response = await fetch('/api/auth/userinfo', { credentials: 'include', // Cookie 전송 }); const userData = await response.json(); user.value = userData; roles.value = userData.roles; isAuthenticated.value = true; } catch (error) { apiLogout(); } }; const logout = () => { user.value = null; roles.value = []; isAuthenticated.value = false; apiLogout(); // 로그아웃 API 호출 }; // 반환 return { user, roles, isAuthenticated, hasRole, fetchUser, logout, }; });
OAuth2 Callback 처리
// packages/auth/src/composables/useAuthCallback.js export function useAuthCallback() { const handleCallback = async () => { const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); const error = urlParams.get('error'); if (error) { console.error('OAuth2 error:', error); // 에러 처리 return; } if (code) { try { // Authorization Code를 백엔드로 전달하여 Token 발급 const response = await fetch('/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, code_verifier: codeVerifier, // PKCE }), credentials: 'include', }); const { expiresAt } = await response.json(); // Token 갱신 타이머 시작 const { startTimer } = useTokenRefresh(); startTimer(expiresAt); // 사용자 정보 가져오기 const authStore = useAuthStore(); await authStore.fetchUser(); // 원래 이동하려던 페이지로 리다이렉트 const redirectUrl = sessionStorage.getItem('redirect_after_login') || '/'; sessionStorage.removeItem('redirect_after_login'); window.location.href = redirectUrl; } catch (error) { console.error('Token exchange failed:', error); } } }; return { handleCallback }; }
에러 처리 (ky-client afterResponse)
// packages/client/src/kyClient.js import ky from 'ky'; export const kyClient = ky.create({ prefixUrl: '/api', credentials: 'include', hooks: { afterResponse: [ async (request, options, response) => { if (response.status === 401) { // Unauthorized - 자동 로그아웃 const authStore = useAuthStore(); authStore.logout(); window.location.href = '/login'; } if (response.status === 403) { // Forbidden - authRequired가 아닌 페이지로 리다이렉트 window.location.href = '/'; } return response; }, ], }, });
동시 다발적 API 요청 시 토큰 갱신 처리
여러 API 요청이 동시에 진행 중일 때 토큰이 만료되면, 첫 번째 요청이 401을 받아 토큰 갱신을 시도하고, 나머지 요청들은 대기했다가 재시도하도록 구현했습니다.
// packages/client/src/kyClient.js let isRefreshing = false; let refreshSubscribers = []; const onRefreshed = () => { refreshSubscribers.forEach((callback) => callback()); refreshSubscribers = []; }; const addRefreshSubscriber = (callback) => { refreshSubscribers.push(callback); }; export const kyClient = ky.create({ hooks: { beforeRequest: [ async (request) => { if (isRefreshing) { // 토큰 갱신 중이면 대기 return new Promise((resolve) => { addRefreshSubscriber(() => { resolve(request); }); }); } return request; }, ], afterResponse: [ async (request, options, response) => { if (response.status === 401 && !isRefreshing) { isRefreshing = true; try { await refreshAccessToken(); onRefreshed(); // 실패했던 요청 재시도 return ky(request); } catch (error) { // 갱신 실패 시 대기 중인 모든 요청 abort refreshSubscribers = []; logout(); } finally { isRefreshing = false; } } return response; }, ], }, });

🚀 주요 성과


모노레포 시스템 구축 성공

  • 코드 중복 약 40% 감소: 공통 패키지 활용으로 중복 코드 최소화
  • 유지보수 효율성 향상: 버그 수정 시 한 번의 수정으로 두 앱 동시 반영
  • 개발 생산성 향상: 40+ 공통 컴포넌트 및 Composables 제공으로 개발 시간 단축
  • 팀 내 개발 표준화: 일관된 코드 스타일 및 컨벤션 확립

Multi-tenant 통합 인증 시스템 구축

  • 3개 Tenant 독립 인증: Tenant별 독립적인 인증 시스템 구축
  • 5개 인증 제공자 통합: OAuth2 (Naver, Kakao, 다대구, Facebook), Form Login 통합
  • 보안 강화: PKCE + HttpOnly Cookie로 XSS 공격 방지

Web Worker 기반 안정적 토큰 갱신

  • 정확한 토큰 갱신: 메인 스레드 독립적으로 정확한 타이머 운영
  • 사용자 경험 향상: 세션 만료로 인한 불편 최소화
  • Silent Authentication: 사용자 인터랙션 없이 백그라운드 토큰 갱신

Visibility API 기반 보안 강화

  • Idle 감지 및 자동 로그아웃: 30분 미사용 시 자동 로그아웃
  • 서버 리소스 최적화: 불필요한 세션 관리 감소

번들 최적화

  • 사전 최적화: Visualizer를 통한 주기적 모니터링
  • Code Splitting: Lazy Component 및 manualChunk 적용
  • 빌드 시간 관리: Turborepo 캐싱으로 증분 빌드 지원

🔧 프로젝트 진행 시 어려웠던 점과 해결 과정


💡 Tenant별 설정 관리의 복잡성

문제 상황
3개의 Tenant에 대해 각각 독립적인 인증 설정을 관리해야 했고, 각 Tenant별로 OAuth2 Client 등록, Callback URL 설정 등이 필요했습니다. 이 과정에서 프론트엔드에 Callback 처리 로직이 중복되어 코드가 복잡해지는 문제가 있었습니다.
해결 과정
Auth 패키지에 useAuthCallback Composable을 구현하여 Callback 처리 로직을 공통화했습니다. 컴포넌트에서는 기본적인 동작 설정만 Props로 전달하고, 실제 처리는 Composable에서 담당하도록 추상화했습니다.
// packages/auth/src/composables/useAuthCallback.js export function useAuthCallback(options = {}) { const { onSuccess = () => {}, onError = () => {}, redirectAfterLogin = '/', } = options; const handleCallback = async () => { // 공통 Callback 처리 로직 const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); const error = urlParams.get('error'); if (error) { onError(error); return; } if (code) { try { // Token 발급 및 사용자 정보 가져오기 await exchangeCodeForToken(code); await fetchUserInfo(); onSuccess(); window.location.href = redirectAfterLogin; } catch (err) { onError(err); } } }; return { handleCallback }; }
<!-- 컴포넌트에서 사용 --> <template> <div>Processing login...</div> </template> <script setup> import { onMounted } from 'vue'; import { useAuthCallback } from '@packages/auth'; const { handleCallback } = useAuthCallback({ redirectAfterLogin: '/dashboard', onError: (error) => { console.error('Login failed:', error); alert('로그인에 실패했습니다.'); }, }); onMounted(() => { handleCallback(); }); </script>
효과
  • 중복 코드 제거
  • Tenant별 Callback 컴포넌트 유지보수 용이
  • 일관된 에러 처리

💡 OAuth2 제공자별 API 스펙 차이 통합

문제 상황
각 OAuth2 제공자(Naver, Kakao, 다대구, Facebook)마다 API 응답 형식이 달라 이를 추상화하는 과정이 어려웠습니다.
  • Naver: response 객체 안에 사용자 정보
  • Kakao: kakao_account 객체 안에 사용자 정보
  • 다대구: 자체 API 스펙
해결 과정
Spring Security의 OAuth2UserService를 커스터마이징하여 각 제공자별 사용자 정보 추출 로직을 추상화했습니다.
// OAuth2UserInfoExtractor.java public interface OAuth2UserInfoExtractor { String extractUserId(Map<String, Object> attributes); String extractEmail(Map<String, Object> attributes); String extractName(Map<String, Object> attributes); } // NaverOAuth2UserInfoExtractor.java public class NaverOAuth2UserInfoExtractor implements OAuth2UserInfoExtractor { @Override public String extractUserId(Map<String, Object> attributes) { Map<String, Object> response = (Map<String, Object>) attributes.get("response"); return (String) response.get("id"); } @Override public String extractEmail(Map<String, Object> attributes) { Map<String, Object> response = (Map<String, Object>) attributes.get("response"); return (String) response.get("email"); } // ... } // KakaoOAuth2UserInfoExtractor.java public class KakaoOAuth2UserInfoExtractor implements OAuth2UserInfoExtractor { @Override public String extractUserId(Map<String, Object> attributes) { return attributes.get("id").toString(); } @Override public String extractEmail(Map<String, Object> attributes) { Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account"); return (String) kakaoAccount.get("email"); } // ... }
// CustomOAuth2UserService.java @Service public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final Map<String, OAuth2UserInfoExtractor> extractors = Map.of( "naver", new NaverOAuth2UserInfoExtractor(), "kakao", new KakaoOAuth2UserInfoExtractor(), "dadaegu", new DadaeguOAuth2UserInfoExtractor(), "facebook", new FacebookOAuth2UserInfoExtractor() ); @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) { OAuth2User oauth2User = super.loadUser(userRequest); String registrationId = userRequest.getClientRegistration().getRegistrationId(); OAuth2UserInfoExtractor extractor = extractors.get(registrationId); if (extractor == null) { throw new OAuth2AuthenticationException("Unsupported provider: " + registrationId); } String userId = extractor.extractUserId(oauth2User.getAttributes()); String email = extractor.extractEmail(oauth2User.getAttributes()); String name = extractor.extractName(oauth2User.getAttributes()); // 2차 인증 연계 확인 User user = userRepository.findByProviderCode(registrationId, userId) .orElseThrow(() -> new OAuth2AuthenticationException("2차 인증이 연계되지 않은 사용자입니다.")); return new CustomOAuth2User(user, oauth2User.getAttributes()); } }
효과
  • 제공자별 API 스펙 차이 추상화
  • 확장 가능한 구조 (새로운 제공자 추가 용이)

💡 Web Worker 디버깅의 어려움

문제 상황
Web Worker는 메인 스레드와 분리된 환경에서 실행되므로 디버깅이 어려웠습니다. 특히 타이머 로직의 정확성을 검증하는 과정에서 많은 시행착오가 있었습니다.
해결 과정
Chrome DevTools의 Worker 디버깅 기능을 활용하고, Worker 내부에 로그를 추가하여 디버깅했습니다.
// token-refresh.worker.js function startTimer() { console.log('[Worker] Timer started, expires at:', new Date(expiresAt)); timerId = setInterval(() => { const now = Date.now(); const timeRemaining = expiresAt - now; console.log('[Worker] Time remaining:', timeRemaining / 1000, 'seconds'); if (timeRemaining <= 3 * 60 * 1000 && timeRemaining > 0) { console.log('[Worker] Sending REFRESH_TOKEN message'); self.postMessage({ type: 'REFRESH_TOKEN' }); stopTimer(); } if (timeRemaining <= 0) { console.log('[Worker] Token expired, sending TOKEN_EXPIRED message'); self.postMessage({ type: 'TOKEN_EXPIRED' }); stopTimer(); } }, 1000); }
Chrome DevTools Worker 디버깅
  1. DevTools → Sources → 좌측 패널에서 "Workers" 선택
  1. Worker 파일 확인 및 브레이크포인트 설정
  1. Console에서 Worker 로그 확인
통신 오류 처리
Worker와 메인 스레드 간 통신 시 Origin 확인 외에는 별도 검증을 하지 않았습니다. 내부 통신이므로 보안 위협이 적다고 판단했습니다.

💡 모노레포 빌드 시간 증가

문제 상황
개발 중이다 보니 공통 패키지에 대한 수정이 빈번하게 발생하고, 이로 인해 Turborepo의 캐시 효과를 누리지 못해 빌드 시간 편차가 컸습니다.
현재 상황
  • 공통 패키지 수정 시: 전체 재빌드 (캐시 무효화)
  • 앱만 수정 시: 증분 빌드 (빠름)
개선 방향
개발이 안정화되면 공통 패키지 수정 빈도가 줄어들어 캐시 효과를 충분히 누릴 수 있을 것으로 예상됩니다. 또한, CI/CD 파이프라인에서 Turborepo의 Remote Caching을 도입하여 팀원 간 빌드 캐시를 공유하는 방안을 검토 중입니다.
// turbo.json { "remoteCache": { "enabled": true, "signature": true } }

🤔 아쉬운 점 및 개선 방향


패키지 버전 관리 전략 미흡

현재 상황
패키지 버전 관리를 Fixed 방식으로 하고 있어, 모든 패키지가 동일한 버전을 사용합니다. 이는 초기 설정이 간단하지만, 개별 패키지의 독립적인 버전 관리가 어렵습니다.
개선 방향
Independent 버전 관리 도입
  • 각 패키지가 독립적인 버전을 가지도록 변경
  • Semantic Versioning (Major.Minor.Patch) 엄격히 적용
  • Breaking Changes 발생 시 Major 버전 업데이트
Changeset 도입
Changesets를 통해 패키지 변경사항을 관리하고 자동으로 CHANGELOG 생성
# Changesets 설치 pnpm add -Dw @changesets/cli pnpm changeset init
# 변경사항 기록 pnpm changeset # 버전 업데이트 pnpm changeset version # 배포 pnpm changeset publish
기대 효과
  • 패키지별 독립적인 버전 관리
  • Breaking Changes 명확히 표시
  • 자동화된 CHANGELOG 생성

E2E 테스트 부족

현재 상황
공통 패키지에 대한 단위 테스트는 Jest로 진행하고 있지만, 애플리케이션 레벨의 E2E 테스트가 부족합니다.
개선 방향
Playwright 기반 E2E 테스트 도입
  • 주요 사용자 플로우 테스트 자동화
  • 인증 플로우 테스트 (OAuth2, Form Login)
  • CI/CD 파이프라인 통합
// e2e/auth-flow.spec.js import { test, expect } from '@playwright/test'; test('OAuth2 로그인 플로우', async ({ page }) => { await page.goto('/login'); // Naver 로그인 버튼 클릭 await page.click('[data-testid="naver-login-button"]'); // Naver 로그인 페이지로 리다이렉트 확인 await expect(page).toHaveURL(/nid\\.naver\\.com/); // 로그인 (테스트 계정) await page.fill('[name="id"]', 'test@naver.com'); await page.fill('[name="pw"]', 'testpassword'); await page.click('[type="submit"]'); // Callback 처리 및 대시보드 리다이렉트 확인 await expect(page).toHaveURL('/dashboard'); // 사용자 정보 표시 확인 await expect(page.locator('[data-testid="user-name"]')).toBeVisible(); });
기대 효과
  • 인증 플로우 안정성 보장
  • 리그레션 방지
  • 수동 테스트 시간 대폭 감소

성능 모니터링 체계 부재

현재 상황
번들 최적화를 사전에 진행했지만, 실제 프로덕션 환경에서의 성능 지표를 지속적으로 모니터링하는 체계가 부족합니다.
개선 방향
Sentry 도입
  • 프론트엔드 에러 추적
  • 성능 모니터링 (LCP, FID, CLS)
  • 사용자 세션 재생
// main.js import * as Sentry from '@sentry/vue'; Sentry.init({ app, dsn: 'YOUR_SENTRY_DSN', integrations: [ new Sentry.BrowserTracing({ routingInstrumentation: Sentry.vueRouterInstrumentation(router), }), ], tracesSampleRate: 1.0, });
Performance Budget 설정
CI/CD에서 번들 크기 및 성능 지표 체크
// performance-budget.json { "budgets": [ { "path": "dist/index.js", "maxSize": "600kb", "gzip": true }, { "path": "dist/index.css", "maxSize": "350kb", "gzip": true } ] }
기대 효과
  • 실시간 성능 모니터링
  • 성능 저하 조기 발견
  • 데이터 기반 최적화 의사결정

📚 기술적 성장


대규모 모노레포 설계 및 구축 경험

  • pnpm workspace + Turborepo 기반 모노레포 시스템 설계 및 구축 주도
  • 공통 패키지와 기능 패키지 분리 전략 수립
  • 점진적 마이그레이션 및 팀 협업 경험

Multi-tenant 인증 시스템 설계 역량

  • 3개 Tenant에 대한 독립적인 인증 시스템 설계 및 구현
  • OAuth2 2.1 + PKCE 플로우 이해 및 실무 적용
  • 5개 인증 제공자 통합 경험

Web API 활용 능력

  • Web Worker를 활용한 백그라운드 작업 처리
  • Visibility API를 통한 사용자 활동 감지
  • 브라우저 API를 활용한 성능 최적화

보안 강화 경험

  • XSS 공격 방지 (HttpOnly Cookie)
  • PKCE를 통한 Authorization Code 탈취 방지
  • Silent Authentication을 통한 안전한 토큰 갱신

협업 및 문서화 능력

  • 팀 온보딩 세션 진행 및 가이드 문서 작성
  • Jest 테스트 템플릿 기반 자동 문서 생성
  • 일관된 코드 컨벤션 확립