Skip to content

Feat/#47 스터디 게시판 구현#50

Merged
fredleeJH merged 9 commits intodevelopfrom
feat/#47-스터디-게시판-구현
Apr 13, 2026

Hidden character warning

The head ref may contain hidden characters: "feat/#47-\uc2a4\ud130\ub514-\uac8c\uc2dc\ud310-\uad6c\ud604"
Merged

Feat/#47 스터디 게시판 구현#50
fredleeJH merged 9 commits intodevelopfrom
feat/#47-스터디-게시판-구현

Conversation

@starshape7
Copy link
Copy Markdown
Collaborator

@starshape7 starshape7 commented Apr 13, 2026

Related issue 🛠

  • closed #이슈넘버

Work Description 📝

  • 작업 내용

Screenshot 📸

Uncompleted Tasks 😅

  • Task1

PR Point 📌

트러블 슈팅 💥

Summary by CodeRabbit

새로운 기능

  • 스터디 게시판 기능: 스터디별 게시판에서 게시물을 작성하고 조회할 수 있습니다.
  • 게시물 상호작용: 게시물에 좋아요 표시 및 중요 게시물 핀 고정 기능을 지원합니다.
  • 댓글 시스템: 게시물에 댓글을 작성하고 조회할 수 있습니다.

# Conflicts:
#	data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt
#	data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt
#	data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt
#	data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt
#	data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt
#	domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt
#	feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt
#	feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt
#	feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt
#	feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt
#	feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyDetailState.kt
#	feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt
@starshape7 starshape7 linked an issue Apr 13, 2026 that may be closed by this pull request
3 tasks
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

Walkthrough

스터디 게시판(보드) 기능을 추가하는 변경사항입니다. 게시글 생성, 조회, 목록 조회, 좋아요/핀 토글, 댓글 작성 등의 API 및 UI를 포함하는 전체 스택 구현입니다. 네트워크, 데이터, 도메인, UI 레이어 전반에 걸쳐 확장되었습니다.

Changes

Cohort / File(s) Summary
Design System Components
core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CountsView.kt, core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostItem.kt
StudyPostResult 타입을 받는 새로운 CountView 오버로드 추가; PostListItem 타입을 PostResult에서 StudyPostResult로 변경하고 핀 아이콘 조건부 렌더링 추가
Icon Resources
core/designsystem/src/main/res/drawable/ic_pin.xml, core/designsystem/src/main/res/drawable/ic_unpin.xml
게시글 핀 기능을 위한 새로운 벡터 드로어블 리소스 추가 (각 11dp, 14dp 크기)
Network & Service Layer
core/network/src/main/java/com/umcspot/spot/network/model/BaseResponse.kt, data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt
미사용 import 추가 및 파일 끝 개행 수정; 게시글 CRUD, 상호작용(핀/좋아요), 댓글 생성 API 엔드포인트 8개 추가
Data Layer Request DTOs
data/study/src/main/java/com/umcspot/spot/study/dto/request/BoardCreateRequestDto.kt, data/study/src/main/java/com/umcspot/spot/study/dto/request/StudyPostCommentRequestDto.kt
게시판 생성(title, content, isPrivate)과 댓글 생성(content) 요청 DTO 추가
Data Layer Response DTOs
data/study/src/main/java/com/umcspot/spot/study/dto/response/BoardCreateResponseDto.kt, data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyPostsResponseDto.kt, data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyPostDetailResponseDto.kt, data/study/src/main/java/com/umcspot/spot/study/dto/response/SendStudyPostCommentResponseDto.kt
게시글 목록, 상세, 댓글 응답 DTO 추가 (페이지네이션, 통계, 작성자, 댓글 정보 포함)
Data Layer DataSource & Implementation
data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt, data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt
8개의 새로운 게시판 관련 suspend 함수 인터페이스 정의 및 구현 (CRUD, 상호작용)
Data Layer Repository
data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt
게시판 CRUD, 상호작용 메서드 구현 (성공/실패 처리, 에러 로깅 포함)
Domain Models
domain/study/src/main/java/com/umcspot/spot/study/model/BoardCreateModel.kt, domain/study/src/main/java/com/umcspot/spot/study/model/StudyPostsResultList.kt, domain/study/src/main/java/com/umcspot/spot/study/model/StudyPostsDetailResult.kt
게시판 생성, 게시글 목록(페이지네이션), 게시글 상세(댓글 포함) 도메인 모델 추가
Domain Repository Interface
domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt
8개의 새로운 게시판 관련 suspend 함수 시그니처 추가 (인터페이스)
Mapper
data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt
DTO ↔ 도메인 변환 함수 5개 추가 (게시글 목록/상세, 댓글, 타임스탬프 포맷팅)
Feature - Home Navigation
feature/home/src/main/java/com/umcspot/spot/home/HomeScreen.kt, feature/home/src/main/java/com/umcspot/spot/home/navigation/HomeNavigation.kt
onStudyClick 콜백 타입을 (StudyResult) -> Unit에서 (Long) -> Unit으로 변경 (스터디 ID만 전달)
Feature - Main Navigation & Screen
feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt, feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt, feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt
게시글 상세 화면 네비게이션 연결; StudyPostContent 라우트 추가 및 FAB 동작 확장
Feature - Study Detail Navigation
feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt
StudyBoardPost, StudyPostContent 새로운 네비게이션 라우트 및 콜백 추가
Feature - Study Detail ViewModel & State
feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt, feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyDetailState.kt, feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyPostContentState.kt
게시판 포스팅, 조회, 상호작용(핀/좋아요), 댓글 작성 로직 13개 메서드 추가; 상태 모델 확장 (postState, postDetailState, postEditorState)
Feature - Study Detail Screen
feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt
lifecycle 기반 데이터 새로고침, 핀/좋아요 콜백 추가; 게시글 상세 클릭 콜백 추가
Feature - Board & Post Content Screens
feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailBoardScreen.kt, feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyBoardPostScreen.kt, feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyPostContentScreen.kt
게시판 목록 화면(스와이프 핀 토글, 로딩/빈 상태), 게시글 작성 화면(에디터), 게시글 상세 화면(댓글, 모달 메뉴) 구현
Utility - Swipe Reveal
feature/study/src/main/java/com/umcspot/spot/study/detail/component/post/SwipeRevealRow.kt
스와이프 제스처 기반 콘텐츠 공개 컴포저블 (현재 주석 처리됨)

Sequence Diagram

