[{"content":"Hugo란? Hugo는 Go로 작성된 정적 사이트 생성기(Static Site Generator)입니다. 마크다운으로 글을 쓰면 HTML로 변환해주며, 빌드 속도가 매우 빠릅니다.\n수천 개의 페이지도 수 초 이내에 빌드 마크다운 기반 콘텐츠 관리 테마와 레이아웃 커스터마이징 자유도 높음 GitHub Pages, Netlify, Vercel 등 다양한 플랫폼에 배포 가능 1. 설치 Windows # winget winget install Hugo.Hugo.Extended # chocolatey choco install hugo-extended macOS brew install hugo Linux (Ubuntu/Debian) # snap sudo snap install hugo # apt (버전이 오래될 수 있음) sudo apt install hugo Linux (직접 설치) 최신 버전이 필요하면 GitHub 릴리즈에서 직접 다운로드합니다.\nHUGO_VERSION=0.128.0 wget https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb sudo dpkg -i hugo_extended_${HUGO_VERSION}_linux-amd64.deb 설치 확인 hugo version # hugo v0.128.0-... extended extended 버전인지 확인하세요. SCSS/SASS 처리에 필요합니다.\n2. 새 사이트 만들기 hugo new site my-blog cd my-blog 생성되는 디렉토리 구조:\nmy-blog/ ├── archetypes/ # 새 글 템플릿 ├── content/ # 마크다운 콘텐츠 ├── layouts/ # HTML 템플릿 ├── static/ # 정적 파일 (CSS, JS, 이미지) ├── themes/ # 테마 └── hugo.toml # 설정 파일 3. 콘텐츠 작성 새 포스트를 생성합니다.\nhugo new posts/my-first-post.md 생성된 파일을 편집합니다.\n--- title: \u0026#34;나의 첫 번째 포스트\u0026#34; date: 2026-02-16T10:00:00+09:00 draft: false tags: [\u0026#34;시작\u0026#34;] --- 여기에 마크다운으로 글을 작성합니다. ## 소제목 본문 내용... draft: true이면 빌드에 포함되지 않습니다. 작성이 완료되면 false로 바꾸세요.\n4. 로컬 개발 서버 hugo server http://localhost:1313에서 블로그를 확인할 수 있습니다.\n파일을 수정하면 자동으로 새로고침됩니다(Live Reload).\nWSL 환경에서 Live Reload가 안 될 때 WSL에서 /mnt/c/ 경로의 파일을 수정하면 파일 변경 이벤트가 Hugo에 전달되지 않을 수 있습니다. --poll 옵션으로 해결합니다.\nhugo server --poll 500ms 파일 시스템 이벤트 대신 0.5초마다 변경을 직접 확인하는 방식입니다.\n유용한 옵션 # 초안(draft) 포스트도 포함 hugo server -D # 포트 변경 hugo server -p 3000 # 외부 접속 허용 (같은 네트워크의 다른 기기에서 확인) hugo server --bind 0.0.0.0 5. 빌드 hugo public/ 디렉토리에 정적 파일이 생성됩니다. 이 디렉토리를 웹 서버에 배포하면 됩니다.\n프로덕션 빌드 hugo --gc --minify --gc: 사용하지 않는 캐시 파일 정리 --minify: HTML, CSS, JS 압축 6. GitHub Pages 배포 이 블로그는 GitHub Actions로 자동 배포됩니다. .github/workflows/hugo.yml 파일을 설정하면 main 브랜치에 push할 때마다 자동으로 빌드 및 배포가 진행됩니다.\nname: Deploy Hugo site to Pages on: push: branches: - main jobs: build: runs-on: ubuntu-latest env: HUGO_VERSION: 0.128.0 steps: - name: Install Hugo CLI run: | wget -O ${{ runner.temp }}/hugo.deb \\ https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \\ \u0026amp;\u0026amp; sudo dpkg -i ${{ runner.temp }}/hugo.deb - name: Checkout uses: actions/checkout@v4 - name: Build with Hugo run: hugo --gc --minify - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./public deploy: runs-on: ubuntu-latest needs: build permissions: pages: write id-token: write steps: - name: Deploy to GitHub Pages uses: actions/deploy-pages@v4 워크플로우 설정 후 GitHub 리포지토리의 Settings \u0026gt; Pages \u0026gt; Source에서 GitHub Actions를 선택하면 배포가 활성화됩니다.\n마무리 Hugo를 사용하면 마크다운 파일만 작성하면 블로그가 완성됩니다. 설치 → 글 작성 → hugo server로 확인 → push하면 자동 배포. 이것이 전부입니다.\n다음 글에서는 Hugo 테마 커스터마이징과 레이아웃 구조를 다루겠습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/hugo-getting-started/","summary":"\u003ch2 id=\"hugo란\"\u003eHugo란?\u003c/h2\u003e\n\u003cp\u003eHugo는 Go로 작성된 정적 사이트 생성기(Static Site Generator)입니다. 마크다운으로 글을 쓰면 HTML로 변환해주며, 빌드 속도가 매우 빠릅니다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e수천 개의 페이지도 수 초 이내에 빌드\u003c/li\u003e\n\u003cli\u003e마크다운 기반 콘텐츠 관리\u003c/li\u003e\n\u003cli\u003e테마와 레이아웃 커스터마이징 자유도 높음\u003c/li\u003e\n\u003cli\u003eGitHub Pages, Netlify, Vercel 등 다양한 플랫폼에 배포 가능\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"1-설치\"\u003e1. 설치\u003c/h2\u003e\n\u003ch3 id=\"windows\"\u003eWindows\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# winget\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ewinget install Hugo.Hugo.Extended\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:#6c7086;font-style:italic\"\u003e# chocolatey\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003echoco install hugo-extended\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"macos\"\u003emacOS\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003ebrew install hugo\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"linux-ubuntudebian\"\u003eLinux (Ubuntu/Debian)\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# snap\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo snap install hugo\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:#6c7086;font-style:italic\"\u003e# apt (버전이 오래될 수 있음)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt install hugo\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"linux-직접-설치\"\u003eLinux (직접 설치)\u003c/h3\u003e\n\u003cp\u003e최신 버전이 필요하면 GitHub 릴리즈에서 직접 다운로드합니다.\u003c/p\u003e","tags":["Hugo","블로그","정적사이트"],"title":"Hugo 시작하기 - 설치, 사이트 생성, GitHub Pages 배포"},{"content":"1. 브랜치란? 브랜치의 개념 브랜치(Branch)는 독립적인 작업 공간을 만들어주는 Git의 핵심 기능입니다. 마치 평행세계처럼 원본 코드에 영향을 주지 않고 새로운 기능을 개발할 수 있습니다.\n# 현재 브랜치 확인 git branch # 모든 브랜치 확인 (원격 포함) git branch -a HEAD 포인터 HEAD는 현재 작업 중인 브랜치를 가리키는 포인터입니다.\n# HEAD가 가리키는 위치 확인 cat .git/HEAD # 출력: ref: refs/heads/main # 특정 커밋으로 HEAD 이동 (detached HEAD 상태) git checkout \u0026lt;commit-hash\u0026gt; 브랜치 구조 시각화:\nmain A --- B --- C \\ feature D --- E 2. 브랜치 기본 명령어 브랜치 생성 및 이동 # 브랜치 생성 git branch feature/login # 브랜치 이동 (checkout, 구버전) git checkout feature/login # 브랜치 생성 + 이동 (checkout) git checkout -b feature/signup # 브랜치 이동 (switch, Git 2.23+) git switch feature/login # 브랜치 생성 + 이동 (switch) git switch -c feature/signup 권장: switch가 더 명확하므로 최신 Git에서는 switch를 사용하세요.\n브랜치 병합 # main 브랜치로 이동 git switch main # feature 브랜치를 main에 병합 git merge feature/login 브랜치 삭제 # 로컬 브랜치 삭제 (병합 완료된 경우) git branch -d feature/login # 강제 삭제 (병합 안 됐어도) git branch -D feature/login # 원격 브랜치 삭제 git push origin --delete feature/login 3. Merge vs Rebase Merge: 합치기 Merge는 두 브랜치의 변경사항을 합쳐 새로운 커밋을 만듭니다.\ngit switch main git merge feature/login Before:\nmain A --- B --- C \\ feature D --- E After (Merge):\nmain A --- B --- C --- M \\ / feature D ------- E 장점:\n커밋 히스토리가 있는 그대로 보존됨 브랜치가 언제 병합되었는지 명확 단점:\n히스토리가 복잡해짐 (Merge 커밋이 계속 생김) 그래프가 지저분해질 수 있음 Rebase: 재배치하기 Rebase는 커밋을 다른 브랜치 위로 재배치합니다.\ngit switch feature/login git rebase main Before:\nmain A --- B --- C \\ feature D --- E After (Rebase):\nmain A --- B --- C \\ feature D\u0026#39; --- E\u0026#39; 장점:\n깔끔한 선형 히스토리 그래프가 단순하고 읽기 쉬움 단점:\n커밋 히스토리가 재작성됨 (커밋 해시 변경) 공유된 브랜치에 사용하면 위험 (다른 팀원과 충돌) 언제 무엇을 쓸까? 상황 권장 방법 개인 feature 브랜치 정리 rebase main 브랜치에 병합 merge (또는 squash merge) 공개/공유된 브랜치 절대 rebase 금지, merge 사용 히스토리 정리가 중요한 프로젝트 rebase 병합 기록이 중요한 프로젝트 merge 황금률: 푸시된 커밋은 rebase 하지 않는다!\n4. 충돌(Conflict) 해결 충돌이 발생하는 이유 두 브랜치가 같은 파일의 같은 부분을 다르게 수정했을 때 Git은 자동으로 병합할 수 없습니다.\n# merge 시도 시 충돌 발생 git merge feature/login # Auto-merging src/auth.js # CONFLICT (content): Merge conflict in src/auth.js 충돌 확인 # 충돌 파일 확인 git status # Unmerged paths: # both modified: src/auth.js 충돌 파일 내용 // src/auth.js function login(username, password) { \u0026lt;\u0026lt;\u0026lt;\u0026lt;\u0026lt;\u0026lt;\u0026lt; HEAD // main 브랜치의 코드 return validateUser(username, password); ======= // feature 브랜치의 코드 return authenticateUser(username, password); \u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt; feature/login } \u0026lt;\u0026lt;\u0026lt;\u0026lt;\u0026lt;\u0026lt;\u0026lt; HEAD: 현재 브랜치(main)의 내용 =======: 구분선 \u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt; feature/login: 병합하려는 브랜치의 내용 충돌 해결 과정 # 1. 충돌 파일 수동 수정 (마커 제거 + 원하는 코드 선택) # src/auth.js를 다음과 같이 수정: function login(username, password) { return authenticateUser(username, password); } # 2. 수정한 파일 스테이징 git add src/auth.js # 3. 병합 완료 git commit -m \u0026#34;Merge feature/login - resolve auth.js conflict\u0026#34; 도구 활용 # VSCode, IntelliJ, Sublime Merge 등 GUI 도구 사용 git mergetool # 병합 취소 (충돌 해결 포기) git merge --abort 5. Git Flow 전략 Git Flow는 Vincent Driessen이 제안한 브랜치 전략으로, 복잡한 릴리즈 관리에 적합합니다.\n브랜치 종류 main (production) ↓ develop (개발 베이스) ↓ feature/* (기능 개발) release/* (릴리즈 준비) hotfix/* (긴급 수정) 1) main 브랜치 프로덕션 배포용 항상 안정적인 상태 유지 태그로 버전 관리 2) develop 브랜치 다음 릴리즈를 위한 개발 베이스 feature 브랜치들이 여기로 병합됨 3) feature 브랜치 # feature 브랜치 생성 (develop에서 분기) git switch develop git switch -c feature/user-profile # 개발 완료 후 develop에 병합 git switch develop git merge feature/user-profile git branch -d feature/user-profile 네이밍: feature/login, feature/payment-integration\n4) release 브랜치 # release 브랜치 생성 (develop에서 분기) git switch develop git switch -c release/1.2.0 # 버그 수정, 문서 업데이트 등 # 완료 후 main과 develop에 모두 병합 git switch main git merge release/1.2.0 git tag v1.2.0 git switch develop git merge release/1.2.0 git branch -d release/1.2.0 5) hotfix 브랜치 # hotfix 브랜치 생성 (main에서 분기) git switch main git switch -c hotfix/security-patch # 긴급 수정 후 main과 develop에 병합 git switch main git merge hotfix/security-patch git tag v1.2.1 git switch develop git merge hotfix/security-patch git branch -d hotfix/security-patch Git Flow 전체 흐름 main o-------o-------o (v1.0) (v1.1) \\ \\ \\ develop o---o---o---o---o \\ \\ / / feature/A o---o---o / \\ / feature/B o-------o 적합한 경우:\n명확한 릴리즈 주기가 있는 프로젝트 여러 버전을 동시에 유지보수해야 하는 경우 대규모 팀 프로젝트 6. GitHub Flow GitHub Flow는 Git Flow보다 단순하며, 지속적 배포(CD)에 최적화되어 있습니다.\n핵심 원칙 main 브랜치는 항상 배포 가능한 상태 새 기능은 main에서 브랜치를 만들어 작업 Pull Request로 코드 리뷰 후 병합 병합 즉시 배포 워크플로우 # 1. main에서 feature 브랜치 생성 git switch main git pull origin main git switch -c feature/add-comment # 2. 커밋 및 푸시 git add . git commit -m \u0026#34;Add comment feature\u0026#34; git push origin feature/add-comment # 3. GitHub에서 Pull Request 생성 # 4. 코드 리뷰 후 main에 병합 (GitHub에서 처리) # 5. 로컬에서 정리 git switch main git pull origin main git branch -d feature/add-comment 브랜치 전략 시각화 main A --- B --- C --- D --- E \\ / / feature/1 o-------o / \\ / feature/2 o---------o 적합한 경우:\n웹 서비스, SaaS 등 지속적 배포가 필요한 경우 작은 팀, 빠른 릴리즈 주기 CI/CD가 잘 갖춰진 환경 7. Trunk-Based Development 트렁크 기반 개발은 하나의 메인 브랜치(trunk/main)를 중심으로 모든 개발자가 작업하는 방식입니다.\n핵심 원칙 브랜치 수명이 매우 짧음 (1~2일 이내) 작은 단위로 자주 커밋 및 병합 Feature Flag로 미완성 기능 숨김 자동화된 테스트 필수 워크플로우 # 1. main에서 짧은 브랜치 생성 git switch main git pull origin main git switch -c feature/button-style # 2. 작은 변경사항 커밋 (몇 시간 내) git add . git commit -m \u0026#34;Update button primary color\u0026#34; # 3. 빠르게 main에 병합 git switch main git merge feature/button-style git push origin main git branch -d feature/button-style Feature Flag 예시 // 미완성 기능을 플래그로 숨김 if (featureFlags.newCheckout) { return \u0026lt;NewCheckoutFlow /\u0026gt;; } else { return \u0026lt;OldCheckoutFlow /\u0026gt;; } 장점:\n병합 충돌 최소화 (브랜치가 짧아서) 지속적 통합(CI)과 궁합이 좋음 배포 주기가 빨라짐 단점:\n강력한 CI/CD 인프라 필요 높은 테스트 커버리지 필요 개발자 간 긴밀한 협업 필요 8. Pull Request 활용 PR 작성법 좋은 PR 제목:\n✅ feat: 사용자 프로필 편집 기능 추가 ✅ fix: 로그인 시 토큰 만료 버그 수정 ❌ update ❌ 작업 완료 PR 설명 템플릿:\n## 변경 사항 - 사용자 프로필 편집 API 추가 - 프로필 이미지 업로드 기능 구현 ## 테스트 - [ ] 유닛 테스트 추가 - [x] 수동 테스트 완료 - [x] 기존 테스트 통과 ## 스크린샷 (UI 변경이 있다면 첨부) ## 관련 이슈 Closes #123 코드 리뷰 문화 리뷰어:\n코드 품질, 로직 오류, 보안 이슈 확인 건설적인 피드백 제공 ✅ \u0026#34;여기서 null 체크를 추가하면 더 안전할 것 같습니다.\u0026#34; ❌ \u0026#34;이 코드는 완전히 잘못됐네요.\u0026#34; 작성자:\n피드백을 받아들이고 코드 수정 논의가 필요한 부분은 댓글로 토론 PR 템플릿 설정 프로젝트 루트에 .github/PULL_REQUEST_TEMPLATE.md 파일 생성:\n## 변경 사항 \u0026lt;!-- 이 PR에서 무엇을 변경했는지 설명해주세요 --\u0026gt; ## 테스트 - [ ] 유닛 테스트 추가 - [ ] 통합 테스트 추가 - [ ] 수동 테스트 완료 ## 체크리스트 - [ ] 코드 스타일 가이드 준수 - [ ] 주석 및 문서 업데이트 - [ ] Breaking Change 없음 ## 관련 이슈 \u0026lt;!-- Closes #이슈번호 --\u0026gt; 9. Cherry-pick 활용 Cherry-pick은 다른 브랜치의 특정 커밋만 가져오는 기능입니다.\n사용 시나리오 hotfix 커밋을 여러 브랜치에 적용 실수로 잘못된 브랜치에 커밋한 경우 feature 브랜치의 일부 커밋만 main에 적용 기본 사용법 # 1. 가져올 커밋 해시 확인 git log feature/payment # commit abc1234 - \u0026#34;Add payment validation\u0026#34; # 2. main 브랜치로 이동 git switch main # 3. 특정 커밋만 가져오기 git cherry-pick abc1234 # 성공 시 새 커밋이 main에 생성됨 여러 커밋 가져오기 # 연속된 커밋 범위 가져오기 git cherry-pick abc1234..def5678 # 여러 개별 커밋 가져오기 git cherry-pick abc1234 def5678 ghi9012 충돌 처리 # cherry-pick 중 충돌 발생 시 git status # 충돌 파일 수정 후 git add . git cherry-pick --continue # 포기하고 취소 git cherry-pick --abort 실전 예시: hotfix를 여러 브랜치에 적용 # main에서 hotfix 커밋 git switch main git commit -m \u0026#34;Fix critical security bug\u0026#34; # commit: xyz7890 # develop 브랜치에도 동일한 수정 적용 git switch develop git cherry-pick xyz7890 # release 브랜치에도 적용 git switch release/2.0 git cherry-pick xyz7890 주의사항:\nCherry-pick은 커밋을 복사하는 것 (해시가 달라짐) 같은 커밋을 여러 곳에 cherry-pick하면 나중에 merge 충돌 발생 가능 가능하면 merge를 사용하고, cherry-pick은 예외적인 상황에만 사용 10. 실전 시나리오: 팀 프로젝트 브랜치 운영 상황: 5명이서 쇼핑몰 프로젝트 개발 팀 구성:\nA: 팀장 (리뷰 총괄) B, C: 백엔드 개발 D, E: 프론트엔드 개발 전략: GitHub Flow 채택\n1단계: 프로젝트 초기 설정 # 팀장 A가 레포지토리 생성 및 초기 세팅 git init git add . git commit -m \u0026#34;Initial project setup\u0026#34; git branch -M main git remote add origin https://github.com/team/shopping-mall.git git push -u origin main 2단계: 팀원들이 작업 시작 백엔드 개발자 B - 상품 API 개발:\n# 1. 레포지토리 클론 git clone https://github.com/team/shopping-mall.git cd shopping-mall # 2. 브랜치 생성 git switch -c feature/product-api # 3. 작업 + 커밋 git add src/api/product.js git commit -m \u0026#34;feat: Add product CRUD API\u0026#34; # 4. 원격에 푸시 git push origin feature/product-api # 5. GitHub에서 Pull Request 생성 프론트엔드 개발자 D - 상품 목록 UI:\ngit switch -c feature/product-list-ui git add src/components/ProductList.jsx git commit -m \u0026#34;feat: Add product list component\u0026#34; git push origin feature/product-list-ui # PR 생성 3단계: 코드 리뷰 및 병합 팀장 A가 PR 리뷰:\nReview Comment: \u0026#34;ProductList 컴포넌트에서 API 호출 시 에러 핸들링이 없는데, try-catch로 감싸주시겠어요?\u0026#34; 개발자 D가 수정:\n# 같은 브랜치에서 수정 작업 git add src/components/ProductList.jsx git commit -m \u0026#34;fix: Add error handling for API calls\u0026#34; git push origin feature/product-list-ui # PR에 자동으로 반영됨 팀장 A가 승인 및 병합:\nGitHub에서 \u0026quot;Merge pull request\u0026quot; 클릭 Squash and merge 선택 (여러 커밋을 하나로 합침) 4단계: 동기화 및 다음 작업 모든 팀원이 최신 main 가져오기:\ngit switch main git pull origin main 개발자 B가 다음 작업 시작:\n# 최신 main에서 새 브랜치 생성 git switch -c feature/order-api # 작업 계속... 5단계: 긴급 버그 발생 프로덕션에서 결제 버그 발견! (개발자 C가 처리)\n# main에서 hotfix 브랜치 생성 git switch main git pull origin main git switch -c hotfix/payment-bug # 수정 git add src/api/payment.js git commit -m \u0026#34;fix: Resolve payment calculation error\u0026#34; # 긴급 PR 생성 및 빠른 리뷰 git push origin hotfix/payment-bug # 병합 후 즉시 배포 6단계: 주간 스프린트 마무리 금요일 오후, 모든 feature 브랜치 병합 완료:\n# 각 팀원이 로컬 정리 git switch main git pull origin main # 병합된 브랜치 삭제 git branch -d feature/product-api git branch -d feature/product-list-ui # 원격 브랜치도 정리 (이미 GitHub에서 삭제됐다면) git fetch --prune 브랜치 네이밍 규칙 (팀 컨벤션) feature/기능명 # feature/login, feature/user-profile bugfix/버그명 # bugfix/cart-total-calculation hotfix/긴급수정명 # hotfix/security-patch refactor/리팩토링명 # refactor/auth-service 커밋 메시지 규칙 feat: 새로운 기능 추가 fix: 버그 수정 docs: 문서 수정 style: 코드 포맷팅 (기능 변경 없음) refactor: 코드 리팩토링 test: 테스트 추가 chore: 빌드 설정, 패키지 업데이트 등 예시:\ngit commit -m \u0026#34;feat: Add user authentication API\u0026#34; git commit -m \u0026#34;fix: Resolve CORS issue in payment endpoint\u0026#34; git commit -m \u0026#34;refactor: Extract validation logic to util\u0026#34; 마무리 브랜치 전략 선택 가이드 프로젝트 특성 추천 전략 스타트업 웹 서비스 GitHub Flow 대규모 엔터프라이즈 소프트웨어 Git Flow CI/CD가 잘 갖춰진 프로젝트 Trunk-Based Development 오픈소스 프로젝트 GitHub Flow + Fork \u0026amp; PR 레거시 시스템 유지보수 Git Flow 체크리스트 브랜치 관리:\n브랜치 이름은 의미 있게 짓기 작업 완료 후 브랜치 삭제 main 브랜치는 항상 안정적인 상태 유지 푸시된 커밋은 rebase 하지 않기 협업:\nPR에 명확한 설명 작성 코드 리뷰는 건설적으로 충돌은 신중하게 해결 팀 컨벤션 준수 습관:\n자주 커밋, 자주 푸시 main에서 브랜치 생성 전 항상 git pull 작은 단위로 PR 올리기 테스트는 필수 Git 브랜치 전략은 팀의 규모, 프로젝트 특성, 배포 주기에 따라 달라집니다. 무조건 복잡한 전략이 좋은 것이 아니라, 팀에 맞는 전략을 선택하고 일관되게 적용하는 것이 중요합니다. 작게 시작해서 필요에 따라 확장하세요!\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/git-branch-strategy/","summary":"\u003ch2 id=\"1-브랜치란\"\u003e1. 브랜치란?\u003c/h2\u003e\n\u003ch3 id=\"브랜치의-개념\"\u003e브랜치의 개념\u003c/h3\u003e\n\u003cp\u003e브랜치(Branch)는 독립적인 작업 공간을 만들어주는 Git의 핵심 기능입니다. 마치 평행세계처럼 원본 코드에 영향을 주지 않고 새로운 기능을 개발할 수 있습니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 현재 브랜치 확인\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit branch\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:#6c7086;font-style:italic\"\u003e# 모든 브랜치 확인 (원격 포함)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit branch -a\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"head-포인터\"\u003eHEAD 포인터\u003c/h3\u003e\n\u003cp\u003eHEAD는 현재 작업 중인 브랜치를 가리키는 포인터입니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# HEAD가 가리키는 위치 확인\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecat .git/HEAD\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 출력: ref: refs/heads/main\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:#6c7086;font-style:italic\"\u003e# 특정 커밋으로 HEAD 이동 (detached HEAD 상태)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit checkout \u0026lt;commit-hash\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e브랜치 구조 시각화:\u003c/strong\u003e\u003c/p\u003e","tags":["Git","브랜치전략","협업","GitHub"],"title":"Git 브랜치 전략 - Git Flow, GitHub Flow, Rebase, Cherry-pick"},{"content":"들어가며 현대 소프트웨어 시스템에서 데이터 보안은 선택이 아닌 필수입니다. 사용자 비밀번호, 개인정보, 금융 데이터 등 민감한 정보를 안전하게 보호하려면 암호화 기술을 올바르게 이해하고 적용해야 합니다.\n이 글에서는 대칭키 암호화(AES), 비대칭키 암호화(RSA), 디지털 서명의 원리와 Java 구현을 실전 예제와 함께 다룹니다.\n암호화가 필요한 이유 보호해야 할 데이터 저장 데이터(Data at Rest): 데이터베이스의 비밀번호, 개인정보 전송 데이터(Data in Transit): HTTPS 통신, API 요청/응답 처리 데이터(Data in Use): 메모리 상의 민감 정보 암호화 없이 발생하는 문제 // 위험한 예: 평문 저장 String password = \u0026#34;user1234\u0026#34;; db.save(\u0026#34;INSERT INTO users (password) VALUES (\u0026#39;\u0026#34; + password + \u0026#34;\u0026#39;)\u0026#34;); // DB 유출 시 모든 비밀번호 노출 // 위험한 예: 평문 전송 HttpClient.get(\u0026#34;http://api.example.com/user?apiKey=secret123\u0026#34;); // 중간자 공격(MITM)으로 API 키 탈취 가능 암호화 적용 후 // 안전한 예: 암호화 저장 String encrypted = AESUtil.encrypt(password, secretKey); db.save(\u0026#34;INSERT INTO users (password) VALUES (\u0026#39;\u0026#34; + encrypted + \u0026#34;\u0026#39;)\u0026#34;); // DB 유출되어도 암호화된 상태 // 안전한 예: HTTPS 사용 HttpClient.get(\u0026#34;https://api.example.com/user?apiKey=secret123\u0026#34;); // TLS/SSL로 암호화된 통신 대칭키 암호화 (AES) 같은 키로 암호화와 복호화를 수행하는 방식입니다. 빠르고 효율적이지만 키 공유 문제가 있습니다.\nAES-256 GCM 모드 AES (Advanced Encryption Standard):\n미국 표준 암호화 알고리즘 키 길이: 128, 192, 256비트 (256비트 권장) GCM (Galois/Counter Mode):\n인증 암호화(Authenticated Encryption) 지원 암호화 + 무결성 검증을 동시에 제공 병렬 처리 가능하여 성능 우수 Java에서 AES 암호화/복호화 package com.example.security; import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; public class AESUtil { private static final String ALGORITHM = \u0026#34;AES/GCM/NoPadding\u0026#34;; private static final int GCM_IV_LENGTH = 12; // 96비트 private static final int GCM_TAG_LENGTH = 128; // 128비트 /** * AES-256 비밀키 생성 */ public static SecretKey generateKey() throws Exception { KeyGenerator keyGen = KeyGenerator.getInstance(\u0026#34;AES\u0026#34;); keyGen.init(256, new SecureRandom()); return keyGen.generateKey(); } /** * Base64 문자열을 SecretKey로 변환 */ public static SecretKey keyFromString(String keyString) { byte[] decodedKey = Base64.getDecoder().decode(keyString); return new SecretKeySpec(decodedKey, \u0026#34;AES\u0026#34;); } /** * SecretKey를 Base64 문자열로 변환 */ public static String keyToString(SecretKey key) { return Base64.getEncoder().encodeToString(key.getEncoded()); } /** * 암호화 * @return Base64 인코딩된 \u0026#34;IV + 암호문\u0026#34; 문자열 */ public static String encrypt(String plaintext, SecretKey key) throws Exception { // IV(Initialization Vector) 생성 byte[] iv = new byte[GCM_IV_LENGTH]; new SecureRandom().nextBytes(iv); // 암호화 Cipher cipher = Cipher.getInstance(ALGORITHM); GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); cipher.init(Cipher.ENCRYPT_MODE, key, parameterSpec); byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); // IV + 암호문 결합 ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + ciphertext.length); byteBuffer.put(iv); byteBuffer.put(ciphertext); return Base64.getEncoder().encodeToString(byteBuffer.array()); } /** * 복호화 * @param ciphertext Base64 인코딩된 \u0026#34;IV + 암호문\u0026#34; 문자열 */ public static String decrypt(String ciphertext, SecretKey key) throws Exception { byte[] decoded = Base64.getDecoder().decode(ciphertext); // IV와 암호문 분리 ByteBuffer byteBuffer = ByteBuffer.wrap(decoded); byte[] iv = new byte[GCM_IV_LENGTH]; byteBuffer.get(iv); byte[] encrypted = new byte[byteBuffer.remaining()]; byteBuffer.get(encrypted); // 복호화 Cipher cipher = Cipher.getInstance(ALGORITHM); GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec); byte[] plaintext = cipher.doFinal(encrypted); return new String(plaintext, StandardCharsets.UTF_8); } } 사용 예제 public class AESExample { public static void main(String[] args) throws Exception { // 1. 비밀키 생성 SecretKey secretKey = AESUtil.generateKey(); System.out.println(\u0026#34;Generated Key: \u0026#34; + AESUtil.keyToString(secretKey)); // 2. 암호화 String plaintext = \u0026#34;Hello, AES-256-GCM!\u0026#34;; String encrypted = AESUtil.encrypt(plaintext, secretKey); System.out.println(\u0026#34;Encrypted: \u0026#34; + encrypted); // 3. 복호화 String decrypted = AESUtil.decrypt(encrypted, secretKey); System.out.println(\u0026#34;Decrypted: \u0026#34; + decrypted); // 출력: // Generated Key: 5K9j2F8mL3pQ7sT1vW6xZ0bC4eG8hI2k... // Encrypted: xJ2mP5sV8zC1fH4kL7nQ0rT3uW6yB9eD... // Decrypted: Hello, AES-256-GCM! } } SecretKey 생성과 관리 // 방법 1: 랜덤 생성 (최초 1회) SecretKey key = AESUtil.generateKey(); String keyString = AESUtil.keyToString(key); // DB 또는 환경 변수에 저장: \u0026#34;5K9j2F8mL3pQ7sT1vW6xZ0bC4eG8hI2k...\u0026#34; // 방법 2: 저장된 키 로드 String savedKey = System.getenv(\u0026#34;AES_SECRET_KEY\u0026#34;); SecretKey key = AESUtil.keyFromString(savedKey); // 방법 3: 비밀번호 기반 키 생성 (PBKDF2) public static SecretKey keyFromPassword(String password, byte[] salt) throws Exception { SecretKeyFactory factory = SecretKeyFactory.getInstance(\u0026#34;PBKDF2WithHmacSHA256\u0026#34;); KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 65536, 256); SecretKey tmp = factory.generateSecret(spec); return new SecretKeySpec(tmp.getEncoded(), \u0026#34;AES\u0026#34;); } 비대칭키 암호화 (RSA) 공개키와 개인키 쌍을 사용하는 방식입니다. 공개키로 암호화하면 개인키로만 복호화 가능합니다.\n공개키/개인키 원리 [발신자] [수신자] 평문 ──\u0026gt; 수신자 공개키로 암호화 ──\u0026gt; 암호문 ──\u0026gt; 개인키로 복호화 ──\u0026gt; 평문 특징:\n키 배포 문제 해결 (공개키는 공개, 개인키는 비밀) 암호화 속도가 느림 (대용량 데이터에 부적합) 주로 키 교환 또는 소량 데이터 암호화에 사용 RSA-2048 키페어 생성 package com.example.security; import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; public class RSAUtil { private static final String ALGORITHM = \u0026#34;RSA\u0026#34;; private static final int KEY_SIZE = 2048; /** * RSA 키페어 생성 */ public static KeyPair generateKeyPair() throws Exception { KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM); keyGen.initialize(KEY_SIZE, new SecureRandom()); return keyGen.generateKeyPair(); } /** * 공개키를 Base64 문자열로 변환 */ public static String publicKeyToString(PublicKey publicKey) { return Base64.getEncoder().encodeToString(publicKey.getEncoded()); } /** * 개인키를 Base64 문자열로 변환 */ public static String privateKeyToString(PrivateKey privateKey) { return Base64.getEncoder().encodeToString(privateKey.getEncoded()); } /** * Base64 문자열을 공개키로 변환 */ public static PublicKey publicKeyFromString(String keyString) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(keyString); X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM); return keyFactory.generatePublic(spec); } /** * Base64 문자열을 개인키로 변환 */ public static PrivateKey privateKeyFromString(String keyString) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(keyString); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM); return keyFactory.generatePrivate(spec); } } Java에서 RSA 암호화/복호화 package com.example.security; import javax.crypto.Cipher; import java.nio.charset.StandardCharsets; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Base64; public class RSAEncryption { private static final String TRANSFORMATION = \u0026#34;RSA/ECB/OAEPWITHSHA-256ANDMGF1PADDING\u0026#34;; /** * 공개키로 암호화 */ public static String encrypt(String plaintext, PublicKey publicKey) throws Exception { Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } /** * 개인키로 복호화 */ public static String decrypt(String ciphertext, PrivateKey privateKey) throws Exception { Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decoded = Base64.getDecoder().decode(ciphertext); byte[] decrypted = cipher.doFinal(decoded); return new String(decrypted, StandardCharsets.UTF_8); } } 사용 예제 public class RSAExample { public static void main(String[] args) throws Exception { // 1. 키페어 생성 KeyPair keyPair = RSAUtil.generateKeyPair(); PublicKey publicKey = keyPair.getPublic(); PrivateKey privateKey = keyPair.getPrivate(); System.out.println(\u0026#34;Public Key: \u0026#34; + RSAUtil.publicKeyToString(publicKey)); System.out.println(\u0026#34;Private Key: \u0026#34; + RSAUtil.privateKeyToString(privateKey)); // 2. 공개키로 암호화 String plaintext = \u0026#34;Hello, RSA!\u0026#34;; String encrypted = RSAEncryption.encrypt(plaintext, publicKey); System.out.println(\u0026#34;Encrypted: \u0026#34; + encrypted); // 3. 개인키로 복호화 String decrypted = RSAEncryption.decrypt(encrypted, privateKey); System.out.println(\u0026#34;Decrypted: \u0026#34; + decrypted); // 출력: // Public Key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... // Private Key: MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC... // Encrypted: gK3mP7sW9zD2fI5kM8nR1rU4uX7yC0eF... // Decrypted: Hello, RSA! } } PKCS8 키 포맷 RSA 개인키는 PKCS#8 형식으로 저장됩니다.\n-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC... (Base64 인코딩된 키) ... -----END PRIVATE KEY----- 공개키는 X.509 형식입니다.\n-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... (Base64 인코딩된 키) ... -----END PUBLIC KEY----- 디지털 서명 메시지의 무결성과 송신자의 신원을 보장하는 기술입니다.\n서명 원리 [서명 생성] 원본 데이터 ──\u0026gt; SHA-256 해시 ──\u0026gt; 개인키로 서명 ──\u0026gt; 서명값 [서명 검증] 원본 데이터 ──\u0026gt; SHA-256 해시 ──┐ 서명값 ──\u0026gt; 공개키로 복호화 ──────┴──\u0026gt; 비교 (일치하면 검증 성공) 암호화와의 차이:\n암호화: 데이터 기밀성 (공개키로 암호화 → 개인키로 복호화) 서명: 데이터 무결성 + 인증 (개인키로 서명 → 공개키로 검증) SHA256withRSA 서명/검증 package com.example.security; import java.nio.charset.StandardCharsets; import java.security.PrivateKey; import java.security.PublicKey; import java.security.Signature; import java.util.Base64; public class DigitalSignature { private static final String ALGORITHM = \u0026#34;SHA256withRSA\u0026#34;; /** * 개인키로 서명 생성 */ public static String sign(String data, PrivateKey privateKey) throws Exception { Signature signature = Signature.getInstance(ALGORITHM); signature.initSign(privateKey); signature.update(data.getBytes(StandardCharsets.UTF_8)); byte[] signatureBytes = signature.sign(); return Base64.getEncoder().encodeToString(signatureBytes); } /** * 공개키로 서명 검증 */ public static boolean verify(String data, String signatureStr, PublicKey publicKey) throws Exception { Signature signature = Signature.getInstance(ALGORITHM); signature.initVerify(publicKey); signature.update(data.getBytes(StandardCharsets.UTF_8)); byte[] signatureBytes = Base64.getDecoder().decode(signatureStr); return signature.verify(signatureBytes); } } 사용 예제 public class SignatureExample { public static void main(String[] args) throws Exception { // 1. 키페어 생성 KeyPair keyPair = RSAUtil.generateKeyPair(); PrivateKey privateKey = keyPair.getPrivate(); PublicKey publicKey = keyPair.getPublic(); // 2. 서명 생성 String data = \u0026#34;Important message\u0026#34;; String signature = DigitalSignature.sign(data, privateKey); System.out.println(\u0026#34;Signature: \u0026#34; + signature); // 3. 서명 검증 boolean isValid = DigitalSignature.verify(data, signature, publicKey); System.out.println(\u0026#34;Valid: \u0026#34; + isValid); // true // 4. 데이터 위변조 시도 String tamperedData = \u0026#34;Important message!\u0026#34;; boolean isTamperedValid = DigitalSignature.verify(tamperedData, signature, publicKey); System.out.println(\u0026#34;Tampered Valid: \u0026#34; + isTamperedValid); // false // 출력: // Signature: gK3mP7sW9zD2fI5kM8nR1rU4uX7yC0eF... // Valid: true // Tampered Valid: false } } 서명 vs 암호화 차이 구분 암호화 서명 목적 데이터 기밀성 데이터 무결성 + 인증 사용 키 공개키로 암호화 개인키로 서명 복호화/검증 개인키로 복호화 공개키로 검증 데이터 크기 원본과 같거나 큼 고정 크기 (256바이트 등) Base64 인코딩 바이너리 데이터를 텍스트로 변환하는 인코딩 방식입니다.\nURL-safe Base64 public class Base64Example { public static void main(String[] args) { String data = \u0026#34;Hello, World!\u0026#34;; byte[] bytes = data.getBytes(StandardCharsets.UTF_8); // 표준 Base64 String standard = Base64.getEncoder().encodeToString(bytes); System.out.println(\u0026#34;Standard: \u0026#34; + standard); // SGVsbG8sIFdvcmxkIQ== // URL-safe Base64 (+ → -, / → _, padding 유지) String urlSafe = Base64.getUrlEncoder().encodeToString(bytes); System.out.println(\u0026#34;URL-safe: \u0026#34; + urlSafe); // SGVsbG8sIFdvcmxkIQ== // Padding 없는 URL-safe Base64 String noPadding = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); System.out.println(\u0026#34;No padding: \u0026#34; + noPadding); // SGVsbG8sIFdvcmxkIQ } } 사용 사례:\nJWT 토큰 (Header.Payload.Signature) URL 쿼리 파라미터 파일 첨부 (이메일, JSON) 실전 예시 1: 패스워드 저장 (올바른 방법) ⚠️ 중요: 패스워드는 절대 AES 같은 양방향 암호화로 저장하면 안 됩니다. 복호화가 가능하기 때문에 DB 유출 시 모든 패스워드가 노출됩니다.\n올바른 방법: BCrypt 단방향 해시 package com.example.service; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class PasswordService { private final PasswordEncoder encoder = new BCryptPasswordEncoder(); /** * 패스워드 해시화 (단방향) */ public String hashPassword(String plainPassword) { return encoder.encode(plainPassword); } /** * 패스워드 검증 */ public boolean verifyPassword(String plainPassword, String hashedPassword) { return encoder.matches(plainPassword, hashedPassword); } } UserService @Service public class UserService { @Autowired private PasswordService passwordService; @Autowired private UserRepository userRepository; /** * 사용자 등록 */ public void registerUser(String username, String password) { String hashedPassword = passwordService.hashPassword(password); User user = new User(); user.setUsername(username); user.setPassword(hashedPassword); userRepository.save(user); } /** * 로그인 */ public boolean login(String username, String password) { User user = userRepository.findByUsername(username); if (user == null) return false; return passwordService.verifyPassword(password, user.getPassword()); } } 왜 BCrypt를 사용하는가?\n단방향 해시: 해시값에서 원본 패스워드를 복원할 수 없음 Salt 자동 생성: 같은 패스워드도 다른 해시값 생성 (레인보우 테이블 공격 방어) 느린 연산: 브루트포스 공격을 어렵게 만듦 검증된 알고리즘: 보안 업계 표준 대안:\nArgon2: 2015년 Password Hashing Competition 우승, 가장 안전 scrypt: 메모리 집약적 설계로 GPU 공격 방어 PBKDF2: NIST 표준, 레거시 시스템과 호환성 좋음 실전 예시 2: 라이선스 키 생성 소프트웨어 라이선스 키를 RSA 서명으로 생성하고 검증합니다.\nLicenseGenerator package com.example.license; import com.example.security.DigitalSignature; import com.example.security.RSAUtil; import com.fasterxml.jackson.databind.ObjectMapper; import java.security.KeyPair; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Base64; import java.util.Map; public class LicenseGenerator { private final PrivateKey privateKey; private final ObjectMapper objectMapper = new ObjectMapper(); public LicenseGenerator(PrivateKey privateKey) { this.privateKey = privateKey; } /** * 라이선스 키 생성 * 형식: Base64(JSON Payload) + \u0026#34;.\u0026#34; + Base64(Signature) */ public String generateLicense(String productId, String customerId, long expiresAt) throws Exception { // 1. Payload 생성 (JSON) Map\u0026lt;String, Object\u0026gt; payload = Map.of( \u0026#34;productId\u0026#34;, productId, \u0026#34;customerId\u0026#34;, customerId, \u0026#34;issuedAt\u0026#34;, System.currentTimeMillis(), \u0026#34;expiresAt\u0026#34;, expiresAt ); String jsonPayload = objectMapper.writeValueAsString(payload); String encodedPayload = Base64.getUrlEncoder().withoutPadding() .encodeToString(jsonPayload.getBytes()); // 2. 서명 생성 String signature = DigitalSignature.sign(jsonPayload, privateKey); String encodedSignature = Base64.getUrlEncoder().withoutPadding() .encodeToString(signature.getBytes()); // 3. Payload + Signature 결합 return encodedPayload + \u0026#34;.\u0026#34; + encodedSignature; } } LicenseValidator package com.example.license; import com.example.security.DigitalSignature; import com.fasterxml.jackson.databind.ObjectMapper; import java.security.PublicKey; import java.util.Base64; import java.util.Map; public class LicenseValidator { private final PublicKey publicKey; private final ObjectMapper objectMapper = new ObjectMapper(); public LicenseValidator(PublicKey publicKey) { this.publicKey = publicKey; } /** * 라이선스 키 검증 */ public boolean validateLicense(String licenseKey) { try { String[] parts = licenseKey.split(\u0026#34;\\\\.\u0026#34;); if (parts.length != 2) return false; // 1. Payload와 Signature 분리 String encodedPayload = parts[0]; String encodedSignature = parts[1]; byte[] payloadBytes = Base64.getUrlDecoder().decode(encodedPayload); String jsonPayload = new String(payloadBytes); String signature = new String(Base64.getUrlDecoder().decode(encodedSignature)); // 2. 서명 검증 if (!DigitalSignature.verify(jsonPayload, signature, publicKey)) { return false; } // 3. 만료 시간 확인 Map\u0026lt;String, Object\u0026gt; payload = objectMapper.readValue(jsonPayload, Map.class); long expiresAt = ((Number) payload.get(\u0026#34;expiresAt\u0026#34;)).longValue(); return System.currentTimeMillis() \u0026lt; expiresAt; } catch (Exception e) { return false; } } /** * 라이선스 정보 추출 */ public Map\u0026lt;String, Object\u0026gt; extractLicenseInfo(String licenseKey) throws Exception { String[] parts = licenseKey.split(\u0026#34;\\\\.\u0026#34;); byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[0]); String jsonPayload = new String(payloadBytes); return objectMapper.readValue(jsonPayload, Map.class); } } 사용 예제 public class LicenseExample { public static void main(String[] args) throws Exception { // 1. 키페어 생성 (서버) KeyPair keyPair = RSAUtil.generateKeyPair(); PrivateKey privateKey = keyPair.getPrivate(); PublicKey publicKey = keyPair.getPublic(); // 공개키는 클라이언트 애플리케이션에 배포 System.out.println(\u0026#34;Public Key (embed in client):\u0026#34;); System.out.println(RSAUtil.publicKeyToString(publicKey)); // 2. 라이선스 키 생성 (서버) LicenseGenerator generator = new LicenseGenerator(privateKey); long expiresAt = System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000; // 1년 후 String licenseKey = generator.generateLicense(\u0026#34;PROD-001\u0026#34;, \u0026#34;CUSTOMER-123\u0026#34;, expiresAt); System.out.println(\u0026#34;\\nGenerated License Key:\u0026#34;); System.out.println(licenseKey); // 3. 라이선스 키 검증 (클라이언트) LicenseValidator validator = new LicenseValidator(publicKey); boolean isValid = validator.validateLicense(licenseKey); System.out.println(\u0026#34;\\nLicense Valid: \u0026#34; + isValid); // 4. 라이선스 정보 추출 Map\u0026lt;String, Object\u0026gt; info = validator.extractLicenseInfo(licenseKey); System.out.println(\u0026#34;License Info: \u0026#34; + info); // 5. 위변조 시도 String tamperedKey = licenseKey.replace(\u0026#34;PROD-001\u0026#34;, \u0026#34;PROD-999\u0026#34;); boolean isTamperedValid = validator.validateLicense(tamperedKey); System.out.println(\u0026#34;\\nTampered License Valid: \u0026#34; + isTamperedValid); // false // 출력: // Public Key (embed in client): // MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... // // Generated License Key: // eyJwcm9kdWN0SWQiOiJQUk9ELTAwMSIsImN1c3RvbWVySWQiOiJDVVNUT01FUi0xMjMiLCJpc3N1ZWRBdCI6MTcwODA1NjAwMCwiZXhwaXJlc0F0IjoxNzM5NTkyMDAwfQ.gK3mP7sW9zD2fI5kM8nR1rU4uX7yC0eF... // // License Valid: true // License Info: {productId=PROD-001, customerId=CUSTOMER-123, issuedAt=1708056000, expiresAt=1739592000} // // Tampered License Valid: false } } 라이선스 키 구조 [Payload: Base64 URL-safe].[Signature: Base64 URL-safe] Payload (JSON): { \u0026#34;productId\u0026#34;: \u0026#34;PROD-001\u0026#34;, \u0026#34;customerId\u0026#34;: \u0026#34;CUSTOMER-123\u0026#34;, \u0026#34;issuedAt\u0026#34;: 1708056000, \u0026#34;expiresAt\u0026#34;: 1739592000 } Signature: RSA 개인키로 Payload를 서명한 값 보안 특징:\n공개키만으로는 라이선스 키를 생성할 수 없음 (개인키 필요) Payload 위변조 시 서명 검증 실패 만료 시간 포함으로 시간 제한 가능 하이브리드 암호화 (RSA + AES) RSA는 느리고 큰 데이터를 암호화할 수 없으므로, AES와 조합하여 사용합니다.\n동작 원리 1. 송신자가 랜덤 AES 키 생성 2. AES 키로 대용량 데이터 암호화 (빠름) 3. RSA 공개키로 AES 키 암호화 (작은 데이터) 4. 암호화된 데이터 + 암호화된 AES 키 전송 5. 수신자가 RSA 개인키로 AES 키 복호화 6. 복호화된 AES 키로 데이터 복호화 구현 package com.example.security; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Base64; public class HybridEncryption { /** * 하이브리드 암호화 * @return \u0026#34;암호화된 데이터,암호화된 AES 키\u0026#34; */ public static String encrypt(String plaintext, PublicKey publicKey) throws Exception { // 1. 랜덤 AES 키 생성 SecretKey aesKey = AESUtil.generateKey(); // 2. AES로 데이터 암호화 String encryptedData = AESUtil.encrypt(plaintext, aesKey); // 3. RSA로 AES 키 암호화 String aesKeyString = AESUtil.keyToString(aesKey); String encryptedKey = RSAEncryption.encrypt(aesKeyString, publicKey); // 4. 결합 return encryptedData + \u0026#34;,\u0026#34; + encryptedKey; } /** * 하이브리드 복호화 */ public static String decrypt(String ciphertext, PrivateKey privateKey) throws Exception { String[] parts = ciphertext.split(\u0026#34;,\u0026#34;); String encryptedData = parts[0]; String encryptedKey = parts[1]; // 1. RSA로 AES 키 복호화 String aesKeyString = RSAEncryption.decrypt(encryptedKey, privateKey); SecretKey aesKey = AESUtil.keyFromString(aesKeyString); // 2. AES로 데이터 복호화 return AESUtil.decrypt(encryptedData, aesKey); } } 사용 예제 public class HybridExample { public static void main(String[] args) throws Exception { KeyPair keyPair = RSAUtil.generateKeyPair(); PublicKey publicKey = keyPair.getPublic(); PrivateKey privateKey = keyPair.getPrivate(); // 대용량 데이터 String largeData = \u0026#34;This is a very large document...\u0026#34;.repeat(1000); // 암호화 String encrypted = HybridEncryption.encrypt(largeData, publicKey); System.out.println(\u0026#34;Encrypted (first 100 chars): \u0026#34; + encrypted.substring(0, 100)); // 복호화 String decrypted = HybridEncryption.decrypt(encrypted, privateKey); System.out.println(\u0026#34;Decrypted matches: \u0026#34; + largeData.equals(decrypted)); // true } } 장점:\nRSA의 키 배포 용이성 + AES의 빠른 속도 HTTPS/TLS가 이 방식을 사용 마무리 암호화는 현대 소프트웨어 보안의 핵심입니다. 대칭키(AES)는 빠르지만 키 공유 문제가 있고, 비대칭키(RSA)는 키 배포가 쉽지만 느립니다. 디지털 서명은 무결성과 인증을 보장합니다.\n핵심 요약:\nAES-256 GCM: 빠른 대칭키 암호화, 비밀키 관리 필수 RSA-2048: 공개키/개인키 쌍, 소량 데이터 또는 키 교환 디지털 서명: 개인키로 서명 → 공개키로 검증 (무결성 + 인증) 하이브리드 암호화: RSA + AES 조합으로 장점 결합 실전 적용: 패스워드는 BCrypt, 라이선스는 RSA 서명, 대용량은 하이브리드 다음 글에서는 OAuth2와 소셜 로그인 구현을 다루며 Google, GitHub 인증 연동을 알아보겠습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/security-encryption-basics/","summary":"\u003ch2 id=\"들어가며\"\u003e들어가며\u003c/h2\u003e\n\u003cp\u003e현대 소프트웨어 시스템에서 데이터 보안은 선택이 아닌 필수입니다. 사용자 비밀번호, 개인정보, 금융 데이터 등 민감한 정보를 안전하게 보호하려면 암호화 기술을 올바르게 이해하고 적용해야 합니다.\u003c/p\u003e\n\u003cp\u003e이 글에서는 대칭키 암호화(AES), 비대칭키 암호화(RSA), 디지털 서명의 원리와 Java 구현을 실전 예제와 함께 다룹니다.\u003c/p\u003e\n\u003ch2 id=\"암호화가-필요한-이유\"\u003e암호화가 필요한 이유\u003c/h2\u003e\n\u003ch3 id=\"보호해야-할-데이터\"\u003e보호해야 할 데이터\u003c/h3\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e저장 데이터(Data at Rest)\u003c/strong\u003e: 데이터베이스의 비밀번호, 개인정보\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e전송 데이터(Data in Transit)\u003c/strong\u003e: HTTPS 통신, API 요청/응답\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e처리 데이터(Data in Use)\u003c/strong\u003e: 메모리 상의 민감 정보\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"암호화-없이-발생하는-문제\"\u003e암호화 없이 발생하는 문제\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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:#6c7086;font-style:italic\"\u003e// 위험한 예: 평문 저장\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eString password \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;user1234\u0026#34;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edb.\u003cspan style=\"color:#89b4fa\"\u003esave\u003c/span\u003e(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;INSERT INTO users (password) VALUES (\u0026#39;\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e password \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;\u0026#39;)\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// DB 유출 시 모든 비밀번호 노출\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:#6c7086;font-style:italic\"\u003e// 위험한 예: 평문 전송\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eHttpClient.\u003cspan style=\"color:#89b4fa\"\u003eget\u003c/span\u003e(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;http://api.example.com/user?apiKey=secret123\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 중간자 공격(MITM)으로 API 키 탈취 가능\u003c/span\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:#cdd6f4;background-color:#1e1e2e;-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:#6c7086;font-style:italic\"\u003e// 안전한 예: 암호화 저장\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eString encrypted \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e AESUtil.\u003cspan style=\"color:#89b4fa\"\u003eencrypt\u003c/span\u003e(password, secretKey);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edb.\u003cspan style=\"color:#89b4fa\"\u003esave\u003c/span\u003e(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;INSERT INTO users (password) VALUES (\u0026#39;\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e encrypted \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;\u0026#39;)\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// DB 유출되어도 암호화된 상태\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:#6c7086;font-style:italic\"\u003e// 안전한 예: HTTPS 사용\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eHttpClient.\u003cspan style=\"color:#89b4fa\"\u003eget\u003c/span\u003e(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;https://api.example.com/user?apiKey=secret123\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// TLS/SSL로 암호화된 통신\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"대칭키-암호화-aes\"\u003e대칭키 암호화 (AES)\u003c/h2\u003e\n\u003cp\u003e같은 키로 암호화와 복호화를 수행하는 방식입니다. 빠르고 효율적이지만 키 공유 문제가 있습니다.\u003c/p\u003e","tags":["보안","암호화","RSA","AES"],"title":"암호화 기초 - AES, RSA, 디지털 서명, 패스워드 해싱, 하이브리드 암호화"},{"content":"Git이란? Git은 분산 버전 관리 시스템(Distributed Version Control System)으로, 파일의 변경 이력을 추적하고 여러 개발자가 협업할 수 있도록 도와줍니다.\nSVN과의 주요 차이점 특징 Git (분산) SVN (중앙집중) 저장소 위치 로컬에 전체 이력 저장 중앙 서버에만 이력 저장 오프라인 작업 가능 (커밋, 브랜치 등) 제한적 속도 빠름 (로컬 작업) 느림 (서버 통신 필요) 브랜치 생성 빠르고 가벼움 무겁고 비용이 큼 Git 핵심 개념: 3가지 영역 Git은 파일을 3가지 상태로 관리합니다.\nWorking Directory → Staging Area → Repository (작업 디렉토리) (스테이징 영역) (저장소) ↓ ↓ ↓ git add git commit git push Working Directory: 실제 파일을 수정하는 공간 Staging Area (Index): 커밋할 변경사항을 준비하는 공간 Repository: 커밋된 스냅샷이 저장되는 공간 기본 명령어 저장소 초기화 및 복제 # 새 Git 저장소 생성 git init # 원격 저장소 복제 git clone https://github.com/username/repository.git # 특정 브랜치만 복제 git clone -b develop https://github.com/username/repository.git 변경사항 추적 및 커밋 # 파일 상태 확인 git status # 파일을 Staging Area에 추가 git add file.txt # 특정 파일 git add . # 현재 디렉토리 모든 변경사항 git add -p # 대화형 모드 (부분 커밋 가능) # 커밋 생성 git commit -m \u0026#34;커밋 메시지\u0026#34; # add + commit 한 번에 (추적 중인 파일만) git commit -am \u0026#34;커밋 메시지\u0026#34; # 커밋 메시지 수정 git commit --amend 변경사항 확인 # Working Directory vs Staging Area git diff # Staging Area vs Repository git diff --staged # 특정 파일의 변경사항 git diff file.txt # 두 커밋 간 차이 git diff commit1 commit2 커밋 히스토리 관리 로그 확인 # 기본 로그 git log # 한 줄로 보기 git log --oneline # 그래프로 보기 git log --graph --oneline --all # 최근 3개 커밋만 git log -3 # 특정 날짜 범위 git log --since=\u0026#34;2 weeks ago\u0026#34; --until=\u0026#34;yesterday\u0026#34; # 특정 작성자 git log --author=\u0026#34;홍길동\u0026#34; # 파일별 로그 git log -- file.txt # 커밋 메시지 검색 git log --grep=\u0026#34;버그 수정\u0026#34; 특정 커밋 상세 보기 # 커밋 상세 내용 git show commit-hash # 특정 파일의 특정 커밋 git show commit-hash:path/to/file.txt # 각 줄을 마지막으로 수정한 사람 확인 git blame file.txt # 특정 줄 범위만 git blame -L 10,20 file.txt 되돌리기 reset vs revert vs checkout # reset: 커밋 히스토리 변경 (로컬 작업에만 사용) git reset --soft HEAD~1 # 커밋만 취소 (Staging Area 유지) git reset --mixed HEAD~1 # 커밋 + add 취소 (기본값) git reset --hard HEAD~1 # 모든 변경사항 삭제 (위험!) # revert: 새로운 커밋으로 되돌림 (안전, 공유된 브랜치에 사용) git revert commit-hash # checkout: 특정 커밋 상태로 이동 (읽기 전용) git checkout commit-hash # restore: 파일 되돌리기 (Git 2.23+) git restore file.txt # Working Directory에서 되돌림 git restore --staged file.txt # Staging Area에서 되돌림 reset 옵션 비교 옵션 HEAD 이동 Staging Area Working Directory --soft O 유지 유지 --mixed O 초기화 유지 --hard O 초기화 초기화 실수 복구 # 실수로 reset --hard 했을 때 git reflog # 모든 HEAD 이동 이력 확인 git reset --hard HEAD@{2} # 특정 시점으로 복구 # 삭제된 브랜치 복구 git reflog git checkout -b recovered-branch HEAD@{3} .gitignore 작성법 기본 문법 # 주석 # 특정 파일 secret.txt # 특정 디렉토리 logs/ # 와일드카드 *.log # 모든 .log 파일 *.log.* # .log.로 시작하는 파일 # 예외 처리 *.log !important.log # important.log는 추적 # 특정 디렉토리의 파일만 /config.json # 루트의 config.json만 **/temp/ # 모든 경로의 temp 디렉토리 언어별 예시 Node.js 프로젝트\nnode_modules/ npm-debug.log* .env dist/ build/ .DS_Store Java/Spring Boot 프로젝트\n*.class *.jar *.war target/ .gradle/ build/ .idea/ *.iml Python 프로젝트\n__pycache__/ *.py[cod] *$py.class venv/ .env .pytest_cache/ *.egg-info/ 이미 추적 중인 파일 제외하기 # .gitignore에 추가한 후 git rm --cached file.txt git rm -r --cached directory/ # 커밋 git commit -m \u0026#34;gitignore 적용\u0026#34; 원격 저장소 원격 저장소 관리 # 원격 저장소 목록 git remote -v # 원격 저장소 추가 git remote add origin https://github.com/username/repo.git # 원격 저장소 URL 변경 git remote set-url origin https://github.com/username/new-repo.git # 원격 저장소 삭제 git remote remove origin push, pull, fetch 차이 # push: 로컬 → 원격 git push origin main # 강제 push (위험! 팀 작업 시 주의) git push --force origin main # fetch: 원격 → 로컬 (병합 안 함) git fetch origin # pull: fetch + merge git pull origin main # = git fetch origin + git merge origin/main # pull with rebase (깔끔한 히스토리) git pull --rebase origin main 원격 브랜치 작업 # 원격 브랜치 목록 git branch -r # 원격 브랜치 추적 git checkout -b local-branch origin/remote-branch # 원격 브랜치 삭제 git push origin --delete feature-branch Stash 활용법 Stash는 작업 중인 변경사항을 임시 저장하는 기능입니다.\n# 현재 변경사항 임시 저장 git stash # 메시지와 함께 저장 git stash save \u0026#34;WIP: 로그인 기능 작업 중\u0026#34; # untracked 파일도 포함 git stash -u # stash 목록 git stash list # stash@{0}: WIP on main: 1a2b3c4 커밋 메시지 # stash@{1}: WIP on develop: 5d6e7f8 이전 작업 # stash 적용 (stash 유지) git stash apply stash@{0} # stash 적용 + 삭제 git stash pop # stash 내용 확인 git stash show -p stash@{0} # 특정 stash 삭제 git stash drop stash@{0} # 모든 stash 삭제 git stash clear Stash 실전 활용 # 시나리오: 급하게 다른 브랜치로 이동해야 할 때 git stash # 현재 작업 임시 저장 git checkout hotfix-branch # 브랜치 이동 # ... hotfix 작업 ... git checkout main # 원래 브랜치로 복귀 git stash pop # 작업 복원 # stash를 새 브랜치로 만들기 git stash branch new-feature-branch stash@{0} 태그 관리 태그는 특정 커밋에 이름을 붙여 버전을 관리합니다.\nLightweight vs Annotated 태그 # Lightweight 태그 (단순 포인터) git tag v1.0.0 # Annotated 태그 (권장: 메타데이터 포함) git tag -a v1.0.0 -m \u0026#34;첫 번째 정식 릴리즈\u0026#34; # 태그 목록 git tag git tag -l \u0026#34;v1.*\u0026#34; # 패턴 검색 # 태그 상세 정보 git show v1.0.0 # 특정 커밋에 태그 git tag -a v0.9.0 commit-hash -m \u0026#34;베타 릴리즈\u0026#34; 태그 원격 저장소 관리 # 특정 태그 push git push origin v1.0.0 # 모든 태그 push git push origin --tags # 태그 삭제 git tag -d v1.0.0 # 로컬 git push origin --delete v1.0.0 # 원격 # 태그로 체크아웃 git checkout v1.0.0 시맨틱 버저닝(Semantic Versioning) # 버전 형식: MAJOR.MINOR.PATCH git tag -a v1.0.0 -m \u0026#34;Major: 첫 정식 릴리즈\u0026#34; git tag -a v1.1.0 -m \u0026#34;Minor: 새 기능 추가\u0026#34; git tag -a v1.1.1 -m \u0026#34;Patch: 버그 수정\u0026#34; # 예시 v1.0.0 # 첫 릴리즈 v1.1.0 # 새 기능 (하위 호환) v1.1.1 # 버그 수정 v2.0.0 # Breaking Changes 실전 팁: 좋은 커밋 메시지 작성법 Conventional Commits 규칙 # 형식 \u0026lt;type\u0026gt;(\u0026lt;scope\u0026gt;): \u0026lt;subject\u0026gt; \u0026lt;body\u0026gt; \u0026lt;footer\u0026gt; 타입 종류\nfeat: 새로운 기능 추가 fix: 버그 수정 docs: 문서 수정 style: 코드 포맷팅 (세미콜론 등) refactor: 코드 리팩토링 test: 테스트 코드 추가 chore: 빌드, 패키지 매니저 수정 좋은 예시 # 간단한 메시지 git commit -m \u0026#34;feat: 사용자 로그인 기능 추가\u0026#34; # 상세한 메시지 git commit -m \u0026#34;fix(auth): JWT 토큰 만료 처리 오류 수정 - 만료된 토큰으로 요청 시 401 응답 반환 - 토큰 갱신 로직 개선 - 관련 테스트 추가 Closes #123\u0026#34; # Breaking Change git commit -m \u0026#34;feat!: API 응답 형식 변경 BREAKING CHANGE: API 응답이 { data, error } 형식으로 변경됨\u0026#34; 나쁜 예시 vs 좋은 예시 # ❌ 나쁜 예시 git commit -m \u0026#34;수정\u0026#34; git commit -m \u0026#34;버그 고침\u0026#34; git commit -m \u0026#34;WIP\u0026#34; # ✅ 좋은 예시 git commit -m \u0026#34;fix(login): 빈 이메일 입력 시 에러 처리 추가\u0026#34; git commit -m \u0026#34;refactor(user): 사용자 검증 로직을 별도 함수로 분리\u0026#34; git commit -m \u0026#34;docs(readme): 설치 가이드 업데이트\u0026#34; 커밋 작성 가이드라인 제목은 50자 이내로 간결하게 제목과 본문은 빈 줄로 구분 제목은 명령형으로 작성 (\u0026quot;추가함\u0026quot; ❌ → \u0026quot;추가\u0026quot; ✅) 본문은 \u0026quot;무엇을, 왜\u0026quot; 중심으로 작성 하나의 커밋은 하나의 논리적 변경만 포함 마무리 Git은 현대 소프트웨어 개발의 필수 도구입니다. 이 글에서 다룬 기본 개념과 명령어를 익히면:\n✅ 코드 변경 이력을 체계적으로 관리 ✅ 실수를 안전하게 되돌리기 ✅ 팀원과 원활한 협업 ✅ 버전 관리를 통한 릴리즈 관리 다음 단계: 브랜치 전략(Git Flow), Merge vs Rebase, Conflict 해결, Git Hooks 등의 고급 주제를 학습하세요.\n참고 자료 Pro Git Book (한글) Conventional Commits Git 공식 문서 ","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/git-basics/","summary":"\u003ch2 id=\"git이란\"\u003eGit이란?\u003c/h2\u003e\n\u003cp\u003eGit은 \u003cstrong\u003e분산 버전 관리 시스템\u003c/strong\u003e(Distributed Version Control System)으로, 파일의 변경 이력을 추적하고 여러 개발자가 협업할 수 있도록 도와줍니다.\u003c/p\u003e\n\u003ch3 id=\"svn과의-주요-차이점\"\u003eSVN과의 주요 차이점\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e특징\u003c/th\u003e\n          \u003cth\u003eGit (분산)\u003c/th\u003e\n          \u003cth\u003eSVN (중앙집중)\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\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          \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          \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          \u003ctd\u003e무겁고 비용이 큼\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch2 id=\"git-핵심-개념-3가지-영역\"\u003eGit 핵심 개념: 3가지 영역\u003c/h2\u003e\n\u003cp\u003eGit은 파일을 3가지 상태로 관리합니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eWorking Directory → Staging Area → Repository\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    git add         git commit      git push\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eWorking Directory\u003c/strong\u003e: 실제 파일을 수정하는 공간\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eStaging Area\u003c/strong\u003e (Index): 커밋할 변경사항을 준비하는 공간\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eRepository\u003c/strong\u003e: 커밋된 스냅샷이 저장되는 공간\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"기본-명령어\"\u003e기본 명령어\u003c/h2\u003e\n\u003ch3 id=\"저장소-초기화-및-복제\"\u003e저장소 초기화 및 복제\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 새 Git 저장소 생성\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit init\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:#6c7086;font-style:italic\"\u003e# 원격 저장소 복제\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit clone https://github.com/username/repository.git\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:#6c7086;font-style:italic\"\u003e# 특정 브랜치만 복제\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit clone -b develop https://github.com/username/repository.git\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:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 파일 상태 확인\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit status\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:#6c7086;font-style:italic\"\u003e# 파일을 Staging Area에 추가\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit add file.txt              \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 특정 파일\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit add .                     \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 현재 디렉토리 모든 변경사항\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit add -p                    \u003cspan style=\"color:#6c7086;font-style:italic\"\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:#6c7086;font-style:italic\"\u003e# 커밋 생성\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit commit -m \u003cspan style=\"color:#a6e3a1\"\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\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# add + commit 한 번에 (추적 중인 파일만)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit commit -am \u003cspan style=\"color:#a6e3a1\"\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\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 커밋 메시지 수정\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit commit --amend\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:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# Working Directory vs Staging Area\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit diff\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:#6c7086;font-style:italic\"\u003e# Staging Area vs Repository\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit diff --staged\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:#6c7086;font-style:italic\"\u003e# 특정 파일의 변경사항\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit diff file.txt\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:#6c7086;font-style:italic\"\u003e# 두 커밋 간 차이\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit diff commit1 commit2\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"커밋-히스토리-관리\"\u003e커밋 히스토리 관리\u003c/h2\u003e\n\u003ch3 id=\"로그-확인\"\u003e로그 확인\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 기본 로그\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit log\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:#6c7086;font-style:italic\"\u003e# 한 줄로 보기\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit log --oneline\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:#6c7086;font-style:italic\"\u003e# 그래프로 보기\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit log --graph --oneline --all\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:#6c7086;font-style:italic\"\u003e# 최근 3개 커밋만\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit log -3\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:#6c7086;font-style:italic\"\u003e# 특정 날짜 범위\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit log --since\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;2 weeks ago\u0026#34;\u003c/span\u003e --until\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;yesterday\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:#6c7086;font-style:italic\"\u003e# 특정 작성자\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit log --author\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#a6e3a1\"\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\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 파일별 로그\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit log -- file.txt\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:#6c7086;font-style:italic\"\u003e# 커밋 메시지 검색\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit log --grep\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;버그 수정\u0026#34;\u003c/span\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:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 커밋 상세 내용\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit show commit-hash\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:#6c7086;font-style:italic\"\u003e# 특정 파일의 특정 커밋\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit show commit-hash:path/to/file.txt\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:#6c7086;font-style:italic\"\u003e# 각 줄을 마지막으로 수정한 사람 확인\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit blame file.txt\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:#6c7086;font-style:italic\"\u003e# 특정 줄 범위만\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit blame -L 10,20 file.txt\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"되돌리기\"\u003e되돌리기\u003c/h2\u003e\n\u003ch3 id=\"reset-vs-revert-vs-checkout\"\u003ereset vs revert vs checkout\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# reset: 커밋 히스토리 변경 (로컬 작업에만 사용)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit reset --soft HEAD~1    \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 커밋만 취소 (Staging Area 유지)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit reset --mixed HEAD~1   \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 커밋 + add 취소 (기본값)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit reset --hard HEAD~1    \u003cspan style=\"color:#6c7086;font-style:italic\"\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:#6c7086;font-style:italic\"\u003e# revert: 새로운 커밋으로 되돌림 (안전, 공유된 브랜치에 사용)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit revert commit-hash\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:#6c7086;font-style:italic\"\u003e# checkout: 특정 커밋 상태로 이동 (읽기 전용)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit checkout commit-hash\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:#6c7086;font-style:italic\"\u003e# restore: 파일 되돌리기 (Git 2.23+)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit restore file.txt              \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# Working Directory에서 되돌림\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit restore --staged file.txt     \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# Staging Area에서 되돌림\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"reset-옵션-비교\"\u003ereset 옵션 비교\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e옵션\u003c/th\u003e\n          \u003cth\u003eHEAD 이동\u003c/th\u003e\n          \u003cth\u003eStaging Area\u003c/th\u003e\n          \u003cth\u003eWorking Directory\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e--soft\u003c/td\u003e\n          \u003ctd\u003eO\u003c/td\u003e\n          \u003ctd\u003e유지\u003c/td\u003e\n          \u003ctd\u003e유지\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e--mixed\u003c/td\u003e\n          \u003ctd\u003eO\u003c/td\u003e\n          \u003ctd\u003e초기화\u003c/td\u003e\n          \u003ctd\u003e유지\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e--hard\u003c/td\u003e\n          \u003ctd\u003eO\u003c/td\u003e\n          \u003ctd\u003e초기화\u003c/td\u003e\n          \u003ctd\u003e초기화\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch3 id=\"실수-복구\"\u003e실수 복구\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 실수로 reset --hard 했을 때\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit reflog                  \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# 모든 HEAD 이동 이력 확인\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit reset --hard HEAD@\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e{\u003c/span\u003e2\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e}\u003c/span\u003e   \u003cspan style=\"color:#6c7086;font-style:italic\"\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:#6c7086;font-style:italic\"\u003e# 삭제된 브랜치 복구\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit reflog\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egit checkout -b recovered-branch HEAD@\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e{\u003c/span\u003e3\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"gitignore-작성법\"\u003e.gitignore 작성법\u003c/h2\u003e\n\u003ch3 id=\"기본-문법\"\u003e기본 문법\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-fallback\" data-lang=\"fallback\"\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\u003esecret.txt\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\u003elogs/\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*.log           # 모든 .log 파일\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e*.log.*         # .log.로 시작하는 파일\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*.log\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e!important.log  # important.log는 추적\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/config.json    # 루트의 config.json만\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e**/temp/        # 모든 경로의 temp 디렉토리\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"언어별-예시\"\u003e언어별 예시\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eNode.js 프로젝트\u003c/strong\u003e\u003c/p\u003e","tags":["Git","버전관리","개발도구","협업"],"title":"Git 핵심 개념 - 커밋, 되돌리기, Stash, 태그, 원격 저장소"},{"content":"들어가며 웹 애플리케이션에서 사용자 인증은 가장 기본적이면서도 중요한 기능입니다. 전통적인 세션 기반 인증 방식은 서버에 상태를 저장하기 때문에 확장성과 분산 환경에서 한계가 있습니다. JWT(JSON Web Token)는 이러한 문제를 해결하는 토큰 기반 인증 방식으로, 최근 REST API와 마이크로서비스 환경에서 널리 사용되고 있습니다.\n이 글에서는 JWT의 원리부터 Spring Boot와 프론트엔드에서의 실전 구현까지 다룹니다.\n세션 기반 vs 토큰 기반 인증 세션 기반 인증 전통적인 방식으로, 서버가 세션 정보를 메모리나 DB에 저장합니다.\n1. 사용자 로그인 → 서버가 세션 ID 생성 → 메모리/DB 저장 2. 클라이언트에 세션 ID 쿠키 전송 3. 이후 요청마다 쿠키로 세션 ID 전송 → 서버가 세션 저장소 조회 장점:\n서버에서 언제든 세션 무효화 가능 구현이 간단하고 검증된 방식 단점:\n서버가 상태를 유지해야 함 (메모리 사용) 여러 서버 환경에서 세션 동기화 필요 (Redis 등) CORS 환경에서 쿠키 처리 복잡 토큰 기반 인증 (JWT) 서버가 상태를 저장하지 않는 무상태(stateless) 방식입니다.\n1. 사용자 로그인 → 서버가 JWT 토큰 생성 및 서명 2. 클라이언트에 토큰 전송 (JSON 응답) 3. 이후 요청마다 Authorization 헤더에 토큰 포함 4. 서버는 서명 검증만으로 인증 (저장소 조회 불필요) 장점:\n서버가 상태를 저장하지 않아 확장성 우수 마이크로서비스 간 인증 정보 공유 용이 모바일 앱과 SPA에 적합 단점:\n토큰 탈취 시 만료 전까지 무효화 어려움 토큰 크기가 쿠키보다 큼 JWT 구조 JWT는 .으로 구분된 세 부분으로 구성됩니다.\nHeader.Payload.Signature Header 토큰 타입과 서명 알고리즘을 명시합니다.\n{ \u0026#34;alg\u0026#34;: \u0026#34;HS256\u0026#34;, \u0026#34;typ\u0026#34;: \u0026#34;JWT\u0026#34; } Base64 URL 인코딩: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\nPayload 실제 전달할 데이터(Claims)를 담습니다.\n{ \u0026#34;sub\u0026#34;: \u0026#34;user@example.com\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;홍길동\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;ADMIN\u0026#34;, \u0026#34;iat\u0026#34;: 1708056000, \u0026#34;exp\u0026#34;: 1708059600 } 표준 클레임:\nsub: 토큰 주체 (사용자 ID) iat: 발급 시각 (issued at) exp: 만료 시각 (expiration) iss: 발급자 (issuer) 커스텀 클레임:\n사용자 역할, 권한, 이름 등 필요한 정보 Base64 URL 인코딩 후 중간 부분이 됩니다.\nSignature Header와 Payload를 비밀키로 서명합니다.\nHMACSHA256( base64UrlEncode(header) + \u0026#34;.\u0026#34; + base64UrlEncode(payload), secret # 최소 256비트(32바이트) 무작위 키 ) 이 서명으로 토큰 위변조를 검증합니다. 비밀키는 반드시 충분한 길이(256비트 이상)와 무작위성을 가져야 하며, 환경 변수로 안전하게 관리해야 합니다.\nJWT 동작 원리 1. 토큰 발급 // 사용자 로그인 성공 시 String username = \u0026#34;user@example.com\u0026#34;; String token = jwtUtil.generateToken(username); // 반환: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwiaWF0IjoxNzA4MDU2MDAwLCJleHAiOjE3MDgwNTk2MDB9.abc123... 2. 토큰 전송 클라이언트는 HTTP 요청 헤더에 토큰을 포함합니다.\nGET /api/users Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwiaWF0IjoxNzA4MDU2MDAwLCJleHAiOjE3MDgwNTk2MDB9.abc123... 3. 토큰 검증 서버는 서명을 검증하고 만료 시간을 확인합니다.\nif (jwtUtil.validateToken(token)) { String username = jwtUtil.extractUsername(token); // 사용자 인증 완료 } Spring Security + JWT 통합 JwtUtil 클래스 토큰 생성, 검증, Claims 추출을 담당합니다.\npackage com.example.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.security.Key; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.function.Function; @Component public class JwtUtil { @Value(\u0026#34;${jwt.secret}\u0026#34;) private String secret; @Value(\u0026#34;${jwt.expiration}\u0026#34;) private Long expiration; // 밀리초 (예: 3600000 = 1시간) private Key getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes()); } // 토큰에서 username 추출 public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } // 토큰에서 만료 시간 추출 public Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } // 특정 클레임 추출 public \u0026lt;T\u0026gt; T extractClaim(String token, Function\u0026lt;Claims, T\u0026gt; claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } // 모든 클레임 추출 private Claims extractAllClaims(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } // 토큰 만료 여부 확인 private Boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } // 토큰 생성 public String generateToken(String username) { Map\u0026lt;String, Object\u0026gt; claims = new HashMap\u0026lt;\u0026gt;(); return createToken(claims, username); } // 추가 클레임과 함께 토큰 생성 public String generateToken(String username, Map\u0026lt;String, Object\u0026gt; extraClaims) { return createToken(extraClaims, username); } private String createToken(Map\u0026lt;String, Object\u0026gt; claims, String subject) { return Jwts.builder() .setClaims(claims) .setSubject(subject) .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } // 토큰 검증 public Boolean validateToken(String token, String username) { final String extractedUsername = extractUsername(token); return (extractedUsername.equals(username) \u0026amp;\u0026amp; !isTokenExpired(token)); } } application.yml 설정:\njwt: secret: ${JWT_SECRET} # 환경 변수로 관리 (절대 하드코딩 금지!) expiration: 3600000 # 1시간 (밀리초) ⚠️ 보안 경고:\nJWT 비밀키는 최소 256비트(32바이트)의 무작위 값이어야 합니다 절대 코드에 하드코딩하지 말고 환경 변수로 관리하세요 키 생성 예시: openssl rand -base64 32 JwtAuthenticationFilter 모든 요청에서 JWT 토큰을 검증하는 필터입니다.\npackage com.example.security; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Autowired private UserDetailsService userDetailsService; @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { final String authHeader = request.getHeader(\u0026#34;Authorization\u0026#34;); final String jwt; final String username; // Authorization 헤더 검증 if (authHeader == null || !authHeader.startsWith(\u0026#34;Bearer \u0026#34;)) { filterChain.doFilter(request, response); return; } // 토큰 추출 jwt = authHeader.substring(7); username = jwtUtil.extractUsername(jwt); // 사용자 인증 처리 if (username != null \u0026amp;\u0026amp; SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtUtil.validateToken(jwt, userDetails.getUsername())) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); SecurityContextHolder.getContext().setAuthentication(authToken); } } filterChain.doFilter(request, response); } } SecurityConfig 설정 Stateless 세션 정책과 JWT 필터를 등록합니다.\npackage com.example.security; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -\u0026gt; csrf.disable()) .authorizeHttpRequests(auth -\u0026gt; auth .requestMatchers(\u0026#34;/api/auth/**\u0026#34;).permitAll() .requestMatchers(\u0026#34;/api/admin/**\u0026#34;).hasRole(\u0026#34;ADMIN\u0026#34;) .anyRequest().authenticated() ) .sessionManagement(session -\u0026gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config ) throws Exception { return config.getAuthenticationManager(); } } 로그인 컨트롤러 package com.example.controller; import com.example.security.JwtUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping(\u0026#34;/api/auth\u0026#34;) public class AuthController { @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtUtil jwtUtil; @PostMapping(\u0026#34;/login\u0026#34;) public Map\u0026lt;String, String\u0026gt; login(@RequestBody LoginRequest request) { // 사용자 인증 Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); // JWT 토큰 생성 String token = jwtUtil.generateToken(request.getUsername()); Map\u0026lt;String, String\u0026gt; response = new HashMap\u0026lt;\u0026gt;(); response.put(\u0026#34;token\u0026#34;, token); response.put(\u0026#34;type\u0026#34;, \u0026#34;Bearer\u0026#34;); return response; } } class LoginRequest { private String username; private String password; // getters, setters public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } 프론트엔드 JWT 처리 💡 참고: React 애플리케이션에서 JWT를 Context와 함께 사용하는 완전한 예제는 React 상태 관리 포스트의 \u0026quot;실전 예시: 인증 상태 관리\u0026quot; 섹션을 참고하세요.\nAxios 인터셉터로 토큰 자동 주입 모든 요청에 Authorization 헤더를 자동으로 추가합니다.\n// src/api/axios.js import axios from \u0026#39;axios\u0026#39;; const instance = axios.create({ baseURL: \u0026#39;http://localhost:8080/api\u0026#39;, timeout: 10000 }); // 요청 인터셉터: 토큰 자동 주입 instance.interceptors.request.use( config =\u0026gt; { const token = localStorage.getItem(\u0026#39;token\u0026#39;); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, error =\u0026gt; { return Promise.reject(error); } ); // 응답 인터셉터: 401 에러 처리 instance.interceptors.response.use( response =\u0026gt; response, error =\u0026gt; { if (error.response?.status === 401) { // 토큰 만료 또는 인증 실패 localStorage.removeItem(\u0026#39;token\u0026#39;); window.location.href = \u0026#39;/login\u0026#39;; } return Promise.reject(error); } ); export default instance; 로그인 처리 // src/services/authService.js import axios from \u0026#39;../api/axios\u0026#39;; export const login = async (username, password) =\u0026gt; { const response = await axios.post(\u0026#39;/auth/login\u0026#39;, { username, password }); const { token } = response.data; localStorage.setItem(\u0026#39;token\u0026#39;, token); return token; }; export const logout = () =\u0026gt; { localStorage.removeItem(\u0026#39;token\u0026#39;); window.location.href = \u0026#39;/login\u0026#39;; }; export const isAuthenticated = () =\u0026gt; { const token = localStorage.getItem(\u0026#39;token\u0026#39;); if (!token) return false; // JWT 만료 시간 확인 try { const payload = JSON.parse(atob(token.split(\u0026#39;.\u0026#39;)[1])); return payload.exp * 1000 \u0026gt; Date.now(); } catch (e) { return false; } }; 토큰 만료 감지와 자동 갱신 // src/utils/tokenRefresh.js import axios from \u0026#39;../api/axios\u0026#39;; let refreshTimer = null; // 토큰 만료 5분 전에 갱신 export const startTokenRefresh = () =\u0026gt; { const token = localStorage.getItem(\u0026#39;token\u0026#39;); if (!token) return; try { const payload = JSON.parse(atob(token.split(\u0026#39;.\u0026#39;)[1])); const expiresIn = payload.exp * 1000 - Date.now(); const refreshTime = expiresIn - 5 * 60 * 1000; // 5분 전 if (refreshTime \u0026gt; 0) { refreshTimer = setTimeout(async () =\u0026gt; { try { const response = await axios.post(\u0026#39;/auth/refresh\u0026#39;); localStorage.setItem(\u0026#39;token\u0026#39;, response.data.token); startTokenRefresh(); // 재귀 호출 } catch (error) { console.error(\u0026#39;Token refresh failed:\u0026#39;, error); logout(); } }, refreshTime); } } catch (e) { console.error(\u0026#39;Invalid token:\u0026#39;, e); } }; export const stopTokenRefresh = () =\u0026gt; { if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; } }; 세션 만료 경고 다이얼로그 // src/components/SessionWarning.jsx import React, { useEffect, useState } from \u0026#39;react\u0026#39;; const SessionWarning = () =\u0026gt; { const [showWarning, setShowWarning] = useState(false); const [remainingTime, setRemainingTime] = useState(60); useEffect(() =\u0026gt; { const checkExpiration = () =\u0026gt; { const token = localStorage.getItem(\u0026#39;token\u0026#39;); if (!token) return; try { const payload = JSON.parse(atob(token.split(\u0026#39;.\u0026#39;)[1])); const expiresIn = Math.floor((payload.exp * 1000 - Date.now()) / 1000); if (expiresIn \u0026lt;= 60 \u0026amp;\u0026amp; expiresIn \u0026gt; 0) { setShowWarning(true); setRemainingTime(expiresIn); } else { setShowWarning(false); } } catch (e) { console.error(\u0026#39;Token parse error:\u0026#39;, e); } }; const interval = setInterval(checkExpiration, 1000); return () =\u0026gt; clearInterval(interval); }, []); const handleExtend = async () =\u0026gt; { try { const response = await axios.post(\u0026#39;/auth/refresh\u0026#39;); localStorage.setItem(\u0026#39;token\u0026#39;, response.data.token); setShowWarning(false); } catch (error) { console.error(\u0026#39;Session extension failed:\u0026#39;, error); } }; if (!showWarning) return null; return ( \u0026lt;div className=\u0026#34;session-warning-modal\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;modal-content\u0026#34;\u0026gt; \u0026lt;h3\u0026gt;세션 만료 경고\u0026lt;/h3\u0026gt; \u0026lt;p\u0026gt;{remainingTime}초 후 세션이 만료됩니다.\u0026lt;/p\u0026gt; \u0026lt;button onClick={handleExtend}\u0026gt;연장하기\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; ); }; export default SessionWarning; Refresh Token 전략 Access Token의 만료 시간을 짧게 하고, Refresh Token으로 재발급받는 방식입니다.\n⚠️ 보안 권장사항:\nRefresh Token은 반드시 HttpOnly 쿠키로 저장 (XSS 방어) Access Token은 메모리 또는 localStorage 저장 가능 Refresh Token은 긴 만료 시간 (7일30일), Access Token은 짧게 (15분1시간) 서버 구현 @PostMapping(\u0026#34;/refresh\u0026#34;) public Map\u0026lt;String, String\u0026gt; refresh(@RequestBody RefreshRequest request) { String refreshToken = request.getRefreshToken(); // Refresh Token 검증 if (jwtUtil.validateToken(refreshToken, jwtUtil.extractUsername(refreshToken))) { String username = jwtUtil.extractUsername(refreshToken); // 새로운 Access Token 발급 String newAccessToken = jwtUtil.generateToken(username); Map\u0026lt;String, String\u0026gt; response = new HashMap\u0026lt;\u0026gt;(); response.put(\u0026#34;token\u0026#34;, newAccessToken); return response; } throw new RuntimeException(\u0026#34;Invalid refresh token\u0026#34;); } 클라이언트 구현 // 올바른 방법: Refresh Token은 HttpOnly 쿠키, Access Token은 localStorage export const loginWithRefresh = async (username, password) =\u0026gt; { const response = await axios.post(\u0026#39;/auth/login\u0026#39;, { username, password }, { withCredentials: true // 쿠키 전송 활성화 }); const { accessToken } = response.data; // refreshToken은 서버가 HttpOnly 쿠키로 자동 설정 (Set-Cookie 헤더) localStorage.setItem(\u0026#39;token\u0026#39;, accessToken); return accessToken; }; // Access Token 만료 시 자동 갱신 instance.interceptors.response.use( response =\u0026gt; response, async error =\u0026gt; { const originalRequest = error.config; if (error.response?.status === 401 \u0026amp;\u0026amp; !originalRequest._retry) { originalRequest._retry = true; try { const response = await axios.post(\u0026#39;/auth/refresh\u0026#39;); const { accessToken } = response.data; localStorage.setItem(\u0026#39;token\u0026#39;, accessToken); originalRequest.headers.Authorization = `Bearer ${accessToken}`; return axios(originalRequest); } catch (refreshError) { logout(); return Promise.reject(refreshError); } } return Promise.reject(error); } ); JWT 보안 주의사항 1. 비밀키 관리 # 잘못된 예: 짧은 비밀키 jwt: secret: mysecret # 올바른 예: 256비트 이상 무작위 키 jwt: secret: ${JWT_SECRET:your-256-bit-secret-key-here-must-be-long-enough-for-hs256-algorithm} 환경 변수로 관리하고 절대 코드에 하드코딩하지 않습니다.\n2. 토큰 크기 너무 많은 정보를 Payload에 담으면 토큰이 비대해집니다.\n// 나쁜 예: 불필요한 정보 포함 Map\u0026lt;String, Object\u0026gt; claims = new HashMap\u0026lt;\u0026gt;(); claims.put(\u0026#34;user_profile_image\u0026#34;, \u0026#34;data:image/png;base64,...\u0026#34;); // 큰 데이터 claims.put(\u0026#34;user_preferences\u0026#34;, largeObject); // 좋은 예: 최소한의 정보만 Map\u0026lt;String, Object\u0026gt; claims = new HashMap\u0026lt;\u0026gt;(); claims.put(\u0026#34;role\u0026#34;, \u0026#34;ADMIN\u0026#34;); claims.put(\u0026#34;userId\u0026#34;, 12345); 3. XSS 방어 LocalStorage는 JavaScript로 접근 가능하므로 XSS 공격에 취약합니다.\n// 방어 1: Content Security Policy 설정 // \u0026lt;meta http-equiv=\u0026#34;Content-Security-Policy\u0026#34; content=\u0026#34;default-src \u0026#39;self\u0026#39;\u0026#34;\u0026gt; // 방어 2: 민감한 토큰은 HttpOnly 쿠키 사용 (Refresh Token) // 서버: response.addCookie(new Cookie(\u0026#34;refreshToken\u0026#34;, token).setHttpOnly(true)) // 방어 3: 입력 값 검증 및 이스케이프 const sanitizeInput = (input) =\u0026gt; { return input.replace(/[\u0026lt;\u0026gt;\\\u0026#34;\u0026#39;]/g, \u0026#39;\u0026#39;); }; 4. CSRF 방어 Stateless 토큰 방식은 CSRF에 상대적으로 안전하지만, 쿠키 사용 시 주의가 필요합니다.\n// SameSite 쿠키 속성 사용 Cookie cookie = new Cookie(\u0026#34;token\u0026#34;, jwt); cookie.setHttpOnly(true); cookie.setSecure(true); // HTTPS only cookie.setAttribute(\u0026#34;SameSite\u0026#34;, \u0026#34;Strict\u0026#34;); 5. HTTPS 사용 JWT는 평문이므로 반드시 HTTPS를 사용해야 합니다.\n실전 예시: 관리 콘솔 인증 시스템 시나리오 관리자가 관리 콘솔에 로그인하여 사용자 목록을 조회하고, 토큰 만료 전 자동 갱신되는 시스템을 구현합니다.\n백엔드: 사용자 컨트롤러 @RestController @RequestMapping(\u0026#34;/api/admin/users\u0026#34;) public class UserController { @Autowired private UserService userService; @GetMapping public List\u0026lt;User\u0026gt; getAllUsers() { // JWT 필터가 이미 인증을 처리했으므로 바로 조회 return userService.findAll(); } @GetMapping(\u0026#34;/me\u0026#34;) public User getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) { return userService.findByUsername(userDetails.getUsername()); } } 프론트엔드: 로그인 페이지 // src/pages/Login.jsx import React, { useState } from \u0026#39;react\u0026#39;; import { useNavigate } from \u0026#39;react-router-dom\u0026#39;; import { login } from \u0026#39;../services/authService\u0026#39;; const Login = () =\u0026gt; { const [username, setUsername] = useState(\u0026#39;\u0026#39;); const [password, setPassword] = useState(\u0026#39;\u0026#39;); const [error, setError] = useState(\u0026#39;\u0026#39;); const navigate = useNavigate(); const handleSubmit = async (e) =\u0026gt; { e.preventDefault(); setError(\u0026#39;\u0026#39;); try { await login(username, password); navigate(\u0026#39;/dashboard\u0026#39;); } catch (err) { setError(\u0026#39;로그인 실패: 사용자명 또는 비밀번호를 확인하세요\u0026#39;); } }; return ( \u0026lt;div className=\u0026#34;login-container\u0026#34;\u0026gt; \u0026lt;form onSubmit={handleSubmit}\u0026gt; \u0026lt;h2\u0026gt;관리자 로그인\u0026lt;/h2\u0026gt; {error \u0026amp;\u0026amp; \u0026lt;div className=\u0026#34;error\u0026#34;\u0026gt;{error}\u0026lt;/div\u0026gt;} \u0026lt;input type=\u0026#34;text\u0026#34; placeholder=\u0026#34;사용자명\u0026#34; value={username} onChange={(e) =\u0026gt; setUsername(e.target.value)} /\u0026gt; \u0026lt;input type=\u0026#34;password\u0026#34; placeholder=\u0026#34;비밀번호\u0026#34; value={password} onChange={(e) =\u0026gt; setPassword(e.target.value)} /\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;로그인\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;/div\u0026gt; ); }; export default Login; 프론트엔드: 사용자 목록 페이지 // src/pages/Users.jsx import React, { useEffect, useState } from \u0026#39;react\u0026#39;; import axios from \u0026#39;../api/axios\u0026#39;; import SessionWarning from \u0026#39;../components/SessionWarning\u0026#39;; const Users = () =\u0026gt; { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); useEffect(() =\u0026gt; { fetchUsers(); }, []); const fetchUsers = async () =\u0026gt; { try { const response = await axios.get(\u0026#39;/admin/users\u0026#39;); setUsers(response.data); } catch (error) { console.error(\u0026#39;Failed to fetch users:\u0026#39;, error); } finally { setLoading(false); } }; if (loading) return \u0026lt;div\u0026gt;로딩 중...\u0026lt;/div\u0026gt;; return ( \u0026lt;div className=\u0026#34;users-container\u0026#34;\u0026gt; \u0026lt;SessionWarning /\u0026gt; \u0026lt;h2\u0026gt;사용자 목록\u0026lt;/h2\u0026gt; \u0026lt;table\u0026gt; \u0026lt;thead\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;th\u0026gt;ID\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;사용자명\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;이메일\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;역할\u0026lt;/th\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;/thead\u0026gt; \u0026lt;tbody\u0026gt; {users.map(user =\u0026gt; ( \u0026lt;tr key={user.id}\u0026gt; \u0026lt;td\u0026gt;{user.id}\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;{user.username}\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;{user.email}\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;{user.role}\u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; ))} \u0026lt;/tbody\u0026gt; \u0026lt;/table\u0026gt; \u0026lt;/div\u0026gt; ); }; export default Users; 동작 흐름 사용자가 /login에서 인증 정보 입력 서버가 JWT 토큰 발급 (1시간 유효) 클라이언트가 LocalStorage에 토큰 저장 /dashboard 이동 시 Axios 인터셉터가 자동으로 Authorization: Bearer \u0026lt;token\u0026gt; 헤더 추가 서버의 JwtAuthenticationFilter가 토큰 검증 만료 55분 시점에 자동 갱신 또는 60초 전 경고 다이얼로그 표시 사용자가 \u0026quot;연장하기\u0026quot; 클릭 시 /auth/refresh로 새 토큰 발급 마무리 JWT는 현대 웹 애플리케이션에서 확장성과 무상태성을 제공하는 강력한 인증 방식입니다. 세션 기반 인증의 한계를 극복하고, REST API와 마이크로서비스 환경에 적합합니다.\n핵심 요약:\nJWT는 Header.Payload.Signature 구조로 자체 서명된 토큰 Spring Security와 통합하여 stateless 인증 구현 프론트엔드에서 Axios 인터셉터로 토큰 자동 관리 Refresh Token으로 보안성과 편의성 균형 XSS/CSRF 방어와 HTTPS 사용 필수 다음 글에서는 암호화 기초를 다루며 AES, RSA, 디지털 서명의 원리와 구현을 알아보겠습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/security-jwt-authentication/","summary":"\u003ch2 id=\"들어가며\"\u003e들어가며\u003c/h2\u003e\n\u003cp\u003e웹 애플리케이션에서 사용자 인증은 가장 기본적이면서도 중요한 기능입니다. 전통적인 세션 기반 인증 방식은 서버에 상태를 저장하기 때문에 확장성과 분산 환경에서 한계가 있습니다. JWT(JSON Web Token)는 이러한 문제를 해결하는 토큰 기반 인증 방식으로, 최근 REST API와 마이크로서비스 환경에서 널리 사용되고 있습니다.\u003c/p\u003e\n\u003cp\u003e이 글에서는 JWT의 원리부터 Spring Boot와 프론트엔드에서의 실전 구현까지 다룹니다.\u003c/p\u003e\n\u003ch2 id=\"세션-기반-vs-토큰-기반-인증\"\u003e세션 기반 vs 토큰 기반 인증\u003c/h2\u003e\n\u003ch3 id=\"세션-기반-인증\"\u003e세션 기반 인증\u003c/h3\u003e\n\u003cp\u003e전통적인 방식으로, 서버가 세션 정보를 메모리나 DB에 저장합니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e1. 사용자 로그인 → 서버가 세션 ID 생성 → 메모리/DB 저장\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e2. 클라이언트에 세션 ID 쿠키 전송\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e3. 이후 요청마다 쿠키로 세션 ID 전송 → 서버가 세션 저장소 조회\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e장점:\u003c/strong\u003e\u003c/p\u003e","tags":["보안","JWT","인증","Spring Security"],"title":"JWT 인증 시스템 - Spring Security 통합, Refresh Token, 보안 설정"},{"content":"GitHub Actions를 활용하면 코드 푸시부터 배포까지 전 과정을 자동화할 수 있습니다. 이번 포스트에서는 실전에서 바로 사용할 수 있는 CI/CD 파이프라인 구축 방법을 다룹니다.\n1. CI/CD 개념 이해하기 CI (Continuous Integration, 지속적 통합) 개발자가 코드를 커밋할 때마다 자동으로 빌드와 테스트를 실행하는 프로세스입니다.\n주요 목적:\n코드 통합 과정에서 발생하는 버그를 조기 발견 빌드 실패나 테스트 실패를 즉시 파악 코드 품질 유지 및 향상 CD (Continuous Deployment/Delivery, 지속적 배포) 테스트를 통과한 코드를 자동으로 프로덕션 환경에 배포하는 프로세스입니다.\nContinuous Delivery vs Continuous Deployment:\nDelivery: 배포 준비까지 자동화, 최종 배포는 수동 승인 Deployment: 배포까지 완전 자동화 2. GitHub Actions 기본 구조 핵심 개념 # .github/workflows/example.yml name: CI/CD Pipeline # 워크플로우 이름 on: [push, pull_request] # 트리거 이벤트 jobs: # 실행할 작업들 build: # Job 이름 runs-on: ubuntu-latest # Runner (실행 환경) steps: # 순차적으로 실행할 단계들 - name: Checkout code uses: actions/checkout@v4 - name: Run tests run: npm test 주요 구성 요소:\nWorkflow: .github/workflows/ 디렉토리의 YAML 파일 Job: 독립적으로 실행되는 작업 단위 (병렬 실행 가능) Step: Job 내에서 순차적으로 실행되는 명령 Runner: 워크플로우를 실행하는 서버 (ubuntu-latest, windows-latest 등) 3. YAML 문법 기초 on: 워크플로우 트리거 # 단일 이벤트 on: push # 여러 이벤트 on: [push, pull_request] # 세부 설정 on: push: branches: - main - develop pull_request: branches: - main jobs: 작업 정의 jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm test deploy: needs: test # test job 완료 후 실행 runs-on: ubuntu-latest steps: - run: echo \u0026#34;Deploying...\u0026#34; with: 액션 파라미터 전달 steps: - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: \u0026#39;20\u0026#39; cache: \u0026#39;npm\u0026#39; env: 환경변수 설정 env: NODE_ENV: production jobs: build: env: DATABASE_URL: ${{ secrets.DATABASE_URL }} steps: - run: echo $NODE_ENV 4. 첫 번째 워크플로우 작성 간단한 Node.js 프로젝트의 빌드 및 테스트 자동화:\n# .github/workflows/ci.yml name: CI on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: build-and-test: runs-on: ubuntu-latest steps: - name: 코드 체크아웃 uses: actions/checkout@v4 - name: Node.js 설정 uses: actions/setup-node@v4 with: node-version: \u0026#39;20\u0026#39; cache: \u0026#39;npm\u0026#39; - name: 의존성 설치 run: npm ci - name: 빌드 run: npm run build - name: 테스트 실행 run: npm test 주요 포인트:\nnpm ci: npm install보다 빠르고 일관된 설치 (CI 환경에 최적화) cache: 'npm': node_modules 캐싱으로 설치 속도 향상 PR 생성 시에도 자동으로 테스트 실행 5. Spring Boot CI 파이프라인 Gradle 기반 Spring Boot 프로젝트의 빌드 및 테스트:\n# .github/workflows/spring-boot-ci.yml name: Spring Boot CI on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: build: runs-on: ubuntu-latest steps: - name: 코드 체크아웃 uses: actions/checkout@v4 - name: JDK 17 설정 uses: actions/setup-java@v4 with: java-version: \u0026#39;17\u0026#39; distribution: \u0026#39;temurin\u0026#39; cache: gradle - name: Gradle 실행 권한 부여 run: chmod +x gradlew - name: Gradle 빌드 run: ./gradlew build - name: 테스트 실행 run: ./gradlew test - name: JaCoCo 테스트 커버리지 리포트 run: ./gradlew jacocoTestReport - name: 테스트 결과 업로드 if: always() uses: actions/upload-artifact@v4 with: name: test-results path: build/test-results/ - name: 커버리지 리포트 업로드 uses: actions/upload-artifact@v4 with: name: jacoco-report path: build/reports/jacoco/ JaCoCo 설정 (build.gradle):\nplugins { id \u0026#39;jacoco\u0026#39; } jacoco { toolVersion = \u0026#34;0.8.11\u0026#34; } test { finalizedBy jacocoTestReport } jacocoTestReport { dependsOn test reports { xml.required = true html.required = true } } 6. React CI 파이프라인 Vite 기반 React 프로젝트의 린트, 테스트, 빌드:\n# .github/workflows/react-ci.yml name: React CI on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: lint-test-build: runs-on: ubuntu-latest steps: - name: 코드 체크아웃 uses: actions/checkout@v4 - name: Node.js 설정 uses: actions/setup-node@v4 with: node-version: \u0026#39;20\u0026#39; cache: \u0026#39;npm\u0026#39; - name: 의존성 설치 run: npm ci - name: ESLint 검사 run: npm run lint - name: 타입 체크 (TypeScript) run: npm run type-check continue-on-error: false - name: 테스트 실행 run: npm test -- --coverage - name: 프로덕션 빌드 run: npm run build - name: 빌드 결과물 업로드 uses: actions/upload-artifact@v4 with: name: build-output path: dist/ package.json 스크립트 예시:\n{ \u0026#34;scripts\u0026#34;: { \u0026#34;dev\u0026#34;: \u0026#34;vite\u0026#34;, \u0026#34;build\u0026#34;: \u0026#34;tsc \u0026amp;\u0026amp; vite build\u0026#34;, \u0026#34;lint\u0026#34;: \u0026#34;eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0\u0026#34;, \u0026#34;type-check\u0026#34;: \u0026#34;tsc --noEmit\u0026#34;, \u0026#34;test\u0026#34;: \u0026#34;vitest\u0026#34;, \u0026#34;preview\u0026#34;: \u0026#34;vite preview\u0026#34; } } 7. Docker 이미지 빌드 \u0026amp; GitHub Container Registry 푸시 빌드한 애플리케이션을 Docker 이미지로 만들고 GHCR에 푸시:\n# .github/workflows/docker-build.yml name: Docker Build \u0026amp; Push on: push: branches: [ main ] tags: - \u0026#39;v*\u0026#39; env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: 코드 체크아웃 uses: actions/checkout@v4 - name: Docker Buildx 설정 uses: docker/setup-buildx-action@v3 - name: GHCR 로그인 uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: 메타데이터 추출 id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha - name: Docker 이미지 빌드 및 푸시 uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max Dockerfile 예시 (Spring Boot):\nFROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY build/libs/*.jar app.jar EXPOSE 8080 ENTRYPOINT [\u0026#34;java\u0026#34;, \u0026#34;-jar\u0026#34;, \u0026#34;app.jar\u0026#34;] 주요 포인트:\nGITHUB_TOKEN: 자동으로 제공되는 시크릿 (별도 설정 불필요) cache-from/cache-to: 빌드 캐시로 속도 향상 metadata-action: 브랜치명, 태그, SHA 기반으로 이미지 태그 자동 생성 8. 환경별 배포 전략 환경변수와 Secrets 관리 GitHub Secrets 설정:\nRepository → Settings → Secrets and variables → Actions New repository secret 클릭 Name과 Value 입력 (예: DATABASE_URL, API_KEY) Environment 기반 배포:\n# .github/workflows/deploy.yml name: Deploy on: push: branches: - develop - main jobs: deploy-dev: if: github.ref == \u0026#39;refs/heads/develop\u0026#39; runs-on: ubuntu-latest environment: development steps: - name: 개발 환경 배포 run: | echo \u0026#34;배포 대상: ${{ vars.DEPLOY_URL }}\u0026#34; echo \u0026#34;API_KEY: ${{ secrets.API_KEY }}\u0026#34; deploy-prod: if: github.ref == \u0026#39;refs/heads/main\u0026#39; runs-on: ubuntu-latest environment: production steps: - name: 프로덕션 배포 run: | echo \u0026#34;배포 대상: ${{ vars.DEPLOY_URL }}\u0026#34; echo \u0026#34;API_KEY: ${{ secrets.API_KEY }}\u0026#34; Environment 설정 방법:\nRepository → Settings → Environments New environment 클릭 (development, staging, production) Environment secrets 및 variables 설정 (선택) Protection rules 설정 (승인 필요, 특정 브랜치만 허용) 다중 환경 배포 워크플로우 # .github/workflows/multi-env-deploy.yml name: Multi-Environment Deploy on: workflow_dispatch: inputs: environment: description: \u0026#39;배포 환경\u0026#39; required: true type: choice options: - development - staging - production jobs: deploy: runs-on: ubuntu-latest environment: ${{ github.event.inputs.environment }} steps: - name: 코드 체크아웃 uses: actions/checkout@v4 - name: 환경 정보 출력 run: | echo \u0026#34;Environment: ${{ github.event.inputs.environment }}\u0026#34; echo \u0026#34;Deploy URL: ${{ vars.DEPLOY_URL }}\u0026#34; - name: Docker 이미지 배포 run: | docker pull ghcr.io/${{ github.repository }}:latest # 배포 스크립트 실행 주요 포인트:\nworkflow_dispatch: 수동으로 워크플로우 실행 가능 environment: 환경별로 다른 시크릿/변수 사용 vars.VARIABLE_NAME: 환경 변수 참조 secrets.SECRET_NAME: 환경 시크릿 참조 9. 실전 워크플로우 예제 PR 생성 시 테스트 → main 머지 시 자동 배포:\n# .github/workflows/pr-main-deploy.yml name: PR Test \u0026amp; Main Deploy on: pull_request: branches: [ main ] push: branches: [ main ] jobs: test: name: 테스트 실행 runs-on: ubuntu-latest steps: - name: 코드 체크아웃 uses: actions/checkout@v4 - name: Node.js 설정 uses: actions/setup-node@v4 with: node-version: \u0026#39;20\u0026#39; cache: \u0026#39;npm\u0026#39; - name: 의존성 설치 run: npm ci - name: 린트 검사 run: npm run lint - name: 테스트 실행 run: npm test -- --coverage - name: 빌드 검증 run: npm run build build-and-push: name: Docker 이미지 빌드 및 푸시 needs: test if: github.event_name == \u0026#39;push\u0026#39; \u0026amp;\u0026amp; github.ref == \u0026#39;refs/heads/main\u0026#39; runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: 코드 체크아웃 uses: actions/checkout@v4 - name: GHCR 로그인 uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker 이미지 빌드 및 푸시 uses: docker/build-push-action@v5 with: context: . push: true tags: | ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:${{ github.sha }} deploy: name: 프로덕션 배포 needs: build-and-push runs-on: ubuntu-latest environment: production steps: - name: 서버 배포 uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | cd /app docker pull ghcr.io/${{ github.repository }}:latest docker-compose down docker-compose up -d docker image prune -f - name: 배포 완료 알림 run: | echo \u0026#34;✅ 배포 완료: https://${{ vars.DEPLOY_URL }}\u0026#34; Spring Boot + React 풀스택 예제 # .github/workflows/fullstack-cicd.yml name: Fullstack CI/CD on: pull_request: branches: [ main ] push: branches: [ main ] jobs: test-backend: name: 백엔드 테스트 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: JDK 설정 uses: actions/setup-java@v4 with: java-version: \u0026#39;17\u0026#39; distribution: \u0026#39;temurin\u0026#39; cache: gradle - name: 백엔드 테스트 run: | cd backend chmod +x gradlew ./gradlew test test-frontend: name: 프론트엔드 테스트 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Node.js 설정 uses: actions/setup-node@v4 with: node-version: \u0026#39;20\u0026#39; cache: \u0026#39;npm\u0026#39; cache-dependency-path: frontend/package-lock.json - name: 프론트엔드 테스트 run: | cd frontend npm ci npm run lint npm test npm run build deploy: name: 통합 배포 needs: [test-backend, test-frontend] if: github.event_name == \u0026#39;push\u0026#39; \u0026amp;\u0026amp; github.ref == \u0026#39;refs/heads/main\u0026#39; runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - name: Docker Compose 배포 uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | cd /app git pull origin main docker-compose build docker-compose down docker-compose up -d docker-compose.yml 예시:\nversion: \u0026#39;3.8\u0026#39; services: backend: build: ./backend ports: - \u0026#34;8080:8080\u0026#34; environment: - SPRING_PROFILES_ACTIVE=prod - DATABASE_URL=${DATABASE_URL} frontend: build: ./frontend ports: - \u0026#34;80:80\u0026#34; depends_on: - backend 마무리 GitHub Actions를 활용한 CI/CD 파이프라인 구축의 핵심은 다음과 같습니다:\n작은 단위로 시작: 간단한 테스트 자동화부터 시작하여 점진적으로 확장 실패 빠르게 파악: PR 단계에서 문제를 조기 발견 환경 분리: 개발/스테이징/프로덕션 환경을 명확히 구분 보안 관리: Secrets로 민감 정보 보호 캐싱 활용: 빌드 시간 단축으로 개발자 경험 향상 다음 단계로는 다음 주제들을 고려해볼 수 있습니다:\nBlue-Green 배포 전략 Canary 배포로 점진적 롤아웃 자동 롤백 메커니즘 Slack/Discord 알림 연동 성능 테스트 자동화 실제 프로젝트에 적용하면서 팀의 상황에 맞게 커스터마이징해보세요.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/docker-github-actions-cicd/","summary":"\u003cp\u003eGitHub Actions를 활용하면 코드 푸시부터 배포까지 전 과정을 자동화할 수 있습니다. 이번 포스트에서는 실전에서 바로 사용할 수 있는 CI/CD 파이프라인 구축 방법을 다룹니다.\u003c/p\u003e\n\u003ch2 id=\"1-cicd-개념-이해하기\"\u003e1. CI/CD 개념 이해하기\u003c/h2\u003e\n\u003ch3 id=\"ci-continuous-integration-지속적-통합\"\u003eCI (Continuous Integration, 지속적 통합)\u003c/h3\u003e\n\u003cp\u003e개발자가 코드를 커밋할 때마다 자동으로 빌드와 테스트를 실행하는 프로세스입니다.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e주요 목적:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e코드 통합 과정에서 발생하는 버그를 조기 발견\u003c/li\u003e\n\u003cli\u003e빌드 실패나 테스트 실패를 즉시 파악\u003c/li\u003e\n\u003cli\u003e코드 품질 유지 및 향상\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"cd-continuous-deploymentdelivery-지속적-배포\"\u003eCD (Continuous Deployment/Delivery, 지속적 배포)\u003c/h3\u003e\n\u003cp\u003e테스트를 통과한 코드를 자동으로 프로덕션 환경에 배포하는 프로세스입니다.\u003c/p\u003e","tags":["GitHub Actions","CI/CD","DevOps","배포"],"title":"GitHub Actions CI/CD - Spring Boot, React, Docker 빌드와 배포"},{"content":"1. Docker란? - 컨테이너 vs VM Docker가 등장한 이유 \u0026quot;내 컴퓨터에선 잘 되는데요?\u0026quot; - 개발자라면 한 번쯤 들어본 말입니다. 개발 환경과 운영 환경의 차이로 인한 문제를 해결하기 위해 Docker가 등장했습니다.\n컨테이너 vs 가상머신 (VM) [ VM 구조 ] [ Container 구조 ] ┌──────────────┐ ┌──────────────┐ │ App A │ │ App A │ ├──────────────┤ ├──────────────┤ │ Guest OS │ │ Libraries │ ├──────────────┤ ├──────────────┤ │ Hypervisor │ │ Docker Engine│ ├──────────────┤ ├──────────────┤ │ Host OS │ │ Host OS │ └──────────────┘ └──────────────┘ 가상머신 (VM)\n하드웨어 전체를 가상화 각 VM마다 독립적인 OS 실행 무겁고 느림 (GB 단위 크기, 분 단위 부팅) 완전한 격리 보장 컨테이너 (Container)\nOS 커널을 공유하며 프로세스만 격리 호스트 OS의 커널 활용 가볍고 빠름 (MB 단위 크기, 초 단위 부팅) 적절한 수준의 격리 Docker를 쓰는 이유 환경 일관성: 개발/테스트/운영 환경이 동일 빠른 배포: 초 단위로 컨테이너 실행 가능 효율적 자원 활용: VM보다 훨씬 가볍고 많은 컨테이너 실행 가능 버전 관리: 이미지로 애플리케이션 버전 관리 마이크로서비스 적합: 서비스별 독립 배포/확장 2. Docker 핵심 개념 이미지 (Image) 컨테이너를 실행하기 위한 읽기 전용 템플릿입니다.\n# 공식 이미지 예시 docker pull ubuntu:22.04 # Ubuntu 22.04 이미지 docker pull node:18-alpine # Node.js 18 (Alpine Linux 기반) docker pull mysql:8.0 # MySQL 8.0 컨테이너 (Container) 이미지를 실행한 실제 인스턴스입니다. 격리된 환경에서 애플리케이션이 실행됩니다.\n# 이미지 → 컨테이너 실행 docker run ubuntu:22.04 레지스트리 (Registry) Docker 이미지를 저장하는 저장소입니다.\nDocker Hub: 공식 퍼블릭 레지스트리 (hub.docker.com) Private Registry: 기업 내부용 (AWS ECR, Google GCR 등) 레이어 (Layer) Docker 이미지는 여러 읽기 전용 레이어로 구성됩니다.\nFROM ubuntu:22.04 # Layer 1: 기본 OS RUN apt-get update # Layer 2: 패키지 업데이트 RUN apt-get install -y # Layer 3: 패키지 설치 COPY app.jar /app/ # Layer 4: 애플리케이션 파일 각 레이어는 캐싱되어 빌드 속도를 높입니다.\n3. Docker 설치 및 기본 명령어 설치 확인 docker --version # Docker version 25.0.0, build ... docker run hello-world # Hello from Docker! ... 핵심 명령어 # 1. 이미지 검색 및 다운로드 docker search nginx # Docker Hub에서 이미지 검색 docker pull nginx:latest # 이미지 다운로드 # 2. 이미지 관리 docker images # 로컬 이미지 목록 docker rmi nginx:latest # 이미지 삭제 docker image prune # 사용하지 않는 이미지 정리 # 3. 컨테이너 실행 docker run nginx # 컨테이너 실행 (포그라운드) docker run -d nginx # 백그라운드 실행 (-d: detached) docker run -d -p 8080:80 nginx # 포트 매핑 (호스트:컨테이너) docker run -d --name my-nginx nginx # 컨테이너 이름 지정 docker run -d -e ENV=prod nginx # 환경변수 설정 # 4. 컨테이너 상태 확인 docker ps # 실행 중인 컨테이너 docker ps -a # 모든 컨테이너 (중지된 것 포함) # 5. 컨테이너 제어 docker stop my-nginx # 컨테이너 중지 docker start my-nginx # 컨테이너 시작 docker restart my-nginx # 컨테이너 재시작 docker rm my-nginx # 컨테이너 삭제 docker rm -f my-nginx # 강제 삭제 (실행 중이어도) # 6. 컨테이너 내부 접근 docker logs my-nginx # 로그 확인 docker logs -f my-nginx # 실시간 로그 (-f: follow) docker exec -it my-nginx bash # 컨테이너 내부 쉘 접속 docker exec my-nginx ls /app # 컨테이너 내부 명령 실행 # 7. 컨테이너 정보 확인 docker inspect my-nginx # 상세 정보 (JSON) docker stats # 리소스 사용량 (CPU, 메모리) 실전 예제 # MySQL 컨테이너 실행 docker run -d \\ --name my-mysql \\ -e MYSQL_ROOT_PASSWORD=root123 \\ -e MYSQL_DATABASE=myapp \\ -p 3306:3306 \\ mysql:8.0 # Redis 컨테이너 실행 docker run -d \\ --name my-redis \\ -p 6379:6379 \\ redis:7-alpine # 실행 확인 docker ps # MySQL 접속 docker exec -it my-mysql mysql -uroot -proot123 4. Dockerfile 작성법 Dockerfile 핵심 명령어 # 기본 이미지 지정 (필수, 맨 처음에 와야 함) FROM ubuntu:22.04 # 메타데이터 (선택) LABEL maintainer=\u0026#34;admin@example.com\u0026#34; LABEL version=\u0026#34;1.0\u0026#34; # 작업 디렉토리 설정 WORKDIR /app # 파일/디렉토리 복사 COPY package.json . # 파일 복사 COPY src/ ./src/ # 디렉토리 복사 ADD https://example.com/file . # URL에서 다운로드 (비권장) # 명령 실행 (이미지 빌드 시) RUN apt-get update \u0026amp;\u0026amp; \\ apt-get install -y curl \u0026amp;\u0026amp; \\ rm -rf /var/lib/apt/lists/* # 캐시 정리로 이미지 크기 최소화 # 환경변수 설정 ENV NODE_ENV=production ENV PORT=3000 # 포트 노출 (문서화 목적, 실제 포트 열림은 -p 옵션) EXPOSE 3000 # 컨테이너 시작 시 실행할 명령 (쉘 형식) CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] # ENTRYPOINT vs CMD ENTRYPOINT [\u0026#34;python\u0026#34;, \u0026#34;app.py\u0026#34;] # 고정 실행 명령 CMD [\u0026#34;--port\u0026#34;, \u0026#34;8000\u0026#34;] # 기본 파라미터 (오버라이드 가능) CMD vs ENTRYPOINT 차이 # CMD만 사용 (전체 교체 가능) FROM ubuntu CMD [\u0026#34;echo\u0026#34;, \u0026#34;Hello\u0026#34;] # docker run image → \u0026#34;Hello\u0026#34; # docker run image bye → \u0026#34;bye\u0026#34; (CMD 완전 교체) # ENTRYPOINT + CMD (파라미터만 교체) FROM ubuntu ENTRYPOINT [\u0026#34;echo\u0026#34;] CMD [\u0026#34;Hello\u0026#34;] # docker run image → \u0026#34;Hello\u0026#34; # docker run image bye → \u0026#34;bye\u0026#34; (CMD만 교체, echo는 유지) 5. Spring Boot 앱 Dockerfile (멀티스테이지 빌드) 기본 Dockerfile FROM eclipse-temurin:17-jdk-alpine WORKDIR /app # Gradle 캐싱 최적화 COPY build.gradle settings.gradle gradlew ./ COPY gradle ./gradle RUN ./gradlew dependencies --no-daemon # 소스코드 복사 및 빌드 COPY src ./src RUN ./gradlew bootJar --no-daemon # JAR 파일 실행 EXPOSE 8080 CMD [\u0026#34;java\u0026#34;, \u0026#34;-jar\u0026#34;, \u0026#34;build/libs/app.jar\u0026#34;] 멀티스테이지 빌드 (권장) 빌드 환경과 실행 환경을 분리하여 이미지 크기를 대폭 줄입니다.\n# Stage 1: 빌드 스테이지 FROM eclipse-temurin:17-jdk-alpine AS builder WORKDIR /app # Gradle Wrapper 및 의존성 캐싱 COPY build.gradle settings.gradle gradlew ./ COPY gradle ./gradle RUN ./gradlew dependencies --no-daemon # 소스 복사 및 빌드 COPY src ./src RUN ./gradlew bootJar --no-daemon # Stage 2: 실행 스테이지 (JRE만 포함) FROM eclipse-temurin:17-jre-alpine WORKDIR /app # 빌드 스테이지에서 JAR만 복사 COPY --from=builder /app/build/libs/*.jar app.jar # 보안: 루트가 아닌 사용자로 실행 RUN addgroup -S spring \u0026amp;\u0026amp; adduser -S spring -G spring USER spring:spring EXPOSE 8080 # 힙 메모리 설정 및 실행 ENV JAVA_OPTS=\u0026#34;-Xmx512m -Xms256m\u0026#34; ENTRYPOINT [\u0026#34;sh\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;java $JAVA_OPTS -jar app.jar\u0026#34;] 빌드 및 실행 # 이미지 빌드 docker build -t my-spring-app:1.0 . # 컨테이너 실행 docker run -d \\ --name spring-app \\ -p 8080:8080 \\ -e SPRING_PROFILES_ACTIVE=prod \\ -e SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/mydb \\ my-spring-app:1.0 # 로그 확인 docker logs -f spring-app 6. React 앱 Dockerfile (Nginx 서빙) 멀티스테이지 Dockerfile # Stage 1: 빌드 스테이지 FROM node:18-alpine AS builder WORKDIR /app # 의존성 설치 (캐싱 최적화) COPY package.json package-lock.json ./ RUN npm ci # 소스 복사 및 빌드 COPY . . RUN npm run build # Stage 2: Nginx 서빙 FROM nginx:1.25-alpine # Nginx 설정 복사 COPY nginx.conf /etc/nginx/conf.d/default.conf # 빌드 결과물만 복사 COPY --from=builder /app/build /usr/share/nginx/html EXPOSE 80 CMD [\u0026#34;nginx\u0026#34;, \u0026#34;-g\u0026#34;, \u0026#34;daemon off;\u0026#34;] Nginx 설정 파일 (nginx.conf) server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; # SPA 라우팅 지원 (React Router 등) location / { try_files $uri $uri/ /index.html; } # API 프록시 (선택) location /api { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 정적 파일 캐싱 location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control \u0026#34;public, immutable\u0026#34;; } # gzip 압축 gzip on; gzip_types text/plain text/css application/json application/javascript; } 빌드 및 실행 # 이미지 빌드 docker build -t my-react-app:1.0 . # 컨테이너 실행 docker run -d \\ --name react-app \\ -p 3000:80 \\ my-react-app:1.0 # 브라우저에서 http://localhost:3000 접속 7. Docker Compose - 여러 컨테이너 관리 Docker Compose란? 여러 컨테이너를 YAML 파일 하나로 정의하고 관리하는 도구입니다.\nSpring Boot + MySQL + Redis 예제 디렉토리 구조\nproject/ ├── docker-compose.yml ├── backend/ │ └── Dockerfile ├── init.sql └── .env docker-compose.yml\nversion: \u0026#39;3.8\u0026#39; services: # MySQL 데이터베이스 mysql: image: mysql:8.0 container_name: mysql-db restart: always environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: myapp MYSQL_USER: user MYSQL_PASSWORD: ${MYSQL_PASSWORD} ports: - \u0026#34;3306:3306\u0026#34; volumes: - mysql-data:/var/lib/mysql - ./init.sql:/docker-entrypoint-initdb.d/init.sql networks: - app-network healthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;mysqladmin\u0026#34;, \u0026#34;ping\u0026#34;, \u0026#34;-h\u0026#34;, \u0026#34;localhost\u0026#34;] interval: 10s timeout: 5s retries: 5 # Redis 캐시 redis: image: redis:7-alpine container_name: redis-cache restart: always ports: - \u0026#34;6379:6379\u0026#34; volumes: - redis-data:/data networks: - app-network command: redis-server --appendonly yes # Spring Boot 백엔드 backend: build: context: ./backend dockerfile: Dockerfile container_name: spring-backend restart: always ports: - \u0026#34;8080:8080\u0026#34; environment: SPRING_PROFILES_ACTIVE: prod SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/myapp SPRING_DATASOURCE_USERNAME: user SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD} SPRING_REDIS_HOST: redis SPRING_REDIS_PORT: 6379 depends_on: mysql: condition: service_healthy redis: condition: service_started networks: - app-network # 볼륨 정의 volumes: mysql-data: driver: local redis-data: driver: local # 네트워크 정의 networks: app-network: driver: bridge .env 파일\nMYSQL_ROOT_PASSWORD=root123 MYSQL_PASSWORD=user123 Docker Compose 명령어 # 전체 서비스 시작 (백그라운드) docker-compose up -d # 빌드 후 시작 docker-compose up -d --build # 로그 확인 docker-compose logs -f # 전체 로그 docker-compose logs -f backend # 특정 서비스 로그 # 서비스 상태 확인 docker-compose ps # 특정 서비스 재시작 docker-compose restart backend # 전체 중지 docker-compose stop # 전체 중지 및 삭제 (볼륨 유지) docker-compose down # 전체 삭제 (볼륨 포함) docker-compose down -v # 특정 서비스만 실행 docker-compose up -d mysql redis 8. 볼륨과 네트워크 기초 볼륨 (Volume) 컨테이너가 삭제되어도 데이터를 유지하기 위한 저장소입니다.\n# 볼륨 생성 docker volume create my-data # 볼륨 목록 docker volume ls # 볼륨 사용 docker run -d \\ --name mysql \\ -v my-data:/var/lib/mysql \\ mysql:8.0 # 호스트 디렉토리 마운트 (바인드 마운트) docker run -d \\ --name web \\ -v /host/path:/container/path \\ nginx # 읽기 전용 마운트 docker run -d -v /host/config:/app/config:ro nginx 볼륨 vs 바인드 마운트\n구분 볼륨 바인드 마운트 위치 Docker 관리 영역 호스트 임의 경로 관리 Docker CLI로 관리 직접 관리 이식성 높음 낮음 (경로 의존) 용도 데이터베이스, 영구 데이터 개발 중 코드 동기화 네트워크 (Network) 컨테이너 간 통신을 위한 가상 네트워크입니다.\n# 네트워크 생성 docker network create my-network # 네트워크 목록 docker network ls # 네트워크에 컨테이너 연결 docker run -d --name mysql --network my-network mysql:8.0 docker run -d --name backend --network my-network my-app # 같은 네트워크 내에서 컨테이너 이름으로 접근 가능 # backend 컨테이너에서: jdbc:mysql://mysql:3306/mydb 네트워크 드라이버\nbridge (기본): 단일 호스트, 컨테이너 간 통신 host: 호스트 네트워크 직접 사용 (포트 매핑 불필요) overlay: 여러 Docker 호스트 간 통신 (Swarm) none: 네트워크 없음 (완전 격리) 9. Docker 이미지 최적화 팁 .dockerignore 사용 빌드 컨텍스트에서 불필요한 파일을 제외합니다.\n# .dockerignore node_modules npm-debug.log .git .gitignore README.md .env .DS_Store build dist coverage .vscode .idea *.log 레이어 캐싱 활용 변경이 적은 명령을 앞쪽에 배치합니다.\n# ❌ 비효율적 (소스 변경 시 의존성 재설치) FROM node:18-alpine COPY . . RUN npm install RUN npm run build # ✅ 효율적 (의존성 캐싱) FROM node:18-alpine COPY package*.json ./ RUN npm ci # package.json 변경 시에만 재실행 COPY . . RUN npm run build # 소스 변경 시에만 재실행 이미지 크기 줄이기 # 1. Alpine 기반 이미지 사용 FROM node:18-alpine # 18-alpine (40MB) vs 18 (300MB) # 2. 멀티스테이지 빌드 FROM node:18 AS builder RUN npm install \u0026amp;\u0026amp; npm run build FROM node:18-alpine COPY --from=builder /app/build ./build # 빌드 결과만 복사 # 3. RUN 명령 체이닝 (레이어 최소화) RUN apt-get update \u0026amp;\u0026amp; \\ apt-get install -y curl \u0026amp;\u0026amp; \\ rm -rf /var/lib/apt/lists/* # 캐시 정리 # 4. 불필요한 파일 제거 RUN npm ci --only=production \u0026amp;\u0026amp; \\ npm cache clean --force # 5. .dockerignore 적극 활용 보안 강화 # 1. 최신 베이스 이미지 사용 FROM node:18-alpine # 취약점 패치된 최신 버전 # 2. 루트 사용자 회피 RUN addgroup -S appgroup \u0026amp;\u0026amp; adduser -S appuser -G appgroup USER appuser # 3. 읽기 전용 파일시스템 (가능한 경우) docker run --read-only -v /tmp my-app # 4. 비밀정보 ARG 사용 (빌드 시에만) ARG API_KEY RUN curl -H \u0026#34;Authorization: $API_KEY\u0026#34; ... # 환경변수 ENV는 이미지에 남으므로 비밀정보 부적합 이미지 크기 비교 # 이미지 크기 확인 docker images # 레이어 확인 docker history my-app:1.0 # 이미지 분석 (dive 도구) dive my-app:1.0 마무리 Docker 핵심 개념 이미지: 읽기 전용 템플릿 컨테이너: 이미지의 실행 인스턴스 레지스트리: 이미지 저장소 레이어: 이미지를 구성하는 읽기 전용 층 자주 쓰는 명령어 docker pull \u0026lt;image\u0026gt; # 이미지 다운로드 docker run -d -p 8080:80 \u0026lt;image\u0026gt; # 컨테이너 실행 docker ps # 실행 중인 컨테이너 docker logs -f \u0026lt;container\u0026gt; # 로그 확인 docker exec -it \u0026lt;container\u0026gt; bash # 내부 접속 docker-compose up -d # Compose 서비스 시작 실전 팁 개발 환경: 바인드 마운트로 코드 동기화 운영 환경: 볼륨으로 데이터 영구 저장 이미지 빌드: 멀티스테이지 + .dockerignore 보안: 루트 사용자 회피, 최신 이미지 사용 디버깅: docker logs, docker exec, docker inspect 다음 단계 Kubernetes: 컨테이너 오케스트레이션 CI/CD: GitHub Actions + Docker 자동 배포 모니터링: Prometheus + Grafana 보안: Trivy로 이미지 취약점 스캔 Docker는 현대 개발/배포의 필수 도구입니다. 직접 Dockerfile을 작성하고 컨테이너를 실행하며 감을 익혀보세요!\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/docker-basics/","summary":"\u003ch2 id=\"1-docker란---컨테이너-vs-vm\"\u003e1. Docker란? - 컨테이너 vs VM\u003c/h2\u003e\n\u003ch3 id=\"docker가-등장한-이유\"\u003eDocker가 등장한 이유\u003c/h3\u003e\n\u003cp\u003e\u0026quot;내 컴퓨터에선 잘 되는데요?\u0026quot; - 개발자라면 한 번쯤 들어본 말입니다. 개발 환경과 운영 환경의 차이로 인한 문제를 해결하기 위해 Docker가 등장했습니다.\u003c/p\u003e\n\u003ch3 id=\"컨테이너-vs-가상머신-vm\"\u003e컨테이너 vs 가상머신 (VM)\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[ VM 구조 ]                    [ Container 구조 ]\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│   App A      │              │   App A      │\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│  Guest OS    │              │  Libraries   │\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│  Hypervisor  │              │ Docker Engine│\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│   Host OS    │              │   Host OS    │\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\u003cstrong\u003e가상머신 (VM)\u003c/strong\u003e\u003c/p\u003e","tags":["Docker","컨테이너","DevOps","배포"],"title":"Docker 시작하기 - Dockerfile, Compose, 멀티스테이지 빌드"},{"content":"네트워크 기초 네트워크 프로그래밍에서 가장 중요한 선택 중 하나는 전송 프로토콜입니다. TCP와 UDP의 차이를 이해하는 것이 첫 번째 단계입니다.\nTCP vs UDP TCP (Transmission Control Protocol)\n연결 지향 프로토콜: 3-way handshake로 연결 수립 신뢰성 보장: 패킷 순서 보장, 재전송 메커니즘 흐름 제어와 혼잡 제어 용도: HTTP, 파일 전송, 데이터베이스 연결 UDP (User Datagram Protocol)\n비연결 프로토콜: 연결 수립 없이 즉시 전송 신뢰성 미보장: 패킷 손실 가능, 순서 보장 없음 낮은 오버헤드, 빠른 전송 속도 용도: DNS, 스트리밍, 온라인 게임 이 포스트에서는 신뢰성 있는 통신이 필요한 대부분의 애플리케이션에 적합한 TCP를 중심으로 설명합니다.\nJava 소켓 기본 Java는 java.net 패키지를 통해 소켓 프로그래밍을 지원합니다.\nServerSocket과 Socket // 서버 측: 포트에서 연결 대기 ServerSocket serverSocket = new ServerSocket(8080); Socket clientSocket = serverSocket.accept(); // 블로킹 // 클라이언트 측: 서버에 연결 Socket socket = new Socket(\u0026#34;localhost\u0026#34;, 8080); accept() 메서드는 클라이언트 연결이 들어올 때까지 블로킹됩니다. 연결이 수립되면 양방향 통신이 가능한 Socket 객체를 반환합니다.\nTCP 클라이언트-서버 구현 기본적인 에코 서버와 클라이언트를 구현해보겠습니다.\n에코 서버 public class EchoServer { private static final int PORT = 8080; public static void main(String[] args) { try (ServerSocket serverSocket = new ServerSocket(PORT)) { System.out.println(\u0026#34;서버가 포트 \u0026#34; + PORT + \u0026#34;에서 시작되었습니다.\u0026#34;); while (true) { Socket clientSocket = serverSocket.accept(); System.out.println(\u0026#34;클라이언트 연결: \u0026#34; + clientSocket.getRemoteSocketAddress()); // 각 클라이언트를 별도 스레드에서 처리 new Thread(() -\u0026gt; handleClient(clientSocket)).start(); } } catch (IOException e) { e.printStackTrace(); } } private static void handleClient(Socket socket) { try ( BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream()) ); PrintWriter out = new PrintWriter( socket.getOutputStream(), true ) ) { String line; while ((line = in.readLine()) != null) { System.out.println(\u0026#34;수신: \u0026#34; + line); out.println(\u0026#34;Echo: \u0026#34; + line); } } catch (IOException e) { System.err.println(\u0026#34;클라이언트 처리 오류: \u0026#34; + e.getMessage()); } finally { try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } } 에코 클라이언트 public class EchoClient { private static final String HOST = \u0026#34;localhost\u0026#34;; private static final int PORT = 8080; public static void main(String[] args) { try ( Socket socket = new Socket(HOST, PORT); BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream()) ); PrintWriter out = new PrintWriter( socket.getOutputStream(), true ); BufferedReader userInput = new BufferedReader( new InputStreamReader(System.in) ) ) { System.out.println(\u0026#34;서버에 연결되었습니다.\u0026#34;); String line; while ((line = userInput.readLine()) != null) { out.println(line); String response = in.readLine(); System.out.println(\u0026#34;응답: \u0026#34; + response); } } catch (IOException e) { e.printStackTrace(); } } } InputStream과 OutputStream으로 데이터 교환 소켓 통신의 핵심은 스트림을 통한 데이터 교환입니다.\n바이너리 데이터 전송 // 서버: 바이너리 데이터 수신 DataInputStream dis = new DataInputStream(socket.getInputStream()); int length = dis.readInt(); byte[] data = new byte[length]; dis.readFully(data); // 클라이언트: 바이너리 데이터 전송 byte[] data = \u0026#34;Hello\u0026#34;.getBytes(StandardCharsets.UTF_8); DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); dos.writeInt(data.length); dos.write(data); dos.flush(); 텍스트 데이터 전송 // BufferedReader/PrintWriter 사용 (줄 단위 처리) BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8) ); PrintWriter writer = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true // autoFlush ); writer.println(\u0026#34;Hello Server\u0026#34;); String response = reader.readLine(); JSON 기반 메시지 프로토콜 설계 실전에서는 구조화된 데이터를 주고받아야 합니다. JSON을 사용한 프로토콜을 설계해보겠습니다.\nLength-Prefixed Binary Framing 메시지 경계를 명확히 하기 위해 길이 접두사를 사용합니다.\n[4 bytes: message length][N bytes: JSON message] public class MessageProtocol { // 메시지 전송 public static void sendMessage(OutputStream out, String jsonMessage) throws IOException { byte[] data = jsonMessage.getBytes(StandardCharsets.UTF_8); DataOutputStream dos = new DataOutputStream(out); // 1. 길이 전송 (4 bytes) dos.writeInt(data.length); // 2. 데이터 전송 dos.write(data); dos.flush(); } // 메시지 수신 public static String receiveMessage(InputStream in) throws IOException { DataInputStream dis = new DataInputStream(in); // 1. 길이 읽기 int length = dis.readInt(); // 2. 데이터 읽기 byte[] data = new byte[length]; dis.readFully(data); return new String(data, StandardCharsets.UTF_8); } } JSON 메시지 구조 public class Message { private String type; // \u0026#34;command\u0026#34;, \u0026#34;response\u0026#34;, \u0026#34;heartbeat\u0026#34; private String action; // \u0026#34;execute\u0026#34;, \u0026#34;status\u0026#34;, \u0026#34;ping\u0026#34; private Map\u0026lt;String, Object\u0026gt; payload; private long timestamp; // Getters, Setters, Constructors } // Jackson 라이브러리 사용 ObjectMapper mapper = new ObjectMapper(); // 직렬화 Message msg = new Message(\u0026#34;command\u0026#34;, \u0026#34;execute\u0026#34;, Map.of(\u0026#34;cmd\u0026#34;, \u0026#34;ls -la\u0026#34;), System.currentTimeMillis()); String json = mapper.writeValueAsString(msg); // 역직렬화 Message received = mapper.readValue(json, Message.class); 멀티 클라이언트 처리 서버가 여러 클라이언트를 동시에 처리하려면 스레드풀을 활용합니다.\nExecutorService를 사용한 스레드풀 public class ThreadPoolServer { private static final int PORT = 8080; private static final int THREAD_POOL_SIZE = 10; public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE); try (ServerSocket serverSocket = new ServerSocket(PORT)) { System.out.println(\u0026#34;서버 시작 (스레드풀 크기: \u0026#34; + THREAD_POOL_SIZE + \u0026#34;)\u0026#34;); while (true) { Socket clientSocket = serverSocket.accept(); executor.submit(new ClientHandler(clientSocket)); } } catch (IOException e) { e.printStackTrace(); } finally { executor.shutdown(); } } static class ClientHandler implements Runnable { private final Socket socket; public ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { try (socket) { handleClient(socket); } catch (IOException e) { System.err.println(\u0026#34;클라이언트 처리 오류: \u0026#34; + e.getMessage()); } } private void handleClient(Socket socket) throws IOException { ObjectMapper mapper = new ObjectMapper(); while (!socket.isClosed()) { String json = MessageProtocol.receiveMessage(socket.getInputStream()); Message msg = mapper.readValue(json, Message.class); // 메시지 처리 Message response = processMessage(msg); String responseJson = mapper.writeValueAsString(response); MessageProtocol.sendMessage(socket.getOutputStream(), responseJson); } } private Message processMessage(Message msg) { // 비즈니스 로직 return new Message(\u0026#34;response\u0026#34;, \u0026#34;ack\u0026#34;, Map.of(\u0026#34;status\u0026#34;, \u0026#34;ok\u0026#34;), System.currentTimeMillis()); } } } 동시성 제어 // 클라이언트 목록 관리 private static final Set\u0026lt;Socket\u0026gt; clients = Collections.synchronizedSet(new HashSet\u0026lt;\u0026gt;()); // 연결 시 clients.add(clientSocket); // 연결 종료 시 clients.remove(clientSocket); // 브로드캐스트 public static void broadcast(String message) { synchronized (clients) { for (Socket client : clients) { try { MessageProtocol.sendMessage(client.getOutputStream(), message); } catch (IOException e) { clients.remove(client); } } } } 하트비트 메커니즘 연결이 살아있는지 확인하기 위해 주기적으로 하트비트를 전송합니다.\n서버 측 하트비트 구현 public class HeartbeatServer { private static final long HEARTBEAT_INTERVAL = 60_000; // 60초 private static final long HEARTBEAT_TIMEOUT = 180_000; // 3분 static class ClientSession { private final Socket socket; private volatile long lastHeartbeat; private final ScheduledExecutorService scheduler; public ClientSession(Socket socket) { this.socket = socket; this.lastHeartbeat = System.currentTimeMillis(); this.scheduler = Executors.newSingleThreadScheduledExecutor(); // 하트비트 전송 작업 scheduler.scheduleAtFixedRate( this::sendHeartbeat, HEARTBEAT_INTERVAL, HEARTBEAT_INTERVAL, TimeUnit.MILLISECONDS ); // 타임아웃 체크 작업 scheduler.scheduleAtFixedRate( this::checkTimeout, HEARTBEAT_TIMEOUT, HEARTBEAT_TIMEOUT, TimeUnit.MILLISECONDS ); } private void sendHeartbeat() { try { Message heartbeat = new Message( \u0026#34;heartbeat\u0026#34;, \u0026#34;ping\u0026#34;, Map.of(), System.currentTimeMillis() ); String json = new ObjectMapper().writeValueAsString(heartbeat); MessageProtocol.sendMessage(socket.getOutputStream(), json); } catch (IOException e) { System.err.println(\u0026#34;하트비트 전송 실패: \u0026#34; + e.getMessage()); close(); } } public void onHeartbeatReceived() { this.lastHeartbeat = System.currentTimeMillis(); } private void checkTimeout() { long elapsed = System.currentTimeMillis() - lastHeartbeat; if (elapsed \u0026gt; HEARTBEAT_TIMEOUT) { System.out.println(\u0026#34;하트비트 타임아웃: \u0026#34; + elapsed + \u0026#34;ms\u0026#34;); close(); } } public void close() { scheduler.shutdown(); try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } } 클라이언트 측 하트비트 응답 public class HeartbeatClient { private void handleMessage(Message msg) { if (\u0026#34;heartbeat\u0026#34;.equals(msg.getType()) \u0026amp;\u0026amp; \u0026#34;ping\u0026#34;.equals(msg.getAction())) { // 하트비트에 응답 Message pong = new Message( \u0026#34;heartbeat\u0026#34;, \u0026#34;pong\u0026#34;, Map.of(), System.currentTimeMillis() ); sendMessage(pong); } } } 재연결 전략 네트워크 오류 시 자동으로 재연결하는 전략을 구현합니다.\n지수 백오프 (Exponential Backoff) public class ReconnectClient { private static final String HOST = \u0026#34;localhost\u0026#34;; private static final int PORT = 8080; private static final int[] BACKOFF_INTERVALS = {5, 10, 60}; // 초 private int reconnectAttempt = 0; private Socket socket; private volatile boolean running = true; public void start() { while (running) { try { connect(); reconnectAttempt = 0; // 연결 성공 시 리셋 handleConnection(); } catch (IOException e) { System.err.println(\u0026#34;연결 오류: \u0026#34; + e.getMessage()); reconnect(); } } } private void connect() throws IOException { System.out.println(\u0026#34;서버에 연결 중...\u0026#34;); socket = new Socket(HOST, PORT); System.out.println(\u0026#34;연결 성공: \u0026#34; + socket.getRemoteSocketAddress()); } private void reconnect() { int delay = getBackoffDelay(); System.out.println(delay + \u0026#34;초 후 재연결 시도...\u0026#34;); try { Thread.sleep(delay * 1000L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } reconnectAttempt++; } private int getBackoffDelay() { int index = Math.min(reconnectAttempt, BACKOFF_INTERVALS.length - 1); return BACKOFF_INTERVALS[index]; } private void handleConnection() throws IOException { try ( BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream()) ); PrintWriter out = new PrintWriter( socket.getOutputStream(), true ) ) { String line; while ((line = in.readLine()) != null) { processMessage(line); } } } private void processMessage(String message) { System.out.println(\u0026#34;수신: \u0026#34; + message); } public void stop() { running = false; try { if (socket != null) { socket.close(); } } catch (IOException e) { e.printStackTrace(); } } } BufferedReader와 BufferedWriter 성능 최적화 버퍼링을 통해 I/O 성능을 크게 향상시킬 수 있습니다.\n버퍼 크기 조정 // 기본 버퍼 크기: 8192 bytes BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream()) ); // 커스텀 버퍼 크기: 16KB (대용량 데이터에 유리) BufferedReader readerLarge = new BufferedReader( new InputStreamReader(socket.getInputStream()), 16 * 1024 ); // 버퍼 크기가 클수록 시스템 콜 횟수 감소 BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(socket.getOutputStream()), 16 * 1024 ); 명시적 플러시 PrintWriter out = new PrintWriter( socket.getOutputStream(), true // autoFlush = true (println 시 자동 플러시) ); // autoFlush가 false일 때는 명시적으로 flush 필요 PrintWriter outManual = new PrintWriter(socket.getOutputStream()); outManual.println(\u0026#34;Message 1\u0026#34;); outManual.println(\u0026#34;Message 2\u0026#34;); outManual.flush(); // 버퍼에 쌓인 데이터를 한 번에 전송 성능 측정 public class PerformanceTest { public static void main(String[] args) throws IOException { Socket socket = new Socket(\u0026#34;localhost\u0026#34;, 8080); // 버퍼 없이 전송 long start1 = System.currentTimeMillis(); OutputStream rawOut = socket.getOutputStream(); for (int i = 0; i \u0026lt; 10000; i++) { rawOut.write((\u0026#34;Message \u0026#34; + i + \u0026#34;\\n\u0026#34;).getBytes()); } long time1 = System.currentTimeMillis() - start1; System.out.println(\u0026#34;버퍼 없이: \u0026#34; + time1 + \u0026#34;ms\u0026#34;); // 버퍼 사용 long start2 = System.currentTimeMillis(); BufferedWriter buffered = new BufferedWriter( new OutputStreamWriter(socket.getOutputStream()) ); for (int i = 0; i \u0026lt; 10000; i++) { buffered.write(\u0026#34;Message \u0026#34; + i + \u0026#34;\\n\u0026#34;); } buffered.flush(); long time2 = System.currentTimeMillis() - start2; System.out.println(\u0026#34;버퍼 사용: \u0026#34; + time2 + \u0026#34;ms\u0026#34;); socket.close(); } } 실전 예시: 에이전트-콘솔 간 TCP 소켓 통신 실제 프로젝트에서 사용할 수 있는 에이전트 시스템을 구현해보겠습니다.\n메시지 프로토콜 정의 public class AgentMessage { public enum Type { REGISTER, // 에이전트 등록 COMMAND, // 명령 실행 RESPONSE, // 명령 응답 STATUS_UPDATE, // 상태 업데이트 HEARTBEAT // 하트비트 } private Type type; private String agentId; private String action; private Map\u0026lt;String, Object\u0026gt; data; private long timestamp; // Constructors, Getters, Setters public static AgentMessage command(String agentId, String action, Map\u0026lt;String, Object\u0026gt; data) { AgentMessage msg = new AgentMessage(); msg.setType(Type.COMMAND); msg.setAgentId(agentId); msg.setAction(action); msg.setData(data); msg.setTimestamp(System.currentTimeMillis()); return msg; } public static AgentMessage response(String agentId, Map\u0026lt;String, Object\u0026gt; data) { AgentMessage msg = new AgentMessage(); msg.setType(Type.RESPONSE); msg.setAgentId(agentId); msg.setData(data); msg.setTimestamp(System.currentTimeMillis()); return msg; } } 콘솔 서버 구현 public class ConsoleServer { private static final int PORT = 9999; private final Map\u0026lt;String, AgentConnection\u0026gt; agents = new ConcurrentHashMap\u0026lt;\u0026gt;(); private final ObjectMapper mapper = new ObjectMapper(); private final ExecutorService executor = Executors.newCachedThreadPool(); public void start() throws IOException { try (ServerSocket serverSocket = new ServerSocket(PORT)) { System.out.println(\u0026#34;콘솔 서버 시작: 포트 \u0026#34; + PORT); while (true) { Socket socket = serverSocket.accept(); executor.submit(() -\u0026gt; handleAgent(socket)); } } } private void handleAgent(Socket socket) { try { AgentConnection conn = new AgentConnection(socket); // 등록 메시지 대기 String json = MessageProtocol.receiveMessage(socket.getInputStream()); AgentMessage registerMsg = mapper.readValue(json, AgentMessage.class); if (registerMsg.getType() == AgentMessage.Type.REGISTER) { String agentId = registerMsg.getAgentId(); agents.put(agentId, conn); System.out.println(\u0026#34;에이전트 등록: \u0026#34; + agentId); // ACK 전송 AgentMessage ack = AgentMessage.response(agentId, Map.of(\u0026#34;status\u0026#34;, \u0026#34;registered\u0026#34;)); sendMessage(socket, ack); // 메시지 처리 루프 processMessages(socket, agentId); } } catch (IOException e) { System.err.println(\u0026#34;에이전트 처리 오류: \u0026#34; + e.getMessage()); } } private void processMessages(Socket socket, String agentId) throws IOException { while (!socket.isClosed()) { String json = MessageProtocol.receiveMessage(socket.getInputStream()); AgentMessage msg = mapper.readValue(json, AgentMessage.class); switch (msg.getType()) { case STATUS_UPDATE: handleStatusUpdate(agentId, msg); break; case RESPONSE: handleResponse(agentId, msg); break; case HEARTBEAT: agents.get(agentId).updateHeartbeat(); break; default: System.out.println(\u0026#34;알 수 없는 메시지: \u0026#34; + msg.getType()); } } agents.remove(agentId); System.out.println(\u0026#34;에이전트 연결 종료: \u0026#34; + agentId); } private void sendMessage(Socket socket, AgentMessage msg) throws IOException { String json = mapper.writeValueAsString(msg); MessageProtocol.sendMessage(socket.getOutputStream(), json); } public void sendCommand(String agentId, String action, Map\u0026lt;String, Object\u0026gt; data) { AgentConnection conn = agents.get(agentId); if (conn != null) { try { AgentMessage cmd = AgentMessage.command(agentId, action, data); sendMessage(conn.socket, cmd); } catch (IOException e) { System.err.println(\u0026#34;명령 전송 실패: \u0026#34; + e.getMessage()); } } } private void handleStatusUpdate(String agentId, AgentMessage msg) { System.out.println(\u0026#34;상태 업데이트 [\u0026#34; + agentId + \u0026#34;]: \u0026#34; + msg.getData()); } private void handleResponse(String agentId, AgentMessage msg) { System.out.println(\u0026#34;응답 [\u0026#34; + agentId + \u0026#34;]: \u0026#34; + msg.getData()); } static class AgentConnection { final Socket socket; volatile long lastHeartbeat; AgentConnection(Socket socket) { this.socket = socket; this.lastHeartbeat = System.currentTimeMillis(); } void updateHeartbeat() { this.lastHeartbeat = System.currentTimeMillis(); } } } 에이전트 클라이언트 구현 public class Agent { private final String agentId; private final String host; private final int port; private Socket socket; private final ObjectMapper mapper = new ObjectMapper(); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); public Agent(String agentId, String host, int port) { this.agentId = agentId; this.host = host; this.port = port; } public void start() throws IOException { connect(); register(); startHeartbeat(); listenForCommands(); } private void connect() throws IOException { socket = new Socket(host, port); System.out.println(\u0026#34;서버에 연결됨: \u0026#34; + socket.getRemoteSocketAddress()); } private void register() throws IOException { AgentMessage registerMsg = new AgentMessage(); registerMsg.setType(AgentMessage.Type.REGISTER); registerMsg.setAgentId(agentId); registerMsg.setData(Map.of( \u0026#34;hostname\u0026#34;, InetAddress.getLocalHost().getHostName(), \u0026#34;os\u0026#34;, System.getProperty(\u0026#34;os.name\u0026#34;) )); sendMessage(registerMsg); // ACK 대기 String json = MessageProtocol.receiveMessage(socket.getInputStream()); AgentMessage ack = mapper.readValue(json, AgentMessage.class); System.out.println(\u0026#34;등록 완료: \u0026#34; + ack.getData()); } private void startHeartbeat() { scheduler.scheduleAtFixedRate(() -\u0026gt; { try { AgentMessage heartbeat = new AgentMessage(); heartbeat.setType(AgentMessage.Type.HEARTBEAT); heartbeat.setAgentId(agentId); sendMessage(heartbeat); } catch (IOException e) { System.err.println(\u0026#34;하트비트 전송 실패: \u0026#34; + e.getMessage()); } }, 60, 60, TimeUnit.SECONDS); } private void listenForCommands() throws IOException { while (!socket.isClosed()) { String json = MessageProtocol.receiveMessage(socket.getInputStream()); AgentMessage msg = mapper.readValue(json, AgentMessage.class); if (msg.getType() == AgentMessage.Type.COMMAND) { handleCommand(msg); } } } private void handleCommand(AgentMessage cmd) { String action = cmd.getAction(); System.out.println(\u0026#34;명령 수신: \u0026#34; + action); // 명령 처리 Map\u0026lt;String, Object\u0026gt; result = executeAction(action, cmd.getData()); // 응답 전송 AgentMessage response = AgentMessage.response(agentId, result); try { sendMessage(response); } catch (IOException e) { System.err.println(\u0026#34;응답 전송 실패: \u0026#34; + e.getMessage()); } } private Map\u0026lt;String, Object\u0026gt; executeAction(String action, Map\u0026lt;String, Object\u0026gt; data) { switch (action) { case \u0026#34;system_info\u0026#34;: return Map.of( \u0026#34;cpu_count\u0026#34;, Runtime.getRuntime().availableProcessors(), \u0026#34;memory_total\u0026#34;, Runtime.getRuntime().totalMemory(), \u0026#34;memory_free\u0026#34;, Runtime.getRuntime().freeMemory() ); case \u0026#34;execute_command\u0026#34;: String cmd = (String) data.get(\u0026#34;command\u0026#34;); return executeCommand(cmd); default: return Map.of(\u0026#34;error\u0026#34;, \u0026#34;Unknown action: \u0026#34; + action); } } private Map\u0026lt;String, Object\u0026gt; executeCommand(String command) { try { Process process = Runtime.getRuntime().exec(command); int exitCode = process.waitFor(); String output = new String(process.getInputStream().readAllBytes()); String error = new String(process.getErrorStream().readAllBytes()); return Map.of( \u0026#34;exit_code\u0026#34;, exitCode, \u0026#34;output\u0026#34;, output, \u0026#34;error\u0026#34;, error ); } catch (IOException | InterruptedException e) { return Map.of(\u0026#34;error\u0026#34;, e.getMessage()); } } private void sendMessage(AgentMessage msg) throws IOException { msg.setTimestamp(System.currentTimeMillis()); String json = mapper.writeValueAsString(msg); MessageProtocol.sendMessage(socket.getOutputStream(), json); } public void stop() { scheduler.shutdown(); try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } 실행 예시 public class Main { public static void main(String[] args) throws Exception { // 서버 시작 Thread serverThread = new Thread(() -\u0026gt; { try { ConsoleServer server = new ConsoleServer(); server.start(); } catch (IOException e) { e.printStackTrace(); } }); serverThread.start(); Thread.sleep(1000); // 에이전트 시작 Agent agent = new Agent(\u0026#34;agent-001\u0026#34;, \u0026#34;localhost\u0026#34;, 9999); Thread agentThread = new Thread(() -\u0026gt; { try { agent.start(); } catch (IOException e) { e.printStackTrace(); } }); agentThread.start(); // 명령 전송 테스트는 서버 측에서 수동으로 수행 } } TCP 통신에서 블로킹 문제와 해결 실제 에이전트 시스템에서 흔히 겪는 문제가 있습니다. 에이전트가 긴 작업을 처리하는 동안 서버의 PING에 응답하지 못하는 것입니다.\n문제 상황 Agent → 긴 작업 처리 중... (30초) Console → PING 전송 Agent → 응답 못함 (긴 작업에 블로킹) readLine()으로 메시지를 수신하는 스레드가 직접 긴 작업을 처리하면, 작업이 끝날 때까지 다음 메시지를 읽지 못합니다.\n해결: Agent 내부 스레드 분리 TCP 수신 스레드와 작업 처리 스레드를 분리하고, LinkedBlockingQueue로 연결합니다.\npublic class Agent { private final BlockingQueue\u0026lt;String\u0026gt; commandQueue = new LinkedBlockingQueue\u0026lt;\u0026gt;(); private final ExecutorService worker = Executors.newSingleThreadExecutor(); public void start() throws Exception { Socket socket = new Socket(\u0026#34;서버IP\u0026#34;, 9091); BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); // 워커 스레드: 긴 작업 처리 worker.submit(() -\u0026gt; { while (true) { String cmd = commandQueue.take(); doLongTask(cmd); out.println(\u0026#34;ACK: \u0026#34; + cmd); } }); // 메인 스레드: TCP 수신 (블로킹 안됨) String line; while ((line = in.readLine()) != null) { if (\u0026#34;PING\u0026#34;.equals(line)) { out.println(\u0026#34;PONG\u0026#34;); // 즉시 응답 } else { commandQueue.put(line); // 큐에만 넣고 리턴 } } } } 실행 흐름 TCP 수신 (메인 스레드) ├── PING → 즉시 PONG ├── 명령 → 큐에만 넣고 리턴 └── 기타 → 즉시 처리 워커 스레드 (별도) └── 큐에서 명령 꺼내서 긴 작업 처리 핵심은 submit() 호출 시점에 새 스레드가 분기되어 메인 스레드와 병렬로 실행된다는 것입니다.\n시간 → 메인 스레드: [start()]─[submit()]─[readLine 대기]─[PING→PONG]─[put(cmd)] ↓ 워커 스레드: [생성]────[take() 대기]──────────────[꺼냄]─[doLongTask()] 긴 작업이 여러 개 동시에 들어온다면 newFixedThreadPool(N)으로 워커 수를 늘릴 수 있습니다.\nSpring Boot TCP 중계 시스템 구조 실무에서 Console-Server-Agent 구조로 TCP 통신을 설계할 때의 아키텍처 패턴입니다.\n시스템 구조 Console (명령 발신) ↓ TCP Spring Boot Server ├── PostgreSQL (명령 큐 + 이력) ├── TCP → Agent 1 └── TCP → Agent 2 Console이 직접 Agent와 통신하지 않고, Spring Boot Server가 중계 Agent는 DB 연결 불필요 (TCP 통신만 담당) DB 관련 작업(명령 저장, 이력 관리)은 Spring Boot 서버가 전담 명령 유실 방지가 필요한 경우 TCP만으로는 서버나 Agent 재시작 시 처리 중이던 명령이 유실될 수 있습니다. 이런 경우 DB 기반 명령 큐를 도입합니다.\n상황 설명 서버/Agent 재시작 시 명령 유실 방지 Agent 재시작 후 PENDING 명령 재전송 명령 처리 순서 보장 재시작해도 순서 유지 감사 로그 / 이력 관리 언제 누가 어떤 명령을 내렸는지 추적 여러 서버 동시 처리 SELECT ... FOR UPDATE SKIP LOCKED로 중복 실행 방지 PostgreSQL을 메시지 큐처럼 사용하는 대표적인 방식:\n방식 장점 단점 추천 상황 PGMQ 전용 큐 기능, visibility timeout PostgreSQL 확장 설치 필요 전문적인 MQ 기능 필요 시 LISTEN/NOTIFY 실시간, 내장 기능 메시지 유실 가능 실시간 알림/이벤트 테이블 기반 단순, 메시지 영속성 보장 폴링 필요 안정성이 중요할 때 판단 기준: \u0026quot;서버나 Agent가 죽어도 명령이 반드시 실행되어야 한다\u0026quot;는 요구사항이 있으면 DB 기반 큐가 필요합니다. 단순 실시간 통신만 필요하면 TCP + Agent 내부 스레드 분리로 충분합니다.\n마무리 이번 포스트에서는 Java 소켓 프로그래밍의 기초부터 실전 응용까지 다뤘습니다.\n핵심 내용 정리:\nTCP는 신뢰성 있는 연결 지향 프로토콜 ServerSocket과 Socket으로 클라이언트-서버 구현 Length-prefixed framing으로 메시지 경계 명확화 JSON으로 구조화된 프로토콜 설계 스레드풀로 멀티 클라이언트 처리 하트비트로 연결 상태 모니터링 지수 백오프 재연결 전략 버퍼링으로 I/O 성능 최적화 스레드 분리(LinkedBlockingQueue)로 PING 블로킹 문제 해결 Spring Boot TCP 중계 구조와 DB 기반 명령 큐로 유실 방지 다음 단계:\nWebSocket과 STOMP 프로토콜 학습 Netty 프레임워크로 고성능 서버 구현 프로토콜 버퍼(Protobuf) 적용 TLS/SSL로 암호화 통신 구현 PostgreSQL PGMQ / LISTEN·NOTIFY 실전 적용 소켓 프로그래밍은 네트워크 통신의 가장 기본이 되는 기술입니다. 이 기초를 탄탄히 다지면 더 고급 네트워크 프레임워크도 쉽게 이해할 수 있습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/network-socket-programming/","summary":"\u003ch2 id=\"네트워크-기초\"\u003e네트워크 기초\u003c/h2\u003e\n\u003cp\u003e네트워크 프로그래밍에서 가장 중요한 선택 중 하나는 전송 프로토콜입니다. TCP와 UDP의 차이를 이해하는 것이 첫 번째 단계입니다.\u003c/p\u003e\n\u003ch3 id=\"tcp-vs-udp\"\u003eTCP vs UDP\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eTCP (Transmission Control Protocol)\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e연결 지향 프로토콜: 3-way handshake로 연결 수립\u003c/li\u003e\n\u003cli\u003e신뢰성 보장: 패킷 순서 보장, 재전송 메커니즘\u003c/li\u003e\n\u003cli\u003e흐름 제어와 혼잡 제어\u003c/li\u003e\n\u003cli\u003e용도: HTTP, 파일 전송, 데이터베이스 연결\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eUDP (User Datagram Protocol)\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e비연결 프로토콜: 연결 수립 없이 즉시 전송\u003c/li\u003e\n\u003cli\u003e신뢰성 미보장: 패킷 손실 가능, 순서 보장 없음\u003c/li\u003e\n\u003cli\u003e낮은 오버헤드, 빠른 전송 속도\u003c/li\u003e\n\u003cli\u003e용도: DNS, 스트리밍, 온라인 게임\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e이 포스트에서는 신뢰성 있는 통신이 필요한 대부분의 애플리케이션에 적합한 TCP를 중심으로 설명합니다.\u003c/p\u003e","tags":["네트워크","소켓","TCP","프로토콜","Java","Spring Boot"],"title":"소켓 프로그래밍 - Java TCP 통신, 프로토콜 설계, 스레드 분리"},{"content":"데이터베이스의 성능을 좌우하는 가장 중요한 요소 중 하나가 바로 인덱스입니다. 이 글에서는 인덱스의 동작 원리부터 실전 최적화 기법까지 단계별로 알아봅니다.\n1. 인덱스란? 인덱스는 데이터베이스 테이블의 검색 속도를 높이기 위한 자료구조입니다. 책의 색인(index)처럼 특정 데이터를 빠르게 찾을 수 있도록 도와줍니다.\nB-Tree 구조 대부분의 RDBMS는 B-Tree(Balanced Tree) 구조를 사용합니다.\n[50] / \\ [25] [75] / \\ / \\ [10] [30] [60] [90] B-Tree의 특징:\n균형 잡힌 트리 구조로 탐색 시간이 O(log N) 루트 노드에서 시작해 리프 노드까지 탐색 데이터가 정렬된 상태로 유지됨 왜 빠른가?\n-- 인덱스 없을 때 (Full Table Scan) -- 100만 건 중 1건 찾기: 100만 번 비교 -- 인덱스 있을 때 (Index Scan) -- B-Tree 깊이가 4라면: 4번만 비교 인덱스는 전체 스캔을 피하고 필요한 데이터만 빠르게 찾을 수 있게 해줍니다.\n2. 인덱스 종류 단일 인덱스 (Single Column Index) 하나의 컬럼에 대한 인덱스입니다.\nCREATE INDEX idx_user_email ON users(email); 복합 인덱스 (Composite Index) 여러 컬럼을 조합한 인덱스입니다. 컬럼 순서가 중요합니다.\nCREATE INDEX idx_user_name_age ON users(name, age); -- 사용됨 SELECT * FROM users WHERE name = \u0026#39;홍길동\u0026#39; AND age = 30; SELECT * FROM users WHERE name = \u0026#39;홍길동\u0026#39;; -- 첫 번째 컬럼만 사용 -- 사용 안됨 (첫 번째 컬럼이 WHERE에 없음) SELECT * FROM users WHERE age = 30; 복합 인덱스 규칙:\n왼쪽부터 순서대로 사용됨 (Leftmost Prefix Rule) 등호(=) 조건이 많은 컬럼을 앞에 배치 범위 조건(\u0026gt;, \u0026lt;, BETWEEN) 컬럼은 뒤에 배치 유니크 인덱스 (Unique Index) 중복을 허용하지 않는 인덱스입니다.\nCREATE UNIQUE INDEX idx_user_email ON users(email); -- PK는 자동으로 유니크 인덱스 생성됨 커버링 인덱스 (Covering Index) 쿼리에 필요한 모든 컬럼을 인덱스가 포함하여 테이블 접근 없이 결과를 반환합니다.\n-- 인덱스: (name, age, email) SELECT name, age, email FROM users WHERE name = \u0026#39;홍길동\u0026#39;; -- 인덱스만으로 모든 데이터 조회 가능 (Using index) 3. 인덱스 생성/삭제 인덱스 생성 -- 기본 인덱스 CREATE INDEX idx_name ON users(name); -- 복합 인덱스 CREATE INDEX idx_name_age ON users(name, age); -- 유니크 인덱스 CREATE UNIQUE INDEX idx_email ON users(email); -- 내림차순 인덱스 CREATE INDEX idx_created_desc ON posts(created_at DESC); 인덱스 조회 -- MySQL SHOW INDEX FROM users; -- PostgreSQL \\d users 인덱스 삭제 DROP INDEX idx_name ON users; -- MySQL DROP INDEX idx_name; -- PostgreSQL 4. EXPLAIN - 실행 계획 읽는 법 실행 계획을 확인하여 쿼리가 인덱스를 제대로 사용하는지 확인합니다.\nEXPLAIN SELECT * FROM users WHERE email = \u0026#39;test@example.com\u0026#39;; MySQL 실행 계획 주요 항목 +----+-------------+-------+------+---------------+------+---------+-------+------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+------+---------------+------+---------+-------+------+-------+ type (접근 방법) - 중요도 순:\ntype 설명 성능 const PK 또는 Unique 인덱스로 단건 조회 ⭐⭐⭐⭐⭐ eq_ref JOIN 시 PK/Unique로 접근 ⭐⭐⭐⭐⭐ ref 일반 인덱스로 여러 건 조회 ⭐⭐⭐⭐ range 범위 조건 (BETWEEN, \u0026gt;, \u0026lt;) ⭐⭐⭐ index 인덱스 풀 스캔 ⭐⭐ ALL 테이블 풀 스캔 ⭐ Extra 항목:\n-- 좋은 케이스 Using index -- 커버링 인덱스 사용 Using where -- WHERE 조건 필터링 -- 나쁜 케이스 Using filesort -- 별도 정렬 작업 발생 Using temporary -- 임시 테이블 생성 실습 예제 -- 테스트 데이터 CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, status VARCHAR(20), created_at DATETIME ); -- 인덱스 없을 때 EXPLAIN SELECT * FROM orders WHERE user_id = 100; -- type: ALL (테이블 풀 스캔) -- 인덱스 생성 CREATE INDEX idx_user_id ON orders(user_id); -- 인덱스 있을 때 EXPLAIN SELECT * FROM orders WHERE user_id = 100; -- type: ref (인덱스 사용) 5. 인덱스가 동작하지 않는 경우 인덱스를 만들어도 아래 경우엔 사용되지 않습니다.\n1) 컬럼에 함수 적용 -- ❌ 인덱스 사용 안됨 SELECT * FROM users WHERE YEAR(created_at) = 2024; SELECT * FROM users WHERE UPPER(name) = \u0026#39;HONG\u0026#39;; -- ✅ 인덱스 사용됨 SELECT * FROM users WHERE created_at \u0026gt;= \u0026#39;2024-01-01\u0026#39; AND created_at \u0026lt; \u0026#39;2025-01-01\u0026#39;; SELECT * FROM users WHERE name = \u0026#39;Hong\u0026#39;; -- 대소문자 구분 설정 확인 2) 타입 변환 -- user_id는 INT 타입 -- ❌ 문자열로 비교 (타입 변환 발생) SELECT * FROM users WHERE user_id = \u0026#39;100\u0026#39;; -- ✅ 동일한 타입으로 비교 SELECT * FROM users WHERE user_id = 100; 3) LIKE 앞에 와일드카드 -- ❌ 인덱스 사용 안됨 SELECT * FROM users WHERE name LIKE \u0026#39;%홍길동\u0026#39;; SELECT * FROM users WHERE name LIKE \u0026#39;%홍%\u0026#39;; -- ✅ 인덱스 사용됨 SELECT * FROM users WHERE name LIKE \u0026#39;홍길동%\u0026#39;; 4) OR 조건 -- ❌ 인덱스 사용 제한적 SELECT * FROM users WHERE name = \u0026#39;홍길동\u0026#39; OR age = 30; -- ✅ UNION 사용 (각각 인덱스 활용) SELECT * FROM users WHERE name = \u0026#39;홍길동\u0026#39; UNION SELECT * FROM users WHERE age = 30; 5) 복합 인덱스 순서 위반 -- 인덱스: (name, age, city) -- ✅ 사용됨 SELECT * FROM users WHERE name = \u0026#39;홍길동\u0026#39;; SELECT * FROM users WHERE name = \u0026#39;홍길동\u0026#39; AND age = 30; -- ❌ 사용 안됨 (첫 번째 컬럼 없음) SELECT * FROM users WHERE age = 30; SELECT * FROM users WHERE city = \u0026#39;서울\u0026#39;; 6. 쿼리 최적화 기법 1) SELECT * 지양 -- ❌ 불필요한 데이터까지 조회 SELECT * FROM users WHERE id = 1; -- ✅ 필요한 컬럼만 조회 SELECT id, name, email FROM users WHERE id = 1; 이유:\n네트워크 트래픽 감소 메모리 사용량 감소 커버링 인덱스 활용 가능 2) 적절한 JOIN -- INNER JOIN: 양쪽 테이블에 모두 존재하는 데이터 SELECT u.name, o.order_date FROM users u INNER JOIN orders o ON u.id = o.user_id; -- LEFT JOIN: 왼쪽 테이블 기준, 오른쪽은 NULL 허용 SELECT u.name, o.order_date FROM users u LEFT JOIN orders o ON u.id = o.user_id; 최적화 팁:\nJOIN 컬럼에 인덱스 생성 작은 테이블을 먼저 조인 (Driving Table) WHERE 조건으로 먼저 필터링 후 JOIN 3) 페이징 최적화 OFFSET 방식 (전통적) -- 1페이지 (빠름) SELECT * FROM posts ORDER BY id DESC LIMIT 10 OFFSET 0; -- 1000페이지 (느림 - 10000건을 읽고 버림) SELECT * FROM posts ORDER BY id DESC LIMIT 10 OFFSET 10000; 문제점: OFFSET이 클수록 읽어야 할 데이터가 많아져 느려짐\nCursor 방식 (최적화) -- 첫 페이지 SELECT * FROM posts ORDER BY id DESC LIMIT 10; -- 다음 페이지 (마지막 id = 100) SELECT * FROM posts WHERE id \u0026lt; 100 ORDER BY id DESC LIMIT 10; 장점:\n항상 일정한 성능 인덱스 스캔만으로 처리 가능 7. N+1 문제와 해결법 N+1 문제란? -- 1. 사용자 목록 조회 (1번 쿼리) SELECT * FROM users LIMIT 10; -- 2. 각 사용자의 주문 조회 (N번 쿼리) -- user_id = 1 SELECT * FROM orders WHERE user_id = 1; -- user_id = 2 SELECT * FROM orders WHERE user_id = 2; -- ... (10번 반복) -- 총 11번의 쿼리 발생 해결법 1: JOIN 사용 -- 1번의 쿼리로 해결 SELECT u.*, o.* FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.id IN (1, 2, 3, ..., 10); 해결법 2: IN 절 사용 -- 1. 사용자 목록 조회 SELECT * FROM users LIMIT 10; -- id: 1, 2, 3, ..., 10 -- 2. 해당 사용자들의 주문 일괄 조회 SELECT * FROM orders WHERE user_id IN (1, 2, 3, ..., 10); -- 총 2번의 쿼리 JPA/Hibernate 최적화 // ❌ N+1 발생 @Entity public class User { @OneToMany(mappedBy = \u0026#34;user\u0026#34;) private List\u0026lt;Order\u0026gt; orders; } // ✅ Fetch Join @Query(\u0026#34;SELECT u FROM User u JOIN FETCH u.orders\u0026#34;) List\u0026lt;User\u0026gt; findAllWithOrders(); // ✅ Batch Size 설정 @BatchSize(size = 10) @OneToMany(mappedBy = \u0026#34;user\u0026#34;) private List\u0026lt;Order\u0026gt; orders; 8. 트랜잭션과 격리 수준 격리 수준 (Isolation Level) 격리 수준은 동시에 실행되는 트랜잭션 간 데이터 일관성을 제어합니다.\n격리 수준 Dirty Read Non-Repeatable Read Phantom Read READ UNCOMMITTED 발생 발생 발생 READ COMMITTED 방지 발생 발생 REPEATABLE READ 방지 방지 발생 SERIALIZABLE 방지 방지 방지 1) READ UNCOMMITTED 커밋되지 않은 데이터를 읽을 수 있습니다 (거의 사용 안 함).\nSET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- Transaction A: UPDATE 후 커밋 전 UPDATE users SET balance = 1000 WHERE id = 1; -- Transaction B: 커밋 안 된 데이터 읽음 (Dirty Read) SELECT balance FROM users WHERE id = 1; -- 1000 2) READ COMMITTED (MySQL InnoDB 기본) 커밋된 데이터만 읽습니다.\nSET TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM users WHERE id = 1; -- 500 -- 다른 트랜잭션에서 UPDATE \u0026amp; COMMIT -- UPDATE users SET balance = 1000 WHERE id = 1; COMMIT; SELECT balance FROM users WHERE id = 1; -- 1000 (변경됨) COMMIT; Non-Repeatable Read 발생: 같은 트랜잭션 내에서 같은 데이터를 두 번 읽었을 때 값이 다름\n3) REPEATABLE READ (MySQL 기본) 트랜잭션 시작 시점의 스냅샷을 읽습니다.\nSET TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT balance FROM users WHERE id = 1; -- 500 -- 다른 트랜잭션에서 UPDATE \u0026amp; COMMIT -- UPDATE users SET balance = 1000 WHERE id = 1; COMMIT; SELECT balance FROM users WHERE id = 1; -- 500 (일관성 유지) COMMIT; 4) SERIALIZABLE 가장 엄격한 격리 수준. 모든 SELECT에 Lock이 걸립니다.\nSET TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION; SELECT * FROM users WHERE age BETWEEN 20 AND 30; -- 다른 트랜잭션에서 INSERT 시도 (대기) -- INSERT INTO users (age) VALUES (25); -- 블로킹됨 격리 수준 선택 기준 -- 실시간 집계/통계 (속도 중요) SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 일반 서비스 (기본값 사용) -- MySQL: REPEATABLE READ -- PostgreSQL: READ COMMITTED -- 금융 거래 (정확성 중요) SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; 9. 실전 최적화 사례 문제 상황 사용자 주문 목록 조회가 5초 이상 걸립니다.\n-- 초기 쿼리 (5초 소요) SELECT o.*, u.name, u.email, p.name as product_name, p.price FROM orders o LEFT JOIN users u ON o.user_id = u.id LEFT JOIN order_items oi ON o.id = oi.order_id LEFT JOIN products p ON oi.product_id = p.id WHERE o.created_at \u0026gt;= \u0026#39;2024-01-01\u0026#39; ORDER BY o.created_at DESC LIMIT 20; 1단계: 실행 계획 확인 EXPLAIN 위의 쿼리; -- 결과: -- orders: type=ALL (Full Table Scan) -- users: type=ALL -- order_items: type=ALL -- products: type=ALL 문제: 모든 테이블이 풀 스캔\n2단계: 인덱스 추가 -- orders 테이블 CREATE INDEX idx_orders_created ON orders(created_at); -- JOIN 컬럼들 CREATE INDEX idx_orders_user_id ON orders(user_id); CREATE INDEX idx_order_items_order_id ON order_items(order_id); CREATE INDEX idx_order_items_product_id ON order_items(product_id); 결과: 5초 → 2초\n3단계: 불필요한 JOIN 제거 -- 실제로 product 정보는 나중에 별도로 조회해도 됨 SELECT o.*, u.name, u.email FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE o.created_at \u0026gt;= \u0026#39;2024-01-01\u0026#39; ORDER BY o.created_at DESC LIMIT 20; -- 필요시 product 정보는 애플리케이션에서 조회 결과: 2초 → 0.5초\n4단계: 커버링 인덱스 적용 -- 자주 조회하는 컬럼만 포함한 인덱스 CREATE INDEX idx_orders_covering ON orders(created_at, user_id, id, status); -- 쿼리 수정 (최소 컬럼만 SELECT) SELECT o.id, o.created_at, o.status, o.user_id FROM orders o WHERE o.created_at \u0026gt;= \u0026#39;2024-01-01\u0026#39; ORDER BY o.created_at DESC LIMIT 20; -- 사용자 정보는 IN 절로 일괄 조회 SELECT id, name, email FROM users WHERE id IN (/* 위에서 조회한 user_id 목록 */); 결과: 0.5초 → 0.1초\n5단계: 페이징 최적화 (Cursor 방식) -- 첫 페이지 SELECT o.id, o.created_at, o.status, o.user_id FROM orders o WHERE o.created_at \u0026gt;= \u0026#39;2024-01-01\u0026#39; ORDER BY o.created_at DESC LIMIT 20; -- 다음 페이지 (마지막 created_at = \u0026#39;2024-06-15 10:30:00\u0026#39;) SELECT o.id, o.created_at, o.status, o.user_id FROM orders o WHERE o.created_at \u0026gt;= \u0026#39;2024-01-01\u0026#39; AND o.created_at \u0026lt; \u0026#39;2024-06-15 10:30:00\u0026#39; ORDER BY o.created_at DESC LIMIT 20; 최종 결과: 5초 → 0.1초 (50배 개선)\n최적화 체크리스트 쿼리 성능 문제가 생겼을 때 순서대로 확인하세요:\nEXPLAIN으로 실행 계획 확인\ntype이 ALL인가? → 인덱스 추가 Extra에 filesort, temporary가 있는가? → 쿼리 개선 인덱스 확인\nWHERE 조건 컬럼에 인덱스가 있는가? JOIN 컬럼에 인덱스가 있는가? 복합 인덱스 순서가 적절한가? 쿼리 패턴 개선\nSELECT *를 사용하고 있는가? 불필요한 JOIN이 있는가? N+1 문제가 발생하는가? 페이징 방식\nOFFSET이 큰가? → Cursor 방식으로 변경 데이터량 확인\n테이블 row 수가 너무 많은가? → 파티셔닝 고려 오래된 데이터인가? → 아카이빙 고려 마무리 인덱스와 쿼리 최적화는 측정 → 분석 → 개선 → 검증의 반복입니다.\nEXPLAIN으로 현재 상태를 정확히 파악하고 병목 지점을 찾아 인덱스를 추가하며 쿼리 패턴을 개선하고 다시 측정하여 개선 효과를 확인하세요 성능 튜닝의 핵심은 추측이 아닌 데이터 기반의 개선입니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/database-index-optimization/","summary":"\u003cp\u003e데이터베이스의 성능을 좌우하는 가장 중요한 요소 중 하나가 바로 \u003cstrong\u003e인덱스\u003c/strong\u003e입니다. 이 글에서는 인덱스의 동작 원리부터 실전 최적화 기법까지 단계별로 알아봅니다.\u003c/p\u003e\n\u003ch2 id=\"1-인덱스란\"\u003e1. 인덱스란?\u003c/h2\u003e\n\u003cp\u003e인덱스는 데이터베이스 테이블의 \u003cstrong\u003e검색 속도를 높이기 위한 자료구조\u003c/strong\u003e입니다. 책의 색인(index)처럼 특정 데이터를 빠르게 찾을 수 있도록 도와줍니다.\u003c/p\u003e\n\u003ch3 id=\"b-tree-구조\"\u003eB-Tree 구조\u003c/h3\u003e\n\u003cp\u003e대부분의 RDBMS는 B-Tree(Balanced Tree) 구조를 사용합니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        [50]\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    [25]    [75]\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[10] [30] [60] [90]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eB-Tree의 특징:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e균형 잡힌 트리 구조로 탐색 시간이 O(log N)\u003c/li\u003e\n\u003cli\u003e루트 노드에서 시작해 리프 노드까지 탐색\u003c/li\u003e\n\u003cli\u003e데이터가 정렬된 상태로 유지됨\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e왜 빠른가?\u003c/strong\u003e\u003c/p\u003e","tags":["SQL","Database","인덱스","성능최적화"],"title":"DB 인덱스와 쿼리 최적화 - EXPLAIN, N+1 문제, 트랜잭션 격리 수준"},{"content":"E2E 테스트는 실제 사용자 관점에서 애플리케이션을 검증합니다. 이번 글에서는 Playwright를 활용한 React 애플리케이션 E2E 테스트 작성법을 알아봅니다.\nE2E 테스트란 테스트 피라미드 소프트웨어 테스트는 세 가지 레벨로 나뉩니다.\n/\\ / \\ E2E 테스트 (적음, 느림, 비용 높음) /____\\ / \\ 통합 테스트 (중간) /________\\ / \\ 단위 테스트 (많음, 빠름, 비용 낮음) /____________\\ 단위 테스트 (Unit Test):\n개별 함수, 컴포넌트 테스트 빠르고 격리된 환경 예: sum(1, 2) === 3 통합 테스트 (Integration Test):\n여러 모듈 간 상호작용 테스트 API 호출, 데이터베이스 연동 예: React Testing Library로 컴포넌트 + API 테스트 E2E 테스트 (End-to-End Test):\n실제 브라우저에서 사용자 시나리오 테스트 전체 시스템 통합 검증 예: 로그인 → 상품 검색 → 장바구니 → 결제 E2E 테스트가 필요한 이유 // 단위 테스트로는 잡기 어려운 문제들 // 1. 네트워크 에러 처리 function LoginForm() { const handleSubmit = async (e) =\u0026gt; { e.preventDefault(); try { await login(email, password); // ❌ 로그인 성공 후 리다이렉트 코드를 깜빡함 } catch (error) { setError(error.message); } }; // ... } // 2. CSS로 인한 버튼 클릭 불가 \u0026lt;button style={{ position: \u0026#39;absolute\u0026#39;, left: \u0026#39;-9999px\u0026#39; }}\u0026gt; 제출 \u0026lt;/button\u0026gt; // 3. 타이밍 이슈 useEffect(() =\u0026gt; { // ❌ fetchData가 완료되기 전에 컴포넌트가 언마운트될 수 있음 fetchData().then(setData); }, []); E2E 테스트는 이런 실전 문제를 잡아냅니다.\nPlaywright 소개와 설치 Playwright는 Microsoft가 만든 모던 E2E 테스팅 프레임워크입니다.\n특징:\nChromium, Firefox, WebKit 모두 지원 자동 대기 (Auto-wait) 네트워크 요청 모킹 스크린샷, 비디오 녹화 병렬 실행, 재시도 기능 설치 npm init playwright@latest 대화형 설치 과정:\n✔ Do you want to use TypeScript or JavaScript? · TypeScript ✔ Where to put your end-to-end tests? · tests ✔ Add a GitHub Actions workflow? · true ✔ Install Playwright browsers? · true 생성되는 파일:\nmy-app/ ├── tests/ │ └── example.spec.ts ├── playwright.config.ts └── package.json playwright.config.ts 설정 전체 설정 파일 예시 (Playwright 1.40+):\n// playwright.config.ts import { defineConfig, devices } from \u0026#39;@playwright/test\u0026#39;; export default defineConfig({ // 테스트 디렉토리 testDir: \u0026#39;./tests\u0026#39;, // 각 테스트 최대 실행 시간 timeout: 30 * 1000, // expect() 타임아웃 expect: { timeout: 5000 }, // 테스트 실패 시 재시도 횟수 retries: process.env.CI ? 2 : 0, // 병렬 실행 워커 수 workers: process.env.CI ? 1 : undefined, // 리포터 설정 reporter: [ [\u0026#39;html\u0026#39;], [\u0026#39;json\u0026#39;, { outputFile: \u0026#39;test-results.json\u0026#39; }], [\u0026#39;junit\u0026#39;, { outputFile: \u0026#39;test-results.xml\u0026#39; }] ], // 모든 테스트에 공통 적용 use: { // 기본 URL (baseURL 사용 시 page.goto(\u0026#39;/\u0026#39;)로 이동 가능) baseURL: \u0026#39;http://localhost:3000\u0026#39;, // 브라우저 컨텍스트 옵션 viewport: { width: 1280, height: 720 }, // 실패 시 스크린샷 screenshot: \u0026#39;only-on-failure\u0026#39;, // 실패 시 비디오 video: \u0026#39;retain-on-failure\u0026#39;, // 네트워크 로그 trace: \u0026#39;on-first-retry\u0026#39;, }, // 프로젝트 (브라우저별 설정) projects: [ { name: \u0026#39;chromium\u0026#39;, use: { ...devices[\u0026#39;Desktop Chrome\u0026#39;] }, }, { name: \u0026#39;firefox\u0026#39;, use: { ...devices[\u0026#39;Desktop Firefox\u0026#39;] }, }, { name: \u0026#39;webkit\u0026#39;, use: { ...devices[\u0026#39;Desktop Safari\u0026#39;] }, }, // 모바일 테스트 { name: \u0026#39;Mobile Chrome\u0026#39;, use: { ...devices[\u0026#39;Pixel 5\u0026#39;] }, }, { name: \u0026#39;Mobile Safari\u0026#39;, use: { ...devices[\u0026#39;iPhone 12\u0026#39;] }, }, ], // 개발 서버 자동 실행 webServer: { command: \u0026#39;npm run dev\u0026#39;, url: \u0026#39;http://localhost:3000\u0026#39;, reuseExistingServer: !process.env.CI, }, }); 주요 설정 설명 timeout: 각 테스트가 완료되어야 하는 최대 시간입니다.\ntimeout: 30 * 1000, // 30초 retries: 실패 시 재시도 횟수입니다. CI 환경에서는 네트워크 불안정으로 인한 실패를 줄이기 위해 사용합니다.\nretries: process.env.CI ? 2 : 0, baseURL: 모든 테스트에서 공통으로 사용할 기본 URL입니다.\nuse: { baseURL: \u0026#39;http://localhost:3000\u0026#39;, } // 테스트에서 await page.goto(\u0026#39;/\u0026#39;); // http://localhost:3000/ 로 이동 await page.goto(\u0026#39;/login\u0026#39;); // http://localhost:3000/login 으로 이동 첫 번째 테스트 작성 로그인 테스트 // tests/login.spec.ts import { test, expect } from \u0026#39;@playwright/test\u0026#39;; test.describe(\u0026#39;로그인\u0026#39;, () =\u0026gt; { test(\u0026#39;성공적인 로그인\u0026#39;, async ({ page }) =\u0026gt; { // 1. 로그인 페이지로 이동 await page.goto(\u0026#39;/login\u0026#39;); // 2. 폼 입력 await page.getByLabel(\u0026#39;이메일\u0026#39;).fill(\u0026#39;user@example.com\u0026#39;); await page.getByLabel(\u0026#39;비밀번호\u0026#39;).fill(\u0026#39;password123\u0026#39;); // 3. 로그인 버튼 클릭 await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;로그인\u0026#39; }).click(); // 4. 대시보드로 리다이렉트 확인 await expect(page).toHaveURL(\u0026#39;/dashboard\u0026#39;); // 5. 사용자 이름이 표시되는지 확인 await expect(page.getByText(\u0026#39;홍길동님 환영합니다\u0026#39;)).toBeVisible(); }); test(\u0026#39;잘못된 비밀번호\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/login\u0026#39;); await page.getByLabel(\u0026#39;이메일\u0026#39;).fill(\u0026#39;user@example.com\u0026#39;); await page.getByLabel(\u0026#39;비밀번호\u0026#39;).fill(\u0026#39;wrongpassword\u0026#39;); await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;로그인\u0026#39; }).click(); // 에러 메시지 확인 await expect(page.getByText(\u0026#39;이메일 또는 비밀번호가 잘못되었습니다\u0026#39;)).toBeVisible(); // 여전히 로그인 페이지에 있는지 확인 await expect(page).toHaveURL(\u0026#39;/login\u0026#39;); }); test(\u0026#39;빈 폼 제출\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/login\u0026#39;); await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;로그인\u0026#39; }).click(); // HTML5 validation 메시지 확인 const emailInput = page.getByLabel(\u0026#39;이메일\u0026#39;); await expect(emailInput).toHaveAttribute(\u0026#39;required\u0026#39;); }); }); 테스트 실행:\n# 모든 테스트 실행 npx playwright test # 특정 파일만 실행 npx playwright test login.spec.ts # UI 모드로 실행 (디버깅에 유용) npx playwright test --ui # 헤드풀 모드 (브라우저 보이게) npx playwright test --headed # 특정 브라우저만 npx playwright test --project=chromium Locator 전략 올바른 locator를 선택하는 것이 테스트 안정성의 핵심입니다.\n우선순위 1순위: getByRole\n접근성을 고려한 가장 안정적인 방법입니다.\n// ✅ 좋음: 의미 있는 role 사용 await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;제출\u0026#39; }); await page.getByRole(\u0026#39;link\u0026#39;, { name: \u0026#39;홈으로\u0026#39; }); await page.getByRole(\u0026#39;textbox\u0026#39;, { name: \u0026#39;이메일\u0026#39; }); await page.getByRole(\u0026#39;checkbox\u0026#39;, { name: \u0026#39;약관 동의\u0026#39; }); // 사용 가능한 role 확인 await page.getByRole(\u0026#39;button\u0026#39;).all(); // 페이지의 모든 버튼 2순위: getByLabel\n폼 요소에 적합합니다.\n// ✅ 좋음: label과 연결된 input await page.getByLabel(\u0026#39;사용자 이름\u0026#39;).fill(\u0026#39;홍길동\u0026#39;); await page.getByLabel(\u0026#39;생년월일\u0026#39;).fill(\u0026#39;2000-01-01\u0026#39;); 3순위: getByText\n텍스트 콘텐츠로 찾기.\n// ✅ 좋음: 고유한 텍스트 await page.getByText(\u0026#39;주문 완료\u0026#39;).click(); // 부분 매칭 await page.getByText(/주문 완료/); await page.getByText(\u0026#39;주문\u0026#39;, { exact: false }); 4순위: getByTestId\n스타일 변경에 영향받지 않는 안정적인 방법입니다.\n// React 컴포넌트 \u0026lt;button data-testid=\u0026#34;submit-button\u0026#34;\u0026gt;제출\u0026lt;/button\u0026gt; // 테스트 await page.getByTestId(\u0026#39;submit-button\u0026#39;).click(); 피해야 할 방법:\n// ❌ 나쁨: CSS selector (스타일 변경에 취약) await page.locator(\u0026#39;.btn-primary.submit-button\u0026#39;); // ❌ 나쁨: XPath (가독성 낮음) await page.locator(\u0026#39;//*[@id=\u0026#34;app\u0026#34;]/div[1]/button\u0026#39;); 복잡한 Locator // 특정 텍스트를 포함한 요소 찾기 await page.getByRole(\u0026#39;listitem\u0026#39;).filter({ hasText: \u0026#39;완료\u0026#39; }); // 자식 요소 찾기 await page.getByRole(\u0026#39;article\u0026#39;).getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;삭제\u0026#39; }); // 여러 조건 조합 await page .getByRole(\u0026#39;row\u0026#39;) .filter({ has: page.getByText(\u0026#39;홍길동\u0026#39;) }) .getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;편집\u0026#39; }); // n번째 요소 await page.getByRole(\u0026#39;listitem\u0026#39;).nth(2); // 첫 번째, 마지막 await page.getByRole(\u0026#39;listitem\u0026#39;).first(); await page.getByRole(\u0026#39;listitem\u0026#39;).last(); 페이지 네비게이션 테스트 // tests/navigation.spec.ts import { test, expect } from \u0026#39;@playwright/test\u0026#39;; test.describe(\u0026#39;네비게이션\u0026#39;, () =\u0026gt; { test(\u0026#39;메뉴 링크 동작\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/\u0026#39;); // 메뉴 클릭 await page.getByRole(\u0026#39;link\u0026#39;, { name: \u0026#39;About\u0026#39; }).click(); await expect(page).toHaveURL(\u0026#39;/about\u0026#39;); // 뒤로가기 await page.goBack(); await expect(page).toHaveURL(\u0026#39;/\u0026#39;); // 앞으로가기 await page.goForward(); await expect(page).toHaveURL(\u0026#39;/about\u0026#39;); }); test(\u0026#39;브레드크럼 네비게이션\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/products/123\u0026#39;); // 브레드크럼: 홈 \u0026gt; 제품 \u0026gt; 제품 상세 await page.getByRole(\u0026#39;link\u0026#39;, { name: \u0026#39;제품\u0026#39; }).click(); await expect(page).toHaveURL(\u0026#39;/products\u0026#39;); await page.getByRole(\u0026#39;link\u0026#39;, { name: \u0026#39;홈\u0026#39; }).click(); await expect(page).toHaveURL(\u0026#39;/\u0026#39;); }); test(\u0026#39;탭 네비게이션\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/settings\u0026#39;); // 프로필 탭 await page.getByRole(\u0026#39;tab\u0026#39;, { name: \u0026#39;프로필\u0026#39; }).click(); await expect(page.getByText(\u0026#39;프로필 정보\u0026#39;)).toBeVisible(); // 알림 탭 await page.getByRole(\u0026#39;tab\u0026#39;, { name: \u0026#39;알림\u0026#39; }).click(); await expect(page.getByText(\u0026#39;알림 설정\u0026#39;)).toBeVisible(); }); }); 폼 인터랙션 테스트 // tests/form.spec.ts import { test, expect } from \u0026#39;@playwright/test\u0026#39;; test.describe(\u0026#39;회원가입 폼\u0026#39;, () =\u0026gt; { test(\u0026#39;전체 폼 제출\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/signup\u0026#39;); // 텍스트 입력 await page.getByLabel(\u0026#39;이메일\u0026#39;).fill(\u0026#39;user@example.com\u0026#39;); await page.getByLabel(\u0026#39;비밀번호\u0026#39;).fill(\u0026#39;StrongPass123!\u0026#39;); await page.getByLabel(\u0026#39;비밀번호 확인\u0026#39;).fill(\u0026#39;StrongPass123!\u0026#39;); // 셀렉트박스 await page.getByLabel(\u0026#39;국가\u0026#39;).selectOption(\u0026#39;KR\u0026#39;); // 라디오 버튼 await page.getByLabel(\u0026#39;남성\u0026#39;).check(); // 체크박스 await page.getByLabel(\u0026#39;마케팅 수신 동의\u0026#39;).check(); await page.getByLabel(\u0026#39;개인정보 처리방침 동의\u0026#39;).check(); // 파일 업로드 await page.getByLabel(\u0026#39;프로필 사진\u0026#39;).setInputFiles(\u0026#39;tests/fixtures/profile.jpg\u0026#39;); // 제출 await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;가입하기\u0026#39; }).click(); // 성공 메시지 await expect(page.getByText(\u0026#39;회원가입이 완료되었습니다\u0026#39;)).toBeVisible(); }); test(\u0026#39;비밀번호 불일치\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/signup\u0026#39;); await page.getByLabel(\u0026#39;이메일\u0026#39;).fill(\u0026#39;user@example.com\u0026#39;); await page.getByLabel(\u0026#39;비밀번호\u0026#39;).fill(\u0026#39;Password123!\u0026#39;); await page.getByLabel(\u0026#39;비밀번호 확인\u0026#39;).fill(\u0026#39;DifferentPass123!\u0026#39;); // blur 이벤트 트리거 await page.getByLabel(\u0026#39;비밀번호 확인\u0026#39;).blur(); // 에러 메시지 확인 await expect(page.getByText(\u0026#39;비밀번호가 일치하지 않습니다\u0026#39;)).toBeVisible(); }); test(\u0026#39;이메일 중복 체크\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/signup\u0026#39;); await page.getByLabel(\u0026#39;이메일\u0026#39;).fill(\u0026#39;existing@example.com\u0026#39;); await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;중복 확인\u0026#39; }).click(); // 에러 메시지 대기 await expect(page.getByText(\u0026#39;이미 사용 중인 이메일입니다\u0026#39;)).toBeVisible(); }); }); 네트워크 요청 모킹 실제 API 호출 없이 테스트할 수 있습니다.\n// tests/api-mocking.spec.ts import { test, expect } from \u0026#39;@playwright/test\u0026#39;; test.describe(\u0026#39;API 모킹\u0026#39;, () =\u0026gt; { test(\u0026#39;사용자 목록 로드\u0026#39;, async ({ page }) =\u0026gt; { // API 응답 모킹 await page.route(\u0026#39;**/api/users\u0026#39;, (route) =\u0026gt; { route.fulfill({ status: 200, contentType: \u0026#39;application/json\u0026#39;, body: JSON.stringify([ { id: 1, name: \u0026#39;홍길동\u0026#39;, email: \u0026#39;hong@example.com\u0026#39; }, { id: 2, name: \u0026#39;김철수\u0026#39;, email: \u0026#39;kim@example.com\u0026#39; } ]) }); }); await page.goto(\u0026#39;/users\u0026#39;); // 모킹된 데이터 확인 await expect(page.getByText(\u0026#39;홍길동\u0026#39;)).toBeVisible(); await expect(page.getByText(\u0026#39;김철수\u0026#39;)).toBeVisible(); }); test(\u0026#39;API 에러 처리\u0026#39;, async ({ page }) =\u0026gt; { // 500 에러 모킹 await page.route(\u0026#39;**/api/users\u0026#39;, (route) =\u0026gt; { route.fulfill({ status: 500, contentType: \u0026#39;application/json\u0026#39;, body: JSON.stringify({ error: \u0026#39;Internal Server Error\u0026#39; }) }); }); await page.goto(\u0026#39;/users\u0026#39;); // 에러 메시지 확인 await expect(page.getByText(\u0026#39;서버 오류가 발생했습니다\u0026#39;)).toBeVisible(); }); test(\u0026#39;네트워크 지연 시뮬레이션\u0026#39;, async ({ page }) =\u0026gt; { await page.route(\u0026#39;**/api/users\u0026#39;, async (route) =\u0026gt; { // 3초 지연 await new Promise(resolve =\u0026gt; setTimeout(resolve, 3000)); route.fulfill({ status: 200, body: JSON.stringify([]) }); }); await page.goto(\u0026#39;/users\u0026#39;); // 로딩 스피너 확인 await expect(page.getByTestId(\u0026#39;loading-spinner\u0026#39;)).toBeVisible(); // 데이터 로드 후 스피너 사라짐 await expect(page.getByTestId(\u0026#39;loading-spinner\u0026#39;)).not.toBeVisible(); }); test(\u0026#39;POST 요청 검증\u0026#39;, async ({ page }) =\u0026gt; { let requestBody; await page.route(\u0026#39;**/api/users\u0026#39;, (route) =\u0026gt; { requestBody = route.request().postDataJSON(); route.fulfill({ status: 201, body: JSON.stringify({ id: 3, ...requestBody }) }); }); await page.goto(\u0026#39;/users/new\u0026#39;); await page.getByLabel(\u0026#39;이름\u0026#39;).fill(\u0026#39;이영희\u0026#39;); await page.getByLabel(\u0026#39;이메일\u0026#39;).fill(\u0026#39;lee@example.com\u0026#39;); await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;생성\u0026#39; }).click(); // 요청 본문 검증 expect(requestBody).toEqual({ name: \u0026#39;이영희\u0026#39;, email: \u0026#39;lee@example.com\u0026#39; }); }); }); 스크린샷과 비디오 캡처 // tests/visual.spec.ts import { test, expect } from \u0026#39;@playwright/test\u0026#39;; test.describe(\u0026#39;비주얼 테스트\u0026#39;, () =\u0026gt; { test(\u0026#39;전체 페이지 스크린샷\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/\u0026#39;); // 전체 페이지 await page.screenshot({ path: \u0026#39;screenshots/homepage.png\u0026#39;, fullPage: true }); // 특정 요소만 const header = page.getByRole(\u0026#39;banner\u0026#39;); await header.screenshot({ path: \u0026#39;screenshots/header.png\u0026#39; }); }); test(\u0026#39;비주얼 리그레션 테스트\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/\u0026#39;); // 스크린샷 비교 (첫 실행 시 기준 이미지 생성) await expect(page).toHaveScreenshot(\u0026#39;homepage.png\u0026#39;); }); test(\u0026#39;다크모드 스크린샷\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/\u0026#39;); // 다크모드 활성화 await page.emulateMedia({ colorScheme: \u0026#39;dark\u0026#39; }); await expect(page).toHaveScreenshot(\u0026#39;homepage-dark.png\u0026#39;); }); test(\u0026#39;모바일 뷰포트 스크린샷\u0026#39;, async ({ page }) =\u0026gt; { await page.setViewportSize({ width: 375, height: 667 }); await page.goto(\u0026#39;/\u0026#39;); await expect(page).toHaveScreenshot(\u0026#39;homepage-mobile.png\u0026#39;); }); }); 비디오는 자동으로 녹화됩니다 (설정에서 활성화한 경우):\n// playwright.config.ts use: { video: \u0026#39;on\u0026#39;, // 항상 녹화 // video: \u0026#39;retain-on-failure\u0026#39;, // 실패 시만 보관 // video: \u0026#39;on-first-retry\u0026#39;, // 재시도 시만 녹화 } CI/CD 파이프라인 통합 GitHub Actions # .github/workflows/playwright.yml name: Playwright Tests on: push: branches: [ main, dev ] pull_request: branches: [ main ] jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - name: Install dependencies run: npm ci - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run Playwright tests run: npx playwright test - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 30 - uses: actions/upload-artifact@v4 if: failure() with: name: test-results path: test-results/ retention-days: 7 병렬 실행 jobs: test: runs-on: ubuntu-latest strategy: matrix: shardIndex: [1, 2, 3, 4] shardTotal: [4] steps: # ... 이전 단계들 - name: Run Playwright tests run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} 실전 예시: 파일 동기화 콘솔 실무에서 사용할 법한 복잡한 시나리오를 테스트해봅시다.\n// tests/file-sync.spec.ts import { test, expect } from \u0026#39;@playwright/test\u0026#39;; test.describe(\u0026#39;파일 동기화 콘솔\u0026#39;, () =\u0026gt; { test.beforeEach(async ({ page }) =\u0026gt; { // 로그인 (재사용 가능하도록 별도 함수로 분리) await login(page, \u0026#39;admin@example.com\u0026#39;, \u0026#39;admin123\u0026#39;); }); test(\u0026#39;로그인 후 대시보드 확인\u0026#39;, async ({ page }) =\u0026gt; { // 이미 beforeEach에서 로그인됨 await expect(page).toHaveURL(\u0026#39;/dashboard\u0026#39;); // 대시보드 위젯 확인 await expect(page.getByText(\u0026#39;동기화 작업\u0026#39;)).toBeVisible(); await expect(page.getByTestId(\u0026#39;active-jobs-count\u0026#39;)).toBeVisible(); }); test(\u0026#39;새 동기화 작업 생성\u0026#39;, async ({ page }) =\u0026gt; { // API 모킹 await page.route(\u0026#39;**/api/jobs\u0026#39;, (route) =\u0026gt; { if (route.request().method() === \u0026#39;POST\u0026#39;) { route.fulfill({ status: 201, body: JSON.stringify({ id: \u0026#39;job-123\u0026#39;, status: \u0026#39;pending\u0026#39;, createdAt: new Date().toISOString() }) }); } }); // 작업 생성 페이지로 이동 await page.getByRole(\u0026#39;link\u0026#39;, { name: \u0026#39;새 작업\u0026#39; }).click(); await expect(page).toHaveURL(\u0026#39;/jobs/new\u0026#39;); // 폼 입력 await page.getByLabel(\u0026#39;작업 이름\u0026#39;).fill(\u0026#39;프로덕션 DB 동기화\u0026#39;); await page.getByLabel(\u0026#39;소스\u0026#39;).selectOption(\u0026#39;postgres-prod\u0026#39;); await page.getByLabel(\u0026#39;대상\u0026#39;).selectOption(\u0026#39;postgres-replica\u0026#39;); // 고급 옵션 토글 await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;고급 옵션\u0026#39; }).click(); await page.getByLabel(\u0026#39;배치 크기\u0026#39;).fill(\u0026#39;1000\u0026#39;); await page.getByLabel(\u0026#39;재시도 횟수\u0026#39;).fill(\u0026#39;3\u0026#39;); // 제출 await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;생성\u0026#39; }).click(); // 성공 알림 await expect(page.getByText(\u0026#39;작업이 생성되었습니다\u0026#39;)).toBeVisible(); // 작업 목록으로 리다이렉트 await expect(page).toHaveURL(\u0026#39;/jobs\u0026#39;); // 새 작업이 목록에 나타남 await expect(page.getByText(\u0026#39;프로덕션 DB 동기화\u0026#39;)).toBeVisible(); }); test(\u0026#39;작업 실행 및 로그 확인\u0026#39;, async ({ page }) =\u0026gt; { // WebSocket 메시지 모킹 await page.goto(\u0026#39;/jobs/job-123\u0026#39;); // 실행 버튼 클릭 await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;실행\u0026#39; }).click(); // 상태 변경 확인 (폴링) await expect(page.getByTestId(\u0026#39;job-status\u0026#39;)).toHaveText(\u0026#39;실행 중\u0026#39;, { timeout: 10000 }); // 실시간 로그 확인 await expect(page.getByTestId(\u0026#39;log-stream\u0026#39;)).toContainText(\u0026#39;연결 중...\u0026#39;); await expect(page.getByTestId(\u0026#39;log-stream\u0026#39;)).toContainText(\u0026#39;데이터 전송 시작\u0026#39;); // 진행률 바 확인 const progressBar = page.getByRole(\u0026#39;progressbar\u0026#39;); await expect(progressBar).toHaveAttribute(\u0026#39;aria-valuenow\u0026#39;, /[1-9]/); // 완료 대기 await expect(page.getByTestId(\u0026#39;job-status\u0026#39;)).toHaveText(\u0026#39;완료\u0026#39;, { timeout: 60000 }); // 통계 확인 await expect(page.getByText(/총 \\d+ 건 전송/)).toBeVisible(); }); test(\u0026#39;데이터 일관성 체크\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/jobs/job-123\u0026#39;); // 일관성 체크 실행 await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;일관성 체크\u0026#39; }).click(); // 체크 결과 대기 await expect(page.getByTestId(\u0026#39;consistency-result\u0026#39;)).toBeVisible({ timeout: 30000 }); // 일치하는 레코드 수 const matchedCount = await page.getByTestId(\u0026#39;matched-count\u0026#39;).textContent(); expect(parseInt(matchedCount!)).toBeGreaterThan(0); // 불일치 레코드가 있으면 표시 const mismatchedRows = page.getByTestId(\u0026#39;mismatched-row\u0026#39;); const count = await mismatchedRows.count(); if (count \u0026gt; 0) { // 첫 번째 불일치 항목 확인 await expect(mismatchedRows.first()).toBeVisible(); // 상세 정보 펼치기 await mismatchedRows.first().getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;상세\u0026#39; }).click(); await expect(page.getByText(\u0026#39;소스 값\u0026#39;)).toBeVisible(); await expect(page.getByText(\u0026#39;대상 값\u0026#39;)).toBeVisible(); } }); test(\u0026#39;에러 처리\u0026#39;, async ({ page }) =\u0026gt; { // 네트워크 에러 시뮬레이션 await page.route(\u0026#39;**/api/jobs/job-123/run\u0026#39;, (route) =\u0026gt; { route.abort(\u0026#39;failed\u0026#39;); }); await page.goto(\u0026#39;/jobs/job-123\u0026#39;); await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;실행\u0026#39; }).click(); // 에러 토스트 메시지 await expect(page.getByRole(\u0026#39;alert\u0026#39;)).toContainText(\u0026#39;네트워크 오류\u0026#39;); // 재시도 버튼 await expect(page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;재시도\u0026#39; })).toBeVisible(); }); test(\u0026#39;동시 작업 제한\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/jobs\u0026#39;); // 이미 3개 실행 중인 상태 모킹 await page.route(\u0026#39;**/api/jobs/active-count\u0026#39;, (route) =\u0026gt; { route.fulfill({ body: JSON.stringify({ count: 3 }) }); }); // 새 작업 실행 시도 await page.getByRole(\u0026#39;row\u0026#39;).first().getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;실행\u0026#39; }).click(); // 경고 메시지 await expect(page.getByText(\u0026#39;동시 실행 가능한 최대 작업 수(3개)에 도달했습니다\u0026#39;)).toBeVisible(); }); }); // 헬퍼 함수 import type { Page } from \u0026#39;@playwright/test\u0026#39;; async function login(page: Page, email: string, password: string) { await page.goto(\u0026#39;/login\u0026#39;); await page.getByLabel(\u0026#39;이메일\u0026#39;).fill(email); await page.getByLabel(\u0026#39;비밀번호\u0026#39;).fill(password); await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;로그인\u0026#39; }).click(); await page.waitForURL(\u0026#39;/dashboard\u0026#39;); } 인증 상태 재사용 매번 로그인하면 느리므로 상태를 저장해서 재사용합니다.\n// tests/auth.setup.ts import { test as setup } from \u0026#39;@playwright/test\u0026#39;; const authFile = \u0026#39;playwright/.auth/user.json\u0026#39;; setup(\u0026#39;authenticate\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;/login\u0026#39;); await page.getByLabel(\u0026#39;이메일\u0026#39;).fill(\u0026#39;admin@example.com\u0026#39;); await page.getByLabel(\u0026#39;비밀번호\u0026#39;).fill(\u0026#39;admin123\u0026#39;); await page.getByRole(\u0026#39;button\u0026#39;, { name: \u0026#39;로그인\u0026#39; }).click(); await page.waitForURL(\u0026#39;/dashboard\u0026#39;); // 인증 상태 저장 await page.context().storageState({ path: authFile }); }); 설정 파일에서 사용:\n// playwright.config.ts export default defineConfig({ projects: [ // 인증 설정 { name: \u0026#39;setup\u0026#39;, testMatch: /.*\\.setup\\.ts/ }, // 인증이 필요한 테스트 { name: \u0026#39;chromium\u0026#39;, use: { ...devices[\u0026#39;Desktop Chrome\u0026#39;], storageState: \u0026#39;playwright/.auth/user.json\u0026#39;, }, dependencies: [\u0026#39;setup\u0026#39;], }, ], }); 마무리 Playwright를 활용하면 실제 사용자 경험을 자동으로 검증할 수 있습니다.\n핵심 정리:\nE2E 테스트: 전체 시스템 통합을 사용자 관점에서 검증 Locator 전략: getByRole \u0026gt; getByLabel \u0026gt; getByTestId 순으로 선택 자동 대기: Playwright는 요소가 준비될 때까지 자동으로 대기 네트워크 모킹: 실제 API 없이도 모든 시나리오 테스트 가능 CI/CD 통합: GitHub Actions로 PR마다 자동 테스트 인증 재사용: 상태 저장으로 테스트 속도 향상 다음 글에서는 React 성능 최적화 기법을 알아보겠습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/react-e2e-testing/","summary":"\u003cp\u003eE2E 테스트는 실제 사용자 관점에서 애플리케이션을 검증합니다. 이번 글에서는 Playwright를 활용한 React 애플리케이션 E2E 테스트 작성법을 알아봅니다.\u003c/p\u003e\n\u003ch2 id=\"e2e-테스트란\"\u003eE2E 테스트란\u003c/h2\u003e\n\u003ch3 id=\"테스트-피라미드\"\u003e테스트 피라미드\u003c/h3\u003e\n\u003cp\u003e소프트웨어 테스트는 세 가지 레벨로 나뉩니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        /\\\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e       /  \\  E2E 테스트 (적음, 느림, 비용 높음)\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\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e단위 테스트 (Unit Test):\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e개별 함수, 컴포넌트 테스트\u003c/li\u003e\n\u003cli\u003e빠르고 격리된 환경\u003c/li\u003e\n\u003cli\u003e예: \u003ccode\u003esum(1, 2) === 3\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e통합 테스트 (Integration Test):\u003c/strong\u003e\u003c/p\u003e","tags":["React","테스팅","Playwright","E2E"],"title":"React E2E 테스트 - Playwright 설정, Locator, 네트워크 모킹, CI 통합"},{"content":"Props drilling 문제를 겪어보신 적 있나요? 컴포넌트가 깊어질수록 상태를 여러 단계에 걸쳐 전달해야 하는 문제가 발생합니다. 이번 글에서는 전역 상태 관리 아키텍처 패턴과 실전 Context 조합 전략, 그리고 프로젝트 규모에 맞는 상태 관리 솔루션 선택 방법을 알아봅니다.\n상태 관리의 필요성 Props Drilling 문제 중간 컴포넌트들이 실제로 사용하지 않는 props를 단순히 아래로 전달만 하는 상황입니다.\n// Props Drilling 예시 function App() { const [user, setUser] = useState(null); return \u0026lt;Layout user={user} setUser={setUser} /\u0026gt;; } function Layout({ user, setUser }) { return ( \u0026lt;div\u0026gt; \u0026lt;Header user={user} setUser={setUser} /\u0026gt; \u0026lt;Main user={user} /\u0026gt; \u0026lt;/div\u0026gt; ); } function Header({ user, setUser }) { return \u0026lt;UserMenu user={user} setUser={setUser} /\u0026gt;; } function UserMenu({ user, setUser }) { // 실제로 사용하는 곳은 여기 return user ? ( \u0026lt;button onClick={() =\u0026gt; setUser(null)}\u0026gt;로그아웃\u0026lt;/button\u0026gt; ) : ( \u0026lt;button onClick={() =\u0026gt; setUser({ name: \u0026#39;홍길동\u0026#39; })}\u0026gt;로그인\u0026lt;/button\u0026gt; ); } 중간 컴포넌트들은 user와 setUser를 직접 사용하지 않지만 전달만 하고 있습니다. 이것이 Props Drilling 문제입니다.\n전역 상태 관리가 필요한 시점 다음과 같은 상황에서 전역 상태 관리를 고려해야 합니다:\n3단계 이상 Props 전달: 중간 컴포넌트들이 단순 전달자 역할만 함 여러 컴포넌트에서 공유: 인증 정보, 테마, 언어 설정 등 앱 전역 이벤트: 알림, 모달, 토스트 메시지 등 복잡한 상태 동기화: 여러 컴포넌트가 동일한 상태를 수정 해결 방법의 스펙트럼 Props → Composition → Context → 상태 관리 라이브러리 (간단) (복잡) Composition 우선 고려: 많은 경우 컴포넌트 합성으로 해결 가능합니다.\n// Props drilling 예시 function App() { const [user, setUser] = useState(null); return \u0026lt;Layout user={user} setUser={setUser} /\u0026gt;; } function Layout({ user, setUser }) { return ( \u0026lt;div\u0026gt; \u0026lt;Header user={user} setUser={setUser} /\u0026gt; \u0026lt;Main user={user} /\u0026gt; \u0026lt;/div\u0026gt; ); } // Composition으로 개선 function App() { const [user, setUser] = useState(null); return ( \u0026lt;Layout header={\u0026lt;Header user={user} setUser={setUser} /\u0026gt;} main={\u0026lt;Main user={user} /\u0026gt;} /\u0026gt; ); } function Layout({ header, main }) { return ( \u0026lt;div\u0026gt; {header} {main} \u0026lt;/div\u0026gt; ); } Layout이 user를 몰라도 되므로 Props drilling이 해결됩니다.\nContext로 전역 상태 관리 AuthContext 예시 실전에서 자주 사용되는 인증 상태 관리입니다.\n// contexts/AuthContext.jsx import { createContext, useContext, useState, useEffect } from \u0026#39;react\u0026#39;; const AuthContext = createContext(null); export function AuthProvider({ children }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() =\u0026gt; { // 초기 로드 시 토큰 확인 const token = localStorage.getItem(\u0026#39;token\u0026#39;); if (token) { fetchUser(token); } else { setLoading(false); } }, []); const fetchUser = async (token) =\u0026gt; { try { const response = await fetch(\u0026#39;/api/user\u0026#39;, { headers: { Authorization: `Bearer ${token}` } }); const userData = await response.json(); setUser(userData); } catch (error) { console.error(\u0026#39;사용자 정보 로드 실패:\u0026#39;, error); localStorage.removeItem(\u0026#39;token\u0026#39;); } finally { setLoading(false); } }; const login = async (email, password) =\u0026gt; { const response = await fetch(\u0026#39;/api/login\u0026#39;, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, body: JSON.stringify({ email, password }) }); const { token, user } = await response.json(); localStorage.setItem(\u0026#39;token\u0026#39;, token); setUser(user); }; const logout = () =\u0026gt; { localStorage.removeItem(\u0026#39;token\u0026#39;); setUser(null); }; return ( \u0026lt;AuthContext.Provider value={{ user, loading, login, logout }}\u0026gt; {children} \u0026lt;/AuthContext.Provider\u0026gt; ); } export const useAuth = () =\u0026gt; { const context = useContext(AuthContext); if (!context) { throw new Error(\u0026#39;useAuth는 AuthProvider 내부에서 사용해야 합니다\u0026#39;); } return context; }; ThemeContext 예시 다크모드 전환 기능을 구현해봅시다.\n// contexts/ThemeContext.jsx import { createContext, useContext, useState, useEffect } from \u0026#39;react\u0026#39;; const ThemeContext = createContext(null); export function ThemeProvider({ children }) { const [theme, setTheme] = useState(() =\u0026gt; { return localStorage.getItem(\u0026#39;theme\u0026#39;) || \u0026#39;light\u0026#39;; }); useEffect(() =\u0026gt; { localStorage.setItem(\u0026#39;theme\u0026#39;, theme); document.documentElement.setAttribute(\u0026#39;data-theme\u0026#39;, theme); }, [theme]); const toggleTheme = () =\u0026gt; { setTheme(prev =\u0026gt; prev === \u0026#39;light\u0026#39; ? \u0026#39;dark\u0026#39; : \u0026#39;light\u0026#39;); }; return ( \u0026lt;ThemeContext.Provider value={{ theme, toggleTheme }}\u0026gt; {children} \u0026lt;/ThemeContext.Provider\u0026gt; ); } export const useTheme = () =\u0026gt; useContext(ThemeContext); 사용 예시:\nfunction ThemeToggle() { const { theme, toggleTheme } = useTheme(); return ( \u0026lt;button onClick={toggleTheme}\u0026gt; {theme === \u0026#39;light\u0026#39; ? \u0026#39;🌙\u0026#39; : \u0026#39;☀️\u0026#39;} \u0026lt;/button\u0026gt; ); } Context 설계 원칙 1. 단일 책임 원칙 각 Context는 하나의 도메인만 담당해야 합니다.\n// ❌ 나쁜 예: 모든 것을 하나의 Context에 const AppContext = createContext({ user: null, theme: \u0026#39;light\u0026#39;, language: \u0026#39;ko\u0026#39;, notifications: [], cart: [] }); // ✅ 좋은 예: 도메인별로 분리 const AuthContext = createContext({ user: null }); const ThemeContext = createContext({ theme: \u0026#39;light\u0026#39; }); const I18nContext = createContext({ language: \u0026#39;ko\u0026#39; }); const NotificationContext = createContext({ notifications: [] }); const CartContext = createContext({ cart: [] }); 장점:\n불필요한 리렌더링 방지 테스트 용이 독립적 개발/배포 가능 2. 커스텀 Hook으로 캡슐화 Context 사용을 커스텀 Hook으로 감싸면 사용성과 안정성이 향상됩니다.\n// contexts/AuthContext.jsx const AuthContext = createContext(null); export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error(\u0026#39;useAuth는 AuthProvider 내부에서 사용해야 합니다\u0026#39;); } return context; } // 사용 시 function Profile() { const { user, logout } = useAuth(); // useContext 대신 커스텀 Hook // ... } 3. 값과 액션 분리 상태 값과 업데이트 함수를 별도 Context로 분리하면 성능이 개선됩니다.\nconst TodoStateContext = createContext(null); const TodoDispatchContext = createContext(null); export function TodoProvider({ children }) { const [state, dispatch] = useReducer(todoReducer, initialState); return ( \u0026lt;TodoStateContext.Provider value={state}\u0026gt; \u0026lt;TodoDispatchContext.Provider value={dispatch}\u0026gt; {children} \u0026lt;/TodoDispatchContext.Provider\u0026gt; \u0026lt;/TodoStateContext.Provider\u0026gt; ); } // 상태만 필요한 컴포넌트 function TodoCount() { const state = useContext(TodoStateContext); return \u0026lt;p\u0026gt;{state.todos.length}개\u0026lt;/p\u0026gt;; } // dispatch만 필요한 컴포넌트 (state 변경 시 리렌더링 안 됨) function AddTodoButton() { const dispatch = useContext(TodoDispatchContext); return \u0026lt;button onClick={() =\u0026gt; dispatch({ type: \u0026#39;ADD_TODO\u0026#39; })}\u0026gt;추가\u0026lt;/button\u0026gt;; } Provider 중첩 패턴 실전에서는 여러 Context를 조합해서 사용합니다.\n// App.jsx function App() { return ( \u0026lt;AuthProvider\u0026gt; \u0026lt;LicenseProvider\u0026gt; \u0026lt;AgentProvider\u0026gt; \u0026lt;ThemeProvider\u0026gt; \u0026lt;Router\u0026gt; \u0026lt;Routes\u0026gt; \u0026lt;Route path=\u0026#34;/\u0026#34; element={\u0026lt;Dashboard /\u0026gt;} /\u0026gt; \u0026lt;Route path=\u0026#34;/login\u0026#34; element={\u0026lt;Login /\u0026gt;} /\u0026gt; \u0026lt;/Routes\u0026gt; \u0026lt;/Router\u0026gt; \u0026lt;/ThemeProvider\u0026gt; \u0026lt;/AgentProvider\u0026gt; \u0026lt;/LicenseProvider\u0026gt; \u0026lt;/AuthProvider\u0026gt; ); } 중첩을 줄이기 위해 컴포즈 패턴을 사용할 수 있습니다:\n// providers/AppProviders.jsx function compose(...Providers) { return function ComposedProviders({ children }) { return Providers.reduceRight((acc, Provider) =\u0026gt; { return \u0026lt;Provider\u0026gt;{acc}\u0026lt;/Provider\u0026gt;; }, children); }; } export const AppProviders = compose( AuthProvider, LicenseProvider, AgentProvider, ThemeProvider ); 사용:\nfunction App() { return ( \u0026lt;AppProviders\u0026gt; \u0026lt;Router\u0026gt; \u0026lt;Routes\u0026gt; \u0026lt;Route path=\u0026#34;/\u0026#34; element={\u0026lt;Dashboard /\u0026gt;} /\u0026gt; \u0026lt;/Routes\u0026gt; \u0026lt;/Router\u0026gt; \u0026lt;/AppProviders\u0026gt; ); } Provider 조합 전략 조건부 Provider 로딩 필요할 때만 Provider를 활성화합니다.\nfunction ConditionalProviders({ children, features }) { let tree = children; if (features.notifications) { tree = \u0026lt;NotificationProvider\u0026gt;{tree}\u0026lt;/NotificationProvider\u0026gt;; } if (features.realtime) { tree = \u0026lt;WebSocketProvider\u0026gt;{tree}\u0026lt;/WebSocketProvider\u0026gt;; } return tree; } // 사용 \u0026lt;ConditionalProviders features={{ notifications: true, realtime: false }}\u0026gt; \u0026lt;App /\u0026gt; \u0026lt;/ConditionalProviders\u0026gt; 의존성 있는 Provider 순서 일부 Provider는 다른 Provider에 의존합니다.\n// ❌ 나쁜 예: NotificationProvider가 AuthContext를 사용하는데 순서가 잘못됨 \u0026lt;NotificationProvider\u0026gt; \u0026lt;AuthProvider\u0026gt; \u0026lt;App /\u0026gt; \u0026lt;/AuthProvider\u0026gt; \u0026lt;/NotificationProvider\u0026gt; // ✅ 좋은 예: 의존성 순서 준수 \u0026lt;AuthProvider\u0026gt; \u0026lt;NotificationProvider\u0026gt; {/* AuthContext 사용 가능 */} \u0026lt;App /\u0026gt; \u0026lt;/NotificationProvider\u0026gt; \u0026lt;/AuthProvider\u0026gt; 의존성 그래프 예시:\nAuthProvider (독립) ↓ I18nProvider (AuthContext 사용) ↓ NotificationProvider (AuthContext, I18nContext 사용) ↓ App Context vs Redux vs Zustand 비교 Context API 장점:\nReact 내장 기능 별도 라이브러리 불필요 간단한 전역 상태에 적합 단점:\n복잡한 상태 로직에는 보일러플레이트가 많음 성능 최적화에 신경써야 함 DevTools 지원 제한적 Redux 장점:\n예측 가능한 상태 관리 강력한 DevTools 미들웨어 생태계 (redux-saga, redux-thunk) 단점:\n보일러플레이트 코드 많음 학습 곡선 높음 작은 앱에는 과한 설정 Zustand 장점:\n간단한 API 보일러플레이트 최소화 Context 없이 작동 (리렌더링 최적화) 단점:\nRedux만큼의 생태계는 아님 팀원이 익숙하지 않을 수 있음 // Zustand 예시 import { create } from \u0026#39;zustand\u0026#39;; const useStore = create((set) =\u0026gt; ({ user: null, login: (userData) =\u0026gt; set({ user: userData }), logout: () =\u0026gt; set({ user: null }) })); function UserMenu() { const { user, logout } = useStore(); return user ? \u0026lt;button onClick={logout}\u0026gt;로그아웃\u0026lt;/button\u0026gt; : null; } 선택 가이드:\n소규모 앱, 테마/인증만 관리 → Context API 대규모 엔터프라이즈, 복잡한 비즈니스 로직 → Redux 중규모 앱, 빠른 개발 → Zustand 실전 예시: 인증 상태 관리 JWT 토큰 기반 인증 시스템을 Context로 구현해봅시다.\n// contexts/AuthContext.jsx import { createContext, useContext, useState, useEffect, useCallback } from \u0026#39;react\u0026#39;; import { jwtDecode } from \u0026#39;jwt-decode\u0026#39;; const AuthContext = createContext(null); export function AuthProvider({ children }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); // 토큰 만료 체크 const isTokenExpired = useCallback((token) =\u0026gt; { try { const decoded = jwtDecode(token); return decoded.exp * 1000 \u0026lt; Date.now(); } catch { return true; } }, []); // 토큰 갱신 const refreshToken = useCallback(async () =\u0026gt; { try { const refreshToken = localStorage.getItem(\u0026#39;refreshToken\u0026#39;); const response = await fetch(\u0026#39;/api/refresh\u0026#39;, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, body: JSON.stringify({ refreshToken }) }); if (!response.ok) throw new Error(\u0026#39;토큰 갱신 실패\u0026#39;); const { accessToken, refreshToken: newRefreshToken } = await response.json(); localStorage.setItem(\u0026#39;token\u0026#39;, accessToken); localStorage.setItem(\u0026#39;refreshToken\u0026#39;, newRefreshToken); return accessToken; } catch (error) { console.error(\u0026#39;토큰 갱신 에러:\u0026#39;, error); logout(); return null; } }, []); // 초기 로드 useEffect(() =\u0026gt; { const initAuth = async () =\u0026gt; { const token = localStorage.getItem(\u0026#39;token\u0026#39;); if (!token) { setLoading(false); return; } // 토큰 만료 체크 if (isTokenExpired(token)) { const newToken = await refreshToken(); if (!newToken) { setLoading(false); return; } } // 사용자 정보 로드 try { const response = await fetch(\u0026#39;/api/user\u0026#39;, { headers: { Authorization: `Bearer ${token}` } }); const userData = await response.json(); setUser(userData); } catch (error) { console.error(\u0026#39;사용자 정보 로드 실패:\u0026#39;, error); logout(); } finally { setLoading(false); } }; initAuth(); }, [isTokenExpired, refreshToken]); // 자동 로그아웃 타이머 useEffect(() =\u0026gt; { if (!user) return; const token = localStorage.getItem(\u0026#39;token\u0026#39;); const decoded = jwtDecode(token); const expiresIn = decoded.exp * 1000 - Date.now(); // 만료 5분 전에 토큰 갱신 const refreshTimer = setTimeout(() =\u0026gt; { refreshToken(); }, expiresIn - 5 * 60 * 1000); return () =\u0026gt; clearTimeout(refreshTimer); }, [user, refreshToken]); const login = async (email, password) =\u0026gt; { const response = await fetch(\u0026#39;/api/login\u0026#39;, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, body: JSON.stringify({ email, password }) }); if (!response.ok) { throw new Error(\u0026#39;로그인 실패\u0026#39;); } const { accessToken, refreshToken, user } = await response.json(); localStorage.setItem(\u0026#39;token\u0026#39;, accessToken); localStorage.setItem(\u0026#39;refreshToken\u0026#39;, refreshToken); setUser(user); }; const logout = useCallback(() =\u0026gt; { localStorage.removeItem(\u0026#39;token\u0026#39;); localStorage.removeItem(\u0026#39;refreshToken\u0026#39;); setUser(null); }, []); const value = useMemo( () =\u0026gt; ({ user, loading, login, logout, refreshToken }), [user, loading, login, logout, refreshToken] ); return ( \u0026lt;AuthContext.Provider value={value}\u0026gt; {children} \u0026lt;/AuthContext.Provider\u0026gt; ); } export const useAuth = () =\u0026gt; { const context = useContext(AuthContext); if (!context) { throw new Error(\u0026#39;useAuth는 AuthProvider 내부에서 사용해야 합니다\u0026#39;); } return context; }; 보호된 라우트 구현:\n// components/ProtectedRoute.jsx import { Navigate } from \u0026#39;react-router-dom\u0026#39;; import { useAuth } from \u0026#39;../contexts/AuthContext\u0026#39;; export function ProtectedRoute({ children }) { const { user, loading } = useAuth(); if (loading) { return \u0026lt;div\u0026gt;로딩 중...\u0026lt;/div\u0026gt;; } if (!user) { return \u0026lt;Navigate to=\u0026#34;/login\u0026#34; replace /\u0026gt;; } return children; } 사용:\nfunction App() { return ( \u0026lt;AuthProvider\u0026gt; \u0026lt;Router\u0026gt; \u0026lt;Routes\u0026gt; \u0026lt;Route path=\u0026#34;/login\u0026#34; element={\u0026lt;Login /\u0026gt;} /\u0026gt; \u0026lt;Route path=\u0026#34;/dashboard\u0026#34; element={ \u0026lt;ProtectedRoute\u0026gt; \u0026lt;Dashboard /\u0026gt; \u0026lt;/ProtectedRoute\u0026gt; } /\u0026gt; \u0026lt;/Routes\u0026gt; \u0026lt;/Router\u0026gt; \u0026lt;/AuthProvider\u0026gt; ); } 마무리 Context API와 커스텀 Hooks를 활용하면 Redux 없이도 효과적인 전역 상태 관리가 가능합니다.\n핵심 정리:\nProps Drilling 방지: Context로 깊은 컴포넌트 트리에 값 전달 커스텀 Hooks: 재사용 가능한 로직을 Hook으로 추출 useReducer: 복잡한 상태 로직은 reducer로 관리 성능 최적화: React.memo, useMemo, Context 분리 외부 상태: useSyncExternalStore로 안전하게 동기화 실전 패턴: JWT 인증, 자동 갱신, 세션 만료 처리 다음 글에서는 React Query와 서버 상태 관리에 대해 알아보겠습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/react-state-management/","summary":"\u003cp\u003eProps drilling 문제를 겪어보신 적 있나요? 컴포넌트가 깊어질수록 상태를 여러 단계에 걸쳐 전달해야 하는 문제가 발생합니다. 이번 글에서는 전역 상태 관리 아키텍처 패턴과 실전 Context 조합 전략, 그리고 프로젝트 규모에 맞는 상태 관리 솔루션 선택 방법을 알아봅니다.\u003c/p\u003e\n\u003ch2 id=\"상태-관리의-필요성\"\u003e상태 관리의 필요성\u003c/h2\u003e\n\u003ch3 id=\"props-drilling-문제\"\u003eProps Drilling 문제\u003c/h3\u003e\n\u003cp\u003e중간 컴포넌트들이 실제로 사용하지 않는 props를 단순히 아래로 전달만 하는 상황입니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-jsx\" data-lang=\"jsx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// Props Drilling 예시\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f38ba8\"\u003efunction\u003c/span\u003e App() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003econst\u003c/span\u003e [user, setUser] \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e useState(\u003cspan style=\"color:#fab387\"\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  \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003eLayout\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003euser\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{user} \u003cspan style=\"color:#89b4fa\"\u003esetUser\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{setUser} /\u0026gt;;\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:#f38ba8\"\u003efunction\u003c/span\u003e Layout({ user, setUser }) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003ediv\u003c/span\u003e\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003eHeader\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003euser\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{user} \u003cspan style=\"color:#89b4fa\"\u003esetUser\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{setUser} /\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003eMain\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003euser\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{user} /\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u0026lt;/\u003cspan style=\"color:#cba6f7\"\u003ediv\u003c/span\u003e\u0026gt;\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:#f38ba8\"\u003efunction\u003c/span\u003e Header({ user, setUser }) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003eUserMenu\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003euser\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{user} \u003cspan style=\"color:#89b4fa\"\u003esetUser\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{setUser} /\u0026gt;;\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:#f38ba8\"\u003efunction\u003c/span\u003e UserMenu({ user, setUser }) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 실제로 사용하는 곳은 여기\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e user \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e?\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003ebutton\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003eonClick\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{() =\u0026gt; setUser(\u003cspan style=\"color:#fab387\"\u003enull\u003c/span\u003e)}\u0026gt;로그아웃\u0026lt;/\u003cspan style=\"color:#cba6f7\"\u003ebutton\u003c/span\u003e\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  ) \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e:\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003ebutton\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003eonClick\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{() =\u0026gt; setUser({ name\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#39;홍길동\u0026#39;\u003c/span\u003e })}\u0026gt;로그인\u0026lt;/\u003cspan style=\"color:#cba6f7\"\u003ebutton\u003c/span\u003e\u0026gt;\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\u003cp\u003e중간 컴포넌트들은 \u003ccode\u003euser\u003c/code\u003e와 \u003ccode\u003esetUser\u003c/code\u003e를 직접 사용하지 않지만 전달만 하고 있습니다. 이것이 Props Drilling 문제입니다.\u003c/p\u003e","tags":["React","상태관리","Context","Architecture"],"title":"React 상태 관리 - Context API, Provider 패턴, Redux vs Zustand 비교"},{"content":"HTTP 폴링 vs WebSocket 실시간 통신을 구현하는 방법은 크게 두 가지입니다.\nHTTP 폴링 (Polling) 클라이언트가 주기적으로 서버에 요청을 보내 새 데이터를 확인합니다.\n// Short Polling setInterval(() =\u0026gt; { fetch(\u0026#39;/api/messages\u0026#39;) .then(res =\u0026gt; res.json()) .then(data =\u0026gt; updateUI(data)); }, 1000); // 1초마다 요청 단점:\n불필요한 요청이 많음 (데이터가 없어도 요청) 서버 부하 증가 (동시 접속자 1000명 = 초당 1000건 요청) 실시간성 낮음 (폴링 간격만큼 지연) Long Polling 서버가 새 데이터가 있을 때까지 응답을 보류합니다.\nfunction longPoll() { fetch(\u0026#39;/api/messages/wait\u0026#39;) .then(res =\u0026gt; res.json()) .then(data =\u0026gt; { updateUI(data); longPoll(); // 다시 연결 }) .catch(() =\u0026gt; { setTimeout(longPoll, 5000); // 오류 시 재시도 }); } 개선점:\n불필요한 요청 감소 더 나은 실시간성 여전한 문제:\nHTTP 헤더 오버헤드 (매 요청마다 수백 바이트) 연결 수립/종료 비용 WebSocket 양방향 전이중(Full-Duplex) 통신을 제공하는 프로토콜입니다.\nconst ws = new WebSocket(\u0026#39;ws://localhost:8080/chat\u0026#39;); ws.onopen = () =\u0026gt; { console.log(\u0026#39;연결됨\u0026#39;); ws.send(JSON.stringify({ type: \u0026#39;join\u0026#39;, room: \u0026#39;general\u0026#39; })); }; ws.onmessage = (event) =\u0026gt; { const data = JSON.parse(event.data); updateUI(data); }; ws.onerror = (error) =\u0026gt; { console.error(\u0026#39;오류:\u0026#39;, error); }; ws.onclose = () =\u0026gt; { console.log(\u0026#39;연결 종료\u0026#39;); // 재연결 로직 }; 장점:\n낮은 지연 시간 (\u0026lt; 10ms) 작은 오버헤드 (프레임 헤더 2-14 바이트) 양방향 통신 (서버 → 클라이언트 푸시 가능) 하나의 TCP 연결로 지속적 통신 WebSocket 프로토콜 이해 WebSocket은 HTTP 위에서 동작하지만 독립적인 프로토콜입니다.\n핸드셰이크 (Handshake) 클라이언트가 HTTP 요청으로 WebSocket 연결을 시작합니다.\nGET /chat HTTP/1.1 Host: localhost:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 서버가 응답하면 프로토콜이 WebSocket으로 전환됩니다.\nHTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= WebSocket 프레임 구조 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+ 주요 필드:\nFIN: 최종 프레임 여부 opcode: 프레임 타입 (0x1=텍스트, 0x2=바이너리, 0x8=종료) MASK: 페이로드 마스킹 여부 (클라이언트 → 서버는 필수) Payload len: 페이로드 길이 Spring Boot WebSocket 설정 Spring Boot는 WebSocket을 쉽게 구현할 수 있는 추상화를 제공합니다.\n의존성 추가 \u0026lt;!-- pom.xml --\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-websocket\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; WebSocket 설정 @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler(), \u0026#34;/chat\u0026#34;) .setAllowedOrigins(\u0026#34;*\u0026#34;); // CORS 설정 } @Bean public WebSocketHandler chatHandler() { return new ChatWebSocketHandler(); } } WebSocketHandler 구현 @Component public class ChatWebSocketHandler extends TextWebSocketHandler { private static final Set\u0026lt;WebSocketSession\u0026gt; sessions = Collections.synchronizedSet(new HashSet\u0026lt;\u0026gt;()); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { sessions.add(session); System.out.println(\u0026#34;새 연결: \u0026#34; + session.getId()); // 환영 메시지 전송 session.sendMessage(new TextMessage( \u0026#34;{\\\u0026#34;type\\\u0026#34;:\\\u0026#34;system\\\u0026#34;,\\\u0026#34;message\\\u0026#34;:\\\u0026#34;채팅방에 입장했습니다\\\u0026#34;}\u0026#34; )); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); System.out.println(\u0026#34;수신: \u0026#34; + payload); // 모든 클라이언트에게 브로드캐스트 synchronized (sessions) { for (WebSocketSession s : sessions) { if (s.isOpen()) { s.sendMessage(message); } } } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { sessions.remove(session); System.out.println(\u0026#34;연결 종료: \u0026#34; + session.getId() + \u0026#34;, 상태: \u0026#34; + status); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { System.err.println(\u0026#34;전송 오류: \u0026#34; + exception.getMessage()); session.close(); } } STOMP 프로토콜 STOMP (Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 메시징 프로토콜입니다.\nSTOMP를 사용하는 이유 WebSocket만으로는 메시지 라우팅, 구독 관리가 어렵습니다. STOMP는 이를 표준화합니다.\nSTOMP의 장점:\n메시지 라우팅 (/topic, /queue, /user) 구독 관리 (subscribe/unsubscribe) ACK/NACK 메커니즘 트랜잭션 지원 STOMP 프레임 구조 COMMAND header1:value1 header2:value2 Body^@ 예시: 구독 (SUBSCRIBE)\nSUBSCRIBE id:sub-1 destination:/topic/chat ^@ 예시: 메시지 전송 (SEND)\nSEND destination:/app/chat content-type:application/json {\u0026#34;message\u0026#34;:\u0026#34;Hello World\u0026#34;}^@ 예시: 메시지 수신 (MESSAGE)\nMESSAGE destination:/topic/chat message-id:123 subscription:sub-1 {\u0026#34;message\u0026#34;:\u0026#34;Hello World\u0026#34;}^@ Spring Boot STOMP 설정 @EnableWebSocketMessageBroker @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { // 클라이언트로 메시지를 전달하는 브로커 설정 config.enableSimpleBroker(\u0026#34;/topic\u0026#34;, \u0026#34;/queue\u0026#34;); // 클라이언트가 메시지를 보낼 목적지 prefix config.setApplicationDestinationPrefixes(\u0026#34;/app\u0026#34;); // 특정 사용자에게 메시지를 보낼 때 사용 config.setUserDestinationPrefix(\u0026#34;/user\u0026#34;); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { // STOMP 엔드포인트 등록 registry.addEndpoint(\u0026#34;/ws\u0026#34;) .setAllowedOriginPatterns(\u0026#34;*\u0026#34;) .withSockJS(); // SockJS 폴백 지원 } } 메시지 컨트롤러 @Controller public class ChatController { @MessageMapping(\u0026#34;/chat.send\u0026#34;) // 클라이언트가 /app/chat.send로 전송 @SendTo(\u0026#34;/topic/chat\u0026#34;) // 구독자 모두에게 전달 public ChatMessage sendMessage(ChatMessage message) { message.setTimestamp(System.currentTimeMillis()); return message; } @MessageMapping(\u0026#34;/chat.join\u0026#34;) @SendTo(\u0026#34;/topic/chat\u0026#34;) public ChatMessage joinChat(@Payload ChatMessage message, SimpMessageHeaderAccessor headerAccessor) { // 세션에 사용자 이름 저장 headerAccessor.getSessionAttributes().put(\u0026#34;username\u0026#34;, message.getSender()); message.setType(ChatMessage.MessageType.JOIN); message.setContent(message.getSender() + \u0026#34;님이 입장했습니다\u0026#34;); return message; } } 메시지 모델 public class ChatMessage { public enum MessageType { CHAT, JOIN, LEAVE } private MessageType type; private String content; private String sender; private long timestamp; // Getters, Setters } 연결 이벤트 처리 @Component public class WebSocketEventListener { private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class); @Autowired private SimpMessageSendingOperations messagingTemplate; @EventListener public void handleWebSocketConnectListener(SessionConnectedEvent event) { logger.info(\u0026#34;새로운 WebSocket 연결\u0026#34;); } @EventListener public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); String username = (String) headerAccessor.getSessionAttributes().get(\u0026#34;username\u0026#34;); if (username != null) { logger.info(\u0026#34;사용자 퇴장: \u0026#34; + username); ChatMessage chatMessage = new ChatMessage(); chatMessage.setType(ChatMessage.MessageType.LEAVE); chatMessage.setSender(username); chatMessage.setContent(username + \u0026#34;님이 퇴장했습니다\u0026#34;); messagingTemplate.convertAndSend(\u0026#34;/topic/chat\u0026#34;, chatMessage); } } } SimpMessagingTemplate으로 서버에서 메시지 발행 서버 측에서 임의로 메시지를 전송할 수 있습니다.\n전체 브로드캐스트 @Service public class NotificationService { @Autowired private SimpMessagingTemplate messagingTemplate; public void notifyAllUsers(String message) { messagingTemplate.convertAndSend(\u0026#34;/topic/notifications\u0026#34;, message); } // 스케줄링 예시 @Scheduled(fixedRate = 60000) // 1분마다 public void sendHeartbeat() { Map\u0026lt;String, Object\u0026gt; heartbeat = Map.of( \u0026#34;type\u0026#34;, \u0026#34;heartbeat\u0026#34;, \u0026#34;timestamp\u0026#34;, System.currentTimeMillis() ); messagingTemplate.convertAndSend(\u0026#34;/topic/heartbeat\u0026#34;, heartbeat); } } 특정 사용자에게 전송 @Service public class UserNotificationService { @Autowired private SimpMessagingTemplate messagingTemplate; public void notifyUser(String username, String message) { messagingTemplate.convertAndSendToUser( username, \u0026#34;/queue/notifications\u0026#34;, message ); } // 사용 예시 public void sendPrivateMessage(String from, String to, String content) { PrivateMessage msg = new PrivateMessage(from, content, System.currentTimeMillis()); messagingTemplate.convertAndSendToUser(to, \u0026#34;/queue/private\u0026#34;, msg); } } 조건부 전송 @Service public class ConditionalBroadcast { @Autowired private SimpMessagingTemplate messagingTemplate; public void notifyActiveUsers(String message) { // 특정 조건을 만족하는 사용자만 필터링 List\u0026lt;String\u0026gt; activeUsers = getActiveUsers(); for (String user : activeUsers) { messagingTemplate.convertAndSendToUser( user, \u0026#34;/queue/updates\u0026#34;, message ); } } private List\u0026lt;String\u0026gt; getActiveUsers() { // 활성 사용자 목록 조회 로직 return Arrays.asList(\u0026#34;user1\u0026#34;, \u0026#34;user2\u0026#34;, \u0026#34;user3\u0026#34;); } } 클라이언트 구현: @stomp/stompjs + SockJS 설치 npm install @stomp/stompjs sockjs-client React 클라이언트 예시 import { Client } from \u0026#39;@stomp/stompjs\u0026#39;; import SockJS from \u0026#39;sockjs-client\u0026#39;; import { useState, useEffect, useRef } from \u0026#39;react\u0026#39;; function ChatComponent() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(\u0026#39;\u0026#39;); const [username, setUsername] = useState(\u0026#39;\u0026#39;); const [connected, setConnected] = useState(false); const clientRef = useRef(null); useEffect(() =\u0026gt; { if (username) { connect(); } return () =\u0026gt; { if (clientRef.current) { clientRef.current.deactivate(); } }; }, [username]); const connect = () =\u0026gt; { const client = new Client({ webSocketFactory: () =\u0026gt; new SockJS(\u0026#39;http://localhost:8080/ws\u0026#39;), onConnect: () =\u0026gt; { console.log(\u0026#39;연결됨\u0026#39;); setConnected(true); // 채팅방 구독 client.subscribe(\u0026#39;/topic/chat\u0026#39;, (message) =\u0026gt; { const chatMessage = JSON.parse(message.body); setMessages(prev =\u0026gt; [...prev, chatMessage]); }); // 입장 메시지 전송 client.publish({ destination: \u0026#39;/app/chat.join\u0026#39;, body: JSON.stringify({ sender: username, type: \u0026#39;JOIN\u0026#39; }) }); }, onStompError: (frame) =\u0026gt; { console.error(\u0026#39;STOMP 오류:\u0026#39;, frame.headers[\u0026#39;message\u0026#39;]); console.error(\u0026#39;상세:\u0026#39;, frame.body); }, onDisconnect: () =\u0026gt; { console.log(\u0026#39;연결 종료\u0026#39;); setConnected(false); } }); clientRef.current = client; client.activate(); }; const sendMessage = () =\u0026gt; { if (input.trim() \u0026amp;\u0026amp; clientRef.current) { clientRef.current.publish({ destination: \u0026#39;/app/chat.send\u0026#39;, body: JSON.stringify({ sender: username, content: input, type: \u0026#39;CHAT\u0026#39; }) }); setInput(\u0026#39;\u0026#39;); } }; return ( \u0026lt;div\u0026gt; {!username ? ( \u0026lt;div\u0026gt; \u0026lt;input placeholder=\u0026#34;이름 입력\u0026#34; onKeyPress={(e) =\u0026gt; { if (e.key === \u0026#39;Enter\u0026#39;) setUsername(e.target.value); }} /\u0026gt; \u0026lt;/div\u0026gt; ) : ( \u0026lt;div\u0026gt; \u0026lt;div className=\u0026#34;messages\u0026#34;\u0026gt; {messages.map((msg, idx) =\u0026gt; ( \u0026lt;div key={idx} className={`message ${msg.type}`}\u0026gt; \u0026lt;strong\u0026gt;{msg.sender}:\u0026lt;/strong\u0026gt; {msg.content} \u0026lt;/div\u0026gt; ))} \u0026lt;/div\u0026gt; \u0026lt;div className=\u0026#34;input-area\u0026#34;\u0026gt; \u0026lt;input value={input} onChange={(e) =\u0026gt; setInput(e.target.value)} onKeyPress={(e) =\u0026gt; { if (e.key === \u0026#39;Enter\u0026#39;) sendMessage(); }} disabled={!connected} /\u0026gt; \u0026lt;button onClick={sendMessage} disabled={!connected}\u0026gt; 전송 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; )} \u0026lt;/div\u0026gt; ); } export default ChatComponent; Vanilla JavaScript 클라이언트 import { Client } from \u0026#39;@stomp/stompjs\u0026#39;; import SockJS from \u0026#39;sockjs-client\u0026#39;; class WebSocketClient { constructor(url, username) { this.url = url; this.username = username; this.client = null; this.subscriptions = new Map(); } connect() { this.client = new Client({ webSocketFactory: () =\u0026gt; new SockJS(this.url), reconnectDelay: 5000, // 재연결 대기 시간 heartbeatIncoming: 20000, // 서버로부터 하트비트 기대 주기 heartbeatOutgoing: 20000, // 클라이언트 하트비트 전송 주기 onConnect: (frame) =\u0026gt; { console.log(\u0026#39;연결됨:\u0026#39;, frame); this.onConnected(); }, onStompError: (frame) =\u0026gt; { console.error(\u0026#39;STOMP 오류:\u0026#39;, frame); }, onWebSocketClose: (event) =\u0026gt; { console.log(\u0026#39;WebSocket 종료:\u0026#39;, event); } }); this.client.activate(); } onConnected() { // 채팅 메시지 구독 this.subscribe(\u0026#39;/topic/chat\u0026#39;, (message) =\u0026gt; { this.handleChatMessage(JSON.parse(message.body)); }); // 개인 알림 구독 this.subscribe(`/user/queue/notifications`, (message) =\u0026gt; { this.handleNotification(JSON.parse(message.body)); }); } subscribe(destination, callback) { const subscription = this.client.subscribe(destination, callback); this.subscriptions.set(destination, subscription); return subscription; } unsubscribe(destination) { const subscription = this.subscriptions.get(destination); if (subscription) { subscription.unsubscribe(); this.subscriptions.delete(destination); } } send(destination, body) { this.client.publish({ destination, body: JSON.stringify(body) }); } disconnect() { if (this.client) { this.client.deactivate(); } } handleChatMessage(message) { console.log(\u0026#39;채팅 메시지:\u0026#39;, message); // UI 업데이트 로직 } handleNotification(notification) { console.log(\u0026#39;알림:\u0026#39;, notification); // 알림 표시 로직 } } // 사용 예시 const client = new WebSocketClient(\u0026#39;http://localhost:8080/ws\u0026#39;, \u0026#39;user123\u0026#39;); client.connect(); // 메시지 전송 setTimeout(() =\u0026gt; { client.send(\u0026#39;/app/chat.send\u0026#39;, { sender: \u0026#39;user123\u0026#39;, content: \u0026#39;Hello!\u0026#39;, type: \u0026#39;CHAT\u0026#39; }); }, 2000); SockJS 폴백 메커니즘 SockJS는 WebSocket을 지원하지 않는 환경에서 자동으로 폴백을 제공합니다.\n폴백 순서 WebSocket (최우선) HTTP Streaming (xhr-streaming, iframe-eventsource) HTTP Long-Polling (xhr-polling, jsonp-polling) Spring Boot 설정 @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(\u0026#34;/ws\u0026#34;) .setAllowedOriginPatterns(\u0026#34;*\u0026#34;) .withSockJS() .setStreamBytesLimit(512 * 1024) // 스트리밍 제한 .setHttpMessageCacheSize(1000) // 메시지 캐시 크기 .setDisconnectDelay(30 * 1000); // 연결 종료 대기 시간 } 클라이언트 옵션 설정 const client = new Client({ webSocketFactory: () =\u0026gt; new SockJS(\u0026#39;http://localhost:8080/ws\u0026#39;, null, { transports: [\u0026#39;websocket\u0026#39;, \u0026#39;xhr-streaming\u0026#39;, \u0026#39;xhr-polling\u0026#39;], timeout: 5000 }), debug: (str) =\u0026gt; { console.log(\u0026#39;SockJS Debug:\u0026#39;, str); } }); 전송 방식 감지 const sockjs = new SockJS(\u0026#39;http://localhost:8080/ws\u0026#39;); sockjs.onopen = function() { console.log(\u0026#39;사용된 전송 방식:\u0026#39;, sockjs.protocol); // 출력 예: \u0026#34;websocket\u0026#34;, \u0026#34;xhr-streaming\u0026#34;, \u0026#34;xhr-polling\u0026#34; }; 하트비트 최적화 하트비트는 연결 상태를 확인하지만 과도하면 네트워크 트래픽을 증가시킵니다.\n기본 설정 문제 // 기본 설정 (4초 간격) heartbeatIncoming: 4000, heartbeatOutgoing: 4000 트래픽 계산:\n하트비트 프레임 크기: 약 2 바이트 초당 전송량: 2 bytes / 4s = 0.5 bytes/s 사용자 1000명 × 0.5 = 500 bytes/s 하루: 500 × 86400 = 43.2 MB/day 최적화된 설정 // 최적화 (20초 간격) heartbeatIncoming: 20000, heartbeatOutgoing: 20000 개선 효과:\n트래픽 80% 절감 서버 CPU 사용량 감소 연결 안정성 유지 서버 측 설정 @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker(\u0026#34;/topic\u0026#34;, \u0026#34;/queue\u0026#34;) .setHeartbeatValue(new long[]{20000, 20000}); // [서버→클라이언트, 클라이언트→서버] config.setApplicationDestinationPrefixes(\u0026#34;/app\u0026#34;); } } 동적 하트비트 조정 class AdaptiveHeartbeat { constructor(client) { this.client = client; this.currentInterval = 20000; this.minInterval = 5000; this.maxInterval = 60000; } onConnectionStable() { // 연결이 안정적이면 간격 증가 this.currentInterval = Math.min( this.currentInterval * 1.5, this.maxInterval ); this.updateHeartbeat(); } onConnectionUnstable() { // 연결이 불안정하면 간격 감소 this.currentInterval = Math.max( this.currentInterval / 2, this.minInterval ); this.updateHeartbeat(); } updateHeartbeat() { this.client.heartbeatIncoming = this.currentInterval; this.client.heartbeatOutgoing = this.currentInterval; console.log(\u0026#39;하트비트 간격 조정:\u0026#39;, this.currentInterval); } } 재연결 전략 네트워크 오류 시 효과적으로 재연결하는 전략이 필요합니다.\nFast Retry + Slow Reconnect class ReconnectStrategy { constructor() { this.fastRetryCount = 0; this.maxFastRetries = 3; this.fastRetryDelay = 5000; // 5초 this.slowRetryDelay = 60000; // 60초 } getDelay() { if (this.fastRetryCount \u0026lt; this.maxFastRetries) { return this.fastRetryDelay; } return this.slowRetryDelay; } onReconnectAttempt() { this.fastRetryCount++; } onConnected() { this.fastRetryCount = 0; // 성공 시 리셋 } } const reconnectStrategy = new ReconnectStrategy(); const client = new Client({ webSocketFactory: () =\u0026gt; new SockJS(\u0026#39;http://localhost:8080/ws\u0026#39;), reconnectDelay: 0, // 직접 관리 onConnect: () =\u0026gt; { console.log(\u0026#39;연결 성공\u0026#39;); reconnectStrategy.onConnected(); }, onWebSocketClose: () =\u0026gt; { console.log(\u0026#39;연결 종료, 재연결 대기 중...\u0026#39;); reconnectStrategy.onReconnectAttempt(); const delay = reconnectStrategy.getDelay(); console.log(`${delay / 1000}초 후 재연결 시도 (${reconnectStrategy.fastRetryCount}/${reconnectStrategy.maxFastRetries})`); setTimeout(() =\u0026gt; { client.activate(); }, delay); } }); 지수 백오프 class ExponentialBackoff { constructor(initialDelay = 1000, maxDelay = 60000, multiplier = 2) { this.initialDelay = initialDelay; this.maxDelay = maxDelay; this.multiplier = multiplier; this.currentDelay = initialDelay; } getDelay() { const delay = this.currentDelay; this.currentDelay = Math.min( this.currentDelay * this.multiplier, this.maxDelay ); return delay; } reset() { this.currentDelay = this.initialDelay; } } const backoff = new ExponentialBackoff(1000, 60000, 2); client.onWebSocketClose = () =\u0026gt; { const delay = backoff.getDelay(); console.log(`${delay}ms 후 재연결 (지수 백오프)`); setTimeout(() =\u0026gt; { client.activate(); }, delay); }; client.onConnect = () =\u0026gt; { backoff.reset(); // 성공 시 리셋 }; 연결 상태 관리 class ConnectionManager { constructor(client) { this.client = client; this.state = \u0026#39;disconnected\u0026#39;; // disconnected, connecting, connected this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; } connect() { if (this.state === \u0026#39;connected\u0026#39; || this.state === \u0026#39;connecting\u0026#39;) { console.log(\u0026#39;이미 연결 중 또는 연결됨\u0026#39;); return; } this.state = \u0026#39;connecting\u0026#39;; this.client.activate(); } onConnected() { this.state = \u0026#39;connected\u0026#39;; this.reconnectAttempts = 0; this.notifyStateChange(\u0026#39;connected\u0026#39;); } onDisconnected() { this.state = \u0026#39;disconnected\u0026#39;; this.notifyStateChange(\u0026#39;disconnected\u0026#39;); if (this.reconnectAttempts \u0026lt; this.maxReconnectAttempts) { this.reconnect(); } else { console.error(\u0026#39;최대 재연결 시도 횟수 초과\u0026#39;); this.notifyStateChange(\u0026#39;failed\u0026#39;); } } reconnect() { this.reconnectAttempts++; console.log(`재연결 시도 ${this.reconnectAttempts}/${this.maxReconnectAttempts}`); const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 60000); setTimeout(() =\u0026gt; this.connect(), delay); } notifyStateChange(state) { // UI 업데이트 또는 콜백 호출 console.log(\u0026#39;연결 상태:\u0026#39;, state); document.dispatchEvent(new CustomEvent(\u0026#39;connectionState\u0026#39;, { detail: state })); } } const manager = new ConnectionManager(client); client.onConnect = () =\u0026gt; manager.onConnected(); client.onWebSocketClose = () =\u0026gt; manager.onDisconnected(); manager.connect(); 토픽 구독과 메시지 라우팅 STOMP는 목적지 기반 라우팅을 제공합니다.\n토픽 vs 큐 토픽 (/topic): 브로드캐스트 (구독자 모두에게 전달)\n@SendTo(\u0026#34;/topic/notifications\u0026#34;) public Notification sendNotification(Notification notification) { return notification; } 큐 (/queue): Point-to-Point (한 구독자에게만 전달)\n@MessageMapping(\u0026#34;/task.assign\u0026#34;) @SendToUser(\u0026#34;/queue/tasks\u0026#34;) public Task assignTask(Task task) { return task; } 다중 구독 // 여러 토픽 구독 const subscriptions = [ client.subscribe(\u0026#39;/topic/agent-status\u0026#39;, handleAgentStatus), client.subscribe(\u0026#39;/topic/progress\u0026#39;, handleProgress), client.subscribe(\u0026#39;/user/queue/alerts\u0026#39;, handleAlert) ]; function handleAgentStatus(message) { const status = JSON.parse(message.body); console.log(\u0026#39;에이전트 상태:\u0026#39;, status); updateAgentStatusUI(status); } function handleProgress(message) { const progress = JSON.parse(message.body); console.log(\u0026#39;진행률:\u0026#39;, progress.percentage + \u0026#39;%\u0026#39;); updateProgressBar(progress.percentage); } function handleAlert(message) { const alert = JSON.parse(message.body); showNotification(alert.title, alert.message); } // 구독 해제 function cleanup() { subscriptions.forEach(sub =\u0026gt; sub.unsubscribe()); } 동적 라우팅 @Controller public class DynamicRoutingController { @Autowired private SimpMessagingTemplate messagingTemplate; @MessageMapping(\u0026#34;/data.update\u0026#34;) public void handleDataUpdate(@Payload DataUpdate update, @Header(\u0026#34;target\u0026#34;) String target) { // 헤더 기반 라우팅 String destination = \u0026#34;/topic/\u0026#34; + target; messagingTemplate.convertAndSend(destination, update); } // 조건부 라우팅 @MessageMapping(\u0026#34;/message.send\u0026#34;) public void sendMessage(@Payload Message message) { if (message.isPriority()) { messagingTemplate.convertAndSend(\u0026#34;/topic/priority\u0026#34;, message); } else { messagingTemplate.convertAndSend(\u0026#34;/topic/general\u0026#34;, message); } } } 실전 예시: 파일 전송 진행률 실시간 모니터링 파일 업로드 진행률을 WebSocket으로 실시간 모니터링하는 대시보드를 만들어보겠습니다.\n백엔드: 파일 업로드 서비스 @Service public class FileUploadService { @Autowired private SimpMessagingTemplate messagingTemplate; public void uploadFile(String uploadId, MultipartFile file, String userId) { long totalSize = file.getSize(); long uploadedSize = 0; try (InputStream is = file.getInputStream()) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = is.read(buffer)) != -1) { // 파일 처리 로직 (저장 등) processChunk(buffer, bytesRead); uploadedSize += bytesRead; int percentage = (int) ((uploadedSize * 100) / totalSize); // 진행률 전송 sendProgress(userId, uploadId, percentage, uploadedSize, totalSize); // 시뮬레이션을 위한 지연 (실제로는 불필요) Thread.sleep(50); } // 완료 알림 sendCompletion(userId, uploadId, file.getOriginalFilename()); } catch (IOException | InterruptedException e) { sendError(userId, uploadId, e.getMessage()); } } private void sendProgress(String userId, String uploadId, int percentage, long uploaded, long total) { Map\u0026lt;String, Object\u0026gt; progress = Map.of( \u0026#34;uploadId\u0026#34;, uploadId, \u0026#34;percentage\u0026#34;, percentage, \u0026#34;uploadedBytes\u0026#34;, uploaded, \u0026#34;totalBytes\u0026#34;, total, \u0026#34;timestamp\u0026#34;, System.currentTimeMillis() ); messagingTemplate.convertAndSendToUser( userId, \u0026#34;/queue/upload-progress\u0026#34;, progress ); } private void sendCompletion(String userId, String uploadId, String filename) { Map\u0026lt;String, Object\u0026gt; completion = Map.of( \u0026#34;uploadId\u0026#34;, uploadId, \u0026#34;filename\u0026#34;, filename, \u0026#34;status\u0026#34;, \u0026#34;completed\u0026#34;, \u0026#34;timestamp\u0026#34;, System.currentTimeMillis() ); messagingTemplate.convertAndSendToUser( userId, \u0026#34;/queue/upload-complete\u0026#34;, completion ); } private void sendError(String userId, String uploadId, String error) { Map\u0026lt;String, Object\u0026gt; errorMsg = Map.of( \u0026#34;uploadId\u0026#34;, uploadId, \u0026#34;error\u0026#34;, error, \u0026#34;timestamp\u0026#34;, System.currentTimeMillis() ); messagingTemplate.convertAndSendToUser( userId, \u0026#34;/queue/upload-error\u0026#34;, errorMsg ); } private void processChunk(byte[] buffer, int length) { // 실제 파일 저장 로직 } } 백엔드: 업로드 컨트롤러 @RestController @RequestMapping(\u0026#34;/api/upload\u0026#34;) public class UploadController { @Autowired private FileUploadService uploadService; @PostMapping public ResponseEntity\u0026lt;Map\u0026lt;String, String\u0026gt;\u0026gt; uploadFile( @RequestParam(\u0026#34;file\u0026#34;) MultipartFile file, @AuthenticationPrincipal User user) { String uploadId = UUID.randomUUID().toString(); // 비동기 처리 CompletableFuture.runAsync(() -\u0026gt; { uploadService.uploadFile(uploadId, file, user.getUsername()); }); return ResponseEntity.ok(Map.of( \u0026#34;uploadId\u0026#34;, uploadId, \u0026#34;message\u0026#34;, \u0026#34;Upload started\u0026#34; )); } } 프론트엔드: React 대시보드 import { Client } from \u0026#39;@stomp/stompjs\u0026#39;; import SockJS from \u0026#39;sockjs-client\u0026#39;; import { useState, useEffect } from \u0026#39;react\u0026#39;; function FileUploadDashboard() { const [uploads, setUploads] = useState({}); const [client, setClient] = useState(null); const [selectedFile, setSelectedFile] = useState(null); useEffect(() =\u0026gt; { const stompClient = new Client({ webSocketFactory: () =\u0026gt; new SockJS(\u0026#39;http://localhost:8080/ws\u0026#39;), onConnect: () =\u0026gt; { console.log(\u0026#39;WebSocket 연결됨\u0026#39;); // 진행률 구독 stompClient.subscribe(\u0026#39;/user/queue/upload-progress\u0026#39;, (message) =\u0026gt; { const progress = JSON.parse(message.body); updateProgress(progress); }); // 완료 알림 구독 stompClient.subscribe(\u0026#39;/user/queue/upload-complete\u0026#39;, (message) =\u0026gt; { const completion = JSON.parse(message.body); handleCompletion(completion); }); // 오류 알림 구독 stompClient.subscribe(\u0026#39;/user/queue/upload-error\u0026#39;, (message) =\u0026gt; { const error = JSON.parse(message.body); handleError(error); }); } }); stompClient.activate(); setClient(stompClient); return () =\u0026gt; { stompClient.deactivate(); }; }, []); const updateProgress = (progress) =\u0026gt; { setUploads(prev =\u0026gt; ({ ...prev, [progress.uploadId]: { ...prev[progress.uploadId], percentage: progress.percentage, uploadedBytes: progress.uploadedBytes, totalBytes: progress.totalBytes, status: \u0026#39;uploading\u0026#39; } })); }; const handleCompletion = (completion) =\u0026gt; { setUploads(prev =\u0026gt; ({ ...prev, [completion.uploadId]: { ...prev[completion.uploadId], filename: completion.filename, status: \u0026#39;completed\u0026#39;, percentage: 100 } })); }; const handleError = (error) =\u0026gt; { setUploads(prev =\u0026gt; ({ ...prev, [error.uploadId]: { ...prev[error.uploadId], status: \u0026#39;error\u0026#39;, error: error.error } })); }; const uploadFile = async () =\u0026gt; { if (!selectedFile) return; const formData = new FormData(); formData.append(\u0026#39;file\u0026#39;, selectedFile); try { const response = await fetch(\u0026#39;http://localhost:8080/api/upload\u0026#39;, { method: \u0026#39;POST\u0026#39;, body: formData, credentials: \u0026#39;include\u0026#39; }); const data = await response.json(); setUploads(prev =\u0026gt; ({ ...prev, [data.uploadId]: { filename: selectedFile.name, totalBytes: selectedFile.size, uploadedBytes: 0, percentage: 0, status: \u0026#39;started\u0026#39; } })); } catch (error) { console.error(\u0026#39;업로드 시작 실패:\u0026#39;, error); } }; return ( \u0026lt;div className=\u0026#34;upload-dashboard\u0026#34;\u0026gt; \u0026lt;h2\u0026gt;파일 업로드 대시보드\u0026lt;/h2\u0026gt; \u0026lt;div className=\u0026#34;upload-controls\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;file\u0026#34; onChange={(e) =\u0026gt; setSelectedFile(e.target.files[0])} /\u0026gt; \u0026lt;button onClick={uploadFile}\u0026gt;업로드\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div className=\u0026#34;upload-list\u0026#34;\u0026gt; {Object.entries(uploads).map(([uploadId, upload]) =\u0026gt; ( \u0026lt;div key={uploadId} className=\u0026#34;upload-item\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;filename\u0026#34;\u0026gt;{upload.filename}\u0026lt;/div\u0026gt; \u0026lt;div className=\u0026#34;progress-bar\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;progress-fill\u0026#34; style={{ width: `${upload.percentage}%` }} /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div className=\u0026#34;upload-stats\u0026#34;\u0026gt; \u0026lt;span\u0026gt;{upload.percentage}%\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt; {formatBytes(upload.uploadedBytes)} / {formatBytes(upload.totalBytes)} \u0026lt;/span\u0026gt; \u0026lt;span className={`status ${upload.status}`}\u0026gt; {upload.status} \u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; {upload.error \u0026amp;\u0026amp; ( \u0026lt;div className=\u0026#34;error\u0026#34;\u0026gt;{upload.error}\u0026lt;/div\u0026gt; )} \u0026lt;/div\u0026gt; ))} \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; ); } function formatBytes(bytes) { if (bytes === 0) return \u0026#39;0 Bytes\u0026#39;; const k = 1024; const sizes = [\u0026#39;Bytes\u0026#39;, \u0026#39;KB\u0026#39;, \u0026#39;MB\u0026#39;, \u0026#39;GB\u0026#39;]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \u0026#39; \u0026#39; + sizes[i]; } export default FileUploadDashboard; CSS 스타일 .upload-dashboard { max-width: 800px; margin: 0 auto; padding: 20px; } .upload-controls { margin-bottom: 20px; } .upload-item { border: 1px solid #ddd; padding: 15px; margin-bottom: 10px; border-radius: 5px; } .filename { font-weight: bold; margin-bottom: 10px; } .progress-bar { width: 100%; height: 20px; background: #f0f0f0; border-radius: 10px; overflow: hidden; margin-bottom: 10px; } .progress-fill { height: 100%; background: linear-gradient(90deg, #4CAF50, #45a049); transition: width 0.3s ease; } .upload-stats { display: flex; justify-content: space-between; font-size: 14px; color: #666; } .status { padding: 2px 8px; border-radius: 3px; } .status.uploading { background: #2196F3; color: white; } .status.completed { background: #4CAF50; color: white; } .status.error { background: #f44336; color: white; } .error { color: #f44336; margin-top: 10px; font-size: 14px; } 마무리 WebSocket과 STOMP 프로토콜을 활용하면 효율적인 실시간 양방향 통신을 구현할 수 있습니다.\n핵심 내용 정리:\nWebSocket은 HTTP 폴링보다 낮은 지연과 오버헤드 STOMP는 메시지 라우팅과 구독 관리 표준화 Spring Boot는 @EnableWebSocketMessageBroker로 간편한 설정 SockJS는 WebSocket 미지원 환경에서 자동 폴백 하트비트 최적화로 트래픽 절감 재연결 전략으로 안정적인 연결 유지 토픽/큐 기반 메시지 라우팅 다음 단계:\nRedis 백엔드로 클러스터 환경 지원 Spring Security로 WebSocket 인증/인가 메시지 압축으로 대역폭 절감 RabbitMQ/Kafka와 통합 실시간 통신은 현대 웹 애플리케이션의 필수 요소입니다. WebSocket과 STOMP로 사용자 경험을 한 단계 끌어올려보세요.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/network-websocket-stomp/","summary":"\u003ch2 id=\"http-폴링-vs-websocket\"\u003eHTTP 폴링 vs WebSocket\u003c/h2\u003e\n\u003cp\u003e실시간 통신을 구현하는 방법은 크게 두 가지입니다.\u003c/p\u003e\n\u003ch3 id=\"http-폴링-polling\"\u003eHTTP 폴링 (Polling)\u003c/h3\u003e\n\u003cp\u003e클라이언트가 주기적으로 서버에 요청을 보내 새 데이터를 확인합니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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:#6c7086;font-style:italic\"\u003e// Short Polling\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esetInterval(() =\u0026gt; {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    fetch(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#39;/api/messages\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        .then(res =\u0026gt; res.json())\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        .then(data =\u0026gt; updateUI(data));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}, \u003cspan style=\"color:#fab387\"\u003e1000\u003c/span\u003e); \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 1초마다 요청\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e단점:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e불필요한 요청이 많음 (데이터가 없어도 요청)\u003c/li\u003e\n\u003cli\u003e서버 부하 증가 (동시 접속자 1000명 = 초당 1000건 요청)\u003c/li\u003e\n\u003cli\u003e실시간성 낮음 (폴링 간격만큼 지연)\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"long-polling\"\u003eLong Polling\u003c/h3\u003e\n\u003cp\u003e서버가 새 데이터가 있을 때까지 응답을 보류합니다.\u003c/p\u003e","tags":["Spring Boot","WebSocket","STOMP","실시간"],"title":"Spring Boot WebSocket - STOMP 프로토콜과 실시간 통신 구현"},{"content":"관계형 데이터베이스 개념 관계형 데이터베이스(RDBMS)는 데이터를 테이블 형태로 저장하고 관리합니다.\n핵심 용어 테이블(Table): 데이터를 저장하는 구조 (엑셀의 시트와 유사) 행(Row): 하나의 레코드 (데이터 항목) 열(Column): 속성 또는 필드 기본키(Primary Key): 각 행을 고유하게 식별하는 열 (중복 불가, NULL 불가) 외래키(Foreign Key): 다른 테이블의 기본키를 참조하는 열 -- 예시: users 테이블 -- id (기본키), name, email, created_at (열) -- 각 행은 한 명의 사용자 데이터 DDL (Data Definition Language) 테이블 구조를 정의하는 명령어입니다.\nCREATE TABLE - 테이블 생성 CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, age INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); 주요 데이터 타입 타입 설명 예시 INT 정수 1, 100, -50 VARCHAR(n) 가변 길이 문자열 'hello', '안녕하세요' TEXT 긴 텍스트 게시글 본문 DECIMAL(p,s) 고정 소수점 19.99 (p=전체자리, s=소수자리) DATE 날짜 '2026-02-16' TIMESTAMP 날짜+시간 '2026-02-16 13:20:00' BOOLEAN 참/거짓 TRUE, FALSE ALTER TABLE - 테이블 수정 -- 열 추가 ALTER TABLE users ADD COLUMN phone VARCHAR(20); -- 열 삭제 ALTER TABLE users DROP COLUMN age; -- 열 타입 변경 ALTER TABLE users MODIFY COLUMN name VARCHAR(150); DROP TABLE - 테이블 삭제 DROP TABLE users; -- 테이블과 모든 데이터 삭제 (주의!) DML (Data Manipulation Language) 데이터를 조작하는 명령어입니다.\nINSERT - 데이터 삽입 -- 단일 행 삽입 INSERT INTO users (name, email, age) VALUES (\u0026#39;김철수\u0026#39;, \u0026#39;kim@example.com\u0026#39;, 28); -- 여러 행 삽입 INSERT INTO users (name, email, age) VALUES (\u0026#39;이영희\u0026#39;, \u0026#39;lee@example.com\u0026#39;, 32), (\u0026#39;박민수\u0026#39;, \u0026#39;park@example.com\u0026#39;, 25); SELECT - 데이터 조회 -- 모든 열 조회 SELECT * FROM users; -- 특정 열만 조회 SELECT name, email FROM users; -- 중복 제거 SELECT DISTINCT age FROM users; -- 별칭(alias) 사용 SELECT name AS 이름, email AS 이메일 FROM users; UPDATE - 데이터 수정 -- 특정 행 수정 UPDATE users SET age = 29 WHERE name = \u0026#39;김철수\u0026#39;; -- 여러 열 동시 수정 UPDATE users SET age = 30, email = \u0026#39;newkim@example.com\u0026#39; WHERE id = 1; DELETE - 데이터 삭제 -- 조건에 맞는 행 삭제 DELETE FROM users WHERE age \u0026lt; 20; -- 모든 행 삭제 (테이블 구조는 유지) DELETE FROM users; WHERE 조건절 데이터 필터링에 사용합니다.\n기본 연산자 -- 비교 연산자 SELECT * FROM users WHERE age = 28; SELECT * FROM users WHERE age \u0026gt; 25; SELECT * FROM users WHERE age \u0026lt;= 30; SELECT * FROM users WHERE age != 28; -- 논리 연산자 SELECT * FROM users WHERE age \u0026gt; 20 AND age \u0026lt; 40; SELECT * FROM users WHERE name = \u0026#39;김철수\u0026#39; OR name = \u0026#39;이영희\u0026#39;; SELECT * FROM users WHERE NOT age = 28; LIKE - 패턴 매칭 -- 이름이 \u0026#39;김\u0026#39;으로 시작 SELECT * FROM users WHERE name LIKE \u0026#39;김%\u0026#39;; -- 이메일에 \u0026#39;gmail\u0026#39;이 포함 SELECT * FROM users WHERE email LIKE \u0026#39;%gmail%\u0026#39;; -- 이름이 \u0026#39;수\u0026#39;로 끝남 SELECT * FROM users WHERE name LIKE \u0026#39;%수\u0026#39;; -- 두 번째 글자가 \u0026#39;영\u0026#39; SELECT * FROM users WHERE name LIKE \u0026#39;_영%\u0026#39;; IN - 여러 값 중 하나와 일치 SELECT * FROM users WHERE age IN (25, 28, 32); -- NOT IN SELECT * FROM users WHERE age NOT IN (25, 28); BETWEEN - 범위 조건 SELECT * FROM users WHERE age BETWEEN 20 AND 30; -- 20 이상 30 이하 SELECT * FROM users WHERE created_at BETWEEN \u0026#39;2025-01-01\u0026#39; AND \u0026#39;2025-12-31\u0026#39;; IS NULL - NULL 값 확인 SELECT * FROM users WHERE phone IS NULL; SELECT * FROM users WHERE phone IS NOT NULL; JOIN - 테이블 결합 여러 테이블의 데이터를 연결하여 조회합니다.\n테스트 데이터 준비 CREATE TABLE departments ( id INT PRIMARY KEY, name VARCHAR(50) ); CREATE TABLE employees ( id INT PRIMARY KEY, name VARCHAR(50), department_id INT, FOREIGN KEY (department_id) REFERENCES departments(id) ); INSERT INTO departments VALUES (1, \u0026#39;개발팀\u0026#39;), (2, \u0026#39;디자인팀\u0026#39;); INSERT INTO employees VALUES (1, \u0026#39;김철수\u0026#39;, 1), (2, \u0026#39;이영희\u0026#39;, 1), (3, \u0026#39;박민수\u0026#39;, 2), (4, \u0026#39;최지연\u0026#39;, NULL); INNER JOIN 양쪽 테이블에 모두 존재하는 데이터만 조회\nSELECT e.name AS 직원명, d.name AS 부서명 FROM employees e INNER JOIN departments d ON e.department_id = d.id; -- 결과: -- 김철수 | 개발팀 -- 이영희 | 개발팀 -- 박민수 | 디자인팀 -- (최지연은 부서가 NULL이므로 제외) LEFT JOIN (LEFT OUTER JOIN) 왼쪽 테이블의 모든 데이터 + 오른쪽 테이블의 매칭되는 데이터\nSELECT e.name AS 직원명, d.name AS 부서명 FROM employees e LEFT JOIN departments d ON e.department_id = d.id; -- 결과: -- 김철수 | 개발팀 -- 이영희 | 개발팀 -- 박민수 | 디자인팀 -- 최지연 | NULL (부서 없는 직원도 포함) RIGHT JOIN (RIGHT OUTER JOIN) 오른쪽 테이블의 모든 데이터 + 왼쪽 테이블의 매칭되는 데이터\nSELECT e.name AS 직원명, d.name AS 부서명 FROM employees e RIGHT JOIN departments d ON e.department_id = d.id; -- 직원이 없는 부서도 포함됨 FULL OUTER JOIN 양쪽 테이블의 모든 데이터 (MySQL은 미지원, UNION으로 구현)\n-- MySQL에서는 UNION 사용 SELECT e.name, d.name FROM employees e LEFT JOIN departments d ON e.department_id = d.id UNION SELECT e.name, d.name FROM employees e RIGHT JOIN departments d ON e.department_id = d.id; JOIN 다이어그램 INNER JOIN: [A ∩ B] LEFT JOIN: [A + (A ∩ B)] RIGHT JOIN: [(A ∩ B) + B] FULL OUTER: [A + B] 집계와 그룹화 집계 함수 CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(100), price DECIMAL(10,2), category VARCHAR(50), stock INT ); INSERT INTO products VALUES (1, \u0026#39;노트북\u0026#39;, 1200000, \u0026#39;전자제품\u0026#39;, 10), (2, \u0026#39;마우스\u0026#39;, 25000, \u0026#39;전자제품\u0026#39;, 50), (3, \u0026#39;책상\u0026#39;, 150000, \u0026#39;가구\u0026#39;, 5), (4, \u0026#39;의자\u0026#39;, 80000, \u0026#39;가구\u0026#39;, 8); -- 총 개수 SELECT COUNT(*) AS 총상품수 FROM products; -- NULL이 아닌 값만 카운트 SELECT COUNT(stock) FROM products; -- 합계 SELECT SUM(stock) AS 총재고 FROM products; -- 평균 SELECT AVG(price) AS 평균가격 FROM products; -- 최대/최소 SELECT MAX(price) AS 최고가, MIN(price) AS 최저가 FROM products; GROUP BY - 그룹별 집계 -- 카테고리별 상품 수 SELECT category, COUNT(*) AS 상품수 FROM products GROUP BY category; -- 결과: -- 전자제품 | 2 -- 가구 | 2 -- 카테고리별 평균 가격 SELECT category, AVG(price) AS 평균가격, SUM(stock) AS 총재고 FROM products GROUP BY category; HAVING - 그룹 필터링 -- WHERE: 그룹화 전 필터링 -- HAVING: 그룹화 후 필터링 -- 평균 가격이 100,000원 이상인 카테고리 SELECT category, AVG(price) AS 평균가격 FROM products GROUP BY category HAVING AVG(price) \u0026gt;= 100000; ORDER BY - 정렬 -- 오름차순 (기본) SELECT * FROM products ORDER BY price; -- 내림차순 SELECT * FROM products ORDER BY price DESC; -- 여러 열 정렬 SELECT * FROM products ORDER BY category ASC, price DESC; 서브쿼리 쿼리 안에 또 다른 쿼리를 포함합니다.\n스칼라 서브쿼리 (단일 값 반환) -- 평균 가격보다 비싼 상품 SELECT name, price FROM products WHERE price \u0026gt; (SELECT AVG(price) FROM products); 인라인 뷰 (FROM절 서브쿼리) -- 카테고리별 평균 가격을 먼저 구한 뒤, 그 결과를 조회 SELECT category, 평균가격 FROM ( SELECT category, AVG(price) AS 평균가격 FROM products GROUP BY category ) AS category_avg WHERE 평균가격 \u0026gt; 100000; WHERE절 서브쿼리 -- IN 사용 SELECT name FROM products WHERE category IN ( SELECT category FROM products WHERE price \u0026gt; 100000 ); -- EXISTS 사용 (존재 여부 확인) SELECT name FROM products p WHERE EXISTS ( SELECT 1 FROM products WHERE category = p.category AND price \u0026gt; 500000 ); 데이터 모델링 기초 정규화 (Normalization) 데이터 중복을 제거하고 무결성을 높이는 과정입니다.\n1NF (제1정규형) 모든 속성이 원자값(Atomic Value)을 가져야 함\n-- 위반 예시 CREATE TABLE orders ( id INT, customer VARCHAR(50), products VARCHAR(255) -- \u0026#39;사과, 바나나, 오렌지\u0026#39; (여러 값 저장) ); -- 1NF 준수 CREATE TABLE order_items ( order_id INT, product VARCHAR(50) -- 각 행에 하나의 상품만 ); 2NF (제2정규형) 1NF를 만족하고, 부분 함수 종속 제거\n기본키가 복합키일 때, 기본키 일부에만 종속되는 컬럼이 있으면 안 됨.\n-- 위반 예시 (order_id + product_id가 복합 기본키) CREATE TABLE order_details ( order_id INT, product_id INT, customer_name VARCHAR(50), -- order_id에만 종속 (문제!) product_name VARCHAR(50), -- product_id에만 종속 (문제!) quantity INT, PRIMARY KEY (order_id, product_id) ); -- 2NF 준수: 테이블 분리 CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_name VARCHAR(50) ); CREATE TABLE order_items ( order_id INT, product_id INT, quantity INT, PRIMARY KEY (order_id, product_id) ); CREATE TABLE products ( product_id INT PRIMARY KEY, product_name VARCHAR(50) ); 3NF (제3정규형) 2NF를 만족하고, 이행 함수 종속 제거\n기본키가 아닌 컬럼이 다른 일반 컬럼을 결정하면 안 됨.\n-- 위반 예시 CREATE TABLE employees ( id INT PRIMARY KEY, name VARCHAR(50), department_id INT, department_name VARCHAR(50) -- department_id에 종속 (문제!) ); -- 3NF 준수: department_name 분리 CREATE TABLE employees ( id INT PRIMARY KEY, name VARCHAR(50), department_id INT ); CREATE TABLE departments ( id INT PRIMARY KEY, name VARCHAR(50) ); ERD (Entity Relationship Diagram) 테이블 간 관계를 시각화한 도표\n1:1 관계: 사용자 - 프로필 1:N 관계: 부서 - 직원 N:M 관계: 학생 - 수업 (중간 테이블 필요) 실전 예제: 쇼핑몰 DB 설계 요구사항 사용자가 상품을 주문할 수 있음 한 주문에 여러 상품이 포함될 수 있음 각 상품의 수량과 가격을 기록 ERD 설계 users (1) ─── (N) orders (1) ─── (N) order_items (N) ─── (1) products 테이블 생성 -- 1. 사용자 테이블 CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 2. 상품 테이블 CREATE TABLE products ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(200) NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 3. 주문 테이블 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) DEFAULT \u0026#39;pending\u0026#39;, -- pending, completed, cancelled created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- 4. 주문 상세 테이블 (N:M 관계 해소) CREATE TABLE order_items ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL, -- 주문 당시 가격 기록 FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(id) ); 샘플 데이터 삽입 -- 사용자 생성 INSERT INTO users (name, email, password) VALUES (\u0026#39;김철수\u0026#39;, \u0026#39;kim@example.com\u0026#39;, \u0026#39;hashed_pw_1\u0026#39;), (\u0026#39;이영희\u0026#39;, \u0026#39;lee@example.com\u0026#39;, \u0026#39;hashed_pw_2\u0026#39;); -- 상품 생성 INSERT INTO products (name, description, price, stock) VALUES (\u0026#39;무선 키보드\u0026#39;, \u0026#39;블루투스 지원\u0026#39;, 45000, 20), (\u0026#39;게이밍 마우스\u0026#39;, \u0026#39;RGB LED\u0026#39;, 65000, 15), (\u0026#39;모니터 암\u0026#39;, \u0026#39;듀얼 모니터 지원\u0026#39;, 89000, 10); -- 주문 생성 INSERT INTO orders (user_id, total_amount, status) VALUES (1, 110000, \u0026#39;completed\u0026#39;); -- 주문 상세 (김철수가 키보드 1개, 마우스 1개 구매) INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (1, 1, 1, 45000), (1, 2, 1, 65000); 실용 쿼리 -- 1. 사용자별 총 주문 금액 SELECT u.name, SUM(o.total_amount) AS 총구매액 FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name; -- 2. 가장 많이 팔린 상품 TOP 3 SELECT p.name, SUM(oi.quantity) AS 판매량 FROM products p JOIN order_items oi ON p.id = oi.product_id GROUP BY p.id, p.name ORDER BY 판매량 DESC LIMIT 3; -- 3. 특정 주문의 상세 내역 SELECT o.id AS 주문번호, u.name AS 고객명, p.name AS 상품명, oi.quantity AS 수량, oi.price AS 단가, (oi.quantity * oi.price) AS 소계 FROM orders o JOIN users u ON o.user_id = u.id JOIN order_items oi ON o.id = oi.order_id JOIN products p ON oi.product_id = p.id WHERE o.id = 1; -- 4. 재고가 10개 이하인 상품 알림 SELECT name, stock FROM products WHERE stock \u0026lt;= 10 ORDER BY stock ASC; -- 5. 최근 30일간 주문이 없는 사용자 SELECT u.name, u.email FROM users u WHERE u.id NOT IN ( SELECT DISTINCT user_id FROM orders WHERE created_at \u0026gt;= DATE_SUB(NOW(), INTERVAL 30 DAY) ); 마무리 SQL 학습 로드맵 기본 CRUD → SELECT, INSERT, UPDATE, DELETE 조건과 정렬 → WHERE, ORDER BY, LIMIT 집계와 그룹화 → GROUP BY, HAVING, 집계 함수 조인 → INNER JOIN, LEFT JOIN 서브쿼리 → WHERE절, FROM절 서브쿼리 데이터 모델링 → 정규화, ERD 설계 실전 DB 설계 → 요구사항 분석 → 테이블 설계 → 쿼리 작성 추가 학습 주제 인덱스(Index)와 쿼리 최적화 트랜잭션(Transaction)과 ACID 뷰(View), 프로시저(Procedure), 트리거(Trigger) NoSQL과의 차이점 SQL은 백엔드 개발의 필수 기술입니다. 직접 데이터베이스를 설계하고 쿼리를 작성하며 익숙해지세요!\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/database-sql-basics/","summary":"\u003ch2 id=\"관계형-데이터베이스-개념\"\u003e관계형 데이터베이스 개념\u003c/h2\u003e\n\u003cp\u003e관계형 데이터베이스(RDBMS)는 데이터를 \u003cstrong\u003e테이블\u003c/strong\u003e 형태로 저장하고 관리합니다.\u003c/p\u003e\n\u003ch3 id=\"핵심-용어\"\u003e핵심 용어\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e테이블(Table)\u003c/strong\u003e: 데이터를 저장하는 구조 (엑셀의 시트와 유사)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e행(Row)\u003c/strong\u003e: 하나의 레코드 (데이터 항목)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e열(Column)\u003c/strong\u003e: 속성 또는 필드\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e기본키(Primary Key)\u003c/strong\u003e: 각 행을 고유하게 식별하는 열 (중복 불가, NULL 불가)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e외래키(Foreign Key)\u003c/strong\u003e: 다른 테이블의 기본키를 참조하는 열\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e-- 예시: users 테이블\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e-- id (기본키), name, email, created_at (열)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e-- 각 행은 한 명의 사용자 데이터\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"ddl-data-definition-language\"\u003eDDL (Data Definition Language)\u003c/h2\u003e\n\u003cp\u003e테이블 구조를 정의하는 명령어입니다.\u003c/p\u003e","tags":["SQL","Database","데이터모델링","백엔드"],"title":"SQL 기초 - DDL, DML, JOIN, 서브쿼리, 데이터 모델링"},{"content":"캐싱이란 캐싱(Caching)은 자주 사용되는 데이터를 빠르게 접근할 수 있는 임시 저장소에 보관하는 기술입니다. 데이터베이스 조회, 외부 API 호출, 복잡한 연산 결과 등을 메모리에 저장하여 동일한 요청에 대해 빠르게 응답할 수 있습니다.\n왜 캐싱이 필요한가 성능 향상 데이터베이스 조회는 일반적으로 수십~수백 밀리초가 걸리지만, 메모리 캐시는 마이크로초 단위로 응답합니다.\n// 캐시 없이 매번 DB 조회 public User getUser(Long id) { return userRepository.findById(id).orElseThrow(); // 평균 50ms } // 캐시 적용 @Cacheable(\u0026#34;users\u0026#34;) public User getUser(Long id) { return userRepository.findById(id).orElseThrow(); // 첫 조회 50ms, 이후 0.1ms } 비용 절감 외부 API 호출이나 복잡한 연산을 캐싱하면 서버 리소스와 비용을 절감할 수 있습니다.\n// 외부 API 호출 (비용 발생) public WeatherData getWeather(String city) { return externalWeatherApi.fetch(city); // API 호출 비용 + 지연시간 } // 캐싱으로 비용 절감 (5분간 재사용) @Cacheable(value = \u0026#34;weather\u0026#34;, key = \u0026#34;#city\u0026#34;) public WeatherData getWeather(String city) { return externalWeatherApi.fetch(city); // 5분에 1번만 호출 } 시스템 안정성 데이터베이스 부하를 줄여 시스템 전체의 안정성을 높입니다.\nSpring Boot 캐시 추상화 Spring Boot는 다양한 캐시 구현체를 동일한 인터페이스로 사용할 수 있는 추상화를 제공합니다.\n@EnableCaching 캐시 기능을 활성화하는 설정입니다.\n@Configuration @EnableCaching public class CacheConfig { } @Cacheable 메서드 결과를 캐시에 저장합니다. 동일한 파라미터로 호출 시 캐시된 결과를 반환합니다.\n@Service public class ProductService { @Cacheable(\u0026#34;products\u0026#34;) public Product findById(Long id) { log.info(\u0026#34;DB에서 상품 조회: {}\u0026#34;, id); return productRepository.findById(id).orElseThrow(); } @Cacheable(value = \u0026#34;products\u0026#34;, key = \u0026#34;#id\u0026#34;) public Product findByIdWithKey(Long id) { return productRepository.findById(id).orElseThrow(); } // 조건부 캐싱 @Cacheable(value = \u0026#34;products\u0026#34;, condition = \u0026#34;#id \u0026gt; 10\u0026#34;) public Product findByIdConditional(Long id) { return productRepository.findById(id).orElseThrow(); } // null 캐싱 제외 @Cacheable(value = \u0026#34;products\u0026#34;, unless = \u0026#34;#result == null\u0026#34;) public Product findByIdUnlessNull(Long id) { return productRepository.findById(id).orElse(null); } } @CacheEvict 캐시를 삭제합니다. 데이터 수정/삭제 시 사용합니다.\n@Service public class ProductService { // 특정 키 삭제 @CacheEvict(value = \u0026#34;products\u0026#34;, key = \u0026#34;#id\u0026#34;) public void deleteProduct(Long id) { productRepository.deleteById(id); } // 전체 캐시 삭제 @CacheEvict(value = \u0026#34;products\u0026#34;, allEntries = true) public void deleteAllProducts() { productRepository.deleteAll(); } // 메서드 실행 전 삭제 @CacheEvict(value = \u0026#34;products\u0026#34;, key = \u0026#34;#product.id\u0026#34;, beforeInvocation = true) public void updateProduct(Product product) { productRepository.save(product); } } @CachePut 메서드를 항상 실행하고 결과를 캐시에 업데이트합니다.\n@Service public class ProductService { @CachePut(value = \u0026#34;products\u0026#34;, key = \u0026#34;#product.id\u0026#34;) public Product updateProduct(Product product) { log.info(\u0026#34;상품 업데이트 및 캐시 갱신: {}\u0026#34;, product.getId()); return productRepository.save(product); } @CachePut(value = \u0026#34;products\u0026#34;, key = \u0026#34;#result.id\u0026#34;) public Product createProduct(ProductRequest request) { Product product = new Product(request); return productRepository.save(product); } } @Caching 여러 캐시 어노테이션을 조합합니다.\n@Service public class ProductService { @Caching( evict = { @CacheEvict(value = \u0026#34;products\u0026#34;, key = \u0026#34;#product.id\u0026#34;), @CacheEvict(value = \u0026#34;productList\u0026#34;, allEntries = true) }, put = { @CachePut(value = \u0026#34;products\u0026#34;, key = \u0026#34;#product.id\u0026#34;) } ) public Product updateProduct(Product product) { return productRepository.save(product); } } Caffeine 캐시 소개 Caffeine은 고성능 Java 캐싱 라이브러리로, Guava Cache의 후속작입니다. Spring Boot 3.x의 기본 캐시 구현체입니다.\nCaffeine의 장점 높은 성능: 비블로킹 알고리즘으로 높은 처리량과 낮은 지연시간 다양한 만료 정책: 시간 기반, 크기 기반, 참조 기반 만료 자동 로딩: CacheLoader를 통한 자동 캐시 로딩 비동기 지원: AsyncCache로 비동기 캐싱 통계 수집: 캐시 히트율, 로드 시간 등 모니터링 의존성 추가 dependencies { implementation \u0026#39;org.springframework.boot:spring-boot-starter-cache\u0026#39; implementation \u0026#39;com.github.ben-manes.caffeine:caffeine\u0026#39; } Caffeine 설정 기본 설정 @Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.setCaffeine(caffeineCacheBuilder()); return cacheManager; } Caffeine\u0026lt;Object, Object\u0026gt; caffeineCacheBuilder() { return Caffeine.newBuilder() .maximumSize(1000) // 최대 1000개 항목 .expireAfterWrite(10, TimeUnit.MINUTES) // 쓰기 후 10분 .recordStats(); // 통계 수집 } } maximumSize 캐시에 저장할 최대 항목 수를 설정합니다.\nCaffeine.newBuilder() .maximumSize(10000) // 최대 10,000개 .build(); // 또는 메모리 크기 기반 Caffeine.newBuilder() .maximumWeight(10_000_000) // 최대 10MB .weigher((key, value) -\u0026gt; ((String) value).length()) .build(); expireAfterWrite 항목이 생성되거나 마지막으로 업데이트된 후 지정된 시간이 지나면 만료됩니다.\nCaffeine.newBuilder() .expireAfterWrite(5, TimeUnit.MINUTES) .build(); expireAfterAccess 항목이 마지막으로 읽히거나 쓰여진 후 지정된 시간이 지나면 만료됩니다.\nCaffeine.newBuilder() .expireAfterAccess(30, TimeUnit.MINUTES) .build(); 동적 만료 시간 Caffeine.newBuilder() .expireAfter(new Expiry\u0026lt;String, User\u0026gt;() { @Override public long expireAfterCreate(String key, User user, long currentTime) { // VIP 사용자는 1시간, 일반 사용자는 10분 return user.isVip() ? TimeUnit.HOURS.toNanos(1) : TimeUnit.MINUTES.toNanos(10); } @Override public long expireAfterUpdate(String key, User user, long currentTime, long currentDuration) { return currentDuration; } @Override public long expireAfterRead(String key, User user, long currentTime, long currentDuration) { return currentDuration; } }) .build(); CacheManager 설정 (다중 캐시) 각 캐시마다 다른 설정을 적용할 수 있습니다.\n@Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.registerCustomCache(\u0026#34;users\u0026#34;, userCache()); cacheManager.registerCustomCache(\u0026#34;products\u0026#34;, productCache()); cacheManager.registerCustomCache(\u0026#34;sessions\u0026#34;, sessionCache()); return cacheManager; } private Cache\u0026lt;Object, Object\u0026gt; userCache() { return Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(1, TimeUnit.HOURS) .recordStats() .build(); } private Cache\u0026lt;Object, Object\u0026gt; productCache() { return Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(30, TimeUnit.MINUTES) .recordStats() .build(); } private Cache\u0026lt;Object, Object\u0026gt; sessionCache() { return Caffeine.newBuilder() .maximumSize(1000) .expireAfterAccess(15, TimeUnit.MINUTES) .recordStats() .build(); } } application.yml 기반 설정 spring: cache: type: caffeine caffeine: spec: maximumSize=1000,expireAfterWrite=10m cache-names: - users - products - sessions 캐시 전략 Cache-Aside (Lazy Loading) 애플리케이션이 캐시를 직접 관리하는 가장 일반적인 패턴입니다. Spring의 @Cacheable이 이 패턴을 구현합니다.\n@Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; @Cacheable(\u0026#34;users\u0026#34;) public User getUser(Long id) { // 1. 캐시 확인 (자동) // 2. 캐시 미스 시 DB 조회 return userRepository.findById(id) .orElseThrow(() -\u0026gt; new UserNotFoundException(id)); // 3. 결과를 캐시에 저장 (자동) } @CachePut(value = \u0026#34;users\u0026#34;, key = \u0026#34;#user.id\u0026#34;) public User updateUser(User user) { // DB 업데이트 후 캐시 갱신 return userRepository.save(user); } @CacheEvict(value = \u0026#34;users\u0026#34;, key = \u0026#34;#id\u0026#34;) public void deleteUser(Long id) { // DB 삭제 후 캐시 제거 userRepository.deleteById(id); } } 장점:\n구현이 간단 필요한 데이터만 캐싱 (메모리 효율적) 단점:\n캐시 미스 시 지연 발생 캐시와 DB 간 불일치 가능성 Write-Through 데이터를 쓸 때 캐시와 DB를 동시에 업데이트합니다.\n@Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; private final CacheManager cacheManager; public Product createProduct(ProductRequest request) { Product product = new Product(request); // 1. DB에 저장 product = productRepository.save(product); // 2. 캐시에도 저장 Cache cache = cacheManager.getCache(\u0026#34;products\u0026#34;); if (cache != null) { cache.put(product.getId(), product); } return product; } } 장점:\n캐시와 DB의 일관성 보장 읽기 성능 우수 (항상 캐시에 존재) 단점:\n쓰기 지연 발생 (DB와 캐시 둘 다 업데이트) 사용되지 않는 데이터도 캐싱 Write-Behind (Write-Back) 데이터를 캐시에 먼저 쓰고, 비동기로 DB에 반영합니다.\n@Service @RequiredArgsConstructor public class LogService { private final LogRepository logRepository; private final Cache\u0026lt;Long, Log\u0026gt; logCache; private final ExecutorService executorService; public void writeLog(Log log) { // 1. 캐시에 즉시 저장 logCache.put(log.getId(), log); // 2. 비동기로 DB에 저장 executorService.submit(() -\u0026gt; { logRepository.save(log); }); } } 장점:\n매우 빠른 쓰기 성능 DB 부하 감소 (배치 처리 가능) 단점:\n복잡한 구현 장애 시 데이터 유실 가능성 TTL과 만료 정책 시간 기반 만료 @Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); // 짧은 TTL: 자주 변경되는 데이터 cacheManager.registerCustomCache(\u0026#34;realtimeData\u0026#34;, Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.MINUTES) .build()); // 중간 TTL: 보통 변경되는 데이터 cacheManager.registerCustomCache(\u0026#34;userData\u0026#34;, Caffeine.newBuilder() .expireAfterWrite(30, TimeUnit.MINUTES) .build()); // 긴 TTL: 거의 변경되지 않는 데이터 cacheManager.registerCustomCache(\u0026#34;configData\u0026#34;, Caffeine.newBuilder() .expireAfterWrite(24, TimeUnit.HOURS) .build()); return cacheManager; } } 크기 기반 만료 Caffeine.newBuilder() .maximumSize(1000) // LRU 방식으로 가장 오래 사용되지 않은 항목 제거 .build(); 참조 기반 만료 Caffeine.newBuilder() .weakKeys() // 키를 WeakReference로 보관 .weakValues() // 값을 WeakReference로 보관 .softValues() // 값을 SoftReference로 보관 (메모리 부족 시 GC) .build(); 실전 예시: 스케줄 설정과 rsync 설정 캐싱 파일 동기화 시스템에서 자주 조회되는 설정 데이터를 캐싱하여 DB 부하를 줄이는 예제입니다.\n캐시 설정 @Configuration @EnableCaching public class SyncCacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); // 스케줄 설정 캐시 cacheManager.registerCustomCache(\u0026#34;schedules\u0026#34;, scheduleCache()); // Rsync 설정 캐시 cacheManager.registerCustomCache(\u0026#34;rsyncConfigs\u0026#34;, rsyncConfigCache()); // 파일 메타데이터 캐시 cacheManager.registerCustomCache(\u0026#34;fileMetadata\u0026#34;, fileMetadataCache()); return cacheManager; } private Cache\u0026lt;Object, Object\u0026gt; scheduleCache() { return Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(5, TimeUnit.MINUTES) .expireAfterAccess(10, TimeUnit.MINUTES) .recordStats() .build(); } private Cache\u0026lt;Object, Object\u0026gt; rsyncConfigCache() { return Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(30, TimeUnit.MINUTES) .recordStats() .build(); } private Cache\u0026lt;Object, Object\u0026gt; fileMetadataCache() { return Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(1, TimeUnit.MINUTES) .recordStats() .build(); } } 스케줄 서비스 @Service @RequiredArgsConstructor @Slf4j public class ScheduleService { private final ScheduleRepository scheduleRepository; @Cacheable(value = \u0026#34;schedules\u0026#34;, key = \u0026#34;#id\u0026#34;) public Schedule getSchedule(Long id) { log.info(\u0026#34;DB에서 스케줄 조회: {}\u0026#34;, id); return scheduleRepository.findById(id) .orElseThrow(() -\u0026gt; new ScheduleNotFoundException(id)); } @Cacheable(value = \u0026#34;schedules\u0026#34;, key = \u0026#34;\u0026#39;active\u0026#39;\u0026#34;) public List\u0026lt;Schedule\u0026gt; getActiveSchedules() { log.info(\u0026#34;DB에서 활성 스케줄 목록 조회\u0026#34;); return scheduleRepository.findByActiveTrue(); } @CachePut(value = \u0026#34;schedules\u0026#34;, key = \u0026#34;#schedule.id\u0026#34;) @CacheEvict(value = \u0026#34;schedules\u0026#34;, key = \u0026#34;\u0026#39;active\u0026#39;\u0026#34;) public Schedule updateSchedule(Schedule schedule) { log.info(\u0026#34;스케줄 업데이트 및 캐시 갱신: {}\u0026#34;, schedule.getId()); return scheduleRepository.save(schedule); } @CacheEvict(value = \u0026#34;schedules\u0026#34;, allEntries = true) public void refreshAllSchedules() { log.info(\u0026#34;모든 스케줄 캐시 삭제\u0026#34;); } } Rsync 설정 서비스 @Service @RequiredArgsConstructor @Slf4j public class RsyncConfigService { private final RsyncConfigRepository rsyncConfigRepository; @Cacheable(value = \u0026#34;rsyncConfigs\u0026#34;, key = \u0026#34;#scheduleId\u0026#34;) public RsyncConfig getConfig(Long scheduleId) { log.info(\u0026#34;DB에서 Rsync 설정 조회: {}\u0026#34;, scheduleId); return rsyncConfigRepository.findByScheduleId(scheduleId) .orElseThrow(() -\u0026gt; new ConfigNotFoundException(scheduleId)); } @Cacheable(value = \u0026#34;rsyncConfigs\u0026#34;, key = \u0026#34;\u0026#39;default\u0026#39;\u0026#34;) public RsyncConfig getDefaultConfig() { log.info(\u0026#34;DB에서 기본 Rsync 설정 조회\u0026#34;); return rsyncConfigRepository.findByIsDefaultTrue() .orElseGet(() -\u0026gt; RsyncConfig.builder() .bandwidth(1000) .timeout(3600) .retryCount(3) .build()); } @CachePut(value = \u0026#34;rsyncConfigs\u0026#34;, key = \u0026#34;#config.scheduleId\u0026#34;) public RsyncConfig updateConfig(RsyncConfig config) { log.info(\u0026#34;Rsync 설정 업데이트: {}\u0026#34;, config.getScheduleId()); return rsyncConfigRepository.save(config); } } 파일 동기화 서비스 (캐시 활용) @Service @RequiredArgsConstructor @Slf4j public class FileSyncService { private final ScheduleService scheduleService; private final RsyncConfigService rsyncConfigService; private final RsyncExecutor rsyncExecutor; public void syncFiles(Long scheduleId) { // 캐시에서 스케줄 조회 (첫 조회만 DB 접근) Schedule schedule = scheduleService.getSchedule(scheduleId); if (!schedule.isActive()) { log.warn(\u0026#34;비활성 스케줄: {}\u0026#34;, scheduleId); return; } // 캐시에서 Rsync 설정 조회 RsyncConfig config = rsyncConfigService.getConfig(scheduleId); log.info(\u0026#34;동기화 시작 - 스케줄: {}, 설정: {}\u0026#34;, schedule.getName(), config.getBandwidth()); // Rsync 실행 rsyncExecutor.execute(schedule, config); } public void syncAllActiveSchedules() { // 활성 스케줄 목록도 캐싱됨 List\u0026lt;Schedule\u0026gt; schedules = scheduleService.getActiveSchedules(); log.info(\u0026#34;활성 스케줄 {}개 동기화 시작\u0026#34;, schedules.size()); schedules.forEach(schedule -\u0026gt; { try { syncFiles(schedule.getId()); } catch (Exception e) { log.error(\u0026#34;동기화 실패: {}\u0026#34;, schedule.getId(), e); } }); } } 성능 비교 @SpringBootTest class CachePerformanceTest { @Autowired private ScheduleService scheduleService; @Test void cachePerformanceTest() { Long scheduleId = 1L; // 첫 번째 조회 (DB 접근) long start1 = System.nanoTime(); scheduleService.getSchedule(scheduleId); long duration1 = System.nanoTime() - start1; // 두 번째 조회 (캐시 히트) long start2 = System.nanoTime(); scheduleService.getSchedule(scheduleId); long duration2 = System.nanoTime() - start2; log.info(\u0026#34;첫 번째 조회 (DB): {}ms\u0026#34;, duration1 / 1_000_000); log.info(\u0026#34;두 번째 조회 (캐시): {}ms\u0026#34;, duration2 / 1_000_000); log.info(\u0026#34;성능 향상: {}배\u0026#34;, duration1 / duration2); // 결과 예시: // 첫 번째 조회 (DB): 45ms // 두 번째 조회 (캐시): 0.05ms // 성능 향상: 900배 } } 캐시 모니터링과 통계 캐시 통계 수집 @Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .recordStats() // 통계 수집 활성화 ); return cacheManager; } } 통계 조회 @Service @RequiredArgsConstructor @Slf4j public class CacheMonitorService { private final CacheManager cacheManager; public CacheStats getCacheStats(String cacheName) { CaffeineCache cache = (CaffeineCache) cacheManager.getCache(cacheName); if (cache == null) { return null; } com.github.benmanes.caffeine.cache.Cache\u0026lt;Object, Object\u0026gt; nativeCache = cache.getNativeCache(); CacheStats stats = nativeCache.stats(); log.info(\u0026#34;캐시 \u0026#39;{}\u0026#39; 통계:\u0026#34;, cacheName); log.info(\u0026#34; 히트 수: {}\u0026#34;, stats.hitCount()); log.info(\u0026#34; 미스 수: {}\u0026#34;, stats.missCount()); log.info(\u0026#34; 히트율: {:.2f}%\u0026#34;, stats.hitRate() * 100); log.info(\u0026#34; 평균 로드 시간: {:.2f}ms\u0026#34;, stats.averageLoadPenalty() / 1_000_000); log.info(\u0026#34; 제거 수: {}\u0026#34;, stats.evictionCount()); return stats; } public void logAllCacheStats() { Collection\u0026lt;String\u0026gt; cacheNames = cacheManager.getCacheNames(); cacheNames.forEach(cacheName -\u0026gt; { getCacheStats(cacheName); }); } } 스케줄링된 모니터링 @Component @RequiredArgsConstructor @Slf4j public class CacheMonitorScheduler { private final CacheMonitorService cacheMonitorService; @Scheduled(fixedRate = 60000) // 1분마다 public void monitorCaches() { log.info(\u0026#34;=== 캐시 통계 수집 시작 ===\u0026#34;); cacheMonitorService.logAllCacheStats(); } @Scheduled(cron = \u0026#34;0 0 * * * *\u0026#34;) // 매 시간 public void checkCacheHealth() { CacheStats stats = cacheMonitorService.getCacheStats(\u0026#34;schedules\u0026#34;); if (stats != null \u0026amp;\u0026amp; stats.hitRate() \u0026lt; 0.5) { log.warn(\u0026#34;캐시 히트율이 낮습니다: {:.2f}%\u0026#34;, stats.hitRate() * 100); // 알림 발송 또는 캐시 설정 조정 } } } REST API로 통계 노출 @RestController @RequestMapping(\u0026#34;/api/cache\u0026#34;) @RequiredArgsConstructor public class CacheStatsController { private final CacheMonitorService cacheMonitorService; private final CacheManager cacheManager; @GetMapping(\u0026#34;/stats\u0026#34;) public Map\u0026lt;String, CacheStatsDto\u0026gt; getAllStats() { Map\u0026lt;String, CacheStatsDto\u0026gt; statsMap = new HashMap\u0026lt;\u0026gt;(); cacheManager.getCacheNames().forEach(cacheName -\u0026gt; { CacheStats stats = cacheMonitorService.getCacheStats(cacheName); if (stats != null) { statsMap.put(cacheName, new CacheStatsDto(stats)); } }); return statsMap; } @GetMapping(\u0026#34;/stats/{cacheName}\u0026#34;) public CacheStatsDto getStats(@PathVariable String cacheName) { CacheStats stats = cacheMonitorService.getCacheStats(cacheName); return new CacheStatsDto(stats); } @DeleteMapping(\u0026#34;/{cacheName}\u0026#34;) public void clearCache(@PathVariable String cacheName) { Cache cache = cacheManager.getCache(cacheName); if (cache != null) { cache.clear(); } } } @Data class CacheStatsDto { private long hitCount; private long missCount; private double hitRate; private double missRate; private long loadCount; private long evictionCount; private double averageLoadPenalty; public CacheStatsDto(CacheStats stats) { this.hitCount = stats.hitCount(); this.missCount = stats.missCount(); this.hitRate = stats.hitRate(); this.missRate = stats.missRate(); this.loadCount = stats.loadCount(); this.evictionCount = stats.evictionCount(); this.averageLoadPenalty = stats.averageLoadPenalty(); } } 캐시 워밍업 애플리케이션 시작 시 자주 사용되는 데이터를 미리 캐싱합니다.\n@Component @RequiredArgsConstructor @Slf4j public class CacheWarmer { private final ScheduleService scheduleService; private final RsyncConfigService rsyncConfigService; @EventListener(ApplicationReadyEvent.class) public void warmUpCache() { log.info(\u0026#34;캐시 워밍업 시작\u0026#34;); // 활성 스케줄 미리 로드 scheduleService.getActiveSchedules(); // 기본 설정 미리 로드 rsyncConfigService.getDefaultConfig(); log.info(\u0026#34;캐시 워밍업 완료\u0026#34;); } } 마무리 Spring Boot의 캐싱은 애플리케이션 성능을 크게 향상시킬 수 있는 강력한 도구입니다.\n핵심 정리 캐싱의 필요성: DB 조회 감소, 성능 향상, 비용 절감 Spring 캐시 추상화: @Cacheable, @CacheEvict, @CachePut로 간편한 캐싱 Caffeine 캐시: 고성능 인메모리 캐시, 다양한 만료 정책 캐시 전략: Cache-Aside, Write-Through, Write-Behind TTL 설정: expireAfterWrite, expireAfterAccess로 만료 관리 모니터링: recordStats()로 히트율, 로드 시간 추적 캐싱 적용 시나리오 읽기 위주: 사용자 정보, 상품 정보, 설정 데이터 계산 비용이 큼: 통계, 집계, 복잡한 연산 결과 외부 API: 날씨, 환율, 주소 검색 등 자주 변경되지 않음: 코드 테이블, 카테고리, 메뉴 주의사항 캐시 일관성: 데이터 변경 시 캐시 무효화 필수 메모리 관리: maximumSize로 메모리 사용량 제한 적절한 TTL: 데이터 특성에 맞는 만료 시간 설정 과도한 캐싱 지양: 자주 변경되는 데이터는 캐싱 비효율 분산 환경: 여러 서버에서는 Redis 등 분산 캐시 고려 Caffeine과 Spring Boot의 캐시 추상화를 활용하면 코드 변경을 최소화하면서 애플리케이션 성능을 대폭 향상시킬 수 있습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/spring-boot-caching/","summary":"\u003ch2 id=\"캐싱이란\"\u003e캐싱이란\u003c/h2\u003e\n\u003cp\u003e캐싱(Caching)은 자주 사용되는 데이터를 빠르게 접근할 수 있는 임시 저장소에 보관하는 기술입니다. 데이터베이스 조회, 외부 API 호출, 복잡한 연산 결과 등을 메모리에 저장하여 동일한 요청에 대해 빠르게 응답할 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"왜-캐싱이-필요한가\"\u003e왜 캐싱이 필요한가\u003c/h2\u003e\n\u003ch3 id=\"성능-향상\"\u003e성능 향상\u003c/h3\u003e\n\u003cp\u003e데이터베이스 조회는 일반적으로 수십~수백 밀리초가 걸리지만, 메모리 캐시는 마이크로초 단위로 응답합니다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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:#6c7086;font-style:italic\"\u003e// 캐시 없이 매번 DB 조회\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e User \u003cspan style=\"color:#89b4fa\"\u003egetUser\u003c/span\u003e(Long id) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e userRepository.\u003cspan style=\"color:#89b4fa\"\u003efindById\u003c/span\u003e(id).\u003cspan style=\"color:#89b4fa\"\u003eorElseThrow\u003c/span\u003e(); \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 평균 50ms\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:#6c7086;font-style:italic\"\u003e// 캐시 적용\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#89b4fa;font-weight:bold\"\u003e@Cacheable\u003c/span\u003e(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;users\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e User \u003cspan style=\"color:#89b4fa\"\u003egetUser\u003c/span\u003e(Long id) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e userRepository.\u003cspan style=\"color:#89b4fa\"\u003efindById\u003c/span\u003e(id).\u003cspan style=\"color:#89b4fa\"\u003eorElseThrow\u003c/span\u003e(); \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 첫 조회 50ms, 이후 0.1ms\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\u003ch3 id=\"비용-절감\"\u003e비용 절감\u003c/h3\u003e\n\u003cp\u003e외부 API 호출이나 복잡한 연산을 캐싱하면 서버 리소스와 비용을 절감할 수 있습니다.\u003c/p\u003e","tags":["Spring Boot","캐싱","Caffeine","성능"],"title":"Spring Boot 캐싱 - Caffeine, @Cacheable, TTL 전략"},{"content":"이벤트 기반 아키텍처란 이벤트 기반 아키텍처(Event-Driven Architecture)는 시스템의 컴포넌트들이 이벤트를 통해 상호작용하는 설계 패턴입니다. 특정 작업이 완료되었을 때 이벤트를 발행하고, 관심 있는 컴포넌트가 이를 구독하여 처리하는 방식입니다.\n전통적인 방식에서는 서비스 A가 서비스 B, C, D를 직접 호출했다면, 이벤트 기반 방식에서는 서비스 A가 이벤트만 발행하고, B, C, D가 각자 필요한 이벤트를 구독하여 처리합니다.\n왜 이벤트 기반 아키텍처인가 서비스 간 결합도 감소 직접 호출 방식의 문제점:\n@Service public class UserService { private final EmailService emailService; private final NotificationService notificationService; private final AuditService auditService; private final AnalyticsService analyticsService; public void registerUser(User user) { userRepository.save(user); // 모든 서비스를 직접 호출 emailService.sendWelcomeEmail(user); notificationService.sendPushNotification(user); auditService.logUserRegistration(user); analyticsService.trackUserSignup(user); } } 위 코드는 UserService가 4개의 서비스에 강하게 결합되어 있습니다. 새로운 기능 추가 시마다 UserService를 수정해야 합니다.\n이벤트 기반 방식:\n@Service public class UserService { private final ApplicationEventPublisher eventPublisher; public void registerUser(User user) { userRepository.save(user); // 이벤트만 발행 eventPublisher.publishEvent(new UserRegisteredEvent(user)); } } UserService는 이제 사용자 등록 후 이벤트만 발행합니다. 다른 서비스들은 독립적으로 이 이벤트를 구독하여 처리할 수 있습니다.\n주요 장점 낮은 결합도: 이벤트 발행자는 구독자를 알 필요가 없습니다 높은 확장성: 새로운 이벤트 리스너를 추가해도 기존 코드를 수정하지 않습니다 비동기 처리: 시간이 오래 걸리는 작업을 비동기로 처리할 수 있습니다 단일 책임 원칙: 각 컴포넌트가 자신의 역할에만 집중합니다 ApplicationEvent 정의하기 Spring에서 이벤트를 정의하는 방법은 간단합니다.\n기본 이벤트 클래스 public class UserRegisteredEvent { private final User user; private final LocalDateTime occurredAt; public UserRegisteredEvent(User user) { this.user = user; this.occurredAt = LocalDateTime.now(); } public User getUser() { return user; } public LocalDateTime getOccurredAt() { return occurredAt; } } Spring 4.2 이후부터는 ApplicationEvent를 상속받지 않아도 됩니다. 일반 POJO 클래스로 이벤트를 정의할 수 있습니다.\nApplicationEvent 상속 방식 public class OrderCompletedEvent extends ApplicationEvent { private final Order order; private final BigDecimal totalAmount; public OrderCompletedEvent(Object source, Order order, BigDecimal totalAmount) { super(source); this.order = order; this.totalAmount = totalAmount; } public Order getOrder() { return order; } public BigDecimal getTotalAmount() { return totalAmount; } } 제네릭 이벤트 public class EntityCreatedEvent\u0026lt;T\u0026gt; { private final T entity; private final String entityType; private final Long entityId; public EntityCreatedEvent(T entity, String entityType, Long entityId) { this.entity = entity; this.entityType = entityType; this.entityId = entityId; } public T getEntity() { return entity; } public String getEntityType() { return entityType; } public Long getEntityId() { return entityId; } } ApplicationEventPublisher로 이벤트 발행 ApplicationEventPublisher는 Spring이 제공하는 이벤트 발행 인터페이스입니다.\n기본 발행 방식 @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final ApplicationEventPublisher eventPublisher; @Transactional public Order createOrder(OrderRequest request) { Order order = new Order(request); order = orderRepository.save(order); // 이벤트 발행 eventPublisher.publishEvent(new OrderCompletedEvent(this, order, order.getTotalAmount())); return order; } } 여러 이벤트 발행 @Service @RequiredArgsConstructor public class PaymentService { private final ApplicationEventPublisher eventPublisher; @Transactional public void processPayment(Payment payment) { payment.setStatus(PaymentStatus.PROCESSING); try { // 결제 처리 로직 externalPaymentGateway.charge(payment); payment.setStatus(PaymentStatus.COMPLETED); // 성공 이벤트 발행 eventPublisher.publishEvent(new PaymentCompletedEvent(payment)); eventPublisher.publishEvent(new NotificationEvent(\u0026#34;결제가 완료되었습니다\u0026#34;)); } catch (PaymentException e) { payment.setStatus(PaymentStatus.FAILED); // 실패 이벤트 발행 eventPublisher.publishEvent(new PaymentFailedEvent(payment, e.getMessage())); } } } @EventListener로 이벤트 수신 @EventListener 어노테이션을 사용하여 이벤트를 구독할 수 있습니다.\n기본 리스너 @Component @Slf4j public class UserEventListener { @EventListener public void handleUserRegistered(UserRegisteredEvent event) { User user = event.getUser(); log.info(\u0026#34;새로운 사용자 등록: {}, 이메일: {}\u0026#34;, user.getName(), user.getEmail()); } } 여러 리스너 메서드 @Component @Slf4j @RequiredArgsConstructor public class OrderEventListener { private final EmailService emailService; private final InventoryService inventoryService; @EventListener public void handleOrderCompleted(OrderCompletedEvent event) { Order order = event.getOrder(); log.info(\u0026#34;주문 완료: 주문번호 {}, 금액 {}\u0026#34;, order.getId(), event.getTotalAmount()); // 주문 확인 이메일 발송 emailService.sendOrderConfirmation(order); } @EventListener public void updateInventory(OrderCompletedEvent event) { Order order = event.getOrder(); // 재고 업데이트 order.getItems().forEach(item -\u0026gt; inventoryService.decreaseStock(item.getProductId(), item.getQuantity()) ); } } 조건부 리스너 @Component @Slf4j public class PaymentEventListener { // 특정 조건에만 실행 @EventListener(condition = \u0026#34;#event.totalAmount \u0026gt; 100000\u0026#34;) public void handleLargePayment(PaymentCompletedEvent event) { log.warn(\u0026#34;고액 결제 감지: {} 원\u0026#34;, event.getTotalAmount()); // 추가 검증 로직 } @EventListener(condition = \u0026#34;#event.user.vip == true\u0026#34;) public void handleVipUserRegistration(UserRegisteredEvent event) { log.info(\u0026#34;VIP 사용자 등록: {}\u0026#34;, event.getUser().getName()); // VIP 전용 처리 } } @Async와 비동기 이벤트 처리 이벤트 처리를 비동기로 수행하여 메인 로직의 성능을 향상시킬 수 있습니다.\n비동기 설정 @Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix(\u0026#34;event-async-\u0026#34;); executor.initialize(); return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -\u0026gt; log.error(\u0026#34;비동기 이벤트 처리 중 예외 발생: {}\u0026#34;, method.getName(), ex); } } 비동기 리스너 @Component @Slf4j @RequiredArgsConstructor public class NotificationEventListener { private final EmailService emailService; private final SmsService smsService; @Async @EventListener public void sendEmailNotification(UserRegisteredEvent event) { log.info(\u0026#34;이메일 발송 시작 (비동기): {}\u0026#34;, Thread.currentThread().getName()); // 시간이 오래 걸리는 작업 emailService.sendWelcomeEmail(event.getUser()); log.info(\u0026#34;이메일 발송 완료: {}\u0026#34;, event.getUser().getEmail()); } @Async @EventListener public void sendSmsNotification(OrderCompletedEvent event) { log.info(\u0026#34;SMS 발송 시작 (비동기): {}\u0026#34;, Thread.currentThread().getName()); // 외부 API 호출 smsService.sendOrderConfirmation(event.getOrder()); log.info(\u0026#34;SMS 발송 완료\u0026#34;); } } 비동기 처리 장점 @Service @RequiredArgsConstructor public class ProductService { private final ApplicationEventPublisher eventPublisher; public void createProduct(Product product) { long startTime = System.currentTimeMillis(); productRepository.save(product); // 비동기 이벤트 발행 - 즉시 반환 eventPublisher.publishEvent(new ProductCreatedEvent(product)); long endTime = System.currentTimeMillis(); log.info(\u0026#34;상품 생성 완료 ({}ms)\u0026#34;, endTime - startTime); // 이메일, 알림 등의 처리 시간과 무관하게 빠르게 응답 } } @TransactionalEventListener (트랜잭션 후 이벤트) 트랜잭션 커밋 후에 이벤트를 처리하려면 @TransactionalEventListener를 사용합니다.\n기본 사용법 @Component @Slf4j @RequiredArgsConstructor public class TransactionalEventListener { private final CacheManager cacheManager; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleAfterCommit(UserRegisteredEvent event) { log.info(\u0026#34;트랜잭션 커밋 후 실행\u0026#34;); // 캐시 무효화 cacheManager.getCache(\u0026#34;users\u0026#34;).clear(); } @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) public void handleAfterRollback(PaymentFailedEvent event) { log.warn(\u0026#34;트랜잭션 롤백 후 실행\u0026#34;); // 보상 트랜잭션 또는 알림 } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) public void handleAfterCompletion(OrderCompletedEvent event) { log.info(\u0026#34;트랜잭션 완료 후 실행 (커밋/롤백 무관)\u0026#34;); } } 트랜잭션 페이즈 BEFORE_COMMIT: 트랜잭션 커밋 직전 AFTER_COMMIT: 트랜잭션 커밋 후 (기본값) AFTER_ROLLBACK: 트랜잭션 롤백 후 AFTER_COMPLETION: 커밋 또는 롤백 후 실전 예제: 외부 시스템 연동 @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final ApplicationEventPublisher eventPublisher; @Transactional public Order createOrder(OrderRequest request) { Order order = new Order(request); order = orderRepository.save(order); // DB 저장 후 이벤트 발행 eventPublisher.publishEvent(new OrderCreatedEvent(order)); return order; } } @Component @RequiredArgsConstructor public class OrderIntegrationListener { private final ExternalApiClient externalApiClient; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void syncToExternalSystem(OrderCreatedEvent event) { // 트랜잭션이 성공적으로 커밋된 후에만 외부 시스템 호출 externalApiClient.notifyOrderCreated(event.getOrder()); } } 실전 예시: 파일 동기화 시스템에서의 이벤트 실제 파일 동기화 시스템에서 23개의 도메인 이벤트를 활용하는 사례를 살펴보겠습니다.\n동기화 진행 상황 이벤트 public class SyncProgressEvent { private final Long scheduleId; private final String phase; private final int progress; private final long totalFiles; private final long processedFiles; public SyncProgressEvent(Long scheduleId, String phase, int progress, long totalFiles, long processedFiles) { this.scheduleId = scheduleId; this.phase = phase; this.progress = progress; this.totalFiles = totalFiles; this.processedFiles = processedFiles; } // getters } 백업 완료 이벤트 public class BackupCompletedEvent { private final Long backupId; private final String backupPath; private final long fileCount; private final long totalSize; private final Duration duration; public BackupCompletedEvent(Long backupId, String backupPath, long fileCount, long totalSize, Duration duration) { this.backupId = backupId; this.backupPath = backupPath; this.fileCount = fileCount; this.totalSize = totalSize; this.duration = duration; } // getters } 동기화 서비스 @Service @Slf4j @RequiredArgsConstructor public class FileSyncService { private final ApplicationEventPublisher eventPublisher; private final RsyncExecutor rsyncExecutor; @Async public void syncFiles(SyncSchedule schedule) { try { // 동기화 시작 이벤트 eventPublisher.publishEvent(new SyncStartedEvent(schedule.getId())); long totalFiles = calculateTotalFiles(schedule); long processedFiles = 0; // 진행 상황 업데이트 for (String directory : schedule.getDirectories()) { rsyncExecutor.sync(directory, schedule.getDestination()); processedFiles++; int progress = (int) ((processedFiles * 100) / totalFiles); eventPublisher.publishEvent( new SyncProgressEvent(schedule.getId(), \u0026#34;syncing\u0026#34;, progress, totalFiles, processedFiles) ); } // 동기화 완료 이벤트 eventPublisher.publishEvent(new SyncCompletedEvent(schedule.getId(), processedFiles)); } catch (Exception e) { // 동기화 실패 이벤트 eventPublisher.publishEvent(new SyncFailedEvent(schedule.getId(), e.getMessage())); } } } 이벤트 리스너들 @Component @Slf4j @RequiredArgsConstructor public class SyncEventListeners { private final NotificationService notificationService; private final MetricsCollector metricsCollector; private final AuditLogger auditLogger; @EventListener public void handleSyncStarted(SyncStartedEvent event) { log.info(\u0026#34;동기화 시작: 스케줄 ID {}\u0026#34;, event.getScheduleId()); auditLogger.log(\u0026#34;SYNC_STARTED\u0026#34;, event.getScheduleId()); } @Async @EventListener public void handleSyncProgress(SyncProgressEvent event) { log.info(\u0026#34;동기화 진행: {}% ({}/{})\u0026#34;, event.getProgress(), event.getProcessedFiles(), event.getTotalFiles()); // WebSocket으로 실시간 진행 상황 전송 notificationService.broadcastProgress(event); } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleSyncCompleted(SyncCompletedEvent event) { log.info(\u0026#34;동기화 완료: 스케줄 ID {}, 파일 수 {}\u0026#34;, event.getScheduleId(), event.getFileCount()); // 메트릭 수집 metricsCollector.recordSyncSuccess(event.getScheduleId(), event.getFileCount()); // 완료 알림 notificationService.sendCompletionNotification(event); } @EventListener public void handleSyncFailed(SyncFailedEvent event) { log.error(\u0026#34;동기화 실패: 스케줄 ID {}, 원인: {}\u0026#34;, event.getScheduleId(), event.getErrorMessage()); // 에러 알림 notificationService.sendErrorNotification(event); // 재시도 스케줄링 retryScheduler.scheduleRetry(event.getScheduleId()); } @Async @EventListener public void handleBackupCompleted(BackupCompletedEvent event) { log.info(\u0026#34;백업 완료: {} 파일, {} bytes, 소요시간 {}\u0026#34;, event.getFileCount(), event.getTotalSize(), event.getDuration()); // 백업 통계 업데이트 metricsCollector.recordBackup(event); // 오래된 백업 정리 스케줄링 cleanupScheduler.scheduleOldBackupCleanup(event.getBackupPath()); } } 다중 이벤트 발행 패턴 @Service @RequiredArgsConstructor public class BackupService { private final ApplicationEventPublisher eventPublisher; @Transactional public void createBackup(BackupRequest request) { Instant startTime = Instant.now(); // 백업 시작 이벤트 eventPublisher.publishEvent(new BackupStartedEvent(request.getScheduleId())); try { BackupResult result = performBackup(request); Duration duration = Duration.between(startTime, Instant.now()); // 백업 완료 이벤트 eventPublisher.publishEvent(new BackupCompletedEvent( result.getBackupId(), result.getBackupPath(), result.getFileCount(), result.getTotalSize(), duration )); // 통계 업데이트 이벤트 eventPublisher.publishEvent(new BackupStatisticsUpdatedEvent( request.getScheduleId(), result.getFileCount(), result.getTotalSize() )); // 검증 시작 이벤트 eventPublisher.publishEvent(new BackupVerificationStartedEvent( result.getBackupId() )); } catch (BackupException e) { // 백업 실패 이벤트 eventPublisher.publishEvent(new BackupFailedEvent( request.getScheduleId(), e.getMessage(), e.getErrorCode() )); } } } 이벤트 vs 직접 호출 비교 직접 호출 방식 @Service @RequiredArgsConstructor public class OrderServiceDirect { private final OrderRepository orderRepository; private final EmailService emailService; private final InventoryService inventoryService; private final AnalyticsService analyticsService; private final NotificationService notificationService; @Transactional public Order createOrder(OrderRequest request) { Order order = new Order(request); order = orderRepository.save(order); // 모든 서비스를 직접 호출 emailService.sendOrderConfirmation(order); inventoryService.updateStock(order); analyticsService.trackOrder(order); notificationService.notifyWarehouse(order); return order; } } 문제점:\nOrderService가 4개의 서비스에 강하게 결합 새 기능 추가 시 OrderService 수정 필요 테스트 시 모든 의존성 모킹 필요 한 서비스의 실패가 전체 주문 프로세스에 영향 이벤트 기반 방식 @Service @RequiredArgsConstructor public class OrderServiceEvent { private final OrderRepository orderRepository; private final ApplicationEventPublisher eventPublisher; @Transactional public Order createOrder(OrderRequest request) { Order order = new Order(request); order = orderRepository.save(order); // 이벤트만 발행 eventPublisher.publishEvent(new OrderCreatedEvent(order)); return order; } } @Component @RequiredArgsConstructor class OrderCreatedListeners { @Async @EventListener public void sendEmail(OrderCreatedEvent event) { emailService.sendOrderConfirmation(event.getOrder()); } @Async @EventListener public void updateInventory(OrderCreatedEvent event) { inventoryService.updateStock(event.getOrder()); } @Async @EventListener public void trackAnalytics(OrderCreatedEvent event) { analyticsService.trackOrder(event.getOrder()); } @Async @EventListener public void notifyWarehouse(OrderCreatedEvent event) { notificationService.notifyWarehouse(event.getOrder()); } } 장점:\nOrderService는 단일 책임(주문 생성)만 가짐 리스너 추가/제거가 OrderService에 영향 없음 각 리스너를 독립적으로 테스트 가능 비동기 처리로 성능 향상 한 리스너의 실패가 다른 리스너에 영향 없음 성능 비교 @Service @Slf4j public class PerformanceComparisonService { // 직접 호출: 순차 실행 public void directCall() { long start = System.currentTimeMillis(); service1.process(); // 100ms service2.process(); // 150ms service3.process(); // 200ms long total = System.currentTimeMillis() - start; log.info(\u0026#34;직접 호출 총 시간: {}ms\u0026#34;, total); // 약 450ms } // 이벤트 기반: 비동기 실행 public void eventDriven() { long start = System.currentTimeMillis(); eventPublisher.publishEvent(new SomeEvent()); long total = System.currentTimeMillis() - start; log.info(\u0026#34;이벤트 발행 시간: {}ms\u0026#34;, total); // 약 5ms // 실제 처리는 백그라운드에서 병렬로 실행 } } 마무리 Spring Boot의 이벤트 기반 아키텍처는 애플리케이션의 결합도를 낮추고 확장성을 높이는 강력한 도구입니다.\n핵심 정리 이벤트 기반 아키텍처: 컴포넌트 간 느슨한 결합을 통한 유연한 설계 ApplicationEvent: POJO 또는 ApplicationEvent 상속으로 이벤트 정의 ApplicationEventPublisher: publishEvent()로 이벤트 발행 @EventListener: 이벤트 구독 및 처리 @Async: 비동기 이벤트 처리로 성능 향상 @TransactionalEventListener: 트랜잭션 커밋 후 안전한 이벤트 처리 사용 시나리오 사용자 활동 추적: 회원가입, 로그인, 주문 등의 이벤트 로깅 알림 시스템: 이메일, SMS, 푸시 알림 비동기 발송 데이터 동기화: 캐시 무효화, 외부 시스템 연동 비즈니스 프로세스: 주문 처리, 결제, 배송 등 다단계 프로세스 모니터링: 성능 메트릭 수집, 에러 추적 주의사항 순서 보장: 여러 리스너의 실행 순서는 보장되지 않습니다(@Order 사용 가능) 트랜잭션 전파: 동기 리스너는 발행자의 트랜잭션을 공유합니다 예외 처리: 리스너의 예외가 발행자에게 전파될 수 있습니다(비동기 시 주의) 과도한 사용: 단순한 로직에는 직접 호출이 더 명확할 수 있습니다 이벤트 기반 아키텍처는 복잡한 비즈니스 로직을 깔끔하게 분리하고, 시스템의 확장성과 유지보수성을 크게 향상시킵니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/spring-boot-event-driven/","summary":"\u003ch2 id=\"이벤트-기반-아키텍처란\"\u003e이벤트 기반 아키텍처란\u003c/h2\u003e\n\u003cp\u003e이벤트 기반 아키텍처(Event-Driven Architecture)는 시스템의 컴포넌트들이 이벤트를 통해 상호작용하는 설계 패턴입니다. 특정 작업이 완료되었을 때 이벤트를 발행하고, 관심 있는 컴포넌트가 이를 구독하여 처리하는 방식입니다.\u003c/p\u003e\n\u003cp\u003e전통적인 방식에서는 서비스 A가 서비스 B, C, D를 직접 호출했다면, 이벤트 기반 방식에서는 서비스 A가 이벤트만 발행하고, B, C, D가 각자 필요한 이벤트를 구독하여 처리합니다.\u003c/p\u003e\n\u003ch2 id=\"왜-이벤트-기반-아키텍처인가\"\u003e왜 이벤트 기반 아키텍처인가\u003c/h2\u003e\n\u003ch3 id=\"서비스-간-결합도-감소\"\u003e서비스 간 결합도 감소\u003c/h3\u003e\n\u003cp\u003e직접 호출 방식의 문제점:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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:#89b4fa;font-weight:bold\"\u003e@Service\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#f9e2af\"\u003eUserService\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003efinal\u003c/span\u003e EmailService emailService;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003efinal\u003c/span\u003e NotificationService notificationService;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003efinal\u003c/span\u003e AuditService auditService;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003efinal\u003c/span\u003e AnalyticsService analyticsService;\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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003evoid\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003eregisterUser\u003c/span\u003e(User user) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        userRepository.\u003cspan style=\"color:#89b4fa\"\u003esave\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        \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 모든 서비스를 직접 호출\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        emailService.\u003cspan style=\"color:#89b4fa\"\u003esendWelcomeEmail\u003c/span\u003e(user);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        notificationService.\u003cspan style=\"color:#89b4fa\"\u003esendPushNotification\u003c/span\u003e(user);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        auditService.\u003cspan style=\"color:#89b4fa\"\u003elogUserRegistration\u003c/span\u003e(user);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        analyticsService.\u003cspan style=\"color:#89b4fa\"\u003etrackUserSignup\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\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e위 코드는 \u003ccode\u003eUserService\u003c/code\u003e가 4개의 서비스에 강하게 결합되어 있습니다. 새로운 기능 추가 시마다 \u003ccode\u003eUserService\u003c/code\u003e를 수정해야 합니다.\u003c/p\u003e","tags":["Spring Boot","이벤트","비동기","아키텍처"],"title":"Spring Boot 이벤트 아키텍처 - @EventListener, @Async, 트랜잭션 이벤트"},{"content":"동시성 프로그래밍이란 현대 애플리케이션은 여러 작업을 동시에 처리해야 합니다. 파일 업로드를 받으면서 DB 쿼리를 실행하고, API 요청을 처리하는 등 멀티태스킹은 필수입니다. Java는 강력한 동시성 라이브러리를 제공하여 이런 작업을 안전하고 효율적으로 처리할 수 있게 합니다.\n이 글에서는 Thread 기본부터 ThreadPool, 동기화 메커니즘까지 실전 예제와 함께 알아봅니다.\nThread와 Runnable 기본 Thread 생성 방법 Java에서 스레드를 생성하는 두 가지 방법이 있습니다.\n1. Thread 클래스 상속\npublic class MyThread extends Thread { @Override public void run() { System.out.println(\u0026#34;Thread 실행: \u0026#34; + Thread.currentThread().getName()); } } // 사용 MyThread thread = new MyThread(); thread.start(); 2. Runnable 인터페이스 구현 (권장)\npublic class MyRunnable implements Runnable { @Override public void run() { System.out.println(\u0026#34;Runnable 실행: \u0026#34; + Thread.currentThread().getName()); } } // 사용 Thread thread = new Thread(new MyRunnable()); thread.start(); // 람다 표현식으로 간결하게 Thread thread2 = new Thread(() -\u0026gt; { System.out.println(\u0026#34;Lambda 실행: \u0026#34; + Thread.currentThread().getName()); }); thread2.start(); Runnable 방식이 권장되는 이유:\n다른 클래스를 상속받을 수 있음 코드 재사용성이 높음 함수형 인터페이스로 람다 사용 가능 간단한 병렬 다운로드 예제 public class SimpleDownloader { public static void main(String[] args) { String[] urls = { \u0026#34;https://example.com/file1.zip\u0026#34;, \u0026#34;https://example.com/file2.zip\u0026#34;, \u0026#34;https://example.com/file3.zip\u0026#34; }; for (String url : urls) { new Thread(() -\u0026gt; { downloadFile(url); }).start(); } } private static void downloadFile(String url) { System.out.println(\u0026#34;다운로드 시작: \u0026#34; + url + \u0026#34; [\u0026#34; + Thread.currentThread().getName() + \u0026#34;]\u0026#34;); try { Thread.sleep(2000); // 다운로드 시뮬레이션 } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;다운로드 완료: \u0026#34; + url); } } 하지만 이 방식은 스레드 수를 제어할 수 없어 위험합니다. 수천 개의 파일이 있다면 수천 개의 스레드가 생성되어 시스템이 멈출 수 있습니다.\nExecutorService와 ThreadPool ThreadPool의 필요성 스레드 생성은 비용이 큽니다. ThreadPool을 사용하면:\n스레드를 미리 생성해두고 재사용 동시 실행 스레드 수 제한 작업 큐를 통한 작업 관리 ExecutorService 기본 사용법 import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class ExecutorServiceExample { public static void main(String[] args) { // 고정 크기 ThreadPool 생성 ExecutorService executor = Executors.newFixedThreadPool(3); // 10개 작업 제출 for (int i = 0; i \u0026lt; 10; i++) { final int taskId = i; executor.submit(() -\u0026gt; { System.out.println(\u0026#34;Task \u0026#34; + taskId + \u0026#34; 실행 중 [\u0026#34; + Thread.currentThread().getName() + \u0026#34;]\u0026#34;); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(\u0026#34;Task \u0026#34; + taskId + \u0026#34; 완료\u0026#34;); }); } // Executor 종료 executor.shutdown(); try { // 모든 작업 완료 대기 (최대 60초) if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); } System.out.println(\u0026#34;모든 작업 완료\u0026#34;); } } 출력 예시:\nTask 0 실행 중 [pool-1-thread-1] Task 1 실행 중 [pool-1-thread-2] Task 2 실행 중 [pool-1-thread-3] Task 0 완료 Task 3 실행 중 [pool-1-thread-1] ... 3개 스레드만 사용하여 10개 작업을 순차적으로 처리합니다.\nnewFixedThreadPool vs newCachedThreadPool newFixedThreadPool(n): 고정된 n개의 스레드 사용\nExecutorService fixed = Executors.newFixedThreadPool(5); // 5개 스레드로 모든 작업 처리 // 작업이 많으면 큐에 대기 newCachedThreadPool(): 필요시 스레드 생성, 60초 유휴시 제거\nExecutorService cached = Executors.newCachedThreadPool(); // 작업 수만큼 스레드 생성 (제한 없음) // 짧고 많은 작업에 적합 선택 기준:\nCPU 집약적 작업: newFixedThreadPool(Runtime.getRuntime().availableProcessors()) I/O 집약적 작업: newFixedThreadPool(n * 2) 또는 newCachedThreadPool() 작업 수가 예측 가능: newFixedThreadPool 작업 수가 변동적이고 짧음: newCachedThreadPool ThreadPoolExecutor 커스터마이징 더 세밀한 제어가 필요하면 ThreadPoolExecutor를 직접 생성합니다.\nimport java.util.concurrent.*; public class CustomThreadPoolExample { public static void main(String[] args) { int corePoolSize = 5; // 기본 스레드 수 int maxPoolSize = 10; // 최대 스레드 수 long keepAliveTime = 60L; // 유휴 스레드 대기 시간 BlockingQueue\u0026lt;Runnable\u0026gt; queue = new LinkedBlockingQueue\u0026lt;\u0026gt;(100); // 작업 큐 ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, maxPoolSize, keepAliveTime, TimeUnit.SECONDS, queue, new ThreadPoolExecutor.CallerRunsPolicy() // 거부 정책 ); // 작업 제출 for (int i = 0; i \u0026lt; 200; i++) { final int taskId = i; executor.submit(() -\u0026gt; { System.out.println(\u0026#34;Task \u0026#34; + taskId + \u0026#34; 처리 중\u0026#34;); try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } // 모니터링 System.out.println(\u0026#34;Active threads: \u0026#34; + executor.getActiveCount()); System.out.println(\u0026#34;Pool size: \u0026#34; + executor.getPoolSize()); System.out.println(\u0026#34;Queue size: \u0026#34; + executor.getQueue().size()); executor.shutdown(); } } 거부 정책 (RejectedExecutionHandler) 큐가 가득 차고 최대 스레드에 도달하면:\nAbortPolicy (기본): RejectedExecutionException 발생 CallerRunsPolicy: 호출한 스레드에서 직접 실행 (백프레셔) DiscardPolicy: 조용히 버림 DiscardOldestPolicy: 가장 오래된 작업 버리고 새 작업 추가 // 백프레셔 적용 - 서버 과부하 방지 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); ConcurrentHashMap vs Collections.synchronizedMap 동기화된 컬렉션의 필요성 일반 HashMap은 멀티스레드 환경에서 안전하지 않습니다.\n// 위험한 코드 Map\u0026lt;String, Integer\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); // 여러 스레드가 동시에 접근 executor.submit(() -\u0026gt; map.put(\u0026#34;key1\u0026#34;, 1)); executor.submit(() -\u0026gt; map.put(\u0026#34;key2\u0026#34;, 2)); // ConcurrentModificationException 또는 데이터 손실 발생 가능 Collections.synchronizedMap Map\u0026lt;String, Integer\u0026gt; syncMap = Collections.synchronizedMap(new HashMap\u0026lt;\u0026gt;()); syncMap.put(\u0026#34;key1\u0026#34;, 1); // 메서드 단위 동기화 단점:\n모든 작업에 락이 걸려 성능 저하 iteration시 외부에서 동기화 필요 synchronized (syncMap) { for (Map.Entry\u0026lt;String, Integer\u0026gt; entry : syncMap.entrySet()) { System.out.println(entry.getKey() + \u0026#34;: \u0026#34; + entry.getValue()); } } ConcurrentHashMap (권장) 세그먼트별 락으로 동시성 향상:\nimport java.util.concurrent.ConcurrentHashMap; public class ConcurrentMapExample { private static ConcurrentHashMap\u0026lt;String, Integer\u0026gt; map = new ConcurrentHashMap\u0026lt;\u0026gt;(); public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(10); // 동시 쓰기 for (int i = 0; i \u0026lt; 100; i++) { final int value = i; executor.submit(() -\u0026gt; { map.put(\u0026#34;key\u0026#34; + value, value); }); } // 원자적 연산 executor.submit(() -\u0026gt; { map.putIfAbsent(\u0026#34;counter\u0026#34;, 0); map.compute(\u0026#34;counter\u0026#34;, (k, v) -\u0026gt; v + 1); }); executor.submit(() -\u0026gt; { map.computeIfAbsent(\u0026#34;newKey\u0026#34;, k -\u0026gt; 100); }); executor.shutdown(); try { executor.awaitTermination(1, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // iteration에 외부 동기화 불필요 map.forEach((key, value) -\u0026gt; { System.out.println(key + \u0026#34;: \u0026#34; + value); }); } } ConcurrentHashMap의 장점:\n읽기 작업은 락 없음 쓰기 작업은 세그먼트별 락 putIfAbsent, compute, merge 등 원자적 연산 제공 AtomicInteger와 AtomicBoolean 문제 상황: 카운터의 동시성 이슈 // 위험한 코드 private int counter = 0; public void increment() { counter++; // 원자적이지 않음! (read-modify-write) } 여러 스레드가 동시에 increment()를 호출하면 값이 누락됩니다.\nsynchronized로 해결 private int counter = 0; public synchronized void increment() { counter++; } 간단하지만 락 비용이 있습니다.\nAtomicInteger 사용 (권장) import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounterExample { private AtomicInteger counter = new AtomicInteger(0); public void increment() { counter.incrementAndGet(); // 원자적 연산, 락 없음 } public int get() { return counter.get(); } public static void main(String[] args) throws InterruptedException { AtomicCounterExample example = new AtomicCounterExample(); ExecutorService executor = Executors.newFixedThreadPool(10); // 1000번 증가 작업 제출 for (int i = 0; i \u0026lt; 1000; i++) { executor.submit(example::increment); } executor.shutdown(); executor.awaitTermination(1, TimeUnit.SECONDS); System.out.println(\u0026#34;최종 카운터: \u0026#34; + example.get()); // 정확히 1000 } } AtomicBoolean으로 상태 관리 import java.util.concurrent.atomic.AtomicBoolean; public class FileProcessor { private AtomicBoolean isProcessing = new AtomicBoolean(false); public void processFile(String filePath) { // 이미 처리 중이면 스킵 if (!isProcessing.compareAndSet(false, true)) { System.out.println(\u0026#34;이미 처리 중입니다.\u0026#34;); return; } try { // 파일 처리 로직 System.out.println(\u0026#34;파일 처리 중: \u0026#34; + filePath); Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { isProcessing.set(false); // 처리 완료 } } } compareAndSet(expected, update): 현재 값이 expected면 update로 변경하고 true 반환 (원자적)\nSemaphore로 동시 접근 제어 Semaphore란 특정 자원에 동시 접근할 수 있는 스레드 수를 제한합니다.\nimport java.util.concurrent.Semaphore; public class ConnectionPool { private Semaphore semaphore; public ConnectionPool(int maxConnections) { this.semaphore = new Semaphore(maxConnections); } public void useConnection(String taskName) { try { System.out.println(taskName + \u0026#34; - 커넥션 대기 중...\u0026#34;); semaphore.acquire(); // 허가 획득 (없으면 대기) System.out.println(taskName + \u0026#34; - 커넥션 획득! [가용: \u0026#34; + semaphore.availablePermits() + \u0026#34;]\u0026#34;); // DB 작업 시뮬레이션 Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { System.out.println(taskName + \u0026#34; - 커넥션 반환\u0026#34;); semaphore.release(); // 허가 반환 } } public static void main(String[] args) { ConnectionPool pool = new ConnectionPool(3); // 최대 3개 동시 접근 ExecutorService executor = Executors.newFixedThreadPool(10); for (int i = 0; i \u0026lt; 10; i++) { final String taskName = \u0026#34;Task-\u0026#34; + i; executor.submit(() -\u0026gt; pool.useConnection(taskName)); } executor.shutdown(); } } 출력 예시:\nTask-0 - 커넥션 대기 중... Task-1 - 커넥션 대기 중... Task-0 - 커넥션 획득! [가용: 2] Task-1 - 커넥션 획득! [가용: 1] Task-2 - 커넥션 대기 중... Task-2 - 커넥션 획득! [가용: 0] Task-3 - 커넥션 대기 중... // Task-3는 누군가 release()할 때까지 대기 synchronized vs Lock synchronized 키워드 public class SynchronizedExample { private int balance = 0; // 메서드 전체 동기화 public synchronized void deposit(int amount) { balance += amount; } // 블록 단위 동기화 public void withdraw(int amount) { synchronized (this) { balance -= amount; } } // 정적 메서드 동기화 (클래스 레벨 락) public static synchronized void staticMethod() { // ... } } 장점:\n간단하고 명확 자동 락 해제 (예외 발생시에도) 단점:\n타임아웃 불가 공정성 제어 불가 읽기/쓰기 구분 불가 ReentrantLock import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class LockExample { private final Lock lock = new ReentrantLock(); private int balance = 0; public void deposit(int amount) { lock.lock(); try { balance += amount; } finally { lock.unlock(); // 반드시 finally에서 해제 } } // 타임아웃 지원 public boolean tryDepositWithTimeout(int amount) { try { if (lock.tryLock(1, TimeUnit.SECONDS)) { try { balance += amount; return true; } finally { lock.unlock(); } } else { System.out.println(\u0026#34;락 획득 실패 - 타임아웃\u0026#34;); return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } } ReadWriteLock으로 성능 향상 읽기는 동시에, 쓰기는 독점적으로:\nimport java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; public class CacheWithReadWriteLock { private final Map\u0026lt;String, String\u0026gt; cache = new HashMap\u0026lt;\u0026gt;(); private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); public String get(String key) { rwLock.readLock().lock(); try { return cache.get(key); } finally { rwLock.readLock().unlock(); } } public void put(String key, String value) { rwLock.writeLock().lock(); try { cache.put(key, value); } finally { rwLock.writeLock().unlock(); } } } 읽기가 많고 쓰기가 적으면 성능이 크게 향상됩니다.\nCompletableFuture 비동기 처리 Future의 한계 ExecutorService executor = Executors.newFixedThreadPool(2); Future\u0026lt;String\u0026gt; future = executor.submit(() -\u0026gt; { Thread.sleep(2000); return \u0026#34;결과\u0026#34;; }); // 블로킹 대기 String result = future.get(); // 2초 동안 멈춤 콜백이나 체이닝이 불가능합니다.\nCompletableFuture 기본 import java.util.concurrent.CompletableFuture; public class CompletableFutureExample { public static void main(String[] args) { // 비동기 작업 시작 CompletableFuture\u0026lt;String\u0026gt; future = CompletableFuture.supplyAsync(() -\u0026gt; { System.out.println(\u0026#34;비동기 작업 시작 [\u0026#34; + Thread.currentThread().getName() + \u0026#34;]\u0026#34;); try { Thread.sleep(2000); } catch (InterruptedException e) { throw new RuntimeException(e); } return \u0026#34;데이터 조회 완료\u0026#34;; }); // 콜백 체이닝 future.thenApply(result -\u0026gt; { System.out.println(\u0026#34;변환: \u0026#34; + result); return result.toUpperCase(); }).thenAccept(result -\u0026gt; { System.out.println(\u0026#34;최종 결과: \u0026#34; + result); }).exceptionally(ex -\u0026gt; { System.err.println(\u0026#34;오류 발생: \u0026#34; + ex.getMessage()); return null; }); System.out.println(\u0026#34;메인 스레드는 계속 실행\u0026#34;); // 완료 대기 (실전에서는 다른 방식 사용) future.join(); } } 여러 작업 조합 public class CombineExample { public static void main(String[] args) { CompletableFuture\u0026lt;String\u0026gt; future1 = CompletableFuture.supplyAsync(() -\u0026gt; { sleep(1000); return \u0026#34;사용자 정보\u0026#34;; }); CompletableFuture\u0026lt;String\u0026gt; future2 = CompletableFuture.supplyAsync(() -\u0026gt; { sleep(1500); return \u0026#34;주문 정보\u0026#34;; }); CompletableFuture\u0026lt;String\u0026gt; future3 = CompletableFuture.supplyAsync(() -\u0026gt; { sleep(800); return \u0026#34;배송 정보\u0026#34;; }); // 모두 완료 대기 CompletableFuture\u0026lt;Void\u0026gt; allOf = CompletableFuture.allOf(future1, future2, future3); allOf.thenRun(() -\u0026gt; { try { System.out.println(future1.get()); System.out.println(future2.get()); System.out.println(future3.get()); } catch (Exception e) { e.printStackTrace(); } }); // 가장 빠른 것 하나만 CompletableFuture\u0026lt;Object\u0026gt; anyOf = CompletableFuture.anyOf(future1, future2, future3); anyOf.thenAccept(result -\u0026gt; { System.out.println(\u0026#34;가장 빠른 결과: \u0026#34; + result); }); allOf.join(); } private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { throw new RuntimeException(e); } } } 순차적 의존 작업 CompletableFuture.supplyAsync(() -\u0026gt; { return getUserId(); }) .thenCompose(userId -\u0026gt; { return CompletableFuture.supplyAsync(() -\u0026gt; getUserProfile(userId)); }) .thenApply(profile -\u0026gt; { return enrichProfile(profile); }) .thenAccept(finalProfile -\u0026gt; { System.out.println(\u0026#34;프로필: \u0026#34; + finalProfile); }); thenCompose는 중첩된 Future를 평탄화합니다 (flatMap과 유사).\nContext Switching (컨텍스트 스위칭) 스레드를 많이 만든다고 항상 빨라지는 것은 아닙니다. CPU가 여러 스레드를 전환하는 비용을 이해해야 합니다.\n컨텍스트 스위칭이란 CPU 코어 1개는 한 순간에 1개의 스레드만 실행 가능 → 여러 스레드를 번갈아가며 실행 → 이 전환 작업 = Context Switching 스레드 전환 1회마다 다음 작업이 발생합니다:\n현재 스레드 상태(레지스터, 프로그램 카운터 등) 저장 다음 스레드 상태 불러오기 CPU 캐시 초기화 (캐시 미스 발생) 전환 1회당 약 1~10 마이크로초가 소요됩니다.\n스레드가 많을수록 나빠지는 이유 스레드 2개: 실제작업 90% / 전환비용 10% 스레드 1000개: 실제작업 20% / 전환비용 80% 스레드 수가 늘어날수록 실제 작업보다 전환 비용이 커져 전체 성능이 오히려 저하됩니다.\nOS가 강제로 번갈아 실행하는 이유 (선점형 스케줄링) 응답성 보장: 긴 작업 중에도 다른 스레드가 CPU 시간을 확보 CPU 낭비 방지: I/O 대기 중인 스레드의 자리를 다른 스레드가 채움 공평성: 특정 스레드의 CPU 독점 방지 스레드 최대 개수와 적정 수 제한 요소 요소 내용 메모리 스레드 1개당 512KB1MB 스택 / RAM 8GB 기준 이론상 최대 8,00016,000개 OS 제한 Linux 기준 수천~수만 사이 Context Switching 스레드가 많을수록 전환 비용 증가 실무 권장 개수 CPU 바운드 작업 (연산이 많은): CPU 코어 수 + 1 I/O 바운드 작업 (대기가 많은): CPU 코어 수 * 2~4 코어 수 * 2는 스위칭을 없애는 것이 아니라, I/O 대기 중 놀고 있는 CPU를 최대한 활용하기 위한 숫자입니다.\n적정 스레드 수 = CPU 코어 수 * (1 + 대기시간/실행시간) 예를 들어 4코어 CPU에서 작업의 75%가 I/O 대기라면: 4 * (1 + 0.75/0.25) = 16개가 적정입니다.\nLinkedBlockingQueue 내부 동작 원리 ThreadPoolExecutor의 작업 큐로 자주 사용되는 LinkedBlockingQueue가 어떻게 스레드 안전성을 보장하는지 알아봅니다.\n간섭 없는 이유 3가지 1. 분리된 Lock - 동시 접근 방지\nputLock → put 전용 락 takeLock → take 전용 락 → put과 take가 서로 다른 락 사용 → 동시에 넣고 꺼내도 충돌 없음 일반적인 큐와 달리 put/take가 서로 다른 락을 사용하므로, 생산자와 소비자가 동시에 동작할 수 있습니다.\n2. Blocking - 스레드 자동 대기/깨움\n큐가 비면 → take() 호출한 워커 스레드가 자동 sleep 데이터 들어오면 → 자동으로 깨어남 → CPU 낭비 없이 대기 바쁜 대기(busy waiting) 없이 효율적으로 동작합니다.\n3. 메모리 가시성 보장\n내부적으로 volatile + Lock 사용 → 한 스레드가 넣은 값을 다른 스레드가 반드시 볼 수 있음 워커 1개 vs 워커 N개 단일 워커와 다중 워커의 차이를 이해하는 것이 중요합니다.\n워커 1개 (newSingleThreadExecutor): 명령1 (30초) → 처리 중... 명령2 (30초) → 큐 대기 (30초 후 시작) 워커 3개 (newFixedThreadPool(3)): 명령1 (30초) → 워커1 처리 중... 명령2 (30초) → 워커2 처리 중... → 동시 실행 명령3 (30초) → 워커3 처리 중... → 동시 실행 // 고정 워커 (권장) - 스레드 수 예측 가능 ExecutorService worker = Executors.newFixedThreadPool(4); // 단일 워커 - 순서 보장이 필요할 때 ExecutorService worker = Executors.newSingleThreadExecutor(); // 유동 워커 - 스레드 무한 생성 위험, 짧은 작업에만 사용 ExecutorService worker = Executors.newCachedThreadPool(); 다중 워커에서 큐를 공유하는 패턴:\nprivate final BlockingQueue\u0026lt;String\u0026gt; commandQueue = new LinkedBlockingQueue\u0026lt;\u0026gt;(); private final ExecutorService worker = Executors.newFixedThreadPool(4); @PostConstruct public void start() { for (int i = 0; i \u0026lt; 4; i++) { worker.submit(() -\u0026gt; { while (true) { String cmd = commandQueue.take(); // 큐가 비면 자동 대기 doLongTask(cmd); // 4개 동시 처리 } }); } } LinkedBlockingQueue가 스레드 안전하므로 4개의 워커가 하나의 큐에서 동시에 작업을 꺼내도 안전합니다.\n실전 예시: 파일 병렬 전송 시나리오 실제 파일 동기화 시스템에서 여러 파티션의 파일을 병렬로 전송하는 예제입니다.\nimport java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class ParallelFileSyncSystem { private final ExecutorService executor; private final Semaphore bandwidthLimiter; private final AtomicInteger totalFilesProcessed = new AtomicInteger(0); private final ConcurrentHashMap\u0026lt;String, SyncStatus\u0026gt; statusMap = new ConcurrentHashMap\u0026lt;\u0026gt;(); public ParallelFileSyncSystem(int maxThreads, int maxConcurrentUploads) { this.executor = new ThreadPoolExecutor( maxThreads / 2, maxThreads, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue\u0026lt;\u0026gt;(1000), new ThreadPoolExecutor.CallerRunsPolicy() ); this.bandwidthLimiter = new Semaphore(maxConcurrentUploads); } public void syncPartitions(List\u0026lt;Partition\u0026gt; partitions) { List\u0026lt;CompletableFuture\u0026lt;Void\u0026gt;\u0026gt; futures = new ArrayList\u0026lt;\u0026gt;(); for (Partition partition : partitions) { CompletableFuture\u0026lt;Void\u0026gt; future = CompletableFuture.runAsync(() -\u0026gt; { syncPartition(partition); }, executor); futures.add(future); } // 모든 파티션 동기화 완료 대기 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .thenRun(() -\u0026gt; { System.out.println(\u0026#34;=== 전체 동기화 완료 ===\u0026#34;); System.out.println(\u0026#34;처리된 파일 수: \u0026#34; + totalFilesProcessed.get()); printStatusSummary(); }) .join(); } private void syncPartition(Partition partition) { System.out.println(\u0026#34;파티션 동기화 시작: \u0026#34; + partition.getName()); statusMap.put(partition.getName(), SyncStatus.IN_PROGRESS); List\u0026lt;String\u0026gt; files = partition.getFiles(); List\u0026lt;CompletableFuture\u0026lt;Void\u0026gt;\u0026gt; fileFutures = new ArrayList\u0026lt;\u0026gt;(); for (String file : files) { CompletableFuture\u0026lt;Void\u0026gt; fileFuture = CompletableFuture.runAsync(() -\u0026gt; { transferFile(partition.getName(), file); }, executor); fileFutures.add(fileFuture); } // 파티션 내 모든 파일 완료 대기 CompletableFuture.allOf(fileFutures.toArray(new CompletableFuture[0])) .thenRun(() -\u0026gt; { statusMap.put(partition.getName(), SyncStatus.COMPLETED); System.out.println(\u0026#34;파티션 완료: \u0026#34; + partition.getName() + \u0026#34; (\u0026#34; + files.size() + \u0026#34; files)\u0026#34;); }) .exceptionally(ex -\u0026gt; { statusMap.put(partition.getName(), SyncStatus.FAILED); System.err.println(\u0026#34;파티션 실패: \u0026#34; + partition.getName() + \u0026#34; - \u0026#34; + ex.getMessage()); return null; }) .join(); } private void transferFile(String partitionName, String filePath) { try { // 대역폭 제한 (동시 업로드 수 제어) bandwidthLimiter.acquire(); System.out.println(\u0026#34; [\u0026#34; + partitionName + \u0026#34;] 전송 시작: \u0026#34; + filePath + \u0026#34; [\u0026#34; + Thread.currentThread().getName() + \u0026#34;]\u0026#34;); // 파일 전송 시뮬레이션 Thread.sleep(ThreadLocalRandom.current().nextInt(500, 1500)); totalFilesProcessed.incrementAndGet(); System.out.println(\u0026#34; [\u0026#34; + partitionName + \u0026#34;] 전송 완료: \u0026#34; + filePath); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(\u0026#34;전송 중단: \u0026#34; + filePath, e); } finally { bandwidthLimiter.release(); } } private void printStatusSummary() { System.out.println(\u0026#34;\\n=== 파티션별 상태 ===\u0026#34;); statusMap.forEach((partition, status) -\u0026gt; { System.out.println(partition + \u0026#34;: \u0026#34; + status); }); } public void shutdown() { executor.shutdown(); try { if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); } } // 데이터 클래스 static class Partition { private final String name; private final List\u0026lt;String\u0026gt; files; public Partition(String name, List\u0026lt;String\u0026gt; files) { this.name = name; this.files = files; } public String getName() { return name; } public List\u0026lt;String\u0026gt; getFiles() { return files; } } enum SyncStatus { IN_PROGRESS, COMPLETED, FAILED } // 테스트 실행 public static void main(String[] args) { List\u0026lt;Partition\u0026gt; partitions = Arrays.asList( new Partition(\u0026#34;partition-A\u0026#34;, Arrays.asList( \u0026#34;/data/a/file1.dat\u0026#34;, \u0026#34;/data/a/file2.dat\u0026#34;, \u0026#34;/data/a/file3.dat\u0026#34; )), new Partition(\u0026#34;partition-B\u0026#34;, Arrays.asList( \u0026#34;/data/b/file1.dat\u0026#34;, \u0026#34;/data/b/file2.dat\u0026#34; )), new Partition(\u0026#34;partition-C\u0026#34;, Arrays.asList( \u0026#34;/data/c/file1.dat\u0026#34;, \u0026#34;/data/c/file2.dat\u0026#34;, \u0026#34;/data/c/file3.dat\u0026#34;, \u0026#34;/data/c/file4.dat\u0026#34; )) ); ParallelFileSyncSystem syncSystem = new ParallelFileSyncSystem( 8, // 최대 스레드 수 3 // 동시 업로드 수 (대역폭 제한) ); long startTime = System.currentTimeMillis(); syncSystem.syncPartitions(partitions); long endTime = System.currentTimeMillis(); System.out.println(\u0026#34;\\n총 소요 시간: \u0026#34; + (endTime - startTime) + \u0026#34;ms\u0026#34;); syncSystem.shutdown(); } } 핵심 포인트:\nThreadPoolExecutor: 스레드 수와 큐 크기 커스터마이징 Semaphore: 동시 업로드 수 제한으로 대역폭 관리 ConcurrentHashMap: 파티션별 상태를 안전하게 관리 AtomicInteger: 전체 처리 파일 수 카운팅 CompletableFuture: 파티션별, 파일별 비동기 처리 및 조합 CallerRunsPolicy: 백프레셔로 메모리 보호 출력 예시:\n파티션 동기화 시작: partition-A 파티션 동기화 시작: partition-B [partition-A] 전송 시작: /data/a/file1.dat [pool-1-thread-1] [partition-B] 전송 시작: /data/b/file1.dat [pool-1-thread-2] [partition-A] 전송 완료: /data/a/file1.dat [partition-A] 전송 시작: /data/a/file2.dat [pool-1-thread-1] ... 파티션 완료: partition-B (2 files) 파티션 완료: partition-A (3 files) === 전체 동기화 완료 === 처리된 파일 수: 9 마무리 Java 동시성 프로그래밍의 핵심을 정리하면:\nThreadPool 사용: 직접 Thread 생성 대신 ExecutorService 사용 적절한 크기 설정: CPU 바운드는 코어 수 + 1, I/O 바운드는 코어 수 * 2~4 Context Switching 인식: 스레드를 무한정 늘리면 전환 비용이 실제 작업을 압도 LinkedBlockingQueue: 생산자-소비자 패턴의 핵심, 분리 락으로 동시 put/take 가능 동시성 컬렉션: HashMap 대신 ConcurrentHashMap Atomic 클래스: 간단한 카운터나 플래그는 AtomicInteger/AtomicBoolean Semaphore: 자원 접근 수 제한 CompletableFuture: 비동기 작업 체이닝과 조합 항상 종료 처리: shutdown() + awaitTermination() 동시성은 어렵지만, Java가 제공하는 도구를 잘 활용하면 안전하고 효율적인 멀티스레드 프로그램을 만들 수 있습니다. 작은 예제부터 시작해서 점진적으로 복잡한 시스템으로 확장해 보세요.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/java-concurrency/","summary":"\u003ch2 id=\"동시성-프로그래밍이란\"\u003e동시성 프로그래밍이란\u003c/h2\u003e\n\u003cp\u003e현대 애플리케이션은 여러 작업을 동시에 처리해야 합니다. 파일 업로드를 받으면서 DB 쿼리를 실행하고, API 요청을 처리하는 등 멀티태스킹은 필수입니다. Java는 강력한 동시성 라이브러리를 제공하여 이런 작업을 안전하고 효율적으로 처리할 수 있게 합니다.\u003c/p\u003e\n\u003cp\u003e이 글에서는 Thread 기본부터 ThreadPool, 동기화 메커니즘까지 실전 예제와 함께 알아봅니다.\u003c/p\u003e\n\u003ch2 id=\"thread와-runnable-기본\"\u003eThread와 Runnable 기본\u003c/h2\u003e\n\u003ch3 id=\"thread-생성-방법\"\u003eThread 생성 방법\u003c/h3\u003e\n\u003cp\u003eJava에서 스레드를 생성하는 두 가지 방법이 있습니다.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e1. Thread 클래스 상속\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#f9e2af\"\u003eMyThread\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eextends\u003c/span\u003e Thread {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#89b4fa;font-weight:bold\"\u003e@Override\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003evoid\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003erun\u003c/span\u003e() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        System.\u003cspan style=\"color:#89b4fa\"\u003eout\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003eprintln\u003c/span\u003e(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Thread 실행: \u0026#34;\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e Thread.\u003cspan style=\"color:#89b4fa\"\u003ecurrentThread\u003c/span\u003e().\u003cspan style=\"color:#89b4fa\"\u003egetName\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\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 사용\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMyThread thread \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#cba6f7\"\u003enew\u003c/span\u003e MyThread();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ethread.\u003cspan style=\"color:#89b4fa\"\u003estart\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e2. Runnable 인터페이스 구현 (권장)\u003c/strong\u003e\u003c/p\u003e","tags":["Java","동시성","멀티스레딩","ThreadPool"],"title":"Java 동시성 프로그래밍 - ExecutorService, 동기화, Context Switching"},{"content":"디자인 패턴이란 디자인 패턴은 소프트웨어 설계에서 반복적으로 나타나는 문제들에 대한 재사용 가능한 해결책입니다. \u0026quot;바퀴를 다시 발명하지 마라\u0026quot;는 원칙처럼, 검증된 설계 방법을 배우고 적용하면 유지보수가 쉽고 확장 가능한 코드를 작성할 수 있습니다.\n이 글에서는 실무에서 자주 쓰이는 핵심 패턴들을 Java 코드와 함께 알아봅니다.\n디자인 패턴의 분류 GoF(Gang of Four)는 23가지 패턴을 세 가지로 분류했습니다:\n생성 패턴 (Creational): 객체 생성 방식 - Singleton, Builder, Factory 구조 패턴 (Structural): 객체 조합 방식 - Facade, Adapter, Decorator 행위 패턴 (Behavioral): 객체 간 통신 방식 - Observer, Strategy, Template Method 모든 패턴을 외울 필요는 없습니다. 문제를 만났을 때 \u0026quot;이 상황에 맞는 패턴이 있나?\u0026quot;를 떠올릴 수 있으면 충분합니다.\n생성 패턴 Singleton - 단 하나의 인스턴스 애플리케이션에서 특정 클래스의 인스턴스가 딱 하나만 존재해야 할 때 사용합니다.\n사용 사례:\n설정 관리자 (ConfigManager) 로거 (Logger) DB 커넥션 풀 캐시 기본 구현 (멀티스레드 환경에서 안전하지 않음) public class ConfigManager { private static ConfigManager instance; private Map\u0026lt;String, String\u0026gt; config; // private 생성자 - 외부에서 new 불가 private ConfigManager() { config = new HashMap\u0026lt;\u0026gt;(); loadConfig(); } public static ConfigManager getInstance() { if (instance == null) { instance = new ConfigManager(); // 위험! 여러 스레드가 동시 접근하면 여러 인스턴스 생성 가능 } return instance; } private void loadConfig() { config.put(\u0026#34;db.url\u0026#34;, \u0026#34;jdbc:mysql://localhost:3306/mydb\u0026#34;); config.put(\u0026#34;max.connections\u0026#34;, \u0026#34;10\u0026#34;); } public String get(String key) { return config.get(key); } } Thread-Safe 구현 1: Eager Initialization public class ConfigManager { // 클래스 로딩 시점에 생성 (스레드 안전) private static final ConfigManager INSTANCE = new ConfigManager(); private Map\u0026lt;String, String\u0026gt; config; private ConfigManager() { config = new HashMap\u0026lt;\u0026gt;(); loadConfig(); } public static ConfigManager getInstance() { return INSTANCE; } private void loadConfig() { config.put(\u0026#34;db.url\u0026#34;, \u0026#34;jdbc:mysql://localhost:3306/mydb\u0026#34;); config.put(\u0026#34;max.connections\u0026#34;, \u0026#34;10\u0026#34;); } public String get(String key) { return config.get(key); } } 장점: 간단하고 스레드 안전 단점: 사용하지 않아도 무조건 생성됨\nThread-Safe 구현 2: Double-Checked Locking public class ConfigManager { private static volatile ConfigManager instance; private Map\u0026lt;String, String\u0026gt; config; private ConfigManager() { config = new HashMap\u0026lt;\u0026gt;(); loadConfig(); } public static ConfigManager getInstance() { if (instance == null) { // 첫 번째 체크 (락 없이) synchronized (ConfigManager.class) { // 락 획득 if (instance == null) { // 두 번째 체크 instance = new ConfigManager(); } } } return instance; } private void loadConfig() { config.put(\u0026#34;db.url\u0026#34;, \u0026#34;jdbc:mysql://localhost:3306/mydb\u0026#34;); config.put(\u0026#34;max.connections\u0026#34;, \u0026#34;10\u0026#34;); } public String get(String key) { return config.get(key); } } volatile 키워드는 CPU 캐시가 아닌 메인 메모리에서 읽도록 강제합니다.\nThread-Safe 구현 3: Holder 패턴 (권장) public class ConfigManager { private Map\u0026lt;String, String\u0026gt; config; private ConfigManager() { config = new HashMap\u0026lt;\u0026gt;(); loadConfig(); } // 내부 static 클래스는 getInstance() 호출 시점에 로딩 private static class Holder { private static final ConfigManager INSTANCE = new ConfigManager(); } public static ConfigManager getInstance() { return Holder.INSTANCE; } private void loadConfig() { config.put(\u0026#34;db.url\u0026#34;, \u0026#34;jdbc:mysql://localhost:3306/mydb\u0026#34;); config.put(\u0026#34;max.connections\u0026#34;, \u0026#34;10\u0026#34;); } public String get(String key) { return config.get(key); } } 장점: Lazy 로딩 + 스레드 안전 + 간결함 (JVM의 클래스 로딩 메커니즘 활용)\n사용 예시 public class Application { public static void main(String[] args) { ConfigManager config = ConfigManager.getInstance(); String dbUrl = config.get(\u0026#34;db.url\u0026#34;); System.out.println(\u0026#34;DB URL: \u0026#34; + dbUrl); // 같은 인스턴스임을 확인 ConfigManager config2 = ConfigManager.getInstance(); System.out.println(\u0026#34;같은 인스턴스? \u0026#34; + (config == config2)); // true } } Builder - 유연한 객체 생성 생성자 파라미터가 많거나 선택적 파라미터가 있을 때 사용합니다.\n문제 상황: 생성자 지옥 public class FileTransferConfig { private String sourceHost; private int sourcePort; private String destHost; private int destPort; private int maxRetries; private int timeout; private boolean compress; private boolean encrypt; // 생성자 1: 필수 파라미터만 public FileTransferConfig(String sourceHost, String destHost) { this(sourceHost, 22, destHost, 22, 3, 30000, false, false); } // 생성자 2: 포트 포함 public FileTransferConfig(String sourceHost, int sourcePort, String destHost, int destPort) { this(sourceHost, sourcePort, destHost, destPort, 3, 30000, false, false); } // 생성자 3: 모든 파라미터 (텔레스코핑 생성자 패턴) public FileTransferConfig(String sourceHost, int sourcePort, String destHost, int destPort, int maxRetries, int timeout, boolean compress, boolean encrypt) { this.sourceHost = sourceHost; this.sourcePort = sourcePort; this.destHost = destHost; this.destPort = destPort; this.maxRetries = maxRetries; this.timeout = timeout; this.compress = compress; this.encrypt = encrypt; } } // 사용: 가독성이 떨어짐 FileTransferConfig config = new FileTransferConfig( \u0026#34;source.com\u0026#34;, 22, \u0026#34;dest.com\u0026#34;, 22, 5, 60000, true, false ); // 무슨 의미인지 파악하기 어려움 Builder 패턴 적용 public class FileTransferConfig { private final String sourceHost; private final int sourcePort; private final String destHost; private final int destPort; private final int maxRetries; private final int timeout; private final boolean compress; private final boolean encrypt; // private 생성자 private FileTransferConfig(Builder builder) { this.sourceHost = builder.sourceHost; this.sourcePort = builder.sourcePort; this.destHost = builder.destHost; this.destPort = builder.destPort; this.maxRetries = builder.maxRetries; this.timeout = builder.timeout; this.compress = builder.compress; this.encrypt = builder.encrypt; } // Getter 메서드들 public String getSourceHost() { return sourceHost; } public int getSourcePort() { return sourcePort; } public String getDestHost() { return destHost; } public int getDestPort() { return destPort; } public int getMaxRetries() { return maxRetries; } public int getTimeout() { return timeout; } public boolean isCompress() { return compress; } public boolean isEncrypt() { return encrypt; } // Builder 내부 클래스 public static class Builder { // 필수 파라미터 private final String sourceHost; private final String destHost; // 선택적 파라미터 (기본값 설정) private int sourcePort = 22; private int destPort = 22; private int maxRetries = 3; private int timeout = 30000; private boolean compress = false; private boolean encrypt = false; public Builder(String sourceHost, String destHost) { this.sourceHost = sourceHost; this.destHost = destHost; } public Builder sourcePort(int port) { this.sourcePort = port; return this; } public Builder destPort(int port) { this.destPort = port; return this; } public Builder maxRetries(int retries) { this.maxRetries = retries; return this; } public Builder timeout(int timeout) { this.timeout = timeout; return this; } public Builder compress(boolean compress) { this.compress = compress; return this; } public Builder encrypt(boolean encrypt) { this.encrypt = encrypt; return this; } public FileTransferConfig build() { // 유효성 검증 if (maxRetries \u0026lt; 0) { throw new IllegalArgumentException(\u0026#34;maxRetries는 0 이상이어야 합니다\u0026#34;); } if (timeout \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;timeout은 0보다 커야 합니다\u0026#34;); } return new FileTransferConfig(this); } } } 사용 예시 public class BuilderExample { public static void main(String[] args) { // 필수 파라미터만 FileTransferConfig simple = new FileTransferConfig.Builder( \u0026#34;source.com\u0026#34;, \u0026#34;dest.com\u0026#34; ).build(); // 선택적 파라미터 체이닝 (읽기 쉬움!) FileTransferConfig advanced = new FileTransferConfig.Builder( \u0026#34;source.com\u0026#34;, \u0026#34;dest.com\u0026#34; ) .sourcePort(2222) .destPort(2222) .maxRetries(5) .timeout(60000) .compress(true) .encrypt(true) .build(); System.out.println(\u0026#34;Source: \u0026#34; + advanced.getSourceHost() + \u0026#34;:\u0026#34; + advanced.getSourcePort()); System.out.println(\u0026#34;Compress: \u0026#34; + advanced.isCompress()); } } 장점:\n가독성 향상 (메서드 이름으로 파라미터 의미 명확) 불변 객체 생성 가능 (모든 필드 final) build() 단계에서 유효성 검증 실제 사용 예: StringBuilder, Retrofit, OkHttp\nFactory Method - 객체 생성 위임 생성할 객체의 타입을 서브클래스가 결정하도록 위임합니다.\n사용 사례: 파티셔닝 전략 생성 // 추상 인터페이스 interface PartitionStrategy { List\u0026lt;String\u0026gt; partition(List\u0026lt;String\u0026gt; files); } // 구체적인 전략들 class SizeBasedPartition implements PartitionStrategy { @Override public List\u0026lt;String\u0026gt; partition(List\u0026lt;String\u0026gt; files) { System.out.println(\u0026#34;파일 크기 기반 파티셔닝\u0026#34;); // 파일 크기별로 그룹화 return files; } } class HashBasedPartition implements PartitionStrategy { @Override public List\u0026lt;String\u0026gt; partition(List\u0026lt;String\u0026gt; files) { System.out.println(\u0026#34;해시 기반 파티셔닝\u0026#34;); // 파일명 해시값으로 그룹화 return files; } } class TimeBasedPartition implements PartitionStrategy { @Override public List\u0026lt;String\u0026gt; partition(List\u0026lt;String\u0026gt; files) { System.out.println(\u0026#34;시간 기반 파티셔닝\u0026#34;); // 수정 시간별로 그룹화 return files; } } // Factory class PartitionStrategyFactory { public static PartitionStrategy createStrategy(String type) { switch (type.toLowerCase()) { case \u0026#34;size\u0026#34;: return new SizeBasedPartition(); case \u0026#34;hash\u0026#34;: return new HashBasedPartition(); case \u0026#34;time\u0026#34;: return new TimeBasedPartition(); default: throw new IllegalArgumentException(\u0026#34;알 수 없는 타입: \u0026#34; + type); } } } // 사용 public class FactoryExample { public static void main(String[] args) { List\u0026lt;String\u0026gt; files = Arrays.asList(\u0026#34;a.txt\u0026#34;, \u0026#34;b.txt\u0026#34;, \u0026#34;c.txt\u0026#34;); // 런타임에 전략 결정 String strategyType = \u0026#34;hash\u0026#34;; // 설정 파일이나 환경 변수에서 읽을 수 있음 PartitionStrategy strategy = PartitionStrategyFactory.createStrategy(strategyType); List\u0026lt;String\u0026gt; partitioned = strategy.partition(files); } } 장점:\n객체 생성 로직을 한 곳에 집중 새로운 타입 추가 시 Factory만 수정 클라이언트 코드는 구체 클래스를 몰라도 됨 구조 패턴 Facade - 복잡한 서브시스템 감싸기 여러 서브시스템을 간단한 인터페이스로 묶어줍니다.\n문제 상황: 복잡한 파일 전송 프로세스 // 클라이언트 코드가 여러 클래스와 직접 상호작용 public class ComplexClient { public void transferFiles() { // 1. 커넥션 설정 ConnectionManager connMgr = new ConnectionManager(); connMgr.setHost(\u0026#34;remote.com\u0026#34;); connMgr.setPort(22); connMgr.setCredentials(\u0026#34;user\u0026#34;, \u0026#34;pass\u0026#34;); connMgr.connect(); // 2. 파일 목록 조회 FileScanner scanner = new FileScanner(connMgr); List\u0026lt;String\u0026gt; files = scanner.scan(\u0026#34;/data\u0026#34;); // 3. 파티셔닝 PartitionStrategy strategy = new HashBasedPartition(); List\u0026lt;String\u0026gt; partitioned = strategy.partition(files); // 4. 전송 FileTransferEngine engine = new FileTransferEngine(connMgr); for (String file : partitioned) { engine.transfer(file); } // 5. 검증 FileValidator validator = new FileValidator(connMgr); validator.validate(partitioned); // 6. 커넥션 종료 connMgr.disconnect(); } } 너무 복잡합니다. 클라이언트가 모든 세부사항을 알아야 합니다.\nFacade 적용 // 참고: ConnectionManager, FileScanner 등은 예시를 위한 가상 클래스입니다 // Facade 클래스 public class FileSyncFacade { private ConnectionManager connMgr; private FileScanner scanner; private PartitionStrategy partitioner; private FileTransferEngine engine; private FileValidator validator; public FileSyncFacade(String host, int port, String user, String password) { this.connMgr = new ConnectionManager(); connMgr.setHost(host); connMgr.setPort(port); connMgr.setCredentials(user, password); this.scanner = new FileScanner(connMgr); this.partitioner = new HashBasedPartition(); this.engine = new FileTransferEngine(connMgr); this.validator = new FileValidator(connMgr); } // 간단한 인터페이스 제공 public void syncDirectory(String sourcePath) { try { // 내부에서 복잡한 과정 처리 connMgr.connect(); List\u0026lt;String\u0026gt; files = scanner.scan(sourcePath); List\u0026lt;String\u0026gt; partitioned = partitioner.partition(files); for (String file : partitioned) { engine.transfer(file); } validator.validate(partitioned); System.out.println(\u0026#34;동기화 완료: \u0026#34; + partitioned.size() + \u0026#34; 파일\u0026#34;); } catch (Exception e) { System.err.println(\u0026#34;동기화 실패: \u0026#34; + e.getMessage()); } finally { connMgr.disconnect(); } } // 추가 편의 메서드 public void syncDirectoryWithStrategy(String sourcePath, String strategyType) { this.partitioner = PartitionStrategyFactory.createStrategy(strategyType); syncDirectory(sourcePath); } } // 클라이언트 코드 - 매우 간단해짐 public class SimpleClient { public static void main(String[] args) { FileSyncFacade facade = new FileSyncFacade( \u0026#34;remote.com\u0026#34;, 22, \u0026#34;user\u0026#34;, \u0026#34;pass\u0026#34; ); facade.syncDirectory(\u0026#34;/data\u0026#34;); } } 장점:\n복잡한 로직을 숨기고 간단한 API 제공 서브시스템 변경이 클라이언트에 영향 없음 코드 중복 감소 실제 사용 예: Spring의 JdbcTemplate, SLF4J 로깅 Facade\n행위 패턴 Observer - 이벤트 기반 통신 한 객체의 상태 변화를 여러 객체에게 자동으로 알립니다.\n참고: Spring Boot에서는 ApplicationEvent와 @EventListener로 Observer 패턴을 구현합니다. 자세한 내용은 Spring Boot 이벤트 기반 아키텍처 포스트를 참고하세요.\n사용 사례: 파일 전송 진행률 모니터링 import java.util.ArrayList; import java.util.List; // Observer 인터페이스 interface TransferProgressListener { void onProgress(String fileName, int percentage); void onComplete(String fileName); void onError(String fileName, String error); } // Subject (Observable) class FileTransferService { private List\u0026lt;TransferProgressListener\u0026gt; listeners = new ArrayList\u0026lt;\u0026gt;(); // Observer 등록 public void addListener(TransferProgressListener listener) { listeners.add(listener); } // Observer 제거 public void removeListener(TransferProgressListener listener) { listeners.remove(listener); } // 파일 전송 (Subject의 상태 변화) public void transferFile(String fileName) { System.out.println(\u0026#34;전송 시작: \u0026#34; + fileName); try { for (int progress = 0; progress \u0026lt;= 100; progress += 20) { Thread.sleep(300); // 모든 Observer에게 알림 notifyProgress(fileName, progress); } notifyComplete(fileName); } catch (InterruptedException e) { notifyError(fileName, e.getMessage()); } } private void notifyProgress(String fileName, int percentage) { for (TransferProgressListener listener : listeners) { listener.onProgress(fileName, percentage); } } private void notifyComplete(String fileName) { for (TransferProgressListener listener : listeners) { listener.onComplete(fileName); } } private void notifyError(String fileName, String error) { for (TransferProgressListener listener : listeners) { listener.onError(fileName, error); } } } // Concrete Observer 1: 콘솔 로거 class ConsoleLogger implements TransferProgressListener { @Override public void onProgress(String fileName, int percentage) { System.out.println(\u0026#34;[LOG] \u0026#34; + fileName + \u0026#34;: \u0026#34; + percentage + \u0026#34;%\u0026#34;); } @Override public void onComplete(String fileName) { System.out.println(\u0026#34;[LOG] 완료: \u0026#34; + fileName); } @Override public void onError(String fileName, String error) { System.err.println(\u0026#34;[LOG] 오류: \u0026#34; + fileName + \u0026#34; - \u0026#34; + error); } } // Concrete Observer 2: 통계 수집기 class StatisticsCollector implements TransferProgressListener { private int totalFiles = 0; private int completedFiles = 0; private int failedFiles = 0; @Override public void onProgress(String fileName, int percentage) { // 통계엔 진행률은 불필요 } @Override public void onComplete(String fileName) { completedFiles++; printStats(); } @Override public void onError(String fileName, String error) { failedFiles++; printStats(); } private void printStats() { System.out.println(\u0026#34;[STATS] 완료: \u0026#34; + completedFiles + \u0026#34;, 실패: \u0026#34; + failedFiles); } } // Concrete Observer 3: UI 업데이트 (시뮬레이션) class UIUpdater implements TransferProgressListener { @Override public void onProgress(String fileName, int percentage) { updateProgressBar(fileName, percentage); } @Override public void onComplete(String fileName) { showNotification(fileName + \u0026#34; 전송 완료\u0026#34;); } @Override public void onError(String fileName, String error) { showErrorDialog(fileName, error); } private void updateProgressBar(String fileName, int percentage) { System.out.println(\u0026#34;[UI] 프로그레스바 업데이트: \u0026#34; + fileName + \u0026#34; -\u0026gt; \u0026#34; + percentage + \u0026#34;%\u0026#34;); } private void showNotification(String message) { System.out.println(\u0026#34;[UI] 알림: \u0026#34; + message); } private void showErrorDialog(String fileName, String error) { System.out.println(\u0026#34;[UI] 오류 다이얼로그: \u0026#34; + fileName + \u0026#34; - \u0026#34; + error); } } // 사용 public class ObserverExample { public static void main(String[] args) { FileTransferService service = new FileTransferService(); // Observer 등록 service.addListener(new ConsoleLogger()); service.addListener(new StatisticsCollector()); service.addListener(new UIUpdater()); // 파일 전송 (모든 Observer가 자동으로 알림받음) service.transferFile(\u0026#34;file1.dat\u0026#34;); } } 출력 예시:\n전송 시작: file1.dat [LOG] file1.dat: 0% [UI] 프로그레스바 업데이트: file1.dat -\u0026gt; 0% [LOG] file1.dat: 20% [UI] 프로그레스바 업데이트: file1.dat -\u0026gt; 20% ... [LOG] 완료: file1.dat [STATS] 완료: 1, 실패: 0 [UI] 알림: file1.dat 전송 완료 장점:\nSubject와 Observer가 느슨하게 결합 새로운 Observer 추가가 쉬움 (OCP 준수) 런타임에 동적으로 구독/해지 가능 실제 사용 예: Java Swing의 ActionListener, RxJava Observable\nStrategy - 알고리즘 교체 알고리즘을 캡슐화하고 런타임에 교체 가능하게 합니다.\n사용 사례: 파일 압축 방식 선택 // Strategy 인터페이스 interface CompressionStrategy { byte[] compress(byte[] data); byte[] decompress(byte[] data); String getName(); } // Concrete Strategy 1: ZIP 압축 class ZipCompression implements CompressionStrategy { @Override public byte[] compress(byte[] data) { System.out.println(\u0026#34;ZIP 압축 수행\u0026#34;); // 실제로는 java.util.zip 사용 return data; // 시뮬레이션 } @Override public byte[] decompress(byte[] data) { System.out.println(\u0026#34;ZIP 압축 해제\u0026#34;); return data; } @Override public String getName() { return \u0026#34;ZIP\u0026#34;; } } // Concrete Strategy 2: GZIP 압축 class GzipCompression implements CompressionStrategy { @Override public byte[] compress(byte[] data) { System.out.println(\u0026#34;GZIP 압축 수행 (ZIP보다 빠름)\u0026#34;); return data; } @Override public byte[] decompress(byte[] data) { System.out.println(\u0026#34;GZIP 압축 해제\u0026#34;); return data; } @Override public String getName() { return \u0026#34;GZIP\u0026#34;; } } // Concrete Strategy 3: LZ4 압축 class Lz4Compression implements CompressionStrategy { @Override public byte[] compress(byte[] data) { System.out.println(\u0026#34;LZ4 압축 수행 (초고속, 낮은 압축률)\u0026#34;); return data; } @Override public byte[] decompress(byte[] data) { System.out.println(\u0026#34;LZ4 압축 해제\u0026#34;); return data; } @Override public String getName() { return \u0026#34;LZ4\u0026#34;; } } // Context (Strategy를 사용하는 클래스) class FileCompressor { private CompressionStrategy strategy; public FileCompressor(CompressionStrategy strategy) { this.strategy = strategy; } // 런타임에 전략 변경 가능 public void setStrategy(CompressionStrategy strategy) { this.strategy = strategy; } public void compressFile(String fileName, byte[] data) { System.out.println(\u0026#34;\\n파일 압축: \u0026#34; + fileName); System.out.println(\u0026#34;압축 방식: \u0026#34; + strategy.getName()); byte[] compressed = strategy.compress(data); System.out.println(\u0026#34;원본 크기: \u0026#34; + data.length + \u0026#34; bytes\u0026#34;); System.out.println(\u0026#34;압축 후: \u0026#34; + compressed.length + \u0026#34; bytes\u0026#34;); } public void decompressFile(String fileName, byte[] compressedData) { System.out.println(\u0026#34;\\n파일 압축 해제: \u0026#34; + fileName); byte[] decompressed = strategy.decompress(compressedData); System.out.println(\u0026#34;복원 완료\u0026#34;); } } // 사용 public class StrategyExample { public static void main(String[] args) { byte[] fileData = new byte[1024 * 100]; // 100KB // ZIP 전략 사용 FileCompressor compressor = new FileCompressor(new ZipCompression()); compressor.compressFile(\u0026#34;document.txt\u0026#34;, fileData); // 런타임에 전략 변경 compressor.setStrategy(new Lz4Compression()); compressor.compressFile(\u0026#34;video.mp4\u0026#34;, fileData); // 파일 타입에 따라 전략 선택 String fileName = \u0026#34;archive.tar\u0026#34;; CompressionStrategy strategy = selectStrategy(fileName); compressor.setStrategy(strategy); compressor.compressFile(fileName, fileData); } private static CompressionStrategy selectStrategy(String fileName) { if (fileName.endsWith(\u0026#34;.txt\u0026#34;) || fileName.endsWith(\u0026#34;.log\u0026#34;)) { return new GzipCompression(); // 텍스트는 GZIP } else if (fileName.endsWith(\u0026#34;.mp4\u0026#34;) || fileName.endsWith(\u0026#34;.jpg\u0026#34;)) { return new Lz4Compression(); // 이미 압축된 파일은 빠른 LZ4 } else { return new ZipCompression(); // 기본은 ZIP } } } 출력 예시:\n파일 압축: document.txt 압축 방식: ZIP ZIP 압축 수행 원본 크기: 102400 bytes 압축 후: 102400 bytes 파일 압축: video.mp4 압축 방식: LZ4 LZ4 압축 수행 (초고속, 낮은 압축률) 원본 크기: 102400 bytes 압축 후: 102400 bytes Factory와의 조합:\nclass CompressionStrategyFactory { public static CompressionStrategy create(String type) { switch (type.toLowerCase()) { case \u0026#34;zip\u0026#34;: return new ZipCompression(); case \u0026#34;gzip\u0026#34;: return new GzipCompression(); case \u0026#34;lz4\u0026#34;: return new Lz4Compression(); default: throw new IllegalArgumentException(\u0026#34;Unknown type: \u0026#34; + type); } } } // 사용 String compressionType = System.getProperty(\u0026#34;compression.type\u0026#34;, \u0026#34;gzip\u0026#34;); CompressionStrategy strategy = CompressionStrategyFactory.create(compressionType); FileCompressor compressor = new FileCompressor(strategy); 장점:\n알고리즘 변경이 쉬움 (OCP 준수) 조건문(if-else) 제거 새로운 알고리즘 추가 시 기존 코드 수정 불필요 Template Method - 알고리즘 골격 정의 알고리즘의 구조는 고정하고, 세부 단계는 서브클래스에서 구현합니다.\n사용 사례: 데이터 처리 파이프라인 // 추상 클래스 - 템플릿 메서드 정의 abstract class DataProcessor { // 템플릿 메서드 (final로 오버라이드 방지) public final void process(String inputFile) { System.out.println(\u0026#34;=== 데이터 처리 시작 ===\\n\u0026#34;); byte[] data = readData(inputFile); if (validate(data)) { byte[] transformed = transform(data); byte[] enriched = enrich(transformed); writeData(enriched); // Hook 메서드 (선택적 구현) afterProcessing(); } else { System.err.println(\u0026#34;검증 실패: \u0026#34; + inputFile); } System.out.println(\u0026#34;\\n=== 데이터 처리 완료 ===\u0026#34;); } // 추상 메서드 (서브클래스에서 반드시 구현) protected abstract byte[] readData(String inputFile); protected abstract boolean validate(byte[] data); protected abstract byte[] transform(byte[] data); protected abstract void writeData(byte[] data); // 구체 메서드 (공통 로직) protected byte[] enrich(byte[] data) { System.out.println(\u0026#34;데이터 보강 (타임스탬프 추가)\u0026#34;); return data; } // Hook 메서드 (선택적 오버라이드) protected void afterProcessing() { // 기본 구현 없음 (서브클래스에서 필요시 구현) } } // Concrete Class 1: CSV 처리 class CsvDataProcessor extends DataProcessor { @Override protected byte[] readData(String inputFile) { System.out.println(\u0026#34;CSV 파일 읽기: \u0026#34; + inputFile); return new byte[100]; // 시뮬레이션 } @Override protected boolean validate(byte[] data) { System.out.println(\u0026#34;CSV 형식 검증 (헤더 확인)\u0026#34;); return true; } @Override protected byte[] transform(byte[] data) { System.out.println(\u0026#34;CSV 변환 (컬럼 재정렬, 타입 변환)\u0026#34;); return data; } @Override protected void writeData(byte[] data) { System.out.println(\u0026#34;CSV 파일 저장\u0026#34;); } @Override protected void afterProcessing() { System.out.println(\u0026#34;CSV 통계 생성\u0026#34;); } } // Concrete Class 2: JSON 처리 class JsonDataProcessor extends DataProcessor { @Override protected byte[] readData(String inputFile) { System.out.println(\u0026#34;JSON 파일 읽기: \u0026#34; + inputFile); return new byte[100]; } @Override protected boolean validate(byte[] data) { System.out.println(\u0026#34;JSON 스키마 검증\u0026#34;); return true; } @Override protected byte[] transform(byte[] data) { System.out.println(\u0026#34;JSON 변환 (필드 매핑)\u0026#34;); return data; } @Override protected void writeData(byte[] data) { System.out.println(\u0026#34;JSON 파일 저장 (포맷팅)\u0026#34;); } // afterProcessing은 구현하지 않음 (Hook은 선택적) } // Concrete Class 3: XML 처리 class XmlDataProcessor extends DataProcessor { @Override protected byte[] readData(String inputFile) { System.out.println(\u0026#34;XML 파일 읽기: \u0026#34; + inputFile); return new byte[100]; } @Override protected boolean validate(byte[] data) { System.out.println(\u0026#34;XML 스키마 검증 (XSD)\u0026#34;); return true; } @Override protected byte[] transform(byte[] data) { System.out.println(\u0026#34;XML 변환 (XSLT)\u0026#34;); return data; } @Override protected void writeData(byte[] data) { System.out.println(\u0026#34;XML 파일 저장\u0026#34;); } } // 사용 public class TemplateMethodExample { public static void main(String[] args) { DataProcessor csvProcessor = new CsvDataProcessor(); csvProcessor.process(\u0026#34;data.csv\u0026#34;); System.out.println(\u0026#34;\\n\u0026#34; + \u0026#34;=\u0026#34;.repeat(40) + \u0026#34;\\n\u0026#34;); DataProcessor jsonProcessor = new JsonDataProcessor(); jsonProcessor.process(\u0026#34;data.json\u0026#34;); } } 출력 예시:\n=== 데이터 처리 시작 === CSV 파일 읽기: data.csv CSV 형식 검증 (헤더 확인) CSV 변환 (컬럼 재정렬, 타입 변환) 데이터 보강 (타임스탬프 추가) CSV 파일 저장 CSV 통계 생성 === 데이터 처리 완료 === 장점:\n알고리즘 구조를 재사용 코드 중복 제거 (공통 로직은 부모 클래스에) Hollywood Principle: \u0026quot;Don't call us, we'll call you\u0026quot; (프레임워크가 흐름 제어) 실제 사용 예: Spring의 JdbcTemplate, Servlet의 HttpServlet\n실전 활용: 패턴 조합 예시 여러 패턴을 조합하여 시스템을 설계하는 방법을 살펴봅시다. 아래 예시는 간단한 작업 스케줄러를 구현합니다.\n참고: 동시성 처리(ExecutorService, CompletableFuture 등)를 포함한 실전 파일 동기화 시스템은 Java 동시성 프로그래밍 포스트를 참고하세요.\nimport java.util.*; // 1. Singleton: ConfigManager class AppConfigManager { private static class Holder { private static final AppConfigManager INSTANCE = new AppConfigManager(); } private Map\u0026lt;String, String\u0026gt; config = new HashMap\u0026lt;\u0026gt;(); private AppConfigManager() { config.put(\u0026#34;retry.count\u0026#34;, \u0026#34;3\u0026#34;); config.put(\u0026#34;timeout.seconds\u0026#34;, \u0026#34;30\u0026#34;); } public static AppConfigManager getInstance() { return Holder.INSTANCE; } public String get(String key) { return config.get(key); } } // 2. Strategy: 작업 실행 전략 interface ExecutionStrategy { void execute(Task task); } class ImmediateExecution implements ExecutionStrategy { @Override public void execute(Task task) { System.out.println(\u0026#34;즉시 실행: \u0026#34; + task.getName()); task.run(); } } class DelayedExecution implements ExecutionStrategy { @Override public void execute(Task task) { System.out.println(\u0026#34;지연 실행 예약: \u0026#34; + task.getName()); // 실제로는 스케줄러에 등록 } } // 3. Observer: 작업 상태 리스너 interface TaskListener { void onStart(Task task); void onComplete(Task task); } class ConsoleLogger implements TaskListener { @Override public void onStart(Task task) { System.out.println(\u0026#34;[시작] \u0026#34; + task.getName()); } @Override public void onComplete(Task task) { System.out.println(\u0026#34;[완료] \u0026#34; + task.getName()); } } // 4. Builder: 복잡한 작업 설정 class Task { private final String name; private final int priority; private final int retryCount; private final List\u0026lt;TaskListener\u0026gt; listeners; private Task(Builder builder) { this.name = builder.name; this.priority = builder.priority; this.retryCount = builder.retryCount; this.listeners = builder.listeners; } public String getName() { return name; } public int getPriority() { return priority; } public void run() { listeners.forEach(l -\u0026gt; l.onStart(this)); // 작업 실행 System.out.println(\u0026#34; 작업 수행 중...\u0026#34;); listeners.forEach(l -\u0026gt; l.onComplete(this)); } public static class Builder { private final String name; private int priority = 0; private int retryCount = 3; private List\u0026lt;TaskListener\u0026gt; listeners = new ArrayList\u0026lt;\u0026gt;(); public Builder(String name) { this.name = name; } public Builder priority(int priority) { this.priority = priority; return this; } public Builder retryCount(int count) { this.retryCount = count; return this; } public Builder addListener(TaskListener listener) { this.listeners.add(listener); return this; } public Task build() { return new Task(this); } } } // 5. Facade: 전체 시스템 통합 class TaskScheduler { private ExecutionStrategy strategy; private AppConfigManager config; public TaskScheduler(ExecutionStrategy strategy) { this.strategy = strategy; this.config = AppConfigManager.getInstance(); } public void scheduleTask(Task task) { System.out.println(\u0026#34;=== 작업 스케줄링 ===\u0026#34;); System.out.println(\u0026#34;작업명: \u0026#34; + task.getName()); System.out.println(\u0026#34;우선순위: \u0026#34; + task.getPriority()); // 전략 패턴으로 실행 방식 결정 strategy.execute(task); } public void setStrategy(ExecutionStrategy strategy) { this.strategy = strategy; } } // 실행 public class TaskSchedulerExample { public static void main(String[] args) { // Builder로 작업 생성 Task task = new Task.Builder(\u0026#34;데이터 백업\u0026#34;) .priority(5) .retryCount(3) .addListener(new ConsoleLogger()) .build(); // Facade로 스케줄링 TaskScheduler scheduler = new TaskScheduler(new ImmediateExecution()); scheduler.scheduleTask(task); // 전략 변경 scheduler.setStrategy(new DelayedExecution()); scheduler.scheduleTask(task); } } 패턴 조합 정리:\n패턴 역할 위치 Singleton 설정 관리 (전역 접근) AppConfigManager Strategy 실행 방식 교체 ExecutionStrategy Observer 작업 상태 알림 TaskListener Builder 복잡한 작업 설정 Task.Builder Facade 전체 시스템 통합 TaskScheduler 마무리 디자인 패턴은 도구입니다. 모든 상황에 패턴을 적용하려 하지 마세요. 오히려 과도한 추상화로 코드가 복잡해질 수 있습니다.\n패턴 선택 가이드:\n전역 인스턴스 필요? → Singleton 생성자 파라미터 많음? → Builder 런타임에 알고리즘 교체? → Strategy 복잡한 서브시스템 감추기? → Facade 상태 변화를 여러 곳에 알림? → Observer 알고리즘 구조는 같고 세부 단계만 다름? → Template Method 패턴은 암기가 아니라 문제 해결 도구입니다. 코드 리뷰나 리팩토링 과정에서 \u0026quot;이 부분에 패턴을 적용하면 더 나아질까?\u0026quot;를 질문하며 자연스럽게 익혀보세요.\n실무에서는 Spring Framework, JPA 등이 이미 많은 패턴을 사용하고 있습니다. 프레임워크 코드를 읽으며 패턴을 발견하는 연습도 큰 도움이 됩니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/java-design-patterns/","summary":"\u003ch2 id=\"디자인-패턴이란\"\u003e디자인 패턴이란\u003c/h2\u003e\n\u003cp\u003e디자인 패턴은 소프트웨어 설계에서 반복적으로 나타나는 문제들에 대한 재사용 가능한 해결책입니다. \u0026quot;바퀴를 다시 발명하지 마라\u0026quot;는 원칙처럼, 검증된 설계 방법을 배우고 적용하면 유지보수가 쉽고 확장 가능한 코드를 작성할 수 있습니다.\u003c/p\u003e\n\u003cp\u003e이 글에서는 실무에서 자주 쓰이는 핵심 패턴들을 Java 코드와 함께 알아봅니다.\u003c/p\u003e\n\u003ch2 id=\"디자인-패턴의-분류\"\u003e디자인 패턴의 분류\u003c/h2\u003e\n\u003cp\u003eGoF(Gang of Four)는 23가지 패턴을 세 가지로 분류했습니다:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e생성 패턴 (Creational)\u003c/strong\u003e: 객체 생성 방식 - Singleton, Builder, Factory\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e구조 패턴 (Structural)\u003c/strong\u003e: 객체 조합 방식 - Facade, Adapter, Decorator\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e행위 패턴 (Behavioral)\u003c/strong\u003e: 객체 간 통신 방식 - Observer, Strategy, Template Method\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e모든 패턴을 외울 필요는 없습니다. 문제를 만났을 때 \u0026quot;이 상황에 맞는 패턴이 있나?\u0026quot;를 떠올릴 수 있으면 충분합니다.\u003c/p\u003e","tags":["Java","디자인패턴","객체지향","설계"],"title":"Java 디자인 패턴 - Singleton, Builder, Observer, Strategy 구현"},{"content":"React Hooks란? Hooks는 함수형 컴포넌트에서 상태 관리, 사이드 이펙트 처리, 성능 최적화 등을 가능하게 하는 함수입니다. React 16.8에서 도입되어 현재 React 개발의 핵심이 되었습니다.\nHooks 규칙 최상위에서만 호출: 조건문, 반복문, 중첩 함수 내에서 호출 금지 React 함수 내에서만 호출: 일반 JavaScript 함수에서 호출 금지 // 잘못된 사용 function BadExample() { if (someCondition) { const [value, setValue] = useState(\u0026#34;\u0026#34;); // 조건문 안에서 호출 금지 } } // 올바른 사용 function GoodExample() { const [value, setValue] = useState(\u0026#34;\u0026#34;); if (someCondition) { // Hook이 아닌 로직은 조건문 안에서 사용 가능 } } useEffect - 사이드 이펙트 처리 기본 사용법 import { useState, useEffect } from \u0026#34;react\u0026#34;; function UserProfile({ userId }: { userId: number }) { const [user, setUser] = useState\u0026lt;User | null\u0026gt;(null); const [loading, setLoading] = useState(true); useEffect(() =\u0026gt; { // 사이드 이펙트 실행 setLoading(true); fetch(`/api/users/${userId}`) .then((res) =\u0026gt; res.json()) .then((data) =\u0026gt; { setUser(data); setLoading(false); }); }, [userId]); // userId가 변경될 때만 실행 if (loading) return \u0026lt;p\u0026gt;로딩 중...\u0026lt;/p\u0026gt;; if (!user) return \u0026lt;p\u0026gt;사용자를 찾을 수 없습니다.\u0026lt;/p\u0026gt;; return ( \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;{user.name}\u0026lt;/h2\u0026gt; \u0026lt;p\u0026gt;{user.email}\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; ); } 의존성 배열 패턴 function EffectPatterns() { const [count, setCount] = useState(0); // 1. 마운트 시 1회만 실행 useEffect(() =\u0026gt; { console.log(\u0026#34;컴포넌트 마운트\u0026#34;); }, []); // 2. 특정 값 변경 시 실행 useEffect(() =\u0026gt; { console.log(\u0026#34;count 변경:\u0026#34;, count); }, [count]); // 3. 매 렌더링마다 실행 (의존성 배열 생략) useEffect(() =\u0026gt; { console.log(\u0026#34;렌더링됨\u0026#34;); }); return \u0026lt;button onClick={() =\u0026gt; setCount(count + 1)}\u0026gt;{count}\u0026lt;/button\u0026gt;; } 클린업 함수 컴포넌트 언마운트 시 또는 이펙트 재실행 전에 정리 작업을 수행합니다.\nfunction Timer() { const [seconds, setSeconds] = useState(0); useEffect(() =\u0026gt; { const interval = setInterval(() =\u0026gt; { setSeconds((prev) =\u0026gt; prev + 1); }, 1000); // 클린업: 컴포넌트 언마운트 시 인터벌 정리 return () =\u0026gt; clearInterval(interval); }, []); return \u0026lt;p\u0026gt;경과 시간: {seconds}초\u0026lt;/p\u0026gt;; } function EventListener() { const [windowWidth, setWindowWidth] = useState(window.innerWidth); useEffect(() =\u0026gt; { const handleResize = () =\u0026gt; setWindowWidth(window.innerWidth); window.addEventListener(\u0026#34;resize\u0026#34;, handleResize); // 클린업: 이벤트 리스너 제거 return () =\u0026gt; window.removeEventListener(\u0026#34;resize\u0026#34;, handleResize); }, []); return \u0026lt;p\u0026gt;창 너비: {windowWidth}px\u0026lt;/p\u0026gt;; } useRef - DOM 접근과 값 유지 DOM 요소 접근 import { useRef, useEffect } from \u0026#34;react\u0026#34;; function AutoFocusInput() { const inputRef = useRef\u0026lt;HTMLInputElement\u0026gt;(null); useEffect(() =\u0026gt; { // 마운트 시 자동 포커스 inputRef.current?.focus(); }, []); return \u0026lt;input ref={inputRef} placeholder=\u0026#34;자동 포커스\u0026#34; /\u0026gt;; } 렌더링 없이 값 유지 useRef는 값이 변경되어도 리렌더링을 발생시키지 않습니다.\nfunction StopWatch() { const [time, setTime] = useState(0); const [isRunning, setIsRunning] = useState(false); const intervalRef = useRef\u0026lt;number | null\u0026gt;(null); const start = () =\u0026gt; { if (isRunning) return; setIsRunning(true); intervalRef.current = setInterval(() =\u0026gt; { setTime((prev) =\u0026gt; prev + 10); }, 10); }; const stop = () =\u0026gt; { if (intervalRef.current) { clearInterval(intervalRef.current); } setIsRunning(false); }; const reset = () =\u0026gt; { stop(); setTime(0); }; return ( \u0026lt;div\u0026gt; \u0026lt;p\u0026gt;{(time / 1000).toFixed(2)}초\u0026lt;/p\u0026gt; \u0026lt;button onClick={start}\u0026gt;시작\u0026lt;/button\u0026gt; \u0026lt;button onClick={stop}\u0026gt;정지\u0026lt;/button\u0026gt; \u0026lt;button onClick={reset}\u0026gt;초기화\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; ); } 이전 값 기억하기 function PreviousValue({ value }: { value: number }) { const prevRef = useRef\u0026lt;number\u0026gt;(value); useEffect(() =\u0026gt; { prevRef.current = value; }, [value]); return ( \u0026lt;p\u0026gt; 현재: {value}, 이전: {prevRef.current} \u0026lt;/p\u0026gt; ); } useMemo - 계산 결과 캐싱 비용이 큰 계산의 결과를 메모이제이션합니다.\nimport { useState, useMemo } from \u0026#34;react\u0026#34;; function ExpensiveList({ items, filter }: { items: Item[]; filter: string }) { // filter가 변경될 때만 재계산 const filteredItems = useMemo(() =\u0026gt; { console.log(\u0026#34;필터링 실행\u0026#34;); return items.filter((item) =\u0026gt; item.name.toLowerCase().includes(filter.toLowerCase()) ); }, [items, filter]); return ( \u0026lt;ul\u0026gt; {filteredItems.map((item) =\u0026gt; ( \u0026lt;li key={item.id}\u0026gt;{item.name}\u0026lt;/li\u0026gt; ))} \u0026lt;/ul\u0026gt; ); } 정렬과 통계 계산 function Dashboard({ data }: { data: number[] }) { const stats = useMemo(() =\u0026gt; { const sorted = [...data].sort((a, b) =\u0026gt; a - b); const sum = data.reduce((acc, val) =\u0026gt; acc + val, 0); const avg = sum / data.length; const min = sorted[0]; const max = sorted[sorted.length - 1]; return { sum, avg, min, max }; }, [data]); return ( \u0026lt;div\u0026gt; \u0026lt;p\u0026gt;합계: {stats.sum}\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;평균: {stats.avg.toFixed(2)}\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;최소: {stats.min} / 최대: {stats.max}\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; ); } useCallback - 함수 메모이제이션 함수의 참조를 유지하여 불필요한 자식 컴포넌트 리렌더링을 방지합니다.\nimport { useState, useCallback, memo } from \u0026#34;react\u0026#34;; // memo로 감싼 자식 컴포넌트 const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete, }: { todo: Todo; onToggle: (id: number) =\u0026gt; void; onDelete: (id: number) =\u0026gt; void; }) { console.log(`TodoItem 렌더링: ${todo.text}`); return ( \u0026lt;li\u0026gt; \u0026lt;span style={{ textDecoration: todo.completed ? \u0026#34;line-through\u0026#34; : \u0026#34;none\u0026#34; }} onClick={() =\u0026gt; onToggle(todo.id)} \u0026gt; {todo.text} \u0026lt;/span\u0026gt; \u0026lt;button onClick={() =\u0026gt; onDelete(todo.id)}\u0026gt;삭제\u0026lt;/button\u0026gt; \u0026lt;/li\u0026gt; ); }); function TodoList() { const [todos, setTodos] = useState\u0026lt;Todo[]\u0026gt;([]); const [input, setInput] = useState(\u0026#34;\u0026#34;); // useCallback으로 함수 참조 안정화 const handleToggle = useCallback((id: number) =\u0026gt; { setTodos((prev) =\u0026gt; prev.map((todo) =\u0026gt; todo.id === id ? { ...todo, completed: !todo.completed } : todo ) ); }, []); const handleDelete = useCallback((id: number) =\u0026gt; { setTodos((prev) =\u0026gt; prev.filter((todo) =\u0026gt; todo.id !== id)); }, []); const handleAdd = () =\u0026gt; { if (!input.trim()) return; setTodos((prev) =\u0026gt; [ ...prev, { id: Date.now(), text: input, completed: false }, ]); setInput(\u0026#34;\u0026#34;); }; return ( \u0026lt;div\u0026gt; \u0026lt;input value={input} onChange={(e) =\u0026gt; setInput(e.target.value)} onKeyDown={(e) =\u0026gt; e.key === \u0026#34;Enter\u0026#34; \u0026amp;\u0026amp; handleAdd()} /\u0026gt; \u0026lt;button onClick={handleAdd}\u0026gt;추가\u0026lt;/button\u0026gt; \u0026lt;ul\u0026gt; {todos.map((todo) =\u0026gt; ( \u0026lt;TodoItem key={todo.id} todo={todo} onToggle={handleToggle} onDelete={handleDelete} /\u0026gt; ))} \u0026lt;/ul\u0026gt; \u0026lt;/div\u0026gt; ); } useReducer - 복잡한 상태 관리 useState보다 복잡한 상태 로직을 체계적으로 관리합니다.\nimport { useReducer } from \u0026#34;react\u0026#34;; // 상태 타입 interface CartState { items: CartItem[]; total: number; } // 액션 타입 type CartAction = | { type: \u0026#34;ADD_ITEM\u0026#34;; payload: CartItem } | { type: \u0026#34;REMOVE_ITEM\u0026#34;; payload: number } | { type: \u0026#34;UPDATE_QUANTITY\u0026#34;; payload: { id: number; quantity: number } } | { type: \u0026#34;CLEAR_CART\u0026#34; }; // 리듀서 함수 function cartReducer(state: CartState, action: CartAction): CartState { switch (action.type) { case \u0026#34;ADD_ITEM\u0026#34;: { const existing = state.items.find((i) =\u0026gt; i.id === action.payload.id); if (existing) { const items = state.items.map((i) =\u0026gt; i.id === action.payload.id ? { ...i, quantity: i.quantity + 1 } : i ); return { items, total: calcTotal(items) }; } const items = [...state.items, { ...action.payload, quantity: 1 }]; return { items, total: calcTotal(items) }; } case \u0026#34;REMOVE_ITEM\u0026#34;: { const items = state.items.filter((i) =\u0026gt; i.id !== action.payload); return { items, total: calcTotal(items) }; } case \u0026#34;UPDATE_QUANTITY\u0026#34;: { const items = state.items.map((i) =\u0026gt; i.id === action.payload.id ? { ...i, quantity: action.payload.quantity } : i ); return { items, total: calcTotal(items) }; } case \u0026#34;CLEAR_CART\u0026#34;: return { items: [], total: 0 }; default: return state; } } function ShoppingCart() { const [cart, dispatch] = useReducer(cartReducer, { items: [], total: 0 }); return ( \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;장바구니 ({cart.items.length}개)\u0026lt;/h2\u0026gt; {cart.items.map((item) =\u0026gt; ( \u0026lt;div key={item.id}\u0026gt; \u0026lt;span\u0026gt;{item.name} x {item.quantity}\u0026lt;/span\u0026gt; \u0026lt;button onClick={() =\u0026gt; dispatch({ type: \u0026#34;REMOVE_ITEM\u0026#34;, payload: item.id })}\u0026gt; 삭제 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; ))} \u0026lt;p\u0026gt;총액: {cart.total.toLocaleString()}원\u0026lt;/p\u0026gt; \u0026lt;button onClick={() =\u0026gt; dispatch({ type: \u0026#34;CLEAR_CART\u0026#34; })}\u0026gt; 장바구니 비우기 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; ); } 커스텀 Hook 반복되는 로직을 재사용 가능한 Hook으로 추출합니다.\nuseLocalStorage function useLocalStorage\u0026lt;T\u0026gt;(key: string, initialValue: T) { const [storedValue, setStoredValue] = useState\u0026lt;T\u0026gt;(() =\u0026gt; { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch { return initialValue; } }); const setValue = (value: T | ((val: T) =\u0026gt; T)) =\u0026gt; { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); window.localStorage.setItem(key, JSON.stringify(valueToStore)); }; return [storedValue, setValue] as const; } // 사용 function Settings() { const [theme, setTheme] = useLocalStorage(\u0026#34;theme\u0026#34;, \u0026#34;light\u0026#34;); const [fontSize, setFontSize] = useLocalStorage(\u0026#34;fontSize\u0026#34;, 16); return ( \u0026lt;div\u0026gt; \u0026lt;select value={theme} onChange={(e) =\u0026gt; setTheme(e.target.value)}\u0026gt; \u0026lt;option value=\u0026#34;light\u0026#34;\u0026gt;라이트\u0026lt;/option\u0026gt; \u0026lt;option value=\u0026#34;dark\u0026#34;\u0026gt;다크\u0026lt;/option\u0026gt; \u0026lt;/select\u0026gt; \u0026lt;input type=\u0026#34;range\u0026#34; min={12} max={24} value={fontSize} onChange={(e) =\u0026gt; setFontSize(Number(e.target.value))} /\u0026gt; \u0026lt;/div\u0026gt; ); } useFetch interface FetchState\u0026lt;T\u0026gt; { data: T | null; loading: boolean; error: string | null; } function useFetch\u0026lt;T\u0026gt;(url: string): FetchState\u0026lt;T\u0026gt; { const [state, setState] = useState\u0026lt;FetchState\u0026lt;T\u0026gt;\u0026gt;({ data: null, loading: true, error: null, }); useEffect(() =\u0026gt; { const controller = new AbortController(); setState({ data: null, loading: true, error: null }); fetch(url, { signal: controller.signal }) .then((res) =\u0026gt; { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) =\u0026gt; setState({ data, loading: false, error: null })) .catch((err) =\u0026gt; { if (err.name !== \u0026#34;AbortError\u0026#34;) { setState({ data: null, loading: false, error: err.message }); } }); return () =\u0026gt; controller.abort(); }, [url]); return state; } // 사용 function UserList() { const { data: users, loading, error } = useFetch\u0026lt;User[]\u0026gt;(\u0026#34;/api/users\u0026#34;); if (loading) return \u0026lt;p\u0026gt;로딩 중...\u0026lt;/p\u0026gt;; if (error) return \u0026lt;p\u0026gt;에러: {error}\u0026lt;/p\u0026gt;; return ( \u0026lt;ul\u0026gt; {users?.map((user) =\u0026gt; ( \u0026lt;li key={user.id}\u0026gt;{user.name}\u0026lt;/li\u0026gt; ))} \u0026lt;/ul\u0026gt; ); } useDebounce function useDebounce\u0026lt;T\u0026gt;(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() =\u0026gt; { const timer = setTimeout(() =\u0026gt; setDebouncedValue(value), delay); return () =\u0026gt; clearTimeout(timer); }, [value, delay]); return debouncedValue; } // 사용 - 검색 입력 디바운싱 function SearchInput() { const [query, setQuery] = useState(\u0026#34;\u0026#34;); const debouncedQuery = useDebounce(query, 300); useEffect(() =\u0026gt; { if (debouncedQuery) { // 300ms 후에 API 호출 console.log(\u0026#34;검색:\u0026#34;, debouncedQuery); } }, [debouncedQuery]); return ( \u0026lt;input value={query} onChange={(e) =\u0026gt; setQuery(e.target.value)} placeholder=\u0026#34;검색어 입력...\u0026#34; /\u0026gt; ); } Context API - 전역 상태 관리 Props drilling 없이 컴포넌트 트리 전체에서 데이터를 공유합니다.\nimport { createContext, useContext, useState } from \u0026#34;react\u0026#34;; // Context 생성 interface AuthContext { user: User | null; login: (email: string, password: string) =\u0026gt; Promise\u0026lt;void\u0026gt;; logout: () =\u0026gt; void; } const AuthContext = createContext\u0026lt;AuthContext | null\u0026gt;(null); // Provider 컴포넌트 function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState\u0026lt;User | null\u0026gt;(null); const login = async (email: string, password: string) =\u0026gt; { const res = await fetch(\u0026#34;/api/login\u0026#34;, { method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; }, body: JSON.stringify({ email, password }), }); const data = await res.json(); setUser(data.user); }; const logout = () =\u0026gt; setUser(null); return ( \u0026lt;AuthContext.Provider value={{ user, login, logout }}\u0026gt; {children} \u0026lt;/AuthContext.Provider\u0026gt; ); } // 커스텀 Hook으로 사용 편의성 향상 function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error(\u0026#34;useAuth는 AuthProvider 내에서 사용해야 합니다\u0026#34;); } return context; } // 사용 function Navbar() { const { user, logout } = useAuth(); return ( \u0026lt;nav\u0026gt; {user ? ( \u0026lt;\u0026gt; \u0026lt;span\u0026gt;{user.name}님 환영합니다\u0026lt;/span\u0026gt; \u0026lt;button onClick={logout}\u0026gt;로그아웃\u0026lt;/button\u0026gt; \u0026lt;/\u0026gt; ) : ( \u0026lt;a href=\u0026#34;/login\u0026#34;\u0026gt;로그인\u0026lt;/a\u0026gt; )} \u0026lt;/nav\u0026gt; ); } // App에서 Provider로 감싸기 function App() { return ( \u0026lt;AuthProvider\u0026gt; \u0026lt;Navbar /\u0026gt; \u0026lt;main\u0026gt;{/* ... */}\u0026lt;/main\u0026gt; \u0026lt;/AuthProvider\u0026gt; ); } 마무리 React Hooks를 정리하면:\nHook 용도 useState 컴포넌트 상태 관리 useEffect 사이드 이펙트 (API 호출, 이벤트 리스너) useRef DOM 접근, 렌더링 없이 값 유지 useMemo 비용 큰 계산 결과 캐싱 useCallback 함수 참조 안정화 useReducer 복잡한 상태 로직 관리 useContext 전역 상태 공유 커스텀 Hook 재사용 가능한 로직 추출 Hooks를 활용하면 클래스 컴포넌트 없이도 React의 모든 기능을 효과적으로 사용할 수 있으며, 로직의 재사용성과 코드의 가독성을 크게 향상시킬 수 있습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/react-hooks/","summary":"\u003ch2 id=\"react-hooks란\"\u003eReact Hooks란?\u003c/h2\u003e\n\u003cp\u003eHooks는 함수형 컴포넌트에서 상태 관리, 사이드 이펙트 처리, 성능 최적화 등을 가능하게 하는 함수입니다. React 16.8에서 도입되어 현재 React 개발의 핵심이 되었습니다.\u003c/p\u003e\n\u003ch3 id=\"hooks-규칙\"\u003eHooks 규칙\u003c/h3\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e최상위에서만 호출\u003c/strong\u003e: 조건문, 반복문, 중첩 함수 내에서 호출 금지\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eReact 함수 내에서만 호출\u003c/strong\u003e: 일반 JavaScript 함수에서 호출 금지\u003c/li\u003e\n\u003c/ol\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-tsx\" data-lang=\"tsx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 잘못된 사용\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f38ba8\"\u003efunction\u003c/span\u003e BadExample() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003eif\u003c/span\u003e (someCondition) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#cba6f7\"\u003econst\u003c/span\u003e [value, setValue] \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e useState(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;\u0026#34;\u003c/span\u003e); \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 조건문 안에서 호출 금지\n\u003c/span\u003e\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:#6c7086;font-style:italic\"\u003e// 올바른 사용\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f38ba8\"\u003efunction\u003c/span\u003e GoodExample() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003econst\u003c/span\u003e [value, setValue] \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e useState(\u003cspan style=\"color:#a6e3a1\"\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  \u003cspan style=\"color:#cba6f7\"\u003eif\u003c/span\u003e (someCondition) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// Hook이 아닌 로직은 조건문 안에서 사용 가능\n\u003c/span\u003e\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=\"useeffect---사이드-이펙트-처리\"\u003euseEffect - 사이드 이펙트 처리\u003c/h2\u003e\n\u003ch3 id=\"기본-사용법\"\u003e기본 사용법\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-tsx\" data-lang=\"tsx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#cba6f7\"\u003eimport\u003c/span\u003e { useState, useEffect } \u003cspan style=\"color:#cba6f7\"\u003efrom\u003c/span\u003e \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;react\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:#f38ba8\"\u003efunction\u003c/span\u003e UserProfile({ userId }\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e:\u003c/span\u003e { userId: \u003cspan style=\"color:#f38ba8\"\u003enumber\u003c/span\u003e }) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003econst\u003c/span\u003e [user, setUser] \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e useState\u0026lt;\u003cspan style=\"color:#cba6f7\"\u003eUser\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003enull\u003c/span\u003e\u0026gt;(\u003cspan style=\"color:#fab387\"\u003enull\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003econst\u003c/span\u003e [loading, setLoading] \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e useState(\u003cspan style=\"color:#fab387\"\u003etrue\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  useEffect(() \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 사이드 이펙트 실행\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    setLoading(\u003cspan style=\"color:#fab387\"\u003etrue\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    fetch(\u003cspan style=\"color:#a6e3a1\"\u003e`/api/users/\u003c/span\u003e\u003cspan style=\"color:#a6e3a1\"\u003e${\u003c/span\u003euserId\u003cspan style=\"color:#a6e3a1\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#a6e3a1\"\u003e`\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      .then((res) \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u0026gt;\u003c/span\u003e res.json())\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      .then((data) \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        setUser(data);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        setLoading(\u003cspan style=\"color:#fab387\"\u003efalse\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  }, [userId]); \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// userId가 변경될 때만 실행\n\u003c/span\u003e\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:#cba6f7\"\u003eif\u003c/span\u003e (loading) \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003ep\u003c/span\u003e\u0026gt;\u003cspan style=\"color:#f38ba8\"\u003e로딩\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003e중\u003c/span\u003e...\u0026lt;/\u003cspan style=\"color:#cba6f7\"\u003ep\u003c/span\u003e\u0026gt;;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e!\u003c/span\u003euser) \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003ep\u003c/span\u003e\u0026gt;\u003cspan style=\"color:#f38ba8\"\u003e사용자를\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003e찾을\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003e수\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003e없습니다\u003c/span\u003e.\u0026lt;/\u003cspan style=\"color:#cba6f7\"\u003ep\u003c/span\u003e\u0026gt;;\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:#cba6f7\"\u003ereturn\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003ediv\u003c/span\u003e\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003eh2\u003c/span\u003e\u0026gt;{user.name}\u0026lt;/\u003cspan style=\"color:#cba6f7\"\u003eh2\u003c/span\u003e\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003ep\u003c/span\u003e\u0026gt;{user.email}\u0026lt;/\u003cspan style=\"color:#cba6f7\"\u003ep\u003c/span\u003e\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u0026lt;/\u003cspan style=\"color:#cba6f7\"\u003ediv\u003c/span\u003e\u0026gt;\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:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-tsx\" data-lang=\"tsx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f38ba8\"\u003efunction\u003c/span\u003e EffectPatterns() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#cba6f7\"\u003econst\u003c/span\u003e [count, setCount] \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e useState(\u003cspan style=\"color:#fab387\"\u003e0\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:#6c7086;font-style:italic\"\u003e// 1. 마운트 시 1회만 실행\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  useEffect(() \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    console.log(\u003cspan style=\"color:#a6e3a1\"\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:#6c7086;font-style:italic\"\u003e// 2. 특정 값 변경 시 실행\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  useEffect(() \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    console.log(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;count 변경:\u0026#34;\u003c/span\u003e, count);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }, [count]);\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:#6c7086;font-style:italic\"\u003e// 3. 매 렌더링마다 실행 (의존성 배열 생략)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  useEffect(() \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    console.log(\u003cspan style=\"color:#a6e3a1\"\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:#cba6f7\"\u003ereturn\u003c/span\u003e \u0026lt;\u003cspan style=\"color:#cba6f7\"\u003ebutton\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003eonClick\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e{() \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u0026gt;\u003c/span\u003e setCount(count \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e \u003cspan style=\"color:#fab387\"\u003e1\u003c/span\u003e)}\u0026gt;{count}\u0026lt;/\u003cspan style=\"color:#cba6f7\"\u003ebutton\u003c/span\u003e\u0026gt;;\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\u003cp\u003e컴포넌트 언마운트 시 또는 이펙트 재실행 전에 정리 작업을 수행합니다.\u003c/p\u003e","tags":["React","Hooks","프론트엔드","TypeScript"],"title":"React Hooks 심화 - useEffect, useMemo, useReducer, 커스텀 Hook"},{"content":"Stream API란? Java 8에서 도입된 Stream API는 컬렉션 데이터를 함수형 스타일로 처리할 수 있게 해주는 강력한 도구입니다. Stream을 사용하면 데이터를 선언적으로 처리하고, 병렬 처리를 쉽게 구현할 수 있으며, 코드의 가독성을 크게 향상시킬 수 있습니다.\nStream은 데이터의 흐름을 나타내며, 원본 데이터를 변경하지 않고 중간 연산과 최종 연산을 통해 데이터를 처리합니다. 이러한 특성 덕분에 불변성을 유지하면서도 효율적인 데이터 처리가 가능합니다.\nStream 생성 방법 Stream을 생성하는 다양한 방법이 있습니다.\n컬렉션으로부터 생성 List\u0026lt;String\u0026gt; list = Arrays.asList(\u0026#34;Apple\u0026#34;, \u0026#34;Banana\u0026#34;, \u0026#34;Cherry\u0026#34;); Stream\u0026lt;String\u0026gt; stream = list.stream(); // 병렬 스트림 Stream\u0026lt;String\u0026gt; parallelStream = list.parallelStream(); 배열로부터 생성 String[] array = {\u0026#34;Apple\u0026#34;, \u0026#34;Banana\u0026#34;, \u0026#34;Cherry\u0026#34;}; Stream\u0026lt;String\u0026gt; stream = Arrays.stream(array); // 범위 지정 int[] numbers = {1, 2, 3, 4, 5}; IntStream stream = Arrays.stream(numbers, 1, 4); // 2, 3, 4 Stream.of() 사용 Stream\u0026lt;String\u0026gt; stream = Stream.of(\u0026#34;Apple\u0026#34;, \u0026#34;Banana\u0026#34;, \u0026#34;Cherry\u0026#34;); Stream\u0026lt;Integer\u0026gt; numberStream = Stream.of(1, 2, 3, 4, 5); 범위 생성 // 1부터 10까지 (10 포함) IntStream range = IntStream.rangeClosed(1, 10); // 1부터 10까지 (10 미포함) IntStream range2 = IntStream.range(1, 10); 무한 스트림 생성 // iterate: 초기값부터 시작해서 함수를 반복 적용 Stream\u0026lt;Integer\u0026gt; evenNumbers = Stream.iterate(0, n -\u0026gt; n + 2); // generate: 매번 새로운 값을 생성 Stream\u0026lt;Double\u0026gt; randomNumbers = Stream.generate(Math::random); // 무한 스트림은 limit()으로 제한 필요 Stream\u0026lt;Integer\u0026gt; first10Evens = Stream.iterate(0, n -\u0026gt; n + 2).limit(10); 중간 연산 (Intermediate Operations) 중간 연산은 Stream을 반환하므로 여러 개를 연결(chaining)할 수 있습니다. 중간 연산은 지연 평가(lazy evaluation)되어 최종 연산이 호출될 때까지 실행되지 않습니다.\nfilter() - 조건에 맞는 요소 필터링 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); // 짝수만 필터링 List\u0026lt;Integer\u0026gt; evenNumbers = numbers.stream() .filter(n -\u0026gt; n % 2 == 0) .collect(Collectors.toList()); // [2, 4, 6, 8, 10] // 여러 조건 결합 List\u0026lt;Integer\u0026gt; filtered = numbers.stream() .filter(n -\u0026gt; n \u0026gt; 3) .filter(n -\u0026gt; n \u0026lt; 8) .collect(Collectors.toList()); // [4, 5, 6, 7] map() - 요소를 다른 형태로 변환 List\u0026lt;String\u0026gt; names = Arrays.asList(\u0026#34;alice\u0026#34;, \u0026#34;bob\u0026#34;, \u0026#34;charlie\u0026#34;); // 대문자로 변환 List\u0026lt;String\u0026gt; upperNames = names.stream() .map(String::toUpperCase) .collect(Collectors.toList()); // [ALICE, BOB, CHARLIE] // 문자열 길이로 변환 List\u0026lt;Integer\u0026gt; nameLengths = names.stream() .map(String::length) .collect(Collectors.toList()); // [5, 3, 7] flatMap() - 중첩 구조를 평탄화 List\u0026lt;List\u0026lt;Integer\u0026gt;\u0026gt; nestedList = Arrays.asList( Arrays.asList(1, 2, 3), Arrays.asList(4, 5), Arrays.asList(6, 7, 8, 9) ); // 중첩 리스트를 단일 리스트로 평탄화 List\u0026lt;Integer\u0026gt; flatList = nestedList.stream() .flatMap(List::stream) .collect(Collectors.toList()); // [1, 2, 3, 4, 5, 6, 7, 8, 9] // 문자열을 문자로 분리 List\u0026lt;String\u0026gt; words = Arrays.asList(\u0026#34;Hello\u0026#34;, \u0026#34;World\u0026#34;); List\u0026lt;String\u0026gt; characters = words.stream() .flatMap(word -\u0026gt; Arrays.stream(word.split(\u0026#34;\u0026#34;))) .collect(Collectors.toList()); // [H, e, l, l, o, W, o, r, l, d] sorted() - 정렬 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(5, 3, 8, 1, 9, 2); // 기본 정렬 (오름차순) List\u0026lt;Integer\u0026gt; sorted = numbers.stream() .sorted() .collect(Collectors.toList()); // [1, 2, 3, 5, 8, 9] // 역순 정렬 List\u0026lt;Integer\u0026gt; reversed = numbers.stream() .sorted(Comparator.reverseOrder()) .collect(Collectors.toList()); // [9, 8, 5, 3, 2, 1] // 객체 정렬 List\u0026lt;Person\u0026gt; people = Arrays.asList( new Person(\u0026#34;Alice\u0026#34;, 25), new Person(\u0026#34;Bob\u0026#34;, 30), new Person(\u0026#34;Charlie\u0026#34;, 20) ); List\u0026lt;Person\u0026gt; sortedByAge = people.stream() .sorted(Comparator.comparing(Person::getAge)) .collect(Collectors.toList()); distinct() - 중복 제거 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 2, 3, 3, 3, 4, 5, 5); List\u0026lt;Integer\u0026gt; unique = numbers.stream() .distinct() .collect(Collectors.toList()); // [1, 2, 3, 4, 5] limit()와 skip() - 개수 제한 및 건너뛰기 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); // 처음 5개만 List\u0026lt;Integer\u0026gt; first5 = numbers.stream() .limit(5) .collect(Collectors.toList()); // [1, 2, 3, 4, 5] // 처음 3개 건너뛰고 5개 List\u0026lt;Integer\u0026gt; middle = numbers.stream() .skip(3) .limit(5) .collect(Collectors.toList()); // [4, 5, 6, 7, 8] 최종 연산 (Terminal Operations) 최종 연산은 Stream을 소비하고 결과를 반환합니다. 최종 연산이 호출되면 Stream은 더 이상 사용할 수 없습니다.\ncollect() - 결과를 컬렉션으로 수집 List\u0026lt;String\u0026gt; names = Arrays.asList(\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Charlie\u0026#34;); // List로 수집 List\u0026lt;String\u0026gt; list = names.stream() .collect(Collectors.toList()); // Set으로 수집 Set\u0026lt;String\u0026gt; set = names.stream() .collect(Collectors.toSet()); // Map으로 수집 Map\u0026lt;String, Integer\u0026gt; nameToLength = names.stream() .collect(Collectors.toMap( name -\u0026gt; name, String::length )); reduce() - 요소를 하나로 축약 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 3, 4, 5); // 합계 int sum = numbers.stream() .reduce(0, (a, b) -\u0026gt; a + b); // 15 // 곱셈 int product = numbers.stream() .reduce(1, (a, b) -\u0026gt; a * b); // 120 // 최댓값 Optional\u0026lt;Integer\u0026gt; max = numbers.stream() .reduce(Integer::max); forEach() - 각 요소에 대해 작업 수행 List\u0026lt;String\u0026gt; names = Arrays.asList(\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Charlie\u0026#34;); names.stream() .forEach(System.out::println); // 순서 보장이 필요한 경우 forEachOrdered 사용 names.parallelStream() .forEachOrdered(System.out::println); count() - 요소 개수 세기 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 3, 4, 5); long count = numbers.stream() .filter(n -\u0026gt; n \u0026gt; 2) .count(); // 3 anyMatch(), allMatch(), noneMatch() - 조건 검사 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 3, 4, 5); // 하나라도 조건을 만족하는가? boolean hasEven = numbers.stream() .anyMatch(n -\u0026gt; n % 2 == 0); // true // 모두 조건을 만족하는가? boolean allPositive = numbers.stream() .allMatch(n -\u0026gt; n \u0026gt; 0); // true // 모두 조건을 만족하지 않는가? boolean noNegative = numbers.stream() .noneMatch(n -\u0026gt; n \u0026lt; 0); // true findFirst()와 findAny() - 요소 찾기 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 3, 4, 5); // 첫 번째 요소 Optional\u0026lt;Integer\u0026gt; first = numbers.stream() .findFirst(); // 아무 요소나 (병렬 스트림에서 유용) Optional\u0026lt;Integer\u0026gt; any = numbers.stream() .findAny(); Collectors 활용 Collectors는 Stream의 요소를 다양한 방식으로 수집할 수 있는 유틸리티를 제공합니다.\ngroupingBy() - 그룹핑 List\u0026lt;Person\u0026gt; people = Arrays.asList( new Person(\u0026#34;Alice\u0026#34;, 25, \u0026#34;Engineering\u0026#34;), new Person(\u0026#34;Bob\u0026#34;, 30, \u0026#34;Marketing\u0026#34;), new Person(\u0026#34;Charlie\u0026#34;, 25, \u0026#34;Engineering\u0026#34;), new Person(\u0026#34;David\u0026#34;, 30, \u0026#34;Sales\u0026#34;) ); // 부서별로 그룹핑 Map\u0026lt;String, List\u0026lt;Person\u0026gt;\u0026gt; byDept = people.stream() .collect(Collectors.groupingBy(Person::getDepartment)); // 나이별로 그룹핑하고 개수 세기 Map\u0026lt;Integer, Long\u0026gt; ageCount = people.stream() .collect(Collectors.groupingBy( Person::getAge, Collectors.counting() )); // 부서별 평균 나이 Map\u0026lt;String, Double\u0026gt; avgAgeByDept = people.stream() .collect(Collectors.groupingBy( Person::getDepartment, Collectors.averagingInt(Person::getAge) )); partitioningBy() - 조건으로 분할 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); // 짝수와 홀수로 분할 Map\u0026lt;Boolean, List\u0026lt;Integer\u0026gt;\u0026gt; partitioned = numbers.stream() .collect(Collectors.partitioningBy(n -\u0026gt; n % 2 == 0)); // {false=[1, 3, 5, 7, 9], true=[2, 4, 6, 8, 10]} joining() - 문자열 결합 List\u0026lt;String\u0026gt; names = Arrays.asList(\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Charlie\u0026#34;); // 단순 결합 String joined = names.stream() .collect(Collectors.joining()); // \u0026#34;AliceBobCharlie\u0026#34; // 구분자 사용 String withComma = names.stream() .collect(Collectors.joining(\u0026#34;, \u0026#34;)); // \u0026#34;Alice, Bob, Charlie\u0026#34; // 접두사, 구분자, 접미사 String formatted = names.stream() .collect(Collectors.joining(\u0026#34;, \u0026#34;, \u0026#34;[\u0026#34;, \u0026#34;]\u0026#34;)); // \u0026#34;[Alice, Bob, Charlie]\u0026#34; summarizingInt/Long/Double() - 통계 List\u0026lt;Integer\u0026gt; numbers = Arrays.asList(1, 2, 3, 4, 5); IntSummaryStatistics stats = numbers.stream() .collect(Collectors.summarizingInt(Integer::intValue)); System.out.println(\u0026#34;Count: \u0026#34; + stats.getCount()); System.out.println(\u0026#34;Sum: \u0026#34; + stats.getSum()); System.out.println(\u0026#34;Min: \u0026#34; + stats.getMin()); System.out.println(\u0026#34;Max: \u0026#34; + stats.getMax()); System.out.println(\u0026#34;Average: \u0026#34; + stats.getAverage()); 병렬 스트림 (Parallel Stream) 병렬 스트림을 사용하면 멀티코어 환경에서 데이터를 병렬로 처리할 수 있습니다.\nList\u0026lt;Integer\u0026gt; numbers = IntStream.rangeClosed(1, 1000000) .boxed() .collect(Collectors.toList()); // 순차 스트림 long sequentialSum = numbers.stream() .mapToInt(Integer::intValue) .sum(); // 병렬 스트림 long parallelSum = numbers.parallelStream() .mapToInt(Integer::intValue) .sum(); // 순차를 병렬로 전환 Stream\u0026lt;Integer\u0026gt; parallelStream = numbers.stream().parallel(); // 병렬을 순차로 전환 Stream\u0026lt;Integer\u0026gt; sequentialStream = numbers.parallelStream().sequential(); 병렬 스트림 사용 시 주의사항 상태가 없는 연산 사용: 병렬 처리 시 스레드 안전성을 보장해야 합니다. // 나쁜 예: 외부 변수 사용 (스레드 안전하지 않음) List\u0026lt;Integer\u0026gt; results = new ArrayList\u0026lt;\u0026gt;(); IntStream.range(1, 100).parallel() .forEach(results::add); // 경쟁 조건 발생 // 좋은 예: collect 사용 List\u0026lt;Integer\u0026gt; results = IntStream.range(1, 100) .parallel() .boxed() .collect(Collectors.toList()); 작은 데이터셋에는 비효율적: 오버헤드가 이익보다 클 수 있습니다.\n순서가 중요한 경우: forEachOrdered() 사용\nOptional과 함께 사용 Stream과 Optional을 함께 사용하면 null 안전한 코드를 작성할 수 있습니다.\nList\u0026lt;String\u0026gt; names = Arrays.asList(\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Charlie\u0026#34;); // 이름 찾기 Optional\u0026lt;String\u0026gt; found = names.stream() .filter(name -\u0026gt; name.startsWith(\u0026#34;B\u0026#34;)) .findFirst(); found.ifPresent(System.out::println); // \u0026#34;Bob\u0026#34; // 기본값 제공 String result = names.stream() .filter(name -\u0026gt; name.startsWith(\u0026#34;Z\u0026#34;)) .findFirst() .orElse(\u0026#34;Not Found\u0026#34;); // 예외 던지기 String result2 = names.stream() .filter(name -\u0026gt; name.startsWith(\u0026#34;Z\u0026#34;)) .findFirst() .orElseThrow(() -\u0026gt; new NoSuchElementException(\u0026#34;Name not found\u0026#34;)); // Optional 스트림 (Java 9+) List\u0026lt;Optional\u0026lt;String\u0026gt;\u0026gt; optionals = Arrays.asList( Optional.of(\u0026#34;Alice\u0026#34;), Optional.empty(), Optional.of(\u0026#34;Bob\u0026#34;) ); List\u0026lt;String\u0026gt; values = optionals.stream() .flatMap(Optional::stream) .collect(Collectors.toList()); // [Alice, Bob] 실전 예제 데이터 변환 예제 // 주문 데이터를 통계로 변환 class Order { private String product; private int quantity; private double price; // constructor, getters } List\u0026lt;Order\u0026gt; orders = Arrays.asList( new Order(\u0026#34;Laptop\u0026#34;, 2, 1200.0), new Order(\u0026#34;Mouse\u0026#34;, 5, 25.0), new Order(\u0026#34;Keyboard\u0026#34;, 3, 75.0), new Order(\u0026#34;Monitor\u0026#34;, 2, 300.0) ); // 제품별 총 매출 Map\u0026lt;String, Double\u0026gt; revenueByProduct = orders.stream() .collect(Collectors.groupingBy( Order::getProduct, Collectors.summingDouble(o -\u0026gt; o.getQuantity() * o.getPrice()) )); // 총 매출 double totalRevenue = orders.stream() .mapToDouble(o -\u0026gt; o.getQuantity() * o.getPrice()) .sum(); // 가장 많이 팔린 제품 Optional\u0026lt;String\u0026gt; topProduct = orders.stream() .collect(Collectors.groupingBy( Order::getProduct, Collectors.summingInt(Order::getQuantity) )) .entrySet().stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey); 복잡한 필터링과 변환 class Employee { private String name; private String department; private int age; private double salary; // constructor, getters } List\u0026lt;Employee\u0026gt; employees = Arrays.asList( new Employee(\u0026#34;Alice\u0026#34;, \u0026#34;Engineering\u0026#34;, 28, 75000), new Employee(\u0026#34;Bob\u0026#34;, \u0026#34;Marketing\u0026#34;, 35, 65000), new Employee(\u0026#34;Charlie\u0026#34;, \u0026#34;Engineering\u0026#34;, 32, 80000), new Employee(\u0026#34;David\u0026#34;, \u0026#34;Sales\u0026#34;, 29, 60000), new Employee(\u0026#34;Eve\u0026#34;, \u0026#34;Engineering\u0026#34;, 26, 70000) ); // Engineering 부서에서 급여가 70000 이상인 직원의 이름을 나이순으로 List\u0026lt;String\u0026gt; seniorEngineers = employees.stream() .filter(e -\u0026gt; e.getDepartment().equals(\u0026#34;Engineering\u0026#34;)) .filter(e -\u0026gt; e.getSalary() \u0026gt;= 70000) .sorted(Comparator.comparing(Employee::getAge).reversed()) .map(Employee::getName) .collect(Collectors.toList()); // 부서별 평균 급여 Map\u0026lt;String, Double\u0026gt; avgSalaryByDept = employees.stream() .collect(Collectors.groupingBy( Employee::getDepartment, Collectors.averagingDouble(Employee::getSalary) )); // 30세 미만 직원들을 부서별로 그룹핑 Map\u0026lt;String, List\u0026lt;Employee\u0026gt;\u0026gt; youngByDept = employees.stream() .filter(e -\u0026gt; e.getAge() \u0026lt; 30) .collect(Collectors.groupingBy(Employee::getDepartment)); 데이터 집계 예제 // 텍스트 분석 String text = \u0026#34;Java Stream API is a powerful tool for processing collections. \u0026#34; + \u0026#34;Stream makes data processing efficient and readable.\u0026#34;; // 단어별 빈도수 Map\u0026lt;String, Long\u0026gt; wordFrequency = Arrays.stream(text.toLowerCase().split(\u0026#34;\\\\s+\u0026#34;)) .collect(Collectors.groupingBy( word -\u0026gt; word.replaceAll(\u0026#34;[^a-z]\u0026#34;, \u0026#34;\u0026#34;), Collectors.counting() )); // 가장 자주 나오는 단어 Optional\u0026lt;Map.Entry\u0026lt;String, Long\u0026gt;\u0026gt; mostFrequent = wordFrequency.entrySet().stream() .max(Map.Entry.comparingByValue()); // 길이가 5 이상인 단어들을 알파벳 순으로 List\u0026lt;String\u0026gt; longWords = Arrays.stream(text.split(\u0026#34;\\\\s+\u0026#34;)) .map(word -\u0026gt; word.replaceAll(\u0026#34;[^a-zA-Z]\u0026#34;, \u0026#34;\u0026#34;)) .filter(word -\u0026gt; word.length() \u0026gt;= 5) .map(String::toLowerCase) .distinct() .sorted() .collect(Collectors.toList()); 성능 최적화 팁 Stream을 올바르게 사용하면 불필요한 오버헤드를 줄이고 처리 속도를 높일 수 있습니다.\n적절한 Stream 타입 사용: IntStream, LongStream, DoubleStream을 사용하면 박싱/언박싱 오버헤드를 피할 수 있습니다. // 비효율적 int sum = numbers.stream() .mapToInt(Integer::intValue) .sum(); // 효율적 int sum = numbers.stream() .mapToInt(i -\u0026gt; i) .sum(); // 더 효율적 (직접 IntStream 사용) IntStream.range(1, 100) .sum(); Short-circuit 연산 활용: anyMatch, allMatch, findFirst 등은 조건을 만족하면 즉시 종료됩니다. boolean hasLargeSalary = employees.stream() .anyMatch(e -\u0026gt; e.getSalary() \u0026gt; 100000); // 조건 만족 시 즉시 종료 적절한 순서로 연산 배치: 데이터를 먼저 줄이는 연산을 앞에 배치합니다. // 비효율적: map 후 filter list.stream() .map(expensiveOperation) .filter(condition) .collect(Collectors.toList()); // 효율적: filter 후 map list.stream() .filter(condition) .map(expensiveOperation) .collect(Collectors.toList()); 마무리 Java Stream API는 컬렉션 데이터를 함수형 스타일로 처리할 수 있는 강력한 도구입니다. 선언적이고 읽기 쉬운 코드를 작성할 수 있으며, 병렬 처리를 통해 성능을 향상시킬 수 있습니다.\n핵심 포인트:\nStream은 원본 데이터를 변경하지 않습니다 중간 연산은 지연 평가되며 최종 연산에서 실행됩니다 병렬 스트림으로 멀티코어 활용이 가능합니다 Collectors를 활용하면 복잡한 데이터 변환과 집계가 쉬워집니다 Optional과 함께 사용하면 null 안전한 코드를 작성할 수 있습니다 Stream API를 효과적으로 활용하면 더 간결하고 유지보수하기 쉬운 코드를 작성할 수 있습니다. 다만, 모든 상황에서 Stream이 최선은 아니므로, 코드의 가독성과 성능을 고려하여 적절히 사용하는 것이 중요합니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/java-stream-api/","summary":"\u003ch2 id=\"stream-api란\"\u003eStream API란?\u003c/h2\u003e\n\u003cp\u003eJava 8에서 도입된 Stream API는 컬렉션 데이터를 함수형 스타일로 처리할 수 있게 해주는 강력한 도구입니다. Stream을 사용하면 데이터를 선언적으로 처리하고, 병렬 처리를 쉽게 구현할 수 있으며, 코드의 가독성을 크게 향상시킬 수 있습니다.\u003c/p\u003e\n\u003cp\u003eStream은 데이터의 흐름을 나타내며, 원본 데이터를 변경하지 않고 중간 연산과 최종 연산을 통해 데이터를 처리합니다. 이러한 특성 덕분에 불변성을 유지하면서도 효율적인 데이터 처리가 가능합니다.\u003c/p\u003e\n\u003ch2 id=\"stream-생성-방법\"\u003eStream 생성 방법\u003c/h2\u003e\n\u003cp\u003eStream을 생성하는 다양한 방법이 있습니다.\u003c/p\u003e\n\u003ch3 id=\"컬렉션으로부터-생성\"\u003e컬렉션으로부터 생성\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003eList\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026lt;\u003c/span\u003eString\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e list \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e Arrays.\u003cspan style=\"color:#89b4fa\"\u003easList\u003c/span\u003e(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Apple\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Banana\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Cherry\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eStream\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026lt;\u003c/span\u003eString\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e stream \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e list.\u003cspan style=\"color:#89b4fa\"\u003estream\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:#6c7086;font-style:italic\"\u003e// 병렬 스트림\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eStream\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026lt;\u003c/span\u003eString\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e parallelStream \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e list.\u003cspan style=\"color:#89b4fa\"\u003eparallelStream\u003c/span\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:#cdd6f4;background-color:#1e1e2e;-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\u003eString\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e[]\u003c/span\u003e array \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e {\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Apple\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Banana\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Cherry\u0026#34;\u003c/span\u003e};\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eStream\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026lt;\u003c/span\u003eString\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e stream \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e Arrays.\u003cspan style=\"color:#89b4fa\"\u003estream\u003c/span\u003e(array);\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:#6c7086;font-style:italic\"\u003e// 범위 지정\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f38ba8\"\u003eint\u003c/span\u003e\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e[]\u003c/span\u003e numbers \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e {1, 2, 3, 4, 5};\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eIntStream stream \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e Arrays.\u003cspan style=\"color:#89b4fa\"\u003estream\u003c/span\u003e(numbers, 1, 4); \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 2, 3, 4\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"streamof-사용\"\u003eStream.of() 사용\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003eStream\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026lt;\u003c/span\u003eString\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e stream \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e Stream.\u003cspan style=\"color:#89b4fa\"\u003eof\u003c/span\u003e(\u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Apple\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Banana\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;Cherry\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eStream\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026lt;\u003c/span\u003eInteger\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e numberStream \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e Stream.\u003cspan style=\"color:#89b4fa\"\u003eof\u003c/span\u003e(1, 2, 3, 4, 5);\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:#cdd6f4;background-color:#1e1e2e;-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:#6c7086;font-style:italic\"\u003e// 1부터 10까지 (10 포함)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eIntStream range \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e IntStream.\u003cspan style=\"color:#89b4fa\"\u003erangeClosed\u003c/span\u003e(1, 10);\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:#6c7086;font-style:italic\"\u003e// 1부터 10까지 (10 미포함)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eIntStream range2 \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e IntStream.\u003cspan style=\"color:#89b4fa\"\u003erange\u003c/span\u003e(1, 10);\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:#cdd6f4;background-color:#1e1e2e;-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:#6c7086;font-style:italic\"\u003e// iterate: 초기값부터 시작해서 함수를 반복 적용\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eStream\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026lt;\u003c/span\u003eInteger\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e evenNumbers \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e Stream.\u003cspan style=\"color:#89b4fa\"\u003eiterate\u003c/span\u003e(0, n \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e-\u0026gt;\u003c/span\u003e n \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e 2);\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:#6c7086;font-style:italic\"\u003e// generate: 매번 새로운 값을 생성\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eStream\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026lt;\u003c/span\u003eDouble\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e randomNumbers \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e Stream.\u003cspan style=\"color:#89b4fa\"\u003egenerate\u003c/span\u003e(Math::random);\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:#6c7086;font-style:italic\"\u003e// 무한 스트림은 limit()으로 제한 필요\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eStream\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026lt;\u003c/span\u003eInteger\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e\u0026gt;\u003c/span\u003e first10Evens \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e Stream.\u003cspan style=\"color:#89b4fa\"\u003eiterate\u003c/span\u003e(0, n \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e-\u0026gt;\u003c/span\u003e n \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e 2).\u003cspan style=\"color:#89b4fa\"\u003elimit\u003c/span\u003e(10);\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"중간-연산-intermediate-operations\"\u003e중간 연산 (Intermediate Operations)\u003c/h2\u003e\n\u003cp\u003e중간 연산은 Stream을 반환하므로 여러 개를 연결(chaining)할 수 있습니다. 중간 연산은 지연 평가(lazy evaluation)되어 최종 연산이 호출될 때까지 실행되지 않습니다.\u003c/p\u003e","tags":["Java","Stream","함수형프로그래밍","컬렉션"],"title":"Java Stream API - filter, map, collect와 병렬 스트림"},{"content":"React란? React는 Meta(구 Facebook)에서 개발한 UI 라이브러리입니다. 컴포넌트 기반 아키텍처로 복잡한 UI를 독립적이고 재사용 가능한 조각으로 나누어 개발할 수 있습니다.\nReact의 핵심 특징 컴포넌트 기반: UI를 독립적인 컴포넌트로 분리하여 관리 선언적 UI: 상태에 따라 UI가 자동으로 업데이트 Virtual DOM: 효율적인 렌더링으로 성능 최적화 단방향 데이터 흐름: 예측 가능한 데이터 관리 풍부한 생태계: Next.js, React Native 등 확장 가능 프로젝트 생성 Vite로 React 프로젝트 생성 # Vite로 React + TypeScript 프로젝트 생성 npm create vite@latest my-react-app -- --template react-ts # 프로젝트 디렉토리로 이동 cd my-react-app # 의존성 설치 npm install # 개발 서버 시작 npm run dev 프로젝트 구조 my-react-app/ ├── public/ │ └── vite.svg ├── src/ │ ├── assets/ │ ├── App.tsx │ ├── App.css │ ├── main.tsx │ └── index.css ├── index.html ├── package.json ├── tsconfig.json └── vite.config.ts 핵심 파일 설명:\nsrc/main.tsx: 앱의 진입점, ReactDOM 렌더링 src/App.tsx: 루트 컴포넌트 index.html: HTML 템플릿 vite.config.ts: Vite 빌드 설정 JSX (JavaScript XML) JSX는 JavaScript 안에서 HTML과 유사한 마크업을 작성할 수 있게 해주는 문법 확장입니다.\n기본 문법 function App() { return ( \u0026lt;div className=\u0026#34;app\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;안녕하세요, React!\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;첫 번째 React 앱입니다.\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; ); } JSX 규칙 function JsxRules() { const name = \u0026#34;React\u0026#34;; const isLoggedIn = true; const items = [\u0026#34;사과\u0026#34;, \u0026#34;바나나\u0026#34;, \u0026#34;체리\u0026#34;]; return ( // 1. 반드시 하나의 루트 요소로 감싸야 함 \u0026lt;div\u0026gt; {/* 2. JavaScript 표현식은 중괄호 {} 사용 */} \u0026lt;h1\u0026gt;안녕하세요, {name}!\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;2 + 3 = {2 + 3}\u0026lt;/p\u0026gt; {/* 3. class 대신 className 사용 */} \u0026lt;div className=\u0026#34;container\u0026#34;\u0026gt; {/* 4. 조건부 렌더링 */} {isLoggedIn ? \u0026lt;p\u0026gt;환영합니다!\u0026lt;/p\u0026gt; : \u0026lt;p\u0026gt;로그인 해주세요\u0026lt;/p\u0026gt;} {isLoggedIn \u0026amp;\u0026amp; \u0026lt;button\u0026gt;로그아웃\u0026lt;/button\u0026gt;} {/* 5. 리스트 렌더링 - key 필수 */} \u0026lt;ul\u0026gt; {items.map((item, index) =\u0026gt; ( \u0026lt;li key={index}\u0026gt;{item}\u0026lt;/li\u0026gt; ))} \u0026lt;/ul\u0026gt; {/* 6. 인라인 스타일은 객체로 */} \u0026lt;p style={{ color: \u0026#34;blue\u0026#34;, fontSize: \u0026#34;1.2rem\u0026#34; }}\u0026gt; 스타일 적용 \u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; ); } Fragment 사용 불필요한 DOM 요소 없이 여러 요소를 그룹화할 수 있습니다.\nfunction FragmentExample() { return ( \u0026lt;\u0026gt; \u0026lt;h1\u0026gt;제목\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;내용\u0026lt;/p\u0026gt; \u0026lt;/\u0026gt; ); } 컴포넌트 함수형 컴포넌트 React에서는 함수형 컴포넌트가 표준입니다.\n// 기본 컴포넌트 function Welcome() { return \u0026lt;h1\u0026gt;환영합니다!\u0026lt;/h1\u0026gt;; } // Arrow Function으로도 작성 가능 const Welcome = () =\u0026gt; { return \u0026lt;h1\u0026gt;환영합니다!\u0026lt;/h1\u0026gt;; }; // 사용 function App() { return ( \u0026lt;div\u0026gt; \u0026lt;Welcome /\u0026gt; \u0026lt;/div\u0026gt; ); } 컴포넌트 분리 // Header.tsx function Header() { return ( \u0026lt;header\u0026gt; \u0026lt;nav\u0026gt; \u0026lt;a href=\u0026#34;/\u0026#34;\u0026gt;홈\u0026lt;/a\u0026gt; \u0026lt;a href=\u0026#34;/about\u0026#34;\u0026gt;소개\u0026lt;/a\u0026gt; \u0026lt;/nav\u0026gt; \u0026lt;/header\u0026gt; ); } export default Header; // Footer.tsx function Footer() { return ( \u0026lt;footer\u0026gt; \u0026lt;p\u0026gt;\u0026amp;copy; 2026 My App\u0026lt;/p\u0026gt; \u0026lt;/footer\u0026gt; ); } export default Footer; // App.tsx import Header from \u0026#34;./Header\u0026#34;; import Footer from \u0026#34;./Footer\u0026#34;; function App() { return ( \u0026lt;\u0026gt; \u0026lt;Header /\u0026gt; \u0026lt;main\u0026gt; \u0026lt;h1\u0026gt;메인 콘텐츠\u0026lt;/h1\u0026gt; \u0026lt;/main\u0026gt; \u0026lt;Footer /\u0026gt; \u0026lt;/\u0026gt; ); } Props (속성) Props는 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하는 방법입니다.\n기본 Props // Props 타입 정의 interface GreetingProps { name: string; age?: number; // 선택적 prop } function Greeting({ name, age }: GreetingProps) { return ( \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;안녕하세요, {name}님!\u0026lt;/h2\u0026gt; {age \u0026amp;\u0026amp; \u0026lt;p\u0026gt;나이: {age}세\u0026lt;/p\u0026gt;} \u0026lt;/div\u0026gt; ); } // 사용 function App() { return ( \u0026lt;div\u0026gt; \u0026lt;Greeting name=\u0026#34;홍길동\u0026#34; age={30} /\u0026gt; \u0026lt;Greeting name=\u0026#34;김철수\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; ); } children Props interface CardProps { title: string; children: React.ReactNode; } function Card({ title, children }: CardProps) { return ( \u0026lt;div className=\u0026#34;card\u0026#34;\u0026gt; \u0026lt;h3\u0026gt;{title}\u0026lt;/h3\u0026gt; \u0026lt;div className=\u0026#34;card-body\u0026#34;\u0026gt;{children}\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; ); } // 사용 function App() { return ( \u0026lt;Card title=\u0026#34;공지사항\u0026#34;\u0026gt; \u0026lt;p\u0026gt;이것은 카드 안의 내용입니다.\u0026lt;/p\u0026gt; \u0026lt;button\u0026gt;자세히 보기\u0026lt;/button\u0026gt; \u0026lt;/Card\u0026gt; ); } Props로 이벤트 핸들러 전달 interface ButtonProps { label: string; onClick: () =\u0026gt; void; variant?: \u0026#34;primary\u0026#34; | \u0026#34;secondary\u0026#34;; } function Button({ label, onClick, variant = \u0026#34;primary\u0026#34; }: ButtonProps) { return ( \u0026lt;button className={`btn btn-${variant}`} onClick={onClick} \u0026gt; {label} \u0026lt;/button\u0026gt; ); } // 사용 function App() { const handleClick = () =\u0026gt; { alert(\u0026#34;버튼이 클릭되었습니다!\u0026#34;); }; return ( \u0026lt;div\u0026gt; \u0026lt;Button label=\u0026#34;확인\u0026#34; onClick={handleClick} /\u0026gt; \u0026lt;Button label=\u0026#34;취소\u0026#34; onClick={() =\u0026gt; console.log(\u0026#34;취소\u0026#34;)} variant=\u0026#34;secondary\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; ); } State (상태) useState 컴포넌트 내부에서 변경 가능한 데이터를 관리합니다.\nimport { useState } from \u0026#34;react\u0026#34;; function Counter() { const [count, setCount] = useState(0); return ( \u0026lt;div\u0026gt; \u0026lt;p\u0026gt;카운트: {count}\u0026lt;/p\u0026gt; \u0026lt;button onClick={() =\u0026gt; setCount(count + 1)}\u0026gt;+1\u0026lt;/button\u0026gt; \u0026lt;button onClick={() =\u0026gt; setCount(count - 1)}\u0026gt;-1\u0026lt;/button\u0026gt; \u0026lt;button onClick={() =\u0026gt; setCount(0)}\u0026gt;초기화\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; ); } 다양한 State 타입 function StateExamples() { // 문자열 const [name, setName] = useState(\u0026#34;\u0026#34;); // 불리언 const [isVisible, setIsVisible] = useState(false); // 객체 const [user, setUser] = useState({ name: \u0026#34;\u0026#34;, email: \u0026#34;\u0026#34; }); // 배열 const [todos, setTodos] = useState\u0026lt;string[]\u0026gt;([]); return ( \u0026lt;div\u0026gt; {/* 입력 폼 */} \u0026lt;input value={name} onChange={(e) =\u0026gt; setName(e.target.value)} placeholder=\u0026#34;이름 입력\u0026#34; /\u0026gt; {/* 토글 */} \u0026lt;button onClick={() =\u0026gt; setIsVisible(!isVisible)}\u0026gt; {isVisible ? \u0026#34;숨기기\u0026#34; : \u0026#34;보이기\u0026#34;} \u0026lt;/button\u0026gt; {isVisible \u0026amp;\u0026amp; \u0026lt;p\u0026gt;보이는 내용입니다!\u0026lt;/p\u0026gt;} {/* 객체 업데이트 (스프레드 연산자 사용) */} \u0026lt;input value={user.name} onChange={(e) =\u0026gt; setUser({ ...user, name: e.target.value })} placeholder=\u0026#34;사용자 이름\u0026#34; /\u0026gt; {/* 배열에 항목 추가 */} \u0026lt;button onClick={() =\u0026gt; setTodos([...todos, name])}\u0026gt; 할 일 추가 \u0026lt;/button\u0026gt; \u0026lt;ul\u0026gt; {todos.map((todo, i) =\u0026gt; ( \u0026lt;li key={i}\u0026gt;{todo}\u0026lt;/li\u0026gt; ))} \u0026lt;/ul\u0026gt; \u0026lt;/div\u0026gt; ); } 이벤트 처리 기본 이벤트 function EventExamples() { // 클릭 이벤트 const handleClick = (e: React.MouseEvent\u0026lt;HTMLButtonElement\u0026gt;) =\u0026gt; { console.log(\u0026#34;클릭!\u0026#34;, e.currentTarget); }; // 폼 제출 const handleSubmit = (e: React.FormEvent\u0026lt;HTMLFormElement\u0026gt;) =\u0026gt; { e.preventDefault(); console.log(\u0026#34;폼 제출!\u0026#34;); }; // 입력 변경 const handleChange = (e: React.ChangeEvent\u0026lt;HTMLInputElement\u0026gt;) =\u0026gt; { console.log(\u0026#34;입력값:\u0026#34;, e.target.value); }; // 키보드 이벤트 const handleKeyDown = (e: React.KeyboardEvent\u0026lt;HTMLInputElement\u0026gt;) =\u0026gt; { if (e.key === \u0026#34;Enter\u0026#34;) { console.log(\u0026#34;Enter 키 누름!\u0026#34;); } }; return ( \u0026lt;form onSubmit={handleSubmit}\u0026gt; \u0026lt;input onChange={handleChange} onKeyDown={handleKeyDown} placeholder=\u0026#34;입력하세요\u0026#34; /\u0026gt; \u0026lt;button onClick={handleClick}\u0026gt;제출\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; ); } 조건부 렌더링 상태에 따라 다른 UI를 렌더링하는 방법입니다. if-else, 삼항 연산자, 논리 연산자를 활용할 수 있습니다.\ninterface StatusProps { status: \u0026#34;loading\u0026#34; | \u0026#34;success\u0026#34; | \u0026#34;error\u0026#34;; data?: string; error?: string; } function StatusDisplay({ status, data, error }: StatusProps) { // if-else 패턴 if (status === \u0026#34;loading\u0026#34;) { return \u0026lt;div className=\u0026#34;spinner\u0026#34;\u0026gt;로딩 중...\u0026lt;/div\u0026gt;; } if (status === \u0026#34;error\u0026#34;) { return \u0026lt;div className=\u0026#34;error\u0026#34;\u0026gt;에러: {error}\u0026lt;/div\u0026gt;; } return \u0026lt;div className=\u0026#34;success\u0026#34;\u0026gt;데이터: {data}\u0026lt;/div\u0026gt;; } // 사용 function App() { const [status, setStatus] = useState\u0026lt;\u0026#34;loading\u0026#34; | \u0026#34;success\u0026#34; | \u0026#34;error\u0026#34;\u0026gt;(\u0026#34;loading\u0026#34;); return \u0026lt;StatusDisplay status={status} data=\u0026#34;결과 데이터\u0026#34; /\u0026gt;; } 리스트 렌더링 배열 데이터를 map()으로 순회하여 렌더링합니다. 각 항목에는 고유한 key prop이 필수입니다.\ninterface Todo { id: number; text: string; completed: boolean; } function TodoList() { const [todos, setTodos] = useState\u0026lt;Todo[]\u0026gt;([ { id: 1, text: \u0026#34;React 배우기\u0026#34;, completed: false }, { id: 2, text: \u0026#34;TypeScript 배우기\u0026#34;, completed: true }, { id: 3, text: \u0026#34;프로젝트 만들기\u0026#34;, completed: false }, ]); const toggleTodo = (id: number) =\u0026gt; { setTodos( todos.map((todo) =\u0026gt; todo.id === id ? { ...todo, completed: !todo.completed } : todo ) ); }; const deleteTodo = (id: number) =\u0026gt; { setTodos(todos.filter((todo) =\u0026gt; todo.id !== id)); }; return ( \u0026lt;ul\u0026gt; {todos.map((todo) =\u0026gt; ( \u0026lt;li key={todo.id}\u0026gt; \u0026lt;span style={{ textDecoration: todo.completed ? \u0026#34;line-through\u0026#34; : \u0026#34;none\u0026#34;, }} onClick={() =\u0026gt; toggleTodo(todo.id)} \u0026gt; {todo.text} \u0026lt;/span\u0026gt; \u0026lt;button onClick={() =\u0026gt; deleteTodo(todo.id)}\u0026gt;삭제\u0026lt;/button\u0026gt; \u0026lt;/li\u0026gt; ))} \u0026lt;/ul\u0026gt; ); } 폼 처리 제어 컴포넌트 패턴으로 폼 상태를 React state와 동기화합니다. onChange 핸들러로 입력값을 관리하고 onSubmit으로 제출을 처리합니다.\ninterface FormData { username: string; email: string; role: string; } function SignupForm() { const [formData, setFormData] = useState\u0026lt;FormData\u0026gt;({ username: \u0026#34;\u0026#34;, email: \u0026#34;\u0026#34;, role: \u0026#34;user\u0026#34;, }); const handleChange = ( e: React.ChangeEvent\u0026lt;HTMLInputElement | HTMLSelectElement\u0026gt; ) =\u0026gt; { const { name, value } = e.target; setFormData((prev) =\u0026gt; ({ ...prev, [name]: value })); }; const handleSubmit = (e: React.FormEvent) =\u0026gt; { e.preventDefault(); console.log(\u0026#34;제출 데이터:\u0026#34;, formData); }; return ( \u0026lt;form onSubmit={handleSubmit}\u0026gt; \u0026lt;div\u0026gt; \u0026lt;label htmlFor=\u0026#34;username\u0026#34;\u0026gt;사용자명\u0026lt;/label\u0026gt; \u0026lt;input id=\u0026#34;username\u0026#34; name=\u0026#34;username\u0026#34; value={formData.username} onChange={handleChange} required /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div\u0026gt; \u0026lt;label htmlFor=\u0026#34;email\u0026#34;\u0026gt;이메일\u0026lt;/label\u0026gt; \u0026lt;input id=\u0026#34;email\u0026#34; name=\u0026#34;email\u0026#34; type=\u0026#34;email\u0026#34; value={formData.email} onChange={handleChange} required /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div\u0026gt; \u0026lt;label htmlFor=\u0026#34;role\u0026#34;\u0026gt;역할\u0026lt;/label\u0026gt; \u0026lt;select id=\u0026#34;role\u0026#34; name=\u0026#34;role\u0026#34; value={formData.role} onChange={handleChange}\u0026gt; \u0026lt;option value=\u0026#34;user\u0026#34;\u0026gt;일반 사용자\u0026lt;/option\u0026gt; \u0026lt;option value=\u0026#34;admin\u0026#34;\u0026gt;관리자\u0026lt;/option\u0026gt; \u0026lt;/select\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;가입\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; ); } 마무리 React의 핵심 개념을 정리하면:\nJSX: JavaScript 안에서 UI를 선언적으로 작성 컴포넌트: 독립적이고 재사용 가능한 UI 조각 Props: 부모에서 자식으로 데이터 전달 (읽기 전용) State: 컴포넌트 내부의 변경 가능한 데이터 이벤트 처리: onClick, onChange 등으로 사용자 상호작용 처리 다음 글에서는 React Hooks를 활용한 심화 패턴과 상태 관리 방법을 알아보겠습니다.\n관련 글:\nReact Hooks 심화 가이드 ","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/react-introduction/","summary":"\u003ch2 id=\"react란\"\u003eReact란?\u003c/h2\u003e\n\u003cp\u003eReact는 Meta(구 Facebook)에서 개발한 UI 라이브러리입니다. \u003cstrong\u003e컴포넌트 기반 아키텍처\u003c/strong\u003e로 복잡한 UI를 독립적이고 재사용 가능한 조각으로 나누어 개발할 수 있습니다.\u003c/p\u003e\n\u003ch3 id=\"react의-핵심-특징\"\u003eReact의 핵심 특징\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e컴포넌트 기반\u003c/strong\u003e: UI를 독립적인 컴포넌트로 분리하여 관리\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e선언적 UI\u003c/strong\u003e: 상태에 따라 UI가 자동으로 업데이트\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eVirtual DOM\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: Next.js, React Native 등 확장 가능\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"프로젝트-생성\"\u003e프로젝트 생성\u003c/h2\u003e\n\u003ch3 id=\"vite로-react-프로젝트-생성\"\u003eVite로 React 프로젝트 생성\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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\u003e\u003cspan style=\"color:#6c7086;font-style:italic\"\u003e# Vite로 React + TypeScript 프로젝트 생성\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003enpm create vite@latest my-react-app -- --template react-ts\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:#6c7086;font-style:italic\"\u003e# 프로젝트 디렉토리로 이동\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#89dceb\"\u003ecd\u003c/span\u003e my-react-app\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:#6c7086;font-style:italic\"\u003e# 의존성 설치\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003enpm install\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:#6c7086;font-style:italic\"\u003e# 개발 서버 시작\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003enpm run dev\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:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emy-react-app/\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── public/\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e│   └── vite.svg\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── src/\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e│   ├── assets/\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e│   ├── App.tsx\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e│   ├── App.css\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e│   ├── main.tsx\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e│   └── index.css\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── index.html\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── package.json\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e├── tsconfig.json\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e└── vite.config.ts\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e핵심 파일 설명\u003c/strong\u003e:\u003c/p\u003e","tags":["React","JavaScript","프론트엔드","웹개발"],"title":"React 시작하기 - JSX, 컴포넌트, Props, State, 이벤트 처리"},{"content":"REST API 설계 원칙 RESTful API는 HTTP 프로토콜을 기반으로 자원(Resource)을 URI로 표현하고, HTTP 메서드로 작업을 정의하는 아키텍처 스타일입니다. Spring Boot는 REST API 개발을 위한 강력한 기능을 제공합니다.\nREST의 핵심 원칙 1. 자원 기반 URI 설계\n명사를 사용하여 자원을 표현: /users, /products 계층 구조 표현: /users/{id}/orders 복수형 사용 권장: /users (O), /user (X) 2. HTTP 메서드 활용\nGET: 조회 POST: 생성 PUT: 전체 수정 PATCH: 부분 수정 DELETE: 삭제 3. 상태 코드 활용\n2xx: 성공 (200 OK, 201 Created, 204 No Content) 4xx: 클라이언트 오류 (400 Bad Request, 404 Not Found) 5xx: 서버 오류 (500 Internal Server Error) @RestController와 @RequestMapping Spring Boot에서 REST API를 만들 때 가장 먼저 사용하는 어노테이션입니다.\n@RestController @RequestMapping(\u0026#34;/api/users\u0026#34;) public class UserController { // 모든 응답이 자동으로 JSON으로 변환됩니다 } @RestController vs @Controller\n@RestController = @Controller + @ResponseBody 모든 메서드의 반환값이 HTTP 응답 본문으로 직렬화됩니다 ViewResolver를 거치지 않고 바로 JSON/XML로 변환됩니다 @RequestMapping 속성\n@RequestMapping( value = \u0026#34;/api/users\u0026#34;, method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE ) HTTP 메서드 매핑 Spring Boot는 각 HTTP 메서드에 대응하는 편리한 어노테이션을 제공합니다.\n@GetMapping - 조회 @RestController @RequestMapping(\u0026#34;/api/users\u0026#34;) public class UserController { // 전체 목록 조회 @GetMapping public List\u0026lt;UserResponse\u0026gt; getAllUsers() { return userService.findAll(); } // 단건 조회 @GetMapping(\u0026#34;/{id}\u0026#34;) public UserResponse getUser(@PathVariable Long id) { return userService.findById(id); } // 검색 (쿼리 파라미터) @GetMapping(\u0026#34;/search\u0026#34;) public List\u0026lt;UserResponse\u0026gt; searchUsers( @RequestParam(required = false) String name, @RequestParam(required = false) Integer age ) { return userService.search(name, age); } } @PostMapping - 생성 @PostMapping public ResponseEntity\u0026lt;UserResponse\u0026gt; createUser( @RequestBody @Valid UserCreateRequest request ) { UserResponse created = userService.create(request); return ResponseEntity .status(HttpStatus.CREATED) .body(created); } @PutMapping - 전체 수정 @PutMapping(\u0026#34;/{id}\u0026#34;) public UserResponse updateUser( @PathVariable Long id, @RequestBody @Valid UserUpdateRequest request ) { return userService.update(id, request); } @PatchMapping - 부분 수정 @PatchMapping(\u0026#34;/{id}\u0026#34;) public UserResponse patchUser( @PathVariable Long id, @RequestBody Map\u0026lt;String, Object\u0026gt; updates ) { return userService.patch(id, updates); } @DeleteMapping - 삭제 @DeleteMapping(\u0026#34;/{id}\u0026#34;) public ResponseEntity\u0026lt;Void\u0026gt; deleteUser(@PathVariable Long id) { userService.delete(id); return ResponseEntity.noContent().build(); } 요청/응답 DTO 패턴 Entity를 직접 노출하지 않고 DTO(Data Transfer Object)를 사용하는 것이 모범 사례입니다.\n요청 DTO // 생성 요청 @Getter @NoArgsConstructor public class UserCreateRequest { @NotBlank(message = \u0026#34;이름은 필수입니다\u0026#34;) private String name; @Email(message = \u0026#34;올바른 이메일 형식이 아닙니다\u0026#34;) @NotBlank private String email; @Min(value = 0, message = \u0026#34;나이는 0 이상이어야 합니다\u0026#34;) @Max(value = 150, message = \u0026#34;나이는 150 이하여야 합니다\u0026#34;) private Integer age; @Builder public UserCreateRequest(String name, String email, Integer age) { this.name = name; this.email = email; this.age = age; } } // 수정 요청 @Getter @NoArgsConstructor public class UserUpdateRequest { @NotBlank private String name; @Email @NotBlank private String email; @Min(0) @Max(150) private Integer age; @Builder public UserUpdateRequest(String name, String email, Integer age) { this.name = name; this.email = email; this.age = age; } } 응답 DTO @Getter @NoArgsConstructor public class UserResponse { private Long id; private String name; private String email; private Integer age; private LocalDateTime createdAt; private LocalDateTime updatedAt; @Builder public UserResponse(Long id, String name, String email, Integer age, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.name = name; this.email = email; this.age = age; this.createdAt = createdAt; this.updatedAt = updatedAt; } // Entity에서 DTO로 변환 public static UserResponse from(User user) { return UserResponse.builder() .id(user.getId()) .name(user.getName()) .email(user.getEmail()) .age(user.getAge()) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) .build(); } } @PathVariable, @RequestBody, @RequestParam @PathVariable - URI 경로 변수 // 단일 경로 변수 @GetMapping(\u0026#34;/users/{id}\u0026#34;) public UserResponse getUser(@PathVariable Long id) { return userService.findById(id); } // 다중 경로 변수 @GetMapping(\u0026#34;/users/{userId}/orders/{orderId}\u0026#34;) public OrderResponse getUserOrder( @PathVariable Long userId, @PathVariable Long orderId ) { return orderService.findByUserAndOrder(userId, orderId); } // 변수명과 파라미터명이 다를 때 @GetMapping(\u0026#34;/users/{user-id}\u0026#34;) public UserResponse getUser(@PathVariable(\u0026#34;user-id\u0026#34;) Long userId) { return userService.findById(userId); } @RequestBody - 요청 본문 @PostMapping(\u0026#34;/users\u0026#34;) public ResponseEntity\u0026lt;UserResponse\u0026gt; createUser( @RequestBody @Valid UserCreateRequest request ) { // request 객체가 JSON에서 자동 변환됩니다 UserResponse created = userService.create(request); return ResponseEntity.status(HttpStatus.CREATED).body(created); } @Valid 검증\n@PostMapping(\u0026#34;/users\u0026#34;) public UserResponse createUser( @RequestBody @Valid UserCreateRequest request, BindingResult bindingResult ) { if (bindingResult.hasErrors()) { // 검증 오류 처리 throw new ValidationException(bindingResult); } return userService.create(request); } @RequestParam - 쿼리 파라미터 // 단일 파라미터 @GetMapping(\u0026#34;/users/search\u0026#34;) public List\u0026lt;UserResponse\u0026gt; searchByName( @RequestParam String name ) { return userService.findByName(name); } // 다중 파라미터 (선택적) @GetMapping(\u0026#34;/users/filter\u0026#34;) public List\u0026lt;UserResponse\u0026gt; filterUsers( @RequestParam(required = false) String name, @RequestParam(required = false) Integer minAge, @RequestParam(required = false) Integer maxAge, @RequestParam(defaultValue = \u0026#34;0\u0026#34;) int page, @RequestParam(defaultValue = \u0026#34;20\u0026#34;) int size ) { return userService.filter(name, minAge, maxAge, page, size); } // Map으로 모든 파라미터 받기 @GetMapping(\u0026#34;/users/dynamic\u0026#34;) public List\u0026lt;UserResponse\u0026gt; dynamicSearch( @RequestParam Map\u0026lt;String, String\u0026gt; params ) { return userService.dynamicSearch(params); } ResponseEntity 활용 ResponseEntity를 사용하면 HTTP 상태 코드, 헤더, 본문을 세밀하게 제어할 수 있습니다.\n기본 사용법 @RestController @RequestMapping(\u0026#34;/api/users\u0026#34;) public class UserController { // 200 OK with body @GetMapping(\u0026#34;/{id}\u0026#34;) public ResponseEntity\u0026lt;UserResponse\u0026gt; getUser(@PathVariable Long id) { UserResponse user = userService.findById(id); return ResponseEntity.ok(user); } // 201 Created with Location header @PostMapping public ResponseEntity\u0026lt;UserResponse\u0026gt; createUser( @RequestBody @Valid UserCreateRequest request ) { UserResponse created = userService.create(request); URI location = URI.create(\u0026#34;/api/users/\u0026#34; + created.getId()); return ResponseEntity .created(location) .body(created); } // 204 No Content @DeleteMapping(\u0026#34;/{id}\u0026#34;) public ResponseEntity\u0026lt;Void\u0026gt; deleteUser(@PathVariable Long id) { userService.delete(id); return ResponseEntity.noContent().build(); } // 404 Not Found @GetMapping(\u0026#34;/{id}/optional\u0026#34;) public ResponseEntity\u0026lt;UserResponse\u0026gt; getUserOptional(@PathVariable Long id) { return userService.findByIdOptional(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } } 커스텀 헤더 추가 @GetMapping(\u0026#34;/{id}\u0026#34;) public ResponseEntity\u0026lt;UserResponse\u0026gt; getUser(@PathVariable Long id) { UserResponse user = userService.findById(id); return ResponseEntity.ok() .header(\u0026#34;X-Custom-Header\u0026#34;, \u0026#34;CustomValue\u0026#34;) .header(\u0026#34;X-User-Count\u0026#34;, String.valueOf(userService.count())) .body(user); } 조건부 응답 @GetMapping(\u0026#34;/{id}\u0026#34;) public ResponseEntity\u0026lt;UserResponse\u0026gt; getUser( @PathVariable Long id, @RequestHeader(value = \u0026#34;If-Modified-Since\u0026#34;, required = false) String ifModifiedSince ) { UserResponse user = userService.findById(id); if (ifModifiedSince != null \u0026amp;\u0026amp; !userService.isModified(id, ifModifiedSince)) { return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build(); } return ResponseEntity.ok(user); } 예외 처리 @ExceptionHandler - 컨트롤러 레벨 @RestController @RequestMapping(\u0026#34;/api/users\u0026#34;) public class UserController { @GetMapping(\u0026#34;/{id}\u0026#34;) public UserResponse getUser(@PathVariable Long id) { return userService.findById(id); } // 이 컨트롤러 내에서 발생하는 UserNotFoundException 처리 @ExceptionHandler(UserNotFoundException.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleUserNotFound( UserNotFoundException ex ) { ErrorResponse error = ErrorResponse.builder() .status(HttpStatus.NOT_FOUND.value()) .message(ex.getMessage()) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); } } @ControllerAdvice - 전역 예외 처리 @RestControllerAdvice public class GlobalExceptionHandler { // 404 Not Found @ExceptionHandler(UserNotFoundException.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleUserNotFound( UserNotFoundException ex ) { ErrorResponse error = ErrorResponse.builder() .status(HttpStatus.NOT_FOUND.value()) .message(ex.getMessage()) .path(ex.getPath()) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); } // 400 Bad Request - Validation 오류 @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleValidationException( MethodArgumentNotValidException ex ) { Map\u0026lt;String, String\u0026gt; errors = new HashMap\u0026lt;\u0026gt;(); ex.getBindingResult().getFieldErrors().forEach(error -\u0026gt; errors.put(error.getField(), error.getDefaultMessage()) ); ErrorResponse error = ErrorResponse.builder() .status(HttpStatus.BAD_REQUEST.value()) .message(\u0026#34;입력값 검증 실패\u0026#34;) .errors(errors) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.badRequest().body(error); } // 409 Conflict - 중복 데이터 @ExceptionHandler(DuplicateEmailException.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleDuplicateEmail( DuplicateEmailException ex ) { ErrorResponse error = ErrorResponse.builder() .status(HttpStatus.CONFLICT.value()) .message(ex.getMessage()) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.status(HttpStatus.CONFLICT).body(error); } // 500 Internal Server Error - 예상치 못한 오류 @ExceptionHandler(Exception.class) public ResponseEntity\u0026lt;ErrorResponse\u0026gt; handleGenericException( Exception ex ) { ErrorResponse error = ErrorResponse.builder() .status(HttpStatus.INTERNAL_SERVER_ERROR.value()) .message(\u0026#34;서버 오류가 발생했습니다\u0026#34;) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); } } 에러 응답 DTO @Getter @Builder public class ErrorResponse { private int status; private String message; private String path; private LocalDateTime timestamp; private Map\u0026lt;String, String\u0026gt; errors; // 필드별 검증 오류 } 커스텀 예외 public class UserNotFoundException extends RuntimeException { private final String path; public UserNotFoundException(Long id, String path) { super(\u0026#34;사용자를 찾을 수 없습니다: \u0026#34; + id); this.path = path; } public String getPath() { return path; } } public class DuplicateEmailException extends RuntimeException { public DuplicateEmailException(String email) { super(\u0026#34;이미 사용 중인 이메일입니다: \u0026#34; + email); } } Spring Data JPA 연동 Entity 정의 @Entity @Table(name = \u0026#34;users\u0026#34;) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 100) private String name; @Column(nullable = false, unique = true, length = 100) private String email; @Column private Integer age; @CreatedDate @Column(nullable = false, updatable = false) private LocalDateTime createdAt; @LastModifiedDate @Column(nullable = false) private LocalDateTime updatedAt; @Builder public User(String name, String email, Integer age) { this.name = name; this.email = email; this.age = age; } // 비즈니스 로직 메서드 public void update(String name, String email, Integer age) { this.name = name; this.email = email; this.age = age; } } Repository 인터페이스 public interface UserRepository extends JpaRepository\u0026lt;User, Long\u0026gt; { Optional\u0026lt;User\u0026gt; findByEmail(String email); boolean existsByEmail(String email); List\u0026lt;User\u0026gt; findByNameContaining(String name); List\u0026lt;User\u0026gt; findByAgeBetween(Integer minAge, Integer maxAge); @Query(\u0026#34;SELECT u FROM User u WHERE \u0026#34; + \u0026#34;(:name IS NULL OR u.name LIKE %:name%) AND \u0026#34; + \u0026#34;(:minAge IS NULL OR u.age \u0026gt;= :minAge) AND \u0026#34; + \u0026#34;(:maxAge IS NULL OR u.age \u0026lt;= :maxAge)\u0026#34;) List\u0026lt;User\u0026gt; searchUsers( @Param(\u0026#34;name\u0026#34;) String name, @Param(\u0026#34;minAge\u0026#34;) Integer minAge, @Param(\u0026#34;maxAge\u0026#34;) Integer maxAge ); } Service 레이어 @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class UserService { private final UserRepository userRepository; // 전체 조회 public List\u0026lt;UserResponse\u0026gt; findAll() { return userRepository.findAll().stream() .map(UserResponse::from) .toList(); } // 단건 조회 public UserResponse findById(Long id) { User user = userRepository.findById(id) .orElseThrow(() -\u0026gt; new UserNotFoundException(id, \u0026#34;/api/users/\u0026#34; + id)); return UserResponse.from(user); } // 생성 @Transactional public UserResponse create(UserCreateRequest request) { // 이메일 중복 검사 if (userRepository.existsByEmail(request.getEmail())) { throw new DuplicateEmailException(request.getEmail()); } User user = User.builder() .name(request.getName()) .email(request.getEmail()) .age(request.getAge()) .build(); User saved = userRepository.save(user); return UserResponse.from(saved); } // 수정 @Transactional public UserResponse update(Long id, UserUpdateRequest request) { User user = userRepository.findById(id) .orElseThrow(() -\u0026gt; new UserNotFoundException(id, \u0026#34;/api/users/\u0026#34; + id)); // 다른 사용자가 이미 해당 이메일을 사용 중인지 확인 userRepository.findByEmail(request.getEmail()) .filter(u -\u0026gt; !u.getId().equals(id)) .ifPresent(u -\u0026gt; { throw new DuplicateEmailException(request.getEmail()); }); user.update(request.getName(), request.getEmail(), request.getAge()); return UserResponse.from(user); } // 삭제 @Transactional public void delete(Long id) { if (!userRepository.existsById(id)) { throw new UserNotFoundException(id, \u0026#34;/api/users/\u0026#34; + id); } userRepository.deleteById(id); } // 검색 public List\u0026lt;UserResponse\u0026gt; search(String name, Integer age) { if (name != null \u0026amp;\u0026amp; age != null) { return userRepository.findByNameContaining(name).stream() .filter(u -\u0026gt; u.getAge().equals(age)) .map(UserResponse::from) .toList(); } else if (name != null) { return userRepository.findByNameContaining(name).stream() .map(UserResponse::from) .toList(); } else { return findAll(); } } } 전체 CRUD 예제 - 사용자 관리 API 이제 모든 구성 요소를 결합한 완전한 사용자 관리 API를 구현해 보겠습니다.\n프로젝트 구조 src/main/java/com/example/demo/ ├── domain/ │ └── user/ │ ├── User.java (Entity) │ ├── UserRepository.java │ ├── UserService.java │ └── UserController.java ├── dto/ │ ├── UserCreateRequest.java │ ├── UserUpdateRequest.java │ └── UserResponse.java ├── exception/ │ ├── UserNotFoundException.java │ ├── DuplicateEmailException.java │ ├── ErrorResponse.java │ └── GlobalExceptionHandler.java └── DemoApplication.java 완성된 Controller @RestController @RequestMapping(\u0026#34;/api/users\u0026#34;) @RequiredArgsConstructor public class UserController { private final UserService userService; /** * 전체 사용자 조회 * GET /api/users */ @GetMapping public ResponseEntity\u0026lt;List\u0026lt;UserResponse\u0026gt;\u0026gt; getAllUsers() { List\u0026lt;UserResponse\u0026gt; users = userService.findAll(); return ResponseEntity.ok(users); } /** * 사용자 단건 조회 * GET /api/users/{id} */ @GetMapping(\u0026#34;/{id}\u0026#34;) public ResponseEntity\u0026lt;UserResponse\u0026gt; getUser(@PathVariable Long id) { UserResponse user = userService.findById(id); return ResponseEntity.ok(user); } /** * 사용자 검색 * GET /api/users/search?name=홍길동\u0026amp;age=30 */ @GetMapping(\u0026#34;/search\u0026#34;) public ResponseEntity\u0026lt;List\u0026lt;UserResponse\u0026gt;\u0026gt; searchUsers( @RequestParam(required = false) String name, @RequestParam(required = false) Integer age ) { List\u0026lt;UserResponse\u0026gt; users = userService.search(name, age); return ResponseEntity.ok(users); } /** * 사용자 생성 * POST /api/users */ @PostMapping public ResponseEntity\u0026lt;UserResponse\u0026gt; createUser( @RequestBody @Valid UserCreateRequest request ) { UserResponse created = userService.create(request); URI location = URI.create(\u0026#34;/api/users/\u0026#34; + created.getId()); return ResponseEntity.created(location).body(created); } /** * 사용자 수정 * PUT /api/users/{id} */ @PutMapping(\u0026#34;/{id}\u0026#34;) public ResponseEntity\u0026lt;UserResponse\u0026gt; updateUser( @PathVariable Long id, @RequestBody @Valid UserUpdateRequest request ) { UserResponse updated = userService.update(id, request); return ResponseEntity.ok(updated); } /** * 사용자 삭제 * DELETE /api/users/{id} */ @DeleteMapping(\u0026#34;/{id}\u0026#34;) public ResponseEntity\u0026lt;Void\u0026gt; deleteUser(@PathVariable Long id) { userService.delete(id); return ResponseEntity.noContent().build(); } } application.yml 설정 spring: application: name: demo datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: create-drop properties: hibernate: format_sql: true show_sql: true open-in-view: false h2: console: enabled: true path: /h2-console logging: level: org.hibernate.SQL: debug org.hibernate.type.descriptor.sql.BasicBinder: trace 테스트 (REST API 호출 예제) 1. 사용자 생성\ncurl -X POST http://localhost:8080/api/users \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;name\u0026#34;: \u0026#34;홍길동\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;hong@example.com\u0026#34;, \u0026#34;age\u0026#34;: 30 }\u0026#39; 응답:\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;홍길동\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;hong@example.com\u0026#34;, \u0026#34;age\u0026#34;: 30, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-02-12T10:00:00\u0026#34;, \u0026#34;updatedAt\u0026#34;: \u0026#34;2026-02-12T10:00:00\u0026#34; } 2. 전체 사용자 조회\ncurl http://localhost:8080/api/users 3. 단건 조회\ncurl http://localhost:8080/api/users/1 4. 검색\ncurl \u0026#34;http://localhost:8080/api/users/search?name=홍길동\u0026#34; 5. 수정\ncurl -X PUT http://localhost:8080/api/users/1 \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;name\u0026#34;: \u0026#34;홍길동\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;hong.updated@example.com\u0026#34;, \u0026#34;age\u0026#34;: 31 }\u0026#39; 6. 삭제\ncurl -X DELETE http://localhost:8080/api/users/1 마무리 Spring Boot로 REST API를 구현할 때 핵심 포인트는 다음과 같습니다.\n명확한 URI 설계: 자원 중심의 REST 원칙을 따르고 HTTP 메서드를 적절히 활용합니다 DTO 패턴: Entity를 직접 노출하지 않고 요청/응답 전용 객체를 사용합니다 Validation: @Valid와 Bean Validation으로 입력값을 검증합니다 예외 처리: @ControllerAdvice로 일관된 에러 응답을 제공합니다 ResponseEntity: HTTP 상태 코드와 헤더를 명시적으로 제어합니다 계층 분리: Controller - Service - Repository 구조로 책임을 분리합니다 이 가이드의 패턴을 활용하면 확장 가능하고 유지보수하기 쉬운 REST API를 구축할 수 있습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/spring-boot-rest-api/","summary":"\u003ch2 id=\"rest-api-설계-원칙\"\u003eREST API 설계 원칙\u003c/h2\u003e\n\u003cp\u003eRESTful API는 HTTP 프로토콜을 기반으로 자원(Resource)을 URI로 표현하고, HTTP 메서드로 작업을 정의하는 아키텍처 스타일입니다. Spring Boot는 REST API 개발을 위한 강력한 기능을 제공합니다.\u003c/p\u003e\n\u003ch3 id=\"rest의-핵심-원칙\"\u003eREST의 핵심 원칙\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e1. 자원 기반 URI 설계\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e명사를 사용하여 자원을 표현: \u003ccode\u003e/users\u003c/code\u003e, \u003ccode\u003e/products\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e계층 구조 표현: \u003ccode\u003e/users/{id}/orders\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e복수형 사용 권장: \u003ccode\u003e/users\u003c/code\u003e (O), \u003ccode\u003e/user\u003c/code\u003e (X)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e2. HTTP 메서드 활용\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eGET: 조회\u003c/li\u003e\n\u003cli\u003ePOST: 생성\u003c/li\u003e\n\u003cli\u003ePUT: 전체 수정\u003c/li\u003e\n\u003cli\u003ePATCH: 부분 수정\u003c/li\u003e\n\u003cli\u003eDELETE: 삭제\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e3. 상태 코드 활용\u003c/strong\u003e\u003c/p\u003e","tags":["Spring Boot","REST API","CRUD","백엔드"],"title":"Spring Boot REST API - Controller, JPA CRUD, 예외 처리"},{"content":"Spring Boot란 무엇인가? Spring Boot는 Spring 프레임워크를 기반으로 한 오픈소스 Java 프레임워크입니다. Spring의 강력한 기능을 그대로 사용하면서도, 복잡한 설정 없이 빠르게 프로덕션 레벨의 애플리케이션을 만들 수 있도록 설계되었습니다.\nSpring vs Spring Boot 전통적인 Spring Framework의 경우:\nXML 기반의 복잡한 설정 파일 필요 의존성 버전 관리를 직접 해야 함 서버 배포를 위한 WAR 파일 생성 및 Tomcat 설치 필요 설정 코드가 비즈니스 로직보다 많아지는 경우 발생 Spring Boot는 이러한 문제를 해결합니다:\n자동 설정(Auto Configuration): 클래스패스의 라이브러리를 분석하여 자동으로 설정 내장 서버: Tomcat, Jetty 등이 내장되어 있어 별도 설치 불필요 독립 실행 가능: JAR 파일 하나로 실행 가능 스타터 의존성: 관련 라이브러리를 묶어서 제공 프로덕션 준비 기능: 모니터링, 헬스 체크 등 기본 제공 프로젝트 생성하기 Spring Initializr 사용 Spring Boot 프로젝트를 시작하는 가장 쉬운 방법은 Spring Initializr를 사용하는 것입니다.\nProject: Gradle 선택 (Groovy 또는 Kotlin DSL)\nLanguage: Java, Kotlin, Groovy 중 선택\nSpring Boot 버전: 안정 버전(SNAPSHOT이 아닌) 선택\nProject Metadata:\nGroup: 회사 도메인 역순 (예: com.example) Artifact: 프로젝트 이름 (예: demo) Packaging: Jar (권장) 또는 War Java Version: 17 이상 권장 Dependencies 추가:\nSpring Web: REST API 개발 Spring Data JPA: 데이터베이스 연동 H2 Database: 테스트용 인메모리 DB Lombok: 보일러플레이트 코드 감소 IntelliJ IDEA나 VS Code에서도 Spring Initializr 플러그인을 통해 직접 생성할 수 있습니다.\nGradle을 사용한 프로젝트 구조 my-spring-boot-app/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── example/ │ │ │ └── demo/ │ │ │ └── DemoApplication.java │ │ └── resources/ │ │ ├── application.properties │ │ ├── static/ (정적 리소스: CSS, JS, 이미지) │ │ └── templates/ (Thymeleaf 등 템플릿) │ └── test/ │ └── java/ │ └── com/ │ └── example/ │ └── demo/ │ └── DemoApplicationTests.java ├── build/ (빌드 결과물) ├── build.gradle (Gradle 빌드 스크립트) ├── settings.gradle (프로젝트 설정) └── gradlew, gradlew.bat (Gradle Wrapper) 핵심 디렉토리 설명:\nsrc/main/java: Java 소스 코드 src/main/resources: 설정 파일 및 리소스 src/test/java: 테스트 코드 build.gradle: 프로젝트 의존성 및 빌드 설정 프로젝트 설정 파일 application.properties src/main/resources/application.properties는 애플리케이션 설정을 관리하는 핵심 파일입니다.\n# 서버 포트 설정 server.port=8080 # 애플리케이션 이름 spring.application.name=my-app # 데이터베이스 설정 spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= # JPA 설정 spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true # 로그 레벨 logging.level.root=INFO logging.level.com.example.demo=DEBUG application.yml (YAML 형식) YAML 형식은 계층 구조를 더 명확하게 표현할 수 있습니다:\nserver: port: 8080 spring: application: name: my-app datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: true logging: level: root: INFO com.example.demo: DEBUG 두 형식 모두 동일하게 작동하며, 선호하는 형식을 선택하면 됩니다.\n@SpringBootApplication 어노테이션 Spring Boot 애플리케이션의 진입점은 @SpringBootApplication 어노테이션이 붙은 클래스입니다.\npackage com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } } @SpringBootApplication은 세 가지 어노테이션의 조합입니다:\n@SpringBootConfiguration: Spring Boot 설정 클래스임을 명시 @EnableAutoConfiguration: 클래스패스 기반 자동 설정 활성화 @ComponentScan: 현재 패키지와 하위 패키지를 스캔하여 Spring Bean 등록 자동 설정의 원리 Spring Boot는 클래스패스에 있는 라이브러리를 분석합니다:\nspring-boot-starter-web이 있으면 → Tomcat 서버와 Spring MVC 자동 설정 spring-boot-starter-data-jpa가 있으면 → JPA와 Hibernate 자동 설정 H2 드라이버가 있으면 → 인메모리 데이터베이스 자동 설정 개발자는 비즈니스 로직에만 집중할 수 있습니다.\n내장 서버 (Embedded Server) Spring Boot의 가장 큰 장점 중 하나는 내장 서버입니다.\n내장 Tomcat spring-boot-starter-web 의존성을 추가하면 Tomcat이 자동으로 포함됩니다:\ndependencies { implementation \u0026#39;org.springframework.boot:spring-boot-starter-web\u0026#39; } 애플리케이션 실행 시 다음과 같은 로그를 볼 수 있습니다:\nTomcat started on port(s): 8080 (http) Started DemoApplication in 2.345 seconds Jetty나 Undertow로 변경 Tomcat 대신 다른 서버를 사용하려면:\ndependencies { implementation(\u0026#39;org.springframework.boot:spring-boot-starter-web\u0026#39;) { exclude module: \u0026#39;spring-boot-starter-tomcat\u0026#39; } implementation \u0026#39;org.springframework.boot:spring-boot-starter-jetty\u0026#39; } 서버 설정 커스터마이징 server.port=9090 server.servlet.context-path=/api server.compression.enabled=true server.http2.enabled=true 의존성 관리 - Starter Dependencies Spring Boot Starter는 관련 라이브러리를 묶어서 제공하는 의존성 묶음입니다.\n주요 Starter 목록 Starter 용도 spring-boot-starter-web REST API, Spring MVC spring-boot-starter-data-jpa JPA, Hibernate spring-boot-starter-security Spring Security 인증/인가 spring-boot-starter-validation Bean Validation spring-boot-starter-test JUnit, Mockito, AssertJ spring-boot-starter-actuator 모니터링, 헬스 체크 spring-boot-starter-cache 캐싱 추상화 spring-boot-starter-mail 이메일 발송 버전 관리의 편리함 Spring Boot Gradle 플러그인이 호환되는 라이브러리 버전을 자동으로 관리합니다:\nplugins { id \u0026#39;java\u0026#39; id \u0026#39;org.springframework.boot\u0026#39; version \u0026#39;3.2.0\u0026#39; id \u0026#39;io.spring.dependency-management\u0026#39; version \u0026#39;1.1.4\u0026#39; } dependencies { // 버전을 명시하지 않아도 됨 implementation \u0026#39;org.springframework.boot:spring-boot-starter-web\u0026#39; implementation \u0026#39;org.springframework.boot:spring-boot-starter-data-jpa\u0026#39; } 모든 의존성이 테스트된 호환 버전으로 자동 설정되므로 버전 충돌 걱정이 없습니다.\n프로필 관리 (Profiles) 개발, 스테이징, 프로덕션 환경마다 다른 설정이 필요합니다. Spring Boot는 프로필 기능을 제공합니다.\n프로필별 설정 파일 resources/ ├── application.properties (공통 설정) ├── application-dev.properties (개발 환경) ├── application-prod.properties (프로덕션 환경) └── application-test.properties (테스트 환경) application.properties (공통):\nspring.application.name=my-app application-dev.properties:\nserver.port=8080 spring.datasource.url=jdbc:h2:mem:devdb logging.level.root=DEBUG application-prod.properties:\nserver.port=80 spring.datasource.url=jdbc:mysql://prod-db:3306/myapp logging.level.root=WARN 프로필 활성화 방법 1. application.properties에서:\nspring.profiles.active=dev 2. 실행 시 인자로:\njava -jar myapp.jar --spring.profiles.active=prod 3. 환경 변수로:\nexport SPRING_PROFILES_ACTIVE=prod java -jar myapp.jar 4. IDE 설정: IntelliJ IDEA의 Run Configuration에서 Active profiles에 dev 입력\n프로필별 Bean 등록 @Configuration public class DataSourceConfig { @Bean @Profile(\u0026#34;dev\u0026#34;) public DataSource devDataSource() { return new H2DataSource(); } @Bean @Profile(\u0026#34;prod\u0026#34;) public DataSource prodDataSource() { return new MySQLDataSource(); } } 첫 번째 컨트롤러 만들기 이제 실제로 동작하는 REST API를 만들어보겠습니다.\n간단한 Hello World 컨트롤러 package com.example.demo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloController { @GetMapping(\u0026#34;/hello\u0026#34;) public String hello() { return \u0026#34;Hello, Spring Boot!\u0026#34;; } @GetMapping(\u0026#34;/greet\u0026#34;) public String greet(@RequestParam(defaultValue = \u0026#34;World\u0026#34;) String name) { return \u0026#34;Hello, \u0026#34; + name + \u0026#34;!\u0026#34;; } } 실행 및 테스트 1. 애플리케이션 실행:\n./gradlew bootRun 또는 IDE에서 DemoApplication 클래스의 main 메서드 실행\n2. API 테스트:\ncurl http://localhost:8080/hello # 출력: Hello, Spring Boot! curl http://localhost:8080/greet?name=John # 출력: Hello, John! JSON 응답 반환 package com.example.demo.controller; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping(\u0026#34;/api\u0026#34;) public class UserController { @GetMapping(\u0026#34;/user/{id}\u0026#34;) public Map\u0026lt;String, Object\u0026gt; getUser(@PathVariable Long id) { Map\u0026lt;String, Object\u0026gt; user = new HashMap\u0026lt;\u0026gt;(); user.put(\u0026#34;id\u0026#34;, id); user.put(\u0026#34;name\u0026#34;, \u0026#34;홍길동\u0026#34;); user.put(\u0026#34;email\u0026#34;, \u0026#34;hong@example.com\u0026#34;); return user; } @PostMapping(\u0026#34;/user\u0026#34;) public Map\u0026lt;String, Object\u0026gt; createUser(@RequestBody Map\u0026lt;String, String\u0026gt; userData) { Map\u0026lt;String, Object\u0026gt; response = new HashMap\u0026lt;\u0026gt;(); response.put(\u0026#34;status\u0026#34;, \u0026#34;success\u0026#34;); response.put(\u0026#34;data\u0026#34;, userData); return response; } } 테스트:\n# GET 요청 curl http://localhost:8080/api/user/1 # POST 요청 curl -X POST http://localhost:8080/api/user \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;김철수\u0026#34;,\u0026#34;email\u0026#34;:\u0026#34;kim@example.com\u0026#34;}\u0026#39; DTO를 사용한 타입 안전성 package com.example.demo.dto; import lombok.Data; @Data public class UserDto { private Long id; private String name; private String email; } @RestController @RequestMapping(\u0026#34;/api/v2\u0026#34;) public class UserV2Controller { @GetMapping(\u0026#34;/user/{id}\u0026#34;) public UserDto getUser(@PathVariable Long id) { UserDto user = new UserDto(); user.setId(id); user.setName(\u0026#34;홍길동\u0026#34;); user.setEmail(\u0026#34;hong@example.com\u0026#34;); return user; } } 애플리케이션 실행 방법 1. IDE에서 실행 IntelliJ IDEA나 Eclipse에서 DemoApplication 클래스를 찾아 Run 버튼 클릭\n2. Gradle로 실행 ./gradlew bootRun 3. JAR 파일로 실행 # 빌드 ./gradlew clean bootJar # 실행 java -jar build/libs/demo-0.0.1-SNAPSHOT.jar 4. 개발 중 Hot Reload spring-boot-devtools 의존성을 추가하면 코드 변경 시 자동 재시작:\ndependencies { developmentOnly \u0026#39;org.springframework.boot:spring-boot-devtools\u0026#39; } 마무리 Spring Boot는 설정보다 **관례(Convention over Configuration)**를 우선시하여 개발자가 비즈니스 로직에 집중할 수 있게 해줍니다.\n이번 글에서 다룬 내용:\n✅ Spring Boot의 개념과 장점 ✅ 프로젝트 생성 및 구조 ✅ 설정 파일 관리 (properties, yml) ✅ 자동 설정과 내장 서버 ✅ Starter 의존성 시스템 ✅ 프로필을 통한 환경별 설정 ✅ REST API 컨트롤러 작성 다음 글에서는 Spring Boot로 본격적인 REST API를 개발하고 데이터베이스 연동하는 방법을 알아보겠습니다.\n관련 글:\nSpring Boot REST API 개발하기 Spring Data JPA로 데이터베이스 다루기 Spring Security로 인증/인가 구현하기 ","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/spring-boot-introduction/","summary":"\u003ch2 id=\"spring-boot란-무엇인가\"\u003eSpring Boot란 무엇인가?\u003c/h2\u003e\n\u003cp\u003eSpring Boot는 Spring 프레임워크를 기반으로 한 오픈소스 Java 프레임워크입니다. Spring의 강력한 기능을 그대로 사용하면서도, 복잡한 설정 없이 빠르게 프로덕션 레벨의 애플리케이션을 만들 수 있도록 설계되었습니다.\u003c/p\u003e\n\u003ch3 id=\"spring-vs-spring-boot\"\u003eSpring vs Spring Boot\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e전통적인 Spring Framework\u003c/strong\u003e의 경우:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eXML 기반의 복잡한 설정 파일 필요\u003c/li\u003e\n\u003cli\u003e의존성 버전 관리를 직접 해야 함\u003c/li\u003e\n\u003cli\u003e서버 배포를 위한 WAR 파일 생성 및 Tomcat 설치 필요\u003c/li\u003e\n\u003cli\u003e설정 코드가 비즈니스 로직보다 많아지는 경우 발생\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eSpring Boot\u003c/strong\u003e는 이러한 문제를 해결합니다:\u003c/p\u003e","tags":["Spring Boot","Java","웹개발","백엔드"],"title":"Spring Boot 시작하기 - 프로젝트 생성, 구조, 첫 실행"},{"content":"Lombok이란 Java로 개발하다 보면 getter, setter, 생성자, toString, equals 같은 반복적인 코드를 매번 작성해야 합니다. Lombok은 어노테이션만으로 이런 보일러플레이트 코드를 컴파일 시점에 자동 생성해주는 라이브러리입니다.\nLombok 없이 vs 있을 때 Lombok 없이:\npublic class User { private Long id; private String name; private String email; public User() {} public User(Long id, String name, String email) { this.id = id; this.name = name; this.email = email; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } @Override public String toString() { return \u0026#34;User{id=\u0026#34; + id + \u0026#34;, name=\u0026#34; + name + \u0026#34;, email=\u0026#34; + email + \u0026#34;}\u0026#34;; } @Override public boolean equals(Object o) { /* ... */ } @Override public int hashCode() { /* ... */ } } Lombok 사용:\n@Data @NoArgsConstructor @AllArgsConstructor public class User { private Long id; private String name; private String email; } 40줄이 넘던 코드가 7줄로 줄어듭니다.\n설치 방법 Gradle dependencies { compileOnly \u0026#39;org.projectlombok:lombok\u0026#39; annotationProcessor \u0026#39;org.projectlombok:lombok\u0026#39; } Maven \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.projectlombok\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lombok\u0026lt;/artifactId\u0026gt; \u0026lt;optional\u0026gt;true\u0026lt;/optional\u0026gt; \u0026lt;/dependency\u0026gt; IDE 설정 IntelliJ IDEA: Settings → Plugins → Lombok 플러그인 설치 후 Settings → Build → Compiler → Annotation Processors → Enable annotation processing 체크 VS Code: Language Support for Java 확장이 Lombok을 자동 지원 핵심 어노테이션 @Getter / @Setter 필드의 getter/setter 메서드를 자동 생성합니다.\n@Getter @Setter public class Product { private Long id; private String name; private int price; } // 사용 Product p = new Product(); p.setName(\u0026#34;노트북\u0026#34;); System.out.println(p.getName()); // 노트북 클래스 레벨에 붙이면 모든 필드에 적용되고, 필드 레벨에 붙이면 해당 필드에만 적용됩니다.\n@Getter public class Product { private Long id; private String name; @Setter private int price; // price만 setter 생성 } 접근 제한도 가능합니다:\n@Getter(AccessLevel.PROTECTED) private String internalCode; @NoArgsConstructor / @AllArgsConstructor / @RequiredArgsConstructor 생성자를 자동 생성합니다.\n@NoArgsConstructor // 기본 생성자: User() @AllArgsConstructor // 전체 필드 생성자: User(id, name, email) public class User { private Long id; private String name; private String email; } @RequiredArgsConstructor는 final 필드와 @NonNull 필드만으로 생성자를 만듭니다.\n@RequiredArgsConstructor public class UserService { private final UserRepository userRepository; // 생성자에 포함 private final EmailService emailService; // 생성자에 포함 private String tempValue; // 생성자에 미포함 } // 자동 생성되는 코드: // public UserService(UserRepository userRepository, EmailService emailService) { // this.userRepository = userRepository; // this.emailService = emailService; // } Spring에서 가장 많이 사용되는 패턴입니다. @Autowired 없이 생성자 주입이 가능합니다.\n@Data @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor를 한 번에 적용합니다.\n@Data public class UserDto { private Long id; private String name; private String email; } // 다음이 모두 자동 생성: // - 모든 필드의 getter/setter // - toString() // - equals(), hashCode() // - final 필드 기반 생성자 DTO나 간단한 데이터 클래스에 적합합니다.\n@Builder 빌더 패턴을 자동 생성합니다. 필드가 많은 객체를 가독성 좋게 생성할 수 있습니다.\n@Getter @Builder public class Order { private Long orderId; private String customerName; private String product; private int quantity; private double totalPrice; private String shippingAddress; } // 사용 Order order = Order.builder() .orderId(1L) .customerName(\u0026#34;홍길동\u0026#34;) .product(\u0026#34;노트북\u0026#34;) .quantity(2) .totalPrice(2400000) .shippingAddress(\u0026#34;서울시 강남구\u0026#34;) .build(); 기본값을 지정하려면 @Builder.Default를 사용합니다:\n@Getter @Builder public class Order { private Long orderId; private String customerName; @Builder.Default private String status = \u0026#34;PENDING\u0026#34;; @Builder.Default private LocalDateTime createdAt = LocalDateTime.now(); } @Slf4j 로깅을 위한 log 필드를 자동 생성합니다.\n@Slf4j @Service public class PaymentService { public void processPayment(Payment payment) { log.info(\u0026#34;결제 처리 시작: {}\u0026#34;, payment.getId()); try { // 결제 로직 log.debug(\u0026#34;결제 상세: amount={}, method={}\u0026#34;, payment.getAmount(), payment.getMethod()); } catch (Exception e) { log.error(\u0026#34;결제 실패: {}\u0026#34;, payment.getId(), e); } } } // 자동 생성되는 코드: // private static final org.slf4j.Logger log = // org.slf4j.LoggerFactory.getLogger(PaymentService.class); Log4j2를 사용한다면 @Log4j2를 대신 사용합니다.\n@ToString toString() 메서드를 자동 생성합니다.\n@ToString public class User { private Long id; private String name; @ToString.Exclude private String password; // toString에서 제외 } // User(id=1, name=홍길동) @EqualsAndHashCode equals()와 hashCode()를 자동 생성합니다.\n@EqualsAndHashCode public class Product { private Long id; private String name; private int price; } // 특정 필드만 사용 @EqualsAndHashCode(of = \u0026#34;id\u0026#34;) public class Product { private Long id; private String name; private int price; } @Value 불변(Immutable) 클래스를 만듭니다. 모든 필드가 private final이 되고 setter가 생성되지 않습니다.\n@Value public class Money { String currency; BigDecimal amount; } // 자동 생성: getter, toString, equals, hashCode, AllArgsConstructor // setter는 생성되지 않음 (불변 객체) Money price = new Money(\u0026#34;KRW\u0026#34;, new BigDecimal(\u0026#34;50000\u0026#34;)); // price.setAmount(...) → 컴파일 에러 Spring Boot에서의 실전 패턴 Service - @RequiredArgsConstructor로 생성자 주입 Spring에서 의존성 주입의 권장 방식은 생성자 주입입니다. Lombok과 함께 사용하면 매우 간결해집니다.\n// Lombok 없이 @Service public class OrderService { private final OrderRepository orderRepository; private final PaymentService paymentService; private final NotificationService notificationService; @Autowired public OrderService(OrderRepository orderRepository, PaymentService paymentService, NotificationService notificationService) { this.orderRepository = orderRepository; this.paymentService = paymentService; this.notificationService = notificationService; } } // Lombok 사용 @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final PaymentService paymentService; private final NotificationService notificationService; } 생성자가 하나뿐이면 Spring이 자동으로 @Autowired를 적용합니다.\nEntity - @Getter + @NoArgsConstructor JPA Entity에서는 @Data 대신 @Getter와 @NoArgsConstructor를 사용합니다.\n@Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(nullable = false, unique = true) private String email; @Builder public User(String name, String email) { this.name = name; this.email = email; } } @NoArgsConstructor(access = AccessLevel.PROTECTED) - JPA 스펙상 기본 생성자가 필요하지만, 외부에서 빈 객체를 만드는 것은 막습니다.\nDTO - @Getter + @Builder 또는 @Data 요청/응답 DTO에서는 용도에 따라 다르게 사용합니다.\n// 요청 DTO - setter 필요 (Jackson 역직렬화) @Getter @NoArgsConstructor public class CreateUserRequest { private String name; private String email; } // 응답 DTO - 불변 객체 @Getter @Builder public class UserResponse { private Long id; private String name; private String email; private LocalDateTime createdAt; public static UserResponse from(User user) { return UserResponse.builder() .id(user.getId()) .name(user.getName()) .email(user.getEmail()) .createdAt(user.getCreatedAt()) .build(); } } Controller + Service + Repository 전체 패턴 @RestController @RequestMapping(\u0026#34;/api/users\u0026#34;) @RequiredArgsConstructor public class UserController { private final UserService userService; @PostMapping public UserResponse createUser(@RequestBody CreateUserRequest request) { return userService.createUser(request); } } @Service @RequiredArgsConstructor @Slf4j public class UserService { private final UserRepository userRepository; public UserResponse createUser(CreateUserRequest request) { log.info(\u0026#34;사용자 생성: {}\u0026#34;, request.getName()); User user = User.builder() .name(request.getName()) .email(request.getEmail()) .build(); user = userRepository.save(user); return UserResponse.from(user); } } @RequiredArgsConstructor로 의존성 주입, @Slf4j로 로깅, @Builder로 객체 생성까지 Lombok이 Spring Boot 전반에 걸쳐 사용됩니다.\n주의사항 @Data를 Entity에 사용하면 안 되는 이유 @Data는 @EqualsAndHashCode를 포함하며, 모든 필드를 비교합니다. JPA Entity에서 이것이 문제가 됩니다:\n// 위험한 코드 @Data @Entity public class Order { @Id private Long id; @ManyToOne private User user; // equals() 호출 시 User도 로딩 → N+1 문제 @OneToMany(mappedBy = \u0026#34;order\u0026#34;) private List\u0026lt;OrderItem\u0026gt; items; // 순환 참조 → StackOverflowError } 해결: Entity에는 @Getter + @NoArgsConstructor만 사용하고, 필요하면 @EqualsAndHashCode(of = \u0026quot;id\u0026quot;)를 명시합니다.\n@Builder와 @NoArgsConstructor 함께 사용 @Builder만 사용하면 @AllArgsConstructor를 암묵적으로 생성하므로, @NoArgsConstructor와 충돌합니다.\n// 컴파일 에러 @Builder @NoArgsConstructor public class User { ... } // 해결: @AllArgsConstructor를 명시 @Builder @NoArgsConstructor @AllArgsConstructor public class User { ... } @ToString과 순환 참조 양방향 관계에서 @ToString이 순환 참조를 일으킬 수 있습니다.\n@Entity @Getter @ToString public class Team { @OneToMany(mappedBy = \u0026#34;team\u0026#34;) private List\u0026lt;Member\u0026gt; members; // Member.toString() → Team.toString() → 무한루프 } // 해결 @ToString(exclude = \u0026#34;members\u0026#34;) public class Team { ... } 어노테이션 선택 가이드 사용처 권장 조합 이유 JPA Entity @Getter + @NoArgsConstructor(PROTECTED) + @Builder setter 방지, equals/hashCode 문제 회피 요청 DTO @Getter + @NoArgsConstructor Jackson 역직렬화에 기본 생성자 필요 응답 DTO @Getter + @Builder 불변 객체로 안전하게 반환 Service/Controller @RequiredArgsConstructor + @Slf4j 생성자 주입 + 로깅 간단한 데이터 클래스 @Data 빠른 개발, Entity가 아닌 경우만 불변 객체 @Value 모든 필드 final, setter 없음 마무리 Lombok은 Java 개발에서 반복적인 보일러플레이트 코드를 줄여주는 필수 도구입니다.\n핵심 정리:\n@Getter/@Setter: 접근자 자동 생성 @RequiredArgsConstructor: Spring 생성자 주입의 핵심 @Builder: 가독성 좋은 객체 생성 @Slf4j: 로깅 설정 간소화 @Data: DTO용 올인원 (Entity에는 사용 금지) @Value: 불변 객체 생성 실전 원칙:\nEntity에는 @Data 대신 @Getter + @NoArgsConstructor Service/Controller에는 @RequiredArgsConstructor + @Slf4j DTO는 용도에 따라 @Builder 또는 @Data 선택 양방향 관계에서는 @ToString.Exclude로 순환 참조 방지 ","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/java-lombok/","summary":"\u003ch2 id=\"lombok이란\"\u003eLombok이란\u003c/h2\u003e\n\u003cp\u003eJava로 개발하다 보면 getter, setter, 생성자, toString, equals 같은 반복적인 코드를 매번 작성해야 합니다. Lombok은 어노테이션만으로 이런 보일러플레이트 코드를 컴파일 시점에 자동 생성해주는 라이브러리입니다.\u003c/p\u003e\n\u003ch3 id=\"lombok-없이-vs-있을-때\"\u003eLombok 없이 vs 있을 때\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eLombok 없이:\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#f9e2af\"\u003eUser\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e Long id;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e String name;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e String email;\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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003eUser\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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003eUser\u003c/span\u003e(Long id, String name, String email) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#cba6f7\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003eid\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e id;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#cba6f7\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003ename\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e name;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#cba6f7\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003eemail\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e email;\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:#f38ba8\"\u003epublic\u003c/span\u003e Long \u003cspan style=\"color:#89b4fa\"\u003egetId\u003c/span\u003e() { \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e id; }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003evoid\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003esetId\u003c/span\u003e(Long id) { \u003cspan style=\"color:#cba6f7\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003eid\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e id; }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e String \u003cspan style=\"color:#89b4fa\"\u003egetName\u003c/span\u003e() { \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e name; }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003evoid\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003esetName\u003c/span\u003e(String name) { \u003cspan style=\"color:#cba6f7\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003ename\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e name; }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e String \u003cspan style=\"color:#89b4fa\"\u003egetEmail\u003c/span\u003e() { \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e email; }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003evoid\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003esetEmail\u003c/span\u003e(String email) { \u003cspan style=\"color:#cba6f7\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003eemail\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e email; }\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:#89b4fa;font-weight:bold\"\u003e@Override\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e String \u003cspan style=\"color:#89b4fa\"\u003etoString\u003c/span\u003e() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;User{id=\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e id \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;, name=\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e name \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e \u003cspan style=\"color:#a6e3a1\"\u003e\u0026#34;, email=\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e email \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e+\u003c/span\u003e \u003cspan style=\"color:#a6e3a1\"\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:#89b4fa;font-weight:bold\"\u003e@Override\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eboolean\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003eequals\u003c/span\u003e(Object o) { \u003cspan style=\"color:#6c7086;font-style:italic\"\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:#89b4fa;font-weight:bold\"\u003e@Override\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eint\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003ehashCode\u003c/span\u003e() { \u003cspan style=\"color:#6c7086;font-style:italic\"\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\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eLombok 사용:\u003c/strong\u003e\u003c/p\u003e","tags":["Java","Lombok","Spring Boot","생산성"],"title":"Lombok 핵심 어노테이션 - @Data, @Builder, Spring Boot 실전 패턴"},{"content":"개요 Java는 6개월마다 새로운 버전을 출시하며 지속적으로 발전하고 있습니다. Java 17은 LTS(Long-Term Support) 버전으로, 이후 Java 21도 LTS로 지정되었습니다. 이 글에서는 Java 17 이상에서 도입된 주요 기능들을 실제 코드 예제와 함께 살펴보겠습니다.\n1. Sealed Classes (봉인 클래스) Sealed Classes는 Java 17에서 정식 기능으로 추가되었으며, 클래스 계층 구조를 명시적으로 제어할 수 있게 해줍니다. 어떤 클래스가 특정 클래스를 상속할 수 있는지를 제한함으로써 더 안전하고 예측 가능한 코드를 작성할 수 있습니다.\n기본 문법 public sealed class Shape permits Circle, Rectangle, Triangle { // 공통 메서드 } public final class Circle extends Shape { private final double radius; public Circle(double radius) { this.radius = radius; } public double area() { return Math.PI * radius * radius; } } public final class Rectangle extends Shape { private final double width; private final double height; public Rectangle(double width, double height) { this.width = width; this.height = height; } public double area() { return width * height; } } public non-sealed class Triangle extends Shape { // 다른 클래스가 Triangle을 상속할 수 있음 } 주요 특징 sealed 키워드로 봉인 클래스 선언 permits 절로 허용할 하위 클래스 명시 하위 클래스는 반드시 final, sealed, non-sealed 중 하나로 선언 도메인 모델링 시 타입 안전성 향상 2. Pattern Matching (패턴 매칭) instanceof 패턴 매칭 기존의 instanceof 검사 후 캐스팅하는 번거로운 과정을 간소화합니다.\n// 기존 방식 if (obj instanceof String) { String str = (String) obj; System.out.println(str.length()); } // 패턴 매칭 방식 (Java 16+) if (obj instanceof String str) { System.out.println(str.length()); } // 조건과 함께 사용 if (obj instanceof String str \u0026amp;\u0026amp; str.length() \u0026gt; 5) { System.out.println(\u0026#34;긴 문자열: \u0026#34; + str); } Switch 패턴 매칭 Java 21에서는 switch 문에서도 패턴 매칭을 사용할 수 있습니다.\npublic String processShape(Shape shape) { return switch (shape) { case Circle c -\u0026gt; \u0026#34;원의 넓이: \u0026#34; + c.area(); case Rectangle r -\u0026gt; \u0026#34;사각형의 넓이: \u0026#34; + r.area(); case Triangle t -\u0026gt; \u0026#34;삼각형의 넓이: \u0026#34; + t.area(); case null -\u0026gt; \u0026#34;도형이 없습니다\u0026#34;; }; } // when 절을 사용한 조건부 패턴 public String classifyNumber(Object obj) { return switch (obj) { case Integer i when i \u0026gt; 0 -\u0026gt; \u0026#34;양수\u0026#34;; case Integer i when i \u0026lt; 0 -\u0026gt; \u0026#34;음수\u0026#34;; case Integer i -\u0026gt; \u0026#34;0\u0026#34;; case Double d -\u0026gt; \u0026#34;실수: \u0026#34; + d; case String s -\u0026gt; \u0026#34;문자열: \u0026#34; + s; default -\u0026gt; \u0026#34;알 수 없는 타입\u0026#34;; }; } 3. Records (레코드) Records는 불변 데이터를 간결하게 표현하기 위한 특별한 클래스입니다. Java 16에서 정식 기능이 되었습니다.\n기본 사용법 // Record 정의 public record Person(String name, int age, String email) { // 자동 생성: 생성자, getter, equals(), hashCode(), toString() } // 사용 예제 Person person = new Person(\u0026#34;김철수\u0026#34;, 30, \u0026#34;kim@example.com\u0026#34;); System.out.println(person.name()); // 김철수 System.out.println(person.age()); // 30 System.out.println(person); // Person[name=김철수, age=30, email=kim@example.com] 고급 기능 public record Point(int x, int y) { // Compact 생성자 - 유효성 검사 public Point { if (x \u0026lt; 0 || y \u0026lt; 0) { throw new IllegalArgumentException(\u0026#34;좌표는 음수일 수 없습니다\u0026#34;); } } // 추가 메서드 public double distanceFromOrigin() { return Math.sqrt(x * x + y * y); } // 정적 팩토리 메서드 public static Point origin() { return new Point(0, 0); } } // 중첩 Record public record Order( String orderId, Customer customer, List\u0026lt;Item\u0026gt; items ) { public record Customer(String name, String email) {} public record Item(String productId, int quantity, double price) {} } 4. Text Blocks (텍스트 블록) Java 15에서 정식 기능이 된 Text Blocks는 여러 줄의 문자열을 깔끔하게 작성할 수 있게 해줍니다.\n// 기존 방식 String html = \u0026#34;\u0026lt;html\u0026gt;\\n\u0026#34; + \u0026#34; \u0026lt;body\u0026gt;\\n\u0026#34; + \u0026#34; \u0026lt;p\u0026gt;Hello, World!\u0026lt;/p\u0026gt;\\n\u0026#34; + \u0026#34; \u0026lt;/body\u0026gt;\\n\u0026#34; + \u0026#34;\u0026lt;/html\u0026gt;\\n\u0026#34;; // Text Blocks 방식 String html = \u0026#34;\u0026#34;\u0026#34; \u0026lt;html\u0026gt; \u0026lt;body\u0026gt; \u0026lt;p\u0026gt;Hello, World!\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; \u0026#34;\u0026#34;\u0026#34;; // JSON 예제 String json = \u0026#34;\u0026#34;\u0026#34; { \u0026#34;name\u0026#34;: \u0026#34;김철수\u0026#34;, \u0026#34;age\u0026#34;: 30, \u0026#34;address\u0026#34;: { \u0026#34;city\u0026#34;: \u0026#34;서울\u0026#34;, \u0026#34;country\u0026#34;: \u0026#34;한국\u0026#34; } } \u0026#34;\u0026#34;\u0026#34;; // SQL 쿼리 String query = \u0026#34;\u0026#34;\u0026#34; SELECT u.name, u.email, o.order_date FROM users u JOIN orders o ON u.id = o.user_id WHERE o.status = \u0026#39;COMPLETED\u0026#39; ORDER BY o.order_date DESC \u0026#34;\u0026#34;\u0026#34;; // 변수 보간 String name = \u0026#34;홍길동\u0026#34;; int age = 25; String message = \u0026#34;\u0026#34;\u0026#34; 안녕하세요, %s님! 회원님의 나이는 %d세입니다. \u0026#34;\u0026#34;\u0026#34;.formatted(name, age); 5. Virtual Threads (가상 스레드) Java 21의 가장 혁신적인 기능 중 하나로, 경량 스레드를 통해 수백만 개의 동시 작업을 효율적으로 처리할 수 있습니다.\n기본 사용법 // 기존 플랫폼 스레드 Thread platformThread = new Thread(() -\u0026gt; { System.out.println(\u0026#34;플랫폼 스레드\u0026#34;); }); platformThread.start(); // Virtual Thread 생성 Thread virtualThread = Thread.startVirtualThread(() -\u0026gt; { System.out.println(\u0026#34;가상 스레드\u0026#34;); }); // ExecutorService 사용 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i \u0026lt; 10000; i++) { int taskId = i; executor.submit(() -\u0026gt; { System.out.println(\u0026#34;작업 \u0026#34; + taskId + \u0026#34; 실행\u0026#34;); Thread.sleep(1000); return taskId; }); } } // 자동으로 종료 대기 실전 예제 - 동시 HTTP 요청 public class VirtualThreadExample { public static void fetchMultipleUrls(List\u0026lt;String\u0026gt; urls) { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { List\u0026lt;Future\u0026lt;String\u0026gt;\u0026gt; futures = urls.stream() .map(url -\u0026gt; executor.submit(() -\u0026gt; fetchUrl(url))) .toList(); for (Future\u0026lt;String\u0026gt; future : futures) { try { String result = future.get(); System.out.println(\u0026#34;결과: \u0026#34; + result); } catch (Exception e) { e.printStackTrace(); } } } } private static String fetchUrl(String url) throws Exception { // HTTP 요청 시뮬레이션 Thread.sleep(100); return \u0026#34;데이터 from \u0026#34; + url; } } Virtual Thread 장점 메모리 효율성: 플랫폼 스레드보다 훨씬 적은 메모리 사용 확장성: 수백만 개의 동시 작업 처리 가능 간단한 프로그래밍 모델: 기존 스레드 API와 호환 I/O 집약적 작업에 최적화 6. var 키워드 (지역 변수 타입 추론) Java 10에서 도입된 var 키워드는 지역 변수의 타입을 추론하여 코드를 더 간결하게 만듭니다.\n// 기존 방식 HashMap\u0026lt;String, List\u0026lt;Integer\u0026gt;\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); ArrayList\u0026lt;String\u0026gt; list = new ArrayList\u0026lt;\u0026gt;(); // var 사용 var map = new HashMap\u0026lt;String, List\u0026lt;Integer\u0026gt;\u0026gt;(); var list = new ArrayList\u0026lt;String\u0026gt;(); // 복잡한 타입도 간결하게 var result = apiService.fetchData() .map(this::transform) .filter(Objects::nonNull) .collect(Collectors.toList()); // for-each에서 사용 var numbers = List.of(1, 2, 3, 4, 5); for (var number : numbers) { System.out.println(number); } // try-with-resources에서 사용 try (var reader = new BufferedReader(new FileReader(\u0026#34;file.txt\u0026#34;))) { var line = reader.readLine(); System.out.println(line); } var 사용 시 주의사항 // 좋은 사용 예 var userName = getUserName(); // 메서드 이름이 타입을 암시 var activeUsers = filterActiveUsers(); // 반환 타입이 명확 // 피해야 할 사용 예 var data = getData(); // 타입이 불분명 var x = process(); // 의미 없는 변수명 // 불가능한 사용 // var noInit; // 초기화 필수 // var nullValue = null; // null 불가 // var lambda = x -\u0026gt; x; // 람다 불가 7. 기타 유용한 기능들 Stream API 개선 // takeWhile / dropWhile (Java 9+) List\u0026lt;Integer\u0026gt; numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8); var result = numbers.stream() .takeWhile(n -\u0026gt; n \u0026lt; 5) // [1, 2, 3, 4] .toList(); // toList() 편의 메서드 (Java 16+) var squares = numbers.stream() .map(n -\u0026gt; n * n) .toList(); // 불변 리스트 반환 Optional 개선 // ifPresentOrElse (Java 9+) Optional\u0026lt;String\u0026gt; opt = Optional.of(\u0026#34;Hello\u0026#34;); opt.ifPresentOrElse( value -\u0026gt; System.out.println(\u0026#34;값: \u0026#34; + value), () -\u0026gt; System.out.println(\u0026#34;값이 없습니다\u0026#34;) ); // or (Java 9+) Optional\u0026lt;String\u0026gt; result = Optional.empty() .or(() -\u0026gt; Optional.of(\u0026#34;기본값\u0026#34;)); String 메서드 추가 // isBlank() (Java 11+) \u0026#34; \u0026#34;.isBlank(); // true // lines() (Java 11+) String multiline = \u0026#34;첫째\\n둘째\\n셋째\u0026#34;; multiline.lines() .forEach(System.out::println); // repeat() (Java 11+) \u0026#34;*\u0026#34;.repeat(10); // \u0026#34;**********\u0026#34; // indent() (Java 12+) String text = \u0026#34;Hello\\nWorld\u0026#34;; text.indent(4); // 각 줄에 4칸 들여쓰기 마무리 Java 17 이상의 최신 버전들은 개발자의 생산성을 크게 향상시키는 다양한 기능을 제공합니다. Sealed Classes로 타입 안전성을 높이고, Records로 데이터 클래스를 간결하게 작성하며, Virtual Threads로 고성능 동시성 프로그래밍을 구현할 수 있습니다.\n특히 Java 21 LTS는 Virtual Threads와 Pattern Matching의 완성도가 높아져, 프로덕션 환경에서 적극적으로 활용할 만한 가치가 있습니다. 기존 Java 8이나 11을 사용 중이라면, 최신 LTS 버전으로의 마이그레이션을 검토해보시기 바랍니다.\n이러한 최신 기능들을 활용하면 더 안전하고, 읽기 쉬우며, 성능이 뛰어난 Java 애플리케이션을 개발할 수 있습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/java-modern-features/","summary":"\u003ch2 id=\"개요\"\u003e개요\u003c/h2\u003e\n\u003cp\u003eJava는 6개월마다 새로운 버전을 출시하며 지속적으로 발전하고 있습니다. Java 17은 LTS(Long-Term Support) 버전으로, 이후 Java 21도 LTS로 지정되었습니다. 이 글에서는 Java 17 이상에서 도입된 주요 기능들을 실제 코드 예제와 함께 살펴보겠습니다.\u003c/p\u003e\n\u003ch2 id=\"1-sealed-classes-봉인-클래스\"\u003e1. Sealed Classes (봉인 클래스)\u003c/h2\u003e\n\u003cp\u003eSealed Classes는 Java 17에서 정식 기능으로 추가되었으며, 클래스 계층 구조를 명시적으로 제어할 수 있게 해줍니다. 어떤 클래스가 특정 클래스를 상속할 수 있는지를 제한함으로써 더 안전하고 예측 가능한 코드를 작성할 수 있습니다.\u003c/p\u003e\n\u003ch3 id=\"기본-문법\"\u003e기본 문법\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003esealed\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#f9e2af\"\u003eShape\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    permits Circle, Rectangle, Triangle {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#6c7086;font-style:italic\"\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\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003efinal\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#f9e2af\"\u003eCircle\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eextends\u003c/span\u003e Shape {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003efinal\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003edouble\u003c/span\u003e radius;\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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003eCircle\u003c/span\u003e(\u003cspan style=\"color:#f38ba8\"\u003edouble\u003c/span\u003e radius) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#cba6f7\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003eradius\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e radius;\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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003edouble\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003earea\u003c/span\u003e() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e Math.\u003cspan style=\"color:#89b4fa\"\u003ePI\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e*\u003c/span\u003e radius \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e*\u003c/span\u003e radius;\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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003efinal\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#f9e2af\"\u003eRectangle\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eextends\u003c/span\u003e Shape {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003efinal\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003edouble\u003c/span\u003e width;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f38ba8\"\u003eprivate\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003efinal\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003edouble\u003c/span\u003e height;\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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003eRectangle\u003c/span\u003e(\u003cspan style=\"color:#f38ba8\"\u003edouble\u003c/span\u003e width, \u003cspan style=\"color:#f38ba8\"\u003edouble\u003c/span\u003e height) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#cba6f7\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003ewidth\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e width;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#cba6f7\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#89b4fa\"\u003eheight\u003c/span\u003e \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e=\u003c/span\u003e height;\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:#f38ba8\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003edouble\u003c/span\u003e \u003cspan style=\"color:#89b4fa\"\u003earea\u003c/span\u003e() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#cba6f7\"\u003ereturn\u003c/span\u003e width \u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e*\u003c/span\u003e height;\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:#f38ba8\"\u003epublic\u003c/span\u003e non\u003cspan style=\"color:#89dceb;font-weight:bold\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#f38ba8\"\u003esealed\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#f9e2af\"\u003eTriangle\u003c/span\u003e \u003cspan style=\"color:#f38ba8\"\u003eextends\u003c/span\u003e Shape {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#6c7086;font-style:italic\"\u003e// 다른 클래스가 Triangle을 상속할 수 있음\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\u003ch3 id=\"주요-특징\"\u003e주요 특징\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003esealed\u003c/code\u003e 키워드로 봉인 클래스 선언\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003epermits\u003c/code\u003e 절로 허용할 하위 클래스 명시\u003c/li\u003e\n\u003cli\u003e하위 클래스는 반드시 \u003ccode\u003efinal\u003c/code\u003e, \u003ccode\u003esealed\u003c/code\u003e, \u003ccode\u003enon-sealed\u003c/code\u003e 중 하나로 선언\u003c/li\u003e\n\u003cli\u003e도메인 모델링 시 타입 안전성 향상\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"2-pattern-matching-패턴-매칭\"\u003e2. Pattern Matching (패턴 매칭)\u003c/h2\u003e\n\u003ch3 id=\"instanceof-패턴-매칭\"\u003einstanceof 패턴 매칭\u003c/h3\u003e\n\u003cp\u003e기존의 instanceof 검사 후 캐스팅하는 번거로운 과정을 간소화합니다.\u003c/p\u003e","tags":["Java","Java17","최신기능","프로그래밍"],"title":"Java 17+ 최신 문법 - Record, Sealed Class, Pattern Matching, Virtual Thread"},{"content":"멀티 에이전트 협업의 필요성 복잡한 소프트웨어 프로젝트는 단일 개발자가 모든 것을 처리하기 어렵습니다. 프론트엔드, 백엔드, 데이터베이스, 테스트, 배포 등 각 영역은 서로 다른 전문성을 요구합니다. OMC의 Team과 Pipeline 모드는 이러한 현실을 AI 에이전트 세계에 그대로 적용합니다.\n여러 전문 에이전트가 각자의 역할을 수행하고, 서로 통신하며, 의존성을 관리하고, 최종 목표를 향해 협업합니다. 마치 실제 개발팀처럼 작동하는 AI 팀을 구성할 수 있습니다.\nTeam 모드 - 조직화된 협업 Team 모드는 Claude Code의 네이티브 팀 기능을 활용하여 여러 에이전트를 조직화하고 협업시킵니다.\n기본 사용법 /team 5 \u0026#34;풀스택 블로그 애플리케이션 구축: React 프론트엔드, Express API, PostgreSQL 데이터베이스, 인증, 배포 설정\u0026#34; 숫자 5는 팀원 수를 의미합니다. Team 모드는 리드 에이전트를 포함하여 총 6개의 에이전트로 구성됩니다.\nTeam 워크플로우 Team 모드는 리드 에이전트가 중심이 되어 작업을 조율합니다:\n작업 분해 (team-lead)\n사용자 요청을 구체적인 하위 작업으로 분할 각 작업의 우선순위와 의존성 파악 필요한 에이전트 역할 결정 팀 생성\nTeamCreate로 팀 인스턴스 생성 각 팀원에게 고유한 역할 부여 (worker-1, worker-2, ...) 태스크 할당\nTaskCreate로 작업 생성 의존성 설정 (addBlockedBy) 각 팀원에게 작업 할당 병렬 실행\n블로킹되지 않은 작업들을 팀원들이 동시에 수행 각 팀원은 할당된 작업을 독립적으로 완료 진행 모니터링\nTaskList로 전체 진행 상황 추적 완료된 작업은 의존 작업 언블로킹 막힌 작업은 team-lead가 해결 결과 통합\n모든 작업 완료 확인 통합 테스트 실행 최종 검증 및 보고 Staged Pipeline Team 모드는 내부적으로 단계별 파이프라인을 실행합니다:\nteam-plan → team-prd → team-exec → team-verify → team-fix\n각 단계는 전문화된 에이전트들을 사용합니다:\nteam-plan 단계 explore (haiku): 코드베이스 탐색, 관련 파일 식별 planner (opus): 작업 계획 수립, 순서 정의 analyst (선택): 요구사항 명확화 architect (선택): 시스템 설계 검토 team-prd 단계 analyst (opus): 상세 요구사항과 수용 기준 작성 product-manager (선택): 문제 프레이밍, 사용자 스토리 critic (선택): 계획 검증 및 리스크 식별 team-exec 단계 executor (sonnet): 핵심 코드 구현 designer (sonnet): UI/UX 컴포넌트 작업 build-fixer (sonnet): 빌드 및 타입 오류 해결 writer (haiku): 문서 작성 test-engineer (sonnet): 테스트 코드 작성 deep-executor (opus): 복잡한 자율 작업 team-verify 단계 verifier (sonnet): 완성도 검증 security-reviewer (sonnet): 보안 검토 code-reviewer (opus): 코드 품질 리뷰 quality-reviewer (sonnet): 유지보수성 평가 performance-reviewer (sonnet): 성능 분석 team-fix 단계 executor (sonnet): 일반 버그 수정 build-fixer (sonnet): 빌드 문제 해결 debugger (sonnet): 복잡한 버그 분석 및 수정 단계 전환 규칙 team-plan → team-prd: 계획과 분해 완료 시 team-prd → team-exec: 수용 기준과 범위 명확화 시 team-exec → team-verify: 모든 실행 작업이 완료 상태 도달 시 team-verify → team-fix | complete | failed: 검증 결과에 따라 분기 team-fix → team-exec | team-verify | complete | failed: 수정 후 재실행, 재검증, 또는 종료 team-fix 루프는 최대 시도 횟수로 제한되며, 초과 시 failed 상태로 전환됩니다.\n팀원 간 통신 Team 모드에서 에이전트들은 SendMessage 도구로 통신합니다.\n직접 메시지 특정 팀원에게 메시지 전송:\nSendMessage( type=\u0026#34;message\u0026#34;, recipient=\u0026#34;worker-2\u0026#34;, content=\u0026#34;Task #3 완료했습니다. API 엔드포인트 /api/posts가 준비되었으니 프론트엔드 연동 시작하셔도 됩니다.\u0026#34;, summary=\u0026#34;API 완료 알림\u0026#34; ) 브로드캐스트 전체 팀원에게 중요한 공지:\nSendMessage( type=\u0026#34;broadcast\u0026#34;, content=\u0026#34;데이터베이스 스키마가 변경되었습니다. 모든 팀원은 마이그레이션을 실행해주세요.\u0026#34;, summary=\u0026#34;스키마 변경 공지\u0026#34; ) 브로드캐스트는 비용이 높으므로 (팀원 수만큼 메시지 전송) 정말 필요한 경우만 사용합니다.\n셧다운 요청 작업 완료 후 팀원에게 종료 요청:\nSendMessage( type=\u0026#34;shutdown_request\u0026#34;, recipient=\u0026#34;worker-3\u0026#34;, content=\u0026#34;할당된 모든 작업이 완료되었습니다. 종료해주세요.\u0026#34; ) 팀원은 shutdown_response로 승인 또는 거부:\nSendMessage( type=\u0026#34;shutdown_response\u0026#34;, request_id=\u0026#34;abc-123\u0026#34;, approve=true ) 태스크 관리 Team 모드의 핵심은 태스크 시스템입니다.\n태스크 생성 TaskCreate( subject=\u0026#34;API 엔드포인트 구현\u0026#34;, description=\u0026#34;RESTful API 엔드포인트 /api/posts CRUD 작업 구현. Express 라우터 사용, PostgreSQL 연동, 입력 검증 포함.\u0026#34;, activeForm=\u0026#34;API 엔드포인트 구현 중\u0026#34; ) subject: 작업 제목 (명령형: \u0026quot;구현\u0026quot;, \u0026quot;수정\u0026quot;, \u0026quot;테스트\u0026quot;) description: 상세 설명과 수용 기준 activeForm: 진행 중 표시 문구 (현재진행형: \u0026quot;구현 중\u0026quot;, \u0026quot;테스트 중\u0026quot;) 태스크 상태 변경 작업 시작:\nTaskUpdate(taskId=\u0026#34;1\u0026#34;, status=\u0026#34;in_progress\u0026#34;) 작업 완료:\nTaskUpdate(taskId=\u0026#34;1\u0026#34;, status=\u0026#34;completed\u0026#34;) 작업 삭제:\nTaskUpdate(taskId=\u0026#34;1\u0026#34;, status=\u0026#34;deleted\u0026#34;) 의존성 설정 Task #2가 Task #1 완료 후에만 시작 가능:\nTaskUpdate(taskId=\u0026#34;2\u0026#34;, addBlockedBy=[\u0026#34;1\u0026#34;]) Task #3이 Task #4와 #5를 차단:\nTaskUpdate(taskId=\u0026#34;3\u0026#34;, addBlocks=[\u0026#34;4\u0026#34;, \u0026#34;5\u0026#34;]) 태스크 조회 전체 태스크 목록:\nTaskList() 특정 태스크 상세 정보:\nTaskGet(taskId=\u0026#34;3\u0026#34;) Pipeline 모드 - 순차 에이전트 체이닝 Pipeline 모드는 에이전트들을 순차적으로 연결하여 데이터를 전달합니다.\n기본 사용법 /pipeline \u0026#34;사용자 피드백 데이터 분석 → 개선 사항 도출 → 기능 명세 작성 → 구현\u0026#34; 데이터 흐름 Pipeline은 각 단계의 출력을 다음 단계의 입력으로 전달합니다:\nscientist: 피드백 데이터 분석\n출력: 통계 리포트, 주요 불만 사항 analyst: 개선 사항 도출\n입력: scientist의 분석 결과 출력: 우선순위가 정해진 개선 항목 product-manager: 기능 명세 작성\n입력: analyst의 개선 항목 출력: PRD 문서 executor: 구현\n입력: product-manager의 PRD 출력: 완성된 코드 각 에이전트는 이전 에이전트의 결과물을 컨텍스트로 받아 작업합니다.\nPipeline vs Team Pipeline 사용 시기:\n순차적 변환이 필요한 작업 각 단계가 이전 단계에 강하게 의존 데이터 파이프라인, 분석 워크플로우 Team 사용 시기:\n병렬 실행 가능한 독립적 작업 대규모 프로젝트, 여러 영역 동시 작업 복잡한 의존성과 협업 필요 Team + Ralph - 지속적 개선 Team과 Ralph를 결합하면 팀 전체가 목표 달성까지 반복 작업합니다.\n사용법 /team ralph \u0026#34;완벽한 REST API 구축: 모든 엔드포인트 테스트 통과, 보안 검토 통과, 성능 기준 충족\u0026#34; 동작 방식 Team 실행: team-plan → team-prd → team-exec Team 검증: team-verify Ralph 루프: 검증 실패 시 team-fix: 문제점 수정 team-exec: 재실행 team-verify: 재검증 성공 또는 최대 반복 도달 시 종료 Team의 병렬 실행 능력과 Ralph의 반복 검증을 결합하여 품질과 속도를 모두 확보합니다.\n실전 예시 - 풀스택 블로그 구축 5명의 에이전트로 풀스택 블로그 애플리케이션을 구축하는 과정:\n초기 요청 /team 5 \u0026#34;풀스택 블로그: React 프론트엔드, Express API, PostgreSQL, JWT 인증, Tailwind CSS, 배포 설정\u0026#34; 팀 구성 및 작업 분배 team-lead가 다음과 같이 작업을 분해하고 할당합니다:\nworker-1 (Backend Engineer)\nTask #1: PostgreSQL 스키마 설계 및 마이그레이션 Task #2: Express API 엔드포인트 구현 Task #3: JWT 인증 미들웨어 worker-2 (Frontend Engineer)\nTask #4: React 컴포넌트 구조 설계 (blocked by #1) Task #5: 포스트 목록/상세 페이지 구현 (blocked by #2) Task #6: 인증 플로우 UI (blocked by #3) worker-3 (Designer)\nTask #7: Tailwind CSS 디자인 시스템 Task #8: 반응형 레이아웃 구현 worker-4 (Test Engineer)\nTask #9: API 통합 테스트 (blocked by #2) Task #10: 프론트엔드 E2E 테스트 (blocked by #5) worker-5 (DevOps)\nTask #11: Docker 컨테이너화 Task #12: CI/CD 파이프라인 설정 Task #13: 클라우드 배포 구성 실행 흐름 0분: worker-1(#1), worker-3(#7), worker-5(#11) 동시 시작 15분: #1 완료 → worker-2가 #4 시작 20분: #7 완료 → worker-3이 #8 시작 30분: #11 완료 → worker-5가 #12 시작 35분: worker-1이 #2 완료 → worker-2가 #5 시작, worker-4가 #9 시작 50분: worker-1이 #3 완료 → worker-2가 #6 시작 65분: #5 완료 → worker-4가 #10 시작 80분: 모든 작업 완료 → team-verify 단계 진입 85분: 검증 통과 → 프로젝트 완성\n총 소요 시간: 약 85분 (순차 실행 시 약 300분 소요)\n팀원 간 통신 예시 worker-1 → worker-2:\nSendMessage( type=\u0026#34;message\u0026#34;, recipient=\u0026#34;worker-2\u0026#34;, content=\u0026#34;API 엔드포인트가 준비되었습니다. GET /api/posts, POST /api/posts, GET /api/posts/:id, PUT /api/posts/:id, DELETE /api/posts/:id 모두 사용 가능합니다.\u0026#34;, summary=\u0026#34;API 준비 완료\u0026#34; ) worker-3 → team-lead:\nSendMessage( type=\u0026#34;message\u0026#34;, recipient=\u0026#34;team-lead\u0026#34;, content=\u0026#34;디자인 시스템 완성. 모든 팀원은 tailwind.config.js의 커스텀 테마를 사용하세요.\u0026#34;, summary=\u0026#34;디자인 시스템 공유\u0026#34; ) 상태 지속성 Team 모드는 .omc/state/team-state.json에 상태를 저장합니다:\n{ \u0026#34;active\u0026#34;: true, \u0026#34;current_phase\u0026#34;: \u0026#34;team-exec\u0026#34;, \u0026#34;team_name\u0026#34;: \u0026#34;fullstack-blog-team\u0026#34;, \u0026#34;fix_loop_count\u0026#34;: 0, \u0026#34;stage_history\u0026#34;: [\u0026#34;team-plan\u0026#34;, \u0026#34;team-prd\u0026#34;, \u0026#34;team-exec\u0026#34;] } 작업 중단 시에도 상태를 복구하여 이어서 진행할 수 있습니다.\n작업 취소 모든 팀원에게 종료를 요청하고 팀을 해체:\n/oh-my-claudecode:cancel Team과 Ralph가 연결된 경우 두 모드 모두 취소됩니다.\n마무리 OMC의 Team과 Pipeline 모드는 AI 에이전트들의 진정한 협업을 가능하게 합니다. Team은 실제 개발팀처럼 작업을 분배하고 병렬로 실행하며, Pipeline은 데이터를 단계별로 변환합니다.\n여러 전문 에이전트가 각자의 강점을 발휘하고, 서로 통신하며, 공동의 목표를 향해 나아가는 모습은 미래의 소프트웨어 개발 방식을 보여줍니다. OMC와 함께라면 혼자서도 전체 팀의 생산성을 발휘할 수 있습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/omc-team-pipeline/","summary":"\u003ch2 id=\"멀티-에이전트-협업의-필요성\"\u003e멀티 에이전트 협업의 필요성\u003c/h2\u003e\n\u003cp\u003e복잡한 소프트웨어 프로젝트는 단일 개발자가 모든 것을 처리하기 어렵습니다. 프론트엔드, 백엔드, 데이터베이스, 테스트, 배포 등 각 영역은 서로 다른 전문성을 요구합니다. OMC의 Team과 Pipeline 모드는 이러한 현실을 AI 에이전트 세계에 그대로 적용합니다.\u003c/p\u003e\n\u003cp\u003e여러 전문 에이전트가 각자의 역할을 수행하고, 서로 통신하며, 의존성을 관리하고, 최종 목표를 향해 협업합니다. 마치 실제 개발팀처럼 작동하는 AI 팀을 구성할 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"team-모드---조직화된-협업\"\u003eTeam 모드 - 조직화된 협업\u003c/h2\u003e\n\u003cp\u003eTeam 모드는 Claude Code의 네이티브 팀 기능을 활용하여 여러 에이전트를 조직화하고 협업시킵니다.\u003c/p\u003e","tags":["OMC","Team","Pipeline","멀티에이전트"],"title":"OMC Team과 Pipeline - 멀티 에이전트 협업, 태스크 관리, 순차 체이닝"},{"content":"실행 모드의 중요성 oh-my-claudecode(OMC)는 다양한 실행 모드를 제공하여 작업의 특성에 따라 최적의 전략을 선택할 수 있습니다. 그 중에서도 Ecomode와 Ultrawork는 서로 다른 목표를 가진 두 가지 핵심 모드입니다.\nEcomode: 토큰 비용을 최소화하면서 합리적인 성능 유지 Ultrawork: 실행 속도를 최대화하기 위한 공격적인 병렬 처리 이 두 모드를 이해하고 적절히 활용하면 비용과 성능 사이의 균형을 프로젝트 요구사항에 맞게 조정할 수 있습니다.\nEcomode: 토큰 효율적 실행 Ecomode란? Ecomode는 가능한 한 적은 토큰을 사용하면서도 작업을 완수하는 것을 목표로 하는 실행 모드입니다. Claude 모델의 계층 구조를 활용하여 작업의 복잡도에 따라 적절한 모델을 자동 선택합니다.\n모델 라우팅 전략 Ecomode는 다음과 같은 모델 라우팅 규칙을 적용합니다:\n┌─────────────────────┬──────────────────┬─────────────────┐ │ 작업 유형 │ 기본 모드 │ Ecomode │ ├─────────────────────┼──────────────────┼─────────────────┤ │ 코드베이스 탐색 │ Sonnet │ Haiku │ │ 간단한 검색/조회 │ Sonnet │ Haiku │ │ 스타일 리뷰 │ Sonnet │ Haiku │ │ 문서 작성 │ Sonnet │ Haiku │ ├─────────────────────┼──────────────────┼─────────────────┤ │ 코드 구현 │ Sonnet │ Sonnet │ │ 디버깅 │ Sonnet │ Sonnet │ │ 테스트 작성 │ Sonnet │ Sonnet │ │ 품질 리뷰 │ Opus │ Sonnet │ ├─────────────────────┼──────────────────┼─────────────────┤ │ 아키텍처 설계 │ Opus │ Opus │ │ 복잡한 리팩토링 │ Opus │ Opus │ │ 전체 코드 리뷰 │ Opus │ Opus │ └─────────────────────┴──────────────────┴─────────────────┘ 핵심 원칙:\nHaiku 우선: 단순 작업은 가장 저렴한 Haiku 모델 활용 Sonnet 적정 사용: 구현과 분석은 Sonnet으로 처리 Opus 선택적 사용: 복잡한 설계 결정만 Opus 활용 Ecomode 활성화 Ecomode를 사용하는 방법은 여러 가지가 있습니다:\n1. 키워드로 활성화 대화 중 \u0026quot;eco\u0026quot;, \u0026quot;ecomode\u0026quot;, \u0026quot;budget\u0026quot; 키워드를 사용하면 자동 활성화됩니다.\n\u0026#34;eco 모드로 블로그 포스트 5개 생성해줘\u0026#34; \u0026#34;budget-friendly 방식으로 전체 테스트 실행\u0026#34; 2. 슬래시 스킬로 활성화 /oh-my-claudecode:ecomode \u0026#34;Create 10 blog posts about React\u0026#34; 3. 설정 파일로 기본값 지정 ~/.claude/.omc-config.json에서 기본 실행 모드를 지정할 수 있습니다:\n{ \u0026#34;defaultExecutionMode\u0026#34;: \u0026#34;ecomode\u0026#34;, \u0026#34;modelRouting\u0026#34;: { \u0026#34;explore\u0026#34;: \u0026#34;haiku\u0026#34;, \u0026#34;executor\u0026#34;: \u0026#34;sonnet\u0026#34;, \u0026#34;planner\u0026#34;: \u0026#34;sonnet\u0026#34;, \u0026#34;architect\u0026#34;: \u0026#34;opus\u0026#34; } } Ecomode 사용 시나리오 시나리오 1: 대량 콘텐츠 생성 블로그 포스트, 문서, 테스트 케이스 등 대량의 콘텐츠를 생성할 때 Ecomode가 효과적입니다.\n/ecomode \u0026#34;Create 20 unit tests for the authentication module\u0026#34; 비용 비교:\n기본 모드: Sonnet 20회 실행 = 높은 비용 Ecomode: Haiku 15회 + Sonnet 5회 = 약 60% 비용 절감 시나리오 2: 반복적인 리팩토링 여러 파일에 걸친 단순한 패턴 변경 작업:\n/ecomode \u0026#34;Convert all class components to functional components\u0026#34; Haiku가 파일 탐색과 단순 변환을 처리하고, Sonnet은 복잡한 상태 로직만 처리합니다.\n시나리오 3: 문서 업데이트 README, API 문서, 주석 업데이트 등:\n/ecomode \u0026#34;Update all JSDoc comments to include TypeScript types\u0026#34; 문서 작성은 Haiku로도 충분하므로 큰 비용 절감 효과가 있습니다.\nEcomode 제한사항 다음 상황에서는 Ecomode를 피하는 것이 좋습니다:\n❌ 복잡한 버그 디버깅\nHaiku는 복잡한 논리 추론에 한계가 있음 미묘한 버그를 놓칠 위험 ❌ 아키텍처 의사결정\n시스템 설계는 Opus의 깊이 있는 분석 필요 잘못된 결정의 비용이 토큰 절약보다 큼 ❌ 보안 검토\n보안 취약점은 절대 놓쳐서는 안 됨 Opus 수준의 철저한 분석 필요 ❌ 프로덕션 코드 리뷰\n품질 관련 타협 불가 Sonnet 또는 Opus 사용 권장 Ultrawork: 최대 병렬 처리 Ultrawork란? Ultrawork는 OMC의 공격적인 병렬 실행 모드입니다. 독립적인 작업들을 최대한 많이 동시에 실행하여 전체 완료 시간을 최소화합니다.\n병렬 처리 전략 Ultrawork는 다음 원칙으로 작업을 병렬화합니다:\n1. 의존성 분석 작업 간 의존성을 자동으로 분석하여 병렬 실행 가능 여부를 판단합니다.\nTask 1: Explore codebase → 독립 실행 가능 Task 2: Read config files → 독립 실행 가능 Task 3: Analyze dependencies → Task 1 완료 후 실행 Task 4: Write documentation → 독립 실행 가능 Task 5: Update tests → Task 3 완료 후 실행 병렬화 결과:\nWave 1: Task 1, 2, 4 동시 실행 Wave 2: Task 3 실행 (Task 1 완료 대기) Wave 3: Task 5 실행 (Task 3 완료 대기) 2. 에이전트 풀 관리 동시에 최대 20개의 에이전트를 실행할 수 있습니다:\n// Ultrawork 내부 로직 (개념적 표현) const MAX_PARALLEL = 20; const agentPool = []; while (tasksRemaining) { const availableSlots = MAX_PARALLEL - agentPool.length; const readyTasks = getTasksWithNoBlockingDependencies(); const tasksToSpawn = readyTasks.slice(0, availableSlots); tasksToSpawn.forEach(task =\u0026gt; { agentPool.push(spawnAgent(task)); }); await Promise.race(agentPool); // 하나라도 완료되면 계속 } 3. 작업 분할 큰 작업을 더 작은 독립적 단위로 분할합니다:\n\u0026#34;Update 50 component files\u0026#34; → - Task 1: Update files 1-10 - Task 2: Update files 11-20 - Task 3: Update files 21-30 - Task 4: Update files 31-40 - Task 5: Update files 41-50 (5개 에이전트가 동시에 각 그룹 처리) Ultrawork 활성화 1. 키워드로 활성화 \u0026quot;ulw\u0026quot;, \u0026quot;ultrawork\u0026quot;, \u0026quot;parallel\u0026quot;, \u0026quot;fast\u0026quot; 등의 키워드 사용:\n\u0026#34;ultrawork 모드로 전체 테스트 스위트 실행해줘\u0026#34; \u0026#34;빠르게 모든 컴포넌트에 TypeScript 타입 추가\u0026#34; 2. 슬래시 스킬로 활성화 /oh-my-claudecode:ultrawork \u0026#34;Refactor all API routes to use new error handling\u0026#34; 3. 설정으로 기본값 지정 { \u0026#34;defaultExecutionMode\u0026#34;: \u0026#34;ultrawork\u0026#34;, \u0026#34;parallelism\u0026#34;: { \u0026#34;maxConcurrent\u0026#34;: 20, \u0026#34;chunkSize\u0026#34;: 10 } } Ultrawork 사용 시나리오 시나리오 1: 대규모 마이그레이션 100개 파일의 import 경로를 변경하는 작업:\n/ultrawork \u0026#34;Update all imports from \u0026#39;@/components\u0026#39; to \u0026#39;@/ui/components\u0026#39;\u0026#34; 성능 비교:\n순차 실행: 100개 × 30초 = 50분 Ultrawork (20개 병렬): 100개 ÷ 20 × 30초 = 2.5분 속도 향상: 20배 시나리오 2: 전체 테스트 실행 각 테스트 파일을 독립적으로 실행:\n/ultrawork \u0026#34;Run all test files and collect coverage\u0026#34; 각 테스트 파일이 독립적이므로 완전한 병렬 실행이 가능합니다.\n시나리오 3: 다중 파일 분석 여러 파일을 동시에 분석하여 리포트 생성:\n/ultrawork \u0026#34;Analyze all API routes for security vulnerabilities\u0026#34; 20개 에이전트가 각각 다른 라우트를 분석하여 시간을 크게 단축합니다.\nUltrawork 주의사항 1. API Rate Limiting 너무 많은 동시 요청은 API 제한에 걸릴 수 있습니다:\n{ \u0026#34;parallelism\u0026#34;: { \u0026#34;maxConcurrent\u0026#34;: 10, // 제한이 있다면 낮게 설정 \u0026#34;requestDelay\u0026#34;: 100 // 요청 간 지연 (ms) } } 2. 메모리 사용량 각 에이전트는 별도의 컨텍스트를 유지하므로 메모리를 소비합니다. 시스템 리소스를 모니터링하세요.\n3. 디버깅 어려움 병렬 실행 중 오류가 발생하면 원인 파악이 어려울 수 있습니다. 중요한 작업은 순차 실행을 고려하세요.\n4. 의존성 오판 자동 의존성 분석이 완벽하지 않을 수 있습니다. 명시적으로 의존성을 지정할 수 있습니다:\n/ultrawork \u0026#34;Task A, then Task B (depends on A), Task C (independent)\u0026#34; Ecomode + Ultrawork 조합 두 모드를 함께 사용하여 비용 효율성과 속도를 동시에 얻을 수 있습니다:\n/ecomode /ultrawork \u0026#34;Generate 50 test files for all components\u0026#34; 이 조합은:\nEcomode: 각 에이전트가 Haiku 모델 사용 (비용 절감) Ultrawork: 20개 에이전트 병렬 실행 (속도 향상) 조합 활용 사례 사례 1: 대량 문서 생성 /eco /ulw \u0026#34;Create API documentation for all 100 endpoints\u0026#34; Haiku로 각 엔드포인트 문서 생성 (저비용) 20개씩 병렬 처리 (빠른 완료) 결과: 저비용 + 고속 실행 사례 2: 테스트 스위트 확장 /ecomode /ultrawork \u0026#34;Add unit tests for all utility functions\u0026#34; 단순 테스트는 Haiku로 생성 복잡한 로직은 Sonnet으로 처리 모든 파일 동시 작업 사례 3: 스타일 일관성 작업 /eco /ulw \u0026#34;Format all source files and update imports\u0026#34; 포맷팅은 Haiku로 충분 모든 파일 병렬 처리 빠르고 저렴한 완료 실행 모드 선택 가이드 의사결정 트리 작업 유형 파악 │ ├─ 시간이 중요한가? │ ├─ YES → Ultrawork 고려 │ └─ NO → 비용이 중요한가? │ ├─ YES → Ecomode 고려 │ └─ NO → 기본 모드 │ ├─ 많은 독립 작업인가? │ ├─ YES → Ultrawork 적합 │ └─ NO → 순차 모드 또는 기본 Team │ ├─ 단순 반복 작업인가? │ ├─ YES → Ecomode 적합 │ └─ NO → 복잡도는? │ ├─ HIGH → Opus (기본 모드) │ └─ MEDIUM → Sonnet (Ecomode 가능) │ └─ 품질이 최우선인가? ├─ YES → 기본 모드 (Opus 사용) └─ NO → Ecomode 또는 Ultrawork 상황별 권장 모드 상황 권장 모드 이유 긴급 버그 수정 기본 모드 (Sonnet/Opus) 품질과 정확성 우선 100개 파일 리팩토링 Ultrawork 병렬 처리로 시간 단축 문서 대량 생성 Ecomode Haiku로 충분, 비용 절감 아키텍처 설계 기본 모드 (Opus) 깊이 있는 분석 필요 테스트 작성 (다수) Eco + Ultra 저비용 + 고속 보안 감사 기본 모드 (Opus) 절대 타협 불가 스타일 일관성 작업 Eco + Ultra 단순 + 많은 파일 모니터링과 최적화 비용 추적 OMC는 모드별 토큰 사용량을 추적합니다:\n# 세션 통계 확인 /oh-my-claudecode:trace-summary # 출력 예시: Mode: Ecomode Total Tokens: 150,000 - Haiku: 100,000 (67%) - Sonnet: 45,000 (30%) - Opus: 5,000 (3%) Estimated Cost: $2.50 Mode: Ultrawork Parallel Agents: 20 Avg Completion Time: 3.2 minutes Total Tokens: 200,000 성능 최적화 팁 1. 작업 크기 조정 너무 작은 작업으로 쪼개면 오버헤드가 증가합니다:\n❌ 나쁨: 500개 파일을 500개 작업으로 분할 ✅ 좋음: 500개 파일을 25개 작업(각 20개 파일)으로 분할 2. 적절한 모델 선택 Ecomode에서도 수동으로 모델을 지정할 수 있습니다:\n/ecomode \u0026#34;Complex refactoring\u0026#34; --model sonnet # Haiku 대신 Sonnet 강제 3. 병렬도 조정 시스템 리소스에 따라 동시 실행 수를 조정하세요:\n{ \u0026#34;parallelism\u0026#34;: { \u0026#34;maxConcurrent\u0026#34;: 10 // 기본 20에서 조정 } } 마무리 Ecomode와 Ultrawork는 OMC의 유연성을 보여주는 핵심 기능입니다. 프로젝트의 요구사항, 예산, 일정에 따라 적절한 모드를 선택하면 Claude Code를 더욱 효과적으로 활용할 수 있습니다.\n핵심 원칙:\n품질이 중요하면 → 기본 모드 시간이 중요하면 → Ultrawork 비용이 중요하면 → Ecomode 둘 다 중요하면 → Eco + Ultra 조합 처음에는 기본 모드로 시작하여 작업 패턴을 파악한 후, 점차 Ecomode나 Ultrawork를 실험해보세요. 경험이 쌓이면 상황에 맞는 최적의 모드를 자연스럽게 선택할 수 있게 될 것입니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/omc-ecomode-ultrawork/","summary":"\u003ch2 id=\"실행-모드의-중요성\"\u003e실행 모드의 중요성\u003c/h2\u003e\n\u003cp\u003eoh-my-claudecode(OMC)는 다양한 실행 모드를 제공하여 작업의 특성에 따라 최적의 전략을 선택할 수 있습니다. 그 중에서도 Ecomode와 Ultrawork는 서로 다른 목표를 가진 두 가지 핵심 모드입니다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eEcomode\u003c/strong\u003e: 토큰 비용을 최소화하면서 합리적인 성능 유지\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eUltrawork\u003c/strong\u003e: 실행 속도를 최대화하기 위한 공격적인 병렬 처리\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e이 두 모드를 이해하고 적절히 활용하면 비용과 성능 사이의 균형을 프로젝트 요구사항에 맞게 조정할 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"ecomode-토큰-효율적-실행\"\u003eEcomode: 토큰 효율적 실행\u003c/h2\u003e\n\u003ch3 id=\"ecomode란\"\u003eEcomode란?\u003c/h3\u003e\n\u003cp\u003eEcomode는 가능한 한 적은 토큰을 사용하면서도 작업을 완수하는 것을 목표로 하는 실행 모드입니다. Claude 모델의 계층 구조를 활용하여 작업의 복잡도에 따라 적절한 모델을 자동 선택합니다.\u003c/p\u003e","tags":["OMC","Ecomode","Ultrawork","비용 최적화"],"title":"OMC Ecomode와 Ultrawork - 토큰 절약 모드, 병렬 처리 엔진"},{"content":"자동화된 개발의 시작 OMC의 가장 강력한 기능은 개발 과정 전체를 자동화하는 실행 모드들입니다. Autopilot은 아이디어에서 완성된 코드까지의 전체 여정을 자동으로 처리하고, Ralph는 작업이 완벽하게 완료될 때까지 반복 검증합니다.\n이 두 모드는 완전 자동화와 품질 보장이라는 각각의 강점을 가지고 있으며, 작업의 성격에 따라 적절히 선택하면 개발 생산성을 획기적으로 향상시킬 수 있습니다.\nAutopilot - 완전 자동 개발 Autopilot은 사용자의 요청을 받아 분석부터 구현, 테스트, 검증까지 전체 개발 사이클을 자동으로 수행합니다.\n기본 사용법 /autopilot \u0026#34;JWT 기반 사용자 인증 API를 만들어줘\u0026#34; 단 한 줄의 명령으로 Autopilot은 다음 워크플로우를 자동 실행합니다.\n자동 워크플로우 요구사항 분석 (analyst 에이전트)\n사용자 요청을 명확한 요구사항으로 변환 숨겨진 제약사항과 수용 기준 도출 기술적 선택지와 트레이드오프 분석 코드베이스 탐색 (explore 에이전트)\n관련 파일과 함수 탐색 기존 패턴과 컨벤션 파악 의존성과 아키텍처 이해 계획 수립 (planner 에이전트)\n작업을 순차적 단계로 분해 각 단계의 검증 기준 정의 리스크와 블로커 식별 구현 (executor 에이전트)\n계획에 따른 코드 작성 기존 코드와의 일관성 유지 필요한 헬퍼와 유틸리티 생성 테스트 작성 (test-engineer 에이전트)\n단위 테스트와 통합 테스트 작성 엣지 케이스와 에러 시나리오 커버 테스트 실행과 결과 확인 검증 (verifier 에이전트)\n모든 요구사항 충족 확인 테스트 통과 여부 검증 코드 품질과 보안 검토 실전 예시 /autopilot \u0026#34;블로그 포스트 CRUD API를 Express로 구현하고, PostgreSQL 연동, 입력 검증, 에러 핸들링 포함\u0026#34; Autopilot은 이 요청으로부터:\nExpress 라우터 구조 설계 PostgreSQL 스키마 및 마이그레이션 생성 CRUD 엔드포인트 구현 Joi 또는 Zod로 입력 검증 에러 미들웨어 구현 단위 및 통합 테스트 작성 API 문서 생성 모든 과정을 자동으로 수행합니다.\nRalph - 자기 참조 검증 루프 Ralph는 작업이 완벽하게 완료될 때까지 실행과 검증을 반복하는 모드입니다. 단순히 코드를 작성하는 것이 아니라, 결과가 기준을 충족할 때까지 자동으로 개선합니다.\n기본 사용법 /ralph \u0026#34;모든 단위 테스트가 통과하고 커버리지가 80% 이상이 될 때까지 버그를 수정해\u0026#34; 자기 참조 루프 메커니즘 Ralph의 실행 사이클은 다음과 같습니다:\n작업 실행\n지정된 작업 수행 (버그 수정, 기능 추가 등) 코드 변경 및 테스트 실행 결과 검증 (verifier 에이전트)\n테스트 통과 여부 확인 커버리지, 성능 등 메트릭 측정 요구사항 충족 여부 판단 Architect 검토 (architect 에이전트)\n코드 품질과 아키텍처 일관성 검토 장기적 유지보수성 평가 개선 필요 사항 식별 재시도 또는 완료\n검증 실패 시: 문제점 분석 후 1단계로 복귀 검증 성공 시: 작업 완료 max_iterations 도달 시: 실패 보고 실전 예시 /ralph \u0026#34;API 응답 시간이 100ms 이하가 될 때까지 성능 최적화\u0026#34; Ralph는:\n첫 시도: 데이터베이스 쿼리 최적화 검증: 응답 시간 150ms - 실패 두 번째 시도: 인덱스 추가 검증: 응답 시간 120ms - 실패 세 번째 시도: Redis 캐싱 추가 검증: 응답 시간 80ms - 성공 각 반복마다 architect가 접근 방식을 검토하고, verifier가 결과를 측정하며, 목표 달성까지 자동으로 개선합니다.\n마무리 OMC의 Autopilot과 Ralph는 개발 자동화의 두 가지 핵심 접근 방식을 제공합니다. Autopilot은 아이디어에서 완성된 코드까지의 전체 여정을 자동화하고, Ralph는 명확한 품질 기준이 충족될 때까지 반복 검증과 개선을 수행합니다.\n이 두 모드를 적절히 활용하면 반복적인 개발 작업에서 해방되어 더 창의적이고 전략적인 업무에 집중할 수 있습니다.\n다음 글에서는 Ultrawork와 Ecomode를 통해 병렬 실행 최적화와 비용 효율을 달성하는 방법을, 그리고 Team과 Pipeline 모드로 여러 AI 에이전트가 협업하는 워크플로우를 구축하는 방법을 살펴보겠습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/omc-autopilot-ralph/","summary":"\u003ch2 id=\"자동화된-개발의-시작\"\u003e자동화된 개발의 시작\u003c/h2\u003e\n\u003cp\u003eOMC의 가장 강력한 기능은 개발 과정 전체를 자동화하는 실행 모드들입니다. Autopilot은 아이디어에서 완성된 코드까지의 전체 여정을 자동으로 처리하고, Ralph는 작업이 완벽하게 완료될 때까지 반복 검증합니다.\u003c/p\u003e\n\u003cp\u003e이 두 모드는 완전 자동화와 품질 보장이라는 각각의 강점을 가지고 있으며, 작업의 성격에 따라 적절히 선택하면 개발 생산성을 획기적으로 향상시킬 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"autopilot---완전-자동-개발\"\u003eAutopilot - 완전 자동 개발\u003c/h2\u003e\n\u003cp\u003eAutopilot은 사용자의 요청을 받아 분석부터 구현, 테스트, 검증까지 전체 개발 사이클을 자동으로 수행합니다.\u003c/p\u003e\n\u003ch3 id=\"기본-사용법\"\u003e기본 사용법\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e/autopilot \u0026#34;JWT 기반 사용자 인증 API를 만들어줘\u0026#34;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e단 한 줄의 명령으로 Autopilot은 다음 워크플로우를 자동 실행합니다.\u003c/p\u003e","tags":["OMC","Autopilot","Ralph","자동화"],"title":"OMC Autopilot과 Ralph - 자동 실행 모드와 검증 루프"},{"content":"OMC란 무엇인가 oh-my-claudecode(OMC)는 Claude Code를 위한 멀티 에이전트 오케스트레이션 레이어입니다. Claude Code의 기본 기능 위에서 동작하며, Hooks와 MCP(Model Context Protocol) 도구를 활용하여 복잡한 소프트웨어 개발 작업을 여러 전문화된 AI 에이전트들이 협업하여 처리할 수 있도록 합니다.\n단일 AI 에이전트가 모든 작업을 처리하는 대신, OMC는 각 작업의 특성에 맞는 전문 에이전트에게 업무를 위임합니다. 코드 탐색, 아키텍처 설계, 구현, 테스트, 검증, 리뷰 등 각 단계마다 최적화된 에이전트가 투입되어 작업의 품질과 효율성을 극대화합니다.\nQuick Start Step 1: Install Claude Code 플러그인 마켓플레이스에서 설치합니다.\n/plugin marketplace add https://github.com/Yeachan-Heo/oh-my-claudecode /plugin install oh-my-claudecode Step 2: Setup 설치 후 초기 설정을 실행합니다.\n/oh-my-claudecode:omc-setup Step 3: Build something 바로 사용해보세요. 나머지는 전부 자동입니다.\nautopilot: build a REST API for managing tasks 에이전트 카탈로그 OMC는 다양한 역할의 전문 에이전트를 제공합니다. 각 에이전트는 특정 작업에 최적화된 프롬프트와 워크플로우를 가지고 있습니다.\nBuild/Analysis 레인 개발 과정의 핵심 단계를 담당하는 에이전트들입니다.\nexplore: 코드베이스 탐색과 파일/심볼 매핑. 프로젝트 구조를 빠르게 파악하고 관련 코드를 찾아냅니다. analyst: 요구사항 명확화, 수용 기준 정의, 숨겨진 제약사항 발견 planner: 작업 순서 계획, 실행 계획 수립, 리스크 플래깅 architect: 시스템 설계, 경계와 인터페이스 정의, 장기적 트레이드오프 고려 executor: 코드 구현, 리팩토링, 기능 개발의 실질적 작업 수행 deep-executor: 복잡한 자율적 목표 지향 작업 처리 debugger: 근본 원인 분석, 리그레션 격리, 실패 진단 verifier: 완성도 증명, 주장 검증, 테스트 적정성 확인 Review 레인 코드 품질을 보장하는 리뷰 전문 에이전트들입니다.\nstyle-reviewer: 포매팅, 네이밍, 관용구, 린트 규칙 준수 검토 quality-reviewer: 로직 결함, 유지보수성, 안티패턴 탐지 api-reviewer: API 계약, 버저닝, 하위 호환성 검증 security-reviewer: 취약점, 신뢰 경계, 인증/인가 보안 검토 performance-reviewer: 핫스팟, 복잡도, 메모리/레이턴시 최적화 code-reviewer: 여러 관심사를 아우르는 종합 리뷰 Domain 스페셜리스트 특정 영역의 전문성을 제공하는 에이전트들입니다.\ntest-engineer: 테스트 전략, 커버리지, 플래키 테스트 강화 build-fixer: 빌드/툴체인/타입 오류 해결 designer: UX/UI 아키텍처, 인터랙션 디자인 writer: 문서화, 마이그레이션 노트, 사용자 가이드 작성 모델 라우팅 OMC는 작업의 복잡도에 따라 적절한 Claude 모델을 선택할 수 있습니다.\nhaiku: 빠른 검색, 경량 작업, 간단한 스캔에 적합 sonnet: 표준 구현, 디버깅, 리뷰 작업에 적합 opus: 아키텍처 설계, 심층 분석, 복잡한 리팩토링에 적합 Task 호출 시 model 파라미터로 명시할 수 있습니다:\nTask(subagent_type=\u0026#34;oh-my-claudecode:architect\u0026#34;, model=\u0026#34;opus\u0026#34;, prompt=\u0026#34;이 모듈의 경계를 요약해줘\u0026#34;) Task(subagent_type=\u0026#34;oh-my-claudecode:executor\u0026#34;, model=\u0026#34;sonnet\u0026#34;, prompt=\u0026#34;로그인 플로우에 입력 검증 추가\u0026#34;) Task(subagent_type=\u0026#34;oh-my-claudecode:explorer\u0026#34;, model=\u0026#34;haiku\u0026#34;, prompt=\u0026#34;인증 관련 파일 찾기\u0026#34;) 올바른 모델 선택은 작업 품질과 비용 효율성 모두를 최적화합니다.\nMCP 도구 통합 OMC는 외부 AI 모델을 MCP 프로토콜을 통해 통합합니다.\nask_codex OpenAI의 gpt-5.3-codex 모델을 사용합니다. 코드 분석, 계획 검증, 비평, 리뷰에 특화되어 있습니다.\n권장 역할: architect, planner, critic, analyst, code-reviewer, security-reviewer, tdd-guide\nmcp__x__ask_codex( agent_role=\u0026#34;architect\u0026#34;, prompt=\u0026#34;이 API 설계의 확장성 문제를 분석해줘\u0026#34;, context_files=[\u0026#34;src/api/routes.ts\u0026#34;, \u0026#34;src/api/middleware.ts\u0026#34;] ) ask_gemini Google의 gemini-3-pro-preview 모델을 사용합니다. 1M 토큰 컨텍스트 윈도우로 대용량 파일 분석과 디자인 리뷰에 특화되어 있습니다.\n권장 역할: designer, writer, vision\nmcp__g__ask_gemini( agent_role=\u0026#34;designer\u0026#34;, prompt=\u0026#34;이 컴포넌트 구조의 UX 개선점을 제안해줘\u0026#34;, files=[\u0026#34;src/components/**/*.tsx\u0026#34;] ) MCP 도구는 Claude 에이전트보다 빠르고 비용 효율적이며, 읽기 전용 분석 작업에 권장됩니다.\n상태 관리 OMC는 작업 모드의 상태를 추적하고 지속합니다.\nstate_write(mode=\u0026#34;autopilot\u0026#34;, active=true, iteration=3, current_phase=\u0026#34;implementation\u0026#34;) state_read(mode=\u0026#34;autopilot\u0026#34;) state_clear(mode=\u0026#34;autopilot\u0026#34;) 지원되는 모드: autopilot, ultrapilot, team, pipeline, ralph, ultrawork, ultraqa, ecomode\n모든 상태 파일은 {worktree}/.omc/state/ 디렉토리에 저장됩니다.\nNotepad - 세션 메모리 프로젝트별 메모를 관리하는 notepad 시스템을 제공합니다.\nnotepad_write_priority(\u0026#34;이 프로젝트는 항상 TypeScript strict 모드 사용\u0026#34;) notepad_write_working(\u0026#34;API 엔드포인트 /auth/login 구현 완료\u0026#34;) notepad_write_manual(\u0026#34;데이터베이스 마이그레이션은 수동으로만 실행\u0026#34;) priority: 500자 이하 권장, 세션 시작 시 항상 로드, 영구 보존 working: 타임스탬프 자동 추가, 7일 후 자동 정리 manual: 영구 보존, 자동 정리 없음 notepad는 {worktree}/.omc/notepad.md에 저장됩니다.\nProject Memory - 프로젝트 지식베이스 프로젝트의 기술 스택, 빌드 규칙, 컨벤션을 영구 저장합니다.\nproject_memory_write({ techStack: \u0026#34;React 18, TypeScript 5, Vite 4\u0026#34;, build: \u0026#34;npm run build\u0026#34;, conventions: \u0026#34;모든 컴포넌트는 함수형, props는 interface로 정의\u0026#34;, structure: \u0026#34;src/components - 재사용 컴포넌트, src/pages - 페이지 컴포넌트\u0026#34; }) project_memory_add_note(\u0026#34;build\u0026#34;, \u0026#34;프로덕션 빌드 전 반드시 타입 체크 실행\u0026#34;) project_memory_add_directive(\u0026#34;항상 에러 바운더리로 컴포넌트 래핑\u0026#34;, priority=\u0026#34;high\u0026#34;) Project Memory는 {worktree}/.omc/project-memory.json에 저장되며 세션 간 공유됩니다.\n마무리 OMC는 Claude Code의 능력을 멀티 에이전트 협업으로 확장합니다. 각 에이전트는 특정 작업에 최적화되어 있으며, 모델 라우팅과 MCP 통합으로 비용과 성능을 모두 최적화할 수 있습니다.\n다음 글에서는 OMC의 Autopilot과 Ralph 모드를 통해 아이디어에서 완성된 코드까지 완전 자동화하는 방법을 살펴보겠습니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/omc-introduction/","summary":"\u003ch2 id=\"omc란-무엇인가\"\u003eOMC란 무엇인가\u003c/h2\u003e\n\u003cp\u003eoh-my-claudecode(OMC)는 Claude Code를 위한 멀티 에이전트 오케스트레이션 레이어입니다. Claude Code의 기본 기능 위에서 동작하며, Hooks와 MCP(Model Context Protocol) 도구를 활용하여 복잡한 소프트웨어 개발 작업을 여러 전문화된 AI 에이전트들이 협업하여 처리할 수 있도록 합니다.\u003c/p\u003e\n\u003cp\u003e단일 AI 에이전트가 모든 작업을 처리하는 대신, OMC는 각 작업의 특성에 맞는 전문 에이전트에게 업무를 위임합니다. 코드 탐색, 아키텍처 설계, 구현, 테스트, 검증, 리뷰 등 각 단계마다 최적화된 에이전트가 투입되어 작업의 품질과 효율성을 극대화합니다.\u003c/p\u003e\n\u003ch2 id=\"quick-start\"\u003eQuick Start\u003c/h2\u003e\n\u003ch3 id=\"step-1-install\"\u003eStep 1: Install\u003c/h3\u003e\n\u003cp\u003eClaude Code 플러그인 마켓플레이스에서 설치합니다.\u003c/p\u003e","tags":["OMC","에이전트","플러그인","AI코딩"],"title":"oh-my-claudecode 소개 - 에이전트 카탈로그, 모델 라우팅, 상태 관리"},{"content":"Plan Mode란 무엇인가 Claude Code의 Plan Mode는 복잡한 코드 변경 작업을 시작하기 전에 체계적인 설계와 탐색을 수행할 수 있는 특별한 작업 모드입니다. EnterPlanMode 도구를 통해 진입하며, 이 모드에서는 코드를 직접 수정하지 않고 코드베이스를 탐색하고 설계 문서를 작성하는 데 집중합니다.\nPlan Mode의 핵심 철학은 \u0026quot;생각 먼저, 구현은 나중에\u0026quot;입니다. 특히 멀티파일 변경이나 아키텍처 결정이 필요한 상황에서 성급한 코드 수정으로 인한 시행착오를 크게 줄일 수 있습니다.\nPlan Mode를 사용해야 하는 시나리오 Plan Mode는 모든 작업에 필요한 것은 아닙니다. 다음과 같은 경우에 특히 유용합니다.\n새로운 기능 구현 기존 코드베이스에 새로운 기능을 추가할 때, 어떤 파일들을 수정해야 하고 어떤 인터페이스를 설계해야 하는지 먼저 파악하는 것이 중요합니다.\n멀티파일 변경 3개 이상의 파일을 수정해야 하는 작업이라면 Plan Mode를 통해 변경 범위와 영향도를 먼저 분석하는 것이 좋습니다.\n아키텍처 결정 새로운 모듈 구조를 설계하거나 기존 아키텍처를 리팩토링할 때는 전체적인 구조를 먼저 계획해야 합니다.\n불명확한 요구사항 요구사항이 모호하거나 기존 코드의 동작 방식을 먼저 이해해야 하는 경우, Plan Mode에서 탐색 후 명확한 계획을 세울 수 있습니다.\nPlan Mode 워크플로우 1단계: Plan Mode 진입과 탐색 Plan Mode에 진입하면 Claude Code는 코드 수정 도구(Edit, Write 등)를 사용할 수 없고, 대신 탐색 도구에 집중합니다.\n# 주로 사용하는 탐색 도구들 - Glob: 파일 패턴 검색 - Grep: 코드 내용 검색 - Read: 파일 읽기 예를 들어, 사용자 인증 시스템을 구현한다면 다음과 같이 탐색합니다.\n# 인증 관련 기존 코드 확인 - Glob로 auth 관련 파일 찾기: **/auth*.js, **/user*.js - Grep으로 session 관련 코드 검색 - Read로 주요 파일 내용 확인 2단계: 설계 문서 작성 탐색한 내용을 바탕으로 구체적인 설계 계획을 작성합니다. 이 계획은 ExitPlanMode 시 사용자에게 제시됩니다.\n## 사용자 인증 시스템 설계 계획 ### 파일 구조 - src/auth/authenticate.js (새 파일) - 인증 로직 - src/auth/middleware.js (새 파일) - Express 미들웨어 - src/models/User.js (수정) - 비밀번호 해싱 메서드 추가 - src/routes/auth.js (새 파일) - 로그인/로그아웃 라우트 ### 주요 설계 결정 1. bcrypt를 사용한 비밀번호 해싱 2. JWT 토큰 기반 세션 관리 3. 기존 User 모델 확장 (재작성 불필요) ### 구현 순서 1. User 모델에 comparePassword 메서드 추가 2. authenticate.js에 로그인 로직 구현 3. 미들웨어로 보호된 라우트 처리 4. 라우트 통합 및 테스트 3단계: Plan Mode 종료와 승인 ExitPlanMode를 호출하면 작성한 계획이 사용자에게 제시됩니다. 사용자는 계획을 검토하고 승인하거나 수정을 요청할 수 있습니다.\n4단계: 구현 계획이 승인되면 Claude Code는 설계 문서를 따라 실제 코드를 작성합니다. 이미 명확한 로드맵이 있기 때문에 구현이 빠르고 정확합니다.\n실전 예시: 사용자 인증 시스템 설계 실제로 Plan Mode를 사용하여 인증 시스템을 설계하는 전체 과정을 살펴보겠습니다.\n초기 요청 \u0026quot;Express 앱에 JWT 기반 사용자 인증을 추가해주세요.\u0026quot;\nPlan Mode 탐색 // 1. 기존 User 모델 확인 (Glob + Read) // src/models/User.js const mongoose = require(\u0026#39;mongoose\u0026#39;); const userSchema = new mongoose.Schema({ email: { type: String, required: true, unique: true }, username: { type: String, required: true }, passwordHash: String, createdAt: { type: Date, default: Date.now } }); module.exports = mongoose.model(\u0026#39;User\u0026#39;, userSchema); // 2. Express 앱 구조 확인 (Grep \u0026#34;app.use\u0026#34;) // src/app.js에서 라우트 구조 파악 설계 계획 작성 ## 인증 시스템 구현 계획 ### 새로운 의존성 - jsonwebtoken (JWT 생성/검증) - bcrypt (비밀번호 해싱) ### 파일 변경사항 1. src/models/User.js (수정) - comparePassword 인스턴스 메서드 추가 - hashPassword pre-save 훅 추가 2. src/middleware/auth.js (새 파일) - authenticateToken 미들웨어 구현 3. src/routes/auth.js (새 파일) - POST /auth/login - POST /auth/logout 4. src/app.js (수정) - auth 라우트 연결 ### 보안 고려사항 - JWT secret은 환경변수로 관리 - 비밀번호는 bcrypt로 해싱 (saltRounds: 10) - 토큰 만료 시간: 24시간 구현 코드 예시 계획 승인 후 생성되는 코드:\n// src/models/User.js (수정) const bcrypt = require(\u0026#39;bcrypt\u0026#39;); userSchema.methods.comparePassword = async function(candidatePassword) { return bcrypt.compare(candidatePassword, this.passwordHash); }; userSchema.pre(\u0026#39;save\u0026#39;, async function(next) { if (!this.isModified(\u0026#39;passwordHash\u0026#39;)) return next(); this.passwordHash = await bcrypt.hash(this.passwordHash, 10); next(); }); // src/middleware/auth.js (새 파일) const jwt = require(\u0026#39;jsonwebtoken\u0026#39;); function authenticateToken(req, res, next) { const authHeader = req.headers[\u0026#39;authorization\u0026#39;]; const token = authHeader \u0026amp;\u0026amp; authHeader.split(\u0026#39; \u0026#39;)[1]; if (!token) return res.sendStatus(401); jwt.verify(token, process.env.JWT_SECRET, (err, user) =\u0026gt; { if (err) return res.sendStatus(403); req.user = user; next(); }); } module.exports = { authenticateToken }; // src/routes/auth.js (새 파일) const express = require(\u0026#39;express\u0026#39;); const jwt = require(\u0026#39;jsonwebtoken\u0026#39;); const User = require(\u0026#39;../models/User\u0026#39;); const router = express.Router(); router.post(\u0026#39;/login\u0026#39;, async (req, res, next) =\u0026gt; { try { const { email, password } = req.body; const user = await User.findOne({ email }); if (!user || !(await user.comparePassword(password))) { return res.status(401).json({ error: \u0026#39;Invalid credentials\u0026#39; }); } const token = jwt.sign( { userId: user._id, email: user.email }, process.env.JWT_SECRET, { expiresIn: \u0026#39;24h\u0026#39; } ); res.json({ token, user: { id: user._id, email: user.email } }); } catch (error) { next(error); } }); module.exports = router; Plan Mode 활용 팁 간단한 작업에는 불필요 단일 파일 수정이나 명확한 버그 수정처럼 간단한 작업에는 Plan Mode가 오히려 비효율적입니다. 바로 구현하는 것이 낫습니다.\n3개 이상 파일 변경 시 권장 여러 파일을 동시에 수정해야 한다면 Plan Mode로 전체 그림을 먼저 그리는 것이 좋습니다.\n설계 문서는 구체적으로 \u0026quot;인증 추가\u0026quot;가 아니라 \u0026quot;User.js에 comparePassword 메서드 추가, auth.js 미들웨어 생성\u0026quot;처럼 구체적으로 작성합니다.\n기존 코드 패턴 따르기 Plan Mode에서 기존 코드를 탐색할 때는 프로젝트의 기존 패턴과 관례를 파악하고 따라야 합니다.\n마무리 Plan Mode는 복잡한 코드 변경 작업에서 체계적인 접근을 가능하게 합니다. 탐색과 설계를 먼저 수행하고, 명확한 계획을 세운 후 구현함으로써 시행착오를 줄이고 더 나은 코드를 작성할 수 있습니다.\n특히 CLAUDE.md와 .claude/ 디렉토리를 통해 프로젝트별 컨텍스트를 잘 관리하면 Plan Mode의 효과가 극대화됩니다. 다음 번 복잡한 기능을 구현할 때는 Plan Mode를 활용해보세요.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/claude-code-plan-mode/","summary":"\u003ch2 id=\"plan-mode란-무엇인가\"\u003ePlan Mode란 무엇인가\u003c/h2\u003e\n\u003cp\u003eClaude Code의 Plan Mode는 복잡한 코드 변경 작업을 시작하기 전에 체계적인 설계와 탐색을 수행할 수 있는 특별한 작업 모드입니다. EnterPlanMode 도구를 통해 진입하며, 이 모드에서는 코드를 직접 수정하지 않고 코드베이스를 탐색하고 설계 문서를 작성하는 데 집중합니다.\u003c/p\u003e\n\u003cp\u003ePlan Mode의 핵심 철학은 \u0026quot;생각 먼저, 구현은 나중에\u0026quot;입니다. 특히 멀티파일 변경이나 아키텍처 결정이 필요한 상황에서 성급한 코드 수정으로 인한 시행착오를 크게 줄일 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"plan-mode를-사용해야-하는-시나리오\"\u003ePlan Mode를 사용해야 하는 시나리오\u003c/h2\u003e\n\u003cp\u003ePlan Mode는 모든 작업에 필요한 것은 아닙니다. 다음과 같은 경우에 특히 유용합니다.\u003c/p\u003e","tags":["Claude Code","Plan Mode","코드설계","AI코딩"],"title":"Claude Code Plan Mode - 워크플로우, 설계 계획 수립, 활용 팁"},{"content":"MCP란 무엇인가 MCP(Model Context Protocol)는 AI 모델과 외부 도구를 연결하는 표준 프로토콜입니다. Claude Code는 MCP를 통해 파일 시스템, 데이터베이스, 웹 API, 문서 검색 엔진 등 다양한 외부 서비스와 통합할 수 있습니다.\n기본적으로 Claude Code는 코드 읽기, 쓰기, 실행 등의 기능을 제공하지만, MCP 서버를 추가하면 이러한 능력을 무한히 확장할 수 있습니다. 예를 들어, Context7 MCP 서버를 연결하면 최신 라이브러리 문서를 실시간으로 검색할 수 있고, Exa MCP 서버를 통해 웹 검색 결과를 활용할 수 있습니다.\nMCP 아키텍처 MCP는 클라이언트-서버 구조로 동작합니다.\nClaude Code (클라이언트) ↕ MCP Protocol MCP 서버 (filesystem, context7, exa 등) ↕ 각 서버의 프로토콜 외부 서비스 (파일시스템, API, 데이터베이스 등) 클라이언트인 Claude Code는 표준화된 MCP 프로토콜로 서버에 요청을 보내고, 각 MCP 서버는 자신이 연결된 외부 서비스에 맞는 방식으로 요청을 처리합니다. 이러한 추상화 덕분에 Claude Code는 각 서비스의 세부 구현을 몰라도 통일된 방식으로 다양한 도구를 사용할 수 있습니다.\nMCP 서버 설정하기 MCP 서버는 .claude/settings.json 파일에서 설정합니다.\n{ \u0026#34;mcpServers\u0026#34;: { \u0026#34;filesystem\u0026#34;: { \u0026#34;command\u0026#34;: \u0026#34;npx\u0026#34;, \u0026#34;args\u0026#34;: [\u0026#34;-y\u0026#34;, \u0026#34;@anthropic-ai/mcp-filesystem-server\u0026#34;, \u0026#34;/home/user/projects\u0026#34;] }, \u0026#34;context7\u0026#34;: { \u0026#34;command\u0026#34;: \u0026#34;npx\u0026#34;, \u0026#34;args\u0026#34;: [\u0026#34;-y\u0026#34;, \u0026#34;@context7/mcp-server\u0026#34;] }, \u0026#34;exa\u0026#34;: { \u0026#34;command\u0026#34;: \u0026#34;npx\u0026#34;, \u0026#34;args\u0026#34;: [\u0026#34;-y\u0026#34;, \u0026#34;@exa/mcp-server\u0026#34;], \u0026#34;env\u0026#34;: { \u0026#34;EXA_API_KEY\u0026#34;: \u0026#34;your-api-key-here\u0026#34; } } } } 각 서버 설정은 다음 요소로 구성됩니다.\ncommand: 실행할 명령어 (주로 npx 또는 node) args: 명령어 인자 (서버 패키지 이름과 옵션) env: 환경변수 (API 키 등) 설정을 저장하면 Claude Code는 세션 시작 시 자동으로 MCP 서버를 실행합니다.\n주요 MCP 서버 filesystem - 파일 시스템 접근 filesystem 서버는 특정 디렉토리에 대한 읽기/쓰기 권한을 제공합니다.\n{ \u0026#34;mcpServers\u0026#34;: { \u0026#34;filesystem\u0026#34;: { \u0026#34;command\u0026#34;: \u0026#34;npx\u0026#34;, \u0026#34;args\u0026#34;: [ \u0026#34;-y\u0026#34;, \u0026#34;@anthropic-ai/mcp-filesystem-server\u0026#34;, \u0026#34;/home/user/documents\u0026#34;, \u0026#34;/home/user/projects\u0026#34; ] } } } 제공하는 도구:\nmcp__filesystem__read_file: 파일 읽기 mcp__filesystem__write_file: 파일 쓰기 mcp__filesystem__list_directory: 디렉토리 목록 조회 mcp__filesystem__search_files: 파일 검색 context7 - 최신 문서 검색 context7 서버는 프로그래밍 라이브러리와 프레임워크의 최신 문서를 검색합니다.\n{ \u0026#34;mcpServers\u0026#34;: { \u0026#34;context7\u0026#34;: { \u0026#34;command\u0026#34;: \u0026#34;npx\u0026#34;, \u0026#34;args\u0026#34;: [\u0026#34;-y\u0026#34;, \u0026#34;@context7/mcp-server\u0026#34;] } } } 사용 예시:\n// Claude Code가 내부적으로 호출 // \u0026#34;React 18의 useTransition 훅 사용법을 알려줘\u0026#34; // → mcp__context7__resolve-library-id(\u0026#34;react\u0026#34;) // → mcp__context7__query-docs(\u0026#34;/facebook/react\u0026#34;, \u0026#34;useTransition hook usage\u0026#34;) 이 서버는 공식 문서, GitHub README, 코드 예제를 통합하여 최신 정보를 제공합니다.\nexa - 웹 검색 exa 서버는 웹 검색 기능을 제공합니다.\n{ \u0026#34;mcpServers\u0026#34;: { \u0026#34;exa\u0026#34;: { \u0026#34;command\u0026#34;: \u0026#34;npx\u0026#34;, \u0026#34;args\u0026#34;: [\u0026#34;-y\u0026#34;, \u0026#34;@exa/mcp-server\u0026#34;], \u0026#34;env\u0026#34;: { \u0026#34;EXA_API_KEY\u0026#34;: \u0026#34;your-api-key\u0026#34; } } } } 제공하는 도구:\nmcp__exa__web_search_exa: 일반 웹 검색 mcp__exa__company_research_exa: 회사 정보 검색 mcp__exa__get_code_context_exa: 코드 예제 검색 MCP 도구 호출 방식 MCP 서버가 등록되면 Claude Code는 자동으로 해당 서버의 도구들을 인식합니다. 도구 이름은 mcp__서버명__도구명 패턴을 따릅니다.\nmcp__filesystem__read_file mcp__context7__query-docs mcp__exa__web_search_exa 사용자가 직접 이 도구들을 호출할 필요는 없습니다. Claude Code가 문맥에 맞게 자동으로 선택하여 사용합니다.\n예를 들어:\n\u0026quot;이 프로젝트의 package.json을 읽어줘\u0026quot; → filesystem 서버 사용 \u0026quot;Next.js 15의 App Router 사용법\u0026quot; → context7 서버 사용 \u0026quot;2026년 JavaScript 트렌드\u0026quot; → exa 서버 사용 커스텀 MCP 서버 만들기 자신만의 MCP 서버를 만들어 특정 API나 데이터베이스를 Claude Code와 연결할 수 있습니다.\nNode.js MCP 서버 예시 간단한 데이터베이스 조회 MCP 서버를 만들어보겠습니다.\n// db-mcp-server.js const { Server } = require(\u0026#39;@modelcontextprotocol/sdk/server/index.js\u0026#39;); const { StdioServerTransport } = require(\u0026#39;@modelcontextprotocol/sdk/server/stdio.js\u0026#39;); const mysql = require(\u0026#39;mysql2/promise\u0026#39;); const server = new Server( { name: \u0026#39;database-server\u0026#39;, version: \u0026#39;1.0.0\u0026#39;, }, { capabilities: { tools: {}, }, } ); // 데이터베이스 연결 풀 const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, }); // 도구 목록 제공 server.setRequestHandler(\u0026#39;tools/list\u0026#39;, async () =\u0026gt; { return { tools: [ { name: \u0026#39;query_users\u0026#39;, description: \u0026#39;Query users from the database\u0026#39;, inputSchema: { type: \u0026#39;object\u0026#39;, properties: { limit: { type: \u0026#39;number\u0026#39;, description: \u0026#39;Maximum number of users to return\u0026#39;, }, }, }, }, { name: \u0026#39;get_user\u0026#39;, description: \u0026#39;Get a specific user by ID\u0026#39;, inputSchema: { type: \u0026#39;object\u0026#39;, properties: { userId: { type: \u0026#39;number\u0026#39;, description: \u0026#39;User ID\u0026#39;, }, }, required: [\u0026#39;userId\u0026#39;], }, }, ], }; }); // 도구 실행 핸들러 server.setRequestHandler(\u0026#39;tools/call\u0026#39;, async (request) =\u0026gt; { const { name, arguments: args } = request.params; if (name === \u0026#39;query_users\u0026#39;) { const limit = args.limit || 10; const [rows] = await pool.query(\u0026#39;SELECT * FROM users LIMIT ?\u0026#39;, [limit]); return { content: [ { type: \u0026#39;text\u0026#39;, text: JSON.stringify(rows, null, 2), }, ], }; } if (name === \u0026#39;get_user\u0026#39;) { const [rows] = await pool.query(\u0026#39;SELECT * FROM users WHERE id = ?\u0026#39;, [args.userId]); return { content: [ { type: \u0026#39;text\u0026#39;, text: JSON.stringify(rows[0] || null, null, 2), }, ], }; } throw new Error(`Unknown tool: ${name}`); }); // 서버 시작 async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main().catch(console.error); package.json 설정 { \u0026#34;name\u0026#34;: \u0026#34;db-mcp-server\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;1.0.0\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;module\u0026#34;, \u0026#34;bin\u0026#34;: { \u0026#34;db-mcp-server\u0026#34;: \u0026#34;./db-mcp-server.js\u0026#34; }, \u0026#34;dependencies\u0026#34;: { \u0026#34;@modelcontextprotocol/sdk\u0026#34;: \u0026#34;^0.5.0\u0026#34;, \u0026#34;mysql2\u0026#34;: \u0026#34;^3.6.0\u0026#34; } } Claude Code에서 사용 { \u0026#34;mcpServers\u0026#34;: { \u0026#34;database\u0026#34;: { \u0026#34;command\u0026#34;: \u0026#34;node\u0026#34;, \u0026#34;args\u0026#34;: [\u0026#34;/path/to/db-mcp-server/db-mcp-server.js\u0026#34;], \u0026#34;env\u0026#34;: { \u0026#34;DB_HOST\u0026#34;: \u0026#34;localhost\u0026#34;, \u0026#34;DB_USER\u0026#34;: \u0026#34;root\u0026#34;, \u0026#34;DB_PASSWORD\u0026#34;: \u0026#34;password\u0026#34;, \u0026#34;DB_NAME\u0026#34;: \u0026#34;myapp\u0026#34; } } } } 이제 Claude Code에서 \u0026quot;사용자 목록을 조회해줘\u0026quot;라고 요청하면 자동으로 mcp__database__query_users 도구를 호출합니다.\n실전 활용 사례 사례 1: 문서 검색 통합 프로젝트에서 사용하는 라이브러리의 최신 문서를 실시간으로 참조할 수 있습니다.\n사용자: \u0026#34;Prisma ORM에서 트랜잭션을 어떻게 사용하나요?\u0026#34; Claude Code: 1. mcp__context7__resolve-library-id(\u0026#34;prisma\u0026#34;) 2. mcp__context7__query-docs(\u0026#34;/prisma/prisma\u0026#34;, \u0026#34;transaction usage\u0026#34;) 3. 최신 공식 문서 기반으로 답변 이는 Claude의 지식 컷오프 날짜 이후에 나온 새로운 기능도 정확히 안내할 수 있게 합니다.\n사례 2: 데이터베이스 스키마 분석 커스텀 MCP 서버를 통해 데이터베이스 스키마를 분석하고 마이그레이션을 생성할 수 있습니다.\n// \u0026#34;users 테이블에 verified_at 컬럼을 추가하는 마이그레이션을 만들어줘\u0026#34; // → mcp__database__describe_table(\u0026#34;users\u0026#34;) // → 현재 스키마 확인 후 마이그레이션 파일 생성 사례 3: CI/CD 파이프라인 연결 GitHub Actions나 Jenkins와 연결하여 빌드 상태를 확인하거나 배포를 트리거할 수 있습니다.\n{ \u0026#34;mcpServers\u0026#34;: { \u0026#34;github\u0026#34;: { \u0026#34;command\u0026#34;: \u0026#34;npx\u0026#34;, \u0026#34;args\u0026#34;: [\u0026#34;-y\u0026#34;, \u0026#34;@github/mcp-server\u0026#34;], \u0026#34;env\u0026#34;: { \u0026#34;GITHUB_TOKEN\u0026#34;: \u0026#34;ghp_your_token\u0026#34; } } } } 사용자: \u0026#34;최근 빌드가 실패한 이유를 분석해줘\u0026#34; Claude Code: 1. mcp__github__get_workflow_runs() 2. 실패한 빌드 로그 분석 3. 원인 파악 및 수정 제안 MCP 서버 디버깅 MCP 서버가 제대로 작동하지 않을 때는 다음을 확인하세요.\n서버 로그 확인 # MCP 서버 직접 실행하여 에러 확인 node /path/to/mcp-server.js settings.json 문법 검증 JSON 문법 오류가 있으면 서버가 로드되지 않습니다. JSON 검증기로 확인하세요.\n환경변수 확인 API 키 등 필수 환경변수가 올바르게 설정되었는지 확인합니다.\n{ \u0026#34;mcpServers\u0026#34;: { \u0026#34;exa\u0026#34;: { \u0026#34;command\u0026#34;: \u0026#34;npx\u0026#34;, \u0026#34;args\u0026#34;: [\u0026#34;-y\u0026#34;, \u0026#34;@exa/mcp-server\u0026#34;], \u0026#34;env\u0026#34;: { \u0026#34;EXA_API_KEY\u0026#34;: \u0026#34;${EXA_API_KEY}\u0026#34; } } } } 시스템 환경변수를 참조하려면 ${변수명} 형식을 사용합니다.\n마무리 MCP는 Claude Code의 능력을 무한히 확장할 수 있는 강력한 메커니즘입니다. 파일 시스템, 문서 검색, 웹 API, 데이터베이스 등 필요한 모든 외부 도구를 표준화된 방식으로 연결할 수 있습니다.\n기본 제공되는 MCP 서버들을 활용하는 것부터 시작하여, 점차 프로젝트에 특화된 커스텀 MCP 서버를 만들어보세요. 이를 통해 Claude Code는 단순한 코드 편집기를 넘어 프로젝트 전체를 이해하고 관리하는 지능형 개발 파트너가 될 것입니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/claude-code-mcp-servers/","summary":"\u003ch2 id=\"mcp란-무엇인가\"\u003eMCP란 무엇인가\u003c/h2\u003e\n\u003cp\u003eMCP(Model Context Protocol)는 AI 모델과 외부 도구를 연결하는 표준 프로토콜입니다. Claude Code는 MCP를 통해 파일 시스템, 데이터베이스, 웹 API, 문서 검색 엔진 등 다양한 외부 서비스와 통합할 수 있습니다.\u003c/p\u003e\n\u003cp\u003e기본적으로 Claude Code는 코드 읽기, 쓰기, 실행 등의 기능을 제공하지만, MCP 서버를 추가하면 이러한 능력을 무한히 확장할 수 있습니다. 예를 들어, Context7 MCP 서버를 연결하면 최신 라이브러리 문서를 실시간으로 검색할 수 있고, Exa MCP 서버를 통해 웹 검색 결과를 활용할 수 있습니다.\u003c/p\u003e","tags":["Claude Code","MCP","도구통합","AI코딩"],"title":"Claude Code MCP 서버 - 설정, 주요 서버 소개, 커스텀 서버 구축"},{"content":"Hooks 시스템 개요 Claude Code의 Hooks는 도구 실행의 특정 시점에 자동으로 쉘 명령을 실행할 수 있는 자동화 메커니즘입니다. 파일을 편집할 때마다 자동으로 린팅을 실행하거나, 세션 시작 시 프로젝트 컨텍스트를 로딩하거나, 특정 도구 호출 후 알림을 보내는 등 다양한 자동화가 가능합니다.\nHooks는 개발 워크플로우를 크게 개선할 수 있습니다. 반복적인 작업을 자동화하고, 코드 품질을 즉각적으로 검증하며, 작업 흐름을 방해하지 않으면서도 필요한 체크를 수행할 수 있습니다.\nHook 이벤트 타입 Claude Code는 네 가지 주요 이벤트 타입을 제공합니다.\nPreToolUse - 도구 실행 전 도구가 실행되기 직전에 트리거됩니다. 주로 사전 검증이나 준비 작업에 사용합니다.\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;PreToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Write\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;echo \u0026#39;About to write file...\u0026#39;\u0026#34; } ] } ] } } PostToolUse - 도구 실행 후 도구 실행이 완료된 직후에 트리거됩니다. 결과 검증이나 후속 작업에 유용합니다.\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;npm run lint-staged\u0026#34; } ] } ] } } SessionStart - 세션 시작 Claude Code 세션이 시작될 때 한 번 실행됩니다. 환경 설정이나 초기화 작업에 적합합니다.\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;SessionStart\u0026#34;: [ { \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;git fetch origin\u0026#34; } ] } ] } } UserPromptSubmit - 프롬프트 제출 사용자가 프롬프트를 제출할 때마다 실행됩니다. 사용자 입력에 따른 동적 처리에 사용합니다.\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;UserPromptSubmit\u0026#34;: [ { \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;echo \u0026#39;User submitted a prompt\u0026#39;\u0026#34; } ] } ] } } Hook 설정 방법 Hooks는 .claude/settings.json 파일에서 설정합니다.\n기본 구조 { \u0026#34;hooks\u0026#34;: { \u0026#34;이벤트타입\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;도구이름\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;실행할 명령어\u0026#34; } ] } ] } } matcher 패턴 matcher는 어떤 도구에 대해 hook을 실행할지 지정합니다.\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;hooks\u0026#34;: [{ \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;npm run lint\u0026#34; }] }, { \u0026#34;matcher\u0026#34;: \u0026#34;Write\u0026#34;, \u0026#34;hooks\u0026#34;: [{ \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;npm run format\u0026#34; }] } ] } } 특정 도구에만 적용하려면 도구 이름을 정확히 명시합니다. 모든 도구에 적용하려면 matcher를 생략하거나 정규식을 사용할 수 있습니다.\n컨텍스트 변수 Hook 명령어에서는 다음 환경변수를 사용할 수 있습니다.\ntool_name: 실행된 도구 이름 (예: Edit, Write, Bash) tool_input: 도구에 전달된 입력 (JSON 문자열) tool_response: 도구 실행 결과 (PostToolUse에서만 사용 가능) session_id: 현재 세션 ID cwd: 현재 작업 디렉토리 변수 활용 예시 { \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;echo \\\u0026#34;Edited file in session: $session_id\\\u0026#34;\u0026#34; } ] } ] } } 실전 예시 1: 파일 편집 후 자동 린팅 코드를 편집할 때마다 자동으로 ESLint를 실행하여 즉시 문제를 발견할 수 있습니다.\nsettings.json 설정 { \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;npm run lint-staged 2\u0026gt;\u0026amp;1 || echo \u0026#39;Lint found issues\u0026#39;\u0026#34; } ] } ] } } package.json 설정 { \u0026#34;scripts\u0026#34;: { \u0026#34;lint-staged\u0026#34;: \u0026#34;eslint --fix $(git diff --name-only --cached | grep \u0026#39;\\\\.js$\u0026#39;)\u0026#34; } } 이렇게 설정하면 파일을 편집할 때마다 자동으로 ESLint가 실행되고, 가능한 문제는 자동으로 수정됩니다.\nHook 실행 흐름 1. Claude Code가 Edit 도구 실행 2. 파일 수정 완료 3. PostToolUse hook 트리거 4. npm run lint-staged 실행 5. ESLint가 수정된 파일 검사 6. 자동 수정 가능한 문제 해결 7. 결과를 Claude Code에 전달 실전 예시 2: 세션 시작 시 프로젝트 컨텍스트 로딩 세션이 시작될 때 자동으로 프로젝트 정보를 로드하여 Claude Code가 프로젝트를 더 잘 이해하도록 합니다.\n컨텍스트 로더 스크립트 #!/bin/bash # .claude/scripts/load-context.sh echo \u0026#34;Loading project context...\u0026#34; # Git 정보 수집 BRANCH=$(git rev-parse --abbrev-ref HEAD) LAST_COMMIT=$(git log -1 --oneline) # 프로젝트 구조 분석 FILE_COUNT=$(find src -type f | wc -l) JS_FILES=$(find src -name \u0026#34;*.js\u0026#34; | wc -l) TS_FILES=$(find src -name \u0026#34;*.ts\u0026#34; | wc -l) # 의존성 확인 HAS_ESLINT=$(grep -q \u0026#34;eslint\u0026#34; package.json \u0026amp;\u0026amp; echo \u0026#34;yes\u0026#34; || echo \u0026#34;no\u0026#34;) HAS_PRETTIER=$(grep -q \u0026#34;prettier\u0026#34; package.json \u0026amp;\u0026amp; echo \u0026#34;yes\u0026#34; || echo \u0026#34;no\u0026#34;) # 컨텍스트 정보 출력 cat \u0026lt;\u0026lt;EOF Project Context Loaded: - Branch: $BRANCH - Last commit: $LAST_COMMIT - Total files: $FILE_COUNT - JavaScript files: $JS_FILES - TypeScript files: $TS_FILES - ESLint configured: $HAS_ESLINT - Prettier configured: $HAS_PRETTIER EOF Hook 설정 { \u0026#34;hooks\u0026#34;: { \u0026#34;SessionStart\u0026#34;: [ { \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;bash .claude/scripts/load-context.sh\u0026#34; } ] } ] } } 세션이 시작될 때마다 이 스크립트가 실행되어 프로젝트 상태를 요약하여 Claude Code에 제공합니다.\n실전 예시 3: 테스트 자동 실행 코드를 수정한 후 관련 테스트를 자동으로 실행하여 즉시 피드백을 받을 수 있습니다.\n스마트 테스트 러너 #!/bin/bash # .claude/scripts/smart-test.sh # tool_input에서 수정된 파일 경로 추출 MODIFIED_FILE=$(echo \u0026#34;$tool_input\u0026#34; | jq -r \u0026#39;.file_path // empty\u0026#39;) if [ -z \u0026#34;$MODIFIED_FILE\u0026#34; ]; then echo \u0026#34;No file path found\u0026#34; exit 0 fi # 파일 이름에서 테스트 파일 추론 TEST_FILE=\u0026#34;${MODIFIED_FILE%.js}.test.js\u0026#34; if [ -f \u0026#34;$TEST_FILE\u0026#34; ]; then echo \u0026#34;Running tests for $MODIFIED_FILE...\u0026#34; npm test -- \u0026#34;$TEST_FILE\u0026#34; else echo \u0026#34;No test file found for $MODIFIED_FILE\u0026#34; fi Hook 설정 { \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;bash .claude/scripts/smart-test.sh\u0026#34; } ] } ] } } 이제 src/utils/format.js를 수정하면 자동으로 src/utils/format.test.js가 실행됩니다.\n고급 기능: 조건부 실행 특정 조건에서만 hook을 실행하도록 스크립트를 작성할 수 있습니다.\n조건부 린팅 #!/bin/bash # .claude/scripts/conditional-lint.sh # JavaScript/TypeScript 파일만 린트 MODIFIED_FILE=$(echo \u0026#34;$tool_input\u0026#34; | jq -r \u0026#39;.file_path // empty\u0026#39;) if [[ \u0026#34;$MODIFIED_FILE\u0026#34; =~ \\.(js|ts|jsx|tsx)$ ]]; then echo \u0026#34;Linting $MODIFIED_FILE...\u0026#34; npx eslint \u0026#34;$MODIFIED_FILE\u0026#34; --fix else echo \u0026#34;Skipping lint for non-JS file: $MODIFIED_FILE\u0026#34; fi 브랜치별 검증 #!/bin/bash # .claude/scripts/branch-check.sh CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) if [ \u0026#34;$CURRENT_BRANCH\u0026#34; = \u0026#34;main\u0026#34; ]; then echo \u0026#34;WARNING: You are on the main branch!\u0026#34; echo \u0026#34;Running full test suite...\u0026#34; npm test else echo \u0026#34;On branch: $CURRENT_BRANCH - running quick checks only\u0026#34; npm run lint fi 매처 패턴 고급 활용 여러 도구에 동일한 hook을 적용하거나, 특정 패턴의 도구만 선택할 수 있습니다.\n여러 도구에 적용 { \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;hooks\u0026#34;: [{ \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;npm run format\u0026#34; }] }, { \u0026#34;matcher\u0026#34;: \u0026#34;Write\u0026#34;, \u0026#34;hooks\u0026#34;: [{ \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;npm run format\u0026#34; }] } ] } } 모든 도구에 적용 { \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;echo \\\u0026#34;Tool executed: $tool_name\\\u0026#34;\u0026#34; } ] } ] } } matcher를 생략하면 모든 도구에 대해 hook이 실행됩니다.\n디버깅 및 문제 해결 Hook 실행 로그 확인 Hook이 예상대로 작동하지 않을 때는 로그를 확인합니다.\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;echo \\\u0026#34;[DEBUG] tool_name=$tool_name tool_input=$tool_input\\\u0026#34; \u0026gt;\u0026gt; /tmp/claude-hooks.log \u0026amp;\u0026amp; npm run lint\u0026#34; } ] } ] } } /tmp/claude-hooks.log 파일에서 hook이 실제로 실행되었는지, 어떤 컨텍스트 변수가 전달되었는지 확인할 수 있습니다.\n에러 처리 Hook 실행이 실패해도 Claude Code의 주 작업은 계속됩니다. 하지만 에러를 명시적으로 처리하면 더 나은 피드백을 받을 수 있습니다.\n#!/bin/bash # .claude/scripts/safe-lint.sh set -e # 에러 발생 시 즉시 종료 if ! command -v eslint \u0026amp;\u0026gt; /dev/null; then echo \u0026#34;ESLint not found. Skipping lint.\u0026#34; exit 0 fi if ! npx eslint \u0026#34;$MODIFIED_FILE\u0026#34; --fix; then echo \u0026#34;ESLint found issues that could not be auto-fixed\u0026#34; echo \u0026#34;Please review the errors above\u0026#34; exit 1 fi Hook 비활성화 디버깅 중 일시적으로 hook을 비활성화하려면 환경변수를 사용합니다.\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;[ \\\u0026#34;$DISABLE_HOOKS\\\u0026#34; != \\\u0026#34;true\\\u0026#34; ] \u0026amp;\u0026amp; npm run lint || echo \u0026#39;Hooks disabled\u0026#39;\u0026#34; } ] } ] } } # Hooks 비활성화하고 Claude Code 실행 DISABLE_HOOKS=true claude Hook 활용 모범 사례 빠른 피드백 우선 Hook은 즉각적으로 실행되므로 빠른 작업을 우선합니다. 느린 테스트나 빌드는 별도 CI/CD에서 실행하는 것이 좋습니다.\n{ \u0026#34;hooks\u0026#34;: { \u0026#34;PostToolUse\u0026#34;: [ { \u0026#34;matcher\u0026#34;: \u0026#34;Edit\u0026#34;, \u0026#34;hooks\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;npm run lint\u0026#34; } ] } ] } } 전체 테스트 스위트는 포함하지 않습니다.\n멱등성 보장 Hook은 여러 번 실행될 수 있으므로 멱등성을 보장해야 합니다.\n# 나쁜 예: 매번 파일에 추가 echo \u0026#34;lint result\u0026#34; \u0026gt;\u0026gt; results.log # 좋은 예: 덮어쓰기 echo \u0026#34;lint result\u0026#34; \u0026gt; results.log 명확한 피드백 Hook 실행 결과를 명확히 출력하여 사용자가 무슨 일이 일어났는지 알 수 있도록 합니다.\necho \u0026#34;Running ESLint on modified files...\u0026#34; npm run lint echo \u0026#34;Lint complete\u0026#34; 마무리 Claude Code의 Hooks 시스템은 개발 워크플로우를 자동화하는 강력한 도구입니다. 파일 편집 후 자동 린팅, 세션 시작 시 컨텍스트 로딩, 조건부 테스트 실행 등 다양한 자동화를 구현할 수 있습니다.\n효과적인 hook 설정은 반복 작업을 줄이고 코드 품질을 즉시 검증하여 개발 생산성을 크게 향상시킵니다. 프로젝트의 특성에 맞는 hook을 설정하여 더 스마트한 개발 환경을 구축해보세요.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/claude-code-hooks-automation/","summary":"\u003ch2 id=\"hooks-시스템-개요\"\u003eHooks 시스템 개요\u003c/h2\u003e\n\u003cp\u003eClaude Code의 Hooks는 도구 실행의 특정 시점에 자동으로 쉘 명령을 실행할 수 있는 자동화 메커니즘입니다. 파일을 편집할 때마다 자동으로 린팅을 실행하거나, 세션 시작 시 프로젝트 컨텍스트를 로딩하거나, 특정 도구 호출 후 알림을 보내는 등 다양한 자동화가 가능합니다.\u003c/p\u003e\n\u003cp\u003eHooks는 개발 워크플로우를 크게 개선할 수 있습니다. 반복적인 작업을 자동화하고, 코드 품질을 즉각적으로 검증하며, 작업 흐름을 방해하지 않으면서도 필요한 체크를 수행할 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"hook-이벤트-타입\"\u003eHook 이벤트 타입\u003c/h2\u003e\n\u003cp\u003eClaude Code는 네 가지 주요 이벤트 타입을 제공합니다.\u003c/p\u003e","tags":["Claude Code","Hooks","자동화","워크플로우"],"title":"Claude Code Hooks - 이벤트 타입, 자동 린팅, 조건부 실행 설정"},{"content":"슬래시 커맨드란? Claude Code에서 슬래시 커맨드(Slash Command)는 /commit, /plan, /review처럼 /로 시작하는 단축 명령어입니다. 이 커맨드들은 복잡한 작업 지시를 간단한 한 줄로 실행할 수 있게 해주며, 반복적인 작업을 자동화하는 강력한 도구입니다.\n내장 커맨드 외에도 프로젝트별로 커스텀 슬래시 커맨드를 정의하여 팀의 워크플로우에 맞는 자동화를 구축할 수 있습니다.\n커스텀 커맨드의 필요성 반복 작업의 문제 다음과 같은 상황을 생각해봅시다:\n\u0026#34;새 블로그 포스트를 만들어줘. 제목은 [title]이고, 날짜는 오늘, 태그는 [tags], 시리즈는 [series]로 설정하고, /content/posts/ 디렉토리에 slug 형식으로 파일명 만들고, 프론트매터는 기존 포스트 형식 따라서...\u0026#34; 이런 지시를 매번 반복하는 것은 비효율적입니다. 커스텀 커맨드를 만들면:\n/new-post \u0026#34;Next.js 서버 액션 가이드\u0026#34; --tags \u0026#34;Next.js, React\u0026#34; --series \u0026#34;Next.js 심화\u0026#34; 한 줄로 해결할 수 있습니다.\n커맨드 파일 구조 Claude Code의 커스텀 커맨드는 .claude/commands/ 디렉토리에 마크다운 파일로 정의됩니다.\n기본 디렉토리 구조 your-project/ ├── .claude/ │ ├── commands/ │ │ ├── new-post.md │ │ ├── update-deps.md │ │ ├── run-tests.md │ │ └── deploy-preview.md │ └── CLAUDE.md ├── src/ └── package.json 커맨드 파일 형식 각 커맨드 파일은 프론트매터와 프롬프트 본문으로 구성됩니다.\n--- description: \u0026#34;Create a new blog post with proper front matter\u0026#34; args: - name: title description: \u0026#34;Post title\u0026#34; required: true - name: tags description: \u0026#34;Comma-separated tags\u0026#34; required: false - name: series description: \u0026#34;Series name\u0026#34; required: false --- Create a new blog post with the following specifications: Title: {{title}} Date: {{currentDate}} Tags: {{tags || \u0026#34;General\u0026#34;}} Series: {{series || \u0026#34;none\u0026#34;}} 1. Generate a URL-friendly slug from the title 2. Create /content/posts/{slug}.md 3. Add proper front matter following our blog format 4. Include basic content structure with h2 headings 5. Open the file for editing Front matter template: ```yaml --- title: \u0026#34;{{title}}\u0026#34; date: {{currentDate}} description: \u0026#34;TODO: Add description\u0026#34; tags: [{{tags}}] {{#if series}}series: \u0026#34;{{series}}\u0026#34;{{/if}} draft: false --- Content template:\nIntroduction section Main content sections (at least 2 h2 headings) Conclusion section ## 실전 커맨드 예제 ### 1. 블로그 포스트 생성 커맨드 **`.claude/commands/new-post.md`** ```markdown --- description: \u0026#34;Create a new blog post with Korean content structure\u0026#34; args: - name: title description: \u0026#34;Post title in Korean\u0026#34; required: true - name: tags description: \u0026#34;Comma-separated tags\u0026#34; required: true - name: series description: \u0026#34;Series name (optional)\u0026#34; required: false --- Create a new Korean blog post: **Title:** {{title}} **Date:** {{currentDate}} **Tags:** {{tags}} **Series:** {{series}} ## Steps: 1. Generate slug from title (romanized, lowercase, hyphens) 2. Create `/content/posts/{slug}.md` with this front matter: ```yaml --- title: \u0026#34;{{title}}\u0026#34; date: {{currentDate}} description: \u0026#34;{{title}}에 대한 상세 가이드\u0026#34; tags: [{{tags}}] {{#if series}}series: \u0026#34;{{series}}\u0026#34;{{/if}} draft: false --- Add Korean blog post structure:\n서론 (h2): 주제 소개 본론 (h2): 핵심 내용 (2-3개 섹션) 실전 예제 (h2): 코드 예제 포함 마치며 (h2): 요약 및 다음 단계 Minimum 1500 characters in Korean\nInclude at least 2 code blocks with proper language tags\nOpen the file for review\nAfter creation, show me the file path and ask if I want to edit anything.\n**사용 예:** ```text /new-post \u0026#34;React Server Components 완벽 가이드\u0026#34; --tags \u0026#34;React, Next.js, RSC\u0026#34; --series \u0026#34;Next.js 심화\u0026#34; 2. 의존성 업데이트 커맨드 .claude/commands/update-deps.md\n--- description: \u0026#34;Update project dependencies safely\u0026#34; args: - name: scope description: \u0026#34;Update scope: all, prod, dev, or package-name\u0026#34; required: false default: \u0026#34;all\u0026#34; --- Safely update project dependencies: **Scope:** {{scope}} ## Process: 1. **Backup:** Create git commit with current state ```bash git add -A git commit -m \u0026#34;chore: backup before dependency update\u0026#34; Check outdated packages:\nnpm outdated Update based on scope: {{#if (eq scope \u0026quot;all\u0026quot;)}}\nnpm update {{else if (eq scope \u0026quot;prod\u0026quot;)}}\nnpm update --save {{else if (eq scope \u0026quot;dev\u0026quot;)}}\nnpm update --save-dev {{else}}\nnpm update {{scope}} {{/if}}\nVerify:\nRun npm run typecheck Run npm test Run npm run build Report:\nList updated packages with version changes Show any breaking changes or warnings Recommend manual testing areas If any step fails, rollback with:\ngit reset --hard HEAD~1 Show me the results before committing the update.\n**사용 예:** ```text /update-deps /update-deps --scope next /update-deps --scope dev 3. 테스트 실행 및 리포트 커맨드 .claude/commands/test-full.md\n--- description: \u0026#34;Run full test suite with coverage report\u0026#34; args: - name: watch description: \u0026#34;Run in watch mode\u0026#34; required: false default: false --- Run comprehensive test suite: ## Test Execution Plan: 1. **Type Check:** ```bash npm run typecheck Linting:\nnpm run lint Unit Tests: {{#if watch}}\nnpm test -- --watch {{else}}\nnpm test -- --coverage {{/if}}\nBuild Verification:\nnpm run build Report Format: After all tests complete, provide:\n✅ Pass/Fail Status TypeScript: [PASS/FAIL] Linting: [PASS/FAIL] Unit Tests: [PASS/FAIL] (X/Y tests) Build: [PASS/FAIL] 📊 Coverage Summary (if applicable) Statements: X% Branches: X% Functions: X% Lines: X% ❌ Failures (if any) List each failure with:\nTest name Error message File location Suggested fix 📝 Recommendations Areas needing more test coverage Flaky tests to investigate Performance concerns Run all steps and show me the consolidated report.\n**사용 예:** ```text /test-full /test-full --watch 4. 배포 프리뷰 생성 커맨드 .claude/commands/deploy-preview.md\n--- description: \u0026#34;Create preview deployment and share URL\u0026#34; args: - name: message description: \u0026#34;Deployment message/description\u0026#34; required: false --- Create preview deployment: **Message:** {{message || \u0026#34;Preview deployment\u0026#34;}} ## Deployment Steps: 1. **Pre-deployment checks:** - Verify no uncommitted changes (or auto-commit them) - Run `npm run build` to ensure production build works - Check for any critical errors or warnings 2. **Create preview branch:** ```bash git checkout -b preview/$(date +%Y%m%d-%H%M%S) Deploy to preview environment:\nvercel deploy --prod=false Capture deployment info:\nPreview URL Deployment ID Commit SHA Timestamp Generate shareable message:\n🚀 Preview Deployment Ready 📎 URL: [preview-url] 💬 Message: {{message}} 🔖 Commit: [sha] ⏰ Time: [timestamp] Test the following: - [ ] Homepage loads correctly - [ ] Blog posts render properly - [ ] Search functionality works - [ ] Navigation is functional Cleanup:\ngit checkout - git branch -D preview/* Show me the preview URL and the test checklist.\n**사용 예:** ```text /deploy-preview /deploy-preview --message \u0026#34;Testing new search feature\u0026#34; 고급 패턴 1. 조건부 로직 Handlebars 템플릿 문법을 활용하여 조건부 동작을 정의할 수 있습니다.\n{{#if (eq environment \u0026#34;production\u0026#34;)}} Run full test suite including E2E tests {{else}} Run unit tests only {{/if}} 2. 반복문 여러 항목을 처리할 때 유용합니다.\n{{#each files}} Process file: {{this}} - Check formatting - Run linter - Verify imports {{/each}} 3. 변수와 헬퍼 내장 변수와 헬퍼 함수를 활용합니다.\n- Current date: {{currentDate}} - Current time: {{currentTime}} - Project root: {{projectRoot}} - Git branch: {{gitBranch}} 베스트 프랙티스 1. 명확한 설명 작성 커맨드의 목적과 사용법을 description에 명확히 작성하세요.\n--- description: \u0026#34;Create API endpoint with TypeScript types, validation, and tests\u0026#34; --- 2. 필수/선택 인자 구분 사용자가 어떤 인자를 꼭 제공해야 하는지 명시하세요.\nargs: - name: endpoint description: \u0026#34;API endpoint path (e.g., /api/users)\u0026#34; required: true - name: method description: \u0026#34;HTTP method (GET, POST, etc.)\u0026#34; required: true - name: auth description: \u0026#34;Require authentication\u0026#34; required: false default: true 3. 단계별 지시 복잡한 작업은 번호가 매겨진 단계로 나누어 작성하세요.\n1. First, do X 2. Then, verify Y 3. Finally, report Z 4. 안전장치 추가 실수를 방지하기 위한 확인 단계를 포함하세요.\nBefore proceeding: - Confirm no uncommitted changes will be lost - Verify backup exists - Check production safeguards are in place Ask for confirmation before executing destructive operations. 5. 결과 보고 형식 정의 커맨드 실행 후 어떤 정보를 보여줄지 명시하세요.\nAfter completion, report: - Files created/modified (with paths) - Any errors or warnings encountered - Next steps or recommendations - Verification checklist 커맨드 관리 팁 1. 팀과 공유 .claude/commands/ 디렉토리를 Git에 커밋하여 팀원들이 동일한 커맨드를 사용하도록 합니다.\ngit add .claude/commands/ git commit -m \u0026#34;Add custom slash commands for blog workflow\u0026#34; 2. 문서화 README.md나 별도 문서에 사용 가능한 커맨드 목록과 예제를 정리하세요.\n## Available Custom Commands - `/new-post` - Create new blog post - `/update-deps` - Update dependencies safely - `/test-full` - Run complete test suite - `/deploy-preview` - Create preview deployment 3. 정기적으로 리뷰 사용하지 않는 커맨드는 삭제하고, 자주 사용하는 워크플로우는 커맨드로 만드세요.\n4. 버전 관리 커맨드 파일에 주석으로 변경 이력을 남기세요.\n\u0026lt;!-- Version: 1.2.0 Last updated: 2026-02-09 Changes: - Added --series parameter support - Improved slug generation for Korean titles --\u0026gt; 마무리 커스텀 슬래시 커맨드는 Claude Code를 프로젝트의 워크플로우에 완벽하게 통합하는 강력한 방법입니다. 반복적인 작업을 자동화하고, 팀의 베스트 프랙티스를 코드화하며, 일관된 품질을 유지할 수 있습니다.\n처음에는 간단한 커맨드 하나부터 시작하세요. 팀원들의 피드백을 받아 점진적으로 개선하다 보면, 프로젝트에 최적화된 커맨드 라이브러리를 구축할 수 있을 것입니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/claude-code-custom-slash-commands/","summary":"\u003ch2 id=\"슬래시-커맨드란\"\u003e슬래시 커맨드란?\u003c/h2\u003e\n\u003cp\u003eClaude Code에서 슬래시 커맨드(Slash Command)는 \u003ccode\u003e/commit\u003c/code\u003e, \u003ccode\u003e/plan\u003c/code\u003e, \u003ccode\u003e/review\u003c/code\u003e처럼 \u003ccode\u003e/\u003c/code\u003e로 시작하는 단축 명령어입니다. 이 커맨드들은 복잡한 작업 지시를 간단한 한 줄로 실행할 수 있게 해주며, 반복적인 작업을 자동화하는 강력한 도구입니다.\u003c/p\u003e\n\u003cp\u003e내장 커맨드 외에도 프로젝트별로 커스텀 슬래시 커맨드를 정의하여 팀의 워크플로우에 맞는 자동화를 구축할 수 있습니다.\u003c/p\u003e\n\u003ch2 id=\"커스텀-커맨드의-필요성\"\u003e커스텀 커맨드의 필요성\u003c/h2\u003e\n\u003ch3 id=\"반복-작업의-문제\"\u003e반복 작업의 문제\u003c/h3\u003e\n\u003cp\u003e다음과 같은 상황을 생각해봅시다:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#cdd6f4;background-color:#1e1e2e;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u0026#34;새 블로그 포스트를 만들어줘. 제목은 [title]이고, 날짜는 오늘,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e태그는 [tags], 시리즈는 [series]로 설정하고,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e/content/posts/ 디렉토리에 slug 형식으로 파일명 만들고,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e프론트매터는 기존 포스트 형식 따라서...\u0026#34;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e이런 지시를 매번 반복하는 것은 비효율적입니다. 커스텀 커맨드를 만들면:\u003c/p\u003e","tags":["Claude Code","슬래시 커맨드","자동화"],"title":"Claude Code 커스텀 슬래시 커맨드 - 커맨드 파일 구조와 실전 예제"},{"content":"CLAUDE.md란? CLAUDE.md는 Claude Code에게 프로젝트별 컨텍스트와 작업 지침을 전달하는 특수한 마크다운 파일입니다. 이 파일을 프로젝트 루트에 두면 Claude Code가 세션 시작 시 자동으로 읽어들여 프로젝트의 규칙, 코딩 스타일, 아키텍처 결정사항 등을 이해하고 작업에 반영합니다.\n전역 설정 파일인 ~/.claude/CLAUDE.md와 달리, 프로젝트 루트의 CLAUDE.md는 해당 프로젝트에만 적용되며 팀원 간 공유가 가능하여 일관된 개발 경험을 제공합니다.\nCLAUDE.md의 주요 역할 1. 프로젝트 컨텍스트 제공 Claude Code에게 프로젝트의 목적, 기술 스택, 디렉토리 구조 등을 명시적으로 알려줄 수 있습니다.\n# Project Context This is a Next.js 14 blog built with: - Framework: Next.js 14 (App Router) - Styling: Tailwind CSS - Content: MDX files in /content/posts/ - Deployment: Vercel ## Directory Structure - /app: Next.js app router pages - /components: React components - /content/posts: Blog post MDX files - /lib: Utility functions 2. 코딩 규칙과 스타일 가이드 팀의 코딩 컨벤션을 명시하여 Claude Code가 일관된 스타일로 코드를 작성하도록 유도합니다.\n## Coding Conventions ### TypeScript - Always use strict mode - Prefer type over interface for object types - Use explicit return types for all functions - No any types - use unknown instead ### React - Use functional components only (no class components) - Prefer named exports over default exports - Use React Server Components by default - Client components must have \u0026#39;use client\u0026#39; directive 3. 작업 프로세스 및 워크플로우 프로젝트별 개발 워크플로우, 테스트 전략, 배포 프로세스를 문서화합니다.\n## Development Workflow ### Before Making Changes 1. Read existing code first - understand before modifying 2. Check for related tests 3. Verify the change aligns with our architecture ### After Implementation 1. Run `npm run typecheck` - must pass 2. Run `npm test` - all tests must pass 3. Run `npm run build` - verify production build succeeds 4. Manual testing in dev environment ### Git Workflow - Feature branches: `feature/short-description` - Commit messages: Follow conventional commits - Always squash before merging to main CLAUDE.md 작성 모범 사례 1. 명확하고 구체적으로 작성 모호한 지침보다는 구체적인 예시와 함께 작성하는 것이 효과적입니다.\n나쁜 예:\n- Write good error messages 좋은 예:\n- Error messages should be user-facing and actionable: ✅ \u0026#34;Email format invalid. Expected: user@example.com\u0026#34; ❌ \u0026#34;Invalid input\u0026#34; 2. 우선순위와 맥락 제공 모든 규칙이 동등하게 중요한 것은 아닙니다. 우선순위를 명시하세요.\n## Critical Rules (MUST follow) - Never commit secrets or API keys - All database queries must use prepared statements - Authentication required for all /api/admin/* endpoints ## Style Preferences (SHOULD follow) - Prefer functional programming patterns - Use descriptive variable names - Keep functions under 50 lines 3. 예제 코드 포함 실제 코드 예제를 포함하면 Claude Code가 패턴을 더 잘 이해합니다.\n## API Route Pattern All API routes should follow this structure: ```typescript // app/api/posts/route.ts import { NextRequest, NextResponse } from \u0026#39;next/server\u0026#39;; import { z } from \u0026#39;zod\u0026#39;; const schema = z.object({ title: z.string().min(1), content: z.string(), }); export async function POST(request: NextRequest) { try { const body = await request.json(); const validated = schema.parse(body); // Business logic here return NextResponse.json({ success: true }); } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( { error: \u0026#39;Validation failed\u0026#39;, details: error.errors }, { status: 400 } ); } return NextResponse.json( { error: \u0026#39;Internal server error\u0026#39; }, { status: 500 } ); } } ### 4. 자주 발생하는 실수 명시 프로젝트에서 반복적으로 발생하는 문제를 미리 방지할 수 있습니다. ```markdown ## Common Mistakes to Avoid ❌ Don\u0026#39;t use `fetch` in Server Components - use our `db` client directly ❌ Don\u0026#39;t use `useEffect` for data fetching - use React Server Components ❌ Don\u0026#39;t import from `@/components/index` - import directly from component files ❌ Don\u0026#39;t use CSS modules - we use Tailwind only ✅ Do use Server Actions for mutations ✅ Do use `\u0026lt;Link\u0026gt;` instead of `\u0026lt;a\u0026gt;` for internal navigation ✅ Do use absolute imports with `@/` prefix ✅ Do validate all user input with Zod 실전 예시: 블로그 프로젝트 CLAUDE.md 다음은 실제 Next.js 블로그 프로젝트에서 사용할 수 있는 CLAUDE.md 예시입니다.\n# Blog Project - Claude Code Instructions ## Project Overview Next.js 14 blog with MDX content, series support, and search functionality. ## Tech Stack - Next.js 14.2+ (App Router, React Server Components) - TypeScript 5.4+ (strict mode) - Tailwind CSS 3.4+ - MDX for blog content - Vercel for deployment ## File Organization ### Content Structure - Blog posts: `/content/posts/*.md` - Front matter required: title, date, description, tags, series (optional), draft - Use ISO date format: YYYY-MM-DD ### Code Structure - `/app`: Next.js app router (layouts, pages, API routes) - `/components`: Reusable React components - `/lib`: Utilities (mdx processing, search indexing, etc.) - `/public`: Static assets (images, favicon, etc.) ## Development Rules ### Content Guidelines 1. All blog posts must include proper front matter 2. Code blocks must specify language for syntax highlighting 3. Use h2/h3 headings for automatic TOC generation 4. Keep line length under 100 characters for readability ### Code Standards 1. TypeScript strict mode - no implicit any 2. Named exports only (no default exports except page.tsx) 3. Server Components by default - add \u0026#39;use client\u0026#39; only when needed 4. Tailwind for all styling - no CSS modules or inline styles ### Before Committing - Run `npm run build` to verify production build - Check that all MDX files have valid front matter - Verify no hardcoded URLs (use env vars) - Test search functionality if content changed ## Architecture Decisions ### Why Server Components? We use React Server Components for all blog pages to: - Reduce client-side JavaScript - Improve SEO and initial page load - Simplify data fetching ### Why MDX in /content? - Version control friendly - Easy to edit in any text editor - Supports front matter for metadata - Can embed React components when needed ## Common Tasks ### Adding a New Blog Post 1. Create `/content/posts/slug-name.md` 2. Add front matter (copy from existing post) 3. Write content with proper headings 4. Test locally: `npm run dev` 5. Verify in production build: `npm run build \u0026amp;\u0026amp; npm start` ### Adding a New Component 1. Create in `/components/ComponentName.tsx` 2. Use named export: `export function ComponentName()` 3. Add TypeScript props interface 4. Use Tailwind for styling 5. Test in Storybook if applicable ## Don\u0026#39;t Do This - Don\u0026#39;t modify /node_modules or /package-lock.json manually - Don\u0026#39;t use `any` type - use `unknown` or proper types - Don\u0026#39;t create new CSS files - use Tailwind - Don\u0026#39;t use client-side rendering for static content - Don\u0026#39;t commit commented-out code - delete it ## Workflow Preferences - Read existing code before making changes - Make surgical changes - only touch what\u0026#39;s necessary - Verify changes don\u0026#39;t break existing functionality - Keep commits focused and atomic CLAUDE.md 활용 팁 1. 점진적으로 발전시키기 처음부터 완벽한 CLAUDE.md를 작성하려 하지 마세요. 프로젝트를 진행하면서 Claude Code가 반복적으로 실수하는 부분이나 자주 설명해야 하는 내용을 추가해 나가세요.\n2. 팀과 함께 관리하기 CLAUDE.md는 Git으로 관리되므로 팀원들과 함께 리뷰하고 개선할 수 있습니다. Pull Request를 통해 새로운 규칙을 추가하거나 오래된 내용을 업데이트하세요.\n3. 프로젝트 문서와 동기화 README.md, CONTRIBUTING.md 등 다른 프로젝트 문서와 일관성을 유지하세요. 중복된 내용은 한 곳에만 두고 서로 참조하도록 합니다.\n4. 너무 길어지지 않게 유지 CLAUDE.md가 너무 길면 Claude Code가 핵심 내용을 놓칠 수 있습니다. 중요한 규칙과 자주 참조할 내용 위주로 간결하게 유지하고, 상세한 내용은 별도 문서로 분리하세요.\n마무리 CLAUDE.md는 단순한 설정 파일이 아니라 Claude Code와 효과적으로 협업하기 위한 커뮤니케이션 도구입니다. 프로젝트의 맥락을 명확히 전달하고 일관된 코드 품질을 유지하는 데 큰 도움이 됩니다.\n프로젝트의 특성에 맞게 CLAUDE.md를 작성하고 지속적으로 개선해 나가다 보면, Claude Code가 점점 더 프로젝트에 익숙한 팀원처럼 작동하는 것을 경험할 수 있을 것입니다.\n","date":"2026-02-16","permalink":"https://korobopolly.github.io/posts/claude-code-claude-md/","summary":"\u003ch2 id=\"claudemd란\"\u003eCLAUDE.md란?\u003c/h2\u003e\n\u003cp\u003eCLAUDE.md는 Claude Code에게 프로젝트별 컨텍스트와 작업 지침을 전달하는 특수한 마크다운 파일입니다. 이 파일을 프로젝트 루트에 두면 Claude Code가 세션 시작 시 자동으로 읽어들여 프로젝트의 규칙, 코딩 스타일, 아키텍처 결정사항 등을 이해하고 작업에 반영합니다.\u003c/p\u003e\n\u003cp\u003e전역 설정 파일인 \u003ccode\u003e~/.claude/CLAUDE.md\u003c/code\u003e와 달리, 프로젝트 루트의 \u003ccode\u003eCLAUDE.md\u003c/code\u003e는 해당 프로젝트에만 적용되며 팀원 간 공유가 가능하여 일관된 개발 경험을 제공합니다.\u003c/p\u003e\n\u003ch2 id=\"claudemd의-주요-역할\"\u003eCLAUDE.md의 주요 역할\u003c/h2\u003e\n\u003ch3 id=\"1-프로젝트-컨텍스트-제공\"\u003e1. 프로젝트 컨텍스트 제공\u003c/h3\u003e\n\u003cp\u003eClaude Code에게 프로젝트의 목적, 기술 스택, 디렉토리 구조 등을 명시적으로 알려줄 수 있습니다.\u003c/p\u003e","tags":["Claude Code","CLAUDE.md","프로젝트 설정"],"title":"CLAUDE.md - 프로젝트 규칙, 코딩 컨벤션, 워크플로우 설정"},{"content":"이 블로그에 대해 실전 개발에 필요한 기술을 정리하는 기술 블로그입니다. AI 도구 활용법부터 백엔드, 프론트엔드까지 실무에서 바로 쓸 수 있는 내용을 다룹니다.\n다루는 주제 AI 개발 도구 Claude Code: Plan Mode, MCP 서버, Hooks 자동화, 컨텍스트 관리 oh-my-claudecode(OMC): 멀티 에이전트 오케스트레이션, Autopilot, Team 모드 백엔드 Java: 최신 기능 (Sealed Classes, Records, Virtual Threads), Stream API Spring Boot: 프로젝트 설정, REST API, JPA 연동 프론트엔드 React: 컴포넌트, JSX, Hooks, 상태 관리, 커스텀 훅 대상 독자 실전 개발 기술을 체계적으로 정리하고 싶은 개발자 AI 코딩 도구를 활용한 생산성 향상에 관심이 있는 분 Java, Spring Boot, React를 배우거나 복습하고 싶은 분 연락처 GitHub: korobopolly ","date":"2026-02-16","permalink":"https://korobopolly.github.io/about/","summary":"\u003ch2 id=\"이-블로그에-대해\"\u003e이 블로그에 대해\u003c/h2\u003e\n\u003cp\u003e실전 개발에 필요한 기술을 정리하는 기술 블로그입니다. AI 도구 활용법부터 백엔드, 프론트엔드까지 실무에서 바로 쓸 수 있는 내용을 다룹니다.\u003c/p\u003e\n\u003ch3 id=\"다루는-주제\"\u003e다루는 주제\u003c/h3\u003e\n\u003ch4 id=\"ai-개발-도구\"\u003eAI 개발 도구\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eClaude Code\u003c/strong\u003e: Plan Mode, MCP 서버, Hooks 자동화, 컨텍스트 관리\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eoh-my-claudecode(OMC)\u003c/strong\u003e: 멀티 에이전트 오케스트레이션, Autopilot, Team 모드\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch4 id=\"백엔드\"\u003e백엔드\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eJava\u003c/strong\u003e: 최신 기능 (Sealed Classes, Records, Virtual Threads), Stream API\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSpring Boot\u003c/strong\u003e: 프로젝트 설정, REST API, JPA 연동\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch4 id=\"프론트엔드\"\u003e프론트엔드\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eReact\u003c/strong\u003e: 컴포넌트, JSX, Hooks, 상태 관리, 커스텀 훅\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"대상-독자\"\u003e대상 독자\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e실전 개발 기술을 체계적으로 정리하고 싶은 개발자\u003c/li\u003e\n\u003cli\u003eAI 코딩 도구를 활용한 생산성 향상에 관심이 있는 분\u003c/li\u003e\n\u003cli\u003eJava, Spring Boot, React를 배우거나 복습하고 싶은 분\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"연락처\"\u003e연락처\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eGitHub: \u003ca href=\"https://github.com/korobopolly\"\u003ekorobopolly\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e","tags":null,"title":"소개"}]