![[Spring] 내가 ~Service, ServiceImpl로 분리하는 이유](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLYbJ7%2Fbtr1JttDXG3%2FUIsoFyRcRd9bsrgBIFeGSK%2Fimg.png)
목표
서비스 계층을 개발할 때 ~Service, ~ServiceImpl로 나누는 이유에 대해서 기술합니다.
개요
저는 Spring 기반의 백엔드를 구현하는 프로젝트를 구현할 때 항상 ~Service, ~ServiceImpl과 같이 인터페이스와 구현체로 분리해서
개발했습니다. 처음에는 이렇게 배웠기 때문에 따라 했고, 경험이 쌓이면서 왜 이렇게 해야 하는지에 대한 고민을 하기 시작했습니다.
정말 다양한 측면에서 생각해 볼 수 있는 문제이고, 상황에 따라 의견이 분분한 문제이므로 정답은 없다고 생각합니다.
하지만, 제가 프로젝트에서 이렇게 적용할 때는 그만한 근거가 필요하고, 저는 분리해서 개발하는 게 합당하다고 판단했습니다.
막상 면접 자리에서 '왜 이렇게 구현했는가?'라는 질문을 받았을 때, DI? 확장성? 등등 많은 생각이 동시에 들어 대답을 시원하게 못했습니다.
이번 기회에 ~Service, ~ServiceImpl로 분리해서 개발한 이유에 대해 정리해보려고 합니다.
객체지향 관점에서 바라보는 ~Service, ~ServiceImpl
인터페이스와 구현체로 분리해서 개발하는 것을 객체지향적인 관점에서 바라볼 수 있습니다.
먼저, 흔히 SOILD라고 불리는 객체지향적인 설계 5원칙에 대해 알아보겠습니다.
SRP (Single Responsibility Principle) | 단일 책임 원칙 |
OCP (Open Closed Priciple) | 개방 폐쇄 원칙 |
LSP (Listov Substitution Priciple) | 리스코프 치환 원칙 |
ISP (Interface Segregation Principle) | 인터페이스 분리 원칙 |
DIP (Dependency Inversion Principle) | 의존 역전 원칙 |
SOLID는 소프트웨어 설계를 보다 모듈화 되고 유연하며 유지 관리하기 쉽게 만드는 것을 목표로 하는 OOP를 위한 일련의 원칙입니다.
전체적으로 구현체에 의존하지 않고 추상화된 인터페이스에 의존해 프로그램이 유연하게 동작하는 것을 권장하고 있습니다.
기능이 확장되거나, 요구사항이 변해도 변화하는 모듈에만 수정이 일어나고, 다른 것에 영향을 미치지 않습니다.
물론, 과하게 높은 수준의 추상화는 오히려 역효과를 불러일으킵니다.쉬운 것도 어렵게 돌아가게 만들 수 있기 때문입니다.
당장 주어진 상황 뿐만 아니라, 앞으로의 변화 가능성을 판단해야 합니다. 이는 해당 도메인에 대한 깊은 이해를 요구합니다.
따라서, 적정선의 추상화가 어렵고 기준이 명확하지 않기 때문에 인터페이스와 구현체로 분리하는 것에 대한 의견이 분분하다고 생각합니다.
그럼에도 불구하고, 추상화는 객체지향의 중요한 특징 중 하나로 뽑히는 만큼 포기할 수 없습니다.
코드의 재사용성을 늘리고, 독립적으로 클래스 내부를 수정할 수 있다는 특징이 있기 때문입니다.
특히 저 같이 경험이 적은 개발자는 장기적인 상황을 예측하기 힘들기 때문에, 여러 상황에 대비해야 합니다.
SOLID와 같이 원칙이 주어지면 최대한 지키는 쪽이 장기적으로 봤을 때 안정적이라는 판단을 내렸습니다.
따라서, 이번 주제를 위해 주의 깊게 봐야 하는 원칙은 OCP, ISP, DIP입니다.
각각의 원칙을 지키기 위해 Spring, Java를 활용한 내용에 대해 알아보도록 하겠습니다.
OCP (개방 폐쇄 원칙)
OCP는 확장에는 열려있고 변경에는 닫혀있는 원칙입니다.
기능을 변경하거나 확장을 하면서도, 이를 사용하는 클래스의 코드는 수정되면 안 된다는 뜻입니다.
간단한 Java 예제를 확인해 보겠습니다.
public interface Shape {
double area();
}
@AllArgsConstructor
public class Rectangle implements Shape {
private double width;
private double height;
public double area() {
return width * height;
}
}
위와 같이, Shape라는 인터페이스가 있습니다. 해당 클래스는 area()라는 넓이를 계산하는 메소드가 있습니다.
이를 상속받아 Rectangle 클래스를 만들었고, 사각형의 넓이를 반환합니다.
여기서 삼각형 클래스를 추가(확장)하고 싶으면 다음과 같이 인터페이스를 상속받아 쉽게 확장할 수 있습니다.
@AllArgsConstructor
public class Circle implements Shape {
private double radius;
public double area() {
return Math.PI * radius * radius;
}
}
실제 Shpae 인터페이스를 활용하는 예제를 확인해 보겠습니다.
public class Desk implements Shape {
private final Shape shape = new Rectangle();
...
}
책상(Desk) 클래스는 Shape를 상속받아 모양을 결정합니다.
만약, shape를 사각형이 아닌, 삼각형으로 바꾸고 싶다면 선언부 코드의 수정이 불가피합니다.
이게 문제가 되는 이유는 바로 구현체(Reactangle)에 의존하고 있기 때문입니다. 이는 DIP 위반입니다.
DIP (의존 역전 원칙)
DIP는 상위 수준 모듈이 하위 수준 모듈에 의존해서는 안된다는 원칙입니다.
쉽게 말해서, 구체화에 의존하면 안되고, 추상화에 의존해야한다는 것입니다. 즉, 클래스가 아닌 인터페이스에 의존하라는 뜻입니다.
위 예제에서 Rectangle을 주입하기 위해 Rectangle에 의존성이 생기는게 불가피합니다.
(제가 생각하는 의존이란, Rectangle이 없어지거나 변화가 일어나면 Desk 클래스에도 영향을 미치는 것이라고 생각합니다.)
DIP를 만족시키려면 다음과 같이 코드가 작성되어야 합니다.
public class Desk implements Shape {
private Shape shape;
public Desk(Shape shape) {
this.shpae = shape;
}
...
}
보시는 것처럼 구현체(구체화)에 대한 의존이 없어지고 인터페이스(추상화)에만 의존되어 있는 것을 알 수 있습니다.
하지만 이는 한계점이 존재합니다. Desk의 객체를 생성할 때 생성자에 구현체를 주입해줘야 한다는 점입니다.
해당 예제가 OCP, DIP를 만족하도록 하려면 Java의 다형성만으론 역부족입니다. 매 번 알맞은 객체를 넣는 것은 번거롭기 때문입니다.
이제부터는 프레임워크의 도움을 받아야 합니다.
DI(Dependency Injection)
코드로 직접 선언하지 않고 무언가 의존성을 주입해 준다면 OCP도 만족할 수 있겠지요.
이를 가능하게 하는 것이 Spring의 DI(의존성 주입)입니다.
Spring은 DI Container를 통해 인스턴스의 생명주기를 관리합니다.
설정 파일 혹은 어노테이션을 기반으로 빈(bean)을 생성해, 애플리케이션 전역에서 사용할 수 있습니다.
개발자가 직접 종속성을 관리하지 않고 프레임 워크에서 직접 해주기 때문에 IoC(Inverse of Container)라고도 불립니다.
덕분에, 개발자는 비즈니스 로직에만 집중할 수 있습니다.
위 예시에서, Desk 클래스에서 동그라미 모양으로 만들고 싶다면 다음과 같이 Circle 클래스를 빈으로 등록하면 됩니다.
@Component
@AllArgsConstructor
public class Circle implements Shape {
private double radius;
public double area() {
return Math.PI * radius * radius;
}
}
@Component 어노테이션을 추가함으로써, 컴포넌트 스캔의 대상이 되어 빈으로 등록됩니다.
Desk 클래스의 생성자에 빈으로 등록된 Circle이 주입되면서 원하는 대로 동작할 수 있습니다.
혹은, Rectangle로 변경하고 싶다면 @Qulifier, @Primary같은 어노테이션을 활용해 원하는 인스턴스를 빈으로 등록할 수 있습니다.
이처럼, 인터페이스로 추상화시키는 것은 변화에 더욱 유연하게 대처할 수 있도록 만듭니다.
~Service, ~ServiceImpl을 분리하는 것은 전략 패턴(Strategy Pattern)이라고 할 수 있습니다.
인터페이스에 해야 할 역할을 정의하고, 이를 상속 받아 각 전략에 맞게 기능을 구현하는 것입니다.
인터페이스로 분리한 덕분에, 구현체에 의존하지 않고 개발할 수 있습니다. 이는 결합도(Coupling)를 낮춰서 변화가 생겨도 유연하게 받아들일 수 있습니다.
결국 인터페이스로 추상화시킴으로써 객체지향의 설계 원칙을 지킴과 동시에 Java의 다형성까지 활용할 수 있습니다.
정리
이론적인 설명을 위해 간단한 예시를 들었지만, 실제 프로젝트를 진행할 때 인터페이스로 추상화하는 것이 도움이 될 때가 많습니다.
프로젝트를 진행하면서 다양한 이유로 인해 요구사항은 수시로 변화합니다. 때문에, 변화에 항상 대처할 필요가 있습니다.
만약, 특정 기능이 바뀌어야 한다면 인터페이스를 기반으로 새롭게 개발할 수 있어 보다 수월하게 개발할 수 있습니다.
보다 유연하고 확장성 있는 프로그램이 되어가는 것입니다.
반면, 간단한 기능마저도 추상화를 해버린다면 DI, 상속 등 불필요한 오버 엔지니어링이 발생할 수 있습니다.
이는 자연스럽게 프로그램의 복잡도를 높이게 됩니다.
복잡도가 증가한다는 문제도 있지만, 잘 설계된 인터페이스 단위로 관리를 한다면 충분히 커버할 수 있다고 생각합니다.
물론, '잘 설계된' 것을 만들기 어려운 것은 사실이나, 블록처럼 맞아 떨어지는 프로그램을 관리하는 것이 되려 피로도를 낮출 수 있기 때문입니다.
전체적인 아키텍처를 정리하는 문서나 명세서 등을 통해 유지보수의 비용을 최소화하면, 추후 확장할 때의 비용도 줄일 수 있다고 생각합니다.
제 프로젝트의 코드는 많은 고민 끝에 짜여지는 코드지만, 아직 부족한 점이 많습니다.
특히, SRP와 ISP를 제대로 지키지 못하고 있습니다. 복잡도의 증가와 책임을 몰아주는 것 사이의 판단이 힘들기 때문입니다.
꾸준히 학습하고 경험을 쌓으면서 개선해나갈 것입니다.
'개발 > Spring & Spring boot' 카테고리의 다른 글
[Spring] 전역 예외 처리를 위한 @ControllerAdvice와 @RestControllerAdvice (0) | 2022.07.06 |
---|---|
[Spring] API 문서 자동화를 위한 Swagger 3.0.0 적용 (0) | 2022.06.29 |
[Spring] JPA, RDS, MySQL 연동 시 연결 안됨-CommunicationsException (0) | 2022.04.04 |