Java 정적 팩토리 메서드 vs 팩토리 클래스

Libienz·2024년 12월 17일


사다리 게임이라는 우아한테크코스 미션을 수행하고 리뷰어와 이야기를 나눠보면서 객체의 생성 로직을 어디에 응집해야 할지 많은 생각을 해볼 수 있었다. 더 나아가 객체를 생성하는 쪽과 사용하게 되는 쪽 사이의 "계약"이 어떤 방식으로 이루어져야하는지 학습할 수 있었고 객체 지향에 대해서도 한 단계 더 깊은 고민을 해볼 수 있었다. 구현을 이리저리 바꾸어보기도 하고 책도 읽으면서 나름의 결론을 내려보았는데, 객체지향에 대한 학습 내용을 기록할 겸, 포스팅으로서 작성해보고자 한다.


객체의 생성 로직을 추상화하는 두 가지 방법

사다리 게임을 Java로 구현하는 경우를 생각해보자. 사다리 게임은 참여하는 인원 수와 주어진 높이에 맞게 만들어져야 한다. 또한 사다리 생성 전략인 RowLineGenrator를 통해 사다리를 구현해야 함으로 사다리 생성 로직은 다음처럼 복잡해질 수 있다.

복잡한 사다리 생성 로직


    public Ladder createLadder(RowLineGenerator rowLineGenerator, int personCount, Height height) {
        List<RowLine> lines = IntStream.range(0, height.getHeight())
                .mapToObj(i -> rowLineGenerator.generate(personCount))
                .toList();
        return new Ladder(lines);
    }

위의 로직은 사다리의 높이 만큼 RowLine을 생성한 다음 List로 Integration한 후에 이를 기반으로 Ladder 객체를 생성하는 로직이다. 생성 로직이 꽤나 복잡한 편으로 Naive하게 Ladder 클래스의 생성자에 위치시키기는 부담스러운 면이 있다. (생성자의 책임을 어디까지 보느냐에 따라 다를 수 있겠지만 필자는 생성자의 책임을 할당과 검증으로 생각하기에 생성자의 책임을 벗어났다고 판단했다)

복잡한 생성로직을 추상화하고 싶다. 그렇다면 다음의 두가지 방법들이 떠오른다.

1. 정적 팩토리 메서드를 활용한 생성 로직 추상화

public class Ladder {
   List<RowLine> rowLines; 
 	
   private Ladder(List<RowLine> rowLines) {
 		this.rowLines = rowLines;
 	}
 
 	public static Ladder createFrom(RowLineGenerator rowLineGenerator, int personCount, Height height) {
 	    List<RowLine> lines = IntStream.range(0, height.getHeight())
               .mapToObj(i -> rowLineGenerator.generate(personCount))
               .toList();
       return new Ladder(lines);
 	}
}
Ladder ladder = ladderCreator.create(..);

2. 팩토리 클래스(LadderCreator)를 활용한 생성 로직 추상화


 public class LadderCreator {
    public Ladder createLadder(RowLineGenerator rowLineGenerator, int personCount, Height height) {
       List<RowLine> lines = IntStream.range(0, height.getHeight())
               .mapToObj(i -> rowLineGenerator.generate(personCount))
               .toList();
       return new Ladder(lines);
   }
 }

여러분들은 복잡한 생성로직을 어디에 추상화 할 생각인가? 1번 처럼 정적 팩토리 메서드를 사용해볼 수도 있을 것이고 2번 처럼 팩토리 클래스를 활용해서 추상화해볼수도 있을 것이다. 현재는 요구사항이 복잡하지 않고 객체 볼륨이 작아 어느 쪽의 선택을 취하든 큰 위화감이 없을지도 모르겠다. 하지만 훗날에 거대한 요구사항을 마주할 우리는 내구성이 높은 객체 설계에 대해서 끊임 없이 고민해야한다.


1. 정적 팩토리 메서드를 활용한 생성 로직 추상화

내가 처음에 선택했던 방식이다. 생성 로직이 Ladder 클래스 밖에 있으면 다른 개발자가 생성 규칙을 추적하기 어려워 인지 비용이 발생할 수 있다고 생각했기 때문에 이러한 방식을 선택했다. 하지만 내가 가장 크게 느낄 수 있었던 주요한 장점은 다음의 장점들이다.

정적 팩토리 메서드 방식의 장점들

1. 생성 로직 일원화를 통한 도메인 생성 규칙 준수 강제화

첫번째로 정적 팩토리 메서드를 사용하면 생성 로직을 한 곳으로 일원화하여 도메인 생성 규칙 준수를 강제화 할 수 있다.

정적 팩토리 메서드 하나로 일원화된 생성 로직

