dev notes

일급 컬렉션에 HashMap 인덱스를 넣으면 규약 위반인가 — 성능과 설계 원칙의 충돌

2026-04-2025 min read
공유

상황#

ECS CPU spike 디버깅 과정에서, 레거시 서비스의 트리 구조를 감싸는 일급 컬렉션의 탐색 성능을 손봐야 하는 상황이 생겼습니다.

이 서비스는 계층형 데이터를 트리로 빌드해서 사용합니다. 트리가 여러 개 존재하고, 이를 하나로 묶어 관리하는 래퍼 클래스가 있습니다. 구조는 이렇습니다.

FooTrees (일급 컬렉션)
  └── List<FooTree> values
        └── FooTree (도메인 객체)
              ├── FooNode rootNode
              ├── List<FooNode> nodes
              └── (탐색/빌드 메서드들)

FooTreesList<FooTree>만 감싸는 일급 컬렉션입니다. Lombok의 @AllArgsConstructor@Getter만 붙어 있고, 컬렉션에 대한 도메인 행위(탐색, 검증, 변환)만 메서드로 제공합니다.

FooTree는 루트 노드, 노드 리스트, 트리 빌드 로직 등을 가진 도메인 객체로, 일급 컬렉션이 아닙니다.

원본 코드의 탐색 방식#

FooTrees에서 가장 자주 호출되는 메서드 3개가 있습니다.

java
// 1. 특정 ID가 어떤 트리에 속하는지 찾기
public FooTree findTreeIncludeId(Long id) {
    return this.values.stream()
                      .filter(tree -> tree.contains(id))
                      .findFirst()
                      .orElse(null);
}
 
// 2. 전체 트리에서 특정 노드 찾기
public Optional<FooNode> findNode(Long targetId) {
    return this.values.stream()
                      .flatMap(tree -> tree.getNodes().stream())
                      .filter(node -> node.getId().equals(targetId))
                      .findAny();
}
 
// 3. 여러 ID에 해당하는 노드 일괄 조회
public List<FooNode> findNodes(List<Long> ids) {
    return values.stream()
                 .flatMap(tree -> tree.getNodes().stream())
                 .filter(node -> ids.contains(node.getId()))
                 .collect(Collectors.toList());
}

FooTree.contains()의 내부 구현:

java
public boolean contains(Long id) {
    return this.nodes.stream()
                     .anyMatch(node -> node.getId().equals(id));
}

모든 탐색이 stream filter 기반입니다.

왜 문제인가#

findTreeIncludeId()의 시간 복잡도를 분석하면:

  1. K개의 트리를 순회합니다
  2. 각 트리에서 contains()가 N개 노드를 stream으로 탐색합니다
  3. O(K × N)

이 메서드가 한 번 호출되면 괜찮습니다. 하지만 상위 서비스에서 이런 코드가 있었습니다:

java
for (SimpleDto item : itemsForInit) {  // itemsForInit.size() == 1,000
    FooTree tree = fooTrees.findTreeIncludeId(item.getId());  // O(K × N)
    // ...
}

1,000번 반복 × O(K × N). K=20, N=12,000이면 1,000 × 20 × 12,000 = 2.4억 번의 비교 연산입니다. 이 루프 하나가 API 전체를 61초 timeout으로 만들고 있었습니다.

1단계: FooTree에 nodeIndex 추가#

FooTree는 일급 컬렉션이 아닌 도메인 객체이므로 필드를 자유롭게 추가할 수 있습니다. 트리 빌드 시점에 HashMap 인덱스를 같이 만들어둡니다.

java
public class FooTree {
    private FooNode rootNode;
    private List<FooNode> nodes;
    private Map<Long, FooNode> nodeIndex;  // 추가
 
    private FooTree(FooNode rootNode) {
        this.rootNode = rootNode;
        initNodes();
    }
 
    private void initNodes() {
        nodes = new ArrayList<>();
        nodeIndex = new HashMap<>();
        rootNode.traverse(node -> {
            nodes.add(node);
            nodeIndex.put(node.getId(), node);
            return null;
        });
    }
 
