라라벨의 엘로퀀트 ORM에서 값 객체 사용하기

today 2019-02-06 face Posted by appkr turned_in Work & Play forum 0

이 포스트에서는 엘로퀀트 모델에서 값 객체(Value Object)를 사용하는 몇 가지 방법을 고객 모델을 예제로 설명합니다.

  1. 변경자와 접근자(Attribute Mutator & Accessor)를 이용하는 방법
  2. LOB(Large Object)를 이용하는 방법
  3. 참조(외래키)를 이용하는 방법

먼저 이 포스트에서 사용할 용어를 설명하겠습니다.

모델

현실 세계의 복잡한 물체 또는 사상을 목적에 적합하도록 핵심만 간소화한 것. x:1 비행기 프라모델은 실물을 본따 만든 모형입니다. 웹 서비스를 개발하는 컨텍스트에서 모델은 비즈니스에 참여하는 여러 실체의 본질적인 특징만 뽑아 추상화한 것입니다. e.g. 고객 모델

엔티티와 값 객체

모델은 다시 1) “엔티티”와 2) “값 객체”로 분류할 수 있습니다. 고객#1과 고객#2는 고유한 식별자로 구분지을 수 있으므로 엔티티라 하는 반면, 고객의 주소는 전체 값으로만 서로 같고 다름을 식별할 수 있으므로 값 객체라 합니다. 값 객체는 밸류 오브젝트, 또는 줄여서 밸류라고 부르기도 합니다. e.g. 고객 1번과 고객 2번은 식별자에 의해 서로 다른 모델. 고객 1번과 2번은 가족인데, 이들의 주소 ‘서울특별시 강남구 삼성동 162-17’과 ‘서울특별시 강남구 삼성동 162-17’은 전체 문자열이 같으므로 같은 주소임.

원시 타입과 박스 타입

