JAVA · 심층 가이드
Java 객체지향 완전 정리
단일 상속과 다중 인터페이스라는 Java의 제약 위에서 sealed 계층, 불변 객체, 빌더와 전략 같은 실전 설계를 어떻게 세우는지 20개 주제로 다룹니다.
Java의 객체지향은 클래스 상속은 하나만, 인터페이스는 여럿이라는 규칙 위에 서 있습니다. 오랫동안 인터페이스는 순수한 계약이었지만 default 메서드가 들어오면서 부분 구현을 갖게 됐고, sealed가 추가되면서 이번에는 반대 방향으로 구현 가능한 타입을 제한할 수 있게 됐습니다. 계층을 열어 두는 대신 닫아 두는 이 선택이 중요한 이유는, 닫힌 계층 위에서는 switch가 모든 경우를 다뤘는지 컴파일러가 검사해 주기 때문입니다.
추상 클래스 vs 인터페이스로 둘의 역할 구분을 잡고, default 메서드와 충돌 해결에서 여러 인터페이스가 같은 시그니처를 제공할 때의 규칙을 확인하는 순서가 좋습니다. 그다음 sealed 클래스 심화와 record 심화 — 커스텀과 제약을 붙이면 데이터 중심 모델링이 완성됩니다. 나머지 절반은 패턴입니다. 불변 객체 설계를 먼저 읽고 빌더 패턴으로 넘어가야 빌더가 왜 불변 객체와 짝을 이루는지가 보입니다.
설계 실수 중 디버깅이 가장 고약한 건 생성자에서 오버라이드 가능한 메서드를 호출하는 경우입니다. 하위 클래스 생성자는 상위 생성자를 먼저 실행하므로, 상위 생성자가 부른 오버라이드 메서드는 하위 필드가 아직 초기화되지 않은 상태에서 돌아갑니다. 결과는 조용한 null이나 0입니다. 비슷하게 record도 만능이 아닙니다. 자동 생성되는 equals는 각 컴포넌트를 Object.equals로 비교하므로, 배열을 컴포넌트로 두면 내용이 같아도 다른 값으로 판정됩니다.
01클래스 & 상속 & @Override
Java OOP의 핵심. 캡슐화, 상속, 다형성의 실전 적용.
Java code
// 부모 클래스
public abstract class Animal {
private final String name; // 캡슐화 (private)
protected int age;
public Animal(String name, int age) {
this.name = name;
this.age = age;
}
// abstract — 하위 클래스에서 반드시 구현
public abstract String sound();
// 공통 구현
public void introduce() {
System.out.printf("%s: %s (나이 %d)%n",
name, sound(), age);
}
public String getName() { return name; }
}
// 자식 클래스
public class Dog extends Animal {
private final String breed;
public Dog(String name, int age, String breed) {
super(name, age); // 부모 생성자 호출
this.breed = breed;
}
@Override
public String sound() { return "멍멍"; }
@Override
public String toString() {
return String.format("Dog{name=%s, breed=%s}",
getName(), breed);
}
}
// final class — 상속 불가
public final class Cat extends Animal {
public Cat(String name, int age) { super(name, age); }
@Override public String sound() { return "야옹"; }
}
class Main {
public static void main(String[] args) {
Animal[] animals = {
new Dog("Rex", 3, "진돗개"),
new Cat("Kitty", 2),
};
for (Animal a : animals) {
a.introduce(); // 다형성
// instanceof + Pattern Matching (Java 16+)
if (a instanceof Dog d) {
System.out.println("견종: " + d.breed);
}
}
}
}@Override 어노테이션은 선택 사항이지만 항상 붙이세요. 부모 메서드 이름을 잘못 입력해도 어노테이션 없이는 컴파일러가 오류를 잡지 못합니다.
Java 상속에서 super() 호출은 생성자 첫 줄이어야 합니다. 그렇지 않으면 컴파일 에러입니다.
02인터페이스 — default & static & private
Java 8~9에 추가된 인터페이스의 기본 구현, 정적 메서드, private 메서드.
Java code
import java.util.Objects;
// 인터페이스 — default, static, private 메서드
public interface Validator<T> {
boolean validate(T value); // 추상 메서드
// default — 공통 구현 제공 (Java 8+)
default boolean isValid(T value) {
return validate(value);
}
// private — 내부 헬퍼 (Java 9+)
private String formatError(String field) {
return "[INVALID] " + field;
}
// static — 팩토리/유틸리티 (Java 8+)
static <T> Validator<T> nonNull() {
return Objects::nonNull;
}
// 다중 Validator 조합
default Validator<T> and(Validator<T> other) {
return value -> this.validate(value) && other.validate(value);
}
default Validator<T> or(Validator<T> other) {
return value -> this.validate(value) || other.validate(value);
}
default Validator<T> negate() {
return value -> !this.validate(value);
}
}
class EmailValidator implements Validator<String> {
@Override
public boolean validate(String email) {
return email != null
&& email.contains("@")
&& email.contains(".");
}
}
class Main {
public static void main(String[] args) {
Validator<String> emailValidator = new EmailValidator();
Validator<String> notEmpty = s -> s != null && !s.isEmpty();
Validator<String> notTooLong = s -> s.length() <= 100;
// 조합
Validator<String> fullValidator =
Validator.<String>nonNull()
.and(notEmpty)
.and(emailValidator)
.and(notTooLong);
System.out.println(fullValidator.isValid("dev@java.io")); // true
System.out.println(fullValidator.isValid("invalid")); // false
System.out.println(fullValidator.isValid(null)); // false
}
}두 인터페이스에 같은 이름의 default 메서드가 있으면 구현 클래스에서 반드시 오버라이드해야 합니다. InterfaceName.super.method()로 특정 인터페이스의 default를 호출할 수 있습니다.
인터페이스의 private 메서드는 Java 9+에서만 사용 가능합니다. Java 8 이하라면 코드 중복을 감수하거나 추상 클래스를 사용해야 합니다.
03Enum — 완전한 활용
Java의 Enum은 단순 상수 이상입니다. 필드, 메서드, 추상 메서드, 인터페이스 구현이 가능합니다.
Java code
public enum Planet {
MERCURY(3.303e+23, 2.4397e6),
VENUS (4.869e+24, 6.0518e6),
EARTH (5.976e+24, 6.37814e6),
MARS (6.421e+23, 3.3972e6);
private final double mass; // 질량 (kg)
private final double radius; // 반지름 (m)
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
}
static final double G = 6.67300E-11;
double surfaceGravity() {
return G * mass / (radius * radius);
}
double surfaceWeight(double otherMass) {
return otherMass * surfaceGravity();
}
}
// 추상 메서드를 가진 Enum
enum Operation {
PLUS("+") { @Override double apply(double x, double y) { return x + y; } },
MINUS("-") { @Override double apply(double x, double y) { return x - y; } },
TIMES("*") { @Override double apply(double x, double y) { return x * y; } },
DIVIDE("/") { @Override double apply(double x, double y) { return x / y; } };
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
abstract double apply(double x, double y);
@Override public String toString() { return symbol; }
}
class Main {
public static void main(String[] args) {
double earthWeight = 75.0;
double mass = earthWeight / Planet.EARTH.surfaceGravity();
for (Planet p : Planet.values()) {
System.out.printf("체중 on %s: %.2f%n",
p, p.surfaceWeight(mass));
}
double x = 10, y = 3;
for (Operation op : Operation.values()) {
System.out.printf("%.1f %s %.1f = %.1f%n",
x, op, y, op.apply(x, y));
}
// Enum 메서드
Planet earth = Planet.valueOf("EARTH");
System.out.println(earth.ordinal()); // 2 (0-based)
System.out.println(earth.name()); // EARTH
}
}EnumSet과 EnumMap은 Enum 전용 컬렉션으로 일반 HashSet/HashMap보다 훨씬 효율적입니다. Enum 기반 컬렉션에는 항상 이를 사용하세요.
==으로 Enum 비교는 완전히 안전합니다. Enum은 싱글턴이므로 ==와 .equals()가 동일하게 동작합니다.
04SequencedCollection (Java 21)
List/Deque 통합 인터페이스 SequencedCollection
Java code
<span class="cm">// SequencedCollection (Java 21) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("SequencedCollection (Java 21)") }JAVA 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
05Sealed Interfaces & Permits
Java 17+의 sealed 인터페이스는 구현 가능한 클래스를 permits로 제한합니다. 도메인 모델링에서 타입 안전한 계층 구조를 만들고, switch 패턴 매칭과 결합하면 강력한 타입 시스템을 구축합니다.
Java code
// Sealed Interface — 허용된 구현만 가능
public sealed interface Payment
permits CreditCard, BankTransfer, DigitalWallet {
double amount();
String currency();
}
// 각 구현은 record, class, 또는 sealed로 선언 가능
record CreditCard(double amount, String currency,
String cardNumber, int installments) implements Payment {}
record BankTransfer(double amount, String currency,
String bankCode, String accountNumber) implements Payment {}
// non-sealed로 선언하면 추가 확장 허용
non-sealed class DigitalWallet implements Payment {
private final double amount;
private final String currency;
private final String provider;
public DigitalWallet(double amount, String currency, String provider) {
this.amount = amount;
this.currency = currency;
this.provider = provider;
}
@Override public double amount() { return amount; }
@Override public String currency() { return currency; }
public String provider() { return provider; }
}
// 패턴 매칭과 결합
static String processPayment(Payment payment) {
return switch (payment) {
case CreditCard cc -> "카드결제: " + cc.cardNumber() +
(cc.installments() > 1 ? " (" + cc.installments() + "개월)" : " (일시불)");
case BankTransfer bt -> "계좌이체: " + bt.bankCode();
case DigitalWallet dw -> "디지털월렛: " + dw.provider();
};
}sealed 타입의 구현체는 같은 패키지(또는 모듈)에 있어야 합니다. non-sealed로 선언하면 해당 서브타입은 자유롭게 확장 가능합니다.
permits 절을 생략하면 같은 파일에 선언된 클래스만 구현 가능합니다. 다른 파일에서 구현하려면 반드시 permits를 명시하세요.
06추상 클래스 vs 인터페이스
추상 클래스와 인터페이스의 차이점과 각각의 적절한 사용 시나리오를 비교합니다.
Java code
// 추상 클래스 — 상태(필드) + 구현 공유
abstract class Animal {
protected String name; // 상태 보유 가능
Animal(String name) { this.name = name; }
abstract void speak(); // 추상 메서드
void sleep() { // 구현 메서드
System.out.println(name + " 잠을 잡니다");
}
}
// 인터페이스 — 행동 계약
interface Flyable {
void fly(); // 추상 메서드
default void land() { // 기본 구현 (Java 8+)
System.out.println("착륙합니다");
}
static Flyable create(String type) { // 정적 메서드
return () -> System.out.println(type + " 날아갑니다");
}
}
// 다중 인터페이스 구현 가능
class Bird extends Animal implements Flyable {
Bird(String name) { super(name); }
@Override void speak() { System.out.println("짹짹"); }
@Override public void fly() { System.out.println(name + " 날아갑니다"); }
}
class Main {
public static void main(String[] args) {
Bird b = new Bird("참새");
b.speak(); b.fly(); b.land(); b.sleep();
}
}상태와 구현 공유가 필요하면 추상 클래스, 행동 계약만 필요하면 인터페이스를 선택하세요. Java 8+에서는 인터페이스도 default 메서드를 가집니다.
클래스는 하나만 상속할 수 있지만 인터페이스는 여러 개 구현할 수 있습니다. 다중 상속이 필요하면 인터페이스를 활용하세요.
07default 메서드와 충돌 해결
인터페이스의 default 메서드와 다중 구현 시 충돌 해결 방법입니다.
Java code
interface Printable {
default void log(String msg) {
System.out.println("[Print] " + msg);
}
}
interface Loggable {
default void log(String msg) {
System.out.println("[Log] " + msg);
}
}
// 두 인터페이스의 default 메서드 충돌 → 반드시 재정의
class Service implements Printable, Loggable {
@Override
public void log(String msg) {
// 특정 인터페이스의 default 호출
Printable.super.log(msg);
Loggable.super.log(msg);
}
}
// 인터페이스 진화에 활용
interface Collection2 {
// 기존 구현체를 깨지 않고 메서드 추가
default void forEach2(java.util.function.Consumer<Object> action) {
// 기본 구현 제공
}
}
class Main {
public static void main(String[] args) {
Service svc = new Service();
svc.log("테스트"); // 두 인터페이스 모두 호출
}
}default 메서드는 인터페이스 진화(evolution)를 위한 것입니다. 기존 구현체를 수정하지 않고 새 메서드를 추가할 수 있습니다.
두 인터페이스에 같은 시그니처의 default 메서드가 있으면 반드시 구현 클래스에서 재정의해야 합니다. 안 하면 컴파일 에러입니다.
08private 인터페이스 메서드
Java 9+의 private 인터페이스 메서드로 default 메서드의 중복을 제거합니다.
Java code
interface Logger {
default void logInfo(String msg) {
log("INFO", msg);
}
default void logError(String msg) {
log("ERROR", msg);
}
default void logWarn(String msg) {
log("WARN", msg);
}
// private 메서드 — default 메서드에서만 호출
private void log(String level, String msg) {
String time = java.time.LocalTime.now().toString();
System.out.printf("[%s] %s: %s%n", time, level, msg);
}
// private static 메서드도 가능
private static String formatMessage(String msg) {
return msg.trim().toUpperCase();
}
}
class AppService implements Logger {
void process() {
logInfo("처리 시작");
logWarn("경고 발생");
logError("에러 발생");
// log() 직접 호출 불가 — private
}
}private 인터페이스 메서드로 default 메서드 간의 공통 로직을 추출하면 DRY 원칙을 지킬 수 있습니다.
private 인터페이스 메서드는 구현 클래스에서 접근할 수 없습니다. 인터페이스 내부의 default 메서드에서만 호출 가능합니다.
09sealed 클래스 심화
Java 17의 sealed 클래스로 상속 계층을 제한하고 패턴 매칭을 활용합니다.
Java code
// sealed: 허용된 하위 클래스만 상속 가능
public sealed interface Shape
permits Circle, Rectangle, Triangle {
double area();
}
// final: 더 이상 상속 불가
record Circle(double radius) implements Shape {
public double area() { return Math.PI * radius * radius; }
}
// final (record는 암묵적 final)
record Rectangle(double w, double h) implements Shape {
public double area() { return w * h; }
}
// non-sealed: 자유롭게 상속 가능
non-sealed class Triangle implements Shape {
double base, height;
Triangle(double base, double height) {
this.base = base; this.height = height;
}
public double area() { return 0.5 * base * height; }
}
class Main {
// 패턴 매칭과 결합 — exhaustive switch
static String describe(Shape s) {
return switch (s) {
case Circle c -> "원(r=" + c.radius() + ")";
case Rectangle r -> "사각(w=" + r.w() + ")";
case Triangle t -> "삼각";
// default 불필요! sealed이므로 컴파일러가 완전성 검증
};
}
}sealed + switch 패턴 매칭을 함께 사용하면 default 없이 완전한 분기를 보장합니다. 새 하위 타입 추가 시 컴파일러가 누락된 케이스를 알려줍니다.
permits에 나열한 클래스는 반드시 final, sealed, 또는 non-sealed 중 하나여야 합니다.
10record 심화 — 커스텀과 제약
record의 커스텀 생성자, 메서드, 제약사항을 알아봅니다.
Java code
// 기본 record
record Point(int x, int y) {
// 컴팩트 생성자 — 유효성 검사
Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("음수 불가");
}
}
// 커스텀 생성자
Point(int xy) {
this(xy, xy); // 정규 생성자 호출 필수
}
// 추가 메서드
double distanceTo(Point other) {
int dx = this.x - other.x;
int dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
// static 메서드
static Point origin() { return new Point(0, 0); }
}
// 제네릭 record
record Pair<A, B>(A first, B second) {}
// 인터페이스 구현
record Range(int start, int end) implements Comparable<Range> {
Range { if (start > end) throw new IllegalArgumentException(); }
@Override
public int compareTo(Range o) {
return Integer.compare(this.start, o.start);
}
}컴팩트 생성자에서 필드 할당은 자동으로 수행됩니다. 유효성 검사만 작성하세요. this.x = x;를 직접 쓰면 에러입니다.
record의 필드는 final입니다. setter를 만들 수 없고, 상속도 불가능합니다(암묵적 final).
11열거형에 메서드 추가
열거형 상수별로 다른 동작을 정의하는 패턴입니다.
Java code
enum Operation {
ADD("+") {
@Override
public double apply(double a, double b) { return a + b; }
},
SUB("-") {
@Override
public double apply(double a, double b) { return a - b; }
},
MUL("*") {
@Override
public double apply(double a, double b) { return a * b; }
},
DIV("/") {
@Override
public double apply(double a, double b) {
if (b == 0) throw new ArithmeticException("0 나누기");
return a / b;
}
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
public abstract double apply(double a, double b);
@Override
public String toString() { return symbol; }
}
class Main {
public static void main(String[] args) {
for (Operation op : Operation.values()) {
System.out.printf("10 %s 3 = %.1f%n",
op, op.apply(10, 3));
}
}
}열거형에 abstract 메서드를 선언하면 모든 상수가 반드시 구현해야 합니다. 새 상수 추가 시 구현 누락을 방지합니다.
열거형 상수 본문에서 다른 상수를 참조하면 초기화 순서 문제가 발생할 수 있습니다. 정적 필드로 분리하세요.
12내부 클래스 4종류
멤버 클래스, 정적 중첩 클래스, 지역 클래스, 익명 클래스의 차이를 비교합니다.
Java code
public class OuterClass {
private int field = 10;
// 1. 멤버 내부 클래스 (non-static)
class Inner {
void show() {
System.out.println("외부 필드: " + field); // 외부 접근 가능
}
}
// 2. 정적 중첩 클래스 (static)
static class StaticNested {
void show() {
// field 접근 불가 (인스턴스 없음)
System.out.println("정적 중첩 클래스");
}
}
void method() {
// 3. 지역 클래스
class Local {
void show() {
System.out.println("지역 클래스, field=" + field);
}
}
new Local().show();
}
public static void main(String[] args) {
OuterClass outer = new OuterClass();
// 멤버 내부 클래스 생성
Inner inner = outer.new Inner();
inner.show();
// 정적 중첩 클래스 생성 (외부 인스턴스 불필요)
StaticNested nested = new StaticNested();
nested.show();
// 4. 익명 클래스
Runnable r = new Runnable() {
@Override public void run() {
System.out.println("익명 클래스");
}
};
r.run();
}
}대부분의 경우 정적 중첩 클래스가 적합합니다. 외부 인스턴스 참조를 유지하지 않아 메모리 누수 위험이 없습니다.
멤버 내부 클래스는 암묵적으로 외부 인스턴스 참조를 가집니다. 직렬화하면 외부 객체까지 직렬화되어 문제가 발생합니다.
13익명 클래스와 람다 변환
익명 클래스의 사용법과 람다로 변환할 수 있는 조건을 알아봅니다.
Java code
import java.util.*;
public class AnonymousClass {
interface Greeting {
String greet(String name);
}
abstract static class Formatter {
abstract String format(String text);
String wrap(String text) { return "[" + format(text) + "]"; }
}
public static void main(String[] args) {
// 익명 클래스 — SAM이면 람다로 변환 가능
Greeting g1 = new Greeting() {
@Override
public String greet(String name) {
return "안녕, " + name;
}
};
// 람다로 변환
Greeting g2 = name -> "안녕, " + name;
// 추상 클래스 — 람다 변환 불가 (상태/여러 메서드)
Formatter f = new Formatter() {
@Override
String format(String text) {
return text.toUpperCase();
}
};
System.out.println(f.wrap("hello")); // [HELLO]
// 익명 클래스에서 this는 익명 클래스 자신
// 람다에서 this는 바깥 클래스
Runnable anon = new Runnable() {
public void run() {
System.out.println(this.getClass().getName());
}
};
Runnable lambda = () -> {
System.out.println(this.getClass().getName());
};
}
}람다에서 this는 바깥 클래스를 가리키고, 익명 클래스에서 this는 익명 클래스 자신을 가리킵니다.
추상 클래스나 여러 메서드를 가진 인터페이스는 람다로 변환할 수 없습니다. 이 경우 익명 클래스를 사용해야 합니다.
14프록시 패턴 (동적 프록시)
Java의 Proxy API로 동적 프록시를 만들어 AOP 기능을 구현합니다.
Java code
import java.lang.reflect.*;
interface UserService {
String findUser(String id);
void saveUser(String name);
}
class UserServiceImpl implements UserService {
public String findUser(String id) { return "User-" + id; }
public void saveUser(String name) { System.out.println("저장: " + name); }
}
// InvocationHandler — 모든 메서드 호출을 가로챔
class LoggingHandler implements InvocationHandler {
private final Object target;
LoggingHandler(Object target) { this.target = target; }
@Override
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
System.out.printf("[LOG] %s 호출, 인자: %s%n",
method.getName(), java.util.Arrays.toString(args));
long start = System.nanoTime();
Object result = method.invoke(target, args);
long elapsed = System.nanoTime() - start;
System.out.printf("[LOG] %s 완료 (%dns)%n", method.getName(), elapsed);
return result;
}
}
class Main {
public static void main(String[] args) {
UserService real = new UserServiceImpl();
UserService proxy = (UserService) Proxy.newProxyInstance(
UserService.class.getClassLoader(),
new Class[]{UserService.class},
new LoggingHandler(real));
System.out.println(proxy.findUser("123"));
proxy.saveUser("홍길동");
}
}동적 프록시는 인터페이스 기반으로만 동작합니다. 클래스 프록시가 필요하면 CGLIB이나 ByteBuddy를 사용하세요.
InvocationHandler에서 method.invoke(proxy, args)를 호출하면 무한 재귀가 발생합니다. 반드시 target을 전달하세요.
15불변 객체 설계
불변 객체를 올바르게 설계하는 5가지 규칙을 배웁니다.
Java code
import java.util.*;
// 불변 클래스 설계 5규칙
public final class Money { // 1. final 클래스
private final int amount; // 2. final 필드
private final String currency;
private final List<String> tags; // 가변 객체 포함
// 3. 생성자에서 방어적 복사
public Money(int amount, String currency, List<String> tags) {
this.amount = amount;
this.currency = currency;
this.tags = List.copyOf(tags); // 방어적 복사
}
// 4. getter만 제공 (setter 없음)
public int amount() { return amount; }
public String currency() { return currency; }
// 5. 가변 객체는 방어적 복사로 반환
public List<String> tags() {
return Collections.unmodifiableList(tags);
}
// 값 변경 시 새 객체 반환
public Money add(int value) {
return new Money(this.amount + value, this.currency, this.tags);
}
// record로 더 간단하게
// record Money(int amount, String currency, List<String> tags) {}
}불변 객체는 스레드 안전하고, 캐싱이 가능하며, 부수 효과가 없습니다. 가능하면 record를 사용하세요.
가변 컬렉션을 필드에 직접 할당하면 외부에서 수정할 수 있습니다. List.copyOf()로 방어적 복사를 하세요.
16빌더 패턴
복잡한 객체 생성을 단계별로 수행하는 빌더 패턴을 구현합니다.
Java code
public class HttpRequest {
private final String method;
private final String url;
private final String body;
private final Map<String, String> headers;
private final int timeout;
private HttpRequest(Builder builder) {
this.method = builder.method;
this.url = builder.url;
this.body = builder.body;
this.headers = Map.copyOf(builder.headers);
this.timeout = builder.timeout;
}
// 정적 내부 빌더 클래스
static class Builder {
private final String method; // 필수
private final String url; // 필수
private String body = "";
private Map<String, String> headers = new java.util.HashMap<>();
private int timeout = 30;
Builder(String method, String url) {
this.method = method;
this.url = url;
}
Builder body(String body) { this.body = body; return this; }
Builder header(String k, String v) { headers.put(k, v); return this; }
Builder timeout(int sec) { this.timeout = sec; return this; }
HttpRequest build() {
return new HttpRequest(this);
}
}
public static void main(String[] args) {
HttpRequest req = new HttpRequest.Builder("POST", "/api/users")
.header("Content-Type", "application/json")
.body("{}")
.timeout(10)
.build();
}
}필수 파라미터는 빌더 생성자에, 선택 파라미터는 메서드 체인으로 설정하세요. Lombok @Builder로 자동 생성도 가능합니다.
빌더 인스턴스를 재사용하면 이전 설정이 남아있을 수 있습니다. build() 후에는 새 빌더를 만드세요.
17팩토리 메서드 패턴
객체 생성 로직을 팩토리 메서드로 캡슐화하여 유연성을 높입니다.
Java code
// 팩토리 메서드 — 인터페이스 기반
interface Notification {
void send(String message);
}
class EmailNotification implements Notification {
public void send(String msg) { System.out.println("이메일: " + msg); }
}
class SmsNotification implements Notification {
public void send(String msg) { System.out.println("SMS: " + msg); }
}
class PushNotification implements Notification {
public void send(String msg) { System.out.println("푸시: " + msg); }
}
// 팩토리 클래스
class NotificationFactory {
// 정적 팩토리 메서드
static Notification create(String type) {
return switch (type.toLowerCase()) {
case "email" -> new EmailNotification();
case "sms" -> new SmsNotification();
case "push" -> new PushNotification();
default -> throw new IllegalArgumentException(
"알 수 없는 타입: " + type);
};
}
// 간편 팩토리 메서드
static Notification email() { return new EmailNotification(); }
static Notification sms() { return new SmsNotification(); }
}
class Main {
public static void main(String[] args) {
Notification n = NotificationFactory.create("email");
n.send("안녕하세요!");
NotificationFactory.sms().send("인증코드: 1234");
}
}of(), from(), create() 등의 명명 규칙을 따르면 가독성이 좋습니다. List.of(), Optional.of()가 좋은 예입니다.
팩토리에 타입이 추가될 때마다 switch를 수정해야 합니다. Map<String, Supplier>로 등록 기반 팩토리를 만들면 OCP를 지킬 수 있습니다.
18전략 패턴
알고리즘을 캡슐화하고 런타임에 교체할 수 있는 전략 패턴입니다.
Java code
import java.util.*;
import java.util.function.UnaryOperator;
// 함수형 인터페이스를 전략으로 활용
public class StrategyPattern {
// 전략: 문자열 포맷터
record TextProcessor(UnaryOperator<String> strategy) {
String process(String text) {
return strategy.apply(text);
}
}
// 전략 정의
static final UnaryOperator<String> UPPER = String::toUpperCase;
static final UnaryOperator<String> LOWER = String::toLowerCase;
static final UnaryOperator<String> TITLE = text -> {
String[] words = text.split(" ");
StringBuilder sb = new StringBuilder();
for (String w : words) {
if (!w.isEmpty()) {
sb.append(Character.toUpperCase(w.charAt(0)))
.append(w.substring(1).toLowerCase())
.append(" ");
}
}
return sb.toString().trim();
};
public static void main(String[] args) {
String text = "hello world java";
// 런타임에 전략 교체
var processor = new TextProcessor(UPPER);
System.out.println(processor.process(text)); // HELLO WORLD JAVA
processor = new TextProcessor(TITLE);
System.out.println(processor.process(text)); // Hello World Java
// 람다로 즉석 전략
processor = new TextProcessor(s -> s.replace(" ", "_"));
System.out.println(processor.process(text)); // hello_world_java
}
}Java의 함수형 인터페이스를 전략으로 사용하면 별도의 전략 인터페이스 없이도 전략 패턴을 구현할 수 있습니다.
전략이 상태를 가지면 스레드 안전 문제가 발생할 수 있습니다. 가능하면 상태 없는(stateless) 전략을 사용하세요.
19옵저버 패턴
이벤트 발행-구독 모델을 구현하는 옵저버 패턴입니다.
Java code
import java.util.*;
import java.util.function.Consumer;
public class ObserverPattern {
// 이벤트 발행자
static class EventEmitter<T> {
private final Map<String, List<Consumer<T>>> listeners =
new HashMap<>();
void on(String event, Consumer<T> listener) {
listeners.computeIfAbsent(event, k -> new ArrayList<>())
.add(listener);
}
void off(String event, Consumer<T> listener) {
List<Consumer<T>> list = listeners.get(event);
if (list != null) list.remove(listener);
}
void emit(String event, T data) {
List<Consumer<T>> list = listeners.get(event);
if (list != null) {
list.forEach(l -> l.accept(data));
}
}
}
public static void main(String[] args) {
EventEmitter<String> emitter = new EventEmitter<>();
// 리스너 등록
Consumer<String> logger = msg -> System.out.println("[LOG] " + msg);
Consumer<String> alert = msg -> System.out.println("[ALERT] " + msg);
emitter.on("error", logger);
emitter.on("error", alert);
emitter.on("info", logger);
// 이벤트 발행
emitter.emit("error", "서버 다운!");
emitter.emit("info", "정상 동작");
}
}Consumer를 리스너로 사용하면 람다로 간결하게 등록할 수 있습니다. 타입 안전한 이벤트 시스템을 만들 수 있습니다.
리스너를 해제하지 않으면 메모리 누수가 발생합니다. WeakReference를 사용하거나 반드시 off()를 호출하세요.
20데코레이터 패턴
기존 객체에 동적으로 기능을 추가하는 데코레이터 패턴입니다.
Java code
import java.util.function.Function;
public class DecoratorPattern {
// 기본 인터페이스
interface TextFormatter {
String format(String text);
}
// 기본 구현
static class PlainFormatter implements TextFormatter {
public String format(String text) { return text; }
}
// 데코레이터 (추상)
static abstract class FormatterDecorator implements TextFormatter {
protected final TextFormatter wrapped;
FormatterDecorator(TextFormatter wrapped) { this.wrapped = wrapped; }
}
// 구체 데코레이터들
static class BracketDecorator extends FormatterDecorator {
BracketDecorator(TextFormatter w) { super(w); }
public String format(String t) { return "[" + wrapped.format(t) + "]"; }
}
static class UpperDecorator extends FormatterDecorator {
UpperDecorator(TextFormatter w) { super(w); }
public String format(String t) { return wrapped.format(t).toUpperCase(); }
}
// 함수형 데코레이터 (더 간결!)
static TextFormatter decorate(TextFormatter base,
Function<String, String>... decorators) {
TextFormatter result = base;
for (var d : decorators) {
TextFormatter prev = result;
result = text -> d.apply(prev.format(text));
}
return result;
}
public static void main(String[] args) {
TextFormatter fmt = new BracketDecorator(
new UpperDecorator(new PlainFormatter()));
System.out.println(fmt.format("hello")); // [HELLO]
}
}데코레이터는 InputStream/OutputStream 계열에서 널리 사용됩니다. BufferedReader(new FileReader(f))가 대표적 예입니다.
데코레이터가 너무 깊게 중첩되면 디버깅이 어렵습니다. 3단계를 넘으면 파이프라인이나 빌더 패턴으로 리팩토링하세요.
정리하며
- sealed로 계층을 닫으면 switch 분기 누락을 컴파일러가 대신 잡아 줍니다
- 생성자 안에서는 오버라이드 가능한 메서드를 호출하지 않습니다. 하위 필드가 아직 비어 있습니다
- record 컴포넌트에 배열을 두면 equals가 내용이 아닌 참조를 비교합니다
- 전략·데코레이터처럼 메서드가 하나뿐인 패턴은 람다나 메서드 참조로 대체할 수 있습니다
더 깊이 들어가고 싶다면 Java 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.