public class Ladder {
   List<RowLine> rowLines; 
 	 
   // 생성자 접근 제어 수준 강화
   private Ladder(List<RowLine> rowLines) {
 		this.rowLines = rowLines;
 	}
  // 하나로 좁혀진 객체 생성 방법
 	public static Ladder createFrom(RowLineGenerator rowLineGenerator, int personCount, Height height) {
 	    List<RowLine> lines = IntStream.range(0, height.getHeight())
               .mapToObj(i -> rowLineGenerator.generate(personCount))
               .toList();
       return new Ladder(lines);
 	}
}
Ladder ladder = ladderCreator.create(..);

정적 팩토리 메서드를 활용한 위의 코드는 도메인을 생성하는 방법을 하나의 방법으로 좁히고 있다. 우리는 Ladder를 생성하려면 Ladder 객체에 있는 createFrom이라는 정적 팩토리 메서드를 활용할 수 밖에 없다. 이는 해당 객체를 사용하는 클라이언트로 하여금 객체를 생성하고 사용하는 방식을 통제하는 좋은 수단이 될 수 있다.

정적 팩토리 메서드를 사용하지 않고 LadderCreator라는 팩토리 클래스를 운용하는 경우를 생각해보자. 이 경우 Ladder를 생성할 수 있는 방식은 여러가지가 될 수 있다.

  • Ladder의 생성자를 통해 생성
  • LadderCreator의 메서드를 통해 생성

이는 Ladder 객체를 생성하고자 하는 다른 클라이언트에게 어느 API를 사용해야할지 혼선을 줄 수 있다. 그리고 더 주요하게 도메인 생성 규칙을 준수하지 않은 객체의 가능성을 닫지 않는다.

사다리 게임에서 Ladder 객체는 정해진 규칙대로(RowLineGenerator를 통해)생성 되어야 한다. 팩토리 클래스를 운용하기 위해 Ladder의 생성자가 열려있다면 이는 생성 규칙에 어긋나는 Ladder객체가 생성될 가능성이 존재함을 시사하고 다른 개발자의 실수로 인한 시스템 오류 가능성을 존재하게 하는 것이다.

생성 규칙에 어긋나는 Ladder 객체 생성 예시

RowLine emptyRowLine1 = RowLine.emptyRowLine();
RowLine emptyRowLine2 = RowLine.emptyRowLine();
RowLine emptyRowLine3 = RowLine.emptyRowLine();

