Good code bad code 정리 - 3장: 코드 계약


소프트웨어 개발은 보통 팀 단위로 이루어진다. 1장에서 언급된 코드 품질의 핵심 요소에 있었던 ‘예측 가능하고, 오용하기 어렵게 만들어라’ 이 두 항목은 다른 개발자와 상호작용 할 때 발생할 수 있는 문제와 관련이 있다.

여러 개발자가 함께 작업하는 프로젝트는 하나의 코드 베이스가 어디에서 다시 쓰일 지 작성하는 시점에는 알 수 없다. 전혀 생각하지 못했던 곳에서 내가 작성한 하위 문제 해결 코드가 사용될 수 있다. 하지만 요구사항은 변하기 마련이다.

내 코드가 어디에서 쓰이고 있는지 정확히 알지 못하는 상황에서 코드에 수정을 가해 품질이 유지되지 못한다면 문제가 발생할 것이다. 그래서 코드를 작성할 때는 다른 개발자가 내 코드와 상호작용 할 때 발생할 수 있는 문제가 있을지 찾아보고 그 문제를 완화하기 위한 고민이 필요하다.

협업을 위한 코드 작성시 고려하면 좋은 점

  • 자신에게 명백하다고 해서 다른 사람에게도 명백한 것은 아니다.

    코드를 작성하는 사람은 많은 시간을 보내 코드를 설계해 본인에게는 명백한 사실들도, 시간이 지난 후 이 코드를 쓸려고 하는 다른 개발자에게는 보이지 않을 수 있다.

  • 다른 개발자는 무의식중에 내 코드를 망가뜨릴 수 있다.

    다른 개발자는 코드의 원 작성자가 가진 명시되지 않은 사전 지식을 가지고 있지 않다. 그래서 의도치 않게 코드를 본인의 방식으로 수정하거나 오용할 수 있음을 항상 염두에 두고 최대한 오용할 수 없는 코드를 만들어야 한다.

  • 시간이 지남에 따라 자신의 코드를 본인도 기억하지 못한다.

    1년, 아니 한 달만 지나도 자신이 작성한 코드는 새로워질 수 있다. 작성 당시에는 명백했던 세부사항들이 기억 나지 않는 일은 정말 빈번하다. 이 코드를 볼 다른 개발자는 미래의 본인이 될 수도 있다. 배경지식이 없어도 코드를 봤을 때 한눈에 이해할 수 있도록 작성하는 것이 본인을 위해서도 좋다.

코드의 사용법 파악하기

코드의 사용법 다섯 가지가 있다.

  • 어떤 상황에 어떤 함수를 호출해야 하는지
  • 클래스가 무엇을 나타내며 언제 호출해야 하는지
  • 어떤 값을 인수로 사용해야 하는지
  • 코드가 수행하는 동작이 무엇인지
  • 어떤 값을 반환하는지

사용법을 파악하는 방법도 다섯 가지가 있다.

  • 함수, 클래스의 이름을 살펴본다.
  • 함수의 생성자 매개변수 유형 또는 반환값 유형 등 데이터 유형을 살펴본다.
  • 함수/클래스 수준의 문서나 주석문을 읽어본다.
  • 직접 물어본다.
  • 자세한 구현 코드를 읽는다.

하지만 효용성이 있는 것은 위 두 가지 방법이 전부다. 주석이나 문서는 제때 업데이트 된다는 보장이 없고 구현 코드를 읽는 것은 코드 양이 많을 경우 시간 비효율적이다. 추상화를 만드는 데에 장점이 하위 문제에 대해 제대로 알지 못해도 해결책을 사용할 수 있는 것인데, 하위 문제 해결 코드를 다 읽어야만 사용법을 이해할 수 있게 설계한다면 이는 추상화를 사용하는 큰 장점을 위반하는 것이 된다.

코드 계약

