이 글은 멀티 모듈을 적용하는 "방법"이 아닌 "어떤 기준"으로 모듈화를 적용했는지에 대한 글이다.
💦 기존 구조
- chaewsstore
- admin
- app
- core
admin과 app 서버가 있고 공통으로 사용하는 코드들을 core 모듈에 모아놓은 구조이다.
문제점
1. 의존성
공통 모듈에 너무 많은 의존성이 생기게 되었다. Spring Security, JPA 등 프로젝트에서 사용하는 대부분의 의존성이 공통 모듈로부터 시작되었다. 문제는 애플리케이션들이 사용하는 의존성은 다를 수 있는데 스프링 부트의 특징인 AutoConfiguration을 통해 필요하지 않은 기능들이 자동으로 추가되어 최적화되지 못한 코드가 되게 된다.
2. 공통 설정
해당 구조라면 admin과 app 모듈의 설정까지 모듈에 두는 상황이 된다. 공통으로 사용되어야 하는 호스트 정보 등은 공통으로 볼 수 있겠지만, 그 외의 Thread Pool, Connection Pool, Timeout 등의 설정도 가장 민감하고 중요한 애플리케이션 기준으로 설정되게 된다.
이때 발생하는 대표적인 문제로는 데이터베이스 커넥션이 있다. 모든 데이터베이스에는 가질 수 있는 최대 커넥션 개수가 정해져 있는데, 데이터베이스를 사용하지 않는 애플리케이션에서 공통 모듈 사용을 위해 사용되는 커넥션으로 인해 문제가 생길 수 있게 된다.
3. 스파게티 코드
스파게티 코드로 인해 특정 기능이 사라져도 의존도가 높으면 클래스를 수정할 수 없게 된다.
멀티 모듈에 관한 영상들을 보고 구조에 대해 다시 한번 생각해 보았다.
아래는 내가 영상들을 보고 간단히 정리해 놓은 글이다.
🔥 수정된 구조
- {project-name}-admin
- 모든 모듈에 의존
- H2 의존성 주입
- Spring Security 의존성 주입
- Spring REST Docs 의존성 주입
- {project-name}-app
- 모든 모듈에 의존
- H2, Spring Security, Spring REST Docs 의존성 주입 (admin과 마찬가지)
- {project-name}-core
- domain
- global-utils 모듈만 의존
- JPA, QueryDsl 의존성 주입
- 엔티티와 관련한 데이터베이스 작업을 처리하는 도메인 서비스 생성
- 비즈니스 로직과 관련이 없는 엔티티 CRUD 작업을 캡슐화한 도메인 서비스를 만들고 레포지토리 접근 제한자를 default(package-private)으로 설정해 동일한 패키지 내에서만 접근 가능하도록 제한하였다.
- 패키지 외부에 레포지토리를 노출하지 않아 불필요한 클래스나 인터페이스가 외부에 노출되는 것을 방지하였다.
- infra
- global-utils 모듈만 의존
- infra 모듈은 인프라스트럭처 관련 코드만 포함하고, 도메인 엔티티에 접근하지 않도록 유지
- Spring Security, JWT, P6Spy 의존성 주입
- infra 모듈에 JWT 관련 기능을 두어, admin 모듈과 app 모듈 등 다른 모듈에서 해당 기능을 재사용할 수 있도록 하였다.
- 추후 인증 방식이나 보안 정책 변경 시 해당 모듈만 수정하면 되기 때문에 확장성에 용이하다.
- domain
- global-utils
- 프로젝트 내의 어떠한 모듈도 의존하지 않음
- 공통으로 사용하는 어노테이션, response 형식, exception 등 정의
chaewsstore (root)
├── chaewsstore-admin
│ └── com.chaewsstore
│ └── apis # 각 usecase 별 패키지 ex) auth, bid, user
│ ├── controller
│ ├── dto
│ ├── helper # 해당 모듈이나 기능과 관련된 다양한 보조 작업 수행
│ └── usecase # 비즈니스 로직 구현, 특정 기능이나 시나리오에 대한 작업 수행
│ ├── common # 프로젝트에서 공통적으로 사용되는 유틸리티, 예외 처리
│ └── config # 프로젝트 각종 설정
├── chaewsstore-app
│ └── com.chaewsstore # 어드민과 동일
├── chaewsstore-core
│ └── com.chaewsstore.core
│ ├── domain # 각 도메인별 엔티티, 레포지토리, 서비스, dto, 에러 코드 등
│ └── infra # 설정 정보를 동적으로 로드하고 처리하는 기능 제공
└── global-utils # 여러 유틸리티 클래스, 공통 기능 제공
└── com.globalutils
├── annotation # 스프링 애플리케이션 구성 요소를 정의 · 관리하기 위한 커스텀 어노테이션 제공
├── exception # 다양한 예외 상황을 다루기 위한 기반 클래스와 유틸리티 포함
└── response # HTTP 응답 관련 클래스 제공
+ 내용 계속 추가중
🪵 주 변경 내용
build.gradle api 사용 X
예를 들어 위와 같이 Project C는 B에 의존하고, B는 A에 의존할 경우, Project C도 A에 의존하게 되는 것을 전이 의존성 또는 추이 의존성이라고 한다.
📌 api
api는 전이 의존성을 허용한다.
예를 들어 Project C가 implementation을 통해 B에 의존하고, B가 api를 사용하여 A에 의존할 경우 Project C는 implementation을 통해 Project B의 컴파일 경로, 런타임 경로 등이 노출됨과 동시에 Project B의 api의 전이 의존성을 통해 Project A도 사용 가능하다.
📌 implementation
implementation의 경우 전이 의존성이 허용되지 않는다.
위의 경우 Project B에서 implementation을 사용하여 A에 의존하고 있으므로, Project C에서 A의 코드에 접근이 불가능하다. 만약 A의 코드에 접근을 시도할 경우 컴파일 에러가 발생한다.
상위 모듈에 주입한 라이브러리를 하위 모듈에서 접근하려면 의존성 옵션을 api로 선언해야 접근이 가능하다. 따라서 각 모듈에 implementation으로 의존성을 주입하는 것 대신 상위 모듈에만 api를 사용하여 의존성을 주입할 경우 편리하다는 장점이 있지만 의도치 않은 의존성이 노출될 수 있다는 치명적인 단점이 있다.
implementation을 사용하면 하위 모듈이 상위 모듈의 라이브러리에 접근을 시도할 경우 컴파일 에러가 발생하기 때문에 불필요한 컴파일 의존성을 사전 차단할 수 있어 모든 모듈에서 api 사용하지 않기로 규칙을 정하였다.
단일 책임 원칙(SRP) 준수
단일 책임 원칙(SRP: Single Responsibility Principle)은 로버트 마틴이 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙(SOLID) 중 하나이다.
SRP 원칙은 "하나의 모듈이 하나의 책임을 가져야 한다"고 해석하는 대신 "모듈이 변경되는 이유가 한 가지여야 함"으로 받아들여야 한다. 여기서 변경의 이유가 한 가지라는 것은 해당 모듈이 여러 대상 또는 액터들에 대해 책임을 가져서는 안 되고, 오직 하나의 액터에 대해서만 책임을 져야 한다는 것을 의미한다.
하지만 이 '책임'의 범위는 주관적이라 기준을 개발자 스스로 정해야 한다. 나의 경우 기존 서비스 코드에서 크게 세 가지로 책임을 더 나누어보았다.
1️⃣ 도메인 서비스 / 애플리케이션 서비스 구분
🍎 도메인 서비스 (Domain Service)
- 도메인 서비스는 도메인 객체의 상태를 조회하거나 변경할 때 사용된다.
- 외부 시스템과의 통신이나 인프라 관련 코드를 포함하지 않는다.
🍏 애플리케이션 서비스 (Application Service)
- 애플리케이션 서비스는 사용자의 요청을 처리하고, 비즈니스 로직을 실행하며, 트랜잭션을 관리한다.
- 주로 도메인 서비스를 호출하여 비즈니스 로직을 수행한다.
🧐 두 서비스를 구분하는 이유
- 책임 분리
- app 혹은 admin 모듈의 애플리케이션 서비스는 비즈니스 로직에 집중하고, infra 모듈의 도메인 서비스는 데이터 접근 로직에 집중하도록 모듈의 역할을 명확히 분리하였다.
- 접근 제어
- infra 모듈 내의 레포지토리들의 접근제한자를 default로 설정하여 레포지토리들이 해당 패키지에서만 사용될 수 있다.
- 따라서 infra 모듈의 내부 구현 세부사항이 외부로 노출되지 않도록 하고, 모듈 간의 의존성을 명확하게 관리할 수 있다.
⬅️ 기존 코드
기존에 작성했던 코드에서는 각 레이어의 역할을 Controller - Service - Repository로 분리(MVC 패턴)하여 개발했었다.
@RequiredArgsConstructor
@Service
public class AuthService {
private final AccountRepository accountRepository;
private final RefreshTokenRepository refreshTokenRepository;
// ...
@Transactional
public LoginResponseDto login(LoginRequestDto request) {
Account account = accountRepository.findByUsername(request.email())
.orElseThrow(() -> NOT_FOUND_ACCOUNT);
account.validatePassword(request.password(), passwordEncoder);
// 인증, 토큰 발급 로직(해당 부분에서 주의깊게 보지 않아도 됨)
UsernamePasswordAuthenticationToken authenticationToken = request.toAuthentication();
Authentication authentication = authenticationManager.authenticate(authenticationToken);
String accessToken = tokenProvider.createAccessToken(authentication);
RefreshToken refreshToken = RefreshToken.create(account,
tokenProvider.createRefreshToken());
refreshTokenRepository.save(refreshToken);
TokenDto token = TokenDto.of(accessToken, refreshToken.getToken(), BEARER_TYPE);
return new LoginResponseDto(account.getId(), token);
}
}
따라서 이전 코드에서는 로그인 기능의 경우 인증 관련 모든 로직이 AuthService에 집중되어 있었다.
➡️ 변경 코드
AuthService 클래스를 크게 AuthUseCase와 AccountService로 나누어 AuthUseCase에서 인증 관련 애플리케이션 로직, AccountService에서 계정 관련 데이터 접근과 비즈니스 로직을 담당하도록 하였다. 이로 인해 app(또는 admin) 모듈에서 레포지토리를 직접 의존하지 않고 레포지토리와 같은 패키지에 위치한 도메인 서비스에서 레포지토리를 의존하게 되었다.
@RequiredArgsConstructor
@UseCase
public class AuthUseCase {
private final AccountService accountService;
// ...
@Transactional
public LoginResponseDto login(LoginRequestDto request) {
Account account = accountService.readByUsername(request.email())
.orElseThrow(() -> NOT_FOUND_ACCOUNT);
// ...
return new LoginResponseDto(account.getId(), token);
}
}
@RequiredArgsConstructor
@DomainService
public class AccountService {
private final AccountRepository accountRepository;
@Transactional(readOnly = true)
public Optional<Account> readByUsername(String username) {
return accountRepository.findByUsername(username);
}
}
도메인 서비스와 레포지토리가 같은 패키지에 위치해 있기 때문에 레포지토리 접근 제한자를 default(package-private)으로 설정할 수 있어 레포지토리가 외부에 노출되는 것을 방지할 수 있게 되었다.
interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByUsername(String username);
}
2️⃣ Helper 클래스를 통한 책임 분산
이전 단계에서 도메인 서비스 로직과 애플리케이션 서비스 로직을 분리하여 책임을 나누었고, 추가적으로 Helper 클래스를 통해 책임을 더 분산해 보았다.
⬅️ 기존 코드
@RequiredArgsConstructor
@UseCase
public class AuthUseCase {
// ...
private final TokenProvider tokenProvider;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
@Transactional
public LoginResponseDto login(LoginRequestDto request) {
// ... account 관련 로직 생략
// 비밀번호 검증 로직
account.validatePassword(request.password(), passwordEncoder);
// 인증 관련 로직
UsernamePasswordAuthenticationToken authenticationToken = request.toAuthentication();
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 토큰 생성, 저장 로직
String accessToken = tokenProvider.createAccessToken(authentication);
RefreshToken refreshToken = RefreshToken.create(account,
tokenProvider.createRefreshToken());
refreshTokenRepository.save(refreshToken);
TokenDto token = TokenDto.of(accessToken, refreshToken.getToken(), BEARER_TYPE);
return new LoginResponseDto(account.getId(), token);
}
}
해당 예시 코드처럼 애플리케이션 서비스 클래스에서 JWT 토큰의 처리 로직을 직접 담당하게 될 경우 이 클래스는 "유저 인증 관련 비즈니스 로직"과 "JWT 토큰 처리"라는 두 가지 책임을 가지게 된다. 즉, 변경의 이유가 한 개를 초과할 뿐만 아니라 다른 유스케이스에서 JWT 토큰 처리를 사용할 때 중복 코드가 발생하게 된다.
또 만약 기존 비밀번호 암호화 정책을 개편할 경우 새로운 암호화 정책과 무관한 AuthUseCase를 다음과 같이 수정해야 되는 문제가 발생한다.
@RequiredArgsConstructor
@UseCase
public class AuthUseCase {
private final SHA256PasswordEncoder passwordEncoder;
...
}
이는 확장에는 열려있고 수정에 대해서는 닫혀있어야 한다는 개방 폐쇄 원칙(Open-Closed Principle, OCP)에 위배된다.
해당 로직에서 변하지 않는 것은 사용자를 추가할 때 암호화가 필요하다는 것이고, 변하는 것은 사용되는 구체적인 암호화 정책이다. 따라서 AuthUserCase는 어떤 구체적인 암호화 정책이 사용되는지는 알 필요 없이 passwordEncoder 객체를 통해 암호화가 된 비밀번호를 받기만 하면 된다. 그러므로 AuthUserCase는 구체적인 암호화 클래스에 의존하지 않고 PasswordEncoderHelper라는 헬퍼 클래스에 의존하도록 추상화해 개방 폐쇄 원칙을 충족시키는 코드를 작성할 수 있다.
➡️ 변경 코드
비밀번호 인코딩 및 검증을 담당하는 헬퍼 클래스와 JWT 토큰 관련 일을 처리하는 헬퍼 클래스를 따로 나누어 단일 책임 원칙을 준수하도록 하였다.
@RequiredArgsConstructor
@UseCase
public class AuthUseCase {
// ...
private final JwtAuthHelper jwtAuthHelper;
private final AuthenticationManager authenticationManager;
private final PasswordEncoderHelper passwordEncoderHelper;
@Transactional
public LoginResponseDto login(LoginRequestDto request) {
// ...
// PasswordEncoderHelper 사용
if (!passwordEncoderHelper.matches(request.password(), account.getPassword())) {
throw INVALID_PASSWORD;
}
// 인증 관련 로직(그대로 유지)
UsernamePasswordAuthenticationToken authenticationToken = request.toAuthentication();
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// JwtAutheHelper 사용
Jwts token = jwtAuthHelper.generateTokensAndSave(account, authentication);
return new LoginResponseDto(account.getId(), token);
}
}
@RequiredArgsConstructor
@Helper
public class PasswordEncoderHelper {
private final PasswordEncoder passwordEncoder;
public String encodePassword(String password) {
// ..
}
public boolean matches(String rawPassword, String encodedPassword) {
return passwordEncoder.matches(rawPassword, encodedPassword);
}
}
@RequiredArgsConstructor
@Helper
public class JwtAuthHelper {
private final TokenProvider tokenProvider;
private final RefreshTokenService refreshTokenService;
public Jwts generateTokensAndSave(Account account, Authentication authentication) {
String accessToken = tokenProvider.generateAccessToken(authentication);
String refreshToken = tokenProvider.generateRefreshToken();
refreshTokenService.create(RefreshToken.create(account, refreshToken));
return Jwts.of(accessToken, refreshToken, BEARER_TYPE);
}
}
이제 AuthUseCase 클래스는 유저 인증과 관련된 비즈니스 로직에만 집중하게 되고, 비밀번호 관련 로직은 PasswordEncoderHelper, JWT 토큰 생성 및 저장 로직은 JwtAuthHelper가 담당하게 되어 관심사의 분리가 이루어졌다.
이러한 유연한 의존성 주입을 통해 클래스 간 결합도가 낮아져 필요한 경우 쉽게 모듈을 교체하거나 변경할 수 있다. 또한 특정한 일을 하는 메서드들이 Helper 클래스에 캡슐화되어, 다른 클래스들이 해당 로직의 처리 방식을 신경 쓰지 않아도 된다. 더불어 PasswordEncoderHelper나 JwtAuthHelper의 구현을 변경할 때 다른 클래스에 영향을 주지 않는다는 장점도 있다.
📚 참고
[OOP] 객체지향 프로그래밍의 5가지 설계 원칙, 실무 코드로 살펴보는 SOLID
'Back-end' 카테고리의 다른 글
Jmeter를 사용한 동시성 테스트 (0) | 2024.07.05 |
---|---|
[Spring] Spring Security를 이용한 JWT 기반 인증 및 인가 설정 (0) | 2024.06.22 |
[Gradle] 자바 플러그인, implementation과 api의 차이 (1) | 2024.05.23 |
[Spring Boot] 멀티 모듈 참고 영상 및 정리 (0) | 2024.05.17 |
[JPA] JPA Entity에서의 equals(), hashCode() (0) | 2024.05.16 |