Spring 기반 애플리케이션을 설계하다 보면 Service 인터페이스와 이를 구현한 ServiceImpl 클래스를 분리하는 구조를 흔히 접하게 된다. 처음 Spring을 접하는 입장에서는 굳이 왜 이렇게 나누는지 의문이 자연스럽게 든다. 이 구조는 객체지향 설계 원칙, 특히 OCP(Open-Closed Principle)와 깊게 연결되어 있다고 하지만 여기에 대한 필요성과 논의는 오래도록 있어온 것 같다.
이 글에서는 Service와 ServiceImpl의 관계를 객체지향 관점에서 정리하고, 이 구조가 OCP를 어떻게 만족시키는지 살펴보려 한다. 먼저 OCP(Open-Closed Principle)가 무엇인지 알아보자.
OCP(Open-Closed Principle)
OCP는 “소프트웨어 엔티티는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다”는 원칙이다. 쉽게 말해 이는 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있어야 한다는 의미이다.
이 원칙이 중요한 이유는 다음과 같다.
- 이미 검증된 코드를 수정하면 버그가 발생할 가능성이 높음
- 기능이 추가될수록 수정 범위가 넓어지면 유지보수가 어려움
- 협업 환경에서 코드 변경 충돌이 잦아짐
따라서 OCP를 만족하는 구조는 장기적인 관점에서 안정성과 확장성을 동시에 확보한다.
Service와 ServiceImpl의 기본 구조
Spring에서 일반적으로 사용하는 구조는 다음과 같다.
Service
- Service: 비즈니스 로직의 역할(Role)을 정의하는 interface
public interface MemberService {
void join(Member member);
Member findMember(Long memberId);
}
ServiceImpl
- ServiceImpl: 해당 역할을 구현(Implementation)한 class
import org.springframework.stereotype.Service;
@Service
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
public MemberServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public void join(Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(Long memberId) {
return memberRepository.findById(memberId);
}
}
다시 말해 Service는 “무엇을 하는가”를 정의하고, ServiceImpl은 “어떻게 하는가”를 정의한다. 이 분리는 단순한 파일 분리가 아니라, 의존성 방향을 제어하기 위한 의도적인 설계이다. Controller나 다른 Service 계층은 Service 인터페이스에만 의존하고, 구체적인 구현체인 ServiceImpl에는 의존하지 않는다. 이로 인해 상위 계층은 하위 계층의 구현 변경으로부터 보호된다.
Service / ServiceImpl 구조와 OCP 예시
Service 인터페이스를 기준으로 의존성을 설계하면, 구현체가 변경되거나 추가되더라도 기존 코드를 수정할 필요가 없다. 예를 들어 결제 로직을 생각해 보면 다음과 같은 상황이 발생할 수 있다.
- 초기에는 카드 결제만 지원한다
- 이후 계좌 이체 결제를 추가해야 한다
- 계약 변경으로 외부 PG사를 변경해야 한다
초기 요구사항 반영
먼저 결제라는 역할을 정의하고 결제가 카드인지, 계좌이체인지, 외부 PG사인지에 대한 정보는 포함하지 않는 다음과 같은 Service를 작성한다.
public interface PaymentService {
void pay(PaymentRequest paymentRequest);
}
그 다음 카드 결제 구현체를 다음과 같이 작성한다.
import org.springframework.stereotype.Service;
@Service
public class CardPaymentServiceImpl implements PaymentService {
private final CardPaymentClient cardPaymentClient;
public CardPaymentServiceImpl(CardPaymentClient cardPaymentClient) {
this.cardPaymentClient = cardPaymentClient;
}
@Override
public void pay(PaymentRequest paymentRequest) {
cardPaymentClient.requestPayment(
paymentRequest.getAmount(),
paymentRequest.getCardNumber()
);
}
}
요구사항 변경
위와 같이 잘 사용하고 있는 도중 계좌이체 결제 구현체 추가되어야 하는 신규 요구사항이 접수되고 계좌이체 결제가 다음과 같이 추가된 상황이다. 이때 PaymentService 인터페이스는 전혀 변경되지 않는다. 구현체만 확장되며, 이는 OCP를 충족하는 구조이다.
import org.springframework.stereotype.Service;
@Service
public class AccountTransferPaymentServiceImpl implements PaymentService {
private final BankTransferClient bankTransferClient;
public AccountTransferPaymentServiceImpl(
BankTransferClient bankTransferClient
) {
this.bankTransferClient = bankTransferClient;
}
@Override
public void pay(PaymentRequest paymentRequest) {
bankTransferClient.transfer(
paymentRequest.getAmount(),
paymentRequest.getAccountNumber()
);
}
}
Controller는 다음과 같이 구현되어 있었고 결제 방식에 대해 전혀 알지 못하고 오직 PaymentService에만 의존한다.
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
public void pay(PaymentRequest paymentRequest) {
paymentService.pay(paymentRequest);
}
}
만약 이때 Controller나 상위 로직이 특정 구현 클래스에 직접 의존하고 있었다면, 구현이 바뀔 때마다 상위 코드도 함께 수정해야 한다. 이는 OCP를 위반하는 구조이다. 반면, Service 인터페이스에만 의존하고 있다면 새로운 ServiceImpl을 추가하거나 교체하는 것만으로 기능 확장이 가능하다.
Discussion
실제로 프로젝트를 구현할 때 1:N으로 구현체를 만드는 경우보단 1:1로 만드는 경우가 더 많은 것 같다. 따로 Service 이외에도 구현체를 만드는 과정이 불필요해보이기도 하고 번거롭기도 하다. 다시 말해 모든 Service에 반드시 인터페이스를 만들어야 하는 것은 아니다. 다음과 같은 경우는 Service에 직접 구현하는 것도 좋은 방안이 될 수 있다.
- 구현이 단 하나뿐이고 변경 가능성이 매우 낮은 경우
- 프로젝트 규모가 작고 확장이 거의 없는 경우
하지만 위에 보이던 예시와 같이 시시각각 요구사항에 대응하기엔 추상화된 Service Layer를 쉽게 확인할 수 있게 구현하고 뒤에 세부 로직들을 바꾸는 것이 좀 더 명확하고 협업하는 상황에서 의도를 전달하기 더 좋다고 생각된다. 또한 규모가 커지거나, 요구사항 변경 가능성이 조금이라도 존재한다면 Service와 ServiceImpl을 분리하는 설계가 장기적으로 유리하다. 이는 미래의 변경 비용을 현재의 설계 비용으로 미리 지불하는 선택이다.
그리고 Impl이라는 네이밍 컨벤션에 대한 논의들도 다수 존재하는 것 같은데 관습적으로 굳어진 것을 혼자 더 좋은 방향으로 바꾸려고 하는 것도 쉽지는 않을 것 같아 다수의 의견에 동참하는 것이 작업 효율을 더 높이는 방향이지 않을까 생각한다.
참고 자료
https://junior-datalist.tistory.com/243