`(int n) -> n + 9`는 도대체 무엇일까? | Runnable부터 Java 람다와 예외 처리까지

대현·5일 전

JAVA 와 Spring

목록 보기
6/6
post-thumbnail

(int n) -> n + 9는 도대체 무엇일까? | Runnable부터 Java 람다와 예외 처리까지

Java 문제를 풀다가 갑자기 이런 코드가 등장했다.

run((int n) -> n + 9)

처음 봤을 때 가장 먼저 든 생각은

->는 도대체 뭐지?

였다.

Java에서 메서드라면 보통 이렇게 생겼다.

int add(int n) {
    return n + 9;
}

그런데 문제에서는 메서드 이름도 없이

(int n) -> n + 9

라고 적혀 있었다.

더 복잡한 문제에서는 다음과 같은 코드까지 등장했다.

F f = (x) -> {
    if (x > 2) {
        throw new Exception();
    }

    return x * 2;
};

처음에는 이것이 F와 무슨 관계가 있는지, apply()는 어디로 사라졌는지, run(f)를 호출하면 무엇이 실행되는지 이해하기 어려웠다.

그런데 이 문제를 이해하려면 먼저 Java에서 동작 자체를 객체로 전달하는 방식부터 생각해볼 필요가 있었다.


1. 먼저 Runnable을 보자

Java에서 다음과 같은 코드를 볼 수 있다.

class Car implements Runnable {

    @Override
    public void run() {
        System.out.println("run");
    }
}

Runnable은 인터페이스다.

핵심적으로 실행할 작업을 run()이라는 메서드로 표현한다.

public interface Runnable {
    void run();
}

따라서

class Car implements Runnable

이라고 하면 Car는 Runnable이 요구하는

run()

을 구현해야 한다.

class Car implements Runnable {

    public void run() {
        System.out.println("run");
    }
}

즉 Car 객체는 단순히 자동차라는 데이터만 표현하는 것이 아니라 실행할 작업을 가진 객체로 사용할 수 있다.


2. 왜 Runnable 객체를 Thread에 전달할 수 있을까?

다음 코드를 보자.

Thread t = new Thread(new Car());

처음에는 Thread 생성자 안에 왜 Car 객체를 넣을 수 있는지 이상했다.

하지만 Car는

implements Runnable

을 했다.

따라서 Car 객체는 Runnable 타입으로 사용할 수 있다.

개념적으로

Runnable task = new Car();

가 가능한 것이다.

그리고 Thread는 실행할 작업을 Runnable 형태로 전달받을 수 있다.

new Car()
    ↓
Runnable 구현 객체
    ↓
Thread에 전달
    ↓
Thread가 실행할 작업을 가짐

그래서

Thread t = new Thread(new Car());

가 가능하다.


3. start()와 run()은 다르다

여기서 시험에서 자주 헷갈리는 부분이 하나 있다.

t.start();

와

t.run();

은 같지 않다.

start()는 새로운 스레드의 실행을 시작한다.

t.start()
   ↓
새로운 스레드 시작
   ↓
run() 실행

반면

t.run();

을 직접 호출하면 일반적인 메서드 호출처럼 동작한다.

즉

start()
→ 새로운 스레드의 실행을 시작

run()
→ run() 메서드를 직접 호출

이라고 구분해야 한다.

이 부분을 이해하고 나니 Runnable이 단순히 run()이라는 이름을 외우는 인터페이스가 아니라 실행할 동작을 하나의 객체로 표현하는 방식이라는 것이 조금씩 보이기 시작했다.

그리고 이 생각이 람다식으로 연결된다.


4. 함수형 인터페이스란?

다음 인터페이스가 있다고 하자.

interface F {

    int apply(int x) throws Exception;
}

F에는 구현해야 할 추상 메서드가 하나 있다.

int apply(int x) throws Exception;

이처럼 람다의 대상으로 사용할 수 있는 추상 메서드가 하나인 인터페이스를 함수형 인터페이스라고 한다.

여기서 중요한 것은 F를 구현하는 객체가 사실상 하나의 동작을 가지고 있다는 것이다.

F
└── apply(int x)

그렇다면 이 인터페이스를 구현하려면 원래 어떻게 해야 할까?


5. 클래스를 직접 만들어 구현할 수 있다

가장 익숙한 방법은 클래스를 하나 만드는 것이다.

class MyFunction implements F {

    @Override
    public int apply(int x) throws Exception {

        if (x > 2) {
            throw new Exception();
        }

        return x * 2;
    }
}

그리고

F f = new MyFunction();

이라고 하면 된다.

이제

f.apply(3);

을 호출하면 MyFunction에서 구현한 apply()가 실행된다.

