04. SOLID: 코드의 유지보수성을 판단할 때 사용할 수 있는 맥락 (영향 범위 / 의존성 / 확장성)
- SRP / 단일책임원칙: 한 클래스에 너무 많은 책임이 할당돼서는 안 되며, 단 하나의 책임만 있어야 한다. 클래스는 하나의 책임만 가지고 있을 때 변경이 쉬워진다. = 클래스를 변경해야 할 이유는 단 하나여야 한다.
- 복잡하고 과한 책임이 할당된 클래스는 코드를 변경하려고 할 때 문제가 된다. 영향 범위를 알 수 없으니 코드 변경이 어려워진다. -> 과하게 집중된 책임은 피하고, 분할해야 한다.
- 책임: 액터에 대한 책임; 문맥을 포함하는 개념이며, 개인이나 상황마다 다르게 해석될 여지가 있다.
- 액터 actor: 메세지를 전달하는 주체로, 하나의 모듈은 오직 하나의 액터에 대해서만 책임져야 한다.
- 어떤 모듈이나 클래스가 담당하는 액터가 혼자라면 단일 책임 원칙을 지키고 있는 것이며, 어떤 모듈이나 클래스가 담당하는 액터가 여럿이라면 단일 책임 원칙에 위배되는 것이다.
- Open-Closed Principle / 개방 폐쇄 원칙: 확장에는 열려있고, 변경에는 닫혀 있어야 한다. -> 코드를 추상화된 역할에 의존하게 만든다. = 클래스의 동작을 수정하지 않고 확장할 수 있어야 한다.
- Liskov Subsitution Principle / 리스코프 치환 원칙: 기본 클래스의 계약을 파생 클래스가 제대로 치환할 수 있는지 확인한다. = 파생 클래스는 기본 클래스의 모든 동작을 완전히 대체할 수 있어야 한다. -> 기본 클래스에 할당된 의도와 계약이 무엇인지 파악할 수 있어야 하며, 코드 외에 테스트 코드를 사용하여 코드 작성자의 의도를 드러낸다.
- Interface Segregation Principle / 인터페이스 분리 원칙: 클라이언트가 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다. 즉, 어떤 클래스가 자신에게 필요하지 않은 인터페이스의 메서드를 구현하거나 의존하지 않아야 한다. -> 클라이언트별로 세분화된 인터페이스를 만든다. / 범용성을 갖춘 하나의 인터페이스를 만들기보다 다수의 특화된 인터페이스를 만드는 편이 낫다.
- 인터페이스(역할)가 통합되면 인터페이스의 역할이 모호해지고, 여러 액터들을 상대해야 한다; 인터페이스 분리가 제대로 지켜지지 않은 코드는 단일 책임 원칙도 위배하고 있을 확률이 높다.
- 인터페이스를 통합하고 한 곳으로 모으는 것은 응집도가 높아지는 것인가? = 응집도는 유사한 코드를 한 곳에 모은다 에서 끝나는 것이 아님; 위로갈수록 응집도가 높다고 평가함
- 기능적 응집도: 모듈 내 컴포넌트들이 같은 기능을 수행하도록 설계된 경우 = 역할과 책임 측면에서 바라보는 응집도
- 순차적 응집도: 모듈 내 컴포넌트들이 특정한 작업을 수행하기 위해 순차적으로 연결된 경우
- 통신적 응집도: 모듈 내 컴포넌트들이 같은 데이터나 정보를 공유하고, 상호작용할 때 이에 따라 모듈을 구성하는 경우
- 절차적 응집도: 모듈 내 요소들이 단계별 절차를 따라 동작하도록 설계된 경우
- 논리적 응집도: 모듈 내 요소들이 같은 목적을 달성하기 위해 논리적으로 연관된 경우 = 유사한 코드라서 한 곳에 모은다
- Dependency Inversion Principle / 의존성 역전 원칙: 상위 모듈은 하위 모듈에 의존해서는 안 된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야하며, 추상화는 세부 사항에 의존해서는 안 된다. 세부사항이 추상화에 의존해야 한다. = 구체화가 아니라 추상화에 의존해야 한다.
- 의존성: 어떤 객체가 다른 코드를 사용하는 상태 = 결합(coupling)
- 의존성 주입: 필요한 의존성을 외부에서 넣어주는 것 = 의존성과 결합도를 낮추기 위한 기법 중 하나 -> new 사용을 자제한다. = 상세한 구현 객체에 의존하는 것을 피하고, 구현 객체가 인스턴스화 되는 시점을 최대한 뒤로 미룬다.
- 의존성 역전: 의존의 방향이 바뀌는 것 = 원래 의존 당하던 객체가 의존을 하는 객체로 바꾸는 것 -> 추상화를 이용한 간접 의존 형태로 바꾼다.
- 의존성 전이: 한 컴포넌트가 변경되거나 영향을 받으면 관련된 다른 컴포넌트에도 영향이 갈 수 있다. 이렇게 영향을 받은 컴포넌트는 연쇄적으로 또 다른 관련 컴포넌트에 영향을 준다 -> 의존성 전이의 영향 범위를 축소하기 위해 추상화를 적용한다. = 의존성 역전을 적용한다.
05. 순환참조: 두 개 이상의 객체나 컴포넌트가 서로를 참조함으로써 의존 관계에 사이클이 생기는 상황 (ex. JPA의 양방향 매핑)
- 시스템에 무한 루프가 생길 수 있다.
- 시스템의 복잡도를 높인다.
- 메모리 누수를 유발할 수 있다.
-> 필요하지 않은 참조는 제거하여 가능한 한 도메인 모델들에 단일 진입점을 만들어 단방향으로 접근하도록 한다.
-> 만약 관계를 표현해야 한다면 한 쪽이 다른 한 쪽의 식별자를 갖고 있게 하여 간접 참조 형태로 관계를 바꾼다.
-> 양쪽 서비스에 있던 공통 기능을 하나의 컴포넌트로 분리하고, 양쪽 서비스가 공통 컴포넌트에 의존하도록 바꾼다.
-> 이벤트 기반 프로그래밍을 적용한다. = 이벤트와 이벤트 큐에 의존한다.
2부. 스프링과 객체지향 설계
06. 안티패턴
- 양방향 레이어드 아키텍쳐(=순환 참조): 하위 레이어드 컴포넌트가 상위 레이어에 존재하는 모델을 이용하는 경우
- ex. Controller(프레젠테이션 레이어)에서 요청받은 PostCreateRequest를 PostService(비즈니스 레이어)에서 사용하는 경우
- 해결1) 레이어별 모델 구성: PostCreateCommand와 같은 모델을 추가로 만든다. 각 레이어의 관심사를 명확히 분리할 수 있지만, 작성해야 하는 코드의 양이 늘어난다는 단점이 있다.
- 해결2) 공통 모듈 구성: 순환참조의 원인이 되는 PostCreateRequest를 별도의 공통 모듈로 분리한다. 레이어별 모델 구성에 비해 코드 중복을 줄일 수 있으나, 레이어 간 결합도가 여전히 존재하므로 공통 모듈의 변경이 여러 레이어에 영향을 줄 수 있다.
- 트랜잭션 스크립트: 서비스 컴포넌트에서 발생하는 안티패턴으로, 서비스 컴포넌트의 동작이 사실상 트랜잭션이 걸려있는 거대한 스크립트를 실행하는 것처럼 보이는 경우; 객체지향보다 절차지향에 가까운 사례
- 비즈니스 로직이 처리되는 주 영역은 도메인 모델에 위치해야 한다.
- 서비스는 단순히 컨트롤러가 사용하는 것이 아니다. 서비스는 도메인을 실행하는 역할을 해야한다.
07. 서비스: 도메인(도메인객체/도메인서비스)에 일을 위임하는 공간이어야 한다.
- 도메인 객체를 불러온다 - 도메인에 일을 위임한다. - 도메인 객체의 변경 사항을 저장한다.
- @Service: 캡슐화 된 상태 없이, 모델과는 독립된 동작을 제공하는 인터페이스이다.
- 도메인 객체가 처리하기 애매한 연산 자체를 표현하기 위한 컴포넌트
- 도메인 서비스 vs 애플리케이션 서비스:
- 도메인 서비스: 비즈니스 연산 로직을 처리하며, 도메인 객체에 기술할 수 없는 연산 로직을 처리한다.
- 애플리케이션 서비스: 애플리케이션 연산 로직을 처리하며, 도메인을 저장소에서 불러오고/도메인 서비스를 실행하며/도메인을 실행한다.
- 서비스는 불변해야 한다.
08. 레이어드 아키텍쳐
컴포넌트에 맞춰 레이어를 분류하는 것은 폴더를 관리하는 것과 다를 바 없다.
- 레이어
- Layer 간 의존 방향은 단방향이어야 한다.
- Layer 간 통신은 인접한 레이어에서만 이루어지도록 한다.
- 아키텍쳐: 정책과 제약을 정하는 과정
- 아키텍쳐는 제약사항이 더 중요하다 = 목적에 따라 아키텍쳐가 달라질 수 있다.
- Account 시스템을 개발한다면
- Account 엔티티를 어떻게 설계할 것인가? -> 객체지향스럽지 않음(데이터 위주의 사고방식)
- 기능이 만들어지기 전 DB가 만들어져야 하므로 DB 종속적이다.
- API를 어떻게 만들것인가? / RequestBody/ResponseEntity의 형태는 어때야하는가?
- 프로젝트가 프레임워크에 종속된다. 웹소켓/gRPC/메세지 큐 등 다양한 형태가 될 수 있어야 함
- 비즈니스 레이어: 도메인을 파악하고, 도메인 모델을 구성하고, 도메인 분석/개발부터 시작하는 것이 좋다.
- Account 엔티티를 어떻게 설계할 것인가? -> 객체지향스럽지 않음(데이터 위주의 사고방식)
- 패키지 != 레이어
- 레이어는 외부 라이브러리에 의존하지 않고(어노테이션X) 순수 자바코드로 작성해야
- 의존성 역전: 변경 영향이 적어진다
09. 모듈
- 모듈과 모듈 시스템 != 패키지
- 모듈: 프로그램의 기본 구성요소/라이브러리 + 의존성관리/캡슐화/독립성,은닉성
- 패키지: 폴더, requres/exports
10. 도메인: 사용자들이 겪는 문제 영역이고, 이것을 해결하기 위해 소프트웨어를 개발하는 것
신규 프로젝트를 개발할 때, 어떤 기술을 사용할지부터 정하지 말고 무엇을 만들고싶은지, 도메인을 분석하고 도메인의 요구사항부터 정리해야한다. (앞의 내용 복습 느낌) ->시스템의 설계/패턴/세부 구현은 도메인을 바탕으로 선택해야 한다.
- 보일러플레이트: 새로운 SW 프로젝트를 시작할 때, 기본적으로 필요한 구성 요소나 의존성, 기능 등을 미리 갖춰놓은 프로젝트로, 이러한 프로젝트를 이용하면 반복적인 설정 작업을 건너 뛸 수 있으므로 빠르게 개발에 집중 할 수 있다
- 도메인 모델과 영속성 개체
- 통합하기: 객체/매핑 메서드를 추가로 작성할 필요가 없다. ↔ 클래스 책임이 제대로 눈에 들어오지 않는다. DB위주의 사고를 하게 된다. 도메인 모델이 커질수록 관리가 어렵다.
- 구분하기: 굳이 ORM을 사용할 필요가 없게 된다. DB 접근 라이브러리에 비종속적
11. 알아두면 유용한 스프링 활용법
무지성으로 사용했던 것들을 다시 볼 수 있어서 좋았다.
- @Autowired: 타입을 기반으로 빈을 찾아 의존성 주입
- 없으면 NoSuchBeanDefinitionException
- 어떤 빈을 주입할지 선택할 수 없는 상황이라면 NoUniqueBeanDefinitionException
- @Qualifier: 주입하려는 빈을 지정할 수 있다.
- @Primary: 타입이 일치하는 빈이 여러개일때 가장 우선하여 주입하도록 한다.
- List type으로 주입하는 경우 모든 빈이 List의 요소로 들어간다
- @Resource: 이름이 일치하는 빈을 먼저 찾고, 그 다음에 타입을 기반으로 빈을 찾아 의존성 주입
NotificationService에서 PushNotificationChannel를 추가하는 예제가 흥미로웠다. Push를 추가하는 요구사항이 발생하더라도 NotificationService 컴포넌트를 수정할 필요가 없어짐(OCP)
- 스프링의 빈 메서드에서 자가 호출이 일어나는 경우(@Transactional 예제): 자가 호출이 발생하면 호출되는 메서드에 적용된 AOP 애너테이션이 동작하지 않을 수 있다.
- doSomething2 메서드는 doSomething1 메서드의 자가 호출로 실행되므로, doSomething2에 걸려있는 Transactional이 실행되지 않음
- AOP가 프록시를 기반으로 동작하기 때문에 발생하는 현상: 메서드에 지정된 AOP 애너테이션이 수행되려면 반드시 프록시 객체를 통해 메서드가 실행돼야 한다.
- 메서드를 자가호출하는 상황에서는 프록시를 거치지 않고 클래스에 정의된 메서드를 곧바로 호출하게 된다.
3부. 테스트
12. 자동테스트
수동 테스트는 소모적이며, 시스템이 커질수록 누적된다. -> 그래서 테스트 코드를 작성해서 개발자의 부담을 줄여야 한다
- Postman으로도 자동테스트를 만들 수 있는지 몰랐음 https://www.postman.com/automated-testing/
- 12.1~12.2 파트를 읽으면서 시스템을 파악할 땐 물론 기본적인 코드도 봐야하지만, 테스트 코드를 통해 큰 시스템을 빠르게 파악할 수도 있겠구나 하는 생각이 들었다.
13. 테스트 피라미드
- unit test(80) - integration test(15) -e2e test(5, ex.cypress)
- 소형테스트 - 중형테스트 - 대형테스트 로 사용되는 리소스의 크기로 분류하니 조금 더 직관적이었다.
- 13.2에 H2를 활용한 테스트는 비결정적으로 동작할 가능성이 있다고 한다는것은 조금 의외였다.
- mock을 사용하는 것이 오히려 귀찮아서 H2나 디비 연결을 자주 사용했었는데, 그런 테스트들은 코드베이스 외의 다른 요소에 의존하기 때문에 테스트가 실패하더라도 디버깅이 어렵고 신뢰할 수 없음. -> 사실 H2가 다른 프로세스를 사용하니까 프로세스 상태와 프로세스 간 통신에도 의존할 것이 너무나도 당연하긴 한데 그 동안 이런 생각을 하지 못하고 테스트를 작성했던 것 같다.
14. 테스트 대역
코드 14.2~14.3에 UserServie에 DummyVerificationEmailSender를 주입하는 형태는 처음 보는 형태였음
(원래 Mock으로 만드는것은 많이 봤던거같은데 새로운 Sender를 재정의 해서 동작 상황을 다양하게 연출하는 것은 좋은 것 같다) 이 파트는 상세한 예시가 도움이 많이 되었다.
- 이런 implements를 통한 재정의를 테스트 과정에 사용하기 위해서는 추상화가 중요하고 의존성 역전이 잘 되어있어야 겠다
15. 테스트 가능성: 테스트하기 쉬운 코드일수록 좋은 설계일 학률이 높다.
- 테스트를 사용하는 목적은 두 가지가 될 수 있다: 회귀 버그 방지 / 좋은 설계를 얻기 위함
- 테스트는 입력을 쉽게 변경할 수 있고 + 출력을 쉽게 검증할 수 있을 때 작성하기 쉽다. <-> 숨겨진 입력이 존재하거나 숨겨진 출력이 있을 때 테스트를 검증하기 어려워진다.
- UserService 사용자 login 시각 갱신 예제
- login의 인수로 timestamp를 받는 것 까진 생각했는데 책에 나와있는대로 의존성을 외부로 미룬게 아닌가 하는 생각이 듦
- ClockHolder 인터페이스를 만들고, 의존성 주입/의존성 역전 -> 유연한 변경이 가능하다.
- 반환값의 경우 String 그 자체를 리턴하기보다는 확장성을 위한다면 DTO를 만들도록 한다.
- private 메서드는 내부 구현이기 때문에 테스트하지 않아도 된다. -> 만약 테스트 해야할정도로 중요하다는 생각이 든다면 책임을 잘못 할당한 경우이다.
- 서비스 컴포넌트를 테스트 해야할 때 객체 주입이 과하게 필요한 경우 서비스 컴포넌트의 세분화가 필요한 것일 수도 있다.
16. 테스트와 설계
- SRP: 테스트할 때 불필요한 의존성이 있다면 컴포넌트를 분리하는 것이 좋을 수 있다. (=단일 책임 위반)
- ISP: SRP와 마찬가지로 인터페이스는 기능 단위로 상세히 분리하는 것이 좋다.
- OCP, DIP: 유연한 설계를 했음을 확인하기 위해 컴포넌트 간 의존 관계를 전부 파악해 볼 수도 있지만, 실제 실행되는 배포환경과 다른 테스트환경에서 새로운 요구사항에 맞게 원활하게 실행되는지 확인하면 된다.
- 구현체를 상속하여 의존성 역전 원칙 없이도 테스트를 작성할 수 있으나, 서비스가 추상에 의존하도록 해야 세부 구현에 의존하지 않게 된다.
- LSP: 테스트는 유지하고 싶은 시스템의 상태를 모두 작성하는 것이다. 즉, 리스코프 치환 원칙에 관한 테스트를 작성함으로써, 파생 클래스 수정에도 이 원칙이 준수되는지 테스트를 통해 능동적으로 감시할 수 있다.
- 하지만 새로운 파생클래스가 처음부터 기본클래스를 대체하지 못하는 경우는 위반을 감지할 테스트가 없다. -> 단, 테스트를 고민함으로써 리스코프 치환을 유지할 수 없는 설계의 잘못된 점을 발견할수도 있다.
17. 테스트와 개발방법론
- TDD: Red-Green-Refactor
- Red: 아직 구현되지 않은 기능을 테스트 작성 -> 테스트 실패
- Green: 테스트를 통과시키기 위한 최소한의 코드 작성
- Refactor(=Blue): 가독성/유지보수성/성능을 높이기 위해 리팩터링 -> 단, 기능은 유지된 상태에서 코드의 구조만 변경
- TDD는 요구사항이 명확하지 않고 자주 변경되는 상황에서는 좋지 않다. (요구사항이나 인터페이스가 극단적으로 변경되는 경우)
- BDD: 사용자 행동(행동 명세)을 요구사항으로 먼저 만들고, 이를 테스트로 표현될 수 있게 만든다. -> TDD+DDD
- TDD의 경우 무엇을 어떻게 테스트해야하는지 알 수 없다.
- Give-When-Then 형식