우당탕탕 에러처리 도입기

안녕하세요! 에코노베이션 25기 김근성입니다.

저는 개발집팀에서 EATceed의 Android 앱 개발을 하고 있는데요, 오늘은 앱을 개발하면서 에러 처리를 깔끔하게 도입하기 위해 고민했던 과정을 이야기해 보려 해요. 그렇게 대단한 구현이나 구조는 아니지만, 어떠한 흐름으로 생각하고 변경했는지, 그리고 코드가 어떻게 변경되어 가는지 봐주시면 감사하겠습니다!

EATceed의 구조

이야기에 앞서, EATceed에서는 MVVM(Model, View, ViewModel) 패턴을 채용하고 있어요.

  • View : 데이터를 보여주고, 입력을 받아들임. ViewModel에 데이터를 요청한 뒤 관찰하며 대기하다 데이터 변경 시 통보받아 처리
  • ViewModel : View에서 들어온 요청을 바탕으로 model에 데이터 요청, 데이터를 받아 처리 후 저장
  • Model : ViewModel에서 들어온 요청을 바탕으로 데이터를 리턴

image.png

해당 패턴은 여러 장점이 있지만 핵심은 각 컴포넌트의 분리를 이뤄낼 수 있다는 점이에요.

View는 ViewModel에만 요청을 보내고 통보받고, ViewModel은 Model에만 요청을 보내며, Model은 아무런 요청도 보내지 않죠.

만약 아래 구조와 같이 중간에 인터페이스를 끼게 된다면 기존 코드의 변경을 전혀 하지 않고 각각의 컴포넌트를 그대로 바꿔 낄 수 있게 됩니다.

image.png

모델 컴포넌트를 바꿔 끼는 모습

모델 컴포넌트를 바꿔 끼는 모습

이외에도 장점이 가득한 구조이기에 저희 프로젝트에서 사용하고 있지만, 컴포넌트가 분리되어 있기에 Model에서 요청 도중 에러가 발생 시 이를 ViewModel을 거쳐 View까지 보내줘야만 처리가 가능하다는 문제가 있어요. 오늘의 이야기는 이런 배경으로부터 시작합니다.

변화의 필요성

EATceed는 최초 개발 시부터 위와 같은 구조를 잘 지켜서 개발하고 있었지만, 기능 개발이 우선시 되었기에 이 작업을 시작하기 전까지 요청에 있어 별도의 에러처리를 하지 않고 있었어요.


suspend fun getFoodInfoById(id: Int): FoodInfoResponseDTO? {
	val result = service_main.getFoodInfoById(id)
	return result.body()?.response
}

기능이 동작하는지만 확인하면 되었기에 큰 문제 없이 작업하였지만, 이후 조금씩 기능들이 완성되고 테스트에 들어가자, 주변에서 “실패 케이스에 대한 처리가 없어서 된 지 안된지 모르겠다”, “요청이 간지 안간지 모르겠다” 와 같은 이야기가 나오기 시작하였어요,

여기저기서 이제는 미룰 수 없다는 신호가 나오자 서둘러 구조를 짜기 시작하였어요.

실패 - Result

구조를 짜며 처음 계획한 것은 너무 많은 것을 하려 하지 말고 일단 한 뒤 문제가 보이면 고치자 였어요.

너무 많은 것을 고민하다 보면 작업이 하염없이 밀릴 것이라 생각하였고, 분명 이렇게 하려 해도 완벽한 구조를 짜는 건 불가능하다 생각했기 때문이에요.

이 작업의 시작으로 가장 첫 번째로 문제라는 생각이 든 부분은 “실패”의 표현이었습니다.

지금 형태의 경우 API 요청을 한 후 결과를 받아 만약 body가 비어 있다면(null) 에러가 발생한 것으로 판단, 실패의 의미로 null을 넘기고, body가 있다면 성공한 것으로 판단하여 response 값을 넘기는 방식이었는데, null이 실패를 의미한다는 건 나만 아는 게 아닌가? 하는 생각이 들었어요.

그렇기에 가장 먼저 실패와 성공을 명시적으로 표시하는 방식으로 바꾸기로 하였어요.

Image 1 Image 2


이를 편하게 구현하기 위한 방법을 찾던 중 Kotlin에서 제공하는 Result 클래스를 발견했어요.

