[Spring Boot] Service에서 다른 Service 의존에 대하여
들어가며
회사에서 진행하는 신규 프로젝트의 AA 역할을 맡아 API 프로젝트를 아키텍처링했다. 기존 프로젝트에서 API 서버와 UI 서버를 분리하고, 레거시 코드를 리팩터링 하는 등 개편과 동시에 기능 개발을 진행하는 프로젝트였다.
필자가 맡았던 프로젝트는 기능이 꽤나 복잡하나 프로젝트였다. 예를 들어, 리뷰를 작성하면 포인트도 주고, 쿠폰도 주고, 등급 점수도 올라갔다. 그런데 이 리뷰 작성을 하는 방식이 총 4가지가 있었다. 혹은 A라는 기능은 메인 페이지, 마이 페이지, 특정 동작 완료 페이지에서 사용했다. 또, B라는 기능은 B-1, B-2, ..., B-10이라는 기능으로 고도화되었다.
왜 이렇게 비즈니스 로직이 복잡하게 기획되었는지는 나중에 생각하고, 당장 이 상황에 맞는 설계가 필요했다. 하나의 기능이 어디에서 재사용될지 모르고, 어떤 유사한 기능이 새롭게 추가될지 모르는 상황에서 여러 개발자들이 쉽게 개발 및 유지보수를 할 수 있고, 재사용할 수 있는 그런 설계를 하고 싶었다.
프로젝트를 진행하며 Service 단위로 개발된 기능을 재사용하다 보니 Service에서 다른 Service를 의존하는 상황이 자주 발생했고, 결국에는 Service 순환참조가 빈번하게 발생하였다. 그리고 여러 시도 끝에 적합한 아키텍처링을 할 수 있었다.(그나마...)
이번 글에선 필자가 경험해 본 Service에서 다른 Service를 이존하는 방법에 대해 정리해보려 한다. 참고로 JPA가 아닌 MyBatis를 사용하였다.
이번 글에선 service 간 의존을 해결한 방법에 대해서만 설명하겠다.
1. Service는 무조건 DAO와 의존관계 가지기
가장 처음 생각한 방법은 Service 계층은 무조건 DAO 계층만을 의존하는 방법이다. 이 방법은 프로젝트가 단순한 기능 (CRUD)만을 필요로 한다면 가장 좋은 방법이라고 생각한다.
@Mapper
public interface TestMapper {
// ...
}
@Mapper
public interface TestOtherMapper {
// ...
}
// interface
public interface TestService {
// ...
}
// class
@Service
@RequiredArgsConstructor
public class TestServiceImpl implements TestService {
private final TestMapper testMapper;
private final TestOtherMapper testOtherMapper;
// ...
}
그러나 해당 방식은 프로젝트가 복잡한 기능을 요구하는 경우 비효율적이었다. 하나의 Service가 여러 DAO를 관리하기 때문에 그만큼 Service의 책임감이 무거워지게 됐다. 또, 단순하게 DAO만 의존하여 해결할 수 없는 복잡한 기능을 여러 곳에서 사용해야 하는 경우에는 해당 기능을 Service 계층에 개발하여 재사용하는 것이 불가능하기 때문에 중복되는 코드가 많아진다는 단점이 있었다.
2. Controller에서 여러 Service 기능을 호출하기
다음으로 생각한 방법은 Controller에서 여러 개의 Service를 의존하고, 한 번에 여러 개의 Service 기능을 호출하는 방법이다.
@Mapper
public interface TestMapper {
// ...
}
@Mapper
public interface TestOtherMapper {
// ...
}
// interface
public interface TestService {
void a();
}
// interface
public interface TestOtherService {
void b();
}
// class
@Service
@RequiredArgsConstructor
public class TestServiceImpl implements TestService {
private final TestMapper testMapper;
@Transactional
@Override
public void a() {
// ...
}
}
// class
@Service
@RequiredArgsConstructor
public class TestOtherServiceImpl implements TestOtherService {
private final TestOtherMapper testOtherMapper;
@Transactional
@Override
public void b() {
// ...
}
}
@RestController
@RequiredArgsConstructor
public class TestController {
private final TestService testService;
private final TestOtherService testOtherService;
@GetMapping(value = "/test")
public void test() {
// testService a 메소드 호출
testService.a();
// testOtherService b 메소드 호출
testOtherService.b();
}
}
그러나 해당 방식은 트랜잭션의 원자성을 지키지 못했다. TestService의 a()와 TestOtherService의 b()가 모두 성공한 경우에만 DB에 데이터를 적용하고 싶었다. 하지만 a()와 b()가 각각 다른 트랜잭션 단위에서 동작하기 때문에 반드시 모두 실행되거나 아예 실행되지 않아야 한다는 트랜잭션의 원자성을 지키지 못하는 단점이 있었다.
3. Service layer를 구분하여 Service 간 의존하기
마지막으로 생각한 방법은 Service layer를 구분하고, Service 간 의존하는 방법이다. Service layer를 구분하고 의존성 방향을 고정하여 순환참조를 방지하였다. 더 나아가 전체적인 의존성 방향을 한 방향으로 고정했다.
Module Service layer는 DAO만을 의존하며, 하나의 기능만을 담당하는 저수준 인터페이스 패키지 계층이다.
Component Service layer는 Module Service만을 의존하며, Module Service의 조합으로 하나의 복합 기능을 담당하는 고수준 인터페이스 패키지 계층이다.
Component Service layer는 디자인 패턴 중 파사드 패턴 (Facade Pattern) 방식을 사용하였다. 저수준 인터페이스인 Module Service를 하나의 고수준 인터페이스인 Component Service로 묶어주었다. Component Service에 트랜잭션을 적용하여 여러 기능을 하나의 트랜잭션 단위에서 동작 가능하도록 했다. 물론 Module Service는 독립적으로 사용 가능하기 때문에, 필요에 따라 재사용이 가능하다.
필자는 해당 구조를 개발 컨벤션으로 지정했다. 또, 고수준 인터페이스인 Component Service 중복 개발을 방지하기 위해 고수준 인터페이스 명세서를 작성하도록 정했다. 개발자는 Controller 개발 시 Module Service나 Component Service를 상황에 맞게 사용하기만 하면 된다.
정리하며
필자는 마지막 방법인 Service layer를 구분하는 방법을 사용하였다. 덕분에 복합적인 기능을 하나의 트랜잭션에서 관리하며, 동시에 순환참조 또한 방지할 수 있었다. 그리고 이러한 구조를 지키기 위해 가이드 문서를 작성하여 개발팀에 공유하였다.
Component Service layer 설계에 사용한 파사드 패턴(Facade Pattern)에 대한 설명은 해당 글을 참고하면 된다.
링크 : https://jangjjolkit.tistory.com/61
혹시나 더 좋은 방법이나 개선할 부분이 있다면 댓글에 남겨주시면 감사하겠습니다!