    public FooNode findNode(Long id) {
        FooNode node = nodeIndex.get(id);  // O(N) → O(1)
        if (node == null) {
            throw new IllegalArgumentException("id: " + id);
        }
        return node;
    }
 
    public boolean contains(Long id) {
        return nodeIndex.containsKey(id);  // O(N) → O(1)
    }
}

initNodes()에서 이미 전체 노드를 순회하고 있으므로, 같은 루프 안에서 nodeIndex를 빌드합니다. 추가 순회 비용 없이 O(N) 공간만 사용합니다.

findNode(): O(N) → O(1). contains(): O(N) → O(1).

이 변경만으로 FooTrees.findTreeIncludeId()의 복잡도가 O(K × N) → O(K)로 바뀝니다. 각 트리의 contains()가 O(1)이 되었기 때문입니다.

2단계: FooTrees에 cross-tree 인덱스 추가 — 규약 위반#

O(K)도 충분히 빠르지만, O(1)로 만들 수 있다면 더 좋지 않을까? FooTrees 레벨에서 "특정 ID → 어떤 트리에 속하는가"를 바로 알 수 있는 cross-tree 인덱스를 만들었습니다.

java
public class FooTrees {
    private List<FooTree> values;
    private Map<Long, FooTree> idToTreeIndex;   // 규약 위반
    private Map<Long, FooNode> idToNodeIndex;   // 규약 위반
 
    public FooTrees(List<FooTree> values) {
        this.values = values;
        this.idToTreeIndex = new HashMap<>();
        this.idToNodeIndex = new HashMap<>();
        for (FooTree tree : values) {
            for (FooNode node : tree.getNodes()) {
                idToTreeIndex.put(node.getId(), tree);
                idToNodeIndex.put(node.getId(), node);
            }
        }
    }
 
    public FooTree findTreeIncludeId(Long id) {
        return idToTreeIndex.get(id);  // O(1)
    }
 
    public Optional<FooNode> findNode(Long targetId) {
        return Optional.ofNullable(idToNodeIndex.get(targetId));  // O(1)
    }
 
    public List<FooNode> findNodes(List<Long> ids) {
        return ids.stream()
                  .map(idToNodeIndex::get)
                  .filter(Objects::nonNull)
                  .collect(Collectors.toList());  // O(M)
    }
}

성능은 모든 메서드에서 최적입니다. 하지만 두 가지 문제가 바로 드러났습니다.

문제 1: 내부 구현이 public API로 새어나감#

FooTrees에는 Lombok @Getter가 클래스 레벨에 붙어 있었습니다.

java
@Getter
public class FooTrees {
    private List<FooTree> values;
    private Map<Long, FooTree> idToTreeIndex;
    private Map<Long, FooNode> idToNodeIndex;
}

@Getter가 클래스 레벨이면 모든 필드에 getter가 생성됩니다. getValues()만 의도했는데, getIdToTreeIndex()getIdToNodeIndex()도 public으로 노출됩니다. 일급 컬렉션의 내부 최적화 구조가 외부 API에 그대로 드러난 것입니다.

@Getter(AccessLevel.NONE)을 인덱스 필드에 붙이면 노출은 막을 수 있습니다.

java
@Getter(AccessLevel.NONE)
private Map<Long, FooTree> idToTreeIndex;

하지만 이건 근본적인 문제를 우회할 뿐입니다. 일급 컬렉션에 values 외의 필드가 존재한다는 사실은 변하지 않습니다.

문제 2: 생성자 계약 변경#

원래 @AllArgsConstructornew FooTrees(List<FooTree>)만 받았는데, 인덱스 빌드를 위해 @AllArgsConstructor를 제거하고 명시적 생성자를 작성해야 했습니다. 이 클래스를 사용하는 모든 곳의 생성 방식은 동일하지만, 클래스 내부가 단순 래핑에서 초기화 로직을 가진 객체로 바뀌었습니다. 일급 컬렉션의 단순함이 사라진 셈입니다.

코드 리뷰에서 지적이 들어왔습니다: 일급 컬렉션에 다른 속성이 들어가면 안 된다.

