리팩토링과 테스트 코드

리팩토링은 흔히 “코드를 더 깔끔하게 고치는 것” 정도로 이해되곤 하지만,
더 정확하게 말하면 동일한 입력에 대해 동일한 결과를 유지한 채, 코드 구조를 개선하는 작업이다.
즉, 겉으로 드러나는 동작은 바뀌지 않아야 하고, 내부 구조만 더 읽기 좋고 유지보수하기 쉬운 형태로 바뀌어야 한다.
이때 말하는 결과에는 단순히 반환값만 포함되는 것이 아니라, 사이드 이펙트 역시 포함된다고 볼 수 있다.
예를 들어 메서드가 반환하는 값은 같더라도, 원래 저장되던 데이터가 다르게 저장되거나,
예외가 다르게 발생한다면 그것은 더 이상 리팩토링이라고 보기 어렵다.
리팩토링과 테스트 코드는 거의 한 세트처럼 같이 이야기된다.
리팩토링 전에 테스트 코드가 먼저 준비되어 있으면,
적어도 테스트 코드가 검증하고 있는 범위 안에서는 결과가 변하지 않았는지 확인할 수 있다.
즉, 리팩토링을 한 뒤에도 테스트가 그대로 통과한다면,
“적어도 이 시나리오들에 대해서는 기존 동작이 유지되고 있다”는 신뢰를 얻을 수 있다.
테스트 코드는 단순히 기능이 잘 동작하는지 확인하는 용도만 있는 것이 아니다.
기존 코드를 고치거나 구조를 바꾸는 과정에서,
내가 의도하지 않게 원래 동작을 망가뜨리지 않았는지 확인하는 안전장치 역할도 한다.
테스트 코드 작성해보기
테스트 코드라고 하면 많은 사람들이 “정상적인 입력을 넣고 기대한 결과가 나오는지 확인하는 코드”를 먼저 떠올린다.
예를 들어 상품을 추가한 뒤 id로 조회했을 때,
방금 저장한 상품이 그대로 조회되어야 한다는 것은 아주 대표적인 정상 시나리오다.
이런 흐름을 코드로 검증하면 다음과 같은 테스트가 된다.
@SpringBootTest
@ActiveProfiles("test")
class SimpleProductServiceTest {
@Autowired
SimpleProductService simpleProductService;
@Test
@DisplayName("상품을 추가한 후 id로 조회하면 해당 상품이 조회되어야 한다.")
void productAddAndFindByIdTest() {
ProductDto productDto = new ProductDto("연필", 300, 20);
ProductDto savedProductDto = simpleProductService.add(productDto);
Long savedProductId = savedProductDto.getId();
ProductDto foundProductDto = simpleProductService.findById(savedProductId);
assertTrue(foundProductDto.getId()).isEqualTo(savedProductDto.getId());
assertTrue(foundProductDto.getName()).isEqualTo(savedProductDto.getName());
assertTrue(foundProductDto.getPrice()).isEqualTo(savedProductDto.getPrice());
assertTrue(foundProductDto.getAmount()).isEqualTo(savedProductDto.getAmount());
}
}
먼저 ProductDto를 하나 만들고, 서비스의 add()를 통해 저장한다.
그다음 저장된 결과에서 id를 꺼내고, 다시 findById()로 조회한다.
마지막으로 저장된 객체와 조회된 객체의 id, 이름, 가격, 재고 수량이 모두 같은지를 비교한다.
여기서 assert 계열 메서드는 테스트 코드에서 핵심적인 역할을 한다.
테스트는 결국 “무엇을 기대하는가”를 코드로 적는 작업인데,
assertTrue, assertEquals, assertThat 같은 구문은 그 기대를 명시하는 방법이다.
만약 기대와 다른 결과가 나오면 테스트는 실패하게 되고,
여러 테스트가 있어도 어떤 테스트가 깨졌는지를 한눈에 파악할 수 있다.
그리고 테스트 코드는 정상 동작만 검증하는 것이 아니다.
예외가 발생하는 상황도 충분히 “정상적인 기대 결과”가 될 수 있다.
예를 들어 존재하지 않는 상품 id로 조회했을 때 EntityNotFoundException이 발생하도록 설계했다면,
이 경우에는 예외가 발생하는 것이 오히려 올바른 동작이다. 이런 상황은 assertThrows()로 검증할 수 있다.
@Test
@DisplayName("존재하지 않는 상품 id로 조회하면 EntityNotFoundException이 발생해야 한다.")
void findProductNotExistIdTest() {
Long notExistId = -1L;
assertThrows(EntityNotFoundException.class, () -> {
simpleProductService.findById(notExistId);
});
}
이 테스트는 “없는 상품을 조회했을 때 실패해야 한다”는 것을 검증하는 것이 아니라,
“우리가 의도한 방식으로 실패해야 한다”는 것을 검증한다.
즉, 예외가 난다는 사실 자체가 문제가 아니라, 설계한 예외가 정확히 던져지는지를 확인하는 것이다.
또 한 가지 눈여겨볼 점은, 이번 테스트 클래스에서 @Autowired를 사용해 필드 주입 방식으로
SimpleProductService를 받고 있다는 점이다.
일반적인 애플리케이션 코드에서는 생성자 주입이 더 권장되는 경우가 많지만,
테스트 코드에서는 이런 필드 주입 방식이 큰 문제를 만들지 않는 경우가 있다.
테스트 코드는 애플리케이션 자체의 확장성보다는, 특정 기능을 빠르게 검증하는 목적이 더 크기 때문이다.
특히 여기서는 테스트 대상이 명확하고,
다른 다형적 구성까지 복잡하게 고려할 필요가 없어서 비교적 단순하게 사용할 수 있다.
@Transactional
데이터베이스를 사용하는 테스트를 작성할 때는 한 가지 중요한 문제가 생긴다.
테스트가 데이터를 직접 추가하거나 수정하면, 그 결과가 실제 데이터베이스에 남아버릴 수 있다는 점이다.
예를 들어 “상품 추가 후 조회” 테스트를 한 번 실행할 때마다 상품이 DB에 계속 누적된다면,
같은 테스트를 여러 번 돌렸을 때 결과가 달라질 수 있고, 다른 테스트에도 영향을 줄 수 있다.
즉, 테스트가 서로를 오염시키는 문제가 생긴다.
이런 상황을 막기 위해 테스트 코드에 @Transactional을 붙일 수 있다.
스프링에서 @Transactional은 하나의 논리적 트랜잭션 안에서 작업을 수행하게 해주는 어노테이션이다.
그리고 테스트 코드에 이 어노테이션이 붙으면, 보통 테스트가 끝난 뒤 변경 사항을 커밋하지 않고 롤백하는 방식으로 동작한다.
즉, 테스트 실행 중에는 실제로 DB에 insert/update/delete가 수행되지만,
테스트가 끝나면 그 모든 변경이 취소되어 원래 상태로 돌아간다.
예를 들어 다음처럼 사용할 수 있다.
@Transactional
@Test
이 두 가지가 함께 있을 때, 테스트 실행 중에는 트랜잭션이 열리고,
테스트가 끝나면 커밋이 아니라 롤백이 일어나도록 하는 것이다.
그 결과 테스트는 실제 데이터베이스를 사용하는 것처럼 검증할 수 있으면서도,
테스트가 끝난 뒤에는 데이터가 남지 않는다.
'공부 기록 > 이것이 백엔드 개발이다 With Java' 카테고리의 다른 글
| EP25. 상품 관리 애플리케이션에 객체지향 더하기 3편 (0) | 2026.04.27 |
|---|---|
| EP23. 상품 관리 애플리케이션에 객체지향 더하기 1편 (0) | 2026.04.25 |
| EP22. 상품 관리 애플리케이션에 데이터베이스 연동하기 3편 (0) | 2026.04.24 |
| EP21. 상품 관리 애플리케이션에 데이터베이스 연동하기 2편 (0) | 2026.04.23 |
| EP20. 상품 관리 애플리케이션에 데이터베이스 연동하기 1편 (0) | 2026.04.22 |