Skip to content

목표 생성 API 연동#71

Merged
chanho0908 merged 27 commits intodevelopfrom
feat/#70-create-goal-api
Feb 9, 2026
Merged

목표 생성 API 연동#71
chanho0908 merged 27 commits intodevelopfrom
feat/#70-create-goal-api

Conversation

@dogmania
Copy link
Copy Markdown
Contributor

@dogmania dogmania commented Feb 9, 2026

이슈 번호

작업내용

  • 목표 생성 API 통신 로직을 구현했습니다.
  • GoalRefreshBus로 불필요한 API 호출을 방지했습니다.
  • BaseViewModel에 SideEffect tryEmit을 추가했습니다.

결과물

2026-02-09.5.40.11.mov

리뷰어에게 추가로 요구하는 사항 (선택)

지난번에 BaseViewModel onSuccess가 suspend가 아니라서 SideEffect 발생시키기 어렵다고 하셔서 tryEmit 추가했습니다! 더 좋은 방법 있으면 알려주세요

@dogmania dogmania requested a review from chanho0908 February 9, 2026 08:51
@dogmania dogmania self-assigned this Feb 9, 2026
@dogmania dogmania added the Feature Extra attention is needed label Feb 9, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 9, 2026

📝 Walkthrough

Walkthrough

목표 생성 API 연동을 위한 기능들이 추가되었습니다. 네트워크 계층에 CreateGoalRequest/CreateGoalResponse 모델과 GoalService.createGoal POST 엔드포인트가 추가되고, 매핑 함수(CreateGoalParam.toRequest(), CreateGoalResponse.toDomain())가 구현되었습니다. 도메인에 CreateGoalParam, CreatedGoal 모델과 enum 변환 (toApi())이 추가되었고, 리포지토리에 createGoal 구현이 포함되었습니다. ViewModel과 UI 쪽은 GoalEditor에서 생성 흐름 및 side effect(내비게이션/토스트) 처리, HomeViewModel은 GoalRefreshBus를 구독해 목록 갱신하도록 변경되었습니다. Koin 모듈(utilModule)과 관련 DI·빌드 설정, 리소스 문자열도 추가되었습니다.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목 '목표 생성 API 연동'은 변경 사항의 핵심 내용(목표 생성 API 통신 로직 구현)을 명확하게 요약하고 있습니다.
Description check ✅ Passed PR 설명은 이슈 번호, 구체적인 작업 내용, 추가 기술적 배경을 포함하고 있으며 변경 사항과 관련이 있습니다.
Linked Issues check ✅ Passed PR은 #70의 모든 목표(GoalRepository.createGoal 구현, DTO/Domain Model 구현, ViewModel ↔ Repository 호출 로직 구현)를 만족합니다.
Out of Scope Changes check ✅ Passed UI 패딩(safeContentPadding) 추가, BaseViewModel의 tryEmit 메서드 추가, HomeViewModel의 GoalRefreshBus 구독 등의 변경은 모두 목표 달성을 위한 범위 내 변경입니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#70-create-goal-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorViewModel.kt (1)

39-43: ⚠️ Potential issue | 🟠 Major

title.isBlank() 체크로 인해 사용자가 제목을 지울 수 없는 버그가 있습니다.

현재 로직에서 title.isBlank()이면 return하기 때문에, 사용자가 입력한 텍스트를 모두 삭제하여 빈 문자열로 만들 수 없습니다. 텍스트 필드에 "abc"를 입력한 후 전체 선택 + 삭제를 해도 상태가 갱신되지 않아 UI와 상태가 불일치하게 됩니다.

제목 유효성 검증은 이미 isEnabled에서 수행하고 있으므로, 여기서는 빈 문자열도 허용하는 것이 적절합니다.

🐛 제안 수정
 private fun setTitle(title: String) {
-    if (title.isBlank()) return
-
     reduce { copy(goalTitle = title) }
 }
🧹 Nitpick comments (8)
core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt (1)

72-74: trySend 실패 시 결과가 무시되고 있습니다.

같은 클래스의 dispatch() 메서드(Line 52-55)에서는 trySend 실패 시 로깅을 하고 있는데, tryEmitSideEffect에서는 실패 결과를 완전히 무시하고 있습니다. SideEffect는 네비게이션이나 토스트 등 사용자에게 직접 노출되는 동작을 포함할 수 있어서, 유실 시 디버깅이 매우 어려워질 수 있습니다.

dispatch()와 동일한 패턴으로 실패 시 최소한 로깅을 추가하는 것은 어떨까요?

