fe.resolver.ts
fe.resolver.ts

Powered by Notion & Next.js

Navigate

  • 개인정보처리방침

Connect

  • GitHub

© 2026 Hanul Lee. All rights reserved.

Powered by Notion & Next.js

목록으로
Development2025년 8월 8일

구글 캘린더 알고리즘의 비밀

1.7x 와 계단식 밀어내기, 빈 컬럼 확인 등 노하우 뿌려요

#JavaScript

개요

어느날 디자이너 분께서 ‘플래너 카드 배치를 구글 캘린더처럼 만들어 주실 수 있어요?’ 라고 하셨다.

이 포스팅은 Schedule Layout 화면의 플래너탭을 구글캘린더처럼 바꾸기위해 요소탭을 뜯어가며 파헤친 비밀을 공유하는 창고 대방출글이다.

기존 플래너의 배치 알고리즘

지금까지의 플래너 화면은 균등한 분배가 원칙이었다.

알고리즘을 간단히 표현하면 ‘Stacking & Shifting (계단식 밀어내기)’ 인데, 시간순으로 정렬 후 1번 라인(가장 왼쪽)에 이벤트를 겹치지 않게 채우고, 겹치는 이벤트들은 다음 라인으로 넘기고, 이 과정을 이벤트가 없을때까지 반복한다.

1. Grouping (그룹핑)

가장 먼저 하는일은 엮인 일정들을 Greedy 방식으로 묶는다.

javascript
// 1. 시간순 정렬 (Sort)
const sortedPlans = plans.sort(...); 

// 2. 그룹 묶기 (Reduce)
const planGroups = sortedPlans.reduce((acc, current) => {
  // 현재 그룹(acc.local)에 있는 일정 중 하나라도 '나'와 겹치는가?
  const hasOverlap = acc.local.some(
    (plan) => current.startAt.isBefore(plan.endAt) && 
              current.endAt.isAfter(plan.startAt)
  );

  if (hasOverlap) {
    // 겹치면 같은 그룹으로 합류!
    acc.local.push(current);
  } else {
    // 안 겹치면, 이전 그룹을 완성(commit)하고 새로운 그룹 시작
    acc.result.push(acc.local);
    acc.local = [current];
  }
  return acc;
}, ...);

2. Line Allocation (라인 배정)

javascript
// 재귀적으로 라인을 생성하는 함수
const lineUpPlans = (plans) => {
  const currentLine = [];
  const remains = [];
  plans.forEach((plan) => {
    // 현재 라인의 마지막 녀석과 겹치지 않으면 -> 이 줄에 태운다 (Push)
    if (!lastOnCurrentLine || !plan.startAt.isBefore(lastOnCurrentLine.endAt)) {
      currentLine.push(plan);
    } else {
      // 겹치면 -> 다음 줄로 토스한다 (Remains)
      remains.push(plan);
    }
  });
  return { currentLine, remains };
};

// 메인 루프 (do-while)
do {
  // 남은 녀석들끼리 다시 줄을 세운다
  const { currentLine, remains } = lineUpPlans(remainPlans);
  localPlanWithLine.push(...currentLine); // 이번 줄 확정
  lineIndex += 1;
  remainPlans = remains; // 탈락자들은 다음 라운드로
} while (remainPlans.length > 0);

3. Position Calculation (위치 계산)

라인이 몇 개 생겼는지(maxIndices) 를 기준으로 너비와 위치를 결정한다.

javascript
// maxIndices: 총 라인 개수 (예: 3개)
// lineIndex: 내 라인 번호 (1, 2, 3)
const widthRatio = (1 + maxIndices - lineIndex) / maxIndices;
const leftOffsetRatio = 1 - widthRatio;

직관적으로 사진으로 보면 다음과 같았다.

Loading image...
Notion Image

구글캘린더는 어떻게 다른데?

구글 캘린더는 특이한 알고리즘을 가지고있었다.

너무 단순하지 않은 예시로 아래와 같은 사진을 꼽을 수 있다.

