[고찰하기] 레이어 및 패키지 구조 설계

개요

 

Spring Boot 멀티 모듈 프로젝트 구성

개요Spring Boot 멀티 모듈 프로젝트 구성 이유 및 방법들을 해당 문서에 정리한다 목적초기 사이드 프로젝트 설계에서 어떠한 설계로 시작하는게 가장 적합할지 고찰해 보는 과정을 통해프로젝

pablo7.tistory.com

 

목표

  • 어떠한 기준으로 레이어를 설계했는지 정리하는 과정을 통해 코드 작성에 대한 원칙을 만든다

  • DIP (Dependency Inversion Principle)을 지킬 수 있는 패키지 구조를 고려하여
    가능한 기술 중심이 아닌, 기능 중심의 패키지 구조를 설계한다

 

기본 레이어 구조

필자는 그동안 NestJS라는 프레임워크를 통해 아래와 같은 레이어 구조로 개발을 해왔다

Application Support Layer -> Controller Layer -> Service Layer -> [Repository Layer -> Domain Layer]
  • 스스로 명확히 정의를 내린 것은 아니었지만, 각 레이어의 역할을 다음과 생각하며 개발을 해온 것 같다

1. Application Support Layer: 공통 처리(예외 핸들링, 공통 응답) 레이어로 가장 최상단 레이어

2. Controller Layer: 클라이언트의(외부) 요청을 받아 적절한 서비스로 위임하는 레이어

3. Service Layer: 비즈니스 로직을 처리하는 핵심 레이어

4. Repository Layer: 데이터 저장소(DB)와 직접 상호작용하는 레이어

5. Domain Layer: 도메인 정보를 관리하는 핵심 레이어

 

위 레이어 구조는 여러 백엔드 API 프레임워크에서 볼 수 있는 레이어 구조이며,
필자 또한 위 레이어 구조로 인해 쉽고 빠르게 API를 개발할 수 있었다

하지만 스스로 돌이켜보니 많은 아쉬움이 남는 코드를 작성하곤 했다

주된 아쉬움은 다음과 같다

  • 코드 재사용하려다 보니 같은 서비스 레이어를 서로 의존하게 만드는 코드를 작성했다
  • 도메인 객체가 가져야 할 도메인 핵심 로직을 서비스 레이어에서 처리하도록 하는 코드를 작성했다

즉, 서비스 레이어에서 코드를 매우 복잡하게 구현하여, 이로 인해 점점 유지 보수가 어려워진 경험이 있다

같은 실수를 반복하지 않기 위해 레이어 구조에 대해 고찰해 보는 시간을 갖고,
이러한 고찰을 통해 패키지 구조 설계까지 사고의 폭을 넓혀보자


왜 레이어 구조를 설계하는가?

레이어 구조에 대한 고찰을 하려면 레이어 구조가 필요한 근본적인 이유부터 다시 생각해 볼 필요가 있다


왜 레이어 구조를 설계했을까?
사실 이유는 되게 간단하다, 바로 책임을 명확히 분리하기 위해서

 

시스템이 복잡해질수록 책임 분리(Separation of Concerns)는 필수이다

먼저 책임 분리 안 하면 생기는 지옥 같은 시나리오를 떠올려보자

📌 책임 분리 안 하면 생기는 지옥 같은 시나리오

❌ 1. 모든 기능이 하나의 레이어(보통 Service)에 몰린다

  • 예를 들면 아래와 같다
    • User 정보 외부 API 요청과 응답
    • User 정보 DB Access 및 조회
    • User 정보 수정
    • 알림 메일 발송
  • 전부 UserService 하나에 있음

👉 발생하는 문제:

  • 서로 다른 책임이 엉켜서 의존성 꼬임
  • 메서드 수가 수십 개 → 클래스 크기 폭발
  • 하나의 변경이 전체 서비스에 영향

 

❌ 2. 도메인 객체는 Dumb Object로 전락

  • 도메인은 "데이터 덩어리"로만 쓰이고 모든 로직은 Service에 몰린다

👉 발생하는 문제:

  • 도메인 객체가 아무 역할도 안 함
  • 서비스는 로직 + 조건문 + 검증 + 유효성 체크 다 함
  • 객체 지향 설계는 사라지고, 절차적 코드만 가득해진다

 

❌ 3. 같은 레이어 간 상호 참조

  • 코드를 재사용하려다 보면 상호 참조가 발생하게 된다
    • 예를 들면 아래와 같다
      UserService → CouponService → OrderService → UserService (😱)

👉 발생하는 문제:

  • 순환 참조 발생
  • 테스트 코드 작성 불가
  • DI 컨테이너 에러 발생
  • 구조를 뜯어고치지 않으면 확장이 불가능

 

❌ 4. 모듈화 실패로 인한 협업 지옥

  • 책임이 명확하지 않으니, 어디에 어떤 로직이 존재하는지, 혹은 어디에 존재해야 할지 판단하기 어렵다
  • 예를 들면 아래와 같다
    • “이거 어디에 붙이면 되죠?” -> “서비스에 그냥 추가하시면 돼요~”

👉 발생하는 문제:

  • 공통 모듈/도메인 구분 없이 모든 코드가 service에 몰린다
  • 작업 중 누군가 기존 로직을 깨뜨려도, 영향 범위 파악이 불가하다
  • 코드 리뷰 시 “여기 왜 이 클래스에서 이걸 하고 있죠?” 질문 자주 나온다

 

❌ 5. 테스트가 불가능하거나, 작성 비용이 너무 높아짐

  • Service가 너무 많은 책임을 가지고 있고, 의존하는 게 많아지면 Mocking이 복잡해진다
    • 예를 들어 테스트할 로직이 5줄인데, 설정 코드가 100줄이 될 수도 있고,
    • 테스트 실패 원인이 너무 많아서 디버깅 지옥이 펼쳐 질 수도 있다

 

