DB/Query

[펌] 도메인 로직과 SQL

시처럼 음악처럼 2008. 7. 2. 10:58

퍼온곳 : http://gyumee.egloos.com/1783716


[번역] 도메인 로직과 SQL

원본 글은 http://martinfowler.com/articles/dblogic.html 에 있습니다.

최종 주요 갱신: 2003년 2월

우리는 지난 20여 년 이상 데이터베이스 지향 소프트웨어 개발자와 메모리에서 처리하는 애플리케이션 소프트웨어 개발자 간의 간격이 커지는 것을 봐왔습니다. 이 때문에 SQL이나 저장 프로시듀어 같은 데이터베 이스의 기능을 어떻게 이용할지에 대한 논의가 생겨났습니다. 이 글에서 전 업무 로직을 SQL 질의문 안에 둘 것인지 아니면 메모리에 있는 코드 안에 둘 것인지에 대한 문제를 주로 성능과 유지보수성의 관점에서 단순한 -그러나 SQL 충분히 활용한 - 예제를 사용해 살펴보려고 합니다.



요즘 출판된 기업용 애플리케이션 구축과 관련된 책 - 제가 쓴 P of EAA 같은 - 을 보면 기업용 애플리케이션을 서로 다른 부분으로 분리해 다중 계층으로 만들고 업무 로직을 각 계층에 잘게 쪼개서 넣는 것을 볼 수 있습니다.  사람마다 다른 계층 구조를 사용하겠지만 보통은 도메인 로직(비즈니스 룰)과 데이터 소스 로직(자료를 가지고 오는 곳)을 분리합니다. 기업용 애플리케이션 자료의 많은 부분이 관계형 데이터베이스에 저장되어 있는 이상 이렇게 계층화하는 방법은 비즈니스 로직을 관계형 데이터베이스에서 분리하려는시도라고 할 수 있습니다.

많은 애플리케이션 개발자, 특히 저 같이 객체지향에 헌신 된 개발자는 관계형 데이터베이스를 뒤에 꼭꼭 숨겨 두는 것이 상책인 저장 메커니즘으로 취급하곤 합니다. 애플리케이션 개발자들을 복잡한 SQL로부터 보호하는 기능을 가지고 있다고 떠벌리는 프레임워크들도 있습니다.

하지 만, SQL은 단순히 자료를 갱신하고 취득하는 메커니즘 이상입니다. SQL의 질의 처리 과정에서 많은 일들을 수행할 수 있습니다. SQL을 뒤에 숨긴다는 것은 애플리케이션 개발자들이 사용할 수 있는 강력한 도구 하나를 없애는 것을 의미합니다.

이 글에서 전 SQL의 기능을 충분히 활용하는 것의 장단점을 탐구해 보려고 합니다. 이런 SQL에는 아마도 도메인 로직이 들어 있을 것입니다. 저의 객체지향에 치우친 성향을 논의에 반영할 것이라는 것을 말해 두어야겠습니다. 하지만 전 반대 입장에 서본 경험도 있습니다. (이전의 한 고객에게 객체지향 전문가그룹이 있었는데 그들은 저를 회사에서 내몰았었습니다. 제가 데이터 모델러였기 때문이죠.)

복잡한 질의문 (Complex Queries)

관계형 데이터베이스들은 모두 SQL(StandardQuery Language)를 지원합니다. 전 기본적으로 관계형 데이터베이스가 지금의 영역을 장악하는 데 성공할 수 있었던 첫째 이유는 SQL에 있다고 믿습니다. 데이터베이스와 상호작용 할 수 있는 표준화된 방법 때문에 높은 수준의 업체 독립성이 확보될 수 있었고 이런 면이 관계형 데이터베이스가 득세하고 객체지향의 도전을 극복하는 데 도움이 되었습니다.

SQL에 많은 강점이 있지만, 무엇보다 데이터베이스에 질의할 때에 클라이언트가 SQL 코드 몇 줄만으로도 대규모의 자료를 선별하고 요약할 수 있다는 것이 특히 대단한 능력이라고 할 수 있습니다. 그러나 종종 강력한 SQL 질의문 안에는 도메인 로직에 들어 있기도  합니다. 이것은 계층화된 기업용 애플리케이션 아키텍처의 기본 원칙을 위배하는 것입니다.

단순한 예제를 보면서 이 주제를 좀 더 탐구해 보겠습니다. 그림 1의 선을 따라 데이터 모델링을 시작해보겠습니다. 우리 회사에 Cuillen이라고 부르는 특별 가격 할인이 있다고 가정하겠습니다. 고객이 한 달에 한 번 이상 주문을 했고 거기에 $5000 이상의 Talisker 구입비가 포함되어 있다면 이 가격 할인을 받을 수 있는 대상이 됩니다. 한 주문이 $5000 이상이어야 하기 때문에 같은 달에 $3000짜리 주문을 두 번 했어도 해당이 되지 않는다는 것에 주의하십시오. 이제 특정 고객을 찾아서 이 고객이 작년 몇 월에Cuillen 가격 할인 대상이 되는지 알아보려고 한다. user interface는 생각하지 말고 단지 조건에 해당하는 달을 뜻하는 숫자의 목록만 얻으면 된다고 하겠습니다.

그림 1: 예제의 데이터베이스 구조(UML 표기)


이 질문에 대한 답은 많겠지만 기본인 세가지 방법 Transaction script, Domain model, 복잡한 SQL 등 에서 시작해보겠습니다.

