도메인 로직과 유스케이스
핵심 비즈니스 로직
- 현실 세계의 비즈니스 규칙 그 자체
유스케이스
- 사용자가 시스템을 통해 어떤 목적을 달성하는 흐름
예시
도메인 로직:
- 게시글 제목은 비어 있을 수 없다.
- 삭제된 게시글에는 댓글을 달 수 없다.
- 작성자만 게시글을 수정할 수 있다.
유스케이스:
- 게시글 작성하기
- 게시글 수정하기
- 게시글 삭제하기
- 게시글 목록 조회하기
즉, 유스케이스는 도메인 로직을 사용해서 하나의 기능 흐름을 완성하는 계층이다.
유스케이스는 공통모듈에 담지 않는 것이 좋다
- 유스케이스를 공통 코드로 뺀다면, 사이드 이펙트가 발생하기 쉬울 뿐만 아니라 예외 케이스를 위한 분기 처리도 들어가게 되면서 많은 문제가 발생한다
- 또한 유스케이스는 서비스마다 요구 사항이 다르기 쉽기 때문이다
예를 들어 게시글 작성 이라는 이름은 같아도,
board-service:
- 게시글 저장
- 작성자 검증
- 카테고리 검증
community-service:
- 게시글 저장
- 알림 발송
- 포인트 지급
- 신고 제한 검증
이렇게 흐름이 달라질 수 있다
이걸 공통 모듈에 넣으면 결국 아래와 같은 코드가 생기기 쉽게 된다
if (serviceType==BOARD) {
...
}else if (serviceType==COMMUNITY) {
...
}
그러면 공통 모듈이 오히려 더러워지고, 한 서비스 수정이 다른 서비스에 영향을 주는 사이드 이펙트가 생김.
그래서 정리하면:
공통 모듈에 넣어도 되는 것:
- 공통 예외
- 공통 응답 형식
- 공통 유틸
- 공통 인증 객체
- 공통 타입
- 정말 안정적인 shared kernel
공통 모듈에 넣으면 위험한 것:
- 유스케이스
- 서비스별 비즈니스 흐름
- 특정 서비스의 Repository
- 특정 서비스의 Entity
- 특정 서비스의 Application Service
자동 빈 등록 방식을 피해야한다
공통 인프라 설정 모듈에서는 컴포넌트 스캔에 의존한 자동 등록을 피하고, 각 서비스가 필요한 설정만 명시적으로 선택하도록 하는 것이 좋다.
주의점
모든 @Component, @Service, @Repository를 쓰지 말라는 뜻이 아니다
- 일반적인 서비스 내부에서는 여전히 이렇게 써도 됨
@Service
publicclassCreateBoardUseCase {
...
}
@Repository
publicinterfaceBoardRepositoryextendsJpaRepository<Board,Long> {
}
다만 문제는 공통 모듈에 있는 인프라 설정 클래스
- 컴포넌트 스캔 기반의 자동 등록 방식을 사용해서는 인프라 구성을 제어할 수 없다.
- 또한 해당 방식은 어떤 인프라 구성들이 존재하는지, 무엇이 현재 모듈에 적용되는지 명시적으로 파악하기가 어렵다
예를 들어 공통 모듈에 이런 설정이 있다고 해보자
@Configuration
@EnableJpaRepositories(...)
public class JpaConfig {
}
이게 컴포넌트 스캔으로 자동 등록된다면, 어떤 서비스에서는 JPA가 필요없는데도 JPA 설정이 들어올 수 있다.
또는 어떤 서비스는 Redis만 필요한데, JPA, Async, Jasypt까지 딸려올 수도 있다
따라서 우리는 인프라 구성에 대한 제어권을 획득할 수 있는 방법을 사용하는 것이 좋은데, 이 때 Spring 프레임워크에서 제공하는 ImportSelector를 이용하여 이를 구현해보도록 하자
인터페이스를 구성하자
각 서비스가 필요한 인프라 설정만 선택해서 사용한다.
먼저, 인프라 구성 타입을 추상화한 인터페이스를 생성해야한다.
해당 인터페이스를 구현한 클래스는 다른 모듈에서 적용될 수 있음을 나타낸다
public interface BoardInfraConfig {
}
모든 인프라 구성들은 해당 인터페이스를 구현한다. 구성 클래스들에는 자동 빈 스캔 방식에 의해 등록되지 않도록 @Component 를 포함한 하위 어노테이션들이 존재하지 않는다.
설정 클래스의 의미 붙이기
@Configuration(proxyBeanMethods = false)
@EnableAsync
public class AsyncConfig implements BoardInfraConfig {
...
}
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement
@EntityScan("com.mangkyu.appstore")
@EnableJpaRepositories("com.mangkyu.appstore")
public class JpaConfig implements BoardInfraConfig {
...
}
@Configuration(proxyBeanMethods = false) 를 붙이는 게 의도를 명확히 드러내기에 안전하다
하지만 이렇게 하면 @Configuration도 결국 내부적으로는 @Component 계열이라서 컴포넌트 스캔 대상이 될 수 있다.
그래서 자동 등록을 피하려면 아래와 같다
1. 해당 설정 클래스를 컴포넌트 스캔 범위 밖에 두거나
2. 메인 애플리케이션의 scanBasePackages에 포함되지 않게 하거나
3. @Import / ImportSelector로만 가져오도록 구조를 잡아야 함
즉 ,@Configuration 을 붙이면 무조건 문제가 아니라, 스캔으로 자동 등록되지 않게 패키지 구조를 잘 잡는 게 중요하다
이제 구성 클래스들을 모아둔 enum 클래스를 하나 생성한다.
이를 통해 인프라 구성들이 존재하는지, 무엇을 적용할지 손쉽게 파악할 수 있다.
@Getter
@RequiredArgsConstructor
public enum BoardConfig{
ASYNC(AsyncConfig.class),
JASYPT(JasyptConfig.class),
JPA(JpaConfig.class),
;
private final Class<? extends BoardConfig> configClass;
}
여기까지 했다면 적용할 인프라 구성을 명시할 @Enable 어노테이션과 어노테이션에 존재하는 인프라 구성을 적용시켜 줄 ImportSelector를 구현한다. ImportSelector는 애플리케이션이 실행되는 시점에 해당 어노테이션에 붙은 Enum 값들을 기반으로 동적으로 구성 파일들을 불러온다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(BoardConfigImportSelector.class)
public @interface EnableBoardConfig {
BoardConfigGroup[] value();
}
ImportSelector를 구현할 때는 ImportSelector의 하위 인터페이스인 DeferredImportSelector를 사용하면 된다.
DeferredImportSelector는 모든 @Configuration 빈들이 처리된 후에 동작하는 ImportSelector이다. 선택된 설정들이 @Conditional 조건을 갖고 있는 경에 유용하다.
이를 처리하기 위해 EnableBoardConfig의 value에 할당된 Enum 값들을 모두 불러온 후에 configClass 값을 가져와야한다. 이러한 역할을 담당하는 ImportSelector 구현체는 다음과 같이 만들어줄 수 있다.
public class BoardConfigImportSelector implements DeferredImportSelector {
@Override
public String[] selectImports(AnnotationMetadata metadata) {
return Arrays.stream(getValues(metadata))
.map(value -> value.getConfigClass().getName())
.toArray(String[]::new);
}
private BoardConfigGroup[] getValues(AnnotationMetadata metadata) {
Map<String, Object> attributes =
metadata.getAnnotationAttributes(EnableBoardConfig.class.getName());
if (attributes == null) {
return new BoardConfigGroup[]{};
}
return (BoardConfigGroup[]) attributes.getOrDefault(
"value",
new BoardConfigGroup[]{}
);
}
}
그러면 이제 다음과 같이 개별 모듈들에서 적용할 인프라 구성을 동적으로 그리고 명시적으로 선택할 수 있다. 이를 통해 인프라 구성에 대한 제어권을 획득할 수 있게 된 것이다.
@Configuration(proxyBeanMethods = false)
@EnableAppStoreConfig({
BoardConfigGroup.ASYNC,
BoardConfigGroup.JASYPT,
BoardConfigGroup.JPA,
})
class InfraConfig {
}
하지만 이렇게 하더라도 엄격하게 공통 모듈을 관리해야할 수도 있다 아래 링크에 자세히 나와있다
https://mangkyu.tistory.com/304
결론
- 공통 모듈은 최대한 작고 안정적으로 유지한다.
- 유스케이스는 각 서비스 내부에 둔다.
- 인프라 설정은 필요한 서비스가 명시적으로 선택하게 한다.
- 컴포넌트 스캔으로 의도치 않은 Bean이 등록되지 않게 한다.
📚참고자료
'CS > Architecture' 카테고리의 다른 글
| DDD(Domain-Driven Design)란? 핵심 개념과 계층형 아키텍처 (0) | 2026.07.11 |
|---|---|
| 객체 간의 결합도(Coupling)와 응집도(Cohesion)의 관계 (0) | 2026.07.10 |
| [TIL] 객체 지향 프로그래밍 (OOP) 핵심 개념 총정리 (1) | 2025.10.15 |