하지만 이 동작 하나를 사용하기 위해 클래스를 따로 만드는 것이 번거로울 수도 있다.


6. 익명 클래스로도 만들 수 있다

별도의 클래스 이름이 필요하지 않다면 익명 클래스를 사용할 수도 있다.

F f = new F() {

    @Override
    public int apply(int x) throws Exception {

        if (x > 2) {
            throw new Exception();
        }

        return x * 2;
    }
};

별도의 MyFunction 클래스를 만들지는 않았지만 F의 apply()를 구현한 객체를 바로 만들었다.

그런데 이것도 상당히 길다.

우리가 정말 표현하고 싶은 핵심은 사실 다음 부분이다.

int apply(int x) throws Exception {

    if (x > 2) {
        throw new Exception();
    }

    return x * 2;
}

그리고 F에는 구현할 추상 메서드가 하나뿐이다.

그렇다면 Java 입장에서는 어떤 메서드를 구현하려는 것인지 이미 알 수 있다.

그래서 이 부분을 더 간결하게 표현할 수 있다.

그것이 람다식(Lambda Expression)이다.


7. 람다식으로 바꾸면

익명 클래스 형태의 다음 코드를

F f = new F() {

    @Override
    public int apply(int x) throws Exception {

        if (x > 2) {
            throw new Exception();
        }

        return x * 2;
    }
};

람다로 표현하면 다음과 같이 간단해진다.

F f = (x) -> {

    if (x > 2) {
        throw new Exception();
    }

    return x * 2;
};

처음에는 apply라는 이름이 사라져서 이상했다.

하지만 F에는 람다의 대상이 되는 추상 메서드가 하나뿐이다.

int apply(int x) throws Exception;

따라서 Java는 이 람다가 apply()에 해당하는 동작이라는 것을 알 수 있다.

개념적으로는

F f = (x) -> x * 2;

를 볼 때

F의 추상 메서드
        ↓
apply(int x)
        ↓
구현 내용
        ↓
return x * 2;

라고 연결해서 생각하면 된다.


8. (int n) -> n + 9는 어떻게 읽을까?

이제 처음에 이해되지 않았던 코드를 다시 보자.

(int n) -> n + 9

처음에는 문법을 세세하게 분석하기보다 다음처럼 읽는 것이 가장 쉬웠다.

int n을 받아서 n + 9를 반환한다.

즉

입력
 ↓
 n
 ↓
n + 9
 ↓
결과

다.

예를 들어

(int n) -> n * 2

라면

int n을 받아서 n의 2배를 반환한다.

이고,

(int n) -> n + 9

라면

int n을 받아서 9를 더한 값을 반환한다.

라고 읽으면 된다.


9. 한 줄짜리 람다는 return도 생략할 수 있다

다음 람다를 보자.

(int n) -> n + 9

여기에는 return이 없다.

하지만 값을 반환하는 표현식 하나로 구성된 람다는 결과를 그대로 반환하는 형태로 사용할 수 있다.

따라서 개념적으로

(int n) -> n + 9

는

(int n) -> {
    return n + 9;
}

와 연결해서 이해할 수 있다.

그리고 F의 apply()와 연결하면

int apply(int n) {
    return n + 9;
}

와 같은 동작이라고 생각할 수 있다.


10. 매개변수 타입도 생략되는 이유

다음 두 표현을 볼 수 있다.

(int n) -> n + 9
(n) -> n + 9

상황에 따라 두 번째처럼 타입이 생략될 수도 있다.

왜냐하면 람다가 어떤 함수형 인터페이스를 대상으로 하는지 알면 매개변수 타입도 알 수 있기 때문이다.

예를 들어

interface F {
    int apply(int x) throws Exception;
}

이라면 apply()의 매개변수는 이미

int

라고 정의되어 있다.

그래서

F f = (x) -> x * 2;

에서 x가 int라는 것을 추론할 수 있다.


11. 이제 실제 문제를 풀어보자

문제는 다음과 같다.

public class Main {

    static interface F {
        int apply(int x) throws Exception;
    }

    public static int run(F f) {

        try {
            return f.apply(3);

        } catch (Exception e) {
            return 7;
        }
    }

    public static void main(String[] args) {

        F f = (x) -> {

            if (x > 2) {
                throw new Exception();
            }

            return x * 2;
        };

        System.out.print(
            run(f) + run((int n) -> n + 9)
        );
    }
}

처음에는 코드가 복잡해 보이지만 람다를 apply()의 구현이라고 생각하면 하나씩 따라갈 수 있다.


12. 먼저 run(f)를 계산해보자

run()은 다음과 같다.

public static int run(F f) {

    try {
        return f.apply(3);

    } catch (Exception e) {
        return 7;
    }
}

