이론

본 문서는 개발 이력 간단 로그이다. 라라벨이 Rapid Development를 지향한다고 하는데 대체 그게 얼마나 뢔피드한 것이냐 궁금하면 이 문서로 한번 맛 좀 보시라.

만든 메모장 앱 설명

주석 2019-02-13 104529.jpg

  • 이름: Memmo
  • 요약: 조용한 메모앱 찾다가 열받아서 내가 차린 메모앱
    • 기존 메모앱들은 열자마자 바로 메모를 쓰기 시작할 수 없다. 꼭 내가 지금까지 쓴 메모의 목록을 보여준다.
    • 이 메모앱은 다른건 다 똑같고 첫 화면 마빡 가장 잘 보이는 곳에 메모 입력 폼을 넣어버리기로 한다.
    • 뭐 본질적으로 또하나의 TodoMVC 예제라고 보시면 됨

계획과 전제

  • 기존 도메인 yuptogun.com이 있으므로 서브도메인 memmo.yuptogun.com을 만들고 여기에 라우팅을 연결한다.
  • 이미 php artisan make:auth && php artisan migrate를 실행했다고 가정한다.
    • 라라벨 기본 인증/계정 시스템 스캐폴딩을 쓰고 있다는 가정.
    • 그렇다면 모든 라우트를 auth 미들웨어 태울 수 있고, 기존의 users 테이블과 관계 설정하는 칼럼을 둘 수 있다.
    • 그러면 비로그인 상황에 대한 예외처리를 할필요가 없어져버림 ㅋㅋ
  • 최소 기능 제품으로 만든다.
    • CRUD만 할수있으면 됨
    • 심지어 별도의 record 생성 뷰 및 조회 뷰도 만들지 않는다. 모두 목록 + 폼 통합으로 처리한다.

실제

1. 필요한 것 받기

컴포저npm은 당연히 사용 가능하다고 전제한다.

1.1. 폼 헬퍼

composer require "laravelcollective/html":"^5.5.0"

라라벨 내장 라이브러리 중 버전업에 따라 공식적으로 관리를 중단하는 것들이 있는데 이 중 Forms & HTML은 라라벨 콜렉티브라는 착한 사람들이 줍줍 하여 별도 패키지로 관리해주고 있다. 이 패키지를 require할 때는 php artisan --version 쳐서 나오는 버전에 맞게 명시해 준다. (나의 경우에는 라라벨 5.5를 쓰고 있어서 5.5로 명시해줌)

사실 코드이그나이터에서 라라벨로 넘어올 때 마지막으로 귀찮고 힘들었던 게 폼빌더가 없다는 거였는데, 아주 없지는 않더라는 느낌?

1.2. Bootstrap 4

npm install bootstrap popper.js

프론트엔드 힙스터들은 요즘세상에 누가 부트스트랩 쓰냐고 기겁할지 모르겠는데 리얼 월드에서는 아직도 부트스트랩 2와 PHP 4가 쓰이는 곳이 있다는 거나 좀 알아주셨으면.

2. 스캐폴딩

2.1. 필요한 모든 파일 초고속즉시생성

php artisan make:model Memmo -mcr

이 1줄짜리 명령으로 모델 + 마이그레이션 + 컨트롤러의 기본 골격을 만들고, 이때의 컨트롤러를 Resourceful한 컨트롤러로 세팅까지 할 수 있다. 이 문서의 나머지 부분은 사실상 이 커맨드의 뒷수습 과정이다.

2.2. 마이그레이션 작성

상기 2.1. 스텝이 만든 database/migrations/2019_02_어쩌구_저쩌구_create_memmos_table.php 파일의 up() 메소드를 이렇게 고쳐준다.

public function up()
{
    Schema::create('memmos', function (Blueprint $table) {
        $table->increments('id');
        $table->text('memo');
        $table->unsignedInteger('user');
        $table->timestampsTz();
        $table->softDeletesTz();
        $table->foreign('user')->references('id')->on('users');
    });
}

