[Android/Flutter 교육] 32일차

MSU·2024년 2월 13일

Android-Flutter

목록 보기
33/85
post-thumbnail

Fragment

  • 여러 화면을 가지고 있는 애플리케이션은 여러 Activity를 가지고 있는 애플리케이션을 의미
  • Acitivity는 독립된 실행단위로 메모리를 많이 소모하는데 독립된 실행단위가 아닌 화면만 필요한 경우 Activity보다는 Fragement를 활용하는 것이 효율적임
  • Fragment는 Activity내의 작은 화면 조각으로 Activity의 화면을 여러 영역으로 나누어 관리하는 목적으로 사용한다.
  • Fragment를 이용하면 MVC패턴 기반의 앱을 만들 수 있다.

액티비티 위에 화면만한 프래그먼트를 띄우고 버튼을 누르면 띄워진 프래그먼트를 내리고 다른 프래그먼트를 띄우는 방식

Activity의 역할

  • 각 Fragment를 교환하고 관리하는 역할을 한다.
  • Fragment들이 사용할 데이터를 관리하는 역할을 한다.
  • MVC패턴에서 Activity가 Controller, Fragment가 View의 역할을 하게 된다.

BackStack

  • Activity에서 다른 Activity를 실행하면 이전 Activity는 Back Stack에 담겨 정지 상태가 되고 새로 실행된 Activity가 활동하게 된다.
  • 새로 실행된 Activity가 제거 되면 Back Stack에 있던 Activity가 다시 활동하게 된다.

AddToBackStack

  • 안드로이드에서 back button은 현재 Activity를 종료한다.
  • Fragment는 Activity가 아니므로 Back Button으로 제거할 수 없는데 addToBackStack 메서드를 통해 Back Stack에 포함한 경우 Back Button으로 제거할 수 있다. 이를 통해 마치 이전 화면으로 돌아가는 듯한 효과를 줄 수 있다.
  • popBackStack 메서드를 사용하면 Back Stack에서 Fragment를 순차적으로 제거해 이전으로 돌아가는 효과를 얻을 수 있다.
  • 처음 포함된 프래그먼트에서 back button을 누르면 빈화면의 Activity 화면이 나오고 한번 더 back button을 눌러야 종료되기 때문에 처음 프래그먼트는 Back Stack에 포함시키지 않는다.

Fragment 생명주기

Fragment 추가

FragmentContainerView를 먼저 화면에 드래그하여 배치하려하면 Fragment가 없다고 에러 메시지가 뜸

화면에 배치하는게 아니라 Component Tree에 드래그를 해주면 정상적으로 추가가 됨

그래서 디자인탭에서 추가하기보다는 코드로 작성하는것을 권장함

New > Fragment > Fragment (Blank)

Fragment(Blank) 선택

MainFragment.kt 파일이 생성되고 처음에 생성된 코드는 지운다

다시 디자인탭에서 FragmentContainerView를 화면에 드래그해주면 정상적으로 추가가 가능해짐

생성한 MainFragment를 선택해준다.

name 속성은 지워준다.

// MainActivity.kt

package kr.co.lion.android33_fragment

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.fragment.app.Fragment
import kr.co.lion.android33_fragment.databinding.ActivityMainBinding

// Activity의 역할 :
// 1. Fragment 들을 관리
// 2. 각 Fragment 들이 공통적으로 사용하는 데이터들을 관리

class MainActivity : AppCompatActivity() {
    lateinit var activityMainBinding: ActivityMainBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        activityMainBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(activityMainBinding.root)

