Lucian Log
Blog
Computer Science
Algorithm
DB
Network
OS
General
AI
Blockchain
Concurrency
ETC
Git
Infrastructure
AWS
Docker
Java-Ecosystem
JPA
Java
Spring
JavaScript-Ecosystem
JavaScript
Next.js
React
TypeScript
Python-Ecosystem
Django
FastAPI
Python
SQLAlchemy
Software Engineering
Architecture
Culture
Test
Home
Contact
Copyright © 2024 |
Yankos
Home
> Software Engineering
Now Loading ...
Software Engineering
소프트웨어 장인
감사하다. 경험이 풍부한 그리고 소프트웨어 장인 정신을 실천하기 위해 노력했던 선배 개발자의 이야기를 들을 수 있는 책이었다. 추상적이던 애자일, XP, 소프트웨어 장인정신의 역사를 알 수 있었던 점도 좋았다. 2015년에 나온 책이지만, 대다수의 이야기들이 여전히 공감된다. 재밌는 부분은 과거에 문제였던 것들이 오늘 날에도 여전히 반복된다는 점이다. 선배들의 시행착오에서 어떤 개발자들은 나아갔고 어떤 개발자들은 나아가지 못했다. 개발자는 여러 경험을 쌓을 수 있지만, 소프트웨어의 품질 나아가 소프트웨어 산업 전반을 향상시키는 태도와 노력은 스스로 함양하고 실천해야 한다. 애자일 방법론, XP 실행 관례는 좋은 품질의 소프트웨어를 만들기 위해 과거 시행착오로부터 나온 증명된 노하우다. 우리는 선배들이 앞서 쌓아왔던 좋은 가치를 지향하고 그 위에 새로움과 변화를 쌓을 직업 윤리와 책임이 있다. 어려운 일이지만, 개발자라는 직업을 더욱 전문적이고 가치 있게 하는 이유이기도 하다. 애자일 (Agile) 서로 다른 여러 맥락에 따른 방법론과 테크닉의 조합 (단일 개념 X) 소프트웨어 프로젝트의 기본속성인 변화에 개발팀과 기업이 잘 적응할 수 있도록 도움 모든 애자일 방법론은 빠르고 짧은 피드백 루프에 대한 것 -> 피드백이 빠르고 짧을수록 애자일해짐 기술적 탁월함이 전제되어 있음 -> 기술적 수준이 개선되어야 한다는 것 애자일 매니페스토 창안 (2001년 2월) 소프트웨어 업계에 영향력이 있는 17명이 유타 주 스키 리조트에서 모임 켄트 백, 알리스테어 콕번, 워드 커닝햄, 마틴 파울러, 로버트 C. 마틴… 서로의 경험과 기술, 방법론을 공유하며 더 나은 소프트웨어 프로젝트 수행 방법 모색 여러 방법론과 테크닉을 발표 익스트림 프로그래밍(eXtreme Programming: XP) 스크럼 실용주의 프로그래밍 피처-드리븐 개발(FDD) 동적 시스템 개발 모델(Dynamic System Development Model: DSDM), 적응형 소프트웨어 개발, 크리스탈… 애자일 원칙 절차적 관점: 올바른 목표를 향해 진행 중인지 확인 회의 방식, 구성원들의 역할, 요구사항 파악 방법, 작업 진척 속도 파악 방법, 피드백 방식… 기술적 관점: 목표한 것을 올바르게 실행하고 있는지에 대해 안심할 수 있음 TDD, 페어 프로그래밍, 지속적인 통합, 단순한 디자인 원칙… 12가지 원칙 애자일 방식으로 일하기 위해 개발자는 비즈니스와 고객 가치 창출에 직접 관여해야함 (개발팀은 수평적이 되어감) 이렇게 일하기 위해서는 소프트웨어 프로페셔널이 되어야 한다 코드를 잘 작성하는 것은 최소 요건 테스트, 분석, 비즈니스에 대한 이해, 커뮤니케이션 능력, 보다 외향적인 성격 등이 요구됨 문제: 많은 애자일 전환이 기술적 개선 없이 절차와 도구에만 집중하다 실패 애자일은 절차와 기술적 탁월함이 모두 필요 스크럼 마스터, 애자일 코치가 절차에만 집중하고 사람들에 대한 기술적 훈련에는 관심이 없음 즉, 절차에만 집중하고 XP 실행 관례를 활용하는 경우는 드물다 (가르칠 역량도 없음) 프로젝트를 이끄는 상급자들이 기술 이해가 떨어지는 경우, 의사 결정이 프로젝트를 재앙으로 이끔 해결책: 완전한 애자일 전환을 위해서는 기업과 개발팀의 소프트웨어 장인정신이 필요 자기 목소리를 내는 프로페셔널한 개발자들이 필요하다 프로페셔널한 개발자: 기술적 실행 관례, 기술적 전문성, 관련 도구들을 마스터한 개발자 익스트림 프로그래밍 (XP) XP의 기원 켄트 백(Kent Beck)은 여러 실행 관례들의 묶음을 발표, 1996 이후 크라이슬러 사 급여 지급 시스템 (C3) 프로젝트 리더로 일하며 일부분 수정 C3 팀에는 론 제프리스, 마틴 파울러, 돈 웰스 등의 애자일 지지자들이 있었음 C3 프로젝트는 성공 -> XP 실행 관례 도입 후, 버그가 1/3로 감소, 테스트 커버리지 상승, 디버깅 시간 제로 근접, 생산성 10배 상승 실행 관례는 매일 같이 습관처럼 해야 하는 것 (내재화) 제공 가치 (증명됨) 빠른 피드백 루프, 요구사항과 비용에 대한 더 나은 이해, 지식 공유, 버그 감소, 자동화, 빠른 릴리즈 실행 관례를 거부하는 사람들에게 의사 결정에 대해서 책임감만 가지면 된다. 거부하는 사람들의 이유와 이야기에서도 듣고 배울 것이 있을 것이다. 다만, 물어보자 XP가 제공하는 가치와 동등한 가치를 만들어내기 위해 무엇을 하고 있나요? 더 나은 방법은 있나요? 미래에 더 훌륭한 실행 관례가 나타난다면 비교해보고 또 따르자 절대적인 것은 없음, 개방적인 사고방식 필요 아래만 비교하면 된다 프로젝트에 어떤 가치를 주는지? 피드백 루프가 얼마나 긴지? XP 실행 관례 테스트 주도 개발(TDD) 테스트가 코딩 방향을 주도하면 코드 설계가 간단해진다는 방법론 (복잡하기 어려움) 정확히 요구사항만 만족시키게 됨 코드가 복잡해지는 것을 방지 (복잡하면 테스트 자체가 어렵기 때문) 피드백이 빠르고 코드가 살아있는 문서 역할을 함 ‘테스트 코드를 먼저 작성한다’의 진화 버전 페어 프로그래밍 실시간 코드 리뷰 효과 전체 시스템 이해도 및 개발자 스킬이 팀 차원에서 누적되고 향상 코딩 표준도 정의 및 유지 가능 같은 페어끼리 너무 오래 있지 않도록 하루 이틀 단위로 교체 필요 리펙토링 지속적인 코드 리펙토링 필요 전체 시스템을 한꺼번에 새로 작성하고 싶은 욕구를 조심하고, 한정해서 리팩토링에 집중 자주 변경 되는 부분을 대상으로 시작 (몇 년 동안 안바뀐 부분은 바꿀 필요가 없음) 단순한 설계 공동 오너십 지속적인 통합 버그 예방 협업을 위해 코드를 배포할 때마다 전체 테스트 스위트가 실행되고 실패하면 알림 몇 분 정도의 빠른 피드백 루프로 완료 TDD + 지속적인 통합은 QA 팀의 부담이 줄거나 팀 자체가 필요하지 않을 수 있음 … 소프트웨어 장인정신 (Software Craftsmanship) 소프트웨어 개발의 프로페셔널리즘 소프트웨어 개발자로서 일을 더 잘하기 위해 품는 이념이자 삶의 철학 즉, 책임감, 프로페셔널리즘, 실용주의, 소프트웨어 개발자로서의 자부심 스스로 커리어에 책임감을 가지고, 지속적으로 새로운 도구와 기술을 익히며 발전하겠다는 태도 탁월함에 헌신하고 탁월함을 추구함 소프트웨어 장인정신 운동의 사명 프로페셔널리즘으로 소프트웨어 개발이라는 업의 수준을 기술적, 사회적으로 높이는 것 직업 윤리 역량 미달의 수동적인 노동자가 아니라 프로로서 수준을 높여 일하는 개발자 지향 (일평생 정진) 전문가들은 당연히 스스로에게 돈과 시간을 투자한다 (교육이 회사의 의무는 아님) 배움과 훈련이 멈추는 순간 우리의 커리어도 멈춰버린다 최선이라고 알려진 몇몇 조합들에 대해서 완전하게 마스터하고 있어야 한다 고객이 바라는 바를 가장 효율적으로 만족시킬 것 (실용주의와 밀접) 경험이 적은 소프트웨어 장인과 지식을 나누는 것 다음 세대 장인을 키우는데 사회적 윤리적 의무감을 느껴야 함 불가능한 일정에 대하여 모든 위험을 공유하여 아니오라고 말하고 대안을 제시할 것 자신이 떠나고 난 자리가 부끄럽지 않도록할 것 역사 “실용주의 프로그래머: 수련자에서 마스터로”, 1999 (의미 있는 시작) “소프트웨어 장인정신: 새로운 요구상” - 피트 맥브린, 2001 소프트웨어 도제 토론 모임 - 켄 아우어, 2002 애자일 콘퍼런스 & “클린 코드: 애자일 소프트웨어 장인정신을 위한 핸드북” - 로버트 마틴, 2008 애자일의 절차 중심적 상업화에 대한 걱정으로 소프트웨어 장인정신 정의와 대중화에 관심 소프트웨어 장인 매니페스토, 2009 핵심은 프로페셔널 소프트웨어 개발의 수준을 높인다(부제)에 있다. 내용 소프트웨어 장인을 열망하는 우리는, 스스로의 기술을 연마하고, 다른 사람들이 기술을 배울 수 있도록 도움으로써 프로페셔널 소프트웨어 개발의 수준을 높인다. 이러한 일을 하는 과정에서 우리는 다음과 같은 가치들을 추구한다. 동작하는 SW뿐만 아니라, 정교하고 솜씨 있게 만들어진 작품을, -> 개발자가 쉽게 이해할 수 있는 SW (테스트, 비즈니스 용어 코드, 명료, …) 변화에 대응하는 것뿐만 아니라, 계속해서 가치를 더하는 것을, -> 테스트, 확장 가능한 구조, 쉬운 유지보수 개별적으로 협력하는 것뿐만 아니라, 프로페셔널 커뮤니티를 조성하는 것을, -> 멘토링과 공유 고객과 협업하는 것뿐만 아니라, 생산적인 동반자 관계를, 파트너십과 프로페셔널한 행동을 계약관계보다 상위에 둔다 적극적으로 프로젝트 성공에 기여해야 함 평판 관리 + 고객(협업할 기업)을 선별하는 능력도 요구됨 이 왼쪽의 항목들을 추구하는 과정에서, 오른쪽 항목들이 꼭 필요함을 의미한다. 소프트웨어 장인 컨퍼런스 & 소프트웨어 장인 커뮤니티(LSCC), 2009~ 사용되는 비유 장인 (개발자) & 공예품 (소프트웨어) 도제 시스템 (다른 개발자들에게 기술을 공유하고 가르친다는 관점) 개발자와 기업들이 일을 올바르게 수행하도록 도움 여러 기술적 실행 관례를 활용 애자일과 소프트웨어 장인 정신은 상호 보완적 익스트림 프로그래밍(XP) 실행 관례도 적극적으로 활용 정교하고 솜씨 있게 짠 코드의 중요성을 강조 코드를 넘어 고객의 더 많은 부분을 도울 것을 강조 소프트웨어 장인은 애자일 원칙과 XP 실행 관례를 습관화하고 있음 익숙하지 않아 업무 속도가 느릴 수는 있지만, 원칙과 관례로 인해 업무 속도가 느려질리는 없음 추천하는 노력 과정 끊임 없는 자기계발 독서, 블로그, 기술 웹사이트, 리더 그룹 팔로우(트위터) 블로그는 나 자신을 위한 기록이 가장 우선, 다른 사람 생각은 너무 걱정 말자 끊임 없는 훈련 카타, 펫 프로젝트, 오픈소스, 페어 프로그래밍 다양한 경험 소프트웨어 개발은 다양성이 상당히 높은 전문 분야 다양한 기술과 도구를 접하면 프로페셔널해지고 생각지 못했던 커리어 선택지도 생김 탁월함을 위한 조언 커리어 형편없는 코드를 남기지 말자 커리어 패스는 내가 열정이 있는 것, 진정 즐겁게 할 수 있는 것을 따라야 한다 (개발자의 즐거움) 고참은 일시적이고 상대적인 것이다 거쳐 가는 모든 직장, 프로젝트들 하나하나가 미래 목표를 위한 투자다. 직장은 단순히 돈 버는 곳이 아니다. 지식노동자를 움직이는 것은 자율성, 통달, 목적의식이다. 소프트웨어 장인은 이를 따라 일할 곳을 선택한다. 소프트웨어 장인은 일자리를 잃는 것에 걱정이 없다. 자신의 커리어 방향이 일치하는 경우에만 수용한다. 회사 내 커리어보다 개인의 커리어를 항상 우선해야 한다. 테스트 자동화할 수 있는 버그를 QA가 발견하는 것은 개발자로서 대단히 수치스러운 일이다 QA의 역할은 인간의 예측할 수 없는 행동을 반영해 개발자가 예상하지 못한 문제를 찾아내는 것 테스트 코드를 쓰지 않았다면, 코드 작성을 완료했다고 할 수 없다. 자신이 짠 코드를 알고 있으니 테스트 코드를 안만들어도 된다는 개발자는 대단히 이기적인 사람이다. 리팩토링 레거시 코드를 볼 때 짠 사람을 너무 욕하지말고 즐거운 도전 과제로 생각하자. 남이 작성한 코드를 엉망이라고 말하기는 쉽지만, ‘나라면 더 잘 만들 수 있는가?’를 스스로 물어보자. 레거시 코드는 경계부터 점진적으로 테스트 코드를 작성하면서 이해도를 높이고 리팩토링해나간다 설계 가장 훌륭한 코드는 작성할 필요가 없는 코드다. 더 적게 작성할수록 더 좋다. 켄트 백이 제시한 ‘단순한 설계를 위한 4가지 원칙’ 모든 테스트를 통과해야 한다 명료하고, 충분히 표현되고, 일관되어야 한다 동작이나 설정에 중복이 있어서는 안된다 메서드, 클래스, 모듈의 수는 가능한 적어야 한다 -> 요약하면 중복의 최소화, 명료성의 최대화 디자인 패턴은 범용적이어서 오버 엔지니어링과 복잡함을 유도하므로 리팩토링이 필요할 때 적용하자. TDD 기반 애자일과 XP 실행 관례는 당장 필요를 충족시키는 단순한 코드를 유도한다. 예를 들어, 기능 추가로 인해 실제 필요한 상황에만 추상화를 도입하자. (실용적) 채용 자신보다 훌륭한 사람과 함께 일하기를 원하고 최소 자신과 비슷한 역량의 사람이 채용되길 희망하자. 다른 개발자를 추천하는 것 자체가 스스로의 평판을 시험대에 올리는 행위임을 이해하자. 항상 새로운 것을 시도하고, 배우고, 지식을 공유하고, 커뮤니티 활동에 적극적인 열정 있는 사람이 중요하다. 특정 기술에 대한 지식, 경력년수, 학위는 훌륭한 개발자를 놓치게 하는 요소다. GitHub 계정, 블로그, 오픈 소스 활동, 커뮤니티 활동 내역, 펫 프로젝트, 트위터 계정, 좋아하는 기술 서적 목록, 참석했던 컨퍼런스 등이 열정 있는 사람을 채용하기 위한 이력서 요소다. 열정적인 개발자는 성장하기 위해 개인 시간을 기꺼이 투자한다. 회사는 시급히 채용할 상황을 절대 만들어서는 안된다. 잘못된 채용은 프로젝트를 망친다. 채용은 파트너십이다. 재능있는 개발자의 의견을 중요시 하고 일 방식 개선에서 도움을 받겠다는 것이다. 면접에서 질문을 많이 하는 사람은 좋은 파트너십을 맺을 가능성이 높다. 새로운 프로젝트의 개발자 채용은 열정과 소프트웨어 개발 기초 역량 외에도 프로젝트 성공 경험이 필요하다. 고객의 문제에 대응하고 비즈니스적 압박을 견뎌내는 노련한 개발자가 필요 면접관은 지원자를 프로페셔널로 대하고 건강한 기술 토론이 되도록 이끌어야 한다. 지원자를 무시하거나 바보로 만들면 안된다. 문화 소프트웨어 장인은 주변 사람들에게 영감을 불어 넣기 위해 모든 노력을 아끼지 않는다. 배움의 문화를 만들면 회사에 열정을 주입할 수 있다. 북클럽, 테크 런치, 그룹 토론회, 업무 교환, 그룹 코드 리뷰, 그룹 코드 카타, 회사 시간 내 펫프로젝트 시간 허용, 외부 기술 커뮤니티와 교류하기… 배움의 문화는 강제하지 말고 관심 있는 사람에게 집중하자. Reference 소프트웨어 장인
Software Engineering
· 2024-12-30
도메인 주도 개발 시작하기
DDD (Domain Driven Design) 도메인이 중심이 되는 개발 방식 DDD는 추상적인 설계 철학이고 여러 답이 나올 수 있다는 점에서 예술이다 기존 Model Driven Design을 한 단계 발전시킴 기존 Model Driven Design은 생산성 향상에 초점을 맞춘 기술 중심적 접근 방식 DDD는 MDD에 비즈니스 중심의 접근 방식과 전략적 설계 개념을 추가 핵심 목표: Loose Coupling, High Cohesion 지속적으로 진화하는 모델을 만들면서 복잡한 어플리케이션을 쉽게 만들어 가는 것 전략적 설계 & 전술적 설계 전략적 설계 (Strategic Design) 도메인 문제를 문제 공간에서 해결 공간으로 가져가는 과정 (도메인 전문가와 기술팀이 함께 회의) 문제 공간(Problem Space): 도메인 추출 및 하위 도메인 분류 (도메인 전문가가 주요 역할) 해결 공간(Solution Space): 바운디드 컨텍스트 및 컨텍스트 맵 정의 (개발자가 주요 역할) 범위: 전반적 핵심 개념: Ubiquitous Language (보편 언어) 핵심은 유비쿼터스 언어에 기반한 팀 간 커뮤니케이션 유용한 도구 사용 사례(유스케이스) 분석 이벤트 스토밍 (Event Stroming) Business Model 분석 … 전술적 설계 (Tactical Design) 전략적 설계에서 도출된 도메인 모델과 컨텍스트 맵을 이용해 실제 구현 진행 e.g. 바운디드 컨텍스트마다 도메인에 알맞은 아키텍처 사용 핵심 Bounded Context는 Model-Driven 지원 Bounded Context는 CRUD (서비스-DAO) 일반 Bounded Context는 Model-Driven, 3rd-party… e.g. 컨텍스트 내 혼합도 가능 (CQRS) 상태 변경 관련 기능은 Model-Driven 조회 기능은 CRUD (서비스-DAO) e.g. 바운디드 컨텍스트마다 서로 다른 구현 기술 사용도 가능 바운디드 컨텍스트 1: 스프링 MVC + JPA 바운디드 컨텍스트 2: Netty + Mybatis 바운디드 컨텍스트 3: 스프링 MVC + 몽고 DB 범위: 특정 Bounded Context 핵심 개념: Model Driven Design (모델 주도 설계) 도메인 모델을 중심으로 패턴 적용 유용한 패턴 계층형 아키텍처 Entity, Value Object Aggregate Factory Repository Domain Event 콘웨이의 법칙 소프트웨어의 구조는 해당 소프트웨어를 개발하는 조직의 구조를 따라간다. 역콘웨이의 전략 개발하는 조직의 구조를 소프트웨어의 구조에 맞춘다. 바운디드 컨텍스트 별로 팀을 구성하는 것도 좋다. DDD 주요 개념 도메인 소프트웨어로 해결하고자 하는 현실 세계의 문제 영역 e.g. 온라인 서점 세부적 분류 비즈니스 도메인: 전체 문제 영역 문제 도메인: 전체 문제 영역 중 IT로 해결하고자 하는 문제 영역 도서 기업의 경우 문제 도메인은 전체 비즈니스 도메인의 일부분 IT 기업의 경우 비즈니스 도메인 전부가 문제 도메인 하위 도메인 한 도메인은 여러 하위 도메인으로 나눌 수 있음 e.g. 카탈로그, 주문, 혜택, 배송 … 3가지 유형으로 분류 가능 핵심 하위 도메인: 가장 중요한 문제 지원 하위 도메인: 핵심 도메인을 지원하는 문제 일반 하위 도메인: 대부분의 소프트웨어가 가지고 있는 문제 (e.g. 회원) 하위 도메인들은 서로 연동하여 완전한 기능을 제공 도메인의 특정 기능은 외부 시스템이나 수작업을 활용하기도 함 e.g. 배송 업체, 결제 대행 업체, 소규모 업체의 정산 수작업 엑셀 처리 도메인 전문가 온라인 홍보, 정산, 배송 등 각 영역에는 전문가 존재 요구사항을 제대로 이해할수록 도메인 전문가가 원하는 제품으로 향할 가능성 높음 개발자와 전문가는 직접 대화해야 함 중간에서 발생하는 정보의 왜곡과 손실을 방지 개발자는 전문가가 진짜 원하는 것을 찾아야 함 도메인 전문가 스스로도 요구사항을 정확히 표현 못할 수 있음 도메인 모델 특정 도메인을 개념적으로 표현한 것 e.g. 객체 기반 모델링 (기능과 데이터), 상태 다이어그램 기반 모델링 (상태 전이) - UML 예시 도메인을 이해하는데 도움이 된다면 표현 방식은 어느 것이든 괜찮음 (중요한 내용만 담음) 관계가 중요한 도메인은 그래프, 계산 규칙이 중요하면 수학 공식 이용 도메인 모델 (개념) VS 구현 모델 (구현 기술) 각 하위 도메인마다 별도로 모델을 만들어야 함 (올바른 방법) 같은 용어라도 하위 도메인마다 의미가 달라질 수 있음 e.g. 카탈로그 상품 (이미지, 상품명, 가격 위주), 재고 관리 상품 (실존 개별 객체 추적 목적) 도메인 모델은 엔터티와 밸류 타입으로 구분 가능 모델링 방법 요구사항을 바탕으로 도메인 모델을 구성하는 핵심 구성요소(엔터티, 속성), 규칙, 기능 찾기 상위 수준에서 정리한 문서화가 매우 큰 도움이 됨 (e.g. 화이트 보드, 위키 등) 바운디드 컨텍스트 (Bounded Context) 특정 도메인 모델을 구분하는 경계를 정의 구조: 도메인 모델 + 표현 영역, 응용 서비스, 도메인, 인프라스트럭처, DBMS를 모두 포함 바운디드 컨텍스트는 용어를 기준으로 구분 (컨택스트 내에서 동일한 유비쿼터스 언어 사용) e.g. 같은 상품도 카탈로그 B.C의 Product와 재고 B.C의 Product는 각 B.C에 맞는 모델 가짐 바운디드 컨텍스트가 하위 도메인과 1 : 1 관계를 가지면 이상적이지만, 현실은 그렇지 않을 때가 많음 이상적 목표: 바운디드 컨텍스트 1 : 하위 도메인 1 : 도메인 모델 1 현실 e.g. 팀 조직 구조에 따라 결정 주문 바운디드 컨텍스트 + 결제 금액 계산 바운디드 컨텍스트 = 주문 하위 도메인 e.g. 용어를 명확히 구분 못해 두 하위 도메인을 하나의 바운디드 컨텍스트에서 구현 상품 바운디드 컨텍스트 = 카탈로그 하위 도메인 + 재고 하위 도메인 e.g. 규모가 작은 기업은 전체 시스템을 한 개팀에서 구현 소규모 쇼핑몰을 1개의 웹 애플리케이션으로 제공 1개 바운디드 컨텍스트 = 회원 + 카탈로그 + 재고 + 구매 + 결제 하위 도메인 여러 하위 도메인을 하나의 바운디드 컨텍스트에서 개발할 때, 하위 도메인 모델이 섞이지 않도록 하자 물리 바운디드 컨텍스트가 1개여도 논리 바운디드 컨텍스트 생성하자 (내부에서 패키지 활용) 바운디드 컨텍스트 간 통합 e.g. 카탈로그 하위 도메인에서 카탈로그 B.C와 추천 B.C 개발 카탈로그 B.C와 추천 B.C는 서로 다른 도메인 모델 가짐 직접 통합 방식 (e.g. REST API) 클라이언트로 호출한 후 응답 받은 데이터를 현재 도메인에 맞는 모델로 변환 e.g. 카탈로그 시스템은 카탈로그 도메인 모델에 기반한 도메인 서비스로 상품 추천 기능 표현 ProductRecommendationService (도메인 서비스) - RecSystemClient (Infra) RecSystemClient externalRecClient로 추천 시스템의 REST API 호출 응답받은 추천 데이터를 카탈로그 도메인에 맞는 상품 모델로 변환 변환이 복잡하면 별도의 Translator 클래스를 만들어 처리해도 됨 간접 통합 방식 (e.g. 메시지 큐) 출판/구독 모델을 이용해 바운디드 컨텍스트끼리 연동 한 바운디드 컨텍스트가 메시지 큐에 필요한 데이터를 저장 다른 바운디드 컨텍스트들은 메시지 큐에서 데이터 수신 서로 메시지 형식을 맞춰야 함 보통 큐를 제공하는 주체에 기반해 데이터 구조 결정 e.g. 추천 시스템은 사용자 활동 이력이 필요 (조회 상품 이력이나 구매 이력 등) 메시지 교환 카탈로그 시스템은 메시지 큐에 사용자 활동 이력 추가 추천 시스템은 메시지 큐에서 메시지를 읽어와 사용 카탈로그 시스템에서 큐를 제공하면 메시지 형식은 카탈로그 도메인 모델에 기반함 메시지 형식은 어떤 도메인 관점을 쓸지에 따라 달라짐 카탈로그 도메인 관점 (ViewLog, OrderLog-OrderLineLog) 카탈로그 도메인 모델 기준의 데이터를 메시지 큐에 저장 추천 도메인 관점 (ActiveLog) 추천 도메인 모델 기준의 데이터로 변환해 메시지 큐에 저장 만일, 큐를 추천 시스템에서 제공하면 REST API와 비슷해짐 물론 카탈로그 시스템이 비동기로 전달한다는 차이는 있음 바운디드 컨텍스트 간 관계 API 호출 방식 (e.g. REST API, 프로토콜 버퍼) - 메시지 큐 방식도 포함될 듯함! 단점은 하류 컴포넌트(Downstream)는 상류 컴포넌트(Upstream)에 의존 즉, API를 사용하는 바운디드 컨텍스트는 API를 제공하는 바운디드 컨텍스트에 의존 상류 컴포넌트는 상류 B.C의 도메인 모델을 따름 공개 호스트 서비스 (Open Host Service) 상류 팀이 여러 하류팀의 요구사항을 수용할 수 있는 API를 만들어 제공하는 서비스 e.g. 검색 B.C (상류 컴포넌트) : 블로그, 카페, 게시판 B.C (하류 컴포넌트) 안티코럽션 계층 (Anticorruption Layer) 하류 서비스는 상류 서비스 모델이 자신의 모델에 영향을 주지 않도록 완충지대 구성 e.g. 앞선 RecSystemClient는 모델 변환 처리와 안티코럽션 계층 역할을 함 공유 커널 방식 (Shared Kernel) 두 바운디드 컨텍스트가 같은 모델을 공유 공유 커널: 함께 공유하는 모델 e.g. 운영자를 위한 주문 관리 도구 개발 팀 VS 고객을 위한 주문 서비스 개발 팀 주문을 표현하는 모델을 서로 공유해 주문과 관련된 중복 설계 방지 중복을 줄여주는 장점 (동일한 모델을 두 번 개발하는 것을 방지) 공유 커널을 사용하는 두 팀은 반드시 밀접한 관계를 유지해야 함 독립 방식 (Separate Way) 서로 통합하지 않는 방식 두 바운디드 컨텍스트 간 통합이 필요하다면 수동으로 진행 e.g. 온라인 쇼핑몰 판매 정보를 운영자가 직접 ERP 시스템에 입력 규모가 커지면 결국 두 바운디드 컨텍스트를 통합해야 함 (수동 통합은 한계가 있음) 컨텍스트 맵 (Context Map) 바운디드 컨텍스트 간의 관계를 표시한 지도 전체 비즈니스 조망 가능 (시스템의 전체 구조) 규칙은 크게 없음 해결 공간의 대표적 산출물 매핑 관계 용어 유비쿼터스 언어 (Ubiquitous Language, 전략적 설계의 핵심) 전문가, 관계자, 개발자가 공유하는 도메인과 관련된 공통 언어 (도메인에서 사용하는 용어) 바운디드 컨텍스트 내에서 동일한 유비쿼터스 언어 공유 대화, 문서, 도메인 모델, 코드, 테스트 등 모든 곳에 반영해야 한다 소통 과정에서 용어의 모호함이 감소 개발자는 도메인과 코드 사이에서 불필요한 의미 해석 과정 감소 알맞은 영어 단어 찾는 시간을 아끼지 말고, 코드와 문서는 변화를 바로 반영해 최신 상태를 유지 모델 주도 설계 (Model Driven Design, 전술적 설계의 핵심) 비즈니스 도메인의 핵심 개념과 규칙을 반영한 모델을 기반으로 설계하는 방법론 DDD를 위한 패턴들을 사용해 모델이 생명력을 잃지 않도록 지속적으로 관리 e.g. 계층형 아키텍처, Entity, Value Object, Aggregate, Factory, Repository… 객체 기반 도메인 모델링 상태 다이어그램 기반 도메인 모델링 도메인 모델, DTO와 get/set 메서드 도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다. 특히 set 메서드는 필드값만 변경하고 끝나기 때문에 상태 변경과 관련된 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다. 또한, set 메서드를 열어두는 것은 도메인 객체를 불완전하게 생성하도록 허용한다. 따라서, 도메인 객체는 생성자를 통해 필요한 데이터를 모두 받도록 설계해야 한다. (이 경우, private한 set을 만들어 생성자에서 사용할 수 있음) 또한, 불변 밸류 타입을 사용하면 자연스럽게 set 메서드 사용이 사라진다. DTO는 도메인 로직이 없어 get/set 메서드를 사용해도 데이터 일관성에 영향을 덜 주지만, 프레임워크의 private 필드 직접 할당 기능을 최대한 사용하면 불변 객체의 장점을 DTO까지 확장할 수 있어 권장한다. 그린 필드 & 브라운 필드 그린필드: 소프트웨어의 초기 개발 시점 (코드가 깨끗함) 브라운 필드: 소프트웨어가 장기간 개발되어 복잡해진 시점 계층 구조 아키텍처 구성 - 도메인 모델 패턴 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴 마틴파울러, 계층 구조는 상위 계층에서 하위 계층으로만 의존하는 특성을 가짐 (지름길은 허용) DIP 적용 -> 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존하는 구조로 변화 인프라스트럭처의 클래스가 도메인이나 응용 영역에 정의된 인터페이스를 상속 DIP를 항상 적용할 필요 X -> DIP 장점(변경에 유연, 테스트가 쉬움) VS 구현의 편리함 적절히 고려 DIP의 장점을 해치지 않는 범위라면 응용 영역과 도메인 영역이 구현 기술을 의존해도 괜찮다 응용 영역의 트랜잭션 처리 의존 정도는 괜찮음 (@Transactional) 리포지터리와 도메인 모델은 구현기술이 거의 바뀌지 않아 타협도 좋음 (테스트 문제도 X) e.g.1 JPA 애너테이션이 적용된 도메인 모델 (@Entity, @Table) e.g.2 스프링 데이터 JPA를 상속하는 리포지터리 인터페이스 구조 표현 계층 (Presentation, UI) 사용자의 요청을 처리하고 정보를 보여줌 데이터 변환 역할 요청 데이터를 응용 서비스가 요구하는 알맞은 형태로 변환해 전달 실행 결과를 사용자에게 알맞은 형식으로 응답 응용 계층 (Application) 도메인 계층을 조합해서 사용자가 요청한 기능을 실행 주로 오케스트레이션을 하는 계층이어서 단순한 형태를 가짐 도메인 기능 예시 리포지터리에서 애그리거트를 구한다 애그리거트의 도메인 기능을 실행한다 결과를 리턴한다 새 애그리거트 생성 예시 데이터가 유효한지 검사한다 (데이터 중복 등) 애그리거트를 생성한다 리포지터리에 애그리거트를 저장한다 결과를 리턴한다 트랜잭션 처리, 접근 제어, 이벤트 처리 등을 담당 도메인 계층 (Domain) 도메인 모델에 도메인 핵심 규칙을 구현 인프라스트럭처 계층 (Infrastructure) 외부 시스템과의 연동 처리 (e.g. DB, SMTP, REST, 메시징 시스템) 구현 전략 인증 및 인가 전략 URL 이용 인증 및 인가는 서블릿 필터가 좋은 위치 (스프링 시큐리티도 유사하게 동작) URL만으로 어려운 경우 응용 서비스의 메서드 단위로 인증 및 인가 수행 (@PreAuthorize) 개별 도메인 객체 단위로 필요한 경우, 직접 권한 로직 구현 (심화: 스프링 시큐리티 확장) e.g. 게시글 삭제는 본인 또는 관리자 역할을 가진 사용자만 할 수 있다 게시글 애그리거트를 로딩해야 권한 검사할 수 있음 (도메인 서비스에 구현) permissionService.checkDeletePermission(userId, article); 응용 계층 전략 한 도메인과 관련된 기능은 각각 별도의 서비스 클래스로 구현하자. 각 클래스 별로 필요한 의존 객체만 포함하므로 코드 품질 유지와 이해에 도움이 됨 클래스의 개수가 많아지고 단순 코드 중복은 문제 필요 시, 한 응용 서비스 클래스에서 1개 내지 2~3개 기능 정도를 가지도록 허용 코드 중복이 신경쓰인다면 별도의 헬퍼 클래스를 둬서 해결 // 각 응용 서비스에서 공통되는 로직을 별도 클래스로 구현 public final class MemberServiceHelper { public static Member findExistingMember(MemberRepository repo, String memberId) { Member member = repo.findById(memberId); if (member == null) throw new NoMemberException(memberId); return member; } } // 공통 로직을 제공하는 메서드를 응용 서비스에서 사용 import static com.myshop.member.application.MemberServiceHelper.*; public class ChangePasswordService { private MemberRepository memberRepository; public void changePassword(String memberId, String curPw, String newPw) { Member member = findExistingMember(memberRepository, memberId); member.changePassword(curPw, newPw); } // ... } 응용 계층 전달 데이터가 2개 이상이면 DTO 객체를 사용해 표현 계층에서 자동 변환하면 편리 e.g. 스프링 MVC 웹 요청 파라미터 자바 객체 변환 기능 응용 서비스는 표현 영역에서 필요한 데이터만 리턴하자 도메인 객체 리턴하면 도메인 로직을 표현 영역에서 실행할 가능성이 생김 요청 값에 대한 검증은 표현 계층 보다 응용 서비스에서 처리하자 (응용서비스 완성도 상승) 여러 검증 정보가 한 번에 필요하면, 서비스에서 에러 코드를 모아 1개 예외로 발생시키자 List<ValidationError> errors = new ArrayList<>; if (!errors.isEmpty()) throw new ValidationErrorException(errors); 표현 영역에서 예외 잡아 변환 - bindingResult.rejectValue() 조회 전용 기능의 경우 서비스 없이 표현 영역에서 바로 사용해도 괜찮음 요청 처리 흐름 패키지 구조 한 패키지는 가능한 10~15개 미만으로 타입 개수 유지 (코드 찾기 불편하지 않을 정도) 기본 패키지 구성 도메인이 클 때는 하위 도메인마다 별도로 패키지 구성하자 각 하위 도메인의 응용 영역과 도메인 영역은 애그리거트 기준으로 나누어 재구성 가능 애그리거트, 모델, 리포지터리는 같은 도메인 모듈에 위치 도메인이 크면 도메인 모델과 도메인 서비스를 별도 패키지로 구분 가능 order.domain.order, order.domain.service 도메인 영역의 주요 구성요소 엔터티와 밸류 (Entity & Value type) 엔터티와 밸류의 구분법: 고유 식별자를 갖는지 확인 DB 테이블 갖는다고 엔터티는 아님 엔터티 식별자와 DB PK 식별자는 다른 것 e.g. Article - ArticleContent ArticleContent는 밸류이며, DB에 PK가 있지만 도메인에서의 식별자는 아님 참고: 식별자 구현 위치 식별자 생성규칙(도메인 규칙)이 있으면, 도메인 영역에 위치 (e.g. 도메인 서비스, 리포지토리) 엔터티 고유의 식별자를 갖는 객체 식별자가 같으면 두 엔터티는 같다 (equals() 및 hashCode() 로 구현) 자신의 라이프 사이클을 가짐 도메인 모델 엔터티와 DB 관계형 모델 엔터티는 서로 다름 도메인 모델 엔터티는 데이터와 함께 도메인 기능을 제공 도메인 모델 엔터티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류로 표현 가능 RDBMS는 밸류 타입이 표현이 어려움 개별 데이터로 저장 -> 개념이 드러나지 않음 테이블을 분리해 저장 -> 테이블의 엔터티에 가깝고 밸류라는 의미가 드러나지 않음 밸류 고유의 식별자를 갖지 않는 객체 모든 속성이 같으면 두 밸류 객체는 같다 (equals() 및 hashCode() 로 구현) 주로 개념적으로 하나인 값을 표현하고 싶거나 의미를 명확하게 표현하고 싶을 때 사용 ex 1. Receiver = receiverName + receiverPhoneNumber ex 2. Address = shippingAddress1 + shippingAddress2 + shipppingZipcode ex 3. ShippingInfo = Receiver + Address ex 4. Money - ‘돈’을 의미하도록 하여 코드 이해에 도움을 줌 ex 5. OrderNo - 주문 엔터티의 식별자로 밸류를 사용해 코드 이해에 도움을 줌 엔터티의 속성 혹은 다른 밸류 타입의 속성으로 사용됨 기능 추가가 가능하다는 장점이 있음 불변(immutable) 으로 설계해야 함 데이터 변경 기능 제공 X 변경할 때는 새로 밸류 객체를 생성해 반환 JPA 관련 테크닉 JPA가 강제하는 엔터티와 벨류 정의 시 필요한 기본 생성자는 protected로 선언하자 값이 없는 온전치 못한 객체 생성 예방 @Access(AccessType.FIELD): 메서드 매핑을 완전히 방지하고 필드 매핑 강제 - 원래 JPA는 메서드에도 컬럼 매핑(AccessType.PROPERTY)이 가능하므로 아얘 막자 밸류 매핑 @Embedded, @Embeddable: 값 객체 지정 애노테이션 @EmbeddedId: 식별자 자체를 밸류 타입으로 지정 (식별자 의미 강조 위해) JPA는 식별자 타입이 Serializable 이어야 하므로, 밸류 타입은 상속 필요 기능 추가 가능한 장점 (e.g. 주문 번호 세대 구분) @SecondaryTable: 밸류를 별도 테이블로 매핑 조회 성능이 안좋음 (두 테이블을 조인해서 가져옴) -> 조회 전용 DAO 사용 필수! 지연 로딩을 위해 밸류를 엔터티로 매핑할 수도 있지만, 밸류 정체성을 잃어버려서 안좋음 @AttributeOverrides: 값 객체의 칼럼 이름이 다른 값 객체의 그것과 서로 다를 때 사용 AttributeConverter: 2개 이상의 프로퍼티를 가진 밸류 타입을 1개 컬럼에 매핑 가능 AttributeConverter 인터페이스를 상속해 convertToDatabaseColumn(), convertToEntityAttribute() 구현한 후, 해당 클래스에 @Converter(autoApply = true) 적용 autoApply = false인 경우 필요한 곳에 @Convert(converter = ...) 적용 e.g. Length 클래스(int value, String unit) -> DB 컬럼 width 밸류 컬렉션 매핑 @ElementCollection, @CollectionTable: 밸류 컬렉션을 별도 테이블로 매핑 e.g. Order - OrderLines AttributeConverter: 밸류 컬렉션을 한 개 컬럼에 매핑할 때도 사용 e.g. Email 주소 목록 Set (EmailSet) -> 1개 DB 칼럼에 콤마로 구분해 저장 기술적 혹은 팀 표준적 한계로 인해, 밸류를 @Entity로 구현해야 할 수도 있음 e.g. 밸류 타입인데 상속 매핑이 필요한 경우 상태 변경 메서드 제거 & cascade + orphanRemoval=true 적용 다만, images 리스트의 clear() 호출하면 쿼리가 비효율적이어서, @Embeddable로 단일 클래스 구현하고 기능은 타입에 따라 if-else로 구현하는 것도 방법이다! @SecondaryTable 예제 @Entity @Table(name = "article") @SecondaryTable( name = "article_content", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") ) public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @AttributeOverrides({ @AttributeOverride( name = "content", column = @Column(table = "article_content", name = "content") ), @AttributeOverride( name = "contentType", column = @Column(table = "article_content", name = "content_type") ) }) @Embedded private ArticleContent content; } 애그리거트 (Aggregate) 연관된 엔터티와 밸류 객체를 개념적으로 하나로 묶은 군집 개념상 완전한 1개의 도메인 모델 표현 e.g. 주문 애그리거트(상위 개념) = Order 엔터티, OrderLine 밸류, Orderer 밸류(하위) 보통 대다수의 애그리거트는 1개의 엔터티 객체만으로 구성되며 2개 이상은 드물다 루트 엔터티 이외의 엔터티는 실제 엔터티가 맞는지, 맞다면 다른 애그리거트는 아닌지 의심 애그리거트를 나누는 기준: 도메인 규칙에 따라 함께 생성 혹은 변경되는 구성 요소인가? (라이프사이클) ‘A가 B를 갖는다’ 라는 요구사항이 있어도 A와 B가 다른 애그리거트일 수 있음 e.g. 상품과 리뷰는 다른 애그리거트 - 변경 주체가 다르고 함께 생성하거나 함께 변경하지 않음 특징 커져서 복잡해진 도메인 모델을 상위 수준에서 애그리거트 간 관계로 파악 및 관리 가능 애그리거트는 도메인 규칙과 요구사항에 따라 경계를 가짐 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않음 애그리거트에 속한 객체는 유사하거나 동일한 라이프사이클을 가짐 (대부분 함께 생성 및 제거) 일관성을 관리하는 기준이 됨 복잡한 도메인 모델을 단순한 구조로 만들어 도메인 기능 확장 및 변경 비용 감소 루트 엔터티 애그리거트에 속한 전체 객체를 관리하는 엔터티 핵심 역할: 애그리거트의 일관성이 깨지지 않도록 하는 것 도메인 규칙을 지켜 애그리거트 내 모든 객체가 항상 정상 상태를 유지하도록 함 e.g. OrderLine 밸류 변경 시, Order 엔터티의 주문 총 금액을 함께 변경 e.g. changeShippingInfo()는 배송 시작 전에만 배송지를 변경 가능하도록 구현 애그리거트의 유일한 진입점 (애그리거트 단위로 내부 구현 캡슐화) 애그리거트의 도메인 기능은 루트 엔터티를 통해서만 실행 가능 애그리거트 내 엔터티 및 밸류 객체는 루트 엔터티를 통해서만 간접적으로 접근 가능 필요한 구현 습관 단순한 필드 변경 public set 메서드는 지양하자 set 메서드는 도메인 의도를 표현하지 못함 공개 set만 줄여도 cancel, change 등 의미가 드러나는 메서드 구현 빈도 상승 밸류 타입은 불변으로 구현하기 덕분에 애그리거트 외부에서 밸류 객체에 접근해도 상태 변경은 불가능 e.g. 루트 엔터티는 내부 다른 객체를 조합(참조, 기능 실행 위임)해 기능 구현 완성 Order - calculateTotalAmounts() OrderLines - ChangeOrderLines() 혹시 Order가 getOrderLines()를 제공해도 OrderLines가 불변이면 애그리거트 외부에서 변경이 불가능해 안전하다 혹시 불변 구현이 불가능한 경우, 변경 기능을 default(package-private), protected로 제한하면 애그리거트 외부 상태 변경을 방지할 수 있음 애그리거트에 영속성 전파 적용하자 (@OneToMany, @OneToOne) 애그리거트의 모든 객체는 함께 저장되고 함께 삭제되어야 함 cascade = {CascadeType.PERSIST, CascadeType.REMOVE} orphanRemoval = true @Embeddable은 기본적으로 함께 저장 및 삭제되므로 cascade 속성 필요 X 애그리거트 간 참조 = 루트 엔터티가 다른 루트 엔터티를 참조하는 것 다른 애그리거트를 참조할 때는 ID 참조하자 애그리거트 경계가 명확해지고 응집도가 높아짐 여러 애그리거트를 읽어야할 때, 조회 속도 문제는 조회 전용 쿼리로 해결 (DAO) 여러 애그리거트를 읽어야할 때, 조회 속도 문제 발생할 수 있음 (N + 1) e.g. 주문 개수가 10개 - 주문 쿼리 1, 상품 쿼리 10 DAO에서 조인을 이용해 1번의 쿼리로 데이터 로딩하자 (e.g. OrderViewDao, JPQL) 만약 애그리거트마다 다른 저장소를 사용한다면, 캐시 적용 혹은 조회 전용 저장소 이용 코드는 복잡해져도 시스템 처리량 상승 객체 참조는 구현은 편리하지만 단점이 많음 다른 애그리거트의 상태를 쉽게 변경할 수 있다는 단점 지연로딩/즉시로딩 고민 필요 확장 시에도 하위 도메인마다 기술이 달라질 수 있는데 JPA에 종속되어버림 애그리거트를 팩토리로 사용하기 한 애그리거트의 상태를 보고 다른 애그리거트를 생성해야 한다면 애그리거트에 팩토리 메서드 추가 e.g. Store의 상태를 보고 Product를 생성한다면, Store에 createProduct() 추가 정보가 많다면 Store - createProduct() - ProductFactory.create()도 가능 도메인의 응집도가 높아짐 리포지터리 (Repository) 애그리거트 단위로 도메인 객체를 조회하고 저장하는 기능 정의 (도메인 모델의 영속성 처리) 구현을 위한 도메인 모델 (엔터티와 밸류는 요구사항에서 도출되는 도메인 모델) 애그리거트는 개념적으로 하나이므로 저장할 때도 전체를 저장하고, 불러올 때도 전체를 불러와야 함 원자적 변경 구현: RDBMS는 트랜잭션 사용, 몽고 DB는 한 애그리거트를 한 문서에 저장 응용 서비스 계층이 사용 주체 리포지터리 인터페이스는 도메인 영역, 구현 클래스는 인프라스트럭처 영역에 속함 기본 정의 메서드 (애그리거트 저장 및 조회) save(Some some) findById(SomeId id) 필요에 따라 다양한 조건의 검색이나 delete(id)나 count() 추가 리포지터리와 DAO 리포지터리와 DAO는 데이터를 DB로 부터 가져온다. 둘은 목적이 같지만 의미에서 차이가 있다. CQRS에서 명령 모델에서 사용할 때는 리포지터리라고 지칭하고, 조회 모델에서 사용할 때는 DAO라고 지칭한다. 스프링 데이터 JPA Specification DAO 구현 시 다양한 조건 검색에는 Specification을 사용이 도움이 된다. 하이버네이트 @Subselect 쿼리 결과를 @Entity로 매핑할 수 있는 기능으로, 마치 뷰를 사용하는 것 처럼 쿼리 실행 결과를 매핑할 테이블처럼 사용할 수 있다. @Immutable, @Synchronize를 함께 사용하자. 도메인 서비스 (Domain Service) 한 애그리거트만으로는 구현이 불가능한 특정 엔터티에 속하지 않는 도메인 로직을 처리 여러 애그리거트가 필요한 기능을 억지로 한 애그리거트에 넣으면 안된다 코드가 복잡하고 외부 의존이 높아져 수정이 어려움 애그리거트의 범위를 넘어서는 도메인 개념이 애그리거트에 숨어들어 명시적으로 드러나지 않게 됨 계산 로직 & 외부 시스템 연동이 필요한 도메인 로직에 사용 계산 로직 (e.g. 실제 결제 금액 계산) 총 주문 금액은 주문 애그리거트에서 가능 But, 할인 금액 계산은 상품, 쿠폰, 회원 등급, 구매 금액 등이 필요 나아가 2개의 할인 쿠폰 적용은 단일 할인 쿠폰 애그리거트로 처리 불가 public class DiscountCalculationService {...} public Money calculateDiscountAmounts(List<OrderLines>, List<Coupon> coupons, MemberGrade grade) 외부 시스템 연동 도메인 로직 (e.g. 설문 조사 시스템이 외부 역할 관리 시스템과 연동해야 할 때) 설문 조사 생성 권한이 있는지 확인하는 것은 도메인 규칙 외부 연동 보다는 도메인 로직 관점에서 인터페이스 작성 - 응용 서비스에서 사용 public interface SurveyPermissionChecker boolean hasUserCreationPermission(String userId) 인터페이스는 도메인 영역, 구현 클래스는 인프라스트럭처 영역에 위치 구현 및 사용 방법 상태 없이 로직만 구현 (엔터티, 밸류 등과의 차이) 사용 주체는 애그리거트 혹은 응용 서비스 둘 다 가능 e.g. 결제 금액 계산: Order - calculateAmounts(DiscountCalculationService disCalSvc, MemberGrade grade) e.g. 계좌 이체: TransferService (도메인 서비스) - transfer(Account fromAcc, Account toAcc, Money amounts) 도메인 서비스는 도메인 영역에 위치 e.g. 실제 계산 금액 도메인 서비스는 주문 애그리거트와 같은 패키지에 위치 domain.model, domain.service, domain.repository로 분할해도 괜찮음 도메인 로직이 외부 시스템을 이용해 구현될 때는 인터페이스와 클래스를 분리하자 도메인 서비스 인터페이스 (도메인 영역) - 도메인 서비스 구현 클래스 (인프라스트럭처 영역) 애그리거트와 트랜잭션 트랜잭션 구현 전략 1개의 트랜잭션에서는 1개의 애그리거트만 수정하자 트랜잭션 범위는 작을수록 좋다 (1개 테이블 한 행 잠금이 3개 테이블 잠금보다 처리량 높음) 2개 이상의 애그리거트 수정 -> 트랜잭션 충돌 가능성 상승 -> 처리량 감소 부득이하게 1개 트랜잭션으로 2개 이상의 애그리거트 수정이 필요할 경우 응용 서비스에서 수정 다음 상황에서만 허용 기술적으로 도메인 이벤트를 사용할 수 없거나 팀 표준인 경우 UI 구현의 편리 - 운영자의 편의를 위해 여러 주문의 상태를 한 번에 변경하고 싶을 때 도메인 이벤트 사용 -> 1 트랜잭션 1 애그리거트 수정한 후 다른 애그리거트 수정 가능 (동기, 비동기) 애그리거트를 위한 추가적인 트랜잭션 처리 기법 (잠금) 애그리거트 간 동시성 문제 제어를 위해 필요 e.g. 운영자와 고객이 동시에 논리적으로 같은 애그리거트에 접근하지만 물리적으로 다른 애그리거트 객체를 사용하게 되어 동시성 문제 발생 종류 선점 잠금 (Pessimistic Lock) 한 스레드의 애그리거트 사용이 끝날 때까지 다른 스레드의 해당 애그리거트 수정을 막음 e.g. 운영자가 배송지 정보를 조회하고 상태를 변경하는 동안, 고객의 애그리거트 수정을 막는다 구현: for update 쿼리 (DBMS 지원 행단위 잠금/특정 레코드에 한 커넥션만 접근 가능) JPA (하이버네이트) EntityManger의 find() 메서드에 LockModeType.PESSIMISTIC_WRITE 인자 전달 e.g. entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE) 스프링 데이터 JPA @Lock(LockModeType.PESSIMISTIC_WRITE) 지정 주의점: 교착 상태 예방을 위해 최대 대기 시간 지정 필요 (DBMS마다 지원 여부 다름) JPA Map<String, Object> hints = new HashMap<>(); hints.put("javax.persistence.lock.timeout", 2000); Order order = entityManager.find( Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints ); 스프링 데이터 JPA @QueryHints({ @QueryHint(name = "javax.persistence.lock.timeout", value = "2000") }) 비선점 잠금 (Optimistic Lock) 버전 값을 사용해서 변경 가능 여부를 실제 DBMS 변경 반영 시점에 확인하는 방법 e.g. 운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면, 운영자가 애그리거트를 다시 조회한 후 수정하도록 한다 선점 잠금으로 해결할 수 없는 경우를 해결 사용자가 버전 값을 응답 받고 다음 요청에 버전 값을 함께 보내는 방식 덕분에 여러 트랜잭션이나 시간에 걸쳐 락 확장 가능 구현: 쿼리에서 애그리거트의 버전이 동일한 경우에만 수정하고 성공하면 버전값도 올리기 쿼리 UPDATE aggtable SET version = version + 1, colx = ?, coly = ? WHERE aggid = ? and version = 현재버전 JPA @Version private long version (필드에 애너테이션 적용) 트랜잭션 충돌 시 OptimisticLockingFailureException 발생 주의점: 강제 버전 증가 잠금 모드 필요 (for 애그리거트 일관성 유지) 애그리거트에서 일부 구성요소의 값만 바뀌어도 루트 엔터티 버전 값을 증가시키자 JPA EntityManger의 find() 메서드에 LockModeType.OPTIMISTIC_FORCE_INCREMENT 인자 전달 엔터티 상태 변경 여부와 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리 스프링 데이터 JPA @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) 지정 오프라인 선점 잠금 (Offline Pessimistic Lock) 선점 잠금과 달리 여러 트랜잭션에 걸쳐 동시 변경을 막는 방식 e.g. 누군가 수정 화면을 보고 있을 때, 수정 화면 자체를 실행하지 못하게 막음 구현: 락 직접 구현 애플리케이션 단 락 구현 (LockManger) DB 단 락 구현 (테이블) 주의점 락을 얻은 사용자가 영원히 반납하지 않는 경우를 고려해 잠금 유효 시간 필요 락을 얻은 사용자는 일정 주기로 유효 시간을 증가시켜야 UX 불편 없이 수정 가능 수정 폼에서 1분 단위로 Ajax 호출해 1분씩 유효 시간 증가시키기 이벤트 (Event) 문제점: 바운디드 컨텍스트 간에는 강결합이 발생한다 e.g. 주문 B.C와 결제 B.C 간의 강결합 (환불 기능의 강결합) 환불 기능 구현 2가지 방법 Order 엔터티의 cancel()에 RefundService 도메인 서비스를 파라미터 넘겨 실행 응용 서비스에서 Order 엔터티의 cancel()과 RefundService의 refund() 실행 환불은 외부 결제 API에 의존, 도메인 서비스가 필요 외부 의존으로 생기는 문제점 트랜잭션 처리 문제 외부결제시스템이 비정상이라 예외가 발생하면, 트랜잭션을 롤백할지 커밋할지 애매 환불 실패시 롤백하는 것이 맞아보이지만, 반드시 롤백해야 하는 것은 아님 주문은 취소 상태로 변경하고 환불만 나중에 다시 시도하는 방식도 가능 성능 문제 외부 결제 시스템의 응답 시간이 길어지면 대기 시간도 길어져 성능 문제 발생 설계상 문제점: 도메인 객체에 도메인 서비스를 전달할 때 도메인 로직이 뒤섞임 (강결합) 변경의 이유가 증가 주문 도메인에 결제 도메인의 환불 관련 로직이 뒤섞임 주문 도메인 객체의 코드가 결제 도메인 때문에 변경될 수 있음 기능 추가 시 로직이 더욱 섞임 주문취소 및 환불에 통지 기능을 추가하면, NotiService도 파라미터로 넘김 로직이 더욱 섞여 트랜잭션 처리가 복잡해지고 외부 서비스가 2개로 증가 이벤트 (해결책) 과거에 벌어진 어떤 것 => 상태 변경 e.g. 암호를 변경했음, 주문을 취소했음 “~할 때”, “~가 발생하면”, “만약 ~하면” 같은 요구사항 -> 이벤트 구현 가능 도메인의 상태 변경과 관련되는 경우가 많음 e.g. 주문을 취소할 때, 이메일을 보낸다. (앞: 이벤트, 뒤: 후속 처리) 효과 도메인 로직이 섞이지 않아 바운디드 컨텍스트 간 강결합을 크게 해소 (변경 및 기능 확장 용이) e.g. 주문 도메인의 결제 도메인 의존을 제거 e.g. 구매 취소 시 이메일 보내기 기능을 추가하더라도 구매 취소 로직은 수정할 필요 X 용도 트리거 도메인의 상태가 바뀔 때 후처리 실행 e.g. 주문 취소 이벤트 -> 환불 처리, 예매 완료 이벤트 -> SMS 발송 시스템 간의 데이터 동기화 e.g. 배송지 변경 이벤트 -> 핸들러는 외부 배송 서비스와 배송지 정보 동기화 관련 구성요소 (도메인 모델에 이벤트 도입 시) 이벤트 발생한 이벤트에 대한 정보를 담음 클래스 이름으로 이벤트 종류 표현 이벤트 발생 시간 이벤트와 관련된 추가 데이터 (e.g. 주문 번호, 신규 배송지 정보) 구현 일반 클래스 사용 (과거시제로 명명 e.g. ShippingInfoChangedEvent) 공통 프로퍼티가 있다면 상위 이벤트 클래스를 만들어도 괜찮음 (e.g. 발생 시간) 이벤트 처리에 필요한 최소한의 정보만 포함 이벤트 생성 주체 도메인 객체(엔터티, 밸류, 도메인 서비스)가 주체 도메인 로직 실행으로 상태가 변경되면 관련 이벤트를 발생시킴 구현 Events: 이벤트 발행 래퍼 클래스 구현 (ApplicationEventPublisher를 이용) public class Events { private static ApplicationEventPublisher publisher; static void setPublisher(ApplicationEventPublisher publisher) { Events.publisher = publisher; } public static void raise(Object event) { if (publisher != null) { publisher.publishEvent(event); } } } Events.raise(...): 이벤트를 디스패처에 전달 설정 컴포넌트에서 파라미터 전달하고 빈 등록 이벤트 디스패처 (퍼블리셔) 이벤트 생성 주체와 이벤트 핸들러를 연결 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 전달 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파 이벤트 생성과 처리는 디스패처 구현 방식에 따라 동기 혹은 비동기로 실행됨 구현 ApplicationEventPublisher 이용 (스프링 제공) 이벤트 핸들러 (구독자) 발생한 이벤트에 반응 이벤트를 전달받고 담긴 데이터를 이용해서 원하는 기능을 실행 구현 @EventListener(이벤트.class): 이벤트 수신 이벤트 발생 시 해당 애너테이션이 붙은 메서드를 모두 찾아 실행 이벤트 처리 방식 동기 이벤트 처리 응용 서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러 실행 (순차적) 도메인 상태 변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행 문제점: 외부 서비스 영향 문제 이벤트를 사용해 강결합 문제는 해결했지만, 외부 서비스 영향 문제는 여전히 남아 있음 = 트랜잭션 처리 문제 및 성능 문제 2가지 해결책: 비동기 이벤트 처리 or 이벤트와 트랜잭션 연계 e.g. 외부 환불 시스템이 실패했다고 반드시 트랜잭션을 롤백해야 할까? 구매 취소는 처리하고 환불만 재처리 혹은 수동 처리하는 것도 가능한 방법! 즉, 주문 취소 후 수십초 내에 결제 취소가 이루어지면 됨! 마찬가지로, 회원 가입 신청 후 몇 초 뒤에 이메일이 도착해도 문제 없음! 실패 시 사용자는 이메일 재전송 요청을 이용해 수동으로 인증 메일 다시 받음 비동기 이벤트 처리 요구사항: ‘A 하면 이어서 B 하라’ -> ‘A하면 최대 언제까지 B하라‘인 경우가 많음 즉, 대부분 일정 시간 안에만 후속 조치 처리하면 되는 경우가 많음 B를 실패하면 일정 간격으로 재시도하거나 수동 처리해도 상관 없음 ‘A하면 최대 언제까지 B하라‘로 바꿀 수 있는 요구사항은 비동기 이벤트 처리로 구현 가능 방법: A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러 실행 구현 방법 로컬 핸들러를 비동기로 실행하기 main 함수가 있는 클래스에 @EnableAsync 적용해 비동기 기능 활성화 이벤트 핸들러 메서드에 @Async 적용 (=이벤트 핸들러를 별도 스레드로 비동기 실행) 메시지 큐 사용하기 (Kafka or RabbitMQ) 보통 이벤트 생성 주체와 이벤트 핸들러는 별도 프로세스에서 동작 이벤트 발생 JVM과 이벤트 처리 JVM이 다름 동일 JVM에서 비동기를 위해 메시지 큐를 사용할 수 있지만 시스템만 복잡해짐 과정 (별도의 스레드나 프로세스로 처리) 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐로 보냄 메시지 큐는 이벤트를 메시지 리스너에 전달 메시지 리스너는 알맞은 이벤트 핸들러를 이용해 이벤트 처리 글로벌 트랜잭션 (Optional) 필요시 도메인 기능과 메시지 큐 이벤트 저장 절차를 한 트랜잭션으로 묶을 수 있음 e.g. 같은 트랜잭션 범위: 도메인 상태변화 DB 반영 & 이벤트 메시지 큐 저장 장점: 이벤트를 안전하게 메시지 큐에 전달 가능 단점: 전체 성능 감소, 글로벌 트랜잭션을 지원하지 않는 메시지 시스템 존재 RabbitMQ: 글로벌 트랜잭션 지원 O, 안정적 메시지 전달 (클러스터 및 고가용성 지원) Kafka: 글로벌 트랜잭션 지원 X, 높은 성능 이벤트 저장소(Event Store)를 이용한 비동기 처리 (DB) 이벤트를 DB에 저장한 뒤 별도 프로그램을 이용해 이벤트 핸들러에 전달 동일한 DB에서 도메인의 상태와 이벤트를 저장 도메인 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리됨 (한 트랜잭션) 트랜잭션에 성공하면 이벤트가 저장소에 보관되는 것을 보장 장점 물리적 저장소에 이벤트를 저장하므로, 이벤트 핸들러가 처리에 실패해도 다시 저장소에서 읽어와 실행 가능 구현 이벤트 저장소와 이벤트 포워더 사용하기 포워더가 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러 실행 포워더는 별도의 스레드 이용 @Scheduled 로 주기적으로 이벤트를 읽고 전달 이벤트를 어디까지 처리했는지 이벤트 포워더가 추적해야 함 DB 테이블이나 로컬 파일에 마지막 offset 값 보관 이벤트 저장소와 이벤트 제공 API 사용하기 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져감 어디까지 처리했는지 이벤트 목록을 요구하는 외부 핸들러가 기억해야 함 lastOffset: 마지막에 처리한 데이터의 offset을 기억 이벤트 적용 추가 고려사항 이벤트 발생 주체를 EventEntry에 추가할지 여부 e.g “Order가 발생시킨 이벤트만 조회하기” 기능이 필요하다면 이벤트 발생 주체 정보를 추가 포워더에서 전송 실패를 얼마나 허용할 것인지 특정 이벤트에서 계속 전송에 실패하면, 해당 이벤트 때문에 나머지 이벤트를 전송할 수 없게됨 실패한 이벤트의 재전송 횟수 제한을 두자 e.g. 동일 이벤트 전송 3회 실패 시, 해당 이벤트를 생략하고 다음 이벤트로 넘어가는 정책 처리 실패 이벤트를 별도 실패용 DB나 메시지 큐에 저장하는 것도 좋음 물리적 저장소에 남겨두면 실패 이유 분석이나 후처리에 도움이 됨 이벤트 손실 이벤트 저장소 사용 방식은 이벤트 저장을 보장 (도메인 로직과 한 트랜잭션에서 실행됨) 반면, 로컬 핸들러 방식은 이벤트 처리에 실패하면 이벤트를 유실하게 됨 이벤트 순서 이벤트 저장소: 이벤트 발생 순서대로 처리 가능 메시징 시스템: 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수 있음 (동일한) 이벤트 재처리 마지막으로 처리한 이벤트의 순번을 기억하고 이미 처리한 순번의 이벤트가 도착하면 무시하기 e.g. 회원 가입 신청 이벤트가 처음 도착하면 이메일을 발송 동일 순번 이벤트가 다시 들어오면 이메일 발송 X 이벤트를 멱등으로 처리 동일 이벤트를 한 번 적용하나 여러 번 적용하나 시스템이 같은 상태가 되도록 핸들러 구현 시스템 장애로 동일 이벤트 중복 발생이나 처리가 발생해도 부담이 감소 e.g. 배송지 정보 변경 이벤트를 받아서 주소를 변경하는 핸들러 DB 트랜잭션 실패와 이벤트 처리 실패 동기든 비동기든 트랜잭션 실패와 이벤트 처리는 함께 고려해야 함 다만, 경우의 수가 복잡하다 e.g. 주문 취소 상태 변경과 결제 외부 API 환불 처리 실패 시나리오 예시 동기: 결제 취소 API 호출이 완료됐는데, DB 트랜잭션에 실패해 취소 상태가 안됨 비동기: 주문은 취소 상태로 DB 반영 됐는데, 결제는 취소되지 않음 경우의 수 줄이기: 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하도록 처리 트랜잭션 실패 경우의 수가 줄어 이벤트 처리 실패만 고민하면 됨 이벤트 핸들러를 실행했는데, 트랜잭션이 롤백되는 상황은 없어짐! 방법 @TransactionalEventListener 적용 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 해줌 @TransactionalEventListener(classes = OrderCanceledEvent.class, phase = TransactionPhase.AFTER_COMMIT) (TransactionPhase.AFTER_COMMIT) 스프링은 트랜잭션 커밋에 성공한 뒤, 핸들러 메서드를 실행 중간에 예외 발생으로 롤백 시, 핸들러 메서드 실행 X 이벤트 저장소로 DB를 사용하는 것도 동일한 효과 발생 트랜잭션 성공할 때만 이벤트가 DB에 저장되므로 CQRS (Command Query Responsibility Segregation) 명령을 위한 모델과 조회를 위한 모델을 분리하는 패턴 명령(Command): 상태를 변경 주로 한 애그리거트의 상태를 변경 e.g. 새 주문 생성, 배송지 정보 변경, 회원 암호 변경 ORM이 적합: 객체 지향으로 도메인 모델을 구현하기가 편리 조회(Query): 상태를 제공 2개 이상의 애그리거트가 필요할 때가 많음 e.g. 주문 상세 내역 보기, 게시글 목록 보기, 회원 정보 보기, 판매 통계 보기 ORM이 부적합: 애그리거트들이 분리되어 있어, 성능 고려 필요 응용 서비스 계층 없이 컨트롤러에서 바로 DAO 실행해도 무방 장점 명령 모델 구현 시 도메인 자체에 집중 가능 (조회 성능 위한 코드 분리) 복잡한 도메인에 적합 단일 모델로 처리하면, 조회 기능의 로딩 속도를 위해 도메인 모델이 매우 복잡해짐 조회 성능 향상에 유리 (자유롭게 성능 향상 시도 가능) 각 모델에 맞는 구현 기술 선택 가능 e.g.1 명령 모델은 JPA, 조회 모델은 MyBatis… e.g.2 명령 모델은 트랜잭션 지원 RDBMS, 조회 모델은 조회 성능이 좋은 메모리 기반 NoSQL 대규모 웹 서비스는 조회 성능 개선을 위해 암묵적으로 이미 CQRS를 적용 웹 서비스는 상태 변경 요청보다 조회 요청이 월등히 많음 쿼리 최적화, 캐싱, 조회 전용 저장소 사용 등의 기법을 적용하게 됨 다만, 명시적으로도 CQRS 적용하는 것이 좋음 단점 구현 코드가 더 많아짐 도메인이 복잡하거나 대규모 트래픽이 발생하는 서비스 -> 유지보수에 유리 도메인이 단순하거나 트래픽이 많지 않은 서비스 -> 비용 상승, 이점이 적음 더 많은 구현 기술 필요 다른 구현 기술, 다른 저장소, 데이터 동기화를 위한 메시징 시스템 도입 등 중요: 명령 모델과 조회 모델 간 데이터 동기화는 이벤트를 활용해 처리하자 명령 모델에서 상태 변경 후 이벤트 발생 -> 조회 모델에 이벤트를 전달해 변경 내역 반영 DB 저장소가 다른 경우, 동기화 시점에 따라 구현이 달라짐 실시간 동기화: 동기 이벤트 or 글로벌 트랜잭션 사용 (성능은 떨어지는 단점) 특정 시간 안에만 동기화: 비동기로 데이터 전송 Reference 도메인 주도 개발 시작하기 NHN FORWARD 22 - DDD 뭣이 중헌디? 🧐 Domain Driven Design – 1부 (Strategic Design) DDD 이야기 part1
Software Engineering
· 2024-12-16
만들면서 배우는 클린 아키텍처
주요 도메인 중심 아키텍처 용어 기원 아키텍처 선택 가이드: 도메인 코드가 애플리케이션에서 가장 중요하면 사용하자 종류 클린 아키텍처 - 로버트 마틴 (Robert C. Martin) 도메인 중심 아키텍처들에 적용되는 원칙을 제시 (추상적) 도메인 중심의 아키텍처들은 DDD의 조력자 육각형 아키텍처 (Hexagonal Architecture) - 알리스테어 콕번 (Alistair Cockburn) (구체적) 도메인 주도 설계 (DDD, Domain Driven Design)- 에릭 에반스 (Eric Evans) 전통적인 계층형 아키텍처의 문제 일반적인 3계층 아키텍처 올바르게 계층을 구축하고 추가 아키텍처 강제 규칙 적용하면 쉽게 기능 추가 및 유지보수 가능 웹계층이나 영속성 계층에 독립적으로 도메인 로직 작성 가능 아래 문제점만 극복할 수 있다면, 좋은 코드 유지 가능 (강제가 없어 어려울 뿐) 문제점: 장기적으로 나쁜 방향의 코드를 쉽게 허용 의존성 방향으로 인해 데이터베이스 주도 설계를 유도 비즈니스 관점에서 도메인 로직을 먼저 만들어야하지만, 영속성 계층을 먼저 구현하게 됨 도메인 계층이 영속성 계층의 ORM 엔터티를 사용해 강한 결합 발생 강제가 적어 지름길을 택하기 쉬워짐 전통 계층형 아키텍처의 유일한 규칙: 같은 계층 혹은 아래 계층에만 의존 가능 필요한 상위 컴포넌트를 계속 아래로 내리기 쉬움 (e.g. 영속성 계층에 몰리는 헬퍼, 유틸리티) 아키텍처 규칙 강제 필요성 (빌드 실패 수준으로 관리) 테스트가 어려워짐 계층 건너뛰기(웹 계층 -> 영속성 계층) 시, 종종 웹 계층으로 도메인 로직 책임이 전파 웹 계층 테스트에서 영속성 계층까지 모킹해야 해서, 테스트 복잡도 상승 유스케이스를 숨김 도메인 로직이 여러 계층에 흩어짐 -> 새로운 기능을 추가할 위치 찾기가 어려움 (수직적 측면) 여러 개 유스케이스 담당하는 넓은 서비스 허용 -> 작업할 서비스 찾기 어려움 (수평적 측면) 동시 작업 지원 아키텍처는 아님 (병합 충돌) 영속성 -> 도메인 -> 웹 순으로 개발하므로, 특정 기능은 동시에 1명의 개발자만 작업 가능 현재 넓은 서비스라면, 다른 유스케이스 작업도 같은 서비스에서 동시에 하게 됨 클린 아키텍처의 핵심 토대 단일 책임 원칙(SRP) 일반적 정의: 하나의 컴포넌트는 한 가지 일만 해야 한다. 실질적 정의: 컴포넌트를 변경하는 이유는 오직 하나뿐이어야 한다. (책임 = 변경할 이유) 컴포넌트를 변경할 이유가 한 가지라면, 다른 곳 수정 시 해당 컴포넌트를 신경쓸 필요가 없음 의존성 역전 원칙(DIP) 정의: 코드상의 어떤 의존성이든 그 방향을 바꿀 수 있다. (서드파티 라이브러리 제외) 상위 계층의 변경할 이유를 줄임 e.g. 영속성 계층에 대한 도메인 계층의 의존성 클린 아키텍처 (Clean Architecture) 핵심 의존성 규칙: 계층 간의 모든 의존성이 도메인 코드로 향해야 한다. 의존성 역전 -> 영속성과 UI 문제로부터 도메인 로직의 결합 제거 -> 변경 이유의 수 감소 -> 유지보수성 향상 외부의 영향을 받지 않고 도메인 코드를 자유롭게 발전시킬 수 있음 구조 애플리케이션 코어 도메인 엔터티: 비즈니스 규칙에 집중 유스케이스: 도메인 엔터티에 접근 가능 비즈니스 규칙 지원 컴포넌트 (컨트롤러, 게이트웨이, 프레젠터 등) 바깥쪽 계층: 다른 서드파티 컴포넌트에 어댑터 제공 대가 엔터티에 대한 모델을 각 계층에서 따로 유지보수해야 함 (통신할 때는 매핑 작업 필요) 하지만, 결합이 제거되어 바람직한 상태 e.g. ORM 엔터티는 기본생성자를 강제하지만 도메인 모델에는 필요없음 유의사항 의존성 역전은 실제로 유스케이스와 영속성 어댑터 간 적용 (의존성 방향이 코어로 향하도록) 육각형 아키텍처 (Hexagonal Architecture) 클린 아키텍처 원칙들에 부합하는 구체적 아키텍처 중 하나 포트와 어댑터(ports-and-adapters) 아키텍처로도 불림 핵심 클린 아키텍처의 의존성 규칙 그대로 적용 (모든 의존성은 코어로 향한다.) 의존성 역전 -> 영속성과 UI 문제로부터 도메인 로직의 결합 제거 -> 변경 이유의 수 감소 -> 유지보수성 향상 외부의 영향을 받지 않고 도메인 코드를 자유롭게 발전시킬 수 있음 구조 애플리케이션 코어 도메인 엔터티 유스케이스: 도메인 엔터티와 상호작용 어댑터 애플리케이션과 다른 시스템 간의 번역을 담당 e.g. 웹 어댑터, 영속성 어댑터, 외부 시스템 어댑터 분류 주도하는 어댑터(driving adapter) = 인커밍 어댑터 = 왼쪽 어댑터 애플리케이션 코어를 호출 (in) 주도되는 어댑터(driven adapter) = 아웃고잉 어댑터 = 오른쪽 어댑터 애플리케이션 코어가 호출 (out) 포트 애플리케이션 코어와 어댑터들 간의 통신을 위한 인터페이스 분류 입력 포트(in) = 인커밍 포트 주도하는 어댑터가 호출하는 인터페이스 구현체: 코어 내 유스케이스 클래스 출력 포트(out) = 아웃고잉 포트 애플리케이션 코어가 호출하는 인터페이스 구현체: 어댑터 클래스 계층 분류 어댑터 계층: 어댑터 애플리케이션 계층: 포트 + 유스케이스 구체 클래스(Service) 도메인 계층: 도메인 엔터티 유의사항 의존성 역전은 실제로 유스케이스와 주도되는 어댑터 간에 적용됨 -> 의존성 방향이 코어로 향하도록 주도하는 어댑터는 원래 의존성 방향이 코어로 향함 -> 인터페이스는 단순 진입점 구분 역할 표현력 있는 패키지 구조 표현력 있는 패키지 구조는 각 요소들을 패키지 하나씩에 직접 매핑 아키텍처-코드 갭을 완화시킴 아키텍처에 대한 적극적인 사고를 촉진 -> 의사소통, 개발, 유지보수 모두 조금 더 수월해짐 분류 엔터티: domain - Account, Activity 유스케이스: application - SendMoneyService 인커밍 포트: application - port - SendMoneyUseCase 아웃고잉 포트: application - port - LoadAccountPort, UpdateAccountStatePort 인커밍 어댑터: adapter - in - web - AccountController 아웃고잉 어댑터: adapter - out - persistence - AcountPersistenceAdapter 고려사항 접근 제한자로 계층 사이 불필요한 의존성 예방 가능 (e.g. 도메인의 영속성 계층 의존) port만 public 두기 나머지는 모두 package-private DDD 개념과 직접적 대응 가능 상위 레벨 패키지를 바운디드 컨텍스트로 활용 가능 (e.g. account) 책임을 좁히는 유스케이스명을 사용하자 (로버트 마틴, 소리치는 아키텍처) AccountService 보다 SendMoneyService 인터페이스명이 좋음 (송금하기 유스케이스) 모든 계층에 의존성을 가진 중립적인 컴포넌트를 도입해 의존성 주입하자 아키텍처를 구성하는 대부분의 클래스를 초기화해 인스턴스 주입 e.g. AccountController, SendMoneyService, AccountPersistenceAdapter 유스케이스 구현하기 입력 유효성 검증 & 비즈니스 규칙 검증 두 검증 모두 비즈니스 규칙으로 다루는게 옳을 수도 있으나 구분하면 유지보수성이 향상됨 구분 방법 도메인 모델의 현재 상태에 접근하는가? O -> 비즈니스 규칙 검증 e.g. 출금 계좌는 초과 출금되어서는 안된다. X -> 입력 유효성 검증 e.g. 송금되는 금액은 0보다 커야 한다. 전략 유스케이스 -> 비즈니스 규칙 검증 책임 O, 입력 유효성 검증 책임 X -> 도메인 엔터티 내에 두기 애플리케이션 계층 -> 입력 유효성 검증 책임 O -> 입력 모델에서 다루자! 유효하지 않은 입력값이 코어로 향해 모델의 상태를 해치는 것을 막아야 함 유스케이스 구현체 주위에 잘못된 입력에 대한 보호막 형성 (소위 오류 방지 계층) e.g. SendMoneyCommand 생성자에서 처리 (final 필드 사용하면 생성 후 변경 막음) 규칙 예: 모든 파라미터는 null이 아니어야함 규칙 예: 송금액은 0보다 커야함 Bean Validation API 사용도 좋음 없는 것만 직접 구현 (requireGreaterThan()) 비즈니스 규칙을 도메인 엔터티 내에서 다루기 애매할 때는 유스케이스 코드에서 진행 검증 실패 시 유효성 검증 전용 예외 던지기 유스케이스마다 각각 입력 모델, 출력 모델 두기 유스케이스가 보다 명확해짐 다른 유스케이스와의 결합 방지 -> 유지보수성 향상 읽기는 전용 쿼리 서비스 두기 읽기 작업은 유스케이스가 아니고 간단한 데이터 쿼리 따라서, 쿼리를 위한 전용 인커밍 포트를 만들고 쿼리 서비스(query service)에 구현하기 (CQS) e.g. GetAccountBalanceQuery 인터페이스 (인커밍 포트) GetAccountBalanceService 구현체, getAccountBalance() 메서드 풍부한 도메인 모델 VS 빈약한 도메인 모델 도메인 로직이 도메인 엔터티에 많다면 DDD 철학을 따르는 풍부한 도메인 모델이 된다. 반면에, 유스케이스에 도메인 로직이 몰리면 빈약한 도메인 모델이 된다. 물론, 스타일에 따라 선택이 가능한 부분이다. 웹 어댑터 구현하기 인커밍 포트의 필요성 (feat. 의존성 방향이 올바름에도 사용하는 이유) 계층 진입점의 명세가 되어 유지보수 정보가 됨 웹 소켓 시나리오의 경우 반드시 포트가 필요 인커밍 포트이자 아웃고잉 포트가 되므로 유스케이스 입력 모델과 다른 구조와 의미를 가지는 유효성 검증 필요 웹 어댑터의 입력 모델이 유스케이스의 입력 모델로 변환할 수 있다는 것을 검증 컨트롤러 나누기 컨트롤러는 가능한 좁고 너무 많은게 낫다! (기본적으로 클래스 코드는 적을수록 좋음!) 각 연산에 대해 별도의 패키지 안에 별도의 컨트롤러를 만드는 방식이 좋음 e.g. AccountController로 모두 묶는 것 보다 SendMoneyController가 나음 메서드와 클래스 명은 유스케이스를 최대한 반영해서 짓기 e.g. CreateAccount 보다 RegisterAccount가 더 나을 수 있음 각 컨트롤러가 별도의 입출력 모델을 가지는게 좋음! e.g. CreateAccountResource, UpdateAccountResource 컨트롤러 나누기는 동식 작업 시 병합 충돌 예방에도 도움이 됨 영속성 어댑터 구현하기 입출력 모델은 애플리케이션 코어에 위치한 도메인 엔터티나 DTO (포트에 지정) 입출력 모델이 애플리케이션 코어에 위치 -> 어댑터 내부 변경이 코어에 영향을 주지 않음 다만, 어댑터에서 매핑 작업이 필요 입력 모델 -> JPA 엔터티 JPA 엔터티 -> 출력 모델 좁은 포트 인터페이스 지향하기 일반적인 방법은 엔터티를 기준으로 모든 연산을 하나의 리포지토리에 모아두는 것 기본은 도메인 클래스 혹은 DDD 애그리거트 당 하나의 영속성 어댑터 구현하기 추가적으로, JPA 어댑터 & SQL 어댑터 구성도 가능 (성능 개선 위해) 그렇다면, 인터페이스 분리 원칙(ISP)에 따라 좁은 포트 인터페이스 지향하자! 항상은 아니더라도 가능한 각 서비스가 자신에게 필요한 메서드만 알면 되도록 하자 장점: 코드 가독성 향상, 테스트 편리 e.g. AccountRepository -> LoadAccountPort, UpdateAccountStatePort, CreateAccountPort 테스트 성공의 기준 테스트는 얼마나 마음 편하게 소프트웨어를 배포할 수 있는냐를 성공 기준으로 삼으면 된다. 초기 몇 번의 프로덕션 배포 시 버그가 나온다면, 해당 케이스를 추가해서 개선하면 된다. 시간이 걸릴지라도 결국은 유지보수를 위한 옳은 길이 될 것이다. 경계 간 매핑하기 매핑에 대한 찬성과 반대 찬성 두 계층 간에 매핑이 없으면 서로 같은 모델을 사용하게 되어, 두 계층이 강하게 결합된다. 반대 매핑이 있으면 모델이 각 계층에 있어 보일러플레이트 코드가 많아진다. e.g. 간단한 CRUD 유스케이스 적절한 사용 원칙 유스케이스마다 적절한 전략을 선택하거나 섞어 써야 한다! (한 전략을 철칙으로 여겨선 안됨!) 좁은 포트를 사용하면 유스케이스마다 다른 매핑 전략 사용 가능 어려운 방법이지만, 정확히 해야하는 일만 수행하면서도 유지보수하기 쉬운 코드가 될 것 간단한 전략으로 시작해서 복잡한 전략으로 갈아타는 것도 좋은 방법 많은 유스케이스들은 간단한 CRUD에서 점차 풍부한 비즈니스 유스케이스로 변화 어떤 매핑 전략을 선택했더라도 나중에 언제든 바꿀 수 있음! 팀 내 합의할 수 있는 가이드라인을 정해둬야 함 가이드 라인 예시 변경 유스케이스 웹 계층 & 애플리케이션 계층 사이 1순위 전략: 완전 매핑 유효성 검증 규칙이 명확해짐 애플리케이션 계층 & 영속성 계층 사이 1순위 전략: 매핑하지 않기 매핑 오버헤드 줄이고 빠르게 코드 짜기 위해 2순위 전략: 양방향 매핑 애플리케이션 계층에서 영속성 문제를 다루면 결합 제거가 우선 쿼리 작업 웹 계층 & 애플리케이션 계층 사이 + 애플리케이션 계층 & 영속성 계층 사이 1순위 전략: 매핑하지 않기 매핑 오버헤드 줄이고 빠르게 코드 짜기 위해 2순위 전략: 양방향 매핑 애플리케이션 계층에서 웹 문제, 영속성 문제를 다루면 결합 제거가 우선 4가지 전략 매핑하지 않기 (No Mapping) 모든 계층이 도메인 모델을 입출력 모델로 사용 장점 간단한 CRUD 유스케이스에는 유용 (모든 계층이 정확히 같은 구조와 정보 띄는 상황) 단점: 단일 책임 원칙 위반 도메인과 애플리케이션 계층이 웹이나 영속성 관련 특수 요구사항에 오염됨 웹이나 영속성 관련 이유로 변경될 가능성 생김 애플리케이션 및 도메인 계층이 웹과 영속성 문제를 다루면 바로 다른 전략을 취해야 함 양방향 매핑 (Two-Way) 각 어댑터가 전용 모델을 사용하고 포트 전달 전에 계층 내에서 매핑 실행 웹 계층에서는 웹 모델 -> 도메인 모델, 도메인 모델 -> 웹 모델 매핑 진행 영속성 계층도 마찬가지 장점: 단일 책임 원칙 준수 한 계층의 전용 모델을 변경하더라도 다른 계층에는 영향이 없음 (깨끗한 도메인 모델) 단점 보일러플레이트 코드가 많아짐 아주 간단한 CRUD 유스케이스에선 개발을 더디게 함 인커밍 포트와 아웃커밍 포트에서 도메인 모델이 계층 경계를 넘어 통신에 사용됨 도메인 모델이 바깥쪽 계층의 요구에 따른 변경에 취약해짐 완전 매핑 (Full) 각 계층이 전용 모델을 가짐 입력 모델은 command, request 등 네이밍 사용) 웹 계층은 입력을 커맨드 객체로 매핑할 책임을 가짐 애플리케이션 계층은 커맨드 객체를 도메인 모델로 매핑할 책임을 가짐 전역 패턴으로는 비추천 웹 계층(혹은 인커밍 어댑터)과 애플리케이션 계층 사이를 추천 애플리케이션 계층과 영속성 계층 사이에서는 매핑 오버헤드로 비추천 장점: 유지보수하기가 훨씬 쉬움 단점: 보일러플레이트 코드가 많아짐 단방향 매핑 (One-Way) 모든 계층의 모델들이 같은 인터페이스를 구현 (몇몇 getter 메서드를 제공하는 인터페이스) 도메인 모델은 풍부한 행동을 구현 도메인 객체는 매핑 없이 바깥 계층으로 전달 가능 바깥 계층에서는 상태 인터페이스를 이용할지, 전용 모델로 매핑할지 결정 DDD의 팩터리(factory) 개념과 어울림 장점: 계층 간 모델이 비슷할 때 효과적 읽기 전용 연산은 전용 모델 매핑 없이 상태 인터페이스만으로 충분 단점: 매핑이 계층을 넘나들며 퍼져 있어, 개념적으로 어려움 애플리케이션 조립하기 설정 컴포넌트 코드 의존성이 올바른 방향을 가리키게 하기 위해서 필요 책임 모든 클래스에 대한 의존성을 가지고 객체 인스턴스를 생성할 책임을 가짐 런타임에 애플리케이션 조립에 대한 책임을 가짐 의존성 주입 메커니즘으로 런타임에 필요한 곳에 객체 주입 설정 파일, 커맨드라인 파라미터 등에도 접근 장점 단일 책임 원칙을 위반하지만, 덕분에 애플리케이션의 나머지 부분을 깔끔하게 유지 가능 테스트가 쉬워짐 @Component를 포함한 커스텀 애노테이션 컴포넌트 스캔 사용 시, @PersistenceAdapter, @WebAdapter 등의 커스텀 애노테이션을 만들어 적용하면 아키텍처 구조를 더욱 쉽게 파악할 수 있다. 아키텍처 경계 강제하기 ‘경계를 강제한다’ 의존성이 올바른 방향을 향하도록 강제하는 것 시간이 지나면서 아키텍처가 서서히 무너지는 것을 방지 -> 유지보수하기 좋은 코드 아래 3가지 경계 강제 방법을 조합해 사용 방법 1: 접근 제한자 전략 도메인 엔터티 및 포트만 public으로 열고, 나머지 모두 package-private으로 진행 컴포넌트 스캐닝만 가능 (빈 수동 등록은 public 열지 않으면 불가) package-private 자바 패키지를 통해 클래스들을 모듈로 만들어주므로 중요 모듈의 진입점으로 활용할 클래스만 골라서 public으로 만들면 됨 의존성 규칙 위반 위험이 감소 단점 모듈 내 클래스가 많아지면 하위 패키지를 만들어야 함 -> 계층 내 의존이 불가해져 public 열어야 함 -> 아키텍처 의존성 규칙이 깨질 환경 조성됨 방법 2: 컴파일 후 체크 (post-compile check) 컴파일러로는 경계 강제 불가할 경우, 런타임에 체크 시도 ArchUnit 사용하기 의존성 방향이 예상대로 설정됐는지 체크할 수 있는 API 계층 간 의존성 확인 테스트를 추가해 확인 단점 타입 세이프하지 않음 (오타나 패키지명 리팩터링에 취약) 항상 코드와 함께 유지보수해야 함 방법 3: 빌드 아티팩트를 분리하기 각 모듈 혹은 계층에 대해 각각 빌드 모듈(JAR 파일)을 만들어 계층 간 의존성 강제 가능 빌드 스크립트에 아키텍처에서 허용하는 의존성만 지정 장점 순환 의존성 방지 보증 다른 모듈을 고려하지 않고 특정 모듈 코드만 격리한 채로 변경 가능 새로운 의존성 추가 시 항상 의식적으로 행동하게 됨 의식적으로 지름길 사용하기 깨진 창문 이론 인간의 뇌는 망가져 있는 것을 보면 더 망가뜨려도 된다고 생각한다. 지름길을 거의 사용하지 않고 깨끗하게 프로젝트를 시작하고 유지하는 것이 중요 지름길을 취하는 것이 가끔 실용적일 때가 있음 프로젝트 전체에서 중요하지 않은 부분, 프로토타이핑, 경제적 이유… 의도적인 지름길은 세심하게 기록해두자! -> 팀원이 이를 인지하면 깨진 창문 이론이 덜할 것 유스케이스가 단순한 CRUD 상태에서 벗어나는 시점을 잘 파악해 리팩토링하자! 단순한 CRUD 상태에서 더이상 벗어나지 않는 유스케이스는 그대로 유지하는게 더 경제적 지름길 발생 예시 유스케이스 간 입출력 모델 공유 (지양) 특정 요구사항을 공유할 때만 괜찮음 -> 다만 둘이 독립적이라면 분리하는게 맞음 도메인 엔터티를 입출력 모델로 사용하기 (지양) 인커밍 포트 건너뛰기 (지양) 인커밍 포트는 진입점 식별에 중요 애플리케이션 서비스 건너뛰기 (지양) 간단한 CRUD에서는 고려해볼 수 있음 아웃고잉 어댑터 클래스가 애플리케이션 서비스의 인커밍 포트 구현 도메인 모델을 입력 모델로 사용 도메인 로직이 생기면 바로 애플리케이션 서비스 계층 사용할 것 Reference 만들면서 배우는 클린 아키텍처
Software Engineering
· 2024-12-03
단위 테스트 (Unit Testing) - 가치 있는 테스트 작성하기
단위 테스트 스타일과 함수형 아키텍처 단위 테스트 스타일 종류 출력 기반 테스트 (output-based testing, 함수형) SUT에 입력을 넣고 출력을 점검하는 방식 사이드 이펙트 X, 반환 값만 검증 e.g. decimal discount = sut.CalculateDiscount(product1, product2) Assert.Equal(0.02m, discount) 상태 기반 테스트 (state-based testing) 작업이 완료된 후 시스템 상태를 확인하는 방식 상태: SUT, 협력자, 프로세스 외부 의존성의 상태 (DB, 파일 시스템) e.g. sut.AddProduct(product) Assert.Equal(1, sut.Products.Count) 통신 기반 테스트 (communication based testing) 목을 사용해 SUT와 협력자 간의 통신을 검증 e.g emailGatewayMock.Verify(x => x.SendGreetingsEmail(), Times.Once) 단위 테스트 4대 요소 통한 비교 결론: 항상 출력 기반 테스트를 선호하자 객체 지향에 적용이 어렵지만, 테스트를 출력기반 스타일로 변경하는 기법 사용하면 됨 리팩토링 내성 출력 기반 테스트가 가장 우수 테스트가 테스트 대상 메서드에만 결합해 거짓 양성 방지 탁월 상태 기반 테스트, 통신 기반 테스트는 취약 테스트가 구현 세부 사항에 결합될 가능성 높음 유지 보수성 출력 기반 테스트가 가장 우수 거의 항상 짧고 간결하므로 유지보수가 쉬움 전역 상태나 내부 상태를 변경할리 없으므로, 프로세스 외부 의존성 X 상태 기반 테스트, 통신 기반 테스트는 취약 상태 기반 테스트, 통신 기반 테스트는 검증부가 커짐 보완 1: 검증부 헬퍼 메서드 -> 메서드 유지와 재사용에 대한 명분이 드물 것 보완 2: 검증 대상 클래스에 동등 멤버 정의 -> 코드 오염 (Code Pollution) 회귀 방지와 빠른 피드백은 단위 테스트 스타일과 관련이 적음 함수형 프로그래밍 사이드 이펙트가 없는 순수 함수 코드를 강조하는 프로그래밍 방식 숨은 입출력이 없어야 함 사이드 이펙트(인스턴스 상태 변경, 파일 I/O), 예외, 내외부 상태 참조(비공개 속성, DB 조회) 지향점 비즈니스 로직 코드와 사이드 이펙트 발생 코드를 분리 어떤 사이드 이펙트도 일으키지 않는 애플리케이션은 불가능 함수형 아키텍처 사이트 이펙트 코드를 최소화하고 순수 함수 방식 코드를 극대화하는 방식 구성 함수형 코어 (functional core, immutable core) 결정을 내리는 코드 수학적 함수로 작성 가변 셸 (mutable shell) 해당 결정에 따라 작용하는 코드 (실행) 수학적 함수에 의해 이뤄진 모든 결정을 가시적으로 변환 (DB 변경, 메시지 버스 전송) 협력 과정 가변 셸이 모든 입력 수집 -> 함수형 코어는 결정을 생성 -> 가변 셸은 결정을 사이드 이펙트로 변환 헥사고날 아키텍처과의 공통점 및 차이점 헥사고날 아키택처 ⊃ 함수형 아키텍처 (극단적으로 함수형 아키텍처 = 헥사고날 아키텍처) 공통점 관심사 분리 측면 도메인 : 애플리케이션 서비스 = 함수형 코어 : 가변 셸 단방향 의존성 흐름 차이점 사이드 이펙트 처리 헥사고날 아키텍처는 도메인 계층 내라면 사이드 이펙트 허용 함수형 아키텍처는 모든 사이드 이펙트를 함수형 코어 밖 가장자리로 밀어냄 단점 적용 불가 상황 존재 의사 결정 절차 중간에 프로세스 외부 의존성을 조회 시, 출력 기반 테스트 적용 불가 e.g. DB에 있는 방문자의 접근 레벨을 중간에 조회해야할 때 public FileUpdate AddRecord(..., IDatabase database) {...} 도메인은 절대로 DB에 의존해서는 안됨 해결책 애플리케이션 서비스 전면부에서 방문자 접근 레벨도 수집 접근 레벨이 필요 없어도 DB 조회하므로 성능 저하 그럼에도 사이드 이펙트 분리 유지 가능한 장점 AuditManager에 IsAccessLevelCheckRequired() 메서드 두기 애플리케이션 서비스에서 AddRecord() 전에 호출 true 반환 시 AddRecord()에 접근 레벨 전달 분리를 다소 완화하고 성능 향상 (필요할 때만 DB 조회) 성능 감소 함수형 아키텍처를 지키다보면 시스템이 외부 의존성을 더 많이 호출 e.g. 초기 버전과 목 버전과 달리 최종 버전은 디렉토리에서 모든 파일을 읽음 결론: 성능이 영향이 미미하다면 유지보수성을 택하는게 나음 코드베이스 크기 증가 함수형 아키텍처는 코드 복잡도가 낮아지고 유지보수성이 향상되지만 초기 코딩이 증가 복잡도가 낮은 간단한 프로젝트는 초기 투자가 타당 X 단위 테스트 스타일 선택 전략 최대한 출력 기반 테스트 지향 함수형 코어는 출력 기반 테스트로, 가변 셸은 훨씬 더 적은 수의 통합 테스트로 다루기 함수형 프로그래밍을 활용해 기반 코드가 함수형 아키텍처 지향하도록 재구성 출력 기반 스타일 변환 사이드 이펙트를 비즈니스 연산 끝으로 몰아서 비즈니스 로직을 사이드 이펙트와 분리 e.g. 파일 I/O가 섞인 코드 (AuditManger) 초기 도메인 객체 AuditManger는 파일 I/O 코드를 품고 있음 테스트도 파일 I/O로 검증 (단위 테스트 X, 통합 테스트 O) 방법 1: 파일 I/O를 목으로 대체해 주입하기 (목 사용 테스트) 방법 2: I/O 담당 클래스로 따로 만들어 외부로 빼기 (출력 기반 테스트) = 사이드 이펙트 외부 추출 AuditManager (함수형 코어) - Persister (가변 셸) AuditManager는 new FileUpdate() 식으로 업데이트 명령 반환 추가할만한 사항 삭제 유스케이스가 있다면 FileAction & ActionType Enum 처리 오류처리 필요시 예외 클래스를 만들어 반환 간헐적으로 상태 기반 테스트, 통신 기반 테스트 사용 객체 지향은 모든 테스트를 출력 기반 전환 불가 e.g. User 클래스의 email, type 속성 변경 상태 기반 테스트지만 사이드 이펙트가 메모리에 남아 있어 테스트 용이성 향상 최대한 출력 기반 테스트로 전환하되 비용에 따라 상태, 통신 기반 테스트를 적절히 섞자 스타일과 단위 테스트 분파 두 분파는 출력 기반 테스트를 사용 고전파는 상태 기반 테스트 선호, 런던파는 통신 기반 테스트 선호 코드 오염 (Code Pollution) 단위 테스트를 가능하게 하거나 단순화하기 위한 목적만으로 제품 코드베이스를 오염시키는 것을 말한다. 가치 있는 테스트를 위한 리팩토링 제품 코드의 4가지 유형 분류 기준 코드 복잡도: 코드 내 의사 결정 분기 수 도메인 유의성: 코드가 프로젝트 문제 도메인에 얼마나 의미가 있는지 협력자 수: 클래스나 메서드 내에 가변 의존성 또는 프로세스 외부 의존성 수 유형 도메인 모델 및 알고리즘 (중요) 복잡한 코드와 도메인 유의성을 갖는 코드가 단위테스트에서 가장 이로움 협력자가 없어 유지비가 낮고 회귀 방지 탁월 참고: 복잡도와 도메인 유의성은 서로 독립적 (도메인 코드가 안복잡할 수 있음) 간단한 코드 (테스트 필요 X) 컨트롤러 도메인 클래스나 외부 애플리케이션 같은 다른 구성 요소의 작업 조정 협력자가 많은 코드는 테스트 비용이 많이 듦 (유지 보수성 감소) 통합 테스트로 간단히 테스트 지나치게 복잡한 코드 알고리즘과 컨트롤러로 나누어 리팩토링하자 이상적으로 여기 속하는 코드는 없어야 함 e.g. 여러 책임을 가지고 있는 덩치 큰 컨트롤러 분리 불가능한 경우도 존재하지만 분리를 지향하면 지나치게 복잡한 코드는 아닐 것!! 컨트롤러에 비즈니스 로직이 있을 수도 있음 도메인 클래스에 협력자가 하나, 둘, 심지어 셋 있을 수도 있음 그래도 프로세스 외부 의존성 및 목 사용은 지양 지나치게 복잡한 코드 분할하기 험블 객체 패턴 (Humble Object) 험블 객체(험블 래퍼)를 두고 이곳에서 중요 로직과 테스트가 어려운 의존성을 붙이는 패턴 프레임워크 의존성과 결합되어 있는 코드는 테스트가 어려움 e.g. 비동기, 멀티스레딩, 사용자 인터페이스, 프로세스 외부 의존성 통신 방법 테스트 가능한 로직을 따로 추출 험블 객체를 통해 테스트 로직과 테스트 어려운 의존성을 각각 호출 험블 객체는 오케스트레이션을 할 뿐 자체적인 로직이 없으므로 테스트할 필요 X 예시 헥사고날 아키텍처, 함수형 아키텍처와 완전히 일치! 로직: 도메인 계층, 함수형 코어 테스트하기 어려운 의존성: 애플리케이션 서비스 계층, 가변 셸 단일 책임 원칙(SRP) 관점과도 일치 비즈니스 로직과 오케스트레이션 분리 MVC(Model-View-Controller), MVP(Model-View-Presenter) 패턴 컨트롤러와 프레젠터는 험블 객체로서 모델과 뷰를 붙임 DDD의 집계 패턴 (Aggregate Pattern) 클래스를 클러스터로 묶으면 코드 베이스의 총 통신 수가 줄고 테스트 용이성 향상 지나치게 복잡한 코드 리팩토링 단계 1단계: 암시적 의존성을 명시적 의존성으로 만들기 도메인 객체 내 프로세스 외부 의존성은 인터페이스를 두어 주입 (목 방식) e.g. 데이터베이스, 메시지 버스 통합 테스트에도 중요 그러나 도메인 모델은 프로세스 외부 의존성에 의존하지 않는 것이 깔끔 2단계: 애플리케이션 서비스 계층 도입 험블 컨트롤러로 오케스트레이션 책임을 위임 도메인 모델이 외부 시스템과 직접 통신하는 문제 극복 도메인 모델은 잘 분리되었지만 컨트롤러는 아직 복잡한 상태 3단계: 애플리케이션 서비스 복잡도 낮추기 객체 매핑 작업 추출하기 ORM 사용 원시 데이터베이스 사용 시 데이터 매핑을 위한 팩토리 클래스 작성 (in 도메인 모델) 방법 별도의 클래스 (권장) 간단한 경우, 기존 도메인 클래스의 정적 메서드 애플리케이션 서비스에서 조정 object[] userData = _database.GetUserById(userId); User user = UserFactory.create(userData); 테스트해볼 만함 언어 혹은 프레임워크 내 숨은 분기 존재 데이터 요소 접근이나 타입 캐스팅 예외 등 오케스트레이션 처리 절충하기 비즈니스 로직과 오케스트레이션 분리는 다음 패턴에서 가장 효율적 외부 읽기 - 비즈니스 로직 실행 - 외부 쓰기 중간 결과를 바탕으로 프로세스 외부 의존성을 추가로 조회해야할 경우 존재 외부 읽기 - 비즈니스 로직 실행 - 외부 읽기 - 비즈니스 로직 실행 - 외부 쓰기 대처 방법 모든 대처 방법은 위 3가지 특성 중 2가지만 가질 수 있으므로 선택 필요 도메인 모델 테스트 유의성: 도메인 클래스 내 협력자 수와 유형 영향 컨트롤러 단순성: 분기 수 영향 성능: 프로세스 외부 의존성 호출 수 종류 의사 결정 프로세스 단계를 더 세분화하기 지나치게 복잡한 컨트롤러를 만들지만 완화 방법 사용으로 절충 CanExecute/Execute 패턴 사용 도메인 클래스 내 CanExecute() 메서드에 두기 모든 유효성 검사 진행 메서드 Execute() 및컨트롤러 둘 모두에서 호출! 비즈니스 로직이 컨트롤러로 유출되는 것을 방지 (캡슐화) 도메인 계층의 모든 결정 통합 e.g. User에 CanChangeEmail() 메서드 두기 모든 유효성 검사를 CanChangeEmail()에 두기 ChangeEmail()은 CanChangeEmail() 호출 컨트롤러도 CanChangeEmail() 호출 외부 통신 여부 결정, 성공하면 외부 통신 CanExecute/Execute 패턴 적용 불가능한 경우도 존재 파편화 로직을 컨트롤러에 넣고 통합테스트로 처리해야 함 e.g. 이메일 고유성 검증 프로세스 외부 의존성에 따른 도메인 로직 의사 결정 모든 외부 읽기 쓰기를 가장자리로 밀어내기 대부분 프로젝트에서 성능은 매우 중요하므로 고려 X 도메인 모델에 프로세스 외부 의존성 주입(내부에서 외부 읽기쓰기 결정) 비즈니스 로직과 외부 통신이 결합되므로 테스트와 유지보수 어렵 도메인 이벤트를 사용해 도메인 모델 변경 사항 추적하기 도메인 이벤트는 도메인 모델의 중요 변경 사항을 추적하고 외부에 알리는데 사용됨 e.g. 메시지 버스에 메시지를 보내서 외부에 변경 알리기 비즈니스 로직 파편화 예방 의사 결정 책임을 도메인 모델에 유지 컨트롤러로 도메인 로직이 유출되는 것을 방지 e.g. 이메일 변경이 안되었다면 이벤트만으로 이메일 메시지 전송 안할 수 있음 DB는 이메일 변경이 안되어도 매번 저장해도 됨 식별할 수 있는 동작 X, 상대적으로 성능 차이 미미 ORM 사용 시 상태 변경 없으면 DB I/O가 없어 더욱 용이 이메일 메시지 전송은 식별할 수 있는 동작이어서 조정 필요 도메인 이벤트 구현 도메인 이벤트 클래스 public class EmailChangedEvent { public int UserId { get; } public string NewEmail { get; } } 외부 시스템에 통보하는데 필요한 데이터가 포함된 클래스 e.g. 사용자 ID, Email 과거 시제 명명 (이미 일어난 일들을 나타내므로) 값 객체 (불변) DomainEvent를 공통 클래스로 뽑아도 좋음 도메인 클래스 이벤트 컬렉션 보유 e.g. List<DomainEvent> 컨트롤러 끝에서 외부로 이벤트 발행하거나 이벤트 디스패처 사용 가능 e.g. User 도메인 클래스 ChangeEmail() 메서드 EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail)); 컨트롤러 끝단에서 도메인 이벤트 처리 foreach (var ev in user.EmailChangedEvents) { _messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail); } 4단계: 책임 명확히 위임하기 잘못 둔 책임은 새로운 클래스에 두어 리팩토링 e.g. Company 클래스 - ChangeNumberOfEmployees(), IsEmailCorporate() 5단계: 테스트 적용 외부 클라이언트 입장에서 식별할 수 있는 동작을 파악해 계층적으로 테스트하자! 고객(클라이언트) 입장에서 컨트롤러의 ChangeEmail() 및 메시지 버스 호출 컨트롤러(클라이언트) 입장에서 User의 ChangeEmail() User(클라이언트) 입장에서 Company의 ChangeNumberOfEmployees(), IsEmailCorporate() 즉, 외부 계층의 관점에서 각 계층을 테스트하고, 기저 계층과의 통신(구현)은 무시 단위 테스트 User의 ChangeEmail() 테스트 Changing_email_from_non_corporate_to_corporate() Assert.Equal(2, company.NumberOfEmployees) Assert.Equal("new@mycop.com, sut.Email) Assert.Equal(UserType.Employee, sut.Type) Changing_email_from_corporate_to_non_corporate() sut.Email.Should().Be("new@gmail.com"); sut.Type.Should().Be(UserType.Customer); sut.EmailChangedEvents.Should().Equal(new EmailChangedEvent(1, "new@gmail.com")); 도메인 이벤트 검증 Changing_email_without_changing_user_type() Changing_email_to_the_same_one() Company 테스트 도메인 유의성이 있는 모든 전제 조건은 테스트 O ChangeNumberOfEmployees() -> 전제조건: 직원수는 음수 X 도메인 유의성이 없는 전제 조건은 테스트 X UserFactory의 Create() -> 전제조건: data.Length >= 3 User와 Company 생성자 테스트 -> 필요 X 통합 테스트 UserController의 ChangeEmail() 테스트 액티브 레코드 패턴 (Active Record pattern) 도메인 클래스가 스스로 데이터베이스를 검색하고 저장하는 방식을 말한다. 단순하고 단기적인 프로젝트에는 잘 작동하지만, 코드베이스가 커지면 확장하기 어렵다. CanExecute/Execute 패턴 예시 도메인 클래스 내 유효성 검사를 담당하는 CanExcute()는 Execute()와 컨트롤러에서 모두 호출한다. User 도메인 클래스 public string CanChangeEmail() { if (IsEmailConfirmed) return "Can't change a confirmed email"; return null; } public void ChangeEmail(string newEmail, Company company) { Precondition.Requires(CanChangeEmail() == null); ... } 컨트롤러 public string ChangeEmail(int userId, string newEmail) { object[] userData = _database.GetUserById(userId); User user = UserFactory.Create(userData); string error = user.CanChangeEmail(); if (error != null) return error; object[] companyData = _database.GetCompany(); Company company = CompanyFactory.Create(companyData); ... } 통합 테스트 통합 테스트: 단위 테스트가 아닌 모든 테스트 단위 테스트의 3가지 요구 사항을 하나라도 충족하지 않으면 통합테스트 단일 동작 단위를 검증 빠르게 수행 다른 테스트와 별도로 처리 통합 테스트는 시스템이 전체적으로 잘 작동하는지 확신하기 위해 사용 각 부분이 외부 시스템(DB, 메시지 버스)과 어떻게 통합되는지 확인 필요 모든 테스트는 도메인 모델과 컨트롤러에만 초점을 맞춰야 한다! 단위 테스트로 가능한 한 많이 비즈니스 시나리오의 예외 상황 확인 통합 테스트는 주요 흐름(happy path)과 단위 테스트가 못 다루는 기타 예외 상황(edge case) 확인 비즈니스 시나리오 당 1~2개 -> 시스템 전체의 정확도 보장 통합 테스트 전략 가장 긴 주요 흐름(happy path)을 선택해 프로세스 외부 의존성과의 상호작용을 모두 확인 1개 테스트로 어렵다면 외부 통신을 모두 확인할 수 있도록 통합 테스트 추가 작성 컨트롤러에서 빠른 실패 원칙에 해당하는 예외는 통합 테스트로 다루지 말기 e.g. CanChangeEmail()는 통합 테스트 가치가 적음 애플리케이션 초반부에서 버그를 내어 데이터 손상으로 이어지지 않음 오히려 단위 테스트에서 확인하기 좋음 관리 의존성은 실제 인스턴스 사용하고, 비관리 의존성은 목으로 대체하자 프로세스 외부 의존성 유형 관리 의존성 애플리케이션을 통해서만 접근할 수 있는 의존성 ex. DB 구현 세부사항 (하위 호환 고려 X) 비관리 의존성 외부에서도 접근할 수 있는 의존성 ex. SMTP, 메시지버스 식별할 수 있는 동작 (하위 호환 유지 필요) 특이 케이스) 관리 의존성이면서 비관리 의존성인 경우 e.g. 다른 애플리케이션에서 접근할 수 있는 DB (특정 테이블 접근 권한 열어둠) 일시적 대응: 공유된 테이블을 비관리 의존성 취급하자 사실상 메시지 버스, 목 대체 필요 다만, 시스템 간 결합도와 복잡도가 증가하므로 지양 API, 메시지 버스 통신이 더 나음 실제 데이터베이스를 사용할 수 없는 경우, 통합 테스트 작성하지 말고 도메인 모델 단위 테스트에 집중 보안 혹은 비용 문제로 실제 DB를 사용할 수 없는 경우 존재 관리 의존성을 목으로 대체하면 회귀 방지에서 단위 테스트와 차이 X (리팩터링 내성도 저하) 엔드 투 엔드 테스트는 대부분의 경우 생략 가능 통합 테스트 보호 수준이 엔드 투 엔드와 비슷함 (관리 의존성 포함 및 비관리 의존성 목 대체) 배포 후 1~2개 정도의 중요한 엔드 투 엔드 테스트 작성 가능 엔드 투 엔드 테스트는 프로세스 외부 의존성을 모두 실제 인스턴스 사용해야 해 느림 검증: 메시지 버스를 직접 확인, 데이터베이스 상태는 애플리케이션을 통해 간접 확인 테스트 예시 (CRM 프로젝트) 가장 긴 주요 흐름 (Changing_email_from_corporate_to_non_corporate()) 기업 이메일에서 일반 이메일로 변경하는 것 사이드 이펙트 가장 많음 (DB update, 메시지버스) 단위 테스트로 어려운 예외 상황 (이메일을 변경할 수 없는 시나리오) 테스트 필요 X, 빠른 실패 케이스 로깅(Logging) 기능 테스트? //User 도메인 클래스 public void ChangeEmail(string newEmail, Company company) { _logger.Info( $"Changing email for user {UserId} to {newEmail}"); //진단 로깅 (지양) Precondition.Requires(CanChangeEmail() == null); if (Email == newEmail) return; UserType newType = company.IsEmailCorporate(newEmail) ? UserType.Employee : UserType.Customer; if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; company.ChangeNumberOfEmployees(delta); AddDomainEvent( new UserTypeChangedEvent( UserId, Type, newType)); //DomainLogger 대신 도메인 이벤트 사용 } Email = newEmail; Type = newType; AddDomainEvent(new EmailChangedEvent(UserId, newEmail)); _logger.Info($"Email is changed for user {UserId}"); //진단 로깅 (지양) } 로깅은 횡단 관심사 (코드베이스 어느 부분에서나 필요함) 로깅은 프로세스 외부 의존성에 사이드 이펙트를 초래 (텍스트 파일, DB) 사이드 이펙트를 개발자 이외 사람(API 클라이언트, 고객)이 보는 경우 -> 반드시 테스트! 지원 로깅 (support logging) 식별할 수 있는 동작 e.g. 비즈니스 요구사항이므로 명시적으로 래퍼 클래스 만들기 (IDomainLogger, DomainLogger) public class DomainLogger : IDomainLogger { private readonly ILogger _logger; public DomainLogger(ILogger logger) { _logger = logger; } public void UserTypeHasChanged( int userId, UserType oldType, UserType newType) { _logger.Info( $"User {userId} changed type " + $"from {oldType} to {newType}"); } } 사이드 이펙트를 개발자만 보는 경우 -> 테스트 X 진단 로깅 (diagnostic logging) 구현 세부 사항 e.g. 로그 라이브러리 그대로 사용 (ILogger) 과도한 사용 지양 도메인 모델에서 절대 사용하지 말자 컨트롤러에서 무언가를 디버깅해야 할 때만 일시적으로 사용하고 제거하자 실제 테스트 지원 로깅은 도메인 클래스에서 필요할 때 도메인 이벤트 사용해 분리 프로세스 외부 의존성(로그 저장소)이 있으므로 컨트롤러에서 필요할 때 로그 라이브러리 그대로 사용 프로세스 외부 의존성을 조정하는 곳이므로 단위 테스트는 User에서 UserTypeChangedEvent 확인 통합 테스트는 목을 사용해 DomainLogger와의 상호 작용 확인 목 사용 가치 최대화 모범 전략 비관리 의존성만 목으로 대체하기 비관리 의존성 검증에 필요한 정확도에 따라 어느 지점을 목으로 대체할지 결정해야 함 메시지 버스는 발행되는 메시지 구조가 중요하므로 통신하는 시스템 끝 마지막 타입 검증이 유리 지원 로깅은 로그의 구조가 중요하진 않으므로 IDomainLogger만 목으로 처리해도 충분 e.g. 메시지 버스 예시 IMessageBus(도메인 관련 메시지 정의 래퍼 클래스) & IBus(메시지 버스 SDK 래퍼) public interface IMessageBus { void SendEmailChangedMessage(int userId, string newEmail); } public class MessageBus : IMessageBus { private readonly IBus _bus; public void SendEmailChangedMessage( int userId, string newEmail) { _bus.Send("Type: USER EMAIL CHANGED; " + $"Id: {userId}; " + $"NewEmail: {newEmail}"); } } public interface IBus { void Send(string message); } IBus를 목으로 처리하면 회귀방지, 리팩터링 내성 극대화 가능 IBus가 비관리 의존성과 통신하는 마지막 타입 구현 세부 사항이 아닌 실제 사이드 이펙트 검증 가능 테스트 - 목 버전 [Fact] public void Changing_email_from_corporate_to_non_corporate() { var busMock = new Mock<IBus>(); // IBus를 목으로 대체 var messageBus = new MessageBus(busMock.Object);//구체클래스 var loggerMock = new Mock<IDomainLogger>(); var sut = new UserController(db, messageBus, loggerMock.Object); /* ... */ busMock.Verify( x => x.Send( "Type: USER EMAIL CHANGED; " + $"Id: {user.UserId}; " + $"NewEmail: new@gmail.com"), Times.Once); } IMessageBus 인터페이스를 삭제하고 MessageBus로 대체 가능 목 대체 목적이 사라진 IMessageBus는 구현이 하나뿐인 인터페이스 테스트 - 스파이 버전 [Fact] public void Changing_email_from_corporate_to_non_corporate() { var busSpy = new BusSpy(); var messageBus = new MessageBus(busSpy); var loggerMock = new Mock<IDomainLogger>(); var sut = new UserController(db, messageBus, loggerMock.Object); /* ... */ busSpy.ShouldSendNumberOfMessages(1) .WithEmailChangedMessage(user.UserId, "new@gmail.com"); } 시스템 끝에 있는 클래스는 스파이가 목보다 나음 (스파이: 직접 작성한 목) public interface IBus { void Send(string message); } public class BusSpy : IBus { private List<string> _sentMessages = new List<string>(); public void Send(string message) { _sentMessages.Add(message); } public BusSpy ShouldSendNumberOfMessages(int number) { Assert.Equal(number, _sentMessages.Count); return this; } public BusSpy WithEmailChangedMessage(int userId, string newEmail) { string message = "Type: USER EMAIL CHANGED; " + $"Id: {userId}; " + $"NewEmail: {newEmail}"; Assert.Contains( _sentMessages, x => x == message); return this; } } 검증 단계에서 코드를 재사용해 테스트 크기 감소 간결한 영어 문장의 플루언트 인터페이스로 가독성 향상 통합 테스트에서만 목 사용하기 (단위 테스트 X) 항상 목 호출 수 확인하기 비관리 의존성 통신에서 확인해야할 것 예상하는 호출이 있는가? 예상치 못한 호출은 없는가? e.g. Times.Once (정확히 한 번만 전송되는지 확인하기) e.g. messageBusMock.VerifyNoOtherCalls(); (목 라이브러리 지원) 보유 타입만 목으로 처리하기 서드파티 라이브러리 위에 항상 어댑터를 작성하고, 해당 어댑터를 목으로 처리해야 함 손상 방지 계층으로서 서드파티 라이브러리의 복잡성을 추상화하고 필요한 기능만 노출 마찬가지로 비관리 의존성에만 적용 e.g. IBus 데이터베이스 테스트 (관리 의존성) 테스트 전제 조건 형상 관리 시스템에 데이터베이스를 유지하자 데이터베이스 스키마를 일반 코드 취급해 형상 관리 시스템에 저장 (Git) SQL 스크립트 형태 (테이블, 뷰, 인덱스, 저장 프로시저) 참조 데이터의 경우 SQL INSERT 문 형태로 함께 저장 (e.g. UserType 테이블) 모델 데이터베이스 사용은 안티패턴 데이베이스 스키마를 과거 특정 시점으로 되돌릴 수 없음 (추적 불가) 모든 개발자는 로컬에서 별도의 데이터베이스 인스턴스 사용하자 마이그레이션 기반 데이터베이스 배포 지향하자 마이그레이션 방식은 초기에는 구현과 유지보수가 어렵지만 효과적 상태 기반 방식 배포 중에 비교 도구가 스크립트를 자동 생성해 모델 DB에 맞게 운영 DB 업데이트 스크립트는 형상 관리로 저장 마이그레이션 방식 업그레이드 스크립트를 직접 작성해 형상 관리로 저장 SQL 스크립트 혹은 SQL로 변환할 수 있는 DSL 언어 사용 통합 테스트 트랜잭션 관리 대부분의 ORM은 Unit of Work 패턴을 구현 작업 단위 비즈니스 작업에서 하나의 트랜잭션으로 묶이는 데이터 변경 작업의 집합 e.g. JPA 영속성 컨텍스트 통합 테스트에서는 적어도 3개의 작업 단위를 사용하자! (준비, 실행, 검증 구절 당 하나씩) 통합 테스트는 가능한 운영 환경과 비슷해야함 같은 테스트에서 작업 단위 재사용은 운영 환경과 다른 환경을 만들어서 문제 e.g. 동일 테스트 내에서 DB에 바로 업데이트 쿼리를 날림 조회는 ORM의 1차 캐시에서 진행해서 업데이트 반영 X 검증부에서 업데이트가 안되어 테스트가 실패할 수 있음 공유 데이터베이스에서 각 통합 테스트 격리하기 통합 테스트를 순차적으로 실행하기 순차적 테스트가 병렬 테스트보다 실용적 (성능 향상 이점보다 복잡함이 큼) 대부분의 단위 테스트 프레임워크에서 기능 지원 두 가지 테스트군 만들기 (단위 테스트 & 통합 테스트) 통합테스트군은 테스트 병렬처리 비활성화하기 테스트 실행 간에 남은 데이터 제거하기 테스트 시작 시점에 데이터 정리하기 (Unit Testing 책의 권장 전략) 정리 단계를 실수로 건너 뛰지 않고 빠른 동작과 일관성을 제공 모든 통합 테스트에 기초 클래스 두고 삭제 스크립트 작성 public abstract class IntegrationTests { private const string ConnectionString = "..."; protected IntegrationTests() { ClearDatabase(); } private void ClearDatabase() { string query = "DELETE FROM dbo.[User];" + "DELETE FROM dbo.Company;"; using (var connection = new SqlConnection(ConnectionString)) { var command = new SqlCommand(query, connection) { CommandType = CommandType.Text }; connection.Open(); command.ExecuteNonQuery(); } } } 데이터베이스 트랜잭션에 각 테스트를 래핑하고 커밋하지 않기 (애매) 변경 내용이 자동으로 롤백되어 정리 단계 생략 문제를 해결하고 편리 운영 환경과 다른 환경을 만듦 ReadUncommited 격리 레벨이 아닌 이상 트랜잭션 하나에서 준비, 실행, 검증 구절을 진행 -> 1차 캐시로 인한 테스트 변질 발생 가능성 테스트 종료 시점에 데이터 정리하기 빠르지만 정리 단계를 건너뛰기 쉬움 테스트 도중 중단하면 데이터가 DB에 남아 있어 이후 테스트에 영향을 줌 각 테스트 전 데이터베이스 백업 복원하기 (지양) 가장 느림 컨테이너를 사용해도 컨테이터 인스턴스 제거 및 새 컨테이너 생성에 몇 초 걸림 인메모리 데이터베이스 피하기 테스트용 DB로 SQLite 같은 인메모리 DB를 사용할 수 있음 테스트 데이터를 제거할 필요 X 빠름 테스트 실행할 때마다 인스턴스화 가능 공유 의존성 X (단위 테스트화) 일반 DB와 기능적 일관성이 없음 (운영 환경과 테스트 환경 불일치) 거짓 양성, 나아가 거짓 음성 다량 발생 테스트에서도 운영환경과 같은 DBMS를 사용하자 (버전은 달라도 괜찮, 공급업체는 같음) 테스트 구절에서 코드 재사용하기 (통합 테스트 크기 줄이기) 비즈니스와 관련 없는 기술적인 부분을 비공개 메서드 혹은 헬퍼 클래스로 추출 (재사용) 헬퍼 메서드로 작업 단위(트랜잭션 수)가 더욱 늘어날 수 있지만 유지보수성 위해 절충 준비 구절 전략 기본적으로 테스트와 동일한 클래스에 팩토리 메서드 배치 기초 클래스에 두지 말자 모든 테스트에서 실행하는 코드만 둬야 함 e.g. 데이터 정리 코드 반복 있을 시, 헬퍼 클래스 생성 및 배치 오브젝트 마더 패턴 private User CreateUser( string email = "user@mycorp.com", UserType type = UserType.Employee, bool isEmailConfirmed = false) { using (var context = new CrmContext(ConnectionString)) { var user = new User(0, email, type, isEmailConfirmed); var repository = new UserRepository(context); repository.SaveUser(user); context.SaveChanges(); return user; } } 오브젝트 마더 (Object Mother) - 지향 테스트 픽스처(테스트 실행 대상)를 만드는데 도움이 되는 클래스 또는 메서드 준비 구절에서 코드 재사용 용이 테스트 데이터 빌더 패턴 - 지양 User user = new UserBuilder().WithEmail(..).WithType(..).Build(); 플루언트 인터페이스 제공 (약간의 가독성 향상) 마찬가지로 준비 구절에서 코드 재사용 용이 상용구가 너무 많이 필요하므로 불편 실행 구절 private string Execute( Func<UserController, string> func, MessageBus messageBus, IDomainLogger logger) { using (var context = new CrmContext(ConnectionString)) { var controller = new UserController( context, messageBus, logger); return func(controller); } } 컨트롤러 정보를 받아 실행하는 헬퍼 메서드 도입 (실행 구절 줄이기) 프로세스 외부 의존성도 한 번에 전달 검증 구절 public static class UserExtensions { public static User ShouldExist(this User user) { Assert.NotNull(user); return user; } public static User WithEmail(this User user, string email) { Assert.Equal(email, user.Email); return user; } } // Example usage User userFromDb = QueryUser(user.UserId); userFromDb .ShouldExist() .WithEmail("new@gmail.com") .WithType(UserType.Customer); Company companyFromDb = QueryCompany(); companyFromDb .ShouldExist() .WithNumberOfEmployees(0); 헬퍼 메서드 두기 (+플루언트 인터페이스) 읽기 테스트를 해야 하는가? 가장 복잡하거나 중요한 읽기 작업만 테스트하고 나머지는 무시 (할 경우 통합 테스트로 진행) 읽기 버그는 해로운 문제가 없음 성능면에서 일반 SQL 사용하는 것이 좋음! 도메인 모델도 필요 X ORM의 불필요한 추상화 계층 피할 수 있음 쓰기를 철저히 테스트하는 것이 매우 중요 위험성이 높기 때문에 매우 가치 있음 리포지토리 테스트를 해야 하는가? 마찬가지로 직접 테스트하지말고 통합 테스트의 일부로서만 다루기 컨트롤러 사분면에 소속 -> 통합테스트가 필요한데 이점이 적음 유지비가 높음 (외부 통신 존재) 그에 비해 회귀 방지 이점이 적음 (복잡도가 거의 없음) 복잡도가 있는 부분은 별도 알고리즘으로 추출해 테스트 e.g. 객체 매핑 작업 (UserFactory, CompanyFactory) EventDispatcher도 별도로 테스트하지 말자 유지비가 높지만 회귀 방지 이점이 적음 인터페이스의 사용이유 2가지 느슨한 결합 구체 클래스가 2개 이상일 때 추상화를 위해 사용 구체 클래스가 1개일 때 인터페이스 도입은 YAGNI 위배 (You aren’t gonna need it) 목 사용 구체 클래스가 1개일 경우에도 인터페이스를 사용하는 이유 인터페이스가 없으면 테스트 대역을 만들 수 없음 의존성을 목으로 처리할 필요가 있을 때만, 프로세스 외부 의존성에 인터페이스 두자 = 비관리 의존성에만 인터페이스를 쓰자 e.g. private readonly Database _database; private readonly ImessageBus _messageBus; Unit Testing 책 권장 백엔드 시스템 계층 간접 계층은 많은 애플리케이션 문제를 해결하지만, 가능한 간접 계층을 적게 사용하자. 도메인 모델 (도메인 로직) 애플리케이션 서비스 계층 = 컨트롤러 (외부 클라이언트에 대한 진입점 제공 및 오케스트레이션) 인프라 계층 (데이터베이스 저장소, ORM 매핑, SMTP 게이트웨이) 순환 의존성 제거하기 순환 의존성이란 둘 이상의 클래스가 제대로 작동하고자 직간접적으로 서로 의존하는 것을 말한다. 순환 의존성은 코드를 읽을 때 주변 클래스 그래프를 파악해야 하는 부담이 존재하며 테스트를 방해한다. 따라서, 순환 의존성은 최대한 제거하자. 실행 구절이 여러 개인 다단계 테스트 여러 개 실행 구절을 가지는 테스트는 프로세스 외부 의존성을 관리하기 어려운 경우에 발생한다. 따라서, 다단계 테스트는 거의 항상 엔드 투 엔드 테스트 범주에서만 허용된다. (통합테스트도 드묾) 단위 테스트는 절대로 실행 구절이 여러 개 있어서는 안된다. 식별할 수 있는 동작 기준 식별할 수 있는 동작은 다음 2가지 기준 중 하나를 충족해야 한다. 클라이언트의 목표 중 하나에 직접적 연관이 있음 외부에서 접근할 수 있는 프로세스 외부 의존성에서 사이드 이펙트가 발생함 권장 의존성 주입 모든 의존성은 항상 생성자 혹은 메서드를 통해 명시적으로 주입하자. 의존성을 내부로 숨기는 Ambient Context는 안티패턴이다. 이는 의존성이 숨어있어 변경이 어렵고 테스트가 더 어려워진다. 단위 테스트 안티 패턴 안티 패턴 1: 비공개 메서드 단위 테스트 식별할 수 있는 동작으로 간접적으로 비공개 메서드를 테스트해야 함 즉, 최대한 하지 말아야 한다! 식별할 수 있는 동작으로 테스트 해도 비공개 메서드가 매우 복잡해 커버리지가 낮은 경우 해당 비공개 메서드는 죽은 코드이거나 (삭제 필요) 추상화가 누락된 징후 (별도 클래스로 도출 필요) e.g. 복잡한 비공개 메서드 public class Order { private Customer _customer; private List<Product> _products; public string GenerateDescription() { return $"Customer name: {_customer.Name}, " + $"total number of products: {_products.Count}, " + $"total price: {GetPrice()}"; } private decimal GetPrice() { decimal basePrice = /* _products에 기반한 계산 */; decimal discounts = /* _customer에 기반한 계산 */; decimal taxes = /* _products에 기반한 계산 */; return basePrice - discounts + taxes; } } 추상화 적용 코드 public class Order { private Customer _customer; private List<Product> _products; public string GenerateDescription() { var calc = new PriceCalculator(); return $"Customer name: {_customer.Name}, " + $"total number of products: {_products.Count}, " + $"total price: {calc.Calculate(_customer, _products)}"; } } public class PriceCalculator { public decimal Calculate(Customer customer, List<Product> products) { decimal basePrice = /* _products에 기반한 계산 */; decimal discounts = /* _customer에 기반한 계산 */; decimal taxes = /* _products에 기반한 계산 */; return basePrice - discounts + taxes; } } 비공개 메서드 테스트가 타당한 예외도 존재 신용 조회 관리 시스템 (Inquiry 클래스의 비공개 생성자 내 승인 로직) 승인 로직은 중요하므로 단위테스트를 거쳐야 함 -> public 허용 안티 패턴 2: 단위 테스트 목적으로 비공개 상태 노출하기 비공개 상태 -> 식별할 수 없는 동작 비공개 상태를 바꾸는 메서드 테스트 -> 단일 메서드 보다 식별할 수 있는 동작 관점에서 테스트하자 추후에 비즈니스 요구 사항으로 공개 상태로 바뀌면, 그 때는 상태를 검증하면 좋다! 안티 패턴 3: 테스트로 유출된 도메인 지식 public class CalculatorTests { [Theory] [InlineData(1, 3)] [InlineData(11, 33)] [InlineData(100, 500)] public void Adding_two_numbers(int value1, int value2) { int expected = value1 + value2; // 유출 int actual = Calculator.Add(value1, value2); Assert.Equal(expected, actual); } } 테스트가 제품 코드에서 알고리즘 구현을 복사한 상황 (value1 + value2) 복잡한 알고리즘 다루는 테스트에서 주로 발생 구현 세부사항과 결합되는 테스트 (리팩토링 내성 0점) 테스트 작성 시 결과를 하드코딩하자! public class CalculatorTests { [Theory] [InlineData(1, 3, 4)] [InlineData(11, 33, 44)] [InlineData(100, 500, 600)] public void Adding_two_numbers(int value1, int value2, int expected) { int actual = Calculator.Add(value1, value2); Assert.Equal(expected, actual); } } 하드코딩 예상 결과값은 도메인 전문가의 도움을 받아 SUT가 아닌 다른 것으로 미리 계산 레거시 코드 리팩토링 시 레거시 코드로 결과를 생성하고 예상 결과 값으로 사용 가능 안티 패턴 4: 코드 오염 테스트에만 필요한 코드를 제품 코드에 추가하는 것 테스트 코드와 제품 코드가 혼재되면 유지비 증가 e.g. private readonly bool _isTestEnvironment 테스트 코드를 제품 코드 베이스와 반드시 분리하자! (운영용 진짜 구현체 & 테스트용 가짜 구현체) e.g. ILogger 인터페이스 -> Logger (운영용), FakeLogger (테스트용) 이 상황의 인터페이스도 일종의 코드 오염이지만, 오염도가 낮고 다루기 쉬움 안티 패턴 5: 구체 클래스를 목으로 처리하기 인터페이스가 아닌 구체 클래스로 목으로 처리할 경우 단일 책임 원칙 위배되는 시나리오일 가능성 여러 책임이 합쳐진 클래스일 가능성을 의심하고 분리하자! 안티 패턴 6: 앰비언트 컨텍스트로서의 시간 처리 시간을 정적 메서드 혹은 필드로 참조하는 것 -> 테스트가 더 어려움 (공유 의존성 발생) 더 나은 방안: 시간을 명시적 의존성으로 주입하기 컨트롤러에는 시간 관련 인스턴스를 전달 (클래스는 메서드에서 시간 반환) 도메인 클래스에는 시간을 값으로 전달 Reference 단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)
Software Engineering
· 2024-11-22
단위 테스트 (Unit Testing) - 가치 있는 테스트 식별하기
좋은 단위 테스트의 4대 요소 좋은 단위 테스트의 4가지 특성 회귀 방지 (=소프트웨어 버그 방지) 중요 지표: 테스트로 실행되는 코드의 양, 코드 복잡도, 코드의 도메인 유의성 복잡도와 도메인 유의성이 높은 코드에 대한 테스트가 많을수록 회귀 방지가 탁월 리팩터링 내성 테스트 실패없이 애플리케이션 코드 리펙토링 가능한지에 대한 척도 중요지표: 거짓 양성 발생량 (적을수록 좋음) 거짓 양성: 리팩토링 후 기능이 의도대로 작동해도 테스트가 실패하는 상황 (허위 경보) 회귀 발생 시 조기 경고를 제공 X (잘못된 것이므로 개발자가 무시) 리팩토링에 대한 능력과 의지 감소 (테스트 스위트에 대한 신뢰가 부족) 거짓 양성의 원인: SUT의 구현 세부 사항과 결합된 테스트 (분리 필요) 해결책: 테스트에서 구현 세부사항이 아닌 최종 결과를검증하기 결합도를 낮추면 리팩토링 내성 상승 거짓 양성 발생량이 크게 감소 거짓 양성에 대한 올바른 대응은 테스트 스위트의 안정성을 높이는 것 빠른 피드백 중요 지표: 테스트 실행 속도 빠른 테스트는 버그 수정 비용이 대폭 감소 (더 많은 테스트를 자주 실행할 수 있음) 느린 테스트는 버그 수정 비용이 상승 (뒤늦게 버그를 발견, 시간 낭비) 유지 보수성 중요 지표: 유지비 (테스트 이해 난이도, 테스트 실행 난이도) 테스트 이해 난이도: 테스트의 크기를 의미 (코드라인이 적을수록 읽기 쉬움) 테스트 실행 난이도: 테스트가 프로세스 외부 종속성으로 작동하면, 의존성 운영 비용 고려 필요 회귀 방지 & 리팩터링 내성 간 관계 올바른 추론: 올바르게 작동해 테스트가 통과 & 기능이 고장나 테스트가 실패 회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도 극대화를 목표로하는 특성 테스트 정확도 = 신호(발견된 버그 수) / 소음(허위 경보 발생 수) 거짓 양성, 거짓 음성 발생 확률 줄이기 -> 테스트 정확도 상승 회귀 방지가 훌륭한 테스트는 거짓 음성 수를 최소화 리팩터링 내성이 훌륭한 테스트는 거짓 양성 수를 최소화 중대형 프로젝트는 거짓 음성과 거짓 양성에 똑같이 주의를 기울여야 함 프로젝트 초반은 리팩토링이 많지 않아 거짓 양성은 무시할만 함 프로젝트 중후반으로 갈수록 리팩토링이 중요한데, 거짓 양성이 잦으면 문제가 커짐 테스트 전략 테스트의 가치 = 회귀 방지 X 리팩터링 내성 X 빠른 피드백 X 유지 보수성 하나라도 0이면 전체가 0 (모두 1도 불가능) 유지보수성은 다른 특성과 독립적 (엔드 투 엔드 테스트에서만 회귀 방지와 연관됨) 회귀 방지, 리팩토링 내성, 빠른 피드백은 상호 배타적 -> 하나를 희생해야 둘이 최대 가능 회귀 방지 희생 -> 너무 간단한 테스트 리팩토링 내성 희생 -> 구현에 결합된 깨지기 쉬운 테스트 빠른 피드백 희생 -> 엔드 투 엔드 테스트 각 요소에 높은 임계치를 두고 이를 충족하는 테스트만 테스트 스위트에 남기기 소수의 매우 가치 있는 테스트가 프로젝트의 지속적 성장에 효과적 전략적 절충 리팩토링 내성은 최대화 필요 (리팩토링 내성은 대부분 있거나 없거나 둘 중 하나이므로…) 회귀 방지와 빠른 피드백 사이에서 조절하자 테스트 피라미드 관점 전략 테스트 유형 간 비율은 피라미드 형태를 유지할 것 (팀, 프로젝트 마다 비율 차이 O) 모든 테스트 계층은 가능한 거짓 양성 최소화 목표 (리팩토링 내성 최대화) 피라미드 내 테스트 유형에 따라 회귀 방지와 빠른 피드백 사이에서 선택함 엔드 투 엔드 테스트는 매우 중요한 기능에만 적용 빠른 피드백과 유지보수성 결여 -> 숫자가 가장 적은 이유 예외 케이스 복잡도가 거의 없는 기본 CRUD 프로젝트 통합 테스트 수가 단위 테스트 수와 같거나 많고 엔드 투 엔드 테스트가 없음 단위 테스트는 복잡도 없는 환경에서 유용성 감소 통합 테스트는 여전히 시스템 간 통합 동작 확인에 가치 있음 프로세스 외부 의존성 하나만 연결하는 API (e.g. DB) 엔드 투 엔드 테스트를 더 많이 두는 것이 적합 (환경 상 통합 테스트와 구분 불가) 속도가 상당히 빠를 것이고 유지비도 적음 블랙 박스 테스트 & 화이트 박스 테스트 전략 둘을 조합하되 테스트 작성 시 블랙 박스 테스트 선택하자 화이트 박스 테스트는 구현에 결합 -> 리팩토링 내성 포기할 수는 없음! 테스트 분석 시 화이트 박스 테스트 사용! (e.g. 코드 커버리지 도구) 목과 테스트 취약성 테스트 대역(test double) 모든 유형의 비운영용 가짜 의존성 e.g. 더미, 스텁, 스파이, 목, 페이크 사용 의도에 따라 목과 스텁으로 나뉨 (Mock 프레임워크로 똑같이 인스턴스를 생성) 목(mock) - 목, 스파이 외부로 나가는 상호 작용을 모방하고 검사 상태 변경을 위해 의존성을 호출하는 것 (사이드 이펙트 O) e.g. SMTP 서버로 이메일 발송 작업 CQS 관점에서 명령을 대체 (보통 반환값 X) 구현 목: 목 프레임워크의 도움 받아 생성 스파이: 수동으로 작성한 목 스텁(stub) - 스텁, 더미, 페이크 내부로 들어오는 상호 작용을 모방만 함 입력 데이터를 얻기 위해 의존성을 호출하는 것 (사이드 이펙트 X) e.g. DB로 부터 데이터 검색 CQS 관점에서 조회를 대체 (보통 반환값 O) 구현 더미: 단순 하드코딩 값 (null, 가짜 문자열) 스텁: 더 정교하게 시나리오마다 다른 값 반환하는 의존성 페이크: 스텁과 같지만, 아직 존재하지 않는 의존성을 대체하고자 구현 무분별한 목 사용 지양하기 (feat. 리팩토링 내성 감소) API를 잘 설계하면 단위테스트도 자동으로 좋아짐 식별할 수 있는 동작만 공개하고 구현 세부사항을 비공개함으로써 리팩토링 내성 상승 스텁의 상호작용은 검증하지 말자! (안티패턴) 입력을 제공할 뿐이지 SUT의 최종 결과가 아님 스텁의 상호작용 검증은 내부 구현 세부사항과 결합(overspecifiation) -> 리팩토링 내성 감소 목의 상호작용 검증은 최종 결과 검증 e.g. mock.Verify(x => x.SendGreetingsEmail("user@email.com")) -> O stub.Verify(x => x.GetNumberOfUsers(), Times.Once) -> X 사이드 이펙트가 있는 시스템 간 통신은 목으로 테스트하자! (외부 애플리케이션 통신) 클래스 간 통신에도 목을 쓰는 것은 런던파의 단점 가치 있는 목 테스트 var mock = new Mock<IEmailGateway>() mock.Verify(x => x.SendReceipt("..@x.com","egg",5), Times.Once) 클라이언트 목표 달성에 도움이 되는 연산 잘못된 목 테스트 var storeMock = new Mock<IStore>() storeMock.Verify(x => x.RemoveInventory("egg", 5), Time.Once) 시스템 내 통신(도메인 간 통신)은 클라이언트 목표로 가는 중간 단계 (구현 세부 사항) 애플리케이션을 통해서만 접근할 수 있는 프로세스 외부 의존성은 목 대체 X 모든 공유 의존성을 목으로 대체하는 것은 고전파의 단점 외부 클라이언트 관점에서 접근 불가한 시스템은 구현 세부 사항 e.g. 데이터베이스 식별할 수 있는 동작과 공개 API 모든 제품 코드는 2차원으로 분류할 수 있다. 공개 API (public) & 비공개 API (private) 식별할 수 있는 동작과 구현 세부 사항 식별할 수 있는 동작은 클라이언트가 목표를 달성하는데 도움이 되는 연산(Operation)과 상태(State)를 최소한으로 노출한다. (연산은 계산 수행 혹은 사이드 이펙트를 초래하는 메서드를 의미) 구현 세부사항은 두 가지 중 어떤 것도 하지 않는다. 잘 설계된 API는 식별할 수 있는 동작은 공개 API와 일치하고, 모든 구현 세부 사항은 비공개 API 뒤에 숨어 있다. 만일, 식별할 수 있는 동작을 달성하고자 할 때 클래스에서 호출해야 하는 연산 수가 1보다 크면 해당 클래스는 구현 세부 사항을 유출했을 가능성이 크다. 또한, API를 잘 설계하면 단위테스트도 자동으로 좋아진다. (리팩토링 내성 상승) 장기적으로 캡슐화는 증가하는 복잡성에 대응하고 소프트웨어의 지속적 성장을 가능하게 하는 유일한 방법이다. 헥사고날 아키텍처(Hexagonal Architecture, Alistair Cockburn) - 애플리케이션 서비스 + 도메인 - 도메인 계층 (도메인 지식) - 비즈니스 로직 책임 - 애플리케이션 서비스 계층 (유스케이스) - 외부 환경과의 통신을 조정 (SMTP, 메시지 버스, 서드파티…) - 잘 설계된 API는 프랙탈 특성 존재 - 서로 다른 계층의 테스트도 동일한 동작을 서로 다른 수준으로 검증하는 프랙탈 특성 존재 - 목표(유스 케이스) - 하위 목표 - … Reference 단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)
Software Engineering
· 2024-11-13
단위 테스트 (Unit Testing) - 단위 테스트의 목표와 구조
단위 테스트의 목표 단위 테스트의 목표: 소프트웨어 프로젝트의 지속 가능한 성장 버그 없이 주기적으로 리팩토링하고 새로운 기능을 추가할 수 있도록 지원 테스트 없는 프로젝트 초기 개발 속도가 빠름 -> 시간이 갈수록 엔트로피(시스템 내 무질서도) 증가 및 개발 속도 감소 테스트 있는 프로젝트 초반에 상당한 노력이 들어감 -> 프로젝트 후반에도 안정적으로 잘 성장 단위 테스트 적용은 필수이고 논쟁거리가 아님 테스트는 코드베이스의 일부 애플리케이션의 정확성을 보장하는 책임을 가진 코드 현재의 논쟁: 좋은 단위테스트는 어떤 것인가? 개발 주기에 통합되어 있는 것 매 배포 전 테스트 실행 코드베이스에 가장 중요한 부분을 대상으로 하는 것 핵심인 도메인 모델을 다른 것과 구분해 테스트 최소한의 유지비로 최대 가치를 끌어내는 것 고품질 테스트는 동작의 단위를 검증하는 것 (비즈니스 로직 테스트) 식별할 수 있는 동작은 테스트하고 구현 세부사항은 테스트 X 필요 사항 가치 있는 테스트 식별하기 가치 있는 테스트 작성하기 테스트 스위트 품질 측정 방법 커버리지 지표 테스트 스위트가 소스 코드를 얼마나 실행하는지 백분율로 표현 중요한 피드백을 줄 순 있지만 테스트 스위트 품질 측정에 부적합 커버리지가 낮으면 테스트가 충분치 않다는 좋은 증거 시스템의 핵심 부분은 커버리지를 높게 두는게 좋음 하지만 커버리지가 높다고 품질을 보장하지는 못함 모든 결과의 검증을 보장하지 못함 (100%여도 빠져나가는 케이스들이 있음) 외부 라이브러리는 경로 검증 불가 지표로만 보고 목표로 삼아서는 안됨!! 특정 커버리지를 목표로하면 개발자들은 시스템을 속일 방법을 궁리하는 부작용 발생 종류 코드 커버리지(code coverage, test coverage) 코드 커버리지 = 실행 코드 라인 수 / 전체 라인 수 커버리지 숫자는 조작이 가능,,, e.g. return input.Length() > 5 //input='abc', 100% 커버리지 분기 커버리지(branch coverage) 분기 커버리지 = 통과 분기 / 전체 분기수 코드 커버리지를 조금은 보완 e.g. return input.Length() > 5 //input='abc', 50% 커버리지 회귀(=소프트웨어 버그) 코드 수정 후 기능이 의도한 대로 작동하지 않는 경우를 의미한다. 단위 테스트란? 단위 테스트의 속성 작은 코드 조각(Unit)을 검증 빠르게 수행 격리된 방식으로 처리하는 자동화된 테스트 (쟁점) 격리가 무엇인지에 대한 의견 차이가 근본적으로 고전파와 런던파를 가름 단위 테스트 접근 방식에 대한 분파 고전파 (Classical School, Detroit) - 지향 원론적인 접근 추구 상향식 TDD (도메인 모델부터 시작) 상태를 검증 (e.g. 고객이 상점을 통해 구매하면 상점의 재고 차감 여부를 검증) 격리 방식에 대한 관점: 단위 테스트끼리 격리 테스트는 적합한 순서(순차적 or 병렬적)로 실행 가능하며 서로의 결과에 영향 X 공유 의존성에 대해서만 테스트 대역 사용 단위 테스트 간 공유되는 의존성은 테스트 대역으로 교체 싱글톤 객체 같은 경우 각 테스트마다 생성하면 테스트 간 공유 X 설정 파일도 각 테스트마다 생성자 주입하는 방식으로 가능 공유 의존성 = 프로세스 외부 의존성 실무에서 예외 케이스가 거의 없음! 예외 상황: 읽기 전용 외부 API (불변 의존성이므로 공유 의존성 X) 테스트 속도와 안정성을 위해 테스트 대역으로 교체 권장 빠르고 안정적이라면 원칙에 맞춰 그대로 사용해도 괜찮 교체 시 테스트 속도 상승 (단위 테스트의 2번째 요건 충족) 공유 의존성은 대부분 프로세스 외부에 있어 호출이 느림 외부 공유 의존성 테스트는 보통 통합 테스트의 영역 코드 조각 범위 (Unit): 공유 의존성만 없으면 여러 클래스 묶어 테스트 가능 통합 테스트: 단위 테스트 정의를 충족하지 않는 테스트 고전파 관점의 단위 테스트 정의 단일 동작 단위를 검증하고 빠르게 수행하고 다른 테스트와 별도로 처리한다. e.g. 둘 이상의 동작 단위 검증 테스트, 다른 팀 개발 코드와 통합해 검증하는 테스트, 프로세스 외부 의존성, 공유 의존성 장점 단위 테스트 목표 달성에 적합 -> 동작의 단위 검증에 유용 단점 SUT가 올바르게 동작하더라도 협력자에 버그에 있는 경우 테스트 실패 런던파 (London School, Mockist) 런던의 프로그래밍 커뮤니티에서 시작 하향식 TDD (상위 레벨 테스트부터 시작) 상호작용을 검증 (e.g. 목 객체의 메서드가 올바르게 호출되었는지, 호출 횟수는 맞는지…) 격리 방식에 대한 관점: 테스트 대상 시스템에서 협력자를 격리 한 클래스의 의존성을 모두 테스트 대역(test double)으로 대체 (불변 의존성은 그대로) e.g. 인터페이스를 통해 목 객체를 만들고 SUT에 인자로 넘김 코드 조각 범위 (Unit): 단일 클래스 혹은 해당 클래스 내 메서드 통합 테스트: 실제 협력자 객체를 사용하는 모든 테스트 장점 테스트가 실패하면 테스트 대상 시스템이 고장난 것이 확실해짐 (테스트가 세밀) 중요성 떨어짐 (정기적으로 테스트 실행하면 고장난 부분 좁히기 쉬움) 테스트 준비 시 복잡한 의존성 조립을 피하고 대역 하나로 대체 가능 e.g. 한 번에 한 클래스만 테스트 -> 전체 단위 테스트 구조 간단해짐 중요성 떨어짐 (복잡한 의존성은 잘못된 설계이므로 설계를 바꿔야 함) 단점 목을 다루는 것은 불안정함 내포 테스트가 SUT의 구현 세부 사항에 빈번히 결합 테스트 대상 시스템(SUT, System Under Test) & 협력자(Collaborator) SUT는 현재 테스트에 대상이 되는 시스템을 의미한다. 테스트 대상 메서드는 MUT(Method Under Test)라고도 한다. 반면에 협력자는 불변 의존성(값 객체)을 제외한 시스템에 엮여 있는 모든 의존성들을 의미한다. 즉, 일반적인 클래스는 협력자와 값 객체로 2가지 유형의 의존성으로 동작할 수 있다. 의존성 종류 공유 의존성(shared dependency) 테스트 간 공유되고 서로의 결과에 영향을 미칠 수 있는 의존성 e.g. 정적 가변 필드, 데이터베이스 비공개 의존성(private dependency) 테스트 간 공유하지 않는 의존성 프로세스 외부 의존성(out-of-process dependency) 애플리케이션 프로세스 외부에서 실행되는 의존성. 대부분 공유 의존성이지만 아닌 경우도 있다. e.g. 데이터베이스는 외부 의존성이면서 공유 의존성인 반면, 테스트 실행 전 도커 컨테이너로 시작한 데이터베이스는 외부 의존성이면서 비공개 의존성 읽기 전용 API처럼 프로세스 외부 의존성이지만 불변 의존성이어서 공유 의존성이 아님 엔드 투 엔드 테스트 (end-to-end test) 시스템을 최종 사용자 관점에서 검증하는 것을 의미한다. (동의어: UI 테스트, GUI 테스트, 기능 테스트) 엔드 투 엔드 테스트는 통합 테스트의 일부다. 둘 모두 코드가 프로세스 외부 의존성과 함께 어떻게 작동하는지 검증한다. 다만, 엔드 투 엔드 테스트가 일반적으로 의존성을 더 많이 포함한다. (통합 테스트가 프로세스 외부 의존성이 1~2개, 엔드 투 엔드 테스트는 전부 혹은 대다수) 예를 들어, DB, 파일 시스템, 결제 게이트 웨이라는 3가지 프로세스 외부 의존성이 있다면, 보통의 통합 테스트는 완전히 제어 가능하지 않은 결제 게이트 웨이만 테스트 대역으로 대체하는 반면에, 엔드 투 엔드 테스트는 전부 테스트에 포함한다. 통합 테스트와 엔드 투 엔드 테스트의 뚜렷한 경계는 없다. 테스트 버전이 없거나 자동으로 가져오는 것이 불가능한 프로세스 외부 의존성의 경우 엔드 투 엔드 테스트 역시 테스트 대역을 사용해야 한다. 또한, 엔드 투 엔드 테스트는 유지보수 측면에서 가장 비용이 많이 들기 때문에 모든 단위 테스트와 통합 테스트가 통과한 후 빌드 프로세스 후반에 실행하는 것이 좋다. 단위 테스트 구조 AAA 패턴 (Arrage, Act, Assert) 준비, 실행, 검증패턴으로 테스트하는 일반적인 방식 단순하고 균일한 구조를 만들어 가독성과 유지보수성이 향상 Given, When, Then은 비기술자에게 조금 더 읽기 쉬운 점말고 AAA와 차이가 없음 단위 테스트 구조에 대한 지침 한 테스트에 여러 개의 준비, 실행, 검증 구절 -> 여러 테스트로 나눠라! 여러 구절 = 테스트가 여러 개의 동작 단위를 한 번에 검증 = 통합 테스트 테스트 내 if 문 피하자 -> 여러 테스트로 나눠라! 분기가 있다는 것 역시 한 번에 너무 많은 것을 검증한다는 표시 단위 테스트 든 통합 테스트 든 테스트에 분기가 있어서 얻는 이점은 없음 (가독성만 감소) 각 구절의 적정 크기 준비 구절: 세 구절 중 가장 큼 많이 크다면 테스트 내 비공개 메서드, 별도 팩토리 클래스 추출해 재사용 실행 구절: 한 줄 (이상적) 두 줄 이상인 경우 SUT API 설계 문제 의심 -> 캡슐화 지키기 e.g. 테스트 내 Purchase(), RemoveInventory() 각각 실행-> 캡슐화가 깨짐 단, 유틸리티나 인프라 코드는 덜 적용되므로, 절대 두줄 이상 두지 말라고 할 수는 없음! 검증 구절: 여러 검증이 있을 수 있지만 너무 커지는 것을 경계 equals() 정의해 객체 끼리 Assert문 한번에 검증하는 것이 좋음 참고) 종료 구절: 리소스 정리 목적으로 통합테스트에 주로 쓰임 (메서드 추출해 재사용) SUT 구분하기 SUT는 동작에 대한 유일한 진입점 테스트 내 SUT 이름을 sut로 명명해 구분하자 구절 주석 지침 AAA 패턴 따르고 주석 없이 빈 줄로 각각 구절을 3등분해 구분 각 구절 내에 빈 줄이 있다면, 주석 추가하기 (//arrage, //act, //assert) 테스트 픽스처 재사용하기 테스트 픽스처: 단위 테스트를 수행할 때 필요한 초기 상태나 설정 e.g. 계좌 잔고 확인 테스트를 위한 초기 입금 설정 작업 준비 구절 코드 재사용은 좋은 방법 픽스처 재사용 방법 권장: 테스트 클래스에 비공개 팩토리 메서드 두자 (가독성, 재사용성 향상) CustomerTests -> CreateStoreWithInventory(), CreateCustomer() 안티 패턴: 테스트 클래스 생성자에서 픽스처 초기화 e.g. _store.AddInventory(Product.Shampoo, 10) 테스트 간 결합도 상승 및 가독성 감소 테스트마다 재고 변경을 다르게 설정 하고 싶어도 결합되어 어려움 (공유 상태) 생성자 재사용의 유일한 예외: 테스트 대부분에 사용되는 픽스처 기초 클래스를 두고 생성자에서 초기화한 후 개별 테스트 클래스에서 상속해 재사용 e.g. 통합 테스트 시 DB 커넥션 초기화 CustomerTests가 DB 커넥션이 있는 IntegrationTests를 상속받음 단위 테스트 명명법 Best Practice 표현력이 있는 간단하고 쉬운 영어 구문 (엄격하지 않게 표현의 자유 허용) 장황한 표현 지양 e.g. considered X 사실만 서술하고 소망이나 욕구 지양 e.g. should be X -> is O 기초 영문법 지키기 e.g. 관사 도메인에 익숙한 비개발자에게 설명하듯이 이름 짓기 (도메인 전문가, 비즈니스 분석가) 동작으로 이름 짓기 테스트 이름에 메서드 이름 넣지 말기 예외: 유틸리티 코드는 메서드 이름 사용해도 괜찮 (비즈니스 로직 X) 테스트 클래스 이름 지정: [클래스명]Tests 동작 단위 검증의 진입점 역할 해당 클래스만 검증한다는 것이 아님 -> 여러 클래스 걸쳐도 동작을 검증 _ 로 단어 구분 (가독성 향상) e.g Sum_of_two_numbers() Delivery_with_a_past_date_is_invalid 안티 패턴: [테스트 대상 메서드]_[시나리오]_[예상 결과] 타인이 읽기 난해하고 구현 세부사항에 묶임 e.g. Sum_TwoNumbers_ReturnsSum() IsDeliveryValid_InvalidDate_ReturnsFalse() 매개변수화된 테스트 리팩토링 하기 (Parameterized Test) 유사한 테스트를 묶을 수 있는 기능 제공 하나의 동작은 여러 테스트가 필요하고 복잡하면 테스트 수가 급증하므로 관리에 용이 사용 지침 입력 매개변수만으로 테스트케이스 판단이 가능하면, 하나의 테스트 메서드 사용 매개변수만으로 판단이 어렵다면 긍정 테스트 케이스와 부정 테스트 케이스 나누기 동작이 너무 복잡하면 매개변수화된 테스트 사용말고 모두 개별 테스트로 두기 e.g. 가장 빠른 배송일이 오늘부터 이틀 후가 되도록 작동하는 배송 기능 4가지(어제, 오늘, 내일, 모레) 테스트가 필요하지만 유일한 차이점은 배송 날짜 Parameterized Test로 묶기 하나의 테스트 메서드 두기 Can_detect_an_invalid_delivery_date() [(-1, false), (0, false), (1, false), (2, true)] 긍정 테스트와 부정 테스트 나누기 (boolean 매개변수 제거 효과) 긍정: The_soonest_delivery_date_is_two_days_from_now() 2 부정: Detects_an_invalid_delivery_date() [-1, 0, 1] 검증문 라이브러리 사용하기 쉬운 영어로 구성된 이야기 패턴으로 테스트 가독성 향상 유일한 단점은 프로젝트에 의존성 추가하는 것 Assert.equal(30, result) -> result.Should().Be(30) Reference 단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)
Software Engineering
· 2024-11-11
파이썬으로 살펴보는 아키텍처 패턴: TDD, DDD, EDM 적용하기
Part 1 목표 아키텍처 Chapter 0 Big Ball of Mud에 대한 접근 Big Ball of Mud 안티패턴 처음에 깔끔한 작성을 목표로 시작한 소프트웨어 시스템도 시간이 지나면서 모든 요소 (도메인 지식, 비즈니스 로직, 로깅, 이메일 보내기 etc…)들이 서로 Coupling(결합)되어 시스템의 일부를 바꾸는 것도 힘들어지는 상황 Big Ball of Mud를 피하기 위한 일반적인 접근 1. Abstraction(추상화) & Encapsulation(캡슐화) 행동을 캡슐화하여 추상화로 사용하는 것은 코드의 표현력을 높이고 테스트와 유지보수를 더 쉽게 만든다. 2. Layering(계층화) Layering Architecture는 복잡한 의존성들을 해결한다. 코드의 역할을 구분하고 범주(category)를 나눠 어떤 코드 범주가 특정 코드 범주를 호출할 수 있는지 규칙을 정한다. 도메인 모델(Domain Model)로 비즈니스 계층을 만들고, 모든 비즈니스 로직을 이곳에 모아야 한다. 3-Layer Architecture: 표현 계층 (UI or API or CLI…) ————|———— 비즈니스 로직 (Business Rules & Workflows) ————|———— 데이터베이스 계층 (Data Read & Write) 3. DIP (Dependency Inversion Principle, 의존성 역전 원칙) 비즈니스 코드는 기술적인 세부 사항에 의존해서는 안된다. 서로 추상화를 사용해 강한 의존성을 해소하여 각자가 독립적으로 변경될 수 있는 환경을 만들어야 한다. 예를 들어, 인프라를 바꿔야 하는 필요성이 있을 때 비즈니스 계층을 변경하지 않고도 인프라 세부 사항을 바꿀 수 있어야 한다. DIP의 정의 고수준 모듈은 저수준 모듈에 의존해서는 안된다. 두 모듈 모두 추상화에 의존해야 한다. 추상화는 세부 사항에 의존해서는 안된다. 반대로 세부 사항은 추상화에 의존해야 한다. Chapter 1. 도메인 모델링 도메인 모델 Domain(도메인): 해결해야 할 문제 Model(모델): 어떤 프로세스나 현상을 설명하기 위해 그것의 특성을 관찰하고 정리한 일종의 Mind Map DDD(Domain-Driven Design)는 도메인 모델링의 개념을 널리 알렸고, 소프트웨어에서 가장 중요한 요소는 문제에 대해 유용한 모델을 제공하는 것이라고 주장한다. 도메인 모델링 자체는 DDD보다도 일찍 시작된 개념 (1980~) 비즈니스 전문가는 이미 그들의 도메인의 비즈니스 용어가 있으므로 개발자는 이를 공부하고 소프트웨어에 녹여내야 한다. 도메인 모델의 용어와 규칙은 비즈니스 전문가와 Ubiquitous Language(유비쿼터스 언어=비즈니스 전문용어)로 표현해야 한다. Value Object, Entity Value Object 데이터는 있지만 유일한 식별자가 없는 비즈니스 개념, 내부 데이터에 의해 개체 식별 값이 같으면 동일하다. (Structural Equality,구조적 동등성) 10파운드를 말할 때 10파운드라는 값(가치)이 중요하지, 어떤 지폐인지는 중요하지 않다. 수명이 없고 항상 Entity에 속한다. 불변(immutable) 속성 dataclass의 @frozen=True 로 해시 설정 Entity 고유한 식별자로 구분되는 개념 식별자가 같으면 동일하다. (Identifier Equality) 같은 이름, 같은 성별의 군인도 다른 군번(id)을 가진 동명이인일 수 있다. 수명이 있다. 가변(mutable) 속성 __eq__를 식별자로 비교하도록 구현 __hash__를 None으로 설정해서 집합등에 사용할 수 없게 구현 엔티티(entity)와 값객체(value-object)에 대해서 __hash__는 객체를 집합에 추가하거나 딕셔너리의 키로 사용할 때 동작을 제어하는 magic method Domain Service Function 동사로 표현되는 부분은 (도메인과 관련된 비즈니스 로직)을 함수로 구현한다. Domain Exception(예외)을 통해서도 도메인 개념을 표현할 수 있다 Ex) OutOfStock 이러한 동사 하나하나가 단위 테스트가 된다. Chapter 2. 저장소 패턴 앱과 도메인이 복잡한 경우 Repository Pattern을 통해 저장소 계층을 하나 추가하는 방향을 생각해 볼 수 있다. 영속성과 분리된 모델(Persistence-Igorant Model) - 도메인 모델과 ORM의 분리 도메인 모델은 그 어떤 의존성도 없어야 한다. 즉, 인프라와 관련된 문제가 도메인 모델에 영향을 끼쳐 단위테스트를 느리게 하고 도메인 모델 변경을 어렵게 해서는 안된다. 따라서, 모델(비즈니스 로직)을 내부에 있도록 하여 의존성이 내부로 들어오게 해야 한다. (Onion Architecture) 이를 위해 도메인 모델과 ORM을 분리하여 도메인 모델이 항상 순수한 상태를 유지하고 인프라에 신경쓰지 않도록 한다. SQLAlchemy의 Classical Mapper를 사용하면 이를 구현할 수 있다. 이러한 구조에서는 비즈니스 로직에 영향을 주지 않고 SQLAlchemy를 제거하여 다른 ORM 혹은 전혀 다른 영속화 시스템을 채택해 갈아 끼울 수 있다. Repository Pattern (저장소 패턴) 데이터 저장소를 간단히 추상화하는 것으로 데이터 계층을 분리할 수 있다. 추상화한 Repository는 마치 모든 데이터가 메모리 상에 존재하는 것처럼 가정해 데이터 접근과 관련된 세부 사항을 감춘다. 일반적으로 get(), add()를 통해 데이터를 가져오고 조작한다. 저장소에 대한 테스트는 모든 모델이 할 필요는 없다. 한 모델 클래스에 대해 생성/변경/저장을 모두 테스트했다면, 새로 추가되는 비슷한 패턴의 클래스는 최소한의 호출 응답만 확인하거나 테스트를 전혀 진행하지 않을 수도 있다. Pros & Cons 장점 Repository와 Domain Model사이의 인터페이스를 간단하게 유지할 수 있다. 모델과 인프라를 완전히 분리했기 때문에 도메인이 복잡해도 비즈니스 로직 변경과 인프라 변경이 쉽다. 영속성을 생각하기 전에 도메인 모델을 작성하면, 처리해야 할 비즈니스 문제에 더 잘 집중할 수 있다. Fake Repository를 만드는 식으로 단위 테스트를 위한 가짜 저장소를 쉽게 만들 수 있다. 단점 ORM mapping 변경 및 유지 보수 작업에 공수가 더 든다. (모델, ORM 둘 다 손봐야 하기 때문에) 저장소 계층에 대한 러닝커브가 발생한다. Chapter 3. 결합과 추상화 Cohesion(응집)과 Coupling(결합) 응집: 한 컴포넌트가 다른 컴포넌트를 지원하며 서로 맞물려 잘 돌아가는 상황 (지역적인 결합) 결합: B 컴포넌트가 깨지는게 두려워서 A 컴포넌트를 변경할 수 없는 경우 (전역적인 결합) Abstraction(추상화) 추상화를 통해 세부사항을 감추면 시스템 내 결합 정도를 줄일 수 있다. 또한, 추상화는 테스트를 더 쉽게 해준다. Fake Object VS Mock Fake Object 대치하려는 대상을 동작할 수 있게 구현한 존재, 테스트를 위한 구현만 제공 (고전 스타일 TDD) 의존성 주입을 하는 함수를 만들면 Test 시 Fake Object를 만들어 주입하기 쉬움 I/O의 경우 의존성 주입해 Fake를 뜨면 편함 def synchronise_dirs(reader, **filesystem**, source_root, dest_root): Mock 대상이 어떻게 쓰이는지 검증할 때 사용 (런던 학파 TDD) 목을 너무 많이 사용하는 테스트는 설정 코드가 많아서 정작 신경을 써야 하는 이야기가 드러나지 않는 단점이 있다. Chapter 4. 서비스 계층 (유스 케이스) Use Case(유스 케이스) 사용자의 행동 요청 시나리오에 따라 시스템이 수행하는 작업 과정 Orchestration(오케스트레이션) 저장소에서 여러 데이터를 가져오고, 데이터베이스 상태에 따라 입력을 검증하며 오류 처리하고, 성공적인 경우 데이터를 데이터베이스에 커밋하는 일련의 작업들을 의미한다. 이러한 로직은 웹 API 엔드포인트와 관련이 없고 엔드포인트를 무겁고 장황하게 만드므로, 따로 서비스 계층에 분리하는 것이 타당하다. Service Layer 유스 케이스를 정의하고 워크 플로를 조정하는 Orchestration(오케스트레이션) 로직을 담는다. (서비스 계층=오케스트레이션 계층=유스 케이스 계층) 전형적인 서비스 계층 함수들은 다음과 비슷한 단계를 거친다. 저장소에서 어떤 객체들을 가져온다. 현재 세계를 바탕으로 요청을 검사하거나 어서션으로 검증한다. 도메인 서비스(비즈니스 로직)를 호출한다. 모든 단계가 정상적으로 실행됐다면 변경한 상태를 저장하거나 업데이트한다. 서비스 계층 추가 시 다음과 같은 장점이 있다. 엔드포인트가 아주 얇아지고 작성하기 쉬워진다. 엔드포인트는 JSON 파싱이나 웹 기능만 담당한다. 테스트의 상당 부분을 빠른 단위 테스트와 최소화된 E2E 및 통합 테스트로 만들어, 테스트 피라미드를 높은 기어비(High Gear)로 적절히 구성할 수 있다. Chapter 5. 높은 기어비와 낮은 기어비의 TDD 결합과 설계 피드백 사이의 트레이드 오프 API 테스트(High Gear)로 갈수록 세부 설계 피드백은 적어지지만, 더 넓은 커버리지의 테스트를 제공하므로 데이터베이스 스키마 변경 등의 대규모 변경에 대하여 코드가 망가지지 않는다는 자신감을 제공한다. 반대로, 도메인 모델 테스트(Low Gear)는 도메인 언어로 작성되므로 모델의 살아있는 문서 역할을 한다. 다만, 특정 구현과 긴밀하게 결합되어 있어서 전체가 깨질 수 있는 불안함을 포함해 로직 변경시 Cost가 크다 Service Layer 추가 후 지향할 테스트 방향 도메인 모델에 집중되어 있던 단위 테스트를 모두 서비스 계층 함수에 대해 테스트하도록 리팩토링할 필요가 있다. 즉, E2E 테스트는 호출과 응답에 관련한 Happy Path, Unhappy Path만 테스트하고 비즈니스 로직 관련 테스트는 Service Layer 함수들에 대한 단위테스트로 진행한다. 도메인 모델에 대한 테스트가 너무 많으면 코드베이스를 바꿀 때마다 수십 개에서 수백 개의 테스트를 변경해야 하는 문제가 생긴다. 서비스 계층 테스트는 더 낮은 결합(Coupling)을 제공하고 커버리지, 실행 시간, 효율 사이를 잘 절충할 수 있게 도와줘서 도메인 모델 테스트 보다 이점이 있다. 또한, 서비스 계층 테스트에 집중하면 커버리지가 더 높으므로, 도메인 모델을 리팩토링할 때 변경해야 하는 코드의 양을 크게 줄일 수 있다. 서비스 계층 테스트를 도메인으로부터 완전히 분리하기 서비스 함수 파라미터는 도메인 객체를 받지 않고 원시 타입으로 받도록 선언하자. def allocate(line: OrderLine, repo: AbstractRepository, session) -> str 보다는 def allocate(orderid: str, sku: str, qty: int, repo: abstractRepository, session) -> str: 으로 사용하자. 서비스 테스트의 모든 도메인 의존성을 한 곳에 모으자. 픽스처 함수에 팩토리 함수를 넣어 도메인 의존성을 모으는 방법이 있다. 개인적으로 가장 좋은 것은 모델 객체를 추가하는 서비스 함수를 하나 작성해두면, 도메인 의존성 없이 테스트에 지속적으로 사용할 수 있어 편리하다. 덕분에 서비스 계층이 오직 서비스 계층에만 의존한다. def test_add_batch(): repo, session = FakeRepository([]), FakeSession() services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session) assert repo.get("b1") is not None assert session.committed 다만, 단순히 테스트 의존성 제거 만을 위해 새 서비스를 작성할 필요는 없다. 미래에 필요성을 고려해 도입한다. 엔드 투 엔드 테스트 API 테스트 역시 API 테스트에만 의존하도록 하는 것은 괜찮은 방법이다. 또한, Happy Path를 위한 하나의 E2E & 모든 Unhappy Path를 위한 하나의 E2E를 작성해 관리하자. 정리 Chapter 6. 작업 단위 패턴 (Unit of Work) 작업 단위 패턴은 원자적 연산(Atomic Operation)에 대한 추상화다. 어떤 객체가 메모리에 적재됐고 어떤 객체가 최종 상태인지를 기억한다. 장점 UoW는 영속적 저장소에 대한 단일 진입점으로 기능하여 엔드포인트와 서비스 계층을 데이터 계층과 완전히 분리할 수 있다. (서비스 함수 자체와 엔드포인트(Flask, FastAPI)가 데이터베이스와 직접 대화하지 않는다.) 데이터베이스에 접근하는 코드가 여기저기 흩어지지 않게 하나로 모으고, 각 컴포넌트가 자신에게 반드시 필요한 것들만 갖게 하는 것이 좋다. 원자적 연산을 표현하는 좋은 추상화가 생기고, 파이썬 콘텍스트 관리자를 사용하면 원자적 한 그룹으로 묶여야 하는 코드 블록을 시각적으로 쉽게 알아볼 수 있다. 트랜잭션 시작과 끝을 명시적으로 제어할 수 있고, 애플리케이션이 실패하면 기본적으로 안전한 방식의 트랜잭션 처리를 할 수 있다. UoW는 세션을 단순화해 핵심 부분만 사용하도록 해준다. 세션 API는 풍부한 기능과 도메인에 불필요한 연산을 제공하므로, 코드를 Session 인터페이스와 결합하는 것은 SQLAlchemy의 모든 복잡성을 결합하기로 결정하는 것이다. 단점 ORM이 이미 원자적 연산에 대한 좋은 추상화를 제공할 수 있다. (롤백, 다중 스레딩이 담긴) 복잡한 트랜잭션을 처리하는 코드의 경우 매우 신중하게 생각해야 한다.
Software Engineering
· 2022-09-20
<
>
Touch background to close