엘레강트 오브젝트 1장 - 출생

박진형·2022년 6월 7일
0

엘레강트오브젝트

목록 보기
2/3

1장. 출생

저자는 객체를 살아있는 유기체라고 생각하고 의인화를 하기위해 최대한 노력을하고자 한다. 즉 객체를 사람처럼 다룬다.
이 책의 목표는 유지보수성의 향상이다. 유지보수성은 소프트웨어의 중요한 가치이며, 코드를 이해하는 시간이 오래걸릴수록 유지보수성이 나빠지고 코드 품질이 저하된다고 한다.

"제가 여러분의 코드를 이해할 수 없다면 문제의 원인은 여러분에게 있습니다."
좋은 말인것 같다. 결국 프로젝트는 혼자서만 하는 작업이 아니기에 코드를 이해하기 쉽다는 것은 결국 나뿐만아니라 다른 팀원들이 유지보수하기 쉬워지며 프로젝트 비용 절감을 뜻하기 때문이다.

1.1 -er로 끝나는 이름을 사용하지 마세요

객체와 클래스의 차이는 클래스는 객체의 팩토리. 클래스는 객체를 생성한다.(인스턴스화 한다라고 표현)
클래스는 객체를 생성하고 반환하는 객체의 웨어하우스로 바라봐야한다.
종종 클래스를 객체의 템플릿으로 설명하지만 이러한 설명은 수동적이고 별 볼일 없는 코드 덩어리로 보게된다.
객체는 템플릿이아닌 팩토리로 바라봐야한다.

이러한 관점을 가지고 클래스를 바라보게 된다면 클래스 이름을 짓는 방법을 다시한번 생각해봐야한다.

저자는 클래스 이름을 짓는 올바른 방법과 잘못된 방법을 보여주며 설명한다.

클래스 이름을 짓는 잘못된 방법과 올바른 방법

클래스의 객체들이 무엇을 하고 있는지를 살펴본 후 기능에 기반해서 이름을 짓는 방법.

class CashFormatter {
	private int dollars;
    CashFormatter(int dlr) {
    	this.dollars = dir;
    }
}

public String format() {
	return String.format("$ %d", this.dollars);
}

CashFormatter 클래스의 객체는 dollar에 저장된 금액을 문자열로 포맷팅하는 역할을 수행한다.
적절한 네이밍으로 볼 수도 있지만 저자는 CashFormatter는 존중할 만한 객체가 아니라고 한다. 의인화 할 수도, 코드 안에서 존경받는 시민으로 대우할 수도 없다고 한다.

클래스의 이름은 객체가 노출하고 있는 기능에 기반해서는 안된다. 무엇을 하는지가 아니라 무엇인지에 기반해야한다.
-> Cash, USDCash, CashInUSD와 같은 이름이 적절하며, format()도 usd()로 수정해야한다.

class Cash {
	private int dollars;
    Cash(int dlr) {
    	this.dollars = dir;
    }
}

public String usd() {
	return String.format("$ %d", this.dollars);
}

객체가 노출하고 있는 기능에 기반해서 객체 이름을 짓는 것이아니라, 객체가 무엇이고 이 객체가 무엇을 할 수 있는지를 생각하라는 뜻인 것 같은데, 객체가 노출하고 있는 기능에 기반해서는 안된다는 말은 어느정도 이해하지만 usd()의 네이밍이 적절하다고 느껴지진 않는다. formatUsd() 이 좀 더 적절하지 않을까? 왜냐하면 비록 한 줄짜리 짧은 코드지만, 코드를 까보기 전 까지는 이 함수가 어떤 동작을 하는지 알 수 없기 때문이다. 유지 보수를 강조한다면 이런 더욱이 usd()는 부적절하지 않을까?

"객체는 그의 역량으로 특징지어져야 합니다. 제가 어떤 사람인지는 키, 몸무게, 피부색과 같은 속성이아니라, 제가 할 수 있는 일로 설명해야합니다."
위에서 말했던 객체가 무엇이고 이 객체가 무엇을 할 수 있는지를 생각하라는 뜻인듯 하다.

Manager, Controller, Helper, Handler, Writer, Reader, Converter, Validator,Router, Dispatcher, Observer, Listener, Sorter, Encoder, Decoder 이런 네이밍 전부 잘못 지어진 이름이고,
Target, EncodedText, DecodedData, Content, SortedLines, ValidPage, Source 는 잘 지어진 이름이라고 한다.

그럼 내가 지금까지 과제, 프로젝트 등을 하면서 사용했던 Controller, Manager, Converter, Writer, Reader, Mapper, Deserializer 등 모두 잘못 지어진 이름이라는 주장인데..

실제로 내가 작성했던 String을 voucher 객체로 만들어주는 코드를 살펴보면 Mapper라는 네이밍을 사용하고 있었다.

return FileIOUtils.readAllLine(voucherDb.getFile())
				.stream()
				.map(csvMapper::deserialize)
				.collect(Collectors.toList());

CsvMapper 보다는 CsvFormat 이라는 이름으로 바꿔야할까? 또는 VoucherFormmatedInCsv? 아직은 감이 잘 안잡힌다.