Programming by Contract(계약에 의한 프로그래밍), Design by Contract(계약에 의한 디자인) 이라는 원칙이 있다. 이는 코드 간의 상호 작용을 계약처럼 생각하는 철학이다. 코드는 특정 요건을 충족해야 하며 호출되는 코드는 원하는 값을 반환하거나 일부 상태를 수정한다. 이는 계약에서 정의되기 때문에 예상과 다르게 실행되어서는 안된다.

  • 선결 조건(Precondition): 코드를 호출하기 전에 사실이어야 하는 것. 예를 들어 시스템은 어떤 상태여야 하고 코드에 어떤 입력을 공급해야 하는지
  • 사후 조건(Postcondition): 코드가 호출된 후에 사실이어야 하는 것. 예를 들어 시스템이 새로운 상태에 놓인다든지 반환되는 값과 같은 사항
  • 불변 사항(Invariant): 코드가 호출되기 전과 후에 시스템 상태를 비교해서 변경되지 않아야 하는 사항

코드의 계약 정의

  • 명확한 부분
    • 함수와 클래스 이름
    • 인자 유형
    • 반환 유형
    • 검사 예외 (checked exception)
  • 세부 조항
    • 주석문과 문서
    • 비검사 예외 (unchecked exception)

세부 조항은 줄이는 게 훨씬 낫다. 사람들은 주석문과 문서를 잘 읽지 않거나 대충 읽을 가능성이 높다. 또한 문서화는 제때 업데이트 되지 않는 경우가 많다.

코드에 세부 조항이 많은 아래 에시가 있다.

class UserSettings{
	UserSettings() {}

	// 이 함수를 사용해 설정이 올바르게 로드되기 전까지는 다른 함수는 호출하면 안된다.
	// 설정이 성공적으로 로드되면 참을 반환한다.
	Boolean loadSettings(File location) {}

	// init()은 다른 함수 호출 이전에 호출해야 하지만 loadSettings 함수 호출 이후에 호출해야 한다.
	init() {}

	// 사용자가 선택한 UI의 색상을 반환한다.
	// 선택된 색상이 없거나, 설정이 로드되지 않았거나, 초기화되지 않은 상태면 널을 반환한다.
	Color? getUiColor() {}
}

명확한 부분과 세부 조항을 나눠 보자.

  • 명확한 부분
    • 클래스 이름은 UserSettings이다. 사용자 설정 관련 내용으로 추측할 수 있다.
    • getUiColor()는 사용자가 선택한 UI의 색상을 반환할 것이 추측된다. 하지만 주석문을 읽지 않으면 어떤 상황에서 null이 반환되는지 알기 어렵다.
    • loadSettings()는 Bool을 반환하기 때문에 로드에 성공하면 참을 반환할거라는 것을 추측할 수 있다.
  • 세부 조항
    • 이 클래스는 정해진 순서로 호출해야 한다. loadSetting → init → getUiColor 순서가 아니면 getUiColor는 null을 반환한다.
    • loadSettings가 false를 반환하면 다른 함수를 사용할 수 없다.
    • getUiColor는 클래스가 설정되지 않았을 때도 null을 반환한다.

주석을 읽지 않으면 이 클래스는 제대로 사용할 수 없을 것이 분명한 코드다.

void setUiColor(UserSettings userSettings){
	Color? chosenColor = userSettings.getUiColor();
	if(chosenColor == null){
		ui.setColor(DEFAULT_UI_COLOR);
		return;
	}
	ui.setColor(chosenColor);
}

위 클래스를 사용하면 이렇게 발견하기 어려운 버그를 만들어내는 코드가 작성된다. 설정이 로드되지 않아 getUiColor는 항상 null을 반환하며 이미 설정된 색상이 있음에도 불러오지 못한다.

세부 조항을 제거하기

이제 위 코드를 개선해보자. 세부 조항을 제거하면 더 좋은 코드가 될 것이다.

컴파일 단계에서 제한

