Thursday, June 13, 2019

RecyclerView 클릭 리스너, `getAdapterPosition()`이 -1을 반환할 때 대처법 (Kotlin/Java 실전 예제 포함)

안드로이드 개발에서 RecyclerView는 목록 형태의 UI를 만드는 데 있어 이제는 없어서는 안 될 필수적인 존재입니다. 수많은 데이터를 효율적으로 사용자에게 보여주는 강력한 도구이지만, 그 유연성만큼이나 개발자를 함정에 빠뜨리는 몇 가지 까다로운 부분들이 있습니다. 그 중 가장 대표적인 것이 바로 아이템 클릭 이벤트를 처리할 때 마주치는 getAdapterPosition()의 예기치 않은 동작입니다.

분명 코드는 정상적인 것 같은데, 앱이 비정상적으로 종료됩니다. 로그를 확인해보면 ArrayIndexOutOfBoundsException: -1 이라는 무시무시한 에러 메시지가 우리를 반깁니다. 이 에러의 주범은 대부분 getAdapterPosition()이 반환한 -1, 즉 RecyclerView.NO_POSITION 값입니다. 이 값을 그대로 데이터 리스트의 인덱스로 사용하려다 보니 발생하는 문제입니다.

getAdapterPosition()은 때때로 유효한 위치 값이 아닌 -1을 반환하는 것일까요? 이 문제를 어떻게 하면 근본적으로 해결하고, 사용자가 어떤 상황에서 아이템을 클릭하더라도 안정적으로 동작하는 RecyclerView를 만들 수 있을까요? 이 글에서는 getAdapterPosition()-1을 반환하는 근본적인 원인을 깊이 있게 파헤치고, 실제 프로젝트에 바로 적용할 수 있는 안정적이고 현대적인 해결 방법을 Kotlin과 Java 예제 코드를 통해 상세히 알아보겠습니다.

1. `getAdapterPosition()`의 역할과 `getLayoutPosition()`과의 차이점

문제를 해결하기에 앞서, 우리가 다루는 메서드가 정확히 어떤 역할을 하는지 이해하는 것이 중요합니다. RecyclerView.ViewHolder는 현재 자신의 위치를 알 수 있는 몇 가지 메서드를 제공합니다.

getAdapterPosition()

이름에서 알 수 있듯이, 어댑터의 데이터 리스트를 기준으로 현재 ViewHolder가 나타내는 아이템의 위치(인덱스)를 반환합니다. 개발자가 어댑터에 전달한 List, ArrayList 등의 데이터 소스에서 몇 번째 아이템인지를 알려주는 것입니다. 우리가 클릭된 아이템에 해당하는 데이터를 가져오고 싶을 때 사용해야 하는 바로 그 메서드입니다.

getLayoutPosition() (Deprecated)

이 메서드는 화면에 배치된 레이아웃을 기준으로 ViewHolder의 위치를 반환합니다. 일반적으로는 getAdapterPosition()과 같은 값을 반환하지만, 결정적인 차이가 있습니다. 아이템이 추가되거나 삭제될 때 애니메이션이 동작하는 동안, 또는 레이아웃이 재계산되는 찰나의 순간에 getLayoutPosition()getAdapterPosition()의 값은 달라질 수 있습니다.

예를 들어, 어댑터에서 5번 위치의 아이템을 삭제하면(notifyItemRemoved(5)), 어댑터의 데이터는 즉시 갱신됩니다. 하지만 화면에서는 삭제 애니메이션이 진행 중일 수 있습니다. 이 순간, 삭제되고 있는 뷰의 getLayoutPosition()은 여전히 유효한 값(예: 5)을 가질 수 있지만, getAdapterPosition()은 데이터 소스에 더 이상 해당 아이템이 없으므로 RecyclerView.NO_POSITION (-1)을 반환합니다.

이러한 불일치 때문에 getLayoutPosition()으로 얻은 위치 값으로 데이터에 접근하면 잘못된 데이터를 가져오거나 IndexOutOfBoundsException이 발생할 수 있습니다. 이러한 이유로 getLayoutPosition()은 이제 Deprecated 되었으며, 우리는 항상 getAdapterPosition() (또는 잠시 후 설명할 새로운 메서드들)을 사용해야 합니다.

2. `getAdapterPosition()`은 왜 -1 (`NO_POSITION`)을 반환하는가?