객체는 객체의 외부 세계와 내부 세계를 이어주는 연결 장치가 아닙니다. 객체는 내부에 캡슐화된 데이터를 다루기 위해 요청할 수 있는 절차의 집합이 아닙니다. 객체는 캡슐화된 데이터의 대표자 입니다.
-er 네이밍은 객체를 대표자로 만들기보다는 데이터를 다루는 절차들의 집합 따위로 취급될 수 있다는 것이다.

예를 들어 숫자 리스트 중 소수를 찾는 기능을 만든다면 Primer, PrimeFinder, PrimeChooser 등이 아니라
소수를 대표하는 클래스인 PrimeNumbers가 소수를 찾고 갖고 있으며 PrimeNumbers 자체가 소수 목록이야라는 뉘앙스를 풍겨야된다.

결론

무엇을 하는지가 아니라 무엇인지를 생각해야 한다.

1.2 생성자 하나를 주 생성자로 만드세요

저자는 메서드는 2~3개, 생성자는 5~10개가 적당하다고 한다. 메서드가 적으면 적을수록 생성자가 많으면 많을수록 응집도가 높고 견고한 클래스라고 한다.
메서드가 많아지면 클래스의 초점이 흐려지고 SRP를 위반하기 쉬워진다. 반면 생성자가 많아지면 유연해진다.

주 생성자를 정하고 부 생성자들이 주 생성자를 재사용 하도록 만들면 좋다. 부 생성자를 사용함으로써 유연성이 증가하고 주 생성자에서 유효성 검사를 한곳에서 몰아서 하므로 유지보수가 편리해진다.

  • 아래 코드는 dlr이 양수여야 한다면 주 생성자에서만 유효성 검사를 수행하면 된다. 주 생성자, 부 생성자를 나누지 않는다면 모든 생성자에서 유효성 검사를 진행해야한다 -> 유지보수 어려워짐.
    class Cash {
    	private int dollars;
       Cash(float dlr) {
       	this((int) dlr);
       }
       Cash(String dlr) {
       	this(Cash.parse(dlr));
       }
       Cash(int dlr) {
       	if (dlr < 0)
           	throw new IllegalArgumentException("양수여야 합니다.");
       	this.dollars = dlr;
       }
    }

결론

주 생성자와 부 생성자를 나누어 복잡성을 줄이고 중복을 제거해 유지보수성을 향상시키자.

1.3 생성자에 코드를 넣지 마세요

객체를 초기화하는 생성자에서는 코드가 없어야한다. 코드가 없어야한다는게 할당하는 코드조차 없어야 된다는 말이아니고 어떤 동작을 수행하는(변환을 하는, 또는 어떤 기능을 실행하는?) 코드가 없어야 한다는 말이다.

생성자에서 특정 동작을 수행한다면?

  • 최적화가 불가능하다.
    Decoding하거나 Encoding하는 등 변환을 하는 동작이 있고 이 동작은 큰 비용을 요구한다고 가정했을 때, 이 동작을 생성자에 넣는다면 사용자가 변환 동작을 제어할 수 없어 최적화 할 수 없다.

책에서 나오는 예제를 참고해 나만의 예제를 만들어 봤다.

class User {
	String password;
    
	public User(String password) {
    	this.password = RSA.encode(s);//15초가 걸리는 동작이라면?
    }
}

User user = new User("1234");
if (Something Bad Situation) {
	throw new Exception("Bad Request");
}

이미 "1234"라는 비밀번호를 생성과 동시에 변환을 해버렸기 때문에 아래에 나오는 예외 상황에 마주하게 되었을 때 15초라는 인코딩 시간을 허비하게 된 것이다.

다음과 같이 변경하면 인코딩 시점을 제어할 수 있다.

class Password {
	private String password;
    
    public abstract String encode(String password);
    
    public abstract void validationCheck();
}

class User {
	private Password password;
	public User(String password) { //부 생성자
    	this(new DefaultEncodedPassword(password));
    }
    
    public User(Password password) { //주 생성자
    	password.validationCheck();
    	this.password = password;
    }
    
    Password getPassword() {
    	return this.password;
	}
}

class DefaultEncodedPassword extends Password {
	private String source;
     
    DefaultEncodedPassword(String source) {
    	this.source = source;
    }
    
    @Override
    String encode() {
    	return ...;
    }
    
    @Override
    void validationCheck() {
    	if(notValid source) {
        	throw Exception("잘못된 패스워드.");
        }
    }
}

User user = new User("1234");
if (Something Bad Situation) {
	throw new Exception("Bad Request");
}
String encoddedPassword = user.getPassword().encode(); //원하는 시점에 인코딩 할 수 있다.

적절한 예제인지는 모르겠지만 인코딩 시점을 제어할 수 있고, 주 생성자 부 생성자로 비교적 유연하게 User 객체를 생성할 수 있다. 그리고 주 생성자에서만 최종적으로 Validation Check를 하기 때문에 유지보수가 편하다.

추가적으로 비용이 비싼 인코딩 로직을 여러번 수행하지 않기 위해 아래와 같이 변경해 여러번 인코딩을 수행해도 문제 없도록 개선할 수 있다.