// 랜덤하게 생성되어야 하는 사다리의 생성 규칙을 무시한 사다리 객체 생성
Ladder illegal = new Ladder(List.of(emptyRowLine1, emptyRowLine2, emptyRowLine3);

2. 유연한 확장성

두번째로 정적 팩토리 메서드를 사용하면 일원화된 생성 로직으로 확장 요구사항에서 유연하다.
예로 Ladder에 반드시 생성시에 초기화 되어야 하는 새로운 필드가 추가되는 확장 요구사항을 생각해보자. LadderCreator를 운용하는 경우는 Ladder의 생성자와 LadderCreator의 메서드를 같이 수정해야 하는 경우가 생길 수 있다. 이는 하나의 변경을 위한 수정지점이 퍼져있고 이를 관리하기 어렵게 한다. 반면 정적 팩토리 메서드를 사용하는 경우는 관리 지점이 일원화되어 유지보수 내성이 높아진다.

정적 팩토리 메서드 방식의 단점들

하지만 정적 팩토리 메서드를 사용하는 방식에도 위와 같은 장점만 존재하는 것은 아니다. 다음의 단점들을 살펴보자.

1. 낮아진 객체 유연성으로 인한 테스트 코드 작성 난이도 상승

첫번째로 Ladder를 생성하는 로직을 일원화하기 위해서 생성자의 접근 제어자를 수정하는 경우 객체의 자유도가 크게 떨어져 모듈성이 낮아지고 로직의 결합도가 필요 이상으로 높아질 수 있다. 이로 인해서 테스트 하기 어려운 코드가 발생할 수 있고, 객체의 재활용성이 크게 낮아질 수 있다.

복잡한 로직의 높은 응집도로 인한 객체 자율성이 떨어지는 예시 (테스트 객체 구성의 어려움)

class LadderTest {

    @Test
    void ladderCreationIsDifficult() {
        // 테스트용 Mock 생성
        RowLineGenerator rowLineGenerator = mock(RowLineGenerator.class);

        // Mock 동작 설정
        when(rowLineGenerator.generate(anyInt())).thenReturn(new RowLine());

        // 단지 Ladder를 만들기 위해 많은 준비가 필요
        Ladder ladder = Ladder.createFrom(
            rowLineGenerator,
            5,
            new Height(10)
        );

        // 실제 테스트 로직
        assertThat(ladder).isNotNull();
        // 여기까지 오기 위해 필요 이상으로 많은 설정과 Mocking 수행
    }
}

물론 생성자의 접근 제어 수준을 완화해서 이러한 문제를 해결할 수도 있다. 하지만 이는 생성 로직을 일원화하고 도메인 생성 규칙 준수를 강제한다는 정적 팩토리 메서드의 장점을 희석시키는 반쪽짜리 해결책이다.

2. 객체의 책임 비대화

두번째로 정적 팩토리 메서드를 사용하는 경우 객체의 책임이 거대해질 수 있다.

  • 사다리의 높이 만큼 RowLine을 생성
  • List로 Integration

객체의 책임도 개발자가 어디까지 보는지 그 시선에 따라 해석이 다를 수 있지만, RowLine을 생성하고 List로 Integration하는 것은 Ladder 클래스의 책임으로 보기 힘들다.

SRP라는 객체지향 설계 원칙이 높은 유지보수성으로 이어짐을 감안한다면 정적 팩토리 메서드를 재고할만한 충분한 이유가 될지도 모르겠다.


2. 팩토리 클래스 운용을 통한 생성 로직 응집

지금까지 정적 팩토리 메서드를 활용해서 생성 로직을 응집하는 경우의 장단점을 살펴보았다. 이를 정확히 반대로 뒤집으면 팩토리 클래스를 운용하는 것의 장단점을 알 수 있다. 하나씩 살펴보자.

팩토리 클래스 운용 방식의 장점

객체 자율성을 높이고 객체 협력 구조를 세분화한다.

생성 로직을 팩토리 클래스로 응집하면 객체 자율성을 높이고 객체 협력 구조를 세분화 할 수 있다. 팩토리 클래스를 통해 생성 로직을 응집시키면 Ladder 객체 자체는 자신의 도메인 규칙과 상태를 유지하는 데 집중하고, 생성 과정의 복잡한 로직 등은 외부 객체에게 위임 가능하다.

이를 통해 객체의 자율성을 강화하고 테스트 용이성을 개선하며 유연한 협력 구조를 구축할 수 있다.

좁아진 Ladder의 책임

  public class Ladder {
    private final List<RowLine> rowLines;

    // 생성자의 책임은 상태 할당과 검증으로 최소화
    public Ladder(List<RowLine> rowLines) {
        validate(rowLines);
        this.rowLines = List.copyOf(rowLines);
    }

    private void validate(List<RowLine> rowLines) {
        if (rowLines.isEmpty()) {
            throw new IllegalArgumentException("사다리의 높이는 최소 1 이상이어야 합니다.");
        }
    }

    // Ladder는 도메인 로직에 집중: 예를 들어 특정 위치의 연결 상태 확인 등
    public boolean hasConnection(int level, int index) {
        // 도메인 로직 예시
        return rowLines.get(level).hasConnection(index);
    }
}

복잡해진 생성 로직을 추상화하는 LadderFactory

// 팩토리 클래스: 복잡한 생성 로직을 여기서 처리
public class LadderFactory {
    private final RowLineGenerator rowLineGenerator;

    public LadderFactory(RowLineGenerator rowLineGenerator) {
        this.rowLineGenerator = rowLineGenerator;
    }

    public Ladder createLadder(int personCount, Height height) {
        List<RowLine> lines = IntStream.range(0, height.getHeight())
            .mapToObj(i -> rowLineGenerator.generate(personCount))
            .toList();
        return new Ladder(lines);
    }
}  

팩토리 클래스 운용 방식의 단점

하지만 이러한 객체 설계는 도메인 규칙 준수 강제가 약화된다는 단점과 추적 인지 비용이 늘어난다는 단점이 있을 수 있다.

1. 도메인 규칙 준수 강제의 약화

정적 팩토리 메서드 하나로 도메인 생성 규칙을 강제하면 Ladder 객체 생성의 단일 경로를 확보할 수 있었던 반면, 팩토리 클래스를 사용하면 Ladder 생성의 또 다른 경로가 생긴다. 이는 유연성을 주지만, 반대로 강력한 규칙 준수를 조금 약화시킬 수 있다. 도메인 규칙을 확실히 강제하고 싶다면, 팩토리 외부에서 생성자를 열어두지 않고 팩토리 내에서만 Ladder를 생성하게끔 접근 제어를 조정하거나, Ladder 생성자에 검증 로직을 더욱 강화해 Ladder가 항상 유효한 상태를 갖도록 보장해야 한다.

2. 추적 인지 비용 상승

Ladder를 생성하는 로직이 Ladder 클래스 외부(팩토리)로 빠져나감에 따라, Ladder 생성 규칙을 파악하기 위해 개발자가 Ladder 외 다른 클래스를 탐색해야 할 수 있다. 이는 인지 비용 증가로 이어질 수 있으나, 적절한 네이밍(LadderFactory)과 문서화, 패키지 구조를 통해 완화할 수 있다.


사다리 게임 구현에서의 나의 선택은?

나는 처음에 사다리 게임 미션을 구현할 때에는 정적 팩토리 메서드 방식을 선택했지만 후에 팩토리 클래스를 운용하는 방식으로 전환하였다. 정적 팩토리 메서드가 가지는 단점들의 의미가 희미하다고 느끼게 되었기 때문이다.

1. 객체는 자신이 쓰이는 곳을 미리 정하지 않는다.

정적 팩토리 메서드를 사용하지 않는 경우 잘못된 객체가 생성될 수 있음을 선술한 바 있다. 그렇지만 생각해보면 잘못된 객체의 생성을 우려하는 것은 객체가 쓰이는 곳을 미리 판단하고 있다는 소리가 된다. 객체는 자신이 사용될 곳을 미리 정해서는 안된다. 이는 자유로운 협력 관계를 약화시키고 여러 제약 사항이 하나의 객체에게 얽히게 함으로서 종국에는 객체지향을 깨뜨리는 설계로 이어질 가능성이 있기 때문이다. Ladder의 책임이 비대해지는 것이 객체지향을 깨뜨린 예로 해석될 수도 있겠다.

나는 반드시 하나의 방식으로 객체를 생성하도록 하고 싶다는 욕심이 안좋은 설계로 이어질 수 있다고 생각하게 되었다. 더 나아가 객체는 나를 사용하려면 ~~이런 조건을 갖춰, 그럼 난 이걸 해줄수 있어 이런 계약만 잘 지키면 된다고 생각하게 되었다. 실제로 팩토리 클래스를 운용하는 방식으로 전환해보니 생성시 validation등 제약조건은 객체에 있으나 생성로직을 다른데 둔다고 해서 해당 객체의 가치가 떨어지는건 아닌 것 같다고 느끼게 되었다.

2. 유지보수라는 건 코딩을 적게 해야만 좋은 것은 아니다.

유지보수라는 건 코딩을 적게 해야만 좋은 것은 아니다. 변경 시 Ladder만 변경된지만 Ladder가 많은 기능 or 책임이 있어 객체가 비대해지는 것은 좋지 않다. 프로그램을 개별적으로 테스트 할 수 있는 구조부터 갖추어보고 그 기반으로 리팩토링 / 설계 활동을 하는 것이 좋다.

프로젝트를 진행해보니 초기의 잘못된 설계 위에 쌓여나간 코드는 설계 오류를 수정하기도 발견하기도 어렵다는 것을 느끼게 되었다. 초기의 설계가 중요한 만큼 편법없이, 객체지향적인 코드를 처음부터 고민하는 편이 좋다고 생각하게 되었다.


결론

편법이라는 워딩이 조금 강했을지도 모르겠다. 이번 포스팅을 작성하면서 확실하게 느낀 점은 내가 정답이 없는 개인 혹은 팀단위의 선호에 대한 포스팅을 작성하고 있다는 것이다.

나는 정답은 없지만 위에서 선술한 과정을 통해서 팩토리 클래스를 운용하는 쪽이 좋다고 생각하게 되었다. 여담으로 객체지향에 관심을 가지고 학습하고 있는 나의 단계에서는 의도적으로 객체를 분리하려고 하는 노력이 개인의 성장에 더 큰 도움이 될 것 같다고 생각하기도 했다.

참고로 리뷰어와 내가 해당 포스팅의 주제에 대해서 이야기 나눈 내용이 궁금하다면 여기에서 확인할 수 있다.

여러분들의 생각도 궁금하다. 댓글로 소통할 수 있으면 서로의 철학을 공유하는 좋은 기회가 생길 수도 있을 것 같다. 여러분들은 어떤 방식에 조금 더 설득되는가?

profile
추상보다 상세에 집착하는 개발자 리비(리비엔즈)입니다 🤗

3개의 댓글

comment-user-thumbnail
2025년 1월 8일

이펙티브 자바가 사람 여럿 담갔다고 생각해.. 차라리 해당 내용이 뒤쪽 아이템에 있었으면 어땠을까 하는... 🤔 객체의 책임이나 쓰임에 대해서 깊게 탐구하지 않고 정팩메로 뛰어들면 되려 유지보수에 어려워지고 추적하기 힘든 코드가 되더라고 ㅋㅋㅋ

1개의 답글