제안
 protected fun tryEmitSideEffect(effect: SE) {
-    sideEffectHolder.tryEmit(effect)
+    val result = sideEffectHolder.tryEmit(effect)
+    if (result.isFailure) {
+        logger.w { "SideEffect 유실: $effect, 원인 = ${result.exceptionOrNull()}" }
+    }
 }

이 경우 SideEffectHolder.tryEmit의 반환 타입도 ChannelResult<Unit>으로 변경이 필요합니다.

core/ui/src/main/java/com/twix/ui/base/SideEffectHolder.kt (1)

14-16: trySend의 반환값을 호출자에게 전달하는 것을 권장합니다.

현재 ChannelResult를 버리고 있어서, 채널이 가득 차거나 닫힌 경우에도 호출자가 이를 알 수 없습니다. 반환값을 전달하면 BaseViewModel.tryEmitSideEffect에서 실패 시 로깅 등의 처리를 할 수 있습니다.

제안
-    fun tryEmit(effect: S) {
-        channel.trySend(effect)
+    fun tryEmit(effect: S): ChannelResult<Unit> {
+        return channel.trySend(effect)
     }
domain/src/main/java/com/twix/domain/model/enums/GoalIconType.kt (1)

14-24: toApi()fromApi() 간 매핑 문자열 중복 — 개선 여지 있음

현재 toApi()fromApi()에서 "ICON_DEFAULT", "ICON_CLEAN" 등 동일한 문자열이 양쪽에 중복 정의되어 있습니다. 새 enum 값을 추가할 때 한쪽만 수정하면 불일치가 발생할 수 있습니다.

toApi()when이 exhaustive하므로 컴파일 타임에 누락을 잡아주지만, fromApi()else -> DEFAULT 분기가 있어 새 값 누락 시 조용히 DEFAULT로 매핑됩니다.

매핑 테이블을 한 곳에서 관리하거나, 네이밍 규칙이 일정하므로 아래처럼 단순화할 수도 있습니다:

♻️ 선택적 개선 제안
+    fun toApi(): String = "ICON_$name"
+
     companion object {
-        fun fromApi(icon: String): GoalIconType =
-            when (icon) {
-                "ICON_DEFAULT" -> DEFAULT
-                ...
-                else -> DEFAULT
-            }
+        fun fromApi(icon: String): GoalIconType =
+            entries.find { it.toApi() == icon } ?: DEFAULT
     }

이렇게 하면 매핑이 toApi() 한 곳에서만 관리되어 유지보수가 편해집니다. 다만 현재 방식도 명시적이고 안전하므로, 팀 선호에 따라 결정하시면 됩니다.

build-logic/convention/src/main/kotlin/com/twix/convention/FeatureConventionPlugin.kt (1)

18-18: 모든 Feature 모듈에 :core:util 의존성 추가 — 범위 확인 필요

Convention Plugin에 추가하면 모든 Feature 모듈core:util에 의존하게 됩니다. 현재 core:utilGoalRefreshBus만 포함하고 있다면, 실제로 이를 사용하는 모듈(home, goal-editor)에서만 개별적으로 의존성을 선언하는 것이 더 적절할 수 있습니다.

다만 :core:result, :core:analytics 등과 동일한 패턴이고, core:util이 앞으로 범용 유틸리티 모듈로 확장될 예정이라면 현재 방식도 합리적입니다. 팀 내에서 core:util의 역할 범위에 대해 합의가 되어 있다면 괜찮을 것 같습니다. 🙂

core/network/src/main/java/com/twix/network/model/response/goal/mapper/GoalMapper.kt (1)

44-53: LocalDate.parse() 호출 시 예외 처리가 없어 런타임 크래시 위험이 있습니다.

LocalDate.parse(startDate)는 기본적으로 ISO-8601 형식(yyyy-MM-dd)만 허용합니다. API 응답의 날짜 형식이 이와 다르거나 잘못된 값이 반환되면 DateTimeParseException이 발생합니다.

같은 파일 내 GoalIconType.fromApi()RepeatCycle.fromApi()runCatching으로 안전하게 처리하고 있는 반면, 날짜 파싱에는 이러한 방어 로직이 없습니다. API 응답 형식이 확실히 보장된다면 현재 방식도 괜찮지만, 일관성을 위해 방어적 처리를 고려해보시면 어떨까요?

🛡️ 방어적 파싱 예시
 fun CreateGoalResponse.toDomain(): CreatedGoal =
     CreatedGoal(
         goalId = goalId,
         name = name,
         icon = GoalIconType.fromApi(icon),
         repeatCycle = RepeatCycle.fromApi(repeatCycle),
         repeatCount = repeatCount,
-        startDate = LocalDate.parse(startDate),
-        endDate = endDate?.let(LocalDate::parse),
+        startDate = LocalDate.parse(startDate),  // API 계약상 yyyy-MM-dd 보장 확인 필요
+        endDate = runCatching { endDate?.let(LocalDate::parse) }.getOrNull(),
         createdAt = createdAt,
     )
domain/src/main/java/com/twix/domain/model/goal/CreatedGoal.kt (1)

7-16: createdAt 필드를 String 대신 타입이 있는 날짜/시간 객체로 정의하는 것을 고려해 보시겠어요?

다른 날짜 필드(startDate, endDate)는 LocalDate로 타입이 지정되어 있는 반면, createdAtString입니다. 만약 이 값이 날짜/시간 정보를 담고 있다면 LocalDateTime이나 Instant 등으로 파싱하여 도메인 모델의 일관성을 높일 수 있습니다. 현재 이 필드를 직접 활용하는 곳이 없다면 추후 리팩터링으로 남겨두어도 괜찮습니다.

core/util/src/main/java/com/twix/util/bus/GoalRefreshBus.kt (1)

6-14: 이벤트 버스 구현이 적절합니다.

replay = 0으로 새 구독자가 이전 이벤트를 받지 않고, extraBufferCapacity = 1tryEmit으로 비동기 안전하게 알림을 보내는 패턴이 잘 적용되었습니다. 빠르게 연속 호출되더라도 "갱신 필요" 시그널이 하나로 합쳐지는 효과가 있어 불필요한 API 호출을 방지합니다.

한 가지 참고 사항으로, onBufferOverflow 기본값이 SUSPEND이므로 tryEmit 호출 시 버퍼가 가득 차면 이벤트가 조용히 드롭됩니다. 현재 용도(갱신 시그널)에서는 문제되지 않지만, 명시적으로 onBufferOverflow = BufferOverflow.DROP_OLDEST를 설정하면 의도가 더 명확해질 수 있습니다.

💡 의도를 명시적으로 표현하는 제안
+import kotlinx.coroutines.channels.BufferOverflow
+
 private val _events =
     MutableSharedFlow<Unit>(
         replay = 0,
         extraBufferCapacity = 1,
+        onBufferOverflow = BufferOverflow.DROP_OLDEST,
     )
feature/main/src/main/java/com/twix/home/HomeViewModel.kt (1)

26-34: init 블록 구성이 잘 되어 있습니다. 다만 동시 호출 가능성에 대해 한 가지 제안드립니다.

현재 goalRefreshBus.events.collect에서 fetchGoalList()를 호출할 때, 이미 진행 중인 fetch가 있다면 동시에 두 개의 네트워크 요청이 발생할 수 있습니다. 현재 규모에서는 큰 문제가 되지 않지만, 향후 요청이 빈번해질 경우를 대비해 이전 요청을 취소하는 패턴을 고려해 볼 수 있습니다.

예를 들어 collectLatest를 사용하면, 새 이벤트가 들어올 때 이전 fetchGoalList() 호출을 자동으로 취소할 수 있습니다. 다만 launchResult 내부 구현에 따라 취소가 전파되지 않을 수도 있으므로, 현재 구조에서 이 부분이 필요한지는 팀 내 논의가 필요할 수 있습니다.

💡 collectLatest 적용 예시
         viewModelScope.launch {
-            goalRefreshBus.events.collect {
+            goalRefreshBus.events.collectLatest {
                 fetchGoalList()
             }
         }

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorViewModel.kt`:
- Around line 80-88: The onSuccess callback uses tryEmitSideEffect which can
silently drop the NavigateToHome event; change it to call the suspend
emitSideEffect inside a coroutine so the event is delivered reliably — e.g.,
inside the onSuccess body use viewModelScope.launch {
emitSideEffect(GoalEditorSideEffect.NavigateToHome) } instead of
tryEmitSideEffect; alternatively, make launchResult's onSuccess parameter a
suspend lambda and call emitSideEffect directly. Ensure references:
launchResult, onSuccess, tryEmitSideEffect, emitSideEffect, viewModelScope,
GoalEditorSideEffect.NavigateToHome.

Comment on lines +80 to +88

launchResult(
block = { goalRepository.createGoal(currentState.toCreateParam()) },
onSuccess = {
goalRefreshBus.notifyChanged()
tryEmitSideEffect(GoalEditorSideEffect.NavigateToHome)
},
onError = { emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_create_goal_failed, ToastType.ERROR)) },
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the GoalEditorViewModel file and search for sideEffectHolder
find . -name "GoalEditorViewModel.kt" -type f

Repository: YAPP-Github/Twix-Android

Length of output: 147


🏁 Script executed:

# Search for MutableSharedFlow configuration in the codebase
rg "MutableSharedFlow|sideEffectHolder" --type kotlin -B 2 -A 2

Repository: YAPP-Github/Twix-Android

Length of output: 3386


🏁 Script executed:

# Check for ViewModel base class that might define sideEffectHolder
fd -e kt -path "*/ViewModel.kt" | head -20

Repository: YAPP-Github/Twix-Android

Length of output: 236


🏁 Script executed:

# Find SideEffectHolder implementation
rg "class SideEffectHolder" --type kotlin -A 10

Repository: YAPP-Github/Twix-Android

Length of output: 989


🏁 Script executed:

# Also check the BaseViewModel for more context
cat core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt

Repository: YAPP-Github/Twix-Android

Length of output: 4374


tryEmitSideEffect 사용 시 이벤트 유실 가능성이 존재합니다.

네비게이션처럼 반드시 전달되어야 하는 NavigateToHome 이벤트에 tryEmitSideEffect를 사용하는 것이 문제입니다. SideEffectHolder는 내부적으로 Channel(Channel.BUFFERED) (용량: 64)를 사용하는데, tryEmit()은 버퍼가 가득 차면 이벤트를 조용히 실패시킵니다. 용량이 64로 충분하지만, 버퍼 오버플로우 상황에서는 사용자가 화면 전환 없이 멈춰있을 수 있습니다.

반면 onError에서 emitSideEffect(suspend)를 사용하고 있어 불일치가 발생합니다.

개선 방안:
onSuccess 콜백 내에서 viewModelScope.launch { emitSideEffect(...) }로 감싸면 suspend 호출이 가능해지고, 버퍼가 가득 찬 경우에도 안전하게 대기할 수 있습니다:

개선 예시
launchResult(
    block = { goalRepository.createGoal(currentState.toCreateParam()) },
    onSuccess = {
        goalRefreshBus.notifyChanged()
        viewModelScope.launch {
            emitSideEffect(GoalEditorSideEffect.NavigateToHome)
        }
    },
    onError = { emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_create_goal_failed, ToastType.ERROR)) },
)

launchResultonSuccess 시그니처를 suspend 람다로 변경하는 것도 근본적인 해결 방안입니다.

🤖 Prompt for AI Agents
In
`@feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorViewModel.kt`
around lines 80 - 88, The onSuccess callback uses tryEmitSideEffect which can
silently drop the NavigateToHome event; change it to call the suspend
emitSideEffect inside a coroutine so the event is delivered reliably — e.g.,
inside the onSuccess body use viewModelScope.launch {
emitSideEffect(GoalEditorSideEffect.NavigateToHome) } instead of
tryEmitSideEffect; alternatively, make launchResult's onSuccess parameter a
suspend lambda and call emitSideEffect directly. Ensure references:
launchResult, onSuccess, tryEmitSideEffect, emitSideEffect, viewModelScope,
GoalEditorSideEffect.NavigateToHome.

Copy link
Copy Markdown
Contributor

@chanho0908 chanho0908 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생했어 현수야 !

내가 남긴 리뷰는 시간을 가지고 서로 이야기 해봐야 할 것 들인데,
당장에 인증 화면이랑 연동해야해서 우선 머지할게 !

내가 배포 끝나면 같이 이야기하거나 컨벤션 맞추면 좋을 것들 정리해볼게 :)

Comment on lines +6 to +7
@Serializable
data class CreateGoalResponse(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버도 코틀린을 사용하다보니 카멜 케이스를 사용하는게 똑같아서
파라미터 네이밍이 거의 같을 것 같은데 SerialName을 사용하는 것에 대해서 컨벤션을 정해보면 좋을 것 같아 !

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

나중에 서버에서 변수명 변경할 수도 있어서 확장성 고려해서 다 붙여놨어요!

import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow

class GoalRefreshBus {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 클래스의 역할이 어떤건지 설명해줄 수 있을ㄲ ㅏ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HomeScreen으로 진입할 때 LaunchedEffect로 매번 API를 부르면 비효율적이니까 다른 NavGraph에서 데이터가 생성되거나 삭제됐음을 알리는 용도입니다! 딱 필요할 때만 데이터를 불러오도록 돕는 역할이에요

Comment on lines +14 to +16
fun tryEmit(effect: S) {
channel.trySend(effect)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토끼 리뷰 참고해서 send와 trySend 어떤걸 사용할지 이야기 해보면 좋을 것 같아 !

나도 요거에 대해서 배포 끝나면 공부해볼태니 이야기 해보는거 어때 ?

@chanho0908 chanho0908 merged commit df9be75 into develop Feb 9, 2026
8 checks passed
@chanho0908 chanho0908 deleted the feat/#70-create-goal-api branch February 25, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature Extra attention is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

목표 생성 API 연동

2 participants