본문 바로가기
Design Pattern/생성 패턴(Creational Patterns)

팩토리 메서드(Factory Method) - 생성 패턴(Creational Patterns)

by 김 민 준 2024. 5. 13.

 

 

팩토리 메서드 패턴은 객체 생성을 위한 인터페이스를 정의한다. 하지만 실제 객체 생성은 하위 클래스가 결정하도록 위임하는 생성 패턴이다. 이 패턴은 객체를 생성하는 코드와 해당 객체를 사용하는 코드를 분리함으로써 결합도를 낮추고, 유연성 및 확장성을 향상시킨다.

  1. Product (제품): 만들어질 객체들이 공통적으로 따라야 하는 인터페이스를 정의한다.
  2. Concrete Product (구체적인 제품): Product의 규칙을 실제로 구현한 객체이다.
  3. Creator (생성자): 객체를 만드는 방법을 선언하는 역할을 하며, "어떤 종류의 제품을 만들지"에 대해 추상적으로 정의한다. 실제 구현은 하위 클래스에서 이루어진다.
  4. Concrete Creator (구체적인 생성자): Creator가 정의한 메서드를 실제로 구현한다. 즉, 구체적인 제품을 만드는 방법을 정의한다.

예로 자동차 공장을 생각해 보자. 자동차 공장(Creator)은 생산될 차량(Product)의 제작 지침을 가지고 있지만, 실제 차량 제조는 각 차종의 공장에서 이루어진다.

 

예를 들어, 현대기아차에서 세단과 SUV는 각기 다르게 나온다. 이는 자동차라는 같은 Product라 할지라도 세단과 SUV는 각각의 공장(Concrete Creator)에서 생산되며, 이 각각의 공장은 생산해야 하는 차종의 구체적인 방법, 즉 Concrete Product을 알고 있는 것이다.

 

차량을 구매하려는 고객은 주문을 하고 차량만 받는다. 차량이 어떻게 만들어지는지는 신경 쓰지 않는다.

 

고객들은 다양한 종류의 차종을 계약하고 구매할 수 있지만, 각 차종을 만드는 공장과 세부 사항들은 고객과 분리되어 있다. 객체 생성과 사용을 분리함으로써 유연성이 증가하고, 새로운 차종을 추가하거나 변경하는 것이 쉬워진다.

 

동작 방식:

 

- Creator 클래스는 Product 객체를 생성할 책임을 가지지만, 구체적인 클래스 타입은 지정하지 않는다.

- 대신, Creator는 Product 인터페이스를 반환하는 팩토리 메서드를 정의한다. 이 메서드의 구현은 Concrete Creator에 의해 제공된다.

- 클라이언트는 Creator 인터페이스를 사용하여 객체를 요청하지만, 생성될 객체의 실제 클래스는 팩토리 메서드에 의해 추상화된다.

- 이 방식 덕분에 클라이언트는 다양한 Product 구현체 중 어느 것이 반환될지 몰라도 된다.

 

 

그럼 소스코드로 살펴보자

 

- Product 

 

모든 자동차가 따라야 할 기본 인터페이스인 Car을 정의했다. 이 인터 페이스는 모든 자동차가 구현해야 할 drive 메서드를 포함한다.

public interface Car {
    void drive();
}

 

- Concreate Product 

 

Car 인터페이스를 구현하는 구체적인 자동차 클래스를 두 개 생성한다. 세단과 SUV이다.

public class Sedan implements Car {
    @Override
    public void drive() {
        System.out.println("Driving a sedan.");
    }
}

public class SUV implements Car {
    @Override
    public void drive() {
        System.out.println("Driving an SUV.");
    }
}

 

- Creator

 

자동차를 생성하는 공장의 역할을 하는 CarFactory 인터페이스를 정의한다. 이 인터페이스는 패곹리 메서드 createCar를 포함한다. 

 

public abstract class CarFactory {
    abstract Car createCar(String type);
}

 

- Concreate Creator 

 

CarFactory를 상속받아 실제 자동차를 생성하는 구체적인 공장 클래스를 구현한다. 차종에 따라서 다른 자동차 인스턴스를 생성한다. 

 

public class ConcreteCarFactory extends CarFactory {
    @Override
    Car createCar(String type) {
        if (type.equals("Sedan")) {
            return new Sedan();
        } else if (type.equals("SUV")) {
            return new SUV();
        } else {
            throw new IllegalArgumentException("Unknown car type.");
        }
    }
}

 

이렇게 만들어진 코드를 클라이언트에서는 어떻게 호출할까? 

 

public class Client {
    public static void main(String[] args) {
        CarFactory factory = new ConcreteCarFactory();
        Car myCar = factory.createCar("SUV");
        myCar.drive();

        Car anotherCar = factory.createCar("Sedan");
        anotherCar.drive();
    }
}

 

클라이언트는 CarFactory를 사용해서 필요한 자동차 타입을 요청하고 생성된 자동차로 drive 메서드를 호출한다. 

 

위에 설명에서 유연하게 확장할 수 있다고 했다. 만약 차종에 트럭이 추가되면 어떻게 될까? 

우선 Truck클래스를 추가한다. 

public class Truck implements Car {
    @Override
    public void drive() {
        System.out.println("Driving a truck.");
    }
}

 

 

그리고 if문으로 작성된 ConcreateCarFactory를 수정한다. 

