Skip to content

추억 노트 미디어 음성 녹음 첨부 — #369 에서 서버 계약 부재로 빠진 갈래 #1118

Closed as not planned
1 of 1 issue completed

Description

@OnlyReadOneBook

📜 Overview (Required)

#369 가 추억 노트 미디어에 즉석 촬영을 붙였지만(사진·영상, PR #1112), 녹음은 서버 자리가 없어 빠졌다. 그 자리가 서면 붙이는 후속.

지금 상태:

계층 오디오
PhotoUploadRepositoryImpl.ALLOWED_EXTENSIONS mp3·m4a·wav 이미 포함
서버 업로드 계약 (presigned-url) 허용 (mp3·m4a·wav, 상한 50MB)
MediaKind PHOTO, VIDEO — 없음
저장 페이로드 (memorialPhotoUrl·memorialVideo) 자리 없음

즉 올릴 수는 있는데 추억 노트에 붙여 둘 필드가 없어 저장 시점에 버려진다. 서버 필드가 선행이라 blocked_by 로 걸어 둔다.

🚀 해야할 일

  • MediaKind 에 AUDIO 추가 — MemorialMediaUploadRepositoryImpl.resolve 의 when 이 컴파일 에러로 누락을 잡아 준다
  • ResolveMemorialMediaForSaveUseCase 에 오디오 갈래 추가 + 저장 페이로드 매핑
  • 녹음 인텐트 — MediaStore.Audio.Media.RECORD_SOUND_ACTION. 사진·영상과 달리 결과 URI 를 인텐트가 돌려주는 방식이라 TakePicture/CaptureVideo 처럼 출력 URI 를 미리 넘기는 계약이 아니다. 전용 ActivityResultContract 를 짜야 한다
  • 녹음 앱이 없는 기기 처리 — MemorialMediaSourceState.launchCapture 의 ActivityNotFoundException 갈래를 그대로 재사용
  • UI 자리 — 추억 노트 편집 화면에 음성 슬롯을 새로 두는지, 기존 슬롯에 합치는지 시안 확인 필요. 미답변이면 FE 재량으로 확정하고 근거를 PR 에 남긴다
  • 시트 옵션 — MemorialMediaSourceBottomSheet 의 MemorialMediaTarget 에 AUDIO 추가 (아이콘은 타임레터 ic_mic 와 같은 모양으로 신규)
  • 재생 UI — 붙인 녹음을 다시 듣는 수단이 필요한지 판단 (수신자 열람 화면 포함)

📍 Note (Optional)