모 든 예제는 루비 프로그래밍 언어를 사용해서 표현하도록 하려고 합니다. 전 보통 이런 것들을 표현할 때에 대부분 개발자가 C기반의 언어를 읽을 수 있다는 것 때문에 자바나 C#을 사용하지만 여기서는 약간 위험한 일을 하려고 합니다. 루비를 선택한 것은 어찌 보면 실험적입니다. 전 이 언어가 간결하면서도 잘 구성된 코드를 촉진하고 객체지향 스타일의 코드를 쉽게 짤 수 있어서 좋아합니다. 루비는 스크립팅을 위해 제가 선택한 언어입니다. 이글에서 사용한 루비에 기초해서 속성 루비 문법 안내서를 작성했습니다.

트렌젝션 스크립트(Transaction Script)

트렌젝션 스크립트는 P of EAA에서 제가 만든 패턴의 이름으로 요청을 절차적인 방법으로 처리하는 것을 말합니다. 이 경우에 프로시저는 필요로 하는 모든 데이터를 읽고 선택 작업을 한 후 어떤 달이 필요로 하는 달인지 계산하기 위해 메모리 상에서 작업합니다.

def cuillen_months name
customerID = find_customerID_named(name)
result = []
find_orders(customerID).each do |row|
result << row['date'].month if cuillen?(row['orderID'])
end
return result.uniq
end

def cuillen? orderID
talisker_total = 0.dollars
find_line_items_for_orderID(orderID).each do |row|
talisker_total += row['cost'].dollars if 'Talisker' == row['product']
end
return (talisker_total > 5000.dollars)
end

두 메서드 cuillen_months 와 cuillen? 는 도메인 로직을 가지고 있습니다. 이것들은 데이터베이스에 질의를 하기 위해 여러번 "finder" 메서드를 호출합니다.

def find_customerID_named name
sql = 'SELECT * from customers where name = ?'
return $dbh.select_one(sql, name)['customerID']
end

def find_orders customerID
result = []
sql = 'SELECT * FROM orders WHERE customerID = ?'
$dbh.execute(sql, customerID) do |sth|
result = sth.collect{|row| row.dup}
end
return result
end

def find_line_items_for_orderID orderID
result = []
sql = 'SELECT * FROM lineItems l WHERE orderID = ?'
$dbh.execute(sql, orderID) do |sth|
result = sth.collect{|row| row.dup}
end
return result
end

이 방법은 여러 가지로 무척 단순한 접근법이고 아주 비효율적으로 SQL을 사용합니다. 주문 정보 N 개를 얻기 위해서 질의를 2+N 번 실행해야 합니다. 지금 당장은 이것 때문에 너무 걱정할 필요는 없습니다. 나중에 어떻게 개선할지 얘기하도록 하겠습니다. 주목해야 할 핵심은 관심 있는 모든 자료를 읽어서 반복문을 돌면서 필요한 것을 선택한다는 것입니다.

(위의 도메인 로직은 읽기 좋도록 만들어져 있기는 하지만 루비답다고는 여겨지지 않습니다. 전 루비의 강력한 블록과 컬렉션을 활용한 아래의 방법을 선호합니다. 이 코드가 좀 이상하다고 생각하는 사람들이 많겠지만 스몰토크 개발자들은 좋아할 것으로 생각합니다.)

def cuillen_months2 name
customerID = find_customerID_named(name)
qualifying_orders = find_orders(customerID).select {|row| cuillen?(row['orderID'])}
return (qualifying_orders.collect {|row| row['date'].month}).uniq
end

도메인 모델 (Domain Model)

두 번째 시작점으로 전통적인 객체지향적 도메인 모델을 생각해 보겠습니다. 지금은 메모리에 둘 객체를 데이터베이스 테이블을 반영하게 만들겠지만 실제 시스템에서는 보통 데이터베이스 테이블과 정확히 일치하지는 않습니다. 일군의 파인더 객체들이 데이터베이스에서 이객체들을 읽어 들입니다. 일단 메모리 상에 이 객체들을 가지고 있게 되면 이 객체들이 가지고 있는 로직을 실행합니다.

finder들부터 시작해 보겠습니다. 이것들은 데이터베이스를 질의하고 객체를 만듭니다.

class CustomerMapper
def find name
result = nil
sql = 'SELECT * FROM customers WHERE name = ?'
return load($dbh.select_one(sql, name))
end
def load row
result = Customer.new(row['customerID'], row['NAME'])
result.orders = OrderMapper.new.find_for_customer result
return result
end
end

class OrderMapper
def find_for_customer aCustomer
result = []
sql = "SELECT * FROM orders WHERE customerID = ?"
$dbh.select_all(sql, aCustomer.db_id) {|row| result << load(row)}
load_line_items result
return result
end
def load row
result = Order.new(row['orderID'], row['date'])
return result
end
def load_line_items orders
#Cannot load with load(row) as connection gets busy
orders.each do
|anOrder| anOrder.line_items = LineItemMapper.new.find_for_order anOrder
end
end
end

class LineItemMapper
def find_for_order order
result = []
sql = "select * from lineItems where orderID = ?"
$dbh.select_all(sql, order.db_id) {|row| result << load(row)}
return result
end
def load row
return LineItem.new(row['lineNumber'], row['product'], row['cost'].to_i.dollars)
end
end

이들 load 메서드들은 다음 클래스들을 읽어들입니다.

class Customer...
attr_accessor :name, :db_id, :orders
def initialize db_id, name
@db_id, @name = db_id, name
end

class Order...
attr_accessor :date, :db_id, :line_items
def initialize (id, date)
@db_id, @date, @line_items = id, date, []
end

