오늘날 안드로이드 애플리케이션 개발은 단순히 기능 구현을 넘어, 외부 데이터와 어떻게 효과적으로 연동하고 사용자에게 보여주는지가 중요한 과제가 되었습니다. 특히 웹에 있는 수많은 데이터를 앱으로 가져와 활용하는 기술, 즉 웹 크롤링 또는 웹 스크래핑은 매우 유용한 기술 중 하나입니다. 이 글에서는 자바(Java) 기반의 HTML 파서 라이브러리인 Jsoup과 이미지 로딩 라이브러리 Glide를 사용하여 특정 웹사이트의 이미지를 추출하고, 이를 안드로이드 앱에서 보여주는 프로젝트의 전 과정을 상세히 다루고자 합니다.
이 프로젝트는 본래 한 회사의 기술 면접 과제로 시작되었습니다. 초기 요구사항은 특정 상업 사이트의 정보를 가져오는 것이었지만, 저작권 및 웹사이트 이용 약관 준수의 중요성을 고려하여, 개발자 및 창작자에게 친화적인 무료 이미지 제공 사이트인 Pixabay를 대상으로 프로젝트를 수정하여 진행했습니다. 이를 통해 법적, 윤리적 문제를 회피하면서도 웹 크롤링의 핵심 기술을 학습하고 적용하는 데 집중할 수 있었습니다.
이 글을 통해 독자 여러분은 다음과 같은 핵심 기술과 개념을 심도 있게 이해하게 될 것입니다.
- 웹 크롤링의 기본 원리와 Jsoup을 이용한 실제 HTML 파싱 방법
- 안드로이드 환경에서의 비동기 네트워킹 처리의 중요성과 콜백(Callback) 패턴 구현
- 대용량 데이터를 효율적으로 목록에 표시하기 위한 RecyclerView의 구조와 최적화 기법
- Glide 라이브러리를 활용한 스마트한 이미지 로딩 및 캐싱 전략
- 화면 회전과 같은 안드로이드 생명주기 이벤트에 대응하는 다양한 방법
단순한 코드 나열을 넘어, 각 기술이 왜 필요하며 어떤 원리로 동작하는지, 그리고 실제 프로젝트에서 마주할 수 있는 문제점(예: Lazy Loading)과 그에 대한 고민까지 함께 나누고자 합니다. 이 글이 안드로이드 개발자로서 한 단계 더 성장하고자 하는 분들에게 실질적인 도움이 되기를 바랍니다.
프로젝트 아키텍처 및 핵심 구성 요소
본격적인 개발에 앞서 프로젝트의 전체적인 구조와 데이터 흐름을 이해하는 것은 매우 중요합니다. 이 앱은 비교적 간단한 구조를 가지고 있지만, 각 구성 요소가 명확한 책임을 가지도록 설계되었습니다. 이를 통해 코드의 유지보수성과 확장성을 높일 수 있습니다.

