ORM이냐 날쿼리냐 그것이 문제로다

엽토군·2019년 10월 21일
8
post-thumbnail

오해 없이 들으시면 좋겠는데, 실무가 가끔 막막한 것은 이런 걸 혼자 결정해야 하는 순간이 있기 때문인 것 같다.
요즘 진지하게 고민하고 있다. 과연 우리 서비스에 ORM 개념을 계속 가져가도 좋을까? 여기서는 정말로 날쿼리를 써야 하는 것이 아닐까?

미친거 아니야? 멀쩡한 ORM 냅두고 왜 날쿼리를 써??

ORM은 정말 훌륭한 개념이다. 하나의 테이블이 하나의 존재(엔티티)를 정의하는 체계에서, 모델 기반 객체-관계 맵핑은 환상적으로 작동한다.

use Order;
class User Extends Member {
    const ID = 'no';
    protected $table = 'TBL_MEMBER_USER';
    protected $primaryKey = self::ID;
    public function orders() {
    	return $this->hasMany(Order::class, Order::USER, self::ID);
    }
    public function isPremiumUser() {
    	return $this->orders()->exists() && $this->orders->valid()->exists();
    }
}

지금 맡고 있는 백오피스 소스에서 내가 지난 몇달간 해놓은 일은 순전히 이런 식으로 라라벨 모델 클래스들을 정리해 놓은 일이었다. 기존 소스는 모든 모델이 App\Models 네임스페이스 밑에 다 평등하게 널브러져 아무 관계도, 아무 의미론도 정립돼 있지 않은 상태였다. 정말이지, 이럴 거면 뭐하러 모델 만들었나 싶을 정도로. 왜 그냥 다 DB::query(DB::raw("날쿼리")) 쳐버리지 않고.

몇 달이 지났고 지금은 어느 정도 정리는 되었다. 테이블들 관계 파악도 필요한 것들은 나름 했다고 생각했다. 그런데, 그런 태평한 시절이 지나자, 예컨대 이런 이슈들이 발목을 잡는다.

  1. USER 모델이 AVATARS 모델을 hasMany로 가짐.
  2. USER 모델이 ORDER_WEB 모델을 hasMany로 가지거나 가지지 않음.
  3. 사용자들에게는 USER.NAME, AVATARS.NAME, ORDER_WEB.PRODUCT_NAME을 보여줘야 함.
  4. 이 attributes들은 모두 검색 및 정렬이 가능해야 함.

꽤 있을 법한 시나리오다. 별로 새롭거나 놀라운 챌린지도 없다. 뭐가 잘못될 수 있겠어요? 그간 관계를 빡 짜놓은 모델들을 자신만만하게 use해온 다음 쿼리를 엮었고, 지금은 매우 장렬하게 열세에 몰려 뒤로 돌격을 하는 중이다.

모델이 뭐가 안된다는 건데??

디테일한 사례를 먼저 제시하자면, 이런 부분이 걸린다.

  1. $user->with('avatars', 'orderWeb')으로 eager loading을 한다.
    • 여기까지 매우 라라벨스러움
  2. $user->where(User::NAME, 'like', '%'.$search.'%')를 해서 메인 테이블 검색을 건다.
    • 아직까지 괜찮음
  3. $user->orWhereHas('orderWeb', function ($q) use ($search) { ... }) 같은걸로 어찌어찌 eager load된 모델에 검색을 건다.
    • ...그럴 수 있음
  4. 이제 orderBy()를 걸려고 보니 사용자가 요청한 $orderBy 값이 orderWeb.PURCHASED_AT이라고 한다.
    • ......여기서부터는 약간 나몰라라 됨

혹자는 이럴지도 모르겠다.

아니 뭔 개짓이야? eager loading이라는 거 이렇게 쓰는게 아닌데?? 일단 다 get()한 다음에 그 콜렉션을 where()하고 sortBy()해서 거기서 $model->relation->attribute를 불러오는 게 맞지 않아???

답변을 하자면... 무슨 말을 하고 싶은 건지는 알겠지만, 그게 이론상으로만 그렇고 실제로는 영 할짓이 못된다. 일단 정렬과 페이징은 같은 단계에서 해야 된다는 문제는 치워놓고라도, 애초에 $search = 'naver.com'; 같은 빡센 (그러나 충분히 있을 수 있는) 경우의 수에서, 이 쿼리는 get()의 결과를 다 가져오기도 전에 머신의 메모리를 폭사시키고 그냥 죽어버린다. 별 도움이 안 되는 것이다.