❌ 6. 결국, “갈아엎자”가 되는 구조

  • 필자는 현업에서 레거시 프로젝트를 갈아엎는 광경을 여러 번 목격했다
    • 게다가 6개월간 5개로 산재되어 있는 레거시 프로젝트를 다른 기술 스택으로 통합하는 작업을 진행했었다
  • 그때마다 레거시 코드가 왜 저렇게까지 됐을까라는 고민을 해본 적이 있다
    • 필자가 생각하기엔 분명 처음 시작할 땐 레거시 코드도 괜찮았을 거다
    • 하지만 점점 여러 기능이 붙기 시작했을 거고,
    • 책임이 마구 섞인 객체가 존재하기 많아졌을 거다
    • 게다가 기존 개발자들은 다 이직하고 왜 이렇게 작성된 건지 물어볼 사람도 없게 된다
    • 애초에 테스트 코드도 없었지만, 테스트 코드를 작성해보려 하니 죽을 맛이었다

👉 결론: 새로 만듭시다


📌 위와 같은 레거시라는 지옥을 맛봤던 경험자로서,
    시스템이 커질수록 요구사항은 늘어나고, 책임이 분리되지 않은 구조는 확장에 치명적이라 생각한다

 

    그렇기에 레이어를 나누는 건, 단지 보기 좋게 나누자는 게 아니라,
    “변경에 강한 코드”를 만들기 위한 핵심 설계 전략이다.


     즉, 설계란 단지 나누는 게 아니라, 책임과 방향을 부여하는 것이라 생각한다


     그리고 언제나 목적의 최종 종착지는 "사람이 쉽게 이해하도록" 함에 있다고 생각한다

     그렇기에 이러한 책임과 방향을 부여해서 설계한 것이,
     "과연 사람이 쉽게 이해하도록 도울 수 있는가"를 계속해서 되물어야 한다

 

🤔 그렇다면 왜 아쉬움이 남는 코드를 작성해 왔나?

프레임워크라는 편의성에 취해, 레이어 및 여러 기능들에 대해 스스로 고찰하지 않았기 때문이다 

  • 프레임워크를 활용하다보면, 내가 굳이 고민하지 않아도 레이어 구조를 만들 수 있도록 제공해준다
    • 부정적으로 얘기하면, 오히려 프레임워크가 짜준 레이어 구조로 개발하도록 강제성을 부여한다

    • 예를들면, 필자는 프레임워크가 제공하는 기능을 필요에 맞게 커스텀하여 구현한 적이 있으나,
      이로 인해 다른 기능들이 오동작하는 시행 착오를 겪으면서 프레임워크가 제공하는 편의성과 멀어진 경험이 있다 
  • 프레임워크를 활용하는건 빠르고 안정적으로 개발을 시작할 수 있게 해주는 아주 탁월한 선택이다
    그러나 프레임워크가 제공하는 수 많은 기능들이 스스로 깊은 고찰을 통해 설계한 기능이 아니기 때문에,
    프레임워크가 해당 기능을 설계하게 되기까지의 주된 목적이나 의도을 망각한 채 코드부터 작성하게 된다

  • 즉, 최소 한번 쯤은 프레임워크가 제공하는 편의성을 그냥 사용하는게 아니라,
    왜 이러한 기능을 제공했을지, 왜 이렇게 구조를 설계했을지,
    과연 해당 기능과 구조로 내가 목표로하는 기능과 설계를 충족 시킬 수 있는지
    한 번쯤은 고찰해보는 시간이 필요하다 느꼈다

  • 따라서 지금이라도 Spring Boot 프레임워크가 제공하는 레이어 구조 안에서,
    내가 지향하고자하는 코드 설계를 어떻게 충족 시킬 것인가를 고민해보고자 한다

 

💎 레이어 설계에 따라 어떠한 목적을 두어야 할까

  • 현재 필자의 수준에서는 레이어를 나눔으로서 아래와 같은 목적를 두고자 한다

1. 실행 흐름의 가시성 확보 (Flow의 명확화)

  • 레이어를 구분함으로써, 어플리케이션의 실행 흐름이 명확히 드러나는 구조를 만들어야 한다
  • 기능을 처음 접하는 개발자도
    Controller → Application Service → Domain → Repository로 이어지는 흐름을 통해,
    "요청이 어떻게 흘러가고, 어디서 어떤 책임이 처리되는지" 직관적으로 파악할 수 있도록 구성해야한다

 

2. 협업 시 기능 추가의 예측 가능성 확보

  • 새로운 기능을 추가할 때, “이 코드는 어디에 위치해야 할까?”라는 질문에 명확한 답을 줄 수 있는 구조를 지향하고자 한다
  • 각 레이어가 담당하는 책임이 구체적으로 명시되어 있다면,
    협업 시에도 기능의 위치, 의존 대상, 테스트 범위 등을 쉽게 유추할 수 있다

 

3. 레이어 간 참조 방향 고정 → 순환참조 방지

  • 구조를 설계할 때 가장 우선적으로 고려해야 할 것은 의존 방향의 고정이다
  • "상위 레이어는 하위 레이어에만 의존한다"는 원칙을 통해, 각 레이어가 책임 외의 것에 관여하지 않도록 통제해야한다
  • 이를 통해 순환 참조, 서비스 간 의존성 꼬임 등을 예방하고, 설계의 확장성과 유지보수성을 확보할 수 있다고 판단한다

 

4. 레이어 단위의 코드 응집도 향상

  • 기능을 분리하는 목적은 단순한 ‘나눔’이 아니라,
    "같은 책임을 가진 코드들이 함께 묶이고, 다른 책임은 철저히 분리되는 구조"를 만드는 것이다

  • 각 레이어의 응집도를 높이면, 변경 시 영향 범위를 예측할 수 있고,
    테스트 단위도 명확하게 구분되어 생산성과 안정성을 높일 수 있다

 

