Table of contents
- 익숙하다고 쉬운 건 아니다
- Singleton — 하나만 있어야 한다는 제약
- Iterator — 순회의 표준화
- Prototype — 복제를 통한 생성
- 세 패턴이 남기는 교훈
- 시리즈를 마치며
익숙하다고 쉬운 건 아니다
Singleton, Iterator, Prototype. 이 세 패턴은 디자인 패턴을 처음 배울 때 가장 먼저 마주치는 이름들이다. Singleton은 인스턴스 하나만 만드는 패턴, Iterator는 반복문에 쓰는 패턴, Prototype은 복사하는 패턴 — 한 줄 요약이 너무 쉬워서 오히려 깊이 생각하지 않게 된다.
그런데 실무에서 만나면 이야기가 달라진다. Singleton을 쓸 때마다 “이거 안티패턴 아니야?”라는 의문이 따라붙고, Iterator는 이미 언어가 다 제공해주니 왜 패턴으로 배워야 하는지 납득이 안 되며, Prototype은 얕은 복사와 깊은 복사 사이에서 버그가 터진다.
이번 편에서는 이 세 패턴의 의도를 정확히 짚고, 실제로 어디서 쓰이는지, 어디서 문제가 생기는지를 정리한다. 시리즈의 마지막 편이기도 하니, 끝에서 8편 전체를 관통하는 이야기도 함께 담았다.
Singleton — 하나만 있어야 한다는 제약
의도
Singleton 패턴의 의도는 단순하다. 클래스의 인스턴스가 오직 하나만 존재하도록 보장하고, 그 인스턴스에 대한 전역 접근점을 제공한다. 데이터베이스 커넥션 풀, 설정 관리자, 로깅 시스템처럼 프로그램 전체에서 딱 하나만 있어야 의미가 있는 객체를 다룰 때 사용한다.
문제는 이 단순한 의도가 구현과 사용 과정에서 얼마나 많은 논쟁을 만들어내는가다.
Java에서의 Singleton 구현 — 생각보다 까다롭다
Java에서 Singleton을 구현하는 방식은 여러 가지가 있고, 각각 장단점이 다르다. 대표적인 세 가지를 비교해보자.
flowchart TB
Q{"멀티스레드\n환경인가?"}
Q -->|아니오| EAGER["Eager Initialization\n(클래스 로딩 시 생성)"]
Q -->|예| Q2{"지연 초기화가\n필요한가?"}
Q2 -->|아니오| EAGER
Q2 -->|예| Q3{"Java? Kotlin?"}
Q3 -->|Java| HOLDER["LazyHolder\n(내부 클래스 활용)"]
Q3 -->|Kotlin| OBJ["object 키워드\n(언어 차원 지원)"]
style EAGER fill:#4a90d9
style HOLDER fill:#5ca45c
style OBJ fill:#d4943a
1. Eager Initialization (즉시 초기화)
클래스가 로딩되는 시점에 인스턴스를 바로 만든다.
public class AppConfig {
private static final AppConfig INSTANCE = new AppConfig();
private AppConfig() {
// 외부 생성 차단
}
public static AppConfig getInstance() {
return INSTANCE;
}
}
static final 필드는 클래스 로딩 시 JVM이 한 번만 초기화하므로 스레드 안전하다. 가장 간단한 방법이지만, 인스턴스를 실제로 사용하지 않더라도 무조건 생성된다는 단점이 있다. 생성 비용이 크지 않다면 이 방식이 가장 무난하다.
2. LazyHolder (내부 클래스를 이용한 지연 초기화)
인스턴스가 실제로 필요한 시점까지 생성을 미루면서도 스레드 안전성을 보장하는 방식이다.
public class AppConfig {
private AppConfig() {}
private static class Holder {
private static final AppConfig INSTANCE = new AppConfig();
}
public static AppConfig getInstance() {
return Holder.INSTANCE;
}
}
Holder 클래스는 getInstance()가 처음 호출될 때 비로소 로딩된다. JVM의 클래스 로딩 메커니즘이 스레드 안전성을 보장하므로 synchronized 키워드 없이도 안전하다. Java에서 Singleton을 구현할 때 가장 널리 추천되는 방식이다.
3. DCL(Double-Checked Locking)
한때 유행했지만 지금은 권장하지 않는 방식이다. 잠깐 언급하는 이유는, 왜 안 쓰는지를 아는 것도 중요하기 때문이다.
public class AppConfig {
private static volatile AppConfig instance;
private AppConfig() {}
public static AppConfig getInstance() {
if (instance == null) { // 1차 확인
synchronized (AppConfig.class) {
if (instance == null) { // 2차 확인
instance = new AppConfig();
}
}
}
return instance;
}
}
volatile 키워드가 없으면 명령어 재정렬(instruction reordering) 문제로 다른 스레드가 불완전하게 초기화된 객체를 볼 수 있다. volatile을 붙이면 해결되긴 하지만, LazyHolder보다 코드가 복잡하고 성능상 이점도 거의 없어서 실무에서는 잘 쓰지 않는다.
Kotlin의 object — 언어가 패턴을 흡수하다
Kotlin에서는 Singleton이 언어 키워드로 내장되어 있다. object 선언이 바로 그것이다.
object AppConfig {
var dbUrl: String = "jdbc:postgresql://localhost:5432/mydb"
var maxPoolSize: Int = 10
fun validate(): Boolean {
return dbUrl.isNotBlank() && maxPoolSize > 0
}
}
object로 선언하면 클래스 정의와 단일 인스턴스 생성이 동시에 이루어진다. 컴파일하면 내부적으로 static final 필드 + private 생성자 패턴이 만들어지므로, 스레드 안전성도 JVM 수준에서 보장된다.
사용할 때는 클래스 이름으로 바로 접근한다.
fun main() {
println(AppConfig.dbUrl)
AppConfig.maxPoolSize = 20
println(AppConfig.validate()) // true
}
Java에서 수십 줄 걸리던 것이 Kotlin에서는 한 줄(object)로 끝난다. 디자인 패턴이 언어 기능으로 흡수된 대표적인 사례다.
Singleton은 안티패턴인가
Singleton에 대한 비판은 거세다. 주요 논점을 정리하면 이렇다.
- 전역 상태(Global State): Singleton은 사실상 전역 변수다. 어디서든 접근할 수 있으므로 의존성이 코드에 명시되지 않고 숨겨진다. 클래스의 생성자만 봐서는 그 클래스가 Singleton에 의존하는지 알 수 없다
- 테스트 어려움: Singleton의 상태가 테스트 간에 공유되면 테스트가 격리되지 않는다. 하나의 테스트가 Singleton의 상태를 바꾸면 다음 테스트에 영향을 준다
- 결합도 증가:
AppConfig.getInstance()를 코드 여기저기서 직접 호출하면,AppConfig의 구현이 바뀔 때 호출하는 모든 곳이 영향을 받는다
이 비판들은 대부분 타당하다. 하지만 Singleton 자체가 나쁜 게 아니라, Singleton을 남용하는 방식이 나쁜 것이다. 해결 방법은 DI(Dependency Injection, 의존성 주입)를 함께 쓰는 것이다.
// 나쁜 예: 직접 참조
class UserService {
fun getUser(id: String): User {
val db = DatabasePool.getInstance() // 숨겨진 의존성
return db.query("SELECT * FROM users WHERE id = ?", id)
}
}
// 좋은 예: 생성자 주입
class UserService(private val db: DatabasePool) {
fun getUser(id: String): User {
return db.query("SELECT * FROM users WHERE id = ?", id)
}
}
DatabasePool이 Singleton이더라도, 그걸 주입받으면 의존성이 명시적으로 드러나고, 테스트 시 Mock으로 교체할 수 있다. Spring Framework가 Bean을 기본적으로 Singleton 스코프로 관리하면서도 DI를 통해 주입하는 이유가 바로 이것이다.
결론적으로, Singleton은 인스턴스가 하나여야 한다는 제약이 진짜로 필요한 곳에서만 쓰고, 접근 방식은 DI로 감싸는 것이 현대적인 관행이다.
Iterator — 순회의 표준화
컬렉션 내부를 모르고도 순회하기
배열, 리스트, 트리, 해시맵. 이 자료구조들은 내부 구조가 전부 다르다. 배열은 인덱스로 접근하고, 리스트는 노드를 따라가고, 트리는 재귀적으로 탐색하며, 해시맵은 버킷을 순회한다.
Iterator(이터레이터) 패턴은 컬렉션의 내부 구조를 노출하지 않으면서, 그 요소들을 순차적으로 접근할 수 있는 방법을 제공하는 패턴이다. 클라이언트는 다음 요소를 달라고 요청하기만 하면 되고, 그 요소가 배열의 몇 번째 칸에 있는지, 트리의 어떤 깊이에 있는지는 알 필요가 없다.
순회 시퀀스 다이어그램
Iterator가 동작하는 과정을 시퀀스 다이어그램으로 그려보면 이렇다.
sequenceDiagram
participant Client as 클라이언트
participant Collection as Iterable
participant Iter as Iterator
Client->>Collection: iterator()
Collection-->>Client: Iterator 객체 반환
loop hasNext()가 true인 동안
Client->>Iter: hasNext()
Iter-->>Client: true
Client->>Iter: next()
Iter-->>Client: 다음 요소
end
Client->>Iter: hasNext()
Iter-->>Client: false
클라이언트가 컬렉션에게 iterator()를 요청하면, 컬렉션은 자신의 내부 구조를 순회할 수 있는 Iterator 객체를 돌려준다. 클라이언트는 hasNext()로 남은 요소가 있는지 확인하고, next()로 다음 요소를 가져온다. 이 프로토콜만 지키면 어떤 자료구조든 동일한 방식으로 순회할 수 있다.
Java/Kotlin의 Iterable과 Iterator
Java는 java.util.Iterator와 java.lang.Iterable 인터페이스로 이 패턴을 표준화했다. Kotlin도 동일한 인터페이스를 사용하되, 더 편리한 문법을 제공한다.
먼저 Java의 Iterator 인터페이스 구조를 보자.
public interface Iterator<E> {
boolean hasNext();
E next();
}
public interface Iterable<T> {
Iterator<T> iterator();
}
Iterable을 구현하면 for-each 루프를 쓸 수 있다. ArrayList, HashSet, LinkedList 등 Java의 모든 컬렉션이 Iterable을 구현하고 있다.
직접 Iterator를 만들어보면 패턴의 구조가 명확해진다. 정해진 범위의 정수를 순회하는 간단한 예시를 Kotlin으로 작성해보겠다.
class IntRange(private val start: Int, private val end: Int) : Iterable<Int> {
override fun iterator(): Iterator<Int> = IntRangeIterator()
private inner class IntRangeIterator : Iterator<Int> {
private var current = start
override fun hasNext(): Boolean = current <= end
override fun next(): Int {
if (!hasNext()) throw NoSuchElementException()
return current++
}
}
}
IntRange가 Iterable을 구현하므로 for 루프에 직접 쓸 수 있다.
fun main() {
val range = IntRange(1, 5)
for (num in range) {
print("$num ") // 1 2 3 4 5
}
}
Kotlin의 for (x in collection) 문법은 내부적으로 iterator() → hasNext() → next()를 호출하는 코드로 변환된다. 개발자가 의식하지 않아도 Iterator 패턴이 동작하고 있는 것이다.
커스텀 자료구조와 Iterator
Iterator 패턴의 진가는 커스텀 자료구조를 만들 때 드러난다. 이진 트리를 중위 순회(Inorder Traversal)로 반복하는 Iterator를 만들어보자.
class BinaryTree<T : Comparable<T>> : Iterable<T> {
private var root: Node<T>? = null
data class Node<T>(val value: T, var left: Node<T>? = null, var right: Node<T>? = null)
fun insert(value: T) {
root = insertRecursive(root, value)
}
private fun insertRecursive(node: Node<T>?, value: T): Node<T> {
if (node == null) return Node(value)
if (value < node.value) node.left = insertRecursive(node.left, value)
else if (value > node.value) node.right = insertRecursive(node.right, value)
return node
}
override fun iterator(): Iterator<T> = InorderIterator(root)
private class InorderIterator<T>(root: Node<T>?) : Iterator<T> {
private val stack = ArrayDeque<Node<T>>()
init {
pushLeft(root)
}
private fun pushLeft(node: Node<T>?) {
var current = node
while (current != null) {
stack.addLast(current)
current = current.left
}
}
override fun hasNext(): Boolean = stack.isNotEmpty()
override fun next(): T {
if (!hasNext()) throw NoSuchElementException()
val node = stack.removeLast()
pushLeft(node.right)
return node.value
}
}
}
스택을 이용해 중위 순회를 비재귀적으로 구현했다. pushLeft()가 왼쪽 자식을 끝까지 따라가면서 스택에 쌓고, next()가 스택에서 하나씩 꺼내면서 오른쪽 서브트리를 처리한다.
이제 이진 트리도 일반 리스트처럼 for 루프로 순회할 수 있다.
fun main() {
val tree = BinaryTree<Int>()
listOf(5, 3, 7, 1, 4, 6, 8).forEach { tree.insert(it) }
for (value in tree) {
print("$value ") // 1 3 4 5 6 7 8 (정렬된 순서)
}
}
클라이언트 입장에서는 이것이 트리인지 리스트인지 알 수 없다. Iterable이라는 표준 인터페이스 뒤에 트리의 복잡한 순회 로직이 숨겨져 있기 때문이다. 이것이 Iterator 패턴의 핵심 가치다.
Iterator가 언어에 녹아든 방식
현대 언어에서 Iterator 패턴은 너무 깊이 내장되어 있어서 패턴이라는 인식 자체가 옅어졌다. Kotlin의 Sequence, Java의 Stream, Python의 제너레이터 모두 Iterator 패턴의 변형이다.
Kotlin에서는 sequence 빌더로 지연 평가(lazy evaluation)되는 Iterator를 만들 수 있다.
val fibonacci = sequence {
var a = 0L
var b = 1L
while (true) {
yield(a)
val next = a + b
a = b
b = next
}
}
fibonacci는 무한 시퀀스지만, 실제로 값을 꺼낼 때만 계산이 일어난다. yield()가 Iterator의 next()에 대응하고, 코루틴이 상태를 유지하면서 호출마다 다음 값을 돌려준다.
fun main() {
fibonacci.take(10).forEach { print("$it ") }
// 0 1 1 2 3 5 8 13 21 34
}
패턴이 언어 기능으로 흡수되면 보일러플레이트가 사라지고, 개발자는 무엇을 순회할 것인가에만 집중할 수 있게 된다.
Prototype — 복제를 통한 생성
왜 복제가 필요한가
객체를 만드는 비용이 클 때가 있다. 데이터베이스에서 데이터를 읽어와서 초기화해야 한다거나, 복잡한 설정 과정을 거쳐야 한다거나, 네트워크 호출이 필요하다거나. 이런 객체의 변형본이 여러 개 필요한 상황이라면, 매번 처음부터 만드는 것은 낭비다.
Prototype(프로토타입) 패턴은 기존 객체를 복제해서 새 객체를 만드는 패턴이다. 원본(prototype)을 하나 만들어두고, 필요할 때마다 복제한 뒤 일부만 수정해서 사용한다.
얕은 복사와 깊은 복사
Prototype 패턴에서 가장 까다로운 문제가 복사의 깊이다.
얕은 복사(Shallow Copy): 객체의 필드 값을 그대로 복사한다. 기본 타입은 값이 복사되지만, 참조 타입은 주소만 복사된다. 원본과 복제본이 같은 객체를 공유하게 된다.
깊은 복사(Deep Copy): 참조 타입 필드가 가리키는 객체까지 재귀적으로 새로 만든다. 원본과 복제본이 완전히 독립된다.
Java의 clone() 메서드가 악명 높은 이유가 여기 있다.
public class Document implements Cloneable {
private String title;
private List<String> tags; // 참조 타입
@Override
public Document clone() {
try {
Document copy = (Document) super.clone();
// super.clone()은 얕은 복사만 한다!
// tags는 원본과 같은 리스트를 참조한다
copy.tags = new ArrayList<>(this.tags); // 깊은 복사 필요
return copy;
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}
super.clone()은 얕은 복사를 하므로, tags 리스트를 새로 만들어주지 않으면 원본의 태그를 수정할 때 복제본도 영향을 받는다. 필드가 많아지고 중첩 참조가 깊어질수록 빠뜨리기 쉬워진다.
Cloneable 인터페이스의 설계도 문제가 있다. 이 인터페이스에는 메서드가 하나도 없다. 단지 clone()을 호출해도 된다는 마커(marker) 역할만 할 뿐이다. clone() 메서드는 Object에 정의되어 있고, protected이므로 외부에서 호출하려면 오버라이드해야 한다. Joshua Bloch가 Effective Java에서 Cloneable은 깨진 인터페이스라고 표현한 것이 이런 맥락이다.
Kotlin의 data class copy()
Kotlin의 data class는 copy() 메서드를 자동으로 생성한다. 이 메서드가 사실상 Prototype 패턴의 현대적 구현이다.
data class GameCharacter(
val name: String,
val level: Int,
val hp: Int,
val skills: List<String>
)
copy()를 사용하면 원본의 값을 기반으로 일부만 변경한 새 객체를 만들 수 있다.
fun main() {
val warrior = GameCharacter(
name = "전사",
level = 10,
hp = 1000,
skills = listOf("검격", "방어")
)
// 이름과 스킬만 다른 변형본 생성
val mage = warrior.copy(
name = "마법사",
hp = 600,
skills = listOf("파이어볼", "텔레포트")
)
println(warrior) // GameCharacter(name=전사, level=10, hp=1000, skills=[검격, 방어])
println(mage) // GameCharacter(name=마법사, level=10, hp=600, skills=[파이어볼, 텔레포트])
}
copy()는 명시적으로 변경한 필드만 새 값을 쓰고, 나머지는 원본 값을 그대로 가져간다. level은 warrior와 동일한 10이 된다.
하지만 여기에도 함정이 있다. copy()는 얕은 복사다.
data class Team(
val name: String,
val members: MutableList<String>
)
fun main() {
val original = Team("A팀", mutableListOf("Alice", "Bob"))
val copied = original.copy(name = "B팀")
copied.members.add("Charlie")
println(original.members) // [Alice, Bob, Charlie] ← 원본도 변경됨!
}
members가 MutableList이고 copy()가 참조만 복사했기 때문에, 복제본에서 멤버를 추가하면 원본에도 영향이 간다. 이 문제를 피하려면 두 가지 방법이 있다.
방법 1: 불변 컬렉션 사용. 가장 간단하고 권장되는 방법이다. MutableList 대신 List를 쓰면 복제본에서 원본을 수정할 수 없다. 변경이 필요하면 새 리스트를 만들어 대입한다.
data class Team(
val name: String,
val members: List<String> // 불변
)
val original = Team("A팀", listOf("Alice", "Bob"))
val copied = original.copy(
name = "B팀",
members = original.members + "Charlie" // 새 리스트 생성
)
방법 2: 수동 깊은 복사. 가변 컬렉션이 꼭 필요한 경우, 복사 시점에 새 컬렉션을 만들어준다.
fun Team.deepCopy(name: String = this.name): Team {
return Team(
name = name,
members = this.members.toMutableList() // 새 리스트 생성
)
}
실무에서는 가능한 한 불변 객체를 쓰는 것이 복사 문제를 원천적으로 차단하는 가장 좋은 방법이다. 값이 바뀌지 않으니 얕은 복사든 깊은 복사든 결과가 같기 때문이다.
Prototype이 유용한 상황
Prototype 패턴이 빛나는 구체적인 상황을 정리해보자.
- 설정 템플릿: 기본 설정 객체를 하나 만들어두고, 환경별(개발/스테이징/운영)로 일부만 바꿔서 복제하는 경우
- 게임 개발: 적 캐릭터나 아이템의 원형을 두고, 스탯만 조금씩 바꿔서 대량으로 찍어내는 경우
- 문서 템플릿: 기본 문서 양식을 복제한 뒤 내용만 채워 넣는 경우
핵심은 거의 같은데 조금만 다른 객체가 여러 개 필요한 상황이다. 이런 상황에서 매번 생성자를 호출하며 모든 필드를 일일이 채우는 것보다, 원형을 복제하고 차이만 수정하는 것이 훨씬 간결하다.
세 패턴이 남기는 교훈
Singleton, Iterator, Prototype은 GoF 패턴 중에서도 가장 많이 쓰이면서 가장 많이 변형된 패턴들이다. 세 패턴에서 공통적으로 읽을 수 있는 교훈이 하나 있다.
패턴은 언어와 함께 진화한다.
Singleton은 Java에서 수십 줄의 보일러플레이트가 필요했지만, Kotlin에서는 object 한 줄로 줄었다. Iterator는 for-each 문법, Stream, Sequence라는 언어 기능으로 흡수되었다. Prototype의 clone()은 Kotlin의 data class copy()로 대체되었다.
GoF가 디자인 패턴을 정리한 1994년에는 이 패턴들을 직접 구현해야 했다. 그 시절의 패턴이 30년이 지난 지금은 언어 키워드가 되어 있거나, 표준 라이브러리에 녹아들어 있다. 패턴을 공부하는 진짜 이유는 보일러플레이트 코드를 외우려는 게 아니다. 패턴이 풀려고 했던 문제를 이해하는 것이다. 문제를 이해하면, 그 문제를 더 나은 방식으로 해결하는 현대적 도구를 발견했을 때 아, 이게 그 패턴이구나라고 알아볼 수 있다.
시리즈를 마치며
8편에 걸쳐 GoF의 주요 디자인 패턴을 살펴봤다. Strategy, State, Observer, Command, Template Method, Chain of Responsibility, Factory Method, Abstract Factory, Builder, Decorator, Proxy, Adapter, Facade, Composite, Singleton, Iterator, Prototype. 이름만 나열해도 꽤 많다.
이 패턴들을 관통하는 원칙은 결국 하나다. 변하는 것과 변하지 않는 것을 분리하라. Strategy는 알고리즘이 변하니 분리했고, Observer는 관찰자가 변하니 분리했고, Factory는 생성 로직이 변하니 분리했다. Adapter는 인터페이스가 맞지 않으니 중간 계층을 끼웠고, Composite는 부분과 전체의 차이를 인터페이스로 통일시켰다. 패턴의 이름은 달라도, 근본에 있는 질문은 같다. “이 코드에서 바뀔 수 있는 부분은 어디인가?”
패턴을 공부할 때 흔히 빠지는 함정이 있다. 패턴을 적용하는 것 자체가 목적이 되는 것이다. 코드에 문제가 있으니 패턴을 쓰는 것이지, 패턴을 쓰기 위해 문제를 찾는 건 순서가 뒤집힌 것이다. 코드가 충분히 간단하면 패턴 없이도 괜찮고, 그 단순함이 오히려 미덕일 때가 많다.
디자인 패턴은 설계의 공통 언어이기도 하다. 여기에 Strategy를 적용하자라고 말하면, 팀원 모두가 어떤 구조가 될지 머릿속에 그릴 수 있다. 패턴을 아는 것은 코드를 잘 짜기 위해서이기도 하지만, 다른 사람의 설계 의도를 읽어내고 자신의 의도를 정확히 전달하기 위해서이기도 하다.




Loading comments...