[데브인턴십 - 몰입캠프 2022 겨울학기]스플 안드로이드 앱 개발 인턴십 후기

이현정

업데이트:

[system] 스플 초보 개발자 업적 획득 성공!

띵스플로우 입사 후 본격적으로 실제 앱에 기여하기 전, 3주간 스플에서 사용하는 안드로이드 앱의 구조와 기술 스택을 익히는 시간을 가졌습니다. 스플은 Single Activity 기반으로 MVVM 패턴을 지향합니다. 처음에는 그런 구조나 디자인 패턴 같은 기본적인 내용을 이해할 수 있는 온보딩 과제가 주어졌고, 과제를 진행하면서 DI, MVVM 패턴, viewBinding 등 여러 스택을 익힐 수 있었습니다. 이후 스플 코드를 리팩토링하는 간단한 과제를 진행하면서 스플 안드로이드 코드를 이해하는 과정을 거쳤습니다.


첫 번째 스프린트 (220627)

채팅 내 하드웨어 볼륨 키 미디어 볼륨 조절

1

앱 내에서 하드웨어 볼륨 키를 작동할 때, 기존에는 소리가 나오는 경우에만 미디어 볼륨 조절이 가능했습니다. 하지만 채팅 진행 중 효과음처럼 짧은 소리가 나올 경우 볼륨을 조절하기 위해 하드웨어 볼륨 키를 작동해도 이미 효과음 출력이 끝나 기기 볼륨 조절로만 작동되는 불편함이 있었습니다. 그래서 채팅 내부에서 하드웨어 볼륨 키를 조작했을 때는 항상 미디어 볼륨이 조절되도록 하였습니다.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    ...
    activity?.volumeControlStream = AudioManager.STREAM_MUSIC
}

override fun onDestroyView() {
    ...
    activity?.volumeControlStream = AudioManager.STREAM_RING
    super.onDestroyView()
}

처음에는 하드웨어 키 이벤트를 인식하게 하고, 그 이벤트에 대한 리스너를 만드는 방법을 도입할까 싶었지만 불필요한 코드 추가가 많아보여서 찾아보다가 알게 된 것이 AudioManager의 volumeControlStream 기능이었습니다. 우선 ChatFragment로 진입해서 view가 create 될 때 무조건 기본 볼륨 조절은 미디어 볼륨(STREAM_MUSIC)을 조절하도록 설정을 해주었습니다. 다만 이 기능은 fragment에서는 단독으로 작동하지 않았기 때문에 activity를 참조해주었습니다.

해당 피쳐 진행 중 간과했던 것은 요구조건을 정확히 파악하는 것이었습니다. 하드웨어 볼륨 키를 작동했을 때 채팅창 내부에서만 기본값이 미디어 볼륨 조절이 되도록 하고, 채팅창을 벗어났을 때는 다시 원 상태로 돌아와야 한다는 것이 요구사항이었습니다. 그래서 뷰가 destroy될 때 하드웨어 볼륨 키로 다시 기기 볼륨을 조절할 수 있도록 설정해주었습니다.

아이템 창 기능 추가 구현

아이템 창과 관련된 기능을 몇 가지 추가하는 작업을 진행했습니다.

  • 아이템 클릭 시 설명 다이얼로그

2

채팅 진행 중 아이템을 획득했을 때는 아이템명과 해당 아이템에 대한 설명이 함께 팝업창으로 뜹니다. 하지만 획득한 후 ‘내 아이템’ 창에서는 아이템 목록만 제공될 뿐 아이템에 대한 설명은 다시 볼 수 없었습니다. 그래서 ‘내 아이템’ 창에서 아이템을 클릭했을 때 해당 아이템에 대한 설명 다이얼로그가 다시 뜨도록 하는 기능을 구현하였습니다.

private fun renderUi(item: StoryItem) = with(binding) {
    ...
    root.setOnClickListener {
        event.onClickItem(item)
    }
}

interface Event : HolderEvent {
    fun onClickItem(item: StoryItem)
}

