블로그
전체 62025. 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을 연습해 보고자 한다.