ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Masonry Layout 구현 여정 🚀
    개발여정 2024. 7. 5. 23:42

    회사에 입사한지 1달하고도 보름쯤... 신입으로써 회사 곳간의 식량을 충실히(?) 축내고 있던 시기였습니다. 이번 스프린트 때 어떤 일을 맡을지 플래닝을 진행하던 도중 저에게 "Masonry Layout" 이라는 글자가 적힌 백로그가 눈에 들어왔습니다.

     

     

    "Masonry Layout... Masonry..."를 마음속으로 잠시 되어었던 저는 곧장 "이건 제가 꼭 개발하고 싶습니다!!!"라며 멤버들에게 소리쳤습니다.

     

    당시 CSS Grid를 공부하고 있었고 자연스레 Masonry Layout에 대해 어렴풋이 알고 있었던 저는 공부했던 것을 실제 프로덕션에 반영해볼 수 있는 기회라 생각하여 크게 소리쳤고, 멤버들은 신입이지만 패기있는 모습에 "한번해보세요!!" 라며, 저에게 백로그를 양보해 주었습니다.

     

    Masonry Layout이란 뭘까?

    먼저 Masonry Layout에 대해 검색해보면 다음과 같이 나옵니다.

     

    Masonry Layout(메이슨리 레이아웃)은 웹 디자인에서 사용되는 그리드 레이아웃의 한 종류입니다. 일반적인 그리드 레이아웃과는 다르게, Masonry Layout은 각각의 요소가 불규칙한 크기를 가질 수 있고, 격자의 각 열에 따라 요소가 정렬됩니다.

     

    Masonry Layout을 적용한 사이트를 찾아보면 대표적으로 핀터레스트가 있고 네이버의 이미지 탭 등에서도 확인할 수 있습니다.

     

     

     

    직접 구현할 결심...

    당시 Masonry Layout을 구현하려면 CSS의 Grid를 활용해서 구현하는 방식이 있었고, 다른 개발자가 개발한 라이브러리를 가져다 사용하는 방법이 존재했었습니다.

    다른 개발자가 개발한 라이브러리를 사용하면 Masonry Layout를 쉽게 적용할 수 있었지만, 1px 차이에 민감했던 저에게 각 요소의 아래쪽에 2~3px의 공백이 생기는 것이 거슬렸던(?) 저는 다른 방법을 모색했습니다.

    다른 방법인 CSS의 Grid를 활용해서 Masonry Layout을 쉽게 적용할 수 있지만, 단순히 CSS를 건들이기 싫었던 마음이 들었던 나머지... JavaScript로 직접 구현할 결심을 하게 되었습니다.

    제 밥값도 하기 힘들었던 시기... 신입이었던 저에게는 너무나도 힘든 일이었지만, 꼭 뭔가 회사에서 1인분을 해보겠다는 마음이 간절했던 나머지... 설날과 주말에 쉬는 것을 포기하고 열심히 Masonry Layout 구현 방법에 대해서 미친듯이 몰두했습니다.

     

    그 중에서도 영감을 얻기 위해서 핀터레스트에 접속하여 개발자 도구를 열어 분석했던 기억이 납니다. 😭

     

     

    연습했던 흔적들...

     

     

    어떻게 구현했을까?

    Masonry Layout을 구현에 들어가기 전에 먼저 다음과 같은 개념을 정의했습니다.

    요소

    • Item : 사용자가 렌더링할 요소
    • Wrapper : Item을 감싸는 요소. columnGaprowGap 을 지정하기 위한 요소
    • Brick : Wrapper 를 감싸는 요소. Item 을 어디에 위치시킬 것인지 결정
    • Container : Brick 이 어디에 위치해야 하는지 결정하는 기준이 되는 요소

    • columnGap : 그리드의 컬럼의 너비를 의미
    • rowGap : 그리드의 각 로우 사이의 간격을 의미
    • posX , posY : Container 를 기준으로 Brick 이 어디에 위치하는지 x, y 좌표를 의미

     

    그래서 코드는...?

    이제 개념 정의를 마쳤으니 코드를 보면서 어떻게 구현했는지 차근차근 살펴봅시다. 앞으로 볼 예시 코드의 구조는 다음과 같습니다.

    masonry-layout-poc
    ├── app.js
    ├── index.html
    └── utils.js

     

    index.html

    <!DOCTYPE html>
    <html lang="ko">
      <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Masonry Layout PoC</title>
    
        <!-- CSS -->
        <style>
          /* 아이템들의 공통적인 스타일링을 설정 */
          .item {
            background-color: #ccc;
            border: 1px solid #444;
          }
    
          /* 각 아이템의 높이를 임의로 설정 */
          .item1 {
            height: 152px;
          }
          .item2 {
            height: 123px;
          }
          .item3 {
            height: 234px;
          }
          .item4 {
            height: 143px;
          }
          .item5 {
            height: 143px;
          }
          .item6 {
            height: 123px;
          }
          .item7 {
            height: 332px;
          }
          .item8 {
            height: 232px;
          }
          .item9 {
            height: 142px;
          }
          .item10 {
            height: 224px;
          }
        </style>
      </head>
      <body>
        <div id="grid">
          <div class="item item1">1</div>
          <div class="item item2">2</div>
          <div class="item item3">3</div>
          <div class="item item4">4</div>
          <div class="item item5">5</div>
          <div class="item item6">6</div>
          <div class="item item7">7</div>
          <div class="item item8">8</div>
          <div class="item item9">9</div>
          <div class="item item10">10</div>
        </div>
        <!-- // grid -->
    
        <!-- scripts -->
        <script src="utils.js"></script>
        <script src="app.js"></script>
      </body>
    </html>

     

    index.html 파일을 찬찬히 살펴보면, 개념 설계에서 설명했던 Container , Brick , Wrapper 의 요소가 존재하지 않는 것을 발견할 수 있습니다.

     

    Container , Brick , Wrapper 이 세 가지 요소는 사용자들로부터 입력받지 않고 JavaScript로 각 Item 요소를 감싸줄 것입니다.

     

    app.js

    이제 핵심적인 로직을 살펴봅시다! 먼저 다음 값을 값을 임의로 설정했습니다.

    // options
    let columnWidth = 250; // 그리드의 컬럼의 너비를 의미
    let columnGap = 20; // 그리드의 각 컬럼 사이의 간격을 의미
    let rowGap = 30; // 그리드의 각 로우 사이의 간격을 의미

     

    그 다음 화면에서 load 이벤트와 resize 이벤트가 발생했을 때 동작할 이벤트 리스너를 등록했습니다.

    // index.html 로부터 각 요소를 가져옵니다.
    const grid = document.getElementById('grid');
    const items = grid.querySelectorAll('.item');
    
    // ...
    
    // load 이벤트가 발생했을 때 호출될 이벤트 리스너를 등록합니다.
    window.addEventListener('load', handleLoad);
    // resize 이벤트가 발생했을 때 호출될 이벤트 리스너를 등록합니다.
    window.addEventListener('resize', handleResize);

     

    load 이벤트가 발생할 때는 다음과 같은 동작을 하게 됩니다.

    function handleLoad() {
      const gridWidth = grid.clientWidth;
    
      // 1. Item 요소에 Container, Brick, Wrapper 요소로 감싸줍니다.
      wrapAllItems();
      // 2. Brick의 위치를 계산하여 지정합니다.
      positionAllItems(gridWidth);
    }

     

    두 가지 동작중 먼저 wrapAllItems 함수를 살펴봅시다. 이 함수에서는 Item 요소에 Container , Brick , Wrapper 라는 요소로 감싸줄 것입니다.

    function wrapAllItems() {
      // container 요소 생성 
      const container = document.createElement('div');
      container.classList.add('container');
      // container 요소는 brick 요소가 어디에 위치할 것인지 기준이 되기 때문에 다음과 같이 설정합니다.
      const containerConfig = {
        position: 'relative',
      };
      utils.setElementStyle(container, containerConfig);
    
      // 각 item 요소를 순회합니다.
      items.forEach((item) => {
        // wrapper 요소를 생성합니다.
        const wrapper = document.createElement('div');
        wrapper.classList.add('wrapper');
        // wrapper 요소에는 사용자들로부터 입력받은 columnGap 값과 rowGap 값을 지정해줍니다.
        const wrapperStyleConfig = {
          'padding-left': columnGap / 2 + 'px',
          'padding-right': columnGap / 2 + 'px',
          'padding-bottom': rowGap + 'px',
        };
        utils.setElementStyle(wrapper, wrapperStyleConfig);
        // wrapper 요소를 item 요소의 부모 요소로 지정합니다.
        wrapper.appendChild(item);
    
        // brick 요소를 생성합니다.
        const brick = document.createElement('div');
        brick.classList.add('brick');
        // brick 요소는 container 요소를 기준으로 어디에 위치할 것인지를 담당할 것이기 때문에 다음과 같이 설정합니다.
        const brickStyleConfig = {
          position: 'absolute',
          left: 0,
          top: 0,
        };
        utils.setElementStyle(brick, brickStyleConfig);
        // brick 요소를 wrapper 요소의 부모 요소로 지정합니다.
        brick.appendChild(wrapper);
    
        // brick 요소를 container 요소의 자식 요소로 지정합니다.
        container.appendChild(brick);
      });
    
      // container 요소를 grid 요소의 자식 요소로 지정합니다.
      grid.appendChild(container);
    }

     

    이제 Item 요소에 Container , Brick , Wrapper 요소로 감쌌으니 각 Brick 의 위치를 결정할 positionAllItems() 함수를 살펴봅시다.

    let cCol = 0; // 이전에 생성된 컬럼의 개수
    
    function positionAllItems(gridWidth) {
      // 그리드 너비에 몇 개의 칼럼이 생성될 수 있는지 계산합니다.
      const nCol = Math.floor(gridWidth / brickWidth);
    
      // 현재 생성된 컬럼의 개수와 생성되어야 하는 컬럼의 개수가 같거나 생성되어야 하는 컬럼의 개수가 0개이면 연산을 하지 않음
      if (cCol === nCol || nCol === 0) return;
    
      // 생성되어야 하는 컬럼의 개수를 이전에 생성된 컬럼의 개수로 지정
      cCol = nCol;
      
      // 컬럼의 개수만큼 Brick에 사용될 x좌표와 y좌표를 생성
      const rowWidths = Array(cCol).fill(0); // 0으로 초기화
      const colHeights = Array(cCol).fill(0); // 0으로 초기화
    
      // x 좌표는 Brick의 너비만큼 정해줌
      rowWidths.forEach((_, idx) => {
        rowWidths[idx] = brickWidth * idx;
      });
    
      // 각 item 요소를 순회
      items.forEach((item) => {
        // item 요소의 가장 가까운 brick 요소를 찾음
        const brick = item.closest('.brick');
        // item 요소의 높이를 구하여 rowGap만큼 더해줌
        const brickHeight = item.clientHeight + rowGap;
        // 이 item이 현재 row에서 몇 번째 인덱스에 해당하는지 찾음
        const minIndex = utils.findMinIndex(colHeights);
    
        // 찾은 인덱스로 brick의 x, y 좌표를 설정
        const posX = rowWidths[minIndex];
        const posY = colHeights[minIndex];
    
        // y 좌표를 현재 요소의 높이만큼 더해줌
        colHeights[minIndex] += brickHeight;
    
        const brickStyleConfig = {
          ...brick.style,
          width: brickWidth + 'px',
          height: brickHeight + 'px',
          transform: 'translateX(' + posX + 'px) translateY(' + posY + 'px)',
        };
        utils.setElementStyle(brick, brickStyleConfig);
      });
    
      // 컨테이너 높이 설정
      const container = grid.querySelector('.container');
      const containerWidth = brickWidth * cCol;
      const containerHeight = Math.max.apply(null, colHeights); // 컬럼의 최대값
      utils.setElementStyle(container, {
        ...container.style,
        width: containerWidth + 'px',
        height: containerHeight + 'px',
      });
    }

     

    Item 배치의 원리를 쉽게 이해하기

    코드만 봤을 때 어떻게 동작하는지 쉽게 파악이 어려우니 예시와 함께 positionAllItems 함수를 이해해 봅시다.

    그리드에 생성될 컬럼의 개수가 3개이며 brickWidth = 250, rowGap = 30 이라고 가정합니다.

     

    Item1의 위치 결정

     

    Item1의 높이가 100 이라고 생각하고, Item1의 위치를 결정해 봅시다. 현재 rowWidths , colHeights 배열을 살펴보면 다음과 같습니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 0 0 0

     

    여기서 colHeights 배열에서 가장 작은 값을 찾습니다. 만약 값이 동일한 것이 존재한다면, 인덱스가 가장 작은 것을 선택합니다. 여기서 선택되는 인덱스는 0 입니다.

     

    따라서 Item1의 x, y 좌표는 (0, 0) 입니다.

     

    이제 Item1의 좌표를 정했으니 colHeights 배열의 선택된 0 번째 인덱스에 Item1 요소의 높이(100)를 더해줍니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 100 0 0

     

     

    Item2의 위치 결정

     

    Item2의 높이가 70 이라고 생각하고, Item2의 위치를 결정해 봅시다. 현재 rowWidths , colHeights 배열을 살펴보면 다음과 같습니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 100 0 0

     

    여기서 colHeights 배열에서 가장 작은 값을 찾습니다. 만약 값이 동일한 것이 존재한다면, 인덱스가 가장 작은 것을 선택합니다. 여기서 선택되는 인덱스는 1 입니다.

     

    따라서 Item2의 x, y 좌표는 (250, 0) 입니다.

     

    이제 Item2의 좌표를 정했으니 colHeights 배열의 선택된 1 번째 인덱스에 Item2 요소의 높이(70)를 더해줍니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 100 70 0

     

     

    Item3의 위치 결정

     

    Item3의 높이가 120 이라고 생각하고, Item3의 위치를 결정해 봅시다. 현재 rowWidths , colHeights 배열을 살펴보면 다음과 같습니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 100 70 0

     

    여기서 colHeights 배열에서 가장 작은 값을 찾습니다. 만약 값이 동일한 것이 존재한다면, 인덱스가 가장 작은 것을 선택합니다. 여기서 선택되는 인덱스는 2 입니다.

     

    따라서 Item3의 x, y 좌표는 (500, 0) 입니다.

     

    이제 Item3의 좌표를 정했으니 colHeights 배열의 선택된 2 번째 인덱스에 Item3 요소의 높이(120)를 더해줍니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 100 70 120

     

     

    Item4의 위치 결정

     

    Item4의 높이가 170 이라고 생각하고, Item4의 위치를 결정해 봅시다. 현재 rowWidths , colHeights 배열을 살펴보면 다음과 같습니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 100 70 120

     

    여기서 colHeights 배열에서 가장 작은 값을 찾습니다. 만약 값이 동일한 것이 존재한다면, 인덱스가 가장 작은 것을 선택합니다. 여기서 선택되는 인덱스는 2 입니다.

     

    따라서 Item4의 x, y 좌표는 (250, 70) 입니다.

     

    이제 Item4의 좌표를 정했으니 colHeights 배열의 선택된 2 번째 인덱스에 Item4 요소의 높이(120)를 더해줍니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 100 190 120

     

     

    Item5의 위치 결정

     

    Item5의 높이가 50 이라고 생각하고, Item4의 위치를 결정해 봅시다. 현재 rowWidths , colHeights 배열을 살펴보면 다음과 같습니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 100 190 120

     

    여기서 colHeights 배열에서 가장 작은 값을 찾습니다. 만약 값이 동일한 것이 존재한다면, 인덱스가 가장 작은 것을 선택합니다. 여기서 선택되는 인덱스는 0 입니다.

     

    따라서 Item5의 x, y 좌표는 (0, 100) 입니다.

     

    이제 Item5의 좌표를 정했으니 colHeights 배열의 선택된 0 번째 인덱스에 Item5 요소의 높이(50)를 더해줍니다.

      0 1 2
    rowWidths 0 250 500
    colHeights 150 190 120

     

    나도... 1인분 할 수 있다!!!!

    이런식으로 접근하여 우여곡절 끝에 JavaScript로 Masonry Layout 구현을 마쳤습니다.

     

    이에 멤버들은 감동한 나머지 이것을 기반으로 하여금 React에서 동작하도록 하고 여기에 무한 스크롤링 기능이 추가되었으면 좋겠다는 말을 남기게 되었고...

     

    https://github.com/rosaleeee/masonry-layout

     

    GitHub - rosaleeee/masonry-layout

    Contribute to rosaleeee/masonry-layout development by creating an account on GitHub.

    github.com

     

    여기에서 그 코드를 살펴보실 수 있습니다.

     

    마무리

    개발 당시에 이렇게 정리했다면 좋겠지만... 개발에 너무 힘을 써버려 글을 쓸 힘조차 나지 않았습니다...

     

    그리고 지금도 포스팅에 힘을 너무 써버린 탓에 이쯤에서 어서 마쳐보려합니다...

     

    감사합니다 🙇‍♂️

    댓글

Designed by Tistory.