Result는 성공과 실패를 간단하게 표현하기 위한 클래스로 성공 시엔 값을, 실패 시엔 예외(Throwable)을 전달받을 수 있는 클래스에요. 거기다 map, onSuccess, onFailure 등 여러 함수형 기능을 제공하여 처리를 간결하게 할 수 있기에 저의 목적인 실패, 성공 표현에 매우 적합하기도 하였죠.


suspend fun getFoodInfoById(id: Int): Result<FoodInfoResponseDTO> {
	return try {
		val result = service_main.getFoodInfoById(id)
		val body = result.body()?
		if(body == null) {
			Result.failure(Exception())
		} else {
			Result.success(body.response)
		}
	} catch (e: Exception){
		Result.failure(e)
	}
}

적용하면서 혹시 요청 과정에서 발생할 수 있는 에러를 잡기 위해 추가로 try-catch도 적용했어요.

아직 에러 코드에 대한 처리는 없지만 Result를 도입함으로써 직관적으로 성공, 실패를 알 수 있게 되었고, ViewModel에서도 onSuccess를 통해 성공했을 시 업데이트하는 부분을 간결하게 표현할 수 있게 되었습니다!

제 3의 상태 - nullable, runningWithCheckRefresh

실패, 성공을 쉽게 표현할 수 있게 된 후 다음 작업으로는 에러 코드를 보내고 이에 따른 처리를 하자 였어요. 하지만, 이 작업을 해야 하나 생각할 때쯤에는 BE에서 에러 코드가 토큰 만료와 같은 몇 가지를 제외하면 아직 다 정해지지 않기에 진행할 수가 없었어요.

나중에 해야하나 고민하던 중 앱에서는 계속 토큰 만료로 실패와 로그아웃이 발생해 리프레시 토큰 갱신만 우선 작업하기로 하였습니다.

토큰 만료는 실패와 달리 재시도가 필요하여 실패로 처리해서는 안 된다고 생각하였기에 처음에는 실패, 성공에 이은 토큰 만료라는 새로운 상태를 도입하고자 하였어요. 하지만 그러면서도 기존의 코드에 크게 변경이 가해지면 안 되었기에 Result를 그대로 사용하면서 확장하는 방식을 고민해 보았어요.

이에 제가 선택한 방식은 Result를 nullable하게 만드는 것이였습니다. 아예 새로운 클래스나 Result를 래핑하는 클래스를 고민해 보았지만, 리프레시 토큰 발급 하나를 위해 이렇게까지 하는 것은 너무 과하다 생각하기도 하였고, 이후에 나올 방식을 통해 토큰 만료 상태를 model 안에서만 다루고 제거할 수 있다 생각했어요.

생각이 어느 정도 정리된 후에는 바로 위에서 적용한 Result 반환 타입들을 전부 nullable하게 바꾸어 주었어요.