Activity

  1. changed the title [-]feat(afternote): 추모 미디어 음성 녹음 첨부 — #369 에서 서버 계약 부재로 빠진 갈래[/-] [+]feat(afternote): 추억 노트 미디어 음성 녹음 첨부 — #369 에서 서버 계약 부재로 빠진 갈래[/+] on Aug 25, 2026
  2. changed the title [-]feat(afternote): 추억 노트 미디어 음성 녹음 첨부 — #369 에서 서버 계약 부재로 빠진 갈래[/-] [+]추억 노트 미디어 음성 녹음 첨부 — #369 에서 서버 계약 부재로 빠진 갈래[/+] on Aug 28, 2026
  3. OnlyReadOneBook commented on Aug 30, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    전제 재판정 — 「서버 계약 부재」는 끝났다

    BE origin/main 실코드로 대조했다. 근거는 커밋 72fee63 feat: 추억 노트 미디어에 음성과 삭제·관리키 검증을 넣는다 (2026-08-30). blocked_by 로 걸어 둔 Afternote-BE#248 도 CLOSED.

    본문 표와 어긋나는 지점

    본문 주장 실제 (BE origin/main)
    저장 페이로드에 오디오 자리 없음 있다 — AfternotePlaylist.memorialAudioUrl (@Column(name = "memorial_audio_url", length = 1000))
    저장 요청에 자리 없음 있다 — AfternoteCreateRequest.PlaylistRequest.memorialAudioUrl, AfternoteUpdateRequest.playlist 동일
    조회로 못 돌려받음 돌려준다 — AfternotePublishedPlaylistResponse.memorialAudioUrl, ReceivedAfternoteDetailResponse.PlaylistInfo.memorialAudioUrl
    서버 필드가 선행이라 blocked_by 선행 이슈 BE#248 CLOSED — 블로커 해소
    아이콘은 타임레터 ic_mic 와 같은 모양으로 신규 신규 불필요 — core:ui 에 core_ui_ic_mic.xml 이 이미 있고 타임레터가 그걸 쓴다 (MediaBottomSheetContent.kt:50)

    여전히 맞는 것: MediaKind 는 PHOTO·VIDEO 뿐, presigned 는 mp3·m4a·wav 허용(상한 50MB — max-bytes.audio:52428800), PhotoUploadRepositoryImpl.ALLOWED_EXTENSIONS 에 오디오 확장자 포함.

    계약 세부 — 본문에 없던 제약

    // PlaylistValidationStrategy.validatePlaylistMedia
    if (shouldValidateSlot(create, playlist.memorialAudioUrlSpecified(), playlist.getMemorialAudioUrl())) {
        requireNonBlank(playlist.getMemorialAudioUrl(), ErrorCode.MEMORIAL_AUDIO_URL_CANNOT_BE_EMPTY);
        requireManaged(playlist.getMemorialAudioUrl(), S3Service.MediaKind.AUDIO);
    }
    • 우리 S3 키만 받는다. afternotes/ 디렉터리의 관리 객체가 아니면 UNMANAGED_MEDIA_URL, 확장자가 {mp3, m4a, wav} 밖이면 INVALID_FILE_EXTENSION — 둘 다 400. 즉 업로드는 반드시 DIRECTORY_AFTERNOTES 로 가야 하고 확장자가 저 셋 중 하나로 확정돼야 한다.
    • PATCH 삭제가 열렸다. PlaylistRequestDeserializer 가 «필드 생략 = 유지» 와 «JSON null = 삭제(DB 참조 + S3 객체)» 를 가른다. 현재 FE 는 Json { } 기본값(encodeDefaults = false)이라 값이 null 인 필드를 아예 안 실어 보내므로 지금 동작은 «유지» 로 안전하다.

    착수 전에 바뀌어야 할 항목

    1. 녹음 인텐트 — RECORD_SOUND_ACTION 은 확장자를 보장하지 못한다

    본문의 «MediaStore.Audio.Media.RECORD_SOUND_ACTION + 전용 ActivityResultContract» 는 계약 관점에서 위험하다. 결과 포맷을 녹음 앱이 정한다 — AOSP 계열은 audio/amr·audio/3gpp, 삼성은 audio/mp4(m4a). BE 화이트리스트는 mp3·m4a·wav 뿐이라 amr/3gp 가 오면 400 으로 튕긴다. 게다가 PhotoUploadRepositoryImpl.resolveExtension 은 모르는 MIME 을 DEFAULT_EXTENSION = "jpg" 로 떨어뜨려서, 녹음 파일이 .jpg 로 올라간 뒤 오디오 슬롯 검증에서 다시 튕긴다.

    → 인앱 MediaRecorder(MPEG_4 + AAC → .m4a)가 출력 포맷을 결정적으로 잡는 유일한 길이다. 대신 RECORD_AUDIO 런타임 권한이 필요해지고(인텐트 경로는 불필요) 매니페스트를 건드리므로 CI Test Plan 이 selected/full 로 올라간다. 인텐트 경로를 유지한다면 확장자 화이트리스트 밖 결과를 거르는 갈래가 반드시 붙어야 한다.

    또한 core 에 오디오 전용 업로드 경로가 필요하다 — 지금은 PhotoUploadRepository·VideoUploadRepository 뿐이고 전자는 위처럼 오디오 MIME 을 jpg 로 흘린다.

    2. UI 자리 — 시안 없음 (실측)

    Figma 정리 Screen Design 의 애프터노트(4327:99446)·NEW 추억 노트(4327:72816) 두 구역 전체를 REST 로 받아 노드명·텍스트를 «음성·녹음·보이스·오디오·voice·audio·mic» 으로 전수 검색 → 실히트 0건(42건은 Dynamic Island spacer 오탐). 수신자 열람 쪽도 같다.

    → 「미답변 디자인 질의는 FE 재량」 방침대로 시안 부재는 블로커가 아니다. 기존 미디어 첨부 관례(MemorialPhotoUpload·MemorialVideoUpload 슬롯 + MemorialMediaSourceBottomSheet)를 그대로 따라 세 번째 슬롯을 두는 형태로 확정하고 근거를 PR 에 남긴다.

    3. 재생 UI

    수신자 열람 MemorialReceivedDetailScreen 에 영상 섹션이 이미 있고, core:common 으로 승격된 launchMemorialVideo(#1436)가 http/https 스킴만 골라 외부 앱으로 여는 패턴을 쓴다(#1394) — 실패는 스낵바 2문구로 갈린다(#1391). 오디오도 같은 함수를 그대로 태우면 새 플레이어 없이 닫힌다.

    지금 착수하지 않는 이유 — 파일 경합

    계약은 열렸지만 이 기능의 본체(폼 상태 + 저장 배선)가 열린 PR 4건과 같은 파일을 쓴다.

    파일 잡고 있는 PR
    author/editor/state/AfternoteTypeForm.kt, EditorFormMutations.kt, AfternoteEditorViewModel.kt #1565 · #1567 · #1582 · #1589
    author/navigation/**, AppNavigationActions.kt #1582
    feature/afternote/presentation/src/main/res/values/strings.xml #1546 (리소스 프리픽스 전면 교체)

    특히 #1565 가 AfternoteTypeForm.Memorial 을 값 객체로 재편하며 picked/server 축을 가르는 중이다. 그 전에 옛 모양 위에 오디오 필드를 얹으면 #1565 가 오디오까지 다시 손봐야 하고, 반대로 #1565 뒤에 붙이면 오디오도 같은 값 객체 규격을 그대로 받는다. 그 아래 계층(MediaKind·ResolveMemorialMediaForSaveUseCase·DTO·매퍼)만 먼저 내면 폼에서 값이 안 흘러 전부 휴면 코드가 된다.

    → #1565 머지 후 한 판으로 간다. 순서: MediaKind.AUDIO + core 오디오 업로드 → 폼 상태·저장 배선 → 녹음 슬롯 UI + 시트 옵션 → 수신자 재생.

    갱신된 체크리스트

    • MediaKind 에 AUDIO 추가 — MemorialMediaUploadRepositoryImpl.resolve 의 when 이 누락을 컴파일 에러로 잡아 준다
    • core 오디오 업로드 경로 — PhotoUploadRepository 는 모르는 오디오 MIME 을 jpg 로 흘린다
    • ResolveMemorialMediaForSaveUseCase 오디오 갈래 + MemorialWritePayload.memorialAudioUrl + DTO(AfternotePlaylistRequestDto·AfternotePlaylistDto)·매퍼 양방향
    • 녹음 수단 — 인앱 MediaRecorder(m4a 확정, RECORD_AUDIO 권한) vs RECORD_SOUND_ACTION(확장자 비보장, 화이트리스트 필터 필수) 중 택일. 기기 실측으로 확정
    • 녹음 앱/권한 부재 처리 — MemorialMediaSourceState.launchCapture 의 ActivityNotFoundException 갈래 재사용
    • 폼 자리 — MemorialPhotoUpload·MemorialVideoUpload 관례를 따른 세 번째 슬롯 (시안 0건, FE 재량)
    • 시트 옵션 — MemorialMediaTarget.AUDIO (아이콘은 core_ui_ic_mic 재사용, 신규 제작 불필요)
    • 수신자 재생 — core:common 의 launchMemorialVideo(refactor(core): 추모 영상 실행 가드가 발신자·수신자 2벌 — 37줄 문자 단위 동일본을 core:common 으로 승격 #1436) 를 그대로 태워 스킴 가드 + 스낵바 2문구 재사용

    곁가지 — 같은 커밋이 #1406 의 전제도 바꿨다

    MemorialMediaSourceBottomSheet.removableMemorialMediaTargets 의 KDoc 은 서버에 저장된 미디어를 삭제 대상에서 뺀 근거를 이렇게 적고 있다:

    수정(PATCH) 계약이 삭제를 표현하지 못한다 — BE AfternotePlaylist.update 는 null 필드를 "기존 값 유지" 로 해석하므로 (…) 서버 미디어 삭제는 BE 계약 확장 후 후속.

    그 «BE 계약 확장» 이 같은 커밋 72fee63 으로 들어왔다 — 이제 JSON null 을 명시하면 DB·S3 에서 지워진다. 근거가 사라진 주석이고, 그 축은 #1406 이 잡고 있다.

  4. OnlyReadOneBook commented on Aug 30, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    본문의 「서버 자리 없음」이 낡았습니다 — BE 계약이 오늘 열렸습니다 (2026-08-30)

    이 이슈는 본문에 「서버 업로드는 mp3·m4a·wav 를 허용하는데 추억 노트에 붙여 둘 필드가 없어 저장 시점에 버려진다」고 적고, 그래서 서버 선행을 blocked_by 로 걸어 뒀습니다. 그 전제가 오늘 해소됐습니다.

    실측:

    항목 상태
    Afternote-BE#248 (「추억 노트 미디어에 음성 자리 부재」) CLOSED — 2026-08-30 03:35:45Z
    Afternote-BE#268 feat: 추억 노트 미디어 음성·삭제·관리키 검증 MERGED — 2026-08-30 03:35:44Z
    BE 저장소의 memorialAudioUrl 코드 검색 15건

    즉 「올릴 수는 있는데 붙여 둘 필드가 없다」는 더 이상 사실이 아닙니다. 본문 표의 「저장 페이로드 (memorialPhotoUrl·memorialVideo) — 자리 없음」 행이 낡았습니다.

    그래도 지금 당장 착수하지는 않았습니다 — 파일이 겹칩니다

    FE 쪽 MediaKind 는 여전히 PHOTO·VIDEO 뿐이라 할 일 자체는 본문 그대로입니다. 다만 이 이슈가 건드릴 파일(추억 노트 미디어 폼·DTO·미디어 선택 시트)을 지금 열려 있는 PR 들이 동시에 만지고 있습니다:

    • #1565 — 추억 영상·썸네일 값 객체화(picked/server 축)
    • #1600 · #1602 — 수정 요청의 명시적 null / 빈 배열 전송
    • #1605 — 미디어 선택 바텀시트를 core:ui 로 승격

    특히 #1605 가 승격한 MediaSelectSheetContent 는 항목 개수를 계약에 박지 않았습니다(items: List<MediaSheetItem>). 그 스택이 착륙한 뒤에 시작하면 음성 항목은 호출부에서 원소 하나 더 넣는 것으로 끝나고 core:ui 는 안 건드려도 됩니다.

    정리 — 이 이슈의 남은 선행 조건은 「서버 계약」이 아니라 「위 스택 착륙」입니다. blocked_by 를 손대야 하면 그 기준으로 다시 거는 게 맞습니다.

    착수할 때 확인할 것

  5. added
    pr-open이 이슈를 구현하는 PR 이 열려 있다 — base 가 기본 브랜치가 아니어도 드러낸다
    on Aug 30, 2026
  6. OnlyReadOneBook commented on Aug 30, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    본문의 전제가 낡았다 — 서버 자리는 이미 생겼다 (2026-08-31 재판정)

    본문은 「저장 페이로드에 음성을 실을 자리가 없다」를 전제로 Afternote-BE#248 을 blocked_by 로 걸어 뒀는데, 그 블로커는 2026-08-30 에 닫혔다. BE origin/main 8e605f4 실코드로 확인했다 (OpenAPI 는 전 필드를 optional 로 보여 주므로 근거로 쓰지 않았다):

    계약 실코드 확인된 값
    쓰기 요청 AfternoteCreateRequest.PlaylistRequest#L115 String memorialAudioUrl — nullable, 추억 노트당 1개
    엔티티 AfternotePlaylist#L35 memorial_audio_url VARCHAR(1000)
    확장자 S3Service#L83 mp3 · m4a · wav
    저장 시 재검증 PlaylistValidationStrategy#L84 업로드가 성공해도 확장자가 다르면 400
    조회 응답 작성자 · 수신자 #L77 둘 다 memorialAudioUrl nullable

    구현 커밋은 72fee63 (PR Afternote/Afternote-BE#268).

    본문에 함께 적힌 「PATCH 가 첨부 삭제를 표현하지 못한다」도 같은 커밋에서 풀렸다. PlaylistRequestDeserializer 가 「키 없음」과 「키 있고 JSON null」을 갈라 AfternotePlaylist.update 의 specified 플래그로 넘긴다 — 즉 명시적 null 을 보내면 DB 참조와 S3 객체가 함께 지워진다.

    구현 — PR #1625

    위 판정에 따라 체크리스트를 이렇게 처리했다.

    • MediaKind.AUDIO · ResolveMemorialMediaForSaveUseCase 오디오 갈래 · 저장 페이로드 매핑 — 완료.
    • 녹음 인텐트 — 완료. 본문 지적대로 RECORD_SOUND_ACTION 은 결과 URI 를 인텐트가 돌려주는 계약이라 전용 ActivityResultContract(RecordSoundContract)를 새로 짰다.
    • 녹음 앱 부재 처리 — 완료. 기존 ActivityNotFoundException 갈래를 그대로 재사용.
    • 시트 옵션 — 완료. 다만 「파일에서 선택」·「음성 녹음」 두 갈래다. 음성은 사진 선택기(PickVisualMedia) 대상이 아니라 문서 선택기(SAF)로 골라야 해서 첫 항목의 아이콘·문구도 슬롯을 따라 갈린다. 아이콘은 신규로 그리지 않고 core_ui_ic_mic(타임레터 시안과 같은 모양)을 재사용했다.
    • UI 자리 — FE 확정. 착수 시점에 시안을 다시 열었지만 「NEW 추억 노트」 구역(4327:72816)에 추억노트_사진추가·추억노트_영상 추가만 있고 음성 프레임이 없다. 파일 전체에서 「음성 추가하기」가 나오는 곳은 타임레터 블록 시트뿐이다. 그래서 영상 슬롯 바로 뒤에 같은 규격으로 「남기고 싶은 목소리」 섹션을 새로 뒀다 — 근거는 PR 본문에.
    • 재생 UI — 이번 범위에서 뺐다. 영상 슬롯도 앱 안에서 재생하지 않고 첨부 여부만 보여 준다. 수신자 열람 화면의 재생 수단은 영상과 함께 다뤄야 일관돼서 뒤로 미뤘다.

    아직 안 한 것

    이 이슈는 열어 둔다 — PR #1625 는 Closes 가 아니라 Refs 로 걸었다. 아래 항목은 본문 체크리스트의 마지막 줄(재생 UI)이 계속 추적하며, 담당은 그대로 나(afternote·receiver 모듈)다. 남에게 넘기는 몫은 없다.

    • 수신자 열람 화면의 음성 노출·재생. 서버 응답에는 이미 실려 오지만(위 표) FE 수신자 경로는 아직 읽지 않는다. 영상의 재생 수단과 함께 다루는 편이 낫다.

    참고로 첨부 단계에서 막은 형식이 있다 — 안드로이드 기본 녹음기가 흔히 내놓는 audio/amr·audio/3gpp 는 서버가 안 받아 첨부 시점에 안내하고 되돌린다. 서버가 형식을 늘리면 MemorialAudioFormats 한 곳만 고치면 된다.

  7. OnlyReadOneBook commented on Sep 2, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    착수 시 함께 판단할 것 — 썸네일 저장 직전 재업로드를 ResolveMemorialMediaForSaveUseCase 로 모을지 (2026-09-02)

    완료 조건의 「ResolveMemorialMediaForSaveUseCase 에 오디오 갈래 추가」 를 할 때, 같은 자리에 썸네일 갈래도 모을지 한 번에 결정한다. 두 번 손대지 않기 위한 메모다.

    현황. #1550 (PR #1567) 이 추모 영상 썸네일 실패에 재시도를 붙이면서, 저장 직전에 업로드 실패한 썸네일 바이트를 한 번 더 올려 보는 recoverPendingThumbnailOrNull 을 AfternoteEditorViewModel 에 두었다. UseCase 로 빼지 않은 근거는 #246 의 기준(재사용 1곳 + 약한 로직이면 ViewModel 이 Repository 직접 호출)이다 — 바이트가 ViewModel 소유 상태이고, «실패해도 저장은 간다» 정책은 호출부의 ?: memorialMedia 한 줄이다.

    갈림. 이 이슈로 저장 직전 미디어 정리에 슬롯이 하나 더(음성) 생기면, 영상·사진·음성·썸네일 네 갈래 중 썸네일만 ViewModel 에 남는 모양이 된다. 그때 두 선택지가 있다.

    선택 내용 대가
    (a) 그대로 둔다 썸네일은 실패를 삼키는 갈래라 AfternoteFailure.MediaSave 로 올려 보낼 이유가 없다 — UseCase 에 있을 동기가 애초에 다르다 저장 직전 정리가 두 층에 흩어진 채로 남는다
    (b) UseCase 로 모은다 invoke 에 썸네일 바이트 입력을 더해 URL 또는 null 로 해결하되 절대 실패로 종료하지 않는 갈래로 둔다 UseCase 계약과 #349 테스트를 바꾼다. 바이트 소유는 그대로 ViewModel 이라 인자로 넘겨 주는 층이 하나 생긴다

    별도 이슈로 만들 만한 크기는 아니라(#1550 대화에서 판단) 여기에만 남긴다. 착수 전 위 표로 결정하고, (b) 로 가면 완료 조건에 한 줄 추가하면 된다.

  8. OnlyReadOneBook commented on Sep 2, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    첨부·녹음 경로(해야 할 일 1~6)를 #1734 로 분리했고 PR #1625 가 그 이슈를 Closes 합니다.

    이 이슈에는 7번 — 붙인 음성을 다시 듣는 수단(작성자 상세·수신자 열람, 영상 재생과 함께) — 만 남습니다. 블로커 없음(BE#248 닫힘).

  9. removed
    pr-open이 이슈를 구현하는 PR 이 열려 있다 — base 가 기본 브랜치가 아니어도 드러낸다
    on Sep 2, 2026
  10. OnlyReadOneBook commented on Sep 11, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    시안에 없는 기능이라 닫습니다. 추억 노트 음성은 #369 본문의 "(및 음성 녹음)" 한 줄에서 유추한 것이고, 추억 노트 구역 시안에는 사진과 영상만 있습니다. 판정 근거는 PR #1625 의 닫는 코멘트에 있습니다. 서버의 memorialAudioUrl 은 FE 에서 소비하지 않으며, Afternote/Afternote-BE#248 에 같은 내용을 남겼습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:afternote애프터노트 기능 영역enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions