[THE SOPT] Android 4차 세미나 과제

한승현·2022년 5월 13일
3

SOPT 30기 Android 파트 세미나 과제입니다.
github: https://github.com/KINGSAMJO/iOS_Seunghyeon
해당 주차 브랜치(ex. seminar/4)에서 각 세미나별 과제 코드를 확인할 수 있습니다.
github를 통해 코드를 보시는 것을 추천드립니다.

목차

  • 필수과제
  • 성장과제
  • 도전과제

들어가기에 앞서, 본 과제는 DataBinding, MVVM, Coroutine을 사용해 구현하였음을 미리 알려드립니다.

필수과제

필수과제 1.

필수과제 1은 제공된 API 문서를 사용해 로그인과 회원가입 서버통신을 구현하는 것입니다. 과제의 요구사항은 다음과 같습니다.

  1. POSTMAN을 사용하여 테스트해본다.
  2. 회원가입 완료, 로그인 완료를 구현한다.
  3. Retrofit 인터페이스와 구현체 코드, Request / Response 객체에 대한 코드를 구현한다.

먼저 API 문서로 이동해 보겠습니다. API 문서에는 2가지 API에 대한 정보가 있습니다.


먼저 회원가입 API부터 보겠습니다. POST 요청의 /auth/signup 엔드포인트이며, Request Body와 Response Body는 아래와 같습니다.

// Request Body
{
  "email": String,
  "password": String
}

// Response Body
{
  "status": Int,
  "message": String,
  "data": {
    "email": String,
    "name": String
  }
}

이를 활용해 POSTMAN에서 테스트를 진행해 보겠습니다.

status 201, 즉 회원가입에 성공한 모습입니다. 이 상황에서, 똑같은 email인 test@example.com으로 한 번 더 회원가입 요청을 보내보면 어떨까요?

Swagger에 적혀있던 것처럼 중복된 email로 회원가입 요청을 보내면 status 409, 즉 클라이언트의 잘못된 요청으로 인해 서버통신이 실패했음을 알 수 있습니다. 우리는 이런 status, 혹은 code 값을 통해 응답의 상태를 알 수 있고, 이를 활용하면 다양한 분기처리가 가능합니다.

이번엔 Retrofit 인터페이스와 구현체를 만들어 보겠습니다. 세미나에서는 objectServiceCreator를 만들어 싱글톤으로 사용하는 방법에 대해 배웠습니다. 저는 Hilt를 사용해 의존성 주입(DI)을 프로젝트 전반에서 사용하고 있기 때문에, SingletonModule이라는 모듈 내에서 Retrofit 싱글톤 객체를 생성합니다. 코드는 이렇습니다.

@Module
@InstallIn(SingletonComponent::class)
object SingletonModule {
	....
    @SoptRetrofit
    @Singleton
    @Provides
    fun provideSoptRetrofit(
        @SoptClient client: OkHttpClient
    ): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BuildConfig.BASE_URL)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
    ....
}

이번엔 Retrofit 인터페이스를 보겠습니다.

interface SignUpService {
    @POST("/auth/signup")
    suspend fun postSignUp(
        @Body requestBody: RequestSignUp
    ): BaseResponse<ResponseSignUp>
}

회원가입 서버통신을 수행할 SignUpService 인터페이스입니다. 구조를 보면, POST 요청과 엔드포인트를 명시하고(@POST("/auth/signup")), Coroutine을 사용하기 때문에 suspend fun으로 선언하였으며 RequestBody를 담아 보내기 위해서 @Body requestBody: RequestSignUp을 파라미터로 받습니다. 또한 이 서버통신 메서드의 리턴 타입, 즉 서버통신의 응답을 받기 위해 BaseResponse<ResponseSignUp>을 리턴 타입으로 지정했습니다.

이번엔 이 인터페이스의 구현체가 어떻게 생성되는지 보겠습니다. 이 Service 인터페이스의 구현체는 DI 패키지 내의 ServiceModule에서 제공합니다.

@Module
@InstallIn(SingletonComponent::class)
object ServiceModule {
	....
    @Singleton
    @Provides
    fun provideSignUpService(@SoptRetrofit retrofit: Retrofit): SignUpService {
        return retrofit.create(SignUpService::class.java)
    }
    ....
}

이번엔 이 Service를 활용해 서버통신을 수행하는 Repository와 Repository 구현체를 보겠습니다.