sequenceDiagram
    actor User
    participant Screen as StudyBoardPostScreen
    participant ViewModel as StudyDetailViewModel
    participant Repo as StudyRepository
    participant Service as StudyService
    participant Server as API Server

    User->>Screen: 게시글 작성 (제목, 내용, 비공개 설정)
    Screen->>ViewModel: submitPostFromEditor(studyId)
    ViewModel->>Repo: postBoard(studyId, BoardCreateModel)
    Repo->>Service: postBoard(studyId, BoardCreateRequestDto)
    Service->>Server: POST /studies/{studyId}/board/posts
    Server-->>Service: BoardCreateResponseDto {postId}
    Service-->>Repo: BaseResponse<BoardCreateResponseDto>
    Repo-->>ViewModel: Result<Long>
    ViewModel->>ViewModel: postState 업데이트<br/>(에디터 초기화)
    ViewModel-->>Screen: BoardPostSuccess SideEffect
    Screen->>User: 게시글 생성 완료, 뒤로 이동

    User->>+Screen as DetailScreen: 게시글 상세 열기
    Screen->>ViewModel: fetchStudyPostDetail(studyId, postId)
    ViewModel->>Repo: getStudyPostDetail(studyId, postId)
    Repo->>Service: getStudyPostDetail(studyId, postId)
    Service->>Server: GET /studies/{studyId}/board/posts/{postId}
    Server-->>Service: StudyPostDetailResponseDto
    Service-->>Repo: BaseResponse<StudyPostDetailResponseDto>
    Repo->>Repo: toDomain() 변환
    Repo-->>ViewModel: Result<StudyPostDetailResult>
    ViewModel->>ViewModel: postDetailState 업데이트
    ViewModel-->>Screen: UiState.Success<PostData>
    Screen-->>User: 게시글, 댓글 표시

    User->>Screen: 좋아요 버튼 클릭
    Screen->>ViewModel: toggleStudyPostDetailLike()
    ViewModel->>ViewModel: 낙관적 UI 업데이트<br/>(isLiked, likeCount)
    ViewModel->>Repo: studyPostLike(studyId, postId)
    Repo->>Service: studyPostLike(studyId, postId)
    Service->>Server: POST /studies/{studyId}/board/posts/{postId}/like
    Server-->>Service: NullResultResponse {success}
    Service-->>Repo: 응답
    Repo-->>ViewModel: Result<Unit>
    ViewModel->>ViewModel: 실패 시 롤백<br/>또는 상태 확정

    User->>Screen: 댓글 입력 & 전송
    Screen->>ViewModel: sendStudyPostComment(studyId, postId, content)
    ViewModel->>Repo: createStudyPostComment(studyId, postId, content)
    Repo->>Service: createStudyPostComment(studyId, postId, StudyPostCommentRequestDto)
    Service->>Server: POST /studies/{studyId}/board/posts/{postId}/comments
    Server-->>Service: SendStudyPostCommentResponseDto {commentId}
    Service-->>Repo: BaseResponse<SendStudyPostCommentResponseDto>
    Repo->>Repo: Comment를 CommentResult로 변환
    Repo-->>ViewModel: Result<Unit>
    ViewModel->>ViewModel: commentData 리스트 업데이트
    ViewModel-->>Screen: postDetailState 갱신
    Screen-->>User: 댓글 목록에 표시
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly Related PRs

Suggested Labels

🐶 종훈, 🍥 [UI], 🍒 [FEAT]

Poem

🐰 게시판이 태어났네요,

글 쓰고 핀 꽂고 좋아요를 누르네!

댓글로 톡톡 나누는 우리들,

스터디 커뮤니티가 더 따뜻해졌어요~

✨ 함께하는 즐거움, 이제 시작!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning PR 설명이 템플릿 구조만 있고 구체적인 내용이 모두 플레이스홀더로 채워져 있어 실제 정보가 없습니다. 관련 이슈 번호, 작업 내용, 스크린샷, PR 포인트, 트러블슈팅 등 각 섹션을 구체적으로 작성하여 변경사항을 명확히 설명해주세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 3.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 스터디 게시판 구현이라는 주요 변경사항을 명확히 반영하고 있으며, 변경 내용과 직접적으로 관련되어 있습니다.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#47-스터디-게시판-구현

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.

Actionable comments posted: 13

Caution

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

⚠️ Outside diff range comments (1)
feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt (1)

467-477: ⚠️ Potential issue | 🔴 Critical

메모아 반응 토글 진입점이 여기서 끝납니다.

여기서는 현재 선택 상태만 계산하고 종료합니다. 그래서 3-인자 toggleMemoirReaction() 경로로 들어오면 UI 갱신도, 아래 4-인자 오버로드 호출도 일어나지 않아 반응 버튼이 사실상 동작하지 않습니다.

