반응형

개요

코드 분석중...

 

 누군가 작성한 코드를 분석하기 위해 100줄 남짓한 메서드를 보고있다. 최상위에 지역변수들이 초기화되어 있고, 아래로 비지니스 로직이 주욱 구현되어 있다. 코드를 분석해나가는 도중 어떤 지역변수가 사용되었지만, 이 값이 어떤 값을 갖고있는지를 잊어버려 최상위에 적힌 지역변수를 다시 확인했다. 어떤 변수는 반복문에서도, try 문 안에서도, 다른 변수에 값을 할당하는 부분에도 사용됐다. 변수의 유효 범위가 너무 넓은 것이다. 변수를 체크하다보니 로직의 흐름을 까먹어 다시 분석하기도 한다. 이런 과정을 반복하면서 코드 분석을 마무리했다.

 


지역변수가 최상위에 선언되어 있지 않았다면?

 지역변수들이 최상위에 선언되어 있지 않고, 쓰일 때 초기화되어 있거나, 현재 보이는 코드라인에 초기화 된 값이 보인다면 어땠을까? 다시 확인할 필요도 없고, 코드 분석에 대한 집중력도 유지할 수 있다. 즉, 지역변수의 범위를 최소화한다면 유지보수성과 가독성을 향상시킬 수 있다.

 


지역변수의 범위를 최소화하는 방법

 가장 강력한 방법은 '가장 처음 쓰일 때 선언하는 방법'이다. 앞서 개요에서 말했던 방법이다. 사용하려면 멀었는데 미리 선언부터 해두면 이를 다시 확인하거나 잘못 사용하게 되는 상황이 발생할 수 있다. 사실 거의 모든 지역변수는 선언과 동시에 초기화해야한다. 여기서 거의라고 말한 이유는 그렇지 않은 예외 케이스가 존재하기 때문인데 바로 try-catch 문이다. 예외 처리를 해야하는 경우 선언은 try 문 밖에서, 초기화는 try 문 안에서 해야 catch나 finally 에서 이에 맞게 핸들링을 하거나 리소스를 제거하는 행위를 할 수 있다.

 

아래와 같이 FileInputStream 과 같은 타입의 값을 초기화할 때 예외처리를 하지 않을 경우 아래와 같이 컴파일 타임에 에러가 발생한다. 이를 처리하기 위해 예외를 외부로 던질수도 있지만, 이는 예외에 대한 책임을 전가하기에 메서드 내에서 처리를 하려는 사람도 있을 것이다.

예외 처리를 하지 않아 컴파일 에러 발생

 

 

그 경우 inputStream을 try 외부에서 먼저 선언하고 내부에서 초기화하게 된다.

public void myMethod(){
    File file = new File("");

    FileInputStream inputStream = null;
            
    try{
        inputStream = new FileInputStream(file);
    }catch (FileNotFoundException e){
        e.printStackTrace();
    }finally {
        try{
            inputStream.close();
        }catch (IOException e){
            e.printStackTrace();
        }
    }
}

 

물론 InputStream은 autoClose 가 구현되어 있으니 try-resource를 사용한다면 try 괄호 안에서 초기화가 되겠지만, 그게 아닐 경우 위처럼 선언과 동시에 초기화되지 않을 수도 있다. 어쨌든 중요한건 대부분의 지역변수는 사용할때, 선언과 동시에 초기화해야 한다는 것이다.

 


반복문의 반복변수

 지역변수는 반복문에서도 사용된다. 바로 '반복변수'이다. 일반적으로 for나 while 과 같은 반복문에서 사용하는데 만약 반복 변수의 값을 반복문이 종료된 뒤에도 써야 하는 상황이 아니라면 while 보다 for 문을 쓰는 게 낫다.

 

예를들어 컬렉션을 순회하는 코드를 작성할 경우 for문은 아래와 같이 for-each나 전통적인 for문을 사용해서 구현할 수 있다.

List<String> list = new ArrayList<>();
//...


// for-each
for(String e : list){
    //...
}

// 전통 for
for(Iterator<String> i = list.iterator(); i.hasNext();){
	//...
}

 

 

while의 경우는 아래와 같이 구현되는데, while 구문 안에 조건을 넣어야하므로 while 구문 전에 반복 변수를 초기화를 해야한다. 그런데 아래와 같이 while 반복문이 두번 사용될 경우 복붙 과정에서 실수로 조건부의 i를 i2로 수정하지 않아 버그가 발생할 수 있다. 이 경우 두번째 반복문이 실행되지 않을 것이다.

Iterator<String> i = list.iterator();
while(i.hasNext()){
    //...
}

Iterator<String> i2 = list.iterator();
while(i.hasNext()){ // 버그유발
    //...
}

 

 

for문의 경우는 어떨까? 반복 변수의 유효범위가 for문 안으로 한정되어 있기 때문에 위와 같은 버그가 발생하지 않는다. 심지어 변수명을 다르게 설정할 필요도 없다.

for(Iterator<String> i = list.iterator(); i.hasNext();){
    //...
}
for(Iterator<String> i = list.iterator(); i.hasNext();){
    //...
}

 

 

여기서 키포인트는 초기화를 내부에서 했냐, 외부에서 했냐가 아니다. while에 사용되는 반복변수는 for문에 사용되는 반복변수보다 유효 범위가 훨씬 넓다는 것이다. while의 반복변수는 메서드 전체인데 반해, for문의 반복변수는 for문 안으로 한정되어 있다. 즉, 지역변수의 유효범위가 넓을수록 버그를 발생시킬 확률이 높고, 가독성과 유지보수성을 헤친다는 것을 말하고 있다.

 


정리

 지역변수의 범위를 최소화하는 목적은 가독성과 유지보수성을 높이는 것이다. 이를 위해 지역변수를 선언과 동시에 초기화하거나, 실제로 쓰일 때 초기화 하거나, while 보다는 for문을 쓴것인데, 만약 이를 다 지킨다고 하더라도, 코드가 길면 어떨까? 긴 코드 안에는 여러 책임들이 얽혀있고, 여러 기능들을 하고, 여러 예외들을 처리한다면 지역변수의 범위를 최소화한다 한들 가독성과 유지보수성을 높인다는 목적을 이루지 못한다.

 

때문에 지역변수의 범위를 최소화하기에 앞서 선행되어야 할 가장 중요한 것은 단일 책임원칙에 맞게 코드를 구현하여 메서드를 작게 만드는 것이다. 이게 선행됐을 때 비로소 지역변수의 범위를 최소화하는 것이 의미있는 행위가 될것이다.

반응형
반응형

개요

 어떤 메서드를 구현할 때 매개변수가 유효하다는 것을 당연하게 여기곤 한다. 이렇게 가정한 상태에서 비지니스 로직을 구현하곤 한다. 어떤 이들은 이러한 사태를 방지하기 위해 생성자나 메서드 호출 부 앞단에 유효성 검사하는 메서드를 추가하기도 한다.

 어찌됐든, 유효한 매개변수를 당연시하게 될 경우 여러 문제가 발생할 수 있다. 메서드 수행 중간에 개발자가 생각지 못했던 예외가 발생한다거나, 메서드가 잘 수행됐지만 잘못된 값을 반환하거나 하는 등의 문제이다.

 