PHP 언어에서 int, float, string, array, bool과 같은 데이터 타입을 원시 타입(Primitive Type)이라 합니다. 64bit 환경에서는 int라 쓰지만 4byte가 아니라 8bytes 즉 2^64 메모리 공간을 차지하고, float도 double과 같은 표현 범위를 가집니다(see http://php.net/manual/en/language.types.php). 여튼, 아래 예처럼 고객을 의미할 때 customer:string처럼 원시타입으로 표현하기 보다는 customer:Customer로 쓰는 것이 더 많은 컨텍스트를 전달 할 수 있습니다. 여기서 후자를 박스 타입(Boxed Type)이라 부를 수 있습니다. 이를 다시 맥락에 따라서 엔티티나 값 객체로 부를 수도 있고요.

$c = '홍길동';

// v.s.

$c = new Customer('홍길동');

One to Many 조인에서 Many 쪽 최종 레코드만 조회하기

today 2018-11-20 face Posted by appkr turned_in Work & Play forum 0

고객 목록에 고객별 최근 주문 1건에 대한 요약 정보를 보여주세요.

라는 요구사항이 있습니다.

“고객” 객체와 “주문” 객체간의 관계는 다음과 같습니다.

+------------+           0..* +------------+
|  Customer  | <>-----------> |   Order    |
+------------+                +------------+

애플리케이션 레이어에서 구현한다면, ORM을 통해 구한 Collection<Customer>을 순회하면서, Customer객체의 멤버 필드인 Collection<Order>를 대상으로 최근 Order 객체만 필터링했거나, 적절한 순서로 정렬하여 뽑아 쓰기 쉽도록 했을겁니다. 요런 느낌으로요(검증 안된 Pseudo Code 입니다).

// Service Layer
public function listCustomers()
{
    return $customers->map(function (Customer $customer) {
        $sorted = $customer->orders->sortByDesc('id')->values();
        $customer->setRelation('orders', $sorted);
        return $customer;
    });
}

// Controller/View 등
foreach ($customers as $customer) {
    $lastOrder = $customer->orders->first();
    $lastOrder->order_number; // 최근 주문 번호   
}

문제점들이 눈에 띕니다.

  • 시간복잡도는 O(m x n)입니다.
  • CPU와 메모리를 혹사시킵니다.
  • Customer.orders: Collection<Order>를 미리 로드하지 않았다면, N + 1 문제가 발생합니다.
  • 이 외에도 제가 보지 못한 문제점들이 더 있을 겁니다…

읽기 전용 쿼리이므로 ORM을 쓰지 않아도 됩니다. 싸고, 빠르고, 안전하게 SQL만으로 뽑아내는 방법을 찾아봤습니다.

PHP 객체의 복제 특성

today 2018-11-04 face Posted by appkr turned_in Learn & Think forum 0

PHP 객체를 다른 변수에 할당(대입)하면, 객체 자체가 메모리 복제되어 새로운 변수에 할당되는 것이 아니라, 원본 객체가 담긴 메모리 번지만 참조됩니다(Like Pointer in C language).

  • $foo$foo2Foo 클래스의 인스턴스가 담긴, 같은 메모리 번지를 가리킵니다.
  • $foo3$foo 인스턴스로 부터 복제했으므로, 복제 시점의 $foo의 상태를 그대로 가져오지만, 서로 다른 객체입니다.
  • $foo4Foo 클래스의 새로운 인스턴스이므로, 당연히 $foo, $foo2, $foo3와 다른 객체입니다.

SQL Injection 방어

today 2018-10-20 face Posted by appkr turned_in Learn & Think forum 0

Exploit of a mom 그림출처: Exploits of a Mom

Cut#1 (학교) 여기 학교인데요. 전화 드린 이유는, 학교에 컴퓨터 문제가 좀 있어서요.
Cut#2 (엄마) 우리 애가 사고쳤나요?
     (학교) 예... 일종의...
Cut#3 (학교) 그런데, 정말로 아들 이름을 "Robert'); DROP TABLE students;--" 로 지으신게 맞나요?
      (엄마) 예 맞아요. 그래서 집에서는 우리 애를 "리틀 바비 테이블"이라고 불러요.
Cut#4 (학교) ;;; 기뻐하실 지 모르겠지만, 학생 테이블의 레코드 전체를 잃어 버렸어요.
      (엄마) 적어도 이번 사건 덕분에 DB에 입력할 값을 잘 필터링 해야 한다는 사실 정도는 배우셨겠군요~
• • •

프레임웍을 쓰면 쉽게 안전성을 확보할 수 있는데요. 그럼에도 불구하고, 아래 코드의 queryStoresByParams() 함수처럼 Raw 쿼리를 써야 할 때가 있죠. 이 때 SQL Injection을 당하지 않도록 주의해야 합니다.

<?php // 회사 코드에서 일부 발췌

class StoreRetriever
{
    // 라라벨 프레임웍이 제공하는 엘로퀀트 ORM과 쿼리 빌더를 이용하는 경우
    // PDO와 Prepared Statement를 이용하므로 SQL Injection은 자동 방어됨
    public function retrieveStoresByParams(StoreSearchParamDto $dto, array $eagerLoads = [])
    {
        $builder = !empty($eagerLoads)
            ? Store::query()->with($eagerLoads)->select('stores.*')
            : Store::query()->select('stores.*');
        $this->applySearchParams($builder, $dto);
        $this->applyOrderBy($builder, $dto);

        return $builder->paginate($dto->getSize(), ['*'], 'page', $dto->getPage());
    }

    // Raw 쿼리를 쓸 때 사용자로부터 받은 문자열을 직접 쿼리에 끼워 넣으면 SQL Injection에 무방비 상태가 됨
    // e.g. $query[] = "and stores.created_at >= {$from}";
    //
    // 여기서 Raw 쿼리를 왜 썼는가? "성능". 소위 말하는 "Query Model"
    // PHP7 with Xdebug on Docker, 테이블 조인 5개 & 12,000 레코드 조회시 API 응답 시간
    // 	 - retrieveStoresByParams(): around N sec 
    //   - queryStoresByParams(): under N/10 sec
    public function queryStoresByParams(StoreSearchParamDto $dto, array $columns = ['stores.*'])
    {
        $columnString = implode(',', $columns);
        $query[] = "select {$columnString} from stores";
        $bindings = [];

        $query[] = "where 1 = 1";

        $from = $dto->getFrom();
        if ($from !== null) {
            $query[] = "and stores.created_at >= :from";
            $bindings['from'] = $from;
        }

        // ...

        return \DB::select(implode(' ', $query), $bindings);
    }

    private function applySearchParams(Builder $builder, StoreSearchParamDto $dto)
    {
        $from = $dto->getFrom();
        if ($from !== null) {
            $builder->where('stores.created_at', '>=', $from);
        }
    
        // ...
    }
    
    private function applyOrderBy(Builder $builder, StoreSearchParamDto $dto)
    {
        foreach ($dto->getOrderBy() as $order) {
            $builder->orderBy($order['sortKey'], $order['sortDirection']);
        }
    }
}
keyboard_arrow_up