프로젝트의 주요 흐름은 다음과 같은 세 단계로 요약할 수 있습니다.
- UI 컨트롤러 (MainActivity)의 요청:
- 사용자가 앱을 실행하면
MainActivity
가 화면에 표시됩니다. MainActivity
는 화면에 이미지 목록을 표시하기 위해 필요한 데이터(이미지 URL 등)를 얻고자, 네트워킹을 전담하는Networker
클래스에게 데이터 요청을 보냅니다.- 이때 중요한 점은,
MainActivity
자신을 콜백(Callback) 인터페이스의 구현체로 함께 전달한다는 것입니다. 이는 비동기 작업이 완료된 후 결과를 돌려받기 위한 약속입니다.
- 사용자가 앱을 실행하면
- 백그라운드 작업 (Networker)의 처리:
Networker
클래스는MainActivity
로부터 요청을 받으면, 안드로이드의 메인 스레드가 아닌 별도의 백그라운드 스레드에서 작업을 시작합니다. (메인 스레드에서 네트워크 통신을 시도하면 ANR 오류가 발생합니다.)- Jsoup 라이브러리를 사용하여 Pixabay 웹사이트에 접속하고, 해당 페이지의 전체 HTML 문서를 가져옵니다.
- 가져온 HTML 문서에서 이미지 정보를 담고 있는 특정 태그(
)와 속성(src
,alt
)을 CSS 선택자를 이용해 파싱(parsing)합니다. - 추출한 이미지 정보(URL, 이미지 설명 등)를
ImageListModel
이라는 데이터 클래스 객체에 담고, 이 객체들의 리스트(List
)를 만듭니다. - 작업이 완료되면, 요청 시 함께 전달받았던 콜백 인터페이스의 메서드를 호출하여, 생성된 이미지 리스트를
MainActivity
로 전달합니다.
- UI 업데이트 (MainActivity):
Networker
로부터 콜백을 통해 이미지 데이터 리스트를 전달받은MainActivity
는 이 데이터를RecyclerView
의 어댑터(Adapter)에 넘겨줍니다.- 어댑터는 데이터를 갱신하고
notifyDataSetChanged()
와 같은 메서드를 호출하여RecyclerView
에게 화면을 새로 그리도록 알립니다. - 최종적으로 사용자는 화면에서 파싱된 이미지 목록을 볼 수 있게 됩니다.
1단계: 웹 페이지 분석 및 Jsoup을 이용한 데이터 추출
웹 크롤링의 첫 단추는 목표 웹사이트의 구조를 정확히 파악하고, 원하는 데이터가 어떤 HTML 태그에 어떤 형태로 들어있는지 분석하는 것입니다. 이 과정 없이는 정확한 데이터 추출이 불가능합니다.
웹 크롤링의 기본과 윤리적 고려사항
웹 크롤링(Web Crawling) 또는 웹 스크래핑(Web Scraping)은 자동화된 방식으로 웹 페이지로부터 데이터를 추출하는 기술을 총칭합니다. 검색 엔진이 웹을 탐색하는 것부터 데이터 분석, 가격 비교 서비스 등 다양한 분야에서 활용됩니다. 하지만 기술을 사용하기에 앞서 반드시 고려해야 할 점이 있습니다. 바로 웹사이트의 이용 약관과 robots.txt 파일입니다.
- 이용 약관 (Terms of Service): 대부분의 웹사이트는 데이터의 무단 수집 및 재배포를 금지하고 있습니다. 상업적 목적으로 크롤링을 할 경우 법적인 문제에 휘말릴 수 있습니다.
- robots.txt: 이는 웹사이트 루트 디렉토리에 위치하는 파일로, 웹사이트 관리자가 크롤러(검색 로봇 등)에게 어떤 페이지를 수집해도 되고, 어떤 페이지는 수집하지 말아야 하는지를 알려주는 규약입니다.
User-agent: *
,Disallow: /private/
와 같은 규칙을 통해 접근 제한을 명시합니다. 이를 존중하는 것은 크롤링의 기본적인 예의입니다.
이러한 이유로 본 프로젝트에서는 저작권 걱정 없이 고품질의 이미지를 사용할 수 있도록 허가하고, API 또한 제공하는 Pixabay를 대상으로 선정했습니다. 비록 이 프로젝트에서는 공식 API 대신 웹 페이지를 직접 파싱하지만, 실제 서비스 개발 시에는 가급적 공식적으로 제공되는 API를 사용하는 것이 안정적이고 올바른 방법이라는 점을 기억해야 합니다.
크롬 개발자 도구를 활용한 HTML 구조 파악
원하는 데이터를 추출하려면, 먼저 데이터가 HTML 문서의 어디에 위치하는지 알아야 합니다. PC의 크롬 브라우저가 제공하는 개발자 도구는 이 작업을 위한 최고의 친구입니다.
1. Pixabay 사이트에 접속합니다. 2. 이미지 목록이 보이는 페이지에서 마우스 오른쪽 버튼을 클릭하고 '검사'(Inspect)를 선택하거나, F12 키를 누릅니다. 3. 개발자 도구 창이 나타나면, 왼쪽 상단의 요소 선택 아이콘(화살표 모양)을 클릭합니다. 4. 페이지에서 정보를 추출하고 싶은 이미지 위로 마우스를 가져가면, 해당 이미지를 구성하는 HTML 코드가 개발자 도구의 'Elements' 탭에 하이라이트됩니다.