매개변수로 인한 예외는 문서화하라

public 과 protected 메서드는 매개변수 값이 잘못됐을 때 던지는 예외를 문서화 하길 권장하고 있다. @throws 자바독 태그를 사용하면 된다. 이렇게 문서화를 하는 이유는 유효하지 않은 매개변수에 대한 것을 개발자도 인지하고 있고, 다른 개발자에게도 알려주기 위함이라고 생각한다. 왜? 접근제어자에 따라 어디서든 호출될 수 있기 때문이다.

 

    /**
     * 현재 값 mod m 값을 반환한다.
     * 항상 음이 아닌 BigInteger를 반환한다.
     * 
     * @param m 계수(양수여야 한다.)
     * @return 현재 값 mod m
     * @throws ArithmeticException m이 0보다 작거나 같으면 발생한다.
     */
    public BigInteger mod(BigInteger m){
        if(m.signum() <= 0)
            throw new ArithmeticException("계수(m)은 양수여야 합니다. "+ m);

        return m.mod(m);
    }

 

 

m 이 null인 경우도 있잖아요?? 그럼 NullPointException 도 추가해야하는거 아닌가요?

추가하지 않는다. 그 이유는 이 설명을 mod 와 같은 개별 메서드가 아닌 매개변수 자체, 즉, BigInteger 클래스 수준에서 기술했기 때문이다. 클래스 수준 주석은 그 클래스의 모든 public 메서드에 적용되므로 각 메서드에 일일이 기술하는 것보다 깔끔하다.

 

아래는 BigInteger 클래스 내에 주석을 보면 [이 클래스의 모든 메서드와 생성자는 입력 매개변수에 대해 null 개체 참조가 전달되면 NullPointerException 이 발생한다]고 기재되어 있다.

* <p>All methods and constructors in this class throw
* {@code NullPointerException} when passed
* a null object reference for any input parameter.

 

어찌됐던 Null 검사는 해야하지 않나요?

 

순수하게 null을 체크하고자 한다면 자바 7에 추가된 java.util.Objects.requireNonNull 메서드를 사용하면 된다. a != null 과 같은 방법보다 훨씬 유연한 방법이다. 원하는 예외 메시지를 지정할 수도 있고, 입력을 그대로 반환하니 이를 활용할 수도 있다. 반환 값을 무시하고 순수한 null 검사 목적으로 사용해도 된다.

 

Null Check + 예외 메시징 처리

Integer value = null;
Objects.requireNonNull(value, "value 값이 null입니다.");

 

Null Check 와 예외 메시징 처리를 동시에 수행한다.

 

 

체크 입력 값 그대로 반환

Integer value2 = 3;
Integer value3 = Objects.requireNonNull(value2, "value 값이 null입니다.");
System.out.println(value3); // 3

 

private 는 매개변수로 인한 예외를 문서화하지 않나요?

굳이 문서화할 필요가 없다. 왜냐하면 public 이나 protected 는 외부에서 호출이 가능하다. 특히 public 은 어디서든 호출이 가능하기 때문에 매개변수로 인한 예외 가능성이 다분하다. 이에 반해 private 는 클래스 내에서만 호출 가능하다. 즉, 유효한 매개변수가 들어온다는 것을 충분히 보증할 수 있고, 또 그렇게 해야 한다. 이런 상황에서는 예외가 아닌 단언문(assert)를 사용해 매개변수 유효성을 검증할 수 있다.

 

private static void sort(long a[], int offset, int length){
    assert a != null;
    assert offset >= 0;
    assert length >= 0;
    ...
}

 

여기서 핵심은 이 단언문들은 자신이 단언한 조건이 무조건 참이라고 선언하는 것이다. 단언문은 몇가지 면에서 유효성 검사와 다르다. 첫째, 실패하면 AssertionError를 던진다. 둘째, 런타임에 아무런 효과도, 성능 저하도 없다.

 

그럼 무조건 매개변수 유효성 검사를 해야하나요?

예외는 있다. 유효성 검사 비용이 지나치게 높거계산 과정에서 암묵적으로 검사가 수행될 때다. 예를들어 Collections.sort(List) 처럼 객체 리스트를 정렬하는 메서드의 경우 리스트 안의 객체들은 모두 상호 비교될 수 있어야 하며, 이 과정에서 사실상 유효성 검사가 이루어진다. 그 객체와 비교할 때 비교될 수 없는 타입의 데이터가 있을 경우 ClassCastException 이 발생하기 때문이다.

 sort() 메서드 실행 초반부에 파라미터로 들어온 List 에 대한 유효성 검사를 한다면, 사실상 두 번의 유효성 검사를 한 격이다. 단, 이런 암묵적 유효성 검사의 너무 의존하는 것은 좋지 않다.

 

정리

 메서드나 생성자를 작성할 때 매개변수들에 어떤 제약이 있을지 생각해야 한다. 그 제약들을 문서화하고 메서드 코드 시작 부분에서 명시적으로 검사해야 한다. 이 노력은 유효성 검사가 실제 오류를 처음 걸러낼 때 보상받을 수 있다. 하지만 이번 아이템을 "매개변수에 제약을 두는게 좋다"로 해석하면 안된다. 메서드는 최대한 범용적으로 설계하는게 좋기 때문이다. 

반응형
반응형

개요

 스트림을 처음 접하면 이해하기 어렵고, 스트림으로 뭔가를 구현하더라도 이로인한 이점을 파악하기 어렵다. 스트림은 함수형 프로그래밍에 기초한 패러다임이기 때문이다. 스트림의 이점을 이해하고 사용하려면 이 패러다임까지 함께 이해해야한다.

 


함수형 프로그래밍이란?

함수형 프로그래밍(functional programming)은 자료 처리를 함수의 계산으로 취급하고
상태와 가변 데이터를 멀리하는 프로그래밍 패러다임

 

데이터 처리를 원시적인 계산 코드가 아닌 정형화된 함수로 처리하는 것이다. 사용하는 함수는 상태 값이나 가변 데이터를 멀리하도록 구현해야하는데 이러한 함수를 '순수 함수'라고 한다.

 

입력만이 결과에 영향을 주는 함수. 즉, 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는 함수를 순수 함수라 한다. - 이펙티브 자바 中

 

 

순수 함수는 가변 및 상태 값을 참조하지 않기에 입력 값에 의해 결과 값이 정해지는 특성을 갖는다. 만약 상태 값을 참조하게 된다면 입력 값에 의해 결과 값이 달라지는 '부작용'이 발생할 수 있다. 결국 이번 주제인 '부작용 없는 함수''순수 함수'를 말한다.

 

순수 함수는 개발자가 커스텀하여 만들 수도 있지만, 스트림 API에서 제공하는 함수를 사용하는 것도 좋은 방법이다. 스트림에서 제공하는 공식적인 함수이므로 부작용이 없고, 성능 최적화가 되어있으며, 40가지 이상의 다양한 함수를 제공하기 때문이다.

 