이제 본론으로 돌아와, 왜 이 중요한 getAdapterPosition() 메서드가 -1을 반환하여 우리를 괴롭히는지 그 원인을 상세히 살펴보겠습니다. 이 원인들을 이해하면 자연스럽게 해결책에 가까워질 수 있습니다.

원인 1: ViewHolder가 아직 레이아웃에 완전히 바인딩(연결)되지 않았을 때

RecyclerViewViewHolder를 생성하는 시점(onCreateViewHolder)과 화면에 그리고 위치를 부여하는 시점 사이에는 미세한 시간 차이가 있습니다. 특히 `ViewHolder`의 생성자(constructor 또는 init 블록) 내부에서 위치 값을 얻으려고 시도하는 경우, 해당 ViewHolder는 아직 RecyclerView의 레이아웃 시스템에 편입되기 전 상태일 가능성이 높습니다.

즉, '아직 자리가 정해지지 않은' 상태의 뷰에게 "너의 자리가 어디야?"라고 묻는 것과 같습니다. 당연히 뷰는 "아직 내 자리는 없어"라는 의미로 -1을 반환할 수밖에 없습니다.


// 좋지 않은 예: ViewHolder 생성자에서 position에 접근
class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    init {
        val position = adapterPosition // 이 시점에서는 -1을 반환할 확률이 매우 높다!
        if (position != RecyclerView.NO_POSITION) {
            // 이 코드는 거의 실행되지 않는다.
        }

        // 클릭 리스너를 여기에 설정하는 것은 괜찮지만,
        // position 변수를 외부에서 참조하여 사용하는 것은 위험하다.
    }
}

원인 2: 아이템이 데이터셋에서 제거되었을 때

가장 흔하게 -1을 마주치는 상황입니다. 사용자가 아이템을 삭제하는 기능을 생각해보세요. 어댑터에서 특정 아이템을 제거하고 notifyItemRemoved(position)을 호출했습니다. 그러면 RecyclerView는 부드러운 삭제 애니메이션을 보여주며 해당 아이템 뷰를 화면에서 서서히 사라지게 합니다.

바로 이 애니메이션이 진행되는 동안, 사용자가 빠르게 다른 아이템을 클릭하거나, 혹은 이 삭제된 뷰가 완전히 사라지기 전에 어떤 이벤트가 발생하여 위치를 다시 조회하게 되면 문제가 발생합니다.

  • 어댑터의 데이터: 이미 해당 아이템은 리스트에서 제거된 상태입니다.
  • 화면의 뷰 (ViewHolder): 아직 애니메이션 때문에 화면에 잠시 남아있습니다.

이 '유령' 상태의 ViewHolder에게 getAdapterPosition()을 호출하면, 어댑터는 "그 뷰에 해당하는 데이터는 이제 내 리스트에 없어"라고 말하며 -1을 반환합니다. 이 상황에서 반환된 -1로 데이터 리스트에 접근하려고 시도하면 ArrayIndexOutOfBoundsException이 발생하게 되는 것입니다.

원인 3: 데이터셋이 통째로 교체되었을 때 (notifyDataSetChanged)

notifyDataSetChanged()RecyclerView에게 "모든 것이 바뀌었으니 처음부터 다시 그려라"라고 지시하는, 강력하지만 비효율적인 메서드입니다. 이 메서드가 호출되면 RecyclerView는 기존의 ViewHolder들을 재활용하거나 버릴 수 있습니다.

만약 어떤 ViewHolder에 연결된 클릭 리스너가 notifyDataSetChanged() 호출 이후, 하지만 해당 뷰가 완전히 화면에서 정리되기 전에 (예: 비동기 작업 콜백 등) 실행된다면, 그 ViewHolder는 이미 '구식'이 되어버린 상태입니다. 새로운 데이터셋 기준으로 자신의 위치를 알 수 없으므로 -1을 반환하게 됩니다.

이것이 바로 안드로이드 공식 문서에서도 DiffUtil과 같은 더 정교한 알림(notification) 메커니즘 사용을 권장하는 이유 중 하나입니다. `notifyDataSetChanged()`는 이러한 위치 정보의 일관성을 깨뜨리기 쉽습니다.

3. 완벽한 해결책: 안전하게 위치를 얻고 사용하는 방법

원인을 알았으니 이제 해결 방법을 알아볼 차례입니다. 핵심은 간단합니다. "위치 값이 필요한 바로 그 순간에 값을 가져오고, 그 값이 유효한지 반드시 확인한다" 입니다.

해결 전략 1: 클릭 리스너 '내부'에서 `getAdapterPosition()` 호출하기