5. 유지보수와 리팩터링 시 구조적 방어선 확보

  • 시간이 지나고 요구사항이 확장되더라도 기능을 어디에 추가할지, 
    어디까지 리팩터링할지 경계를 잡을 수 있는 기준선이 필요하다

  • 이 기준선이 바로 "레이어 구조"이고, 이는 시스템이 커졌을 때 개발자의 설계 의도를 지켜주는
    마지막 방어선이라고 생각한다

 


 

과연 레이어 설계에 따른 목적을 잘 이뤘을까

  • 위와 같이 목적을 정리했으나, 그동안 목적을 잘 이뤄왔는지 스스로 돌이켜봤을 때 목적 달성보다는 '실패'에 가까웠다
  • 그리고 실패의 가장 큰 원인은 시간이 지날 수록 서비스 레이어의 책임을 너무 비대하게 설계했다는 것이었다
  • 예를 들어 코드 재사용을 위해 같은 서비스 레이어끼리 상호 의존하게 되는 경우가 존재했다
  • 그로인해 테스트 코드 작성도 어려워졌고, 어느 순간부터 단순한 기능의 코드 리뷰에도 많은 시간이 걸리기 시작했다
  • 그렇다면 목적을 이루기 위해 어떻게 서비스 레이어의 책임을 분리해야할까

 

서비스 레이어를 잘못 활용한 사례

  • 필자는 그동안 아래와 같이 서비스 로직을 작성한 적이 있다

❌ 예를 들어 달러 이용량에 따라 원화로 결제가 필요한 경우

1. Payment

public class Payment {
    private BigDecimal usd;     // 달러 기준 사용량
    private BigDecimal amount;    // 원화 기준 결제 금액
    
    // Getter/Setter 생략
}

 

2. PaymentService

public class PaymentService {
	// 달러 환율 적용 기능
    public void applyExchangeRate(Payment payment, BigDecimal exchangeRate) {
        // ❌ 도메인 내부 상태를 외부에서 직접 조작
        BigDecimal calculatedAmount = payment.getUsd().multiply(exchangeRate);
        payment.setAmount(calculatedAmount);  // 내부 캡슐화를 깨뜨림
    }
}
  • 위와 같이 도메인이 스스로 책임을 지도록 위임하지 않고,
    외부에서 상태를 조작하는 방식으로 객체 지향의 본질을 잃게 만들었다

 

❌ 예를들어 회원에게 쿠폰을 발급해야 할 경우

1. UserService

public class UserService {
    private final CouponService couponService;

    public UserService(CouponService couponService) {
        this.couponService = couponService;
    }

    public void registerUser(User user) {
        // 회원가입 후 쿠폰 지급
        couponService.issueWelcomeCoupon(user.getId());
    }
    
    // 나머지 메서드 생략..
}

 

 2. CouponService

public class CouponService {
    private final UserService userService;  // ❌ 강한 결합

    public CouponService(UserService userService) {
        this.userService = userService;
    }

    public void issueWelcomeCoupon(Long userId) {
        if (userService.isVipUser(userId)) {   // ❌ 순환참조 유발
            // 특별 쿠폰 발급
        }
    }
}
  • 위와 같이 서비스 로직을 재사용하려다가, 서비스 간 강결합이 발생했던 경험이 있다
    이는 곧 순환참조로 이어지고, 테스트 불가능한 구조를 만들어 냈다

  • 위 사레 말고도 여러 케이스가 존재하나, 근본 원인과 추구하고자 하는 방향성이 같기에 2가지 사례만 정리한다

 

서비스 레이어에 너무 많은 책임을 부여하지 말자

✅ 예를 들어 달러 이용량에 따라 원화로 결제가 필요한 경우

  • 첫 번째 사례와 같이 Domain 객체의 책임을 서비스 레이어가 처리하지 않아야한다

    WHY?

    Domain 객체의 상태를 수정하는 로직을 외부 객체에 위임하기 시작하면 Domain class의 응집도가 떨어지기 때문이다


    만약 위 예시처럼 환율을 반영해서 비용을 업데이트하는 로직이

    PaymentService 뿐만 아니라 다른 서비스에도 존재하기 시작하고,
    정책상 환율 비용 계산 로직이 수정된다면(소수점 올림 처리한다던가),
    여러 서비스 레이어에서 수 많은 변경이 필요해진다


  • 따라서 서비스 레이어는 순수하게 비즈니스 절차를 표현하는 것에 집중하고,
    가능한 비즈니스 내부 로직은 해당 Domain 객체가 책임지도록 설계한다

 

1. Payment

public class Payment {
    private BigDecimal usd;     // 달러 기준 사용량
    private BigDecimal amount;    // 원화 기준 결제 금액

    public void applyExchangeRate(BigDecimal exchangeRate) {
        this.amount = usd.multiply(exchangeRate);  // ✅ 내부 책임은 객체가 갖는다
    }

    // Getter/Setter 생략
}

 

2. PaymentService

public class PaymentService {
    public void applyExchange(Payment payment, BigDecimal exchangeRate) {
        payment.applyExchangeRate(exchangeRate);  // 도메인에 위임
    }
}
  •  위와 같이 도메인 객체가 자기 책임을 수행하도록 코드를 설계하자

 

✅ 예를들어 회원에게 쿠폰을 발급해야 할 경우

  • 두 번째 사례와 같이 서비스 레이어 간 상호 참조가 일어난 경우,
    공통 관심사가 무엇인지, 그리고 이러한 공통 관심사를 어떻게 분리하는게 좋을지 고민해볼 필요가 있다
    • 예시로 든 사례에서는
      회원 가입을 담당하는 UserSevice에서 신규 가입한 회원에게 쿠폰을 발급해줘야했고,
      쿠폰을 발급해줘야하는 CouponService에서는 쿠폰을 발급하기 전에 쿠폰 발급해도 좋은 회원인지 검증이 필요했다

    • 즉, UserService가 신규 회원 가입 역할과 회원 조회 및 검증 역할을 책임지고,
      CouponService가 쿠폰 발급 역할을 책임져야 한다
      그런데 그 역할을 수행하는 과정에서 서로의 책임을 의존하게 된다

    • 따라서 공통 관심사는 바로 회원 검증쿠폰 발급이다

      WHY?


      UserService가 회원 검증 역할을 맡고 있지만, CouponService는 이 기능을 필요로 한다

      반대로 CouponService가 쿠폰 발급 역할을 맡고 있지만, UserService는 이 기능을 필요로 한다
  • 위와 같이 공통 관심사가 무엇인지 판단을 마쳤다고 치자,
    이제 어떻게하면 서로 의존하지 않게 할 수 있을까?

 