class LineItem...
attr_reader :line_number, :product, :cost
def initialize line_number, product, cost
@line_number, @product, @cost = line_number, product, cost
end
Cuillen 달을 판단하는 로직은 메서드 두어개 안에 나뉘어 서술될 수 있습니다.
class Customer...
def cuillenMonths
result = []
orders.each do |o|
result << o.date.month if o.cuillen?
end
return result.uniq
end

class Order...
def cuillen?
discountableAmount = 0.dollars
line_items.each do |line|
discountableAmount += line.cost if 'Talisker' == line.product
end
return discountableAmount > 5000.dollars
end

이 해법은 트렌젝션 스크립 버전 보다 코드가 깁니다. 하지만 객체를 읽는 로직과 실제 도메인 로직이 더 확실히 분리되었다는 점에 주목하는 것이 중요합니다. 이 도메인 객체들을 사용하는 다른 어떤 작업도 같은 적재 로직을 사용할 수 있습니다. 즉 우리가 많은도메인 로직 조각들로 작업을 한다면 모든 도메인 로직에 걸쳐 적재하는 로직을 따로 만들어야 하는 노력이 상쇄되기 때문에 코드길이가 길어진 것이 별 문제가 안 됩니다. 심지어 메타데이터맵핑 같은 기술을 사용해 이 비용을 더 줄일 수 있기도 합니다.

또 이번에도 많은(주문 숫자 + 2회) SQL 질의가 실행됩니다.

SQL 안에 로직 넣기(Logic in SQL)

앞 서 봤던 두 가지 방법 모두 데이터베이스를 마치 저장 메커니즘인 것처럼 활용했습니다. 아주 단순한 선별 조건으로 특정 테이블의모든 자료를 요청했던 것이 우리가 한 전부입니다. SQL은 아주 강력한 질의 언어이기 때문에 지금까지 예제에서 했던 것 처럼단순한 선별 작업보다 훨씬 많은 것을 할 수 있습니다.

SQL의 전 기능을 다 사용한다면 SQL만으로 모든 작업을 다 할 수 있습니다.

def discount_months customerID
sql = <<-END_SQL
SELECT DISTINCT MONTH(o.date) AS month
FROM lineItems l
INNER JOIN orders o ON l.orderID = o.orderID
INNER JOIN customers c ON o.customerID = c.customerID
WHERE (c.name = ?) AND (l.product = 'Talisker')
GROUP BY o.orderID, o.date, c.NAME
HAVING (SUM(l.cost) > 5000)
END_SQL
result = []
$dbh.select_all(sql, customerID) {|row| result << row['month']}
return result
end

제 가 이것을 '복잡한 질의'라고 말하기는 했지만 단지 앞서 봤었던 예제의 단순한 select와 where 절에 비해서 복잡하다는것 입니다. 비록 많은 애플리케이션 개발자들이 이 정도로 복잡한 질의문 조차도 피하고 싶어하지만 SQL 질의문은 이것보다도 훨씬 이해하기 어려울 수 있습니다.

성능 살펴보기

사람들이 이런 논의를 할 때에 가장 먼저 궁금해 하는 것은 '성능'입니다. 전 개인적으로 성능이 가장 중요한 사항이 되어야한다고 생각하지 않습니다. 저는 대부분의 경우 유지보수하기 좋은 코드를 짜는데 집중해야 한다고 믿고 있습니다. 그런 다음 프로파일러를 이용해 병목지점을 찾아 그 병목지점만 덜 깔끔하기는 해도 더 빠르게 작동하는 코드로 교체합니다. 제가 이러는 이유는 실제로 대부분의 시스템에서 매주 작은 부분의 코드만 성능 문제에 밀접한 관계가 있기 때문입니다. 그리고 유지보수하기에 좋게 만들어진 코드가 성능을 향상시키기 더 쉽습니다.

어찌 되었건, 성능과 관련해 득실을 따져보기로 하겠습니다. 제 소형 노트북 컴퓨터에서는 복잡한 SQL 질의문이 다른 두 가지 접근법보다 20배나 빨리 작동했습니다. 깨끗하기는 하지만 구형인 노트북에서 얻은 결과로 데이터 센터 서버에서 어떤 성능을 낼지 어떤 결론도 얻을 수 없지만 복잡한 질의문이 메모리를 사용하는 방법보다 몇 배 빠르다는 사실에 저도 놀랐습니다.

이런 결과가 나타난 원인의 일부는 메모리에 데이터를 두는 접근법이 SQL의 관점에서 매우 비효율적으로 작성되었다는 것입니다. 이미 언급 했었지만 한 고객의 모든 주문 정보에 대해서 SQL 질의를 하나씩 실행합니다. 제 테스트 데이터베이스에는 각 고객마다 주문 이천건 등록되어 있습니다.

메모리를 사용하는 프로그램을 SQL 질의를 하나만 사용하도록 재작성하면 이런 부하를 줄일 수 있습니다. 트랜젝션 스크립부터 수정해보겠습니다.

SQL = <<-END_SQL
SELECT * from orders o
INNER JOIN lineItems li ON li.orderID = o.orderID
INNER JOIN customers c ON c.customerID = o.customerID
WHERE c.name = ?
END_SQL

def cuillen_months customer_name
orders = {}
$dbh.select_all(SQL, customer_name) do |row|
process_row(row, orders)
end
result = []
orders.each_value do |o|
result << o.date.month if o.talisker_cost > 5000.dollars
end
return result.uniq
end

