노르웨이 개발자 Trygve Reenskaug가 Xerox PARC의 Smalltalk 환경에서 처음 제안한 MVC 패턴은 다음 세 가지 문제로 인해 생겨났다.
JSP Model 2
[ 사용자 ]
│ 1. 요청 (예: 상품 목록 보기 클릭)
▼
[ Controller ]
│ 2. 요청 해석 및 Model 호출
│ (예: ProductService.getProductList() 호출)
▼
[ Model ]
│ 3. 비즈니스 로직 처리 및 데이터 반환
│ (예: 데이터베이스에서 상품 정보 조회)
▼
[ Controller ]
│ 4. 적절한 View 선택 및 데이터 전달
│ (예: 상품 목록을 productList.jsp로 전달)
▼
[ View ]
│ 5. 데이터를 HTML 형태로 렌더링
└──────────────────────────────────────────────┐
│
▼
[ 사용자 화면 ]
6. 최종 결과 표시
[ HTTP 요청 ]
▼
[ DispatcherServlet ] → 요청 라우팅
▼
[ Controller ] → 요청 해석, 검증
▼
[ Service ] → 트랜잭션 관리, 비즈니스 로직 조율
▼
[ Domain Model ] → 핵심 비즈니스 로직 실행
▼
[ Repository ] → 데이터 영속성 처리
▼
[ Database ]
MVC 패턴을 이용하면 동일한 Model을 다양한 View에서 재사용할 수 있어 개발 효율성이 크게 향상된다. 예를 들어, 상품 정보를 관리하는 ProductService라는 Model이 있다면, 이를 웹 페이지용 View, 모바일 앱용 View, 관리자용 View에서 모두 동일하게 사용할 수 있다.
단일 책임 원칙에 따라 각 구성 요소가 명확한 하나의 역할만 담당하므로, 한 부분의 수정이 다른 부분에 미치는 영향이 최소화된다. 또한 문제가 발생했을 때 어떤 계층에서 발생한 문제인지 쉽게 식별할 수 있어 버그 추적이 용이하다.
프론트엔드 개발자는 View에, 백엔드 개발자는 Model과 Controller에 집중할 수 있어 병렬 개발이 가능하다. UI/UX 디자이너는 View 부분에만, 서버 개발자는 비즈니스 로직과 데이터 처리 부분에만 집중할 수 있어 각자의 전문성을 최대한 발휘할 수 있다.
MVC 패턴은 많은 장점을 제공하기도 하지만, 실제 개발 과정에서 몇 가지 한계점과 문제점이 나타나기도 한다.
단순한 MVC 패턴을 적용할 때 가장 흔히 발생하는 문제는 Controller가 너무 많은 책임을 떠안게 되는 것이다. Controller가 원래 담당해야 할 요청 해석과 제어 흐름뿐만 아니라, 비즈니스 로직 처리, 데이터 변환, 데이터베이스 접근, 외부 서비스 연동까지 모든 작업을 직접 수행하게 되면 코드의 복잡성이 급격하게 증가한다.
이로 인해 코드 중복이 발생하고, 테스트가 어려워지며 하나의 변경사항이 전체 Controller에 영향을 미치는 문제가 생긴다.
// 문제가 있는 Controller - 너무 많은 책임을 가짐
@RestController
public class BadUserController {
@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody UserDto userDto) {
// 1. 검증 로직 (원래 Controller의 역할)
if (userDto.getEmail() == null || !userDto.getEmail().contains("@")) {
throw new IllegalArgumentException("잘못된 이메일");
}
// 2. 비즈니스 로직 (Model의 역할)
if (existingUserRepository.existsByEmail(userDto.getEmail())) {
throw new DuplicateEmailException("이미 존재하는 이메일");
}
// 3. 데이터 변환 로직 (Service의 역할)
User user = new User();
user.setEmail(userDto.getEmail());
user.setPassword(passwordEncoder.encode(userDto.getPassword()));
// 4. 데이터베이스 접근 (Repository의 역할)
User savedUser = userRepository.save(user);
// 5. 이메일 발송 (외부 서비스 연동 - Service의 역할)
emailService.sendWelcomeEmail(savedUser.getEmail());
return ResponseEntity.ok(savedUser);
}
}
해결책:
@Valid, Bean Validation 활용)MVC 패턴의 또 다른 한계는 Controller와 View 간의 강한 결합 문제가 있는데, Controller가 특정 View 기술(JSP, Thymeleaf 등)에 직접 의존하게 되면, View 기술을 변경할 때마다 Controller 코드도 함께 수정해야 한다.
예를 들어, JSP에서 React로 프론트엔드를 변경하려면 Controller의 반환 타입과 데이터 형식을 모두 바꿔야 할 수 있다. 이는 테스트를 어렵게 만들고, 시스템의 유연성을 크게 저하시킨다. 또한 Controller를 단위 테스트할 때 실제 View 컴포넌트가 필요하게 되어 테스트 설정이 복잡해지는 문제도 발생한다.
해결책:
단순한 MVC의 한계를 해결하기 위해 현대적 아키텍처는 Service, Repository, DTO 계층까지 확장되었다.
┌─────────────────┐
│ Presentation │ ← View (Thymeleaf, React, 모바일 앱 등)
│ Layer │ • UI 컴포넌트, 템플릿, 정적 리소스
├─────────────────┤
│ Controller │ ← 요청 해석, 흐름 제어, 응답 형태 결정
│ Layer │ • HTTP 요청/응답 처리, 라우팅, 기본 검증
├─────────────────┤
│ Service │ ← 트랜잭션 관리, 비즈니스 로직 조율
│ Layer │ • 업무 규칙 적용, 계층 간 조정, 외부 서비스 연동
├─────────────────┤
│ Domain │ ← 핵심 비즈니스 로직, 엔티티
│ Model Layer │ • 도메인 객체, 비즈니스 규칙, 불변성 보장
├─────────────────┤
│ Persistence │ ← 데이터 접근, CRUD 연산
│ Layer │ • 데이터베이스 연결, 쿼리 실행, 영속성 관리
└─────────────────┘
// 1. Domain Layer (핵심 비즈니스 로직)
@Entity
public class Order {
private Long id;
private BigDecimal totalAmount;
private OrderStatus status;
private List<OrderItem> items;
// 비즈니스 로직
public void cancel() {
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalStateException("배송 중인 주문은 취소할 수 없습니다.");
}
this.status = OrderStatus.CANCELLED;
}
public BigDecimal calculateTotal() {
return items.stream()
.map(OrderItem::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
// 2. Persistence Layer (데이터 접근)
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerIdAndStatus(Long customerId, OrderStatus status);
@Query("SELECT o FROM Order o WHERE o.createdAt >= :startDate")
List<Order> findRecentOrders(@Param("startDate") LocalDateTime startDate);
}
// 3. Service Layer (트랜잭션 관리, 계층 간 조율)
@Service
@Transactional
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentService paymentService;
@Autowired
private InventoryService inventoryService;
public OrderDto createOrder(CreateOrderRequest request) {
// 1. 재고 확인
inventoryService.checkAvailability(request.getItems());
// 2. 주문 생성
Order order = new Order(request.getCustomerId(), request.getItems());
// 3. 결제 처리
PaymentResult payment = paymentService.processPayment(
request.getPaymentInfo(), order.getTotalAmount()
);
if (payment.isSuccess()) {
// 4. 주문 저장
Order savedOrder = orderRepository.save(order);
// 5. 재고 차감
inventoryService.decreaseStock(request.getItems());
return OrderDto.from(savedOrder);
} else {
throw new PaymentFailedException("결제에 실패했습니다.");
}
}
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("주문을 찾을 수 없습니다."));
// Domain 객체의 비즈니스 로직 활용
order.cancel();
orderRepository.save(order);
}
}
// 4. Controller Layer (요청 처리, 흐름 제어)
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponseDto> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
try {
OrderDto orderDto = orderService.createOrder(request);
OrderResponseDto response = OrderResponseDto.from(orderDto);
return ResponseEntity.ok(response);
} catch (InsufficientStockException e) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("재고가 부족합니다."));
} catch (PaymentFailedException e) {
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED)
.body(new ErrorResponse(e.getMessage()));
}
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> cancelOrder(@PathVariable Long orderId) {
orderService.cancelOrder(orderId);
return ResponseEntity.ok().build();
}
}
계층별로 유효성 검증을 진행해야 하는 이유와 구체적인 구현 방법을 살펴보자면,
위와 같은 이유로 보통의 프로젝트에서는 유효성 검증을 계층별로 진행하게 된다.
웹 페이지나 모바일 앱에서 사용자가 입력하는 즉시 실시간으로 검증한다. 이는 사용자 경험 향상이 주목적이다.
온라인 회원가입 폼에서 이메일 입력란에 "@"가 없으면 즉시 빨간색으로 "올바른 이메일 형식을 입력해주세요"라고 표시하는 것
서버로 들어온 HTTP 요청의 기본적인 형식과 필수 값들을 검증한다. 이는 악의적이거나 잘못된 요청을 초기에 걸러내는 역할이다.
API 호출 시 필수 파라미터가 누락되었거나, 숫자가 들어가야 할 자리에 문자가 들어온 경우를 체크
실제 업무 로직에 맞는 규칙들을 검증한다. 이는 데이터베이스의 현재 상태와 비교하여 비즈니스적으로 유효한지 판단한다.
- 은행 시스템에서 "계좌 잔액이 출금 요청 금액보다 많은가?"
- 쇼핑몰에서 "주문하려는 상품의 재고가 충분한가?"
- 회사 시스템에서 "퇴사한 직원에게 새로운 권한을 부여하려고 하는가?"
데이터 자체의 일관성과 도메인 규칙을 검증한다. 이는 객체가 항상 유효한 상태를 유지하도록 보장한다.
- 사용자 객체에서 "비밀번호 변경 시 현재 비밀번호가 맞는가? 새 비밀번호가 현재와 다른가?"
- 주문 객체에서 "이미 배송된 주문을 취소하려고 하는가?"
- 게임에서 "체스 말이 규칙에 맞게 이동하려고 하는가?"
| 특성 | MVC | MVP | MVVM |
|---|---|---|---|
| View–Controller 결합도 | 강함 | 약함 (인터페이스로 연결) | 없음 (데이터 바인딩) |
| 테스트 용이성 | 보통 | 높음 | 높음 |
| View 로직 | 최소한 포함 | 거의 없음 | 없음 |
| 데이터 업데이트 | 수동 | 수동 | 자동 (데이터 바인딩) |
| 학습 곡선 | 낮음 | 보통 | 높음 |
| 적용 분야 | 웹 애플리케이션 | 데스크톱 애플리케이션 | 현대 프론트엔드 |
| 대표 기술 | Spring MVC, JSP | WinForms, WPF(초기) | React, Vue.js, Angular, 최신 WPF 등 |