Chapter 7. 코드를 오용하기 어렵게 만들라

w-beom·2023년 4월 16일
1
post-thumbnail

Chapter 7. 코드를 오용하기 어렵게 만들라

■ 코드 오남용으로 인해 버그가 발생하는 방식
■ 코드를 오용하기 쉬운 흔한 방식
■ 코드를 오용하기 어렵게 만드는 기술

코드가 오용하기 쉽게 작성된다면, 조만간 오용될 가능성이 있고 소프트웨어가 올바르게 작동하지 않을 것이다.
비합리적이거나 애매한 가정에 기반해서 코드가 작성되거나 다른 개발자가 잘못된 일을 하는 것을 막지 못할 때 코드는 오용되기 쉽다.
코드를 잘못 사용할 수 있는 몇 가지 일반적인 경우는 다음과 같다.

  • 호출하는 쪽에서 잘못된 입력을 제공
  • 다른 코드의 부수 효과(side effect) (입력 매개변수 수정 등)
  • 정확한 시간이나 순서에 따라 함수를 호출하지 않음
  • 관련 코드에서 가정과 맞지 않게 수정이 이루어짐

7.1 불변 객체로 만드는 것을 고려하라.

객체가 생성된 후에 상태를 바꿀 수 없다면 이 객체는 불변immutable이다. 불변성이 바람직한 이유를 이해하기 위해서는 그 반대인 가변 mutability 객체가 어떻게 문제를 일으킬 수 있는지 고려해야 한다.

가변 객체가 일으키는 문제

  • 가변 객체는 추론하기 어렵다.
    • 요즘 치킨 배달을 시키면 포장 박스에 봉인씰이 붙여져서 온다. 봉인씰은 붙인 후에 누군가가 떼면 자국이 선명하게 생기기 때문에 누군가가 뜯었다는 증거가 된다. 하지만 봉인씰을 뜯었다고 안에 들어있는 치킨에 어떤 짓을 했는지는 뜯은 사람만 알고있다. 사실 봉인씰만 뜯고 치킨에는 아무짓도 안했을 수도 있는 것이다.
      코드를 작성할 때 만약 객체가 불변이라면, 그것은 마치 누구도 해제할 수 없는 변질 방지 봉인을 붙여 놓는 것과 같다. 객체를 여기저기에 전달하더라도 어디서도 그 객체가 변경됐거나 무언가 추가되지 않았다는 것을 확신할 수 있다.
  • 가변 객체는 다중 스레드에서 문제가 발생할 수 있다.
    • 객체가 가변적이면 해당 객체를 사용하는 다중 스레드 코드가 특히 취약할 수 있다. 한 스레드가 객체를 읽는 동안 다른 스레드가 그 객체를 수정하는 경우 오류가 발생할 수 있다. 예를 들어 한 스레드가 리스트에서 마지막 요소를 제거하는 동안 다른 스레드가 그 요소를 읽으려는 경우다.

객체를 불변으로 만드는 것이 항상 가능하지도 않고, 또 항상 적절한 것도 아니다. 필연적으로 상태 변화를 추적해야 하는 경우가 있고 이때는 가변적인 자료구조가 필요하다. 하지만 방금 설명했듯이 가변적인 객체는 코드의 복잡성을 늘리고 문제를 일으킬 수 있기 때문에, 기본적으로는 불변적인 객체를 만들되 필요한 곳에서만 가변적이 되도록 하는 것이 바람직하다.

7.1.1 가변 클래스는 오용하기 쉽다.

클래스를 가변적으로 만드는 가장 일반적인 방법은 세터setter 함수를 제공하는 것이다.

class User{
	private String name;
    private int age;
   
   	void setName(String name){ //setter 함수를 이용해 User의 name을 수정할 수 있다.
    	this.name = name;
	}
    void setAge(int age){
    	this.age = age;
    }
}

가변성으로 인한 버그

// User의 name을 출력하는 함수
public void printName(User user) {
	doSomething(user);
    System.out.println(uset.getName());
}

private void doSomething(User user){
	// doSomething
    user.setName("A"); // 이 메소드에서 user의 name을 강제로 A로 set해버린다.
}

7.1.2 해결책: 객체를 생성할 때만 값을 할당하라

클래스 내에서 변수를 정의할 때 심지어 클래스 내에서도 변수의 값이 변경되지 않도록 할 수 있다. 이 방법은 언어에 따라 다른데 공통적으로 사용하는 키워드는 const, final, readonly이다.

class User{
	private final String name;
   	private final int age;
    
    public User(String name, int age){
    	this.name = name;
        this.age = age;
    }
    