먼저 아이템 목록을 띄워주던 리사이클러뷰에 클릭 이벤트를 추가해주기 위해 Holder에 이벤트와 리스너를 추가해주었습니다. 그리고 이 Holder의 이벤트를 활용하여 새로운 아이템 이벤트 클래스를 생성한 후 이를 Dialog 창에서 이벤트로 받을 수 있도록 했습니다.

private val _showItemEvent = MutableLiveData<Event<StoryItem>>()
val showItemEvent: LiveData<Event<StoryItem>> = _showItemEvent

fun onClickItem(item: StoryItem) {
    _showItemEvent.value = Event(item)
}

ItemDialog 창에서 클릭을 인지하고 팝업 창을 띄울 수 있도록 하기 위해 먼저 viewModel에 LiveData를 추가하고, 클릭이 됐을 때 그 값에 변화를 주는 함수를 추가하였습니다.

protected val adapter by lazy {
	...
  event(showItemEvent) {
      showItem(it)
  }
  ...
}
...
private fun showItem(item: StoryItem) {
    ItemPopupDialog().apply {
        // 아이템 정보
        dialogType = ItemPopupDialog.DialogType.SHOW
    }.show(childFragmentManager, ItemPopupDialog.TAG)
}

이후 ItemDialog 창에서 이를 옵저빙하고, 값이 바뀔 때는 아이템 설명 팝업창을 띄우도록 했습니다. 처음에는 새로 아이템을 획득할 때와 같은 형태의 팝업창을 띄우기 때문에 ui를 함께 쓰도록 적용했지만, 사실 새로 아이템을 획득할 때와 획득한 아이템을 조회할 때는 팝업 창의 제목이나 버튼의 텍스트가 달라야 했습니다. 그래서 띄워야 할 다이얼로그 창을 dialogType 변수로 구분할 수 있도록 하고, 새로 획득은 NEW, 이미 획득한 것을 조회할 때는 SHOW를 띄우도록 했습니다.

override fun setupComposeUi(): View {
    return ComposeView(requireContext()).apply {
        setContent {
            LayoutObtainDialog(
                if (dialogType == DialogType.NEW) stringResource(R.string.item_gain)
                else stringResource(R.string.item_collection),
                ...
                if (dialogType == DialogType.NEW) stringResource(R.string.obtain)
                else stringResource(R.string.item_confirm)
            ) {
                dismiss()
            }
        }
    }
}
...
enum class DialogType {
    NEW, SHOW
}

그래서 위와 같이 compose를 활용해서 ui를 구성할 때 조건에 따라 다르게 구성할 수 있도록 나누어 주었습니다. 동시에 채팅 창에서 아이템 팝업 다이얼로그를 apply할 때는 dialogType을 NEW로 설정했고, 아이템 창에서 apply할 때는 SHOW로 설정해서 팝업 창으로 진입할 수 있도록 설정했습니다.

  • 아이템 개수 표시

3

기존에는 아이템 창에서 같은 아이템이 몇 개가 있든 그 개수를 알 수 없었습니다. 그래서 동일 아이템이 2개 이상 있을 때 개수를 함께 띄우는 기능을 추가했습니다. 변경된 디자인도 동시에 반영하였습니다.

override fun onDraw(canvas: Canvas) {
    ...
    for (i in 0 until layout.lineCount) {
        val left: Float = layout.getLineLeft(i)
        val baseLine: Int = layout.getLineBaseline(i)

        paint.style = Paint.Style.STROKE
        paint.color = strokeColor
        canvas.drawText(
            text,
            left + totalPaddingLeft,
            baseLine.toFloat(),
            paint
        )

        paint.style = Paint.Style.FILL
        paint.color = currentTextColor
        canvas.drawText(
            text,
            left + totalPaddingLeft,
            baseLine.toFloat(),
            paint
        )
    }
}

숫자 표기 방식이 진한 색상의 stroke+밝은 안쪽 색상이었기 때문에 처음에는 textView의 onDraw를 활용해서 stroke를 설정하고 그려넣어주었습니다. 하지만 계속해서 숫자 양 옆 잘림 문제가 있었습니다. Padding의 문제도 아니었기 때문에 이 문제를 해결하기 위해 고민하다가 기본 onDraw를 사용하지 말고 직접 커스텀해서 drawText를 하기로 결정했습니다. 그래서 숫자를 표기할 커스텀한 TextView 클래스에서 onDraw를 override 해서, stroke와 fill을 그려주면서 동시에 padding을 넣은 위치로 그려주도록 설정했고, 잘림 문제는 해결되었습니다.

  • 아이템 이미지 full & round 표시