분석 결과, Pixabay의 각 이미지는 <div>
컨테이너 안에 <a>
태그로 감싸진
태그 형태로 구성되어 있음을 알 수 있습니다. 우리가 필요한 실제 이미지의 URL은
태그의 src
또는 data-lazy-src
(Lazy Loading의 경우) 속성에, 이미지에 대한 설명은 alt
속성에 들어있습니다. 이제 이 구조를 Jsoup이 알아들을 수 있는 언어, 즉 CSS 선택자로 변환할 차례입니다. 예를 들어, 특정 클래스 이름을 가진 div
아래의 모든 img
태그를 찾는다면 "div.some-class-name img"
와 같은 선택자를 사용할 수 있습니다.
Jsoup을 이용한 HTML 파싱 실전 코드
Jsoup은 URL에 접속해 HTML 문서를 가져오고, DOM(Document Object Model)을 파싱하여 원하는 데이터를 손쉽게 추출할 수 있도록 도와주는 강력한 오픈소스 자바 라이브러리입니다. 먼저 `build.gradle` 파일에 Jsoup 의존성을 추가해야 합니다.
// app/build.gradle
dependencies {
...
implementation 'org.jsoup:jsoup:1.15.3' // 최신 버전은 공식 사이트에서 확인
}
아래는 네트워킹과 파싱을 담당하는 `Networker` 클래스의 핵심 코드 예시입니다. 이 코드는 백그라운드 스레드에서 실행되어야 합니다. (이 예제에서는 간단한 Thread를 사용했다고 가정합니다.)