    public getName() {
    	return this.name;
    }
    public getAge() {
    	return this.age;
    }
}

7.1.3 해결책: 불변성에 대한 디자인 패턴을 사용하라

이를 위한 두 가지 유용한 디자인 패턴은 다음과 같다.

  • 빌더 패턴
  • 쓰기 시 복사 패턴

7.2 객체를 깊은 수준까지 불변적으로 만드는 것을 고려하라

클래스가 실수로 가변적으로 될 수 있는 일반적인 경우는 깊은 가변성(deep mutability) 때문이다. 이 문제는 멤버 변수 자체가 가변적인 유형이고 다른 코드가 멤버 변수에 액세스할 수 있는 경우에 발생할 수 있다.

class Users {
	private final List<User> users;
    
    public List<User> getUsers(){
    	return this.users;
    }
}

public void doSomething(Users users){
	List<User> list = users.getUsers();
    list.add(new User()); // 깊은 가변성으로 인해 users의 멤버변수가 수정된다.
}

7.2.2 해결책: 방어적으로 복사하라

class Users {
	private final List<User> users;
    
    public List<User> getUsers(){
    	return new List<User>(users);
    }
}

public void doSomething(Users users){
	List<User> list = users.getUsers();
    list.add(new User()); // getUser메소드 호출 시 새로운 List객체가 반환 되므로 users의 멤버변수의 users List는 변경이 없다.
}

7.2.3 해결책: 불변적 자료구조를 사용하라

class Users {
	private final List<User> users;
    
    public List<User> getUsers(){
    	return Collections.unmodifiableList(this.users);
    }
}

public void doSomething(Users users){
	List<User> list = users.getUsers();
    list.add(new User()); // list가 불변객체이므로 add메소드 호출 시 exception이 발생한다.
}

7.3 지나치게 일반적인 데이터 유형을 피하라

정수, 문자열 및 리스트 같은 간단한 데이터 유형은 코드의 기본적인 구성 요소 중 하나다. 정수나 리스트와 같은 유형으로 표현이 ‘가능’ 하다고 해서 그것이 반드시 ‘좋은’ 방법은 아니다. 설명이 부족하고 허용하는 범위가 넓을수록 코드 오용은 쉬워진다.

7.3.1 지나치게 일반적인 유형은 오용될 수 있다.

예를 들어 2D 지도의 위치는 위도와 경도에 대한 두 가지 값이 모두 필요하다. 지도에서 위치를 처리하는 코드를 작성할 경우 위치를 나타내는 자료구조가 필요하다. 자료구조에는 해당 위치의 위도와 경도에 대한 값이 모두 포함되어야 한다.

지나치게 일반적인 데이터 유형

class LocationDisplay {
  private final DrawableMap map;
  
  /**
  * 지도 위에 제공된 모든 좌표의 위치를 표시한다.
  *
  * 리스트의 리스트를 받아들이는데, 내부의 리스트는 정확히
  * 두 개의 값을 가지고 있다. 첫 번째 값은 위치의 위도이고
  * 두 번째 값은 경도를 나타낸다(둘 다 각도 값).
  */
  void markLocationsOnMap(List<List<Double>> locations) {
    for (List<Double> location in locations) {
      map.markLocation(location[0], location[1]);
    }
  }
}

코드를 오용하기 쉽게 만드는 단점이 있는데 다음과 같다.

  • List<List<Double>> 유형 자체로는 아무것도 설명해주지 않는다. 개발자가 markLocationsOn
    Map() 함수에 대한 주석문을 읽어보지 않는다면 이 리스트가 무엇인지, 어떻게 이해해야 하는지 알지 못할 것이다.
  • 개발자는 리스트에서 어떤 항목이 위도와 경도인지 혼동하기 쉽다. 만약 주석문을 완전히 읽지 않았거나 잘못 이해했다면, 위도와 경도의 순서를 뒤바꿀 수 있고, 이로 인해 버그가 발생할 것
    이다.
  • 형식 안전성이 거의 없다. 컴파일러가 목록 내에 몇 개의 요소가 있는지 보장할 수 없다. 그림 7.5에서 볼 수 있듯이 내부 리스트에 들어 있는 항목의 수가 잘못될 수 있다. 이렇게 되면 코드가 정상적으로 컴파일되지만 런타임에 문제가 일어난다.

7.3.2 페어 유형은 오용하기 쉽다.

7.3.3 해결책: 전용 유형 사용

지도의 2D 위치를 나타내는 경우 코드의 오용 혹은 오해의 소지를 줄이는 간단한 방법은 위도와 경도를 나타내는 전용 클래스를 정의하는 것이다.

