이전 글에서는 다형성을 활용해서 여러 구현을 같은 타입으로 다루는 방법을 정리했습니다. 이번 글에서는 한 걸음 더 나아가 다형성이 객체 지향 설계에서 왜 중요한지를 살펴보겠습니다.
다형성은 단순히 문법 문제가 아닙니다. 코드가 변경에 강해지도록 역할과 구현을 나누는 방법입니다.
아직 다형성의 기본 문법이 헷갈린다면 자바 다형성 정리 - 부모 타입으로 자식 객체 다루기를 먼저 보는 편이 좋습니다. 추상 클래스와 인터페이스 문법이 낯설다면 자바 다형성 활용 정리 - 추상 클래스와 인터페이스까지를 먼저 확인하면 흐름이 더 자연스럽습니다.
좋은 객체 지향 코드는 무엇일까?
좋은 객체 지향 코드는 단순히 클래스를 많이 만든 코드가 아닙니다. 중요한 것은 변경이 생겼을 때 어디를 고치면 되는가입니다.
예를 들어 웹 서비스에서 프로필 이미지를 저장한다고 생각해보겠습니다. 처음에는 서버 컴퓨터의 폴더에 이미지를 저장할 수 있습니다. 서비스가 커지면 나중에는 클라우드 스토리지에 저장해야 할 수도 있습니다.
이때 코드 전체가 특정 저장 방식에 강하게 묶여 있으면 변경이 어려워집니다. 반대로 “파일을 저장한다”는 역할과 실제 저장 방식을 분리하면 변경이 쉬워집니다.
역할: 파일을 저장한다
구현: 로컬 폴더에 저장한다, 클라우드 스토리지에 저장한다
다형성은 이 역할과 구현을 나누는 데 사용됩니다.
구체 클래스에 직접 의존하는 코드
먼저 다형성을 사용하지 않은 코드를 보겠습니다.
프로필 이미지 저장 기능이 LocalFileStorage를 직접 사용합니다.
class LocalFileStorage {
void save(String fileName) {
System.out.println("로컬 폴더에 저장: " + fileName);
}
}
class ProfileImageService {
private LocalFileStorage storage = new LocalFileStorage();
void upload(String fileName) {
System.out.println("프로필 이미지 업로드 시작");
storage.save(fileName);
System.out.println("프로필 이미지 업로드 완료");
}
}
이 코드는 동작합니다.
하지만 ProfileImageService가 LocalFileStorage를 직접 알고 있습니다.
ProfileImageService
↓ 직접 의존
LocalFileStorage
나중에 저장 방식을 클라우드로 바꾸려면 ProfileImageService 안의 코드도 수정해야 합니다.
서비스 코드는 “이미지를 업로드한다”는 흐름만 알면 충분한데, 구체적인 저장 방식까지 알고 있는 상태입니다.
역할을 인터페이스로 분리하기
이제 “파일을 저장한다”는 역할을 인터페이스로 분리해보겠습니다.
interface FileStorage {
void save(String fileName);
}
FileStorage는 실제 저장 방법을 모릅니다.
그저 파일 저장 기능이 반드시 save()를 가져야 한다는 약속만 정합니다.
이제 로컬 저장소와 클라우드 저장소가 이 인터페이스를 구현하게 만들 수 있습니다.
class LocalFileStorage implements FileStorage {
@Override
public void save(String fileName) {
System.out.println("로컬 폴더에 저장: " + fileName);
}
}
class CloudFileStorage implements FileStorage {
@Override
public void save(String fileName) {
System.out.println("클라우드 스토리지에 저장: " + fileName);
}
}
두 클래스는 저장 방식이 다릅니다.
하지만 둘 다 FileStorage 역할을 수행합니다.
FileStorage 역할
├─ LocalFileStorage 구현
└─ CloudFileStorage 구현
서비스는 역할에 의존하게 만들기
이제 ProfileImageService가 구체 클래스가 아니라 인터페이스에 의존하게 바꿔보겠습니다.
class ProfileImageService {
private FileStorage storage;
ProfileImageService(FileStorage storage) {
this.storage = storage;
}
void upload(String fileName) {
System.out.println("프로필 이미지 업로드 시작");
storage.save(fileName);
System.out.println("프로필 이미지 업로드 완료");
}
}
중요한 변화는 필드 타입입니다.
private FileStorage storage;
이제 ProfileImageService는 로컬 저장소인지 클라우드 저장소인지 모릅니다.
그저 FileStorage 역할을 가진 객체를 받아서 save()를 호출할 뿐입니다.
사용하는 쪽에서 어떤 구현을 넣을지 결정합니다.
public class Main {
public static void main(String[] args) {
FileStorage storage = new LocalFileStorage();
ProfileImageService service = new ProfileImageService(storage);
service.upload("profile.png");
}
}
실행 결과입니다.
프로필 이미지 업로드 시작
로컬 폴더에 저장: profile.png
프로필 이미지 업로드 완료
클라우드 저장소로 바꾸고 싶다면 생성하는 객체만 바꾸면 됩니다.
public class Main {
public static void main(String[] args) {
FileStorage storage = new CloudFileStorage();
ProfileImageService service = new ProfileImageService(storage);
service.upload("profile.png");
}
}
실행 결과입니다.
프로필 이미지 업로드 시작
클라우드 스토리지에 저장: profile.png
프로필 이미지 업로드 완료
ProfileImageService 코드는 그대로입니다.
저장 방식만 바뀌었습니다.
역할과 구현을 나누면 좋은 점
역할과 구현을 나누면 코드를 바꾸는 범위가 줄어듭니다. 서비스 코드는 역할만 바라보고, 실제 동작은 구현 객체가 담당합니다.
ProfileImageService
↓ 역할에 의존
FileStorage
↑ 구현
LocalFileStorage, CloudFileStorage
이 구조의 장점은 다음과 같습니다.
- 저장 방식이 바뀌어도 서비스 흐름은 유지됩니다.
- 새 저장소 구현을 추가하기 쉽습니다.
- 테스트할 때 가짜 저장소를 넣어 확인할 수 있습니다.
- 코드가 “무엇을 하는지”와 “어떻게 하는지”로 나뉩니다.
처음에는 인터페이스가 오히려 코드를 늘리는 것처럼 보일 수 있습니다. 하지만 변경 가능성이 있는 부분이라면 역할을 분리하는 편이 나중에 더 안전합니다.
OCP 원칙
OCP는 Open-Closed Principle의 줄임말입니다. 한국어로는 개방-폐쇄 원칙이라고 부릅니다.
핵심은 다음과 같습니다.
확장에는 열려 있어야 하고,
변경에는 닫혀 있어야 합니다.
처음 보면 말이 조금 추상적입니다. 방금 만든 파일 저장 예제로 보면 더 쉽습니다.
새로운 저장 방식인 DatabaseFileStorage를 추가한다고 생각해보겠습니다.
class DatabaseFileStorage implements FileStorage {
@Override
public void save(String fileName) {
System.out.println("데이터베이스에 파일 정보 저장: " + fileName);
}
}
이제 사용하는 쪽에서 구현만 바꾸면 됩니다.
FileStorage storage = new DatabaseFileStorage();
ProfileImageService service = new ProfileImageService(storage);
service.upload("profile.png");
새 구현은 추가했습니다.
하지만 ProfileImageService는 수정하지 않았습니다.
이것이 OCP에 가까운 구조입니다.
확장: DatabaseFileStorage 추가
변경: ProfileImageService 수정 없음
OCP가 깨지는 구조
반대로 서비스 안에서 구현을 직접 고르면 OCP가 깨지기 쉽습니다.
class ProfileImageService {
void upload(String fileName, String storageType) {
System.out.println("프로필 이미지 업로드 시작");
if (storageType.equals("local")) {
LocalFileStorage storage = new LocalFileStorage();
storage.save(fileName);
} else if (storageType.equals("cloud")) {
CloudFileStorage storage = new CloudFileStorage();
storage.save(fileName);
}
System.out.println("프로필 이미지 업로드 완료");
}
}
이 방식은 저장 방식이 늘어날 때마다 if 문이 계속 추가됩니다.
새 구현을 추가하는 일이 기존 서비스 코드를 수정하는 일로 이어집니다.
새 저장 방식 추가
↓
ProfileImageService의 if 문 수정
↓
기존 코드에 영향 가능성 증가
동작은 할 수 있습니다. 하지만 변경이 잦아질수록 코드가 점점 불안정해집니다.
다형성만으로 모든 문제가 해결되지는 않습니다
다형성은 강력하지만, 다형성만 쓴다고 자동으로 좋은 설계가 되는 것은 아닙니다. 중요한 것은 어디가 바뀔 가능성이 있는지를 보는 것입니다.
예를 들어 파일 저장 방식이 앞으로 절대 바뀌지 않는 작은 프로그램이라면 인터페이스까지 나누는 것이 과할 수 있습니다. 반대로 저장 방식이 바뀔 가능성이 높거나, 테스트를 쉽게 하고 싶거나, 여러 구현을 교체해야 한다면 인터페이스가 도움이 됩니다.
바뀔 가능성이 낮음: 단순한 코드가 더 좋을 수 있음
바뀔 가능성이 높음: 역할과 구현을 나누는 편이 좋음
객체 지향 설계는 무조건 복잡하게 만드는 것이 아닙니다. 변경이 생길 부분을 예상하고, 그 부분을 부드럽게 갈아끼울 수 있게 만드는 것입니다.
역할과 구현을 구분하는 연습
다형성을 설계에 활용하려면 먼저 역할과 구현을 구분하는 연습이 필요합니다.
아래처럼 질문해보면 도움이 됩니다.
- 이 객체가 맡은 역할은 무엇인가?
- 같은 역할을 하는 다른 구현이 생길 수 있는가?
- 사용하는 쪽이 구체 클래스를 꼭 알아야 하는가?
- 새 구현이 추가될 때 기존 코드를 얼마나 고쳐야 하는가?
파일 저장 예제에서는 이렇게 나눌 수 있습니다.
| 구분 | 예시 |
|---|---|
| 역할 | FileStorage |
| 구현 | LocalFileStorage, CloudFileStorage |
| 사용하는 쪽 | ProfileImageService |
| 변하기 쉬운 부분 | 저장 방식 |
| 유지하고 싶은 부분 | 프로필 이미지 업로드 흐름 |
처음부터 완벽하게 나누려고 하면 어렵습니다. 작은 예제에서 “이건 역할인가, 구현인가”를 계속 구분해보는 것이 중요합니다.
전체 코드
마지막으로 지금까지 만든 코드를 한 번에 정리해보겠습니다.
interface FileStorage {
void save(String fileName);
}
class LocalFileStorage implements FileStorage {
@Override
public void save(String fileName) {
System.out.println("로컬 폴더에 저장: " + fileName);
}
}
class CloudFileStorage implements FileStorage {
@Override
public void save(String fileName) {
System.out.println("클라우드 스토리지에 저장: " + fileName);
}
}
class DatabaseFileStorage implements FileStorage {
@Override
public void save(String fileName) {
System.out.println("데이터베이스에 파일 정보 저장: " + fileName);
}
}
class ProfileImageService {
private FileStorage storage;
ProfileImageService(FileStorage storage) {
this.storage = storage;
}
void upload(String fileName) {
System.out.println("프로필 이미지 업로드 시작");
storage.save(fileName);
System.out.println("프로필 이미지 업로드 완료");
}
}
public class Main {
public static void main(String[] args) {
FileStorage storage = new CloudFileStorage();
ProfileImageService service = new ProfileImageService(storage);
service.upload("profile.png");
}
}
실행 결과입니다.
프로필 이미지 업로드 시작
클라우드 스토리지에 저장: profile.png
프로필 이미지 업로드 완료
ProfileImageService는 CloudFileStorage를 직접 알지 못합니다.
그저 FileStorage 역할에 의존합니다.
그래서 다른 구현으로 바꾸더라도 서비스 코드는 그대로 유지할 수 있습니다.
실수하기 쉬운 부분
첫째, 인터페이스를 만들었는데 사용하는 쪽이 여전히 구체 클래스에 의존하는 경우입니다.
class ProfileImageService {
private CloudFileStorage storage = new CloudFileStorage();
}
이렇게 작성하면 인터페이스를 만든 효과가 줄어듭니다.
필드 타입도 가능하면 역할인 FileStorage로 두는 편이 좋습니다.
둘째, 모든 클래스에 무조건 인터페이스를 붙이는 경우입니다.
인터페이스는 변경 가능성이 있거나 여러 구현이 필요한 곳에서 힘을 발휘합니다. 반대로 구현이 하나뿐이고 바뀔 가능성도 낮다면 처음에는 단순하게 시작해도 됩니다.
셋째, 다형성을 if 문 제거 도구로만 생각하는 경우입니다.
다형성의 진짜 목적은 조건문을 줄이는 것만이 아닙니다. 코드가 구체적인 구현이 아니라 역할에 기대도록 만드는 것입니다.
정리
다형성은 부모 타입이나 인터페이스 타입으로 여러 구현을 다루는 문법입니다. 하지만 객체 지향 설계에서는 더 큰 의미를 가집니다.
역할과 구현을 나누면 사용하는 쪽은 역할만 바라볼 수 있습니다. 구현이 바뀌어도 사용하는 쪽의 코드를 덜 수정하게 됩니다.
OCP는 확장에는 열려 있고 변경에는 닫혀 있어야 한다는 원칙입니다. 새 구현을 추가하면서 기존 핵심 코드를 덜 건드릴 수 있다면 OCP에 가까운 구조라고 볼 수 있습니다.
처음에는 이 문장만 기억해도 좋습니다.
구체 클래스보다 역할에 의존하면,
구현을 바꿀 때 코드가 덜 흔들립니다.
다형성은 어려운 문법이 아니라, 변경을 견디는 코드를 만들기 위한 도구입니다.