즉 F 객체를 하나 전달받아서

f.apply(3);

을 호출한다.

현재 전달한 f는 다음 람다다.

F f = (x) -> {

    if (x > 2) {
        throw new Exception();
    }

    return x * 2;
};

이를 apply()처럼 펼쳐 생각해보면

int apply(int x) throws Exception {

    if (x > 2) {
        throw new Exception();
    }

    return x * 2;
}

와 같은 동작이다.


13. apply(3)을 실행하면

run(f) 내부에서는

f.apply(3);

을 호출한다.

따라서

x = 3

이다.

조건을 확인한다.

if (x > 2)

즉

3 > 2

이므로 참이다.

따라서

throw new Exception();

이 실행된다.

흐름은 다음과 같다.

run(f)
   ↓
f.apply(3)
   ↓
x = 3
   ↓
3 > 2
   ↓
true
   ↓
Exception 발생

14. 예외가 발생하면 어디로 갈까?

f.apply(3)은 try 안에서 실행되고 있다.

try {
    return f.apply(3);
} catch (Exception e) {
    return 7;
}

따라서 Exception이 발생하면 catch로 이동한다.

Exception 발생
      ↓
catch (Exception e)
      ↓
return 7

결국

run(f)

의 결과는

7

이다.


15. 두 번째 람다를 보자

두 번째 호출은 다음과 같다.

run((int n) -> n + 9)

처음에는 run()의 매개변수가 F인데 어떻게 갑자기 람다를 바로 넣을 수 있는지 헷갈렸다.

run()을 다시 보면

public static int run(F f)

다.

즉 F 타입의 동작을 전달해야 한다.

그리고 F는

interface F {
    int apply(int x) throws Exception;
}

라는 하나의 추상 메서드를 가지고 있다.

따라서

(int n) -> n + 9

는 그 apply()를 구현하는 동작으로 사용할 수 있다.

개념적으로

run((int n) -> n + 9)

를 다음처럼 생각할 수 있다.

F temp = (int n) -> n + 9;
run(temp);

16. 두 번째 run()의 결과

run()에서는 항상

f.apply(3);

을 호출한다.

이번 f의 동작은

(int n) -> n + 9

다.

따라서

n = 3

이 들어간다.

3 + 9 = 12

예외도 발생하지 않는다.

따라서

run((int n) -> n + 9)

의 결과는

12

다.


17. 최종 결과

원래 코드는

System.out.print(
    run(f) + run((int n) -> n + 9)
);

였다.

앞에서 각각 계산했다.

run(f)
→ 7

그리고

run((int n) -> n + 9)
→ 12

따라서

7 + 12
= 19

최종 출력은

19

다.


18. 람다 문제는 이렇게 풀면 편하다

처음에는 람다 문법을 그대로 따라가려고 해서 더 어려웠다.

오히려 람다를 만나면 원래 메서드 형태로 다시 펼쳐서 생각하는 것이 편했다.

예를 들어

F f = (x) -> x * 2;

가 있고

interface F {
    int apply(int x);
}

라면 머릿속에서

int apply(int x) {
    return x * 2;
}

로 바꿔본다.

그러면

f.apply(3);

도 바로 이해된다.

x = 3
↓
3 * 2
↓
6

즉 시험에서는

람다 발견
   ↓
대상 인터페이스 확인
   ↓
추상 메서드 확인
   ↓
람다를 그 메서드의 구현으로 생각
   ↓
실제 전달되는 값 대입
   ↓
결과 계산

순서로 따라가면 된다.


19. 문자열 + 연산도 같이 헷갈렸다

Java 문제를 풀면서 람다 외에도 출력 결과에서 자주 헷갈렸던 것이 문자열의 + 연산이었다.

예를 들어

int r1 = 9;
int r2 = 2;
char r3 = '3';

System.out.println(r1 + r2 + "2" + r3);

가 있다고 하자.

처음부터 전부 문자열로 합치는 것이 아니다.

Java의 + 연산은 왼쪽부터 진행된다.

먼저

r1 + r2

를 계산한다.

9 + 2 = 11

아직 둘 다 숫자이므로 숫자 덧셈이다.

그다음

11 + "2"

를 계산한다.

여기서 String을 만났다.

따라서 문자열 연결이 된다.

"112"

그리고

"112" + '3'

도 문자열 연결이 된다.

결과는

"1123"

이다.


20. 1 + 2 + "3"과 "1" + 2 + 3

이 차이는 시험에서 특히 주의해야 한다.

먼저

System.out.println(1 + 2 + "3");

를 보자.

왼쪽부터 계산한다.

1 + 2
↓
3

3 + "3"
↓
"33"

따라서

33

이 출력된다.

반면

