객체지향 프로그래밍의 네 가지 원칙
👉🏻 캡슐화, 상속, 추상화, 다형성
이 중 추상화가 가장 중요하면서도 이해하기 어렵다!
ListProductRepository
사용DatabaseProductRepository
사용 이를 대응하기 위해서는
의존성 주입을 기준으로 테스트 환경에서는 ListProductRepository 빈이 의존성 주입되어야 하고, 서비스 환경에서는 DatabaseProductRepository 빈이 주입되어야 한다.
하지만 현재 상황은 DatabaseProductRepository라는 클래스에 의존하고 있다. 우리는 이를 추상화를 통해서 해결할 예정!
빈(Bean)이란?
스프링 컨테이너에 의해 관리되는 재사용 가능한 소프트웨어 컴포넌트
👉🏻 스프링 컨테이너가 관리하는 자바 객체
클래스와 빈의 개념이 자꾸 헷갈려서 다시 한 번 정리...
빈이 클래스의 등록 정보, getter/setter 메서드를 포함하고 있다.
ListProductRepository와 DatabaseProductRepository 두 클래스에 대한 인터페이스가 필요하다.
그렇다면 이 인터페이스를 어떤 패키지에 만드는 것이 좋을까?!
정답은 domain 패키지!!!
👉🏻 다른 모든 계층은 인프라스트럭처 계층에 의존하면 안 된다
라는 말을 기억한다면! 한 번 생각을 해 볼 일이다.
infrastructure 패키지에 ProductRepository 인터페이스를 위치시키면 애플리케이션 계층인 SimpleProductService에서 인프라스트럭처 계층 방향으로 의존성이 생긴다! 그렇기 때문에 domain 계층에 ProductRepository를 위치시켜야 한다.
ProductRepository.java
package kr.co.hanbit.product.management.domain;
import java.util.List;
public interface ProductRepository {
Product add(Product product);
Product findById(Long id);
List<Product> findAll();
List<Product> findByNameContaining(String name);
Product update(Product product);
void delete(Long id);
}
두 클래스가 가진 public 메서드를 인터페이스가 갖고 있어야 한다.
이후 ListProductRepository와 DatabaseProductRepository에도 각각 ProductRepository 인터페이스를 구현하고 있다는 사실을 알려주는 코드를 넣어야 한다.
DatabaseProductRepository.java
package kr.co.hanbit.product.management.infrastructure;
import kr.co.hanbit.product.management.domain.Product;
import kr.co.hanbit.product.management.domain.ProductRepository;
(생략)
@Repository
public class DatabaseProductRepository implements ProductRepository {
(생략)
}
ListProductRepository.java
package kr.co.hanbit.product.management.infrastructure;
import kr.co.hanbit.product.management.domain.EntityNotFoundException;
import kr.co.hanbit.product.management.domain.Product;
import kr.co.hanbit.product.management.domain.ProductRepository;
(생략)
@Repository
public class ListProductRepository implements ProductRepository {
(생략)
}
그리고 이제 SimpleProductService에서도 ProductRepository를 사용하도록 코드를 변경한다.
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.infrastructure.DatabaseProductRepository;
import kr.co.hanbit.product.management.infrastructure.ListProductRepository;
import kr.co.hanbit.product.management.presentation.ProductDto;
@Service
public class SimpleProductService {
private ProductRepository productRepository;
private ModelMapper modelMapper;
private ValidationService validationService;
@Autowired
SimpleProductService(ProductRepository productRepository, ModelMapper modelMapper, ValidationService validationService) {
this.productRepository = productRepository;
this.modelMapper = modelMapper;
this.validationService = validationService;
}
public ProductDto add(ProductDto productDto) {
Product product = modelMapper.map(productDto, Product.class);
validationService.checkValid(product);
Product savedProduct = productRepository.add(product);
ProductDto savedProductDto = modelMapper.map(savedProduct, ProductDto.class);
return savedProduct;
}
public ProductDto findById(Long id) {
Product product = productRepository.findById(id);
ProductDto productDto = modelMapper.map(product, ProductDto.class);
return productDto;
}
public List<ProductDto> findAll() {
List<Product> products = productRepository.findAll();
List<ProductDto> productDtos = products.stream()
.map(product -> modelMapper.map(product, ProductDto.class))
.toList();
return productDtos;
}
public List<ProductDto> findByNameContaining(String name) {
List<Product> products = productRepository.findByNameContaining(name);
List<ProductDto> productDtos = products.stream()
.map(product -> modelMapper.map(product, ProductDto.class))
.toList();
return productDto;
}
public ProductDto update(ProductDto productDto) {
Product product = modelMapper.map(productDto, Product.class);
Product updateProduct = productRepository.update(product);
ProductDto updatedProductDto = modelMapper.map(updateProduct, ProductDto.class);
return updatedProductDto;
}
public void delete(Long id) {
productRepository.delete(id);
}
}
하지만! 여기까지 고친다고 해서... 끝이 아니다.
ProductRepository로 바꿈으로인해 어떤 빈을 주입해야 할지 모호해져버렸기 때문이다.
SimpleProductService를 ProductRepository라는 '인터페이스'에 의존하도록 변경
👉🏻 구체적인 존재가 아닌 추상적인 존재에 의존하도록 함으로써 애플리케이션의 동작을 코드 변경 없이, 실행 시점(Runtime)에 결정할 수 있도록 만든 것이다
응용계층(service)와 인프라스트럭처 계층(repository)의 클래스들이 전부 도메인 계층의 ProductRepository 인터페이스에 의존한다.
@Profile
우리는 ProductRepository에 주입될 빈을 테스트 환경과 서비스 환경에서 각각 다르게 적용해야 한다. 이를 위해 'Spring Profiles'를 사용하여 요구사항을 충족시킬 것이다.
@Profile
애너테이션을 사용하여 각각 'test', 'prod'라는 이름으로 지정한다.
DatabaseProductRepository.java
package kr.co.hanbit.product.management.infrastructure;
import kr.co.hanbit.product.management.domain.Product;
import kr.co.hanbit.product.management.domain.ProductRepository;
(생략)
@Repository
@Profile("prod")
public class DatabaseProductRepository implements ProductRepository {
(생략)
}
ListProductRepository.java
package kr.co.hanbit.product.management.infrastructure;
import kr.co.hanbit.product.management.domain.EntityNotFoundException;
import kr.co.hanbit.product.management.domain.Product;
import kr.co.hanbit.product.management.domain.ProductRepository;
(생략)
@Repository
@Profile("test")
public class ListProductRepository implements ProductRepository {
(생략)
}
@Profile
을 지정하면 특정 환경에서 특정 클래스의 빈이 생성되도록 만들 수 있다.
→ test라는 이름의 Profile로 애플리케이션을 실행하면 ListProductRepository의 빈이 생성되고,
→ prod라는 이름의 Profile로 애플리케이션을 실행하면 DatabaseProductRepository의 빈이 생성되는 것이다.
application.yaml
spring:
profiles:
active: test
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
url: jdbc:mysql://localhost:3307/product_management
password: hanbit
application.yaml에서 test Profile을 사용하도록 지정하는 방법이다.
prod로 실행하고 싶다면 실행 시점에서 Profile을 명시할 수 있다.
java -jar -Dspring.profiles.active=test application.jar
혹은
java -jar --spring.profiles.active=test application.jar
이와 같이 실행했을 때 명령어로 지정한 Profile이 application.yaml에 설정된 Profile보다 우선 순위가 높다.
이렇게 바꿔주었음에도 어플리케이션은 정상 작동하지 않는다. 왜냐하면 ListProductRepository는 데이터베이스를 사용하지 않는다. 하지만 우리가 데이터베이스를 사용하기 위해 추가한 의존성인 spring-boot-starter-jdbc에서 데이터베이스 연결과 관련된 자동 설정(auto configuration)을 진행하기 때문이다. 때문에 test Profile로 애플리케이션이 실행될 때는 데이터베이스 연결과 관련된 자동 설정이 작동하지 않도록 만들어야 한다.
이를 위해 우리는 application.yaml 파일을 나눠줄 것이다.
application.yaml
spring:
profiles:
active: test
기본 Profile을 test로 설정하고, 데이터베이스 연결과 관련된 정보를 제거했다. 모든 애플리케이션 환경에서 공통적으로 사용할 설정만 남긴다.
application-test.yaml
spring:
autoconfigure:
exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
application-test.yaml에 넣은 이 설정은 spring-boot-starter-jdbc에 의해 진행되는 데이터베이스 연결 자동 설정이 작동하지 않도록 제외하는 것이다. 이를 통해서 test 환경에서 데이터베이스를 사용하지 않는 개발환경과 부합하게 된다.
application-prod.yaml
spring:
autoconfigure:
exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
application-prod.yaml에는 데이터베이스에 대한 연결 정보를 넣는다.
여기서 멈추면 안됨! 지난번에 우리는 main 메서드가 있는 Application 클래스의 코드에서 애플리케이션이 시작할 때 ApplicationRunner를 등록했던 것을 기억해야 한다... DataSource 타입의 빈이 없기 때문에 여전히 에러에 부딪힐 것이다.
Application.java
package kr.co.hanbit.product.management;
import org.springframework.context.annotation.Profile;
import javax.sql.DataSource;
import java.sql.Connection;
(생략)
@SpringBootApplication
public class Application {
(생략)
@Bean
@Profile("prod")
public ApplicationRunner runner(DataSource dataSource) {
return args -> {
Connection connection = dataSource.getConnection();
};
}
}
ApplicationRunner 빈을 생성하는 코드에도 @Profile("prod")
를 붙여준다. 이로써 우리는! @Profile
을 통해 환경에 따라 서로 다른 빈이 생성될 수 있도록 코드를 구성하기에 성공했다!
의존성 주입 → 의존성이 주입되는 행위 자체에 초점
의존성 주입 패턴 → 의존성이 주입될 수 있는 코드 설계를 지칭
@Service
public class SimpleProductService {
private ProductRepository productRepository;
@Autowired
SimpleProductService(ProductRepository productRepository, (생략)) {
this.productRepository = productRepository;
(생략)
}
// ...
}
SimpleProductService가 정상 작동하기 위해서 ProductRepository에 필요한 의존성이 주입되어야 한다. 이렇게 의존성을 주입해주는 행위는 이와 같이 코드를 작성하여 스프링 프레임워크에 의해서 수행된다. 이 패턴 자체가 이미 의존성 주입 패턴을 따르는 코드이다.
잘못된 예시 👉🏻 필요한 의존성 직접 생성
@Service
public class SimpleProductService {
private ProductRepository productRepository;
@Autowired
SimpleProductService(
(생략)
) {
this.productRepository = new ListProductRepository();
(생략)
}
// ...
}
코드 자체는 문법적으로 올바르다. (실행 잘 됨)
👉🏻 그러나 이 코드에서는 Profile에 따라 ProductRepository에 주입될 의존성 변경이 불가 (직접 ListProductRepository를 생성하여 넣어줬기 때문)
👉🏻 SimpleProductService에서 ListProductRepository쪽으로의 의존을 제거하려고 인터페이스를 뒀지만 다시 의존이 생겼다!
필요한 의존성을 직접 생성하여 사용하는 코드는 추상화의 이점이 없어지는 코드가 된다.
의존성을 주입하여 사용하는 것이 훨씬 유연하게 실행 시점에서 의존성을 바꿔 사용할 수 있다.
'고수준 컴포넌트가 저수준 컴포넌트에 의존하지 말아야 한다' = '추상화에 의존해야 한다'
하지만... 저수준과 고수준... 그거 누가 나누는데요...
✅ 비교 대상이 되는 두 컴포넌트가 있을 때 도메인의 정책에 가까울수록 고수준
✅ 애플리케이션 외부에 가까울수록 저수준으로 분류
ListProductRepository는 애플리케이션 메모리 내부에 데이터를 저장하니까 외부에 가깝지 않다고 생각할 수 있지만, SimpleProductService와 비교하면 상대적으로 도메인의 정책과 거리가 멀다.
👉🏻 따라서 ListProductRepository와 DatabaseProductRepository가 SimpleProductService에 비해 저수준 컴포넌트임
의존성의 방향이 역전된 것을 의미한다. 👉🏻 고수준 컴포넌트가 저수준 컴포넌트에 의존하던 의존성 방향이 역전된 것!
ListProductRepository와 DatabaseProductRepository를 의존하던 SimpleProductService의 의존성 방향이 더 이상 두 클래스를 의존하지 않고 추상적인 존재인 ProductRepository만을 의존하도록 바뀌었다!
저수준 컴포넌트였던 두 클래스에서 ProductRepository 방향으로 의존성 방향도 생겼다. (기존과 반대 방향) → 의존성 역전 원칙을 지키는 설계
✳️ 인터페이스에 의존하는 코드를 만들면서 이미 의존성 역전 원칙을 지키는 코드를 만들게 되었다.
👉🏻 의존성의 방향이 인터페이스 쪽으로 모인다
✅ 추상화(인터페이스)에 의존하는 코드를 만들면 의존성 역전 원칙을 지킬 수 있다!
의존성 역전 원칙을 지키는 코드가 되었기 때문에
👉🏻 저수준이던 Repository 구현체들이 ProductRepository로 추상화 될 수 있다
👉🏻 추상화된 ProductRepository를 통해 의존성 주입 패턴을 적용할 수 있다⚠️ 만일 ProductRepository가 없었다면 실행 시점에 서로 다른 의존성 주입 불가
→ 추상화가 제공하는 유연함
추상화라는 개념은 언제나 정말 어려운 것 같다... 일단 추상이라는 단어 자체부터가 그냥 어렵지 않나? 그런데 코드에 추상이라는 개념을 적용?... 오우
의존성을 주입한다는 것도 어려웠는데