카테고리 없음

java 8 예제 코드

JUNGKEUNG 2024. 8. 31. 20:19
반응형

주요 변경사항

Java 8의 주요 변경 사항은 아래와 같다.

 

  1. Lambda expression(람다 표현식)
  2. Functional interface(함수형 인터페이스)
  3. Default method(디폴트 메서드)
  4. Stream(스트림)
  5. Optional(옵셔널)

 

 

1. Lambda expression(람다 표현식)

메서드로 전달할 수 있는 Anonymous function(익명 함수)를 단순한 문법으로 표기한 것을 람다 표현식이라고 한다. 글로는 설명이 어려우니 바로 코드를 보자. Thread 객체를 생성하여 동작시키는 코드다.

 

위 두 코드는 동일한 결과이지만 문법만 다르게 사용했을 뿐이다. 이번에는 자세히 람다 표현식의 구성을 살펴보자.

// 익명 클래스로 Runnalbe을 구현
Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("Start to new thread!");
    }
});

thread.start();


// 람다 표현식으로 단순하게 표현
Thread thread = new Thread(() -> System.out.println("Start to new thread!"));
        
thread.start();

 

람다 표현식의 구성

람다 표현식은 람다 파라미터와 화살표, 람다 바디로 구성되어 있다.

위 예제에서는 '()'가 람다 파라미터, '->'가 화살표, 'System.out.println("Start to new thread!")'가 람다 바디에 해당한다.

 

동작하는 방법은 기본적으로 함수라고 생각하면 이해하기 쉽다. 복잡한 정의 말고 기능으로써만 보면 함수와 람다 표현식은 다음과 같다.

 

어떤 값이 들어가면(람다 파라미터) 결과물이 나온다.(람다 바디)

 

몇 가지 람다 표현식을 살펴보면서 어떻게 함수처럼 동작하는지 살펴보자.

 

1. 하나의 파라미터를 갖고 리턴 타입이 없는 람다 표현식

(String str) -> System.out.println("parameter is " + str);

String 타입의 파라미터 str을 받아서 출력문을 실행하는 람다 표현식이다. 람다 파라미터의 타입은 생략이 가능 가며 컴파일러가 문맥에 맞게 해석하여 알맞은 객체를 선택하게 된다. 거기다 파라미터가 한 개인 경우에는 괄호를 생략할 수 있다.

따라서 아래와 같은 문법도 사용 가능하다.

// 타입을 생략한 코드
(str) -> System.out.println("parameter is " + str);

// 람다 파라미터 괄호를 생략한 코드
str -> System.out.println("parameter is " + str);

 

2. 하나의 파라미터를 갖고 리턴 타입이 있는 람다 표현식

// 람다 바디를 하나의 라인으로 표현
(i) -> i + 10;

// 람다 바디를 여러 라인으로 표현
(i) -> {
    int result = i + 10;
    return result;
};

Integer 타입의 파라미터 i를 받아서 10을 더한 값을 반환하는 람다 표현식이다. 만약 람다 바디가 짧아서 한 줄로 끝난다면 중괄호와 return 키워드를 생략할 수 있다. 두 줄 이상이 된다면 명시적으로 중괄호와 return 키워드를 선언해야 한다.

 

 

2. Functional interface(함수형 인터페이스)

단 하나의 추상 메서드를 갖는 인터페이스 함수형 인터페이스라고 한다. 앞서 예제로 사용했던 Runnable 인터페이스는 추상 메서드 run() 하나만 있기 때문에 함수형 인터페이스라고 할 수 있다. Java 8부터는 함수형 인터페이스를 컴파일 시점에도 확인할 수 있도록 @FunctionalInterface 애노테이션도 제공한다. 간단한 함수형 인터페이스를 만들어보고 왜 이런 형태를 함수형 인터페이스라고 부르는지 알아보자.

 

 

함수형 인터페이스 만들기

앞으로 자주 사용하게 될 Car 인터페이스를 함수형 인터페이스로 만들어보자. 

@FunctionalInterface
public interface Car {
    String drive(int driveLevel);
}

 

물론 @FunctionalInterface가 없어도 Car는 함수형 인터페이스이다. 어디까지나 컴파일 시점에 해당 인터페이스가 규칙을 잘 지키고 있는지 검증하는 역할을 도와줄 뿐이다.(여담으로 Retention은 런타임으로 설정되어 있다.)

 

그럼 전통적인 방법으로 인터페이스를 사용해보자.

Car car = new Car() {
    @Override
    public String drive(int driveLevel) {
        return driveLevel == 0 ? "" : "자동차가 " + driveLevel + " 의 속도로 이동합니다.";
    }
};