웬만한 웹 개발자라면 뭐가 뭔지 알수 있을 것이고...

  • softDeletesTz() (또는 softDeletes()) 메소드는 deleted_at이라는 TIMESTAMP 칼럼을 만들어준다. 여기에 값이 있으면 삭제된 것으로 간주하게 할 수 있다. 이런걸 소프트 삭제라고 한다는데 암튼 라라벨은 이런 게 다 준비되어 있고 그냥 갖다 쓰면 된다는 점이 rapid하다.
  • foreign()->references()->on() 여러분이 생각하시는 그 외래키 제약 걸기 방법이 맞다.

2.3. 마이그레이션 실행

php artisan migrate

이제 사용 중인 로컬DB를 까보면 memmos라는 이름의 빈 테이블이 예상하는 그 구조 그대로 만들어져 있음.

3. 모델 설정

아마도 app/Memmo.php 파일일 것인데 이걸 까본다.

3.1. 클래스 설정

use Carbon\Carbon;
use \Illuminate\Database\Eloquent\Model;
use Collective\Html\Eloquent\FormAccessible;
use \Illuminate\Database\Eloquent\SoftDeletes;

각각 다음과 같다.

3.2. 트레이트 및 클래스 변수

use SoftDeletes;
use FormAccessible;

상기 3.1. 에서 언급한 트레이트들을 갖다써준다.

public $fillable = ['memo', 'user'];
protected $dates = ['deleted_at'];
  • $fillable '채워넣기' 가능한 컬럼 정의. 채워넣기가 무엇인지는 잠시 후에 보여드리기로 한다. 여기에 명시하지 않은 컬럼을 입력/수정하려면 완전 FM 방식으로만 해야 한다. 웹 폼으로 입력받는 필드는 여기에 싹다 명시해 줘야 한다고 생각하면 편하고 대체로 그게 맞다.
  • $dates DATETIME 타입 컬럼 정의. 여기에 명시하지 않은 컬럼은 아무리 실제 타입이 날짜/시간 타입이라 하더라도 모델이 제대로 취급하지 않는다. 기본값은 created_atupdated_at 둘뿐인데 우리는 deleted_at 필드도 써야 하므로, 저렇게 deleted_at을 추가해줘야 한다.

3.3. 관계 설정

public function user()
{
    return $this->hasOne('App\User');
}

이건 사실 이 문서에서는 전체적으로 필요 없는 부분인데, 이걸 해두면 나중에 이런 게 가능하다는 걸 보여주고 싶어서 가져왔다.

// 어떤 메모를 쓴 회원의 이름을 단 한 줄로 가져올 수 있다.
$memmoWriterName = $memmo->user()->name;

그렇다. 당신이 SELECT ... FROM ... LEFT JOIN ... ON ... HAVING ... 쿼리를 설계하고 한 땀 한 땀 서버에 때리고 있을 필요가 없다. 그냥 주어진 걸 사용하고, 일단 넘어가고, 필요할 때 쓰면 된다. Rapid Development.

4. 라우팅 설정

routes/web.php를 수정하는데... 여기는 이론과 실제가 약간 달랐다.

4.1. 이론

Memmo라는 Resource를 읽고 쓰는 짓을 할 예정이므로 원래는 이거 하나면 충분하다.

Route::resource('memmo', 'MemmoController');

근데 이렇게 하면 yuptogun.com/memmo/어쩌구/저쩌구 형식의 라우팅을 이용하게 된다. 뭐 이걸로 괜찮다면 만사 오케이기도 하다. 근데 우리는 서브도메인을 쓰기로 했잖아?!

4.2. 실제

갖은 삽질을 해보았고, 서브도메인의 루트를 사용하려면 결국은 각 라우트를 일일이 잡아줘야 된다는 게 판명났다.

Route::domain('memmo.localhost')->name('memmo.')->group(function () {
    Route::get('/', 'MemmoController@index')            ->name('index');
    Route::post('/', 'MemmoController@store')           ->name('store');
    Route::get('/{memmo}', 'MemmoController@edit')      ->name('edit');
    Route::put('/{memmo}', 'MemmoController@update')    ->name('update');
    Route::delete('/{memmo}', 'MemmoController@destroy')->name('destroy');
});