1. 읽기 전용 인터페이스를 활용하자

  • 다시 한번 정리해보자
    • UserService는 유저 관리 전반을 담당하는 복잡한 서비스이다
    • 그러나 CouponService 입장에서는 “유저가 VIP인지 알고 싶다”는 정보만 필요로 한다
    • 그렇다면 UserService 전체를 의존할 게 아니라, 이 정보만 제공하는 인터페이스를 만들어서 의존하면 어떨까?

 

1. UserReader Interface

public interface UserReader {
    User findById(Long userId);
    boolean isVip(Long userId);
}
  • 위와 같이 CouponService가 필요한 기능만 UserReader 인터페이스로 분리한다

 

2. UserService

public class UserService implements UserReader {
    // 코드 생략...

    @Override
    public User findById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new NotFoundException("User not found"));
    }

    @Override
    public boolean isVip(Long userId) {
        return findById(userId).isVip();
    }
}
  • 구현은 기존 UserService 내부에서 수행한다

 

3. CouponService

public class CouponService {
    private final UserReader userReader;

    public CouponService(UserReader userReader) {
        this.userReader = userReader;
    }

    public void issueWelcomeCoupon(Long userId) {
        if (!userReader.isVip(userId)) {
            throw new IllegalStateException("Only VIP can get this coupon");
        }

        // 쿠폰 발급 로직
    }
}
  • 그리고 CouponService는 UserReader만 의존한다
  • 따라서 CouponService는 UserService의 나머지 책임을 모르고, 알 필요도 없다

  • UserService가 UserReader를 구현하는 건 단지 "역할을 만족시키는 구현체"일 뿐,
    CouponService는 UserReader라는 추상화만 알고 있으므로, 
    UserService를 직접 참조하지도 않고, UserService 내부 구조가 바뀌어도 영향을 받지 않는다
    • 즉, 사용은 하되, 구체적인 구현에 묶이지 않음으로서 CouponService와 UserService 결합도를 낮출 수 있게 된다
  • 또한, 이러한 구조로 인해 유연성과 확장성, 테스트가 용이해진다 

위와 같이 설계함으로써,
"내가 필요로 하는 기능의 최소한만 알고, 그 이상은 알 필요 없다"는
의존성 역전 원칙(DIP)을 지킬 수 있다고 판단된다

이렇게 보면 오히려 "그 이상은 알 필요 없다"가 아니라,
"불필요한 결합으로부터의 자유"인 것이 아닌가 생각된다

 

그리고 이러한 불필요한 결합으로부터의 자유를 얻게 해 주는 데 있어서,
상속의 원리를 활용할 수 있는 인터페이스만큼 훌륭한 도구가 또 있을까

 

여기서 한 가지 다짐해야 할 것은,
모든 설계를 인터페이스로 추상화하겠다는 사고를 가지면 안 된다는 것이다

진정으로 추상화가 필요한 부분인지 아닌지 깊이 있게 고민하는 자세가 필요하다


그러므로 필자는 미래를 예측하고 미리 인터페이스를 만들어 두기보다는,
위 사례와 같이 진정으로 인터페이스 활용이 필요하다고 판단되는 시기에 활용하도록 하겠다


 

2. 특수한 흐름을 제어하는 Application Service를 활용하자

  • 회원 조회, 검증에 대한 기능을 UserReader 인터페이스를 활용함으로써,
    CouponService에서 UserService 결합도를 낮추었다

  • 그런데 아직 UserService에서 쿠폰 발급 기능이 필요하니, 역시 CouponService와의 강하게 결합되어 있다

  • 이를 어떻게 분리하면 좋을까? 똑같이 쿠폰 발급을 위한 기능을 인터페이스를 분리하면 좋을까?
    • 한번 생각해 보자, 쿠폰 발급이라는 기능 또한 인터페이스로 분리는 가능하다
    • 그리고 인터페이스를 활용하면 UserService에서 CouponService의 결합도는 분명 낮춰질 수 있다
  • 그럼, 일단 직접 눈으로 확인해 보자

1. CouponIssuer

public interface CouponIssuer {
    void issueWelcomeCoupon(Long userId);
}
  • 쿠폰 발급 기능을 담당하는 인터페이스다

 

2. CouponService

public class CouponService implements CouponIssuer {
    // 코드 생략..

    @Override
    public void issueWelcomeCoupon(Long userId) {
        if (!userReader.isVip(userId)) {
            throw new IllegalStateException("Only VIP can get this coupon");
        }
        // 쿠폰 발급 로직
    }
}
  • 마찬가지로 구현은 기존 CouponService 내부에서 수행한다

 

2. UserService

public class UserService {
    private final CouponIssuer couponIssuer;

    public void registerUser(User user) {
        // 회원 가입 처리
        ...
        couponIssuer.issueWelcomeCoupon(user.getId());
    }
}
  • UserService가 CouponService가 아닌 CouponIssuer를 의존하고,
    회원 가입 과정에서 CouponIssuer 인터페이스를 활용해 쿠폰을 발급한다

위 과정을 통해 분명하게 UserService와 CouponService의 결합도는 낮춰졌다

(일단 원하고자 했던 목적은 달성됐다)

 

하지만.. 과연 "신규 회원 가입 시 쿠폰 발급"이라는 요구 사항에,