4

스프린트 종료 전 간단하게 아이템창의 디자인을 변경하는 문제를 해결했습니다. 디자인이 바뀌면서 테두리가 생겼는데, 처음에는 안드로이드와 iOS가 이미지를 처리하는 방법이 달랐습니다. 안드로이드는 어떤 이미지가 들어오든 패딩이 항상 존재했는데, 디자인적 요소로 어떤 것이 더 나을지 따져보았을 때 iOS처럼 이미지를 테두리 내부에 full로 차도록 처리하는 것이 좋다고 결정이 났습니다. 하지만 이미지를 그냥 딱 맞게 받아오면 테두리를 가리는 문제가 있었기 때문에 테두리 두께만큼 패딩을 주고 이미지를 모서리에 round를 줘서 자른 후 가져와서 문제를 해결하였습니다.

첫 번째 스프린트를 진행하며 가장 크게 느꼈던 것은 컴케의 중요성과 코드리뷰의 장점이었습니다. 같은 직무가 아닌 타 직무의 팀원분들과 서로 이해한 내용이 같은지 주기적으로 확인하고, 더 정확한 구현을 위해 하는 컴케는 보다 좋은 결과를 가져온다는 것을 알게 되었습니다. 그리고 코드리뷰를 받으면서 제 코드의 좋지 않은 부분은 수정하고 더 깔끔하게 코드를 작성하는 방법을 배우게 됐습니다.


두 번째 스프린트 (220719)

아이템 획득/소모 시 토스트에 개수 추가

5

아이템의 개수가 드러나면서 아이템을 획득하거나 소비할 때도 “몇 개”를 하는지를 함께 토스트에 띄우는 기능을 추가해야했습니다.

해당 피쳐를 진행하면서 간단한 문제임에도 꽤 많은 고민을 했습니다.

  1. 새로운 LiveData 변수를 만들어서 뷰모델에서 값을 변경하고 ChatFragment에서 그 값을 사용하는 방법

    → MVVM 구조를 해치지 않기 위해 최대한 뷰모델의 값을 직접 활용하는 것은 지양 필요

  2. 뷰모델에 새 변수를 만들고 ChatFragment에서 옵저빙

    → 토스트는 획득 다이얼로그가 dismiss 된 이후에 떠야하므로 타이밍을 맞출 수 없음

  3. StoryItem에 아이템 증감을 나타내는 변수 추가

    → 처음 데이터 클래스를 만들 때 담겼던 의도를 해칠 수도 있음

  4. 새로운 업데이트용 StoryItem 데이터 클래스 생성 후 활용

    → StoryItem을 활용하고 있던 모든 코드에 많은 변화가 필요시 될 것 같아 보류

다행히 이런 제 고민들을 여쭤보고 의견을 나눈 결과 데이터 클래스의 의도를 심하게 해치지 않는다는 판단이 들어 3번 방안으로 간단하게 해결할 수 있었습니다. (간단한 문제임에도 여러가지 요소를 고려할 수도 있다는 것을 알려준 피쳐였습니다.)

data class StoryItem(
    val itemName: String,
    val itemCount: Int,
    val itemDescription: String,
    val itemImageUrl: String?,
    val whenChoice: Boolean = false,
    val itemIncrease: Int = 0
)

가장 먼저 아이템에 대한 정보가 담겨있는 데이터 클래스에 아이템의 증감을 나타내는 변수를 추가했습니다.

(itemEntity.increase > 0) -> { // 증가할 때(획득)
    _itemEvent.value = Event(
        StoryItem(
            ...
            itemIncrease = itemEntity.increase
        )
    )
    ...
}
(itemEntity.increase < 0) -> { // 감소할 때(소비)
    _itemToastEvent.value = Event(
        StoryItem(
            ...
            itemIncrease = itemEntity.increase
        )
    )
    ...
}