def process_row row, orders
orderID = row['orderID']
orders[orderID] = Order.new(row['date']) unless orders[orderID]
if 'Talisker' == row['product']
orders[orderID].talisker_cost += row['cost'].dollars
end
end

class Order
attr_accessor :date, :talisker_cost
def initialize date
@date, @talisker_cost = date, 0.dollars
end
end

트렌젝션 스크립을 무척 많이 수정했지만, 속도가 세 배나 빨라졌습니다.

도메인 모델에도 비슷한 기법을 적용할 수 있습니다. 도메인 모델의 복잡한 구조의 이점이 확인됩니다. 도메인 객체 안에 있는 비즈니스 로직은 전혀 바꿀 필요 없이 데이터를 읽는 메서드만 수정하면 됩니다.

class CustomerMapper
SQL = <<-END_SQL
SELECT c.customerID,
c.NAME as NAME,
o.orderID,
o.date as date,
li.lineNumber as lineNumber,
li.product as product,
li.cost as cost
FROM customers c
INNER JOIN orders o ON o.customerID = c.customerID
INNER JOIN lineItems li ON o.orderID = li.orderID
WHERE c.name = ?
END_SQL

def find name
result = nil
om = OrderMapper.new
lm = LineItemMapper.new
$dbh.execute (SQL, name) do |sth|
sth.each do |row|
result = load(row) if result == nil
unless result.order(row['orderID'])
result.add_order(om.load(row))
end
result.order(row['orderID']).add_line_item(lm.load(row))
end
end
return result
end

(도메인 객체를 수정할 필요가 없다고 말했지만 사실 조금 거짓말을 했습니다. 만족할만한 성능을 얻기으려면 고객의 데이터 구조를 바꾸어야만 했습니다. 이제 주문 정보는 배열이 아닌 hasp map으로 보관합니다. 하지만 이것은 순전히 내제적인 변경일뿐 할인을 측정하는 코드에는 영향을 주지 않습니다.)

몇 가지 요점을 정리해보겠습니다. 먼저 더 지능적인 쿼리는 종종 메모리를 사용하는 코드를 향상시켜준다는 것을 잊지 않는 것이 좋습니다. 데이터베 이스를 여러 번 중복해서 호출하는지 그리고 이것을 단일 호출로 대신할 방법이 있는지 늘 찾아보십시오. 사람들은 흔히 클래스를 한 번에 하나씩 접근하는 것으로 생각하는 경향이 있어 도메인 모델을 가지고 있을 때 특히 이것을 잊기 쉽습니다. (테이블 자료를 한번에 한 행만 읽는 사람을 본적이 있기는 하지만, 이런 방식은 상대적으로 희귀한 경우입니다.)

트렌젝션 스크립과 도메인 모델 사이의 가장 큰 차이점 중 하나는 질의 구조의 변경이 주는 영향입니다. 트렌젝션 스크립트에서는 전체스크립트를 교체했다고 볼 수 있습니다. 더우기 같은 데이터를 사용하는 도메인 로직이 많이 있었다면 각각을 수정해주었어야 합니다.도메인 모델에서는 깔끔하게 분리된 부분의 코드를 손 보았고 도메인 로직 자체는 변경하지 않아도 되었습니다. 여기에 트렌젝션 스크립트와 도메인 모델 사이의 손익이 있습니다. 도메인 모델은 도메인 로직의 데이터베이스 접속을 위한 복잡도 때문에 초기 비용이 들어가지만 도메인 로직이 많을 경우 이를 회수할 수 있습니다.

다중 테이블 질의에서도 메모리를 사용하는 방식이 복잡한 SQL을 사용하는 방식보다 여전히 느립니다. 제 경우에는 6배 차이가 났습니다. 이것은 이해할만한데 메모리에 상주하는 방식은 자료 5000건을 클라이언트에 전송해야 하지만 복잡한 SQL 방식은 데이터베이스에서 자료를 선택하고비용을 합산한 후에 약간의 결과만을 반환하면 되기 때문입니다.

성능이 어떤 길로 갈지 판단하는 유일한 요소는 아니지만, 결정적일 때가 종종 있습니다. 만약 반드시 향상시켜야 할 필요가 있는 병목지점이 있다면 다른 사항들은 부차적인 것이 됩니다. 결국, 많은 도메인 모델 지지자들은 메모리에서 일을 처리하는 시스템을기본으로 따르다가 병목이 발생하는 지점에서만 어쩔 수 없이 복잡한 질의 같은 것을 사용합니다.

이 예제가 데이터베이스의 강점을 활용하고 있다는 점을 강조하는 것이 좋을 것 같습니다. 많은 DB 질의문들은 이것처럼 자료를 찾고 집합하는 DB의 강점을 활용하지 않고 있어 이런 성능 변화를 기대할 수는 없습니다. 그리고 다중 사용자 시나리오는 종종 쿼리가 작동하는 형태를 극단적으로 바꾸기 때문에 실제 프로파일링은 현실의 다중 사용자 환경에서와 같은 부하 아래서 진행 되어야 합니다.개별적으로 실행했을 때 더 빨리 실행되던 쿼리가 다른 무엇보다 무거운 락 문제를 야기한다는 경우를 경험하기도 합니다.

수정 가능성 (Modifiability)

모 든 기업용 애플리케이션은 생명이 길어서 많은 변경이 가해질 것이라는 이 한 가지를 확실히 해야 합니다. 결국, 그 시스템은 바꾸기 쉽게 구성되도록 보장되어야만 합니다. 수정 가능성이 아마도 비즈니스 로직을 메모리에 두는 주된 이유일 것입니다.