System.out.println(car.drive(10));

 

특별함 없는 익명 클래스 구현 코드다. 람다 표현식을 설명할 때 설명하지 않은 부분이 있는데 람다 표현식은 함수형 인터페이스를 간결하게 표현할 수 있다. 아래 코드는 위의 코드와 동일하게 동작하지만 람다 표현식으로 간결하게 표현하였다.

Car car = (i) -> i == 0 ? "" : "자동차가 " + i + " 의 속도로 이동합니다.";
System.out.println(car.drive(0));

 

한결 코드가 보기 편해졌다. 실제로 Java 8에서 람다 표현식은 함수형 인터페이스를 위해 등장했다 해도 과언이 아니다. 파라미터 X가 여러 함수형 인터페이스를 거쳐 최종적인 값 Y의 형태로 변경할 수 있기 때문이다. 바로 함수형 프로그래밍이 Java에도 등장하게 되었단 뜻이다.

 

함수형 프로그래밍

이 그럼을 눈에 익혀 둔다면 앞으로 등장하게 되는 Method chaining(메서드 체이닝) 형태의 프로그래밍이 익숙하게 느껴질 것이다. 

 

 

3. Default method(디폴트 메서드)

Java 8부터 인터페이스에서 구현된 메서드를 만들 수 있다! 바로 샘플 코드를 보자.

public interface SampleInterface {
    // abstract method
    String returnHello(String msg);

    // default method
    default void hello(String msg) {
        System.out.println("hello " + msg);
    }
}

 

default 키워드를 사용하여 간단하게 구현할 수 있다. 그럼 어떻게 실무에서 활용할 수 있을까? 간단한 요구사항을 가정해보자.

 

 

코드 호환성을 유지하면서 새로운 기능 추가

Car의 구현체별로 fly()를 구현하는 방법도 있지만 언제 변경될지 모르는 한시적인 이벤트에서 좋은 방법은 아닌 것 같다. 이럴 때 기존의 코드의 변경 없이 디폴트 메서드로 fly()를 만들 수 있다.

public interface Car {
    // 전진
    void drive();
    
    // 후진    
    void reverse();
    
    // 날기
    default void fly() {
        System.out.println("Fly to the moon");
    }
}

 

이처럼 디폴드 메서드는 기존의 인터페이스 구현체들의 변경 없이 공통적인 기능을 제공할 때 사용된다.

 

 

불필요한 구현부 제거

인터페이스를 구현하다보면 사용하지 않는 메서드를 빈 상태로 구현하는 경험이 종종 있었을 것이다. 가령 위 Car 인터페이스의 구현체인 HorseCar는 후진 기능이 필요 없다. 따라서 불필요하지만 비어있는 메서드를 구현해줘야 한다.

public class HorseCar{
    public void drive() {
        System.out.println("마차가 앞으로 갑니다.");
    }
    
    public void reverse() {
        
    }
}

 

이런 경우에도 디폴트 메서드를 이용하면 불필요한 코드를 줄일 수 있다. 디폴트 메서드를 사용한 Car 인터페이스를 보자.

public interface Car {
    // 전진
    void drive();
    
    // 후진    
    default void reverse() {
    
    }
    
    // 날기
    default void fly() {
        System.out.println("Fly to the moon");
    }
}

 

reverse()를 빈 상태로 디폴트 메서드로 선언한다면 필요한 구현체에서만 Override 하여 구현할 수 있다. 이런 방법 역시 기존의 코드 호환성의 변화 없이 적용할 수 있는 방법이다. 다만 이 방법은 인터페이스가 갖는 추상 메서드 구현의 강제성이 사라지므로 동료들에게 왜 이렇게 했는지 꼭 인지시켜야 한다.

 

 

4. Stream(스트림)

Java 8이 등장하기 전부터 많은 관심을 받았고, 등장 이후에도 가장 사랑받는 기능이 아닐까 싶다. 스트림 Collection(컬렉션)을 멋지고 편리하게 처리하는 방법을 제공하는 API이다. 별다른 노력 없이 병렬 처리도 제공해주며, 마치 데이터베이스 쿼리를 작성하듯 직관적인 코드를 제공해준다. 요구사항을 통해 스트림의 기능을 살펴보자.

 

 

기본적인 스트림 사용

먼저 스트림을 사용하지 않은 코드를 보겠다. 정렬을 먼저 한 이유는 니체가 작성한 책을 필터링 하고 정렬하려면 새로운 List객체를 만들어 담는 과정이 필요하기 때문이다. (물론 이 코드는 불필요한 정보까지 정렬하기 때문에 성능은 매우 안 좋다)

