TIL) 12/01 오래도 걸렸다

100·2025년 12월 1일

TIL

목록 보기
9/11

11/28~주말 조금~12/01 진행 내용

진행 상황

1. 상태 관리 구조 개선: Sealed Class → Freezed 전환

상태 관리를 기존의 수동 sealed class 방식에서 Freezed 기반 구조로 정리함.
특히 화면 성격별로 플래그 기반 단일 State유니온 타입 State를 구분하여 적용함.

MainScreen: 플래그 기반 단일 State로 정리

메인 화면은 동시에 여러 데이터를 들고 있고, 부분 업데이트가 잦음.
따라서 여러 클래스로 나누기보다 하나의 State 안에 플래그로 묶는 방식이 적합하다고 판단함.


class MainScreenState with _$MainScreenState {
  const factory MainScreenState({
    (UserRegistrationData()) UserRegistrationData userData,
    (<Pet>[]) List<Pet> pets,
    (false) bool isLoading,
    String? error,
  }) = _MainScreenState;
}

이 구조는 다음과 같은 장점을 가짐:

  • copyWith()로 필요한 부분만 갱신하기 쉬움
  • UI는 if (state.isLoading) 같은 단순 조건으로 분기함
  • 등록 정보 + 펫 정보처럼 서로 다른 리소스를 동시에 관리하는 화면에 적합함

UserRegistration / PetRegistration: 유니온 타입 유지

등록 플로우는 단계가 명확한 구조라 유니온 타입이 더 자연스러움.
각 단계가 “명시적으로 표현되는” 것이 중요하다고 판단해 Freezed의 union type을 그대로 유지함.


class PetRegistrationState with _$PetRegistrationState {
  const factory PetRegistrationState.initial(Pet pet) = PetRegistrationInitial;
  const factory PetRegistrationState.loaded(Pet pet) = PetRegistrationLoaded;
  const factory PetRegistrationState.loading() = PetRegistrationLoading;
  const factory PetRegistrationState.success(Pet pet) = PetRegistrationSuccess;
  const factory PetRegistrationState.failure(String message) = PetRegistrationFailure;
}

유니온 타입의 장점은 다음과 같음:

  • when, map으로 모든 상태를 반드시 처리하게 되어 실수 방지
  • 각 상태별로 필요한 데이터만 들고 있게 설계 가능함
  • 단계가 명확한 “등록/입력 흐름”에 최적화되어 있음

정리:

  • 메인 화면 → 플래그 기반 단일 State
  • 등록 플로우 → Freezed 유니온 타입
    두 방식을 상황에 맞게 병행함.

2. ReactiveX 패턴 적용: BehaviorSubject + switchMap

변경 사항 자동 반영, 요청 버전 관리, 타이밍 문제 해결을 위해 Repository 레이어에 ReactiveX 패턴을 도입함.

BehaviorSubject를 Repository에 추가함

각 Repository에서 BehaviorSubject<String?>를 사용해 “어떤 유저의 데이터가 변경됐는지”를 스트림 형태로 발행함.

final _userUpdateSubject = BehaviorSubject<String?>.seeded(null);

Stream<String?> get userUpdates => _userUpdateSubject.stream;

Future<void> registerUser(String userId, UserRegistrationData data) async {
  await dataSource.registerUser(userId, data);
  _userUpdateSubject.add(userId);
}

BehaviorSubject의 핵심 특징은 다음과 같음:

  • “마지막 emit 값”을 기억함
  • 새로운 구독자가 붙으면 그 값을 즉시 emit함
  • Cubit이 늦게 구독해도 최신 데이터를 놓치지 않음

MainScreenCubit에서 switchMap으로 요청 버전 관리함

MainScreenCubit은 userUpdates 스트림을 구독하며,
switchMap을 이용해 이전 API 요청을 취소하고 마지막 요청만 처리하는 방식을 적용함.

_userUpdateSubscription = userRegistrationRepository.userUpdates
    .where((userId) => userId != null && _currentUserId == userId)
    .switchMap(
      (userId) => Stream.fromFuture(
        userRegistrationRepository.getUserDataByUserId(userId!),
      ),
    )
    .listen(
      (userData) {
        emit(state.copyWith(
          userData: userData ?? state.userData,
          error: null,
        ));
      },
    );

이 방식의 장점은 다음과 같음:

  • 연속으로 빠르게 업데이트가 들어와도 마지막 이벤트만 유효
  • 오래 걸린 응답이 늦게 도착해 UI를 덮어쓰는 문제 방지
  • BehaviorSubject와 조합하면 “초기 로딩 + 자동 새로고침”이 자연스럽게 연결됨

요약하면,
BehaviorSubject는 최신 이벤트 보장, switchMap은 요청 버전 관리
이 두 가지가 결합해 타이밍 이슈 전반이 해결됨.


3. FastAPI 로컬 서버 구축

