Is ORM really fxxk

2020. 6. 22. 01:20_

해외에서 소프트웨어 설계와 관련되어 논의되는 주제가 있다.

 

"ORM is antipattern"

"ORM is bad"

"ORM is fxxk"

등 데이터베이스를 구성하는 소프트웨어 개발에 흔히 사용하고 있는 ORM(Object Relational Mapping)의 설계적 관점에 대한 논의이다. (당연하게도 ORM 라이브러리를 사용하면 엔지니어가 신경 쓸 부분이 줄어 개발 속도는 상당히 증가한다. )

 

 

1. 왜 ORM을 사용하는가

사용이 간단하다.

 

길게 말할 것 없이 ORM을 사용하는 이유는 성능, 구조를 떠나 간단하기 때문이다.

 

조금 강하게 말해보자면, SQL과 집합에 대한 지식 없이 코드 개발만 익힌 엔지니어도 데이터베이스 연동 개발을 성공적으로 진행할 수 있다. (실제로 대부분의 코딩 교육기관에서 SQL을 직접 가르치는지? 대부분 orm을 사용해 개발을 교육하고 있지 않는지? 를 확인하는 것만으로도 알 수 있을 것 같다.)

 

또한, 이러한 이유로 엔지니어는 비즈니스 로직 개발에 집중할 수 있다.

(테이블에 쌓인 데이터를 조회하는 서비스에서 굳이 쿼리를 매번 직접 작성하는 것은 합리적이지 않은 노동인 것 같다. )

 

마지막으로 엔지니어들이 직접 쿼리를 작성(쿼리빌더를 포함)하는 경우 alias 네이밍 컨벤션을 정하고 그에 따라야 할 것인데, ORM은 그런 alias까지 알아서(사실 신경 쓸 필요가 없이) 데이터에 맵핑을 해주니 이보다 편리할 수가 없다.

 

2. 왜 ORM을 사용하는 것이 문제인가

엔지니어가 ORM의 문제를 모르는게 문제다.

 

이게 뭔소리냐 GNU is Not Unix도 아니고

ORM사용의 문제는 엔지니어가 ORM사용의 문제(엔지니어가 ORM사용의 문제를 모르는 게 문제다)를 모르는게 문제다

 

말장난이 아니라 엔지니어가 ORM의 문제를 인지하고 SQL을 정확히 다룰 수 있는 수준이라면 ORM을 사용할 때에 발생하는 문제에 대한 대처나 해결법의 수준이 다르다는 것이다.

 

실제로 n+1 problem이 무엇인지조차 모르는 엔지니어도(orm을 통해 데이터베이스로부터 데이터가 추출되니 그 이상 신경 쓰지 않는 것일 가능성이 높다.) 꽤나 많고, orm의 원리에 대해 깊이 고민하지 않아 왜 n+1 problem이 발생하는지, 어떤 원리로 result rows가 object에 맵핑되는 것인지 이해하지 않는 경우가 많다.

 

집을 짓는데 굳이 벽돌 재료까지 알고 있어야 하느냐 라고 말할 수 있겠지만, 벽돌 재료까지 알면 좋지 않나? 최소한 집이 무너졌을 때에 벽돌이 겹쳐진 상태에서 수직 방향으로 견딜 수 있는 하중이나 그 원료를 이해하고 있다면 붕괴 원인을 찾기가 더 수월하지 않을까?

 

앞서 말했듯 조립형 목조주택을 짓는데 나무의 강성, 경도를 굳이 알 필요는 없듯 이미 잘 만들어진 테이블 구조로부터 데이터를 조회하기만 하면 된다면 ORM이 어떤 원리인지는 굳이 그 서비스를 만드는 데에는 필요 없는 지식일 것이다.  다만 초고층빌딩을 짓는데 철근의 인장강도도 모르고 지어도 될지는... 음 사실 건축학도가 아니라 잘 모르겠다. (그런 것 모르고 그냥 철근 세우고 틀에 콘크리트 부어도 잘 지어질 수도 있다.)

 

다만 실제로 ORM만을 사용하고 서비스를 개발하는 것으로도 비즈니스 로직을 충분히 소화할 수도 있지만 SQL을 정확히 이해하고 사용하는 엔지니어와는 분명한 차이가 있을 것이다. (거의 모든 대학교육 과정에서는 3/4학년 수준에서 데이터베이스 등의 이름으로 과정이 구성되어 있다.)

 

