개발여정

Masonry Layout 구현 여정 🚀

rosaleee 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

 

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

 

마무리

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

 

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

 

감사합니다 🙇‍♂️