쿠폰 발급 기능만 분리한다고 해서 충분히 유지보수가 용이한 코드로 남을 수 있을까? 

 

예를 들어 "신규 회원 가입 시 쿠폰 발급"이라는 과정이 다음과 같이 복잡해진다고 가정해 보자

회원가입 완료
 → VIP 여부 판단
 → 재가입 여부 체크
 → 이미 쿠폰 발급된 이력 있는지 확인
 → 신규 회원 전용 쿠폰 객체 생성
 → 발급 대상 테이블에 기록
 → 사용자에게 발급 알림 전송

위와 같이 여러 로직의 흐름을 과연 인터페이스 기능 하나로 구현하는 게 맞는 걸까?

 

필자는 회원 정보 조회 및 VIP 여부 검증 같이 매우 간단한 기능이라면
인터페이스로 분리하는 게 적절한 방향이라고 생각하나,
위와 같이 단순한 기능이 아닌, 여러 다양한 비즈니스의 흐름을 정의하는 것에는
인터페이스를 활용하는 게 오히려 독이 된다고 판단한다

WHY?
여러 비즈니스의 흐름을 인터페이스의 하나의 기능으로 묶는다면,
해당 기능의 구현체인 CouponService에서 또 다른 결합 지옥이 펼쳐질 것이기 때문이다

 

그렇다면 어떻게 결합도는 낮추면서, 적절하게 비즈니스 흐름을 정의할 것인가? 
-> 이때 고려해 볼 수 있는 것이 특수한 비즈니스 흐름을 담당할 객체를 활용하는 것이다

 

1. WelcomeCouponIssuer (특수한 비즈니스 흐름을 담당할 객체)

public class WelcomeCouponIssuer {

    private final CouponService couponService;
    private final UserReader userReader;
    
    //.. 코드 생략

    public void issue(User user) {
        if (userReader.isVip(user.getId())) {
            couponService.issueWelcomeCoupon(user.getId());
        }
    }
}
  • 위와 같이 신규 회원 쿠폰 발급을 뜻하는 특수한 비즈니스 흐름을 담당할 객체를 설계한다 

 

2. UserService

public class UserService {
    private final WelcomeCouponIssuer welcomeCouponIssuer;

    public UserService(WelcomeCouponIssuer welcomeCouponIssuer) {
        this.welcomeCouponIssuer = welcomeCouponIssuer;
    }

    public void registerUser(User user) {
        //.. 회원 가입 로직 생략
        welcomeCouponIssuer.issue(user);
    }
}
  • UserService에서는 CouponService가 아닌 WelcomeCouponIssuer를 의존하며,
    신규 회원 쿠폰 발급이라는 비즈니스 흐름을 WelcomeCouponIssuer에 위임한다

  • 이로 인해 UserService가 맡아야 할 회원 가입이라는 역할에 충실히 책임을 다할 수 있고,
    신규 회원 쿠폰 발급이 복잡해진다 하더라도 WelcomeCouponIssuer가 그 역할을 맡음으로써, 
    UserService와 CouponService 모두 결합도는 낮추고 응집도를 올릴 수 있게 되었다

  • 따라서 위와 같이 간단한 기능이 아닌, 특별한 비즈니스적 흐름이 존재하는 기능이라면,
    흐름을 조정하는 조정자 객체를 별도로 분리하여 설계하고자 한다

이러한 개념을 스스로 명확히 구분 짓기 위해,
인터페이스 기능 분리와 조정자 객체의 핵심 차이를 정리하고자 한다


✅ 핵심 차이 정리

  인터페이스 분리 (CouponIssuer) 흐름 조정자 객체 (WelcomeCouponIssuer)
목적 기능 제공자 추상화 유즈케이스 흐름 조율
책임 쿠폰 발급만 보장 가입 + 발급 + 정책 판단 등 흐름 책임
결합도 결합은 줄지만 흐름은 각자 서비스에 존재 결합도 낮고 흐름도 한 곳에 명확히 존재
테스트 단위 테스트는 쉬움 유즈케이스 단위 테스트 가능
확장성 "발급" 자체는 잘 추상화 흐름이 많아질 때 더 구조화됨
적용 위치 “단일 기능”이 여러 곳에서 쓰일 때 복잡한 흐름”이 생겼을 때

위와 같이 인터페이스는 간단한 기능의 추상화에는 적합하겠으나, 
해당 기능이 특정한 도메인에 종속되기 어려운 특수한 비즈니스의 흐름이 존재한다면,
여러 도메인 비즈니스들의 흐름을 조정하는 조정자 객체를 별도로 두는 것이 더 명확하다

 

여기서 중요한 것은 “의존성을 줄이기 위한 도구”와 “흐름을 명시하기 위한 도구”의 목적이 다르다는 점이다

 

그래서 레이어 기준을 어떻게 둘 것 인가?

  • 사실 여기까지 GPT와 같이 학습을 하다 보니, 의도하지 않았으나 DDD와 Hexagonal Architecture라는 키워드가 등장했다

  • GPT는 Port, Application Layer, Infrastructure Layer 등, 필자가 직접 경험해보지 않은 영역으로 이끌기 시작했다

  • GPT와 수차례 떠들면서 Port와 Adapter, Application Layer, Infrastructure Layer 개념에 이해는 가기 시작했다
    • 그러나 과연 내가 의도한 레이어 설계가 이것이 확실한가?
      그리고 내가 그러한 레이어 설계가 필요했던 경험을 확실히 갖고 있는가? 의문이 들기 시작했고,
      지금 당장 GPT가 제시한 Hexagonal Architecture와 가깝게 레이어  구조를 잡고 개발을 시작하다 보면,
      오히려 내 의도와 멀어지는 코드가 작성될 것 같다는 느낌이 들었다

      • 특히 Port와 Adapter 개념부터는 DB 영역과 가까운 Repository가 아닌,
        외부 API를 다수 통신해야 하는 시점부터 필요해질 것 같았다
        추후 MSA 구조로 설계하게 되면 마이크로 서비스들끼리 잦은 API 통신이 필요하게 될지 모르니,
        그때 가서야 이러한 레이어 구조가 적절한 상황이 오지 않을까

  • 안타깝지만 필자는 DDD나 Hexagonal Architecture 둘 다 그동안 크게 관심을 가진 영역은 아니었다
    • 이유를 떠올려보면 객체지향 설계의 기본을 지키려는 사고? 명확한 주관을 만드는 것이 부족했기 때문이다
    • 그만큼 DDD나 Hexagonal Architecture를 도입하기엔 필자가 성숙하지 못하다는 뜻이다
  • 따라서 레이어 기준은 변함이 없다

