다크 모드를 켰는데 글자가 사라졌다. 에러는 하나도 없었다.

팩토리 메서드와 추상 팩토리를 공부하면서 직접 만든 예제에서 실제로 본 장면입니다. 두 패턴은 이름도 비슷하고, 정의만 읽으면 둘 다 "객체 생성을 대신해 주는 패턴"이라 차이가 잘 와닿지 않았습니다. 그래서 정의를 외우는 대신, 다크 모드를 만들면서 코드를 세 번 고쳐 봤습니다. 단계마다 문제가 생기고, 패턴으로 풀고, 그 패턴의 한계가 다음 단계의 출발점이 되는 구조입니다.

01. 예제 소개: 화면은 배경과 글자뿐
예제를 단순하게 만들려고 화면 부품을 딱 두 개로 줄였습니다. 라이트 테마는 흰 배경에 검정 글자, 다크 테마는 검은 배경에 흰 글자입니다.
부품은 각자 색만 돌려주는 인터페이스입니다.
public interface Background { Color color(); }
public interface Text { Color color(); }
화면을 그린 결과는 `Rendered`에 담습니다. 여기 있는 `readable()`이 배경색과 글자색이 같으면 글자가 안 보인다고 판정합니다. 뒤에 나오는 "잘 보임 / 글자가 안 보임!" 출력은 전부 이 메서드에서 나옵니다.
// Rendered.java
public record Rendered(Color background, Color text, String message) {
/** 배경과 글자 색이 같으면 글자가 보이지 않는다. */
public boolean readable() {
return background != text;
}
}
1단계: if로 테마 나누기
"다크 모드? 배경만 까맣게 하면 되겠네." 가장 먼저 떠오르는 코드는 이렇습니다.
// step1_nopattern/IfElseScreen.java
public Rendered render(String theme, String message) {
Text text = new BlackText(); // 글자는 모든 테마 공통
Background background;
if (theme.equals("LIGHT")) {
background = new WhiteBackground();
} else if (theme.equals("DARK")) {
background = new BlackBackground();
} else {
throw new IllegalArgumentException("지원하지 않는 테마입니다: " + theme);
}
return new Rendered(background.color(), text.color(), message);
}
동작은 하는데, 고칠 점이 세 가지 보였습니다.
1. 고대비 모드 같은 테마를 추가하려면 이 메서드를 열어서 고쳐야 합니다.
2. '무엇을 만들지 고르는 코드'와 '화면을 그리는 흐름'이 한 메서드에 섞여 있습니다.
3. 테마 이름이 문자열이라 `"DAKR"` 같은 오타는 실행해 봐야 압니다.
2번이 조금 추상적으로 들리는데, 메서드를 두 부분으로 나눠 보면 바로 보입니다.
public Rendered render(String theme, String message) {
// ① 무엇을 만들지 고르기 ─ 테마마다 다름
Text text = new BlackText();
Background background;
if (theme.equals("LIGHT")) { ... } else if (theme.equals("DARK")) { ... }
// ② 화면 그리기 ─ 테마와 상관없이 항상 같음
return new Rendered(background.color(), text.color(), message);
}
②는 테마와 아무 상관이 없는데, ①이 같은 메서드에 붙어 있으니 테마가 늘어날 때마다 ②가 있는 메서드까지 같이 고쳐야 합니다. 결국 문제는 '무엇을 만들지' 고르는 코드가 일하는 코드 한가운데 박혀 있다는 것이었습니다.
그래서 '무엇을 만들지'만 떼어 내 봤습니다. 그게 팩토리 메서드입니다.
---
2단계: Factory Method
부모는 흐름을, 자식은 "무엇을 만들지"를
// step2_factorymethod/Screen.java
public abstract class Screen {
public final Rendered render(String message) {
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("표시할 글자가 비어 있습니다.");
}
Background background = createBackground(); // 무슨 배경을 쓸지는 하위 클래스가 정한다
Text text = new BlackText(); // 글자는 모든 테마 공통
return new Rendered(background.color(), text.color(), message);
}
/** Factory Method: 하위 클래스가 어떤 배경을 만들지 결정한다. */
protected abstract Background createBackground();
}
부모 `Screen`은 화면을 그리는 흐름을 한 번만 정의합니다. 글자가 비었는지 검사하고, 배경을 얻고, 그립니다. `render()`에 `final`이 붙어 있어서 자식은 이 흐름을 바꿀 수 없습니다.
부모가 아는 건 "배경이 필요하다"는 사실뿐이고, 흰색인지 검은색인지는 모릅니다. 그 결정은 `createBackground()`로 비워 두고 자식에게 넘깁니다.
잠깐, 추상 메서드가 뭔가요?
`createBackground()`처럼 이름과 모양만 정해 두고 몸통(`{ }`)은 비워 둔 메서드를 추상 메서드라고 합니다.
`abstract`를 붙이고 세미콜론으로 끝내며, 자식 클래스는 반드시 구현해야 합니다. 빠뜨리면 컴파일 에러가 납니다. 추상 메서드가 있는 클래스는 `abstract class`가 되고, `new Screen()`처럼 직접 만들 수 없습니다.
빈칸이 있는 신청서 양식을 떠올리면 쉽습니다. 부모가 양식(순서와 칸)을 만들고 "배경: ____" 칸만 비워 두면, 자식이 그 칸을 채웁니다. 이렇게 무엇을 만들지 자식에게 맡기려고 비워 둔 메서드를 팩토리 메서드라고 부릅니다.

