개주 훈련일지/🏋️ 전집중 호흡 훈련

AI 추천 위젯 UX 개선기: 페이지 이동 후에도 '대화 복원'되도록 만들기

lshfood2 2026. 2. 21. 19:17

[ 위젯 개선 이유 ]

이번 작업에서는 우하단 FAB 형태의

AI 추천 위젯 UX를 개선했다.

 

기존에는 같은 사이트 안에서 페이지를 이동하면

위젯 대화가 항상 초기화됐다.


추천을 받고 상세 페이지로 이동한 뒤 다시 위젯을 열면,

이전 추천 맥락이 사라져서 처음부터 다시 입력해야 했다.

 

특히 추천 위젯은 '더 추천', '조건 바꾸기'처럼

이전 대화 상태를 이어가는 흐름이 중요한데,

페이지 이동만으로 끊기는 구조는 UX 손실이 컸다.

 

이번 개선의 핵심은 아래 3가지다.

  • open과 reset의 역할 분리
  • /open 응답에 복원용 데이터 추가
  • 프론트에서 '초기 렌더'와 '복원 렌더' 분기 처리

결과적으로 이제는 페이지 이동 후에도

이전 대화와 마지막 추천 카드가 복원되고,

추천 흐름을 자연스럽게 이어갈 수 있게 됐다.


문제 상황

기존 위젯 동작 흐름은 대략 이랬다.

  1. 위젯 열기
  2. 취향 키워드 입력
  3. 추천 카드 출력
  4. 추천 카드 클릭해서 상세 페이지 이동
  5. 상세 페이지에서 위젯 다시 열기
  6. 이전 대화 맥락 없이 처음 상태로 표시

좌 : 추천 받은 애니 목록 / 우 : 클릭 시 상세페이지 이동되며 대화 초기화

 

사용자 입장에서는 다음과 같은 불편이 발생했다.

  • 추천 카드 탐색 도중 위젯 흐름이 끊김
  • '더 추천'을 계속 쓰기 어려움
  • 추천 위젯이 세션 기반처럼 보이지 않고
    일회성 UI처럼 느껴짐

원인 정리

문제는 단순히 프론트만의 문제가 아니었다.

초기 구조에서는 open 호출 시 사실상

'새 대화 시작'처럼 동작하는 흐름이 강했고,

프론트도 /open 응답을 받아도

항상 초기 UI처럼 렌더링했다.

 

즉, 서버 세션에 상태가 남아 있더라도

프론트가 복원해주지 않으니 화면에서는

매번 초기화처럼 보이는 구조였다.

 

정리하면 원인은 두 가지였다.

  • 백엔드
    : open이 복원 중심이 아니라
    초기 진입 중심으로 사용됨
  • 프론트
    : /open 성공 시 무조건 초기 렌더 수행

개선 목표

이번 개선의 목표는 명확했다.

  • open은 현재 세션 상태를 조회해서
    프론트가 복원할 수 있게 응답
  • reset만 진짜 초기화 담당
  • 프론트는 /open 응답에 따라
    '초기 진입'과 '복원'을 분기 렌더

이렇게 역할을 분리하면 위젯을

다시 열 때마다 초기화되는 구조를 없앨 수 있다.


백엔드 개선 1: open과 reset 역할 분리

컨트롤러 엔드포인트는 그대로 유지하되,

서비스 동작 의미를 분리했다.

 

1) GET /api/ai-chat/open

  • 세션이 없으면 기본 상태 생성
  • 세션이 있으면 기존 상태 유지
  • 복원에 필요한 데이터 반환

2) POST /api/ai-chat/reset

  • 대화/추천 관련 세션 상태 전체 초기화
  • 초기 상태 응답 반환

컨트롤러 예시

// 핵심 포인트
// - open: 현재 세션 상태를 '열어주는' 역할
// - reset: 실제 초기화 역할
@Controller
@RequestMapping("/api/ai-chat")
public class AiChatController {

    @Autowired
    private AiChatService aiChatService;

    @ResponseBody
    @GetMapping("/open")
    public AiChatOpenResponse open(HttpSession session) {
        if (session == null) {
            throw new ApiException(HttpStatus.UNAUTHORIZED, "NO_SESSION", "세션이 없습니다.");
        }
        return aiChatService.open(session);
    }

