저는 평소에 인터페이스를 자주 사용하지 않습니다. 보통 작은 프로젝트를 만들기 때문에 제가 생각한 대로, 설계한 대로 만들면 콘크리트 클래스만 만들어도 문제가 없기 때문입니다.
또한 기획의 일부가 바뀌게 되어도 별로 큰 타격을 입지 않는데, 클래스를 사용한 위치, 사용하는 이유 등등 명확히 알고 있기 때문이죠.
하지만 여러 사람이 하나의 프로젝트를 만들 때는 이야기가 다른 것 같습니다. "이렇게 만듭시다."라고 모두에게 말하면 모두 똑같이 이해하고 만들기 힘들죠!
혹은 변경 점이 생겼을 때 남이 만든 코드의 변경된 클래스 사용처를 알 수 있을까? 그리고 그것을 잘 고칠 수 있을까? 이런 의문들도 함께 남습니다.
위에서 얘기한 문제점 두 가지는 협업 때의 규약의 부재와 변경의 유연성 부재 라고 생각합니다. 우리는 이 것을 인터페이스로 해결할 수 있죠!
해결하기에 앞서 먼저 한 상황을 가정해 봅시다.
"우리는 서버로부터, 혹은 로컬에서 데이터를 가져오는 시스템을 만들라는 명령을 받았다. 우리 팀에는 미국인과 일본인 동료가 있다. 두 분은 매우 뛰어난 베테랑 프로그래머이지만, 난 불행하게도 일본어, 영어 모두 못한다. 이들에게 각각 로컬 데이터 받아오기, 서버 데이터 받아오기를 시키려고 한다. 어떻게 해야 할까?"
public interface DataProviderWithDataBase {
abstract int getDataFromDataBase();
}
자 이제 이 interface를 미국인 동료의 클래스에 implement시켜 봅시다. implement는 한글로 "시행하다" 즉, 무언가를 시행하라고 명령하는 것이기에 인터페이스는 implement를 한 클래스에게 자신에게 정의된 메소드, 필드 껍데기의 구현을 강제하게 됩니다.

(implement를 해 놓고 구현을 하지 않으면 이렇게 에러 메시지를 띄운다.)
이렇게 강제하게 되면 DataProviderWithDataBase 안에 있는 getDataFromDataBase 메소드를 구현해야(재정의 해야) 에러 메시지가 없어 집니다. Implement methods를 누르거나, 혹은 Alt+Shift+Enter를 눌러 메소드를 재정의 해 봅시다.

자 이제 에러는 사라졌으며, interface 안에 있던 구현해야 할 메소드가 보입니다. 베테랑 프로그래머인 미국인 동료는 자신이 해야 할 일을 메소드의 이름을 통해 단번에 알아차렸고, 그는 구현을 시작할 것입니다.
마찬가지로, 일본인 동료도 똑같이 interface를 만들어 줍시다.
public interface DataProviderWithServer {
abstract int getDataFromServer();
}
자. 이제 그도 일을 시작할 것입니다.
자 모두 해결한 것 같습니다. 일단 이들이 구현한 클래스로 데이터를 받아 와 봅시다!
public int getData(Boolean isServer){
if (isServer){
American american=new American();
return american.getDataFromDataBase();
}
else{
Japanese japanese=new Japanese();
return japanese.getDataFromServer();
}
}
두 부분을 구분하기 위해 Boolean자료형을 이용하여 구현하였습니다. 하지만 무언가 찜찜 합니다.
마침 위에서 기획을 바꾼다고 지시가 내려왔습니다. 다른 곳에서도 데이터를 받아와야 한다고 하는데, 이번에는 중국인 동료가 있습니다.. 또 인터페이스를 작성해야 할까요..?
곰곰히 생각해 보면 사실 이 세 부분에서 데이터를 받아 오는 것은 공통점이 존재합니다. 바로 "데이터를 받아온다" 라는 것으로 묶을 수 있다는 것이죠! 이 개념적인 것을 interface로 뽑아 봅시다.
public interface DataProvider {
abstract int getData();
}
각각 DataProviderWithDataBase, DataProviderWithServer로 나누어 졌던 것을 DataProvider로 한 곳으로 모았습니다. 그리고 getData라는 추상 메소드로 통합하였죠. 또한 중국인 동료에게 줄 추가될 인터페이스도 데이터를 가져 온다는 것으로 구현을 할 수 있기 때문에 일맥상통합니다.
자 이제 세 동료 모두에게 이 interface를 implement 시켜 구현하도록 부탁해 봅시다.



