Skip to main content
← 블로그

좋아요와 프로필, 그리고 작은 UX 보정

VauDium ·

커뮤니티에 좋아요 기능과 다른 유저 프로필 보기를 한 번에 붙였습니다. 그 사이사이 작은 결정들과, 마지막에 한 번 되돌린 결정에 대해.

좋아요와 프로필, 그리고 작은 UX 보정

커뮤니티에 두 가지를 붙였습니다. 좋아요다른 유저 프로필 보기. 두 기능 모두 리스트와 디테일에 다 들어가 있어서, 점 하나가 아니라 코드베이스 전체에 흩어져 자리잡는 종류의 작업이었습니다.

좋아요: 팩토리는 두 번째에 만들어진다

좋아요는 overview와 freeboard 둘 다에 붙어야 했습니다. 두 도큐먼트 타입이 거의 똑같이 동작하니까, 라우트도 똑같이 두 벌 짤 수도 있었지만 — 댓글에서 이미 같은 상황을 한 번 겪었던 게 떠올랐습니다.

댓글은 처음 만들 때 register_comment_routes(router, document_type) 라는 팩토리로 짰었습니다. document type만 다르고 나머지는 같으니, 한 번 정의하고 두 라우터에 등록하는 식. 좋아요도 똑같은 모양이니 같은 패턴으로 갔습니다.

# routes/factory/like/factory.py
def register_like_routes(router, document_type):
    @router.put("/{document_id}/like/set", status_code=204)
    async def set_like(...): ...

    @router.put("/{document_id}/like/unset", status_code=204)
    async def unset_like(...): ...

그리고 overview/freeboard 라우터에서 한 줄씩:

register_like_routes(router, DocumentType.OVERVIEW)
register_like_routes(router, DocumentType.FREEBOARD)

팩토리는 두 번째 인스턴스에서 만들어집니다. 첫 번째에선 그냥 짜는 게 맞고 (추상화의 모양을 모르니까), 두 번째에 와서 같은 패턴이 보일 때 추출. 댓글 때 미리 만들었던 걸 좋아요에서 두 번째로 활용하는 거니, 정확히 그 시점이었습니다.

is_liked는 어디에 붙어야 하나

처음엔 “좋아요 한 것 모아보기” 화면을 따로 만들까 했습니다. 인스타그램의 저장 같은 거. 근데 사용자가 “좋아요는 그냥 좋아요만 하자, 모아보기 하지 말고” 라고 잘랐습니다. 그래서 좋아요는 그냥 카운트 + 토글만.

대신 다른 질문이 남았습니다. is_liked (현재 유저가 이 글을 좋아하나) 는 어디에 붙어야 할까?

처음엔 가장 적은 곳에만 붙일까 했지만, 사용자가 “is_liked는 모든 것에 다 붙어야 하는 거 아니야?” 라고 못박아줬습니다. 맞는 말이죠 — 리스트에서 좋아요 표시가 보이고, 들어가서 디테일에서도 보이고, 그 어떤 응답이든 일관되게 보여야 하니까.

그래서 enrichment 패턴으로 통일:

# routes/achiever/overview/enrich.py
async def enrich_overview_response(response, achiever_id):
    achiever_response, _ = await asyncio.gather(
        fetch_achiever_response(response.created_by, ...),
        apply_is_liked_to_response(response, achiever_id, DocumentType.OVERVIEW),
    )
    response.achiever_response = achiever_response
    return response

기존엔 라우트마다 fetch_achiever_response 호출 + 수동 할당이 흩어져 있었는데, 이번 기회에 enrich_overview_response로 모았습니다. is_liked를 추가하면서 자연스럽게 정리된 거죠. (라우트 파일에서 같은 4-5줄짜리 패턴이 6번 반복됐던 게 사라졌습니다.)

asyncio.gather로 묶은 건 두 쿼리를 병렬로 — achiever 정보 fetch와 likes collection 조회를 동시에 돌립니다. 직렬보다 살짝 빠르고, 코드도 더 직관적.

옵티미스틱 토글의 prototype 함정

클라이언트 쪽에서 좋아요 토글은 옵티미스틱으로. 탭하는 순간 UI가 바로 변하고, API는 백그라운드. 실패하면 롤백. 이게 빠르고 자연스럽습니다.

근데 모델 객체를 “isLiked만 토글한 사본”으로 만들려고 하니 클래스 인스턴스 복사 문제에 부딪혔습니다.

// 안 됨 — 그냥 plain object가 되어버려서 prototype 메소드가 사라짐
const next = {...overview, isLiked: !overview.isLiked};

해결:

function withToggledLike<T>(model: T, nextIsLiked: boolean): T {
  const next = Object.assign(Object.create(Object.getPrototypeOf(model)), model);
  next.isLiked = nextIsLiked;
  next.likesCount = Math.max(0, model.likesCount + (nextIsLiked ? 1 : -1));
  return next;
}

Object.create(Object.getPrototypeOf(model)) 로 같은 프로토타입의 새 객체를 만들고, Object.assign 으로 필드 복사. 여기서는 OverviewModel/FreeboardModel이 단순한 클래스라 메소드를 부를 일은 없었지만 — instanceof 체크 같은 게 어딘가에서 깨지면 디버깅 지옥이라서, 안전하게 같은 프로토타입을 유지하는 패턴으로.