    @ResponseBody
    @PostMapping("/reset")
    public AiChatOpenResponse reset(HttpSession session) {
        return aiChatService.reset(session);
    }
}

이 분리가 중요한 이유는,

위젯을 다시 여는 행위를 더 이상

'초기화 신호' 로 취급하지 않기 위해서다.


백엔드 개선 2: /open 응답에 복원용 데이터 추가

프론트가 화면을 복원하려면

단순 환영 메시지만으로는 부족하다.

 

그래서 AiChatOpenResponse에

복원용 필드를 추가했다.

 

응답 DTO에 추가한 핵심 필드

  • resumed
  • chatHistory
  • moreCount
  • lastRecommendedAnimes

DTO 예시

public class AiChatOpenResponse implements Serializable {
    private static final long serialVersionUID = 1L;

    private String welcomeMessage;
    private String initialPrompt;

    // 복원 여부
    private boolean resumed;

    // 텍스트 대화 이력 복원용
    private List<ChatMessage> chatHistory;

    // 추가 추천 횟수(필요 시 UI/정책 반영 가능)
    private int moreCount;

    // 마지막 추천 카드 복원용
    private List<RecommendedAnimeDTO> lastRecommendedAnimes;

    // getter/setter 생략
}

여기서 가장 중요한 필드는

lastRecommendedAnimes다.

 

chatHistory만 있으면

말풍선 텍스트는 복원할 수 있지만,

추천 카드 UI를 다시 그릴 수 없다.


그래서 마지막 추천 결과 자체를 세션에 저장하고

/open에서 함께 내려주도록 설계했다.


백엔드 개선 3: 세션 상태 저장 범위 보강

세션 서비스에서 관리하는 상태에

마지막 추천 리스트를 추가했다.

 

세션에 관리한 상태

  • chatHistory
  • excludeAnimeIds
  • moreRecommendCount
  • lastQuerySpec
  • lastRecommendedAnimes

세션 서비스 핵심 예시

@Service
public class AiChatSessionService {

    public static final String S_CHAT_HISTORY = "ai.chatHistory";
    public static final String S_EXCLUDE_IDS  = "ai.excludeAnimeIds";
    public static final String S_MORE_COUNT   = "ai.moreRecommendCount";
    public static final String S_LAST_SPEC    = "ai.lastQuerySpec";

    // 마지막 추천 카드 복원용 상태
    public static final String S_LAST_RECOMMENDS = "ai.lastRecommendedAnimes";

    public void initIfAbsent(HttpSession session) {
        requireSession(session);

        if (!(session.getAttribute(S_CHAT_HISTORY) instanceof List)) {
            session.setAttribute(S_CHAT_HISTORY, new ArrayList<ChatMessage>());
        }
        if (!(session.getAttribute(S_EXCLUDE_IDS) instanceof Set)) {
            session.setAttribute(S_EXCLUDE_IDS, new HashSet<Integer>());
        }
        if (!(session.getAttribute(S_MORE_COUNT) instanceof Integer)) {
            session.setAttribute(S_MORE_COUNT, Integer.valueOf(0));
        }

        // 마지막 추천 리스트 초기화
        if (session.getAttribute(S_LAST_RECOMMENDS) == null) {
            session.setAttribute(S_LAST_RECOMMENDS, new ArrayList<RecommendedAnimeDTO>());
        }
    }

    @SuppressWarnings("unchecked")
    public List<RecommendedAnimeDTO> getLastRecommendedAnimes(HttpSession session) {
        Object v = session.getAttribute(S_LAST_RECOMMENDS);

        if (v == null) {
            List<RecommendedAnimeDTO> list = new ArrayList<>();
            session.setAttribute(S_LAST_RECOMMENDS, list);
            return list;
        }
        return (List<RecommendedAnimeDTO>) v;
    }

    public void setLastRecommendedAnimes(HttpSession session, List<RecommendedAnimeDTO> list) {
        if (list == null) {
            session.setAttribute(S_LAST_RECOMMENDS, new ArrayList<RecommendedAnimeDTO>());
        } else {
            // 복사본 저장으로 외부 참조 영향 최소화
            session.setAttribute(S_LAST_RECOMMENDS, new ArrayList<>(list));
        }
    }