        // MainFragment가 보여지도록 한다.
        replaceFragment(FragmentName.MAIN_FRAGMENT)
    }

    // 지정한 Fragment를 보여주는 메서드
    fun replaceFragment(name:FragmentName){
        // Fragment를 교체할 수 있는 객체를 추출
        val fragmentTransaction = supportFragmentManager.beginTransaction()
        //  새로운 Fragment를 담을 변수
        var newFragment:Fragment? = null

        // 이름으로 분기한다.
        // Fragment의 객체를 생성하여 변수에 담아준다.
        when(name){
            FragmentName.MAIN_FRAGMENT -> {
                newFragment = MainFragment()
            }
            FragmentName.INPUT_FRAGMENT -> {

            }
        }

        if(newFragment != null){
            // Fragment를 교체한다.(이전 Fragment가 없으면 새롭게 추가하는 역할을 수행한다)
            // 첫 번째 매개 변수 : Fragment를 배치할 FragmentContainer의 ID
            // 두 번째 매개 변수 : 보여주고자 하는 Fragment 객체
            fragmentTransaction.replace(R.id.mainContainer, newFragment)
            // Fragment 교체를 확정한다.
            fragmentTransaction.commit()
        }
    }
}

// Fragment들의 이름
enum class FragmentName{
    MAIN_FRAGMENT,
    INPUT_FRAGMENT
}

// 학생 정보를 담을 클래스
data class StudentInfo(var name:String, var age:Int, var kor:Int, var math:Int, var eng:Int){

}

InputFragment 추가

Fragment파일은 onCreateView만 빼고 모두 삭제
Activity와 동일한 방식으로 바인딩을 해준다.

// MainActivity.kt

class MainFragment : Fragment() {

    lateinit var fragmentMainBinding: FragmentMainBinding

    // Fragment가 눈에 보여질 때 호출되는 메서드
    // 반환하는 View를 화면에 보여준다.
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        fragmentMainBinding = FragmentMainBinding.inflate(inflater)
        //fragmentMainBinding = FragmentMainBinding.inflate(layoutInflater)
        return inflater.inflate(R.layout.fragment_main, container, false)
    }

}

Activity는 layoutInflater를 프로퍼티로 가지고 있으나 Fragment는 가지고 있지 않기 때문에 이를 매개변수로 받아온다. 추후에 Fragment도 프로퍼티로 layoutInflater를 갖고 있도록 수정되었다.

Fragment를 교체하는건 MainActivity에서 하고 있음 따라서 Fragment에서도 MainActivity를 불러와줘야 함

MainActivity에서 Fragment교체하는 코드에 InputActivity를 추가해줌

// MainActivity.kt

    // 지정한 Fragment를 보여주는 메서드
    fun replaceFragment(name:FragmentName){
        // Fragment를 교체할 수 있는 객체를 추출
        val fragmentTransaction = supportFragmentManager.beginTransaction()
        //  새로운 Fragment를 담을 변수
        var newFragment:Fragment? = null

        // 이름으로 분기한다.
        // Fragment의 객체를 생성하여 변수에 담아준다.
        when(name){
            FragmentName.MAIN_FRAGMENT -> {
                newFragment = MainFragment()
            }
            FragmentName.INPUT_FRAGMENT -> {
                newFragment = InputFragment()
            }
        }

        if(newFragment != null){
            // Fragment를 교체한다.(이전 Fragment가 없으면 새롭게 추가하는 역할을 수행한다)
            // 첫 번째 매개 변수 : Fragment를 배치할 FragmentContainer의 ID
            // 두 번째 매개 변수 : 보여주고자 하는 Fragment 객체
            fragmentTransaction.replace(R.id.mainContainer, newFragment)
            // Fragment 교체를 확정한다.
            fragmentTransaction.commit()
        }
    }

MainFragment에서 viewSetting설정

// MainFragment.kt

class MainFragment : Fragment() {

    lateinit var fragmentMainBinding: FragmentMainBinding
    lateinit var mainActivity: MainActivity

    // Fragment가 눈에 보여질 때 호출되는 메서드
    // 반환하는 View를 화면에 보여준다.
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        fragmentMainBinding = FragmentMainBinding.inflate(inflater)
        //fragmentMainBinding = FragmentMainBinding.inflate(layoutInflater)

        // Activity의 주소값을 담아준다.
        mainActivity = activity as MainActivity

        viewSetting()

