티스토리 뷰
계층형 아키텍처, 헥사고날 아키텍처, 벌티컬 슬라이스 아키텍처 등 다양한 패턴들이 나왔음에도 불구하고, 우리는 아직까지 정착된 아키텍처 패턴을 갖지 못했다. 누군가는 계층형 아키텍처로 개발하면 확장이 어렵다고 말하고, 누군가는 헥사고날 아키텍처로 개발하면 유지보수가 어렵고 공수가 크다고 얘기한다. 이번 포스팅은 여러 가지 아키텍처 패턴을 적용해보면서 느꼈던 각각의 장점을 결합하여 정착한 아키텍처 패턴을 소개한다.
1. 아키텍처 패턴(Architecture Pattern)이란?
[ 아키텍처 패턴의 필요성과 개념 ]
개발자들은 비즈니스 로직 자체를 작성하는 데에도 많은 시간을 소요하지만, 적지 않게 여러 고민을 하는 시간 역시 존재한다. “클래스 이름을 뭐라고 하지?”, “해당 클래스를 어느 패키지에 위치시키지?” 등 소프트웨어 개발에 있어 우리는 항상 의사결정을 내려야 하며, 최선의 판단을 내리기 위해 고민을 한다.
만약 하나의 완성도 높은 그리고 일관된 방식을 정해둔다면 어떨까? 순수하게 코드를 작성하는 데 많은 시간을 쏟을 수 있을 것이다. 또한 내가 아닌 다른 사람의 코드도 비슷하게 작성되어 있어서, 코드 사이의 인지 부하를 최소화하는 이점도 얻을 수 있다. 다른 사람의 코드를 읽는 시간을 줄이거나, 코드의 탐색 시간을 줄이는 등 버스 팩터(Bus Factor)를 높이는 데에도 자연스럽게 기여할 것이다.
이러한 문제의식을 갖고 나온 것이 계층형 아키텍처(Layered Architecture) 혹은 헥사고날 아키텍처(Hexgaonal Architecture) 등과 같은 아키텍처 패턴이라고 볼 수 있다. 아키텍처 패턴(Architecture Pattern)이란, 주어진 문맥 안에서 소프트웨어 아키텍처의 공통적인 발생 문제에 대한 일반적인 그리고 재사용 가능한 해결책이다. 즉, 하나의 공통된 방식을 정해두고 일관된 코드 작성 방식을 가져가자는 것이다.
[ 인지 부하와 실용적인 아키텍처 패턴 ]
인지 부하란 개발자가 작업을 완료하기 위해 생각해야 하는 정도를 의미한다. 일반적으로 복잡성이 높아질수록 많은 혼란이 생기고, 보통 혼란은 높은 인지 부하로 인해 발생한다. 문제는 혼란에는 시간과 비용이 든다는 것인데, 연구 결과에 따르면 인간은 약 4개 정도의 작업 덩어리를 기억할 수 있고, 이를 초과하면 급격한 혼란을 겪는다고 한다.
인지 부하는 내재적 부하와 외재적 부하로 나눠볼 수 있다. 내재적 부하는 작업 그 자체의 순수한 난이도이자 복잡도이므로, 이것을 줄일 수는 없다. 대신 제공되는 정보에 의한 외재적 부하를 최대한 줄여서 인지 부하를 최소화할 필요가 있다. 만약 우리 프로젝트에 복잡한 추상화나 과도한 모듈화 혹은 지나치게 트렌디한 낯선 기술들이 많이 적용되어 있다면, 우리는 높은 인지적 부하를 자연스레 떠안게 된다. 이는 빠르게 비즈니스 로직을 개발해서 제품을 생존시켜야 하는 우리의 역할에 방해가 된다. 따라서 불필요하게 복잡한 부분들을 최소화하고, 지나치게 세분화된 클래스나 모듈을 지양하고, 풍부한 언어 차원의 기능을 지양하고, 코드 추적을 어렵게 만드는 도메인 이벤트 등을 포기하는 선택을 하고자 한다.
소프트웨어 개발은 기획된 문제를 코드로 푸는 과정일 뿐, 우리는 결국 비즈니스 문제를 해결하는 사람들이다. 따라서 개발을 위한 개발을 한다거나 자아 실현을 위한 코드를 작성하는 것은 프로답지 못하다. 따라서 최대한 간단하고 직관적으로 개발하여 실용적인 방향으로 구현하는 것이 합리적이라고 생각한다.
그러면 이어서 스스로 정리한, 실용적인 아키텍처 패턴을 살펴보도록 하자.
2. 도메인 객체 계층(Domain Object Layer)
[ 비즈니스 로직을 갖는 도메인 객체 ]
도메인 객체 계층(Domain Object Layer)은 비즈니스 데이터를 바탕으로 핵심 비즈니스 로직을 수행하는 도메인 객체가 존재하는 계층이다. 핵심 비즈니스 로직은 핵심 비즈니스 데이터 만으로 처리 가능해야 한다. 예를 들어 멤버가 활성 상태인지를 판단하는 isActive와 같은 비즈니스 로직이 도메인 객체에 존재할 수 있다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
private Long id;
private String name;
private LocalDate birthday;
private MemberStatus status;
public boolean isActive() {
return status == MemberStatus.ACTIVE;
}
}
이러한 핵심 비즈니스 로직은 시스템의 가장 중요한 부분이며, 다른 계층의 의존없이 독립적으로 수행될 수 있어야 한다. 이를 위해서는 아키텍처의 가장 안쪽의 계층이 되어야 한다.

