Java NIO 패키지

이전에 올렸던 java.io 패키지에 대해서 공부하고 포스팅했지만, Java 4부터 등장한 java.nio에 대해서도 궁금하여 포스팅하였습니다.

1. IO와 NIO의 차이

NIO는 의미만 봤을 때 Non-blocking IO의 줄임말이라고 생각했지만, 사실 New IO의 줄임말이였습니다.

java.io 패키지랑 무슨 차이가 있는지 javadoc에서 찾아보니 파일에 데이터를 읽고 쓰는 통로 역할을 하는 채널은 버퍼라는 곳에 항상 데이터를 read하거나 write 하도록 되어있다고 나와있습니다.

IO 같은 경우에는 Stream을 통해서 파일로부터 데이터를 읽거나 쓰도록 되어 있고, NIO는 Channel을 통해서 무조건 버퍼에 데이터를 읽거나 씁니다.

Stream

  • 파일을 읽기 위한 InputStream, 파일을 쓰기 위한 OutputStream 객체가 별도로 존재하고, 단방향으로만 데이터가 흐릅니다.

Channel

  • 양방향으로 데이터가 흐를 수 있고, ByteChannel, FileChannel을 만들어서 읽고 쓰는게 가능합니다.

  • io와 다르게 Non-Blocking 방식도 가능합니다. 하지만 언제나 Non-blocking 방식으로 동작하는 것이 아니라는것을 명심해야 합니다.

2. NIO Channel

Channel을 살펴보기전에 기본적으로 Buffer에 대한 개념을 알아야 합니다. 채널을 통한 파일 입출력은 무조건 버퍼를 사용해야 합니다. 기본적으로 nio 패키지에서 정적(static) 메서드를 이용하여 생성할 수 있습니다.

Channel을 통한 파일 읽기 예제

public class ChannelReadExam {

    public static void main(String[] args) {

        Path path = Paths.get("/Users/limjun-young/workspace/privacy/dev/test/video/video/temp.txt");
        // 채널 객체를 파일 읽기 모드로 생성합니다.
        try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
            // 1024 바이트 크기를 가진 Buffer 객체 생성
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            ch.read(buffer);

            buffer.flip();
            Charset charset = Charset.defaultCharset();
            String inputData = charset.decode(buffer).toString();
            System.out.println("inputData: " + inputData);

            buffer.clear();

        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("파일 작업 실패");
        }
    }
}

Channel을 통한 파일 wirte 예제 코드

public class ChannelWriteExam {

