빨간색코딩

[이펙티브 자바3판] 9장 일반적인 프로그래밍 원칙 본문

Java

[이펙티브 자바3판] 9장 일반적인 프로그래밍 원칙

빨간색소년 2018. 12. 13. 21:47

해당 내용은 이펙티브 자바 3판 (조슈아 블로크 지음, 이복연 옮김)를 읽고 나같은 초심자의 눈으로 이해한 내용을 정리해보았다.

이전 장은 아직 정리가 덜 끝나서... 먼저 끝난 9장부터 포스팅한다.


책에 있는 내용을 기반으로 썼지만 책에 없는 내용도 조금 적었다. (인터페이스화에 대한 고찰, 컴파일러의 문자열연산 최적화 등)


9장의 아이템 목록

  1. 지역변수의 범위를 최소화하라
  2. 전통적인 for문보다는 foreach문을 사용하여라
  3. 라이브러리를 익히고 사용하라
  4. 정확한 답이 필요하다면 float와 double은 피하라
  5. 박싱된 기본 타입보다는 기본 타입을 사용하라
  6. 다른 타입이 적절하다면 문자열 사용을 피하라
  7. 문자열 연결은 느리니 주의하라
  8. 객체는 인터페이스를 사용해 참조하라
  9. 리플렉션보다는 인터페이스를 사용하라
  10. 네이티브 메소드는 신중히 사용하라
  11. 최적화는 신중히 하라
  12. 일반적으로 통용되는 명명 규칙을 따르라

아이템57. 지역변수의 범위를 최소화하라

지역변수의 유효 범위를 최소로 줄이면 코드 가독성과 유지 보수성은 높아지고 오류 가능성은 낮아진다. 아래는 지역변수의 범위를 좁히는 방법들이다.

57-1. 지역변수는 사용할 때 선언 및 할당해라

지역변수의 범위를 줄이는 가장 좋은 방법은 처음 쓸 때 선언과 동시에 초기화하는 것이다. 맨 위에서 미리 선언부터 잔뜩 해두면 가독성이 떨어진다. 또 사용시점엔 초기 값이 기억이 안날 수도 있다. 여기서 try-catch 문은 예외이다. 초기화하다가 예외를 던질수 있다면, try 바로 위에 선언하고, try 블록 안에서 초기화해주어야 한다.

57-2. while문보다는 for을 사용하라

반복 변수의 값을 반복문이 종료된 뒤에도 써야하는 상황이 아니라면 while 문보다는 for 문을 쓰는 편이 낫다. while문은 반복변수를 반복문 바깥 블록에 선언해야하기 때문이다.

  • 컬렉션 순회 관용구
for (Element e : c) {
    ... // e로 무언가 수행
}
  • 반복자 사용 관용구
for (Iterator<Element> i = c.iterator(); i.hasNext();) {
    Element e = i.next();
    ... // e로 무언가 수행
}

57-3. 메소드를 작게 만들어라

메소드를 애초에 작게 유지하고 한가지 기능에 집중하게 만들어라

아이템58. 전통적인 for문보다는 foreach문을 사용하여라

향상된 for문(foreach)을 사용하면 반복자와 인덱스 변수를 사용하지 않으니 코드가 깔끔해지고 오류가 날 일도 사라진다. 또한 컬렉션과 배열을 모두 처리할 수 있다. (Iterable 인터페이스를 구현한다면 뭐든 순회할 수 있다)

아래는 for문으로 원소를 제거하려고 시도한 예제이다. 컴파일도 잘되고 동작도 하지만 엉뚱한 결과를 출력한다.

// 메인메소드
ArrayList<String> list = new ArrayList<>();
list.addAll(Arrays.asList("a", "b", "c", "d"));
System.out.println(list); // [a, b, c, d]

for (int i = 0; i < list.size(); i++) { // list의 원소들이 제거되면서 2번만 순회한다
    list.remove(i);
    System.out.println(list); // 처음엔 [b, c, d] , 이후에 [b, d] 출력
}

58-1. foreach문을 사용할 수 없는 경우

  1. 파괴적인 필터링(deftructive filtering) : 컬렉션을 순회하면서 선택된 원소를 제거하려면 반복자의 remove를 사용해야 하므로 foreach를 쓸 수 없다. (ConcurrentModificationException 발생)
  2. 변형(transforming) : 리스트나 배열을 순회하면서 그 원소의 값 일부 혹은 전체를 변경해야한다면 반복자나 인덱스를 사용해야 하므로 foreach를 쓸 수 없다.
  3. 병렬 반복(parallel iteration) : 여러 컬렉션을 병렬로 순회해야 한다면 각각의 반복자와 인덱스 변수를 사용해야 하므로 foreach를 쓸 수 없다.

