새소식

Welcome to the tech blog of Junior Backend Developer Seulbin Kang!

Backend

(BE 개발자 5개월차 독후감) [Unit Testing : 단위 테스트] 독후감 마무리 (feat. 가치 있는 테스트 작성하기)

  • -

👀 들어가기 전에

출퇴근 시간을 활용해 UnitTesting : 단위 테스트를 완독하였다. 테스트에 대한 내용이라 흥미롭게 읽을 수 있었고, 1차 독후감에 이어 핵심적으로 정리하고 싶은 내용들을 정리해보려 한다.

 

책을 읽으며 가장 인상 깊었던 부분은 크게 두 가지였다.

  1. 가치 있는 단위 테스트를 위해 기존 코드를 어떻게 리팩터링 할 것인가?
  2. 좋은 테스트를 만들기 위해 의존성을 어떻게 처리할 것인가?

👀 본론

테스트의 본질

테스트는 개발자에게 필수적인 도구다. 하지만 테스트 커버리지 자체가 목표가 되어서는 안된다. (1편 참고)

테스트의 본질적인 목적은 코드의 안정성 확보, 리팩터링 내성 향상에 있다. 

 

테스트를 작성하다 보면 자연스럽게 강결합, 숨겨진 의존성 문제를 마주하게 되고, 이를 해결하기 위해 코드 구조를 다시 고민하게 된다.

책에서는 이런 문제를 해결하기 위한 세 가지 핵심 원칙을 제시한다.


1. 암시적 의존성을 명시적으로 만들기

결국 도메인 모델은 직접적으로든 간접적으로든 프로세스 외부 협력자에게 의존하지 않는 것이 훨씬 깔끔하다. 도메인 모델은 외부 시스템과의 통신을 책임지지 않아야 한다.
- Unit Testing 239p - 

 

아래 두 가지의 코드를 비교해보자.

1) 나쁜 예시↓

public class User {
    private String email;
    
    public void changeEmail(String newEmail) {
        this.email = newEmail;
        
        // 도메인 모델이 직접 외부 시스템과 통신
        EmailGateway.sendConfirmation(newEmail);
        Database.updateUser(this);
    }
}

 

도메인 객체가 외부 시스템과의 통신 책임까지 함께 지고 있다. 이 경우 테스트는 외부 환경에 강하게 의존하게 되고 테스트 난이도는 급격하게 상승하게 된다.

 

2) 개선된 예시↓

public class User {
    private String email;
    
    // 도메인 로직만 처리
    public void changeEmail(String newEmail) {
        this.email = newEmail;
    }
    
    public String getEmail() {
        return email;
    }
}

// 외부 통신은 서비스 계층에서 처리
public class UserService {
    public void changeUserEmail(Long userId, String newEmail) {
        User user = userRepository.findById(userId);
        user.changeEmail(newEmail);
        userRepository.save(user);
        emailGateway.sendConfirmation(newEmail);
    }
}

 

결국 핵심은 객체의 책임을 명확히 분리하는 것이다. 도메인 모델은 비즈니스 규칙만을 담도록 만들어지며 외부 시스템과의 통신은 서비스 계층으로 올려버린다. 이렇게 분리하면 도메인 모델은 순수하게 테스트 가능한 객체가 된다.


2. 어플리케이션 서비스 계층의 도입

Spring 기반 개발자라면 MVC 구조에 익숙할 것이다.
하지만 왜 서비스 계층을 놓고 서비스 계층을 쓰고 있는지는 다시 한 번 생각해볼 필요가 있다.

계층 별 책임은 아래와 같다.

  • 도메인 계층 : 순수한 비즈니스 로직, 프로세스 내부 의존성만 가짐
  • 서비스 계층 : 비즈니스 로직 조율, 트랜잭션 관리
  • 컨트롤러 계층 : 외부 요청 처리, 응답 반환

서비스 계층을 도입하면 각 계층의 책임이 명확해지고, 테스트하기 쉬운 구조가 되는 것이다.

// Controller - 외부 요청 처리
@RestController
public class UserController {
    private final UserService userService;
    
    @PostMapping("/users/{id}/email")
    public ResponseEntity<Void> changeEmail(
        @PathVariable Long id, 
        @RequestBody EmailChangeRequest request
    ) {
        userService.changeUserEmail(id, request.getNewEmail());
        return ResponseEntity.ok().build();
    }
}

// Service - 비즈니스 로직 조율
@Service
public class UserService {
    private final UserRepository userRepository;
    private final EmailGateway emailGateway;
    
