객체지향

테스트가 무거운 건 의지 문제가 아니라 설계 문제다

경딩 2026. 3. 16. 00:57

레이어트 아키텍처의 함정과 도메인 중심 설계로의 탈출

 

이 글에서 얻어갈 것들

  • 테스트가 무거운 이유가 의지 문제가 아니라 설계 문제라는 것
  • 레이어드 아키텍처가 Fat Service 와  Anemic Domain Model을 만들기 쉬운 구조적 이유
  • 이 문제가 코드 수준을 넘어 팀 협업과 생산성에 어떤 영향을 주는지
  • 의존성 역전(DIP)을 통해 외부 기술을 분리하는 실전 흐름
  • Mock과 Fack 테스트의 차이와 언제 무엇을 써야 하는지
  • Spring Context 없이도 가능한 순수 Java  단위 테스트 구조
  • 이 내용이 클린 아키텍처, 헥사고날 아키텍처와와 어떻게 연결되는지 큰 그림

1. 레이어드 아키텍처는 왜 기본이 되었을까?

Sprig 기반 프로젝트를 시작하면 대부분 자연스럽게 다음 구조를 사용한다.

Controller → Service → Repository → Database

직관적이고 배우기 쉽다. 빠르게 기능을 만들 수 있고, 초기에 이해도 어렵지 않다. 많은  CRUD 중심 서비스에서는 지금 충분히 실용적인 구조다.

 

문제는 프로젝트가 커지고 비즈니스 로직이 복잡해지기 시작할 때 나타난다. 특히 테스트를 진지하게 작성하기 시작하면 이런 질문이 등장한다.

테스트 한 번 돌리는 게 왜 이렇게 부담스럽지?

이 질문이 나오기 시작하면, 대부분의 경우 테스트 문제가 아니라 설계가 보내는 신호다.

 

현재 거의 모든 테스트가 h2 를 필요로 한다.

h2 를 사용하는 순간 그 테스트는 중형 테스트가 된다.

다시 말해 현재 시스템에는 소형 테스트가 없다.

그래서 테스트 한번 돌리는 것이 부담스럽다.


2. 코드 문제이기 전에, 팀 문제다

레이어드 아키텍처의 문제는 코드에서 시작하지만 팀에서 완성된다. 이 구조가 무너지는 건 항상 같은 패턴이다.

 

온보딩이 오래 걸린다. controller, service , respository 패키지를 열어도 이 시스템이 어떤 도메인을 다루는지 보이지 않는다.

기능 하나를 이해하려면 Controller → Service → Repository → Entity 를 계속 따라가야 한다. 도메인이 코드 구조에서 드러나지 않기 때문이다.

 

개발은 순차적으로 진행된다.  Repository가 준비되기 전까지 Service  개발이 블로킹되고, Service가 나오기 전까지 Controller를 짤 수 없다. 팀 규모가 커질수록 이 병목은 선형이 아니라 지수적으로 커진다.

 

리펙토링이 두려운 코드가 된다. Service가 DB와 강결합되어 있으며 Service 를 건드릴 때 DB 코드도 영향을 받는다. 테스트도 무겁기 때문에 변경 검증이 느려진다. 자연스럽게 "이 부분은 건드리지 말자" 는 문화가 생긴다.

 

코드가 레거시가 되는 건 오래되어서가 아니라 아무도 건드리지 않기 때문이다.

아키텍처는 코드 품질보다 팀의 협업 방식을 결정한다. 좋은 구조는 좋은 코드를 만들기 쉽게 만들고, 나쁜 구조는 나쁜 코드를 만들기 쉽게 만든다.

 


 

3. 테스트가 무거워지는 이유

레이어드 아키텍처에서 테스트를 작성하다 보면 대부분 이런 환경이 된다.

Spring Context + H2 + Mockito

 

왜 이런 상황이 생길까? Service 가 외부 기술에 직접 의존하기 때문이다.

@Transactional
public UserEntity create(UserCreateDto dto) {
    UserEntity user = new UserEntity();
    user.setEmail(dto.getEmail());
    user.setNickname(dto.getNickname());
    userRepository.save(user);
    sendCertificationEmail(user.getEmail());
    return user;
}

