DIP : 변경에 유연하고 테스트하기 좋은 코드 설계
1. 들어가며
클린 코드 관련 저서들이 공통적으로 강조하는 내용은 단연 변경에 유연하고 테스트하기 좋은 코드의 중요성입니다. 소프트웨어는 항상 변경이 발생하기 쉽습니다. 요구 사항이 변경될 때마다 코드를 대거 수정해야 한다면 유지보수 측면에서 좋지 않습니다. 요구 사항 변경에 유연하게 대처할 수 있는 구조의 코드는 소프트웨어의 지속적인 성장을 위한 초석입니다.
a code smell is any characteristic in the source code of a program that possibly indicates a deeper problem.
어떻게 하면 작성한 코드 구조가 좋고 나쁜지를 판별할 수 있을까요? 저희 프로젝트 팀은 테스트하기 쉬운 코드 구조를 지표로 삼았습니다. 만약 작성한 코드가 테스트하기 어렵다면 코드에서 냄새가 난다고 간주했습니다. 코드 냄새는 컴퓨터 프로그래밍 코드에서 더 심오한 문제를 일으킬 가능성이 있는 프로그램 소스 코드의 특징을 가리킵니다. 테스트하기 어려운 코드는 설계가 잘못되었음을 시사하며, 설계를 개선할 경우 문제가 해결될 공산이 큽니다.
이번 글에서는 프로젝트 Pick-Git 개발 중 DIP를 통해 변경에 유연하고 테스트하기 좋은 코드를 설계한 사례를 공유하고자 합니다.
2. Backgrounds
프로젝트 Pick-Git은 Github Repo 기반 개발 장려 SNS이며, 어플리케이션 특성상 GitHub Open API를 사용해 클라이언트의 GitHub 통계 정보를 시각화합니다.
프로젝트 아키텍쳐는 Presentation - Application - Domain - Infrastructure 순의 Layered Architecture를 기반으로 구성되었습니다. 상위 계층은 인접한 하위 계층만을 의존하는 것이 특징입니다.
3. Problems
UserService.java
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {
private final UserRepository userRepository;
private final GitHubContributionCalculator gitHubContributionCalculator;
public ContributionResponseDto calculateContributions(ContributionRequestDto requestDto) {
User user = userRepository.findByName(requestDto.getUsername())
.orElseThrow(InvalidUserException::new);
Contribution contribution = gitHubContributionCalculator
.calculate(requestDto.getAccessToken(), user.getName());
return ContributionResponseDto.builder()
.starsCount(contribution.getStarsCount())
.commitsCount(contribution.getCommitsCount())
.prsCount(contribution.getPrsCount())
.issuesCount(contribution.getIssuesCount())
.reposCount(contribution.getReposCount())
.build();
}
}
GitHubContributionCalculator.java
@RequiredArgsConstructor
@Component
public class GithubContributionCalculator {
@Value("${github.contribution.url}")
private final String url;
public Contribution calculate(String accessToken, String username) {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setBearerAuth(accessToken);
RequestEntity<Void> requestEntity = RequestEntity
.get(url)
.headers(httpHeaders)
.build();
RestTemplate restTemplate = new RestTemplate();
return restTemplate.exchange(requestEntity, Contribution.class)
.getBody();
}
}
GitHub Open API를 바탕으로 클라이언트의 연동된 GitHub 계정의 통계 정보를 조회하는 예제 코드입니다. 위 서비스 로직은 Application 계층이 Domain 계층만 의존하는 것이 아니라, Infrastructure 구현 기술까지 직접 의존하는 문제가 있습니다.
3.1. 변경의 가능성
서두에서 언급했듯이, 소프트웨어는 항상 변경이 발생하기 쉽습니다. 만약 요구사항 변경으로 인해 현재 프로젝트가 더이상 GitHub 플랫폼 연동을 지원하지 않고 GitLab 등 다른 플랫폼을 지원하게 된다면 어떻게 될까요?
UserService.java
Contribution contribution = gitHubContributionCalculator
.calculate(requestDto.getAccessToken(), user.getName());
GithubContributionCalculator
뿐만 아니라 GitHub API 관련 Infrastructure 구현체를 사용하는 코드들이 전부 수정되어야 합니다. 만약 프로젝트 전역에 걸쳐 GitHub API 관련 Infrastructure 구현체를 사용하는 곳이 1000곳이라면, 1000번의 코드 수정이 발생하는 것입니다. 이는 코드가 변경에 유연하지 못하며 유지보수하기 힘들다는 점을 시사합니다.
3.2. 테스트하기 어려운 구조
UserServiceIntegrationTest.java
@Autowired
private UserRepository userRepository;
@Autowired
private UserService userService;
@MockBean
private GitHubContributionCalculator gitHubContributionCalculator;
@DisplayName("사용자는 활동 통계를 조회할 수 있다.")
@Test
void calculateContributions_LoginUser_Success() {
// given
userRepository.save(UserFactory.user());
ContributionRequestDto requestDto = ContributionRequestDto.builder()
.accessToken("access-token")
.username("test-user-61")
.build();
ContributionResponseDto contributions = getExpectedContributionResponseDto();
given(gitHubContributionCalculator.calculate("access-token", "test-user-61")).willReturn(contributions);
// when
ContributionResponseDto responseDto = userService.calculateContributions(requestDto);
// then
assertThat(responseDto)
.usingRecursiveComparison()
.isEqualTo(contributions);
}
GitHubContributionCalculator
는 RestTemplate을 통해 GitHub로 API 요청을 보냅니다. 이 때, GitHub OAuth 로그인을 통해 얻은 실제 Access Token을 Header에 담습니다. 이에 대한 테스트 코드는 어떻게 작성할까요? 외부 API와의 통신은 직접 테스트하기 현실적으로 번거롭고 어려움이 많습니다. 외부 브라우저를 통해 로그인하고 실제 GitHub Access Token을 가져와 테스트 코드에 입력해야하기 때문입니다.
따라서 주로 Mocking하는 방식을 주로 사용합니다. 그러나 슬라이스 테스트가 아닌 통합 테스트에 매번 Mockito 혹은 RestClientTest 작업을 해주는 것 또한 번거롭습니다.
4. DIP(Dependency Inversion Principle)
우리가 다루는 모듈은 고수준 모듈과 저수준 모듈로 나눌 수 있습니다. 고수준 모듈이란 의미있는 단일 기능을 제공하는 모듈이며, 저수준 모듈은 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현인 모듈입니다. Layered Architecture 상에서 Application 및 Domain 등의 고수준 모듈은 Infrastructure라는 저수준 모듈을 의존합니다. 그 결과, 앞서 언급한 것처럼 구현 부분의 변경에 유연하지 못하고 테스트하기 어렵다는 문제점이 발생합니다.
DIP(Dependency Inversion Principle)이란 의존 관계를 역전시켜서 저수준 모듈이 고수준 모듈에 의존하도록 구현하는 것을 의미합니다. 고수준 모듈이 저수준 모듈을 직접 의존하는 것이 아니라, 저수준 모듈이 인터페이스 등 추상을 매개체로 고수준 모듈을 참조하도록 합니다. 이를 통해 구현 기술과 관련된 종속성을 쉽게 제거할 수 있습니다.
PlatformContributionCalculator.java
public interface PlatformContributionCalculator {
Contribution calculate(String accessToken, String username);
}
GitHubContributionCalculator.java
@RequiredArgsConstructor
@Component
public class GithubContributionCalculator implements PlatformContributionCalculator {
// ...
}
UserService.java
@RequiredArgsConstructor
@Transactional
@Service
public class UserService {
private final UserRepository userRepository;
private final PlatformContributionCalculator platformContributionCalculator;
public ContributionResponseDto calculateContributions(ContributionRequestDto requestDto) {
User user = userRepository.findByName(requestDto.getUsername())
.orElseThrow(InvalidUserException::new);
Contribution contribution = platformContributionCalculator
.calculate(requestDto.getAccessToken(), user.getName());
return ContributionResponseDto.builder()
.starsCount(contribution.getStarsCount())
.commitsCount(contribution.getCommitsCount())
.prsCount(contribution.getPrsCount())
.issuesCount(contribution.getIssuesCount())
.reposCount(contribution.getReposCount())
.build();
}
}
Application 계층이 Infrastructure 계층의 구현체가 아닌 Domain 계층의 PlatformContributionCalculator
인터페이스를 의존하도록 코드를 변경했습니다. 만약 요구사항 변경으로 인해 저수준 모듈이 GitHub 플랫폼 연동에서 GitLab 플랫폼 연동으로 변경되더라도, 고수준 모듈에서의 변경을 최소화할 수 있게 됩니다.
UserServiceIntegrationTest.java
@Autowired
private UserRepository userRepository;
private UserService userService;
@BeforeEach
void setUp() {
PlatformContributionCalculator platformContributionCalculator = (accessToken, username) -> {
// do something
};
userService = new UserService(userRepository, platformContributionCalculator);
}
또한 테스트를 진행할 때 PlatformContributionCalculator
에 적합한 Mock Object를 주입할 수 있게 됩니다. 따라서 GitHub 서버가 아닌 Mock 서버로 API 요청을 보내게끔 테스트 대역을 조절함으로써, Access Token으로 인한 문제에서 벗어나 자동화된 테스트를 쉽게 작성할 수 있습니다.
InfrastructureTestConfiguration.java
@TestConfiguration
public class InfrastructureTestConfiguration {
@Bean
public PlatformContributionCalculator platformContributionCalculator() {
return new MockContributionCalculator();
}
}
테스트 클래스에 정의하는 것이 번거롭다면 대역을 테스트용 Bean으로 주입해 여러 통합 테스트에서 사용할 수 있습니다.
5. 마치며
어떤 경우에 인터페이스를 추출하고 DIP를 적용해야할지 등에 대해 아직 감이 잡히지 않는다면, 우아한형제들 기술 블로그의 안정된 의존관계 원칙과 안정된 추상화 원칙에 대하여 글을 참고하시면 큰 도움이 되실겁니다. 의존은 안정적인 쪽(Presentation -> Application -> Domain -> Infrastructure)으로 향해야 한다는 글입니다.
안정성이란 추상성을 내포한다고 말하기 때문에 따라서 의존 관계는 추상성의 방향으로 흘러야 합니다. 안정된 추상화 원칙은 안정적인 패키지는 그 안정성 때문에 확장이 불가능하지 않도록 추상적이기도 해야하며, 거꾸로 이 원칙에 따르면 불안정한 패키지는 구체적이어야 하는데, 그 불안정성이 그 패키지 안의 구체적인 코드가 쉽게 변경될 수 있도록 허용하기 때문입니다.
Email 발송, Push, SMS 발송, Logging 등등 인프라성 코드는 거의 불안정성이 0에 가깝습니다(안정적). 호출자는 매우 많은데 그 자신이 의존하는 것은 별로 많지 않은 경우가 많습니다. 그러면서도 그 구현체는 시스템의 성장에 따라 바뀌기 쉽습니다(Email의 경우 SMTP → DB → MQ). 안정성이 높으면 변경 대응을 위해 추상적이어야 합니다. 따라서 인프라성 Service는 거의 무조건 interface를 구현해야 합니다.
DIP가 항상 만능 은 아닙니다. Runtime에 의존 대상이 결정되기 때문에 코드를 이해하거나 디버깅하기 어려워집니다. 처음부터 모든 의존 관계에 인터페이스를 추출하여 DIP를 적용하면 코드의 복잡도가 높아집니다. 특정 모듈의 변경 가능성 및 의존도 등을 다각도로 검토하여 인터페이스 추출과 DIP를 고려해야 합니다.