Chapter 02. 코드 스타일 레벨 업

Yeseong31·2023년 8월 19일
0

자바 코딩의 기술

목록 보기
2/8

훌륭한 코드는 짧고 단순하고 대칭을 이룬다. 문제는 어떻게 그렇게 하느냐다. - 숀 파렌트


매직 넘버를 상수로 대체

매직 넘버는 표면상 의미 없는 숫자이지만 프로그램의 동작을 제어한다.
하지만 매직 넘버를 사용하면 코드를 이해하기 어렵고 오류가 발생하기 쉬워진다.

class CruiseControl {

    private double targetSpeedKmh;

    void setPreset(int speedPreset) { 

        if (speedPreset == 2) {
            setTargetSpeedKmh(16944); 
        } else if (speedPreset == 1) {
            setTargetSpeedKmh(7667);
        } else if (speedPreset == 0) {
            setTargetSpeedKmh(0);
        }
    }

    void setTargetSpeedKmh(double speed) { 

        targetSpeedKmh = speed;
    } 
}
  • 매직 넘버를 사용하는 코드는 누가 봐도 이해하기 어렵다.
    • setPreset() 메서드를 정수와 함깨 호출하려면 메서드 내부에 대한 이해가 필수로 요구된다.
    • 또한 speedPreset과 대응하는 실제 targetSpeedKmh임의적이다.
class CruiseControl {

    static final int STOP_PRESET = 0;
    static final int PLANETARY_SPEED_PRESET = 1; 
    static final int CRUISE_SPEED_PRESET = 2;

    static final double CRUISE_SPEED_KMH = 16944; 
    static final double PLANETARY_SPEED_KMH = 7667; 
    static final double STOP_SPEED_KMH = 0;

    private double targetSpeedKmh;

    void setPreset(int speedPreset) { 

        if (speedPreset == CRUISE_SPEED_PRESET) {
            setTargetSpeedKmh(CRUISE_SPEED_KMH); 
        } else if (speedPreset == PLANETARY_SPEED_PRESET) {
            setTargetSpeedKmh(PLANETARY_SPEED_KMH);
        } else if (speedPreset == STOP_PRESET) {
            setTargetSpeedKmh(STOP_SPEED_KMH);
        }
    }

    void setTargetSpeedKmh(double speed) { 

        targetSpeedKmh = speed;
    } 
}
  • 이해하기 어려운 매직 넘버 대신 static final로 정의한 상수를 사용하면 코드를 이해하기 쉬워진다.
  • 하지만 지금의 코드는 너무 많은 상수로 인해 코드가 지저분해 보인다.

정수 상수 대신 열거형

옵션을 모두 열거할 수 있다면 자바 타입 시스템이 제공하는 방법이 더 낫다.

  • 자바와 같은 정적 타입 언어오류를 프로그램 실행 전에 발견할 수 있는 기능을 제공한다.
class CruiseControl {

    private double targetSpeedKmh;

    void setPreset(SpeedPreset speedPreset) {

        Objects.requireNonNull(speedPreset);

        setTargetSpeedKmh(speedPreset.speedKmh); 
    }

    void setTargetSpeedKmh(double speedKmh) {

        targetSpeedKmh = speedKmh;
    } 
}

enum SpeedPreset {
    STOP(0), PLANETARY_SPEED(7667), CRUISE_SPEED(16944);

    final double speedKmh;

    SpeedPreset(double speedKmh) {

        this.speedKmh = speedKmh;
    }
}
  • 자바 타입 시스템은 가끔 들어오는 유효하지 않은 입력값을 막는 데 큰 역할을 한다.
  • 그 중에서 enum 타입은 가능한 옵션을 열거하는 데 사용한다.

enum의 장점

  • 더 이상 존재하지 않는 값을 setPreset() 메서드로 넣을 수 없게 되었다. (알아도 컴파일러가 중지시킴)
  • 상수를 사용하는 것과 같이 여전히 의미 있는 이름으로 값을 참조할 수 있다.
  • 값 비교를 위한 if~else if 문을 제거할 수 있다.