지켜야하는 조항이 있다면 애초에 어길 수 없게 만드는 것이 좋다. 코드가 오용되거나 잘못 설정되면 컴파일이 되지 않도록 하는 것이다

예시로 정적 팩토리 함수를 추가해 초기화가 이루어진 인스턴스만 사용자가 쓸 수 있도록 하는 방법이 있다.

class UserSettings{
	private UserSettings() {}

	static UserSettings? create(File location){
		UserSettings settings = new UserSettings();
		if (!settings.loadSettings(location)){
			return null;
		}
		settings.init();
		return settings;
	}

	private Boolean loadSettings(File location){}

	private void init(){}

	Color? getUiColor() {}

	}
  • UserSettings의 생성자와 init 함수를 private으로 만들고 static 정적 팩토리 함수 create를 추가한다. 사용자는 이 함수를 이용해야만 UserSettings를 사용할 수 있어서 초기화되지 않은 상태로 getUiColor를 실행할 수 없다.
  • 클래스의 사애를 변경하는 함수가 모두 private이면 외부에서 변경할 수 없어서 안전해진다.

세부 조항을 많이 없앳지만 여전히 문제는 있다. setting 값을 로드하다 실패하는 경우에 null을 반환하기 때문에 디버깅이 어렵다. 이런 경우에 오류 정보를 전파하는 것이 디버깅에 도움이 된다.

체크

위 방식은 컴파일 단계에서 오용을 방지하는 방식이었다면, 런타임 단계에서도 사고를 방지할 수 있다. 컴파일 단계에서 막는 것이 더 낫지만 모든 상황에서 그게 가능하지 않기 때문에 아무 조치도 취하지 않는 것보다는 런타임에서라도 막는것이 낫다.

체크는 코드 계약이 준수되었는지 확인하는 추가 로직이다.

  • 전제 조건 검사: 일부 코드를 실행하기 전 시스템 유효성을 확인하는 로직
  • 사후 상태 검사: 코드를 실행한 후 시스템 유효성을 확인하는 로직
class UserSettings{
	UserSettings() {}

	// 이 함수를 사용해 설정이 올바르게 로드 되기 전에는 다른 함수를 호출하면 안된다.
	bool loadSettings(File location){}

	void init()
		if(!haveSettingsBeenLoaded()) {
			throw new StateException("Settings not loaded")
		}
	}

	Color? getUiColor(){
		if (!hasBeenInitialized()){
			throw new StateException("Settings not loaded")
		}
	}
}

이제 클래스 함수가 순서대로 호출되지 않은 경우 예외가 발생해 오용을 방지할 수 있다. 하지만 위의 컴파일 단계에서 오용을 막은 것만큼 이상적이어 보이지 않는다.

체크가 잘 작동해서 실패가 되더라도 시스템 작동을 멈추지 않도록 하기 위해 상위 수준에서 예외가 처리되고 로그만 남기면 개발자가 로그를 따로 확인하지 않는 이상 버그를 인지하지 못할 가능성도 있다. 함수에 체크가 많다면 세부 조항을 없애는 것에 대해 고려해봐야 한다.

어서션 (assertion)

어서션은 보통 언어 차원에서 지원한다. 하지만 성능 향상과 코드 오류 발생률을 줄이기 위해 빌드 단계에서는 제외되는 경우가 많다. 체크와 크게 다르지 않다.

class UserSettings {
	Color? getUiColor(){
		assert(hasBeenInitialized() , "UserSettings is not initialized");
		}
	}

결론

  • 코드베이스는 계속 변화한다
  • 오용이 불가능한 코드를 작성하도록 노력하라
  • 코드를 작성하는 것은 코드 계약을 만드는 것이다
  • 코드의 세부 조항을 줄이기위해 고민하라
  • 컴파일러를 사용해 계약을 확인하도록 하는 것이 가장 우선이며 여건이 안될 경우 체크나 어서션도 활용하라