이 코드의 문제는 테스트 코드가 아니라 의존성 구조다.

UserRepository, Database, MailSender, Spring Transaction에 직접 의존하기 때문에
이 메서드를 검증하려는 순간 테스트는 자연스럽게 Spring Context와 DB를 필요로 하는 테스트가 된다.

이 시점에서 테스트는 이미 단위 테스트가 아니라 중형 통합 테스트가 된다.

 

이런 테스트는 보통 1-3초 이상 걸리고, 수백 개가 쌓이면 CI 시간이 크게 늘어난다.

테스트 실행 시간이 느려지고, 작성 비용은 올라가며 개발자는 테스트 자체를 부담스럽게 느끼기 시작한다.

이것 역시 테스트 문제가 아니라 설계 문제다.

더 근본적인 문제도 있다. Elasticsearch 처럼 임베디드 테스트 서버가 없는 외부 시스템은 어떻게 테스트할 것인가? H2 나 Mockito 사용법을 익히는 것이 테스트의 전부가 되어버리는 구조다.


4. 레이어드 아키텍처가 만드는 구조적 문제

데이터베이스 중심 사고

레이어드 아키텍처에서는 개발 흐름이 자연스럽게 이렇게 된다

테이블 설계 → Entity → Repository → Service → Controller

 

하지만 비즈니스 관점에서는 이 순서가 더 자연스럽다.

Use Case → Domain Model → Database

 

비즈니스 규칙이 먼저고, 데이터 저장 방식은 그 다임이다. 레이어드 아키텍처에서는 이 순서가 종종 뒤집히면서 도메인이 DB구조를 따라가게 된다.

 

Fat Servie 와 Anemic Domain Model

레이어드 아키텍처에서는 도메인 객체가 다음처럼 되기 쉽다.

class UserEntity {
    private String email;
    private String nickname;
    // getter/setter만 존재. 스스로 아무것도 하지 않는다.
}

 

데이터만 담는 수동적인 컨테이너다 이를 Anemic Domain Model (빈혈 도메인 모델) 이라 부른다.

반면 실제 로직은 모두 Servie 에 몰린다. Service  는 점점 커지고 결국 절차형 프로그램의 메인 함수처럼 변한다.

 

낮은 테스트 가능성 

Service 가 외부 기수로가 직접 결합되어 있다.

Service
  ├── JpaRepository
  ├── JavaMailSender
  └── Database

 

이 구조에서는 작은 단위 테스트가 만들어지기 어렵다.

 

 

해결 방안 - 도메인을 중심으로 설계하기

문제의 핵심은 레이어드 아키텍처가 아니라 도메인이 구조에서 드러나지 않는 것이다.

DDD의 창시자 Eric Evans 는 이렇게 말했다.

소프트웨어의 복잡성은 기술이 아니라 도메인에서 발생한다.

 

그래서 구조를 이렇게 재구성한다.

Presentation  (Controller)
Application   (Service)
Domain        (순수 Java 비즈니스 객체)
Infrastructure (DB, 메일 등 외부 기술)

 

Domain Layer 가 핵심이다. 도메인은 외부 기술에 의존하지 않는 순수 Java  객체들의 협력 공간이다.

여기서 중요한 분리가 하나있다. JPA Entity(영속성 객체) 와 Domain Entity를 분리하는 것이다.

User          // Domain Entity — 비즈니스 규칙을 담는다
UserJpaEntity // Persistence Entity — DB에 저장하기 위한 객체

 

이 둘을 동일한 객체로 쓰는 순간, 도메인이 DB 기술에 종속된다. 분리하는 순간, 도메인은 DB가 바뀌어도 영향받지 않는 독립적이 존재가 된다.

참고: Business Layer = Application Layer + Domain Layer. 기존에 "비즈니스 레이어"라고 통칭하니 도메인이 눈에 보이지 않았던 것이다.

 

6. 의존성에는 방향이 있다 - 클린 아키텍처

