반응형

개요

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

 


함수형 프로그래밍이란?

함수형 프로그래밍(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이다.

반응형
반응형

개요

이 게시글은 자바 IO, NIO에 대해 정리하려고 했으나, 공부를 하다보니 InputStream, OutputStream 및 Stream과 같은 용어가 많이 등장했다. 곧 이에 대한 개념이 잡히지 않았되지 않다는 것을 알았고, 필요성을 느껴 이에 대해 정리해보았다. InputStream과 OutputStream에 대해 이해해보기 전 Stream의 개념에 대해 알아보자.

 

Stream이 뭐에요?

스트림이란 데이터, 패킷, 비트 등의 일련의 연속성을 갖는 흐름을 의미한다.

 

스트림과 비슷한 용어인 '스트리밍'을 생각해보자. 유투브에서 제공하는 영상들은 '동영상 스트리밍' 형태로 보게 되는데, 요즘은 영상 화질도 좋아져서 동영상의 용량이 어마어마하다는 것은 누구나 알것이다.

1.2GB 크기의 유투브 영상 추상화

 

하지만 이렇게 큰 용량을 가진 영상을 우리는 오랜 기다림 없이 바로 볼 수 있다. 그 이유가 뭘까? 서버에서 영상파일을 작은 단위로 쪼개어 클라이언트에게 전달하기 때문이다. 이처럼 데이터를 잘라 연속적인 데이터의 흐름 형태로 전달하는 것을 스트림이라고 한다.

 

네트워크가 불안정할 경우 영상이 끊긴 경험이 있을 것이다. 이 모두 영상 데이터에 대해 스트림 형태로 전달받다가 중간에 네트워크 문제가 생겨 다음 데이터를 전달을 받지 못해 발생하는 현상인 것이다. 만약 스트림이 아닌 영상 데이터를 한번에 전달받는 방식이었다면 1.2GB 크기의 유투브 영상을 모두 전달받기 전까지 영상을 재생할 수 없을 것이다.

 

InputStream이 뭐에요?

java.io 패키지에서 제공하는 이 InputStream는 데이터를 입력받기위한 스트림을 제공하는 추상 클래스이다.  입력받는다는 것은 데이터를 읽어오는 것이다. 

서브 클래스로 ByteArrayInputStream, FileInputStream, AudioInputStream 클래스 등 다양한 InputStream 클래스가 있는 것으로 보아 바이트 배열, 파일, 오디오 파일 등 다양한 형태의 데이터에 대한 스트림을 제공한다는 것을 알 수 있다. 한번 사용해보자.

 

Input, Output이 헷갈려요... 😫

Java 입장에서 생각해보자. Java 입장에서 Input은 들어오는 것이니 데이터를 읽어오는 것, Output은 나가는 것이니 데이터를 쓰는 것으로 이해하면 쉽다.

 

 

InputStream을 통해 텍스트 파일 읽어보기

// C:/testFile/txtFile.txt
aaaaaaaa
bbbbbbb
cccccccc
ddddddddd
eeeeeeee
ffffffff

 

위 텍스트 파일을 만든 후 InputStream으로 읽어오는 것을 테스트해보았다. 참고로 try/resources 를 사용해 close 메서드를 자동호출하도록 하였다. println 메서드를 통해 결과를 출력하면 '97'이 출력된다.

@Test
void fileInputStreamTest(){
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        System.out.println(inputStream.read()); // 97 출력
    }catch (IOException e){
        e.printStackTrace();
    }
}

 

 

 

아까 말했듯이 Stream은 잘게 잘린 데이터의 연속적인 흐름이다. FileInputStream을 사용하면 파일 데이터를 byte 타입의 쪼개진 데이터들로 받을 수 있고, read() 메서드는 스트림을 통해 1byte씩 읽는 메서드이기 때문에 97이라는 값이 출력된 것이다. 참고로 97은 'a' 문자의 아스키코드이다.

 

read()
Reads the next byte of data from the input stream.
= 입력 스트림에서 데이터의 다음 바이트를 읽습니다.

 

 

그럼 read() 메서드를 계속 호출하면 어떻게 될까? 🙄

 

아래와 같이 더 이상 읽을 데이터가 없는 시점부터 -1이 출력된다. 스트림이 비어있는 것이다.

@Test
void fileInputStreamTest(){
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        
        System.out.println(inputStream.read());
        System.out.println(inputStream.read());
        System.out.println(inputStream.read());
        ...
        ...
        System.out.println(inputStream.read()); //-1 출력
        System.out.println(inputStream.read()); //-1 출력
        System.out.println(inputStream.read()); //-1 출력
       
    }catch (IOException e){
        e.printStackTrace();
    }
}

 

 

이러한 특성때문에 일반적으로 스트림을 통해 데이터를 read 할때에는 while과 같은 반복문을 사용해 -1을 체크하는 구문이 들어가게 된다.