💡 제안 수정
 fun toggleMemoirReaction(studyId: Long, memoirId: Long, reactionType: String) {
     val memoir = _uiState.value.memoirState.memoirs.find { it.memoirId == memoirId } ?: return
     val isCurrentlySelected = when (reactionType) {
         "FIRE" -> memoir.reactions.isFired
         "HEART" -> memoir.reactions.isHearted
         "STAR" -> memoir.reactions.isStarred
         "SMILE" -> memoir.reactions.isSmiled
         else -> return
     }
+    val nextSelected = !isCurrentlySelected
+    updateMemoirReactionUIState(memoirId, reactionType, nextSelected)
+    toggleMemoirReaction(studyId, memoirId, reactionType, isCurrentlySelected)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt`
around lines 467 - 477, The toggleMemoirReaction(studyId: Long, memoirId: Long,
reactionType: String) function computes isCurrentlySelected but then returns
early; update it to continue by dispatching the actual toggle flow: compute the
new desired state and call the overloaded toggleMemoirReaction(studyId,
memoirId, reactionType, isCurrentlySelected) (or the existing method that
performs UI update + backend call) so the UI and network actions execute. Locate
the function toggleMemoirReaction(studyId, memoirId, reactionType: String) and
ensure it forwards to the 4-argument overload (or invokes the same logic used in
that overload) instead of exiting after computing isCurrentlySelected.
🧹 Nitpick comments (3)
feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyPostContentState.kt (1)

16-19: 댓글 데이터도 UiState로 감싸는 구조가 더 안전합니다.

지금 구조에서는 빈 댓글로딩 실패를 동일하게 표현하게 되어 UI/재시도 처리 분기가 어려워집니다.

구조 개선 예시
 data class StudyPostContentState(
     val postData: UiState<PostData> = UiState.Empty,
-    val commentData : ImmutableList<CommentData> = persistentListOf()
+    val commentData: UiState<ImmutableList<CommentData>> = UiState.Empty
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyPostContentState.kt`
around lines 16 - 19, Wrap the comment list in UiState to distinguish empty vs
error/loading by changing StudyPostContentState's commentData from
ImmutableList<CommentData> = persistentListOf() to a
UiState<ImmutableList<CommentData>> (e.g., UiState.Empty by default); update any
consumers of StudyPostContentState.commentData (views, reducers, mappers) to
handle UiState states (Loading/Success/Error) instead of treating an empty list
as failure and adjust places that construct comment data to emit
UiState.Success(persistentListOf(...)) or UiState.Error(...) as appropriate.
feature/study/src/main/java/com/umcspot/spot/study/detail/component/post/SwipeRevealRow.kt (1)

1-91: 전체 주석 처리된 컴포넌트 파일은 머지 전 정리해 주세요.

현재 상태는 실행 코드가 전혀 없어 기능 기여 없이 코드베이스 복잡도만 증가합니다. 실제 사용 예정이면 구현을 활성화하고, 미완료라면 파일 제거 후 별도 작업으로 분리하는 편이 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/component/post/SwipeRevealRow.kt`
around lines 1 - 91, This file contains a fully commented-out composable
(SwipeRevealRow and RevealState) which adds no runtime value; either re-enable
the component by uncommenting and ensuring all imports and references
(RevealState enum, SwipeRevealRow composable, functions like
rememberAnchoredDraggableState, LaunchedEffect blocks, snapshotFlow usage, and
modifiers offset/anchoredDraggable) compile and are exercised by a calling
screen, or delete the file and move the in-progress implementation to a feature
branch/issue for later work; pick one approach and apply it consistently so the
codebase no longer contains a dead, commented-out component.
data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyPostDetailResponseDto.kt (1)

32-39: 상세 DTO의 stats 타입을 목록 DTO에서 분리하는 편이 안전합니다.

지금은 StudyPostDetailResponseDtoStudyPostsResponseDto.kt에 있는 top-level Stats에 직접 묶여 있습니다. 한쪽 응답 스키마가 바뀌면 다른 endpoint 파싱도 같이 깨질 수 있어서, 공용 DTO로 명시 분리하거나 이 파일 전용 타입으로 두는 편이 유지보수에 더 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyPostDetailResponseDto.kt`
around lines 32 - 39, StudyPostDetailResponseDto currently reuses the top-level
Stats type defined for StudyPostsResponseDto which couples two response schemas;
to fix, introduce a dedicated Stats type for this file (e.g., DetailStats) or
move a shared Stats into a common DTO file and update StudyPostDetailResponseDto
to reference the new type; update the import/usages where
StudyPostDetailResponseDto.stats is declared and ensure serialization
annotations (`@SerialName`("stats")) remain correct for the new type name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostItem.kt`:
- Around line 104-106: PostItem의 ClickSurface에서 고정된 Modifier.fillMaxWidth()로 호출해
호출자가 전달한 modifier(padding, semantics, testTag 등)를 덮어쓰고 있습니다; ClickSurface 호출부를
수정해 전달받은 modifier를 보존하도록 변경하세요 (예: replace Modifier.fillMaxWidth() with
modifier.then(Modifier.fillMaxWidth()) or modifier.fillMaxWidth()) so the
passed-in modifier is applied before adding fillMaxWidth; locate the
ClickSurface call in PostItem.kt (the line with ClickSurface(onClick = {
onClick(item) }, modifier = ...)) and use the passed modifier variable instead
of the static Modifier.

In `@core/designsystem/src/main/res/drawable/ic_pin.xml`:
- Around line 6-8: The path in ic_pin.xml currently hardcodes
android:fillColor="#C5C5CD"; replace that hardcoded hex with a theme-aware color
reference (either a color resource or a theme attribute) so the icon respects
dark mode and branding. Concretely, update the path's android:fillColor to
reference a color resource (e.g. `@color/design_system_icon`) or a theme attribute
(e.g. ?attr/colorOnBackground or ?attr/colorControlNormal) and ensure the
referenced color is defined in your colors.xml or themes.xml for light/dark
variants; modify the <path> element with the new reference instead of the
literal hex.

In
`@data/study/src/main/java/com/umcspot/spot/study/dto/response/BoardCreateResponseDto.kt`:
- Around line 7-9: The BoardCreateResponseDto currently declares postId as
String which risks runtime parsing errors later; change the postId property in
BoardCreateResponseDto from String to Long (keep `@SerialName`("postId")) so the
DTO carries a numeric ID type end-to-end and update any callers/serializers that
construct or consume BoardCreateResponseDto to pass/expect a Long instead of
relying on toLong parsing.

In
`@data/study/src/main/java/com/umcspot/spot/study/dto/response/SendStudyPostCommentResponseDto.kt`:
- Around line 7-10: The DTO SendStudyPostCommentResponseDto currently declares
commentId as String which can cause type mismatch later; change the property
type to a numeric ID (preferably Long) and update its serialization mapping if
needed so commentId: Long replaces commentId: String in the data class, ensuring
downstream code expecting numeric IDs won't need conversions.

In
`@data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt`:
- Around line 480-482: The code maps response.result.toDomainList() without
checking response.isSuccess, so failures get turned into mapping/NPEs instead of
surfacing server error messages; update the blocks that call
studyDataSource.getStudyPostsList (and the similar call at the other location)
to first check response.isSuccess and on true return
response.result.toDomainList(), otherwise propagate a meaningful exception or
return a failed Result containing response.message (or response.error) so the
original server failure is preserved for logging/handling.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyBoardPostScreen.kt`:
- Around line 54-61: Replace the current LaunchedEffect(Unit) block so
resetPostEditor() runs only once on first composition and side-effect collection
runs in a separate effect: use a rememberSaveable mutableStateOf flag (e.g.,
initialized) to guard a one-time call to viewModel.resetPostEditor(), set the
flag true after calling it, then move the viewModel.sideEffect.collect { ... }
into its own LaunchedEffect that only handles side effects (collects and calls
onBackClick() when effect is StudyDetailSideEffect.BoardPostSuccess). Add
imports for androidx.compose.runtime.rememberSaveable and
androidx.compose.runtime.mutableStateOf as suggested.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyPostContentScreen.kt`:
- Around line 251-255: The click handler currently hides showReportRequestDialog
and immediately sets showAcceptRequestDialog = true before the report result;
change this so the UI only shows the success dialog after observing the report
success result from onReportPost (e.g., via the ViewModel's LiveData/StateFlow
or the callback returned by onReportPost) — remove the direct assignment of
showAcceptRequestDialog = true from the onClick lambda (the block around onClick
that references showReportRequestDialog, showAcceptRequestDialog, and
onReportPost) and instead set showAcceptRequestDialog = true only when the
report success state is emitted/confirmed; apply the same change to the similar
block at lines 258-266.
- Around line 236-240: The current onClick handler calls onDeletePost(...) and
then immediately onDeleteClick(), which can close the screen even if deletion
fails; change the flow so onDeleteClick() is invoked only after a confirmed
successful delete. Update onDeletePost (or add a new variant) to return a
success boolean or accept a completion callback (e.g., onDeletePost(studyId,
post.postId, onResult: (success)->Unit)) and in the onClick set
showDeleteRequestDialog = false only when the result indicates success, calling
onDeleteClick() from that success branch; keep showDeleteRequestDialog behavior
unchanged for failure so the user remains on screen.
- Around line 302-309: The Image used as the clickable menu icon in
StudyPostContentScreen (the Image with painterResource(R.drawable.meetball) and
modifier.clickable { menuExpanded = true }) lacks an accessibility label; update
its contentDescription from null to a descriptive string (preferably a string
resource like "Open menu" or "메뉴 열기") so screen readers announce its purpose
when the Image with .clickable is focused; ensure you update the Image call in
StudyPostContentScreen.kt and use stringResource(...) for localization where
applicable.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt`:
- Around line 105-124: DisposableEffect is currently capturing lifecycleOwner
and uiState.plannerState.selectedDate directly which can cause stale reads and
leaked observers; change the DisposableEffect keys to include lifecycleOwner
(and studyId and selectedTab) e.g. DisposableEffect(lifecycleOwner, studyId,
selectedTab) and wrap uiState.plannerState.selectedDate with
rememberUpdatedState to get a fresh value inside the LifecycleEventObserver
(e.g. val currentSelectedDate =
rememberUpdatedState(uiState.plannerState.selectedDate)) and use
currentSelectedDate.value when calling viewModel.fetchMonthlySchedules, leaving
the rest of the LifecycleEventObserver and add/remove observer logic intact.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt`:
- Around line 683-708: togglePostLike sends duplicate requests when tapped
quickly because it lacks the in-flight guard used by
toggleStudyPostDetailLike/inFlightDetailLikes; add a similar inFlightPosts
(e.g., a MutableSet<Long>) and check at the start of togglePostLike to
early-return if postId is present, add postId to the set before calling
studyRepository.studyPostLike or studyPostUnLike, and remove it in a finally
block so concurrent taps are ignored while a request is in-flight; ensure you
only update _uiState inside result.onSuccess (as now) and that the in-flight set
is referenced (and cleared) even on failure to prevent permanent blocking.
- Around line 526-556: fetchStudyBoardPosts can send duplicate requests for the
same cursor when multiple scroll events occur; add an in-flight guard keyed by
the cursor (or a single boolean for list-loading) to prevent launching another
request for the same nextCursor, and also perform cursor-based deduplication
when merging results. Concretely: in StudyDetailViewModel introduce a mutableSet
or map like inFlightCursors (or a single isPostListLoading flag), check/insert
the cursor (use null as initial) before calling
studyRepository.getStudyPostsList, return early if already in-flight, and remove
the cursor from the set in both onSuccess and onFailure; additionally when
updating state in the onSuccess block (fetchStudyBoardPosts -> _uiState.update),
merge posts by skipping items whose unique id already exists in
state.postState.studyPosts (or ignore the response if nextCursor equals the
current state.nextCursor) to ensure duplicate pages are not appended.
- Around line 710-727: The fetchStudyPostDetail flow updates
currentDetailStudyId/currentDetailPostId but doesn’t verify them when the async
callback returns; guard the success and failure handlers inside
fetchStudyPostDetail by comparing the returned context to the
currentDetailStudyId and currentDetailPostId before calling _uiState.update or
emitError. Specifically, inside the onSuccess and onFailure blocks for
studyRepository.getStudyPostDetail in fetchStudyPostDetail, check that the
stored currentDetailStudyId and currentDetailPostId still match the studyId and
postId parameters (or the identifiers from the returned detail) and only then
update _uiState or call emitError to avoid stale responses overwriting the UI.

---

Outside diff comments:
In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt`:
- Around line 467-477: The toggleMemoirReaction(studyId: Long, memoirId: Long,
reactionType: String) function computes isCurrentlySelected but then returns
early; update it to continue by dispatching the actual toggle flow: compute the
new desired state and call the overloaded toggleMemoirReaction(studyId,
memoirId, reactionType, isCurrentlySelected) (or the existing method that
performs UI update + backend call) so the UI and network actions execute. Locate
the function toggleMemoirReaction(studyId, memoirId, reactionType: String) and
ensure it forwards to the 4-argument overload (or invokes the same logic used in
that overload) instead of exiting after computing isCurrentlySelected.

---

Nitpick comments:
In
`@data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyPostDetailResponseDto.kt`:
- Around line 32-39: StudyPostDetailResponseDto currently reuses the top-level
Stats type defined for StudyPostsResponseDto which couples two response schemas;
to fix, introduce a dedicated Stats type for this file (e.g., DetailStats) or
move a shared Stats into a common DTO file and update StudyPostDetailResponseDto
to reference the new type; update the import/usages where
StudyPostDetailResponseDto.stats is declared and ensure serialization
annotations (`@SerialName`("stats")) remain correct for the new type name.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/component/post/SwipeRevealRow.kt`:
- Around line 1-91: This file contains a fully commented-out composable
(SwipeRevealRow and RevealState) which adds no runtime value; either re-enable
the component by uncommenting and ensuring all imports and references
(RevealState enum, SwipeRevealRow composable, functions like
rememberAnchoredDraggableState, LaunchedEffect blocks, snapshotFlow usage, and
modifiers offset/anchoredDraggable) compile and are exercised by a calling
screen, or delete the file and move the in-progress implementation to a feature
branch/issue for later work; pick one approach and apply it consistently so the
codebase no longer contains a dead, commented-out component.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyPostContentState.kt`:
- Around line 16-19: Wrap the comment list in UiState to distinguish empty vs
error/loading by changing StudyPostContentState's commentData from
ImmutableList<CommentData> = persistentListOf() to a
UiState<ImmutableList<CommentData>> (e.g., UiState.Empty by default); update any
consumers of StudyPostContentState.commentData (views, reducers, mappers) to
handle UiState states (Loading/Success/Error) instead of treating an empty list
as failure and adjust places that construct comment data to emit
UiState.Success(persistentListOf(...)) or UiState.Error(...) as appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5189d7ce-5711-4ee7-aedf-cf10e212c506

📥 Commits

Reviewing files that changed from the base of the PR and between 52623de and 9390d98.

📒 Files selected for processing (34)
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CountsView.kt
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostItem.kt
  • core/designsystem/src/main/res/drawable/ic_pin.xml
  • core/designsystem/src/main/res/drawable/ic_unpin.xml
  • core/network/src/main/java/com/umcspot/spot/network/model/BaseResponse.kt
  • data/study/src/main/java/com/umcspot/spot/study/datasource/StudyDataSource.kt
  • data/study/src/main/java/com/umcspot/spot/study/datasourceimpl/StudyDataSourceImpl.kt
  • data/study/src/main/java/com/umcspot/spot/study/dto/request/BoardCreateRequestDto.kt
  • data/study/src/main/java/com/umcspot/spot/study/dto/request/StudyPostCommentRequestDto.kt
  • data/study/src/main/java/com/umcspot/spot/study/dto/response/BoardCreateResponseDto.kt
  • data/study/src/main/java/com/umcspot/spot/study/dto/response/SendStudyPostCommentResponseDto.kt
  • data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyPostDetailResponseDto.kt
  • data/study/src/main/java/com/umcspot/spot/study/dto/response/StudyPostsResponseDto.kt
  • data/study/src/main/java/com/umcspot/spot/study/mapper/StudyMapper.kt
  • data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt
  • data/study/src/main/java/com/umcspot/spot/study/service/StudyService.kt
  • domain/study/src/main/java/com/umcspot/spot/study/model/BoardCreateModel.kt
  • domain/study/src/main/java/com/umcspot/spot/study/model/StudyPostsDetailResult.kt
  • domain/study/src/main/java/com/umcspot/spot/study/model/StudyPostsResultList.kt
  • domain/study/src/main/java/com/umcspot/spot/study/repository/StudyRepository.kt
  • feature/home/src/main/java/com/umcspot/spot/home/HomeScreen.kt
  • feature/home/src/main/java/com/umcspot/spot/home/navigation/HomeNavigation.kt
  • feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt
  • feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt
  • feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/component/post/SwipeRevealRow.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyDetailState.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/model/StudyPostContentState.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/navigation/StudyDetailNavigation.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyBoardPostScreen.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailBoardScreen.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyPostContentScreen.kt
👮 Files not reviewed due to content moderation or server errors (8)
  • core/designsystem/src/main/res/drawable/ic_unpin.xml
  • data/study/src/main/java/com/umcspot/spot/study/dto/request/StudyPostCommentRequestDto.kt
  • feature/home/src/main/java/com/umcspot/spot/home/navigation/HomeNavigation.kt
  • feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt
  • core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/CountsView.kt
  • feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt
  • feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt
  • feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyDetailBoardScreen.kt

Comment on lines +104 to +106
ClickSurface(
onClick = { onClick(item) },
modifier = Modifier.fillMaxWidth()
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 | 🟡 Minor

새 오버로드에서 전달받은 modifier가 무시됩니다.

Line 106에서 Modifier.fillMaxWidth()를 고정해서 호출부가 넘긴 padding, semantics, testTag 같은 수정이 모두 사라집니다. 기존 API 계약을 유지하려면 전달받은 modifier를 이어서 써야 합니다.

🔧 Proposed fix
     ClickSurface(
         onClick = { onClick(item) },
-        modifier = Modifier.fillMaxWidth()
+        modifier = modifier.fillMaxWidth()
     ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ClickSurface(
onClick = { onClick(item) },
modifier = Modifier.fillMaxWidth()
ClickSurface(
onClick = { onClick(item) },
modifier = modifier.fillMaxWidth()
) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/post/PostItem.kt`
around lines 104 - 106, PostItem의 ClickSurface에서 고정된 Modifier.fillMaxWidth()로
호출해 호출자가 전달한 modifier(padding, semantics, testTag 등)를 덮어쓰고 있습니다; ClickSurface
호출부를 수정해 전달받은 modifier를 보존하도록 변경하세요 (예: replace Modifier.fillMaxWidth() with
modifier.then(Modifier.fillMaxWidth()) or modifier.fillMaxWidth()) so the
passed-in modifier is applied before adding fillMaxWidth; locate the
ClickSurface call in PostItem.kt (the line with ClickSurface(onClick = {
onClick(item) }, modifier = ...)) and use the passed modifier variable instead
of the static Modifier.

Comment on lines +6 to +8
<path
android:pathData="M7.227,0.41C7.071,0.253 6.879,0.136 6.668,0.068C6.457,-0 6.232,-0.017 6.013,0.018C5.794,0.054 5.587,0.141 5.408,0.272C5.23,0.404 5.085,0.576 4.985,0.774L3.774,3.195C3.659,3.425 3.464,3.605 3.226,3.7L0.713,4.705C0.605,4.748 0.51,4.817 0.436,4.906C0.361,4.995 0.31,5.101 0.287,5.215C0.264,5.328 0.269,5.446 0.302,5.557C0.336,5.668 0.396,5.768 0.478,5.85L2.305,7.678L0,9.982V10.478H0.495L2.8,8.172L4.627,9.999C4.709,10.081 4.81,10.142 4.921,10.175C5.032,10.208 5.149,10.214 5.263,10.19C5.376,10.167 5.482,10.116 5.571,10.042C5.66,9.968 5.729,9.872 5.772,9.765L6.777,7.252C6.873,7.013 7.053,6.818 7.283,6.703L9.703,5.492C9.902,5.393 10.074,5.248 10.205,5.069C10.337,4.89 10.424,4.683 10.459,4.464C10.495,4.245 10.478,4.021 10.41,3.81C10.342,3.599 10.224,3.407 10.067,3.25L7.227,0.41Z"
android:fillColor="#C5C5CD"/>
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 | 🟡 Minor

아이콘 색상 하드코딩은 테마 일관성을 깨뜨릴 수 있습니다.

fillColor를 고정 hex 대신 디자인시스템 색상 리소스(또는 theme attr)로 연결해 두는 편이 다크모드/브랜딩 변경에 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/designsystem/src/main/res/drawable/ic_pin.xml` around lines 6 - 8, The
path in ic_pin.xml currently hardcodes android:fillColor="#C5C5CD"; replace that
hardcoded hex with a theme-aware color reference (either a color resource or a
theme attribute) so the icon respects dark mode and branding. Concretely, update
the path's android:fillColor to reference a color resource (e.g.
`@color/design_system_icon`) or a theme attribute (e.g. ?attr/colorOnBackground or
?attr/colorControlNormal) and ensure the referenced color is defined in your
colors.xml or themes.xml for light/dark variants; modify the <path> element with
the new reference instead of the literal hex.

Comment on lines +7 to +9
data class BoardCreateResponseDto(
@SerialName("postId") val postId: String
) No newline at end of file
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

postIdString으로 두면 ID 파싱 실패 경로가 생깁니다.

생성 결과 ID는 이후 화면 라우팅/조회에서 숫자 ID로 쓰일 가능성이 높아, 문자열 파싱(toLong)에 의존하면 런타임 예외 위험이 생깁니다. DTO에서 타입을 일관되게 맞추는 편이 안전합니다.

수정 제안
 data class BoardCreateResponseDto(
-    `@SerialName`("postId") val postId: String
+    `@SerialName`("postId") val postId: Long
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
data class BoardCreateResponseDto(
@SerialName("postId") val postId: String
)
data class BoardCreateResponseDto(
`@SerialName`("postId") val postId: Long
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/study/src/main/java/com/umcspot/spot/study/dto/response/BoardCreateResponseDto.kt`
around lines 7 - 9, The BoardCreateResponseDto currently declares postId as
String which risks runtime parsing errors later; change the postId property in
BoardCreateResponseDto from String to Long (keep `@SerialName`("postId")) so the
DTO carries a numeric ID type end-to-end and update any callers/serializers that
construct or consume BoardCreateResponseDto to pass/expect a Long instead of
relying on toLong parsing.

Comment on lines +7 to +10
data class SendStudyPostCommentResponseDto(
@SerialName("commentId")
val commentId: String
)
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

commentId도 숫자 ID 체계와 타입을 맞추는 게 안전합니다.

댓글 ID가 문자열이면 후속 로직에서 숫자 변환 실패가 발생할 수 있습니다. DTO 단계에서 타입 정합성을 맞춰 두는 것을 권장합니다.

수정 제안
 data class SendStudyPostCommentResponseDto(
     `@SerialName`("commentId")
-    val commentId: String
+    val commentId: Long
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
data class SendStudyPostCommentResponseDto(
@SerialName("commentId")
val commentId: String
)
data class SendStudyPostCommentResponseDto(
`@SerialName`("commentId")
val commentId: Long
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/study/src/main/java/com/umcspot/spot/study/dto/response/SendStudyPostCommentResponseDto.kt`
around lines 7 - 10, The DTO SendStudyPostCommentResponseDto currently declares
commentId as String which can cause type mismatch later; change the property
type to a numeric ID (preferably Long) and update its serialization mapping if
needed so commentId: Long replaces commentId: String in the data class, ensuring
downstream code expecting numeric IDs won't need conversions.

Comment on lines +480 to +482
val response = studyDataSource.getStudyPostsList(studyId, cursor, size)
response.result.toDomainList()
}.onFailure {
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

응답 성공 여부 검증이 누락되어 실패 원인이 왜곡됩니다.

두 메서드 모두 response.isSuccess 확인 없이 response.result를 바로 매핑합니다. 실패 응답일 때 서버 메시지 대신 매핑/NPE 예외로 바뀌어 디버깅과 에러 처리 품질이 떨어집니다.

수정 제안
     override suspend fun getStudyPostsList(
         studyId : Long,
         cursor: Long?,
         size: Int
     ) : Result<StudyPostsResultList> =
         runCatching {
             val response = studyDataSource.getStudyPostsList(studyId, cursor, size)
+            if (!response.isSuccess) {
+                throw Exception(response.message.ifBlank { "게시글 목록 조회에 실패했습니다." })
+            }
             response.result.toDomainList()
         }.onFailure {
             Log.e("StudyRepository", "getStudyPostsList failed", it)
         }

@@
     override suspend fun getStudyPostDetail(
         studyId: Long,
         postId: Long
     ): Result<StudyPostDetailResult> =
         runCatching {
             val response = studyDataSource.getStudyPostDetail(studyId, postId)
+            if (!response.isSuccess) {
+                throw Exception(response.message.ifBlank { "게시글 상세 조회에 실패했습니다." })
+            }
             response.result.toDomain()
         }.onFailure {
             Log.e("StudyRepository", "getStudyPostDetail failed", it)
         }

Also applies to: 491-493

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt`
around lines 480 - 482, The code maps response.result.toDomainList() without
checking response.isSuccess, so failures get turned into mapping/NPEs instead of
surfacing server error messages; update the blocks that call
studyDataSource.getStudyPostsList (and the similar call at the other location)
to first check response.isSuccess and on true return
response.result.toDomainList(), otherwise propagate a meaningful exception or
return a failed Result containing response.message (or response.error) so the
original server failure is preserved for logging/handling.

Comment on lines +302 to +309
Image(
painter = painterResource(R.drawable.meetball),
contentDescription = null,
modifier = Modifier
.align(Alignment.Center)
.size(screenWidthDp(14.dp))
.clickable { menuExpanded = true }
)
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:

# First, find the file in the repository
find . -name "StudyPostContentScreen.kt" -type f

Repository: SPOTeam/Android-V2

Length of output: 154


🏁 Script executed:

# Read the file around lines 302-309
sed -n '295,315p' ./feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyPostContentScreen.kt

Repository: SPOTeam/Android-V2

Length of output: 893


🏁 Script executed:

# Check for similar patterns in the codebase to see if there are other instances
rg "contentDescription\s*=\s*null" --type kotlin | head -20

Repository: SPOTeam/Android-V2

Length of output: 2890


🏁 Script executed:

# Check the context around the Image to confirm it's interactive
sed -n '300,320p' ./feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyPostContentScreen.kt

Repository: SPOTeam/Android-V2

Length of output: 834


클릭 가능한 메뉴 아이콘에 접근성 라벨이 없습니다.

contentDescription = null인 상태에서 .clickable 수정자가 적용된 Image는 스크린리더에서 목적을 알 수 없습니다. 메뉴 버튼의 역할을 설명하는 contentDescription을 추가해 주세요.

수정 제안
                 Image(
                     painter = painterResource(R.drawable.meetball),
-                    contentDescription = null,
+                    contentDescription = "게시글 더보기 메뉴",
                     modifier = Modifier
                         .align(Alignment.Center)
                         .size(screenWidthDp(14.dp))
                         .clickable { menuExpanded = true }
                 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Image(
painter = painterResource(R.drawable.meetball),
contentDescription = null,
modifier = Modifier
.align(Alignment.Center)
.size(screenWidthDp(14.dp))
.clickable { menuExpanded = true }
)
Image(
painter = painterResource(R.drawable.meetball),
contentDescription = "게시글 더보기 메뉴",
modifier = Modifier
.align(Alignment.Center)
.size(screenWidthDp(14.dp))
.clickable { menuExpanded = true }
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/screen/StudyPostContentScreen.kt`
around lines 302 - 309, The Image used as the clickable menu icon in
StudyPostContentScreen (the Image with painterResource(R.drawable.meetball) and
modifier.clickable { menuExpanded = true }) lacks an accessibility label; update
its contentDescription from null to a descriptive string (preferably a string
resource like "Open menu" or "메뉴 열기") so screen readers announce its purpose
when the Image with .clickable is focused; ensure you update the Image call in
StudyPostContentScreen.kt and use stringResource(...) for localization where
applicable.

Comment on lines +105 to +124
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(studyId, selectedTab) {
val observer = LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_RESUME) return@LifecycleEventObserver

when (selectedTab) {
StudyDetailTab.HOME -> viewModel.fetchStudyHomeDetail(studyId)
StudyDetailTab.PLANNER -> {
val date = uiState.plannerState.selectedDate
viewModel.fetchMonthlySchedules(studyId, date.year, date.monthValue)
}
StudyDetailTab.BOARD -> viewModel.fetchStudyBoardPosts(studyId, refresh = true)
StudyDetailTab.MEMOIR -> viewModel.fetchAllMemoirs(studyId)
}
}

val lifecycle = lifecycleOwner.lifecycle
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
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:

fd -t f StudyDetailScreen.kt

Repository: SPOTeam/Android-V2

Length of output: 140


🏁 Script executed:

cat -n feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt | sed -n '100,130p'

Repository: SPOTeam/Android-V2

Length of output: 1492


🌐 Web query:

Compose DisposableEffect LifecycleEventObserver stale state best practices

💡 Result:

Best practices for using DisposableEffect with LifecycleEventObserver in Jetpack Compose, particularly avoiding stale state: 1. Use DisposableEffect to register LifecycleEventObserver and ensure cleanup in onDispose. Official example from Android docs for analytics on lifecycle events: @Composable fun HomeScreen( lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, onStart: -> Unit, onStop: -> Unit ) { val currentOnStart by rememberUpdatedState(onStart) val currentOnStop by rememberUpdatedState(onStop) DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> when { event == Lifecycle.Event.ON_START -> currentOnStart event == Lifecycle.Event.ON_STOP -> currentOnStop } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } // Home screen content } Key practices to avoid stale state (stale closures): - Wrap callbacks in rememberUpdatedState(newValue): Returns a State that always reads the latest value during execution, even if the effect was created with an older snapshot. Essential when lambdas might change between recompositions but effect key (like lifecycleOwner) doesn't. - Pass dependencies as keys (e.g., DisposableEffect(lifecycleOwner)): Restarts effect if lifecycleOwner changes, disposing old observer first. - Always include meaningful onDispose cleanup; empty onDispose is poor practice. Prefer higher-level APIs when possible (Lifecycle 2.7.0+): - LifecycleResumeEffect(key1) { /* on RESUME / onPauseOrDispose { / cleanup / } } - LifecycleStartEffect(key1) { / on START / onStopOrDispose { / cleanup / } } - LifecycleEventEffect(Lifecycle.Event.ON_START) { / one-shot, no paired cleanup */ } These automatically handle observer registration/removal, reducing boilerplate and error risk vs. manual DisposableEffect. Avoid: - Capturing changing state/lambdas directly in observer: Causes stale closures as effect persists across recompositions. - Unstable keys causing excessive restarts. - State updates inside DisposableEffect (use LaunchedEffect for async/state changes). - Long-running sync work (non-suspendable only). For async lifecycle-aware work, combine with repeatOnLifecycle in LaunchedEffect. This ensures resource safety, correct lifecycle sync, and fresh state access.

Citations:


ON_RESUME 옵저버가 오래된 상태를 캡처합니다.

DisposableEffectselectedTab만 key로 두고 lifecycleOwneruiState.plannerState.selectedDate를 직접 캡처합니다. 플래너 날짜를 바꾼 뒤 resume하면 이전 날짜로 조회하거나, lifecycleOwner가 변경되었을 때 예전 lifecycle에 observer가 남을 수 있습니다.

DisposableEffect(lifecycleOwner, studyId, selectedTab)로 키를 추가하고 selectedDaterememberUpdatedState로 감싸서 항상 최신 값을 읽도록 수정하세요.

🔧 제안된 수정
     val lifecycleOwner = LocalLifecycleOwner.current
+    val currentSelectedDate by rememberUpdatedState(uiState.plannerState.selectedDate)
-    DisposableEffect(studyId, selectedTab) {
+    DisposableEffect(lifecycleOwner, studyId, selectedTab) {
         val observer = LifecycleEventObserver { _, event ->
             if (event != Lifecycle.Event.ON_RESUME) return@LifecycleEventObserver

             when (selectedTab) {
                 StudyDetailTab.HOME -> viewModel.fetchStudyHomeDetail(studyId)
                 StudyDetailTab.PLANNER -> {
-                    val date = uiState.plannerState.selectedDate
+                    val date = currentSelectedDate
                     viewModel.fetchMonthlySchedules(studyId, date.year, date.monthValue)
                 }
                 StudyDetailTab.BOARD -> viewModel.fetchStudyBoardPosts(studyId, refresh = true)
                 StudyDetailTab.MEMOIR -> viewModel.fetchAllMemoirs(studyId)
             }
         }

         val lifecycle = lifecycleOwner.lifecycle
         lifecycle.addObserver(observer)
         onDispose { lifecycle.removeObserver(observer) }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(studyId, selectedTab) {
val observer = LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_RESUME) return@LifecycleEventObserver
when (selectedTab) {
StudyDetailTab.HOME -> viewModel.fetchStudyHomeDetail(studyId)
StudyDetailTab.PLANNER -> {
val date = uiState.plannerState.selectedDate
viewModel.fetchMonthlySchedules(studyId, date.year, date.monthValue)
}
StudyDetailTab.BOARD -> viewModel.fetchStudyBoardPosts(studyId, refresh = true)
StudyDetailTab.MEMOIR -> viewModel.fetchAllMemoirs(studyId)
}
}
val lifecycle = lifecycleOwner.lifecycle
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
val lifecycleOwner = LocalLifecycleOwner.current
val currentSelectedDate by rememberUpdatedState(uiState.plannerState.selectedDate)
DisposableEffect(lifecycleOwner, studyId, selectedTab) {
val observer = LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_RESUME) return@LifecycleEventObserver
when (selectedTab) {
StudyDetailTab.HOME -> viewModel.fetchStudyHomeDetail(studyId)
StudyDetailTab.PLANNER -> {
val date = currentSelectedDate
viewModel.fetchMonthlySchedules(studyId, date.year, date.monthValue)
}
StudyDetailTab.BOARD -> viewModel.fetchStudyBoardPosts(studyId, refresh = true)
StudyDetailTab.MEMOIR -> viewModel.fetchAllMemoirs(studyId)
}
}
val lifecycle = lifecycleOwner.lifecycle
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailScreen.kt`
around lines 105 - 124, DisposableEffect is currently capturing lifecycleOwner
and uiState.plannerState.selectedDate directly which can cause stale reads and
leaked observers; change the DisposableEffect keys to include lifecycleOwner
(and studyId and selectedTab) e.g. DisposableEffect(lifecycleOwner, studyId,
selectedTab) and wrap uiState.plannerState.selectedDate with
rememberUpdatedState to get a fresh value inside the LifecycleEventObserver
(e.g. val currentSelectedDate =
rememberUpdatedState(uiState.plannerState.selectedDate)) and use
currentSelectedDate.value when calling viewModel.fetchMonthlySchedules, leaving
the rest of the LifecycleEventObserver and add/remove observer logic intact.

Comment on lines +526 to +556
fun fetchStudyBoardPosts(studyId: Long, refresh: Boolean = false) {
viewModelScope.launch {
val currentState = _uiState.value.postState
val cursor = if (refresh) null else currentState.nextCursor

if (!refresh && !currentState.hasNext && currentState.studyPosts.isNotEmpty()) return@launch

_uiState.update { it.copy(isLoading = true) }

studyRepository.getStudyPostsList(
studyId = studyId,
cursor = cursor,
size = 20
).onSuccess { posts ->
_uiState.update { state ->
val baseList = if (refresh || cursor == null) emptyList() else state.postState.studyPosts
state.copy(
postState = state.postState.copy(
studyPosts = (baseList + posts.studyPostsList).toPersistentList(),
hasNext = posts.hasNext,
nextCursor = posts.nextCursor
),
isLoading = false
)
}
}.onFailure {
_uiState.update { state -> state.copy(isLoading = false) }
emitError(it)
}
}
}
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

페이지 요청 중복을 막지 않아 같은 게시글 페이지가 두 번 붙을 수 있습니다.

무한 스크롤 이벤트가 연속으로 들어오면 같은 cursor로 요청이 둘 이상 날아가고, 응답이 모두 성공했을 때 동일 페이지가 studyPosts에 중복 append될 수 있습니다. 목록 로딩 전용 in-flight 가드나 cursor 기준 중복 방지가 필요합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt`
around lines 526 - 556, fetchStudyBoardPosts can send duplicate requests for the
same cursor when multiple scroll events occur; add an in-flight guard keyed by
the cursor (or a single boolean for list-loading) to prevent launching another
request for the same nextCursor, and also perform cursor-based deduplication
when merging results. Concretely: in StudyDetailViewModel introduce a mutableSet
or map like inFlightCursors (or a single isPostListLoading flag), check/insert
the cursor (use null as initial) before calling
studyRepository.getStudyPostsList, return early if already in-flight, and remove
the cursor from the set in both onSuccess and onFailure; additionally when
updating state in the onSuccess block (fetchStudyBoardPosts -> _uiState.update),
merge posts by skipping items whose unique id already exists in
state.postState.studyPosts (or ignore the response if nextCursor equals the
current state.nextCursor) to ensure duplicate pages are not appended.

Comment on lines +683 to +708
fun togglePostLike(studyId: Long, postId: Long, isCurrentlyLiked: Boolean) {
viewModelScope.launch {
val result = if (isCurrentlyLiked) {
studyRepository.studyPostUnLike(studyId, postId)
} else {
studyRepository.studyPostLike(studyId, postId)
}

result.onSuccess {
_uiState.update { state ->
val updatedPosts = state.postState.studyPosts
.map { post ->
if (post.postId == postId) {
post.copy(
isLiked = !isCurrentlyLiked,
likeCount = if (!isCurrentlyLiked) post.likeCount + 1 else post.likeCount - 1
)
} else post
}
.toPersistentList()

state.copy(postState = state.postState.copy(studyPosts = updatedPosts))
}
}.onFailure { emitError(it) }
}
}
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

목록 좋아요는 연속 탭 시 카운트가 쉽게 틀어집니다.

상세용 toggleStudyPostDetailLike()inFlightDetailLikes로 중복 요청을 막는데, 목록용은 같은 보호가 없습니다. 빠르게 두 번 탭하면 같은 isCurrentlyLiked 값으로 요청이 중복 전송되고 likeCount가 2번 증감할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt`
around lines 683 - 708, togglePostLike sends duplicate requests when tapped
quickly because it lacks the in-flight guard used by
toggleStudyPostDetailLike/inFlightDetailLikes; add a similar inFlightPosts
(e.g., a MutableSet<Long>) and check at the start of togglePostLike to
early-return if postId is present, add postId to the set before calling
studyRepository.studyPostLike or studyPostUnLike, and remove it in a finally
block so concurrent taps are ignored while a request is in-flight; ensure you
only update _uiState inside result.onSuccess (as now) and that the in-flight set
is referenced (and cleared) even on failure to prevent permanent blocking.

Comment on lines +710 to +727
fun fetchStudyPostDetail(studyId: Long, postId: Long) {
currentDetailStudyId = studyId
currentDetailPostId = postId
_uiState.update { it.copy(postDetailState = UiState.Loading) }

viewModelScope.launch {
studyRepository.getStudyPostDetail(studyId, postId)
.onSuccess { detail ->
_uiState.update { it.copy(postDetailState = UiState.Success(detail)) }
}
.onFailure { t ->
_uiState.update {
it.copy(postDetailState = UiState.Failure(t.message ?: "게시글을 불러오지 못했습니다."))
}
emitError(t)
}
}
}
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

이전 상세 응답이 늦게 도착하면 현재 화면을 덮어쓸 수 있습니다.

currentDetailStudyId/currentDetailPostId를 저장하지만 성공/실패 콜백에서 응답 대상 검증을 하지 않습니다. A 게시글에서 B 게시글로 바로 이동했을 때 A 응답이 늦게 오면 postDetailState가 B 화면 위에 잘못 반영됩니다.

💡 제안 수정
         viewModelScope.launch {
             studyRepository.getStudyPostDetail(studyId, postId)
                 .onSuccess { detail ->
+                    if (currentDetailStudyId != studyId || currentDetailPostId != postId) return@onSuccess
                     _uiState.update { it.copy(postDetailState = UiState.Success(detail)) }
                 }
                 .onFailure { t ->
+                    if (currentDetailStudyId != studyId || currentDetailPostId != postId) return@onFailure
                     _uiState.update {
                         it.copy(postDetailState = UiState.Failure(t.message ?: "게시글을 불러오지 못했습니다."))
                     }
                     emitError(t)
                 }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/study/src/main/java/com/umcspot/spot/study/detail/StudyDetailViewModel.kt`
around lines 710 - 727, The fetchStudyPostDetail flow updates
currentDetailStudyId/currentDetailPostId but doesn’t verify them when the async
callback returns; guard the success and failure handlers inside
fetchStudyPostDetail by comparing the returned context to the
currentDetailStudyId and currentDetailPostId before calling _uiState.update or
emitError. Specifically, inside the onSuccess and onFailure blocks for
studyRepository.getStudyPostDetail in fetchStudyPostDetail, check that the
stored currentDetailStudyId and currentDetailPostId still match the studyId and
postId parameters (or the identifiers from the returned detail) and only then
update _uiState or call emitError to avoid stale responses overwriting the UI.

@fredleeJH fredleeJH self-requested a review April 13, 2026 14:38
@fredleeJH fredleeJH assigned fredleeJH and starshape7 and unassigned fredleeJH Apr 13, 2026
@fredleeJH fredleeJH merged commit 65a9f25 into develop Apr 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT/#47] 스터디 게시판 구현

2 participants