아이템59. 라이브러리를 익히고 사용하라

아주 특별하거나 해당 프로젝트에서만 쓰이는 기능이 아니라면 누군가 이미 라이브러리 형태로 구현해놓았을 가능성이 크다. 아래는 라이브러리를 사용하면 얻는 이점들이다.

59-1. 표준 라이브러리를 사용하면 좋은 이유

  1. 표준 라이브러리를 사용하면 그 코드를 작성한 전문가의 지식과 이것을 사용한 다른 개발자의 경험, 노하우 문서까지 활용할 수 있다. (책에서 예제로 든 Random보다는 jdk 1.7부터 나온 ThreadLocalRandom을 추천, Random은 멀티 스레드에서 사용할 경우 Seed 값이 겹치는 경우가 발생할 수 있으나, ThreadLocalRandom은 안전하다.)
  2. 핵심 비즈니스 로직 외에 들이는 시간이 줄어든다.
  3. 따로 노력하지 않아도 성능이 지속적으로 개선되며 기능이 추가된다. 라이브러리 개발자들이 계속 노력하기 때문이다.
  4. 코드가 많은 사람들에게 낯익은 코드가 되며 유지보수성과 재활용성이 좋아진다.

라이브러리가 너무 방대하여 모든 API를 아는 것은 힘들지만 적어도 아래 패키지들은 잘 알면 꽤 유용하다.

  1. java.lang
  2. java.io
  3. java.util : 컬렉션, 스트림, 동시성

59-2. 서드파티 라이브러리

원하는 기능이 없다면 서드파티 라이브러리들을 사용해보자.

  1. apache commons : 자바관련 공통 컴포넌트 개발, StringUtils를 한번도 안써본 사람은 있어도 한번만 쓴 사람은 없다..
  2. google guava : 컬렉션, 캐싱, 문자열 처리 등 구글에서 만든 자바 라이브러리들
  3. jackson
  4. jsoup
  5. 많지만 라이브러리 소개 코너가 아니므로 생략..

아이템60. 정확한 답이 필요하다면 float와 double은 피하라

float와 double 타입은 과학과 공학 계산용으로 설계되었다. 이진 부동소수점 연산에 쓰이며 넓은 범위의 수를 빠르게 정밀한 근사치로 계산하도록 세심하게 설계되었다. 따라서 정확한 결과가 필요할 때는 사용하면 안된다. (ex. 금융) 0.1 혹은 10의 음의 거듭 제곱 수(0.01, 0.001 등)를 표현할 수 없기 때문이다. (ex. 1.03 - 0.42 = 0.6100000000000001)

대안으로는 2가지 방법이 있다. BigDecimal을 사용하거나 int와 long을 쓰되 소수점을 직접 관리해주는 방법이다.

  1. BigDecimal은 잘 계산되지만 원시 타입보다 쓰기 불편하고 훨씬 느리다.
  2. int와 long은 쉽게 사용할 수 있지만 자릿수를 다시 맞춰주는 등의 작업이 필요하다.

따라서 아홉 자리 십진수로 표현할 수 있다면 int를 사용하고, 열여덟 자리 십진수로 표현할 수 있다면 long 을 쓰자. 이것도 넘어간다면 BigDecimal 을 쓰면 된다.

아이템61. 박싱된 기본 타입보다는 기본 타입을 사용하라

아래는 원시타입과 박싱된 원시타입의 주된 차이이다.

  1. 원시타입은 값만 가지고 있으나, 박싱된 원시타입은 값과 식별성까지 갖고 있다. 즉, 박싱된 원시타입은 값이 같아도 다르다고 식별될 수 있다.
  2. 원시타입의 값은 언제나 유효하지만 박싱된 원시타입은 null을 가질 수 있다.
  3. 원시타입이 박싱된 원시타입보다 시간과 메모리 사용면에서 더 효율적이다.

61-1. 박싱된 원시타입의 문제

다음 비교자는 박싱된 원시타입에서 발생할 수 있는 첫번째 문제이다.

Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1)

// 메인메소드
naturalOrder.compare(new Integer(82), new Integer(82)); // 기대값 0, 실제값 1

앞에서 i < j 는 잘 작동하였으나, 뒤에서 i == j 이 값의 동등을 비교하지 않고 인스턴스 동치성을 판단한 것이다. 따라서 true 대신 false 가 반환되면서 오동작하였다. 즉, 박싱된 기본 타입에 == 연산자를 사용하면 오류가 발생한다.

아래는 두번째 문제이다.