일급 컬렉션 규약이란#

원전: Object Calisthenics Rule 8#

Jeff Bay의 Object Calisthenics (ThoughtWorks Anthology, 2008)의 Rule 8:

Use First-Class Collections Any class that contains a collection should contain no other member variables.

컬렉션을 감싸는 클래스는 그 컬렉션 외에 다른 멤버 변수를 갖지 않는다.

Object Calisthenics는 9가지 규칙으로 구성된 OOP 연습 규칙집입니다. "calisthenics"는 "체조/맨몸운동"을 뜻하며, Jeff Bay는 이를 연습을 위한 극단적 제약으로 제안했습니다. 실전에서 항상 지켜야 하는 절대 원칙이 아니라, 이 제약 안에서 코드를 짜보면서 OOP 감각을 기르는 것이 목적입니다.

9가지 규칙 중 8번("2개 이상의 인스턴스 변수를 가진 클래스 금지")이 사실상 일급 컬렉션 규칙의 상위 규칙입니다. 컬렉션을 포함하는 클래스에 인덱스용 HashMap을 추가하면 이 규칙도 같이 위반됩니다.

일급 컬렉션의 장점#

이 규약이 지켜지면 컬렉션 관련 행위가 한 곳에 응집되고, List<FooTree> 대신 FooTrees라는 도메인 의미가 부여되며, 단일 필드만 관리하면 되므로 불변 설계가 단순해집니다. 장점에 대한 자세한 설명은 jojoldu의 글Tecoble의 글에 잘 정리되어 있습니다.

이 글에서 다루려는 건 장점이 아니라, 이 규약이 성능 최적화와 충돌할 때 어떻게 하는가입니다.

한국 커뮤니티에서의 수용#

한국 Java/Spring 커뮤니티에서는 jojoldu의 글을 통해 널리 알려졌습니다. 우아한테크코스(Tecoble)를 비롯한 교육 과정에서도 적극 가르치고 있고, 실무에서도 코드 리뷰 기준으로 활용되는 경우가 많습니다.

대부분의 자료는 장점을 중심으로 설명하며, 규약과 성능이 충돌하는 경우는 다루지 않습니다. "컬렉션 외 다른 필드를 추가하면 안 된다"는 규칙을 명확히 설명하지만, "그러면 내부 캐시가 필요할 때는 어떻게 하는가?"라는 질문에 답하는 글은 찾을 수 없었습니다.

원전의 의도와 실무 적용 사이의 간극#

원전을 다시 보면, Object Calisthenics의 서문에서 Jeff Bay는 이렇게 말합니다:

These are exercises. [...] Try to use these rules for a week.

연습(exercises)입니다. 일주일 동안 시도해보라는 것이지, 모든 프로덕션 코드에 영원히 적용하라는 것이 아닙니다.

그런데 한국 커뮤니티에서 수용되는 과정에서 연습 규칙이 실전 원칙으로 격상된 측면이 있습니다. "일급 컬렉션에 다른 필드를 넣으면 안 된다"가 코드 리뷰 기준이 되면, 성능 최적화를 위한 내부 캐시도 허용되지 않는 상황이 만들어집니다.

영문권의 비판과 유연한 해석#

  • PHP CodeSniffer의 Calisthenics 규칙은 이 규칙을 정적 분석으로 자동 검사하려다, "This rule makes sense, yet is too strict to be useful in practice. Even our code didn't pass it at all." 이라는 코멘트와 함께 deprecated 처리되었습니다. 자기네 코드도 통과 못했다는 솔직한 고백입니다.

  • William Durand는 9가지 규칙을 상세히 해설하면서, "Breaking rules is fine, as long as it is well thought and deliberated" 라고 명시합니다. 숙고한 결과라면 위반해도 된다는 입장입니다.

3단계: 위임으로 우회 — O(1)은 포기하되 O(K×N)은 해소#

코드 리뷰 피드백을 수용하고, 인덱스 필드를 빼되 성능은 유지하는 방법을 찾았습니다. FooTree에 이미 추가한 nodeIndex에 위임합니다.