SQL 이 많은 일을 할 수 있지만, 능력에 한계가 있습니다. 어떤 경우는 자료 집합의 평균을 구하는 알고리즘을 보듯 매우 창의적인 코딩을 해야만 할 수도 있습니다. 또는 이식성에 문제가 되는 비표준 확장기능을 쓰지 않으면 해결 불가능한 경우도 있습니다.

자료를 데이터베이스에 저장하기 전에 비즈니스 로직을 실행하고 싶을 때가 자주 있습니다. 특히 더 처리할 것이 있는 정보를 가지고 일할 때 말입니다. 임시로 보관하고 있는 세선자료와 검증이 완료된 자료는 격리해서 따로 보관하는 것을 원할 것이므로 이런 자료를 데이터베이스에 저장하는 것은 문제가 될 수 있습니다. 이런 임시 세션 자료는 데이터베이스의 무결한 최종 자료와 같은 검증 규칙을 적용할 수 없는 경우가 종종 있습니다.

이해 가능성 (Understandability)

흔히 SQL은 애플리케이션 개발자가 직접 처리할 필요가 없는 어떤 특수한 언어로 여겨집니다. 실제로 많은 데이터베이스프레임워크들은 자신들을 이용하면 SQL를 사용할 필요가 없어진다고 말하고 싶어합니다. 전 늘 이런 것들이 좀 이상한 논의 같다고 생각됩니다. 전 어느 정도 복잡한 SQL에 그리 불편한 생각이 들지 않으니 말입니다. 좌우간 많은 개발자들은 SQL을 전통적인 언어들보다 다루기 힘들어하며 몇몇 SQL의 특징은 SQL 전문가들이 아닌 일반 개발자들이 이해하기 어렵다고 생각합니다.

이들 세 가지 해법을 살펴보고 어떤 것이 도메인 로직을 이해하기 쉬운가, 또 이같이 어떤 것이 수정하기 쉬운가를 찾아보는 것이 좋은 평가 방법입니다. 저는 (데이터 접근 부분이 분리되어 있기 때문에 두어 개의 메서드만 가진) 도메인 모델이 가장 이해하기 쉽다고 생각합니다. 그다음은 SQL 버전이고 마지막이 트렌젝션 스크립입니다. 하지만, 다른 분들은 확실히 선호하는 것이 다를 것입니다.

만약 팀 구성원 대부분이 SQL을 어색하게 여긴다면 도메인 로직을 SQL에서 분리할 이유가 됩니다. 이는 물론 더욱 많은 사람에게 - 적어도 중급단계까지 - SQL을 가르칠 이유가 되기도 합니다. 이런 상황이 팀을 구성하는데 신경 써야 할 부분이기도 합니다. 사람은 아키텍처와 관련된 결정을 하는데 고려되어야하는 요소입니다.

중복 배제 (Avoiding Duplication)

