[카테고리:] 워드프레스 개발

  • 액션/필터 레퍼런스

    코어 동작 어딘가에 적절히 콜백을 덧대 자신이 원하는 동작을 만들어내는 것, 이것이 플러그인 개발의 핵심이 아닐까 한다. 그러려면 코어가 어떤 흐름으로 동작하는지, 어떤 액션과 필터를 제공하는지 알아두는 것은 자명하다.

    그런 흐름을 잘 정리해 둔 문서가 바로 이 액션 레퍼런스필터 레퍼런스이다. 모든 필터와 액션을 담은 것은 아니겠지만, 리퀘스트를 받아 리스폰스를 내기까지의 순서를 따라 잘 나열되어 있다. 가장 흔히 사용되는 대표적인 필터와 액션 흐름을 살펴볼 수 있는 좋은 문서이다.

    프론트 기준으로 코어는 아주 대략적으로 이런 흐름으로 진행된다. 이 흐름을 생각하면서 훅을 훑어보면 코어를 이해하는데 많은 도움이 될 것이다.

    • 설정 파일 로드, 필수 파일 로드, 데이터베이스 설정.
    • MU 플러그인 로드.
    • 플러그인 로드.
    • 테마 로드.
    • 로그인된 사용자 설정.
    • 프론트의 주소에 따라 메인 쿼리 준비.
    • HTTP 헤더 전송.
    • 메인 쿼리 – 데이터베이스 쿼리.
    • 메인 쿼리의 종류에 따라 어떤 프론트 템플릿을 불러올지 결정.
    • 템플릿 인클루드.
      • 템플릿 헤더 출력.
      • 템플릿 본문과 설정에 따라 메뉴, 사이드바 출력.
      • 템플릿 푸터 출력.
    • 어드민 바 출력.
    • 종료.

    이 대략적인 흐름이 중요한 이유가 있다. 이 흐름을 모르면 도대체 어떤 때 어떤 동작을 넣어야 하는지 이해하기 어려울 뿐만 아니라 원하는 동작을 제때 코어에 전달하지 못할 수도 있기 때문이다. add_action을 넣는 시점은 do_action이 일어나기 이전에 이뤄져야 한다. 예를 들어 푸터 출력 진행중에 ‘plugins_loaded’ 액션을 넣어도 절대 동작하지 않는다. 왜냐면 ‘plugins_loaded’ 액션은 그 이전에 끝났기 때문이다. 이런 워드프레스 동작 라이프사이클은 알아둘수록 도움이 된다.

  • dashicons

    워드프레스 대시보드(또는 어디서든)에서 사용되는 아이콘 모음이다. 웹사이트에서 간편히 전체 목록을 확인할 수 있다.

    개발시 관리자 화면의 메뉴 아이콘을 꾸밀 때 특히 유용하다. 기본 아이콘인 톱니바퀴나 핀 말고 다양한 아이콘으로 만들어 보자.

    목록에서 원하는 아이콘을 클릭하면 아이콘 이름, HTML 코드나 CSS 스타일이 적절히 출력된다. 커스텀 포스트나 메뉴를 삽입할 때는 아이콘 이름을 쓰고, 나머지 경우에서는 적당히 HTML 태그나 CSS 스타일 요소 등을 사용하면 된다.

    예를 들어, 아래 커피 아이콘의 이름은 ‘dashicon-coffee’이다.

    이 아이콘을 커스텀 포스트의 메뉴 아이콘으로 쓰려면,

    <?php
    register_post_type(
        'foo_type',
        array(
            ....
            'menu_icon' => 'dashicons-coffee', // 이렇게!
        )
    );Code language: HTML, XML (xml)

    커스텀 페이지의 메뉴 아이콘으로 쓴다면,

    <?php
    add_menu_page(
        'Page Title',
        'Menu Title',
        'manage_options',
        'callback_function',
        'dashicons-coffee' // 이렇게!
    );Code language: HTML, XML (xml)

    이렇게 사용 가능하다.

  • wp_enqueue_script

    wp_enqueue_script() 함수는 직접적으로 HTML 코드에 스크립트를 조율해야 하는 과정을 없애준다. 스크립트간 의존성도 해결해주고, 캐시 문제도 해결해 준다. “JavaScript와 CSS를 삽입하는 과정을 명확히 알아두자.” 같은 긴 포스트를 작성하고는 싶지만, 글이 너무 길어지니 생략한다.

    그보다는 문서 중 여러 JS 라이브러리의 핸들을 문서화한 부분을 오늘 포스트의 키 포인트로 전달하고 싶다. 워드프레스가 사용한 여러 유용한 라이브러리를 재활용하는 측면에서 꽤 쓸만하다. 한번쯤 꼭 읽어보고 개발할 때 활용하도록 하자.

  • wp-config.php 설정 숙지하기

    wp-config.php 수정하기 문서.

    여기 내용들은 완전히까지는 아니지만 그래도 매우 잘 알아두어야 할 사항들이다. 어정쩡하게 알아두지 말고 한 번쯤 꼭 숙독해 두어야 한다.

    (더 보기…)
  • 플러그인 핸드북

    자 간단하게 시작하자, 그리고 꾸준하게 가 보자. 그러려면 하찮게 가자.

    (더 보기…)
  • front-page.php를 쓰지 말아야 할 이유

    얼마전 워드프레스 기반의 사이트 하나를 제작하였다. 그러나 워드프레스의 다양한 콘텐츠 기능을 활용하기 보다는 포스트의 아카이브/싱글에만 치우쳤고, 기존 테마들로는 구현하기 어려운 요소들이 너무 많아 아예 밑바닥부터 테마를 만들기로 결정했다. 이런 사이트들은 기존 테마를 억지로 비틀어 맞추는 것보다 처음부터 테마를 만드는 것이 훨씬 만들기되 쉽고 결과물도 좋다.

    보통 대문 페이지, 워드프레스에서는 프론트 페이지라고 부르는데, 방문자들이 보통 주소만 치고 들어올 때 보게 되는 대표 페이지이자 사이트의 얼굴이기 때문에 가장 신경을 많이 쓰는 페이지라고 할 수 있겠다. 이 페이지를 어떻게 만들까 생각했었는데, 별 신경 안 쓰고 테마의 front-page.php를 활용해 제작하기로 결정했다.

    그런데 나중에 만들고 보니 그것은 썩 좋지 않은 결정이었던 것 같다. 이 포스트에서는 그것이 왜 실수인지, 왜 쓰지 말아야 하는지에 대해 적고자 한다. 이렇게 구구절절히 적었으니 테마 제작시 같은 실수를 반복하지 않기를 바란다.

    (더 보기…)
  • 제발 맨 아래 텀만 찍어라

    ‘개발 레시피’ 텀도 찍을까 말까?

    카테고리 찍을 때 고민될 것이다. 맨 아래 텀만 찍을 것인가, 아래 텀과 같이 상위 텀도 찍어야 할 것인가? 상위 텀을 안 찍으면 상위 텀에 포함 안되는 것 아닐까 고민한 적 있으신지?

    보통 카테고리 같은 위계적인 택소노미에 대해 쿼리할 때 텀은 기본적으로 그 부모 위치의 텀은 기본적으로 모두 고려하여 작성이 되고, 하위 텀도 마찬가지다. 하위 텀은 프로그래머의 필요에 따라 포함시키지 않는 경우도 있지만 기본 동작은 포함되는 것이다.

    그러니까 맨 마지막 텀만 찍어라. 나중에 카테고리가 확장되어서 중간에 찍힐 수는 있겠지만, 현재 가장 말단에 위치한 텀만 찍으면 알아서 그 상위 텀에 포함된다. 괜히 중간 텀까지 찍어 놓으면 불필요한 정보만 더 쌓일 뿐이다.

  • 같은 옵션에 경쟁을 붙이면 어떻게 될까?

    워드프레스에서 옵션 테이블은 매우 자주 이용하는 테이블입니다. 여기에 사이트 전체에 빈번하게 사용되는 값들이 저장되어 있습니다. 코어, 테마, 플러그인 모두 상당히 이 옵션 테이블에 의존하고 있습니다. 때로는 너무 옵션 테이블에 의존해서 상당히 퍼포먼스가 떨어질 때도 있긴 하지요. 이 포스트에서는 그 퍼포먼스의 문제는 논외로 하고, 이 옵션 테이블의 업데이트를 할 때 알아두면 좋을 내용을 적어 보도록 하겠습니다.

    (더 보기…)

    페이지: 1 2 3 4 5

  • 아파치 워드프레스 관리자 IP 접속 제한

    주로 보안 제약이 강한 대기업 블로그에서 주로 요구하는 사항인데, 관리자나 로그인 페이지의 접근을 IP 대역으로 막는다.

    주의할 점은 관리자 페이지를 막아도 admin-ajax.php, admin-post.php 의 접근은 열어 두어야 한다는 거다. 그것까지 다 막으면 워드프레스의 일반적인 폼 제출과 AJAX 요청까지 다 틀어막혀 사이트 동작이 되지 않을 수 있다.

    아래 코드를 참고하여 아파치 설정에 추가한다. LocationMatch 지시자는 .htaccess 에 넣을 수 없다는 것에 유의한다. 그리고 .htacess 파일에 넣지 못하고 일반 설정 파일에 삽입하니, 수정 후 서버 재시작을 꼭 해주는 것을 잊지 말자.

    <LocationMatch "wp-admin/(?!admin-ajax|admin-post).*\.php$|wp-signup\.php$|wp-login\.php$">
    Require all denied
    Require ip 127.0.0.1
    </LocationMatch>
    # vim: syntax=apache ts=4 sw=4 sts=4 sr noet
  • gulpfile.js

    스크립트 minification 을 위해 자주 사용하는 gulpfile.js 템플릿 코드

    const gulp = require('gulp')
        , uglify = require('gulp-uglify')
        , uglifycss = require('gulp-uglifycss')
        , rename = require('gulp-rename')
        , sourcemaps = require('gulp-sourcemaps')
        , clean = require('gulp-clean')
    ;
    
    gulp.task('min-js', function () {
        return gulp
            .src([
                'assets/js/**/*.js',
                '!assets/js/**/*.min.js',
                '!assets/js/vendor/*.js',
            ])
            .pipe(sourcemaps.init())
            .pipe(uglify())
            .pipe(rename(function (path) {
                path.extname = '.min.js';
            }))
            .pipe(sourcemaps.write('.'))
            .pipe(gulp.dest('assets/js'));
    });
    
    gulp.task('min-css', function () {
        return gulp
            .src([
                'assets/css/**/*.css',
                '!assets/css/**/*.min.css',
                '!assets/css/vendor/*.css',
            ])
            .pipe(uglifycss())
            .pipe(rename(function (path) {
                path.extname = '.min.css';
            }))
            .pipe(gulp.dest('assets/css'));
    });
    
    gulp.task('clean', function () {
        return gulp
            .src([
                'assets/**/*.min.{js,css}',
                '!assets/css/vendor/*.css',
                '!assets/js/vendor/*.js',
            ], {read: false})
            .pipe(clean())
    });
    
    
    gulp.task('watch', function () {
        gulp.watch([
            'assets/js/**/*.js',
            '!assets/js/**/*.min.js',
            '!assets/js/vendor/*.js',
        ], gulp.parallel(['min-js']));
    
        gulp.watch([
            'assets/css/**/*.css',
            '!assets/css/**/*.min.css',
            '!assets/css/vendor/*.css',
        ], gulp.parallel(['min-css']));
    });
    
    gulp.task('default', gulp.series('clean', 'min-js', 'min-css', 'watch'));
    gulp.task('build', gulp.series('clean', 'min-js', 'min-css'));
    
  • 자동 업데이트 후 이메일 생략하기

    요즘 자동 업데이트는 잘 되는 편인데, 굳이 의미없는 이메일을 받는 건 좀 부담스럽다. 이메일 끄는 mu 플러그인을 아래처럼 만들어 붙일 수 있겠다.

    <?php
    /**
     * Plugin Name: Disable Auto-Update Email
     */
    
    add_filter( 'auto_plugin_update_send_email', '__return_false' );
    add_filter( 'auto_theme_update_send_email', '__return_false' );
    

  • ESNext #07: Redux Hello, World

    Redux는 어플리케이션의 상태 관리를 위한 라이브러리이다. 하도 이 리덕스를 리액트랑 같이 엮어 들었던 탓에 리액트 라이브러리인줄 알았으나, 그렇지는 않더라. 그냥 독립적으로도 사용 가능하다.

    얼마 전에 상태 변화가 빡센 웹 앱을 작성한 프로젝트가 있었는데 이런저런 문제로 인해 jQuery 스타일로 매우 빡빡하게 만들었다. 어떻게는 완료하였으나 Redux를 조금 더 일찍 알았더라면 보다 깔끔하고 완성도 높은 결과물이 나왔을걸 하고 아쉬움이 들었다.

    이번에도 리포지터리에 결과물을 업데이트한다. 물론 Redux는 React를 위한 여러 세련된 방법이 있기는 한데, 나는 그냥 hello, world! 같은 것을 하고 싶을 뿐. 핵심적인 기능만 이용해 단순한 장난감을 하나 만들어 보았다.

    워드프레스에서 작성했으므로 결과물을 보려면 숏코드 wes07을 사용해야 한다. 프로그램은 PHP 스크립트로부터 시작값을 받아 출력한다.

    이 출력값은 +/- 버튼을 눌러 증가, 감소시킬 수 있다. 단순하다. 이 정도면 React.Component 안에서 this.state 객체 안에서 상태 관리를 할 수 있을 정도로 단순하지만, 이번에는 Redux에게 양보해 보자.

    초간단 reducer이다.

    function reducer(state = {value: 0}, action) {
        switch (action.type) {
            case 'wes07/increase':
                return {value: state.value + 1};
    
            case 'wes07/decrease':
                return {value: state.value - 1};
    
            case 'wes07/set':
                return {value: action.value};
    
            default:
                return state;
        }
    }

    그리고 이 reducer로 store를 생성한다.

    import {createStore} from 'redux'
    
    //...
    
    const store = createStore(reducer)

    그리고 component가 mount 완료되었을 때 초기값을 설정하고, 또 store로부터 값을 받아 내부 state에 적용한다.

    class ReduxHelloWorld extends React.Component {
        constructor(props) {
            super(props);
    
            this.state = {
                value: 0
            }
        }
    
        componentDidMount() {
            store.subscribe(() => this.setState({value: store.getState().value}))
            store.dispatch({
                type: 'wes07/set',
                value: this.props.initialValue
            })
        }
    
    // ...
    
    }
  • wp_localize_script()로 숫자를 전달할 때

    아래 예제 코드와 결과를 보면 명확하다.

    wp_localize_script(
    	'foo',
    	'bar',
    	[
    		'initial' => [
    			'value' => 0,
    			'true'  => true,
    			'false' => false,
    			'null'  => null,
    		],
    		'value'   => 11,
    		'true'    => true,
    		'false'   => false,
    		'null'    => null,
    	]
    );
    
    // console.log( 'bar', bar ); in js.
    console.log( ‘bar’, bar );

    wp_localize_script() 로 전달한 연관 배열의 최상위 키의 값들은 모두 문자열로 변경된다. 때때로 PHP 측에서 자바스크립트 쪽으로 값을 전달할 때 타입이 달라 의도치 않은 버그를 낼 때가 있다.

    이것은 wp_localizae_script() 함수의 알짜인 WP_Scripts::localize() 함수 구현에서 이유를 찾을 수 있다.

    ...
    foreach ( (array) $l10n as $key => $value ) {
    	if ( ! is_scalar( $value ) ) {
    		continue;
    	}
    
    	$l10n[ $key ] = html_entity_decode( (string) $value, ENT_QUOTES, 'UTF-8' );
    }
    ...
    

    함수 내부에서 배열 내부의 키/값을 순회하면서 값을 문자열로 캐스팅한다. 단, 스칼라가 아닌 경우 무시하고 넘어간다.

    전달된 값을 숫자나 불리언으로 다시 변경하려면 스크립트 초기에 parseInt()parseFloat()을 호출하면 되긴 한다. 불리언은 parseInt()! 연산자를 두 번 사용하면 편리하다.

    하지만 일일이 파라미터마다 일일이 이런 짓을 하는 것은 매우 번거롭다. 가급적 문자열로 변경되는 것을 피해야 할 값들은 최상위 키/값에 값을 두지 말고, 위 예제처럼 배열을 중첩하여 중첩된 배열 안쪽에 두도록 하자.

    출처: wp_localize_script unexpectedly converts numbers to strings

  • 우커머스 장바구니 추가 로직에 대해 노트

    우커머스 장바구니 추가 로직에 대해 노트

    우커머스 상거래 중 기본이 되는 작업 중 하나는 장바구니에 물건을 담는 과정일 것이다. 관련 플러그인 개발을 하면서 자꾸 이 부분을 반복하게 된다. 하여 이 과정을 아주 간단하게 노트한다.

    우커머스에는 기본적으로 네 가지 상품 타입이 존재한다.

    • 기본 상품 (simple)
    • 옵션 상품 (variable)
    • 그룹 상품 (grouped)
    • 외부 상품 (external)

    이중 외부 상품은 사이트 외부의 상품을 단순 링크할 뿐 장바구니를 이용하지 않으므로 노트할 대상에서 제외된다. 그러면 나머지 상품들이 싱글 페이지 폼에서 어떻게 처리되는지 노트한다. 상점의 테마는 storefront를 사용했다.

    폼 핸들러

    우선 폼 핸들러에 대해 노트. 상품을 장바구니에 담는 행위는 상품 싱글 페이지에서 사용자가 선택한 상품과 그 갯수를 폼을 통해 전송하는 것으로 구현한다. 이 때 폼을 전송하는 서버의 URL은 해당 페이지와 동일하다.

    한편 우커머스는 ‘wp_loaded‘ 액션의 콜백으로 WC_Form_Handler::add_to_cart_action() 을 등록한다. 우커머스 플러그인 디렉토리 includes/class-wc-form-handlers.php에 위치해 있으며, 여기서 각 상품의 타입에 따라 각각 다른 세부 폼 핸들러에 대응하도록 구현되어 있다.

    폼에서 상품 ID를 인지하려면 반드시 폼에 ‘add-to-cart’ 변수가 설정되어 있어야 한다. 그래야 핸들러가 제대로 동작한다.

    기본상품

    가장 간단한 상품 구조이다. 단일 상품에 대해 갯수만 선택하면 장바구니에 담긴다. 이것의 폼 구조는,

    add-to-cart정수. 상품의 ID.
    quantity정수. 상품의 개수.

    옵션상품

    대체적으로 같은 상품이지만, 상품의 여러 속성에 따라 각자 조금씩 하위 분류로 구분되는 상품들이다. 옷들이 옵션 상품이 될 수 있는 대표적인 상품이다. 한 상품의 색상, 사이즈에 따라 구분되는 것.

    다른 상품으로 상점 관리에서 미리 상품에 대한 여러 속성을 정의하면, 그 속성의 조합에 따라 미리 하위 상품을 만들어 둔다. 예를 들어 옷의 색상이 흰색, 검정, 2가지가 있고 옷의 사이즈는 소, 중, 대로 3가지가 가능하다고 하자. 그러면 옵션 상품의 하위 상품(variation)으로 총 2×3, 6개가 생성된다. 이 하위 상품은 각자 가격, SKU, 재고, 무게나 크기 같은 상품 정보를 다르게 취할 수도 있다.

    attribute-{$attribute}문자열. 사용자가 선택한 속성값이다.
    $attribute는 속성값을 의미한다. 위 그림에서 ‘색상’에 해당한다. 한글의 경우 URL Encode 된다. 예를 들어 ‘색상’은 ‘%ec%83%89%ec%83%81’로 인코드된다.
    quantity해당 상품의 수량
    add-to-cart옵션 상품의 ID와 동일하다. 핸들러는 여기서 기준이 되는 상품 ID를 캐치한다.
    product_id옵션 상품(variable)의 ID. 결국 add-to-cart 값과 동일하다.
    variation_id사용자가 선택한 속성에 해당하는 하위 집합의 상품 ID 중 하나. 폼 핸들러에서 사용하는 값은 이 쪽이다.

    우커머스는 현재 옵션 상품의 옵션을 설정해 한 번에 하나의 하위 상품만을 장바구니에 담는 것만이 가능하다. 다시 말해 옷을 구매할 때, 검정-소 사이즈를 설정한 다음 장바구니에 넣고, 다시 페이지로 돌아와 흰색-중 사이즈를 설정해 장바구니에 넣어야 한다. 검정-소와, 흰색-중 이 두 조합을 한 번에 선택한 다음 한 번에 장바구니에 넣을 수는 없다.

    그룹상품

    그룹 상품은 몇 개의 상품을 하나로 묶어 한 페이지에서 동시에 여러 상품을 구매할 수 있도록 한다. 연결된 상품이라고 해서 동시에 구매했을 때 할인이 주어지게 하는 옵션은 기본적으로는 없는 것 같다. 단지 몇 개의 상품을 한 페이지에서 한번에 장바구니로 넣게 해주는 것일 뿐.

    qty연관 배열. 키는 선택된 상품의 ID. 값은 해당 상품의 개수.
    add-to-cart그룹 상품의 ID.

    기타

    장바구니 추가 로직에서 딱히 강한 제약은 없다. 폼의 구조만 틀리지 않는다면, 폼의 정보는 싱글 페이지와 무관하다. 가령 48번 상품 싱글 페이지에서 값을 강제로 변경해 50번 상품으로 만들어 장바구니에 담아도, 50번 상품의 상태만 올바르다면 막히는 것이 없다.

    또한 폼의 내용은 마음대로 확장할 수 있고, 확장된 폼의 내용은 적절히 폼 핸들러의 동작을 커스텀하여 처리하면 된다.

  • Heartbeat 주기 설정하기

    Heartbeat Control 같은 플러그인이 있지만, 굳이 플러그인까지 쓸 필요 없다. 간단한 몇 줄의 코드로 주기 설정이 가능하다.

    우선, heartbeat가 어떻게 동작하는지 간단하게 설명한다. heartbeat는 특정 관리자 페이지 접속시 브라우저와 주기적으로 통신한다. 이 주기는 자바스크립트의 setTimeout() 함수로 구현되며, 기본값은 관리자 페이지에 포커스가 있을 때 15초, 해당 페이지에서 포커스를 잃었을 때 120초로 설정된다. 이 120초는 하드코딩되어 있어 값을 변경하기 어렵지만 포커스가 있을 때 15초는 비교적 손쉽게 변경 가능하다.

    우선 heartbeat을 완전히 없애는 방법이다. 그다지 추천하지는 않지만 개발환경에는 그럭저럭 고려해 볼만할 것이다.

    add_action( 'admin_init', function () {
    	if ( wp_script_is( 'heartbeat', 'registered' ) ) {
    		wp_deregister_script( 'heartbeat' );
    	}
    } );

    heartbeat을 변경하되, 주기를 설정하려면 다음처럼 하면 된다.

    add_action( 'admin_init', function () {
    	// 5, 10, 15, 30, 60, 120, 'long-polling'
    	$script = "jQuery(document).ready(function () {wp.heartbeat.interval(20)});";
    	wp_add_inline_script( 'post', $script );
    	wp_add_inline_script( 'inline-edit-post', $script );
    	add_action( 'customize_controls_enqueue_scripts', function () use ( $script ) {
    		wp_add_inline_script( 'heartbeat', $script );
    	} );
    }, 100 );
    
    add_filter( 'heartbeat_settings', function ( $settings ) {
    	$settings['interval'] = 20;
    	return $settings;
    } );

    wp.heartbeat.interval(20) 부분과 $settings['interval'] = 20; 부분의 20은 heartbeat의 주기를 20초로 설정하라는 부분이다. 적절히 변경해서 사용하면 될 것이다. 당연히 이게 불편하면 플러그인으로 대체해도 무방하다.

  • 멀티사이트의 멀티사이트, 멀티 네트워크

    멀티 사이트는 여러 워드프레스 사이트를 하나의 설치본으로 관리할 수 있게 한다. 이렇게 만들면 데이터베이스에는 추가적으로 테이블이 생기게 된다.

    이 중 wp_blogs, wp_site 가 눈에 뜨인다. 보통 멀티사이트를 만들면 wp_blogs 에는 멀티로 만든 사이트 목록이 기록된다. 그리고 wp_site는 주로 단일 레코드가 기록될 것이다.

    그런데 wp_site의 레코드를 통해 멀티사이트의 확장이 가능하다. 즉, 하나의 워드프레스 설치본으로 멀티사이트를 여러 개 만드는 것까지 가능하다는 소리다. 즉 이런 계층구도가 생성된다.

    • 멀티사이트 #1
      • 사이트 #1-1
      • 사이트 #1-2
    • 멀티사이트 #2
      • 사이트 #2-1
      • 사이트 #2-2

    이렇게 하려면 데이터베이스에 여러 레코드를 편집하면 되는데, 편집해야 할 양이 좀 많아 관리하기 까다롭다. wp_site, wp_blogs, {prefix}_{blog_id}_* 테이블을 괸리해야 하기 때문이다. 이 정도는 WP Multi Network 플러그인에게 맡기는 것이 좋을 것 같다. 멀티사이트 관련 API에 큰 변동이 없어 그런지 이 플러그인은 현재도 잘 동작한다.

    네트워크까지 관리할 수 있다.
    DB는 이렇다. 네트워크 사이트가 하나 더 생겼다.
    site_id가 3인 녀석이 등장했다.

  • ESNext #6: Hooks

    훅 기능은 워드프레스에서 구현한 기능이며 일반적인 자바스크립트가 아니지만, 카테고리를 간결하게 유지하고 싶어 이 곳에 작성한다.

    PHP에서 작성된 add_action, add_filter 같은 훅 제어가 JavaScript에서도 아주 유사하게 작성되어 있다! 자바스크립트가 비동기 방식이고, 이벤트 핸들링을 위해 콜백 함수를 쓰는 것이 매우 일상적이긴 하지만, 자바스크립트 고유의 콜백 방식을 쓰기는 조금 난감할 때가 있는데, 이 패키지를 사용하면 문제 없이 훅을 다룰 수 있다.

    리포지터리에 이 혹을 사용해 보는 코드를 작성해 봤다. 아주 간단하게 자바스크립트에서 액션과 필터가 동작하는 코드이다. 기대되는 동작은 기존에 사용하던 PHP 쪽 버전과 동일하게 동작하기 때문에 사용하는 법만 대충 테스트하면 충분할 듯하다.

    예제를 위해 슬러그가 ‘hooks-test’인 포스트를 생성한다. 스크립트는 이 포스트에서만 동작하도록 처리했다. 결과는 아래 그림과 같다.

    액션과 필터는 아래처럼 사용한다.

    addAction('wes06-test-action', 'changwoo/wes06-hooks/index', actionCallback01, 30);
    addFilter('wes06-test-filter', 'changwoo/wes06-hooks/index', filterCallback01);

    다 같은데, 두번째 인자가 네임스페이스라는 점이 다르다. 이 네임스페이스는 hasFilter(), hasAction() 체크시에 사용되는 것 외에는 크게 의미는 없다. 저걸 다르게 준다고 해서 doAction, applyFilters 함수 호출시 특별한 동작을 하게 되는 것은 아니다. 즉 네임스페이스가 다르되, 훅 이름이 같다 하더라도 각 훅이 구분되는 일 따위가 없을 것이다.

    아무튼 이 훅 관련 함수는 이미 코어 여러 부분에 적용되어 있다. 응용하면 자바스크립트에서 그동안 아쉬웠던 훅을 통한 기능 수정이 편안하게 될 수 있으리라 기대한다.

  • ESNext #5: 동적 렌더링과 사이드바에 커스텀 필드 편집

    지난 포스트에서는 블록의 속성값에 대해 간단하게 알아 보았다. 블록이 어떻게 자신의 값을 저장하고 표현하는지 알 수 있었다.

    블록은 HTML 주석 부분에 블록이 필요한 데이터를 JSON 형태로 저장하거나, 자신이 가지고 있는 HTML 마크업 구조에서 가져올 수 있고, 또 커스텀 필드로부터 가져올 수 있다. 단, 블록 자체는 HTML 코드 그대로 기록하기 때문에 이런 동적인 값들을 표현하려면 생짜 HTML 코드를 사용할 수 없을 것이다.

    마크업 구조나 주석에 표현된 JSON을 사용하려면 리액트의 콤포넌트를 활용하거나, 메타 값을 표현하려면 동적인 렌더링을 활용해야 할 것이다.

    이번에는 이런 것들을 표현할 수 있도록 해 보자. 이번에 만들 블록의 요구 조건은 다음과 같이 정리해 볼 수 있을 것이다.

    • 블록은 커스텀 필드의 값을 받아 동적 렌더링을 한다.
    • 블록은 마크업의 요소, 주석의 JSON 데이터를 받아 동적으로 값을 처리한다.
    • 블록의 속성값은 사이드바를 활용할 수 있도록 한다.

    완성된 코드는 Github 레포지터리에 올려두었다.

    예제 설명

    예제는 ‘Dynamic Render’라는 블록을 생성한다. 이 블록은 이전처럼 name과 anotherName 속성을 가지고 Hello, World! 처럼 문자를 출력하는 단순한 역할을 한다. 그러나 이 값은 보다 동적이다.

    name은 따로 텍스트 입력 상자로부터 가져오며, anotherName은 사이드바로부터 온다. 또 name은 주석에 있는 블록 속성에 저장하며 anotherName은 커스텀 필드 _another_name에 저장한다.

    커스텀 블록의 edit() 메소드 결과.
    사이드바에서 Another Name을 편집한다.
    프론트에 그린 것.

    사이드바나 커스텀 블록을 만드는 것은 기존 예제와 크게 다를 것이 없다. 그러나 사이드바에 넣은 값이 메타 필드와 연동되어야만 한다는 점이 이 예제의 키 포인트라고 할 수 있겠다. 그리고 이번에는 save() 메소드는 사실상 아무것도 하지 않는다. 프론트 렌더링을 PHP 측에서 담당하기 때문이다.

    렌더 콜백

    우선 프론트 쪽 렌더링을 살펴보자. PHP 코드에서 register_block_type() 호출시 ‘editor_script’ 뿐 아니라 ‘render_callback’ 또한 지정해 준다.

    register_block_type(
    	'wp-estnext-study/wes05-dynaic-render',
    	[
    		'editor_script'   => 'wes05-dynamic-render',  // 필수!
    		'render_callback' => 'wes05_render_callback', // 동적 렌더링.
    	]
    );

    render callback 함수를 실제 작성해 보니, 이 함수는 관리자 페이지에서 포스트 편집 화면에서도, REST API 호출 때에도, 프론트에서도 다 불리는 것을 확인했다. 단순히 프론트에서 호출할 때만을 상정하고 이 함수를 작성하면 프론트 이외 작업에서 에러가 날 수 있다.

    render_callback을 프론트에서만 호출한다고 생각하고 작성하면 포스트 수정시 다음처럼 오류가 발생한다. 출력 내용이 REST API 응답시 JSON 호출 전에 먼저 불려버려 클라이언트가 JSON 문서 파싱시 에러를 일으키게 한다.

    또한 관리자에서 포스트 편집 화면에 들어갈 때 잠깐의 화면 공백이 발생하는데, 그 때에도 이 출력이 나온다. 그러므로 렌더 콜백은 다음과 같은 if … else … 구문을 사용하여 구분하는 것이 좋을 것이다.

    if ( is_admin() ) {
    	// 관리자 편집 화면 진입시
    } elseif ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
    	// REST API 호출시
    } else {
    	// 프론트 호출은 여기서.
    }

    사이드바 컨트롤과 커스텀 필드 연동

    사이드바에서 TextControl 하나를 넣어 onClick 이벤트와 value 속성을 커스텀 필드의 값과 이어지도록 처리 한다. 그런데 커스텀 필드는 블록 속성이 아니고, 코어가 포스트 편집 화면을 위해 이미 불러와져 있는 상태이다. register_meta() 함수를 부를 때 우리는 ‘show_in_rest’ 항목에 true를 주었기 때문이다.

    그런데 이 커스텀 필드를 불러오거나 업데이트해야 할 때는 조금 복잡하다. 아무튼 현재 수준에서 가능한 설명을 기록한다. 우선 TextControl을 하나 감싸 ‘DynamicRender’라고 하자.

    let DynamicRender = ({anotherName, onChange}) => {
        return (
            <TextControl
                label="Another Name"
                value={anotherName}
                onChange={onChange}
            />
        );
    }

    이제 DynamicRender에 마법을 부려 보자. 메타 값을 불러와 컨트롤의 속성으로 넣어 주려면 아래 코드를 참고한다.

    // withSelect 로 DynamicRender 함수를 래핑.
    DynamicRender = withSelect(select => {
        const {getEditedPostAttribute} = select('core/editor');
        return {
            anotherName: getEditedPostAttribute('meta')['_another_name']
        };
    })(DynamicRender);

    한편 메타 값을 다시 업데이트하려면 아래 코드를 작성한다.

    // withDispatch 로 DynamicRender 함수를 다시 래핑.
    DynamicRender = withDispatch(dispatch => {
        const {editPost} = dispatch('core/editor');
        return {
            onChange(value) {
                editPost({meta: {_another_name: value}});
            }
        }
    })(DynamicRender);

    Redux와 워드프레스 자바스크립트 코어가 겹치는 부분이라 어렵고 이해가 잘 안가는 부분이 있다. 이해하려 해도 파야 할 부분이 많아 보여 현재는 코드 조각 수준밖에는 이해하지 못하는게 불만이다. 보다 이해를 높이려면 리액트나 리덕스를 보다 이해해야만 알 수 있을 것 같다. 보다 시간과 노력이 필요한 부분인듯.

  • ESNext #4: 블록 에디터 속성

    블록 에디터는 post_content 필드에 직접 기록된다. 그리고 기본적으로 블록의 내용 또한 이 필드 안에 본문으로서 기록된다.

    예를 들어, ESNext #2: 블록 제작에서 작성한 Hello, World! 블록은 다음처럼 텍스트로 기록된다.

    <!-- wp:wp-esnext-study/wes02-hello-world -->
    <p class="wp-block-wp-esnext-study-wes02-hello-world">WP ES Next #2 안녕, 세상!</p>
    <!-- /wp:wp-esnext-study/wes02-hello-world -->

    HTML 코드로 기록되는 것이 보인다. 그리고 주석으로 이것이 워드프레스 블록임을, 또 어떤 블록 타입인지를 기록하는 것이 보인다. wp:로 시작하고 /wp:로 닫는 것으로 보인다. ‘wp-esnext-study/wes02-hello-world‘는 내가 지정한 블록 타입 이름이다.

    블록은 워낙 단순해서 어떤 상태나 속성을 가지지 않지만, 이제 간단한 속성을 지정해 보도록 하자. 이것을 활용하면 블록 에디터에 값을 저장하고, 불러와 쓸 수 있게 된다.

    Github 리포지터리에 04-attributes 디렉토리로 예제 코드를 작성하였다. 아래부터는 이 예제 코드에 대한 설명을 덧붙인다.

    속성값에 대한 설명은 블록 에디터 문서에서 찾을 수 있다. 이 개념은 마치 리액트의 콤포넌트의 state와 약간 비슷한 느낌이다. registerBlockType() 함수의 옵션 인자로 주어지며, 해당 블록 타입 내 속성값의 키-값 쌍들의 목록이다.

    registerBlockType('foo/bar', {
      ...
      attributes: {
        name: {
          type: 'string',
          default: 'John',
        }
      }
      ...
    });

    키는 해당 속성의 이름, 값은 속성의 성격을 정의한다.

    ‘type’은 필수적으로 지정해야 하며 다음 문자열 중 하나여야 한다.

    • null
    • boolean
    • object
    • array
    • number
    • string
    • integer

    ‘source’는 이 속성값이 블록 내 어떤 곳에서 오는지 지정한다.

    • 생략: source를 생략할 수 있다. 이 때 블록의 주석에 인코딩된다. 명시적으로 주는 방법도 있을 텐데 정확히 알 수가 없다.
    • attribute: 속성에서 가져온다.
    • html: HTML 코드를 취한다.
    • text: 텍스트를 취한다.
    • children: 내부 자식 요소에서 취한다.
    • meta: 메타 값에서 온다.
    • query: 속성 값에 여러 군데에서 값을 가져와야 할 경우 유용하다.

    ‘source’와 더불어 ‘selector’, ‘attribute’ 속성을 더할 수 있다. ‘selector’는 CSS 선택자로 내부 요소를 선택할 때 쓰고, ‘attribute’는 검색된 노드에서 어트리뷰트 값을 취할 때 쓴다.

    {
    ...
    attributes: {
      ingredients: {
        type: 'array',
        source: 'children',
        selector: '.ingredients',
      },
      url: {
        type: 'string',
        source: 'attribute',
        selector: 'img',
        attribute: 'src',
      },
      comment: {
        type: 'string',
        source: 'meta',
        meta: '_my_comment'
      }
    }
    ...
    }
    // attributes의 예제. 'ingredients' 클래스를 가진 자식 노드의 텍스트를 가져온다.
    // img 노드의 src 속성에서 url 속성값을 채운다.
    // comment 속성은 메타 필드 '_my_commnet' 로부터 온다.

    예제에서 나는 두 개의 입력 필드를 삽입했다. 이 입력 필드를 통해 위젯의 내용을 동적으로 표현한다. 하나는 주석에 저장하고, 하나는 메타 필드로부터 가져온다.

    {
    ...
    attributes: {
        name: {
            type: 'string',
            default: 'John',
        },
        anotherName: {
            type: 'string',
            source: 'meta',
            meta: '_another_name',
            default: 'Jane',
        }
    },
    ...
    }

    나머지 edit(), save() 메소드를 완성한다.

    edit({setAttributes, attributes}) {
        return (
            <>
                <p>{__('Hello, World!', 'wp-esnext-study')}, {attributes.name}, {attributes.anotherName}!</p>
                {/* 첫번째 입력 요소. Hello, World!, {이름}을 출력하게 합니다.
                 이 요소는 HTML 주석에 JSON 인코드 됩니다. */}
                <TextControl
                    label={__('Your Name', 'wp-esnext-study')}
                    value={attributes.name}
                    onChange={value => {
                        setAttributes({name: value});
                    }}
                />
                <TextControl
                    label={__('Another Name', 'wp-esnext-study')}
                    value={attributes.anotherName}
                    onChange={value => {
                        setAttributes({anotherName: value});
                    }}
                />
            </>
        );
    },
    save({attributes}) {
        return (
            <>
                <p>{__('Hello, World!', 'wp-esnext-study')}, {attributes.name}, {attributes.anotherName}!</p>
            </>
        );
    }

    이렇게 하면, name은 본문 블록의 주석에 저장되고, anotherName은 메타 값으로 저장된다. 단, 메타 값을 REST API에 노출하기 위해 index.php에 register_meta() 함수를 호출해 _another_name 필드를 지정한다.

    <?php
    ...
    
    register_meta(
    	'post',
    	'_another_name',
    	[
    		'type'              => 'string',
    		'description'       => 'Another name.',
    		'default'           => 'Jane',
    		'single'            => true,
    		'sanitize_callback' => function ( $value ) {
    			return sanitize_text_field( $value );
    		},
    		/**
    		 * apply_filters( "auth_{$object_type}_meta_{$meta_key}", $allowed, $meta_key, $object_id, $user_id, $cap, $caps );
    		 *
    		 * @see map_meta_cap()
    		 */
    		'auth_callback'     => function ( $allowed, $meta_key, $object_id, $user_id ) {
    			return user_can( $user_id, 'edit_post', $object_id );
    		},
    		'show_in_rest'      => true,
    	]
    );

    편집기에서 값을 저장하고 저장된 상태를 확인해 본다. 값이 잘 저장되는 것을 확인했다.

    편집기 화면, 이름으로 Simth, Ashley를 입력했다.
    저장 후 프론트에서 본 화면.
    해당 포스트의 포스트 메타 상황을 데이터베이스 테이블에서 검사.

    단, 값이 잘 저장된 것처럼 보이지만 헛점이 하나 있다. 이 글을 코드 에디터로 봐서 본문을 날것 그대로 보면 아래 그림처럼 나온다.

    메타 값으로 지정한 Ashly는 그저 텍스트로 출력되어 있다. 만약 다른 곳에서 메타 값을 Ashly에서 Simpson으로 변경한다 하더라도, 이 텍스트는 변경되지 않는다. 이렇게 DB로 저장된 값을 가져와 표시하려면 지금처럼 정적인 렌더링은 알맞지 않다. 이것은 차후 동적 렌더링에서 따로 알아보도록 하자.