개발 일기

[고찰하기] 객체 생성에 관해

Pleasant Pain 2025. 3. 3. 14:39

개요

  • 개인적으로 객체 설계에서 가장 중요하다고 생각되는 객체 생성에 관해 해당 문서에 정리한다
    • 여기서 얘기하는 객체는 비즈니스 로직을 담당하는 핵심 개체들을 의미한다
    • 예를 들어 주로 데이터 전송의 역할을 하는 DTO 같은 경우, 객체 생성 관점의 수용 범위가 좀 더 넓다

 

목표

  • 객체 생성에 대한 나만의 명확한 주관을 갖기 위함
    • 구체적으로 어떠한 주관?
      • 어떠한 상황에 어떠한 이유로 무엇이 가장 ‘적절한’ 객체 생성 방식인가를 판단할 수 있는 주관
      • 즉, 일관된 객체 생성 방식을 규정하고자 함이 아니다
  • 왜 이러한 주관을 갖길 원하냐?
    • 스스로 돌이켜 봤을 때, 그동안 객체 생성에 대한 설계를 안일하게 구현했기 때문이다
      • 예를 들면 롬복의 빌더 어노테이션만 붙여, 무분별하게 생성된 객체를 방치했기 때문이다
    • 게다가 객체지향 프로그래밍을 논하기 전에,
      애초에 객체의 상태가 불완전하게 생성될 수 있도록 설계되었다면,
      캡슐화든, 상속이든, 컴포지션이든, 추상화든 그게 다 무슨 소용인가?
      • 즉, 객체 설계에서 어떻게 올바르게 객체를 생성할 수 있게끔 코드를 작성하느냐가
        OOP를 논할 때 매우 중요한 부분이라고 판단했기 때문이다

 

객체 생성에 대해 지키고 싶은 다짐

🙏🏻 꼭 지켜야 할 것 (원칙)

  • 불완전한 상태의 객체가 생성되는 일이 존재할 수 없도록 설계한다
    • 소제목에 원칙이라 얘기했지만, 성능이 정말 크리티컬한 상황인 경우엔 얘기가 달라질 수 있다
    • 예를 들어 필자는 대용량 csv 데이터를 최대한 빠르고 안정적으로 DB에 동기화시키기 위해,
      (여러 테스팅 끝에) 스스로 엔터티 생성 자체를 포기한 경험이 있다..

 

💰 지키기 어렵더라도 추구하고 싶은 것 (욕심) 

  • 여러 상황(비즈니스)에 따라 올바른 상태의 객체를 생성할 수 밖에 없도록 강제하는 것
  • 즉, 내 설계에 따라 객체 생성 시에 발생할 수 있는 실수를 사전에 차단하는 것

