본문 바로가기
IT개발/SpringBoot

1. 실무형 아키텍처를 위한 설계 기록

by jusyBear 2025. 12. 1.
반응형

실무형 아키텍처를 위한 설계 기록

구조 선택 이유

Node.js + Express로 백엔드 서비스를 개발해 오다가, Spring Boot 기반의 실무형 아키텍처를 다시 정리하고자 보일러플레이트 프로젝트를 설계했습니다. 오랫동안 Spring을 사용하지 않았던 터라, 현재 내가 어떤 구조를 선택하고 왜 그런 선택을 하는지 명확히 설명할 수 있는 기준이 필요했습니다. 여러 아키텍처 패턴을 검토한 끝에, 실무 서비스에 바로 적용 가능한 수준의 확장성과 유지보수성을 갖추기 위해 레이어드 아키텍처 + 클린 아키텍처 + 헥사고날 아키텍처를 결합한 하이브리드 구조를 채택했습니다.

레이어드 + clean + Hexagonal 구조란?

이 구조는 세 가지 아키텍처 패턴을 결합한 하이브리드 접근법입니다:

  1. 레이어드 아키텍처 (Layered Architecture)
    • 전통적인 Spring의 계층 구조 (Controller → Service → Repository)
    • 명확한 책임 분리와 계층 간 의존성 방향 제어
    • Controller(Presentation): HTTP 요청을 받고, 요청 파라미터/JSON을 DTO로 매핑하고, Service를 호출해 결과를 응답 DTO로 만들어 반환한다.
    • Service(Business): 도메인 규칙·트랜잭션·분기·예외처리를 담당하며, 필요 시 Repository를 호출해 데이터를 읽고 쓴다.​
    • Repository(Persistence): JPA 등으로 DB와 직접 통신하며, 엔티티 저장·조회·수정·삭제를 수행하는 데이터 접근 계층이다.
  2. 클린 아키텍처 (Clean Architecture)
    • 의존성 역전 원칙(DIP) 적용
    • 도메인 로직을 외부 프레임워크로부터 독립적으로 유지
    • UseCase 중심의 비즈니스 로직 구성
  3. 헥사고날 아키텍처 (Hexagonal Architecture)
    • 포트(Port)와 어댑터(Adapter) 패턴 활용
    • 인바운드 포트(API)와 아웃바운드 포트(DB, 외부 API)로 명확히 구분
    • 도메인을 중심으로 한 의존성 방향

장점

  1. 테스트 용이성
    • 포트 인터페이스를 통한 Mock 객체 주입이 간단
    • 도메인 로직을 인프라스트럭처 없이 단위 테스트 가능
    • Express에서 경험한 Jest 기반 테스트와 유사한 독립성 확보
  2. 유지보수성
    • 각 계층의 책임이 명확하여 코드 변경 시 영향 범위 파악이 쉬움
    • 도메인 로직과 인프라스트럭처의 분리로 기술 스택 변경이 용이
    • Express의 미들웨어 체이닝과 유사한 관심사 분리
  3. 확장성
    • 새로운 기능 추가 시 기존 코드에 미치는 영향 최소화
    • 포트-어댑터 패턴으로 다양한 데이터 소스나 외부 API 연동이 쉬움
    • CQRS 패턴 적용이 용이 (Command/Query 분리)
  4. 팀 협업
    • 명확한 폴더 구조로 코드 위치 예측 가능
    • 도메인별로 독립적인 개발 가능 (Feature 구조와 결합 시)

단점

  1. 복잡도 증가
    • Express의 단순한 라우트-컨트롤러 구조에 비해 파일 수가 많아짐
    • 초기 학습 곡선이 가파름
    • 작은 프로젝트에서는 오버 엔지니어링일 수 있음
  2. 보일러플레이트 코드
    • Command, Query, Port, Adapter 등 많은 인터페이스와 클래스 생성 필요
    • Express의 간결한 라우트 핸들러에 비해 코드량 증가
  3. 성능 오버헤드
    • 여러 계층을 거치면서 약간의 성능 저하 가능 (미미함)
    • 하지만 대부분의 엔터프라이즈 애플리케이션에서는 무시 가능한 수준
  4. 과도한 추상화 위험
    • 모든 기능에 적용하면 불필요한 복잡도만 증가
    • 단순 CRUD는 전통적인 레이어드 구조로도 충분할 수 있음