이렇게 작성된 도메인 객체의 비즈니스 로직은 도메인 객체만 생성해주면 핵심 비즈니스 로직을 손쉽게 테스트할 수 있다. 또한 여러 곳에서 해당 로직을 재사용할 수 있을 뿐만 아니라 가독성도 높다. 참고로 이렇게 도메인 객체에 비즈니스 로직을 구현하는 방식을 도메일 모델 패턴(Domain Model Pattern)이라고 한다. 도메일 모델 패턴은 많은 장점을 더해주므로 적극적으로 활용해주도록 하자.
[ 단일 엔티티는 외부 인프라에 매핑될 수 있음 ]
현대의 애플리케이션 개발에서는 엔티티를 설계할 때 데이터의 영속화를 고려하지 않을 수 없다. 엔티티는 크게 비즈니스 로직을 처리하는 도메인 엔티티(Domain Entity)와 영속성 계층과 매핑되는 영속성 엔티티(Persistence Entity)로 구분할 수 있다. 따라서 그냥 엔티티라고 얘기하면 도메인 엔티티인지 영속성인지 구분이 어려우므로, 확실히 표현할 필요가 있다.
하지만 현실에서는 그냥 엔티티라고 표현해도 소통에 큰 어려움이 없는데, 그 이유는 대부분 단일한 엔티티에서 2가지 역할을 모두 구현하고 있기 때문이다. 둘을 별도의 클래스로 세분화하는 것이 바람직할 수도 있지만 이는 개발 비용 뿐만 아니라 유지 보수 비용을 상당히 증가시킬 수 있다. 또한 실제 업무를 해보면 둘이 결합되어 있는 것이 훨씬 실용적임을 느낄 수 있다. 왜냐하면 현실에서는 결국 데이터가 핵심이라 데이터베이스를 많이 건드리게 되고, 테이블 및 컬림 이름 등의 정보가 매우 중요하기 때문이다. 따라서 기본적으로 두 가지 역할을 겸하는 엔티티를 사용하다가, 분리가 필요해지는 시점에 분리하는 것이 효율적이다. 즉, 비즈니스를 담당하는 엔티티에 JPA 어노테이션 등을 허용하여 보다 실용적으로 개발하자는 관점이다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private LocalDate birthday;
@Enumerated(EnumType.STRING)
private MemberStatus status;
public boolean isActive() {
return status == MemberStatus.ACTIVE;
}
}
누군가는 영속성 어노테이션 등이 순수 비즈니스를 다루어야 하는 도메인 계층에 침투한다고 주장할 수 있다. 하지만 모든 엔티티마다 영속성 엔티티와 도메인 엔티티를 따로 만들고, 둘을 변환(converting)하는 로직을 작성하는 것은 꽤나 소모적인 작업이다. 그리고 도메인 객체로부터 컬럼 이름 등을 찾아내기 위해서는 도메인 객체 → 컨버팅 로직 → 영속성 엔티티 → 컬럼 이름 등의 복잡한 탐색 작업이 생기기에 유지 보수도 까다로워 진다.
개발자는 클린 코드를 작성하는 사람이 아니라 비즈니스 가치를 창출하는 사람이다. 그러한 기술적 침투가 발생하여도 전혀 문제될 것이 없다고 생각한다. 혹여나 영속성 기술이 변경되면 비즈니스 부분에 영향을 주게 된다고 주장할 수도 있는데, 실제로 영속성 기술이 바뀔 가능성은 매우 낮을 뿐만 아니라 영속성 기술이 바뀌어도 영향이 없도록 분리 및 추상화해두어도 실질적으로는 영향이 갈 수 밖다. 따라서 이러한 부분은 트레이드 오프(trade-off)하여 비즈니스 가치 창출에 더 많은 시간을 사용할 수 있도록 하자. 물론 나중에 둘의 분리가 불가피해지는 시점이 올 수 있는데, 그것은 그 때 고민하도록 하자. 대부분의 제품은 시장에서 실패할 것인데, 비즈니스가 망해서 이 코드가 더 이상 쓰일 경우가 없다면, 시간만 낭비했을 뿐이다.
[ 일급 컬렉션의 활용 ]
일급 컬렉션(First-class Collection)이란 컬렉션 객체를 다른 객체와 동일한 수준으로 다루는 개념이다. 이를 통해 컬렉션이 다른 객체처럼 값으로 취급되어 활용된다.
먼저 일급 컬렉션를 사용하지 않는 코드를 살펴보도록 하자. 앞서 살펴본 Member 객체의 목록이 존재하고, 활성 상태의 멤버들만을 식별해야 하는 상황이라고 하자. 이러한 상황을 코드로 작성하면 다음과 같이 작성될 것이다.
List<Member> activeMembers = members.stream()
.filter(v -> v.isActive())
.toList()
만약 해당 로직이 한 번만 필요하다면 문제가 없다. 하지만 현실에서는 이러한 로직이 빈번하게 필요진다는 점이다. 약관 변경을 위한 메일 발송하거나 활성화된 멤버들을 대상으로 이벤트를 진행하는 등 여러 경우에 사용될 수 있는데, 위와 같이 코드를 작성하면 중복이 발생한다. 따라서 이러한 경우에는 일급 컬렉션을 활용해주면 좋다. 일급 컬렉션은 다음과 같이 구현할 수 있다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Members {
private final List<Member> members;
public Members getActiveMembers() {
List<Member> activeMembers = members.stream()
.filter(v -> v.isActive())
.toList()
return new Members(activeMembers)
}
}
위와 같이 코드를 작성하면 도메인에 대한 응집도를 높임으로써 재사용성(reusability)을 손쉽게 확보할 수 있다. 또한 테스트 범위가 좁아지기 때문에 테스트 가능성(Testability)과 유지보수성(maintainability) 등을 향상시키고 중복을 제거하는 등의 많은 장점이 있다.
[ 비즈니스 정책의 유효성 검사 ]
우리의 시스템을 안전하고 견고하게 만들기 위해서는 유효성 검사를 진행해주어야 한다. 외부로부터 전달된 값이 올바른지 검증하는 것이다. 이때 우리는 유효성 검증을 크게 입력 데이터 유효성과 도메인 정책 유효성으로 나눠볼 필요가 있다. 예를 들어 회원 가입을 위해서는 이메일 길이가 200을 넘을 수 없는 정책이 있다고 하자. 그러면 우리가 시스템을 구현할 때, 해당 정책 만으로도 충분한가? 현실에서는 입력된 이메일 값이 비어있거나 공백으로 가득찬 문자열일 수도 있을 것이다. 이메일 길이가 200을 넘을 수 없다는 것은 세상에 존재하는 모든 회원 가입 시스템마다 정책이 다를 수 있을 것이다. 이는 우리의 비즈니스 영역에 국한되는 도메인 정책이다. 따라서 해당 부분은 핵심 비즈니스 로직을 담당하는 도메인 계층에 구현해주는 것이 좋다. 이를 통해 도메인 정책의 응집도를 높이고 시스템을 안전하게 만들 수 있다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private LocalDate birthday;
@Enumerated(EnumType.STRING)
private MemberStatus status;
{
if (email.length() > 200) {
throw new IllegalArgumentException("이메일 길이는 200을 넘을 수 없습니다.");
}
}
public boolean isActive() {
return status == MemberStatus.ACTIVE;
}
}
그렇다면 이메일이 null 이거나 공백인 부분에 대한 입력 데이터 유효성 검증은 어느 계층에서 진행하는 것이 좋을까? 이는 시스템의 가장 앞단에서 수행하여 우리의 시스템 전반에서 데이터의 무결성을 보장하는 것이 가장 바람직할 것이다. 아래에서 관련 계층에서 다시 살펴보도록 하자.
2. 포트 계층(Port Layer)
[ 외부와의 통신 계층 ]
현대의 애플리케이션 개발에서는 외부 인프라와의 통신이 불가피하다. 데이터를 저장하기 위해서는 데이터베이스가 필요하고, 처리 성능 개선 등을 위해서는 캐시가 필요하다. 특히나 오늘날에는 MSA 시대가 되면서 심지어 서버와 서버 간의 통신도 매우 빈번하다. 이들은 외부 인프라와 통신한다는 공통점이 있기 때문에, 이를 처리하는 포트 계층이 등장하게 되었다. 대표적으로 다음과 같은 Spring-Data-Jpa의 JPA Repository가 포트 계층에 속할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
}
[ 네이밍 컨벤션 ]
그 외에도 레디스와 같은 캐시에 접근하기 위한 클래스와 API 호출을 담당하는 클래스 역시도 포트 계층에 존재할 수 있다. 각각은 목적이 다르기 때문에 개별적인 네이밍 컨벤션이 또 필요한데, 개인적으로는 다음과 같은 컨벤션을 선호하는 편이다.
- 클래스 네이밍
- 캐시: XXX Cache
- API 호출: XXX Client
- 입력/출력 파라미터
- 단일 파라미터, 도메인 엔티티, 별도의 DTO 등 제한 없이 사용
- 단, 파라미터가 많아지는 경우에는 XXX Request, XXX Response 네이밍을 사용함
- 만약 응답값이 비즈니스 로직을 갖게 된다면, Member 같이 도메인 이름을 사용하고 도메인 계층으로 격상시킴
public class OrderClient {
public Order order(OrderRequest orderRequest) {
return restClient
.get()
...
;
}
}
public class OrderRequest {
private Long memberId;
private Long productId;
private Long quantity;
private String paymentMethod;
}
이때 어떠한 네이밍을 갖는가는 중요하지 않다. 중요한 것은 팀 안의 혹은 프로젝트 단위의 일관성 있는 방식을 정의하고 모두가 그 규칙을 따르는 것이다.
[ 불필요한 인터페이스 제거 ]
일부 아키텍처 패턴에서는 구현체 클래스와 이를 추상화한 인터페이스를 분리할 것을 권장한다. 하지만 현실 개발 세계에서는 구현체 없이 인터페이스만 찾는 경우는 드물다. 또한 당장 분리가 필요하지도 않은 시점에서 인터페이스를 도입하는 것은 오버 엔지니어링이 될 수 있다. 인터페이스의 분리가 불필요하다는 것이 아니라, 아직 필요한 시점이 아닐 수 있는 것이다. 따라서 인터페이스의 분리는 필요한 시점에 적절하게 가져가주도록 하자. 위에서 구현된 OrderClient는 인터페이스로 추상화되지 않고 구체 클래스로 구현한 것이 그 예시이다.
3. 유스케이스 계층(UseCase Layer)
[ 요구사항 단위로 구현되는 유스케이스 ]
유스케이스(Use Case)란 사용자가 시스템을 통해 특정 목표를 달성하기 위해 수행하는 일련의 활동을 나타내는 시나리오를 의미한다. 결국 우리가 제공하는 비즈니스적 요구 사항에 해당하는데, 회원 가입이나 상품 주문 등과 같은 기능들을 의미한다.
일반적으로 기획자와의 소통은 요구사항 단위로 이루어진다. 기획자는 “회원 가입 시에 이메일 중복 검사가 필요할 것 같아요.” 라고 소통하지, “회원 로직 중에서 isDuplicateEmail 메서드 보완이 필요해요”와 같이 얘기하지 않는다. 그렇기 때문에 우리는 소통의 단위가 되는 유스케이스를 기반으로 클래스를 분리해서 구현해두면, 기획자가 원하는 특정한 비즈니스 요구사항을 구현한 클래스를 빠르게 찾고, 변경과 확장을 용이하게 할 수 있다.
만약 모든 기능을 단일 클래스에서 작성 및 관리하면 단일 클래스가 상당히 비대하져 복잡해져서 원하는 로직을 찾기가 어려워질 수 있다. 예를 들어 Member에 대해 필수적인 유스케이스만 떠올려도 상세 조회, 목록 조회, 등록, 수정, 삭제 등이 있다. 이것들을 하나의 클래스에 담는 것은 코드에 대한 이해와 작업 생산성을 떨어뜨릴 수 있고, 테스트 코드까지 복잡도가 높아지게 된다. 따라서 유스케이스 별로 해당 로직을 구현하는 클래스를 분리하는 것이 바람직하다.
- RegisterMemberUseCase
- GetMemberListUseCase
- GetMemberDetailUseCase
- UpdateMemberUseCase
- DeleteMemberUseCase
- …
유스케이스를 구현하려면 먼저 공통되는 비즈니스 로직을 활용해서 기능을 수행하고, 데이터베이스나 캐시 등을 통한 데이터 처리가 필요하다. 따라서 유스케이스 계층은 도메인 객체 계층과 포트 계층에 의존하여 해당 기능을 구현하게 된다. 이러한 유스케이스 계층을 구현함에 있어서는 다음과 같은 컨벤션을 선호하는 편이다.
- 클래스 네이밍
- XXX UseCase
- 입력/출력 파라미터
- 입력 파라미터로는 Input, 출력 파라미터로는 Output 네이밍을 사용함
- Input, Output 클래스는 UseCase 클래스의 내부에 위치시킴
@Service
@RequiredArgsConstructor
public class RegisterMemberUseCase {
private final MemberRepository memberRepository;
record Input(String name, String email, String password) {
}
record Output(Member member) {
}
public Output execute(Input input) {
if (memberRepository.existsByEmail(input.email)) {
throw new IllegalStateException("중복된 이메일입니다");
}
Member savedMember = memberRepository.save(
Member.builder()
.name(input.name)
.email(input.email)
.password(input.password)
.build()
);
savedMember.activate();
return new Output(savedMember);
}
}
이때 유스케이스에 대한 입력과 출력을 담당하는 Input과 Output이 유스케이스 내부에 존재하는 이유는, 유스케이스가 우리 서비스의 핵심이기 때문이다. 애플리케이션에서 가장 중요한 구성 요소이자, 기획자와의 소통부터 작업 단위는 유스케이스 이다. 따라서 유스케이스를 빠르게 이해 가능하고 변경이 쉽도록 작성할 필요가 있고, 이를 위해서 입력과 출력 전용 DTO를 두어 빠르게 구현과 변경을 가능하도록 한 것이다.
예를 들어 다음은 유스케이스를 문서로 표현한 것인데, 이를 그대로 구현한 것이 바로 우리의 유스케이스 계층이라고 볼 수 있다.

