-
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을 감싸는 요소. columnGap 과 rowGap 을 지정하기 위한 요소
- 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
여기에서 그 코드를 살펴보실 수 있습니다.
마무리
개발 당시에 이렇게 정리했다면 좋겠지만... 개발에 너무 힘을 써버려 글을 쓸 힘조차 나지 않았습니다...
그리고 지금도 포스팅에 힘을 너무 써버린 탓에 이쯤에서 어서 마쳐보려합니다...
감사합니다 🙇♂️
'개발여정' 카테고리의 다른 글
React Native로 모바일과 웹을 한번에 개발하기 (0) 2024.07.19 엑셀 파일 다운로드 기능 구현 여정 🚀 (1) 2024.07.12 OCR 구현 여정 🚀 (0) 2024.06.28