목차
1. 목표 - MVC에서 모델 단순화하기
- MVC(Model, View, Controller)란 소프트웨어 공학에서 사용되는 아키텍처 패턴.
- 기존의 프로그램은 아래와 같은 패턴을 가지고 있음.
프로그램()
{
입력을 받는다.
정보를 조금 처리한다.
조금 출력한다.
입력을 또 받는다.
정보를 또 처리한다.
더 출력한다.
}
- 이렇게 프로그램을 구성할 경우, 조금만 구성을 변경하려 해도 많은 비용을 초래하기 때문에, 이를 각 영역별로 나누어 처리하는 것이 좋겠다는 생각을 가짐.
- 이처럼 나누어 처리하기 위해 구성한 것이 입력 처리(Controller), 정보 처리(Model), 출력 처리(View) 이며, 이를 MVC라 함.
- MVC의 역할은 아래와 같음
Controller | 사용자의 입력을 받아 처리하고, Model과 상호작용하며, 적절한 View를 선택해 주는 역할 |
Model | 프로그램이 사용될 영역에 따라서 처리해야 하는 논리들을 처리하는 부분입니다. 대개의 경우 Database에 내용을 넣고, 빼고, 바꾸고 하는 것이 Model에서 처리 |
View | 프로그램의 외관을 책임지는 부분입니다. Model의 정보들을 받아서 화면에 그리는 역할 |
2. 안티패턴 - 액티브 레코드인 모델
- 간단한 애플리케이션에서는, 모델에 맞춤 로직이 많이 필요하지 않기 때문에, CRUD 동작만 알면 됨.
- Martin Fowler는 이런 매핑을 지원하는 디자인 패턴을 액티브 레코드(Active Record)라 지칭했는데, 이는 데이터 접근 패턴임.
- 데이터베이스의 테이블이나 뷰에 대응되는 클래스를 정의하고, 클래스 메소드인 find() 또는 save()를 호출하여 DML을 처리함.
<?php
$bugsTable = Doctrine_Core::getTable('Bugs');
$bugsTable->find(1234);
$bug = new Bugs();
$bug->summary = "Crashes when I save";
$bug->save();
- Joel Spolsky는 2002년 누설되기 쉬운 추상화(leaky abstraction)란 용어를 만듬. 추상화는 어떤 기술의 내부 동작을 단순화해 사용하기 쉽게 해주지만, 생산적이 되기 위해 내부 구조를 알아야만 한다면, 누설되기 쉬운 추상화라 할 수 있음.
- MVC에서 액티브 레코드를 모델로 사용하는 것은 누설되기 쉬운 추상화의 좋은 예인데, 아주 단순한 경우 액티브 레코드는 마치 마법처럼 동작하지만, GROUP BY 같은 구문을 사용할 경우 액티브 레코드에서는 다루기 힘들어짐.
- 어떤 프레임워크에서는 액티브 레코드를 개선해 다양한 SQL 절을 지원하려 하지만, 이는 SQL을 직접 사용하는 것이 더 낫겠다는 느낌만 들게 함.
- 2004년, Ruby on rails는 액티브 레코드를 웹 개발 프레임워크에 보급했고, 이제 이 패턴은 대부분의 웹 개발 프레임워크에서 데이터 접근 객체(DAO, Data Access Object)에 대한 사실상의 표준이 됨.
- 액티브 레코드를 사용하는 것에는 아무런 잘못이 없지만, 안티패턴은 MVC 애플리케이션에서 모든 모델 클래스가 액티브 레코드 클래스를 상속하기 때문에 문제가 됨.
1) 액티브 레코드는 모델을 스키마와 결합시킨다
- 액티브 레코드는 단순한 패턴인데, 액티브 레코드 클래스는 데이터베이스 테이블 하나 또는 뷰 하나를 나타내며, 액티브 레코드 객체의 각 필드는 대응되는 테이블의 각 컬럼과 매칭됨. 테이블이 16개라면, 모델 서브클래스도 16개를 정의함.
- 이는 새로운 구조의 데이터를 표현하기 위해 데이터베이스를 리팰토링해야 할 때 모델 클래스도 수정해야 하고, 이들 모델 클래스를 사용하는 애플리케이션 코드도 수정해야 함을 뜻함.
2) 액티브 레코드는 CRUD 함수를 노출시킨다
- 또 다른 문제는, 당신의 모델 클래스를 사용하는 다른 프로그래머가 당신이 의도한 사용법을 우회해, CRUD 함수로 데이터를 직접 업데이트 할 수 있다는 것.
- 예를 들어, 버그 모델에 assignUser() 메소드를 추가해 버그가 업데이트되면 담당 엔지니어에게 메일을 보내도록 했다고 하자.
<?php
class CustomBugs extends BaseBugs
{
public function assignUser(Accounts $a)
{
$this->assigned_to = $a->account_id;
$this->save();
mail($a->email, "Assigned bug",
"You are now responsible for bug #{$this->bug_id}.");
}
}
- 그러나 다른 프로그래머가 이 메소드를 우회해 메일을 보내지 않고 버그를 직접 할당할 수 있다.
<?php
$bugsTable = Doctrine_Core::getTable('Bugs');
$bugsTable->find(1234);
$bug = new Bugs();
$bug->summary = "Crashes when I save";
$bug->save();
- 요구사항은 할당이 바뀔 때마다 이메일을 보내 알려주는 것인데, 이메일을 보내는 단계를 우회할 수 있는 것이다.
- 이처럼, 상속한 모델 클래스에 액티브 레코드 클래스의 CRUD 메소드를 노출시켜, 다른 프로그래머가 이를 부적적하게 사용할 경우 어떻게 막을 수 있는가?
3) 액티브 레코드는 빈약한 도메인 모델을 조장한다
- 액티브 레코드는 모델이 기본적인 CRUD 메소드 외에는 아무런 동작도 가지지 않는 경우가 많으며, 많은 개발자들이 액티브 레코드 클래스를 상속한 다음, 모델이 해야 하는 동작과 관련한 메소드를 추가하지 않고 사용함.
- 모델을 단순한 데이터 접근 객체로 취급하면, 비지니스 로직은 모델 외부에 여러 개의 컨트롤러 클래스에 걸쳐 존재하게 되고, 이로 인해 모델 동작의 응집도(cohesion)가 낮아짐.
- Martin Fowler는 자신의 블로그에서 이런 안티패턴을 빈약한 도메인 모델(Anemic Domain Model)이라 부름.
- 만약 Bugs, Accounts, Products 테이블에 대응되는 액티브 레코드 클래스를 각각 가질 수 있는데, 많은 애플리케이션에서 이 3개 테이블의 데이터다 모두 필요할 경우 불리하게 작용함.
- 버그 추적 애플리케이션에서 버그 할당, 데이터 입력, 버그 표시, 버그 검색 작업을 구현하는 간단한 코드 예제를 살펴보자.
<?php
class AdminController extends Zend_Controller_Action
{
public function assignAction()
{
$bugsTable = Doctrine_Core::getTable("Bugs");
$bug = $bugsTable->find($_POST["bug_id"]);
$bug->Products[] = $_POST["product_id"];
$bug->assigned_to = $_POST["user_assigned_to"];
$bug->save();
}
}
class BugController extends Zend_Controller_Action
{
public function enterAction()
{
$bug = new Bugs();
$bug->summary = $_POST["summary"];
$bug->description = $_POST["summary"];
$bug->status = "NEW";
$accountsTable = Doctrine_Core::getTable("Accounts");
$auth = Zend_Auth::getInstance();
if ($auth && $auth->hasIdentity()) {
$bug->reported_by = $auth->getIdentity();
}
$bug->save();
}
public function displayAction()
{
$bugsTable = Doctrine_Core::getTable("Bugs");
$this->view->bug = $bugsTable->find($_GET["bug_id"]);
$accountsTable = Doctrine_Core::getTable("Accounts");
$this->view->reportedBy = $accountsTable->find($bug->reported_by);
$this->view->assignedTo = $accountsTable->find($bug->assigned_to);
$this->view->verifiedBy = $accountsTable->find($bug->verified_by);
$productsTable = Doctrine_Core::getTable("Products");
$this->view->products = $bug->Products;
}
}
class SearchController extends Zend_Controller_Action
{
public function bugsAction()
{
$q = Doctrine_Query::create()
->from("Bugs b")
->join("b.Products p")
->where("b.status = ?", $_GET["status"])
->andWhere("MATCH(b.summary, b.description) AGAINST (?)", $_GET["search"]);
$this->view->searchResults = $q->fetchArray();
}
}
- 컨트롤러 클래스에서 액티브 레코드를 사용하는 코드는 애플리케이션 로직을 정리하기 위해 절차적 방법을 사용했음.
- 데이터베이스 스키마나 애플리케이션 동작이 변하기라도 한다면, 코드의 여러 부분을 수정해야 하고, 마찬가지로 컨트롤러를 추가할 경우 모델에 대한 쿼리가 다른 컨트롤러에 있는 것과 비슷하다 하더라도 새로운 코드를 작성해야 함.
- 또한 클래스 상호작용 다이어그램이 너저분하고 읽기도 어려우며, 컨트롤러나 DAO 클래스를 추가하면 더 나빠짐.
- 이는 다른 모델을 함께 사용하는 코드가 컨트롤러 여기저기에 중복되어 있음을 나타내는 강력한 단서가 될 수 있으며, 애플리케이션을 단순화하고 캡슐화기 위한 다른 접근방법이 필요하다는 것을 알 수 있음.
4) 마법의 콩은 단위 테스트가 어렵다
- 마법의 콩 안티패턴을 사용하면, MVC의 각 계층을 테스트하기 어려워짐
모델 테스트 | 모델을 액티브 레코드와 같은 클래스로 만들었기 때문에, 데이터 접근과 분리해 모델의 동작을 테스트할 수 없음. 모델을 테스트하려면, 실제 데이터베이스에 쿼리를 실행해야 하며, 이로 인해 모델테스트를 위하 준비작업과 정리 작업을 느리게 하고 에러 발생 가능성을 높임. |
뷰 테스트 | 뷰를 테스트한다는 말응 뷰를 HTML로 렌더링하고 결과를 파싱해 모델로부터 제공받은 동적 HTML 요소가 출력에 나타나는지 확인한다는 의미. |
컨트롤러 테스트 | 데이터 접근 객체인 모델로 인해 여러 컨트롤러에 동일한 코드가 반복해서 나타나게 되는데, 이 모든 것이 테스트되어야 하며, 이로 인해 비지니스 로직을 테스트하는 데 많은 설정 코드가 필요해지고, 테스트 실행도 느려짐 |
3. 안티패턴 인식 방법
- "모델에 맞춤 SQL 쿼리를 어떻게 넘길 수 있을까?"
- 질문을 보면 모델 클래스를 데이터베이스 접근 클래스로 사용하고 있는 것을 알 수 있음.
- 모델에 SQL 쿼리를 넘길 필요가 없어야 하고, 모델 클래스는 필요한 쿼리가 어떤 것이든 캡슐화해야 함.
- "복잡한 모델 쿼리를 모든 컨트롤러에 복사해야 할까, 아니면 코드를 추상 컨트롤러에 한 번만 작성해야 할까?"
- 어떤 방법도 안정성이나 단순성 같은 원하는 것을 얻을 수 없음.
- 모델 안에 복잡한 쿼리를 코딩해야 하고, 모델의 인터페이스로 노출해서, DRY(Dont Repeat Yourself) 원칙을 따르고, 모델의 사용을 쉽게 해야 함.
- "내 모들을 단위 테스트하기 위해 데이터베이스 픽스처를 더 작성해야 해."
- 데이터베이스 픽스처(템프테이블)를 사용하고 있다면, 데이터베이스 접근을 테스트하는 것이지 비지니스 로직을 테스트 하는 것이 아님.
- 데이터베이스와 격리된 상태에서 모델을 단위 테스트 할 수 있어야 함.
4. 안티패턴 사용이 합당한 경우
- 액티브 레코드 디자인 패턴은 CRUD 동작에서는 편리한 패턴이므로, 한정지어서 사용할 경우 유용함.
- 추가로, 프로토타입 처럼 코드를 빨리 작성해서 테스트 및 유지보수하기 쉬운 코드 작성 시 좋음.
5. 해법 - 액티브 레코드를 가지는 모델
- 액티브 레코드를 가지는 모델에서는 입력/출력을 담당하는 컨트톨러와 뷰 보다, 어떻게 모델을 객체지향적으로 설계해야 하는지에 대한 고민이 더 필요함.
1) 모델 이해하기
- 모델을 이해하기 위해 각 파트별로 역할을 살펴본다.
1-1) 정보 전문가
- 어떤 동작에 책임이 있는 객체는 동작을 수행하는 데 필요한 모든 데이터를 가지고 있어야 함.
- 애플리케이션의 어떤 동작은 여러 테이블과 관련이 있을 수 있는데, 액티브 레코드는 한 번에 한 테이블하고만 잘 동작하므로, 여러 개의 데이터 접근 객체를 모아 합성 동작에 사용할 다른 클래스가 필요함.
- 모델과 액티브 레코드와 같은 DAO 사이의 관계는 IS-A(상속)가 아닌 HASH(집합연관)여야 함.
1-2) 창조자
- 모델이 데이터를 데이터베이스 저장하는 방법은 내부적 구현 상세여야 하며, DAO를 모은 도메인 모델이 이런 DAO 객체 생성을 책임져야 함.
- 컨트롤러와 뷰는 모델이 데이터를 어떻게 구성하는지 알 필요 없이 인터페이스만을 고민해야 하며, 이렇게 해야만 모델에서 쿼리를 변경하기도 쉬워짐
1-3) 낮은 결합도
- 논리적으로 독립적인 코드 블록을 분리하는 것이 중요하며, 이렇게 할 경우 다른 곳에 영향을 주지 않으면서 클래스의 구현을 변경할 수 있는 유연성을 얻을 수 있음.
1-4) 높은 응집도
- 도메인 모델 클래스의 인터페이스는 의도된 사용법을 반영해야 하며, 데이터베이스의 물리적 구조나 CRUD 동작을 반영하면 안됨.
- 또한 메소드 이름은 find(), insert() 같이 불명확하게 할 경우 컨트롤러에서 처리하기 힘드므로, assignUser() 같은 이름으로 해야 함.
2) 도메인 모델 동작하게 하기
- 모델을 데이터베이스 Layout이 아닌 애플리케이션 개념에 따라 설계하면, 데이터베이스 동작을 모델 클래스 속에 완전히 숨길 수 있는데, 이를 도메인 모델이라 함.
- 아래는 앞에서 살펴본 예제를 도메인 모델로 리팩토링 한 결과.
<?php
class BugReport
{
protected $bugsTable;
protected $accountsTable;
protected $productsTable;
public function __construct()
{
$this->bugsTable = Doctrine_Core::getTable("Bugs");
$this->accountsTable = Doctrine_Core::getTable("Accounts");
$this->productsTable = Doctrine_Core::getTable("Products");
}
public function create($summary, $description, $reportedBy)
{
$bug = new Bugs();
$bug->summary = $summary
$bug->description = $description
$bug->status = "NEW";
$bug->reported_by = $reportedBy;
$bug->save();
}
public function assignUser($bugId, $assignedTo)
{
$bug = $bugsTable->find($bugId);
$bug->assigned_to = $assignedTo"];
$bug->save();
}
public function get($bugId)
{
return $bugsTable->find($bugId);
}
public function search($status, $searchString)
{
$q = Doctrine_Query::create()
->from("Bugs b")
->join("b.Products p")
->where("b.status = ?", $status)
->andWhere("MATCH(b.summary, b.description) AGAINST (?)", $searchString]);
return $q->fetchArray();
}
}
class AdminController extends Zend_Controller_Action
{
public function assignAction()
{
$this->bugReport->assignUser(
$this->_getParam("bug"),
$this->_getParam("user"));
}
}
class BugController extends Zend_Controller_Action
{
public function enterAction()
{
$auth = Zend_Auth::getInstance();
if ($auth && $auth->hasIdentity()) {
$identity = $auth->getIdentity();
}
$this->bugReport->create(
$this->_getParam("summary"),
$this->_getParam("description"),
$identity);
}
public function displayAction()
{
$this->view->bug = $this->bugReport->get(
$this->_getParam("bug"));
}
}
class SearchController extends Zend_Controller_Action
{
public function bugsAction()
{
$this->view->searchResults = $this->bugReport->search(
$this->_getParam("status", "OPEN"),
$this->_getParam("search"));
}
}
3) 간단한 객체 테스트하기
- 이상적으로, 실제 데이터베이스에 접속하지 않고도 모델을 테스트할 수 있어야 함.
- 이와 마찬가지로, 도메인 모델의 인터페이스도 다른 객체지향 테스트와 비슷하게 테스트할 수 있음.
- 객체의 메소드를 호출하고 리턴 값을 검증하는 방식으로 할 수 있으며, 이렇게 하는 것이 가짜 HTTP 요청을 만들어 컨트롤러에 넘기고 결과로 리턴된 HTTP 응답을 파싱하는 것보다 쉽고 빠름.
4) 땅으로 내려오기
- 어떤 프레임워크이든 데이터 접근 객체를 생산적으로 사용할 수 있지만, 객체지향 설계 원칙을 어떻게 활용하는지 배우지 못한 개발자는 스파게티처럼 복잡한 코드를 작성할 수 밖에 없음.
- 그러므로, 위에서 설명한 방법론을 숙지할 경우 최적의 설계를 선정하는 데 도움이 될 것이다.
문서에 대하여
- 최초작성자 : ~xsoft
- 최초작성일 : 2012년 01월 14일
- 이 문서는 오라클클럽 코어 오라클 데이터베이스 스터디 모임에서 작성하였습니다.
- {*}이 문서의 내용은 인사이트(insight) 에서 출간한 'SQL AntiPatterns : 개발자가 알아야 할 25가지 SQL 함정과 해법'를 참고하였습니다.*