블로그
전체 82025. 06. 22.
0
[발자국 4주차] 문어야 문어야 뭐하니
문어야 문어야 뭐하니 해당 글은 인프런 워밍업 클럽 스터디 4기 - DevOps (쿠버네티스)를 진행하며 깊게 고민해 보려고 한 내용을 담습니다. 개괄적 정리보다 아는 것과의 비교를 통한 이해를 추구합니다. 또 Jenkins 파이프라인이 실패했다. 빌드는 성공했는데 배포 단계에서 타임아웃. kubectl로 수동 배포를 시도하니 이번엔 yaml 문법 에러다. 그런데 오늘부터 우리(?) 집에는 문어가 산다. 이 문어는 상당히 똑똑해서, 우리가 작업하고 싶어하는 배포들을 보다 단순하게 처리할 수 있도록 해 줄 것이다. 우리의 Jenkins가 못해먹겠다는 일을, 조금 더 덜어 보러 가 보자. ArgoCD? 우리의 문어, ArgoCD는 쿠버네티스 환경에서 GitOps 방식으로 애플리케이션을 배포해주는 도구다.Git 저장소를 지속적으로 모니터링하다가 변경사항이 생기면 자동으로 쿠버네티스에 배포해준다. 왜 ArgoCD일까? 궁금해서 찾아 보았더니. 그리스 신화에서 이아손과 아르고 선원들이 황금양털을 찾으러 탔던 배가 '아르고호(Argo)'란다. 험난한 바다를 항해해서 목적지에 도달하는 것처럼, ArgoCD도 복잡한 배포 과정을 자동으로 항해해서 원하는 상태로 애플리케이션을 배포해준다는 의미인 것 같다. Git이라는 나침반만 있으면 알아서 목적지까지 데려다준다니, 나름 꽤 적절한 네이밍 아닌가. 배포 진화의 4단계 강사님이 정리해주신 배포 레벨별 진화 과정이 명확했다. Level 1: Jenkins UI에서 클릭 몇 번으로 해결. 편하지만 파이프라인 설정이 UI에만 저장되어 변경 이력 추적이 안 된다.Level 2: Jenkinsfile로 파이프라인을 코드화. 이제 Git으로 관리할 수 있다. 하지만 여전히 Jenkins가 kubectl을 실행해서 직접 배포한다.Level 3: Helm이나 Kustomize로 YAML 템플릿화. 개발/스테이징/운영 환경별로 다른 설정을 쉽게 관리할 수 있다. 그래도 배포 트리거는 수동이다.Level 4: ArgoCD로 GitOps 완성. Git 변경 → 자동 배포, Blue/Green이나 Canary 같은 고급 배포 전략도 지원. 중요한 건 Level 3까지도 충분히 서비스 운영이 가능하다는 점이다. ArgoCD는 더 나은 선택지지, 반드시 필요한 건 아니다. 하지만 나는 ArgoCD를 도입해야만 한다. 사실 내가 할 건 아니다. 회사에서 쓰고 있다. 그럼 알아야지. (ㅋㅋ ㅠㅠ) Git이 진실의 원천이 되는 순간 이전 버전에서 알려주신 바로는 Jenkins에서 모든 걸 처리했다. 소스 빌드, 이미지 생성, 쿠버네티스 배포까지. 그런데 ArgoCD 아키텍처를 보니 접근 방식이 완전히 달랐다. Repo Server가 3분마다 Git 저장소를 확인한다. YAML 파일이 바뀌었나? 새로운 커밋이 있나?Application Controller가 현재 쿠버네티스 상태를 읽어온다. 그리고 Git의 내용과 비교한다.다르면? 자동으로 동기화한다. 이게 핵심이었다. Jenkins는 "배포하라"고 명령하는 방식이라면,ArgoCD는 "현재 상태가 원하는 상태와 같은가?"를 지속적으로 확인하는 방식이다. 일종의 Polling이지만, 권한을 더 이상 신경쓰지 않아도 된다는 의존 관계 해소가 가능하다.결과적으로 이제 Jenkins에서는 kubectl 권한을 관리할 필요가 없어진다.Pod에 대한 다른 모니터링 관리 툴 필요성도 줄어든다. 보안 측면에서도 좋고, 역할 분리도 명확해진다. 그런데 가장 흥미로웠던 건 Image Updater였다. Image Updater, 드디어 찾은 진짜 자동화? 기존에는 이랬다. 개발자가 코드 푸시Jenkins가 빌드하고 이미지 생성Jenkins가 YAML 파일의 이미지 태그를 수정하는 스크립트 실행수정된 YAML을 Git에 푸시ArgoCD가 변경사항 감지해서 배포 이제 Image Updater를 쓰면. 개발자가 코드 푸시Jenkins가 빌드하고 이미지를 Docker Hub에 업로드Image Updater가 새 이미지 감지자동으로 Git의 YAML 파일 수정ArgoCD가 변경사항 감지해서 배포 개발자는 코드만 푸시하면 끝이다. Jenkins 파이프라인에서 복잡한 YAML 수정 로직을 짤 필요가 없다. 단, 제약이 있다. Helm이나 Kustomize로 배포해야만 동작한다. 내부적으로 --set image.tag 옵션을 사용하기 때문이다. update-strategy 설정도 중요하다. semver로 하면 버전 규칙에 맞는 최신 버전으로, latest로 하면 가장 최근 이미지로 업데이트된다. Sync Policy, 자동화의 양날의 검 Manual vs Auto Sync Auto Sync. 좋아 보이지만 너무 무서운 단어가 아닌가? 난 서버에서 자동은 늘 무섭다.(;; 물론 요즘 선배님들의 자동화는 나의 매뉴얼보다 훨씬 낫다고 생각하지만..) 그래도 역시 강사님 설명을 들으니 함정이 있었다.Auto Sync + Self Heal 조합을 켜두면, 쿠버네티스에서 직접 수정한 내용이 모두 무시된다.HPA로 replica가 늘어나도 Git에 정의된 숫자로 다시 줄어든다. Sync Options Sync Options에서도 유사한 상황이 발생할 수 있다. AUTO-CREATE-NAMESPACE: 편리하지만 오타로 잘못된 네임스페이스가 생성될 수도 있다PRUNE RESOURCES: Git에서 삭제된 리소스를 쿠버네티스에서도 삭제하는데, 실수로 파일을 지웠을 때 운영 중인 서비스가 날아갈 수 있다 결국 환경별로 다르게 설정해야 한다. 개발 환경에서는 Auto Sync로 빠른 피드백을, 운영 환경에서는 Manual Sync로 안정성을 택하는 식으로. 오, 어쩐지 H2 같다. 정확한 타입 제한은 운영 DB에서만 처리할 때도 있으니까. 컴포넌트들의 역할 분담 이제 우리 집 문어를 뜯어 보자. ArgoCD 아키텍처를 보면 각 컴포넌트가 명확한 역할을 가지고 있다. Server: Web UI와 API 제공. 우리가 보는 대시보드가 여기서 나온다.Repo Server: Git 저장소와 소통. YAML 파일을 다운받아서 실제 배포할 매니페스트로 변환한다.Application Controller: 핵심 로직. 쿠버네티스 현재 상태와 Git의 desired state를 비교해서 동기화를 결정한다.Dex: SSO 연동. 회사에서 쓰는 LDAP이나 OAuth 프로바이더와 연결할 수 있다.Redis: 캐시 역할. Git과 쿠버네티스 API 호출을 줄여서 성능을 향상시킨다.Notification: 배포 결과를 Slack이나 이메일로 알려준다. 특히 ApplicationSet Controller가 인상적이었다. 개발/스테이징/운영 환경을 각각 다른 ArgoCD Application으로 만들 필요 없이, 하나의 템플릿으로 여러 환경을 관리할 수 있다고 해서. Directory vs Helm vs Kustomize ArgoCD에서 지원하는 세 가지 배포 방식. Directory 방식그냥 YAML 파일들을 kubectl apply 하는 것과 같다가장 간단하고 직관적이다하지만 Image Updater를 쓸 수 없다. 이미지 태그를 동적으로 바꿀 방법이 없기 때문이다 Helm 방식:values.yaml에서 환경별 설정을 관리한다Image Updater가 values.yaml의 이미지 태그를 자동으로 수정해준다템플릿 문법이 복잡할 수 있지만, 기능이 가장 풍부하다 Kustomize 방식base 디렉토리에 공통 설정, overlay 디렉토리에 환경별 차이점을 둔다Helm보다 가볍고 배우기 쉽다Image Updater도 지원하지만 Helm만큼 유연하지는 않다 결론적으로, 단순한 서비스라면 Directory도 충분하다.하지만 Image Updater로 자동화를 원하거나 멀티 환경 관리가 필요하다면 Helm이나 Kustomize를 써야 한다. 하지만 우리는 직전 강의에서 Helm의 태깅과 다양성이 필요하다는 이야기를 들었다. 간단한 프로젝트에서는 Kustomize이 괜찮겠지만, 어쩐지 Kustomize를 사용할 프로젝트에서 k8s를 사용하는 것은 오버엔지니어링일지도 모른다는 생각이 들기도 한다. 유사한 관점은 ArgoCD에서도 이어진다. 그래서, 정말 필요한가? Level 3으로도 충분히 잘 돌아가는 환경이라면 굳이 ArgoCD를 도입할 필요가 있을까? 강사님 말씀처럼 "유지보수 부담"도 생긴다. ArgoCD 자체도 쿠버네티스 위에서 돌아가는 애플리케이션이니까. 하지만 다음과 같은 상황이라면 도입을 고려해볼 만하다. Blue/Green, Canary 배포가 필요한 서비스멀티 클러스터 환경 관리Image Updater로 완전 자동화를 원하는 경우배포 과정에서 사람의 개입을 최소화하고 싶은 경우Git 기반으로 모든 배포 히스토리를 추적하고 싶은 경우 결국 '현재 내 상황에서 어떤 문제를 해결하고 싶은가'가 기준이 되어야 한다. Spring Boot 환경에서는 특히 Image Updater의 장점이 클 것이다.코드 변경 → 자동 빌드 → 자동 배포까지 완전히 세팅을 통해 자동화할 수 있으니까. 마무리하며 "나 이제... ArgoCD 쓸 수 있냐?" 음, 편리해졌다. 동시에 복잡성도 조금은 생긴다. Git에 푸시하면 알아서 배포되고, UI 대시보드에서 상태를 실시간으로 확인할 수 있고, 문제가 생기면 이전 버전으로 쉽게 롤백할 수 있다. 하지만 쉬워진 만큼 더 신중해져야 할 부분도 있다. Sync Policy 설정, Image Updater 전략, 멀티 클러스터 관리 등. 결국 도구는 도구일 뿐이고, 어떻게 쓰느냐가 중요하다. 그래도 한 가지는 확실하다. GitOps라는 개념을 제대로 구현해보고 싶다면, ArgoCD만큼 완성도 높은 도구는 찾기 어려울 것 같다. 이번에 강의를 마무리하면서 적용 시점이 다가오고 있어서 회사에서 다시금 ArgoCD를 접속했는데, 권한이 빠졌는지 대시보드가 뜨지 않고 회색 창이 떴다. SSO가 LDAP에서 키클락으로 바뀌어서 그럴 거라고 생각했다. 강의에도 그 부분이 나왔었다. 그리고 GitLab에 아키 팀이 설정해 둔 Helm 세팅에 대한 옵션을 봤다. 원래는 Helm이 무슨 역할을 하는지도 정확하게 알지 못해, 분명히 위키와 옵션을 봤음에도 기억하지 못했었다. 이제 옵션을 이해할 수 있다. 왜 이걸 쓰는지 알고 있다. 나는 이제 제법 K8S를 겁내지 않을 수 있겠구나. 4주 간의 여정을 즐겁게 마무리하면서, 앞으로의 Sprint 3에 대한 욕심도 생기고 있다. 멀게만 느껴졌던 DevOps, 이번 인프런 워밍업 클럽을 토대로 조금 더 가까워진 것 같다.
2025. 06. 15.
2
발자국 3주차: 우아하게 종료하기, 우아하게 새로 켜기
해당 글은 인프런 워밍업 클럽 스터디 4기 - DevOps (쿠버네티스)를 진행하며 깊게 고민해 보려고 한 내용을 담습니다. 개괄적 정리보다 아는 것과의 비교를 통한 이해를 추구합니다. 우아하게 종료하기, 우아하게 새로 켜기 # 정상적인 프로세스 종료 (권장) kill 1234 kill -15 1234 # 강제 종료 (SIGTERM이 안 될 때) kill -9 1234 # SIGKILL 내가 서버와 관련된, 특히 프로세스와 관련된 생각을 할 때 가장 먼저 떠올리는 것은 [생명 주기]다. 잘 시작하는 것, 잘 살아 있는 것은 무엇보다 중요하지만, 내가 가장 중요하다고 생각하는 나의 가장 큰 관심사는. 바로 종료다. 잘 돌아가던 것은 잘못된 종료로 인해 한 순간에 와르르 실패할 수 있다. 따라서 배포는 기존의 것을 죽이고 → 새로운, 심지어 달라진 것을 올린다는 점에서 매우 중요한 작업이다. 지난주에 서버가 "살아있다"는 것의 의미를 깊이 고민했다면, 이번 주는 그 살아있는 서버를 어떻게 종료하고, 어떻게 새로운 코드를 안전하게 배포할 것인가를 파헤쳐봤다. DevOps, 거대한 파이프라인 DevOps는 개발(Dev)과 운영(Ops)의 경계를 허물고 자동화를 통해 빠르고 안정적인 배포를 추구하는 문화이자 방법론. 개발 소스 → 커밋 → GitHub → CI/CD 환경 → 빌드 → 실행 파일 → 컨테이너 이미지 → k8s 배포 이 과정의 가장 처음 확인해야 하는 핵심은 무엇일까? 결국 실행 파일을 만드는 것이다. DevOps가 아무리 복잡해 보여도, 개발 → 빌드 → 실행 파일이라는 본질은 변하지 않는다. 왜 환경을 나눠야 하는가 이에 따라 빌드 환경을 고려하는 것을 잊어서는 안 된다. 어디에서 실행하기 위해 빌드하는가? 무엇을 위해 분리하는가? 단일 환경으로 모든 것을 처리하려는 시도는 위험하다. 각 환경은 명확한 목적을 가지고 분리되어야 한다. 개발 환경: 개발자들의 통합 테스트 공간. 자유로운 실험이 가능해야 한다.검증 환경: QA팀의 영역. 운영과 최대한 동일하되 보안 제약은 완화된 환경.운영 환경: 실제 사용자를 위한 공간. 안정성이 최우선이다. 운영 환경에서 오픈소스를 도입할 때 이중화 가능 여부를 확인해야 하는 이유는 명확하다. SPOF. 단일 장애점(Single Point of Failure)은 전체 서비스 중단으로 이어질 수 있기 때문이다. 예를 들어, 메시지 큐 시스템이 이중화를 지원하지 않는다면, 해당 시스템 장애 시 전체 서비스가 마비될 수 있다. 이제 상황에 따른 빌드 파일이 완료되었다면, 실제로 서버에 올릴 시간이다. 그냥 말고, 쿠버네티스랑 같이. 안 무서워?쿠버네티스의 자동화된 배포는 분명 편리하지만, 나에게는 중요한 질문이 있었다. "자동화된 배포가 정상적인 통신을 보장할 수 있는가?" 이번 주 강의에서는 그 안전장치에 대한 설정값을 보고, [가능성]을 확인하는 과정을 거쳤다. 쿠버네티스가 제공하는 안전장치 Readiness Probe의 역할readinessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 10 periodSeconds: 5 트래픽 자체에 대한 [통신 가능성] 확인이다. 새 Pod가 트래픽을 받기 전에 정말 준비되었는지 확인한다.단순히 프로세스가 떴는지가 아니라, DB 연결, 캐시 워밍업, 외부 API 연결 등 실제 서비스에 필요한 모든 것이 준비되었는지 검증할 수 있다. 점진적 롤아웃strategy: rollingUpdate: maxSurge: 1 maxUnavailable: 0 한 번에 하나씩만 교체하므로, 문제 발생 시 영향 범위를 최소화한다. 자동 롤백 메커니즘 새 버전의 Pod가 계속 실패하면 쿠버네티스가 자동으로 이전 버전을 유지한다. 그럼에도 남는 나의 두려움 물론 스프링 서버 단계에서 많은 걸 보완할 수 있다. 그리고 위의 것들로 많은 걸 보완할 수 있다. 하지만 하나하나 톺아 보면서 조금 더 든든하게 정리해 보자. 데이터 일관성 문제 해결 방안기존 서버를 프로세스 종료까지 유지데이터베이스 마이그레이션을 배포와 분리이벤트 소싱 패턴으로 변경 이력 관리두 버전이 공존할 수 있도록 하위 호환성 유지이미 DB에 쓰인 데이터는 어떻게 할 것인가? 쿠버네티스는 애플리케이션 레벨의 트랜잭션을 관리하지 않는다. 이에 따라 생각한 나의 해결 방안은, 기존 서버를 [기존 프로세스가 실행될 때까지만] 살아 있게 하는 것이었다. 그리고 신규 서버로 인입되는 데이터에 대해서 확실한 표시를 하는 것. 그 부분을 쿠버네티스 매핑과 연결해 달라고, 클로드한테 부탁했는데. 기존 서버를 기존 프로세스가 실행될 때까지만 살아있게 유지terminationGracePeriodSeconds 설정과 PreStop Hook을 활용해 진행 중인 프로세스가 완료될 때까지 Pod 종료를 지연시킬 수 있다.데이터베이스 마이그레이션을 배포와 분리:Helm Job이나 Init Container를 사용해 마이그레이션을 배포와 독립적으로 실행할 수 있다.이벤트 소싱 패턴으로 변경 이력 관리ConfigMap을 통해 이벤트 스토어 설정을 관리하고, StatefulSet으로 이벤트 저장소의 순서를 보장할 수 있다.두 버전이 공존할 수 있도록 하위 호환성 유지롤링 업데이트의 maxSurge와 maxUnavailable 설정으로 두 버전이 동시에 실행되는 기간을 제어할 수 있다.신규 서버로 인입되는 데이터에 대해서 확실한 표시라벨과 어노테이션을 활용해 Pod 버전을 명시하고, 애플리케이션에서 이를 참조할 수 있다. 로컬 캐시 불일치 :구 버전과 신 버전이 동시에 실행되면서 캐시 데이터가 불일치할 수 있다. 해결 방안배포 시 캐시 무효화 전략 수립버전별 캐시 키 분리기존 캐시에 대해서 소멸 시간을 설정한 만큼 기존 서버를 살려두기새로운 캐시에 대해서는 버전 처리 다음으로 떠오른 것은 로컬 캐시. 우리는 서버의 가용성을 위해 어떠한 정보들을 저장해 둔다. 이런 부분에 대해서 떠오른 해결 방안 또한 무효화와 버저닝이었다. 기존 캐시에 대해서 소멸 시간을 설정한 만큼 기존 서버를 살려 둘 수도 있다. 또한 새로운 캐시에 대해서는 버전 처리를 할 수 있다. 사실 가장 좋은 건 로컬 캐시에 이런 [사용자성] 데이터를 넣지 않는 편이 아닐까 싶지만. 이번 부분도 쿠버네티스 매핑과 연결해 보자면. 배포 시 캐시 무효화 전략 수립ConfigMap 업데이트를 통해 캐시 무효화 신호를 전달하고, Rolling Restart를 트리거해서 캐시를 초기화할 수 있다.버전별 캐시 키 분리Pod의 라벨이나 환경변수를 통해 버전 정보를 주입하고, 이를 캐시 키 접두어로 사용해서 버전별로 캐시를 분리할 수 있다.기존 캐시에 대해서 소멸 시간을 설정한 만큼 기존 서버를 살려두기terminationGracePeriodSeconds를 캐시 TTL과 동일하게 설정해서 캐시가 자연스럽게 만료될 때까지 기존 Pod를 유지할 수 있다.새로운 캐시에 대해서는 버전 처리Deployment의 metadata.labels에 버전 정보를 포함하고, 이를 애플리케이션에서 참조해서 새로운 캐시 키에 버전을 포함시킬 수 있다. 세션 문제 사용자가 구 버전에서 신 버전으로 넘어갈 때 세션이 유실될 수 있다. 해결 방안Redis 같은 외부 세션 스토어 사용 Sticky Session 설정 (단, 롤링 업데이트 효과 감소) 그리고 세션. 아무래도 서버가 사라질 때 기존 서버에 붙어 있던 세션들도 함께 끊겨 버릴 수 있으니까. 이 부분도 똑같이 해당 프로세스가 마칠 때까지의 Sticky Session이 떠올랐다. 또는 세션 자체의 Store를 밖에 두는 것. 상황에 따른 의사결정이 필요한 시점 같다. 이번에도 클로드의 힘을 빌려 보자면. Redis 같은 외부 세션 스토어 사용StatefulSet으로 Redis 클러스터를 구성하고 Service로 엔드포인트를 관리할 수 있다.Sticky Session 설정Ingress Controller의 어노테이션(nginx.ingress.kubernetes.io/affinity: "cookie")을 사용해 쉽게 설정할 수 있다. 이제 이 프로세스의 장점을 담아, 자동 배포를 위한 젠킨스를 실행해 보자. Jenkins 파이프라인 구축 여정 1단계: 기본 구성 처음에는 Jenkins UI를 통해 각 단계를 개별 Job으로 구성할 수 있다. 소스 코드 체크아웃Gradle 빌드Docker 이미지 생성kubectl 배포 처음엔 Jenkins UI에서 클릭클릭하며 Job을 만든다.GitHub에서 소스 가져오고, Gradle로 빌드하고, Docker 이미지 만들고, kubectl로 배포하고. 각 단계마다 "Build Now" 버튼을 눌러야 했다. 수동으로 트리거해야 했고, 전체 흐름을 파악하기 어려웠다. 2단계: Pipeline으로의 전환 그러다 Pipeline의 시작. Jenkins Pipeline은 코드로 파이프라인을 정의하는 방식이다. Stage View로 진행 상황 시각화Jenkinsfile로 버전 관리 가능병렬 처리 및 조건부 실행 지원 시각적인 Stage View가 제공되고, 각 단계별 소요 시간이 보이고, 무엇보다 Jenkinsfile로 버전 관리가 가능하다. 3단계: Blue/Green 배포 구현 다음부터는 배포 방식. Blue/Green 배포는 두 개의 동일한 환경을 준비하고 트래픽을 한 번에 전환하는 방식이다. 메모리: 두 배의 리소스가 필요하다. CPU는 기동 시에만 피크를 치지만, 메모리는 지속적으로 점유한다.네이밍 전략: blue-v1, green-v2와 같은 일관된 네이밍이 필요하다.Label 관리: Service의 Selector를 변경하기 위한 적절한 Label 설계가 중요하다. Blue/Green 배포 - 쿠버네티스와 전통적 방식의 차이 사실 이 배포 방식에 대해서는 이해하고 있었지만, k8s에서 어떻게 진행하는지를 한 번 더 비교해 보고 싶었다. 그리고 정리한 것은 다음과 같았다. 전통적인 Blue/Green 기존의 블루/그린 전략은 다음과 같다. L4 로드밸런서에서 수동으로 트래픽 전환물리 서버나 VM 단위로 환경 분리스크립트로 복잡한 전환 로직 구현롤백 시 다시 수동으로 전환 쿠버네티스의 Blue/Green # Service의 selector만 변경 selector: app: myapp version: green # blue → green 그리고 쿠버네티스의 블루/그린. 핵심 차이점 이제 전체 배포 방식에 대해 비교해 보고 싶었고, 그 내용이 다음과 같았다.내가 기존에 알고 있던 카나리를 포함해서. 배포 전략의 선택 기준 Jenkins 파이프라인으로 배포 자동화를 구축했지만, 곧 새로운 문제를 마주한다.개발, 검증, 운영 환경마다 다른 설정값들. 서비스가 늘어날수록 관리해야 할 YAML 파일이 기하급수적으로 증가한다. Helm과 Kustomize HelmHelm은 쿠버네티스의 패키지 매니저다. apt나 yum처럼 복잡한 애플리케이션을 쉽게 설치하고 관리할 수 있게 해준다. 핵심 개념Chart: Helm 패키지. 쿠버네티스 리소스를 정의하는 템플릿 모음Values: 차트에 주입할 설정 값Release: 차트의 인스턴스. 같은 차트로 여러 릴리즈 생성 가능 Helm은 이건 마치 프로그래밍의 함수와 같았다.템플릿이라는 함수에 values라는 매개변수를 전달하면, 원하는 YAML이 생성되는 방식. Kustomize란? Kustomize는 YAML 파일을 직접 수정하지 않고 패치를 통해 커스터마이징하는 도구다.kubectl에 내장되어 있어 별도 설치가 불필요하다. 핵심 개념Base: 기본 리소스 정의Overlay: 환경별 커스터마이징Patch: 특정 필드만 수정하는 파일 Kustomize의 접근법은 더 직관적이었다. "기본 YAML은 그대로 두고, 환경별로 다른 부분만 덮어쓰자"는. 이 두 가지를 언제 선택해야 할까? 선택 기준 Helm을 선택해야 할 때복잡한 의존성을 가진 애플리케이션여러 환경에 동일한 애플리케이션을 다른 설정으로 배포커뮤니티 차트를 활용하고 싶을 때 (Artifact Hub)팀원들과 표준화된 배포 방식을 공유하고 싶을 때 Kustomize를 선택해야 할 때간단한 YAML 커스터마이징만 필요한 경우템플릿 문법 없이 순수 YAML을 유지하고 싶을 때kubectl과의 네이티브 통합을 선호할 때작은 규모의 프로젝트 특히 와닿았던 부분은 "대부분의 오픈소스가 Helm 차트로 제공된다"는 점이었다.실제로 Prometheus, Grafana, Redis 등을 설치할 때 Helm을 사용하지 않으면 수십 개의 YAML을 직접 관리해야 한다. 반면 작은 마이크로서비스 하나를 여러 환경에 배포할 때는 Kustomize의 단순함이 빛을 발할 것이다.템플릿 문법을 배울 필요 없이 바로 사용할 수 있으니까. 이처럼 배포 방식이 단순해질수록, 그 배포를 자동화하는 CI/CD 도구의 선택도 함께 고민해야 한다. 어떤 도구를 선택하느냐에 따라 개발 생산성과 운영 효율이 크게 달라지기 때문이다. CI/CD 도구 선택의 지혜 커뮤니티 활성도: Google Trends, GitHub Stars, Stack Overflow 질문 수유지보수 지원: 상용 지원이 필요한가? 내부 전문가가 있는가?인프라 요구사항: 온프레미스 필수인가? 클라우드 네이티브가 가능한가?보안 요구사항: 외부 서비스 사용이 가능한가? 에어갭 환경인가? 예를 들어, 금융권처럼 보안이 중요한 환경에서는 GitHub Actions보다 Jenkins나 Tekton같은 온프레미스 솔루션이 적합하다. 반면 스타트업처럼 빠른 구축이 중요하다면 GitHub Actions가 더 나은 선택일 수 있다. 마무리하며 이번 주를 통해 배포는 더 이상 두려운 작업이 아닌, 체계적인 프로세스임을 배웠다. 하지만 쿠버네티스의 자동화된 배포가 만능은 아니다. 데이터 일관성, 버전 호환성, 상태 관리 등 여전히 개발자가 신경 써야 할 부분들이 많다. 적절한 도구와 전략을 선택하되, 그 한계를 어떻게 극복할지 명확히 인지하고 있어야 한다. 다음 주에는 ArgoCD를 통한 GitOps, 그리고 더 고도화된 배포 자동화를 다룬다고 한다. 배포의 여정은 계속된다!
2025. 06. 08.
5
발자국 2회차: 너 살아 있니?
너 살아 있니? 해당 글은 인프런 워밍업 클럽 스터디 4기 - DevOps (쿠버네티스)를 진행하며 깊게 고민해 보려고 한 내용을 담습니다. 개괄적 정리보다 아는 것과의 비교를 통한 이해를 추구합니다. 서버가 죽었다. 새벽 3시에 알람이 울렸다. 뭘 생각할 정신도 없이 노트북을 켜고 터미널에 접속한다. 등골이 서늘하다. 음. 옛날에는 그랬겠지. 그런데 이제 그럴 필요가 적어졌다. 쿠버네티스를 만나고 나의 꿀잠 시대 시작됐다.그런데 정말 든든한 꿀잠을 자기 위해서는 어떻게 [설정]해야 하는지가 중요한 영역이 된다. 이번 주는 그 부분에 대해서 더 깊이 파 보는 시간이었고, 나는 지금까지 애플리케이션의 '살아있음'을 너무 단순하게 생각한 것이 아닌가? 회고했다. 살아있다는 게 뭐야? 처음 Probe를 접했을 때, 그냥 서버 헬스체크 정도로만 생각했다.그런데 강사님의 매우 중요 별 100개를 들으면서 아 이거 집중해야겠네... 라는 긴장 상태 (ㅋㅋ) 에 돌입했다. 각 Probe는 다음 세 가지 결과 중 하나를 가진다. Success : 컨테이너가 진단을 통과함.Failure : 컨테이너가 진단에 실패함.UnKnown : 진단 자체가 실패하였으므로 아무런 액션도 수행되면 안됨. 이 판단에 따라 쿠버네티스는 액션하고, 우리의 통신 시도를 서버와 연결한다. 이 판단 중에는 단적인 평가만 있지 않다. Probe에는 세 가지 종류가 있다. Startup Probe는 "너 아직 준비 중이니?"라고 묻는다.애플리케이션이 무거워서 시작하는 데 시간이 오래 걸린다면, 이 친구가 기다려준다. 덕분에 아직 준비도 안 된 애플리케이션을 쿠버네티스가 죽었다고 판단해서 재시작시키는 불상사를 막을 수 있다. Liveness Probe는 "너 진짜 살아있어?"라고 묻는다.애플리케이션은 돌고 있는데 데드락에 걸렸다거나, 무한루프에 빠졌다면? 겉으로는 멀쩡해 보여도 실제로는 아무 일도 못하고 있는 좀비 프로세스가 되어버린다. 이럴 때 liveness Probe가 판단해서 재시작을 명령한다. Readiness Probe는 "너 일할 준비 됐어?"라고 묻는다.살아있는 것과 트래픽을 받을 준비가 된 것은 다르다. DB 커넥션 풀이 아직 준비 안 됐다면? 캐시 워밍업이 필요하다면? readiness Probe가 실패하면 Service의 엔드포인트에서 제외된다. Startup Probe가 활성화되어 있으면, 이 프로브가 성공할 때까지 다른 프로브는 실행되지 않음Liveness Probe가 실패하면, 쿠버네티스는 컨테이너를 재시작함.Readiness Probe가 실패하면, 해당 파드를 서비스의 엔드포인트에서 제외함그럼 판단은 언제 내려야 할까? 그건 우리가 정해 줘야 하는 영역이다. Probe도 리소스를 먹는다. 당연하다. 호출하는 것도 리소스다.따라서 Probe를 설정할 때의 고민은 [무엇을 책임지는가] 로 간다는 생각을 했다. Startup Probe: 넉넉하게. 특히 JVM 워밍업이 필요한 애플리케이션은 더더욱. Liveness Probe: 보수적으로. 함부로 재시작하면 안 되니까.Readiness Probe: 민감하게. 사용자 경험을 위해서. 설정은 코드다 - ConfigMap과 Secret "환경변수는 어디에 둬?" Spring 베이스로 개발을 하던 나는 자연스럽게 yml과 properties로 생각이 향했다. 그러나 K8S 환경에서의 Config는 조금 달랐다. 런타임의 등장이다.런타임에서 고려해야 하는 설정 값들은 단순하게 local / dev 등으로 분기할 수 없다. 빌드 타임에 결정되는 것: application.yaml런타임에 결정되는 것: ConfigMap절대 노출되면 안 되는 것: Secret (어쩌면 Base64일 뿐이지만) Kubentes는 기본적으로 Secret 값을 ETCD에 저장하는데, Base64 인코딩을 한다. 사실 Secret이라는 단어를 봤을 때 드는 생각은, 오. 민감한 정보 다 맡기면 되겠는데.였는데... 안타깝게도 Base64로만 암호화가 되는 불상사(?)가 존재했다. 즉 ETCD에 접근권한이 있다면 Secret을 읽는 게 어려운 일이 아니다. 물론 이제 클라우드 서비스에서 제공하는 KMS(EKS)와 같은 encrypt 수단이 있긴 하지만,결국 중요한 것은 [그 Secret 오브젝트에 대한 읽기 권한을 누가 가지게 할지]가 된다. Secret의 진짜 가치는 암호화가 아니라 '관리'에 있는 것. RBAC으로 접근을 제어하고, audit log로 누가 언제 접근했는지 추적하고, 필요하면 암호화 솔루션과 연동할 수도 있고. 음, 이제 설정 파일까지 완료가 됐다. 뼈대를 잡았다면 좋은 브레이크도 필요하다. 준비시키는 것도 중요하지만 얼마나 잘 죽는지가 더 중요하다. Graceful Shutdown에 대해 생각해 볼 시간이다. PV/PVC, 뭘 저장해? 파드는 죽는다. 언제든지, 아무 때나. 파드가 죽으면 컨테이너도 죽고, 컨테이너 안의 데이터도 날아간다. 로그 파일이든, 업로드한 이미지든, 세션 데이터든 다 사라진다. 그래서 PV(Persistent Volume)가 필요하다. 파드가 죽어도 살아남는 저장소. hostPath노드의 경로를 그대로 마운트파드가 다른 노드로 스케줄링되면 데이터를 못 찾음 -> nodeSelector로 노드를 고정쿠버네티스 공식 문서에서 사용하지 않는걸 권장하고 있음local PVnodeAffinity로 어느 노드에 있는 스토리지인지 명시쿠버네티스가 스케줄링할 때 이를 고려 그런데 이름에서 보여지는 것처럼 해당 저장소들은 전부 로컬이겠지. 로컬이면? 노드가 죽으면? 데이터도 같이 죽는다. 동시에 개발자가 직접 스토리지를 관리해야 할까? 그래서 프로덕션에서는 EBS, NFS, Ceph 같은 네트워크 스토리지를 쓴다고 한다. PVC(Persistent Volume Claim)가 등장함과 함께. "나 100GB 스토리지 필요해"라고 요청하면, 쿠버네티스가 알아서 적절한 PV를 찾아서 연결해준다. 인프라 팀은 PV를 미리 준비해두거나 StorageClass로 동적 프로비저닝을 설정한다. 역할 분리. 깔끔하다. 안전하게 죽이는 방법은 확인했다. 그럼 이제 살리는 [시점]에 어떻게 할지를 생각해 볼 시간이다. 어떻게 살리지? 옛날에는... Deployment: 배포를 새벽에 하라고요? ... 였다!하지만 배포할 때마다 서비스가 죽는다면, 그건 2025년의 방식이 아니다.우리에게 무중단 배포는 이제 고려할 것이 아니라 필수의 영역이다. 쿠버네티스의 Deployment는 여러 전략을 제공한다. Rolling Update기본 배포 전략. 새 버전을 하나씩 올리고 구 버전을 하나씩 내리는 방식maxSurge와 maxUnavailable을 잘 조절하면 리소스 사용량과 안정성 사이에서 균형을 맞출 수 있다.Recreate모든 구 버전을 내리고 새 버전을 올린다.다운타임이 발생하지만, 두 버전이 동시에 돌면 안 되는 경우에는 답이다.Blue-Green근데 Recreate만 쓸 수 있는 건 아니지.두 개의 완전한 환경을 준비하고 트래픽을 한 번에 전환. 롤백이 쉽다.단점은? 리소스가 두 배.Canary새 버전에 트래픽의 일부만 보내서 테스트. 문제없으면 점진적으로 늘려간다.Istio나 Flagger 같은 도구와 함께 쓰면 자동화도 가능하다. Rolling Update는 가장 기본적인 쿠버네티스의 전략이다. 그런데 Rolling Update 중에 readiness Probe가 너무 빨리 성공해서, 아직 캐시 워밍업이 안 된 Pod에 트래픽이 들어갔다면? 이는 응답 지연이나 에러 발생의 원인이 된다. 이것을 방지하기 위해서는 [점진적 헬스 체크] 설정이 중요하다고 한다. initialDelaySeconds를 충분히 확보livenessProbe가 10초 뒤에 최초 실행되는 등readinessProbe에서 진짜 준비 상태를 판단하도록 구현단순히 HTTP 200이 아닌, 내부적으로 캐시, DB, 외부 API 등의 상태가 정상인지 점검하여 응답하게 만듦 오케이, 살리는 방법까지 확인했다. 그러면 [언제] 다시 살리는가? 우리는 꿀잠 메타를 노리고 있다. 알람 울리기 전에 알아서 처리하게 해 보자. 그러려면 k8s가 수치를 쳐다보게 해야 한다. 모니터링은 사치가 아니다 "로그 어디서 봐?"답은 사실 1주차부터 나와 있기는 했다. 설치했으니까. ㅎㅎ Prometheus + Grafana + Loki. 쿠버네티스는 기본적으로 메트릭을 노출한다.별도의 에이전트 설치 없이도 CPU, Memory, Network 사용량을 수집할 수 있다.하지만 진짜 중요한 건 애플리케이션 메트릭이다. 비즈니스 메트릭: 주문 수, 가입자 수, 결제 금액성능 메트릭: 응답 시간, 처리량, 에러율시스템 메트릭: DB 커넥션 수, 캐시 히트율, 큐 길이 이러한 3가지 메트릭에 따라 고려해 둘 것은 다음과 같은 것들이 있었다. 단순 임계값이 아닌 rate() 함수로 변화율 감지여러 조건을 AND로 묶어서 false positive 줄이기알람 피로도를 줄이기 위한 억제(inhibition) 규칙 이와 같이 모니터링을 토대로 트래픽을 식별하는 이유는 뭘까? 역시 스케일링도 그 꼭지 중 하나일 것이다. 트래픽이 몰릴 때 서버를 늘릴 수 있도록. (꿀잠) HPA - 수직적 스케일링의 두등장 "트래픽이 몰리면 서버를 늘리면 되지 않나?"맞는 말이다. 그런데 '언제' 늘릴 것인가? 얼추 CPU 사용률 80% 넘으면 Pod 추가, 얼추 30% 이하면 Pod 제거?만약 그렇게 간단했다면 DevOps 의 연봉 풀이... 더보기 팩터 수집가 - 여러 가지 스케일링 팩터를 가지고 있기Pod 추가 - 빠른 스케일 아웃 방지하기: Behavior 설정Pod 제거 - 빠른 스케일 다운 방지하기: scaleDown의 stabilizationWindowSeconds 그 기준에 대해서는 여러가지 스케일링 팩터를 가지고 있는 것이 중요하고,너무 빠르게 스케일 아웃을 하게 하지 않는 게 중요하고,빠르게 서버를 낮추지 않게 하는 것이 중요하다. 어떠한 기준에 도달했을 때 스케일 아웃을 할지 / 스케일 다운을 할지에 대한 팩터를 서비스에 맞춰 조절할 수 있어야 한다. 그리고 해당 기준에 도달했을 때 너무 빠른 속도로 서버가 올라가거나 내려가지 않도록 설정해 두는 것이 중요하다. 마무리하며 "서버, 너 살아 있니?" 이 질문은 어쩌면 [통신 가능]의 여부로 단순해 보이지만, 쿠버네티스 환경에서는 훨씬 깊은 의미를 담고 있는 것 같다. Probe로 건강을 체크하고, ConfigMap/Secret으로 설정을 관리하고, 적절한 배포 전략으로 무중단을 달성하고, 모니터링으로 상태를 추적하고, HPA로 탄력적으로 대응한다. 이 모든 게 맞물려 돌아갈 때, 우리는 비로소 서버의 "나 살아있다!"라는 대답을 신뢰할 수 있게 된다. (그리고 꿀잠 잔다) 그래서, 다음 주는? 차주에는 실제 젠킨스 파이프라인과 배포 시 유의점, Helm과 Kustomize에 대해 알아본다고 한다. 뭘 알게 될까? 궁금증이 들어서 GPT에게 스포일러를 요청했다. 아, 정말 어떻게 [배포]할지에 대해서 보다 디테일하게 배워 보는 시간인가 보다. 여러 pod를 어떤 식으로, 서비스 운영에 문제가 없도록 배포할 수 있는지. 이 오케스트레이션을 알게 되는 것이 아닌지, 어쩌면 수동으로 하던 행위를 바로 일임하는 것인 만큼 가장 중요한 것이 아닌가... 하는 생각이 든다. 다음 주에도 재밌게!
2025. 06. 01.
1
발자국 1주차: Pod 파티를 개최합니다
Pod Party에 초대합니다! 해당 글은 인프런 워밍업 클럽 스터디 4기 - DevOps (쿠버네티스)를 진행하며 깊게 고민해 보려고 한 내용을 담습니다. 개괄적 정리보다 아는 것과의 비교를 통한 이해를 추구합니다. Pod 파티. 내가 지어 본 농담 겸 이번 도전의 시작을 알리는 단어다. 나는 좋은 Pod 파티의 주최진이 되어서, 수많은 Pod들을 중구난방이 아니라 예쁘고 잘 정렬된 형태로 빚어 보고 싶으니까. 그를 위해 이번에 수강을 참여하게 된 강의는 쿠버네티스 어나더 클래스 (지상편). 일프로님과 함께 멋진 빙산(?)의 일각을 한 달간 맛볼 시간을 만들어 보았다. 그렇게 만나게 된 이번 주는 쿠버네티스의 전체적인 생태계와 핵심 개념들을 깊이 있게 학습하는 주간. 유닉스부터 이어지는 리눅스 배포판의 흐름부터 시작해서 컨테이너 기술의 발전 과정, 그리고 쿠버네티스의 실제 설치와 운영까지 체계적으로 접근할 수 있었다. 리눅스가 가지고 있는 역사의 흐름을 강의에서 보여 주셨는데, 사진을 하나 더 찾게 되어서 첨부해 보았다. 리눅스 배포판 관련해서는 Debian 계열(Ubuntu)이 개인/중소기업에 적합하고 시장 점유율이 높다는 점, 그리고 Red Hat 계열에서 CentOS가 2024년에 종료되면서 Rocky Linux와 Alma Linux로 기업들이 이동하고 있다는 현실적인 변화를 이해할 수 있었다. 컨테이너 기술의 발전 과정에서는 chroot, namespace, cgroup 같은 리눅스 격리 기술이 어떻게 *LXC를 거쳐 Docker로 발전했는지, 그리고 Docker가 어떻게 개발자 친화적으로 재구성되어 앱 배포와 실행에 최적화되었는지 순서대로 이해할 수 있었다. LXC: 리눅스 커널을 공유하면서 각 컨테이너가 독립적인 환경을 가지도록 하는 경량화된 가상화 방법 그래, 도커의 등장. 시스템 가상화에서 애플리케이션 가상화. 정말 커다란 패러다임 시프트가 아니었을까? 일단 파티 건물을 만들어 볼까? 일프로님의 경험이 조금 더 많음에 따라 천천히 구성된 Rocky Linux 환경. 이 안에서 직접 쿠버네티스 클러스터를 구축해 보는 시간이다. 기본 설정 단계에서 패키지 업데이트를 주석 처리하고, Asia/Seoul 타임존 설정, NTP 시간 동기화, tc와 openssl 관련 패키지 설치 등을 진행하면서 강의 환경 통일의 중요성을 이해했다. 특히 hosts 파일에 클러스터 노드 IP와 이름을 등록하고, 방화벽을 비활성화하고, Swap을 해제하는 등의 작업은 언젠가 맥락을 이해할 수 있지 않을까 (ㅋㅋ) 하는 생각을 하게 되는 시간이었다. containerd 설치 과정에 조금 더 중점을 가져 보자는 생각을 했는데, 이는 containerd가 Kubernetes의 기본 컨테이너 런타임이자 OCI 표준이기 때문이다. containerd 기본 설정에서 SystemdCgroup을 true로 변경해 kubelet과 호환성을 맞추는 부분을 보면서, kubelet의 역할이 뭔지 한 번 더 생각해 보았다. kubelet은 Kubernetes 노드에서 컨테이너를 관리하는 에이전트이자 containerd와 통신하여 컨테이너의 생성, 실행, 중지 등의 작업을 수행하는 것. kubelet과 Docker의 역사: CRI와 OCI 표준 CRI: Container Runtime InterfaceOCI: Open Container Initiative 결국 kubelet은 에이전트다. containerd 통신을 토대로 컨테이너를 관리한다. 그런데 통신이 멋대로 되면 괜찮을까? 음, 아닐 것이다. 우리는 중구난방에서 규약으로 가는 것을 좋아한다. 이때 우리는 인터페이스를 생각한다. CRI다. CRI를 도입하며, 사람들은 kubelet이 런타임에 gRPC로 Pod 실행을 요청하는 과정을 훨씬 규약으로 다듬을 수 있게 되었다. 초기에는 Docker와 rkt만 대응했지만, 1.23에서 dockershim을 사용하고, 1.24에서 dockershim이 제거되면서 containerd, CRI-O 등 다양한 런타임을 지원하게 된 변화 과정이 흥미로웠다. 그것을 누가 주도했느냐? 바로 OCI다. 이런 상황이 되면서 발생한 Docker의 우여곡절도 인상적이었다. docker-containerd라는 고수준 런타임docker-runC라는 저수준 런타임 Docker가 처음에는 많은 기능을 포함한 솔루션이지만, containerd만을 쿠버네티스를 위해서 분리하고, runC가 Docker의 실행 엔진으로 OCI 표준에 부합하게 된 과정, 그리고 Red Hat이 개발한 CRI-O가 Kubernetes 친화적인 경량화 런타임으로 등장한 배경을 이해할 수 있었다. 비롯해 이를 강한 이식성을 토대로 적용할 수 있도록 오픈소스 커뮤니티 생태계를 컨트롤하는 CNCF(Cloud Native Computing Foundation)의 역할도 생각해 볼 수 있었다. 너 살아 있니? 다 좋다. 이제 건물도 세워졌고, Pod도 살아 있다고 한다. 그런데 얘가 갑자기 뻗어버리면 어떻게 해? 이라고 했을 때, 서버 개발자들은 주로 Prometheus와 Grafana를 가장 먼저 떠올릴 것이다. 이들은 이미 세팅을 해 본 적이 있었는데, Loki는 처음이었다. 쿠버네티스에서는 에이전트 삽입 없이도 자동으로 메트릭을 수집할 수 있다는 점이 기존 모니터링 방식과의 큰 차이점이었다. 신규 앱이 추가되어도 label 기준으로 자동 인식되어 모니터링 누락을 방지할 수 있다는 점에서 운영의 편의성이 크게 향상된다고 느꼈다. 개발 환경과 운영 환경 간의 모니터링 불일치 문제를 해결할 수 있다는 점도 실무적으로 매우 중요한 장점이라고 생각했다. Spring Boot Actuator로 메트릭을 노출하는 것과 유사하게, 쿠버네티스에서는 표준화된 방식으로 메트릭을 수집할 수 있어서 일관성 있는 모니터링이 가능하다는 점이 인상적이었다. HPA(Horizontal Pod Autoscaler)를 통한 자동 스케일링 기능에서는 CPU 사용률 등 메트릭을 기준으로 Pod 수를 자동 조절하고, Behavior 설정을 통해 스케일 아웃 속도를 제한할 수 있다는 점을 학습했다. 이거 누가 수정했어? 모든 것이 잘 돌아가고 있었다. 근데 갑자기 어느 날부터 문제가 터지기 시작했다? 자, 이제부터 누가 이 파티 주최자인지 식별해 볼 시간이다. 옛날엔 누군지 몰랐겠지만 지금은 다 안다. 인프라를 YAML로 선언적으로 관리하는 GitOps 개념을 토대로, Git을 통한 버전 관리와 변경 이력 추적이 가능해졌... 다는 점이 두려웠지만... 좋은 게 좋은 거라고? (농담) 이 방식은 기존 방식에 비해 재현성이 높고 기록이 남으며, 환경 간 차이를 줄이고 운영 이슈 대응이 수월해지는 장점을 지닌다. 설정은 어디다가 해요? 그리고 ConfigMap과 Secret을 통한 설정 관리 방식. 이 부분을 보면서는 Spring Boot의 @ConfigurationProperties나 application.properties와 매우 유사한 관심사 분리 개념이 적용되어 있다고 느꼈다. ConfigMap은 주로 metadata와 data로 구성되어 Pod에 환경변수 값을 제공하는 용도로 사용되고, Secret은 stringData 형태로 정의해 Pod 안에 파일로 만들어져 민감한 정보를 안전하게 관리할 수 있다는 점이 실용적이었다. 유사한 이름의 둘도 있었다. PVC(PersistentVolumeClaim): Pod가 저장공간을 요청할 때 사용 / 저장공간 크기와 accessMode가 필수 설정 항목PV(PersistentVolume): 실제 물리적 저장공간을 지정하는 역할 PV가 Namespace에 속하지 않고 클러스터 단위로 관리된다는 점에서 글로벌 리소스의 특성을 잘 보여준다고 생각했다. 비롯해 Prometheus 라벨링 및 네이밍에서 쿠버네티스 권고 라벨링 방식을 학습했는데, 일관성 있는 메타데이터 관리를 통해 모니터링과 운영의 효율성을 높일 수 있다고 생각했다. 그리고 자동화 기능. Self-Healing, AutoScaling, RollingUpdate 등의 자동화 운영 기능들을 학습하면서 쿠버네티스의 진정한 가치를 이해할 수 있었다. Self-Healing으로 Pod가 죽으면 자동 복구되고, AutoScaling으로 CPU 사용률에 따라 Pod 수가 자동 조절되며, RollingUpdate로 무중단 배포와 실패 시 롤백이 지원된다는 점에서 운영 부담을 크게 줄일 수 있다고 느꼈다. 그래서, 다음 주는? 이번 주는 학습을 통해 쿠버네티스의 전체적인 그림을 그려 보는 시간이었던 것 같다. 앞으로의 시간이 기대되는데, 아무래도 애플리케이션 생명 주기와 서버의 상태와의 연동을 조금 더 눈여겨 보게 되는 것 같다. Spring Boot Actuator의 health check 엔드포인트를 쿠버네티스와 연동하는 방법이나, Spring Boot의 Graceful Shutdown 기능과 쿠버네티스의 Pod 종료 프로세스를 매끄럽게 연결하는 방법도 궁금해진다. 다음 주에도 멋진 파티 주최자로서의 발돋움을 하는 것으로.
2025. 03. 30.
1
발자국 4주차: 그래서 우리는 왜 귀찮음을 이겨내야 하는가
그래서 우리는 왜 귀찮음을 이겨내야 하는가 마지막 주간. 나는 이번 강의를 전반적으로 들으면서 문득 떠오른 그림이 있었다. 수학에서 함수를 이야기할 때 가장 먼저 나오는 그림이다. 우리가 테스트를 하는 대상은 결국 추상화된 로직 안쪽에 대한 구체적인 결과물이다. 테스트에서 가장 중요한 것은 I/O. 그리고 연계성. 여기에서 여러가지 박스가 중첩되어 있는 게 프로그램이고, 우리가 만든 이 프로그램에서 a라는 것이 정확한 f(a)를 보장하는가? 에 대한 물음표를 컴파일 타임에서 해소해 주는 것. 즉 I/O를 얼마나 촘촘하게 필터링할수 있을지에 대한 이야기이다. Mockist가 여기서 발언할 수 있는 여지가 있다고 생각한다. 위에 있는 박스가 어떤 것을 뱉든 a가 막을 수 있는 테스트코드로 막으면 되잖아? 난 결국 a에 대한 모든 케이스를 전부 테스트했으니까. 연계에 있어서 굳이 힘을 뺄 이유가 있겠느냐는 패러다임이다. 나는 강의에서 말씀하신 것과 비슷한 시야를 가지고 있는 것 같은데 ㅎㅎ 결국 인간이란 실수할 수 있는 동물이지 않나... 하는 생각을 한다. 어떠한 아웃풋이 나올지 모르니까 그걸 막으려고 테스트를 한다.어떠한 인풋이 나올지 모르니까 그걸 막으려고 테스트를 한다. 이 관점에서는 확실히 시나리오를 짜는 일이 유리해 보이니까.나올 수 있는 케이스를 조금 더 촘촘히 만들고 막는 것이 Classicist의 접근 방향이지 않을까.... 하는 생각이 들었다. 근데 귀찮잖아. 많은 테스트는 귀찮잖아. 아니, 테스트는 귀찮잖아! 아, 귀찮아...... 음... 테스트 작성은 귀찮다. 마지막 강의의 말미에서도 이야기하는 만큼, 선생님도 "귀찮음"을 이겨내고 테스트를 작성하신다고 했다. 결국은 장기적으로 유지보수가 될 프로그램에 대한 마음의 확신. 과감한 리팩토링을 가능하게 하는 것. 사실 이런 말이 무의미할 수도 있다. 이 강의를 듣는 것 자체가 테스트가 중요하다는 것을 알고 있어서라고 생각하니까. 그러면 우리는 이 귀찮음을 어떻게 이겨내는가? 그래서, 실행 강의나 책의 가장 중요한 핵심은 제목에 있다고 생각한다. 강의의 이름을 다시 한번 돌이켜 보자. Practical Testing: 실용적인 테스트 가이드 결국 Practical. 강의에서도 계속 강조하는 만큼 결국 가장 중요한 것은 실무에서의 활용이다. 어떻게 활용할 수 있을까? 무엇이 귀찮음을 이겨내도록 할까? 무엇에 대한 내용은 강의에서 많이 들었다. 따라서 나는 어떻게로 접근해 보고자 했다. 귀찮음을 어떻게 이겨낼까? 많은 자기계발서들은 귀찮음을 이겨내는 방법에 대해서 이야기한다. 의지력을 발휘하는 법, 언제나 열정을 불어넣는 법, 시간과 체력을 관리하는 방법.... 등등등. 난 이게 다 무의미하다고 생각하는 사람이다. 결국 Do에 있어서의 방법론이 귀찮음을 누른다고 생각하니까. 나는 하기 싫은 일이 있을 때마다 하고 싶어질 때까지 아주 잘게 쪼갠다. 그럼 테스트에서도 이걸 적용하려면? 어떻게 해야할까? 나는 먼저 로 접근해 보고자 했다. 우리는 f(a)라는 함수를 테스트하려고 한다. 개별에 대한 단위 해피 테스트, 엣지 테스트 같은 것들을 리스트에 포함해 보면 좋겠다. 그리고 연계 시의 단위 해피 테스트, 엣지 테스트 같은 것들을 리스트에 포함해 보면 좋겠다. 오. 나는 이제야 테스트 작성이 좀 더 빠르게 접근할 수 있는 것으로 보인다. 이것만 함수로 만들면 되잖아? a에 대해 들어올 수 있는 값은 null일 수도, 숫자일 수도, 문자열일 수도, 객체일 수도 있다. 우리가 만약 string이라고 했다면, 숫자와 null에 대해서 필터링을 할 방법을 다 적어보면 되겠다. 강의에서는 이 방법으로 ParameterizedTest를 이야기했다. @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {"Hello", " ", "123", "!@#"}) @DisplayName("입력값이 null 또는 빈 문자열이면 안 된다.") void shouldNotAllowNullOrEmptyString(String input) { // given String result = f(input); // then assertNotNull(result); assertFalse(result.isEmpty()); } f(a)로 만들어질 수 있는 값도 동일하다. null일 수도, 숫자일 수도, 문자열일 수도, 객체일 수도 있다. 우리가 만약 객체라고 했다면, 그 객체가 만들어낸 값이 동일한지를 찾아보면 되겠다. @Test @DisplayName("출력값은 null이 아니어야 한다.") void shouldNotReturnNull() { // given String input = "hello"; // when String result = f(input); // then assertNotNull(result); } @Test @DisplayName("출력값은 예상한 문자열과 일치해야 한다.") void shouldReturnExpectedString() { // given String input = "hello"; // when String result = f(input); // then assertEquals("HELLO", result); } @Test @DisplayName("출력값은 모두 대문자로 변환되어야 한다.") void shouldConvertToUpperCase() { // given String input = "hello"; // when String result = f(input); // then assertTrue(result.matches("[A-Z]+")); } 같은 식으로 말이다. 여기서 아주 촘촘하게 테스트를 짠다면 좋겠지만 인간은 언제나 놓치는 존재니까. 그래도 아무것도 없는 백지에서 짜는 것보다 훨씬 더 촘촘할 것이고, 조금 더 접근하기가 쉬워지지 않을까. 적어도 나는 그랬다. 테스트가 서비스 운영에서 어떤 위치를 가질 수 있는지 이야기해보는 시간이었으니, 나도 정리와 함께 활용 방법을 이야기해보고 싶어 여러 이야기들을 종합적으로 해 봤다. 이제 오늘을 끝으로 인프런 워밍업 클럽 스터디 3기는 막을 내리게 된다. 회사 일, 이직 면접, 스터디를 병행하며 했던 만큼 더욱 뿌듯함이 큰 것 같다. (이 병행을 이유로 우수 수강생은 노리기 힘들 것 같아서 조금 아쉽기는 했다. ㅎㅎ 아직 수퍼맨은 아닌 걸로...) 사실 테스트 코드에 대한 배움을 가장 중점으로 가지고 들어왔지만, Readable Code 수강을 하면서 예상치 못한 배움들이 등장했다.아는 것이라고 여기는 게 얼마나 바보같은 일인지 조금 더 절감하는 시간이었다. 수강은 이번에 막을 내리지만, 앞전 언급했던 것처럼 결국 가장 중요한 것은 실행이라는 것을 알고 있다. 실행을 토대로 내일부터 조금씩 테스트를 작성해 보는 시간을, 실제로 시간을 이유로 하지 못하더라도 어떠한 테스트 케이스가 나올 수 있는지에 대한 정리를 하고서 테스트를 하는 습관을 들여 보고자 한다. 3월은 짧았는데 어쩐지 워밍업 클럽은 길었던 느낌. 모두 고생하셨습니다.
2025. 03. 23.
1
발자국 3주차: 테스트에서 가장 중요한 것
테스트의 중요성? 사실 테스트가 [중요하다]는 것은 주입당한(?) 사상으로 어렴풋하게 알고 있었던 것 같다. 다들 중요하다고 하니까. 내가 보기에 그렇게 보이기도 하니까. 하지만 실무를 실제로 진행하면서 테스트를 작성할 수 있는 일 자체는 [일정]을 이유로 불가능했고,이는 테스트 코드 없이 실제 운영되는 코드를 작성하는 결과만을 낳았다. 돌이켜보면 이는 오히려 비효율적인 수동 반복 테스트를 낳기도 했다. 음, 그 시간에 차라리 테스트 코드를 짰다면... 나는 더 빠르게 테스트를 진행했을 수도 있다. 하지만 그동안은 테스트 코드가 [학습]의 영역이 아니어야 실무에 적용이 가능하다고 여겼다.(특히 일정이 이슈가 된다면 더더욱) 따라서 이번 기회에 [학습]의 꼭지점을 잡고, 프로젝트에 적용하는 것을 연습해 보기로 했다. 이번 프로그램을 신청한 목적은 [테스트] 였기 때문에 가장 초점을 두고 공부하고자 한 영역이기도 했다. 무엇을 테스트하는가? 우리는 프로그램을 만든다. 프로그램은 잘 돌아가야 한다. 잘 돌아간다는 것은 곧 정확하게 돌아가는 것과 같다 테스트는 프로그램이 [정확하게] 돌아가는 것을 보장하는 일이다.. 그렇다면 무엇을 기준으로 정확성을 판단할 수 있을까? 음... 내가 가장 어려워 하는 것은 이런 부분인 것 같았다. 테스트를 삼을 기준. 따라서 이번에는 어떤 것이 기준이어야 하는지 생각해 보았고, 그 결과는 다음 4가지로 귀결되었다. 기능적 요구사항 – 프로그램이 제공해야 하는 기능이 기대한 대로 동작하는가? 데이터 저장 후 올바르게 조회할 수 있는가? 성능적 요구사항 – 성능, 보안, 확장성과 같은 요소들이 기대치를 충족하는가? API 응답 속도가 1초 이내로 유지되는가? 동시 접속자가 많아도 서비스가 정상적으로 운영되는가? TPS는 원하는 대로 나오는가? 경계 조건 및 예외 처리 - 비정상적인 IO가 발생하는가? 입력 값이 비어 있거나, 허용 범위를 벗어난 경우에도 오류 없이 처리되는가?예외 시에 적절한 예외 반환을 보장하는가? 정확한 유효성 검사를 수행하는가? 데이터 무결성의 검증 - 트랜잭션은 작동했는가? 하나의 작업에 대해 의도한 모든 트랜잭션이 적절하게 적용되었는가? 실패한 프로세스는 없는가? 실패 시 Fallback이 정확하게 이뤄졌는가? 사실 무엇을 테스트할까 알아보면서 내리게 된 결론은.. 한 가지의 방향점을 가리키고 있었다. 지금 내가 세운 기준은 그동안 세워 왔던 단위 테스트 명세서에 들어 있던 것들이라는 걸. 왜 이것들을 진작 연결지어 보지 못했었는지. 테스트가 개발의 병목이 되지 않으려면 좋다. 이제 어떤 걸 테스트할지에 대해서는 명확해졌다. 강의에서는 [어떻게] 테스트를 하는지에 대한 방법론과 그걸 [할 때]의 효율성에 대해 다룬다. 강의를 보게 됨으로써 내 테스트는 한 층 더 정교해지고 빨라졌을 것이다. 그런데 나는 한 가지를 더 생각해 보고 싶었다. [무엇을 우선순위로 삼을 것인가.] 서두에도 언급한 만큼, 테스트 코드를 작성하는 것은 중요하지만, 현실적으로 일정이 촉박한 상황에서 테스트가 개발 속도를 저해하는 요소가 되어서는 안 된다. 따라서, 테스트를 작성하면서도 일정을 맞추는 방법을 고민해야 한다. 이럴 때 필요한 것이 우선순위가 아닐까 한다. 나는 이번에 다음과 같은 우선 순위를 결정해 보았다. 1. 핵심 로직만을 검증하는 Smoke Test(기본 동작 여부 확인) 모든 코드에 대해 100% 테스트 커버리지를 목표로 하면 개발은 지연될 수 있다. 따라서 일정이 촉박한 상황에서는 가장 중요한 핵심 로직과 장애 발생 가능성이 높은 부분만을 테스트 코드로 먼저 작성해야 한다는 생각이 들었다. 이를 비롯해 내가 처음에 이야기했던 [반복 수행될 수밖에 없는 케이스] 같은 곳. 어떠한 케이스 테스트를 코드를 수정할 때마다 돌려 봐야 한다면, 그 케이스들을 먼저 작성하고, 코드로 옮길 수 있는 부분들을 먼저 고려해 볼 것 같다. 2. 테스트 전략을 역할별로 나누어 적용하기 테스트를 작성할 때 모든 것을 하나의 방식으로 해결하려고 하면 시간이 오래 걸릴 수 있다. 이에 따라 역할별로 최소한의 전략을 선택하면 효율적인 테스트 작성이 가능할 것 같다. 단위 테스트(Unit Test)메서드 단위로 검증하여 빠르게 문제를 찾는다.검증이 잦게 필요한 곳이나 오류가 나기 쉬운 테스트에 우선 적용한다.통합 테스트(Integration Test)여러 모듈 간의 연동을 검증한다. 실행 속도가 느리므로 꼭 필요한 부분에만 적용한다.UI 테스트사용자 흐름을 검증하는 테스트로, 작성 및 유지보수에 시간이 많이 걸린다.핵심 기준을 명확하게 세우고, 비즈니스 로직과 분리해서 테스트한다. 즉, 단위 테스트를 우선 작성하여 빠르게 피드백을 받고, 일정에 여유가 있다면 통합 테스트를 추가하는 방식으로 접근하면 효율적일 것 같다. 또한 업무 단위로 테스트가 이뤄지는 곳은 UI 테스트가 통합 테스트로 여겨지기도 하기 때문에, 이러한 분리를 거친다면 검증에 대한 효율이 올라갈 듯하다. 테스트는 ##이다. 이번에 내리게 된 결론. 테스트는 결국 시간의 효율을 위한 것이다. 코드는 사람이 작성하는 것이며, 사람은 실수를 한다. 테스트는 이러한 실수를 빠르게 발견하고 수정할 수 있도록 도와준다. 좋은 테스트는 단순히 버그를 잡는 것이 아니라, 개발자와 사용자 모두에게 소프트웨어가 신뢰할 수 있는지에 대한 확신을 제공한다. 그 고민의 시간을 줄여 줄 것이다. 코드가 변경될 때마다 테스트를 통해 예상치 못한 문제가 발생하지 않는다는 것을 확인할 수 있으며, 이는 코드의 품질을 유지하는 가장 확실한 방법이다. 결국, 테스트는 단순한 검증 과정이 아니라, 코드의 신뢰성을 보장하는 핵심 요소이다. 이를 통해 개발자는 더 빠르고 안정적으로 코드를 수정할 수 있으며, 사용자 역시 신뢰할 수 있는 소프트웨어를 사용할 수 있다. 이 모든 것을 알면서도 주저할 수밖에 없는 것은 당장의 시간, 당장의 일정, 당장의 촉박함. 하지만 이번 회고를 토대로 나는 그러한 상황에서도 어떤 의사결정을 내릴지 결정할 수 있었다. 강사님은 말씀하셨다. 고용된 우리는 프로이며, 프로의 첫 의무는 일정 준수라고. 뼈에 오늘도 새겨 두면서 ..... 이제 앞으로 남은 강의와, 네 번째 발자국이 남아 있다. 3월의 마지막 주도 잘 갈무리해 보고자 한다.
2025. 03. 16.
1
발자국 2주차: 읽기 좋은 코드 == 쓰기 좋은 코드
읽기 좋은 코드, 쓰기 좋은 코드 이번 주의 발자국 회고. Readable 코드에 대한 이야기가 마무리되는 주간이다. 이번에는 강의 듣기와 함께 미션을 토대로, 내가 실제로 코드를 구현해 보는 시간을 가져갈 수 있었는데, 중간 점검 타임에서 과제에 대해서 조금 더 짚어주시면서 내 코드를 돌이켜보는 기회를 얻을 수 있었다. 사실 Spring과 JPA를 사용하는 평소 개발 방식은 많은 부분 "이미 모듈화된" 것들을 사용하는 경우가 많다. 특히 어노테이션을 쓴다든지 하는 케이스의 런타임 객체 관리 위임과 같은 상황에서 우리는 책임의 분리를 덜 고려하고도 편안한 개발을 할 수 있다. 단적인 예만 해도 Dispatcher Servelt은 우리의 xml 등록을 대리해 주고, 스프링은 Bean을 대신 관리해서 DL을 말려준다. (ㅋㅋ) 이런 것들이 감춰져 있기 때문에 우리는 서비스 로직을 조금 더 경량화할 수 있다. 그런데 기껏 스프링이 열심히 도와준 걸 망치면 안 되지 않을까? 필드를 얼마나 넣을 것인지, 이 객체의 책임 = 변경 가능 요소는 몇 개인지와 같은 것들을 잘 고려해 보면서 리팩토링 과제를 진행해 보았다. 그리고 이러한 미션을 점검하는 중간 회고. 중간 점검 회고 우선 중간 점검에서 했던 질문 시간이 꽤 재미있었던 기억이 난다. 멘토님의 커피 사랑 ㅋㅋㅋ 을 알 수 있는 좋은 기회이기도 했고... 나중에 게이샤 커피 꼭 먹어 보겠습니다. 공통 리뷰와 함께 개별 신청자에 대한 리뷰를 진행하는 2시간 가까이의 시간이었다. 목이 아프셨다고 했는데, 다음날 출근해서 말 한마디 못하시는 건 아니었는지 염려가 된다. 주요 내용은 장표를 공유해 주셨는데, 내가 이 내용 외에도 다른 분들 코드 피드백을 들으면서 인상깊었던 부분들을 정리해 보았다. 사물함 사용 가능 여부 등의 ENUM 처리: 추천컬렉션을 가공하는 로직이 생기면 일급 컬렉션을 고민해 볼 것Get / Set이 아닌 연상되는 단어 선택한 점이 좋음if-else보다 if-early return을 추천Mutable 컬렉션보다는 한번에 Immutable 컬렉션을 만들 것많은 클래스에서 사용한다 == 하나의 객체에 책임이 과도하게 몰려 있는 것은 아닌가? : 객체 분리의 신호탄IO 로직이 변경되어도 우리의 도메인 로직은 순수하게 보존되어야 함 이런 이야기가 있으면 나는 이중에서 나에게 도움이 가장 많이 된 것들을 꼽고는 하는데, 이번 글에서는 꼽을 수가 없다. 정말 모든 관점이 큰 도움이 되었던 것 같다. 강의 회고 하고 싶은 것 현재 진행 중인 실무 프로젝트에 배운 내용을 점진적으로 적용해보기"능동적 읽기" 방식으로 오픈소스 코드를 분석하며 좋은 패턴 학습하기 (TOBE - 진짜?) 이번 주간으로 에 대한 강의가 끝이 났다. 사실 강의는... 진짜 솔직하게 말하면 아는 내용이 많다고 생각하면서 봤었는데. 실제로 코드를 리팩토링 하는 과정에서 그 생각이 제법 오만이라는 생각을 계속해서 하게 되었다. 역시 이론과 실제는 다르고, 이상과 활용은 천지차이다. 이번 강의와 미션을 통해 클린코드와 객체 지향 설계의 중요성을 실제로 체감할 수 있었다. 특히 회사에서 가장 의식적으로, 많이 노력하려고 했던 것. "코드는 작성하는 시간보다 읽는 시간이 훨씬 많다"는 강의 내용을 들었던 것을 염두에 두고 코드를 작성하고자 노력을 많이 했다. 사실 가장 어려운 부분은 적절한 추상화 레벨을 결정하는 것인 것 같다. 어떻게 인터페이스를 나누고 책임을 나눠야 하는지... 너무 세부적으로 메서드를 분리하면 오히려 코드 흐름이 파악하기 어려워질 것이고, 너무 크게 묶으면 단일 책임 원칙을 위반하게 될 것이다. 이러한 관점에서 하나 인상깊었던 것이 떠올랐는데, 멘토님이 예로 들어 주셨던 조건 분기문에 대한 코드 분리가 그것이다. 예를 들면, if(type.equals("blahblah")) 일 때 if(isEditable()) 으로 코드를 바꾸고, isEditable에 대한 함수를 하나 더 빼는 형식이다. 이 조건일 때 이런 행위를 한다는 분기를 하나의 함수로 표현하는 것. 사실 객체의 상태를 객체 안에서만 넣는 바람에 밖에서 나눠 볼 생각을 못했던 것 같은데.... 결국 코드는 1줄이나 2줄인 게 중요한 게 아니라, 들이는 공수를 대비해서, 로직을 망가뜨리지 않는 범위에서의 효율을 추구하는 일이라는 것. 이 균형을 맞추는 것이 클린코드의 핵심이라는 것을 조금 더 깨달았다. 읽기 좋은 코드 == (나중에) 쓰기 좋은 코드 따라서 이번에 정립하게 된 것. 읽기 좋은 코드는 동시에 쓰기 좋은 코드이다. 사실 읽기 좋은 코드를 짜다 보면 옆에서 "왜 굳이?" 라는 표현을 들을 수 있다는 생각이 든다. 그런데 나는 읽기 좋은 코드가 오히려 쓰기 좋은 코드라고 생각한다. 보다 정확하게 말하면, "내가 나중에 쓰기 좋은 코드"라고 생각한다. 단적인 예로 일급 컬렉션. 일급 컬렉션은 개발자의 책임을 줄여 로직이 망가질 확률을 최소화하는 일이다. 만약 특정 변수를 주입해서 판단하는 로직이 있다면, 그 로직은 변수에 종속적이며, 추후 개발자의 판단에 종속된다. 우리는 판단을 최소화함으로써 행동을 제약하고, 제약한 행동은 나중에 내가 쓰기 좋은 코드를 낳는다. 행복한 선순환이다. 하지만. "오만"을 참는 법 처음부터 완벽한 코드는 오만이다 이번 중간점검 때 멘토님이 하신 말씀이 있다. 우리는 개발자고, 회사에 고용되어 일하는 것은 프로라는 뜻이다. 그리고 프로가 가장 중요하게 여겨야 할 것은 시간 관리다. 즉, 코드 퀄리티를 우선하다 주객이 전도되는 상황을 일으키는 것은 옳지 않다는 이야기다. 이러한 코드에 대해서는 추후 반영 시 가능하다면 리팩토링을 하는 식으로 점진적 개선을 할 수 있어야 한다. 이상적인 클린코드를 추구하다가 실용성을 간과한 경우를 자주 접하게 된다. 가령 진짜 if문 하나로 해결되는 문제를 객체로 분리했을 때 생기는 문제라든지.... 최신 트렌드의 개발에서 개념을 이해하는 것이 아니라 해당 개념을 적용하고만 싶어서 처리하는 케이스가 그런 상황을 발생시키는 것이 아닐까. 아주 자주 나오는 격언. "은탄환은 없다." 멘토님을 통해서 이번 기회에 또 한 번 상기할 수 있었다. 완벽한 동그라미의 바퀴를 만들고자 하지 말고, 굴러가게 만드는 것이 1번이다. 그 다음 세공하면 된다. 상황에 맞는 적절한 수준의 클린코드 적용을, 리팩토링을 위한 리팩토링을 계속해서 경계해야 한다고 또 한 번 다짐.
2025. 03. 09.
1
발자국 1주차: 읽기 좋은 코드와 현실 비즈니스 속 객체지향의 관계
해당 글은 인프런 워밍업 클럽 스터디 3기 - 백엔드 클린 코드, 테스트 코드를 진행하며 깊게 고민해 보려고 한 내용을 담습니다. 단순한 이해보다는 현실적 적용에 집중합니다. 우리는 자바를 기반한, 객체지향이라는 패러다임을 담은 개발을 하며 의 중요성을 귀가 닳도록 듣는다. 책임의 분리. 어디까지가 객체의 책임인가? 어떤 것은 추상이고 어떤 것이 구상인가? 책임이라는 개념은 객체지향에서 매우 중요하지만, 그와 동시에 현실에서 적용하는 데에서는 생각할 부분이 많다. 나는 비즈니스 일정과 아름다운 코드, 그리고 복잡성에 대한 사이에서 그동안 고민을 많이 해 왔었다. 강의를 들으면서 그것들을 고민하고자 했고, 그 고민에 대한 이번 주 일주일 동안의 이야기를 해 보려고 한다. Graceful Code와 Business 사이에서 사실 SOLID? 안다. 내 연차에서 그것을 모르는 자바 개발자는 드물 것이다. 하지만 그 개발자들은 나와 같이 이 SOLID를 어떻게 적용할지에 대해 고민할 것이다. 따라서 나는 이번 주에는 SOLID 원칙을 실무적으로 적용하는 과정에서 현실적인 한계를 분석하는 데 집중하려 했다. 특히, 기존 시스템에 SOLID 원칙을 도입하려 할 때 발생하는 문제점을 파악하고, 이를 해결하기 위한 현실적인 방법을 고민하고자 했다. 그래, 현재 코드베이스의 특성을 고려하여 현실적인 타협점을 찾는 방법을 고민해 보고 싶었다. 강의를 보며 생각해 본 이번 주의 고민들을 녹이며, 지금부터 SOLID 원칙을 한 가지씩 짚어가면서 이야기해 보고자 한다. 원칙과 현실의 부딪힘 SRP(단일 책임 원칙) 적용의 현실적 고민 사실 제일 좋아하는 원칙이다. SOLID의 가장 핵심이라고 생각하기도 한다. 그렇지만 책임이란 단어는 참 모호하다. [카페]에서 [커피]를 [주문]한다고 했을 때, [주문]이 담당하고 있는 책임은 어디까지일까? 이와 같이 나는 SRP를 고민할 때, [어디까지가 객체의 책임]인가에 대해서 고민한다. class CafeOrderService { private PaymentProcessor paymentProcessor; private NotificationService notificationService; private TransportService transportService; public void processOrder(Order order) { paymentProcessor.process(order); notificationService.sendOrderConfirmation(order); transportService.giveDrink(); } } 카페 주문은 [결제] / [완료 안내] / [손님에게 음료 주기]를 포함할 수 있다. 그런데 카페의 할 일이 많아지게 되면, 이 클래스는 너무 많은 책임을 가지게 된다. 만약 카페가 배달 서비스를 시작하면 카페의 책임은 [배달] / [배달 기사님 콜 부르기] / [배달 완료표시] 등으로 확장될 수 있다. 카페의 할 일은 많지만 책임의 갯수가 늘어날 때는 역시 분리를 고민해야 할 것 같다. 그럼 그걸 몇 개로 제한해야 할까? 3개? 4개? … 개수가 아니지 않을까? 내가 이번에 정의를 내리게 된 것은 SRP가 단순하게 ”하나의 클래스 = 하나의 책임“이 아니라. “변경의 이유가 하나인가”를 고민하는 원칙이라는 것이다. 따라서 우리는 카페 주문이라는 서비스가 “언제” 바뀔지에 따라 응집도의 기준을 정해야 한다. 가령 [카페 주문]이라는 것이 여러 방식의 주문을 가질 수 있게 된다면(방식의 개수가 바뀜), [매장 카페 주문] / [배달 카페 주문]으로 바꾸고, 외부 인터페이스는 타입에 따라 분리하도록 지정하는 것이다. // 주문 처리를 위한 상위 인터페이스 interface OrderService { void processOrder(Order order); } // 매장 주문 처리 class StoreOrderService implements OrderService { private PaymentProcessor paymentProcessor; private NotificationService notificationService; private ServeService serveService; @Override public void processOrder(Order order) { paymentProcessor.process(order); notificationService.sendInStoreOrderConfirmation(order); serveService.serveDrinkAtCounter(order); } } // 배달 주문 처리 class DeliveryOrderService implements OrderService { private PaymentProcessor paymentProcessor; private NotificationService notificationService; private DeliveryService deliveryService; private DriverNotificationService driverService; @Override public void processOrder(Order order) { paymentProcessor.process(order); notificationService.sendDeliveryOrderConfirmation(order); deliveryService.prepareForDelivery(order); driverService.notifyAvailableDriver(order); } } // 주문 타입에 따라 적절한 서비스를 선택하는 팩토리 class OrderServiceFactory { private StoreOrderService storeOrderService; private DeliveryOrderService deliveryOrderService; public OrderService getOrderService(OrderType orderType) { switch (orderType) { case STORE: return storeOrderService; case DELIVERY: return deliveryOrderService; default: throw new UnsupportedOperationException("지원하지 않는 타입:" + orderType); } } } // 클라이언트 코드 class CafeOrderController { private OrderServiceFactory orderServiceFactory; public void processOrder(Order order) { OrderService orderService = orderServiceFactory.getOrderService(order.getType()); orderService.processOrder(order); } } 이제 OrderService 입장에서는 책임이 분리됐다. 타입만 ENUM에서 선택해서 넘겨 주면 되겠다. 그런데 여기서 튀어나오는 원칙이 하나 더 있다. OCP다. OCP(개방-폐쇄 원칙) 적용의 현실적 고민 OCP. 인터페이스 정의의 핵심이다. 특히 전략 패턴에서 고민하게 되는 부분인 것 같다. OCP 이야기가 많이 나오는 예제로 Oauth 로그인이 있다. 네이버 / 카카오 인증을 인터페이스로, 행위 중심을 토대로 폐쇄하되 앞으로 여러 가지 인증의 가능성을 넓히는 것. 그런데 나는 확장의 필요성과 지속성을 먼저 고려해야 한다고 생각한다. “우리는 이 객체를 어디까지 확장할 것인가?” 확장성을 높이기 위해 인터페이스를 도입했지만, 너무 많은 추상화가 오히려 코드 가독성을 해치는 경우도 있을 수 있다. 이번에 크리스마스 특집으로 행사를 한다고 한다. 이 행사는 일주일간 일어나고 사라질 것이다. 그럼 우리는 여러 가지 이벤트가 발생할 때마다 늘 DiscountPolicy의 구현체를 추가해 주어야 하는 것일까? 위의 예시에서도, 이벤트성으로 음료 주문 방식에 증정 이벤트가 추가되었다고 해 보자. 그렇다면 증정 이벤트가 추가된 클래스를 만들어야 할까? 뭐, 그럴 수도 있다. 클래스는 쓰다 지우면 된다. 그런데 추가된 클래스를 나중에 삭제할 수 있다고 쉽게 생각하지만, 실제로는 코드베이스에 남아 오히려 누군가 옵션을 더하는 식으로 클래스가 커져, 유지보수 비용이 발생할 수 있다. 같은 소스코드 안에 있다면 3줄로 관리하면 되는데, 클래스를 하나 늘리는 것이 추후 휴먼 오류 가능성을 높이는 행동이 될 수 있다는 것이다. (세상에는 객체지향을 사랑하는 사람만 있지는 않다) 결국 객체의 [개방]을 위해 보장해야 하는 것은 로직이 얼마나 지속될지의 여부인 것 같다. 그렇다면 리스코프 치환 원칙은 어떠한가? LSP(리스코프 치환 원칙) 적용의 현실적 고민 리스코프 치환 원칙의 중심은 부모에게 있다. 부모가 하는 일을 자식이 위반하지 않아야 하는 것이다. 여기서 가장 적용하기 모호해지는 것은 [부모가 하는 일]이다. 부모는 어떠한 책임을 가질까? 그리고 어떠한 행위를 할까? 역할이 모호한 만큼, 부모의 역할 또한 모호하게 느껴진다. 우리의 카페 주문 예시로 생각해 보자. 사람은 카페에게 기대하는 역할이 있다. 나에게 내가 원하는 음료수를 주는 것이다. 그것이 배달이든, 실제로 가서 주문하는 것이든 달라지는 것은 없다. 여기서 가장 중요한 것은 나 / 음료수 / 전달 이다. 나는 리스코프 원칙을 [클라이언트가 기대하는 응답을 주는 것]을 부모가 하는 일을 위반하지 않는 것이라고 생각한다. 우리의 카페 주문에서의 리스코프 책임 원칙은, “부모가 가진 인터페이스의 계약을 지키는 것”은, [음료수를 전달하는 것]일 것이다. 방식은 다르더라도 음료수만 잘 배달하면 된다. 그러면 지킨 것이다. 그렇다면 스프링에서 가장 많이 쓰이는 DIP는 어떨까? DIP(의존성 역전 원칙) 적용의 현실적 고민 DIP는 고수준 모듈이 저수준 모듈에 의존하지 않고 둘 다 추상화에 의존하게 만든다. 스프링 프레임워크에서는 이 원칙을 기반으로 DI(의존성 주입)를 제공한다. 개발자로서 우리는 스프링에게 객체 생성을 위임하고 수많은 DI를 수행한다. new를 직접 호출할 필요가 없다니! 정말 편리하다. 그런데 모든 의존성을 인터페이스로 추상화하는 것이 항상 최선일까? 다음과 같은 상황을 고려해보자. interface UserService { User findById(Long id); void register(User user); } class CafeUserService implements UserService { // 카페 유저 관련 구현 } class StoreUserService implements UserService { // 상점 유저 관련 구현 } 현재 서비스에는 카페 사용자만 존재하고, 상점 사용자는 아직 구현 계획이 없다. 그럼에도 불구하고 “미래의 확장성”을 위해 인터페이스를 도입해야 할까? 인터페이스를 도입하면 분명 유연성을 얻을 수 있지만, 당장 StoreUserService 구현체가 필요하지 않다면 이는 불필요한 복잡성을 가져올 수 있다. 게다가 초기 스타트업에서 이 부분은 두드러진다. 도입 가능성을 예측했으나 갈아엎어지는 기획이 너무나 많으니까... 따라서 DIP 적용의 균형점은 근미래의 변경 가능성 / 팀의 개발 문화에 있다고 생각한다. 물론 대규모 엔터프라이즈 애플리케이션에서는 철저한 DIP 적용이 장기적으로 유리할 수 있다. 작은 프로젝트나 스타트업에서는 과도한 추상화가 오히려 개발 속도를 늦출 수 있다. “도입이 상상되는 인터페이스”는 애자일과 맞지 않는다. 결국 DIP의 적용은 실용적 균형의 문제가 아닐까 생각해 본다. 이제 마지막, ISP에 대해서 생각해 보았다. ISP(인터페이스 분리 원칙) 적용의 현실적 고민 ISP의 케이스에서 가장 경계해야 할 것은 결국 [개수]라는 생각을 한다. 얼마나 분리할 것인가? 얼마나 분리하는 것이 효율적인가? 나는 [현재 상태에서 필요한 만큼]이라고, 그러니까 최소한이라라고 생각한다. 많은 인터페이스가 생겼을 때 가장 큰 문제는 아무래도 파일이 최소 2배가 된다는 점이다. 수많은 파일은 복잡성을 높이는 것이 사실이니까. 어떠한 아키텍처가 이 수많은 인터페이스들을 깔끔하게 감당할 수 있는가? Spring과 같은 DI 프레임워크에서는 이런 문제가 더욱 두드러질 수 있다. 수많은 작은 인터페이스들의 구현체를 모두 Bean으로 등록하고 관리해야 하기 때문이다. 동시에 나는 섣부른 인터페이스 분리를 가장 경계해야 한다고 생각한다. 인터페이스가 재정의되어야 하는 순간, 기존에 해당 인터페이스들을 사용하고 있는 객체의 로직을 전부 다 다시 살펴야 한다. 따라서 ISP 또한... 먼저 상상하지 않는 것이 중요해 보인다. 이 인터페이스를 구현한 뒤 시일이 지난 이후, 다른 구현체에서도 상태가 변하지 않을 인터페이스만을 최대한 고민해 보려 한다. 잠시 응집도가 떨어지더라도. 회고 우리는 현실과 아름다운 코드의 사이에서 무엇을 포기해야 하는가? 객체의 책임은 비즈니스의 상황마다 가변적일 수 있다는 생각을 하기 때문이다. 인터페이스가 많으면 아름답다. 하지만 한눈에 파악하는 것은 어려워진다. 전략패턴은 객체의 책임을 확장성있게 분리한다. 하지만 어떠한 객체를 할당할지 정하는 구체적인 룰이 정해져야 한다. 결국 아름다움은 집단의 룰을 포함한다. 혼자서는 무한대로 아름다울 수 있지만, 같이 하는 현실에서도 아름다움을 추구하는 것이 Readable한 코드의 핵심이라고 생각한다. 비즈니스적으로 빠른 코드 ≠ 읽기 좋은 코드 ≠ 아름다운 코드 장기적인 관점에서 아름다운 코드는 결국 집단이 얼마나 동일한 룰을 체득하고 있는지에 따라 달라진다고 생각한다. 그리고 우빈님의 강의는 그 룰에 대한 가이드라인을 주고 있다는 생각이 들었다. 이 룰을 체득한다면, 적어도 비즈니스를 위한 코드를 생각할 때 객체지향의 관점을 망치지는 않을 것이다. 다음 주 계획 다음 주에는 내가 알고 있는 클린코드와, 강의에서 이야기하는 클린 코드를 적용하면서 TDD를 공부해 볼 예정이다.프로젝트 한 가지를 가지고 와서 이야기를 하면 좋을 것 같아서, Gilded Rose를 가지고 와 봤다.유명한 리팩토링 kata 라이브러리니 프로젝트를 [적용]하는 방식에 있어서 많은 것을 이야기해 볼 수 있을 것 같다.테스트 코드에 대해서도 강의를 베이스로 해서 Junit을 연습해 보고자 한다.