/**
* 위도와 경도를 각도로 나타낸다.
*/
class LatLong {
  private final Double latitude;
  private final Double longitude;
  LatLong(Double latitude, Double longitude) {
    this.latitude = latitude;
    this.longitude = longitude;
  }
  Double getLatitude() {
    return latitude;
  }
  Double getLongitude() {
    return longitude;
  }
}
class LocationDisplay {
  private final DrawableMap map;
  
  /**
  * 지도 위에 제공된 모든 좌표의 위치를 표시한다.
  **/
  void markLocationsOnMap(List<LatLong> locations) {
    for (LatLong location in locations) {
      map.markLocation(location.getLatitude(), location.getLongitude());
    }
  }
}

7.4 시간 처리

시간을 다룰 때 코드를 잘못 사용하고 혼동을 일으킬 여지가 굉장히 많다.

  • 어떤 때는 ‘1969년 7월 21일 02:56 UTC’와 같이 절대적인 시간을 지칭하지만 또 다른 때는 ‘5분 내’와 같은 상대적인 시간으로 표현한다.
  • 오븐에서 30분 굽기’와 같은 시간의 양을 언급하는 경우도 있다. 시간은 분, 초, 밀리초 등 다양한 단위 중 하나로 표시할 수 있다.
  • 표준 시간대, 일광 절약 시간, 윤년leap year, 심지어 윤초leap second와 같은 개념도 있어서 상황이 훨씬 더 복잡하다.

7.4.1 정수로 시간을 나타내는 것은 문제가 될 수 있다.

시간을 나타낼 때 일반적으로 정수나 큰 정수long integer를 사용한다. 이것으로 어느 한순간을 의미하는 시각과 시간의 양, 두 가지를 모두 나타낸다.
정수는 매우 일반적인 유형이기 때문에 시간을 나타내는 데 사용하는 경우 코드가 오용되기 쉽다

7.4.2 해결책: 적절한 자료구조를 사용하라.

  • 양으로서의 시간과 순간으로서의 시간의 구분
    Java.time 패키지에서 Instant라는 클래스와 Duration이라는 클래스를 제공한다.
/** 
* @param message 보낼 메시지 
* @param deadline 데드라인이 경과하기까지 메시지가 전송 
* 되지 않으면 전송은 취소된다. 
* @return 메시지가 전송되면 참을, 그렇지 않으면 거짓 
*/

Boolean sendMessage(String message, Duration deadline) {
	...
}

더 이상 단위에 대한 혼동이 없다.
단위가 사용되어야 하는지 설명하기 위한 계약의 세부 조항이 필요하지 않으며, 실수로 잘못된 단위를 제공하는 것이 불가능하다.

Duration duration1 = Duration.ofSeconds(5);
print(duration1.toMillis()); // 출력: 5000
Duration duration2 = Duration.ofMinutes(2);
print(duration2.toMillis()); // 출력: 120000

7.5 데이터에 대해 진실의 원천을 하나만 가져야 한다.

코드에서 숫자, 문자열, 바이트 스트림과 같은 종류의 데이터를 처리하는 경우가 많다. 데이터는 종종 두 가지 형태로 제공된다.

  • 기본 데이터 primary data: 코드에 제공해야 할 데이터. 코드에 이 데이터를 알려주지 않고는 코드가 처리할 방법이 없다.
  • 파생 데이터 derived data: 주어진 기본 데이터에 기반해서 코드가 계산할 수 있는 데이터

예를 들어 은행계좌의 상태를 설명하는 데 필요한 데이터가 있을 수 있다. 여기에서 기본 데이터는 대변credit 금액과 차변debit 금액이다. 계좌 잔액은 파생 데이터인데 대변에서 차변을 뺀 금액이다.

기본 데이터는 일반적으로 프로그램에서 진실의 원천source of truth이 된다. 대변과 차변에 대한 값은 계좌의 상태를 완전히 설명하고 계좌의 상태를 추적하기 위해 저장되어야 하는 유일한 값이다.

7.5.1 또 다른 진실의 원천은 유효하지 않은 상태를 초래할 수 있다.

은행계좌의 경우 계좌 잔고액은 두 가지 기본 데이터에 의해 완전히 제한된다. 대변이 5달러이고 차변이 2달러인 상태에서 잔액이 10달러라고 하는 것은 말이 안 된다.

기본 데이터와 파생 데이터를 모두 처리하는 코드를 작성할 때, 이와 같이 논리적으로 잘못된 상태가 발생할 수 있다. 논리적으로 잘못된 상태가 발생할 수 있는 코드를 작성하면 코드의 오용이 너무 쉬워진다.