        return fragmentMainBinding.root
    }

    // View 설정
    fun viewSetting(){
        fragmentMainBinding.apply {
            // 버튼
            buttonShowInput.setOnClickListener {
                // InputFragment로 교체한다.
                mainActivity.replaceFragment(FragmentName.INPUT_FRAGMENT)
            }
        }

    }

}

앱을 실행하고

버튼을 누르면 InputFragment가 보임

MainActivity의 replaceFragment메서드에 addToBackStack 매개변수 추가

// MainActivity.kt

class MainActivity : AppCompatActivity() {
    lateinit var activityMainBinding: ActivityMainBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        activityMainBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(activityMainBinding.root)

        // MainFragment가 보여지도록 한다.
        replaceFragment(FragmentName.MAIN_FRAGMENT, true)
    }

    // 지정한 Fragment를 보여주는 메서드
    fun replaceFragment(name:FragmentName, addToBackStack:Boolean){
        // Fragment를 교체할 수 있는 객체를 추출
        val fragmentTransaction = supportFragmentManager.beginTransaction()
        //  새로운 Fragment를 담을 변수
        var newFragment:Fragment? = null

        // 이름으로 분기한다.
        // Fragment의 객체를 생성하여 변수에 담아준다.
        when(name){
            FragmentName.MAIN_FRAGMENT -> {
                newFragment = MainFragment()
            }
            FragmentName.INPUT_FRAGMENT -> {
                newFragment = InputFragment()
            }
        }

        if(newFragment != null){
            // Fragment를 교체한다.(이전 Fragment가 없으면 새롭게 추가하는 역할을 수행한다)
            // 첫 번째 매개 변수 : Fragment를 배치할 FragmentContainer의 ID
            // 두 번째 매개 변수 : 보여주고자 하는 Fragment 객체
            fragmentTransaction.replace(R.id.mainContainer, newFragment)

            // addToBackStack 변수의 값이 true면 새롭게 보여질 Fragment를 BackStack에 포함시켜 준다.
            if(addToBackStack == true){
                fragmentTransaction.addToBackStack(null)
            }

            // Fragment 교체를 확정한다.
            fragmentTransaction.commit()
        }
    }
}

MainFragment에서 viewSetting에 true값 추가하고
MainActivity에서 replaceFragment호출 부분에는 false값을 넣어준다.

// MainActivity.kt

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        activityMainBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(activityMainBinding.root)

        // MainFragment가 보여지도록 한다.
        replaceFragment(FragmentName.MAIN_FRAGMENT, false)
        // 가장 처음 보여줄 Fragment는 BackStack에 포함시키지 않는다.
    }


// MainFragment.kt

    // View 설정
    fun viewSetting(){
        fragmentMainBinding.apply {
            // 버튼
            buttonShowInput.setOnClickListener {
                // InputFragment로 교체한다.
                mainActivity.replaceFragment(FragmentName.INPUT_FRAGMENT, true)
            }
        }

    }

MainFragment는 BackStack에 포함되어있지 않기 때문에 InputFragment에서 Back button을 누르면 앱이 종료된다.

InputFragment에서 뒤로가기 버튼 누를 시 앱이 종료됨

MainActivity에서 replaceFragment를 호출하는 부분에서 true값을 넣었을 경우에는 MainFragment도 BackStack에 포함되기 때문에 InputFragment에서 MainFragment로 뒤로가기가 가능해지나 MainFragment에서 다시 한번 뒤로가기를 누를 경우에는 빈 화면이 나타나게 된다.

Fragment 제거

회원가입 화면을 구성할때 이름 -> 주소 -> ID,PW -> 메인화면 순서로 Fragment를 구성했다면
메인화면으로 왔을때 다시 입력받는 Fragment로 돌아가면 안된다. 하지만 입력받는 Fragment에서는 주소 입력에서 이름 입력으로 돌아올수 있게 해야 한다.
BackStack에서는 Fragment를 제거할 수 있는 기능이 있어서 메인화면으로 왔을 때 입력 Fragment를 제거해주면 된다. 그렇게 되면 입력할 때에는 뒤로가기가 가능해지나 회원가입을 완료하고 메인화면으로 돌아오면 뒤로가기를 클릭할때 회원가입이 아닌 앱종료가 된다.

