
생성자(Constructor) - 클래스로부터 객체를 생성할 때의 호출하는 메소드
개발 과정에서 너무나도 당연하게 사용하는 거라 오히려 단어로 보면 생소하다.
public class Laptop {
private String brand;
public Laptop(String brand) {
this.brand = brand;
}
}
public class Application {
public static void main(String[] args) {
Laptop laptop = new Laptop("apple");
}
}
위와 같이 Laptop이라는 Class를 정의할 때 같은 이름의 메서드로 생성자를 구성하게 되며, main 함수에서 Laptop이라는 객체를 만들 때, 그 생성자를 통하게 된다.
- new 연산자를 통해 객체 생성 시, 반드시 호출되고 제일 먼저 실행된다.
- 객체의 초기화(Initialize) 역할을 한다.
- 생성자의 이름은 클래스의 이름과 동일해야 한다.
- 만약 생성자를 생성하지 않으면, 컴파일러가 자동으로 Default Constructor를 생성.
- 만약 파라미터 구성이 같은 생성자는 두개 이상 만들 수 없다.
(예를 들어, String 변수를 하나 받는 생성자가 두개일 수는 없다.)
- GoF Pattern - "Gang Of Four"의 줄임말로, 네 명의 개발자가 정리한 Software Design Pattern.
- Software Pattern - 소프트웨어를 설계하는 단계에서 참고할 수 있는 Problem & Solving을 정리해 놓은 것.
- Factory - GoF 패턴에 등장하는 기법 중 하나로, 메소드 하나를 두어 객체 생성의 역할만을 하게 한다는 것 (Expert, Low Coupling, High Cohesion)
- Expert - GoF 패턴에 등장하는 기법 중 하나. 한 객체가 기능을 최대한 담당하는 것이 좋다는 이론이다.
- Low Coupling - GoF 패턴에 등장하는 기법 중 하나이며 OOP의 대원칙. 객체와 객체 사이에 최대한 결합도를 낮게 해야한다는 것.
- High Cohesion - GoF 패턴에 등장하는 기법 중 하나이며 OOP의 대원칙. 한 기능은 최대한 한 객체에 응집되어 있어야 한다는 것.
Factory란 무엇인가?
우리는 일단 Factory라는 것이 무엇인지 이해해볼 필요가 있다.
여기서의 Factory라는 단어는 GoF Pattern에서 유래하고 있다.
OOP(Object Oriented Programming: 객체 지향 프로그래밍)에서 객체의 생성이라는 것은 꽤나 중요한 논점이다.
개발을 하다보면 하나의 클래스에서 여러 객체를 생성하기도 하고 여러 기능적인 로직까지 추가하기도 한다.
이는 OOP의 대원칙 중 하나인 High Cohesion(높은 응집도)에 어긋난다.
High Cohesion을 이해하기 위해, 가령 이런 예시를 들어볼 수 있다.
(나는 OOP를 생각할 때 레고 놀이를 떠올리곤 한다.)예를 들어, 나는 A부품을 머리로, B부품을 몸통으로, C부품을 다리로 각자 가져와 조합하고 싶었다.\
그런데 A부품은 그 통째로 사람 하나이기 때문에 머리를 떼어올 수가 없다.
결국 머리만 담당할 다른 부품을 만들어야 하는 상황인 것이다.만약 A부품은 머리만 담당하고 B부품은 몸통만 담당하고 C부품은 다리만 담당한다면 어떤 조합에서도 유연하게 재사용할 수 있지 않았을까?
그런데 문제는 한 클래스당 로직이 안들어갈 수는 없는 일이다.
(적어도 A.java라는 파일은 A라는 로직을 수행해야 할 것이 아닌가?)
그런데 또 클래스에는 생성자가 또한 필요한 법이다.
(A라는 객체를 써먹으려면 생성은 해야하지 않냐는 말이다.)
그래서 GoF Pattern에서는 차라리 여러 클래스들의 생성만을 전문적으로(Expert Pattern) 담당하는 것을 하나 만드는 것이 어떠냐고 제안한다.
마치 공장에서 부품을 찍어내듯이 말이다. (Factory Pattern)
이제 필요한 배경 지식 설명은 끝난 것 같으니 다시 본론으로 돌아오자.
본인은 객체의 생성을 일반적인 생성자를 통하지 않고, 생성을 위한 Method를 별도로 만들어서 하는건 어떤가 하고 제안하고 싶다.
위에서 생성자의 예시로 들었던 간단한 코드를 이번엔 정적 팩토리 메서드로 작성해보고 다시 이야기해보자.
public class Laptop {
private String brand;
private Laptop(String brand) {
this.brand = brand;
}
public static Laptop withBrand(String brand) {
return new Laptop(brand);
}
}
public class Application {
public static void main(String[] args) {
Laptop laptop = Laptop.withBrand("apple");
}
}
위처럼 단순한 예시도 있고, 우리에게 보다 친숙한 예시도 있다.
public final class Optional<T> {
private Optional(T value) {
this.value = Objects.requireNonNull(value);
}
public static <T> Optional<T> of(T value) {
return new Optional<>(value);
}
}
public class Application {
public static void main(String[] args) {
Optional<Long> value = Optional.of(1L);
}
}
자바 1.8에서 등장한 Optional은 대표적인 정적 팩토리 메서드로 구현되어 있다고 한다.
여튼 예시를 보았는데 어떤가? 말은 샌선자를 안쓸것 같이 말하지만 생성자는 똑같이 있고, 정적 팩터리 메서드 내부에서 어차피 다시 new 연산을 통해 생성자를 사용하고 있다.
솔직히 코드만 봐서는 오히려 바로 생성자를 통하는 것보다 더 번거로워 보인다.
그럼 대체 뭐가 다르다는 것일까? 이제부터 그 차이 장단점을 통해 알아보고자 한다.
public 생성자를 사용해 객체를 생성하는 방법 외 다음과 같이 public static factoryh method를 사용해 해당 클래스의 인스턴스를 만드는 방법이 있다.
// boolean의 기본 타입의 값을 받아 Boolean 객체 참조로 변환
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
이처럼 생성자 대신 정적 팩토리 메소드를 사용하는 것에 대한 장단점은 다음과 같다.
앞에서 말했듯 생성자의 이름은 클래스의 이름과 같아야 한다. 이는 생성자의 특징이자 제한사항이다.
만약 정적 팩토리 메서드를 사용한다면, 클래스는 이름과는 다른 보다 특징적인 이름을 지어줄 수가 있다.
public class Book {
private String title;
private String author;
// constructor1
public Book(String title, String author) {
this.title = title;
this.author = author;
}
/**
* 생성자는 하나의 시그니처만 사용하므로, 다음과 같이 생성이 불가능하다.
*
*/
public Book(String title) {
this.title = title;
}
public Book(String author) {
this.title = title;
}
}
일반적인 생성자는 클래스 이름과 같은 이름을 가진다는 제약이 있기 때문에, 무슨 의도인지, 무엇으로 만드는지 파악하기가 어렵다.
생성자는 똑같은 타입의 파라미터로 받는 생성자를 여러개 생성할 수 없다. static factory method는 한 클래스에 시그니처가 같은 생성자가 여러 개 필요한 경우에도 사용할 수 있으며, 또한, 파라미터가 반환하는 객체를 잘 설명하지 못하는 경우에, 이름을 잘 지은 static factory method를 사용할 수 있다.
public class Book {
String title;
String author;
public Book(String title, String author) {
this.title = title;
this.author = author;
}
public Book() {}
/*
* withAuthor, withTitle과 같이 이름을 명시적으로 선언할 수 있으며,
* 한 클래스에 시그니처가 같은(String) 생성자가 여러개 필요한 경우에도 다음과 같이 생성할 수 있다.
*
*/
public static Book withAuthor(String author) {
Book book = new Book();
book.author = author;
return book;
}
public static Book withTitle(String title) {
Book book = new Book();
book.title = title;
return book;
}
}
그런데 위의 예시를 보면
"아, 작가를 가지고 책이라는 객체를 만든다는 의미구나"
"아, 제목을 가지고 책이라는 객체를만들겠다는 거구나"
를 알 수가 있다.
결과적으로 코드의 가독성을 높여주게 된다.
여기서 시그니처라고 말하는 것은 생성자의 생성조건이라고 이해하는 것이 마음 편하다. 즉, 생성자를 저의할 때 보통 매개변수의 조합을 말한다.
장점1
- 각 정적 메서드에게 이름을 부여하여 코드 가독성 및 편의를 높인다.
- 생성 조건 (파라미터의 조합)이 같은 생성자는 두개 이상 존재할 수 없다는 제약 조건이 사라진다.
현재까지 개념을 잘 따라오고 있다면 정적 팩토리 메소드란 결국 객체의 생성을 책임지고 있다는 것을 알 것이다.
조금 돌려서 말하면, 정적 패토리 메소드가 객체의 생성 방식 또한 관리할 수 있다는 말이 된다.
- 필요에 따라 항상 새로운 객체를 생성해서 반환할 수 있다.
- 필요에 따라 새로운 객체 생성을 금지하고 같은 객체만 반환할 수 있다.
- 불필요한 객체를 굳이 만들지 않게 하도록 통제할 수 있다.
일반적인 생성자(Constructor)를 사용할 경우 호출할 때마다 항상 새로운 객체를 만들게 된다. 그런데 인스턴스의 내용이 바뀔 일도 없고,호출 빈도수가 매우 잦은데 생성비용까지 크다면?
그것은 자원 낭비가 매우 심하다.
만약 정적 팩토리 메서드를 사용한다면,
경우에 따라 어떤 클래스는 객체를 만들지 않도록 하거나,
객체를 오로지 하나만 만들도록 할 수 있기 때문에,
위와 같은 특수적인 상황에서는 효율성이 증대되며 빛을 발한다.
인스턴스화 불가(noninstantiable) - 인스턴스의 추가 생성을 통제한다.
보통 클래스에서 인스턴스화 될 때 어떤 일에도 변할 일이 없을 경우에,
이를 불변 클래스(immutable class)라고 한다.
그래서 이런 경우에는 인스턴스를 미리 만들어 놓고 있다가,
호출이 오면 새로 만들지 않고 기존 것을 반환하게 하는게 좋다고 한다.
가장 대표적인 예시가 Boolean이다.
public final class Boolean implements java.io.Serializable, Comparable<Boolean> {
/**
* The {@code Boolean} object corresponding to the primitive
* value {@code true}.
*/
public static final Boolean TRUE = new Boolean(true);
/**
* The {@code Boolean} object corresponding to the primitive
* value {@code false}.
*/
public static final Boolean FALSE = new Boolean(false);
// ...
}
// boolean의 기본 타입의 값을 받아 Boolean 객체 참조로 변환
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
위 코드를 살펴보면,
Boolean 클래스는 TRUE와 FALSE라는 인스턴스를 사전에 생성해두고 있다.
그리고 정적 팩토리 메서드인 valueOf는,
호출될 경우 기존에 생성되어 있는 TRUE 또는 FALSE만을 반환한다.
사실상 계속 같은 인스턴스만을 반환하고 있는 것인데,
Boolean 클래스의 인스턴스는 변형의 여지가 없기 때문에 이렇게 하는 것이다.
(그래봤자 true 아니면 false를 반환하는 목적의 클래스이기 때문이다.)
싱글턴(singleton) - 객체를 단 하나만 생성하게 하고, 생성된 객체를 시스템 어디에서든지 사용할 수 있게 하는 GoF 패턴의 하나.
이번에는 인스턴스를 만들지 못하게 하지는 않지만, 단 하나만 만들도록 하는 제약 조건이다.
사실 이 말 자체가 싱글턴(singleton) 패턴을 뜻하는데,
이에 대해서는 뒤에서 더 다루므로 여기서는 언급만 하도록 한다.
여기서는 이렇게 싱글턴(singleton) 또한 정적 팩토리 메서드로 구현이 가능하며,
다양하게 객체 생성 통제가 가능하다.
예시 코드를 보자
public class Singleton {
private static Singleton singleton = null;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
public class Application {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
}
}
코드를 보면, if문을 사용하여 생성된 객체가 없으면 생성해서 반환하고, 기존에 생성된게 있으면 그것을 반환하도록 되어있다.
결과적으로 단 하나의 인스턴스만을 생성하고 반환하게 되는 것이다.
장점2
- 불필요한 인스턴스의 생성을 막을 수 있다.
- 인스턴스화 불가 (noninstantiable) 보장.
- 인스턴스를 단 하나만 생성해서 그것만을 사용하게 할 수도 있다.
- Singleton 패턴 구현 가능.
- 결론적으로, 상황에 적절하게 객체 생성을 통제하는 것이 가능하다.
- 인스턴스 통제 클래스 (instance-controlled) 구현 가능
Singleton: 해당 객체의 인스턴스를 하나로 통제하는 것
noninstantiable: 생성자로 인스턴스를 생성할 수 없게 하는 것
하위 타입
자바에서 하위 타입이라는 이야기가 나오면 두 가지를 생각하면 된다.
상속 클래스(Extend)와 추상 클래스(Interface).
public interface Grade {
String toText();
public static Grade of(int score) {
if (score >= 90)
return new A();
else if (score >= 80)
return new B();
else
return new F();
}
}
public class A implements Grade {
@Override
public String toText() {
return "A";
}
}
public class B implements Grade {
@Override
public String toText() {
return "B";
}
}
public class F implements Grade {
@Override
public String toText() {
return "F";
}
}
public class Application {
public static void main(String[] args) {
Grade grade = Grade.of(95);
System.out.println(grade);
}
}
코드를 간단히 설명하자면, Grade라는 추상 클래스를 A, B, F 라는 클래스가 구현하고 있다.
또 정적 팩토리 메서드인 of()의 반환타입은 Grade이다.
그런데 실제로 of() 메서드를 보면 반환은 A, B, F의 인스턴스로 하고 있다.
이처럼 정적 팩토리 메서드를 사용하면, 반환 타입 (추상 클래스: Grade)의 하위 타입 (구현 클래스: A, B, F) 객체를 반환하는게 가능하다.
먼저, 위 코드에서 볼 수 있듯 Grade의 of() 메서드는 조건에 따라 다른 객체를 자유롭게 선택하여 바환한다.
즉, 엄청난 유연성을 보여준다.
또, Grade 클래스 내에 구현체가 있는 것이 아니고,
Grade는 실제 구현체를 연결해주는 역할만을 하기 때문에,
구현 로직은 숨길 수 있으면서, API는 매우 경량화 된다.
개발자의 입장에서도 인터페이스대로 객체를 가져올 것임을 알기에,
굳이 구현체를 찾아볼 필요가 없게 된다.
이는 API를 사용하는데 있어 난이도를 낮추게 된다.
public Collections() {
// ...
public static final List EMPTY_LIST = new EmptyList<>();
public static final <T> List<T> emptyList() {
return (List<T>) EMPTY_LIST;
}
// ...
}
장점3
- 반환 타입의 하위 타입 객체를 반환할 수 있다.
- 유연하게 객체를 반환할 수가 있어진다. (높은 유연성)
- API를 경량화 할 수 있다.
- 구현체를 숨길 수 있다.
- 개발자에게 API 사용 난이도를 낮춰준다.
입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
사실 장점 3번의 내용과 장점 4번의 내용은 맥락이 서로 같다.
장점 3번은 반환 타입의 하위 객체를 반환할 수 있다는 기능성적인 측면에 초점을 두고 있고, 장점 4번은 하위 객체를 반환할 수 있기에 다양한 객체를 선택적으로 반환할 수 있다는 기능적인 측면에 초점을 두고 있을 뿐이다.
그렇기에 3번은 이해했다면 4번은 자동으로 이해가 된다.
위에서 봤던 예시를 한번 더 언급해보자.
public interface Grade {
String toText();
public static Grade of(int score) {
if (score >= 90)
return new A();
else if (score >= 80)
return new B();
else
return new F();
}
}
public class A implements Grade {
@Override
public String toText() {
return "A";
}
}
public class B implements Grade {
@Override
public String toText() {
return "B";
}
}
public class F implements Grade {
@Override
public String toText() {
return "F";
}
}
public class Application {
public static void main(String[] args) {
Grade grade = Grade.of(95);
System.out.println(grade);
}
}
Grade 추상 클래스 내의 of() 메서드를 보면,
매개변수로 score 값에 따라 A, B, F 객체를 선택적으로 반환하고 있는 것이 보인다.
public abstract class EnumSet<E extends Enum<E>> extends AbstractSet<E> implements Clonable, java.io.Serializable {
//...
/**
* Creates an empty enum set with the specified element type.
*
* @param <E> The class of the elements in the set
* @param elementType the class object of the element type for this enum
* set
* @return An empty enum set of the specified type.
* @throws NullPointerException if <tt>elementType</tt> is null
*/
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
Enum<?>[] universe = getUniverse(elementType);
if (universe == null)
throw new ClassCastException(elementType + " not an enum");
if (universe.length <= 64)
return new RegularEnumSet<>(elementType, universe);
else
return new JumboEnumSet<>(elementType, universe);
}
//...
}
EnumType의 원소의 개수에 따라 RegularEnumSet, JumboEnumSet으로 결정되는데 클라이언트는 이 두 객체의 존재를 모르며, 추후에 새로운 타입을 만들거나 기존 타입을 없애는 경우에도 문제되지 않는다.
장점4
- 반환타입의 하위 객체라면, 얼마든지 선택적으로 다양한 객체를 반환할 수 있다.
public interface HelloService {
Stirng hello();
}
Service Provider Interface(SPI): HelloService 인터페이스는 서비스를 정의하는 인터페이스로 여러 구현체가 이 인터페이스를 구현할 수 있다.
public class HelloServiceImpl1 implements HelloService {
@Override
public String hello() {
return "Hello imp1";
}
}
public class HelloServiceImpl2 implements HelloService {
@Override
public String hello() {
return "Hello imp2";
}
}
Service Provider: HelloService를 구현하는 구현체
import java.util.Optional;
import java.util.ServiceLoader;
public class ServiceConsumer {
public static void main(String[] args) {
ServiceLoader<HelloService> loader = ServiceLoader.load(HelloService.class);
Optional<HelloService> helloServiceOptional1 = loader.findFirst();
helloServiceOptional1.ifPresent(h -> {
System.out.println(h.hello());
});
}
}
Service Consumer: 서비스를 사용하는 클라이언트 코드
위와 같이 사용하면 consumer코드를 변경하지 않고 서비스의 구현체를 변경할 수 있다.
근데 위와 같이 사용하면 구현체가 여러개일 경우 어느 것을 선택할 지 모르는 상태인데 원한느 것을 동적으로 선택하게 하려면 아래와 같은 코드로 작성해서 사용하면 된다.
import java.util.Iterator;
import java.util.ServiceLoader;
public class DynamicServiceConsumer {
public static void main(String[] args) {
ServiceLoader<HelloService> loader = ServiceLoader.load(HelloService.class);
HelloService selectedService = selectService(loader);
if (selectedService != null) {
System.out.println(selectedService.hello());
}
}
private static HelloService selectService(ServiceLoader<HelloService> loader) {
// 예제: 이름이 "Impl2"로 끝나는 서비스 제공자 선택
for (HelloService service : loader) {
if (service.getClass().getSimpleName().endWith("Impl2")) {
return service;
}
}
}
}
위의 코드에서 selectService 메서드는 ServiceLoader를 반복하면서 특정 조건을 만족하는 서비스 제공자를 선택한다. 이 예제에서는 간단하게 구현한 클래스의 이름이 "Impl2"로 끝나는 경우를 선택하는 조건으로 사용했다. 이런식으로 필요에 따라 원하는 조건을 추가 하면 된다.
public class Laptop {
private String brand;
private Laptop() {}
public static Laptop fromBrand(String brand) {
Laptop laptop = new Laptop();
laptop.brand = brand;
return laptop;
}
}
보통 정적 팩토리 메서드만을 사용하여 생성 기능을 제공하는 클래스의 경우,
외부에서 생성자를 사용하지 못하게 해야하니 private 형태로 선언한다.
하지만 확장(Extend) 기능을 사용하기 위해서는 public이나 protected 형태의 확장자가 필요하다.
다시 말해, 정적 팩토리 메서드 만을 제공하는 클래스의 경우에는 상속을 통한 확장이 불가하다.
단점1
- 생성 기능으로 정적 팩토리 메서드만을 제공하는 클래스의 경우, 상속을 통한 확장이 불가하다.
- 하지만 오히려 이 제약조건은 장점으로 자용할 수도 있다.
public class Laptop {
private String brand;
private Laptop(){
}
public static Laptop fromBrand(String brand)
{
Laptop laptop = new Laptop();
laptop.brand = brand;
return laptop;
}
}
보통 생성자(Constructor)의 경우, 클래스의 최상단부에 위치하게 된다.
뭐 이름 또한 클래스명과 같으니, 위치로 보나 이름으로 보나 개발자의 입장에서는 찾기가 수월하다.
하지만 정적 팩토리 메서드의 경우,
별다른 위치가 정해지지도 않았을 뿐더러 이름도 별다른 제약조건이 없기 때문에,
언뜻 보면 일반 메서드와 차이를 구별하기 쉽지가 않다.
다시 말해, 개발자가 객체를 생성하는 정적 팩토리 메서드를 보고자 할 때,
그 위치를 한번에 찾기가 쉽지 않다.
단점2
- 정적 팩토리 메서드는 일반 메서드와 같은 형태를 띄고 있기 때문에, 한번에 알아보기가 어렵다.
Item1 정리
- 정적 팩토리 메서드와 public 생성자는 각자의 쓰임새가 있다.
- 각자의 장단점을 이해하는게 좋다.
- 하지만 정적 팩토리 메서드를 사용하는게 유리한 경우가 더 많다.
- 무작정 public 생성자를 사용하는 습관이 있다면 고치자.