[태그:] 커스텀 포스트

  • 커스텀 포스트의 주소체계 (permastruct) 수정하기

    워드프레스에서 일반적인 포스트는 관리자 > 설정 > 고유주소에서 적절히 주소 체계를 수정할 수 있지만, 커스텀 포스트의 주소 체계는 딱 고정되어 있다. 보통 <커스텀 포스트 타입 이름>/<포스트 이름(postname, 또는 slug)> 으로 되어 있으며, <커스텀 포스 타입 이름> , 즉 “앞부분” 정도만 register_post_type() 함수의 파라미터 조정을 통해 변경할 수 있다.

    앞으로의 내용을 돕기 위해 커스텀 포스트를 작성한다. 아래와 같이 등록하였다.

    add_action( 'init', 'my_cpt_init' );
    function my_cpt_init() {
    	register_post_type(
    		'my-cpt',
    		[
    			'label'        => 'My CPT',
    			'public'       => true,
    			'hierarchical' => false,
    			'rewrite'      => [
    				'slug'    => 'cpt',
    				'pages'   => true,
    				'feeds'   => false,
    				'ep_mask' => EP_NONE,
    			],
    			'has_archive'  => true,
    			'show_in_rest' => true,
    		]
    	);
    }Code language: PHP (php)

    “앞부분” 변경하기

    우선 앞부분을 변경하는 방법부터 설명한다. ‘my-cpt’란 포스트 타입을 위 예제처럼 입력했을 때, ‘rewrite’ 파라미터 ‘slug’ 항목을 ‘cpt’로 변경하였다. 그러므로 이 타입 포스트들의 URL은 이제 /cpt/<포스트 이름> 체계를 따르게 된다. 임의로 cpt라는 문자열을 주었고 이 문자열은 슬래시도 허용한다. 그러므로 ‘my/cpt’ 같이 줘도 문제 없다.

    “뒷부분” 변경하기

    앞부분은 아주 쉽게 변경 가능하다. 문제는 뒷부분이다. 커스텀 포스트는 자유롭게 이 뒷부분을 변경할 수 없으며, 포스트 이름으로 고정되어 있다. 가령 이 부분을 포스트의 ID로 변경하려고 한다. 하지만 프론트엔드에서 만날 수 있는 설정으로는 이것이 불가능하다.

    그럼 별도의 액션/필터를 활용하여 이 부분을 수정해 보자. 우선 목표부터 좀 설명하기 쉽게 정리하자.

    목표: My CPT 타입의 포스트 ID  2476의 포스트 이름은 'aaaa'다.
    현재 주소는 /cpt/aaaa/ 이다. 이것을 /cpt/2476/ 으로 변경한다.

    워드프레스의 URL 매칭과 메인 쿼리 방식을 생각했을 때, 위 목표를 가능하게 하려면 다음과 같은 3가지를 작업해야 한다.

    1. 워드프레스가 커스텀 포스트를 등록할 때, 하드코딩된 permastruct(고유주소 구조)를 포스트 이름 기반에서 포스트 아이디 기반으로 수정할 것.
    2. 커스텀 포스트의 퍼마링크를 가져올 때, 정확한 주소가 나오는지 확인하고 수정할 것.
    3. rewrite_rule 을 생성할 때 포스트 아이디 기반 다시 쓰기가 메인 쿼리에서 정확히 쿼리될 수 있는지 파라미터를 확인하고 수정할 것.

    변경 #1: permastruct 수정

    커스텀 포스트의 permastruct (고유주소 구조)는 커스텀 포스트 등록시, 포스트의 이름으로 고정되어 있다. 구현부는 WP_Post_Type::add_permastruct() 메소드를 참고한다.

    이것을 수정하려면 ‘registered_post_type’ 액션을 사용하여 글로벌 변수 $wp_rewrite에 접근한다. 그리고 여기서 등록된 내역을 수정한다.

    add_action( 'registered_post_type', 'my_cpt_registered_post_type', 10, 2 );
    function my_cpt_registered_post_type( string $post_type, WP_Post_Type $post_type_object ) {
    	global $wp_rewrite;
    
    	if ( 'my-cpt' === $post_type ) {
    		$wp_rewrite->extra_permastructs[ $post_type ]['struct'] =
    			$wp_rewrite->front . $post_type_object->rewrite['slug'] . '/%post_id%';
    	}
    }Code language: PHP (php)

    워드프레스 코어가 일괄적으로 생성하는 다시 쓰기 규칙 중, 커스텀 포스트 단일 페이지에 접근하기 위한 정규식 표현과 다시쓰기는 아래와 같다. 좌측이 URL과 매칭할 정규식, 오른쪽이 실제로 다시 쓰기되는 쿼리 파라미터 표현이다.

    cpt/([^/]+)/page/?([0-9]{1,})/?$ 	index.php?my-cpt=$matches[1]&paged=$matches[2]
    cpt/([^/]+)(?:/([0-9]+))?/?$ 	index.php?my-cpt=$matches[1]&page=$matches[2]

    두 규칙 다 cpt/ 다음에 ‘슬래시가 아닌 아무 문자열’을 매칭하며, 이 매칭은 my-cpt 포스트의 포스트 이름(슬러그)와 매칭되도록 설계되어 있다. 위 코드까지 추가하게 되면 두 쿼리 파라미터 표현은 아래처럼 변경될 것이다.

    cpt/([0-9]+)/page/?([0-9]{1,})/?$ 	index.php?p=$matches[1]&paged=$matches[2]
    cpt/([0-9]+)(?:/([0-9]+))?/?$ 	index.php?p=$matches[1]&page=$matches[2]

    ‘%post_id%’를 넣어준 덕분에 ‘슬래시가 아닌 아무 문자열’ 정규식 부분이 ‘연속된 숫자’로 변경된 것을 볼 수 있다.

    변경 #2: permalink 수정

    다시 쓰기 규칙이 변경되었으니, 이제 “/cpt/2476/”으로 주소가 변경되었을 것 같지만, 아직은 부족하다. 포스트 편집기에서 보면 퍼마링크가 생뚱맞게 “/cpt/%post_id%/”로 나올 것이다. 이것은 get_permalink() 함수가 커스텀 포스트에 대해서는 %post_id%라는 치환 문자열을 고려하지 않기 때문이다.

    생뚱맞은 %post_id% 가 URL 주소로.

    이 문제는 ‘post_type_link’ 필터를 통해 해결이 가능하다. 아래 코드까지 붙여 넣는다.

    add_filter( 'post_type_link', 'my_cpt_post_type_link', 10, 2 );
    function my_cpt_post_type_link( string $post_link, WP_Post $post ): string {
    	if ( 'my-cpt' === $post->post_type ) {
    		$post_link = str_replace( '%post_id%', $post->ID, $post_link );
    	}
    	return $post_link;
    }Code language: PHP (php)

    이제 URL 주소가 그럴듯하게 나온다! 코드를 수정한 후 반드시 관리자 > 설정 > 고유주소를 재방문하여 rewrite_rule을 다시 생성해야 하는 것을 잊지 말자.

    URL이 수정되었다.

    변경 #3: rewrite_rule 수정

    주소는 올바르게 찍히지만, 해당 주소로 들어갔을 때 404 NOT FOUND 페이지가 나온다. 아직 한 가지가 부족하다. 아까 다시 쓰기를 했을 때 규칙을 보자.

    • 정규 표현식: cpt/([0-9]+)(?:/([0-9]+))?/?$
    • 다시 쓰기: index.php?p=$matches[1]&page=$matches[2]

    다시 쓰기에서 p=$matches[1] 부분이 중요하다. 우리가 ‘/cpt/2476/’ 경로로 들어왔을 때, 이것은 ‘index.php?p=2476&page=’로 치환된다. 여기서 포스트 ID 2476번을 쿼리하라는 명령은 올바르게 전달되지만, 포스트 타입 파라미터가 생략되어 있다. 포스트 타입 파라미터가 생략되면 기본값인 ‘post’가 사용된다. 그러므로 실제로 SQL 쿼리문은 다음처럼 입력되어버린다.

    SELECT wp_posts.* FROM wp_posts WHERE 1=1 AND wp_posts.ID = 2476 AND hws_posts.post_type = 'post' ORDER BY wp_posts.post_date DESC Code language: SQL (Structured Query Language) (sql)

    2476번 포스트의 포스트 타입은 ‘my-cat’이니, 결과적으로 포스트가 없는 것으로 인식되는 것이다. 그럼 이 문제까지 수정해 보자. 다시 쓰기 규칙을 생성하고 기록하기 전에 위치한 ‘rewrite_rules_array’ 필터를 활용하면 수정이 가능하다.

    add_filter( 'rewrite_rules_array', 'my_cpt_rewrite_rules_array' );
    function my_cpt_rewrite_rules_array( array $rules ): array {
    	$obj = get_post_type_object( 'my-cpt' );
    
    	if ( $obj ) {
    		$slug = $obj->rewrite['slug'];
    
    		$new_rules = [
    			$slug . '/([0-9]+)/page/?([0-9]{1,})/?$' => 'index.php?post_type=my-cpt&p=$matches[1]&paged=$matches[2]',
    			$slug . '/([0-9]+)(?:/([0-9]+))?/?$'     => 'index.php?post_type=my-cpt&p=$matches[1]&page=$matches[2]',
    		];
    
    		foreach ( $new_rules as $key => $value ) {
    			if ( isset( $rules[ $key ] ) ) {
    				$rules[ $key ] = $value;
    			}
    		}
    	}
    
    	return $rules;
    }Code language: PHP (php)

    post_type을 ‘my-cpt’로 수정하면 올바르게 글이 쿼리된다.

    위 코드를 합친 플러그인 예제는 gist에도 올려둔다. 이러한 커스터마이즈는 주로 게시판 같은 스타일로 커스텀 포스트를 운용할 때 상당히 유용하다. 적절히 사용하자.

  • 커스텀 포스트가 관리자 페이지에서 일반 페이지처럼 템플릿을 선택할 수 있도록 하기

    커스텀 포스트가 관리자 페이지에서 일반 페이지처럼 템플릿을 선택할 수 있도록 하기

    register_post_type() 함수 인자에서 ‘support’ 항목에 ‘page-attributes’를 추가한다. 그리고 아래의 필터를 추가한다.

    <?php
    add_filter( 'theme_' . 'custom_post_type' . '_templates', 'my_templates', 10, 4);
    
    function my_templates( $post_templates, $wp_theme, $post, $post_type ) {
      if ( empty( $post_templates ) ) {
        $pt = $wp_theme->get_post_templates();
        $post_templates = $pt['page'] ?? array();
      }
      return $post_templates;
    }

     

  • 특정 포스트의 권한을 제어하는 레시피

    User Role Editor 보다 더욱 세밀한 권한 체크를 진행시킬 수 있는 레시피. ‘user_has_cap’ 필터를 잘 활용하면 된다.

    좀 더 구체적인 예로 설명을 하자. 만일 내가 포스트 아이디 1141번을 임시 글로 등록해 두었다고 가정하자. 그리고 단지 이 포스트에 대해서만은 editor들은 편집을 허용하지 않게 만들고 싶다. 그렇다면 다음처럼 코드르 만들 수 있다. 커스텀 포스트 타입은 ‘music_collection’이고 적절히 이에 따라 권한 세트를 생성했다.

    add_filter( 'user_has_cap', 'my_user_has_cap', 10, 4 );
    
    function my_user_has_cap( $all_caps, $caps, $args, \WP_User $user ) {
    
      if ( sizeof( $caps ) && in_array( $caps[0], array( 'edit_music_collections', 'edit_others_music_collections' ) ) ) {
    
        if ( sizeof( $args ) >= 3 && $args[2] == 1141 ) {
    
          if ( $user->has_cap( 'editor' ) ) {
    
            unset( $all_caps[ $caps[0] ] );
          }
        }
      }
    
      return $all_caps;
    
    }

     콜백 함수는 4개의 인자를 가지고 있으며, 이 인자의 확인이 은근히 복잡하다. 디버깅 및 설명의 편의를 위해 하나의 if로 처리하지 않고 3개의 if로 잘라 넣었다. 실전 코드에서는 당연히 하나로 붙이도록 하자.

    첫번째 if는 현재 요구되는 primitive 권한을 체크한다. 어떤 커스텀 포스트에서 요구하는 권한이 내가 제어하고 싶은 범위의 것인지를 검사한다.

    두번째 if는 특정 단일 포스트에 대한 권한 요구인지, 아니면 전반적인 복수의 포스트에 대한 요구인지를 체크한다. args에 인자로 0번째는 매핑되기 전의 권한 템플릿 이름, 1번째는 유저의 ID, 2번째로 post의 ID가 있다. 이 부분이 매우 중요하다. 단일 포스트의 권한 검사이므로 args 길이는 3이상이어야 한다.

    세번째 if에서 다시 사용자의 권한 체크를 한다. 지금 권한 체크에 대한 콜백 함수인데, 다시 권한 체크를 재귀적으로 부르고 있다. 권한 필터링을 실제 구현하는 것이 은근히 쉽지 않음을 짐작할 수 있다. 물론 익숙해지면 이야기는 다르겠지만, 주 역할과 메타 역할의 개념 등등을 잘 이해하지 못하면 이 권한 체크에 번번히 실패할 가능성이 높다.  재귀 함수의 오버헤드를 줄이려면 바로 $user->caps 를 활용할 수도 있다.

    이렇게 설정하면,  edtor들은 1141번 포스트에 대해 다음과 같은 화면을 만나게 된다.

    특정 포스트를 특정 권한에게 접근 제한했을 때의 결과 예제 스크린샷

    글에는 두 가지 draft가 있다. 첫번째 draft는 편집이 가능하지만, 두번째 draft는 접근이 막혀 있다. 이렇게 단일 포스트 단위로 접근을 제어하는 방법은 우리가 흔히 접할 수 있는 user role editor로도 쉽게 구현하기 어려운 기능이다. 게다가 코어의 자연스러운 접근 권한 체크를 하기 때문에 엉뚱한 곳에서 적당히 땜빵 코드로 접근을 막는 것보다 더욱 확실하고 자연스러운 제어가 가능하다. 스크린샷으로도 보이듯 접근 제어가 안 되는 항목은 확실하게 UI적으로도 막혀 있는 것이 보인다. 이렇게 구현하면 예상치 못한 URL로 접근하더라도 코어가 확실히 접근을 차단해 줌을 기대할 수 있는 것이다.

    물론 보통은 커스텀 포스트에 이 정도로 세밀한 접근 제어 기능을 구현하지는 않으나, 이와 유사한 요구 사항은 실무에서 많이 발생할 수 있을 것이라 생각한다. 이걸 자유자재로 사용할 수 있다면 정말 훌륭한 플러그인 구현이 되리라 생각한다.

    워드프레스의 역할과 권한은 잘 이해하는 사람도 흔치 않을 것 같다 코드가 지저분하든 더럽든 어쨌든 간에, 나는 워드프레스가 훌륭한 CMS라고 생각하며 그 근거 중의 하나로 이 강력하고 유연한 역할과 권한 시스템을 든다.  이걸 사용자가 쉽고 간편하게 쓸 수 있는 UI가 없는 것은 아쉽지만 (아니, 그런 UI를 쉽게 사용하게 만드는 것 자체가 미친 난이도지만) 이러한 시스템이 기저에 있다는 것 자체가 놀라움이다.

    덧글 ) 권한 체크는 상당히 어렵다. 비활성화된 항목에 마우스를 가져다 대어 보자.

    어이쿠, 이게 뭔가. Trash? 편집은 못하지만 지울 수는 있다. 편집자 역할은 휴지통에 있는 글도 영구 삭제 가능하다. 물론 같은 스태프끼리 그럴 일은 없겠지만… 아, 권한 체크는 세심해야 함을 강조한다. 해당 권한 목록을 꼼꼼하게 살펴서 이런 구멍이 없도록 잘 대비하기를 권한다.

  • 어드민 화면의 열 수를 1개로 고정하는 레시피

    add_filter( 'screen_layout_columns', function ( $columns ) {
      $screen = get_current_screen();
      $columns[ $screen->id ] = 1;
      return $columns;
    } );
    
    add_filter( 'get_user_option_screen_layout_kpm_paper', function ( $value ) {
      return 1;
    } );
    
    add_action( 'in_admin_header', function () {
      $screen = get_current_screen();
      if ( $screen->id == 'kpm_paper' ) {
        $screen->remove_option( 'layout_columns' );
      }
    } );

     커스텀 포스트에 활용할 수 있다.