// InputFragment.kt

class InputFragment : Fragment() {

    lateinit var fragmentInputBinding: FragmentInputBinding
    lateinit var mainActivity: MainActivity

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment

        fragmentInputBinding = FragmentInputBinding.inflate(inflater)
        mainActivity = activity as MainActivity

        viewSetting()
        
        return fragmentInputBinding.root
    }

    fun viewSetting(){
        fragmentInputBinding.apply {
            // 버튼
            buttonPrev.setOnClickListener {
                // BackStack에서 Fragment를 제거해 이전 Fragment가 보이도록 한다.
                mainActivity.removeFragment()
            }
        }
    }
}

// MainActivity.kt

    // 지정한 Fragment를 보여주는 메서드
    fun replaceFragment(name:FragmentName, addToBackStack:Boolean){
        // Fragment를 교체할 수 있는 객체를 추출
        val fragmentTransaction = supportFragmentManager.beginTransaction()
        //  새로운 Fragment를 담을 변수
        var newFragment:Fragment? = null

        // 이름으로 분기한다.
        // Fragment의 객체를 생성하여 변수에 담아준다.
        when(name){
            FragmentName.MAIN_FRAGMENT -> {
                newFragment = MainFragment()
            }
            FragmentName.INPUT_FRAGMENT -> {
                newFragment = InputFragment()
            }
        }

        if(newFragment != null){
            // Fragment를 교체한다.(이전 Fragment가 없으면 새롭게 추가하는 역할을 수행한다)
            // 첫 번째 매개 변수 : Fragment를 배치할 FragmentContainer의 ID
            // 두 번째 매개 변수 : 보여주고자 하는 Fragment 객체
            fragmentTransaction.replace(R.id.mainContainer, newFragment)

            // addToBackStack 변수의 값이 true면 새롭게 보여질 Fragment를 BackStack에 포함시켜 준다.
            if(addToBackStack == true){
                // BackStack에 포함시킬 때 이름을 지정해주면 원하는 Fragment를 BackStack에서 제거할 수 있다.
                fragmentTransaction.addToBackStack(name.str)
            }

            // Fragment 교체를 확정한다.
            fragmentTransaction.commit()
        }
    }



    // BackStack에서 Fragment를 제거한다.
    fun removeFragment(name:FragmentName){
        // BackStack에 가장 위에 있는 Fragment를 BackStack에서 제거한다
        // supportFragmentManager.popBackStack()

        // 지정한 이름으로 있는 Fragment를 BackStack에서 제거한다.
        supportFragmentManager.popBackStack(name.str, FragmentManager.POP_BACK_STACK_INCLUSIVE)
    }
 
 
// Fragment들의 이름
enum class FragmentName(var str:String){
    MAIN_FRAGMENT("mainFragment"),
    INPUT_FRAGMENT("inputFragment")
}



// InputFragment.kt

    fun viewSetting(){
        fragmentInputBinding.apply {
            // 버튼
            buttonPrev.setOnClickListener {
                // BackStack에서 Fragment를 제거해 이전 Fragment가 보이도록 한다.
                mainActivity.removeFragment(FragmentName.INPUT_FRAGMENT)
            }
        }
    }

popBackStack에 없는 아이디(문자열)를 지정하면 아무일도 일어나지 않는다.

Fragment Animation

이전 프래그먼트에 적용할 애니메이션이 있고 새로운 프래그먼트에 적용할 애니메이션이 있기 때문에 2개의 프래그먼트 객체가 필요하다.

// MainActivity.kt

class MainActivity : AppCompatActivity() {

    // 이전 Fragment
    var oldFragment:Fragment? = null
    // 새로 나타날 Fragment
    var newFragment:Fragment? = null
    
}

