일급 컬렉션에 HashMap 인덱스를 넣으면 규약 위반인가?
상황#
ECS CPU spike 디버깅 과정에서, 레거시 서비스의 트리 구조를 감싸는 일급 컬렉션의 탐색 성능을 손봐야 하는 상황이 생겼습니다.
이 서비스는 계층형 데이터를 트리로 빌드해서 사용합니다. 트리가 여러 개 존재하고, 이를 하나로 묶어 관리하는 래퍼 클래스가 있습니다. 구조는 이렇습니다.
FooTrees (일급 컬렉션)
└── List<FooTree> values
└── FooTree (도메인 객체)
├── FooNode rootNode
├── List<FooNode> nodes
└── (탐색/빌드 메서드들)
FooTrees는 List<FooTree>만 감싸는 일급 컬렉션입니다. Lombok의 @AllArgsConstructor와 @Getter만 붙어 있고, 컬렉션에 대한 도메인 행위(탐색, 검증, 변환)만 메서드로 제공합니다.
FooTree는 루트 노드, 노드 리스트, 트리 빌드 로직 등을 가진 도메인 객체로, 일급 컬렉션이 아닙니다.
원본 코드의 탐색 방식#
FooTrees에서 가장 자주 호출되는 메서드 3개가 있습니다.
// 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()의 내부 구현:
public boolean contains(Long id) {
return this.nodes.stream()
.anyMatch(node -> node.getId().equals(id));
}모든 탐색이 stream filter 기반입니다.
왜 문제인가#
findTreeIncludeId()의 시간 복잡도를 분석하면:
- K개의 트리를 순회합니다
- 각 트리에서
contains()가 N개 노드를 stream으로 탐색합니다 - 총 O(K × N)
이 메서드가 한 번 호출되면 괜찮습니다. 하지만 상위 서비스에서 이런 코드가 있었습니다:
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 인덱스를 같이 만들어둡니다.
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 인덱스를 만들었습니다.
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가 클래스 레벨에 붙어 있었습니다.
@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)을 인덱스 필드에 붙이면 노출은 막을 수 있습니다.
@Getter(AccessLevel.NONE)
private Map<Long, FooTree> idToTreeIndex;하지만 이건 근본적인 문제를 우회할 뿐입니다. 일급 컬렉션에 values 외의 필드가 존재한다는 사실은 변하지 않습니다.
문제 2: 생성자 계약을 변경한다.#
원래 @AllArgsConstructor로 new FooTrees(List<FooTree>)만 받았는데, 인덱스 빌드를 위해 @AllArgsConstructor를 제거하고 명시적 생성자를 작성해야 했습니다. 이 클래스를 사용하는 모든 곳의 생성 방식은 동일하지만, 클래스 내부가 단순 래핑에서 초기화 로직을 가진 객체로 바뀌었습니다. 일급 컬렉션의 단순함이 사라진 셈입니다.
코드 리뷰에서 지적이 들어왔습니다: 일급 컬렉션에 다른 속성이 들어가면 안 된다.
일급 컬렉션 규약이란#
출처: Object Calisthenics Rule 4#
Jeff Bay가 『ThoughtWorks Anthology』(2008)에서 소개한 Object Calisthenics의 Rule 4는 다음과 같습니다.
Use First-Class Collections
Any class that contains a collection should contain no other member variables.
즉, 컬렉션을 감싸는 클래스는 해당 컬렉션 외에 다른 멤버 변수를 갖지 않아야 한다는 규칙입니다.
Object Calisthenics는 객체지향적인 설계 감각을 기르기 위한 아홉 가지 규칙으로 구성된 OOP 훈련법입니다. 여기서 calisthenics는 ‘체조’ 또는 ‘맨몸운동’을 뜻합니다. Jeff Bay는 이 규칙들을 실무에서 반드시 지켜야 하는 절대 원칙이 아니라, 의도적으로 극단적인 제약을 두고 그 안에서 코드를 작성해 보는 연습법으로 제안했습니다.
일급 컬렉션 규약은 Rule 8인 **“클래스가 두 개를 초과하는 인스턴스 변수를 갖지 않도록 한다”**는 규칙과도 관련이 있습니다. 예를 들어 컬렉션을 감싼 클래스에 조회 성능을 위한 HashMap 인덱스를 추가하면, 엄밀한 의미의 일급 컬렉션 규약을 벗어날 뿐 아니라 클래스의 상태를 제한하려는 Rule 8의 취지에서도 멀어질 수 있습니다.
일급 컬렉션의 장점#
이 규약이 지켜지면 컬렉션 관련 행위가 한 곳에 응집되고, List<FooTree> 대신 FooTrees라는 도메인 의미가 부여되며, 단일 필드만 관리하면 되므로 불변 설계가 단순해집니다. 장점에 대한 자세한 설명은 인프런 이동욱님의 블로그과 우테코 기술블로그에 잘 정리되어 있습니다.
이 글에서 다루려는 건 장점이 아니라, 이 규약이 성능 최적화와 충돌할 때 어떻게 하는가입니다.
한국 커뮤니티에서의 수용#
한국 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에 위임합니다.
@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 필터링 없이 깔끔하게 처리할 수 있습니다.
복잡도 비교#
| 메서드 | 원본 | 인덱스 (규약 위반) | 위임 (규약 준수) |
|---|---|---|---|
findTreeIncludeId | O(K × N) | O(1) | O(K) |
findNode | O(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배를 곱해도 마이크로초 단위입니다.(빅-오 측정에서 무의미한 값)
반면 원본 → 위임의 차이는 N배, 즉 12,000배로 61초 timeout을 25초로 줄이게될 수 있었던 개선의 원인이었습니다.
실측 수치#
이 서비스의 운영 환경 기준:
| 변수 | 값 | 설명 |
|---|---|---|
| K | 10~30 | 최상위 목표 수 |
| N | 1,000~12,000 | 전체 하위 노드 수 |
| M | 수십~수백 | 일괄 조회 시 ID 수 |
| 루프 반복 | ~1,000 | 구성원 수 |
원본 기준 최악: 1,000 × 30 × 12,000 = 3.6억 번 비교 위임 기준 최악: 1,000 × 30 = 30,000번 비교
약 12,000배의 성능차이가 발생할 수 있습니다.
정리해보자면#
일급 컬렉션 내부에 별도의 캐시를 둘 수 없다
→ id가 어느 컬렉션 원소에 속하는지 나타내는 cross-object 인덱스를 두기 어렵다
→ 컬렉션 전체를 대상으로 한 평균 O(1) 조회를 포기해야 한다
→ 대신 하위 객체에 인덱스를 두고 탐색을 위임하면 O(K)까지 줄일 수 있다
→ K가 작다면 실질적인 성능 차이는 크지 않다
→ K가 크다면 규약을 깨거나, 일급 컬렉션이라는 설계 자체를 재검토해야 한다이는 객체지향적 추상화와 성능 최적화 사이의 트레이드오프로 볼 수 있습니다.
Game Programming Patterns의 Data Locality 챕터에서도 이와 유사한 충돌을 다룹니다. 객체지향 설계는 데이터를 객체 단위로 묶습니다. 각 객체가 자신의 상태를 관리하고, 외부에서는 공개된 메서드를 통해서만 접근하도록 하여 캡슐화를 확보합니다.
반면 성능 최적화에서는 데이터를 객체의 경계보다 실제 접근 패턴에 맞게 배치하려 합니다. 함께 읽히는 데이터가 물리적으로 가까이 있어야 메모리 접근 비용을 줄이고 캐시 지역성을 높일 수 있기 때문입니다.
일급 컬렉션 규약은 이러한 충돌이 드러나는 구체적인 사례 중 하나였습니다. “컬렉션 외의 다른 필드를 두지 않는다”는 제약은 컬렉션과 관련된 책임을 한곳에 모으도록 유도하지만, 조회 성능을 위한 인덱스 역시 별도의 필드에 해당합니다. 규약을 엄격하게 지키면 인덱스를 둘 수 없고, 인덱스를 추가하면 규약을 벗어나게 됩니다.
게임 프로그래밍에서는 이러한 문제를 해결하기 위해 ECS(Entity-Component-System)나 Data-Oriented Design과 같은 접근을 사용하기도 합니다. 객체 단위로 흩어진 데이터를 컴포넌트 타입별 배열에 모아, 객체 모델보다 데이터 접근 패턴과 캐시 지역성을 우선하는 방식입니다. OOP를 완전히 포기한다기보다는, 객체 중심 설계의 일부를 내려놓고 성능에 유리한 데이터 중심 구조를 선택한 것에 가깝습니다.
이번에 선택한 하위 객체에 위임하는 방식은 객체 모델을 유지하면서도 탐색 비용을 줄일 수 있는 현실적인 타협점이었습니다.
대안 검토: 외부 Searcher로 인덱스를 분리하면?#
코드 리뷰에서는 인덱스를 일급 컬렉션 내부에 두지 말고, 별도의 Searcher나 Converter 클래스로 분리하자는 제안도 나왔습니다.
// 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)
}
}이 구조에서는 FooTrees가 컬렉션만 보유하므로 일급 컬렉션 규약을 지키면서 평균 O(1) 조회도 얻을 수 있습니다. 얼핏 보면 규약과 성능을 모두 챙긴 것처럼 보이지만, 대신 객체의 책임과 생명주기 관리가 복잡해집니다.
탐색 행위가 일급 컬렉션 밖으로 나감#
// 하위 위임 방식
fooTrees.findNode(id);
// 외부 Searcher 방식
searcher.findNode(id);Martin Fowler의 Tell, Don’t Ask는 객체의 상태를 밖으로 꺼내 외부에서 판단하기보다, 필요한 행동을 객체에 요청하도록 설계하라는 원칙입니다.
외부 Searcher를 사용하는 것 자체가 반드시 이 원칙을 위반하는 것은 아닙니다. 다만 위 예시처럼 FooTrees가 내부 컬렉션을 getValues()로 노출하고, 외부 객체가 그 데이터를 순회하며 인덱스를 구축한다면 캡슐화가 약해집니다.
탐색 메서드까지 모두 Searcher로 옮기면 FooTrees에는 사실상 getValues()만 남습니다. 이 경우 FooTrees는 행동 없이 데이터만 보유하는 객체가 되며, Fowler가 설명한 Anemic Domain Model에 가까워질 수 있습니다.
객체가 사용되는 범위#
판단의 핵심은 FooTrees가 실제 코드베이스에서 어떤 역할을 하는지였습니다. 조사 결과 이 객체는 총 29개 파일에서 사용되고 있었습니다.
- 도메인 레이어 9개 파일: 엔티티 검증, 순환 연결 확인, 점검자 생성, 피드 생성
- 웹·애플리케이션 레이어 13개 이상 파일: 목록 조회, 리포트 쿼리, 캠페인 개요 계산
주요 사용 패턴은 다음과 같았습니다.
// 서비스 A에서 생성
IssueTrees issueTrees = IssueTree.buildTreesWithIssues(allIssues);
// 서비스 B로 전달
createAssessorsByIssueConnection(issue, issueTrees);
// 서비스 B에서 탐색 메서드 호출
IssueTree tree =
issueTrees.findTreeIncludeIssueId(issue.getId());즉, IssueTrees는 한 서비스 내부에서만 사용하는 임시 조회 도구가 아니라, 여러 레이어를 오가며 사용되는 도메인 객체였습니다.
탐색 책임을 별도의 Searcher로 분리하면 호출부에서 FooTrees와 Searcher를 함께 전달하거나, 필요한 곳마다 Searcher를 새로 생성해야 합니다. 기존 사용 범위가 넓은 만큼 변경 영향도 역시 커집니다.
데이터와 인덱스의 정합성#
또 다른 문제는 원본 데이터와 인덱스의 생명주기입니다.
FooTrees.values가 변경될 수 있다면, 별도로 생성된 Searcher의 인덱스가 이전 상태를 계속 가리킬 가능성이 있습니다. 이를 방지하려면 다음과 같은 제약이 추가로 필요합니다.
FooTrees를 불변 객체로 만든다.- 값이 변경될 때마다
Searcher를 재생성한다. FooTrees와Searcher를 항상 같은 스코프에서 관리한다.- 외부에서 원본 컬렉션을 수정할 수 없도록 막는다.
반면 탐색 행위와 인덱스가 데이터를 소유한 객체 또는 그 하위 객체 안에 있으면, 데이터와 인덱스의 생명주기를 함께 관리하기가 상대적으로 쉽습니다.
그렇다면 Searcher는 언제 적합한가?#
Craig Larman의 Information Expert 원칙에 따르면, 책임은 그 책임을 수행하는 데 필요한 정보를 가장 잘 알고 있는 객체에 할당하는 것이 자연스럽습니다.
FooTrees가 트리 컬렉션을 소유하고 있고 탐색이 그 컬렉션의 핵심 행위라면, 탐색 책임도 FooTrees 또는 FooTree에 두는 편이 자연스럽습니다.
반면 Eric Evans가 DDD에서 설명한 Domain Service처럼, 어떤 하나의 엔티티나 값 객체에도 자연스럽게 귀속되지 않는 행위라면 외부 서비스로 분리할 수 있습니다.
예를 들어 다음과 같은 작업입니다.
- 트리 데이터
- 등급 척도
- 회원 정보
- 외부 API 결과
이처럼 여러 데이터 소스를 조합해 리포트를 생성하는 로직은 어느 한 객체의 책임으로 보기 어렵습니다. 이러한 경우에는 Converter, Assembler, Query Service 또는 Domain Service로 분리하는 편이 더 적절합니다.
비교하면 다음과 같습니다.
| 구분 | 하위 객체 위임 | 외부 Searcher |
|---|---|---|
| 적합한 경우 | 객체가 여러 레이어에서 도메인 객체로 사용됨 | 특정 서비스나 유스케이스 안에서만 사용됨 |
| 주요 책임 | 단일 데이터 소스에 대한 핵심 탐색 | 조회 전용 인덱스 또는 여러 데이터 조합 |
| 조회 복잡도 | O(K) | 평균 O(1) |
| 행위 응집도 | 유지하기 쉬움 | 탐색 책임이 외부로 이동함 |
| 데이터 정합성 | 같은 객체 생명주기 안에서 관리 가능 | 별도의 동기화 또는 불변성 보장이 필요함 |
| 호출부 영향 | 기존 객체만 전달 | Searcher 생성·전달 방식이 추가될 수 있음 |
이번 사례에서는 FooTrees가 29개 파일에서 도메인 객체로 사용되고 있었고, 단일 컬렉션에 대한 탐색이 객체의 핵심 행위였습니다. 따라서 별도의 Searcher를 도입하는 것보다 하위 객체에 탐색을 위임하는 방식이 더 적합하다고 판단했습니다.
판단 기준#
모든 일급 컬렉션에 인덱스를 추가할 필요는 없습니다. 실무에서 비슷한 상황을 만났을 때는 다음 순서로 판단할 수 있습니다.
1. 해당 메서드가 hot path에 있는가?#
한 번만 호출되는 메서드라면 O(K × N)이어도 실제 문제가 되지 않을 수 있습니다. 성능 문제가 발생하는 경우는 대개 루프 안에서 반복적으로 호출되거나, 데이터 크기가 지속적으로 증가할 때입니다.
따라서 먼저 프로파일링이나 APM 데이터를 통해 실제 병목인지 확인해야 합니다.
2. 하위 객체에 인덱스를 위임할 수 있는가?#
가능하다면 일급 컬렉션 규약을 유지하면서도 내부 탐색 비용을 줄일 수 있습니다.
이번 사례에서는 FooTree에 nodeIndex를 두고, FooTrees가 각 FooTree의 contains()에 탐색을 위임하도록 구성했습니다.
기존: O(K × N)
변경: O(K)각 트리 내부의 노드 탐색은 평균 O(1)로 줄어들고, 상위 컬렉션은 어느 트리에 해당 노드가 있는지만 순차적으로 확인합니다.
3. K가 충분히 작은가?#
위임 방식에서도 상위 컬렉션의 원소 수인 K만큼은 순회해야 합니다.
K가 10~30처럼 작고 크기의 상한이 어느 정도 보장된다면, 평균 O(1) 조회와의 차이가 실제 시스템에서 의미 있는 수준인지 먼저 측정해야 합니다. 반면 K가 수백 또는 수천까지 증가할 수 있고 해당 탐색이 자주 실행된다면 O(K) 역시 병목이 될 수 있습니다.
중요한 것은 복잡도 표기만으로 판단하지 않고, 실제 데이터 크기와 호출 빈도를 함께 보는 것입니다.
4. 위 조건으로 해결되지 않는다면#
하위 위임이 불가능하고, K가 크며, 실제 hot path라는 근거까지 확인됐다면 규약을 깨고 일급 컬렉션 내부에 인덱스를 둘 수 있습니다.
이 경우에는 인덱스가 외부 모델의 일부가 아니라 내부 구현 세부사항임을 명확히 하는 편이 좋습니다.
@Getter
public class FooTrees {
private final List<FooTree> values;
@Getter(AccessLevel.NONE)
private final Map<Long, FooTree> idToTreeIndex;
}그리고 PR이나 코드 주석에 다음 내용을 함께 남길 수 있습니다.
- 기존 조회 복잡도
- 예상 데이터 크기
- 호출 빈도
- 프로파일링 또는 APM 근거
- 하위 위임이나 외부 Searcher를 선택하지 않은 이유
- 원본 데이터와 인덱스의 정합성을 유지하는 방법
Object Calisthenics의 규칙은 실무에서 반드시 지켜야 할 절대 법칙이 아니라, 객체지향 설계를 연습하기 위한 의도적인 제약입니다. 따라서 충분히 검토한 뒤 근거를 가지고 규칙을 벗어나는 것은 가능합니다.
중요한 것은 규약을 지켰다는 사실 자체가 아니라, 왜 이 규약이 필요한지 이해하고, 어떤 조건에서 지키거나 깨야 하는지를 설명할 수 있는가입니다.
이번 사례에서는 K가 약 10~30으로 작았고, 하위 객체에 인덱스를 위임하는 것도 가능했습니다. 따라서 일급 컬렉션 규약을 깨지 않고 O(K)까지 줄이는 방식을 선택했습니다.
반대로 K가 수천까지 증가하고 탐색이 hot path에 있었다면, 규약을 벗어나 상위 컬렉션에 직접 인덱스를 추가했을 가능성이 큽니다.
결국 규약을 지키는 것 자체가 목적은 아닙니다. 규약이 보호하려는 설계 가치를 이해하고, 성능과 복잡도 사이에서 어떤 선택을 했는지 설명할 수 있는 상태를 만드는 것이 더 중요합니다.