자바에서 자주 사용되는 디자인 패턴 중에서, 팩토리 메서드(Factory Method), 프로토타입(Prototype), 싱글턴(Singleton) 패턴은 객체 생성과 관련된 패턴으로, 프로그램의 유연성을 높이고 유지보수성을 향상시키는 데 중요한 역할을 합니다. 각각의 패턴을 자세히 설명하겠습니다.
팩토리 메서드 패턴은 객체 생성 로직을 서브클래스로 분리하여, 클라이언트 코드가 객체 생성 방식을 알지 못해도 객체를 사용할 수 있도록 하는 패턴입니다.
Creator 클래스: 객체를 생성하는 팩토리 메서드를 정의하는 클래스입니다. 이 클래스는 createProduct()라는 팩토리 메서드를 가질 수 있으며, 구체적인 생성 로직은 서브클래스에서 구현됩니다.
Concrete Creator 클래스: Creator 클래스의 서브클래스로, 구체적인 객체 생성 로직을 구현합니다.
Product 인터페이스: 팩토리 메서드가 생성하는 객체의 공통 인터페이스입니다.
Concrete Product 클래스: Product 인터페이스의 구체적인 구현 클래스입니다.
예제 코드
// Product 인터페이스
interface Product {
void use();
}
// Concrete Product 구현 클래스
class ConcreteProductA implements Product {
@Override
public void use() {
System.out.println("Using Product A");
}
}
// Creator 클래스
abstract class Creator {
public abstract Product createProduct();
public void useProduct() {
Product product = createProduct();
product.use();
}
}
// Concrete Creator 클래스
class ConcreteCreatorA extends Creator {
@Override
public Product createProduct() {
return new ConcreteProductA();
}
}
// 사용
public class FactoryMethodExample {
public static void main(String[] args) {
Creator creator = new ConcreteCreatorA();
creator.useProduct(); // Output: Using Product A
}
}
프로토타입 패턴은 새로운 객체를 직접 생성하는 대신, 기존 객체를 복제하여 새로운 객체를 생성하는 패턴입니다. 복제된 객체는 원본 객체의 모든 속성을 동일하게 가집니다.
목적
비용이 많이 드는 객체 생성 과정을 피하고, 대신 기존 객체를 복제하여 새로운 객체를 빠르게 생성합니다.
객체 생성에 필요한 복잡한 초기화 작업을 피할 수 있습니다.
구조
Prototype 인터페이스: 객체가 복제할 수 있도록 clone() 메서드를 정의합니다.
Concrete Prototype 클래스: Prototype 인터페이스를 구현하고, clone() 메서드를 통해 객체를 복제합니다.
Client: Prototype 객체를 복제하여 새 객체를 생성하는 역할을 합니다.
예제 코드
// Prototype 인터페이스
interface Prototype extends Cloneable {
Prototype clone();
}
// Concrete Prototype 클래스
class ConcretePrototype implements Prototype {
private String name;
public ConcretePrototype(String name) {
this.name = name;
}
@Override
public Prototype clone() {
try {
return (Prototype) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
public void show() {
System.out.println("Prototype: " + name);
}
}
// 사용
public class PrototypePatternExample {
public static void main(String[] args) {
ConcretePrototype prototype1 = new ConcretePrototype("Prototype 1");
prototype1.show();
ConcretePrototype prototype2 = (ConcretePrototype) prototype1.clone();
prototype2.show();
}
}
싱글턴 패턴은 클래스의 인스턴스가 오직 하나만 생성되도록 보장하고, 이 인스턴스에 전역적으로 접근할 수 있도록 하는 패턴입니다. 주로 전역 상태를 관리하거나, 공유 자원을 관리할 때 사용됩니다.
Private 생성자: 외부에서 인스턴스를 생성하지 못하도록 생성자를 private으로 선언합니다.
Static 변수: 클래스 내에 하나의 인스턴스를 정적(static)으로 선언하여, 해당 인스턴스를 참조할 수 있도록 합니다.
Static 메서드: 인스턴스를 반환하는 메서드를 통해 전역적으로 접근할 수 있게 합니다.
예제 코드
// Singleton 클래스
class Singleton {
// 유일한 인스턴스를 저장할 정적 변수
private static Singleton instance;
// private 생성자로 외부에서 인스턴스 생성 불가
private Singleton() {}
// 유일한 인스턴스를 반환하는 정적 메서드
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
public void showMessage() {
System.out.println("This is the Singleton instance!");
}
}
// 사용
public class SingletonExample {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
singleton.showMessage(); // Output: This is the Singleton instance!
}
}
Q1: 스레드 안전한 싱글턴 패턴을 구현하기 위한 방법에는 어떤 것들이 있나요?
Q2: 팩토리 메서드 패턴과 추상 팩토리 패턴(Abstract Factory)의 차이점은 무엇인가요?
Q3: 프로토타입 패턴에서 깊은 복제와 얕은 복제의 차이점은 무엇인가요, 그리고 각각의 장단점은 무엇인가요?
답:
스레드 안전한 싱글턴 패턴을 구현하는 방법에는 여러 가지가 있습니다. 그중 몇 가지 주요 방법을 소개합니다.
1) 이른 초기화(Eager Initialization)
클래스가 로드될 때 싱글턴 인스턴스를 미리 생성하는 방식입니다. 이 방식은 스레드 안전하며, 인스턴스가 항상 존재하지만, 사용하지 않을 경우에도 메모리를 차지할 수 있습니다.
class Singleton {
// 클래스가 로드될 때 미리 인스턴스 생성
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
2) 지연 초기화(Lazy Initialization) + 동기화(synchronized)
getInstance() 메서드에 synchronized 키워드를 추가하여, 멀티스레드 환경에서 동시 접근을 방지합니다. 하지만 성능 저하를 일으킬 수 있습니다.
class Singleton {
private static Singleton instance;
private Singleton() {}
// 동기화하여 스레드 안전성 확보
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
3) 더블 체크 락(Double-Checked Locking)
성능 문제를 해결하기 위해, instance가 이미 생성된 경우에는 동기화 블록을 건너뛰는 방식입니다.
class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
4) 정적 내부 클래스(Static Inner Class) 방식
정적 내부 클래스는 클래스가 로드될 때 즉시 초기화되지 않으며, 메서드가 호출될 때 클래스 로딩이 이루어집니다. 이 방식은 스레드 안전하면서도, 성능 문제를 해결할 수 있습니다.
class Singleton {
private Singleton() {}
private static class SingletonHelper {
private static final Singleton instance = new Singleton();
}
public static Singleton getInstance() {
return SingletonHelper.instance;
}
}
팩토리 메서드 패턴
팩토리 메서드 패턴은 단일 제품(Product)의 생성 로직을 서브클래스에 위임합니다. 즉, 하나의 객체를 생성하는 책임만을 가진 패턴입니다. 구체적으로 어떤 클래스의 인스턴스를 생성할지는 서브클래스에서 결정합니다.
사용 목적: 구체적인 클래스에 의존하지 않고 객체를 생성할 수 있도록 하기 위해 사용됩니다.
구조: 하나의 제품을 생성하는 팩토리 메서드를 제공합니다.
interface Product {
void use();
}
class ConcreteProductA implements Product {
public void use() {
System.out.println("Using Product A");
}
}
abstract class Creator {
public abstract Product createProduct();
}
추상 팩토리 패턴
추상 팩토리 패턴은 여러 제품군(Product Family)을 생성하는 패턴입니다. 즉, 관련된 제품 객체들을 생성할 수 있는 팩토리들을 묶은 하나의 팩토리를 정의합니다. 이를 통해 다양한 제품군을 생성할 수 있습니다.
사용 목적: 서로 관련된 객체들의 집합(예: GUI 컴포넌트 패밀리)을 생성하는 데 사용됩니다.
구조: 다양한 제품군을 생성하는 여러 팩토리 메서드를 제공합니다.
interface GUIFactory {
Button createButton();
Checkbox createCheckbox();
}
class MacFactory implements GUIFactory {
public Button createButton() {
return new MacButton();
}
public Checkbox createCheckbox() {
return new MacCheckbox();
}
}
차이점 요약:
팩토리 메서드는 단일 제품 객체를 생성하는 데 집중하고, 추상 팩토리는 관련 제품군을 묶어서 생성하는 데 집중합니다.
팩토리 메서드는 하나의 제품을 생성하는 팩토리 메서드를 제공하는 반면, 추상 팩토리는 여러 제품을 생성하는 여러 팩토리 메서드를 제공합니다.
Q3: 프로토타입 패턴에서 깊은 복제와 얕은 복제의 차이점은 무엇인가요, 그리고 각각의 장단점은 무엇인가요?
얕은 복제(Shallow Copy)
얕은 복제는 객체의 필드 값만 복사하는 복제 방식입니다. 복제된 객체는 원본 객체와 참조형 필드를 공유하게 됩니다.
class Prototype implements Cloneable {
int number;
List<String> list = new ArrayList<>();
public Prototype clone() throws CloneNotSupportedException {
return (Prototype) super.clone(); // 얕은 복제
}
}
깊은 복제(Deep Copy)
깊은 복제는 객체의 모든 필드와 참조 객체들까지 새롭게 복사하는 복제 방식입니다. 복제된 객체는 원본 객체와 완전히 독립적입니다.
class Prototype implements Cloneable {
int number;
List<String> list = new ArrayList<>();
public Prototype deepClone() {
Prototype copy = new Prototype();
copy.number = this.number;
copy.list = new ArrayList<>(this.list); // 참조 객체도 복제
return copy;
}
}
순환 참조(Circular Reference)란 객체 A가 객체 B를 참조하고, 객체 B가 다시 객체 A를 참조하는 경우와 같이, 객체 간의 참조 관계가 순환적으로 연결된 상황을 말합니다. 이런 구조에서 깊은 복제를 수행하면, 참조가 무한히 반복되어 복제가 끝나지 않거나 메모리 문제가 발생할 수 있습니다.
이를 해결하기 위한 몇 가지 방법은 다음과 같습니다:
참조 맵(Reference Map) 사용 복제할 때 이미 복제한 객체를 추적하는 해시 맵(HashMap) 등을 사용하여, 복제된 객체가 있다면 새로운 복제를 하지 않고 기존 복제본을 사용하도록 할 수 있습니다. 이를 통해 동일한 객체가 여러 번 복제되는 것을 방지할 수 있습니다.
class Prototype {
Prototype reference;
public Prototype deepClone(Map<Prototype, Prototype> clonedObjects) {
if (clonedObjects.containsKey(this)) {
return clonedObjects.get(this);
}
Prototype clone = new Prototype();
clonedObjects.put(this, clone);
// 순환 참조 처리
if (this.reference != null) {
clone.reference = this.reference.deepClone(clonedObjects);
}
return clone;
}
}
연결 끊기 순환 참조가 필요한 상황이 아니라면, 순환 참조를 제거하는 것도 방법입니다. 복제 과정에서 필요한 참조 관계만 유지하고, 순환 참조 부분을 적절히 끊거나 새로운 참조 방식으로 대체할 수 있습니다.
지연 복제(Lazy Copy) 깊은 복제를 지연(Lazy) 처리하여, 참조 객체가 처음 접근될 때 복제하는 방식입니다. 이렇게 하면 복제 과정에서 순환 참조 문제를 피할 수 있고, 복제에 필요한 객체만 복제할 수 있어 효율적입니다.
팩토리 메서드 패턴을 사용해 객체를 동적으로 생성할 때, 확장성을 보장하는 방법은 다음과 같습니다:
인터페이스/추상 클래스 사용 팩토리 메서드가 반환하는 객체들은 공통 인터페이스 또는 추상 클래스를 구현해야 합니다. 이 방식으로 클라이언트는 구체적인 구현 클래스에 의존하지 않고, 인터페이스를 통해 객체를 처리할 수 있습니다. 새로운 타입의 객체를 추가할 때는 해당 인터페이스를 구현하는 새로운 클래스를 정의하기만 하면 됩니다.
interface Product {
void use();
}
class ConcreteProductA implements Product {
public void use() {
System.out.println("Using Product A");
}
}
다형성 활용 팩토리 메서드에서 다양한 서브클래스를 반환할 수 있도록 설계하면, 객체의 구체적인 타입이 변경되더라도 클라이언트 코드를 수정할 필요가 없습니다. 새로운 제품을 추가할 때는 기존 팩토리 클래스에 새로운 서브클래스를 추가하기만 하면 됩니다.
abstract class Creator {
public abstract Product createProduct();
}
class ConcreteCreatorA extends Creator {
public Product createProduct() {
return new ConcreteProductA();
}
}
등록 기반 팩토리(Registry-based Factory) 동적으로 객체 타입을 관리하려면 등록 기반 팩토리를 사용할 수 있습니다. 객체 타입을 키(key)-값(value) 형태로 등록하고, 필요할 때 해당 키로 객체를 생성하는 방식입니다. 이렇게 하면 새로운 타입을 추가할 때 팩토리 메서드를 수정하지 않고도 동적으로 타입을 확장할 수 있습니다.
class ProductFactory {
private Map<String, Supplier<Product>> registeredProducts = new HashMap<>();
public void registerProduct(String type, Supplier<Product> supplier) {
registeredProducts.put(type, supplier);
}
public Product createProduct(String type) {
Supplier<Product> product = registeredProducts.get(type);
if (product != null) {
return product.get();
}
throw new IllegalArgumentException("Unknown product type");
}
}
// 등록과 사용
ProductFactory factory = new ProductFactory();
factory.registerProduct("A", ConcreteProductA::new);
Product productA = factory.createProduct("A");
플러그인 구조(Plugin Architecture) 시스템을 플러그인 형태로 설계하면, 외부 모듈에서 새로운 제품군을 동적으로 추가할 수 있습니다. 이 방식은 프로젝트가 커지고 확장되는 경우 유용합니다. 새로운 객체 생성 로직을 외부에서 추가하거나 수정할 수 있어 확장성을 높일 수 있습니다.
싱글턴 패턴이 멀티스레드 환경에서 위험한 이유:
싱글턴 패턴의 문제는 멀티스레드 환경에서 동시에 여러 스레드가 인스턴스를 생성하려고 시도할 수 있다는 점입니다. 두 개 이상의 스레드가 동시에 getInstance() 메서드를 호출하면, 아직 인스턴스가 생성되지 않았을 때 각 스레드가 동시에 인스턴스를 생성하려고 할 수 있습니다. 이로 인해 여러 개의 인스턴스가 생성되어 싱글턴 패턴의 원칙이 깨질 수 있습니다.
멀티스레드 환경에서 안전하게 싱글턴을 구현하는 방법:
이른 초기화(Eager Initialization)
싱글턴 인스턴스를 프로그램 시작 시 미리 생성하는 방식입니다. 자바의 클래스 로더 메커니즘 덕분에 스레드 안전을 보장할 수 있습니다. 간단하고 효율적이지만, 인스턴스가 필요하지 않은 경우에도 메모리를 차지하는 단점이 있습니다.
class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
동기화(synchronized) 메서드 getInstance() 메서드에 synchronized 키워드를 붙여 여러 스레드가 동시에 이 메서드에 접근하지 못하게 합니다. 하지만 성능 저하가 발생할 수 있습니다.
class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
더블 체크 락(Double-Checked Locking) 성능을 최적화하면서 스레드 안전성을 유지하기 위한 방법입니다. instance가 처음에 null인지 확인한 후에만 동기화 블록에 진입하도록 합니다. 이를 통해 동기화 비용을 최소화할 수 있습니다.
class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
정적 내부 클래스(Static Inner Class) 가장 효율적이고 간결한 스레드 안전한 싱글턴 구현 방법입니다. 정적 내부 클래스는 외부 클래스가 로드될 때 즉시 초기화되지 않으며, getInstance()가 호출될 때 클래스가 로딩되면서 인스턴스를 생성합니다. 자바의 클래스 로딩 메커니즘 덕분에 동기화 없이도 스레드 안전성을 보장합니다.
class Singleton {
private Singleton() {}
private static class SingletonHelper {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHelper.INSTANCE;
}
}
결론:
가장 효율적인 방법은 정적 내부 클래스(Static Inner Class) 방식입니다. 이 방법은 자바의 클래스 로딩 메커니즘을 활용하여, 동기화 비용을 피하면서도 스레드 안전성을 보장할 수 있습니다. 메모리 낭비도 없으며, 필요할 때만 인스턴스를 생성하는 지연 초기화(lazy initialization)도 지원됩니다.