이 글을 쓰게 된 계기

CQRS(https://benfatto.tistory.com/68)에 대해 찾아보면서 CQRS를 도입하지 않을 경우 안티 패턴 중 무거운 엔티티도 있었다. 흥미로워 더 찾아보다가 점점 작성할 내용들이 방대해져 CQRS와 분리하게 되었다.

여기서는 성능의 관점을 조금 더 집중해보았다.

👀 무거운 엔티티란?

먼저 단순한 엔티티는 아래와 같이 깔끔하다.

class Issue {
    Long id;
    String title;
    String status;
}

 

하지만 엔티티가 다양한 요구사항(Read, Create)을 모두 만족하기 위해 노력하는 순간 “무거운” 엔티티가 되기 십상이다. 아래와 같이 두 요구사항을 모두 만족하고, 필드 과잉, 로직 과잉, 연관 관계 과잉이 되어 버린다.

  • 예시
    • 이슈는 뒤로 돌아갈 없음
    • Sub-task가 열려 있으면 DONE 불가
    • Assignee만 상태 변경 가능
    • Workflow Transition 검증 필요
// 무거운 엔티티
class Issue {
    Long id;
    String title;
    IssueStatus status;
    User reporter;
    User assignee;
    Project project;
    List<Issue> subTasks;
    Workflow workflow;
    LocalDateTime dueDate;
    List<Comment> comments;
    List<Attachment> attachments;
    AuditLog auditLog;
    List<Watcher> watchers;

    // 도메인 규칙들
    void transitionTo(IssueStatus next, User user) {
        checkPermission(user);
        checkWorkflowTransition(next);
        checkSubtasksClosed();
        checkDueDate();
        checkProjectPolicy();
        ...
        this.status = next;
    }

    void checkSubtasksClosed() { ... }
    void checkPermission(User user) { ... }
    void checkProjectPolicy() { ... }
}

보기에만 어려우면 다행이다. 성능적 이슈 또한 분명히 발생한다.

🔥 무거운 엔티티가 가져오는 이슈들은?

1. 책임 과다

무거운 엔티티는 보통 Read, Create 역할을 복합적으로 가지게 되는데, 보통 아래와 같다.

  • 도메인 규칙 (상태 전이, 권한 검증)
  • 조회를 위한 데이터 구조(필드)
  • API 응답 모델
  • 화면 표시용 데이터

즉, 하나의 객체가 너무 많은 책임을 떠안는다.

이렇게 되면 결국, 이 엔티티가 무엇을 위한 객체인지 설명하기 어려워진다. 또, 변경 시 영향 범위가 넓어지고, 최종적으로 코드 이해와 유지보수 난이도가 급증하게 되는 상태가 된다.

2. 오버페치(Over-fetch)와 불필요한 연관 로딩

조회에 필요한 데이터는 일부인데, 엔티티 구조는 다음을 함께 끌고 오기 쉽다.

  • 연관 엔티티
  • 컬렉션(List, Set)
  • 깊은 객체

이러한 것들은 결국 불필요한 DB 조인이 증가하게 되고, N+1 쿼리 발생 가능성이 증가하며, 메모리 사용량이 증가하게 된다.

3. 직렬화/역직렬화 비용 증가

Spring 기반 서비스에서는 serialization(직렬화), deserialization(역직렬화)는 주로 네 곳에서 발생한다.

  1. 컨트롤러 → JSON 응답 변환 시 (직렬화)
  2. 요청 바디(JSON) → Java 객체 변환 시 (역직렬화)
  3. 캐시(Redis 등)에 저장 또는 불러올 때
  4. Kafka 같은 메시지 브로커에 Publish/consume 할 때

네 가지의 상황을 보면 API호출, 캐시, 이벤트 처리, 내부 통신 등 서비스 전체에서 발생하는 기본 비용들이다.

조금 더 구체적으로 파고 들면 엔티티의 필드 개수와 객체 깊이에 비례해서 JSON 변환 비용이 증가한다. JSON 변환 비용이 증가하면 다음과 같은 일이 생긴다.

  • CPU 메모리 사용량 증가
  • heap allocation 증가
  • GC pressure 증가
  • TPS 감소(처리량 감소)

이것들을 하나씩 자세히 아래와 같이 정리할 수 있다.

CPU 메모리 사용량 증가

JSON 변환은 단순 문자열 붙이기가 아니라, 라이브러리(Jackson)가 아래를 반복 수행한다.

  • 필드/게터 탐색(리플렉션/메타데이터 접근)
  • 타입별 변환(숫자/날짜/enum/컬렉션 등)
  • 중첩 객체 재귀 탐색
  • 리스트 요소 반복 처리
  • (설정에 따라) null 제외, 포맷, 커스텀 serializer 호출

결국 엔티티에서 필드 수가 많고, 중첩 깊이가 깊고, 거기다가 컬렉션이 있으면 변환해야 할 “단위 작업”이 급증해서 CPU 시간을 많이 잡아 먹는다. DB는 여유가 있음에도 API가 느려지는 앱 레벨 병목이 발생한다.

Heap Allocation 증가

직렬화/역직렬화 과정에서 “일시적으로” 객체가 많이 만들어진다.

  • 역직렬화: JSON → 객체 생성(엔티티, 중첩 객체, List/Map, String 등)
  • 직렬화: 객체 → JSON 문자열/버퍼 생성(문자 배열, 바이트 배열, StringBuilder 류)

특히 JSON은 문자열 기반이라 String/char[]/byte[] 같은 “짧게 살다 죽는 객체”가 많아진다.

이러한 현상은 요청 수가 늘어날수록 메모리 사용량이 빠르게 늘어나고, 같은 TPS인데도 Young 영역이 빨리 차는 패턴이 나타난다 컨테이너(K8s)기준으로는 메모리 limit 근처에서 흔들리기 쉽다.

GC pressure 증가

위에서 만든 객체들 대부분은 요청 처리가 끝나면 바로 필요 없어져서 곧바로 Garbage가 된다.

그런데 객체 생성량이 많아지고, 버려지는 객체도 많다면 JVM은 그걸 치우려고 GC를 더 자주 실행해야 한다.

그런데 객체가 너무 많거나 크면 Young GC(자주) 시간도 늘고, 일부가 Old로 승격되면 Mixed/Old GC 부담도 커질 수 있다.

이렇게 GC가 늘어나면 발생하는 문제는 다음과 같다.

  • GC가 실행되는 동안 애플리케이션 스레드는 일부 구간에서 멈추거나(Stop-the-world) 처리량 감소
  • 결과적으로 응답이 “가끔” 튀는 현상 발생

서버에서는 트래픽이 올라가면 GC 횟수와 시간이 눈에 띄게 증가하고, DB 쿼리는 빠른데 API가 랜덤하게 느려지는 현상이 발생할 수 있다.

TPS 감소(처리량 감소)

TPS(Transactions Per Second)는 “초당 처리 건수”이며, 이는 결국 위 3가지가 합쳐져서 발생하는 최종적인 결과에 가깝다.

서버가 할 수 있는 일(요청 처리 시간)이 제한되어 있는데,

  • JSON 변환에 CPU 시간을 더 쓰고
  • 메모리 할당이 많아지고
  • GC로 멈추는 시간이 늘어나면

같은 시간 안에 처리 가능한 요청 수가 줄어든다.

결국 서버에서는 HPA로 Pod를 늘려도 스케일이 기대한 만큼 따라오지 않는다. 앱 자체가 더 비싸졌기 때문이다. 결국 타임아웃이나 429, 5xx 응답이 늘어나게 된다.

4. 대량 조회 시 폭발적인 비용 발생

만약 게시판 목록 조회 화면에서 사용자 아이디, 제목, 등록일시만 필요할 수 있다. 하지만 이런 무거운 엔티티는 내용, 댓글 목록, 첨부파일 목록과 같은 것까지 가져올 수 있다.

엔티티 하나가 JSON으로 18KB일 때

  • 리스트 100건 → 1.8MB
  • 리스트 300건 → 5.4MB

이렇게 되는 경우 응답 바이트 수가 급증하고, 서버와 네트워크 그리고 클라이언트 모두 부담이 증가된다. 결국 이는 응답 지연(latency)가 증가된다.

5. 도메인 모델 오염

무거운 엔티티에는 이런 필드들이 섞인다.

  • 화면 표시용 데이터
  • 포맷된 문자열
  • 카운트 값
  • 다른 엔티티의 이름/레이블

이렇게 되면 도메인 개념과 UI 개념이 섞인다. 도메인 모델의 의미도 흐려진다.

이는 결국 테스트와 확장, 재사용까지 어려워지는 결과를 초래한다.

📦 정리

무거운 엔티티는 하나의 객체에 너무 많은 책임과 데이터를 몰아넣으면서, 조회 성능 저하·메모리/GC 부담·도메인 모델 오염·유지보수 난이도 증가를 동시에 초래하는 구조적 문제다.

+ Recent posts