위 이미지의 코드를 텍스트로 풀어 상세히 설명하면 다음과 같습니다.
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.IOException;
import java.util.ArrayList;
public class Networker {
// 결과를 반환할 콜백 인터페이스
public interface Callback {
void onResult(ArrayList<ImageListModel> imageList);
void onError(Exception e);
}
public void getImageList(final String url, final Callback callback) {
new Thread(() -> {
try {
ArrayList<ImageListModel> resultList = new ArrayList<>();
// 1. Jsoup.connect()로 URL에 접속하고 get()으로 HTML 문서를 가져온다.
Document doc = Jsoup.connect(url).get();
// 2. CSS 선택자를 사용해 원하는 요소들을 선택한다.
// 예: "div.container-fluid div.item a img"
// 실제 Pixabay의 클래스 이름은 변경될 수 있으므로, 직접 분석한 값을 사용해야 한다.
Elements elements = doc.select("div.flex_grid div.item a img");
// 3. 선택된 모든 요소(Elements)를 순회한다.
for (Element element : elements) {
// 4. 각 요소(Element)에서 필요한 속성(attribute) 값을 추출한다.
String imageUrl = element.attr("src"); // 'src' 속성의 값
String imageAlt = element.attr("alt"); // 'alt' 속성의 값
// 5. Lazy-loading 대응: 'src'가 비어있거나 플레이스홀더라면 'data-lazy-src' 같은 다른 속성을 확인한다.
if (imageUrl == null || imageUrl.contains("placeholder")) {
imageUrl = element.attr("data-lazy-src"); // 'data-lazy-src' 속성 값
}
if(imageUrl != null && !imageUrl.isEmpty()) {
// 6. 추출한 정보로 데이터 모델 객체를 생성하여 리스트에 추가한다.
ImageListModel model = new ImageListModel(imageUrl, imageAlt);
resultList.add(model);
}
}
// 7. 메인 스레드로 결과를 전달하기 위해 콜백을 호출한다.
// (실제 앱에서는 Handler나 runOnUiThread를 사용해야 한다.)
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
callback.onResult(resultList);
});
} catch (IOException e) {
e.printStackTrace();
// 8. 오류 발생 시 에러 콜백 호출
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
callback.onError(e);
});
}
}).start();
}
}
이처럼 Jsoup을 사용하면 몇 줄의 코드로 복잡한 HTML 문서에서 원하는 데이터를 손쉽게 추출할 수 있습니다. 가장 중요한 부분은 `doc.select("...")`에 들어갈 정확한 CSS 선택자를 찾는 것입니다.
2단계: 안드로이드 비동기 처리와 콜백 패턴
안드로이드 앱 개발에서 가장 중요하고 또 자주 실수하는 부분 중 하나가 바로 '스레드(Thread)' 관리입니다. 특히 네트워크 통신이나 대용량 파일 입출력과 같이 시간이 오래 걸리는 작업은 반드시 백그라운드 스레드에서 처리해야 합니다.
왜 메인 스레드에서 네트워킹을 할 수 없을까?
안드로이드 애플리케이션은 사용자의 터치 이벤트 처리, 화면 애니메이션 등 모든 UI 관련 작업을 **'메인 스레드(Main Thread)'** 또는 **'UI 스레드(UI Thread)'**라고 불리는 단일 스레드에서 처리합니다. 만약 이 메인 스레드에서 네트워크 요청과 같이 언제 끝날지 모르는 작업을 수행하면 어떻게 될까요?
네트워크 상태가 좋지 않아 서버로부터 응답을 받는 데 10초가 걸린다고 가정해봅시다. 그 10초 동안 메인 스레드는 아무것도 하지 못하고 멈춰있게 됩니다. 사용자가 화면을 터치하거나 스크롤해도 앱은 아무런 반응이 없고, 결국 시스템은 "이 앱이 멈췄다"고 판단하여 **ANR(Application Not Responding)** 이라는 무시무시한 대화상자를 띄웁니다. 이는 사용자 경험에 최악인 상황입니다.
이러한 문제를 방지하기 위해 안드로이드는 API 레벨 11 (허니콤)부터 메인 스레드에서 네트워크 작업을 시도하면 `NetworkOnMainThreadException`을 발생시켜 앱을 강제로 종료시킵니다. 따라서 모든 네트워크 작업은 개발자가 명시적으로 백그라운드 스레드를 생성하여 처리해야 합니다.
콜백(Callback) 패턴을 이용한 비동기 결과 처리
백그라운드 스레드에서 작업을 수행했다면, 그 결과를 다시 메인 스레드로 가져와서 UI(예: 텍스트뷰, 리스트)를 업데이트해야 합니다. 그런데 백그라운드 스레드는 메인 스레드와 별개로 동작하므로, 언제 작업이 끝날지 예측할 수 없습니다. 이때 사용되는 고전적이고 효과적인 방법이 바로 **콜백(Callback) 패턴**입니다.
콜백 패턴의 원리는 간단합니다. "이 일을 시킬 테니, 끝나면 아까 알려준 방법(함수)을 호출해서 알려줘"라고 약속하는 것입니다. 이 '알려주는 방법'에 해당하는 것이 콜백 인터페이스입니다.
1. 콜백 인터페이스 정의
먼저 작업 결과를 전달하기 위한 약속, 즉 인터페이스를 정의합니다. 성공했을 때 호출될 메서드와 실패했을 때 호출될 메서드를 포함할 수 있습니다.
// Networker.java 내부에 정의하거나 별도 파일로 생성
public interface NetworkCallback {
void onImageParsed(ArrayList<ImageListModel> images); // 성공 시 이미지 리스트 전달
void onParseError(String errorMessage); // 실패 시 에러 메시지 전달
}
2. 요청하는 쪽(MainActivity)에서 인터페이스 구현
작업을 요청하는 `MainActivity`는 이 인터페이스를 구현(implements)하여, 작업이 끝났을 때 수행할 동작을 구체적으로 정의합니다.
public class MainActivity extends AppCompatActivity implements Networker.NetworkCallback {
private RecyclerView recyclerView;
private ImageListAdapter adapter;
private ArrayList<ImageListModel> imageList = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// ... RecyclerView 초기화 코드 ...
// Networker에게 작업 요청
Networker networker = new Networker();
networker.fetchImagesFromPixabay("...url...", this); // 'this'를 통해 콜백 구현체를 전달
}
// 성공 콜백 메서드 구현
@Override
public void onImageParsed(ArrayList<ImageListModel> images) {
// 백그라운드 스레드에서 받은 데이터를 어댑터에 추가하고 UI 갱신
this.imageList.clear();
this.imageList.addAll(images);
adapter.notifyDataSetChanged();
// 로딩 인디케이터 숨기기 등
}
// 실패 콜백 메서드 구현
@Override
public void onParseError(String errorMessage) {
// 사용자에게 토스트 메시지 등으로 에러 상황 알리기
Toast.makeText(this, "Error: " + errorMessage, Toast.LENGTH_SHORT).show();
// 로딩 인디케이터 숨기기 등
}
}
이처럼 콜백 패턴을 사용하면 비동기 작업의 시작과 끝을 명확하게 분리하여 코드 구조를 깔끔하게 유지할 수 있습니다. 비록 최근에는 Kotlin 코루틴(Coroutines)이나 RxJava 같은 더 발전된 비동기 처리 라이브러리들이 많이 사용되지만, 콜백은 여전히 안드로이드 SDK 곳곳에서 사용되는 기본적인 패턴이므로 반드시 이해하고 넘어가야 합니다.
3단계: RecyclerView로 대량의 데이터 효율적으로 표시하기
크롤링으로 수십, 수백 개의 이미지 데이터를 가져왔다고 상상해봅시다. 이 많은 아이템을 어떻게 화면에 효율적으로 보여줄 수 있을까요? 단순히 `ScrollView` 안에 `LinearLayout`을 넣고 아이템 개수만큼 뷰를 추가하는 방식은 아이템이 많아질수록 엄청난 메모리를 소모하고 극심한 버벅임을 유발합니다. 이를 해결하기 위해 등장한 것이 바로 `RecyclerView`입니다.
RecyclerView의 작동 원리: View의 재활용
`RecyclerView`의 핵심은 이름 그대로 'View를 재활용(Recycle)'하는 데 있습니다. 화면에 100개의 아이템이 있고, 한 번에 10개만 보인다고 가정합시다. `ListView`나 구식 방법은 100개의 아이템 뷰를 모두 메모리에 생성합니다. 반면, `RecyclerView`는 화면에 보이는 10개와, 스크롤될 때를 대비한 약간의 여분(예: 위아래 2개)을 합쳐 약 12~14개의 뷰 객체만 생성합니다.
사용자가 스크롤을 해서 맨 위에 있던 1번 아이템이 화면 밖으로 사라지면, `RecyclerView`는 이 1번 뷰를 버리지 않고 '재활용 ViewHolder' 풀에 보관합니다. 그리고 화면 아래에 새로 나타나야 할 11번 아이템이 있다면, 풀에 있던 1번 뷰를 가져와서 내용만 11번 데이터에 맞게 바꿔치기하여 보여줍니다. 뷰 객체를 새로 생성(inflate)하는 비용이 매우 비싸기 때문에, 이러한 재활용 메커니즘은 성능에 엄청난 이점을 가져다줍니다.
이를 위해 `RecyclerView`는 다음과 같은 핵심 구성요소를 필요로 합니다.
- LayoutManager: 아이템들을 어떻게 배치할지 결정합니다. (예: `LinearLayoutManager` - 수직/수평 리스트, `GridLayoutManager` - 격자무늬, `StaggeredGridLayoutManager` - 엇갈린 격자무늬)
- Adapter: 데이터와 뷰를 연결하는 다리 역할을 합니다. 전체 데이터 리스트를 관리하고, 각 위치(position)에 맞는 데이터를 뷰에 바인딩(binding)하는 책임을 집니다.
- ViewHolder: 재활용되는 각 아이템 뷰의 참조를 보관하는 객체입니다. `findViewById()`는 비용이 비싼 작업인데, ViewHolder는 한 번 찾아놓은 뷰(예: `ImageView`, `TextView`)들을 필드로 저장해두어 매번 다시 찾을 필요가 없게 해줍니다.
Adapter와 ViewHolder 구현하기
다음은 이 프로젝트를 위한 `ImageListAdapter`의 전체적인 구현 예시입니다.
public class ImageListAdapter extends RecyclerView.Adapter<ImageListAdapter.ImageViewHolder> {
private ArrayList<ImageListModel> dataList;
private Context context;
private OnItemClickListener listener; // 클릭 이벤트를 위한 인터페이스
// 클릭 리스너 인터페이스 정의
public interface OnItemClickListener {
void onItemClick(int position);
}
public void setOnItemClickListener(OnItemClickListener listener) {
this.listener = listener;
}
public ImageListAdapter(Context context, ArrayList<ImageListModel> dataList) {
this.context = context;
this.dataList = dataList;
}
// 1. ViewHolder를 생성하고 뷰를 inflate하는 부분 (뷰가 처음 생성될 때만 호출됨)
@NonNull
@Override
public ImageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(context).inflate(R.layout.item_image, parent, false);
return new ImageViewHolder(view);
}
// 2. ViewHolder에 데이터를 바인딩하는 부분 (뷰가 재활용될 때마다 호출됨)
@Override
public void onBindViewHolder(@NonNull ImageViewHolder holder, int position) {
ImageListModel currentItem = dataList.get(position);
String imageUrl = currentItem.getImageUrl();
String imageAlt = currentItem.getTitle();
holder.titleTextView.setText(imageAlt);
// Glide를 사용하여 이미지 로드 (상세 내용은 다음 챕터에서)
Glide.with(context)
.load(imageUrl)
.placeholder(R.drawable.ic_placeholder) // 로딩 중 이미지
.error(R.drawable.ic_error) // 에러 발생 시 이미지
.into(holder.imageView);
}
// 3. 전체 데이터 아이템의 개수를 반환
@Override
public int getItemCount() {
return dataList.size();
}
// ViewHolder 클래스 정의
public class ImageViewHolder extends RecyclerView.ViewHolder {
public ImageView imageView;
public TextView titleTextView;
public ImageViewHolder(@NonNull View itemView) {
super(itemView);
// findViewById는 ViewHolder 생성자에서 한 번만 수행
imageView = itemView.findViewById(R.id.item_image_view);
titleTextView = itemView.findViewById(R.id.item_title_text_view);
// 클릭 리스너 설정 (onBindViewHolder가 아닌 여기서!)
itemView.setOnClickListener(v -> {
if (listener != null) {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) { // 유효한 포지션인지 확인
listener.onItemClick(position);
}
}
});
}
}
}
성능 최적화: 클릭 리스너 설정 위치
원문에서 언급된 중요한 포인트 중 하나는 `onClickListener`를 설정하는 위치입니다. 초보 개발자들이 흔히 하는 실수는 `onBindViewHolder()` 메서드 안에서 리스너를 설정하는 것입니다.
// 좋지 않은 예: onBindViewHolder 내에서 리스너 설정
@Override
public void onBindViewHolder(@NonNull ImageViewHolder holder, int position) {
// ... 데이터 바인딩 ...
holder.itemView.setOnClickListener(v -> {
// ... 클릭 이벤트 처리 ...
});
}
`onBindViewHolder()`는 뷰가 재활용될 때마다, 즉 사용자가 스크롤할 때마다 계속해서 호출됩니다. 이 안에서 `new View.OnClickListener()`와 같이 리스너 객체를 계속 생성하고 설정하는 것은 불필요한 가비지(garbage)를 많이 만들고, 미세하지만 성능 저하를 유발할 수 있습니다.
반면, `onCreateViewHolder()`는 뷰와 ViewHolder가 처음 생성될 때만 호출됩니다. 따라서 리스너 객체를 이 시점에 한 번만 생성하여 ViewHolder에 붙여주는 것이 훨씬 효율적입니다. 위 `ImageViewHolder`의 생성자 코드에서 보여준 방식이 바로 권장되는 방법입니다. 이 작은 차이가 스크롤 성능을 눈에 띄게 부드럽게 만들 수 있습니다.
4단계: Glide를 활용한 지능적인 이미지 로딩
웹에서 이미지 URL 목록을 성공적으로 가져왔습니다. 이제 이 URL을 이용해 실제 이미지를 다운로드하고 `ImageView`에 표시해야 합니다. 직접 `HttpURLConnection`으로 이미지를 다운받고, `Bitmap`으로 변환하고, `ImageView`에 설정하는 코드를 짤 수도 있습니다. 하지만 이는 생각보다 복잡합니다.
- 네트워크 작업이므로 당연히 백그라운드 스레드에서 처리해야 합니다.
- 다운로드한 이미지는 재사용을 위해 메모리나 디스크에 캐싱(caching)해야 합니다.
- 큰 이미지를 작은 `ImageView`에 그대로 넣으면 `OutOfMemoryError`가 발생할 수 있으므로, 뷰 크기에 맞게 리사이징해야 합니다.
- `RecyclerView`와 함께 사용할 때, 뷰가 재활용되면서 이미지가 엉뚱한 곳에 표시되는 문제를 방지해야 합니다.
이 모든 복잡한 처리를 알아서 해주는 것이 바로 **이미지 로딩 라이브러리**이며, Glide는 안드로이드 생태계에서 가장 널리 사용되는 라이브러리 중 하나입니다.
Glide 기본 사용법 및 주요 옵션
먼저 `build.gradle`에 Glide 의존성을 추가합니다.
// app/build.gradle
dependencies {
...
implementation 'com.github.bumptech.glide:glide:4.14.2' // Glide v4 기준
annotationProcessor 'com.github.bumptech.glide:compiler:4.14.2'
}
Glide의 사용법은 매우 직관적이고 연쇄적인 메서드 호출(Method Chaining)을 통해 이루어집니다.