System.out.println("1" + 2 + 3);

에서는 처음부터 String이 등장한다.

"1" + 2
↓
"12"

"12" + 3
↓
"123"

따라서

123

이 출력된다.

둘의 차이는 결국 String을 어느 시점에 만나는가다.


21. 이번에 이해한 Runnable과 람다의 연결

처음에는 Runnable과 람다가 서로 다른 문법처럼 느껴졌다.

하지만 둘 다 어떤 동작을 전달한다는 관점으로 보면 연결된다.

Runnable에는 실행할 동작을 나타내는

void run()

이 있다.

그래서 다음처럼 익명 클래스를 만들 수 있다.

Runnable task = new Runnable() {

    @Override
    public void run() {
        System.out.println("Hello");
    }
};

그리고 Runnable은 람다의 대상으로 사용할 수 있는 형태이므로 이 동작을 더 간단하게 표현할 수도 있다.

Runnable task = () -> System.out.println("Hello");

Thread에 전달하면

Thread t = new Thread(task);
t.start();

처럼 사용할 수 있다.

더 줄이면

Thread t = new Thread(
    () -> System.out.println("Hello")
);

t.start();

와 같은 형태도 이해할 수 있게 된다.

처음에는

() -> ...

가 갑자기 등장한 이상한 문법처럼 보였지만 결국

Runnable의 run()에서 실행할 내용을 전달한 것

이라고 생각할 수 있었다.


익명 클래스와 람다를 연결해서 보기

람다가 아직 익숙하지 않다면 다음 세 단계를 비교해보는 것이 가장 이해하기 쉬웠다.

직접 클래스 구현

class MyFunction implements F {

    @Override
    public int apply(int x) {
        return x * 2;
    }
}

F f = new MyFunction();

익명 클래스

F f = new F() {

    @Override
    public int apply(int x) {
        return x * 2;
    }
};

람다식

F f = (x) -> x * 2;

표현은 점점 짧아지지만 핵심적으로 표현하려는 동작은

x를 받아서 x * 2를 반환한다.

로 같다.


문제를 풀 때 내가 확인할 것

람다 문제가 나오면 다음 순서로 보는 것이 가장 편했다.

1. 람다가 어떤 인터페이스 타입으로 사용되고 있는가?

2. 그 인터페이스의 추상 메서드는 무엇인가?

3. 매개변수 타입과 반환 타입은 무엇인가?

4. 람다를 그 메서드의 구현이라고 생각한다.

5. 실제로 어떤 값이 전달되는지 확인한다.

6. 예외가 발생하는지 확인한다.

7. try-catch가 있다면 어디에서 처리되는지 확인한다.

예를 들어

run((int n) -> n + 9)

를 보면

run()의 매개변수
↓
F

F의 추상 메서드
↓
int apply(int x)

람다
↓
(int n) -> n + 9

run() 내부
↓
f.apply(3)

따라서
↓
n = 3

결과
↓
3 + 9 = 12

로 따라가면 된다.


마무리

처음에는

(int n) -> n + 9

라는 표현 자체가 이해되지 않았다.

메서드 이름도 없고 return도 없는데 어떻게 실행되는지 감이 오지 않았다.

하지만 함수형 인터페이스와 연결해서 보니 훨씬 단순했다.

interface F {
    int apply(int x) throws Exception;
}

처럼 구현해야 할 추상 메서드가 하나라면

(x) -> x * 2

라는 람다를

int apply(int x) {
    return x * 2;
}

라는 동작의 구현으로 연결해서 생각할 수 있었다.

그리고

run(f)

처럼 람다를 전달받는 메서드에서는 결국 그 동작을

f.apply(3);

처럼 실행하고 있는 것이다.

이번 내용을 한 흐름으로 정리하면 다음과 같다.

함수형 인터페이스
       ↓
하나의 추상 메서드
       ↓
그 메서드의 동작을 구현
       ↓
익명 클래스로 표현 가능
       ↓
람다식으로 더 간결하게 표현 가능
       ↓
다른 메서드의 인자로 전달 가능
       ↓
필요한 시점에 실행

결국 람다를 처음 볼 때부터 어려운 문법으로 생각할 필요는 없었다.

(int n) -> n + 9는 int 값을 하나 받아서 9를 더한 값을 반환하는 동작이다.

그리고 그 동작이 어떤 메서드에 해당하는지는 람다가 사용되는 함수형 인터페이스를 확인하면 알 수 있다.

이렇게 생각하니 Runnable, 익명 클래스, 람다식이 따로 떨어진 문법이 아니라 “실행할 동작을 객체처럼 전달한다”는 하나의 흐름으로 연결되기 시작했다.

profile
도전을 멈추지 않는 개발자

0개의 댓글