void fileInputStreamTest(){
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){

        int i = 0;
        while((i = inputStream.read()) != -1){
            System.out.write(i);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
}

 

출력결과

 


잠깐, System.out.print와 System.out.write 의 차이

 

System.out.print는 다양한 타입의 데이터를 '텍스트 형식'으로 출력하도록 설계된 메서드이다. 예를들어 System.out.print(97)을 사용하면 숫자 97이 그대로 출력된다.

 

이에반해 System.out.write는 바이트 데이터를 출력하기 위해 사용하는 메서드이다. 메서드로 전달된 정수 값을 바이트로 변환하여 출력하기때문에 아스키 코드에 해당하는 문자를 출력한다. 예를들어 System.out.write(97)을 사용하면 97은 'a'로 변환되어 출력된다.

 

print는 사용자 친화적인 텍스트 출력에, write는 바이트 단위의 데이터 처리와 출력에 사용된다.

 


read() 메서드는 1byte씩 스트림으로 전달한다? 너무 느리지않나요? 🤔

1 바이트씩 처리하는 InputStream.read()

 

 

@Test
void fileInputStreamTest(){
    long start = System.currentTimeMillis();
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        int i = 0;
        while((i = inputStream.read()) != -1){
            //System.out.write(i);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms"); // 2589ms
}

 

실제로 10만줄의 텍스트파일을 만들고, 불필요한 로직(System.out.write)을 제거한 후 실행해보니 온전히 InputStream을 통해 데이터를 읽는데만 약 2.5초(2589ms)가 걸렸다. Stream에서 1byte 씩 빼다보니 속도가 느린 것이다. 만약 Stream을 통해 데이터를 읽어올 때 1byte보다 큰 크기의 byte 데이터들을 읽어들인다면 속도가 개선되지 않을까?

 

 

b 바이트 만큼의 버퍼 단위로 처리하는 InputStream.read(byte[] b)

 

 
read(byte[] b)
Reads some number of bytes from the input stream and stores them into the buffer array b.
= 입력 스트림에서 일부 바이트를 읽고 이를 버퍼 배열에 저장합니다.

 

 

찾아보니 버퍼를 활용하는 read(byte[] b) 메서드가 있었다. 256byte의 버퍼 사이즈만큼 스트림에서 데이터를 읽도록 하니 속도가 12ms로 개선되었다. 아무래도 1byte씩 읽는 방식보다는 버퍼를 활용하는 방식을 사용하는 것이 좋아보인다. 

@Test
void fileInputStreamTest2(){
    long start = System.currentTimeMillis();
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        int i = 0;
        byte[] buf = new byte[256];
        while(inputStream.read(buf) != -1){
            //System.out.write(buf);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms"); // 12ms
}

 

 

출력했더니 데이터가 누락됐어요! 😨

이제 작성한 코드에 있던 주석을 해제하고 읽어온 데이터를 콘솔에 출력해보았다. 그런데 ff 문자 두개가 누락된것 같다. 예외도 발생하지 않았다. 어째서 이런일이 발생하는 것일까? 

ff 문자가 누락된 모습

 

 

알고보니 데이터가 누락된게 아니었다. 이는 버퍼를 사용하는 메커니즘을 이해하지 못한 필자의 착각이었다. read(byte[] b) 메서드를 호출하면 직전 buffer에 스트림에서 읽은 데이터가 덮어 씌워진다. 필자는 buffer가 자동으로 비워질줄 알았는데 덮어 씌워지는 것이었다. 누락보다는 추가됐다고 할 수 있겠다.

 

Buffer는 있는 그대로 덮어 씌워버려요~

 

 

이때문에 InputStream.read(byte[] b) 메서드를 사용하려면 앞선 예제처럼 사용하면 안된다. buffer에서 read한 사이즈만큼 처리하는 로직을 따로 구현해야 한다.

앞서 read() 메서드는 스트림을 통해 읽은 바이트를 그대로 리턴한다. 이에반해 read(byte[] b) 메서드는 읽은 바이트의 수를 리턴한다. 이를 활용하여 i와 같은 임시 변수를 만든 후 읽은 바이트 수를 저장하고, 이 길이만큼 버퍼에서 읽도록 구현하자.

@Test
void fileInputStreamTest2(){
    long start = System.currentTimeMillis();
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        int i;
        byte[] buf = new byte[256];
        while((i = inputStream.read(buf)) != -1){
            for(int len = 0; len<i; len++){
                System.out.write(buf[len]);
            }
        }
    }catch (IOException e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms");
}

 


OutputStream은 뭐야?

OutputStream은 데이터를 출력하기 위한 스트림을 제공하는 추상 클래스이다. 자바 입장에서 데이터를 나가게 하므로 외부 파일에 데이터를 쓰는 것이라고 생각하면 쉽다.

 

이것도 마찬가지로 write(byte b) 메서드를 통해 1byte씩 출력 스트림을 통해 데이터를 전달할 수 있다.

@Test
void fileOutputStreamTest(){

    byte[] bytes = {97,97,97,97,97};

    try(OutputStream outputStream = new FileOutputStream("C:/testFile/txtFile.txt")){
        for(byte b : bytes){
        	outputStream.write(b);
        }
        outputStream.flush();
    }catch (Exception e){
        e.printStackTrace();
    }

}

 

 

txtFile.txt에 97을 가진 byte 배열을 쓰기(쓰기작업) 위해 write() 메서드를 호출한다. 그럼 txtFile.txt에는 97에 해당하는 아스키 코드 값인 a가 5개 작성된다.

OutputStream.write() 를 통해 aaaaa를 넣어보아요

 

 


1Byte씩? 이것도 느리지않나요? 🤔

속도 테스트를 위해 bigFile이라는 많은 데이터를 가진 텍스트파일을 만들고 이 byte 값을 OutputStream을 통해 전달해보았다. 텍스트 내 데이터를 복사하는 것이다. 첫번째는 InputStream을 통해 읽어들인 byte 데이터를 1byte씩 write하고, 두번째는 버퍼 사이즈만큼 write하도록 하였다. 결론부터 말하면 느렸고, 버퍼를 활용하는 것이 훨씬 빠르다.

 

1Byte씩 write

2654ms 초가 걸렸다. 1byte씩 Stream을 통해 데이터를 전달해서 그런지 속도가 느리다.

@Test
void fileOutputStreamTest2(){
    long start = System.currentTimeMillis();
    try(
            OutputStream outputStream = new FileOutputStream("C:/testFile/txtFile.txt");
            InputStream inputStream = new FileInputStream("C:/testFile/bigFile.txt"))
    {
        byte[] buf = new byte[256];
        int i;
        while((i = inputStream.read(buf)) != -1){
            for(int len = 0; len<i; len++){
                outputStream.write(buf[len]); // 1byte씩 write
            }
        }
    }catch (Exception e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms");
}

 

 

Buffer write

25ms가 걸렸다. InputStream 때와 마찬가지로 Buffer를 사용하니 스트림으로의 쓰기 작업 횟수가 줄어들어 속도가 굉장히 빨라진 것을 확인할 수 있었다.

@Test
void fileOutputStreamTest3(){
    long start = System.currentTimeMillis();
    try(
            OutputStream outputStream = new FileOutputStream("C:/testFile/txtFile.txt");
            InputStream inputStream = new FileInputStream("C:/testFile/bigFile.txt"))
    {
        byte[] buf = new byte[256];
        int i;
        while((i = inputStream.read(buf)) != -1){
            outputStream.write(buf,0,i); // 버퍼의 0부터 읽은 사이즈까지 한번에 write
        }
        outputStream.flush();
    }catch (Exception e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms");
}

 

 

 

InputStream, OutputStream을 사용할때 무조건적으로 예제에서 제공하는 코드를 사용하다가는 큰코다칠 수 있다. 버퍼를 적극 활용하고 입출력에 대한 실행 시간을 고려해보는 습관을 갖자.

반응형
반응형

Jenkins sshPublishers의 removePrefix가 뭔지 잘 이해되지 않아 정리합니다. 추가로 remoteDirectory, sourceFiles 설정도 함께 정리하였습니다.

 

빌드 후 조치의 SSH Publishers 설정 🤗

예제로 사용할 Send build artifacts over SSH의 설정입니다.

빌드 후 조치 - SSH Server 설정

 

 

Source files

Source files는 로컬 서버에서 원격지 서버로 전송할 파일을 의미합니다. 주의할 점은 위와 같이 /build/libs/*.jar를 입력할 경우 build/libs에 있는 jar 파일만 이동되는게 아닙니다. /build/libs 폴더도 이동됩니다. 😯

 

/build/libs/*.jar 가 통으로 이동됨

 

 

remoteDirectory

빌드 후 조치 - Remote directory

 

ssh로 연결한 원격 서버의 작업 디렉토리를 의미합니다. Source files에 입력한 파일들이 이 작업디렉토리로 이동됩니다. 앞서 /build/libs/*.jar 파일들은 Remote directory에 입력한 namevalue 디렉토리 내에 위치한 것을 확인할 수 있습니다. 참고로 작업 디렉토리의 기준이 되는 루트 디렉토리는 Jenkins의 시스템 설정에 추가한 SSH Server 의 Remote Directory 입니다.

SSH Servers 설정

 

 

만약 /home/sksim/application/namevalue 라는 폴더로 파일들을 복사하고 싶다면 Remote directory에 /application/namevalue를 입력하면 됩니다.

 

removePrefix

지금은 jar 파일과 build/libs 디렉토리를 함께 전송하고 있습니다. 사실 저는 jar 파일만 이동시키면 됩니다. build/libs라는 디렉토리까지 이동시킬 필요가 없는 것입니다. 즉, Source files 에 포함된 파일들 중 전송시키고 싶지 않는 Prefix 경로를 적어주면 removePrefix에 적어주면 Prefix에 해당하는 파일이나 디렉토리는 이동되지 않습니다. /build/libs를 입력하면 *.jar 에 해당하는 파일만 이동되는 것입니다.

 

Remove prefix에 /build/libs를 입력

 

 

/build/libs 경로를 제외한 Source Files 이동

 

반응형
반응형

프로세스란 뭔가요? 🧐

프로세스의 개념은 프로그램과 관련 있습니다. 프로그램은 하드웨어에 '정적 상태'로 저장되어 있습니다. 누군가 실행시키지 않는 한 그 상태를 유지합니다. 그럼 프로그램이 실행되어 '동적 상태'로 되는 것은 무엇일까요? 이게 바로 프로세스입니다. 프로그램이 실행되어 메모리에 올라온 상태를 프로세스라고 합니다.

 

 

프로세스는 메모리에 올라간다!

프로그램이 실행되면 운영체제는 프로세스를 메모리의 적당한 위치로 가져오고, 프로세스의 정보들을 저장한 PCB(Process Control Block)를 생성합니다. 더 자세히는 프로세스는 메모리의 사용자(유저) 영역에, PCB는 커널 영역에 올라가게 됩니다. 

 

메모리

 

PCB(Process Control Block)
CPU가 프로세스를 실행하기 위해 필요한 프로세스 구분자, 메모리 관련 정보, 프로그램 카운터, 각종 중간값들을 보관하는 데이터 구조입니다. 프로그램이 프로세스가 되려면 메모리에 올라오는 것과 동시에 PCB가 반드시 생성되어야 합니다. 프로세스가 종료되면 프로세스는 메모리에서 삭제되며, PCB도 폐기됩니다. 

 

 

프로세스의 연산을 처리하는 CPU

실행중인 프로그램의 상태를 프로세스라고 했습니다. 그리고 실행중이라는 뜻은 프로그램에 정의된 코드들의 연산이 처리되는 것을 말합니다. 이 연산을 처리하는 것이 바로 CPU 입니다. 그럼 연산할 코드들은 어디서 얻어오는 걸까요? 바로 스레드입니다. 하나의 프로세스는 무조건 하나 이상의 스레드를 갖습니다. 이 스레드들을 CPU가 처리하는 것입니다.

 

프로세스 구조

프로세스에 스레드가 하나밖에 없으면 싱글 스레드, 둘 이상이면 멀티 스레드라고 말합니다. 이 둘의 구조적인 차이가 뭘까요? 이를 이해하기 위해서는 먼저 프로세스의 구조를 이해해야 합니다.

 

프로세스 구조

 

코드 영역

프로그램의 코드가 기술된 곳입니다. 프로그래머가 작성한 프로그램은 코드 영역에 탑재되며 탑재된 코드는 읽기전용으로 처리됩니다.

 

데이터 영역

코드가 실행되면서 사용하는 변수나 파일 등의 각종 데이터를 모아놓은 곳입니다. 데이터는 변하는 값이기때문에 읽기와 쓰기가 가능합니다. 물론 상수는 읽기 전용입니다.

 

스택 영역

운영체제가 프로세스를 실행하기 위해 부수적으로 필요한 데이터를 모아놓은 곳입니다. 프로세스 내에서 함수를 호출하면 함수 실행 후 돌아올 위치를 이 영역에 저장합니다. 위 예에서는 exit() 함수를 호출했을 때 돌아올 위치가 180이라는 주소임을 말하고 있습니다. 프로그램을 실행하면 운영체제는 프로그램을 메모리의 코드 영역에 넣습니다. 그리고 데이터 영역과 스택 영역을 확보하고  프로세스를 실행합니다. 이와 동시에 PCB도 생성합니다.

 

 

스레드가 뭔가요? 🤔

CPU가 처리하는 실행 단위를 말합니다. 한 개 이상의 스레드가 모여 프로세스를 이루기 때문에, 스레드를 프로세스 실행 단위라고도 합니다.

 

싱글 스레드와 멀티 스레드의 차이

이제 싱글 스레드와 멀티 스레드의 차이를 알아보겠습니다. 싱글 스레드는 앞서 언급한대로 프로세스가 하나의 스레드만을 갖는 것을 말합니다. CPU는 한번에 하나의 스레드만을 처리할 수 있으므로 CPU가 1 개인 시스템에서 프로세스의 실행은 문제가 되지 않습니다. 하지만 현재 시스템은 대부분 여러개의 CPU로 구성되어 있습니다. 필자의 경우 12개의 CPU 코어가 있으니, 동시에 12개의 스레드를 처리할 수 있습니다. 이러한 환경에서 단일 스레드 프로세스를 실행하게 되면 11개의 CPU 코어를 활용하지 못해 시스템의 효율성이 내려가게 됩니다. 이왕이면 여러 개의 스레드가 처리되는게 더 좋겠죠?

 

단일 스레드와 멀티 스레드를 대하는 CPU의 자세

 

 

프로세스를 여러개 만들면 되는거 아냐? 🤔

그럼 단일 스레드를 갖는 프로세스를 여러개 실행하면 어떻게될까요? 프로세스와 스레드가 새로 생성될것이고 여러 개의 CPU가 이들을 처리하게 될것입니다. 그런데 이 방식은 문제아닌 문제가 있습니다. 바로 프로세스마다 메모리 할당과 PCB 생성을 해야한다는 것입니다.

 

위에서 프로세스의 구조를 설명했는데 사실 힙 영역이라는 영역이 더 존재합니다. 그리고 힙 영역과 스택 영역은 동적 영역에 해당하는데 동적으로 크기가 줄어들고 늘어나는 영역입니다. 스택 영역은 함수 호출 후 복귀 시 사용하고, 추가로 지역변수를 저장할때 사용됩니다. 참고로 전역변수는 데이터 영역에 저장됩니다. 힙 영역은 프로그램이 실행되는 동안 할당되는 영역으로 자바의 인스턴스나 c언어의 malloc() 함수입니다.

 

프로세스 구조

 

 

스레드는 프로세스 구조 중 동적영역에 생성됩니다. 아래와 같이 말이죠. 

멀티 스레드

 

만약 단일 스레드 프로세스를 여러개 실행하면 어떻게될까요? 프로세스 개수만큼의 정적영역이 메모리에 추가로 할당되어야 할것입니다. 

멀티 태스킹

 

 

또 하나의 문제가 있습니다. 바로 Context Switching 속도가 느리다는 것입니다. 각각의 프로세스를 Context Switching 하는것보다 같은 프로세스를 갖는 스레드에 대해 Context Swtiching하는 속도가 더 빠릅니다.

 

Context Switching (문맥교환)
CPU를 차지하던 프로세스가 나가고 새로운 프로세스를 받아들이는 작업을 말합니다. 실행 상태에 있던 PCB에는 지금까지의 작업을 저장하고, 실행 상태로 들어오는 PCB의 내용으로 CPU가 다시 셋팅되는 작업입니다. 이와 같이 두 프로세스의 PCB를 교환하는 작업이 문맥교환입니다.

 

 

멀티 스레드의 문맥교환이 단일 스레드보다 더 빠른 이유가 뭐야? 🤔

멀티 스레드는 같은 프로세스에 속해있기 때문에 정적인 데이터를 공유하게 됩니다. 데이터 영역과 코드 영역을 공유합니다. 캐시는 CPU에서 읽어들인 메모리의 데이터를 저장하고 있다가 CPU가 다시 데이터를 요구할 때 메모리에서 전달해줍니다. 즉, 문맥 교환이 발생하고 PCB 내용을 기반으로 CPU를 셋팅할때 데이터 영역과 코드영역을 메모리영역에서 빠르게 읽어오게 됩니다. 왜? 프로세스가 같으니까요!

이에 반해 단일 스레드의 경우 PCB가 다르므로 기존에 쌓았던 캐시 데이터는 무의미해지고 CPU가 데이터를 읽어들이면 이를 다시 저장해야합니다. 이런 이유로 단일 스레드보다 멀티 스레드의 문맥교환이 더 빠른것입니다. 

 

 

그럼 문맥 교환은 언제 일어나는거야? 😲 

문맥 교환이 일어나는 상황은 매우 다양하나 대표적으로 두가지가 있습니다. 하나는 CPU가 처리중인 프로세스가 자신에게 주어진 시간을 다 사용했을 때이며, 하나는 인터럽트가 발생했을 때입니다. 인터럽트가 발생하는 상황은 매우 다양합니다. 예를들어 프로세스가 자신에게 주어진 메모리 공간을 넘어가려 한다면 인터럽트 관리 프로세스를 실행시킵니다. 이때 문맥교환이 발생합니다. 그리고 인터럽트 관리 프로세스가 메모리 범위를 넘어서려는 프로세스를 강제 종료하게 됩니다. 

 

멀티 스레드의 장점 

 

첫째, 응답성이 향상됩니다. 한 스레드가 입출력으로 인해 작업이 진행되지 않아도 다른 스레드가 작업을 계속하여 사용자의 작업 요구에 빨리 응답할 수 있습니다.

둘째, 자원을 공유합니다. 프로세스가 가진 자원을 모든 스레드가 공유하게 되어 작업을 원활하게 진행할 수 있습니다.

셋째, 시스템 효율성이 향상됩니다. 여러 개의 프로세스를 생성할 필요가 없어 불필요한 자원의 중복과 메모리 중복을 막고 문맥교환이 빨라집니다. 전반적인 시스템 효율이 향상되는 것입니다.

 

멀티 스레드의 단점

하나의 스레드에 문제가 생겨 종료될 경우 해당 스레드만 종료되는 것이 아니라 프로세스 전체가 종료됩니다. 인터넷 익스플로러는 멀티 스레드라 탭을 하나 추가할 경우 스레드가 생성된다. 이때 하나의 탭에 문제가 생겨 종료된다면 프로세스 자체가 종료되어 인터넷 익스플로러가 종료되게 됩니다. 이에반에 크롬은 싱글 스레드로 각 탭마다 독립적인 프로세스로 동작합니다. 만약 한 프로세스의 스레드에 문제가 생겨 종료되도, 다른 탭에 미치는 영향이 적습니다. 크롬은 이처럼 다른 스레드가 영향받는 것을 최소화하기 위해 낭비 요소가 있더라도 멀티스레드 대신 멀티태스킹을 사용합니다.

 

 

프로세스 상태

프로세스는 CPU 스케줄러에 의해 선별되며 스케줄러가 프로세스의 스레드를 CPU에게 전달하게 됩니다. 이를 '실행 상태' 라고 하는데, 이 외에도 여러 상태들이 있습니다. 한번 알아봅시다.

프로세스의 상태는 시스템마다 다르게 구성됩니다. 일괄 작업 시스템의 경우 생성, 실행, 완료 상태를 갖지만, 우리가 현재 대부분 사용하는 시분할 시스템의 프로세스 상태는 생성, 준비, 실행, 대기, 완료 상태를 갖습니다.

 

프로세스 상태

생성 상태

프로그램이 메모리에 올라오고, 운영체제로부터 PCB를 할당받은 상태입니다. 생성된 프로세스는 바로 실행되는 것이 아니라 준비 상태(준비 큐)에서 기다리게 됩니다.

 

준비 상태

프로세스가 CPU를 얻을때까지 기다리는 상태입니다. 준비 큐라는 곳에서 기다리며 CPU 스케줄러에 의해 관리됩니다.

참고로 CPU가 하나인 컴퓨터에서는 한번에 하나의 프로세스(정확히는 프로세스 내 스레드)만을 실행할 수 있습니다. CPU가 많을수록 준비 상태에 있는 프로세스가 빨리 처리될 것입니다.

 

CPU 스케줄러
준비 상태에 있는 여러 프로세스 중 다음 실행할 프로세스를 선정하는 일을 담당합니다. 준비 상태의 맨 앞에서 기다리는 PCB와 스레드를 CPU에게 전달하여 작업이 이루어지도록 합니다.

 

디스패치 (Dispatch)
준비 상태의 프로세스 중 하나를 골라 실행 상태로 바꾸는 CPU 스케줄러의 작업을 말합니다.

 

 

실행 상태

준비 상태에 있는 프로세스 중 하나가 CPU를 얻어 실제 작업(스레드)을 수행하는 상태를 말합니다. 실행 상태에 들어가는 프로세스의 수는 CPU의 개수만큼입니다. 프로세스마다 할당된 시간(타임 슬라이스)을 다 사용하고도 작업이 끝나지 않는다면 해당 프로세스는 준비 상태로 돌아가 다음 차례를 기다리게 됩니다.

 

타임 슬라이스 (= 퀀텀)
프로세스에 할당된 작업 시간을 말합니다.

 

클록
타임 슬라이스가 지났는지를 CPU에게 알려주는 장치입니다. 시간이 끝나면 인터럽트를 발생시켜 CPU에게 알려줍니다.

 

 

대기 상태

프로세스가 실행 상태에서 입출력(I/O)을 요청할 경우 입출력이 완료될 때까지 기다리는 상태입니다. 이 상태의 프로세스는 입출력 장치별로 마련된 큐에서 기다립니다. 입출력이 완료되면 입출력 관리자로부터 인터럽트를 받고, 준비 상태로 이동하여 다음 작업 수행을 기다린다.

 

완료 상태

실행 상태의 프로세스가 주어진 시간 동안 작업을 마치거나 종료되는 상태입니다. 프로세스를 메모리에서 제거하고, PCB를 폐기합니다. 만약 비정상 종료될 경우 코어 덤프가 발생합니다.

 

코어 덤프
프로세스가 비정상 종료될 경우 강제 종료 직전 메모리 상태를 저장 장치로 옮기는 것

 

 

 

반응형

'CS' 카테고리의 다른 글

[CS] 웹 프락시 / Proxy  (0) 2023.09.20
[CS] HTTP 메시지  (0) 2023.08.30
[CS] URL이란?  (0) 2023.08.30
[CS] Web Cache / 웹 캐시란?  (0) 2023.08.23
반응형

확장할 수 없는 열거 타입

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

 

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

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보다는 클래스를 사용하여 구현하는게 더 좋지않을까?? 이번 내용은 크게 와닿지 않는 것 같다.

반응형
반응형

JVM이 뭐야?

JVM(Java Virtual Machine)은 바이트코드(.class)를 OS에 특화된 코드(기계어)로 변환하고, 이를 실행하는 '가상의 머신'이다. 특정 OS에 특화된 코드로 변환하기때문에 OS 종속적이다. JVM은 JRE에 내포되어 있다.

 

JVM

 


JRE는 뭔데?

JRE 구조

 

JRE(Java Runtime Environment)는 자바 어플리케이션을 실행할 수 있도록 구성된 배포판이다. 자바 어플리케이션을 실행한다는 것은 코드를 '실행'한다는 것인데 바로 이를 JRE에 포함된 JVM이 처리한다. 코드를 실행하는데 있어 꼭 필요한 java.util, java.io, java.net 등의 라이브러리도 포함되어 있기에 JRE의 구조는 위처럼 JVM + Library 로 구성된다.

 


JDK는 뭔데?

JDK(Java Development Kit)는 자바 어플리케이션 개발에 필요한 도구 및 라이브러리를 JRE와 함께 제공하는 개발 키트이다. 개발에 필요한 javac, jconsole, javadoc 과 같은 도구와 컬렉션 프레임워크, 파일 I/O, 스트림 API, 데이터 액세스 관련 라이브러리를 제공하는 것이다.

 

JVM , JRE, JDK 의 구조

 

 


Oracle Java 11 버전부터는 JRE를 따로 제공하지 않아요

Oracle 홈페이지에 들어가면 Java 8의 경우 JRE를 따로 제공했지만, Java 11 이상은 제공하지 않음을 확인할 수 있다.

 

JRE와 JDK를 각각 지원하던 Java 8

https://www.oracle.com/kr/java/technologies/javase/javase8-archive-downloads.html

 

Java Archive Downloads - Java SE 8 | Oracle 대한민국

죄송합니다. 검색 내용과 일치하는 항목을 찾지 못했습니다. 원하시는 정보를 찾는 데 도움이 되도록 다음을 시도해 보십시오. 검색에 사용하신 키워드의 철자가 올바른지 확인하십시오. 입력

www.oracle.com

 

JDK만 지원하는 Java 11 이상 버전

https://www.oracle.com/kr/java/technologies/javase/jdk11-archive-downloads.html

 

Java Archive Downloads - Java SE 11 | Oracle 대한민국

WARNING: These older versions of the JRE and JDK are provided to help developers debug issues in older systems. They are not updated with the latest security patches and are not recommended for use in production. For production use Oracle recommends downlo

www.oracle.com

 


JVM의 구조

 

JVM 구조

 

 


Class Loader

Class Loader

 

 Java는 동적로딩을 하는 특징이 있다. 이 동적로딩을 담당하는 부분이 바로 클래스 로더이다.

 

동적로딩
어플리케이션 실행 시점에 모든 클래스 파일을 메모리에 올려두는 것이 아닌, 필요할 때 하나씩 메모리에 올리는 방식을 말한다. 즉, 런타임시 동적으로 클래스파일을 로드하는 것이다.

 

클래스 로더는 class 파일을 읽어 OS에서 할당한 JVM의 메모리 영역으로 동적 로딩한다. 이 과정은 로딩, 링크, 초기화라는 3단계로 구성된다.

 

로딩

.class 파일(바이트 코드)을 읽고, 이를 JVM 메모리의 메서드 영역에 저장한다.

저장되는 정보는 FQCN, 타입(클래스인지, 인터페이스인지, 이늄인지), 메서드, 변수이다.

리플렉션 API를 통해 읽어오는 FQCN, 메서드, 파라미터, 멤버필드와 같은 클래스 정보를 바로 이 메서드 영역에서 읽어온다.

 

* 로딩이 끝나면 해당 클래스 타입의 Class 객체를 생성하여 힙 영역에 저장한다.

 

FQCN(Fully Qualified Class Name)
패키지 경로를 포함한 클래스 풀 네임을 말한다.

 

링크

.class 파일이 유효한지 검증하고 클래스가 필요한 메모리 양을 미리 할당하며, 클래스가 참조하는 실제 메모리 주소값을 할당한다. 위 작업들은 검증단계, 준비단계, 분석단계로 구분된다.

 

검증단계 : class 파일이 유효한지 검증한다. 

준비단계 : 클래스가 필요한 메모리 양을 미리 할당한다. 

분석단계 : 클래스가 참조하는 실제 메모리 주소값을 할당한다.

 

초기화

클래스 변수(static 변수)를 초기화한다.

 

 

클래스 로더의 종류

클래스 로드 요청을 받으면 캐시에서 로드하고, 캐시에 없을 시 상위 클래스 로더부터 하위 클래스 로더 순으로 요청받은 클래스의 로드 작업을 수행한다.

 

클래스 로더의 계층구조

 

 

BootStrap Class Loader > JAVA_HOME/lib 경로에 있는 자바의 기본 클래스 로드

 

Plaform Class loader > JAVA_HOME/lib/ext 경로에 있는 자바의 확장 클래스 로드

 

Application Class loader > -classpath 옵션 또는 java.class.path 환경 변수의 값에 해당하는 위치에서 클래스를 로드 


JVM 메모리

 

메서드 영역

클래스 수준의 정보 (클래스 이름, 메서드, 변수, 부모클래스 이름)가 저장된다.

여러 쓰레드들이 공유하여 사용하는 공유자원 영역이다.

 

힙 영역

생성한 인스턴스들이 저장된다. 여러 쓰레드들이 공유하여 사용하는 공유자원 영역이다. 힙 영역의 인스턴스는 GC에 의해 메모리에서 제거된다.

 

스택영역

 스택영역에는 쓰레드마다 런타임 스택을 만들고, 그 안에 메서드 호출을 스택 프레임(메서드 콜)이라 부르는 블럭으로 쌓는다. 쓰레드를 종료하면 런타임 스택도 사라진다. 예외가 발생했을 때 로그에 쭉 쌓이는 스택들이 바로 스택영역으로부터 추출한 데이터들이다.

스택 영역에서 조회한 스택

 

PC 레지스터

쓰레드마다 현재 어느 메서드를 콜하고 있는지를 가리키는 포인터를 저장하는 곳이다.

 

네이티브 메서드 스택

네이티브 메서드를 호출할 때 사용하는 별도의 스택이 저장된다.

 

JNI(Java Native Interface)

 자바 어플리케이션에서 C, C++, 어셈블리로 작성된 함수를 사용할 수 있는 방법을 제공하는 인터페이스이며, native 키워드가 붙어 있다. Thread의 currentThread() 메서드 또한 네이티브 인터페이스 중 하나이다.

 

Thread.currentThread()

 

Native Method Library

 C, C++로 작성된 네이티브 라이브러리이다.

 

 

* 스택과 PC 레지스터, 네이티브 메서드 스택은 쓰레드별로 생성되며, 쓰레드끼리 공유하지 않는다.

 


실행엔진

 

인터프리터

 바이트 코드를 한줄 씩 실행하는 프로그램이다.

 

JIT 컴파일러

 인터프리터 효율을 높이기 위해, 인터프리터가 반복되는 코드를 발견하면 JIT 컴파일러로 반복되는 코드를 모두 네이티브 코드로 바꿔둔다. 그 다음부터 인터프리터는 네이티브 코드로 컴파일된 코드를 바로 사용한다.

 

GC(가비지 컬렉터)

 더 이상 참조되지 않는 인스턴스들을 정리해주는 프로그램이다.

 


출처

더 자바, 코드를 조작하는 다양한 방법 - 인프런 백기선님 강의

https://www.youtube.com/watch?v=-p5vM1PSOVs - 개발자 장고님의 유투브 동영상

반응형
반응형
반응형

필자의 주관적인 생각과 이해를 바탕으로 작성된 글입니다. 잘못된 부분이 있거나 있다면 댓글로 피드백 부탁드립니다!

 

개요

 

HashMap은 Key, Value 데이터쌍을 저장하는 자료구조로 익히 알고있다. 속도면에서 장점을 갖고 있어 코딩테스트에서도 많이 활용된다. 도대체 어떻게 생겨먹은 녀석이길래 이렇게 빠른지 알아보자.

 


HashMap = Hash + Map

 

Map
Key, Value 쌍으로 이루어진 자료형.
순서를 보장하지 않음.
키는 중복이 허용되지 않음.

 

Hash
해시 함수를 사용하여 임의의 길이를 가진 데이터를 고정된 길이를 가진 데이터로 매핑한 값

 

 

즉, HashMap이란 Map은 Map인데 Hash를 활용한 Map인 것이다.


Map은 어떻게 Key, Value 쌍으로 관리할 수 있을까? 🤔

HashMap을 이해하기 위해선 Map과 Hash에 대해 이해해야한다. 먼저 Map이 어떻게 Key와 Value 쌍으로 관리할 수 있는 이유는 내부적으로 Key와 Value를 담을 수 있는 배열 타입으로 데이터를 관리하고 있기 때문이다.

 

 먼저 일반적인 배열의 형태를 생각해보자. 인덱스마다 하나의 값을 넣을 수 있는 자료구조인데 Key와 Value를 둘 다 넣는다는 건 말이 되지 않아보인다. 그런데 배열의 인덱스에 Key값을 넣는다면 얘기가 된다. 0이라는 Key에 대한 Value는 010-1111-1111, 1이라는 Key에 대한 Value는 010-2222-2222로 관리한다고 가정한다면 아래와 같이 배열의 인덱스에는 Key 값을 넣어 관리할 수 있다.

 

배열의 Index를 키로 활용한다.


 

배열의 인덱스를 키로 활용한다고? 그럼 Key는 정수만 가능하잖아... 🤔

 그렇다. HashMap의 Key는 정수 뿐 아니라 모든 타입의 인스턴스가 들어올 수 있다. 그럼 인스턴스를 정수로 변환할 수 있다면 어떨까? 그럼 Key로 들어온 모든 인스턴스는 Key로 사용 가능하게 된다. HashMap의 Hash 의미를 여기서 알 수 있다. 키로 들어온 값을 해시함수를 통해 해시화 시키고, 이를 배열의 인덱스로 사용하는 것이다! 여기서 사용되는 해시함수는 들어온 인스턴스의 hashCode() 메서드이다.

 


 

그럼 HashMap 은 이렇게 생겼나요? (상상)

hashMap.put("Sim","010-1111-1111");
hashMap.put("Park","010-2222-2222");
hashMap.put("Hong","010-3333-3333");

 

String 타입의 Key와 Value를 저장하기 위해 String 타입의 HashMap을 생성하고 위 코드를 실행시킨다고 가정해보자. Sim, Park, Hong에 대한 해시 값이 아래와 같다.

해시 값

 

지금까지의 설명을 토대로 HashMap의 구조를 상상해보면 다음과 같을것이다. 만약 새로운 Key, Value 쌍이 들어온다면 해시값과 Size 나머지 연산을 통해 구한 인덱스에 Value가 추가될것이다.

 

상상속의 HashMap

 


 

갑자기 % Size는 뭐야? 🤔

 Hash Func, 즉 해시함수를 통해 해시 코드를 구하고 이를 Size 로 나머지 연산(실제로는 시프트 연산)을 한다. 나머지 연산을 하는 이유는 배열의 인덱스 중 하나로 매핑시키기 위함이다.

 예를들어 Size가 10인 배열은 0~9까지의 인덱를 갖는다. 해시 값은 hashCode() 메서드 뿐 아니라 부가적인 연산도 함께 수행되어 구해지는데 아래와 같이 큰 숫자의 정수형이 리턴된다.

특정 값에 대한 해시값

 

  만약 3288449라는 값을 배열의 인덱스로 사용한다면 최소 3288449 크기의 배열을 생성해야한다. 메모리를 많이 차지할것이다. 때문에 이 값을 배열의 Size로 나눈 나머지를 구하고 이를 Index로 사용하는 것이다. 만약 HashMap 내부 배열 Size가 10이라면 3288449 % 10 = 9. 즉 9라는 인덱스를 갖게 된다.


실제로는 이렇지 않아요. 배열은 배열인데 Node 타입의 배열이랍니다. 🤭

 

실제로 데이터가 저장되는 곳은 제네릭 타입, Object 타입의 배열일까? 모두 아니다 Node 타입의 배열에 저장된다. HashMap에 선언된 Node 타입 변수 및 클래스이다.

 

transient Node<K,V>[] table; // hashMap 클래스 내에 선언되어있어요

 

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    
    ...
    
}

 

 멤버필드로 hash, key, value, nextNode 가 존재한다. 사실 Index와 Value로만 Key, Value 쌍을 관리하면 문제가 많다. 해시가 충돌될 경우 처리도 못하고, 리사이징과 해시 재배치 시 문제가 된다. (문제가 되는 이유는 아래에서 설명하도록 하겠다!)


Index랑 value 만 관리하면 될 줄 알았는데 아니네요? 🤔

hash, key, value, nextNode 필드를 갖는 Node 타입의 인스턴스로 관리되는 이유를 HashMap의 리사이징과 재배치, 충돌 우회 전략과 함께 이해해보자.


리사이징

리사이징
새로운 길이의 배열을 생성한 후 데이터를 이관시킴으로써 결과적으로 배열의 사이즈를 변경시키는 작업

 

 배열의 사이즈는 초기화 시 정해진다. 기본 생성자를 통해 HashMap을 생성한 후 put 메서드를 실행하면 기본사이즈인 16 사이즈의 Node 배열이 생성되고 리사이징 임계 값으로 12(0.75*16)가 결정된다. 여기서 리사이징 임계값이란 배열을 리사이징하는 기준값을 뜻하며 현재 배열길이의 두 배로 리사이징한다. 12개를 초과할 경우 배열을 16의 2배인 32 사이즈로 리사이징 하는것이다.

  ArrayList도 내부적으로 배열을 사용하고, 배열에 더 이상 들어갈 공간이 없을 경우 현재 사이즈의 절반 사이즈를 추가한 새로운 배열로 리사이징하는데 이와 같은 이치이다.

최초 put 메서드 실행 시

 


재배치

재배치
배열의 사이즈가 변경될 때 기존 데이터들의 Index를 재배치하는 작업

 

 리사이징과 이어지는 내용이다. 리사이징을 할 경우 두 배 사이즈로 배열을 재생성하고 데이터를 이관시킨다고 했다. 이는 기존에 저장되어 있던 Node들의 Index가 재배치되어야 함을 의미한다. Index를 구하는 공식은 hashCode % Size 이므로  Size가 바뀐다면 Index도 바뀌어야하기 때문이다. 예를들어 사이즈가 10 일때 해시값 5755151를 통해 구한 Index는 1이지만, 사이즈가 20일 경우 Index는 11이 된다.


Node에서 hash 값이 관리되는 이유

 이 재배치 작업 때 해시 값을 가져와 연산을 해야하는데 해시 값을 hash에 저장해놨기 때문에 나머지 연산만 하면 된다. 만약 해시 값이 없다면 해시 값 추출을 위해 존재하는 데이터 수 만큼의 해시함수 연산을 해야할것이다.

 


충돌 우회 전략 - Separate Chaining

 지금 Index 기반으로 값을 넣고 있는데 과연 Index가 충돌할 확률은 대략적으로 어느정도일까? 사이즈에 대한 나머지를 Index로 사용하므로 배열 사이즈가 20이라면, Index가 중복될 확률은 최소 20분의 1이 된다.

어찌됐든 충돌이 일어날 수 있는 상황이다. HashMap은 이런 충돌에 대한 우회 전략으로 Separate Chaining 방식을 사용하며, 이 전략을 위해 nextNode를 사용한다.

Separate Chaning 
동일한 해시값이 이미 존재할경우 LinkedList로 관리한다. 즉, Node에 있는 필드 중 nextNode를 활용하여 중복된 해시 값에 대한 Value를 관리하는 것이다.

 

 

 그런데 만약 동일한 Key 값이 들어왔다면 어떨까? Sim이라는 Key값이 들어있는 HashMap에 Sim이라는 Key 값으로 다른 Value를 넣는 것은 전혀 문제되지 않는다. 이 경우 Index에 대한 Value가 덮어씌워져야한다. 즉, 동일한 Key가 들어왔는지를 확인하려면 Index 값만 비교하는 게 아니라 실제 Key 값도 비교해봐야한다.

 


Node에서 key, nextNode 값이 관리되는 이유

  동일한 Key가 들어왔는지 확인하고, Linked List 형태로 우회하는 Separate Chaining 전략을 사용하기 위해 key와 nextNode가 관리된다.


Separate Chaining 동작원리

충돌 발생! 비이상!

 

 

 해시함수로 해시값을 구하고 나머지 연산으로 추출한 Index 가 충돌할 경우를 가정했다.

충돌이 일어나면 들어온 Kim에 대한 해시 값과 키 값을 충돌한 Node의 값과 비교한다. 다를 경우 Separate Chaining 전략에 따라 key, value, hash, nextNode를 갖는 Node 인스턴스를 생성하여 NextNode에 할당한다. 만약 비교한 Key와 Hash 값이 같았다면 중복된 Key가 들어온 것이므로 해당 Node의 Value 값을 새로 들어온 Value 값으로 수정한다.


그럼 NextNode에 추가된 Kim을 조회할 땐 어떻게 동작할까?🤔

HashMap.get("Kim")

 

 

NextNode에 추가된 Kim에 대한 Value 값 조회를 시도하면 다음 과정을 수행하게 된다.

 

1. Hash Func % Size 연산을 통해 Index를 구한다.

2. Index 에 매핑된 노드가 존재하는지 확인한다.

3. 매핑된 노드가 존재하므로(충돌) 해당 노드의 Key, Hash 값과 요청으로 들어온 Key, Hash 값을 비교한다.

4. Kim과 Sim의 Key와 Hash 값이 다르므로 해당 노드의 nextNode가 있는지 존재한다.

5. nextNode가 존재하므로 해당 Node를 참조한다.

6. nextNode에 저장된 Key와 Hash 값이 들어온 Key와 Hash 값과 일치하므로 이 노드에 대한 Value 값을 리턴한다.

 


내부 구조를 이해한 후 다시 생각해본 HashMap의 장점 

 

1. 조회가 빠르다.

조회 시 Key 값에 해시 및 나머지 연산만 하면 Index를 구할 수 있고, Index 기반으로 접근하니 당연히 조회 속도가 빠를수밖에 없다.

 

2. 저장, 삭제도 ArrayList보다 빠르다.

저장은 Key에 대한 Index를 구한 후 값을 넣기만 하면 되고, 삭제도 Key에 대한 Index를 구하고 삭제하면 된다. ArrayList의 경우 순서를 유지해야 하기 때문에 중간에 값을 삭제할 경우 빈자리를 채우기 위한 이동 연산이, 등록할 경우 빈자리를 만들기 위한 이동 연산이 수행되어야하는데 말이다.

 


내부 구조를 이해한 후 다시 생각해본 HashMap의 단점

 

1. 너무 많은 저장이 일어날 경우 오히려 속도가 느려진다.

 저장이 많아지면 그만큼 리사이징과 재배치작업이 많아지기 때문이다. 만약 저장해야할 데이터가 많다면 HashMap의 사이즈를 너프하게 잡는것도 좋은 방법이다.

반응형
반응형

개요

 일반적으로 어플리케이션 내에서 사용되는 상수는 Enum을 통해 관리한다. Enum이 등장하기 전에는 어떤 방식으로 상수를 관리했고, Enum이 기존의 방식보다 어떤 장점을 갖고 있는지 알아보자.


Enum이 등장하기 전 상수 관리

public class Constant {

    public static final int PIZZA_S_DIAMETER = 14;
    public static final int PIZZA_M_DIAMETER = 16;
    public static final int PIZZA_L_DIAMETER = 18;
    
    public static final int TORTILLA_S_SIZE = 4;
    public static final int TORTILLA_M_SIZE = 6;
    public static final int TORTILLA_L_SIZE = 8;
}

 

위와 같이 상수만을 정의하는 클래스를 만들고 외부에서 바로 접근 가능하고, 변하지 않도록 public static final 키워드를 통해 정의한다. 이러한 구현 방식을 정수 열거 패턴이라고 한다.

 


정수 열거 패턴의 단점

1. 타입 안전을 보장할 방법이 없다.

 상수를 타입으로 구분하지 못하기 때문에 PIZZA, TORTILLA와 같은 접두어를 썼다. 하지만 이 값을 받는 메서드나 클래스에서는 타입으로 값을 받기 때문에 int 타입으로 받게 된다. 그 결과 PIZZA 관련 상수를 받아야할 메서드에 TORTILLA 관련 상수를 보내도 컴파일 에러가 발생하지 않지만 의도한 값을 받지 못했기 때문에 추후 로직에서 버그가 발생할 수 있다.

public class Pizza {

    private final int size;

    public Pizza(int size){
        this.size = size;
    }
}

 

Pizza firstPizza = new Pizza(Constant.PIZZA_S_DIAMETER); // s 사이즈 피자
Pizza secondPizza = new Pizza(30); // 30 사이즈 피자 >> 버그가 발생할 수도...
Pizza thirdPizza = new Pizza(Constant.TORTILLA_S_SIZE); // 또띠아 S 사이즈 피자 >> 버그가 발생할 수도...

 

 

2. 문자열로 출력했을 때 의미를 알 수 없다.

 상수 값을 출력하거나 이 값을 파라미터로 받은 메서드에서 이를 디버깅하면 값만 출력된다. 이 상태에서는 'S 사이즈 피자의 길이' 라는 의미는 알 수 없고 14라는 값만 알 수 있는 것이다. 14가 어떤 의미를 가지고 있는지도 함께 알려줄 수 있다면 디버깅 시 값의 의미를 파악하거나 출처를 알기 훨씬 쉬워질것이다.

 

3. 순회 방법이 까다롭다.

같은 열거그룹에 속한 모든 상수를 한 바퀴 순회하는 방법도 마땅지 않다. 예를들어 현재 존재하는 모든 피자 사이즈를 출력하고 싶다면 개발자가 일일이 하드코딩하거나 리플렉션을 사용해야 한다.

Class<Constant> constant = Constant.class;

for(Field field : constant.getFields()){
    if(field.getName().startsWith("PIZZA")){
        System.out.println(field.getName());
    }
}

 

 


 

 

Enum을 통한 상수 관리

 Enum 타입 자체는 클래스이며, 상수 하나당 인스턴스를 만들어 public static final 필드로 공개하는 방식이다. 아래의 예제는 내부적으로 각각의 size를 가진 S 인스턴스, M 인스턴스, L 인스턴스를 PizzaSize 타입으로 생성하고 이를 public static final로 공개하는 것이다.

 

public enum PizzaSize {
    S(14), M(16), L(18);
    
    private final int size;
    PizzaSize(int size){
        this.size = size;
    }
}

 

public enum TortillaSize {
    S(4), M(6), L(8);

    private final int size;

    TortillaSize(int size){
        this.size = size;
    }
}

 

 


열거 패턴의 단점을 극복한 Enum 타입

1. 컴파일 타임에서의 타입 안전성을 제공한다.

 이제 상수를 타입으로 구분할 수 있으므로 Pizza 클래스의 생성자 메서드에서 int 가 아닌 PizzaSize 타입으로 값을 받을 수 있다. 이로써 PizzaSize 타입이 아닌 다른 타입이 들어올 경우 컴파일 에러가 발생하게 된다. 컴파일 타임에서의 타입 안정성을 제공받게 되었다.

 

public class Pizza {

    private final PizzaSize size; // PizzaSize로 수정

    public Pizza(PizzaSize size){ // PizzaSize로 수정
        this.size = size;
    }
}

 

Pizza firstPizza = new Pizza(PizzaSize.S); // s 사이즈 피자
Pizza secondPizza = new Pizza(30); // 30 사이즈 피자 >> 컴파일 에러!!
Pizza thirdPizza = new Pizza(TortillaSize.S); // 또띠아 S 사이즈 피자 >> 컴파일 에러!!

 

2. 문자열로 출력했을 때 의미를 알 수 있다.

Enum에는 값과 이름이 존재하기 때문에 문자열로 출력했을 때 이 값의 의미를 알 수 있다. 또한 메서드 레벨에서 Enum 타입으로 값을 받기 때문에 디버깅 시 값에 대한 의미를 파악하기 쉽다. 그냥 14가 들어왔을 때 보다 PizzaSize.size = 14, PizzaSize.name = "S"로 들어온다면 피자 사이즈 S는 14라는 것과, 이 값이 PizzaSize 라는 Enum에서 관리된다는 출처도 알 수 있다.

디버깅 시 size에 대한 의미를 알 수 있다.

 

 

3. 순회 방법이 간단하다

 Enum 타입은 정의된 상수 값을 배열에 담아 반환하는 정적 메서드인 values를 제공한다. 정의된 상수를 모두 출력하고 싶다면 values 메서드를 사용하면 된다. 리플렉션을 사용했던 방식에 비하면 매우 간단함을 알 수 있다.

Arrays.stream(PizzaSize.values()).forEach(System.out::println);

 

 

 

4. 메서드나 멤버 필드를 추가할 수 있다.

 enum도 결국은 클래스이다. 멤버 필드나 메서드를 추가하여 피자 사이즈 관련 책임을 부여할 수 있다. 예를들어 피자 사이즈별로 사용되어야 하는 밀가루 양이 정해져있고 이 공식이 size * 30g 이라고 가정해보자. 이를 외부에서 구할 수도 있지만 size에 따라 밀가루 양이 결정되므로 size를 관리하는 PizzaSize 에게 책임을 위임해도 된다.

 

public enum PizzaSize {
    S(14), M(16), L(18);

    private final int size;

    private static final int SIZE_PER_FLOUR = 30; // 가중치
    
    PizzaSize(int size){
        this.size = size;
    }

    public int amountOfFlour(){
        return size * SIZE_PER_FLOUR;
    }
}

 

System.out.println(PizzaSize.S.amountOfFlour()); // 420
System.out.println(PizzaSize.M.amountOfFlour()); // 480
System.out.println(PizzaSize.L.amountOfFlour()); // 540

 

 

amountOfFlour 라는 메서드를 통해 모든 사이즈의 피자가 필요한 밀가루 양을 구하고 있다. 사이즈에 따라 밀가루 양을 구하는 로직이 다르지 않기 때문에 가능한 것이었다.

 

그럼 반대로 인스턴스마다 처리해야하는 로직이 다를 경우는 어떻게 처리해야 할까?

 


값에 따라 분기하는 Enum 타입

 

public enum Operation {
    PLUS, MINUS, TIMES, DIVIDE;
    
    public double apply(double x, double y){
        switch (this){
            case PLUS : return x+y;
            case MINUS : return x-y;
            case TIMES : return x*y;
            case DIVIDE: return x/y;
        }
        
        throw new AssertionError("알 수 없는 연산 : "+this);
    }
}

 

 위 코드는 동작하기는 하나 좋은 코드는 아니다. 새로운 상수를 추가하면 case 문도 추가해야 한다. 만약 이를 깜빡한다면 컴파일은 되지만 새로 추가한 연산을 수행하려 할 때 "알 수 없는 연산"이라는 런타임 에러가 발생한다.

 이를 구현하는 좋은 방법은 열거 타입에 apply 라는 추상 메서드를 선언하고 각 인스턴스에서 이를 재정의하는 방법이다.

 


추상화 메서드를 재정의하는 Enum 타입

public enum Operation {
    
    PLUS {public double apply(double x, double y){return x+y;}},
    MINUS {public double apply(double x, double y){return x+y;}},
    TIMES {public double apply(double x, double y){return x+y;}},
    DIVIDE {public double apply(double x, double y){return x+y;}};
    
    abstract double apply(double x, double y);
}

 

 이 경우 상수를 추가하게 되면 반드시 apply 메서드를 반드시 정의해야 한다. 정의하지 않을 경우 컴파일 에러가 발생한다. 또한 분기처리 로직이 사라져 코드가 훨씬 깔끔해졌다.


 

정리

 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는 한 컨테이너가 다룰 수 있는 타입의 수가 고정되어 있다. 하지만 타입 안정 이종 컨테이너 패턴을 사용하면 타입에 제약이 없는 컨테이너로 만들 수 있다.

 

 

 

반응형
반응형

 

매개변수화 타입은 유연할까?

매개변수화 타입은 불공변이다. 불공변은 계층적으로 설계된 타입을 아예 다른 타입으로 인식하는 성질이다. 일반적인 레퍼런스 타입의 경우 다형성을 활용하여 상위 타입 변수에 하위 타입 인스턴스가 할당될 수 있지만, 매개변수화 타입은 계층 관계가 있다 한들 그저 다른 타입으로 인식하기 때문에 할당이 불가능하다. 다형성을 활용할 수 없기 때문에 타입에 대해 유연하다고 할 수 없는 것이다.

List<Object> a = new ArrayList<String>(); // 불공변. 컴파일 에러
Object b = "hi";

List<Animal> c = new ArrayList<Cat>(); // 불공변. 컴파일 에러
Animal d = new Cat();

 

 


매개변수화 타입도 유연해질수 있다

매개변수화 타입도 여러 타입을 받을 수 있도록 유연해지는 방법이 있다. 바로 한정적 와일드카드를 사용하는 것이다. 먼저 기본적인 매개변수화 타입을 사용하는 스택 클래스를 보고 타입 유연성에 대한 문제점을 인지해보자.

 


스택 클래스의 문제점

아래는 필자가 간단하게 구현한 스택 클래스이다. Stack 인스턴스 생성 시 E 라는 타입 매개변수를 받고 있으며, 이때 받은 타입 매개변수가 여러 메서드에서 사용되고 있다. 이 스택 클래스의 첫번째 문제점은 pushAll에서 찾을 수 있다. 

public class Stack<E> {

    private final List<E> elementList = new ArrayList<>();
    private int size = 0;
    
    public void push(E element){
        elementList.add(element);
        size++;
    }

    public void pushAll(List<E> anotherList){
        elementList.addAll(anotherList);
        size += anotherList.size();
    }

    public void flush(List<E> anotherList){
        while(!isEmpty()){
            anotherList.add(pop());
        }
    }

    private E pop(){
        E element = elementList.get(--size);
        elementList.remove(size);
        return element;
    }

    private boolean isEmpty(){
        return elementList.isEmpty();
    }

    public void print(){
        elementList.forEach(System.out::println);
    }
}

 


Number 타입 엘리멘트 저장용 스택

Number 타입 엘리멘트를 저장하기 위해 Number 타입의 스택을 생성하였다. Number 클래스는 Double, Integer와 같은 숫자 타입 클래스의 상위 클래스이며, push 메서드를 통해 값을 넣을 수 있다. push 메서드는 그저 제네릭 타입의 매개변수를 받고있기 때문이다. 여기서는 Double 타입으로 오토박싱될 1.1 값을 통해 push 메서드를 호출하고 있다. 

Stack<Number> stack = new Stack<>();
stack.push(1.1);

 

 

문제는 매개변수화 타입을 받는 pushAll에서 발생한다. Double 타입의 리스트를 받지 못하는 것이다.

컴파일 타임에서의 pushAll 메서드 매개변수 타입은 List<Number> 인데 List<Double> 타입의 값을 매개변수로 사용하려 했기 때문이다. List<Number>와 List<Double>은 타입이 다르므로 컴파일 에러가 발생한다.

List<Double> list = new ArrayList<>();
list.add(2.2);
list.add(3.3);

stack.pushAll(list); // 컴파일 에러 발생

 


한정적 와일드카드를 통해 유연성을 높여보자.

한정적 와일드 카드를 사용하여 Number 타입과 같거나 서브 타입에 대한 타입 매개변수를 갖는 리스트로 변경하였다. List<Double> 타입의 Double은 Number의 하위타입이므로 컴파일 에러가 발생하지 않게 된다. 

public void pushAll(List<? extends E> anotherList){
    elementList.addAll(anotherList);
    size += anotherList.size();
}

 

 

여기서 끝이 아니다 flush 메서드도 비슷한 문제가 있다. 아래 테스트 케이스를 보자.

Integer 타입의 스택 인스턴스를 생성하고, Integer 리스트로 스택의 데이터를 모두 추출하여 전달하고 있다. 여기서의 문제도 마찬가지로 유연성이다. Stack을 Integer 타입으로 생성하면 flush 시 List<Integer> 타입의 매개변수밖에 받지 못한다.

Stack<Integer> stack = new Stack<>();
stack.push(1);

List<Integer> numberList = new ArrayList<>();
stack.flush(numberList);

 

 

 Integer 상위 타입인 Number 리스트로 받을 수 있을 것이라 생각했지만 컴파일 에러가 발생해버린다.

List<Number> numberList = new ArrayList<>();
stack.flush(numberList); // 타입 불일치 컴파일에러 발생

 

 

이것도 마찬가지로 한정적 와일드카드를 사용하여 해결 가능하다. pushAll과의 차이는 extends가 아닌 super를 사용하는 것이다. <? super E> 는 E 클래스의 상위 타입을 의미한다. 즉, Number는 Integer의 상위타입이므로 컴파일 에러가 발생하지 않게 된다.

public void flush(List<? super E> anotherList){
    while(!isEmpty()){
        anotherList.add(pop());
    }
}

 

 

이로써 수정된 Stack 클래스는 다형성을 지원하는 것처럼 유연한 클래스로 변경되었다.


팩스(PECS)

 PECS는 Producer-Extends, Consumer-Super의 약자로, 들어온 매개변수화 타입이 생산자라면 extends를 사용하고 소비자라면 super를 사용하라는 공식이다.

 pushAll 메서드는 들어오는 매개변수화 타입 인스턴스를 엘리멘트에 추가하기 위해 외부에서 생성해 들어온 값이다. 즉 생산자이므로 List<? extends E>를 사용하고, flush 메서드는 들어오는 매개변수화 타입 인스턴스에 엘리멘트를 소비한다. 즉, 소비자이므로 List<? super E> 를 사용한다.

 


 

정리

 와일드카드 타입을 사용하면 API가 유연해진다. 생산자와 소비자를 잘 구분하여 적절한 한정적 와일드카드를 사용하자. 공식을 활용하는 것도 좋지만 왜 이런 공식이 나왔는지를 이해하고 사용하는 것이 중요하다고 생각한다.

반응형

+ Recent posts