로토의 블로그

[인디스트릿] id 기반 문서에 slug를 붙여보자

[인디스트릿] id 기반 문서에 slug를 붙여보자 대표 이미지

글에 들어가기에 앞서

인디스트릿(https://indistreet.com)은 인디 뮤지션들의 정보, 공연 정보 등을 한 곳에서 모아서 볼 수 있는 사이트로 기존에 존재했던 사이트를 올해 초에 도메인을 이어받아 새로 만들어나가고 있는 서비스다.

기본적으로 JAMStack을 지향하며, next.js, strapi, graphql + apollo 기반으로 구현 되어있다.

인디스트릿을 구현하고 운영하면서 얻은 내용을 블로그에 적어야지 적어야지 하면서 마음만 먹다 드디어 오늘 그 첫삽을 뜬다.

작업내용

인디뮤지션별로 페이지가 따로 존재하는데, /musicians/{musicianId}와 같은 패턴을 쓰고 있었다.

얼마전 musicianId 대신 유니크한 slug로도 해당 페이지에 접근할 수 있도록 작업을 했는데, 그 과정을 간략하게 기록해본다.

id를 number로 썼던 이유

여기서 musicianId는 number다.

새로운 뮤지션 생성의 경우 공연 데이터를 등록하다 라인업을 입력하는 UI에서 하는 경우가 많은데, 만약 id를 number 대신 slug string으로 했다면 이 부분이 굉장히 불편했을 것이다. 생성할 때마다 slug를 유니크하게 입력해주어야 했을테니까. 아래의 UI 형태를 참고하면 이해가 될 것이다.

slug를 붙여보자

number 형태의 id의 경우 자동으로 증가되기 때문에 데이터 추가 시 신경쓰지 않아도 되는 장점이 있지만, url로 표현한 경우 아무래도 slug로 표현하는 것보다는 덜 이뻐보이는(?) 문제가 있다.

특히 뮤지션 페이지의 경우 뮤지션의 어떠한 개성을 표현해야하는데 자동으로 증가된 숫자가 url에 포함되어있다보니 뭔가 미묘하게 답답한 부분이 있어 기존의 형태를 유지한 상태로 slug를 붙여보자는 생각을 하게 되었다.

slug를 사용하는 url 패턴을 정의하기

기존 url 패턴에서 musicianId가 숫자로 왔는지 문자로 왔는지 체크해서 쓰는 방법도 있겠지만, 명확하게 id로 들어오는 경우와 slug로 들어오는 경우를 구분하기로 했다.

기존 url이 /musicians/1 과 같은 방식이라면, slug를 사용하는 url은 /artists/idiots와 같은 방식이다.

Musician 모델에 필드 추가하기

인디스트릿은 https://strapi.io/ 를 쓰고 있다. 손쉽게 Musician 모델에 아래 두 필드를 추가했다.

  • useSlug: boolean - slug 사용 여부. default false
  • slug: string - slug값. unique해야함

뮤지션 편집 페이지에 slug 관련 기능 붙이기

위 화면과 같은 UI를 추가했다.

slug를 사용하는 페이지에 id로 접근시 redirect 하기

인디스트릿은 next.js를 쓰고 있다.

뮤지션 페이지의 getStaticProps 부분에 몇가지 로직을 추가해준다.

slug 데이터가 유효하고 useSlug가 true인 경우 slug가 붙은 url로 redirect 해준다.

  const res = await apolloClient.query<FindOneMusicianQuery>({
    query: FindOneMusicianDocument,
    variables: {
      id: musicianId,
    },
  })

const { musician } = res.data

if (!musician) { return { notFound: true, } }

if (!isNil(musician.slug) && musician.useSlug) { return { redirect: { destination: /artists/${musician.slug}, }, } }

slug 기준으로 musician 데이터를 불러오는 처리

strapi에서는 기본적으로 id 기준으로 findOne 해오는 REST API 함수를 제공한다. 그래서 id 외에 필드로 findOne 해야하는 경우 별도로 코드를 추가해주어야 한다.

// api/musician/controllers/musician.js
module.exports = {
  async findBySlug(ctx) {
    const slug = ctx.params.slug ? ctx.params.slug : ctx.params._slug
    const entity = await strapi.services.musician.findOne({ slug })
    return sanitizeEntity(entity, { model: strapi.models.musician })
  },
}

graphql 요청인 경우 params 앞에 _ 가 붙어서 임시로 저렇게 처리했다. 저렇게 들어오는 이유가 있을 것 같은데 그건 이 글을 읽고 아시는 분이 코멘트 해주실거라 믿는다.

어쨌든 저런 식으로 추가하고 api/mnusicians/config/route.json에 url 라우팅을 추가한다.

...
    {
      "method": "GET",
      "path": "/musicians-by-slug/:slug",
      "handler": "musician.findBySlug",
      "config": {
        "policies": []
      }
    },
...

실제로는 REST API 방식 말고 graphql로 쓰고 있기 때문에 추가하지 않아도 되겠지만 호옥시나 REST로 쓸 수도 있기 때문에 추가했다.

다음으로 api/mnusicians/config 폴더에 schema.graphql.js 파일을 만든다.

이 파일을 통해 strapi가 기본으로 만들어주는 쿼리 외에 다른 쿼리를 정의할 수 있다.

module.exports = {
  query: `musicianBySlug(slug: String!): Musician`,
  resolver: {
    Query: {
      musicianBySlug: {
        resolver: 'application::musician.musician.findBySlug',
      },
    },
  },
}

이제 데이터를 불러오는 준비는 끝났다.

/artists/{slug} 에 대응하는 페이지 만들기

slug를 이용해 데이터를 불러올 수 있게 만들었으니 이제 렌더링 페이지를 만들 차례다.

/artists/{slug} 페이지의 경우 렌더링 되는 결과물 자체는 /musicians/{musicianId}와 완전 동일하기 때문에, 기존에 렌더링하던 부분을 별도의 컴포넌트로 분리하고 각 페이지 컴포넌트에서 불러다 쓰는 식으로 처리했다.

getStaticProps에서는 slug를 이용해서 불러오도록 처리하면 끝이다.

마치며

instagram, twitter, linktree 처럼 각 뮤지션 페이지의 url에 뮤지션의 어떤 상징, 개성 같은 느낌을 주고자 시작한 작업이다. 작업한 양은 많지 않았는데 결과물이 만족스럽다.