class UserAccount { 
  private final Double credit; 
  private final Double debit; 
  private final Double balance; 
  
  UserAccount(Double credit, Double debit, Double balance) { // 대변, 차변, 잔액이 모두 생성자에 전달된다.
    this.credit = credit; 
    this.debit = debit; 
    this.balance = balance; 
  } 
  Double getCredit() { 
    return credit; 
  } 
  Double getDebit() { 
    return debit; 
  } 
  Double getBalance() { 
    return balance; 
  }
}


// 잔액이 차변에서 대변을 빼는 잘못된 방법으로 계산된다.
UserAccount account = new UserAccount(credit, debit, debit - credit); 

7.5.2 해결책: 기본 데이터를 유일한 진실의 원천으로 사용하라

class UserAccount { 
  private final Double credit; 
  private final Double debit;
  
  UserAccount(Double credit, Double debit) { 
    this.credit = credit; 
    this.debit = debit; 
  } 
  Double getCredit() { 
    return credit; 
  } 
  Double getDebit() { 
    return debit; 
  } 
  Double getBalance() {  // 잔액은 대변과 차변으로 계산된다.
    return credit - debit; 
  }
}

7.6 논리에 대한 진실의 원천을 하나만 가져야 한다.

진실의 원천 sources of truth은 코드에 제공된 데이터에만 적용되는 것이 아니라 코드에 포함된 논리에도 적용된다.

7.6.1 논리에 대한 진실의 원천이 여러 개 있으면 버그를 유발할 수 있다.

정숫값을 기록한 후에 파일로 저장하는 클래스를 보여준다. 이 코드에는 값이 파일에 저장되는 방식에 대한 두 가지 중요한 세부 정보가 있다.

  1. 각 값은 10진수 문자열 형식으로 변환된다.
  2. 그다음 각 값의 문자열을 쉼표로 구분하여 결합한다.
class DataLogger { 
  private final List<Int> loggedValues; 
  
  public void saveValues(FileHandler file) {
    String serializedValues = loggedValues.map(value -> value.toString(Radix.BASE_10)).join(","); 
    file.write(serializedValues); 
  }
}

DataLogger.saveValues()의 반대 과정, 즉 파일을 열고 정수로 읽어 들이는 코드도 어딘가에 있을 가능성이 크다.

이 코드는 DataLogger 클래스와 완전히 다른 파일이고 코드베이스의 다른 부분에 있을 가능성이 크지만 논리는 서로 일치해야 한다.

  1. 문자열을 쉼표로 구분해서 분할해 문자열의 목록을 만든다.
  2. 목록의 각 문자열을 십진수 정수로 읽는다.
class DataLoader { 
  public List<Integer> loadValues(FileHandler file) { 
    return file.readAsString().split(",").map(str -> Int.parse(str, Radix.BASE_10)); 
}

이 시나리오에서 값이 파일에 저장되는 형식은 논리의 중요한 부분이지만, 이 형식이 무엇인지에 대해서는 진실의 원천이 두 개 존재한다.
클래스가 모두 동일한 논리를 포함하면 모든 것이 잘 작동하지만 한 클래스가 수정되고 다른 클래스가 수정되지 않으면 문제가 발생한다.

7.6.2 해결책: 진실의 원천은 단 하나만 있어야 한다.


직렬화된 정수를 저장하는 형식에 대한 진실의 원천을 하나만 갖게 되면 코드가 더 견고해지고 오류의 가능성을 줄일 수 있다.

class IntListFormat {
    private const String DELIMITER = ",";
    private const Radix RADIX = Radix.BASE_10;
    String serialize(List <Integer> values) {
        return values.map(value - > value.toString(RADIX)).join(DELIMITER);
    }
    List <Integer> deserialize(String serialized) {
        return serialized.split(DELIMITER).map(str - > Int.parse(str, RADIX));
    }
}
class DataLogger {
    private final List<Integer> loggedValues;
    private final IntListFormat intListFormat;
    
    public void saveValues(FileHandler file) {
        file.write(intListFormat.serialize(loggedValues));
    }
}

class DataLoader {
    private final IntListFormat intListFormat;
    
    List <Integer> loadValues(FileHandler file) {
        return intListFormat.deserialize(file.readAsString());
    }
}

이렇게 하면 앞서 DataLogger 클래스에서 사용하는 형식을 변경하지만 실수로 DataLoader 클래스는 변경하지 않는 것과 같은 위험은 제거된다.

profile
습득한 지식과 경험을 나누며 다른 사람들과 문제를 함께 해결해 나가는 과정에서 서로가 성장할 수 있는 기회를 만들고자 노력합니다.

0개의 댓글