Robert C. Martin은 클린 아키텍처에서 이것을 하나의 규칙으로 정리했다.

"소스 코드의 의존성은 반드시 안쪽, 즉 고수준 정책을 향해야 한다"

 

'안쪽' 이란 도메인이다. '바깥쪽'이란 DB, 메일, 프레임워크 같은 세부기술이다. 이 규칙을 어기는 순간, Service 테스트에 DB가 따라오기 시작한다.

 

도메인은 세부 기술이 바뀌어도 영향받이 않아야 한다. MySQL 을 MongoDB로 바꾸든,  JavaMailSender 를 다른 서비스로 교체하든, 비즈니스 규칙 자체는 변하지 않는다.

이 글에서 적용하는 것이 바로 이 원칙 구현이다.  이 구조를 일반적으로 Port-Adapter 패턴, 또는 헥사고날 아키텍처라고 부른다.


7.Port-Adapter 의존성 역전하기

Service 는 이제 인터페이스 (Port)에만 의존한다. 구현체(Adapter)는 Infrastructure 레이어에 둔다.

// Service Layer / port 패키지 — Port (인터페이스)
public interface UserRepository {
    Optional<UserEntity> findById(long id);
    UserEntity save(UserEntity user);
}

// Infrastructure Layer — Adapter (구현체)
public class UserRepositoryImpl implements UserRepository {
    private final UserJpaRepository jpaRepository;
    // JPA는 구현체 안에서만 존재한다
}

메일 발송도 동일하게 분리한다.

// Port
public interface MailSender {
    void send(String email, String title, String content);
}

// Adapter
public class MailSenderImpl implements MailSender {
    private final JavaMailSender javaMailSender;
}

 

그리고 UserService 에서 인증 메일 로직을 분리해  CertificationService 로 독립시킨다. SRP (단일 책임 원칙) 실천이다.

@Service
@RequiredArgsConstructor
public class CertificationService {

    private final MailSender mailSender; // 구현체가 아닌 인터페이스에 의존

    public void send(String email, long userId, String certificationCode) {
        String url = generateCertificationUrl(userId, certificationCode);
        mailSender.send(email,
            "Please certify your email address",
            "Please click the following link: " + url);
    }

    private String generateCertificationUrl(long userId, String certificationCode) {
        return "http://localhost:8080/api/users/" + userId
            + "/verify?certificationCode=" + certificationCode;
    }
}

 

이 단순한 변경이 만들어내는 효과가 크다. MySQL을 MongoDB로 교체해도 Service 코드는 변하지 않는다.

테스트할 때는 Facke 구현체를 자유롭게 교체할 수 있다.


8. Fack 객체로 만드는 단위 테스트 

class FakeMailSender implements MailSender {
    String email;
    String title;
    String content;

    @Override
    public void send(String email, String title, String content) {
        this.email = email;
        this.title = title;
        this.content = content;
    }
}

테스트는 이렇게 작성된다.

@Test
void 이메일이_정상적으로_전송되는지_확인한다() {
    FakeMailSender mailSender = new FakeMailSender();
    CertificationService service = new CertificationService(mailSender);

    service.send("test@test.com", 2L, "code");

    assertThat(mailSender.email).isEqualTo("test@test.com");
    assertThat(mailSender.content).contains("certificationCode=code");
}

 

Spring 없음. DataBase. 없음. Mockito 없음. 순수 Java 단위 테스트다.

이 테스트는 보통 수 밀리초(ms)안에 실행된다.

앞서 말한 1-3초짜리 중현 테스트와 비교해보면, 설계 하나가 테스트 속도를 수십배 바꾼다. 같은 기능을 테스트 하더라고 어떻게 테스트 하느냐가 테스트 크기를 결정한다.

 

테스트하기 어려운 코드에는 공통점이 있다. 책임이 분리되지 않았거나, 의존성이 잘못 설계되었거나, 도메인 로직이 Service 에 몰려있다.

반대로 테스트 하기 쉬운 코드는 결국 변경하기 쉬운 코드다.

테스트 전략을 보면 아키텍처가 보인다.

 

