인터페이스라는 용어는 말 그대로 "접점"을 의미한다. 프로그래밍에서도 이러한 맥락은 그대로 유지된다.
간단한 예시 코드를 통해 인터페이스에 대해 알아보자.
/// 추상화된 인터페이스
abstract interface class BookRepository {
Future<List<BookModel>> getBooks();
Future<BookModel> getBook(String isbn);
void addBook();
void deleteBook();
void updateBook();
}
/// 인터페이스의 실제 구현
final class BookRepositoryImpl implements BookRepository {
void addBook() {
// TODO: implement addBook
}
void deleteBook() {
// TODO: implement deleteBook
}
Future<BookModel> getBook(String isbn) {
// TODO: implement getBook
throw UnimplementedError();
}
Future<List<BookModel>> getBooks() {
// TODO: implement getBooks
throw UnimplementedError();
}
void updateBook() {
// TODO: implement updateBook
}
}
위의 예시와 같이, 인터페이스는 추상화된 함수의 이름과 매개변수, 그리고 반환타입만 선언한다. 그리고 이를 상속받은 실제 구현체 함수에서 비즈니스 로직을 정의한다.
대체 무엇때문에 이렇게 불편하게 코드를 작성하는 것일까?
인터페이스를 사용한다는 것은 쉽게 말해, 다음과 같은 의미를 가진다.
"내부 구현에는 관심이 전혀 없고, 결과만 제대로 전달해"
대표적인 예시로, 소셜 로그인 기능을 들 수 있다. 예를 들어, 우리가 아래와 같은 기능을 구현한다고 가정해보자.
파이어베이스의 Auth 기능을 통해, 구글 소셜 로그인 기능을 구현한다.
그러면 우리는 FirebaseAuth 인증을 위해 아래와 같은 코드를 작성할 것이다.
final class FirebaseAuthService {
void signInWithGoogle() {
// 파이어베이스 구글 로그인 동작 수행
}
void signInWithApple() {
// 파이어베이스 애플 로그인 동작 수행
}
}
이렇게 코드 작성을 마친 우리는, 훌륭하게 서비스를 런칭할 수 있었다. 그리고 6개월 뒤, 다음과 같은 소식이 들려왔다.
이제부터, Firebase에서 Supabase로 넘어가려고 합니다. 기존 서비스에 문제가 생기면 안되니, Firebase의 인증과 Supabase의 인증을 한동안 같이 사용하다가 점진적으로 Supabase로 이동하게로 결정되었습니다.
청천벽력같은 일이다. 기존에 잘 사용하던 Firebase를 버리고 Supabase로 가야한다니! 우리는 이제 다시 인증 관련 코드를 개발해야만 하게 생겼다. 그런데, FirebaseAuthService가 생각보다 많은 Repository에서 사용되고 있었다. 우리는 이 모든 코드를 수정해야하는데, 너무나도 많은 곳에 산재되어 있다 보니, 리팩토링에 시간이 많이 걸릴 것 같다. 큰일이다.
자, 이러한 상황은 그리 드문 일이 아니다. 우리는 이러한 상황을 사전에 최대한 방지하기 위해, 인터페이스를 사용해야한다. 그렇다면, 어떤 방식으로 코드를 구현해야 앞서 발생한 상황에서 손쉽게 대처가 가능했을까?
장황한 설명보다는 직접 코드를 살펴보자.
abstract interface class SocialService {
void signInWithGoogle();
void signInWithApple();
}
/// 기존 파이어베이스를 사용하는 로그인 프로세스
final class FirebaseAuthService implements SocialService {
void signInWithGoogle() {
// 파이어베이스 구글 로그인 동작 수행
}
void signInWithApple() {
// 파이어베이스 애플 로그인 동작 수행
}
}
/// 신규 수파베이스를 사용하는 로그인 프로세스
final class SupabaseAuthService implements SocialService {
void signInWithGoogle() {
// 수파베이스 구글 로그인 동작 수행
}
void signInWithApple() {
// 수파베이스 애플 로그인 동작 수행
}
}
위의 코드를 통해, 우리는 FirebaseAuthService라는 구현체와 SupabaseAuthService라는 구현체를 만들고, 이 두 구현체가 상속하는 SocialService라는 인터페이스를 만들었다.
이렇게 작성할 경우, 우리는 SocialService라는 타입을 통해 파이어베이스와 수파베이스의 로그인 동작을 의존성 주입만 바꿔주면 손쉽게 교체할 수 있게 된다. 바로, 아래와 같이.
/* Repository 선언부 */
...
final loginRepo = LoginRepository(
socialService: FirebaseAuthService(),
// socialService: SupabaseAuthService(), // 필요시 교체
);
...
-------------------------------
/* Repository 구현부 */
final class LoginRepository {
final SocialService _socialService;
const LoginRepository({
required SocialService socialService,
}) : _socialService = socialService;
void socialLogin(SocialType type) {
switch(type) {
case SocialType.google:
_socialService.signInWithGoogle();
break;
case SocialType.apple:
_socialService.signInWithApple();
break;
}
}
}
이렇게 되면, LoginRepository는 SocialService가 실제로 어떻게 구현되어 있든지 전혀 관계없이, 결과만을 원하는 함수 호출을 하게 된다.
이렇게 인터페이스가 무엇이고, 왜 필요하며, 실제로 어떻게 작성하는지 알아보았다. 이러한 추상화는 객체간의 의존도를 떨어트리고, 코드의 유지보수성을 용이하게 하는 장점을 가지지만, 단점으로는 가독성이 조금 떨어지게 된다는 점이 있다.
(호출되는 함수가 인터페이스에 정의되어 있기 때문에, 곧바로 실제 구현부를 찾기 어렵기 때문이다.)
하지만, 단점이 있음에도 장점이 가져다주는 효과가 크기에, 인터페이스를 활용한 코드 작성 방식은 익혀두고 사용하면 매우 좋은 개발 노하우가 될 것이다.