For 루프 대신 For-Each

class LaunchChecklist {

	List<String> checks = Arrays.asList("Cabin Pressure", "Communication", "Engine");

	Status prepareForTakeoff(Commander commander) {

		for (int i = 0; i < checks.size(); i++) {
			boolean shouldAbortTakeoff = commander.isFailing(checks.get(i));
			if (shouldAbortTakeoff) {
				return Status.ABORT_TAKE_OFF;
			}
		}

		return Status.READY_FOR_TAKE_OFF;
	}
}
  • 위 코드를 보면 리스트 내 다음 원소에 접근할 때가 아니면 인덱스를 쓰지 않는다.
  • 인덱스 변수protected가 아니기 때문에 언제든 덮어쓸 수 있는 문제가 있다.
  • 또한 인덱싱된 컬렉션을 순회하는 과정에서 종료 기준<인지 <=인지에 따라 IndexOutOfBoundsExceptions가 일어날 수 있다.

인덱스 변수가 제공하는 정보를 자세히 알아야 하는 경우는 드물다.
이럴 때에는 세부 순회 내용은 보호할 수 없어도 프로그래머에게는 숨기는 식으로 작성해야 한다.

class LaunchChecklist {

	List<String> checks = Arrays.asList("Cabin Pressure", "Communication", "Engine");

	Status prepareForTakeoff(Commander commander) {
	
		for (String check : checks) {  // 변경
			boolean shouldAbortTakeoff = commander.isFailing(check);
			if (shouldAbortTakeoff) {
				return Status.ABORT_TAKE_OFF;
			}
		}

		return Status.READY_FOR_TAKE_OFF;
	}
}
  • for-each 문을 사용하면 반복 인덱스를 더 이상 다루지 않아도 된다.
  • 자바는 매 반복마다 자료 구조에서 새로운 객체를 가져와서 check에 할당한다.
  • 이는 인덱싱된 컬렉션 이외에도 Set 등과 같이 인덱싱되지 않은 컬렉션에서도 잘 동작한다.

또 다른 순회 매커니즘은 순회하며 컬렉션 수정하지 않기처럼 반복자(iterator)를 사용하는 방법이 있다.


순회하며 컬렉션 수정하지 않기

  • 컬렉션 순회 중에 요소를 수정하게 되면 ConcurrentModificationException 예외가 발생한다.
    • List 인터페이스의 표준 구현
    • Set, Queue와 같은 Collection 인터페이스의 구현 등

단일 스레드 애플리케이션에서 ConcurrentModificationException이라는 이름이 낯설게 느껴질 수 있다.
여기서의 동시(concurrency) 실행Collection을 순회하는 동안 그 컬렉션을 수정한다는 의미이다.
참고로 ConcurrentModificationException은 자바 컴파일 시점에 잡지 못한다.

class Inventory {

	private List<Supply> supplies = new ArrayList<>();

	void disposeContaminatedSupplies() {
		
		// 이터레이터 사용
		Iterator<Supply> iterator = supplies.iterator();
		
		while (iterator.hasNext()) {
			if (iterator.next().isContaminated()) {
				iterator.remove();
			}
		}
	}
}
  • 문제를 해결할 직관적인 방법은 리스트를 순회하면서 타겟이 되는 원소를 찾고, ‘그 후’ 앞에서 발견한 원소를 모두 제거하는 것이다.
  • 순회를 먼저 진행하고, 나중에 수정하는 방법을 사용하는 것이다.

잘 동작하지만 코드 몇 줄을 더 적어야 하고, 순회하는 동안 타겟이 되는 원소를 저장할 자료 구조가 필요하다.

  • 위에서는 supplies 컬렉션의 Iterator를 활용하는 while 루프라는 새로운 순회 방식을 사용한다.
  • hasNext()로 남은 원소를 확인하고, next()로 다음 원소를 얻고, 반환된 마지막 원소를 remove() 한다.
  • List를 직접 수정하지 않더라도 iterator를 사용하면 순회 중에도 모든 작업을 올바르게 수행한다.