이후 정보를 받아오면서 아이템의 개수가 증가하거나 감소할 때 해당 변수에 그 값을 담아주었고, 토스트를 발생시킬 때 그 값을 함께 넘겨주어 같이 표현할 수 있도록 했습니다.

등장인물 프로필 이미지 변경 기능 추가

등장인물 프로필 이미지를 변경할 수 있도록 기능을 추가하는 것이 두 번째 스프린트에서 제가 맡은 가장 중요한 업무였습니다. 먼저 기존 온보딩 팝업의 UI를 수정했고, 그 후 프로필 이미지들을 띄울 프로필 리스트 화면과 사진을 변경할 때 띄울 바텀시트를 추가했습니다. 마지막으로 서버와 사진을 문제없이 주고받을 수 있도록 기존 온보딩 팝업의 API를 수정했습니다.

  • 기존 온보딩 팝업 UI 수정

6

프로필 이미지 변경이 가능한 온보딩 팝업에서는 편집 아이콘이 나타나고, 변경이 불가능한 팝업에서는 편집 아이콘이 사라져야 했습니다. 그래서 서버에서 내려준 canUploadProfileImage를 받아올 수 있도록 graphql 파일을 수정하고, 그 값이 true인지 false인지에 따라 참(업로드 가능)이면 아이콘 view를 visible하게, 거짓(업로드 불가능)이면 아이콘 view를 gone으로 처리해주었습니다.

fragment OnBoardingPopupsDto on Chapter {
    onboardingPopups {
        ...
        canUploadProfileImage
        ...
        popupProfileImages {
            popupId
            profileImageFileId
            profileImageFile {
                link
            }
        }
    }
}
if (popup.canUpload) {
    binding.btnImageChoiceProfile.visibility = View.VISIBLE
    // 뷰모델의 팝업 or 바텀시트 apply 함수 콜
} else {
    binding.btnImageChoiceProfile.visibility = View.GONE
}

또한 등록이 되어있는 기본 이미지 리스트들을 받아올 수 있도록 popupProfileImages를 graphql 파일에 함께 추가를 해서 link를 받아왔습니다.

  • 프로필 리스트 화면 추가

7

다음으로는 프로필 이미지 변경 가능 온보딩 팝업이면서 기본 프로필 이미지 리스트의 size가 1 이상이면 해당 프로필 이미지들을 띄워줄 프로필 리스트 화면을 구현했습니다.

fun editProfileImage(popupProfileImages: List<Profile.ProfileImage>) {
  when {
      popupProfileImages.isNotEmpty() -> {
          _showProfileListDialogEvent.value = Event(popupProfileImages)
      }
      else -> {
          // 기본 리스트 없을 때(바텀시트 바로 띄우기)
      }
  }
}

이전에 받아왔던 프로필 이미지들이 리스트로 들어왔을 때, 그 길이를 뷰모델에서 체크하게 합니다. 리스트가 비어있을 때는 바텀시트를 바로 띄우고 하나라도 이미지가 있을 경우 프로필 리스트 화면을 띄워줘야 했기 때문에 조건에 따라 나누어서 각기 다른 이벤트를 실행, 온보딩 다이얼로그에서 해당 이벤트를 각각 옵저빙하도록 했습니다.

init {
        lifecycleScope.launchWhenCreated {
            selectProfileImageManager = SelectProfileImageManager(
                this@ChatOnBoardingDialog,
                object : SelectProfileImageManager.OnSelectedImageListener {
                    override fun onSelected(uri: Uri) {
                        // 사용자 커스텀 이미지
                    }

                    override fun onSelected(defaultImage: String) {
                        // 기본 이미지로 변경
                    }
                }
            )
        }
    }

private fun observeUi() = with(viewModel) {
    ...
    event(showProfileListDialogEvent) {
        ProfileImageListDialog().apply {
            ...
            data = it
            onResult = { selected, custom ->
                selected?.let { (imageId, image) ->
                    viewModel.selectedProfileImage(imageId, image)
                } ?: run {
                    custom?.let { customImage ->
                        viewModel.setCustomProfileImage(customImage)
                    }
                }
            }
        }.show(requireActivity().supportFragmentManager, ProfileImageListDialog.TAG)
    }
    ...
}