그렇다고 그냥 평범한 쿼리빌더로 짜자니 그건 또 너무 짜치는 일이다. 여기에 그간 각 모델에 정의해 놓은 CONST와 관계성, 기타 "모델 좋다는 게 뭐냐" 하는 것들을 구태여 갖다가 쓰자고까지 나오면... 대충 이 꼴이 난다.

$userPurchaseList = User::select([
	$user->getTable().'.'.$user::NAME,
    $avatar->getTable().'.'.$avatars::NAME,
    $orderWeb->getTable().'.'.$orderWeb::PURCHASED_AT
])
->join($avatar->getTable(), $avatar::USER, '=', $user::ID)
->leftJoin($orderWeb->getTable(), $orderWeb::USER, '=', $user::ID)
->where(DB::raw($user->getTable().'.'.$user::NAME.' like \'%'.$search.'%\''))
->orWhere(function ($q) use ($search) {
	$q->where(DB::raw($orderWeb->getTable().'.'.$orderWeb::PRODUCT_NAME.' like \'%
    '.$search.'%\''))
    ->orWhere(DB::raw($orderWeb->getTable().'.'.$orderWeb::PRODUCT_DESC.' like \'%
    '.$search.'%\''));
})
->orderBy($order, $direction)->groupBy([
	$user->getTable().'.'.$user::NAME,
    $avatar->getTable().'.'.$avatars::NAME,
    $orderWeb->getTable().'.'.$orderWeb::PURCHASED_AT
])->get();

현타가 찐하게 온다. ~ㅅㅂ~ 난 그저 라라벨 매뉴얼이 약속했던 $user->with()->get() 같은 환상적으로 깔쌈한 쿼리를 치고 싶었을 뿐인데 대체 내가 뭘 그렇게 잘못했다고??

인정할 건 인정해야겠다는 생각이 드는 건 나뿐인가??

내가 잘못한 건 없(겠)지만, 체계가 잘못(?)하고 있는 원죄라 할 만한 것은 있다. 적어도 내 경험의 지평 안에서는, 대부분의 리얼 월드 상황은 ORM 방식을 곧이곧대로 도입하기가 어려운 것이다. 하나의 테이블이 하나의 엔티티를 구성하지 않을 가능성이 높은 탓이다.

테이블 1개를 보고 거기서 어떤 엔티티의 정보를 얻을 수 있는 비율을 엔티티 효율(entity efficiency)라고 부를 수 있을까? 만약 그런 게 있다면, 그게 100%에 가까울수록 그 테이블은 엔티티 효율이 좋은 테이블이고, 그건 그냥 모델로 만들어서 상수 설정을 쭉 해 편리하게 사용하면 그만일 터인데, 안타깝게도, 적지 않은 경우, 실무 상황에 놓여 있는 테이블들은 엔티티 효율이 매우 떨어지는 것이다.
morphTo 관계는 기본이고, 단순 매트릭스, 특정한 조건에 맞추어 몇 가지 특정 테이블과의 임의 연관, 심지어는 특정 컬럼의 값이 무어냐에 따른 JOIN 규칙 변화 등이 다 필요한 때가 있는 것이다. 그래서, 3 ~ 4개 테이블을 LEFT JOIN해 봐야 비로소 의미 있는 하나의 자료 집합이 도출되는 경우가 -- 계산하자면 대충 33~25%의 엔티티 효율이 될 터이다 -- 실로 병가지 상사인 것이다.

그렇게 따지면, 사실은, 자료를 바라보는 관점 자체부터가 ORM만 혼자 다르다. 예컨대 "올해 가입했는데 아직 결제 이력이 없는 회원"이라는 조건부 엔티티를 생각해 보자. 이 얼마나 간단하고 명쾌하며 예상 가능한 엔티티인가? 데이터 사용자는 당연히 이 정도 자료는 추출 가능하다고 기대하고, DBA 또한 대충 JOIN 몇 개 붙인 간단한 쿼리 결과를 엑셀에 붙여 일을 끝낼 것이다. 그리고 그걸로 그만인 일이다. 그런데, 모델 개념으로 각 잡고 풀자고 들면, 거기서부터는 일이 꼬이고 만다.
일단 $user->whereDoesntHave('orderWeb')까지는 자신만만하게 붙인다. 그런데 생각해 보니 결제 이력 저장하는 테이블이 2개 있다. 그래서 orWhereDoesntHave() 메소드가 필요하다는 생각에 이른다. 그런 건 없다. 만들어야 하나? 싶어서 orWhereHas(function ($q) { $q->whereNull(OrderApp::ID); }) 같은걸 시도해 본다. 안 되는 걸 확인한다. 여기서부터는 잠시 눈앞이 깜깜해지거나 하늘이 노래진다. '데이터 추출 요청' 업무의 due가 조정되고, 한없이 뒤로 밀린다.