for-each 문도 내부적으로 iterator를 사용한다.

CopyOnWriteArrayList와 같은 특수 List 구현은 순회와 수정을 동시에 수행하기도 한다.
하지만 리스트에 원소를 추가하거나 삭제할 때마다 매번 전체 리스트를 복사해서 시간/공간적 문제가 될 수 있다.

자바 8부터는 람다를 통해 Collection.removeIf() 메서드를 사용할 수 있다.


순회하며 계산 집약적 연산하지 않기

자료 구조를 순회할 때는 수행할 연산 유형에 주의해야 한다.

class Inventory {

    private List<Supply> supplies = new ArrayList<>();

    List<Supply> find(String regex) {

        List<Supply> result = new LinkedList<>();

        for (Supply supply : supplies) {
            if (Pattern.matches(regex, supply.toString())) { 
                result.add(supply);
            } 
        }

        return result;
    } 
}
  • 자바에서는 정규식 regex로 쿼리 문자열을 만든다.

자바 API의 java.util.regex.Pattern 클래스는 정규식을 만들고 관련 메서드들을 제공한다.

  • 위 코드처럼 Pattern의 정적 메서드인 matches()를 호출하여 정규식 검색하는 방식이 많이 사용된다.
  • 하지만 이 방법은 성능을 저하시키는 요인이 될 수 있다.
  • 자바는 String의 표현식인 regex를 가져온 뒤, regex로부터 특수한 목적의 오토마톤(automaton)을 만든다.

오토마톤(automaton)패턴을 따르는 문자열만 허용하고, 나머지는 모두 거절한다.

  • Pattern.matches(regex, supply.toString())는 오토마톤을 컴파일해서 supply.toString()과 매칭시켜본다.
  • 이때 정규식 오토마톤 컴파일은 클래스 컴파일처럼 처리하는 데 시간이 든다.
  • 이는 일회성 동작이지만, 반복적인 정규식 컴파일은 성능에 영향을 줄 수 있다.
class Inventory {

    private List<Supply> supplies = new ArrayList<>();

    List<Supply> find(String regex) {

        List<Supply> result = new LinkedList<>();

		// 정규식은 메서드 호출 전에 딱 한 번만 컴파일
        Pattern pattern = Pattern.compile(regex);

        for (Supply supply : supplies) {
            if (pattern.matcher(supply.toString()).matches()) { 
                result.add(supply);
            } 
        }
        return result;
    } 
}
  • 위 코드처럼 메서드를 호출할 때 정규식을 딱 한 번만 컴파일하면 성능 저하를 막을 수 있다.

  • 이렇게 하려면 Pattern.matches() 호출을 두 번의 연산으로 분해해야 한다.

    1. 표현식 컴파일
      Pattern pattern = Pattern.compile();

    2. 컴파일된 표현식 실행
      pattern.matcher(supply.toString()).matches()


새 줄로 그루핑

코드 블록이 서로 붙어 있다면 보통 한 덩어리로 간주한다.
각각의 블록을 새 줄로 분리하면 코드 이해도를 높일 수 있다.

enum DistanceUnit {

    MILES, KILOMETERS;

    static final int IDENTITY = 1;

    static final double MILE_IN_KILOMETERS = 1.60934;
    static final double KILOMETER_IN_MILES = 1 / MILE_IN_KILOMETERS;

    double getConversionRate(DistanceUnit unit) { 
        if (this == unit) {
            return IDENTITY; 
        }

        if (this == MILES && unit == KILOMETERS) { 
            return MILE_IN_KILOMETERS;
        } else {
            return KILOMETER_IN_MILES;
        } 
    }
}

