본문 바로가기
Projects/FineByMe

[Android/Kotlin] BaseViewModel을 걷어내고 원본 비율을 살린 Masonry 레이아웃 개선기

by quessr 2025. 4. 14.

 

Pinterest 스타일의 UI를 구현하기 위해, 저는 처음에 각 이미지 카드의 높이를 랜덤하게 설정하는 방식으로 StaggeredGrid를 구성했습니다.
이때는 ViewModel에 높이 계산 로직을 두고 position을 기준으로 랜덤한 값을 생성해서 캐싱했으며, 이를 여러 Fragment에서 공통으로 사용하기 위해 BaseViewModel을 상속받는 구조를 사용했었습니다.

하지만 프로젝트의 주요 UI를 Jetpack Compose로 마이그레이션하면서,
사진 콘텐츠를 더 자연스럽고 의미 있게 표현하기 위한 Masonry 스타일의 본질을 다시 생각하게 되었고, 그 결과 ViewModel 기반 구조는 제거하고 확장 함수를 활용한 방향으로 리팩터링하게 되었습니다.


문제점: 랜덤한 높이는 시각적 흥미를 줄 수 있지만 Masonry의 장점을 살리지 못함

처음엔 랜덤한 높이를 부여하면 Pinterest처럼 유동적인 레이아웃이 만들어지고, 다양한 높이의 카드가 배치되면서 시각적인 흥미를 유도할 수 있다는 장점이 있었습니다.

하지만 랜덤 값은 이미지의 실제 크기와는 무관하기 때문에

  • 이미지가 잘려서 중요한 부분이 안 보일 수 있고
  • 사진 비율이 왜곡되어 의도한 느낌과 다르게 보이며
  • 무엇보다 Masonry 레이아웃의 본질인 "원본 비율에 맞춘 자연스러운 정렬"이라는 장점을 살릴 수 없었습니다.

그래서 이 구조는 개선이 필요하다고 판단했습니다.


개선 방향: 확장 함수로 옮긴 높이 계산, ViewModel 제거

Compose 도입과 함께 높이 계산 로직을 ViewModel에서 분리하여
Photo 모델 자체의 확장 함수로 위임하였습니다.

Photo 모델 + 확장 함수

@Parcelize
data class Photo(
    val id: String,
    val title: String,
    val description: String?,
    val fullUrl: String,
    val thumbUrl: String,
    val width: Int,
    val height: Int
) : Parcelable

fun Photo.calculateHeight(itemWidth: Int): Int {
    val aspectRatio = height.toFloat() / width
    return (itemWidth * aspectRatio).toInt()
}

 

이 확장 함수는 Photo 객체가 가진 원본 이미지의 크기를 바탕으로,
그리드에서 사용할 itemWidth에 맞춰 정확한 높이를 계산해줍니다.


Compose로 구현한 Masonry 스타일

LazyVerticalStaggeredGrid를 활용하여
이미지의 원본 비율을 반영한 Masonry 레이아웃을 다음과 같이 구성했습니다.

@Composable
fun PhotoStaggeredGrid(
    photos: List<Photo>,
    onPhotoClick: (Photo) -> Unit
) {
    val density = LocalDensity.current
    val screenWidth = LocalConfiguration.current.screenWidthDp.dp
    val itemSpacing = 4.dp * 3
    val itemWidthDp = (screenWidth - itemSpacing) / 2
    val itemWidthPx = with(density) { itemWidthDp.toPx() }.toInt()

    LazyVerticalStaggeredGrid(
        columns = StaggeredGridCells.Fixed(2),
        contentPadding = PaddingValues(4.dp),
        horizontalArrangement = Arrangement.spacedBy(4.dp),
        verticalItemSpacing = 4.dp
    ) {
        items(photos) { photo ->
            val heightPx = photo.calculateHeight(itemWidthPx)

            PhotoItem(
                photo = photo,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(with(density) { heightPx.toDp() })
                    .clip(RoundedCornerShape(8.dp)),
                onClick = { onPhotoClick(photo) }
            )
        }
    }
}

BaseViewModel 제거

기존에 랜덤 높이를 계산하고 캐싱하던 BaseViewModel은 이제 더 이상 필요하지 않게 되었습니다.

  • PhotoListFragment, FavoriteListFragment 등에서 불필요하게 상속할 필요가 없어졌고
  • UI 로직과 ViewModel의 역할이 분리되어 코드의 의도 전달력과 유지보수성이 더 좋아졌습니다

결과 정리

전 (before) 후 (after)
랜덤한 높이로 유사 Pinterest 스타일 구현 원본 비율을 반영한 진짜 Masonry 구현
BaseViewModel에서 position 기반 height 캐싱 Photo 확장 함수로 height 계산을 모델로 위임
이미지가 잘릴 수 있고 비율이 왜곡됨 이미지 전체가 자연스럽게 노출되고 안정적임
ViewModel 상속 구조 ViewModel 없이도 더 깔끔한 컴포저블 구성

이번 리팩터링을 통해, 단순히 UI를 구현하는 것에서 벗어나
콘텐츠의 본질을 더 잘 전달할 수 있는 방식으로 접근하는 것이 중요하다는 걸 다시 한 번 느꼈습니다.

Masonry 레이아웃은 단순히 높이가 다르면 되는 게 아니라,
이미지의 원본 비율을 존중해 콘텐츠를 온전히 보여주는 방식이라는 점에서
그 레이아웃을 왜 써야 하는지, 어떤 의미가 있는지를 되새겨볼 수 있는 계기가 되었습니다.


결과 화면

 

 

 

반응형