얼마 전 자바8 람다 나머지 이야기를 보면서 평소 필자가 알던 Java와는 완전히 달라 보였다. 필자가 알고 있던 Java는 보수적이지만 정통적이라고 생각 해왔는데 과감히 이 생각을 깨졌다.



Java 8 Lambda 에 대해 궁금한 부분은 필자가 예전에 작성한 아티클을 참고하기 바란다.

Java 8 Interface 변경 사항 default 키워드

오라클의 Defining an Interface 문서에 의하면 Java Interface의 정의는 변경되었다. Java Interface는 abstract methods, default methods, static methods 를 정의할 수 있다고 한다.

The interface body can contain abstract methods, default methods, and static methods.

명세에 따르면 Java Interface에 default 키워드를 통해 메서드를 구현할 수 있다. 또, 이를 구현하는 클래스는 Interface의 메서드를 @Override 할 수 있다.

Oracle 문서에서 아래와 같은 예제를 볼 수 있다.

public interface DoIt {
   void doSomething(int i, double x);
   int doSomethingElse(String s);
   default boolean didItWork(int i, double x, String s) {
       // Method body 
   }  
}

Java 8 Interface, 그 인터페이스는 그 인터페이스가 아니다.

원래 객체지향 언어에서 Interface는 그 시그너처와 선언이 변하지 않는다는 것을 전제로 하여 다형성(polymophism)을 정의하는 객체간의 규약이다. 그러나 Java8 Interface는 Interface를 업그레이드하는 개념을 도입하였다.

Now users of your code can choose to continue to use the old interface or to upgrade to the new interface.
Alternatively, you can define your new methods as default methods

객체지향 언어의 객체 규약을 정의함에 있어 항상 논의 되는 것이 Interface와 Abstract 클래스 둘 중 어떤 것을 쓸 것이가에 대한 것이다. 어떤 것을 사용해도 무방하겠지만 객체 간의 규약(서로간의 약속)은 Interface 로 정의해야 한다. 구현체가 없는 온전한 인터페이스 역할을 해야 하기 때문이다.

특히 분산 객체(distributed object)는 분산된 두 객체의 규약, 즉 Interface 의 프록시(proxy)를 통해 분산 객체를 사용한다. Java8 Interface는 구현체가 포함될 수 있으므로 더 이상 분산 객체를 정의하는 규약으로 부적합하다.

이는 반드시 분산 객체 뿐만 아니라, 일반적인 객체로서도 문제의 소지가 충분하다. 아래는 이런 현상을 약간 억지스럽게 구현한 코드이다.

이를 통해 알 수 있는 것은 Java 8 Interface 로 규약된 분산 객체에 구현 코드가 포함이 되므로 더 이상 ‘규약’이라는 표현 자체가 규약이 될 수 없을 것 같다.

interface Duck {
    void say();
    void walk();
}

interface MyDuck extends Duck { }

interface YourDuck extends Duck {
    default void walk() { say(); }
}

class MyDuckImpl implements MyDuck {
    @Override
    public void say() { System.out.println("MyDuck: 꽥~"); }

    @Override
    public void walk() { System.out.println("MyDuck: 뒤뚱~"); }
}

class YourDuckImpl implements YourDuck {
    @Override
    public void say() { System.out.println("YourDuck: 꽥꽥꽥~"); }
}

public class Main {
    public static void main(String[] args) {
        new MyDuckImpl().say();
        new YourDuckImpl().say();

        new MyDuckImpl().walk();
        new YourDuckImpl().walk();
    }
}

// 결과
MyDuck: 꽥~
YourDuck: 꽥꽥꽥~
MyDuck: 뒤뚱~
YourDuck: 꽥꽥꽥~

상대적인 취약해진 Java 8 Interface

일반적으로 실행 파일(executable file)이나 라이브러리(library) 파일은 코드와 데이터 등을 구조적으로 저장하고 링크(link) 과정을 거친 바이너리 파일이다. 반면 Java는 각 클래스 파일을 컴파일하면 .class 확장자를 가진 바이너리 파일을 출력한다.

일반적으로 코드와 데이터가 모두 포함된 바이너리 파일(일반적으로 portable executable)은 가리키는 offset 에 따라 RVA(relative virtual address)/VA(virtual address)와 매핑된다. 따라서 바이너리 파일을 비정상적으로 수정(삽입/삭제)하면 offset 정보도 함께 변경해 주어야 한다. 그렇지 않으면 메모리에 로드될 때 코드가 정상적으로 동작하지 않을 수 있다.

