-
BuilderCS/Java (with Effective) 2022. 2. 6. 14:07
해당 글은 Effective Java Item2를 기반으로 합니다.
생성자에 매개변수가 많다면 빌더를 고려하라.
이번 글에선 알고리즘을 풀며, 코드를 어떻게 변경해 나갔고 결론적으로 왜 빌더가 필요하게 됐는지 과정을 보여드립니다.
빌더 구현을 바로 보시려면 'Builder 구현'을 검색해주세요.
어느 언어나 마찬가지겠지만, 자바로 알고리즘 문제를 풀다보면 여러 클래스를 선언하게 됩니다. 따라서, 본래는 클래스 중복을 피하고 사용하기 위해 이너클래스로 선언해 사용을 했었습니다.
근데, 조금은 객체지향적으로 코드를 구성하고 싶다 생각이 들었고, 공통 코드를 구성해 알고리즘 문제를 풀게되었습니다.
예를 들면 초기 코드는 아래와 같습니다.
import java.util.LinkedList; import java.util.Queue; public class Main { private static final int[][] DIRECTIONS = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; private static final int ROW = 0; private static final int COL = 1; private static class Point{ // inner class int row; int col; public Point(int row, int col) { this.row = row; this.col = col; } } public static void main(String[] args) throws Exception{ // ... 입력 및 출력 } private static int bfs(int n, int m, int[][] arr, char target) { boolean[][] isVisited = new boolean[m][n]; int answer = 0; Queue<Point> q = new LinkedList<>(); // .. BFS return answer; } }
중복되는 클래스를 매번 선언할 수 있다는 장점이 있습니다. private이므로 해당 클래스에서만 접근 가능하구요.
단점은 클래스의 역할 분리가 안되어있죠.. (물론 알고리즘 문제 코드긴하지만요.)
또한, 클래스 멤버를 private으로 선언해도, 내부에선 실수로 다른 값을 넣어 데이터가 변경될 여지를 막지 못합니다. 이에 따라 getter도 따로 필요없죠. 어차피 값 접근 및 수정이 가능하니까요.
그래서 class를 외부로 빼고, 해당 클래스 밖에서 값 변경을 막을 수 있도록 코드를 변경했습니다.
import java.util.LinkedList; import java.util.Queue; public class Main { private static final int[][] DIRECTIONS = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; private static final int ROW = 0; private static final int COL = 1; public static void main(String[] args) throws Exception{ // ... 입력 및 출력 Point p = new Point(0, 0); System.out.println(p.getRow() + " " + p.getCol()); } private static int bfs(int n, int m, int[][] arr, char target) { boolean[][] isVisited = new boolean[m][n]; int answer = 0; Queue<Point> q = new LinkedList<>(); // .. BFS return answer; } } class Point { private final int row; private final int col; public Point(int row, int col) { this.row = row; this.col = col; } public int getRow() { return row; } public int getCol() { return col; } }
이렇게 코드를 구성하면 클래스가 역할에 따라 분리되어 있고, 외부에서 즉 메인 함수에서 필드의 값을 멋대로 바꿀 수 없게됩니다.
이전 코드보단 훨씬 나은 코드라고 느껴지는데, 그래도 아쉬운 점은 코드가 한 곳에 묶여있고 매번 같은 Point 클래스를 정의해야한다는 점입니다. 게다가, 똑같은 Point 클래스인데, 클래스 명은 매번 바꿔 선언해야하구요.
상당히 번거로웠겠죠?
따라서 해당 코드를 common이라는 패키지를 만들어 따로 빼고 호출하는 방식으로 변경했습니다.
외부 패키지에서도 호출 가능해야하기 때문에 클래스의 접근한정자는 public으로 설정해줍니다.
package common; public class Point { private final int row; private final int col; public Point(int row, int col) { this.row = row; this.col = col; } public int getRow() { return row; } public int getCol() { return col; } }
기존 코드는 common 패키지의 Point만 import해서 사용하면됩니다.
물론, 알고리즘 문제 제출시에는 해당 클래스를 밑에 일일이 붙여줘야하지만, 그 정도는 해줍시다. ㅎㅎ
import common.Point; import java.util.LinkedList; import java.util.Queue; public class Main { private static final int[][] DIRECTIONS = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; private static final int ROW = 0; private static final int COL = 1; public static void main(String[] args) throws Exception{ // ... 입력 및 출력 } private static int bfs(int n, int m, int[][] arr, char target) { boolean[][] isVisited = new boolean[m][n]; int answer = 0; Queue<Point> q = new LinkedList<>(); // .. BFS return answer; } }
문제는 여기서 끝난 것이 아닙니다.
이너 클래스를 사용할 땐 어차피 문제에 따라 매번 일일이 클래스를 구현했기 때문에 몰랐지만, 현재는 공통 코드로 빼두었잖아요. 따라서, 공통으로 쓰는 클래스인 만큼 범용적으로 사용 가능하도록 유도해야하는데, 그것이 어려워졌습니다.
왜냐하면, row, col 정보만 가지고 있어도 풀리는 문제가 있는 반면, 어떤 문제는 cost도 멤버로 필요한 경우가 있기 때문이죠.
물론, 아래처럼 구현할 수는 있겠습니다.
package common; public class Point { private final int row; private final int col; private final int cost; public Point(int row, int col) { this.row = row; this.col = col; } public Point(int row, int col, int cost) { this.row = row; this.col = col; this.cost = cost; } public int getRow() { return row; } public int getCol() { return col; } public int getCost() { return cost; } }
근데, 계속해서 필요한 멤버 변수가 바뀌는 과정에서 여러개의 생성자를 사용한다면, 코드의 가독성이 매우 안 좋아지는 결과를 초래할 수 있을 것이라 생각되었습니다.
위의 코드만 봐도 어떤 건 row, col만 포함하고 어떤 것은 cost까지 포함하는데, 이름은 똑같이 Point이니 말이죠..
저는 그 대안으로 정적 팩토리 메서드(static factory method)를 적용하게됩니다.
생성자를 여러개 선언하 듯 쓸 수 있고, 이에 더해 따로 이름도 지어 구분할 수 있기 때문이죠.
package common; public class Point { private final int row; private final int col; private final int cost; public Point(int row, int col) { this.row = row; this.col = col; } private Point(int row, int col, int cost) { this.row = row; this.col = col; this.cost = cost; } public static Point pointWithCost(int row, int col, int cost) { return new Point(row, col, cost); } public int getRow() { return row; } public int getCol() { return col; } public int getCost() { return cost; } }
정적 팩토리 메서드는 명확한 이름을 지어주어 밖으로 드러내고, 이에 해당하는 생성자는 private으로 처리해 숨겨줌으로써 좀 더 가독성 좋은 코드를 의도할 수 있었습니다.
변수명이 그렇게 정성스럽진 못한 점은 양해 부탁드립니다.. ㅎㅎ
그런데, 어떤 필드는 필요하고 어떤 필드는 필요없고 이런 경우들을 모두 케어하기 위해 종류별로 생성자 및 정적 팩토리 메소드를 계속 선언할 순 없는 노릇입니다. 또한, 파라미터가 많아질수록 생성자 호출하는데 있어 어떤 데이터가 어디에 들어가야하는지도 파악이 어렵습니다.
그래서 결국 여기서 Builder를 적용하게됩니다. 참 많이 돌아왔지만, 그 만큼 필요성이 강하게 느껴졌습니다. ㅎㅎ
Builder 구현
package common; public class Point { private final int row; private final int col; private final int cost; public static class Builder { // must private final int row; private final int col; // opt private int cost; public Builder (int row, int col) { this.row = row; this.col = col; } public Builder cost(int value) { cost = value; return this; } public Point build() { return new Point(this); } } private Point(Builder builder) { row = builder.row; col = builder.col; cost = builder.cost; } public static Point pointWithCost(int row, int col, int cost) { return new Point.Builder(row, col) .cost(cost) .build(); } public int getRow() { return row; } public int getCol() { return col; } public int getCost() { return cost; } }
구현이 조금은(?) 복잡한데, 살펴봅시다.
정적 팩토리 메소드는 사실 없어도 되는 코든데, 빌더를 어떻게 사용할지 보여줄 수 있는 코드라 두었습니다.
- Builder를 이너클래스로 선언했습니다. public임은 당연하겠죠. 다른 패키지의 클래스에서 Builder를 호출해 써야하니까요.
- 빌더 생성시 필수로 들어가야하는 멤버는 final로 선언하고 생성자 호출시 바로 값 할당이 되도록 구현합니다.
- 필수가 아닌 멤버는 setter 처럼 파라미터가 들어오면 값을 설정하도록 하고, 연쇄적으로 Builder를 호출할 수 있게 구현합니다.
- 최종적으로 build 메소드를 호출해, 이제껏 생성한 Builder를 Point 클래스의 private 생성자에 담아 반환합니다.
- 이렇게 생성된 Builder는 정적 팩토리 메소드의 코드처럼 사용할 수 있습니다.
위에서 말씀드렸던 문제점들을 모두 케어할 수 있는 유연한 코드가 되었고, 필요한 멤버가 늘어나도 필수 값인지 아닌지만 구분해 추가해주면 다른 클래스에도 적용이 가능합니다.
이번엔 이렇게 Builder를 구현했지만, Lombok에서 '@Builder'를 지원합니다. 개발할 땐 해당 annotation을 사용하시면 될 것 같습니다.
여전히 문제는 존재합니다. 예를 들면, cost가 현재는 int로 선언되어 있지만, long, double로 필요한 경우도 있을테니까요. 이는 Generic을 이용해 구현해보면 될 듯한데, 아직 해당 파트는 자세히 안 봤으니 그때 가서 다시 살펴보도록 하겠습니다.
겨우 알고리즘 푸는데 이런 번거로운 일을 왜하나 생각하실 수도 있습니다만, 좋은 습관이 계속되다보면 좋은 사람이 될 수 있다 생각합니다. 좋은 코드를 위해서 계속해서 고민해보고 노력하다보면, 좋은 코드를 짤 수 있는 개발자가 될 수 있지 않을까요.
물론 저도 필요성을 제대로 느끼지 못하면 시작조차 안하는 타입이긴해서.. 할 말은 없는데요.. ㅎㅎㅎ; 제 경험이 어떤 분에겐 좋은 계기나 필요성을 느끼게된 과정이 된다면 더 좋을 것 같네요.
부족한 부분이나 관련해서 댓글 주시면 수정하고 보완 해보겠습니다. 감사합니다.
참고자료
Effective Java - 조슈아 블로크
반응형'CS > Java (with Effective)' 카테고리의 다른 글
Generic과 raw-type 그리고 비검사 경고 (0) 2022.04.28 Composition vs extends (0) 2022.03.02 HashCode (0) 2022.02.27 Singleton (0) 2022.02.08 Static Factory Method (0) 2022.01.13