자식은 한 줄만 정한다
// step2_factorymethod/DarkScreen.java
public class DarkScreen extends Screen {
@Override
protected Background createBackground() {
return new BlackBackground();
}
}
`LightScreen`도 `WhiteBackground`를 돌려준다는 것만 다릅니다. 고대비 모드가 필요하면 `HighContrastScreen` 클래스를 하나 추가하면 끝이고, 기존 코드는 한 줄도 고치지 않습니다.
그런데 다크 모드를 켜 보면

글 맨 위에서 본 그 장면입니다. 다크 모드에서 글자가 사라지는데, 에러는 나지 않습니다.
원인은 부모 코드의 이 한 줄이었습니다.
Text text = new BlackText(); // 글자는 모든 테마 공통
배경만 테마마다 바꾸고, 글자는 모든 테마가 같은 검정을 쓰고 있었던 겁니다. 다시 보니 1단계 if 코드에도 같은 문제가 숨어 있었습니다. 구조를 바꿨다고 해결되는 문제가 아니었던 거죠.
테마가 바뀌면 배경과 글자가 같이 바뀌어야 합니다.
여기서 "그럼 `createText()`도 추상 메서드로 하나 더 두면 되지 않나?"라는 생각이 들 수 있습니다. 바로 그 생각이 3단계의 출발점입니다. 다른 점은 둘을 상속으로 해결하지 않고, 별도 객체(공장)로 묶어서 넘긴다는 것입니다.
3단계: Abstract Factory
배경과 글자를 세트로 만드는 공장
// step3_abstractfactory/ThemeFactory.java
public interface ThemeFactory {
// "테마라면 배경과 글자를 둘 다 만들 수 있어야 한다"는 약속
Background createBackground();
Text createText();
}
// step3_abstractfactory/DarkThemeFactory.java
public class DarkThemeFactory implements ThemeFactory {
// 다크 테마 공장은 다크 테마용 부품만 만든다
@Override
public Background createBackground() {
return new BlackBackground();
}
@Override
public Text createText() {
return new WhiteText();
}
}
라이트 공장은 흰 배경과 검정 글자를, 다크 공장은 검은 배경과 흰 글자를 만듭니다. 옷으로 치면 상의만 따로 사는 게 아니라 코디 세트를 사는 셈입니다.

화면은 공장 하나만 받는다
// step3_abstractfactory/ThemedScreen.java
public class ThemedScreen {
private final Background background;
private final Text text;
public ThemedScreen(ThemeFactory factory) {
this.background = factory.createBackground();
this.text = factory.createText();
}
public Rendered render(String message) {
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("표시할 글자가 비어 있습니다.");
}
return new Rendered(background.color(), text.color(), message);
}
}
이 패턴에서 제일 중요한 건 생성자입니다. `ThemedScreen`은 배경과 글자를 따로 받지 않고 공장 하나만 받습니다. 그래서 `ThemedScreen`을 쓰는 쪽에서는 짝이 안 맞는 조합을 만들 방법이 없습니다. 그리고 `ThemedScreen`은 지금이 라이트인지 다크인지 모르고, 알 필요도 없습니다.
다만 "절대 섞일 수 없다"고 하면 과장입니다. 누군가 `ThemeFactory`를 잘못 구현해서 검은 배경과 검은 글자를 돌려주면 그대로 섞입니다. 정확히는 짝을 맞추는 책임이 공장 한 곳으로 모인 것입니다.
실행 결과

