개발 노트/기타

[Android] 외부 검색 API와 내부 관리자 API 구조 분리하기 (Retrofit, DTO, 보안)

pposooj 2026. 5. 22. 14:22

앱 서비스를 개발하고 운영하다 보면 외부 플랫폼 API(지도, 장소 검색, 주소 조회 등)와 내부에서 관리하는 관리자 API(공지사항, 카테고리 설정, 지역별 상태 등)를 함께 연동해야 하는 상황이 자주 생깁니다.

처음에는 진척도를 빨리 내려고 하나의 Retrofit Client에 주소를 대충 섞어 넣고, 응답 모델(DTO)을 화면(UI)까지 바로 꽂아서 쓰곤 합니다. 당장은 잘 돌아가는 것처럼 보이지만, 서비스 규모가 조금만 커져도 유지보수할 때 지옥을 맛보게 됩니다. 외부 API 응답 필드 하나 바뀌었다고 화면 전체가 깨지거나, 어떤 데이터가 외부에서 온 거고 어떤 게 내부 운영 데이터인지 분간이 안 가기 시작하거든요.

나중에 고생하지 않고 장애 범위를 최소화하기 위해, 외부 API와 내부 API의 경계를 명확히 나누는 기준을 정리해 둡니다.

1. 외부 API와 내부 API는 목적부터 다릅니다

두 API는 태생부터 목적과 관리 주체가 완전히 다릅니다. 이걸 하나로 묶으면 운영 관점에서 사이드 이펙트가 너무 커집니다.

구분주요 역할예시 데이터개발 및 운영 시 주의점
외부 검색 API 외부 플랫폼의 데이터 조회 장소명, 주소, 좌표, 이미지 API Key 노출 주의, 호출 제한(Rate Limit), 응답 구조 변경 가능성 대비
내부 관리자 API 서비스 운영 데이터 제공 공지사항, 카테고리, 배너, 지역 설정 관리자 URL 숨김, 권한 검증, 내부 시스템 코드 보안 유지
공공데이터 API 공공기관 공공 데이터 조회 행정 정보, 날씨, 지역별 목록 데이터 데이터 최신성 불일치, 기관 서버 장애 및 오류 응답 예외 처리
 

운영 관점에서는 API마다 Client, DTO, 에러 처리 흐름을 완전히 분리하는 편이 안전합니다. 외부 API가 터졌다고 해서 내부 공지사항이나 앱 기본 설정까지 같이 먹통이 되는 불상사는 막아야 하니까요.

2. API Client는 역할별로 완전히 분리하기

Retrofit을 사용한다면 Service 인터페이스부터 목적에 맞게 찢어놓는 것이 좋습니다. Base URL, 인증 방식, Timeout 정책, 에러 구조가 서로 다를 확률이 100%이기 때문입니다.

  • 외부 검색 API: 검색어, 좌표, 페이지네이션 중심 구조
  • 내부 관리자 API: 앱 설정, 공지사항, 카테고리 상태 중심 구조

Retrofit Service 인터페이스 분리 예시

Kotlin
 
// 외부 검색 API 전용
interface ExternalSearchApi {
    @GET("v1/search/place")
    suspend fun searchPlace(
        @Query("query") keyword: String,
        @Query("page") page: Int
    ): ExternalSearchResponse
}

// 내부 관리자 API 전용
interface AdminApi {
    @GET("api/v1/notices")
    suspend fun getAppNotice(): NoticeResponse

    @GET("api/v1/configs/region")
    suspend fun getRegionConfig(): RegionConfigResponse
}

이렇게 나누면 외부 플랫폼의 응답 필드가 바뀌어도 내부 관리자 API 코드에는 손댈 일이 없습니다. 반대로 관리자 공지사항 구조를 개편할 때 검색 기능을 건드릴 리스크도 사라집니다. 단순한 파일 정리가 아니라 장애 격리(Bulkhead)의 시작입니다.

3. API 응답 모델(DTO)을 UI 화면에 바로 꽂지 않기

API 응답으로 받는 DTO를 ViewModel이나 Compose/XML 레이아웃까지 그대로 가져다 쓰는 분들이 많습니다. 당장은 편하지만, 구조가 바뀌는 순간 화면단 코드까지 연쇄적으로 수정해야 하는 대공사가 터집니다. 특히 외부 API는 우리가 통제할 수 없는 영역이라 언제 응답이 바뀔지 모릅니다.

화면 모델을 분리해서 완충지대를 만들어야 합니다.

모델 종류역할변경 원인화면(UI) 직접 사용 여부
외부/내부 API DTO 서버 응답을 그대로 받아내는 모델 외부 플랫폼 스펙 변경, 서버 DB 구조 변경 절대 비추천. 데이터 파싱 용도로만 사용
Domain Model 앱 내부 비즈니스 로직에 의미 있는 데이터 비즈니스 규칙, 앱 기능 변경시 비즈니스 로직 및 ViewModel에서 주로 사용
UI Model 화면에 그리기 직전의 상태와 텍스트 화면 디자인 및 UX 기획 변경시 강력 추천. 화면(View)은 이것만 바라봄
 

Mapper를 활용한 데이터 변환

중간에 Mapper를 두어 API DTO를 깔끔한 UI Model로 깎아서 넘겨줍니다.