그리고 중요한 역설이 있다. 테스트 커버리지를 측정했는데 Domain/Sevice 제외하면 너무 낮게 나온다면, 그건 도메인이 빈약하다는 신호다.

CRUD 외에 비즈니스 로직이 거의 없다는 의미다. 커버리지를 높이기 위해 테스트를 추가하기 전에, 도메인이 충분히 표현되어 있는지를 먼저 검점하는 것이 좋다.


9.Mock 과 Fack - 무엇을 언제 쓸까?

Mock은 행동을 검증한다.

// 호출 여부를 검증
verify(mailSender).send(eq("test@test.com"), anyString(), anyString());

 

Fake는 상태를 검증한다.

// 결과값을 검증
assertThat(mailSender.email).isEqualTo("test@test.com");

 

Mock 자체가 나쁜 것은 아니다. 외부 API 호출 횟수나 호출 순서가 비즈니스 요구사항인 경우, , 임베디드 서버가 없는 외부 시스템을 다룰 때는 여전히 Mock이 유용하다. 

 

다만 Mock이 지나치게 많다면 설계가 테스트 친화적이지 않을 가능성을 의심해볼 수 있다. Fack를 만들 수 없다는 건 인터페이스로 추상화되지 않았다는 의미이기도 하기 때문이다.


10. 패키지 구조 개선

변경 전 (계층형)

controller / service / repository / entity / dto

변경 후 (도메인형)

user/
  ├── domain/           ← User, UserStatus (순수 Java)
  ├── service/
  │    └── port/        ← UserRepository (인터페이스)
  ├── infrastructure/   ← UserJpaEntity, UserRepositoryImpl
  └── controller/
post/
  ├── domain/
  ├── service/
  │    └── port/
  ├── infrastructure/
  └── controller/

패키지를 열면 이 시스템이 무슨 문제를 해결했는지 바로 드러난다. 이 구조는 Vertical Slice Architecture라고도 불린다. 응집도가 높아지고, MSA 전환 시 도메인 단위 분리도 훨씬 쉬워진다.

 


11. 언제 이런 구조가 필요한가

효과적인 경우

- 비즈니스 규칙이 복잡한 서비스

- 테스트 자동화가 중요한 서비스

- 장기적으로 유지보수해야 하는 프로젝트

 

과한 경우

- 단순 CRUD 서비스

- 내부 관리 툴, 빠른 프로토타입

 

아키텍처에는 항상 트레이드오프가 존재한다. 팀 규모, 도메인 복잡도, 프로젝트 수명에 따라 적절한 구조는 달라진다./\

중요한 건 특정 패턴을 따르는 것이 아니라 도메인을 명확하게 드러내고 변경 비용을 낮추는 방향으로 설계하는 것이다.


마치며

좋은 아키텍처의 목적은 새로운 패턴을 적용하는 것이 아니다. 변화를 쉽게 만드는 것이다.

도메인이 살아있는 시스템은 이해하기 쉽고, 테스트하기 쉽고, 확장하기 쉽다. 

반대로 도메인이 죽어 있는 시스템은 결국 Service 가 모든 것을 처리하는 거대한 절차형 프로그램이 된다.

아키텍처 패턴은 정단이 아니다. 하지만 방향은 있다. "도메인을 명확히 드러내고, 외부 기술로부터 핵심 규칙을 보호하는 것.

이 두 가지가 지켜진 시스템은 기술이 바뀌어도, 팀이 바뀌어도 살아남는다.

이 글에서 다룬 개념들의 위치를 정리하면 이렇다.

이 글에서 다룬 것
  ├── Fat Service → 도메인 분리        (DDD - Rich Domain Model)
  ├── 의존성 역전 + Port-Adapter       (Hexagonal Architecture)
  ├── 계층형 → 도메인형 패키지         (Vertical Slice Architecture)
  └── Fake 기반 단위 테스트            (Clean Architecture 테스트 전략)

 

아키텍처 공부는 책을 읽는 것보다, 내가 짠 코드가 왜 힘든지 설명할 수 있게 되는 과정이다. 이 글이 그 출발점이 되길 바란다.