3주차 미션 로또를 진행하면서, 로또 객체를 생성해주는 LottoFactory 라는 클래스를 만들어 사용했다.
팩토리 패턴이 있다는 것은 알고있었고 때때로 팩토리라는 클래스를 만들어 사용했지만, 자세히 알고 사용했던 것은 아니였다.
정확히 알고 장점을 살려서 사용하고자 학습하고 글을 정리한다.
우선 팩토리 패턴은 이름과 비슷한 의미로
객체를 찍어내는 공장을 만든다는
의미로 생각하면 쉽다.
어떤 상황 및 조건에 따라 객체를 다르게 생성해야 할 때가 있다.
만약 상위 클래스와 여러개의 하위 클래스가 존재할 때
사용자의 입력 값에 따라 특정 하위 객체를 생성해야 한다면
if 문을 통해 객체를 선별하고 생성할 것이다.
조건에 따른 객체 생성을(new) 컨트롤러나 도메인 객체에서 직접 하지 않고
팩토리라는 클래스에 위임하여 팩토리에서 객체를 생성하도록 하는 방식을
팩토리 메서드 패턴이라고 한다.
다양한 팩토리 패턴들 중 세가지를 정리하고자 한다.
- 간단한 팩토리
- 정적 팩토리
- 팩토리 메소드 패턴
간단한 팩토리
1 2 3 | public class Car { } |
Car 클래스가 존재하고
1 2 | public class LightCar extends Car { } |
1 2 | public class SportsCar extends Car { } |
Car 클래스를 상속하는 LightCar 클래스와 SportCar클래스가 존재할 때
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | import java.util.List; public class Cars { List<Car> cars; public void addCar(String type) { if (type.equals("sports")) { cars.add(new SportsCar()); } else { cars.add(new LightCar()); } } } |
여러대의 차를 저장하는 자료구조인 Cars에
Car를 add할 때 다음과 같이 추가한다고 가정하자.
위의 코드는 새로운 종류의 Car가 추가된다면 수정이 불가피하다.
팩토리 패턴의 핵심은 새로운 객체를 생성하지 않는 것이다.
즉, 컨트롤러나 다른 객체에서 new를 사용하지 않는 것이다.
new 자체에 문제가 있는 것은 아니다.
문제를 일으키는 부분은변화이다.
항상 구현과 요구사항은 결국 변하게 되고,
위의 코드 처럼 변화가 있을 때마다
수정되어야 하는 코드는 변화에 닫혀있는 코드이다.
저 한 부분만 있다면 상관이 없지만,
저렇게 새로운 객체를 만드는 부분이 웹 애플리케이션 전체에 퍼져있다면
새로운 타입이 하나 추가될 때 마다
사용하는 부분을 전부 찾아서 수정을 해야한다.
유지보수하기 쉬운 코드를 만들기 위해
인터페이스와 추상클래스의 다형성에 초점을 두고 코딩을 하는 것처럼
위의 코드에도 대책이 필요하다.
문제를 해결하기 위한 방법은
지속적으로 바뀌는 부분을 찾는 것이다.
객체지향에서 가장 중요한 원칙 중 하나는
바뀔 수 있는 부분을 찾아내서 바뀌지 않는 부분과 분리시키는 것이다.
1 2 3 4 5 6 7 8 9 | public class CarFactory { public static Car createByType(String type) { if (type.equals("sports")) { return new SportsCar(); } return new LightCar(); } } |
바뀔 가능성이 있는 부분을 위의 코드처럼
팩토리 클래스로 분리하였다.
이제 새로운 타입이 추가된다면
사용하는 부분을 전부 찾는게 아니라,
팩토리 클래스만 찾아서 수정하면 된다.
결론적으로 Cars 객체에서는 어떤 객체가 반환되는지 신경 쓸 필요없이
반환된 객체만 사용하면 되고 코드 변경은 최소화할 수 있게 되었다.
즉, 결합도를 낮추었고 유연성을 높였다고 볼 수 있다.
위의 코드들에선
상위 클래스인 Car가 일반 클래스인데,
추상 클래스나 인터페이스이면 더 좋다.
이유는 밑에서 계속 나온다.
정적 팩토리
위에서 살펴본 간단한 팩토리는 간단한 로직이기 때문에
정적(static)으로 사용하는 경우가 많은데 이를 정적 팩토리라고 한다.
생성자 대신 정적 팩토리 메소드를 고려해볼 것
Effective Java의 첫 번째 내용이다.
생성자를 통해 객체를 생성하는 전통적인 방법 말고,
1 2 3 |
위의 코드처럼 public static 팩토리 메소드를 사용해서 해당 클래스의 인스턴스를 만드는
방법을 고려해보라는 말이다.
무조건 사용하는 것이 아니라,
"고려해보라"이다.
그 이유는 다음과 같다.
장점
1. 이름을 가질 수 있다.
생성자에 제공하는 파라미터만을 통해서는
어떤 내용을 가진 코드인지, 객체를 잘 설명하지 못할 경우에는
잘 만든 이름을 가진 static 팩토리를 사용한다면 메소드명을 통해 의도를 전달하기 때문에
사용하기 보다 더 쉽고 해당 팩토리 메소드의 클라이언트 코드를 읽기 편하다.
1 2 3 4 5 6 7 8 9 10 11 12 | import java.util.List; public class Cars { List<Car> cars; public Cars(String carsName) { String[] names = carsName.split(","); for (String name : names) { cars.add(new Car(name)); } } } |
1 2 3 4 | public static void main(String[] args) { Cars cars = new Cars("둔덩, 카일, 호돌, 럿고"); } |
차들의 이름을 파라미터로 받아서
split 후 Car로 객체를 생성한다음
List에 추가하는 다음과 같은 코드를
1 2 3 4 5 6 7 8 9 10 | import java.util.ArrayList; import java.util.List; public class Cars { List<Car> cars; public Cars(List<Car> cars) { this.cars = new ArrayList<>(cars); } } |
1 2 3 4 5 6 7 8 9 10 11 | public class CarsFactory { public static Cars withSplitName(String carsName) { .map(Car::new) .collect(Collectors.toList()); return new Cars(cars); } } |
1 2 3 | public static void main(String[] args) { Cars cars = CarsFactory.withSplitName("둔덩, 카일, 호돌, 럿고"); } |
적절한 네이밍을 한 정적 팩토리 메소드를 통해
더 읽기 좋게 바꿀 수 있다.
2. 반드시 새로운 객체를 만들 필요가 없다.
불변(immutable) 클래스인 경우나 매번 새로운 객체를 만들 필요가 없는 경우에
미리 만들어둔 인스턴스 또는 캐시해둔 인스턴스를 반환할 수 있다.
Boolean.valueOf(boolean)메소드도 그 경우에 해당한다.
이 부분은 로또 미션을 구현한 제이미의 코드를 참고한 예제로 살펴보면
1 2 3 4 5 6 7 8 9 | public class LottoBall { int number; public LottoBall(int number) { this.number = number; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import java.util.HashMap; import java.util.Map; public class LottoBallFactory { private static final Map<Integer, LottoBall> lottoBox = new HashMap<>(); static { for (int number = 1; number <= 45; number++) { lottoBox.put(number, new LottoBall(number)); } } public static LottoBall get(int number) { return lottoBox.get(number); } } |
검증은 생략했지만,
무조건 1부터 45까지의 숫자만 가질 수 있는 로또볼은
굳이 새로운 객체를 계속 생성할 필요 없이
정적 팩토리 메소드를 사용해서
캐시로 미리 생성해둔 인스턴스를 반환할 수 있다.
3. 리턴 타입의 하위 타입 인스턴스를 만들 수 있다.
위에서 살펴본 Car의 예제처럼
리턴 타입의 하위 타입의 인스턴스를 만들어줘도 되기 때문에
만들어 줄 객체의 클래스를 선택하는 유연함을 가진다.
리턴 타입은 인터페이스로 지정하고 그 인터페이스의 구현체는 API로 노출 시키지 않지만
그 구현체의 인스턴스를 만들어 줄 수 있다는 말이다.
java.util.Collections가 그 예에 해당한다.
java.util.Collections는 45개에 달하는 인터페이스의 구현체의 인스턴스를 제공하지만
그 구현체들은 전부 non-public으로 뒤에 감쳐줘 있다.
때문에 public으로 제공해야 할 API를 줄였을 뿐 아니라 프로그래머가 어떤 인터페이스에서
제공하는 API를 사용할 때 알아야 할 개념의 개수와 난이도를 줄여준다.
그러한 팩토리를 사용하는 코드가 구현체가 아닌 인터페이스 타입으로 코딩하게 되는건
다형성과 유지보수적인 측면에서 좋은 일이다.
4. 리턴하는 객체의 클래스가 입력 매개변수에 따라 매번 다를 수 있다.
장점 3과 같은 이유로 객체의 타입은 다를 수 있다.
EnumSet클래스는 생성자 없이 public static 메소드,allOf(),of()등을 제공한다.
그 안에서 리턴하는 객체의 타입은 enum 타입의 개수에 따라
RegularEnumSet또는JumboEnumSet으로 달라진다.
1 2 3 4 5 6 7 8 9 | public class CarFactory { public static Car getCar(int speed) { if (speed > 5) { return new SportsCar(); } return new LightCar(); } } |
Car 예제를 통해 살펴보면
파라미터로 속도를 전달받아 사용자는 모르지만
속도에 맞는 새로운 하위 클래스를
리턴할 수 있다.
이렇게 하면 하위 객체 타입은 노출하지 않고 감춰져 있기 때문에
새로운 타입을 만들거나 기존 타입을 없애도 문제가 되지 않고
EnumSet의 경우엔 추후에 JDK의 변화에도 잘 대처할 수 있다.
5. 리턴하는 객체의 클래스가 public static 팩토리 메소드를 작성할 시점에 반드시 존재하지 않아도 된다.
3번, 4번과 비슷한 개념이다.
상속받은 클래스를 메소드를 만든 나중에도 만들 수 있고 교체할 수 있다.
예를 들어 어떤 특정 약속되어있는 텍스트 파일에서 Car의 구현체를 읽어 온다고 가정할 때
텍스트 파일에서 읽어올 수 있는 새로운 Car의 구현체가 추가되어도
상위 객체를 리턴하기 때문에
메소드에는 변화가 없다.
코드와 주석을 통해 설명하자면 다음과 같다.
1 2 3 4 5 6 7 8 9 10 11 | public class CarFactory { public static Car getCar(int speed) { Car car = new Car(); //특정하 약속이 되어있는 텍스트 파일에서 Car의 하위 클래스, 구현체를 읽어온다 //해당하는 인스턴스를 생성한다. //car가 인스턴스를 가리키도록 수정한다. return car; } } |
단점
1. public 또는 protected 생성자 없이 public static 메소드만 제공하는 클래스는 상속할 수 없다.
따라서 Collections는 상속할 수 없다.
상속보다 컴포지션을 사용하도록 유도하고,
불변타입으로 만들려면 이 제약을 지켜야한다는 점에서
오히려 장점으로 받아 들일 수 도 있다.
어떤 경우에 정적 팩토리를 사용하고 일반 팩토리를 사용하는지 힌트를 주는 단점인 것 같다.
밑에서 살펴볼 디자인 패턴으로서의 팩토리 메서드 패턴은
팩토리를 상속하는 구조를 가져야 하기 때문에 정적 팩토리로는 구현할 수 없다.
간단한 팩토리일 경우엔 정적 팩토리로 구현하는 것이 좋고
디자인 패턴으로서 팩토리 메서드 패턴을 구현할 때는
일반 팩토리로 구현하는 것이 좋을 것 같다.
2. 프로그래머가 static 팩토리 메소드를 찾는게 어렵다.
생성자는 Javadoc 상단에 모아서 보여주지만
static 팩토리 메소드는 API문서에서 특별히 다뤄주지 않는다.
따라서 클래스나 인터페이스 문서 상단에
팩토리 메소드에 대한 문서를 제공하는 것이 좋다.
팩토리 메서드 패턴
간단한 팩토리에서는 Car의 종류가 SportsCar와 LightCar가 있었고
type을 입력받아 type에 맞는 Car구현체를 리턴했었다.
간단한 팩토리는 디자인 패턴이라고 보기에는 너무 간단하다.
만약 차의 종류가 현대차, 기아차로 나뉘어지고
현대차에서도 그랜저, 소나타 등으로 나뉘어진다면
간단한 팩토리로는 문제가 많은 코드를 만들 수 밖에 없다.
차량을 출고하기 위해서 차종류에 상관없이 공통적으로 해야하는 작업들(타이어 장착, 창문 세팅 등등)이
있다면 각각의 팩토리에서 중복적으로 작업을 수행해줘야하고,
요구사항이 계속 변한다면 여러 곳에 퍼져있는 코드들을 전부 수정해줘야하는
닫혀있는 코드, 유지보수 하기 어려운 코드가 되는 것이다.
해결방법은 차량 제작과정을 하나로 묶어주는 것이다.
회사별 팩토리에서는 원하는 차량을 생성해주고
공통작업들을 한 곳에서 해줌으로써
수정이 편한, 유지보수가 용이한 코드가 될 수 있다.
초 간단 예제
data-origin-width="0" data-origin-height="0" | invalid-file |
1 2 3 4 5 6 7 8 9 10 | public abstract class Car { public void setTyre() { System.out.println("타이어 장착"); } public void setWindow() { System.out.println("창문 세팅"); } } |
Car 클래스는 추상클래스로, new를 통해 객체로 생성할 수 없고
차량이라면 무조건 해줘야할 공통 작업들이
메소드로 구현되어 있다.
1 2 | public class K3 extends Car { } |
1 2 | public class K5 extends Car { } |
1 2 | public class K7 extends Car { } |
기아차는
Car를 상속한
k3, k5, k7 3개의 차량 객체가 있다.
1 2 | public class Grandeur extends Car { } |
1 2 | public class Sonata extends Car { } |
현대차는
그랜저와 소나타 차량 객체가 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public abstract class CarFactory { public Car delivery(String type) { Car car = create(type); car.setTyre(); car.setWindow(); return car; } public abstract Car create(String type); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 | public class HyundaiCarFactory extends CarFactory { @Override public Car create(String type) { if (type.equals("그랜저")) { return new Grandeur(); } if (type.equals("소나타")) { return new Sonata(); } throw new IllegalArgumentException(); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 | public class KiaCarFactory extends CarFactory { @Override public Car create(String type) { if (type.equals("k3")) { return new K3(); } if (type.equals("k5")) { return new K5(); } throw new IllegalArgumentException(); } } |
CarFactory를 상속한 각 회사의 Factory에서
create추상메소드를 재정의해서 각각 회사에 맞게 차량을 생산 후 리턴하고
차량에 공통적으로 해줘야할 작업들을 CarFactory 한 곳에서 해주고 있다.
1 2 3 4 5 6 7 8 | public class Main { CarFactory hyundaiFactory = new HyundaiCarFactory(); CarFactory kiaFactory = new KiaCarFactory(); Car hyundaiCar = hyundaiFactory.delivery("그랜저"); Car kiaCar = kiaFactory.delivery("k3"); } |
차량을 만드는 전체적인 작업은 CarFactory에서 해주지만
어떤 차량을 만들지는 KiaCarFactory등의 서브클래스에서 정한다.
서브클래스에서는 차량의 생산과정에 관여할 수 없다.
결정할 수 있는것은 차량의 종류 하나 뿐이다.
CarFactory에서는 Car 객체를 통해 작업을 하지만 Car는 추상 클래스이기 때문에
실제로는 Car의 어떤 구상클래스에서 작업을 처리하는지는 외부에서는 알 수 없다.
즉, Car 객체와 CarFactory 객체는 서로 완전히 분리되어 있는 것이다.
CarFactory에서는 객체 생성의 전체를 처리하고
객체를 생성하는 부분은 KiaCarFactroy등의 서브 클래스에 캡슐화 시킨 것이다.
결론적으로 변하지 않는 부분은 남기고, 변하는 부분은 추상 메서드를 상속하게끔 만들 수 있게 된거다.
정리하자면
팩토리 메서드 패턴이란
서브 클래스에서 어떤 클래스를 만들 것인지 결정하게 함으로써, 객체 생성을 캡슐화 한다.
이 패턴에서는 두 종류의 클래스가 있다.
1. Creator 클래스
CarFactory가 해당 된다.
create() 추상 메소드처럼 나중에 KiaCarFactory등의 서브 클래스에서
제품을 생산하기 위해 구현할 추상 메소드를 정의한다.
2. Product 클래스
KiaCarFactory, HyundaiCarFactory등이 해당된다. 제품을 생산하는 클래스를 말한다.
팩토리 메소드 패턴은 간단한 팩토리와 비슷해 보일 수 있다.
하지만 간단한 팩토리 메소드는 캡슐화는 하지만 일회용 처방에 불과하다.
왜냐하면 생성하는 제품을 마음대로 변경할 수 없기 때문이다.
반면 팩토리 메서드 패턴은 어떤 구현체를 사용할지를 서브 클래스에서 결정하기 때문에
강력한 유연성을 제공한다.
또한 팩토리 메소드 패턴을 사용한다면
의존성 뒤집기 원칙(Dependency Inversion Principle)을 지킬 수 있다.
의존성 뒤집기 원칙은 구상 클래스에 의존하지 않고 추상화된 것에 의존하도록 만들라는 원칙으로
팩토리 메소드 패턴에서는 그 원칙을 아주 잘 지키고 있다.
CarFactory에서는 Car를 생성하고
K3, K5, K7 등의 구현체들이 Car에 의존하고 있다.
팩토리 메소드 패턴이 의존성 뒤집기 원칙을 준수하기 위해 쓸 수 있는 유일한 기법은 아니지만
가장 적합한 방법 가운데 하나이다.
참고
'Java' 카테고리의 다른 글
Exception 정리 (0) | 2020.03.04 |
---|---|
Immutable Object(불변 객체) (0) | 2020.03.03 |
Iterator 인터페이스와 Iterable 인터페이스 (0) | 2020.02.17 |
함수형 인터페이스 API 정리 (0) | 2020.02.12 |
추상 클래스와 인터페이스 (0) | 2020.02.10 |