먼저 스트림 API를 사용했지만, 저자는 스트림 코드라고 인정하지 않는 코드를 살펴보자.


단어 빈도표 예제

 

다음은 텍스트 파일에서 단어별 수를 세어 빈도표로 만드는 코드이다. 스트림, 람다, 메서드 참조를 사용했고, 결과도 올바르지만 이를 스트림 코드라 하지 않는다. 스트림을 잘못 사용했고, 사용하지 않았을 때보다 가독성이 떨어지기 때문이다.

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word ->
            freq.merge(word.toLowerCase(), 1L, Long::sum));
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

 

문제는 외부 상태인 빈도 수(freq)를 수정하는 람다 부분이다. 이 코드의 모든 데이터 처리 작업이 최종 연산(종단 연산)인 forEach 구문에서 일어나고 있는데, forEach는 스트림 계산 결과를 보고할 때만 사용하는 것이 권장된다. 연산 결과를 보여주는 일 이상을 하니 좋은 코드라 할 수 없다.

 


스트림의 최종 연산이 뭔가요?

 

최종 연산 (종단 연산)
스트림의 요소를 '소비' 해가며 결과를 만들어내는 연산이다. 소비를 하기 때문에 최종 연산 후 스트림은 재사용할 수 없는 빈 상태가 된다.

 

더 이상 추가적인 연산을 할 수 없는 상태이기에 최종 연산이라고 한다. 최종 연산에 사용되는 대표 함수를 몇개 기재한다.

함수 내용
void forEach() 요소를 하나씩 소비해가며 지정된 작업 수행(병렬 스트림에서 순서 보장 X)
void forEachOrdered() 요소를 하나씩 소비해가며 지정된 작업 수행(병렬 스트림에서 순서 보장 O)
long count() 요소 개수 반환
Optional max() 요소 중 최대 값을 참조하는 Optional 반환
Optional min() 요소 중 최소 값을 참조하는 Optional 반환
Optional findFirst() 첫번째 요소를 참조하는 Optional 반환
Optional findAny() 첫번째 요소를 참조하는 Optional 반환 (병렬 스트림에서는 첫번째 요소 보장 X)
boolean allMatch() 모든 요소가 특정 조건을 만족하는지 여부를 반환
boolean anyMatch() 하나의 요소라도 특정 조건을 만족하는지 여부를 반환
boolean noneMatch() 모든 요소가 특정 조건을 불만족 하는지에 대한 여부를 반환
reduce() 요소를 하나씩 빼며 지정된 연산 처리 후 결과를 반환
collect() 요소들을 컬렉션으로 반환

 

 


 

최종 연산이 나와서 말인데... 중간 연산은 뭔가요?

 

중간 연산
스트림 생성부터 시작해서 최종 연산 직전까지의 연산이다. 최종 연산과 달리 체이닝 메서드 방식으로 여러 개의 중간 연산이 수행될 수 있다.

 

함수 내용
Stream<T> distinct() 요소의 중복 제거
Stream<T> filter() 요소에 대한 필터링 조건 추가
Stream<T> limit() 요소 개수 제한
Stream<T> skip() 처음 n개의 요소 건너뛰기 
Stream<T> sorted() 요소 정렬
Stream<T> peek() 요소에 대한 작업 수행, forEach와는 다르게 요소를 소비하지 않음

 


다시 단어 빈도표 예제로

다시 돌아가서 위 코드를 올바른 스트림 코드로 작성한다면 아래와 같다.

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

 이 코드는 collector를 사용하는데 collector는 스트림을 사용하려면 꼭 배워야하는 개념이다. 참고로 collector는 java.util.stream.Collectors 클래스를 말하며 groupingBy는 Collectors 클래스가 지원하는 static 메서드이다. 앞서 언급했던 '부작용' 없는 함수 중 하나인 것이다. groupingBy 메서드는 요소들을 그룹핑하여 Map 타입으로 반환한다. counting 메서드는 동일한 단어의 수를 반환하는데, 최종적으로 key는 소문자 단어, value는 단어의 수를 가진 Map이 생성된다.

 

collector는 이러한 메서드를 40개 이상 지원한다. 복잡한 세부 사항을 잘 몰라도 사용 가능하다.

이러한 메서드를 활용해 빈도표에서 가장 흔한 단어 10개를 뽑아내는 스트림 파이프라인을 작성해보자.

 

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toString, counting()));

    List<String> list = freq.keySet().stream()
            .sorted(comparing(freq::get).reversed())
            .limit(10)
            .collect(Collectors.toList());

    list.forEach(System.out::println);
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

comparing 메서드는 비교할 키를 받아 비교자를 생성하는 메서드이며, freq의 value 값을 비교 키로 하기 위해 freq::get 메서드를 파라미터로 전달하고 있다. 그 후 내림차순 정렬을 위해 reversed() 메서드를 호출한다.

 


toMap 사용해보기

collector에서 제공하는 메서드 중 toMap(keyMapper, valueMapper)은 스트림으로 가장 간단하게 맵을 만들 수 있는 메서드이다. 사용법도 쉬운게 키에 매핑하는 함수, 값에 매핑하는 함수를 인수로 넘기면 된다.

List<String> values = List.of("one","two","three","four");
Map<String, Integer> map = values.stream().collect(Collectors.toMap(s -> s, String::length));

for(Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println(entry.getKey());
    System.out.println(entry.getValue());
}

 

키 함수는 s -> s 로 List의 원소 값이 그대로 들어가고, 벨류 함수는 String::length 로 원소의 길이가 들어가도록 하였다. 하지만 만약 키가 중복될 경우 파이프라인에서 예외가 발생한다.

List<String> values = List.of("one","two","three","four","one");// 키 중복
Map<String, Integer> map = values.stream().collect(Collectors.toMap(s -> s, String::length));

for(Map.Entry<String, Integer> entry : map.entrySet()){ // IllegalStateException 발생 !
    System.out.println(entry.getKey());
    System.out.println(entry.getValue());
}

 

이러한 충돌을 다루는 전략으로 머지 함수를 제공한다. 함수의 형태는 BinaryOperator<U> 이며, 여기서 U는 해당 맵의 값 타입이다. 같은 키를 공유하는 값들은 이 병합 함수를 사용해 기존 값에 합쳐진다. 예컨데 병합 함수가 새로운 값으로 대체하는 함수라면 기존 값 대신 새로운 값으로 대체되도록 하려면 아래와 같이 작성할 수 있다.

Map<String, Integer> map = values.stream()
	.collect(Collectors.toMap(s -> s, String::length, (oldVal, newVal) -> newVal));

 

groupingBy는 입력으로 분류 함수(classifier)를 받고 출력으로 원소들을 카테고리별로 모아 놓은 맵을 담은 collector를 반환한다. 분류 함수는 입력 받은 원소가 속하는 카테고리를 반환한다. 그리고 이 카테고리가 해당 원소의 맵 키로 쓰이며, 해당 카테고리가 속하는 원소들을 담은 리스트는 값으로 쓰이게 된다.