[ SRP 관점에서의 분리 ]
그 뿐만 아니라 SRP를 위반하여 의도하지 않은 사이드이펙트로 인해 문제가 생기는 것을 방지하기 위함 역시 유스케이스 별로 비즈니스 로직을 구분하는 이유이다. 요구사항 단위로 비즈니스 로직을 구현한다는 것은 단일 책임 원칙(SRP, Single Responsibility Principle)을 위반하지 않음을 의미하며, 이는 코드 레벨에서 다른 유스케이스를 주입받지 않음을 의미한다.
로버트 마틴은 SOLID 원칙 중에서 그 의미가 가장 전달되지 못한 원칙으로 SRP(Single Responsibility Principle, 단일 책임 원칙)를 뽑았다. 그러면서 SRP는 “하나의 일만 해야한다”는 의미가 아니라 "단일 모듈은 변경의 이유가 하나, 오직 하나뿐 이여야 한다."는 것이라고 설명했다. 여기서 변경의 이유가 하나여야 한다는 것은 하나의 액터만 책임져야 한다는 의미이며, 여기서 액터란 동일한 변경을 요청하는 사람들을 의미한다. 즉, 최종적으로 "하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임을 져야 한다."는 원칙이 바로 SRP이다.
예를 들어 멤버를 조회하는 기능은 액터에 따라 구현이 달라질 수 있다. 일반 사용자가 다른 사용자에게 쿠폰을 선물하기 위해 멤버를 조회하는 것은 정상 상태의 멤버 만을 조회해야 한다. 하지만 관리자의 입장에서는 휴면 상태를 풀거나 정지 처리를 해제하기 위해 모든 상태의 멤버를 조회해야 할 수 있다. 만약 이를 하나의 GetMemberUseCase로 두고 재사용한다면 SRP를 위반하는 것이고, 변경 시에 사이드이펙트가 발생할 수 있다. 마찬가지로 하나의 MemberService 내부에서 두 가지 기능을 구현하는 것 역시 마찬가지다.
따라서 하나의 유스케이스는 하나의 액터를 책임지도록 설계해야 한다. 가능하다면 모듈을 나누는 등의 방법을 통해 참조하지 못하도록 막는 것이 가장 좋지만, 불가능하다면 적어도 패키지 정도는 나누어 구분하도록 하자. 보다 자세한 내용은 멀티 모듈 관련 포스팅에서 자세히 살펴보도록 하자.
[ 진짜 중복과 가짜 중복 구분하기 ]
이렇게 하면 각각의 유스케이스 별로 클래스가 구분되면서, 유스케이스 내부 마다 비슷해 보이는 코드들이 여럿 생긴다. 따라서 이것을 공통 기능으로 추출해야 하나 고민이 될 수 있는데, 중요한 것은 코드 텍스트가 동일하다고 해서 모두 중복은 아니라는 점이다. 예를 들어 비슷한 코드라고 하더라도 서로 다른 액터를 처리하고 있다면 혹은 변경의 이유 또는 주기가 다르다면, 이것은 “진짜 중복”이 아니다. 따라서 우리는 이를 “가짜 중복” 또는 “우발적 중복”이라고 부른다.
따라서 중복처럼 보이는 코드가 생겼다면, 이것이 “진짜 중복”인지 고민해볼 필요가 있다. 진짜 중복인지 판단하는 기준은 비즈니스 여부를 따져보아야 하는데, 관련 포스팅을 통해 보다 자세한 내용을 참고하도록 하자.
4. 프로세서 혹은 애플리케이션 서비스 계층(Processor or ApplicationService Layer)
[ 중복된 비즈니스 로직의 담당 ]
여러 유스케이스에서 갖는 비즈니스 로직이 “진짜 중복”인 로직일 수 있다. 그렇다면 해당 부분을 별도의 클래스로 추출하여 하나의 클래스로 공통화할 필요가 있다. 프로세서 계층 혹은 애플리케이션 서비스 계층은 이를 처리한다.
해당 계층에서 가장 중요한 것은 필요에 의해 탄생해야 한다는 것이다. 필요하지도 않든데 별도의 클래스로 분리하여 코드를 작성하는 것은 계층 추가로 인한 인지 부하와 복잡도 상승을 유발할 수 있다.
해당 계층의 클래스는 특정한 비즈니스 로직이 공통으로 활용되어 여러 유스케이스에서 재활용하기 위해 분리된 클래스이므로, 비즈니스 구현에 따라 존재하지 않을 수 있다. 따라서 반드시 필요에 의해 “탄생되어야” 한다. 임의로 혹은 작위적으로 해당 계층을 파생시킬 필요는 없다.
5. 컨트롤러 계층(Controller Layer)
[ 외부로부터의 입력을 받는 계층 ]
오늘날에는 HTTP 프로토콜 기반으로 작성된 API 기반의 통신이 주를 이룬다. 따라서 컨트롤러 계층은 API 요청을 받아서, 유스케이스로 그 요청을 위임하는 역할을 한다. 그리고 앞서 설명하였듯 우리의 아키텍처 패턴에서는 유스케이스 별로 컨트롤러가 존재하기 때문에, 컨트롤러에서는 이들을 모두 의존성 주입(Dependency Injection) 받아서 요청을 전달하게 된다.
@RestController
@RequiredArgsConstructor
public class MemberController {
private final RegisterMemberService registerMemberService;
private final GetMemberDetailService getMemberDetailService;
...
}
그렇다면 컨트롤러에서는 서로 다른 유스케이스를 의존해도 되는 것인가? 나는 괜찮다고 생각한다. 왜냐하면 일단 우리에게 가장 중요한 계층은 유스케이스 계층이고, 컨트롤러 계층은 다소 핵심에서 거리가 있다. 또한 컨트롤러에서는 서로 다른 유스케이스가 재활용되는 구조라기 보다는 들어온 입력 요청을 유스케이스로 그대로 위임한다. 즉, 유스케이스 간에 간섭이 일어나며 SRP를 위반할 일이 없는 것이다.
따라서 컨트롤러 역시 모두 유스케이스에 따라 나누는 것은 효율적이지 못하므로, 하나의 컨트롤러에서 이를 처리해도 괜찮을 것이다.
[ 컨트롤러의 DTO에 대하여 ]
간단한 작업의 경우에는 유스케이스의 Input과 컨트롤러로 전달되는 Body가 동일할 수 있다. 마찬가지로 대부분의 경우 유스케이스의 Output을 컨트롤러 응답으로 그대로 전달해도 되는 경우가 많다. 이러한 경우에는 유스케이스의 Input과 Output을 컨트롤러에서 그대로 사용해주도록 하자. 굳이 불필요하게 컨트롤러용 DTO를 별도로 나눌 필요가 없다. 나중에 분리가 필요한 시점에 분리를 해주는 것이 실용적이다.
@RestController
@RequiredArgsConstructor
public class MemberController {
private final RegisterMemberService registerMemberService;
@PostMapping("/members")
public ResponseEntity<RegisterMemberService.Output> registerMember(
@RequestBody RegisterMemberService.Input input
) {
return ResponseEntity
.ok(registerMemberService.execute(input))
.build();
}
}
하지만 이렇게 처리가 불가능한 상황도 존재할 수 있다. 예를 들어 멤버의 상세 정보를 수정하기 위해 member의 ID를 Path로 받으면서 수정할 데이터를 별도의 파라미터로 받는 것이다. 이러한 경우에는 어쩔 수 없이 별도의 DTO를 분리하고, 유스케이스의 입력으로 전달해주어야 할 것이다.
@RestController
@RequiredArgsConstructor
public class MemberController {
private final UpdateMemberService updateMemberService;
@PostMapping("/members/{memberId}")
public ResponseEntity<UpdateMemberService.Output> updateMember(
@PathVariable long memberId,
@RequestBody UpdateMemberBody body
) {
var input = UpdateMemberService.Input(
memberId,
body.name(),
body.email(),
body.password()
)
return ResponseEntity
.ok(updateMemberService.execute(input))
.build();
}
}
record UpdateMemberBody(String name, String email, String password) {
}
가능하다면 유스케이스의 Input과 Output을 그대로 사용하고, 불가피하다면 별도의 컨트롤러 전용 DTO를 만들어 활용해주도록 하자. 이때 개인적으로 컨트롤러의 DTO 네이밍은 다음과 같은 방식을 선호한다.
- 컨트롤러의 입력 DTO: XXX Request 네이밍과 함께 컨트롤러에 위치시킴
- 컨트롤러의 출력 DTO: XXXResponse 네이밍과 함께 컨트롤러에 위치시킴
[ 입력 데이터의 유효성 검사 ]
앞서 도메인 정책에 대한 유효성 검사는 도메인 객체 계층에서 처리하는 것이 바람직하다고 설명하였다. 그리고 입력 데이터의 유효성 검사는 시스템의 가장 앞단 중 하나인 컨트롤러 계층에서 수행하는 것이 바람직하다. 이를 통해 우리 시스템의 모든 데이터가 무결함을 보장할 수 있다. 스프링 프레임워크를 사용한다면 validation 관련 모듈을 제공하고 있으므로 이를 활용해주는 것이 좋다. 해당 라이브러리를 활용하는 방법은 다음의 유효성 검증 포스팅을 참고해주도록 하자.
implementation("org.springframework.boot:spring-boot-starter-validation")
만약 컨트롤러에서 유스케이스의 Input을 그대로 사용중이라면, 다음과 같이 Input 클래스에 입력 데이터 유효성 관련 애노테이션이 붙게 될 것이다.
@Service
@RequiredArgsConstructor
public class RegisterMemberUseCase {
private final MemberRepository memberRepository;
record Input(@NotBlank String name, @NotBlank String email, @NotBlank String password) {
}
record Output(Member member) {
}
public Output execute(Input input) {
if (memberRepository.existsByEmail(input.email)) {
throw new IllegalStateException("중복된 이메일입니다");
}
Member savedMember = memberRepository.save(
Member.builder()
.name(input.name)
.email(input.email)
.password(input.password)
.build()
);
savedMember.activate();
return new Output(savedMember);
}
}
만약 Input의 파라미터가 지나치게 많거나 하면 오히려 컨트롤러 DTO로 유스케이스의 Input을 사용하는 것이 코드를 복잡하게 만들 수 있다. 이러한 경우에도 컨트롤러 DTO와 유스케이스의 Input을 분리하는 것도 바람직하다.
클라이언트로부터 잘못된 입력 값은 언제든지 들어올 수 있다. 이때 입력값의 검증을 가장 앞단에서 한다면 그 하위 의 모든 영역에서는 유효한 입력값이 존재함을 확신하고 로직을 구현할 수 있다. 만약 입력값의 유효성 검증을 가장 하위 계층에서 한다면, 유효하지 않은 값이 존재할 수 있는 범위가 모든 계층이 되므로 고려할 부분이 상당히 많아질 것이다. 따라서 전반적인 시스템의 안정성을 위해 입력값의 유효성 검증은 가장 앞 단 계층에서 해주도록 하자.
아키텍처 패턴에 정답은 없다. 사람마다 관점이 다를 수 있고, 요구사항에 따라 적합한 구성이 달라질 수 있다. 따라서 정해진 틀을 고수하기보다는 서비스를 운영하면서 그 서비스에 맞는 최적의 방안을 찾아가는 것이 바람직하다. 무엇보다 중요한 것은 하나의 서비스 내에서는 일관된 규칙을 가져야 한다는 것이다. 이를 통해 인지 부하를 줄이고 작업 비용을 최소화하여 시장에서 생존할 수 있다. 개발자는 코드 작성을 통해 비즈니스 문제를 해결하는 사람이기 때문에, 비즈니스 문제를 해결하는 데 실용적이고 효율적인 나만의 방법을 찾아보도록 하자.
'Server' 카테고리의 다른 글
| [AI] AI Harness(하네스) 구축을 위한 shim 아키텍처 with Busy Box pattern and PATH 하이재킹 (1) | 2026.05.05 |
|---|---|
| [Kafka] 카프카 파티션 증설 시 컨슈머의 auto.offset.reset 설정 주의사항 (0) | 2026.04.28 |
| [Server] 운영 환경을 위한 실용적인 로그 레벨(Practical Log Level) (0) | 2025.11.11 |
| [Server] MCP 서버 프로토콜, SSE에서 Streamable HTTP 방식으로의 대변경 (11) | 2025.08.12 |
| [Gradle] 그레이들 의존성 분석을 통해 NoClassDefFoundError, NoSuchFieldError 오류 해결하기 (3) | 2025.07.08 |