자, 다들 자신의 임무에 알맞게 구현을 해왔습니다.
먼저 우리는 각각 다른 interface로 각각 다른 class를 구현하였고, 각각의 class로 instance를 생성하여 method를 실행시켜 return 하였는데요.
하지만 이번엔 하나의 interface로 통합 시킨 후 각각의 class를 구현하였습니다. 제가 자주 까먹는 것이 있는데, 굳이 자신의 class 타입으로 instance를 생성하지 않아도 된다는 겁니다!
implement 혹은 extend를 한 class는 상위 class, 혹은 interface의 타입으로 instance를 생성할 수 있다는 것이죠. 여기서 interface의 장점이 나옵니다.
즉, 위의 DataProvider로 구현한 class 세 개는 DataProvider의 타입으로 통합할 수 있습니다.
public int getData(int flag){
DataProvider provider = null;
if(flag==Flag.GET_FROM_SERVER){
provider=new American();
}
else if(flag==Flag.GET_FROM_OTHER_SERVER){
provider=new Chinese();
}
else if(flag==Flag.GET_FROM_DATABASE){
provider=new Japanese();
}
//변하지 않는 곳.
if(provider!=null){
return provider.getData();
}
else{
throw new NullPointerException();
}
}
public class Flag {
GET_FROM_SERVER, GET_FROM_OTHER_SERVER, GET_FROM_DATABASE
}
3개의 선택지로 바뀌었으니 Boolean으로 구분 하던 것을 int로 바꾸어 주었고, 구분을 쉽게 하기 위해 Flag라는 클래스를 새로 만들어 그 안에 flag를 넣어 주었습니다.
또한 기존에 있던 함수에도 변화가 생겼는데, 데이터를 제공하는 클래스의 타입이 interface인 DataProvider로 변경 되었습니다.
미국인, 일본인, 중국인 동료 각각 메소드의 내용을 따로 작성하였어도, 메소드의 이름이 모두 같기 때문에 interface 타입으로 메소드를 실행 시켜도 아무 문제가 없습니다.
또한 할당한 객체에 따라 같은 타입이어도 다른 행동을 하는 메소드를 실행시킬 수 있게 됩니다!
그리고 변하지 않는 부분이 생겼기 때문에 새로운 방법으로 데이터를 가져오는 로직이 생겨도, 바꾸는 코드는 줄어들게 되었죠!
좀 더 다듬는 방법은 팩토리 메소드 패턴을 이용하여 DataProvider의 생성을 따로 작성할 수 있습니다. 밑과 같이 하면 클래스 간의 연결성이 더욱 떨어집니다.
public class DataProviderFactory {
private static DataProviderFactory instance;
private DataProviderFactory(){
}
public static DataProviderFactory getInstance(){
//싱글톤 패턴 적용
if(instance==null){
instance=new DataProviderFactory();
}
return instance;
}
public DataProvider getDataProvider(Flag flag){
DataProvider provider=null;
if(flag==Flag.GET_FROM_SERVER){
provider= new Japanese();
}
else if (flag==Flag.GET_FROM_DATABASE){
provider= new Chinese();
}
else if(flag==Flag.GET_FROM_OTHER_SERVER){
provider= new American();
}
return provider;
}
}
위의 코드의 양 만으로 보아선 간단해 지지 않았습니다. 코드의 양이 더욱 늘었죠. 하지만 더욱 많은 상황을 떠올려 보면 장점이 더욱 부각 됩니다.
만약 DataProvider가 여러 곳에서 참조해야 하는 상황이 생겼다면?
이 전의 구현에서는 객체 타입이 서로 다르기 때문에 참조하는 곳 마다 if, else를 달아 주어야 될 것이고, 새롭게 데이터를 받아오는 로직이 생기면 또 if를 달아 주어 코드의 양이 기하급수적으로 늘어날 것 입니다.
따라서 DataProvider 하나로 통합하면, 각각 다른 경로로 데이터를 받아오는 메소드를 단 하나의 타입으로 모두 수행할 수 있게 되죠!
이 뿐만 아니라, 이 인터페이스 타입을 사용하는 측에서도 이익이 있습니다.
interface가 먼저 구현이 되지 않아도, interface 타입을 사용하는 쪽에서 자신의 로직을 테스트 할 수 있게 됩니다. 하드 코딩을 통해서 임시로 interface를 구현하는 것이죠.
public int process(){
MockDataProvider mock=new MockDataProvider();
//test logic
int data= mock.getData();
data-=1000;
return data;
}
class MockDataProvider implements DataProvider{
@Override
public int getData() {
//hard coding
int itIsTestData=99999;
return itIsTestData
}
}
이렇게 모두 생산성에 이익을 보며 같이 작업할 수 있게 됩니다.
이번에는 interface의 사용처에 대해서 알아보았습니다. 큰 프로젝트 경험은 없지만, 이렇게 간접적으로 문제를 해결해 나가며 다음에도 작성해 보겠습니다.
틀린 부분이 있다면 적극 반영하겠습니다. 감사합니다!
(이 포스팅은 책 "객체 지향과 디자인 패턴"을 읽고 재구성한 글 입니다.)