처음 프래그먼트(F1)를 보여줄 때 newFragment에 Fragment객체(F1)를 담는다.
다음 프래그먼트(F2)를 보여줄 때 newFragment에 있던 처음 Fragment 객체(F1)를 oldFragment에 담아주고 newFragment에 다음 프래그먼트 객체(F2)를 담아주는 방식

F1에는 처음 나타날때 애니메이션을 적용하지 않지만 F2에서 F1로 돌아올때 애니메이션을 적용해줘야 한다.
F2는 F1에서 F2로 새롭게 나타날 때 애니메이션을 적용해준다. 추후 F3가 있다면 F3에서 F2로 돌아올때 애니메이션을 적용해줄 수 있다.

newFragment에 있는 객체주소를 oldFragment에 담는 과정이 필요
replaceFragment메서드에 새로운 Fragment를 담을 변수 newFragment는 지워주고
매개변수로 isAnimate:Boolean를 받는다.
첫화면은 애니메이션을 주지 않는다.

// MainActivity.kt

class MainActivity : AppCompatActivity() {

    lateinit var activityMainBinding: ActivityMainBinding

    // 이전 Fragment
    var oldFragment:Fragment? = null
    // 새로 나타날 Fragment
    var newFragment:Fragment? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        activityMainBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(activityMainBinding.root)

        // MainFragment가 보여지도록 한다.
        replaceFragment(FragmentName.MAIN_FRAGMENT, false, false)
    }

    // 지정한 Fragment를 보여주는 메서드
    fun replaceFragment(name:FragmentName, addToBackStack:Boolean, isAnimate:Boolean){
    
        // Fragment 전환에 딜레이를 조금 준다.
        SystemClock.sleep(200)
        
        // Fragment를 교체할 수 있는 객체를 추출
        val fragmentTransaction = supportFragmentManager.beginTransaction()
        // 새로운 Fragment를 담을 변수
        // var newFragment:Fragment? = null 이 코드는 지워주기

        // oldFragment에 newFragment가 가지고 있는 Fragment 객체를 담아준다.
        if(newFragment != null){
            oldFragment = newFragment
        }

        // 이름으로 분기한다.
        // Fragment의 객체를 생성하여 변수에 담아준다.
        when(name){
            FragmentName.MAIN_FRAGMENT -> {
                newFragment = MainFragment()
            }
            FragmentName.INPUT_FRAGMENT -> {
                newFragment = InputFragment()
            }
        }

        if(newFragment != null){
            // 애니메이션 설정
            if(isAnimate){
                // oldFragment -> newFragment
                // oldFragment : exit
                // newFragment : enter

                // newFragment -> oldFragment
                // oldFragment : reenter
                // newFragment : return
                
                // MaterialSharedAxis : 좌우, 위아래, 공중 바닥 사이로 이동하는 애니메이션 효과
                // X - 좌우
                // Y - 위아래
                // Z - 공중 바닥
                // 두 번째 매개변수 : 새로운 화면이 나타나는 것인지 여부를 설정
                // true : 새로운 화면이 나타나는 애니메이션이 동작한다.
                // false : 이전으로 되돌아가는 애니메이션이 동작한다.
                if(oldFragment != null){
                    // old에서 new가 새롭게 보여질 때 old의 애니메이션
                    oldFragment?.enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
                    // new에서 old로 되돌아갈 때 old의 애니메이션
                    oldFragment?.enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)

                    oldFragment?.enterTransition = null
                    oldFragment?.returnTransition = null
                }
                
                // old에서 new가 새롭게 보여질 때 new의 애니메이션
                newFragment?.enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
                // new에서 old로 되돌아갈 때의 애니메이션
                newFragment?.enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)

                newFragment?.exitTransition = null
                newFragment?.reenterTransition = null
            }

            // Fragment를 교체한다.(이전 Fragment가 없으면 새롭게 추가하는 역할을 수행한다)
            // 첫 번째 매개 변수 : Fragment를 배치할 FragmentContainer의 ID
            // 두 번째 매개 변수 : 보여주고자 하는 Fragment 객체
            fragmentTransaction.replace(R.id.mainContainer, newFragment!!)

            // addToBackStack 변수의 값이 true면 새롭게 보여질 Fragment를 BackStack에 포함시켜 준다.
            if(addToBackStack == true){
                // BackStack에 포함시킬 때 이름을 지정해주면 원하는 Fragment를 BackStack에서 제거할 수 있다.
                fragmentTransaction.addToBackStack(name.str)
            }

            // Fragment 교체를 확정한다.
            fragmentTransaction.commit()
        }
    }

    // BackStack에서 Fragment를 제거한다.
    fun removeFragment(name:FragmentName){
        // BackStack에 가장 위에 있는 Fragment를 BackStack에서 제거한다
        // supportFragmentManager.popBackStack()
        
        SystemClock.sleep(200)

        // 지정한 이름으로 있는 Fragment를 BackStack에서 제거한다.
        supportFragmentManager.popBackStack(name.str, FragmentManager.POP_BACK_STACK_INCLUSIVE)
    }
}
// MainFragment.kt

    // View 설정
    fun viewSetting(){
        fragmentMainBinding.apply {
            // 버튼
            buttonShowInput.setOnClickListener {
                // InputFragment로 교체한다.
                mainActivity.replaceFragment(FragmentName.INPUT_FRAGMENT, true, true)
            }
        }

    }

