클래스 추상화하기
애플리케이션을 개발하다 보면, 같은 기능을 하더라도 실행 환경에 따라 다른 구현을 써야 하는 요구사항이 자주 생긴다.
예를 들어 로컬 개발 환경에서는 테스트가 편하도록 List를 사용하는 저장소를 쓰고,
실제 서버에서는 데이터베이스를 사용하는 저장소를 쓰고 싶을 수 있다.
이런 요구는 학습용 예제에서만 나오는 특별한 상황이 아니라, 실무에서도 아주 흔하게 마주치는 문제다.

이 문제를 처음 떠올렸을 때 가장 단순하게 생각할 수 있는 방법은, 실행 환경에 따라 코드를 직접 바꿔치기하는 것이다.
즉, 내 노트북에서 실행할 때는 ListProductRepository를 연결해두고,
서버에 배포할 때는 DatabaseProductRepository를 연결하도록 소스를 수정하는 방식이다.
하지만 이 방법은 금방 한계를 드러낸다.
수정해야 하는 코드가 많아질 수 있고, 배포 직전에 바꿔야 할 부분을 빠뜨리면 그대로 장애로 이어질 수 있다.
그래서 더 좋은 방법은, 환경에 따라 서로 다른 Bean이 주입되도록 만드는 것이다.
즉, 서비스 코드는 “상품 저장소”라는 추상적인 대상만 바라보고,
실제로는 로컬 테스트 환경에서는 ListProductRepository가 주입되고, 운
영 환경에서는 DatabaseProductRepository가 주입되도록 만드는 것이다.
이 방식이 되면 서비스 계층은 구체적인 저장 방식이 무엇인지 몰라도 되고,
환경에 맞는 구현체가 바뀌더라도 서비스 코드는 거의 손대지 않아도 된다.
인터페이스 도입
이제 이 문제를 코드로 풀기 위해, 자바의 기본 문법인 인터페이스(interface) 를 사용할 수 있다.
현재 애플리케이션에서 저장소 역할은 ListProductRepository와 DatabaseProductRepository가 각각 수행하고 있다.
이 둘은 구현 방식은 다르지만, 사실상 같은 역할을 한다.
상품을 추가하고, id로 조회하고, 전체 조회를 하고, 이름 검색을 하고, 수정하고, 삭제하는 기능을 공통으로 가진다.
즉, 이미 논리적으로는 같은 인터페이스를 공유하고 있었던 셈이다.
이제 그것을 자바 코드에 명시적으로 드러내는 것이다.
먼저 도메인 패키지에 ProductRepository 인터페이스를 만든다.
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);
}
이 인터페이스에는 두 저장소 클래스가 공통으로 제공해야 하는 메서드들을 정의한다.
즉, “상품 저장소라면 최소한 이런 기능은 있어야 한다”는 약속을 코드로 만든 것이다.
그다음 DatabaseProductRepository와 ListProductRepository는 각각 이 인터페이스를 구현하도록 바꾼다.
public class DatabaseProductRepository implements ProductRepository
public class ListProductRepository implements ProductRepository
이렇게 하면 두 클래스는 더 이상 “각자 따로 존재하는 저장소 클래스”가 아니라,
둘 다 ProductRepository라는 공통 역할을 구현하는 클래스가 된다.
이제 SimpleProductService는 더 이상 DatabaseProductRepository 같은 구체 클래스에 직접 의존하지 않고, ProductRepository 인터페이스에 의존하도록 바꿀 수 있다.
@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 savedProductDto;
}
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 productDtos;
}
public ProductDto update(ProductDto productDto){
Product product = modelMapper.map(productDto, Product.class);
Product updatedProduct = productRepository.update(product);
ProductDto updatedProductDto = modelMapper.map(updatedProduct, ProductDto.class);
return updatedProductDto;
}
public void delete(Long id){
productRepository.delete(id);
}
}
이제 서비스는 “DB에 저장하는지, 리스트에 저장하는지”를 직접 알 필요가 없어진다.
서비스가 아는 것은 오직 ProductRepository가 제공하는 기능 뿐이다.
다만 이 상태로 바로 실행하면 애플리케이션이 정상적으로 뜨지 않을 수 있다.
왜냐하면 스프링 입장에서는 ProductRepository 타입으로 주입할 수 있는 Bean이 두 개나 있기 때문이다.
@Repository가 붙은 두 클래스 모두 Bean으로 등록되므로, 스프링은 “둘 중 어느 것을 넣어야 하지?”를 결정하지 못한다.
@Profile
ProductRepository 구현체가 두 개이기 때문에, 스프링은 어떤 Bean을 주입해야 할지 알 수 없다.
이럴 때 사용할 수 있는 대표적인 방법이 바로 @Profile이다.
프로파일(Profile)은 애플리케이션이 어떤 환경에서 실행되는지를 구분하는 방법이다.
여기서는 환경을 test와 prod 두 가지로 나눈다.
test 환경에서는 ListProductRepository를 사용
prod 환경에서는 DatabaseProductRepository를 사용
이를 위해 각 저장소 클래스에 프로파일을 붙인다.
@Profile("test")
@Repository
public class ListProductRepository implements ProductRepository
@Profile("prod")
@Repository
public class DatabaseProductRepository implements ProductRepository
이렇게 하면 애플리케이션이 test 프로파일로 실행될 때는 ListProductRepository만 Bean으로 등록되고,
prod 프로파일로 실행될 때는 DatabaseProductRepository만 Bean으로 등록된다.
즉, 동시에 두 Bean이 뜨는 것이 아니라, 환경에 맞는 Bean 하나만 생성되도록 만들 수 있다.
그다음에는 어떤 프로파일로 실행할지 지정해야 한다.
가장 간단한 방법은 application.properties에 설정하는 것이다.
spring.profiles.active=test
또는
spring.profiles.active=prod
이렇게 하면 애플리케이션이 시작될 때 어떤 환경 기준으로 Bean을 구성할지 결정된다.
여기서 한 가지 더 생각할 점이 있다.
test 환경에서는 리스트 기반 저장소만 사용할 것이므로, 굳이 데이터베이스 커넥션 풀을 만들 필요가 없다.
그런데 이전에 ApplicationRunner로 커넥션을 확인하는 Bean을 만들어두었기 때문에,
test 환경에서도 이 코드가 실행되면 오히려 문제가 될 수 있다.
그래서 이 Bean도 prod 환경에서만 생성되도록 바꿔주는 것이 좋다.
@Bean
@Profile("prod")
public ApplicationRunner runner(DataSource dataSource){
return args -> {
Connection connection = dataSource.getConnection();
};
}
이렇게 하면 prod일 때만 데이터베이스 연결 확인용 Bean이 생성되고,
test 환경에서는 굳이 DB 연결을 만들지 않아도 애플리케이션이 정상적으로 뜰 수 있다.
의존성 주입(DI)과 의존성 역전 원칙(DIP)