switch문을 이용해서 수정하고 앞으로 추가되는 차종에 대해서는 동일하게 차종에 대한 클래스를 추가하고 case를 계속 추가하면 된다.

 

public class ConcreteCarFactory extends CarFactory {
    @Override
    Car createCar(String type) {
        switch (type) {
            case "Sedan":
                return new Sedan();
            case "SUV":
                return new SUV();
            case "Truck":
                return new Truck();
            default:
                throw new IllegalArgumentException("Unknown car type.");
        }
    }
}

 

클라이언트에서는 추가된 차종을 생성할 수 있다. 

 

public class Client {
    public static void main(String[] args) {
        CarFactory factory = new ConcreteCarFactory();
        Car mySUV = factory.createCar("SUV");
        mySUV.drive();

        Car mySedan = factory.createCar("Sedan");
        mySedan.drive();

        Car myTruck = factory.createCar("Truck");
        myTruck.drive();
    }
}

 

즉 추가되는 사항에 대해서 예제 소스와 같이 ConcreateCarFactory 클래스만 수정하면 된다. 

클라이언트에서는 어떤 타입의 Car 객체가 생성되는지 그리고 그 객체가 어떻게 생성되는지 알 필요 없다. 

 

그렇다면 Node.js로는 어떻게 소스코드를 작성하는지 예시코드를 보자

 

class Car {
    drive() {
        console.log("Driving a car");
    }
}

 

class Sedan extends Car {
    drive() {
        console.log("Driving a sedan.");
    }
}

class SUV extends Car {
    drive() {
        console.log("Driving an SUV.");
    }
}

class Truck extends Car {
    drive() {
        console.log("Driving a truck.");
    }
}

 

class CarFactory {
    // 팩토리 메서드: 차종에 따라 해당 차종의 인스턴스를 생성하고 반환
    createCar(type) {
        switch (type) {
            case 'Sedan':
                return new Sedan();
            case 'SUV':
                return new SUV();
            case 'Truck':
                return new Truck();
            default:
                throw new Error('Unknown car type');
        }
    }
}

 

const factory = new CarFactory();

// Sedan 생성 및 사용
const mySedan = factory.createCar('Sedan');
mySedan.drive();

// SUV 생성 및 사용
const mySUV = factory.createCar('SUV');
mySUV.drive();

// Truck 생성 및 사용
const myTruck = factory.createCar('Truck');
myTruck.drive();

 

 

이렇게 차종이 추가될 때마다 유연하게 확장하는 팩토리 메서드 패턴을 알아봤다.

 

그렇다면 우리가 실제로 현업에서 사용할 때는 주로 어떤 부분에서 사용할까? 

내 경험상 가장 심플하게 다가오는 것은 데이터베이스를 접속하는 부분이 아닐까 싶다. 

과거에는 주로 1개의 데이터 베이스를 사용했지만 요즘은 다르다. 각 데이터베이스별로 목적성에 맞게 사용하기도 하며, 필요에 따라서는 2개, 3개 이런 식으로도 사용한다. 그러면 우리는 데이터베이스에 접근할때 설정파일을 통해 세팅을 하는경우가 대부분인데 여러개의 데이터베이스를 접근하기 위해서는 어떻게 코드를 작성하는게 바람직할까? 

 

만약 회사에서 회원정보에 대한 데이터베이스는 MySQL을 사용하고, 제품에 대한 데이터베이스는 PostgresSQL을 사용한다고 생각하자. 

 

public interface Database {
    Connection getConnection();
}

public class MySQLDatabase implements Database {
    public Connection getConnection() {
        // MySQL 연결 반환
    }
}

public class PostgreSQLDatabase implements Database {
    public Connection getConnection() {
        // PostgreSQL 연결 반환
    }
}

public class DatabaseFactory {
    public Database getDatabase(String type) {
        if ("MySQL".equals(type)) {
            return new MySQLDatabase();
        } else if ("PostgreSQL".equals(type)) {
            return new PostgreSQLDatabase();
        } else {
            throw new IllegalArgumentException("No such database type");
        }
    }
}

 

이런식으로 소스코드를 작성하고 추가적인 데이터베이스는 계속 추가할 수 있다.

비슷한 개념으로 각 목적성에 맞는 Amazon S3 스토리지에 대해서 대한 접근도 생각해 볼 수 있다.

 

그러면 팩토리 메서드 패턴의 단점은 무엇이 있을까? 

 

객체를 생성하는데 클래스와 인터페이스가 계속해서 추가되면 코드의 양이 증가하고 이에 따라 복잡성이 높아 질 수 있다. 그리고 프로그래밍이란 어차피 if와 else가 대부분을 차지하는데 모든 상황에서 팩토리 메서드 패턴을 사용하면 경우에 따라서는 오버엔지니어링이 될 수 있다. 간단하게 생성해서 사용할 수 있는 객체생성에 대해서 복잡하게 할 필요가 없는 경우도 있기 때문이다.

 

팩토리 메서드 패턴은 객체 생성 방법이 자주 변경되거나, 클라이언트 코드에서 생성되는 객체의 특정 구현체에 의존하지 않아야할때 유용하며, 다양한 환경이나 조건에 따라 다른 로직으로 객체를 생성해야할 때 팩토리 메서드 패턴을 고려해봐야 한다. 

 

필요성과 유지보수에 대한 비용을 잘 고민해보고 사용하자.