Loading image...
Notion Image

한두개로 간단히 정리할 수 없는 규칙이 있는것 같았다.

그래서 우선 가장 간단한 케이스부터 확인해봤다.

Loading image...
Notion Image
Loading image...
Notion Image

동일한 시간의 플랜이 두개있을때, 1번라인의 플랜은 단순 100%가 아니었다.

포지션 관련된 스타일은 다음과 같았다.

javascript
// 1번 라인
top: 479px; height: 46px; left: calc(0% + 0px); width: calc(85% + 0px); z-index: 5;


// 2번 라인
top: 479px; height: 46px; left: calc(50% + 0px); width: calc(50% + 0px); z-index: 6;

  • 포지션 (left):
    • Plan 1: 0%
    • Plan 2: 50%
  • 너비 (width):
    • Plan 1: 85%
    • Plan 2: 50%

85% 가 눈에 먼저 밟힌다.

이번엔 세개를 확인해봤다.

Loading image...
Notion Image
Loading image...
Notion Image

javascript
// 1번 라인
top: 479px; height: 46px; left: calc(0% + 0px); width: calc(56.6667% + 0px); z-index: 5;

// 2번 라인
top: 479px; height: 46px; left: calc(33.3333% + 0px); width: calc(56.6667% + 0px); z-index: 6;

// 3번 라인
top: 479px; height: 46px; left: calc(66.6667% + 0px); width: calc(33.3333% + 0px); z-index: 7;

느낌이 오는것 같다.

  • 포지션 (left):
    • Plan 세개를 정확히 1/3 로 나눈값들
  • 너비 (width):
    • Plan 1: Width 56.6667%
    • Plan 2: Width 56.6667%
    • Plan 3: Width 33.3333%

상수 1.7 을 곱하기

가장 오른쪽에 배치되는 마지막 플랜을 제외하고는 모두 너비가 단순 1/N 한게 아니었다.

너비 공식은 다음과 같았다.

typescript
(BaseWidth) * 1.7

  • 2개일때
    • 50 * 1.7 = 85
  • 3개일때
    • 33.3334 * 1.7 = 56.6667

Loading image...
Notion Image
Loading image...
Notion Image

이렇게 1.7배의 너비를 가질때의 가장 큰 장점은 해당 플랜을 ‘선택’ 했을때다.

지금은 Mock 데이터라 정보가 많이 없지만 저 작은 카드에 환자 이름, 환자 태그, 담당의, 대기리스트, 메모 등 다양한 정보가 들어 있을 수 있다.

이때 조금이나마 너비를 넓힘으로써 마우스를 올려 미리보기 툴팁을 보지 않아도 이전에 비해 많은 정보를 볼 수 있다는 장점이 생긴다.

left 5% 의 비밀

구글 캘린더의 규칙은 이것뿐만이 아니었다.

Loading image...
Notion Image

typescript
// 1번 플랜 (7:00 ~)
top: 335px; height: 94px; left: calc(0% + 0px); width: calc(42.5% + 0px); z-index: 5; background-color: rgb(3, 155, 229); border-color: rgb(3, 155, 229);

// 2번 플랜 (7:00 ~)
top: 335px; height: 94px; left: calc(25% + 0px); width: calc(75% + 0px); z-index: 6; background-color: rgb(3, 155, 229); border-color: rgb(3, 155, 229);

// 3번 플랜 (7:45 ~)
top: 371px; height: 94px; left: calc(30% + 0px); width: calc(70% + 0px); z-index: 7; background-color: rgb(3, 155, 229); border-color: rgb(3, 155, 229);

// 4번 플랜 (08:45 ~)
top: 419px; height: 94px; left: calc(35% + 0px); width: calc(65% + 0px); z-index: 8; background-color: rgb(3, 155, 229); border-color: rgb(3, 155, 229);

1번 플랜은 위에 있는 1.7 공식이 그대로 적용되었다.

(1/4) * 1.7 = 42.5

그런데 2번플랜부터는 뭔가 많이 다르다.