   public static void main(String[] args) {

        Path path = Paths.get("/Users/limjun-young/workspace/privacy/dev/test/video/video/output.txt");

        try (FileChannel ch = FileChannel.open(path, 
        StandardOpenOption.WRITE, 
        StandardOpenOption.CREATE)) {

            String data = "NIO Channel을 이용해서 파일에 데이터를 써보겠습니다.";
            Charset charset = Charset.defaultCharset();
            ByteBuffer buffer = charset.encode(data);
            ch.write(buffer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

실행 결과

image

Channel 객체 생성

  • 채널(Channel) 생성 옵션을 가진 기본 라이브러리 Enum 클래스
  • open() 메서드를 이용한 채널 인스턴스 생성 시 옵션은 여러 개 중복으로 넣어줄 수 있습니다.
옵션 설명
READ 읽기용으로 파일을 엽니다.
WRITE 쓰기용으로 파일을 엽니다.
CREATE 파일이 없으면 새 파일을 생성합니다.
CREATE_NEW 새 파일 생성합니다. (기존에 존재하면 예외 발생)
APPEND 추가 모드로 파일을 엽니다.(EOF 위치부터 시작, WRITE / CREATE와 같이 사용)
DELETE_ON_CLOSE 채널이 닫힐 때 파일을 삭제합니다.
TRUNCATE_EXISTING 파일을 열 때 파일 내용을 모두 삭제 합니다.(0 바이트로 만들고, WRITE와 같이 사용합니다.)

java.nio.Path / Files 클래스를 이용해 미리 파일 상태를 확인해서 Path 객체를 생성한 뒤 적절한 옵션을 사용하면 됩니다.

ByteBuffer 객체 생성

파일 I/O를 자주하면 allocate()를 크게 하나 만들어두고 계속 사용합니다.

ByteBuffer buffer = ByteBuffer.allocate(10);

기본적으로 아래와 같이 메모리에 버퍼가 생성되고, 파일의 데이터를 가르키는 파일 포인터처럼 버퍼도 버퍼 포인터가 존재합니다.

  • Capacity: 버퍼의 전체 크기
  • Position: 현재 버퍼를 쓰거나 읽을 위치, 파일 포인터의 개념과 같은 버퍼 포인터라고 보면 됩니다.
  • Limit: 전체 크기 중에 실제 읽고 쓸 수 있는 위치를 따로 지정한 것으로 기존에 Capacity와 동일하게 생성됩니다.

image

Charset 객체 생성

위의 과정을 통해 파일과 채널을 생성하고 읽고 쓸 수 있는 버퍼 생성까지 완료했습니다. 이제 파일을 I/O 준비가 되었습니다.

외부의 문자 데이터를 주고 받을 때는 서로 같은 인코딩 타입을 사용하지 않을 수 있습니다. Window 환경에서는 메모장은 ANSI 코드를 사용하고, Java는 charset으로 유니코드를 사용합니다. 따라서 한글처럼 2byte 이상으로 이루어진 문자를 유니코드를 출력해도 메모장에서는 해당 문자가 깨지게 됩니다.

이러한 문제를 해결하기 위해 인코딩 타입 간 변환을 위해 일단 Charset 클래스의 인스턴스를 하나 생성해야 합니다.

아래와 같이 2가지 방법으로 Charset 인스턴스 생성이 가능합니다.

Charset charset = Charset.defaultCharset();
Charset charset = Charset.forName("UTF-8");
  • defaultCharset(): OS의 인코딩 타입 간 변환을 해주는 객체 생성합니다.
  • forName("타입"): 직접 입력한 타입 간 변환을 해주는 객체를 생성합니다.

위 예제 코드에서는 파일 읽기 용으로 Channel을 생성하였기 때문에, Buffer에 파일 데이터를 읽어와서 Charset을 통해서 해당 인코딩 타입으로 다시 디코드하여 문자열을 출력하고 있습니다.

Charset 인코딩

encode() 데이터를 UTF-8로 인코딩 후 버퍼에 저장하고 있습니다.

String data = "NIO Channel을 이용해서 파일에 데이터를 써보겠습니다.";
Charset charset = Charset.defaultCharset();
ByteBuffer buffer = charset.encode(data);

Charset 디코딩

decode() 메서드는 버퍼에 저장된 바이너리 값을 UTF-8로 디코딩 후 문자열로 리턴하고 있습니다.

Charset charset = Charset.defaultCharset();
String inputData = charset.decode(buffer).toString();
System.out.println("inputData: " + inputData);

3. NIO에 대한 오해

NIO가 의미만 봤을 때 Non-Blocking 방식으로 동작할 것 같지만 생각만큼 Non-Blocking 하지 않다고 합니다.

예를 들어서 아래 java.nio.Files는 NIO 중에서 File I/O를 담당합니다. 파일을 읽는데 사용되는 Files.newBufferedReader(), Files.newInputStream()등은 모두 blocking 입니다. 마찬가지로 Files.newBufferedWriter(), Files.newOutputStream 등도 모두 blocking 입니다.

그렇다면 왜 사용할까 찾아보니 성능적인면에서 FILE I/O에 사용되는 Channel이 blocking 모드로 동작하지만 데이터를 Buffer를 통해 이동시키므로써 기존의 java.io 패키지에서 사용하는 Stream I/O에서 병목을 유발하는 몇가지 레이어를 건너뛸 수 있어서 성능상의 이점을 누릴 수 있다고 합니다.

4. Non-Blocking 방식으로 I/O 처리

Java 7부터 도입되어 NIO2라고 불리는 NIO에는 AsynchronousFileChannel이 Non-Blocking 모드로 동작합니다.

AsynchronousFileChannel 예제 코드

아래는 AsynchronousFileChannel 객체를 이용하여 Non-Blocking 처리하는 예제 코드를 작성해봤습니다. 여기서 try-with-resources를 사용할 경우 파일을 버퍼에 저장 후 CompletionHandler의 콜백 함수가 실행이 되는데 try-with-resources에서 자동으로 닫히기 때문에 비동기 방식으로 파일을 읽으려고 하는 순간 예외가 발생합니다. 그래서 try-with-resources 구문을 사용하지 않고 콜백 함수에서 close 하도록 처리하였습니다.

public class AsynchronousFileChannelExam {

    public static void main(String[] args) {

        Path path = Paths.get("/Users/limjun-young/workspace/privacy/dev/test/video/video/output.txt");

        try {

            AsynchronousFileChannel ch = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            long position = 0;

            ch.read(buffer, position, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                @Override
                public void completed(Integer result, ByteBuffer attachment) {
                    // 읽은 바이트 수를 리턴합니다.
                    System.out.println("result = " + result);

                    attachment.flip();
                    byte[] data = new byte[attachment.limit()];
                    attachment.get(data);
                    System.out.println(new String(data));
                    attachment.clear();

                    // AsynchronousFileChannel close 처리
                    if (ch != null || ch.isOpen()) {
                        try {
                            ch.close();
                        } catch (IOException e) {
                            e.printStackTrace();
                            throw new RuntimeException(e);
                        }
                    }
                }

                @Override
                public void failed(Throwable exc, ByteBuffer attachment) {
                    System.out.println("파일 읽기 실패");
                    exc.printStackTrace();
                }
            });
            System.out.println("Non-Blocking 중이니?");

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

참조 사이트: https://codevang.tistory.com/160

'SpringFramework > JAVA' 카테고리의 다른 글

비동기 프로그래밍  (0) 2020.04.11
프록시 패턴 예제  (0) 2020.02.15
객체지향 프로그래밍  (0) 2020.01.01
멀티 스레드의 개념  (0) 2019.12.30
스트림 메소드 2편  (0) 2019.12.05

비동기 프로그래밍

프로그래밍을 하면서 비동기(Async), 동기(Sync), 블로킹(Blocking), 논 블로킹(Non-Blocking) 방식의 프로그래밍이라는 언어를 많이 들어보게 됩니다. 저도 회사에서 비동기 방식으로 코드를 작성한적이 있었는데 아무래도 기능을 빠르게 만들다보니 제대로 공부를 하지 않고 작성한 경우가 대부분이라서 오늘은 비동기, 동기, 블로킹, 논 블로킹이 무엇인지 살펴보고 간단하게 예제 코드를 작성하였습니다.

제가 생각하는 동기, 비동기는 메서드를 제공해주는 객체 입장에서 보는 관점이라고 생각하고, 블로킹, 논 블로킹은 메서드를 호출하는 곳 즉 클라이언트 관점이라고 생각합니다. 물론 여러 블로그 포스팅을 보면 아직까지 정확한 정의가 개발자분들마다 다르다고 생각합니다.

블로킹(Blocking), 논 블로킹(Non-Blocking)

동기,비동기는 메서드를 제공하는 곳의 입장이라면, 블로킹, 논 블로킹은 메서드를 호출하는 곳, 즉 클라이언트에서의 입장입니다.

만약 데이터를 조회하는 메서드를 제공하는 객체를 클라이언트에서 호출한다고 하면 블로킹 방식으로 프로그래밍을 구현하면 데이터 조회 메서드를 호출하는 순간 클라이언트의 코드 흐름에 대한 제어권이 데이터를 조회하는 부분으로 넘어가게 됩니다. 그러면 클라이언트는 데이터 조회 결과를 받기전 까지 아무것도 할 수 없는 블로킹 상태에 빠지게 됩니다.

반대로, 논 블로킹 방식은 데이터 조회 메서드를 호출 후 제어권을 넘기지 않고 다른 작업을 수행할 수 있는 프로그래밍 방식이라고 생각하면 됩니다.

이제 동기, 비동기, 블로킹, 논 블로킹을 좀 더 이해하기 위해 간단한 예제 코드로 설명하겠습니다.

커피 이름으로 커피 가격을 조회하는 Repository 객체를 생성하였습니다. H2 DB를 이용해서 조회를 하려고 했지만 아무래도 가볍게 설명하기 위해서는 Map 객체를 사용하는게 좋다고 판단이 되었습니다.

커피 도메인 클래스를 만들어서 해당 인스턴스를 생성하여 Map 객체에서 관리하도록 하기 위해서 만들었습니다.

public class Coffee {

    private String name;
    private int price;

    @Builder
    public Coffee(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public static Coffee makeCoffee(String name, int price) {
        return Coffee.builder()
                .name(name)
                .price(price)
                .build();
    }

}
@Repository
public class CoffeeRepository {

    private Map<String, Coffee> coffeeMap = new HashMap<>();

    @PostConstruct
    public void init() {
        coffeeMap.put("latte", Coffee.makeCoffee("latte", 3500));
        coffeeMap.put("mocha", Coffee.makeCoffee("mocha", 4000));
        coffeeMap.put("americano", Coffee.makeCoffee("americano", 2000));
    }

    public int getPriceByName(String name) {

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        return coffeeMap.get(name).getPrice();
    }
}

위의 코드를 살펴보면, 스프링 부트 환경에서 작성하였습니다. 데이터를 제공해주는 Repository 클래스의 getPriceByName() 메서드를 정의하여 커피의 이름을 파라미터로 받아서 커피의 가격을 리턴해주는 메서드입니다.
단, 해당 메서드는 1초의 지연 시간을 주기로 하였습니다. 클라이언트는 커피의 가격을 조회하기 위해서 최소 1초가 걸릴 것입니다.

이제 이 Repository 객체를 의존하는 서비스 인터페이스를 아래와 같이 정의하였습니다.

public interface CoffeeUseService {
    int getPrice(String name); // 동기
    CompletableFuture<Integer> getPriceAsync(String name); // 비동기
    CompletableFuture<Integer> getDiscountPriceAsync(Integer price); // 비동기
}

getPrice()는 동기 메서드이고, Async가 붙은 나머지 메서드 두개는 비동기 방식의 메서드입니다. 기능을 제공하는 곳에서 동기, 비동기에 대한 개념을 포함하고 있습니다. 블로킹으로 할지, 논 블로킹으로 할지 선택은 기능을 제공하는 클래스에서 결정되는게 아니라, 해당 메서드를 호출 하는곳, 즉 클라이언트에서 선택할 것입니다.

동기(Sync) 방식

CoffeeUseService 인터페이스의 구현체를 작성하였습니다. 첫번째 메서드인 getPrice()는 동기 방식으로 데이터를 제공합니다. 즉, 클라이언트에서 제어권을 받아서 데이터 처리 연산이 완료되어야 반환하는 방식입니다.

@Slf4j
@RequiredArgsConstructor
@Service
public class CoffeeUseServiceImpl implements CoffeeUseService {

    private final CoffeeRepository coffeeRepository;
    Executor executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    @Override
    public int getPrice(String name) {
        log.info("동기 호출 방식으로 가격 조회 시작");

        return coffeeRepository.getPriceByName(name);
    }
}

테스트 코드를 작성하여 검증하였습니다. CoffeeTest라는 테스트 클래스를 생성하고, @SpringBootTest 어노테이션을 사용해서 테스트에 필요한 빈들을 주입하여 사용하도록 했습니다. 참고로 @SpringBootTest 어노테이션을 사용할 경우 스프링 컨테이너에 생성된 모든 Bean들을 가져오기 때문에 간단한 테스트를 할 경우에는 필요한 Bean들만 가져와서 사용하는 것을 권고드립니다. 저는 귀찮아서 @SpringBootTest 어노테이션을 사용했습니다.

@SpringBootTest
public class CoffeeTest {

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    CoffeeUseService coffeeUseService;

    @Test
    public void  가격_조회_동기_블로킹_호출_테스트() throws Exception {

        //given
        int expectedPrice = 2000;

        //when
        int resultPrice = coffeeUseService.getPrice("americano");
        logger.info("최종 가격 전달: [{}]", resultPrice);

        //then
        assertThat(resultPrice).isEqualTo(expectedPrice);

     }
}

실행 결과

image

테스트가 성공적으로 수행되었습니다. 1초라는 지연시간이 걸렸습니다. 만약 두 번 수행하면 동기호출이기 때문에 2초가 넘게 걸릴 것입니다.

비동기(Async) 메서드, 논 블로킹 + 블로킹 혼합

이번에는 Async(비동기) 메서드를 구현하겠습니다. 여기서는 CompleteFuture를 사용합니다. 이 객체는 비동기 방식의 메서드를 호출하여 리턴값을 받고자 할때 자주 사용하는 객체라고 생각하면 됩니다. 사실 저도 ComplteFuture 클래스를 최근 들어서 써봤기 때문에 Doc은 아래 블로그 포스팅에 잘나와있습니다.

참조: https://gunju-ko.github.io/java/2018/07/05/Future.html

비동기 메서드에서는 새로운 쓰레드를 생성해서 Repository를 통해서 데이터를 조회합니다. 최종 데이터 연산이 끝나지 않아도 일단 return future를 실행해서 먼저 껍데기만 반환하게 됩니다.

@Override
public CompletableFuture<Integer> getPriceAsync(String name) {

    log.info("비동기 호출 방식으로 가격 조회 시작");

    CompletableFuture<Integer> future = new CompletableFuture<>();

    new Thread(() -> {
        log.info("새로운 쓰레드로 작업 시작");
        Integer price = coffeeRepository.getPriceByName(name);
        future.complete(price);
    }).start();

    return future;
}

getPriceByName() 메서드에서 1초의 지연시간을 임의로 주었지만, 해당 데이터는 무작정 기다리지 않고, 다른 작업을 병행할 수 있습니다. 아래와 같이 테스트 코드를 통해 검증하였습니다.

@Test
public void 가격_조회_비동기_블록킹_호출_테스트() throws Exception {

    //given
    int expectedPrice = 3500;

    //when
    // 비동기 메소드 호출 후 껍데기 반환
    CompletableFuture<Integer> future = coffeeUseService.getPriceAsync("latte");
    logger.info("아직 최종 데이터를 전달 받지는 않았지만, 다른 작업 수행 가능");

    int resultPrice = future.join(); // 블로킹
    logger.info("최종 가격 전달 받음: [{}]", resultPrice);


    //then
    assertThat(resultPrice).isEqualTo(expectedPrice);
}

CompletableFuture로 리턴을 받았지만, 최종 데이터를 조회하기 전까지 다른 작업을 병핼할 수 있습니다. 즉, 제어권을 넘겨주지 않고 다른 작업을 할 수 있습니다.

실행 결과

image

하지만, 최종 데이터를 조회하기 위해서는 CompletableFuture의 join 또는 get 메서드를 사용해야 합니다. 일단 get과 join은 예외처리를 하는 방식이 조금 다릅니다. 이 정도 차이만 있고, join이나 get을 수행하는 시점에서는 데이터를 조회할 때까지 블로킹 됩니다. 데이터가 계산이 안되었다면 될때까지 기다렸다가 결과를 전달받습니다.

결국 동기든 비동기는 결과 값을 받기 위해서는 로직이 다 돌아야되기 때문이죠.

메서드를 제공하는 곳에서는 CompletableFuture를 반환하고, 메서드를 사용하는 곳, 즉 클라이언트에서는 논 블로킹과 블로킹이 혼합되어 있는 상황입니다. 어떻게 보면 완전한 논블로킹 프로그래밍은 아닙니다.

비동기(Async)를 더 깔끔하게 수정하기

getPriceAsync 메서드를 좀 더 깔끔하게 수정해보겠습니다. 참고로, 테스트 코드는 수정이 되지 않습니다. 즉 메서드를 리팩토링한 이후에도 테스트 코드는 수정없이 정상적으로 통과해야 합니다. CompletableFuture에서는 몇개의 유용한 팩토리 메서드를 제공하는데, 그 중에서 supplyAsync와 runAsync 메서드를 살펴보겠습니다. supplyhAsync 메서드는 supplier라는 함수적 인터페이스를 파라미터로 받습니다. 람다에 대해서 알고 있다면 쉽게 이해할 수 있는 내용입니다. 반면에 runAsync 메서드는 Runnable 함수적 인터페이스를 파라미터로 받고 있습니다.

전자인 supplyAsync는 파라미터는 없지만 리턴 값이 존재하고, Runnable은 파라미터, 리턴 모두 없는 함수적 인터페이스입니다. 먼저 supplyAsync 팩토리 메서드를 사용해서 아래와 같이 코드를 수정하였습니다.

@Override
public CompletableFuture<Integer> getPriceAsync(String name) {

    log.info("비동기 호출 방식으로 가격 조회 시작");

    return CompletableFuture.supplyAsync(() -> {
         log.info("Thread CurrentName: [{}]", Thread.currentThread().getName());
        log.info("supplyAsync");
        return coffeeRepository.getPriceByName(name);
    });
}

image

Thread CurrentName: [ForkJoinPool.commonPool-worker-9]

위와 같이 supplyAsync 메서드로 수행하는 로직은 ForkJoinPool의 commonPool을 사용하는 것을 확인할 수 있습니다. 사실, 일반적으로 commonPool을 사용하는 방법은 바람직하지 않습니다. 그래서, 좀 더 수정을 하였습니다. supplyAsync를 실행할 때 Executor를 파라미터로 추가하면, Common Pool에서 동작하지 않고 별도의 쓰레드 풀에서 동작할 것입니다. 함수를 제공하는 코드를 아래와 같이 수정하였습니다.

Executor executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

@Override
public CompletableFuture<Integer> getPriceAsync(String name) {

    log.info("비동기 호출 방식으로 가격 조회 시작");

    return CompletableFuture.supplyAsync(() -> {
        log.info("Thread CurrentName: [{}]", Thread.currentThread().getName());
        log.info("supplyAsync");
        return coffeeRepository.getPriceByName(name);
    }, executor);
}

테스트 코드를 수행하면, commonPool을 사용하지 않고, 별도로 정의한 쓰레드 풀을 사용합니다.

Thread CurrentName: [pool-1-thread-1]

콜백함수를 사용하여 Non-Blocking 프로그래밍 구현 에제를 살펴보겠습니다.

이제, 논 블로킹(Non-Blocking)을 위해서 코드를 더 수정하겠습니다. 비동기 메서드는 수정하지 않고, 클라이언트 코드를 수정해야 합니다.

블로킹, 논 블로킹은 메서드를 사용하는 곳, 즉 클라이언트에서의 입장입니다.

Non-Blocking 구현: thenAccept, thenApply

위 코드는, CompletableFuture의 get, join 메서드를 사용하는데, 해당 메서드를 호출하는 순간 블로킹 현상이 발생합니다. 논 블로킹으로 개선하기 위해서는 콜백 함수를 구현해야 하는데, CompletableFuture는 thenAccept()와 thenApply() 메서드를 제공합니다. thenAccept() 메서드는 CompletableFuture를 반환합니다. 즉 결과를 반환하지 않습니다. 하지만 thenApply() 메서드는 CompletableFuture 즉, 데이터를 포함하는 Future를 반환합니다.

 public CompletableFuture<Void> thenAccept(Consumer<? super T> action) {
    return uniAcceptStage(null, action);
}

public <U> CompletableFuture<U> thenApply(
    Function<? super T,? extends U> fn) {
    return uniApplyStage(null, fn);
}

먼저, thenAccept() 메서드를 사용해서 테스트 코드를 작성해보겠습니다. 일단 getPriceAsync()는 CompletableFuture를 반환하는데, 이때 thenAccept() 메서드를 정의하면 콜백함수를 선언할 수 있습니다. CompletableFuture가 complete가 되면, 즉 커피의 가격 조회가 완료되면 thenAccept를 수행하게 될 것 입니다.

@Test
public void 가격_조회_비동기_호출_콜백_테스트() throws Exception {

    //given
    int expectedPrice = 4000;

    //when
    CompletableFuture<Void> future = coffeeUseService
        .getPriceAsync("mocha")
        .thenAccept(p -> {
        logger.info("콜백, 가격은: [{}]", p + "원, 하지만 데이터를 반환하지 않습니다." );
        assertThat(p).isEqualTo(expectedPrice);
                  });
    //then
    logger.info("아직 최종 데이터를 전달 받지는 않았지만, 다른 작업 수행 가능, 논 블로킹");

    assertThat(future.join()).isEqualTo(expectedPrice);
}

위에서 작성했던 테스트 코드처럼 get이나 join 메서드를 사용해서 최종 연산이 된 데이터를 조회할 필요가 없습니다. CompletableFuture 객체에서 알아서 최종 연산이 되면 콜백 함수를 실행해주기 때문입니다. 단, 해당 코드는 테스트 코드이기 때문에 제일 하단에 future.join() 메서드를 실행해서 블로킹 코드를 추가하였습니다. 실제 서비스 코드에서 해당 코드는 필요없지만, 테스트 코드이기 때문에 추가하였는데, 해당 코드가 없다면 thenAccept() 메소드가 수행하기 전에 테스트는 통과해버릴 것입니다. 그 이유는 테스트 코드는 Main 쓰레드에서 동작하게 되고, thenAccept 콜백 메서드가 수행하기도 전에 Main 쓰레드는 종료되기 때문입니다. Non-Blocking 코드이기 때문에 결과가 오는 것을 기다리지 않고 게속 코드가 동작이 되는데, 테스트 코드 특성상 Main 쓰레드가 종료되기 때문에, Main 쓰레드를 종료시키지 않기 위해서 임의로 작성한 코드입니다.

thenAccept는 CompletableFuture를 반환합니다. 즉, 연산된 데이터를 반환하지 않기 때문에 해당 로직이 끝나면 데이터를 조회할 수 없습니다. 만약, 데이터를 반환하기 위해서는 어떻게 구현하면 될까요? 이때는 thenApply 메소드를 사용해야 합니다. 커피의 가격을 조회한 다음에 100원을 추가하고 싶으면 아래와 같이 코드를 작성합니다.

@Test
public void 가격_조회_비동기_호출_콜백_테스트() throws Exception {

    //given
    int expectedPrice = 4000;

    //when
    CompletableFuture<Void> future = coffeeUseService
        .getPriceAsync("mocha")
        .thenApply(p -> {
            logger.info("같은 쓰레드로 동작");
            return p + 100;
        })
        .thenAccept(p -> {
        logger.info("콜백, 가격은: [{}]", p + "원, 하지만 데이터를 반환하지 않습니다." );
        assertThat(p).isEqualTo(expectedPrice);
                  });
    //then
    logger.info("아직 최종 데이터를 전달 받지는 않았지만, 다른 작업 수행 가능, 논 블로킹");

    assertThat(future.join()).isEqualTo(expectedPrice);
}

실행 결과

image

참고로, thenApply와 thenAccept 메서드를 별도의 쓰레드로 동작하고 싶다면, thenApplyAsync와 thenAcceptAsync 메서드를 사용하면 됩니다.

thenCombine 메서드

thenCombine() 메서드는 CompletableFuture를 2개 실행해서 결과를 조합할 때 사용합니다.thenCombine()는 병렬 실행을 해서 조합하는데, 순차적으로 실행하지 않습니다. 커피의 가격을 조회하는 기능은 1초의 지연시간이 있습니다. 만약 순차적으로 조회하면 1+1이 되기 때문에 총 2초가 걸릴 것입니다. 그래서 동시에 두가지 조회를 같이 수행한 다음에 결과를 조합할 것이고, 그러면 2개를 조회하는데 1초가 걸리도록 프로그램을 작성할 것입니다. 이것이 바로 병렬 프로그래밍입니다.

@Test
public void thenCombine_테스트() throws Exception {

    //given
    Integer expectedPrice = 7500;

    //when
    CompletableFuture<Integer> futureA = coffeeUseService.getPriceAsync("latte");
    CompletableFuture<Integer> futureB = coffeeUseService.getPriceAsync("mocha");

    Integer resultPrice = futureA.thenCombine(futureB,Integer::sum).join();

    //then
    assertThat(resultPrice).isEqualTo(expectedPrice);
}

실행 결과

image

커피 이름 중, 라떼와 모카를 조회하는데 총 1초 정도 걸렸습니다. 즉 2초가 걸리지 않았습니다. 두 작업은 별도의 쓰레드 풀에서 동작하고, thenCombine 메서드에 의해서 조합이 됩니다. 여기서 쓰레드 풀의 쓰레드 개수를 1로 주게되면 쓰레드가 한개이기 때문에 병렬로 수행하지 못하고 하나의 쓰레드를 사용합니다. 따라서 1초가 아니고 2초가 걸리게 됩니다.

thenCompose 메소드

thenCompose() 메서드는 바로 위에서 설명한 thenCombine와는 다르게 CompletableFuture를 순차적으로 실행합니다. 가격을 조회하는 기능이 있고, 조회된 가격에서 할인을 하는 기능을 별도로 조회하는 기능을 구현해보겠습니다.
https://github.com/HomoEfficio/dev-tips/blob/master/Java-Spring%20Thread%20Programming%20%EA%B0%84%EB%8B%A8%20%EC%A0%95%EB%A6%AC.md

@Override
public CompletableFuture<Integer> getDiscountPriceAsync(Integer price) {
    return CompletableFuture.supplyAsync(() -> {
            log.info("supplyAsync");
            return (int)(price * 0.9);
    }, threadPoolTaskExecutor);
}
  1. 가격 조회
  2. 조회된 가격에 할인율 적용이라는 기능을 순차적으로 수행해야합니다.
  @Test
public void thenCompose_테스트() throws Exception {

    //given
    Integer expectedPrice = (int)(3500 * 0.9);

    //when
    CompletableFuture<Integer> futureA = coffeeUseService.getPriceAsync("latte");
    Integer resultPrice = futureA.thenCompose(result ->
        coffeeUseService.getDiscountPriceAsync(result)).join();

    //then
    assertThat(resultPrice).isEqualTo(expectedPrice);
}

실행 결과

image

참조: https://brunch.co.kr/@springboot/267

'SpringFramework > JAVA' 카테고리의 다른 글

Java NIO 채널(Channel)  (0) 2021.05.11
프록시 패턴 예제  (0) 2020.02.15
객체지향 프로그래밍  (0) 2020.01.01
멀티 스레드의 개념  (0) 2019.12.30
스트림 메소드 2편  (0) 2019.12.05

프록시 예제

프록시(Proxy)라는 뜻으로 자바에서 정말 많이 나오는 개념입니다. 물론 이전에도 Spring AOP 개념에 대해서 살펴볼때 설명하였지만, 그때는 대충 개념에 대해서만 알았지 내부적으로 어떤식으로 동작하는지는 이해할 수 없었습니다.

스프링 AOP에서 공통의 기능을 작성할 때 프록시를 이용해서 구현을 하고, 주로 클라이언트에서 타겟의 메소드를 호출할 때 선처리를 하느냐... 후처리를 하느냐에 중점을 두었습니다. 이번에 포스팅 할 내용에서는 간단하게 타겟의 메소드를 호출할 때 프록시를 이용해서 기존의 타겟의 기능에 추가적으로 살을 덧붙이는? 예제 코드를 작성해보았습니다.

일단 프록시 예제 코드는 세 부분의 클래스로 나누어져 있습니다. 첫 번째는 프록시를 관리하는 클래스인 ProxyField 객체, 두 번째는 프록시가 구현하고 있는 인터페이스, 세 번째로는 클라이언트에서 타겟의 메소드를 호출할 때 프록시에서 내부적으로 호출하는 InvocationHandler의 구현체 입니다.

가장 먼저, 타겟이 구현하고 있는 인터페이스를 작성하였습니다. 이 인터페이스는 간단하게 name이라는 String 타입의 파라미터를 던져주면 "Hello" + name이라는 문자열을 리턴하는 메소드를 가지고 있습니다. 하나만 있으면 재미없으니 "Hi" + name이라는 문자열도 리턴하는 메소드도 선언하였습니다. 여기서 정말 말도 안돼는 억지이지만... 문자열에 추가적으로 + 씨라고 살을 덧 붙여주도록 프록시 객체를 호출하는 코드를 작성하였습니다.

타겟이 구현하는 인터페이스

public interface Hello {

    public String sayHello(String name);
    public String sayHi(String name);

}

클라이언트가 호출하는 실제 타겟

public class HelloTarget implements Hello {

    @Override
    public String sayHello(String name) {
        return "hello!" + name;
    }

    @Override
    public String sayHi(String name) {
        return "hi!" + name;
    }
}

이제 가장 중요한 프록시 객체를 가지고 있는 ProxyField 클래스를 작성하였습니다.

import java.lang.reflect.Proxy;

public class ProxyField {

    private Hello proxyHello = (Hello) Proxy.newProxyInstance(
            getClass().getClassLoader(),
            new Class[] { Hello.class },
            new TargetHandler(new HelloTarget())
    );

    public Hello getProxyHello(){
        return proxyHello;
    }

}

여기서 proxyHello는 Hello라는 타겟의 인터페이스를 구현하는 Proxy입니다. 자바에서는 객체에 대한 정확한 정보를 자세히 알지 못하여도 해당 객체의 메소드나 필드에 대한 정확한 정보를 제공하여 우회적으로 객체를 핸들링 할 수 있도록 reflection이라는 API를 제공하고 있습니다.

프록시를 생성할 때 reflect 패키지에서 제공하는 Proxy 클래스의 newProxyInstance() 메소드를 호출하여 프록시를 생성합니다. 이 때 파라미터로 3개를 메소드에 넘겨주는데 첫 번째 인자로는 ProxyField 클래스를 로드하는 클래스 로더 객체를 넘겨줍니다.
클래스 로더는 클래스 패스에 존재하는 클래스들을 JVM이 관리하는 RuntimeDataArea 중 하나인 class 영역에 클래스 파일을 적재하는 역할을 합니다. 즉 프록시 클래스를 로드 할 클래스 로더라고 생각하시면 됩니다. 두 번째로는 프록시가 구현할 인터페이스 대상인데 여기서 Class 배열로 [ 인터페이스.class ]로 넘겨주면 됩니다. 마지막으로 실제 클라이언트에서 타겟을 호출할 때 프록시 내부적으로 호출되는 InvocationHandler의 구현체인 TargetHandler를 넘겨주는데 이 TargetHandler 객체는 HelloTarget(실제 호출 대상)을 간접적으로 호출하면서 추가적으로 살을 덧 붙여 주는 역할을 하는 객체라고 생각하시면 됩니다.

newProxyInstance() 메소드 파라미터 목록

  • 클래스 로더 (프록시 클래스를 로드하는 클래스 로더 객체)
  • 구현할 인터페이스 (Class[])
  • InvocationHandler 구현체

InvocationHandler 구현체

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class TargetHandler implements InvocationHandler {

    // 실제 클라이언트에서 호출하는 타겟 객체
    private Hello target;

    public TargetHandler(Hello target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        // 프록시 객체를 통해 들어온 메소드 정보를 넘겨줍니다.
        if(method.getName().equals("sayHello")){
            String result = (String) method.invoke(target, args);
            // 타겟이 리턴한 결과 값에 씨를 추가하여 리턴하도록 하였습니다.
            return result + "씨";
        }


        if(method.getName().equals("sayHi")){
            String result = (String) method.invoke(target, args);
            return result + "씨";
        }

        // 위의 두 메소드가 아닐 경우 리턴 값 정의
        return "Hello! 이름없는 사나이씨";
    }

}

위에서 프록시를 생성할 때 세 번째 파라미터로 InvocationHandler의 구현체를 넘겨주었는데 이때 생성자로 타겟을 받습니다. 이 타겟을 받는 이유는 InvocationHandler의 역할은 프록시 객체가 클라이언트로부터 타겟 호출에 대한 요청을 가로채어(intercept) 간접적으로 타겟을 호출하게 되는데 이 역할을 InvocationHandler 구현체가 수행하게 됩니다. 여기서 추가적으로 타겟이 리턴한 결과 값을 목적에 맞게 살을 덧 붙일 수도 있는 것입니다.

 @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

    // 타겟 호출 결과 후 추가 작업 수행
}

이 메소드에서 받는 첫 번째 파라미터는 프록시 자기 자신을 매개 변수로 넘겨줍니다. 두 번째 매개변수 method는 프록시 객체를 통해 들어온 메소드 정보를 넘겨주며, 마지막 매개변수인 args는 메소드를 호출하는데 필요한 매개변수들을 의미합니다. 여기서는 생성자로 받은 인스턴스 맴버 변수인 target 변수를 invoke() 메소드의 파라미터로 넣어주어서 실제 타겟의 메소드를 호출하도록 구현했습니다.

메소드는 문자열을 기준으로 어떤 메소드를 호출하느냐에 따라서 분기처리가 가능합니다.

참고로 스프링에서 AOP에 대해서 공부하신분들은 알시겠지만 Proxy를 생성하는 방식에는 대표적으로 두 가지 방식이 있는데, 이전 포스팅에서 설명했었지만 인터페이스를 구현하여 생성하는 JDK Dynamic Proxy와 클래스를 상속하여 생성하는 CGLIB Proxy 방식이 존재합니다. 스프링에서는 기본적으로 CGLIB 방식으로 프록시를 생성합니다.

테스트 수행 결과

image

참고: https://meaownworld.tistory.com/923

'SpringFramework > JAVA' 카테고리의 다른 글

Java NIO 채널(Channel)  (0) 2021.05.11
비동기 프로그래밍  (0) 2020.04.11
객체지향 프로그래밍  (0) 2020.01.01
멀티 스레드의 개념  (0) 2019.12.30
스트림 메소드 2편  (0) 2019.12.05

객체지향 프로그래밍

객체지향 프로그래밍을 향해

객체지향은 객체를 지향하는 것입니다. 객체를 지향한다는 말이 무슨 의미일까요? C++, 자바, 루비, C#과 같이 클래스 기반의 객체지향 언어에 익숙한 사람이라면 가장 먼저 어떤 클래스가 필요한지 고민부터 합니다. 대부분의 사람들은 클래스를 결정한 후에 클래스에 어떤 속성과 어떤 메서드가 필요한지 고민합니다.

안타깝게도 이것은 객체지향의 본질과는 거리가 멉니다. 객체지향은 말 그대로 객체를 지향하는 것입니다.진정한 객체지향의 패러다임으로의 전환은 클래스가 아니라 객체에 초점을 맞출 때에만 얻을 수 있습니다. 이를 위해서는 프로그래밍하는 동안 다음의 두 가지에 집중해야 합니다.

첫째, 어떤 클래스들이 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민해야 합니다. 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화 한것입니다. 따라서 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야합니다. 객체를 중심에 두는 접근 방식은 설계를 단순하고 깔끔하게 만듭니다.

둘째, 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 합니다. 객체는 홀로 존재하는 것이 아닙니다. 다른 객체에게 도움을 주거나 의존하면서 살아가는 협력적인 존재입니다. 객체를 협력하는 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 만듭니다. 객체 지향적으로 생각하고 싶다면 객체를 고립된 존재로 바라보지 말고 협력에 참여하는 협력자로 바라보기 바랍니다. 객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현합니다. 훌륭한 협력이 훌륭한 객체를 낳고 훌륭한 객체가 훌륭한 클래스를 낳습니다.

도메인 구조를 따르는 프로그램의 구조

이 시점에서 도메인(domain)이라는 용어를 살펴보는것이 도움이 될 것입니다. 소프트웨어는 사용자가 원하는 어떠한 문제를 해결하기 위해 만들어졌습니다. 영화 예매 시스템의 목적은 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것입니다. 이 처럼 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야를 도메인이라고 부릅니다.

객체지향 패러다임이 강력한 이유는 요구사항을 분석하는 초기 단계부터 프로그램을 구현하는 마지막 단계까지 객체라는 동일한 추상화 기법을 사용할 수 있기 때문입니다. 요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있기 때문에 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결될 수 있습니다.

영화 예매 도메인을 구성하는 타입들의 구조

Untitled Diagram

위의 그림은 영화 예매 도메인을 구성하는 개념과 관계를 표현한 것입니다. 영화는 여러 번 상영될 수 있고 상영은 여러번 예매될 수 있다는 것을 알 수 있습니다. 영화에는 할인 정책을 할당하지 않거나 할당하더라도 오직 하나만 할당할 수 있고 할인 정책이 존재하는 경우에는 하나 이상의 할인 조건이 반드시 존재한다는 것을 알 수 있습니다.
할인 정책의 종류로는 금액 할인 정책과 비율 할인 정책이 있고, 할인 조건의 종류로는 순번 조건과 기간 조건이 있다는 사실 역시 확인할 수 있습니다.

자바나 C#과 같은 클래스 기반의 객체지향 언어에 익숙하다면 도메인 개념들을 구현하기 위해 클래스를 사용한다는 사실이 낮설지는 않을 것입니다. 일반적으로 클래스의 이름은 대응되는 도메인 개념의 이름과 동일하거나 적어도 유사하게 지어야 합니다. 클래스 사이의 관계도 최대한 도메인 개념 사이에 맺어진 관계와 유사하게 만들어서 프로그램의 구조를 이해하고 예상하기 쉽게 만들어야 합니다.

이 원칙에 따라 영화라는 개념은 Movie 클래스로, 상영이라는 개념은 Screening 클래스로 구현합니다. 할인 정책은 DiscountPolicy, 금액 할인 정책은 AmountDiscountPolicy, 비율 할인 정책은 PercentDiscountPolicy 클래스로 구현하고, 할인 조건은 DiscountCondition, 순번 조건은 SequenceCondition, 기간 조건은 PeriodCondition 클래스로 구현합니다. 예매라는 개념은 Reservation이라고 이름 지어진 클래스로 구현합니다. 도메인 개념과 관계를 반영하도록 프로그램을 구조화해야 하기 때문에 아래 그림과 같이 클래스의 구조는 도메인의 구조와 유사한 형태를 띄어야 합니다.

Untitled Diagram

클래스 구현하기

도메인 개념들의 구조를 반영하는 적절한 클래스 구조를 만들었다고 가정합시다. 이제 남은 일은 적절한 프로그래밍 언어를 이용해 이 구조를 구현하는 것입니다. 여기서는 설명을 위해 설계 과정을 생략하고 최종 코드의 모습과 객체지향 프로그래밍과 관련된 중요한 개념을 살펴보기로 합시다.

Screening 클래스는 사용자들이 예매하는 대상인 상영을 구현합니다. Screening은 상영할 영화(movie), 순번(sequence),상영 시작 시간(whenScreened)을 인스턴스 변수로 포함합니다. Screening은 상영 시작 시간을 반환하는 getStartTime 메서드, 순번의 일치 여부를 검사하는 isSequence 메서드, 기본 요금을 반환하는 getMovieFee 메서드를 포함합니다.

import java.time.LocalDateTime;

public class Screening {

    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;

    public LocalDateTime getStartTime(){
        return whenScreened;
    }

    public boolean isSequence(int sequence){
        return  this.sequence == sequence;
    }

    public Money getMovieFee(){
        return movie.getFee();
    }
}

여기서 주목할 점은 인스턴스 변수의 가시성은 private이고 메서드의 가시성은 public 입니다. 클래스를 구현하거나 다른 개발자에 의해 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것입니다. 클래스는 내부와 외부로 구분되며 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출지를 결정하는 것입니다. Screening에서 알 수 있는 것처럼 외부에서는 객체의 속성에 직접 접근할수 없도록 막고 적절한 public 메서드를 통해서만 내부 상태를 변경할 수 있게 해야합니다.

클래스의 내부와 외부를 구분하는 이유는 경계의 명확성이 객체의 자율성을 보장하기 때문입니다. 그리고 더 중요한 이유로 프로그래머에게 구현의 자유를 제공하기 때문입니다.

자율적인 객체

먼저 두가지 중요한 사실을 알아야 합니다. 첫 번째 사실은 객체가 상태와 행동을 함께 가지는 복합적인 존재라는 것입니다. 두 번째 사실은 객체가 스스로 판단하고 행동하는 자율적인 존재라는 것입니다. 두 가지 사실은 서로 깊이 연관돼 있습니다.

객체지향 이전의 패러다임에서는 데이터와 기능이라는 독립적인 존재를 서로 엮어 프로그램을 구성했습니다. 이와 달리 객체지향은 객체라는 단위 안에 데이터 와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현할 수 있게 합니다. 이처럼 데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화라고 부릅니다.

또한 캡슐화하는 것에서 한 걸음 더 나아가 외부에서 접근을 통제할 수 있는 접근 제어 메커니즘도 함께 제공합니다. 많은 프로그래밍 언어들은 접근 제어(access control)를 위해 public, private, protected과 같은 접근 수정자(access modifier)를 제공합니다.

객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서 입니다. 객체지향의 핵심은 스스로 상태를 관리하고, 판단하고, 행동하는 자율적인 객체들의 공동체를 구성하는 것입니다. 자율적인 존재로 우뚝 서기 위해서는 외부의 간섭을 최소화 해야합니다. 외부에서는 객체가 어떤 상태에 놓여 있는지, 어떤 생각을 하고 있는지 알아서는 안되며, 결정에 직접적으로 개입하려고 해서도 안됩니다. 객체에게 원하는 것을 요청하고 객체가 스스로 최선의 방법을 결정할 수 있을 것이라는 점을 믿고 기다려야 합니다.

캡슐화와 접근제어는 객체를 두 부분으로 나눕니다. 하나는 외부에서 접근 가능한 부분으로 이를 퍼블릭 인터페이스(public interface)라고 부르고, 다른 하나는 외부에서 접근 불가능하고 오직 내부에서만 접근 가능한 부분으로 이를 구현(implementation)이라고 부릅니다. 인터페이스와 구현의 분리 원칙은 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙입니다.

일반적으로 객체의 상태를 숨기고 행동만 외부에 공개해야 합니다. 어떤 메서드들이 서브 클래스나 내부에서만 접근 가능해야 한다면 가시성을 protected나 private으로 지정해야 합니다. 이때 퍼블릭 인터페이스에는 public으로 지정된 메서드만 포함합니다. 그 밖의 private 메서드나 protected 메서드, 속성은 구현에 포함됩니다.

프로그래머의 자유

프로그래머의 역할을 클래스 작성자와 클라이언트 프로그래머로 구분하는 것이 유용합니다. 클래스 작성자는 새로운 데이터 타입을 프로그램에 추가하고, 클라이언트 프로그래머는 클래스 작성자가 추가한 데이터 타입을 사용합니다.

클라이언트 프로그래머의 목표는 필요한 클래스들을 엮어서 애플리케이션을 빠르고 안정적으로 구축하는 것입니다. 클래스 작성자는 프로그래머에게 필요한 부분만 공개하고 나머지는 꽁꽁 숨겨야 합니다. 클라이언트 프로그래머가 숨겨 놓은 부분에 마음대로 접근할 수 없도록 방지함으로써 클라이언트 프로그래머에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있습니다. 이를 구현 은닉(implementation hiding)이라고 부릅니다.

구현 은닉은 클래스 작성자와 클라이언트 프로그래머 모두에게 유용한 개념입니다. 클라이언트 프로그래머는 내부의 구현은 무시한 채 인터페이스만 알고 있어도 클래스를 사용할 수 있기 때문에 머릿속에 담아둬야 하는 지식의 양을 줄일 수 있습니다. 클래스 작성자는 인터페이스를 바꾸지 않는 한 외부에 미치는 영향을 걱정하지 않고도 내부 구현을 마음대로 변경 할 수 있습니다. 다시 말해 public 영역을 변경하지 않는다면 코드를 자유롭게 수정할 수 있다는 것입니다.

객체의 외부와 내부를 구분하면 클라이언트 프로그래머가 알아야 할 지식의 양이 줄어들고, 클래스 작성자가 자유롭게 구현을 변경할 수 있는 폭이 넓어집니다. 따라서 클래스를 개발할 때마다 인터페이스와 구현을 깔끔하게 분리하기 위해 노력해야 합니다.

협력하는 객체들의 공동체

이제 영화를 예매하는 기능을 구현하는 메서드를 살펴보겠습니다. Screening의 reserve 메서드는 영화를 예매한 후 예매 정보를 담고 있는 Reservation의 인스턴스를 생성해서 반환합니다. 인자인 customer는 예메자에 대한 정보를 담고 있고 audienceCount는 인원수 입니다.

public class Screening{
    public Reservation reserve(Customer customer, int audienceCount){
        return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
    }
}

Screening의 reserve 메서드를 보면 calculateFee라는 private 메서드를 호출해서 요금을 계산한 후 그 결과를 Reservation의 생성자에 전달하는 것을 알 수 있습니다. calculateFee 메서드는 요금을 계산하기 위해 다시 Movie의 calculateMovieFee 메서드를 호출합니다. Movie의 calculateMovieFee 메서드의 반환 값은 1인당 예매 요금입니다. 따라서 Screening은 전체 예매 요금을 구하기 위해 calculateMovieFee 메서드의 반환 값에 인원 수인 audienceCount를 곱합니다.

public class Screening{
  private Money calculateFee(int audienceCount){
        return movie.calculateMovieFee(this).times(audienceCount);
    }
}

Money는 금액과 관련된 다양한 계산을 구현하는 클래스 입니다.

import java.math.BigDecimal;

public class Money {

    public static final Money ZERO = Money.wons(0);

    private final BigDecimal amount;

    public static Money wons(long amount){
        return new Money(BigDecimal.valueOf(amount));
    }

    public static Money wons(double amount){
        return new Money(BigDecimal.valueOf(amount));
    }

    Money(BigDecimal amount){
        this.amount = amount;
    }

    public Money plus(Money amount){
        return new Money(this.amount.add(amount.amount));
    }

    public Money minus(Money amount){
        return new Money(this.amount.subtract(amount.amount));
    }

    public Money times(double percent){
        return new Money(this.amount.multiply(
                BigDecimal.valueOf(percent)
        ));
    }

    public boolean isLessThan(Money other){
        return amount.compareTo(other.amount) < 0;
    }


    public boolean isGreaterThanOrEqual(Money other){
        return amount.compareTo(other.amount) >= 0;
    }

}

이전 시간에 공부했던 티켓발권 시스템을 공부했을때 금액을 구현하기 위해서 Long 타입을 사용했었습니다. Long 타입은 변수의 크기나 연산자의 종류와 관련된 구현 관점의 제약은 표현할 수 있지만 Money 타입처럼 저장하는 값이 금액과 관련돼 있다는 의미를 전달 할 수는 없습니다. 또한 금액과 관련된 로직이 서로 다른 곳에 중복되어 구현되는 것을 막을 수 없습니다. 객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것입니다. 따라서 의미를 좀 더 명시적이고 분명하게 표현할 수 있다면 객체를 사용해서 해당 개념을 구현해야 합니다. 그 개념이 비록 하나의 인스턴스 변수만 포함하더라도 개념을 명시적으로 표현하는 것은 전체적인 설계의 명확성과 유연성을 높이는 첫걸음 입니다.

Reservation 클래스는 고객, 상영정보, 예매 요금, 인원 수를 속성으로 포함합니다.

public class Reservation {

    private Customer customer;
    private Screening screening;
    private Money fee;
    private int audienceCount;

    public Reservation(Customer customer, Screening screening, Money fee, int audienceCount) {
        this.customer = customer;
        this.screening = screening;
        this.fee = fee;
        this.audienceCount = audienceCount;
    }
}

영화를 예매하기 위해 Screening, Movie, Reservation 인스턴스들은 서로의 메서드를 호출하며 상호작용합니다. 이처럼 시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용을 협력(Collaboration)이라고 부릅니다.

Screening, Reservation, Movie 사이의 협력

Untitled Diagram (1)

객체지향 프로그램을 작성할 때는 먼저 협력의 관점에서 어떤 객체가 필요한지를 결정하고, 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성합니다. 따라서 협력에 대한 개념을 간략하게라도 살펴보는 것이 도움이 됩니다.

협력에 관한 짧은 이야기

객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청을 할 수 있습니다. 요청을 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답합니다.

객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송하는 것 뿐입니다. 다른 객체에게 요청이 도착할 때 해당 객체가 메시지 수신했다고 이야기 합니다. 메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정합니다. 이 처럼 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드라고 부릅니다.

메시지와 메서드를 구분하는 것은 매우 중요합니다. 객체지향 패러다임이 유연하고 확장 가능하며, 재사용 가능한 설계를 낳는다는 명성을 얻게 된 배경에는 메시지와 메서드를 명확하게 구분한 것도 단단히 한몫합니다.

위에서 Screening이 Movie의 calculateMovieFee 메서드를 호출한다고 말했지만 사실은 Screening이 Movie에게 calculateMovieFee 메시지를 전송한다고 말하는 것이 더 적절한 표현입니다. 사실 Screening은 Movie안에 calculateMovieFee 메서드가 존재하고 있는지조차 알지 못합니다. 단지 Movie가 calculateMovieFee 메시지에 응답할수 있다고 믿고 메시지를 전송할 뿐입니다.

메시지를 수신한 Movie는 스스로 적절한 메서드를 선택합니다. 사실 예제에서 사용한 자바 같은 정적 타입 언어에는 해당되지 않지만 루비나 스몰토크 같은 동적 타입 언어에서는 calculateMovieFee가 아닌 다른 시그니처를 가진 메서드를 통햏서도 해당 메시지에 응답할 수 있습니다. 결국 메시지를 처리하는 방법을 결정하는 것은 Movie 스스로의 문제인 것입니다.

할인 요금 구하기

할인 요금 계산을 위한 협력 시작하기

Movie는 제목(title), 상영시간(Duration), 기본요금(fee), 할인 정책(discountPolicy)을 속성으로 가집니다. 이 속성들의 값은 생성자를 통해서 전달받습니다.

import java.time.Duration;

public class Movie {
    private String title;
    private Duration runningTime; 
    private Money fee;
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicy = discountPolicy;
    }

    public Money getFee(){
        return fee;
    }

    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

calculateMovieFee 메서드는 discountPolicy에 calculateDiscountAmount 메시지를 전송해 할인 요금을 반환 받습니다. Movie는 기본 요금인 fee에서 반환된 할인 요금을 차감합니다.

이 메서드 안에는 한 가지 이상한 점이 있습니다. 어떤 할인 정책을 사용할 것인지 결정하는 코드가 어디에도 존재하지 않는다는 것입니다. 도메인을 설명할 때 언급했던 것처럼 영화 예매 시스템에는 두 가지 종류의 할인 정책이 존재합니다. 하나는 일정한 금액을 할인해 주는 금액 할인 정책이고 다른 하나는 일정한 비율에 따라 할인 요금을 결정하는 비율 할인 정책입니다. 따라서 예매 요금을 게산하기 위해서는 현재 영화에 적용되 있는 할인 정책의 종류를 판단할 수 있어야 합니다. 하지만 코드 어디에도 할인 정책을 판단하는 코드는 존재하지 않습니다. 단지 discountPolicy에게 메시지를 전송할 뿐입니다.

이 코드가 어색하다면 아직 객체지향 패러다임에 익숙하지 않는 것이라고 봐도 무방하다는군요.... 이 코드에는 객체지향에서 중요하다고 여겨지는 두가지 개념이 숨겨져 있습니다. 하나는 상속이고 다른 하나는 다형성입니다. 그리고 그 기반에는 추상화라는 원리가 숨겨져 있습니다. 먼저 코드의 개념부터 살펴보겠습니다.

할인 정책과 할인 조건

할인 정책은 금액 할인 정책과 비율 할인 정책으로 구분됩니다. 두 가지 할인 정책을 각각 AmountDiscountPolicy와 PercentDiscountPolicy라는 클래스로 구현할 것입니다. 두 클래스는 대부분의 코드가 유사하고 할인 요금을 계산하는 방식만 조금 다릅니다. 따라서 두 클래스 사이의 중복 코드를 제거하기 위해 공통의 코드를 보관할 장소가 필요합니다.

여기서 부모 클래스인 DiscoutPolicy 안에 중복 코드를 두고 AmountDiscoutPolicy와 PercentDiscountPolicy가 이 클래스를 상속받게 할 것입니다. 실제 애플리케이션에서는 DiscountPolicy의 인스턴스를 생성할 필요가 없기 때문에 추상화 클래스로 구현했습니다.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public abstract class DiscountPolicy {

    private List<DiscountCondition> conditions = new ArrayList<>();

    public DiscountPolicy(DiscountCondition ... conditions) {
        this.conditions = Arrays.asList(conditions);
    }

    public Money calculateDiscountAmount(Screening screening){
        for (DiscountCondition each : conditions){
            if(each.isSatisfiedBy(screening)){
                return getDiscountAmount(screening);
            }
        }

        return Money.ZERO;
    }

    abstract protected Money getDiscountAmount(Screening screening);
}

DiscountPolciy는 DiscountCondition의 리스트인 conditions를 인스턴스 변수로 가지기 때문에 하나의 할인 정책은 여러 개의 할인 조건을 포함할 수 있습니다. calculateDiscountAmount 메서드는 전체 할인 정책에 대해 차례대로 DiscountCondition의 isSatisfiedBy 메서드를 호출합니다. isSatisfiedBy 메서드는 인자로 전달된 Screening이 할인 조건을 만족시킬 경우에는 true를, 만족시키지 못할 경우에는 false를 반환합니다.

할인 조건을 만족하는 DiscountCondition이 하나라도 존재하는 경우에는 추상 메서드인 getDiscountAmount 메서드를 호출하여 할인 요금을 계산합니다. 만족하는 할인 조건이 하나도 존재하지 않는다면 Screening의 getMovieFee 메서드를 호출해 원래의 영화 가격을 반환합니다.

DiscountPolicy는 할인 여부와 요금 계산에 필요한 전체적인 흐름은 정의하지만 실제로 요금을 계산하는 부분은 추상 메서드인 getDiscountAmount 메서드에게 위임합니다. 실제로 DiscountPolicy를 상속받은 자식 클래스에서 오버라이딩한 메서드가 실행될 것입니다. 이처럼 부모 클래스에 기본적인 알고리즘을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라고 부릅니다.

DiscountCondition은 자바의 인터페이스를 이용해 선언돼 있습니다. isSatisfiedBy 오퍼레이션은 인자로 전달된 Screening이 할인이 가능한 경우 true를 반환하고 할인이 불가능한 경우에는 false를 반환합니다.

public interface DiscountCondition{
    boolean isSatisfiedBy(Screening screening);
}

영화 예매 시스템에는 순번 조건과 기간 조건의 두 가지 할인 조건이 존재합니다. 두 가지 할인 조건은 각각 SequenceCondition과 PeriodCondition이라는 클래스로 구현할 것입니다.

SequenceCondtion은 할인 여부를 판단하기 위해 사용할 순번(sequence)을 인스턴스 변수로 포함합니다. isSatisfiedBy 메서드는 파라미터로 전달된 Screening의 상영 순번과 일치할 경우 할인 가능한 것으로 판단해서 true를, 그렇지 않는 경우에는 false를 반환합니다.

public class SequenceCondition implements DiscountCondition {

    private int sequence;

    public SequenceCondition(int sequence) {
        this.sequence = sequence;
    }

    @Override
    public boolean isSatisfiedBy(Screening screening) {
        return screening.isSequence(sequence);
    }
}

PeriodCondition은 상영 시작 시간이 특정한 기간 안에 포함되는지 여부를 판단해 할인 여부를 결정합니다. 조건에 사용할 요일(dayOfWeek)과 시작 시간(startTime), 종료 시간(endTime)을 인스턴스 변수로 포함합니다. isSatisfiedBy 메서드는 인자로 전달된 Screening의 상영 요일이 dayOfWeek과 같고 상영 시작 시간이 startTime과 endTime 사이에 있을 경우에는 true를 반환하고 그렇지 않은 경우에는 false를 반환합니다.

import java.time.DayOfWeek;
import java.time.LocalTime;

public class PeriodCondition implements DiscountCondition {

    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
        this.dayOfWeek = dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }

    @Override
    public boolean isSatisfiedBy(Screening screening) {
        return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
                startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
                endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
    }
}

이제 할인 정책을 구현해봅시다. AmountDiscountPolicy는 DiscountPolicy로 자식 클래스로서 할인 조건을 만족할 경우 일정한 금액을 할인해주는 금액 할인 정책을 구현합니다. 이 클래스는 DiscountPolicy의 getDiscountAmount 메서드를 오버라이딩 합니다. 할인 요금은 이스턴스 변수인 discountAmount에 저장합니다.

public class AmountDiscoutPolicy extends DiscountPolicy {

    private Money discountAmount;

    public AmountDiscoutPolicy(Money discountAmount, DiscountCondition... conditions) {
        super(conditions);
        this.discountAmount = discountAmount;
    }

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return discountAmount;
    }
}

PercentDiscountPolicy 역시 DiscountPolicy의 자식 클래스로서 getDiscountAmount 메서드를 오버라이딩 합니다. AmountDiscountPolicy와 다른 점이라면 고정 금액이 아닌 일정 비율을 차감한다는 것입니다. 할인 비율은 인스턴스 변수인 percent에 저장합니다.

public class PercentDiscountPolicy extends DiscountPolicy {

    private double percent;

    public PercentDiscountPolicy(double percent, DiscountCondition... conditions) {
        super(conditions);
        this.percent = percent;
    }

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return screening.getMovieFee().times(percent);
    }
}

할인 정책과 할인 조건 다이어그램

Untitled Diagram

할인 정책 구성하기

하나의 영화에 대해 단 하나의 할인 정책만 설정할 수 있지만 할인 조건의 경우에는 여러 개를 적용할 수 있습니다. Movie와 DiscountPolicy의 생성자는 이런 제약을 강제합니다. Movie의 생성자는 오직 하나의 DiscountPolicy 인스턴스만을 받을 수 있도록 선언돼 있습니다.

   public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
        ...
        this.discountPolicy = discountPolicy;
    }

반면 DiscountPolicy의 생성자는 여러 개의 DiscoutCondition 인스턴스를 허용합니다.

 public DiscountPolicy(DiscountCondition ... conditions) {
        this.conditions = Arrays.asList(conditions);
    }

이처럼 생성자의 파라미터 목록을 이용해 초기화에 필요한 정보를 전달하도록 강제하면 올바른 상태를 가진 객체 생성을 보장할 수 있습니다. 아래 코드는 아바타에 대한 할인 정책과 할인 조건을 설정한 것입니다. 할인 정책으로 금액 할인 정책이 적용되고, 두 개의 순서 조건과 두개의 기간조건을 이용해 할인 여부를 판단한다는 것을 알 수 있습니다.

Movie avatar = new Movie("아바타", 
    Duration.ofMinute(120),
    Money.wons(10000),
    new AmountDiscountPolicy(Money.wons(800),
    new SequenceCondition(1),
    new SequenceCondition(10),
    new PeriodCondition(DayOfWeek.MONDAY, LocalTime.of(10,0), LoclaTime.of(11,59)),
    new PeriodCondition(DayOfWeek.THURSDAY, LocalTime.of(10,0), LoclaTime.of(20,59))));

또한 타이타닉에 대한 할인 정책은 다음과 같이 설정할 수 있습니다. 10%의 비율 할인 정책이 적용되고 두개의 기간 조건과 한 개의 순서 조건을 이용해 할인 여부를 판단한다는 것을 알 수 있습니다.

Movie titanic = new Movie("타이타닉",
    Duration.ofMinute(180),
    Money.wons(11000),
    new PercentDiscountPolicy(0.1,
    new PeriodCondition(DayOfWeek.TUESDAY, LocalTime.of(14,0), LocalTime.of(16,59)),
    new SequenceCondition(2),
    new PeriodCondition(DayOfWeek.TUESDAY, LocalTime.of(10,0), LocalTime.of(13,59))));

상속과 다형성

컴파일 시간 의존성과 실행 시간 의존성

Movie와 DiscountPolicy 계층 사이의 관계를 클래스 다이어그램으로 표현한 것입니다. Movie는 DiscountPolicy와 연결돼 있으며, AmountDiscountPolicy와 PercentDiscountPolicy는 추상 클래스인 DiscountPolicy를 상속받습니다. 이처럼 어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체의 메서드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 말합니다.

DiscountPolicy 상속 계층

Untitled Diagram

여기서 눈여겨봐야 할 부분은 Movie 클래스가 DiscountPolicy 클래스와 연결돼 있다는 것입니다. 문제는 영화 요금을 계산하기 위해서는 추상 클래스인 DiscountPolicy가 아니라 AmountDiscountPolicy와 PercentDiscountPolicy 인스턴스가 필요하다는 것입니다. 따라서 Movie의 인스턴스는 실행 시 AmountDiscountPolicy나 PercentDiscountPolicy의 인스턴스에 의존해야 합니다. 하지만 코드 수준에서는 Movie 클래스는 이 두 클래스 중 어떤 것에도 의존하지 않습니다. 오직 추상 클래스인 DiscountPolicy에만 의존하고 있습니다.

그렇다면 Movie의 인스턴스가 코드 작성 시점에는 그 존재조차 알지 못했던 AmountDiscountPolicy와 PercentDiscountPolicy의 인스턴스 실행 시점에 협력 가능한 이유는 무엇일까요? 그것은 Movie의 생성자에서 DiscountPolicy 타입의 객체를 인자로 받았기 때문입니다. 만약 요금을 계산하기 위해 금액 할인 정책을 적용하고 싶다면 Movie의 인스턴스를 생성할 때 인자로 AmountDiscountPolicy의 인스턴스를 전달하면 됩니다.

Movie avatar = new Movie("아바타", 
    Duration.ofMinute(120),
    Money.wons(10000),
    new AmountDiscountPolicy(Money.wons(800),...));

Untitled Diagram

실행 시에 Movie는 AmountDiscountPolicy에 의존합니다.

영화 요금을 계산하기 위해 비율 할인 정책을 적용하고 싶다면 AmountDiscountPolicy 대신 PercentDiscountPolicy의 인스턴스를 전달하면 됩니다.

Movie avatar = new Movie("아바타", 
    Duration.ofMinute(120),
    Money.wons(10000),
    new PercentDiscountPolicy(0.1,...));

이 경우 Movie의 인스턴스는 PercentDiscountPolicy의 인스턴스에 의존하게 됩니다.

Untitled Diagram (1)

여기서 이야기하고자 하는 것은 코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다는 것입니다. 다시 말해 클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있습니다. 그리고 유연하게, 쉽게 재사용 할 수 있으며, 확장 가능한 객체지향 설계가 가지는 특성은 코드의 의존성과 실행 시점의 의존성이 다르다는 것입니다.

한 가지 간과해서는 안 되는 사실은 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워진다는 것입니다. 코드를 이해하기 위해서는 코드뿐만 아니라 객체를 생성하고 연결하는 부분을 찾아야 하기 때문입니다. 반면 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드는 더 유연해지고 확장 가능해집니다. 이와 같은 의존성의 양면성은 설계가 트레이드오프의 산물이라는 사실을 잘 보여줍니다.

설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 어려워진다는 사실을 기억해야합니다. 반면 유연성을 억제하면 코드를 이해하고 디버깅하기는 쉬워지지만 재사용성과 확장 가능성은 낮아진다는 사실도 기억해야합니다. 무조건 유연한 설계도, 무조건 읽기 쉬운 코드도 정답이 아닙니다. 이것이 객체지향 설계가 어려우면서 매력적인 이유입니다.

상속과 인터페이스

상속이 가치 있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문입니다. 이것은 상속을 바라보는 일반적인 인식과는 거리가 있는데 대부분의 사람들은 상속의 목적이 메서드나 인스턴스 변수를 재사용하는 것이라고 생각하기 때문입니다.

인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의한다는 것을 기억하세요. 상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함하게 됩니다. 결과적으로 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있습니다.

public class Movie{
    public Money calculateMovieFee(Screening){
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

Movie가 DiscountPolicy의 인터페이스에 정의된 calculateDiscountAmount 메시지를 전송하고 있습니다. DiscountPolicy를 상속받는 AmountDiscountPolicy와 PercentDiscountPolicy의 인터페이스에도 이 오퍼레이션이 포함돼 있습니다. Movie 입장에서 자신과 협력하는 객체가 어떤 클래스의 인스턴스인지가 중요헌 것이 아니라 calculateDiscountAmount 메시지를 수신할 수 있다는 사실이 더 중요합니다. 다시 말해 Movie는 협력 객체가 calculateDiscountAmount라는 메시지를 이해할 수만 있다면 그 객체가 어떤 클래스의 인스턴스인지는 상관하지 않는다는 것입니다. 따라서 AmountDiscountPolicy와 PercentDiscountPolicy 모두 DiscountPolicy를 대신해서 Movie와 협력할 수 있습니다.

참고로 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅이라고 부릅니다. 업캐스팅이라고 부르는 이유는 일반적으로 아래에 위치한 자식 클래스가 위에 위치한 부모 클래스로 자동적으로 타입 캐스팅되는 것처럼 보이기 때문에 업캐스팅이라는 용어를 사용합니다.

다형성

다시 한번 강조하지만 메시지와 메서드는 다른 개념입니다. Movie는 DiscountPolicy의 인스턴스에게 calculateDiscountAmount 메시지를 전송합니다. 그렇다면 실행되는 메서드는 무엇일까요? Movie와 상호작용하기 위해 연결된 객체의 클래스가 무엇인가에 따라 달라집니다. Movie와 협력하는 객체가 AmountDiscoutPolicy의 인스턴스라면 AmountDiscountPolicy에서 오버라이딩한 메서드가 실행될 것입니다. PercentDiscountPolicy의 인스턴스가 연결된 경우에는 PercentDiscountPolicy에서 오버라이딩한 메서드가 실행될 것입니다.

결국 다형성이란 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을 말합니다. 따라서 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 합니다. 다시 말해 인터페이스가 동일해야 한다는 것입니다.

다형성을 구현하는 방법은 매우 다양하지만 메시지에 응답하기 위해 실행된 메서드를 컴파일 시점이 아닌 실행 시점에 결정한다는 공통점이 있습니다. 다시 말해 메시지와 메서드를 실행 시점에 바인딩한다는 것입니다. 이를 지연 바인딩 또는 동적 바인딩이라고 부릅니다. 그에 반해 전통적인 함수 호출처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을 초기 바인딩 또는 정적 바인딩이라고 부릅니다.

객체지향이 컴파일 시점의 의존성과 실행 시점의 의존성을 분리하고, 하나의 메시지를 선택적으로 서로 다른 메서드에 연결할 수 있는 이유가 바로 지연 바인딩이라는 메커니즘을 사용하기 때문입니다.

인터페이스와 다형성

앞에서는 DiscountPolicy를 추상 클래스로 구현함으로써 자식 클래스들이 인터페이스 내부 구현을 함께 상속받도록 만들었습니다. 그러나 종종 구현은 공유할 필요가 없고 순수하게 인터페이스만 공유하고 싶을 때가 있습니다. 이를 위해 C#과 자바에서는 인터페이스라는 프로그래밍 요소를 제공합니다. 자바의 인터페이스는 말 그대로 구현에 대한 고려 없이 다형적인 협력에 참여하는 클래스들이 공유 가능한 외부 인터페이스를 정의한 것입니다.

추상클래스를 이용해 다형성을 구현했던 할인 정책과 달리 할인 조건은 구현을 공유할 필요가 없기 때문에 아래 그림처럼 자바의 인터페이스를 이용해 타입 계층을 구현했습니다.
DiscountCondition 인터페이스를 실체화하고 있는 SequenceCondition과 PeriodCondition은 동일한 인터페이스를 공유하며 다형적인 협력에 참여할 수 있습니다.

Untitled Diagram

SequenceCondition과 PeriodCondition은 isSatisfiedBy 메시지를 이해할 수 있기 때문에 클라이언트인 DiscountPolicy 입장에서 이 둘은 DiscountCondition과 아무 차이도 없습니다. DiscountCondition을 실체화하는 클래스들은 동일한 인터페이스를 공유하며 DiscountCondition을 대신해서 사용될 수 있습니다. 이 경우에도 업캐스팅이 적용되며 협력은 다형적입니다.

추상화와 유연성

추상화의 힘

지금까지 살펴본 것처럼 할인 정책은 구체적인 금액 할인 정책과 비율 할인 정책을 포함하는 추상적인 개념입니다. 할인 조건 역시 더 구체적인 순번 조건과 기간 조건을 포괄하는 추상적인 개념입니다. 다시 말해 DiscountPolicy는 AmountDiscountPolicy와 PercentDiscountPolicy보다 추상적이고 DiscountCondition은 SequenceCondition과 PercentCondition보다 추상적입니다.

프로그래밍 언어 측면에서 DiscountPolicy와 DiscountCondition이 더 추상적인 이유는 인터페이스에 초점을 맞추기 때문입니다. DiscountPolicy는 모든 할인 정책들이 수신할 수 있는 calculateDiscountAmount 메시지를 정의합니다. DiscountCondition은 모든 할인 조건들이 수신할 수 있는 isSatisfiedBy 메시지를 정의합니다. 둘 다 같은 계층에 속하는 클래스들이 공통으로 가질 수 있는 인터페이스를 정의하며 구현의 일부 또는 전체를 자식 클래스가 결정할 수 있도록 결정권을 위임합니다.

아래 그림은 자식 클래스를 생략한 코드 구조를 그림으로 표현하였습니다. 이 그림은 추상화를 사용할 경우의 두 가지 장점을 보여줍니다. 첫 번째 장점은 추상화의 계층만 따로 뗴어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술하고 있다는 것입니다.
두 번째 장점은 추상화를 이용하면 설계가 좀 더 유연해진다는 것입니다.

Untitled Diagram (1)

위의 그림을 하나의 문장으로 정리하면 영화 예매 요금은 최대 하나의 할인 정책다수의 할인 조건을 이용해 계산할 수 있다로 표현할 수 있습니다. 이 문장이 영화 예매 요금은 금액 할인 정책두개의 순서 조건, 한개의 기간 조건을 이용해서 계산할 수 있다라는 문장을 포괄할 수 있다는 사실이 중요합니다. 이것은 할인 정책과 할인 조건이라는 좀 더 추상적인 개념들을 사용해서 문장을 작성했기 때문입니다.

추상화를 사용하면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있습니다. 추상화의 이런 특징은 세부사항에 억눌리지 않고 상위 개념만으로도 도메인의 중요한 개념을 설명할 수 있게 합니다. 금액 할인 정책과 비율 할인 정책을 사용한다는 사실이 중요할 때도 있겠지만 어떤 때는 할인 정책이 존재한다고 말하는 것만으로도 충분한 경우가 있습니다. 추상화를 이용한 설계는 필요에 따라 표현의 수준을 조정하는 것을 가능하게 해줍니다.

추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는 것을 의미합니다. 영화 예매 가격을 계산하기 위해 흐름은 항상 Movie에서 DiscountPolicy로, 그리고 다시 DiscountCondition을 향해 흐릅니다. 할인 정책이나 할인 조건의 새로운 자식 클래스들은 추상화를 이용해서 정의한 상위의 협력 흐름을 그대로 따르게 됩니다. 이 개념은 매우 중요합니다. 재사용 가능한 설계의 기본을 이루는 디자인 패턴이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향 메커니즘을 활용하고 있기 때문입니다.

두 번째 특징은 첫 번째 특징으로부터 유추할 수 있습니다. 추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있습니다. 다시 말해 설계를 유연하게 만들수 있습니다.

유리한 설계

아래 세 번째 코드에서 서술한 스타워즈의 할인 정책은 해결하지 않았습니다. 사실 스타워즈에는 할인 정책이 적용돼 있지 않습니다. 즉, 할인 요금을 계산할 필요 없이 이 영화에 설정된 기본 금액을 그대로 사용하면 됩니다.

public class Movie{
    public Money calculateMovieFee(Screening screening) {

        if(discountPolicy == null){
            return fee;
        }

        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

이 방식의 문제점은 할인 정책이 없는 경우를 예외 케이스로 취급하기 때문에 지금까지 일관성 있던 협력 방식이 무너지게 된다는 것입니다. 기존 할인 정책의 경우에는 할인할 금액을 계산하는 책임이 DiscountPolicy의 자식 클래스에 있었지만 할인 정책이 없는 경우에는 할인 금액이 0원이라는 사실을 결정하는 책임이 DiscountPolicy가 아닌 Movie 쪽에 있기 때문입니다. 따라서 책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않는 선택입니다. 항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택해야 합니다. 아래 NonDiscountPolicy 클래스를 추가하였습니다.

public class NonDiscountPolicy extends DiscountPolicy {

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}

이제 Movie의 인스턴스에 NonDiscountPolicy의 인스턴스를 연결해서 할인되지 않는 영화를 생성할 수 있습니다.

Movie starWars = new Movie("스타워즈",
    Duration.ofMinute(210),
    Money.wons(10000),
    new NoneDiscountPolicy());

중요한 것은 기존의 Movie와 DiscountPolicy는 수정하지 않고 NoneDiscountPolicy라는 새로운 클래스를 추가하는 것만으로 애플리케이션의 기능을 확장했다는 것입니다. 이처럼 추상화를 중심으로 코드의 구조를 설계하면서 유연하고 확장 가능한 설계를 만들 수 있습니다.

추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문입니다. Movie는 특정한 할인 정책에 묶이지 않습니다. 할인 정책을 구현한 클래스가 DiscountPolicy를 상속받고 있다면 어떤 클래스와도 협력이 가능합니다.

DiscountPolicy 역시 특정한 할인 조건에 묶여있지 않습니다. DiscountCondition을 상속받는 어떤 클래스와도 협력이 가능합니다. 이것은 DiscountPolicy와 DiscountCondition이 추상적이기 때문에 가능합니다. 컨텍스트의 독립성이라고 불리는 이 개념은 프레임워크와 같은 유연한 설계가 필수적인 분야에서 그 진가를 발휘합니다.

추상화를 이용하면 기존 코드를 수정하지 않고도 기능을 확장할 수 있습니다.

Untitled Diagram

추상 클래스와 인터페이스 트레이드 오프

앞의 NoneDiscountPolicy 클래스의 코드를 자세히 살펴보면 getDiscountAmount()메서드가 어떤 값을 반환하더라도 상관이 없다는 사실을 알 수 있습니다. 부모 클래스인 DiscountPolicy에서 할인 조건이 없을 경우에는 getDiscountAmount() 메서드를 호출하지 않기 때문입니다. 이것은 부모 클래스인 DiscountPolicy와 NoneDiscountPolicy를 개념적으로 결합시킵니다. NoneDiscountPolicy의 개발자는 getDiscountAmount()가 호출되지 않을 경우 DiscountPolicy가 0원을 반환할 것이라는 사실을 가정하고 있기 때문입니다.

이 문제를 해결하는 방법은 DiscountPolicy를 인터페이스로 바꾸고 NoneDiscountPolicy가 DiscountPolicy의 getDiscountAmount() 메서드가 아닌 calculateDiscountAmount() 오퍼레이션을 오버라이딩 하도록 변경하는 것입니다.

public interface DiscountPolicy{
    Money calculateDisountAmount(Screening screening);
}

원래의 DiscountPolicy 클래스의 이름을 DefaultDiscountPolicy로 변경하고 인터페이스를 구현하도록 수정합니다.

public abstact DefaultDiscountPolicy implements DiscountPolicy{
    ...
}

이제 NoneDiscountPolicy가 DiscountPolicy 인터페이스를 구현하도록 변경하면 개념적인 혼란과 결합을 제거할 수 있습니다.

인터페이스를 이용해서 구현한 DiscountPolicy 계층

Untitled Diagram

이상적으로는 인터페이스를 사용하도록 변경한 설계가 더 좋을 것입니다. 현실적으로는 NoneDiscountPolicy만을 위해 인터페이스를 추가하는 것이 과하다는 생각이 들 수도 있을 것입니다. 어쨌든 변경 전의 NoneDiscountPolicy 클래스 역시 할인 금액이 0원이라는 사실을 효과적으로 전달하기 때문입니다. 결론은 구현과 관련된 모든 것들이 트레이드오프의 대상이 될 수 있다는 사실입니다.내가 작성하는 모든 코드에는 합당한 이유가 있어야 합니다. 비록 아주 사소한 결정이더라도 트레이드오프를 통해 얻어진 결론과 그렇지 않는 결론 사이의 차이는 큽니다. 고민하고 트레이드오프해야합니다.

코드 재사용

상속은 코드를 재사용하기 위해 널리 사용되는 방법입니다. 그러나 널리 사용되는 방법이라고 해서 가장 좋은 방법인것은 아닙니다. 객체지향 설계와 관련된 자료를 조금이라도 본 사람들은 코드 재사용을 위해서는 상속보다는 합성(composition)이 더 좋은 방법이라는 이야기를 많이 들었을 겁니다. 합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법을 말합니다.

Movie가 DiscountPolicy의 코드를 재사용하는 방법이 바로 합성입니다. 이 설계를 상속으로 사용하도록 변경할 수 도있습니다. 예를 들어서 Moive를 직접 상속받 AmountDiscountMoive와 PercentDiscountMovie라는 두 개의 클래스를 추가하면 합성을 사용한 기존 방법과 기능적인 관점에서 완벽히 동일합니다. 하지만 그럼에도 많은 사람들이 상속 대신 합성을 선호하는 이유가 있습니다.

상속

두 가지 관점에서 상속은 설계에 안 좋은 영향을 미칩니다. 하나는 상속이 캡슐화를 위반한다는 것이고, 다른 하나는 설계를 유연하지 못하게 만든다는것 입니다.

상속의 가장 큰 문제점은 캡슐화를 위반한다는 것입니다. 상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 합니다. AmountDiscountMoive와 PercentDiscountMovie를 구현하는 개발자는 부모 클래스인 Moive의 calculateMovieFee 메서드 안에서 추상 메서드인 getDiscountAmount 메서드를 호출한다는 사실을 알고 있어야 합니다.

결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화 됩니다.
캡슐화의 약화는 자식 클래스가 부모 클래스에게 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높입니다. 결과적으로 상속을 과도하게 사용한 코드는 변경하기도 어려워집니다.

상속의 두 번째 단점은 설계가 유연하지 않다는 것입니다. 상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정합니다. 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능 합니다.

예를 들어 실행 시점에 금액 할인 정책인 영화를 비율 할인 정책으로 변경한다고 가정합니다. 상속을 사용한 설계에서 AmountDiscountMovie의 인스턴스를 PercentDiscountMovie의 인스턴스로 변경해야 합니다. 대부분의 언어는 이미 생성된 객체의 클래스를 변경하는 기능을 지원하지 않기 때문에 이 문제를 해결할 수 있는 최선의 방법은 PercentDiscountMovie의 인스턴스를 생성한 후 AmountDiscountMovie의 상태를 복사하는 것 뿐입니다. 이것은 부모 클래스와 자식 클래스가 강하게 결합돼 있기 때문에 발생하는 문제입니다.

반면 인스턴스 변수로 연결한 기존 방법을 사용하면 실행 시점에 할인 정책을 간단하게 변경할 수 있습니다.

public class Movie {
    private DiscountPolicy discountPolicy;

    public void changeDiscountPolicy(DiscountPolicy discountPolicy){
        this.discountPolicy = discountPolicy;
    }
}

금액 할인 정책이 적용된 영화에 비율 할인 정책이 적용되도록 변경하는 것은 새로운 DiscountPolicy 인스턴스를 연결하는 간단한 작업으로 바뀝니다.

Movie avatar = new Movie("아바타",
    Duration.ofMinute(120),
    Money.wons(10000),
    new AmountDiscountPolicy(Money.wons(800), ...));

avatar.changeDiscountPolicy(new PercentDiscountPolicy(0.1, ...));

이 에제를 통해 상속보다 인스턴스 변수로 관계를 연결한 원래의 설계가 더 유연하다는 사실을 알 수 있습니다. Movie가 DiscountPolicy를 포함하는 이 방법 역시 코드를 재사용하는 방법이라는 점을 눈여겨 봐야 합니다. Movie가 DiscountPolicy의 코드를 재사용하는 이 방법은 너무나도 유용하기 때문에 특별한 이름으로 불립니다.

합성

Movie는 요금을 계산하기 위해 DiscountPolicy의 코드를 재사용합니다. 이 방법이 상속과 다른 점은 상속이 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합하는데 비해 Movie가 DiscountPolicy의 인터페이스를 통해 약하게 결합된다는 것입니다. 실제로 Movie는 DiscountPolicy가 외부에 calculateDiscountAmount 메서드를 제공한다는 사실만 알고 있고 내부 구현에 대해서는 전혀 알지 못합니다. 이처럼 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라고 부릅니다.

합성은 상속이 가지는 두 가지 문제점을 모두 해결합니다. 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있습니다. 또한 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만듭니다. 상속은 클래스를 통해 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합됩니다. 따라서 코드의 재사용을 위해서는 상속보다 합성을 선호하는 것이 더 좋은 방법 입니다.

그렇다고 상속을 절대 사용하지 말라는 것은 아닙니다. 대부분의 설계에서는 상속과 합성을 함께 사용해야 합니다. Movie와 DiscountPolicy는 합성 관계로 연결돼 있고 DiscountPolicy와 AmountDiscountPolicy와 PercentDiscountPolicy는 상속 관계로 연결돼 있습니다. 이처럼 코드를 재사용하는 경우에는 상속보다 합성을 선호하는 것이 옳지만 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용할 수 밖에 없습니다.

객체지향에서 가장 중요한 것은 애플리케이션의 기능을 구현하기 위해 협력에 참여하는 객체들 사이의 상호작용 입니다. 객체들은 협력에 참여하기 위해 역할을 부여받고 역할에 적합한 책임을 수행합니다.

객체지향 설계의 핵심은 적절한 협력을 식별하고 협력에 필요한 역할을 정의한 후에 역할을 수행 할 수 있는 적절한 객체에게 적절한 책임을 할당하는 것입니다.

참조 : 오브젝트 - 코드로 이해하는 객체지향 설계

'SpringFramework > JAVA' 카테고리의 다른 글

비동기 프로그래밍  (0) 2020.04.11
프록시 패턴 예제  (0) 2020.02.15
멀티 스레드의 개념  (0) 2019.12.30
스트림 메소드 2편  (0) 2019.12.05
스트림 처리 메소드 1편  (0) 2019.12.04

멀티 스레드(Multi Thread) 개념

운영체제에서는 실행 중인 하나의 애플리케이션을 프로세스라고 부릅니다. 사용자가 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당받아 애플리케이션의 코드를 실행하는데 이것이 프로세스 입니다.
예를들어, Chrome 브라우저를 두 개 실행했다면 두 개의 Chrome 프로세스가 생성된 것 입니다.

Untitled Diagram (3)

기본적으로 멀티 태스킹은 두 가지 이상의 작업을 동시에 처리하는 것을 말합니다. 운영체제는 멀티 태스킹을 할 수 있도록 CPU 및 메모리 자원을 프로세스마다 적절히 할당해주고, 병렬로 실행시킵니다. 중요한건 멀티 태스킹은 꼭 멀티 프로세스를 뜻하지 않습니다. 대표적인 것이 미디어 플레이어와 메신저 입니다. 미디어 플레이는 동영상 재생과 음악 재생이라는 두 작업을 동시에 처리하고, 메신저는 채팅 기능을 제공하면서 동시에 파일 전송기능을 수행하기도 합니다. 이렇게 하나의 프로세스가 두 가지 이상의 작업을 처리하기 위해서는 멀티 스레드를 이해해야 합니다.
스레드는 사전적 의미로 한 가닥의 실이라는 뜻입니다. 한 가지 작업을 실행하기 위해 순차적으로 실행할 코드를 실처럼 이어 놓았다고 해서 유래된 이름입니다. 하나의 스레드는 하나의 코드 실행 흐름이기 때문에 한 프로세스 내에 스레드가 두 개라면 두 개의 코드 실행흐름이 생긴다는 의미입니다.

멀티 프로세스가 애플리케이션 단위의 멀티 태스킹이라면 멀티 스레드는 애플리케이션 내부에서 멀티 태스킹이라고 볼 수 있습니다.

Untitled Diagram

멀티 프로세스들은 운영체제에서 할당받은 자신의 메모리를 가지고 실행하기 때문에 서로 독립적입니다. 따라서 하나의 프로세스에서 오류가 발생해도 다른 프로세스에게 영향을 미치지 않습니다. 하지만 멀티 스레드는 하나의 프로세스 내부에 생성되기 때문에 하나의 스레드가 예외를 발생시키면 프로세스 자체가 종료될 수 있어 다른 스레드에게도 영향을 미칩니다. 그렇기 때문에 멀티 스레드에서는 예외 처리에 만전을 기해야 합니다.

멀티 스레드는 다양한 곳에 사용되지만, 대표적으로 대용량 데이터의 처리 시간을 줄이기 위해 데이터를 분할해서 병렬로 처리하는 곳에서 사용되기도 하고, UI를 가지고 있는 애플리케이션에서 네트워크 통신을 하기 위해 사용되기도 합니다. 또 다수 클라이언트의 요청을 처리하는 서버를 개발할 때에도 사용됩니다. 멀티 스레드는 애플리케잏션을 개발하는데 꼭 필요한 기능이기 때문에 반드시 이해하고 활용할 수 있도록 합니다.

메인 스레드

모든 자바 애플리케이션은 메인 스레드가 main() 메소드를 실행하면서 시작됩니다. 메인 스레드는 main() 메소드의 첫 코드부터 아래로 순차적으로 실행하고, main() 메소드의 마지막 코드를 실행하거나 return 문을 만나면 실행이 종료됩니다.

public static void main(String[] args){
    // 코드의 실행 흐름 -> 스레드
    String data = null;
    if(...){

    }
    while(...){

    }
    System.out.println("...");
}

메인 스레드는 필요에 따라 작업 스레드들을 만들어서 병렬로 코드를 실행할 수 있습니다. 즉, 멀티 스레드를 생성해서 멀티 태스킹을 수행합니다. 싱글 스레드 애플리케이션에서는 메인 스레드가 종료하면 프로세스도 종료합니다. 하지만 멀티 스레드 애플리케이션에서는 실행 중인 스레드가 하나라도 있다면, 프로세스는 종료되지 않습니다. 메인 스레드가 작업 스레드보다 먼저 종료되더라도 작업 스레드가 계속 실행 중이라면 프로세스는 종료되지 않습니다.

작업 스레드 생성과 실행

멀티 스레드로 실행하는 애플리케이션을 개발하려면 먼저 몇 개의 작업을 병렬로 실행할지 결정하고 각 작업별로 스레드를 생성해야 합니다.
어떤 자바 애플리케이션이건 메인 스레드는 반드시 존재하기 때문에 메인 작업 이외에 추가적인 병렬 작업의 수만큼 스레드를 생성합니다. java.lang.Thread 클래스로부터 작업 스레드 객체를 직접 생성하려면 아래코드 같이 Runnable을 매개값으로 갖는 생성자를 호출해야 합니다.

Thread thread = new Thread(Runnable target);

Runnable은 작업 스레드가 실행할 수 있는 코드를 가지고 있는 객체입니다. Runnable은 인터페이스 타입이기 때문에 구현 객체를 만들어 대입해야 합니다. Ruunable에는 run() 메소드 하나가 정의되어 있는데, 구현 클래스는 run()을 재정의해서 작업 스레드가 실행할 코드를 작성해야 합니다.

class Task implements Runnable{

    @Override
    public void run(){
        스레드가 실행할 코드;
    }
}

주의할 점은 Runnable은 작업 내용을 가지고 있는 객체이지 실제 스레드는 아닙니다. Runnable 구현 객체를 생성한 후, 이것을 매개값으로 해서 Thread 생성자를 호출하면 비로소 작업 스레드가 생성됩니다.

Runnable task = new Task();

Thread thread = new Thread(task);

여기서 한술 더 떠서 코드를 좀 더 절약하기 위해 Thread 생성자를 호출할 때 Runnable 익명 객체를 매개값으로 사용할 수 있습니다. 오히려 이 방법이 더 많이 사용됩니다.

Thread thread = new Thread(new Runnable(){

    public void run(){
        스레드가 실행할 코드;
    }
});

Runnable 인터페이스는 run() 메소드가 하나만 정의되어 있기 때문에 함수적 인터페이스 입니다. 따라서 아래와 같이 람다식을 매개값으로 사용할 수 있습니다.

참고로 람다식은 자바 8부터 지원되기 때문에 자바 7 이전 버전에서는 사용할 수 없습니다.

Thread thread = new Thread(() -> {
    스레드가 실행할 코드;
});

작업 스레드는 생성되는 즉시 실행되는 것이 아니라, start() 메소드를 다음과 같이 호출해야만 비로소 실행됩니다.

thread.start();

start() 메소드가 호출되면, 작업 스레드는 매개값으로 받은 Runnable의 run() 메소드를 실행하면서 자신의 작업을 처리합니다.

스크린샷 2019-12-29 오전 1 52 25

0.5초 주기로 비프(beep)음을 발생시키면서 동시에 프린팅하는 작업이 있다고 가정해보겠습니다. 비프음 발생과 프린팅은 서로 다른 작업이므로 메인 스레드가 동시에 두가지 작업을 처리할 수 없습니다. 만약 다음과 같이 작성했다면 메인 스레드는 비프음을 모두 발생한 다음, 프린팅을 시작합니다.

import java.awt.*;

public class BeepPrintExample1 {

    public static void main(String[] args) {
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        for (int i = 0; i < 5; i++) {
            toolkit.beep();
            try{ Thread.sleep(500);}catch (Exception e){}
        }


        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try{ Thread.sleep(500);}catch (Exception e){}

        }
    }
}

이제 프린팅을 메인 스레드가 담당하고, 비프음을 들려주는 것은 작업 스레드가 담당하도록 수정해보겠습니다. 우선 작업을 정의하는 Runnable 구현 클래스를 다음과 같이 작성합니다.

비프음을 들려주는 작업 스레드 정의

import java.awt.*;

public class BeepTask implements Runnable{

    // 스레드 실행 내용
    @Override
    public void run() {
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        for (int i = 0; i < 5; i++) {
           toolkit.beep();
           try{ Thread.sleep(500);}catch (Exception e){}
        }
    }
}

메인 스레드와 작업 스레드가 동시에 실행되도록 코드 수정

public class BeepPrintExample1 {

    public static void main(String[] args) {
        BeepTask beepTask = new BeepTask();
        Thread thread = new Thread(beepTask);
        thread.start();

        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try{ Thread.sleep(500);}catch (Exception e){}
        }
    }
}
---------------------------------------------------------
// 람다식 이용
public class BeepPrintExample1 {

    public static void main(String[] args) {
        BeepTask beepTask = new BeepTask();
        Thread thread = new Thread(() -> {
            Toolkit toolkit = Toolkit.getDefaultToolkit();
            for(int i = 0; i < 5; i++){
                toolkit.beep();
                try{ Thread.sleep(500); }catch(Exception e){}
            }
        });

        thread.start();

        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try{ Thread.sleep(500);}catch (Exception e){}
        }
    }
}

Thread 하위 클래스부터 생성

작업 스레드가 실행할 작업은 Runnable로 만들지 않고, Thread의 하위 클래스로 작업 스레드를 정의하면서 작업 내용을 포함시킬 수도 있습니다. Thread 클래스를 상속한 후 run 메소드를 재정의해서 스레드가 실행할 코드를 작성하면 됩니다.

public class WorkerThread extends Thread{

    @Override
    public void run(){
        // 스레드가 실행할 코드
    }
}
Thread thread = new WorkerThread();

코드를 절약하기 위해 아래와 같이 Thread 익명 객체로 작업 스레드 객체를 생성할 수도 있습니다.

Thread thread = new Thread(){

    public void run(){
        // 스레드가 실행할 코드
    }
};

스레드의 이름

스레드는 자신의 이름을 가지고 있습니다. 스레드의 이름 자체가 큰 역할을 하는 것은 아니지만, 디버깅할 때 어떤 스레드가 어떤 작업을 하는지 조사할 목적으로 가끔 사용됩니다. 메인 스레드는 "main"이라는 이름을 가지고 있고, 우리가 직접 생성한 스레드는 자동적으로 "Thread-n"이라는 이름으로 설정됩니다. n은 스레드 번호를 의미합니다. Thread-n 대신 다른 이름으로 변경하고 싶다면 Thread 클래스의 setName() 메소드로 변경하면 됩니다.

thread.setName("스레드 이름");

반대로 스레드 이름을 알고 싶을 경우에는 getName() 메소드를 호출하면 됩니다.

thread.getName();

setName()과 getName()은 Thread의 인스턴스 메소드이므로 스레드의 객체의 참조가 필요합니다. 만약 스레드 객체의 참조를 가지고 있지 않다면, Thread의 정적 메소드인 currentThread()로 코드를 실행하는 현재 스레드의 참조를 얻을 수 있습니다.

thread.currentThread();
// 메인 스레드 이름 출력 및 UserThread 생성 및 시작
public class ThreadNameExample {
    public static void main(String[] args) {

        Thread mainThread = Thread.currentThread();

        System.out.println("프로그램 스레드 이름: " + mainThread.getName());

        ThreadA threadA = new ThreadA();
        System.out.println("작업 스레드 이름: "+ threadA.getName());
        threadA.start();


        ThreadB threadB = new ThreadB();
        System.out.println("작업 스레드 이름: "+ threadB.getName());
        threadB.start();

    }
}
// ThreadA 클래스
public class ThreadA extends Thread{

    public ThreadA() {
        setName("ThreadA");
    }
    // 스레드 이름 얻기
    @Override
    public void run() {
        for (int i = 0; i < 2; i++) {
            System.out.println(getName() + "가 출력한 내용");
        }
    }
}
// ThreadB 클래스
public class ThreadB extends Thread{

    public ThreadB() {
        setName("ThreadB");
    }

    // 스레드 이름 얻기
    @Override
    public void run() {
        for (int i = 0; i < 2; i++) {
            System.out.println(getName() + "가 출력한 내용");
        }
    }
}

스레드 우선 순위

멀티 스레드는 동시성(Concurrency) 또는 병렬성(Parallelism)으로 실행되기 때문에 이 용어들에 대해 정확히 이해하는 것이 좋습니다. 동시성은 멀티 작업을 위해 하나의 코어에서 멀티 스레드가 번갈아가며 실행하는 성질을 말하고, 병렬성은 멀티 작업을 위해 멀티 코어에서 개별 스레드를 동시에 실행하는 성질을 말합니다. 싱글 코어 CPU를 이용한 멀티 스레드 작업은 병렬적으로 실행되는 것 처럼 보이지만, 사실은 번갈아가며 실행하는 동시성 작업입니다. 번갈아 실행하는 것이 워낙 빠르다보니 병렬성으로 보일 뿐입니다.

Untitled Diagram (1)

스레드의 개수가 코어의 수보다 많을 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 하는데 이것을 스레드 스케줄링이라고 합니다. 스레드 스케줄링에 의해 스레드들은 아주 짧은 시간에 번갈아가며 그들의 run() 메소드를 조금씩 실행합니다.

스크린샷 2019-12-29 오후 10 42 51

자바의 스레드 스케줄링은 우선순위(Priority) 방식과 순환 할당(Round-Robin) 방식을 사용합니다. 우선순위 방식은 우선순위가 높은 스레드가 실행 상태를 더 많이 가지도록 스케줄링하는 것을 말합니다. 순한 할당 방식은 시간 할당량을 정해서 하나의 스레드를 정해진 시간만큼 실행하고 다시 다른 스레드를 실행하는 방식을 말합니다.
스레드 우선순위 방식은 스레드 객체에 우선순위 번호를 부여할 수 있기 때문에 개발자가 코드로 제어할 수 있습니다. 하지만 순환 할당 방식은 자바 가상 머신에 의해서 정해지기 때문에 코드로 제어할 수 없습니다.

우선순위 방식에서 우선순위는 1에서부터 10까지 부여되는데 1이 가장 우선순위가 낮고, 10이 가장 높습니다. 우선순위를 부여하지 않으면 모든 스레드들은 기본적으로 5의 우선순위를 할당받습니다. 우선순위를 변경하고 싶다면 Thread 클래스가 제공하는 setPriority() 메소드를 이용하면 됩니다.

thread.setPriority();

우선순위의 매개값으로 1 ~ 10까지의 값을 직접 주어도 되지만, 코드의 가독성을 높이기 위해 Thread 클래스의 상수를 사용할 수도 있습니다.

thread.setPriority(Thread.MAX_PRIORITY);
thread.setPriority(Thread.NORM_PRIORITY);
thread.setPriority(Thread.MIN_PRIORITY);

MAX_PRIORITY는 10, NORM_PRIORITY은 5, MIN_PRIORITY은 각각 1의 값을 가지고 있습니다. 다른 스레드에 비해 실행 기회를 더 많이 가지려면 MAX_PRIORITY로 우선순위를 높게 설정하면 됩니다. 동일한 계산 작업을 하는 스레드들이 있고, 싱글 코어에서 동시성으로 실행할 경우, 우선 순위가 높은 스레드가 실행 기회를 더 많이 가지기 때문에 우선순위가 낮은 스레드보다 계산 작업을 빨리 끝냅니다. 쿼드 코어일 경우 4개의 스레드가 병렬성으로 실행될 수 있기 때문에 4개 이하의 스레드를 실행할 경우에는 우선순위 방식이 크게 영향을 미치지 못합니다. 최소한 5개 이상의 스레드가 실행되어야 우선순위의 영향을 받습니다. 아래 코드는 10개의 스레드를 생성하고 20억 번의 루핑을 누가 더 빨리 끝내는가를 테스트하는 예제입니다. Thread 1 ~ 9는 우선순위를 가장 낮게 주었고, Thread10은 우선순위를 가장 높게 주었다. 결과는 Thread10의 계산 작업이 가장 빨리 끝난다.

public class CalcThread extends Thread {

    public CalcThread(String name) {
        setName(name);
    }

    @Override
    public void run() {
        for (int i = 0; i < 2000000000; i++) {
        }
        System.out.println(getName());
    }
}
public class PriorityExample {

    public static void main(String[] args) {
        for (int i = 1; i <= 10; i++) {
            Thread thread = new CalcThread("thread" + i);
            if(i != 10){
                thread.setPriority(Thread.MIN_PRIORITY); // 가장 낮은 우선순위 설정
            }else {
                thread.setPriority(Thread.MAX_PRIORITY); // 가장 높은 우선순위 설정
            }
            thread.start();
        }
    }
}

실행결과

스크린샷 2019-12-29 오후 11 18 11

사실 몇번을 돌려봤지만 스레드10을 제외한 나머지 스레드 1~9에 우선순위를 1로 주어도 무조건 스레드10이 계산을 가장 먼저 끝나지 않습니다. 아무래도 위에서 언급한것 처럼 현재 저의 PC가 옥타코어이고 스레드 생성 수를 10개로 해서 그런지 스레드 각각의 실행속도가 너무 빨라서 우선순위가 작업의 속도에는 영향을 미치는지는 정확하게 알수가 없는것 같습니다.... 스레드 생성 수를 5개로 제한하고 다시 돌려보니 그래도 1 ~ 2번째로 작업이 빨리 끝나는거 같습니다.

동기화 메소드와 동기화 블록

공유 객체를 사용할 때의 주의할 점

싱글 스레드 프로그램에서는 한 개의 스레드가 객체를 독차지해서 사용하면 되지만, 멀티 스레드 프로그램에서는 스레드들이 객체를 공유해서 작업해야 하는 경우가 있습니다. 이 경우 스레드 A를 사용하던 객체가 스레드 B에 의해 상태가 변경될 수 있기 때문에 스레드 A가 의도했던 것과는 다른 결과를 산출할 수도 있습니다. 이는 마치 여러사람이 계산기를 함께 나눠 쓰는 상황과 같아서 사람 A가 계산기로 작업을 하다가 계산 결과를 메모리에 저장한 뒤 잠시 자리를 비웠을 때 사람 B가 계산기를 만져서 앞 사람이 메모리에 저장한 값을 다른 값으로 변경하는 것과 같습니다. 그런 다음 사람 A가 들어와 계산기에 저장된 값을 이용해서 작업을 진행한다면 결국 사람 A는 엉터리 값을 이용하게 됩니다.

스크린샷 2019-12-29 오후 11 47 59
public class MainThreadExample {

    public static void main(String[] args) {

        Calculator calculator = new Calculator();

        // User1 스레드 생성
        User1 user1 = new User1();
        // 공유 객체 설정
        user1.setCalculator(calculator);
        user1.start();

        // User2 스레드 생성
        User2 user2 = new User2();
        // 공유 객체 설정
        user2.setCalculator(calculator);
        user2.start();

    }
}
// 공유 객체
public class Calculator {

    private int memory;

    public int getMemory() {
        return memory;
    }

    public void setMemory(int memory) {
        this.memory = memory;

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}
// User1 스레드
public class User1 extends Thread {

    private Calculator calculator;

    public void setCalculator(Calculator calculator){
        setName("User1");
        this.calculator = calculator;
    }

    @Override
    public void run() {
        calculator.setMemory(100);
    }
}
// User2 스레드
public class User2 extends Thread{

    private Calculator calculator;

    public void setCalculator(Calculator calculator) {
        setName("User2");
        this.calculator = calculator;
    }

    @Override
    public void run() {
        calculator.setMemory(50);
    }
}

동기화 메소드 및 동기화 블록

스레드가 사용중인 객체를 다른 스레드가 변경할 수 없도록 하려면 스레드 작업이 끝날 때까지 객체에 잠금을 걸어서 다른 스레드가 사용할 수 없도록 해야합니다. 멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 영역을 임계영역이라고 합니다. 자바는 임계영역을 지정하기 위해 동기화(synchronized) 메소드와 동기화 블록을 제공합니다. 스레드가 객체 내부의 동기화 메소드 또는 블록에 들어가면 즉시 객체에 잠금을 걸어 다른 스레드가 임계 영역 코드를 실행하지 못하도록 합니다. 동기화 메소드를 만드는 방법은 아래와 같이 메소드 선언에 synchronized 키워드를 붙이면 됩니다. synchronized 키워드는 인스턴스와 정적 메소드 어디든 붙일 수 있습니다.

public synchronized void method(){
    임계 영역; // 단 하나의 스레드만 실행
}

동기화 메소드는 메소드 전체 내용이 임계 영역이므로 스레드가 동기화 메소드를 실행하는 즉시 객체에는 잠금이 일어납니다. 스레드가 동기화 메소드를 실행 종료하면 잠금이 풀립니다. 메소드 전체 내용이 아니라, 일부 내용만 임계 영역으로 만들고 싶다면 다음과 같이 동기화 블록을 만들면 됩니다.

public void method(){

    //여러 스레드가 실행 가능 영역
    ...
    synchronized(공유 객체){
        임계 영역 // 단 하나의 스레드만 실행
    }
    // 여러 스레드가 실행 가능 영역
    ...
}   

동기화 블록의 외부 코드들은 여러 스레드가 동시에 실행할 수 있지만, 동기화 블록의 내부 코드는 임계 영역이므로 한 번에 한 스레드만 실행할 수 있고 다른 스레드는 실행할 수 없습니다. 만약 메소드 동기화 블록이 여러개 있을 경우, 스레드가 이들 중 하나를 실행할 때 다른 스레드는 해당 메소드는 물론이고 다른 동기화 메소드 및 블록도 실행할 수 없습니다.하지만 일반 메소드는 실행이 가능합니다.

스크린샷 2019-12-30 오전 12 34 10

이제 예제 코드를 수정해 보겠습니다. 문제가 된 공유 객체 Calculator를 수정한 것입니다. Calculator의 setMemory() 메소드를 동기화 메소드로 만들어서 User1 스레드가 setMemory()를 실행할 동안 User2 스레드가 setMemory() 메소드를 실행할 수 없도록 했습니다.

// 동기화 메소드로 수정된 공유 객체
public class Calculator {

    private int memory;

    public int getMemory() {
        return memory;
    }

    public synchronized void setMemory(int memory) {
        this.memory = memory;

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

이제 실행을 하면 User1은 100, User2는 50이라는 출력값을 얻습니다.
아래처럼 동기화 블록으로도 만들 수 있습니다.

public void setMemory(int memory) {
    synchronized(this){
        this.memory = memory;

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

스레드가 동기화 블록으로 들어가면 this(Calculator 객체)를 잠그고, 동기화 블록을 실행합니다. 동기화 블록을 모두 실행할 때까지 다른 스레드들은 this(Calculator 객체)의 모든 동기화 메소드 또는 동기화 블록을 실행할 수 없게 됩니다.

'SpringFramework > JAVA' 카테고리의 다른 글

프록시 패턴 예제  (0) 2020.02.15
객체지향 프로그래밍  (0) 2020.01.01
스트림 메소드 2편  (0) 2019.12.05
스트림 처리 메소드 1편  (0) 2019.12.04
람다식을 통한 메소드 참조  (0) 2019.12.03

mapXXX() 메소드

mapXXX() 메소드는 요소를 대체하는 요소로 구성된 새로운 스트림을 리턴합니다.
아래 예제 코드를 통해서 이름, 점수를 인스턴스 변수로 가지고 있는 Student 객체를 타입 파라미터로 가지고 있는 리스트 객체에서 학생의 점수를 요소로 하는 새토룬 스트림을 생성하고 점수를 순차적으로 콘솔에 출력해보겠습니다.

import java.util.Arrays;
import java.util.List;

public class MapExample {

    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
                new Student("홍길동", 90),
                new Student("신용권", 40),
                new Student("유미선", 50)
        );


        students.stream()
                .map(Student::getScore)
                .forEach(System.out :: println);
    }
}

asDoubleStream(), asLongStream(), boxed() 메소드

asDoubleStream() 메소드는 IntStream의 int 요소 또는 LongStream의 long 요소를 double 요소로 타입 변환해서 DoubleStream을 생성합니다. 마찬가지로 asLongStream() 메소드는 IntStream의 int 요소를 long 요소로 타입 변환해서 LongStream을 생성합니다. boxed() 메소드는 int, long, double 요소를 Integer, Long, Double 요소로 박싱해서 Stream을 생성합니다.

import java.util.Arrays;
import java.util.stream.IntStream;

public class AsDoubleStreamBoxedExample {
    public static void main(String[] args) {
        int[] intArray = {1, 2, 3, 4, 5};

        IntStream intStream = Arrays.stream(intArray);

        intStream
                .asDoubleStream()
                .forEach(System.out :: println);

        System.out.println();

        // 스트림은 한번 사용후에 재사용이 불가능 하기 때문에 다시 사용하려면 스트림 객체를 생성해줘야 합니다.
        intStream = Arrays.stream(intArray);

        intStream
                .boxed()
                .forEach(obj -> System.out.println(obj.intValue()));

    }
}

정렬(sorted())

스트림은 요소가 최종 처리되기 전에 중간 단계에서 요소를 정렬해서 최종 처리 순서를 변경할 수 있습니다.

객체 요소일 경우에는 클래스가 Comparable을 구현하지 않으면 sorted() 메소드를 호출했을때 ClassCastException이 발생하기 때문에 Comparable을 구현한 요소에만 sorted() 메소드를 호출해야 합니다. 다음은 점수를 기준으로 Student 요소를 오름차순으로 정렬하기 위해 Comparable을 구현해야 합니다.

public class Student implements Comparable<Student> {

    private String name;
    private int score;

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

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getScore() {
        return score;
    }

    public void setScore(int score) {
        this.score = score;
    }

    @Override
    public int compareTo(Student obj) {
        return Integer.compare(score, obj.score);
    }
}

객체 요소가 Comparable을 구현한 상태에서 기본 비교방법으로 정렬하고 싶다면 다음 세가지 방법 중 하나를 선택해서 sorted()를 호출하면 됩니다.

sorted();
sorted((a,b) -> a.compareTo(b));
sorted( Comparator.naturalOrder());

만약 객체 요소가 Comparable을 구현하고 있지만, 기본 비교 방법과 정반대 방법으로 정렬하고 싶다면 다음과 같이 sorted()를 호출하면 됩니다.

sorted( (a,b) -> b.compareTo(a));
sorted( Comparator.reverseOrder());

중괄호 {} 안에는 a와 b를 비교해서 a가 작으면 음수, 같으면 0, a가 크면 양수를 리턴하는 코드를 작성하면 됩니다.

// 정렬
public class SortingExample {

    public static void main(String[] args) {

        IntStream intStream = Arrays.stream(new int[] {5, 3, 2, 1, 4});

        //오름 차순으로 정렬하는 중간 스트림 생성 후 최종처리에서 출력합니다.
        intStream
                .sorted()
                .forEach(n -> System.out.println(n + ","));
        System.out.println();

        List<Student> studentList = Arrays.asList(
                new Student("홍길동", 30),
                new Student("신용권", 10),
                new Student("유미선", 20)
        );

        // 정수를 기준으로 오름차순으로 Student 정렬
        studentList.stream()
                .sorted()
                .forEach(s -> System.out.print(s.getScore() + ","));

        System.out.println();

        // 정수를 기준으로 내림차순으로 Student 정렬
        studentList.stream()
                .sorted(Comparator.reverseOrder())
                .forEach(s -> System.out.print(s.getScore() + ","));

    }
}

루핑(peek(), forEach())

루핑은 요소 전체를 반복하는 것을 말합니다. 루핑하는 메소드에는 peek(), forEach()가 있습니다. 이 두 메소드는 루핑한다는 기능에서는 동일하지만, 동작 방식은 다릅니다. peek()은 중간 처리 메소드이고, forEach()는 최종 처리 메소드입니다.

peek()는 중간 처리 단계에서 전체 요소를 루핑하면서 추가적인 작업을 하기 위해 사용합니다. 최종 처리 메소드가 실행되지 않으면 지연되기 때문에 반드시 최종 처리 메소드가 호출되어야 동작합니다.

예를 들어 필터링 후 어떤 요소만 남아있는지 확인하기 위해 다음과 같이 peek()를 마지막에서 호출할 경우, 스트림은 전혀 동작하지 않습니다.

요소 처리의 최종 단계가 합을 구하는 것이라면, peek() 메소드 호출 후 sum()을 호출해야만 peek()가 정상적으로 동작합니다.

intStream
    .filter(a -> a%2 == 0)
    .peek(a -> Systemp.out.println(a))
    .sum()

하지만 forEach()는 최종 처리 메소드이기 때문에 파이프라인 마지막에 루핑하면서 요소를 하나씩 처리합니다. forEach()는 요소를 소비하는 최종 처리 메소드이므로 이후에 sum()과 같은 다른 최종 메소드를 호출하면 안됩니다.

public class LoopingExample {
    public static void main(String[] args) {

        int[] intArr = {1, 2, 3, 4, 5};

        System.out.println("[peek()를 마지막에 호출한 경우]");

        Arrays.stream(intArr)
                .filter(a -> a % 2 == 0)
                .peek(n -> System.out.println(n)); // 동작하지 않습니다.


        System.out.println("[최종처리 메소드를 마지막에 호출한 경우]");

        int total = Arrays.stream(intArr)
                .filter(a -> a % 2 == 0)
                .peek(n -> System.out.println(n)) // 동작함
                .sum();                           // 최종 메소드

        System.out.println("총합: " + total);



        Arrays.stream(intArr)
                .filter(a -> a % 2 == 0)
                .forEach(n -> System.out.println(n)); // 최종 메소드로 동작합니다.

    }
}

매칭(allMatch(), anyMatch(), noneMatch())

스트림 클래스는 최종 처리 단계에서 요소들이 특정 조건에 만족하는지 조사할 수 있도록 세가지 메소드를 제공하고 있습니다. allMatch() 메소드는 모든 요소들이 매개값으로 주어진 Predicate의 조건을 만족하는지 조사하고, anyMatch() 메소드는 최소한 한 개의 요소가 매개 값으로 주어진 Predicate의 조건을 만족하는지 조사합니다. 그리고 noneMatch()는 모든 요소들이 매개값으로 주어진 Predicate의 조건을 만족하지 않는지 조사합니다.

스크린샷 2019-12-15 오후 11 25 28

public class MatchExample {
    public static void main(String[] args) {

        int[] intArr = {2, 4, 6};

        boolean result = Arrays.stream(intArr)
                .allMatch(a -> a%2==0);

        System.out.println("모두 2의 배수인가?"+ result);

        result = Arrays.stream(intArr)
                .anyMatch(a -> a%3==0);
        System.out.println("하나라도 3의 배수가 있는가? " + result);

        result = Arrays.stream(intArr)
                .noneMatch(a -> a%3==0);
        System.out.println("3의 배수가 없는가? " + result);

    }
}

기본 집계(sum(), count(), average(), max(), min())

집계(Aggregate)는 최종 처리 기능으로 요소들을 처리해서 카운팅, 합계, 평균값, 최대값, 최소값 등과 같이 하나의 값으로 산출하는 것을 말합니다. 집계는 대량의 데이터들을 가공해서 축소하는 리덕션이라고 볼 수 있습니다.

스트림이 제공하는 기본 집계

스크린샷 2019-12-15 오후 11 25 39

이 집계 메소드에서 리턴하는 OptionalXXX는 자바 8에서 추가한 java.util 패키지의 Optional, OptionalDouble, OptionalInt, OptionalLong 클래스 타입을 말합니다. 이들은 값을 저장하는 값 기반 클래스들입니다. 이 객체에서 값을 얻기 위해서는 get(), getAsDouble(), getAsInt(), getAsLong()를 호출하면 됩니다.

public class AggregateExample {
    public static void main(String[] args) {
        long count = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .count();

        System.out.println("2의 배수 개수: " + count);

        long sum = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .sum();

        System.out.println("2의 배수의 합: " + sum);


        double avg = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .average()
                .getAsDouble();

        System.out.println("2의 배수의 평균: " + avg);


        int max = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .max()
                .getAsInt();

        System.out.println("2의 배수의 최대 값: " + max);


        int min = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 2 == 0)
                .min()
                .getAsInt();

        System.out.println("2의 배수의 최소 값: " + min);


        int first = Arrays.stream(new int[]{1, 2, 3, 4, 5})
                .filter(n -> n % 3 == 0)
                .findFirst()
                .getAsInt();


        System.out.println("첫번째 3의 배수: " + first);
    }
}

실행 결과

스크린샷 2019-12-04 오후 11 05 00

Optional 클래스

Optional, OptionalDouble, OptionalInt, OptionalLong 클래스에 대해서 좀 더 알아보기로 하자. 이 클래스들은 저장하는 값의 타입만 다를 뿐 제공하는 기능은 거의 동일합니다. Optional 클래스는 단순히 집계 값만 저장하는 것이 아니라, 집계 값이 존재하지 않을 경우 디폴트 값을 설정할 수도 있고, 집계 값을 처리하는 Consumer도 등록할 수 있습니다. 다음은 Optional 클래스들이 제공하는 메소드들입니다.

스크린샷 2019-12-15 오후 11 25 52

컬렉션의 요소는 동적으로 추가되는 경우가 많습니다. 만약 컬렉션의 요소가 추가되지 않아 저장된 요소가 없을 경우 요소가 없기 때문에 평균값도 있을 수 없습니다.

List<Integer> list = new ArrayList<>();

double avg = list.stream()
                .mapToInt(Integer::intValue)
                .average()
                .getAsDouble();
        System.out.println("평균: " + avg);

그렇기 때문에 NoSuchElementException 예외가 발생합니다. 요소가 없을 경우 예외를 피하는 세 가지 방법이 있습니다. 첫 번째는 Optional 객체를 얻어 isPresent()메소드로 평균값 여부를 확인하는 것입니다. isPresent() 메소드가 true를 리턴할 때만 getAsDouble() 메소드로 평균값을 얻으면 됩니다.

OptionalDouble optional = list.stream()
                .mapToInt(Integer::intValue)
                .average();

    if(optional.isPresent()){
        System.out.println("평균: " + optional.getAsDouble());
    }else {
        System.out.println("평균: 0.0");
    }

두 번째 방법은 orElse() 메소드로 디폴트 값을 정해 놓습니다. 평균값을 구할 수 없는 경우에는 orElse()의 매개값이 디폴트 값이 됩니다.

double avg = list.stream()
                .mapToInt(Integer::intValue)
                .average()
                .orElse(0.0);
System.out.println("평균: " + avg);

세 번째 방법은 ifPresent() 메소드로 평균값이 있을 경우에만 값을 이용하는 람다식을 실행합니다.

list.stream()
        .mapToInt(Integer::intValue)
        .average()
        .ifPresent(a -> System.out.println("평군: " + a));

수집(collect())

스트림은 요소들을 필터링 또는 매핑한 후 요소들을 수집하는 최종 처리 메소드인 collect()를 제공하고 있습니다. 이 메소드를 이용하면 필요한 요소만 컬렉션으로 담을 수 있고, 요소들을 그룹핑한 후 집계할 수 있습니다.

필터링한 요소 수집

Stream의 collect(Collector<T,A,R> collector) 메소드는 필터링 또는 매핑된 요소들을 새로운 컬렉션에 수집하고, 이 컬렉션을 리턴합니다.

스크린샷 2019-12-15 오후 11 26 03

매개값인 Collector(수집기)는 어떤 요소를 어떤 컬렉션에 수집할 것인지를 결정합니다. Collector의 타입 파라미터 T는 요소이고 A는 누적기이고, 그리고 R은 요소가 저장될 컬렉션입니다. 풀어서 해석하면 T요소를 A누적기가 R에 저장한다는 의미입니다. Collector의 구현 객체는 다음과 같이 Collectors 클래스의 다양한 정적 메소드를 이용해서 얻을 수 있습니다.

스크린샷 2019-12-15 오후 11 26 14

리턴값인 Collector를 보면 A(누적기)가 ?로 되어 있는데, 이것은 Collector가 R(컬렉션)에 T(요소)를 저장하는 방법을 알고 있어 A(누적기)가 필요 없기 때문입니다.
Map과 ConcurrentMap의 차이점은 Map는 스레드에 안전하지 않고, ConcurrentMap은 스레드에 안전합니다. 멀티 스레드 환경에서 사용하려면 ConcurrentMap을 얻는 것이 좋습니다. 다음 코드는 전체 학생 중에서 남학생들만 필터링해서 별도의 List로 생성합니다.

// 전체 학생 List에서 Stream을 얻습니다.
Stream<Student> totalStream = totalList.stream();
//남학생만 필터링해서 Stream을 얻습니다.
Stream<Student> maleStream = totalStream.filter(s -> s.getSex() == Student.Sex.MALE);
//List에 Student를 수집하는 Collector를 얻습니다.
Collector<Student, ?, List<Student>> collector = Collectors.toList();
// Stream에서 collect() 메소드로 Student를 수집해 새로운 List를 얻습니다.
List<Student> maleList = maleStream.collect(collector);

위의 코드를 아래와 같이 간단하게 작성할 수 있습니다.

List<Student> maleList = totalList.stream()
        .filter(s -> s.getSex() == Student.Sex.MALE)
        .collect(Collectors.toList());

다음 코드는 전체 학생 중에서 여학생들만 필터링해서 별도의 HashSet으로 생성합니다.

// 전체 학생 List에서 Stream을 얻습니다.
Stream<Student> totalStream = totalList.stream();
// 여학생만 필터링해서 Stream을 얻습니다.
Stream<Student> femaleStream = totalStream.filter(s -> s.getSex()== Student.Sex.FEMALE);
// 새로운 HashSet을 공급하는 Supplier를 얻습니다. 
Supplier<HashSet<Student>> supplier = HashSet :: new;
// Supplier가 공급하는 HashSet에 Student를 수집하는 Collector를 얻습니다.
Collector<Student, ? , HashSet<Student>> collector = 
Collectors.toCollection(supplier);
// Stream에서 collect() 메소드로 Student를 수집해서 새로운 HashSet을 얻습니다.
Set<Student> femaleSet = femaleStream.collect(collector);

위의 코드도 마찬가지로 아래처럼 간단하게 작성이 가능합니다.

Set<Student> femaleSet = totalList.stream()
        .filter(s -> s.getSex() == Student.Sex.FEMALE)
        .collect(Collectors.toCollection(HashSet :: new));
// 필터링해서 새로운 컬렉션 생성 예제
public class StudyMember {

    public enum Sex { MALE, FEMALE }
    public enum City { Seoul, Pusan}


    private String name;
    private int score;
    private Sex sex;
    private City city;


    public StudyMember(String name, int score, Sex sex) {
        this.name = name;
        this.score = score;
        this.sex = sex;
    }

    public StudyMember(String name, int score, Sex sex, City city) {
        this.name = name;
        this.score = score;
        this.sex = sex;
        this.city = city;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    public Sex getSex() {
        return sex;
    }

    public City getCity() {
        return city;
    }
}

public class ToListExample {
    public static void main(String[] args) {
        List<StudyMember> totalList = Arrays.asList(
                new StudyMember("홍길동", 10, StudyMember.Sex.MALE),
                new StudyMember("김수애", 6, StudyMember.Sex.FEMALE),
                new StudyMember("신용권", 10, StudyMember.Sex.MALE),
                new StudyMember("박수미", 6, StudyMember.Sex.FEMALE)
        );

        //남학생들만 묶어 List 생성
        List<StudyMember> maleList = totalList.stream()
                .filter(s -> s.getSex() == StudyMember.Sex.MALE)
                .collect(Collectors.toList());

        maleList.stream()
                .forEach(s -> System.out.println(s.getName()));



        // 여학생들만 묶어 HashSet 생성
        Set<StudyMember> femaleList = totalList.stream()
                .filter(s -> s.getSex() == StudyMember.Sex.FEMALE)
                .collect(Collectors.toCollection(HashSet::new));
        femaleList.forEach(s -> System.out.println(s.getName()));
    }
}

요소를 그룹핑해서 수집

collect() 메소드는 단순히 요소를 수집하는 기능 이외에 컬렉션의 요소들을 그룹핑해서 Map객체를 생성하는 기능도 제공합니다. collect()를 호출할 때 Collectors의 groupingBy() 또는 groupingByConcurrent()가 리턴하는 Collector를 매개값으로 대입하면 됩니다. groupingBy()는 스레드에 안전하지 않는 Map을 생성하지만, groupingByConcurrent()는 스레드에 안전한 ConcurrentMap을 생성합니다.

아래 코드는 학생들을 성별로 그룹핑하고 나서, 같은 그룹에 속하는 학생 List를 생성한 후, 성별을 키로, 학생 List를 값으로 갖는 Map을 생성합니다. collect()의 매개값으로 groupingBy(Function<T,K> classifier)를 사용하였습니다.

// 전체 학생 List에서 Stream을 얻습니다.
Stream<Student> totalStream = totalList.stream();
// Student를 Student.Sex로 매핑하는 Function을 얻습니다.
Function<Student, Student.Sex> classifier = Student :: getSex;
// Student.Sex가 키가 되고,  그룹핑된 List<Student>가 값인 Map을 생성하는 Collector를 얻습니다.
Collector<Student, ?, Map<Student.Sex, List<Student>>> collector = Collectors.groupingBy(classifier);
// Stream의 collect() 메소드로 Student를 Student.Sex 별로 그룹핑해서 Map을 얻습니다.
Map<Student.Sex, List<Student>> mapBySex = totalStream.collect(Collector);

또 다른 코드는 학생들을 거주 도시별로 그룹핑하고 나서, 같은 그룹에 속하는 학생들의 이름 List를 생성한 후, 거주 도시를 키로, 이름 List를 값으로 갖는 Map을 생성합니다. collect()의 매개값으로 groupingBy(Function<T,K> classifier, Collector<T,A,D> collector)를 사용하였습니다.

// 전체 학생 List에서 Stream을 얻습니다.
Stream<Student> totalStream = totalList.stream();
// Student를 Student.City로 매핑하는 Function을 얻습니다.
Function<Student, Student.City> classifier = Student :: getCity;

// Student의 이름을 List에 수집하는 Collector를 얻습니다.
Function<Student, String> mapper = Student :: getName;

Collector<String, ?, List<String>> collector1 = Collectors.toList();
// Collectors의 mapping() 메소드로 Student를 이름으로 매핑하고 이름을 List에 수집하는 Collector를 얻습니다.
Collector<Student, ?, List<String>> collector2 = Collectors.mapping(mapper, collector1);
// Student.City가 키이고, 그룹핑된 이름 List가 값인 Map을 생성하는 Collector를 얻습니다.
Collector<Student, ?, Map<Student.City, List<String>>> collector3 = Collectors.groupingBy(classifier, collector2);
// Stream의 collect() 메소드로 Student를 Student.City별로 그룹핑해서 Map을 얻습니다. 
Map<Student.City, List<String>> mapByCity = totalStream.collect(collector3);

위의 상기 코드에서 변수를 생략하면 다음과 같이 간단하게 작성이 가능합니다.

Map<Student.City, List<String>> mapByCity = totalList.stream()
        .collect(Collectors.groupingBy(
            Student :: getCity,
            Collectors.mapping(Student::getName, Collectors.toList())
        )
);

다음 코드는 위와 동일하지만, TreeMap 객체를 생성하도록 groupingBy(Function<T, K> classifier, Supplier<Map<K,D>> mapFactory, Collector<T,A,D> collector)를 사용했습니다.

Map<Student.City, List<String>> mapByCity = totalList.stream()
        .collect(Collectors.groupingBy(
            Student :: getCity,
            TreeMap :: new,
            Collectors.mapping(Student::getName, Collectors.toList())
        )    
);
//성별로 그룹핑하기
public class GroupingByExample {
    public static void main(String[] args) {
        List<StudyMember> totalList = Arrays.asList(
                new StudyMember("홍길동", 10, StudyMember.Sex.MALE, StudyMember.City.Seoul),
                new StudyMember("김수애", 6, StudyMember.Sex.FEMALE, StudyMember.City.Pusan),
                new StudyMember("신용권", 10, StudyMember.Sex.MALE, StudyMember.City.Pusan),
                new StudyMember("박수미", 6, StudyMember.Sex.FEMALE, StudyMember.City.Seoul)
        );

        Map<StudyMember.Sex, List<StudyMember>> mapBySex = totalList.stream()
                                                                    .collect(
                                                                            Collectors.groupingBy(
                                                                                    StudyMember :: getSex
                                                                            )
                                                                    );
        System.out.print("[남학생] ");
        mapBySex.get(StudyMember.Sex.MALE).stream()
                .forEach(s -> System.out.print(s.getName() + " "));

        System.out.print("\n[여학생] ");
        mapBySex.get(StudyMember.Sex.FEMALE).stream()
                .forEach(s -> System.out.print(s.getName() + " "));


        System.out.println();

        Map<StudyMember.City, List<String>> mapByCity = totalList.stream()
                                                                 .collect(
                                                                        Collectors.groupingBy(
                                                                            StudyMember :: getCity,
                                                                            Collectors.mapping(StudyMember :: getName, Collectors.toList())
                                                                        )
                                                                 );

        System.out.print("\n[서울] ");
        mapByCity.get(StudyMember.City.Seoul).stream()
                .forEach(s -> System.out.print(s + " "));
        System.out.print("\n[부산] ");
        mapByCity.get(StudyMember.City.Pusan).stream()
                .forEach(s -> System.out.print(s + " "));
    }
}

실행 결과

스크린샷 2019-12-05 오전 3 00 26

참조: 이것이 자바다

'SpringFramework > JAVA' 카테고리의 다른 글

객체지향 프로그래밍  (0) 2020.01.01
멀티 스레드의 개념  (0) 2019.12.30
스트림 처리 메소드 1편  (0) 2019.12.04
람다식을 통한 메소드 참조  (0) 2019.12.03
제네릭을 사용하는 이유?  (1) 2019.12.03

+ Recent posts