내가 그러고 있었다.

programmatic raw query는 가능할까/바람직할까??

최근에는 이런 아이디어를 구체화하는 중이다.

class User extends Mold {
	private $tables = [
    	'user' => 'TBL_MEMBER_USER',
        'avatars' => ['TBL_AVATARS', 'avatar.USERID = user.user_no', 'left'],
    ];
    private $columns = [
    	'user' => [
        	'name' => 'NICKNAME',
            'joined_at' => 'CREATED_AT',
        ],
        'avatars' => [
        	'name' => 'NNAME',
            'sex' => 'SEX',
        ]
    ];
}
$user = new App\Classes\Mold\User;
$user->setWhere('user.name', "like '%$search%'");
$user->setOrder('user.joined_at', 'desc');
$user->setPage(3, 30);
var_dump($user->select());

별거 없고... 사실 위의 코드는 아래와 같은 SQL을 어떻게 손도 못 써보고 그저 죽어라 노려보고나 있자면 속에서 천불이 나서라도 생각날 수밖에 없는 뭐 그런 구성이 아닌가 한다.

SELECT
	user.NICKNAME as user__name,
	user.CREATED_AT as user__joined_at,
	avatars.NNAME as avatars__name,
	avatars.SEX AS avatars__sex
FROM TBL_MEMBER_USER user
LEFT JOIN TBL_AVATARS ON avatar.USERID = user.user_no
WHERE user.NICKNAME like '%foo%'
GROUP BY user.NICKNAME, user.CREATED_AT, avatars.NNAME, avatars.SEX
ORDER BY user.CREATED_AT DESC
OFFSET 60 FETCH NEXT 30 ROWS ONLY;

그럴 일이 있을지 모르겠지만 만약 이 클래스를 완성해서 packagist에 공개하는 날이 오면 아마 네임스페이스는 Mold\Mold가 될 것이다. 모델과 흡사하지만 모델보다는 덜 멋진, 어쨌든 특정한 자료 엔티티의 거푸집(mold) 역할은 할 수 있는 것으로 만들고 싶다. 할 일은 많다. (DB 드라이버 지원부터 INSERT, UPDATE 추가까지...) 다만 이게 정말 쓸모가 있는지 그걸 모르겠어서 소심하게 깨작거리고만 있다.

사실, 다 써놓고 나서 조사하면서야 알게 된 건데, 라라벨 ^6.0에 오면서는 서브쿼리 지원이 강화되었다는 모양이다. 그런데 대충 살펴보자면 여전히 흡족하지 못하다. 좀더 표현력 있고 이해 가능한 코드가 되긴 하겠지만, 이 코드만으로 엔티티 효율이 올라가는 것은 아니라는 점에서는 본질적으로 위에서 "대충 이꼴이 난다" 하고 장황하게 늘어놓은 쿼리와 썩 다르지 않다는 생각이다.

$userPurchaseList = User::addSelect([
	'purchased_at' => OrderWeb::select(OrderWeb::PURCHASED_AT)
    	->whereColumn(OrderWeb::USER, 'user.'.User::ID)
])->orderByDesc(OrderWeb::select(OrderWeb::PURCHASED_AT)
	->whereColumn(OrderWeb::USER, 'user.'.User::ID)
)->get();

Mold\Mold 클래스는, 한번 Mold를 정의해 놓으면 그냥 그 자체로 하나의 온전한 엔티티가 되도록 하는 데 주안점을 두려고 한다. Mold\User가 있고 Mold\AdminUser가 있고 Mold\SuperAdminUser가 있는 식이다. (이 방식을 권장하려고 한다.) 각각의 $mold는 그냥 select()를 하면 사전 정의된 테이블/컬럼 정의에 맞춰 2차원 매트릭스 하나가 딱 떨어지고, insert()를 하면 사전 정의된 테이블의 사전 정의된 컬럼에 필요한 INSERT를 실행해 주는 식이다. (기본 CRUD 외의 다른걸 거의 안/못 하도록 할 생각이다.) 사실은, 차포 떼고 말하자면, 날쿼리에 사용되는 특정 구문들을 자동 완성해 주는 도구에 불과한 것이다.

나쁜 아이디어는 아니라고 생각한다. 아주 혁신적인 아이디어도 아니고. 그냥 이게 나 한 사람 이상의 필요를 채워 주는 일이면 좋겠다고 생각한다. 일단 해봐야겠지? 근데 이것도 일로 해야 진척이 있는 일인데 이것 참 내일 출근하기 싫어서 벌써부터 큰일이다.

profile
4년차 PHP 개발자입니다.

0개의 댓글