class DefaultEncodedPassword extends Password {
	private String source;
    private Collection<String> cachedEncodedPassword;
    DefaultEncodedPassword(String source) {
    	this.source = source;
    }
    
    @Override
    String encode() {
    	if (cachedEncodedPassword.isEmpty()) {
        	...encoding logic
        	this.cachedEncoddedPassword.add(encoddedPassword);
        }
    	return this.cachedEncodedPassword.get(0);
    }
    
    @Override
    void validationCheck() {
    	if(notValid source) {
        	throw Exception("잘못된 패스워드.");
        }
    }
}

User user = new User("1234");
if (Something Bad Situation) {
	throw new Exception("Bad Request");
}
String encoddedPassword = user.getPassword().encode(); //원하는 시점에 인코딩 할 수 있다.
String encoddedPassword = user.getPassword().encode(); // 캐싱된 인코딩된 패스워드를 가져온다

결론

생성자에서 코드를 없애자. 일관성 측면에서 생성자에서 코드를 허용하는 것은 지양하도록 하자. 깨진 유리창 법칙처럼 겉잡을 수 없이 나쁜 코드들이 불어날 수 있다.

생성하고 제어를 전달하라.

App app = new App(new Data()), new Screen());
app.run();

클린코드 11장에서 비슷한 내용을 언급한다.(클린코드 194p, 시스템 제작과 사용을 분리하라.)

추가 - 부 생성자에는 로직이 있어도 된다고?

같이 스터디를 진행하는 팀원분께서 부 생성자에서는 로직이 있어도 되는거 아닌가라는 토픽을 던져주셨다.
내 생각은 다르다. Lazy initializing을 하는 이유가 무엇이었는가? 생성시 불필요한 자원 낭비를하지 않기 위해서지 않았는가?

팀원분께서는 아래 코드에서와 같이 저자도 이런식으로 코드를 썼는데 부 생성자에서는 허락을 한거 아닌가? 라고 말씀하셨다.

class Cash {
  private int dollars;

  Cash(float dlr) {
    this((int) dlr);
  }

  Cash(String dlr) {
    this(Cash.parse(dlr));
  }

  Cash(int dlr){
    this.dollars = dlr;
  }
}

내 생각은 달랐다. 이 예제 코드는 생성자에 코드를 넣지마세요라는 주제인 챕터 1.3이 나오기 전의 예제다.
바로 챕터 1.2 생성자 하나를 주 생성자로 만드세요 라는 챕터의 예제다.

저자는 해당 소주제에 대해 집중한 예제만을 제시했다고 생각했다. 그럼 이 예제를 1.3에서 나오는 생성자에 코드를 넣지마세요를 적용해봤다.

  • 부 생성자에 코드를 넣었을때
    주 생성자에는 코드가 없이 깔끔하고, 부 생성자에는 코드를 넣었다.
    여전히 생성 시점에서는 값 비싼 변환 동작을 수행한다.
public class Cash2 {
	private int dollars;

	public Cash2(String dlr) throws InterruptedException {
		this(Integer.parseInt(dlr));
		Thread.sleep(5000);
	}

	public Cash2(int dlr) {
		this.dollars = dlr;
	}

	public int getDollars() {
		return dollars;
	}
}

public class Main {

	public static void main(String[] args) throws InterruptedException {
		//생성 시점에서 값 비싼 동작을 수행한다. 컨버팅이 필요하지 않아도 이미 비용을 지불한 상태
		Cash2 cash2 = new Cash2("5");
		if(Something Bad Situation) {
			throw new RuntimeException();
		}
		cash2.getDollars();
	}
}
  • 부 생성자에 코드를 넣지 않았을 때
    1.3 생성자에 코드를 넣지마세요에서 말하고 있는 "좋은 의미로 게으른 객체"가 되었다.
public class Cash {
	private Number dollars;

	public Cash(float dlr) {
		this(new FloatAsInteger(dlr));
	}

	public Cash(String dlr) {
		this(new StringAsInteger(dlr));
	}

	public Cash(Number dlr) {
		this.dollars = dlr;
	}

	public Number getDollars() {
		return dollars;
	}
}

public class FloatAsInteger implements Number {
	private float source;
	public FloatAsInteger(float dlr) {
	}

	@Override
	public int convert() throws InterruptedException {
		Thread.sleep(5000);
		return (int)source;
	}
}

public class StringAsInteger implements Number{
	private String source;

	public StringAsInteger(String source) {
		this.source = source;
	}

	@Override
	public int convert() throws InterruptedException {
		Thread.sleep(5000);
		return Integer.parseInt(this.source);
	}
}

public class Main {

	public static void main(String[] args) throws InterruptedException {
		Cash cash = new Cash("5");
		if (Something Bad Situation) {
			throw new RuntimeException();
		}
		// 사용 시점에서 값 비싼 동작을 수행한다. 위의 예외 상황이 벌어지면 비용을 지불하지 않고 종료한다.
		System.out.println(cash.getDollars().convert());
	}
}

0개의 댓글