Clean Architecture 개념과 의존성 역전 원칙(DIP)
How does Clean Architecture follow to the DIP (Dependency Inversion Principle) ?
Aug 18, 2024
Clean Architecture 란?계층(Layer)Frameworks & DriversInterface Adapters Application Business RulesEnterprise Business Rules : EntitiesClean Architecture Principal의존성 역전 원칙고수준 모듈이 저수준 모듈에 의존Clean Architecture는 의존성 역전 원칙을 어떻게 지킬까?
클린 아키텍처(Clean Architecture)는 소프트웨어 개발 방법론 중 하나로, 복잡해진 프로젝트의 유지보수, 확장성, 다양한 요구사항에 유연하게 대응할 수 있도록 돕는 설계 방식이다. 이 방법론은 로버트 마틴(Robert C. Martin), 즉 Uncle Bob이 제안한 개념으로, 그의 저서 Clean Code에서도 그 중요성이 강조된다.
Clean Architecture 란?
클린 아키텍처의 핵심은 애플리케이션의 다양한 요소를 여러 계층(layer)으로 분리하여 관심사를 분리하는 것이다. 이를 통해 애플리케이션의 유지보수성과 유연성을 높일 수 있다.
클린 아키텍처는 각 계층 간에 독립적인 경계(boundary)를 설정하여, 한 계층의 요소들이 다른 계층에서 일어나는 일에 대해 알 필요 없이 동작할 수 있도록 설계한다. 이렇게 설계된 시스템은 변경에 강하고, 유지보수가 용이하며, 새로운 요구사항에도 유연하게 대응할 수 있다.
그렇다면 이런 설계는 왜 필요할까?
클린 아키텍처의 설계는 소프트웨어 개발 과정에서 필요한 시스템을 구축하고 유지하는 데 필요한 인적 자원을 최소화하기 위한 최적화 기술이다. 로버트 마틴은 이를 다음과 같이 설명했다:
“The goal of software architecture is to minimize the human resources required to build and maintain the required system.” ― Robert C. Martin, Clean Architecture 직역하면, “소프트웨어 아키텍처의 목표는 필요한 시스템을 구축하고 유지하는 데 필요한 인적 자원을 최소화하는 것입니다.” 이다.
즉, 소프트웨어 아키텍처의 궁극적인 목표는 시스템 구축과 유지에 필요한 인적 자원을 최소화하는 데 있으며, 클린 아키텍처는 이러한 목표를 달성하기 위한 효과적인 방법 중 하나라는 것이다.
계층(Layer)
위 다이어그램을 보면 4개의 계층이 있다. 각 계층은 다음과 같은 표현으로 대표될 수 있다.
Frameworks & Drivers
- 책임: 외부 시스템과의 인터페이스를 담당하는 가장 바깥쪽 계층이다. 이는 데이터베이스, 외부 API, 클라우드 서비스(예: AWS SDK), 서드파티 라이브러리 등을 포함한다.
- 역할: 이 계층은 애플리케이션의 외부와 상호작용하며, 애플리케이션의 나머지 부분에 대한 직접적인 접근을 제공하지 않는다. 인프라스트럭처 계층으로 표현될 수 있다.
- Devices, DB, External interface, UI …
Interface Adapters
- 책임: 데이터를 애플리케이션의 Use Case와 Entity가 사용하기 편리한 형태로 변환하거나, 그 반대로 변환한다. 즉, 외부 데이터와 내부 데이터 구조 간의 변환을 담당한다.
- 역할: 외부의 데이터가 애플리케이션 내부에서 사용될 수 있도록 하고, 애플리케이션 내부의 데이터를 외부 시스템에서 이해할 수 있도록 변환한다. 프레젠테이션 레이어로도 표현될 수 있다.
- Controller, Gateways, Presenters
Application Business Rules
- 책임: 애플리케이션의 핵심 비즈니스 로직과 규칙을 포함하지 않지만, 필수적인 비즈니스 로직을 구현한다. 이 계층은 Use Case를 통해 도메인의 핵심 비즈니스 로직을 호출하고, 애플리케이션의 특정 사용 사례를 처리한다.
- 역할: 애플리케이션의 특정 기능을 구현하며, 도메인 계층의 핵심 비즈니스 로직을 호출한다. 애플리케이션 레이어로 표현될 수 있다.
- UseCase
Enterprise Business Rules : Entities
- 책임: 프로젝트의 핵심 비즈니스 규칙이나 도메인 별 핵심 비즈니스 규칙을 보유한다. 변경 가능성이 가장 낮으며, 잘 설계된 스키마(모델)는 오랜 기간 동안 변경되지 않는다.
- 역할: 애플리케이션의 비즈니스 로직을 포함하며, 외부 계층(Frameworks & Drivers, Interface Adapters 등)의 변경에 영향을 받지 않아야 한다.
Business Rules ?
Application Business와 Enterprise Business의 구분이 다소 헷갈릴 수 있다. "핵심"과 "필수"라는 표현이 비슷하게 느껴질 수 있기 때문이다. 이를 RPG 게임과 은행을 예로 들어 설명해보면,
RPG 게임을 예로 들면,
- 특정 레벨에 도달해야만 열리는 퀘스트나, 특정 퀘스트를 완료해야만 열리는 지도는 Application Business Rules에 해당한다.
- 반면, 경험치 획득 공식, 특정 종족의 고유 특성, 아이템 거래 방식 등은 특정 상황이나 조건에 구애받지 않고 게임 전반에 적용되는 Enterprise Business Rules이다.
은행을 예로 들면,
- 특정 서비스나 상품에 따라 이자를 지급하는 방식은 Application Business Rules로 볼 수 있다.
- 반면, 모든 대출 상품이 일정 비율의 금리를 가져야 한다는 정책이나 고객 신원 확인 절차는 Enterprise Business Rules에 해당한다.
Application Rules과 Enterprise Rules은 특정 상황이나 메서드에 따라 적용되느냐, 도메인 전체에 걸쳐 적용되느냐에 따라 구분된다. 더 정확히 말하면, 적용 범위와 변경 가능성에 따라 이 두 규칙이 달라진다.
Clean Architecture Principal
클린 아키텍처(Clean Architecture)는 소프트웨어 개발에서 유지보수성, 유연성, 독립성을 극대화하기 위한 원칙을 다음과 같이 정의한다.
- Separation of Concerns(관심사 분리)
- 정의: 클린 아키텍처는 애플리케이션의 각 계층이 명확한 관심사와 책임을 가지도록 하여, 계층 간의 상호작용을 독립적으로 관리한다. 각 계층은 자신의 역할에만 집중하며, 다른 계층의 세부사항에 대해 알 필요가 없다.
- 목표: 시스템의 각 부분이 독립적으로 개발, 변경, 확장될 수 있도록 하여 복잡성을 줄이고 유지보수를 용이하게 한다.
- Depenency Rule(종속성 규칙)
- 정의: 종속성 규칙은 소스 코드의 종속성이 항상 내부 계층으로 향해야 한다는 원칙이다. 즉, 외부 계층은 내부 계층의 세부사항을 알 필요가 없으며, 내부 계층이 외부 계층의 세부사항에 의존하지 않아야 한다.
- 목표: 계층 간의 의존성을 관리하여 내부 비즈니스 로직이 외부의 변화에 영향을 받지 않도록 한다. 이는 계층 간의 독립성을 유지하고 시스템의 안정성을 보장한다.
- Testability(테스트 가능성)
- 정의: 클린 아키텍처는 UI와 외부 종속성과 독립적으로 테스트할 수 있는 구조를 지향한다. 이는 단위 테스트를 통해 애플리케이션의 핵심 기능을 검증할 수 있도록 설계되어야 하며, e2e(End-to-End) 테스트나 통합 테스트 없이도 핵심 기능을 테스트할 수 있어야 함을 의미한다.
- 목표: 애플리케이션의 주요 기능을 독립적으로 테스트하여 버그를 조기에 발견하고, 변경 시에 기존 기능이 잘 동작하는지 검증할 수 있도록 한다.
- Platform Independence(플랫폼에 독립적)
- 정의: 비즈니스 로직은 특정 프레임워크, 기술, 또는 플랫폼에 의존하지 않고 독립적으로 동작해야 한다. 데이터베이스, 프레임워크, 또는 기술 스택의 변경이 비즈니스 로직에 영향을 미쳐서는 안 된다.
- 목표: 시스템이 다양한 기술 환경에서 유연하게 동작할 수 있도록 하여, 특정 기술의 변화나 업데이트가 비즈니스 로직에 영향을 미치지 않도록 한다.
의존성 역전 원칙
CA에서 의존성은 의존성 역전 원칙에 따라 관리된다. 의존성 역전 원칙은 고수준 모듈(Layer)가 저수준 모듈(Layer)에 의존해선 안된다라는 원칙이다. 대표적으로 아래와 같은 핵심을 가지고 있다.
의존성 역전 원칙의 핵심:
- 고수준 모듈(예: Controller)은 저수준 모듈(예: Service)에 의존해서는 안된다.
- 둘 다 추상화(인터페이스)에 의존해야 한다.
고수준 모듈이 저수준 모듈에 의존해서는 안된다는게 어떤 의미일까?
고수준 모듈이 저수준 모듈에 의존
// Presentaion Layer public class SignUpController { private SignUpService signUpService; public SignUpController() { this.signUpService = new SignUpService(); } public String signUp(String username, String email, String password) { return signUpService.registerUser(username, email, password); } } // Application Layer public class SignUpService { private UserRepository userRepository; public SignUpService() { this.userRepository = new UserRepositoryImpl(); } public String registerUser(String username, String email, String password) { User user = new User(username, email, password); return userRepository.save(user); } }
위 코드에서 볼 수 있듯이, 고수준 모듈인 SignUpController가 저수준 모듈인 SignUpService에 직접 의존하고 있다. 이로 인해 다음과 같은 문제가 발생할 수 있다.
- 유연성 부족(강결합)
코드가 변경될 때 모든 의존 코드가 수정되어야 할 위험이 있다. SignUpController가 SignUpService의 구체적인 구현체를 직접 생성하고 사용하기 때문에, SignUpService에 변경이 생길 경우 SignUpController도 함께 수정해야 할 가능성이 높다. 이는 시스템의 유연성을 저하시킨다.
- 테스트 코드 구성의 어려움
테스트를 작성할 때 SignUpService가 UserRepositoryImpl에 직접 의존하므로, 유닛 테스트를 위해 실제 구현체를 사용해야 한다. 이는 테스트의 격리성을 낮추고, 외부 의존성에 영향을 받을 수밖에 없는 복잡한 테스트 환경을 초래한다. 또한, UserRepositoryImpl을 Mocking하는 것이 어려워 테스트 작성이 더욱 복잡해진다.
Clean Architecture는 의존성 역전 원칙을 어떻게 지킬까?
위와 같은 문제를 CA에서는 아래와 같은 방식으로 해결한다.
// Presentation Layer public class SignUpController { private SignUpUseCase signUpUseCase; public SignUpController(SignUpUseCase signUpUseCase) { this.signUpUseCase = signUpUseCase; } public String signUp(String username, String email, String password) { return signUpUseCase.execute(new SignUpRequest(username, email, password)); } } // Application Layer public interface SignUpUseCase { String execute(SignUpRequest request); } public class SignUpService implements SignUpUseCase { private UserRepository userRepository; public SignUpService(UserRepository userRepository) { this.userRepository = userRepository; } @Override public String execute(SignUpRequest request) { User user = new User(request.getUsername(), request.getEmail(), request.getPassword()); return userRepository.save(user); } } public class SignUpRequest { private String username; private String email; private String password; // Constructor, getters and setters } // Domain Layer public class User { private String username; private String email; private String password; public User(String username, String email, String password) { this.username = username; this.email = email; this.password = password; } // Getters and setters } public interface UserRepository { String save(User user); } // Infrastructure Layer public class UserRepositoryImpl implements UserRepository { @Override public String save(User user) { // 실제 데이터베이스 저장 로직 return "User saved: " + user.getUsername(); } }
- 각 계층은 실제 구현부를 직접 주입받아 사용되는 것이 아닌 interface를 통한 추상화를 통해 소통한다.(꼭 interface가 아니여도 된다. 구현부에 직접적으로 연결되어 강결합으로 동작하는 식만 아니면 된다.)
- 도메인 계층(User, UseRepository)는 다른 계층에 의존하지 않는다.
- Application 계층은 Domain 계층에만 의존한다.
- Presentation 계층은 Application 계층의 interface(usecase)에만 의존한다.
4가지 이유를 들어 개선 방법을 설명했지만, 핵심은 간단하다. 종속성 규칙을 통해 소스 코드의 종속성이 내부로만 향하도록 구현한 것이다.
이해가 어려운 분들을 위해 좀 더 자세히 설명하자면, CA가 어떻게 종속성 규칙을 지켰는지 살펴보겠다. 강결합된 코드의 경우, 종속성 규칙을 유지하기 위해 다음과 같은 접근을 했다.
// controller public Response<User> getUserById(Long id) { return new Response(userService.getUserById(id)); } // service public User getUserById(Long id) { ... return user; }
위 코드의 상태에서 만약 Controller의 응답 타입이 CustomResponse Type으로 변경된다면 Service의 return type도 Controller의 변경에 따라 같이 변경되야 한다. 이 말은,
Controller
의 구현이 변경되면 Use Case
도 함께 변경해야 한다는 것을 의미한다.// controller public Response<User> getUserById(String id) { return new Response(userService.getUserById(id)); } // service public User getUserById(String id) { ... return user; }
하지만 추상화된 interface를 사용하고 있다면 아래와 같이 Service의 비즈니스 로직을 변경하지 않고도 Controller만을 수정하여 레이어간 불필요한 간섭을 막을 수 있다.
public interface UserUseCase { User getUserById(Long id); } @Controller @Require...어쩌구 저쩌구 public class UserController { private final UserUseCase userUseCase; public Response<UserDto> getUserById(Long id) { // Controller는 반환 타입이 변경되어도, Service와의 인터페이스는 동일하게 유지 User user = userUseCase.getUserById(id); UserDto userDto = Mapper.userDtoMapper(user); // 새로운 변환 로직 추가 return new Response<>(userDto); } } // 어디간의 Presentation 계층의 Dto Mapper public UserDto userDtoMapper(User user) { // 변환 로직 구현 // UserDto userDto = ...; return userDto; }
개선된 점
- UserUseCase 인터페이스를 통해 Controller는 Service의 구체적인 구현에 의존하지 않고, 인터페이스를 통해 소통한다. 이는 계층 간의 강결합을 방지한다.
- UserService는 User를 UserDto로 변환하는 책임을 가지며, Controller는 UserDto를 사용하여 응답을 반환한다. 이렇게 함으로써 Controller의 응답 타입이 변경되더라도 Service의 비즈니스 로직은 영향을 받지 않게 됐다.
- UserService는 UserRepository에만 의존하며, Controller는 UserUseCase 인터페이스에만 의존한다. 이는 각 계층이 독립적으로 동작하도록 보장한다.
이런 변화와 동작을 통해 의존성 규칙(의존성 역전원칙(DIP))와 단일 책임 원칙을 지키는 Application을 만들 수 있게 됐다.
이러한 구조를 채택함으로써 얻는 또 다른 이점은 UseCase의 실제 구현을 더욱 간단하게 유지할 수 있다는 것이다. UseCase는 클라이언트 응답 값에 대한 고민 없이 내부 데이터 구조(Entity, Schema 등)를 간단히 반환할 수 있다. 실제 클라이언트에 전달될 데이터의 매핑 작업은 Adapter(Controller)에서 처리된다. 이로 인해 비즈니스 로직이 단순해지고, 계층 간 독립성이 유지되며, 시스템의 유지보수성이 향상된다.
좀 더 디테일하게 들어가면, Controller에서 받는 요청 객체와 UseCase에서 사용하는 도메인 객체를 별도로 정의할 수 있다. 이렇게 하면 Controller와 UseCase 사이의 데이터 전송 형식을 분리하여 유연성을 높일 수 있다.
예를 들어, Controller는 외부의 JSON 형식 데이터를 처리하고, 이를 UseCase가 필요로 하는 형식으로 변환하여 전달할 수 있다. 만약 향후 데이터 전송 방식이 JSON에서 RPC로 변경되더라도, 데이터 형식만 조정하면 되므로 Controller와 UseCase 간의 통합을 쉽게 유지하면서 시스템의 유연성을 확보할 수 있다.
결론적으로, 클린 아키텍처는 관심사 분리, 종속성 규칙, 테스트 가능성, 플랫폼 독립성을 원칙으로 삼아, 소프트웨어의 유지보수성과 유연성을 높이는 설계 접근 방식을 제시한다. 이를 통해 복잡한 시스템의 구조를 명확히 하고, 개발 및 유지보수 작업을 효율적으로 수행할 수 있게 만들 수 있다.
참조(:cc)
Share article