이 부분은 좀 모양 빠지긴 하는데... 일단 매뉴얼이 설명하고 있는 Resourceful 규약대로 구현한 것이긴 하니까 대충 그렇다 치고 넘어가자. 어차피 resource() 메소드가 세팅해주는 게 저거다.

아직은 갈 길이 멀다. 컨트롤러로 넘어가자.

5. 컨트롤러 액션 작성

5.1. 클래스 설정

use App\User;
use App\Memmo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

요청객체, 인증 파사드, 필요한 모델을 use해준다.

public function __construct()
{
    $this->middleware('auth');
}

모든 메소드에 auth 인증 미들웨어를 먹인다. 여기서부터는 아무데서나 Auth::id()를 쓸 수 있고 그건 지금 로그인한 사람의 id값이다. 로그인을 안 했을 때는 id가 없지 않느냐고? 그럴 일은 없다. 로그인을 안 했을 때는 덮어놓고 로그인 폼으로 회송될 예정이니까. (routes/web.php의 최상단에 Auth::routes();가 있는지만 확인해 주자.)

5.2. index() 첫 화면 그리는 메소드

public function index()
{
    $memmos = Memmo::where('user', Auth::id())
                   ->orderBy('updated_at', 'desc')
                   ->get();
    return view('memmo.index', ['memmos' => $memmos]);
}

우리의 계획은 입력폼 + 그간쓴 모든 메모 표출이다. 그러므로 지금 로그인한 사람의 id를 기준으로 모든 메모를 최근 수정 순으로 싹다 가져와서 뷰에 넘기면... 컨트롤러의 일은 끝난다.

5.3. store() 새 메모 입력 처리하는 메소드

public function store(Request $request)
{
    $memmo = new Memmo;
    $memmo->user = Auth::id();
    $memmo->fill($request->input());
    $memmo->save();
    return redirect()->route('memmo.index');
}

우리의 계획은 index()가 그린 화면 안의 폼이 POST 요청을 날려 이 메소드가 콜될 때, 그 요청 입력값들을 DB에 그대로 쓰는 것이다.

  • $memmo는 새 Memmo 모델이다. 아직 아무 필드에 아무 값도 없고 어느 테이블에 어떤 규칙으로 어떻게 값을 넣을 수 있는지만 정해져 있는 클래스 객체다.
  • 요청 입력값은 $request->input() 찍으면 배열로 얻을 수 있다.
  • 상기 3.2. 에서 memouser$fillable하다.
  • 그러므로 $memmo->fill($request->input())을 하면, 요청 입력값을 그대로 이 모델에 채워넣을 수 있다.
    • 사실 $request->input() 안에는 _token 같은 것도 있지만, 이건 $fillable하지 않으므로 무시된다.
  • $memmo->save()를 하면, 지금까지 채워넣어진 모든 값들을 현 상황 그대로 DB에 "저장"한다.
    • "저장"이란 말하자면 updateOrCreate()인데, 여기서는 $memmo = new Memmo;이므로 create() 분기를 타게 된다.

이거 다 하고 나서 시치미 뚝떼고 리디렉션하면 컨트롤러의 일은 끝.

5.4. edit() 특정 메모 수정/삭제 폼 출력 메소드

public function edit(Memmo $memmo)
{
    return view('memmo.edit', [
        'memmo' => $memmo
    ]);
}

메소드 인자 Memmo $memmo를 주목할 것. 분명 라우팅에서는 이 액션이 Route::get('/{memmo}') 형식의 라우트에 바인딩되어 있고 저기서의 {memmo}는 아무리 생각해 봐도 그냥 단순 문자열일 것이다. 그런데 어떻게 컨트롤러는 아무것도 검증하지 않고 저걸 그대로 Memmo 모델로 타입 힌팅하여 넘길 수 있는 것일까?