books.sort(Comparator.comparing(Book::getName));

List<String> booksWrittenByNietzsche = new ArrayList<>();
for (Book book : books) {
    if (book.getAuthor().equals("Friedrich Nietzsche")) {
        booksWrittenByNietzsche.add(book.getIsbn());
    }
}

 

아직은 괜찮은 코드로 보인다. 하지만 이틀 정도 지나고 나서 이 코드를 보고 어떤 요구사항을 구현한 코드인지 알 수 있을까 의문이 든다. 코드를 분석하는 시간이 늘어난다는 뜻으로 생각해도 괜찮다. 그럼 이번에는 스트림의 기능을 적극적으로 사용하여 요구사항을 구현해보겠다.

List<String> booksWrittenByNietzsche = 
            books.stream()
                .filter(book -> book.getAuthor().equals("Friedrich Nietzsche"))
                .sorted(Comparator.comparing(Book::getName))
                .map(Book::getIsbn)
                .collect(Collectors.toList());

 

스트림의 기능을 잘 몰라도 이 코드가 어떤 요구사항을 구현했는지 느낌이 바로 올 것이다. 한 달이 지나고 이 코드를 봐도 의미를 해석하는데 큰 무리가 없다. 그럼 어디서 이런 차이를 만들까? 그것은 바로 for문과 if문이 눈에 보이지 않기 때문이다. 두 문법은 꼭 필요하지만 잘 작성되지 않으면 의미를 해석하는데 많은 어려움을 만든다. 그래서 스트림은 컬랙션에서 자주 사용되는 기능을 미리 제공하여 개발자가 불필요한 for문과 if문을 사용하지 않도록 돕는다.

 

5. Optional(옵셔널)

Optional은 Java가 가지고 있는 null의 문제점을 보완하고자 등장하였다. 기본 콘셉트는 Integer나 Double과 같은 Wrapper class로서 객체를 바로 호출하지 않고 Optional 안에서 호출함으로써 null이 발생할 가능성을 봉쇄시킨다. 그럼 요구사항의 기능을 개발하면서 Optional 객체가 어떻게 실무에 적용이 될 수 있는지 예제 코드를 살펴보도록 하겠다.

 

전통적인 null 처리

public class BookService {
    // 파라미터로 Book 객체를 받는다.
    // Book 객체가 가진 Author객체의 getName()으로 저자의 이름을 반환한다.
    public String getAuthorName(Book book) {
        if (book == null) {
            throw new NullPointerException("This book is null");
        }
        
        Author author = book.getAuthor();
        return author.getName();
    }
}

 

이 코드에는 몇 가지 문제점이 있다.

첫 번째는 Book 객체가 가진 getAuthor()의 결괏값이 null 일 가능성이 있다. 그래서 Author 객체에 대한 null 값을 확인하는 방어 코드를 추가해야 한다.

// book.getAuthor()에서 null이 반환된다면 author.getName() 코드에서 NPE가 발생한다.
Author author = book.getAuthor();

if (author == null) {
    throw new NullPointerException("This author is null");
}

 

두 번째는 이 메서드가 null을 반환할 수 있다는 것이다. BookService의 getAuthorName()을 호출하는 객체는 이 결괏값이 null인지 아닌지 신뢰할 수 없어 NPE을 방어하는 코드를 추가해야 한다.

// 이전 코드 생략...

String authorName = bookService.getAuthorName(book);

// 리턴 타입이 null이 아니라는 확신이 없으므로 확인 로직을 추가해야한다.
if (authorName == null) {
    throw new NullPointerException("This author name is null");
}

 

마지막으로 getAuthorName() 메서드가 어떤 비즈니스 로직을 수행하고 있는지 한눈에 파악이 힘들다. 이렇게 간단한 예제에서도 NPE를 막기 위한 방어 로직이 여러 곳에 흩어져 있다. 그래서 소스코드가 어떤 기능을 수행하는지 분석할 때  많은 시간이 필요하게 된다.

 

그럼 Optianl을 통해 이 문제가 어떻게 해결이 되는지 살펴보자.

 

Optional을 활용한 null 처리

getAuthorName()은 아래와 같이 변경될 수 있다. 자세히 봐야 할 부분은 if를 통해 null을 확인하는 부분이 사라졌다는 것과 메서드의 반환 타입이 String -> Optional<String> 으로 변경되었다는 점이다.