프로필 리스트 화면(다이얼로그)을 띄워야 할 때 선택된 사진이 이미지 선택 모듈(selectProfileImageManger)을 통해서 결과로 넘어옵니다. 받아온 이미지를 가지고 커스텀된 이미지일 경우 사용자의 이미지를 서버로 업로드하는 뷰모델의 함수를 실행시키고, 기존 이미지일 경우 선택된 이미지의 아이디를 서버로 넘기는 함수를 호출합니다.

sealed class OnBoardingInfo(
    ...
) {
    data class Name(
        ...
        val profileImages: List<Profile.ProfileImage>,
        ...
    ) : OnBoardingInfo(popupId, content, isSelected, index, hasNext, visibleCondition)
}

이미지를 받아올 때는 온보딩 데이터 클래스에 프로필 이미지 리스트를 받아오는 변수를 추가하여 받아오도록 했습니다.

sealed class OnBoardingPopupListEntity(
    ...
) {
    ...
    data class Name(
        ...
        val profileUrl: String,
        val profileImages: List<Any>,
        ...
    ) : OnBoardingPopupListEntity(popupId, visibleConditions)
}
class OnBoardingPopupListEntityMapper @Inject constructor() {
  fun map(
      ...
  ): List<OnBoardingPopupListEntity> {
      if (from.isEmpty()) {
          return listOf(
              OnBoardingPopupListEntity.Name(
                  ...
                  profileImages = listOf()
              )
          )
      } else {
          return from.map { popup ->
              when (popup.type) {
                  ...
                  ONBOARDING_POPUP_TYPE.NAME, ONBOARDING_POPUP_TYPE.OTHERNAME -> {
                      OnBoardingPopupListEntity.Name(
                          ...
                          profileImages = popup.popupProfileImages!!.map { image ->
                              ProfileImageListEntity(
                                  popupId = image.popupId,
                                  profileImageFileId = image.profileImageFileId,
                                  profileImageFileLink = image.profileImageFile?.link ?: ""
                              )
                          },
                          ...
                      )
                  }
                  ...
              }
          }
      }
  }
}

또한 이미지를 서버에서 받아오면서 기존에는 존재하지 않던 이미지 리스트를 받아오기 때문에 그 과정에서 map을 해주는 mapper의 수정이 꼭 필요했기 때문에 OnBoardingPopupListEntityMapper와 OnBoardingPopupMapper도 일부 수정을 해야 했습니다.

