이펙티브 자바의 첫 시작은 객체를 생성하고 파괴하는 것에 대한 고찰이다.
"2장 - 객체의 생성과 파괴" 는 다음과 같은 기준으로 맥락을 잡고 있다.
- 객체를 만들어야 할 때는 언제인가
- 객체를 만들지 말아야 할 때는 언제인가
- 올바른 객체 생성 방법은 무엇인가
- 객체의 불필요한 생성을 피하는 방법은 무엇인가
- 객체를 제 때에 파괴시키는 방법은 무엇인가
- 파괴 전에 수행해야 할 정리 작업을 관리하는 요령이 있는가
위와 같은 맥락을 계속 기억하며 공부하자.
Item1을 하고 나서인지 그래도 Item2 제목은 아예 낯설지는 않은것 같다.
(적어도 생성자랑 친해져서 온 상태가 아닌가..)
들어가기에 앞서 이해에 도움이 되는(= ..알아야 되는..) 배경지식을 하나 알고가자.
다음은 클래스 Laptop에 담겨져 있는 정보들이다.
< Laptop >
Data type | Name | 설명 | 구분 |
---|---|---|---|
String | macAddr | 고유 식별정보 | 필수 |
String | brand | 브랜드 명 | 필수 |
String | color | 색깔 | 선택 |
String | cpu | CPU 종류 | 선택 |
String | country | 생산지 | 선택 |
int | weight | 무게 | 선택 |
int | birth | 제조년도 | 선택 |
int | inch | 화면크기 | 선택 |
이걸 보며 문득 이런 생각이 들 수 있다.
Q. 객체 하나 생성하는데 굳이 저 정보들이 필수적으로 있어야 하나..?
다시 말해, 당장 객체를 만들어서 써야하는데
현재 저 정보들을 다 가지고 있지 않으면 어쩌냐는 말이다.
그런데 급하지 않은 정보 때문에 객체 생성을 못하고 있는건 딱히 달갑지 않다.
그래서 보통 클래스를 정의할 때,
인스턴스 생성에 꼭 필요한 변수와 굳이 필요하지 않는 변수로 구분하곤 한다.
- 필수 매개변수 - 인스턴스 생성에 꼭 필요한 변수.
- 선택 매개변수 - 인스턴스 생성에 있어도 되고 없어도 되는 변수.
이와 같은 배경지식을 가지고 나서, Item2를 제대로 시작해보자.
앞서 Item1에서, 클래스에서 인스턴스를 생성하는 역할을 하는 두 친구,
생성자 (Constructor)와 정적 팩토리 메서드 (Static Factoy Method)를 배웠다.
앞에서 두 친구가 서로 장단점을 말하며 치열하게 싸웠지만,
둘 다 가지고 있는 공통적인 문제가 하나 있다.
'선택적 매개변수가 많을 때 적절히 대응하기 어렵다' 라는 것이다.
책에서는 딱 여기까지만 말하고 그냥 본론이다.
이해가 빠른 천재분들은 찬양하며 먼저 보내드리고,
나와 같이 또 '그래서 어쨌다고....' 를 시전하는 친구들은 가만히 아래를 읽어보자.
Data type | Name | 설명 | 구분 |
---|---|---|---|
String | macAddr | 고유 식별정보 | 필수 |
String | brand | 브랜드 명 | 필수 |
String | color | 색깔 | 선택 |
String | cpu | CPU 종류 | 선택 |
String | country | 생산지 | 선택 |
int | weight | 무게 | 선택 |
int | birth | 제조년도 | 선택 |
int | inch | 화면크기 | 선택 |
자 잊을만 하면 등장하는 우리 Laptop이다.
위 표를 보며 다음 "나와 너의 (?) 대화" 를 읽어보자.
나 : Latop을 생성하기 위해서 macAddr과 brand는 무조건 필요다네요.
: 변수 두개 받는 생성자 사용하죠!너 : 네.. 그런데 이번 Laptop은 저거 두개 말고도 color도 있는데 어떡하죠?
나 : 음.. 그러면 color까지.. 변수 세개 받는 생성자 하나 더 만들게요.
너 : 네.. 그런데 또 이번 Laptop은 birth에 inch까지 있는데 어떡하죠?
: 그냥 넣지 말까요..?나 : .. 그렇다고 안넣는건 말이 안되니까.. 다섯개짜리 생성자 또 만들게요.
너 : 네.. 그런데 이번껀 macAddr에 brand에 weight만 있는데..
나 : ㅋ.. 이 클래스 변수 7개니까 그냥 모든 경우 다 생성자 만들죠 뭐.
너 : 일일이 찾아써야 되겠네요.. 어쩔수 없죠 뭐.
: 근데 다음 클래스는 선택 매개변수만 40개던데요?나 : .....
뭔가 짠내나는 대화이긴 한데 이해는 팍팍 된다.
맞다. 선택 매개변수가 많아질수록 이렇게 곤란한 상황에 처한다.
생성자와 정적 팩토리 메서드는 인스턴스를 만드는 방법에서의 차이가 있을 뿐,
이런 본질적인 문제에서는 차이가 없다.
결국 이를 해결하려면 새로운 방법이 필요하다..
사실 위의 "나와 너(?) 의 대화" 의 대화를 읽었다면,
이미 이 부분은 다 이해를 한 것이다.
정리하자면 이런 것이다.
<점층적 생성자 패턴 (telescoping constructor pattern)>
필수 매개변수만 가진 생성자
필수 매개변수 + 선택 매개변수1개 를 가진 생성자
필수 매개변수 + 선택 매개변수2개 를 가진 생성자
...
....
.....
위와 같이 매개변수를 점점 늘려가며 모조합에 대한 생성자를 생성하는 것.
관련된 예시를 바로 한번 봐보자.
다음은 식품 포장의 영양정보를 표현하는 클래스이다.
public class NutritionFacts { private final int servingSize; // (mL, 1회 제공량) 필수 private final int servings; // (회, 총 n회 제공량) 필수 private final int calories; // (1회 제공량당) 선택 private final int fat; // (g/1회 제공량) 선택 private final int sodium; // (mg/1회 제공량) 선택 private final int carbohydrate; // (g/1회 제공량) 선택 public NutritionFacts(int servingSize, int servings) { this.sevingSize = servingSize; this.servings = servings; } public NutritionFacts(int servingSize, int servings, int calories) { this.sevingSize = servingSize; this.servings = servings; this.calories = calories; } public NutritionFacts(int servingSize, int servings, int calories, int fat) { this.sevingSize = servingSize; this.servings = servings; this.calories = calories; this.fat = fat; } public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) { this.sevingSize = servingSize; this.servings = servings; this.calories = calories; this.fat = fat; this.sodium = sodium; } public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) { this.servingSize = servingSize; this.servings = servings; this.calories = calories; this.fat = fat; this.sodium = sodium; this.carbohydrate = carbohydrate; } }
public static void main(String[] args) { NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27); }
어떤가? (사실 빠르게 코딩 많이 한 것 같아서 기분은 좋다.)
하지만 생각만 해도 매우 단무지(?) 스러운..
비효율적인 발상이라는건 누구나 알 것이다.
그런데 실제로 지금까지 전통적으로 제일 즐겨써오던 방식이 바로 이거다.
(가만두면 우리도 그럴거 같으니까 필자가 책을 쓴거겠지.)
여튼 지금이야 변수가 6개 뿐이라서 "6개 정도면 그냥 해도 괜찮지 않나..?"
라고 생각할 수는 있다.
Comment.
사실 엄밀히 말해서 변수가 6개라고 생성자가 6개만 필요한 것도 아니다.
대충 모든 조합을 생각해봐도
(6C1 + 6C2 + 6C3 + 6C4 + 6C5 + 6C6 = 58)개 정도는 필요하다.
그런데 실제로 상용화 되고있는 서비스들의 코드를 보면,
변수가 100개도 넘는 클래스는 차고 넘친다.
딱히 다들 상상하고 싶지는 않을테니 마저 이야기를 이어가면..
결국 개발자가 원하는 조합을 정확하게 가진는 생성자는 기대하기 어렵고,
그와 비슷한 생성자를 직접 찾아서 써야한다.
바로 위의 예시 일부를 다시 보면,
public static void main(String[] args) { NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27); }
네번째 매개변수에 0을 넣은 이유는 현재 개발자에게 fat 정보가 없기 때문이다.
그런데 fat만 제외한 조합이 없기 때문에,
가장 비슷한 생성자를 찾고 fat에다가는 임의값을 넣은것이다.
지금이야 하나만 빠진 매우 유사한 생성자를 찾았으니 다행이지만,
변수가 많아질수록 문제는 심화된다.
물론 여지껏 써왔던 만큼, 이 방법도 쓰려면 쓸 수는 있다.
하지만 클라이언트 측면에서 코드를 작성하기도 어렵고 읽기도 어렵다는 것이다.
<점층적 생성자 패턴의 단점>
- 사용자가 원하는 매개변수 조합을 가진 생성자는 기대할 수 없다.
- 수많은 생성자 중 유사한 것을 찾아보는 것은 너무 힘든 일이다.
- 개발자가 생성자 사용에 있어 실수할 가능성이 높다.
- 이는 결국 컴파일러에 걸리지 않는 런타임 에러를 발생시킬 수 있다. (디버그도 힘들지 않은가..)
어쨋든 점층적 생성자 패턴이 별로이므로 조금 다른 방법을 생각해본다.
점층적 생성자 패턴은 매개변수의 조합을 맞추는 것이 포인트였다면,
이번엔 객체를 만든 후에 매개변수를 주입하는 것이 포인트다.
<자바빈즈 패턴 (JavaBeans pattern)>
- 매개변수가 없는 생성자를 사용한다.
- 생성자를 통해 객체를 만든다.
- Setter 메서드를 통해 매개변수들을 하나씩 주입한다.
뭐 원리는 간단한 것 같으니 바로 예제 코드를 보자.
public class NutritionFacts { // 매개변수들은 (기본값이 있다면) 기본값으로 초기화된다. private int servingSize = -1; // 필수; 기본값 없음 private int servings = -1; // 필수; 기본값 없음 private int calories = 0; private int fat = 0; private int sodium = 0; private int carbohydrate = 0; public NutritionFacts() { } // Setters public void setServingSize(int val) { servingSize = val; } public void setServings(int val) { servings = val; } public void setCalories(int val) { calories = val; } public void setFat(int val) { fat = val; } public void setSodium(int val) { sodium = val; } public void setCarbohydrate(int val) { carbohydrate = val; }
public static void main(String[] args) { NutritionFacts cocaCola = new NutritionFacts(); cocaCola.setServingSize(240); cocaCola.setServings(8); cocaCola.setCalories(100); cocaCola.setSodium(35); cocaCola.setCarbohydrate(27); } }
확실히 점층적 생성자 패턴에서 고민했던 것들이 해결되었다.
매개변수 조합을 신경쓸 필요도 없고, 코드도 훨씬 가독성이 좋아보인다.
하지만.. 이 패턴에도 좀 더 심각한 문제들이 발생한다.
잠시 클라이언트 측의 코드를 다시 살펴보자.
public static void main(String[] args) { NutritionFacts cocaCola = new NutritionFacts(); cocaCola.setServingSize(240); cocaCola.setServings(8); cocaCola.setCalories(100); cocaCola.setSodium(35); cocaCola.setCarbohydrate(27); } }
가독성은 좋다. 깔끔하다. 그런데 뭔가 찝찝하다.
Q. 객체 하나를 생성하는데 이렇게나 많은 메서드를 호출해야 하는 것일까..?
지금이야 5개인데 주입할 매개변수가 100개라면?
일일이 다 넣고있기엔 좀 그렇지 않은가.
물론 전부 Setter 메서드이긴 하지만,
개발자 측면에서는 일일이 하나하나 호출하는건 조금 귀찮고 잡일스럽다.
자바빈즈 패턴은 시스템적으로도 문제점을 가지고 있다.
점층적 생성자 패턴의 경우 적절한 생성자 검증이 귀찮긴 하지만,
매개변수들을 이용해 한번에 생성하므로 그 객체의 일관성이 유지된다.
그런데 자바빈즈 패턴의 경우 객체를 일단 생성해놓고 변수를 하나하나 주입한다.
즉, 객체 생성은 되었어도 매개변수 주입 과정이 끝나지 않으면,
객체는 일관성(Consistency)이 무너진 상태가 된다.
쉽게 말해 매개변수가 하나 추가 될 때마다 인스턴스가 변한다는 말이다.
(사실 이게 그렇게 큰 문제인지는 모르겠다.)
다만, 일관성이 무너지므로 불변 클래스로 만드는 것도 불가능하다.
<자바빈즈 패턴의 단점>
- 객체 생성 후, Setter 메서드를 하나하나 호출하는 것은 불편하다.
- 매개변수 주입 과정이 끝나기 전까지 객체의 일관성이 무너진다.
- 그러므로 클래스를 불변 타입으로 만드는 것이 불가능해진다.
잠시 정리를 해보자.
점층적 생성자 패턴은 분명 시스템적으로 안전한 것이 장점이다.
그렇지만 너무 단순하기 때문에 불편함이 증대된다는 단점은 개선의 여지가 없다.
자바빈즈 패턴은 가독성도 좋고 유연하다는 것이 장점이다.
그렇지만 시스템적으로 불안정하다는 것과 사용하기 불편하다는 것이 치명적이다.
이러한 맥락에서 등장한 것이 바로 빌더 패턴 (Builder Pattern) 이다.
<빌더 패턴 (Builder pattern)>
- 사용자는 필수 매개변수만으로 생성자(혹은 정적 팩토리 메서드)를 호출해 빌더 객체를 얻는다.
- 빌더 객체가 제공하는 (Setter 메서드와 유사한) 것으로 선택 매개변수를 주입한다.
- 매개변수가 없는 build 메서드를 호출하여 필요한 객체를 얻는다.
(빌더는 생성할 클래스 안에 정적 멤버 클래스로 만들어두는게 보통이다.)
매개변수를 직접 주입하기 때문에 분명 유연해질 수 있다.
또 객체를 미리 생성하지 않고 나중에 한번에 생성하기 때문에,
일관성(Consistency)이 무너지지도 않는다.
결국 점층적 생성자 패턴의 안정성과 자바빈즈 패턴의 가독성,
둘의 장점을 모두 취한 것이다.
다음은 빌더 패턴의 예시 코드이다.
public class NutritionFacts { private final int servingSize; private final int servings; private final int calories; private final int fat; private final int sodium; private final int carbohydrate; public static class Builder { // 필수 매개변수 private final int servingSize; private final int servings; // 선택 매개변수 - 기본값으로 초기화한다. private int calories = 0; private int fat = 0; private int sodium = 0; private int carbohydrate = 0; public Builder(int servingSize, int servings) { this.servingSize = servingSize; this.servings = servings; } public Builder calories(int val) { calories = val; return this; } public Builder fat(int val) { fat = val; return this; } public Builder sodium(int val) { sodium = val; return this; } public Builder carbohydrate(int val) { carbohydrate = val; return this; } public NutritionFacts build() { return new NutritionFacts(this); } } private NutritionFacts(Builder builder) { servingSize = builder.servingSize; servings = builder.servings; calories = builder.calories; fat = builder.fat; sodium = builder.sodium; carbohydrate = builder.carbohydrate; }
public static void main(String[] args) { NutritionFacts cocaCola = new NutritionFacts .Builder(240, 8) .calories(100) .sodium(35) .carbohydrate(27) .build(); } }
빌더 패턴을 구현하면 위와 같은 형식이 된다.
빌더라는 정적 클래스를 선언한 뒤,
그 내에는 빌더의 생성자와 각 변수의 Setter역할을 하는 메서드들을 넣어준다.
구현법이 크게 어렵지도 않고,
클라이언트 코드도 사용법이 간결하며 가독성도 너무 좋아보인다.
또한 이때 NutritionFacts 라는 클래스는 불변(Immutable)이 된다.
한편 조금 특징적인 모습이 보이는데, 잠깐 클라이언트 코드를 다시 살펴보자.
public static void main(String[] args) { NutritionFacts cocaCola = new NutritionFacts .Builder(240, 8) .calories(100) .sodium(35) .carbohydrate(27) .build(); } }
빌더 객체를 생성하면서 자연스럽게 그 하위의 메서드들을 순차적으로 호출하고 있다.
이런 방식 자체가 가능한 이유는,
빌더의 Setter 역할을 하는 메서드들이 자기 스스로를 반환하기 때문이다.
위와 같은 방식을
플루언트 API (Fluent API), 혹은 메서드 연쇄 (Method Chaining) 이라고 부른다.
한편, 위 코드에서는 핵심만 보이도록 하기 위해 유효검 검사는 하지 않았지만,
원래는 유효성 체크를 해줘야한다.
<빌더 패턴의 유효성 체크>
- 빌더의 생성자와 메서드에서 매개변수 검사.
- build 메서드가 호출하는 생성자에서 여러 매개변수에 대한 불변식 검사.
- 공격에 대비해 불변식을 보장.
- 검사를 통해 구체적으로 어떤 매개변수가 잘못되었는지를 알려주는 IllegalArgumentException을 이용
Comment.
불변 이란?
- Immutable 혹은 Immutability
- 어떠한 변경도 허용하지 않는다는 뜻
- 주로 가변(Mutable)객체와 구분하는 용도로 사용
- Ex) String 객체는 한번 만들어지면 절대 값을 바꿀 수 없는 불변 객체.
불변식 이란?
- 만드시 만족해야 하는 조건
- 변경을 허용할 순 있으나, 주어진 조건 내에서만 허용.
- Ex) 리스트의 크기는 변할 수 있어도 어떤 때에도 반드시 0 이상이어야 함.
- 가변 객체에도 불변식은 존재할 수 있음.
- 불변은 불변식의 극단적인 예
빌더 패턴이 무엇이고, 빌더 패턴을 사용할 때의 유의할 점까지 알아봤다.
장점을 많이 취할 수 있으므로 분명 일반적으로도 좋은 패턴임은 알겠으나,
그 중에도 유독 빌더 패턴이 빛을 발하는 때가 있다.
바로 계층적으로 설계된 클래스일 때다.
여기서 계층적이라는 뜻은,
마치 음식 레시피와 같이 A를 넣고 B를 넣고 C를 넣고... 의 느낌이다.
이게 말로는 잘 설명이 안되는데 직접 예시 코드를 보면 조금 느낌이 온다.
public abstract class Pizza { public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE } final Set<Topping> toppings; abstract static class Builder<T extends Builder<T>> { EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class); public T addTopping(Topping topping) { toppings.add(Objects.requireNonNull(topping)); return self(); } abstract Pizza build(); // 하위 클래스는 이 메서드를 재정의(overriding)하여 // "this"를 반환하도록 해야 한다. protected abstract T self(); } Pizza(Builder<> builder) { toppings = builder.toppings.clone(); // 아이템 50 참조 } }
위와 같은 Pizza를 상속받는 하위 클래스 두 개가 있다.
하나는 일반적인 뉴욕 피자이고, 하나는 깔조네 피자이다.
뉴욕 피자는 크기(size)를 필수 매개변수로 하고,
깔조네 피자는 소스유무 (sauceInside)를 필수 매개변수로 한다.
일단 뉴욕피자의 코드이다.
public class NyPizza extends Pizza { public enum Size { SMALL, MEDIUM, LARGE } private final Size size; public static class Builder extends Pizza.Builder<Builder> { private final Size size; public Builder(Size size) { this.size = Objects.requireNonNull(size); } @Override public NyPizza build() { return new NyPizza(this); } @Override protected Builder self() { return this; } } private NyPizza(Builder builder) { super(builder); size = builder.size; } @Override public String toString() { return toppings + "로 토핑한 뉴욕 피자"; } }
다음은 깔조네 피자의 코드이다.
public class Calzone extends Pizza { private final boolean sauceInside; public static class Builder extends Pizza.Builder<Builder> { private boolean sauceInside = false; // 기본값 public Builder sauceInside() { sauceInside = true; return this; } @Override public Calzone build() { return new Calzone(this); } @Override protected Builder self() { return this; } } private Calzone(Builder builder) { super(builder); sauceInside = builder.sauceInside; } @Override public String toString() { return String.format("%s로 토핑한 칼초네 피자 (소스는 %s에)", toppings, sauceInside ? "안" : "바깥"); } }
그렇다면 클라이언트 코드는 어떻게 될지도 생각해볼 필요가 있다.
public class PizzaTest { public static void main(String[] args) { NyPizza pizza = new NyPizza .Builder(SMALL) .addTopping(SAUSAGE) .addTopping(ONION) .build(); Calzone calzone = new Calzone .Builder() .addTopping(HAM) .sauceInside() .build(); System.out.println(pizza); System.out.println(calzone); } }
클라이언트 코드를 보는 순간 느껴질 것이다.
'아 계층적 클래스라는게 저걸 말하는 거구나, 어울리네'
약간 피자 추문할 때 이것저것 설정을 추가하듯이 단계적으로 쌓이는 듯한 느낌이 든다.
빌더 패턴 특유의 메서드 연쇄 호출로 인한 코드 가독성 때문인지로 모르겠다.
위 코드를 보며 알면 좋을 점이 하나 있는데,
NyPizza.Builder와 Calzone.Builder는 Pizza를 상속하고 있지만,
반환은 상위 클래스가 정한것이 아닌 자신의 것으로 반환하고 있다.
이처럼 하위 클래스가 상위 클래스의 것이 아닌 자신의 것으로 반환하는 기능을
공변 변환 타이핑 (covariant return typing)라고 부른다.
뭐 외울 필요는 없고 여기서 중요한 것은,
이렇게 하게 되면 클라이언트 측에서 굳이 형변환을 하지 않아도 된다는 것이다.
지금까지 빌더 패턴의 장점에 대해서 살펴 봤다.
하지만 꼭 장점만 있는 것은 아니다.
눈치 챘겠지만, 객체를 만들려고 하면 일단 빌더 코드를 따로 짜야한다.
물론 빌더 코드 자체가 그렇게 기회비용이 높지는 않지만,
아주 미세한 성능 차이도 민감한 상황이라면 조금 문제가 될 수 있다.
또 굳이 매개변수 갯수가 많지 않은데 빌더 패턴을 쓸 이유는 없다.
적어도 매개변수 갯수가 4개는 되야 사용하는 의미가 있다.
하지만, API는 처음 만들때가 중요한게 아니라 시간이 갈수록 비대해지곤 한다.
처음엔 생성자나 정적 팩토리 메서드로 만들었다가 나중에 빌더 패턴으로 바꿀수도 있긴 하지만,
그럴거면 그냥 시작부터 빌더로 시작하는게 나을수도 있다.
<빌더 패턴의 장점>
- 점층적 생성자 패턴처럼 객체 안정성이 있다.
- 자바빈즈 패턴처럼 코드 가독성이 좋고 유연하다.
<빌더 패턴의 단점>
- 빌더에 대한 코드를 따로 작성해야 한다.
- 매개변수가 4개 이상일 때 그 효과가 좋다.
여기까지 빌더 패턴에 대한 내용이였다.
앞 글과 마찬가지로, 필자의 코멘트로 글을 마무리 한다.
Item2 정리
- 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 것이 낫다.
- 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 간결하다.
- 자바빈즈보다 훨씬 안전하다.