아래와 같이 처음 정리했던 레이어 설계 그대로 활용한다 (현재로선)

Application Support Layer -> Controller Layer -> Service Layer -> [Repository Layer -> Domain Layer]


1. Application Support Layer: 공통 처리(예외 핸들링, 공통 응답) 레이어로 가장 최상단 레이어

2. Controller Layer: 클라이언트의(외부) 요청을 받아 적절한 서비스로 위임하는 레이어

3. Service Layer: 비즈니스 로직을 처리하는 핵심 레이어

4. Repository Layer: 데이터 저장소(DB)와 직접 상호작용하는 레이어

5. Domain Layer: 도메인 정보를 관리하는 핵심 레이어

 

그러나 위 레이어 구조에서 지켜야 할 원칙을 세운다

 

-> 원칙 1. 레이어들의 의존 관계는 상위 레이어에서 하위 레이어로만 가능하다

-> 원칙 2. 같은 레이어끼리 상호 의존하는 관계. 즉, 강결합하는 관계를 만들지 않는다

-> 원칙 3. 도메인에 책임을 넘기되, 로직은 도메인이 중심이 되게 한다

-> 추가로 원칙이라고 하긴 어렵지만, 상호 의존 관계를 만들지 않기 위해,
     Service Layer에서 인터페이스로 관심사를 분리하거나,
     복잡한 비즈니스 흐름은 조정자 객체로 분리한다

레이어 설계에 따른 패키지 구조까지 정의해 보자

  • 패키지 구조 설계에서 내가 가장 추구하고자 바는,
    패키지 설계만 보고도, 설계된 레이어 구조대로 자연스럽게 코드를 분리해서 작성할 수 있게끔 하는 것이다
    • 그렇기에 각 패키지명이 어떠한 레이어에 속하는 것인지 의미를 그대로 부여하고 싶다
  • 하지만 위 사항만큼 추구하고자 하는 바가 하나 더 있는데, 그것은 바로 도메인 중심의 패키지 구조를 설계하고 싶은 것이다
    • WHY?
      코드는 시간이 지날수록 커지고 복잡해진다
      그렇다면 쉽게 바뀌는 건 무엇이고, 쉽게 바뀌지 않는 건 무엇일까 고민해 볼 필요가 있다


      필자의 경험으로는 비즈니스가 복잡해져 코드가 추가되거나 수정된다 하더라도 도메인의 본질은 쉽게 바뀌지 않았다
      또한, 패키지명이 도메인 명을 뜻함으로써 상위 패키지만 보고도 찾으려는 기능을 쉽게 파악할 수 있었다

      즉, 도메인 중심의 패키지 구조는 기능의 소속과 책임의 경계를 자연스럽게 드러내주고,
      추가되는 요구사항에도 구조적 혼란 없이 대응할 수 있게 도와준다

      그렇기 때문에 자연스럽게 응집도 높은 코드를 작성하게 되며,
      이점은 추후 MSA와 같은 구조로 나아갈 때도 도메인 단위 분리가 이미 되어 있어 확장이 수월하다 판단된다
  • 따라서, 레이어 설계에 따른 기술적인 부분과 도메인 중심의 기능적인 부분을 모두 묶어 표현할 수 있는 패키지 구조를 설계하고 싶었다

[기술 중심과 기능 중심 그 사이에 어딘가에 위치한] 패키지 구조

com.reservation.customer  ← customer 멀티 모듈
└── global                ← customer 모듈 안에서 공통적으로 적용되는 것들 (ControllerAdvice 같은)
└── user                  ← domain 대단위 구분
    ├── service           ← UserService, UserRepository 인터페이스, Dto
    ├── domain            ← User, 도메인 비즈니스 로직, 일급 컬렉션 등
    ├── repository        ← UserRepository 구현체
    └── controller        ← UserController, Dto
    
└── review                ← domain 대단위 구분
    ├── service           ← ReviewService, ReviceRepository 인터페이스, Dto 등
    ├── domain            ← Review, 비즈니스 로직, 일급 컬렉션 등
    ├── repository        ← ReviewRepository 구현체
    └── controller        ← ReviewController, Dto 등
    ...
com.reservation.admin     ← admin 멀티 모듈
└── global                ← admin 모듈 안에서 공통적으로 적용되는 것들 (ControllerAdvice 같은)
└── admin                 ← domain 대단위 구분
    ├── service           ← AdminService, AdminRepository 인터페이스, Dto
    ├── domain            ← Admin, 도메인 비즈니스 로직, 일급 컬렉션 등
    ├── repository        ← AdminRepository 구현체
    └── controller        ← AdminController, Dto
    
└── payment               ← domain 대단위 구분
    ├── service           ← PaymentService, PaymentRepository 인터페이스, Dto 등
    ├── domain            ← Payment, 비즈니스 로직, 일급 컬렉션 등
    ├── repository        ← PaymentRepository 구현체
    └── controller        ← PaymentController, Dto 등
    ...
  • 위와 같이 상위 패키지 명이 도메인 단위(user, review)로 묶여 있기 때문에, 각 도메인별 응집도 있게 관리된다
  • 동시에 레이어 중심 패키지(controller, service, repository, domain)들은 현재 채택한 레이어 철학을 그대로 반영한다
  • 또한, global 패키지를 통해 공통 책임을 명확히 분리한다

 