Integer i;
if (i == 82) {
    ...
}

이 코드는 NullPointerException을 던진다. 원시타입과 박싱된 원시타입을 혼용한 연산에서는 박싱된 원시타입의 박싱이 자동으로 풀리기 때문이다. null을 언박싱했기 때문에 예외가 발생했다.

61-2. 박싱된 원시타입을 사용해야 할 때

  1. 타입 매개변수를 사용 : 제네릭에서 보았듯이 타입 매개변수에는 원시타입을 쓸 수 없다. 예를들면 컬렉션은 원시타입을 담을 수 없으므로 원소, 키, 값으로 박싱된 원시타입을 써야한다.
  2. 리플렉션을 통해 메소드를 호출할 때는 박싱된 원시타입을 써야한다.

아이템62. 다른 타입이 적절하다면 문자열 사용을 피하라

문자열은 텍스트를 표현하도록 설계되었다. 그런데 문자열은 원래 의도하지 않은 용도로도 쓰이는 경향이 있다. 문자열은 다른 값 타입을 대신하기에 적합하지 않다.

  1. 문자열은 값 자료형(value type)을 대신하기에 적합하지 않다. 수치형이라면 int, BigInteger 등을 쓰자.
  2. 문자열은 열거 타입을 대신하기에 적합하지 않다. (아이템34)
  3. 문자열은 혼합 타입(aggregate type)을 대신하기에 적합하지 않다. 여러 요소가 혼합된 데이터를 하나의 문자열로 표현하는 것은 대체로 좋지 않다. 구분자가 겹치면 escape 처리 및 파싱해서 써야하고 오류가능성도 커진다. 차라리 전용 클래스를 만드는 편이 낫다.
  4. 문자열은 권한(capability)을 표현하기엔 적합하지 않다. 문자열을 사용해 기능 접근 권한을 표현하는 것은 어렵다.

아이템63. 문자열 연결은 느리니 주의하라

문자열 연결 연산자(+)는 여러 문자열을 하나로 합쳐주는 편리한 방법이다. 그러나 이 연산자를 많이 사용하면 성능저하가 생길 수 있다. 문자열 연결 연산자로 문자열 n개를 잇는 시간은 n의 제곱에 비례한다. 문자열은 불변이므로 양쪽 내용 모두를 복사하므로 생기는 결과이다.

대표적인 예제는 반복문 내 result += str 같은 예제일 것이다. 성능을 포기하고 싶지 않다면 String 대신 StringBuilder 또는 StringBuffer를 사용하자.

63-1. StringBuilder 와 StringBuffer 의 차이

StringBuffer는 멀티스레드 환경을 염두에 두고 설계되었기 때문에, 메소드들마다 synchronized 가 걸려있다. 그러나 StringBuilder는 없다. 따라서 멀티스레드 환경에서 thread-safe 하게 쓰고 싶다면 StringBuffer 를 쓰고, 싱글스레드라면 StringBuilder를 쓰는게 유리하다.

63-2. 컴파일러의 문자열 연결 최적화

jdk 1.5부터는 문자열 연결 연산자(+)를 자동으로 최적화해준다. 아래의 예제를 보자

String plusOpStr1 = "a" + "b" + "c"; // 컴파일 시 String plusOpStr1 = "abc"; 로 바뀐다
String plusOpStr2 = "x" + plusOpStr1 + "y"; // 컴파일 시 String plusOpStr2 = (new StringBuilder("x")).append(plusOpStr1).append("y").toString(); 로 바뀐다
String plusOpStr3 = "";
for (int i = 0; i < 100; i++) {
    plusOpStr3 += "f"; // 컴파일 시 plusOpStr3 = (new StringBuilder(String.valueOf(plusOpStr3))).append(f).toString(); 로 바뀐다
}

단순한 상수 문자열 연결은 컴파일러가 이미 알아서 합쳐주었고, 가변변수는 StringBuilder의 append, toString으로 변하였다. 이를 활용해서 가독성있게 긴 문자열을 여러 줄로 나눠서 쓸 수도 있겠다. 다만 반복문에서는 append만 하는 것이 아니라, StringBuilder 를 계속 new 해서 쓰고 있기 때문에 명시적으로 써주는 게 성능에 좋을 것 같다.

아이템64. 객체는 인터페이스를 사용해 참조하라

아이템51에서 매개변수 타입으로 클래스가 아니라 인터페이스를 사용하라고 권장했다. 이것은 객체는 클래스가 아닌 인터페이스를 참조하라고 확장할 수 있다. 적합한 인터페이스만 있다면 매개변수 뿐 아니라 반환값, 변수, 필드를 전부 인터페이스 타입으로 선언하라. 객체의 실제 클래스를 사용해야 할 상황은 오직 생성자로 생성할 때 뿐이다.