List<String> values = List.of("one","two","three","four","five","six","seven");

Map<Integer,List<String>> map = values.stream().collect(groupingBy(s -> s.length()));

for(Map.Entry<Integer, List<String>> entry : map.entrySet()){
    System.out.println(entry.getKey());

    for(String str : entry.getValue()){
        System.out.println(str);
    }
}

 


정리

스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수에 있다. 이러한 함수는 직접 만들어도 되지만 스트림에서 제공하는 함수가 매우 다양하기 때문에 이를 사용하는 것도 좋은 방법이다.

 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용하고 계산 자체에는 이용하지 말자.

 마지막으로 스트림을 잘 사용하려면 collector를 잘 알아둬야한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

반응형
반응형

확장할 수 없는 열거 타입

열거 타입은 거의 모든 상황에서 타입 안전 열거 패턴보다 우수하다. 단, 예외가 하나 있으니, 타입 안전 열거 패턴은 확장할 수 있지만, 열거 타입은 그럴 수 없다는 점이다.

 

확장 시 에러가 발생하는 열거타입

public enum AEnum {
    A,B,C
}

public enum BEnum extends AEnum{ // enum은 확장할 수 없다는 에러 발생
    D,E,F
}

 


확장형 열거타입에 어울리는 연산코드

연산 코드의 각 원소는 특정 기계가 수행하는 연산을 뜻한다. 기본으로 더하기, 빼기, 곱하기, 나누기 연산을 제공한다고 가정했을 때 제곱, 나머지 연산과 같은 확장된 연산을 제공해야 할 경우가 있다. 그런데 앞서 말했듯 열거타입은 확장이 불가능하다. 하지만 확장의 효과를 내는 방법이 있다. 바로 인터페이스를 사용하는 것이다. 기본적인 연산에 대한 열거 타입 클래스를 인터페이스를 사용하여 구현하였다.

public interface Operation {
    double apply(double x, double y);
}

public enum BasicOperation implements Operation{
    PLUS("+"){
        @Override
        public double apply(double x, double y) {
            return x+y;
        }
    },
    MINUS("-"){
        @Override
        public double apply(double x, double y) {
            return x-y;
        }
    },
    TIMES("*"){
        @Override
        public double apply(double x, double y) {
            return x*y;
        }
    },
    DIVIDE("/"){
        @Override
        public double apply(double x, double y) {
            return x/y;
        }
    };

    private final String symbol;

    BasicOperation(String symbol){
        this.symbol = symbol;
    }

    @Override
    public String toString() {
        return symbol;
    }
}

 

특별 연산이 추가된다면 인터페이스를 구현하자

만약 특별 연산이 추가되어야 한다면 새로운 열거 타입 클래스에서 인터페이스를 구현하면 된다.

 

public enum ExtendedOperation implements Operation{
    EXP("^"){
        @Override
        public double apply(double x, double y) {
            return Math.pow(x,y);
        }
    },

    REMAINDER("%"){
        @Override
        public double apply(double x, double y) {
            return x % y;
        }
    };

    private final String symbol;
    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }


    @Override
    public String toString() {
        return symbol;
    }
}

 

 

테스트

기본 열거 타입 대신 확장된 열거 타입을 넘겨 확장된 열거 타입의 원소 모두를 사용할 수 있다.

public static void main(String[] args) {
    test(ExtendedOperation.class, 3, 3);
}

public static <T extends Enum<T> & Operation> void test(Class<T> opEnumType, double x, double y){
	for(Operation op : opEnumType.getEnumConstants()){
		System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x,y));
    }
}

 

 main 메서드는 test 메서드에 ExtendedOperation의 class 리터럴을 넘겨 확장된 연산들이 무엇인지 알려준다.

타입 매개변수 부분인 <T extends Enum<T> & Operation> 코드는 타입 매개변수 T가 Enum<T>. 즉, 열거 타입임과 동시에 Operation의 하위 타입이어야 한다는 것이다. 이는 Enum을 통한 원소 순회와 Operation 인터페이스의 메서드를 호출하기 위함이다.

 

이게 복잡하다면 아래 방법을 사용할 수 있다.

public static void main(String[] args) {
    test(Arrays.asList(ExtendedOperation.values()), 3, 3);
}

public static void test(Collection<? extends Operation> opSet, double x, double y){
    for(Operation op : opSet){
        System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x,y));
    }
}

 

ExtendedOperation의 상수 인스턴스를 List로 만든 후 각각의 상수 인스턴스에서 값을 순회하며 출력하는 방식이다. 이 코드는 위 방법보다 덜 복잡하고 유연해졌다. Operation을 구현하는 여러 클래스들에서 본인이 필요한 상수 인스턴스들을 추출하여 List로 넘겨주기만 한다면 다양한 연산들을 호출할 수 있기 때문이다.

 


정리

 열거 타입 자체는 확장할 수 없지만, 인터페이스와 그 인터페이스를 구현하는 기본 열거 타입을 함께 사용해 같은 효과를 낼 수 있다. 이렇게 하면 클라이언트는 이 인터페이스를 구현해 자신만의 열거 타입을 만들 수 있다. 하지만 이런 구조는 너무 생소할 뿐더러 오히려 시스템의 복잡도를 높힐 수 있다는 생각이 든다. 만약 확장해야한다면 Enum보다는 클래스를 사용하여 구현하는게 더 좋지않을까?? 이번 내용은 크게 와닿지 않는 것 같다.

반응형
반응형

개요

 이전 포스팅에서 매개변수화 타입을 사용하는 클래스에 유연성을 더하기 위해 한정적 와일드카드를 사용했고, 그 결과 자식 타입까지 허용 가능하도록 구현하였다. 타입의 다형성을 활용한 것이다. 이는 다형성을 활용하지 못하는 타입은 접근이 불허하다는 한계가 있다는 뜻이다. 이 한계를 타입 안전 이종 컨테이너 방식으로 극복할 수 있다.

 


즐겨찾기 기능 구현

 타입별로 즐겨 찾는 인스턴스를 저장하고 조회할 수 있는 즐겨찾기 기능을 구현해보자. 

 

1. HashMap을 통한 구현

Map<Class<?>, Object> favorites = new HashMap<>();

favorites.put(String.class, "hi");
favorites.put(Integer.class, 1);

String favoriteString = (String)favorites.get(String.class);
Integer favoriteInteger = (Integer)favorites.get(Integer.class);

System.out.println(favoriteString);
System.out.println(favoriteInteger);

 

HashMap을 통해 간단하게 구현할 수 있지만 단점이 있다.

 

단점 1. 타입 안전성을 보장받지 못한다.

 Key를 Integer.class로 하고, value를 String 타입으로 넣어도 컴파일 에러가 발생하지 않는다. 이에 따라 런타임 시 해시맵의 Integer.class의 값을 조회할 때 ClassCastException이 발생하게 된다.

