728x90

 

개요

Spring 기반의 프로젝트를 살펴보면, ServiceServiceImpl나누어진 구조를 자주 접하게 됩니다.

 

그런데 저는 개인 프로젝트나 팀 프로젝트를 진행할 때에는 별도로 ServiceImpl을 두지 않고 하나의 Service 클래스에서 비즈니스 로직을 구현하곤 했습니다.

 

하지만 실제 업무에서 코드를 분석하다 보니, 많은 프로젝트들이 ServiceServiceImpl분리하는 방식을 택하고 있었습니다.

 

이 글에서는 왜 Service와 ServiceImpl을 분리하는지, 이러한 구조를 채택한 이유에 대해서 정리하고자 합니다.

 

 

분리하는 이유는❓

1. 유지보수성

Service는 인터페이스로 정의하고, ServiceImpl에서 실제 구현을 담당하는 방식은 결합도를 낮추고 변경에 유연하게 대응할 수 있도록 도와줍니다.

즉, 느슨한 결합으로 인해 수정이 용이합니다.

 

예를 들어, UserService 인터페이스 가 있고, 이를 구현한 UserServiceImpl 이 있다고 가정해봅시다.

public interface UserService {
    User findUserById(Long id);
}

@Service
public class UserServiceImpl implements UserService {
    @Override
    public User findUserById(Long id) {
        // 실제 로직
    }
}


만약 기존 UserServiceImpl을 변경하지 않고 새로운 구현체(NewUserServiceImpl)로 교체하고 싶다면, 단순히 스프링 빈(Bean) 설정을 변경하면 됩니다.

 

이는 특히 다양한 비즈니스 로직이 필요하거나, 구현체를 변경해야 할 가능성이 높은 경우 유리합니다.


2. 테스트 용이성

인터페이스를 사용하면 단위 테스트 시 실제 ServiceImpl을 사용하지 않고, Mock 객체를 주입하여 테스트를 수행할 수 있습니다.


이렇게 하면 불필요한 의존성을 제거하고 테스트 속도를 향상시킬 수 있습니다.

@Mock
private UserService userService;

@Test
void testFindUser() {
    when(userService.findUserById(1L)).thenReturn(new User(1L, "Test User"));
    User user = userService.findUserById(1L);
    assertEquals("Test User", user.getName());
}

 

예를 들어, Mockito를 사용하여 UserService의 Mock 객체를 생성하면, UserServiceImpl의 내부 구현과 무관하게 UserService를 사용하는 로직을 테스트할 수 있습니다.


3. AOP 적용의 유연성

인터페이스 기반 구조AOP 적용을 더 유연하게 할 수 있습니다.

 

과거 Spring 2.0에서는 JDK Dynamic Proxy를 사용하여 AOP Proxy를 생성했으며, 이는 인터페이스를 반드시 구현해야만 AOP 적용이 가능한 방식이었습니다.

즉,
@Transactional(트랜잭션 관리), @Cacheable(캐싱), @Logging(로깅) 등 과 같은 AOP 기능을 사용하려면
인터페이스-구현체 구조가 필수적이었으며, 자연스럽게 ServiceServiceImpl로 나누는 패턴이 일반화되었습니다.

 

그러나 Spring 3.2 / Spring Boot 1.4 이후부터는 CGLIB가 기본적으로 포함되면서, 클래스 기반으로도 AOP 프록시를 생성할 수 있게 되었습니다.


덕분에 이제는 인터페이스 없이도 AOP 적용이 가능하며, 과거처럼 인터페이스-구현체 구조를 강제할 필요는 없어졌습니다.


4. 의존성 역전(DIP)의 실현

  • 고수준 모듈(컨트롤러)이 저수준 모듈(서비스 구현체)에 의존하지 않고, 둘 다 추상화(인터페이스)에 의존하게 합니다.
  • 이를 통해 "구현보다 인터페이스에 의존하라"는 객체지향 설계 원칙을 따릅니다.

분리하는 이유는❓

1. 불필요한 추상화

인터페이스는 여러 개의 구현체가 존재할 가능성이 있을 때 의미가 있습니다. 그러나 하지만 대부분의 서비스 계층에서는 하나의 구현체만 존재하는 경우가 많습니다.

public interface UserService {
    User findUserById(Long id);
}

@Service
public class UserServiceImpl implements UserService {
    @Override
    public User findUserById(Long id) {
        return userRepository.findById(id).orElseThrow();
    }
}

 

위처럼 구현체가 하나뿐이라면, 굳이 인터페이스를 분리할 필요가 없습니다. 이런 경우, 직접 클래스를 사용하면 코드가 간결해지고 유지보수도 쉬워집니다.

@Service
public class UserService {
    public User findUserById(Long id) {
        return userRepository.findById(id).orElseThrow();
    }
}

 

즉, 1:1로 구현할 것이라면 굳이 인터페이스를 분리할 필요가 없다고 생각합니다.

2. 코드의 가독성과 유지보수성 저하

인터페이스를 분리하면 코드의 가독성이 오히려 떨어질 수 있습니다.

  • 인터페이스와 구현체를 왔다 갔다 하면서 코드를 확인해야 함.
  • 단순한 비즈니스 로직에도 불필요한 추상화가 추가됨.
  • 작은 규모의 프로젝트에서는 오버엔지니어링이 될 가능성이 높음

3. 테스트 시 복잡성 증가

Mocking을 활용한 단위 테스트는 Service-Impl 구조 없이도 충분히 가능합니다.

@Mock
private UserRepository userRepository;

@InjectMocks
private UserService userService; // 인터페이스 없이도 Mock 주입 가능

@Test
void testFindUser() {
    when(userRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "Test User")));
    User user = userService.findUserById(1L);
    assertEquals("Test User", user.getName());
}

 

인터페이스 없이도 테스트는 문제없이 수행될 수 있으며, 오히려 단순한 구조를 유지할 수 있습니다.


4. DI(의존성 주입)와 충돌❌

Spring의 @Service는 기본적으로 클래스 기반의 빈 등록을 지원하기 때문에, 인터페이스 없이도 DI(의존성 주입)에는 아무런 문제가 없습니다.

@Service
public class UserService {
    private final UserRepository userRepository;

    @Autowired
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

 

즉, 인터페이스 없이도 Spring의 IoC 컨테이너는 정상적으로 동작합니다.

 

 

결론

당연한 이야기지만, 프로젝트의 규모상황을 고려하여 선택하는 것이 최선입니다.

 

객체지향프로그래밍(OOP) 관점에서 보았을 땐 인터페이스는 다형성 또는 SOLID원칙의 OCP(계방폐쇄원칙)때문에 사용합니다. 그러나 대부분의 경우 Service는 1:1 구조를 가지며, 하나의 Service가 여러 개의 ServiceImpl을 가지는 1:N 관계가 아닙니다.


즉, 다형성을 적극적으로 활용할 필요가 없는 경우가 많으므로, 불필요한 추상화는 오히려 복잡도를 증가시킬 수 있습니다.

 

따라서 특별한 이유가 없다면 단순한 구조를 유지하는 것이 바람직하며, 필요할 때만 인터페이스를 도입하는 것이 합리적인 접근이라고 생각이 듭니다.