Masonry Layout 구현 여정 🚀
회사에 입사한지 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
GitHub - rosaleeee/masonry-layout
Contribute to rosaleeee/masonry-layout development by creating an account on GitHub.
github.com
여기에서 그 코드를 살펴보실 수 있습니다.
마무리
개발 당시에 이렇게 정리했다면 좋겠지만... 개발에 너무 힘을 써버려 글을 쓸 힘조차 나지 않았습니다...
그리고 지금도 포스팅에 힘을 너무 써버린 탓에 이쯤에서 어서 마쳐보려합니다...
감사합니다 🙇♂️