2단계에서 사라졌던 글자가 돌아왔습니다. 다크 공장이 검은 배경과 흰 글자를 함께 만들어 줬기 때문입니다.
반대로 공장을 거치지 않고 부품을 직접 섞으면 어떻게 될까요?
new Rendered(new BlackBackground().color(), new BlackText().color(), "안녕하세요");
// [배경 검정 / 글자 검정] 안녕하세요 → 글자가 안 보임!
컴파일도 되고 에러도 안 납니다. 조용히 틀린 화면이 그대로 사용자에게 나가는 거죠. 추상 팩토리가 막아 주는 게 정확히 이 상황입니다. 객체를 만드는 일과 함께, 서로 맞는 것끼리만 묶이도록 보장하는 일까지 맡습니다.
분기는 사라지지 않는다. 한 곳에 모일 뿐
// step3_abstractfactory/Theme.java
public enum Theme {
LIGHT,
DARK;
public ThemeFactory factory() {
return switch (this) {
case LIGHT -> new LightThemeFactory();
case DARK -> new DarkThemeFactory();
};
}
}
여기에도 여전히 `switch`가 있습니다. 패턴을 쓴다고 분기가 마법처럼 사라지지는 않더라고요. 달라진 건 분기가 어디에 있느냐입니다. 1단계에서는 화면을 그리는 메서드 안에 분기가 있어서 화면 코드가 테마를 알아야 했고, 3단계에서는 공장을 고르는 `Theme.factory()` 한 곳에만 분기가 있어서 화면 코드는 테마를 모릅니다.
덤으로 문자열 대신 enum을 쓰니 1단계의 오타 문제도 같이 풀렸습니다. `Theme.DAKR`이라고 쓰면 그런 상수가 없으니 컴파일이 안 됩니다. 그리고 Java 17의 switch 식은 default가 없으면 모든 상수를 다뤄야 해서, `HIGH_CONTRAST`를 추가하고 case를 빠뜨리면 컴파일 에러로 알려 줍니다.
단, 테마 이름을 설정 파일이나 사용자 입력에서 문자열로 받아 `Theme.valueOf("DAKR")`로 바꾼다면 그 오타는 여전히 실행 중에 터집니다. 컴파일 단계에서 잡히는 건 코드에 직접 `Theme.XXX`라고 쓸 때뿐입니다.
참고로 이 `Theme` enum은 패턴 자체가 아니라, 어떤 공장을 쓸지 고르는 입구 역할을 하는 보조 도구입니다. 메서드 이름이 `factory()`라 팩토리 메서드처럼 보이지만, 자식이 상속으로 결정하는 구조가 아니라 switch로 고르는 방식이라 흔히 "심플 팩토리"라고 부르는 쪽에 가깝습니다.
두 패턴 비교
구분 Factory Method Abstract Factory
| 만드는 것 | 객체 하나 (배경) | 짝이 맞는 객체 묶음 (배경 + 글자) |
| 방법 | 상속: 자식이 메서드를 재정의 | 조합: 공장 객체를 넘겨받음 |
| 이 예제 | DarkScreen.createBackground() | DarkThemeFactory |
| 언제 | 흐름은 같고 부품 하나만 다를 때 | 여러 부품이 함께 바뀌고, 섞이면 안 될 때 |
한 줄로 줄이면, 팩토리 메서드는 객체 하나를 상속으로, 추상 팩토리는 짝이 맞는 객체 묶음을 조합으로 만듭니다.
두 패턴이 따로 노는 것도 아닙니다. 추상 팩토리 안의 `createBackground()`, `createText()` 하나하나도 결국 "무엇을 만들지 구현체가 정하는 메서드"입니다. GoF 책에서도 추상 팩토리는 보통 팩토리 메서드로 구현된다고 설명합니다. 재미있는 건, GoF 책이 추상 팩토리를 소개할 때 든 예시가 바로 UI 룩앤필, 그러니까 테마였다는 점입니다.
헷갈리기 쉬운 것: 정적 팩토리 메서드
`List.of(...)`, `LocalDate.of(...)` 같은 걸 "정적 팩토리 메서드"라고 부릅니다. 이름은 비슷하지만 오늘 본 패턴과는 다릅니다. 정적 팩토리 메서드는 생성자 대신 이름 있는 static 메서드로 객체를 만드는 기법(Effective Java 아이템 1)이고, 팩토리 메서드 패턴은 무엇을 만들지 하위 클래스가 결정하는 구조입니다. 상속이 있느냐 없느냐로 구분하면 쉽습니다.
언제 쓰지 말아야 할까?
테마가 하나 늘 때마다 공장과 부품 클래스가 함께 늘어납니다. 더 큰 약점은 묶음에 새 부품, 예를 들어 버튼을 추가할 때입니다. 그러면 모든 공장을 고쳐야 합니다. 새 테마를 추가하기는 쉽지만 새 부품 종류를 추가하기는 어렵다는 게 추상 팩토리의 대표적인 약점입니다.
그리고 실제 앱에서 색 몇 개만 바꾸는 정도라면, 클래스를 만들지 않고 CSS 변수나 디자인 토큰 같은 설정값으로 처리하는 게 보통입니다. 이 예제는 패턴을 보여 주려고 일부러 클래스로 나눈 것이고, 이 패턴이 진짜 값을 하는 건 부품이 색깔이 아니라 동작이 다른 객체일 때입니다.
정리하면서 가장 크게 느낀 건, 패턴이 "이렇게 짜야 한다"는 정답이 아니라는 점이었습니다. 팩토리 메서드를 고른 건 "배경만 바뀔 것"이라는 예측이었고, 그 예측이 틀리자(글자도 바뀌어야 했다) 추상 팩토리가 필요해졌습니다.
> 패턴은 정답이 아니라, 변경이 어디서 일어날지에 대한 예측이다.
'Design Pattern' 카테고리의 다른 글
| Singleton (0) | 2026.10.11 |
|---|---|
| Builder + Prototype (0) | 2026.10.11 |
| 팩토리 매소드, 추상 팩토리 (0) | 2026.10.06 |
| 데코레이터 패턴, 날씨 API 예제로 이해하기 (0) | 2026.10.05 |
| 옵저버 패턴으로 회원 탈퇴 후 남는 데이터 정리하기 (0) | 2026.09.30 |