안드로이드 애플리케이션 개발 시, 정식 API가 제공되지 않는 레거시 시스템이나 외부 웹사이트의 데이터를 통합해야 하는 요구사항은 빈번하게 발생합니다. 이러한 상황에서 HTML 파싱(Parsing)은 유일한 대안이 될 수 있습니다. 본 문서에서는 Jsoup 라이브러리를 활용한 데이터 추출 파이프라인 구축과, 추출된 이미지 데이터를 Glide를 통해 비동기적으로 렌더링하는 아키텍처를 분석합니다. 특히 메인 스레드(Main Thread) 블로킹 방지를 위한 동시성 처리 모델과 대용량 리스트 렌더링 시 발생하는 성능 이슈에 대한 해결책을 중점적으로 다룹니다.
1. 데이터 파이프라인 아키텍처 설계
웹 크롤링 기반의 앱은 '요청(Request) - 파싱(Parsing) - 렌더링(Rendering)'의 3단계 파이프라인으로 구성됩니다. 이 과정에서 가장 치명적인 안티 패턴은 네트워크 I/O를 UI 스레드에서 직접 수행하는 것입니다. 안드로이드 OS는 메인 스레드가 일정 시간(약 5초) 이상 응답하지 않을 경우 ANR(Application Not Responding)을 발생시킵니다.
따라서, 본 프로젝트는 다음과 같은 계층적 분리를 적용합니다.
- UI Layer (MainActivity): 사용자 인터랙션 처리 및 최종 데이터 렌더링 담당.
- Network Layer (Networker): 별도의 워커 스레드(Worker Thread)에서 Jsoup 연결 및 DOM 탐색 수행.
- Data Model (ImageListModel): 파싱된 데이터를 캡슐화하는 DTO(Data Transfer Object).
최신 안드로이드 개발 환경에서는 Kotlin Coroutines나 RxJava를 표준으로 사용하나, 본 구현에서는 비동기 처리의 원자적 이해를 위해 Java의 Thread와 Interface Callback 패턴을 사용하여 로우 레벨(Low-level)에서의 흐름을 제어합니다.
2. Jsoup을 이용한 정적 HTML 분석 및 파싱
Jsoup은 HTML 문서를 DOM(Document Object Model) 구조로 메모리에 로드하여 jQuery 스타일의 CSS 선택자(Selector)로 데이터를 추출할 수 있게 해줍니다. Pixabay와 같은 이미지 호스팅 사이트는 일반적으로 <div> 컨테이너 내부에 <img> 태그를 중첩시키는 구조를 가집니다.
DOM 탐색 및 데이터 추출 로직
HTML 파싱 시 주의할 점은 웹사이트의 구조 변경 가능성과 예외 처리입니다. 아래 코드는 백그라운드 스레드에서 실행되어야 하며, 파싱 결과는 콜백 인터페이스를 통해 메인 스레드로 전달됩니다.
public void getImageList(final String url, final Callback callback) {
new Thread(() -> {
try {
ArrayList<ImageListModel> resultList = new ArrayList<>();
// 1. Connection 수립 및 DOM 객체 획득 (Blocking I/O)
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0") // User-Agent 설정 필수
.timeout(5000)
.get();
// 2. CSS Selector를 통한 요소 추출
// 실제 DOM 구조에 따라 선택자는 변경되어야 함
Elements elements = doc.select("div.container > div.item img");
for (Element element : elements) {
// 3. 속성(Attribute) 값 추출
String imageUrl = element.attr("src");
String title = element.attr("alt");
// Lazy Loading 대응: src가 비어있거나 placeholder인 경우 data-src 확인
if (imageUrl.contains("blank.gif") || imageUrl.isEmpty()) {
imageUrl = element.attr("data-lazy-src");
}
if (!imageUrl.isEmpty()) {
resultList.add(new ImageListModel(imageUrl, title));
}
}
// 4. 메인 스레드로 결과 마샬링(Marshaling)
new Handler(Looper.getMainLooper()).post(() -> {
callback.onSuccess(resultList);
});
} catch (IOException e) {
new Handler(Looper.getMainLooper()).post(() -> {
callback.onError(e);
});
}
}).start();
}
최신 웹사이트들은 초기 로딩 속도 최적화를 위해 이미지를 뷰포트에 진입할 때 로드하는 Lazy Loading을 사용합니다. 이 경우
src 속성에는 플레이스홀더 이미지만 있고 실제 URL은 data-src 등의 커스텀 속성에 숨겨져 있으므로 반드시 개발자 도구로 속성값을 확인해야 합니다.
3. RecyclerView 성능 최적화 전략
수백 개 이상의 이미지 아이템을 리스트 형태로 표시할 때 ListView 대신 RecyclerView를 사용하는 이유는 뷰 재사용(View Recycling) 메커니즘 때문입니다. 하지만 Adapter와 ViewHolder를 잘못 구현하면 스크롤 시 끊김 현상(Jank)이 발생할 수 있습니다.
이벤트 리스너 바인딩의 위치
가장 흔한 실수 중 하나는 onBindViewHolder 내부에서 setOnClickListener를 호출하는 것입니다. onBindViewHolder는 스크롤 할 때마다 빈번하게 호출되므로, 여기서 리스너 객체를 매번 생성하는 것은 불필요한 메모리 할당(Garbage Collection 유발)을 초래합니다.
권장 패턴: 리스너는 ViewHolder가 생성되는 시점(생성자)에 단 한 번만 연결해야 합니다.
public class ImageViewHolder extends RecyclerView.ViewHolder {
ImageView imageView;
TextView titleView;
public ImageViewHolder(@NonNull View itemView, final OnItemClickListener listener) {
super(itemView);
imageView = itemView.findViewById(R.id.img_view);
titleView = itemView.findViewById(R.id.txt_title);
// 최적화: 뷰 홀더 생성 시점에 클릭 리스너 바인딩
itemView.setOnClickListener(v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION && listener != null) {
listener.onItemClick(position);
}
});
}
}
4. Glide를 활용한 이미지 캐싱 및 메모리 관리
이미지 로딩은 안드로이드 앱에서 가장 메모리를 많이 소모하는 작업 중 하나입니다. 원본 이미지를 그대로 로드하면 OutOfMemoryError(OOM)가 발생할 위험이 높습니다. Glide는 내부적으로 다음 과정을 통해 이를 방지합니다.
| 기능 | 설명 | 이점 |
|---|---|---|
| Downsampling | ImageView 크기에 맞춰 이미지를 리사이징하여 디코딩 | 메모리 사용량 최소화 |
| Memory Cache | 자주 사용하는 리소스를 RAM(LruCache)에 저장 | UI 렌더링 속도 즉시 반영 |
| Disk Cache | 다운로드된 이미지를 로컬 스토리지에 저장 | 네트워크 트래픽 절약 및 오프라인 지원 |
Glide 구현 및 옵션 적용
네트워크 상태가 불안정하거나 이미지 로딩에 실패했을 때의 UX를 고려하여 placeholder와 error 이미지를 반드시 설정해야 합니다.
@Override
public void onBindViewHolder(@NonNull ImageViewHolder holder, int position) {
ImageListModel item = dataList.get(position);
Glide.with(context)
.load(item.getImageUrl())
.placeholder(R.drawable.bg_loading_placeholder) // 로딩 중 표시할 드로어블
.error(R.drawable.bg_error_image) // 실패 시 표시할 드로어블
.diskCacheStrategy(DiskCacheStrategy.ALL) // 원본 및 리사이징된 이미지 모두 캐싱
.override(Target.SIZE_ORIGINAL) // 필요 시 사이즈 강제 지정
.into(holder.imageView);
holder.titleView.setText(item.getTitle());
}
5. 생명주기(Lifecycle) 이슈와 아키텍처 한계
화면 회전 시 데이터 손실 방지
기본적으로 안드로이드는 화면 회전(Configuration Change) 시 Activity를 파괴하고 재생성합니다. 이 과정에서 메모리에 있던 데이터 리스트가 초기화됩니다. 본 예제에서는 AndroidManifest.xml에 configChanges 속성을 추가하여 Activity 재생성을 막는 방법을 사용했습니다.
<activity android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden">
</activity>
configChanges를 사용하는 것은 임시방편(Band-aid fix)에 가깝습니다. 리소스 한정자(예: 가로 모드 전용 레이아웃)를 사용할 수 없게 되기 때문입니다. 프로덕션 레벨에서는 Jetpack ViewModel을 사용하여 UI 데이터 상태를 수명 주기와 분리하여 관리하는 것이 표준입니다.
정적 파서(Static Parser)의 한계
Jsoup은 브라우저가 아닙니다. 자바스크립트 엔진이 없기 때문에 SPA(Single Page Application)나 CSR(Client Side Rendering)로 동작하는 웹사이트의 데이터는 가져올 수 없습니다. 만약 대상 사이트가 자바스크립트 실행 후에만 DOM을 생성한다면, Jsoup 대신 Selenium과 같은 Headless Browser를 활용하거나, 네트워크 패킷을 분석하여 내부 API 엔드포인트를 직접 호출하는 방식으로 전략을 수정해야 합니다.
결론
본 구현을 통해 안드로이드에서의 비동기 네트워크 처리와 대용량 이미지 렌더링의 기본 패턴을 확인했습니다. Jsoup을 이용한 크롤링은 API가 없는 환경에서 강력한 도구이지만, 웹사이트의 구조 변경에 취약하므로 예외 처리가 필수적입니다. 또한, 앱의 안정성을 위해 Thread 관리와 View 재활용 패턴을 엄격히 준수해야 하며, 장기적으로는 Coroutines와 ViewModel을 도입하여 유지보수성을 높이는 방향으로 고도화해야 합니다.
Post a Comment