1. 다층 추상화
프로그래밍을 막 시작했을 때는 모든 것이 단순해 보입니다: 클래스를 만들고, 메서드를 호출하고, 결과를 받으면 끝이죠. 하지만 실제 프로젝트에서는 복잡해집니다: 수십 개의 클래스, 수백 개의 메서드, 수천 줄의 코드... 거기에 팀으로 함께 작업한다면 — 상황은 더 어려워집니다! 이 혼돈 속에서 어떻게 길을 잃지 않을 수 있을까요?
해답은 — 복잡한 것을 단순한 것으로 나누는 것, 더 나아가 — 추상화 계층으로 나누는 것입니다.
추상화 계층이란?
큰 건물의 층처럼 생각해 보세요: 각 층은 제 역할을 하지만, 모든 층은 서로 연결되어 있습니다. 프로그래밍에서는 보통 다음과 같은 계층을 구분합니다:
- 사용자 인터페이스(UI) — 사용자가 보는 부분.
- 비즈니스 로직 — 애플리케이션의 본질을 구현하는 규칙과 프로세스.
- 데이터 액세스(DAO, Repository) — 데이터베이스나 파일을 다루는 계층.
각 계층은 다른 계층의 세부 구현을 모르는 상태에서 추상화로 동작합니다. 예를 들어 비즈니스 로직은 사용자 인터페이스가 어떻게 구현됐는지나 데이터가 어떻게 저장되는지는 중요하지 않습니다 — 중요한 것은 saveOrder()나 findUserById() 같은 메서드가 있다는 사실이죠.
생활 속 비유
레스토랑을 떠올려 보세요. 손님(UI)은 웨이터(인터페이스의 추상화)를 통해 주문하고, 요리사(비즈니스 로직)는 음식을 만들며, 자재 담당자(데이터 액세스)는 창고의 재고를 관리합니다. 손님은 요리사가 정확히 어떻게 조리하는지 모르고, 요리사는 감자가 어디에 보관되는지 신경 쓰지 않습니다 — 중요한 건 필요할 때 손이 닿는다는 점입니다.
2. 예시: 실전의 다층 아키텍처
우리의 학습용 프로젝트를 확장해 봅시다 — 예를 들어 작업 관리(task manager) 애플리케이션. 우리는 이미 작업을 위한 클래스를 만들 수 있으니, 이제 구조를 조금 더 발전시켜 애플리케이션을 계층으로 나눠 보겠습니다.
추상화 도출
- Task — 작업에 대한 추상적 정의: 작업에는 제목, 상태가 있고, 실행을 위한 메서드가 있습니다.
- TaskRepository — 작업 저장소에 대한 추상화(어디에 저장되는지는 중요하지 않음 — 메모리, 파일, 데이터베이스).
- TaskService — 비즈니스 로직: 추가, 검색, 실행.
추상 클래스와 인터페이스
// 비즈니스 로직 계층
public abstract class Task {
private String title;
private boolean completed;
public Task(String title) {
this.title = title;
this.completed = false;
}
public abstract void complete();
public String getTitle() { return title; }
public boolean isCompleted() { return completed; }
protected void setCompleted(boolean completed) { this.completed = completed; }
}
// 데이터 저장 계층(추상화)
public interface TaskRepository {
void save(Task task);
Task findByTitle(String title);
List<Task> findAll();
}
계층 구현
Task 구현
public class WorkTask extends Task {
private String deadline;
public WorkTask(String title, String deadline) {
super(title);
this.deadline = deadline;
}
@Override
public void complete() {
setCompleted(true);
System.out.println("업무 작업 '" + getTitle() + "'이 마감일 " + deadline + "까지 완료되었습니다");
}
}
TaskRepository 구현
public class InMemoryTaskRepository implements TaskRepository {
private List<Task> tasks = new ArrayList<>();
@Override
public void save(Task task) {
tasks.add(task);
}
@Override
public Task findByTitle(String title) {
for (Task task : tasks) {
if (task.getTitle().equals(title)) {
return task;
}
}
return null;
}
@Override
public List<Task> findAll() {
return new ArrayList<>(tasks);
}
}
TaskService 구현
public class TaskService {
private TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}
public void addTask(Task task) {
repository.save(task);
}
public void completeTask(String title) {
Task task = repository.findByTitle(title);
if (task != null) {
task.complete();
} else {
System.out.println("작업을 찾을 수 없습니다: " + title);
}
}
public void showAllTasks() {
for (Task task : repository.findAll()) {
System.out.println(task.getTitle() + " — " + (task.isCompleted() ? "완료됨" : "미완료"));
}
}
}
메인 클래스에서의 사용
public class Main {
public static void main(String[] args) {
TaskRepository repo = new InMemoryTaskRepository();
TaskService service = new TaskService(repo);
service.addTask(new WorkTask("보고서 작성", "2025-07-15"));
service.addTask(new WorkTask("프레젠테이션 준비", "2025-07-16"));
service.showAllTasks();
service.completeTask("보고서 작성");
service.showAllTasks();
}
}
우리가 얻은 것
- 메인 클래스(Main)는 작업 저장소의 내부 구조를 모릅니다 — TaskRepository라는 추상화로만 동작합니다.
- TaskService는 어떤 종류의 작업이 있는지 모릅니다 — 추상 클래스 Task로만 동작합니다.
- 내일 작업을 메모리가 아닌 데이터베이스에 저장하고 싶다면 — 비즈니스 로직과 UI를 고치지 않고 새 클래스 DatabaseTaskRepository만 구현하면 됩니다.
- 새로운 작업 타입이 생긴다면, 예를 들어 HomeTask처럼 — 새 하위 클래스를 추가하기만 하면 됩니다.
3. 팀 협업에 대한 장점
큰 프로젝트에서 한 사람이 모든 것을 다 작성하는 일은 드뭅니다. 보통 팀은 “프런트엔드 개발자”, “백엔드 개발자”, “저장소(데이터) 개발자” 등으로 나뉩니다. 추상화는 이들이 서로의 발을 밟지 않도록 어떻게 도울까요?
책임 분리
각자는 자신의 추상화 계층에서 일합니다.
- 한 개발자는 데이터베이스 연동을 위한 TaskRepository 구현을 작성합니다.
- 다른 개발자는 비즈니스 로직(TaskService)을 담당합니다.
- 또 다른 사람은 사용자 인터페이스를 만듭니다.
계층 간 계약은 추상화로 고정됩니다.
모두가 TaskRepository에 save, findByTitle, findAll 메서드가 있다는 데 합의하면 — 구현 세부사항은 중요하지 않습니다.
테스트와 컴포넌트 교체의 용이성
- 한 구현을 다른 구현으로 손쉽게 교체할 수 있습니다(예: 테스트에서는 InMemoryTaskRepository를 쓰고, 프로덕션에서는 데이터베이스 구현을 사용).
- 테스터는 데이터 계층을 “목(mock)”으로 대체해 비즈니스 로직을 독립적으로 테스트할 수 있습니다.
독립적인 발전
- 누군가 새로운 작업 유형을 추가해도 기존 코드를 깨지 않습니다 — Task의 새 하위 클래스를 구현하기만 하면 됩니다.
- 새로운 데이터 저장 방식이 생겨도 인터페이스 구현만 바꾸면 되고, 나머지 코드는 건드릴 필요가 없습니다.
4. Best practices: 추상화를 과하게 쓰지 않는 법
추상화는 요리의 소금과 같습니다: 없으면 싱겁고, 너무 넣으면 모든 걸 망칩니다. 다음을 참고하세요:
추상화는 시스템을 실제로 단순화하는 곳에서만 사용하세요.
추상을 위한 추상은 지양하세요. 작업 유형이 하나뿐이라면, 어쩌면 추상화가 필요하지 않을 수도 있습니다.
추상 클래스와 메서드를 문서화하세요.
좋은 문서는 하위 클래스가 무엇을, 왜 구현해야 하는지 이해하는 데 도움을 줍니다.
추상화는 의미 있어야 합니다.
추상 클래스는 진정으로 공통된 동작 및/또는 상태를 표현해야 합니다.
책임을 섞지 마세요.
특정 하위 클래스에만 필요한 메서드를 추상 클래스에 넣지 마세요.
5. 대규모 시스템에서의 추상화: 실제 사례
전자상거래 같은 실제 대형 프로젝트에서 추상화가 어떻게 동작하는지 살펴보겠습니다.
시스템의 계층
- 컨트롤러(UI): 사용자 요청을 받습니다(예: “주문하기”).
- 서비스(비즈니스 로직): 재고를 확인하고, 할인을 계산하며, 주문을 처리합니다.
- 리포지토리(데이터 액세스): 주문, 상품, 사용자 등을 데이터베이스에 저장합니다.
추상화 예시
// 주문 처리 서비스에 대한 추상화
public interface OrderService {
void createOrder(Order order);
Order findOrderById(String id);
}
// 주문 저장소에 대한 추상화
public interface OrderRepository {
void save(Order order);
Order findById(String id);
}
각 계층은 자신에게 관련된 추상화만 압니다. 내일 주문을 클라우드에 저장하기로 해도 — 바뀌는 것은 OrderRepository의 구현뿐입니다.
계층 간 상호작용 — 도식
[UI/Controller] <--> [OrderService (추상화)] <--> [OrderRepository (추상화)] <--> [데이터베이스]
- 각 계층은 하위 계층의 세부 구현을 모른 채 추상화로만 동작합니다.
- 이 덕분에 각 계층을 서로 독립적으로 개발, 테스트, 개선할 수 있습니다.
추상화와 코드 유지보수
- 새로운 기능을 쉽게 추가할 수 있습니다(새 작업, 결제, 운송 수단 등).
- 버그를 쉽게 수정할 수 있습니다(한 곳을 고치면 모든 하위 클래스가 업데이트를 받습니다).
- 테스트가 쉽습니다 (유닛 테스트를 위해 계층을 “목”으로 대체할 수 있습니다).
6. 추상화 설계 시 흔한 실수
오류 №1: 과도한 추상화. 무엇이든 추상 클래스로 만들고 싶어질 때가 있습니다. 하지만 엔터티가 한 종류뿐이라면 유행처럼 추상화를 도입하지 마세요 — 코드를 더 복잡하게 만들 뿐입니다.
오류 №2: 지나치게 모호한 추상화. 추상 클래스가 너무 많은 것을 포괄하고 명확한 책임 범위가 없다면, 하위 클래스가 불필요한 메서드를 구현하거나 “죽은” 필드를 갖게 됩니다.
오류 №3: 단일 책임 원칙 위반. 추상 클래스는 오직 하나의 책임 영역만 가져야 합니다. 예컨대 저장 관련 메서드와 비즈니스 로직 메서드를 한 추상 클래스에 섞지 마세요.
오류 №4: 계층 간 강한 결합. 비즈니스 로직 계층이 특정 저장소 구현에 직접 의존한다면(예: 내부에서 new InMemoryTaskRepository()를 사용), 저장소를 교체할 때 전체 코드를 다시 작성해야 합니다. 결합도를 낮추기 위해 추상화(인터페이스, 추상 클래스)를 사용하세요.
오류 №5: 불충분한 문서화. 추상화는 계약이며, 이를 명확히 기술해야 합니다. 하위 클래스가 무엇을 해야 하는지 적어 두지 않으면 예상치 못한 오류나 동료들의 “창의성”을 초래하기 쉽습니다.
GO TO FULL VERSION