가장 중요하고 기본적인 원칙입니다. `ViewHolder`의 위치는 언제든지 바뀔 수 있으므로, 미리 변수에 저장해두고 사용하는 것은 위험합니다. `onBindViewHolder`에서 전달된 `position` 파라미터를 클릭 리스너의 콜백 함수에서 그대로 사용하려고 하는 것이 대표적인 실수입니다.

잘못된 예시 (Stale Position Problem):


// onBindViewHolder 내부
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    // ... 데이터 바인딩 ...

    // !! 매우 위험한 코드 !!
    holder.itemView.setOnClickListener {
        // 이 리스너는 나중에, 어쩌면 뷰가 재활용된 후에 호출될 수 있다.
        // 그 때가 되면 'position' 변수는 더 이상 유효하지 않은 ' stale' 상태가 된다.
        val item = myList[position] // 여기서 에러 발생 가능!
        // doSomething(item)
    }
}

위 코드는 ViewHolder가 재활용될 때 심각한 버그를 유발합니다. 0번 위치의 뷰를 보여주기 위해 `onBindViewHolder`가 호출되었고, 클릭 리스너는 position = 0을 캡처했습니다. 이후 사용자가 스크롤하여 이 뷰가 재활용되어 10번 위치의 데이터를 보여주게 되었습니다. 이제 `onBindViewHolder`가 position = 10으로 다시 호출되어 뷰의 내용은 10번 데이터로 바뀌었지만, 이전에 설정된 클릭 리스너는 여전히 position = 0을 기억하고 있습니다! 이 상태에서 사용자가 뷰를 클릭하면 10번 아이템이 아니라 0번 아이템에 대한 동작이 수행됩니다.

올바른 예시:

클릭 이벤트가 발생한 바로 그 시점에 ViewHolder로부터 현재의 정확한 어댑터 위치를 물어봐야 합니다.


// ViewHolder 내부 또는 onBindViewHolder 내부 모두 가능하지만 ViewHolder 내부를 추천
// ViewHolder 클래스 내부
init {
    itemView.setOnClickListener {
        // 클릭이 발생한 '바로 그 시점'에 위치를 조회한다.
        val currentPosition = adapterPosition
        if (currentPosition != RecyclerView.NO_POSITION) {
            // 이 블록 안에서 position은 유효함이 보장된다.
            // 여기에서 리스너 콜백을 호출하거나 데이터를 처리한다.
            val item = myList[currentPosition]
            onItemClickListener?.onItemClick(item)
        }
    }
}

이렇게 하면 리스너는 항상 최신 위치 정보를 기준으로 동작하므로, 뷰가 재활용되어도 문제가 발생하지 않습니다.

해결 전략 2: `NO_POSITION` 체크는 선택이 아닌 필수

앞선 올바른 예시 코드에서 볼 수 있듯이, getAdapterPosition()을 호출한 후에는 반드시 반환된 값이 RecyclerView.NO_POSITION (-1) 인지 확인하는 방어 코드를 넣어야 합니다. 이 간단한 `if`문 하나가 ArrayIndexOutOfBoundsException으로부터 앱을 완벽하게 보호해줍니다.


// Java 예시
public MyViewHolder(View itemView) {
    super(itemView);
    // ...
    itemView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            int position = getAdapterPosition();
            if (position != RecyclerView.NO_POSITION) {
                // 이 체크를 통과했다면 position은 안전하다.
                MyData data = dataList.get(position);
                // doSomething with data...
            }
        }
    });
}

아이템이 삭제되는 애니메이션 도중에 클릭되는 등의 엣지 케이스에서 getAdapterPosition()-1을 반환하더라도, `if` 조건문 덕분에 dataList.get(-1)과 같은 위험한 코드가 실행되는 것을 막아줍니다. 사용자는 아무런 동작이 일어나지 않는 것을 경험할 뿐, 앱이 강제 종료되는 최악의 상황은 피할 수 있습니다.

4. 실전 예제: 안정적인 클릭 리스너 구현 (Kotlin & Java)

이론을 실제 코드로 옮겨보겠습니다. Activity/Fragment와 Adapter가 가장 이상적으로 상호작용하는 인터페이스 기반의 클릭 리스너 구현 방법입니다.

Kotlin 전체 예제 코드


// 1. 클릭 이벤트를 전달하기 위한 인터페이스 정의
interface OnItemClickListener {
    fun onItemClick(data: MyData)
}

