관광 앱 상세 화면 만들 때 놓치는 데이터 검증과 정보 구조 설계 기준

제주에서 이런저런 가이드 앱이나 유틸리티 서비스를 만들고 운영하다 보면, 결국 가장 손이 많이 가고 컴플레인이 자주 들어오는 곳이 바로 상세 화면(Detail View)입니다.

처음에는 공공데이터 API 땡겨와서 응답 나오는 대로 대충 뿌려주면 끝날 줄 알았는데, 실제 운영해보면 데이터가 대화면에 찍히는 것과 사용자가 그걸 믿고 실제 방문 결정을 내리는 것은 완전히 다른 문제더군요. 공공데이터는 설명문, 이미지, 운영 시간의 출처가 제각각이거나 갱신 주기가 달라서 관리를 까먹으면 바로 버그성 화면이 되기 십상입니다.

나중에 제가 다시 개발할 때 참고하려고 상세 화면의 정보 구조 설계, 데이터 기준일 처리, HTML 정제, 보안 체크리스트까지 실무 기준으로 짧고 명확하게 정리해 둡니다.

1. 내가 겪은 문제: API 응답을 그대로 나열할 때의 한계

공공데이터나 외부 API 응답 필드를 아무 생각 없이 데이터 바인딩해서 UI에 그대로 때려 넣으면 아래와 같은 문제가 무조건 터집니다.

  • 사용자가 진짜 지금 보고 싶어 하는 정보(운영 여부, 주소, 전화번호)가 긴 소개글에 묻힘.

  • 데이터가 비어 있을 때(Null) 화면이 툭 끊기거나 뜬금없는 공백이 생김.

  • 공공데이터의 기괴한 HTML 태그(<br>, &gt; 등)가 화면을 깨뜨리거나 웹뷰 보안 취약점을 만듦.

  • 공개하면 안 되는 내부 API URL, 지도 API Key, 사용자 좌표가 로그나 소스코드에 섞여 들어감.

결국 상세 화면은 단순한 '데이터 표시 창'이 아니라, 사용자가 방문 판단을 내리는 신뢰 화면으로 접근해야 구조가 안정적으로 잡힙니다.

2. 해결 방법 1: 화면 전용 UI 모델(UiModel) 분리

API 응답 모델(DTO)을 Activity나 Fragment, 또는 Compose 스크린에 그대로 던지지 마세요. 무조건 화면 표시용 독립 모델을 따로 파서 파싱하는 게 유지보수에 정신 건강을 이롭게 합니다.

Kotlin
data class TourDetailUiModel(
    val placeId: String,
    val title: String,
    val categoryLabel: String,
    val heroImageUrl: String?,
    val address: String?,
    val phoneNumber: String?,
    val openingHours: String?,
    val description: String?,
    val latitude: Double?,
    val longitude: Double?,
    val sourceLabel: String,
    val sourceUpdatedAt: String?,
    val imageSourceLabel: String?,
    val isStale: Boolean, // 캐시된 오래된 데이터인지 여부
)

이렇게 화면에 딱 필요한 값만 정제해서 넘겨야 내부 필드가 UI 레이어에 꼬이지 않고, 나중에 디자인이 엎어져도 대응하기 편합니다.

3. 정보의 우선순위와 출처 매핑 표준화

화면을 짤 때는 사용자의 시선 흐름에 맞춰 정보 영역의 체계를 명확히 쪼개야 합니다. 저의 경우 아래 기준으로 영역을 나누어 배치합니다.

정보 영역별 우선순위 기준

정보 영역예시 데이터표시 우선순위
핵심 식별 정보장소명, 카테고리, 대표 이미지높음 (상단 고정)
방문 결정 정보주소, 운영 시간, 전화번호, 휴무일높음
이동/액션 정보지도, 길찾기 버튼, 전화 걸기높음
설명 정보상세 소개문, 이용 안내, 태그중간 (접기/펼치기 제안)
신뢰 정보데이터 제공 출처, 마지막 업데이트일중간 (하단 배치)
부가 기능즐겨찾기(북마크), 공유하기상황별 배치

데이터 성격에 따른 출처 분리

동일한 상세 화면이라도 데이터 마다 출처와 라이선스가 다를 수 있으므로 관리 기준을 세워야 합니다.

  • 장소명/주소: 공공데이터 API 기준 (제공 기관 및 데이터 기준일 명시)

  • 대표 이미지: 공공데이터 또는 자체 CDN (저작권 라이선스 타입 필수 확인)

  • 운영 시간: 자체 서버 또는 사용자 제보 연동 (최신성 안내 팝업이나 문구 고려)

  • 위치 좌표: 공공데이터 기반 정보이되, 지도 API 맵 매칭 오차 안내 포함

4. 실무 적용 코드: HTML 정제와 오프라인 캐시 처리

① HTML 설명문 변환 처리

공공데이터 설명문은 관리자가 통째로 긁어 넣은 경우가 많아 추잡한 HTML 태그가 자주 섞여 있습니다. 웹뷰에 그냥 넣으면 보안상 위험하니 Plain Text로 밀어버리거나 Allowlist 방식으로 걷어내야 합니다.