3. 그럼 ORM 그 자체의 문제가 무엇인가

SQL쿼리의 결과를 테이블과 1대1 매칭으로만 볼 수 없다.

 

이제 본격적인 주제와 그 결론이 나온다.

 

소프트웨어 코드를 작성할 때에 데이터 모델을 만드는 이유가 무엇인가? 해당 데이터 모델이 사용될 모든 데이터 형을 포함하도록 설계해 접근성과 구조적 설계 용이성을 확보하는 것이다.

 

하지만 SQL쿼리는 단순히 테이블에 저장된 정보만을 리턴하는 것이 목적이 아니다. RDBMS의 특성상 각 테이블 간은 관계적으로 연결되어있는데, 이 관계를 연결하고, 그로부터 새로운 계산 결과를 도출하는 것이 가장 큰 목적이다.

 

다음과 같은 형태로 결제 정보를 갖고 있는 payment 테이블이 존재한다고 가정해보자.

paid_at DATETIME
amount INT

이에 대한 ORM data class 모델은 다음과 같은 형태일 것이다.

class Payment(Model):
    class Meta:
        table = 'payment'
        
    paid_at = fields.DatetimeField()
    amount = fields.IntField()

다른 얘기에 앞서서 극단적으로 말하자면 이 모델로일별 결제 총량을 가져올 수 없다.(모델에 일간 정보에 대한 것이 없기 때문에 설계적인 관점에서 이 모델은 일간 정보를 가져올 수 있는 모델이 아니다.)

 

물론, 이 경우 python 라이브러리인 tortoise-orm 을 사용하고 있기 때문에 python 특성상 모델에 정의되지 않은 필드도 설정할 수 있다. (tortoise도 그 점을 이용하고 있고, annotate문법을 사용할 수 있다.)

payments = Payment.annotate(daily_amount_total = Sum('amount')).group_by(Date('paid_at')).all()
amounts = [payment.daily_amount_total for payment in payments]

 

슈도 코드이니group by절에 대한 부분은 뒤이어 다루도록 하고 annotate에 집중하도록 하자.

 

그런데...  payment.daily_amount_total?? 분명 payments는 List[Payment] 형태로 리턴이 되는데, Payment에 정의되지 않은 daily_amount_total을 사용하고 있다. (python 특성상 가능한 부분이지만, strict typing 언어의 경우 이런 형태는 설계적으로 큰 결함이라고 볼 수 있다.)

 

만약 이 부분을 구조적으로 안정되도록 개발한다면 아마 이런 형태가 될 것이다.

payments = Payment.all()
daily_paments = {}
for payment in payments:
    paid_date = payment.paid_at.date()
    if paid_date not in daliy_payments:
        daily_paments[paid_date] = 0
    daily_paments[paid_date] += payment.amount

payment를 가져온 후 다시 일 별로 분리한다는 것은..

 

 

와우. 놀라운 접근이다.

 

바로 이어가자면 group by가 데이터와 오브젝트 맵핑의 가장 큰 문제라고 볼 수 있다. group by 절이 발생하는 경우 DB상의 데이터는 테이블의 해당 column(혹은 함수 실행 결과)에 따라 데이터가 계산되어 추출된다.

SELECT Sum(amount), 
       Date(paid_at) 
FROM   payment
GROUP BY Date(paid_at)

이 경우 ResultRow는 Payment 모델에 어떻게 맵핑되어야 하는 것인가?

 

기존 payment 테이블의 column의 어느 형태라도 aggregate 없이 데이터를 추출할 수 없다. 이는 곧 payment 테이블의 어떠한 정보도 원래 column상태대로 사용될 수 없다는 것이고 정의한 데이터 모델과 전혀 무관한 데이터들이 리턴된다는 것이다.

 

tortoise-orm의 group by 실 사용 예에서 비슷한 모습을 볼 수 있다. (tortoise-orm의 문제가 아니냐 라고 할 수도 있지만 tortoise orm is inspired by django orm인데, 실제로 django-orm도 동일한 문제를 갖는다. 그냥 orm 자체에서 우아하게 해결할 수 없는 문제라고 보면 될 것이다. )

from tortoise import Model, Tortoise, fields, run_async
from tortoise.functions import Avg, Count, Sum


class Author(Model):
    name = fields.CharField(max_length=255)


class Book(Model):
    name = fields.CharField(max_length=255)
    author = fields.ForeignKeyField("models.Author", related_name="books")
    rating = fields.FloatField()