연관된 코드와 개념은 함께 그루핑하고, 서로 다른 그룹은 빈 줄로 각각 분리해야 한다.

  • 위 코드에서는 다음의 기준으로 빈 줄을 추가했다.
    • IDENTITY 필드를 다른 상수들과 분리했다.
    • getConversionRate()에서는 두 개의 if 블록을 서로 분리했다.

로버트 C. 마틴은 <클린 코드>에서 수직 서식화를 신문에 비유했다.

  • 훌륭한 기사는 제목(클래스명)으로 시작한다.
  • 그다음으로 섹션 머릿말(공개 멤버/생성자/메서드)이 온다.
  • 뒤이어 세부 내용(비공개 메서드)이 위치한다.
  • 코드를 이렇게 정리하면 코드를 읽기만 해도 클래스를 훨씬 더 쉽게 이해할 수 있다.

이어붙이기 대신 서식화

긴 문자열을 생성할 때 서식 문자열을 사용하면 더 읽기 쉽게 만들 수 있다.

class Mission {

    Logbook logbook;
    LocalDate start;

    void update(String author, String message) {
        final LocalDate today = LocalDate.now();
        
		String entry = String.format(
				"%S: [%tm-%<te-%<tY](Day %d)> %s%n", 
                author, today,ChronoUnit.DAYS.between(start, today) + 1, message);
        
		logbook.write(entry);
    }
}
  • 서식 문자열의 핵심은 String 레이아웃과 데이터를 분리하는 것이다.
  • %S, %tm, %te, %Y, %d, %s, %n 등은 모두 위치 지정자이다.

위치 지정자와 일반 데이터가 복잡하게 뒤섞여 있으면 무엇이 출력되는지 알기 힘들다.
하지만 이 방식은 문서화가 잘 된 표준이자 훌륭한 대안이다.

만약 문자열이 길다면 강력한 템플릿 엔진인 StringTemplate의 사용을 권장한다.

서식화된 문자열은 몇 가지 예제를 들면서 문서화를 해놓는 것이 좋다.


직접 만들지 않고 자바 API 사용하기

자바 API는 시간이 지나면서 거대해졌고, 빠르고 버그가 거의 없는 표준 라이브러리가 많이 만들어졌다.

class Inventory {

    private List<Supply> supplies = new ArrayList<>();

    int getQuantity(Supply supply) { 

        if (supply == null) {
            throw new NullPointerException("supply must not be null"); 
        }

        int quantity = 0;
        for (Supply supplyInStock : supplies) {
            if (supply.equals(supplyInStock)) {
                quantity++;
            } 
        }

        return quantity;
    }
}
  • 위 코드는 필요 이상으로 장황하다.
    • null 값이 없도록 입력 매개변수를 “직접” 검증하고 있다.
    • 내부 자료 구조로서 추상 타입을 “직접” 사용하고 있다.
    • 일반적인 for 루프 대신 for-each 루프로 자료 구조를 “직접” 순회하고 있다.
class Inventory {

	private List<Supply> supplies = new ArrayList<>();

	int getQuantity(Supply supply) {

		Objects.requireNonNull(supply, "supply must not be null");
		return Collections.frequency(supplies, supply);
	}
}
  • 자바 API를 이용하면 개발자가 “직접” 작성해야 하는 로직을 획기적으로 줄일 수 있다.
    • Objects 유틸리티 클래스의 requireNonNull() 메서드로 null 검증을 할 수 있다.
    • 유틸리티 클래스인 CollectionsCollection 내 객체 빈도수를 세는 frequency() 메서드를 제공한다.

이 외에도 자바 API는 온갖 유용한 기능을 제공하는 수천 개 클래스를 포함하고 있다.


이 장의 내용은 [자바 코딩의 기술: 똑똑하게 코딩하는 법]의 2장 내용을 정리한 것입니다.

profile
역시 개발자는 알아야 할 게 많다.

0개의 댓글

관련 채용 정보