그 외에 프로필 이미지 리스트 화면을 구현할 때 제가 신경썼던 요소들은 다음과 같습니다.

  1. 사진 추가 버튼과 이미지 함께 리사이클러뷰 추가 → Sealed class 활용

    프로필 리스트 화면에서 하단 리사이클러뷰에 사진 추가 버튼과 이미지를 함께 추가해야하는 것이 문제였습니다. 같은 ‘Profile’ 데이터 클래스로 구현하자니 사진 추가 버튼을 클릭했을 때와 이미지를 클릭했을 때 각각 발생시켜야 하는 이벤트가 달랐기 때문입니다. 이때 온보딩 과제에서 처음 사용해본 sealed class를 사용해서 프로필 이미지와 사진 추가 버튼을 두 타입으로 나누어서 다루었습니다.

     sealed class Profile {
         data class ProfileImage(
             val popupId: Int,
             val profileImageId: Int?,
             val link: String,
             val localImage: Uri?,
             val selected: Boolean
         ) : Profile()
        
         object ProfileAdd : Profile()
     }
    

    데이터 타입을 두 가지로 나눴기 때문에 이후 프로필 리스트 뷰모델에서 각 아이템을 클릭했을 때 발생하는 함수도 따로 나누어 처리할 수 있었습니다. 각 함수에서는 각기 다른 이벤트를 발생시켜 프로필 리스트 화면에서 기능해야할 것들도 나눌 수 있었습니다.

     fun onClickItem(item: Profile.ProfileImage) {
     	// 이미지 클릭 시 이벤트 발생
         _showProfileImageEvent.value = Event(item)
         ...
     }
        
     fun onClickAdd(item: Profile.ProfileAdd) {
         // 사진 추가 버튼 누른 이벤트 발생
     	_showImageGalleryEvent.value = Event(item)
     	...
     }
    
  2. 선택 사진 UI 변경 → selected 변수로 판단, viewModel에서

    하단 리사이클러뷰에서 사용자가 이미지를 선택했을 때 사진처럼 이미지의 테두리와 이미지를 덮고 있는 뷰의 색상이 변하도록 설정해야 했습니다. 저는 처음에 리사이클러뷰에 이미지를 넣으며 Holder에서 그 설정을 처리해줘야 한다고 생각했습니다. 하지만 position에 대한 정보 등은 얻을 수 있어도 UI에 변화를 주는 것이 불가능 했습니다.

    이때 코드리뷰를 바탕으로 select된 이미지인지 아닌지를 나타내주는 변수를 하나 데이터 클래스에 추가한 후 Holder가 아닌 뷰모델에서 그 아이템의 정보를 변경시키는 것이 좋다는 것을 알았습니다. 실제로 Holder는 매번 초기화되기 때문에 그 순간은 선택된 것을 알았더라도 다시 리사이클러뷰가 로드되면 그 정보는 사라지고 reset되기 때문입니다.

     fun onClickItem(item: Profile.ProfileImage) {
         _showProfileImageEvent.value = Event(item)
         (profileImageListState.value as? ProfileImageListState.Data)?.data?.let {
             val mutableList = mutableListOf<Profile>()
             for (profile in it) {
                 when (profile) {
                     is Profile.ProfileImage -> {
     			// 선택한 이미지와 같은 이미지를 리스트에서 골라 selected 됐다고 설정해준다.
                         if (profile.profileImageId == item.profileImageId) {
                             mutableList.add(profile.copy(selected = true))
                         } else {
                             mutableList.add(profile.copy(selected = false))
                         }
                     }
                     is Profile.ProfileAdd -> {
                         mutableList.add(profile)
                     }
                 }
             }
             _profileImageListState.value = ProfileImageListState.Data(data = mutableList)
         }
     }
        
     fun onClickAdd(item: Profile.ProfileAdd) {
         // 사진 추가 버튼 누른 이벤트 발생
     }
    

    따라서 뷰모델에 이미지를 클릭했을 때 실행되는 함수에 리사이클러뷰에 넣어줄 모든 이미지들을 한번씩 순회하며 선택된 이미지와 같은 이미지의 경우 해당 이미지 selected 값을 true로 변경시켜주었습니다.

  • 사진 변경 바텀시트 추가

8

바텀시트가 뜨는 경우는 다음과 같습니다.

  1. 기본 프로필 이미지 리스트가 없을 때, 온보딩 팝업 편집 아이콘 클릭 시 바로
  2. 프로필 리스트 화면에서 사진 추가 버튼을 눌렀을 때

바텀시트의 종류도 두 가지입니다.

  1. 기본 이미지로 변경이 있는 경우
  2. 기본 이미지로 변경 없이 두 가지 선택지만 있는 경우

첫 번째 바텀시트가 뜨는 경우는 딱 한가지 입니다. 기본 프로필 이미지 리스트가 없어 온보딩 팝업에서 바로 사진 편집을 할 때, 초기 상태가 아닌 이미 n번 사용자가 사진을 변경한 경우입니다. 두 번째 바텀시트는 그 외 모든 경우에 해당합니다.