left는 2번플랜이 25%를 차지한 후부터 5% 씩만 더해진다. width 는 항상 100% - (left) 값이다.

왜 1, 2번은 얌전히 있는데 3, 4번만 5%씩 밀렸을까? 그 힌트는 시작 시간(StartAt)에 있었다.

  • 1, 2번: 7시 시작 (동시 시작 → 일반 컬럼 분할)
  • 3번: 7시 45분 시작 (45분 차이 → 5% 알고리즘 적용)
  • 4번: 8시 45분 시작 (60분 차이 → 5% 알고리즘 적용)

구글은 겹치는 일정들 간의 시간 격차가 30분을 넘어가면, 단순히 공간을 나누는(Divide) 모드를 버리고 계단식 중첩(Cascading) 을 한다.

해당 플랜의 포지션과 너비, zIndex는 이렇게 구현하면 된다.

typescript
// 만약 30분을 초과하는 일정이라면
left = prevLeft + 5%;
width = 100% - left;
zIndex = prevZIndex++;

위 규칙 두개를 복합적으로 잘 다뤄보자

Loading image...
Notion Image

지금 이 모양은 구글 캘린더의 규칙을 사람이 가장 쉽게 이해할 수 있는 그룹 케이스라고 생각한다. (내가 이해하기 제일 쉬웠기 때문)

typescript
// 1번 플랜
top: 95px; height: 142px; left: calc(0% + 0px); width: calc(85% + 0px); z-index: 5; background-color: rgb(3, 155, 229); border-color: rgb(3, 155, 229);

// 2번 플랜
top: 95px; height: 46px; left: calc(50% + 0px); width: calc(50% + 0px); background-color: rgb(3, 155, 229); border-color: rgb(3, 155, 229);

// 3번 플랜
top: 143px; height: 46px; left: calc(5% + 0px); width: calc(95% + 0px); z-index: 6; background-color: rgb(3, 155, 229); border-color: rgb(3, 155, 229);

세개라서 복잡해 보이지만, 두개씩만 생각해보도록 하자.

1번과 2번의 관계

→ width 1.7배 공식

1번과 2번은 같은 시간에 시작한다. 동시 시작이니 5% 계단식은 적용되지 않고, 순수하게 컬럼 분할 모드로 돌아간다.

  • 총 컬럼 수(MaxConcurrent) = 2
  • Base Width = 100% / 2 = 50%
  • 1번(왼쪽): 50% * 1.7 = 85% (1.7배 확장!)
  • 2번(오른쪽, 마지막 컬럼): 50% (기본 너비 유지)

1번과 3번의 관계

→ left 5% 계단식

3번은 8시에 시작한다. 1번(7시)과의 시작 시간 차이는 1시간이다. 30분을 초과했다.

  • 3번의 Left = 1번의 Left(0%) + 5% = 5%

그리고 z-index도 올라가야 한다. 3번이 1번 위에 얹히듯 렌더링되어야 하니까.

  • 1번: z-index 5
  • 3번: z-index 6

2번과 3번의 관계

→ 빈공간 재활용 (Back-filling)

3번 플랜(3시 ~ 4시 플랜)이 배치될 때, 알고리즘은 빈 컬럼을 탐색한다.

  • Col 0: 1번이 쓰고 있음 (7시~10시) → 꽉 참
  • Col 1: 2번이 쓰고 있었지만... 2번은 8시에 끝났다. 3번은 8시에 시작한다. → 비었다!

3번은 새로운 Col 2를 만들지 않고, 2번이 떠난 Col 1 자리에 쏙 들어간다.

그리고 오른쪽(startAt 이 겹치는 다른 플랜)이 없으니? Width는 100% - 5% = 95% 로 끝까지 확장한다.

단 세 개의 플랜으로 내가 밝혀낸 세 가지 규칙이 모두 나타난다.

구글 캘린더의 알고리즘을 이해하기에 이보다 좋은 예시는 없다고 생각한다.