제가 접했던 가장 단순하면서도 강력한 설계 원칙 중 하나는 - 「실용주의 프로그래머」에서 DRY(Don't Repeat Yourself) 원칙으로 공식화된 - 「중복 배제의 원칙」입니다.

DRY 원칙을 우리가 살펴보는 경우에 적용해보고자 이 애플리케이션의 다른 요구사항 하나를 생각해 보겠습니다. 한 고객이 특정 한 달에 주문한 목록을 얻는데 목록에는 주문 ID, 날짜, 총 비용, 해당 주문이 Cuillen 계획에 맞는지 여부가 포함됩니다. 모든 주문은 총 비용으로 정렬이 되어야 합니다.

도메인 객체를 사용한 방식을 사용하려면 주문 객체에 총 비용을 계산하는 메서드를 추가해야 합니다.

class Order...
def total_cost
result = 0.dollars
line_items.each {|line| result += line.cost}
return result
end

이 메서드를 적당한 장소에 두면 주문 목록을 표시하기 쉽습니다.

class Customer
def order_list month
result = ''
selected_orders = orders.select {|o| month == o.date.month}
selected_orders.sort! {|o1, o2| o2.total_cost <=> o1.total_cost}
selected_orders.each do |o|
result << sprintf("%10d %20s %10s %3s\n",
o.db_id, o.date, o.total_cost, o.discount?)
end
return result
end

단일 SQL 명령을 사용하는 같은 질의를 정의하려면 관련된 - 몇몇 사람들의 기를 꺾는 - 서브쿼리가 필요합니다.

 def order_list customerName, month
sql = <<-END_SQL
SELECT o.orderID, o.date, sum(li.cost) as totalCost,
CASE WHEN
(SELECT SUM(li.cost)
FROM lineitems li
WHERE li.product = 'Talisker'
AND o.orderID = li.orderID) > 5000
THEN 'Y'
ELSE 'N'
END AS isCuillen
FROM dbo.CUSTOMERS c
INNER JOIN dbo.orders o ON c.customerID = o.customerID
INNER JOIN lineItems li ON o.orderID = li.orderID
WHERE (c.name = ?)
AND (MONTH(o.date) = ?)
GROUP by o.orderID, o.date
ORDER BY totalCost desc
END_SQL
result = ""
$dbh.select_all(sql, customerName, month) do |row|
result << sprintf("%10d %20s %10s %3s\n",
row['orderID'],
row['date'],
row['totalCost'],
row['isCuillen'])
end
return result
end

사 람에 따라서 둘 중 어떤 쪽이 이해하기 쉬운지 의견이 다를 수 있습니다. 하지만, 제가 지금 곱씹어보고 싶은 논점은 중복에 대한 것입니다. 이 질의문은 주문한 달만을 얻는 원래 질의와 로직이 중복됩니다. 도메인 객체 접근법은 이런 중복이 없습니다. Cuillen 계획의 정의를 바꾸고 싶다고 해도 cuillen? 메서드의 정의를 교체하고 그 사용처들만 바꾸어 주면 됩니다.

중복 문제 때문에 SQL을 쓰레기통에 처박는 것은 공정하지 않습니다. SQL을 다양하게 사용하는 방법도 역시 중복을 제거할 수 있습니다. 열렬한 데이터베이스 애호가들이 지적하려고 안달이 날 만할 이 기법은 뷰를 사용하는 것입니다.

다음 질의문을 기반으로 간단히 orders2라고 하는 뷰를 만들 수 있습니다.

SELECT  TOP 100 PERCENT 
o.orderID, c.name, c.customerID, o.date,
SUM(li.cost) AS totalCost,
CASE WHEN
(SELECT SUM(li2.cost)
FROM lineitems li2
WHERE li2.product = 'Talisker'
AND o.orderID = li2.orderID) > 5000
THEN 'Y'
ELSE 'N'
END AS isCuillen
FROM dbo.orders o
INNER JOIN dbo.lineItems li ON o.orderID = li.orderID
INNER JOIN dbo.CUSTOMERS c ON o.customerID = c.customerID
GROUP BY o.orderID, c.name, c.customerID, o.date
ORDER BY totalCost DESC

이제 이 뷰를 달을 얻고 주문 목록을 만드는데 사용할 수 있습니다.

def cuillen_months_view customerID
sql = "SELECT DISTINCT month(date) FROM orders2 WHERE name = ? AND isCuillen = 'Y'"
result = []
$dbh.select_all(sql, customerID) {|row| result << row[0]}
return result
end

def order_list_from_view customerName, month
result = ''
sql = "SELECT * FROM Orders2 WHERE name = ? AND month(date) = ?"
$dbh.select_all(SQL, customerName, month) do |row|
result << sprintf("%10d %10s %10s\n",
row['orderID'],
row['date'],
row['isCuillen'])
end
return result
end

뷰는 두 질의문을 단순하게 하고 핵심 비즈니스 로직을 한 곳에 둡니다.
중복을 제거하는데 뷰를 이런 식으로 사용하는 것이 흔한 것은 아닙니다. 제가 읽었던 SQL 관련 서적들은 이런 종류의 내용이 없었던 것 같습니다. 어떤 환경에서는 데이터베이스 개발자와 애플리케이션 개발자 사이의 조직적 문화적 괴리 때문에 이런 식으로 일하기 어렵습니다. 종종 애플리케이션 개발자들은 뷰를 만들지 못하게 제한받고 있고 데이터베 이스 개발자들은 이런 뷰를 얻으려는 애플리케이션 개발자들을 낙담하게 하는 병목을 만듭니다. DBA는 한 애플리케이션에서만 사용하는 뷰를 만드는 것을거부하기까지 합니다. 하지만 저는 SQL도 다른 것들과 같이 신중하게 설계될 만한 가치가 있다고 생각합니다.

캡슐화 (Encapsulation)

캡 슐화는 잘 알려진 객체지향 설계 원칙이며 제 생각에 일반 소프트웨어 설계에도 잘 적용 할 수 있습니다. 이것이 말하는핵심은 프로그램이 절차적 호출의 인터페이스 뒤에 자료 구조가 숨겨져 있는 모듈로 나뉘어야 한다는 것입니다. 이 원칙의 목적은 시스템 전반에 영향을 주는 큰 파문을 일으키지 않고 기본 자료 구조를 변경할 수 있게 하는데 있습니다.

우리의 문제는 데이터베이스를 어떻게 캡슐화할 것이냐는 것입니다. 좋은 캡슐화 방안은 애플리케이션 전반에 대한 일련의 괴로운 수정 작업을 유발하지 않고 데이터베이스를 구조를 변경할 수 있게 해줍니다.

기업용 애플리케이션에서 일반적인 캡슐화는 계층을 만드는 것입니다. 우리가 도메인 로직에서 자료 읽기 로직을 분리하는 것 처럼 말입니다. 이런 방식에서는 데이터베이스 설계를 변경해도 비즈니스 로직을 수행하는 코드에 영향이 없습니다.

도메인 모델 방식은 이런 캡슐화의 한 좋은 예입니다. 비즈니스 로직은 메모리에 있는 객체만 가지고 작업을 합니다. 지료를 어떻게 얻는지는 완전히 분리되어 있습니다. 트렌젝션 스크립 방식은 find 메서드로- 비록 데이터베이스 구조가 반환되는 결과 값에 드러나 있지만 - 다소의 데이터베이스 캡슐화가 되어 있습니다.

애플리케 이션 세계에서는 프로시듀어와 객체의 API를 통해서 캡슐화를 달성합니다. SQL에서 이에 상응하는 것은 뷰의 사용입니다. 테이블을 바꾸었다면 예전 테이블을 지원하는 뷰를 만들 수 있습니다. 이때 가장 큰 문제는 종종 뷰로 갱신을 적절히 처리할 수 없다는 것입니다. 이것이 많은 작업장에서 DML(Data Management Language)를 stored procedure로 덮어 싸는 이유입니다.

캡슐화는 단순히 뷰에 변화를 지원하는 것 이상입니다. 이것은 자료에 접근하는 것과 비즈니스 로직을 정의하는 것 사이의 차이에 대한 것이기도 합니다. SQL을 사용하면 이 둘의 경계는 쉽게 희미해지지만 여전히 어떤 형식의 분리를 할 수 있습니다.

예를 들어, 제가 위에서 질의 중복을 제거하려고 정의했던 뷰를 생각해보십시오. 이 뷰는 한 뷰이지만 비지니스 로직 관련 줄과 자료 읽기 관련 줄로 나뉠 수 있습니다. 데이터 읽기 뷰는 아래와 같은 것이 될 것입니다.

   SELECT o.orderID, o.date, c.customerID, c.name, 
SUM(li.cost) AS total_cost,
(SELECT SUM(li2.cost)
FROM lineitems li2
WHERE li2.product = 'Talisker' AND o.orderID =li2.orderID
) AS taliskerCost
FROM dbo.CUSTOMERS c
INNER JOIN dbo.orders o ON c.customerID = o.customerID
INNER JOIN dbo.lineItems li ON li.orderID = o.orderID
GROUP BY o.orderID, o.date, c.customerID, c.name


이제 이 뷰를 도메인 로직에 보다 초점이 맞춰진 다른 뷰에서 사용할 수 있습니다. 다음은 Cuilen에 부합하는지 나타내는 것 입니다.

        SELECT orderID, date, customerID, name, total_cost, 
CASE WHEN taliskerCost > 5000 THEN 'Y' ELSE 'N' END AS isCuillen
FROM dbo.OrdersTal

이 런 생각은 자료를 도메인 모델로 읽어들이는 곳에도 적용할 수 있습니다. 저는 앞에서 Cuillen 달을 읽은 전체 질의문을 취해서 이를 단일 SQL 질의로 교체하는 것으로도 어떻게 도메인 모델의 성능 문제를 처리할 수 있는지에 대해서 말했습니다. 위의 자료 읽기용 뷰를 사용하는 것이 또 다른 방법이 될 수 있습니다. 이렇게 하면 도메인 로직을 도메인 모델에 계속 두면서도 높은 성능을 유지할 수 있습니다. 상품 정보는 Lazy Load 기법을 사용해서 필요할 때에만 읽겠지만 적절한 요약 정보는 뷰를 통해서 얻을 수 있습니다.

뷰 나 stored procedure를 사용하면 어느 정도까지 캡슐화를 할 수 있습니다. 많은 기업용 애플리케이션의 정보는 여러 원천에서 옵니다. 단순히 다중 관계형 데이터베이스일뿐 아니라 오래된 기존 시스템이나 다른 애플리케이션 또는 여러 파일일 수 있습니다. 실제로 XML의 성장 때문에 네트워크를 통해서 공유되는 일반 파일에서 더 많은 자료를 볼 수 있을 것입니다. 이런 때에 완벽한 캡슐화는 진정 애플리케이션 코드 안의 계층화로만 이뤄질 수 있습니다. 이는 도메인 로직이 메모리에 자리를 차지한다는 것을 의미합니다.

데이터베이스 이식성(Database Portability)

많 은 개발자들이 복잡한 SQL을 피하는 이유중 하나는 데이터베이스 이식성 문제 때문입니다. 여러 데이터베이스 플렛폼에서 동일한 표준 SQL을 사용할 수 있고 쉽게 다른 데이테베이스 제품으로 바꿀 수 있다는 것이 SQL의 약속이기는 합니다.

현실에서 이것은 줄곧 어느 정도 날조된 것이었습니다. 실무에서 보면 SQL의 대부분이 표준이기는 해도 걸림돌이 되는 작은 부분이 있습니다. 그럼에도, 주의만 기울인다면 데이터베이스 서버를 다른 것으로 바꾸는데 그리 수고가 들지 않는 SQL 을 만들 수 있습니다. 단 이렇게 하려면 많은데이터메이스의 기능을 사용할 수 없게 됩니다.

데이터베이스 이식성에 대한 결정은 결국 프로젝트의 사정에 따라 다릅니다. 요즘에는 예전보다 그리 큰 문제가가 되지 않고 있습니다. 데이터베이스 시장이 크게 변화되어 대부분의 영역이 세 주요 진영중 하나에 넘어가 버렸습니다. 기업들은 자신이 속한 진영에 무척 의존하는 편입니다. 만약 이런 좋류의 투자 때문에 데이터베이스를 교체하는 일이 있을 것 같지 않다면 당연히 가지고 있는 데이터페이스의 특별한 기능의 이점을 이용해도 좋습니다.

배포되어 다중 데이터페이스와 운영될 수 있는 제품을 만드는 사람 같은 어떤 사람에게는 여전히 이식성이 필요합니다. 이 경우 SQL에 로직을 넣는 것에 관한 강한 논쟁이 있습니다. SQL의 어떤 부분을 안심하고 사용할 수 있는지에 대해 매우 조심해야 하기 때문입니다.

테스트 용이성(Testability)

테 스트 용이성이라는 주제는 설계와 관련된 논의에서 충분이 다뤄지지 않는 경향이 있습니다. 테스트 주도 개발(TDD : Test Driven Development)의 이익 중 하나는 테스트 용이성이 설계에서 필수불가결한 부분이라는 다짐을 되살려 준다는 것입니다.

통상적으로 SQL은 테스트되지 않는 것 같습니다. 실제로 핵심적인 뷰와 stored procedure가 형상관리도구에 보관되는 것 조차 드문일입니다. 그렇지만 테스트가 용이한 SQL을 갖는 것은 정말 가능합니다. 유명한 xunit에는 데이터베이스 환경에서도 테스트에 사용할 수 있는 도구들을 여럿있습니다. Evolutionary database 기술의 데이터베이스 테스트는 TDD 프로그래머들이 즐기는 것과 유사한 테스트 가능한 환경을 제공해줄 수 있습니다.

테 스트 환경을 가장 중요한 측면은 성능입니다. 운영 환경에서는 직접 SQL을 사용하는 것이 더 빠르기는 하지만, 데이터베이스 인터페이스가 데이터베이스 연결을 Service Stub을 사용해서 교체할 수 있 게 설계되어 있다면, 메모리에 있는 비즈니스 로직으로 테스트를 하는 것이 훨씬 빠른 수 있습니다.

요약 (Summing Up)

지 금까지 저는 여러가지 논점에 대해서 말 했습니다. 이제 결정을 내려야 할 시간입니다. 기본적으로 각자가 해야 할 일은 제가 여기에서 얘기한 다양한 쟁점들을 숙고하는 것입니다. 그리고 처한 상황에 따라 평가 한 후 SQL을 활용하기 위해서 그리고 도메인 로직을 거기에 포함시키기 위해서 어떤 정책을 취할지 결정하십시오.

제 방식 데로 하면 가장 중요한 고려사항은 자료가 한 논리적 관계형 데이터베이스에서 오는지 아니면 다수의 다른 - 종종 SQL을 쓸 수 없는 - 저장소 에 걸쳐 흩어져 있는지 여부입니다. 만약 흩어져 있다면 데이터 소스 계층을 메모리에 만들어서 데이터 소스들을 캡슐화 하고 도메인 로직을 메모리에 두어야 합니다. 이 경우 언어로써 SQL의 강점은 문제가 되지 않습니다. 모은 자료가 SQL을 쓸 수 있는 데이터베이스에 들어있는 것이 아니기 때문입니다.

절대 다수의 자료가 논리적으로 한 데이터페이스에 보관되어 있다면 상황이 재미있어집니다. 이 경우에는 먼저 고려해야 할 사항이 두 가지 있습니다. 하나는 SQL과 애플리케이션을 만드는데 사용한 언어 중 어떤 언어를 프로그래밍 언어로 쓸지 선택하는 것입니다. 다른 하나는 코드를 작동시킬 장소(SQL은 데이터베이스이고 아니면 메모리) 입니다.

SQL이 어떤 일은 쉽게 처리할 수 있게 하지만 다른 일은 더 어렵게 만듭니다. 어떤 사람은 SQL이 일하기 쉽다고 느끼는 반면 다른 사람들은 끔찍할 정도로 이해하기 힘든 것이라 여깁니다. 이 대목에서 생각해볼 것은 팀원들 개인의 심리적 편안함도 큰 문제라는 것 입니다. 만약 많은 로직을 SQL에 넣기로 했다면 이식성은 기대하지 말도록 조언드리려고 합니다. 업체 고유의 확장기능을 모두 사용하고 즐겁게 그들의 기술에 의지하십시오. 이식성을 원한다면 로직은 SQL 밖에 두십시오.

앞에서 수정가능성에 대해서 말했었습니다. 저는 수정가능성을 가장 먼저 고려해야 한다고 생각하지만 아주 심각한 성능 문제가 있다면 우선권을 내려놔야 합니다. 만약 메모리에서 처리하는 접근법을 사용하고 있는데 병목이 생겼고 이것을 더 강력한 SQL 질의를 사용해서 해결할 수 있다면 그렇게 하십시오. 데이터를 얻어오는 질의를 어떻게 구성하면 성증을 향상시킬 수 있는지 연구해 보시기를 바랍니다. 제가 위에서 대략 기술한 것 같은 방법으로 하면 SQL에 도메인 로직을 넣는 일을 최소화 할 수 있습니다.


  • 오래된 글이지만 여러가지 생각해볼만 한 것들이 있어 자주 참고하게 되기에 시간을 내서 번역해 보았습니다. 검색을 해도 다른 번역된 글이 없는 듯 하네요.
  • 제가 이글을 읽고 든 생각은

    • 단순히 SQL에서만 생기는 문제인 것 같지는 않습니다. ORM을 써도 여전히 SQL 비슷한 질의 언어나 Criteria는 있으니까요.
    • View를 쓰지 않고 Query Object 같은 방법으로도 해결이 어느 정도 가능할 것 같습니다. 저는 솔직히 이런식으로 View를 쓰는 것을 안 좋아합니다. 로직이 분산되기 때문에 유지보수가 힙듭니다.
    • 객체지향의 기본 가치들과 성능 사이에 우선순위 문제를 어떨게 다룰지에 대해 배울 수 있습니다.
  • 이 기사는 신명수씨가 알려주었습니다.
  • 엉터리 번역이라도 이해해주시고 잘못된 부분은 알려주시면 수정하겠습니다.
  • 이 글 쓰면서 이글루스를 떠날 것을 결심했습니다. 편집기가 참을 수 없을 정도네요.
    • 예제 코드를 편집하기 너무 힘듭니다. 하다하다 안되서 IE에서 편집했더니 <pre>안의 글이 한줄로 표시...
    • Cut & Paste를 하면 단어들이 붙거나 단어 중간에 공백이 들어가는 현상이 빈번
    • 웹 표준 완전 무시, 문단 개념이 없음
    • 긴 글은 뒤를 잘라먹더군요.
    • 하루 종일 별별 방법으로 작업하다 포기하고 대충 올리고 맙니다.
  • 문서수정기록
    • 2008/6/14 : 원래 원본과 비교해볼 수 있게 작성했지만 너무 문서가 길어져서 원본은 뺐습니다.