실무형 아키텍처를 위한 설계 기록
구조 선택 이유
Node.js + Express로 백엔드 서비스를 개발해 오다가, Spring Boot 기반의 실무형 아키텍처를 다시 정리하고자 보일러플레이트 프로젝트를 설계했습니다. 오랫동안 Spring을 사용하지 않았던 터라, 현재 내가 어떤 구조를 선택하고 왜 그런 선택을 하는지 명확히 설명할 수 있는 기준이 필요했습니다. 여러 아키텍처 패턴을 검토한 끝에, 실무 서비스에 바로 적용 가능한 수준의 확장성과 유지보수성을 갖추기 위해 레이어드 아키텍처 + 클린 아키텍처 + 헥사고날 아키텍처를 결합한 하이브리드 구조를 채택했습니다.
레이어드 + clean + Hexagonal 구조란?
이 구조는 세 가지 아키텍처 패턴을 결합한 하이브리드 접근법입니다:
- 레이어드 아키텍처 (Layered Architecture)
- 전통적인 Spring의 계층 구조 (Controller → Service → Repository)
- 명확한 책임 분리와 계층 간 의존성 방향 제어
- Controller(Presentation): HTTP 요청을 받고, 요청 파라미터/JSON을 DTO로 매핑하고, Service를 호출해 결과를 응답 DTO로 만들어 반환한다.
- Service(Business): 도메인 규칙·트랜잭션·분기·예외처리를 담당하며, 필요 시 Repository를 호출해 데이터를 읽고 쓴다.
- Repository(Persistence): JPA 등으로 DB와 직접 통신하며, 엔티티 저장·조회·수정·삭제를 수행하는 데이터 접근 계층이다.
- 클린 아키텍처 (Clean Architecture)
- 의존성 역전 원칙(DIP) 적용
- 도메인 로직을 외부 프레임워크로부터 독립적으로 유지
- UseCase 중심의 비즈니스 로직 구성
- 헥사고날 아키텍처 (Hexagonal Architecture)
- 포트(Port)와 어댑터(Adapter) 패턴 활용
- 인바운드 포트(API)와 아웃바운드 포트(DB, 외부 API)로 명확히 구분
- 도메인을 중심으로 한 의존성 방향
장점
- 테스트 용이성
- 포트 인터페이스를 통한 Mock 객체 주입이 간단
- 도메인 로직을 인프라스트럭처 없이 단위 테스트 가능
- Express에서 경험한 Jest 기반 테스트와 유사한 독립성 확보
- 유지보수성
- 각 계층의 책임이 명확하여 코드 변경 시 영향 범위 파악이 쉬움
- 도메인 로직과 인프라스트럭처의 분리로 기술 스택 변경이 용이
- Express의 미들웨어 체이닝과 유사한 관심사 분리
- 확장성
- 새로운 기능 추가 시 기존 코드에 미치는 영향 최소화
- 포트-어댑터 패턴으로 다양한 데이터 소스나 외부 API 연동이 쉬움
- CQRS 패턴 적용이 용이 (Command/Query 분리)
- 팀 협업
- 명확한 폴더 구조로 코드 위치 예측 가능
- 도메인별로 독립적인 개발 가능 (Feature 구조와 결합 시)
단점
- 복잡도 증가
- Express의 단순한 라우트-컨트롤러 구조에 비해 파일 수가 많아짐
- 초기 학습 곡선이 가파름
- 작은 프로젝트에서는 오버 엔지니어링일 수 있음
- 보일러플레이트 코드
- Command, Query, Port, Adapter 등 많은 인터페이스와 클래스 생성 필요
- Express의 간결한 라우트 핸들러에 비해 코드량 증가
- 성능 오버헤드
- 여러 계층을 거치면서 약간의 성능 저하 가능 (미미함)
- 하지만 대부분의 엔터프라이즈 애플리케이션에서는 무시 가능한 수준
- 과도한 추상화 위험
- 모든 기능에 적용하면 불필요한 복잡도만 증가
- 단순 CRUD는 전통적인 레이어드 구조로도 충분할 수 있음
선택 이유
Express 개발 경험에서 느낀 점은 "빠른 개발"과 "유연한 구조"의 트레이드오프였습니다. Express는 빠르게 프로토타입을 만들 수 있지만, 프로젝트가 커지면서 코드 구조가 복잡해지는 경험을 했습니다.
Spring Boot로 전환하면서 특히 아래 세 가지 관점을 중심으로 구조를 검토했습니다:
- 실무 적용 가능성
- 엔터프라이즈급 모놀리식/마이크로서비스 환경을 모두 고려한 구조
- 유지보수와 확장성이 중요한 비즈니스 서비스에 바로 가져다 쓸 수 있는 설계
- 레거시 코드와의 통합·점진적 리팩터링을 염두에 둔 구조
- 학습 가치
- 아키텍처 패턴을 단순 이론이 아니라 코드 레벨에서 체득
- 이후 다른 Spring Boot 프로젝트에서도 재사용할 수 있는 설계 템플릿 확보
- Express에서 사용하던 방식과 비교하며 장단점을 정리할 수 있는 학습 재료
- 점진적 적용 가능
- 모든 기능에 일괄 적용하기보다, 복잡도가 높은 도메인부터 선택적으로 적용
- 단순 CRUD는 전통적인 레이어드 구조로 두고, 필요한 부분만 점진적으로 리팩터링
- Express의 모듈화 철학과 유사하게, 단계적으로 구조를 정교화하는 접근
도메인 기반 구조(Feature 구조)의 장단점
전통적인 레이어드 구조(Controller → Service → Repository를 기술별로 분리) 대신, 도메인(Feature)별로 폴더를 구성하는 방식입니다.
장점
- 높은 응집도
- 관련된 코드가 한 곳에 모여 있어 이해하기 쉬움
- Express의 라우터별 모듈화와 유사한 개념
- 특정 기능 수정 시 해당 도메인 폴더만 집중하면 됨
- 독립적인 개발
- 팀원들이 서로 다른 도메인을 동시에 개발 가능
- Git 충돌 가능성 감소
- 마이크로서비스로 전환 시 도메인 단위로 분리 용이
- 도메인 중심 사고
- 비즈니스 로직이 명확하게 드러남
- DDD(Domain-Driven Design) 접근과 자연스럽게 결합
- Express에서 경험한 "라우트별 비즈니스 로직 캡슐화"와 유사
- 확장성
- 새로운 도메인 추가가 간단 (새 폴더 생성)
- 기존 도메인에 영향 없이 기능 확장 가능
단점
- 공통 코드 관리
- 여러 도메인에서 사용하는 공통 로직의 위치가 애매할 수 있음
common패키지에 과도하게 의존할 위험- Express의 공통 미들웨어 관리와 유사한 고민
- 수직적 구조의 복잡도
- 각 도메인마다 동일한 계층 구조 반복
- 작은 도메인에서도 모든 계층을 만들어야 할 수 있음
- 공통 기능 파악 어려움
- 모든 도메인의 Controller를 보려면 여러 폴더를 탐색해야 함
- 전역적인 변경 시 여러 도메인을 수정해야 함
- 초기 학습 곡선
- Express의 단순한 구조에 익숙한 개발자에게는 복잡해 보일 수 있음
- 하지만 도메인별로 독립적이라 오히려 이해하기 쉬울 수도 있음
선택: Feature 구조 채택
이 보일러플레이트에서는 Feature 구조를 채택했습니다. 이유는:
- 실무 환경 대비: 대규모 프로젝트에서 Feature 구조가 더 유리
- 확장성: 마이크로서비스 전환 시 유리
- 팀 협업: 도메인별로 작업 분담이 명확
- Express 경험: Express의 라우터별 모듈화와 유사한 패턴으로 친숙함
흐름도와 설명
전체 흐름도
[HTTP Request]
↓
[Controller (API Layer)]
↓
[Command/Query 객체 생성]
↓
[Service (UseCase Layer)]
↓
[Port Interface 호출]
↓
[Adapter 구현체]
↓
[Repository/External API]
↓
[Database/External Service]
상세 설명
1. API Layer (Controller)
- 역할: HTTP 요청/응답 처리, 입력 검증
- Express 비교: Express의 라우트 핸들러와 유사
- 책임:
- Request DTO를 Command/Query로 변환
- Service 호출
- Response DTO로 변환하여 반환
2. Command/Query 객체
- 역할: UseCase에 전달할 파라미터를 캡슐화
- CQRS 패턴: Command(쓰기)와 Query(읽기) 분리
- 장점: 파라미터 변경 시 UseCase 시그니처 변경 불필요
3. UseCase Layer (Service)
- 역할: 비즈니스 로직 실행
- Express 비교: Express의 서비스 레이어와 유사하지만 더 명확한 인터페이스
- 책임:
- 도메인 규칙 검증
- Port를 통한 데이터 접근
- 트랜잭션 관리
4. Port Interface
- 역할: UseCase가 필요한 기능을 정의하는 인터페이스
- 의존성 역전: UseCase는 구현체가 아닌 인터페이스에 의존
- 장점: 테스트 시 Mock 객체 주입 용이
5. Adapter (Infrastructure)
- 역할: Port 인터페이스의 실제 구현
- 종류:
- Persistence Adapter: JPA를 통한 DB 접근
- External API Adapter: 외부 서비스 연동
- Express 비교: Express의 데이터베이스 드라이버나 HTTP 클라이언트와 유사
6. Domain Layer
- 역할: 핵심 비즈니스 엔티티와 값 객체
- 특징: 순수 Java 객체, 프레임워크 의존성 없음
- 장점: 비즈니스 로직의 핵심을 명확히 표현
예시: 사용자 회원가입 흐름
1. POST /api/users/signup
↓
2. Controller: SignupRequest → SignupCommand 변환
↓
3. UserAuthService.signup(SignupCommand)
↓
4. UserRepositoryPort.findByEmail() - 중복 체크
↓
5. UserPasswordPort.encode() - 비밀번호 암호화
↓
6. UserRepositoryPort.save() - 사용자 저장
↓
7. UserResponse 반환
이 흐름에서 각 계층은 명확한 책임을 가지며, 의존성은 항상 안쪽(도메인)을 향합니다.
폴더 구조 다이어그램
src/main/java/com/example/springboilerpate2025
├─ common/
│ ├─ exception/
│ ├─ response/
│ ├─ paging/
│ ├─ security/
│ │ ├─ jwt/
│ │ ├─ password/
│ │ └─ refresh/
│ └─ util/
│
├─ domain/
│ └─ user/
│ ├─ api/
│ │ ├─ UserController.java
│ │ └─ dto/
│ │ ├─ request/
│ │ │ ├─ LoginRequest.java
│ │ │ ├─ SignupRequest.java
│ │ │ ├─ UserCreateRequest.java
│ │ │ └─ UserUpdateRequest.java
│ │ └─ response/
│ │ ├─ UserResponse.java
│ │ └─ UserDetailResponse.java
│ │
│ ├─ application/
│ │ ├─ command/
│ │ │ ├─ SignupCommand.java
│ │ │ ├─ LoginCommand.java
│ │ │ ├─ UserCreateCommand.java
│ │ │ └─ UserUpdateCommand.java
│ │ ├─ query/
│ │ │ └─ UserSearchQuery.java
│ │ ├─ port/
│ │ │ ├─ in/
│ │ │ │ ├─ UserCommandPort.java
│ │ │ │ └─ UserQueryPort.java
│ │ │ └─ out/
│ │ │ ├─ UserRepositoryPort.java
│ │ │ ├─ UserReaderPort.java
│ │ │ └─ UserPasswordPort.java
│ │ └─ service/
│ │ ├─ UserCommandService.java
│ │ ├─ UserQueryService.java
│ │ └─ UserAuthService.java
│ │
│ ├─ domain/
│ │ ├─ User.java
│ │ ├─ UserId.java
│ │ ├─ UserStatus.java
│ │ └─ UserSortKey.java
│ │
│ └─ infrastructure/
│ ├─ persistence/
│ │ ├─ UserJpaEntity.java
│ │ ├─ UserJpaRepository.java
│ │ └─ UserPersistenceAdapter.java
│ └─ adapter/
│ ├─ UserQueryAdapter.java
│ └─ ExternalUserApiAdapter.java # 필요 시
│
├─ support/
│ ├─ config/
│ │ ├─ SecurityConfig.java
│ │ ├─ SwaggerConfig.java
│ │ ├─ WebConfig.java
│ │ ├─ JacksonConfig.java
│ │ └─ MessageSourceConfig.java
│ └─ db/
│ ├─ JpaConfig.java
│ └─ UtcDateTimeProvider.java
│
└─ resources/
├─ application.yml
├─ application-local.yml
├─ logback-spring.xml
└─ messages/
├─ messages.properties
├─ messages_ko.properties
└─ messages_en.properties
의존성 규칙
클린 아키텍처와 헥사고날 아키텍처의 핵심은 의존성 방향입니다. 모든 의존성은 도메인을 향해야 합니다.
의존성 방향 원칙
Infrastructure → Application → Domain
↑ ↑
└─────────── (의존성 역전) ─────┘
구체적인 규칙
- Domain Layer (가장 안쪽)
- 다른 어떤 계층에도 의존하지 않음
- 순수 Java 객체만 사용
- 프레임워크 어노테이션 최소화 (필요시에만)
- Application Layer (UseCase)
- Domain에만 의존
- Port 인터페이스에 의존 (구현체가 아님)
- Infrastructure에 직접 의존 금지
- Infrastructure Layer
- Domain과 Application에 의존
- Port 인터페이스를 구현
- 외부 라이브러리(JPA, HTTP Client 등) 사용
- API Layer (Controller)
- Application Layer의 Service에 의존
- DTO 변환 책임
- Domain에 직접 접근 금지
의존성 역전 원칙 (DIP) 적용
// ❌ 잘못된 예: UseCase가 구현체에 의존
public class UserService {
private UserJpaRepository repository; // 구현체에 직접 의존
}
// ✅ 올바른 예: UseCase가 인터페이스에 의존
public class UserService {
private UserRepositoryPort repository; // 인터페이스에 의존
}
// Infrastructure에서 구현
@Component
public class UserPersistenceAdapter implements UserRepositoryPort {
private UserJpaRepository jpaRepository; // JPA는 여기서만 사용
}
Express와의 비교
Express에서는 다음과 같은 의존성이 자연스럽게 발생합니다:
// Express 예시
router.post('/signup', async (req, res) => {
const user = await UserModel.create(req.body); // 직접 DB 접근
});
Spring Boot의 이 구조에서는:
// Controller는 Service에만 의존
@PostMapping("/signup")
public ResponseEntity<UserResponse> signup(@RequestBody SignupRequest request) {
SignupCommand command = request.toCommand();
UserResponse response = userAuthService.signup(command);
return ResponseEntity.ok(response);
}
// Service는 Port 인터페이스에 의존
public UserResponse signup(SignupCommand command) {
// Port를 통한 데이터 접근
userRepositoryPort.save(user);
}
// Adapter가 실제 구현
public class UserPersistenceAdapter implements UserRepositoryPort {
// JPA는 여기서만 사용
}
의존성 규칙의 이점
- 테스트 용이성: Port 인터페이스를 Mock으로 대체 가능
- 기술 독립성: JPA를 다른 ORM으로 변경해도 UseCase는 변경 불필요
- 명확한 책임: 각 계층의 역할이 명확히 구분됨
- 확장성: 새로운 Adapter 추가가 기존 코드에 영향 없음
실무 적용 시 주의사항
- 과도한 추상화 지양: 단순 CRUD는 전통적인 방식도 허용
- 점진적 적용: 복잡한 도메인부터 적용하고 단순한 기능은 기존 방식 유지
- 팀 컨벤션: 팀 내에서 의존성 규칙을 명확히 정의하고 문서화
정리: Express 개발자로서의 인사이트
이 보일러플레이트를 설계하면서, Express 스타일의 자유로운 구조와 Spring Boot의 엄격한 아키텍처 사이에서 다음과 같은 차이와 배움을 정리할 수 있었다.
- 라우트 중심 vs 도메인 중심
- Express에서는 라우트 파일을 중심으로 기능을 쌓아 올리기 쉽다.
- 여기서는
domain/user처럼 도메인 단위로 API, 서비스, 리포지토리를 한 덩어리로 묶는다. - “URL 기준이 아니라, 비즈니스 개념 기준으로 폴더를 나눈다”는 점이 가장 큰 차이였다.
- 직접 접근 vs Port를 통한 간접 접근
- Express에서는
db.query()처럼 DB 코드에서 DB를 직접 호출하는 패턴을 자주 썼다. - 여기서는 Port 인터페이스를 사이에 두고, 실제 구현은 Adapter에서 담당한다.
- 덕분에 “비즈니스 로직은 무엇을 원하는지”만 표현하고, “어떻게 가져오는지”는 뒤로 숨길 수 있었다.
- Express에서는
- 빠른 구현 vs 변경에 강한 구조
- Express는 작은 서비스나 프로토타입을 만들 때 압도적으로 빠르다.
- 반면 이 구조는 초반 설정과 파일 수는 늘어나지만, 기능이 늘어날수록 “어디에 코드를 넣어야 할지 애매하지 않은” 장점이 있다.
- 즉, 초기 속도보다는 중·장기 유지보수에 초점을 둔 구조라는 점을 체감했다.
- 테스트 관점의 사고 전환
- Spring에서는 도메인/UseCase 레이어를 프레임워크와 분리해 두었기 때문에, 비즈니스 규칙만 테스트하는 순수 단위 테스트를 쓰기 쉬웠다.
- “테스트하기 쉽게 설계하면, 자연스럽게 구조도 좋아진다”는 원칙을 다시 확인했다.
- 내가 다시 보고 싶은 한 줄 요약
- 이 보일러플레이트의 목표는 “Express의 생산성과 Spring Boot의 아키텍처적 안정성을 동시에 이해하는 것”이다.
- 그리고 그 결과물로, 실무 서비스에 바로 가져다 쓸 수 있는 도메인 중심 + Port/Adapter 기반의 Spring Boot 템플릿을 갖게 되었다.