// Repository
interface SignUpRepository {
    suspend fun signUp(name: String, email: String, password: String): Result<Int>
}
// RepositoryImpl
class SignUpRepositoryImpl @Inject constructor(
    private val service: SignUpService,
    private val coroutineDispatcher: CoroutineDispatcher
) : SignUpRepository {
    override suspend fun signUp(name: String, email: String, password: String): Result<Int> {
        return withContext(coroutineDispatcher) {
            kotlin.runCatching {
                service.postSignUp(RequestSignUp(name, email, password)).data.id
            }
        }
    }
}

SignUpRepository는 인터페이스로, signUp 메서드에 대한 선언만 존재합니다. 이 signUp 메서드의 구현은 SignUpRepository 인터페이스를 구현하는 SignUpRepositoryImpl 클래스 내부에 있습니다. 구현체는 SignUpServiceCoroutineDispatcher 객체를 주입받아 이를 활용해 해당 coroutineDispatcher의 context에서 service의 회원가입 메서드를 수행합니다.

이 Repository 역시 마찬가지로 Hilt Module을 통해 생성되어 적절한 곳에 주입됩니다. 이 Module의 이름은 RepositoryModule입니다. 아래 코드입니다.

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    @Singleton
    @Provides
    fun provideSignUpRepository(
        signUpService: SignUpService,
        @IoDispatcher coroutineDispatcher: CoroutineDispatcher
    ): SignUpRepository = SignUpRepositoryImpl(signUpService, coroutineDispatcher)
}

이번엔 이 Repository를 활용해 서버통신을 수행하는 ViewModel을 보겠습니다.

// ViewModel
@HiltViewModel
class SignUpViewModel @Inject constructor(
    private val signUpRepository: SignUpRepository
) : ViewModel() {
    var userId = MutableLiveData<String>()
    var userPassword = MutableLiveData<String>()
    var userName = MutableLiveData<String>()

    private var _isSuccess = SingleLiveEvent<Boolean>()
    val isSuccess: LiveData<Boolean> get() = _isSuccess

    private var _isDuplicated = SingleLiveEvent<Boolean>()
    val isDuplicated: LiveData<Boolean> get() = _isDuplicated

    private var _isEmpty = SingleLiveEvent<Boolean>()
    val isEmpty: LiveData<Boolean> get() = _isEmpty

    fun signUp() {
        var email = userId.value
        var password = userPassword.value
        var name = userName.value
        
        if (!userId.value.isNullOrBlank() && !userPassword.value.isNullOrBlank() && !userName.value.isNullOrBlank()) {
            email = email.toString()
            password = password.toString()
            name = name.toString()

            viewModelScope.launch {
                signUpRepository.signUp(name, email, password)
                    .onSuccess {
                        _isSuccess.value = true
                    }
                    .onFailure { exception ->
                        if ((exception as? HttpException)?.code() == 409) {
                            _isDuplicated.value = true
                        } else {
                            _isSuccess.value = false
                        }
                        Timber.e(exception)
                    }
            }
        } else {
            _isEmpty.value = true
        }
    }
}

참고로, SingleLiveEvent는 MutableLiveData를 상속받아 만든 클래스입니다. 이것에 대해서는 이 포스팅에서 다루지 않으나, 조금만 검색해도 많은 정보가 나오기 때문에 넘어가겠습니다. 전체적인 서버통신의 흐름을 먼저 살펴보겠습니다.

이름과 이메일과 비밀번호를 입력했다면, 회원가입 서버통신을 시도합니다. viewModelScope는 ViewModel의 생명주기를 따라가는 scope입니다. 이 viewModelScope에서 작업을 launch하는데, 의존성 주입을 통해 @Inject해준 SignUpRepository 객체 signUpRepositorysignUp 메서드를 호출합니다. 예외처리는 Repository 구현체에서 runCatching을 통해 수행하는데, 이 signUp 메서드는 Result 객체를 반환하기 때문에 Result 클래스의 확장함수인 onSuccessonFailure를 signUp 메서드의 반환값에 바로 이어서 사용할 수 있습니다.

서버통신 성공 시 _isSuccess의 value를 true로 set합니다. 실패 시에는, exception이 HttpException이며 status가 409인지 확인합니다. 이 경우는 중복된 이메일을 입력해서 서버통신이 실패한 것이므로, _isDuplicated의 value를 true로 set합니다. 그렇지 않다면, 즉 status != 409라면 이제 우리도 무슨 문제인지 알 수 없습니다. 따라서 _isSuccess의 value를 false로 set합니다.

마지막으로 View에서 어떻게 isSuccessisDuplicated를 활용하는지 확인해보겠습니다.

@AndroidEntryPoint
class SignUpActivity : BaseActivity<ActivitySignUpBinding>() {
    override val layoutRes: Int
        get() = R.layout.activity_sign_up