그렇게 하면, 라라벨이 암시적으로 모델에 바인딩을 해주기 때문이다. 이 상황에서 예컨대 인간들이 memmo.yuptogun.com/3에 접속하면, 라라벨은 요령껏 Memmo::find(3)을 수행하고, 뭔가가 나오면 그걸 그대로 $memmo에 할당한 다음에, 짐짓 모른 체 edit($memmo)를 실행해 주는 것이다. 라라벨은 알고 있다. 당신이 주로 구현하는 것이 무엇인지, 그걸 가장 빠르고 일반적으로 해내는 방법은 무엇인지.

5.5. update() 수정 입력폼이 제출되면 그걸 처리하는 메소드

public function update(Request $request, Memmo $memmo)
{
    $memmo->fill($request->input());
    if ($memmo->save()) {
        return redirect()->route('memmo.index');
    }
}

상기 5.3. 과 본질적으로 똑같고 딱 하나가 다른데, $memmo 모델이 새 모델이 아니라는 점이다. 여기서의 $memmo는 상기 5.4. 에서 언급한 암시적 모델 바인딩에 의해 Memmo::find($memmo)로 찾아진 기존 레코드의 모델이다.

5.6. destroy() 특정 메모 삭제 처리 메소드

public function destroy(Memmo $memmo)
{
    if ($memmo->delete()) {
        return redirect()->route('memmo.index');
    }
}

이쯤되면 정말 농담처럼 코딩한다는 생각이 들 것이다. 앗! 리소스풀 컨트롤러 개발이 끝났네요!!

6. 프론트엔드 자산 형성

프론트엔드 스캐폴딩을 몰랐을 때는 온갖 삽질을 해가면서 "이렇게 하는 게 아닐텐데..." 찝찝한 기분을 느껴야 했다. 막상 깨우치고 나니 세상 이렇게 쉬운 PHP 프론트 개발 환경 세팅이 없다.

6.1. 파일 만들고 webpack.mix.js 작성

일단 다음 두 파일을 만들어둔다. 빈 깡통이어도 상관없음.

  • resources/assets/js/memmo/app.js
  • resources/assets/sass/memmo/app.scss

그 다음 webpack.mix.js 파일에 추가를 해준다.

mix.disableNotifications()
    .autoload({
        "jquery": ['$', 'window.jQuery'],
        "popper.js": ['Popper', 'window.Popper', 'popper', 'window.popper']
    })
    .js('resources/assets/js/memmo/app.js', 'public/memmo.js')
    .sass('resources/assets/sass/memmo/app.scss', 'public/memmo.css');
  • .disableNotifications() 이걸 빼면 나중에 npm run watch를 돌리고 소스 수정을 할 때마다 정신 없는 데스크톱 알림이 뜬다.
  • .autoload() 그야말로 오토로딩.
    • 부트스트랩의 CSS/JS는 왜 오토로딩 안하냐고? 나중에 각각 app.scssapp.js에서 불러올 것이다.
  • .js().sass() 각각 웹팩 처리해서 2번째 인자로 명시된 경로에 떨군다. 2번째 인자의 경로에는 파일명 없이 경로만 적어줘도 되긴 한다.

6.2. 부트스트랩 추가

require('bootstrap')

  • 이건 resources/assets/js/memmo/app.js 파일 맨 첫줄에 들어간다.

@import "~bootstrap/scss/bootstrap";

  • 이건 resources/assets/sass/memmo/app.scss 파일 맨 첫줄에 들어간다.
  • 혹시나 부트스트랩 기본 SASS 변수들을 오버라이딩하고 싶다면 이 줄 위에 변수값들을 적어줘야 한다.

6.3. 개발모드 시작

npm run watch

이걸 실행한 CMD 창이 열려 있는 한, app.jsapp.scss의 수정사항은 각각 즉시 public/memmo.jspublic/memmo.css에 반영된다.

7. 뷰 작성

블레이드 문법에 대해서는 일일이 설명하지 않기로 하고 주요 아이디어 위주로만 짚고 넘어간다.

이 장에서 모든 파일명은 resources/memmo/가 붙은 것이라고 보면 됨.