선택 이유

Express 개발 경험에서 느낀 점은 "빠른 개발""유연한 구조"의 트레이드오프였습니다. Express는 빠르게 프로토타입을 만들 수 있지만, 프로젝트가 커지면서 코드 구조가 복잡해지는 경험을 했습니다.

Spring Boot로 전환하면서 특히 아래 세 가지 관점을 중심으로 구조를 검토했습니다:

  1. 실무 적용 가능성
    • 엔터프라이즈급 모놀리식/마이크로서비스 환경을 모두 고려한 구조
    • 유지보수와 확장성이 중요한 비즈니스 서비스에 바로 가져다 쓸 수 있는 설계
    • 레거시 코드와의 통합·점진적 리팩터링을 염두에 둔 구조
  2. 학습 가치
    • 아키텍처 패턴을 단순 이론이 아니라 코드 레벨에서 체득
    • 이후 다른 Spring Boot 프로젝트에서도 재사용할 수 있는 설계 템플릿 확보
    • Express에서 사용하던 방식과 비교하며 장단점을 정리할 수 있는 학습 재료
  3. 점진적 적용 가능
    • 모든 기능에 일괄 적용하기보다, 복잡도가 높은 도메인부터 선택적으로 적용
    • 단순 CRUD는 전통적인 레이어드 구조로 두고, 필요한 부분만 점진적으로 리팩터링
    • Express의 모듈화 철학과 유사하게, 단계적으로 구조를 정교화하는 접근

도메인 기반 구조(Feature 구조)의 장단점

전통적인 레이어드 구조(Controller → Service → Repository를 기술별로 분리) 대신, 도메인(Feature)별로 폴더를 구성하는 방식입니다.

장점

  1. 높은 응집도
    • 관련된 코드가 한 곳에 모여 있어 이해하기 쉬움
    • Express의 라우터별 모듈화와 유사한 개념
    • 특정 기능 수정 시 해당 도메인 폴더만 집중하면 됨
  2. 독립적인 개발
    • 팀원들이 서로 다른 도메인을 동시에 개발 가능
    • Git 충돌 가능성 감소
    • 마이크로서비스로 전환 시 도메인 단위로 분리 용이
  3. 도메인 중심 사고
    • 비즈니스 로직이 명확하게 드러남
    • DDD(Domain-Driven Design) 접근과 자연스럽게 결합
    • Express에서 경험한 "라우트별 비즈니스 로직 캡슐화"와 유사
  4. 확장성
    • 새로운 도메인 추가가 간단 (새 폴더 생성)
    • 기존 도메인에 영향 없이 기능 확장 가능

단점

  1. 공통 코드 관리
    • 여러 도메인에서 사용하는 공통 로직의 위치가 애매할 수 있음
    • common 패키지에 과도하게 의존할 위험
    • Express의 공통 미들웨어 관리와 유사한 고민
  2. 수직적 구조의 복잡도
    • 각 도메인마다 동일한 계층 구조 반복
    • 작은 도메인에서도 모든 계층을 만들어야 할 수 있음
  3. 공통 기능 파악 어려움
    • 모든 도메인의 Controller를 보려면 여러 폴더를 탐색해야 함
    • 전역적인 변경 시 여러 도메인을 수정해야 함
  4. 초기 학습 곡선
    • Express의 단순한 구조에 익숙한 개발자에게는 복잡해 보일 수 있음
    • 하지만 도메인별로 독립적이라 오히려 이해하기 쉬울 수도 있음

선택: Feature 구조 채택