java
@Getter
@AllArgsConstructor
public class FooTrees {
    private List<FooTree> values;  // 이것만. 다른 필드 없음.
 
    public FooTree findTreeIncludeId(Long id) {
        return this.values.stream()
                          .filter(tree -> tree.contains(id))  // contains()는 O(1)
                          .findFirst()
                          .orElse(null);
    }
 
    public Optional<FooNode> findNode(Long targetId) {
        return this.values.stream()
                          .filter(tree -> tree.contains(targetId))
                          .findFirst()
                          .map(tree -> tree.findNode(targetId));
    }
 
    public List<FooNode> findNodes(List<Long> ids) {
        return ids.stream()
                  .flatMap(id -> values.stream()
                      .filter(tree -> tree.contains(id))
                      .findFirst()
                      .map(tree -> tree.findNode(id))
                      .stream())
                  .collect(Collectors.toList());
    }
}

코드 형태는 원본과 비슷하게 stream filter를 사용합니다. 하지만 핵심적인 차이가 있습니다: tree.contains(id)가 이제 O(1)입니다. 원본에서는 List.stream().anyMatch()로 O(N)이었던 것이 HashMap.containsKey()로 바뀌었습니다.

findNodes()에서 사용한 Optional.stream()은 Java 9에서 추가된 메서드입니다. 값이 있으면 1개짜리 Stream을, 없으면 빈 Stream을 반환합니다. flatMap과 조합하면 null 필터링 없이 깔끔하게 처리할 수 있습니다.

복잡도 비교#

메서드원본인덱스 (규약 위반)위임 (규약 준수)
findTreeIncludeIdO(K × N)O(1)O(K)
findNodeO(K × N)O(1)O(K)
findNodes(M개)O(M × K × N)O(M)O(M × K)

변수 설명:

  • K: 트리 개수. 최상위 노드 수로, 보통 수 개~수십 개.
  • N: 개별 트리 내 전체 노드 수. 수백~수만.
  • M: findNodes()에 전달되는 ID 개수.

핵심: N이 제거됨#

세 가지 방식 모두에서 가장 큰 차이를 만드는 건 N의 유무입니다.

원본에서 K=20, N=12,000일 때 findTreeIncludeId() 1회 호출은 20 × 12,000 = 240,000번 비교입니다. 위임 방식은 20번. 인덱스 방식은 1번.

위임 방식이 인덱스 방식보다 K배 느린 건 맞지만, K=20이면 20배입니다. HashMap lookup이 수십 나노초 수준이므로, 20배를 곱해도 마이크로초 단위입니다. API 응답 시간(수백 ms ~ 수 초)에서 이 차이는 측정할 수 없습니다.

반면 원본 → 위임의 차이는 N배, 즉 12,000배입니다. 이게 61초 timeout을 25초로 줄인 개선의 핵심입니다.

실측 수치#

이 서비스의 운영 환경 기준:

변수설명
K10~30최상위 목표 수
N1,000~12,000전체 하위 노드 수
M수십~수백일괄 조회 시 ID 수
루프 반복~1,000구성원 수

원본 기준 최악: 1,000 × 30 × 12,000 = 3.6억 번 비교 위임 기준 최악: 1,000 × 30 = 30,000번 비교

12,000배 차이입니다.

구조적으로 보면#

일급 컬렉션에 캐시를 둘 수 없다
  → cross-object 인덱스(id → 어떤 컬렉션 원소에 속하는가)가 불가능하다
  → O(1) lookup을 포기해야 한다
  → 대신 하위 객체에 캐시를 두고 위임하면 O(K)까지는 가능하다
  → K가 작으면 실질적으로 문제 없음
  → K가 크면 규약을 깨거나, 일급 컬렉션 자체를 포기해야 함

이건 OOP 추상화와 성능 최적화 사이의 근본적인 충돌입니다.

