프링의 서비스 계층 클래스에 굳이 인터페이스를 만들 필요가 없다, 그 이유는 구현체가 하나 뿐이다라는 얘기를 많이 듣습니다.

우선 인터페이스를 만들어봤자 구현체가 하나 뿐이라는 전제 자체가 틀렸습니다. 대부분의 서비스는 트랜잭션 경계이기 때문에 트랜잭션 AOP가 적용되죠. 스프링 AOP는 프록시 패턴(혹은 데코레이터 패턴)을 사용합니다. 이 패턴들은 당연히 인터페이스가 반드시 필요합니다. 그래야 이를 사용하는 쪽(컨트롤러 등)에서 최종 타깃인 서비스 구현 클래스와 동일하다고 생각하고 트랜잭션을 적용하는 프록시 클래스를 주입 받아서 사용하게 되겠죠. @Transactioanl 이 붙어있는데도 인터페이스를 안 써도 되긴하죠. 그건 스프링이 프록시를 만들 때 인터페이스가 없어도 클래스를 상속해서 인터페이스처럼 사용하는 트릭을 쓰기 때문에 그렇습니다. 그런데 이건 원래 그러라고 만들어둔 건 아닙니다. 원칙은 인터페이스를 만드는 것인데, 내가 손을 댈 수 없는 레거시 클래스나 변경이 불가능한 클래스가 있을 때에도 스프링의 AOP를 적용할 수 있도록, 자바 언어의 기본적인 다이나믹 프록시 대신 cglib이라는 도구를 활용해서 클래스 상속을 통해서 억지로 동일 타입의 다른 구현을 하나 넣은, 꼼수일 뿐입니다.

상속을 통해서 프록시를 만드는 것은 2가지 치명적인 단점이 있습니다. 우선은 생성자가 2번 호출된다는 문제가 있습니다. 상속 클래스의 생성자를 호출하면 자바의 동작 방식 대로 수퍼 클래스의 디폴트 생성자도 실행됩니다. 만약 서비스 클래스의 생성자 메소드에서 중요한 초기화 작업 같은 것을 했다면, 이게 두 번 실행이 되는 문제가 발생합니다.

두번째는 상속을 통해서 프록시를 만들어야 하기 때문에 해당 클래스를 final로 지정할 수가 없습니다. 의도를 가지고 로직을 담은 구현 클래스를 final로 해서 추가 상속을 통한 구현 오버라이딩 등을 허용하지 않도록 만들고 싶어도 그걸 못하죠. 특히 클래스의 기본이 final이고, 상속을 하려면 일일히 open을 붙여줘야 하는 코틀린에서는 매우 귀찮은 작업이 들어가거나, 프록시 생성이 아예 안 되는 문제가 있습니다.

그러니 @Trasanctional 하나만 써도, 서비스 인터페이스의 구현 클래스가 최소 2개가 됩니다. 이걸, 전략 패턴처럼 코드에서 이리저리 구현을 바꿔서 사용하지 않는다고 하나 뿐이라고 생각하는 것에는 동의할 수 없습니다.

또한, 저는 SOLID의 ISP를 매우 중요하게 생각합니다. 인터페이스는 클라이언트가 바라보는 중요한 창이고, 클라이언트를 기준으로 인터페이스를 정의하고, 이걸 어느 클래스에서 구현할 것인지는 다양한 이유로 결정되고 변경될 수 있어야 합니다.

보통 처음에는 엔티티 하나 기준으로 서비스 클래스를 만들지만, 점차 기능이 확장되고 서비스의 책임이 늘어나면 클래스를 분리하기도 하죠. 처음부터 서비스를 용도에 맞게 상세하게 다 쪼개서(예를 들어 회원 가입 서비스 클래스, 회원 레벨 조정 서비스 클래스, 회원 정보 조회… 등등) 만들 수도 있지만, 보통 초반에 무리하게 나눌 필요는 없습니다.

그런데 서비스를 주로 사용하는 컨트롤러 쪽을 설계하고 API 기준으로 어떤 서비스를 이용할지 정리해보면, 사실 서비스 클래스가 제공하는 기능의 일부 기능 그룹만 사용하는 경우가 많습니다. 이 때도 매번 서비스 클래스를 통채로 주입 받아서 사용한다면, 의존관계 자체가 매우 두리뭉실해지고, 자칫 아무 기능이나 막 가져다 쓸 수 있죠. 혹은 단위테스트를 만들기 위해서 서비스 mock을 만들 때도 목 툴의 도움 없이는 테스트 작성이 어렵습니다.

그래서 저는 서비스에 대해서 분석에서 나온 사용 시나리오(유스 케이스, 유저 스토리)에 따라서 인터페이스를 정의해두고, 그리고 초반에 복잡하지 않을 때는 서비스 클래스 하나가 이걸 다 구현하도록 만듭니다. RegisterUser 인터페이스와 FindUser, UpgradeUser 등등 사용 시나리오에 따라 쪼개거나, 그게 너무 복잡하면 일단 읽기용 인터페이스와 등록,수정용 인터페이스 정도로라도 인터페이스를 구분합니다. 분명 사용하는 컨트롤러에서는 이 중에서 필요한 기능을 가진 인터페이스는 대부분 1개 뿐일 겁니다.

컨트롤러는 서비스 클래스가 같은 것인지 따로인지 모르죠. 알 필요도 없습니다. 테스트도 아주 간결해집니다. 필요한 경우 람다식 하나만 쓰면 굳이 Mockito 같은 걸 쓸 필요도 없으니, 테스트 속도 빠르고 구현도 깔끔하게 나오겠죠.

그리고 구현 서비스 클래스는 시간이 지나면서 기능이 많아 지거나, 의존하는 다른 서비스나 리포지리 등등이 늘어나면, 적절한 시점에 분리를 합니다. 인터페이스 단위로 쪼개면 대체로 잘 맞습니다. 이때도 이를 사용하는 컨트롤러 코드는 전혀 수정할 게 없습니다.

당장 스프링 시큐리티만 써도 사용자 정보를 가져오는 시큐리티쪽에서 필요로하는 유저 정보 제공을 위한 인터페이스가 UserDetails가 있죠. 이거랑 UserService만 해도 2개자나요. 이걸 따로 만들 필요가 없으니 일단 인터페이스 2개를 구현한 클래스가 자연스럽게 만들어지죠. 여러가지 이유로 이게 다시 분리될 수도 있고, User 정보를 조회하는 구현만 모아서, 그 중에서도 용도에 따라서, 계속 적절한 리팩터링을 자연스럽게 할 수 있습니다.

이런 여러가지 장점과 활용 용도가 있는데도 서비스 인터페이스를 만들지 말라는 건.. 이해할 수 없습니다. 마지막으로 서비스는 그 자체로 중요한 layer입니다. 계층구조의 아키텍처에서 지켜야할 것은 계층 간에 의존성을 최소화 하고, 꼭 필요한 것만 노출되도록 API(인터페이스를 말합니다. 웹 API 말고)를 공개하는 것입니다. 자바의 모듈 기능을 잘 써서 구현한다면, 상세 구현 클래스를 감추고, 해당 계층이나 모듈이 외부로 제공하는 인터페이스만 잘 정의해서 쓸 수 있죠.

그래서 계층을 넘어설 때는 테스트를 비롯해서 수많은 이유로 인터페이스를 만들어 쓰는 것이 자연스럽습니다. 이와 같은 이유로, 서비스에는 인터페이스를 정의해서 쓰라고 설명합니다.