    private val signUpViewModel by viewModels<SignUpViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.viewmodel = signUpViewModel
        binding.lifecycleOwner = this

        signUp()
        observeSignUp()
    }

    private fun signUp() {
        binding.btnSignUp.setOnClickListener {
            signUpViewModel.signUp()
        }
    }

    private fun observeSignUp() {
        signUpViewModel.isSuccess.observe(this) {
            when (it) {
                true -> {
                    val intent = Intent(this, SignInActivity::class.java).apply {
                        putExtra("userId", binding.etUserId.text.toString())
                        putExtra("userPassword", binding.etUserPassword.text.toString())
                    }
                    setResult(RESULT_OK, intent)
                    if (!isFinishing) {
                        finish()
                    }
                }
                else -> {
                    Toast.makeText(this, "다시 시도해주세요", Toast.LENGTH_SHORT).show()
                }
            }
        }

        signUpViewModel.isEmpty.observe(this) {
            if(it) {
                Toast.makeText(this, "입력되지 않은 정보가 있습니다", Toast.LENGTH_SHORT).show()
            }
        }

        signUpViewModel.isDuplicated.observe(this) {
            if(it) {
                Toast.makeText(this, "이미 가입한 계정입니다", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

observeSignUp 메서드에서 isSuccessisDuplicated를 observe하고 있습니다. 이를 통해 isSuccess 값이 true이면 회원가입 성공으로, 로그인 화면으로 되돌아갑니다. 반면 isSuccess 값이 false라면, 회원가입 실패이며 이메일 중복인 상황은 아닙니다. 따라서 무언가 문제가 발생하였으며, 다시 시도해달라는 Toast를 띄웁니다. isDuplicated 값이 true인 경우는 명확합니다. 따라서 이미 가입한 계정입니다라는 Toast를 띄웁니다.

로그인 과정에 대해 궁금하시면 Github를 참고해 주세요. 큰 흐름은 회원가입과 다르지 않습니다.

성장과제

성장과제 1.

성장과제 1은 세미나 API가 아닌, 다른 오픈 API와 연동해보는 과제입니다. 그 중에서도 Github API와 연동합니다. 팔로워 목록을 Github API를 통해 받아오는 것입니다. 저는 이 4가지 API를 활용했습니다.

  1. 유저의 프로필 받아오기
  2. 유저의 팔로워 받아오기
  3. 유저의 팔로잉 받아오기
  4. 유저의 레포지토리 받아오기

그 중 유저의 팔로워를 받아오는 부분에 대해 리뷰하겠습니다.

먼저 Github API 문서를 봐야 합니다. 아래 주소는 유저의 팔로워 목록을 받아오는 API 문서입니다.

https://docs.github.com/en/rest/users/followers#list-followers-of-a-user

GET 요청을 /users/{username}/followers 엔드포인트로 보내면 됩니다. 참고로 Github API의 baseUrl은 https://api.github.com입니다.

Request의 파라미터는 필수적으로 username이라는 Path 파라미터가 들어가야 합니다. 나머지 HeaderQuery는 부가적인 것으로 여기서 다루지 않겠습니다.

Response는 제법 큰 객체의 배열입니다. 우리는 이 중에서 중요한 몇 가지들만 뽑습니다. 여기서는 저 큰 객체 중 2가지 속성만 사용할 것입니다.

login : Github 계정 ID입니다.
avatar_url : Github 계정 프로필 이미지입니다.

물론, Response 데이터 클래스의 속성을 저 2가지로만 구성해도 문제는 발생하지 않습니다. 하지만 저는 서버에서 주는 데이터는 일단 다 받고 클라이언트 내에서 필요한 데이터만 뽑아내서 사용하자는 주의이기 때문에, Response 데이터 클래스에 저 모든 것들을 선언해 주었습니다.

또한 이 Response는 배열이기 때문에, Response 데이터 클래스를 List<>로 감싸서 서버 통신의 응답을 받아야 합니다. 이 이후부터는 앞서 설명한 회원가입과 정확히 같은 방식으로 통신하기 때문에 코드만 첨부하겠습니다.

// Service
interface HomeService {
    @GET("/users/{userId}/followers")
    suspend fun fetchUserFollowers(
        @Path("userId") userId: String
    ): List<ResponseFetchUserFollowItem>
}
// ServiceModule
@Module
@InstallIn(SingletonComponent::class)
object ServiceModule {
    @Singleton
    @Provides
    fun provideHomeService(@GithubRetrofit retrofit: Retrofit): HomeService {
        return retrofit.create(HomeService::class.java)
    }
}
// Repository
interface HomeRepository {
    suspend fun fetchUserFollowers(userId: String): Result<List<UserFollowInformation>>
}
// RepositoryImpl
class HomeRepositoryImpl @Inject constructor(
    private val service: HomeService,
    private val coroutineDispatcher: CoroutineDispatcher
) : HomeRepository {
    override suspend fun fetchUserFollowers(userId: String): Result<List<UserFollowInformation>> {
        return withContext(coroutineDispatcher) {
            kotlin.runCatching {
                service.fetchUserFollowers(userId)
                    .mapIndexed { index, responseFetchUserFollowerItem ->
                        responseFetchUserFollowerItem.toUserFollowInformation().apply {
                            followOrder = index
                        }
                    }
            }
        }
    }
}
// RepositoryModule
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    @Singleton
    @Provides
    fun provideHomeRepository(
        homeService: HomeService,
        @IoDispatcher coroutineDispatcher: CoroutineDispatcher
    ): HomeRepository = HomeRepositoryImpl(homeService, coroutineDispatcher)
}
// ViewModel
@SuppressLint("NullSafeMutableLiveData")
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getUserIdUseCase: GetUserIdUseCase,
    private val homeRepository: HomeRepository
) : ViewModel() {
    private var id: String = ""
    
	private var _follower = MutableLiveData<List<UserFollowInformation>>()
    val follower: LiveData<List<UserFollowInformation>> get() = _follower
    
    fun getFollowerList() {
        viewModelScope.launch {
            when (id) {
                "" -> {
                    getUserIdUseCase()
                        .onSuccess {
                            homeRepository.fetchUserFollowers(id)
                                .onSuccess { list ->
                                    _follower.value = list
                                }
                                .onFailure { exception ->
                                    Timber.e(exception)
                                }
                        }
                }
                else -> {
                    homeRepository.fetchUserFollowers(id)
                        .onSuccess { list ->
                            _follower.value = list
                        }
                        .onFailure { exception ->
                            Timber.e(exception)
                        }
                }
            }
        }
	}
}

성장과제 2.

성장과제 2는 각 Response 데이터 클래스마다 공통으로 중복되는 status와 message를 어떻게 줄일 수 있을지 고민하는 과제입니다. 이를 해결하기 위해서는 Generic이라는 개념을 알아야 합니다.

Generic을 가장 쉽게 이해할 수 있는 키워드는 일반화라고 생각합니다.

Generic은 여러 언어에서 지원하는 개념입니다. Generic이란 데이터의 타입을 일반화하는 것입니다. 클래스나 메서드에서 사용할 내부 타입을 컴파일 시 미리 지정하는 방식입니다. 세미나 API의 Response는 statusmessage는 항상 Int, String이며 서버에서 보내주는 data만 매번 타입을 바꿔주면 하나의 Response 클래스를 여러 곳에서 재활용할 수 있습니다. 실제 코드로 보겠습니다. 아래 코드는 제 BaseResponse 클래스입니다.

data class BaseResponse<T>(
    val status: Int,
    val message: String,
    val data: T
)

data의 타입은 T 타입입니다. 이 T 타입은 BaseResponse 객체를 선언할 때 지정해줘야 합니다. 실제로는 이렇게 사용합니다.

interface SignUpService {
    @POST("/auth/signup")
    suspend fun postSignUp(
        @Body requestBody: RequestSignUp
    ): BaseResponse<ResponseSignUp>
}

앞서 본 회원가입 Service입니다. 이 Service의 메서드인 postSignUp()의 리턴 타입은 BaseResponse입니다. 하지만 그저 BaseResponse라고만 적으면, 컴파일러는 이 BaseResponse 안의 data가 어떤 타입인지 알지 못합니다. 그래서 객체 생성 시 T 타입에 대한 명시가 필요합니다. 이 부분이 바로 <> 안에 들어가는 ResponseSignUp입니다.

data class ResponseSignUp(
    val id: Int
)

즉, postSignUp 메서드의 리턴 타입은 data의 타입이 ResponseSignUpBaseResponse가 됩니다. 이렇게 Generic을 사용하면 T 타입의 data를 갖는 BaseResponse로 일반화할 수 있습니다. 이 BaseResponse는 다른 곳에서도 재활용이 가능합니다. 로그인 Service를 같이 보겠습니다.

interface SignInService {
    @POST("/auth/signin")
    suspend fun postSignIn(
        @Body requestBody: RequestSignIn
    ): BaseResponse<UserInformation>
}

여기서도 postSignIn 메서드의 리턴 타입은 BaseResponse입니다. 달라진 점은, 이 BaseResponse의 data의 타입입니다. 이번에는 UserInformation 타입으로 바뀌었습니다. UserInformation 클래스도 보겠습니다.

data class UserInformation(
    val email: String,
    val name: String
)

편리하지 않나요? BaseResponse를 한 번만 만들어두면, 우리는 서버통신 응답에 대한 데이터 클래스를 만들 때 매번 statusmessage를 만들지 않아도 됩니다. 이 개념은 실제로 여러 곳에서 사용됩니다. 예를 들어, ActivityFragment의 보일러 플레이트 코드를 줄이기 위해 사용하는 BaseActivity, BaseFragment에서도 사용될 수 있습니다.

// BaseActivity
abstract class BaseActivity<T : ViewDataBinding> : AppCompatActivity() {
    protected lateinit var binding: T
    abstract val layoutRes: Int

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycle.addObserver(MyDefaultLifecycleObserver())
        binding = DataBindingUtil.setContentView(this, layoutRes)
        binding.lifecycleOwner = this
    }
}
// BaseFragment
abstract class BaseFragment<T : ViewDataBinding> : Fragment() {
    private var _binding: T? = null
    protected val binding get() = _binding ?: error("Binding not Initialized")
    abstract val TAG: String
    abstract val layoutRes: Int

    override fun onAttach(context: Context) {
        super.onAttach(context)
        Timber.tag(TAG).i("onAttach")
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycle.addObserver(MyDefaultLifecycleObserver())
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        Timber.tag(TAG).i("onCreateView")
        _binding = DataBindingUtil.inflate(layoutInflater, layoutRes, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val imm: InputMethodManager =
            requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        imm.hideSoftInputFromWindow(getView()?.windowToken, 0)
        Timber.tag(TAG).i("onViewCreated")
    }

    override fun onViewStateRestored(savedInstanceState: Bundle?) {
        super.onViewStateRestored(savedInstanceState)
        Timber.tag(TAG).i("onViewStateRestored")
    }


    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        Timber.tag(TAG).i("onSaveInstanceState")
    }

    override fun onDestroyView() {
        _binding = null
        super.onDestroyView()
        Timber.tag(TAG).i("onDestroyView")
    }

    override fun onDetach() {
        super.onDetach()
        Timber.tag(TAG).i("onDetach")
    }

    companion object {
        @JvmStatic
        fun newInstance() = this
    }
}

이처럼 Generic을 잘 활용하면 매번 타입이 달라질 때마다 중복된 코드를 작성하지 않고, 하나의 Base 클래스를 활용해 매번 재사용하며 타입만 바꿔 끼울 수 있다는 장점이 있습니다.

도전과제

도전과제 1.

도전과제 1은 세미나 방식의 Retrofit Call을 이용한 서버통신의 코드가 너무 길다는 점에서 시작합니다. 매 서버통신마다 저렇게 길게 Callback을 작성하면 피로가 너무 큽니다. 그리고 다른 간결한 방식으로 똑같은 기능을 수행할 수 있다면, 그걸 공부해볼 가치가 있다고 생각합니다.

비동기 처리를 하는 방법은 다양합니다. 새로운 Thread를 생성해 그 Thread에서 별도의 작업을 처리할 수도 있고, RxJavaRxKotlin 라이브러리를 사용할 수도 있습니다. 그리고 Thread보다 훨씬 가벼운 Coroutine을 사용할 수도 있습니다. 저는 그 중에서 Coroutine을 사용해 구현해 봤습니다.

Coroutine은 동시성 프로그래밍 개념을 지원하는 라이브러리입니다. suspend라는 특성을 활용해 어떤 작업을 하다가 중단하고 다른 작업을 하기도 하고, 다시 중단했던 그 지점으로 돌아가 이어서 작업하기도 합니다. CoroutineDispatcher를 사용해 다른 스레드에서 작업을 하기도 합니다.

사실 Coroutine 하나를 설명하기 위해 지금까지 쓴 세미나 과제 리뷰들보다 더 많은 양을 할애해야 할 수도 있습니다. 그래서 긴 설명은 여기서는 하지 않으려고 합니다.

또, 꼭 Coroutine을 써야하는 것은 아닙니다. Thread를 생성해 비동기 처리를 구현해도 되고, RxJava, RxKotlin을 사용해도 상관없습니다. 하지만 Coroutine을 사용하면 코드가 간결해지고 이해하기 쉬워지는 것은 사실입니다. 가장 좋은 것은 이 모든 것을 알고 상황에 적절한 방식으로 구현하는 것이라고 생각합니다. 아직도 갈 길이 멀다는 것을 한 번 더 느끼며 4차 세미나 과제 리뷰를 마무리하겠습니다.

profile
영차영차

0개의 댓글