Game Programming Patterns의 Data Locality 챕터에서 이 딜레마를 정면으로 다루고 있습니다. OOP의 캡슐화는 데이터를 객체 단위로 분산시킵니다. 각 객체가 자기 데이터를 갖고, 외부에서는 메서드를 통해서만 접근합니다. 반면 성능 최적화는 데이터를 접근 패턴에 맞게 모으려 합니다. 자주 같이 조회되는 데이터는 물리적으로 가까이 놓아야 캐시 히트율이 올라갑니다.

일급 컬렉션 규약은 이 충돌의 구체적인 한 사례입니다. "컬렉션 외 다른 필드 금지"는 응집도를 위한 규칙인데, 성능을 위한 인덱스는 "다른 필드"에 해당합니다. 응집도를 지키면 성능이 희생되고, 성능을 챙기면 응집도가 깨집니다.

게임 프로그래밍에서는 이 문제를 ECS(Entity-Component-System) 패턴으로 풀었습니다. 객체를 해체하고 데이터를 타입별로 배열에 모아서 캐시 지역성을 확보합니다. OOP를 포기한 것입니다.

우리가 선택한 "하위 객체에 위임" 패턴은 OOP를 포기하지 않으면서 얻을 수 있는 현실적인 타협점입니다.

대안 검토: 외부 Searcher로 인덱스를 빼면?#

코드 리뷰에서 또 다른 제안이 나왔습니다. 인덱스를 일급 컬렉션 안에 두지 말고, 별도의 Searcher/Converter 클래스로 분리하면 되지 않느냐는 것이었습니다.

java
// FooTrees는 순수 일급 컬렉션
@AllArgsConstructor
public class FooTrees {
    private List<FooTree> values;
    // 행위 없음, getter만
}
 
// Searcher가 인덱스를 보유
public class FooTreesSearcher {
    private final Map<Long, FooTree> idToTreeIndex;
    private final Map<Long, FooNode> idToNodeIndex;
 
    public FooTreesSearcher(FooTrees fooTrees) {
        this.idToTreeIndex = new HashMap<>();
        this.idToNodeIndex = new HashMap<>();
        for (FooTree tree : fooTrees.getValues()) {
            for (FooNode node : tree.getNodes()) {
                idToTreeIndex.put(node.getId(), tree);
                idToNodeIndex.put(node.getId(), node);
            }
        }
    }
 
    public FooTree findTreeIncludeId(Long id) {
        return idToTreeIndex.get(id);  // O(1)
    }
}

규약 준수 + O(1). 얼핏 보면 양쪽 다 챙긴 것 같습니다. 하지만 이 구조는 다른 것을 포기합니다.

행위가 일급 컬렉션 밖으로 나감#

java
// 하위 위임 방식: 객체에게 tell
fooTrees.findNode(id);
 
// Searcher 방식: 데이터를 꺼내서(ask) 외부에서 처리
searcher.findNode(id);

Martin Fowler의 Tell, Don't Ask 원칙에 따르면, 객체에게 데이터를 꺼내서 외부에서 판단하지 말고, 객체에게 무엇을 하라고 "tell"해야 합니다. Searcher 방식은 FooTrees의 내부 데이터(getValues())를 꺼내서 외부에서 인덱싱하는 구조입니다.

FooTrees에서 탐색 메서드를 전부 빼면 남는 건 getValues()뿐입니다. Fowler가 말하는 Anemic Domain Model — 데이터만 있고 행동이 없는 도메인 객체 — 이 됩니다.

이 객체가 어디까지 돌아다니는가#

판단의 핵심은 FooTrees가 실제로 어떻게 사용되는지입니다. 코드베이스를 조사해보니 29개 파일에서 사용되고 있었습니다.

  • 도메인 레이어 (9파일): 엔티티 검증, 순환 연결 체크, 점검자 생성, 피드 생성
  • 웹/애플리케이션 레이어 (13파일+): 목록 조회, 리포트 쿼리, 캠페인 개요 계산

사용 패턴은 이렇습니다:

java
// 서비스 A에서 생성
IssueTrees issueTrees = IssueTree.buildTreesWithIssues(allIssues);
 
// 파라미터로 서비스 B에 전달
createAssessorsByIssueConnection(issue, issueTrees);
 
// 서비스 B 안에서 탐색 메서드 직접 호출
IssueTree tree = issueTrees.findTreeIncludeIssueId(issue.getId());

여러 레이어에 걸쳐 파라미터로 넘겨지는 도메인 객체입니다. Searcher로 빼면 받는 쪽에서 FooTrees와 Searcher를 둘 다 받아야 하거나, 매번 Searcher를 새로 생성해야 합니다. 29개 파일에 영향이 갑니다.

정합성 문제#

FooTrees의 values가 변경되면 Searcher의 인덱스와 sync가 맞지 않을 수 있습니다. 행위가 객체 안에 있으면 데이터와 인덱스가 항상 같은 생명주기를 갖습니다.

그러면 Searcher는 언제 쓰는가#

Craig Larman의 Information Expert에 따르면, 책임은 그 책임을 수행하는 데 필요한 정보를 가진 객체에 할당해야 합니다. FooTrees가 데이터를 갖고 있으니 탐색 행위도 FooTrees에 있어야 합니다.

하지만 Eric Evans의 DDD에서 Domain Service는 "어떤 Entity에도 자연스럽게 속하지 않는 행동"에 사용합니다. 만약 여러 데이터소스를 조합하는 복잡한 변환 로직이라면 — 예를 들어 트리 데이터 + 등급 척도 + 회원 정보 + 외부 API 결과를 조합해서 리포트를 만드는 경우 — 그건 어떤 한 객체에 속하지 않으므로 Converter/Service로 빼는 게 맞습니다.

여기까지 따져보면 비교 기준은 이렇습니다:

하위 위임외부 Searcher
적합한 경우객체가 여러 곳에 넘겨짐한 서비스 안에서만 사용됨
단일 데이터소스 탐색여러 데이터소스 조합 변환
성능O(K)O(1)
행위 응집유지깨짐
정합성자동수동 관리 필요

이번 케이스는 FooTrees가 29개 파일에서 도메인 객체로 돌아다니고, 단일 컬렉션에 대한 탐색이 핵심 행위이므로 하위 위임이 적합합니다.

판단 기준#

결국 모든 일급 컬렉션에 인덱스를 넣을 필요는 없습니다. 실무에서 이 상황을 만나면 저는 대체로 아래 순서로 봅니다.

1. 해당 메서드가 hot path에 있는가

한 번 호출되는 메서드면 O(K×N)이라도 문제 없습니다. 문제가 되는 건 루프 안에서 반복 호출될 때입니다. 프로파일링이나 APM 데이터로 먼저 확인합니다.

2. 하위 객체에 인덱스를 위임할 수 있는가

가능하면 일급 컬렉션 규약을 지키면서 N을 제거할 수 있습니다. 이번 케이스가 이 경우였습니다. FooTree(도메인 객체)에 nodeIndex를 추가하고, FooTrees(일급 컬렉션)는 tree.contains() O(1)에 위임합니다.

3. K가 충분히 작은가

위임 방식의 복잡도는 O(K)입니다. K가 수십 이하면 O(1)과의 차이가 마이크로초 단위라 무시할 수 있습니다. K가 수백~수천이면 O(K)도 부담이 됩니다.

4. 위 조건이 모두 안 되면

그때는 규약을 깨고 인덱스를 넣되, 코드에 그 근거를 남깁니다. @Getter(AccessLevel.NONE)으로 인덱스 필드의 외부 노출을 막고, PR에서 복잡도 분석과 함께 왜 규약을 깼는지 설명합니다.

Jeff Bay도, William Durand도 "숙고한 위반은 괜찮다"고 했습니다. 다만 진짜 중요한 건 그 위반이 즉흥적인 타협이 아니라는 증거를 남기는 일이라고 봤습니다.

이번 케이스에서는 K가 10~30으로 작고 하위 객체 위임이 가능해서 2번으로 정리했습니다. K가 수천이었다면 아마 4번을 택했을 겁니다. 결국 규약을 지키는 게 목적이 아니라, 왜 지키고 왜 깨는지를 설명할 수 있는 상태를 만드는 게 더 중요했습니다.

Connected Notes