BE/Spring & Spring Boot

[Spring Boot] Service를 Interface + ServiceImpl 구조로 사용하는 이유

baek-dev 2025. 2. 20. 19:00

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) 구현체 변경 시 유연한 대응

 

인터페이스를 사용하면 여러 개의 구현체를 만들고 쉽게 변경할 수 있음.

 

예를 들어, UserServiceImplUserCacheServiceImpl 두 가지 구현이 있을 경우 인터페이스만 사용하면 쉽게 교체 가능함.

@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