public class BookService {
    // Optional을 사용하여 null에서 안전한 코드 작성하기
    public Optional<String> getAuthorName(Book book) {
        return Optional.ofNullable(book)
                .map(bookObject -> bookObject.getAuthor())
                .map(authorObject -> authorObject.getName());
    }
}

 

전통적인 null 처리의 세 가지 문제점이 어떻게 처리되었는지 하나씩 살펴보자.

 

  1. null을 방어하는 코드를 추가해야 한다
    -> Optional을 사용하여 null이 발생할 여지를 제거했다. 파라미터의 Book 객체가 null이어도 메서드 내부에서는 NPE가 발생하지 않고 비즈니스 로직 그대로 흘러갈 수 있다.
  2. getAuthorName()을 호출하는 곳에서 결괏값으로 null을 받을 수 있다.
    -> 근본적인 문제는 해결되지 않았다. 다만 리턴 타입을 Optional<String>으로 했기 때문에 메서드를 사용하는 개발자는 '빈 결괏값이 반환될 수 있다'라고 인지할 수 있다. 만약 null이 반환되지 않는 것이 확실한 경우에는 리턴 타입을 String으로 표기하여 호출하는 입장에서 별도의 null 방어 코드를 넣지 않아도 되게 할 수 있다.
  3. 비즈니스 코드와 null 방어 코드가 뒤섞여 분석하는데 오랜 시간이 걸린다.
    -> null의 방어 코드가 완전히 사라져 getAuthorName()이 어떤 로직을 수행하는지 한눈에 파악할 수 있다.

이제는 코드를 작성하다 NPE가 발생할 때마다 if문을 칠 필요가 없어졌다. 코드의 가독성과 안정성은 높이고 코드 분석 시간은 줄여 전체적인 프로젝트 수행능력을 향상할 수 있는 Optional을 익혀 적극적으로 사용해 볼 법하다.

 

그럼 주인공인 Optional의 주요 메서드를 간단히 살펴보고 마무리하겠다.

 

Optional의 메서드

먼저 객체를 Optional로 감싸는 메서드를 살펴보자. 이들은 static 메서드라는 특징이 있다.

  • of(T):Optional<T>
    -> 파라미터로 받은 객체를 Optional로 감싸 반환한다. 만약 파라미터가 null이면 NPE가 발생한다.

  • ofNullable(T):Optional<T>
    -> 기본적으로 of()와 동일하나 파라미터가 null이면 빈 Optional을 반환한다.

  • empty():Optional<T>
    -> 빈 Optional을 반환한다. Optional 객체의 중간 연산 중에 값이 null이 되면 내부적으로 이 메서드를 호출한다. 

 

다음으로는 Optional의 중간 연산이다. 중간 연산은 non static 메서드면서 동시에 반환 값이 Optional<T>라는 특징이 있다.

  • filter(Predicate<? super T> predicate):Optional<T>
    -> Stream API의 filter()와 동일하다. predicate의 조건에 맞는 값을 필터링한다.

  • map(Function<? super T, ? extends U> mapper):Optional<T>
    -> Stream API의 map()과 동일하다. Optional로 감싸진 객체를 다른 객체로 변경하도록 데이터 변경을 한다.

  • flatMap(Function<? super T, Optional<U>> mapper):Optional<T>
    -> Optional안의 Optional이 있는 이중 구조일 때, 단일 구조로 변경하여 map()의 기능을 수행할 수 있다.

 

이번에는 Optional의 종료 연산이다. 이들은 Optional에서 벗어나 값으로 반환되는 특징이 있다.

  • get():T
    -> Optional의 값을 반환한다. 만약 값이 빈 값이라면 NPE가 발생한다.

  • orElse(T):T
    -> get()과 동일한 기능을 수행하지만, 값이 비어있다면 파라미터로 받은 값으로 반환한다.

  • orElseGet(Supplier<? extends T> other):T
    -> get과 동일한 기능을 수행하지만, 값이 비어있다면 파라미터에서 제공하는 값을 반환한다.

  • orElseThrow(Supplier<? extends X> exceptionSupplier):T
    -> get과 동일한 기능을 수행하지만, 값이 비어있다면 파리미터에서 생성한 exception을 발생시킨다. 

 

마지막으로 분류가 되지 않는 기타 메서드들이다.

  • isPresent():boolean
    -> Optional의 값이 있다면 true, 비어있다면 false를 반환한다. 상태를 확인할 뿐 값에 어떤 영향도 미치지 않는다.

  • ifPresent(Consumer<? super T> consumer):void
    -> Optional의 값이 있다면 파라미터를 실행하고, 비어있다면 false를 반환한다.