Bundle

Activity간에 데이터를 전달할 때 intent를 사용했는데 Fragment에서는 intent가 없음
A Fragment와 B Fragment에서 사용하는 데이터가 있을 때 Activity에서 프로퍼티를 정의하고 A Fragment에서 Activity의 프로퍼티 값을 저장하면 B Fragment에서도 Activity의 프로퍼티 값을 가져와 사용할 수 있다.

// MainActivity.kt

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        activityMainBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(activityMainBinding.root)

        // MainFragment가 보여지도록 한다.
        replaceFragment(FragmentName.MAIN_FRAGMENT, false, false, null)
    }

    // 지정한 Fragment를 보여주는 메서드
    // name : 프래그먼트 이름
    // addToBackStack : BackStack에 포함 시킬 것인지
    // isAnimate : 애니메이션을 보여줄 것인지
    // data : 새로운 프래그먼트에 전달할 값이 담겨져 있는 Bundle 객체
    fun replaceFragment(name:FragmentName, addToBackStack:Boolean, isAnimate:Boolean, data:Bundle?){


        // 새로운 Fragment에 전달할 Bundle 객체가 있다면 arguments 프로퍼티에 넣어준다.
        if(data != null){
            newFragment?.arguments = data
        }
        
        
    }
// MainFragment.kt

    // View 설정
    fun viewSetting(){
        fragmentMainBinding.apply {
            // 버튼
            buttonShowInput.setOnClickListener {

                // 데이터를 담을 Bundle 객체를 생성한다.
                val bundle = Bundle()
                // 데이터를 담는다.
                bundle.putInt("data1",10)
                bundle.putDouble("data2",11.11)
                bundle.putString("data3","문자열")

                // InputFragment로 교체한다.
                mainActivity.replaceFragment(FragmentName.INPUT_FRAGMENT, true, true, bundle)


            }
        }

    }
// InputFragment.kt

    fun viewSetting(){
        fragmentInputBinding.apply {
            // 버튼
            buttonPrev.setOnClickListener {
                // BackStack에서 Fragment를 제거해 이전 Fragment가 보이도록 한다.
                mainActivity.removeFragment(FragmentName.INPUT_FRAGMENT)
            }

            // TextView
            textViewInput.apply {
                // arguments 프로퍼티를 통해 data 추출한다.
                text = "data1 : ${arguments?.getInt("data1")}\n"
                append("data2 : ${arguments?.getDouble("data2")}\n")
                append("data3 : ${arguments?.getString("data3")}")
            }
        }
    }
    




※ 출처 : 멋쟁이사자 앱스쿨 2기, 소프트캠퍼스 
profile
안드로이드공부

0개의 댓글