// ImageListAdapter의 onBindViewHolder 내부
Glide.with(context) // 1. 로드 작업을 시작할 컨텍스트(Activity, Fragment, Context) 지정
.load(imageUrl) // 2. 로드할 이미지 소스 (URL, 파일 경로, 리소스 ID 등)
.placeholder(R.drawable.loading_spinner) // 3. 이미지를 로드하는 동안 보여줄 이미지
.error(R.drawable.image_not_found) // 4. 이미지 로드에 실패했을 때 보여줄 이미지
.centerCrop() // 5. 이미지를 뷰에 맞게 자르는 옵션 (fitCenter()도 많이 사용됨)
.diskCacheStrategy(DiskCacheStrategy.ALL) // 6. 캐싱 전략 설정
.into(holder.imageView); // 7. 최종 이미지를 표시할 타겟 ImageView
각 옵션은 사용자 경험에 큰 영향을 줍니다. 예를 들어 `placeholder`를 사용하면 이미지가 로딩되는 동안 빈 공간 대신 로딩 중임을 알려주는 아이콘을 보여줄 수 있어 사용자가 앱이 멈춘 것으로 오해하지 않게 합니다. `error` 옵션은 깨진 이미지 아이콘을 보여주어 문제가 발생했음을 명확히 인지시킵니다.
특히 Glide의 가장 강력한 기능은 **자동 캐싱**입니다. 한번 로드한 이미지는 메모리와 디스크에 자동으로 캐시됩니다. 다음에 같은 URL의 이미지를 요청하면 네트워크를 다시 통하지 않고 캐시된 이미지를 즉시 보여주므로, 데이터 사용량을 줄이고 로딩 속도를 비약적으로 향상시킬 수 있습니다.
고급 주제 및 프로젝트의 한계점
화면 회전에 대응하기: `configChanges` vs ViewModel
안드로이드 기기는 사용자가 언제든지 가로 또는 세로로 방향을 바꿀 수 있습니다. 기본적으로 화면 방향이 바뀌면, 안드로이드 시스템은 현재 `Activity`를 파괴(destroy)하고 새로운 방향에 맞춰 처음부터 다시 생성(create)합니다. 이 과정에서 `Activity`가 가지고 있던 모든 상태(멤버 변수 등)는 초기화됩니다. 즉, 힘들게 크롤링해서 가져온 이미지 목록이 화면 회전 한 번에 전부 사라져 버립니다.
이 프로젝트에서는 `AndroidManifest.xml` 파일에 다음과 같은 속성을 추가하여 이 문제를 간단히 해결했습니다.
...
`android:configChanges` 속성은 시스템에게 "화면 크기(screenSize)나 방향(orientation)이 바뀌더라도, Activity를 파괴하고 재생성하지 말고 그냥 그대로 둬. 대신 `onConfigurationChanged()` 메서드를 호출해 줄 테니 알아서 처리해" 라고 알려주는 것입니다. 이렇게 하면 `Activity`가 유지되므로 데이터가 사라지지 않습니다.
하지만 이 방법은 '땜질 처방'에 가깝고, 많은 숙련된 개발자들은 이 방법의 사용을 지양하라고 조언합니다. 방향에 따라 다른 레이아웃(layout-land)을 보여줘야 하거나, 언어 변경 등 다른 설정 변경에도 대응해야 할 때 관리가 복잡해지기 때문입니다.
현대적인 안드로이드 앱 개발에서는 ViewModel 아키텍처 컴포넌트를 사용하는 것이 표준적인 해결책입니다. `ViewModel`은 UI 컨트롤러(`Activity`, `Fragment`)의 생명주기와는 별개로 존재하여, 화면 회전과 같이 UI 컨트롤러가 파괴되고 재생성되어도 데이터를 그대로 유지합니다. 데이터를 `ViewModel`에 보관하고, UI 컨트롤러는 이 `ViewModel`의 데이터를 관찰(`Observing`)하여 화면을 그리는 방식으로 설계하면, 생명주기 문제로부터 훨씬 자유롭고 안정적인 앱을 만들 수 있습니다.
웹사이트의 'Lazy Loading' 문제와 그 한계
프로젝트를 진행하며 발견된 흥미로운 문제점 중 하나는 스크롤을 내리면 어느 순간부터 이미지가 로드되지 않는 현상이었습니다. 이는 현대 웹사이트들이 성능 최적화를 위해 널리 사용하는 **'Lazy Loading(지연 로딩)'** 기법 때문입니다.
Lazy Loading은 웹 페이지가 처음 로드될 때, 화면에 보이지 않는 이미지들은 로드하지 않고, 사용자가 스크롤하여 해당 이미지가 화면에 가까워졌을 때 비로소 JavaScript를 이용해 이미지를 로드하는 방식입니다. 이를 통해 초기 페이지 로딩 속도를 크게 단축할 수 있습니다.
이것이 Jsoup 기반의 크롤러에게는 문제가 됩니다. Jsoup은 JavaScript를 실행하지 않습니다. 단지 처음 URL에 접속했을 때 서버가 내려주는 순수한 HTML 텍스트만을 분석할 뿐입니다. 따라서 처음 로드된 HTML에 포함된 이미지들(보통 화면 상단에 보이는 일부)은 `src` 속성에 진짜 이미지 주소가 들어있어 파싱이 잘 됩니다. 하지만 스크롤해야 보이는 이미지들은 `src` 속성이 비어있거나 `placeholder.gif` 같은 가짜 이미지로 채워져 있고, 실제 이미지 주소는 `data-src`, `data-lazy` 와 같은 별도의 속성에 담겨있습니다. 원문의 코드에서도 `data-lazy-src`를 확인하는 부분이 바로 이 때문입니다.
더 나아가, 일부 웹사이트는 초기 HTML에 이미지 태그 자체를 몇 개 포함하지 않고, 사용자가 스크롤하면 JavaScript가 비동기 통신(AJAX/Fetch)으로 서버에 추가 이미지 데이터를 요청하고, 그 결과를 바탕으로 동적으로
태그를 생성하여 페이지에 삽입하기도 합니다. Jsoup은 이런 동적 변화를 전혀 감지할 수 없습니다.
이러한 Lazy Loading 사이트를 완벽하게 크롤링하기 위해서는 다음과 같은 더 복잡한 접근 방식이 필요합니다.
- Selenium 또는 Puppeteer와 같은 헤드리스 브라우저(Headless Browser) 사용: 실제 브라우저 엔진을 백그라운드에서 구동하여 JavaScript를 실행하고, 페이지를 렌더링한 후의 최종 DOM 상태를 분석하는 방식입니다. 가장 확실하지만 리소스 소모가 큽니다.
- 네트워크 요청 분석: 크롬 개발자 도구의 'Network' 탭을 이용해, 스크롤 시 어떤 API 엔드포인트로 데이터를 요청하는지 역추적하여, 웹 페이지 대신 그 API를 직접 호출하는 방식입니다. 많은 경우 이것이 가장 효율적이고 안정적인 방법입니다.
본 프로젝트에서는 이 문제를 깊게 파고들지 않고, 초기 로드된 HTML에서 파싱 가능한 이미지들만 가져오는 수준으로 마무리했습니다. 이는 Jsoup과 같은 정적 HTML 파서의 명확한 한계를 보여주는 좋은 사례입니다.
결론 및 소스 코드
이 프로젝트는 Pixabay라는 실제 웹사이트를 대상으로 웹 크롤링부터 안드로이드 UI에 표시하기까지의 전 과정을 담고 있습니다. Jsoup을 이용한 HTML 파싱, 콜백 기반의 비동기 처리, RecyclerView를 통한 효율적인 목록 표시, Glide를 이용한 이미지 로딩 등 안드로이드 앱 개발의 핵심적인 요소 기술들을 종합적으로 경험해 볼 수 있었습니다.
또한, `configChanges` 속성의 편리함과 한계, 그리고 Lazy Loading이라는 실전적인 문제에 부딪히며, 단순한 기술 구현을 넘어 더 나은 아키텍처와 문제 해결 방법에 대해 고민하는 계기가 되었습니다.
0 개의 댓글:
Post a Comment