자바를 공부하다 보면 interface라는 키워드를 마주치게 된다. 처음에는 "왜 굳이 이걸 쓰지?"라는 의문이 생기기 마련이다. 이번 글에서는 인터페이스가 무엇인지, 왜 사용하는지를 최대한 쉽게 풀어보려 한다.

인터페이스는 "이 클래스는 반드시 이런 기능을 가져야 한다" 는 것을 약속(규약)처럼 정의해두는 것이다.
예를 들어, "리모컨"이라는 인터페이스가 있다면 전원켜기(), 볼륨올리기(), 채널변경() 같은 기능이 반드시 있어야 한다고 명시하는 것이다. TV 리모컨이든, 에어컨 리모컨이든 구체적인 동작 방식은 달라도, 리모컨이라면 이 기능들은 무조건 있어야 한다는 약속이다.
// "리모컨이라면 이 기능들은 반드시 있어야 한다"고 약속만 한다
public interface Remote {
void powerOn();
void volumeUp();
void changeChannel(int channel);
}
// TV 리모컨은 이 약속을 지키며 자신만의 방식으로 구현한다
public class TvRemote implements Remote {
public void powerOn() { System.out.println("TV 전원을 켭니다."); }
public void volumeUp() { System.out.println("TV 볼륨을 높입니다."); }
public void changeChannel(int channel) { System.out.println(channel + "번 채널로 변경합니다."); }
}
인터페이스는 "무엇을 해야 하는지(What)" 만 정의하고, "어떻게 할 것인지(How)" 는 구현 클래스에게 맡긴다.
덕분에 서로 다른 구현체를 동일한 타입으로 취급할 수 있다. 이것을 다형성(Polymorphism) 이라고 한다.
// 둘 다 Remote 타입으로 다룰 수 있다
Remote tvRemote = new TvRemote();
Remote acRemote = new AirConditionerRemote();
tvRemote.powerOn(); // TV 전원을 켭니다.
acRemote.powerOn(); // 에어컨 전원을 켭니다.
자바의 클래스는 부모 클래스를 하나만 상속받을 수 있다. 하지만 인터페이스는 여러 개를 동시에 구현할 수 있다.
스마트폰을 예로 들면, 스마트폰은 카메라이기도 하고, 전화기이기도 하고, 인터넷 기기이기도 하다.
// 스마트폰은 3가지 역할을 동시에 수행한다
public class Smartphone implements Camera, Phone, Internet {
// 3가지 인터페이스에서 요구하는 기능들을 모두 구현한다
}
인터페이스를 사용하면 실제 구현체가 바뀌어도 이를 사용하는 쪽의 코드를 수정할 필요가 없다.
마치 콘센트처럼, 규격(인터페이스)만 맞으면 어떤 플러그(구현체)든 꽂아서 쓸 수 있는 것과 같다.
이 개념은 개방-폐쇄 원칙(OCP, Open-Closed Principle) 이라고 부른다.
"확장에는 열려 있고, 변경에는 닫혀 있어야 한다"는 객체 지향 설계 원칙이다.
처음에 인터페이스는 추상 메서드(껍데기 메서드)와 상수만 가질 수 있었다. 하지만 자바가 발전하면서 인터페이스도 함께 진화했다.
default 메서드는 인터페이스 안에 기본 구현을 직접 작성할 수 있게 해준다.
왜 필요할까? 기존 인터페이스에 새 메서드를 추가하면, 그 인터페이스를 구현한 모든 클래스에서 컴파일 에러가 발생한다. default 메서드를 쓰면 기존 코드를 건드리지 않고 새 기능을 추가할 수 있다.
static 메서드는 인터페이스를 객체로 만들지 않아도 인터페이스명.메서드명() 으로 바로 호출할 수 있는 유틸리티 메서드다.
public interface PaymentService {
void processPayment(int amount); // 추상 메서드 (구현체가 반드시 구현해야 함)
// default 메서드: 기본 구현을 제공, 필요하면 오버라이딩 가능
default void printReceipt() {
System.out.println("기본 영수증을 출력합니다.");
}
// static 메서드: PaymentService.validateAmount(500) 처럼 바로 호출 가능
static void validateAmount(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("금액은 0보다 커야 합니다.");
}
}
}
인터페이스 내부에서만 사용하는 private 메서드를 작성할 수 있게 되었다.
여러 default 메서드에서 공통으로 사용하는 코드를 private 메서드로 분리해 중복을 없애고, 외부에 노출하지 않을 수 있다.

둘 다 "완성되지 않은 설계도"라는 공통점이 있지만, 사용하는 목적이 다르다.
| 구분 | 추상 클래스 | 인터페이스 |
|---|---|---|
| 핵심 질문 | "이 객체는 무엇인가?" (IS-A) | "이 객체는 무엇을 할 수 있는가?" (CAN-DO) |
| 다중 상속 | 불가능 (하나만) | 가능 (여러 개) |
| 필드 | 일반 변수 사용 가능 | 상수(static final)만 가능 |
| 예시 | 동물 → 강아지, 고양이 | 날 수 있다 → 새, 비행기, 드론 |
한 줄 정리:
Spring으로 백엔드를 개발하다 보면 아래처럼 인터페이스와 구현체를 분리하는 패턴을 자주 보게 된다.
UserService (인터페이스)
└── UserServiceImpl (구현체)
처음엔 "왜 굳이 나누지?"라는 생각이 들 수 있다. 이유는 두 가지다.
이유 1. 구현체를 쉽게 교체할 수 있다 (DI, 의존성 주입)
DI(Dependency Injection, 의존성 주입)란?
객체가 직접 필요한 객체를 만들지 않고, 외부에서 만들어진 객체를 주입받는 방식이다. Spring이 이를 자동으로 처리해준다.
Controller는 UserService 인터페이스만 바라본다. 구현체가 UserServiceImpl에서 NewUserServiceImpl로 바뀌어도 Controller 코드는 전혀 수정할 필요가 없다.
이유 2. 테스트가 쉬워진다
실제 DB 없이도 인터페이스를 흉내 내는 가짜 객체(Mock Object)를 만들어 테스트할 수 있어, 테스트 작성이 훨씬 편해진다.
@RestController
@RequiredArgsConstructor
public class UserController {
// 구현체(UserServiceImpl)가 아닌 인터페이스를 주입받는다
// → 구현체가 바뀌어도 이 코드는 수정 불필요
private final UserService userService;
@PostMapping("/users")
public ResponseEntity<Void> createUser(@RequestBody UserDto dto) {
userService.join(dto);
return ResponseEntity.ok().build();
}
}
인터페이스는 처음엔 "굳이 왜?"라는 생각이 드는 개념이지만, 코드가 커질수록 그 진가를 발휘한다.
"이 클래스는 반드시 이런 기능을 해야 한다" 는 약속을 코드로 표현하는 것, 그것이 인터페이스의 본질이다.
자바를 배우는 단계에서는 문법을 익히는 것도 중요하지만, 왜 이렇게 설계하는가에 대한 감각을 함께 키워나가길 바란다.