개밟자 블로그
[JAVA] 인터페이스 코딩 본문
자바의 인터페이스 사용은 코드의 확장성, 재사용성 향상에 큰 도움이 된다.
인터페이스란 무엇일까?
간단하게 말하면 공통 규약이다. 하나의 객체를 정의한다고 했을 때, 해당 객체의 클래스가 어떠한 인터페이스를 상속했다면, 인터페이스가 요구하는 것들을 모두 구현해야 한다.
요구 사항을 간단하게 살펴보자면, 인터페이스가 명시한 인자 값, 반환 값이 일치하는 메소드를 클래스에 @Override하여 작성 해주면 되는 것이다.
협업에서 필수..? 라고는 하는데, 아직 인터페이스를 공유 하면서까지 코드를 같이 작성 할 정도의 프로젝트는 해보지 않았다.
인터페이스는 추상 자료형이며, 본인 이름으로 된 인스턴스를 생성할 수 없다.
(Interface A일 때, new A()와 같은 선언이 불가능 하다는 의미. 단, 익명 클래스 사용시 명시적으로 표현 가능)
그러나, 해당 인터페이스를 구현한 클래스에 한해, 다양한 종류의 객체를 가리키는 레퍼런스 변수로 사용될 수 있다.
인터페이스는 언제 활용 할까?
나는 서로 다른 Class들이 동일한 목적으로 사용되며 하나의 추상적인 집합으로 묶여야 한다면 종종 사용하는 편이다.
구현의 강제성?
인터페이스는 구현을 강제한다. 강제하는 이유는 무엇일까? 나는 그저 이유없는 규칙인 줄 알았다. 그래서 그럴듯한 이유를 생각 해보았다.
거창한건 아니고, 그냥 대충 합리적인 이유이다.
이걸 설명하기 위해, 예시를 위한 빌드업이 좀 필요하다.
"두 정수 값을 이용해서 하나의 정수 값을 반환해야 하는 메소드를 구현하시오"
해당 인터페이스가 이를 상속하는 클래스에게 원하는 요구 사항이다.
이름이 Calculation인 만큼, 이 요구사항을 만족하는 4가지의 클래스를 만들어 보았다.
더하기, 빼기, 곱하기, 나누기 등 Calculation 인터페이스를 상속하는 4가지의 클래스를 정의했다.
코드를 보면 알 수 있겠지만, 각 클래스의 고유 연산을 getResult 반환 값에 그대로 갖다 붙였다.
음 아무튼, 이 클래스들의 인스턴스를 생성한다 쳤을 때, 보통의 방법이라면 이렇게 구현했을 것이다.
이렇게 한다 쳤을 때, Add가 사용할 수 있는 메소드의 범위를 확인해보자.
총 이렇게 2가지가 나온다. 그렇다면 다른 방식으로 인스턴스를 생성해보고, 메소드 목록을 확인해보자.
위에서 언급 했듯이, 인터페이스는 자체 인스턴스가 없는 대신, 인터페이스를 구현한 모든 클래스에 대해 레퍼런스 변수로 사용할 수 있다.
그 대신 자신이 가지고 있는 것, 인터페이스의 구현부 밖에 참조 할 수 없다.
getResult는 모든 인터페이스 구현체(클래스)들이 사용 할 수 있어야 하는 메소드이다.
만약, 강제하지 않는다고 치자. 해당 요구사항에 대해 누군 구현하고, 누군 구현하지 않은 상황이 발생하면 어떻게 될까?
구현하지 않은 특정 Caculation strategy에 대해서 getResult를 아예 사용할 수 없게 해야 할까?
아니면 그냥 관련해서 Exception을 발생시켜 버릴까?
그냥 귀찮은 상황이다. 나도 차라리 컴파일 타임에 검사해서 강제하는 것이 안정성이 높다 생각한다.
참고로, 부모 클래스 상속(extend)에서는 구현을 강제하지 않는다.
부모 클래스 구조를 반영한 영역(즉, child+parent)도 같이 할당 받기 때문이다. 다시 말해, 이미 구현되어 있어서 그런 것이다.
어?
인터페이스도 이미 구현 되어 있었다면, 강제성을 부여하지 않아도 되는게 아닌가?
그래서, JAVA 8에서는 인터페이스에 default라는 기능을 도입하였다.
이렇게 default를 명시 해주어 요구사항을 정의했다면, 클래스에서 굳이 코드로 명시를 해주지 않아도 된다.
어.. 지금 Calculation 구조에서는 default가 그렇게 큰 효과를 가져다 주진 않는다.
따라서, 의미있는 default 메소드가 있을 때 사용하면 될 것 같다.
나아가서, 익명 클래스 활용과 람다식 코딩
Calculation 레퍼런스 변수가 어떠한 객체를 참조 했을 때, 결국에 가장 사용하게 되는 것은 무엇인가?
바로 요구사항 int getResult(int a,int b) 메소드이다.
사실 해당 메소드의 구현 부분만 있으면 Calculation의 구현체를 전부 사용하는 것이라 봐도 과언이 아니다.
우리는 극강의 효율성을 가져오기 위해, 먼저 한 가지의 방법을 사용하기로 한다.
익명 클래스
익명 클래스란, 이름이 없는 클래스이자 객체이다. 이름이 없다는 건, 구현 부만 명시된 클래스라는 의미이다.
따라서, 클래스 정의와 동시에 메모리를 할당 받게된다.
익명 클래스는 한 번 쓰이고 버려진다는 표현을 사용한다. 얘 자체가 하드 코딩된 클래스이기 때문에, 다른 용도로서의 재사용성이 전혀 없다.
새로 생성한 strategy5를 보자.
Add,Subtract,Multiply,Divide 클래스처럼 선언하는 것 보다, 비교적 짧게 나타낼 수 있다.
음.. 그런데 이렇게 요구사항이 단 한 개 밖에 없는 인터페이스의 구현부를 더 짧게 나타낼 수 없을까?
다음의 방법을 추가로 적용해서, 구현부가 하나 밖에 없는 인터페이스에 대해 코드를 더 짧게 써보도록 한다.
람다식
람다식은 익명 함수이다. 앞서 설명한 익명 클래스보다 더 작은 단위임을 알 수 있다.
그말인 즉슨, 익명 클래스가 하드 코딩한 인스턴스를 생성한다 치면, 익명 함수 람다식은 하드 코딩한 함수를 생성한다.
똑같이 람다식도 한 번 쓰이고 마는 함수이며, 재사용성이 없는 메소드의 구현부라고 할 수 있겠다.
먼저 인터페이스에 @FunctionalInterface 어노테이션을 붙여 해당 인터페이스가 함수형 인터페이스임을 정의한다.
이를 명시 함으로서, 함수형 인터페이스로 이용할 메소드를 꼽는다.
빨간줄이 떴는데, 이는 default를 함수형 인터페이스로 채택하지 않기 때문이다. 따라서 우측의 코드로 바꿔주었다.
새로 생성한 strategy6를 보자.
int 인자 2개와, 반환 타입 int라는 정보를 기반으로, 한 줄로 표현할 수 있는 메소드 구현 부에 대해서는 이런 식으로 짧게 작성할 수 있다.
람다식은, 메소드가 단 하나밖에 없는(default를 제외한) 인터페이스에 대해 함수형 인터페이스로 지정하고, 해당 메소드의 구현부를 짧게 나타낼 수 있도록 하는 하나의 방법이라고 볼 수 있겠다.
참고로, 람다식 표현의 특징 때문에 반환 타입이 있냐 없냐, 파라미터가 있냐 없냐를 따져서 만든 컬렉션 프레임워크가 존재 하는데, 여기에 여러 개의 함수형 인터페이스가 이미 정의되어 있다. stream도 그 예시이다.
더욱 더 나아가서.. 디자인 패턴
Calculation 인터페이스에 대해 4가지의 연산을 클래스로 정의했었다.
Mode change를 통해 연산의 방법을 자유롭게 바꾸어 결과 값을 받아올 수 있는 클래스가 있다면 편하지 않을까?
그러니까, 확장성과 재사용성을 고려한 그런 자료구조를 만들어 볼 수 있지 않을까?
Strategy Pattern은 이러한 고민을 쉽게 해결해주는 디자인 패턴 전략이다.
즉, 같은 인터페이스 구현체들에 대한 Switching 방식을 제공하는 전략으로 볼 수 있겠다.
입력한 명령어를 바탕으로 4가지의 연산 중 하나를 실행하는 프로그램이다. 물론 예외처리는 하지 않았다.
import java.util.*;
import java.io.*;
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader br=new BufferedReader(new InputStreamReader(System.in));
Calculator strategyPattern = new Calculator();
strategyPattern.add("add",new Add());
strategyPattern.add("subtract",new Subtract());
strategyPattern.add("multiply",new Multiply());
strategyPattern.add("divide",new Divide());
String cmd;
while((cmd=br.readLine()).equals("quit")){
System.out.print("Input Command: ");
String[] parameter=cmd.split(" ");
strategyPattern.setStrategy(parameter[0]);
int a=Integer.parseInt(parameter[1]);
int b=Integer.parseInt(parameter[2]);
System.out.println("RESULT : "+strategyPattern.calculate(a,b));
}
System.out.println("QUIT");
}
}
class Calculator{
private HashMap<String, Calculation> strategyMap=new HashMap<>();
private Calculation currentStrategy=null;
void add(String cmd, Calculation strategy){
this.strategyMap.put(cmd, strategy);
}
int calculate(int a, int b){
return currentStrategy.getResult(a,b);
}
void setStrategy(String cmd){
if(strategyMap.containsKey(cmd)){
this.currentStrategy=strategyMap.get(cmd);
System.out.println("Strategy Changed : "+cmd);
}
}
}
@FunctionalInterface
interface Calculation{
int getResult(int a, int b);
}
class Add implements Calculation{
public int add(int x,int y){
return x+y;
}
@Override
public int getResult(int a, int b) {
return this.add(a,b);
}
}
class Subtract implements Calculation{
public int subtract(int x, int y){
return x-y;
}
@Override
public int getResult(int a, int b) {
return this.subtract(a,b);
}
}
class Multiply implements Calculation{
public int multiply(int x,int y){
return x*y;
}
@Override
public int getResult(int a, int b) {
return this.multiply(a,b);
}
}
class Divide implements Calculation{
public int divide(int x,int y){
return x/y;
}
@Override
public int getResult(int a, int b) {
return this.divide(a,b);
}
}
Strategy 패턴을 보조해 줄 Calculator Class를 정의 하였다. Strategy 인스턴스의 선택은 HashMap을 사용하였다.
그러니까, key만 잘 입력 받는다면 의도한 Strategy를 가져올 수 있다는 것이다. currentStrategy 레퍼런스 변수를 통해 switching 하도록 한다.
명령어를 각 파라미터로 쪼개어 HashMap으로 Strategy를 가져오고, 연산을 수행하는 방식이다.
다음과 같이 입력하면 잘 작동한다.
여기서 mod 연산을 추가하려고 한다. 이 글에서 설명한 모든 방법들의 장점이 여기서 부각된다.
mod 연산을 추가 할 때, 작성 해야하는 코드는 얼마나 될까?
단 한 줄이다.
함수형 인터페이스와 Strategy의 조합이기에 가능한 것이다.
결론은, Strategy Pattern은 동적으로 로직의 교체가 가능하다는 점, 로직의 추가, 변경, 삭제 등이 쉽다는 것이 장점이 되는 디자인 패턴이라고 볼 수 있겠다.
인터페이스 실제 프로젝트 활용
JPA를 활용하여 데이터 베이스 테이블을 생성하고 관리할 때, 너무 많은 데이터가 예상되어 년도별로 테이블을 나눈 적이 있다.
EntireSubject는 이름만 다르지 그 구성 내용은 완전히 똑같은 엔티티들이다.
솔직히, 썩 보기좋은 구성은 아닌데.. 테이블 명을 동적 생성하는 방법이 딱히 없는 것 같아 눈물을 머금고 엔티티를 년도별로 따로 생성해 주었다.
그래도, 이 클래스들은 공통점이 있는 친구들이니 집합으로 묶을 수 있었다.
만약, 어떤 엔티티를 조회하거나 데이터를 추가할 때, 3개 클래스 중 하나를 선택해야 하는 상황이 자주 발생하고, 어떠한 클래스를 선택해도 똑같은 동작을 할 가능성이 높을 것으로 예상되었다.
결과적으로 인터페이스를 선언한다면, 다른 년도가 들어와도 코드를 별로 안 고쳐도 될 것 같아서 사용했다.
각 엔티티 클래스에 EntireSubject 인터페이스를 implements 해 주었다. EntireSubject 내용은 다음과 같다.
클래스의 멤버변수 Getter를 인터페이스로 선언하였다.
나는 프로젝트 특성상 클래스 별 Getter가 자주 사용되었다. 그리고 Lombok을 통해 엔티티에는 @Getter 어노테이션 하나로 이 인터페이스를 implements 한 것으로 간주한다.
다음은 실제 활용 코드이다.
이건 JPA 얘기인데, JPA는 createQuery 메소드 인자로 실제 매핑할 클래스를 작성 해주어야 한다.
서로 다른 3개의 EntireSubject를 Interface로 묶었기 때문에, 실제 매핑할 클래스를 동일하게 작성할 수 있었다.
그리고 쿼리의 결과문도 Interface List로 반환할 수 있어, return 타입을 통일 시킬 수 있었다.
근데 왜 EntireSubject 인터페이스도 class로 작성 하는지는 잘 모르겠다. 이유가 있나?
뭐, 결국 EntireSubject Interface List를 사용할 때, findAllByYear 메소드가 어떠한 년도의 엔티티 List를 받아 오든지 간에 그 엔티티의 멤버변수를 사용할 수 있도록 Getter를 작성하여 값을 얻어올 수 있게 되는 시나리오이다.
만약 인터페이스를 사용하지 않았다면, EntireSubject가 사용되는 부분의 코드가 3배 늘어나지 않았을까?
하지만 최선은 아니라는 점은 여전한 것 같다.
'뇌피셜..' 카테고리의 다른 글
liquibase (0) | 2023.06.27 |
---|---|
처음이자 마지막 빵빵데이터 일기 (0) | 2023.06.23 |
네이버 클라우드 활용해서 3tier 구축 (0) | 2023.04.10 |
[Interface] 인터페이스랑 친해지기 (0) | 2023.02.18 |
interface 다중 상속 (0) | 2023.01.31 |