    public void resetAll(HttpSession session) {
        requireSession(session);

        session.setAttribute(S_CHAT_HISTORY, new ArrayList<ChatMessage>());
        session.setAttribute(S_EXCLUDE_IDS, new HashSet<Integer>());
        session.setAttribute(S_MORE_COUNT, Integer.valueOf(0));
        session.removeAttribute(S_LAST_SPEC);

        // reset 시 마지막 추천 리스트도 함께 초기화
        session.setAttribute(S_LAST_RECOMMENDS, new ArrayList<RecommendedAnimeDTO>());
    }

    private void requireSession(HttpSession session) {
        if (session == null) {
            throw new IllegalArgumentException("HttpSession must not be null");
        }
    }
}

백엔드 개선 4: open() 응답 생성 로직을 복원 중심으로 변경

open()은 이제 세션이 있으면 상태를 유지하고,

프론트 복원에 필요한 데이터를 응답으로 내려준다.

 

서비스 open() 핵심 흐름 예시

public AiChatOpenResponse open(HttpSession session) {
    if (session == null) {
        throw new ApiException(HttpStatus.UNAUTHORIZED, "NO_SESSION", "세션이 없습니다.");
    }

    // 없으면 생성, 있으면 유지
    sessionService.initIfAbsent(session);

    List<ChatMessage> history = sessionService.getChatHistory(session);
    int moreCount = sessionService.getMoreCount(session);
    List<RecommendedAnimeDTO> lastRecs = sessionService.getLastRecommendedAnimes(session);

    return buildOpenResponse(history, moreCount, lastRecs);
}

private AiChatOpenResponse buildOpenResponse(
        List<ChatMessage> history,
        int moreCount,
        List<RecommendedAnimeDTO> lastRecommendedAnimes) {

    AiChatOpenResponse res = new AiChatOpenResponse();
    res.setWelcomeMessage("안녕! 취향 기반으로 애니 3개 추천해줄게~");
    res.setInitialPrompt("원하는 장르/분위기/키워드를 말해줘! 예: 판타지+성장+모험");

    boolean resumed = history != null && !history.isEmpty();
    res.setResumed(resumed);

    // 프론트가 안전하게 복원할 수 있도록 복사본 응답
    res.setChatHistory(history == null ? new ArrayList<>() : new ArrayList<>(history));
    res.setMoreCount(moreCount);
    res.setLastRecommendedAnimes(
        lastRecommendedAnimes == null ? new ArrayList<>() : new ArrayList<>(lastRecommendedAnimes)
    );

    return res;
}

프론트 개선 1: /open 성공 시 무조건 초기 렌더하던 구조 변경

기존 프론트에서는 /open 성공 시

항상 이런 흐름이 실행됐다.

  • 빠른 추천 칩 렌더
  • 추천 상태 초기화
  • 액션 버튼 숨김
  • 환영 메시지 출력

이 구조에서는

서버가 resumed: true를 내려줘도

화면이 새 대화처럼 보인다.

 

그래서 /open 응답을 기반으로

아래처럼 분기하도록 변경했다.

  • 복원 가능한 데이터가 있으면 복원 렌더
  • 없으면 초기 진입 렌더

프론트 개선 2: chatHistory 말풍선 복원 + lastRecommendedAnimes 추천 카드 복원

복원 렌더는 두 단계로 나눴다.

  1. chatHistory를 말풍선으로 복원
  2. lastRecommendedAnimes를
    기존 추천 카드 렌더러로 복원

이 방식의 장점은 기존 렌더 함수를 
재사용할 수 있다는 점이다.
(= appendRecommendations)


복원 전용 추천 카드 렌더 함수를

따로 만들지 않아도 돼서 유지보수가 편해졌다.

 

프론트 복원 유틸 예시

// 안전한 배열 변환
// - null/undefined/배열 아닌 값이 와도 빈 배열로 처리
function toArray(v) {
  return Array.isArray(v) ? v : [];
}