7.1. _.blade.php 레이아웃

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>@yield('title')Memmo</title>
    <link rel="stylesheet" type="text/css" href="{{ mix('memmo.css') }}">
</head>
<body>
    <div id="memmo" class="memmo container">
        @yield('form')
        @yield('list')
    </div>
    <script src="{{ mix('memmo.js') }}"></script>
</body>
</html>

상기 6.3. 에 의해 public/memmo.csspublic/memmo.js가 실시간 업뎃되는 중이므로 사실은 {{ url('memmo.css') }} 형식으로 가져와도 되긴 된다. 하지만 {{ mix('memmo.css') }}를 쓰면, 적당히 버전 파라미터를 붙여주므로, 브라우저 캐시 문제 때문에 쓸데없는 시간을 허비할 필요가 없다.

7.2. index.blade.php 첫화면 목록+입력폼 뷰

@extends('memmo._')

@section('form')
@include('memmo._form')
@endsection

@section('list')
@include('memmo._list')
@endsection

여기서는 별 특이사항은 없고 이제 _form.blade.php_list.blade.php가 필요해진다는 것 정도만 파악하시면 되겠다.

7.3. _form.blade.php 메모 입력폼

@php
$isEdit = Route::currentRouteName() == 'memmo.edit';
@endphp

<div class="row">
    <div class="col p-4" id="form">
        {!! Form::open([
            'route' => $isEdit ?
                ['memmo.update', 'memmo' => $memmo->getKey()] :
                'memmo.store'
        ]) !!}
        @if ($isEdit)
        {{ method_field('PUT') }}
        @endif
        <div class="form-group">
            {!! Form::textarea('memo', ($isEdit ? $memmo->memo : null), [
                'class' => 'form-control',
                'placeholder' => '퀵메모 헛소리없음',
                'rows' => 6,
            ]) !!}
        </div>
        <button type="submit" class="btn btn-primary btn-lg btn-block" disabled="disabled">
            MEMMO
        </button>
        {!! Form::close() !!}
    </div>
</div>

상기 1.1. 에서 설치한 laravelcollective/html 패키지가 Form::textarea() 같은 것을 지원하면서 빛을 발하는 대목이다. 특히 Form::open(['route' => 어쩌구]) 메소드는 이 폼태그의 action 속성에 넣을 URL을 라우트에서 찾아서 설정해 준다.

{{ method_field('PUT') }}은 웹브라우저 요청으로 HTTP PUT을 보내기 위해 히든 인풋을 추가하는 블레이드 문법이다. 근데 이건 라라벨 5.5까지만 사용되고, 5.6부터는 @method('PUT') 문법을 사용해야 한다.

$isEdit 변수는... 하나의 입력폼을 신규입력과 수정입력 양쪽에 다 써먹을 목적으로 임의로 만들어본 변수.

7.4. _list.blade.php

<div class="row">
    <div class="col" id="list">
        <ul class="list-group list-group-flush">
            @each('memmo.memo', $memmos, 'memmo', 'memmo.nothing')
        </ul>
    </div>
</div>

리스트 뷰란 결국 DB 조회 결과를 뿌리는 것인데 라라벨에서 DB 조회 결과는 하나의 컬렉션이다. 그리고 라라벨 블레이드 템플릿은 이 컬렉션을 특정 템플릿 파일들로 순회하는 @each 메소드를 만들어 놨다. 여기서는 결과가 있다면 memo.blade.php를 이용해서, 없다면 nothing.blade.php를 이용해서 결과를 그린다.

그렇다. 힘들게 if (isset(어쩌구) && !empty(어쩌구)) { ... } else { ... } 할 것 없이 있는 거 갖다 쓰면 된다.

7.5. memo.blade.php & nothing.blade.php 메모 목록 내 조각뷰

<li class="list-group-item">
    <small class="text-muted float-right">
        <a href="{{ route('memmo.edit', ['memmo' => $memmo->getKey()]) }}">edit</a>
    </small>
    {{ $memmo->memo }}
</li>
<li class="list-group-item">
    <p class="display-4 text-center">Y U NO MEMO</p>
</li>