Kotlin
 
// 화면에서 사용할 담백한 모델
data class PlaceUiModel(
    val title: String,
    val subtitle: String,
    val sourceLabel: String
)

// 외부 DTO -> UI 모델 변환 확장함수
fun ExternalSearchResponse.toPlaceUiModel(): PlaceUiModel {
    return PlaceUiModel(
        title = this.placeName.orEmpty(),
        subtitle = this.roadAddressName.orEmpty(),
        sourceLabel = "외부 검색 결과"
    )
}

이렇게 하면 외부 API 필드명이 placeName에서 name으로 바뀌더라도, UI 코드는 건드릴 필요 없이 이 Mapper 함수 내부만 슥 수정하면 끝납니다.

4. API 실패 시 Fallback(대체) 기준 세우기

네트워크 통신은 언제든 실패할 수 있습니다. 하지만 모든 실패를 똑같이 "에러가 발생했습니다"라는 팝업 하나로 퉁치면 사용자 경험(UX)이 망가집니다. API 성격에 맞게 대안을 세워야 합니다.

  • 외부 검색 API 실패 시: * 사용자가 직접 행동(검색 버튼 클릭)을 취한 경우이므로 "네트워크 연결을 확인하고 다시 시도해주세요" 안내 및 재시도 버튼 노출.
  • 내부 공지 API 실패 시:
    • 앱 사용에 치명적이지 않다면 에러 팝업을 띄우기보다 공지사항 영역을 그냥 숨기거나 기본 캐시 데이터(또는 빈 값)를 보여주는 것이 자연스러움.
  • 지역/앱 설정 API 실패 시:
    • 앱 구동에 필수적인 데이터라면 무리하게 잘못된 기본값을 잡았다가 결제나 서비스 오작동으로 이어질 수 있으므로, "필수 설정을 가져오지 못했습니다" 안내 후 진입을 제한하는 편이 안전함.

Timeout도 다르게 가져가야 합니다. 사용자가 기다리는 검색 API는 타임아웃을 타이트하게(예: 5초) 잡고 빠르게 실패 처리를 해주는 게 좋고, 백그라운드에서 도는 설정 API는 조금 더 여유 있게 잡아도 무방합니다.

5. 보안 기준: 클라이언트 노출 위험성 인지하기

Android 앱은 디컴파일이 너무 쉽습니다. 앱 내부에 심어둔 문자열, URL, 토큰은 언제든 유출될 수 있다는 가정하에 움직여야 합니다.

  • 외부 검색 API Key: 정책상 클라이언트에 포함해야 한다면 반드시 해당 플랫폼 콘솔에서 패키지 명 및 SHA-1 인증서 키로 도메인 제한을 걸어두셔야 합니다. 누군가 키를 훔쳐 가도 다른 곳에서 못 쓰게 막아야 하니까요.
  • 내부 관리자 API URL / 토큰: 관리자 권한용 비밀번호나 토큰은 절대 클라이언트 코드에 하드코딩하면 안 됩니다. 마스터 권한 토큰 같은 게 유출되면 DB가 통째로 털릴 수 있습니다. 민감한 어드민 기능은 앱에서 직접 쏘는 게 아니라, 중간에 중계 서버(BFF 등)를 두고 서버 대 서버로 통신하게 설계하는 것이 정석입니다.

권장하는 프로젝트 패키지 구조

작은 규모의 프로젝트라도 아래처럼 패키지 규칙을 잡아두면 눈에 잘 들어옵니다. 무리한 아키텍처 패턴 도입보다 책임을 격리하는 게 우선입니다.

Plaintext
 
data/
  ├── api/
  │    ├── ExternalSearchApi.kt  (외부 검색용 Retrofit Service)
  │    └── AdminApi.kt           (내부 관리자용 Retrofit Service)
  ├── dto/
  │    ├── ExternalSearchResponse.kt
  │    ├── NoticeResponse.kt
  │    └── RegionConfigResponse.kt
  ├── mapper/
  │    ├── SearchResultMapper.kt  (DTO -> UI Model 변환)
  │    └── NoticeMapper.kt
  └── repository/
       ├── SearchRepository.kt
       └── AppConfigRepository.kt
ui/
  ├── search/
  ├── notice/
  └── region/

개발 후 자가 체크리스트 (자주 하는 실수)

  1. [ ] 외부 API와 내부 API가 하나의 Retrofit Client 안에서 엉켜 있지는 않은가?
  2. [ ] 서버에서 내려오는 DTO의 필드명을 UI 레이아웃 소스에서 직접 참조하고 있지는 않은가?
  3. [ ] 외부 API가 죽었을 때 앱 전체 화면이 하얗게 뻗어버리지는 않는가? (예외 처리 여부)
  4. [ ] 소스코드나 local.properties가 아닌 곳에 관리자용 서크릿 토큰이 평문으로 들어가 있지는 않은가?

처음 붙일 때는 파일 몇 개에 다 밀어 넣는 게 빨라 보이지만, 서너 달만 지나도 책임 분리가 안 된 코드는 부메랑이 되어 돌아옵니다. 나중의 나를 위해서, 그리고 함께 작업할 동료를 위해서 처음부터 경계를 명확히 나누고 시작합시다.