async def run():
    await Tortoise.init(db_url="sqlite://:memory:", modules={"models": ["__main__"]})
    await Tortoise.generate_schemas()

    a1 = await Author.create(name="author1")
    a2 = await Author.create(name="author2")
    for i in range(10):
        await Book.create(name=f"book{i}", author=a1, rating=i)
    for i in range(5):
        await Book.create(name=f"book{i}", author=a2, rating=i)

    ret = await Book.annotate(count=Count("id")).group_by("author_id").values("author_id", "count")
    print(ret)
    # >>> [{'author_id': 1, 'count': 10}, {'author_id': 2, 'count': 5}]

Book으로부터 데이터를 가져오는 코드이나 Book모델의 구조와 무관한 데이터셋이 리턴되는 것을 확인할 수 있다.

 

FYI.

물론 ORM은 이러한 문제를 해결하기 위해 DSL 형태의 쿼리 생성 기능을 제공한다.

JPA에서 @Query annotation을 통해 커스텀 데이터클래스에 맵핑하거나, exposed의 경우 DSL을 통해 직접 정의하는 형태가 그것인데, 일반적으로 Map<String, Any> 형태를 리턴하고 이 값을 직접 Object에 맵핑하게 된다.

 

SELECT
       paid_at,
       Sum(amount), 
       Date(paid_at) 
FROM   payment
GROUP BY Date(paid_at)

실제로 원본 형태의 column을 추출해보려 시도하면 아래 형태의 오류가 발생할 것이다. (굳이 추출하겠다면 가능은 하지만 group by를 사용하는 의미가 없을 것이기 때문에 ORM과 GROUP BY는 결코 양쪽 다 우아하게 해결될 수 없는 문제이다.)

SELECT list is not in GROUP BY clause and contains nonaggregated column

 

4. 그래서?

상황에 맞게 잘 타협해라.

 

솔직히 말해 ORM의 편리성은 누구도 반박할 수 없는 부분이다.

(join 쿼리를 알아서 작성해주는 것만 해도 개발 속도에 굉장히 큰 영향을 끼친다.)

 

내 경우 기본적으로 tortoise-orm 라이브러리를 주로 사용하고 (필요한 경우 기여하고) 있고 기본적으로는 tortoise-orm의 기능으로 개발을 진행한다. 다만 위와 같이 group by를 사용해야 하는 경우 아래와 같은 형태로 pypika (kayak에서 maintain 하는 파이썬 SQL 쿼리 빌더 라이브러리이다.)로 직접 쿼리를 작성하고 있다.

filter = ...

payment = Table('payment')
payment__id = payment.field('id')

payment_history = Table('payment_history')
payment_history__amount = payment_history.field('amount')
payment_history__payment_id = payment_history.field('payment_id')
payment_history__created_at = payment_history.field('created_at')
payment_history__payment_method = payment_history.field('payment_method').as_(
    'payment_history__payment_method'
)

payment_history__created_at__date = functions.Date(payment_history__created_at).as_(
    'payment_history__created_at__date'
)
payment__amount__total = Sum(payment_history__amount).as_('payment__amount__total')
    
query = Query.from_(payment_history).select(
    payment__amount__total,
    payment_history__created_at__date,
    payment_history__payment_method,
).left_join(payment).on(
    payment__id == payment_history__payment_id
).where(
    filter
).groupby(
    payment_history__payment_method, payment_history__created_at__date
).get_sql(quote_char='`')

ORM의 편리성을 이용해 개발하고, 어쩔 수 없는 부분의 경우 쿼리 빌더로 직접 작성하는 것이 속도와 기능, 성능을 모두 잡는 방향이 아닐까 생각한다.

 

만약 ORM is fxxk이라는 것으로 결론이 난다고 해서 무조건 쓰면 안 된다 는 것은 문제가 있다고 생각한다.

 

지금 당장 불안정하고 문제가 있어 보이는 코드도 그 당시엔 그럴만한 이유가 있었던 코드이다. 안티 패턴도 상황에 따라 써야 할 상황이 있는 것이고, ORM의 경우에도 비즈니스 로직의 개발이 우선시되는데 설계적인 이유를 가져와 굳-이 "ORM은 절대 안 된다"는 식으로 프로젝트를 진행하는 것보다는 조금 더 유연히 대처하는 것이 어떨까 생각한다.