이 보일러플레이트에서는 Feature 구조를 채택했습니다. 이유는:

  1. 실무 환경 대비: 대규모 프로젝트에서 Feature 구조가 더 유리
  2. 확장성: 마이크로서비스 전환 시 유리
  3. 팀 협업: 도메인별로 작업 분담이 명확
  4. 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
     ↑                            ↑
     └─────────── (의존성 역전) ─────┘

구체적인 규칙

  1. Domain Layer (가장 안쪽)
    • 다른 어떤 계층에도 의존하지 않음
    • 순수 Java 객체만 사용
    • 프레임워크 어노테이션 최소화 (필요시에만)
  2. Application Layer (UseCase)
    • Domain에만 의존
    • Port 인터페이스에 의존 (구현체가 아님)
    • Infrastructure에 직접 의존 금지
  3. Infrastructure Layer
    • Domain과 Application에 의존
    • Port 인터페이스를 구현
    • 외부 라이브러리(JPA, HTTP Client 등) 사용
  4. 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는 여기서만 사용
}

의존성 규칙의 이점

  1. 테스트 용이성: Port 인터페이스를 Mock으로 대체 가능
  2. 기술 독립성: JPA를 다른 ORM으로 변경해도 UseCase는 변경 불필요
  3. 명확한 책임: 각 계층의 역할이 명확히 구분됨
  4. 확장성: 새로운 Adapter 추가가 기존 코드에 영향 없음

실무 적용 시 주의사항

  • 과도한 추상화 지양: 단순 CRUD는 전통적인 방식도 허용
  • 점진적 적용: 복잡한 도메인부터 적용하고 단순한 기능은 기존 방식 유지
  • 팀 컨벤션: 팀 내에서 의존성 규칙을 명확히 정의하고 문서화

정리: Express 개발자로서의 인사이트

이 보일러플레이트를 설계하면서, Express 스타일의 자유로운 구조Spring Boot의 엄격한 아키텍처 사이에서 다음과 같은 차이와 배움을 정리할 수 있었다.

  1. 라우트 중심 vs 도메인 중심
    • Express에서는 라우트 파일을 중심으로 기능을 쌓아 올리기 쉽다.
    • 여기서는 domain/user처럼 도메인 단위로 API, 서비스, 리포지토리를 한 덩어리로 묶는다.
    • “URL 기준이 아니라, 비즈니스 개념 기준으로 폴더를 나눈다”는 점이 가장 큰 차이였다.
  2. 직접 접근 vs Port를 통한 간접 접근
    • Express에서는 db.query()처럼 DB 코드에서 DB를 직접 호출하는 패턴을 자주 썼다.
    • 여기서는 Port 인터페이스를 사이에 두고, 실제 구현은 Adapter에서 담당한다.
    • 덕분에 “비즈니스 로직은 무엇을 원하는지”만 표현하고, “어떻게 가져오는지”는 뒤로 숨길 수 있었다.
  3. 빠른 구현 vs 변경에 강한 구조
    • Express는 작은 서비스나 프로토타입을 만들 때 압도적으로 빠르다.
    • 반면 이 구조는 초반 설정과 파일 수는 늘어나지만, 기능이 늘어날수록 “어디에 코드를 넣어야 할지 애매하지 않은” 장점이 있다.
    • 즉, 초기 속도보다는 중·장기 유지보수에 초점을 둔 구조라는 점을 체감했다.
  4. 테스트 관점의 사고 전환
    • Spring에서는 도메인/UseCase 레이어를 프레임워크와 분리해 두었기 때문에, 비즈니스 규칙만 테스트하는 순수 단위 테스트를 쓰기 쉬웠다.
    • “테스트하기 쉽게 설계하면, 자연스럽게 구조도 좋아진다”는 원칙을 다시 확인했다.
  5. 내가 다시 보고 싶은 한 줄 요약
    • 이 보일러플레이트의 목표는 “Express의 생산성과 Spring Boot의 아키텍처적 안정성을 동시에 이해하는 것”이다.
    • 그리고 그 결과물로, 실무 서비스에 바로 가져다 쓸 수 있는 도메인 중심 + Port/Adapter 기반의 Spring Boot 템플릿을 갖게 되었다.
반응형