favorites.put(String.class, "hi");
favorites.put(Integer.class, "bye"); // 컴파일 에러는 발생하지 않는다.

..

Integer favoriteInteger = (Integer)favorites.get(Integer.class); // 런타임 에러 발생 !

 

단점 2. 조회 시 정적 타입 캐스팅이 필요하다

 모든 타입을 받아야 하기에 Map의 값을 Object 타입으로 선언하였다. 이에 따라 값을 조회할 때 정적인 타입 캐스팅이 필요하다. Integer.class에 대한 값을 조회할 때는 Integer 타입으로, String.class에 대한 값을 조회할 때는 String 타입으로 개발자가 직접 캐스팅해야한다.

 

 

2. 타입 안전 이종 컨테이너 패턴을 통한 구현

타입 안전 이종 컨테이너 패턴
 키 값을 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하는 패턴이다. 이에 따라 키와 값의 타입이 보장된다.

 

 

public class Favorites {

    private final Map<Class<?>, Object> favorites = new HashMap<>();

    public <T> void putFavorite(Class<T> type, T instance){
        favorites.put(Objects.requireNonNull(type), instance);
    }

    public <T> T getFavorite(Class<T> type){
        return type.cast(favorites.get(type));
    }
}

 

 

 HashMap 인스턴스를 감싸는 Favorites라는 래퍼 클래스를 만들고, 값을 넣거나 뺄 때 '키' 값에 매개변수화 타입 값을 함께 제공하고 있다. 이 방식이 Map 방식의 단점을 모두 극복했는지 알아보자.

 

1. 타입 안정성을 보장받는다.

Favorites f = new Favorites();

f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 123);
f.putFavorite(Class.class, Favorites.class);

String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);

 

 putFavorite 메서드에서 사용하는 제네릭 타입에 의해 내부 Map 인스턴스의 Key로 사용할 클래스 타입과 값이 모두 같은 타입임이 컴파일 타임에 보장된다.

 

만약 아래와 같이 String.class에 대해 123이라는 Integer 타입의 값을 사용한다면 메서드 시그니처에 맞지 않다는 컴파일 에러가 발생하게 된다. 즉, 기존 Map을 직접 사용한 방식과는 다르게 타입 안정성이 보장되고 있다.

f.putFavorite(String.class, 123); // 메서드 시그니처에 맞지 않는다는 컴파일 에러가 발생!

 

2. 조회 시 동적 타입 캐스팅이 가능하다.

public <T> T getFavorite(Class<T> type){
	return type.cast(favorites.get(type));
}

 

 조회 시 Class 클래스의 cast() 메서드를 사용하고 있다. 이 메서드는 매개변수로 들어온 값을 자신의 타입으로 캐스팅 할 수 있다면 캐스팅 후 반환하고, 캐스팅이 불가능할 경우 ClassCastException을 반환하는 메서드이다.

 어차피 getFavorite 메서드의 파라미터로 들어온 클래스의 타입 T와 favorites.get(type)을 통해 조회한 클래스의 타입 T 는 일치할 수 밖에 없으므로 type.cast 메서드를 아주 적절하게 사용할 수 있는 부분이다.

 

public <T> T getFavorite(Class<T> type){
    return (T)favorites.get(type);
}

 

물론 위와 같이 정적 형변환 방식으로 수정해도 되지만, 비검사 형변환이므로 '경고'가 발생하게 된다. 타입 안정성이 보장되는 부분이기에 @SuppressWarnings와 주석을 남겨야 한다.

 

 


제약 사항

Class 타입을 로 타입으로 넘길 경우 타입 안정성이 깨진다.

List<String> stringList = new ArrayList<>();
stringList.add("hi");

f.putFavorite(List<String>.class, stringList); // 컴파일 에러 발생

 

 List<String>과 List<Integer>에 대한 값을 Favorites 클래스로 관리하기 위해 putFavorite 메서드에 List<Class>.class 형태로 파라미터를 넘길 경우 경우 컴파일 에러가 발생한다. List<String>.class 구문 자체가 문법 에러를 발생시키기 때문이다.

 

List<String> stringList = new ArrayList<>();
stringList.add("hi");

List<Integer> integerList = new ArrayList<>();
integerList.add(1);

f.putFavorite(List.class, stringList);
f.putFavorite(List.class, integerList); // 덮어 씌워진다.

 

이를 해결하기 위해 위와 같이 로 타입으로 값을 넘기게 되면 List<String>과 List<Integer> 모두 같은 키를 공유하게 되므로 List.class 키에 대한 값은 마지막에 넣은 값으로 덮어 씌워지게 된다. List<String>과 List<Integer> 타입에 대한 값을 따로 관리하려 했던 목적을 이루지 못했다.

 


정리

 타입 매개변수를 사용하는 컬렉션 API는 한 컨테이너가 다룰 수 있는 타입의 수가 고정되어 있다. 하지만 타입 안정 이종 컨테이너 패턴을 사용하면 타입에 제약이 없는 컨테이너로 만들 수 있다.

 

 

 

반응형
반응형

개요

 '태그달린 클래스'라는 단어가 되게 생소하다. 이에 대한 의미를 이해하고, 이 클래스가 과연 어떤 단점을 갖길래 계층구조로 리팩토링 하라는지도 이해해보자.

 


태그달린 클래스란?

 태그달린 클래스란 멤버 필드와 관련있다. 멤버 필드가 클래스의 유형을 나타내는 경우 해당 멤버 필드를 태그 필드라고 한다. 그리고 태그 필드를 갖는 클래스를 태그달린 클래스라고 한다.


태그 달린 클래스의 예

 Figure 클래스는 shape 필드가 이 클래스의 유형을 나타낸다. 즉, 태그 필드이다. 유형마다 생성자가 따로 존재하며, area 메서드의 동작도 달라지는 것을 볼 수 있다. 이러한 클래스의 단점을 하나씩 짚어보자.

public class Figure {
    enum Shape { RECTANGLE, CIRCLE };

    // 태그 필드 - 현재 모양을 나타낸다.
    final Shape shape;

    // 다음 필드들은 모양이 사각형(RECTANGLE)일 때만 쓰인다.
    double length;
    double width;

    // 다음 필드는 모양이 원(CIRCLE)일 때만 쓰인다.
    double radius;

    // 원 생성자
    Figure(double radius){
        shape = Shape.CIRCLE;
        this.radius = radius;
    }

    Figure(double length, double width){
        shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }

    double area(){
        switch (shape){
            case RECTANGLE:
                return length * width;
            case CIRCLE:
                return Math.PI * (radius * radius);
            default:
                throw new AssertionError(shape);
        }
    }
}

태그달린 클래스의 단점

 

1. 쓸데없는 코드가 많다

 열거 타입 선언, 태그 필드, switch 등 쓸데없는 코드가 많다. 이런 코드가 많은 이유는 이 클래스로 생성되는 인스턴스가 여러 유형(태그)을 가질 수 있기 때문이다.

 