반면 Java 의 컴파일 된 바이너리는 클래스 별로 컴파일되어 .class 파일로 출력되기 때문에 이런 offset 정보가 필요가 없다. 컴파일된 코드가 논리적인 구조에 따라 물리적인 파일을 생성하지 않는다. 할 수 있는 건 Stack Size 정도 늘리거나 줄일 수 있다.

따라서 Java 8 Interface 의 .class 파일에 악의적으로 default 메서드 구현을 임의로 수정하면 간편하게 응용 프로그램 전체에 영향을 끼치도록 소정의 목적을 달성할 수 있다. (상대적으로 Java의 바이너리가 더 취약하다는 의미이다.)

다시 불거지는 다중 상속 문제

Java 8 Interface는 다시 다중 상속 문제에서 자유롭지 못하게 되었다.

Java는 단일 상속만 가능하지만 Interface는 다중으로 구현할 수 있다. 이는 C++의 다중 상속으로 인해 발생하는 문제점과 복잡성으로 Java는 단일 상속 구조를 택하게 되었다.

다중 상속으로 발생하는 모호성에 대해서도 Java 8 Interface 가 내놓은 해결책은 C++ 의 해결책과 다를 바가 없다.

아래는 Java 8 Interface 다중 상속(구현)으로 인한 모호성 문제 해결 방법이다.

public interface OperateCar {
    default public int startEngine(EncryptedKey key) {
        // Implementation
    }
}
public interface FlyCar {
    default public int startEngine(EncryptedKey key) {
        // Implementation
    }
}

public class FlyingCar implements OperateCar, FlyCar {
    public int startEngine(EncryptedKey key) {
        FlyCar.super.startEngine(key);
        OperateCar.super.startEngine(key);
    }
}

아래는 C++ 의 다중 상속으로 인해 발생하는 모호성을 해결 하는 방법이다.

#include <iostream>  
using namespace std;  

class OperateCar {  
public:  
    int startEngine(EncryptedKey *key) {  
        // Implementation
    }  
};  

class FlyCar {  
public:  
    int startEngine(EncryptedKey *key) {  
        // Implementation
    }  
};  

class CCC : public OperateCar, public FlyCar {  
public:  
    int startEngine(EncryptedKey *key){  
        OperateCar::startEngine(key);  
        FlyCar::startEngine(key);
    }  
};  

위의 Java와 C++ 소스 코드를 비교해 보면 결국 Java 8 Interface는 다중 상속의 개념이 다시 도입된 것을 알 수 있다.

참 아이러니 하다.

결론

어느 것이 답이 될 수는 없다. 이전까지 지향하고 고수하던 객체지향이 반드시 정답은 아닐 것이다. 다만, Java가 추구하던 Interface의 개념이 그 동안 알고 있던 개념과 이치에 맞지 않고, 어쩔 도리 없이 구현한 스팩일 수 있다.

필자처럼 C# 5.0의 가장 최신 스팩까지 쭉 경험한 개발자라면 도저히 용납할 수 없을 지도 모르겠다.

까마득한 2007년도에 릴리즈한 C# 3.0 스팩의 Lambda, LINQ, Extension Methods 의 언어 스팩과 비교해보면 Java 8 는 너무나도 기대에 미치지 못한 방법으로 구현해 놓았다는 게 실망스럽다.

아직 Java 8 모든 것을 훓어 본 것이 아니므로 필자가 오해하고 있는 부분은 정정해 주길 바란다. (이념, 철학, 사상적인 부분은 사양한다)

참고로 이해를 돕기 위해 2007년도 C# 3.0 스팩과 2014년 Java 8 스팩을 비교하면 다음과 같다.

  • C# Lambda = Java Lambda
  • C# Extension Methods = Java Stream API
  • C# LINQ = Java 엔 없다!


Posted by 땡초 POWERUMC