여기서 뜻하는 객체의 상태란

  • 코드 관점에서는 class의 필드다
    • 예를 들어, 인간이라는 객체를 나타내기 위해 Human이라는 class를 설계한다면,
      인간이 가질 수 있는 특성 중 하나인 '나이'라는 상태
      class의 필드로 `int age`라고 인스턴스 변수로 표현하면 된다
      (접근제어자에 대한 관점은 잠시 생략한다, 캡슐화, 은닉화 등 매우 중요한 부분이긴 하지만..

 

어떻게 생성 시 불완전한 상태를 만들지 않을 건데? (원칙)

1️⃣ 생성자 + 예외 활용

  • 존재하면 안되는 상태를 생성하려고 할 때 생성자에서 예외를 던지는 방법이다
  •  예외를 던져서 적어도 잘못 된 상태를 가진 객체가 생성될 시, 이후의 로직이 실행될 수 없도록 하기 위함
    • 안타깝지만 어떠한 예외로 생성이 실패 됐으니 어떠한 예외를 캐치해서
      올바르게 객체를 생성하는 코드를 작성하라는 기대는 하기 어렵다
    • 예외에서 회복하는 코드를 작성하는 것은 애초에 어렵기 때문이다
      • 따라서 이후 실행을 중단 하라는 의미로 예외를 던진다
      • 그렇다면 예외를 매번 굳이 캐치할 이유도 없이 상위 계층에서 관리하면 된다 
  • 아래는 예외를 던지도록 설계한 예시다 
public class Human {
	int age;
    public Human(int age) {
    	if(age < 0) {
        	throw new IllegalArgumentException("사람의 나이는 0살 이상이어야 합니다"); 
    	} 
    	this.age = age; 
    }    
 }
  • 예를 들어 Human 객체를 생성하려는 입장에서 나이를 -1로 설정하면 예외를 던지고 이후 실행을 중단토록 한다 
    • 여기서 중단이란 비즈니스 로직의 중단이다
    • 에러에 대한 API 응답이나, 로깅을 위해 공통 상단에서 예외를 캐치할 수 있다
    • 그러나 이게 문제 회복의 관점이 아니기에 올바른 예외 처리라고 주장하긴 어렵다
  • 상황에 따라 생성자를 더 추가하여, 비즈니스에 맞춰 설계할 수 있다
    • 그러나 생성자 이름은 모두 같으므로 사용자 입장에서 혼동스러울 수 있다

 

2️⃣ 정적 팩토리 패턴 + Optional 혹은 Result 활용

  • 메서드 시그니처로 명확한 생성 의도를 나타낼 수 있도록 정적 팩토리 패턴을 활용한다
    • 캡슐화 원칙에 따라, 객체를 생성하는 입장에서는 단순 메서드 시그니처만 보고 행위를 요청할 수밖에 없다
    • 호출자 입장에서 내부가 어떻게 구현되어 있는지 알 필요가 없도록 함이 올바른 OOP 방식이다
      • 그런데 예외를 던지게 되면,
        사용자는 본인이 잘 처리 될거라 믿고 호출한 메서드가 실패했다는 사실을 예외를 캐치해야만 알 수 있다 

      • 즉, 예외를 던지는 부분이 있을지 없을지도 모르는 상황에서 불필요한 try ~ catch 문을 작성하거나,

      • 혹은 미리 어떤 예외를 던질지 확인해야 하는 캡슐화 원칙을 깨야한다
        (체크드 익셉션의 존재에 대해서는 잠시 생략한다) 

      • 또한, 대부분의 예외는 회복하는 코드를 작성하기 매우 어렵다 ← 이게 핵심이다
    • 따라서 실패가 발생할 수 있는 메서드에 대해 예외를 던지는 게 아닌, Result 패턴을 고민해 볼 수 있다
    • 여기서 얘기하는 실패란 클래스 설계자의 예상된 실패로, 시스템 실패 같이 예상하지 못한 경우는 포함되지 않는다
  • 아래와 같이 생성자를 외부에 감추고, 정적 메소드를 제공하되, 객체의 생성/실패 여부를 리턴한다
record Result<T> (T data, boolean success, String errorMessage) { 
} 

public class Human { 
	int age; 
    
    private Human(int age) {
    	this.age = age; 
    }
    
    public static Result<Human> createHuman(int age) {
    	if(age < 0) {
        	return new Result<>(null, false, "사람의 나이는 0살 이상이어야 합니다"); 
        } 
        return new Result<>(new Human(age), true, null); 
 	} 
 }
  • 위와 같이 Result 패턴을 도입하면 사용자 입장에서 객체 생성의 성공/실패를 명확하게 확인할 수 있다
  • 이렇게 되면 사용자는 적어도 예외를 캐치할 필요 없이 객체 생성 여부만 확인하여 그다음 로직을 설계하면 된다
    • 이에 따른 분기 처리가 추가되겠지만, 대부분 생성 실패에 대한 early return만 처리하면 된다 
  • 그러나 객체 설계에 있어 상대적으로 코드량이 증가해, 코드를 분석해야하는 입장에서 상대적으로 난이도가 올라갈 수 있다
    (이 부분은 사람마다 다를 것이라 판단한다)
  • 상황에 따라 명확한 의미를 전달할 수 있는 정적 메서드를 추가하여, 비즈니스에 맞춰 설계한다
    • 비즈니스에 맞는 메서드명을 작성하여 사용자가 상황에 맞게 호출할 수 있도록 한다

🤔 원칙을 지키기 위해 어떤 선택하는게 좋을까?

두 차이를 비교하기 전에 예외에 대한 관점을 명확하게 정리하는 게 필요하나, 
여기서 예외에 대해 깊게 다루는 것은 내용이 너무 길어지므로 간략하게 상황에 따른 비교를 한다

또한, 객체 생성을 호출하는 사용자가 명확히 어느정도의 책임이 있는지 규정하기가 매우 어렵다

올바른 상태의 객체가 생성되도록 설계하는 설계자 입장에서도 사용자의 편의성을 고려해야 하나,
사용자도 생성하려는 객체에 대해 먼저 제대로 이해하고 생성하려는 노력이 필요하다고 생각하기 때문이다

즉, 설계자와 사용자 모두 `객체에 대한 이해`(=>도메인에 대한 이해)가 필요하며,
현재로선 단지 설계자가 사용자보다 이에 대한 책임이 더 크다고 판단한 것뿐이다

1️⃣ 생성자 + 예외 활용의 장단점

✅ 장점

  • 직관적인 코드
  • 예외가 발생하지 않으면 이후 로직에서 객체가 유효한 상태를 보장한다
  • 예외가 발생하면 스택 트레이스가 남고, 디버깅이 쉽다
  • 잘못된 값이 들어오면 예외가 발생하므로, 실수를 빨리 인지할 수 있다
    • 여기서의 인지란 실행 환경(런타임)에서의 인지다 

❌ 단점

  • 호출자 입장에서 예외가 발생되기 전까지 객체 생성 실패에 대해 미리 확인할 수 없다
    • 런타임 단계에서의 체크여서 컴파일 단계에서 확인이 어렵다
    • 코드를 까보면 어떤 예외가 발생될지 알 수 있으나, 이는 캡슐화 원칙을 깨게 된다
  • 예외가 비용이 크다
    • 예외 발생 시 스택 트레이스가 생성되므로 성능에 영향이 있을 수 있다
  • 특히, 상황에 따라 객체 생성 실패 원인이 다양할 수 있는데
    예외를 던지고 상단에서 일관되게 처리할 시, 의도하지 않은 결과가 응답될 수도 있다

2️⃣ 팩토리 패턴 + Result 패턴의 장단점

✅ 장점

  • 메서드 시그니처를 토대로(리턴 타입) 객체 생성 실패 가능성에 대해 호출자가 미리 예상 가능
    • 즉, 사용자 입장에서 객체 생성 실패에 대한 이후 로직을 구분하여 대처할 수 있다
    • 반대로 예외를 던지는 경우 유연하게 대처하기 어렵다
  • 메서드 시그니처를 토대로 비즈니스에 맞춰 객체 생성을 유도할 수 있다
    • 예를 들어 갓 태어난 사람의 나이를 1살로 정의할 경우
      메서드 시그니처를 Result<Human> createBabyHuman() 같이 명명하고  age = 1으로 초기화 
  • 명시적, 구체적으로 객체 생성 실패 원인을 다룰 수 있다
    • 복잡한 비즈니스를 책임져야 하는 객체 일 수록, 객체 생성 실패 사유가 다양할 수 있기에
  • 예외 발생 시 스택 트레이스 생성을 피할 수 있어 성능이 조금 더 나아질 수 있다

❌ 단점

  • 객체를 생성 후 활용 과정에서 반드시 `Result`를 체크해야 함
    • Result 성공 여부를 매번 체크해야 하므로 상대적으로 코드가 장황해질 수 있다
    • WHY? 생성자 + 예외 던지는 설계에서는 대부분 사용자가 try ~ catch 하는 코드를 작성하지 않을 것임으로
  • 상대적으로 디버깅이 어려울 수 있다
    • 예외가 발생하면 스택 트레이스로 문제를 추적할 수 있지만, Result 패턴에는 스택 트레이스로 문제 확인 불가함으로
    • 하지만 예외를 던지지 않고 생성만 하여 스택 트레이스를 남길 순 있다

▶️ 정리

도메인 로직의 중요도를 먼저 생각하고 그다음 코드 스타일과 성능을 고려하자
  • 비즈니스적으로 복잡하지 않고, 사용자가 실수할 일도 굉장히 적은 class라면?
    • 필드 수도 적고, 비즈니스적으로 간단한 class의 경우,
      생성자 호출로도 사용자가 충분히 안정적인 객체를 생성할 가능성이 매우 높다 
    • 하지만 그럼에도 객체가 잘못된 상태로 생성되는 것을 막기 위해, 예외를 던진다
    • 따라서 비즈니스적으로 책임이 단순하고, 도메인의 중요도가 낮다면 생성자에 예외를 던지는 설계를 활용한다
  • 비즈니스적으로 복잡하여, 사용자가 충분히 실수를 유발할 수 있는 class라면?
    • 필드 수도 많고, 비즈니스적으로 복잡한 class의 경우,
      비즈니스적 의도가 담길 수 있는 메서드 시그니처를 제공하고,
      객체 생성이 실패할 가능성도 있음을 알리는 것이 사용자의 실수를 줄일 수 있다
    • 따라서 도메인의 중요도가 높다면 팩토리 + Result 패턴을 활용한다
  • 팀 내부에서 약속된 코드 스타일이 있다거나 성능이 매우 크리티컬 하다면?
    • 팀 내부에서 지켰으면 하는 원칙부터 확실히 정의한다
    • 즉, 어떤 선택을 하더라도 올바르지 않은 상태의 객체 생성이 설계되지 않도록 최대한 노력한다
      • 객체가 잘못된 상태로 생성 됐을 때, 비즈니스 위험성을 명확히 설명한다
    • 이후 상황에 대해선 팀 내부 규칙을 따르는데 초점을 맞춘다
    • 규칙이 없을 경우 성능이 중요한 상황인지 여부를 판단한다
  • 또한, 안타까운 상황이지만 현실적으로 객체 설계자 또한 객체에 대한 이해도가 부족한 상황일 수 있다
  • 또는, 객체가 가졌던 비즈니스적 책임이 처음엔 단순했을지라도 점점 복잡해질 수도 있는 일이다
    • 즉, 처음부터 객체 생성에 대한 설계가 당연히 완벽할 수 없다고 생각한다
    • 따라서 객체에 대한 이해도에 따라, 생성 설계도 점진적으로 개선하겠다는 의지를 품고 있어야 한다

 

어떻게 생성 시 사용자의 실수를 줄일 건데? (욕심)

1️⃣ 일급 컬렉션 활용

  • 객체의 상태가 잘못 생성되는 문제에 대해 책임을 나눈다
    • 여기서 의미하고자 하는 책임은,
      사용자가 객체에 어떠한 요청을 했으니 객체는 이에 대해 올바르게 응답해야 할 책임이 있다는 것이다
  • 대표적으로 로또 티켓 객체를 설계하는 예시가 있다
  • 아래는 일급 컬렉션을 사용하지 않고 로또 티켓을 설계한 경우다
public class LottoTicket { 
	private final Set<Integer> numbers; 
    
    public LottoTicket(Set<Integer> numbers) {
		if (numbers.size() != 6) {
			throw new IllegalArgumentException("로또 티켓은 6개의 숫자로 구성되어야 합니다."); 
		} 
		for (number : numbers) { 
			if (number < 1 || number > 45) { 
				throw new IllegalArgumentException("로또 번호는 1 이상 45 이하여야 합니다."); 
			}
		}
	}
}
  • 위와 같이 로또 티켓은 로또 번호들을 가질 수 있도록 설계되었다
    (비즈니스적인 부분을 강조하기 위해 null 체킹은 생략했다)

  • 비즈니스 로직에 따라 로또 번호는 6개의 숫자가 아니면 예외가 발생되고,
    로또 번호는 1부터 45 사이의 숫자가 아니면 예외가 발생되도록 생성자가 설계되었다
    • 위와 같이 설계된 LottoTicket 자체는 아무 문제가 없다
    • 그러나 사용자 입장에서 `Set<Integer> numbers` 파라미터 의미를 쉽게 파악하고 로또 티켓을 생성할 수 있을까?
    • 물론 로또라는 도메인에 대해 매우 잘 알고 있는 사람이라면 애초에 실수할 일이 적긴 하다
    • 하지만 로또라는 단어를 난생처음 듣는 외국인이라면?
    • 혹은  로또보다 훨씬 더 복잡한 비즈니스 라면?
  • 이번엔 일급 컬렉션을 활용해서 사용자가 실수할 수 있는 비즈니스 로직을 더 작은 단위로 분리하고자 한다
    아래는 일급 컬렉션으로 객체들을 나눈 예시다
public record LottoNumber(int number) {
	public LottoNumber { 
		if (number < 1 || number > 45) {
			throw new IllegalArgumentException("로또 번호는 1 이상 45 이하여야 합니다.");
		}
	} 
}

public record LottoNumbers(Set<LottoNumber> numbers) { 
	public LottoNumbers { 
		if (numbers.size() != 6) { 
			throw new IllegalArgumentException("로또 티켓은 6개의 숫자로 구성되어야 합니다.");
		}
	}
}

public class LottoTicket { 
	private final LottoNumbers lottoNumbers; 
	public LottoTicket(LottoNumbers lottoNumbers) { 
		this.lottoNumbers = lottoNumbers; 
	}
}
  • 위와 같이 LottoTicket이 생성되려면
    1. 먼저 올바른 로또 번호들인 LottoNumbers를 생성되어야 하고
    2. 다시 올바른 로또 번호들인 LottoNumbers를 생성하려면 올바른 번호인 LottoNumber들을 생성해야 한다
  • LottoTicket 한 곳에서 중첩된 비즈니스 로직에 대해 여러 예외를 던졌던 것과 달리,
    class 단위로 책임을 분리하여 더 명확하게 책임을 나타내고,
    이로 인해 LottoTicket이 가져야 할 책임이 더 명확해졌다

  • 따라서 이로 인해 사용자 입장에서 코드만 보고도 도메인에 대한 이해를 자연스럽게 높일 수 있다
    • WHY?
      보통의 사람은, 
      => 로또 티켓은 1부터 45까지 중복될 수 없는 6개의 숫자로 이루어져 있음
      위와 같이 한 번에 이해하는 것보다,

      => 로또 번호는 1부터 45까지 번호로 이루어져 있고,
      => 로또 번호들은 중복될 수 없으며,
      => 로또 티켓은 이러한 로또 번호들을 갖고 있다
      위와 같이 절차적으로, 단계별로 인식하는 게 훨씬 더 명확하게 이해할 수 있기 때문이다
      (객체 생성에 대한 책임이 커질수록 더더욱 그렇지 않을까?)
  • 하지만, 여러 클래스를 추가로 설계해야 하므로 익숙지 않는 사람의 입장에서는 코드가 다소 복잡해진다고 느낄 수 있다
    • 이외에도 일급 컬렉션의 장단점이 더 있으나,
      여기서는 객체 생성 시 사용자의 실수를 줄이는데 초점이 맞춰 있음으로,
      일급 컬렉션에 대한 고찰은 다른 글로 이후 정리하도록 한다

 

2️⃣ 빌더 패턴 활용

  • 사실 롬복의 어노테이션으로 빌더를 자주 사용했던 필자의 입장에서는,
    그동안 빌더의 편의성에 취해 잘못된 상태의 객체가 생성될 수 있도록 방치했으니
    빌더 패턴이 마냥 좋은 건가 싶었다

  • 그러나 좀 더 깊이 생각해 보니.. 빌더 패턴은 아무 잘못이 없다
    • 빌더 패턴을 어떠한 관점으로 어떻게 활용해야 하는지, 명확한 주관을 갖기 않았던 필자의 잘못이다
public class Human {
    private final int age; // 멤버 변수
    private final String name; // 멤버 변수


    // private 생성자: Builder를 통해서만 객체 생성 가능
    private Human(Builder builder) {
        this.age = builder.age;
        this.name = builder.name;
    }

    // Builder 클래스
    public static class Builder {
        private int age;
        private String name;

        // 메서드 체이닝을 위해 Builder 인스턴스 반환
        public Builder age(int age) {
            this.age = age;
            return this;
        }
        
        // 메서드 체이닝을 위해 Builder 인스턴스 반환
        public Builder name(String name) {
            this.name = name;
            return this;
        }

        // Human 객체 생성
        public Human build() {
            return new Human(this);
        }
    }
    
    // Builder를 간편하게 사용 가능한 정적 팩토리 메서드 (Optional)
    public static Builder builder() {
        if (age < 0) {
            throw new IllegalArgumentException("나이는 0 이상이어야 합니다.");
        }
        if (name.isBlank()) {
            throw new IllegalArgumentException("이름이 설정되어야 합니다");
        }
        return new Builder();
    }
}

public class Main {
    public static void main(String[] args) {
        Human human = Human.builder().age(25).name("Pleasant").build();
    }
}
  • 위와 같이 빌더를 적용해 생성자를 사용하지 않고,
    각 상태에 대한 설정 값을 메서드 시그니처를 통해 명시적으로 표현하면
    결과적으로 객체 생성을 요청하는 사용자 입장에서 잘못된 상태로 값을 명시하는 실수를 줄일 수 있다
    • 개인적으로 정말 아름다운 패턴이라고 생각한다
      • WHY?
        한 번에 입력해야 할 파라미터가 많을수록
        객체 생성 시, 잘못된 상태를 입력하는 실수를 유발할 가능성이 높은데
        구조상 객체 생성에 대한 메서드 자체는 여러 단계로 쪼개질 수가 없었기 때문이다

      • 정확히 표현하면 객체 생성을 설계하는 내부적으로는 로직을 쪼갤 수 있겠으나,
        객체 생성을 호출하는 사용자 입장에서는 여러 메서드로 단계를 쪼개어
        올바른 객체 상태를 설정하는 단계를 나눌 수 없었기 때문이다

      • 이러한 문제를 메서드 체이닝이라는 기법을 통해 해결한 부분이 매우 인상적이다
  • 어찌 보면 일급 컬렉션 활용에 대해 개인적으로 추구했던 관점 비슷한 느낌을 받는다
    • "사람이 한 번에 인지하기 어려운 큰 단위의 작업(책임)을 더 작은 단위의 작업으로 쪼개 이해를 높인다"
  • 하지만 필자는 이러한 빌더 패턴을 남용해 왔다
    • 이러한 아름다운 패턴을 어떻게 남용했을까?
      => 하나의 빌더에 종속되어 객체가 올바른 상태가 아니어도 생성되도록 방치했다

    • 비즈니스가 복잡해짐에 따라 도메인 객체는 여러 케이스에 따른 다양한 상태를 가질 수 있다
      그러나 객체가 다양한 상태를 가질 수 있다는 것이 올바르지 않은 상태를 허용한다는 뜻이 아니다
      • 하지만 필자는 메서드 체이닝이라는 달콤함에 취해 객체가 올바른 상태를 갖도록 강제하는 게 아닌,
        반대로 올바른 상태를 갖지 않아도 생성할 수 있도록 허용하는 어리석은 판단을 했었다

      • 그동안 가장 우선시되어야 하는 가치를 놓쳐왔던 빌더 활용에 대해 지금이라도 바로 잡고자 한다
  • 위와 같이 빌더를 남용했기 때문에, 앞으로 빌더를 버리겠는 의미가 아니다
  • 객체 생성을 요청하는 사용자의 실수를 줄이기 위해, 빌더를 더 잘 활용해 보겠다는 의미다

 

3️⃣ 정적 팩토리 + 빌더 혼합된 방식으로 강화

  • 먼저, 비즈니스가 복잡해짐에 따라 하나의 빌더로는 사용자의 실수를 줄이기 어려운 사례를 만들어보자
  • 예를 들어, 모든 성인 주민등록번호가 필히 있어야 하고,
    반대로 성인이 아니면 주민등록번호가 필히 존재하지 않아야 한다면?
public class Human {
    private final int age;
    private final String residentRegistrationNumber; // 성인의 경우에만 값을 가짐

    private Human(Builder builder) {
        this.age = builder.age;
        this.residentRegistrationNumber = builder.residentRegistrationNumber;
    }

    public static class Builder {
        private int age = -1;
        private String residentRegistrationNumber; // 선택 값

        public Builder age(int age) {
            this.age = age;
            return this;
        }

        public Builder residentRegistrationNumber(String residentRegistrationNumber) {
            this.residentRegistrationNumber = residentRegistrationNumber;
            return this;
        }

        public Human build() {
            if (age < 0) {
                throw new IllegalArgumentException("나이는 0 이상이어야 합니다.");
            }
            // 성인(18세 이상): 주민등록번호가 반드시 제공되어야 함.
            if (age >= 18 && (residentRegistrationNumber == null || residentRegistrationNumber.isEmpty())) {
                throw new IllegalArgumentException("성인의 경우 주민등록번호를 반드시 제공해야 합니다.");
            }
            // 미성년(18세 미만): 주민등록번호가 있으면 안됨.
            if (age < 18 && residentRegistrationNumber != null && !residentRegistrationNumber.isEmpty()) {
                throw new IllegalArgumentException("미성년자는 주민등록번호를 가질 수 없습니다.");
            }
            return new Human(this);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        // 올바른 성인 객체 생성 (나이 18세 이상, 주민등록번호 제공)
        Human adult = new Human.Builder()
                .age(30)
                .residentRegistrationNumber("123456-1234567")
                .build();
        
        // 올바른 미성년자 객체 생성 (나이 18세 미만, 주민등록번호 미제공)
        Human child = new Human.Builder()
                .age(10)
                .build();
        
        // 잘못된 성인 객체 생성: 나이가 18세 이상인데 주민등록번호 미제공
        try {
            Human invalidAdult = new Human.Builder()
                    .age(20)
                    .build();
        } catch (IllegalArgumentException e) {
            System.out.println("성인 생성 실패: " + e.getMessage());
        }
        
        // 잘못된 미성년자 객체 생성: 미성년인데 주민등록번호가 제공됨
        try {
            Human invalidChild = new Human.Builder()
                    .age(15)
                    .residentRegistrationNumber("SHOULD-NOT-BE")
                    .build();
        } catch (IllegalArgumentException e) {
            System.out.println("미성년자 생성 실패: " + e.getMessage());
        }
    }
}
  • 따지고 보면 정말 단순한 요구 사항이다
    • 나이가 성인이면 민증이 존재해야 하고, 성인이 아니면 민증이 없어야 하는 단순 조건이다
  • 그런데 빌더를 활용해 객체 생성을 호출하는 입장에서는 나이에 따라
    빌더의 residentRegistrationNumber() 메서드 호출을 구분하기 쉬울까?
    • 성인이 아닌 경우엔, residentRegistrationNumber() 메서드가 존재하는 게
      오히려 잘못된 상태의 객체 생성을 유도하는 것과 다름없게 된다

    • 빌더를 활용해 사용자가 객체의 올바른 상태를 설정하도록 유도했던 목적과 정반대가 돼버린다
  • 또한, 위와 같이 매우 단순한 비즈니스 로직이 아니라,
    매우 복잡한 비즈니스 로직이라면?
    • 만약 겨우 2필드가 아니라, 다양한 필드의 값에 따라 굉장히 복잡한 요구 사항들이 섞여있다면?
    • 과연 이때도 하나의 빌더를 활용해서, 잘못된 상태의 객체 생성을 막기 쉬울까?
      • 필자의 경험 상, 필드가 다양하고 요구 사항이 복잡해질수록
        객체 생성 설계자와 객체 생성 사용자 둘 모두에게 굉장히 어려운 과제가 된다

      • 설계자로서 올바른 객체 생성 원칙을 지키면서,
        사용자 입장에서도 실수를 줄일 수 있는 좋은 방안이 있을까?
  • 여기서 쉽게 떠올릴 수 있는 건 객체 생성 원칙을 위해 고민했던 정적 팩토리 패턴이었고,
    정적 팩토리 패턴을 떠올리다 보니 자연스레 하나의 빌더가 아닌,
    요구 사항에 맞는 여러 빌더가 존재해도 아무 문제가 없겠다는 생각이 들었다
public class Human {
    private final int age;
    private final String residentRegistrationNumber; // 성인의 경우 값이 존재, 미성년은 null

    // 성인용 빌더를 위한 생성자
    private Human(AdultBuilder builder) {
        this.age = builder.age;
        this.residentRegistrationNumber = builder.residentRegistrationNumber;
    }

    // 미성년용 빌더를 위한 생성자
    private Human(MinorBuilder builder) {
        this.age = builder.age;
        this.residentRegistrationNumber = null;
    }

    // 정적 팩토리 메서드: 성인용 빌더 반환
    public static AdultBuilder adultBuilder() {
        return new AdultBuilder();
    }

    // 정적 팩토리 메서드: 미성년용 빌더 반환
    public static MinorBuilder minorBuilder() {
        return new MinorBuilder();
    }

    // 성인용 빌더 클래스
    public static class AdultBuilder {
        private int age;
        private String residentRegistrationNumber;

        private AdultBuilder() {
            // 외부에서 직접 생성하지 못하도록 private 처리
        }

        public AdultBuilder age(int age) {
            this.age = age;
            return this;
        }

        public AdultBuilder residentRegistrationNumber(String residentRegistrationNumber) {
            this.residentRegistrationNumber = residentRegistrationNumber;
            return this;
        }

        public Human build() {
            if (age < 18) {
                throw new IllegalArgumentException("성인 빌더: 나이는 18세 이상이어야 합니다.");
            }
            if (residentRegistrationNumber == null || residentRegistrationNumber.isEmpty()) {
                throw new IllegalArgumentException("성인 빌더: 주민등록번호를 반드시 제공해야 합니다.");
            }
            return new Human(this);
        }
    }

    // 미성년용 빌더 클래스
    public static class MinorBuilder {
        private int age = -1;

        private MinorBuilder() {
            // 외부에서 직접 생성하지 못하도록 private 처리
        }

        public MinorBuilder age(int age) {
            this.age = age;
            return this;
        }

        public Human build() {
            if (age < 0) {
                throw new IllegalArgumentException("미성년 빌더: 나이는 필수 입력 값입니다.");
            }
            if (age >= 18) {
                throw new IllegalArgumentException("미성년 빌더: 나이는 18세 미만이어야 합니다.");
            }
            return new Human(this);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        // 성인용 빌더를 사용하여 올바른 성인 객체 생성
        Human adult = Human.adultBuilder()
                .age(30)
                .residentRegistrationNumber("123456-1234567")
                .build();

        // 미성년용 빌더를 사용하여 올바른 미성년 객체 생성
        Human minor = Human.minorBuilder()
                .age(10)
                .build();
    }
}
  • 위와 같이 성인용 빌더, 미성년 빌더를 각각 설계하여 비즈니스 로직의 복잡성을 단순화하고
    사용자 입장에서도 어떠한 상태의 객체를 생성할 것인지 메서드 시그니처를 통해 더 명확히 판단할 수 있게끔 유도한다

  • 물론 비즈니스가 복잡해짐에 따라 객체의 책임이 너무 커졌다면,
    일급 컬렉션으로 책임을 쪼갰던 것과 같이 책임을 잘 쪼개어 객체 자체의 복잡성을 줄 일순 없는지 충분히 고민해봐야 한다

  • 또한, 비즈니스에 따라 여러 빌더를 설계했기 때문에 코드량이 훨씬 더 많아지는 단점이 있다
    • 그러나 사용자가 올바르지 않은 상태의 객체 생성 요청하도록 방치하는 게,
      과연 코드량을 줄이는 것보다 더 나은 가치가 있는가?라고 생각했을 때
      필자는 차라리 코드량을 늘리고 사용자가 올바르게 객체를 생성하도록 유도하는데 가치를 두고 싶다
      • 물론 코드량까지 간결하게 줄일 수 있도록, 올바른 객체 생성에 대한 설계 방안을 계속 고민해 볼 필요가 있다 
  • 이제 적어도 비즈니스가 복잡한 상황에서 빌더를 남용하는 사례를 만들지 말자

 

*️⃣ 결론

  • 올바른 객체 생성에 대한 주관을 갖기 위해, 객체 생성을 어떻게 설계할 것인가 고찰해 보는 시간을 가졌다
    • 단순히 생성자 + 예외로 객체가 올바른 상태를 갖도록 강제하는 것과

    • 중요도가 높은 도메인 객체에서 메서드 시그니처만으론 미리 여러 예외 상황을 예측할 수 없기에,
      그리고 에러 핸들링을 하더라도 결과 처리에 대한 유연성이 떨어지기에,
      좀 더 유연하게 대처하기 위한 정적 팩토리 패턴과 Result 패턴 도입을 고민할 수 있었다

    • 또한, 복잡한 객체 생성에 대한 책임을 여러 객체로 쪼개는 일급 컬렉션 활용이 있었고

    • 메서드 파라미터가 너무 많아 문제가 됐던 부분을,
      메서드 체이닝 기법으로 해결한 인상적인 빌더 패턴 활용이 있었다

    • 마지막으로 빌더를 남용했던 과거를 돌이켜보며,
      코드량이 많아진다 하더라도 비즈니스에 맞춰 정적 팩토리 패턴과 빌더를 구성해 보자는 생각을 갖게 되었다
  • 여러 방안이 존재했으나, 역시나 모두 장점만 있는 게 아니라 단점도 있었고,
    언어마다 컴파일러가 지원해 주는 수준이 다르기 때문에, 결국 상황에 따라 적절한 방안을 선택해야 한다
    • 최종 결론은 내가 앞으로 객체지향 언어를 사용하고 객체지향 프로그래밍을 추구해야 하는 관점에서,
      올바른 상태로 객체가 존재할 수 있도록 정말 많이 노력해야 된다는 점이다
  • 고민하는 데 있어 귀중한 시간을 투자한 만큼, 앞으로 올바른 상태의 객체가 생성되도록 노력해 보자