인터페이스가 아직도 이해가 안 돼. 책 찾아 보고 있는데 봐도 잘 모르겠네... 인터페이스를 쓰는 이유가 뭐야?
학원 동기였던 친구에게 인터페이스에 대한 질문을 받고 두 명을 대상으로 간단하게 자료를 준비해서 강의를 했는데, 혹시 같은 물음을 가지고 있는 분들께 도움이 될까 해서 내용을 공유합니다.
이 글은 자바 인터페이스에 대한 세 가지 궁금증에 대한 답이 될 수 있습니다.
- 인터페이스를 "왜" 사용하는지 모르는가?
- 인터페이스를 "어떻게" 사용하는지 모르는가?
- 인터페이스가 "무엇인지" 모르는가?
깊은 내용은 다루고 있지 않고, 독자의 타겟은 국비학원에서 자바 언어를 접한지 얼마되지 않으셨거나 막 국비학원을 수료한 초보 개발자입니다.
저도 학원에서 인터페이스를 처음 접했을 때 대체 왜 쓰는지 이해가 안 됐던 기억이 있어서 이 글이 도움이 되었으면 좋겠습니다.
틀린 내용이나 보강할 부분이 있다면 피드백 주시면 감사하겠습니다.^^
인터페이스란?
인터페이스는 자바의 (추상)자료형 중 하나입니다. 기능적으로 추상메서드만을 가질 수 있습니다.(Default Method나 Static Method는 잠깐 예외) 그리고 다시 추상메서드는, 구현부를 가지지 않고 선언만 할 수 있는 메소드를 말합니다. 추상메소는 자식 클래스에서 반드시 오버라이드 해야 사용할 수 있는 메서드인데요. 인터페이스나 추상메서드의 정의는 간단한 구글링으로 알아볼 수 있는데, 중요한 것은 왜 인터페이스는 추상메소드만을 가지며, 사용하는 이유는 무엇인가입니다.
우선 인터페이스를 사용하는 이유는 크게 두 가지입니다.
- 메서드의 구현을 강제하기 위해
- 사용자가 구현 클래스가 아니라 인터페이스에 의존하기 위해(Loose Coupling)
개념적인 정리는 너무 추상적이니, 하나의 이야기와 함께 설명해보겠습니다.
인터페이스를 사용하는 이유 하나 : 구현을 강제하기 위해
PM에게 업무 요청이 왔습니다.
자동차에 들어갈 어플리케이션을 만들어야 하는데, 전기 자동차 클래스와 휘발유 자동차 클래스가 필요하다고 하네요.
예지 사원에게는 전기자동차 클래스의 개발을, 대현 사원에게는 휘발유 자동차 클래스의 개발을 요청했습니다.
두 사원은 각자 열심히 클래스를 만들어 PM에게 제출했습니다. 한 번 코드를 살펴보죠.
두 클래스 사이에서 차이점을 발견하셨나요? 눈에 보이는 차이점은 두 클래스의 메서드명이 모두 다르다는 것입니다. 만약 이제 각 자동차 클래스를 사용하려면 아래와 같을 겁니다. 여기서 발생하는 문제는 무엇일까요?
전기차 클래스를 생성하면 전기차에 맞는 메서드를 사용해야 하고, 휘발유차 클래스를 생성하면 휘발유차에 맞는 메소드를 사용해야 한다는 것입니다. 만약 경유 자동차나 수소 자동차가 추가되면요? 각 자동차 클래스를 사용하기 위해서는 약 12가지의 메소드를 알아야 하고 클래스의 확장이 이루어짐에따라 알아야하는 메서드 수가 기하급수적으로 늘어나게 됩니다.
이는 근본적으로, 두 사원이 생각하는 자동차가 가지고 있는 기능이(추상화한) 다르기 때문입니다. 이러한 불편함을 해결하는 게 인터페이스의 첫 번째 목적입니다. 그럼 인터페이스는 어떻게 이 불편함을 해결할까요?
두 사원이 개발한 클래스를 받은 PM은 자신의 실수를 알아차립니다.
적절한 인터페이스를 만들어주지 않아 서로 다른 모양을 가진 자동차 클래스가 만들어졌기 때문이죠.
PM은 자동차들을 생각하고 그 중 어플리케이션에 필요한 공통 기능을 추려 하나의 인터페이스를 만듭니다.
PM이 만든 아주 간단한 인터페이스가 나왔네요. 이제 인터페이스가 어떻게 불편함을 해결하는지에 대한 물음으로 다시 돌아가죠. 인터페이스는 바로 구현부가 없는 추상메소드를 가짐으로써 해결합니다. 추상메소드를 검색해보고 오셨다면 다시 한 번 여기 이상함을 느낄 것 같네요. 방금 보고 온 자료에서 분명 추상메소드의 앞에는 abstract 라는 예약어가 붙어있었는데요. 인터페이스에 선언된 메서드는 필연적으로 추상 메서드이기 때문에 abstract 예약어가 생략되었다는 걸 먼저 짚고 넘어갑시다.
추상메서드는 구현부가 없이 선언되는 메서드이고 하위 클래스에서 반드시 구현해주어야하는 특징을 가지고, 인터페이스는 추상메서드만을 가집니다. 즉, 인터페이스는 추상메서드로 선언한 메서드를 자신의 하위클래스에게 모두 구현하라고 강제합니다. 이를 통해 각자가 바라보는 자동차 클래스에 대한 적절한 규격을 제공할 수 있습니다.
위 인터페이스를 제공받은 두 사원들은 이제 어떻게 개발할까요? 각자 상상의 나래를 펼쳐 자동차 클래스를 개발하는 것이 아니라 자동차 클래스에게는 주유하는 기능과 운전하는 기능만 있다는 것을 알고 그에 맞춰 개발하겠죠?
이제 두 사원이 개발해온 자동차의 모양이 똑같아졌습니다. 각 자동차의 실제 동작 방식(구현)은 다르지만 모양 자체는 인터페이스에서 제공한 규격과 같죠. 다시 두 자동차 클래스를 사용해보겠습니다.
이제 예지 사원이 개발한 ElectricCar나 대현 사원이 개발한 PetrolCar 모두 같은 메서드를 가지고 있어 훨씬 사용하기가 편리해졌네요. Car 인터페이스를 구현하는 하위 클래스들은 모두 같은 메서드를 가진다는 사실을 알 수 있으니까요. 이제 인터페이스를 첫 번째로 사용하는 이유를 리마인드 해봅시다.
- 메서드의 구현을 강제하기 위해
인터페이스로 규격을 제공해서 메서드의 구현을 강제함으로써 쉽게 협업할 수 있게 되었습니다. 또한 만약 앞으로 수소자동차나 경유자동차 클래스를 추가로 만들어도 인터페이스를 기반으로 개발하면 되기 때문에 뛰어난 확장성을 확보했어요.
그런데 여기서 만약, 이런 상황이 생긴다면 어떡하죠?
인터페이스 VS 추상클래스
전기자동차는 자동차지만 자동차 내부에 소프트웨어가 설치되어 있어서 게임을 할 수 있어요. 그리고 이것을 서비스 상 꼭 구현해야합니다. 인터페이스의 특징 상 하위 클래스는 상위 클래스에 없는 메서드를 구현할 수 없는데... 어떡하죠?
여러 인터페이스를 상위 클래스로 두는 방식을 생각해볼 수도 있겠네요. 또는, 인터페이스가 아니라 추상 클래스로 설계를 바꾸는 것도 고려해볼 수 있을 것 같아요.
추상 클래스 또한 추상 메서드만을 가지는 클래스입니다. 인터페이스와 굉장히 유사하죠. 차이점은 바로 확장 여부에 있습니다. 추상 클래스는 태생이 클래스이기 때문에 하위 클래스에서 확장(extends) 할 수 있습니다. 만약 Car 인터페이스가 추상 클래스로 선언되어 있다면 전기 자동차에만 게임하다() 메서드를 추가할 수 있는 거죠. 클래스에는 인터페이스처럼 하위클래스는 반드시 인터페이스에 선언된 메서드만 구현해야한다는 제약조건이 없기 때문에 확장성을 가집니다.
이처럼 추상 클래스와 인터페이스의 차이가 미묘한 이유는 인터페이스의 태생 때문인데요. 자바는 다중 상속을 지원하지 않습니다. 다중 상속에는 다이아몬드 문제(https://en.wikipedia.org/wiki/Multiple_inheritance)라는 큰 문제점이 있어서요. C++이나 파이썬에서는 여전히 다중 상속을 허용하는데, 명확한 문제점은 있지만 장점도 있기 때문입니다. 자바에서는 문제점은 해결하고, 장점만을 취하기 위해 인터페이스를 도입했습니다. 이러한 태생적 이유로 둘의 모습은 굉장히 비슷하죠.
이론적으로 생각하면, 개별 하위 클래스에서 확장성이 고려된다면 인터페이스 대신 추상 클래스로 설계하는 것이 옳아 보이지만 저는 그럼에도 인터페이스의 손을 들어주고 싶습니다. 개별 하위 클래스에서 너무 많은 확장이 일어난다면 설계 과정에서 추상화가 조금 잘못되었지 않을까? 하는 생각이 들어서요. 이 부분은 실무에서 추상 클래스를 본 적이 없어서 선입견이 생겼는지도 모르겠습니다.
인터페이스를 사용하는 이유 둘 : 구현 클래스가 아니라 인터페이스에 의존하기 위해(Loose Coupling)
다시 인터페이스를 사용하는 이유로 돌아와봅시다. 소프트웨어를 만들 때는 그 부품들에 대해 결합도는 낮추고 응집도는 강하게 설계하는 것이 좋습니다. 하나의 부품은 내부에서 하나의 역할만을 위해 응집해야 하고, 다른 부품끼리는 꼭 최대한 적은 부분만을 통해 결합되어야 합니다. 자동차가 타이어가 마모되었을 때 휠까지 교체할 필요는 없겠죠?
객체지향 프로그래밍에서 Loose Coupling 은 다형성을 활용하여 실현할 수 있습니다.
다시 이 코드를 봅시다. ElectricCar라는 하위클래스 객체를 생성했지만 생성한 클래스의 주소를 할당하는 electricCar 변수의 자료형이 중요합니다. Car 인터페이스죠? Car는 인터페이스이기 때문에 주유하다()는 추상 메서드이기 때문에 구현부가 없는데 어떻게 실행이 될까요? 바로 상위 클래스 타입의 변수를 사용해도 하위 클래스(생성한 객체)에서 구현(재정의)한 메서드가 호출되기 때문입니다.
즉, 하위 클래스는 상위 클래스로 묵시적 형변환이 가능하기 때문에 Car 인터페이스 자료형으로 지정된 electricCar 를 사용했음에도 구현클래스 ElectricCar의 주유하다()나 운전하다() 메서드를 사용할 수 있게된 것입니다.
예를 들어 List boards = new ArrayList() 라는 코드를 흔히 사용하는데, 이또한 List는 상위 인터페이스이고 ArrayList가 하위 구현 클래스이기 때문에 묵시적 형변환이 가능한것입니다. 제 설명이 조금 부족해서 이 부분에 대해서 이해가 어려우시면, 검색해보시거나 혹은 우선 넘어갑시다. 형변환이 가능함으로써 얻을 수 있는 장점을 알아보는 것을 통해 이해의 실마리를 얻을 수도 있습니다. 우선은 자식 클래스는 상위 클래스의 자료형으로 받아서 사용할 수 있고, 자식 클래스의 재정의된 메서드가 호출된다. 라는 사실만 알아두세요.
다시 메인 코드로 돌아오겠습니다. 여기서는 변수명이 car 입니다. ElectricCar 객체가 Car 자료형의 car 변수로 할당되었기 때문에 이 코드에서 실제 구현 객체에 대해 알 수 있는 정보는 new ElectricCar() 부분 밖에 없죠. 만약 이 메인 코드에서 자동차의 구동방식을 휘발유 자동차로 바꾸고 싶으면 어디를 바꿔야할까요?
생성하는 구현객체만 바꿔주면 됩니다. 휘발유차와 전기자동차는 같은 상위클래스를 가지기 때문에 둘 모두 Car 자료형으로 받을 수 있죠. 이게 바로 느슨한 결합, 인터페이스를 통해 의존성을 낮추는 방법입니다. 객체를 사용하는 입장(메인코드)에서 변경이 일어났을 때 최소한의 변경만이 가능하도록 하는 것이죠. 만약 인터페이스를 선언하지 않고 각각 PetrolCar와 ElectricCar 클래스를 생성했다면 자동차의 구동방식을 바꾸려면 더 많은 코드를 수정해야겠죠.
다시 한 번 말하지만 인터페이스에 의존하는 것을 통해, 클래스 간의 의존성(결합도)을 낮출 수 있습니다. 하지만, 자바 프로그램을 실행되는 main 메서드를 사용자라고 보면, 결국 자동차의 구동방식을 바꾸려면 사용자가 직접 코드를 수정하네요. 이게 어떻게 인터페이스에만 의존하는거야? 겨우 코드 한 두줄 수정을 줄이는게 장점이야? 라는 물음이 나올 수도 있겠네요. 결국 new 하위클래스()로 하위 클래스를 생성해줘야하니까요.
사용자(클라이언트)는 인터페이스에만 의존하는 게 아니라 차를 바꿔줄 때마다 new (하위클래스명) 을 수정해주어야 하기 때문에 과한 의존관계(강한 결합도)를 가지고 있습니다. 여기까지만 개발하고 그친다면 인터페이스를 사용하는 이유에서 아쉬움이 조금 남죠. 객체지향적 관점에서 소기의 성과는 있지만 결국 코드의 실용성에서 보면 인터페이스를 선언한 의미가 옅어집니다.
하지만 인터페이스를 통해서 설계 되었기 때문에 구체 클래스에 대한 의존을 더 끊어낼 수 있는 방법 또한 있습니다. 조건을 판별하고, 적절한 객체를 반환하는 팩토리 클래스를 하나 생성하면 되는데요.
CarFactory 클래스는 createCar() 메서드를 가집니다. createCar 메서드는 문자열로 차종을 전달받고, 차종이 "전기"면 전기자동차의 객체를 생성해서 반환하고 "휘발유"면 휘발유차 객체를 생성해서 반환합니다. CarFactory를 추가하고 다시 사용자(클라이언트, 메인) 쪽을 수정해보죠.
이제 구체 클래스에 대한 의존이 완전히 끊어진 게 보이시나요? 위의 클라이언트에서 구체 클래스(ElectricCar/PetrolCar)의 이름을 아예 찾을 수 없죠. 이후로 경유자동차나 수소자동차가 추가되어도 CarFactory에 조건만 추가한다면 createCar()의 파라미터가 "전기"든, "휘발유"든, "경유"든, "수소"든 반환되는 객체가 ElectricCar든, PetrolCar든, DieselCar든 결국 Car 인터페이스의 하위 클래스이기 때문에 다형성을 통해서 Car 자료형의 참조 변수로 받아들일 수 있고 반환된 객체가 재정의 한 방식으로 자동차가 구동하게 됩니다.
위의 예제코드에서는 "전기"라고 직접 파라미터를 넘기기는 했는데 실제 애플리케이션이라고 생각해봅시다. 자동차 시뮬레이션 웹 애플리케이션이 있습니다. 이 애플리케이션에서 유저는 전기자동차 모형, 휘발유자동차 모형, 경유자동차 모형을 보고 버튼 클릭으로 시뮬레이션 할 차종을 선택할 수 있는데요. 전기자동차를 클릭하면 HTTP 요청으로 "전기" 파라미터가 들어오고, 휘발유자동차를 클릭하면 "휘발유" 파라미터가 들어오면요? 이렇게 하면 우리가 실제로 코드에 대한 아무런 개입 없이 사용자의 선택에 따라 적절한 객체가 할당되고 그 객체의 행위를 수행할 수 있게 됩니다.
여기까지 인터페이스를 왜 쓰는지 알아보았습니다.
그럼 스프링에서는...?
하지만 또 일부의 의문은 남아있을 수도 있을 것 같네요. 회사에서 본 스프링 프로젝트의 인터페이스는 이렇게 예쁘지 않았을 확률이 높으니까요. 우선 저는 그랬습니다. 실무에서 보는 인터페이스는 대부분 위에서 정의한 인터페이스의 역할 중 첫 번째만 수행하고 있거나(구현의 강제) 혹은 인터페이스의 활용을 전혀 하지 못함에도 관례처럼 사용하고 있기 때문이에요.
그래서 저는 스프링으로 사이드 프로젝트를 진행할 때 Controller - Service interface - ServiceImpl - DAO interface - DAOImpl로 굳이 인터페이스를 선언하지 않고 Controller - Service - DAO 로 레이어를 설계하는 것을 선호합니다. 확장성에 대한 고려가 필요 없는 상황에서 인터페이스를 사용하는 것은 거추장스럽기만 하니까요.
그럼 스프링에서 인터페이스를 적절하게 활용하는 케이스를 짧게 한 번 볼까요? 이 예시는 김영한 님의 인프런 스프링 강의 중 일부를 간단하게 축약해 인용한 것입니다.
서비스의 개발 초기에 기획자가 개발 요청을 가지고 왔네요.
기획자 : 할인 정책을 개발해주세요. 음, 한 건을 구매하면 건당 무조건 천 원을 할인할 수 있게요.
개발자가 신나게 개발하러 가는 도중에 다시 기획자에게 메시지가 옵니다.
기획자 : 음 개발자님. 할인정책이 총 구매금액의 10퍼센트 할인으로 변경될 수도 있어요. 그런데 확정은 아니고요. 내일 오후쯤에 확정날 거 같은데, 서비스 오픈이 내일 오후 3시죠? 알아서 잘 해주세요.
극단적인 상황이지만, 할인 방법이 정해지지 않았고 혹은 바뀔 수 있는 상태에서 유연하게 개발할 수 있는 방법이 있습니다. 바로 인터페이스를 사용하는 것이죠. 스프링에서는 아주 편리한 방식으로 이것을 지원하는데요. 개발자가 작성한 코드를 봅시다.
DiscountService 인터페이스를 만들고 총 구매금액의 10퍼센트를 할인하는 로직을 가진 PercentDiscount 구체 클래스와 건당 천원을 할인하는 AbsoluteDiscount 하위 클래스를 모두 개발합니다. 이제 컨트롤러를 만들어야겠네요.
컨트롤러를 보면, 이제 이상한 게 보이시나요? @Autowired는 의존성을 주입받는 거고...(권장되는 방식은 아니지만 레거시 코드에서 자주 볼 수 있는 방식이죠) 그런데 의존성을 주입받는 게... 이제 보니 클래스가 아니라 인터페이스네요? 인터페이스는 구현부를 가지지 않은 껍데기인데... 그 아래 코드를 보니 인터페이스에 있는 discount 메서드를 사용하고 있어요.
다시 서비스 계층으로 돌아와서 코드를 보죠. AbsoluteDiscount 클래스 위의 @Service 어노테이션이 보이시나요? 스프링은 아주 복잡한 원리*를 통해서 @Service가 붙은 구체 클래스를 스프링이 시작할 때 보관해두었다가, @Autowired를 만나면 선언된 인터페이스의 하위 클래스 중 @Service 어노테이션이 붙은 객체를 주입해주는 겁니다. 여태껏 Service 인터페이스를 구현한 구현부가 하나인 프로젝트만 경험해왔다면 눈치채기 쉽지 않죠.
그럼 여기까지 왔지만 건당 할인이 적용되도록 개발이 되어있고, 이제 서버 오픈을 하려고 합니다. 그런데 갑자기 기획자가 서버 오픈 10분 전에 연락이 오네요.
개발자님! 할인 정책을 전체의 10퍼 할인으로 바꿔주세요!
오픈까지 10분밖에 남지 않은 상황이지만 개발자는 여유로울 수 있죠.
@Service 어노테이션의 위치만 바꿔주면 되니까요. 이게 가능한 이유는 DiscountService가 인터페이스고, 건당할인과 퍼센트할인 클래스가 그 하위클래스이기 때문입니다.
*복잡한 원리에 대한 매우 간단한 설명 : 스프링은 실행 시 @Service, @Repository, @Component 등의 어노테이션을 읽어서 스프링 컨테이너에 Bean으로 등록하고 @Autowired 시 적절한 Bean 객체를 주입한다.
정리
인터페이스를 사용하면 확장성을 높이며 유지보수를 효율적으로 할 수 있습니다. 그리고 결과적으로 "유연한 개발"을 할 수 있죠.
댓글