    @Transactional
    public void changeUserEmail(Long userId, String newEmail) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException());
        user.changeEmail(newEmail);
        userRepository.save(user);
        emailGateway.sendConfirmation(newEmail);
    }
}

// Domain - 순수 비즈니스 로직
public class User {
    private String email;
    
    public void changeEmail(String newEmail) {
        validateEmail(newEmail);
        this.email = newEmail;
    }
    
    private void validateEmail(String email) {
        if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new InvalidEmailException();
        }
    }
}

3. 어플리케이션 서비스 복잡도 낮추기

서비스 계층이 비대해질수록 테스트는 어려워진다. 

검증 로직, 유틸성 코드, 재구성 로직은 별도의 책임으로 분리하는 것이 좋다.

 

우선, 과도하게 복잡한 서비스 예시 코드를 작성해보면 아래와 같다.

@Service
public class UserService {
    public void changeUserEmail(Long userId, String newEmail) {
        User user = userRepository.findById(userId).orElseThrow();
        
        // 복잡한 검증 로직이 서비스에 산재
        if (newEmail == null || newEmail.isEmpty()) {
            throw new IllegalArgumentException();
        }
        if (!newEmail.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new IllegalArgumentException();
        }
        if (userRepository.existsByEmail(newEmail)) {
            throw new DuplicateEmailException();
        }
        
        user.changeEmail(newEmail);
        // ... 복잡한 후처리 로직
    }
}

위 코드를 보게 되면 하나의 서비스에서 하는 일이 정말 많고 복잡하게 얽혀있다. 이를 객체의 책임 분리 관점에서 아래와 같이 분리해볼 수 있다.

// 검증 로직 분리
public class EmailValidator {
    public void validate(String email) {
        if (email == null || email.isEmpty()) {
            throw new IllegalArgumentException("Email cannot be empty");
        }
        if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new InvalidEmailFormatException();
        }
    }
}

// 서비스는 조율만
@Service
public class UserService {
    private final EmailValidator emailValidator;
    
    @Transactional
    public void changeUserEmail(Long userId, String newEmail) {
        emailValidator.validate(newEmail);
        
        User user = userRepository.findById(userId)
            .orElseThrow(UserNotFoundException::new);
        
        if (userRepository.existsByEmail(newEmail)) {
            throw new DuplicateEmailException();
        }
        
        user.changeEmail(newEmail);
        userRepository.save(user);
        emailGateway.sendConfirmation(newEmail);
    }
}

 

핵심은 SOLID 원칙

결국 모든 내용은 SOLID원칙으로 귀결된다. 특히 단일 책임 원칙(Single Responsibility Principle)이 정말 중요하다.

객체의 책임을 명확히 하고 SOLID 원칙을 지키고자 한다면 (물론 몇가지 예외 상황이 발생할 수 있다.) 좋은 코드와 좋은 테스트 코드는 자연스럽게 따라오게 되는 것 같다.


외부 협력자 테스트

이외에도 책에서는 통합 테스트, 로그 서비스, DB 테스트 등 외부 협력자와 관련한 테스트 전략들도 다루고 있다.

핵심 원칙은

  • 시스템 끝단에서 비관리 의존성과의 상호작용을 검증하고
  • 컨트롤러와 비관리 의존성 사이 연결고리를 Mock 처리 하고
  • 이를 통해 회귀 방지와 리팩터링 내성을 향상한다.

모든 비관리 의존성이 동일한 수준의 하위 호환성을 필요로 하는 것은 아니므로 코드별로 적절히 판단하여 상호작용을 검증해야 한다.


👀 마치며..

Unit Testing : 단위 테스트를 통해 테스트에 대한 개념들을 확실히 정립할 수 있었다.

 

기존에 테스트 커버리지에 집착하던 태도를 버리고, 테스트의 본질을 고민하게 되었으며 코드를 작성하는 단계부터  설계를 먼저 생각하는 습관을 들이게 되었다. 테스트는 단순한 검증 수단이 아니라 좋은 설계를 유도하는 도구이다. 어떤 코드들이 테스트 작성이 어렵다면 그 원인은 대부분 코드 구조 자체에 있을 확률이 높다.

테스트하기 어려운 코드는 결국 유지보수가 어려운 코드이다.

 

이에 앞으로도 테스트를 기준으로 코드들을 돌아보며, 리팩터링 강한 구조를 만드는 습관을 꾸준히 이어가고자 한다.

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.