Kotlin
class TourDescriptionFormatter {
    fun format(raw: String?): String {
        if (raw.isNullOrBlank()) return "소개 정보가 준비되어 있지 않습니다."

        // 최소한의 줄바꿈 태그만 살리고머지 태그는 정규식으로 제거
        return raw
            .replace(Regex("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
            .replace(Regex("<[^>]+>"), "")
            .trim()
            .take(2000) // 텍스트 오버플로우 방지용 길이 제한
    }
}

② 네트워크 에러 대비 Repository 설계

산간 지역이나 바닷가 근처 등 네트워크가 튀는 곳에서 앱이 뻗으면 안 됩니다. 로컬 디비(Room 등) 캐시를 먼저 보고, 서버 조회가 터지면 캐시 데이터를 보여주되 '오래된 데이터'임을 UI에 넌지시 찔러주는 구조가 좋습니다.

Kotlin
class TourDetailRepository(
    private val localDataSource: TourDetailLocalDataSource,
    private val remoteDataSource: TourDetailRemoteDataSource,
) {
    suspend fun getDetail(placeId: String): TourDetailUiModel {
        val cached = localDataSource.getDetail(placeId)

        return runCatching {
            val remote = remoteDataSource.fetchDetail(placeId)
            localDataSource.saveDetail(remote) // 최신 데이터 로컬 갱신
            remote.toUiModel(isStale = false)
        }.getOrElse {
            // 네트워크 에러 시 캐시 데이터 반환 (isStale = true)
            cached?.toUiModel(isStale = true) 
                ?: TourDetailUiModel.empty(placeId) // 완전히 없으면 빈 Fallback 모델
        }
    }
}

5. 데이터 누락(Fallback) 및 액션 버튼 대응

공공데이터를 다루다 보면 특정 장소에 전화번호가 없거나 주소가 누락된 경우가 허다합니다. 이럴 때 UI가 깨지지 않도록 막아주는 방어 조치가 필요합니다.

데이터 상태권한 및 UI 처리 기준
주소 없음지도 영역 숨김 처리 + “주소 정보가 준비되어 있지 않습니다.” 안내 문구 노출
전화번호 없음전화 걸기 버튼 비활성화(Disabled) 또는 숨김 처리
운영 시간 없음빈칸으로 두지 말고 “운영 정보는 방문 전 확인이 필요합니다.” 공통 안내 문구 고정
이미지 없음앱 내 고유 패키지에 포함된 Default Placeholder 이미지 강제 매핑
길찾기 액션위/경도 존재할 때만 버튼 활성화. 클릭 시 네이버/카카오/티맵 외부 인텐트 Fallback 처리

주의: 전화 걸기 인텐트를 넘길 때, 단순 다이얼 화면(Intent.ACTION_DIAL)으로 넘기면 유저가 전화를 걸지 말지 판단할 수 있으므로 굳이 런타임 통화 권한을 요구하지 않아도 되어 편리합니다.

6. 보안 및 운영 실무 체크리스트

앱 배포하기 전에 아래 리스트는 무조건 하드코딩 되어 있는지 검사하셔야 합니다. 의외로 깃허브나 블로그에 소스 올리다가 털리는 경우가 많습니다.

  • [ ] 상세 화면 전용 UI 모델을 API 응답 DTO와 엄격하게 분리했는가?

  • [ ] 공공데이터 제공 기관명과 데이터 기준일(업데이트 날짜)이 화면 하단에 찍히는가?

  • [ ] 설명문 렌더링 시 외부 스크립트가 실행될 여지가 차단되었는가? (Plain text 변환 완료 여부)

  • [ ] 디버그 로그(Logcat) 및 서버 전송 로그에 사용자의 실시간 현재 위치(GPS) 좌표를 무단으로 남기지 않는가?

  • [ ] 소스코드 내에 실제 공공데이터 인증키, 지도 API 키가 노출되어 있지 않은가? (local.properties나 환경변수 관리 필수)

  • [ ] 공유하기 기능을 탈 때 URL 파라미터에 유저 세션 토큰이나 개인 식별자(UID)가 포함되지 않는가?

초보 개발자가 자주 하는 실수 3가지

  1. "API 필드 통째로 화면에 그리기"

    개발자가 편하자고 서버 스펙 그대로 UI 바인딩했다가 나중에 서버 필드명 하나 바뀌면 앱 전체 화면이 터집니다. 반드시 UI Model 매퍼를 거치세요.

  2. "출처 표시를 세팅 화면에 몰아넣기"

    각 장소나 이미지마다 저작권 조건(공공누리 유형 등)이 다를 수 있습니다. 개별 상세 화면 하단에 명확히 명시해두어야 나중에 저작권 관련 클레임 소지가 없습니다.

  3. "데이터 누락 시 무조건 View.GONE 처리하기"

    중요한 핵심 정보(예: 운영 시간)를 데이터가 없다고 그냥 숨겨버리면 사용자는 24시간 연중무휴인 줄 알고 찾아갔다가 낭패를 봅니다. 차라리 정보가 없다는 사실을 명시해 주는 편이 신뢰도를 높입니다.

마치며

상세 화면의 본질은 "화려하고 이쁘게 보여주는 것"보다 "사용자가 낙오하지 않고 목적지까지 안전하게 갈 수 있게 신뢰를 주는 것"에 있습니다.

처음 구조 잡을 때 조금 귀찮더라도 모델 분리하고 정제 클래스 하나 구현해두면, 나중에 데이터 소스가 공공데이터에서 자체 서버 DB로 확장되더라도 UI 코드 한 줄 안 건드리고 유연하게 대응할 수 있으니 꼭 적용해 보시길 바랍니다.

댓글

이 블로그의 인기 게시물

안드로이드 화면 꺼짐 방지 FLAG_KEEP_SCREEN_ON 및 WakeLock 적용 방법

안드로이드 백그라운드 타이머 구현 및 Foreground Service 알림 설계 정리

관광 앱 다국어 데이터 모델 및 검색 색인 설계 (자꾸 까먹어서 정리)