이번 편에서 자연스럽게 같이 등장하는 개념이 DI(의존성 주입) 와 DIP(의존성 역전 원칙) 이다.
이름이 비슷해서 헷갈릴 수 있는데, 둘은 같은 말이 아니다.
먼저 의존성 주입(DI) 은 객체가 필요한 의존성을 자기 스스로 new로 만들어 쓰는 것이 아니라, 외부에서 주입받는 방식을 말한다.
지금 애플리케이션에서는 스프링 프레임워크가 이 역할을 해준다.
즉, SimpleProductService는 직접 new DatabaseProductRepository()를 하지 않고,
생성자에서 ProductRepository를 주입받는다.
이것이 바로 DI다.
쉽게 말하면 “필요한 부품을 내가 직접 만들지 않고 외부에서 꽂아준다”는 개념이다.
반면 의존성 역전 원칙(DIP) 은 설계 원칙이다.
고수준 컴포넌트가 저수준 컴포넌트에 직접 의존하지 말고, 둘 다 추상화에 의존해야 한다는 원칙이다.
여기서 고수준 컴포넌트는 상대적으로 더 추상적이고, 애플리케이션의 핵심 흐름을 담당하는 쪽이다.
지금 예제에서는 SimpleProductService가 여기에 해당한다.
반대로 ListProductRepository, DatabaseProductRepository처럼 구체적인 구현 기술을 다루는 클래스는
저수준 컴포넌트라고 볼 수 있다.
인터페이스를 도입하기 전에는 SimpleProductService가 구체적인 저장소 클래스에 직접 의존할 가능성이 있었다.
즉, 고수준 컴포넌트가 저수준 컴포넌트에 의존하고 있었던 것이다.
하지만 지금은 중간에 ProductRepository 인터페이스를 넣어서,
서비스는 더 이상 구체적인 구현이 아니라 추상화에 의존하게 되었다.
오히려 저수준 구현체들이 ProductRepository라는 추상화에 맞춰 구현되는 구조가 되었다.
이게 바로 “역전”이라는 말의 핵심이다.
원래는 서비스 → 구현체 방향으로 의존했는데,
지금은 구현체들도 추상화에 맞춰 움직이게 되면서 의존 방향이 달라진 것이다.
이 구조가 가지는 장점은 분명하다.
① 구현 기술이 바뀌어도 서비스 계층은 덜 영향을 받는다.
② 테스트 환경과 운영 환경에서 다른 저장소를 쉽게 바꿔 끼울 수 있다.
③ 상대적으로 변경 가능성이 큰 저수준 구현에 고수준 로직이 직접 묶이지 않는다.
즉, DI는 객체를 어떻게 연결할 것인가의 문제이고,
DIP는 무엇에 의존하게 설계할 것인가의 문제라고 이해하면 훨씬 깔끔하다.
'공부 기록 > 이것이 백엔드 개발이다 With Java' 카테고리의 다른 글
| EP25. 상품 관리 애플리케이션에 객체지향 더하기 3편 (0) | 2026.04.27 |
|---|---|
| EP24. 상품 관리 애플리케이션에 객체지향 더하기 2편 (0) | 2026.04.26 |
| EP22. 상품 관리 애플리케이션에 데이터베이스 연동하기 3편 (0) | 2026.04.24 |
| EP21. 상품 관리 애플리케이션에 데이터베이스 연동하기 2편 (0) | 2026.04.23 |
| EP20. 상품 관리 애플리케이션에 데이터베이스 연동하기 1편 (0) | 2026.04.22 |