FastAPI로 직접 로컬 서버를 구축해 실제 API 구조와 더 유사한 환경을 구성함.

인메모리 DB 구조 설계

  • Python 딕셔너리로 users, pets 저장
  • user_id_counter, pet_id_counter로 ID 자동 증가
  • login ID와 내부 시스템 ID를 별도로 관리

이 구조로 Flutter 측의 loginUserId와 서버 내부 primary key를 명확히 분리할 수 있었음.

환경 구성

FastAPI에서 CORS 설정 후 Flutter에서 바로 호출하도록 구성했고,
Flutter 앱에서는 환경 변수를 다음과 같이 주입하는 방식으로 관리함.

flutter run --dart-define=API_BASE_URL=http://192.168.0.2:3000

환경별 서버 주소를 유연하게 바꿀 수 있는 구조로 개선함.


4. Retrofit + Dio로 Flutter API 클라이언트 정리

FastAPI와 통신하는 Flutter 클라이언트를 Retrofit + Dio 기반으로 재구성함.

  • @RestApi(), @GET, @POST로 인터페이스만 작성
  • JSON 직렬화와 실제 HTTP 요청은 자동 생성됨
  • build_runner*.g.dart, *.freezed.dart 파일 생성

일관된 API 모델과 상태 모델을 유지할 수 있고, 404 응답을 에러가 아닌 “데이터 없음”으로 처리하는 식의 흐름 제어가 용이함.

if (e is DioException && e.response?.statusCode == 404) {
  return null;
}

등록 정보가 없을 때는 비정상 상황이 아니므로 이렇게 처리함.


5. 로그인 및 초기 로딩 처리 개선

listenWhen으로 불필요한 listener 호출 차단

listenWhen: (previous, current) {
  return previous.user?.id != current.user?.id;
},
  • user.id가 실제로 바뀔 때만 listener가 실행되도록 구성함
  • 로딩 플래그 변화나 사소한 state 변경에는 반응하지 않도록 최적화함

초기 진입 시 데이터가 비어 보이는 문제 해결

BlocListener는 첫 빌드 때 동작하지 않기 때문에,
필요한 경우 addPostFrameCallback으로 초기 loadData를 강제 실행하도록 구성함.

if (!state.isLoading &&
    state.userData.nickname == null &&
    state.pets.isEmpty &&
    loginState.loginUserId != null) {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    context.read<MainScreenCubit>().loadData(loginState.loginUserId!);
  });
}

BehaviorSubject의 캐싱과 결합되면서
“초기 진입 / 로그인 변경 / 등록 완료 후 복귀 등 모든 타이밍에서 최신 상태 유지”가 가능해짐.


여기서부터 팀장님 피드백

진행 예정

1. Repository–DataSource 추상화

현재 DataSource가 단일 구현체라 Repository가 구체 클래스에 직접 결합된 구조.
테스트용 목업과 실제 API를 동일한 흐름으로 교체하려면 DataSource를 인터페이스로 추상화하는 구조가 필요.

개선 방향 요약

  • abstract UserRegistrationDataSource 정의
  • Mock / API 구현체 분리
  • Repository는 인터페이스만 의존
  • 저장 방식 변경 시에도 앱 흐름 유지

2. Freezed State를 Flag 기반으로 통일

MainScreen은 flag 기반 단일 state인데,
UserRegistration / PetRegistration은 union type 구성.

등록 플로우는 단계가 많지 않고 전이가 단순하므로,
MainScreen과 동일하게 단일 state + flag 기반이 더 적합한 구조.

예시 형태


class PetRegistrationState with _$PetRegistrationState {
  const factory PetRegistrationState({
    (Pet.empty()) Pet pet,
    (false) bool isLoading,
    String? error,
  }) = _PetRegistrationState;
}

핵심 개념
Freezed는 상태 변경 지점을 제한하는 목적.
state는 불변이며, 모든 변경은 copyWith 기반으로만 생성.
상태 추적, 디버깅, 변경 히스토리 파악이 쉬운 구조.


3. 모델(UserRegistrationData / Pet)을 Freezed로 전환

현재 모델은

  • 직접 작성한 fromJson / toJson
  • Equatable 기반 비교
    형태로 구성.

필드 추가·변경 시 JSON 변환 코드도 매번 직접 수정해야 하는 구조라 유지보수 비용 증가.

개선 방향
모델을 Freezed + json_serializable 기반으로 재구성.

기대 효과 요약

  • JSON 직렬화 자동화
  • copyWith, equality, 불변성 자동 생성
  • 타입 구조 일관성 확보
  • 코드량 감소

이번 리팩까지 하고나면 플러터 학습 마무리하고 앱 실제 구조 살펴볼 예정

서비스 리뉴얼 참여(약 1주)해서 프레임워크 안쓰고 순수 웹 3대장으로 UI 고치고 배포하는 과정에서 병목 등의 문제 해결..?

profile
멋있는 사람이 되는 게 꿈입니다

0개의 댓글