댓글을 달아 주세요

  1. 경준씨 2015.06.30 10:16 Address Modify/Delete Reply

    메소드가 void startEngine면 아래과 같이 적어도 됩니다.
    public class FlyingCar implements OperateCar, FlyCar {
    public void startEngine(EncryptedKey key) {
    FlyCar.super.startEngine(key);
    OperateCar.super.startEngine(key);
    }
    }
    그러나 메소드가 int startEngine라면
    public class FlyingCar implements OperateCar, FlyCar {
    public int startEngine(int key) {
    FlyCar.super.startEngine(key);
    return OperateCar.super.startEngine(key);
    }
    }
    이런식으로 자바의 인터페이스는 무조건 Override를 해야합니다.
    이 Override를 상속할 때 return이 void값은 둘다 받을 수 있지만 부모의 return 값은 둘 중의 하나만 명시적으로 선택해야 return값으로 쓸 수 있습니다.

  2. brian 2016.09.09 17:18 Address Modify/Delete Reply

    감사합니다. 잘보고 갑니다.

  3. 이창현 2016.11.10 16:12 Address Modify/Delete Reply

    C#의 LINQ 를 Java 8 의 Stream 에 대응시킬 수 있지 않나요?

    • 땡초 POWERUMC 2016.11.11 09:32 신고 Address Modify/Delete

      LINQ 는 C# 언어와 통합된 쿼리식을 말합니다.
      가령 이런거죠.
      var query = from i in list
      select i;

      Java StreamAPI 와 대응되는 것은 C# 의 Where, Select 와 같은 확장 메서드라고 보시면 될 것 같습니다.

  4. 2017.04.05 19:56 Address Modify/Delete Reply

    비밀댓글입니다

개요

Java 8 버전에서 Lambda 표현을 지원한다. 아직 Java 8은 Beta 버전이다. 여러 언어 중에서 Lambda 표현을 지원하지 않는 언어로 손꼽힌다. Wikipedia에서 Anonymous Function을 참고해보면 Java 언어가 언어의 표현력에 있어서 추세를 따라가지 못하는 것이 아닐까 생각한다.

반면,

  • C#은 2007년도에 C# 3.0 버전에 LINQ 라는 대주제를 중심으로 Lambda, Anonymous Class, Extension Methods를 내놓았고,
  • C# 4.0은 2010년도에 Dynamic이라는 대주제를 중심으로 동적 프로그래밍이 가능해졌다.
  • C# 5.0은 2012년도에 비동기 라는 대주제를 중심으로 비동기 프로그래밍을 언어적으로 지원한다.

Wikipedia에서 C# 역사에 대해 더 자세히 알고 싶은 분은 'C# (programming language)' 를 참고하면 좋겠다.

Java를 이용하여 프로그래밍을 하려고 하면 정말 C#이 많이 생각난다. C#에서 한 줄짜리 문장을 Java에서는 십여 줄 넘는 경우가 많기 때문이다. 굳이 예를 들자면, 우리나라에서 유행하는 줄임말 '엄친아'를 풀어서 '엄마 친구의 아들' 로만 말해야 하는 것과 같은 느낌이랄까… 어쨌든 Java는 Java만의 매력이 있는 법. 그 매력을 찾아보는 것도 재미있겠다.

각설하고, 먼저 Java 8을 사용하여 개발할 수 있는 환경부터 간단히 살펴보자.

현재 Java 8 버전은 베타 버전이다. 현재 Java 8은 Sun사의 JDK를 칭한다. 그러므로 Oracle 사이트에서 Java 8 버전을 다운로드 받을 수 없다.

그리고 Project Lambda를 지원하는 개발 툴을 사용해야 한다. 다음의 링크의 NetBeans와 IntelliJ IDEA 12 버전에서 Project Lambda를 사용해볼 수 있다. 아래의 링크에서 다운로드 받을 수 있다.

설치와 JDK 1.8 버전의 환경 구성이 완료되었으면 Lambda 표현을 Java 에서 사용할 수 있다.

Java 8 의 Lambda 샘플 예제 간단한 예제만 소개하겠다.

(Java에서 권장하는 네이밍이나 코드 구현 방식에 맞지 않는 부분이 있더라도 양해 바란다.) 간단한 더하기 계산을 Lambda 표현으로 작성하면 다음과 같다. 



위의 코드로 말미암아 Lambda 표현은 (arguments) -> { … } 로 표현할 수 있겠다.

간단하게 Thread를 돌리는 코드를 Lambda 표현식으로 작성해보자. 




다음은 ExecutorService를 Lambda 표현으로 작성하였다. 





Java의 Lambda 이야기가 나온 김에 어떻게 Lambda 표현으로 발전하였는지도 짤막하게 보자.

원래 이런 코드가 있었다. Runnable Interface를 구현하는 코드이다. 




또는 Java의 Local Class를 이용할 수 있다. Local Class는 메서드 구현부에서 Class를 선언하여 이를 인스턴스화 할 수 있다.

위의 Runnable Interface를 구현한 코드를 Anonymous Class(익명 클래스)로 표현할 수 있게 되었다. 그래서 아래의 예제와 같이 Interface를 구현하는 Class를 만들지 않아도 된다. 



