1. As-is. Service 계층의 순환 참조
Spring을 가장 처음 배우면, Web Application 5계층에 대해서 배우게 된다.
그리고 User라는 Domain에 대해 코드를 작성하면 아래 클래스들을 작성하고 시작한다.
- UserController
- UserService
- UserRepository
- UserDto
간단한 CRUD 기능만을 구현할 거라면 전혀 문제가 되지 않지만,
복잡한 Use case에 대한 비지니스 로직을 처리해야 하는 경우 Service가 Service를 호출하여 순조롭게 순환참조로 Application이 오작동 하는 상황을 마주할 수 있게 된다.
예를 들어, 내가 (처음) 구현했던 JWT을 이용해 로그아웃 과정을 수행하려면 다음 일련의 작업을 수행해야 했다.
- Client로부터 access token과 refresh token을 받는다.
- `jwtUtil` 클래스에서 access token의 유효성을 확인한다.
- 유효하다면, access token에서 userId 정보를 얻는다.
- `refreshTokenService`에서 Client가 전송한 refreshToken을 Redis에서 제거한다.
- Client가 전송한 accessToken과 조회한 userId 정보를 `forbiddenTokenService`를 통해 블랙리스트에 등록한다.
이는 Service Layer에서 다른 Service를 호출하는 명백한 계층 위배에 속한다.
물론, 성능을 위해 Layer 위배를 하는 사례가 종종 있는 것은 사실이지만..정말 이게 최선일까?
2. 고민 과정
1️⃣ Service Layer는 반드시 DAO와 의존관계를 가져야 한다고 강제
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class UserAuthService {
private final RefreshTokenRepository refreshTokenRepository;
private final ForbiddenTokenRepository forbiddenTokenRepository;
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
...
}
- 가장 정석적인 방법이며, 계층을 위배하지 않는다. CRUD 기능만 사용한다면 이걸로 충분하다.
- 복잡한 프로젝트의 경우, UserAuthService의 책임이 과하게 무거워진다.
- 코드 재활용이 힘들다.
2️⃣ Controller Layer에서 의존하는 모든 Service 호출
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Slf4j
public class UserAPI {
private final RefreshTokenService refreshTokenService;
private final ForbiddenTokenService forbiddenTokenService;
private final UserSearchService userSearchService;
private final JwtTokenProvider jwtTokenProvider;
...
}
- 합리적이라 볼 수 있으나, Transaction이 지켜지지 않으므로 사용할 수 없다.
3. To-be. Service 계층 분리
그렇게 정처없이 방황하다가 찾은 획기적인 발상..
Service Layer를 상위 계층(Component Service)와 하위 계층(Module Service)로 분리한다.
이 때, 각 Service Layer가 지켜야 하는 규칙은 다음과 같다.
- Component Service
- DAO를 의존해서는 안 된다.
- 하나의 작업은 Transaction을 지켜야 한다.
- 추상화 수준을 지키기 위해 비지니스 로직을 처리해선 안 된다. (이 내용은 필자가 언급하지 않았지만, Component Service는 하나의 명세서처럼 읽혀야 한다고 생각한다.)
- Module Service
- DAO를 의존한다.
- SRP를 준수 해야한다.
위 Architecture를 적용하면 Web Application Layer를 다음과 같이 구성할 수 있다.
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class UserAuthService {
private final RefreshTokenService refreshTokenService;
private final ForbiddenTokenService forbiddenTokenService;
private final UserSearchService userSearchService;
private final JwtTokenProvider jwtTokenProvider;
...
public void logout(String authHeader, String requestRefreshToken) {
String accessToken = jwtTokenProvider.resolveToken(authHeader);
Long userId = jwtTokenProvider.getUserIdFromToken(accessToken);
refreshTokenService.logout(requestRefreshToken);
forbiddenTokenService.register(accessToken, userId);
}
...
}
- `Module Service`가 실패하더라도 Transaction이 보장된다.
- `Component Service`는 비지니스 로직을 처리하지 않아도 된다(굳이 따지자면 하면 안 된다.). 마치 하나의 소설처럼 읽힐 수 있다.
- 즉, 추상화 수준이 적절하게 지켜진다.
- 모든 로직은 `Module Service`가 처리하며, 이를 사용하는 Client는 내부 구현을 알 필요가 없으므로 캡슐화 또한 지켜진다.
이전에도 비슷한 문제로 많이 골머리를 앓았었는데, 최근 프로젝트를 수행하다가 해당 아키텍처를 발견하게 되었고
적용해본 결과 획기적인 결과를 얻을 수 있었다.
나는 Jwt 관련 모듈을 작성하고 있었고, 되도록이면 내가 작성한 모듈이 다른 팀원들이 사용할 때 내부 구현을 알지 않아도 되게끔 구현하고 싶었다.
Service Layer를 분리하면, 내가 원하는 방식을 우아하게 구현할 수 있었고 실제로 같이 프로젝트를 하던 현업자 분께서는 본인의 회사에 해당 아키텍처를 적용하는 것을 건의하기도 했다.
비지니스 로직이 복잡해질 때 적용해보면 굉장히 좋다고 생각한다.