// 2. Adapter 구현
class MyAdapter(
    private val dataList: List<MyData>,
    private val onItemClickListener: OnItemClickListener
) : RecyclerView.Adapter<MyAdapter.MyViewHolder>() {

    // 3. ViewHolder 구현 - 리스너 로직을 여기에 포함시키는 것이 깔끔하다.
    inner class MyViewHolder(private val binding: ItemMyBinding) : RecyclerView.ViewHolder(binding.root) {

        init {
            // 뷰홀더가 생성될 때 리스너를 한 번만 설정한다.
            binding.root.setOnClickListener {
                val position = bindingAdapterPosition // 최신 메서드 사용 (아래에서 설명)
                if (position != RecyclerView.NO_POSITION) {
                    onItemClickListener.onItemClick(dataList[position])
                }
            }
        }

        fun bind(data: MyData) {
            // View에 데이터를 바인딩하는 로직
            binding.textView.text = data.title
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val binding = ItemMyBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return MyViewHolder(binding)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(dataList[position])
    }

    override fun getItemCount(): Int = dataList.size
}

// 4. Fragment 또는 Activity에서 사용
class MyFragment : Fragment(), OnItemClickListener {
    
    private lateinit var myAdapter: MyAdapter

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        val dummyData = listOf(MyData("title 1"), MyData("title 2")) // 예시 데이터
        
        // 어댑터를 생성할 때 'this' (OnItemClickListener 구현체)를 전달
        myAdapter = MyAdapter(dummyData, this)
        
        recyclerView.adapter = myAdapter
        recyclerView.layoutManager = LinearLayoutManager(context)
    }

    // 5. 인터페이스 구현 - 클릭 이벤트가 여기로 전달된다.
    override fun onItemClick(data: MyData) {
        Toast.makeText(context, "${data.title} clicked!", Toast.LENGTH_SHORT).show()
        // 다음 화면으로 전환하는 등의 로직 처리
    }
}

Java 전체 예제 코드


// 1. 인터페이스 정의
public interface OnItemClickListener {
    void onItemClick(MyData data);
}

// 2. Adapter 구현
public class MyAdapter extends RecyclerView.Adapter<MyAdapter.MyViewHolder> {

    private List<MyData> dataList;
    private OnItemClickListener onItemClickListener;

    public MyAdapter(List<MyData> dataList, OnItemClickListener onItemClickListener) {
        this.dataList = dataList;
        this.onItemClickListener = onItemClickListener;
    }
    
    // 3. ViewHolder 구현
    public class MyViewHolder extends RecyclerView.ViewHolder {
        // ex) TextView textViewTitle;
        
        public MyViewHolder(@NonNull View itemView) {
            super(itemView);
            // ex) textViewTitle = itemView.findViewById(R.id.textViewTitle);

            itemView.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    int position = getBindingAdapterPosition(); // 최신 메서드 사용
                    if (position != RecyclerView.NO_POSITION && onItemClickListener != null) {
                        onItemClickListener.onItemClick(dataList.get(position));
                    }
                }
            });
        }

        void bind(MyData data) {
            // ex) textViewTitle.setText(data.getTitle());
        }
    }
    
    @NonNull
    @Override
    public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_my, parent, false);
        return new MyViewHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull MyViewHolder holder, int position) {
        holder.bind(dataList.get(position));
    }

    @Override
    public int getItemCount() {
        return dataList.size();
    }
}

// 4. Fragment 또는 Activity에서 사용
public class MyFragment extends Fragment implements OnItemClickListener {
    
    private MyAdapter myAdapter;
    private RecyclerView recyclerView;

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        recyclerView = view.findViewById(R.id.recycler_view);

        List<MyData> dummyData = new ArrayList<>(); // 예시 데이터
        dummyData.add(new MyData("Title 1"));
        dummyData.add(new MyData("Title 2"));

        myAdapter = new MyAdapter(dummyData, this); // 'this'를 리스너로 전달

        recyclerView.setAdapter(myAdapter);
        recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
    }

    // 5. 인터페이스 구현
    @Override
    public void onItemClick(MyData data) {
        Toast.makeText(getContext(), data.getTitle() + " clicked!", Toast.LENGTH_SHORT).show();
    }
}

5. 심화 과정: `getBindingAdapterPosition()`과 `getAbsoluteAdapterPosition()`

최신 AndroidX RecyclerView 라이브러리(1.2.0-alpha02 이상)에서는 `getAdapterPosition()`을 대체하는 새로운 두 개의 메서드가 등장했습니다. 이는 여러 어댑터를 하나로 합쳐서 사용하는 `ConcatAdapter`의 등장과 관련이 깊습니다.