인터페이스를 타입으로 사용하는 습관을 길러두면 프로그램이 훨씬 유연해질 것이다. (ex. List<String> nameList = new ArrayList<>();) 나중에 구현 클래스를 교체하고자 한다면 새 클래스의 생성자(또는 정적 팩토리)를 호출해주기만 하면 된다. (ex. List<String> nameList = new LinkedList<>();)

만약 적합한 인터페이스가 없다면 당연히 클래스로 참조해야 한다. 이 경우 보통 값 타입(String, Integer 등)에 해당된다. 또한 PriorityQueue 와 같이 Queue 인터페이스에 없는 새로운 메소드를 제공한다면 직접 참조해야한다.

64-1. 인터페이스화에 대한 개인적인 고찰

OCP(개방 폐쇄 원칙), DIP(의존 역전 원칙) 등에서 한결같이 하는 말이 구체클래스보단 인터페이스에 의존함으로써 해결하자는 것이다. 이 책의 저자 역시 이번 아이템에서 인터페이스를 최대한 쓸 것을 언급하고 있다. 이해는 가지만 서비스개발에서도 이것을 '언제' 지켜야하는 지는 논쟁이 될 수 있다.

Service - ServiceImpl 구조 : Spring 의 여러 교과서들이 말하고 있는 구조이다. 그러나 비즈니스 로직을 처리하는 서비스가 구현 클래스를 2개 이상 가질 일이 거의 없어도 꼭 인터페이스를 만들고 의존해야할까? 일단 인터페이스없이 만들고 새로운 구현체가 추가되는 시점에 인터페이스를 만들자는 의견도 있다.

DAO의 경우 DB에 의존하지 않고 테스트하기 위해 인터페이스를 만들고 MockDAO로 교체하여 테스트할 때 쓴다. 라고 말할 수도 있다. (mock을 직접 만드는 것 보단 mockito 추천!)

그러나 다른 의견도 있다. 구현 클래스는 캡슐내부이며 보이지 않는 게 좋다. 외부와의 약속만을 표기한 인터페이스는 그 역할을 언어레벨에서 해낸다. 인터페이스는 관심의 경계를 나누도록 도와주는 장치이며 프로토콜을 정하는 규약이다.

아이템65. 리플렉션보다는 인터페이스를 사용하라

리플렉션 기능을 이용하면 프로그램에서 임의의 클래스에 접근할 수 있다. Class 객체가 주어지면 그 클래스의 Constructor, Method, Field 인스턴스를 가져올 수 있고, 이 인스턴스 내의 각종 데이터(메소드 시그니처, 필드명 등)도 가져올 수 있다.

Method.invoke()를 사용하면 어떤 클래스의 어떤 객체가 가진 어떤 메소드라도 호출할 수 있다. 리플렉션을 사용하면 컴파일 당시에 존재하지 않던 클래스도 이용할 수 있다. 그러나 아래와 같은 단점이 있다.

  1. 컴파일타임 타입 검사가 주는 이점을 누릴 수 없다. 예외 검사도 마찬가지이며, 예외발생 시 런타임 오류가 발생한다.
  2. 리플렉션을 이용하면 코드가 지저분해지고 장황해진다.
  3. 성능이 떨어진다.

따라서 리플렉션은 아주 제한된 형태로만 사용하며 단점을 피하고 이점만 취해야 한다. 리플렉션은 인스턴스 생성에만 쓰고, 리플렉션으로 만든 인스턴스는 인터페이스나 상위 클래스로 참조해서 사용하자.

아래 코드는 실행인자로 java.util.TreeSet a b c d 를 넘기면 [a, b, c, d] 를 출력하는 코드이다. 인터페이스를 참조하도록 하여서 컴파일타임의 검사를 조금이나마 이용하였다.

public static void main(String[] args) {
    Class<? extends Set<String>> cl = null;
    Set<String> s = null;
    try {
        cl = (Class<? extends Set<String>>) Class.forName(args[0]);
        Constructor<? extends Set<String>> cons = cl.getDeclaredConstructor();
        s = cons.newInstance();
    } catch (ReflectiveOperationException e) {
         System.err.println("리플렉션 도중 에러 발생");
         System.exit(1);
    }
    s.addAll(Arrays.asList(args).subList(1, args.length));
    System.out.println(s);
}