// 백엔드 role('assistant')를 프론트 말풍선 타입('bot')으로 정규화
function normalizeHistoryRole(role) {
  var r = String(role || '').toLowerCase();

  if (r === 'user') return 'user';
  if (r === 'assistant') return 'bot';
  if (r === 'bot') return 'bot';
  if (r === 'system') return 'bot';

  // 알 수 없는 role은 bot으로 폴백
  return 'bot';
}

// chatHistory 말풍선 복원
function restoreChatHistoryBubbles(historyList) {
  var list = toArray(historyList);

  for (var i = 0; i < list.length; i++) {
    var item = list[i] || {};

    // content 누락/빈 값 방어
    var text = (item.content == null) ? '' : String(item.content);
    text = text.replace(/\r\n/g, '\n').trim();
    if (!text) continue;

    appendTextBubble(normalizeHistoryRole(item.role), text);
  }
}

프론트 개선 3: /open 응답 기반 복원 분기 렌더 함수 추가

restoreOpenState(data)를 만들어서

/open 응답을 한 곳에서 해석하도록 정리했다.

 

핵심 정책은 아래와 같다.

  • 먼저 메시지 영역을 정리하고 quick chips 다시 렌더
  • chatHistory와 lastRecommendedAnimes를 안전하게 읽기
  • 복원 가능하면 복원 렌더
  • 아니면 환영 메시지 기반 초기 렌더

/open 응답 복원 분기 예시

function restoreOpenState(data) {
  // 중복 append 방지: 기존 메시지 영역 정리 후 quick chips 재렌더
  clearMessagesKeepQuick();
  renderQuickChips();

  // 추천 관련 상태 초기화
  // - 복원 중 추천 카드가 있으면 appendRecommendations에서 다시 true가 된다.
  hasRecs = false;
  hideActions();

  // 서버가 내려준 placeholder 반영
  if (data && data.initialPrompt && input) {
    input.placeholder = data.initialPrompt;
  }

  // 복원 데이터 안전 파싱
  var historyList = toArray(data && data.chatHistory);
  var lastRecs = toArray(data && data.lastRecommendedAnimes);

  // resumed 값 + 실제 데이터 존재 여부를 함께 판단
  // - 방어적으로 처리해서 필드 누락/오류 상황에도 최대한 복원 시도
  var resumed = !!(data && data.resumed);
  var hasRestorableData = resumed || historyList.length > 0 || lastRecs.length > 0;

  if (hasRestorableData) {
    // 1) 텍스트 히스토리 복원
    restoreChatHistoryBubbles(historyList);

    // 2) 마지막 추천 카드 복원
    //    appendRecommendations 내부에서:
    //    - hasRecs = true
    //    - showActions()
    //    처리됨
    if (lastRecs.length > 0) {
      appendRecommendations(lastRecs);
    } else {
      // 추천 카드가 없으면 액션바는 숨김 유지
      hasRecs = false;
      hideActions();
    }

    // 3) resumed=true인데 실제 데이터가 비어있는 예외 폴백
    if (historyList.length === 0 && lastRecs.length === 0) {
      appendTextBubble('bot', (data && data.welcomeMessage)
        ? data.welcomeMessage
        : '안녕하세요. 무엇을 도와드릴까요?');
    }

    return;
  }

  // 초기 진입 분기
  appendTextBubble('bot', (data && data.welcomeMessage)
    ? data.welcomeMessage
    : '안녕하세요. 무엇을 도와드릴까요?');
}

프론트 개선 4: callOpen()에서 복원 분기 렌더 호출

기존 callOpen()은 성공 시 항상 초기 UI를 그렸다.
개선 후에는 성공 시 restoreOpenState(data)를 호출해서

응답 기반으로 화면을 구성하도록 바꿨다.

 

callOpen() 핵심 변경 예시

function callOpen() {
  if (busy) return;

  if (!base) {
    appendTextBubble('bot', 'endpoint 설정이 없어서 실행할 수 없어요. data-endpoint를 확인해주세요.');
    openedOnce = false;
    return;
  }

  setBusy(true);

  fetch(base + '/open', {
    method: 'GET',
    credentials: 'same-origin'
  })
    .then(function (res) {
      return safeJson(res).then(function (data) {
        setBusy(false);

        if (!res.ok) {
          appendTextBubble('bot', pickServerMessage(data, '초기화/복원에 실패했어요. 잠시 후 다시 시도해주세요.'));
          openedOnce = false;
          return;
        }

        // 핵심 변경
        // - 이전에는 여기서 환영 메시지 + 초기 UI를 무조건 렌더
        // - 이제는 서버 응답 기반으로 초기/복원 분기 렌더
        restoreOpenState(data);
      });
    })
    .catch(function () {
      setBusy(false);
      appendTextBubble('bot', '서버에 연결할 수 없어요. 네트워크 상태를 확인해주세요.');
      openedOnce = false;
    });
}