getBindingAdapterPosition()

이것이 현재 사실상의 표준으로 사용되는 메서드입니다. getAdapterPosition()과 거의 동일하게 동작하지만, `ConcatAdapter` 환경에서 미묘한 차이를 보입니다. `getBindingAdapterPosition()`은 **"이 ViewHolder를 화면에 바인딩한 그 어댑터 내에서의 위치"**를 반환합니다.

예를 들어, HeaderAdapter(아이템 1개)와 ListAdapter(아이템 10개)를 ConcatAdapter로 합쳤다고 가정해봅시다.

  • `HeaderAdapter`의 ViewHolder에서 `getBindingAdapterPosition()`를 호출하면 0이 반환됩니다.
  • `ListAdapter`의 첫 번째 아이템의 ViewHolder에서 `getBindingAdapterPosition()`를 호출하면, 0이 반환됩니다. (`ListAdapter` 내에서는 첫 번째 아이템이므로)

대부분의 경우, 우리는 특정 어댑터 내에서의 위치가 궁금하므로 getBindingAdapterPosition()을 사용하는 것이 가장 직관적이고 안전합니다.

getAbsoluteAdapterPosition()

`ConcatAdapter`로 합쳐진 전체 `RecyclerView`에서의 절대적인 위치를 반환합니다. 위와 동일한 예시 상황에서:

  • `HeaderAdapter`의 `ViewHolder`에서 `getAbsoluteAdapterPosition()`를 호출하면 0이 반환됩니다.
  • `ListAdapter`의 첫 번째 아이템의 ViewHolder에서 `getAbsoluteAdapterPosition()`를 호출하면, 1이 반환됩니다. (Header의 1개 다음에 위치하므로)

만약 전체 리스트를 기준으로 스크롤 위치를 제어하거나, 절대 위치에 기반한 로직이 필요할 때 유용하게 사용할 수 있습니다.

결론: `ConcatAdapter`를 사용하지 않는 일반적인 상황에서는 `getAdapterPosition()`과 `getBindingAdapterPosition()`은 동일하게 작동합니다. 하지만 미래의 확장성과 명확성을 위해 지금부터는 `getBindingAdapterPosition()` 사용을 습관화하는 것이 좋습니다.

결론 및 핵심 요약

RecyclerView에서 아이템 클릭을 처리하고 위치를 얻는 것은 간단해 보이지만, 안정성을 위협하는 숨겨진 함정들이 존재합니다. 특히 getAdapterPosition()이 반환하는 -1 값은 많은 개발자들을 ArrayIndexOutOfBoundsException의 늪에 빠뜨립니다.

오늘 우리는 이 문제를 해결하기 위한 완벽한 전략을 배웠습니다. 마지막으로 핵심 원칙들을 다시 한번 정리하며 마무리하겠습니다.

  1. 위치는 필요할 때만, 즉시 조회하라: `ViewHolder`의 위치를 변수에 저장해두고 재사용하지 마세요. 반드시 클릭 리스너 콜백과 같이 이벤트가 발생한 시점에 getBindingAdapterPosition() (또는 `getAbsoluteAdapterPosition`)을 호출해야 합니다.
  2. `NO_POSITION` 체크는 생명줄이다: getBindingAdapterPosition()으로 얻은 위치 값은 if (position != RecyclerView.NO_POSITION) 조건문으로 유효성을 검사한 후에만 사용하세요. 이 한 줄이 당신의 앱을 불시의 크래시로부터 지켜줍니다.
  3. `onBindViewHolder`의 position을 리스너에서 직접 쓰지 마라: `onBindViewHolder`의 `position` 파라미터는 뷰를 바인딩하는 시점의 값일 뿐입니다. 재활용 문제를 피하기 위해 리스너 안에서는 항상 `ViewHolder`의 위치 조회 메서드를 사용하세요.
  4. 현대적인 API를 사용하라: 이제 `getLayoutPosition()`은 잊고, `getAdapterPosition()` 대신 `getBindingAdapterPosition()`을 기본으로 사용하는 습관을 들이는 것이 좋습니다.

이 원칙들만 명심한다면, `RecyclerView`의 아이템 클릭 이벤트 처리 시 발생하는 골치 아픈 버그들을 영원히 추방하고, 훨씬 더 안정적이고 예측 가능하게 동작하는 애플리케이션을 만들 수 있을 것입니다.


0 개의 댓글:

Post a Comment