위 코드는 런타입에 ClassNotFoundException, NoSuchMethodException, InvocationTargetException 등 6가지를 던질 수 있었으나 jdk 1.7 부터 지원되는 ReflectiveOperationException 를 통해 그나마 간소화하였다. 그럼에도 불구하고 리플렉션이 아니었다면 생성자 호출 한 줄로 끝났을 것이다.

아이템66. 네이티브 메소드는 신중히 사용하라

자바 네이티브 인터페이스(Java Natvie Interface, JNI)는 자바에서 native 메소드(C, C++ 등으로 작성)를 호출하는 기술이다. natvie 메소드의 쓰임은 다음과 같다.

  1. 레지스트리 같은 플랫폼 특화 기능을 사용
  2. native 코드로 작성된 기본 라이브러리를 사용
  3. 성능 개선을 목적으로 성능에 결정적인 영향을 주는 영역만 따로 natvie 언어로 작성

그러나 java가 계속 발전해가면서 native 메소드를 사용할 일이 점차 사라지고 있다. (ex. java 9부터는 process API가 강화됨) native 메소드는 메모리 훼손으로부터 안전하지 않으며 디버깅도 어렵다. GC는 natvie 메모리를 자동 회수하지 못하기 때문이다. 또한 java 코드와 native 메소드 사이에 바인딩 역시 코드작성을 해야해서 비용이 들어간다.

아이템67. 최적화는 신중히 하라

성능때문에 견고한 구조를 희생하지 말아야 한다. 빠른 프로그램보다는 좋은 프로그램이 좋다. 애초에 설계 단계에서 성능을 반드시 염두에 두어야 한다. 아키텍처의 결함이 성능을 제한하는 상황이라면 전체를 다시 작성하지 않고는 해결이 불가능하기 때문이다.

  1. 성능을 제한하는 설계를 피하라. 완성 후 변경하기 어려운 설계 요소는 외부 컴포넌트, 시스템과의 소통 방식이다. (API, 프로토콜, 파일데이터 등)
  2. API를 설계할 때 성능에 주는 영향을 고려하라. public 메소드에서 내부 데이터를 변경할 수 있게 만들면 불필요한 방어적 복사를 유발한다. 또한 컴포지션으로 해결할 수 있는 public클래스를 상속으로 처리한다면 영원히 상위 클래스에 종속되고 성능까지 물려받는다.
  3. 각각의 최적화 시도 전후로 성능을 측정하라. 프로파일링 도구는 최적화 노력을 어디에 집중해야할지 알려준다.

아이템68. 일반적으로 통용되는 명명 규칙을 따르라

자바의 명명규칙은 크게 철자와 문법으로 나뉜다.

68-1. 철자규칙

철자규칙은 패키지, 클래스, 인터페이스, 메소드, 필드, 타입변수의 이름을 다룬다.

  • 패키지와 모듈명은 각 요소를 점(.)으로 이으며 계층적으로 짓는다. 모두 소문자 혹은 숫자로 지어야한다. 도메인이 있다면 역순으로 사용한다.
  • 클래스와 인터페이스 이름은 대문자로 시작하며 줄여쓰지 않도록 한다. 약자의 경우라도 첫글자만 대문자로 하는 것을 권장한다.
  • 메소드와 필드명은 첫글자를 소문자로 쓴다. 단, 상수필드는 모두 대문자로 쓰며 단어 사이는 밑줄(_)로 구분한다.
  • 타입 매개변수명은 한 문자로 표현한다. 일반적으로 아래와 같이 쓴다.
    • T : 임의의 타입. Type의 약자
    • E : 컬렉션의 원소. Element의 약자
    • K, V : Map의 키와 값. Key와 Value의 약자
    • X : 예외. Exception의 약자
    • R : 메소드의 반환 타입. Return의 약자

68-2. 문법규칙

문법규칙은 논쟁의 소지가 있을 수 있으며, 유연하다.

  • 객체를 생성할 수 있는 클래스명은 보통 명사, 명사구를 사용한다. (ex. Thread)
  • 객체를 생성할 수 없는 클래스명은 보통 복수형 명사로 짓거나 형용사로 짓는다.
  • 메소드명은 동사, 동사구로 짓는다.
  • 해당 인스턴스의 속성을 반환하는 메소드는 명사 또는 get으로 시작하는 동사구로 짓는다. (boolean 제외)
  • 객체의 타입을 바꿔서 다른 타입의 또 다른 객체를 반환하는 메소드는 보통 to타입 형태로 짓는다. (toString, toArray, toPath 등)
  • 객체의 내용을 다른 뷰로 보여주는 메소드는 as타입 형태로 짓는다. (asList 등)
  • 객체의 값을 기본타입 값으로 반환하는 메소드는 타입Value 형태로 짓는다. (intValue 등)


Comments