suspend fun getFoodInfoById(id: Int): Result<FoodInfoResponseDTO>? {
//...생략

그리고 앞서 이야기하였듯 토큰 만료 상태를 Model 안에서만 다루고 제거하기 위해 ‘요청을 해서 null이 반환된다면 토큰 재발급 후 재시도하는 로직’을 자동화 하기로 결정하였어요.

  • 요청을 보내서 성공, 실패했다.
    • 그대로 값 반환
  • 요청을 보내서 토큰 만료가 나왔다.
    • 토큰 재발급 시도
    • 이후 재시도 하여 나온 값 반환

위와 같은 로직을 구현해야 하는데 Kotlin에서는 함수 또한 매개변수로 함수에 넘길 수 있기에 기존 요청을 하던 코드를 그대로 매개변수로 넘겨 결과를 받고 실행하면 기존 코드의 큰 수정 없이 토큰 재발급 로직을 도입할 수 있을거라 생각하며 코드를 작성하기 시작했습니다. 기존 호출을 재발급 로직으로 감싸는 거죠!


private suspend inline fun <T> runningWithCheckRefresh(running : ()->T): T {
  val result = running()
  return if(result == null){
    getAccessToken()
    running()
  }else{
    result
  }
}

초안으로는 위와 같은 형태의 코드를 작성해보았어요.

보면 inline이라는 새로운 키워드가 있는데, kotlin에서 inline 키워드를 함수의 선언에 적을 경우 해당 함수의 코드는 컴파일 시간에 호출한 위치에 그대로 붙여 넣어져요. 함수 콜스택에 쌓이지 않기에 약간의 속도 향상을 기대해 볼 수 있고, 에러 발생 시 stacktrace에 적히지 않기에 에러 분석에 조금 더 도움이 될 거라 판단해 적용해 보았습니다!


suspend fun getFoodInfoById(id: Int): Result<FoodInfoResponseDTO>? {
	return runningWithCheckRefresh {
		try {
			val result = service_main.getFoodInfoById(id)
			val body = result.body()?
			if(body == null) {
				when(result.code()){
          401 -> {
            null
          }

          else->{
            Result.failure(Exception()
          }
		    }
			} else {
				Result.success(body.response)
			}
		} catch (e: Exception){
			Result.failure(e)
		}
	}
}

기존 요청에 적용은 위와 같이 하게되는데, 재발급 로직이 필요한 함수에만 기존 함수 호출을 runningWithCheckRefresh 로 한번 감싸기만 하면 재발급 로직을 적용할 수 있는 거죠!

다만, 추가로 null을 별도로 보내기 위해 응답의 코드를 확인해야해서 switch와 비슷한 when을 통하여 401인지 체크하는 코드를 추가했는데, 이는 추후 다른 에러 코드 또한 비슷하게 처리할 생각이라 토큰 재발급 기능의 비용이라고 생각하진 않았어요.

하지만 가장 처음에 이야기하였듯 화면 상에 표현되는 로직 처리를 위해서는 상태를 View까지 가져올 필요가 있었습니다.

만약 토큰 재발급에 실패하면 토큰 만료 상태인 null 또한 View까지 가져와야 하고 이는 위에서 이야기한 ‘null이 실패를 의미한다는 건 나만 아는 게 아닌가?’와 동일하게 코드의 가독성을 낮추게 되었어요.

또한 토큰 만료는 응답에서 “토큰 만료로 인한 실패” 로 넘어와요. 즉, 처음에 생각한 것과 달리 토큰 만료는 실패와 동일한 상태로 처리해도 되는 거죠. 그렇기에 추후 에러 코드가 다 정해진 시점에 nullable을 전부 제거하고 에러 코드를 통해 토큰 만료를 판별하도록 바꾸었어요.


suspend fun getFoodInfoById(id: Int): Result<FoodInfoResponseDTO> {
	return runningWithCheckRefresh {
		try {
			val result = service_main.getFoodInfoById(id)
			val body = result.body()?
			if(body == null) {
				when(result.code()){
          401 -> {
            Result.failure(Exception("TOKEN_EXP")
          }

          else->{
            Result.failure(Exception()
          }
		    }
			} else {
				Result.success(body.response)
			}
		} catch (e: Exception){
			Result.failure(e)
		}
	}
}

다시 원상 복구하긴 했지만, 시도 자체는 재미있었고, 그 과정에서 토큰 재발급 로직 자동화에 성공했으니 좋은 도전이였다 생각해요.

모델에서 에러 코드 처리?

작업을 다 완료한 후 생각해 보니 에러 코드에 따라 무언가를 보여주고 처리하는 것은 View에서 처리해야 했어요. (가령 토큰 만료 시 로그인 화면으로 보내야 함) 즉, when문을 통해 응답을 받는 부분에서 이를 처리할 필요가 없었죠.

작업한 게 아쉽지만 더 깔끔한 코드를 위해 When문을 제거했어요.


suspend fun getFoodInfoById(id: Int): Result<FoodInfoResponseDTO> {
	return runningWithCheckRefresh {
		try {
			val result = service_main.getFoodInfoById(id)
			val body = result.body()?
			if(body == null) {
        Result.failure(Exception(result.code())
			} else {
				Result.success(body.response)
			}
		} catch (e: Exception){
			Result.failure(e)
		}
	}
}

상태가 부족한데 - RequestResult

이제 에러 코드도 View까지 가져올 수 있고, 토큰 재발급도 바르게 동작하게 되었어요. 지금 글은 연속적인 변경 같지만 실제로는 여기까지 하고 ‘괜찮네!’ 하면서 작업을 마무리하고 다른 기능 개발에 들어갔습니다.

꽤 오래 잘 사용하며 개발하던 도중 특정 기능들의 경우 데이터를 요청했는지, 데이터의 요청이 진행 중인지, 그리고 데이터의 요청이 완료되었는지를 알아야 하는 상황이 생기게 됐어요. 특히나 데이터의 요청이 진행 중인지는 로딩 화면 등의 이유로 대부분의 기능에서 필요로 하였죠.

이를 해결하기 위해서는 새로운 클래스를 만들어 모든 API 요청마다 요청 상태를 관리해야 했는데 여러모로 문제점이 많이 보였어요.

  • API가 많아 수정할 부분이 매우 많음
  • API 마다 결과와 진행 상태 두가지를 관리해야 함.
  • View에서 각 API마다 결과, 진행상태 두가지를 관찰하다 각각 처리해야 함

첫 번째는 어쩔 수 없는 부분이였지만 두 번째, 세 번째는 지금 당장도, 앞으로도 불편함이 많을 요소였어요. 그러던 중 요청 완료가 되었으면 무조건 성공 또는 실패가 존재하겠다는 생각이 떠오르게 되었어요.

Result 클래스는 성공, 실패 두 가지의 상태를 가져요. 그럼 이와 비슷하게 성공, 실패, 요청 안 함, 요청 중 이런 네 가지 상태를 가지는 클래스를 만들어 Result 대신 적용하면 두 가지 상태를 관리해야하는 문제를 해결할 수 있겠다 싶어 바로 변경에 들어갔어요.

모델 컴포넌트를 바꿔 끼는 모습

선 시작 후 통보

일단 가장 먼저 클래스의 틀을 잡았어요. 기존 Result에서 크게 벗어나지 않으면서 새로운 것을 추가해야 했기에 이름과 내부, 그리고 onXXXXX 함수들은 Result와 유사하게 구현했어요.


sealed class RequestResult<out T>{
    object None : RequestResult<Nothing>()
    object Loading : RequestResult<Nothing>()
    data class Success<T>(val data: T) : RequestResult<T>()
    data class Error(val code: String, val exception: Throwable? = null) : RequestResult<Nothing>()

    suspend fun onSuccess (running: suspend (T) -> Unit) :RequestResult<T>{
        if(this is Success){
            running(this.data)
        }
        return this
    }

    suspend fun onFailure(running: suspend (String, Throwable?) -> Unit, ) :RequestResult<T>{
        if(this is Error){
            running(this.code, this.exception)
        }
        return this
    }

    suspend fun onLoading(running: suspend () -> Unit): RequestResult<T>{
        if(this is Loading){
            running()
        }
        return this
    }
    suspend fun onNone(running: suspend () -> Unit): RequestResult<T>{
        if(this is None){
            running()
        }
        return this
    }
}

Enum과 비슷한 Sealed 클래스를 통해 하위에 네 가지 클래스를 생성해 두었고, 제네릭에 out을 통해 공변성을 두었어요. 이렇게 하게 된다면 **임의의 타입 T가 있을 때 T의 서브 클래스인 K또한 문제없이 RequestResult 에 들어갈 수 있게 됩니다.**

보통 응답의 경우 정해져 있기에 T면 T가 들어가는 건데 무슨 필요인가 할 수 있지만, T타입인 성공 값을 넣어줘서 RequestResult가 묵시적으로 정해지는 **Success와 달리 None, Loading, Error는 T가 무엇인지 알 수 없기에 생성 시에 공변성이 없다면 아래와 같이 T를 명시**해줘야 해요.


return RequestResult.Success(data) // Good
return RequestResult<FoodInfoResponseDTO>.Loading // Bad

하지만 공변성을 주고 위 RequestResult와 같이 Nothing을 적어두면 명시해 주지 않아도 생성할 수 있게 돼요. 이는 Nothing이 모든 클래스의 서브 클래스이기에 가능하답니다!


return RequestResult.Success(data) // Good
return RequestResult.Loading // Good

추가로 기존 Result에서는 Error에 Exception만 담을 수 있어 에러 코드를 새로운 Exception을 생성해 메시지로 담았었는데, 예외 메시지에 에러 코드는 맞지 않다 생각하여 새로 만들면서 아예 에러코드를 별도로 담을 수 있게 하였어요.

잘 작동한다

새로운 응답 형식으로 바꾼 뒤 테스트를 해보았어요. 이제 API당 한 개의 값만 관리하면서 결과, 상태를 모두 알 수 있게 되었죠!


//-------변경 관찰
val checkAnalyticsArrivalResult by homeViewModel.checkAnalyticsArrivalResult.collectAsStateWithLifecycle()

//-------값 가져오기   
val isAnalyticsArrival = (checkAnalyticsArrivalResult as? RequestResult.Success)?.data ?: false

//--------변경 통지
LaunchedEffect(changeWeightResultState) {
  changeWeightResultState.onSuccess {
	  //성공시 작업
    homeViewModel.getData()
  }.onFailure { code, e ->
	  //실패시 작업
	}
}

//-----로딩
if(getRemainDetectCountResult is RequestResult.Loading){
  LottieProgress()
}

매우 깔끔하게 잘 동작하네요!

재사용되네

그런데 문제점을 하나 발견하게 되었어요.

안드로이드 View 특성상 일부 경우에 ViewModel에서 다시 한번 변경 통지를 받아요. 즉, 변경 통지를 받는 코드인 LaunchedEffect와 그 안에 있는 onSuccess 등의 코드가 한 번 더 실행되게 됩니다.

대부분은 문제가 없었지만 홈 화면과 같이 단 한 번만 데이터를 받아야 하는 기능들은 그러면 문제가 생겼기에 수정이 필요했어요.


sealed class RequestResult<out T>{
    private var isUsed = false
    class None : RequestResult<Nothing>()
    class Loading : RequestResult<Nothing>()
    data class Success<T>(val data: T) : RequestResult<T>()
    data class Error(val code: String, val exception: Throwable? = null) : RequestResult<Nothing>()

    suspend fun onSuccess (reUse:Boolean = false, running: suspend (T) -> Unit) :RequestResult<T>{
        if(this is Success && (reUse || !isUsed)){
            running(this.data)
            isUsed = true
        }
        return this
    }

이를 수정하기 위해서 RequestResult 내부에 isUsed라는 변수와 함수 요청 시 선택적으로 값을 넣을 수 있는 reUse 플래그를 만들어 isUse가 false(0회 실행) 거나, isUse가 true일때(1회 실행) reUse가 true라면 실행되도록 하여 다른 부분 코드를 전혀 수정하지 않고 해당 문제를 고칠 수 있었어요.

API추가의 불편함 - safeApiCall

이제 모든 작업을 마무리하고 기능 개발에 집중하게 되었어요.

하지만, 기능 구현을 하다 보니 새로운 API추가시 이를 구현하는 게 매우 불편하다는 생각이 다시 한번 들었어요. 지금의 경우 요청 함수가 아래와 같은 구조로 되어있었는데, 이런 구조가 API요청마다 똑같이 적혀져있기에 새로 하나 추가할 때마다 같은 코드를 또 그대로 적어넣어야 했아요.


suspend fun getFoodInfoById(id: Int): RequestResult<FoodInfoResponseDTO> {
 	return runningWithCheckRefresh {
      try {
          val result = service_main.getFoodInfoById(id)
          if(result.body()!=null){
              RequestResult.Success(it.body()!!.response!!)
          }else{
              val gson = Gson()
              val type = object : TypeToken<CommonResponseDTO<FoodInfoResponseDTO>>() {}.type
              val errorBody : CommonResponseDTO<K>? = gson.fromJson(result.errorBody()!!.charStream(), type)

              RequestResult.Error(
                  code = errorBody?.error?.code?: result.code().toString(),
                  Exception(errorBody?.error?.reason?: result.message())
              )
          }
    }catch (e: Exception){
        RequestResult.Error("NONE", e)
    }
}

이에 다시 한번 고치기로 결정합니다. 일단 함수들을 전부 다 확인하였을 때 함수들 마다 다른 부분은

  1. 요청 전처리
  2. API요청부 (service_main.XXXXXX)
  3. 응답 후처리
  4. 에러 시 후처리

이렇게 4가지였어요. 사실상 요청 전처리와 요청은 하나로 묶을 수 있기에 전처리, 성공 후처리, 실패 후처리라 생각한 후 중복을 제거하는 작업에 들어갔습니다.


private suspend inline fun <T, K> safeApiCall(
        onRunning: ()->Response<CommonResponseDTO<K>>,
        onSuccess: (Response<CommonResponseDTO<K>>) -> RequestResult<T>,
        onFailure: ((CommonResponseDTO<K>?)->Unit) = {}
    ): RequestResult<T>{
        return runningWithCheckRefresh {
            try {
                val result = onRunning()
                if(result.isSuccessful&&result.body()!=null){
                    onSuccess(result)
                }else{
                    val gson = Gson()
                    val type = object : TypeToken<CommonResponseDTO<K>>() {}.type
                    val errorBody : CommonResponseDTO<K>? = gson.fromJson(result.errorBody()!!.charStream(), type)

                    onFailure(errorBody)

                    RequestResult.Error(
                        code = errorBody?.error?.code?: result.code().toString(),
                        Exception(errorBody?.error?.reason?: result.message())
                    )

                }
            }catch (e: Exception){
                RequestResult.Error("NONE", e)
            }
        }
}

runningWithCheckRefresh와 비슷한 구조로

  • 응답값을 받아올 호출하여Response<CommonResponseDTO<K>> 를 받아오는 onRunning
  • 성공 시 Response<CommonResponseDTO<K>>를 받아 RequestResult를 만드는 onSuccess
  • 실패 시 (CommonResponseDTO<K>?)->Unit 를 받아 처리를 하는 onFailure.

이렇게 세 가지 함수를 입력받고 각각의 상황에서 호출하도록 구현하였어요. 그리고 요청들에 적용해 보았어요.


suspend fun getFoodInfoById(id: Int): RequestResult<code> {
  return safeApiCall(
      onRunning = {
          service_main.getFoodInfoById(id)
      },
      onSuccess = {
          RequestResult.Success(it.body()!!.response!!)
      }
  )
}

어때요! 엄청나게 깔끔해지지 않았나요? 이런 중복을 추출함으로써 새로운 API를 도입하는 데 드는 시간을 비약적으로 줄일 수 있었어요.

또한 에러 로깅을 추가하거나 요청 시 무언가 공통된 작업을 추가하는 것도 매우 간편해졌답니다!

에러처리 - ErrorCodeExecutor

이왕 한 김에 불편했던 점 한 가지를 더 수정하기로 하였어요.

image.png

지금은 에러 코드를 받아 처리할 때 위와 같이 View에서 when을 통해 에러 코드를 분리하고 각각의 경우에 대해 토스트(에러 메시지 팝업)를 띄워줘요. 다른 작업을 하는 경우도 있지만 사실 90% 이상은 이런 토스트를 띄워주고 에러처리가 마무리됩니다.

보면 에러 코드에 따른 메시지가 대부분 비슷하고 나오는 에러 코드도 비슷하기에 요청마다 이런 토스트가 매우 많이 중복되어 있어요. 이 또한 한곳으로 뭉쳐서 제거해 보기로 하였습니다.

그렇지만 한편으로는 토스트가 아닌 일부 다른 처리를 하는 경우 또한 있었기에 이를 잘 살려주어야 했어요.


object ErrorCodeExecutor {
    fun execute(
        context: Context,
        code: String,
        e: Throwable?,
        otherCase: Map<String, (String, Throwable?) -> Unit> = mapOf()
    ) {
        if (otherCase.containsKey(code)) {
            otherCase[code]!!(code, e)
            return
        }
        when (code) {
          "MEMBER_400_1" -> {
            Toast.makeText(
              context,
              context.getString(R.string.error_already_sign_up), Toast.LENGTH_SHORT
            ).show()
          }
        }
    }
}

이에 ErrorCodeExecutor라는 오브젝트 클래스를 만들게 되었어요.

이 실행기는 에러 코드와 일부 특수 예외 케이스를 받아 만약 예외 케이스에 에러 코드가 포함되지 않는다면 when문에 있는 공통 처리를(토스트 등) 수행하고 만약 포함된다면 예외 케이스에 있는 처리를 수행하도록 구현하였어요.

위와 같이 구현함으로써 토스트를 중복 생성하는 문제를 해결하였고, 일부 예외 케이스 또한 안정적으로 수행할 수 있게 되었습니다.

모델 컴포넌트를 바꿔 끼는 모습

MEMBER_400_2 만 다른 기능이 수행된다.

이렇게 EATceed의 정신없던 에러처리는 아래와 같은 구조로 마무리되게 됩니다.

제목 없는 다이어그램 drawio

마무리하며

상당히 내용이 많은 것 같은데 2주 정도 고민한 내용을 적다 보니 이렇게 길게 된 것 같습니다. 그다지 알찬 내용은 아니지만 코드 한줄 한줄 그리고 하나의 의사결정에 내가 왜 이런 선택을 했는지 고민하고 기록하는 것도 좋은 습관인 것 같아 이렇게 생각하였다.. 하고 적어보았어요!

아! 그리고 본문에 나온 EATceed는 12월에 출시를 준비하고 있어요. 많은 관심 부탁드려요!