fun editProfileImage(popupProfileImages: List<Profile.ProfileImage>) {
    when {
        popupProfileImages.isNotEmpty() -> {
            ...
        }
        else -> {
            (onBoardingInfo.value as? OnBoardingInfo.Name)?.let { currentBoardingInfo ->
                val origin =
                    onBoardingLists.value?.find { it.popupId == currentBoardingInfo.popupId } as? OnBoardingInfo.Name
		// 현재 이미지가 기본 이미지일 때(기본 이미지로 변경 띄우지 않음)
                if (currentBoardingInfo.selectedProfileImageState == OnBoardingInfo.ProfileImageState.DEFAULT) {
                    _showBottomSheetEvent.value = Event(origin?.profileUrl)
                } else { // 현재 이미지가 변경된 이미지일 때(기본 이미지로 변경 띄움)
                    _showDefaultBottomSheetEvent.value = Event(origin?.profileUrl)
                }
            } ?: run {
                _showBottomSheetEvent.value = Event(null)
            }
        }
    }
private fun observeUi() = with(viewModel) {
    ...
    event(showBottomSheetEvent) {
        selectProfileImageManager.showTextListBottomSheet(null)
    }

    event(showDefaultBottomSheetEvent) {
        selectProfileImageManager.showTextListBottomSheet(it)
    }
    ...
}

그리고 각 이벤트에 따라서 selectProfileImageManager의 함수를 각기 다르게 호출합니다. null값이 바텀시트를 보여주는 해당 함수로 넘어가게 되면 기본 이미지로 변경하는 항목은 보이지 않게 설정되어 나오고, 디폴트 이미지가 해당 함수로 넘어가면 기본 이미지로 변경 항목이 나오면서 클릭하는 경우 함수로 넘겨진 기본 이미지로 변경됩니다.

그 밖에 기본 이미지가 없는 경우 안드로이드의 팝업 구현도 변경하였습니다. 기존에 안드로이드는 기본 이미지가 없을 때 아예 원형의 이미지 뷰 자리를 남겨두지 않았습니다. 하지만 기본 이미지가 없더라도 이미지 변경 버튼을 제 위치에 삽입해야 했기 때문에 안드로이드도 iOS와 마찬가지로 빈 둥근 화면을 표시하기로 했습니다.

  • 기존 온보딩 팝업 API 수정

프로필 이미지 변경 작업을 하면서 가장 어려웠던 작업이었습니다.

서버와 성공적으로 이미지를 주고받기 위해서는 다음과 같은 방법으로 진행이 되어야 했습니다.

  1. 온보딩 팝업 정보에 담긴 boolean 정보로 프로필 이미지 업로드 가능 여부를 판단
  2. 기본 프로필 이미지가 있는 경우 해당 챕터 온보딩 팝업들 정보를 담고 있는 곳의 프로필 이미지 리스트 정보를 받아와서 링크를 가져와 사용
  3. 사용자의 이미지를 업로드 할 경우 s3에 먼저 업로드한 후 이미지의 정보를 서버에 넘겨줘야 한다.
    1. getPresignedUrls로 파일을 업로드 할 링크를 서버에서 받아온다. hashId, fileName등 파일에 대한 저장 정보가 리턴된다.
    2. 리턴된 정보를 가지고 있다가 온보딩 팝업이 끝난 후 startStory로 채팅이 시작될 때 customProfileFileInfo에 해당 정보를 담아 넘겨줘야 한다.
  4. 기본 프로필 이미지를 넘기는 경우 그냥 변경된 이미지의 profileImageFileId를 다시 넘겨주면 된다.

하지만 여기서 생각지못한 문제가 있었는데, 이미지가 s3에 잘 업로드 되었음에도 해당 정보를 넘겼을 때 서버에서는 그 이미지가 내려오지 않아 Api에 CustomProfileFileInfo를 추가하니 아예 채팅 진입에 오류가 뜨길래 고전하고 있었는데 문제는 storageType 때문이었습니다.

fun startStory(
    param: StartStoryParams
): Observable<PlayChapterInfoDto> {
    return apolloClient.mutate(
        StartStoryMutation(
            ...
            Input.optional(
                param.otherCharacterInfoList.map { info ->
                    lateinit var result: OtherCharacterInfo
                    lateinit var storageType: Storages
                    // storageType이 Unknown으로 인지되던 문제
                    info.customProfileFileInfo?.let {
                        when (it.storageType) {
                            "STORYFILES" -> {
                                storageType = Storages.STORYFILES
                            }
                            "IMAGEFILE" -> {
                                storageType = Storages.IMAGEFILE
                            }
                            "SFXFILE" -> {
                                storageType = Storages.SFXFILE
                            }
                            "STUDIOFILE" -> {
                                storageType = Storages.STUDIOFILE
                            }
                            else -> {
                                storageType = Storages.UNKNOWN__
                            }
                        }
                        result = OtherCharacterInfo(
                            ...
                            Input.optional(
                                CustomProfileFileInfo(
                                    hashId = it.hashId,
                                    isTemp = it.isTemp,
                                    fileName = it.fileName,
                                    storageType = storageType
                                )
                            )
                        )
                    } ?: run {
                        result = OtherCharacterInfo(
                            ...
                        )
                    }
                    result
                }
            ),
            ...
        )
    )
    ...
}

s3에 이미지를 저장하고 나면 storageType이라는 것이 리턴됩니다. 해당 정보도 다시 서버로 넘겨줘야 했는데, ApiService에서 Storages 타입의 데이터가 잘 넘어가지 않아 오류가 발생했습니다. 왜 unknown으로 분류가 되는지 살펴보기 위해 storageType이 어떻게 넘어오는지 확인해보았는데, Storages에서는 타입을 구분하는 raw value로 “StoryFiles”를 활용하고 있었고, 서버에서 넘어와서 여기서 제가 넣어줬던 값은 “STORYFILES” 라서 대소문자가 달라 같은 값으로 취급이 되지 않아 Unknown이 떴던 것이었습니다. 이 부분을 아예 조건으로 분류하여 따로 처리를 해주었고, 그러자 문제가 해결되었습니다.


띵스플로우 인턴을 마치며

띵스플로우 인턴이 된지 벌써 두 달이 흘러 인턴이 끝날 시간이 되었다는 게 실감도 안나고 아쉽습니다.

그동안 스플 안드로이드 인턴으로 일하며 정말 많은 것을 배우고 갑니다.

아무것도 모르고 코틀린도 어색해했던 제가 실제로 서비스되는 앱에 조금이나마 기여할 수 있었던 건 많은 분들이 도와주신 덕분인 것 같습니다. 혼자였으면 고칠 수 없었을 부분도 코드리뷰를 통해 개선할 수 있었고 다양한 직무에서 일하는 분들과도 소통하며 저 혼자만의 생각보다 더 나은 방법으로 구현할 수 있었습니다. 덕분에 안드로이드 앱 개발이 더 재밌어졌어요!

사실 이때까지 혼자 혹은 친구들과 함께 앱 개발 프로젝트를 몇 번 진행해봤음에도 실제로 스플을 개발하면서 느낀점은 꽤 색달랐습니다. 앱을 설계할 때 무작정 설계하는 것이 아니라, 디자인 패턴을 적용해서 설계할 수 있다는 것. 같은 기능을 구현해도 어떤 기술 스택을 적용했을 때 훨씬 효율적으로 구현할 수 있다는 것. 다른 팀원들과 협업했을 때 그 시너지 효과는 엄청나다는 것. 어려운 부분이 있을 때 서로 공유하고 도움을 줄 수 있다는 것. 그 밖에도 스플 인턴으로 일하며 느낀 모든 것이 저에게는 엄청난 영향을 미쳤습니다.

또한 인턴으로서 띵플에서 함께했던 두 달간의 시간은 학교에서 배운 것 뿐만 아니라 현직 개발자들은 어떻게 일하는지를 알 수 있었고, 제가 항상 궁금했던 스타트업의 생태도 체험할 수 있었던 소중한 시간이자 흔치않은 기회였습니다.

제 첫 인턴 생활이자 직장 생활을 띵스플로우, 그리고 스토리플레이 팀에서 할 수 있어서 너무 행복하고 즐거웠습니다. 띵스플로우, 스플 모두 감사합니다!

띵스플로우 팀은 자기의 일을 좋아하고 잘하는 사람들 입니다. 사용자와 서비스를 중심으로 빠르게 실행하고 학습하며, 다양한 직무의 사람들이 협업을 통해 시너지를 내고 있습니다. 다양한 콘텐츠 혁신을 이루고 있는 띵스플로우 팀에 함께할 분을 찾습니다! 언제든 people@thingsflow.com로 이메일을 주시기 바랍니다!

태그: , ,

카테고리:

업데이트:

댓글남기기