flex 1이 약속을 못 지킬 때
flex 1로 반씩 나누는데 긴 탭바 하나에 양쪽이 다 밀려나는 순간이 있어요.
화면을 1대 1로 가르는 듀얼 뷰를 짜고 있었어요. 양쪽에 flex: 1만 주면 끝날 줄 알았죠. 짧은 텍스트를 넣었을 때는 멀쩡했는데, 왼쪽에 긴 탭바를 넣는 순간 양쪽이 같이 화면 밖으로 밀려났어요. flex: 1은 남는 공간을 어떻게 나눌지만 정하고, 얼마나 작아질 수 있는지의 하한선은 min-width가 따로 쥐고 있거든요.
짧은 텍스트는 되고 긴 탭바는 터진다
flex: 1은 세 속성을 한 번에 쓴 단축 표기예요. flex-grow: 1, flex-shrink: 1, flex-basis: 0. 기준 크기를 0으로 두고 출발하니까 컨테이너 전체가 남는 공간이 되고, 그걸 둘이 반씩 나눠 가져요. 여기까지는 기대한 그대로예요.
함정은 flex-shrink: 1에 있어요. 줄어들 수 있다고 선언은 했는데, 어디까지 줄어들 수 있는지는 이 값이 정하지 않아요. MDN은 그 바닥을 이렇게 못 박아요.
"Small items will not shrink to less than their min-content size, which is the smallest size the element can be if it used all the available soft wrapping opportunities." - MDN
줄바꿈이 가능한 자리를 전부 써먹고도 더는 못 줄이는 크기, 그게 min-content 예요. flex-shrink 값을 아무리 키워도 이 바닥을 뚫고 내려가지는 않아요.
아래 데모에서 버튼을 눌러보세요. 짧은 텍스트일 때는 두 패널이 점선 안에 얌전히 들어가는데, 긴 탭바로 바꾸는 순간 점선을 뚫고 나가요.
min-width의 기본값은 0이 아니다
여기서 대부분이 한 번 놀라요. min-width의 초기값은 0이 아니라 auto거든요.
평소에 이 사실이 안 보이는 이유는, 일반 블록 박스에서는 auto가 그냥 0으로 풀리기 때문이에요. CSS Sizing 명세도 auto는 자동 최소 크기를 뜻하지만 레이아웃 모듈이 따로 정의하지 않는 한 사용값은 0이라고 적어뒀어요. 그리고 flexbox가 바로 그 "따로 정의한" 모듈입니다.
"In general, the automatic minimum size of a flex item is the smaller of its specified size suggestion and its content size suggestion." - W3C CSS Flexbox Level 1
flex 아이템에서 min-width: auto는 0이 아니라 콘텐츠가 요구하는 최소 크기로 해석돼요. width 같은 명시 크기가 없으면 남는 건 콘텐츠 쪽 값, 즉 min-content 하나예요.
그래서 min-width: 0을 준다는 건 새로운 제약을 거는 게 아니에요. 원래 다른 곳에서는 0이던 기본값을 flex 아이템에서도 0으로 되돌리는 일에 가깝죠. 아래에서 체크박스를 켜보면 1대 1 비율이 곧바로 돌아와요.
체크박스를 켜도 탭은 여전히 패널 밖으로 삐져나와요. 박스 크기를 되돌린 것과 넘치는 콘텐츠를 처리한 것은 서로 다른 일이거든요. 후자는 마지막 절에서 이어서 다뤄요.
왜 한쪽이 아니라 둘 다 밀려났나
제가 가장 이해가 안 갔던 부분이 이거였어요. 긴 탭바는 왼쪽 패널에만 넣었는데, 왜 멀쩡한 오른쪽까지 같이 밀려났을까요.
flex의 공간 분배는 한 번에 끝나는 계산이 아니라 루프거든요. 나눠주고, 각 아이템의 최소·최대 제약을 넘겼는지 검사하고, 넘긴 아이템은 그 값으로 고정해서 분배 대상에서 빼내고, 남은 공간을 나머지끼리 다시 나눠요. 컨테이너가 1000px일 때 실제로 무슨 일이 벌어지는지 따라가 볼게요.
남는 공간을 반씩 나눈다
flex-basis 가 0 이라 1000px 전체가 나눠줄 공간이에요. 왼쪽 500px, 오른쪽 500px 로 출발합니다.
최소 크기 제약을 검사한다
왼쪽 탭바는 줄바꿈이 안 돼서 min-content 가 960px 이에요. 배정받은 500px 은 이 하한선을 아래로 뚫었죠.
위반한 아이템을 고정해서 빼낸다
왼쪽은 960px 에 고정되고 이후 분배에서 빠져요. 남는 공간은 1000 에서 960 을 뺀 40px 뿐입니다.
나머지끼리 다시 나눈다
오른쪽 몫은 500px 이 아니라 40px 이 돼요. 오른쪽도 자기 min-content 가 300px 이면 거기서 또 멈추고, 합계 1260px 이 컨테이너를 뚫습니다.
순서가 중요해요. 1대 1이 먼저 깨지고, 넘치는 건 그 다음이에요. 화면이 밀려나기 전에 이미 비율은 무너져 있었던 거죠. DevTools에서 "왜 500px이 아니라 40px이지" 싶은 숫자를 봤다면 그 아이템은 피해자 쪽입니다.
탭바가 유독 잘 터지는 이유
짧은 텍스트는 되고 탭바만 터진 것도 우연이 아니에요. min-content 크기는 콘텐츠가 줄바꿈을 얼마나 허용하느냐에 따라 완전히 달라지거든요.
평범한 한국어 문단은 어절마다 줄바꿈 기회가 널려 있어서 최소 폭이 가장 긴 단어 하나 수준이에요. white-space: nowrap을 걸면 그 기회가 통째로 사라지니까 문장 전체 길이가 최소 폭이 되고요. 탭바는 여기서 한 단계 더 나가요. 자기도 display: flex인데 자식 탭마다 nowrap이 걸려 있으니, 최소 폭이 탭 너비의 총합이 돼버려요. 탭 8개에 각 120px이면 그 패널은 960px 아래로는 절대 안 줄어드는 박스인 거예요.
세 박스에 똑같이 width: min-content를 줬는데 폭이 전혀 다르죠. 줄바꿈 기회를 어떤 값으로 얼마나 열고 닫는지는 word-break 편에서 따로 다뤘어요.
고쳐야 할 자리는 한 군데가 아니다
가장 직접적인 처방은 min-width: 0이에요. 다만 넘침이 시작되는 아이템 하나에만 걸면 부족한 경우가 많아요. 듀얼 뷰 컨테이너 자체가 바깥쪽 flex의 아이템이라면 그 컨테이너에도 똑같은 자동 최소 크기가 걸려 있거든요. 넘치는 지점에서 위로 올라가면서 flex 아이템인 조상마다 풀어줘야 화면 밖으로 새어나가는 게 멈춰요.
overflow: hidden이나 auto를 걸었더니 해결됐던 경험이 있다면, 그것도 같은 규칙의 다른 출구예요. 아이템이 스크롤 컨테이너가 되면 자동 최소 크기가 다시 0으로 돌아가도록 명세에 예외가 박혀 있거든요. 의도를 드러내는 쪽은 min-width: 0이에요. overflow는 부수효과로 최소 크기를 푸는 것이라, position: sticky 자식이나 바깥으로 나가야 할 드롭다운까지 같이 잘려나가요. 크기 제약을 푸는 일은 min-width에, 넘치는 콘텐츠를 어떻게 보여줄지는 overflow에 맡기는 편이 나중에 덜 헷갈려요.
Grid로 짜면 이 함정이 1fr에 숨어 있어요. CSS Grid 명세는 minmax() 밖에 홀로 쓰인 <flex> 값이 자동 최소를 암시한다고, 즉 1fr이 사실은 minmax(auto, 1fr)이라고 정의해요. 그 auto가 flex 아이템에서와 똑같이 콘텐츠 기반 최소 크기로 풀리고요. 그래서 Grid에서 진짜 1대 1을 원하면 minmax(0, 1fr)이라고 적어야 해요. Flex와 Grid 중 어느 쪽이 이 레이아웃에 맞는지가 먼저 궁금하다면 Flexbox만으로는 부족하다 쪽을 먼저 보셔도 좋아요.
세로 방향도 같아요. flex-direction: column이면 주축이 세로라서 같은 규칙이 min-height: auto로 나타나요. 세로로 넘치고 있다면 찾아야 할 건 min-height: 0이에요.
다음은 처음의 듀얼 뷰를 고친 결과예요. 패널에 min-width: 0을 주고, 탭바를 감싼 래퍼에 overflow-x: auto를 얹었어요. 비율은 1대 1로 돌아왔고 넘치는 탭은 패널 안에서 스크롤돼요.
레이아웃이 화면을 뚫고 나갈 때 확인할 것
☐ 넘치는 요소부터 위로 올라가며 Computed 패널의 min-width 가 auto 인 flex 아이템 찾기
☐ 그 아이템의 사용 너비가 콘텐츠 최소 폭과 같은지 대조하기
☐ 스크롤 컨테이너로 만들 자리와 min-width: 0 만 줄 자리를 나누기
☐ Grid 라면 1fr 을 minmax(0, 1fr) 로 바꿔보기
☐ 세로로 넘친다면 같은 검사를 min-height 로 반복하기
정리하면 축은 하나예요. flex: 1은 비율을 정하고, 그 비율이 실제로 지켜질 수 있는 하한선은 min-width가 정해요. 두 값이 충돌하면 언제나 하한선이 이기고, 그 결과가 화면을 뚫고 나오는 콘텐츠로 보이는 거죠. 그래서 넘침을 만났을 때 던질 첫 질문은 "얼마나 줄어들어야 하지"가 아니라 "지금 이 박스가 줄어들 수 있는 바닥이 어디지"예요.
자주 묻는 질문
자주 묻는 질문
답을 펼치기 전에 스스로 답해보세요
참고 자료
- W3C - CSS Flexible Box Layout Module Level 1
flex 아이템의 자동 최소 크기 정의와 공간 분배 루프에서 제약 위반을 처리하는 방식
- MDN - CSS
min-width초기값이 auto 라는 사실과, 레이아웃 모드별로 auto 가 다르게 풀리는 조건
- MDN - Controlling ratios of flex items along the main axis
flex 단축 표기의 해석과 min-content 아래로는 줄지 않는다는 설명
- W3C - CSS Box Sizing Module Level 3
min-content 크기의 정의와, auto 가 기본적으로 0 으로 풀린다는 규정
- W3C - CSS Grid Layout Module Level 1
minmax() 밖의 1fr 이 minmax(auto, 1fr) 로 동작한다는 부분