그래서 어떻게 구현하냐

이제 규칙을 알아냈으니, 실제 코드로 옮겨보자.

사실 스텝 자체는 기존 닥팔 플랜 카드 계산식과 다르지 않다.

다만 닥팔의 순서가

  1. 그룹핑
  2. 라인 배정
  3. 위치 계산

였는데, 2번과 3번에서 ‘무엇을 기준으로 삼을것인지’ 가 완전히 달라진 셈이다.

1. 그룹핑

기존과 동일하다. 여전히 탐욕스럽게(Greedy) 겹치면 묶어버리면 된다.

2. 라인 배정

기존의 라인 배정은 먼저 온 놈이 1번 라인을 독차지하고, 겹치는 녀석은 다음 라인으로 가야했다.

철저한 선점방식이다.

바뀐 알고리즘에서는 ‘빈 자리가 생기면 채워넣는’ 방식이다.

가장 낮은 번호에서부터 차례대로 탐색해 플랜이 들어갈 자리를 찾는다.

typescript
// planId를 key로 { col: 컬럼번호, totalCols: 총 컬럼 }을 저장하는 Map
const positionMap = new Map<string, { col: number; totalCols: number }>();

for (const plan of sortedPlans) {
  // 1. 나와 겹치는 '이전 플랜들'이 몇 번 컬럼을 쓰고 있는지 확인한다.
  const takenCols = new Set<number>();
  
  for (const prevPlan of sortedPlans) {
    // 나와 겹치는가? (Overlap Check)
    if (doesOverlap(plan, prevPlan)) {
      const pos = positionMap.get(prevPlan.id);
      if (pos) {
        takenCols.add(pos.col); // "찾았다 내 자리!"
      }
    }
  }

  // 2. 0번부터 돌면서 비어있는 가장 빠른 번호를 내 자리(col)로 찜한다.
  let col = 0;
  while (takenCols.has(col)) {
    col++; // "여기 찼네. 다음 칸 확인..."
  }
  
  // "찾았다 내 자리!"
  positionMap.set(plan.id, { col, totalCols: 0 });
}

3. 위치 계산: 1.7배, 5% 그리고 끝까지

라인(Column) 번호가 정해졌으니, 이제 실제 CSS 값(left, width)을 찍을 차례다.

이 단계에서 앞서 분석한 두 가지 비밀이 모두 적용된다.

3-1. Left Position 결정: 30분이 넘었나?

먼저 내 바로 왼쪽 컬럼에 있는 부모 플랜과, 시작 시간이 얼마나 차이 나는지를 봐야 한다. (30분 초과인지)

typescript
// left position을 계산하고 저장하는 Map (Memoization)
const calculatedLeftMap = new Map<string, number>();

const getLeftPosition = (plan, col, totalCols) => {
  if (calculatedLeftMap.has(plan.id)) {
    return calculatedLeftMap.get(plan.id)!;
  }

  const baseSlotWidth = 1 / totalCols;
  // 기본: Grid 위치
  let left = col * baseSlotWidth; 

  if (col > 0) {
    // 바로 왼쪽 컬럼(col-1)에서, 나와 겹치는 부모 플랜을 찾는다.
    const parentPlan = findOverlappingPlanInColumn(col - 1);

    if (parentPlan) {
			const timeDiffMinutes = ChronoUnit.MINUTES.between(parentPlan.startAt, plan.startAt);

      if (timeDiffMinutes > 30) {
        // 부모의 left에서 5%만 오른쪽으로 밀기
        const parentLeft = getLeftPosition(parentPlan, col - 1, ...);
        left = parentLeft + 0.05;
      }
    }
  }

  calculatedLeftMap.set(plan.id, left);
  return left;
};

여기서 Map은 다들 알겠지만 DP Top-Down 메모이제이션 기법이다.

이미 계산된 left 값을 캐싱해두면 중복 계산을 피할 수 있기 때문이다.

3-2. Width 결정: 오른쪽 컬럼 확인하기

Left가 정해졌으니 Width를 결정할 차례다. 여기서 두 가지 분기가 발생한다.

typescript
// 오른쪽 컬럼에 나와 30분 이내로 겹치는 Blocker 탐색
let expansionStopsAtColumn = pos.totalCols;

for (let k = pos.col + 1; k < pos.totalCols; k++) {
  const hasBlockerInColumnK = sortedPlans.some((otherPlan) => {
    const otherPos = positionMap.get(otherPlan.id);

    const timeDiffMinutes = ChronoUnit.MINUTES.between(p.startAt, otherPlan.startAt);
    return otherPos?.col === k && doesOverlap(p, otherPlan) && timeDiffMinutes <= 30;
  });

  if (hasBlockerInColumnK) {
    expansionStopsAtColumn = k;
    break;
  }
}

  1. 오른쪽에 있다 → 1.7배 확장
    typescript
    // 딱 한 칸만 차지하되, 1.7배로 살짝 밀고 들어간다.
    if (spannedColumns === 1 && expansionStopsAtColumn < totalCols) {
      widthRatio = baseSlotWidth * 1.7;
    }
  2. 아무것도 없다 → 끝까지 확장
    typescript
    if (expansionStopsAtColumn === totalCols) {
      // 100% - left 으로 다 밀어버리기
      widthRatio = 1 - leftOffsetRatio; 
    }

그리고 혹시모를 안전장치를 추가해둔다.

typescript
// 어떤 경우에도 화면 밖으로 삐져나가지 않는다.
if (leftOffsetRatio + widthRatio > 1) {
  widthRatio = 1 - leftOffsetRatio;
}

결과물

Loading image...
Notion Image
Loading image...
Notion Image
Loading image...
Notion Image

Loading image...
Notion Image
Loading image...
Notion Image
Loading image...
Notion Image

Loading image...
Notion Image
Loading image...
Notion Image

끝맺음

디자이너 분의 한 마디에서 시작된 이 여정은 이렇게해서 성공적으로 배포할 수 있었다.

많이 좋아해주셔서 삽질한만큼 뿌듯하고 보람찼다.

구글 캘린더의 세 가지 비밀을 파헤쳐 볼 수 있는 좋은 경험이었다. 정리하면 다음과 같다.

  1. 1.7배 공식: 기계적인 1/N 분할 대신, 텍스트가 조금 더 보일수 있게, 그리고 클릭 미스가 나지 않도록 배려하는 비율
  2. 5% 계단식 밀어내기: 30분 초과인 늦게 시작하는 일정을 계단식으로 쌓아, 시간의 흐름을 시각적으로 드러냄
  3. 빈공간 재활용: 끝난 일정의 빈 컬럼을 재활용 할 수 있게 구현해서 화면 너비를 조금도 낭비하지 않게 하려는 세심함

구글 요소탭을 뜯어보면서 단순한 계산일 줄로만 알았던 수치 뒤에 UX 의도가 보이는 듯 했다.

좋은 UI는 예쁜 것을 넘어, 사용자가 불편함을 느끼지 않는 자연스러움 이다. 원래 구글이 그런걸 잘 하진 않는 회사지만, 이 구글 캘린더는 적어도 참고가 많이 됐다.

이 글이 나처럼 캘린더 UI와 씨름하고 있는 누군가에게 조금이나마 인사이트를 줄 수있기를 바라본다.

  • 개요
  • 기존 플래너의 배치 알고리즘
  • 1. Grouping (그룹핑)
  • 2. Line Allocation (라인 배정)
  • 3. Position Calculation (위치 계산)
  • 구글캘린더는 어떻게 다른데?
  • 상수 1.7 을 곱하기
  • left 5% 의 비밀
  • 위 규칙 두개를 복합적으로 잘 다뤄보자
  • 그래서 어떻게 구현하냐
  • 1. 그룹핑
  • 2. 라인 배정
  • 3. 위치 계산: 1.7배, 5% 그리고 끝까지
  • 결과물
  • 끝맺음