헉 둘중 어느게 nothing.blade.php인지는 까먹었다.

7.6. edit.blade.php 수정화면

@extends('memmo._')

@section('title', 'Edit Memmo #'.$memmo->id.' - ')

@section('form')
@include('memmo._form')
@endsection

@section('list')
@include('memmo._form_toolbar')
@endsection

목록을 보여줄 필요는 없으므로 대신 그 자리에 '뒤로가기', '이 메모 삭제' 같은 버튼을 넣으면 좋을 것 같아서 _form_toolbar.blade.php를 새로 도입하기로 했다.

7.7. _form_toolbar.blade.php 수정화면 하단 툴바

<div class="row">
    <div class="col pl-4 text-left">
        <small>
            <a href="{{ route('memmo.index') }}">back</a>
        </small>
    </div>
    <div class="col pr-4 text-right">
        <form id="memmo-delete" action="{{ route('memmo.destroy', [
            'memmo' => $memmo->id
        ]) }}" method="POST" class="d-none">
            {{ method_field('DELETE') }}
            {{ csrf_field() }}
        </form>
        <small>
            <a class="memmo-delete text-danger" href="#">delete</a>
        </small>
    </div>
</div>

뭔가 이상하다는 느낌을 받았는가? memmo.destroy 라우트로 연결되는 폼은 있는데, 이 폼을 제출할 방법이 없다. 이 부분은 라라벨 기본 인증 뷰의 로그아웃 폼에서 영감을 받아서, JS로 .submit()을 호출할 생각이다.

7.8. 자바스크립트 기능 구현

resources/js/memmo/app.js에 다음 로직을 추가한다.

$('#memmo .memmo-delete').on('click', function() {
    var confirmed = confirm('Really?')
    if (confirmed) {
        $('#memmo-delete').submit()
    }
})

웹개발을 해 봤다면 이게 뭐 하는 코드인지는 하룻강아지라도 알 것이고... 원래 라라벨 기본인증 뷰에서는 로그아웃 구현을 PHP 소스 안의 인라인 자바스크립트로 했는데 그건 좋은 방법이 아니고 우리는 지금 Mix를 쓰고 있으니까 분리를 확실히 하자는 생각에서 별도 JS에서 작업했다.

아맞다 "열자마자 메모 쓰기 시작" 구현해야지? 근데 사실 그건 별일이 아니다.

$('document').ready(function() {
    $('#memmo textarea').focus()
})

😂

8. 실행

npm run dev && php artisan serve

이제 http://memmo.localhost에 접속하면 온전한 웹앱이 돌아간다. 당장 로그인하라고 할 것이고, 유효한 계정으로 로그인하면 입력칸과 "Y U NO MEMO" 메시지가 반긴다. 아무거나 메모를 입력하면 그 메모들이 입력칸 밑에 순서대로 쌓이고, 'edit'를 눌러 수정을 하거나 수정 화면 하단의 'delete'를 눌러 삭제도 가능하다.

아주 기본적인 TodoMVC 구현인데, 라라벨을 6개월 정도 했다면 이틀 안에, 1년 이상 했다면 하루 안에 전체 과정을 다 따라올 수 있다.

결론

라라벨이 빠른 개발을 도와준다고 하지만 사실 우리가 과연 그 진가를 얼마나 맛보고 있나 하는 생각이 든다. 라라벨은 굉장히 큰 공구 상자 같은 것이어서 인증 스캐폴딩, Mix, make:model -mcr, Route::resoure(), @each 등 별별 도구가 다 들어 있고 분명 언젠가 한 번은 쓰는 것이어서 그런 것들을 포함하고 있는데, 사람들은 그냥 일자 드라이버랑 청테이프만 꺼내서 모든 걸 그것만 갖고 꾸역꾸역 해내고 있다는 느낌이랄까.

아무튼 토이 프로젝트치고는 꽤 보람차게 그간 배운 것을 정리한 시간이었고 앞으로 한창 더 개선할 점이 남아 있다. 3월에 이직해서 정착하기 전까지 한동안은 이게 주력(?) 프로젝트가 될듯.