프로필: projection은 신뢰하지 말라가 아니라 보일 것만 골라낸다

다음으로 프로필. 닉네임 탭 → 프로필 화면. 표시 항목은 닉네임 + 소개 두 가지뿐.

서버에 가장 단순한 방법은 기존 GET /api/achiever/get 엔드포인트를 확장해서 achiever_id 파라미터를 받는 거였지만, 그건 자기 프로필용이고 풀 페이로드(이메일/전화/생년월일/스트릭 등)를 반환합니다. 다른 사람한테 그걸 모두 노출하면 큰일.

그래서 새 엔드포인트 + projection:

@router.get("/{achiever_id}/profile/get")
async def get_achiever_profile(...) -> AchieverProfileResponseModel:
    achiever_entity = await achiever_collection.find_one(
        {'_id': ObjectId(achiever_id)},
        {'_id': 1, 'nick_name': 1, 'introduction': 1},  # 이 세 필드만
    )
    ...

응답 모델도 별도로 AchieverProfileResponseModel. 필드가 셋뿐이니, 실수로 다른 필드를 추가하기도 어렵게 — 만약 누가 미래에 무심코 e_mail 같은 걸 추가하면 응답 모델 정의에 명시적으로 추가해야 하니, 의도가 코드 리뷰에 드러납니다.

공개 surface는 “넣지 않은 것”으로 정의됩니다. “이거 빼면 안전한가?” 보다는 “이거만 보내면 안전한가?” 로 생각하기. 후자가 화이트리스트, 전자가 블랙리스트. 늘 화이트리스트 쪽이 안전합니다.

모바일과 데스크탑의 다른 길

같은 프로필 뷰지만 모바일/데스크탑은 다르게 풀었습니다.

모바일: inform-achiever 풀 페이지. 닉네임 탭 → 새 라우트로 push.

데스크탑: AchieverProfileModal — 글로벌 모달. App.tsx 최상단에 마운트해두고, viewedAchieverIdAtom 이라는 atom을 set하면 어디서든 뜨게.

// App.tsx
<AchieverProfileModal />  // 항상 마운트, atom으로 트리거

// 어디서든
const setViewedAchieverId = useSetAtom(viewedAchieverIdAtom);
<button onClick={() => setViewedAchieverId(authorId)}>{nickName}</button>

데스크탑에서 모달로 간 건, 데스크탑은 이미 SplitLayout(리스트 + 디테일)을 쓰고 있어서 디테일 영역에 또 다른 디테일을 끼워 넣으면 혼란스럽기 때문. 작은 정보 패널은 모달이 자연스럽습니다.

모바일은 모달도 가능했지만 첫 결정은 풀스크린 모달이었는데 — 여기서 한 번 되돌린 결정이 있습니다.

풀스크린 모달이라니

푸시했더니 사용자가 즉시:

“엄 그런데 풀 스크린 모달로 뜨네? 그냥 navigate로 뜨면 좋겠는데?”

맞습니다. 프로필은 하위 정보(글 → 작성자)지 별도 흐름(예: 글 작성 시작)이 아닙니다. 풀스크린 모달은 별도 흐름에 어울리는 형식이고, 단순 정보 navigation에는 일반 push가 어울립니다.

presentation: "fullScreenModal" 한 줄을 빼고, router.dismiss()router.back() 으로 변경. 30초 작업이지만 의미는 큽니다 — 슬라이드 인 방향이 아래에서 위(modal)에서 오른쪽에서 왼쪽(push)으로 바뀌면서, 사용자에게 “여긴 별개의 일이 아니라 같은 흐름의 하위 정보”라는 신호를 줍니다.

모달 vs 푸시는 시각적 차이가 아니라 의미의 차이입니다. 풀스크린 모달은 “지금 다른 일을 하러 갑니다 → 끝나면 돌아옵니다”, 푸시는 “여기서 한 단계 더 들어갑니다 → 이전으로 거슬러 올라갈 수 있습니다”. 잘못 고르면 사용자의 멘탈 모델이 잠깐 흔들립니다.

정리

이번 작업이 코드베이스에 남긴 것:

  • 좋아요 팩토리 (set/unset PUT, 204) — 새 도큐먼트 타입에 한 줄 등록만으로 좋아요 가능
  • enrichment 패턴enrich_*_response() 가 achiever_response + is_liked를 묶어서 어디서든 일관되게
  • withToggledLike 헬퍼 — prototype 보존하면서 옵티미스틱 사본 생성
  • AchieverProfileResponseModel — projection 화이트리스트로 안전한 공개 surface
  • 모바일 push, 데스크탑 모달 — 같은 정보, 다른 형식

작은 두 기능이지만 패턴 쪽 정리가 많이 따라왔습니다. 좋아요 enrichment를 추가하면서 기존 achiever fetch가 흩어져 있던 걸 한 곳으로 모았고, 팩토리 두 번째 인스턴스에서 패턴이 안정됐고, 화이트리스트 projection으로 공개 surface 정의 방식을 한 번 다시 짚었습니다.

그리고 마지막에 풀스크린 모달을 푸시로 되돌린 건 — 사용자 피드백이 30초 안에 왔을 때 즉시 반영하면 흐름이 안 끊깁니다. 이런 작은 보정이 쌓여서 제품의 _느낌_을 만든다고 생각합니다.