위 패키지 구조를 설계하면서 스스로 지켜야 할 원칙이 있다


원칙 1. 레이어 설계를 그대로 나타낸 만큼, 의존 관계의 흐름을 명확히 하자 

[Controller]
    ↓
[Service]
    ↓       ↘︎
[Domain]   [Interface(Reader, Issuer 등)]
               ↓
         [다른 도메인 서비스 or Repository 구현체]
  • 위와 같이 하위 레이어로만 참조가 간다 (Controller → Service → Domain → Repository)

 

원칙 2. 도메인 중심의 패키지를 설계한 만큼, 다른 도메인 영역을 참조할 땐 항상 '인터페이스'에만 의존한다

  • 예를 들어, UserService에서 Review 도메인에 대한 정보가 필요해질 수 있다
  • 코드로 보면 다음과 같다
// com.reservation.customer.user.service.UserService
public class UserService {
    private final UserReviewReader userReviewReader;

    public UserService(UserReviewReader userReviewReader) {
        this.userReviewReader = userReviewReader;
    }

    public void deleteUser(Long userId) {
        // .. 코드 생략
        if (userReviewReader.hasAbusiveReview(userId)) {
            throw new IllegalStateException("유해한 리뷰가 있어 탈퇴 불가");
        }
        // .. 코드 생략
    }
}

 

  • UserService에서 User 탈퇴 처리가 필요할 때, 유해한 리뷰를 작성한 회원은 탈퇴가 불가능하다는 조건이 있다면
// com.booking.customer.review.service.UserReviewReader
public interface UserReviewReader {
    boolean hasAbusiveReview(Long userId);
}
  • 위와 같이 Review 도메인 영역을 의존하게 되나, '인터페이스'를 활용하여 DIP(의존 관계 역전)가 성립할 수 있도록 한다
    • WHY?
      UserSerivce에서 필요한 것은 유해한 리뷰를 작성했는가이지,  Review 도메인의 불필요한 기능까지 알 필요가 없기 때문이다

 

원칙 3. 패키지끼리 너무 복잡하게 꼬이면, 도메인 자체를 통합하거나 모듈을 나누는 고민을 해본다

  • 패키지끼리 너무 복잡하게 꼬이기 시작한다면 도메인 설계 관점에서 다시 바라볼 필요가 있다

 

이렇게 레이어 설계에 따른 패키지 구조 설계까지 정리를 해보았다

 

처음 글을 작성할 때만 해도 하루 이틀 빠르게 정리하고 넘어가게 될 것이라 기대했으나,
돌이켜보니 거의 3일을 붙잡게 된 것 같다

긴 호흡으로 작성된 글이니 만큼, 다시 한번 생각 정리를 하고자 한다

 

생각 정리

1. 그동안 필자는 프레임워크가 제공하는 편의성에 취해 레이어 설계에 대한 사고를 깊이 하지 않았다

   그로 인해 특정 레이어(Service)가 비대해지고 복잡해져 유지보수 및 테스트가 어려운 코드를 작성하곤 했다

   

2. 지난 경험들을 되풀이하지 않기 위해, 근본적으로 레이어 설계가 필요한 이유부터 다시 고찰해 보았다

    결국 레이어 설계의 주된 목적은 책임과 방향을 부여하기 위한 것이라 판단되며,
    책임과 방향을 부여가 필요한 근본적인 이유 또한 결국 사람이 쉽게 이해할 수 있도록 돕고자 하는 것이라 생각한다

3. 따라서 어떻게 하면 사람이 쉽게 이해할 수 있는 코드를 작성할 수 있을까라는 근본 목적을 잊지 않으려 노력하면서,
    의도치 않게 비대해지고, 복잡해졌던 Service Layer를 어떻게 분리시키면 좋을지 고민했다

 

4. GPT의 도움 덕분에 공통 관심사를 분리하는 효율적인 방안을 떠올릴 수 있게 되었다
    그것은 상속의 원리를 사용할 수 있는 인터페이스의 활용이었고,
    특정 코드를 재사용하기 위해, Service Layer끼리 서로 의존했던 문제를 슬기롭게 풀어낼 수 있었다

   
    특히 인터페이스를 활용해 필요한 기능만 가져가게 함으로써,
    불필요한, 아니 어찌 보면 제공되면 위험해질 수 있는 기능을 사전에 차단할 수 있는 장점이 더 크게 와닿았다

 

5. 또한, 간단한 기능 재사용이 아니라 특수한 비즈니스 로직의 흐름이 있는 경우,
    조정자 객체를 별도로 설계하는 방안이 있었다

    이러한 조정자 객체를 설계함으로써 도메인별 Service Layer 끼리 강결합하는 경우를 피할 수 있었다


    더 큰 이점은 앞으로 비즈니스 로직의 흐름이 더 비대해진다 하더라도,
    조정자 객체로 책임을 분리했기에 더 확장성 있는 구조를 만들 수 있다는 것이다
    
6. GPT는 이러한 구조가 DDD + Hexagonal Architecture와 가깝다고 설명하며,
    그에 따른 Port, Adapter, Applicaition Layer, infrasturact Layer 등 다양항 개념 및 장점들을 알려주었다

    하지만 계속 찜찜한 기분이 들었다. 
    예를 들어 UserService에서 도메인 객체를 조회하는데,
    직접 Repository를 호출하지 않고 우회해서 Port를 통해 가져온다?
    반대로 조회가 아닌 도메인 객체 생성이나 수정 시에는 Repository를 직접 호출한다? 
    그럼 이게 정말 비즈니스 흐름을 제대로 조율하는 Service Layer라고 할 수 있을까?
    적어도 현단계에서는 오히려 더 복잡성을 높이는 방안이라고 생각했다

    정말로 코드 재사용이 필요하고 상호 의존이 생기려 하는 경우에만,
    즉, 인터페이스 활용이 꼭 필요하다고 판단되는 시점에만 기능을 분리하고 싶었다

    특히 필자는 그동안 DDD, Hexagonal Architecture까지 관심을 가질 수 없었다
    고차원적인 설계를 하기 전에, 객체지향의 기본부터 지키지 않았던 코드를 많이 봐왔고 스스로 그래왔기 때문이다

    적어도 클래스명과 메서드명, 변수명을 제대로 신경 쓰는 것부터, 객체가 올바른 상태로 유지되도록 설계하는 것,
    어떻게 역할과 책임을 부여할 것이며, 과연 그 역할과 책임을 그 객체가 맡는 게 옳은지 판단할 수 있는가,
    어떠한 부분에 확장 가능하도록 신경 써야 하며, 어떠한 부분은 감추는 게 좋을지 판단하는 능력 등

    기본이 안되어있는 상황에서 더 상위의 개념을 도입하는 것은,
    스스로 기본을 체득할 수 있는 기회를 날려버리는 것이 아닌가 판단됐다

 

    결국 지금 DDD와 Hexagonal Architecture를 도입하는 것은 근본적인 목적을 잃게 되지 않을까 걱정됐다
    따라서 이번에 학습한 DDD와 Hexagonal Architecture은 나중에 기회가 되면 그때 도입해보려 한다

 

7. 이후 패키지 구조를 어떻게 설계해야 사람이 이해하기 쉽도록 코드를 작성할 수 있을지 고민이 됐다


    이러한 고민이 중요하다 느낀 것은 실제로 어지러운 패키지 구조로 인해 당황했던 경험이 있기 때문이다
    필자는 10년, 15년이 넘은 레거시 코드를 경험하면서,
    그때 매우 간단한 기능임에도 어디에 코드를 둬야 할지 애매할 때, 고민하는 시간이 너무 오래 걸렸다

    "이 코드가 여기에 있는 게 맞는 걸까, 나중에 유지보수할 때 이 위치가 또 헷갈리진 않을까.."

    그래서 더 이상 의심하지 않고, 확신을 갖고 코드를 위치시킬 수 있는 패키지 구조를 만들고 싶었다

    적어도 내가 만든 구조 안에서 "이건 여기 있어야 맞지"라고 말할 수 있는 기준이 필요했다
    그 기준이 바로, 레이어 구조를 한눈에 파악할 수 있으며 도메인을 중심으로 묶는 패키지 구조였다

 

8. 필자의 경험 상 도메인은 그 자체의 의미는 쉽게 변하지 않고 오래도록 유지된 적이 많다

    따라서 도메인별로 패키지 구조를 나눴을 때,
    특정 도메인(들)의 서비스만 수정하면 되므로 변화에 빠르게 대응할 수 있을 것이라 판단된다
 
    또한, 유지보수를 하다 보면 대부분을 기능을 먼저 떠올리며 수정할 코드를 찾게 되었다
    특정 기능의 주체가 결국 도메인이 됨으로, 도메인명으로 패키지를 구분하는 게 가독성이나 탐색에 유리하다 판단했다

    마지막으로 자연스럽게 도메인별로 코드 응집도가 높아지면서
    추후 MSA 설계로 전환할 때도 이점이 있을 것이라 보았다

 

9. 도메인 패키지 안에서는 의존 관계의 흐름을 명확히 가져갈 수 있도록
    레이어 설계 그대로 명칭을 붙여 패키지를 분리했다

    이로써, 레이어 설계에 따른 의존 관계의 흐름을 지켜야 한다는 원칙을 강화시킬 수 있었다

 

10. 추가로 비즈니스가 복잡해질수록, 도메인 패키지끼리 서로의 영역을 참조하는 경우가 발생할 것이라 판단했다

     이러한 경우에는 앞서 정리했던 공통 관심사를 분리하는데 활용했던,
     인터페이스를 활용하겠다는 원칙을 다시 강조했다

     도메인 패키지간의 분리가 된 만큼, 관심사 분리에 인터페이스 강제하는 원칙을 지키는데 도움이 되리라 생각한다

 

11. 이러한 사고를 통해 그동안 과거를 돌이켜보니,
     프레임워크를 활용하면서 과연 내가 이 정도로 주체적으로 사고하며 코드를 작성했는가 떠올리게 되었다

     대다수 경우 프레임워크가 제공하는 편의성에 취해,
     프레임워크가 제공하는 기능에 대해 주체적으로 사고하지 않고 그대로 따라 처리하는 경우가 많았고,
     이에 따라 서비스 레이어 간 강결합이 일어나고 있음에도 이를 안일하게 방치한 것이 아닌가 싶다

     멘토링 1회 차 이후 느꼈던 것처럼 객체를 생성하는 간단한 코드 한 줄에도,
     JVM부터 시작해 클래스 로더, JIT, 메모리 구조, 커널 스레드, CPU, 메인 메모리, 캐시 메모리 등
     그 안에는 수많은 의미가 함축되어 있었다
    
    당장에는 의미를 모른 채 개발할 수 있겠으나 제대로 이해가 되기 시작했을 때 진정으로 시야가 트이기 시작했고,
    근본으로 파고들수록 어떠한 상황에서도 동시성, 메모리 가시성 문제 등 해결할 수 있겠다는 자신감이 생기기 시작했다


    프레임워크 활용도 마찬가지라 생각한다, 제공하는 기능을 그냥 활용만 하는 것이 아니라,
    기본적인 원리를 파악할수록 진짜 문제가 닥쳤을 때 문제를 해결할 수 있는 힘이 주어진다

 

    결국 내가 추구해야 할 방향성은 프레임워크 위에서 코드 짜는 사람이 아니라,
    프레임워크를 이해하고, 그 위에서 설계를 주도하는 개발자여야 한다고 다짐한다