리팩토링
: 동일한 입력에 대해 결과의 변경 없이 코드의 구조가 개선되는 것
테스트 코드
: 작성한 로직을 테스트하기 위한 코드
코드를 잘못 수정하는 경우, 기존 작동과 달라질 가능성이 있다. 때문에 우리가 의도한 대로 코드가 잘 수정되었는지 알아차리기 위해서 테스트 코드를 돌려보는 것이 좋다.
리팩토링을 하는 경우 기능 변경을 의도하지 않기 때문에 테스트 코드에 맞춰서 원래 기능대로 작동하도록 수정해야 한다.
다만, 테스트 코드는 작성한 테스트 코드에 한해 기존과 동일하게 작동한다는 사실만을 보장한다. 테스트 코드로 작성하지 않은 부분에서는 여전히 버그가 발생할 수 있으니 주의하자.
✳️ 목표
✅ ModelMapper를 사용하던 코드 제거
✅ 우리가 만든 코드로 Product와 ProductDto 간 변환 수행
ModelMapper를 제거하는 리팩토링을 제거하기 위해서는 해당 코드가 사용되고 있는 SimpleProductService에 대한 테스트 코드를 작성하는 것이 적절하다.
→ SimpleProductService 코드를 마우스 우클릭 [Generate..] - [test...] 선택
build.gradle.
dependencies {
(생략)
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mockito:mockito-core:4.8.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test', Test) {
useJUnitPlatform()
}
후... 이거 때문에 한참을 헤맸는데!
testImplementation 'org.mockito:mockito-core:4.8.0'
이 부분을 dependency에 추가해주지 않으면 테스트 코드가 전혀 돌아가지 않는다.
또한 아래 tasks.named() 함수에 Test 클래스를 추가 인자로 넣어주었다. 이는 해당 작업이 반드시 Test 타입임을 명시적으로 지정하는 것이다.
또 ProductDto에 생성자를 추가해주어야 코드가 돌아갔다...
ProductDto.java
public ProductDto() {
}
SimpleProductService.java
package kr.co.hanbit.product.management.application;
import kr.co.hanbit.product.management.presentation.ProductDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.junit.jupiter.api.Assertions.*;
@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);
System.out.println(savedProductDto.gertId() == foundProductDto.getId());
System.out.println(savedProductDto.gertName() == foundProductDto.getName());
System.out.println(savedProductDto.gertPrice() == foundProductDto.getPrice());
System.out.println(savedProductDto.gertAmount() == foundProductDto.getAmount());
}
}
@SpringBootTest
: '스프링 컨테이너가 뜨는 통합 테스트'를 위해 사용하는 애너테이션@ActiveProfiles
: 테스트 코드에서 사용할 Profile 지정 → 해당 클래스를 빈으로 등록@Test
: 해당 메서드가 테스트 코드라는 것을 의미@DisplayName
: 해당 테스트 코드의 이름 지정매번 테스트 코드에서 true/false 여부를 확인하고 싶지 않기 때문에! 자동으로 체크하도록 바꿔줄 것이다.
import문에서 Assertions
를 활용하면 가능하다.
SimpleProductService.java
package kr.co.hanbit.product.management.application;
(생략)
import static org.junit.jupiter.api.Assertions.*;
@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(savedProductDto.getId() == foundProductDto.getId());
assertTrue(savedProductDto.getName() == foundProductDto.getName());
assertTrue(savedProductDto.getPrice() == foundProductDto.getPrice());
assertTrue(savedProductDto.getAmount() == foundProductDto.getAmount());
}
}
import static
은 import한 클래스들의 static 메서드들을 클래스 이름을 입력하지 않고도 사용하도록 만들어준다. 때문에 assertTrue()
를 메서드 이름만으로도 사용할 수 있다. (아니었다면...? Assertions.assertTrue()
이런식으로 아주 길게 계속 입력해줘야 함...)
테스트 코드에 반드시 포함 되어야 하는 코드 중 하나는 바로 예외 발생에 관한 것이다. 우리가 정의했던 EntityNotFoundException 예외 발생에 대한 테스트 코드를 추가할 것이다.
SimpleProductService.java
package kr.co.hanbit.product.management.application;
(생략)
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@ActiveProfiles("test")
class SimpleProductServiceTest {
(생략)
@Test
@DisplayName("존재하지 않는 상품 id로 조회하면 EntityNotFoundException이 발생해야 한다.")
void findProductNotExistIdTest() {
Long notExistId = -1L;
assertThrows(EntityNotFoundException.class, () -> {
simpleProductService.findById(notExistId );
});
}
}
예외 발생을 테스트하기 위해서는 assertThrows()
메서드를 사용한다.
EntityNotFoundException.class
존재할 수 없는 id -1을 사용한다. -1을 findById의 인자로 넘겨주면 Product를 찾을 수 없기 때문엔 EntityNotFoundException이 발생한다.
여기까지 우리가 작성한 것은 ListProductRepository에서 Product를 찾지 못한 경우 예외를 던져주는 것이다. DatabaseProductRepository에서는 별도로 예외 처리를 하지 않았다.
하지만 우리 예상과 다르게 Profile을 prod로 바꿀 경우 EmptyResultDataAccessException
이 발생한다. 이를 EntityNotFoundException으로 바꿔보도록 할 것이다!
스택 트레이스를 확인하면 namedParameterJdbcTemplete.queryForObject를 호출하는 지점에서 예외가 발생한 것을 알 수 있다. 이 부분을 try-catch로 감싸서 예외를 잡고 우리가 원하는 새로운 예외로 던져주면 된다.
DatabaseProductRepository.java
package kr.co.hanbit.product.management.infrastructure;
(생략)
@Repository
@Profile("prod")
public class DatabaseProductRepository implements ProductRepository {
(생략)
public Product findById(Long id) {
SqlParameterSource namedParameter = new MapSqlParameterSource("id", id);
Product product = null;
try {
product = namedParameterJdbcTemplate.queryForObject(
"SELECT id, name, price, amount FROM products WHERE id=:id",
namedParameter,
new BeanPropertyRowMapper<>(Product.class)
);
} catch (EmptyResultDataAccessException exception) {
throw new EntityNotFoundException("Product를 찾지 못했습니다.");
}
return product;
}
}
동일성(Identity)는 두 객체의 메모리 주소가 같음을 의미한다.
동등성(Equality)는 두 객체의 값이 같음을 의미한다.
동일하다는 것은 완전히 머리 끝부터 발끝까지, DNA 하나하나까지 전부 동일한 클론이라 생각하고, 동등하다는 것은 그냥 키만 같아서 눈높이가 같다, 동등하다 정도로 외우자.
Profile을 prod로 해둔 채로 처음 작성한 테스트 코드로 돌아갈 경우 실패한다...!
id에 대한 비교는 성공했으나 name에 대한 비교는 실패한다. 이유는 동일성 비교를 하고 있기 때문이다.
product = namedParameterJdbcTemplate.queryForObject(
"SELECT id, name, price, amount FROM products WHERE id=:id",
namedParameter,
new BeanPropertyRowMapper<>(Product.class)
);
DatabaseProductRepository를 사용할 때의 savedProductDto의 name과 foundProductDto의 name은 서로 다른 String 인스턴스이다. 그 이유는 findById 메서드에서 애플리케이션 외부에 있는 존재인 데이터베이스에 저장된 상품 데이터를 조회하여 새로운 Product 인스턴스를 생성하기 때문이다. 때문에 같은 값을 가지더라도 완전히 다른 String 인스턴스이다.
public Product findById(Long id) {
return products.stream()
.filter(product -> product.sameId(id))
.findFirst() // 이 부분!
.orElseThrow(() -> new EntityNotFoundException("Product를 찾지 못했습니다."));
}
하지만 ListProductRepository 애플리케이션 메모리상에 존재하는 리스트에서 저장되어 있던 Product 인스턴스를 꺼내 와서 반환한다. Product가 새로 생성되지 않으며 따라서 완전히 동일한 것이기에 에러가 나지 않는다.
우리는 인스턴스가 동일한지 비교한지를 비교하고 싶은 것이 아니기 때문에 두 값을 비교하는 코드를 변경해주어야 한다.
아주 간단하다! equals()
메서드를 사용하면 된다.
SimpleProductServiceTest.java
ProductDto foundProductDto = simpleProductService.findById(savedProductId);
assertTrue(savedProductDto.getId().equals(foundProductDto.getId()));
assertTrue(savedProductDto.getName().equals(foundProductDto.getName()));
assertTrue(savedProductDto.getPrice().equals(foundProductDto.getPrice()));
assertTrue(savedProductDto.getAmount().equals(foundProductDto.getAmount()));
왜 id에 대한 비교는 동일성 비교에서도 통과했는가?
👉🏻 id는 동일성 비교를 하더라도 같은 Long 인스턴스를 반환하기 때문이다. 자바에서는 일정 범위의 Long 인스턴스를 캐시를 생성하여 재사용한다.
prod Profile로 실행하면 테스트 코드가 실행될 때마다 Product가 하나씩 추가되는데, 괜찮은가?
쌉 no.
테스트 코드에서 실행된 데이터가 데이터베이스에 반영되지 않도록 만드는 방법은 간단하다. 테스트 메서드 위에 @Transactional
이라는 애너테이션을 추가하면 된다.
SimpleProductServiceTest.java
@Autowired
SimpleProductService simpleProductService;
@Transactional
@Test
@DisplayName("상품을 추가한 후 id로 조회하면 해당 상품이 조회되어야 한다.")
void productAddAndFindByIdTest() {
ProductDto foundProductDto = simpleProductService.findById(savedProductId);
assertTrue(savedProductDto.getId().equals(foundProductDto.getId()));
assertTrue(savedProductDto.getName().equals(foundProductDto.getName()));
assertTrue(savedProductDto.getPrice().equals(foundProductDto.getPrice()));
assertTrue(savedProductDto.getAmount().equals(foundProductDto.getAmount()));
}
@Transactional
애너테이션은 원래 트랜잭셔널한 처리를 지원하기 위해 사용하는 애너테이션이다.
@Test 애너테이션이 붙어 있는 테스트 코드에 @Transactional을 함께 사용하면, 해당 테스트 코드는 테스트 코드 실행 후 '커밋'되는 것이 아니라 자동으로 '롤백'된다. → 테스트 코드에서 추가한 데이터가 실제로는 데이터베이스에 반영되지 않도록 만들어준다.
👉🏻 ProductDto → Product: modelMapper.map(productDto, Product.class)
👉🏻 Product → ProductDto: modelMapper.map(Product, ProductDto.class)
ProductDto는 표현 계층에 있고, Product는 도메인 계층에 위치한다.
다른 계층이 도메인 계층에 의존하는 것은 가능하나, 도메인 계층은 다른 어떤 계층에도 의존해선 안 된다. 때문에 ProductDto 코드에서 Product 클래스를 의존하는 형태로 만들어야 한다.
SimpleProductService.java
package kr.co.hanbit.product.management.application;
import kr.co.hanbit.product.management.domain.Product;
import kr.co.hanbit.product.management.domain.ProductRepository;
import kr.co.hanbit.product.management.presentation.ProductDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SimpleProductService {
private ProductRepository productRepository;
private ValidationService validationService;
@Autowired
SimpleProductService(ProductRepository productRepository, ValidationService validationService) {
this.productRepository = productRepository;
this.validationService = validationService;
}
public ProductDto add(ProductDto productDto) {
Product product = ProductDto.toEntity(productDto);
validationService.checkValid(product);
Product savedProduct = productRepository.add(product);
ProductDto savedProductDto = ProductDto.toDto(savedProduct);
return savedProductDto;
}
public ProductDto findById(Long id) {
Product product = productRepository.findById(id);
ProductDto productDto = ProductDto.toDto(product);
return productDto;
}
public List<ProductDto> findAll() {
List<Product> products = productRepository.findAll();
List<ProductDto> productDtos = products.stream()
.map(product -> ProductDto.toDto(product))
.toList();
return productDtos;
}
public List<ProductDto> findByNameContaining(String name) {
List<Product> products = productRepository.findByNameContaining(name);
List<ProductDto> productDtos = products.stream()
.map(product -> ProductDto.toDto(product))
.toList();
return productDtos;
}
public ProductDto update(ProductDto productDto) {
Product product = ProductDto.toEntity(productDto);
Product updateProduct = productRepository.update(product);
ProductDto updatedProductDto = ProductDto.toDto(updateProduct);
return updatedProductDto;
}
public void delete(Long id) {
productRepository.delete(id);
}
}
👉🏻 ModelMapper를 사용하는 코드를 toEntity()
와 toDto()
를 사용하도록 바꿨다.
이제 리팩토링한 코드가 잘 동작하는지 테스트 코드를 실행시켜본다. 무사히 성공하면 굿.
Product.java
package kr.co.hanbit.product.management.domain;
(생략)
public class Product {
(생략)
public Product() {
}
public Product(Long id, String name, Integer price, Integer amount) {
this.id = id;
this.name = name;
this.price = price;
this.amount = amount;
}
(생략)
}
ProductDto.java
package kr.co.hanbit.product.management.presentation;
import jakarta.validation.constraints.NotNull;
import kr.co.hanbit.product.management.domain.Product;
public class ProductDto {
(생략)
public ProductDto() {
}
public ProductDto(String name, Integer price, Integer amount) {
this.name = name;
this.price = price;
this.amount = amount;
}
public ProductDto(Long id, String name, Integer price, Integer amount) {
this.id = id;
this.name = name;
this.price = price;
this.amount = amount;
}
(생략)
public static Product toEntity(ProductDto productDto) {
Product product = new Product(
productDto.getId(),
productDto.getName(),
productDto.getPrice(),
productDto.getAmount()
);
return product;
}
public static ProductDto toDto(Product product) {
ProductDto productDto = new ProductDto(
product.getId(),
product.getName(),
product.getPrice(),
product.getAmount()
);
return productDto;
}
}
👉🏻 toEntity()
와 toDto()
가 Product와 ProductDto의 생성자를 사용하도록 변경
Application.java
package kr.co.hanbit.product.management;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import javax.sql.DataSource;
import java.sql.Connection;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
// @Bean
// public ModelMapper modelMapper() {
// ModelMapper modelMapper = new ModelMapper();
// modelMapper.getConfiguration()
// .setFieldAccessLevel(Configuration.AccessLevel.PRIVATE)
// .setFieldMatchingEnabled(true);
//
// return modelMapper;
// }
@Bean
@Profile("prod")
public ApplicationRunner runner(DataSource dataSource) {
return args -> {
Connection connection = dataSource.getConnection();
};
}
}
build.grade
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '3.3.0'
// implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.1.1'
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-jdbc', version: '3.3.0'
implementation group: 'mysql', name: 'mysql-connector-java', version: '8.0.33'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mockito:mockito-core:4.8.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
👉🏻 ModelMapper 코드 제거, 의존성 제거