Spring Boot에서 Service 계층을 인터페이스(Service)와 구현 클래스(ServiceImpl)로 분리하여 사용하는 패턴을 많이 볼 수 있음.
예를 들어, 다음과 같은 구조를 가짐:
📂 service
├── UserService.java (인터페이스)
├── UserServiceImpl.java (구현 클래스)
이렇게 분리하는 이유는 크게 확장성, 테스트 용이성, 유지보수성, 결합도 감소 때문임.
📌 1. Service를 Interface + ServiceImpl로 분리하는 이유
✅ (1) 느슨한 결합 (Low Coupling)
• 인터페이스를 사용하면 구현체를 쉽게 교체할 수 있음.
• 클라이언트 코드(Controller, 다른 Service)가 UserServiceImpl을 직접 참조하지 않고, UserService 인터페이스만 참조하면 됨.
@Service
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
public UserServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public User getUser(Long id) {
return userRepository.findById(id).orElseThrow();
}
}
✅ 결합도 비교
• 결합도가 높은 코드 (인터페이스 없이 구현체를 직접 사용)
@RestController
public class UserController {
private final UserServiceImpl userService; // 직접 구현체 사용 (좋지 않음)
public UserController(UserServiceImpl userService) {
this.userService = userService;
}
}
• 인터페이스를 사용하는 코드 (느슨한 결합, 유지보수 쉬움)
@RestController
public class UserController {
private final UserService userService; // 인터페이스 사용 (좋음)
public UserController(UserService userService) {
this.userService = userService;
}
}
• 인터페이스만 의존하면, 구현체(UserServiceImpl)가 변경되더라도 UserController를 수정할 필요가 없음.
• 예를 들어, UserServiceImpl 대신 NewUserServiceImpl을 사용할 경우에도 코드를 바꾸지 않고 구현체만 변경하면 됨.
✅ (2) 구현체 변경 시 유연한 대응
인터페이스를 사용하면 여러 개의 구현체를 만들고 쉽게 변경할 수 있음.
예를 들어, UserServiceImpl과 UserCacheServiceImpl 두 가지 구현이 있을 경우 인터페이스만 사용하면 쉽게 교체 가능함.
@Service
public class UserCacheServiceImpl implements UserService {
private final UserRepository userRepository;
private final CacheManager cacheManager;
public UserCacheServiceImpl(UserRepository userRepository, CacheManager cacheManager) {
this.userRepository = userRepository;
this.cacheManager = cacheManager;
}
@Override
public User getUser(Long id) {
return cacheManager.getUserFromCache(id).orElseGet(() -> userRepository.findById(id).orElseThrow());
}
}
• UserService 인터페이스만 사용하면, 캐싱 버전을 사용할지, 기본 버전을 사용할지 설정만 변경하면 됨
• 즉, 구현체 변경이 필요할 때 유지보수 비용이 적음
✅ (3) 단위 테스트(Mock 객체 활용)
• Mockito 같은 테스트 프레임워크에서 Mock 객체를 쉽게 생성할 수 있음.
• 만약 UserServiceImpl을 직접 사용하면 데이터베이스 연결이 필요하여 테스트가 어려움.
• 인터페이스를 사용하면 Mock 객체를 주입하여 독립적인 단위 테스트가 가능함.
✅ Mock 객체를 사용한 테스트 코드 (JUnit + Mockito)
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserServiceImpl userService;
@Test
void testGetUser() {
User user = new User(1L, "홍길동");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
User result = userService.getUser(1L);
assertEquals("홍길동", result.getName());
}
}
• @Mock으로 가짜 UserRepository를 생성하여 테스트 진행
• 인터페이스를 사용하면 테스트가 쉽고, 실제 데이터베이스와 연결할 필요 없음
✅ (4) AOP(Aspect-Oriented Programming) 적용 가능
• 인터페이스를 사용하면 프록시(Proxy) 기반의 AOP 적용이 쉬워짐.
• Spring의 @Transactional과 같은 기능을 적용할 때도 인터페이스가 유리함.
@Transactional
public interface UserService {
User getUser(Long id);
}
• @Transactional을 인터페이스에 선언하면, **JDK 동적 프록시(Dynamic Proxy)**가 자동으로 생성됨.
• 인터페이스 없이 직접 UserServiceImpl을 사용하면 CGLIB(바이트코드 조작) 기반 프록시가 생성됨.
✅ 프록시를 사용할 때 인터페이스가 유리한 이유
• JDK Dynamic Proxy: 인터페이스 기반 (더 가벼움)
• CGLIB Proxy: 구현 클래스 기반 (바이트코드 조작 필요, 약간 무거움)
인터페이스를 사용하면 프록시 기반 AOP가 더 쉽게 적용될 수 있음.
✅ (5) 다형성(Polymorphism) 적용 가능
인터페이스를 사용하면 Spring에서 런타임 시점에 구현체를 동적으로 바꿀 수 있음.
예를 들어, 환경(Spring Profile)에 따라 다른 구현체를 사용할 수도 있음.
@Service
@Profile("default")
public class UserServiceImpl implements UserService {
@Override
public User getUser(Long id) {
return new User(id, "기본 서비스");
}
}
@Service
@Profile("test")
public class TestUserServiceImpl implements UserService {
@Override
public User getUser(Long id) {
return new User(id, "테스트 서비스");
}
}
• @Profile("test") 환경에서 실행하면 TestUserServiceImpl이 동작함.
• 코드 변경 없이 환경 설정만으로 다른 구현체를 사용할 수 있음.
📌 2. Service + Impl 구조를 꼭 사용해야 할까?
✅ 필수적인 경우
✅ (1) 구현체가 여러 개일 가능성이 있는 경우
• UserServiceImpl, UserCacheServiceImpl 등 여러 개의 구현체를 만들 가능성이 높다면 인터페이스 사용이 유리함.
✅ (2) Spring AOP, 트랜잭션 처리 시
• AOP 기반 기능(@Transactional, 로깅 등)이 필요한 경우 인터페이스를 사용하면 프록시 생성이 최적화됨.
✅ (3) 테스트 코드 작성 시
• Mock 객체를 활용하여 독립적인 단위 테스트가 필요하다면 인터페이스를 사용하는 것이 좋음.
❌ 필수적이지 않은 경우
❌ (1) 구현체가 하나밖에 없고, 바뀔 가능성이 낮다면 불필요함
• 예를 들어, 단순한 CRUD 기능을 하는 ProductService처럼 여러 개의 구현체를 만들 필요가 없다면 인터페이스 없이 구현체만 사용 가능함.
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Product getProduct(Long id) {
return productRepository.findById(id).orElseThrow();
}
}
🚀 결론
• Service + Impl 구조를 사용하는 이유는 유지보수성, 확장성, 테스트 용이성 때문
• 인터페이스를 사용하면 여러 구현체를 쉽게 교체 가능
• AOP, 트랜잭션, 테스트(Mock) 환경에서 유리
• 하지만 구현체가 하나뿐이라면 단순하게 Service 클래스만 사용할 수도 있음
출처 : ChatGPT
'BE > Spring & Spring Boot' 카테고리의 다른 글
[Spring Boot] @TestMethodOrder (0) | 2025.02.25 |
---|---|
[Spring Boot, JPA] 리플렉션 (0) | 2025.02.23 |
[Spring Boot] REST Docs + Asciidoctor (0) | 2025.02.15 |
[Spring Boot] JaCoCo (Java Code Coverage) (1) | 2025.02.10 |
[Spring Boot] @Slf4j (1) | 2025.02.09 |