위 Anonymous Class를 Lambda 표현으로 작성하면 더 간결하게 표현할 수 있다. 


단, Java 8의 Lambda 표현에 제약이 있다.

그리고 Project Lambda를 소개하는 페이지의 Functional Interfaces 에서 제약에 대한 설명이 있다. 하지만 이는 근본적으로 Java에서는 C/C++의 Pointer를 표현할 방법이 없는 이유이다. 그러므로 함수를 가리키는 Pointer도 있을 수 없다. 반면, C#에서는 함수포인터를 표현하기 위해 Delegate(대리자)를 지원한다. C#에서는 함수포인터를 안전하게 다룰 수 있다.

그래서 Java에서는 함수포인터를 표현하기 위해서 Listener 형태의 패턴을 주로 사용한다. 다른 말로 Observer 패턴이라고 부른다. Java의 Thread가 대표적이다. Java의 Thread는 Runnable을 인자로 받는 생성자가 있다. 위의 코드에서도 볼 수 있듯이 Runnable은 void run() 메서드만 달랑 가지고 있는 Interface이다. Java의 Thread는 이 Runnable Interface만 알고 있으면 되고, Runnable Interface를 구현하는 인스턴스를 Thread에게 넘겨주면 된다.

반면, C#의 Thread는 Delegate(대리자-안전한 함수 포인터)를 이용하여 Thread를 실행한다. C# 컴파일러는 Delegate를 결국 Class 로 취급한다. 이로 말미암아 Java와 C#에서 포인터라는 것은 언어적으로는 전혀 메커니즘으로 작동하지만 런타임 입장에서는 유사한 메커니즘으로 동작한다는 것을 알 수 있다. 하지만 Java에서 함수포인터를 흉내를 낼 수 있는 방법은 있다. 키/쌍의 컬렉션을 이용하여 참조를 전달하는 방법이다. 아래는 간단한 예제 코드이다. 



어찌되었든 결국, Java의 Lambda는 Interface를 이용하여 Lambda 함수를 만듦으로써 Interface의 함수가 단 1개만 있어야 Lambda 표현을 할 수 있는 제약이 생겼다. Interface를 이용하여 Lambda를 표현한다고 함은 내부적으로 Proxy 객체를 생성하여 그 안에 Lambda 표현을 메서드로 만든다.

아래의 익명 클래스를 보자. 아래의 runnable 로컬 변수를 리플랙션을 이용하여 getMethods() 의 목록이다. 



아래의 Lambda 표현을 보자. 마찬가지로 runnable 로컬 변수를 리플랙션을 이용하여 getMethods() 의 목록이다. 


이를 통해 Java의 Lambda 표현식은 내부적으로 Proxy 클래스가 생성됨이 확인되었다. 그런데 이 Proxy 클래스가 언제 생성이 될까? 컴파일 타임에 생성이 될까, 아니면 런타임에 생성이 될까?

이를 JD-GUI 도구를 이용하여 Decompile 결과를 확인하려고 하였으나, Java 1.8.0 버전에 대해서 JD-GUI 가 올바르게 인식을 하지 못해 전혀 class 파일을 전혀 읽을 수 없다. 대신 .class 파일을 Text Editor 로 열어서 대략적인 내용을 확인할 수 있는데, Text Editor 에서는 Lambda 표현으로 구현된 Proxy Class 를 찾을 수 없었다. 따라서 Lambda 표현은 런타임에 구현 객체 Proxy 가 생성된다는 것을 알 수 있다. (다만, 확신은 못하겠다.)

한가지, Java 8의 Lambda 표현의 다른 점이라면 Lambda 표현의 Proxy 객체는 java.lang.invoke.MagicLambdaImpl 클래스를 상속한다는 점이다. 앞서 얘기했듯이 JD-GUI 도구가 Java JRE 1.8.0 의 rt.jar 파일을 상위 호환성이 아직 지원되지 않아 구현 내용을 알 수는 없었다. 이는 좀 더 Java 8의 Release 시기가 다가오기를 기다려야 할 것 같다. 



결론은 Java 8의 Lambda 표현을 하기 위해서는 Interface 의 구현 함수는 반드시 1개여야 한다는 점이다.

Posted by 땡초 POWERUMC

댓글을 달아 주세요

  1. 짜두 2013.01.17 08:59 Address Modify/Delete Reply

    이제 베타버전이니 좀더 발전해야 겠네요. 굉장히 빠른 속도로 발전해나가지 않을까하는 생각이 드네요.ㅎ