welcomeMessage 출력 정책도 함께 조정한 이유

복원 상태에서도 환영 메시지를

무조건 출력하면 UX가 어색해진다.

 

이미 추천을 받고 상세 페이지로 이동한 뒤

다시 위젯을 열었는데 첫 인사 문구가 찍히면

이어지는 대화임에도 새 대화처럼 느껴지기 때문이다.

 

그래서 이번 개선에서는 아래 정책으로 정리했다.

  • 초기 진입일 때만 환영 메시지 중심 렌더
  • 복원 상태일 때는 복원 렌더 중심

작은 차이 같지만 위젯 사용 감각이 훨씬 자연스러워졌다.


트러블슈팅: 세션 문제처럼 보였지만 실제 원인은 캐시/반영 이슈

구현 후 첫 테스트에서는

여전히 위젯이 초기화되는 것처럼 보였다.

 

처음에는 세션 복원 실패를 의심했지만,

네트워크 탭에서 GET /api/ai-chat/open 응답을 확인해보니

복원 데이터는 정상적으로 내려오고 있었다.

 

실제 확인한 응답 포인트는 다음과 같았다.

  1. resumed: true
  2. chatHistory 존재
  3. lastRecommendedAnimes 3건 존재

즉, 백엔드는 정상인데 화면만 초기화처럼 보였고,

원인은 수정한 chatai.js가 브라우저에

바로 반영되지 않은 캐시/빌드 반영 문제였다.

 

결국 아래 순서로 해결했다.

  • 프로젝트 클린
  • 브라우저 새로고침
  • 수정 JS 반영 확인 후 재테스트

이 경험 이후로는 기능 구현 자체와 함께

아래 확인 절차도 습관처럼 가져가고 있다.

 

이번 작업에서 유효했던 확인 절차

  • Network 탭에서 /open 응답 JSON 확인
  • 실제 로드된 chatai.js 파일 내용 확인
  • 캐시 비우고 강력 새로고침
  • 서버 로그에서 세션 ID / 저장 로그 확인

테스트 시나리오

이번 개선은 아래 시나리오로 검증했다.

  1. 위젯 열기
  2. 취향 키워드 입력 후 추천 받기
  3. 추천 카드 클릭해서 상세 페이지 이동
  4. 상세 페이지에서 위젯 다시 열기
  5. 이전 대화 텍스트 + 마지막 추천 카드 복원 확인
  6. 더 추천 동작 확인
  7. 새 대화 클릭 후 초기화 확인
  8. 다시 페이지 이동 후 위젯 열기 시 초기 상태 유지 확인

이 시나리오가 통과되면 open/reset 역할 분리와

프론트 복원 분기 처리가 정상적으로 연결된 것이다.


마무리

이번 작업은 위젯 오류를 고친 게 아니라,

AI 추천 위젯의 상태 관리 UX를

한 단계 개선한 작업이었다.

개선 완료 후 페이지 이동해도 챗봇 대화가 유지되는 모습

 

핵심은 아래 3가지였다.

  • open과 reset 역할 분리
  • /open 응답에 복원용 데이터 추가
  • 프론트에서 초기/복원 분기 렌더 처리

결과적으로 페이지 이동 이후에도

추천 대화를 이어갈 수 있게 되었고,

추천 카드 탐색 흐름이 자연스러워졌다.

위젯을 '열기'만 했는데 대화가 초기화되던 구조를,
세션 상태를 그대로 '열어주는' 구조로 바꾸면서
UX가 크게 좋아졌다.

 

AI 기능은 추천 정확도도 중요하지만,

사용자가 흐름을 끊기지 않고 사용할 수 있도록

상태를 유지해주는 경험 설계도 그만큼

중요하다는 점을 다시 확인한 작업이었다.