2. 가독성 저하

 한 클래스에 여러 유형에 대한 로직이 혼합돼어 있어 가독성이 저하된다.

 

3. 불필요한 초기화가 늘어난다.

 멤버 필드의 불변성을 명시하기 위해 필드를 final로 선언한다. 위와 같은 코드는 필드를 final로 선언하려면 해당 태그에 쓰이지 않는 필드들도 생성자에서 초기화해야한다. 불필요한 초기화 코드가 늘어나는 것이다.

 

4. 태그 추가 시 클래스 전체를 수정해야한다.

 또 다른 의미의 태그를 추가하려면 클래스 전체를 수정해야한다. 예를들어 삼각형이 추가될 경우 이에 대한 생성자가 추가되어야하고, area() 메서드도 수정이 되어야 한다.

 

5. 인스턴스 타입만으로는 어떤 태그인지 알 수 없다.

 Figure 이라는 타입만으로 이 태그가 원인지, 사각형인지 알 수 없다.

 

 

태그 달린 클래스는 장황하고, 오류를 내기 쉽고, 비효율적이다.
태그 달린 클래스는 클래스 계층구조를 어설프게 흉내낸 아류일 뿐이다. - 책에서
태그 달린 클래스

 


클래스 계층 구조로 변환하기

 많은 단점을 갖는 태그달린 클래스를 계층 구조로 리팩토링 해보자. 리팩토링이 끝나면 기존 단점들을 얼마나 극복했는지도 확인해보자. 가장 먼저 계층구조의 루트가 될 추상 클래스를 정의해야한다.

 

1. 추상 메서드 선언

 태그 값에 따라 동작이 달라지는 메서드를 추상 메서드로 선언해야한다. 그리고 각각의 하위 클래스에서 이 동작을 정의하도록 한다. area 메서드가 이에 해당한다.

 

2. 일반 메서드 선언

 태그 값에 상관 없이 동작이 일정한 메서드들을 추상 클래스의 일반 메서드로 추가한다. Figure 클래스에는 이러한 메서드가 없기 때문에 넘어간다.

 

3. 멤버 필드 선언

 모든 하위 클래스에서 공통으로 사용하는 필드들을 전부 추상 클래스의 필드에 추가한다. Figure 클래스에는 이러한 필드가 없기 때문에 넘어간다.

 

이를 토대로 추상 클래스를 작성하면 아래와 같다.

abstract class Figure {
    abstract double area();
}

 

4. 구체 클래스 설계

 이제 추상 클래스를 확장한 구체 클래스를 설계한다. Figure의 태그는 원(Circle)와 사각형(Rectangle)이 있으므로 이를 클래스로 분리한다. 이로써 계층구조로의 리팩토링이 끝났다. 이제 기존 단점들을 극복했는지 확인해보자.

 

public class Circle extends Figure{

    private final double radius;
    
    public Circle(double radius){
        this.radius = radius;
    }
    
    @Override
    double area() {
        return Math.PI * (radius * radius);
    }
}

 

public class Rectangle extends Figure{

    private final double length;
    private final double width;

    public Rectangle(double length, double width){
        this.length = length;
        this.width = width;
    }

    @Override
    double area() {
        return length * width;
    }
}

 


단점을 모두 날려버린 계층구조

 

1. 쓸데없는 코드가 많다

 > 열거 타입 선언, 태그 필드, switch 등 쓸데없는 코드가 모두 없어졌다.

 

2. 가독성 저하

 > 다른 유형의 로직이 혼합되어 있지 않다. 클래스는 자신에 대한 로직만을 관리하고 있다.

 

3. 불필요한 초기화가 늘어난다

 > 사용하지 않아 불필요하게 초기화 해야했던 필드들이 모두 없어졌다.

 

4. 태그 추가 시 클래스 전체를 수정해야한다

 > 이제 태그 추가가 아닌 클래스 추가로 변경되었다. 만약 삼각형이라는 클래스가 추가되어도 기존 클래스의 수정은 필요 없게 되었다.

 

5. 인스턴스 타입만으로는 어떤 태그인지 알 수 없다

 > 타입만으로도 원인지, 사각형인지 알 수 있다.

 


태그 필드가 있다면 무조건 계층구조로?

 

그럼 태그 필드가 있는 클래스는 모두 계층구조로 바꿔야할까? 그건 아닌것 같다. 분리될 태그들이 상위 클래스와 is-a 관계일 때만 효용성을 가진다고 생각하기 때문이다. 물론 이런 관계를 갖지 않았다면 애초에 태그 필드를 도입하지 않았을 확률이 높지만, 코드에는 정답이 없고 사람이 짜는 것이니 기존 클래스의 구조를 분석한 후 계층 구조로 리팩토링 하는 것이 바람직하다고 생각한다.

 


정리

 태그 달린 클래스를 써야 하는 상황은 거의 없다. 만약 태그 필드가 있다면 이를 없애고 계층 구조로 리팩터링 하는 것을 고려해야 한다.

 

반응형
반응형

1. 생성보다는 재사용을 고려하자.

 똑같은 기능의 객체를 매번 사용하기보다는 객체 하나를 재사용하는 편이 낫다. 실제로 Boolean.valueOf() 는 호출할 때마다 객체를 생성하지 않고 내부에 캐싱된 객체를 사용한다.

Boolean.valueOf("true");

 

 우리는 문자열을 초기화할때 매번 new 생성자를 통해 생성하지 않는다. 하나의 String 인스턴스를 사용한다. 이 경우 JVM의 String pool이라는 문자열 풀에서 캐싱하게 되고, 재사용하게 된다.

String a = "abc";
String b = "abc";

System.out.println(a == b); // String Pool에서 조회 : true

String c = new String("abc");
String d = new String("abc");

System.out.println(c == d); // Heap 메모리에 새로 생성된 객체를 조회 : false

 

 


 

2. 성능을 향상시키는 재사용

 재사용을 통해 성능이 향상되는 케이스를 알아보자.


2.1. String.matcher()

static boolean isRomanNumber(String s){
    return s.matches("^(?=.)M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}

 들어온 문자열이 로마 숫자인지를 체크하는 메서드이다. 보면 String.matches() 메서드가 호출되는데 내부에서 입력받은 문자열를 통해 Pattern 인스턴스를 생성한다. Pattern은 인스턴스 생성 비용이 높다. 즉, 이 메서드를 호출할 때마다 비용이 비싼 인스턴스를 생성 후 한번만 사용하고 버리는 것이다.

String.matches()
Pattern.compile()

 

 이를 개선하기 위해 Pattern 인스턴스를 캐싱해두고 재사용하는 코드로 변경하였다. 책에서는 실제 속도를 비교해보니 개선 전에 비해 6.5 배의 성능 향상을 가져왔다고 한다.

private static final Pattern ROMAN = Pattern.compile("^(?=.)M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");

static boolean isRomanNumber(String s){
    return ROMAN.matcher(s).matches();
}

 

 


2.2. 오토박싱 거둬내기

 오토박싱은 프리미티브 타입과 레퍼런스 타입을 섞어 쓸때 상호 변환해주는 기술이다. 코드에서 Long 타입의 sum과 long 타입의 i를 연산하는 과정에서 오토박싱이 발생하여 연산 결과를 Long 인스턴스로 생성하게 된다. Integer.MAX_VALUE가 '2의 31승 -1' 이므로, 이 만큼의 Long 인스턴스가 새로 생성되는 것이다. 실행 시간은 6초가 걸렸다.

Long sum = 0L;

long start = System.currentTimeMillis();
for(long i = 0; i <= Integer.MAX_VALUE; i++){
    sum += i;
}

System.out.println(sum);
System.out.println(System.currentTimeMillis() - start);

 

 불필요한 Long 인스턴스의 생성을 막으려면 오토박싱을 거둬내면 되며, sum의 타입을 Long에서 long으로 바꿔주면 된다. 수정 후 실행 시간 0.6초가 걸렸다. 오토박싱이 적용된 코드보다 약 10배 빨라졌다.

public static void main(String[] args) {
    long sum = 0L; // 수정

    long start = System.currentTimeMillis();
    for(long i = 0; i <= Integer.MAX_VALUE; i++){
        sum += i;
    }

    System.out.println(sum);
    System.out.println(System.currentTimeMillis() - start);
}

 


3. 혼란을 야기할 수 있는 재사용

 객체가 불변이라면 재사용해도 안전하다. 이러한 특성을 살려 자바의 기본 라이브러리에서 많이 활용하고 있는데 만약 개발자가 이런 활용 사실을 인지하지 못할 경우 오히려 혼란을 야기할 수 있다. 

 

3.1. 어댑터

 어댑터는 실제 작업은 뒷단 객체에 위임하고, 자신은 제2의 인터페이스 역할을 해주는 객체이다. 예를들어 Map 인터페이스의 keySet 메서드는 Map 안의 키를 전부 담은 어댑터를 반환한다. 다시말하면 키를 가진 객체를 새로 생성하는게 아닌 내부 객체를 재사용하여 키 정보를 반환하는 것이다.

 

 앞서 keySet 메서드는 키 정보를 내부에서 가져온다고 했지만, 필자의 경우 키를 담은 객체를 생성하여 반환하는 줄 알았다. 만약 필자와 같이 keySet의 어댑터 특성을 몰랐다면 keySet() 메서드를 활용해 두개의 키 셋을 구하고 이를 각각 활용해야 하는 상황에서 아래와 같은 결과에 대해 혼란을 느낄 수 있을 것이다.

 

Map<String, Integer> map = new HashMap<>();
map.put("도끼", 3);
map.put("활",1);

Set<String> set1 = map.keySet();
Set<String> set2 = map.keySet();

set1.remove("도끼");

System.out.println(set1.size()); // 1
System.out.println(set2.size()); // 1 (필자는 2를 예상했다 !)

 

 첨언으로 이러한 상황에서는 keySet 메서드처럼 객체의 주소를 복사하여 사용하는 방식이 아닌, 객체의 내부 값을 참조하여 복사하는 '방어적 복사' 방식을 사용해야 한다.


4. 정리

 이번 아이템은 "객체 생성은 비싸니 피해야한다"가 아니다. 요즘 JVM에서는 작은 객체를 생성하고 회수하는 일이 크게 부담되지 않기 때문이다.

 중요한 건 방어적 복사가 필요한 상황에서 객체를 재사용했을 때의 피해가 필요 없는 객체를 반복 생성했을 때의 피해보다 훨씬 크다는 사실을 인지하고 재사용을 고려해야 하는 것이다.

반응형
반응형

조슈아 블로크의 'Effective Java' 책을 읽고 제멋대로 정리한 내용입니다. :)

 

1. 개요

 인스턴스를 생성할 때 생성자를 많이 사용하는데, '정적 팩터리 메서드'를 사용하기도 한다. 책에서는 정적 팩터리 메서드 사용을 권장하고 있는데, 과연 어떤 장점을 갖고 있길래 이를 권장하는 것일까?

 


2. 정적 팩터리 메서드가 뭔가요?

자바에서의 '팩터리'는 '인스턴스를 만드는 공장' 을 의미한다. 즉, 정적 팩터리 메서드란 인스턴스를 만드는 역할을 하는 Static 메서드이다. 아래와 같이 말이다.

 

public class User {

    private String name;

    // 접근 제한자를 private 로 하여 외부 호출을 막음.
    private User(String name){
        this.name = name;
    }
    
    // 유저 인스턴스를 생성하는 정적 팩터리 메서드
    public static User from(String name){
        return new User(name);
    }
}

 

 정적 팩터리 메서드 안에서 생성자를 호출하고 있고, 생성자는 private로 하여 외부 호출을 막고있다. public 생성자를 사용했을때보다 코드가 늘어나고 복잡해진것 같은데, 과연 어떤 장점을 갖고 있길래 생성자보다 정적 팩터리 메서드 방식을 권장하는 걸까?

 


3. 장점

 

3.1. 이름을 지정함으로써 가독성을 증가시킨다.

 생성자는 이름이 없다. 굳이 따지자면 클래스 명이다. 이에 반해 정적 팩터리 메서드는 이름을 지정할수 있다. 이름과 대학 입학년도를 갖는 Student 클래스로 예를들어 설명하겠다. 멤버필드인 name은 학생 이름, admissionYear는 입학년도이다.

 

1) public 생성자 사용

public class Student {

    private String name;
    private int admissionYear;

    public Student(String name){
        LocalDate now = LocalDate.now();
        this.name = name;
        this.admissionYear = now.getYear();
    }

    public Student(String name, int admissionYear){
        this.name = name;
        this.admissionYear = admissionYear;
    }
}

class Main{
    public static void main(String[] args) {
        Student student1 = new Student("김철수");
        Student student2 = new Student("곽영희", 2020);
    }
}

 

2) 정적 팩터리 메서드 사용

public class Student {

    private String name;
    private int admissionYear;

    private Student(String name, int admissionYear) {
        this.name = name;
        this.admissionYear = admissionYear;
    }

    public static Student createFreshman(String name) {
        LocalDate now = LocalDate.now();
        return new Student(name, now.getYear());
    }

    public static Student createOfAdmissionYear(String name, int year) {
        return new Student(name, year);
    }
}

class Main{
    public static void main(String[] args) {
        Student student1 = Student.createFreshman("김철수");
        Student student2 = Student.createOfAdmissionYear("곽영희", 2020);
    }
}

 

 먼저 생성자의 신입생과 재학생에 대한 학생 인스턴스를 생성할 때 시그니처가 다른 public 생성자를 호출하고 있다. 

 여기서 중요한 점은 메인 메서드에서 이를 사용할 때 public 생성자의 시그니처만 봐서는 어떤 특성을 갖는 인스턴스를 생성하는지 알 수 없다. 직접 생성자 코드를 봐야 알 수 있다.

 

 반면 정적 팩터리 메서드를 사용한 경우 메서드 명을 통해 생성할 인스턴스의 특성을 묘사할 수 있다. createFreshman은 올해 입학한 학생, createOfAdmissionYear는 특정 년도에 입학한 학생 인스턴스를 생성하고 있다. Student라는 생성자 명만 덩그러니 있는것보다 특성을 묘사할 수 있는 메서드 이름을 사용함으로써  가독성을 증가시킨 것이다.

 


3.2. 인스턴스 재활용을 통한 메모리 효율 증대

 인스턴스를 미리 만들어 놓거나, 새로 생성한 인스턴스를 캐싱하여 불필요한 객체 생성을 피할 수 있다. 대표적으로 Boolean.valueOf(boolean) 메서드는 미리 만들어 놓은 인스턴스를 리턴하는 방식으로 사용된다.

 만약 생성 비용이 큰 객체가 자주 생성될 경우에 이 방식을 활용한다면 객체 생성 시 사용하게 되는 힙 메모리의 사용율을 줄일 수 있어 메모리 효율적이라고 할 수 있다.

public final class Boolean{

    public static final Boolean TRUE = new Boolean(true);
    public static final Boolean FALSE = new Boolean(false);
    
    ...
    
    public static Boolean valueOf(boolean b) {
        return (b ? TRUE : FALSE);
    }
    
}

 


3.3. 하위 타입 객체 반환을 통한 유연성 증대

 Java 8버전부터 인터페이스 내에 정적 팩터리 메서드를 구현할 수 있다. 이를 통해 인터페이스의 구현체 클래스를 메서드마다 자유롭게 지정할 수 있어 인스턴스 생성에 대한 유연성을 제공한다.

 아래는 Weapon 인터페이스에 정의한 정적 팩터리 메서드를 통해 Weapon의 구현체 클래스인 Sword, Gun에 대한 인스턴스를 생성하고 있다.

public interface Weapon {
    static Weapon createSword(){
        return new Sword();
    }
    
    static Weapon createGun(){
        return new Gun();
    }
}

...

public class Main {

    public static void main(String[] args) {
        Weapon sword = Weapon.createSword();
        Weapon gun = Weapon.createGun();
    }
}

3.4. 조건에 따른 하위 타입 객체 반환을 통한 유연성 증대

 앞에서는 구현체 클래스에 대한 인스턴스 생성을 위해 외부에서 직접 해당 메서드를 호출하고 있다. 만약 요구사항이 바뀌어 직업이 검사일때는 Sword, 스나이퍼일때는 Gun 인스턴스를 생성해야한다면 어떻게 할까? main 메서드에서 직업에 대해 분기 후 검사일때는 createSword() 메서드를, 스나이퍼일때는 createGun() 메서드를 호출할 수도 있지만, 정적 팩터리 메서드 내에서 직업을 파라미터로 받고, 내부 조건문에 따라 무기 인스턴스를 반환할 수도 있다.

public interface Weapon {
    static Weapon createFromJob(Job job){
        if(job == Job.SNIPER){
            return new Gun();
        }else{
            return new Sword();
        }
    }
}

...

public class Main {

    public static void main(String[] args) {
        Weapon sword = Weapon.createFromJob(Job.SWORDS_MAN);
        Weapon gun = Weapon.createFromJob(Job.SNIPER);
    }
}

 


3.5. 캡슐화를 통한 코드중복 및 파편화 방지

 위 예제를 보면 Weapon 인터페이스에서 무기 생성에 대한 구현부를 캡슐화시키고, 메인 메서드에서는 캡슐화된 메서드를 호출하고 있다. 그렇다면 캡슐화란 무엇이고 목적은 뭘까?

 


※ 캡슐화

캡슐화란 객체의 속성과 행위를 하나로 묶고, 실제 구현부는 외부로부터 은닉하는 것을 말한다.

 

환자가 약사에게 독감 진단서를 제출하면 약사는 정해진 재료와 제조 과정을 거쳐 약을 조제한다. 알약을 받는 환자는 여기에 사용된 재료나 제조 과정을 이해할 필요도, 알 필요도 없다. 다만 진단서를 제출할 뿐이다.

 

 자바 코드로 이해하면 약사 객체인 Chemist 클래스를 만들고 약을 제조하는 전 과정을 makePill 이라는 약을 조제하는 메서드로 묶는 것이다. 외부 클래스에서 약이 필요하다면 Chemist 클래스에서 해당 메서드를 호출하기만 하면 된다. 호출하는 클래스는 어떤 과정을 거쳐 약이 만들어지는지는 알 필요가 없게 된다. 

 이처럼 약을 조제하는데 필요한 여러 속성들과 행위를 makePill 이라는 하나의 메서드로 묶고, 이에 대한 구현부는 외부로부터 은닉하여 알 필요가 없게하는 것이 바로 캡슐화이다.

 

public class Chemist {
    public Pill makePill(String 진단서){

        // 약을 만드는 방법에 대한 로직

        return pill; // 생성된 약
    }
}

 

 만약 약사라는 클래스를 통해 해당 로직을 캡슐화를 하지 않는다면 어떤 일이 벌어질까? 약이 필요한 모든 곳에서 약을 조제하는 로직을 구현해야 하며, 아래와 같이 중복코드와 메서드 파편화가 발생하게 된다.

public void hospitalCare(){

    // 1. 병원 진료

    // 2. 약은 만드는 방법에 대한 로직 >> 코드중복 ! 메서드 파편화!

}

// 병원 진료 내역을 조회한 후 해당 내역에 대한 처방전을 받는 메서드
public void getHospitalCareHistory(){

    // 1. 병원 진료 내역 조회

    // 2. 약은 만드는 방법에 대한 로직 >> 코드중복 ! 메서드 파편화!

}

 

 이 상태에서 약을 만드는 방법에 대한 로직이 변경되면 어떻게될까? 모든 메서드의 로직을 전부 바꿔야한다. 이때 작은 실수가 발생한다면 심각한 버그가 발생하게 된다. 만약 캡슐화가 되어있다면? 앞서 약사 클래스의 makePill 메서드만 변경해주면 된다.

 정리하면 캡슐화는 구현부를 외부로부터 은닉함으로써 책임을 분리하고, 코드의 중복 및 파편화를 예방하고, 유지보수하기 용이한 코드로 만들어주는 유용한 프로그래밍 기법인것이다.

 


4. 정적 팩터리 메서드 명명 방식

 메서드 명은 개발자 마음이지만, 정적 팩터리 메서드 같은 특별한 메서드의 경우 권장하는 명명 방식이 있다.

 


4.1. from

 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 메서드이다.

Date d = Date.from(instant);

4.2. of

 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 메서드이다.

Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);

4.3. instance, getInstance

 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지 않는 메서드이다. 즉 내부에 캐싱된 인스턴스를 리턴할 수도 있다는 말이다.

StackWalker luke = StackWalker.getInstance(options);

4.4. create, newInstance

 매번 새로운 인스턴스를 반환함을 보장한다.

Object newArray = Array.newInstance(classObject, arrayLen);

 

 

반응형

+ Recent posts